various improvements for polls

This commit is contained in:
Joshua Sangmeister 2020-01-30 16:59:46 +01:00 committed by FinnStutzenstein
parent bc54a6eb46
commit df1047fc76
15 changed files with 173 additions and 32 deletions

View File

@ -30,6 +30,10 @@ declare global {
mapToObject(f: (item: T) => { [key: string]: any }): { [key: string]: any }; mapToObject(f: (item: T) => { [key: string]: any }): { [key: string]: any };
} }
interface Set<T> {
equals(other: Set<T>): boolean;
}
/** /**
* Enhances the number object to calculate real modulo operations. * Enhances the number object to calculate real modulo operations.
* (not remainder) * (not remainder)
@ -96,6 +100,7 @@ export class AppComponent {
// change default JS functions // change default JS functions
this.overloadArrayFunctions(); this.overloadArrayFunctions();
this.overloadSetFunctions();
this.overloadModulo(); this.overloadModulo();
// Wait until the App reaches a stable state. // Wait until the App reaches a stable state.
@ -179,6 +184,27 @@ export class AppComponent {
}); });
} }
/**
* Adds some functions to Set.
*/
private overloadSetFunctions(): void {
// equals
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
});
}
/** /**
* Enhances the number object with a real modulo operation (not remainder). * Enhances the number object with a real modulo operation (not remainder).
* TODO: Remove this, if the remainder operation is changed to modulo. * TODO: Remove this, if the remainder operation is changed to modulo.

View File

@ -1,4 +1,4 @@
<mat-select [formControl]="contentForm" [multiple]="multiple" [panelClass]="{ 'os-search-value-selector': multiple }"> <mat-select [formControl]="contentForm" [multiple]="multiple" [panelClass]="{ 'os-search-value-selector': multiple }" [errorStateMatcher]="errorStateMatcher">
<ngx-mat-select-search [formControl]="searchValue"></ngx-mat-select-search> <ngx-mat-select-search [formControl]="searchValue"></ngx-mat-select-search>
<ng-container *ngIf="multiple && showChips"> <ng-container *ngIf="multiple && showChips">
<div #chipPlaceholder> <div #chipPlaceholder>

View File

@ -17,6 +17,7 @@ import { Observable } from 'rxjs';
import { auditTime } from 'rxjs/operators'; import { auditTime } from 'rxjs/operators';
import { BaseFormControlComponent } from 'app/shared/models/base/base-form-control'; import { BaseFormControlComponent } from 'app/shared/models/base/base-form-control';
import { ParentErrorStateMatcher } from 'app/shared/parent-error-state-matcher';
import { Selectable } from '../selectable'; import { Selectable } from '../selectable';
/** /**
@ -69,6 +70,9 @@ export class SearchValueSelectorComponent extends BaseFormControlComponent<Selec
@Input() @Input()
public noneTitle = ''; public noneTitle = '';
@Input()
public errorStateMatcher: ParentErrorStateMatcher;
/** /**
* The inputlist subject. Subscribes to it and updates the selector, if the subject * The inputlist subject. Subscribes to it and updates the selector, if the subject
* changes its values. * changes its values.

View File

@ -2,7 +2,7 @@ import { BaseModel } from '../base/base-model';
export interface ConfigChoice { export interface ConfigChoice {
value: string; value: string;
displayName: string; display_name: string;
} }
/** /**
@ -17,7 +17,8 @@ export type ConfigInputType =
| 'choice' | 'choice'
| 'datetimepicker' | 'datetimepicker'
| 'colorpicker' | 'colorpicker'
| 'translations'; | 'translations'
| 'groups';
export interface ConfigData { export interface ConfigData {
defaultValue: any; defaultValue: any;

View File

@ -43,7 +43,7 @@
<mat-menu #triggerMenu="matMenu"> <mat-menu #triggerMenu="matMenu">
<ng-container *ngIf="poll"> <ng-container *ngIf="poll">
<button mat-menu-item (click)="changeState(state.value)" *ngFor="let state of poll.nextStates | keyvalue"> <button mat-menu-item (click)="changeState(state.value)" *ngFor="let state of poll.getNextStates() | keyvalue">
<span translate>{{ state.key }}</span> <span translate>{{ state.key }}</span>
</button> </button>
</ng-container> </ng-container>

View File

@ -7,6 +7,9 @@
<ng-container *ngSwitchCase="'choice'"> <ng-container *ngSwitchCase="'choice'">
<ng-container *ngTemplateOutlet="select"></ng-container> <ng-container *ngTemplateOutlet="select"></ng-container>
</ng-container> </ng-container>
<ng-container *ngSwitchCase="'groups'">
<ng-container *ngTemplateOutlet="groups"></ng-container>
</ng-container>
<ng-container *ngSwitchDefault> <ng-container *ngSwitchDefault>
<ng-container *ngTemplateOutlet="input"></ng-container> <ng-container *ngTemplateOutlet="input"></ng-container>
</ng-container> </ng-container>
@ -29,6 +32,17 @@
</mat-select> </mat-select>
</ng-template> </ng-template>
<ng-template #groups ngProjectAs="os-search-value-selector">
<os-search-value-selector
formControlName="value"
[multiple]="true"
[showChips]="false"
[includeNone]="false"
[errorStateMatcher]="matcher"
[inputListValues]="groupObservable"
></os-search-value-selector>
</ng-template>
<ng-template #input ngProjectAs="[matInput]"> <ng-template #input ngProjectAs="[matInput]">
<input <input
matInput matInput

View File

@ -15,11 +15,14 @@ import { Title } from '@angular/platform-browser';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import * as moment from 'moment'; import * as moment from 'moment';
import { Moment } from 'moment'; import { Moment } from 'moment';
import { distinctUntilChanged } from 'rxjs/operators'; import { Observable } from 'rxjs';
import { distinctUntilChanged, filter } from 'rxjs/operators';
import { BaseComponent } from 'app/base.component'; import { BaseComponent } from 'app/base.component';
import { ConfigRepositoryService } from 'app/core/repositories/config/config-repository.service'; import { ConfigRepositoryService } from 'app/core/repositories/config/config-repository.service';
import { GroupRepositoryService } from 'app/core/repositories/users/group-repository.service';
import { ParentErrorStateMatcher } from 'app/shared/parent-error-state-matcher'; import { ParentErrorStateMatcher } from 'app/shared/parent-error-state-matcher';
import { ViewGroup } from 'app/site/users/models/view-group';
import { ConfigItem } from '../config-list/config-list.component'; import { ConfigItem } from '../config-list/config-list.component';
import { ViewConfig } from '../../models/view-config'; import { ViewConfig } from '../../models/view-config';
@ -115,6 +118,9 @@ export class ConfigFieldComponent extends BaseComponent implements OnInit, OnDes
@Output() @Output()
public update = new EventEmitter<ConfigItem>(); public update = new EventEmitter<ConfigItem>();
/** used by the groups config type */
public groupObservable: Observable<ViewGroup[]> = null;
/** /**
* The usual component constructor. datetime pickers will set their locale * The usual component constructor. datetime pickers will set their locale
* to the current language chosen * to the current language chosen
@ -130,7 +136,8 @@ export class ConfigFieldComponent extends BaseComponent implements OnInit, OnDes
protected translate: TranslateService, protected translate: TranslateService,
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
private cd: ChangeDetectorRef, private cd: ChangeDetectorRef,
public repo: ConfigRepositoryService public repo: ConfigRepositoryService,
private groupRepo: GroupRepositoryService
) { ) {
super(titleService, translate); super(titleService, translate);
} }
@ -139,6 +146,9 @@ export class ConfigFieldComponent extends BaseComponent implements OnInit, OnDes
* Sets up the form for this config field. * Sets up the form for this config field.
*/ */
public ngOnInit(): void { public ngOnInit(): void {
// filter out empty results in group observable. We never have no groups and it messes up the settings change detection
this.groupObservable = this.groupRepo.getViewModelListObservable().pipe(filter(groups => !!groups.length));
this.form = this.formBuilder.group({ this.form = this.formBuilder.group({
value: [''], value: [''],
date: [''], date: [''],
@ -226,6 +236,14 @@ export class ConfigFieldComponent extends BaseComponent implements OnInit, OnDes
const time = this.form.get('time').value; const time = this.form.get('time').value;
value = this.dateAndTimeToUnix(date, time); value = this.dateAndTimeToUnix(date, time);
} }
if (this.configItem.inputType === 'groups') {
// we have to check here explicitly if nothing changed because of the search value selector
const newS = new Set(value);
const oldS = new Set(this.configItem.value);
if (newS.equals(oldS)) {
return;
}
}
this.sendUpdate(value); this.sendUpdate(value);
this.cd.detectChanges(); this.cd.detectChanges();
} }

View File

@ -1,6 +1,6 @@
import { ChartData } from 'app/shared/components/charts/charts.component'; import { ChartData } from 'app/shared/components/charts/charts.component';
import { MotionPoll, MotionPollMethods } from 'app/shared/models/motions/motion-poll'; import { MotionPoll, MotionPollMethods } from 'app/shared/models/motions/motion-poll';
import { PollColor } from 'app/shared/models/poll/base-poll'; import { PollColor, PollState } from 'app/shared/models/poll/base-poll';
import { BaseViewModel } from 'app/site/base/base-view-model'; import { BaseViewModel } from 'app/site/base/base-view-model';
import { ProjectorElementBuildDeskriptor } from 'app/site/base/projectable'; import { ProjectorElementBuildDeskriptor } from 'app/site/base/projectable';
import { ViewMotionOption } from 'app/site/motions/models/view-motion-option'; import { ViewMotionOption } from 'app/site/motions/models/view-motion-option';
@ -75,6 +75,16 @@ export class ViewMotionPoll extends ViewBasePoll<MotionPoll> implements MotionPo
public get pollmethodVerbose(): string { public get pollmethodVerbose(): string {
return MotionPollMethodsVerbose[this.pollmethod]; return MotionPollMethodsVerbose[this.pollmethod];
} }
/**
* Override from base poll to skip started state in analog poll type
*/
public getNextStates(): { [key: number]: string } {
if (this.poll.type === 'analog' && this.state === PollState.Created) {
return null;
}
return super.getNextStates();
}
} }
export interface ViewMotionPoll extends MotionPoll { export interface ViewMotionPoll extends MotionPoll {

View File

@ -20,6 +20,7 @@
</div> </div>
<mat-chip <mat-chip
*ngIf="poll.getNextStates()"
disableRipple disableRipple
class="poll-state active" class="poll-state active"
[matMenuTriggerFor]="triggerMenu" [matMenuTriggerFor]="triggerMenu"
@ -27,6 +28,14 @@
> >
{{ poll.stateVerbose }} {{ poll.stateVerbose }}
</mat-chip> </mat-chip>
<mat-chip
*ngIf="!poll.getNextStates()"
disableRipple
class="poll-state active"
[ngClass]="poll.stateVerbose.toLowerCase()"
>
{{ poll.stateVerbose }}
</mat-chip>
</div> </div>
</div> </div>
@ -105,7 +114,7 @@
<!-- Select state menu --> <!-- Select state menu -->
<mat-menu #triggerMenu="matMenu"> <mat-menu #triggerMenu="matMenu">
<ng-container *ngIf="poll"> <ng-container *ngIf="poll">
<button mat-menu-item (click)="changeState(state.value)" *ngFor="let state of poll.nextStates | keyvalue"> <button mat-menu-item (click)="changeState(state.value)" *ngFor="let state of poll.getNextStates() | keyvalue">
<span translate>{{ state.key }}</span> <span translate>{{ state.key }}</span>
</button> </button>
</ng-container> </ng-container>

View File

@ -30,8 +30,15 @@ export abstract class BasePollComponent<V extends ViewBasePoll> extends BaseView
super(titleService, translate, matSnackBar); super(titleService, translate, matSnackBar);
} }
public changeState(key: PollState): void { public async changeState(key: PollState): Promise<void> {
key === PollState.Created ? this.repo.resetPoll(this._poll) : this.repo.changePollState(this._poll); if (key === PollState.Created) {
const title = this.translate.instant('Are you sure you want to reset this poll? All Votes will be lost.');
if (await this.promptService.open(title)) {
this.repo.resetPoll(this._poll).catch(this.raiseError);
}
} else {
this.repo.changePollState(this._poll).catch(this.raiseError);
}
} }
/** /**

View File

@ -7,6 +7,7 @@ import { TranslateService } from '@ngx-translate/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { GroupRepositoryService } from 'app/core/repositories/users/group-repository.service'; import { GroupRepositoryService } from 'app/core/repositories/users/group-repository.service';
import { ConfigService } from 'app/core/ui-services/config.service';
import { PercentBase } from 'app/shared/models/poll/base-poll'; import { PercentBase } from 'app/shared/models/poll/base-poll';
import { PollType } from 'app/shared/models/poll/base-poll'; import { PollType } from 'app/shared/models/poll/base-poll';
import { ViewAssignmentPoll } from 'app/site/assignments/models/view-assignment-poll'; import { ViewAssignmentPoll } from 'app/site/assignments/models/view-assignment-poll';
@ -14,6 +15,7 @@ import { BaseViewComponent } from 'app/site/base/base-view';
import { import {
MajorityMethodVerbose, MajorityMethodVerbose,
PercentBaseVerbose, PercentBaseVerbose,
PollClassType,
PollPropertyVerbose, PollPropertyVerbose,
PollTypeVerbose, PollTypeVerbose,
ViewBasePoll ViewBasePoll
@ -85,7 +87,8 @@ export class PollFormComponent extends BaseViewComponent implements OnInit {
snackbar: MatSnackBar, snackbar: MatSnackBar,
private fb: FormBuilder, private fb: FormBuilder,
private groupRepo: GroupRepositoryService, private groupRepo: GroupRepositoryService,
public pollService: PollService public pollService: PollService,
private configService: ConfigService
) { ) {
super(title, translate, snackbar); super(title, translate, snackbar);
this.initContentForm(); this.initContentForm();
@ -104,6 +107,13 @@ export class PollFormComponent extends BaseViewComponent implements OnInit {
} }
if (this.data) { if (this.data) {
if (!this.data.groups_id) {
if (this.data.collectionString === ViewAssignmentPoll.COLLECTIONSTRING) {
this.data.groups_id = this.configService.instant('assignment_poll_default_groups');
} else {
this.data.groups_id = this.configService.instant('motion_poll_default_groups');
}
}
Object.keys(this.contentForm.controls).forEach(key => { Object.keys(this.contentForm.controls).forEach(key => {
if (this.data[key]) { if (this.data[key]) {
this.contentForm.get(key).setValue(this.data[key]); this.contentForm.get(key).setValue(this.data[key]);
@ -150,12 +160,16 @@ export class PollFormComponent extends BaseViewComponent implements OnInit {
* @param data Passing the properties of the poll. * @param data Passing the properties of the poll.
*/ */
private updatePollValues(data: { [key: string]: any }): void { private updatePollValues(data: { [key: string]: any }): void {
this.pollValues = Object.entries(data) this.pollValues = [
.filter(([key, _]) => key === 'type' || key === 'pollmethod') [this.pollService.getVerboseNameForKey('type'), this.pollService.getVerboseNameForValue('type', data.type)]
.map(([key, value]) => [ ];
this.pollService.getVerboseNameForKey(key), // show pollmethod only for assignment polls
this.pollService.getVerboseNameForValue(key, value as string) if (this.data.pollClassType === PollClassType.Assignment) {
this.pollValues.push([
this.pollService.getVerboseNameForKey('pollmethod'),
this.pollService.getVerboseNameForValue('pollmethod', data.pollmethod)
]); ]);
}
if (data.type !== 'analog') { if (data.type !== 'analog') {
this.pollValues.push([ this.pollValues.push([
this.pollService.getVerboseNameForKey('groups'), this.pollService.getVerboseNameForKey('groups'),

View File

@ -8,6 +8,11 @@ import { ViewMotionOption } from 'app/site/motions/models/view-motion-option';
import { ViewGroup } from 'app/site/users/models/view-group'; import { ViewGroup } from 'app/site/users/models/view-group';
import { ViewUser } from 'app/site/users/models/view-user'; import { ViewUser } from 'app/site/users/models/view-user';
export enum PollClassType {
Motion = 'motion',
Assignment = 'assignment'
}
export const PollClassTypeVerbose = { export const PollClassTypeVerbose = {
motion: 'Motion poll', motion: 'Motion poll',
assignment: 'Assignment poll' assignment: 'Assignment poll'
@ -20,6 +25,13 @@ export const PollStateVerbose = {
4: 'Published' 4: 'Published'
}; };
export const PollStateChangeActionVerbose = {
1: 'Reset',
2: 'Start voting',
3: 'End voting',
4: 'Publish'
};
export const PollTypeVerbose = { export const PollTypeVerbose = {
analog: 'Analog voting', analog: 'Analog voting',
named: 'Named voting', named: 'Named voting',
@ -55,8 +67,6 @@ export const PercentBaseVerbose = {
}; };
export abstract class ViewBasePoll<M extends BasePoll<M, any> = any> extends BaseProjectableViewModel<M> { export abstract class ViewBasePoll<M extends BasePoll<M, any> = any> extends BaseProjectableViewModel<M> {
private _tableData: {}[] = [];
public get tableData(): {}[] { public get tableData(): {}[] {
if (!this._tableData.length) { if (!this._tableData.length) {
this._tableData = this.generateTableData(); this._tableData = this.generateTableData();
@ -91,24 +101,25 @@ export abstract class ViewBasePoll<M extends BasePoll<M, any> = any> extends Bas
public get percentBaseVerbose(): string { public get percentBaseVerbose(): string {
return PercentBaseVerbose[this.onehundred_percent_base]; return PercentBaseVerbose[this.onehundred_percent_base];
} }
private _tableData: {}[] = [];
/**
* returns a mapping "verbose_state" -> "state_id" for all valid next states
*/
public get nextStates(): { [key: number]: string } {
const next_state = (this.state % Object.keys(PollStateVerbose).length) + 1;
const states = {};
states[PollStateVerbose[next_state]] = next_state;
if (this.state === PollState.Finished) {
states[PollStateVerbose[PollState.Created]] = PollState.Created;
}
return states;
}
public abstract readonly pollClassType: 'motion' | 'assignment'; public abstract readonly pollClassType: 'motion' | 'assignment';
public canBeVotedFor: () => boolean; public canBeVotedFor: () => boolean;
/**
* returns a mapping "verbose_state" -> "state_id" for all valid next states
*/
public getNextStates(): { [key: number]: string } {
const next_state = (this.state % Object.keys(PollStateVerbose).length) + 1;
const states = {};
states[PollStateChangeActionVerbose[next_state]] = next_state;
if (this.state === PollState.Finished) {
states[PollStateChangeActionVerbose[PollState.Created]] = PollState.Created;
}
return states;
}
public abstract getSlide(): ProjectorElementBuildDeskriptor; public abstract getSlide(): ProjectorElementBuildDeskriptor;
public abstract getContentObject(): BaseViewModel; public abstract getContentObject(): BaseViewModel;

View File

@ -5,7 +5,6 @@ from openslides.core.config import ConfigVariable
def get_config_variables(): def get_config_variables():
""" """
Generator which yields all config variables of this app. Generator which yields all config variables of this app.
They are grouped in 'Ballot and ballot papers' and 'PDF'. The generator has They are grouped in 'Ballot and ballot papers' and 'PDF'. The generator has
to be evaluated during app loading (see apps.py). to be evaluated during app loading (see apps.py).
""" """
@ -50,6 +49,16 @@ def get_config_variables():
subgroup="Elections", subgroup="Elections",
) )
yield ConfigVariable(
name="assignment_poll_default_groups",
default_value=[],
input_type="groups",
label="Default groups for named and pseudoanonymous assignment polls",
weight=415,
group="Voting",
subgroup="Elections",
)
# PDF # PDF
yield ConfigVariable( yield ConfigVariable(
name="assignments_pdf_title", name="assignments_pdf_title",

View File

@ -23,6 +23,7 @@ INPUT_TYPE_MAPPING = {
"datetimepicker": int, "datetimepicker": int,
"static": dict, "static": dict,
"translations": list, "translations": list,
"groups": list,
} }
ALLOWED_NONE = ("datetimepicker",) ALLOWED_NONE = ("datetimepicker",)
@ -143,6 +144,13 @@ class ConfigHandler:
): ):
raise ConfigError("Invalid input. Choice does not match.") raise ConfigError("Invalid input. Choice does not match.")
if config_variable.input_type == "groups":
from ..users.models import Group
groups = set(group.id for group in Group.objects.all())
if not groups.issuperset(set(value)):
raise ConfigError("Invalid input. Chosen group does not exist.")
for validator in config_variable.validators: for validator in config_variable.validators:
try: try:
validator(value) validator(value)

View File

@ -418,3 +418,13 @@ def get_config_variables():
group="Voting", group="Voting",
subgroup="Motions", subgroup="Motions",
) )
yield ConfigVariable(
name="motion_poll_default_groups",
default_value=[],
input_type="groups",
label="Default groups for named and pseudoanonymous motion polls",
weight=430,
group="Voting",
subgroup="Motions",
)