From 96aa3b008446a440c7c44e7f0179d5b98463d0e6 Mon Sep 17 00:00:00 2001 From: GabrielMeyer Date: Tue, 12 Nov 2019 18:30:26 +0100 Subject: [PATCH] Adds the chart and dialog for analog voting --- client/package.json | 76 +-- client/src/app/app.component.scss | 1 + client/src/app/app.component.ts | 39 +- .../app/core/core-services/offline.service.ts | 24 +- .../openslides-status.service.ts | 8 +- .../core-services/relation-manager.service.ts | 14 +- .../has-view-model-list-observable.ts | 5 + ...signment-option-repository.service.spec.ts | 14 + .../assignment-option-repository.service.ts | 75 +++ .../assignment-poll-repository.service.ts | 74 ++- .../assignment-vote-repository.service.ts | 11 + .../app/core/repositories/base-repository.ts | 11 +- .../motion-option-repository.service.spec.ts | 14 + .../motion-option-repository.service.ts | 68 ++ .../motions/motion-poll-repository.service.ts | 47 +- .../motions/motion-vote-repository.service.ts | 7 + .../users/group-repository.service.ts | 7 + .../core/ui-services/banner.service.spec.ts | 12 + .../app/core/ui-services/banner.service.ts | 68 ++ .../ui-services/base-filter-list.service.ts | 2 + .../ui-services/base-poll-dialog.service.ts | 67 ++ .../app/core/ui-services/poll.service.spec.ts | 18 - .../src/app/core/ui-services/poll.service.ts | 216 ------- .../ui-services/voting-banner.service.spec.ts | 18 + .../core/ui-services/voting-banner.service.ts | 65 ++ .../core/ui-services/voting.service.spec.ts | 18 + .../app/core/ui-services/voting.service.ts | 70 ++ .../attachment-control.component.ts | 4 +- .../components/banner/banner.component.html | 18 + .../components/banner/banner.component.scss | 43 ++ .../banner/banner.component.spec.ts | 26 + .../components/banner/banner.component.ts | 40 ++ .../breadcrumb/breadcrumb.component.html | 17 + .../breadcrumb/breadcrumb.component.scss | 25 + .../breadcrumb/breadcrumb.component.spec.ts | 26 + .../breadcrumb/breadcrumb.component.ts | 79 +++ .../components/charts/charts.component.html | 23 + .../components/charts/charts.component.scss | 4 + .../charts/charts.component.spec.ts | 24 + .../components/charts/charts.component.ts | 189 ++++++ .../check-input/check-input.component.html | 21 + .../check-input/check-input.component.scss | 10 + .../check-input/check-input.component.spec.ts | 26 + .../check-input/check-input.component.ts | 147 +++++ .../extension-field.component.html | 1 - .../list-view-table.component.ts | 28 +- .../search-value-selector.component.html | 20 +- .../search-value-selector.component.scss | 21 + .../search-value-selector.component.ts | 39 +- .../models/assignments/assignment-poll.ts | 27 +- .../shared/models/base/base-form-control.ts | 2 +- .../app/shared/models/motions/motion-poll.ts | 18 +- .../src/app/shared/models/poll/base-option.ts | 1 + .../src/app/shared/models/poll/base-poll.ts | 82 +-- .../src/app/shared/models/poll/base-vote.ts | 22 +- client/src/app/shared/shared.module.ts | 38 +- .../agenda-list/agenda-list.component.html | 2 +- .../assignments/assignments-routing.module.ts | 4 +- .../site/assignments/assignments.config.ts | 8 + .../site/assignments/assignments.module.ts | 11 +- .../assignment-detail.component.html | 189 +++--- .../assignment-detail.component.spec.ts | 3 +- .../assignment-detail.component.ts | 13 +- .../assignment-list.component.html | 2 +- .../assignment-list.component.ts | 2 +- .../assignment-poll-detail.component.html | 78 +++ .../assignment-poll-detail.component.scss | 0 .../assignment-poll-detail.component.spec.ts | 27 + .../assignment-poll-detail.component.ts | 59 ++ .../assignment-poll-dialog.component.html | 81 ++- .../assignment-poll-dialog.component.scss | 30 +- .../assignment-poll-dialog.component.ts | 150 +++-- .../assignment-poll-vote.component.html | 47 ++ .../assignment-poll-vote.component.scss | 12 + .../assignment-poll-vote.component.spec.ts | 27 + .../assignment-poll-vote.component.ts | 86 +++ .../assignment-poll.component.html | 76 ++- .../assignment-poll.component.scss | 46 ++ .../assignment-poll.component.spec.ts | 3 +- .../assignment-poll.component.ts | 146 +---- .../models/view-assignment-option.ts | 6 +- .../models/view-assignment-poll.ts | 45 +- .../models/view-assignment-vote.ts | 4 +- ...e.ts => assignment-filter-list.service.ts} | 0 .../assignment-poll-dialog.service.spec.ts | 18 + .../assignment-poll-dialog.service.ts | 22 + .../services/assignment-poll.service.spec.ts | 18 + .../services/assignment-poll.service.ts | 56 ++ .../site/motions/models/view-motion-option.ts | 2 + .../site/motions/models/view-motion-poll.ts | 49 +- .../site/motions/models/view-motion-vote.ts | 2 + .../amendment-list.component.html | 2 +- .../category-list.component.html | 2 +- .../motion-block-detail.component.html | 2 +- .../motion-block-list.component.html | 2 +- .../motion-detail.component.html | 14 +- .../motion-detail.component.scss | 16 +- .../motion-detail.component.spec.ts | 6 +- .../motion-detail/motion-detail.component.ts | 24 +- .../motion-poll-dialog.component.html | 20 - .../motion-poll-dialog.component.scss | 14 - .../motion-poll-dialog.component.ts | 75 --- .../motion-poll-preview.component.html | 56 -- .../motion-poll-preview.component.scss | 7 - .../motion-poll-preview.component.ts | 56 -- .../motion-poll/motion-poll.component.html | 73 --- .../motion-poll/motion-poll.component.scss | 3 - .../motion-poll/motion-poll.component.spec.ts | 27 - .../motion-poll/motion-poll.component.ts | 237 ------- .../motion-detail/motion-detail.module.ts | 10 +- .../motion-list/motion-list.component.html | 2 +- .../motion-poll-detail.component.html | 111 ++-- .../motion-poll-detail.component.scss | 10 + .../motion-poll-detail.component.ts | 145 +---- .../motion-poll-dialog.component.html | 68 ++ .../motion-poll-dialog.component.scss | 0 .../motion-poll-dialog.component.spec.ts | 34 + .../motion-poll-dialog.component.ts | 73 +++ .../motion-poll-list.component.html | 2 +- .../motion-poll-vote.component.html | 28 + .../motion-poll-vote.component.scss | 0 .../motion-poll-vote.component.spec.ts} | 12 +- .../motion-poll-vote.component.ts | 73 +++ .../modules/motion-poll/motion-poll.module.ts | 7 +- .../motion-poll/motion-poll.component.html | 73 +++ .../motion-poll/motion-poll.component.scss | 90 +++ .../motion-poll/motion-poll.component.spec.ts | 25 + .../motion-poll/motion-poll.component.ts | 90 +++ .../workflow-list.component.html | 2 +- client/src/app/site/motions/motions.config.ts | 4 + .../motion-poll-dialog.service.spec.ts | 18 + .../services/motion-poll-dialog.service.ts | 22 + .../services/motion-poll.service.spec.ts | 7 +- .../motions/services/motion-poll.service.ts | 172 +---- .../components/base-poll-detail.component.ts | 240 +++++++ .../components/base-poll-dialog.component.ts | 103 +++ .../components/base-poll-vote.component.ts | 39 ++ .../polls/components/base-poll.component.ts | 54 ++ .../poll-form/poll-form.component.html | 71 +++ .../poll-form/poll-form.component.scss | 32 + .../poll-form/poll-form.component.spec.ts | 26 + .../poll-form/poll-form.component.ts | 155 +++++ .../poll-list/poll-list.component.html | 45 ++ .../poll-list/poll-list.component.scss | 0 .../poll-list/poll-list.component.spec.ts | 27 + .../poll-list/poll-list.component.ts | 52 ++ .../app/site/polls/models/view-base-poll.ts | 111 ++++ .../app/site/polls/polls-routing.module.ts | 18 + client/src/app/site/polls/polls.module.ts | 16 + .../services/base-poll-repository.service.ts | 87 +++ .../services/poll-filter-list.service.spec.ts | 18 + .../services/poll-filter-list.service.ts | 57 ++ .../poll-list-observable.service.spec.ts | 18 + .../services/poll-list-observable.service.ts | 44 ++ .../site/polls/services/poll.service.spec.ts | 17 + .../app/site/polls/services/poll.service.ts | 313 +++++++++ client/src/app/site/site-routing.module.ts | 5 + client/src/app/site/site.component.html | 7 +- client/src/app/site/site.component.scss | 34 +- .../app/site/site.component.scss-theme.scss | 25 - client/src/app/site/site.component.ts | 11 - .../tag-list/tag-list.component.html | 2 +- .../user-list/user-list.component.html | 2 +- .../assignments/poll/poll-slide-data.ts | 2 +- .../assignments/poll/poll-slide.component.ts | 2 +- .../styles/global-components-style.scss | 8 + openslides/assignments/access_permissions.py | 6 + openslides/assignments/apps.py | 12 +- openslides/assignments/models.py | 14 +- openslides/assignments/serializers.py | 1 - openslides/assignments/views.py | 396 ++++++------ openslides/core/apps.py | 1 + openslides/motions/access_permissions.py | 6 + openslides/motions/apps.py | 5 + .../motions/migrations/0034_voting_2.py | 5 +- openslides/motions/models.py | 5 +- openslides/motions/serializers.py | 2 - openslides/motions/views.py | 78 ++- openslides/poll/access_permissions.py | 30 +- openslides/poll/models.py | 11 +- openslides/poll/serializers.py | 8 +- openslides/poll/views.py | 189 ++++-- tests/integration/assignments/test_polls.py | 598 +++++++++++++----- tests/integration/motions/test_polls.py | 367 +++++++---- tests/test_case.py | 7 + 185 files changed, 6060 insertions(+), 2430 deletions(-) create mode 100644 client/src/app/core/definitions/has-view-model-list-observable.ts create mode 100644 client/src/app/core/repositories/assignments/assignment-option-repository.service.spec.ts create mode 100644 client/src/app/core/repositories/assignments/assignment-option-repository.service.ts create mode 100644 client/src/app/core/repositories/motions/motion-option-repository.service.spec.ts create mode 100644 client/src/app/core/repositories/motions/motion-option-repository.service.ts create mode 100644 client/src/app/core/ui-services/banner.service.spec.ts create mode 100644 client/src/app/core/ui-services/banner.service.ts create mode 100644 client/src/app/core/ui-services/base-poll-dialog.service.ts delete mode 100644 client/src/app/core/ui-services/poll.service.spec.ts delete mode 100644 client/src/app/core/ui-services/poll.service.ts create mode 100644 client/src/app/core/ui-services/voting-banner.service.spec.ts create mode 100644 client/src/app/core/ui-services/voting-banner.service.ts create mode 100644 client/src/app/core/ui-services/voting.service.spec.ts create mode 100644 client/src/app/core/ui-services/voting.service.ts create mode 100644 client/src/app/shared/components/banner/banner.component.html create mode 100644 client/src/app/shared/components/banner/banner.component.scss create mode 100644 client/src/app/shared/components/banner/banner.component.spec.ts create mode 100644 client/src/app/shared/components/banner/banner.component.ts create mode 100644 client/src/app/shared/components/breadcrumb/breadcrumb.component.html create mode 100644 client/src/app/shared/components/breadcrumb/breadcrumb.component.scss create mode 100644 client/src/app/shared/components/breadcrumb/breadcrumb.component.spec.ts create mode 100644 client/src/app/shared/components/breadcrumb/breadcrumb.component.ts create mode 100644 client/src/app/shared/components/charts/charts.component.html create mode 100644 client/src/app/shared/components/charts/charts.component.scss create mode 100644 client/src/app/shared/components/charts/charts.component.spec.ts create mode 100644 client/src/app/shared/components/charts/charts.component.ts create mode 100644 client/src/app/shared/components/check-input/check-input.component.html create mode 100644 client/src/app/shared/components/check-input/check-input.component.scss create mode 100644 client/src/app/shared/components/check-input/check-input.component.spec.ts create mode 100644 client/src/app/shared/components/check-input/check-input.component.ts create mode 100644 client/src/app/site/assignments/components/assignment-poll-detail/assignment-poll-detail.component.html create mode 100644 client/src/app/site/assignments/components/assignment-poll-detail/assignment-poll-detail.component.scss create mode 100644 client/src/app/site/assignments/components/assignment-poll-detail/assignment-poll-detail.component.spec.ts create mode 100644 client/src/app/site/assignments/components/assignment-poll-detail/assignment-poll-detail.component.ts create mode 100644 client/src/app/site/assignments/components/assignment-poll-vote/assignment-poll-vote.component.html create mode 100644 client/src/app/site/assignments/components/assignment-poll-vote/assignment-poll-vote.component.scss create mode 100644 client/src/app/site/assignments/components/assignment-poll-vote/assignment-poll-vote.component.spec.ts create mode 100644 client/src/app/site/assignments/components/assignment-poll-vote/assignment-poll-vote.component.ts rename client/src/app/site/assignments/services/{assignment-filter.service.ts => assignment-filter-list.service.ts} (100%) create mode 100644 client/src/app/site/assignments/services/assignment-poll-dialog.service.spec.ts create mode 100644 client/src/app/site/assignments/services/assignment-poll-dialog.service.ts create mode 100644 client/src/app/site/assignments/services/assignment-poll.service.spec.ts create mode 100644 client/src/app/site/assignments/services/assignment-poll.service.ts delete mode 100644 client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll-dialog.component.html delete mode 100644 client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll-dialog.component.scss delete mode 100644 client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll-dialog.component.ts delete mode 100644 client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll-preview/motion-poll-preview.component.html delete mode 100644 client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll-preview/motion-poll-preview.component.scss delete mode 100644 client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll-preview/motion-poll-preview.component.ts delete mode 100644 client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll.component.html delete mode 100644 client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll.component.scss delete mode 100644 client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll.component.spec.ts delete mode 100644 client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll.component.ts create mode 100644 client/src/app/site/motions/modules/motion-poll/motion-poll-dialog/motion-poll-dialog.component.html create mode 100644 client/src/app/site/motions/modules/motion-poll/motion-poll-dialog/motion-poll-dialog.component.scss create mode 100644 client/src/app/site/motions/modules/motion-poll/motion-poll-dialog/motion-poll-dialog.component.spec.ts create mode 100644 client/src/app/site/motions/modules/motion-poll/motion-poll-dialog/motion-poll-dialog.component.ts create mode 100644 client/src/app/site/motions/modules/motion-poll/motion-poll-vote/motion-poll-vote.component.html create mode 100644 client/src/app/site/motions/modules/motion-poll/motion-poll-vote/motion-poll-vote.component.scss rename client/src/app/site/motions/modules/{motion-detail/components/motion-poll/motion-poll-preview/motion-poll-preview.component.spec.ts => motion-poll/motion-poll-vote/motion-poll-vote.component.spec.ts} (57%) create mode 100644 client/src/app/site/motions/modules/motion-poll/motion-poll-vote/motion-poll-vote.component.ts create mode 100644 client/src/app/site/motions/modules/motion-poll/motion-poll/motion-poll.component.html create mode 100644 client/src/app/site/motions/modules/motion-poll/motion-poll/motion-poll.component.scss create mode 100644 client/src/app/site/motions/modules/motion-poll/motion-poll/motion-poll.component.spec.ts create mode 100644 client/src/app/site/motions/modules/motion-poll/motion-poll/motion-poll.component.ts create mode 100644 client/src/app/site/motions/services/motion-poll-dialog.service.spec.ts create mode 100644 client/src/app/site/motions/services/motion-poll-dialog.service.ts create mode 100644 client/src/app/site/polls/components/base-poll-detail.component.ts create mode 100644 client/src/app/site/polls/components/base-poll-dialog.component.ts create mode 100644 client/src/app/site/polls/components/base-poll-vote.component.ts create mode 100644 client/src/app/site/polls/components/base-poll.component.ts create mode 100644 client/src/app/site/polls/components/poll-form/poll-form.component.html create mode 100644 client/src/app/site/polls/components/poll-form/poll-form.component.scss create mode 100644 client/src/app/site/polls/components/poll-form/poll-form.component.spec.ts create mode 100644 client/src/app/site/polls/components/poll-form/poll-form.component.ts create mode 100644 client/src/app/site/polls/components/poll-list/poll-list.component.html create mode 100644 client/src/app/site/polls/components/poll-list/poll-list.component.scss create mode 100644 client/src/app/site/polls/components/poll-list/poll-list.component.spec.ts create mode 100644 client/src/app/site/polls/components/poll-list/poll-list.component.ts create mode 100644 client/src/app/site/polls/models/view-base-poll.ts create mode 100644 client/src/app/site/polls/polls-routing.module.ts create mode 100644 client/src/app/site/polls/polls.module.ts create mode 100644 client/src/app/site/polls/services/base-poll-repository.service.ts create mode 100644 client/src/app/site/polls/services/poll-filter-list.service.spec.ts create mode 100644 client/src/app/site/polls/services/poll-filter-list.service.ts create mode 100644 client/src/app/site/polls/services/poll-list-observable.service.spec.ts create mode 100644 client/src/app/site/polls/services/poll-list-observable.service.ts create mode 100644 client/src/app/site/polls/services/poll.service.spec.ts create mode 100644 client/src/app/site/polls/services/poll.service.ts diff --git a/client/package.json b/client/package.json index 8624fa96d..996c54bda 100644 --- a/client/package.json +++ b/client/package.json @@ -31,79 +31,81 @@ "cleanup-win": "npm run prettify-write & npm run lint-write" }, "dependencies": { - "@angular/animations": "~8.2.4", + "@angular/animations": "^8.2.14", "@angular/cdk": "~8.1.4", "@angular/cdk-experimental": "~8.1.4", - "@angular/common": "~8.2.4", - "@angular/compiler": "~8.2.4", - "@angular/core": "~8.2.4", - "@angular/forms": "~8.2.4", + "@angular/common": "^8.2.14", + "@angular/compiler": "^8.2.14", + "@angular/core": "^8.2.14", + "@angular/forms": "^8.2.14", "@angular/material": "~8.1.4", "@angular/material-moment-adapter": "~8.1.4", - "@angular/platform-browser": "~8.2.4", - "@angular/platform-browser-dynamic": "~8.2.4", - "@angular/pwa": "^0.803.1", - "@angular/router": "~8.2.4", - "@angular/service-worker": "~8.2.4", - "@ngx-pwa/local-storage": "~8.2.1", + "@angular/platform-browser": "^8.2.14", + "@angular/platform-browser-dynamic": "^8.2.14", + "@angular/pwa": "^0.803.23", + "@angular/router": "^8.2.14", + "@angular/service-worker": "^8.2.14", + "@ngx-pwa/local-storage": "^8.2.4", "@ngx-translate/core": "~11.0.1", "@ngx-translate/http-loader": "^4.0.0", "@pebula/ngrid": "1.0.0-rc.16", "@pebula/ngrid-material": "1.0.0-rc.16", "@pebula/utils": "1.0.2", - "@tinymce/tinymce-angular": "^3.2.0", - "acorn": "^7.0.0", - "core-js": "^3.2.1", - "css-element-queries": "^1.2.1", + "@tinymce/tinymce-angular": "^3.3.1", + "acorn": "^7.1.0", + "chart.js": "^2.9.2", + "core-js": "^3.6.4", + "css-element-queries": "^1.2.3", "exceljs": "1.15.0", "file-saver": "^2.0.2", "hammerjs": "^2.0.8", "lz4js": "^0.2.0", "material-icon-font": "git+https://github.com/petergng/materialIconFont.git", "moment": "^2.24.0", + "ng2-charts": "^2.3.0", "ng2-pdf-viewer": "^5.3.4", - "ngx-file-drop": "~8.0.7", + "ngx-file-drop": "^8.0.8", "ngx-mat-select-search": "^1.8.0", "ngx-material-timepicker": "^4.0.2", "ngx-papaparse": "^4.0.2", - "pdfmake": "^0.1.58", - "po2json": "^1.0.0-alpha", - "rxjs": "^6.5.2", - "tinymce": "^5.0.14", + "pdfmake": "^0.1.63", + "po2json": "^1.0.0-beta-2", + "rxjs": "^6.5.4", + "tinymce": "^5.1.5", "tslib": "^1.10.0", - "uuid": "^3.3.2", + "uuid": "^3.3.3", "zone.js": "~0.9.1" }, "devDependencies": { - "@angular-devkit/build-angular": "~0.803.2", - "@angular/cli": "~8.3.2", - "@angular/compiler-cli": "~8.2.4", - "@angular/language-service": "~8.2.4", + "@angular-devkit/build-angular": "^0.803.23", + "@angular/cli": "^8.3.23", + "@angular/compiler-cli": "^8.2.14", + "@angular/language-service": "^8.2.14", "@biesbjerg/ngx-translate-extract": "^3.0.5", - "@compodoc/compodoc": "^1.1.8", - "@types/jasmine": "^3.3.9", - "@types/jasminewd2": "^2.0.6", - "@types/node": "~12.7.2", - "@types/yargs": "^13.0.0", - "codelyzer": "^5.0.1", - "husky": "^3.0.4", + "@compodoc/compodoc": "^1.1.11", + "@types/jasmine": "^3.5.0", + "@types/jasminewd2": "^2.0.8", + "@types/node": "^12.7.12", + "@types/yargs": "^13.0.5", + "codelyzer": "^5.2.1", + "husky": "^3.1.0", "jasmine-core": "~3.4.0", "jasmine-spec-reporter": "~4.2.1", - "karma": "^4.1.0", + "karma": "^4.4.1", "karma-chrome-launcher": "~3.1.0", - "karma-coverage-istanbul-reporter": "^2.0.5", + "karma-coverage-istanbul-reporter": "^2.1.1", "karma-jasmine": "~2.0.1", - "karma-jasmine-html-reporter": "^1.4.0", + "karma-jasmine-html-reporter": "^1.5.1", "npm-license-crawler": "^0.2.1", "npm-run-all": "^4.1.5", "prettier": "^1.19.1", "protractor": "^5.4.2", "resize-observer-polyfill": "^1.5.1", - "source-map-explorer": "^2.0.1", + "source-map-explorer": "^2.2.2", "ts-node": "~8.3.0", "tslint": "~5.19.0", "tsutils": "3.17.1", "typescript": "~3.5.3", - "webpack-bundle-analyzer": "^3.3.2" + "webpack-bundle-analyzer": "^3.6.0" } } diff --git a/client/src/app/app.component.scss b/client/src/app/app.component.scss index 687fbe074..395d04e7f 100644 --- a/client/src/app/app.component.scss +++ b/client/src/app/app.component.scss @@ -1,3 +1,4 @@ .content { flex: 1; + height: 100vh; } diff --git a/client/src/app/app.component.ts b/client/src/app/app.component.ts index ba028738a..e24171a4f 100644 --- a/client/src/app/app.component.ts +++ b/client/src/app/app.component.ts @@ -17,6 +17,7 @@ import { PrioritizeService } from './core/core-services/prioritize.service'; import { RoutingStateService } from './core/ui-services/routing-state.service'; import { ServertimeService } from './core/core-services/servertime.service'; import { ThemeService } from './core/ui-services/theme.service'; +import { VotingBannerService } from './core/ui-services/voting-banner.service'; declare global { /** @@ -25,6 +26,8 @@ declare global { */ interface Array { flatMap(o: any): any[]; + intersect(a: T[]): T[]; + mapToObject(f: (item: T) => { [key: string]: any }): { [key: string]: any }; } /** @@ -79,7 +82,8 @@ export class AppComponent { dataStoreUpgradeService: DataStoreUpgradeService, // to start it. prioritizeService: PrioritizeService, pingService: PingService, - routingState: RoutingStateService + routingState: RoutingStateService, + votingBannerService: VotingBannerService // needed for initialisation ) { // manually add the supported languages translate.addLangs(['en', 'de', 'cs', 'ru']); @@ -92,7 +96,7 @@ export class AppComponent { // change default JS functions this.overloadArrayToString(); - this.overloadFlatMap(); + this.overloadArrayFunctions(); this.overloadModulo(); // Wait until the App reaches a stable state. @@ -138,8 +142,7 @@ export class AppComponent { } /** - * Adds an implementation of flatMap. - * TODO: Remove once flatMap made its way into official JS/TS (ES 2019?) + * Adds an implementation of flatMap and intersect. */ private overloadFlatMap(): void { Object.defineProperty(Array.prototype, 'flatMap', { @@ -150,6 +153,34 @@ export class AppComponent { }, enumerable: false }); + + Object.defineProperty(Array.prototype, 'intersect', { + value: function(other: T[]): T[] { + let a = this, + b = other; + // indexOf to loop over shorter + if (b.length > a.length) { + [a, b] = [b, a]; + } + return a.filter(e => b.indexOf(e) > -1); + }, + enumerable: false + }); + + Object.defineProperty(Array.prototype, 'mapToObject', { + value: function(f: (item: T) => { [key: string]: any }): { [key: string]: any } { + return this.reduce((aggr, item) => { + const res = f(item); + for (const key in res) { + if (res.hasOwnProperty(key)) { + aggr[key] = res[key]; + } + } + return aggr; + }, {}); + }, + enumerable: false + }); } /** diff --git a/client/src/app/core/core-services/offline.service.ts b/client/src/app/core/core-services/offline.service.ts index b8d6a238f..bad80218f 100644 --- a/client/src/app/core/core-services/offline.service.ts +++ b/client/src/app/core/core-services/offline.service.ts @@ -1,7 +1,10 @@ import { Injectable } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; import { BehaviorSubject, Observable } from 'rxjs'; +import { BannerDefinition, BannerService } from '../ui-services/banner.service'; + /** * This service handles everything connected with being offline. * @@ -16,6 +19,16 @@ export class OfflineService { * BehaviorSubject to receive further status values. */ private offline = new BehaviorSubject(false); + private bannerDefinition: BannerDefinition = { + text: 'Offline mode', + icon: 'cloud_off' + }; + + public constructor(private banner: BannerService, translate: TranslateService) { + translate.onLangChange.subscribe(() => { + this.bannerDefinition.text = translate.instant(this.bannerDefinition.text); + }); + } /** * Determines of you are either in Offline mode or not connected via websocket @@ -33,7 +46,7 @@ export class OfflineService { if (!this.offline.getValue()) { console.log('offline because whoami failed.'); } - this.offline.next(true); + this.goOffline(); } /** @@ -43,7 +56,15 @@ export class OfflineService { if (!this.offline.getValue()) { console.log('offline because connection lost.'); } + this.goOffline(); + } + + /** + * Helper function to set offline status + */ + private goOffline(): void { this.offline.next(true); + this.banner.addBanner(this.bannerDefinition); } /** @@ -51,5 +72,6 @@ export class OfflineService { */ public goOnline(): void { this.offline.next(false); + this.banner.removeBanner(this.bannerDefinition); } } diff --git a/client/src/app/core/core-services/openslides-status.service.ts b/client/src/app/core/core-services/openslides-status.service.ts index 0829c761c..90594a0ea 100644 --- a/client/src/app/core/core-services/openslides-status.service.ts +++ b/client/src/app/core/core-services/openslides-status.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@angular/core'; import { History } from 'app/shared/models/core/history'; +import { BannerDefinition, BannerService } from '../ui-services/banner.service'; /** * Holds information about OpenSlides. This is not included into other services to @@ -14,6 +15,9 @@ export class OpenSlidesStatusService { * in History mode, saves the history point. */ private history: History = null; + private bannerDefinition: BannerDefinition = { + type: 'history' + }; /** * Returns, if OpenSlides is in the history mode. @@ -27,7 +31,7 @@ export class OpenSlidesStatusService { /** * Ctor, does nothing. */ - public constructor() {} + public constructor(private banner: BannerService) {} /** * Calls the getLocaleString function of the history object, if present. @@ -44,6 +48,7 @@ export class OpenSlidesStatusService { */ public enterHistoryMode(history: History): void { this.history = history; + this.banner.addBanner(this.bannerDefinition); } /** @@ -51,5 +56,6 @@ export class OpenSlidesStatusService { */ public leaveHistoryMode(): void { this.history = null; + this.banner.removeBanner(this.bannerDefinition); } } diff --git a/client/src/app/core/core-services/relation-manager.service.ts b/client/src/app/core/core-services/relation-manager.service.ts index d3de55fa7..53e86b96b 100644 --- a/client/src/app/core/core-services/relation-manager.service.ts +++ b/client/src/app/core/core-services/relation-manager.service.ts @@ -187,12 +187,24 @@ export class RelationManagerService { const _model: M = target.getModel(); const relation = typeof property === 'string' ? relationsByKey[property] : null; + // try to find a getter for property if (property in target) { - const descriptor = Object.getOwnPropertyDescriptor(viewModelCtor.prototype, property); + // iterate over prototype chain + let prototypeFunc = viewModelCtor, + descriptor = null; + do { + descriptor = Object.getOwnPropertyDescriptor(prototypeFunc.prototype, property); + if (!descriptor || !descriptor.get) { + prototypeFunc = Object.getPrototypeOf(prototypeFunc); + } + } while (!(descriptor && descriptor.get) && prototypeFunc && prototypeFunc.prototype); + if (descriptor && descriptor.get) { + // if getter was found in prototype chain, bind it with this proxy for right `this` access result = descriptor.get.bind(viewModel)(); } else { result = target[property]; + // console.log(property, target); } } else if (property in _model) { result = _model[property]; diff --git a/client/src/app/core/definitions/has-view-model-list-observable.ts b/client/src/app/core/definitions/has-view-model-list-observable.ts new file mode 100644 index 000000000..446bacadb --- /dev/null +++ b/client/src/app/core/definitions/has-view-model-list-observable.ts @@ -0,0 +1,5 @@ +import { Observable } from 'rxjs'; + +export interface HasViewModelListObservable { + getViewModelListObservable(): Observable; +} diff --git a/client/src/app/core/repositories/assignments/assignment-option-repository.service.spec.ts b/client/src/app/core/repositories/assignments/assignment-option-repository.service.spec.ts new file mode 100644 index 000000000..727666b3b --- /dev/null +++ b/client/src/app/core/repositories/assignments/assignment-option-repository.service.spec.ts @@ -0,0 +1,14 @@ +import { TestBed } from '@angular/core/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; + +import { AssignmentOptionRepositoryService } from './assignment-option-repository.service'; + +describe('AssignmentOptionRepositoryService', () => { + beforeEach(() => TestBed.configureTestingModule({ imports: [E2EImportsModule] })); + + it('should be created', () => { + const service: AssignmentOptionRepositoryService = TestBed.get(AssignmentOptionRepositoryService); + expect(service).toBeTruthy(); + }); +}); diff --git a/client/src/app/core/repositories/assignments/assignment-option-repository.service.ts b/client/src/app/core/repositories/assignments/assignment-option-repository.service.ts new file mode 100644 index 000000000..f757e82b7 --- /dev/null +++ b/client/src/app/core/repositories/assignments/assignment-option-repository.service.ts @@ -0,0 +1,75 @@ +import { Injectable } from '@angular/core'; + +import { TranslateService } from '@ngx-translate/core'; + +import { DataSendService } from 'app/core/core-services/data-send.service'; +import { RelationManagerService } from 'app/core/core-services/relation-manager.service'; +import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service'; +import { RelationDefinition } from 'app/core/definitions/relations'; +import { AssignmentOption } from 'app/shared/models/assignments/assignment-option'; +import { ViewAssignmentOption } from 'app/site/assignments/models/view-assignment-option'; +import { ViewAssignmentPoll } from 'app/site/assignments/models/view-assignment-poll'; +import { ViewAssignmentVote } from 'app/site/assignments/models/view-assignment-vote'; +import { ViewUser } from 'app/site/users/models/view-user'; +import { BaseRepository } from '../base-repository'; +import { CollectionStringMapperService } from '../../core-services/collection-string-mapper.service'; +import { DataStoreService } from '../../core-services/data-store.service'; + +const AssignmentOptionRelations: RelationDefinition[] = [ + { + type: 'O2M', + foreignIdKey: 'option_id', + ownKey: 'votes', + foreignViewModel: ViewAssignmentVote + }, + { + type: 'M2O', + ownIdKey: 'poll_id', + ownKey: 'poll', + foreignViewModel: ViewAssignmentPoll + }, + { + type: 'M2O', + ownIdKey: 'user_id', + ownKey: 'user', + foreignViewModel: ViewUser + } +]; + +/** + * Repository Service for Options. + * + * Documentation partially provided in {@link BaseRepository} + */ +@Injectable({ + providedIn: 'root' +}) +export class AssignmentOptionRepositoryService extends BaseRepository { + public constructor( + DS: DataStoreService, + dataSend: DataSendService, + mapperService: CollectionStringMapperService, + viewModelStoreService: ViewModelStoreService, + translate: TranslateService, + relationManager: RelationManagerService + ) { + super( + DS, + dataSend, + mapperService, + viewModelStoreService, + translate, + relationManager, + AssignmentOption, + AssignmentOptionRelations + ); + } + + public getTitle = (titleInformation: object) => { + return 'Option'; + }; + + public getVerboseName = (plural: boolean = false) => { + return this.translate.instant(plural ? 'Options' : 'Option'); + }; +} diff --git a/client/src/app/core/repositories/assignments/assignment-poll-repository.service.ts b/client/src/app/core/repositories/assignments/assignment-poll-repository.service.ts index 78257cb08..81bfe5389 100644 --- a/client/src/app/core/repositories/assignments/assignment-poll-repository.service.ts +++ b/client/src/app/core/repositories/assignments/assignment-poll-repository.service.ts @@ -3,17 +3,18 @@ import { Injectable } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; import { DataSendService } from 'app/core/core-services/data-send.service'; +import { HttpService } from 'app/core/core-services/http.service'; import { RelationManagerService } from 'app/core/core-services/relation-manager.service'; import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service'; import { RelationDefinition } from 'app/core/definitions/relations'; -import { AssignmentOption } from 'app/shared/models/assignments/assignment-option'; +import { VotingService } from 'app/core/ui-services/voting.service'; import { AssignmentPoll } from 'app/shared/models/assignments/assignment-poll'; +import { ViewAssignment } from 'app/site/assignments/models/view-assignment'; import { ViewAssignmentOption } from 'app/site/assignments/models/view-assignment-option'; import { AssignmentPollTitleInformation, ViewAssignmentPoll } from 'app/site/assignments/models/view-assignment-poll'; -import { ViewAssignmentVote } from 'app/site/assignments/models/view-assignment-vote'; +import { BasePollRepositoryService } from 'app/site/polls/services/base-poll-repository.service'; import { ViewGroup } from 'app/site/users/models/view-group'; import { ViewUser } from 'app/site/users/models/view-user'; -import { BaseRepository, NestedModelDescriptors } from '../base-repository'; import { CollectionStringMapperService } from '../../core-services/collection-string-mapper.service'; import { DataStoreService } from '../../core-services/data-store.service'; @@ -29,36 +30,35 @@ const AssignmentPollRelations: RelationDefinition[] = [ ownIdKey: 'voted_id', ownKey: 'voted', foreignViewModel: ViewUser + }, + { + type: 'O2M', + ownIdKey: 'options_id', + ownKey: 'options', + foreignViewModel: ViewAssignmentOption + }, + { + type: 'M2O', + ownIdKey: 'assignment_id', + ownKey: 'assignment', + foreignViewModel: ViewAssignment } ]; -const AssignmentPollNestedModelDescriptors: NestedModelDescriptors = { - 'assignments/assignment-poll': [ - { - ownKey: 'options', - foreignViewModel: ViewAssignmentOption, - foreignModel: AssignmentOption, - order: 'weight', - relationDefinitionsByKey: { - user: { - type: 'M2O', - ownIdKey: 'user_id', - ownKey: 'user', - foreignViewModel: ViewUser - }, - votes: { - type: 'O2M', - foreignIdKey: 'option_id', - ownKey: 'votes', - foreignViewModel: ViewAssignmentVote - } - }, - titles: { - getTitle: (viewOption: ViewAssignmentOption) => (viewOption.user ? viewOption.user.getTitle() : '') - } - } - ] -}; +export interface AssignmentAnalogVoteData { + options: { + [key: number]: { + Y: number; + N?: number; + A?: number; + }; + }; + votesvalid?: number; + votesinvalid?: number; + votescast?: number; + global_no?: number; + global_abstain?: number; +} /** * Repository Service for Assignments. @@ -68,7 +68,7 @@ const AssignmentPollNestedModelDescriptors: NestedModelDescriptors = { @Injectable({ providedIn: 'root' }) -export class AssignmentPollRepositoryService extends BaseRepository< +export class AssignmentPollRepositoryService extends BasePollRepositoryService< ViewAssignmentPoll, AssignmentPoll, AssignmentPollTitleInformation @@ -89,7 +89,9 @@ export class AssignmentPollRepositoryService extends BaseRepository< mapperService: CollectionStringMapperService, viewModelStoreService: ViewModelStoreService, translate: TranslateService, - relationManager: RelationManagerService + relationManager: RelationManagerService, + votingService: VotingService, + http: HttpService ) { super( DS, @@ -100,7 +102,9 @@ export class AssignmentPollRepositoryService extends BaseRepository< relationManager, AssignmentPoll, AssignmentPollRelations, - AssignmentPollNestedModelDescriptors + {}, + votingService, + http ); } @@ -111,4 +115,8 @@ export class AssignmentPollRepositoryService extends BaseRepository< public getVerboseName = (plural: boolean = false) => { return this.translate.instant(plural ? 'Polls' : 'Poll'); }; + + public vote(data: any, poll_id: number): Promise { + return this.http.post(`/rest/assignments/assignment-poll/${poll_id}/vote/`, data); + } } diff --git a/client/src/app/core/repositories/assignments/assignment-vote-repository.service.ts b/client/src/app/core/repositories/assignments/assignment-vote-repository.service.ts index 3e16559f3..a77c14f4c 100644 --- a/client/src/app/core/repositories/assignments/assignment-vote-repository.service.ts +++ b/client/src/app/core/repositories/assignments/assignment-vote-repository.service.ts @@ -7,6 +7,7 @@ import { RelationManagerService } from 'app/core/core-services/relation-manager. import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service'; import { RelationDefinition } from 'app/core/definitions/relations'; import { AssignmentVote } from 'app/shared/models/assignments/assignment-vote'; +import { ViewAssignmentOption } from 'app/site/assignments/models/view-assignment-option'; import { ViewAssignmentVote } from 'app/site/assignments/models/view-assignment-vote'; import { ViewUser } from 'app/site/users/models/view-user'; import { BaseRepository } from '../base-repository'; @@ -19,6 +20,12 @@ const AssignmentVoteRelations: RelationDefinition[] = [ ownIdKey: 'user_id', ownKey: 'user', foreignViewModel: ViewUser + }, + { + type: 'M2O', + ownIdKey: 'option_id', + ownKey: 'option', + foreignViewModel: ViewAssignmentOption } ]; @@ -66,4 +73,8 @@ export class AssignmentVoteRepositoryService extends BaseRepository { return this.translate.instant(plural ? 'Votes' : 'Vote'); }; + + public getVotesForUser(pollId: number, userId: number): ViewAssignmentVote[] { + return this.getViewModelList().filter(vote => vote.option.poll_id === pollId && vote.user_id === userId); + } } diff --git a/client/src/app/core/repositories/base-repository.ts b/client/src/app/core/repositories/base-repository.ts index 3d59f2272..942909904 100644 --- a/client/src/app/core/repositories/base-repository.ts +++ b/client/src/app/core/repositories/base-repository.ts @@ -8,6 +8,7 @@ import { BaseViewModel, TitleInformation, ViewModelConstructor } from '../../sit import { CollectionStringMapperService } from '../core-services/collection-string-mapper.service'; import { DataSendService } from '../core-services/data-send.service'; import { DataStoreService } from '../core-services/data-store.service'; +import { HasViewModelListObservable } from '../definitions/has-view-model-list-observable'; import { Identifiable } from '../../shared/models/base/identifiable'; import { OnAfterAppsLoaded } from '../definitions/on-after-apps-loaded'; import { RelationManagerService } from '../core-services/relation-manager.service'; @@ -30,7 +31,7 @@ export interface NestedModelDescriptors { } export abstract class BaseRepository - implements OnAfterAppsLoaded, Collection { + implements OnAfterAppsLoaded, Collection, HasViewModelListObservable { /** * Stores all the viewModel in an object */ @@ -42,8 +43,8 @@ export abstract class BaseRepository } = {}; /** - * Observable subject for the whole list. These entries are unsorted an not piped through - * autodTime. Just use this internally. + * Observable subject for the whole list. These entries are unsorted and not piped through + * auditTime. Just use this internally. * * It's used to debounce messages on the sortedViewModelListSubject */ @@ -188,7 +189,7 @@ export abstract class BaseRepository number = (a: V, b: V) => a.id - b.id; diff --git a/client/src/app/core/repositories/motions/motion-option-repository.service.spec.ts b/client/src/app/core/repositories/motions/motion-option-repository.service.spec.ts new file mode 100644 index 000000000..84bea42fc --- /dev/null +++ b/client/src/app/core/repositories/motions/motion-option-repository.service.spec.ts @@ -0,0 +1,14 @@ +import { TestBed } from '@angular/core/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; + +import { MotionOptionRepositoryService } from './motion-option-repository.service'; + +describe('MotionOptionRepositoryService', () => { + beforeEach(() => TestBed.configureTestingModule({ imports: [E2EImportsModule] })); + + it('should be created', () => { + const service: MotionOptionRepositoryService = TestBed.get(MotionOptionRepositoryService); + expect(service).toBeTruthy(); + }); +}); diff --git a/client/src/app/core/repositories/motions/motion-option-repository.service.ts b/client/src/app/core/repositories/motions/motion-option-repository.service.ts new file mode 100644 index 000000000..f55014f43 --- /dev/null +++ b/client/src/app/core/repositories/motions/motion-option-repository.service.ts @@ -0,0 +1,68 @@ +import { Injectable } from '@angular/core'; + +import { TranslateService } from '@ngx-translate/core'; + +import { DataSendService } from 'app/core/core-services/data-send.service'; +import { RelationManagerService } from 'app/core/core-services/relation-manager.service'; +import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service'; +import { RelationDefinition } from 'app/core/definitions/relations'; +import { MotionOption } from 'app/shared/models/motions/motion-option'; +import { ViewMotionOption } from 'app/site/motions/models/view-motion-option'; +import { ViewMotionPoll } from 'app/site/motions/models/view-motion-poll'; +import { ViewMotionVote } from 'app/site/motions/models/view-motion-vote'; +import { BaseRepository } from '../base-repository'; +import { CollectionStringMapperService } from '../../core-services/collection-string-mapper.service'; +import { DataStoreService } from '../../core-services/data-store.service'; + +const MotionOptionRelations: RelationDefinition[] = [ + { + type: 'O2M', + foreignIdKey: 'option_id', + ownKey: 'votes', + foreignViewModel: ViewMotionVote + }, + { + type: 'M2O', + ownIdKey: 'poll_id', + ownKey: 'poll', + foreignViewModel: ViewMotionPoll + } +]; + +/** + * Repository Service for Options. + * + * Documentation partially provided in {@link BaseRepository} + */ +@Injectable({ + providedIn: 'root' +}) +export class MotionOptionRepositoryService extends BaseRepository { + public constructor( + DS: DataStoreService, + dataSend: DataSendService, + mapperService: CollectionStringMapperService, + viewModelStoreService: ViewModelStoreService, + translate: TranslateService, + relationManager: RelationManagerService + ) { + super( + DS, + dataSend, + mapperService, + viewModelStoreService, + translate, + relationManager, + MotionOption, + MotionOptionRelations + ); + } + + public getTitle = (titleInformation: object) => { + return 'Option'; + }; + + public getVerboseName = (plural: boolean = false) => { + return this.translate.instant(plural ? 'Options' : 'Option'); + }; +} diff --git a/client/src/app/core/repositories/motions/motion-poll-repository.service.ts b/client/src/app/core/repositories/motions/motion-poll-repository.service.ts index 4ebbcd424..a35f2c1c3 100644 --- a/client/src/app/core/repositories/motions/motion-poll-repository.service.ts +++ b/client/src/app/core/repositories/motions/motion-poll-repository.service.ts @@ -3,17 +3,17 @@ import { Injectable } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; import { DataSendService } from 'app/core/core-services/data-send.service'; +import { HttpService } from 'app/core/core-services/http.service'; import { RelationManagerService } from 'app/core/core-services/relation-manager.service'; import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service'; import { RelationDefinition } from 'app/core/definitions/relations'; -import { MotionOption } from 'app/shared/models/motions/motion-option'; +import { VotingService } from 'app/core/ui-services/voting.service'; import { MotionPoll } from 'app/shared/models/motions/motion-poll'; import { ViewMotionOption } from 'app/site/motions/models/view-motion-option'; import { MotionPollTitleInformation, ViewMotionPoll } from 'app/site/motions/models/view-motion-poll'; -import { ViewMotionVote } from 'app/site/motions/models/view-motion-vote'; +import { BasePollRepositoryService } from 'app/site/polls/services/base-poll-repository.service'; import { ViewGroup } from 'app/site/users/models/view-group'; import { ViewUser } from 'app/site/users/models/view-user'; -import { BaseRepository, NestedModelDescriptors } from '../base-repository'; import { CollectionStringMapperService } from '../../core-services/collection-string-mapper.service'; import { DataStoreService } from '../../core-services/data-store.service'; @@ -29,30 +29,15 @@ const MotionPollRelations: RelationDefinition[] = [ ownIdKey: 'voted_id', ownKey: 'voted', foreignViewModel: ViewUser + }, + { + type: 'O2M', + ownIdKey: 'options_id', + ownKey: 'options', + foreignViewModel: ViewMotionOption } ]; -const MotionPollNestedModelDescriptors: NestedModelDescriptors = { - 'motions/motion-poll': [ - { - ownKey: 'options', - foreignViewModel: ViewMotionOption, - foreignModel: MotionOption, - relationDefinitionsByKey: { - votes: { - type: 'O2M', - foreignIdKey: 'option_id', - ownKey: 'votes', - foreignViewModel: ViewMotionVote - } - }, - titles: { - getTitle: (viewOption: ViewMotionOption) => '' - } - } - ] -}; - /** * Repository Service for Assignments. * @@ -61,7 +46,7 @@ const MotionPollNestedModelDescriptors: NestedModelDescriptors = { @Injectable({ providedIn: 'root' }) -export class MotionPollRepositoryService extends BaseRepository< +export class MotionPollRepositoryService extends BasePollRepositoryService< ViewMotionPoll, MotionPoll, MotionPollTitleInformation @@ -72,7 +57,9 @@ export class MotionPollRepositoryService extends BaseRepository< mapperService: CollectionStringMapperService, viewModelStoreService: ViewModelStoreService, translate: TranslateService, - relationManager: RelationManagerService + relationManager: RelationManagerService, + votingService: VotingService, + http: HttpService ) { super( DS, @@ -83,7 +70,9 @@ export class MotionPollRepositoryService extends BaseRepository< relationManager, MotionPoll, MotionPollRelations, - MotionPollNestedModelDescriptors + {}, + votingService, + http ); } @@ -94,4 +83,8 @@ export class MotionPollRepositoryService extends BaseRepository< public getVerboseName = (plural: boolean = false) => { return this.translate.instant(plural ? 'Polls' : 'Poll'); }; + + public vote(vote: 'Y' | 'N' | 'A', poll_id: number): Promise { + return this.http.post(`/rest/motions/motion-poll/${poll_id}/vote/`, JSON.stringify(vote)); + } } diff --git a/client/src/app/core/repositories/motions/motion-vote-repository.service.ts b/client/src/app/core/repositories/motions/motion-vote-repository.service.ts index 801659fa0..f60037806 100644 --- a/client/src/app/core/repositories/motions/motion-vote-repository.service.ts +++ b/client/src/app/core/repositories/motions/motion-vote-repository.service.ts @@ -7,6 +7,7 @@ import { RelationManagerService } from 'app/core/core-services/relation-manager. import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service'; import { RelationDefinition } from 'app/core/definitions/relations'; import { MotionVote } from 'app/shared/models/motions/motion-vote'; +import { ViewMotionOption } from 'app/site/motions/models/view-motion-option'; import { ViewMotionVote } from 'app/site/motions/models/view-motion-vote'; import { ViewUser } from 'app/site/users/models/view-user'; import { BaseRepository } from '../base-repository'; @@ -19,6 +20,12 @@ const MotionVoteRelations: RelationDefinition[] = [ ownIdKey: 'user_id', ownKey: 'user', foreignViewModel: ViewUser + }, + { + type: 'M2O', + ownIdKey: 'option_id', + ownKey: 'option', + foreignViewModel: ViewMotionOption } ]; diff --git a/client/src/app/core/repositories/users/group-repository.service.ts b/client/src/app/core/repositories/users/group-repository.service.ts index c33c8296e..a0c40a638 100644 --- a/client/src/app/core/repositories/users/group-repository.service.ts +++ b/client/src/app/core/repositories/users/group-repository.service.ts @@ -72,6 +72,13 @@ export class GroupRepositoryService extends BaseRepository ids.includes(group.id)) + .map(group => this.translate.instant(group.getTitle())) + .join(', '); + } + /** * Toggles the given permisson. * diff --git a/client/src/app/core/ui-services/banner.service.spec.ts b/client/src/app/core/ui-services/banner.service.spec.ts new file mode 100644 index 000000000..7f7d378a3 --- /dev/null +++ b/client/src/app/core/ui-services/banner.service.spec.ts @@ -0,0 +1,12 @@ +import { TestBed } from '@angular/core/testing'; + +import { BannerService } from './banner.service'; + +describe('BannerService', () => { + beforeEach(() => TestBed.configureTestingModule({})); + + it('should be created', () => { + const service: BannerService = TestBed.get(BannerService); + expect(service).toBeTruthy(); + }); +}); diff --git a/client/src/app/core/ui-services/banner.service.ts b/client/src/app/core/ui-services/banner.service.ts new file mode 100644 index 000000000..daf5e372c --- /dev/null +++ b/client/src/app/core/ui-services/banner.service.ts @@ -0,0 +1,68 @@ +import { Injectable } from '@angular/core'; + +import { BehaviorSubject } from 'rxjs'; + +export interface BannerDefinition { + type?: string; + class?: string; + icon?: string; + text?: string; + bgColor?: string; + color?: string; + link?: string; +} + +/** + * A service handling the active banners at the top of the site. Banners are defined via a BannerDefinition + * and are removed by reference so the service adding a banner has to store the reference to remove it later + */ +@Injectable({ + providedIn: 'root' +}) +export class BannerService { + public readonly BANNER_HEIGHT = 20; + + public activeBanners: BehaviorSubject = new BehaviorSubject([]); + + /** + * Adds a banner to the list of active banners. Skip the banner if it's already in the list + * @param toAdd the banner to add + */ + public addBanner(toAdd: BannerDefinition): void { + if (!this.activeBanners.value.find(banner => banner === toAdd)) { + const newBanners = this.activeBanners.value.concat([toAdd]); + this.activeBanners.next(newBanners); + } + } + + /** + * Replaces a banner with another. Convenience method to prevent flickering + * @param toAdd the banner to add + * @param toRemove the banner to remove + */ + public replaceBanner(toRemove: BannerDefinition, toAdd: BannerDefinition): void { + if (toRemove) { + const newArray = Array.from(this.activeBanners.value); + const idx = newArray.findIndex(banner => banner === toRemove); + if (idx === -1) { + throw new Error("The given banner couldn't be found."); + } else { + newArray[idx] = toAdd; + this.activeBanners.next(newArray); // no need for this.update since the length doesn't change + } + } else { + this.addBanner(toAdd); + } + } + + /** + * removes the given banner + * @param toRemove the banner to remove + */ + public removeBanner(toRemove: BannerDefinition): void { + if (toRemove) { + const newBanners = this.activeBanners.value.filter(banner => banner !== toRemove); + this.activeBanners.next(newBanners); + } + } +} diff --git a/client/src/app/core/ui-services/base-filter-list.service.ts b/client/src/app/core/ui-services/base-filter-list.service.ts index 455ea51b1..1de1a21ce 100644 --- a/client/src/app/core/ui-services/base-filter-list.service.ts +++ b/client/src/app/core/ui-services/base-filter-list.service.ts @@ -534,6 +534,8 @@ export abstract class BaseFilterListService { if (item[filter.property].id === option.condition) { return true; } + } else if (typeof item[filter.property] === 'function') { + return item[filter.property]() === option.condition; } else if (item[filter.property] === option.condition) { return true; } else if (item[filter.property].toString() === option.condition) { diff --git a/client/src/app/core/ui-services/base-poll-dialog.service.ts b/client/src/app/core/ui-services/base-poll-dialog.service.ts new file mode 100644 index 000000000..cff02ba23 --- /dev/null +++ b/client/src/app/core/ui-services/base-poll-dialog.service.ts @@ -0,0 +1,67 @@ +import { ComponentType } from '@angular/cdk/portal'; +import { Injectable } from '@angular/core'; +import { MatDialog } from '@angular/material'; + +import { CollectionStringMapperService } from 'app/core/core-services/collection-string-mapper.service'; +import { Collection } from 'app/shared/models/base/collection'; +import { PollState, PollType } from 'app/shared/models/poll/base-poll'; +import { mediumDialogSettings } from 'app/shared/utils/dialog-settings'; +import { BasePollDialogComponent } from 'app/site/polls/components/base-poll-dialog.component'; +import { ViewBasePoll } from 'app/site/polls/models/view-base-poll'; +import { PollService } from '../../site/polls/services/poll.service'; + +/** + * Abstract class for showing a poll dialog. Has to be subclassed to provide the right `PollService` + */ +@Injectable({ + providedIn: 'root' +}) +export abstract class BasePollDialogService { + protected dialogComponent: ComponentType; + + public constructor( + private dialog: MatDialog, + private mapper: CollectionStringMapperService, + private service: PollService + ) {} + + /** + * Opens the dialog to enter votes and edit the meta-info for a poll. + * + * @param data Passing the (existing or new) data for the poll + */ + public async openDialog(poll: Partial & Collection): Promise { + if (!poll.poll) { + this.service.fillDefaultPollData(poll); + } + const dialogRef = this.dialog.open(this.dialogComponent, { + data: poll, + ...mediumDialogSettings + }); + const result = await dialogRef.afterClosed().toPromise(); + if (result) { + const repo = this.mapper.getRepository(poll.collectionString); + if (!poll.poll) { + await repo.create(result); + } else { + let update = result; + if (poll.state !== PollState.Created) { + update = { + title: result.title, + onehundred_percent_base: result.onehundred_percent_base, + majority_method: result.majority_method, + description: result.description + }; + if (poll.type === PollType.Analog) { + update = { + ...update, + votes: result.votes, + publish_immediately: result.publish_immediately + }; + } + } + await repo.patch(update, poll); + } + } + } +} diff --git a/client/src/app/core/ui-services/poll.service.spec.ts b/client/src/app/core/ui-services/poll.service.spec.ts deleted file mode 100644 index c04c5aff8..000000000 --- a/client/src/app/core/ui-services/poll.service.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { inject, TestBed } from '@angular/core/testing'; - -import { E2EImportsModule } from 'e2e-imports.module'; - -import { PollService } from './poll.service'; - -describe('PollService', () => { - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [E2EImportsModule], - providers: [PollService] - }); - }); - - it('should be created', inject([PollService], (service: PollService) => { - expect(service).toBeTruthy(); - })); -}); diff --git a/client/src/app/core/ui-services/poll.service.ts b/client/src/app/core/ui-services/poll.service.ts deleted file mode 100644 index dc0566f42..000000000 --- a/client/src/app/core/ui-services/poll.service.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { Injectable } from '@angular/core'; - -import { _ } from 'app/core/translate/translation-marker'; - -/** - * The possible keys of a poll object that represent numbers. - * TODO Should be 'key of MotionPoll|AssinmentPoll if type of key is number' - */ -export type CalculablePollKey = - | 'votesvalid' - | 'votesinvalid' - | 'votescast' - | 'yes' - | 'no' - | 'abstain' - | 'votesno' - | 'votesabstain'; - -/** - * TODO: may be obsolete if the server switches to lower case only - * (lower case variants are already in CalculablePollKey) - */ -export type PollVoteValue = 'Yes' | 'No' | 'Abstain' | 'Votes'; - -/** - * Interface representing possible majority calculation methods. The implementing - * calc function should return an integer number that must be reached for the - * option to successfully fulfill the quorum, or null if disabled - */ -export interface MajorityMethod { - value: string; - display_name: string; - calc: (base: number) => number | null; -} - -/** - * Function to round up the passed value of a poll. - * - * @param value The calculated value of 100%-base. - * @param addOne Flag, if the result should be increased by 1. - * - * @returns The necessary value to get the majority. - */ -export const calcMajority = (value: number, addOne: boolean = false) => { - return Math.ceil(value) + (addOne ? 1 : 0); -}; - -/** - * List of available majority methods, used in motion and assignment polls - */ -export const PollMajorityMethod: MajorityMethod[] = [ - { - value: 'simple_majority', - display_name: 'Simple majority', - calc: base => calcMajority(base * 0.5, true) - }, - { - value: 'two-thirds_majority', - display_name: 'Two-thirds majority', - calc: base => calcMajority((base / 3) * 2) - }, - { - value: 'three-quarters_majority', - display_name: 'Three-quarters majority', - calc: base => calcMajority((base / 4) * 3) - }, - { - value: 'disabled', - display_name: 'Disabled', - calc: a => null - } -]; - -/** - * Shared service class for polls. Used by child classes {@link MotionPollService} - * and {@link AssignmentPollService} - */ -@Injectable({ - providedIn: 'root' -}) -export abstract class PollService { - /** - * The chosen and currently used base for percentage calculations. Is - * supposed to be set by a config service - */ - public percentBase: string; - - /** - * The default majority method (to be set set per config). - */ - public defaultMajorityMethod: string; - - /** - * The majority method currently in use - */ - public majorityMethod: MajorityMethod; - - /** - * An array of value - label pairs for special value signifiers. - * TODO: Should be given by the server, and editable. For now they are hard - * coded - */ - private _specialPollVotes: [number, string][] = [ - [-1, 'majority'], - [-2, 'undocumented'] - ]; - - /** - * getter for the special vote values - * - * @returns an array of special (non-positive) numbers used in polls and - * their descriptive strings - */ - public get specialPollVotes(): [number, string][] { - return this._specialPollVotes; - } - - /** - * empty constructor - * - */ - public constructor() {} - - /** - * Gets an icon for a Poll Key - * - * @param key yes, no, abstain or something like that - * @returns a string for material-icons to represent the icon for - * this key(e.g. yes: positive sign, no: negative sign) - */ - public getIcon(key: CalculablePollKey): string { - switch (key) { - case 'yes': - return 'thumb_up'; - case 'no': - case 'votesno': - return 'thumb_down'; - case 'abstain': - case 'votesabstain': - return 'not_interested'; - // TODO case 'votescast': - // sum - case 'votesvalid': - return 'check'; - case 'votesinvalid': - return 'cancel'; - default: - return ''; - } - } - - /** - * Gets a label for a poll Key - * - * @param key yes, no, abstain or something like that - * @returns A short descriptive name for the poll keys - */ - public getLabel(key: CalculablePollKey | PollVoteValue): string { - switch (key.toLowerCase()) { - case 'yes': - return 'Yes'; - case 'no': - case 'votesno': - return 'No'; - case 'abstain': - case 'votesabstain': - return 'Abstain'; - case 'votescast': - return _('Total votes cast'); - case 'votesvalid': - return _('Valid votes'); - case 'votesinvalid': - return _('Invalid votes'); - default: - return ''; - } - } - - /** - * retrieve special labels for a poll value - * {@link specialPollVotes}. Positive values will return as string - * representation of themselves - * - * @param value check value for special numbers - * @returns the label for a non-positive value, according to - */ - public getSpecialLabel(value: number): string { - if (value >= 0) { - return value.toString(); - // TODO: toLocaleString(lang); but translateService is not usable here, thus lang is not well defined - } - const vote = this.specialPollVotes.find(special => special[0] === value); - return vote ? vote[1] : 'Undocumented special (negative) value'; - } - - /** - * Get the progress bar class for a decision key - * - * @param key a calculable poll key (like yes or no) - * @returns a css class designing a progress bar in a color, or an empty string - */ - public getProgressBarColor(key: CalculablePollKey | PollVoteValue): string { - switch (key.toLowerCase()) { - case 'yes': - return 'progress-green'; - case 'no': - return 'progress-red'; - case 'abstain': - return 'progress-yellow'; - case 'votes': - return 'progress-green'; - default: - return ''; - } - } -} diff --git a/client/src/app/core/ui-services/voting-banner.service.spec.ts b/client/src/app/core/ui-services/voting-banner.service.spec.ts new file mode 100644 index 000000000..7040f7abb --- /dev/null +++ b/client/src/app/core/ui-services/voting-banner.service.spec.ts @@ -0,0 +1,18 @@ +import { TestBed } from '@angular/core/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; + +import { VotingBannerService } from './voting-banner.service'; + +describe('VotingBannerService', () => { + beforeEach(() => + TestBed.configureTestingModule({ + imports: [E2EImportsModule] + }) + ); + + it('should be created', () => { + const service: VotingBannerService = TestBed.get(VotingBannerService); + expect(service).toBeTruthy(); + }); +}); diff --git a/client/src/app/core/ui-services/voting-banner.service.ts b/client/src/app/core/ui-services/voting-banner.service.ts new file mode 100644 index 000000000..4bf3c60c5 --- /dev/null +++ b/client/src/app/core/ui-services/voting-banner.service.ts @@ -0,0 +1,65 @@ +import { Injectable } from '@angular/core'; + +import { TranslateService } from '@ngx-translate/core'; + +import { ViewBasePoll } from 'app/site/polls/models/view-base-poll'; +import { PollListObservableService } from 'app/site/polls/services/poll-list-observable.service'; +import { BannerDefinition, BannerService } from './banner.service'; +import { OpenSlidesStatusService } from '../core-services/openslides-status.service'; +import { VotingService } from './voting.service'; + +@Injectable({ + providedIn: 'root' +}) +export class VotingBannerService { + private currentBanner: BannerDefinition; + + public constructor( + pollListObservableService: PollListObservableService, + private banner: BannerService, + private translate: TranslateService, + private OSStatus: OpenSlidesStatusService, + private votingService: VotingService + ) { + pollListObservableService.getViewModelListObservable().subscribe(polls => this.checkForVotablePolls(polls)); + } + + /** + * checks all polls for votable ones and displays a banner for them + * @param polls the updated poll list + */ + private checkForVotablePolls(polls: ViewBasePoll[]): void { + // display no banner if in history mode + if (this.OSStatus.isInHistoryMode && this.currentBanner) { + this.banner.removeBanner(this.currentBanner); + this.currentBanner = null; + return; + } + + const pollsToVote = polls.filter(poll => this.votingService.canVote(poll) && !poll.user_has_voted); + if (pollsToVote.length === 1) { + const poll = pollsToVote[0]; + const banner = { + text: this.translate.instant('Click here to vote on the poll') + ` "${poll.title}"!`, + link: poll.parentLink, + bgColor: 'green' + }; + this.banner.replaceBanner(this.currentBanner, banner); + this.currentBanner = banner; + } else if (pollsToVote.length > 1) { + const banner = { + text: + this.translate.instant('You have') + + ` ${pollsToVote.length} ` + + this.translate.instant('polls to vote on!'), + link: '/polls/', + bgColor: 'green' + }; + this.banner.replaceBanner(this.currentBanner, banner); + this.currentBanner = banner; + } else { + this.banner.removeBanner(this.currentBanner); + this.currentBanner = null; + } + } +} diff --git a/client/src/app/core/ui-services/voting.service.spec.ts b/client/src/app/core/ui-services/voting.service.spec.ts new file mode 100644 index 000000000..6dab02c52 --- /dev/null +++ b/client/src/app/core/ui-services/voting.service.spec.ts @@ -0,0 +1,18 @@ +import { TestBed } from '@angular/core/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; + +import { VotingService } from './voting.service'; + +describe('VotingService', () => { + beforeEach(() => + TestBed.configureTestingModule({ + imports: [E2EImportsModule] + }) + ); + + it('should be created', () => { + const service: VotingService = TestBed.get(VotingService); + expect(service).toBeTruthy(); + }); +}); diff --git a/client/src/app/core/ui-services/voting.service.ts b/client/src/app/core/ui-services/voting.service.ts new file mode 100644 index 000000000..a413a10c8 --- /dev/null +++ b/client/src/app/core/ui-services/voting.service.ts @@ -0,0 +1,70 @@ +import { Injectable } from '@angular/core'; + +import { PollState, PollType } from 'app/shared/models/poll/base-poll'; +import { ViewBasePoll } from 'app/site/polls/models/view-base-poll'; +import { OperatorService } from '../core-services/operator.service'; + +export enum VotingError { + POLL_WRONG_STATE = 1, // 1 so we can check with negation + POLL_WRONG_TYPE, + USER_HAS_NO_PERMISSION, + USER_IS_ANONYMOUS, + USER_NOT_PRESENT, + USER_HAS_VOTED +} + +export const VotingErrorVerbose = { + 1: "You can't vote on this poll right now because it's not in the 'Started' state.", + 2: "You can't vote on this poll because its type is set to analog voting.", + 3: "You don't have permission to vote on this poll.", + 4: 'You have to be logged in to be able to vote.', + 5: 'You have to be present to vote on a poll.', + 6: "You have already voted on this poll. You can't change your vote in a pseudoanonymous poll." +}; + +@Injectable({ + providedIn: 'root' +}) +export class VotingService { + public constructor(private operator: OperatorService) {} + + /** + * checks whether the operator can vote on the given poll + */ + public canVote(poll: ViewBasePoll): boolean { + return !this.getVotePermissionError(poll); + } + + /** + * checks whether the operator can vote on the given poll + * @returns null if no errors exist (= user can vote) or else a VotingError + */ + public getVotePermissionError(poll: ViewBasePoll): VotingError | void { + const user = this.operator.viewUser; + if (this.operator.isAnonymous) { + return VotingError.USER_IS_ANONYMOUS; + } + if (!poll.groups_id.intersect(user.groups_id).length) { + return VotingError.USER_HAS_NO_PERMISSION; + } + if (poll.type === PollType.Analog) { + return VotingError.POLL_WRONG_TYPE; + } + if (poll.state !== PollState.Started) { + return VotingError.POLL_WRONG_STATE; + } + if (!user.is_present) { + return VotingError.USER_NOT_PRESENT; + } + if (poll.type === PollType.Pseudoanonymous && poll.user_has_voted) { + return VotingError.USER_HAS_VOTED; + } + } + + public getVotePermissionErrorVerbose(poll: ViewBasePoll): string | void { + const error = this.getVotePermissionError(poll); + if (error) { + return VotingErrorVerbose[error]; + } + } +} diff --git a/client/src/app/shared/components/attachment-control/attachment-control.component.ts b/client/src/app/shared/components/attachment-control/attachment-control.component.ts index 2ea306771..8eac84d1a 100644 --- a/client/src/app/shared/components/attachment-control/attachment-control.component.ts +++ b/client/src/app/shared/components/attachment-control/attachment-control.component.ts @@ -108,7 +108,7 @@ export class AttachmentControlComponent extends BaseFormControlComponent