Even more voting refinement

Various additional refinements for a more well rounded
voting experience
This commit is contained in:
Sean Engelhardt 2020-03-13 16:11:28 +01:00 committed by FinnStutzenstein
parent a05662a0f8
commit ee4c6aa0bf
31 changed files with 267 additions and 255 deletions

View File

@ -1,4 +1,4 @@
@import '~assets/styles/poll-colors.scss'; @import '~assets/styles/poll-styles-common.scss';
.result-wrapper { .result-wrapper {
display: grid; display: grid;
@ -21,18 +21,6 @@
text-align: right; text-align: right;
} }
} }
.yes {
color: $votes-yes-color;
}
.no {
color: $votes-no-color;
}
.abstain {
color: $votes-abstain-color;
}
} }
.doughnut-chart { .doughnut-chart {

View File

@ -77,6 +77,22 @@ export abstract class BasePoll<
return this.onehundred_percent_base === PercentBase.Cast; return this.onehundred_percent_base === PercentBase.Cast;
} }
public get isAnalog(): boolean {
return this.type === PollType.Analog;
}
public get isNamed(): boolean {
return this.type === PollType.Named;
}
public get isAnon(): boolean {
return this.type === PollType.Pseudoanonymous;
}
public get isEVoting(): boolean {
return this.isNamed || this.isAnon;
}
/** /**
* Determine if the state is finished or published * Determine if the state is finished or published
*/ */

View File

@ -36,13 +36,11 @@ export class PollPercentBasePipe implements PipeTransform {
totalByBase = this.motionPollService.getPercentBase(poll); totalByBase = this.motionPollService.getPercentBase(poll);
} }
if (totalByBase) { if (totalByBase && totalByBase > 0) {
const percentNumber = (value / totalByBase) * 100; const percentNumber = (value / totalByBase) * 100;
if (percentNumber > 0) {
const result = percentNumber % 1 === 0 ? percentNumber : percentNumber.toFixed(this.decimalPlaces); const result = percentNumber % 1 === 0 ? percentNumber : percentNumber.toFixed(this.decimalPlaces);
return `(${result} %)`; return `(${result} %)`;
} }
}
return null; return null;
} }
} }

View File

@ -263,7 +263,7 @@
<mat-form-field> <mat-form-field>
<input <input
matInput matInput
placeholder="{{ 'Default comment on the ballot paper' | translate }}" placeholder="{{ 'Hint on voting' | translate }}"
formControlName="default_poll_description" formControlName="default_poll_description"
/> />
</mat-form-field> </mat-form-field>

View File

@ -9,4 +9,21 @@
border-bottom: 1px solid mat-color($background, focused-button); border-bottom: 1px solid mat-color($background, focused-button);
} }
} }
.openslides-theme .pbl-ngrid-row:hover {
background-color: mat-color($background, card);
}
.openslides-theme os-list-view-table os-sort-filter-bar .custom-table-header {
&,
.action-buttons .input-container input {
background: mat-color($background, card);
}
}
.openslides-theme .pbl-ngrid-header-cell:first-child {
&::after {
border-right: 1px solid mat-color($background, focused-button);
}
}
} }

View File

@ -27,7 +27,7 @@
<tbody> <tbody>
<tr> <tr>
<th class="voting-option" translate>Candidates</th> <th class="voting-option" translate>Candidates</th>
<th class="result voted-yes"> <th class="result yes">
<span *ngIf="!poll.isMethodY" translate> <span *ngIf="!poll.isMethodY" translate>
Yes Yes
</span> </span>
@ -35,8 +35,8 @@
Votes Votes
</span> </span>
</th> </th>
<th class="result voted-no" translate *ngIf="!poll.isMethodY">No</th> <th class="result no" translate *ngIf="!poll.isMethodY">No</th>
<th class="result voted-abstain" translate *ngIf="poll.isMethodYNA">Abstain</th> <th class="result abstain" translate *ngIf="poll.isMethodYNA">Abstain</th>
</tr> </tr>
<tr *ngFor="let row of getTableData()" [class]="row.class"> <tr *ngFor="let row of getTableData()" [class]="row.class">
<td class="voting-option"> <td class="voting-option">

View File

@ -1,5 +1,5 @@
@import '~assets/styles/variables.scss';
@import '~assets/styles/poll-colors.scss'; @import '~assets/styles/poll-colors.scss';
@import '~assets/styles/poll-styles-common.scss';
.assignment-result-wrapper { .assignment-result-wrapper {
.assignment-result-table { .assignment-result-table {
@ -88,30 +88,6 @@
padding-top: 20px; padding-top: 20px;
} }
.voted-yes {
color: $votes-yes-color;
}
.voted-no {
color: $votes-no-color;
}
.voted-abstain {
color: $votes-abstain-color;
}
// theme
.openslides-theme .pbl-ngrid-row:hover {
background-color: #f9f9f9;
}
.openslides-theme os-list-view-table os-sort-filter-bar .custom-table-header {
&,
.action-buttons .input-container input {
background: white;
}
}
.openslides-theme .pbl-ngrid-no-data { .openslides-theme .pbl-ngrid-no-data {
top: 10%; top: 10%;
} }
@ -128,6 +104,5 @@
top: 0; top: 0;
right: -1px; right: -1px;
height: 100%; height: 100%;
border-right: 1px solid #e0e0e0;
} }
} }

