Merge pull request #5255 from OpenSlides/development

Voting 🎉
This commit is contained in:
Finn Stutzenstein 2020-03-17 07:48:00 +01:00 committed by GitHub
commit 58483d7024
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
358 changed files with 17505 additions and 6233 deletions

View File

@ -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

View File

@ -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"
}
}

View File

@ -1,3 +1,4 @@
.content {
flex: 1;
height: 100vh;
}

View File

@ -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
});
}
/**

View File

@ -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;

View File

@ -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];
}
/**

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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];

View File

@ -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>;
}

View File

@ -0,0 +1,5 @@
import { Observable } from 'rxjs';
export interface HasViewModelListObservable<V> {
getViewModelListObservable(): Observable<V[]>;
}

View File

@ -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();
});
});

View File

@ -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');
};
}

View File

@ -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();
});
});

View File

@ -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);
}
}

View File

@ -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
*

View File

@ -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();
});
});

View File

@ -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);
}
}

View File

@ -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;

View File

@ -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();
});
});

View File

@ -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');
};
}

View File

@ -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();
});
});

View File

@ -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));
}
}

View File

@ -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
*

View File

@ -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();
});
});

View File

@ -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');
};
}

View File

@ -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)));
}
}

View File

@ -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;
}

View File

@ -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)

View 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();
});
});

View 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);
}
}
}

View File

@ -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) {

View 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);
}
}
}
}

View File

@ -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();
}));
});

View File

@ -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 '';
}
}
}

View File

@ -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();
});
});

View 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;
}
}

View 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();
});
});

View 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];
}
}
}

View File

@ -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>

View File

@ -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>

View File

@ -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 || []);
}
}

View File

@ -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>

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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();
});
});

View 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));
}
}

View File

@ -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>

View File

@ -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 + '%');
}
}

View File

@ -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();
});
});

View 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';
}
}
}

View File

@ -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>

View File

@ -0,0 +1,10 @@
.check-input--container {
display: flex;
align-items: center;
justify-content: space-between;
& > * {
flex: 1;
padding: 0 5px;
}
}

View File

@ -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();
});
});

View File

@ -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);
}
}
}

View File

@ -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>

View File

@ -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"

View File

@ -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');
}
}
/**

View File

@ -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>

View File

@ -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
});
}

View File

@ -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>

View File

@ -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;
}
}

View File

@ -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();
});
});

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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>

View File

@ -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;
}

View File

@ -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();

View File

@ -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);
}
}

View File

@ -59,7 +59,7 @@ export class SlideContainerComponent extends BaseComponent {
}
if (error) {
console.log(error);
console.error(error);
}
return;
}

View File

@ -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>

View File

@ -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();
});
});

View File

@ -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 {}
}

View File

@ -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) {

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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 {}

View File

@ -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;

View 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);
}
}

View File

@ -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 {}

View 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);
}
}

View 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);
}
}

View File

@ -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;

View 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);
}
}

View File

@ -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);
}
}

View 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);
}
}

View File

@ -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;

View 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'];
}
}

View 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'];
}
}

View 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'];
}
}

View 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();
}));
});

View 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;
}
}
}

View File

@ -0,0 +1,8 @@
import { PollKeyVerbosePipe } from './poll-key-verbose.pipe';
describe('PollKeyVerbosePipe', () => {
it('create an instance', () => {
const pipe = new PollKeyVerbosePipe();
expect(pipe).toBeTruthy();
});
});

View 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;
}
}

View 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();
}
));
});

View 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;
}
}
}

View File

@ -0,0 +1,8 @@
import { ReversePipe } from './reverse.pipe';
describe('ReversePipe', () => {
it('create an instance', () => {
const pipe = new ReversePipe();
expect(pipe).toBeTruthy();
});
});

View 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