Wait for server while voting

Blocks voting state changes and prevents the user from sending multiple
vote values while the server is not responding during voting
This commit is contained in:
Sean 2020-06-03 14:53:31 +02:00
parent 7665634d42
commit dced8fbcc7
11 changed files with 117 additions and 34 deletions

View File

@ -1,5 +1,5 @@
<ng-container *ngIf="poll"> <ng-container *ngIf="poll">
<ng-container *ngIf="vmanager.canVote(poll) && !alreadyVoted; else cannotVote"> <ng-container *ngIf="vmanager.canVote(poll) && !alreadyVoted && !deliveringVote; else cannotVote">
<!-- Poll hint --> <!-- Poll hint -->
<p *ngIf="pollHint"> <p *ngIf="pollHint">
<i>{{ pollHint }}</i> <i>{{ pollHint }}</i>
@ -9,9 +9,7 @@
<h4 *ngIf="poll.pollmethod === AssignmentPollMethod.Votes && poll.votes_amount > 1"> <h4 *ngIf="poll.pollmethod === AssignmentPollMethod.Votes && poll.votes_amount > 1">
{{ 'Available votes' | translate }}: {{ 'Available votes' | translate }}:
<b> <b> {{ getVotesAvailable() }}/{{ poll.votes_amount }} </b>
{{ getVotesAvailable() }}/{{ poll.votes_amount }}
</b>
</h4> </h4>
<!-- Options and Actions --> <!-- Options and Actions -->
@ -39,6 +37,7 @@
class="vote-button" class="vote-button"
mat-raised-button mat-raised-button
(click)="saveSingleVote(option.id, action.vote)" (click)="saveSingleVote(option.id, action.vote)"
[disabled]="deliveringVote"
[ngClass]=" [ngClass]="
voteRequestData.votes[option.id] === action.vote || voteRequestData.votes[option.id] === action.vote ||
voteRequestData.votes[option.id] === 1 voteRequestData.votes[option.id] === 1
@ -67,6 +66,7 @@
mat-raised-button mat-raised-button
(click)="saveGlobalVote('N')" (click)="saveGlobalVote('N')"
[ngClass]="voteRequestData.global === 'N' ? 'voted-no' : ''" [ngClass]="voteRequestData.global === 'N' ? 'voted-no' : ''"
[disabled]="deliveringVote"
> >
<mat-icon> thumb_down </mat-icon> <mat-icon> thumb_down </mat-icon>
</button> </button>
@ -103,13 +103,19 @@
<ng-template #cannotVote> <ng-template #cannotVote>
<div class="centered-button-wrapper"> <div class="centered-button-wrapper">
<div> <div *ngIf="!deliveringVote">
<mat-icon class="vote-submitted"> <mat-icon class="vote-submitted">
check_circle check_circle
</mat-icon> </mat-icon>
<br /> <br />
<span>{{ 'Voting successful.' | translate }}</span> <span>{{ 'Voting successful.' | translate }}</span>
</div> </div>
<div *ngIf="deliveringVote" class="submit-vote-indicator">
<mat-spinner class="small-spinner"></mat-spinner>
<br />
<span>{{ 'Delivering vote... Please wait!' | translate }}</span>
</div>
</div> </div>
</ng-template> </ng-template>

View File

@ -67,3 +67,10 @@
.mat-divider-horizontal { .mat-divider-horizontal {
position: initial; position: initial;
} }
.submit-vote-indicator {
text-align: center;
.mat-spinner {
margin: auto;
}
}

View File

