commit
58483d7024
@ -85,7 +85,7 @@ matrix:
|
|||||||
- "3.6"
|
- "3.6"
|
||||||
script:
|
script:
|
||||||
- mypy openslides/ tests/
|
- mypy openslides/ tests/
|
||||||
- pytest --cov --cov-fail-under=72
|
- pytest --cov --cov-fail-under=75
|
||||||
|
|
||||||
- name: "Server: Tests Python 3.7"
|
- name: "Server: Tests Python 3.7"
|
||||||
language: python
|
language: python
|
||||||
@ -96,7 +96,7 @@ matrix:
|
|||||||
- isort --check-only --diff --recursive openslides tests
|
- isort --check-only --diff --recursive openslides tests
|
||||||
- black --check --diff --target-version py36 openslides tests
|
- black --check --diff --target-version py36 openslides tests
|
||||||
- mypy openslides/ tests/
|
- mypy openslides/ tests/
|
||||||
- pytest --cov --cov-fail-under=72
|
- pytest --cov --cov-fail-under=75
|
||||||
|
|
||||||
- name: "Server: Tests Python 3.8"
|
- name: "Server: Tests Python 3.8"
|
||||||
language: python
|
language: python
|
||||||
@ -107,7 +107,7 @@ matrix:
|
|||||||
- isort --check-only --diff --recursive openslides tests
|
- isort --check-only --diff --recursive openslides tests
|
||||||
- black --check --diff --target-version py36 openslides tests
|
- black --check --diff --target-version py36 openslides tests
|
||||||
- mypy openslides/ tests/
|
- mypy openslides/ tests/
|
||||||
- pytest --cov --cov-fail-under=72
|
- pytest --cov --cov-fail-under=75
|
||||||
|
|
||||||
- name: "Client: Linting"
|
- name: "Client: Linting"
|
||||||
language: node_js
|
language: node_js
|
||||||
|
@ -31,79 +31,81 @@
|
|||||||
"cleanup-win": "npm run prettify-write & npm run lint-write"
|
"cleanup-win": "npm run prettify-write & npm run lint-write"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "~8.2.4",
|
"@angular/animations": "^8.2.14",
|
||||||
"@angular/cdk": "~8.1.4",
|
"@angular/cdk": "~8.1.4",
|
||||||
"@angular/cdk-experimental": "~8.1.4",
|
"@angular/cdk-experimental": "~8.1.4",
|
||||||
"@angular/common": "~8.2.4",
|
"@angular/common": "^8.2.14",
|
||||||
"@angular/compiler": "~8.2.4",
|
"@angular/compiler": "^8.2.14",
|
||||||
"@angular/core": "~8.2.4",
|
"@angular/core": "^8.2.14",
|
||||||
"@angular/forms": "~8.2.4",
|
"@angular/forms": "^8.2.14",
|
||||||
"@angular/material": "~8.1.4",
|
"@angular/material": "~8.1.4",
|
||||||
"@angular/material-moment-adapter": "~8.1.4",
|
"@angular/material-moment-adapter": "~8.1.4",
|
||||||
"@angular/platform-browser": "~8.2.4",
|
"@angular/platform-browser": "^8.2.14",
|
||||||
"@angular/platform-browser-dynamic": "~8.2.4",
|
"@angular/platform-browser-dynamic": "^8.2.14",
|
||||||
"@angular/pwa": "^0.803.1",
|
"@angular/pwa": "^0.803.23",
|
||||||
"@angular/router": "~8.2.4",
|
"@angular/router": "^8.2.14",
|
||||||
"@angular/service-worker": "~8.2.4",
|
"@angular/service-worker": "^8.2.14",
|
||||||
"@ngx-pwa/local-storage": "~8.2.1",
|
"@ngx-pwa/local-storage": "^8.2.4",
|
||||||
"@ngx-translate/core": "~11.0.1",
|
"@ngx-translate/core": "~11.0.1",
|
||||||
"@ngx-translate/http-loader": "^4.0.0",
|
"@ngx-translate/http-loader": "^4.0.0",
|
||||||
"@pebula/ngrid": "1.0.0-rc.16",
|
"@pebula/ngrid": "1.0.0-rc.16",
|
||||||
"@pebula/ngrid-material": "1.0.0-rc.16",
|
"@pebula/ngrid-material": "1.0.0-rc.16",
|
||||||
"@pebula/utils": "1.0.2",
|
"@pebula/utils": "1.0.2",
|
||||||
"@tinymce/tinymce-angular": "^3.2.0",
|
"@tinymce/tinymce-angular": "^3.3.1",
|
||||||
"acorn": "^7.0.0",
|
"acorn": "^7.1.0",
|
||||||
"core-js": "^3.2.1",
|
"chart.js": "^2.9.2",
|
||||||
"css-element-queries": "^1.2.1",
|
"core-js": "^3.6.4",
|
||||||
|
"css-element-queries": "^1.2.3",
|
||||||
"exceljs": "1.15.0",
|
"exceljs": "1.15.0",
|
||||||
"file-saver": "^2.0.2",
|
"file-saver": "^2.0.2",
|
||||||
"hammerjs": "^2.0.8",
|
"hammerjs": "^2.0.8",
|
||||||
"lz4js": "^0.2.0",
|
"lz4js": "^0.2.0",
|
||||||
"material-icon-font": "git+https://github.com/petergng/materialIconFont.git",
|
"material-icon-font": "git+https://github.com/petergng/materialIconFont.git",
|
||||||
"moment": "^2.24.0",
|
"moment": "^2.24.0",
|
||||||
|
"ng2-charts": "^2.3.0",
|
||||||
"ng2-pdf-viewer": "^5.3.4",
|
"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-mat-select-search": "^1.8.0",
|
||||||
"ngx-material-timepicker": "^4.0.2",
|
"ngx-material-timepicker": "^4.0.2",
|
||||||
"ngx-papaparse": "^4.0.2",
|
"ngx-papaparse": "^4.0.2",
|
||||||
"pdfmake": "^0.1.58",
|
"pdfmake": "^0.1.63",
|
||||||
"po2json": "^1.0.0-alpha",
|
"po2json": "^1.0.0-beta-2",
|
||||||
"rxjs": "^6.5.2",
|
"rxjs": "^6.5.4",
|
||||||
"tinymce": "^5.0.14",
|
"tinymce": "^5.1.5",
|
||||||
"tslib": "^1.10.0",
|
"tslib": "^1.10.0",
|
||||||
"uuid": "^3.3.2",
|
"uuid": "^3.3.3",
|
||||||
"zone.js": "~0.9.1"
|
"zone.js": "~0.9.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-devkit/build-angular": "~0.803.2",
|
"@angular-devkit/build-angular": "^0.803.23",
|
||||||
"@angular/cli": "~8.3.2",
|
"@angular/cli": "^8.3.23",
|
||||||
"@angular/compiler-cli": "~8.2.4",
|
"@angular/compiler-cli": "^8.2.14",
|
||||||
"@angular/language-service": "~8.2.4",
|
"@angular/language-service": "^8.2.14",
|
||||||
"@biesbjerg/ngx-translate-extract": "^3.0.5",
|
"@biesbjerg/ngx-translate-extract": "^3.0.5",
|
||||||
"@compodoc/compodoc": "^1.1.8",
|
"@compodoc/compodoc": "^1.1.11",
|
||||||
"@types/jasmine": "^3.3.9",
|
"@types/jasmine": "^3.5.0",
|
||||||
"@types/jasminewd2": "^2.0.6",
|
"@types/jasminewd2": "^2.0.8",
|
||||||
"@types/node": "~12.7.2",
|
"@types/node": "^12.7.12",
|
||||||
"@types/yargs": "^13.0.0",
|
"@types/yargs": "^13.0.5",
|
||||||
"codelyzer": "^5.0.1",
|
"codelyzer": "^5.2.1",
|
||||||
"husky": "^3.0.4",
|
"husky": "^3.1.0",
|
||||||
"jasmine-core": "~3.4.0",
|
"jasmine-core": "~3.4.0",
|
||||||
"jasmine-spec-reporter": "~4.2.1",
|
"jasmine-spec-reporter": "~4.2.1",
|
||||||
"karma": "^4.1.0",
|
"karma": "^4.4.1",
|
||||||
"karma-chrome-launcher": "~3.1.0",
|
"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": "~2.0.1",
|
||||||
"karma-jasmine-html-reporter": "^1.4.0",
|
"karma-jasmine-html-reporter": "^1.5.1",
|
||||||
"npm-license-crawler": "^0.2.1",
|
"npm-license-crawler": "^0.2.1",
|
||||||
"npm-run-all": "^4.1.5",
|
"npm-run-all": "^4.1.5",
|
||||||
"prettier": "^1.19.1",
|
"prettier": "^1.19.1",
|
||||||
"protractor": "^5.4.2",
|
"protractor": "^5.4.2",
|
||||||
"resize-observer-polyfill": "^1.5.1",
|
"resize-observer-polyfill": "^1.5.1",
|
||||||
"source-map-explorer": "^2.0.1",
|
"source-map-explorer": "^2.2.2",
|
||||||
"ts-node": "~8.3.0",
|
"ts-node": "~8.3.0",
|
||||||
"tslint": "~5.19.0",
|
"tslint": "~5.19.0",
|
||||||
"tsutils": "3.17.1",
|
"tsutils": "3.17.1",
|
||||||
"typescript": "~3.5.3",
|
"typescript": "~3.5.3",
|
||||||
"webpack-bundle-analyzer": "^3.3.2"
|
"webpack-bundle-analyzer": "^3.6.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
.content {
|
.content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
height: 100vh;
|
||||||
}
|
}
|
||||||
|
@ -17,6 +17,7 @@ import { PrioritizeService } from './core/core-services/prioritize.service';
|
|||||||
import { RoutingStateService } from './core/ui-services/routing-state.service';
|
import { RoutingStateService } from './core/ui-services/routing-state.service';
|
||||||
import { ServertimeService } from './core/core-services/servertime.service';
|
import { ServertimeService } from './core/core-services/servertime.service';
|
||||||
import { ThemeService } from './core/ui-services/theme.service';
|
import { ThemeService } from './core/ui-services/theme.service';
|
||||||
|
import { VotingBannerService } from './core/ui-services/voting-banner.service';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
/**
|
/**
|
||||||
@ -25,6 +26,12 @@ declare global {
|
|||||||
*/
|
*/
|
||||||
interface Array<T> {
|
interface Array<T> {
|
||||||
flatMap(o: any): any[];
|
flatMap(o: any): any[];
|
||||||
|
intersect(a: T[]): T[];
|
||||||
|
mapToObject(f: (item: T) => { [key: string]: any }): { [key: string]: any };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Set<T> {
|
||||||
|
equals(other: Set<T>): boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -79,7 +86,8 @@ export class AppComponent {
|
|||||||
dataStoreUpgradeService: DataStoreUpgradeService, // to start it.
|
dataStoreUpgradeService: DataStoreUpgradeService, // to start it.
|
||||||
prioritizeService: PrioritizeService,
|
prioritizeService: PrioritizeService,
|
||||||
pingService: PingService,
|
pingService: PingService,
|
||||||
routingState: RoutingStateService
|
routingState: RoutingStateService,
|
||||||
|
votingBannerService: VotingBannerService // needed for initialisation
|
||||||
) {
|
) {
|
||||||
// manually add the supported languages
|
// manually add the supported languages
|
||||||
translate.addLangs(['en', 'de', 'cs', 'ru']);
|
translate.addLangs(['en', 'de', 'cs', 'ru']);
|
||||||
@ -91,8 +99,8 @@ export class AppComponent {
|
|||||||
translate.use(translate.getLangs().includes(browserLang) ? browserLang : 'en');
|
translate.use(translate.getLangs().includes(browserLang) ? browserLang : 'en');
|
||||||
|
|
||||||
// change default JS functions
|
// change default JS functions
|
||||||
this.overloadArrayToString();
|
this.overloadArrayFunctions();
|
||||||
this.overloadFlatMap();
|
this.overloadSetFunctions();
|
||||||
this.overloadModulo();
|
this.overloadModulo();
|
||||||
|
|
||||||
// Wait until the App reaches a stable state.
|
// Wait until the App reaches a stable state.
|
||||||
@ -106,15 +114,7 @@ export class AppComponent {
|
|||||||
.subscribe(() => servertimeService.startScheduler());
|
.subscribe(() => servertimeService.startScheduler());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private overloadArrayFunctions(): void {
|
||||||
* Function to alter the normal Array.toString - function
|
|
||||||
*
|
|
||||||
* Will add a whitespace after a comma and shorten the output to
|
|
||||||
* three strings.
|
|
||||||
*
|
|
||||||
* TODO: Should be renamed
|
|
||||||
*/
|
|
||||||
private overloadArrayToString(): void {
|
|
||||||
Object.defineProperty(Array.prototype, 'toString', {
|
Object.defineProperty(Array.prototype, 'toString', {
|
||||||
value: function(): string {
|
value: function(): string {
|
||||||
let string = '';
|
let string = '';
|
||||||
@ -135,13 +135,7 @@ export class AppComponent {
|
|||||||
},
|
},
|
||||||
enumerable: false
|
enumerable: false
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adds an implementation of flatMap.
|
|
||||||
* TODO: Remove once flatMap made its way into official JS/TS (ES 2019?)
|
|
||||||
*/
|
|
||||||
private overloadFlatMap(): void {
|
|
||||||
Object.defineProperty(Array.prototype, 'flatMap', {
|
Object.defineProperty(Array.prototype, 'flatMap', {
|
||||||
value: function(o: any): any[] {
|
value: function(o: any): any[] {
|
||||||
const concatFunction = (x: any, y: any[]) => x.concat(y);
|
const concatFunction = (x: any, y: any[]) => x.concat(y);
|
||||||
@ -150,6 +144,54 @@ export class AppComponent {
|
|||||||
},
|
},
|
||||||
enumerable: false
|
enumerable: false
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(Array.prototype, 'intersect', {
|
||||||
|
value: function<T>(other: T[]): T[] {
|
||||||
|
let a = this;
|
||||||
|
let 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<T>(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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds some functions to Set.
|
||||||
|
*/
|
||||||
|
private overloadSetFunctions(): void {
|
||||||
|
Object.defineProperty(Set.prototype, 'equals', {
|
||||||
|
value: function<T>(other: Set<T>): boolean {
|
||||||
|
const difference = new Set(this);
|
||||||
|
for (const elem of other) {
|
||||||
|
if (difference.has(elem)) {
|
||||||
|
difference.delete(elem);
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return !difference.size;
|
||||||
|
},
|
||||||
|
enumerable: false
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -68,15 +68,10 @@ export class AppLoadService {
|
|||||||
let repository: BaseRepository<any, any, any> = null;
|
let repository: BaseRepository<any, any, any> = null;
|
||||||
repository = this.injector.get(entry.repository);
|
repository = this.injector.get(entry.repository);
|
||||||
repositories.push(repository);
|
repositories.push(repository);
|
||||||
this.modelMapper.registerCollectionElement(
|
this.modelMapper.registerCollectionElement(entry.model, entry.viewModel, repository);
|
||||||
entry.collectionString,
|
|
||||||
entry.model,
|
|
||||||
entry.viewModel,
|
|
||||||
repository
|
|
||||||
);
|
|
||||||
if (this.isSearchableModelEntry(entry)) {
|
if (this.isSearchableModelEntry(entry)) {
|
||||||
this.searchService.registerModel(
|
this.searchService.registerModel(
|
||||||
entry.collectionString,
|
entry.model.COLLECTIONSTRING,
|
||||||
repository,
|
repository,
|
||||||
entry.searchOrder,
|
entry.searchOrder,
|
||||||
entry.openInNewTab
|
entry.openInNewTab
|
||||||
@ -108,7 +103,7 @@ export class AppLoadService {
|
|||||||
// to check if the result of the contructor (the model instance) is really a searchable.
|
// to check if the result of the contructor (the model instance) is really a searchable.
|
||||||
if (!isSearchable(new entry.viewModel())) {
|
if (!isSearchable(new entry.viewModel())) {
|
||||||
throw Error(
|
throw Error(
|
||||||
`Wrong configuration for ${entry.collectionString}: you gave a searchOrder, but the model is not searchable.`
|
`Wrong configuration for ${entry.model.COLLECTIONSTRING}: you gave a searchOrder, but the model is not searchable.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
@ -47,12 +47,11 @@ export class CollectionStringMapperService {
|
|||||||
* @param model
|
* @param model
|
||||||
*/
|
*/
|
||||||
public registerCollectionElement<V extends BaseViewModel<M>, M extends BaseModel>(
|
public registerCollectionElement<V extends BaseViewModel<M>, M extends BaseModel>(
|
||||||
collectionString: string,
|
|
||||||
model: ModelConstructor<M>,
|
model: ModelConstructor<M>,
|
||||||
viewModel: ViewModelConstructor<V>,
|
viewModel: ViewModelConstructor<V>,
|
||||||
repository: BaseRepository<V, M, TitleInformation>
|
repository: BaseRepository<V, M, TitleInformation>
|
||||||
): void {
|
): void {
|
||||||
this.collectionStringMapping[collectionString] = [model, viewModel, repository];
|
this.collectionStringMapping[model.COLLECTIONSTRING] = [model, viewModel, repository];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -1,7 +1,11 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
import { BehaviorSubject, Observable } from 'rxjs';
|
import { BehaviorSubject, Observable } from 'rxjs';
|
||||||
|
|
||||||
|
import { _ } from 'app/core/translate/translation-marker';
|
||||||
|
import { BannerDefinition, BannerService } from '../ui-services/banner.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This service handles everything connected with being offline.
|
* This service handles everything connected with being offline.
|
||||||
*
|
*
|
||||||
@ -16,6 +20,16 @@ export class OfflineService {
|
|||||||
* BehaviorSubject to receive further status values.
|
* BehaviorSubject to receive further status values.
|
||||||
*/
|
*/
|
||||||
private offline = new BehaviorSubject<boolean>(false);
|
private offline = new BehaviorSubject<boolean>(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
|
* Determines of you are either in Offline mode or not connected via websocket
|
||||||
@ -33,7 +47,7 @@ export class OfflineService {
|
|||||||
if (!this.offline.getValue()) {
|
if (!this.offline.getValue()) {
|
||||||
console.log('offline because whoami failed.');
|
console.log('offline because whoami failed.');
|
||||||
}
|
}
|
||||||
this.offline.next(true);
|
this.goOffline();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -43,7 +57,15 @@ export class OfflineService {
|
|||||||
if (!this.offline.getValue()) {
|
if (!this.offline.getValue()) {
|
||||||
console.log('offline because connection lost.');
|
console.log('offline because connection lost.');
|
||||||
}
|
}
|
||||||
|
this.goOffline();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to set offline status
|
||||||
|
*/
|
||||||
|
private goOffline(): void {
|
||||||
this.offline.next(true);
|
this.offline.next(true);
|
||||||
|
this.banner.addBanner(this.bannerDefinition);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -51,5 +73,6 @@ export class OfflineService {
|
|||||||
*/
|
*/
|
||||||
public goOnline(): void {
|
public goOnline(): void {
|
||||||
this.offline.next(false);
|
this.offline.next(false);
|
||||||
|
this.banner.removeBanner(this.bannerDefinition);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
import { History } from 'app/shared/models/core/history';
|
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
|
* 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.
|
* in History mode, saves the history point.
|
||||||
*/
|
*/
|
||||||
private history: History = null;
|
private history: History = null;
|
||||||
|
private bannerDefinition: BannerDefinition = {
|
||||||
|
type: 'history'
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns, if OpenSlides is in the history mode.
|
* Returns, if OpenSlides is in the history mode.
|
||||||
@ -27,7 +31,7 @@ export class OpenSlidesStatusService {
|
|||||||
/**
|
/**
|
||||||
* Ctor, does nothing.
|
* Ctor, does nothing.
|
||||||
*/
|
*/
|
||||||
public constructor() {}
|
public constructor(private banner: BannerService) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calls the getLocaleString function of the history object, if present.
|
* Calls the getLocaleString function of the history object, if present.
|
||||||
@ -44,6 +48,7 @@ export class OpenSlidesStatusService {
|
|||||||
*/
|
*/
|
||||||
public enterHistoryMode(history: History): void {
|
public enterHistoryMode(history: History): void {
|
||||||
this.history = history;
|
this.history = history;
|
||||||
|
this.banner.addBanner(this.bannerDefinition);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -51,5 +56,6 @@ export class OpenSlidesStatusService {
|
|||||||
*/
|
*/
|
||||||
public leaveHistoryMode(): void {
|
public leaveHistoryMode(): void {
|
||||||
this.history = null;
|
this.history = null;
|
||||||
|
this.banner.removeBanner(this.bannerDefinition);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -98,6 +98,17 @@ export class RelationManagerService {
|
|||||||
viewModel: BaseViewModel,
|
viewModel: BaseViewModel,
|
||||||
relation: RelationDefinition
|
relation: RelationDefinition
|
||||||
): any {
|
): any {
|
||||||
|
// No cache for reverse relations.
|
||||||
|
// The issue: we cannot invalidate the cache, if a new object is created (The
|
||||||
|
// following example is for a O2M foreign relation):
|
||||||
|
// There is no possibility to detect the create case: The target does not update,
|
||||||
|
// all related models does not update. The autoupdate does not provide the created-
|
||||||
|
// information. So we may check, if the relaten has changed in length every time. But
|
||||||
|
// this is the same as just resolving the relation every time it is requested. So no cache here.
|
||||||
|
if (isReverseRelationDefinition(relation)) {
|
||||||
|
return this.handleRelation(model, viewModel, relation) as BaseViewModel | BaseViewModel[];
|
||||||
|
}
|
||||||
|
|
||||||
let result: any;
|
let result: any;
|
||||||
|
|
||||||
const cacheProperty = '__' + property;
|
const cacheProperty = '__' + property;
|
||||||
@ -187,12 +198,24 @@ export class RelationManagerService {
|
|||||||
const _model: M = target.getModel();
|
const _model: M = target.getModel();
|
||||||
const relation = typeof property === 'string' ? relationsByKey[property] : null;
|
const relation = typeof property === 'string' ? relationsByKey[property] : null;
|
||||||
|
|
||||||
|
// try to find a getter for property
|
||||||
if (property in target) {
|
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 (descriptor && descriptor.get) {
|
||||||
|
// if getter was found in prototype chain, bind it with this proxy for right `this` access
|
||||||
result = descriptor.get.bind(viewModel)();
|
result = descriptor.get.bind(viewModel)();
|
||||||
} else {
|
} else {
|
||||||
result = target[property];
|
result = target[property];
|
||||||
|
// console.log(property, target);
|
||||||
}
|
}
|
||||||
} else if (property in _model) {
|
} else if (property in _model) {
|
||||||
result = _model[property];
|
result = _model[property];
|
||||||
|
@ -7,7 +7,6 @@ import { MainMenuEntry } from '../core-services/main-menu.service';
|
|||||||
import { Searchable } from '../../site/base/searchable';
|
import { Searchable } from '../../site/base/searchable';
|
||||||
|
|
||||||
interface BaseModelEntry {
|
interface BaseModelEntry {
|
||||||
collectionString: string;
|
|
||||||
repository: Type<BaseRepository<any, any, any>>;
|
repository: Type<BaseRepository<any, any, any>>;
|
||||||
model: ModelConstructor<BaseModel>;
|
model: ModelConstructor<BaseModel>;
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,5 @@
|
|||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
export interface HasViewModelListObservable<V> {
|
||||||
|
getViewModelListObservable(): Observable<V[]>;
|
||||||
|
}
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
@ -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<ViewAssignmentOption, AssignmentOption, object> {
|
||||||
|
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');
|
||||||
|
};
|
||||||
|
}
|
@ -0,0 +1,14 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { E2EImportsModule } from 'e2e-imports.module';
|
||||||
|
|
||||||
|
import { AssignmentPollRepositoryService } from './assignment-poll-repository.service';
|
||||||
|
|
||||||
|
describe('AssignmentPollRepositoryService', () => {
|
||||||
|
beforeEach(() => TestBed.configureTestingModule({ imports: [E2EImportsModule] }));
|
||||||
|
|
||||||
|
it('should be created', () => {
|
||||||
|
const service: AssignmentPollRepositoryService = TestBed.get(AssignmentPollRepositoryService);
|
||||||
|
expect(service).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,136 @@
|
|||||||
|
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 { 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 { 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 { CollectionStringMapperService } from '../../core-services/collection-string-mapper.service';
|
||||||
|
import { DataStoreService } from '../../core-services/data-store.service';
|
||||||
|
|
||||||
|
const AssignmentPollRelations: RelationDefinition[] = [
|
||||||
|
{
|
||||||
|
type: 'M2M',
|
||||||
|
ownIdKey: 'groups_id',
|
||||||
|
ownKey: 'groups',
|
||||||
|
foreignViewModel: ViewGroup
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'O2M',
|
||||||
|
ownIdKey: 'options_id',
|
||||||
|
ownKey: 'options',
|
||||||
|
foreignViewModel: ViewAssignmentOption
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'M2O',
|
||||||
|
ownIdKey: 'assignment_id',
|
||||||
|
ownKey: 'assignment',
|
||||||
|
foreignViewModel: ViewAssignment
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'M2M',
|
||||||
|
ownIdKey: 'voted_id',
|
||||||
|
ownKey: 'voted',
|
||||||
|
foreignViewModel: ViewUser
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface AssignmentAnalogVoteData {
|
||||||
|
options: {
|
||||||
|
[key: number]: {
|
||||||
|
Y: number;
|
||||||
|
N?: number;
|
||||||
|
A?: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
votesvalid?: number;
|
||||||
|
votesinvalid?: number;
|
||||||
|
votescast?: number;
|
||||||
|
global_no?: number;
|
||||||
|
global_abstain?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VotingData {
|
||||||
|
votes: Object;
|
||||||
|
global?: GlobalVote;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GlobalVote = 'A' | 'N';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository Service for Assignments.
|
||||||
|
*
|
||||||
|
* Documentation partially provided in {@link BaseRepository}
|
||||||
|
*/
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class AssignmentPollRepositoryService extends BasePollRepositoryService<
|
||||||
|
ViewAssignmentPoll,
|
||||||
|
AssignmentPoll,
|
||||||
|
AssignmentPollTitleInformation
|
||||||
|
> {
|
||||||
|
/**
|
||||||
|
* Constructor for the Assignment Repository.
|
||||||
|
*
|
||||||
|
* @param DS DataStore access
|
||||||
|
* @param dataSend Sending data
|
||||||
|
* @param mapperService Map models to object
|
||||||
|
* @param viewModelStoreService Access view models
|
||||||
|
* @param translate Translate string
|
||||||
|
* @param httpService make HTTP Requests
|
||||||
|
*/
|
||||||
|
public constructor(
|
||||||
|
DS: DataStoreService,
|
||||||
|
dataSend: DataSendService,
|
||||||
|
mapperService: CollectionStringMapperService,
|
||||||
|
viewModelStoreService: ViewModelStoreService,
|
||||||
|
translate: TranslateService,
|
||||||
|
relationManager: RelationManagerService,
|
||||||
|
votingService: VotingService,
|
||||||
|
http: HttpService
|
||||||
|
) {
|
||||||
|
super(
|
||||||
|
DS,
|
||||||
|
dataSend,
|
||||||
|
mapperService,
|
||||||
|
viewModelStoreService,
|
||||||
|
translate,
|
||||||
|
relationManager,
|
||||||
|
AssignmentPoll,
|
||||||
|
AssignmentPollRelations,
|
||||||
|
{},
|
||||||
|
votingService,
|
||||||
|
http
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getTitle = (titleInformation: AssignmentPollTitleInformation) => {
|
||||||
|
return titleInformation.title;
|
||||||
|
};
|
||||||
|
|
||||||
|
public getVerboseName = (plural: boolean = false) => {
|
||||||
|
return this.translate.instant(plural ? 'Polls' : 'Poll');
|
||||||
|
};
|
||||||
|
|
||||||
|
public vote(data: VotingData, poll_id: number): Promise<void> {
|
||||||
|
let requestData;
|
||||||
|
if (data.global) {
|
||||||
|
requestData = `"${data.global}"`;
|
||||||
|
} else {
|
||||||
|
requestData = data.votes;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.http.post(`/rest/assignments/assignment-poll/${poll_id}/vote/`, requestData);
|
||||||
|
}
|
||||||
|
}
|
@ -8,12 +8,9 @@ import { RelationManagerService } from 'app/core/core-services/relation-manager.
|
|||||||
import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service';
|
import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service';
|
||||||
import { RelationDefinition } from 'app/core/definitions/relations';
|
import { RelationDefinition } from 'app/core/definitions/relations';
|
||||||
import { Assignment } from 'app/shared/models/assignments/assignment';
|
import { Assignment } from 'app/shared/models/assignments/assignment';
|
||||||
import { AssignmentPoll } from 'app/shared/models/assignments/assignment-poll';
|
|
||||||
import { AssignmentPollOption } from 'app/shared/models/assignments/assignment-poll-option';
|
|
||||||
import { AssignmentRelatedUser } from 'app/shared/models/assignments/assignment-related-user';
|
import { AssignmentRelatedUser } from 'app/shared/models/assignments/assignment-related-user';
|
||||||
import { AssignmentTitleInformation, ViewAssignment } from 'app/site/assignments/models/view-assignment';
|
import { AssignmentTitleInformation, ViewAssignment } from 'app/site/assignments/models/view-assignment';
|
||||||
import { ViewAssignmentPoll } from 'app/site/assignments/models/view-assignment-poll';
|
import { ViewAssignmentPoll } from 'app/site/assignments/models/view-assignment-poll';
|
||||||
import { ViewAssignmentPollOption } from 'app/site/assignments/models/view-assignment-poll-option';
|
|
||||||
import { ViewAssignmentRelatedUser } from 'app/site/assignments/models/view-assignment-related-user';
|
import { ViewAssignmentRelatedUser } from 'app/site/assignments/models/view-assignment-related-user';
|
||||||
import { ViewMediafile } from 'app/site/mediafiles/models/view-mediafile';
|
import { ViewMediafile } from 'app/site/mediafiles/models/view-mediafile';
|
||||||
import { ViewTag } from 'app/site/tags/models/view-tag';
|
import { ViewTag } from 'app/site/tags/models/view-tag';
|
||||||
@ -35,6 +32,12 @@ const AssignmentRelations: RelationDefinition[] = [
|
|||||||
ownIdKey: 'attachments_id',
|
ownIdKey: 'attachments_id',
|
||||||
ownKey: 'attachments',
|
ownKey: 'attachments',
|
||||||
foreignViewModel: ViewMediafile
|
foreignViewModel: ViewMediafile
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'O2M',
|
||||||
|
ownKey: 'polls',
|
||||||
|
foreignIdKey: 'assignment_id',
|
||||||
|
foreignViewModel: ViewAssignmentPoll
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -57,28 +60,6 @@ const AssignmentNestedModelDescriptors: NestedModelDescriptors = {
|
|||||||
getTitle: (viewAssignmentRelatedUser: ViewAssignmentRelatedUser) =>
|
getTitle: (viewAssignmentRelatedUser: ViewAssignmentRelatedUser) =>
|
||||||
viewAssignmentRelatedUser.user ? viewAssignmentRelatedUser.user.getFullName() : ''
|
viewAssignmentRelatedUser.user ? viewAssignmentRelatedUser.user.getFullName() : ''
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
ownKey: 'polls',
|
|
||||||
foreignViewModel: ViewAssignmentPoll,
|
|
||||||
foreignModel: AssignmentPoll,
|
|
||||||
relationDefinitionsByKey: {}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
'assignments/assignment-poll': [
|
|
||||||
{
|
|
||||||
ownKey: 'options',
|
|
||||||
foreignViewModel: ViewAssignmentPollOption,
|
|
||||||
foreignModel: AssignmentPollOption,
|
|
||||||
order: 'weight',
|
|
||||||
relationDefinitionsByKey: {
|
|
||||||
user: {
|
|
||||||
type: 'M2O',
|
|
||||||
ownIdKey: 'candidate_id',
|
|
||||||
ownKey: 'user',
|
|
||||||
foreignViewModel: ViewUser
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
@ -97,11 +78,8 @@ export class AssignmentRepositoryService extends BaseIsAgendaItemAndListOfSpeake
|
|||||||
AssignmentTitleInformation
|
AssignmentTitleInformation
|
||||||
> {
|
> {
|
||||||
private readonly restPath = '/rest/assignments/assignment/';
|
private readonly restPath = '/rest/assignments/assignment/';
|
||||||
private readonly restPollPath = '/rest/assignments/poll/';
|
|
||||||
private readonly candidatureOtherPath = '/candidature_other/';
|
private readonly candidatureOtherPath = '/candidature_other/';
|
||||||
private readonly candidatureSelfPath = '/candidature_self/';
|
private readonly candidatureSelfPath = '/candidature_self/';
|
||||||
private readonly createPollPath = '/create_poll/';
|
|
||||||
private readonly markElectedPath = '/mark_elected/';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constructor for the Assignment Repository.
|
* Constructor for the Assignment Repository.
|
||||||
@ -179,87 +157,6 @@ export class AssignmentRepositoryService extends BaseIsAgendaItemAndListOfSpeake
|
|||||||
await this.httpService.delete(this.restPath + assignment.id + this.candidatureSelfPath);
|
await this.httpService.delete(this.restPath + assignment.id + this.candidatureSelfPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new Poll to a given assignment
|
|
||||||
*
|
|
||||||
* @param assignment The assignment to add the poll to
|
|
||||||
*/
|
|
||||||
public async addPoll(assignment: ViewAssignment): Promise<void> {
|
|
||||||
await this.httpService.post(this.restPath + assignment.id + this.createPollPath);
|
|
||||||
// TODO: change current tab to new poll
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes a poll
|
|
||||||
*
|
|
||||||
* @param id id of the poll to delete
|
|
||||||
*/
|
|
||||||
public async deletePoll(poll: ViewAssignmentPoll): Promise<void> {
|
|
||||||
await this.httpService.delete(`${this.restPollPath}${poll.id}/`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* update data (metadata etc) for a poll
|
|
||||||
*
|
|
||||||
* @param poll the (partial) data to update
|
|
||||||
* @param originalPoll the poll to update
|
|
||||||
*
|
|
||||||
* TODO: check if votes is untouched
|
|
||||||
*/
|
|
||||||
public async updatePoll(poll: Partial<AssignmentPoll>, originalPoll: ViewAssignmentPoll): Promise<void> {
|
|
||||||
const data: AssignmentPoll = Object.assign(originalPoll.poll, poll);
|
|
||||||
await this.httpService.patch(`${this.restPollPath}${originalPoll.id}/`, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* TODO: temporary (?) update votes method. Needed because server needs
|
|
||||||
* different input than it's output in case of votes ?
|
|
||||||
*
|
|
||||||
* @param poll the updated Poll
|
|
||||||
* @param originalPoll the original poll
|
|
||||||
*/
|
|
||||||
public async updateVotes(poll: Partial<AssignmentPoll>, originalPoll: ViewAssignmentPoll): Promise<void> {
|
|
||||||
const votes = poll.options.map(option => {
|
|
||||||
const voteObject = {};
|
|
||||||
for (const vote of option.votes) {
|
|
||||||
voteObject[vote.value] = vote.weight;
|
|
||||||
}
|
|
||||||
return voteObject;
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = {
|
|
||||||
assignment_id: originalPoll.assignment_id,
|
|
||||||
votes: votes,
|
|
||||||
votesabstain: poll.votesabstain || null,
|
|
||||||
votescast: poll.votescast || null,
|
|
||||||
votesinvalid: poll.votesinvalid || null,
|
|
||||||
votesno: poll.votesno || null,
|
|
||||||
votesvalid: poll.votesvalid || null
|
|
||||||
};
|
|
||||||
|
|
||||||
await this.httpService.put(`${this.restPollPath}${originalPoll.id}/`, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* change the 'elected' state of an election candidate
|
|
||||||
*
|
|
||||||
* @param assignmentRelatedUser
|
|
||||||
* @param assignment
|
|
||||||
* @param elected true if the candidate is to be elected, false if unelected
|
|
||||||
*/
|
|
||||||
public async markElected(
|
|
||||||
assignmentRelatedUser: ViewAssignmentRelatedUser,
|
|
||||||
assignment: ViewAssignment,
|
|
||||||
elected: boolean
|
|
||||||
): Promise<void> {
|
|
||||||
const data = { user: assignmentRelatedUser.user_id };
|
|
||||||
if (elected) {
|
|
||||||
await this.httpService.post(this.restPath + assignment.id + this.markElectedPath, data);
|
|
||||||
} else {
|
|
||||||
await this.httpService.delete(this.restPath + assignment.id + this.markElectedPath, data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sends a request to sort an assignment's candidates
|
* Sends a request to sort an assignment's candidates
|
||||||
*
|
*
|
||||||
|
@ -0,0 +1,14 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { E2EImportsModule } from 'e2e-imports.module';
|
||||||
|
|
||||||
|
import { AssignmentVoteRepositoryService } from './assignment-vote-repository.service';
|
||||||
|
|
||||||
|
describe('AssignmentVoteRepositoryService', () => {
|
||||||
|
beforeEach(() => TestBed.configureTestingModule({ imports: [E2EImportsModule] }));
|
||||||
|
|
||||||
|
it('should be created', () => {
|
||||||
|
const service: AssignmentVoteRepositoryService = TestBed.get(AssignmentVoteRepositoryService);
|
||||||
|
expect(service).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,80 @@
|
|||||||
|
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 { 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';
|
||||||
|
import { CollectionStringMapperService } from '../../core-services/collection-string-mapper.service';
|
||||||
|
import { DataStoreService } from '../../core-services/data-store.service';
|
||||||
|
|
||||||
|
const AssignmentVoteRelations: RelationDefinition[] = [
|
||||||
|
{
|
||||||
|
type: 'M2O',
|
||||||
|
ownIdKey: 'user_id',
|
||||||
|
ownKey: 'user',
|
||||||
|
foreignViewModel: ViewUser
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'M2O',
|
||||||
|
ownIdKey: 'option_id',
|
||||||
|
ownKey: 'option',
|
||||||
|
foreignViewModel: ViewAssignmentOption
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository Service for Assignments.
|
||||||
|
*
|
||||||
|
* Documentation partially provided in {@link BaseRepository}
|
||||||
|
*/
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class AssignmentVoteRepositoryService extends BaseRepository<ViewAssignmentVote, AssignmentVote, object> {
|
||||||
|
/**
|
||||||
|
* @param DS DataStore access
|
||||||
|
* @param dataSend Sending data
|
||||||
|
* @param mapperService Map models to object
|
||||||
|
* @param viewModelStoreService Access view models
|
||||||
|
* @param translate Translate string
|
||||||
|
* @param httpService make HTTP Requests
|
||||||
|
*/
|
||||||
|
public constructor(
|
||||||
|
DS: DataStoreService,
|
||||||
|
dataSend: DataSendService,
|
||||||
|
mapperService: CollectionStringMapperService,
|
||||||
|
viewModelStoreService: ViewModelStoreService,
|
||||||
|
translate: TranslateService,
|
||||||
|
relationManager: RelationManagerService
|
||||||
|
) {
|
||||||
|
super(
|
||||||
|
DS,
|
||||||
|
dataSend,
|
||||||
|
mapperService,
|
||||||
|
viewModelStoreService,
|
||||||
|
translate,
|
||||||
|
relationManager,
|
||||||
|
AssignmentVote,
|
||||||
|
AssignmentVoteRelations
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getTitle = (titleInformation: object) => {
|
||||||
|
return 'Vote';
|
||||||
|
};
|
||||||
|
|
||||||
|
public getVerboseName = (plural: boolean = false) => {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
@ -8,6 +8,7 @@ import { BaseViewModel, TitleInformation, ViewModelConstructor } from '../../sit
|
|||||||
import { CollectionStringMapperService } from '../core-services/collection-string-mapper.service';
|
import { CollectionStringMapperService } from '../core-services/collection-string-mapper.service';
|
||||||
import { DataSendService } from '../core-services/data-send.service';
|
import { DataSendService } from '../core-services/data-send.service';
|
||||||
import { DataStoreService } from '../core-services/data-store.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 { Identifiable } from '../../shared/models/base/identifiable';
|
||||||
import { OnAfterAppsLoaded } from '../definitions/on-after-apps-loaded';
|
import { OnAfterAppsLoaded } from '../definitions/on-after-apps-loaded';
|
||||||
import { RelationManagerService } from '../core-services/relation-manager.service';
|
import { RelationManagerService } from '../core-services/relation-manager.service';
|
||||||
@ -30,7 +31,7 @@ export interface NestedModelDescriptors {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export abstract class BaseRepository<V extends BaseViewModel & T, M extends BaseModel, T extends TitleInformation>
|
export abstract class BaseRepository<V extends BaseViewModel & T, M extends BaseModel, T extends TitleInformation>
|
||||||
implements OnAfterAppsLoaded, Collection {
|
implements OnAfterAppsLoaded, Collection, HasViewModelListObservable<V> {
|
||||||
/**
|
/**
|
||||||
* Stores all the viewModel in an object
|
* Stores all the viewModel in an object
|
||||||
*/
|
*/
|
||||||
@ -42,8 +43,8 @@ export abstract class BaseRepository<V extends BaseViewModel & T, M extends Base
|
|||||||
protected viewModelSubjects: { [modelId: number]: BehaviorSubject<V> } = {};
|
protected viewModelSubjects: { [modelId: number]: BehaviorSubject<V> } = {};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Observable subject for the whole list. These entries are unsorted an not piped through
|
* Observable subject for the whole list. These entries are unsorted and not piped through
|
||||||
* autodTime. Just use this internally.
|
* auditTime. Just use this internally.
|
||||||
*
|
*
|
||||||
* It's used to debounce messages on the sortedViewModelListSubject
|
* It's used to debounce messages on the sortedViewModelListSubject
|
||||||
*/
|
*/
|
||||||
@ -188,7 +189,7 @@ export abstract class BaseRepository<V extends BaseViewModel & T, M extends Base
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* After creating a view model, all functions for models form the repo
|
* After creating a view model, all functions for models from the repo
|
||||||
* are assigned to the new view model.
|
* are assigned to the new view model.
|
||||||
*/
|
*/
|
||||||
protected createViewModelWithTitles(model: M): V {
|
protected createViewModelWithTitles(model: M): V {
|
||||||
@ -269,7 +270,7 @@ export abstract class BaseRepository<V extends BaseViewModel & T, M extends Base
|
|||||||
this.viewModelStore = {};
|
this.viewModelStore = {};
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* The function used for sorting the data of this repository. The defualt sorts by ID.
|
* The function used for sorting the data of this repository. The default sorts by ID.
|
||||||
*/
|
*/
|
||||||
protected viewModelSortFn: (a: V, b: V) => number = (a: V, b: V) => a.id - b.id;
|
protected viewModelSortFn: (a: V, b: V) => number = (a: V, b: V) => a.id - b.id;
|
||||||
|
|
||||||
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
@ -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<ViewMotionOption, MotionOption, object> {
|
||||||
|
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');
|
||||||
|
};
|
||||||
|
}
|
@ -0,0 +1,14 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { E2EImportsModule } from 'e2e-imports.module';
|
||||||
|
|
||||||
|
import { MotionPollRepositoryService } from './motion-poll-repository.service';
|
||||||
|
|
||||||
|
describe('MotionPollRepositoryService', () => {
|
||||||
|
beforeEach(() => TestBed.configureTestingModule({ imports: [E2EImportsModule] }));
|
||||||
|
|
||||||
|
it('should be created', () => {
|
||||||
|
const service: MotionPollRepositoryService = TestBed.get(MotionPollRepositoryService);
|
||||||
|
expect(service).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,98 @@
|
|||||||
|
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 { VotingService } from 'app/core/ui-services/voting.service';
|
||||||
|
import { MotionPoll } from 'app/shared/models/motions/motion-poll';
|
||||||
|
import { VoteValue } from 'app/shared/models/poll/base-vote';
|
||||||
|
import { ViewMotion } from 'app/site/motions/models/view-motion';
|
||||||
|
import { ViewMotionOption } from 'app/site/motions/models/view-motion-option';
|
||||||
|
import { MotionPollTitleInformation, ViewMotionPoll } from 'app/site/motions/models/view-motion-poll';
|
||||||
|
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 { CollectionStringMapperService } from '../../core-services/collection-string-mapper.service';
|
||||||
|
import { DataStoreService } from '../../core-services/data-store.service';
|
||||||
|
|
||||||
|
const MotionPollRelations: RelationDefinition[] = [
|
||||||
|
{
|
||||||
|
type: 'M2M',
|
||||||
|
ownIdKey: 'groups_id',
|
||||||
|
ownKey: 'groups',
|
||||||
|
foreignViewModel: ViewGroup
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'O2M',
|
||||||
|
ownIdKey: 'options_id',
|
||||||
|
ownKey: 'options',
|
||||||
|
foreignViewModel: ViewMotionOption
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'M2O',
|
||||||
|
ownIdKey: 'motion_id',
|
||||||
|
ownKey: 'motion',
|
||||||
|
foreignViewModel: ViewMotion
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'M2M',
|
||||||
|
ownIdKey: 'voted_id',
|
||||||
|
ownKey: 'voted',
|
||||||
|
foreignViewModel: ViewUser
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository Service for Assignments.
|
||||||
|
*
|
||||||
|
* Documentation partially provided in {@link BaseRepository}
|
||||||
|
*/
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class MotionPollRepositoryService extends BasePollRepositoryService<
|
||||||
|
ViewMotionPoll,
|
||||||
|
MotionPoll,
|
||||||
|
MotionPollTitleInformation
|
||||||
|
> {
|
||||||
|
public constructor(
|
||||||
|
DS: DataStoreService,
|
||||||
|
dataSend: DataSendService,
|
||||||
|
mapperService: CollectionStringMapperService,
|
||||||
|
viewModelStoreService: ViewModelStoreService,
|
||||||
|
translate: TranslateService,
|
||||||
|
relationManager: RelationManagerService,
|
||||||
|
votingService: VotingService,
|
||||||
|
http: HttpService
|
||||||
|
) {
|
||||||
|
super(
|
||||||
|
DS,
|
||||||
|
dataSend,
|
||||||
|
mapperService,
|
||||||
|
viewModelStoreService,
|
||||||
|
translate,
|
||||||
|
relationManager,
|
||||||
|
MotionPoll,
|
||||||
|
MotionPollRelations,
|
||||||
|
{},
|
||||||
|
votingService,
|
||||||
|
http
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getTitle = (titleInformation: MotionPollTitleInformation) => {
|
||||||
|
return titleInformation.title;
|
||||||
|
};
|
||||||
|
|
||||||
|
public getVerboseName = (plural: boolean = false) => {
|
||||||
|
return this.translate.instant(plural ? 'Polls' : 'Poll');
|
||||||
|
};
|
||||||
|
|
||||||
|
public vote(vote: VoteValue, poll_id: number): Promise<void> {
|
||||||
|
return this.http.post(`/rest/motions/motion-poll/${poll_id}/vote/`, JSON.stringify(vote));
|
||||||
|
}
|
||||||
|
}
|
@ -14,7 +14,6 @@ import { ConfigService } from 'app/core/ui-services/config.service';
|
|||||||
import { DiffLinesInParagraph, DiffService } from 'app/core/ui-services/diff.service';
|
import { DiffLinesInParagraph, DiffService } from 'app/core/ui-services/diff.service';
|
||||||
import { TreeIdNode } from 'app/core/ui-services/tree.service';
|
import { TreeIdNode } from 'app/core/ui-services/tree.service';
|
||||||
import { Motion } from 'app/shared/models/motions/motion';
|
import { Motion } from 'app/shared/models/motions/motion';
|
||||||
import { MotionPoll } from 'app/shared/models/motions/motion-poll';
|
|
||||||
import { Submitter } from 'app/shared/models/motions/submitter';
|
import { Submitter } from 'app/shared/models/motions/submitter';
|
||||||
import { ViewUnifiedChange, ViewUnifiedChangeType } from 'app/shared/models/motions/view-unified-change';
|
import { ViewUnifiedChange, ViewUnifiedChangeType } from 'app/shared/models/motions/view-unified-change';
|
||||||
import { PersonalNoteContent } from 'app/shared/models/users/personal-note';
|
import { PersonalNoteContent } from 'app/shared/models/users/personal-note';
|
||||||
@ -24,6 +23,7 @@ import { MotionTitleInformation, ViewMotion } from 'app/site/motions/models/view
|
|||||||
import { ViewMotionAmendedParagraph } from 'app/site/motions/models/view-motion-amended-paragraph';
|
import { ViewMotionAmendedParagraph } from 'app/site/motions/models/view-motion-amended-paragraph';
|
||||||
import { ViewMotionBlock } from 'app/site/motions/models/view-motion-block';
|
import { ViewMotionBlock } from 'app/site/motions/models/view-motion-block';
|
||||||
import { ViewMotionChangeRecommendation } from 'app/site/motions/models/view-motion-change-recommendation';
|
import { ViewMotionChangeRecommendation } from 'app/site/motions/models/view-motion-change-recommendation';
|
||||||
|
import { ViewMotionPoll } from 'app/site/motions/models/view-motion-poll';
|
||||||
import { ViewState } from 'app/site/motions/models/view-state';
|
import { ViewState } from 'app/site/motions/models/view-state';
|
||||||
import { ViewStatuteParagraph } from 'app/site/motions/models/view-statute-paragraph';
|
import { ViewStatuteParagraph } from 'app/site/motions/models/view-statute-paragraph';
|
||||||
import { ViewSubmitter } from 'app/site/motions/models/view-submitter';
|
import { ViewSubmitter } from 'app/site/motions/models/view-submitter';
|
||||||
@ -126,12 +126,17 @@ const MotionRelations: RelationDefinition[] = [
|
|||||||
ownKey: 'amendments',
|
ownKey: 'amendments',
|
||||||
foreignViewModel: ViewMotion
|
foreignViewModel: ViewMotion
|
||||||
},
|
},
|
||||||
// TMP:
|
|
||||||
{
|
{
|
||||||
type: 'M2O',
|
type: 'M2O',
|
||||||
ownIdKey: 'parent_id',
|
ownIdKey: 'parent_id',
|
||||||
ownKey: 'parent',
|
ownKey: 'parent',
|
||||||
foreignViewModel: ViewMotion
|
foreignViewModel: ViewMotion
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'O2M',
|
||||||
|
foreignIdKey: 'motion_id',
|
||||||
|
ownKey: 'polls',
|
||||||
|
foreignViewModel: ViewMotionPoll
|
||||||
}
|
}
|
||||||
// Personal notes are dynamically added in the repo.
|
// Personal notes are dynamically added in the repo.
|
||||||
];
|
];
|
||||||
@ -844,46 +849,6 @@ export class MotionRepositoryService extends BaseIsAgendaItemAndListOfSpeakersCo
|
|||||||
.filter((para: ViewMotionAmendedParagraph) => para !== null);
|
.filter((para: ViewMotionAmendedParagraph) => para !== null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Sends a request to the server, creating a new poll for the motion
|
|
||||||
*/
|
|
||||||
public async createPoll(motion: ViewMotion): Promise<void> {
|
|
||||||
const url = '/rest/motions/motion/' + motion.id + '/create_poll/';
|
|
||||||
await this.httpService.post(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sends an update request for a poll.
|
|
||||||
*
|
|
||||||
* @param poll
|
|
||||||
*/
|
|
||||||
public async updatePoll(poll: MotionPoll): Promise<void> {
|
|
||||||
const url = '/rest/motions/motion-poll/' + poll.id + '/';
|
|
||||||
const data = {
|
|
||||||
motion_id: poll.motion_id,
|
|
||||||
id: poll.id,
|
|
||||||
votescast: poll.votescast,
|
|
||||||
votesvalid: poll.votesvalid,
|
|
||||||
votesinvalid: poll.votesinvalid,
|
|
||||||
votes: {
|
|
||||||
Yes: poll.yes,
|
|
||||||
No: poll.no,
|
|
||||||
Abstain: poll.abstain
|
|
||||||
}
|
|
||||||
};
|
|
||||||
await this.httpService.put(url, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sends a http request to delete the given poll
|
|
||||||
*
|
|
||||||
* @param poll
|
|
||||||
*/
|
|
||||||
public async deletePoll(poll: MotionPoll): Promise<void> {
|
|
||||||
const url = '/rest/motions/motion-poll/' + poll.id + '/';
|
|
||||||
await this.httpService.delete(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Signals the acceptance of the current recommendation to the server
|
* Signals the acceptance of the current recommendation to the server
|
||||||
*
|
*
|
||||||
|
@ -0,0 +1,14 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { E2EImportsModule } from 'e2e-imports.module';
|
||||||
|
|
||||||
|
import { MotionVoteRepositoryService } from './motion-vote-repository.service';
|
||||||
|
|
||||||
|
describe('MotionVoteRepositoryService', () => {
|
||||||
|
beforeEach(() => TestBed.configureTestingModule({ imports: [E2EImportsModule] }));
|
||||||
|
|
||||||
|
it('should be created', () => {
|
||||||
|
const service: MotionVoteRepositoryService = TestBed.get(MotionVoteRepositoryService);
|
||||||
|
expect(service).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,76 @@
|
|||||||
|
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 { 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';
|
||||||
|
import { CollectionStringMapperService } from '../../core-services/collection-string-mapper.service';
|
||||||
|
import { DataStoreService } from '../../core-services/data-store.service';
|
||||||
|
|
||||||
|
const MotionVoteRelations: RelationDefinition[] = [
|
||||||
|
{
|
||||||
|
type: 'M2O',
|
||||||
|
ownIdKey: 'user_id',
|
||||||
|
ownKey: 'user',
|
||||||
|
foreignViewModel: ViewUser
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'M2O',
|
||||||
|
ownIdKey: 'option_id',
|
||||||
|
ownKey: 'option',
|
||||||
|
foreignViewModel: ViewMotionOption
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository Service for Assignments.
|
||||||
|
*
|
||||||
|
* Documentation partially provided in {@link BaseRepository}
|
||||||
|
*/
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class MotionVoteRepositoryService extends BaseRepository<ViewMotionVote, MotionVote, object> {
|
||||||
|
/**
|
||||||
|
* @param DS DataStore access
|
||||||
|
* @param dataSend Sending data
|
||||||
|
* @param mapperService Map models to object
|
||||||
|
* @param viewModelStoreService Access view models
|
||||||
|
* @param translate Translate string
|
||||||
|
* @param httpService make HTTP Requests
|
||||||
|
*/
|
||||||
|
public constructor(
|
||||||
|
DS: DataStoreService,
|
||||||
|
dataSend: DataSendService,
|
||||||
|
mapperService: CollectionStringMapperService,
|
||||||
|
viewModelStoreService: ViewModelStoreService,
|
||||||
|
translate: TranslateService,
|
||||||
|
relationManager: RelationManagerService
|
||||||
|
) {
|
||||||
|
super(
|
||||||
|
DS,
|
||||||
|
dataSend,
|
||||||
|
mapperService,
|
||||||
|
viewModelStoreService,
|
||||||
|
translate,
|
||||||
|
relationManager,
|
||||||
|
MotionVote,
|
||||||
|
MotionVoteRelations
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getTitle = (titleInformation: object) => {
|
||||||
|
return 'Vote';
|
||||||
|
};
|
||||||
|
|
||||||
|
public getVerboseName = (plural: boolean = false) => {
|
||||||
|
return this.translate.instant(plural ? 'Votes' : 'Vote');
|
||||||
|
};
|
||||||
|
}
|
@ -1,6 +1,8 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
import { TranslateService } from '@ngx-translate/core';
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { map } from 'rxjs/operators';
|
||||||
|
|
||||||
import { HttpService } from 'app/core/core-services/http.service';
|
import { HttpService } from 'app/core/core-services/http.service';
|
||||||
import { RelationManagerService } from 'app/core/core-services/relation-manager.service';
|
import { RelationManagerService } from 'app/core/core-services/relation-manager.service';
|
||||||
@ -72,6 +74,13 @@ export class GroupRepositoryService extends BaseRepository<ViewGroup, Group, Gro
|
|||||||
return this.translate.instant(plural ? 'Groups' : 'Group');
|
return this.translate.instant(plural ? 'Groups' : 'Group');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
public getNameForIds(...ids: number[]): string {
|
||||||
|
return this.getSortedViewModelList()
|
||||||
|
.filter(group => ids.includes(group.id))
|
||||||
|
.map(group => this.translate.instant(group.getTitle()))
|
||||||
|
.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggles the given permisson.
|
* Toggles the given permisson.
|
||||||
*
|
*
|
||||||
@ -187,4 +196,12 @@ export class GroupRepositoryService extends BaseRepository<ViewGroup, Group, Gro
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an Observable for all groups except the default group.
|
||||||
|
*/
|
||||||
|
public getViewModelListObservableWithoutDefaultGroup(): Observable<ViewGroup[]> {
|
||||||
|
// since groups are sorted by id, default is always the first entry
|
||||||
|
return this.getViewModelListObservable().pipe(map(groups => groups.slice(1)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -125,6 +125,18 @@ export class UserRepositoryService extends BaseRepository<ViewUser, User, UserTi
|
|||||||
return name.trim();
|
return name.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getLevelAndNumber(titleInformation: UserTitleInformation): string {
|
||||||
|
if (titleInformation.structure_level && titleInformation.number) {
|
||||||
|
return `${titleInformation.structure_level} · ${this.translate.instant('No.')} ${titleInformation.number}`;
|
||||||
|
} else if (titleInformation.structure_level) {
|
||||||
|
return titleInformation.structure_level;
|
||||||
|
} else if (titleInformation.number) {
|
||||||
|
return `${this.translate.instant('No.')} ${titleInformation.number}`;
|
||||||
|
} else {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public getVerboseName = (plural: boolean = false) => {
|
public getVerboseName = (plural: boolean = false) => {
|
||||||
return this.translate.instant(plural ? 'Participants' : 'Participant');
|
return this.translate.instant(plural ? 'Participants' : 'Participant');
|
||||||
};
|
};
|
||||||
@ -145,12 +157,13 @@ export class UserRepositoryService extends BaseRepository<ViewUser, User, UserTi
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds teh short and full name to the view user.
|
* Adds the short and full name to the view user.
|
||||||
*/
|
*/
|
||||||
protected createViewModelWithTitles(model: User): ViewUser {
|
protected createViewModelWithTitles(model: User): ViewUser {
|
||||||
const viewModel = super.createViewModelWithTitles(model);
|
const viewModel = super.createViewModelWithTitles(model);
|
||||||
viewModel.getFullName = () => this.getFullName(viewModel);
|
viewModel.getFullName = () => this.getFullName(viewModel);
|
||||||
viewModel.getShortName = () => this.getShortName(viewModel);
|
viewModel.getShortName = () => this.getShortName(viewModel);
|
||||||
|
viewModel.getLevelAndNumber = () => this.getLevelAndNumber(viewModel);
|
||||||
return viewModel;
|
return viewModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -99,7 +99,8 @@ _('Only main agenda items');
|
|||||||
_('Topics');
|
_('Topics');
|
||||||
_('Open requests to speak');
|
_('Open requests to speak');
|
||||||
|
|
||||||
// Motions config strings
|
// ** Motions **
|
||||||
|
// config strings
|
||||||
// subgroup general
|
// subgroup general
|
||||||
_('General');
|
_('General');
|
||||||
_('Workflow of new motions');
|
_('Workflow of new motions');
|
||||||
@ -155,7 +156,7 @@ _('Choose 0 to disable the supporting system.');
|
|||||||
_('Remove all supporters of a motion if a submitter edits his motion in early state');
|
_('Remove all supporters of a motion if a submitter edits his motion in early state');
|
||||||
// subgroup Voting and ballot papers
|
// subgroup Voting and ballot papers
|
||||||
_('Voting and ballot papers');
|
_('Voting and ballot papers');
|
||||||
_('The 100 % base of a voting result consists of');
|
_('Default 100 % base of a voting result');
|
||||||
_('Yes/No/Abstain');
|
_('Yes/No/Abstain');
|
||||||
_('Yes/No');
|
_('Yes/No');
|
||||||
_('All valid ballots');
|
_('All valid ballots');
|
||||||
@ -172,16 +173,13 @@ _('Number of all delegates');
|
|||||||
_('Number of all participants');
|
_('Number of all participants');
|
||||||
_('Use the following custom number');
|
_('Use the following custom number');
|
||||||
_('Custom number of ballot papers');
|
_('Custom number of ballot papers');
|
||||||
|
_('Voting');
|
||||||
// subgroup PDF export
|
// subgroup PDF export
|
||||||
_('PDF export');
|
_('PDF export');
|
||||||
_('Title for PDF documents of motions');
|
_('Title for PDF documents of motions');
|
||||||
_('Preamble text for PDF documents of motions');
|
_('Preamble text for PDF documents of motions');
|
||||||
_('Show submitters and recommendation/state in table of contents');
|
_('Show submitters and recommendation/state in table of contents');
|
||||||
_('Show checkbox to record decision');
|
_('Show checkbox to record decision');
|
||||||
// misc motion strings
|
|
||||||
_('Amendment');
|
|
||||||
_('Statute amendment for');
|
|
||||||
_('Statute paragraphs');
|
|
||||||
|
|
||||||
// motion workflow 1
|
// motion workflow 1
|
||||||
_('Simple Workflow');
|
_('Simple Workflow');
|
||||||
@ -224,46 +222,7 @@ _('Needs review');
|
|||||||
_('rejected (not authorized)');
|
_('rejected (not authorized)');
|
||||||
_('Reject (not authorized)');
|
_('Reject (not authorized)');
|
||||||
_('Rejection (not authorized)');
|
_('Rejection (not authorized)');
|
||||||
// misc for motions
|
// motion workflow manager
|
||||||
_('Called');
|
|
||||||
_('Called with');
|
|
||||||
_('Recommendation');
|
|
||||||
_('Motion block');
|
|
||||||
_('The text field may not be blank.');
|
|
||||||
_('The reason field may not be blank.');
|
|
||||||
|
|
||||||
// Assignment config strings
|
|
||||||
_('Election method');
|
|
||||||
_('Automatic assign of method');
|
|
||||||
_('Always one option per candidate');
|
|
||||||
_('Always Yes-No-Abstain per candidate');
|
|
||||||
_('Always Yes/No per candidate');
|
|
||||||
_('Elections');
|
|
||||||
_('Ballot and ballot papers');
|
|
||||||
_('The 100-%-base of an election result consists of');
|
|
||||||
_(
|
|
||||||
'For Yes/No/Abstain per candidate and Yes/No per candidate the 100-%-base depends on the election method: If there is only one option per candidate, the sum of all votes of all candidates is 100 %. Otherwise for each candidate the sum of all votes is 100 %.'
|
|
||||||
);
|
|
||||||
_('Yes/No/Abstain per candidate');
|
|
||||||
_('Yes/No per candidate');
|
|
||||||
_('All valid ballots');
|
|
||||||
_('All casted ballots');
|
|
||||||
_('Disabled (no percents)');
|
|
||||||
_('Number of ballot papers (selection)');
|
|
||||||
_('Number of all delegates');
|
|
||||||
_('Number of all participants');
|
|
||||||
_('Use the following custom number');
|
|
||||||
_('Custom number of ballot papers');
|
|
||||||
_('Required majority');
|
|
||||||
_('Default method to check whether a candidate has reached the required majority.');
|
|
||||||
_('Simple majority');
|
|
||||||
_('Two-thirds majority');
|
|
||||||
_('Three-quarters majority');
|
|
||||||
_('Disabled');
|
|
||||||
_('Put all candidates on the list of speakers');
|
|
||||||
_('Title for PDF document (all elections)');
|
|
||||||
_('Preamble text for PDF document (all elections)');
|
|
||||||
// motion workflow
|
|
||||||
_('Recommendation label');
|
_('Recommendation label');
|
||||||
_('Allow support');
|
_('Allow support');
|
||||||
_('Allow create poll');
|
_('Allow create poll');
|
||||||
@ -275,11 +234,60 @@ _('Show amendment in parent motion');
|
|||||||
_('Restrictions');
|
_('Restrictions');
|
||||||
_('Label color');
|
_('Label color');
|
||||||
_('Next states');
|
_('Next states');
|
||||||
|
// misc for motions
|
||||||
|
_('Amendment');
|
||||||
|
_('Statute amendment for');
|
||||||
|
_('Statute paragraphs');
|
||||||
|
_('Called');
|
||||||
|
_('Called with');
|
||||||
|
_('Recommendation');
|
||||||
|
_('Motion block');
|
||||||
|
_('The text field may not be blank.');
|
||||||
|
_('The reason field may not be blank.');
|
||||||
|
|
||||||
// other translations
|
// ** Assignments **
|
||||||
|
// Assignment config strings
|
||||||
|
_('Elections');
|
||||||
|
// subgroup ballot
|
||||||
|
_('Default election method');
|
||||||
|
_('Default 100 % base of an election result');
|
||||||
|
_('All valid ballots');
|
||||||
|
_('All casted ballots');
|
||||||
|
_('Disabled (no percents)');
|
||||||
|
_('Default groups with voting rights');
|
||||||
|
_('Sort election results by amount of votes');
|
||||||
|
_('Put all candidates on the list of speakers');
|
||||||
|
// subgroup ballot papers
|
||||||
|
_('Ballot papers');
|
||||||
|
_('Number of ballot papers');
|
||||||
|
_('Number of all delegates');
|
||||||
|
_('Number of all participants');
|
||||||
|
_('Use the following custom number');
|
||||||
|
_('Custom number of ballot papers');
|
||||||
|
_('Required majority');
|
||||||
|
_('Default method to check whether a candidate has reached the required majority.');
|
||||||
|
_('Simple majority');
|
||||||
|
_('Two-thirds majority');
|
||||||
|
_('Three-quarters majority');
|
||||||
|
_('Disabled');
|
||||||
|
_('Title for PDF document (all elections)');
|
||||||
|
_('Preamble text for PDF document (all elections)');
|
||||||
|
// misc for assignments
|
||||||
_('Searching for candidates');
|
_('Searching for candidates');
|
||||||
_('Voting');
|
|
||||||
_('Finished');
|
_('Finished');
|
||||||
|
_('In the election process');
|
||||||
|
|
||||||
|
// Voting strings
|
||||||
|
_('Voting type');
|
||||||
|
_('analog');
|
||||||
|
_('nominal');
|
||||||
|
_('non-nominal');
|
||||||
|
_('Start voting');
|
||||||
|
_('Stop voting');
|
||||||
|
_('Publish');
|
||||||
|
_('Entitled to vote');
|
||||||
|
_('Voting method');
|
||||||
|
_('Amount of votes');
|
||||||
|
|
||||||
// ** Users **
|
// ** Users **
|
||||||
// permission strings (see models.py of each Django app)
|
// permission strings (see models.py of each Django app)
|
||||||
|
12
client/src/app/core/ui-services/banner.service.spec.ts
Normal file
12
client/src/app/core/ui-services/banner.service.spec.ts
Normal file
@ -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();
|
||||||
|
});
|
||||||
|
});
|
66
client/src/app/core/ui-services/banner.service.ts
Normal file
66
client/src/app/core/ui-services/banner.service.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
|
import { BehaviorSubject } from 'rxjs';
|
||||||
|
|
||||||
|
export interface BannerDefinition {
|
||||||
|
type?: string;
|
||||||
|
class?: string;
|
||||||
|
icon?: string;
|
||||||
|
text?: string;
|
||||||
|
subText?: string;
|
||||||
|
link?: string;
|
||||||
|
largerOnMobileView?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 activeBanners: BehaviorSubject<BannerDefinition[]> = new BehaviorSubject<BannerDefinition[]>([]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -534,6 +534,8 @@ export abstract class BaseFilterListService<V extends BaseViewModel> {
|
|||||||
if (item[filter.property].id === option.condition) {
|
if (item[filter.property].id === option.condition) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
} else if (typeof item[filter.property] === 'function') {
|
||||||
|
return item[filter.property]() === option.condition;
|
||||||
} else if (item[filter.property] === option.condition) {
|
} else if (item[filter.property] === option.condition) {
|
||||||
return true;
|
return true;
|
||||||
} else if (item[filter.property].toString() === option.condition) {
|
} else if (item[filter.property].toString() === option.condition) {
|
||||||
|
59
client/src/app/core/ui-services/base-poll-dialog.service.ts
Normal file
59
client/src/app/core/ui-services/base-poll-dialog.service.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstract class for showing a poll dialog. Has to be subclassed to provide the right `PollService`
|
||||||
|
*/
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export abstract class BasePollDialogService<V extends ViewBasePoll> {
|
||||||
|
protected dialogComponent: ComponentType<BasePollDialogComponent<V>>;
|
||||||
|
|
||||||
|
public constructor(private dialog: MatDialog, private mapper: CollectionStringMapperService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(viewPoll: Partial<V> & Collection): Promise<void> {
|
||||||
|
const dialogRef = this.dialog.open(this.dialogComponent, {
|
||||||
|
data: viewPoll,
|
||||||
|
...mediumDialogSettings
|
||||||
|
});
|
||||||
|
const result = await dialogRef.afterClosed().toPromise();
|
||||||
|
if (result) {
|
||||||
|
const repo = this.mapper.getRepository(viewPoll.collectionString);
|
||||||
|
if (!viewPoll.poll) {
|
||||||
|
await repo.create(result);
|
||||||
|
} else {
|
||||||
|
let update = result;
|
||||||
|
if (viewPoll.state !== PollState.Created) {
|
||||||
|
update = {
|
||||||
|
title: result.title,
|
||||||
|
onehundred_percent_base: result.onehundred_percent_base,
|
||||||
|
majority_method: result.majority_method,
|
||||||
|
description: result.description
|
||||||
|
};
|
||||||
|
if (viewPoll.type === PollType.Analog) {
|
||||||
|
update = {
|
||||||
|
...update,
|
||||||
|
votes: result.votes,
|
||||||
|
publish_immediately: result.publish_immediately
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await repo.patch(update, <V>viewPoll);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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();
|
|
||||||
}));
|
|
||||||
});
|
|
@ -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 '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
99
client/src/app/core/ui-services/voting-banner.service.ts
Normal file
99
client/src/app/core/ui-services/voting-banner.service.ts
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
|
|
||||||
|
import { _ } from 'app/core/translate/translation-marker';
|
||||||
|
import { ViewAssignmentPoll } from 'app/site/assignments/models/view-assignment-poll';
|
||||||
|
import { ViewMotionPoll } from 'app/site/motions/models/view-motion-poll';
|
||||||
|
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;
|
||||||
|
|
||||||
|
private subText = _('Click here to vote!');
|
||||||
|
|
||||||
|
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 or there are no polls to vote
|
||||||
|
const pollsToVote = polls.filter(poll => this.votingService.canVote(poll) && !poll.user_has_voted);
|
||||||
|
if ((this.OSStatus.isInHistoryMode && this.currentBanner) || !pollsToVote.length) {
|
||||||
|
this.sliceBanner();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const banner =
|
||||||
|
pollsToVote.length === 1
|
||||||
|
? this.createBanner(this.getTextForPoll(pollsToVote[0]), pollsToVote[0].parentLink)
|
||||||
|
: this.createBanner(`${pollsToVote.length} ${this.translate.instant('open votes')}`, '/polls/');
|
||||||
|
this.sliceBanner(banner);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new `BannerDefinition` and returns it.
|
||||||
|
*
|
||||||
|
* @param text The text for the banner.
|
||||||
|
* @param link The link for the banner.
|
||||||
|
*
|
||||||
|
* @returns The created banner.
|
||||||
|
*/
|
||||||
|
private createBanner(text: string, link: string): BannerDefinition {
|
||||||
|
return {
|
||||||
|
text: text,
|
||||||
|
subText: this.subText,
|
||||||
|
link: link,
|
||||||
|
icon: 'how_to_vote',
|
||||||
|
largerOnMobileView: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns for a given poll a title for the banner.
|
||||||
|
*
|
||||||
|
* @param poll The given poll.
|
||||||
|
*
|
||||||
|
* @returns The title.
|
||||||
|
*/
|
||||||
|
private getTextForPoll(poll: ViewBasePoll): string {
|
||||||
|
if (poll instanceof ViewMotionPoll) {
|
||||||
|
return `${this.translate.instant('Motion')} ${poll.motion.getIdentifierOrTitle()}: ${this.translate.instant(
|
||||||
|
'Voting opened'
|
||||||
|
)}`;
|
||||||
|
} else if (poll instanceof ViewAssignmentPoll) {
|
||||||
|
return `${poll.assignment.getTitle()}: ${this.translate.instant('Ballot opened')}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes the current banner or replaces it, if a new one is given.
|
||||||
|
*
|
||||||
|
* @param nextBanner Optional the next banner to show.
|
||||||
|
*/
|
||||||
|
private sliceBanner(nextBanner?: BannerDefinition): void {
|
||||||
|
if (nextBanner) {
|
||||||
|
this.banner.replaceBanner(this.currentBanner, nextBanner);
|
||||||
|
} else {
|
||||||
|
this.banner.removeBanner(this.currentBanner);
|
||||||
|
}
|
||||||
|
this.currentBanner = nextBanner || null;
|
||||||
|
}
|
||||||
|
}
|
18
client/src/app/core/ui-services/voting.service.spec.ts
Normal file
18
client/src/app/core/ui-services/voting.service.spec.ts
Normal file
@ -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();
|
||||||
|
});
|
||||||
|
});
|
70
client/src/app/core/ui-services/voting.service.ts
Normal file
70
client/src/app/core/ui-services/voting.service.ts
Normal file
@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TODO: It appears that the only message that makes sense for the user to see it the last one.
|
||||||
|
*/
|
||||||
|
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 {
|
||||||
|
const error = this.getVotePermissionError(poll);
|
||||||
|
return !error;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getVotePermissionErrorVerbose(poll: ViewBasePoll): string | void {
|
||||||
|
const error = this.getVotePermissionError(poll);
|
||||||
|
if (error) {
|
||||||
|
return VotingErrorVerbose[error];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -21,15 +21,16 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Parent item -->
|
<!-- Parent item -->
|
||||||
<div *ngIf="itemObserver.value.length > 0">
|
<div *ngIf="itemObserver.value.length > 0" [formGroup]="form">
|
||||||
|
<mat-form-field>
|
||||||
<os-search-value-selector
|
<os-search-value-selector
|
||||||
ngDefaultControl
|
formControlName="agenda_parent_id"
|
||||||
[formControl]="form.get('agenda_parent_id')"
|
|
||||||
[multiple]="false"
|
[multiple]="false"
|
||||||
[includeNone]="true"
|
[includeNone]="true"
|
||||||
listname="{{ 'Parent agenda item' | translate }}"
|
placeholder="{{ 'Parent agenda item' | translate }}"
|
||||||
[inputListValues]="itemObserver"
|
[inputListValues]="itemObserver"
|
||||||
></os-search-value-selector>
|
></os-search-value-selector>
|
||||||
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
@ -1,12 +1,13 @@
|
|||||||
<div class="attachment-container" *ngIf="controlName">
|
<div class="attachment-container" *ngIf="contentForm">
|
||||||
|
<mat-form-field>
|
||||||
<os-search-value-selector
|
<os-search-value-selector
|
||||||
class="selector"
|
class="selector"
|
||||||
ngDefaultControl
|
|
||||||
[multiple]="true"
|
[multiple]="true"
|
||||||
listname="{{ 'Attachments' | translate }}"
|
placeholder="{{ 'Attachments' | translate }}"
|
||||||
[formControl]="controlName"
|
[formControl]="contentForm"
|
||||||
[inputListValues]="mediaFileList"
|
[inputListValues]="mediaFileList"
|
||||||
></os-search-value-selector>
|
></os-search-value-selector>
|
||||||
|
</mat-form-field>
|
||||||
<button type="button" mat-icon-button (click)="openUploadDialog(uploadDialog)" *osPerms="'mediafiles.can_manage'">
|
<button type="button" mat-icon-button (click)="openUploadDialog(uploadDialog)" *osPerms="'mediafiles.can_manage'">
|
||||||
<mat-icon>cloud_upload</mat-icon>
|
<mat-icon>cloud_upload</mat-icon>
|
||||||
</button>
|
</button>
|
||||||
|
@ -1,44 +1,62 @@
|
|||||||
import { Component, EventEmitter, Input, OnInit, Output, TemplateRef } from '@angular/core';
|
import { FocusMonitor } from '@angular/cdk/a11y';
|
||||||
import { ControlValueAccessor, FormControl } from '@angular/forms';
|
import {
|
||||||
import { MatDialog } from '@angular/material';
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
ElementRef,
|
||||||
|
EventEmitter,
|
||||||
|
OnInit,
|
||||||
|
Optional,
|
||||||
|
Output,
|
||||||
|
Self,
|
||||||
|
TemplateRef
|
||||||
|
} from '@angular/core';
|
||||||
|
import { FormBuilder, NgControl } from '@angular/forms';
|
||||||
|
import { MatDialog, MatFormFieldControl } from '@angular/material';
|
||||||
|
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { map } from 'rxjs/operators';
|
import { map } from 'rxjs/operators';
|
||||||
|
|
||||||
import { MediafileRepositoryService } from 'app/core/repositories/mediafiles/mediafile-repository.service';
|
import { MediafileRepositoryService } from 'app/core/repositories/mediafiles/mediafile-repository.service';
|
||||||
|
import { BaseFormControlComponent } from 'app/shared/models/base/base-form-control';
|
||||||
import { mediumDialogSettings } from 'app/shared/utils/dialog-settings';
|
import { mediumDialogSettings } from 'app/shared/utils/dialog-settings';
|
||||||
import { ViewMediafile } from 'app/site/mediafiles/models/view-mediafile';
|
import { ViewMediafile } from 'app/site/mediafiles/models/view-mediafile';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'os-attachment-control',
|
selector: 'os-attachment-control',
|
||||||
templateUrl: './attachment-control.component.html',
|
templateUrl: './attachment-control.component.html',
|
||||||
styleUrls: ['./attachment-control.component.scss']
|
styleUrls: ['./attachment-control.component.scss'],
|
||||||
|
providers: [{ provide: MatFormFieldControl, useExisting: AttachmentControlComponent }],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
})
|
})
|
||||||
export class AttachmentControlComponent implements OnInit, ControlValueAccessor {
|
export class AttachmentControlComponent extends BaseFormControlComponent<ViewMediafile[]> implements OnInit {
|
||||||
/**
|
/**
|
||||||
* Output for an error handler
|
* Output for an error handler
|
||||||
*/
|
*/
|
||||||
@Output()
|
@Output()
|
||||||
public errorHandler: EventEmitter<string> = new EventEmitter();
|
public errorHandler: EventEmitter<string> = new EventEmitter();
|
||||||
|
|
||||||
/**
|
|
||||||
* The form-control name to access the value for the form-control
|
|
||||||
*/
|
|
||||||
@Input()
|
|
||||||
public controlName: FormControl;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The file list that is necessary for the `SearchValueSelector`
|
* The file list that is necessary for the `SearchValueSelector`
|
||||||
*/
|
*/
|
||||||
public mediaFileList: Observable<ViewMediafile[]>;
|
public mediaFileList: Observable<ViewMediafile[]>;
|
||||||
|
|
||||||
/**
|
public get empty(): boolean {
|
||||||
* Default constructor
|
return !this.contentForm.value.length;
|
||||||
*
|
}
|
||||||
* @param dialogService Reference to the `MatDialog`
|
public get controlType(): string {
|
||||||
* @param mediaService Reference for the `MediaFileRepositoryService`
|
return 'attachment-control';
|
||||||
*/
|
}
|
||||||
public constructor(private dialogService: MatDialog, private mediaService: MediafileRepositoryService) {}
|
|
||||||
|
public constructor(
|
||||||
|
formBuilder: FormBuilder,
|
||||||
|
focusMonitor: FocusMonitor,
|
||||||
|
element: ElementRef<HTMLElement>,
|
||||||
|
@Optional() @Self() public ngControl: NgControl,
|
||||||
|
private dialogService: MatDialog,
|
||||||
|
private mediaService: MediafileRepositoryService
|
||||||
|
) {
|
||||||
|
super(formBuilder, focusMonitor, element, ngControl);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* On init method
|
* On init method
|
||||||
@ -64,12 +82,10 @@ export class AttachmentControlComponent implements OnInit, ControlValueAccessor
|
|||||||
* @param fileIDs a list with the ids of the uploaded files
|
* @param fileIDs a list with the ids of the uploaded files
|
||||||
*/
|
*/
|
||||||
public uploadSuccess(fileIDs: number[]): void {
|
public uploadSuccess(fileIDs: number[]): void {
|
||||||
if (this.controlName) {
|
const newValues = [...this.contentForm.value, ...fileIDs];
|
||||||
const newValues = [...this.controlName.value, ...fileIDs];
|
this.updateForm(newValues);
|
||||||
this.controlName.setValue(newValues);
|
|
||||||
this.dialogService.closeAll();
|
this.dialogService.closeAll();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Function to emit an occurring error.
|
* Function to emit an occurring error.
|
||||||
@ -81,28 +97,15 @@ export class AttachmentControlComponent implements OnInit, ControlValueAccessor
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Function to write a new value to the form.
|
* Declared as abstract in MatFormFieldControl and not required for this component
|
||||||
* Satisfy the interface.
|
|
||||||
*
|
|
||||||
* @param value The new value for this form.
|
|
||||||
*/
|
*/
|
||||||
public writeValue(value: any): void {
|
public onContainerClick(event: MouseEvent): void {}
|
||||||
if (value && this.controlName) {
|
|
||||||
this.controlName.setValue(value);
|
protected initializeForm(): void {
|
||||||
}
|
this.contentForm = this.fb.control([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
protected updateForm(value: ViewMediafile[] | null): void {
|
||||||
* Function executed when the control's value changed.
|
this.contentForm.setValue(value || []);
|
||||||
*
|
}
|
||||||
* @param fn the function that is executed.
|
|
||||||
*/
|
|
||||||
public registerOnChange(fn: any): void {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* To satisfy the interface
|
|
||||||
*
|
|
||||||
* @param fn the registered callback function for onBlur-events.
|
|
||||||
*/
|
|
||||||
public registerOnTouched(fn: any): void {}
|
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,25 @@
|
|||||||
|
<div
|
||||||
|
*ngFor="let banner of banners"
|
||||||
|
class="banner"
|
||||||
|
[ngClass]="[
|
||||||
|
banner.type === 'history' ? 'history-mode-indicator' : '',
|
||||||
|
banner.class ? banner.class : '',
|
||||||
|
banner.largerOnMobileView ? 'larger-on-mobile' : ''
|
||||||
|
]"
|
||||||
|
[ngSwitch]="banner.type"
|
||||||
|
>
|
||||||
|
<ng-container *ngSwitchCase="'history'">
|
||||||
|
<span translate>You are using the history mode of OpenSlides. Changes will not be saved.</span>
|
||||||
|
<span>({{ getHistoryTimestamp() }})</span>
|
||||||
|
<a (click)="timeTravel.resumeTime()" translate>Exit</a>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container *ngSwitchDefault>
|
||||||
|
<a class="banner-link" [routerLink]="banner.link" [style.cursor]="banner.link ? 'pointer' : 'default'">
|
||||||
|
<mat-icon>{{ banner.icon }}</mat-icon>
|
||||||
|
<span>{{ banner.text }}</span>
|
||||||
|
<div *ngIf="banner.subText">
|
||||||
|
{{ banner.subText | translate }}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
@ -0,0 +1,57 @@
|
|||||||
|
@import '../../../../assets/styles/media-queries.scss';
|
||||||
|
|
||||||
|
.banner {
|
||||||
|
&.larger-on-mobile {
|
||||||
|
@include set-breakpoint-lower(sm) {
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
position: relative; // was fixed before to prevent the overflow
|
||||||
|
min-height: 20px;
|
||||||
|
line-height: 20px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-bottom: 1px solid white;
|
||||||
|
|
||||||
|
a {
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-decoration: none;
|
||||||
|
color: white;
|
||||||
|
&.banner-link {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
$font-size: 16px;
|
||||||
|
width: $font-size;
|
||||||
|
height: $font-size;
|
||||||
|
font-size: $font-size;
|
||||||
|
|
||||||
|
& + span {
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-mode-indicator {
|
||||||
|
background: repeating-linear-gradient(45deg, #ffee00, #ffee00 10px, #070600 10px, #000000 20px);
|
||||||
|
|
||||||
|
span,
|
||||||
|
a {
|
||||||
|
padding: 2px;
|
||||||
|
color: #000000;
|
||||||
|
background: #ffee00;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,11 @@
|
|||||||
|
@import '~@angular/material/theming';
|
||||||
|
|
||||||
|
/** Custom component theme. Only lives in a specific scope */
|
||||||
|
@mixin os-banner-style($theme) {
|
||||||
|
$accent: map-get($theme, accent);
|
||||||
|
|
||||||
|
/** style for the offline-banner */
|
||||||
|
.banner {
|
||||||
|
background: mat-color($accent, 500);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,26 @@
|
|||||||
|
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { E2EImportsModule } from 'e2e-imports.module';
|
||||||
|
|
||||||
|
import { BannerComponent } from './banner.component';
|
||||||
|
|
||||||
|
describe('BannerComponent', () => {
|
||||||
|
let component: BannerComponent;
|
||||||
|
let fixture: ComponentFixture<BannerComponent>;
|
||||||
|
|
||||||
|
beforeEach(async(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [E2EImportsModule]
|
||||||
|
}).compileComponents();
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(BannerComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
40
client/src/app/shared/components/banner/banner.component.ts
Normal file
40
client/src/app/shared/components/banner/banner.component.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
|
||||||
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
|
|
||||||
|
import { OpenSlidesStatusService } from 'app/core/core-services/openslides-status.service';
|
||||||
|
import { TimeTravelService } from 'app/core/core-services/time-travel.service';
|
||||||
|
import { BannerDefinition, BannerService } from 'app/core/ui-services/banner.service';
|
||||||
|
import { langToLocale } from 'app/shared/utils/lang-to-locale';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'os-banner',
|
||||||
|
templateUrl: './banner.component.html',
|
||||||
|
styleUrls: ['./banner.component.scss']
|
||||||
|
})
|
||||||
|
export class BannerComponent implements OnInit {
|
||||||
|
public banners: BannerDefinition[] = [];
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private OSStatus: OpenSlidesStatusService,
|
||||||
|
protected translate: TranslateService,
|
||||||
|
public timeTravel: TimeTravelService,
|
||||||
|
private banner: BannerService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public ngOnInit(): void {
|
||||||
|
this.banner.activeBanners.subscribe(banners => {
|
||||||
|
this.banners = banners;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the timestamp for the current point in history mode.
|
||||||
|
* Tries to detect the ideal timestamp format using the translation service
|
||||||
|
*
|
||||||
|
* @returns the timestamp as string
|
||||||
|
*/
|
||||||
|
public getHistoryTimestamp(): string {
|
||||||
|
return this.OSStatus.getHistoryTimeStamp(langToLocale(this.translate.currentLang));
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,26 @@
|
|||||||
|
<div class="charts-wrapper" [ngClass]="[classes, hasPadding ? 'has-padding' : '']">
|
||||||
|
<ng-container *ngIf="chartData.length || circleData.length">
|
||||||
|
<canvas
|
||||||
|
*ngIf="type === 'bar' || type === 'stackedBar' || type === 'horizontalBar' || type === 'line'"
|
||||||
|
baseChart
|
||||||
|
[datasets]="chartData"
|
||||||
|
[labels]="labels"
|
||||||
|
[legend]="showLegend"
|
||||||
|
[options]="chartOptions"
|
||||||
|
[chartType]="type"
|
||||||
|
(chartClick)="select.emit($event)"
|
||||||
|
(chartHover)="hover.emit($event)"
|
||||||
|
>
|
||||||
|
</canvas>
|
||||||
|
<canvas
|
||||||
|
*ngIf="type === 'pie' || type === 'doughnut'"
|
||||||
|
baseChart
|
||||||
|
[options]="pieChartOptions"
|
||||||
|
[data]="circleData"
|
||||||
|
[labels]="circleLabels"
|
||||||
|
[colors]="circleColors"
|
||||||
|
[chartType]="type"
|
||||||
|
[legend]="showLegend"
|
||||||
|
></canvas>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
@ -0,0 +1,15 @@
|
|||||||
|
.charts-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
margin: auto;
|
||||||
|
|
||||||
|
&.has-padding {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@for $i from 1 through 100 {
|
||||||
|
.os-charts--#{$i} {
|
||||||
|
width: unquote($string: $i + '%');
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,24 @@
|
|||||||
|
import { async, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { E2EImportsModule } from 'e2e-imports.module';
|
||||||
|
|
||||||
|
describe('ChartsComponent', () => {
|
||||||
|
// let component: ChartsComponent;
|
||||||
|
// let fixture: ComponentFixture<ChartsComponent>;
|
||||||
|
|
||||||
|
beforeEach(async(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [E2EImportsModule]
|
||||||
|
}).compileComponents();
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// fixture = TestBed.createComponent(ChartsComponent);
|
||||||
|
// component = fixture.componentInstance;
|
||||||
|
// fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
// expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
300
client/src/app/shared/components/charts/charts.component.ts
Normal file
300
client/src/app/shared/components/charts/charts.component.ts
Normal file
@ -0,0 +1,300 @@
|
|||||||
|
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, Output } from '@angular/core';
|
||||||
|
import { MatSnackBar } from '@angular/material';
|
||||||
|
import { Title } from '@angular/platform-browser';
|
||||||
|
|
||||||
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
|
import { ChartOptions } from 'chart.js';
|
||||||
|
import { Label } from 'ng2-charts';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
import { BaseViewComponent } from 'app/site/base/base-view';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The different supported chart-types.
|
||||||
|
*/
|
||||||
|
export type ChartType = 'line' | 'bar' | 'pie' | 'doughnut' | 'horizontalBar' | 'stackedBar';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the events the chart is fired, when hovering or clicking on it.
|
||||||
|
*/
|
||||||
|
interface ChartEvent {
|
||||||
|
event: MouseEvent;
|
||||||
|
active: {}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One single collection in an array.
|
||||||
|
*/
|
||||||
|
export interface ChartDate {
|
||||||
|
data: number[];
|
||||||
|
label: string;
|
||||||
|
backgroundColor?: string;
|
||||||
|
hoverBackgroundColor?: string;
|
||||||
|
barThickness?: number;
|
||||||
|
maxBarThickness?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An alias for an array of `ChartDate`.
|
||||||
|
*/
|
||||||
|
export type ChartData = ChartDate[];
|
||||||
|
|
||||||
|
export type ChartLegendSize = 'small' | 'middle';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper for the chart-library.
|
||||||
|
*
|
||||||
|
* It takes the passed data to fit the different types of the library.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'os-charts',
|
||||||
|
templateUrl: './charts.component.html',
|
||||||
|
styleUrls: ['./charts.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class ChartsComponent extends BaseViewComponent {
|
||||||
|
/**
|
||||||
|
* Sets the data as an observable.
|
||||||
|
*
|
||||||
|
* The data is prepared and splitted to dynamic use of bar/line or doughnut/pie chart.
|
||||||
|
*/
|
||||||
|
@Input()
|
||||||
|
public set data(dataObservable: Observable<ChartData>) {
|
||||||
|
this.subscriptions.push(
|
||||||
|
dataObservable.subscribe(data => {
|
||||||
|
if (!data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
data = data.flatMap((date: ChartDate) => ({ ...date, data: date.data.filter(value => value >= 0) }));
|
||||||
|
this.chartData = data;
|
||||||
|
this.circleData = data.flatMap((date: ChartDate) => date.data);
|
||||||
|
this.circleLabels = data.map(date => date.label);
|
||||||
|
const circleColors = [
|
||||||
|
{
|
||||||
|
backgroundColor: data.map(date => date.backgroundColor).filter(color => !!color),
|
||||||
|
hoverBackgroundColor: data.map(date => date.hoverBackgroundColor).filter(color => !!color)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
this.circleColors = !!circleColors[0].backgroundColor.length ? circleColors : null;
|
||||||
|
this.checkAndUpdateChartType();
|
||||||
|
this.cd.detectChanges();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The type of the chart. Defaults to `'bar'`.
|
||||||
|
*/
|
||||||
|
@Input()
|
||||||
|
public set type(type: ChartType) {
|
||||||
|
this._type = type;
|
||||||
|
this.checkAndUpdateChartType();
|
||||||
|
this.cd.detectChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
public get type(): ChartType {
|
||||||
|
return this._type;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
public set chartLegendSize(size: ChartLegendSize) {
|
||||||
|
this._chartLegendSize = size;
|
||||||
|
this.setupChartLegendSize();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to show the legend.
|
||||||
|
*/
|
||||||
|
@Input()
|
||||||
|
public showLegend = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The labels for the separated sections.
|
||||||
|
* Each label represent one section, e.g. one year.
|
||||||
|
*/
|
||||||
|
@Input()
|
||||||
|
public labels: Label[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the position of the legend.
|
||||||
|
* Defaults to `'top'`.
|
||||||
|
*/
|
||||||
|
@Input()
|
||||||
|
public set legendPosition(position: Chart.PositionType) {
|
||||||
|
this.chartOptions.legend.position = position;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine, if the chart has some padding at the borders.
|
||||||
|
*/
|
||||||
|
@Input()
|
||||||
|
public hasPadding = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional passing a number as percentage value for `max-width`.
|
||||||
|
* Range from 1 to 100.
|
||||||
|
* Defaults to `100`.
|
||||||
|
*/
|
||||||
|
@Input()
|
||||||
|
public set size(size: number) {
|
||||||
|
if (size > 100) {
|
||||||
|
size = 100;
|
||||||
|
}
|
||||||
|
if (size < 1) {
|
||||||
|
size = 1;
|
||||||
|
}
|
||||||
|
this._size = size;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get size(): number {
|
||||||
|
return this._size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fires an event, when the user clicks on the chart.
|
||||||
|
*/
|
||||||
|
@Output()
|
||||||
|
public select = new EventEmitter<ChartEvent>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fires an event, when the user hovers over the chart.
|
||||||
|
*/
|
||||||
|
@Output()
|
||||||
|
public hover = new EventEmitter<ChartEvent>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a string to append to the `chart-wrapper's` classes.
|
||||||
|
*/
|
||||||
|
public get classes(): string {
|
||||||
|
return 'os-charts os-charts--' + this.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The general data for the chart.
|
||||||
|
* This is only needed for `type == 'bar' || 'line'`
|
||||||
|
*/
|
||||||
|
public chartData: ChartData = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The data for circle-like charts, like 'doughnut' or 'pie'.
|
||||||
|
*/
|
||||||
|
public circleData: number[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The labels for circle-like charts, like 'doughnut' or 'pie'.
|
||||||
|
*/
|
||||||
|
public circleLabels: Label[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The colors for circle-like charts, like 'doughnut' or 'pie'.
|
||||||
|
*/
|
||||||
|
public circleColors: { backgroundColor?: string[]; hoverBackgroundColor?: string[] }[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The options used for the charts.
|
||||||
|
*/
|
||||||
|
public chartOptions: ChartOptions = {
|
||||||
|
responsive: true,
|
||||||
|
legend: {
|
||||||
|
position: 'top',
|
||||||
|
labels: {}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
xAxes: [
|
||||||
|
{
|
||||||
|
gridLines: {
|
||||||
|
drawOnChartArea: false
|
||||||
|
},
|
||||||
|
ticks: { beginAtZero: true, stepSize: 1 },
|
||||||
|
stacked: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
yAxes: [
|
||||||
|
{
|
||||||
|
gridLines: {
|
||||||
|
drawBorder: false,
|
||||||
|
drawOnChartArea: false,
|
||||||
|
drawTicks: false
|
||||||
|
},
|
||||||
|
ticks: { mirror: true, labelOffset: -20 },
|
||||||
|
stacked: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chart option for pie and doughnut
|
||||||
|
*/
|
||||||
|
@Input()
|
||||||
|
public pieChartOptions: ChartOptions = {
|
||||||
|
responsive: true,
|
||||||
|
legend: {
|
||||||
|
position: 'left'
|
||||||
|
},
|
||||||
|
aspectRatio: 1
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holds the type of the chart - defaults to `bar`.
|
||||||
|
*/
|
||||||
|
private _type: ChartType = 'bar';
|
||||||
|
|
||||||
|
private _chartLegendSize: ChartLegendSize = 'middle';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holds the value for `max-width`.
|
||||||
|
*/
|
||||||
|
private _size = 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor.
|
||||||
|
*
|
||||||
|
* @param title
|
||||||
|
* @param translate
|
||||||
|
* @param matSnackbar
|
||||||
|
* @param cd
|
||||||
|
*/
|
||||||
|
public constructor(
|
||||||
|
title: Title,
|
||||||
|
protected translate: TranslateService,
|
||||||
|
matSnackbar: MatSnackBar,
|
||||||
|
private cd: ChangeDetectorRef
|
||||||
|
) {
|
||||||
|
super(title, translate, matSnackbar);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupBar(): void {
|
||||||
|
if (!this.chartData.every(date => date.barThickness && date.maxBarThickness)) {
|
||||||
|
this.chartData = this.chartData.map(chartDate => ({
|
||||||
|
...chartDate,
|
||||||
|
barThickness: 20,
|
||||||
|
maxBarThickness: 48
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupChartLegendSize(): void {
|
||||||
|
switch (this._chartLegendSize) {
|
||||||
|
case 'small':
|
||||||
|
this.chartOptions.legend.labels = Object.assign(this.chartOptions.legend.labels, {
|
||||||
|
fontSize: 10,
|
||||||
|
boxWidth: 20
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'middle':
|
||||||
|
this.chartOptions.legend.labels = {
|
||||||
|
fontSize: 14,
|
||||||
|
boxWidth: 40
|
||||||
|
};
|
||||||
|
}
|
||||||
|
this.cd.detectChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkAndUpdateChartType(): void {
|
||||||
|
if (this._type === 'stackedBar') {
|
||||||
|
this.setupBar();
|
||||||
|
this._type = 'horizontalBar';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,22 @@
|
|||||||
|
<div class="check-input--container">
|
||||||
|
<mat-form-field>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
class="check-input--input"
|
||||||
|
[type]="inputType"
|
||||||
|
[formControl]="contentForm"
|
||||||
|
[placeholder]="placeholder"
|
||||||
|
[min]="0"
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-checkbox
|
||||||
|
*ngIf="checkboxLabel"
|
||||||
|
[name]="'checkbox'"
|
||||||
|
[ngModel]="isChecked"
|
||||||
|
(change)="checkboxStateChanged($event.checked)"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
{{ checkboxLabel }}
|
||||||
|
</mat-checkbox>
|
||||||
|
<div *ngIf="!checkboxLabel" class="placeholder"></div>
|
||||||
|
</div>
|
@ -0,0 +1,10 @@
|
|||||||
|
.check-input--container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
& > * {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0 5px;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,26 @@
|
|||||||
|
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { E2EImportsModule } from 'e2e-imports.module';
|
||||||
|
|
||||||
|
import { CheckInputComponent } from './check-input.component';
|
||||||
|
|
||||||
|
describe('CheckInputComponent', () => {
|
||||||
|
let component: CheckInputComponent;
|
||||||
|
let fixture: ComponentFixture<CheckInputComponent>;
|
||||||
|
|
||||||
|
beforeEach(async(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [E2EImportsModule]
|
||||||
|
}).compileComponents();
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(CheckInputComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,147 @@
|
|||||||
|
import { Component, forwardRef, Input, OnInit } from '@angular/core';
|
||||||
|
import { ControlValueAccessor, FormBuilder, FormControl, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||||
|
import { MatSnackBar } from '@angular/material';
|
||||||
|
import { Title } from '@angular/platform-browser';
|
||||||
|
|
||||||
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
|
|
||||||
|
import { BaseViewComponent } from 'app/site/base/base-view';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'os-check-input',
|
||||||
|
templateUrl: './check-input.component.html',
|
||||||
|
styleUrls: ['./check-input.component.scss'],
|
||||||
|
providers: [{ provide: NG_VALUE_ACCESSOR, multi: true, useExisting: forwardRef(() => CheckInputComponent) }]
|
||||||
|
})
|
||||||
|
export class CheckInputComponent extends BaseViewComponent implements OnInit, ControlValueAccessor {
|
||||||
|
/**
|
||||||
|
* Type of the used input.
|
||||||
|
*/
|
||||||
|
@Input()
|
||||||
|
public inputType = 'text';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The placeholder for the form-field.
|
||||||
|
*/
|
||||||
|
@Input()
|
||||||
|
public placeholder: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The value received, if the checkbox is checked.
|
||||||
|
*/
|
||||||
|
@Input()
|
||||||
|
public checkboxValue: number | string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Label for the checkbox.
|
||||||
|
*/
|
||||||
|
@Input()
|
||||||
|
public checkboxLabel: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Model for the state of the checkbox.
|
||||||
|
*/
|
||||||
|
public isChecked = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The form-control-reference.
|
||||||
|
*/
|
||||||
|
public contentForm: FormControl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default constructor.
|
||||||
|
*/
|
||||||
|
public constructor(
|
||||||
|
title: Title,
|
||||||
|
protected translate: TranslateService,
|
||||||
|
matSnackbar: MatSnackBar,
|
||||||
|
private fb: FormBuilder
|
||||||
|
) {
|
||||||
|
super(title, translate, matSnackbar);
|
||||||
|
this.initForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OnInit.
|
||||||
|
* Subscribes to value-changes of the form-control.
|
||||||
|
*/
|
||||||
|
public ngOnInit(): void {
|
||||||
|
this.subscriptions.push(this.contentForm.valueChanges.subscribe(value => this.sendValue(value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function to handle checkbox-state-changed-event.
|
||||||
|
*/
|
||||||
|
public checkboxStateChanged(checked: boolean): void {
|
||||||
|
this.isChecked = checked;
|
||||||
|
if (checked) {
|
||||||
|
this.contentForm.disable({ emitEvent: false });
|
||||||
|
} else {
|
||||||
|
this.contentForm.enable({ emitEvent: false });
|
||||||
|
}
|
||||||
|
this.sendValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The value from the FormControl
|
||||||
|
*
|
||||||
|
* @param obj the value from the parent form. Type "any" is required by the interface
|
||||||
|
*/
|
||||||
|
public writeValue(obj: string | number): void {
|
||||||
|
if (obj || typeof obj === 'number') {
|
||||||
|
if (obj === this.checkboxValue) {
|
||||||
|
this.checkboxStateChanged(true);
|
||||||
|
} else {
|
||||||
|
this.contentForm.patchValue(obj);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hands changes back to the parent form
|
||||||
|
*
|
||||||
|
* @param fn the function to propagate the changes
|
||||||
|
*/
|
||||||
|
public registerOnChange(fn: any): void {
|
||||||
|
this.propagateChange = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* To satisfy the interface.
|
||||||
|
*
|
||||||
|
* @param fn
|
||||||
|
*/
|
||||||
|
public registerOnTouched(fn: any): void {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* To satisfy the interface
|
||||||
|
*
|
||||||
|
* @param isDisabled
|
||||||
|
*/
|
||||||
|
public setDisabledState?(isDisabled: boolean): void {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to determine which information to give to the parent form
|
||||||
|
*/
|
||||||
|
private propagateChange = (_: any) => {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initially build the form-control.
|
||||||
|
*/
|
||||||
|
private initForm(): void {
|
||||||
|
this.contentForm = this.fb.control('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends the given value by the propagateChange-funtion.
|
||||||
|
*
|
||||||
|
* @param value Optional parameter to pass a value to send.
|
||||||
|
*/
|
||||||
|
private sendValue(value?: string | number): void {
|
||||||
|
if (this.isChecked) {
|
||||||
|
this.propagateChange(this.checkboxValue);
|
||||||
|
} else {
|
||||||
|
this.propagateChange(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -38,14 +38,13 @@
|
|||||||
(keydown)="keyDownFunction($event)"
|
(keydown)="keyDownFunction($event)"
|
||||||
/>
|
/>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
<mat-form-field *ngIf="searchList">
|
||||||
<os-search-value-selector
|
<os-search-value-selector
|
||||||
*ngIf="searchList"
|
formControlName="list"
|
||||||
ngDefaultControl
|
|
||||||
[formControl]="extensionFieldForm.get('list')"
|
|
||||||
[fullWidth]="true"
|
|
||||||
[inputListValues]="searchList"
|
[inputListValues]="searchList"
|
||||||
[listname]="searchListLabel"
|
[placeholder]="searchListLabel"
|
||||||
></os-search-value-selector>
|
></os-search-value-selector>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
<button mat-button (click)="changeEditMode(true)">{{ 'Save' | translate }}</button>
|
<button mat-button (click)="changeEditMode(true)">{{ 'Save' | translate }}</button>
|
||||||
<button mat-button (click)="changeEditMode()">{{ 'Cancel' | translate }}</button>
|
<button mat-button (click)="changeEditMode()">{{ 'Cancel' | translate }}</button>
|
||||||
|
@ -14,8 +14,9 @@
|
|||||||
<!-- vScrollAuto () -->
|
<!-- vScrollAuto () -->
|
||||||
<pbl-ngrid
|
<pbl-ngrid
|
||||||
[ngClass]="cssClasses"
|
[ngClass]="cssClasses"
|
||||||
[vScrollFixed]="vScrollFixed"
|
[attr.vScrollFixed]="vScrollFixed !== -1 ? vScrollFixed : false"
|
||||||
[showHeader]="!showFilterBar"
|
[attr.vScrollAuto]="vScrollFixed === -1"
|
||||||
|
[showHeader]="!showFilterBar || !fullScreen"
|
||||||
matCheckboxSelection="selection"
|
matCheckboxSelection="selection"
|
||||||
[dataSource]="dataSource"
|
[dataSource]="dataSource"
|
||||||
[columns]="columnSet"
|
[columns]="columnSet"
|
||||||
|
@ -20,7 +20,7 @@ import { distinctUntilChanged, filter } from 'rxjs/operators';
|
|||||||
|
|
||||||
import { OperatorService, Permission } from 'app/core/core-services/operator.service';
|
import { OperatorService, Permission } from 'app/core/core-services/operator.service';
|
||||||
import { StorageService } from 'app/core/core-services/storage.service';
|
import { StorageService } from 'app/core/core-services/storage.service';
|
||||||
import { BaseRepository } from 'app/core/repositories/base-repository';
|
import { HasViewModelListObservable } from 'app/core/definitions/has-view-model-list-observable';
|
||||||
import { BaseFilterListService } from 'app/core/ui-services/base-filter-list.service';
|
import { BaseFilterListService } from 'app/core/ui-services/base-filter-list.service';
|
||||||
import { BaseSortListService } from 'app/core/ui-services/base-sort-list.service';
|
import { BaseSortListService } from 'app/core/ui-services/base-sort-list.service';
|
||||||
import { ViewportService } from 'app/core/ui-services/viewport.service';
|
import { ViewportService } from 'app/core/ui-services/viewport.service';
|
||||||
@ -47,7 +47,7 @@ export interface ColumnRestriction {
|
|||||||
* Creates a sort-filter-bar and table with virtual scrolling, where projector and multi select is already
|
* Creates a sort-filter-bar and table with virtual scrolling, where projector and multi select is already
|
||||||
* embedded
|
* embedded
|
||||||
*
|
*
|
||||||
* Takes a repository-service, a sort-service and a filter-service as an input to display data
|
* Takes a repository-service (or simple Observable), a sort-service and a filter-service as an input to display data
|
||||||
* Requires multi-select information
|
* Requires multi-select information
|
||||||
* Double binds selected rows
|
* Double binds selected rows
|
||||||
*
|
*
|
||||||
@ -63,8 +63,9 @@ export interface ColumnRestriction {
|
|||||||
* @example
|
* @example
|
||||||
* ```html
|
* ```html
|
||||||
* <os-list-view-table
|
* <os-list-view-table
|
||||||
* [repo]="motionRepo"
|
* [listObservableProvider]="motionRepo"
|
||||||
* [filterService]="filterService"
|
* [filterService]="filterService"
|
||||||
|
* [filterProps]="filterProps"
|
||||||
* [sortService]="sortService"
|
* [sortService]="sortService"
|
||||||
* [columns]="motionColumnDefinition"
|
* [columns]="motionColumnDefinition"
|
||||||
* [restricted]="restrictedColumns"
|
* [restricted]="restrictedColumns"
|
||||||
@ -96,10 +97,16 @@ export class ListViewTableComponent<V extends BaseViewModel, M extends BaseModel
|
|||||||
private ngrid: PblNgridComponent;
|
private ngrid: PblNgridComponent;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The required repository
|
* The required repository (prioritized over listObservable)
|
||||||
*/
|
*/
|
||||||
@Input()
|
@Input()
|
||||||
public repo: BaseRepository<V, M, any>;
|
public listObservableProvider: HasViewModelListObservable<V>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ...or the required observable
|
||||||
|
*/
|
||||||
|
@Input()
|
||||||
|
public listObservable: Observable<V[]>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The currently active sorting service for the list view
|
* The currently active sorting service for the list view
|
||||||
@ -187,12 +194,25 @@ export class ListViewTableComponent<V extends BaseViewModel, M extends BaseModel
|
|||||||
@Input()
|
@Input()
|
||||||
public showListOfSpeakers = true;
|
public showListOfSpeakers = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* To optionally hide the menu slot
|
||||||
|
*/
|
||||||
|
@Input()
|
||||||
|
public showMenu = true;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fix value for the height of the rows in the virtual-scroll-list.
|
* Fix value for the height of the rows in the virtual-scroll-list.
|
||||||
*/
|
*/
|
||||||
@Input()
|
@Input()
|
||||||
public vScrollFixed = 110;
|
public vScrollFixed = 110;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines whether the table should have a fixed 100vh height or not.
|
||||||
|
* If not, the height must be set by the component
|
||||||
|
*/
|
||||||
|
@Input()
|
||||||
|
public fullScreen = true;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Option to apply additional classes to the virtual-scrolling-list.
|
* Option to apply additional classes to the virtual-scrolling-list.
|
||||||
*/
|
*/
|
||||||
@ -211,8 +231,8 @@ export class ListViewTableComponent<V extends BaseViewModel, M extends BaseModel
|
|||||||
*/
|
*/
|
||||||
public get cssClasses(): CssClassDefinition {
|
public get cssClasses(): CssClassDefinition {
|
||||||
const defaultClasses = {
|
const defaultClasses = {
|
||||||
'virtual-scroll-with-head-bar ngrid-hide-head': this.showFilterBar,
|
'virtual-scroll-with-head-bar ngrid-hide-head': this.fullScreen && this.showFilterBar,
|
||||||
'virtual-scroll-full-page': !this.showFilterBar,
|
'virtual-scroll-full-page': this.fullScreen && !this.showFilterBar,
|
||||||
multiselect: this.multiSelect
|
multiselect: this.multiSelect
|
||||||
};
|
};
|
||||||
return Object.assign(this._cssClasses, defaultClasses);
|
return Object.assign(this._cssClasses, defaultClasses);
|
||||||
@ -322,15 +342,6 @@ export class ListViewTableComponent<V extends BaseViewModel, M extends BaseModel
|
|||||||
return this.dataSource.length;
|
return this.dataSource.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @returns the repositories `viewModelListObservable`
|
|
||||||
*/
|
|
||||||
private get viewModelListObservable(): Observable<V[]> {
|
|
||||||
if (this.repo) {
|
|
||||||
return this.repo.getViewModelListObservable();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Define which columns to hide. Uses the input-property
|
* Define which columns to hide. Uses the input-property
|
||||||
* "hide" to hide individual columns
|
* "hide" to hide individual columns
|
||||||
@ -342,7 +353,7 @@ export class ListViewTableComponent<V extends BaseViewModel, M extends BaseModel
|
|||||||
hidden.push('selection');
|
hidden.push('selection');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.alwaysShowMenu && !this.isMobile) {
|
if ((!this.alwaysShowMenu && !this.isMobile) || !this.showMenu) {
|
||||||
hidden.push('menu');
|
hidden.push('menu');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -477,23 +488,26 @@ export class ListViewTableComponent<V extends BaseViewModel, M extends BaseModel
|
|||||||
* to the used search and filter services
|
* to the used search and filter services
|
||||||
*/
|
*/
|
||||||
private getListObservable(): void {
|
private getListObservable(): void {
|
||||||
if (this.repo && this.viewModelListObservable) {
|
if (this.listObservableProvider || this.listObservable) {
|
||||||
|
const listObservable = this.listObservableProvider
|
||||||
|
? this.listObservableProvider.getViewModelListObservable()
|
||||||
|
: this.listObservable;
|
||||||
if (this.filterService && this.sortService) {
|
if (this.filterService && this.sortService) {
|
||||||
// filtering and sorting
|
// filtering and sorting
|
||||||
this.filterService.initFilters(this.viewModelListObservable);
|
this.filterService.initFilters(listObservable);
|
||||||
this.sortService.initSorting(this.filterService.outputObservable);
|
this.sortService.initSorting(this.filterService.outputObservable);
|
||||||
this.dataListObservable = this.sortService.outputObservable;
|
this.dataListObservable = this.sortService.outputObservable;
|
||||||
} else if (this.filterService) {
|
} else if (this.filterService) {
|
||||||
// only filter service
|
// only filter service
|
||||||
this.filterService.initFilters(this.viewModelListObservable);
|
this.filterService.initFilters(listObservable);
|
||||||
this.dataListObservable = this.filterService.outputObservable;
|
this.dataListObservable = this.filterService.outputObservable;
|
||||||
} else if (this.sortService) {
|
} else if (this.sortService) {
|
||||||
// only sorting
|
// only sorting
|
||||||
this.sortService.initSorting(this.viewModelListObservable);
|
this.sortService.initSorting(listObservable);
|
||||||
this.dataListObservable = this.sortService.outputObservable;
|
this.dataListObservable = this.sortService.outputObservable;
|
||||||
} else {
|
} else {
|
||||||
// none of both
|
// none of both
|
||||||
this.dataListObservable = this.viewModelListObservable;
|
this.dataListObservable = listObservable;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -529,15 +543,24 @@ export class ListViewTableComponent<V extends BaseViewModel, M extends BaseModel
|
|||||||
// custom filter predicates
|
// custom filter predicates
|
||||||
if (this.filterProps && this.filterProps.length) {
|
if (this.filterProps && this.filterProps.length) {
|
||||||
for (const prop of this.filterProps) {
|
for (const prop of this.filterProps) {
|
||||||
if (item[prop]) {
|
// find nested props
|
||||||
|
const split = prop.split('.');
|
||||||
|
let currValue: any = item;
|
||||||
|
for (const subProp of split) {
|
||||||
|
if (currValue) {
|
||||||
|
currValue = currValue[subProp];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currValue) {
|
||||||
let propertyAsString = '';
|
let propertyAsString = '';
|
||||||
// If the property is a function, call it.
|
// If the property is a function, call it.
|
||||||
if (typeof item[prop] === 'function') {
|
if (typeof currValue === 'function') {
|
||||||
propertyAsString = '' + item[prop]();
|
propertyAsString = '' + currValue();
|
||||||
} else if (item[prop].constructor === Array) {
|
} else if (currValue.constructor === Array) {
|
||||||
propertyAsString = item[prop].join('');
|
propertyAsString = currValue.join('');
|
||||||
} else {
|
} else {
|
||||||
propertyAsString = '' + item[prop];
|
propertyAsString = '' + currValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (propertyAsString) {
|
if (propertyAsString) {
|
||||||
@ -655,8 +678,10 @@ export class ListViewTableComponent<V extends BaseViewModel, M extends BaseModel
|
|||||||
* This function changes the height of the row for virtual-scrolling in the relating `.scss`-file.
|
* This function changes the height of the row for virtual-scrolling in the relating `.scss`-file.
|
||||||
*/
|
*/
|
||||||
private changeRowHeight(): void {
|
private changeRowHeight(): void {
|
||||||
|
if (this.vScrollFixed > 0) {
|
||||||
document.documentElement.style.setProperty('--pbl-height', this.vScrollFixed + 'px');
|
document.documentElement.style.setProperty('--pbl-height', this.vScrollFixed + 'px');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks the array of selected items against the datastore data. This is
|
* Checks the array of selected items against the datastore data. This is
|
||||||
|
@ -13,16 +13,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Directory selector, if no external directory is provided -->
|
<!-- Directory selector, if no external directory is provided -->
|
||||||
<div *ngIf="showDirectorySelector">
|
<div *ngIf="showDirectorySelector" [formGroup]="directorySelectionForm">
|
||||||
|
<mat-form-field>
|
||||||
<os-search-value-selector
|
<os-search-value-selector
|
||||||
ngDefaultControl
|
formControlName="parent_id"
|
||||||
[formControl]="directorySelectionForm.get('parent_id')"
|
|
||||||
[multiple]="false"
|
[multiple]="false"
|
||||||
[includeNone]="true"
|
[includeNone]="true"
|
||||||
[noneTitle]="'Base folder'"
|
[noneTitle]="'Base folder'"
|
||||||
listname="{{ 'Parent directory' | translate }}"
|
placeholder="{{ 'Parent directory' | translate }}"
|
||||||
[inputListValues]="directoryBehaviorSubject"
|
[inputListValues]="directoryBehaviorSubject"
|
||||||
></os-search-value-selector>
|
></os-search-value-selector>
|
||||||
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@ -69,14 +70,15 @@
|
|||||||
<!-- Access groups -->
|
<!-- Access groups -->
|
||||||
<ng-container matColumnDef="access_groups">
|
<ng-container matColumnDef="access_groups">
|
||||||
<th mat-header-cell *matHeaderCellDef><span translate>Access groups</span></th>
|
<th mat-header-cell *matHeaderCellDef><span translate>Access groups</span></th>
|
||||||
<td mat-cell *matCellDef="let file">
|
<td mat-cell *matCellDef="let file" [formGroup]="file.form">
|
||||||
|
<mat-form-field>
|
||||||
<os-search-value-selector
|
<os-search-value-selector
|
||||||
ngDefaultControl
|
formControlName="access_groups_id"
|
||||||
[formControl]="file.form.get('access_groups_id')"
|
|
||||||
[multiple]="true"
|
[multiple]="true"
|
||||||
listname="{{ 'Access groups' | translate }}"
|
placeholder="{{ 'Access groups' | translate }}"
|
||||||
[inputListValues]="groupsBehaviorSubject"
|
[inputListValues]="groupsBehaviorSubject"
|
||||||
></os-search-value-selector>
|
></os-search-value-selector>
|
||||||
|
</mat-form-field>
|
||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
|
@ -90,7 +90,8 @@ export class MediaUploadContentComponent implements OnInit {
|
|||||||
|
|
||||||
public get selectedDirectoryId(): number | null {
|
public get selectedDirectoryId(): number | null {
|
||||||
if (this.showDirectorySelector) {
|
if (this.showDirectorySelector) {
|
||||||
return this.directorySelectionForm.controls.parent_id.value;
|
const parent = this.directorySelectionForm.controls.parent_id;
|
||||||
|
return !parent.value || typeof parent.value !== 'number' ? null : parent.value;
|
||||||
} else {
|
} else {
|
||||||
return this.directoryId;
|
return this.directoryId;
|
||||||
}
|
}
|
||||||
@ -110,7 +111,7 @@ export class MediaUploadContentComponent implements OnInit {
|
|||||||
this.directoryBehaviorSubject = this.repo.getDirectoryBehaviorSubject();
|
this.directoryBehaviorSubject = this.repo.getDirectoryBehaviorSubject();
|
||||||
this.groupsBehaviorSubject = this.groupRepo.getViewModelListBehaviorSubject();
|
this.groupsBehaviorSubject = this.groupRepo.getViewModelListBehaviorSubject();
|
||||||
this.directorySelectionForm = this.formBuilder.group({
|
this.directorySelectionForm = this.formBuilder.group({
|
||||||
parent_id: []
|
parent_id: null
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -0,0 +1,39 @@
|
|||||||
|
<div class="result-wrapper" *ngIf="hasVotes">
|
||||||
|
<!-- result table -->
|
||||||
|
<table class="result-table">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th></th>
|
||||||
|
<th colspan="2" translate>Votes</th>
|
||||||
|
</tr>
|
||||||
|
<tr *ngFor="let row of getTableData()" [class]="row.votingOption">
|
||||||
|
<!-- YNA/Valid etc -->
|
||||||
|
<td>
|
||||||
|
<os-icon-container *ngIf="row.value[0].icon" [icon]="row.value[0].icon">
|
||||||
|
{{ row.votingOption | pollKeyVerbose | translate }}
|
||||||
|
</os-icon-container>
|
||||||
|
<span *ngIf="!row.value[0].icon">
|
||||||
|
{{ row.votingOption | pollKeyVerbose | translate }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Percent numbers -->
|
||||||
|
<td class="result-cell-definition">
|
||||||
|
<span *ngIf="row.value[0].showPercent">
|
||||||
|
{{ row.value[0].amount | pollPercentBase: poll }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Voices -->
|
||||||
|
<td class="result-cell-definition">
|
||||||
|
{{ row.value[0].amount | parsePollNumber }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Chart -->
|
||||||
|
<div class="doughnut-chart" *ngIf="showChart">
|
||||||
|
<os-charts type="doughnut" [data]="chartData" [showLegend]="false" [hasPadding]="false"></os-charts>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -0,0 +1,31 @@
|
|||||||
|
@import '~assets/styles/poll-styles-common.scss';
|
||||||
|
|
||||||
|
.result-wrapper {
|
||||||
|
display: grid;
|
||||||
|
grid-gap: 2em;
|
||||||
|
margin: 2em;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
|
||||||
|
.result-table {
|
||||||
|
// display: block;
|
||||||
|
th {
|
||||||
|
text-align: right;
|
||||||
|
font-weight: initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr {
|
||||||
|
height: 48px;
|
||||||
|
border-bottom: none !important;
|
||||||
|
|
||||||
|
.result-cell-definition {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.doughnut-chart {
|
||||||
|
display: block;
|
||||||
|
margin-top: auto;
|
||||||
|
margin-bottom: auto;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,26 @@
|
|||||||
|
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { E2EImportsModule } from 'e2e-imports.module';
|
||||||
|
|
||||||
|
import { MotionPollDetailContentComponent } from './motion-poll-detail-content.component';
|
||||||
|
|
||||||
|
describe('MotionPollDetailContentComponent', () => {
|
||||||
|
let component: MotionPollDetailContentComponent;
|
||||||
|
let fixture: ComponentFixture<MotionPollDetailContentComponent>;
|
||||||
|
|
||||||
|
beforeEach(async(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [E2EImportsModule]
|
||||||
|
}).compileComponents();
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(MotionPollDetailContentComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,37 @@
|
|||||||
|
import { Component, Input, OnInit } from '@angular/core';
|
||||||
|
|
||||||
|
import { BehaviorSubject } from 'rxjs';
|
||||||
|
|
||||||
|
import { ViewMotionPoll } from 'app/site/motions/models/view-motion-poll';
|
||||||
|
import { MotionPollService } from 'app/site/motions/services/motion-poll.service';
|
||||||
|
import { PollData, PollTableData } from 'app/site/polls/services/poll.service';
|
||||||
|
import { ChartData } from '../charts/charts.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'os-motion-poll-detail-content',
|
||||||
|
templateUrl: './motion-poll-detail-content.component.html',
|
||||||
|
styleUrls: ['./motion-poll-detail-content.component.scss']
|
||||||
|
})
|
||||||
|
export class MotionPollDetailContentComponent implements OnInit {
|
||||||
|
@Input()
|
||||||
|
public poll: ViewMotionPoll | PollData;
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
public chartData: BehaviorSubject<ChartData>;
|
||||||
|
|
||||||
|
public get hasVotes(): boolean {
|
||||||
|
return this.poll && !!this.poll.options;
|
||||||
|
}
|
||||||
|
|
||||||
|
public constructor(private motionPollService: MotionPollService) {}
|
||||||
|
|
||||||
|
public ngOnInit(): void {}
|
||||||
|
|
||||||
|
public getTableData(): PollTableData[] {
|
||||||
|
return this.motionPollService.generateTableData(this.poll);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get showChart(): boolean {
|
||||||
|
return this.motionPollService.showChart(this.poll) && this.chartData && !!this.chartData.value;
|
||||||
|
}
|
||||||
|
}
|
@ -5,7 +5,6 @@
|
|||||||
'message action'
|
'message action'
|
||||||
'bar action';
|
'bar action';
|
||||||
grid-template-columns: auto min-content;
|
grid-template-columns: auto min-content;
|
||||||
}
|
|
||||||
|
|
||||||
.message {
|
.message {
|
||||||
grid-area: message;
|
grid-area: message;
|
||||||
@ -18,3 +17,4 @@
|
|||||||
.action {
|
.action {
|
||||||
grid-area: action;
|
grid-area: action;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
@ -0,0 +1,10 @@
|
|||||||
|
@import '~@angular/material/theming';
|
||||||
|
|
||||||
|
/** Custom component theme. Only lives in a specific scope */
|
||||||
|
@mixin os-progress-snack-bar-style($theme) {
|
||||||
|
$background: map-get($theme, background);
|
||||||
|
|
||||||
|
.mat-progress-bar-buffer {
|
||||||
|
background-color: mat-color($background, card) !important;
|
||||||
|
}
|
||||||
|
}
|
@ -1,19 +1,30 @@
|
|||||||
<mat-form-field [style.display]="fullWidth ? 'block' : 'inline-block'">
|
<mat-select [formControl]="contentForm" [multiple]="multiple" [panelClass]="{ 'os-search-value-selector': multiple }" [errorStateMatcher]="errorStateMatcher">
|
||||||
<mat-select
|
<ngx-mat-select-search [formControl]="searchValue"></ngx-mat-select-search>
|
||||||
[formControl]="formControl"
|
<ng-container *ngIf="multiple && showChips">
|
||||||
placeholder="{{ listname | translate }}"
|
<div #chipPlaceholder>
|
||||||
[multiple]="multiple"
|
<div class="os-search-value-selector-chip-container" [style.width]="width">
|
||||||
#thisSelector
|
<mat-chip-list class="chip-list" [selectable]="false">
|
||||||
|
<mat-chip
|
||||||
|
*ngFor="let item of selectedItems"
|
||||||
|
[removable]="true"
|
||||||
|
(removed)="removeItem(item.id)"
|
||||||
|
[disableRipple]="true"
|
||||||
>
|
>
|
||||||
<ngx-mat-select-search ngModel (ngModelChange)="onSearch($event)"></ngx-mat-select-search>
|
{{ item.getTitle() }}
|
||||||
<div *ngIf="!multiple && includeNone">
|
<mat-icon matChipRemove>cancel</mat-icon>
|
||||||
<mat-option [value]="null">
|
</mat-chip>
|
||||||
|
</mat-chip-list>
|
||||||
|
</div>
|
||||||
|
<div class="os-search-value-selector-chip-placeholder"></div>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container *ngIf="!multiple && includeNone">
|
||||||
|
<mat-option>
|
||||||
{{ noneTitle | translate }}
|
{{ noneTitle | translate }}
|
||||||
</mat-option>
|
</mat-option>
|
||||||
<mat-divider></mat-divider>
|
<mat-divider></mat-divider>
|
||||||
</div>
|
</ng-container>
|
||||||
<mat-option *ngFor="let selectedItem of getFilteredItems()" [value]="selectedItem.id">
|
<mat-option *ngFor="let selectedItem of getFilteredItems()" [value]="selectedItem.id">
|
||||||
{{ selectedItem.getTitle() | translate }}
|
{{ selectedItem.getTitle() | translate }}
|
||||||
</mat-option>
|
</mat-option>
|
||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
|
||||||
|
@ -0,0 +1,21 @@
|
|||||||
|
.os-search-value-selector {
|
||||||
|
max-height: 312px !important ;
|
||||||
|
}
|
||||||
|
|
||||||
|
.os-search-value-selector-chip-container {
|
||||||
|
position: absolute;
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
|
||||||
|
top: 52px;
|
||||||
|
width: 100%;
|
||||||
|
background: white;
|
||||||
|
z-index: 100;
|
||||||
|
min-height: 41px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.os-search-value-selector-chip-placeholder {
|
||||||
|
padding: 8px;
|
||||||
|
width: 100%;
|
||||||
|
background: white;
|
||||||
|
min-height: 39px;
|
||||||
|
}
|
@ -1,6 +1,6 @@
|
|||||||
import { Component, ViewChild } from '@angular/core';
|
import { Component, ViewChild } from '@angular/core';
|
||||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
import { FormBuilder, FormControl } from '@angular/forms';
|
import { FormBuilder } from '@angular/forms';
|
||||||
|
|
||||||
import { BehaviorSubject } from 'rxjs';
|
import { BehaviorSubject } from 'rxjs';
|
||||||
|
|
||||||
@ -43,10 +43,8 @@ describe('SearchValueSelectorComponent', () => {
|
|||||||
hostComponent.searchValueSelectorComponent.inputListValues = subject;
|
hostComponent.searchValueSelectorComponent.inputListValues = subject;
|
||||||
|
|
||||||
const formBuilder: FormBuilder = TestBed.get(FormBuilder);
|
const formBuilder: FormBuilder = TestBed.get(FormBuilder);
|
||||||
const formGroup = formBuilder.group({
|
const formControl = formBuilder.control([]);
|
||||||
testArray: []
|
hostComponent.searchValueSelectorComponent.contentForm = formControl;
|
||||||
});
|
|
||||||
hostComponent.searchValueSelectorComponent.formControl = <FormControl>formGroup.get('testArray');
|
|
||||||
|
|
||||||
hostFixture.detectChanges();
|
hostFixture.detectChanges();
|
||||||
expect(hostComponent.searchValueSelectorComponent).toBeTruthy();
|
expect(hostComponent.searchValueSelectorComponent).toBeTruthy();
|
||||||
|
@ -1,31 +1,40 @@
|
|||||||
import { ChangeDetectionStrategy, Component, Input, OnDestroy, ViewChild } from '@angular/core';
|
import { FocusMonitor } from '@angular/cdk/a11y';
|
||||||
import { FormControl } from '@angular/forms';
|
import {
|
||||||
import { MatSelect } from '@angular/material';
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
ElementRef,
|
||||||
|
Input,
|
||||||
|
Optional,
|
||||||
|
Self,
|
||||||
|
ViewChild,
|
||||||
|
ViewEncapsulation
|
||||||
|
} from '@angular/core';
|
||||||
|
import { FormBuilder, FormControl, NgControl } from '@angular/forms';
|
||||||
|
import { MatFormFieldControl } from '@angular/material';
|
||||||
|
|
||||||
import { TranslateService } from '@ngx-translate/core';
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
import { Observable, Subscription } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { auditTime } from 'rxjs/operators';
|
import { auditTime } from 'rxjs/operators';
|
||||||
|
|
||||||
|
import { BaseFormControlComponent } from 'app/shared/models/base/base-form-control';
|
||||||
|
import { ParentErrorStateMatcher } from 'app/shared/parent-error-state-matcher';
|
||||||
import { Selectable } from '../selectable';
|
import { Selectable } from '../selectable';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reusable Searchable Value Selector
|
* Searchable Value Selector
|
||||||
*
|
*
|
||||||
* Use `multiple="true"`, `[InputListValues]=myValues`,`[formControl]="myformcontrol"` and `placeholder={{listname}}` to pass the Values and Listname
|
* Use `multiple="true"`, `[inputListValues]=myValues`,`formControlName="myformcontrol"` and `placeholder={{listname}}` to pass the Values and Listname
|
||||||
*
|
*
|
||||||
* ## Examples:
|
* ## Examples:
|
||||||
*
|
*
|
||||||
* ### Usage of the selector:
|
* ### Usage of the selector:
|
||||||
*
|
*
|
||||||
* ngDefaultControl: https://stackoverflow.com/a/39053470
|
|
||||||
*
|
|
||||||
* ```html
|
* ```html
|
||||||
* <os-search-value-selector
|
* <os-search-value-selector
|
||||||
* ngDefaultControl
|
|
||||||
* [multiple]="true"
|
* [multiple]="true"
|
||||||
* placeholder="Placeholder"
|
* placeholder="Placeholder"
|
||||||
* [InputListValues]="myListValues"
|
* [inputListValues]="myListValues"
|
||||||
* [formControl]="myformcontrol">
|
* formControlName="myformcontrol">
|
||||||
* </os-search-value-selector>
|
* </os-search-value-selector>
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
@ -35,23 +44,13 @@ import { Selectable } from '../selectable';
|
|||||||
selector: 'os-search-value-selector',
|
selector: 'os-search-value-selector',
|
||||||
templateUrl: './search-value-selector.component.html',
|
templateUrl: './search-value-selector.component.html',
|
||||||
styleUrls: ['./search-value-selector.component.scss'],
|
styleUrls: ['./search-value-selector.component.scss'],
|
||||||
|
providers: [{ provide: MatFormFieldControl, useExisting: SearchValueSelectorComponent }],
|
||||||
|
encapsulation: ViewEncapsulation.None,
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
})
|
})
|
||||||
export class SearchValueSelectorComponent implements OnDestroy {
|
export class SearchValueSelectorComponent extends BaseFormControlComponent<Selectable[]> {
|
||||||
/**
|
@ViewChild('chipPlaceholder', { static: false })
|
||||||
* Saves the current subscription to _inputListSubject.
|
public chipPlaceholder: ElementRef<HTMLElement>;
|
||||||
*/
|
|
||||||
private _inputListSubscription: Subscription = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Value of the search input
|
|
||||||
*/
|
|
||||||
private searchValue = '';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* All items
|
|
||||||
*/
|
|
||||||
private selectableItems: Selectable[];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decide if this should be a single or multi-select-field
|
* Decide if this should be a single or multi-select-field
|
||||||
@ -65,14 +64,14 @@ export class SearchValueSelectorComponent implements OnDestroy {
|
|||||||
@Input()
|
@Input()
|
||||||
public includeNone = false;
|
public includeNone = false;
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
public showChips = true;
|
||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
public noneTitle = '–';
|
public noneTitle = '–';
|
||||||
|
|
||||||
/**
|
|
||||||
* Boolean, whether the component should be rendered with full width.
|
|
||||||
*/
|
|
||||||
@Input()
|
@Input()
|
||||||
public fullWidth = false;
|
public errorStateMatcher: ParentErrorStateMatcher;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The inputlist subject. Subscribes to it and updates the selector, if the subject
|
* The inputlist subject. Subscribes to it and updates the selector, if the subject
|
||||||
@ -83,55 +82,51 @@ export class SearchValueSelectorComponent implements OnDestroy {
|
|||||||
if (!value) {
|
if (!value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
this.selectableItems = value;
|
this.selectableItems = value;
|
||||||
} else {
|
} else {
|
||||||
// unsubscribe to old subscription.
|
this.subscriptions.push(
|
||||||
if (this._inputListSubscription) {
|
value.pipe(auditTime(10)).subscribe(items => {
|
||||||
this._inputListSubscription.unsubscribe();
|
|
||||||
}
|
|
||||||
this._inputListSubscription = value.pipe(auditTime(10)).subscribe(items => {
|
|
||||||
this.selectableItems = items;
|
this.selectableItems = items;
|
||||||
if (this.formControl) {
|
if (this.contentForm) {
|
||||||
!!items && items.length > 0
|
this.disabled = !items || (!!items && !items.length);
|
||||||
? this.formControl.enable({ emitEvent: false })
|
|
||||||
: this.formControl.disable({ emitEvent: false });
|
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public searchValue: FormControl;
|
||||||
* Placeholder of the List
|
|
||||||
*/
|
|
||||||
@Input()
|
|
||||||
public listname: string;
|
|
||||||
|
|
||||||
/**
|
public get empty(): boolean {
|
||||||
* Name of the Form
|
return Array.isArray(this.contentForm.value) ? !this.contentForm.value.length : !this.contentForm.value;
|
||||||
*/
|
|
||||||
@Input()
|
|
||||||
public formControl: FormControl;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The MultiSelect Component
|
|
||||||
*/
|
|
||||||
@ViewChild('thisSelector', { static: true })
|
|
||||||
public thisSelector: MatSelect;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Empty constructor
|
|
||||||
*/
|
|
||||||
public constructor(protected translate: TranslateService) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unsubscribe on destroing.
|
|
||||||
*/
|
|
||||||
public ngOnDestroy(): void {
|
|
||||||
if (this._inputListSubscription) {
|
|
||||||
this._inputListSubscription.unsubscribe();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public get selectedItems(): Selectable[] {
|
||||||
|
return this.selectableItems && this.contentForm.value
|
||||||
|
? this.selectableItems.filter(item => this.contentForm.value.includes(item.id))
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public controlType = 'search-value-selector';
|
||||||
|
|
||||||
|
public get width(): string {
|
||||||
|
return this.chipPlaceholder ? `${this.chipPlaceholder.nativeElement.clientWidth - 16}px` : '100%';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All items
|
||||||
|
*/
|
||||||
|
private selectableItems: Selectable[];
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
protected translate: TranslateService,
|
||||||
|
formBuilder: FormBuilder,
|
||||||
|
@Optional() @Self() public ngControl: NgControl,
|
||||||
|
focusMonitor: FocusMonitor,
|
||||||
|
element: ElementRef<HTMLElement>
|
||||||
|
) {
|
||||||
|
super(formBuilder, focusMonitor, element, ngControl);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -141,29 +136,50 @@ export class SearchValueSelectorComponent implements OnDestroy {
|
|||||||
*/
|
*/
|
||||||
public getFilteredItems(): Selectable[] {
|
public getFilteredItems(): Selectable[] {
|
||||||
if (this.selectableItems) {
|
if (this.selectableItems) {
|
||||||
|
const searchValue: string = this.searchValue.value.toLowerCase();
|
||||||
return this.selectableItems.filter(item => {
|
return this.selectableItems.filter(item => {
|
||||||
const idString = '' + item.id;
|
const idString = '' + item.id;
|
||||||
const foundId =
|
const foundId =
|
||||||
idString
|
idString
|
||||||
.trim()
|
.trim()
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.indexOf(this.searchValue) !== -1;
|
.indexOf(searchValue) !== -1;
|
||||||
|
|
||||||
if (foundId) {
|
if (foundId) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const searchableString = this.translate.instant(item.getTitle()).toLowerCase();
|
|
||||||
return searchableString.indexOf(this.searchValue) > -1;
|
return (
|
||||||
|
item
|
||||||
|
.toString()
|
||||||
|
.toLowerCase()
|
||||||
|
.indexOf(searchValue) > -1
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public removeItem(itemId: number): void {
|
||||||
* Function to set the search value.
|
const items = <number[]>this.contentForm.value;
|
||||||
*
|
items.splice(
|
||||||
* @param searchValue the new value the user is searching for.
|
items.findIndex(item => item === itemId),
|
||||||
*/
|
1
|
||||||
public onSearch(searchValue: string): void {
|
);
|
||||||
this.searchValue = searchValue.toLowerCase();
|
this.contentForm.setValue(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
public onContainerClick(event: MouseEvent): void {
|
||||||
|
if ((event.target as Element).tagName.toLowerCase() !== 'select') {
|
||||||
|
// this.element.nativeElement.querySelector('select').focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected initializeForm(): void {
|
||||||
|
this.contentForm = this.fb.control([]);
|
||||||
|
this.searchValue = this.fb.control('');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected updateForm(value: Selectable[] | null): void {
|
||||||
|
this.contentForm.setValue(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -59,7 +59,7 @@ export class SlideContainerComponent extends BaseComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.log(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,18 @@
|
|||||||
|
<h1 mat-dialog-title>
|
||||||
|
<span translate>Online voting is impossible to secure</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div mat-dialog-content>
|
||||||
|
<span translate>
|
||||||
|
During voting, OpenSlides does not store the individual user ID of the voter. This in no way means that a
|
||||||
|
non-nominal vote is completely anonymous and secure. You cannot track the decisions of your voters after the
|
||||||
|
data has been submitted. The validity of the data cannot always be guaranteed, especially if you use OpenSlides
|
||||||
|
in a distributed online setup. You are responsible for your own actions.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div mat-dialog-actions>
|
||||||
|
<button type="button" mat-button [mat-dialog-close]="null">
|
||||||
|
<span translate>I know the risk</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
@ -0,0 +1,26 @@
|
|||||||
|
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { E2EImportsModule } from 'e2e-imports.module';
|
||||||
|
|
||||||
|
import { VotingPrivacyWarningComponent } from './voting-privacy-warning.component';
|
||||||
|
|
||||||
|
describe('VotingPrivacyWarningComponent', () => {
|
||||||
|
let component: VotingPrivacyWarningComponent;
|
||||||
|
let fixture: ComponentFixture<VotingPrivacyWarningComponent>;
|
||||||
|
|
||||||
|
beforeEach(async(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [E2EImportsModule]
|
||||||
|
}).compileComponents();
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(VotingPrivacyWarningComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,12 @@
|
|||||||
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'os-voting-privacy-warning',
|
||||||
|
templateUrl: './voting-privacy-warning.component.html',
|
||||||
|
styleUrls: ['./voting-privacy-warning.component.scss']
|
||||||
|
})
|
||||||
|
export class VotingPrivacyWarningComponent implements OnInit {
|
||||||
|
public constructor() {}
|
||||||
|
|
||||||
|
public ngOnInit(): void {}
|
||||||
|
}
|
@ -108,7 +108,7 @@ export class PermsDirective implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* COmes from the view.
|
* Comes from the view.
|
||||||
*/
|
*/
|
||||||
@Input('osPermsComplement')
|
@Input('osPermsComplement')
|
||||||
public set osPermsComplement(value: boolean) {
|
public set osPermsComplement(value: boolean) {
|
||||||
|
@ -0,0 +1,12 @@
|
|||||||
|
import { BaseOption } from '../poll/base-option';
|
||||||
|
|
||||||
|
export class AssignmentOption extends BaseOption<AssignmentOption> {
|
||||||
|
public static COLLECTIONSTRING = 'assignments/assignment-option';
|
||||||
|
|
||||||
|
public user_id: number;
|
||||||
|
public weight: number;
|
||||||
|
|
||||||
|
public constructor(input?: any) {
|
||||||
|
super(AssignmentOption.COLLECTIONSTRING, input);
|
||||||
|
}
|
||||||
|
}
|
@ -1,38 +0,0 @@
|
|||||||
import { PollVoteValue } from 'app/core/ui-services/poll.service';
|
|
||||||
import { BaseModel } from '../base/base-model';
|
|
||||||
|
|
||||||
export interface AssignmentOptionVote {
|
|
||||||
weight: number;
|
|
||||||
value: PollVoteValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Representation of a poll option
|
|
||||||
*
|
|
||||||
* part of the 'polls-options'-array in poll
|
|
||||||
* @ignore
|
|
||||||
*/
|
|
||||||
export class AssignmentPollOption extends BaseModel<AssignmentPollOption> {
|
|
||||||
public static COLLECTIONSTRING = 'assignments/assignment-poll-option';
|
|
||||||
|
|
||||||
public id: number; // The AssignmentPollOption id
|
|
||||||
public candidate_id: number; // the user id of the candidate
|
|
||||||
public is_elected: boolean;
|
|
||||||
public votes: AssignmentOptionVote[];
|
|
||||||
public poll_id: number;
|
|
||||||
public weight: number; // weight to order the display
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param input
|
|
||||||
*/
|
|
||||||
public constructor(input?: any) {
|
|
||||||
if (input && input.votes) {
|
|
||||||
input.votes.forEach(vote => {
|
|
||||||
if (vote.weight) {
|
|
||||||
vote.weight = parseFloat(vote.weight);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
super(AssignmentPollOption.COLLECTIONSTRING, input);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,42 +1,79 @@
|
|||||||
import { AssignmentPollMethod } from 'app/site/assignments/services/assignment-poll.service';
|
import { CalculablePollKey } from 'app/site/polls/services/poll.service';
|
||||||
import { AssignmentPollOption } from './assignment-poll-option';
|
import { AssignmentOption } from './assignment-option';
|
||||||
import { BaseModel } from '../base/base-model';
|
import { BasePoll } from '../poll/base-poll';
|
||||||
|
|
||||||
export interface AssignmentPollWithoutNestedModels extends BaseModel<AssignmentPoll> {
|
export enum AssignmentPollMethod {
|
||||||
id: number;
|
YN = 'YN',
|
||||||
pollmethod: AssignmentPollMethod;
|
YNA = 'YNA',
|
||||||
description: string;
|
Votes = 'votes'
|
||||||
published: boolean;
|
}
|
||||||
votesvalid: number;
|
|
||||||
votesno: number;
|
export enum AssignmentPollPercentBase {
|
||||||
votesabstain: number;
|
YN = 'YN',
|
||||||
votesinvalid: number;
|
YNA = 'YNA',
|
||||||
votescast: number;
|
Votes = 'votes',
|
||||||
has_votes: boolean;
|
Valid = 'valid',
|
||||||
assignment_id: number;
|
Cast = 'cast',
|
||||||
|
Disabled = 'disabled'
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Content of the 'polls' property of assignments
|
* Class representing a poll for an assignment.
|
||||||
* @ignore
|
|
||||||
*/
|
*/
|
||||||
export class AssignmentPoll extends BaseModel<AssignmentPoll> {
|
export class AssignmentPoll extends BasePoll<
|
||||||
|
AssignmentPoll,
|
||||||
|
AssignmentOption,
|
||||||
|
AssignmentPollMethod,
|
||||||
|
AssignmentPollPercentBase
|
||||||
|
> {
|
||||||
public static COLLECTIONSTRING = 'assignments/assignment-poll';
|
public static COLLECTIONSTRING = 'assignments/assignment-poll';
|
||||||
private static DECIMAL_FIELDS = ['votesvalid', 'votesinvalid', 'votescast', 'votesno', 'votesabstain'];
|
public static defaultGroupsConfig = 'assignment_poll_default_groups';
|
||||||
|
public static defaultPollMethodConfig = 'assignment_poll_method';
|
||||||
|
public static DECIMAL_FIELDS = [
|
||||||
|
'votesvalid',
|
||||||
|
'votesinvalid',
|
||||||
|
'votescast',
|
||||||
|
'amount_global_abstain',
|
||||||
|
'amount_global_no'
|
||||||
|
];
|
||||||
|
|
||||||
public id: number;
|
public id: number;
|
||||||
public options: AssignmentPollOption[];
|
public assignment_id: number;
|
||||||
|
public votes_amount: number;
|
||||||
|
public allow_multiple_votes_per_candidate: boolean;
|
||||||
|
public global_no: boolean;
|
||||||
|
public global_abstain: boolean;
|
||||||
|
public amount_global_no: number;
|
||||||
|
public amount_global_abstain: number;
|
||||||
|
public description: string;
|
||||||
|
|
||||||
|
public get isMethodY(): boolean {
|
||||||
|
return this.pollmethod === AssignmentPollMethod.Votes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get isMethodYN(): boolean {
|
||||||
|
return this.pollmethod === AssignmentPollMethod.YN;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get isMethodYNA(): boolean {
|
||||||
|
return this.pollmethod === AssignmentPollMethod.YNA;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get pollmethodFields(): CalculablePollKey[] {
|
||||||
|
if (this.pollmethod === AssignmentPollMethod.YN) {
|
||||||
|
return ['yes', 'no'];
|
||||||
|
} else if (this.pollmethod === AssignmentPollMethod.YNA) {
|
||||||
|
return ['yes', 'no', 'abstain'];
|
||||||
|
} else if (this.pollmethod === AssignmentPollMethod.Votes) {
|
||||||
|
return ['yes'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public constructor(input?: any) {
|
public constructor(input?: any) {
|
||||||
// cast stringify numbers
|
|
||||||
if (input) {
|
|
||||||
AssignmentPoll.DECIMAL_FIELDS.forEach(field => {
|
|
||||||
if (input[field] && typeof input[field] === 'string') {
|
|
||||||
input[field] = parseFloat(input[field]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
super(AssignmentPoll.COLLECTIONSTRING, input);
|
super(AssignmentPoll.COLLECTIONSTRING, input);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected getDecimalFields(): string[] {
|
||||||
|
return AssignmentPoll.DECIMAL_FIELDS;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
export interface AssignmentPoll extends AssignmentPollWithoutNestedModels {}
|
|
||||||
|
@ -8,7 +8,6 @@ export class AssignmentRelatedUser extends BaseModel<AssignmentRelatedUser> {
|
|||||||
|
|
||||||
public id: number;
|
public id: number;
|
||||||
public user_id: number;
|
public user_id: number;
|
||||||
public elected: boolean;
|
|
||||||
public assignment_id: number;
|
public assignment_id: number;
|
||||||
public weight: number;
|
public weight: number;
|
||||||
|
|
||||||
|
11
client/src/app/shared/models/assignments/assignment-vote.ts
Normal file
11
client/src/app/shared/models/assignments/assignment-vote.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { BaseVote } from '../poll/base-vote';
|
||||||
|
|
||||||
|
export class AssignmentVote extends BaseVote<AssignmentVote> {
|
||||||
|
public static COLLECTIONSTRING = 'assignments/assignment-vote';
|
||||||
|
|
||||||
|
public id: number;
|
||||||
|
|
||||||
|
public constructor(input?: any) {
|
||||||
|
super(AssignmentVote.COLLECTIONSTRING, input);
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,3 @@
|
|||||||
import { AssignmentPoll } from './assignment-poll';
|
|
||||||
import { AssignmentRelatedUser } from './assignment-related-user';
|
import { AssignmentRelatedUser } from './assignment-related-user';
|
||||||
import { BaseModelWithAgendaItemAndListOfSpeakers } from '../base/base-model-with-agenda-item-and-list-of-speakers';
|
import { BaseModelWithAgendaItemAndListOfSpeakers } from '../base/base-model-with-agenda-item-and-list-of-speakers';
|
||||||
|
|
||||||
@ -8,9 +7,10 @@ export interface AssignmentWithoutNestedModels extends BaseModelWithAgendaItemAn
|
|||||||
description: string;
|
description: string;
|
||||||
open_posts: number;
|
open_posts: number;
|
||||||
phase: number; // see Openslides constants
|
phase: number; // see Openslides constants
|
||||||
poll_description_default: number;
|
default_poll_description: string;
|
||||||
tags_id: number[];
|
tags_id: number[];
|
||||||
attachments_id: number[];
|
attachments_id: number[];
|
||||||
|
number_poll_candidates: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -22,18 +22,9 @@ export class Assignment extends BaseModelWithAgendaItemAndListOfSpeakers<Assignm
|
|||||||
|
|
||||||
public id: number;
|
public id: number;
|
||||||
public assignment_related_users: AssignmentRelatedUser[];
|
public assignment_related_users: AssignmentRelatedUser[];
|
||||||
public polls: AssignmentPoll[];
|
|
||||||
|
|
||||||
public constructor(input?: any) {
|
public constructor(input?: any) {
|
||||||
super(Assignment.COLLECTIONSTRING, input);
|
super(Assignment.COLLECTIONSTRING, input);
|
||||||
}
|
}
|
||||||
|
|
||||||
public get candidates_id(): number[] {
|
|
||||||
return this.assignment_related_users
|
|
||||||
.sort((a: AssignmentRelatedUser, b: AssignmentRelatedUser) => {
|
|
||||||
return a.weight - b.weight;
|
|
||||||
})
|
|
||||||
.map((candidate: AssignmentRelatedUser) => candidate.user_id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
export interface Assignment extends AssignmentWithoutNestedModels {}
|
export interface Assignment extends AssignmentWithoutNestedModels {}
|
||||||
|
12
client/src/app/shared/models/base/base-decimal-model.ts
Normal file
12
client/src/app/shared/models/base/base-decimal-model.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { BaseModel } from './base-model';
|
||||||
|
|
||||||
|
export abstract class BaseDecimalModel<T = any> extends BaseModel<T> {
|
||||||
|
protected abstract getDecimalFields(): string[];
|
||||||
|
|
||||||
|
public deserialize(input: any): void {
|
||||||
|
if (input && typeof input === 'object') {
|
||||||
|
this.getDecimalFields().forEach(field => (input[field] = parseInt(input[field], 10)));
|
||||||
|
}
|
||||||
|
super.deserialize(input);
|
||||||
|
}
|
||||||
|
}
|
161
client/src/app/shared/models/base/base-form-control.ts
Normal file
161
client/src/app/shared/models/base/base-form-control.ts
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
import { FocusMonitor } from '@angular/cdk/a11y';
|
||||||
|
import { coerceBooleanProperty } from '@angular/cdk/coercion';
|
||||||
|
import { ElementRef, HostBinding, Input, OnDestroy, Optional, Self } from '@angular/core';
|
||||||
|
import { ControlValueAccessor, FormBuilder, FormControl, FormGroup, NgControl } from '@angular/forms';
|
||||||
|
import { MatFormFieldControl } from '@angular/material';
|
||||||
|
|
||||||
|
import { Subject, Subscription } from 'rxjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstract class to implement some simple logic and provide the subclass as a controllable form-control in `MatFormField`.
|
||||||
|
*
|
||||||
|
* Please remember to prepare the `providers` in the `@Component`-decorator. Something like:
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* @Component({
|
||||||
|
* selector: ...,
|
||||||
|
* templateUrl: ...,
|
||||||
|
* styleUrls: [...],
|
||||||
|
* providers: [{ provide: MatFormFieldControl, useExisting: <TheComponent>}]
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export abstract class BaseFormControlComponent<T> extends MatFormFieldControl<T>
|
||||||
|
implements OnDestroy, ControlValueAccessor {
|
||||||
|
public static nextId = 0;
|
||||||
|
|
||||||
|
@HostBinding() public id = `base-form-control-${BaseFormControlComponent.nextId++}`;
|
||||||
|
|
||||||
|
@HostBinding('class.floating') public get shouldLabelFloat(): boolean {
|
||||||
|
return this.focused || !this.empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostBinding('attr.aria-describedby') public describedBy = '';
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
public set value(value: T | null) {
|
||||||
|
this.updateForm(value);
|
||||||
|
this.stateChanges.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
public get value(): T | null {
|
||||||
|
return this.contentForm.value || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
public set placeholder(placeholder: string) {
|
||||||
|
this._placeholder = placeholder;
|
||||||
|
this.stateChanges.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
public get placeholder(): string {
|
||||||
|
return this._placeholder;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
public set required(required: boolean) {
|
||||||
|
this._required = coerceBooleanProperty(required);
|
||||||
|
this.stateChanges.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
public get required(): boolean {
|
||||||
|
return this._required;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
public set disabled(disable: boolean) {
|
||||||
|
this._disabled = coerceBooleanProperty(disable);
|
||||||
|
this._disabled ? this.contentForm.disable() : this.contentForm.enable();
|
||||||
|
this.stateChanges.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
public get disabled(): boolean {
|
||||||
|
return this._disabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract get empty(): boolean;
|
||||||
|
|
||||||
|
public abstract get controlType(): string;
|
||||||
|
|
||||||
|
public contentForm: FormControl | FormGroup;
|
||||||
|
|
||||||
|
public stateChanges = new Subject<void>();
|
||||||
|
|
||||||
|
public errorState = false;
|
||||||
|
|
||||||
|
public focused = false;
|
||||||
|
|
||||||
|
private _placeholder: string;
|
||||||
|
|
||||||
|
private _required = false;
|
||||||
|
|
||||||
|
private _disabled = false;
|
||||||
|
|
||||||
|
protected subscriptions: Subscription[] = [];
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
protected fb: FormBuilder,
|
||||||
|
protected fm: FocusMonitor,
|
||||||
|
protected element: ElementRef<HTMLElement>,
|
||||||
|
@Optional() @Self() public ngControl: NgControl
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.initializeForm();
|
||||||
|
|
||||||
|
if (this.ngControl !== null) {
|
||||||
|
this.ngControl.valueAccessor = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.subscriptions.push(
|
||||||
|
fm.monitor(element.nativeElement, true).subscribe(origin => {
|
||||||
|
this.focused = origin === 'mouse' || origin === 'touch';
|
||||||
|
this.stateChanges.next();
|
||||||
|
}),
|
||||||
|
this.contentForm.valueChanges.subscribe(nextValue => this.push(nextValue))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnDestroy(): void {
|
||||||
|
for (const subscription of this.subscriptions) {
|
||||||
|
subscription.unsubscribe();
|
||||||
|
}
|
||||||
|
this.subscriptions = [];
|
||||||
|
|
||||||
|
this.fm.stopMonitoring(this.element.nativeElement);
|
||||||
|
|
||||||
|
this.stateChanges.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
public writeValue(value: T): void {
|
||||||
|
this.value = value;
|
||||||
|
}
|
||||||
|
public registerOnChange(fn: any): void {
|
||||||
|
this._onChange = fn;
|
||||||
|
}
|
||||||
|
public registerOnTouched(fn: any): void {
|
||||||
|
this._onTouched = fn;
|
||||||
|
}
|
||||||
|
public setDisabledState?(isDisabled: boolean): void {
|
||||||
|
this.disabled = isDisabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setDescribedByIds(ids: string[]): void {
|
||||||
|
this.describedBy = ids.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract onContainerClick(event: MouseEvent): void;
|
||||||
|
|
||||||
|
protected _onChange = (value: T) => {};
|
||||||
|
|
||||||
|
protected _onTouched = (value: T) => {};
|
||||||
|
|
||||||
|
protected abstract initializeForm(): void;
|
||||||
|
|
||||||
|
protected abstract updateForm(value: T | null): void;
|
||||||
|
|
||||||
|
protected push(value: T): void {
|
||||||
|
this._onChange(value);
|
||||||
|
this._onTouched(value);
|
||||||
|
}
|
||||||
|
}
|
@ -2,7 +2,7 @@ import { BaseModel } from '../base/base-model';
|
|||||||
|
|
||||||
export interface ConfigChoice {
|
export interface ConfigChoice {
|
||||||
value: string;
|
value: string;
|
||||||
displayName: string;
|
display_name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -17,7 +17,8 @@ export type ConfigInputType =
|
|||||||
| 'choice'
|
| 'choice'
|
||||||
| 'datetimepicker'
|
| 'datetimepicker'
|
||||||
| 'colorpicker'
|
| 'colorpicker'
|
||||||
| 'translations';
|
| 'translations'
|
||||||
|
| 'groups';
|
||||||
|
|
||||||
export interface ConfigData {
|
export interface ConfigData {
|
||||||
defaultValue: any;
|
defaultValue: any;
|
||||||
|
9
client/src/app/shared/models/motions/motion-option.ts
Normal file
9
client/src/app/shared/models/motions/motion-option.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { BaseOption } from '../poll/base-option';
|
||||||
|
|
||||||
|
export class MotionOption extends BaseOption<MotionOption> {
|
||||||
|
public static COLLECTIONSTRING = 'motions/motion-option';
|
||||||
|
|
||||||
|
public constructor(input?: any) {
|
||||||
|
super(MotionOption.COLLECTIONSTRING, input);
|
||||||
|
}
|
||||||
|
}
|
@ -1,36 +1,32 @@
|
|||||||
import { Deserializer } from '../base/deserializer';
|
import { CalculablePollKey } from 'app/site/polls/services/poll.service';
|
||||||
|
import { BasePoll, PercentBase } from '../poll/base-poll';
|
||||||
|
import { MotionOption } from './motion-option';
|
||||||
|
|
||||||
|
export enum MotionPollMethod {
|
||||||
|
YN = 'YN',
|
||||||
|
YNA = 'YNA'
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class representing a poll for a motion.
|
* Class representing a poll for a motion.
|
||||||
*/
|
*/
|
||||||
export class MotionPoll extends Deserializer {
|
export class MotionPoll extends BasePoll<MotionPoll, MotionOption, MotionPollMethod, PercentBase> {
|
||||||
|
public static COLLECTIONSTRING = 'motions/motion-poll';
|
||||||
|
public static defaultGroupsConfig = 'motion_poll_default_groups';
|
||||||
|
|
||||||
public id: number;
|
public id: number;
|
||||||
public yes: number;
|
|
||||||
public no: number;
|
|
||||||
public abstain: number;
|
|
||||||
public votesvalid: number;
|
|
||||||
public votesinvalid: number;
|
|
||||||
public votescast: number;
|
|
||||||
public has_votes: boolean;
|
|
||||||
public motion_id: number;
|
public motion_id: number;
|
||||||
|
|
||||||
/**
|
public get pollmethodFields(): CalculablePollKey[] {
|
||||||
* Needs to be completely optional because motion has (yet) the optional parameter 'polls'
|
const ynField: CalculablePollKey[] = ['yes', 'no'];
|
||||||
* Tries to cast incoming strings as numbers
|
if (this.pollmethod === MotionPollMethod.YN) {
|
||||||
* @param input
|
return ynField;
|
||||||
*/
|
} else if (this.pollmethod === MotionPollMethod.YNA) {
|
||||||
public constructor(input?: any) {
|
return ynField.concat(['abstain']);
|
||||||
if (typeof input === 'object') {
|
|
||||||
Object.keys(input).forEach(key => {
|
|
||||||
if (typeof input[key] === 'string') {
|
|
||||||
input[key] = parseInt(input[key], 10);
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
|
||||||
super(input);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public deserialize(input: any): void {
|
public constructor(input?: any) {
|
||||||
Object.assign(this, input);
|
super(MotionPoll.COLLECTIONSTRING, input);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
11
client/src/app/shared/models/motions/motion-vote.ts
Normal file
11
client/src/app/shared/models/motions/motion-vote.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { BaseVote } from '../poll/base-vote';
|
||||||
|
|
||||||
|
export class MotionVote extends BaseVote<MotionVote> {
|
||||||
|
public static COLLECTIONSTRING = 'motions/motion-vote';
|
||||||
|
|
||||||
|
public id: number;
|
||||||
|
|
||||||
|
public constructor(input?: any) {
|
||||||
|
super(MotionVote.COLLECTIONSTRING, input);
|
||||||
|
}
|
||||||
|
}
|
@ -1,5 +1,4 @@
|
|||||||
import { BaseModelWithAgendaItemAndListOfSpeakers } from '../base/base-model-with-agenda-item-and-list-of-speakers';
|
import { BaseModelWithAgendaItemAndListOfSpeakers } from '../base/base-model-with-agenda-item-and-list-of-speakers';
|
||||||
import { MotionPoll } from './motion-poll';
|
|
||||||
import { Submitter } from './submitter';
|
import { Submitter } from './submitter';
|
||||||
|
|
||||||
export interface MotionComment {
|
export interface MotionComment {
|
||||||
@ -33,7 +32,6 @@ export interface MotionWithoutNestedModels extends BaseModelWithAgendaItemAndLis
|
|||||||
recommendation_extension: string;
|
recommendation_extension: string;
|
||||||
tags_id: number[];
|
tags_id: number[];
|
||||||
attachments_id: number[];
|
attachments_id: number[];
|
||||||
polls: MotionPoll[];
|
|
||||||
weight: number;
|
weight: number;
|
||||||
sort_parent_id: number;
|
sort_parent_id: number;
|
||||||
created: string;
|
created: string;
|
||||||
|
14
client/src/app/shared/models/poll/base-option.ts
Normal file
14
client/src/app/shared/models/poll/base-option.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { BaseDecimalModel } from '../base/base-decimal-model';
|
||||||
|
|
||||||
|
export abstract class BaseOption<T> extends BaseDecimalModel<T> {
|
||||||
|
public id: number;
|
||||||
|
public yes: number;
|
||||||
|
public no: number;
|
||||||
|
public abstain: number;
|
||||||
|
public poll_id: number;
|
||||||
|
public voted_id: number[];
|
||||||
|
|
||||||
|
protected getDecimalFields(): string[] {
|
||||||
|
return ['yes', 'no', 'abstain'];
|
||||||
|
}
|
||||||
|
}
|
114
client/src/app/shared/models/poll/base-poll.ts
Normal file
114
client/src/app/shared/models/poll/base-poll.ts
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import { BaseDecimalModel } from '../base/base-decimal-model';
|
||||||
|
import { BaseOption } from './base-option';
|
||||||
|
|
||||||
|
export enum PollColor {
|
||||||
|
yes = '#4caf50',
|
||||||
|
no = '#cc6c5b',
|
||||||
|
abstain = '#a6a6a6',
|
||||||
|
votesvalid = '#e2e2e2',
|
||||||
|
votesinvalid = '#e2e2e2',
|
||||||
|
votescast = '#e2e2e2'
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum PollState {
|
||||||
|
Created = 1,
|
||||||
|
Started,
|
||||||
|
Finished,
|
||||||
|
Published
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum PollType {
|
||||||
|
Analog = 'analog',
|
||||||
|
Named = 'named',
|
||||||
|
Pseudoanonymous = 'pseudoanonymous'
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum MajorityMethod {
|
||||||
|
Simple = 'simple',
|
||||||
|
TwoThirds = 'two_thirds',
|
||||||
|
ThreeQuarters = 'three_quarters',
|
||||||
|
Disabled = 'disabled'
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum PercentBase {
|
||||||
|
YN = 'YN',
|
||||||
|
YNA = 'YNA',
|
||||||
|
Valid = 'valid',
|
||||||
|
Cast = 'cast',
|
||||||
|
Disabled = 'disabled'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VOTE_MAJORITY = -1;
|
||||||
|
export const VOTE_UNDOCUMENTED = -2;
|
||||||
|
export const LOWEST_VOTE_VALUE = VOTE_UNDOCUMENTED;
|
||||||
|
|
||||||
|
export abstract class BasePoll<
|
||||||
|
T = any,
|
||||||
|
O extends BaseOption<any> = any,
|
||||||
|
PM extends string = string,
|
||||||
|
PB extends string = string
|
||||||
|
> extends BaseDecimalModel<T> {
|
||||||
|
public state: PollState;
|
||||||
|
public type: PollType;
|
||||||
|
public title: string;
|
||||||
|
public votesvalid: number;
|
||||||
|
public votesinvalid: number;
|
||||||
|
public votescast: number;
|
||||||
|
public groups_id: number[];
|
||||||
|
public majority_method: MajorityMethod;
|
||||||
|
public user_has_voted: boolean;
|
||||||
|
|
||||||
|
public pollmethod: PM;
|
||||||
|
public onehundred_percent_base: PB;
|
||||||
|
|
||||||
|
public get isCreated(): boolean {
|
||||||
|
return this.state === PollState.Created;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get isStarted(): boolean {
|
||||||
|
return this.state === PollState.Started;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get isFinished(): boolean {
|
||||||
|
return this.state === PollState.Finished;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get isPublished(): boolean {
|
||||||
|
return this.state === PollState.Published;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get isPercentBaseCast(): boolean {
|
||||||
|
return this.onehundred_percent_base === PercentBase.Cast;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get isAnalog(): boolean {
|
||||||
|
return this.type === PollType.Analog;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get isNamed(): boolean {
|
||||||
|
return this.type === PollType.Named;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get isAnon(): boolean {
|
||||||
|
return this.type === PollType.Pseudoanonymous;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get isEVoting(): boolean {
|
||||||
|
return this.isNamed || this.isAnon;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if the state is finished or published
|
||||||
|
*/
|
||||||
|
public get stateHasVotes(): boolean {
|
||||||
|
return this.isFinished || this.isPublished;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get nextState(): PollState {
|
||||||
|
return this.state + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getDecimalFields(): string[] {
|
||||||
|
return ['votesvalid', 'votesinvalid', 'votescast'];
|
||||||
|
}
|
||||||
|
}
|
32
client/src/app/shared/models/poll/base-vote.ts
Normal file
32
client/src/app/shared/models/poll/base-vote.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { BaseDecimalModel } from '../base/base-decimal-model';
|
||||||
|
|
||||||
|
export type VoteValue = 'Y' | 'N' | 'A';
|
||||||
|
|
||||||
|
export const VoteValueVerbose = {
|
||||||
|
Y: 'Yes',
|
||||||
|
N: 'No',
|
||||||
|
A: 'Abstain'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GeneralValueVerbose = {
|
||||||
|
votesvalid: 'Valid votes',
|
||||||
|
votesinvalid: 'Invalid votes',
|
||||||
|
votescast: 'Total votes cast',
|
||||||
|
votesno: 'Votes No',
|
||||||
|
votesabstain: 'Votes abstain'
|
||||||
|
};
|
||||||
|
|
||||||
|
export abstract class BaseVote<T = any> extends BaseDecimalModel<T> {
|
||||||
|
public weight: number;
|
||||||
|
public value: VoteValue;
|
||||||
|
public option_id: number;
|
||||||
|
public user_id?: number;
|
||||||
|
|
||||||
|
public get valueVerbose(): string {
|
||||||
|
return VoteValueVerbose[this.value];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getDecimalFields(): string[] {
|
||||||
|
return ['weight'];
|
||||||
|
}
|
||||||
|
}
|
20
client/src/app/shared/pipes/parse-poll-number.pipe.spec.ts
Normal file
20
client/src/app/shared/pipes/parse-poll-number.pipe.spec.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { inject, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||||
|
|
||||||
|
import { ParsePollNumberPipe } from './parse-poll-number.pipe';
|
||||||
|
|
||||||
|
describe('ParsePollNumberPipe', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [TranslateModule.forRoot()],
|
||||||
|
declarations: [ParsePollNumberPipe]
|
||||||
|
});
|
||||||
|
TestBed.compileComponents();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('create an instance', inject([TranslateService], (translate: TranslateService) => {
|
||||||
|
const pipe = new ParsePollNumberPipe(translate);
|
||||||
|
expect(pipe).toBeTruthy();
|
||||||
|
}));
|
||||||
|
});
|
24
client/src/app/shared/pipes/parse-poll-number.pipe.ts
Normal file
24
client/src/app/shared/pipes/parse-poll-number.pipe.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { Pipe, PipeTransform } from '@angular/core';
|
||||||
|
|
||||||
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
|
|
||||||
|
import { VOTE_MAJORITY, VOTE_UNDOCUMENTED } from '../models/poll/base-poll';
|
||||||
|
|
||||||
|
@Pipe({
|
||||||
|
name: 'parsePollNumber'
|
||||||
|
})
|
||||||
|
export class ParsePollNumberPipe implements PipeTransform {
|
||||||
|
public constructor(private translate: TranslateService) {}
|
||||||
|
|
||||||
|
public transform(value: number): number | string {
|
||||||
|
const input = Math.trunc(value);
|
||||||
|
switch (input) {
|
||||||
|
case VOTE_MAJORITY:
|
||||||
|
return this.translate.instant('majority');
|
||||||
|
case VOTE_UNDOCUMENTED:
|
||||||
|
return this.translate.instant('undocumented');
|
||||||
|
default:
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,8 @@
|
|||||||
|
import { PollKeyVerbosePipe } from './poll-key-verbose.pipe';
|
||||||
|
|
||||||
|
describe('PollKeyVerbosePipe', () => {
|
||||||
|
it('create an instance', () => {
|
||||||
|
const pipe = new PollKeyVerbosePipe();
|
||||||
|
expect(pipe).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
26
client/src/app/shared/pipes/poll-key-verbose.pipe.ts
Normal file
26
client/src/app/shared/pipes/poll-key-verbose.pipe.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { Pipe, PipeTransform } from '@angular/core';
|
||||||
|
|
||||||
|
const PollValues = {
|
||||||
|
votesvalid: 'Valid votes',
|
||||||
|
votesinvalid: 'Invalid votes',
|
||||||
|
votescast: 'Total votes cast',
|
||||||
|
votesno: 'Votes No',
|
||||||
|
votesabstain: 'Votes abstain',
|
||||||
|
yes: 'Yes',
|
||||||
|
no: 'No',
|
||||||
|
abstain: 'Abstain',
|
||||||
|
amount_global_abstain: 'General abstain',
|
||||||
|
amount_global_no: 'General no'
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pipe to transform a key from polls into a speaking word.
|
||||||
|
*/
|
||||||
|
@Pipe({
|
||||||
|
name: 'pollKeyVerbose'
|
||||||
|
})
|
||||||
|
export class PollKeyVerbosePipe implements PipeTransform {
|
||||||
|
public transform(value: string): string {
|
||||||
|
return PollValues[value] || value;
|
||||||
|
}
|
||||||
|
}
|
24
client/src/app/shared/pipes/poll-percent-base.pipe.spec.ts
Normal file
24
client/src/app/shared/pipes/poll-percent-base.pipe.spec.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { inject, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { E2EImportsModule } from 'e2e-imports.module';
|
||||||
|
|
||||||
|
import { AssignmentPollService } from 'app/site/assignments/services/assignment-poll.service';
|
||||||
|
import { MotionPollService } from 'app/site/motions/services/motion-poll.service';
|
||||||
|
import { PollPercentBasePipe } from './poll-percent-base.pipe';
|
||||||
|
|
||||||
|
describe('PollPercentBasePipe', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [E2EImportsModule]
|
||||||
|
});
|
||||||
|
TestBed.compileComponents();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('create an instance', inject(
|
||||||
|
[AssignmentPollService, MotionPollService],
|
||||||
|
(assignmentPollService: AssignmentPollService, motionPollService: MotionPollService) => {
|
||||||
|
const pipe = new PollPercentBasePipe(assignmentPollService, motionPollService);
|
||||||
|
expect(pipe).toBeTruthy();
|
||||||
|
}
|
||||||
|
));
|
||||||
|
});
|
44
client/src/app/shared/pipes/poll-percent-base.pipe.ts
Normal file
44
client/src/app/shared/pipes/poll-percent-base.pipe.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { Pipe, PipeTransform } from '@angular/core';
|
||||||
|
|
||||||
|
import { AssignmentPollService } from 'app/site/assignments/services/assignment-poll.service';
|
||||||
|
import { MotionPollService } from 'app/site/motions/services/motion-poll.service';
|
||||||
|
import { PollData } from 'app/site/polls/services/poll.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uses a number and a ViewPoll-object.
|
||||||
|
* Converts the number to the voting percent base using the
|
||||||
|
* given 100%-Base option in the poll object
|
||||||
|
*
|
||||||
|
* returns null if a percent calculation is not possible
|
||||||
|
* or the result is 0
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```html
|
||||||
|
* <span> {{ voteYes | pollPercentBase: poll }} </span>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
@Pipe({
|
||||||
|
name: 'pollPercentBase'
|
||||||
|
})
|
||||||
|
export class PollPercentBasePipe implements PipeTransform {
|
||||||
|
public constructor(
|
||||||
|
private assignmentPollService: AssignmentPollService,
|
||||||
|
private motionPollService: MotionPollService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public transform(value: number, poll: PollData): string | null {
|
||||||
|
// logic handles over the pollService to avoid circular dependencies
|
||||||
|
let voteValueInPercent: string;
|
||||||
|
if ((<any>poll).assignment) {
|
||||||
|
voteValueInPercent = this.assignmentPollService.getVoteValueInPercent(value, poll);
|
||||||
|
} else {
|
||||||
|
voteValueInPercent = this.motionPollService.getVoteValueInPercent(value, poll);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (voteValueInPercent) {
|
||||||
|
return `(${voteValueInPercent})`;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
8
client/src/app/shared/pipes/reverse.pipe.spec.ts
Normal file
8
client/src/app/shared/pipes/reverse.pipe.spec.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { ReversePipe } from './reverse.pipe';
|
||||||
|
|
||||||
|
describe('ReversePipe', () => {
|
||||||
|
it('create an instance', () => {
|
||||||
|
const pipe = new ReversePipe();
|
||||||
|
expect(pipe).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
20
client/src/app/shared/pipes/reverse.pipe.ts
Normal file
20
client/src/app/shared/pipes/reverse.pipe.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { Pipe, PipeTransform } from '@angular/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invert the order of arrays in templates
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```html
|
||||||
|
* <li *ngFor="let user of users | reverse">
|
||||||
|
* {{ user.name }} has the id: {{ user.id }}
|
||||||
|
* </li>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
@Pipe({
|
||||||
|
name: 'reverse'
|
||||||
|
})
|
||||||
|
export class ReversePipe implements PipeTransform {
|
||||||
|
public transform(value: any[]): any[] {
|
||||||
|
return value.slice().reverse();
|
||||||
|
}
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user