Enhance Assignment Voting

- repaired the PDF Service for ballots
- fixed some permission errors
- analog voting has no "started" option anymore
- more-link as button
- named voting has a progress bar
- Shows the poll type for eVoting
- Moves and declutters meta info
- Enhance the grid and the layout in detail view
- declutter and enhance the dot-menus
- some other layout changes
- remove breadcrumbs in assignment detail
- other cleanups refinements
- Voting in Assignment over instead of forms
(requires more server changes)
This commit is contained in:
Sean Engelhardt 2020-02-11 11:24:43 +01:00 committed by FinnStutzenstein
parent 524a97cdcc
commit 6044c63c28
23 changed files with 383 additions and 319 deletions

View File

@ -116,6 +116,7 @@ export class AssignmentPollRepositoryService extends BasePollRepositoryService<
return this.translate.instant(plural ? 'Polls' : 'Poll'); return this.translate.instant(plural ? 'Polls' : 'Poll');
}; };
// TODO: data must not be any
public vote(data: any, poll_id: number): Promise<void> { public vote(data: any, poll_id: number): Promise<void> {
return this.http.post(`/rest/assignments/assignment-poll/${poll_id}/vote/`, data); return this.http.post(`/rest/assignments/assignment-poll/${poll_id}/vote/`, data);
} }

View File

@ -51,19 +51,19 @@ export abstract class BasePoll<T = any, O extends BaseOption<any> = any> extends
public onehundred_percent_base: PercentBase; public onehundred_percent_base: PercentBase;
public user_has_voted: boolean; public user_has_voted: boolean;
public get isStateCreated(): boolean { public get isCreated(): boolean {
return this.state === PollState.Created; return this.state === PollState.Created;
} }
public get isStateStarted(): boolean { public get isStarted(): boolean {
return this.state === PollState.Started; return this.state === PollState.Started;
} }
public get isStateFinished(): boolean { public get isFinished(): boolean {
return this.state === PollState.Finished; return this.state === PollState.Finished;
} }
public get isStatePublished(): boolean { public get isPublished(): boolean {
return this.state === PollState.Published; return this.state === PollState.Published;
} }
@ -71,20 +71,6 @@ export abstract class BasePoll<T = any, O extends BaseOption<any> = any> extends
return this.onehundred_percent_base === PercentBase.Valid || this.onehundred_percent_base === PercentBase.Cast; return this.onehundred_percent_base === PercentBase.Valid || this.onehundred_percent_base === PercentBase.Cast;
} }
/**
* If the state is finished.
*/
public get isFinished(): boolean {
return this.state === PollState.Finished;
}
/**
* If the state is published.
*/
public get isPublished(): boolean {
return this.state === PollState.Published;
}
/** /**
* Determine if the state is finished or published * Determine if the state is finished or published
*/ */

View File

@ -6,7 +6,7 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms';
// MaterialUI modules // MaterialUI modules
import { MatBadgeModule } from '@angular/material/badge'; import { MatBadgeModule } from '@angular/material/badge';
import { MatBottomSheetModule } from '@angular/material/bottom-sheet'; import { MatBottomSheetModule } from '@angular/material/bottom-sheet';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule, MatAnchor } from '@angular/material/button';
import { MatButtonToggleModule } from '@angular/material/button-toggle'; import { MatButtonToggleModule } from '@angular/material/button-toggle';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatCheckboxModule } from '@angular/material/checkbox';

View File