View File

@ -1,7 +1,7 @@
import { Component, ViewEncapsulation } from '@angular/core'; import { Component, ViewEncapsulation } from '@angular/core';
import { MatSnackBar } from '@angular/material'; import { MatSnackBar } from '@angular/material';
import { Title } from '@angular/platform-browser'; import { Title } from '@angular/platform-browser';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { PblColumnDefinition } from '@pebula/ngrid'; import { PblColumnDefinition } from '@pebula/ngrid';
@ -49,7 +49,8 @@ export class AssignmentPollDetailComponent extends BasePollDetailComponent<ViewA
pollService: PollService, pollService: PollService,
votesRepo: AssignmentVoteRepositoryService, votesRepo: AssignmentVoteRepositoryService,
private operator: OperatorService, private operator: OperatorService,
private assignmentPollService: AssignmentPollService private assignmentPollService: AssignmentPollService,
private router: Router
) { ) {
super(title, translate, matSnackbar, repo, route, groupRepo, prompt, pollDialog, pollService, votesRepo); super(title, translate, matSnackbar, repo, route, groupRepo, prompt, pollDialog, pollService, votesRepo);
} }
@ -126,8 +127,7 @@ export class AssignmentPollDetailComponent extends BasePollDetailComponent<ViewA
} }
public getVoteClass(votingResult: VotingResult): string { public getVoteClass(votingResult: VotingResult): string {
const cssPrefix = 'voted-'; return votingResult.vote;
return `${cssPrefix}${votingResult.vote}`;
} }
public voteFitsMethod(result: VotingResult): boolean { public voteFitsMethod(result: VotingResult): boolean {
@ -143,6 +143,10 @@ export class AssignmentPollDetailComponent extends BasePollDetailComponent<ViewA
return true; return true;
} }
protected onDeleted(): void {
this.router.navigate(['assignments', this.poll.assignment_id]);
}
public getTableData(): PollTableData[] { public getTableData(): PollTableData[] {
return this.assignmentPollService.generateTableData(this.poll); return this.assignmentPollService.generateTableData(this.poll);
} }

View File

@ -1,11 +1,13 @@
<ng-container *ngIf="poll"> <ng-container *ngIf="poll">
<ng-container *ngIf="vmanager.canVote(poll) && !alreadyVoted; else cannotVote"> <ng-container *ngIf="vmanager.canVote(poll) && !alreadyVoted; else cannotVote">
<!-- Submit Vote --> <!-- Poll hint -->
<ng-container [ngTemplateOutlet]="sendNow"></ng-container> <p *ngIf="pollHint">
<i>{{ pollHint }}</i>
</p>
<!-- Leftover votes --> <!-- Leftover votes -->
<h4 <h4
*ngIf="poll.pollmethod === AssignmentPollMethod.Votes && poll.votes_amount > 1 && !isGlobalOptionSelected()" *ngIf="poll.pollmethod === AssignmentPollMethod.Votes && poll.votes_amount > 1"
> >
{{ 'Votes for this poll' | translate }}: {{ getVotesCount() }}/{{ poll.votes_amount }} {{ 'Votes for this poll' | translate }}: {{ getVotesCount() }}/{{ poll.votes_amount }}
</h4> </h4>
@ -32,10 +34,12 @@
<div *ngFor="let action of voteActions"> <div *ngFor="let action of voteActions">
<button <button
class="vote-button"
mat-raised-button mat-raised-button
(click)="saveSingleVote(option.id, action.vote)" (click)="saveSingleVote(option.id, action.vote)"
[ngClass]=" [ngClass]="
voteRequestData.votes[option.id] === action.vote || voteRequestData.votes[option.id] === 1 voteRequestData.votes[option.id] === action.vote ||
voteRequestData.votes[option.id] === 1
? action.css ? action.css
: '' : ''
" "
@ -57,6 +61,7 @@
<div class="global-option-grid"> <div class="global-option-grid">
<div *ngIf="poll.global_no"> <div *ngIf="poll.global_no">
<button <button
class="vote-button"
mat-raised-button mat-raised-button
(click)="saveGlobalVote('N')" (click)="saveGlobalVote('N')"
[ngClass]="voteRequestData.global === 'N' ? 'voted-no' : ''" [ngClass]="voteRequestData.global === 'N' ? 'voted-no' : ''"
@ -95,9 +100,13 @@
<ng-template #cannotVote> <ng-template #cannotVote>
<div class="centered-button-wrapper"> <div class="centered-button-wrapper">
<os-icon-container icon="check"> <div>
{{ 'You already voted on this poll' | translate}} <mat-icon class="vote-submitted">
</os-icon-container> check_circle
</mat-icon>
<br />
<span>{{ 'You already voted on this poll.' | translate }}</span>
</div>
</div> </div>
</ng-template> </ng-template>

View File

