commit
58483d7024
@ -85,7 +85,7 @@ matrix:
|
||||
- "3.6"
|
||||
script:
|
||||
- mypy openslides/ tests/
|
||||
- pytest --cov --cov-fail-under=72
|
||||
- pytest --cov --cov-fail-under=75
|
||||
|
||||
- name: "Server: Tests Python 3.7"
|
||||
language: python
|
||||
@ -96,7 +96,7 @@ matrix:
|
||||
- isort --check-only --diff --recursive openslides tests
|
||||
- black --check --diff --target-version py36 openslides tests
|
||||
- mypy openslides/ tests/
|
||||
- pytest --cov --cov-fail-under=72
|
||||
- pytest --cov --cov-fail-under=75
|
||||
|
||||
- name: "Server: Tests Python 3.8"
|
||||
language: python
|
||||
@ -107,7 +107,7 @@ matrix:
|
||||
- isort --check-only --diff --recursive openslides tests
|
||||
- black --check --diff --target-version py36 openslides tests
|
||||
- mypy openslides/ tests/
|
||||
- pytest --cov --cov-fail-under=72
|
||||
- pytest --cov --cov-fail-under=75
|
||||
|
||||
- name: "Client: Linting"
|
||||
language: node_js
|
||||
|
@ -31,79 +31,81 @@
|
||||
"cleanup-win": "npm run prettify-write & npm run lint-write"
|
||||
},
|
||||
"dependencies": {
|
||||
"@angular/animations": "~8.2.4",
|
||||
"@angular/animations": "^8.2.14",
|
||||
"@angular/cdk": "~8.1.4",
|
||||
"@angular/cdk-experimental": "~8.1.4",
|
||||
"@angular/common": "~8.2.4",
|
||||
"@angular/compiler": "~8.2.4",
|
||||
"@angular/core": "~8.2.4",
|
||||
"@angular/forms": "~8.2.4",
|
||||
"@angular/common": "^8.2.14",
|
||||
"@angular/compiler": "^8.2.14",
|
||||
"@angular/core": "^8.2.14",
|
||||
"@angular/forms": "^8.2.14",
|
||||
"@angular/material": "~8.1.4",
|
||||
"@angular/material-moment-adapter": "~8.1.4",
|
||||
"@angular/platform-browser": "~8.2.4",
|
||||
"@angular/platform-browser-dynamic": "~8.2.4",
|
||||
"@angular/pwa": "^0.803.1",
|
||||
"@angular/router": "~8.2.4",
|
||||
"@angular/service-worker": "~8.2.4",
|
||||
"@ngx-pwa/local-storage": "~8.2.1",
|
||||
"@angular/platform-browser": "^8.2.14",
|
||||
"@angular/platform-browser-dynamic": "^8.2.14",
|
||||
"@angular/pwa": "^0.803.23",
|
||||
"@angular/router": "^8.2.14",
|
||||
"@angular/service-worker": "^8.2.14",
|
||||
"@ngx-pwa/local-storage": "^8.2.4",
|
||||
"@ngx-translate/core": "~11.0.1",
|
||||
"@ngx-translate/http-loader": "^4.0.0",
|
||||
"@pebula/ngrid": "1.0.0-rc.16",
|
||||
"@pebula/ngrid-material": "1.0.0-rc.16",
|
||||
"@pebula/utils": "1.0.2",
|
||||
"@tinymce/tinymce-angular": "^3.2.0",
|
||||
"acorn": "^7.0.0",
|
||||
"core-js": "^3.2.1",
|
||||
"css-element-queries": "^1.2.1",
|
||||
"@tinymce/tinymce-angular": "^3.3.1",
|
||||
"acorn": "^7.1.0",
|
||||
"chart.js": "^2.9.2",
|
||||
"core-js": "^3.6.4",
|
||||
"css-element-queries": "^1.2.3",
|
||||
"exceljs": "1.15.0",
|
||||
"file-saver": "^2.0.2",
|
||||
"hammerjs": "^2.0.8",
|
||||
"lz4js": "^0.2.0",
|
||||
"material-icon-font": "git+https://github.com/petergng/materialIconFont.git",
|
||||
"moment": "^2.24.0",
|
||||
"ng2-charts": "^2.3.0",
|
||||
"ng2-pdf-viewer": "^5.3.4",
|
||||
"ngx-file-drop": "~8.0.7",
|
||||
"ngx-file-drop": "^8.0.8",
|
||||
"ngx-mat-select-search": "^1.8.0",
|
||||
"ngx-material-timepicker": "^4.0.2",
|
||||
"ngx-papaparse": "^4.0.2",
|
||||
"pdfmake": "^0.1.58",
|
||||
"po2json": "^1.0.0-alpha",
|
||||
"rxjs": "^6.5.2",
|
||||
"tinymce": "^5.0.14",
|
||||
"pdfmake": "^0.1.63",
|
||||
"po2json": "^1.0.0-beta-2",
|
||||
"rxjs": "^6.5.4",
|
||||
"tinymce": "^5.1.5",
|
||||
"tslib": "^1.10.0",
|
||||
"uuid": "^3.3.2",
|
||||
"uuid": "^3.3.3",
|
||||
"zone.js": "~0.9.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "~0.803.2",
|
||||
"@angular/cli": "~8.3.2",
|
||||
"@angular/compiler-cli": "~8.2.4",
|
||||
"@angular/language-service": "~8.2.4",
|
||||
"@angular-devkit/build-angular": "^0.803.23",
|
||||
"@angular/cli": "^8.3.23",
|
||||
"@angular/compiler-cli": "^8.2.14",
|
||||
"@angular/language-service": "^8.2.14",
|
||||
"@biesbjerg/ngx-translate-extract": "^3.0.5",
|
||||
"@compodoc/compodoc": "^1.1.8",
|
||||
"@types/jasmine": "^3.3.9",
|
||||
"@types/jasminewd2": "^2.0.6",
|
||||
"@types/node": "~12.7.2",
|
||||
"@types/yargs": "^13.0.0",
|
||||
"codelyzer": "^5.0.1",
|
||||
"husky": "^3.0.4",
|
||||
"@compodoc/compodoc": "^1.1.11",
|
||||
"@types/jasmine": "^3.5.0",
|
||||
"@types/jasminewd2": "^2.0.8",
|
||||
"@types/node": "^12.7.12",
|
||||
"@types/yargs": "^13.0.5",
|
||||
"codelyzer": "^5.2.1",
|
||||
"husky": "^3.1.0",
|
||||
"jasmine-core": "~3.4.0",
|
||||
"jasmine-spec-reporter": "~4.2.1",
|
||||
"karma": "^4.1.0",
|
||||
"karma": "^4.4.1",
|
||||
"karma-chrome-launcher": "~3.1.0",
|
||||
"karma-coverage-istanbul-reporter": "^2.0.5",
|
||||
"karma-coverage-istanbul-reporter": "^2.1.1",
|
||||
"karma-jasmine": "~2.0.1",
|
||||
"karma-jasmine-html-reporter": "^1.4.0",
|
||||
"karma-jasmine-html-reporter": "^1.5.1",
|
||||
"npm-license-crawler": "^0.2.1",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"prettier": "^1.19.1",
|
||||
"protractor": "^5.4.2",
|
||||
"resize-observer-polyfill": "^1.5.1",
|
||||
"source-map-explorer": "^2.0.1",
|
||||
"source-map-explorer": "^2.2.2",
|
||||
"ts-node": "~8.3.0",
|
||||
"tslint": "~5.19.0",
|
||||
"tsutils": "3.17.1",
|
||||
"typescript": "~3.5.3",
|
||||
"webpack-bundle-analyzer": "^3.3.2"
|
||||
"webpack-bundle-analyzer": "^3.6.0"
|
||||
}
|
||||
}
|
||||
|
@ -1,3 +1,4 @@
|
||||
.content {
|
||||
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 { ServertimeService } from './core/core-services/servertime.service';
|
||||
import { ThemeService } from './core/ui-services/theme.service';
|
||||
import { VotingBannerService } from './core/ui-services/voting-banner.service';
|
||||
|
||||
declare global {
|
||||
/**
|
||||
@ -25,6 +26,12 @@ declare global {
|
||||
*/
|
||||
interface Array<T> {
|
||||
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.
|
||||
prioritizeService: PrioritizeService,
|
||||
pingService: PingService,
|
||||
routingState: RoutingStateService
|
||||
routingState: RoutingStateService,
|
||||
votingBannerService: VotingBannerService // needed for initialisation
|
||||
) {
|
||||
// manually add the supported languages
|
||||
translate.addLangs(['en', 'de', 'cs', 'ru']);
|
||||
@ -91,8 +99,8 @@ export class AppComponent {
|
||||
translate.use(translate.getLangs().includes(browserLang) ? browserLang : 'en');
|
||||
|
||||
// change default JS functions
|
||||
this.overloadArrayToString();
|
||||
this.overloadFlatMap();
|
||||
this.overloadArrayFunctions();
|
||||
this.overloadSetFunctions();
|
||||
this.overloadModulo();
|
||||
|
||||
// Wait until the App reaches a stable state.
|
||||
@ -106,15 +114,7 @@ export class AppComponent {
|
||||
.subscribe(() => servertimeService.startScheduler());
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
private overloadArrayFunctions(): void {
|
||||
Object.defineProperty(Array.prototype, 'toString', {
|
||||
value: function(): string {
|
||||
let string = '';
|
||||
@ -135,13 +135,7 @@ export class AppComponent {
|
||||
},
|
||||
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', {
|
||||
value: function(o: any): any[] {
|
||||
const concatFunction = (x: any, y: any[]) => x.concat(y);
|
||||
@ -150,6 +144,54 @@ export class AppComponent {
|
||||
},
|
||||
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;
|
||||
repository = this.injector.get(entry.repository);
|
||||
repositories.push(repository);
|
||||
this.modelMapper.registerCollectionElement(
|
||||
entry.collectionString,
|
||||
entry.model,
|
||||
entry.viewModel,
|
||||
repository
|
||||
);
|
||||
this.modelMapper.registerCollectionElement(entry.model, entry.viewModel, repository);
|
||||
if (this.isSearchableModelEntry(entry)) {
|
||||
this.searchService.registerModel(
|
||||
entry.collectionString,
|
||||
entry.model.COLLECTIONSTRING,
|
||||
repository,
|
||||
entry.searchOrder,
|
||||
entry.openInNewTab
|
||||
@ -108,7 +103,7 @@ export class AppLoadService {
|
||||
// to check if the result of the contructor (the model instance) is really a searchable.
|
||||
if (!isSearchable(new entry.viewModel())) {
|
||||
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;
|
||||
|
@ -47,12 +47,11 @@ export class CollectionStringMapperService {
|
||||
* @param model
|
||||
*/
|
||||
public registerCollectionElement<V extends BaseViewModel<M>, M extends BaseModel>(
|
||||
collectionString: string,
|
||||
model: ModelConstructor<M>,
|
||||
viewModel: ViewModelConstructor<V>,
|
||||
repository: BaseRepository<V, M, TitleInformation>
|
||||
): void {
|
||||
this.collectionStringMapping[collectionString] = [model, viewModel, repository];
|
||||
this.collectionStringMapping[model.COLLECTIONSTRING] = [model, viewModel, repository];
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -1,7 +1,11 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
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.
|
||||
*
|
||||
@ -16,6 +20,16 @@ export class OfflineService {
|
||||
* BehaviorSubject to receive further status values.
|
||||
*/
|
||||
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
|
||||
@ -33,7 +47,7 @@ export class OfflineService {
|
||||
if (!this.offline.getValue()) {
|
||||
console.log('offline because whoami failed.');
|
||||
}
|
||||
this.offline.next(true);
|
||||
this.goOffline();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -43,7 +57,15 @@ export class OfflineService {
|
||||
if (!this.offline.getValue()) {
|
||||
console.log('offline because connection lost.');
|
||||
}
|
||||
this.goOffline();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to set offline status
|
||||
*/
|
||||
private goOffline(): void {
|
||||
this.offline.next(true);
|
||||
this.banner.addBanner(this.bannerDefinition);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -51,5 +73,6 @@ export class OfflineService {
|
||||
*/
|
||||
public goOnline(): void {
|
||||
this.offline.next(false);
|
||||
this.banner.removeBanner(this.bannerDefinition);
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
import { History } from 'app/shared/models/core/history';
|
||||
import { BannerDefinition, BannerService } from '../ui-services/banner.service';
|
||||
|
||||
/**
|
||||
* Holds information about OpenSlides. This is not included into other services to
|
||||
@ -14,6 +15,9 @@ export class OpenSlidesStatusService {
|
||||
* in History mode, saves the history point.
|
||||
*/
|
||||
private history: History = null;
|
||||
private bannerDefinition: BannerDefinition = {
|
||||
type: 'history'
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns, if OpenSlides is in the history mode.
|
||||
@ -27,7 +31,7 @@ export class OpenSlidesStatusService {
|
||||
/**
|
||||
* Ctor, does nothing.
|
||||
*/
|
||||
public constructor() {}
|
||||
public constructor(private banner: BannerService) {}
|
||||
|
||||
/**
|
||||
* Calls the getLocaleString function of the history object, if present.
|
||||
@ -44,6 +48,7 @@ export class OpenSlidesStatusService {
|
||||
*/
|
||||
public enterHistoryMode(history: History): void {
|
||||
this.history = history;
|
||||
this.banner.addBanner(this.bannerDefinition);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -51,5 +56,6 @@ export class OpenSlidesStatusService {
|
||||
*/
|
||||
public leaveHistoryMode(): void {
|
||||
this.history = null;
|
||||
this.banner.removeBanner(this.bannerDefinition);
|
||||
}
|
||||
}
|
||||
|
@ -98,6 +98,17 @@ export class RelationManagerService {
|
||||
viewModel: BaseViewModel,
|
||||
relation: RelationDefinition
|
||||
): 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;
|
||||
|
||||
const cacheProperty = '__' + property;
|
||||
@ -187,12 +198,24 @@ export class RelationManagerService {
|
||||
const _model: M = target.getModel();
|
||||
const relation = typeof property === 'string' ? relationsByKey[property] : null;
|
||||
|
||||
// try to find a getter for property
|
||||
if (property in target) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(viewModelCtor.prototype, property);
|
||||
// iterate over prototype chain
|
||||
let prototypeFunc = viewModelCtor,
|
||||
descriptor = null;
|
||||
do {
|
||||
descriptor = Object.getOwnPropertyDescriptor(prototypeFunc.prototype, property);
|
||||
if (!descriptor || !descriptor.get) {
|
||||
prototypeFunc = Object.getPrototypeOf(prototypeFunc);
|
||||
}
|
||||
} while (!(descriptor && descriptor.get) && prototypeFunc && prototypeFunc.prototype);
|
||||
|
||||
if (descriptor && descriptor.get) {
|
||||
// if getter was found in prototype chain, bind it with this proxy for right `this` access
|
||||
result = descriptor.get.bind(viewModel)();
|
||||
} else {
|
||||
result = target[property];
|
||||
// console.log(property, target);
|
||||
}
|
||||
} else if (property in _model) {
|
||||
result = _model[property];
|
||||
|
@ -7,7 +7,6 @@ import { MainMenuEntry } from '../core-services/main-menu.service';
|
||||
import { Searchable } from '../../site/base/searchable';
|
||||
|
||||
interface BaseModelEntry {
|
||||
collectionString: string;
|
||||
repository: Type<BaseRepository<any, any, any>>;
|
||||
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 { RelationDefinition } from 'app/core/definitions/relations';
|
||||
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 { AssignmentTitleInformation, ViewAssignment } from 'app/site/assignments/models/view-assignment';
|
||||
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 { ViewMediafile } from 'app/site/mediafiles/models/view-mediafile';
|
||||
import { ViewTag } from 'app/site/tags/models/view-tag';
|
||||
@ -35,6 +32,12 @@ const AssignmentRelations: RelationDefinition[] = [
|
||||
ownIdKey: 'attachments_id',
|
||||
ownKey: 'attachments',
|
||||
foreignViewModel: ViewMediafile
|
||||
},
|
||||
{
|
||||
type: 'O2M',
|
||||
ownKey: 'polls',
|
||||
foreignIdKey: 'assignment_id',
|
||||
foreignViewModel: ViewAssignmentPoll
|
||||
}
|
||||
];
|
||||
|
||||
@ -57,28 +60,6 @@ const AssignmentNestedModelDescriptors: NestedModelDescriptors = {
|
||||
getTitle: (viewAssignmentRelatedUser: ViewAssignmentRelatedUser) =>
|
||||
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
|
||||
> {
|
||||
private readonly restPath = '/rest/assignments/assignment/';
|
||||
private readonly restPollPath = '/rest/assignments/poll/';
|
||||
private readonly candidatureOtherPath = '/candidature_other/';
|
||||
private readonly candidatureSelfPath = '/candidature_self/';
|
||||
private readonly createPollPath = '/create_poll/';
|
||||
private readonly markElectedPath = '/mark_elected/';
|
||||
|
||||
/**
|
||||
* Constructor for the Assignment Repository.
|
||||
@ -179,87 +157,6 @@ export class AssignmentRepositoryService extends BaseIsAgendaItemAndListOfSpeake
|
||||
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
|
||||
*
|
||||
|
@ -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 { DataSendService } from '../core-services/data-send.service';
|
||||
import { DataStoreService } from '../core-services/data-store.service';
|
||||
import { HasViewModelListObservable } from '../definitions/has-view-model-list-observable';
|
||||
import { Identifiable } from '../../shared/models/base/identifiable';
|
||||
import { OnAfterAppsLoaded } from '../definitions/on-after-apps-loaded';
|
||||
import { RelationManagerService } from '../core-services/relation-manager.service';
|
||||
@ -30,7 +31,7 @@ export interface NestedModelDescriptors {
|
||||
}
|
||||
|
||||
export abstract class BaseRepository<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
|
||||
*/
|
||||
@ -42,8 +43,8 @@ export abstract class BaseRepository<V extends BaseViewModel & T, M extends Base
|
||||
protected viewModelSubjects: { [modelId: number]: BehaviorSubject<V> } = {};
|
||||
|
||||
/**
|
||||
* Observable subject for the whole list. These entries are unsorted an not piped through
|
||||
* autodTime. Just use this internally.
|
||||
* Observable subject for the whole list. These entries are unsorted and not piped through
|
||||
* auditTime. Just use this internally.
|
||||
*
|
||||
* It's used to debounce messages on the sortedViewModelListSubject
|
||||
*/
|
||||
@ -188,7 +189,7 @@ export abstract class BaseRepository<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.
|
||||
*/
|
||||
protected createViewModelWithTitles(model: M): V {
|
||||
@ -269,7 +270,7 @@ export abstract class BaseRepository<V extends BaseViewModel & T, M extends Base
|
||||
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;
|
||||
|
||||
|
@ -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 { TreeIdNode } from 'app/core/ui-services/tree.service';
|
||||
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 { ViewUnifiedChange, ViewUnifiedChangeType } from 'app/shared/models/motions/view-unified-change';
|
||||
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 { ViewMotionBlock } from 'app/site/motions/models/view-motion-block';
|
||||
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 { ViewStatuteParagraph } from 'app/site/motions/models/view-statute-paragraph';
|
||||
import { ViewSubmitter } from 'app/site/motions/models/view-submitter';
|
||||
@ -126,12 +126,17 @@ const MotionRelations: RelationDefinition[] = [
|
||||
ownKey: 'amendments',
|
||||
foreignViewModel: ViewMotion
|
||||
},
|
||||
// TMP:
|
||||
{
|
||||
type: 'M2O',
|
||||
ownIdKey: 'parent_id',
|
||||
ownKey: 'parent',
|
||||
foreignViewModel: ViewMotion
|
||||
},
|
||||
{
|
||||
type: 'O2M',
|
||||
foreignIdKey: 'motion_id',
|
||||
ownKey: 'polls',
|
||||
foreignViewModel: ViewMotionPoll
|
||||
}
|
||||
// Personal notes are dynamically added in the repo.
|
||||
];
|
||||
@ -844,46 +849,6 @@ export class MotionRepositoryService extends BaseIsAgendaItemAndListOfSpeakersCo
|
||||
.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
|
||||
*
|
||||
|
@ -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 { TranslateService } from '@ngx-translate/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
import { HttpService } from 'app/core/core-services/http.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');
|
||||
};
|
||||
|
||||
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.
|
||||
*
|
||||
@ -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();
|
||||
}
|
||||
|
||||
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) => {
|
||||
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 {
|
||||
const viewModel = super.createViewModelWithTitles(model);
|
||||
viewModel.getFullName = () => this.getFullName(viewModel);
|
||||
viewModel.getShortName = () => this.getShortName(viewModel);
|
||||
viewModel.getLevelAndNumber = () => this.getLevelAndNumber(viewModel);
|
||||
return viewModel;
|
||||
}
|
||||
|
||||
|
@ -99,7 +99,8 @@ _('Only main agenda items');
|
||||
_('Topics');
|
||||
_('Open requests to speak');
|
||||
|
||||
// Motions config strings
|
||||
// ** Motions **
|
||||
// config strings
|
||||
// subgroup general
|
||||
_('General');
|
||||
_('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');
|
||||
// subgroup 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');
|
||||
_('All valid ballots');
|
||||
@ -172,16 +173,13 @@ _('Number of all delegates');
|
||||
_('Number of all participants');
|
||||
_('Use the following custom number');
|
||||
_('Custom number of ballot papers');
|
||||
_('Voting');
|
||||
// subgroup PDF export
|
||||
_('PDF export');
|
||||
_('Title for PDF documents of motions');
|
||||
_('Preamble text for PDF documents of motions');
|
||||
_('Show submitters and recommendation/state in table of contents');
|
||||
_('Show checkbox to record decision');
|
||||
// misc motion strings
|
||||
_('Amendment');
|
||||
_('Statute amendment for');
|
||||
_('Statute paragraphs');
|
||||
|
||||
// motion workflow 1
|
||||
_('Simple Workflow');
|
||||
@ -224,46 +222,7 @@ _('Needs review');
|
||||
_('rejected (not authorized)');
|
||||
_('Reject (not authorized)');
|
||||
_('Rejection (not authorized)');
|
||||
// misc for motions
|
||||
_('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
|
||||
// motion workflow manager
|
||||
_('Recommendation label');
|
||||
_('Allow support');
|
||||
_('Allow create poll');
|
||||
@ -275,11 +234,60 @@ _('Show amendment in parent motion');
|
||||
_('Restrictions');
|
||||
_('Label color');
|
||||
_('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');
|
||||
_('Voting');
|
||||
_('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 **
|
||||
// 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) {
|
||||
return true;
|
||||
}
|
||||
} else if (typeof item[filter.property] === 'function') {
|
||||
return item[filter.property]() === option.condition;
|
||||
} else if (item[filter.property] === option.condition) {
|
||||
return true;
|
||||
} else if (item[filter.property].toString() === option.condition) {
|
||||
|
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>
|
||||
|
||||
<!-- Parent item -->
|
||||
<div *ngIf="itemObserver.value.length > 0">
|
||||
<os-search-value-selector
|
||||
ngDefaultControl
|
||||
[formControl]="form.get('agenda_parent_id')"
|
||||
[multiple]="false"
|
||||
[includeNone]="true"
|
||||
listname="{{ 'Parent agenda item' | translate }}"
|
||||
[inputListValues]="itemObserver"
|
||||
></os-search-value-selector>
|
||||
<div *ngIf="itemObserver.value.length > 0" [formGroup]="form">
|
||||
<mat-form-field>
|
||||
<os-search-value-selector
|
||||
formControlName="agenda_parent_id"
|
||||
[multiple]="false"
|
||||
[includeNone]="true"
|
||||
placeholder="{{ 'Parent agenda item' | translate }}"
|
||||
[inputListValues]="itemObserver"
|
||||
></os-search-value-selector>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
@ -1,12 +1,13 @@
|
||||
<div class="attachment-container" *ngIf="controlName">
|
||||
<os-search-value-selector
|
||||
class="selector"
|
||||
ngDefaultControl
|
||||
[multiple]="true"
|
||||
listname="{{ 'Attachments' | translate }}"
|
||||
[formControl]="controlName"
|
||||
[inputListValues]="mediaFileList"
|
||||
></os-search-value-selector>
|
||||
<div class="attachment-container" *ngIf="contentForm">
|
||||
<mat-form-field>
|
||||
<os-search-value-selector
|
||||
class="selector"
|
||||
[multiple]="true"
|
||||
placeholder="{{ 'Attachments' | translate }}"
|
||||
[formControl]="contentForm"
|
||||
[inputListValues]="mediaFileList"
|
||||
></os-search-value-selector>
|
||||
</mat-form-field>
|
||||
<button type="button" mat-icon-button (click)="openUploadDialog(uploadDialog)" *osPerms="'mediafiles.can_manage'">
|
||||
<mat-icon>cloud_upload</mat-icon>
|
||||
</button>
|
||||
|
@ -1,44 +1,62 @@
|
||||
import { Component, EventEmitter, Input, OnInit, Output, TemplateRef } from '@angular/core';
|
||||
import { ControlValueAccessor, FormControl } from '@angular/forms';
|
||||
import { MatDialog } from '@angular/material';
|
||||
import { FocusMonitor } from '@angular/cdk/a11y';
|
||||
import {
|
||||
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 { map } from 'rxjs/operators';
|
||||
|
||||
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 { ViewMediafile } from 'app/site/mediafiles/models/view-mediafile';
|
||||
|
||||
@Component({
|
||||
selector: 'os-attachment-control',
|
||||
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()
|
||||
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`
|
||||
*/
|
||||
public mediaFileList: Observable<ViewMediafile[]>;
|
||||
|
||||
/**
|
||||
* Default constructor
|
||||
*
|
||||
* @param dialogService Reference to the `MatDialog`
|
||||
* @param mediaService Reference for the `MediaFileRepositoryService`
|
||||
*/
|
||||
public constructor(private dialogService: MatDialog, private mediaService: MediafileRepositoryService) {}
|
||||
public get empty(): boolean {
|
||||
return !this.contentForm.value.length;
|
||||
}
|
||||
public get controlType(): string {
|
||||
return 'attachment-control';
|
||||
}
|
||||
|
||||
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
|
||||
@ -64,11 +82,9 @@ export class AttachmentControlComponent implements OnInit, ControlValueAccessor
|
||||
* @param fileIDs a list with the ids of the uploaded files
|
||||
*/
|
||||
public uploadSuccess(fileIDs: number[]): void {
|
||||
if (this.controlName) {
|
||||
const newValues = [...this.controlName.value, ...fileIDs];
|
||||
this.controlName.setValue(newValues);
|
||||
this.dialogService.closeAll();
|
||||
}
|
||||
const newValues = [...this.contentForm.value, ...fileIDs];
|
||||
this.updateForm(newValues);
|
||||
this.dialogService.closeAll();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -81,28 +97,15 @@ export class AttachmentControlComponent implements OnInit, ControlValueAccessor
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to write a new value to the form.
|
||||
* Satisfy the interface.
|
||||
*
|
||||
* @param value The new value for this form.
|
||||
* Declared as abstract in MatFormFieldControl and not required for this component
|
||||
*/
|
||||
public writeValue(value: any): void {
|
||||
if (value && this.controlName) {
|
||||
this.controlName.setValue(value);
|
||||
}
|
||||
public onContainerClick(event: MouseEvent): void {}
|
||||
|
||||
protected initializeForm(): void {
|
||||
this.contentForm = this.fb.control([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Function executed when the control's value changed.
|
||||
*
|
||||
* @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 {}
|
||||
protected updateForm(value: ViewMediafile[] | null): void {
|
||||
this.contentForm.setValue(value || []);
|
||||
}
|
||||
}
|
||||
|
@ -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)"
|
||||
/>
|
||||
</mat-form-field>
|
||||
<os-search-value-selector
|
||||
*ngIf="searchList"
|
||||
ngDefaultControl
|
||||
[formControl]="extensionFieldForm.get('list')"
|
||||
[fullWidth]="true"
|
||||
[inputListValues]="searchList"
|
||||
[listname]="searchListLabel"
|
||||
></os-search-value-selector>
|
||||
<mat-form-field *ngIf="searchList">
|
||||
<os-search-value-selector
|
||||
formControlName="list"
|
||||
[inputListValues]="searchList"
|
||||
[placeholder]="searchListLabel"
|
||||
></os-search-value-selector>
|
||||
</mat-form-field>
|
||||
|
||||
<button mat-button (click)="changeEditMode(true)">{{ 'Save' | translate }}</button>
|
||||
<button mat-button (click)="changeEditMode()">{{ 'Cancel' | translate }}</button>
|
||||
|
@ -14,8 +14,9 @@
|
||||
<!-- vScrollAuto () -->
|
||||
<pbl-ngrid
|
||||
[ngClass]="cssClasses"
|
||||
[vScrollFixed]="vScrollFixed"
|
||||
[showHeader]="!showFilterBar"
|
||||
[attr.vScrollFixed]="vScrollFixed !== -1 ? vScrollFixed : false"
|
||||
[attr.vScrollAuto]="vScrollFixed === -1"
|
||||
[showHeader]="!showFilterBar || !fullScreen"
|
||||
matCheckboxSelection="selection"
|
||||
[dataSource]="dataSource"
|
||||
[columns]="columnSet"
|
||||
|
@ -20,7 +20,7 @@ import { distinctUntilChanged, filter } from 'rxjs/operators';
|
||||
|
||||
import { OperatorService, Permission } from 'app/core/core-services/operator.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 { BaseSortListService } from 'app/core/ui-services/base-sort-list.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
|
||||
* 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
|
||||
* Double binds selected rows
|
||||
*
|
||||
@ -63,8 +63,9 @@ export interface ColumnRestriction {
|
||||
* @example
|
||||
* ```html
|
||||
* <os-list-view-table
|
||||
* [repo]="motionRepo"
|
||||
* [listObservableProvider]="motionRepo"
|
||||
* [filterService]="filterService"
|
||||
* [filterProps]="filterProps"
|
||||
* [sortService]="sortService"
|
||||
* [columns]="motionColumnDefinition"
|
||||
* [restricted]="restrictedColumns"
|
||||
@ -96,10 +97,16 @@ export class ListViewTableComponent<V extends BaseViewModel, M extends BaseModel
|
||||
private ngrid: PblNgridComponent;
|
||||
|
||||
/**
|
||||
* The required repository
|
||||
* The required repository (prioritized over listObservable)
|
||||
*/
|
||||
@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
|
||||
@ -109,7 +116,7 @@ export class ListViewTableComponent<V extends BaseViewModel, M extends BaseModel
|
||||
|
||||
/**
|
||||
* The currently active filter service for the list view. It is supposed to
|
||||
* be a FilterListService extendingFilterListService.
|
||||
* be a FilterListService extending FilterListService.
|
||||
*/
|
||||
@Input()
|
||||
public filterService: BaseFilterListService<V>;
|
||||
@ -187,12 +194,25 @@ export class ListViewTableComponent<V extends BaseViewModel, M extends BaseModel
|
||||
@Input()
|
||||
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.
|
||||
*/
|
||||
@Input()
|
||||
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.
|
||||
*/
|
||||
@ -211,8 +231,8 @@ export class ListViewTableComponent<V extends BaseViewModel, M extends BaseModel
|
||||
*/
|
||||
public get cssClasses(): CssClassDefinition {
|
||||
const defaultClasses = {
|
||||
'virtual-scroll-with-head-bar ngrid-hide-head': this.showFilterBar,
|
||||
'virtual-scroll-full-page': !this.showFilterBar,
|
||||
'virtual-scroll-with-head-bar ngrid-hide-head': this.fullScreen && this.showFilterBar,
|
||||
'virtual-scroll-full-page': this.fullScreen && !this.showFilterBar,
|
||||
multiselect: this.multiSelect
|
||||
};
|
||||
return Object.assign(this._cssClasses, defaultClasses);
|
||||
@ -322,15 +342,6 @@ export class ListViewTableComponent<V extends BaseViewModel, M extends BaseModel
|
||||
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
|
||||
* "hide" to hide individual columns
|
||||
@ -342,7 +353,7 @@ export class ListViewTableComponent<V extends BaseViewModel, M extends BaseModel
|
||||
hidden.push('selection');
|
||||
}
|
||||
|
||||
if (!this.alwaysShowMenu && !this.isMobile) {
|
||||
if ((!this.alwaysShowMenu && !this.isMobile) || !this.showMenu) {
|
||||
hidden.push('menu');
|
||||
}
|
||||
|
||||
@ -477,23 +488,26 @@ export class ListViewTableComponent<V extends BaseViewModel, M extends BaseModel
|
||||
* to the used search and filter services
|
||||
*/
|
||||
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) {
|
||||
// filtering and sorting
|
||||
this.filterService.initFilters(this.viewModelListObservable);
|
||||
this.filterService.initFilters(listObservable);
|
||||
this.sortService.initSorting(this.filterService.outputObservable);
|
||||
this.dataListObservable = this.sortService.outputObservable;
|
||||
} else if (this.filterService) {
|
||||
// only filter service
|
||||
this.filterService.initFilters(this.viewModelListObservable);
|
||||
this.filterService.initFilters(listObservable);
|
||||
this.dataListObservable = this.filterService.outputObservable;
|
||||
} else if (this.sortService) {
|
||||
// only sorting
|
||||
this.sortService.initSorting(this.viewModelListObservable);
|
||||
this.sortService.initSorting(listObservable);
|
||||
this.dataListObservable = this.sortService.outputObservable;
|
||||
} else {
|
||||
// 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
|
||||
if (this.filterProps && this.filterProps.length) {
|
||||
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 = '';
|
||||
// If the property is a function, call it.
|
||||
if (typeof item[prop] === 'function') {
|
||||
propertyAsString = '' + item[prop]();
|
||||
} else if (item[prop].constructor === Array) {
|
||||
propertyAsString = item[prop].join('');
|
||||
if (typeof currValue === 'function') {
|
||||
propertyAsString = '' + currValue();
|
||||
} else if (currValue.constructor === Array) {
|
||||
propertyAsString = currValue.join('');
|
||||
} else {
|
||||
propertyAsString = '' + item[prop];
|
||||
propertyAsString = '' + currValue;
|
||||
}
|
||||
|
||||
if (propertyAsString) {
|
||||
@ -655,7 +678,9 @@ 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.
|
||||
*/
|
||||
private changeRowHeight(): void {
|
||||
document.documentElement.style.setProperty('--pbl-height', this.vScrollFixed + 'px');
|
||||
if (this.vScrollFixed > 0) {
|
||||
document.documentElement.style.setProperty('--pbl-height', this.vScrollFixed + 'px');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -13,16 +13,17 @@
|
||||
</div>
|
||||
|
||||
<!-- Directory selector, if no external directory is provided -->
|
||||
<div *ngIf="showDirectorySelector">
|
||||
<os-search-value-selector
|
||||
ngDefaultControl
|
||||
[formControl]="directorySelectionForm.get('parent_id')"
|
||||
[multiple]="false"
|
||||
[includeNone]="true"
|
||||
[noneTitle]="'Base folder'"
|
||||
listname="{{ 'Parent directory' | translate }}"
|
||||
[inputListValues]="directoryBehaviorSubject"
|
||||
></os-search-value-selector>
|
||||
<div *ngIf="showDirectorySelector" [formGroup]="directorySelectionForm">
|
||||
<mat-form-field>
|
||||
<os-search-value-selector
|
||||
formControlName="parent_id"
|
||||
[multiple]="false"
|
||||
[includeNone]="true"
|
||||
[noneTitle]="'Base folder'"
|
||||
placeholder="{{ 'Parent directory' | translate }}"
|
||||
[inputListValues]="directoryBehaviorSubject"
|
||||
></os-search-value-selector>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@ -69,14 +70,15 @@
|
||||
<!-- Access groups -->
|
||||
<ng-container matColumnDef="access_groups">
|
||||
<th mat-header-cell *matHeaderCellDef><span translate>Access groups</span></th>
|
||||
<td mat-cell *matCellDef="let file">
|
||||
<os-search-value-selector
|
||||
ngDefaultControl
|
||||
[formControl]="file.form.get('access_groups_id')"
|
||||
[multiple]="true"
|
||||
listname="{{ 'Access groups' | translate }}"
|
||||
[inputListValues]="groupsBehaviorSubject"
|
||||
></os-search-value-selector>
|
||||
<td mat-cell *matCellDef="let file" [formGroup]="file.form">
|
||||
<mat-form-field>
|
||||
<os-search-value-selector
|
||||
formControlName="access_groups_id"
|
||||
[multiple]="true"
|
||||
placeholder="{{ 'Access groups' | translate }}"
|
||||
[inputListValues]="groupsBehaviorSubject"
|
||||
></os-search-value-selector>
|
||||
</mat-form-field>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
|
@ -90,7 +90,8 @@ export class MediaUploadContentComponent implements OnInit {
|
||||
|
||||
public get selectedDirectoryId(): number | null {
|
||||
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 {
|
||||
return this.directoryId;
|
||||
}
|
||||
@ -110,7 +111,7 @@ export class MediaUploadContentComponent implements OnInit {
|
||||
this.directoryBehaviorSubject = this.repo.getDirectoryBehaviorSubject();
|
||||
this.groupsBehaviorSubject = this.groupRepo.getViewModelListBehaviorSubject();
|
||||
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,16 +5,16 @@
|
||||
'message action'
|
||||
'bar action';
|
||||
grid-template-columns: auto min-content;
|
||||
}
|
||||
|
||||
.message {
|
||||
grid-area: message;
|
||||
}
|
||||
.message {
|
||||
grid-area: message;
|
||||
}
|
||||
|
||||
.bar {
|
||||
grid-area: bar;
|
||||
}
|
||||
.bar {
|
||||
grid-area: bar;
|
||||
}
|
||||
|
||||
.action {
|
||||
grid-area: action;
|
||||
.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]="formControl"
|
||||
placeholder="{{ listname | translate }}"
|
||||
[multiple]="multiple"
|
||||
#thisSelector
|
||||
>
|
||||
<ngx-mat-select-search ngModel (ngModelChange)="onSearch($event)"></ngx-mat-select-search>
|
||||
<div *ngIf="!multiple && includeNone">
|
||||
<mat-option [value]="null">
|
||||
{{ noneTitle | translate }}
|
||||
</mat-option>
|
||||
<mat-divider></mat-divider>
|
||||
<mat-select [formControl]="contentForm" [multiple]="multiple" [panelClass]="{ 'os-search-value-selector': multiple }" [errorStateMatcher]="errorStateMatcher">
|
||||
<ngx-mat-select-search [formControl]="searchValue"></ngx-mat-select-search>
|
||||
<ng-container *ngIf="multiple && showChips">
|
||||
<div #chipPlaceholder>
|
||||
<div class="os-search-value-selector-chip-container" [style.width]="width">
|
||||
<mat-chip-list class="chip-list" [selectable]="false">
|
||||
<mat-chip
|
||||
*ngFor="let item of selectedItems"
|
||||
[removable]="true"
|
||||
(removed)="removeItem(item.id)"
|
||||
[disableRipple]="true"
|
||||
>
|
||||
{{ item.getTitle() }}
|
||||
<mat-icon matChipRemove>cancel</mat-icon>
|
||||
</mat-chip>
|
||||
</mat-chip-list>
|
||||
</div>
|
||||
<div class="os-search-value-selector-chip-placeholder"></div>
|
||||
</div>
|
||||
<mat-option *ngFor="let selectedItem of getFilteredItems()" [value]="selectedItem.id">
|
||||
{{ selectedItem.getTitle() | translate }}
|
||||
</ng-container>
|
||||
<ng-container *ngIf="!multiple && includeNone">
|
||||
<mat-option>
|
||||
{{ noneTitle | translate }}
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-divider></mat-divider>
|
||||
</ng-container>
|
||||
<mat-option *ngFor="let selectedItem of getFilteredItems()" [value]="selectedItem.id">
|
||||
{{ selectedItem.getTitle() | translate }}
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
|
@ -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 { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { FormBuilder, FormControl } from '@angular/forms';
|
||||
import { FormBuilder } from '@angular/forms';
|
||||
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
|
||||
@ -43,10 +43,8 @@ describe('SearchValueSelectorComponent', () => {
|
||||
hostComponent.searchValueSelectorComponent.inputListValues = subject;
|
||||
|
||||
const formBuilder: FormBuilder = TestBed.get(FormBuilder);
|
||||
const formGroup = formBuilder.group({
|
||||
testArray: []
|
||||
});
|
||||
hostComponent.searchValueSelectorComponent.formControl = <FormControl>formGroup.get('testArray');
|
||||
const formControl = formBuilder.control([]);
|
||||
hostComponent.searchValueSelectorComponent.contentForm = formControl;
|
||||
|
||||
hostFixture.detectChanges();
|
||||
expect(hostComponent.searchValueSelectorComponent).toBeTruthy();
|
||||
|
@ -1,31 +1,40 @@
|
||||
import { ChangeDetectionStrategy, Component, Input, OnDestroy, ViewChild } from '@angular/core';
|
||||
import { FormControl } from '@angular/forms';
|
||||
import { MatSelect } from '@angular/material';
|
||||
import { FocusMonitor } from '@angular/cdk/a11y';
|
||||
import {
|
||||
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 { Observable, Subscription } from 'rxjs';
|
||||
import { Observable } from 'rxjs';
|
||||
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';
|
||||
|
||||
/**
|
||||
* 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:
|
||||
*
|
||||
* ### Usage of the selector:
|
||||
*
|
||||
* ngDefaultControl: https://stackoverflow.com/a/39053470
|
||||
*
|
||||
* ```html
|
||||
* <os-search-value-selector
|
||||
* ngDefaultControl
|
||||
* [multiple]="true"
|
||||
* placeholder="Placeholder"
|
||||
* [InputListValues]="myListValues"
|
||||
* [formControl]="myformcontrol">
|
||||
* [inputListValues]="myListValues"
|
||||
* formControlName="myformcontrol">
|
||||
* </os-search-value-selector>
|
||||
* ```
|
||||
*
|
||||
@ -35,23 +44,13 @@ import { Selectable } from '../selectable';
|
||||
selector: 'os-search-value-selector',
|
||||
templateUrl: './search-value-selector.component.html',
|
||||
styleUrls: ['./search-value-selector.component.scss'],
|
||||
providers: [{ provide: MatFormFieldControl, useExisting: SearchValueSelectorComponent }],
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class SearchValueSelectorComponent implements OnDestroy {
|
||||
/**
|
||||
* Saves the current subscription to _inputListSubject.
|
||||
*/
|
||||
private _inputListSubscription: Subscription = null;
|
||||
|
||||
/**
|
||||
* Value of the search input
|
||||
*/
|
||||
private searchValue = '';
|
||||
|
||||
/**
|
||||
* All items
|
||||
*/
|
||||
private selectableItems: Selectable[];
|
||||
export class SearchValueSelectorComponent extends BaseFormControlComponent<Selectable[]> {
|
||||
@ViewChild('chipPlaceholder', { static: false })
|
||||
public chipPlaceholder: ElementRef<HTMLElement>;
|
||||
|
||||
/**
|
||||
* Decide if this should be a single or multi-select-field
|
||||
@ -65,14 +64,14 @@ export class SearchValueSelectorComponent implements OnDestroy {
|
||||
@Input()
|
||||
public includeNone = false;
|
||||
|
||||
@Input()
|
||||
public showChips = true;
|
||||
|
||||
@Input()
|
||||
public noneTitle = '–';
|
||||
|
||||
/**
|
||||
* Boolean, whether the component should be rendered with full width.
|
||||
*/
|
||||
@Input()
|
||||
public fullWidth = false;
|
||||
public errorStateMatcher: ParentErrorStateMatcher;
|
||||
|
||||
/**
|
||||
* The inputlist subject. Subscribes to it and updates the selector, if the subject
|
||||
@ -83,55 +82,51 @@ export class SearchValueSelectorComponent implements OnDestroy {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
this.selectableItems = value;
|
||||
} else {
|
||||
// unsubscribe to old subscription.
|
||||
if (this._inputListSubscription) {
|
||||
this._inputListSubscription.unsubscribe();
|
||||
}
|
||||
this._inputListSubscription = value.pipe(auditTime(10)).subscribe(items => {
|
||||
this.selectableItems = items;
|
||||
if (this.formControl) {
|
||||
!!items && items.length > 0
|
||||
? this.formControl.enable({ emitEvent: false })
|
||||
: this.formControl.disable({ emitEvent: false });
|
||||
}
|
||||
});
|
||||
this.subscriptions.push(
|
||||
value.pipe(auditTime(10)).subscribe(items => {
|
||||
this.selectableItems = items;
|
||||
if (this.contentForm) {
|
||||
this.disabled = !items || (!!items && !items.length);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder of the List
|
||||
*/
|
||||
@Input()
|
||||
public listname: string;
|
||||
public searchValue: FormControl;
|
||||
|
||||
public get empty(): boolean {
|
||||
return Array.isArray(this.contentForm.value) ? !this.contentForm.value.length : !this.contentForm.value;
|
||||
}
|
||||
|
||||
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%';
|
||||
}
|
||||
|
||||
/**
|
||||
* Name of the Form
|
||||
* All items
|
||||
*/
|
||||
@Input()
|
||||
public formControl: FormControl;
|
||||
private selectableItems: Selectable[];
|
||||
|
||||
/**
|
||||
* 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 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[] {
|
||||
if (this.selectableItems) {
|
||||
const searchValue: string = this.searchValue.value.toLowerCase();
|
||||
return this.selectableItems.filter(item => {
|
||||
const idString = '' + item.id;
|
||||
const foundId =
|
||||
idString
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.indexOf(this.searchValue) !== -1;
|
||||
.indexOf(searchValue) !== -1;
|
||||
|
||||
if (foundId) {
|
||||
return true;
|
||||
}
|
||||
const searchableString = this.translate.instant(item.getTitle()).toLowerCase();
|
||||
return searchableString.indexOf(this.searchValue) > -1;
|
||||
|
||||
return (
|
||||
item
|
||||
.toString()
|
||||
.toLowerCase()
|
||||
.indexOf(searchValue) > -1
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to set the search value.
|
||||
*
|
||||
* @param searchValue the new value the user is searching for.
|
||||
*/
|
||||
public onSearch(searchValue: string): void {
|
||||
this.searchValue = searchValue.toLowerCase();
|
||||
public removeItem(itemId: number): void {
|
||||
const items = <number[]>this.contentForm.value;
|
||||
items.splice(
|
||||
items.findIndex(item => item === itemId),
|
||||
1
|
||||
);
|
||||
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) {
|
||||
console.log(error);
|
||||
console.error(error);
|
||||
}
|
||||
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')
|
||||
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 { AssignmentPollOption } from './assignment-poll-option';
|
||||
import { BaseModel } from '../base/base-model';
|
||||
import { CalculablePollKey } from 'app/site/polls/services/poll.service';
|
||||
import { AssignmentOption } from './assignment-option';
|
||||
import { BasePoll } from '../poll/base-poll';
|
||||
|
||||
export interface AssignmentPollWithoutNestedModels extends BaseModel<AssignmentPoll> {
|
||||
id: number;
|
||||
pollmethod: AssignmentPollMethod;
|
||||
description: string;
|
||||
published: boolean;
|
||||
votesvalid: number;
|
||||
votesno: number;
|
||||
votesabstain: number;
|
||||
votesinvalid: number;
|
||||
votescast: number;
|
||||
has_votes: boolean;
|
||||
assignment_id: number;
|
||||
export enum AssignmentPollMethod {
|
||||
YN = 'YN',
|
||||
YNA = 'YNA',
|
||||
Votes = 'votes'
|
||||
}
|
||||
|
||||
export enum AssignmentPollPercentBase {
|
||||
YN = 'YN',
|
||||
YNA = 'YNA',
|
||||
Votes = 'votes',
|
||||
Valid = 'valid',
|
||||
Cast = 'cast',
|
||||
Disabled = 'disabled'
|
||||
}
|
||||
|
||||
/**
|
||||
* Content of the 'polls' property of assignments
|
||||
* @ignore
|
||||
* Class representing a poll for an assignment.
|
||||
*/
|
||||
export class AssignmentPoll extends BaseModel<AssignmentPoll> {
|
||||
export class AssignmentPoll extends BasePoll<
|
||||
AssignmentPoll,
|
||||
AssignmentOption,
|
||||
AssignmentPollMethod,
|
||||
AssignmentPollPercentBase
|
||||
> {
|
||||
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 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) {
|
||||
// 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);
|
||||
}
|
||||
|
||||
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 user_id: number;
|
||||
public elected: boolean;
|
||||
public assignment_id: 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 { BaseModelWithAgendaItemAndListOfSpeakers } from '../base/base-model-with-agenda-item-and-list-of-speakers';
|
||||
|
||||
@ -8,9 +7,10 @@ export interface AssignmentWithoutNestedModels extends BaseModelWithAgendaItemAn
|
||||
description: string;
|
||||
open_posts: number;
|
||||
phase: number; // see Openslides constants
|
||||
poll_description_default: number;
|
||||
default_poll_description: string;
|
||||
tags_id: number[];
|
||||
attachments_id: number[];
|
||||
number_poll_candidates: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -22,18 +22,9 @@ export class Assignment extends BaseModelWithAgendaItemAndListOfSpeakers<Assignm
|
||||
|
||||
public id: number;
|
||||
public assignment_related_users: AssignmentRelatedUser[];
|
||||
public polls: AssignmentPoll[];
|
||||
|
||||
public constructor(input?: any) {
|
||||
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 {}
|
||||
|
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 {
|
||||
value: string;
|
||||
displayName: string;
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -17,7 +17,8 @@ export type ConfigInputType =
|
||||
| 'choice'
|
||||
| 'datetimepicker'
|
||||
| 'colorpicker'
|
||||
| 'translations';
|
||||
| 'translations'
|
||||
| 'groups';
|
||||
|
||||
export interface ConfigData {
|
||||
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.
|
||||
*/
|
||||
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 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;
|
||||
|
||||
/**
|
||||
* Needs to be completely optional because motion has (yet) the optional parameter 'polls'
|
||||
* Tries to cast incoming strings as numbers
|
||||
* @param input
|
||||
*/
|
||||
public constructor(input?: any) {
|
||||
if (typeof input === 'object') {
|
||||
Object.keys(input).forEach(key => {
|
||||
if (typeof input[key] === 'string') {
|
||||
input[key] = parseInt(input[key], 10);
|
||||
}
|
||||
});
|
||||
public get pollmethodFields(): CalculablePollKey[] {
|
||||
const ynField: CalculablePollKey[] = ['yes', 'no'];
|
||||
if (this.pollmethod === MotionPollMethod.YN) {
|
||||
return ynField;
|
||||
} else if (this.pollmethod === MotionPollMethod.YNA) {
|
||||
return ynField.concat(['abstain']);
|
||||
}
|
||||
super(input);
|
||||
}
|
||||
|
||||
public deserialize(input: any): void {
|
||||
Object.assign(this, input);
|
||||
public constructor(input?: any) {
|
||||
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 { MotionPoll } from './motion-poll';
|
||||
import { Submitter } from './submitter';
|
||||
|
||||
export interface MotionComment {
|
||||
@ -33,7 +32,6 @@ export interface MotionWithoutNestedModels extends BaseModelWithAgendaItemAndLis
|
||||
recommendation_extension: string;
|
||||
tags_id: number[];
|
||||
attachments_id: number[];
|
||||
polls: MotionPoll[];
|
||||
weight: number;
|
||||
sort_parent_id: number;
|
||||
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