Rework assignment voting

- Remove "assignments.can_manage_polls" permission
- Let the client handle some user errors
- Add a send button to manually submit polls
- Show a hint that the user already submitted a vote
  - will not (and should not) work for non-nominal voting
- submitting a vote cannot be changed anymore
  - user will have to confirm sending
- enable deselecting YNA-votings
- nomainal voting will behace the same as non nominal voting
- submitting empty votes should be possible

Perhaps server side adjustments might still be required
This commit is contained in:
Sean Engelhardt 2020-03-09 16:40:35 +01:00 committed by FinnStutzenstein
parent 61b7731073
commit 8fe5a0c9f4
10 changed files with 159 additions and 111 deletions

View File

@ -53,6 +53,13 @@ export interface AssignmentAnalogVoteData {
global_abstain?: number; global_abstain?: number;
} }
export interface VotingData {
votes: Object;
global?: GlobalVote;
}
export type GlobalVote = 'A' | 'N';
/** /**
* Repository Service for Assignments. * Repository Service for Assignments.
* *
@ -109,8 +116,14 @@ 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: VotingData, poll_id: number): Promise<void> {
public vote(data: any, poll_id: number): Promise<void> { let requestData;
return this.http.post(`/rest/assignments/assignment-poll/${poll_id}/vote/`, data); if (data.global) {
requestData = `"${data.global}"`;
} else {
requestData = data.votes;
}
return this.http.post(`/rest/assignments/assignment-poll/${poll_id}/vote/`, requestData);
} }
} }

View File

@ -3,7 +3,7 @@
<h2 *ngIf="!!poll">{{ poll.title }}</h2> <h2 *ngIf="!!poll">{{ poll.title }}</h2>
</div> </div>
<div class="menu-slot" *osPerms="'assignments.can_manage_polls'"> <div class="menu-slot" *osPerms="'assignments.can_manage'">
<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>
@ -150,20 +150,20 @@
<!-- 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 *osPerms="'assignments.can_manage_polls'" mat-menu-item (click)="openDialog(poll)"> <button *osPerms="'assignments.can_manage'" mat-menu-item (click)="openDialog(poll)">
<mat-icon>edit</mat-icon> <mat-icon>edit</mat-icon>
<span translate>Edit</span> <span translate>Edit</span>
</button> </button>
<button <button
mat-menu-item mat-menu-item
*osPerms="'assignments.can_manage_polls'; and: poll && poll.type === 'named'" *osPerms="'assignments.can_manage'; and: poll && poll.type === 'named'"
(click)="pseudoanonymizePoll()" (click)="pseudoanonymizePoll()"
> >
<mat-icon>warning</mat-icon> <mat-icon>warning</mat-icon>
<span translate>Anonymize votes</span> <span translate>Anonymize votes</span>
</button> </button>
<mat-divider></mat-divider> <mat-divider></mat-divider>
<button *osPerms="'assignments.can_manage_polls'" mat-menu-item (click)="deletePoll()"> <button *osPerms="'assignments.can_manage'" mat-menu-item (click)="deletePoll()">
<mat-icon color="warn">delete</mat-icon> <mat-icon color="warn">delete</mat-icon>
<span translate>Delete</span> <span translate>Delete</span>
</button> </button>

View File

@ -1,12 +1,12 @@
<ng-container *ngIf="poll"> <ng-container *ngIf="poll">
<ng-container *ngIf="vmanager.canVote(poll)"> <ng-container *ngIf="vmanager.canVote(poll) && !alreadyVoted; else cannotVote">
<!-- TODO: Someone should make this pretty --> <!-- Submit Vote -->
<span *ngIf="poll.user_has_voted_valid">Your vote is valid!</span> <ng-container [ngTemplateOutlet]="sendNow"></ng-container>
<span *ngIf="poll.user_has_voted_invalid">DANGER: Your vote is invalid!</span>
<span *ngIf="poll.user_has_not_voted">You have not give any voting here!</span>
<!-- Leftover votes --> <!-- Leftover votes -->
<h4 *ngIf="poll.pollmethod === AssignmentPollMethod.Votes && poll.votes_amount > 1 && !currentVotes.global"> <h4
*ngIf="poll.pollmethod === AssignmentPollMethod.Votes && poll.votes_amount > 1 && !isGlobalOptionSelected()"
>
{{ 'Votes for this poll' | translate }}: {{ getVotesCount() }}/{{ poll.votes_amount }} {{ 'Votes for this poll' | translate }}: {{ getVotesCount() }}/{{ poll.votes_amount }}
</h4> </h4>
@ -34,7 +34,11 @@
<button <button
mat-raised-button mat-raised-button
(click)="saveSingleVote(option.id, action.vote)" (click)="saveSingleVote(option.id, action.vote)"
[ngClass]="currentVotes[option.id] === action.label ? action.css : ''" [ngClass]="
voteRequestData.votes[option.id] === action.vote || voteRequestData.votes[option.id] === 1
? action.css
: ''
"
> >
<mat-icon> {{ action.icon }}</mat-icon> <mat-icon> {{ action.icon }}</mat-icon>
</button> </button>
@ -55,7 +59,7 @@
<button <button
mat-raised-button mat-raised-button
(click)="saveGlobalVote('N')" (click)="saveGlobalVote('N')"
[ngClass]="currentVotes['global'] === 'No' ? 'voted-no' : ''" [ngClass]="voteRequestData.global === 'N' ? 'voted-no' : ''"
> >
<mat-icon> thumb_down </mat-icon> <mat-icon> thumb_down </mat-icon>
</button> </button>
@ -68,7 +72,7 @@
<button <button
mat-raised-button mat-raised-button
(click)="saveGlobalVote('A')" (click)="saveGlobalVote('A')"
[ngClass]="currentVotes['global'] === 'Abstain' ? 'voted-abstain' : ''" [ngClass]="voteRequestData.global === 'A' ? 'voted-abstain' : ''"
> >
<mat-icon> trip_origin</mat-icon> <mat-icon> trip_origin</mat-icon>
</button> </button>
@ -78,6 +82,9 @@
</div> </div>
</div> </div>
</ng-container> </ng-container>
<!-- Submit Vote -->
<ng-container [ngTemplateOutlet]="sendNow"></ng-container>
</ng-container> </ng-container>
<!-- Shows the permission error --> <!-- Shows the permission error -->
@ -85,3 +92,24 @@
<span>{{ vmanager.getVotePermissionErrorVerbose(poll) | translate }}</span> <span>{{ vmanager.getVotePermissionErrorVerbose(poll) | translate }}</span>
</ng-container> </ng-container>
</ng-container> </ng-container>
<ng-template #cannotVote>
<div class="centered-button-wrapper">
<os-icon-container icon="check">
{{ 'You already voted on this poll' | translate}}
</os-icon-container>
</div>
</ng-template>
<ng-template #sendNow>
<div class="centered-button-wrapper">
<button mat-flat-button color="accent" (click)="submitVote()">
<mat-icon>
how_to_vote
</mat-icon>
<span>
{{ 'Submit vote now' | translate }}
</span>
</button>
</div>
</ng-template>

View File

@ -40,6 +40,15 @@
} }
} }
.centered-button-wrapper {
display: flex;
> * {
margin-left: auto;
margin-right: auto;
}
}
// TODO: Could be some more general component
.voted-yes { .voted-yes {
background-color: $votes-yes-color; background-color: $votes-yes-color;
} }

View File

@ -5,23 +5,28 @@ import { Title } from '@angular/platform-browser';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { OperatorService } from 'app/core/core-services/operator.service'; import { OperatorService } from 'app/core/core-services/operator.service';
import { AssignmentPollRepositoryService } from 'app/core/repositories/assignments/assignment-poll-repository.service'; import {
import { AssignmentVoteRepositoryService } from 'app/core/repositories/assignments/assignment-vote-repository.service'; AssignmentPollRepositoryService,
GlobalVote,
VotingData
} from 'app/core/repositories/assignments/assignment-poll-repository.service';
import { PromptService } from 'app/core/ui-services/prompt.service';
import { VotingService } from 'app/core/ui-services/voting.service'; import { VotingService } from 'app/core/ui-services/voting.service';
import { AssignmentPollMethod } from 'app/shared/models/assignments/assignment-poll'; import { AssignmentPollMethod } from 'app/shared/models/assignments/assignment-poll';
import { PollType } from 'app/shared/models/poll/base-poll'; import { PollType } from 'app/shared/models/poll/base-poll';
import { BasePollVoteComponent } from 'app/site/polls/components/base-poll-vote.component'; import { BasePollVoteComponent } from 'app/site/polls/components/base-poll-vote.component';
import { ViewAssignmentPoll } from '../../models/view-assignment-poll'; import { ViewAssignmentPoll } from '../../models/view-assignment-poll';
import { ViewAssignmentVote } from '../../models/view-assignment-vote';
// TODO: Duplicate // TODO: Duplicate
interface VoteActions { interface VoteActions {
vote: 'Y' | 'N' | 'A'; vote: Vote;
css: string; css: string;
icon: string; icon: string;
label: string; label: string;
} }
type Vote = 'Y' | 'N' | 'A';
@Component({ @Component({
selector: 'os-assignment-poll-vote', selector: 'os-assignment-poll-vote',
templateUrl: './assignment-poll-vote.component.html', templateUrl: './assignment-poll-vote.component.html',
@ -31,11 +36,10 @@ export class AssignmentPollVoteComponent extends BasePollVoteComponent<ViewAssig
public AssignmentPollMethod = AssignmentPollMethod; public AssignmentPollMethod = AssignmentPollMethod;
public PollType = PollType; public PollType = PollType;
public voteActions: VoteActions[] = []; public voteActions: VoteActions[] = [];
public voteRequestData: VotingData = {
/** holds the currently saved votes */ votes: {}
public currentVotes: { [key: number]: string | null; global?: string } = {}; };
public alreadyVoted: boolean;
private votes: ViewAssignmentVote[];
public constructor( public constructor(
title: Title, title: Title,
@ -43,23 +47,19 @@ export class AssignmentPollVoteComponent extends BasePollVoteComponent<ViewAssig
matSnackbar: MatSnackBar, matSnackbar: MatSnackBar,
vmanager: VotingService, vmanager: VotingService,
operator: OperatorService, operator: OperatorService,
private voteRepo: AssignmentVoteRepositoryService, private pollRepo: AssignmentPollRepositoryService,
private pollRepo: AssignmentPollRepositoryService private promptService: PromptService
) { ) {
super(title, translate, matSnackbar, vmanager, operator); super(title, translate, matSnackbar, vmanager, operator);
} }
public ngOnInit(): void { public ngOnInit(): void {
if (this.poll) { if (this.poll && this.poll.user_has_not_voted) {
this.alreadyVoted = false;
this.defineVoteOptions(); this.defineVoteOptions();
} else {
this.alreadyVoted = true;
} }
this.subscriptions.push(
this.voteRepo.getViewModelListObservable().subscribe(votes => {
this.votes = votes;
this.updateVotes();
})
);
} }
private defineVoteOptions(): void { private defineVoteOptions(): void {
@ -90,63 +90,82 @@ export class AssignmentPollVoteComponent extends BasePollVoteComponent<ViewAssig
} }
public getVotesCount(): number { public getVotesCount(): number {
return Object.keys(this.currentVotes).filter(key => this.currentVotes[key]).length; return Object.keys(this.voteRequestData.votes).filter(key => this.voteRequestData.votes[key]).length;
} }
protected updateVotes(): void { public isGlobalOptionSelected(): boolean {
if (this.user && this.votes && this.poll) { return !!this.voteRequestData.global;
const filtered = this.votes.filter(
vote => vote.option.poll_id === this.poll.id && vote.user_id === this.user.id
);
for (const option of this.poll.options) {
let curr_vote = filtered.find(vote => vote.option.id === option.id);
if (this.poll.pollmethod === AssignmentPollMethod.Votes && curr_vote) {
if (curr_vote.value !== 'Y') {
this.currentVotes.global = curr_vote.valueVerbose;
curr_vote = null;
} else {
this.currentVotes.global = null;
}
}
this.currentVotes[option.id] = curr_vote && curr_vote.valueVerbose;
}
}
} }
private getPollOptionIds(): number[] { public submitVote(): void {
return this.poll.options.map(option => option.id); const title = this.translate.instant('Are you sure?');
const content = this.translate.instant('Your decision cannot be changed afterwards');
this.promptService.open(title, content).then(confirmed => {
if (confirmed) {
this.pollRepo
.vote(this.voteRequestData, this.poll.id)
.then(() => {
this.alreadyVoted = true;
})
.catch(this.raiseError);
}
});
}
public saveSingleVote(optionId: number, vote: Vote): void {
if (this.isGlobalOptionSelected()) {
delete this.voteRequestData.global;
} }
public saveSingleVote(optionId: number, vote: 'Y' | 'N' | 'A'): void {
let requestData;
if (this.poll.pollmethod === AssignmentPollMethod.Votes) { if (this.poll.pollmethod === AssignmentPollMethod.Votes) {
const pollOptionIds = this.getPollOptionIds(); const votesAmount = this.poll.votes_amount;
const tmpVoteRequest = this.poll.options
requestData = pollOptionIds.reduce((o, n) => { .map(option => option.id)
.reduce((o, n) => {
o[n] = 0; o[n] = 0;
if (this.poll.votes_amount === 1) { if (votesAmount === 1) {
if (n === optionId && this.currentVotes[n] !== 'Yes') { if (n === optionId && this.voteRequestData.votes[n] !== 1) {
o[n] = 1; o[n] = 1;
} }
} else if ((n === optionId) !== (this.currentVotes[n] === 'Yes')) { } else if ((n === optionId) !== (this.voteRequestData.votes[n] === 1)) {
o[n] = 1; o[n] = 1;
} }
return o; return o;
}, {}); }, {});
// check if you can still vote
const countedVotes = Object.keys(tmpVoteRequest).filter(key => tmpVoteRequest[key]).length;
if (countedVotes <= votesAmount) {
this.voteRequestData.votes = tmpVoteRequest;
// if you have no options anymore, try to send
if (this.getVotesCount() === votesAmount) {
this.submitVote();
}
} else {
this.raiseError(
this.translate.instant('You reached the maximum amount of votes. Deselect somebody first')
);
}
} else { } else {
// YN/YNA // YN/YNA
requestData = {}; if (this.voteRequestData.votes[optionId] && this.voteRequestData.votes[optionId] === vote) {
requestData[optionId] = vote; delete this.voteRequestData.votes[optionId];
} else {
this.voteRequestData.votes[optionId] = vote;
} }
this.pollRepo.vote(requestData, this.poll.id).catch(this.raiseError); // if you filled out every option, try to send
if (Object.keys(this.voteRequestData.votes).length === this.poll.options.length) {
this.submitVote();
}
}
} }
public saveGlobalVote(globalVote: 'N' | 'A'): void { public saveGlobalVote(globalVote: GlobalVote): void {
// This may be a bug in angulars HTTP client: A string is not quoted to be valid json. this.voteRequestData.votes = {};
// Maybe they expect a string to be alrady a jsonified object. this.voteRequestData.global = globalVote;
this.pollRepo.vote(`"${globalVote}"`, this.poll.id).catch(this.raiseError); this.submitVote();
} }
} }