@ -1,4 +1,5 @@
@import '~assets/styles/poll-colors.scss'; @import '~assets/styles/poll-colors.scss';
@import '~assets/styles/poll-styles-common.scss';
%vote-grid-base { %vote-grid-base {
display: grid; display: grid;
@ -42,23 +43,21 @@
.centered-button-wrapper { .centered-button-wrapper {
display: flex; display: flex;
text-align: center;
> * { > * {
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
} }
.vote-submitted {
color: $votes-yes-color;
font-size: 200%;
}
} }
// TODO: Could be some more general component .vote-button {
.voted-yes { min-width: 50px;
background-color: $votes-yes-color; min-height: 50px;
}
.voted-no {
background-color: $votes-no-color;
}
.voted-abstain {
background-color: $votes-abstain-color;
} }
.vote-label { .vote-label {

View File

@ -62,6 +62,10 @@ export class AssignmentPollVoteComponent extends BasePollVoteComponent<ViewAssig
} }
} }
public get pollHint(): string {
return this.poll.assignment.default_poll_description;
}
private defineVoteOptions(): void { private defineVoteOptions(): void {
this.voteActions.push({ this.voteActions.push({
vote: 'Y', vote: 'Y',
@ -93,7 +97,7 @@ export class AssignmentPollVoteComponent extends BasePollVoteComponent<ViewAssig
return Object.keys(this.voteRequestData.votes).filter(key => this.voteRequestData.votes[key]).length; return Object.keys(this.voteRequestData.votes).filter(key => this.voteRequestData.votes[key]).length;
} }
public isGlobalOptionSelected(): boolean { private isGlobalOptionSelected(): boolean {
return !!this.voteRequestData.global; return !!this.voteRequestData.global;
} }
@ -145,7 +149,7 @@ export class AssignmentPollVoteComponent extends BasePollVoteComponent<ViewAssig
} }
} else { } else {
this.raiseError( this.raiseError(
this.translate.instant('You reached the maximum amount of votes. Deselect somebody first') this.translate.instant('You reached the maximum amount of votes. Deselect somebody first.')
); );
} }
} else { } else {
@ -165,7 +169,11 @@ export class AssignmentPollVoteComponent extends BasePollVoteComponent<ViewAssig
public saveGlobalVote(globalVote: GlobalVote): void { public saveGlobalVote(globalVote: GlobalVote): void {
this.voteRequestData.votes = {}; this.voteRequestData.votes = {};
if (this.voteRequestData.global && this.voteRequestData.global === globalVote) {
delete this.voteRequestData.global;
} else {
this.voteRequestData.global = globalVote; this.voteRequestData.global = globalVote;
this.submitVote(); this.submitVote();
} }
}
} }

View File

@ -30,10 +30,9 @@
</div> </div>
<!-- Change state button --> <!-- Change state button -->
<div *osPerms="'assignments.can_manage'"> <div *osPerms="'assignments.can_manage'; and: !hideChangeState">
<button <button
mat-stroked-button mat-stroked-button
*ngIf="!poll.isPublished"
[ngClass]="pollStateActions[poll.state].css" [ngClass]="pollStateActions[poll.state].css"
(click)="changeState(poll.nextState)" (click)="changeState(poll.nextState)"
> >
@ -43,6 +42,11 @@
</span> </span>
</button> </button>
</div> </div>
<!-- Enter Votes Hint -->
<div *osPerms="'assignments.can_manage'; and: poll.type === 'analog' && !poll.stateHasVotes">
{{ 'Edit to enter votes.' | translate }}
</div>
</div> </div>
<!-- Chart --> <!-- Chart -->
@ -70,9 +74,18 @@
<div *osPerms="'assignments.can_manage'; 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>
<!-- The Vote -->
<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>
<!-- More-Button -->
<div class="poll-detail-button-wrapper"> <div class="poll-detail-button-wrapper">
<a mat-icon-button routerLink="/assignments/polls/{{ poll.id }}" matTooltip="{{ 'More' | translate }}"> <a
mat-icon-button
routerLink="/assignments/polls/{{ poll.id }}"
matTooltip="{{ 'More' | translate }}"
*ngIf="poll.isPublished"
>
<mat-icon class="small-icon"> <mat-icon class="small-icon">
visibility visibility
</mat-icon> </mat-icon>

View File

@ -1,4 +1,5 @@
@import '~assets/styles/poll-colors.scss'; @import '~assets/styles/poll-colors.scss';
@import '~assets/styles/poll-styles-common.scss';
.assignment-poll-wrapper { .assignment-poll-wrapper {
position: relative; position: relative;
@ -17,16 +18,4 @@
margin-left: auto; margin-left: auto;
} }
} }
.start-poll-button {
color: green !important;
}
.stop-poll-button {
color: $poll-stop-color;
}
.publish-poll-button {
color: $poll-publish-color;
}
} }

View File

