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 { MotionComment } from './motion-comment';
|
||||||
import { AgendaBaseModel } from '../base/agenda-base-model';
|
import { AgendaBaseModel } from '../base/agenda-base-model';
|
||||||
import { SearchRepresentation } from '../../../core/services/search.service';
|
import { SearchRepresentation } from '../../../core/services/search.service';
|
||||||
|
import { MotionPoll } from './motion-poll';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Representation of Motion.
|
* Representation of Motion.
|
||||||
@ -35,7 +36,7 @@ export class Motion extends AgendaBaseModel {
|
|||||||
public recommendation_extension: string;
|
public recommendation_extension: string;
|
||||||
public tags_id: number[];
|
public tags_id: number[];
|
||||||
public attachments_id: number[];
|
public attachments_id: number[];
|
||||||
public polls: Object[];
|
public polls: MotionPoll[];
|
||||||
public agenda_item_id: number;
|
public agenda_item_id: number;
|
||||||
public log_messages: MotionLog[];
|
public log_messages: MotionLog[];
|
||||||
public weight: number;
|
public weight: number;
|
||||||
|
@ -304,6 +304,16 @@
|
|||||||
<h4 translate>Origin</h4>
|
<h4 translate>Origin</h4>
|
||||||
{{ motion.origin }}
|
{{ motion.origin }}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
@ -601,16 +611,28 @@
|
|||||||
<!-- Line number Menu -->
|
<!-- Line number Menu -->
|
||||||
<mat-menu #lineNumberingMenu="matMenu">
|
<mat-menu #lineNumberingMenu="matMenu">
|
||||||
<div *ngIf="motion">
|
<div *ngIf="motion">
|
||||||
<button mat-menu-item translate (click)="setLineNumberingMode(LineNumberingMode.None)"
|
<button
|
||||||
[ngClass]="{ selected: motion.lnMode === LineNumberingMode.None }">
|
mat-menu-item
|
||||||
|
translate
|
||||||
|
(click)="setLineNumberingMode(LineNumberingMode.None)"
|
||||||
|
[ngClass]="{ selected: motion.lnMode === LineNumberingMode.None }"
|
||||||
|
>
|
||||||
none
|
none
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item translate (click)="setLineNumberingMode(LineNumberingMode.Inside)"
|
<button
|
||||||
[ngClass]="{ selected: motion.lnMode === LineNumberingMode.Inside }">
|
mat-menu-item
|
||||||
|
translate
|
||||||
|
(click)="setLineNumberingMode(LineNumberingMode.Inside)"
|
||||||
|
[ngClass]="{ selected: motion.lnMode === LineNumberingMode.Inside }"
|
||||||
|
>
|
||||||
inline
|
inline
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item translate (click)="setLineNumberingMode(LineNumberingMode.Outside)"
|
<button
|
||||||
[ngClass]="{ selected: motion.lnMode === LineNumberingMode.Outside }">
|
mat-menu-item
|
||||||
|
translate
|
||||||
|
(click)="setLineNumberingMode(LineNumberingMode.Outside)"
|
||||||
|
[ngClass]="{ selected: motion.lnMode === LineNumberingMode.Outside }"
|
||||||
|
>
|
||||||
outside
|
outside
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -618,12 +640,36 @@
|
|||||||
|
|
||||||
<!-- Diff View Menu -->
|
<!-- Diff View Menu -->
|
||||||
<mat-menu #changeRecoMenu="matMenu">
|
<mat-menu #changeRecoMenu="matMenu">
|
||||||
<button mat-menu-item translate (click)="setChangeRecoMode(ChangeRecoMode.Original)"
|
<button
|
||||||
[ngClass]="{ selected: motion?.crMode === ChangeRecoMode.Original }">Original version</button>
|
mat-menu-item
|
||||||
<button mat-menu-item translate (click)="setChangeRecoMode(ChangeRecoMode.Changed)"
|
translate
|
||||||
[ngClass]="{ selected: motion?.crMode === ChangeRecoMode.Changed }">Changed version</button>
|
(click)="setChangeRecoMode(ChangeRecoMode.Original)"
|
||||||
<button mat-menu-item translate (click)="setChangeRecoMode(ChangeRecoMode.Diff)"
|
[ngClass]="{ selected: motion?.crMode === ChangeRecoMode.Original }"
|
||||||
[ngClass]="{ selected: motion?.crMode === ChangeRecoMode.Diff }">Diff version</button>
|
>
|
||||||
<button mat-menu-item translate (click)="setChangeRecoMode(ChangeRecoMode.Final)"
|
Original version
|
||||||
[ngClass]="{ selected: motion?.crMode === ChangeRecoMode.Final }">Final version</button>
|
</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>
|
</mat-menu>
|
||||||
|
@ -259,3 +259,7 @@ span {
|
|||||||
opacity: 1;
|
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 {
|
public opCanEdit(): boolean {
|
||||||
return this.op.hasPerms('motions.can_manage', 'motions.can_manage_metadata');
|
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 { MotionBlockDetailComponent } from './components/motion-block-detail/motion-block-detail.component';
|
||||||
import { MotionImportListComponent } from './components/motion-import-list/motion-import-list.component';
|
import { MotionImportListComponent } from './components/motion-import-list/motion-import-list.component';
|
||||||
import { ManageSubmittersComponent } from './components/manage-submitters/manage-submitters.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({
|
@NgModule({
|
||||||
imports: [CommonModule, MotionsRoutingModule, SharedModule],
|
imports: [CommonModule, MotionsRoutingModule, SharedModule],
|
||||||
@ -40,7 +42,9 @@ import { ManageSubmittersComponent } from './components/manage-submitters/manage
|
|||||||
MotionBlockListComponent,
|
MotionBlockListComponent,
|
||||||
MotionBlockDetailComponent,
|
MotionBlockDetailComponent,
|
||||||
MotionImportListComponent,
|
MotionImportListComponent,
|
||||||
ManageSubmittersComponent
|
ManageSubmittersComponent,
|
||||||
|
MotionPollComponent,
|
||||||
|
MotionPollDialogComponent
|
||||||
],
|
],
|
||||||
entryComponents: [
|
entryComponents: [
|
||||||
MotionChangeRecommendationComponent,
|
MotionChangeRecommendationComponent,
|
||||||
@ -49,7 +53,8 @@ import { ManageSubmittersComponent } from './components/manage-submitters/manage
|
|||||||
MotionCommentSectionListComponent,
|
MotionCommentSectionListComponent,
|
||||||
MetaTextBlockComponent,
|
MetaTextBlockComponent,
|
||||||
PersonalNoteComponent,
|
PersonalNoteComponent,
|
||||||
ManageSubmittersComponent
|
ManageSubmittersComponent,
|
||||||
|
MotionPollDialogComponent
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class MotionsModule {}
|
export class MotionsModule {}
|
||||||
|
@ -22,6 +22,8 @@ export class LocalPermissionsService {
|
|||||||
*
|
*
|
||||||
* actions might be:
|
* actions might be:
|
||||||
* - support
|
* - support
|
||||||
|
* - unsupport
|
||||||
|
* - createpoll
|
||||||
*
|
*
|
||||||
* @param action the action the user tries to perform
|
* @param action the action the user tries to perform
|
||||||
*/
|
*/
|
||||||
@ -38,6 +40,8 @@ export class LocalPermissionsService {
|
|||||||
);
|
);
|
||||||
case 'unsupport':
|
case 'unsupport':
|
||||||
return motion.state.allow_support && motion.supporters.indexOf(this.operator.user) !== -1;
|
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:
|
default:
|
||||||
return false;
|
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 { MotionBlock } from 'app/shared/models/motions/motion-block';
|
||||||
import { Mediafile } from 'app/shared/models/mediafiles/mediafile';
|
import { Mediafile } from 'app/shared/models/mediafiles/mediafile';
|
||||||
import { ConfigService } from '../../../core/services/config.service';
|
import { ConfigService } from '../../../core/services/config.service';
|
||||||
|
import { MotionPoll } from 'app/shared/models/motions/motion-poll';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Repository Services for motions (and potentially categories)
|
* 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)));
|
duplicates.forEach(item => viewMotions.push(this.createViewModel(item)));
|
||||||
return viewMotions;
|
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