Merge pull request #4088 from MaximilianKrambach/motionpoll
motion polls
This commit is contained in:
commit
19a3fcebf3
12
client/src/app/core/poll.service.spec.ts
Normal file
12
client/src/app/core/poll.service.spec.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { PollService } from './poll.service';
|
||||
|
||||
describe('PollService', () => {
|
||||
beforeEach(() => TestBed.configureTestingModule({}));
|
||||
|
||||
it('should be created', () => {
|
||||
const service: PollService = TestBed.get(PollService);
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
124
client/src/app/core/poll.service.ts
Normal file
124
client/src/app/core/poll.service.ts
Normal file
@ -0,0 +1,124 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
/**
|
||||
* The possible keys of a poll object that represent numbers.
|
||||
* TODO Should be 'key of MotionPoll if type of key is number'
|
||||
* TODO: normalize MotionPoll model and other poll models
|
||||
* TODO: reuse more motion-poll-service stuff
|
||||
*/
|
||||
export type CalculablePollKey = 'votesvalid' | 'votesinvalid' | 'votescast' | 'yes' | 'no' | 'abstain';
|
||||
|
||||
/**
|
||||
* Shared service class for polls.
|
||||
* TODO: For now, motionPolls only. TODO See if reusable for assignment polls etc.
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class PollService {
|
||||
/**
|
||||
* The chosen and currently used base for percentage calculations. Is set by
|
||||
* the config service
|
||||
*/
|
||||
public percentBase: string;
|
||||
|
||||
/**
|
||||
* The default majority method (as set per config).
|
||||
*/
|
||||
public defaultMajorityMethod: string;
|
||||
|
||||
/**
|
||||
* An array of value - label pairs for special value signifiers.
|
||||
* TODO: Should be given by the server, and editable. For now: hard coded
|
||||
*/
|
||||
private _specialPollVotes: [number, string][] = [[-1, 'Majority'], [-2, 'Undocumented']];
|
||||
|
||||
/**
|
||||
* getter for the special votes
|
||||
*
|
||||
* @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() {}
|
||||
|
||||
/**
|
||||
* TODO not implemented yet. Should print the ballots for a motion poll,
|
||||
* depending on the motion and on the configuration
|
||||
*/
|
||||
public printBallots(): void {
|
||||
console.log('TODO: Ballot printing Not yet implemented');
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an icon for a Poll Key
|
||||
*
|
||||
* @param key
|
||||
* @returns a string for material-icons to represent the icon for
|
||||
* this key(e.g. yes: positiv sign, no: negative sign)
|
||||
*/
|
||||
public getIcon(key: CalculablePollKey): string {
|
||||
switch (key) {
|
||||
case 'yes':
|
||||
return 'thumb_up';
|
||||
case 'no':
|
||||
return 'thumb_down';
|
||||
case 'abstain':
|
||||
return 'not_interested';
|
||||
// case 'votescast':
|
||||
// sum
|
||||
case 'votesvalid':
|
||||
return 'check';
|
||||
case 'votesinvalid':
|
||||
return 'cancel';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a label for a poll Key
|
||||
*
|
||||
* @returns A short descriptive name for the poll keys
|
||||
*/
|
||||
public getLabel(key: CalculablePollKey): string {
|
||||
switch (key) {
|
||||
case 'yes':
|
||||
return 'Yes';
|
||||
case 'no':
|
||||
return 'No';
|
||||
case 'abstain':
|
||||
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
|
||||
*
|
||||
* @param value
|
||||
* @returns the label for a non-positive value, according to
|
||||
* {@link specialPollVotes}. Positive values will return as string
|
||||
* representation of themselves
|
||||
*/
|
||||
public getSpecialLabel(value: number): string {
|
||||
if (value >= 0) {
|
||||
return value.toString();
|
||||
}
|
||||
const vote = this.specialPollVotes.find(special => special[0] === value);
|
||||
return vote ? vote[1] : 'Undocumented special (negative) value';
|
||||
}
|
||||
}
|
36
client/src/app/shared/models/motions/motion-poll.ts
Normal file
36
client/src/app/shared/models/motions/motion-poll.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { Deserializer } from '../base/deserializer';
|
||||
|
||||
/**
|
||||
* Class representing a poll for a motion.
|
||||
*/
|
||||
export class MotionPoll extends Deserializer {
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
super(input);
|
||||
}
|
||||
|
||||
public deserialize(input: any): void {
|
||||
Object.assign(this, input);
|
||||
}
|
||||
}
|
@ -3,6 +3,7 @@ import { MotionLog } from './motion-log';
|
||||
import { MotionComment } from './motion-comment';
|
||||
import { AgendaBaseModel } from '../base/agenda-base-model';
|
||||
import { SearchRepresentation } from '../../../core/services/search.service';
|
||||
import { MotionPoll } from './motion-poll';
|
||||
|
||||
/**
|
||||
* Representation of Motion.
|
||||
@ -35,7 +36,7 @@ export class Motion extends AgendaBaseModel {
|
||||
public recommendation_extension: string;
|
||||
public tags_id: number[];
|
||||
public attachments_id: number[];
|
||||
public polls: Object[];
|
||||
public polls: MotionPoll[];
|
||||
public agenda_item_id: number;
|
||||
public log_messages: MotionLog[];
|
||||
public weight: number;
|
||||
|
@ -304,6 +304,16 @@
|
||||
<h4 translate>Origin</h4>
|
||||
{{ motion.origin }}
|
||||
</div>
|
||||
|
||||
<!-- motion polls -->
|
||||
<div *ngIf="!editMotion">
|
||||
<os-motion-poll *ngFor="let poll of motion.motion.polls; let i = index" [rawPoll]="poll" [pollIndex]="i">
|
||||
</os-motion-poll>
|
||||
<button mat-button *ngIf="perms.isAllowed('createpoll', motion)" (click)="createPoll()">
|
||||
<mat-icon class="main-nav-color">poll</mat-icon>
|
||||
<span translate>Create poll</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
@ -601,16 +611,28 @@
|
||||
<!-- Line number Menu -->
|
||||
<mat-menu #lineNumberingMenu="matMenu">
|
||||
<div *ngIf="motion">
|
||||
<button mat-menu-item translate (click)="setLineNumberingMode(LineNumberingMode.None)"
|
||||
[ngClass]="{ selected: motion.lnMode === LineNumberingMode.None }">
|
||||
<button
|
||||
mat-menu-item
|
||||
translate
|
||||
(click)="setLineNumberingMode(LineNumberingMode.None)"
|
||||
[ngClass]="{ selected: motion.lnMode === LineNumberingMode.None }"
|
||||
>
|
||||
none
|
||||
</button>
|
||||
<button mat-menu-item translate (click)="setLineNumberingMode(LineNumberingMode.Inside)"
|
||||
[ngClass]="{ selected: motion.lnMode === LineNumberingMode.Inside }">
|
||||
<button
|
||||
mat-menu-item
|
||||
translate
|
||||
(click)="setLineNumberingMode(LineNumberingMode.Inside)"
|
||||
[ngClass]="{ selected: motion.lnMode === LineNumberingMode.Inside }"
|
||||
>
|
||||
inline
|
||||
</button>
|
||||
<button mat-menu-item translate (click)="setLineNumberingMode(LineNumberingMode.Outside)"
|
||||
[ngClass]="{ selected: motion.lnMode === LineNumberingMode.Outside }">
|
||||
<button
|
||||
mat-menu-item
|
||||
translate
|
||||
(click)="setLineNumberingMode(LineNumberingMode.Outside)"
|
||||
[ngClass]="{ selected: motion.lnMode === LineNumberingMode.Outside }"
|
||||
>
|
||||
outside
|
||||
</button>
|
||||
</div>
|
||||
@ -618,12 +640,36 @@
|
||||
|
||||
<!-- Diff View Menu -->
|
||||
<mat-menu #changeRecoMenu="matMenu">
|
||||
<button mat-menu-item translate (click)="setChangeRecoMode(ChangeRecoMode.Original)"
|
||||
[ngClass]="{ selected: motion?.crMode === ChangeRecoMode.Original }">Original version</button>
|
||||
<button mat-menu-item translate (click)="setChangeRecoMode(ChangeRecoMode.Changed)"
|
||||
[ngClass]="{ selected: motion?.crMode === ChangeRecoMode.Changed }">Changed version</button>
|
||||
<button mat-menu-item translate (click)="setChangeRecoMode(ChangeRecoMode.Diff)"
|
||||
[ngClass]="{ selected: motion?.crMode === ChangeRecoMode.Diff }">Diff version</button>
|
||||
<button mat-menu-item translate (click)="setChangeRecoMode(ChangeRecoMode.Final)"
|
||||
[ngClass]="{ selected: motion?.crMode === ChangeRecoMode.Final }">Final version</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
translate
|
||||
(click)="setChangeRecoMode(ChangeRecoMode.Original)"
|
||||
[ngClass]="{ selected: motion?.crMode === ChangeRecoMode.Original }"
|
||||
>
|
||||
Original version
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
translate
|
||||
(click)="setChangeRecoMode(ChangeRecoMode.Changed)"
|
||||
[ngClass]="{ selected: motion?.crMode === ChangeRecoMode.Changed }"
|
||||
>
|
||||
Changed version
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
translate
|
||||
(click)="setChangeRecoMode(ChangeRecoMode.Diff)"
|
||||
[ngClass]="{ selected: motion?.crMode === ChangeRecoMode.Diff }"
|
||||
>
|
||||
Diff version
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
translate
|
||||
(click)="setChangeRecoMode(ChangeRecoMode.Final)"
|
||||
[ngClass]="{ selected: motion?.crMode === ChangeRecoMode.Final }"
|
||||
>
|
||||
Final version
|
||||
</button>
|
||||
</mat-menu>
|
||||
|
@ -259,3 +259,7 @@ span {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.main-nav-color {
|
||||
color: rgba(0, 0, 0, 0.54);
|
||||
}
|
||||
|
@ -921,4 +921,8 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit {
|
||||
public opCanEdit(): boolean {
|
||||
return this.op.hasPerms('motions.can_manage', 'motions.can_manage_metadata');
|
||||
}
|
||||
|
||||
public async createPoll(): Promise<void> {
|
||||
await this.repo.createPoll(this.motion);
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,20 @@
|
||||
<h2><span translate>Vote form</span></h2>
|
||||
<div class="meta-text">
|
||||
<span translate>Special values</span>:<br />
|
||||
<mat-chip>-1</mat-chip> =
|
||||
<span translate>majority</span><br />
|
||||
<mat-chip color="accent">-2</mat-chip> =
|
||||
<span translate>undocumented</span>
|
||||
</div>
|
||||
<div *ngFor="let key of pollKeys">
|
||||
<mat-form-field>
|
||||
<mat-label translate>{{ getLabel(key) | translate }}</mat-label>
|
||||
<input type="number" matInput [(ngModel)]="data[key]" />
|
||||
<!-- TODO mark required fields -->
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="submit-buttons">
|
||||
<button mat-button (click)="cancel()" translate>Cancel</button>
|
||||
<button mat-button (click)="submit()" translate>Save</button>
|
||||
</div>
|
||||
|
@ -0,0 +1,14 @@
|
||||
.submit-buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.meta-text {
|
||||
font-style: italic;
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
mat-chip {
|
||||
margin-left: 5px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
}
|
@ -0,0 +1,76 @@
|
||||
import { Component, Inject } from '@angular/core';
|
||||
import { MatDialogRef, MAT_DIALOG_DATA, MatSnackBar } from '@angular/material';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
|
||||
import { MotionPoll } from 'app/shared/models/motions/motion-poll';
|
||||
import { MotionPollService, CalculablePollKey } from '../../services/motion-poll.service';
|
||||
|
||||
/**
|
||||
* A dialog for updating the values of a poll.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'os-motion-poll-dialog',
|
||||
templateUrl: './motion-poll-dialog.component.html',
|
||||
styleUrls: ['./motion-poll-dialog.component.scss']
|
||||
})
|
||||
export class MotionPollDialogComponent {
|
||||
/**
|
||||
* List of accepted special non-numerical values from {@link PollService}
|
||||
*/
|
||||
public specialValues: [number, string][];
|
||||
|
||||
/**
|
||||
* Array of vote entries in this component
|
||||
*/
|
||||
public pollKeys: CalculablePollKey[];
|
||||
|
||||
/**
|
||||
* Constructor. Retrieves necessary metadata from the pollService,
|
||||
* injects the poll itself
|
||||
*/
|
||||
public constructor(
|
||||
public dialogRef: MatDialogRef<MotionPollDialogComponent>,
|
||||
@Inject(MAT_DIALOG_DATA) public data: MotionPoll,
|
||||
private matSnackBar: MatSnackBar,
|
||||
private translate: TranslateService,
|
||||
private pollService: MotionPollService
|
||||
) {
|
||||
this.pollKeys = this.pollService.pollValues;
|
||||
this.specialValues = this.pollService.specialPollVotes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the dialog, submitting nothing. Triggered by the cancel button and
|
||||
* default angular cancelling behavior
|
||||
*/
|
||||
public cancel(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* validates if 'yes', 'no' and 'abstain' have values, submits and closes
|
||||
* the dialog if successfull, else displays an error popup.
|
||||
* TODO better validation
|
||||
*/
|
||||
public submit(): void {
|
||||
if (this.data.yes === undefined || this.data.no === undefined || this.data.abstain === undefined) {
|
||||
this.matSnackBar.open(
|
||||
this.translate.instant('Please fill in all required values'),
|
||||
this.translate.instant('OK'),
|
||||
{
|
||||
duration: 1000
|
||||
}
|
||||
);
|
||||
} else {
|
||||
this.dialogRef.close(this.data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a label for a poll option
|
||||
* @param key poll option to be labeled
|
||||
*/
|
||||
public getLabel(key: CalculablePollKey): string {
|
||||
return this.pollService.getLabel(key);
|
||||
}
|
||||
}
|
@ -0,0 +1,67 @@
|
||||
<os-meta-text-block showActionRow="true">
|
||||
<ng-container class="meta-text-block-title">
|
||||
<span translate>Poll</span> <span *ngIf="pollIndex"> {{ pollIndex + 1 }}</span>
|
||||
</ng-container>
|
||||
<ng-container class="meta-text-block-content">
|
||||
<div *ngIf="poll.has_votes" class="on-transition-fade poll-result">
|
||||
<div *ngFor="let key of pollValues">
|
||||
<div class="poll-progress on-transition-fade" *ngIf="poll[key] !== undefined">
|
||||
<mat-icon class="main-nav-color" matTooltip="{{ getLabel(key) | translate }}"> {{ getIcon(key) }} </mat-icon>
|
||||
<div class="progress-container">
|
||||
<div>
|
||||
<span translate>{{ getLabel(key) }}</span>: {{ getNumber(key) }}
|
||||
<span *ngIf="!isAbstractValue(key)">({{ getPercent(key) }}%)</span>
|
||||
</div>
|
||||
<div *ngIf="!isAbstractValue(key)" class="poll-progress-bar">
|
||||
<mat-progress-bar
|
||||
mode="determinate"
|
||||
[value]="getPercent(key)"
|
||||
[ngClass]="getProgressBarColor(key)"
|
||||
>
|
||||
</mat-progress-bar>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr *ngIf="key ==='abstain'" flex />
|
||||
</div>
|
||||
<!-- quorum -->
|
||||
<div *ngIf="abstractPoll"><span translate>Quorum not calculable.</span></div>
|
||||
<div class="poll-quorum-line" *ngIf="!abstractPoll">
|
||||
<span>
|
||||
<span *ngIf="yesQuorum">
|
||||
<mat-icon color="warn" *ngIf="!quorumYesReached"> thumb_down </mat-icon>
|
||||
<mat-icon color="primary" *ngIf="quorumYesReached"> thumb_up </mat-icon>
|
||||
</span>
|
||||
<button mat-button [matMenuTriggerFor]="majorityMenu">
|
||||
<span translate>{{ getQuorumLabel() }}</span>
|
||||
<span *ngIf="majorityChoice !== 'disabled'">({{ yesQuorum }})</span>
|
||||
</button>
|
||||
<span *ngIf="majorityChoice !== 'disabled'">
|
||||
<span *ngIf="quorumYesReached" translate> reached.</span>
|
||||
<span *ngIf="!quorumYesReached" translate> not reached.</span>
|
||||
</span>
|
||||
<span *ngIf="majorityChoice === 'disabled'"
|
||||
> — <span translate>No quorum calculated</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-container class="meta-text-block-action-row" *osPerms="'motions.can_manage'">
|
||||
<button mat-icon-button class="main-nav-color" matTooltip="{{ 'Edit poll' | translate }}" (click)="editPoll()">
|
||||
<mat-icon inline>edit</mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button class="main-nav-color" matTooltip="{{ 'Print ballots' | translate }}" (click)="printBallots()">
|
||||
<mat-icon inline>local_printshop</mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button class="main-nav-color" matTooltip="{{ 'Delete poll' | translate }}" (click)="deletePoll()">
|
||||
<mat-icon inline>delete</mat-icon>
|
||||
</button>
|
||||
</ng-container>
|
||||
</os-meta-text-block>
|
||||
|
||||
<mat-menu #majorityMenu="matMenu">
|
||||
<button mat-menu-item *ngFor="let option of majorityChoices" (click)="majorityChoice = option.value">
|
||||
<span translate>{{ option.display_name }}</span>
|
||||
</button>
|
||||
</mat-menu>
|
@ -0,0 +1,60 @@
|
||||
::ng-deep .progress-green {
|
||||
.mat-progress-bar-fill::after {
|
||||
background-color: #4caf50;
|
||||
}
|
||||
.mat-progress-bar-buffer {
|
||||
background-color: #d5ecd5;
|
||||
}
|
||||
}
|
||||
|
||||
::ng-deep .progress-red {
|
||||
.mat-progress-bar-fill::after {
|
||||
background-color: #f44336;
|
||||
}
|
||||
.mat-progress-bar-buffer {
|
||||
background-color: #fcd2cf;
|
||||
}
|
||||
}
|
||||
|
||||
::ng-deep .progress-yellow {
|
||||
.mat-progress-bar-fill::after {
|
||||
background-color: #ffc107;
|
||||
}
|
||||
.mat-progress-bar-buffer {
|
||||
background-color: #fff0c4;
|
||||
}
|
||||
}
|
||||
|
||||
.poll-result {
|
||||
.poll-progress-bar {
|
||||
height: 5px;
|
||||
width: 100%;
|
||||
.mat-progress-bar {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
.poll-progress {
|
||||
display: flex;
|
||||
margin-bottom: 15px;
|
||||
margin-top: 15px;
|
||||
mat-icon {
|
||||
min-width: 40px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
.progress-container {
|
||||
width: 85%;
|
||||
}
|
||||
}
|
||||
}
|
||||
.main-nav-color {
|
||||
color: rgba(0, 0, 0, 0.54);
|
||||
}
|
||||
|
||||
.poll-quorum-line {
|
||||
display: flex;
|
||||
vertical-align: bottom;
|
||||
.mat-button {
|
||||
padding: 1px;
|
||||
}
|
||||
}
|
@ -0,0 +1,27 @@
|
||||
// import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
// import { MotionPollComponent } from './motion-poll.component';
|
||||
// import { E2EImportsModule } from 'e2e-imports.module';
|
||||
// import { MetaTextBlockComponent } from '../meta-text-block/meta-text-block.component';
|
||||
|
||||
describe('MotionPollComponent', () => {
|
||||
// TODO testing fails if personalNotesModule (also having the MetaTextBlockComponent)
|
||||
// is running its' test at the same time. One of the two tests fail, but run fine if tested
|
||||
// separately; so this is some async duplication stuff
|
||||
// let component: MotionPollComponent;
|
||||
// let fixture: ComponentFixture<MotionPollComponent>;
|
||||
// beforeEach(async(() => {
|
||||
// TestBed.configureTestingModule({
|
||||
// imports: [E2EImportsModule],
|
||||
// declarations: [MetaTextBlockComponent, MotionPollComponent]
|
||||
// }).compileComponents();
|
||||
// }));
|
||||
// beforeEach(() => {
|
||||
// fixture = TestBed.createComponent(MotionPollComponent);
|
||||
// component = fixture.componentInstance;
|
||||
// fixture.detectChanges();
|
||||
// });
|
||||
// it('should create', () => {
|
||||
// expect(component).toBeTruthy();
|
||||
// });
|
||||
});
|
@ -0,0 +1,248 @@
|
||||
import { Component, OnInit, Input } from '@angular/core';
|
||||
import { MatDialog } from '@angular/material';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
|
||||
import { CalculablePollKey } from 'app/core/poll.service';
|
||||
import { ConstantsService } from 'app/core/services/constants.service';
|
||||
import { LocalPermissionsService } from '../../services/local-permissions.service';
|
||||
import { MotionPoll } from 'app/shared/models/motions/motion-poll';
|
||||
import { MotionPollService } from '../../services/motion-poll.service';
|
||||
import { MotionPollDialogComponent } from './motion-poll-dialog.component';
|
||||
import { MotionRepositoryService } from '../../services/motion-repository.service';
|
||||
import { PromptService } from 'app/core/services/prompt.service';
|
||||
|
||||
/**
|
||||
* A component used to display and edit polls of a motion.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'os-motion-poll',
|
||||
templateUrl: './motion-poll.component.html',
|
||||
styleUrls: ['./motion-poll.component.scss']
|
||||
})
|
||||
export class MotionPollComponent implements OnInit {
|
||||
/**
|
||||
* A representation of all values of the current poll.
|
||||
*/
|
||||
public pollValues: CalculablePollKey[];
|
||||
|
||||
/**
|
||||
* The motion poll as coming from the server. Needs conversion of strings to numbers first
|
||||
* (see {@link ngOnInit})
|
||||
*/
|
||||
@Input()
|
||||
public rawPoll: any;
|
||||
|
||||
/**
|
||||
* (optional) number of poll iffor dispaly purpose
|
||||
*/
|
||||
@Input()
|
||||
public pollIndex: number;
|
||||
|
||||
/**
|
||||
* The current poll
|
||||
*/
|
||||
public poll: MotionPoll;
|
||||
|
||||
/**
|
||||
* The current choice for calulating a Quorum
|
||||
*/
|
||||
public majorityChoice: string;
|
||||
|
||||
/**
|
||||
* The constants available for calulating a quorum
|
||||
*/
|
||||
public majorityChoices: { display_name: string; value: string }[] = [];
|
||||
|
||||
/**
|
||||
* Getter for calulating the current quorum via pollService
|
||||
*
|
||||
* @returns the number required to be reached for a vote to match the quorum
|
||||
*/
|
||||
public get yesQuorum(): number {
|
||||
return this.pollService.calculateQuorum(this.poll, this.majorityChoice);
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if the poll can be expressed with percentages and calculated quorums or is abstract
|
||||
*
|
||||
* @returns true if abstract (no calculations possible)
|
||||
*/
|
||||
public get abstractPoll(): boolean {
|
||||
return this.pollService.getBaseAmount(this.poll) <= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor. Subscribes to the constants and settings for motion polls
|
||||
*
|
||||
* @param dialog Dialog Service for entering poll data
|
||||
* @param pollService MotionPollService
|
||||
* @param motionRepo Subscribing to the motion to update poll from the server
|
||||
* @param constants ConstantsService
|
||||
* @param config ConfigService
|
||||
* @param translate TranslateService
|
||||
* @param perms LocalPermissionService
|
||||
*/
|
||||
public constructor(
|
||||
public dialog: MatDialog,
|
||||
private pollService: MotionPollService,
|
||||
private motionRepo: MotionRepositoryService,
|
||||
private constants: ConstantsService,
|
||||
private translate: TranslateService,
|
||||
private promptService: PromptService,
|
||||
public perms: LocalPermissionsService
|
||||
) {
|
||||
this.pollValues = this.pollService.pollValues;
|
||||
this.majorityChoice = this.pollService.defaultMajorityMethod;
|
||||
this.subscribeMajorityChoices();
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to updates of itself
|
||||
*/
|
||||
public ngOnInit(): void {
|
||||
this.poll = new MotionPoll(this.rawPoll);
|
||||
this.motionRepo.getViewModelObservable(this.poll.motion_id).subscribe(viewmotion => {
|
||||
const updatePoll = viewmotion.motion.polls.find(poll => poll.id === this.poll.id);
|
||||
if (updatePoll) {
|
||||
this.poll = new MotionPoll(updatePoll);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a delete request for this poll after a confirmation dialog has been accepted.
|
||||
*/
|
||||
public async deletePoll(): Promise<void> {
|
||||
const content = this.translate.instant('The current poll will be deleted!');
|
||||
if (await this.promptService.open('Are you sure?', content)) {
|
||||
this.motionRepo.deletePoll(this.poll);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns the label for a poll option
|
||||
*/
|
||||
public getLabel(key: CalculablePollKey): string {
|
||||
return this.pollService.getLabel(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns the icon's name for the icon of a poll option
|
||||
*/
|
||||
public getIcon(key: CalculablePollKey): string {
|
||||
return this.pollService.getIcon(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the progressbar class for a decision key
|
||||
*
|
||||
* @param key
|
||||
*
|
||||
* @returns a css class designing a progress bar in a color, or an empty string
|
||||
*/
|
||||
public getProgressBarColor(key: CalculablePollKey): string {
|
||||
switch (key) {
|
||||
case 'yes':
|
||||
return 'progress-green';
|
||||
case 'no':
|
||||
return 'progress-red';
|
||||
case 'abstain':
|
||||
return 'progress-yellow';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform special case numbers into their strings
|
||||
* @param key
|
||||
*
|
||||
* @returns the number if positive or the special values' translated string
|
||||
*/
|
||||
public getNumber(key: CalculablePollKey): number | string {
|
||||
if (this.poll[key] >= 0) {
|
||||
return this.poll[key];
|
||||
} else {
|
||||
return this.translate.instant(this.pollService.getSpecialLabel(this.poll[key]));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the value cannot be expressed in percentages.
|
||||
* @param key
|
||||
* @returns if the value cannot be calculated
|
||||
*/
|
||||
public isAbstractValue(key: CalculablePollKey): boolean {
|
||||
return this.pollService.isAbstractValue(this.poll, key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the percentages of a value. See {@link MotionPollService.getPercent}
|
||||
*
|
||||
* @param value
|
||||
* @returns a number with two digits, 100.00 representing 100 percent. May be null if the value cannot be calulated
|
||||
*/
|
||||
public getPercent(value: CalculablePollKey): number {
|
||||
return this.pollService.calculatePercentage(this.poll, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: not implemented. Print the buttons
|
||||
*/
|
||||
public printBallots(): void {
|
||||
this.pollService.printBallots();
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers the 'edit poll' dialog'
|
||||
*/
|
||||
public editPoll(): void {
|
||||
const dialogRef = this.dialog.open(MotionPollDialogComponent, {
|
||||
data: { ...this.poll },
|
||||
maxHeight: '90vh',
|
||||
minWidth: '250px'
|
||||
});
|
||||
dialogRef.afterClosed().subscribe(result => {
|
||||
if (result) {
|
||||
this.motionRepo.updatePoll(result);
|
||||
// TODO error handling
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if the necessary quorum is reached by the 'yes' votes
|
||||
*
|
||||
* @returns true if the quorum is reached
|
||||
*/
|
||||
public get quorumYesReached(): boolean {
|
||||
return this.poll.yes >= this.yesQuorum;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to the available majority choices as given in the server-side constants
|
||||
*/
|
||||
private subscribeMajorityChoices(): void {
|
||||
this.constants.get('OpenSlidesConfigVariables').subscribe(constants => {
|
||||
const motionconst = constants.find(c => c.name === 'Motions');
|
||||
if (motionconst) {
|
||||
const ballotConst = motionconst.subgroups.find(s => s.name === 'Voting and ballot papers');
|
||||
if (ballotConst) {
|
||||
const methods = ballotConst.items.find(b => b.key === 'motions_poll_default_majority_method');
|
||||
this.majorityChoices = methods.choices;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a label for the quorum selection button. See {@link majorityChoices}
|
||||
* for possible values
|
||||
*
|
||||
* @returns a string from the angular material-icon font, or an empty string
|
||||
*/
|
||||
public getQuorumLabel(): string {
|
||||
const choice = this.majorityChoices.find(ch => ch.value === this.majorityChoice);
|
||||
return choice ? choice.display_name : '';
|
||||
}
|
||||
}
|
@ -20,6 +20,8 @@ import { MotionBlockListComponent } from './components/motion-block-list/motion-
|
||||
import { MotionBlockDetailComponent } from './components/motion-block-detail/motion-block-detail.component';
|
||||
import { MotionImportListComponent } from './components/motion-import-list/motion-import-list.component';
|
||||
import { ManageSubmittersComponent } from './components/manage-submitters/manage-submitters.component';
|
||||
import { MotionPollComponent } from './components/motion-poll/motion-poll.component';
|
||||
import { MotionPollDialogComponent } from './components/motion-poll/motion-poll-dialog.component';
|
||||
|
||||
@NgModule({
|
||||
imports: [CommonModule, MotionsRoutingModule, SharedModule],
|
||||
@ -40,7 +42,9 @@ import { ManageSubmittersComponent } from './components/manage-submitters/manage
|
||||
MotionBlockListComponent,
|
||||
MotionBlockDetailComponent,
|
||||
MotionImportListComponent,
|
||||
ManageSubmittersComponent
|
||||
ManageSubmittersComponent,
|
||||
MotionPollComponent,
|
||||
MotionPollDialogComponent
|
||||
],
|
||||
entryComponents: [
|
||||
MotionChangeRecommendationComponent,
|
||||
@ -49,7 +53,8 @@ import { ManageSubmittersComponent } from './components/manage-submitters/manage
|
||||
MotionCommentSectionListComponent,
|
||||
MetaTextBlockComponent,
|
||||
PersonalNoteComponent,
|
||||
ManageSubmittersComponent
|
||||
ManageSubmittersComponent,
|
||||
MotionPollDialogComponent
|
||||
]
|
||||
})
|
||||
export class MotionsModule {}
|
||||
|
@ -22,6 +22,8 @@ export class LocalPermissionsService {
|
||||
*
|
||||
* actions might be:
|
||||
* - support
|
||||
* - unsupport
|
||||
* - createpoll
|
||||
*
|
||||
* @param action the action the user tries to perform
|
||||
*/
|
||||
@ -38,6 +40,8 @@ export class LocalPermissionsService {
|
||||
);
|
||||
case 'unsupport':
|
||||
return motion.state.allow_support && motion.supporters.indexOf(this.operator.user) !== -1;
|
||||
case 'createpoll':
|
||||
return this.operator.hasPerms('motions.can_manage') && motion.state.allow_create_poll;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
@ -0,0 +1,12 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { MotionPollService } from './motion-poll.service';
|
||||
|
||||
describe('PollService', () => {
|
||||
beforeEach(() => TestBed.configureTestingModule({}));
|
||||
|
||||
it('should be created', () => {
|
||||
const service: MotionPollService = TestBed.get(MotionPollService);
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
181
client/src/app/site/motions/services/motion-poll.service.ts
Normal file
181
client/src/app/site/motions/services/motion-poll.service.ts
Normal file
@ -0,0 +1,181 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
import { ConfigService } from 'app/core/services/config.service';
|
||||
import { MotionPoll } from 'app/shared/models/motions/motion-poll';
|
||||
import { PollService } from 'app/core/poll.service';
|
||||
|
||||
export type CalculablePollKey = 'votesvalid' | 'votesinvalid' | 'votescast' | 'yes' | 'no' | 'abstain';
|
||||
|
||||
/**
|
||||
* Service class for motion polls.
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class MotionPollService extends PollService {
|
||||
/**
|
||||
* list of poll keys that are numbers and can be part of a quorum calculation
|
||||
*/
|
||||
public pollValues: CalculablePollKey[] = ['yes', 'no', 'abstain', 'votesvalid', 'votesinvalid', 'votescast'];
|
||||
|
||||
/**
|
||||
* Constructor. Subscribes to the configuration values needed
|
||||
* @param config ConfigService
|
||||
*/
|
||||
public constructor(config: ConfigService) {
|
||||
super();
|
||||
config.get('motions_poll_100_percent_base').subscribe(base => (this.percentBase = base));
|
||||
config.get('motions_poll_default_majority_method').subscribe(method => (this.defaultMajorityMethod = method));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the percentage the given key reaches.
|
||||
*
|
||||
* @param poll
|
||||
* @param key
|
||||
* @returns a percentage number with two digits, null if the value cannot be calculated (consider 0 !== null)
|
||||
*/
|
||||
public calculatePercentage(poll: MotionPoll, key: CalculablePollKey): number {
|
||||
const baseNumber = this.getBaseAmount(poll);
|
||||
if (!baseNumber) {
|
||||
return null;
|
||||
}
|
||||
switch (key) {
|
||||
case 'abstain':
|
||||
if (this.percentBase === 'YES_NO') {
|
||||
return null;
|
||||
}
|
||||
break;
|
||||
case 'votesinvalid':
|
||||
if (this.percentBase !== 'CAST') {
|
||||
return null;
|
||||
}
|
||||
break;
|
||||
case 'votesvalid':
|
||||
if (!['CAST', 'VALID'].includes(this.percentBase)) {
|
||||
return null;
|
||||
}
|
||||
break;
|
||||
case 'votescast':
|
||||
if (this.percentBase !== 'CAST') {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return Math.round(((poll[key] * 100) / baseNumber) * 100) / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the number representing 100 percent for a given MotionPoll, depending
|
||||
* on the configuration and the votes given.
|
||||
*
|
||||
* @param poll
|
||||
* @returns the positive number representing 100 percent of the poll, 0 if
|
||||
* the base cannot be calculated
|
||||
*/
|
||||
public getBaseAmount(poll: MotionPoll): number {
|
||||
if (!poll) {
|
||||
return 0;
|
||||
}
|
||||
switch (this.percentBase) {
|
||||
case 'CAST':
|
||||
if (!poll.votescast) {
|
||||
return 0;
|
||||
}
|
||||
if (poll.votesinvalid < 0) {
|
||||
return 0;
|
||||
}
|
||||
return poll.votescast;
|
||||
case 'VALID':
|
||||
if (poll.yes < 0 || poll.no < 0 || poll.abstain < 0) {
|
||||
return 0;
|
||||
}
|
||||
return poll.votesvalid ? poll.votesvalid : 0;
|
||||
case 'YES_NO_ABSTAIN':
|
||||
if (poll.yes < 0 || poll.no < 0 || poll.abstain < 0) {
|
||||
return 0;
|
||||
}
|
||||
return poll.yes + poll.no + poll.abstain;
|
||||
case 'YES_NO':
|
||||
if (poll.yes < 0 || poll.no < 0 || poll.abstain === -1) {
|
||||
// It is not allowed to set 'Abstain' to 'majority' but exclude it from calculation.
|
||||
// Setting 'Abstain' to 'undocumented' is possible, of course.
|
||||
return 0;
|
||||
}
|
||||
return poll.yes + poll.no;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates which number is needed for the quorum to be surpassed
|
||||
* TODO: Methods still hard coded to mirror the server's.
|
||||
*
|
||||
* @param poll
|
||||
* @param method (optional) majority calculation method. If none is given,
|
||||
* the default as set in the config will be used.
|
||||
* @returns the first integer number larger than the required majority,
|
||||
* undefined if a quorum cannot be calculated.
|
||||
*/
|
||||
public calculateQuorum(poll: MotionPoll, method?: string): number {
|
||||
if (!method) {
|
||||
method = this.defaultMajorityMethod;
|
||||
}
|
||||
const baseNumber = this.getBaseAmount(poll);
|
||||
if (!baseNumber) {
|
||||
return undefined;
|
||||
}
|
||||
let result: number;
|
||||
switch (method) {
|
||||
case 'simple_majority':
|
||||
result = baseNumber * 0.5;
|
||||
break;
|
||||
case 'two-thirds_majority':
|
||||
result = (baseNumber / 3) * 2;
|
||||
break;
|
||||
case 'three-quarters_majority':
|
||||
result = (baseNumber / 4) * 3;
|
||||
break;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
// rounding up, or if a integer was hit, adding one.
|
||||
if (result % 1 !== 0) {
|
||||
return Math.ceil(result);
|
||||
} else {
|
||||
return result + 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a value is abstract (percentages cannot be calculated)
|
||||
*
|
||||
* @param poll
|
||||
* @param value
|
||||
* @returns true if the percentages should not be calculated
|
||||
*/
|
||||
public isAbstractValue(poll: MotionPoll, value: CalculablePollKey): boolean {
|
||||
if (this.getBaseAmount(poll) === 0) {
|
||||
return true;
|
||||
}
|
||||
switch (this.percentBase) {
|
||||
case 'YES_NO':
|
||||
if (['votescast', 'votesinvalid', 'votesvalid', 'abstain'].includes(value)) {
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
case 'YES_NO_ABSTAIN':
|
||||
if (['votescast', 'votesinvalid', 'votesvalid'].includes(value)) {
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
case 'VALID':
|
||||
if (['votesinvalid', 'votescast'].includes(value)) {
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (poll[value] < 0) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
@ -29,6 +29,7 @@ import { CreateMotion } from '../models/create-motion';
|
||||
import { MotionBlock } from 'app/shared/models/motions/motion-block';
|
||||
import { Mediafile } from 'app/shared/models/mediafiles/mediafile';
|
||||
import { ConfigService } from '../../../core/services/config.service';
|
||||
import { MotionPoll } from 'app/shared/models/motions/motion-poll';
|
||||
|
||||
/**
|
||||
* Repository Services for motions (and potentially categories)
|
||||
@ -632,4 +633,44 @@ export class MotionRepositoryService extends BaseRepository<ViewMotion, Motion>
|
||||
duplicates.forEach(item => viewMotions.push(this.createViewModel(item)));
|
||||
return viewMotions;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 haap 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);
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user