various improvements for polls
This commit is contained in:
parent
bc54a6eb46
commit
df1047fc76
@ -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.
|
||||
|
@ -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>
|
||||
|
@ -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.
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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>
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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'),
|
||||
|
@ -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;
|
||||
|
@ -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",
|
||||
|
@ -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)
|
||||
|
@ -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",
|
||||
)
|
||||
|
Loading…
Reference in New Issue
Block a user