View File

@ -21,7 +21,7 @@
<!-- Buttons --> <!-- Buttons -->
<button <button
mat-icon-button mat-icon-button
*osPerms="['core.can_manage_projector', 'assignments.can_manage_polls']" *osPerms="['core.can_manage_projector', 'assignments.can_manage']"
[matMenuTriggerFor]="pollItemMenu" [matMenuTriggerFor]="pollItemMenu"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
> >
@ -30,7 +30,7 @@
</div> </div>
<!-- Change state button --> <!-- Change state button -->
<div *osPerms="'assignments.can_manage_polls'"> <div *osPerms="'assignments.can_manage'">
<button <button
mat-stroked-button mat-stroked-button
*ngIf="!poll.isPublished" *ngIf="!poll.isPublished"
@ -67,7 +67,7 @@
</div> </div>
<!-- Poll progress bar --> <!-- Poll progress bar -->
<div *osPerms="'assignments.can_manage_polls'; and: poll && poll.isStarted"> <div *osPerms="'assignments.can_manage'; and: poll && poll.isStarted">
<os-poll-progress [poll]="poll"></os-poll-progress> <os-poll-progress [poll]="poll"></os-poll-progress>
</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>

View File

@ -30,10 +30,10 @@ export abstract class BasePollVoteComponent<V extends ViewBasePoll> extends Base
this.subscriptions.push( this.subscriptions.push(
this.operator.getViewUserObservable().subscribe(user => { this.operator.getViewUserObservable().subscribe(user => {
this.user = user; this.user = user;
this.updateVotes(); // this.updateVotes();
}) })
); );
} }
protected abstract updateVotes(): void; // protected abstract updateVotes(): void;
} }

