More voting UI improvements

For Motion poll:
- Overworked how motion poll chart displays the legend
- Added the vote counter to the motion detail
- Added a progress bar to the vote counter
- Fixed some perm errors with the chart
- Show a "Singe Votes" link as button for published named polls
- Replace the edit-button with a dot-menu
  - Having project, Edit, PDF and Delete

For Motion Poll detail:
- enhance search panel
- Remove the breadcrumbs
- Remove the vote counter
- Enhanced the single-vote grid, table and filter bar
- Enhance how the poll state enum was checkend

For the Motion Poll Create/Update Form:
- Remove the selection of poll-methode (whenever possible)
- only show "publish imediately" during creation
This commit is contained in:
Sean Engelhardt 2020-01-22 17:39:10 +01:00 committed by FinnStutzenstein
parent 682db96b7c
commit 84a39ccb62
41 changed files with 400 additions and 252 deletions

View File

@ -95,7 +95,6 @@ export class AppComponent {
translate.use(translate.getLangs().includes(browserLang) ? browserLang : 'en');
// change default JS functions
this.overloadArrayToString();
this.overloadArrayFunctions();
this.overloadModulo();
@ -118,7 +117,7 @@ export class AppComponent {
*
* TODO: Should be renamed
*/
private overloadArrayToString(): void {
private overloadArrayFunctions(): void {
Object.defineProperty(Array.prototype, 'toString', {
value: function(): string {
let string = '';
@ -139,12 +138,7 @@ export class AppComponent {
},
enumerable: false
});
}
/**
* Adds an implementation of flatMap and intersect.
*/
private overloadFlatMap(): void {
Object.defineProperty(Array.prototype, 'flatMap', {
value: function(o: any): any[] {
const concatFunction = (x: any, y: any[]) => x.concat(y);
@ -154,10 +148,11 @@ export class AppComponent {
enumerable: false
});
// intersect
Object.defineProperty(Array.prototype, 'intersect', {
value: function<T>(other: T[]): T[] {
let a = this,
b = other;
let a = this;
let b = other;
// indexOf to loop over shorter
if (b.length > a.length) {
[a, b] = [b, a];
@ -167,6 +162,7 @@ export class AppComponent {
enumerable: false
});
// mapToObject
Object.defineProperty(Array.prototype, 'mapToObject', {
value: function<T>(f: (item: T) => { [key: string]: any }): { [key: string]: any } {
return this.reduce((aggr, item) => {

View File

@ -71,6 +71,7 @@ export abstract class PollPdfService {
* @returns the amount of ballots, depending on the config settings
*/
protected getBallotCount(): number {
// TODO: seems to be broken
switch (this.ballotCountSelection) {
case 'NUMBER_OF_ALL_PARTICIPANTS':
return this.userRepo.getViewModelList().length;

View File

@ -52,6 +52,22 @@ export abstract class BasePoll<T = any, O extends BaseOption<any> = any> extends
public onehundred_percent_base: PercentBase;
public user_has_voted: boolean;
public get isStateCreated(): boolean {
return this.state === PollState.Created;
}
public get isStateStarted(): boolean {
return this.state === PollState.Started;
}
public get isStateFinished(): boolean {
return this.state === PollState.Finished;
}
public get isStatePublished(): boolean {
return this.state === PollState.Published;
}
/**
* Determine if the state is finished or published
*/

View File

@ -9,9 +9,9 @@ export const VoteValueVerbose = {
};
export const GeneralValueVerbose = {
votesvalid: 'Votes valid',
votesinvalid: 'Votes invalid',
votescast: 'Votes cast',
votesvalid: 'Valid votes',
votesinvalid: 'Invalid votes',
votescast: 'Total votes cast',
votesno: 'Votes No',
votesabstain: 'Votes abstain'
};

View File

@ -0,0 +1,20 @@
import { inject, TestBed } from '@angular/core/testing';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { ParsePollNumberPipe } from './parse-poll-number.pipe';
describe('ParsePollNumberPipe', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
declarations: [ParsePollNumberPipe]
});
TestBed.compileComponents();
});
it('create an instance', inject([TranslateService], (translate: TranslateService) => {
const pipe = new ParsePollNumberPipe(translate);
expect(pipe).toBeTruthy();
}));
});

View File

@ -0,0 +1,22 @@
import { Pipe, PipeTransform } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
@Pipe({
name: 'parsePollNumber'
})
export class ParsePollNumberPipe implements PipeTransform {
public constructor(private translate: TranslateService) {}
public transform(value: number): number | string {
const input = Math.trunc(value);
switch (input) {
case -1:
return this.translate.instant('majority');
case -2:
return this.translate.instant('undocumented');
default:
return input;
}
}
}

View File

@ -0,0 +1,8 @@
import { ReversePipe } from './reverse.pipe';
describe('ReversePipe', () => {
it('create an instance', () => {
const pipe = new ReversePipe();
expect(pipe).toBeTruthy();
});
});

View File

@ -0,0 +1,20 @@
import { Pipe, PipeTransform } from '@angular/core';
/**
* Invert the order of arrays in templates
*
* @example
* ```html
* <li *ngFor="let user of users | reverse">
* {{ user.name }} has the id: {{ user.id }}
* </li>
* ```
*/
@Pipe({
name: 'reverse'
})
export class ReversePipe implements PipeTransform {
public transform(value: any[]): any[] {
return value.slice().reverse();
}
}

View File

@ -119,6 +119,8 @@ import { BasePollDialogComponent } from 'app/site/polls/components/base-poll-dia
import { PollFormComponent } from 'app/site/polls/components/poll-form/poll-form.component';
import { MotionPollDialogComponent } from 'app/site/motions/modules/motion-poll/motion-poll-dialog/motion-poll-dialog.component';
import { AssignmentPollDialogComponent } from 'app/site/assignments/components/assignment-poll-dialog/assignment-poll-dialog.component';
import { ParsePollNumberPipe } from './pipes/parse-poll-number.pipe';
import { ReversePipe } from './pipes/reverse.pipe';
/**
* Share Module for all "dumb" components and pipes.
@ -279,7 +281,9 @@ import { AssignmentPollDialogComponent } from 'app/site/assignments/components/a
BannerComponent,
PollFormComponent,
MotionPollDialogComponent,
AssignmentPollDialogComponent
AssignmentPollDialogComponent,
ParsePollNumberPipe,
ReversePipe
],
declarations: [
PermsDirective,
@ -333,7 +337,9 @@ import { AssignmentPollDialogComponent } from 'app/site/assignments/components/a
BannerComponent,
PollFormComponent,
MotionPollDialogComponent,
AssignmentPollDialogComponent
AssignmentPollDialogComponent,
ParsePollNumberPipe,
ReversePipe
],
providers: [
{
@ -349,7 +355,9 @@ import { AssignmentPollDialogComponent } from 'app/site/assignments/components/a
DecimalPipe,
ProgressSnackBarComponent,
TrustPipe,
LocalizedDatePipe
LocalizedDatePipe,
ParsePollNumberPipe,
ReversePipe
],
entryComponents: [
SortBottomSheetComponent,

View File

@ -65,20 +65,12 @@
<div *ngIf="!editAssignment">
<!-- assignment meta infos-->
<ng-container [ngTemplateOutlet]="metaInfoTemplate"></ng-container>
<!-- votable polls -->
<ng-container *ngIf="assignment">
<ng-container *ngFor="let poll of assignment.polls; trackBy: trackByIndex">
<mat-card class="os-card" *ngIf="poll.canBeVotedFor">
<os-assignment-poll [poll]="poll"> </os-assignment-poll>
</mat-card>
</ng-container>
</ng-container>
<!-- candidates list -->
<ng-container [ngTemplateOutlet]="candidatesTemplate"></ng-container>
<!-- closed polls -->
<ng-container *ngIf="assignment">
<ng-container *ngFor="let poll of assignment.polls; trackBy: trackByIndex">
<mat-card class="os-card" *ngIf="!poll.canBeVotedFor">
<ng-container *ngFor="let poll of assignment.polls | reverse; trackBy: trackByIndex">
<mat-card class="os-card">
<os-assignment-poll [poll]="poll"> </os-assignment-poll>
</mat-card>
</ng-container>
@ -135,13 +127,17 @@
</ng-template>
<ng-template #candidatesTemplate>
<mat-card class="os-card">
<ng-container *ngIf="assignment && !assignment.isFinished">
<mat-card class="os-card" *ngIf="assignment && !assignment.isFinished">
<ng-container>
<h3 translate>Candidates</h3>
<div>
<div
class="candidates-list"
*ngIf="assignment && assignment.assignment_related_users && assignment.assignment_related_users.length > 0"
*ngIf="
assignment &&
assignment.assignment_related_users &&
assignment.assignment_related_users.length > 0
"
>
<os-sorting-list
[input]="assignment.assignment_related_users"

View File

@ -33,16 +33,18 @@
{{ 'Groups' | translate }}:
<span *ngFor="let group of poll.groups">{{ group.getTitle() | translate }}</span>
</div>
<div>{{ 'Poll type' | translate }}: {{ poll.typeVerbose | translate }}</div>
<div>{{ 'Poll method' | translate }}: {{ poll.pollmethodVerbose | translate }}</div>
<div>{{ 'Majority method' | translate }}: {{ poll.majorityMethodVerbose | translate }}</div>
<div>{{ 'Voting type' | translate }}: {{ poll.typeVerbose | translate }}</div>
<div>{{ 'Election method' | translate }}: {{ poll.pollmethodVerbose | translate }}</div>
<div>{{ 'Required majority' | translate }}: {{ poll.majorityMethodVerbose | translate }}</div>
<div>{{ '100% base' | translate }}: {{ poll.percentBaseVerbose | translate }}</div>
</div>
<!-- TODO Enum -->
<div *ngIf="poll.state === 2">
<os-poll-progress [poll]="poll"></os-poll-progress>
</div>
<div *ngIf="poll.state === 3 || poll.state === 4">
<div *ngIf="poll.stateHasVotes">
<h2 translate>Result</h2>
<div class="chart-wrapper"></div>

View File

@ -8,14 +8,14 @@
<span *ngIf="option.user">{{ option.user.getFullName() }}</span>
<span *ngIf="!option.user">No user {{ option.candidate_id }}</span>
</div>
<div>
<div *ngFor="let value of analogPollValues" [formGroupName]="option.user_id">
<os-check-input
[placeholder]="voteValueVerbose[value] | translate"
[checkboxValue]="-1"
inputType="number"
[checkboxLabel]="'Majority' | translate"
[checkboxLabel]="'majority' | translate"
[formControlName]="value"
></os-check-input>
</div>
@ -28,7 +28,7 @@
[placeholder]="generalValueVerbose[value] | translate"
[checkboxValue]="-1"
inputType="number"
[checkboxLabel]="'Majority' | translate"
[checkboxLabel]="'majority' | translate"
[formControlName]="value"
></os-check-input>
</div>

View File

@ -1,4 +1,10 @@
<div class="assignment-poll-wrapper" *ngIf="poll">
<div class="">
</div>
<div class="poll-menu">
<!-- Buttons -->
<button
@ -9,30 +15,16 @@
>
<mat-icon>more_horiz</mat-icon>
</button>
<mat-menu #pollItemMenu="matMenu">
<div *osPerms="'assignments.can_manage'">
<button mat-menu-item (click)="openDialog()">
<mat-icon>edit</mat-icon>
<span translate>Edit</span>
</button>
</div>
<div *osPerms="'core.can_manage_projector'">
<os-projector-button menuItem="true" [object]="poll"></os-projector-button>
</div>
<div *osPerms="'assignments.can_manage'">
<mat-divider></mat-divider>
<button mat-menu-item class="red-warning-text" (click)="onDeletePoll()">
<mat-icon>delete</mat-icon>
<span translate>Delete</span>
</button>
</div>
</mat-menu>
</div>
<div>
<div>
<h3>
<a routerLink="/assignments/polls/{{ poll.id }}">
{{ poll.title }}
</a>
</h3>
<div class="poll-properties">
<!-- <mat-chip *ngIf="pollService.isElectronicVotingEnabled">{{ poll.typeVerbose }}</mat-chip> -->
<mat-chip
class="poll-state active"
[matMenuTriggerFor]="triggerMenu"
@ -41,23 +33,14 @@
{{ poll.stateVerbose }}
</mat-chip>
</div>
<h3>
<a routerLink="/assignments/polls/{{ poll.id }}">
{{ poll.title }}
</a>
</h3>
</div>
<div>
<div *ngIf="poll.stateHasVotes">
<os-charts [type]="chartType" [labels]="candidatesLabels" [data]="chartDataSubject"></os-charts>
</div>
</div>
<os-assignment-poll-vote *ngIf="poll.canBeVotedFor" [poll]="poll"></os-assignment-poll-vote>
<!-- <ng-container *ngIf="poll.state === pollStates.STATE_PUBLISHED" [ngTemplateOutlet]="resultsTemplate"></ng-container> -->
</div>
<ng-template #resultsTemplate> </ng-template>
<mat-menu #triggerMenu="matMenu">
<ng-container *ngIf="poll">
<button mat-menu-item (click)="changeState(state.value)" *ngFor="let state of poll.nextStates | keyvalue">
@ -65,3 +48,22 @@
</button>
</ng-container>
</mat-menu>
<mat-menu #pollItemMenu="matMenu">
<div *osPerms="'assignments.can_manage'">
<button mat-menu-item (click)="openDialog()">
<mat-icon>edit</mat-icon>
<span translate>Edit</span>
</button>
</div>
<div *osPerms="'core.can_manage_projector'">
<os-projector-button menuItem="true" [object]="poll"></os-projector-button>
</div>
<div *osPerms="'assignments.can_manage'">
<mat-divider></mat-divider>
<button mat-menu-item class="red-warning-text" (click)="onDeletePoll()">
<mat-icon>delete</mat-icon>
<span translate>Delete</span>
</button>
</div>
</mat-menu>

View File

@ -3,33 +3,10 @@
position: relative;
padding: 0 15px;
.poll-main-content {
padding-top: 10px;
}
.poll-grid {
display: grid;
grid-gap: 5px;
padding: 5px;
grid-template-columns: 30px auto 250px 150px;
.candidate-name {
word-wrap: break-word;
}
}
.right-align {
text-align: right;
}
.vote-input {
.mat-form-field-wrapper {
// padding-bottom: 0;
.mat-form-field-infix {
width: 60px;
border-top: 0;
}
}
.poll-menu {
position: absolute;
top: 0;
right: 0;
}
.poll-properties {
@ -62,33 +39,4 @@
}
}
}
.poll-menu {
position: absolute;
top: 0;
right: 0;
}
.poll-quorum {
text-align: right;
margin-right: 10px;
mat-icon {
vertical-align: middle;
font-size: 100%;
}
}
.top-aligned {
position: absolute;
top: 0;
left: 0;
}
.wide {
width: 90%;
}
.hint-form {
margin-top: 20px;
}
}

View File

@ -49,7 +49,7 @@ export class AssignmentPollService extends PollService {
const length = this.pollRepo.getViewModelList().filter(item => item.assignment_id === poll.assignment_id)
.length;
poll.title = !length ? this.translate.instant('Vote') : `${this.translate.instant('Vote')} (${length + 1})`;
poll.title = !length ? this.translate.instant('Ballot') : `${this.translate.instant('Ballot')} (${length + 1})`;
poll.pollmethod = AssignmentPollMethods.YN;
poll.assignment_id = poll.assignment_id;
}

View File

@ -25,6 +25,7 @@
<mat-icon *ngSwitchCase="'General'">home</mat-icon>
<mat-icon *ngSwitchCase="'Agenda'">today</mat-icon>
<mat-icon *ngSwitchCase="'Motions'">assignment</mat-icon>
<mat-icon *ngSwitchCase="'Voting'">pie_chart</mat-icon>
<mat-icon *ngSwitchCase="'Elections'">how_to_vote</mat-icon>
<mat-icon *ngSwitchCase="'Participants'">groups</mat-icon>
<mat-icon *ngSwitchCase="'Custom translations'">language</mat-icon>

View File

@ -463,7 +463,7 @@
<div class="mat-card create-poll-button" *ngIf="perms.isAllowed('createpoll', motion)">
<button mat-button (click)="openDialog()">
<mat-icon class="main-nav-color">poll</mat-icon>
<span translate>New poll</span>
<span translate>New vote</span>
</button>
</div>
</div>

View File

@ -1,9 +1,9 @@
<os-head-bar [goBack]="true" [nav]="false">
<div class="title-slot">
<h2 *ngIf="poll">{{ 'Motion' | translate }} {{ poll.motion.id }}</h2>
<h2 *ngIf="poll">{{ 'Motion' | translate }} {{ poll.motion.identifierOrTitle }}</h2>
</div>
<div class="menu-slot" *osPerms="'agenda.can_manage'; or: 'agenda.can_see_list_of_speakers'">
<div class="menu-slot" *osPerms="'motions.can_manage_polls'">
<button type="button" mat-icon-button [matMenuTriggerFor]="pollDetailMenu">
<mat-icon>more_vert</mat-icon>
</button>
@ -18,14 +18,13 @@
<ng-template #viewTemplate>
<ng-container *ngIf="poll">
<h1>{{ poll.title }}</h1>
<span *ngIf="poll.type !== 'analog'">{{ 'Polly type' | translate }}: {{ poll.type | translate }}</span>
<os-breadcrumb [breadcrumbs]="breadcrumbs"></os-breadcrumb>
<span *ngIf="poll.type !== 'analog'">{{ poll.typeVerbose | translate }}</span>
<div *ngIf="!poll.hasVotes || !poll.stateHasVotes">{{ 'No results to show' | translate }}</div>
<div *ngIf="poll.stateHasVotes">
<h2 translate>Result</h2>
<div *ngIf="!poll.hasVotes">{{ 'No results to show' | translate }}</div>
<div class="result-wrapper" *ngIf="poll.hasVotes">
<!-- Chart -->
<os-charts
@ -52,25 +51,28 @@
</mat-table>
<!-- Named table: only show if votes are present -->
<ng-container *ngIf="poll.type === 'named' && votesDataSource.data">
<input matInput [(ngModel)]="votesDataSource.filter" placeholder="Filter"/>
<div class="named-result-table" *ngIf="poll.type === 'named' && votesDataSource.data">
<h3>{{ 'Single votes' | translate }}</h3>
<mat-form-field>
<input matInput [(ngModel)]="votesDataSource.filter" placeholder="Filter" />
</mat-form-field>
<mat-table [dataSource]="votesDataSource">
<ng-container matColumnDef="key" sticky>
<mat-header-cell *matHeaderCellDef>{{ "User" | translate }}</mat-header-cell>
<mat-header-cell *matHeaderCellDef>{{ 'User' | translate }}</mat-header-cell>
<mat-cell *matCellDef="let vote">
<div *ngIf="vote.user">{{ vote.user.getFullName() }}</div>
<div *ngIf="!vote.user">{{ 'Unknown user' | translate }}</div>
</mat-cell>
</ng-container>
<ng-container matColumnDef="value" sticky>
<mat-header-cell *matHeaderCellDef>{{ "Vote" | translate }}</mat-header-cell>
<mat-header-cell *matHeaderCellDef>{{ 'Vote' | translate }}</mat-header-cell>
<mat-cell *matCellDef="let vote">{{ vote.valueVerbose }}</mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="columnDefinition"></mat-header-row>
<mat-row *matRowDef="let vote; columns: columnDefinition"></mat-row>
</mat-table>
</ng-container>
</div>
</div>
</div>
@ -82,29 +84,29 @@
{{ group.getTitle() | translate }}<span *ngIf="i < poll.groups.length - 1">, </span>
</span>
</div>
<div>{{ 'Majority method' | translate }}: {{ poll.majorityMethodVerbose | translate }}</div>
<div>{{ 'Required majority' | translate }}: {{ poll.majorityMethodVerbose | translate }}</div>
<div>{{ '100% base' | translate }}: {{ poll.percentBaseVerbose | translate }}</div>
</div>
<div *ngIf="poll.state === 2">
<os-poll-progress [poll]="poll"></os-poll-progress>
</div>
</ng-container>
</ng-template>
<!-- More Menu -->
<mat-menu #pollDetailMenu="matMenu">
<os-projector-button [menuItem]="true" [object]="poll" *osPerms="'core.can_manage_projector'"></os-projector-button>
<button mat-menu-item (click)="openDialog()">
<button *osPerms="'motions.can_manage_polls'" mat-menu-item (click)="openDialog()">
<mat-icon>edit</mat-icon>
<span translate>Edit</span>
</button>
<button mat-menu-item *ngIf="poll && poll.type === 'named'" (click)="pseudoanonymizePoll()">
<mat-icon>polymer</mat-icon>
<span translate>Pseudoanonymize</span>
<button
mat-menu-item
*osPerms="'motions.can_manage_polls'; and: poll && poll.type === 'named'"
(click)="pseudoanonymizePoll()"
>
<mat-icon>warning</mat-icon>
<span translate>Anonymize votes</span>
</button>
<mat-divider></mat-divider>
<button mat-menu-item (click)="deletePoll()">
<button *osPerms="'motions.can_manage_polls'" mat-menu-item (click)="deletePoll()">
<mat-icon color="warn">delete</mat-icon>
<span translate>Delete</span>
</button>

View File

@ -35,4 +35,8 @@
.named-result-table {
grid-area: names;
.mat-form-field {
font-size: 14px;
width: 100%;
}
}

View File

@ -6,41 +6,43 @@
[placeholder]="'Yes' | translate"
[checkboxValue]="-1"
inputType="number"
[checkboxLabel]="'Majority' | translate"
[checkboxLabel]="'majority' | translate"
formControlName="Y"
></os-check-input>
<os-check-input
[placeholder]="'No' | translate"
[checkboxValue]="-1"
inputType="number"
[checkboxLabel]="'Majority' | translate"
[checkboxLabel]="'majority' | translate"
formControlName="N"
></os-check-input>
<os-check-input
[placeholder]="'Abstain' | translate"
[checkboxValue]="-1"
inputType="number"
[checkboxLabel]="'Majority' | translate"
[checkboxLabel]="'majority' | translate"
formControlName="A"
></os-check-input>
<os-check-input
[placeholder]="'Votes valid' | translate"
[placeholder]="'Valid votes' | translate"
inputType="number"
formControlName="votesvalid"
></os-check-input>
<os-check-input
[placeholder]="'Votes invalid' | translate"
[placeholder]="'Invalid votes' | translate"
inputType="number"
formControlName="votesinvalid"
></os-check-input>
<os-check-input
[placeholder]="'Votes cast' | translate"
[placeholder]="'Total votes cast' | translate"
inputType="number"
formControlName="votescast"
></os-check-input>
</form>
</div>
<div>
<!-- Publish immediately button. Only show for new polls -->
<div *ngIf="!pollData.state">
<mat-checkbox [(ngModel)]="publishImmediately" (change)="publishStateChanged($event.checked)">
<span translate>Publish immediately</span>
</mat-checkbox>

View File

@ -42,9 +42,6 @@ export class MotionPollDialogComponent extends BasePollDialogComponent {
votesinvalid: data.votesinvalid,
votescast: data.votescast
};
// if (data.pollmethod === 'YNA') {
// update.A = data.options[0].abstain;
// }
if (this.dialogVoteForm) {
const result = this.undoReplaceEmptyValues(update);
@ -64,9 +61,7 @@ export class MotionPollDialogComponent extends BasePollDialogComponent {
votesinvalid: ['', [Validators.min(-2)]],
votescast: ['', [Validators.min(-2)]]
});
// if (this.pollData.pollmethod === MotionPollMethods.YNA) {
// this.dialogVoteForm.addControl('A', this.fb.control('', [Validators.min(-2)]));
// }
if (this.pollData.poll) {
this.updateDialogVoteForm(this.pollData);
}

View File

@ -1,6 +1,8 @@
<ng-container *ngIf="poll">
<div *osPerms="'motions.can_manage_polls';and:poll.isStateStarted">
<os-poll-progress [poll]="poll"></os-poll-progress>
</div>
<ng-container *ngIf="vmanager.canVote(poll)">
<!-- Voting -->
<p *ngFor="let option of voteOptions">
<button

View File

@ -1,16 +1,15 @@
/**
* These colors should be extracted from some global CSS Constants file
*/
@import '~assets/styles/poll-colors.scss';
.voted-yes {
background-color: #9fd773;
background-color: $votes-yes-color;
}
.voted-no {
background-color: #cc6c5b;
background-color: $votes-no-color;
}
.voted-abstain {
background-color: #a6a6a6;
background-color: $votes-abstain-color;
}
.vote-label {

View File

@ -1,23 +1,23 @@
<div class="poll-preview-wrapper">
<div class="poll-preview-wrapper" *ngIf="poll && showPoll()">
<!-- Poll Infos -->
<div class="poll-title-wrapper" *ngIf="poll">
<div class="poll-title-wrapper">
<!-- Title -->
<a class="poll-title" routerLink="/motions/polls/{{ poll.id }}">
<a class="poll-title" [routerLink]="pollLink">
{{ poll.title }}
</a>
<!-- Edit button -->
<!-- Dot Menu -->
<span class="poll-title-actions" *osPerms="'motions.can_manage_polls'">
<button mat-icon-button (click)="openDialog()">
<mat-icon class="small-icon">edit</mat-icon>
<button mat-icon-button [matMenuTriggerFor]="pollDetailMenu">
<mat-icon class="small-icon">more_horiz</mat-icon>
</button>
</span>
<!-- State chip -->
<div class="poll-properties" *osPerms="'motions.can_manage_polls'">
<span *ngIf="pollService.isElectronicVotingEnabled && poll.typeVerbose !== 'Analog'">
{{ 'Poll type' | translate }}: {{ poll.typeVerbose | translate }}
</span>
<div *ngIf="pollService.isElectronicVotingEnabled && poll.type !== 'analog'">
{{ poll.typeVerbose | translate }}
</div>
<mat-chip
disableRipple
@ -37,35 +37,72 @@
</div>
<ng-template #votingResult>
<div (click)="openPoll()">
<ng-container [ngTemplateOutlet]="poll.hasVotes && poll.stateHasVotes ? viewTemplate : emptyTemplate"></ng-container>
<div [routerLink]="pollLink">
<ng-container
[ngTemplateOutlet]="poll.hasVotes && poll.stateHasVotes ? viewTemplate : emptyTemplate"
></ng-container>
</div>
</ng-template>
<ng-template #viewTemplate>
<div class="poll-chart-wrapper">
<div class="votes-yes">
<os-icon-container icon="check" size="large">
{{ voteYes }}
</os-icon-container>
<!-- empty helper div to center the grid wrapper -->
<div></div>
<div class="doughnut-chart">
<os-charts *ngIf="showChart" [type]="'doughnut'" [data]="chartDataSubject" [showLegend]="false"> </os-charts>
</div>
<div *ngIf="showChart" class="doughnut-chart">
<os-charts [type]="'doughnut'" [data]="chartDataSubject" [showLegend]="false"> </os-charts>
</div>
<div class="votes-no">
<os-icon-container icon="close" size="large">
{{ voteNo }}
</os-icon-container>
<div class="vote-legend">
<div class="votes-yes" *ngIf="isVoteDocumented(voteYes)">
<os-icon-container icon="thumb_up" size="large">
{{ voteYes | parsePollNumber }}
</os-icon-container>
</div>
<div class="votes-no" *ngIf="isVoteDocumented(voteNo)">
<os-icon-container icon="thumb_down" size="large">
{{ voteNo | parsePollNumber }}
</os-icon-container>
</div>
<div class="votes-abstain" *ngIf="isVoteDocumented(voteAbstain)">
<os-icon-container icon="trip_origin" size="large">
{{ voteAbstain | parsePollNumber }}
</os-icon-container>
</div>
</div>
</div>
<div class="poll-detail-button-wrapper" *ngIf="poll.type !== 'analog'">
<button mat-button [routerLink]="pollLink">
{{ 'Single Votes' | translate }}
</button>
</div>
</ng-template>
<ng-template #emptyTemplate>
<div>
An empty poll - you have to enter votes.
<div *osPerms="'motions.can_manage_polls'">
{{ 'An empty poll - you have to enter votes.' | translate }}
</div>
</ng-template>
<!-- More Menu -->
<mat-menu #pollDetailMenu="matMenu">
<os-projector-button [menuItem]="true" [object]="poll" *osPerms="'core.can_manage_projector'"></os-projector-button>
<button *osPerms="'motions.can_manage_polls'" mat-menu-item (click)="openDialog()">
<mat-icon>edit</mat-icon>
<span translate>Edit</span>
</button>
<button mat-menu-item (click)="downloadPdf()">
<mat-icon>picture_as_pdf</mat-icon>
<span translate>PDF</span>
</button>
<div *osPerms="'motions.can_manage_polls'">
<mat-divider></mat-divider>
<button mat-menu-item (click)="deletePoll()">
<mat-icon color="warn">delete</mat-icon>
<span translate>Delete</span>
</button>
</div>
</mat-menu>
<!-- 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">

View File

@ -1,3 +1,5 @@
@import '~assets/styles/poll-colors.scss';
.poll-preview-wrapper {
padding: 8px;
background: white;
@ -6,6 +8,7 @@
.poll-title {
color: black;
text-decoration: none;
font-weight: 500;
}
.poll-title-actions {
@ -48,20 +51,41 @@
display: grid;
grid-template-columns: auto minmax(50px, 20%) auto;
.votes-no {
color: #cc6c5b;
margin: auto 0;
width: fit-content;
.doughnut-chart {
margin-top: auto;
margin-bottom: auto;
}
.votes-yes {
color: #9fc773;
margin: auto 0 auto auto;
width: fit-content;
.vote-legend {
margin: auto 10px;
div + div {
margin-top: 10px;
}
.votes-yes {
color: $votes-yes-color;
}
.votes-no {
color: $votes-no-color;
}
.votes-abstain {
color: $votes-abstain-color;
}
}
}
}
.poll-detail-button-wrapper {
display: flex;
margin: auto 0;
> button {
margin-left: auto;
}
}
.poll-preview-meta-info {
span {
padding: 0 5px;

View File

@ -1,7 +1,6 @@
import { Component, Input } from '@angular/core';
import { MatDialog, MatSnackBar } from '@angular/material';
import { Title } from '@angular/platform-browser';
import { Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { BehaviorSubject } from 'rxjs';
@ -12,6 +11,7 @@ import { PromptService } from 'app/core/ui-services/prompt.service';
import { ChartData } from 'app/shared/components/charts/charts.component';
import { ViewMotionPoll } from 'app/site/motions/models/view-motion-poll';
import { MotionPollDialogService } from 'app/site/motions/services/motion-poll-dialog.service';
import { MotionPollPdfService } from 'app/site/motions/services/motion-poll-pdf.service';
import { BasePollComponent } from 'app/site/polls/components/base-poll.component';
import { PollService } from 'app/site/polls/services/poll.service';
@ -40,6 +40,9 @@ export class MotionPollComponent extends BasePollComponent<ViewMotionPoll> {
if (data.label === 'NO') {
this.voteNo = data.data[0];
}
if (data.label === 'ABSTAIN') {
this.voteAbstain = data.data[0];
}
}
this.chartDataSubject.next(chartData);
}
@ -48,6 +51,10 @@ export class MotionPollComponent extends BasePollComponent<ViewMotionPoll> {
return this._poll;
}
public get pollLink(): string {
return `/motions/polls/${this.poll.id}`;
}
/**
* Subject to holding the data needed for the chart.
*/
@ -56,32 +63,45 @@ export class MotionPollComponent extends BasePollComponent<ViewMotionPoll> {
/**
* Number of votes for `Yes`.
*/
public set voteYes(n: number | string) {
public set voteYes(n: number) {
this._voteYes = n;
}
public get voteYes(): number | string {
return this.verboseForNumber(this._voteYes as number);
public get voteYes(): number {
return this._voteYes;
}
/**
* Number of votes for `No`.
*/
public set voteNo(n: number | string) {
public set voteNo(n: number) {
this._voteNo = n;
}
public get voteNo(): number | string {
return this.verboseForNumber(this._voteNo as number);
public get voteNo(): number {
return this._voteNo;
}
/**
* Number of votes for `Abstain`.
*/
public set voteAbstain(n: number) {
this._voteAbstain = n;
}
public get voteAbstain(): number {
return this._voteAbstain;
}
public get showChart(): boolean {
return this._voteYes >= 0 && this._voteNo >= 0;
}
private _voteNo: number | string = 0;
private _voteNo: number;
private _voteYes: number | string = 0;
private _voteYes: number;
private _voteAbstain: number;
/**
* Constructor.
@ -101,27 +121,35 @@ export class MotionPollComponent extends BasePollComponent<ViewMotionPoll> {
public pollRepo: MotionPollRepositoryService,
pollDialog: MotionPollDialogService,
public pollService: PollService,
private router: Router,
private operator: OperatorService
private operator: OperatorService,
private pdfService: MotionPollPdfService
) {
super(titleService, matSnackBar, translate, dialog, promptService, pollRepo, pollDialog);
}
public openPoll(): void {
if (this.operator.hasPerms('motions.can_manage_polls')) {
this.router.navigate(['motions', 'polls', this.poll.id]);
public showPoll(): boolean {
return (
this.operator.hasPerms('motions.can_manage_polls') ||
this.poll.isStatePublished ||
(this.poll.type !== 'analog' && this.poll.isStateStarted)
);
}
public downloadPdf(): void {
console.log('picture_as_pdf');
this.pdfService.printBallots(this.poll);
}
public async deletePoll(): Promise<void> {
const title = 'Delete poll';
const text = 'Do you really want to delete the selected poll?';
if (await this.promptService.open(title, text)) {
this.repo.delete(this.poll).catch(this.raiseError);
}
}
private verboseForNumber(input: number): number | string {
input = Math.trunc(input);
switch (input) {
case -1:
return 'Majority';
case -2:
return 'Not documented';
default:
return input;
}
public isVoteDocumented(vote: number): boolean {
return vote !== null && vote !== undefined && vote !== -2;
}
}

View File

@ -65,7 +65,7 @@ const routes: Routes = [
{
path: 'polls',
loadChildren: () => import('./modules/motion-poll/motion-poll.module').then(m => m.MotionPollModule),
data: { basePerm: 'motions.can_manage_polls' }
data: { basePerm: 'motions.can_see' }
},
{
path: ':id',

View File

@ -17,7 +17,7 @@ type BallotCountChoices = 'NUMBER_OF_DELEGATES' | 'NUMBER_OF_ALL_PARTICIPANTS' |
*
* @example
* ```ts
* this.MotionPollPdfService.printBallos(this.poll);
* this.MotionPollPdfService.printBallots(this.poll);
* ```
*/
@Injectable({

View File

@ -75,7 +75,7 @@ export abstract class BasePollDetailComponent<V extends ViewBasePoll> extends Ba
* @param fb
* @param groupRepo
* @param location
* @param promptDialog
* @param promptService
* @param dialog
*/
public constructor(
@ -85,7 +85,7 @@ export abstract class BasePollDetailComponent<V extends ViewBasePoll> extends Ba
protected repo: BasePollRepositoryService,
protected route: ActivatedRoute,
protected groupRepo: GroupRepositoryService,
protected promptDialog: PromptService,
protected promptService: PromptService,
protected pollDialog: BasePollDialogService<V>
) {
super(title, translate, matSnackbar);
@ -108,16 +108,16 @@ export abstract class BasePollDetailComponent<V extends ViewBasePoll> extends Ba
const title = 'Delete poll';
const text = 'Do you really want to delete the selected poll?';
if (await this.promptDialog.open(title, text)) {
if (await this.promptService.open(title, text)) {
this.repo.delete(this.poll).then(() => this.onDeleted(), this.raiseError);
}
}
public async pseudoanonymizePoll(): Promise<void> {
const title = 'Pseudoanonymize poll';
const text = 'Do you really want to pseudoanonymize the selected poll?';
const title = 'Anonymize single votes';
const text = 'Do you really want to anonymize all votes? This cannot be undone.';
if (await this.promptDialog.open(title, text)) {
if (await this.promptService.open(title, text)) {
this.repo.pseudoanonymize(this.poll).then(() => this.onPollLoaded(), this.raiseError); // votes have changed, but not the poll, so the components have to be informed about the update
}
}

View File

@ -19,7 +19,7 @@
<form [formGroup]="contentForm" class="poll-preview--meta-info-form">
<ng-container *ngIf="!data || !data.state || data.state === 1">
<mat-form-field *ngIf="pollService.isElectronicVotingEnabled">
<mat-select [placeholder]="'Poll type' | translate" formControlName="type" required>
<mat-select [placeholder]="'Voting type' | translate" formControlName="type" required>
<mat-option *ngFor="let option of pollTypes | keyvalue" [value]="option.key">
{{ option.value | translate }}
</mat-option>
@ -56,7 +56,7 @@
</mat-select>
</mat-form-field>
<mat-form-field>
<mat-select placeholder="{{ 'Majority' | translate }}" formControlName="majority_method" required>
<mat-select placeholder="{{ 'Required majority' | translate }}" formControlName="majority_method" required>
<mat-option *ngFor="let option of majorityMethods | keyvalue" [value]="option.key">
{{ option.value | translate }}
</mat-option>

View File

@ -1 +1,3 @@
<span>{{ this.poll.voted_id.length }} von {{ this.max }} haben abgestimmt.</span>
<span>{{ poll.voted_id.length }} von {{ max }} haben abgestimmt.</span>
<mat-progress-bar class="voting-progress-bar" [value]="valueInPercent"></mat-progress-bar>

View File

@ -29,6 +29,10 @@ export class PollProgressComponent extends BaseViewComponent implements OnInit {
super(title, translate, snackbar);
}
public get valueInPercent(): number {
return (this.poll.voted_id.length / this.max) * 100;
}
/**
* OnInit.
* Sets the observable for groups.

View File

@ -22,9 +22,9 @@ export const PollStateVerbose = {
};
export const PollTypeVerbose = {
analog: 'Analog',
named: 'Named',
pseudoanonymous: 'Pseudoanonymous'
analog: 'Analog voting',
named: 'Named voting',
pseudoanonymous: 'Pseudoanonymous voting'
};
export const PollPropertyVerbose = {
@ -37,9 +37,9 @@ export const PollPropertyVerbose = {
};
export const MajorityMethodVerbose = {
simple: 'Simple',
two_thirds: 'Two Thirds',
three_quarters: 'Three Quarters',
simple: 'Simple majority',
two_thirds: 'Two-thirds majority',
three_quarters: 'Three-quarters majority',
disabled: 'Disabled'
};
@ -48,7 +48,7 @@ export const PercentBaseVerbose = {
YNA: 'Yes/No/Abstain',
votes: 'All votes',
valid: 'Valid votes',
cast: 'Cast votes',
cast: 'Total votes cast',
disabled: 'Disabled'
};
@ -104,6 +104,7 @@ export abstract class ViewBasePoll<M extends BasePoll<M, any> = any> extends Bas
}
return states;
}
public abstract readonly pollClassType: 'motion' | 'assignment';
public canBeVotedFor: () => boolean;

View File

@ -0,0 +1,6 @@
/**
* Define the colors used for yes, no and abstain
*/
$votes-yes-color: #9fd773;
$votes-no-color: #cc6c5b;
$votes-abstain-color: #a6a6a6;

View File

@ -9,7 +9,7 @@ def get_config_variables():
They are grouped in 'Ballot and ballot papers' and 'PDF'. The generator has
to be evaluated during app loading (see apps.py).
"""
# Polls
# Voting
yield ConfigVariable(
name="assignment_poll_default_100_percent_base",
default_value="YNA",
@ -20,7 +20,7 @@ def get_config_variables():
for base in AssignmentPoll.PERCENT_BASES
),
weight=400,
group="Polls",
group="Voting",
subgroup="Elections",
)
@ -35,7 +35,8 @@ def get_config_variables():
label="Required majority",
help_text="Default method to check whether a candidate has reached the required majority.",
weight=405,
group="Polls",
hidden=True,
group="Voting",
subgroup="Elections",
)
@ -45,7 +46,7 @@ def get_config_variables():
input_type="boolean",
label="Put all candidates on the list of speakers",
weight=410,
group="Polls",
group="Voting",
subgroup="Elections",
)

View File

@ -117,7 +117,7 @@ def set_correct_state(apps, schema_editor):
AssignmentPoll = apps.get_model("assignments", "AssignmentPoll")
AssignmentVote = apps.get_model("assignments", "AssignmentVote")
for poll in AssignmentPoll.objects.all():
# Polls, that are published (old field) but have no votes, will be
# Voting, that are published (old field) but have no votes, will be
# left at the created state...
if AssignmentVote.objects.filter(option__poll__pk=poll.pk).exists():
if poll.published:

View File

@ -6,7 +6,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0026_remove_history_restricted"),
("core", "0025_projector_color"),
]
operations = [

View File

@ -37,7 +37,7 @@ def calculate_aspect_ratios(apps, schema_editor):
class Migration(migrations.Migration):
dependencies = [
("core", "0027_projector_size_1"),
("core", "0026_projector_size_1"),
]
operations = [

View File

@ -6,7 +6,7 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("core", "0028_projector_size_2"),
("core", "0027_projector_size_2"),
]
operations = [

View File

@ -5,6 +5,6 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [("core", "0025_projector_color")]
dependencies = [("core", "0028_projector_size_3")]
operations = [migrations.RemoveField(model_name="history", name="restricted")]

View File

@ -388,18 +388,18 @@ def get_config_variables():
subgroup="PDF export",
)
# Polls
# Voting
yield ConfigVariable(
name="motion_poll_default_100_percent_base",
default_value="YNA",
input_type="choice",
label="The 100-%-base of an election result consists of",
label="The 100 % base of a voting result consists of",
choices=tuple(
{"value": base[0], "display_name": base[1]}
for base in MotionPoll.PERCENT_BASES
),
weight=420,
group="Polls",
group="Voting",
subgroup="Motions",
)
@ -412,8 +412,9 @@ def get_config_variables():
for method in MotionPoll.MAJORITY_METHODS
),
label="Required majority",
help_text="Default method to check whether a candidate has reached the required majority.",
help_text="Default method to check whether a motion has reached the required majority.",
weight=425,
group="Polls",
hidden=True,
group="Voting",
subgroup="Motions",
)