@ -1,16 +1,9 @@
<os-head-bar <os-head-bar [goBack]="true" [nav]="false">
[goBack]="true"
[nav]="false"
[hasMainButton]="poll ? poll.type === 'analog' && (poll.state === 2 || poll.state === 3) : false"
[mainButtonIcon]="'edit'"
[mainActionTooltip]="'Edit' | translate"
(mainEvent)="openDialog()"
>
<div class="title-slot"> <div class="title-slot">
<h2 *ngIf="!!poll">{{ poll.title }}</h2> <h2 *ngIf="!!poll">{{ poll.title }}</h2>
</div> </div>
<div class="menu-slot" *osPerms="'agenda.can_manage'; or: 'agenda.can_see_list_of_speakers'"> <div class="menu-slot" *osPerms="'assignments.can_manage_polls'">
<button type="button" mat-icon-button [matMenuTriggerFor]="pollDetailMenu"> <button type="button" mat-icon-button [matMenuTriggerFor]="pollDetailMenu">
<mat-icon>more_vert</mat-icon> <mat-icon>more_vert</mat-icon>
</button> </button>
@ -25,30 +18,14 @@
<ng-template #viewTemplate> <ng-template #viewTemplate>
<ng-container *ngIf="isReady"> <ng-container *ngIf="isReady">
<h1>{{ poll.title }}</h1> <h1>{{ poll.title }}</h1>
<mat-divider></mat-divider> <span *ngIf="poll.type !== 'analog'">{{ poll.typeVerbose | translate }}</span>
<os-breadcrumb [breadcrumbs]="breadcrumbs"></os-breadcrumb>
<div class="poll-content">
<div>{{ 'Current state' | translate }}: {{ poll.stateVerbose | translate }}</div>
<div *ngIf="poll.groups && poll.type && poll.type !== 'analog'">
{{ 'Groups' | translate }}:
<span *ngFor="let group of poll.groups">{{ group.getTitle() | translate }}</span>
</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.stateHasVotes"> <div *ngIf="poll.stateHasVotes">
<h2 translate>Result</h2> <h2 translate>Result</h2>
<div class="chart-wrapper" [ngClass]="{ flex: isVotedPoll }"> <div class="result-wrapper">
<mat-table [dataSource]="poll.tableData"> <!-- Result Table -->
<mat-table class="result-table" [dataSource]="poll.tableData">
<ng-container matColumnDef="user" sticky> <ng-container matColumnDef="user" sticky>
<mat-header-cell *matHeaderCellDef>{{ 'Candidates' | translate }}</mat-header-cell> <mat-header-cell *matHeaderCellDef>{{ 'Candidates' | translate }}</mat-header-cell>
<mat-cell *matCellDef="let row">{{ row.user }}</mat-cell> <mat-cell *matCellDef="let row">{{ row.user }}</mat-cell>
@ -81,43 +58,64 @@
<mat-row *matRowDef="let row; columns: columnDefinitionOverview"></mat-row> <mat-row *matRowDef="let row; columns: columnDefinitionOverview"></mat-row>
</mat-table> </mat-table>
<div class="chart-inner-wrapper"> <!-- Result Chart -->
<os-charts <os-charts
*ngIf="chartDataSubject.value" class="result-chart"
[type]="chartType" *ngIf="chartDataSubject.value"
[labels]="candidatesLabels" [type]="chartType"
[size]="isVotedPoll ? 70 : 100" [labels]="candidatesLabels"
[legendPosition]="isVotedPoll ? 'right' : 'top'" [data]="chartDataSubject"
[showLegend]="true" [hasPadding]="false"
[data]="chartDataSubject" [legendPosition]="isVotedPoll ? 'right' : 'top'"
></os-charts> ></os-charts>
<!-- Named Result -->
<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="users" sticky>
<mat-header-cell *matHeaderCellDef>{{ 'User' | translate }}</mat-header-cell>
<mat-cell *matCellDef="let row">
<div *ngIf="row.user">{{ row.user.getFullName() }}</div>
<div *ngIf="!row.user">{{ 'Unknown user' | translate }}</div>
</mat-cell>
</ng-container>
<ng-container
[matColumnDef]="'votes-' + option.user_id"
*ngFor="let option of poll.options"
sticky
>
<mat-header-cell *matHeaderCellDef>
<div *ngIf="option.user">{{ option.user.getFullName() }}</div>
<div *ngIf="!option.user">{{ 'Unknown user' | translate }}</div>
</mat-header-cell>
<mat-cell *matCellDef="let row">
{{ row.votes[option.user_id] }}
</mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="columnDefinitionPerName"></mat-header-row>
<mat-row *matRowDef="let row; columns: columnDefinitionPerName"></mat-row>
</mat-table>
</div> </div>
</div> </div>
</div>
<ng-container *ngIf="poll.type === 'named' && votesDataSource.data"> <!-- Meta Infos -->
<input matInput [(ngModel)]="votesDataSource.filter" placeholder="Filter" /> <div class="poll-content small">
<mat-table [dataSource]="votesDataSource"> <div *ngIf="poll.groups && poll.type && poll.type !== 'analog'">
<ng-container matColumnDef="users" sticky> {{ 'Groups' | translate }}:
<mat-header-cell *matHeaderCellDef>{{ 'User' | translate }}</mat-header-cell>
<mat-cell *matCellDef="let row">
<div *ngIf="row.user">{{ row.user.getFullName() }}</div>
<div *ngIf="!row.user">{{ 'Unknown user' | translate }}</div>
</mat-cell>
</ng-container>
<ng-container [matColumnDef]="'votes-' + option.user_id" *ngFor="let option of poll.options" sticky>
<mat-header-cell *matHeaderCellDef>
<div *ngIf="option.user">{{ option.user.getFullName() }}</div>
<div *ngIf="!option.user">{{ 'Unknown user' | translate }}</div>
</mat-header-cell>
<mat-cell *matCellDef="let row">
{{ row.votes[option.user_id] }}
</mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="columnDefinitionPerName"></mat-header-row> <span *ngFor="let group of poll.groups; let i = index">
<mat-row *matRowDef="let row; columns: columnDefinitionPerName"></mat-row> {{ group.getTitle() | translate }}<span *ngIf="i < poll.groups.length - 1">, </span>
</mat-table> </span>
</ng-container> </div>
<div>{{ 'Required majority' | translate }}: {{ poll.majorityMethodVerbose | translate }}</div>
<div>{{ '100% base' | translate }}: {{ poll.percentBaseVerbose | translate }}</div>
</div> </div>
</ng-container> </ng-container>
</ng-template> </ng-template>
@ -125,13 +123,21 @@
<!-- More Menu --> <!-- More Menu -->
<mat-menu #pollDetailMenu="matMenu"> <mat-menu #pollDetailMenu="matMenu">
<os-projector-button [menuItem]="true" [object]="poll" *osPerms="'core.can_manage_projector'"></os-projector-button> <os-projector-button [menuItem]="true" [object]="poll" *osPerms="'core.can_manage_projector'"></os-projector-button>
<button mat-menu-item *ngIf="poll && poll.type === 'named'" (click)="pseudoanonymizePoll()"> <button *osPerms="'assignments.can_manage_polls'" mat-menu-item (click)="openDialog()">
<mat-icon>polymer</mat-icon> <mat-icon>edit</mat-icon>
<span translate>Pseudoanonymize</span> <span translate>Edit</span>
</button>
<button
mat-menu-item
*osPerms="'assignments.can_manage_polls'; and: poll && poll.type === 'named'"
(click)="pseudoanonymizePoll()"
>
<mat-icon>warning</mat-icon>
<span translate>Anonymize votes</span>
</button> </button>
<mat-divider></mat-divider> <mat-divider></mat-divider>
<button mat-menu-item (click)="deletePoll()"> <button *osPerms="'assignments.can_manage_polls'" mat-menu-item (click)="deletePoll()">
<mat-icon>delete</mat-icon> <mat-icon color="warn">delete</mat-icon>
<span translate>Delete</span> <span translate>Delete</span>
</button> </button>
</mat-menu> </mat-menu>