@ -1,4 +1,4 @@
import { Component, Input, OnInit, ViewEncapsulation } from '@angular/core'; import { Component, Input, OnInit } from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms'; import { FormBuilder, FormGroup } from '@angular/forms';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar'; import { MatSnackBar } from '@angular/material/snack-bar';
@ -21,8 +21,7 @@ import { ViewAssignmentPoll } from '../../models/view-assignment-poll';
@Component({ @Component({
selector: 'os-assignment-poll', selector: 'os-assignment-poll',
templateUrl: './assignment-poll.component.html', templateUrl: './assignment-poll.component.html',
styleUrls: ['./assignment-poll.component.scss'], styleUrls: ['./assignment-poll.component.scss']
encapsulation: ViewEncapsulation.None
}) })
export class AssignmentPollComponent extends BasePollComponent<ViewAssignmentPoll> implements OnInit { export class AssignmentPollComponent extends BasePollComponent<ViewAssignmentPoll> implements OnInit {
@Input() @Input()

View File

@ -190,7 +190,7 @@ export class AssignmentPollPdfService extends PollPdfService {
*/ */
private createPollHint(poll: ViewAssignmentPoll): object { private createPollHint(poll: ViewAssignmentPoll): object {
return { return {
text: poll.description || '', text: poll.assignment.default_poll_description || '',
style: 'description' style: 'description'
}; };
} }

View File

@ -32,6 +32,8 @@ export class AssignmentPollService extends PollService {
public defaultPollMethod: AssignmentPollMethod; public defaultPollMethod: AssignmentPollMethod;
private sortByVote: boolean;
/** /**
* Constructor. Subscribes to the configuration values needed * Constructor. Subscribes to the configuration values needed
* @param config ConfigService * @param config ConfigService
@ -53,6 +55,7 @@ export class AssignmentPollService extends PollService {
config config
.get<AssignmentPollMethod>(AssignmentPoll.defaultPollMethodConfig) .get<AssignmentPollMethod>(AssignmentPoll.defaultPollMethodConfig)
.subscribe(method => (this.defaultPollMethod = method)); .subscribe(method => (this.defaultPollMethod = method));
config.get<boolean>('assignment_poll_sort_poll_result_by_votes').subscribe(sort => (this.sortByVote = sort));
} }
public getDefaultPollData(contextId?: number): AssignmentPoll { public getDefaultPollData(contextId?: number): AssignmentPoll {
@ -74,22 +77,31 @@ export class AssignmentPollService extends PollService {
} }
private getGlobalVoteKeys(poll: ViewAssignmentPoll): VotingResult[] { private getGlobalVoteKeys(poll: ViewAssignmentPoll): VotingResult[] {
// debugger;
return [ return [
{ {
vote: 'amount_global_no', vote: 'amount_global_no',
showPercent: false, showPercent: this.showPercentOfValidOrCast(poll),
hide: poll.amount_global_no === -2 || poll.amount_global_no === 0 hide: poll.amount_global_no === -2 || !poll.amount_global_no
}, },
{ {
vote: 'amount_global_abstain', vote: 'amount_global_abstain',
showPercent: false, showPercent: this.showPercentOfValidOrCast(poll),
hide: poll.amount_global_abstain === -2 || poll.amount_global_abstain === 0 hide: poll.amount_global_abstain === -2 || !poll.amount_global_abstain
} }
]; ];
} }
public generateTableData(poll: ViewAssignmentPoll): PollTableData[] { public generateTableData(poll: ViewAssignmentPoll): PollTableData[] {
const tableData: PollTableData[] = poll.options.map(candidate => ({ const tableData: PollTableData[] = poll.options
.sort((a, b) => {
if (this.sortByVote) {
return b.yes - a.yes;
} else {
return b.weight - a.weight;
}
})
.map(candidate => ({
votingOption: candidate.user.short_name, votingOption: candidate.user.short_name,
votingOptionSubtitle: candidate.user.getLevelAndNumber(), votingOptionSubtitle: candidate.user.getLevelAndNumber(),
class: 'user', class: 'user',
@ -104,8 +116,8 @@ export class AssignmentPollService extends PollService {
} as VotingResult) } as VotingResult)
) )
})); }));
tableData.push(...this.formatVotingResultToTableData(super.getSumTableKeys(poll), poll));
tableData.push(...this.formatVotingResultToTableData(this.getGlobalVoteKeys(poll), poll)); tableData.push(...this.formatVotingResultToTableData(this.getGlobalVoteKeys(poll), poll));
tableData.push(...this.formatVotingResultToTableData(super.getSumTableKeys(poll), poll));
return tableData; return tableData;
} }

View File

@ -31,8 +31,9 @@
<div *ngIf="!poll.hasVotes || !poll.stateHasVotes">{{ 'No results to show' | translate }}</div> <div *ngIf="!poll.hasVotes || !poll.stateHasVotes">{{ 'No results to show' | translate }}</div>
<div *ngIf="poll.stateHasVotes"> <div *ngIf="poll.stateHasVotes && (hasPerms() || poll.isPublished)">
<os-motion-poll-detail-content [poll]="poll" [chartData]="chartDataSubject"> </os-motion-poll-detail-content> <os-motion-poll-detail-content [poll]="poll" [chartData]="chartDataSubject">
</os-motion-poll-detail-content>
<!-- Named table: only show if votes are present --> <!-- Named table: only show if votes are present -->
<div class="named-result-table" *ngIf="poll.type === 'named'"> <div class="named-result-table" *ngIf="poll.type === 'named'">

View File

@ -1,5 +1,4 @@
@import '~assets/styles/variables.scss'; @import '~assets/styles/poll-styles-common.scss';
@import '~assets/styles/poll-colors.scss';
.poll-content { .poll-content {
text-align: right; text-align: right;
@ -30,18 +29,6 @@
height: 500px; height: 500px;
} }
.voted-yes {
color: $votes-yes-color;
}
.voted-no {
color: $votes-no-color;
}
.voted-abstain {
color: $votes-abstain-color;
}
.openslides-theme .pbl-ngrid-no-data { .openslides-theme .pbl-ngrid-no-data {
top: 10%; top: 10%;
} }

View File

@ -1,14 +1,14 @@
<ng-container *ngIf="poll && !poll.user_has_voted; else userHasVotes"> <div *osPerms="'motions.can_manage_polls'; and: poll && poll.isStarted">
<div *osPerms="'motions.can_manage_polls'; and: poll && poll.isStarted">
<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">
<div *ngIf="vmanager.canVote(poll)" class="vote-button-grid"> <div *ngIf="vmanager.canVote(poll)" 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.value === option.vote ? option.css : ''" [ngClass]="currentVote && currentVote.vote === option.vote ? option.css : ''"
> >
<mat-icon> {{ option.icon }}</mat-icon> <mat-icon> {{ option.icon }}</mat-icon>
</button> </button>
@ -19,8 +19,12 @@
<ng-template #userHasVotes> <ng-template #userHasVotes>
<div class="user-has-voted"> <div class="user-has-voted">
<os-icon-container icon="check"> <div>
{{ 'You already voted on this poll' | translate }} <mat-icon class="vote-submitted">
</os-icon-container> check_circle
</mat-icon>
<br />
<span>{{ 'You already voted on this poll.' | translate }}</span>
</div>
</div> </div>
</ng-template> </ng-template>

View File

@ -1,4 +1,5 @@
@import '~assets/styles/poll-colors.scss'; @import '~assets/styles/poll-colors.scss';
@import '~assets/styles/poll-styles-common.scss';
.vote-button-grid { .vote-button-grid {
display: grid; display: grid;
@ -19,24 +20,15 @@
.user-has-voted { .user-has-voted {
display: flex; display: flex;
text-align: center;
> * { > * {
margin-top: 1em; margin-top: 1em;
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
} }
}
.voted-yes { .vote-submitted {
background-color: $votes-yes-color; color: $votes-yes-color;
color: $vote-active-color; font-size: 200%;
} }
.voted-no {
background-color: $votes-no-color;
color: $vote-active-color;
}
.voted-abstain {
background-color: $votes-abstain-color;
color: $vote-active-color;
} }

View File

@ -1,4 +1,4 @@
import { Component, OnInit } from '@angular/core'; import { Component } from '@angular/core';
import { MatSnackBar } from '@angular/material'; import { MatSnackBar } from '@angular/material';
import { Title } from '@angular/platform-browser'; import { Title } from '@angular/platform-browser';
@ -6,20 +6,16 @@ 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 { MotionPollRepositoryService } from 'app/core/repositories/motions/motion-poll-repository.service'; import { MotionPollRepositoryService } from 'app/core/repositories/motions/motion-poll-repository.service';
import { MotionVoteRepositoryService } from 'app/core/repositories/motions/motion-vote-repository.service';
import { PromptService } from 'app/core/ui-services/prompt.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 { MotionPollMethod } from 'app/shared/models/motions/motion-poll';
import { PollType } from 'app/shared/models/poll/base-poll';
import { ViewMotionPoll } from 'app/site/motions/models/view-motion-poll'; import { ViewMotionPoll } from 'app/site/motions/models/view-motion-poll';
import { ViewMotionVote } from 'app/site/motions/models/view-motion-vote';
import { BasePollVoteComponent } from 'app/site/polls/components/base-poll-vote.component'; import { BasePollVoteComponent } from 'app/site/polls/components/base-poll-vote.component';
interface VoteOption { interface VoteOption {
vote: 'Y' | 'N' | 'A'; vote?: 'Y' | 'N' | 'A';
css: string; css?: string;
icon: string; icon?: string;
label: string; label?: string;
} }
@Component({ @Component({
@ -27,19 +23,8 @@ interface VoteOption {
templateUrl: './motion-poll-vote.component.html', templateUrl: './motion-poll-vote.component.html',
styleUrls: ['./motion-poll-vote.component.scss'] styleUrls: ['./motion-poll-vote.component.scss']
}) })
export class MotionPollVoteComponent extends BasePollVoteComponent<ViewMotionPoll> implements OnInit { export class MotionPollVoteComponent extends BasePollVoteComponent<ViewMotionPoll> {
/** public currentVote: VoteOption = {};
* holds the last saved vote
*
* TODO: There will be a bug. This has to be reset if the currently observed poll changes it's state back
* to started
*/
public currentVote: ViewMotionVote;
public MotionPollMethod = MotionPollMethod;
private votes: ViewMotionVote[];
public voteOptions: VoteOption[] = [ public voteOptions: VoteOption[] = [
{ {
vote: 'Y', vote: 'Y',
@ -67,43 +52,17 @@ export class MotionPollVoteComponent extends BasePollVoteComponent<ViewMotionPol
matSnackbar: MatSnackBar, matSnackbar: MatSnackBar,
vmanager: VotingService, vmanager: VotingService,
operator: OperatorService, operator: OperatorService,
private voteRepo: MotionVoteRepositoryService,
private pollRepo: MotionPollRepositoryService, private pollRepo: MotionPollRepositoryService,
private promptService: PromptService private promptService: PromptService
) { ) {
super(title, translate, matSnackbar, vmanager, operator); super(title, translate, matSnackbar, vmanager, operator);
} }
public ngOnInit(): void {
this.subscriptions.push(
this.voteRepo.getViewModelListObservable().subscribe(votes => {
this.votes = votes;
this.updateVotes();
})
);
}
protected updateVotes(): void {
if (this.user && this.votes && this.poll) {
this.currentVote = null;
const filtered = this.votes.filter(
vote => vote.option.poll_id === this.poll.id && vote.user_id === this.user.id
);
if (filtered.length) {
if (filtered.length > 1) {
// output warning and continue to keep the error case user friendly
console.error('A user should never have more than one vote on the same poll.');
}
this.currentVote = filtered[0];
}
}
}
/** /**
* TODO: 'Y' | 'N' | 'A' should refer to some ENUM * TODO: 'Y' | 'N' | 'A' should refer to some ENUM
*/ */
public saveVote(vote: 'Y' | 'N' | 'A'): void { public saveVote(vote: 'Y' | 'N' | 'A'): void {
if (this.poll.type === PollType.Pseudoanonymous) { this.currentVote.vote = vote;
const title = this.translate.instant('Are you sure?'); const title = this.translate.instant('Are you sure?');
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 => { this.promptService.open(title, content).then(confirmed => {
@ -111,8 +70,5 @@ export class MotionPollVoteComponent extends BasePollVoteComponent<ViewMotionPol
this.pollRepo.vote(vote, this.poll.id).catch(this.raiseError); this.pollRepo.vote(vote, this.poll.id).catch(this.raiseError);
} }
}); });
} else {
this.pollRepo.vote(vote, this.poll.id).catch(this.raiseError);
}
} }
} }

View File

@ -1,4 +1,4 @@
<mat-card class="motion-poll-wrapper" *ngIf="poll"> <mat-card class="motion-poll-wrapper" *ngIf="showPoll">
<!-- Poll Infos --> <!-- Poll Infos -->
<div class="poll-title-wrapper"> <div class="poll-title-wrapper">
<!-- Title Area --> <!-- Title Area -->
@ -56,7 +56,7 @@
<!-- Detail link --> <!-- Detail link -->
<div class="poll-detail-button-wrapper"> <div class="poll-detail-button-wrapper">
<a mat-icon-button [routerLink]="pollLink" matTooltip="{{ 'More' | translate }}"> <a mat-icon-button [routerLink]="pollLink" matTooltip="{{ 'More' | translate }}" *ngIf="poll.isPublished">
<mat-icon class="small-icon"> <mat-icon class="small-icon">
visibility visibility
</mat-icon> </mat-icon>

View File

@ -1,4 +1,5 @@
@import '~assets/styles/poll-colors.scss'; @import '~assets/styles/poll-colors.scss';
@import '~assets/styles/poll-styles-common.scss';
.poll-link-wrapper { .poll-link-wrapper {
outline: none; outline: none;
@ -46,18 +47,6 @@
div + div { div + div {
margin-top: 20px; margin-top: 20px;
} }
.yes {
color: $votes-yes-color;
}
.no {
color: $votes-no-color;
}
.abstain {
color: $votes-abstain-color;
}
} }
} }
} }
@ -75,18 +64,6 @@
margin-bottom: auto; margin-bottom: auto;
} }
.start-poll-button {
color: green !important;
}
.stop-poll-button {
color: $poll-stop-color;
}
.publish-poll-button {
color: $poll-publish-color;
}
.motion-couting-in-progress-hint { .motion-couting-in-progress-hint {
margin-top: 1em; margin-top: 1em;
font-style: italic; font-style: italic;

View File

@ -7,7 +7,6 @@ import { TranslateService } from '@ngx-translate/core';
import { MotionPollRepositoryService } from 'app/core/repositories/motions/motion-poll-repository.service'; import { MotionPollRepositoryService } from 'app/core/repositories/motions/motion-poll-repository.service';
import { PromptService } from 'app/core/ui-services/prompt.service'; import { PromptService } from 'app/core/ui-services/prompt.service';
import { VotingPrivacyWarningComponent } from 'app/shared/components/voting-privacy-warning/voting-privacy-warning.component'; import { VotingPrivacyWarningComponent } from 'app/shared/components/voting-privacy-warning/voting-privacy-warning.component';
import { PollType } from 'app/shared/models/poll/base-poll';
import { infoDialogSettings } from 'app/shared/utils/dialog-settings'; import { infoDialogSettings } from 'app/shared/utils/dialog-settings';
import { ViewMotionPoll } from 'app/site/motions/models/view-motion-poll'; import { ViewMotionPoll } from 'app/site/motions/models/view-motion-poll';
import { MotionPollDialogService } from 'app/site/motions/services/motion-poll-dialog.service'; import { MotionPollDialogService } from 'app/site/motions/services/motion-poll-dialog.service';
@ -15,6 +14,7 @@ import { MotionPollPdfService } from 'app/site/motions/services/motion-poll-pdf.
import { MotionPollService } from 'app/site/motions/services/motion-poll.service'; import { MotionPollService } from 'app/site/motions/services/motion-poll.service';
import { BasePollComponent } from 'app/site/polls/components/base-poll.component'; import { BasePollComponent } from 'app/site/polls/components/base-poll.component';
import { PollService, PollTableData } from 'app/site/polls/services/poll.service'; import { PollService, PollTableData } from 'app/site/polls/services/poll.service';
import { OperatorService } from 'app/core/core-services/operator.service';
/** /**
* Component to show a motion-poll. * Component to show a motion-poll.
@ -48,16 +48,25 @@ export class MotionPollComponent extends BasePollComponent<ViewMotionPoll> {
return this.motionPollService.showChart(this.poll); return this.motionPollService.showChart(this.poll);
} }
public get hideChangeState(): boolean {
return this.poll.isPublished || (this.poll.isCreated && this.poll.type === PollType.Analog);
}
public get reducedPollTableData(): PollTableData[] { public get reducedPollTableData(): PollTableData[] {
return this.motionPollService return this.motionPollService
.generateTableData(this.poll) .generateTableData(this.poll)
.filter(data => ['yes', 'no', 'abstain', 'votesinvalid'].includes(data.votingOption)); .filter(data => ['yes', 'no', 'abstain', 'votesinvalid'].includes(data.votingOption));
} }
public get showPoll(): boolean {
if (this.poll) {
if (
this.operator.hasPerms('motions.can_manage_polls') ||
this.poll.isPublished ||
(this.poll.isEVoting && !this.poll.isCreated)
) {
return true;
}
}
return false;
}
/** /**
* Constructor. * Constructor.
* *
@ -77,7 +86,8 @@ export class MotionPollComponent extends BasePollComponent<ViewMotionPoll> {
pollDialog: MotionPollDialogService, pollDialog: MotionPollDialogService,
public pollService: PollService, public pollService: PollService,
private pdfService: MotionPollPdfService, private pdfService: MotionPollPdfService,
private motionPollService: MotionPollService private motionPollService: MotionPollService,
private operator: OperatorService
) { ) {
super(titleService, matSnackBar, translate, dialog, promptService, pollRepo, pollDialog); super(titleService, matSnackBar, translate, dialog, promptService, pollRepo, pollDialog);
} }

View File

@ -42,15 +42,15 @@ export abstract class BasePollDetailComponent<V extends ViewBasePoll> extends Ba
*/ */
public voteOptionStyle = { public voteOptionStyle = {
Y: { Y: {
css: 'voted-yes', css: 'yes',
icon: 'thumb_up' icon: 'thumb_up'
}, },
N: { N: {
css: 'voted-no', css: 'no',
icon: 'thumb_down' icon: 'thumb_down'
}, },
A: { A: {
css: 'voted-abstain', css: 'abstain',
icon: 'trip_origin' icon: 'trip_origin'
} }
}; };
@ -151,8 +151,6 @@ export abstract class BasePollDetailComponent<V extends ViewBasePoll> extends Ba
this.pollDialog.openDialog(viewPoll); this.pollDialog.openDialog(viewPoll);
} }
protected onDeleted(): void {}
/** /**
* Called after the poll has been loaded. Meant to be overwritten by subclasses who need initial access to the poll * Called after the poll has been loaded. Meant to be overwritten by subclasses who need initial access to the poll
*/ */
@ -166,6 +164,8 @@ export abstract class BasePollDetailComponent<V extends ViewBasePoll> extends Ba
protected abstract hasPerms(): boolean; protected abstract hasPerms(): boolean;
protected abstract onDeleted(): void;
protected get canSeeVotes(): boolean { protected get canSeeVotes(): boolean {
return (this.hasPerms && this.poll.isFinished) || this.poll.isPublished; return (this.hasPerms && this.poll.isFinished) || this.poll.isPublished;
} }

