├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.ja.md ├── README.md ├── build-and-push.sh ├── client ├── .browserslistrc ├── .dockerignore ├── .editorconfig ├── .eslintrc.json ├── .gitignore ├── .htaccess-stm-test ├── .vimrc ├── Dockerfile ├── README.ja.md ├── README.md ├── angular.json ├── jest.config.js ├── jest │ ├── setup-jest.ts │ └── worker-mock.ts ├── nginx-test.conf ├── nginx.conf ├── package-lock.json ├── package.json ├── src │ ├── app │ │ ├── app-routing.module.ts │ │ ├── app.component.html │ │ ├── app.component.scss │ │ ├── app.component.spec.ts │ │ ├── app.component.ts │ │ ├── app.module.ts │ │ ├── auth │ │ │ ├── auth.guard.spec.ts │ │ │ ├── auth.guard.ts │ │ │ ├── auth.service.spec.ts │ │ │ ├── auth.service.ts │ │ │ ├── logged-in.interceptor.spec.ts │ │ │ ├── logged-in.interceptor.ts │ │ │ ├── login │ │ │ │ ├── login.component.html │ │ │ │ ├── login.component.scss │ │ │ │ ├── login.component.spec.ts │ │ │ │ └── login.component.ts │ │ │ └── oauth-landing │ │ │ │ ├── oauth-landing.component.html │ │ │ │ ├── oauth-landing.component.scss │ │ │ │ ├── oauth-landing.component.spec.ts │ │ │ │ └── oauth-landing.component.ts │ │ ├── comments │ │ │ ├── comment.material.ts │ │ │ ├── comment.service.spec.ts │ │ │ ├── comment.service.ts │ │ │ └── comment │ │ │ │ ├── comment.component.html │ │ │ │ ├── comment.component.scss │ │ │ │ ├── comment.component.spec.ts │ │ │ │ └── comment.component.ts │ │ ├── common │ │ │ ├── components │ │ │ │ └── map │ │ │ │ │ ├── map.component.html │ │ │ │ │ ├── map.component.scss │ │ │ │ │ ├── map.component.spec.ts │ │ │ │ │ └── map.component.ts │ │ │ ├── entities │ │ │ │ ├── josm-data-source.ts │ │ │ │ ├── language.ts │ │ │ │ └── websocket-message.ts │ │ │ ├── mock-router.ts │ │ │ ├── selected-language.guard.ts │ │ │ ├── services │ │ │ │ ├── geometry.service.spec.ts │ │ │ │ ├── geometry.service.ts │ │ │ │ ├── language.service.spec.ts │ │ │ │ ├── language.service.ts │ │ │ │ ├── loading.service.spec.ts │ │ │ │ ├── loading.service.ts │ │ │ │ ├── map-layer.service.spec.ts │ │ │ │ ├── map-layer.service.ts │ │ │ │ ├── notification.service.spec.ts │ │ │ │ ├── notification.service.ts │ │ │ │ ├── process-point-color.service.spec.ts │ │ │ │ ├── process-point-color.service.ts │ │ │ │ ├── shortcut.service.spec.ts │ │ │ │ ├── shortcut.service.ts │ │ │ │ ├── websocket-client.service.spec.ts │ │ │ │ └── websocket-client.service.ts │ │ │ └── unsubscriber.ts │ │ ├── config │ │ │ ├── config.provider.spec.ts │ │ │ ├── config.provider.ts │ │ │ ├── config.resolver.spec.ts │ │ │ ├── config.resolver.ts │ │ │ └── config.ts │ │ ├── dashboard │ │ │ ├── dashboard.component.html │ │ │ ├── dashboard.component.scss │ │ │ ├── dashboard.component.spec.ts │ │ │ └── dashboard.component.ts │ │ ├── error-handler.ts │ │ ├── project-creation │ │ │ ├── copy-project │ │ │ │ ├── copy-project.component.html │ │ │ │ ├── copy-project.component.scss │ │ │ │ ├── copy-project.component.spec.ts │ │ │ │ └── copy-project.component.ts │ │ │ ├── drawing-toolbar │ │ │ │ ├── drawing-toolbar.component.html │ │ │ │ ├── drawing-toolbar.component.scss │ │ │ │ ├── drawing-toolbar.component.spec.ts │ │ │ │ └── drawing-toolbar.component.ts │ │ │ ├── project-creation │ │ │ │ ├── project-creation.component.html │ │ │ │ ├── project-creation.component.scss │ │ │ │ ├── project-creation.component.spec.ts │ │ │ │ └── project-creation.component.ts │ │ │ ├── project-import.service.spec.ts │ │ │ ├── project-import.service.ts │ │ │ ├── project-import │ │ │ │ ├── project-import.component.html │ │ │ │ ├── project-import.component.scss │ │ │ │ ├── project-import.component.spec.ts │ │ │ │ └── project-import.component.ts │ │ │ ├── project-properties.ts │ │ │ ├── project-properties │ │ │ │ ├── project-properties.component.html │ │ │ │ ├── project-properties.component.scss │ │ │ │ ├── project-properties.component.spec.ts │ │ │ │ └── project-properties.component.ts │ │ │ ├── shape-divide │ │ │ │ ├── shape-divide.component.html │ │ │ │ ├── shape-divide.component.scss │ │ │ │ ├── shape-divide.component.spec.ts │ │ │ │ └── shape-divide.component.ts │ │ │ ├── shape-remote │ │ │ │ ├── shape-remote.component.html │ │ │ │ ├── shape-remote.component.scss │ │ │ │ ├── shape-remote.component.spec.ts │ │ │ │ └── shape-remote.component.ts │ │ │ ├── shape-upload │ │ │ │ ├── shape-upload.component.html │ │ │ │ ├── shape-upload.component.scss │ │ │ │ ├── shape-upload.component.spec.ts │ │ │ │ └── shape-upload.component.ts │ │ │ ├── task-draft-list │ │ │ │ ├── task-draft-list.component.html │ │ │ │ ├── task-draft-list.component.scss │ │ │ │ ├── task-draft-list.component.spec.ts │ │ │ │ └── task-draft-list.component.ts │ │ │ ├── task-draft.service.spec.ts │ │ │ ├── task-draft.service.ts │ │ │ └── task-edit │ │ │ │ ├── task-edit.component.html │ │ │ │ ├── task-edit.component.scss │ │ │ │ ├── task-edit.component.spec.ts │ │ │ │ └── task-edit.component.ts │ │ ├── project │ │ │ ├── all-projects.resolver.ts │ │ │ ├── project-list │ │ │ │ ├── project-list.component.html │ │ │ │ ├── project-list.component.scss │ │ │ │ ├── project-list.component.spec.ts │ │ │ │ └── project-list.component.ts │ │ │ ├── project-settings │ │ │ │ ├── project-settings.component.html │ │ │ │ ├── project-settings.component.scss │ │ │ │ ├── project-settings.component.spec.ts │ │ │ │ └── project-settings.component.ts │ │ │ ├── project.material.ts │ │ │ ├── project.resolver.ts │ │ │ ├── project.service.spec.ts │ │ │ ├── project.service.ts │ │ │ └── project │ │ │ │ ├── project.component.html │ │ │ │ ├── project.component.scss │ │ │ │ ├── project.component.spec.ts │ │ │ │ └── project.component.ts │ │ ├── task │ │ │ ├── task-details │ │ │ │ ├── task-details.component.html │ │ │ │ ├── task-details.component.scss │ │ │ │ ├── task-details.component.spec.ts │ │ │ │ └── task-details.component.ts │ │ │ ├── task-list │ │ │ │ ├── task-list.component.html │ │ │ │ ├── task-list.component.scss │ │ │ │ ├── task-list.component.spec.ts │ │ │ │ └── task-list.component.ts │ │ │ ├── task-map │ │ │ │ ├── task-map.component.html │ │ │ │ ├── task-map.component.scss │ │ │ │ ├── task-map.component.spec.ts │ │ │ │ └── task-map.component.ts │ │ │ ├── task-title.pipe.spec.ts │ │ │ ├── task-title.pipe.ts │ │ │ ├── task.material.spec.ts │ │ │ ├── task.material.ts │ │ │ ├── task.resolver.ts │ │ │ ├── task.service.spec.ts │ │ │ └── task.service.ts │ │ ├── ui │ │ │ ├── footer │ │ │ │ ├── footer.component.html │ │ │ │ ├── footer.component.scss │ │ │ │ └── footer.component.ts │ │ │ ├── icon-button │ │ │ │ ├── icon-button.component.html │ │ │ │ ├── icon-button.component.scss │ │ │ │ └── icon-button.component.ts │ │ │ ├── language-selection │ │ │ │ ├── language-selection.component.html │ │ │ │ ├── language-selection.component.scss │ │ │ │ ├── language-selection.component.spec.ts │ │ │ │ └── language-selection.component.ts │ │ │ ├── max-validator.directive.spec.ts │ │ │ ├── max-validator.directive.ts │ │ │ ├── min-validator.directive.spec.ts │ │ │ ├── min-validator.directive.ts │ │ │ ├── notification │ │ │ │ ├── notification.component.html │ │ │ │ ├── notification.component.scss │ │ │ │ ├── notification.component.spec.ts │ │ │ │ └── notification.component.ts │ │ │ ├── progress-bar │ │ │ │ ├── progress-bar.component.html │ │ │ │ ├── progress-bar.component.scss │ │ │ │ ├── progress-bar.component.spec.ts │ │ │ │ └── progress-bar.component.ts │ │ │ ├── tabs │ │ │ │ ├── tabs.component.html │ │ │ │ ├── tabs.component.scss │ │ │ │ ├── tabs.component.spec.ts │ │ │ │ └── tabs.component.ts │ │ │ ├── toolbar │ │ │ │ ├── toolbar.component.html │ │ │ │ ├── toolbar.component.scss │ │ │ │ ├── toolbar.component.spec.ts │ │ │ │ └── toolbar.component.ts │ │ │ └── zoom-control │ │ │ │ ├── zoom-control.component.html │ │ │ │ ├── zoom-control.component.scss │ │ │ │ ├── zoom-control.component.spec.ts │ │ │ │ └── zoom-control.component.ts │ │ └── user │ │ │ ├── current-user.service.spec.ts │ │ │ ├── current-user.service.ts │ │ │ ├── user-invitation │ │ │ ├── user-invitation.component.html │ │ │ ├── user-invitation.component.scss │ │ │ ├── user-invitation.component.spec.ts │ │ │ └── user-invitation.component.ts │ │ │ ├── user-list │ │ │ ├── user-list.component.html │ │ │ ├── user-list.component.scss │ │ │ ├── user-list.component.spec.ts │ │ │ └── user-list.component.ts │ │ │ ├── user.material.ts │ │ │ ├── user.service.spec.ts │ │ │ └── user.service.ts │ ├── assets │ │ ├── .gitkeep │ │ ├── arrow-down.svg │ │ ├── fonts │ │ │ ├── Linearicons-Free.eot │ │ │ ├── Linearicons-Free.svg │ │ │ ├── Linearicons-Free.ttf │ │ │ ├── Linearicons-Free.woff │ │ │ └── Linearicons-Free.woff2 │ │ ├── i18n │ │ │ ├── changelog.de.html │ │ │ ├── changelog.en-US.html │ │ │ ├── changelog.es.html │ │ │ ├── changelog.it.html │ │ │ ├── de.json │ │ │ ├── en-US.json │ │ │ ├── es.json │ │ │ ├── it.json │ │ │ ├── notice.de.html │ │ │ ├── notice.en-US.html │ │ │ ├── notice.es.html │ │ │ └── notice.it.html │ │ ├── icon.png │ │ ├── login-background-left.webp │ │ └── login-background-right.webp │ ├── colors.scss │ ├── environments │ │ ├── environment.local.ts │ │ ├── environment.prod.ts │ │ └── environment.ts │ ├── favicon.ico │ ├── favicon.xcf │ ├── icons.scss │ ├── index.html │ ├── main.ts │ └── styles.scss ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.spec.json ├── tslint-to-eslint-config.log └── tslint.json ├── create-backup.sh ├── doc ├── README.md ├── api │ └── README.md ├── architecture │ ├── README.md │ ├── server-diagram.png │ └── server.puml ├── authentication │ ├── README.md │ ├── authentication.png │ └── authentication.puml ├── development │ └── README.md ├── operation │ ├── README.md │ ├── automatic-backups.md │ ├── certbot.service │ ├── certbot.timer │ ├── docker.md │ ├── linux.md │ ├── logging.md │ ├── ssl-cert.md │ ├── stm-backup.service │ ├── stm-backup.timer │ └── stm.md └── testing │ └── README.md ├── docker-compose.yml ├── screenshot.webp └── server ├── .dockerignore ├── Dockerfile ├── README.ja.md ├── README.md ├── api ├── api.go ├── api_util.go ├── api_v2_9.go └── context.go ├── comment ├── api.go ├── entity.go ├── service.go ├── service_test.go └── store.go ├── config ├── api.go ├── api_test.go ├── config.go ├── config_test.go ├── default.json ├── local.json ├── prod.json └── root_test.go ├── database ├── database.go ├── init-db.sh └── scripts │ ├── 000_init.sql │ ├── 001_basic-tables.sql │ ├── 002_project-description.sql │ ├── 003_string-to-array-migration.sql │ ├── 004_migrate-user-names.sh │ ├── 005_migrate-to-geo-json.sh │ ├── 006_project-task-relation.sql │ ├── 007_remove-stale-tasks.sql │ ├── 008_migrate-project-task-relation.sh │ ├── 009_remove-task-ids-from-projects.sql │ ├── 010_add-project-creation-date.sql │ ├── 011_comments.sql │ ├── 012_initial-comment-migration.sh │ ├── 013_josm-data-source.sql │ └── common.sh ├── docs ├── docs.go ├── swagger.json └── swagger.yaml ├── export ├── entity.go ├── service.go └── service_test.go ├── go.mod ├── main.go ├── oauth2 ├── auth.go ├── entity.go └── token.go ├── permission ├── store.go └── store_test.go ├── project ├── api.go ├── entity.go ├── service.go ├── service_test.go └── store.go ├── run-tests.sh ├── task ├── api.go ├── entity.go ├── service.go ├── service_test.go └── store.go ├── test ├── dump.sql ├── helper.go └── test-config.json ├── util ├── logger.go ├── random.go ├── util.go └── util_test.go └── websocket └── websocket.go /.gitignore: -------------------------------------------------------------------------------- 1 | # Server side 2 | server/server 3 | server/stm 4 | server/server.debug 5 | server/coverage.out 6 | server/go.sum 7 | server/test.log 8 | 9 | # Migration files 10 | server/database/.tmp.* 11 | 12 | # Client caches and stuff 13 | client/.angular/ 14 | 15 | # IDE stuff 16 | **.idea 17 | client/.run 18 | 19 | # Docker stuff 20 | postgres-data 21 | .env 22 | 23 | # Backups 24 | *.sql.gz 25 | 26 | # Example files 27 | example* 28 | -------------------------------------------------------------------------------- /README.ja.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # SimpleTaskManager 4 | 5 | これは、例えば[OpenStreetMap(OSM)](https://openstreetmap.org)のためのシンプルな構造のタスクマネージャです。 6 | 最新バージョンは [stm.hauke-stieler.de](https://stm.hauke-stieler.de)で確認してください。 7 | 8 | このプロジェクトは、あらゆる種類の地理関連のもの(例えば、OSMでのマッピング、古い建物の写真など)のためのシンプルで汎用的なタスキングマネージャを作成することを目的としています。 9 | 10 | タスキングマネージャは、複数の人が同じ地域で互いに干渉することなく作業できるようにするアプリケーションです。 11 | 通常、そのような領域は正方形に分割され、一度に1つのマッパーだけが、その範囲を作業します。 12 | 13 | 14 | 15 | # どのように動作しますか? 16 | 17 | ユーザは、地図上の大きな領域(例:市区)で構成される**プロジェクト**を作成できます。 18 | この領域は、いわゆる**タスク**より小さな領域(たとえば、1x1kmの大きな正方形)に分割されています。 19 | ファイル(例えばGeoJSONファイル)から**ジオメトリ**をインポートして、タスクを作成することもできます。 20 | 21 | 一度に1人のユーザーがそのようなタスクの作業ができるようになりました。このユーザーは、**進捗ポイント**を設定することで、タスクの**進捗**を更新できます。 22 | 領域が完全にマッピングされると、ユーザは進捗ポイントを最大値(100%)に設定して終了し、次のタスクを開始することができます。 23 | 24 | プロジェクトの所有者は、他のユーザをプロジェクトに招待することもできます。 25 | あるユーザーがタスクで作業している場合、他のユーザーはそのタスクのプロセスを更新できません。 26 | 27 | # さらに別のタスクマネージャですか? 28 | *(つまり、HOT Tasking Managerの何が問題ですか?)* 29 | 30 | 個人的には、HOT Tasking Managerがそれほど好きではない理由がいくつかあります(直感的なUIでない、iD統合、一定時間後にタスクから自動的に割り当てられないなど) 31 | 32 | 別の方法としては、例えばMapCraft・タスキング・マネージャがあります。これは非常に古く、すでにリポジトリをコンパイルできない状況です。 33 | したがって、MapCraftを設定することはできません(依存関係が壊れている古いPHPコードに何時間も費やしたくない場合)。 34 | 35 | つまり、基本的にはこれは別のタスクマネージャですが、既存のもののクローンではありません。 36 | 37 | # ドキュメント 38 | 39 | ドキュメントは、[docフォルダ](doc)(deployment,api,security,architecture,operationなど)と、別の[client](client)および[server](server)フォルダ(主にセットアップと開発情報)にあります。 40 | 41 | 現在、エンドユーザーマニュアルやチュートリアルなどはありません。 42 | 43 | # 貢献 44 | 45 | 現在、実際のガイドラインはありません。**issue**または**プルリクエスト**を自由に作成してください。 46 | 47 | **コーディングを開始しますか?**開始する方法については、[doc/development/README.md](doc/development/README.md)をご覧ください。 -------------------------------------------------------------------------------- /build-and-push.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Exit on errors 4 | set -e 5 | 6 | # First parameter must be tag name 7 | if [ -z $1 ] 8 | then 9 | echo "ERROR: Specify tag name." 10 | echo 11 | echo "Example:" 12 | echo 13 | echo " $(basename $0) \"0.8.0-dev\"" 14 | exit 1 15 | fi 16 | TAG=$1 17 | 18 | function ok { 19 | echo 20 | echo "OK" 21 | } 22 | 23 | function hline { 24 | echo 25 | printf '%*s\n' "${COLUMNS:-$(tput cols)}" '' | tr ' ' = 26 | echo " $1" 27 | printf '%*s\n' "${COLUMNS:-$(tput cols)}" '' | tr ' ' = 28 | echo 29 | } 30 | 31 | hline "[1/4] Build server" 32 | docker buildx build --progress=plain -t simpletaskmanager/stm-server:$TAG server 33 | ok 34 | 35 | hline "[2/4] Build Client" 36 | echo "This step might take a while..." 37 | echo 38 | docker buildx build --progress=plain -t simpletaskmanager/stm-client:$TAG client 39 | ok 40 | 41 | hline "[3/4] Push server" 42 | docker push simpletaskmanager/stm-server:$TAG 43 | ok 44 | 45 | hline "[4/4] Push client" 46 | docker push simpletaskmanager/stm-client:$TAG 47 | ok 48 | 49 | echo 50 | printf '%*s\n' "${COLUMNS:-$(tput cols)}" '' | tr ' ' = 51 | echo 52 | echo "DONE" -------------------------------------------------------------------------------- /client/.browserslistrc: -------------------------------------------------------------------------------- 1 | # This file is used by the build system to adjust CSS and JS output to support the specified browsers below. 2 | # For additional information regarding the format and rule options, please see: 3 | # https://github.com/browserslist/browserslist#queries 4 | 5 | # You can see what browsers were selected by your queries by running: 6 | # npx browserslist 7 | 8 | last 2 Chrome versions 9 | last 1 Firefox version 10 | last 2 Edge major versions 11 | last 2 Safari major versions 12 | last 2 iOS major versions 13 | Firefox ESR 14 | not dead 15 | not IE 9-11 # For IE 9-11 support, remove 'not'. -------------------------------------------------------------------------------- /client/.dockerignore: -------------------------------------------------------------------------------- 1 | docker-compose* 2 | .dockerignore 3 | .git 4 | .gitignore 5 | node_modules 6 | npm-debug.log 7 | 8 | -------------------------------------------------------------------------------- /client/.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.ts] 12 | quote_type = single 13 | 14 | [*.md] 15 | max_line_length = off 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | # jest 8 | /jestCache 9 | 10 | # dependencies 11 | /node_modules 12 | 13 | # profiling files 14 | chrome-profiler-events*.json 15 | speed-measure-plugin*.json 16 | 17 | # IDEs and editors 18 | /.idea 19 | .project 20 | .classpath 21 | .c9/ 22 | *.launch 23 | .settings/ 24 | *.sublime-workspace 25 | 26 | # IDE - VSCode 27 | .vscode/* 28 | !.vscode/settings.json 29 | !.vscode/tasks.json 30 | !.vscode/launch.json 31 | !.vscode/extensions.json 32 | .history/* 33 | 34 | # misc 35 | /.sass-cache 36 | /connect.lock 37 | /coverage 38 | /libpeerconnection.log 39 | npm-debug.log 40 | yarn-error.log 41 | testem.log 42 | /typings 43 | tags 44 | .nx 45 | 46 | # System Files 47 | .DS_Store 48 | Thumbs.db 49 | 50 | # Image files and assets 51 | src/favicon.png 52 | -------------------------------------------------------------------------------- /client/.htaccess-stm-test: -------------------------------------------------------------------------------- 1 | 2 | RewriteEngine On 3 | RewriteBase /stm-test 4 | RewriteRule ^index\.html$ - [L] 5 | RewriteCond %{REQUEST_FILENAME} !-f 6 | RewriteCond %{REQUEST_FILENAME} !-d 7 | RewriteRule . index.html [L] 8 | 9 | -------------------------------------------------------------------------------- /client/.vimrc: -------------------------------------------------------------------------------- 1 | autocmd BufEnter * call SetTabStop() 2 | 3 | " For .ts files: Use 2 spaced for indentation 4 | fun! SetTabStop() 5 | if buffer_name("%") =~ '\.ts$' 6 | set tabstop=2 7 | set softtabstop=0 8 | set expandtab 9 | else 10 | set tabstop=4 11 | set noexpandtab 12 | endif 13 | endfun 14 | 15 | -------------------------------------------------------------------------------- /client/Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 1: Buil 2 | FROM node:22-alpine AS builder 3 | 4 | COPY . /stm-client 5 | WORKDIR /stm-client/ 6 | 7 | COPY package.json /stm-client/package.json 8 | COPY package-lock.json /stm-client/package-lock.json 9 | 10 | RUN npm install 11 | 12 | RUN NODE_OPTIONS="--max_old_space_size=4096" npm run build 13 | 14 | # Stage 2: Run 15 | FROM nginx:1.27-alpine 16 | 17 | RUN rm -rf /usr/share/nginx/html/* 18 | COPY --from=builder /stm-client/dist/simple-task-manager/browser /usr/share/nginx/html 19 | 20 | ENTRYPOINT nginx -g 'daemon off;' 21 | -------------------------------------------------------------------------------- /client/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: "jest-preset-angular", 3 | setupFilesAfterEnv: [ 4 | "/jest/setup-jest.ts" 5 | ], 6 | testPathIgnorePatterns: [ 7 | "/node_modules/", 8 | "/dist/", 9 | "/jest/", 10 | ], 11 | transformIgnorePatterns: [ 12 | "/node_modules/(?!ol|@angular|@ngx-translate|geotiff|observable-fns|quick-lru|color-space|color-rgba|color-parse|rbush|quickselect|pbf).+\.(mjs|js)$" 13 | ], 14 | transform: { 15 | "^.+\\.{ts}$": [ 16 | 'ts-jest', 17 | { 18 | tsconfig: "/tsconfig.spec.json", 19 | stringifyContentPathRegex: "\\.html$" 20 | } 21 | ] 22 | }, 23 | modulePaths: [ 24 | "" 25 | ], 26 | collectCoverage: true, 27 | coverageReporters: ["html", "text"], 28 | cacheDirectory: "./jestCache" 29 | }; -------------------------------------------------------------------------------- /client/jest/setup-jest.ts: -------------------------------------------------------------------------------- 1 | import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone/index'; 2 | 3 | window.URL.createObjectURL = () => ''; 4 | 5 | // Mock the RBush for OpenLayers. Otherwise, the RBush constructor is somehow unavailable. 6 | jest.mock('ol/structs/RBush'); 7 | 8 | window.fail = (reason: any) => { 9 | throw new Error(reason + ''); 10 | }; 11 | 12 | window.ResizeObserver = jest.fn().mockImplementation(() => ({ 13 | observe: jest.fn(), 14 | unobserve: jest.fn(), 15 | disconnect: jest.fn(), 16 | })); 17 | 18 | setupZoneTestEnv(); 19 | -------------------------------------------------------------------------------- /client/jest/worker-mock.ts: -------------------------------------------------------------------------------- 1 | export default class Worker { 2 | public onmessage: (msg: string) => void; 3 | 4 | constructor(public url: string) { 5 | this.onmessage = () => {}; 6 | } 7 | 8 | postMessage(msg: string): void { 9 | this.onmessage(msg); 10 | } 11 | 12 | addEventListener(event: string, fn: () => void): void {} 13 | } 14 | -------------------------------------------------------------------------------- /client/nginx-test.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 8081 default_server; 3 | listen [::]:8081 default_server; 4 | server_name stm-test.hauke-stieler.de; 5 | 6 | # Redirect to HTTPS page 7 | return 301 https://$host$request_uri; 8 | } 9 | 10 | server { 11 | client_max_body_size 0; 12 | listen 8443 ssl; 13 | server_name stm-test.hauke-stieler.de; 14 | 15 | ssl_certificate /etc/letsencrypt/live/stm-test.hauke-stieler.de/cert.pem; 16 | ssl_certificate_key /etc/letsencrypt/live/stm-test.hauke-stieler.de/privkey.pem; 17 | 18 | location / { 19 | root /usr/share/nginx/html; 20 | index index.html; 21 | try_files $uri $uri/ /index.html; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /client/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80 default_server; 3 | listen [::]:80 default_server; 4 | server_name stm.hauke-stieler.de; 5 | 6 | # Redirect to HTTPS page 7 | return 301 https://$host$request_uri; 8 | } 9 | 10 | server { 11 | client_max_body_size 0; 12 | listen 443 ssl; 13 | server_name stm.hauke-stieler.de; 14 | 15 | ssl_certificate /etc/letsencrypt/live/stm.hauke-stieler.de/cert.pem; 16 | ssl_certificate_key /etc/letsencrypt/live/stm.hauke-stieler.de/privkey.pem; 17 | 18 | location / { 19 | root /usr/share/nginx/html; 20 | index index.html; 21 | try_files $uri $uri/ /index.html; 22 | } 23 | } 24 | 25 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "simple-task-manager", 3 | "version": "1.6.1", 4 | "scripts": { 5 | "ng": "ng", 6 | "dev": "ng serve --watch", 7 | "dev-local": "ng serve --watch -c local", 8 | "build": "ng build -c production", 9 | "test": "jest --detectOpenHandles", 10 | "test:coverage": "jest --coverage", 11 | "lint": "ng lint", 12 | "lint:fix": "ng lint --fix", 13 | "update": "npx npm-check-updates -u && npm install" 14 | }, 15 | "private": true, 16 | "dependencies": { 17 | "@angular/animations": "^18.2.13", 18 | "@angular/common": "^18.2.13", 19 | "@angular/compiler": "^18.2.13", 20 | "@angular/core": "^18.2.13", 21 | "@angular/forms": "^18.2.13", 22 | "@angular/localize": "^18.2.13", 23 | "@angular/platform-browser": "^18.2.13", 24 | "@angular/platform-browser-dynamic": "^18.2.13", 25 | "@angular/router": "^18.2.13", 26 | "@ngx-translate/core": "^16.0.3", 27 | "@ngx-translate/http-loader": "^16.0.0", 28 | "@turf/hex-grid": "^6.5.0", 29 | "@turf/square-grid": "^6.5.0", 30 | "@turf/triangle-grid": "^6.5.0", 31 | "ng-mocks": "^14.13.1", 32 | "ol": "^10.3.1", 33 | "rxjs": "^7.8.1", 34 | "tslib": "^2.8.1", 35 | "zone.js": "^0.14.10" 36 | }, 37 | "devDependencies": { 38 | "@angular-eslint/builder": "^18.4.3", 39 | "@angular-eslint/eslint-plugin": "^18.4.3", 40 | "@angular-eslint/eslint-plugin-template": "^18.4.3", 41 | "@angular-eslint/schematics": "^18.4.3", 42 | "@angular-eslint/template-parser": "^18.4.3", 43 | "@angular/build": "^18.2.12", 44 | "@angular/cli": "^18.2.12", 45 | "@angular/compiler-cli": "^18.2.13", 46 | "@types/jest": "^29.5.14", 47 | "@typescript-eslint/eslint-plugin": "5.27.1", 48 | "@typescript-eslint/parser": "5.27.1", 49 | "eslint": "^8.57.1", 50 | "eslint-plugin-import": "^2.31.0", 51 | "eslint-plugin-jsdoc": "^48.11.0", 52 | "eslint-plugin-prefer-arrow": "^1.2.3", 53 | "jasmine-core": "^5.5.0", 54 | "jest": "^29.7.0", 55 | "jest-preset-angular": "^14.4.2", 56 | "ng-mocks": "^14.13.1", 57 | "tslib": "^2.8.1", 58 | "typescript": "^5.5.4" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /client/src/app/app-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { RouterModule, Routes } from '@angular/router'; 3 | import { LoginComponent } from './auth/login/login.component'; 4 | import { AuthGuard } from './auth/auth.guard'; 5 | import { OauthLandingComponent } from './auth/oauth-landing/oauth-landing.component'; 6 | import { DashboardComponent } from './dashboard/dashboard.component'; 7 | import { ProjectComponent } from './project/project/project.component'; 8 | import { ProjectCreationComponent } from './project-creation/project-creation/project-creation.component'; 9 | import { AllProjectsResolver } from './project/all-projects.resolver'; 10 | import { projectResolver } from './project/project.resolver'; 11 | import { SelectedLanguageGuard } from './common/selected-language.guard'; 12 | import { ConfigResolver } from './config/config.resolver'; 13 | 14 | const routes: Routes = [ 15 | { 16 | path: '', 17 | component: LoginComponent, 18 | canActivate: [AuthGuard, SelectedLanguageGuard], 19 | resolve: {config: ConfigResolver} 20 | }, 21 | { 22 | path: 'dashboard', 23 | component: DashboardComponent, 24 | canActivate: [AuthGuard, SelectedLanguageGuard], 25 | resolve: { 26 | projects: AllProjectsResolver, 27 | config: ConfigResolver 28 | } 29 | }, 30 | { 31 | path: 'project/:id', 32 | component: ProjectComponent, 33 | canActivate: [AuthGuard, SelectedLanguageGuard], 34 | resolve: { 35 | project: projectResolver, 36 | config: ConfigResolver 37 | } 38 | }, 39 | { 40 | path: 'new-project', 41 | component: ProjectCreationComponent, 42 | canActivate: [AuthGuard, SelectedLanguageGuard], 43 | resolve: {config: ConfigResolver} 44 | }, 45 | {path: 'oauth-landing', component: OauthLandingComponent}, 46 | {path: '**', redirectTo: ''}, 47 | ]; 48 | 49 | @NgModule({ 50 | imports: [RouterModule.forRoot(routes, {})], 51 | exports: [RouterModule] 52 | }) 53 | export class AppRoutingModule { 54 | } 55 | -------------------------------------------------------------------------------- /client/src/app/app.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |
5 | 6 |
7 | -------------------------------------------------------------------------------- /client/src/app/app.component.scss: -------------------------------------------------------------------------------- 1 | @import "../styles"; 2 | 3 | :host { 4 | width: 100%; 5 | height: 100%; 6 | display: block; 7 | } 8 | 9 | .host-in-test-mode { 10 | height: calc(100% - #{$space-large}); 11 | } 12 | .host-in-prod-mode { 13 | height: 100%; 14 | } 15 | 16 | .test-label { 17 | display: flex; 18 | justify-content: center; 19 | background-color: $color-warn; 20 | height: $space-large; 21 | } 22 | -------------------------------------------------------------------------------- /client/src/app/app.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { AppComponent } from './app.component'; 2 | import { MockBuilder, MockedComponentFixture, MockRender } from 'ng-mocks'; 3 | import { AppModule } from './app.module'; 4 | import { ConfigProvider } from './config/config.provider'; 5 | 6 | describe(AppComponent.name, () => { 7 | let component: AppComponent; 8 | let fixture: MockedComponentFixture; 9 | let configProvider: ConfigProvider; 10 | 11 | beforeEach(() => { 12 | configProvider = {} as ConfigProvider; 13 | 14 | return MockBuilder(AppComponent, AppModule) 15 | .provide({provide: ConfigProvider, useFactory: () => configProvider}); 16 | }); 17 | 18 | beforeEach(() => { 19 | fixture = MockRender(AppComponent); 20 | component = fixture.point.componentInstance; 21 | fixture.detectChanges(); 22 | }); 23 | 24 | it('should create the app', () => { 25 | expect(component).toBeTruthy(); 26 | }); 27 | 28 | it('should return config test environment value', () => { 29 | configProvider.testEnvironment = false; 30 | expect(component.isInTestMode).toEqual(false); 31 | 32 | configProvider.testEnvironment = true; 33 | expect(component.isInTestMode).toEqual(true); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /client/src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { ConfigProvider } from './config/config.provider'; 3 | import { TranslateService } from '@ngx-translate/core'; 4 | import { registerLocaleData } from '@angular/common'; 5 | import localeEn from '@angular/common/locales/en'; 6 | import localeDe from '@angular/common/locales/de'; 7 | import localeEs from '@angular/common/locales/es'; 8 | 9 | @Component({ 10 | selector: 'app-root', 11 | templateUrl: './app.component.html', 12 | styleUrls: ['./app.component.scss'] 13 | }) 14 | export class AppComponent { 15 | constructor(private config: ConfigProvider, private translate: TranslateService) { 16 | translate.addLangs(['de', 'en-US', 'es']); 17 | translate.setDefaultLang('en-US'); 18 | 19 | // To make locale usages (e.g. in date pipe) work 20 | registerLocaleData(localeEn, 'en-US'); 21 | registerLocaleData(localeDe, 'de'); 22 | registerLocaleData(localeEs, 'es'); 23 | } 24 | 25 | get isInTestMode(): boolean { 26 | return this.config.testEnvironment; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /client/src/app/auth/auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from '@angular/router'; 3 | import { AuthService } from './auth.service'; 4 | import { CurrentUserService } from '../user/current-user.service'; 5 | 6 | @Injectable() 7 | export class AuthGuard implements CanActivate { 8 | constructor( 9 | private router: Router, 10 | private authService: AuthService, 11 | private currentUserService: CurrentUserService 12 | ) { 13 | } 14 | 15 | canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean { 16 | // if already logged in, then get the user name and store it locally in the user service 17 | if (this.authService.isAuthenticated()) { 18 | // already logged in 19 | try { 20 | this.authService.setUserNameFromToken(); 21 | } catch { 22 | this.currentUserService.resetUser(); 23 | localStorage.removeItem('auth_token'); 24 | } 25 | } else { 26 | // not logged in 27 | this.currentUserService.resetUser(); 28 | localStorage.removeItem('auth_token'); 29 | } 30 | 31 | // The login component has the route '/' and therefore the path is '' 32 | const requestLoginComponent = route.routeConfig?.path === ''; 33 | 34 | if (requestLoginComponent && this.authService.isAuthenticated()) { 35 | // Token exists and login component should be loaded -> redirect to dashboard 36 | this.router.navigateByUrl('/dashboard'); 37 | return false; 38 | } else if (!requestLoginComponent && !this.authService.isAuthenticated()) { 39 | // No token -> not logged in -> redirect to login page 40 | this.router.navigateByUrl('/'); 41 | return false; 42 | } 43 | 44 | // We have a token and want to load a normal component -> ok 45 | // We don't have a token but want to load the login page -> ok 46 | return true; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /client/src/app/auth/auth.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { AuthService } from './auth.service'; 2 | import { CurrentUserService } from '../user/current-user.service'; 3 | import { NotificationService } from '../common/services/notification.service'; 4 | import { Router } from '@angular/router'; 5 | 6 | describe(AuthService.name, () => { 7 | let service: AuthService; 8 | let currentUserService: CurrentUserService; 9 | let router: Router; 10 | let notificationService: NotificationService; 11 | 12 | beforeEach(() => { 13 | currentUserService = {} as CurrentUserService; 14 | router = {} as Router; 15 | notificationService = {} as NotificationService; 16 | 17 | service = new AuthService(currentUserService, router, notificationService); 18 | }); 19 | 20 | it('should be created', () => { 21 | expect(service).toBeTruthy(); 22 | }); 23 | 24 | it('should determine authenticated state correctly', () => { 25 | localStorage.removeItem('auth_token'); 26 | expect(service.isAuthenticated()).toEqual(false); 27 | 28 | localStorage.setItem('auth_token', 'dummy-token'); 29 | expect(service.isAuthenticated()).toEqual(true); 30 | }); 31 | 32 | it('should interpret empty string as not authenticated', () => { 33 | localStorage.setItem('auth_token', ''); 34 | expect(service.isAuthenticated()).toEqual(false); 35 | }); 36 | 37 | it('should interpret null as not authenticated', () => { 38 | // @ts-ignore 39 | localStorage.setItem('auth_token', null); 40 | expect(service.isAuthenticated()).toEqual(false); 41 | }); 42 | 43 | it('should interpret undefined as not authenticated', () => { 44 | // @ts-ignore 45 | localStorage.setItem('auth_token', undefined); 46 | expect(service.isAuthenticated()).toEqual(false); 47 | }); 48 | 49 | it('constructor should set user name correctly', () => { 50 | currentUserService.setUser = jest.fn(); 51 | 52 | localStorage.setItem('auth_token', 'eyJ2YWxpZF91bnRpbCI6MjU4ODM3MTQ0MywidXNlciI6InRlc3QtdXNlciIsInVpZCI6IjEyMzQ1Iiwic2VjcmV0IjoiMHZWdkJZNHNRWDQrTTY5byt4TEhLSm5oYWZIekNkNlFDWWh3L2pzNlR0MD0ifQo='); 53 | 54 | service.setUserNameFromToken(); 55 | 56 | expect(currentUserService.setUser).toHaveBeenCalledWith('test-user', '12345'); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /client/src/app/auth/logged-in.interceptor.spec.ts: -------------------------------------------------------------------------------- 1 | import { LoggedInInterceptor } from './logged-in.interceptor'; 2 | import { AuthService } from './auth.service'; 3 | import { NotificationService } from '../common/services/notification.service'; 4 | import { HttpRequest } from '@angular/common/http'; 5 | import { environment } from '../../environments/environment'; 6 | import { of } from 'rxjs'; 7 | import { TranslateService } from '@ngx-translate/core'; 8 | 9 | describe(LoggedInInterceptor.name, () => { 10 | let interceptor: LoggedInInterceptor; 11 | let authService: AuthService; 12 | let notificationService: NotificationService; 13 | let translationService: TranslateService; 14 | 15 | beforeEach(() => { 16 | authService = {} as AuthService; 17 | notificationService = {} as NotificationService; 18 | translationService = {} as TranslateService; 19 | 20 | interceptor = new LoggedInInterceptor(authService, notificationService, translationService); 21 | }); 22 | 23 | it('should call next handler on unauthenticated URLs', () => { 24 | const nextHandler = { 25 | handle: jest.fn() 26 | }; 27 | 28 | const request = {url: 'https://foo.com/bar'} as HttpRequest; 29 | 30 | interceptor.intercept(request, nextHandler); 31 | 32 | expect(nextHandler.handle).toHaveBeenCalledWith(request); 33 | }); 34 | 35 | it('should add authentication token to request', () => { 36 | const authToken = 'FOO_BAR'; 37 | localStorage.setItem('auth_token', authToken); 38 | 39 | const nextHandler = { 40 | handle: jest.fn().mockReturnValue(of()) 41 | }; 42 | 43 | const request = {url: environment.base_url + '/foo/bar'} as HttpRequest; 44 | request.clone = jest.fn().mockReturnValue(request); 45 | 46 | interceptor.intercept(request, nextHandler); 47 | 48 | expect(request.clone).toHaveBeenCalled(); 49 | expect(nextHandler.handle).toHaveBeenCalled(); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /client/src/app/auth/logged-in.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { HttpErrorResponse, HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http'; 3 | import { Observable } from 'rxjs'; 4 | import { catchError } from 'rxjs/operators'; 5 | import { AuthService } from './auth.service'; 6 | import { NotificationService } from '../common/services/notification.service'; 7 | import { environment } from '../../environments/environment'; 8 | import { TranslateService } from '@ngx-translate/core'; 9 | 10 | @Injectable() 11 | export class LoggedInInterceptor implements HttpInterceptor { 12 | private readonly unauthenticatedEndpoints = [ 13 | environment.url_config 14 | ]; 15 | 16 | constructor( 17 | private authService: AuthService, 18 | private notificationService: NotificationService, 19 | private translationService: TranslateService) { 20 | } 21 | 22 | intercept(request: HttpRequest, next: HttpHandler): Observable> { 23 | // Do not intercept unauthenticated URLs, for example: JOSM-Remote-Control or OSM-API 24 | const url = request.url; 25 | if (!url.startsWith(environment.base_url) || this.unauthenticatedEndpoints.some(endpoint => url.startsWith(endpoint))) { 26 | return next.handle(request); 27 | } 28 | 29 | const token = localStorage.getItem('auth_token'); 30 | request = request.clone({ 31 | setHeaders: { 32 | Authorization: token 33 | } 34 | } as unknown as HttpRequest); 35 | 36 | return next.handle(request) 37 | .pipe(catchError((e: HttpErrorResponse) => { 38 | console.error(e); 39 | if (e.status === 401) { 40 | console.error('Trigger logout: ' + e.message); 41 | this.notificationService.addWarning(this.translationService.instant('login-failed')); 42 | this.authService.logout(); 43 | } 44 | throw e; 45 | })); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /client/src/app/auth/login/login.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |
5 |
6 |
7 | 8 |
9 |
10 |
11 | 12 | {{"language-selection" | translate}} 13 | 14 |
15 |
16 | 19 |
20 |
21 | 24 |
25 |
26 |

27 |
28 |
29 |

30 |
31 |
32 | 33 |
34 | 35 | -------------------------------------------------------------------------------- /client/src/app/auth/login/login.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { LoginComponent } from './login.component'; 2 | import { AuthService } from '../auth.service'; 3 | import { Router } from '@angular/router'; 4 | import { MockBuilder, MockedComponentFixture, MockRender } from 'ng-mocks'; 5 | import { AppModule } from '../../app.module'; 6 | import { TranslateService } from '@ngx-translate/core'; 7 | import { HttpClient } from '@angular/common/http'; 8 | import { of } from 'rxjs'; 9 | 10 | describe(LoginComponent.name, () => { 11 | let component: LoginComponent; 12 | let fixture: MockedComponentFixture; 13 | let authService: AuthService; 14 | let router: Router; 15 | let httpClient: HttpClient; 16 | let translationService: TranslateService; 17 | 18 | beforeEach(() => { 19 | router = {} as Router; 20 | router.navigate = jest.fn(); 21 | 22 | httpClient = {} as HttpClient; 23 | httpClient.get = jest.fn().mockReturnValue(of()); 24 | 25 | authService = {} as AuthService; 26 | translationService = { 27 | onLangChange: of(), 28 | } as unknown as TranslateService; 29 | 30 | return MockBuilder(LoginComponent, AppModule) 31 | .provide({provide: Router, useFactory: () => router}) 32 | .provide({provide: AuthService, useFactory: () => authService}) 33 | .provide({provide: HttpClient, useFactory: () => httpClient}) 34 | .provide({provide: TranslateService, useFactory: () => translationService}); 35 | }); 36 | 37 | beforeEach(() => { 38 | fixture = MockRender(LoginComponent); 39 | component = fixture.point.componentInstance; 40 | // @ts-ignore 41 | fixture.ngZone.run = (fn) => fn(); 42 | fixture.detectChanges(); 43 | }); 44 | 45 | it('should create', () => { 46 | expect(component).toBeTruthy(); 47 | }); 48 | 49 | it('should redirect user to dashboard after login', () => { 50 | authService.requestLogin = (fn) => fn(); 51 | router.navigate = jest.fn(); 52 | 53 | component.onLoginButtonClick(); 54 | 55 | expect(router.navigate).toHaveBeenCalledWith(['/dashboard']); 56 | }); 57 | 58 | // TODO test for language change to re-load template 59 | }); 60 | -------------------------------------------------------------------------------- /client/src/app/auth/oauth-landing/oauth-landing.component.html: -------------------------------------------------------------------------------- 1 |

Succesfully logged in :)

2 | -------------------------------------------------------------------------------- /client/src/app/auth/oauth-landing/oauth-landing.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hauke96/simple-task-manager/03829aa290467d925e3d52262899a2a6a51819a4/client/src/app/auth/oauth-landing/oauth-landing.component.scss -------------------------------------------------------------------------------- /client/src/app/auth/oauth-landing/oauth-landing.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { OauthLandingComponent } from './oauth-landing.component'; 2 | import { ActivatedRoute } from '@angular/router'; 3 | import { of } from 'rxjs'; 4 | import { MockBuilder, MockedComponentFixture, MockRender } from 'ng-mocks'; 5 | import { AppModule } from '../../app.module'; 6 | 7 | describe(OauthLandingComponent.name, () => { 8 | let component: OauthLandingComponent; 9 | let fixture: MockedComponentFixture; 10 | 11 | beforeEach(() => { 12 | const activeRoute = { 13 | queryParams: of([{ 14 | auth_token: 'abc123' 15 | }]) 16 | }; 17 | 18 | window.close = jest.fn(); 19 | 20 | return MockBuilder(OauthLandingComponent, AppModule) 21 | .provide({ 22 | provide: ActivatedRoute, 23 | useFactory: () => activeRoute 24 | }); 25 | }); 26 | 27 | beforeEach(() => { 28 | fixture = MockRender(OauthLandingComponent); 29 | component = fixture.point.componentInstance; 30 | fixture.detectChanges(); 31 | }); 32 | 33 | it('should create', () => { 34 | expect(component).toBeTruthy(); 35 | }); 36 | 37 | it('should call close on window', () => { 38 | expect(window.close).toHaveBeenCalled(); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /client/src/app/auth/oauth-landing/oauth-landing.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { ActivatedRoute } from '@angular/router'; 3 | 4 | @Component({ 5 | selector: 'app-oauth-landing', 6 | templateUrl: './oauth-landing.component.html', 7 | styleUrls: ['./oauth-landing.component.scss'] 8 | }) 9 | export class OauthLandingComponent { 10 | constructor(private route: ActivatedRoute) { 11 | this.route.queryParams.subscribe(params => { 12 | localStorage.setItem('auth_token', params.token); 13 | window.close(); 14 | }); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /client/src/app/comments/comment.material.ts: -------------------------------------------------------------------------------- 1 | import { User } from '../user/user.material'; 2 | 3 | export class CommentDto { 4 | constructor(public id: number, 5 | public text: string, 6 | public authorId: string, 7 | public creationDate: string) { 8 | } 9 | } 10 | 11 | export class CommentDraftDto { 12 | constructor(public text: string) { 13 | } 14 | } 15 | 16 | export class Comment { 17 | constructor(public id: number, 18 | public text: string, 19 | public author: User, 20 | public creationDate: Date) { 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /client/src/app/comments/comment.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { CommentService } from './comment.service'; 2 | import { CommentDto } from './comment.material'; 3 | import { User } from '../user/user.material'; 4 | 5 | describe(CommentService.name, () => { 6 | let service: CommentService; 7 | 8 | beforeEach(() => { 9 | service = new CommentService(); 10 | }); 11 | 12 | it('should be created', () => { 13 | expect(service).toBeTruthy(); 14 | }); 15 | 16 | it('should correctly convert DTO', () => { 17 | const dtos = [ 18 | new CommentDto(123, 'some text', 'author1', '2024-03-27 10:00'), 19 | new CommentDto(234, 'some other text', 'author2', '2024-03-27 11:00') 20 | ]; 21 | const userMap = new Map([ 22 | ['author2', new User('Peter', 'author2')], 23 | ['author1', new User('Anna', 'author1')], 24 | ]); 25 | 26 | const comments = service.toCommentsWithUserMap(dtos, userMap); 27 | 28 | expect(comments.length).toEqual(2); 29 | 30 | expect(comments[0].id).toEqual(dtos[0].id); 31 | expect(comments[0].text).toEqual(dtos[0].text); 32 | expect(comments[0].author).toEqual(userMap.get('author1') as User); 33 | expect(comments[0].creationDate).toEqual(new Date(dtos[0].creationDate)); 34 | 35 | expect(comments[1].id).toEqual(dtos[1].id); 36 | expect(comments[1].text).toEqual(dtos[1].text); 37 | expect(comments[1].author).toEqual(userMap.get('author2') as User); 38 | expect(comments[1].creationDate).toEqual(new Date(dtos[1].creationDate)); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /client/src/app/comments/comment.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Comment, CommentDto } from './comment.material'; 3 | import { User } from '../user/user.material'; 4 | 5 | @Injectable({ 6 | providedIn: 'root' 7 | }) 8 | export class CommentService { 9 | public toCommentsWithUserMap(dtos: CommentDto[], userMap: Map): Comment[] { 10 | return dtos.map(dto => new Comment( 11 | dto.id, 12 | dto.text, 13 | userMap.get(dto.authorId) as User, 14 | new Date(dto.creationDate) 15 | )); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /client/src/app/comments/comment/comment.component.html: -------------------------------------------------------------------------------- 1 |

{{ title }}

2 | 3 |
4 |
5 |
{{ c.text }}
6 | 14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 | 29 |
31 |
-------------------------------------------------------------------------------- /client/src/app/comments/comment/comment.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { CommentComponent } from './comment.component'; 2 | import { MockBuilder, MockedComponentFixture, MockRender } from 'ng-mocks'; 3 | import { Comment } from '../comment.material'; 4 | import { User } from '../../user/user.material'; 5 | import { TranslateService } from '@ngx-translate/core'; 6 | import { AppModule } from '../../app.module'; 7 | 8 | describe(CommentComponent.name, () => { 9 | let component: CommentComponent; 10 | let fixture: MockedComponentFixture; 11 | let translationService: TranslateService; 12 | 13 | beforeEach(() => { 14 | translationService = {} as TranslateService; 15 | 16 | return MockBuilder(CommentComponent, AppModule) 17 | .provide({provide: TranslateService, useFactory: () => translationService}); 18 | }); 19 | 20 | beforeEach(() => { 21 | fixture = MockRender(CommentComponent, {comments: []}); 22 | component = fixture.point.componentInstance; 23 | fixture.detectChanges(); 24 | }); 25 | 26 | it('should create', () => { 27 | expect(component).toBeTruthy(); 28 | }); 29 | 30 | it('should sort comment correct', () => { 31 | component.comments = [ 32 | new Comment(1, 'first', new User('author', '100'), new Date('2024-03-27 00:10')), 33 | new Comment(3, 'third', new User('author', '100'), new Date('2024-03-27 00:30')), 34 | new Comment(4, 'fourth', new User('author', '100'), new Date('2024-03-27 00:40')), 35 | new Comment(2, 'second', new User('author', '100'), new Date('2024-03-27 00:20')), 36 | ]; 37 | 38 | expect(component.currentComments.length).toEqual(4); 39 | // Order is reverse (youngest first) due to the UI (s. SCSS file) 40 | expect(component.currentComments[0].id).toEqual(4); 41 | expect(component.currentComments[1].id).toEqual(3); 42 | expect(component.currentComments[2].id).toEqual(2); 43 | expect(component.currentComments[3].id).toEqual(1); 44 | }); 45 | 46 | it('should fires event on button click', () => { 47 | const sendButtonSpy = jest.fn(); 48 | component.commentSendClicked.subscribe(sendButtonSpy); 49 | 50 | component.onSendButtonClicked(); 51 | 52 | expect(sendButtonSpy).toHaveBeenCalled(); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /client/src/app/comments/comment/comment.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, EventEmitter, Input, Output } from '@angular/core'; 2 | import { Comment } from '../comment.material'; 3 | import { CurrentUserService } from '../../user/current-user.service'; 4 | import { TranslateService } from '@ngx-translate/core'; 5 | 6 | @Component({ 7 | selector: 'app-comment', 8 | templateUrl: './comment.component.html', 9 | styleUrl: './comment.component.scss' 10 | }) 11 | export class CommentComponent { 12 | 13 | public currentComments: Comment[] = []; 14 | 15 | @Input() 16 | public title: string; 17 | 18 | @Output() 19 | public commentSendClicked = new EventEmitter(); 20 | 21 | enteredComment: string; 22 | 23 | constructor(private currentUserService: CurrentUserService, private translateService: TranslateService) { 24 | } 25 | 26 | @Input() 27 | set comments(value: Comment[]) { 28 | this.currentComments = value; 29 | this.currentComments.sort((a, b) => b.creationDate.getTime() - a.creationDate.getTime()); 30 | } 31 | 32 | public get currentLocale(): string { 33 | return this.translateService.currentLang; 34 | } 35 | 36 | public isFromCurrentUser(comment: Comment): boolean { 37 | return comment.author.uid === this.currentUserService.getUserId(); 38 | } 39 | 40 | public onSendButtonClicked(): void { 41 | this.commentSendClicked.emit(this.enteredComment); 42 | this.enteredComment = ''; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /client/src/app/common/components/map/map.component.html: -------------------------------------------------------------------------------- 1 | 6 |
7 | -------------------------------------------------------------------------------- /client/src/app/common/components/map/map.component.scss: -------------------------------------------------------------------------------- 1 | @import '../../../../styles'; 2 | 3 | :host { 4 | position: relative; 5 | } 6 | 7 | .zoom-control { 8 | position: absolute; 9 | z-index: 5; // Because of OpenLayers claiming some z-indices 10 | left: 0px; 11 | top: 0px; 12 | margin: $space-base; 13 | } 14 | 15 | #map { 16 | height: 100%; 17 | } 18 | -------------------------------------------------------------------------------- /client/src/app/common/entities/josm-data-source.ts: -------------------------------------------------------------------------------- 1 | export type JosmDataSource = 'OSM' | 'OVERPASS'; 2 | -------------------------------------------------------------------------------- /client/src/app/common/entities/language.ts: -------------------------------------------------------------------------------- 1 | export class Language { 2 | constructor(public code: string, public name: string) { 3 | } 4 | } 5 | -------------------------------------------------------------------------------- /client/src/app/common/entities/websocket-message.ts: -------------------------------------------------------------------------------- 1 | export class WebsocketMessage { 2 | constructor( 3 | public type: WebsocketMessageType, 4 | public id: string 5 | ) { 6 | } 7 | } 8 | 9 | export enum WebsocketMessageType { 10 | MessageType_ProjectAdded = 'project_added', 11 | MessageType_ProjectUpdated = 'project_updated', 12 | MessageType_ProjectDeleted = 'project_deleted', 13 | MessageType_ProjectUserRemoved = 'project_user_removed', 14 | } 15 | -------------------------------------------------------------------------------- /client/src/app/common/mock-router.ts: -------------------------------------------------------------------------------- 1 | import { Observable, of } from 'rxjs'; 2 | import { Event } from '@angular/router'; 3 | 4 | export class MockRouter { 5 | public events = new Observable(); 6 | 7 | navigate(commands: any[]): Promise { 8 | return of(true).toPromise(); 9 | } 10 | 11 | navigateByUrl(url: string): Promise { 12 | return of(true).toPromise(); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /client/src/app/common/selected-language.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { CanActivate, CanLoad } from '@angular/router'; 3 | import { LanguageService } from './services/language.service'; 4 | import { environment } from '../../environments/environment'; 5 | 6 | @Injectable() 7 | export class SelectedLanguageGuard implements CanActivate { 8 | constructor(private languageService: LanguageService) { 9 | } 10 | 11 | public canActivate(): boolean { 12 | // Don't care about language redirect when working locally (when "production === false") 13 | return this.languageService.loadLanguageFromLocalStorage(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /client/src/app/common/services/loading.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { LoadingService } from './loading.service'; 2 | import { ReplaySubject } from 'rxjs'; 3 | import { NavigationCancel, NavigationEnd, NavigationError, NavigationStart, Router, RouterEvent } from '@angular/router'; 4 | 5 | describe(LoadingService.name, () => { 6 | const routerEventSubject = new ReplaySubject(); 7 | 8 | let service: LoadingService; 9 | let router: Router; 10 | 11 | beforeEach(() => { 12 | router = { 13 | events: routerEventSubject.asObservable() 14 | } as Router; 15 | 16 | service = new LoadingService(router); 17 | }); 18 | 19 | it('should be created', () => { 20 | expect(service).toBeTruthy(); 21 | }); 22 | 23 | it('should set loading state correctly', () => { 24 | service.start(); 25 | expect(service.isLoading()).toEqual(true); 26 | 27 | service.end(); 28 | expect(service.isLoading()).toEqual(false); 29 | }); 30 | 31 | it('should start loading on routing start event', () => { 32 | routerEventSubject.next(new NavigationStart(1, '/foo/bar')); 33 | expect(service.isLoading()).toEqual(true); 34 | }); 35 | 36 | describe('navigation end events', () => { 37 | beforeEach(() => { 38 | service.start(); 39 | }); 40 | 41 | it('should end loading on navigation error', () => { 42 | routerEventSubject.next(new NavigationError(1, '/foo/bar', 'some error')); 43 | expect(service.isLoading()).toEqual(false); 44 | }); 45 | 46 | it('should end loading on navigation cancellation', () => { 47 | routerEventSubject.next(new NavigationCancel(1, '/foo/bar', 'some error')); 48 | expect(service.isLoading()).toEqual(false); 49 | }); 50 | 51 | it('should end loading on navigation end', () => { 52 | routerEventSubject.next(new NavigationEnd(1, '/foo/bar', '/some/other/url')); 53 | expect(service.isLoading()).toEqual(false); 54 | }); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /client/src/app/common/services/loading.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { NavigationCancel, NavigationEnd, NavigationError, NavigationStart, Router } from '@angular/router'; 3 | 4 | @Injectable({ 5 | providedIn: 'root' 6 | }) 7 | export class LoadingService { 8 | private loading: boolean; 9 | 10 | constructor(private router: Router) { 11 | this.loading = false; 12 | 13 | router.events.subscribe(event => { 14 | if (event instanceof NavigationStart) { 15 | this.loading = true; 16 | } else if (event instanceof NavigationEnd || 17 | event instanceof NavigationCancel || 18 | event instanceof NavigationError) { 19 | this.loading = false; 20 | } 21 | }); 22 | } 23 | 24 | public isLoading(): boolean { 25 | return this.loading; 26 | } 27 | 28 | public start(): void { 29 | this.loading = true; 30 | } 31 | 32 | public end(): void { 33 | this.loading = false; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /client/src/app/common/services/map-layer.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { MapLayerService } from './map-layer.service'; 2 | import BaseLayer from 'ol/layer/Base'; 3 | 4 | describe(MapLayerService.name, () => { 5 | let service: MapLayerService; 6 | 7 | beforeEach(() => { 8 | service = new MapLayerService(); 9 | }); 10 | 11 | it('should be created', () => { 12 | expect(service).toBeTruthy(); 13 | }); 14 | 15 | it('should call layer added handler', () => { 16 | // Arrange 17 | const spy = jest.fn(); 18 | service.onLayerAdded.subscribe(spy); 19 | const layer = new BaseLayer({}); 20 | 21 | // Act 22 | service.addLayer(layer); 23 | 24 | // Assert 25 | expect(spy).toHaveBeenCalledTimes(1); 26 | expect(spy).toHaveBeenCalledWith(layer); 27 | }); 28 | 29 | it('should call fit view handler', () => { 30 | // Arrange 31 | const spy = jest.fn(); 32 | service.onFitView.subscribe(spy); 33 | const extent = [1, 2, 3, 4]; 34 | 35 | // Act 36 | service.fitView(extent); 37 | 38 | // Assert 39 | expect(spy).toHaveBeenCalledTimes(1); 40 | expect(spy).toHaveBeenCalledWith(extent); 41 | }); 42 | 43 | it('should call move to outside geometry handler', () => { 44 | // Arrange 45 | const spy = jest.fn(); 46 | service.onMoveToOutsideGeometry.subscribe(spy); 47 | const extent = [1, 2, 3, 4]; 48 | 49 | // Act 50 | service.moveToOutsideGeometry(extent); 51 | 52 | // Assert 53 | expect(spy).toHaveBeenCalledTimes(1); 54 | expect(spy).toHaveBeenCalledWith(extent); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /client/src/app/common/services/notification.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | @Injectable({ 4 | providedIn: 'root' 5 | }) 6 | export class NotificationService { 7 | private errorMessages: Array; 8 | private infoMessages: Array; 9 | private warningMessages: Array; 10 | 11 | constructor() { 12 | this.errorMessages = new Array(); 13 | this.infoMessages = new Array(); 14 | this.warningMessages = new Array(); 15 | } 16 | 17 | // 18 | // Errors 19 | // 20 | 21 | public hasError(): boolean { 22 | return this.errorMessages.length > 0; 23 | } 24 | 25 | public remainingErrors(): number { 26 | return this.errorMessages.length; 27 | } 28 | 29 | // Returns the oldest message 30 | public getError(): string | undefined { 31 | return this.errorMessages[0]; 32 | } 33 | 34 | // Drops/removes the oldest error reported by "getError()" 35 | public dropError(): void { 36 | this.errorMessages.shift(); 37 | } 38 | 39 | public addError(message: any): void { 40 | this.errorMessages.push('' + message); 41 | } 42 | 43 | // 44 | // Info 45 | // 46 | 47 | public hasInfo(): boolean { 48 | return this.infoMessages.length > 0; 49 | } 50 | 51 | public remainingInfo(): number { 52 | return this.infoMessages.length; 53 | } 54 | 55 | // Returns the oldest message 56 | public getInfo(): string | undefined { 57 | return this.infoMessages[0]; 58 | } 59 | 60 | // Drops/removes the oldest error reported by "getInfo()" 61 | public dropInfo(): void { 62 | this.infoMessages.shift(); 63 | } 64 | 65 | public addInfo(message: any): void { 66 | this.infoMessages.push(message); 67 | } 68 | 69 | // 70 | // Warning 71 | // 72 | 73 | public hasWarning(): boolean { 74 | return this.warningMessages.length > 0; 75 | } 76 | 77 | public remainingWarning(): number { 78 | return this.warningMessages.length; 79 | } 80 | 81 | // Returns the oldest message 82 | public getWarning(): string | undefined { 83 | return this.warningMessages[0]; 84 | } 85 | 86 | // Drops/removes the oldest error reported by "getWarning()" 87 | public dropWarning(): void { 88 | this.warningMessages.shift(); 89 | } 90 | 91 | public addWarning(message: any): void { 92 | this.warningMessages.push(message); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /client/src/app/common/services/process-point-color.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { ProcessPointColorService } from './process-point-color.service'; 2 | 3 | describe(ProcessPointColorService.name, () => { 4 | let service: ProcessPointColorService; 5 | 6 | beforeEach(() => { 7 | service = new ProcessPointColorService(); 8 | }); 9 | 10 | it('should be created', () => { 11 | expect(service).toBeTruthy(); 12 | }); 13 | 14 | it('should return correct color', () => { 15 | expect(service.getProcessPointsColor(0, 120)).toEqual('#e60000'); 16 | expect(service.getProcessPointsColor(59, 120)).toEqual('#d2cf00'); 17 | expect(service.getProcessPointsColor(60, 120)).toEqual('#d2d200'); 18 | expect(service.getProcessPointsColor(120, 120)).toEqual('#00be00'); 19 | expect(() => service.getProcessPointsColor(0, 0)).toThrow(); 20 | expect(() => service.getProcessPointsColor(-5, 10)).toThrow(); 21 | expect(() => service.getProcessPointsColor(5, -10)).toThrow(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /client/src/app/common/services/process-point-color.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | @Injectable({ 4 | providedIn: 'root' 5 | }) 6 | export class ProcessPointColorService { 7 | constructor() { 8 | } 9 | 10 | public getProcessPointsColor(processPoints: number, maxProcessPoints: number): string { 11 | if (processPoints < 0 || maxProcessPoints <= 0) { 12 | throw new Error('Input values [' + processPoints + ' / ' + maxProcessPoints + '] out of range'); 13 | } 14 | 15 | const processPointRatio = processPoints / maxProcessPoints; 16 | 17 | // This makes red a bit lighter than green: Reduce color value the more points the task has 18 | const maxChannelValue = 230 - 40 * processPointRatio; 19 | 20 | const rValue = Math.min(maxChannelValue, maxChannelValue * 2 * (1 - processPointRatio)); 21 | const gValue = Math.min(maxChannelValue, maxChannelValue * 2 * processPointRatio); 22 | const bValue = 0; 23 | 24 | let r = Math.round(rValue).toString(16); 25 | let g = Math.round(gValue).toString(16); 26 | let b = Math.round(bValue).toString(16); 27 | 28 | if (r.length === 1) { 29 | r = '0' + r; 30 | } 31 | if (g.length === 1) { 32 | g = '0' + g; 33 | } 34 | if (b.length === 1) { 35 | b = '0' + b; 36 | } 37 | 38 | return '#' + r + g + b; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /client/src/app/common/services/shortcut.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { ShortcutService } from './shortcut.service'; 2 | import { EventManager } from '@angular/platform-browser'; 3 | 4 | describe(ShortcutService.name, () => { 5 | let service: ShortcutService; 6 | let eventManager: EventManager; 7 | let document: any; 8 | 9 | beforeEach(() => { 10 | eventManager = {} as EventManager; 11 | document = {} as Document; 12 | 13 | service = new ShortcutService(eventManager, document); 14 | }); 15 | 16 | it('should be created', () => { 17 | expect(service).toBeTruthy(); 18 | }); 19 | 20 | it('should call event dashboard', () => { 21 | eventManager.addEventListener = jest.fn(); 22 | 23 | service.add('shift.d').subscribe(); 24 | 25 | // @ts-ignore 26 | expect(eventManager.addEventListener).toHaveBeenCalledWith(window.document.documentElement, 'keydown.shift.d', expect.any(Function)); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /client/src/app/common/services/shortcut.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from '@angular/core'; 2 | import { Observable } from 'rxjs'; 3 | import { EventManager } from '@angular/platform-browser'; 4 | import { DOCUMENT } from '@angular/common'; 5 | 6 | @Injectable({ 7 | providedIn: 'root' 8 | }) 9 | export class ShortcutService { 10 | 11 | constructor(private eventManager: EventManager, 12 | @Inject(DOCUMENT) document: Document) { 13 | } 14 | 15 | // Ignore shortcuts on these node types 16 | private ignoredNodeNames = ['input', 'textarea', 'select', 'option']; 17 | 18 | /** 19 | * @param shortcutString A string defining the shortcut. E.g. "shift.d" should say that the "shift" and "d" key must be pressed. 20 | */ 21 | add(shortcutString: string): Observable { 22 | const eventString = `keydown.${shortcutString}`; 23 | 24 | return new Observable(observer => { 25 | const handler = (e: KeyboardEvent) => { 26 | if (e.target instanceof HTMLElement && !this.ignoredNodeNames.includes(e.target.nodeName.toLowerCase())) { 27 | e.preventDefault(); 28 | observer.next(); 29 | } 30 | }; 31 | 32 | const removeHandlerCallback = this.eventManager.addEventListener( 33 | document.documentElement, eventString, handler 34 | ); 35 | 36 | return () => { 37 | removeHandlerCallback(); 38 | }; 39 | }); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /client/src/app/common/services/websocket-client.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { WebsocketClientService } from './websocket-client.service'; 2 | import { CurrentUserService } from '../../user/current-user.service'; 3 | import { Observable, of } from 'rxjs'; 4 | import { User } from '../../user/user.material'; 5 | import { NgZone } from '@angular/core'; 6 | 7 | describe(WebsocketClientService.name, () => { 8 | let service: WebsocketClientService; 9 | let currentUserService: CurrentUserService; 10 | let ngZone: NgZone; 11 | 12 | beforeEach(() => { 13 | currentUserService = { 14 | onUserChanged: new Observable(), 15 | } as CurrentUserService; 16 | ngZone = {} as NgZone; 17 | ngZone.runOutsideAngular = jest.fn(); 18 | service = new WebsocketClientService(currentUserService, ngZone); 19 | }); 20 | 21 | it('should be created', () => { 22 | expect(service).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /client/src/app/common/unsubscriber.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, OnDestroy } from '@angular/core'; 2 | import { Subscription } from 'rxjs'; 3 | 4 | @Injectable({ 5 | providedIn: 'root' 6 | }) 7 | export class Unsubscriber implements OnDestroy { 8 | private subscriptions: Subscription[] = []; 9 | 10 | public unsubscribeLater(...subject: Subscription[]): void { 11 | this.subscriptions.push(...subject); 12 | } 13 | 14 | public ngOnDestroy(): void { 15 | this.subscriptions.forEach(s => s.unsubscribe()); 16 | this.subscriptions = []; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /client/src/app/config/config.provider.spec.ts: -------------------------------------------------------------------------------- 1 | import { ConfigProvider } from './config.provider'; 2 | import { Config } from './config'; 3 | 4 | describe(ConfigProvider.name, () => { 5 | let provider: ConfigProvider; 6 | 7 | beforeEach(() => { 8 | provider = new ConfigProvider(); 9 | }); 10 | 11 | it('should be created', () => { 12 | expect(provider).toBeTruthy(); 13 | }); 14 | 15 | it('should map all fields', () => { 16 | const config: Config = new Config(); 17 | config.sourceRepoUrl = 'http://my.repo/project'; 18 | config.maxTasksPerProject = 123; 19 | config.maxDescriptionLength = 234; 20 | config.testEnvironment = true; 21 | config.osmApiUrl = 'https://test.api.org'; 22 | expect(Object.keys(config).length).toEqual(5); // All fields filled 23 | 24 | provider.apply(config); 25 | 26 | expect(provider.sourceRepoUrl).toEqual(config.sourceRepoUrl); 27 | expect(provider.maxTasksPerProject).toEqual(config.maxTasksPerProject); 28 | expect(provider.maxDescriptionLength).toEqual(config.maxDescriptionLength); 29 | expect(provider.testEnvironment).toEqual(config.testEnvironment); 30 | expect(provider.osmApiUrl).toEqual(config.osmApiUrl); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /client/src/app/config/config.provider.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Config } from './config'; 3 | 4 | /** 5 | * This config provider is a simple class providing the configurations received from the server (s. the ConfigResolver class). These 6 | * configurations are used to ensure valid requests to the server (e.g. to not exceed the maximum length for a project description). 7 | */ 8 | @Injectable({ 9 | providedIn: 'root' 10 | }) 11 | export class ConfigProvider extends Config { 12 | constructor() { 13 | super(); 14 | } 15 | 16 | public apply(config: Config): void { 17 | this.sourceRepoUrl = config.sourceRepoUrl; 18 | this.maxTasksPerProject = config.maxTasksPerProject; 19 | this.maxDescriptionLength = config.maxDescriptionLength; 20 | this.testEnvironment = config.testEnvironment; 21 | this.osmApiUrl = config.osmApiUrl; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /client/src/app/config/config.resolver.spec.ts: -------------------------------------------------------------------------------- 1 | import { ConfigResolver } from './config.resolver'; 2 | import { ConfigProvider } from './config.provider'; 3 | import { HttpClient } from '@angular/common/http'; 4 | import { Config } from './config'; 5 | import { of } from 'rxjs'; 6 | import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; 7 | 8 | describe(ConfigResolver.name, () => { 9 | let service: ConfigResolver; 10 | let httpClient: HttpClient; 11 | let configProvider: ConfigProvider; 12 | 13 | beforeEach(() => { 14 | httpClient = {} as HttpClient; 15 | configProvider = {} as ConfigProvider; 16 | 17 | service = new ConfigResolver(httpClient, configProvider); 18 | }); 19 | 20 | it('should be created', () => { 21 | expect(service).toBeTruthy(); 22 | }); 23 | 24 | it('should apply config to config provider', done => { 25 | const configFromServer = { 26 | sourceRepoUrl: 'foo', 27 | maxTasksPerProject: 2, 28 | maxDescriptionLength: 3 29 | } as Config; 30 | httpClient.get = jest.fn().mockReturnValue(of(configFromServer)); 31 | const configProviderSpy = configProvider.apply = jest.fn(); 32 | 33 | service.resolve({} as ActivatedRouteSnapshot, {} as RouterStateSnapshot).subscribe(() => { 34 | expect(configProviderSpy).toHaveBeenCalledWith(configFromServer); 35 | done(); 36 | }); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /client/src/app/config/config.resolver.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { HttpClient } from '@angular/common/http'; 3 | import { Config } from './config'; 4 | import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; 5 | import { Observable } from 'rxjs'; 6 | import { environment } from '../../environments/environment'; 7 | import { ConfigProvider } from './config.provider'; 8 | import { tap } from 'rxjs/operators'; 9 | 10 | @Injectable({ 11 | providedIn: 'root' 12 | }) 13 | export class ConfigResolver implements Resolve { 14 | constructor(private httpClient: HttpClient, 15 | private configProvider: ConfigProvider) { 16 | } 17 | 18 | resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { 19 | return this.httpClient.get(environment.url_config).pipe(tap(config => { 20 | this.configProvider.apply(config); 21 | })); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /client/src/app/config/config.ts: -------------------------------------------------------------------------------- 1 | export class Config { 2 | public sourceRepoUrl = ''; 3 | public maxTasksPerProject = 0; 4 | public maxDescriptionLength = 0; 5 | public testEnvironment = false; 6 | public osmApiUrl = ''; 7 | } 8 | -------------------------------------------------------------------------------- /client/src/app/dashboard/dashboard.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |

{{'dashboard.welcome' | translate: {userName: userName} }}

4 |
5 |
6 | 7 | {{'language-selection' | translate}} 8 | 9 |
10 |
11 | 12 |
13 |
14 |
15 | 16 |
17 | 18 | 19 | 20 |
21 |
22 |
23 | 24 | 25 | -------------------------------------------------------------------------------- /client/src/app/dashboard/dashboard.component.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles.scss"; 2 | @import "../../icons.scss"; 3 | 4 | :host { 5 | height: 100%; 6 | display: flex; 7 | flex-direction: column; 8 | justify-content: space-between; 9 | } 10 | 11 | .root-container { 12 | max-width: 600px; 13 | margin-left: auto; 14 | margin-right: auto; 15 | margin-top: $space-large; 16 | margin-bottom: $space-large; 17 | } 18 | 19 | .toolbar-left { 20 | display: flex; 21 | flex-direction: row; 22 | } 23 | 24 | .language-selection { 25 | display: flex; 26 | flex-direction: row; 27 | align-items: center; 28 | } 29 | 30 | .language-selection-label { 31 | margin-right: $space-base; 32 | } 33 | 34 | .separator { 35 | margin-right: $space-large; 36 | margin-left: $space-large; 37 | border-left: solid 1px $color-gray-very-light; 38 | } 39 | 40 | .new-project-buttons-container { 41 | display: flex; 42 | 43 | .import-button { 44 | margin-left: $space-base; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /client/src/app/dashboard/dashboard.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { DashboardComponent } from './dashboard.component'; 2 | import { CurrentUserService } from '../user/current-user.service'; 3 | import { AuthService } from '../auth/auth.service'; 4 | import { MockBuilder, MockedComponentFixture, MockRender } from 'ng-mocks'; 5 | import { AppModule } from '../app.module'; 6 | import { TranslateService } from '@ngx-translate/core'; 7 | 8 | describe(DashboardComponent.name, () => { 9 | let component: DashboardComponent; 10 | let fixture: MockedComponentFixture; 11 | let currentUserService: CurrentUserService; 12 | let authService: AuthService; 13 | let translationService: TranslateService; 14 | 15 | beforeEach(() => { 16 | currentUserService = {} as CurrentUserService; 17 | currentUserService.getUserName = jest.fn(); 18 | authService = {} as AuthService; 19 | translationService = {} as TranslateService; 20 | 21 | return MockBuilder(DashboardComponent, AppModule) 22 | .provide({provide: CurrentUserService, useFactory: () => currentUserService}) 23 | .provide({provide: TranslateService, useFactory: () => translationService}) 24 | .provide({provide: AuthService, useFactory: () => authService}); 25 | }); 26 | 27 | beforeEach(() => { 28 | fixture = MockRender(DashboardComponent); 29 | component = fixture.point.componentInstance; 30 | fixture.detectChanges(); 31 | }); 32 | 33 | it('should create', () => { 34 | expect(component).toBeTruthy(); 35 | }); 36 | 37 | it('should get user name correctly', () => { 38 | localStorage.removeItem('auth_token'); 39 | expect(component.userName).toBeFalsy(); 40 | 41 | currentUserService.getUserName = jest.fn().mockReturnValue('test-user'); 42 | 43 | expect(component.userName).toEqual('test-user'); 44 | }); 45 | 46 | it('should logout correctly', () => { 47 | authService.logout = jest.fn(); 48 | 49 | component.onLogoutClicked(); 50 | 51 | expect(authService.logout).toHaveBeenCalled(); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /client/src/app/dashboard/dashboard.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { CurrentUserService } from '../user/current-user.service'; 3 | import { AuthService } from '../auth/auth.service'; 4 | import { Router } from '@angular/router'; 5 | import { ProjectExport } from '../project/project.material'; 6 | import { ProjectImportService } from '../project-creation/project-import.service'; 7 | import { NotificationService } from '../common/services/notification.service'; 8 | import { TranslateService } from '@ngx-translate/core'; 9 | 10 | @Component({ 11 | selector: 'app-dashboard', 12 | templateUrl: './dashboard.component.html', 13 | styleUrls: ['./dashboard.component.scss'] 14 | }) 15 | export class DashboardComponent { 16 | constructor( 17 | private router: Router, 18 | private authService: AuthService, 19 | private currentUserService: CurrentUserService, 20 | private projectImportService: ProjectImportService, 21 | private notificationService: NotificationService, 22 | private translationService: TranslateService 23 | ) { 24 | } 25 | 26 | public get userName(): string | undefined { 27 | return this.currentUserService.getUserName(); 28 | } 29 | 30 | public onLogoutClicked(): void { 31 | this.authService.logout(); 32 | } 33 | 34 | onImportProjectClicked(event: Event): void { 35 | this.uploadFile(event, (e) => this.addProjectExport(e)); 36 | } 37 | 38 | private addProjectExport(evt: Event): void { 39 | // @ts-ignore 40 | const project = JSON.parse(evt.target?.result) as ProjectExport; 41 | this.projectImportService.importProject(project); 42 | } 43 | 44 | private uploadFile(event: any, loadHandler: (evt: Event) => void): void { 45 | const reader = new FileReader(); 46 | const file = event.target.files[0]; 47 | 48 | reader.readAsText(file, 'UTF-8'); 49 | 50 | reader.onload = loadHandler; 51 | reader.onerror = (evt) => { 52 | console.error(evt); 53 | const message = this.translationService.instant('file-upload-error', {fileName: (evt.target as any).files[0]}); 54 | this.notificationService.addError(message); 55 | }; 56 | } 57 | 58 | public onUploadClicked(): void { 59 | document.getElementById('projectInput')?.click(); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /client/src/app/error-handler.ts: -------------------------------------------------------------------------------- 1 | import { ErrorHandler, Injectable } from '@angular/core'; 2 | import { NotificationService } from './common/services/notification.service'; 3 | 4 | @Injectable() 5 | export class GlobalErrorHandler implements ErrorHandler { 6 | 7 | constructor(private notificationService: NotificationService) { 8 | } 9 | 10 | handleError(error: any): void { 11 | this.notificationService.addError('Unexcpected error occured: ' + error); 12 | console.error(error); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /client/src/app/project-creation/copy-project/copy-project.component.html: -------------------------------------------------------------------------------- 1 |

{{'project-creation.copy' | translate}}

2 | 3 |
{{'project-creation.copy-notice' | translate}}
4 | 5 | 6 |

{{'project-creation.loading-projects' | translate}}

7 |
8 | 9 | 10 |
11 |

{{'project-creation.no-project-notice' | translate}}

12 |
13 |
14 |

{{'project-creation.available-projects' | translate}}

15 |
16 |
19 | {{p.name}} 20 |
21 |
22 | 24 |
25 |
26 | -------------------------------------------------------------------------------- /client/src/app/project-creation/copy-project/copy-project.component.scss: -------------------------------------------------------------------------------- 1 | @import '../../../styles'; 2 | 3 | .project-list { 4 | overflow-y: auto; 5 | } 6 | 7 | .project-list-row { 8 | display: flex; 9 | justify-content: space-between; 10 | } 11 | 12 | .import-button { 13 | margin-top: $space-base; 14 | } 15 | -------------------------------------------------------------------------------- /client/src/app/project-creation/copy-project/copy-project.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | import { Project } from '../../project/project.material'; 3 | import { ProjectService } from '../../project/project.service'; 4 | import { NotificationService } from '../../common/services/notification.service'; 5 | import { ProjectImportService } from '../project-import.service'; 6 | import { TranslateService } from '@ngx-translate/core'; 7 | 8 | @Component({ 9 | selector: 'app-copy-project', 10 | templateUrl: './copy-project.component.html', 11 | styleUrls: ['./copy-project.component.scss'] 12 | }) 13 | export class CopyProjectComponent { 14 | @Input() projects: Project[]; 15 | @Input() loading: boolean; 16 | 17 | public selectedProject: Project | undefined; 18 | 19 | constructor( 20 | private projectService: ProjectService, 21 | private notificationService: NotificationService, 22 | private projectImportService: ProjectImportService, 23 | private translateService: TranslateService 24 | ) { 25 | } 26 | 27 | onProjectClicked(project: Project): void { 28 | this.selectedProject = project !== this.selectedProject ? project : undefined; 29 | } 30 | 31 | onImportClicked(): void { 32 | if (!this.selectedProject) { 33 | return; 34 | } 35 | 36 | this.projectService 37 | .getProjectExport(this.selectedProject.id) 38 | .subscribe({ 39 | next: projectExport => { 40 | this.projectImportService.importProjectAsNewProject(projectExport); 41 | this.selectedProject = undefined; 42 | }, 43 | error: e => { 44 | console.error(e); 45 | const translationParams = {projectName: this.selectedProject?.name}; 46 | const message = this.translateService.instant('project-creation.could-not-import-project', translationParams); 47 | this.notificationService.addError(message); 48 | this.selectedProject = undefined; 49 | } 50 | }); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /client/src/app/project-creation/drawing-toolbar/drawing-toolbar.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/app/project-creation/drawing-toolbar/drawing-toolbar.component.scss: -------------------------------------------------------------------------------- 1 | @import '../../../styles'; 2 | @import '../../../icons'; 3 | 4 | :host { 5 | display: flex; 6 | flex-direction: column; 7 | } 8 | 9 | button { 10 | background-color: $color-white-transparent; 11 | width: $space-huge; 12 | height: $space-huge; 13 | padding: unset; 14 | margin-bottom: -1px; // Get rid of double border 15 | } 16 | 17 | button:hover { 18 | background-color: $color-very-light-transparent; 19 | } 20 | button:hover { 21 | z-index: 1; 22 | } 23 | 24 | button.selected:hover, 25 | .selected { 26 | border-left: 2px solid $color-mid; 27 | background-color: $color-very-light; 28 | } 29 | -------------------------------------------------------------------------------- /client/src/app/project-creation/project-creation/project-creation.component.scss: -------------------------------------------------------------------------------- 1 | @import '../../../styles'; 2 | @import '../../../icons'; 3 | 4 | :host { 5 | height: 100%; 6 | display: flex; 7 | flex-direction: column; 8 | } 9 | 10 | .toolbar-buttons-container { 11 | display: flex; 12 | } 13 | 14 | .root-container { 15 | display: flex; 16 | flex-direction: row; 17 | flex-grow: 1; 18 | min-height: 0px; // important for scrolling (for whatever reason) 19 | } 20 | 21 | .root-tabs, 22 | .root-form { 23 | height: 100%; 24 | } 25 | 26 | .tab-content { 27 | padding: $space-base; 28 | } 29 | 30 | .cancel-button-icon { 31 | margin-right: $space-small; 32 | } 33 | 34 | .project-properties-container { 35 | padding-top: $space-base; 36 | min-width: 350px; 37 | max-width: 450px; 38 | width: 50%; 39 | border-right: 1px solid $color-light; 40 | } 41 | 42 | // Also apply style to divs in child components using ::ng-deep 43 | .project-properties-container ::ng-deep .form-entry { 44 | margin-bottom: $space-base 45 | } 46 | 47 | .properties-label { 48 | margin-top: 0px; 49 | } 50 | 51 | .map-container { 52 | width: 100%; 53 | display: flex; 54 | flex-direction: column; 55 | position: relative; 56 | } 57 | 58 | .drawing-toolbar { 59 | position: absolute; 60 | z-index: 5; // Because of OpenLayers claiming some z-indices 61 | left: 0px; 62 | top: 0px; 63 | margin: $space-base; 64 | margin-top: 100px; // TODO move whole button menu into map 65 | } 66 | 67 | .zoom-control { 68 | margin-bottom: $space-base; 69 | } 70 | 71 | .map { 72 | height: 100%; 73 | width: 100%; 74 | } 75 | 76 | .save-button { 77 | margin-left: $space-base; 78 | } 79 | 80 | app-tabs { 81 | flex-grow: 1; 82 | } 83 | 84 | .tab-container { 85 | display: flex; 86 | flex-direction: column; 87 | min-height: 0px; // important for scrolling (for whatever reason, s. above) 88 | height: 100%; // To enable scrolling within the tab containers 89 | } 90 | 91 | .task-head { 92 | display: flex; 93 | flex-direction: row; 94 | justify-content: space-between; 95 | align-items: baseline; 96 | } 97 | -------------------------------------------------------------------------------- /client/src/app/project-creation/project-import.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { ProjectImportService } from './project-import.service'; 2 | import { TaskDraftService } from './task-draft.service'; 3 | import { ProjectService } from '../project/project.service'; 4 | import { NotificationService } from '../common/services/notification.service'; 5 | 6 | describe(ProjectImportService.name, () => { 7 | let service: ProjectImportService; 8 | let taskDraftService: TaskDraftService; 9 | let projectService: ProjectService; 10 | let notificationService: NotificationService; 11 | 12 | beforeEach(() => { 13 | taskDraftService = {} as TaskDraftService; 14 | projectService = {} as ProjectService; 15 | notificationService = {} as NotificationService; 16 | 17 | service = new ProjectImportService(taskDraftService, projectService, notificationService); 18 | }); 19 | 20 | it('should be created', () => { 21 | expect(service).toBeTruthy(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /client/src/app/project-creation/project-import/project-import.component.html: -------------------------------------------------------------------------------- 1 |

{{'project-creation.import-heading' | translate}}

2 | 3 |
{{'project-creation.import-notice' | translate}}
4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /client/src/app/project-creation/project-import/project-import.component.scss: -------------------------------------------------------------------------------- 1 | @import "../../../styles"; 2 | 3 | .import-button { 4 | margin-top: $space-base; 5 | } 6 | -------------------------------------------------------------------------------- /client/src/app/project-creation/project-import/project-import.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { ProjectExport } from '../../project/project.material'; 3 | import { ProjectImportService } from '../project-import.service'; 4 | import { NotificationService } from '../../common/services/notification.service'; 5 | import { TranslateService } from '@ngx-translate/core'; 6 | 7 | @Component({ 8 | selector: 'app-project-import', 9 | templateUrl: './project-import.component.html', 10 | styleUrls: ['./project-import.component.scss'] 11 | }) 12 | export class ProjectImportComponent { 13 | 14 | constructor( 15 | private notificationService: NotificationService, 16 | private projectImportService: ProjectImportService, 17 | private translationService: TranslateService 18 | ) { 19 | } 20 | 21 | public onProjectSelected(event: any): void { 22 | this.uploadFile(event, (e) => this.addProjectExport(e)); 23 | } 24 | 25 | public addProjectExport(evt: Event): void { 26 | if (!evt || !evt.target) { 27 | return; 28 | } 29 | 30 | // @ts-ignore 31 | const project = JSON.parse(evt.target.result) as ProjectExport; 32 | this.projectImportService.importProjectAsNewProject(project); 33 | } 34 | 35 | private uploadFile(event: any, loadHandler: (evt: Event) => void): void { 36 | const reader = new FileReader(); 37 | const file = event.target.files[0]; 38 | 39 | reader.readAsText(file, 'UTF-8'); 40 | 41 | reader.onload = loadHandler; 42 | reader.onerror = (evt) => { 43 | console.error(evt); 44 | const message = this.translationService.instant('file-upload-error', {fileName: (evt.target as any).files[0]}); 45 | this.notificationService.addError(message); 46 | }; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /client/src/app/project-creation/project-properties.ts: -------------------------------------------------------------------------------- 1 | import { JosmDataSource } from '../common/entities/josm-data-source'; 2 | 3 | export class ProjectProperties { 4 | constructor( 5 | public projectName: string, 6 | public maxProcessPoints: number, 7 | public projectDescription: string, 8 | public josmDataSource: JosmDataSource 9 | ) { 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /client/src/app/project-creation/project-properties/project-properties.component.html: -------------------------------------------------------------------------------- 1 |
2 | {{'name' | translate}} 3 | 4 |
5 |
6 | {{'project-creation.max-points-label' | translate}} 7 | 12 |
13 |
14 | {{'project-creation.josm-data-source-label' | translate}} 15 | 21 |
22 |
23 |
24 |

{{'description' | translate}}

25 | {{descriptionInput.textLength}} / {{config.maxDescriptionLength}} 26 |
27 | 31 |
32 | -------------------------------------------------------------------------------- /client/src/app/project-creation/project-properties/project-properties.component.scss: -------------------------------------------------------------------------------- 1 | @import "../../../styles"; 2 | 3 | .description-text-area { 4 | width: 100%; 5 | height: 120px; 6 | } 7 | 8 | .description-text-area-head { 9 | display: flex; 10 | flex-direction: row; 11 | justify-content: space-between; 12 | align-items: baseline; 13 | } 14 | -------------------------------------------------------------------------------- /client/src/app/project-creation/project-properties/project-properties.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ProjectPropertiesComponent } from './project-properties.component'; 2 | import { ProjectProperties } from '../project-properties'; 3 | import { MockBuilder, MockedComponentFixture, MockRender } from 'ng-mocks'; 4 | import { AppModule } from '../../app.module'; 5 | 6 | describe(ProjectPropertiesComponent.name, () => { 7 | let component: ProjectPropertiesComponent; 8 | let fixture: MockedComponentFixture; 9 | 10 | beforeEach(() => MockBuilder(ProjectPropertiesComponent, AppModule)); 11 | 12 | beforeEach(() => { 13 | fixture = MockRender(ProjectPropertiesComponent, {projectProperties: new ProjectProperties('', 100, '', 'OSM')}); 14 | component = fixture.point.componentInstance; 15 | fixture.detectChanges(); 16 | }); 17 | 18 | it('should create', () => { 19 | expect(component).toBeTruthy(); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /client/src/app/project-creation/project-properties/project-properties.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | import { ProjectProperties } from '../project-properties'; 3 | import { ControlContainer, NgForm } from '@angular/forms'; 4 | import { ConfigProvider } from '../../config/config.provider'; 5 | 6 | @Component({ 7 | selector: 'app-project-properties', 8 | templateUrl: './project-properties.component.html', 9 | styleUrls: ['./project-properties.component.scss'], 10 | viewProviders: [{provide: ControlContainer, useExisting: NgForm}] 11 | }) 12 | export class ProjectPropertiesComponent { 13 | @Input() projectProperties: ProjectProperties; 14 | 15 | constructor(public config: ConfigProvider) { 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /client/src/app/project-creation/shape-divide/shape-divide.component.scss: -------------------------------------------------------------------------------- 1 | @import '../../../styles'; 2 | @import '../../../icons'; 3 | 4 | .space-between { 5 | justify-content: space-between; 6 | } 7 | 8 | .meter-input { 9 | min-width: 0px; 10 | width: 100%; 11 | } 12 | 13 | .meter-label, 14 | .divide-button { 15 | margin-left: $space-base; 16 | } 17 | 18 | .lnr-warning { 19 | margin-right: $space-small; 20 | } 21 | 22 | button.selected:hover, 23 | .selected { 24 | border-left: 2px solid $color-mid; 25 | background-color: $color-lighter; 26 | } -------------------------------------------------------------------------------- /client/src/app/project-creation/shape-remote/shape-remote.component.html: -------------------------------------------------------------------------------- 1 |

{{'project-creation.remote-heading' | translate}}

2 | 3 |
{{'project-creation.url-file-format-notice' | translate}}
4 | 5 | 6 | 7 |
8 | 9 |

{{'project-creation.overpass-heading' | translate}}

10 | 11 |

{{'project-creation.loading-title' | translate}}

12 |
    13 |
  1. {{'project-creation.load-li-1' | translate}}
  2. 14 |
  3. {{'project-creation.load-li-2' | translate}}
  4. 15 |
  5. {{'project-creation.load-li-3' | translate}}
  6. 16 |
  7. {{'project-creation.load-li-4' | translate}}
  8. 17 |
18 | -------------------------------------------------------------------------------- /client/src/app/project-creation/shape-remote/shape-remote.component.scss: -------------------------------------------------------------------------------- 1 | @import "../../../styles"; 2 | 3 | .url-input-heading { 4 | margin-bottom: $space-base; 5 | } 6 | 7 | .url-input { 8 | width: 100%; 9 | margin-bottom: $space-base; 10 | } 11 | -------------------------------------------------------------------------------- /client/src/app/project-creation/shape-remote/shape-remote.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { HttpClient } from '@angular/common/http'; 3 | import { NotificationService } from '../../common/services/notification.service'; 4 | import { GeometryService } from '../../common/services/geometry.service'; 5 | import { LoadingService } from '../../common/services/loading.service'; 6 | import { TaskDraftService } from '../task-draft.service'; 7 | import { TranslateService } from '@ngx-translate/core'; 8 | 9 | @Component({ 10 | selector: 'app-shape-remote', 11 | templateUrl: './shape-remote.component.html', 12 | styleUrls: ['./shape-remote.component.scss'] 13 | }) 14 | export class ShapeRemoteComponent { 15 | public queryUrl: string; 16 | 17 | constructor( 18 | private http: HttpClient, 19 | private notificationService: NotificationService, 20 | private geometryService: GeometryService, 21 | private loadingService: LoadingService, 22 | private taskDraftService: TaskDraftService, 23 | private translationService: TranslateService 24 | ) { 25 | } 26 | 27 | onLoadButtonClicked(): void { 28 | this.loadingService.start(); 29 | 30 | this.http.get(this.queryUrl, {responseType: 'text'}).subscribe( 31 | data => { 32 | this.loadingService.end(); 33 | 34 | const features = this.geometryService.parseData(data); 35 | 36 | if (!!features && features.length !== 0) { 37 | this.taskDraftService.addTasks(features.map(f => this.taskDraftService.toTaskDraft(f))); 38 | } else { 39 | this.notificationService.addError(this.translationService.instant('feature-upload-error')); 40 | } 41 | }, e => { 42 | this.loadingService.end(); 43 | console.error('Error loading data from remote URL'); 44 | console.error(e); 45 | this.notificationService.addError(this.translationService.instant('project-creation.remote-url-error')); 46 | }); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /client/src/app/project-creation/shape-upload/shape-upload.component.html: -------------------------------------------------------------------------------- 1 |

{{'project-creation.upload' | translate}}

2 | 3 |
{{'project-creation.upload-notice' | translate}}
4 | 5 | 7 | 9 | -------------------------------------------------------------------------------- /client/src/app/project-creation/shape-upload/shape-upload.component.scss: -------------------------------------------------------------------------------- 1 | @import "../../../styles"; 2 | 3 | .upload-button { 4 | margin-top: $space-base; 5 | } 6 | -------------------------------------------------------------------------------- /client/src/app/project-creation/shape-upload/shape-upload.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { NotificationService } from '../../common/services/notification.service'; 3 | import { GeometryService } from '../../common/services/geometry.service'; 4 | import { TaskDraftService } from '../task-draft.service'; 5 | import { TranslateService } from '@ngx-translate/core'; 6 | 7 | @Component({ 8 | selector: 'app-shape-upload', 9 | templateUrl: './shape-upload.component.html', 10 | styleUrls: ['./shape-upload.component.scss'] 11 | }) 12 | export class ShapeUploadComponent { 13 | constructor( 14 | private notificationService: NotificationService, 15 | private geometryService: GeometryService, 16 | private taskDraftService: TaskDraftService, 17 | private translationService: TranslateService 18 | ) { 19 | } 20 | 21 | public onFileSelected(event: any): void { 22 | this.uploadFile(event, (e) => this.addTasks(e)); 23 | } 24 | 25 | public addTasks(evt: Event): void { 26 | if (!evt || !evt.target) { 27 | return; 28 | } 29 | 30 | try { 31 | // @ts-ignore 32 | const features = this.geometryService.parseData(evt.target.result); 33 | 34 | if (!!features && features.length !== 0) { 35 | this.taskDraftService.addTasks(features.map(f => this.taskDraftService.toTaskDraft(f))); 36 | } else { 37 | this.notificationService.addError(this.translationService.instant('feature-upload-error')); 38 | } 39 | } catch (e) { 40 | this.notificationService.addError('Error: ' + e); 41 | } 42 | } 43 | 44 | private uploadFile(event: any, loadHandler: (evt: Event) => void): void { 45 | const reader = new FileReader(); 46 | const file = event.target.files[0]; 47 | 48 | reader.readAsText(file, 'UTF-8'); 49 | 50 | reader.onload = loadHandler; 51 | reader.onerror = (evt) => { 52 | console.error(evt); 53 | const message = this.translationService.instant('file-upload-error', {fileName: (evt.target as any).files[0]}); 54 | this.notificationService.addError(message); 55 | }; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /client/src/app/project-creation/task-draft-list/task-draft-list.component.html: -------------------------------------------------------------------------------- 1 |
2 | {{'project-creation.no-task-yet-notice' | translate}} 3 |
4 |
7 |
8 | {{t.name}} 9 |
10 |
11 | -------------------------------------------------------------------------------- /client/src/app/project-creation/task-draft-list/task-draft-list.component.scss: -------------------------------------------------------------------------------- 1 | @import '../../../styles'; 2 | 3 | :host { 4 | overflow-y: auto; 5 | } 6 | -------------------------------------------------------------------------------- /client/src/app/project-creation/task-draft-list/task-draft-list.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { TaskDraftListComponent } from './task-draft-list.component'; 2 | import { TaskDraftService } from '../task-draft.service'; 3 | import { MockBuilder, MockedComponentFixture, MockRender } from 'ng-mocks'; 4 | import { AppModule } from '../../app.module'; 5 | 6 | describe(TaskDraftListComponent.name, () => { 7 | let component: TaskDraftListComponent; 8 | let fixture: MockedComponentFixture; 9 | let taskDraftService: TaskDraftService; 10 | 11 | beforeEach(() => { 12 | taskDraftService = {} as TaskDraftService; 13 | 14 | return MockBuilder(TaskDraftListComponent, AppModule) 15 | .provide({provide: TaskDraftService, useFactory: () => taskDraftService}); 16 | }); 17 | 18 | beforeEach(() => { 19 | fixture = MockRender(TaskDraftListComponent); 20 | component = fixture.point.componentInstance; 21 | fixture.detectChanges(); 22 | }); 23 | 24 | it('should create', () => { 25 | expect(component).toBeTruthy(); 26 | }); 27 | 28 | it('should select task on service', () => { 29 | taskDraftService.selectTask = jest.fn(); 30 | 31 | component.onTaskClicked('123'); 32 | 33 | expect(taskDraftService.selectTask).toHaveBeenCalledWith('123'); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /client/src/app/project-creation/task-draft-list/task-draft-list.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | import { TaskDraft } from '../../task/task.material'; 3 | import { TaskDraftService } from '../task-draft.service'; 4 | 5 | @Component({ 6 | selector: 'app-task-draft-list', 7 | templateUrl: './task-draft-list.component.html', 8 | styleUrls: ['./task-draft-list.component.scss'] 9 | }) 10 | export class TaskDraftListComponent { 11 | @Input() public tasks: TaskDraft[]; 12 | @Input() public selectedTask: TaskDraft | undefined; 13 | 14 | constructor( 15 | private taskDraftService: TaskDraftService 16 | ) { 17 | } 18 | 19 | public onTaskClicked(id: string): void { 20 | this.taskDraftService.selectTask(id); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /client/src/app/project-creation/task-edit/task-edit.component.html: -------------------------------------------------------------------------------- 1 |
2 | {{'name' | translate}} 3 | 4 |
5 | -------------------------------------------------------------------------------- /client/src/app/project-creation/task-edit/task-edit.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hauke96/simple-task-manager/03829aa290467d925e3d52262899a2a6a51819a4/client/src/app/project-creation/task-edit/task-edit.component.scss -------------------------------------------------------------------------------- /client/src/app/project-creation/task-edit/task-edit.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { TaskEditComponent } from './task-edit.component'; 2 | import { TaskDraftService } from '../task-draft.service'; 3 | import { TaskDraft } from '../../task/task.material'; 4 | import { Polygon } from 'ol/geom'; 5 | import { MockBuilder, MockedComponentFixture, MockRender } from 'ng-mocks'; 6 | import { AppModule } from '../../app.module'; 7 | 8 | describe(TaskEditComponent.name, () => { 9 | let component: TaskEditComponent; 10 | let fixture: MockedComponentFixture; 11 | let taskDraftService: TaskDraftService; 12 | 13 | beforeEach(() => { 14 | taskDraftService = {} as TaskDraftService; 15 | 16 | return MockBuilder(TaskEditComponent, AppModule) 17 | .provide({provide: TaskDraftService, useFactory: () => taskDraftService}); 18 | }); 19 | 20 | beforeEach(() => { 21 | fixture = MockRender(TaskEditComponent, {task: {}}); 22 | component = fixture.point.componentInstance; 23 | fixture.detectChanges(); 24 | }); 25 | 26 | it('should create', () => { 27 | expect(component).toBeTruthy(); 28 | }); 29 | 30 | it('should call rename function on service', () => { 31 | taskDraftService.changeTaskName = jest.fn(); 32 | 33 | component.task = new TaskDraft('123', 'some name', new Polygon([]), 0); 34 | component.onTaskNameChanged({target: {value: 'new name'} as unknown as EventTarget} as Event); 35 | 36 | expect(taskDraftService.changeTaskName).toHaveBeenCalledWith('123', 'new name'); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /client/src/app/project-creation/task-edit/task-edit.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, OnInit } from '@angular/core'; 2 | import { TaskDraft } from '../../task/task.material'; 3 | import { TaskDraftService } from '../task-draft.service'; 4 | 5 | @Component({ 6 | selector: 'app-task-edit', 7 | templateUrl: './task-edit.component.html', 8 | styleUrls: ['./task-edit.component.scss'] 9 | }) 10 | export class TaskEditComponent implements OnInit { 11 | @Input() task: TaskDraft; 12 | 13 | constructor( 14 | private taskDraftService: TaskDraftService 15 | ) { 16 | } 17 | 18 | ngOnInit(): void { 19 | } 20 | 21 | onTaskNameChanged(evt: Event): void { 22 | const taskId = this.task.id; 23 | if (!taskId) { 24 | return; 25 | } 26 | 27 | // @ts-ignore 28 | this.taskDraftService.changeTaskName(taskId, evt.target?.value); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /client/src/app/project/all-projects.resolver.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; 3 | import { Project } from './project.material'; 4 | import { ProjectService } from './project.service'; 5 | import { Observable } from 'rxjs'; 6 | import { catchError } from 'rxjs/operators'; 7 | import { HttpErrorResponse } from '@angular/common/http'; 8 | import { NotificationService } from '../common/services/notification.service'; 9 | import { TranslateService } from '@ngx-translate/core'; 10 | 11 | @Injectable({providedIn: 'root'}) 12 | export class AllProjectsResolver implements Resolve { 13 | constructor( 14 | private projectService: ProjectService, 15 | private notificationService: NotificationService, 16 | private translationService: TranslateService 17 | ) { 18 | } 19 | 20 | resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { 21 | return this.projectService.getProjects().pipe( 22 | catchError((e: HttpErrorResponse) => { 23 | this.notificationService.addError(this.translationService.instant('project.could-not-load-projects')); 24 | throw e; 25 | }) 26 | ); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /client/src/app/project/project-list/project-list.component.html: -------------------------------------------------------------------------------- 1 |

{{'project-list.projects' | translate}}

2 |
3 |
4 | {{p.name}} 5 |
6 | ({{p.doneProcessPoints}} / {{p.totalProcessPoints}}) 7 | 8 |
9 | * 10 |
11 |
12 |
13 |
14 | 15 |
16 |

{{'project-list.no-project-notice' | translate}}

17 |
18 | 19 | {{'project-list.you-own-this-project-notice' | translate}} 20 | -------------------------------------------------------------------------------- /client/src/app/project/project-list/project-list.component.scss: -------------------------------------------------------------------------------- 1 | @import "../../../styles"; 2 | 3 | :host { 4 | display: flex; 5 | flex-direction: column; 6 | } 7 | 8 | .list-item { 9 | display: flex; 10 | justify-content: space-between; 11 | } 12 | 13 | .list-item-column { 14 | display: flex; 15 | align-items: center; 16 | } 17 | 18 | .no-wrap { 19 | white-space: nowrap; 20 | } 21 | 22 | .light-label { 23 | color: $color-gray-mid; 24 | } 25 | 26 | .progress-bar { 27 | width: 180px; 28 | justify-content: end; 29 | margin-right: $space-small; 30 | } 31 | 32 | .admin-label { 33 | min-width: 10px; 34 | } 35 | 36 | .ownership-notice { 37 | margin-top: $space-base; 38 | align-self: end; 39 | } 40 | -------------------------------------------------------------------------------- /client/src/app/project/project-settings/project-settings.component.scss: -------------------------------------------------------------------------------- 1 | @import "../../../styles"; 2 | @import "../../../icons"; 3 | 4 | .export-project-button, .save-button { 5 | margin-top: $space-base; 6 | } 7 | 8 | .space-right { 9 | margin-right: $space-base; 10 | } 11 | 12 | .description-text-area { 13 | width: 100%; 14 | height: 120px; 15 | } 16 | 17 | .name-input { 18 | width: 100%; 19 | } 20 | 21 | .josm-data-source-input { 22 | width: 100%; 23 | } 24 | 25 | // Also apply style to divs in child components using ::ng-deep 26 | .project-properties-container ::ng-deep .form-entry { 27 | margin-bottom: $space-base 28 | } 29 | -------------------------------------------------------------------------------- /client/src/app/project/project.resolver.ts: -------------------------------------------------------------------------------- 1 | import { inject } from '@angular/core'; 2 | import { ActivatedRouteSnapshot, ResolveFn } from '@angular/router'; 3 | import { Project } from './project.material'; 4 | import { ProjectService } from './project.service'; 5 | import { of } from 'rxjs'; 6 | import { catchError } from 'rxjs/operators'; 7 | import { HttpErrorResponse } from '@angular/common/http'; 8 | import { NotificationService } from '../common/services/notification.service'; 9 | import { TranslateService } from '@ngx-translate/core'; 10 | 11 | export const projectResolver: ResolveFn = (route: ActivatedRouteSnapshot, _) => { 12 | const projectService = inject(ProjectService); 13 | const notificationService = inject(NotificationService); 14 | const translateService = inject(TranslateService); 15 | 16 | if (!route.paramMap.has('id')) { 17 | return of(); 18 | } 19 | 20 | // @ts-ignore 21 | return projectService.getProject(route.paramMap.get('id')).pipe( 22 | catchError((e: HttpErrorResponse) => { 23 | const message = translateService.instant('project.could-not-load-project', {projectId: route.paramMap.get('id')}); 24 | notificationService.addError(message); 25 | throw e; 26 | }) 27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /client/src/app/task/task-details/task-details.component.html: -------------------------------------------------------------------------------- 1 |
2 |

{{'task-details.title' | translate}} {{task | taskTitle}}

3 |
4 | 5 | 6 |
7 |
8 | 9 | 10 |
11 |
12 | {{'task-details.points' | translate}} 13 | 20 | / {{task?.maxProcessPoints}} 21 |
22 | 23 | 24 |
25 |
26 | 27 | 31 | 32 |
33 | 34 | 35 |
36 | 37 | 38 |
39 | -------------------------------------------------------------------------------- /client/src/app/task/task-details/task-details.component.scss: -------------------------------------------------------------------------------- 1 | @import "../../../styles"; 2 | 3 | .assign-container { 4 | display: flex; 5 | flex-direction: row; 6 | justify-content: space-between; 7 | } 8 | 9 | .assign-button-container { 10 | display: flex; 11 | flex-direction: row; 12 | align-items: center; 13 | } 14 | 15 | .process-point-container { 16 | display: flex; 17 | align-items: flex-start; 18 | height: 40px; // To stay equally high, regardless of what the content is 19 | margin-bottom: $space-base; 20 | } 21 | 22 | .task-metadata-container { 23 | display: flex; 24 | flex-direction: column; 25 | } 26 | 27 | .process-point-input { 28 | max-width: 70px; 29 | } 30 | 31 | .of-process-point-label { 32 | margin-left: $space-base; 33 | white-space: nowrap; 34 | } 35 | 36 | .save-button-row { 37 | display: flex; 38 | flex-direction: row; 39 | justify-content: flex-end; 40 | width: 100%; 41 | } 42 | 43 | .save-button, 44 | .assign-button-container > button { 45 | margin-left: $space-large; 46 | } 47 | 48 | .open-button-row { 49 | margin-top: $space-large; 50 | } 51 | 52 | .open-osm-button, 53 | .done-button { 54 | margin-left: $space-base; 55 | } 56 | -------------------------------------------------------------------------------- /client/src/app/task/task-list/task-list.component.html: -------------------------------------------------------------------------------- 1 |

{{ 'tasks' | translate }}

2 | 3 |
4 |
5 |
6 |
7 | {{ t | taskTitle }} 8 | ({{ t.processPoints }} / {{ t.maxProcessPoints }}) 9 | {{ 'done-button' | translate }} 10 |
11 | {{ t.assignedUser.name }} 13 | {{ 'task-list.you' | translate }} 14 |
15 | 16 |
17 |
18 | -------------------------------------------------------------------------------- /client/src/app/task/task-list/task-list.component.scss: -------------------------------------------------------------------------------- 1 | @import "../../../styles"; 2 | @import "../../../icons"; 3 | 4 | .list-container { 5 | overflow-y: auto; 6 | } 7 | 8 | .list-item-container { 9 | display: flex; 10 | flex-direction: row; 11 | justify-content: space-between; 12 | } 13 | 14 | .list-item { 15 | display: flex; 16 | flex-direction: row; 17 | } 18 | 19 | .list-item-inner { 20 | display: flex; 21 | flex-direction: row; 22 | justify-content: space-between; 23 | flex-grow: 1; 24 | margin-right: $space-base; 25 | } 26 | 27 | .list-item-comment { 28 | align-self: center; 29 | height: $space-base * 2.5; 30 | line-height: $space-base * 2.2; // Move icon a bit more up so it's visually centered 31 | border: 1px solid transparent; 32 | color: $color-gray-dark; 33 | padding-left: $space-base; 34 | padding-right: $space-base; 35 | } 36 | 37 | .list-item-comment:hover { 38 | border: 1px solid $color-light; 39 | border-radius: $space-base * 1.25; 40 | color: black; 41 | cursor: pointer; 42 | } 43 | 44 | .selected { 45 | .list-item-comment:hover { 46 | border: 1px solid $color-light; 47 | border-radius: $space-base * 1.25; 48 | } 49 | } 50 | 51 | .selected { 52 | background-color: $color-very-light; 53 | } 54 | 55 | .done-label { 56 | color: green; 57 | margin-left: $space-base; 58 | } 59 | -------------------------------------------------------------------------------- /client/src/app/task/task-map/task-map.component.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /client/src/app/task/task-map/task-map.component.scss: -------------------------------------------------------------------------------- 1 | .map { 2 | height: 100%; 3 | } 4 | 5 | .map-container { 6 | display: flex; 7 | flex-direction: column; 8 | flex-grow: 1; 9 | } 10 | -------------------------------------------------------------------------------- /client/src/app/task/task-title.pipe.spec.ts: -------------------------------------------------------------------------------- 1 | import { TaskTitlePipe } from './task-title.pipe'; 2 | import { Task } from './task.material'; 3 | import { Feature } from 'ol'; 4 | 5 | describe(TaskTitlePipe.name, () => { 6 | let pipe: TaskTitlePipe; 7 | 8 | beforeEach(() => { 9 | pipe = new TaskTitlePipe(); 10 | }); 11 | 12 | it('create an instance', () => { 13 | expect(pipe).toBeTruthy(); 14 | }); 15 | 16 | it('should return empty string on falsy value', () => { 17 | // @ts-ignore 18 | expect(pipe.transform(undefined)).toEqual(''); 19 | // @ts-ignore 20 | expect(pipe.transform(null)).toEqual(''); 21 | }); 22 | 23 | it('should return correct title', () => { 24 | expect(pipe.transform(new Task('123', '', 0, 0, new Feature(), []))).toEqual('123'); 25 | // @ts-ignore 26 | expect(pipe.transform(new Task(undefined, 'bar', 0, 0, new Feature(), []))).toEqual('bar'); 27 | expect(pipe.transform(new Task('234', 'foo', 0, 0, new Feature(), []))).toEqual('foo'); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /client/src/app/task/task-title.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, PipeTransform } from '@angular/core'; 2 | import { Task } from './task.material'; 3 | 4 | @Pipe({ 5 | name: 'taskTitle' 6 | }) 7 | export class TaskTitlePipe implements PipeTransform { 8 | transform(value: Task | undefined, ...args: unknown[]): unknown { 9 | if (!value) { 10 | return ''; 11 | } 12 | 13 | const task = value ; 14 | return !task.name ? task.id : task.name; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /client/src/app/task/task.material.spec.ts: -------------------------------------------------------------------------------- 1 | import { Task, TestTaskFeature } from './task.material'; 2 | import { User } from '../user/user.material'; 3 | 4 | describe(Task.name, () => { 5 | it('should create an instance without assigned User', () => { 6 | expect(new Task('t-0', '', 0, 100, TestTaskFeature, [])).toBeTruthy(); 7 | }); 8 | 9 | it('should create an instance', () => { 10 | expect(new Task('t-0', '', 0, 100, TestTaskFeature, [], new User('peter', '2'))).toBeTruthy(); 11 | }); 12 | 13 | it('should determine done state correctly', () => { 14 | expect(new Task('t-0', '', 0, 100, TestTaskFeature, [], new User('peter', '2')).isDone).toEqual(false); 15 | expect(new Task('t-1', '', 50, 100, TestTaskFeature, [], new User('peter', '2')).isDone).toEqual(false); 16 | expect(new Task('t-2', '', 99, 100, TestTaskFeature, [], new User('peter', '2')).isDone).toEqual(false); 17 | expect(new Task('t-3', '', 100, 100, TestTaskFeature, [], new User('peter', '2')).isDone).toEqual(true); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /client/src/app/task/task.material.ts: -------------------------------------------------------------------------------- 1 | import { Feature } from 'ol'; 2 | import { Geometry, Polygon } from 'ol/geom'; 3 | import { User } from '../user/user.material'; 4 | import { Comment, CommentDto } from '../comments/comment.material'; 5 | 6 | export class TaskDraftDto { 7 | /** 8 | * @param maxProcessPoints Amount of process points to complete this task. 9 | * @param geometry Polygon feature encoded as GeoJSON. 10 | */ 11 | constructor( 12 | public maxProcessPoints: number, 13 | public processPoints: number, 14 | public geometry: string 15 | ) { 16 | } 17 | } 18 | 19 | export class TaskDraft { 20 | constructor( 21 | public id: string, 22 | public name: string, 23 | public geometry: Geometry, 24 | public processPoints: number 25 | ) { 26 | } 27 | } 28 | 29 | export class TaskDto { 30 | constructor( 31 | public id: string, 32 | public processPoints: number, 33 | public maxProcessPoints: number, 34 | public geometry: string, 35 | public comments: CommentDto[], 36 | public assignedUser?: string, 37 | public assignedUserName?: string 38 | ) { 39 | } 40 | } 41 | 42 | export class Task { 43 | constructor( 44 | public id: string, 45 | public name: string, 46 | public processPoints: number, 47 | public maxProcessPoints: number, 48 | public geometry: Feature, 49 | public comments: Comment[], 50 | public assignedUser?: User 51 | ) { 52 | } 53 | 54 | public get isDone(): boolean { 55 | return this.processPoints === this.maxProcessPoints; 56 | } 57 | } 58 | 59 | export class TaskExport { 60 | constructor( 61 | public name: string, 62 | public processPoints: number, 63 | public maxProcessPoints: number, 64 | public geometry: string, 65 | public assignedUser?: string) { 66 | } 67 | } 68 | 69 | export const TestTaskFeature = new Feature(new Polygon([[[0, 0], [1, 1], [1, 2]]])); 70 | export const TestTaskGeometry = '{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[0, 0], [1, 1], [1, 2]]]},"properties":null}'; 71 | -------------------------------------------------------------------------------- /client/src/app/task/task.resolver.ts: -------------------------------------------------------------------------------- 1 | import { inject } from '@angular/core'; 2 | import { ActivatedRouteSnapshot, ResolveFn } from '@angular/router'; 3 | import { of } from 'rxjs'; 4 | import { catchError } from 'rxjs/operators'; 5 | import { HttpErrorResponse } from '@angular/common/http'; 6 | import { NotificationService } from '../common/services/notification.service'; 7 | import { TranslateService } from '@ngx-translate/core'; 8 | import { TaskService } from './task.service'; 9 | import { Task } from './task.material'; 10 | 11 | export const taskResolver: ResolveFn = (route: ActivatedRouteSnapshot, _) => { 12 | const taskService = inject(TaskService); 13 | const notificationService = inject(NotificationService); 14 | const translateService = inject(TranslateService); 15 | 16 | if (!route.paramMap.has('id')) { 17 | return of(); 18 | } 19 | 20 | // @ts-ignore 21 | return taskService.getTask(route.paramMap.get('id')).pipe( 22 | catchError((e: HttpErrorResponse) => { 23 | const message = translateService.instant('task.could-not-load-task', {taskId: route.paramMap.get('id')}); 24 | notificationService.addError(message); 25 | throw e; 26 | }) 27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /client/src/app/ui/footer/footer.component.html: -------------------------------------------------------------------------------- 1 |
2 | -------------------------------------------------------------------------------- /client/src/app/ui/footer/footer.component.scss: -------------------------------------------------------------------------------- 1 | @import "../../../styles"; 2 | 3 | .version-label { 4 | margin: $space-large; 5 | } 6 | 7 | .sep { 8 | margin-left: $space-large; 9 | margin-right: $space-large; 10 | border-right: 1px solid $color-gray-mid; 11 | } 12 | -------------------------------------------------------------------------------- /client/src/app/ui/footer/footer.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, ViewEncapsulation } from '@angular/core'; 2 | import packageInfo from '../../../../package.json'; 3 | 4 | @Component({ 5 | selector: 'app-footer', 6 | templateUrl: './footer.component.html', 7 | styleUrls: ['./footer.component.scss'], 8 | encapsulation: ViewEncapsulation.None 9 | }) 10 | export class FooterComponent implements OnInit { 11 | public version: string = packageInfo.version; 12 | 13 | constructor() { 14 | } 15 | 16 | ngOnInit(): void { 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /client/src/app/ui/icon-button/icon-button.component.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/app/ui/icon-button/icon-button.component.scss: -------------------------------------------------------------------------------- 1 | @use "sass:math"; 2 | @import "../../../styles"; 3 | @import "../../../colors"; 4 | @import "../../../icons"; 5 | 6 | :host { 7 | display: inline-block; 8 | } 9 | 10 | .button { 11 | display: flex; 12 | align-items: center; 13 | 14 | .lnr { 15 | margin-right: $space-base; 16 | } 17 | } -------------------------------------------------------------------------------- /client/src/app/ui/icon-button/icon-button.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, EventEmitter, Input, Output } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-icon-button', 5 | templateUrl: './icon-button.component.html', 6 | styleUrls: ['./icon-button.component.scss'] 7 | }) 8 | export class IconButtonComponent { 9 | @Input() 10 | public icon: string; 11 | 12 | @Input() 13 | public textKey: string; 14 | 15 | @Input() 16 | public disabled = false; 17 | 18 | @Output() 19 | public clicked = new EventEmitter(); 20 | } 21 | -------------------------------------------------------------------------------- /client/src/app/ui/language-selection/language-selection.component.html: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /client/src/app/ui/language-selection/language-selection.component.scss: -------------------------------------------------------------------------------- 1 | @import "../../../styles"; 2 | 3 | .selection { 4 | width: 120px; 5 | } 6 | -------------------------------------------------------------------------------- /client/src/app/ui/language-selection/language-selection.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { LanguageSelectionComponent } from './language-selection.component'; 2 | import { LanguageService } from '../../common/services/language.service'; 3 | import { Language } from '../../common/entities/language'; 4 | import { MockBuilder, MockedComponentFixture, MockRender } from 'ng-mocks'; 5 | import { AppModule } from '../../app.module'; 6 | import { ActivatedRoute } from '@angular/router'; 7 | 8 | describe(LanguageSelectionComponent.name, () => { 9 | let component: LanguageSelectionComponent; 10 | let fixture: MockedComponentFixture; 11 | let languageService: LanguageService; 12 | 13 | beforeEach(async () => { 14 | languageService = {} as LanguageService; 15 | languageService.getKnownLanguages = jest.fn(); 16 | languageService.getSelectedLanguage = jest.fn(); 17 | const activatedRoute = {} as unknown as ActivatedRoute; 18 | 19 | return MockBuilder(LanguageSelectionComponent, AppModule) 20 | .provide({provide: ActivatedRoute, useFactory: () => activatedRoute}) 21 | .provide({provide: LanguageService, useFactory: () => languageService}); 22 | }); 23 | 24 | beforeEach(() => { 25 | fixture = MockRender(LanguageSelectionComponent); 26 | component = fixture.point.componentInstance; 27 | fixture.detectChanges(); 28 | }); 29 | 30 | it('should create', () => { 31 | expect(component).toBeTruthy(); 32 | }); 33 | 34 | it('should call service to get languages', () => { 35 | const languages = [ 36 | new Language('en-US', 'English'), 37 | new Language('de', 'Deutsch'), 38 | new Language('test', 'Testish'), 39 | ]; 40 | component.languages = []; 41 | languageService.getKnownLanguages = jest.fn().mockReturnValue(languages); 42 | 43 | component.ngOnInit(); 44 | 45 | expect(component.languages).toEqual(languages); 46 | expect(component.languages.length).toEqual(languages.length); 47 | }); 48 | 49 | it('should call service to set language', () => { 50 | languageService.selectLanguageByCode = jest.fn(); 51 | // @ts-ignore 52 | languageService.selectedLanguage = new Language('en-US', 'English'); 53 | 54 | component.onLanguageChange({target: {value: 'de'}}); 55 | 56 | expect(languageService.selectLanguageByCode).toHaveBeenCalledWith('de'); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /client/src/app/ui/language-selection/language-selection.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { ActivatedRoute } from '@angular/router'; 3 | import { Language } from '../../common/entities/language'; 4 | import { LanguageService } from '../../common/services/language.service'; 5 | 6 | @Component({ 7 | selector: 'app-language-selection', 8 | templateUrl: './language-selection.component.html', 9 | styleUrls: ['./language-selection.component.scss'] 10 | }) 11 | export class LanguageSelectionComponent implements OnInit { 12 | languages: Language[] = []; 13 | 14 | constructor(private route: ActivatedRoute, private languageService: LanguageService) { 15 | } 16 | 17 | ngOnInit(): void { 18 | this.languages = this.languageService.getKnownLanguages(); 19 | } 20 | 21 | get selectedLanguage(): Language | undefined { 22 | return this.languageService.getSelectedLanguage(); 23 | } 24 | 25 | onLanguageChange(event: any): void { 26 | this.languageService.selectLanguageByCode(event.target.value); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /client/src/app/ui/max-validator.directive.spec.ts: -------------------------------------------------------------------------------- 1 | import { MaxValidatorDirective } from './max-validator.directive'; 2 | import { FormControl } from '@angular/forms'; 3 | 4 | describe(MaxValidatorDirective.name, () => { 5 | it('should create an instance', () => { 6 | const directive = new MaxValidatorDirective(); 7 | expect(directive).toBeTruthy(); 8 | }); 9 | 10 | it('should work on valid values', () => { 11 | const directive = new MaxValidatorDirective(); 12 | directive.appMaxValidator = 100; 13 | 14 | expect(directive.validate({value: 0} as FormControl)).toEqual(null); 15 | expect(directive.validate({value: 1} as FormControl)).toEqual(null); 16 | expect(directive.validate({value: 99} as FormControl)).toEqual(null); 17 | expect(directive.validate({value: 100} as FormControl)).toEqual(null); 18 | expect(directive.validate({value: '100'} as FormControl)).toEqual(null); 19 | expect(directive.validate({value: ' 100 '} as FormControl)).toEqual(null); 20 | expect(directive.validate({value: -1} as FormControl)).toEqual(null); 21 | }); 22 | 23 | it('should work on invalid values', () => { 24 | const directive = new MaxValidatorDirective(); 25 | directive.appMaxValidator = 100; 26 | 27 | expect(directive.validate({value: 101} as FormControl)).toEqual({appMaxValidator: true}); 28 | expect(directive.validate({value: ''} as FormControl)).toEqual({appMaxValidator: true}); 29 | expect(directive.validate({value: null} as FormControl)).toEqual({appMaxValidator: true}); // works 30 | expect(directive.validate({value: undefined} as FormControl)).toEqual({appMaxValidator: true}); // works 31 | expect(directive.validate({value: '1d'} as FormControl)).toEqual({appMaxValidator: true}); // works 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /client/src/app/ui/max-validator.directive.ts: -------------------------------------------------------------------------------- 1 | import { Directive, Input } from '@angular/core'; 2 | import { UntypedFormControl, NG_VALIDATORS, Validator } from '@angular/forms'; 3 | 4 | @Directive({ 5 | selector: '[appMaxValidator][ngModel]', 6 | providers: [{provide: NG_VALIDATORS, useExisting: MaxValidatorDirective, multi: true}] 7 | }) 8 | export class MaxValidatorDirective implements Validator { 9 | private appMaxValidatorNumber: number; 10 | 11 | constructor() { 12 | } 13 | 14 | @Input() 15 | set appMaxValidator(value: number | string) { 16 | this.appMaxValidatorNumber = +value; 17 | } 18 | 19 | public validate(c: UntypedFormControl): { [appMaxValidator: string]: any } | null { 20 | const v = ('' + c.value).trim(); 21 | return v.match('[-\+]?[0-9]+') 22 | && (+v <= this.appMaxValidatorNumber) 23 | ? null 24 | : {appMaxValidator: true}; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /client/src/app/ui/min-validator.directive.spec.ts: -------------------------------------------------------------------------------- 1 | import { MinValidatorDirective } from './min-validator.directive'; 2 | import { FormControl } from '@angular/forms'; 3 | 4 | describe(MinValidatorDirective.name, () => { 5 | it('should create an instance', () => { 6 | const directive = new MinValidatorDirective(); 7 | expect(directive).toBeTruthy(); 8 | }); 9 | 10 | it('should work on valid values', () => { 11 | const directive = new MinValidatorDirective(); 12 | directive.appMinValidator = 100; 13 | 14 | expect(directive.validate({value: 100} as FormControl)).toEqual(null); 15 | expect(directive.validate({value: 101} as FormControl)).toEqual(null); 16 | expect(directive.validate({value: '100'} as FormControl)).toEqual(null); 17 | expect(directive.validate({value: ' 100 '} as FormControl)).toEqual(null); 18 | }); 19 | 20 | it('should work on invalid values', () => { 21 | const directive = new MinValidatorDirective(); 22 | directive.appMinValidator = 100; 23 | 24 | expect(directive.validate({value: 0} as FormControl)).toEqual({appMinValidator: true}); 25 | expect(directive.validate({value: 1} as FormControl)).toEqual({appMinValidator: true}); 26 | expect(directive.validate({value: 99} as FormControl)).toEqual({appMinValidator: true}); 27 | expect(directive.validate({value: -1} as FormControl)).toEqual({appMinValidator: true}); 28 | expect(directive.validate({value: ''} as FormControl)).toEqual({appMinValidator: true}); 29 | expect(directive.validate({value: null} as FormControl)).toEqual({appMinValidator: true}); // works 30 | expect(directive.validate({value: undefined} as FormControl)).toEqual({appMinValidator: true}); // works 31 | expect(directive.validate({value: '1d'} as FormControl)).toEqual({appMinValidator: true}); // works 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /client/src/app/ui/min-validator.directive.ts: -------------------------------------------------------------------------------- 1 | import { Directive, Input } from '@angular/core'; 2 | import { UntypedFormControl, NG_VALIDATORS, Validator } from '@angular/forms'; 3 | 4 | @Directive({ 5 | selector: '[appMinValidator][ngModel]', 6 | providers: [{provide: NG_VALIDATORS, useExisting: MinValidatorDirective, multi: true}] 7 | }) 8 | export class MinValidatorDirective implements Validator { 9 | private appMinValidatorNumber: number; 10 | 11 | constructor() { 12 | } 13 | 14 | @Input() 15 | set appMinValidator(value: number | string) { 16 | this.appMinValidatorNumber = +value; 17 | } 18 | 19 | public validate(c: UntypedFormControl): { [appMinValidator: string]: any } | null { 20 | const v = ('' + c.value).trim(); 21 | return v.match('[-\+]?[0-9]+') 22 | && (this.appMinValidatorNumber <= +v) 23 | ? null 24 | : {appMinValidator: true}; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /client/src/app/ui/notification/notification.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | {{'notification.loading' | translate}} 4 |
5 |
6 | 7 | 8 |
9 |
10 |
11 |
{{'notification.error' | translate}}
  12 | {{currentErrorText}} 13 | {{ remainingErrors }} {{'notification.errors-left' | translate}} 15 |
16 | 17 |
18 |
19 | 20 | 21 |
22 |
23 |
24 |
{{'notification.warning' | translate}}
  25 | {{currentWarningText}} 26 | {{ remainingWarning }} {{'notification.warnings-left' | translate}} 28 |
29 | 30 |
31 |
32 | 33 | 34 |
35 |
36 |
37 | {{currentInfoText}} 38 | {{ remainingInfo }} {{'notification.notifications-left' | translate}} 40 |
41 | 42 |
43 |
44 |
45 | -------------------------------------------------------------------------------- /client/src/app/ui/notification/notification.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { LoadingService } from '../../common/services/loading.service'; 3 | import { NotificationService } from '../../common/services/notification.service'; 4 | 5 | @Component({ 6 | selector: 'app-notification', 7 | templateUrl: './notification.component.html', 8 | styleUrls: ['./notification.component.scss'] 9 | }) 10 | export class NotificationComponent { 11 | constructor( 12 | private loadingService: LoadingService, 13 | private notificationService: NotificationService 14 | ) { 15 | } 16 | 17 | public get isLoading(): boolean { 18 | return this.loadingService.isLoading(); 19 | } 20 | 21 | // 22 | // Error 23 | // 24 | 25 | public get hasError(): boolean { 26 | return this.notificationService.hasError(); 27 | } 28 | 29 | public get remainingErrors(): number { 30 | return this.notificationService.remainingErrors(); 31 | } 32 | 33 | public get currentErrorText(): string | undefined { 34 | return this.notificationService.getError(); 35 | } 36 | 37 | public onCloseErrorButtonClicked(): void { 38 | this.notificationService.dropError(); 39 | } 40 | 41 | // 42 | // Warning 43 | // 44 | 45 | public get hasWarning(): boolean { 46 | return this.notificationService.hasWarning(); 47 | } 48 | 49 | public get remainingWarning(): number { 50 | return this.notificationService.remainingWarning(); 51 | } 52 | 53 | public get currentWarningText(): string | undefined { 54 | return this.notificationService.getWarning(); 55 | } 56 | 57 | public onCloseWarningButtonClicked(): void { 58 | this.notificationService.dropWarning(); 59 | } 60 | 61 | // 62 | // Info 63 | // 64 | 65 | public get hasInfo(): boolean { 66 | return this.notificationService.hasInfo(); 67 | } 68 | 69 | public get remainingInfo(): number { 70 | return this.notificationService.remainingInfo(); 71 | } 72 | 73 | public get currentInfoText(): string | undefined { 74 | return this.notificationService.getInfo(); 75 | } 76 | 77 | public onCloseInfoButtonClicked(): void { 78 | this.notificationService.dropInfo(); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /client/src/app/ui/progress-bar/progress-bar.component.html: -------------------------------------------------------------------------------- 1 | {{getProcessPointPercentage()}} % 2 |
3 |
4 |
5 | -------------------------------------------------------------------------------- /client/src/app/ui/progress-bar/progress-bar.component.scss: -------------------------------------------------------------------------------- 1 | @import "../../../styles"; 2 | 3 | :host { 4 | display: flex; 5 | align-items: center; 6 | } 7 | 8 | .percentage-label { 9 | color: $color-gray-mid; 10 | text-align: end; 11 | } 12 | 13 | .border-bar { 14 | width: 100px; 15 | padding: 2px; 16 | margin-left: $space-base; 17 | border: 1px solid $color-light; 18 | display: flex; 19 | justify-content: flex-start; 20 | align-items: center; 21 | } 22 | 23 | .color-bar { 24 | height: $space-base - 4px; 25 | border: 1px solid; 26 | } 27 | -------------------------------------------------------------------------------- /client/src/app/ui/progress-bar/progress-bar.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, OnInit } from '@angular/core'; 2 | import { ProcessPointColorService } from '../../common/services/process-point-color.service'; 3 | 4 | @Component({ 5 | selector: 'app-progress-bar', 6 | templateUrl: './progress-bar.component.html', 7 | styleUrls: ['./progress-bar.component.scss'] 8 | }) 9 | export class ProgressBarComponent implements OnInit { 10 | @Input() progressPoints: number; 11 | @Input() totalPoints: number; 12 | 13 | constructor( 14 | private processPointColorService: ProcessPointColorService 15 | ) { 16 | } 17 | 18 | ngOnInit(): void { 19 | } 20 | 21 | getProcessPointColor(): string { 22 | return this.processPointColorService.getProcessPointsColor(this.progressPoints, this.totalPoints); 23 | } 24 | 25 | getProcessPointWidth(): string { 26 | return Math.floor(this.progressPoints / this.totalPoints * 100) + 'px'; 27 | } 28 | 29 | getProcessPointPercentage(): number { 30 | return Math.round(this.progressPoints / this.totalPoints * 100); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /client/src/app/ui/tabs/tabs.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |
5 |
6 |
7 | 8 |
9 | -------------------------------------------------------------------------------- /client/src/app/ui/tabs/tabs.component.scss: -------------------------------------------------------------------------------- 1 | @import "../../../styles"; 2 | 3 | :host { 4 | display: flex; 5 | flex-direction: column; 6 | flex-grow: 1; 7 | min-height: 0; // Needed to make scrolling right. Otherwise content might exceed the page height and doesn't scroll within its container. 8 | } 9 | 10 | button { 11 | position: relative; // needed to set z-Index on hover 12 | background-color: white; 13 | margin-right: -1px; // get rid of douple sized border between two buttons 14 | } 15 | button.selected:hover, 16 | .selected { 17 | border-bottom: 2px solid $color-mid; 18 | background-color: $color-very-light; 19 | } 20 | button:hover { 21 | z-index: 1; 22 | } 23 | 24 | .tab-list { 25 | display: flex; 26 | flex-direction: row; 27 | padding-left: $space-small; 28 | } 29 | 30 | .tab-content { 31 | margin-top: -1px; 32 | border-top: 1px solid $color-light; 33 | min-height: 0px; 34 | display: flex; 35 | flex-direction: column; 36 | overflow: auto; 37 | flex-grow: 1; 38 | height: 100%; 39 | } 40 | 41 | .tab-content-border { 42 | border-right: 1px solid $color-light; 43 | border-left: 1px solid $color-light; 44 | border-bottom: 1px solid $color-light; 45 | } 46 | -------------------------------------------------------------------------------- /client/src/app/ui/tabs/tabs.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { TabsComponent } from './tabs.component'; 2 | import { MockBuilder, MockedComponentFixture, MockRender } from 'ng-mocks'; 3 | import { AppModule } from '../../app.module'; 4 | 5 | describe(TabsComponent.name, () => { 6 | let component: TabsComponent; 7 | let fixture: MockedComponentFixture; 8 | 9 | beforeEach(() => MockBuilder(TabsComponent, AppModule)); 10 | 11 | beforeEach(() => { 12 | fixture = MockRender(TabsComponent, { 13 | tabs: ['tab 1', 'tab 2'] 14 | }); 15 | component = fixture.point.componentInstance; 16 | fixture.detectChanges(); 17 | }); 18 | 19 | it('should create', () => { 20 | expect(component).toBeTruthy(); 21 | }); 22 | 23 | it('should select tab correctly', () => { 24 | const eventSpy = jest.fn(); 25 | component.tabSelected.subscribe(eventSpy); 26 | 27 | component.selectTab(0); 28 | expect(eventSpy).toHaveBeenCalledWith(0); 29 | 30 | component.selectTab(1); 31 | expect(eventSpy).toHaveBeenCalledWith(1); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /client/src/app/ui/tabs/tabs.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, EventEmitter, Input, Output } from '@angular/core'; 2 | 3 | export interface TabItem { 4 | index: number; 5 | title: string; 6 | } 7 | 8 | @Component({ 9 | selector: 'app-tabs', 10 | templateUrl: './tabs.component.html', 11 | styleUrls: ['./tabs.component.scss'] 12 | }) 13 | export class TabsComponent { 14 | /** 15 | * When set to true, there'll be only a border between the tabs and the content but no border around the content. 16 | */ 17 | @Input() borderless = false; 18 | 19 | @Output() tabSelected = new EventEmitter(); 20 | 21 | public selectedTabIndex = 0; 22 | 23 | private currentTabs: TabItem[]; 24 | 25 | public get tabs(): TabItem[] { 26 | return this.currentTabs; 27 | } 28 | 29 | @Input() 30 | public set tabs(titles: string[]) { 31 | this.currentTabs = titles.map((title, index) => ({index, title} as TabItem)); 32 | } 33 | 34 | public selectTab(tabIndex: number): void { 35 | this.selectedTabIndex = tabIndex; 36 | this.tabSelected.emit(this.selectedTabIndex); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /client/src/app/ui/toolbar/toolbar.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 5 | -------------------------------------------------------------------------------- /client/src/app/ui/toolbar/toolbar.component.scss: -------------------------------------------------------------------------------- 1 | @import "../../../styles"; 2 | 3 | .toolbar { 4 | left: 0px; 5 | padding-left: $space-large; 6 | padding-right: $space-large; 7 | padding-top: $space-base; 8 | padding-bottom: $space-base; 9 | color: $color-white; 10 | background-color: $color-mid; 11 | height: $space-huge; 12 | display: flex; 13 | align-items: center; 14 | justify-content: space-between; 15 | } 16 | 17 | .toolbar > p { 18 | color: $color-gray-light; 19 | } 20 | -------------------------------------------------------------------------------- /client/src/app/ui/toolbar/toolbar.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ToolbarComponent } from './toolbar.component'; 2 | import { MockBuilder, MockedComponentFixture, MockRender } from 'ng-mocks'; 3 | import { AppModule } from '../../app.module'; 4 | 5 | describe(ToolbarComponent.name, () => { 6 | let component: ToolbarComponent; 7 | let fixture: MockedComponentFixture; 8 | 9 | beforeEach(() => MockBuilder(ToolbarComponent, AppModule)); 10 | 11 | beforeEach(() => { 12 | fixture = MockRender(ToolbarComponent); 13 | component = fixture.point.componentInstance; 14 | fixture.detectChanges(); 15 | }); 16 | 17 | it('should create', () => { 18 | expect(component).toBeTruthy(); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /client/src/app/ui/toolbar/toolbar.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-toolbar', 5 | templateUrl: './toolbar.component.html', 6 | styleUrls: ['./toolbar.component.scss'] 7 | }) 8 | export class ToolbarComponent { 9 | } 10 | -------------------------------------------------------------------------------- /client/src/app/ui/zoom-control/zoom-control.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /client/src/app/ui/zoom-control/zoom-control.component.scss: -------------------------------------------------------------------------------- 1 | @import '../../../styles'; 2 | @import '../../../icons'; 3 | 4 | :host { 5 | display: flex; 6 | flex-direction: column; 7 | } 8 | 9 | button { 10 | background-color: $color-white-transparent; 11 | width: $space-huge; 12 | height: $space-huge; 13 | padding: unset; 14 | margin-bottom: -1px; // Get rid of double border 15 | } 16 | 17 | button:hover { 18 | background-color: $color-very-light-transparent; 19 | } 20 | button:hover { 21 | z-index: 1; 22 | } 23 | -------------------------------------------------------------------------------- /client/src/app/ui/zoom-control/zoom-control.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ZoomControlComponent } from './zoom-control.component'; 2 | import { MockBuilder, MockedComponentFixture, MockRender } from 'ng-mocks'; 3 | import { AppModule } from '../../app.module'; 4 | 5 | describe(ZoomControlComponent.name, () => { 6 | let component: ZoomControlComponent; 7 | let fixture: MockedComponentFixture; 8 | 9 | beforeEach(() => MockBuilder(ZoomControlComponent, AppModule)); 10 | 11 | beforeEach(() => { 12 | fixture = MockRender(ZoomControlComponent); 13 | component = fixture.point.componentInstance; 14 | fixture.detectChanges(); 15 | }); 16 | 17 | it('should create', () => { 18 | expect(component).toBeTruthy(); 19 | }); 20 | 21 | it('should fire zoom in event', () => { 22 | const zoomSpy = jest.fn(); 23 | component.buttonZoomIn.subscribe(zoomSpy); 24 | 25 | component.onButtonZoomIn(); 26 | 27 | expect(zoomSpy).toHaveBeenCalled(); 28 | }); 29 | 30 | it('should fire zoom out event', () => { 31 | const zoomSpy = jest.fn(); 32 | component.buttonZoomOut.subscribe(zoomSpy); 33 | 34 | component.onButtonZoomOut(); 35 | 36 | expect(zoomSpy).toHaveBeenCalled(); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /client/src/app/ui/zoom-control/zoom-control.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, EventEmitter, OnInit, Output } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-zoom-control', 5 | templateUrl: './zoom-control.component.html', 6 | styleUrls: ['./zoom-control.component.scss'] 7 | }) 8 | export class ZoomControlComponent implements OnInit { 9 | 10 | @Output() public buttonZoomIn: EventEmitter = new EventEmitter(); 11 | @Output() public buttonZoomOut: EventEmitter = new EventEmitter(); 12 | 13 | constructor() { } 14 | 15 | ngOnInit(): void { 16 | } 17 | 18 | public onButtonZoomIn() { 19 | this.buttonZoomIn.emit(); 20 | } 21 | 22 | public onButtonZoomOut() { 23 | this.buttonZoomOut.emit(); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /client/src/app/user/current-user.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { CurrentUserService } from './current-user.service'; 2 | 3 | describe(CurrentUserService.name, () => { 4 | let service: CurrentUserService; 5 | 6 | beforeEach(() => { 7 | service = new CurrentUserService(); 8 | }); 9 | 10 | it('should set and get correctly', () => { 11 | expect(service).toBeTruthy(); 12 | 13 | service.setUser('test-user', '12345'); 14 | 15 | expect(service.getUserName()).toEqual('test-user'); 16 | expect(service.getUserId()).toEqual('12345'); 17 | }); 18 | 19 | it('should reset correctly', () => { 20 | expect(service).toBeTruthy(); 21 | service.setUser('test-user', '12345'); 22 | 23 | service.resetUser(); 24 | 25 | expect(service.getUserName()).toEqual(undefined); 26 | expect(service.getUserId()).toEqual(undefined); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /client/src/app/user/current-user.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { User } from './user.material'; 3 | import { Observable, Subject } from 'rxjs'; 4 | 5 | @Injectable({ 6 | providedIn: 'root' 7 | }) 8 | export class CurrentUserService { 9 | private currentUser?: User; 10 | private $userChanged: Subject = new Subject(); 11 | 12 | constructor() { 13 | } 14 | 15 | get onUserChanged(): Observable { 16 | return this.$userChanged.asObservable(); 17 | } 18 | 19 | public setUser(userName: string, uid: string): void { 20 | this.currentUser = new User(userName, uid); 21 | this.$userChanged.next(this.currentUser); 22 | } 23 | 24 | public resetUser(): void { 25 | this.currentUser = undefined; 26 | this.$userChanged.next(undefined); 27 | } 28 | 29 | public getUserName(): string | undefined { 30 | return this.currentUser?.name; 31 | } 32 | 33 | public getUserId(): string | undefined { 34 | return this.currentUser?.uid; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /client/src/app/user/user-invitation/user-invitation.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 |
5 | -------------------------------------------------------------------------------- /client/src/app/user/user-invitation/user-invitation.component.scss: -------------------------------------------------------------------------------- 1 | @import "../../../styles"; 2 | 3 | #userInput { 4 | margin-right: $space-base; 5 | } 6 | -------------------------------------------------------------------------------- /client/src/app/user/user-invitation/user-invitation.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; 2 | import { NotificationService } from '../../common/services/notification.service'; 3 | import { UserService } from '../user.service'; 4 | import { User } from '../user.material'; 5 | import { TranslateService } from '@ngx-translate/core'; 6 | 7 | @Component({ 8 | selector: 'app-user-invitation', 9 | templateUrl: './user-invitation.component.html', 10 | styleUrls: ['./user-invitation.component.scss'] 11 | }) 12 | export class UserInvitationComponent { 13 | @Input() public users: User[]; 14 | @Output() public userInvited: EventEmitter = new EventEmitter(); 15 | 16 | public enteredUserName: string; 17 | 18 | constructor( 19 | private userService: UserService, 20 | private notificationService: NotificationService, 21 | private translationService: TranslateService 22 | ) { 23 | } 24 | 25 | public onInvitationButtonClicked(): void { 26 | if (this.users.map(u => u.name).includes(this.enteredUserName)) { 27 | this.notificationService.addWarning(this.translationService.instant('user.already-member', {user: this.enteredUserName})); 28 | return; 29 | } 30 | 31 | this.userService.getUserByName(this.enteredUserName).subscribe( 32 | user => { 33 | this.userInvited.emit(user); 34 | }, err => { 35 | console.error(err); 36 | this.notificationService.addError(this.translationService.instant('user.unable-load-user', {user: this.enteredUserName})); 37 | }); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /client/src/app/user/user-list/user-list.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | {{ u.name }} 4 | 9 |
10 |
11 | -------------------------------------------------------------------------------- /client/src/app/user/user-list/user-list.component.scss: -------------------------------------------------------------------------------- 1 | @import "../../../styles"; 2 | @import "../../../colors"; 3 | 4 | .list-item { 5 | padding-right: 0px; 6 | } 7 | 8 | .user-without-name { 9 | font-style: italic; 10 | color: $color-gray-light; 11 | } 12 | 13 | .item-content { 14 | display: flex; 15 | justify-content: space-between; 16 | } 17 | 18 | .remove-button { 19 | border: none; 20 | border-left: 1px solid $color-light; 21 | width: 70px; 22 | background: none; 23 | display: flex; 24 | justify-content: center; 25 | align-items: center; 26 | } 27 | .remove-button:hover { 28 | border: none; 29 | border-left: 1px solid $color-mid; 30 | background-color: $color-very-light; 31 | } 32 | -------------------------------------------------------------------------------- /client/src/app/user/user-list/user-list.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { UserListComponent } from './user-list.component'; 2 | import { CurrentUserService } from '../current-user.service'; 3 | import { MockBuilder, MockedComponentFixture, MockRender } from 'ng-mocks'; 4 | import { AppModule } from '../../app.module'; 5 | 6 | describe(UserListComponent.name, () => { 7 | let component: UserListComponent; 8 | let fixture: MockedComponentFixture; 9 | let currentUserService: CurrentUserService; 10 | 11 | beforeEach(() => { 12 | currentUserService = {} as CurrentUserService; 13 | currentUserService.getUserId = jest.fn().mockReturnValue('123'); 14 | 15 | return MockBuilder(UserListComponent, AppModule) 16 | .provide({provide: CurrentUserService, useFactory: () => currentUserService}); 17 | }); 18 | 19 | beforeEach(() => { 20 | fixture = MockRender(UserListComponent); 21 | component = fixture.point.componentInstance; 22 | fixture.detectChanges(); 23 | }); 24 | 25 | it('should detect removable users', () => { 26 | component.ownerUid = '123'; 27 | expect(component).toBeTruthy(); 28 | 29 | expect(component.canRemove('123')).toEqual(false); 30 | expect(component.canRemove('234')).toEqual(true); 31 | expect(component.canRemove('345')).toEqual(true); 32 | }); 33 | 34 | it('should remove user correctly', () => { 35 | // Arrange 36 | const removeUserSpy = jest.fn(); 37 | component.userRemoved.subscribe(removeUserSpy); 38 | 39 | component.ownerUid = '123'; 40 | expect(component).toBeTruthy(); 41 | 42 | // Act 43 | component.onRemoveUserClicked('123'); 44 | 45 | // Assert 46 | expect(removeUserSpy).toHaveBeenCalledWith('123'); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /client/src/app/user/user-list/user-list.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; 2 | import { CurrentUserService } from '../current-user.service'; 3 | import { User } from '../user.material'; 4 | 5 | @Component({ 6 | selector: 'app-user-list', 7 | templateUrl: './user-list.component.html', 8 | styleUrls: ['./user-list.component.scss'] 9 | }) 10 | export class UserListComponent implements OnInit { 11 | @Input() public users: User[]; 12 | @Input() public ownerUid: string; 13 | 14 | @Output() public userRemoved: EventEmitter = new EventEmitter(); 15 | 16 | constructor( 17 | private currentUserService: CurrentUserService 18 | ) { 19 | } 20 | 21 | ngOnInit(): void { 22 | } 23 | 24 | public onRemoveUserClicked(user: string) { 25 | this.userRemoved.emit(user); 26 | } 27 | 28 | public canRemove(user: string): boolean { 29 | return this.ownerUid === this.currentUserService.getUserId() && user !== this.currentUserService.getUserId(); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /client/src/app/user/user.material.ts: -------------------------------------------------------------------------------- 1 | export class User { 2 | constructor( 3 | public name: string, 4 | public uid: string, 5 | public hasName: boolean = true 6 | ) { 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /client/src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hauke96/simple-task-manager/03829aa290467d925e3d52262899a2a6a51819a4/client/src/assets/.gitkeep -------------------------------------------------------------------------------- /client/src/assets/arrow-down.svg: -------------------------------------------------------------------------------- 1 | 2 | 16 | 18 | 19 | 21 | image/svg+xml 22 | 24 | 25 | 26 | 27 | 29 | 49 | 54 | 55 | -------------------------------------------------------------------------------- /client/src/assets/fonts/Linearicons-Free.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hauke96/simple-task-manager/03829aa290467d925e3d52262899a2a6a51819a4/client/src/assets/fonts/Linearicons-Free.eot -------------------------------------------------------------------------------- /client/src/assets/fonts/Linearicons-Free.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hauke96/simple-task-manager/03829aa290467d925e3d52262899a2a6a51819a4/client/src/assets/fonts/Linearicons-Free.ttf -------------------------------------------------------------------------------- /client/src/assets/fonts/Linearicons-Free.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hauke96/simple-task-manager/03829aa290467d925e3d52262899a2a6a51819a4/client/src/assets/fonts/Linearicons-Free.woff -------------------------------------------------------------------------------- /client/src/assets/fonts/Linearicons-Free.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hauke96/simple-task-manager/03829aa290467d925e3d52262899a2a6a51819a4/client/src/assets/fonts/Linearicons-Free.woff2 -------------------------------------------------------------------------------- /client/src/assets/i18n/changelog.de.html: -------------------------------------------------------------------------------- 1 |

Änderungen in 1.6.0:

2 | 3 |

Funktionen:

4 |
    5 |
  • Es gibt nun die Möglichkeit Kommentare an Aufgaben und Projekte zu schreiben
  • 6 |
  • Die Datenquelle in JOSM ist nun konfigurierbar
  • 7 |
8 | 9 |

Kleinere Verbesserungen:

10 |
    11 |
  • MBildgröße der Titelbilder ist kleiner
  • 12 |
  • Kleine Überarbeitung des Designs bei der Projekterstellung
  • 13 |
  • Zu große Meldungen haben nun eine Scrollbar
  • 14 |
15 | 16 |

Fehlerbehebungen:

17 |
    18 |
  • Manche Aktionen wurden doppelt ausgeführt
  • 19 |
  • Fehlerhafte Behandling von Geometrien beim Öffnen in JOSM
  • 20 |
21 | -------------------------------------------------------------------------------- /client/src/assets/i18n/changelog.en-US.html: -------------------------------------------------------------------------------- 1 |

Changes in 1.6.0:

2 | 3 |

Features:

4 |
    5 |
  • Ability to add comments to tasks and projects
  • 6 |
  • Make data source of JOSM configurable
  • 7 |
8 | 9 |

Minor enhancements:

10 |
    11 |
  • Make title image sizes smaller
  • 12 |
  • Small redesign of project creation UI
  • 13 |
  • Make too long notifications scrollable
  • 14 |
15 | 16 |

Bug fixes:

17 |
    18 |
  • Certain actions are performed twice
  • 19 |
  • Wrong handling of geometries when opening task in JOSM
  • 20 |
21 | -------------------------------------------------------------------------------- /client/src/assets/i18n/changelog.es.html: -------------------------------------------------------------------------------- 1 |

Cambios en 1.6.0:

2 | 3 | (Traducción automática) 4 | 5 |

Funciones: 6 |

    7 |
  • Posibilidad de añadir comentarios a tareas y proyectos
  • 8 |
  • Posibilidad de configurar la fuente de datos de JOSM
  • 9 |
10 | 11 |

Mejoras menores: 12 |

    13 |
  • Reducción del tamaño de las imágenes de los títulos
  • 14 |
  • Pequeño rediseño de la interfaz de creación de proyectos
  • 15 |
  • Desplazamiento de las notificaciones demasiado largas
  • 16 |
17 | 18 |

Corrección de errores: 19 |

    20 |
  • Algunas acciones se realizan dos veces
  • 21 |
  • Tratamiento incorrecto de las geometrías al abrir una tarea en JOSM
  • 22 |
23 | -------------------------------------------------------------------------------- /client/src/assets/i18n/changelog.it.html: -------------------------------------------------------------------------------- 1 |

Novità in 1.6.0:

2 | 3 |

Funzionalità:

4 |
    5 |
  • Abilità di aggiungere commenti ai compiti e alle sfide
  • 6 |
  • Resa la fonte dati di JOSM configurabile
  • 7 |
8 | 9 |

Piccoli miglioramenti:

10 |
    11 |
  • Resa la dimensione del titolo dell'immagine più piccola
  • 12 |
  • Piccola ripogettazione della UI di creazione di un progetto
  • 13 |
  • Rese scorrevoli le notifiche troppo lunghe
  • 14 |
15 | 16 |

Correzioni di bug:

17 |
    18 |
  • Certe azioni vengono effettuate due volte
  • 19 |
  • Gestione errata delle geometrie quando viene aperto un compito in JOSM
  • 20 |
21 | -------------------------------------------------------------------------------- /client/src/assets/i18n/notice.de.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hauke96/simple-task-manager/03829aa290467d925e3d52262899a2a6a51819a4/client/src/assets/i18n/notice.de.html -------------------------------------------------------------------------------- /client/src/assets/i18n/notice.en-US.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hauke96/simple-task-manager/03829aa290467d925e3d52262899a2a6a51819a4/client/src/assets/i18n/notice.en-US.html -------------------------------------------------------------------------------- /client/src/assets/i18n/notice.es.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hauke96/simple-task-manager/03829aa290467d925e3d52262899a2a6a51819a4/client/src/assets/i18n/notice.es.html -------------------------------------------------------------------------------- /client/src/assets/i18n/notice.it.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hauke96/simple-task-manager/03829aa290467d925e3d52262899a2a6a51819a4/client/src/assets/i18n/notice.it.html -------------------------------------------------------------------------------- /client/src/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hauke96/simple-task-manager/03829aa290467d925e3d52262899a2a6a51819a4/client/src/assets/icon.png -------------------------------------------------------------------------------- /client/src/assets/login-background-left.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hauke96/simple-task-manager/03829aa290467d925e3d52262899a2a6a51819a4/client/src/assets/login-background-left.webp -------------------------------------------------------------------------------- /client/src/assets/login-background-right.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hauke96/simple-task-manager/03829aa290467d925e3d52262899a2a6a51819a4/client/src/assets/login-background-right.webp -------------------------------------------------------------------------------- /client/src/colors.scss: -------------------------------------------------------------------------------- 1 | // Use material design colors: http://hauke-stieler.de/public/color-palette/ 2 | 3 | // Colors: 4 | $color-very-very-light: #e7faf9; // lighter #50 teal 5 | $color-very-light: #e0f2f1; // #50 teal 6 | $color-very-light-transparent: #e0f2f1c0; // #50 teal 7 | $color-lighter: #bedfdc; // #100 teal (with slightly less saturation) 8 | $color-light: #80cbc4; // #200 teal 9 | $color-mid: #26a69a; // #400 teal 10 | $color-dark: #00796b; // #700 teal 11 | $color-very-dark: #004d40; // #900 teal 12 | 13 | // Grey-scale: 14 | $color-white: #ffffff; 15 | $color-white-transparent: #ffffffc0; 16 | $color-gray-very-light: #eeeeee; // #200 17 | $color-gray-light: #d2d2d2; // a bit darker than #300 18 | $color-gray-mid: #9e9e9e; // #400 19 | $color-gray-dark: #616161; // between #600 and #700 20 | $color-gray-very-dark: #424242; // #900 21 | 22 | // Others: 23 | $color-error: #ef5350; // #400 red 24 | $color-error-dark: #d32f2f; // #700 red 25 | $color-error-very-light: #ffcdd2; // #100 red 26 | $color-error-very-light-transparent: #ffcdd2c0; // #100 red 27 | 28 | $color-warn: #ffca28; // #400 orange 29 | $color-warn-dark: #ff6f00; // #700 orange 30 | $color-warn-very-light: #ffecb3; // #100 orange 31 | $color-warn-very-light-transparent: #ffecb3c0; // #100 orange 32 | -------------------------------------------------------------------------------- /client/src/environments/environment.local.ts: -------------------------------------------------------------------------------- 1 | const baseUrl = document.location.protocol + '//' + document.location.hostname + ':8080'; 2 | const usedApi = 'v2.9'; 3 | 4 | export const environment = { 5 | production: false, 6 | oauth_landing: document.location.origin + '/oauth-landing', 7 | 8 | base_url: baseUrl, 9 | url_auth: baseUrl + '/oauth2/login', 10 | url_config: baseUrl + '/' + usedApi + '/config', 11 | url_projects: baseUrl + '/' + usedApi + '/projects', 12 | url_projects_by_id: baseUrl + '/' + usedApi + '/projects/{id}', 13 | url_projects_update: baseUrl + '/' + usedApi + '/projects/{id}', 14 | url_projects_users: baseUrl + '/' + usedApi + '/projects/{id}/users', 15 | url_projects_export: baseUrl + '/' + usedApi + '/projects/{id}/export', 16 | url_projects_import: baseUrl + '/' + usedApi + '/projects/import', 17 | url_projects_comments: baseUrl + '/' + usedApi + '/projects/{id}/comments', 18 | url_tasks: baseUrl + '/' + usedApi + '/tasks', 19 | url_task: baseUrl + '/' + usedApi + '/tasks/{id}', 20 | url_task_assignedUser: baseUrl + '/' + usedApi + '/tasks/{id}/assignedUser', 21 | url_task_processPoints: baseUrl + '/' + usedApi + '/tasks/{id}/processPoints', 22 | url_task_comments: baseUrl + '/' + usedApi + '/tasks/{id}/comments', 23 | url_updates: 'ws://' + document.location.hostname + ':8080' + '/' + usedApi + '/updates' 24 | }; 25 | -------------------------------------------------------------------------------- /client/src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | const baseUrl = document.location.protocol + '//' + document.location.hostname + ':8080'; 2 | const usedApi = 'v2.9'; 3 | 4 | export const environment = { 5 | production: true, 6 | oauth_landing: document.location.origin + '/oauth-landing', 7 | 8 | base_url: baseUrl, 9 | url_auth: baseUrl + '/oauth2/login', 10 | url_config: baseUrl + '/' + usedApi + '/config', 11 | url_projects: baseUrl + '/' + usedApi + '/projects', 12 | url_projects_by_id: baseUrl + '/' + usedApi + '/projects/{id}', 13 | url_projects_update: baseUrl + '/' + usedApi + '/projects/{id}', 14 | url_projects_users: baseUrl + '/' + usedApi + '/projects/{id}/users', 15 | url_projects_export: baseUrl + '/' + usedApi + '/projects/{id}/export', 16 | url_projects_import: baseUrl + '/' + usedApi + '/projects/import', 17 | url_projects_comments: baseUrl + '/' + usedApi + '/projects/{id}/comments', 18 | url_tasks: baseUrl + '/' + usedApi + '/tasks', 19 | url_task: baseUrl + '/' + usedApi + '/tasks/{id}', 20 | url_task_assignedUser: baseUrl + '/' + usedApi + '/tasks/{id}/assignedUser', 21 | url_task_processPoints: baseUrl + '/' + usedApi + '/tasks/{id}/processPoints', 22 | url_task_comments: baseUrl + '/' + usedApi + '/tasks/{id}/comments', 23 | url_updates: 'wss://' + document.location.hostname + ':8080' + '/' + usedApi + '/updates' 24 | }; 25 | -------------------------------------------------------------------------------- /client/src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | const baseUrl = document.location.protocol + '//' + document.location.hostname + ':8080'; 2 | const usedApi = 'v2.9'; 3 | 4 | export const environment = { 5 | production: false, 6 | oauth_landing: document.location.origin + '/oauth-landing', 7 | 8 | base_url: baseUrl, 9 | url_auth: baseUrl + '/oauth2/login', 10 | url_config: baseUrl + '/' + usedApi + '/config', 11 | url_projects: baseUrl + '/' + usedApi + '/projects', 12 | url_projects_by_id: baseUrl + '/' + usedApi + '/projects/{id}', 13 | url_projects_update: baseUrl + '/' + usedApi + '/projects/{id}', 14 | url_projects_users: baseUrl + '/' + usedApi + '/projects/{id}/users', 15 | url_projects_export: baseUrl + '/' + usedApi + '/projects/{id}/export', 16 | url_projects_import: baseUrl + '/' + usedApi + '/projects/import', 17 | url_projects_comments: baseUrl + '/' + usedApi + '/projects/{id}/comments', 18 | url_tasks: baseUrl + '/' + usedApi + '/tasks', 19 | url_task: baseUrl + '/' + usedApi + '/tasks/{id}', 20 | url_task_assignedUser: baseUrl + '/' + usedApi + '/tasks/{id}/assignedUser', 21 | url_task_processPoints: baseUrl + '/' + usedApi + '/tasks/{id}/processPoints', 22 | url_task_comments: baseUrl + '/' + usedApi + '/tasks/{id}/comments', 23 | url_updates: 'ws://' + document.location.hostname + ':8080' + '/' + usedApi + '/updates' 24 | }; 25 | -------------------------------------------------------------------------------- /client/src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hauke96/simple-task-manager/03829aa290467d925e3d52262899a2a6a51819a4/client/src/favicon.ico -------------------------------------------------------------------------------- /client/src/favicon.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hauke96/simple-task-manager/03829aa290467d925e3d52262899a2a6a51819a4/client/src/favicon.xcf -------------------------------------------------------------------------------- /client/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SimpleTaskManager 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /client/src/main.ts: -------------------------------------------------------------------------------- 1 | import { enableProdMode } from '@angular/core'; 2 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 3 | 4 | import { AppModule } from './app/app.module'; 5 | import { environment } from './environments/environment'; 6 | 7 | if (environment.production) { 8 | enableProdMode(); 9 | } 10 | 11 | platformBrowserDynamic().bootstrapModule(AppModule) 12 | .catch(err => console.error(err)); 13 | -------------------------------------------------------------------------------- /client/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./out-tsc/app", 5 | "types": [] 6 | }, 7 | "files": [ 8 | "src/main.ts" 9 | ], 10 | "include": [ 11 | "src/**/*.d.ts" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "resolveJsonModule": true, 5 | "baseUrl": "./", 6 | "outDir": "./dist/out-tsc", 7 | "sourceMap": true, 8 | "esModuleInterop": true, 9 | "declaration": false, 10 | "experimentalDecorators": true, 11 | "moduleResolution": "node", 12 | "importHelpers": true, 13 | "target": "ES2022", 14 | "module": "ES2022", 15 | "lib": [ 16 | "ES2021", 17 | "dom" 18 | ], 19 | "strict": true, 20 | "strictPropertyInitialization": false, 21 | "skipLibCheck": true, 22 | "useDefineForClassFields": false 23 | }, 24 | "angularCompilerOptions": { 25 | "fullTemplateTypeCheck": true, 26 | "strictInjectionParameters": true, 27 | "strictTemplates": true, 28 | "extendedDiagnostics": { 29 | "checks": { 30 | "optionalChainNotNullable": "suppress" 31 | } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /client/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./out-tsc/spec", 5 | "types": [ 6 | "jest" 7 | ], 8 | "module": "commonjs", 9 | "emitDecoratorMetadata": true, 10 | "allowJs": true 11 | }, 12 | "include": [ 13 | "src/**/*.spec.ts", 14 | "src/**/*.d.ts" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /client/tslint-to-eslint-config.log: -------------------------------------------------------------------------------- 1 | 8 ESLint rules behave differently from their TSLint counterparts: 2 | * arrow-body-style: 3 | - ESLint will throw an error if the function body is multiline yet has a one-line return on it. 4 | * no-console: 5 | - Custom console methods, if they exist, will no longer be allowed. 6 | * space-before-function-paren: 7 | - Option "constructor" is not supported by ESLint. 8 | - Option "method" is not supported by ESLint. 9 | * no-underscore-dangle: 10 | - Leading or trailing underscores (_) on identifiers will now be forbidden. 11 | * no-invalid-this: 12 | - Functions in methods will no longer be ignored. 13 | * @typescript-eslint/no-unused-expressions: 14 | - The TSLint optional config "allow-new" is the default ESLint behavior and will no longer be ignored. 15 | * prefer-arrow/prefer-arrow-functions: 16 | - ESLint (eslint-plugin-prefer-arrow plugin) does not support allowing named functions defined with the function keyword. 17 | * eqeqeq: 18 | - Option "smart" allows for comparing two literal values, evaluating the value of typeof and null comparisons. 19 | 20 | 2 rules are not known by tslint-to-eslint-config to have ESLint equivalents: 21 | * tslint-to-eslint-config does not know the ESLint equivalent for TSLint's "import-spacing". 22 | * tslint-to-eslint-config does not know the ESLint equivalent for TSLint's "whitespace". 23 | 24 | -------------------------------------------------------------------------------- /create-backup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | echo "Start backup job" 6 | 7 | DIR=backups 8 | YESTERDAY=$(date --date="yesterday" +%Y-%m-%d) 9 | 10 | echo "Create output folder" 11 | mkdir -p $DIR 12 | echo "Done" 13 | 14 | echo "Create compressed database dump" 15 | pg_dump -h $STM_DB_HOST -U $STM_DB_USERNAME --create -d stm | gzip -9 > "$DIR/stm-db-backup_$YESTERDAY.sql.gz" 16 | echo "Done" 17 | 18 | echo "Finished backup job" 19 | -------------------------------------------------------------------------------- /doc/README.md: -------------------------------------------------------------------------------- 1 | The documentation is ordered into few categories: 2 | 3 | ### [api](api) 4 | * Link to general information about the API 5 | * Generating swagger UI 6 | * Versions and API changes 7 | 8 | ### [architecture](architecture) 9 | * Client and server code architecture 10 | 11 | ### [authentication](authentication) 12 | * Technical in-depth description of the authentication and authorization process 13 | * Token creation, handling and validation 14 | 15 | ### [development](development) 16 | * Steps to get started 17 | * Git workflow 18 | * Overview over client, server, docker and further links 19 | 20 | ### [operation](operation) 21 | * Setup and configuration of the STM server 22 | * Setup of a linux machine 23 | * HTTPS 24 | * Create SSL certificates via let's encrypt 25 | * Automatic renewal of SSL certs 26 | * Automatic backups 27 | 28 | ### [testing](testing) 29 | * Write server and client tests 30 | * Run these tests -------------------------------------------------------------------------------- /doc/architecture/server-diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hauke96/simple-task-manager/03829aa290467d925e3d52262899a2a6a51819a4/doc/architecture/server-diagram.png -------------------------------------------------------------------------------- /doc/architecture/server.puml: -------------------------------------------------------------------------------- 1 | @startuml 2 | 3 | package main <> { 4 | class main.main 5 | } 6 | 7 | package util <> { 8 | class util.util 9 | } 10 | 11 | package config <> { 12 | class config.config 13 | } 14 | 15 | package websocket <> { 16 | class websocket.websocket 17 | } 18 | 19 | ' Alignment: 20 | main -[hidden]down- util 21 | main -[hidden]right- websocket 22 | util -[hidden]right- config 23 | 24 | package api <> { 25 | class api.api { 26 | } 27 | class api.api_vX { 28 | } 29 | 30 | api.api --> api.api_vX 31 | 32 | api.api_vX --> project.service 33 | api.api_vX --> task.service 34 | api.api_vX --> export.service 35 | } 36 | 37 | package auth <> { 38 | class auth.auth { 39 | } 40 | 41 | class auth.token { 42 | } 43 | 44 | auth.auth --> auth.token 45 | } 46 | 47 | package project <> { 48 | class project.service { 49 | } 50 | 51 | class project.store { 52 | } 53 | 54 | project.service --> store 55 | } 56 | 57 | package task <> { 58 | class task.service { 59 | } 60 | 61 | class task.store { 62 | } 63 | 64 | task.service --> store 65 | } 66 | 67 | package export <> { 68 | class export.service{ 69 | } 70 | } 71 | 72 | package permission <> { 73 | class permission.store{ 74 | } 75 | } 76 | 77 | 'main.main --> api.api : Init() 78 | 'main.main --> auth.auth : Init() 79 | 'main.main --> project : Init() 80 | 'main.main --> task : Init() 81 | 'main.main --> permission : Init() 82 | 83 | api.api --> auth.auth 84 | api.api_vX --> auth.auth 85 | 86 | task.service --> permission.store 87 | 88 | project.service --> task.service 89 | project.service --> permission.store 90 | project.store --> task.service 91 | 92 | export.service --> project.service 93 | 94 | @enduml -------------------------------------------------------------------------------- /doc/authentication/authentication.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hauke96/simple-task-manager/03829aa290467d925e3d52262899a2a6a51819a4/doc/authentication/authentication.png -------------------------------------------------------------------------------- /doc/operation/README.md: -------------------------------------------------------------------------------- 1 | Here you find resources on how to run STM on a server. 2 | 3 | * [linux.md](linux.md) : Linux setup, i.e. user management, firewall, etc. 4 | * [stm.md](stm.md) : STM-Server setup 5 | * [docker.md](docker.md) : Docker architecture, deployment and logging 6 | * [logging.md](logging.md) : Structure of journald log entries and how to use them 7 | * [ssl-cert.md](ssl-cert.md) : Setup, configuration and updating of SSL-certificates 8 | * [automatic-backups.md](automatic-backups.md) : Setup of automatic backups creation 9 | -------------------------------------------------------------------------------- /doc/operation/certbot.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Certbot 3 | Documentation=file:///usr/share/doc/python-certbot-doc/html/index.html 4 | Documentation=https://letsencrypt.readthedocs.io/en/latest/ 5 | 6 | [Service] 7 | Type=oneshot 8 | PrivateTmp=true 9 | ExecStart=/usr/bin/certbot renew --pre-hook "bash -c \"cd /home/stm/simple-task-manager && docker-compose stop\"" --post-hook "bash -c \"cd /home/stm/simple-task-manager && docker-compose start\"" 10 | 11 | [Install] 12 | WantedBy=multi-user.target 13 | -------------------------------------------------------------------------------- /doc/operation/certbot.timer: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Certbot renewal 3 | 4 | [Timer] 5 | OnCalendar=Mon *-*-* 00:00:00 6 | 7 | [Install] 8 | WantedBy=multi-user.target 9 | -------------------------------------------------------------------------------- /doc/operation/docker.md: -------------------------------------------------------------------------------- 1 | This project uses a lot of the docker feature, including docker-compose. 2 | 3 | I assume you're familiar with docker, so just take a look at the `docker-compose.yml` to get an idea of what container this project uses. 4 | 5 | # Structure 6 | 7 | We have the overall `docker-compose.yml` which contains four service definitions: 8 | 9 | * `stm-client`: The webclient 10 | * `stm-server`: The go-server application without the database 11 | * `stm-db`: The database 12 | 13 | ## Image versions 14 | 15 | The container use a specific version of an image (e.g. `postgres:12`) instead of general tags like `:latest`. 16 | This ensures that a specific version of the SimpleTaskManager still builds and runs in months or even years. 17 | 18 | # Docker hub 19 | 20 | To make deployment easy for everyone, pre-build images are uploaded to [docker hub](https://hub.docker.com/u/simpletaskmanager) which can be used to deploy your own instance of STM without the whole development setup on your server. 21 | 22 | The exact deployment process is described in the [linux.md](./linux.md) file. 23 | 24 | # Logging 25 | 26 | The containers are using the `journald` driver for logging. 27 | So accessing the logs is also possible via e.g. `journalctl CONTAINER_NAME=stm-db` and the logs are appended to the journal after restarting/rebuilding the container. 28 | For more logging commands see the [logging documentation file](./logging.md). -------------------------------------------------------------------------------- /doc/operation/stm-backup.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=STM database backup creation 3 | 4 | [Service] 5 | WorkingDirectory=/home/stm/simple-task-manager 6 | Type=oneshot 7 | PrivateTmp=true 8 | EnvironmentFile=/home/stm/simple-task-manager/.env 9 | ExecStart=/home/stm/simple-task-manager/create-backup.sh 10 | 11 | [Install] 12 | WantedBy=multi-user.target 13 | -------------------------------------------------------------------------------- /doc/operation/stm-backup.timer: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=STM database backup creation 3 | 4 | [Timer] 5 | OnCalendar=*-*-* 00:00:00 6 | 7 | [Install] 8 | WantedBy=multi-user.target 9 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.4' 2 | 3 | services: 4 | stm-server: 5 | image: simpletaskmanager/stm-server:1.6.1 6 | container_name: stm-server 7 | environment: 8 | - STM_OAUTH2_CLIENT_ID 9 | - STM_OAUTH2_SECRET 10 | - STM_DB_USERNAME 11 | - STM_DB_PASSWORD 12 | - STM_DB_HOST 13 | network_mode: host 14 | restart: unless-stopped 15 | volumes: 16 | - /etc/letsencrypt:/etc/letsencrypt 17 | - $STM_SERVER_CONFIG:/stm-server/config.json 18 | # 2020-12-09 hauke96: See systemd issue below 19 | # depends_on: 20 | # - "stm-db" 21 | logging: 22 | driver: 'journald' 23 | stm-client: 24 | image: simpletaskmanager/stm-client:1.6.1 25 | container_name: stm-client 26 | network_mode: host 27 | restart: unless-stopped 28 | volumes: 29 | - /etc/letsencrypt:/etc/letsencrypt 30 | - $STM_NGINX_CONFIG:/etc/nginx/conf.d/default.conf 31 | # This allows you to show arbitrary notes on the login screen (e.g. to inform user about maintenance): 32 | #- ./notice.de.html:/usr/share/nginx/html/assets/i18n/notice.de.html 33 | logging: 34 | driver: 'journald' 35 | stm-db: 36 | image: postgres:12 37 | container_name: stm-db 38 | restart: unless-stopped 39 | network_mode: host 40 | environment: 41 | - POSTGRES_USER=${STM_DB_USERNAME} 42 | - POSTGRES_PASSWORD=${STM_DB_PASSWORD} 43 | volumes: 44 | - ./postgres-data:/var/lib/postgresql/data 45 | # 2020-12-09 hauke96: Because health checks are creating mount points, newer 46 | # systemd versions are flodding the logs with succeed-messages rendering the 47 | # logs more or less useless, because they are way too large. I disable health 48 | # checks here until there's a solution for that. 49 | # 50 | # healthcheck: 51 | # test: ["CMD-SHELL", "pg_isready -U ${STM_DB_USERNAME}"] 52 | # interval: 1s 53 | # timeout: 2s 54 | # retries: 30 55 | # start_period: 1s 56 | logging: 57 | driver: 'journald' 58 | -------------------------------------------------------------------------------- /screenshot.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hauke96/simple-task-manager/03829aa290467d925e3d52262899a2a6a51819a4/screenshot.webp -------------------------------------------------------------------------------- /server/.dockerignore: -------------------------------------------------------------------------------- 1 | docker-compose* 2 | .dockerignore 3 | .git 4 | .gitignore 5 | server 6 | server.debug 7 | 8 | -------------------------------------------------------------------------------- /server/Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 1: Build 2 | FROM golang:1.23-alpine AS builder 3 | 4 | COPY . /stm-server/ 5 | WORKDIR /stm-server/ 6 | 7 | RUN go build -o ./server . 8 | 9 | # Stage 2: Run 10 | FROM alpine:3.21 11 | 12 | RUN mkdir /stm-server 13 | WORKDIR /stm-server/ 14 | 15 | COPY --from=builder /stm-server/server ./ 16 | COPY --from=builder /stm-server/database/scripts ./database/scripts 17 | COPY --from=builder /stm-server/database/init-db.sh ./database/init-db.sh 18 | COPY --from=builder /stm-server/config/default.json ./config.json 19 | 20 | RUN apk add --no-cache bash grep postgresql-client libc6-compat 21 | # "libc6-compat" needed to make go-binary executable 22 | 23 | ENTRYPOINT cd database && ./init-db.sh && cd .. && ./server -c ./config.json 24 | -------------------------------------------------------------------------------- /server/api/context.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "database/sql" 5 | "github.com/pkg/errors" 6 | "stm/comment" 7 | "stm/database" 8 | "stm/export" 9 | "stm/oauth2" 10 | "stm/permission" 11 | "stm/project" 12 | "stm/task" 13 | "stm/util" 14 | "stm/websocket" 15 | ) 16 | 17 | type Context struct { 18 | *util.Logger 19 | Token *oauth2.Token 20 | Transaction *sql.Tx 21 | ProjectService *project.Service 22 | TaskService *task.Service 23 | ExportService *export.Service 24 | WebsocketSender *websocket.Sender 25 | } 26 | 27 | // createContext starts a new Transaction and creates new service instances which use this new Transaction so that all 28 | // services (also those calling each other) are using the same Transaction. 29 | func createContext(token *oauth2.Token, logger *util.Logger) (*Context, error) { 30 | ctx := &Context{} 31 | ctx.Token = token 32 | ctx.Logger = logger 33 | 34 | tx, err := database.GetTransaction(logger) 35 | if err != nil { 36 | return nil, errors.Wrap(err, "error getting Transaction") 37 | } 38 | ctx.Transaction = tx 39 | 40 | permissionStore := permission.Init(tx, ctx.Logger) 41 | commentStore := comment.GetStore(tx, ctx.Logger) 42 | commentService := comment.Init(ctx.Logger, commentStore) 43 | 44 | ctx.TaskService = task.Init(tx, ctx.Logger, permissionStore, commentService, commentStore) 45 | ctx.ProjectService = project.Init(tx, ctx.Logger, ctx.TaskService, permissionStore, commentService, commentStore) 46 | ctx.ExportService = export.Init(logger, ctx.ProjectService) 47 | ctx.WebsocketSender = websocket.Init(ctx.Logger) 48 | 49 | return ctx, nil 50 | } 51 | -------------------------------------------------------------------------------- /server/comment/api.go: -------------------------------------------------------------------------------- 1 | package comment 2 | 3 | type DraftDto struct { 4 | Text string `json:"text"` // The text of the comment. 5 | } 6 | -------------------------------------------------------------------------------- /server/comment/entity.go: -------------------------------------------------------------------------------- 1 | package comment 2 | 3 | import "time" 4 | 5 | type Comment struct { 6 | Id string `json:"id"` // The ID of the task. 7 | Text string `json:"text"` // The name of the task. If the properties of the geometry feature contain the field "name", this field is used here. If no name has been set, this field will be empty. 8 | AuthorId string `json:"authorId"` // The user-ID of the user who is currently assigned to this task. Will never be NULL but might be empty. 9 | CreationDate *time.Time `json:"creationDate"` // The time this comment was created at. 10 | } 11 | -------------------------------------------------------------------------------- /server/comment/service.go: -------------------------------------------------------------------------------- 1 | package comment 2 | 3 | import ( 4 | "fmt" 5 | "github.com/pkg/errors" 6 | "stm/config" 7 | "stm/util" 8 | "time" 9 | ) 10 | 11 | type Service struct { 12 | *util.Logger 13 | store *Store 14 | } 15 | 16 | func Init(logger *util.Logger, store *Store) *Service { 17 | return &Service{ 18 | Logger: logger, 19 | store: store, 20 | } 21 | } 22 | 23 | func (s *Service) AddComment(listId string, commentDraft *DraftDto, authorId string) error { 24 | if len(commentDraft.Text) > config.Conf.MaxCommentLength { 25 | return errors.New(fmt.Sprintf("Comment too long. Allowed are %d characters but found %d.", config.Conf.MaxCommentLength, len(commentDraft.Text))) 26 | } 27 | 28 | return s.store.addComment(listId, commentDraft.Text, authorId, time.Now().UTC()) 29 | } 30 | -------------------------------------------------------------------------------- /server/comment/service_test.go: -------------------------------------------------------------------------------- 1 | package comment 2 | 3 | import ( 4 | "database/sql" 5 | "github.com/hauke96/sigolo" 6 | "stm/config" 7 | "stm/test" 8 | "stm/util" 9 | "testing" 10 | ) 11 | 12 | var ( 13 | tx *sql.Tx 14 | s *Service 15 | h *test.Helper 16 | ) 17 | 18 | func TestMain(m *testing.M) { 19 | h = test.NewTestHelper(setup) 20 | m.Run() 21 | } 22 | 23 | func setup() { 24 | sigolo.LogLevel = sigolo.LOG_DEBUG 25 | config.LoadConfig("../test/test-config.json") 26 | h.InitWithDummyData(config.Conf.DbUsername, config.Conf.DbPassword, config.Conf.DbDatabase) 27 | tx = h.NewTransaction() 28 | 29 | logger := util.NewLogger() 30 | 31 | s = Init(logger, GetStore(tx, logger)) 32 | } 33 | 34 | // 35 | //func TestGetProjects(t *testing.T) { 36 | // h.Run(t, func() error { 37 | // comments, err := s.GetComments("2") 38 | // if err != nil { 39 | // return err 40 | // } 41 | // 42 | // if len(comments) != 2 { 43 | // return errors.Errorf("Expected %d comment for project %d but found %d", 3, 1, len(comments)) 44 | // } 45 | // 46 | // if comments[0].Id != "1" { 47 | // return errors.Errorf("Expected comment ID %s but got %s", "1", comments[0].Id) 48 | // } 49 | // if comments[1].Id != "2" { 50 | // return errors.Errorf("Expected comment ID %s but got %s", "2", comments[1].Id) 51 | // } 52 | // 53 | // return nil 54 | // }) 55 | //} 56 | -------------------------------------------------------------------------------- /server/config/api.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | type Dto struct { 4 | SourceRepoURL string `json:"sourceRepoUrl"` // URL to the source code repository. 5 | MaxTasksPerProject int `json:"maxTasksPerProject"` // Maximum amount of tasks allowed for a project. 6 | MaxDescriptionLength int `json:"maxDescriptionLength"` // Maximum length for the project description in characters. Default: 1000. 7 | TestEnvironment bool `json:"testEnvironment"` // True when the server runs in an test environment 8 | OsmApiUrl string `json:"osmApiUrl"` // The base-URL to the OSM server. 9 | } 10 | 11 | func GetConfigDto() *Dto { 12 | return &Dto{ 13 | SourceRepoURL: Conf.SourceRepoURL, 14 | MaxTasksPerProject: Conf.MaxTasksPerProject, 15 | MaxDescriptionLength: Conf.MaxDescriptionLength, 16 | TestEnvironment: Conf.TestEnvironment, 17 | OsmApiUrl: Conf.OsmApiUrl, 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /server/config/api_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "github.com/pkg/errors" 6 | "testing" 7 | ) 8 | 9 | func TestGetConfigDto(t *testing.T) { 10 | h.Run(t, func() error { 11 | Conf = &Config{} 12 | Conf.SourceRepoURL = "https://some.url/my/repo" 13 | Conf.MaxDescriptionLength = 200 14 | Conf.MaxTasksPerProject = 345 15 | 16 | dto := GetConfigDto() 17 | 18 | if dto.SourceRepoURL != Conf.SourceRepoURL { 19 | return errors.New(fmt.Sprintf("Dto value of 'SourceRepoURL' wrong: Wanted %s but was %s", Conf.SourceRepoURL, dto.SourceRepoURL)) 20 | } 21 | if dto.MaxDescriptionLength != Conf.MaxDescriptionLength { 22 | return errors.New(fmt.Sprintf("Dto value of 'MaxDescriptionLength' wrong: Wanted %d but was %d", Conf.MaxDescriptionLength, dto.MaxDescriptionLength)) 23 | } 24 | if dto.MaxTasksPerProject != Conf.MaxTasksPerProject { 25 | return errors.New(fmt.Sprintf("Dto value of 'MaxTasksPerProject' wrong: Wanted %d but was %d", Conf.MaxTasksPerProject, dto.MaxTasksPerProject)) 26 | } 27 | 28 | return nil 29 | }) 30 | } 31 | -------------------------------------------------------------------------------- /server/config/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "server-url": "http://127.0.0.1", 3 | "client-auth-redirect-url": "http://localhost:4200/oauth-landing", 4 | "debug-logging": true, 5 | "max-task-per-project": 100 6 | } -------------------------------------------------------------------------------- /server/config/local.json: -------------------------------------------------------------------------------- 1 | { 2 | "server-url": "http://localhost", 3 | "client-auth-redirect-url": "http://localhost:4200/oauth-landing", 4 | "osm-base-url": "http://localhost:9000", 5 | "osm-api-url": "http://localhost:9000/api/0.6", 6 | "debug-logging": true 7 | } -------------------------------------------------------------------------------- /server/config/prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "server-url": "https://stm.hauke-stieler.de", 3 | "client-auth-redirect-url": "https://stm.hauke-stieler.de/oauth-landing", 4 | "ssl-cert-file": "/etc/letsencrypt/live/stm.hauke-stieler.de/fullchain.pem", 5 | "ssl-key-file": "/etc/letsencrypt/live/stm.hauke-stieler.de/privkey.pem", 6 | "debug-logging": false, 7 | "max-task-per-project": 2000 8 | } 9 | -------------------------------------------------------------------------------- /server/config/root_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | // This is just a helper file because there are multiple test files in this package. 4 | 5 | import ( 6 | "stm/test" 7 | "testing" 8 | ) 9 | 10 | var ( 11 | h *test.Helper 12 | ) 13 | 14 | func TestMain(m *testing.M) { 15 | h = &test.Helper{} 16 | m.Run() 17 | } 18 | -------------------------------------------------------------------------------- /server/database/database.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | _ "github.com/lib/pq" 7 | "github.com/pkg/errors" 8 | "stm/config" 9 | "stm/util" 10 | ) 11 | 12 | var ( 13 | db *sql.DB 14 | ) 15 | 16 | // GetTransaction tries to open to the database and creates a transaction. Only if the initial connection succeeds, 17 | // a reconnect loop starts. 18 | func GetTransaction(logger *util.Logger) (*sql.Tx, error) { 19 | if db == nil { // No database connection at all 20 | err := open() 21 | if err != nil { 22 | logger.Err("Opening initial DB connection failed") 23 | return nil, err 24 | } 25 | } else if err := db.Ping(); err != nil { // Check the DB connection is broken and try to reconnect 26 | logger.Err("DB ping check failed with error: %s", err.Error()) 27 | logger.Log("Try to reconnect...") 28 | 29 | err := open() 30 | if err != nil { 31 | logger.Err("Reconnect failed") 32 | return nil, err 33 | } 34 | 35 | logger.Log("Successfully created new database connection") 36 | } 37 | 38 | return db.Begin() 39 | } 40 | 41 | // open tries to open to the database and performs a simple health-check by using the "Ping" function on the database. 42 | // Only if the check was successful, the "db" variable is set. 43 | func open() error { 44 | dbConn, err := sql.Open("postgres", fmt.Sprintf("host=%s user=%s password=%s dbname=%s sslmode=disable", config.Conf.DbHost, config.Conf.DbUsername, config.Conf.DbPassword, config.Conf.DbDatabase)) 45 | if err != nil { 46 | return errors.Wrap(err, "unable to open database connection") 47 | } 48 | 49 | err = dbConn.Ping() 50 | if err != nil { 51 | return errors.Wrap(err, "ping on newly opened database connection failed") 52 | } 53 | 54 | db = dbConn 55 | return nil 56 | } 57 | -------------------------------------------------------------------------------- /server/database/scripts/000_init.sql: -------------------------------------------------------------------------------- 1 | BEGIN TRANSACTION; 2 | 3 | CREATE TABLE IF NOT EXISTS db_versions( 4 | version TEXT NOT NULL 5 | ); 6 | 7 | -- Store version of the database scheme 8 | INSERT INTO db_versions VALUES('000'); 9 | 10 | END TRANSACTION; -------------------------------------------------------------------------------- /server/database/scripts/001_basic-tables.sql: -------------------------------------------------------------------------------- 1 | BEGIN TRANSACTION; 2 | 3 | -- Define tables "projects", "tasks", "db_version" 4 | CREATE TABLE projects( 5 | id SERIAL PRIMARY KEY NOT NULL, 6 | name TEXT NOT NULL, 7 | task_ids TEXT NOT NULL, 8 | users TEXT NOT NULL, 9 | owner TEXT NOT NULL 10 | ); 11 | 12 | CREATE TABLE tasks( 13 | id SERIAL PRIMARY KEY NOT NULL, 14 | process_points INT, 15 | max_process_points INT, 16 | geometry TEXT, 17 | assigned_user TEXT 18 | ); 19 | 20 | INSERT INTO db_versions VALUES('001'); 21 | 22 | END TRANSACTION; -------------------------------------------------------------------------------- /server/database/scripts/002_project-description.sql: -------------------------------------------------------------------------------- 1 | BEGIN TRANSACTION; 2 | 3 | ALTER TABLE projects ADD COLUMN description TEXT NOT NULL DEFAULT ''; 4 | 5 | INSERT INTO db_versions VALUES('002'); 6 | 7 | END TRANSACTION; -------------------------------------------------------------------------------- /server/database/scripts/003_string-to-array-migration.sql: -------------------------------------------------------------------------------- 1 | BEGIN TRANSACTION; 2 | 3 | ALTER TABLE projects ADD COLUMN task_id_array TEXT[] NOT NULL DEFAULT '{}'; 4 | 5 | -- Turn every "task_ids" field into an array and store it in "task_id_array" 6 | UPDATE projects SET task_id_array=subquery.regexp_split_to_array FROM (SELECT id,(regexp_split_to_array(task_ids, ',')) FROM projects) AS subquery WHERE projects.id=subquery.id; 7 | 8 | 9 | -- Remove the old "task_ids" and rename the new column 10 | ALTER TABLE projects DROP COLUMN task_ids; 11 | ALTER TABLE projects RENAME COLUMN task_id_array TO task_ids; 12 | 13 | -- Same as above, now for the users: 14 | ALTER TABLE projects ADD COLUMN user_array TEXT[] NOT NULL DEFAULT '{}'; 15 | UPDATE projects SET user_array=subquery.regexp_split_to_array FROM (SELECT id,(regexp_split_to_array(users, ',')) FROM projects) AS subquery WHERE projects.id=subquery.id; 16 | ALTER TABLE projects DROP COLUMN users; 17 | ALTER TABLE projects RENAME COLUMN user_array TO users; 18 | 19 | INSERT INTO db_versions VALUES('003'); 20 | 21 | END TRANSACTION; -------------------------------------------------------------------------------- /server/database/scripts/005_migrate-to-geo-json.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | OUTPUT_FILE=".tmp.migrate-task-geometries.sql" 4 | 5 | # The current stored coordinate list comes in between these strings and BOOM we have some GeoJSON to store :) 6 | # This GeoJSON format is exactly the same as the OpenLayers class "format.GeoJson" output for a feature. I actually 7 | # copied these strings from that class output. 8 | GEOJSON_HEAD="{\"type\":\"Feature\",\"geometry\":{\"type\":\"Polygon\",\"coordinates\":[" 9 | GEOJSON_FOOT="]},\"properties\":null}" 10 | 11 | RAW_DATA=$(psql -h $STM_DB_HOST -U $STM_DB_USERNAME -t -A -c "SELECT id,geometry FROM tasks;" $STM_DB_DATABASE) 12 | 13 | echo "BEGIN TRANSACTION;" > $OUTPUT_FILE 14 | 15 | IFS=$'\n' 16 | for ROW in $RAW_DATA 17 | do 18 | IFS='|' read -ra ROW_ARRAY <<< "$ROW" 19 | IFS=$'\n' 20 | 21 | TASK_ID=${ROW_ARRAY[0]} 22 | ORIGINAL_GEOMETRY=${ROW_ARRAY[1]} 23 | 24 | echo "====== TASK $TASK_ID ======" 25 | echo "Task ID : $TASK_ID" 26 | echo "Geometry : $ORIGINAL_GEOMETRY" 27 | echo 28 | 29 | echo "" >> $OUTPUT_FILE 30 | echo "-- Task: $TASK_ID" >> $OUTPUT_FILE 31 | 32 | GEOJSON_GEOMETRY="$GEOJSON_HEAD$ORIGINAL_GEOMETRY$GEOJSON_FOOT" 33 | echo "$GEOJSON_GEOMETRY" 34 | 35 | echo "UPDATE tasks SET geometry='$GEOJSON_GEOMETRY' WHERE id='$TASK_ID';" >> $OUTPUT_FILE 36 | done 37 | 38 | # 39 | # Set version 40 | # 41 | echo "INSERT INTO db_versions VALUES('005');" >> $OUTPUT_FILE 42 | 43 | # 44 | # Generate the SQL script 45 | # 46 | 47 | echo "END TRANSACTION;" >> $OUTPUT_FILE 48 | 49 | echo "Execute SQL..." 50 | 51 | psql -q -v ON_ERROR_STOP=1 -h $STM_DB_HOST -U $STM_DB_USERNAME -f $OUTPUT_FILE $STM_DB_DATABASE 52 | OK=$? 53 | if [ $OK -ne 0 ] 54 | then 55 | echo 56 | echo "Migration FAILED!" 57 | echo 58 | echo "Exit code: $OK" 59 | echo "See the error log and the '$OUTPUT_FILE' for details." 60 | exit 1 61 | fi 62 | 63 | echo "Migration DONE" 64 | echo "Remove '$OUTPUT_FILE'" 65 | rm $OUTPUT_FILE -------------------------------------------------------------------------------- /server/database/scripts/006_project-task-relation.sql: -------------------------------------------------------------------------------- 1 | BEGIN TRANSACTION; 2 | 3 | ALTER TABLE tasks ADD COLUMN project_id int; 4 | ALTER TABLE tasks ADD CONSTRAINT fk_project FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE; 5 | 6 | INSERT INTO db_versions VALUES('006'); 7 | 8 | END TRANSACTION; -------------------------------------------------------------------------------- /server/database/scripts/007_remove-stale-tasks.sql: -------------------------------------------------------------------------------- 1 | BEGIN TRANSACTION; 2 | 3 | DELETE FROM tasks WHERE NOT 4 | ( 5 | id = ANY 6 | ( 7 | ( 8 | SELECT array_agg(c) FROM 9 | ( 10 | SELECT unnest(task_ids) FROM projects 11 | ) AS dt(c) 12 | )::INT[] 13 | ) 14 | ); 15 | 16 | INSERT INTO db_versions VALUES('007'); 17 | 18 | END TRANSACTION; -------------------------------------------------------------------------------- /server/database/scripts/008_migrate-project-task-relation.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | source scripts/common.sh 4 | 5 | OUTPUT_FILE=".tmp.migrate-project-task-relation.sql" 6 | RAW_DATA=$(psql -h $STM_DB_HOST -U $STM_DB_USERNAME -t -A -c "SELECT id,task_ids FROM projects;" $STM_DB_DATABASE | tr -d "{" | tr -d "}") 7 | 8 | begin_tx 9 | 10 | IFS=$'\n' 11 | for ROW in $RAW_DATA 12 | do 13 | IFS='|' read -ra ROW_ARRAY <<< "$ROW" 14 | 15 | PROJECT_ID=${ROW_ARRAY[0]} 16 | TASK_IDS=${ROW_ARRAY[1]} 17 | 18 | IFS=$',' 19 | for TASK_ID in $TASK_IDS 20 | do 21 | echo "UPDATE tasks SET project_id = $PROJECT_ID WHERE id = $TASK_ID;" >> $OUTPUT_FILE 22 | done 23 | 24 | IFS=$'\n' 25 | done 26 | 27 | # 28 | # Set version 29 | # 30 | set_version "008" 31 | 32 | # 33 | # Generate the SQL script 34 | # 35 | end_tx 36 | 37 | execute -------------------------------------------------------------------------------- /server/database/scripts/009_remove-task-ids-from-projects.sql: -------------------------------------------------------------------------------- 1 | BEGIN TRANSACTION; 2 | 3 | ALTER TABLE projects DROP COLUMN task_ids; 4 | 5 | INSERT INTO db_versions VALUES('009'); 6 | 7 | END TRANSACTION; -------------------------------------------------------------------------------- /server/database/scripts/010_add-project-creation-date.sql: -------------------------------------------------------------------------------- 1 | BEGIN TRANSACTION; 2 | 3 | -- Can be null for old data before adding this column 4 | ALTER TABLE projects ADD COLUMN creation_date TIMESTAMP; 5 | 6 | INSERT INTO db_versions VALUES('010'); 7 | 8 | END TRANSACTION; -------------------------------------------------------------------------------- /server/database/scripts/011_comments.sql: -------------------------------------------------------------------------------- 1 | BEGIN TRANSACTION; 2 | 3 | CREATE TABLE comment_lists 4 | ( 5 | id SERIAL PRIMARY KEY NOT NULL 6 | ); 7 | 8 | CREATE TABLE comments 9 | ( 10 | id SERIAL PRIMARY KEY NOT NULL, 11 | comment_list_id INT NOT NULL, 12 | text TEXT NOT NULL, 13 | creation_date TIMESTAMP NOT NULL, 14 | author_id TEXT NOT NULL 15 | ); 16 | 17 | ALTER TABLE tasks ADD COLUMN comment_list_id INT; 18 | ALTER TABLE projects ADD COLUMN comment_list_id INT; 19 | 20 | ALTER TABLE tasks ADD FOREIGN KEY (comment_list_id) REFERENCES comment_lists; 21 | ALTER TABLE projects ADD FOREIGN KEY (comment_list_id) REFERENCES comment_lists; 22 | ALTER TABLE comments ADD FOREIGN KEY (comment_list_id) REFERENCES comment_lists; 23 | 24 | INSERT INTO db_versions VALUES ('011'); 25 | 26 | END TRANSACTION; -------------------------------------------------------------------------------- /server/database/scripts/012_initial-comment-migration.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | source scripts/common.sh 4 | 5 | OUTPUT_FILE=".tmp.initial-comment-migration.sql" 6 | TASK_IDS=$(psql -h $STM_DB_HOST -U $STM_DB_USERNAME -t -A -c "SELECT id FROM tasks;" $STM_DB_DATABASE) 7 | PROJECT_IDS=$(psql -h $STM_DB_HOST -U $STM_DB_USERNAME -t -A -c "SELECT id FROM projects;" $STM_DB_DATABASE) 8 | 9 | begin_tx 10 | 11 | for TASK_ID in $TASK_IDS 12 | do 13 | echo "WITH new_comment_list AS (INSERT INTO comment_lists DEFAULT VALUES RETURNING id, $TASK_ID as tid) UPDATE tasks t SET comment_list_id = n.id FROM new_comment_list n WHERE t.id = n.tid;" >> $OUTPUT_FILE 14 | done 15 | for PROJECT_ID in $PROJECT_IDS 16 | do 17 | echo "WITH new_comment_list AS (INSERT INTO comment_lists DEFAULT VALUES RETURNING id, $PROJECT_ID as pid) UPDATE projects p SET comment_list_id = n.id FROM new_comment_list n WHERE p.id = n.pid;" >> $OUTPUT_FILE 18 | done 19 | 20 | echo "ALTER TABLE tasks ALTER COLUMN comment_list_id SET NOT NULL;" >> $OUTPUT_FILE 21 | echo "ALTER TABLE projects ALTER COLUMN comment_list_id SET NOT NULL;" >> $OUTPUT_FILE 22 | 23 | set_version "012" 24 | 25 | end_tx 26 | 27 | execute -------------------------------------------------------------------------------- /server/database/scripts/013_josm-data-source.sql: -------------------------------------------------------------------------------- 1 | BEGIN TRANSACTION; 2 | 3 | ALTER TABLE projects ADD COLUMN josm_data_source TEXT; 4 | 5 | -- Set default value to "Overpass" since it's the current behavior 6 | UPDATE projects SET josm_data_source='OVERPASS'; 7 | 8 | INSERT INTO db_versions VALUES ('013'); 9 | 10 | END TRANSACTION; -------------------------------------------------------------------------------- /server/database/scripts/common.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | function begin_tx() 4 | { 5 | echo "BEGIN TRANSACTION;" > $OUTPUT_FILE 6 | } 7 | 8 | function end_tx() 9 | { 10 | echo "END TRANSACTION;" >> $OUTPUT_FILE 11 | } 12 | 13 | # 14 | # Adds the parameter $1 to the db_version table 15 | # 16 | function set_version() 17 | { 18 | echo "INSERT INTO db_versions VALUES('$1');" >> $OUTPUT_FILE 19 | } 20 | 21 | # 22 | # Executes the SQL-script stored at $OUTPUT_FILE. 23 | # 24 | function execute() 25 | { 26 | echo "Execute SQL..." 27 | 28 | psql -q -v ON_ERROR_STOP=1 -h $STM_DB_HOST -U $STM_DB_USERNAME -f $OUTPUT_FILE $STM_DB_DATABASE 29 | OK=$? 30 | if [ $OK -ne 0 ] 31 | then 32 | echo 33 | echo "Migration FAILED!" 34 | echo 35 | echo "Exit code: $OK" 36 | echo "See the error log and the '$OUTPUT_FILE' for details." 37 | exit 1 38 | fi 39 | 40 | echo "Migration DONE" 41 | 42 | echo "Remove '$OUTPUT_FILE'" 43 | rm $OUTPUT_FILE 44 | } -------------------------------------------------------------------------------- /server/export/entity.go: -------------------------------------------------------------------------------- 1 | package export 2 | 3 | import "time" 4 | 5 | type ProjectExport struct { 6 | Name string `json:"name"` 7 | Users []string `json:"users"` 8 | Owner string `json:"owner"` 9 | Description string `json:"description"` 10 | CreationDate *time.Time `json:"creationDate"` 11 | Tasks []*TaskExport `json:"tasks"` 12 | } 13 | 14 | type TaskExport struct { 15 | Name string `json:"name"` 16 | ProcessPoints int `json:"processPoints"` 17 | MaxProcessPoints int `json:"maxProcessPoints"` 18 | Geometry string `json:"geometry"` 19 | // TODO Use "Id" as suffix? 20 | AssignedUser string `json:"assignedUser"` 21 | } 22 | -------------------------------------------------------------------------------- /server/go.mod: -------------------------------------------------------------------------------- 1 | module stm 2 | 3 | go 1.22.0 4 | 5 | toolchain go1.23.4 6 | 7 | require ( 8 | github.com/alecthomas/kong v1.6.1 9 | github.com/gorilla/mux v1.8.1 10 | github.com/gorilla/websocket v1.5.3 11 | github.com/hauke96/sigolo v1.1.0 12 | github.com/lib/pq v1.10.9 13 | github.com/paulmach/go.geojson v1.5.0 14 | github.com/pkg/errors v0.9.1 15 | github.com/swaggo/http-swagger v1.3.4 16 | github.com/swaggo/swag v1.16.4 17 | golang.org/x/oauth2 v0.25.0 18 | ) 19 | 20 | require ( 21 | github.com/KyleBanks/depth v1.2.1 // indirect 22 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 23 | github.com/go-openapi/jsonreference v0.21.0 // indirect 24 | github.com/go-openapi/spec v0.21.0 // indirect 25 | github.com/go-openapi/swag v0.23.0 // indirect 26 | github.com/josharian/intern v1.0.0 // indirect 27 | github.com/mailru/easyjson v0.9.0 // indirect 28 | github.com/swaggo/files v1.0.1 // indirect 29 | golang.org/x/net v0.34.0 // indirect 30 | golang.org/x/tools v0.29.0 // indirect 31 | gopkg.in/yaml.v3 v3.0.1 // indirect 32 | ) 33 | -------------------------------------------------------------------------------- /server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/alecthomas/kong" 6 | _ "github.com/lib/pq" // Make driver "postgres" usable 7 | "os" 8 | "stm/oauth2" 9 | 10 | "github.com/hauke96/sigolo" 11 | "stm/api" 12 | "stm/config" 13 | _ "stm/docs" 14 | "stm/util" 15 | ) 16 | 17 | var cli struct { 18 | Config string `help:"The config file. CLI argument override the settings from that file." short:"c" default:"./config/default.json"` 19 | Version bool `help:"Print the version of STM" short:"v"` 20 | Debug bool `help:"Use debug logging" short:"d"` 21 | } 22 | 23 | func configureCliArgs() { 24 | kong.Name("SimpleTaskManager") 25 | kong.Description("A tool dividing an area of the map into smaller tasks.") 26 | } 27 | 28 | func configureLogging() { 29 | if config.Conf.DebugLogging { 30 | sigolo.LogLevel = sigolo.LOG_DEBUG 31 | } else { 32 | sigolo.LogLevel = sigolo.LOG_INFO 33 | } 34 | } 35 | 36 | // @title SimpleTaskManager Server 37 | // @version 1.6.1 38 | // @description This is the SimpleTaskManager (STM) Server. See the GitHub repo '/doc/api/' for further details on authentication, websockets and changelogs. 39 | 40 | // @contact.name STM issue tracker 41 | // @contact.url https://github.com/hauke96/simple-task-manager/issues 42 | 43 | // @license.name GNU General Public License 3.0 44 | // @license.url https://github.com/hauke96/simple-task-manager/blob/master/LICENSE 45 | func main() { 46 | configureCliArgs() 47 | kong.Parse(&cli) 48 | 49 | if cli.Version { 50 | fmt.Printf("STM - SimpleTaskManager\nVersion %s\n", util.VERSION) 51 | return 52 | } 53 | 54 | if cli.Debug { 55 | sigolo.LogLevel = sigolo.LOG_DEBUG 56 | } 57 | 58 | // Load config an override with CLI args 59 | sigolo.Info("Init simple-task-manager server v" + util.VERSION) 60 | config.LoadConfig(cli.Config) 61 | config.PrintConfig() 62 | 63 | configureLogging() 64 | 65 | // Init of Config, Services, Storages, etc. 66 | oauth2.Init() 67 | sigolo.Info("Initializes services, storages, etc.") 68 | 69 | err := api.Init() 70 | if err != nil { 71 | sigolo.Stack(err) 72 | os.Exit(1) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /server/oauth2/entity.go: -------------------------------------------------------------------------------- 1 | package oauth2 2 | 3 | // Osm is a struct used when requesting user information 4 | type Osm struct { 5 | User OsmUser `xml:"user"` 6 | } 7 | 8 | type OsmUser struct { 9 | DisplayName string `xml:"display_name,attr"` 10 | UserId string `xml:"id,attr"` 11 | } 12 | -------------------------------------------------------------------------------- /server/project/api.go: -------------------------------------------------------------------------------- 1 | package project 2 | 3 | import "stm/task" 4 | 5 | type AddDto struct { 6 | Project DraftDto `json:"project"` 7 | Tasks []task.DraftDto `json:"tasks"` 8 | } 9 | 10 | type DraftDto struct { 11 | Name string `json:"name"` // Name of the project. Must not be NULL or empty. 12 | Description string `json:"description"` // Description of the project. Must not be NULL but cam be empty. 13 | Users []string `json:"users"` // A non-empty list of user-IDs. At least the owner should be in here. 14 | Owner string `json:"owner"` // The user-ID who created this project. Must not be NULL or empty. 15 | JosmDataSource JosmDataSource `json:"josmDataSource"` // The source JOSM should load the data from when opening a task in JOSM. 16 | } 17 | 18 | type UpdateDto struct { 19 | Name string `json:"name"` // Name of the project. Must not be NULL or empty. 20 | Description string `json:"description"` // Description of the project. Must not be NULL but cam be empty. 21 | JosmDataSource JosmDataSource `json:"josmDataSource"` // The source JOSM should load the data from when opening a task in JOSM. 22 | } 23 | -------------------------------------------------------------------------------- /server/project/entity.go: -------------------------------------------------------------------------------- 1 | package project 2 | 3 | import ( 4 | "stm/comment" 5 | "stm/task" 6 | "time" 7 | ) 8 | 9 | type JosmDataSource string 10 | 11 | const ( 12 | OSM JosmDataSource = "OSM" 13 | Overpass JosmDataSource = "OVERPASS" 14 | ) 15 | 16 | type Project struct { 17 | Id string `json:"id"` // The ID of the project. 18 | Name string `json:"name"` // The name of the project. Will not be NULL or empty. 19 | Tasks []*task.Task `json:"tasks"` // List of tasks of the project. Will not be NULL or empty. 20 | // TODO Use "Ids" as suffix? 21 | Users []string `json:"users"` // Array of user-IDs (=members of this project). Will not be NULL or empty. 22 | // TODO Use "Id" as suffix? 23 | Owner string `json:"owner"` // User-ID of the owner/creator of this project. Will not be NULL or empty. 24 | Description string `json:"description"` // Some description, can be empty. Will not be NULL but might be empty. 25 | NeedsAssignment bool `json:"needsAssignment"` // When "true", the tasks of this project need to have an assigned user. 26 | TotalProcessPoints int `json:"totalProcessPoints"` // Sum of all maximum process points of all tasks. 27 | DoneProcessPoints int `json:"doneProcessPoints"` // Sum of all process points that have been set. It applies "0 <= doneProcessPoints <= totalProcessPoints". 28 | CreationDate *time.Time `json:"creationDate"` // UTC Date in RFC 3339 format, can be NIL because of old data in the database. Example: "2006-01-02 15:04:05.999999999 -0700 MST" 29 | Comments []comment.Comment `json:"comments"` // The comment on the project. 30 | JosmDataSource JosmDataSource `json:"josmDataSource"` // The source JOSM should load the data from when opening a task in JOSM. 31 | } 32 | -------------------------------------------------------------------------------- /server/run-tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | SLEEP=3 6 | 7 | # Export variable to be accessible within called scripts 8 | export STM_DB_DATABASE="stm_test" 9 | 10 | function wait() 11 | { 12 | echo "Wait... ($SLEEP s)" 13 | sleep $SLEEP 14 | } 15 | 16 | # If container "stm-db" exists, stop and remove it 17 | if ! docker container list | grep -q "stm-db" 18 | then 19 | cd ../ 20 | echo "Start new 'stm-db' container" 21 | docker-compose up -d --build stm-db 22 | wait 23 | cd server/ 24 | fi 25 | 26 | echo "Remove existing database" 27 | psql -h $STM_DB_HOST -U $STM_DB_USERNAME postgres -tc "DROP DATABASE IF EXISTS $STM_DB_DATABASE;" 28 | 29 | echo "Initialize new database" 30 | cd ./database 31 | ./init-db.sh 32 | 33 | # Switch from "./server/database" into "./server" folder 34 | cd .. 35 | echo "Execute tests" 36 | echo 37 | 38 | # go test github.com/hauke96/simple-task-manager/server/permission -coverprofile=coverage.out -v ./... -args -with-db true | tee test.log 39 | go test -p 1 -coverprofile=coverage.out -v ./... | tee test.log 40 | 41 | # Show failed functions with file and line number. This makes it a bit easier to find them. 42 | echo 43 | if cat test.log | grep -i -q "fail" 44 | then 45 | echo "Failed tests:" 46 | FAILED_FUNCTIONS=$(cat test.log | grep "FAIL:" | grep -o " [a-zA-Z0-9_]* " | sed 's/ //g' | tr '\n' ' ') 47 | for FUNC in $FAILED_FUNCTIONS 48 | do 49 | FUNC_DEF=$(grep --color=never -Hrn "$FUNC" | grep -o --color=never "[a-zA-Z\./_]*\.go:[[:digit:]]*:func $FUNC") 50 | echo " - $FUNC " | tr -d '\n' 51 | echo $FUNC_DEF | grep -o --color=never "[a-zA-Z\./_]*\.go" | tr -d '\n' 52 | echo " : " | tr -d '\n' 53 | echo $FUNC_DEF | grep -o --color=never "[[:digit:]]*" 54 | done 55 | echo 56 | echo "========================================" 57 | echo " FAIL" 58 | echo "========================================" 59 | else 60 | echo "Open coverage with:" 61 | echo " go tool cover -html=../coverage.out" 62 | echo 63 | echo "========================================" 64 | echo " PASSED" 65 | echo "========================================" 66 | fi 67 | 68 | rm -f test.log 69 | -------------------------------------------------------------------------------- /server/task/api.go: -------------------------------------------------------------------------------- 1 | package task 2 | 3 | type DraftDto struct { 4 | MaxProcessPoints int `json:"maxProcessPoints"` // The maximum amount of process points of this task. Must be larger than zero. 5 | ProcessPoints int `json:"processPoints"` // The amount of process points that have been set by the user. It applies that "0 <= processPoints <= maxProcessPoints". 6 | Geometry string `json:"geometry"` // A GeoJson feature with a polygon or multi-polygon geometry. If the feature properties contain the field "name", then this will be used as the name of the task. 7 | } 8 | -------------------------------------------------------------------------------- /server/task/entity.go: -------------------------------------------------------------------------------- 1 | package task 2 | 3 | import "stm/comment" 4 | 5 | type Task struct { 6 | Id string `json:"id"` // The ID of the task. 7 | Name string `json:"name"` // The name of the task. If the properties of the geometry feature contain the field "name", this field is used here. If no name has been set, this field will be empty. 8 | ProcessPoints int `json:"processPoints"` // The amount of process points that have been set by the user. It applies that "0 <= processPoints <= maxProcessPoints". 9 | MaxProcessPoints int `json:"maxProcessPoints"` // The maximum amount of process points of this task. Is larger than zero. 10 | Geometry string `json:"geometry"` // A GeoJson feature of the task wit a polygon or multipolygon geometry. Will never be NULL or empty. 11 | // TODO Use "Id" as suffix? 12 | AssignedUser string `json:"assignedUser"` // The user-ID of the user who is currently assigned to this task. Will never be NULL but might be empty. 13 | Comments []comment.Comment `json:"comments"` 14 | } 15 | -------------------------------------------------------------------------------- /server/test/test-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "server-url": "https://unit-test.stm.org", 3 | "client-auth-redirect-url": "https://unit-test.stm.org/oauth-landing", 4 | "db-database": "stm_test", 5 | "oauth2-client-id": "oAuTH_cliENt-iD", 6 | "oauth2-secret": "seCreT" 7 | } 8 | -------------------------------------------------------------------------------- /server/util/logger.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "fmt" 5 | "github.com/hauke96/sigolo" 6 | "strings" 7 | ) 8 | 9 | var ( 10 | nextTraceId = 0 11 | ) 12 | 13 | func NewLogger() *Logger { 14 | defer func() { nextTraceId++ }() // Just increase trace-ID counter after return statement 15 | return &Logger{LogTraceId: nextTraceId} 16 | } 17 | 18 | type Logger struct { 19 | LogTraceId int 20 | } 21 | 22 | func (l *Logger) Log(format string, args ...interface{}) { 23 | sigolo.Infob(1, "#%x | %s", l.LogTraceId, fmt.Sprintf(format, args...)) 24 | } 25 | 26 | func (l *Logger) Err(format string, args ...interface{}) { 27 | sigolo.Errorb(1, "#%x | %s", l.LogTraceId, fmt.Sprintf(format, args...)) 28 | } 29 | 30 | func (l *Logger) Debug(format string, args ...interface{}) { 31 | sigolo.Debugb(1, "#%x | %s", l.LogTraceId, fmt.Sprintf(format, args...)) 32 | } 33 | 34 | func (l *Logger) Stack(err error) { 35 | sigolo.Stackb(1, err) 36 | } 37 | 38 | func (l *Logger) LogQuery(query string, args ...interface{}) { 39 | for i, a := range args { 40 | query = strings.ReplaceAll(query, fmt.Sprintf("$%d", i+1), fmt.Sprintf("%v", a)) 41 | } 42 | 43 | sigolo.Debugb(1, query) 44 | } 45 | -------------------------------------------------------------------------------- /server/util/random.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "crypto/rand" 5 | "crypto/sha256" 6 | "fmt" 7 | "github.com/pkg/errors" 8 | ) 9 | 10 | func GetRandomString() (string, error) { 11 | randomBytes, err := GetRandomBytes(64) 12 | if err != nil { 13 | return "", err 14 | } 15 | 16 | return fmt.Sprintf("%x", sha256.Sum256(randomBytes)), nil 17 | } 18 | 19 | func GetRandomBytes(count int) ([]byte, error) { 20 | bytes := make([]byte, count) 21 | 22 | n, err := rand.Read(bytes) 23 | 24 | if n != count { 25 | return nil, errors.New(fmt.Sprintf("Could not read all %d random bytes", count)) 26 | } 27 | if err != nil { 28 | return nil, errors.Wrap(err, "Unable to read random bytes") 29 | } 30 | 31 | return bytes, nil 32 | } 33 | -------------------------------------------------------------------------------- /server/util/util.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "fmt" 5 | "github.com/pkg/errors" 6 | "net/http" 7 | "strconv" 8 | "strings" 9 | ) 10 | 11 | const ( 12 | VERSION = "1.6.1" 13 | ) 14 | 15 | func GetParam(param string, r *http.Request) (string, error) { 16 | value := r.FormValue(param) 17 | if strings.TrimSpace(value) == "" { 18 | return "", errors.New(fmt.Sprintf("parameter '%s' not specified", param)) 19 | } 20 | 21 | return value, nil 22 | } 23 | 24 | func GetIntParam(param string, r *http.Request) (int, error) { 25 | valueString, err := GetParam(param, r) 26 | if err != nil { 27 | return 0, err 28 | } 29 | 30 | return strconv.Atoi(valueString) 31 | } 32 | 33 | func ResponseBadRequest(w http.ResponseWriter, logger *Logger, err error) { 34 | ErrorResponse(w, logger, err, http.StatusBadRequest) 35 | } 36 | 37 | func ResponseInternalError(w http.ResponseWriter, logger *Logger, err error) { 38 | ErrorResponse(w, logger, err, http.StatusInternalServerError) 39 | } 40 | 41 | func ResponseUnauthorized(w http.ResponseWriter, logger *Logger, err error) { 42 | ErrorResponse(w, logger, err, http.StatusUnauthorized) 43 | } 44 | 45 | func ErrorResponse(w http.ResponseWriter, logger *Logger, err error, status int) { 46 | if logger != nil { 47 | logger.Err("ErrorResponse with status %d: %s", status, err.Error()) 48 | } 49 | w.WriteHeader(status) 50 | w.Write([]byte(err.Error())) 51 | } 52 | --------------------------------------------------------------------------------