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;
}
export interface VotingData {
votes: Object;
global?: GlobalVote;
}
export type GlobalVote = 'A' | 'N';
/**
* Repository Service for Assignments.
*
@ -109,8 +116,14 @@ export class AssignmentPollRepositoryService extends BasePollRepositoryService<
return this.translate.instant(plural ? 'Polls' : 'Poll');
};
// TODO: data must not be any
public vote(data: any, poll_id: number): Promise<void> {
return this.http.post(`/rest/assignments/assignment-poll/${poll_id}/vote/`, data);
public vote(data: VotingData, poll_id: number): Promise<void> {
let requestData;
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>
</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">
<mat-icon>more_vert</mat-icon>
</button>
@ -150,20 +150,20 @@
<!-- More Menu -->
<mat-menu #pollDetailMenu="matMenu">
<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>
<span translate>Edit</span>
</button>
<button
mat-menu-item
*osPerms="'assignments.can_manage_polls'; and: poll && poll.type === 'named'"
*osPerms="'assignments.can_manage'; and: poll && poll.type === 'named'"
(click)="pseudoanonymizePoll()"
>
<mat-icon>warning</mat-icon>
<span translate>Anonymize votes</span>
</button>
<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>
<span translate>Delete</span>
</button>

View File

@ -1,12 +1,12 @@
<ng-container *ngIf="poll">
<ng-container *ngIf="vmanager.canVote(poll)">
<!-- TODO: Someone should make this pretty -->
<span *ngIf="poll.user_has_voted_valid">Your vote is valid!</span>
<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>
<ng-container *ngIf="vmanager.canVote(poll) && !alreadyVoted; else cannotVote">
<!-- Submit Vote -->
<ng-container [ngTemplateOutlet]="sendNow"></ng-container>
<!-- 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 }}
</h4>
@ -34,7 +34,11 @@
<button
mat-raised-button
(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>
</button>
@ -55,7 +59,7 @@
<button
mat-raised-button
(click)="saveGlobalVote('N')"
[ngClass]="currentVotes['global'] === 'No' ? 'voted-no' : ''"
[ngClass]="voteRequestData.global === 'N' ? 'voted-no' : ''"
>
<mat-icon> thumb_down </mat-icon>
</button>
@ -68,7 +72,7 @@
<button
mat-raised-button
(click)="saveGlobalVote('A')"
[ngClass]="currentVotes['global'] === 'Abstain' ? 'voted-abstain' : ''"
[ngClass]="voteRequestData.global === 'A' ? 'voted-abstain' : ''"
>
<mat-icon> trip_origin</mat-icon>
</button>
@ -78,6 +82,9 @@
</div>
</div>
</ng-container>
<!-- Submit Vote -->
<ng-container [ngTemplateOutlet]="sendNow"></ng-container>
</ng-container>
<!-- Shows the permission error -->
@ -85,3 +92,24 @@
<span>{{ vmanager.getVotePermissionErrorVerbose(poll) | translate }}</span>
</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 {
background-color: $votes-yes-color;
}

View File

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

View File

@ -21,7 +21,7 @@
<!-- Buttons -->
<button
mat-icon-button
*osPerms="['core.can_manage_projector', 'assignments.can_manage_polls']"
*osPerms="['core.can_manage_projector', 'assignments.can_manage']"
[matMenuTriggerFor]="pollItemMenu"
(click)="$event.stopPropagation()"
>
@ -30,7 +30,7 @@
</div>
<!-- Change state button -->
<div *osPerms="'assignments.can_manage_polls'">
<div *osPerms="'assignments.can_manage'">
<button
mat-stroked-button
*ngIf="!poll.isPublished"
@ -67,7 +67,7 @@
</div>
<!-- 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>
</div>
<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.operator.getViewUserObservable().subscribe(user => {
this.user = user;
this.updateVotes();
// this.updateVotes();
})
);
}
protected abstract updateVotes(): void;
// protected abstract updateVotes(): void;
}

View File

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

View File

@ -85,8 +85,6 @@ export class PollFormComponent<T extends ViewBasePoll> extends BaseViewComponent
*/
public publishImmediately = true;
public showSingleAmountHint = 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
*/
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) {
this.showNonNominalWarning = true;
} else {
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> {

View File

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