Enhance voting ux

This commit is contained in:
Sean 2020-01-22 11:57:51 +01:00 committed by FinnStutzenstein
parent 7ab5346198
commit 604df9d48b
22 changed files with 249 additions and 216 deletions

View File

@ -13,6 +13,9 @@ export enum VotingError {
USER_HAS_VOTED
}
/**
* TODO: It appears that the only message that makes sense for the user to see it the last one.
*/
export const VotingErrorVerbose = {
1: "You can't vote on this poll right now because it's not in the 'Started' state.",
2: "You can't vote on this poll because its type is set to analog voting.",

View File

@ -1,17 +1,14 @@
<ol class="breadcrumb-list">
<li *ngFor="let breadcrumb of breadcrumbList" class="breadcrumb" [ngClass]="{ active: breadcrumb.active }">
<ng-container *ngIf="breadcrumb.active">
<div>
<mat-button-toggle-group>
<mat-button-toggle
*ngFor="let breadcrumb of breadcrumbList"
[disabled]="breadcrumb.action === null"
(click)="breadcrumb.action ? breadcrumb.action() : null"
[ngClass]="{ 'active-breadcrumb': breadcrumb.active }"
>
<span>
{{ breadcrumb.label }}
</span>
</ng-container>
<ng-container *ngIf="!breadcrumb.active">
<span
(click)="breadcrumb.action ? breadcrumb.action() : null"
[ngClass]="{ 'accent-foreground has-action': breadcrumb.action }"
>
{{ breadcrumb.label }}
</span>
</ng-container>
</li>
</ol>
</mat-button-toggle>
</mat-button-toggle-group>
</div>

View File

@ -1,25 +1,4 @@
$breadcrumb-content: var(--breadcrumb-content);
.breadcrumb-list {
display: flex;
flex-wrap: wrap;
list-style: none;
}
.breadcrumb {
& + & {
padding-left: 8px;
&::before {
padding-right: 8px;
content: $breadcrumb-content;
}
}
span.has-action {
cursor: pointer;
}
&.active {
color: inherit;
}
.active-breadcrumb {
// Theme
color: rgba($color: #317796, $alpha: 1);
}

View File

@ -23,6 +23,8 @@ export class BreadcrumbComponent implements OnInit {
@Input()
public set breadcrumbs(labels: string[] | Breadcrumb[]) {
this.breadcrumbList = [];
// https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-3.html#caveats
for (const breadcrumb of labels) {
if (typeof breadcrumb === 'string') {
this.breadcrumbList.push({ label: breadcrumb, action: null });
@ -45,16 +47,6 @@ export class BreadcrumbComponent implements OnInit {
this.breadcrumbList[index].active = true;
}
/**
* Sets the separator for the breadcrumbs.
*
* @param style The new separator as string (character).
*/
@Input()
public set breadcrumbStyle(style: string) {
document.documentElement.style.setProperty('--breadcrumb-content', `'${style}'`);
}
/**
* The list of the breadcrumbs built by the input.
*/
@ -63,9 +55,7 @@ export class BreadcrumbComponent implements OnInit {
/**
* Default constructor.
*/
public constructor() {
this.breadcrumbStyle = '/';
}
public constructor() {}
/**
* OnInit.

View File

@ -15,6 +15,7 @@
<canvas
*ngIf="type === 'pie' || type === 'doughnut'"
baseChart
[options]="pieChartOptions"
[data]="circleData"
[labels]="circleLabels"
[colors]="circleColors"

View File

@ -177,6 +177,13 @@ export class ChartsComponent extends BaseViewComponent {
}
};
/**
* Chart option for pie and doughnut
*/
public pieChartOptions: ChartOptions = {
aspectRatio: 1
};
/**
* Holds the type of the chart - defaults to `bar`.
*/

View File

@ -14,6 +14,7 @@
[name]="'checkbox'"
[ngModel]="isChecked"
(change)="checkboxStateChanged($event.checked)"
tabindex="-1"
>
{{ checkboxLabel }}
</mat-checkbox>

View File

@ -52,6 +52,13 @@ export abstract class BasePoll<T = any, O extends BaseOption<any> = any> extends
public onehundred_percent_base: PercentBase;
public user_has_voted: boolean;
/**
* Determine if the state is finished or published
*/
public get stateHasVotes(): boolean {
return this.state === PollState.Finished || this.state === PollState.Published;
}
protected getDecimalFields(): (keyof BasePoll<T, O>)[] {
return ['votesvalid', 'votesinvalid', 'votescast'];
}

View File

@ -26,7 +26,7 @@
<ng-container *ngIf="isReady">
<h1>{{ poll.title }}</h1>
<mat-divider></mat-divider>
<os-breadcrumb [breadcrumbs]="breadcrumbs" [breadcrumbStyle]="'>'"></os-breadcrumb>
<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'">

View File

@ -23,6 +23,10 @@ export class ViewMotionPoll extends ViewBasePoll<MotionPoll> implements MotionPo
private tableKeys = ['yes', 'no', 'abstain'];
private voteKeys = ['votesvalid', 'votesinvalid', 'votescast'];
public get hasVotes(): boolean {
return !!this.options[0].votes.length;
}
public initChartLabels(): string[] {
return ['Votes'];
}

View File

@ -1,13 +1,6 @@
<os-head-bar
[goBack]="true"
[nav]="false"
[hasMainButton]="poll ? poll.state === 2 || poll.state === 3 : false"
[mainButtonIcon]="'edit'"
[mainActionTooltip]="'Edit' | translate"
(mainEvent)="openDialog()"
>
<os-head-bar [goBack]="true" [nav]="false">
<div class="title-slot">
<h2 *ngIf="!!poll">{{ motionTitle }}</h2>
<h2 *ngIf="motion">{{ 'Motion' | translate }} {{ motion.id }}</h2>
</div>
<div class="menu-slot" *osPerms="'agenda.can_manage'; or: 'agenda.can_see_list_of_speakers'">
@ -25,29 +18,26 @@
<ng-template #viewTemplate>
<ng-container *ngIf="poll">
<h1>{{ poll.title }}</h1>
<mat-divider></mat-divider>
<os-breadcrumb [breadcrumbs]="breadcrumbs" [breadcrumbStyle]="'>'"></os-breadcrumb>
<span *ngIf="poll.type !== 'analog'">{{ 'Polly type' | translate }}: {{ poll.type | translate }}</span>
<os-breadcrumb [breadcrumbs]="breadcrumbs"></os-breadcrumb>
<div *ngIf="poll.state === 3 || poll.state === 4">
<div *ngIf="poll.stateHasVotes">
<h2 translate>Result</h2>
<div *ngIf="!poll.hasVotes">{{ 'No results to show' | translate }}</div>
<div class="result-wrapper" *ngIf="poll.hasVotes">
<!-- Chart -->
<os-charts
class="result-chart"
*ngIf="chartDataSubject.value"
[type]="chartType"
[showLegend]="true"
[data]="chartDataSubject"
></os-charts>
<div
*ngIf="poll.type === 'named'"
style="display: grid; grid-template-columns: max-content auto;grid-column-gap: 20px;"
>
<ng-container *ngFor="let vote of poll.options[0].votes">
<div *ngIf="vote.user">{{ vote.user.full_name }}</div>
<div *ngIf="!vote.user">{{ 'Unknown user' | translate }}</div>
<div>{{ vote.valueVerbose }}</div>
</ng-container>
</div>
<mat-table [dataSource]="poll.tableData">
<!-- result table -->
<mat-table class="result-table" [dataSource]="poll.tableData">
<ng-container matColumnDef="key" sticky>
<mat-header-cell *matHeaderCellDef></mat-header-cell>
<mat-cell *matCellDef="let row">{{ row.key }}</mat-cell>
@ -60,19 +50,29 @@
<mat-header-row *matHeaderRowDef="columnDefinition"></mat-header-row>
<mat-row *matRowDef="let row; columns: columnDefinition"></mat-row>
</mat-table>
<!-- Named table -->
<!-- The table was created in another PR -->
<div class="named-result-table" *ngIf="poll.type === 'named'">
<h3>{{ 'Singe votes' | translate }}</h3>
<div *ngFor="let vote of poll.options[0].votes">
<div *ngIf="vote.user">{{ vote.user.full_name }}</div>
<div *ngIf="!vote.user">{{ 'Unknown user' | translate }}</div>
<div>{{ vote.valueVerbose }}</div>
</div>
</div>
</div>
</div>
<div class="poll-content">
<div>{{ 'Current state' | translate }}: {{ poll.stateVerbose | translate }}</div>
<div class="poll-content small">
<div *ngIf="poll.groups && poll.type && poll.type !== 'analog'">
{{ 'Groups' | translate }}:
<span *ngFor="let group of poll.groups; let i = index">
{{ group.getTitle() | translate }}
<span *ngIf="i < poll.groups.length - 1">, </span>
{{ group.getTitle() | translate }}<span *ngIf="i < poll.groups.length - 1">, </span>
</span>
</div>
<div>{{ 'Poll type' | translate }}: {{ poll.typeVerbose | translate }}</div>
<div>{{ 'Poll method' | translate }}: {{ poll.pollmethodVerbose | translate }}</div>
<div>{{ 'Majority method' | translate }}: {{ poll.majorityMethodVerbose | translate }}</div>
<div>{{ '100% base' | translate }}: {{ poll.percentBaseVerbose | translate }}</div>
</div>
@ -82,13 +82,17 @@
<!-- More Menu -->
<mat-menu #pollDetailMenu="matMenu">
<os-projector-button [menuItem]="true" [object]="poll" *osPerms="'core.can_manage_projector'"></os-projector-button>
<button mat-menu-item (click)="openDialog()">
<mat-icon>edit</mat-icon>
<span translate>Edit</span>
</button>
<button mat-menu-item *ngIf="poll && poll.type === 'named'" (click)="pseudoanonymizePoll()">
<mat-icon>questionmark</mat-icon>
<span translate>Pseudoanonymize</span>
</button>
<mat-divider></mat-divider>
<button mat-menu-item (click)="deletePoll()">
<mat-icon>delete</mat-icon>
<mat-icon color="warn">delete</mat-icon>
<span translate>Delete</span>
</button>
</mat-menu>

View File

@ -1,18 +1,38 @@
@import '~assets/styles/variables.scss';
.poll-content {
padding-top: 10px;
padding-top: 20px;
}
.result-title {
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
.result-wrapper {
display: grid;
grid-gap: 10px;
grid-template-areas:
'chart'
'results'
'names';
}
.chart-wrapper {
padding: 16px;
text-align: center;
justify-content: space-around;
align-items: center;
* {
flex: 1;
max-width: 200px;
@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;
margin-left: auto;
margin-right: auto;
}
.named-result-table {
grid-area: names;
}

View File

@ -11,10 +11,10 @@ import { MotionRepositoryService } from 'app/core/repositories/motions/motion-re
import { GroupRepositoryService } from 'app/core/repositories/users/group-repository.service';
import { PromptService } from 'app/core/ui-services/prompt.service';
import { ChartType } from 'app/shared/components/charts/charts.component';
import { ViewMotion } from 'app/site/motions/models/view-motion';
import { ViewMotionPoll } from 'app/site/motions/models/view-motion-poll';
import { MotionPollDialogService } from 'app/site/motions/services/motion-poll-dialog.service';
import { BasePollDetailComponent } from 'app/site/polls/components/base-poll-detail.component';
// import { MotionRepositoryService } from 'app/core/repositories/motions/motion-repository.service';
@Component({
selector: 'os-motion-poll-detail',
@ -22,7 +22,7 @@ import { BasePollDetailComponent } from 'app/site/polls/components/base-poll-det
styleUrls: ['./motion-poll-detail.component.scss']
})
export class MotionPollDetailComponent extends BasePollDetailComponent<ViewMotionPoll> implements OnInit {
public motionTitle = '';
public motion: ViewMotion;
public columnDefinition = ['key', 'value'];
public set chartType(type: ChartType) {
@ -52,11 +52,12 @@ export class MotionPollDetailComponent extends BasePollDetailComponent<ViewMotio
}
protected onPollLoaded(): void {
this.motionTitle = this.motionRepo.getViewModel((<ViewMotionPoll>this.poll).motion_id).getTitle();
this.motion = this.motionRepo.getViewModel((<ViewMotionPoll>this.poll).motion_id);
}
public openDialog(): void {
this.pollDialog.openDialog(this.poll);
console.log('this.poll: ', this.poll.hasVotes);
}
protected onDeleted(): void {

View File

@ -1,6 +1,5 @@
<os-poll-form [data]="pollData" [pollMethods]="motionPollMethods" #pollForm></os-poll-form>
<os-poll-form [data]="pollData" #pollForm></os-poll-form>
<ng-container *ngIf="pollForm.contentForm.get('type').value === 'analog'">
<mat-divider></mat-divider>
<div class="os-form-card-mobile" mat-dialog-content>
<form [formGroup]="dialogVoteForm">
<os-check-input
@ -31,9 +30,7 @@
></os-check-input>
<os-check-input
[placeholder]="'Votes invalid' | translate"
[checkboxValue]="-1"
inputType="number"
[checkboxLabel]="'Majority' | translate"
formControlName="votesinvalid"
></os-check-input>
<os-check-input
@ -43,8 +40,7 @@
></os-check-input>
</form>
</div>
<mat-divider></mat-divider>
<div class="spacer-top-20">
<div>
<mat-checkbox [(ngModel)]="publishImmediately" (change)="publishStateChanged($event.checked)">
<span translate>Publish immediately</span>
</mat-checkbox>
@ -53,7 +49,6 @@
</mat-error>
</div>
</ng-container>
<mat-divider></mat-divider>
<div mat-dialog-actions>
<button mat-button (click)="submitPoll()" [disabled]="pollForm.contentForm.invalid || dialogVoteForm.invalid">
<span translate>Save</span>

View File

@ -1,28 +1,21 @@
<ng-container *ngIf="poll">
<ng-container *ngIf="vmanager.canVote(poll)">
<div *ngIf="currentVote"><span translate>Your current vote is</span> '{{ currentVote.valueVerbose | translate }}'</div>
<div *ngIf="!currentVote" translate>You have not voted yet.</div>
<!-- Voting -->
<mat-radio-group
name="votes-{{ poll.id }}"
[(ngModel)]="selectedVote"
<p *ngFor="let option of voteOptions">
<button
mat-raised-button
(click)="saveVote(option.vote)"
[ngClass]="currentVote && currentVote.value === option.vote ? option.css : ''"
>
<mat-radio-button value="Y">
<span translate>Yes</span>
</mat-radio-button>
<mat-radio-button value="N">
<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>
<button mat-button (click)="saveVote()">
<span translate>Save</span>
<mat-icon> {{ option.icon }}</mat-icon>
</button>
<span class="vote-label"> {{ option.label | translate }} </span>
</p>
</ng-container>
<ng-container *ngIf="!vmanager.canVote(poll)">
<!-- TODO most of the messages are not making sense -->
<!-- <ng-container *ngIf="!vmanager.canVote(poll)">
<span>{{ vmanager.getVotePermissionErrorVerbose(poll) | translate }}</span>
</ng-container>
</ng-container> -->
</ng-container>

View File

@ -0,0 +1,18 @@
/**
* These colors should be extracted from some global CSS Constants file
*/
.voted-yes {
background-color: #9fd773;
}
.voted-no {
background-color: #cc6c5b;
}
.voted-abstain {
background-color: #a6a6a6;
}
.vote-label {
margin-left: 1em;
}

View File

@ -13,21 +13,52 @@ 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';
interface VoteOption {
vote: 'Y' | 'N' | 'A';
css: string;
icon: string;
label: string;
}
@Component({
selector: 'os-motion-poll-vote',
templateUrl: './motion-poll-vote.component.html',
styleUrls: ['./motion-poll-vote.component.scss']
})
export class MotionPollVoteComponent extends BasePollVoteComponent<ViewMotionPoll> implements OnInit {
// holds the currently selected vote
public selectedVote: 'Y' | 'N' | 'A' = null;
// holds the last saved vote
/**
* 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 pollMethods = MotionPollMethods;
private votes: ViewMotionVote[];
public voteOptions: VoteOption[] = [
{
vote: 'Y',
css: 'voted-yes',
icon: 'thumb_up',
label: 'Yes'
},
{
vote: 'N',
css: 'voted-no',
icon: 'thumb_down',
label: 'No'
},
{
vote: 'A',
css: 'voted-abstain',
icon: 'trip_origin',
label: 'Abstain'
}
];
public constructor(
title: Title,
translate: TranslateService,
@ -51,6 +82,7 @@ export class MotionPollVoteComponent extends BasePollVoteComponent<ViewMotionPol
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
);
@ -60,14 +92,14 @@ export class MotionPollVoteComponent extends BasePollVoteComponent<ViewMotionPol
console.error('A user should never have more than one vote on the same poll.');
}
this.currentVote = filtered[0];
this.selectedVote = filtered[0].value;
}
}
}
public saveVote(): void {
if (this.selectedVote) {
this.pollRepo.vote(this.selectedVote, this.poll.id).catch(this.raiseError);
}
/**
* TODO: 'Y' | 'N' | 'A' should refer to some ENUM
*/
public saveVote(vote: 'Y' | 'N' | 'A'): void {
this.pollRepo.vote(vote, this.poll.id).catch(this.raiseError);
}
}

View File

@ -1,61 +1,62 @@
<div class="poll-preview-wrapper">
<!-- Poll Infos -->
<div class="poll-title-wrapper" *ngIf="poll">
<!-- Title -->
<a class="poll-title" routerLink="/motions/polls/{{ poll.id }}">
{{ poll.title }}
</a>
<span class="poll-title-actions">
<!-- Edit button -->
<span class="poll-title-actions" *osPerms="'motions.can_manage_polls'">
<button mat-icon-button (click)="openDialog()">
<mat-icon class="small-icon">edit</mat-icon>
</button>
</span>
<div class="poll-properties">
<mat-chip *ngIf="pollService.isElectronicVotingEnabled">{{ poll.typeVerbose }}</mat-chip>
<!-- State chip -->
<div class="poll-properties" *osPerms="'motions.can_manage_polls'">
<span *ngIf="pollService.isElectronicVotingEnabled && poll.typeVerbose !== 'Analog'">
{{ 'Poll type' | translate }}: {{ poll.typeVerbose | translate }}
</span>
<mat-chip
disableRipple
class="poll-state active"
[matMenuTriggerFor]="triggerMenu"
[ngClass]="poll.stateVerbose.toLowerCase()"
>
{{ poll.stateVerbose }}
</mat-chip>
<!-- <mat-chip
class="poll-state active"
*ngIf="poll.state !== 2"
[matMenuTriggerFor]="triggerMenu"
[ngClass]="poll.stateVerbose.toLowerCase()"
>
{{ poll.stateVerbose }}
</mat-chip> -->
<!-- <mat-chip class="poll-state" *ngIf="poll.state === 2" [ngClass]="poll.stateVerbose.toLowerCase()">
{{ poll.stateVerbose }}
</mat-chip> -->
</div>
</div>
<div class="poll-chart-wrapper" *ngIf="poll">
<div *ngIf="poll.type === 'analog' || poll.state === 3 || poll.state === 4" (click)="openPoll()">
<ng-container *ngIf="poll.state === 3 || poll.state === 4" [ngTemplateOutlet]="viewTemplate"></ng-container>
<ng-container
*ngIf="(poll.state === 1 || poll.state === 2) && poll.type === 'analog'"
[ngTemplateOutlet]="emptyTemplate"
></ng-container>
</div>
<ng-container *ngIf="(poll.state === 1 || poll.state === 2) && poll.type !== 'analog'">
<!-- Results -->
<ng-container *ngIf="poll && !poll.stateHasVotes && poll.type !== 'analog'; else votingResult">
<os-motion-poll-vote [poll]="poll"></os-motion-poll-vote>
</ng-container>
</div>
<div class="poll-preview-result-wrapper"></div>
<ng-template #votingResult>
<div (click)="openPoll()">
<ng-container [ngTemplateOutlet]="poll.hasVotes && poll.stateHasVotes ? viewTemplate : emptyTemplate"></ng-container>
</div>
</ng-template>
<ng-template #viewTemplate>
<div class="chart-wrapper-left">
<mat-icon>close</mat-icon>
: {{ voteNo }}
<div class="poll-chart-wrapper">
<div class="votes-yes">
<os-icon-container icon="check" size="large">
{{ voteYes }}
</os-icon-container>
</div>
<div *ngIf="showChart" class="doughnut-chart">
<os-charts [type]="'doughnut'" [data]="chartDataSubject" [showLegend]="false"> </os-charts>
</div>
<div class="chart-wrapper-right">
<mat-icon>check</mat-icon>
: {{ voteYes }}
<div class="votes-no">
<os-icon-container icon="close" size="large">
{{ voteNo }}
</os-icon-container>
</div>
</div>
</ng-template>

View File

@ -45,32 +45,19 @@
.poll-chart-wrapper {
cursor: pointer;
margin: 4px auto;
display: flex;
justify-content: center;
display: grid;
grid-template-columns: auto minmax(50px, 20%) auto;
div {
flex: 1;
}
.chart-wrapper-left,
.chart-wrapper-right {
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
}
.chart-wrapper-left {
.votes-no {
color: #cc6c5b;
margin: auto 0;
width: fit-content;
}
.chart-wrapper-right {
.votes-yes {
color: #9fc773;
}
.doughnut-chart {
max-width: 35%;
margin: auto 0 auto auto;
width: fit-content;
}
}
}

View File

@ -56,7 +56,6 @@ export class MotionPollComponent extends BasePollComponent<ViewMotionPoll> {
/**
* Number of votes for `Yes`.
*/
// public voteYes = 0;
public set voteYes(n: number | string) {
this._voteYes = n;
}

View File

@ -14,12 +14,6 @@ import { BasePollRepositoryService } from '../services/base-poll-repository.serv
import { ViewBasePoll } from '../models/view-base-poll';
export abstract class BasePollComponent<V extends ViewBasePoll> extends BaseViewComponent {
// /**
// * The poll represented in this component
// */
// @Input()
// public abstract set poll(model: V);
public chartDataSubject: BehaviorSubject<ChartData> = new BehaviorSubject([]);
protected _poll: V;

View File

@ -36,7 +36,7 @@
[inputListValues]="groupObservable"
></os-search-value-selector>
</mat-form-field>
<mat-form-field>
<mat-form-field *ngIf="pollMethods">
<mat-select [placeholder]="'Poll method' | translate" formControlName="pollmethod" required>
<mat-option *ngFor="let option of pollMethods | keyvalue" [value]="option.key">
{{ option.value }}