View File

@ -1,15 +1,40 @@
.chart-wrapper { @import '~assets/styles/variables.scss';
&.flex {
display: flex;
.mat-table { .result-wrapper {
flex: 2; display: grid;
.mat-column-votes { grid-gap: 10px;
justify-content: center; grid-template-areas:
} 'chart'
} 'results'
.chart-inner-wrapper { 'names';
flex: 3; }
}
@include desktop {
.result-wrapper {
grid-template-areas:
'results chart'
'names names';
grid-template-columns: 2fr 1fr;
} }
} }
.result-table {
grid-area: results;
}
.result-chart {
grid-area: chart;
max-width: 300px;
}
.named-result-table {
grid-area: names;
.mat-form-field {
font-size: 14px;
width: 100%;
}
}
.poll-content {
padding-top: 20px;
}

View File

@ -91,6 +91,8 @@ export class AssignmentPollDetailComponent extends BasePollDetailComponent<ViewA
if (this.isVotedPoll) { if (this.isVotedPoll) {
this._chartType = 'doughnut'; this._chartType = 'doughnut';
this.chartDataSubject.next(this.poll.generateCircleChartData()); this.chartDataSubject.next(this.poll.generateCircleChartData());
} else {
super.initChartData();
} }
} }

View File

@ -1,79 +1,80 @@
<ng-container *ngIf="poll"> <ng-container *ngIf="poll">
<!-- Poll progress bar -->
<div *osPerms="'assignments.can_manage_polls'; and: poll.isStarted">
<os-poll-progress [poll]="poll"></os-poll-progress>
</div>
<ng-container *ngIf="vmanager.canVote(poll)"> <ng-container *ngIf="vmanager.canVote(poll)">
<form *ngIf="voteForm" [formGroup]="voteForm" class="voting-grid"> <!-- Leftover votes -->
<!-- empty divs to fit the grid --> <h4 *ngIf="poll.pollmethod === pollMethods.Votes">
<div></div><div></div> {{ 'Votes for this poll' | translate }}: {{ poll.votes_amount }}
<div> <!-- ({{ getVotesCount() }}/{{ poll.votes_amount }} {{ 'Votes' | translate }}) -->
<span *ngIf="poll.pollmethod === pollMethods.Votes"> </h4>
({{ getVotesCount() }}/{{ poll.votes_amount }} {{ 'Votes' | translate }})
</span>
</div>
<!-- candidate votes --> <!-- Options and Actions -->
<ng-container *ngFor="let option of poll.options" formGroupName="votes"> <div *ngFor="let option of poll.options; let i = index">
<div> <div
<span *ngIf="option.user">{{ option.user.getFullName() }}</span> [ngClass]="{
<span *ngIf="!option.user">{{ "Unknown user" | translate }}</span> 'yna-grid': poll.pollmethod === pollMethods.YNA,
</div> 'yn-grid': poll.pollmethod === pollMethods.YN,
'single-vote-grid': poll.pollmethod === pollMethods.Votes
<div class="current-vote"> }"
<ng-container *ngIf="poll.pollmethod !== pollMethods.Votes && currentVotes[option.user_id]">
({{ 'Current' | translate }}: {{ currentVotes[option.user_id] | translate }})
</ng-container>
<ng-container *ngIf="poll.pollmethod === pollMethods.Votes && currentVotes[option.user_id]">
({{ 'Current choice' | translate }})
</ng-container>
</div>
<mat-radio-group
name="votes-{{ poll.id }}-{{ option.id }}"
[formControlName]="option.id"
>
<mat-radio-button value="Y" (click)="yesButtonClicked($event, option.id.toString())">
<span translate>Yes</span>
</mat-radio-button>
<mat-radio-button value="N" *ngIf="poll.pollmethod !== pollMethods.Votes">
<span translate>No</span>
</mat-radio-button>
<mat-radio-button value="A" *ngIf="poll.pollmethod === pollMethods.YNA">
<span translate>Abstain</span>
</mat-radio-button>
</mat-radio-group>
</ng-container>
<!-- global no/abstain -->
<ng-container *ngIf="poll.pollmethod === pollMethods.Votes && (poll.global_no || poll.global_abstain)">
<!-- empty div to fit the grid -->
<div></div>
<div class="current-vote">
<ng-container *ngIf="currentVotes.global">
({{ 'Current' | translate }}: {{ currentVotes.global | translate }})
</ng-container>
</div>
<mat-radio-group
name="votes-{{ poll.id }}-global"
formControlName="global"
>
<mat-radio-button value="N" *ngIf="poll.global_no">
<span translate>Global no</span>
</mat-radio-button>
<mat-radio-button value="A" *ngIf="poll.global_abstain">
<span translate>Global abstain</span>
</mat-radio-button>
</mat-radio-group>
</ng-container>
</form>
<div class="right-align" *ngIf="poll.type !== PollType.Named || poll.pollmethod !== pollMethods.Votes">
<button
mat-button
mat-button-default
(click)="saveVotes()"
[disabled]="isSaveButtonDisabled()"
> >
<span translate>Save</span> <div class="vote-candidate-name">
</button> <span *ngIf="option.user">{{ option.user.getFullName() }}</span>
<span *ngIf="!option.user">{{ 'Unknown user' | translate }}</span>
</div>
<div *ngFor="let action of voteActions">
<button
mat-raised-button
(click)="saveSingleVote(option.id, action.vote)"
[ngClass]="currentVotes[option.id] ? action.css : ''"
>
<mat-icon> {{ action.icon }}</mat-icon>
</button>
<span *ngIf="poll.pollmethod !== pollMethods.Votes" class="vote-label">
{{ action.label | translate }}
</span>
</div>
</div>
<mat-divider *ngIf="poll.options.length - 1 > i"></mat-divider>
</div> </div>
<!-- global no/abstain -->
<ng-container *ngIf="poll.pollmethod === pollMethods.Votes && (poll.global_no || poll.global_abstain)">
<mat-divider></mat-divider>
<div class="global-option-grid">
<div *ngIf="poll.global_no">
<button
mat-raised-button
(click)="saveGlobalVote('N')"
[ngClass]="currentVotes['global'] === 'No' ? 'voted-no' : ''"
>
<mat-icon> thumb_down </mat-icon>
</button>
<span class="vote-label">
{{ 'No to all' | translate }}
</span>
</div>
<div *ngIf="poll.global_abstain">
<button
mat-raised-button
(click)="saveGlobalVote('A')"
[ngClass]="currentVotes['global'] === 'Abstain' ? 'voted-abstain' : ''"
>
<mat-icon> trip_origin</mat-icon>
</button>
<span class="vote-label">
{{ 'Abstain' | translate }}
</span>
</div>
</div>
</ng-container>
</ng-container> </ng-container>
<!-- Shows the permission error -->
<ng-container *ngIf="!vmanager.canVote(poll)"> <ng-container *ngIf="!vmanager.canVote(poll)">
<span>{{ vmanager.getVotePermissionErrorVerbose(poll) | translate }}</span> <span>{{ vmanager.getVotePermissionErrorVerbose(poll) | translate }}</span>
</ng-container> </ng-container>

