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 };
}
interface Set<T> {
equals(other: Set<T>): boolean;
}
/**
* Enhances the number object to calculate real modulo operations.
* (not remainder)
@ -96,6 +100,7 @@ export class AppComponent {
// change default JS functions
this.overloadArrayFunctions();
this.overloadSetFunctions();
this.overloadModulo();
// 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).
* 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>
<ng-container *ngIf="multiple && showChips">
<div #chipPlaceholder>

View File

@ -17,6 +17,7 @@ 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';
/**
@ -69,6 +70,9 @@ export class SearchValueSelectorComponent extends BaseFormControlComponent<Selec
@Input()
public noneTitle = '';
@Input()
public errorStateMatcher: ParentErrorStateMatcher;
/**
* The inputlist subject. Subscribes to it and updates the selector, if the subject
* changes its values.

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

@ -43,7 +43,7 @@
<mat-menu #triggerMenu="matMenu">
<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>
</button>
</ng-container>

View File

@ -7,6 +7,9 @@
<ng-container *ngSwitchCase="'choice'">
<ng-container *ngTemplateOutlet="select"></ng-container>
</ng-container>
<ng-container *ngSwitchCase="'groups'">
<ng-container *ngTemplateOutlet="groups"></ng-container>
</ng-container>
<ng-container *ngSwitchDefault>
<ng-container *ngTemplateOutlet="input"></ng-container>
</ng-container>
@ -29,6 +32,17 @@
</mat-select>
</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]">
<input
matInput

View File

@ -15,11 +15,14 @@ import { Title } from '@angular/platform-browser';
import { TranslateService } from '@ngx-translate/core';
import * as 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 { 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 { ViewGroup } from 'app/site/users/models/view-group';
import { ConfigItem } from '../config-list/config-list.component';
import { ViewConfig } from '../../models/view-config';
@ -115,6 +118,9 @@ export class ConfigFieldComponent extends BaseComponent implements OnInit, OnDes
@Output()
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
* to the current language chosen
@ -130,7 +136,8 @@ export class ConfigFieldComponent extends BaseComponent implements OnInit, OnDes
protected translate: TranslateService,
private formBuilder: FormBuilder,
private cd: ChangeDetectorRef,
public repo: ConfigRepositoryService
public repo: ConfigRepositoryService,
private groupRepo: GroupRepositoryService
) {
super(titleService, translate);
}
@ -139,6 +146,9 @@ export class ConfigFieldComponent extends BaseComponent implements OnInit, OnDes
* Sets up the form for this config field.
*/
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({
value: [''],
date: [''],
@ -226,6 +236,14 @@ export class ConfigFieldComponent extends BaseComponent implements OnInit, OnDes
const time = this.form.get('time').value;
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.cd.detectChanges();
}

View File

@ -1,6 +1,6 @@
import { ChartData } from 'app/shared/components/charts/charts.component';
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 { ProjectorElementBuildDeskriptor } from 'app/site/base/projectable';
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 {
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 {

View File

@ -20,6 +20,7 @@
</div>
<mat-chip
*ngIf="poll.getNextStates()"
disableRipple
class="poll-state active"
[matMenuTriggerFor]="triggerMenu"
@ -27,6 +28,14 @@
>
{{ poll.stateVerbose }}
</mat-chip>
<mat-chip
*ngIf="!poll.getNextStates()"
disableRipple
class="poll-state active"
[ngClass]="poll.stateVerbose.toLowerCase()"
>
{{ poll.stateVerbose }}
</mat-chip>
</div>
</div>
@ -105,7 +114,7 @@
<!-- Select state menu -->
<mat-menu #triggerMenu="matMenu">
<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>
</button>
</ng-container>

View File

@ -30,8 +30,15 @@ export abstract class BasePollComponent<V extends ViewBasePoll> extends BaseView
super(titleService, translate, matSnackBar);
}
public changeState(key: PollState): void {
key === PollState.Created ? this.repo.resetPoll(this._poll) : this.repo.changePollState(this._poll);
public async changeState(key: PollState): Promise<void> {
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 { 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 { PollType } from 'app/shared/models/poll/base-poll';
import { ViewAssignmentPoll } from 'app/site/assignments/models/view-assignment-poll';
@ -14,6 +15,7 @@ import { BaseViewComponent } from 'app/site/base/base-view';
import {
MajorityMethodVerbose,
PercentBaseVerbose,
PollClassType,
PollPropertyVerbose,
PollTypeVerbose,
ViewBasePoll
@ -85,7 +87,8 @@ export class PollFormComponent extends BaseViewComponent implements OnInit {
snackbar: MatSnackBar,
private fb: FormBuilder,
private groupRepo: GroupRepositoryService,
public pollService: PollService
public pollService: PollService,
private configService: ConfigService
) {
super(title, translate, snackbar);
this.initContentForm();
@ -104,6 +107,13 @@ export class PollFormComponent extends BaseViewComponent implements OnInit {
}
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 => {
if (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.
*/
private updatePollValues(data: { [key: string]: any }): void {
this.pollValues = Object.entries(data)
.filter(([key, _]) => key === 'type' || key === 'pollmethod')
.map(([key, value]) => [
this.pollService.getVerboseNameForKey(key),
this.pollService.getVerboseNameForValue(key, value as string)
this.pollValues = [
[this.pollService.getVerboseNameForKey('type'), this.pollService.getVerboseNameForValue('type', data.type)]
];
// show pollmethod only for assignment polls
if (this.data.pollClassType === PollClassType.Assignment) {
this.pollValues.push([
this.pollService.getVerboseNameForKey('pollmethod'),
this.pollService.getVerboseNameForValue('pollmethod', data.pollmethod)
]);
}
if (data.type !== 'analog') {
this.pollValues.push([
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 { ViewUser } from 'app/site/users/models/view-user';
export enum PollClassType {
Motion = 'motion',
Assignment = 'assignment'
}
export const PollClassTypeVerbose = {
motion: 'Motion poll',
assignment: 'Assignment poll'
@ -20,6 +25,13 @@ export const PollStateVerbose = {
4: 'Published'
};
export const PollStateChangeActionVerbose = {
1: 'Reset',
2: 'Start voting',
3: 'End voting',
4: 'Publish'
};
export const PollTypeVerbose = {
analog: 'Analog voting',
named: 'Named voting',
@ -55,8 +67,6 @@ export const PercentBaseVerbose = {
};
export abstract class ViewBasePoll<M extends BasePoll<M, any> = any> extends BaseProjectableViewModel<M> {
private _tableData: {}[] = [];
public get tableData(): {}[] {
if (!this._tableData.length) {
this._tableData = this.generateTableData();
@ -91,24 +101,25 @@ export abstract class ViewBasePoll<M extends BasePoll<M, any> = any> extends Bas
public get percentBaseVerbose(): string {
return PercentBaseVerbose[this.onehundred_percent_base];
}
/**
* 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;
}
private _tableData: {}[] = [];
public abstract readonly pollClassType: 'motion' | 'assignment';
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 getContentObject(): BaseViewModel;

View File

@ -5,7 +5,6 @@ from openslides.core.config import ConfigVariable
def get_config_variables():
"""
Generator which yields all config variables of this app.
They are grouped in 'Ballot and ballot papers' and 'PDF'. The generator has
to be evaluated during app loading (see apps.py).
"""
@ -50,6 +49,16 @@ def get_config_variables():
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
yield ConfigVariable(
name="assignments_pdf_title",

View File

@ -23,6 +23,7 @@ INPUT_TYPE_MAPPING = {
"datetimepicker": int,
"static": dict,
"translations": list,
"groups": list,
}
ALLOWED_NONE = ("datetimepicker",)
@ -143,6 +144,13 @@ class ConfigHandler:
):
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:
try:
validator(value)

View File

@ -418,3 +418,13 @@ def get_config_variables():
group="Voting",
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",
)