@ -1,4 +1,4 @@
import { Component, OnInit } from '@angular/core'; import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar'; import { MatSnackBar } from '@angular/material/snack-bar';
import { Title } from '@angular/platform-browser'; import { Title } from '@angular/platform-browser';
@ -29,7 +29,8 @@ interface VoteActions {
@Component({ @Component({
selector: 'os-assignment-poll-vote', selector: 'os-assignment-poll-vote',
templateUrl: './assignment-poll-vote.component.html', templateUrl: './assignment-poll-vote.component.html',
styleUrls: ['./assignment-poll-vote.component.scss'] styleUrls: ['./assignment-poll-vote.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class AssignmentPollVoteComponent extends BasePollVoteComponent<ViewAssignmentPoll> implements OnInit { export class AssignmentPollVoteComponent extends BasePollVoteComponent<ViewAssignmentPoll> implements OnInit {
public AssignmentPollMethod = AssignmentPollMethod; public AssignmentPollMethod = AssignmentPollMethod;
@ -47,7 +48,8 @@ export class AssignmentPollVoteComponent extends BasePollVoteComponent<ViewAssig
operator: OperatorService, operator: OperatorService,
public vmanager: VotingService, public vmanager: VotingService,
private pollRepo: AssignmentPollRepositoryService, private pollRepo: AssignmentPollRepositoryService,
private promptService: PromptService private promptService: PromptService,
private cd: ChangeDetectorRef
) { ) {
super(title, translate, matSnackbar, operator); super(title, translate, matSnackbar, operator);
} }
@ -58,6 +60,7 @@ export class AssignmentPollVoteComponent extends BasePollVoteComponent<ViewAssig
this.defineVoteOptions(); this.defineVoteOptions();
} else { } else {
this.alreadyVoted = true; this.alreadyVoted = true;
this.cd.markForCheck();
} }
} }
@ -104,20 +107,24 @@ export class AssignmentPollVoteComponent extends BasePollVoteComponent<ViewAssig
return !!this.voteRequestData.global; return !!this.voteRequestData.global;
} }
public submitVote(): void { public async submitVote(): Promise<void> {
const title = this.translate.instant('Submit selection now?'); const title = this.translate.instant('Submit selection now?');
const content = this.translate.instant('Your decision cannot be changed afterwards.'); const content = this.translate.instant('Your decision cannot be changed afterwards.');
this.promptService.open(title, content).then(confirmed => { const confirmed = await this.promptService.open(title, content);
if (confirmed) { if (confirmed) {
this.deliveringVote = true;
this.cd.markForCheck();
this.pollRepo this.pollRepo
.vote(this.voteRequestData, this.poll.id) .vote(this.voteRequestData, this.poll.id)
.then(() => { .then(() => {
this.alreadyVoted = true; this.alreadyVoted = true;
}) })
.catch(this.raiseError); .catch(this.raiseError)
} .finally(() => {
this.deliveringVote = false;
}); });
} }
}
public saveSingleVote(optionId: number, vote: VoteValue): void { public saveSingleVote(optionId: number, vote: VoteValue): void {
if (this.isGlobalOptionSelected()) { if (this.isGlobalOptionSelected()) {

View File

@ -42,10 +42,16 @@
mat-stroked-button mat-stroked-button
[ngClass]="pollStateActions[poll.state].css" [ngClass]="pollStateActions[poll.state].css"
(click)="changeState(poll.nextState)" (click)="changeState(poll.nextState)"
[disabled]="stateChangePending"
> >
<mat-icon> {{ pollStateActions[poll.state].icon }}</mat-icon> <mat-icon> {{ pollStateActions[poll.state].icon }}</mat-icon>
<span class="next-state-label"> <span class="next-state-label">
<ng-container *ngIf="!stateChangePending">
{{ poll.nextStateActionVerbose | translate }} {{ poll.nextStateActionVerbose | translate }}
</ng-container>
<ng-container *ngIf="stateChangePending">
{{ 'In progress, please wait...' | translate }}
</ng-container>
</span> </span>
</button> </button>
</div> </div>

View File

@ -2,19 +2,26 @@
<os-poll-progress [poll]="poll"></os-poll-progress> <os-poll-progress [poll]="poll"></os-poll-progress>
</div> </div>
<ng-container *ngIf="poll && !poll.user_has_voted; else userHasVotes"> <ng-container *ngIf="poll && !poll.user_has_voted; else userHasVotes">
<div *ngIf="vmanager.canVote(poll)" class="vote-button-grid"> <div *ngIf="vmanager.canVote(poll) && !deliveringVote" class="vote-button-grid">
<!-- Voting --> <!-- Voting -->
<div class="vote-button" *ngFor="let option of voteOptions"> <div class="vote-button" *ngFor="let option of voteOptions">
<button <button
mat-raised-button mat-raised-button
(click)="saveVote(option.vote)" (click)="saveVote(option.vote)"
[ngClass]="currentVote && currentVote.vote === option.vote ? option.css : ''" [ngClass]="currentVote && currentVote.vote === option.vote ? option.css : ''"
[disabled]="deliveringVote"
> >
<mat-icon> {{ option.icon }}</mat-icon> <mat-icon> {{ option.icon }}</mat-icon>
</button> </button>
<span class="vote-label"> {{ option.label | translate }} </span> <span class="vote-label"> {{ option.label | translate }} </span>
</div> </div>
</div> </div>
<div *ngIf="deliveringVote" class="submit-vote-indicator">
<mat-spinner class="small-spinner"></mat-spinner>
<br />
<span>{{ 'Delivering vote... Please wait!' | translate }}</span>
</div>
</ng-container> </ng-container>
<ng-template #userHasVotes> <ng-template #userHasVotes>

View File

@ -8,6 +8,14 @@
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
} }
.submit-vote-indicator {
margin-top: 1em;
text-align: center;
.mat-spinner {
margin: auto;
}
}
.vote-button { .vote-button {
display: inline-grid; display: inline-grid;
grid-gap: 1em; grid-gap: 1em;

View File

@ -1,4 +1,4 @@
import { Component } from '@angular/core'; import { ChangeDetectionStrategy, ChangeDetectorRef, Component } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar'; import { MatSnackBar } from '@angular/material/snack-bar';
import { Title } from '@angular/platform-browser'; import { Title } from '@angular/platform-browser';
@ -22,7 +22,8 @@ interface VoteOption {
@Component({ @Component({
selector: 'os-motion-poll-vote', selector: 'os-motion-poll-vote',
templateUrl: './motion-poll-vote.component.html', templateUrl: './motion-poll-vote.component.html',
styleUrls: ['./motion-poll-vote.component.scss'] styleUrls: ['./motion-poll-vote.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class MotionPollVoteComponent extends BasePollVoteComponent<ViewMotionPoll> { export class MotionPollVoteComponent extends BasePollVoteComponent<ViewMotionPoll> {
public currentVote: VoteOption = {}; public currentVote: VoteOption = {};
@ -54,19 +55,28 @@ export class MotionPollVoteComponent extends BasePollVoteComponent<ViewMotionPol
operator: OperatorService, operator: OperatorService,
public vmanager: VotingService, public vmanager: VotingService,
private pollRepo: MotionPollRepositoryService, private pollRepo: MotionPollRepositoryService,
private promptService: PromptService private promptService: PromptService,
private cd: ChangeDetectorRef
) { ) {
super(title, translate, matSnackbar, operator); super(title, translate, matSnackbar, operator);
} }
public saveVote(vote: VoteValue): void { public async saveVote(vote: VoteValue): Promise<void> {
this.currentVote.vote = vote; this.currentVote.vote = vote;
const title = this.translate.instant('Submit selection now?'); const title = this.translate.instant('Submit selection now?');
const content = this.translate.instant('Your decision cannot be changed afterwards.'); const content = this.translate.instant('Your decision cannot be changed afterwards.');
this.promptService.open(title, content).then(confirmed => { const confirmed = await this.promptService.open(title, content);
if (confirmed) { if (confirmed) {
this.pollRepo.vote(vote, this.poll.id).catch(this.raiseError); this.deliveringVote = true;
} this.cd.markForCheck();
this.pollRepo
.vote(vote, this.poll.id)
.catch(this.raiseError)
.finally(() => {
this.deliveringVote = false;
}); });
} }
} }
}

View File

@ -42,10 +42,15 @@
<!-- Change state button --> <!-- Change state button -->
<div *osPerms="'motions.can_manage_polls'; and: !hideChangeState"> <div *osPerms="'motions.can_manage_polls'; and: !hideChangeState">
<button mat-stroked-button [ngClass]="pollStateActions[poll.state].css" (click)="changeState(poll.nextState)"> <button mat-stroked-button [ngClass]="pollStateActions[poll.state].css" (click)="changeState(poll.nextState)" [disabled]="stateChangePending">
<mat-icon> {{ pollStateActions[poll.state].icon }}</mat-icon> <mat-icon> {{ pollStateActions[poll.state].icon }}</mat-icon>
<span class="next-state-label"> <span class="next-state-label">
<ng-container *ngIf="!stateChangePending">
{{ poll.nextStateActionVerbose | translate }} {{ poll.nextStateActionVerbose | translate }}
</ng-container>
<ng-container *ngIf="stateChangePending">
{{ 'In progress, please wait...' | translate }}
</ng-container>
</span> </span>
</button> </button>
</div> </div>

View File

@ -16,6 +16,8 @@ export abstract class BasePollVoteComponent<V extends ViewBasePoll> extends Base
public votingErrors = VotingError; public votingErrors = VotingError;
public deliveringVote = false;
protected user: ViewUser; protected user: ViewUser;
public constructor( public constructor(

View File

@ -15,6 +15,8 @@ import { PollService } from '../services/poll.service';
import { ViewBasePoll } from '../models/view-base-poll'; import { ViewBasePoll } from '../models/view-base-poll';
export abstract class BasePollComponent<V extends ViewBasePoll, S extends PollService> extends BaseViewComponent { export abstract class BasePollComponent<V extends ViewBasePoll, S extends PollService> extends BaseViewComponent {
public stateChangePending = false;
public chartDataSubject: BehaviorSubject<ChartData> = new BehaviorSubject([]); public chartDataSubject: BehaviorSubject<ChartData> = new BehaviorSubject([]);
protected _poll: V; protected _poll: V;
@ -55,10 +57,22 @@ export abstract class BasePollComponent<V extends ViewBasePoll, S extends PollSe
const title = this.translate.instant('Are you sure you want to reset this vote?'); const title = this.translate.instant('Are you sure you want to reset this vote?');
const content = this.translate.instant('All votes will be lost.'); const content = this.translate.instant('All votes will be lost.');
if (await this.promptService.open(title, content)) { if (await this.promptService.open(title, content)) {
this.repo.resetPoll(this._poll).catch(this.raiseError); this.stateChangePending = true;
this.repo
.resetPoll(this._poll)
.catch(this.raiseError)
.finally(() => {
this.stateChangePending = false;
});
} }
} else { } else {
this.repo.changePollState(this._poll).catch(this.raiseError); this.stateChangePending = true;
this.repo
.changePollState(this._poll)
.catch(this.raiseError)
.finally(() => {
this.stateChangePending = false;
});
} }
} }

View File

@ -778,6 +778,17 @@ button.mat-menu-item.selected {
display: none !important; /* hide scrollbars in webkit browsers */ display: none !important; /* hide scrollbars in webkit browsers */
} }
.small-spinner {
// 24px is the size of a normal icon
$spinner-size: 24px;
height: $spinner-size !important;
height: $spinner-size !important;
svg {
height: $spinner-size !important;
height: $spinner-size !important;
}
}
.import-table { .import-table {
.table-container { .table-container {
width: 100%; width: 100%;