View File

@ -1,12 +1,61 @@
.current-vote { @import '~assets/styles/poll-colors.scss';
color: #777;
margin-right: 10px; %vote-grid-base {
display: grid;
grid-gap: 10px;
margin: 20px 0;
} }
.voting-grid { .yn-grid {
display: grid; @extend %vote-grid-base;
grid-gap: 5px; grid-template-areas:
padding: 5px; 'name name'
align-items: baseline; 'yes no';
grid-template-columns: auto max-content max-content; }
.yna-grid {
@extend %vote-grid-base;
grid-template-areas:
'name name name'
'yes no abstain';
}
.single-vote-grid {
@extend %vote-grid-base;
grid-template-areas: 'yes name';
grid-template-columns: min-content auto;
}
.global-option-grid {
@extend %vote-grid-base;
grid-template-columns: auto auto;
}
.vote-candidate-name {
grid-area: name;
display: flex;
span {
margin-top: auto;
margin-bottom: auto;
}
}
.voted-yes {
background-color: $votes-yes-color;
}
.voted-no {
background-color: $votes-no-color;
}
.voted-abstain {
background-color: $votes-abstain-color;
}
.vote-label {
margin-left: 10px;
}
.mat-divider-horizontal {
position: initial;
} }

View File

@ -1,5 +1,4 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { AbstractControl, FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms';
import { MatSnackBar } from '@angular/material'; import { MatSnackBar } from '@angular/material';
import { Title } from '@angular/platform-browser'; import { Title } from '@angular/platform-browser';
@ -15,6 +14,14 @@ import { BasePollVoteComponent } from 'app/site/polls/components/base-poll-vote.
import { ViewAssignmentPoll } from '../../models/view-assignment-poll'; import { ViewAssignmentPoll } from '../../models/view-assignment-poll';
import { ViewAssignmentVote } from '../../models/view-assignment-vote'; import { ViewAssignmentVote } from '../../models/view-assignment-vote';
// TODO: Duplicate
interface VoteActions {
vote: 'Y' | 'N' | 'A';
css: string;
icon: string;
label: string;
}
@Component({ @Component({
selector: 'os-assignment-poll-vote', selector: 'os-assignment-poll-vote',
templateUrl: './assignment-poll-vote.component.html', templateUrl: './assignment-poll-vote.component.html',
@ -23,8 +30,7 @@ import { ViewAssignmentVote } from '../../models/view-assignment-vote';
export class AssignmentPollVoteComponent extends BasePollVoteComponent<ViewAssignmentPoll> implements OnInit { export class AssignmentPollVoteComponent extends BasePollVoteComponent<ViewAssignmentPoll> implements OnInit {
public pollMethods = AssignmentPollMethods; public pollMethods = AssignmentPollMethods;
public PollType = PollType; public PollType = PollType;
public voteActions: VoteActions[] = [];
public voteForm: FormGroup;
/** holds the currently saved votes */ /** holds the currently saved votes */
public currentVotes: { [key: number]: string | null; global?: string } = {}; public currentVotes: { [key: number]: string | null; global?: string } = {};
@ -38,13 +44,13 @@ export class AssignmentPollVoteComponent extends BasePollVoteComponent<ViewAssig
vmanager: VotingService, vmanager: VotingService,
operator: OperatorService, operator: OperatorService,
private voteRepo: AssignmentVoteRepositoryService, private voteRepo: AssignmentVoteRepositoryService,
private pollRepo: AssignmentPollRepositoryService, private pollRepo: AssignmentPollRepositoryService
private formBuilder: FormBuilder
) { ) {
super(title, translate, matSnackbar, vmanager, operator); super(title, translate, matSnackbar, vmanager, operator);
} }
public ngOnInit(): void { public ngOnInit(): void {
this.defineVoteOptions();
this.subscriptions.push( this.subscriptions.push(
this.voteRepo.getViewModelListObservable().subscribe(votes => { this.voteRepo.getViewModelListObservable().subscribe(votes => {
this.votes = votes; this.votes = votes;
@ -53,119 +59,74 @@ export class AssignmentPollVoteComponent extends BasePollVoteComponent<ViewAssig
); );
} }
private defineVoteOptions(): void {
this.voteActions.push({
vote: 'Y',
css: 'voted-yes',
icon: 'thumb_up',
label: 'Yes'
});
if (this.poll.pollmethod !== AssignmentPollMethods.Votes) {
this.voteActions.push({
vote: 'N',
css: 'voted-no',
icon: 'thumb_down',
label: 'No'
});
}
if (this.poll.pollmethod === AssignmentPollMethods.YNA) {
this.voteActions.push({
vote: 'A',
css: 'voted-abstain',
icon: 'trip_origin',
label: 'Abstain'
});
}
}
protected updateVotes(): void { protected updateVotes(): void {
if (this.user && this.votes && this.poll) { if (this.user && this.votes && this.poll) {
const filtered = this.votes.filter( const filtered = this.votes.filter(
vote => vote.option.poll_id === this.poll.id && vote.user_id === this.user.id vote => vote.option.poll_id === this.poll.id && vote.user_id === this.user.id
); );
this.voteForm = this.formBuilder.group({
votes: this.formBuilder.group(
this.poll.options.mapToObject(option => ({ [option.id]: ['', [Validators.required]] }))
)
});
if (
this.poll.pollmethod === AssignmentPollMethods.Votes &&
(this.poll.global_no || this.poll.global_abstain)
) {
this.voteForm.addControl('global', new FormControl('', Validators.required));
}
for (const option of this.poll.options) { for (const option of this.poll.options) {
let curr_vote = filtered.find(vote => vote.option.id === option.id); let curr_vote = filtered.find(vote => vote.option.id === option.id);
if (this.poll.pollmethod === AssignmentPollMethods.Votes && curr_vote) { if (this.poll.pollmethod === AssignmentPollMethods.Votes && curr_vote) {
if (curr_vote.value !== 'Y') { if (curr_vote.value !== 'Y') {
this.currentVotes.global = curr_vote.valueVerbose; this.currentVotes.global = curr_vote.valueVerbose;
this.voteForm.controls.global.setValue(curr_vote.value);
curr_vote = null; curr_vote = null;
} else { } else {
this.currentVotes.global = null; this.currentVotes.global = null;
} }
} }
this.currentVotes[option.user_id] = curr_vote && curr_vote.valueVerbose; this.currentVotes[option.id] = curr_vote && curr_vote.valueVerbose;
this.voteForm.get(['votes', option.id]).setValue(curr_vote && curr_vote.value);
}
if (this.poll.pollmethod === AssignmentPollMethods.Votes) {
this.voteForm.controls.votes.valueChanges.subscribe(value => {
if (Object.values(value).some(vote => vote)) {
const ctrl = this.voteForm.controls.global;
if (ctrl) {
ctrl.reset();
}
this.saveVotesIfNamed();
}
});
this.voteForm.controls.global.valueChanges.subscribe(value => {
if (value) {
this.voteForm.controls.votes.reset();
this.saveVotesIfNamed();
}
});
} }
} }
} }
private saveVotesIfNamed(): void { private getPollOptionIds(): number[] {
if (this.poll.type === PollType.Named && !this.isSaveButtonDisabled()) { return this.poll.options.map(option => option.id);
this.saveVotes();
}
} }
public saveVotes(): void { public saveSingleVote(optionId: number, vote: 'Y' | 'N' | 'A'): void {
let values = this.voteForm.value.votes; const pollOptionIds = this.getPollOptionIds();
// convert Y to 1 and null to 0 for votes method const requestMap = pollOptionIds.reduce((o, n) => {
if (this.poll.pollmethod === this.pollMethods.Votes) { if ((n === optionId && vote === 'Y') !== (this.currentVotes[n] === 'Yes')) {
if (this.voteForm.value.global) { o[n] = 1;
values = JSON.stringify(this.voteForm.value.global);
} else { } else {
this.poll.options.forEach(option => { o[n] = 0;
values[option.id] = this.voteForm.value.votes[option.id] === 'Y' ? 1 : 0;
});
} }
}
this.pollRepo.vote(values, this.poll.id).catch(this.raiseError); return o;
}, {});
this.pollRepo.vote(JSON.stringify(requestMap), this.poll.id).catch(this.raiseError);
} }
public isSaveButtonDisabled(): boolean { public saveGlobalVote(globalVote: 'N' | 'A'): void {
return ( this.pollRepo.vote(`"${globalVote}"`, this.poll.id).catch(this.raiseError);
!this.voteForm ||
this.voteForm.pristine ||
(this.poll.pollmethod === AssignmentPollMethods.Votes
? !this.getAllFormControls().some(control => control.valid)
: this.voteForm.invalid)
);
}
public getVotesCount(): number {
return Object.values(this.voteForm.value.votes).filter(vote => vote).length;
}
private getAllFormControls(): AbstractControl[] {
if (this.voteForm) {
const votesFormGroup = this.voteForm.controls.votes as FormGroup;
return [...Object.values(votesFormGroup.controls), this.voteForm.controls.global];
} else {
return [];
}
}
public yesButtonClicked($event: MouseEvent, optionId: string): void {
if (this.poll.pollmethod === AssignmentPollMethods.Votes) {
// check current value (before click)
if (this.voteForm.value.votes[optionId] === 'Y') {
// this handler is executed before the mat-radio-button handler, so we have to set a timeout or else the other handler will just set the value again
setTimeout(() => {
this.voteForm.get(['votes', optionId]).setValue(null);
this.voteForm.markAsDirty();
this.saveVotesIfNamed();
});
} else {
// check if by clicking this button, the amount of votes would succeed the permitted amount
if (this.getVotesCount() >= this.poll.votes_amount) {
$event.preventDefault();
}
}
}
} }
} }

View File

@ -1,4 +1,4 @@
<mat-card class="os-card" *ngIf="poll"> <mat-card class="os-card" *ngIf="poll && showPoll()">
<div class="assignment-poll-wrapper"> <div class="assignment-poll-wrapper">
<div class="assignment-poll-title-header"> <div class="assignment-poll-title-header">
<mat-card-title> <mat-card-title>
@ -8,28 +8,24 @@
</mat-card-title> </mat-card-title>
<div class="poll-properties"> <div class="poll-properties">
<mat-chip <mat-chip
*osPerms="'assignments.can_manage'" *osPerms="'assignments.can_manage_polls'"
class="poll-state active" class="poll-state active"
[disableRipple]="true" [disableRipple]="true"
[matMenuTriggerFor]="triggerMenu" [matMenuTriggerFor]="triggerMenu"
[ngClass]="poll.stateVerbose.toLowerCase()" [class]="poll.stateVerbose.toLowerCase()"
> [ngClass]="{ disabled: !poll.getNextStates() }"
{{ poll.stateVerbose | translate }}
</mat-chip>
<mat-chip
*ngIf="!canManage && poll.isPublished"
[disableRipple]="true"
class="poll-state active"
[ngClass]="poll.stateVerbose.toLowerCase()"
> >
{{ poll.stateVerbose | translate }} {{ poll.stateVerbose | translate }}
</mat-chip> </mat-chip>
<span *ngIf="poll.type !== 'analog'">
{{ poll.typeVerbose | translate }}
</span>
</div> </div>
<div class="poll-menu"> <div class="poll-menu">
<!-- Buttons --> <!-- Buttons -->
<button <button
mat-icon-button mat-icon-button
*osPerms="'assignments.can_manage'; &quot;core.can_manage_projector&quot;" *osPerms="'assignments.motions.can_manage_polls';or: 'core.can_manage_projector'"
[matMenuTriggerFor]="pollItemMenu" [matMenuTriggerFor]="pollItemMenu"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
> >
@ -39,9 +35,19 @@
</div> </div>
<div *ngIf="hasVotes"> <div *ngIf="hasVotes">
<os-charts [type]="chartType" [labels]="candidatesLabels" [data]="chartDataSubject"></os-charts> <os-charts
[type]="chartType"
[labels]="candidatesLabels"
[data]="chartDataSubject"
[hasPadding]="false"
></os-charts>
</div> </div>
<os-assignment-poll-vote *ngIf="poll.canBeVotedFor" [poll]="poll"></os-assignment-poll-vote> <os-assignment-poll-vote *ngIf="poll.canBeVotedFor" [poll]="poll"></os-assignment-poll-vote>
<div class="poll-detail-button-wrapper">
<a mat-button routerLink="/assignments/polls/{{ poll.id }}">
{{ 'More' | translate }}
</a>
</div>
</div> </div>
</mat-card> </mat-card>
@ -64,6 +70,10 @@
<os-projector-button menuItem="true" [object]="poll"></os-projector-button> <os-projector-button menuItem="true" [object]="poll"></os-projector-button>
</div> </div>
<div *osPerms="'assignments.can_manage'"> <div *osPerms="'assignments.can_manage'">
<button mat-menu-item (click)="printBallot()">
<mat-icon>picture_as_pdf</mat-icon>
<span translate>Ballot paper</span>
</button>
<mat-divider></mat-divider> <mat-divider></mat-divider>
<button mat-menu-item class="red-warning-text" (click)="onDeletePoll()"> <button mat-menu-item class="red-warning-text" (click)="onDeletePoll()">
<mat-icon>delete</mat-icon> <mat-icon>delete</mat-icon>

View File

@ -1,7 +1,7 @@
.assignment-poll-wrapper { .assignment-poll-wrapper {
@import '~assets/styles/poll-common-styles.scss'; @import '~assets/styles/poll-common-styles.scss';
position: relative; position: relative;
padding: 0 15px; margin: 0 15px;
.poll-menu { .poll-menu {
position: absolute; position: absolute;
@ -39,4 +39,12 @@
} }
} }
} }
.poll-detail-button-wrapper {
display: flex;
margin: auto 0;
> a {
margin-left: auto;
}
}
} }

View File

@ -15,6 +15,7 @@ import { PollState } from 'app/shared/models/poll/base-poll';
import { BasePollComponent } from 'app/site/polls/components/base-poll.component'; import { BasePollComponent } from 'app/site/polls/components/base-poll.component';
import { PollService } from 'app/site/polls/services/poll.service'; import { PollService } from 'app/site/polls/services/poll.service';
import { AssignmentPollDialogService } from '../../services/assignment-poll-dialog.service'; import { AssignmentPollDialogService } from '../../services/assignment-poll-dialog.service';
import { AssignmentPollPdfService } from '../../services/assignment-poll-pdf.service';
import { ViewAssignmentOption } from '../../models/view-assignment-option'; import { ViewAssignmentOption } from '../../models/view-assignment-option';
import { ViewAssignmentPoll } from '../../models/view-assignment-poll'; import { ViewAssignmentPoll } from '../../models/view-assignment-poll';
@ -89,7 +90,8 @@ export class AssignmentPollComponent extends BasePollComponent<ViewAssignmentPol
pollDialog: AssignmentPollDialogService, pollDialog: AssignmentPollDialogService,
public pollService: PollService, public pollService: PollService,
private operator: OperatorService, private operator: OperatorService,
private formBuilder: FormBuilder private formBuilder: FormBuilder,
private pdfService: AssignmentPollPdfService
) { ) {
super(titleService, matSnackBar, translate, dialog, promptService, repo, pollDialog); super(titleService, matSnackBar, translate, dialog, promptService, repo, pollDialog);
} }
@ -105,11 +107,17 @@ export class AssignmentPollComponent extends BasePollComponent<ViewAssignmentPol
/** /**
* Print the PDF of this poll with the corresponding options and numbers * Print the PDF of this poll with the corresponding options and numbers
*
*/ */
public printBallot(): void { public printBallot(): void {
throw new Error('TODO'); this.pdfService.printBallots(this.poll);
// this.pdfService.printBallots(this.poll); }
public showPoll(): boolean {
return (
this.operator.hasPerms('assignments.can_manage_polls') ||
this.poll.isPublished ||
(this.poll.type !== 'analog' && this.poll.isStarted)
);
} }
/** /**

View File

@ -2,7 +2,7 @@ import { BehaviorSubject } from 'rxjs';
import { ChartData } from 'app/shared/components/charts/charts.component'; import { ChartData } from 'app/shared/components/charts/charts.component';
import { AssignmentPoll, AssignmentPollMethods } from 'app/shared/models/assignments/assignment-poll'; import { AssignmentPoll, AssignmentPollMethods } from 'app/shared/models/assignments/assignment-poll';
import { PollColor } from 'app/shared/models/poll/base-poll'; import { PollColor, PollState } from 'app/shared/models/poll/base-poll';
import { BaseViewModel } from 'app/site/base/base-view-model'; import { BaseViewModel } from 'app/site/base/base-view-model';
import { ProjectorElementBuildDeskriptor } from 'app/site/base/projectable'; import { ProjectorElementBuildDeskriptor } from 'app/site/base/projectable';
import { PollData, ViewBasePoll } from 'app/site/polls/models/view-base-poll'; import { PollData, ViewBasePoll } from 'app/site/polls/models/view-base-poll';
@ -87,6 +87,16 @@ export class ViewAssignmentPoll extends ViewBasePoll<AssignmentPoll> implements
return data; return data;
} }
/**
* Override from base poll to skip started state in analog poll type
*/
public getNextStates(): { [key: number]: string } {
if (this.poll.type === 'analog' && this.state === PollState.Created) {
return null;
}
return super.getNextStates();
}
public getPercentBase(): number { public getPercentBase(): number {
return 0; return 0;
} }

View File

@ -7,6 +7,7 @@ import { PdfDocumentService } from 'app/core/pdf-services/pdf-document.service';
import { AssignmentRepositoryService } from 'app/core/repositories/assignments/assignment-repository.service'; import { AssignmentRepositoryService } from 'app/core/repositories/assignments/assignment-repository.service';
import { UserRepositoryService } from 'app/core/repositories/users/user-repository.service'; import { UserRepositoryService } from 'app/core/repositories/users/user-repository.service';
import { ConfigService } from 'app/core/ui-services/config.service'; import { ConfigService } from 'app/core/ui-services/config.service';
import { AssignmentPollMethods } from 'app/shared/models/assignments/assignment-poll';
import { ViewAssignmentPoll } from '../models/view-assignment-poll'; import { ViewAssignmentPoll } from '../models/view-assignment-poll';
/** /**
@ -138,7 +139,7 @@ export class AssignmentPollPdfService extends PollPdfService {
// TODO: typing of result // TODO: typing of result
private createCandidateFields(poll: ViewAssignmentPoll): object { private createCandidateFields(poll: ViewAssignmentPoll): object {
/*const candidates = poll.options.sort((a, b) => { const candidates = poll.options.sort((a, b) => {
return a.weight - b.weight; return a.weight - b.weight;
}); });
const resultObject = candidates.map(cand => { const resultObject = candidates.map(cand => {
@ -151,13 +152,12 @@ export class AssignmentPollPdfService extends PollPdfService {
noEntry.margin[1] = 25; noEntry.margin[1] = 25;
resultObject.push(noEntry); resultObject.push(noEntry);
} }
return resultObject;*/ return resultObject;
throw new Error('TODO');
} }
// TODO: typing of result // TODO: typing of result
/*private createYNBallotEntry(option: string, method: AssignmentPollmethods): object { private createYNBallotEntry(option: string, method: AssignmentPollMethods): object {
const choices = method === 'yna' ? ['Yes', 'No', 'Abstain'] : ['Yes', 'No']; const choices = method === 'YNA' ? ['Yes', 'No', 'Abstain'] : ['Yes', 'No'];
const columnstack = choices.map(choice => { const columnstack = choices.map(choice => {
return { return {
width: 'auto', width: 'auto',
@ -174,7 +174,7 @@ export class AssignmentPollPdfService extends PollPdfService {
columns: columnstack columns: columnstack
} }
]; ];
}*/ }
/** /**
* Generates the poll description * Generates the poll description
@ -184,10 +184,9 @@ export class AssignmentPollPdfService extends PollPdfService {
*/ */
// TODO: typing of result // TODO: typing of result
private createPollHint(poll: ViewAssignmentPoll): object { private createPollHint(poll: ViewAssignmentPoll): object {
/*return { return {
text: poll.description || '', text: poll.description || '',
style: 'description' style: 'description'
};*/ };
throw new Error('TODO');
} }
} }