View File

@ -86,7 +86,6 @@
required required
/> />
<mat-hint *ngIf="showSingleAmountHint"> {{ 'Multiple votes are disabled due to security reasons' | translate }}</mat-hint>
</mat-form-field> </mat-form-field>
<mat-checkbox formControlName="global_no">{{ PollPropertyVerbose.global_no | translate }}</mat-checkbox> <mat-checkbox formControlName="global_no">{{ PollPropertyVerbose.global_no | translate }}</mat-checkbox>
<mat-checkbox formControlName="global_abstain">{{ <mat-checkbox formControlName="global_abstain">{{

View File

@ -85,8 +85,6 @@ export class PollFormComponent<T extends ViewBasePoll> extends BaseViewComponent
*/ */
public publishImmediately = true; public publishImmediately = true;
public showSingleAmountHint = false;
public showNonNominalWarning = false; public showNonNominalWarning = false;
/** /**
@ -210,29 +208,11 @@ export class PollFormComponent<T extends ViewBasePoll> extends BaseViewComponent
* TODO: Enabling this requires at least another layout and some rework * TODO: Enabling this requires at least another layout and some rework
*/ */
private setVotesAmountCtrl(): void { private setVotesAmountCtrl(): void {
// Disable "Amounts of votes" if anonymous and yes-method
const votesAmountCtrl = this.contentForm.get('votes_amount');
if (this.contentForm.get('type').value === PollType.Pseudoanonymous) { if (this.contentForm.get('type').value === PollType.Pseudoanonymous) {
this.showNonNominalWarning = true; this.showNonNominalWarning = true;
} else { } else {
this.showNonNominalWarning = false; this.showNonNominalWarning = false;
} }
/**
* TODO: Not required when batch sending works again
*/
if (
this.contentForm.get('type').value === PollType.Pseudoanonymous &&
this.contentForm.get('pollmethod').value === 'votes'
) {
votesAmountCtrl.disable();
votesAmountCtrl.setValue(1);
this.showSingleAmountHint = true;
} else {
votesAmountCtrl.enable();
this.showSingleAmountHint = false;
}
} }
public getValues<V extends ViewBasePoll>(): Partial<V> { public getValues<V extends ViewBasePoll>(): Partial<V> {

View File

@ -16,7 +16,7 @@ class AssignmentAccessPermissions(BaseAccessPermissions):
class AssignmentPollAccessPermissions(BasePollAccessPermissions): class AssignmentPollAccessPermissions(BasePollAccessPermissions):
base_permission = "assignments.can_see" base_permission = "assignments.can_see"
manage_permission = "assignments.can_manage_polls" manage_permission = "assignments.can_manage"
additional_fields = ["amount_global_no", "amount_global_abstain"] additional_fields = ["amount_global_no", "amount_global_abstain"]