View File

@ -8,7 +8,7 @@ import { BehaviorSubject } from 'rxjs';
import { BasePollDialogService } from 'app/core/ui-services/base-poll-dialog.service'; import { BasePollDialogService } from 'app/core/ui-services/base-poll-dialog.service';
import { PromptService } from 'app/core/ui-services/prompt.service'; import { PromptService } from 'app/core/ui-services/prompt.service';
import { ChartData } from 'app/shared/components/charts/charts.component'; import { ChartData } from 'app/shared/components/charts/charts.component';
import { PollState } from 'app/shared/models/poll/base-poll'; import { PollState, PollType } from 'app/shared/models/poll/base-poll';
import { BaseViewComponent } from 'app/site/base/base-view'; import { BaseViewComponent } from 'app/site/base/base-view';
import { BasePollRepositoryService } from '../services/base-poll-repository.service'; import { BasePollRepositoryService } from '../services/base-poll-repository.service';
import { ViewBasePoll } from '../models/view-base-poll'; import { ViewBasePoll } from '../models/view-base-poll';
@ -33,6 +33,10 @@ export abstract class BasePollComponent<V extends ViewBasePoll> extends BaseView
} }
}; };
public get hideChangeState(): boolean {
return this._poll.isPublished || (this._poll.isCreated && this._poll.type === PollType.Analog);
}
public constructor( public constructor(
titleService: Title, titleService: Title,
matSnackBar: MatSnackBar, matSnackBar: MatSnackBar,

View File

@ -234,23 +234,27 @@ export abstract class PollService {
); );
} }
public showPercentOfValidOrCast(poll: PollData | ViewBasePoll): boolean {
return poll.onehundred_percent_base === PercentBase.Valid || poll.onehundred_percent_base === PercentBase.Cast;
}
public getSumTableKeys(poll: PollData | ViewBasePoll): VotingResult[] { public getSumTableKeys(poll: PollData | ViewBasePoll): VotingResult[] {
return [ return [
{ {
vote: 'votesvalid', vote: 'votesvalid',
hide: poll.votesvalid === -2, hide: poll.votesvalid === -2,
showPercent: showPercent: this.showPercentOfValidOrCast(poll)
poll.onehundred_percent_base === PercentBase.Valid ||
poll.onehundred_percent_base === PercentBase.Cast
}, },
{ {
vote: 'votesinvalid', vote: 'votesinvalid',
icon: 'not_interested', icon: 'not_interested',
// TODO || PollType === analog
hide: poll.votesinvalid === -2, hide: poll.votesinvalid === -2,
showPercent: poll.onehundred_percent_base === PercentBase.Cast showPercent: poll.onehundred_percent_base === PercentBase.Cast
}, },
{ {
vote: 'votescast', vote: 'votescast',
// TODO || PollType === analog
hide: poll.votescast === -2, hide: poll.votescast === -2,
showPercent: poll.onehundred_percent_base === PercentBase.Cast showPercent: poll.onehundred_percent_base === PercentBase.Cast
} }

View File

@ -44,8 +44,7 @@ export const allSlides: SlideManifest[] = [
{ {
slide: 'motions/motion-poll', slide: 'motions/motion-poll',
path: 'motions/motion-poll', path: 'motions/motion-poll',
loadChildren: () => loadChildren: () => import('./motions/motion-poll/motion-poll-slide.module').then(m => m.MotionPollSlideModule),
import('./motions/motion-poll/motion-poll-slide.module').then(m => m.MotionPollSlideModule),
verboseName: 'Motion Poll', verboseName: 'Motion Poll',
elementIdentifiers: ['name', 'id'], elementIdentifiers: ['name', 'id'],
canBeMappedToModel: true canBeMappedToModel: true
@ -137,7 +136,8 @@ export const allSlides: SlideManifest[] = [
{ {
slide: 'assignments/assignment-poll', slide: 'assignments/assignment-poll',
path: 'assignments/assignment-poll', path: 'assignments/assignment-poll',
loadChildren: () => import('./assignments/assignment-poll/assignment-poll-slide.module').then(m => m.AssignmentPollSlideModule), loadChildren: () =>
import('./assignments/assignment-poll/assignment-poll-slide.module').then(m => m.AssignmentPollSlideModule),
verboseName: 'Assignment Poll', verboseName: 'Assignment Poll',
elementIdentifiers: ['name', 'id'], elementIdentifiers: ['name', 'id'],
canBeMappedToModel: true canBeMappedToModel: true

View File

@ -5,6 +5,6 @@ $votes-yes-color: #4caf50;
$votes-no-color: #cc6c5b; $votes-no-color: #cc6c5b;
$votes-abstain-color: #a6a6a6; $votes-abstain-color: #a6a6a6;
$vote-active-color: white; $vote-active-color: white;
$poll-create-color: #4caf50; $poll-start-color: #4caf50;
$poll-stop-color: #ff5252; $poll-stop-color: #ff5252;
$poll-publish-color: #e6b100; $poll-publish-color: #e6b100;

View File

@ -0,0 +1,40 @@
@import '~assets/styles/poll-colors.scss';
.yes {
color: $votes-yes-color;
}
.no {
color: $votes-no-color;
}
.abstain {
color: $votes-abstain-color;
}
.voted-yes {
background-color: $votes-yes-color;
color: $vote-active-color;
}
.voted-no {
background-color: $votes-no-color;
color: $vote-active-color;
}
.voted-abstain {
background-color: $votes-abstain-color;
color: $vote-active-color;
}
.start-poll-button {
color: $poll-start-color;
}
.stop-poll-button {
color: $poll-stop-color;
}
.publish-poll-button {
color: $poll-publish-color;
}

View File

@ -65,12 +65,22 @@ def get_config_variables():
subgroup="Ballot", subgroup="Ballot",
) )
yield ConfigVariable(
name="assignment_poll_sort_poll_result_by_votes",
default_value=True,
input_type="boolean",
label="Sort election results by amount of votes",
weight=420,
group="Elections",
subgroup="Ballot",
)
yield ConfigVariable( yield ConfigVariable(
name="assignment_poll_add_candidates_to_list_of_speakers", name="assignment_poll_add_candidates_to_list_of_speakers",
default_value=True, default_value=True,
input_type="boolean", input_type="boolean",
label="Put all candidates on the list of speakers", label="Put all candidates on the list of speakers",
weight=420, weight=425,
group="Elections", group="Elections",
subgroup="Ballot", subgroup="Ballot",
) )