View File

@ -31,7 +31,6 @@
class="result-chart" class="result-chart"
*ngIf="chartDataSubject.value" *ngIf="chartDataSubject.value"
[type]="chartType" [type]="chartType"
[showLegend]="true"
[data]="chartDataSubject" [data]="chartDataSubject"
></os-charts> ></os-charts>

View File

@ -42,7 +42,7 @@
</div> </div>
<!-- Publish immediately button. Only show for new polls --> <!-- Publish immediately button. Only show for new polls -->
<div *ngIf="!pollData.isStatePublished"> <div *ngIf="!pollData.isPublished">
<mat-checkbox [(ngModel)]="publishImmediately" (change)="publishStateChanged($event.checked)"> <mat-checkbox [(ngModel)]="publishImmediately" (change)="publishStateChanged($event.checked)">
<span translate>Publish immediately</span> <span translate>Publish immediately</span>
</mat-checkbox> </mat-checkbox>

View File

@ -1,5 +1,5 @@
<ng-container *ngIf="poll"> <ng-container *ngIf="poll">
<div *osPerms="'motions.can_manage_polls';and:poll.isStateStarted"> <div *osPerms="'motions.can_manage_polls';and:poll.isStarted">
<os-poll-progress [poll]="poll"></os-poll-progress> <os-poll-progress [poll]="poll"></os-poll-progress>
</div> </div>
<ng-container *ngIf="vmanager.canVote(poll)"> <ng-container *ngIf="vmanager.canVote(poll)">

View File

@ -20,19 +20,11 @@
</div> </div>
<mat-chip <mat-chip
*ngIf="poll.getNextStates()"
disableRipple disableRipple
class="poll-state active"
[matMenuTriggerFor]="triggerMenu" [matMenuTriggerFor]="triggerMenu"
[ngClass]="poll.stateVerbose.toLowerCase()"
>
{{ poll.stateVerbose }}
</mat-chip>
<mat-chip
*ngIf="!poll.getNextStates()"
disableRipple
class="poll-state active" class="poll-state active"
[ngClass]="poll.stateVerbose.toLowerCase()" [class]="poll.stateVerbose.toLowerCase()"
[ngClass]="{ 'disabled': !poll.getNextStates() }"
> >
{{ poll.stateVerbose }} {{ poll.stateVerbose }}
</mat-chip> </mat-chip>
@ -87,9 +79,9 @@
</div> </div>
</div> </div>
<div class="poll-detail-button-wrapper"> <div class="poll-detail-button-wrapper">
<button mat-button [routerLink]="pollLink"> <a mat-button [routerLink]="pollLink">
{{ 'More' | translate }} {{ 'More' | translate }}
</button> </a>
</div> </div>
</ng-template> </ng-template>

View File

@ -88,7 +88,7 @@
.poll-detail-button-wrapper { .poll-detail-button-wrapper {
display: flex; display: flex;
margin: auto 0; margin: auto 0;
> button { > a {
margin-left: auto; margin-left: auto;
} }
} }

View File

@ -130,8 +130,8 @@ export class MotionPollComponent extends BasePollComponent<ViewMotionPoll> {
public showPoll(): boolean { public showPoll(): boolean {
return ( return (
this.operator.hasPerms('motions.can_manage_polls') || this.operator.hasPerms('motions.can_manage_polls') ||
this.poll.isStatePublished || this.poll.isPublished ||
(this.poll.type !== 'analog' && this.poll.isStateStarted) (this.poll.type !== 'analog' && this.poll.isStarted)
); );
} }

View File

@ -18,7 +18,7 @@
</span> </span>
</div> </div>
<form [formGroup]="contentForm" class="poll-preview-meta-info-form"> <form [formGroup]="contentForm" class="poll-preview-meta-info-form">
<ng-container *ngIf="!data || !data.state || data.isStateCreated"> <ng-container *ngIf="!data || !data.state || data.isCreated">
<!-- Poll Type --> <!-- Poll Type -->
<mat-form-field *ngIf="pollService.isElectronicVotingEnabled"> <mat-form-field *ngIf="pollService.isElectronicVotingEnabled">
<mat-select [placeholder]="PollPropertyVerbose.type | translate" formControlName="type" required> <mat-select [placeholder]="PollPropertyVerbose.type | translate" formControlName="type" required>
@ -71,7 +71,7 @@
<!-- Amount of Votes --> <!-- Amount of Votes -->
<ng-container <ng-container
*ngIf="contentForm.get('pollmethod').value === 'votes' && (!data || !data.state || data.isStateCreated)" *ngIf="contentForm.get('pollmethod').value === 'votes' && (!data || !data.state || data.isCreated)"
> >
<mat-form-field> <mat-form-field>
<input <input

View File

@ -856,6 +856,13 @@ button.mat-menu-item.selected {
} }
} }
/**
* Use to disable events on (i.e) matMenuTriggerFor
*/
.disabled {
pointer-events: none;
}
// custom horrizontal scroll-bar // custom horrizontal scroll-bar
.h-scroller { .h-scroller {

View File

@ -407,7 +407,7 @@ class AssignmentPollViewSet(BasePollViewSet):
YN/YNA: YN/YNA:
{<option_id>: 'Y' | 'N' [|'A']} {<option_id>: 'Y' | 'N' [|'A']}
- all option_ids must be given - all option_ids must be given TODO: No it must not be that way. Single Votes have to be accepted
- 'A' is only allowed in YNA pollmethod - 'A' is only allowed in YNA pollmethod
Votes for all options have to be given Votes for all options have to be given