Enhance charts and tables for assignments

Also some various improvements
This commit is contained in:
Sean Engelhardt 2020-03-06 13:21:16 +01:00 committed by FinnStutzenstein
parent e2feeb4b65
commit 61b7731073
51 changed files with 528 additions and 367 deletions

View File

@ -173,6 +173,7 @@ _('Number of all participants');
_('Use the following custom number'); _('Use the following custom number');
_('Custom number of ballot papers'); _('Custom number of ballot papers');
_('Voting'); _('Voting');
_('Click here to vote');
// subgroup PDF export // subgroup PDF export
_('PDF export'); _('PDF export');
_('Title for PDF documents of motions'); _('Title for PDF documents of motions');

View File

@ -7,8 +7,7 @@ export interface BannerDefinition {
class?: string; class?: string;
icon?: string; icon?: string;
text?: string; text?: string;
bgColor?: string; subText?: string;
color?: string;
link?: string; link?: string;
largerOnMobileView?: boolean; largerOnMobileView?: boolean;
} }

View File

@ -2,6 +2,7 @@ import { Injectable } from '@angular/core';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { ViewAssignmentPoll } from 'app/site/assignments/models/view-assignment-poll';
import { ViewMotionPoll } from 'app/site/motions/models/view-motion-poll'; import { ViewMotionPoll } from 'app/site/motions/models/view-motion-poll';
import { ViewBasePoll } from 'app/site/polls/models/view-base-poll'; import { ViewBasePoll } from 'app/site/polls/models/view-base-poll';
import { PollListObservableService } from 'app/site/polls/services/poll-list-observable.service'; import { PollListObservableService } from 'app/site/polls/services/poll-list-observable.service';
@ -15,6 +16,8 @@ import { VotingService } from './voting.service';
export class VotingBannerService { export class VotingBannerService {
private currentBanner: BannerDefinition; private currentBanner: BannerDefinition;
private subText = 'Click here to vote';
public constructor( public constructor(
pollListObservableService: PollListObservableService, pollListObservableService: PollListObservableService,
private banner: BannerService, private banner: BannerService,
@ -58,6 +61,7 @@ export class VotingBannerService {
private createBanner(text: string, link: string): BannerDefinition { private createBanner(text: string, link: string): BannerDefinition {
return { return {
text: text, text: text,
subText: this.subText,
link: link, link: link,
icon: 'how_to_vote', icon: 'how_to_vote',
largerOnMobileView: true largerOnMobileView: true
@ -72,11 +76,13 @@ export class VotingBannerService {
* @returns The title. * @returns The title.
*/ */
private getTextForPoll(poll: ViewBasePoll): string { private getTextForPoll(poll: ViewBasePoll): string {
return poll instanceof ViewMotionPoll if (poll instanceof ViewMotionPoll) {
? `${this.translate.instant('Motion') + ' ' + poll.motion.getIdentifierOrTitle()}: ${this.translate.instant( return `${this.translate.instant('Motion')} ${poll.motion.getIdentifierOrTitle()}: ${this.translate.instant(
'Voting is open' 'Voting opened'
)}` )}`;
: `${poll.getTitle()}: ${this.translate.instant('Ballot is open')}`; } else if (poll instanceof ViewAssignmentPoll) {
return `${poll.assignment.getTitle()}: ${this.translate.instant('Ballot openened!')}`;
}
} }
/** /**

View File

@ -16,6 +16,9 @@
<ng-container *ngSwitchDefault> <ng-container *ngSwitchDefault>
<a class="banner-link" [routerLink]="banner.link" [style.cursor]="banner.link ? 'pointer' : 'default'"> <a class="banner-link" [routerLink]="banner.link" [style.cursor]="banner.link ? 'pointer' : 'default'">
<mat-icon>{{ banner.icon }}</mat-icon> <span>{{ banner.text }}</span> <mat-icon>{{ banner.icon }}</mat-icon> <span>{{ banner.text }}</span>
<div *ngIf="banner.subText">
{{ banner.subText | translate }}
</div>
</a> </a>
</ng-container> </ng-container>
</div> </div>

View File

@ -3,12 +3,12 @@
.banner { .banner {
&.larger-on-mobile { &.larger-on-mobile {
@include set-breakpoint-lower(sm) { @include set-breakpoint-lower(sm) {
height: 40px; min-height: 40px;
} }
} }
position: relative; // was fixed before to prevent the overflow position: relative; // was fixed before to prevent the overflow
height: 20px; min-height: 20px;
line-height: 20px; line-height: 20px;
width: 100%; width: 100%;
text-align: center; text-align: center;
@ -18,7 +18,6 @@
border-bottom: 1px solid white; border-bottom: 1px solid white;
a { a {
display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
text-decoration: none; text-decoration: none;

View File

@ -2,10 +2,10 @@
/** Custom component theme. Only lives in a specific scope */ /** Custom component theme. Only lives in a specific scope */
@mixin os-banner-style($theme) { @mixin os-banner-style($theme) {
$primary: map-get($theme, primary); $accent: map-get($theme, accent);
/** style for the offline-banner */ /** style for the offline-banner */
.banner { .banner {
background: mat-color($primary, 900); background: mat-color($accent, 500);
} }
} }

View File

@ -199,14 +199,24 @@ export class ChartsComponent extends BaseViewComponent {
labels: {} labels: {}
}, },
scales: { scales: {
xAxes: [{ ticks: { beginAtZero: true, stepSize: 1 } }], xAxes: [
yAxes: [{ ticks: { beginAtZero: true } }] {
}, gridLines: {
plugins: { drawOnChartArea: false
datalabels: { },
anchor: 'end', ticks: { beginAtZero: true, stepSize: 1 },
align: 'end' stacked: true
} }
],
yAxes: [
{
gridLines: {
drawOnChartArea: false
},
ticks: { beginAtZero: true, mirror: true, labelOffset: -20 },
stacked: true
}
]
} }
}; };
@ -251,16 +261,6 @@ export class ChartsComponent extends BaseViewComponent {
super(title, translate, matSnackbar); super(title, translate, matSnackbar);
} }
/**
* Changes the chart-options, if the `stackedBar` is used.
*/
private setupStackedBar(): void {
this.chartOptions.scales = Object.assign(this.chartOptions.scales, {
xAxes: [{ stacked: true }],
yAxes: [{ stacked: true }]
});
}
private setupBar(): void { private setupBar(): void {
if (!this.chartData.every(date => date.barThickness && date.maxBarThickness)) { if (!this.chartData.every(date => date.barThickness && date.maxBarThickness)) {
this.chartData = this.chartData.map(chartDate => ({ this.chartData = this.chartData.map(chartDate => ({
@ -284,7 +284,6 @@ export class ChartsComponent extends BaseViewComponent {
fontSize: 14, fontSize: 14,
boxWidth: 40 boxWidth: 40
}; };
break;
} }
this.cd.detectChanges(); this.cd.detectChanges();
} }
@ -292,7 +291,6 @@ export class ChartsComponent extends BaseViewComponent {
private checkChartType(chartType?: ChartType): void { private checkChartType(chartType?: ChartType): void {
let type = chartType || this._type; let type = chartType || this._type;
if (type === 'stackedBar') { if (type === 'stackedBar') {
this.setupStackedBar();
this.setupBar(); this.setupBar();
type = 'horizontalBar'; type = 'horizontalBar';
} }

View File

@ -88,12 +88,10 @@ export class CheckInputComponent extends BaseViewComponent implements OnInit, Co
* @param obj the value from the parent form. Type "any" is required by the interface * @param obj the value from the parent form. Type "any" is required by the interface
*/ */
public writeValue(obj: string | number): void { public writeValue(obj: string | number): void {
if (obj) { if (obj && obj === this.checkboxValue) {
if (obj === this.checkboxValue) { this.checkboxStateChanged(true);
this.checkboxStateChanged(true); } else {
} else { this.contentForm.patchValue(obj);
this.contentForm.patchValue(obj);
}
} }
} }

View File

@ -0,0 +1,18 @@
<h1 mat-dialog-title>
<span translate>Online voting is impossible to secure</span>
</h1>
<div mat-dialog-content>
<span translate>
During voting, OpenSlides does not store the individual user ID of the voter. This in no way means that a
non-nominal vote is completely anonymous and secure. You cannot track the decisions of your voters after the
data has been submitted. The validity of the data cannot always be guaranteed, especially if you use OpenSlides
in a distributed online setup. You are responsible for your own actions.
</span>
</div>
<div mat-dialog-actions>
<button type="button" mat-button [mat-dialog-close]="null">
<span translate>I know the risk</span>
</button>
</div>

View File

@ -0,0 +1,26 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { E2EImportsModule } from 'e2e-imports.module';
import { VotingPrivacyWarningComponent } from './voting-privacy-warning.component';
describe('VotingPrivacyWarningComponent', () => {
let component: VotingPrivacyWarningComponent;
let fixture: ComponentFixture<VotingPrivacyWarningComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(VotingPrivacyWarningComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,12 @@
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'os-voting-privacy-warning',
templateUrl: './voting-privacy-warning.component.html',
styleUrls: ['./voting-privacy-warning.component.scss']
})
export class VotingPrivacyWarningComponent implements OnInit {
public constructor() {}
public ngOnInit(): void {}
}

View File

@ -76,6 +76,10 @@ export abstract class BasePoll<
return this.onehundred_percent_base === PercentBase.Valid || this.onehundred_percent_base === PercentBase.Cast; return this.onehundred_percent_base === PercentBase.Valid || this.onehundred_percent_base === PercentBase.Cast;
} }
public get isPercentBaseCast(): boolean {
return this.onehundred_percent_base === PercentBase.Cast;
}
/** /**
* Determine if the state is finished or published * Determine if the state is finished or published
*/ */

View File

@ -114,7 +114,6 @@ import { LocalizedDatePipe } from './pipes/localized-date.pipe';
import { ChartsComponent } from './components/charts/charts.component'; import { ChartsComponent } from './components/charts/charts.component';
import { CheckInputComponent } from './components/check-input/check-input.component'; import { CheckInputComponent } from './components/check-input/check-input.component';
import { BannerComponent } from './components/banner/banner.component'; import { BannerComponent } from './components/banner/banner.component';
import { BasePollDialogComponent } from 'app/site/polls/components/base-poll-dialog.component';
import { PollFormComponent } from 'app/site/polls/components/poll-form/poll-form.component'; import { PollFormComponent } from 'app/site/polls/components/poll-form/poll-form.component';
import { MotionPollDialogComponent } from 'app/site/motions/modules/motion-poll/motion-poll-dialog/motion-poll-dialog.component'; import { MotionPollDialogComponent } from 'app/site/motions/modules/motion-poll/motion-poll-dialog/motion-poll-dialog.component';
import { AssignmentPollDialogComponent } from 'app/site/assignments/components/assignment-poll-dialog/assignment-poll-dialog.component'; import { AssignmentPollDialogComponent } from 'app/site/assignments/components/assignment-poll-dialog/assignment-poll-dialog.component';
@ -122,6 +121,7 @@ import { ParsePollNumberPipe } from './pipes/parse-poll-number.pipe';
import { ReversePipe } from './pipes/reverse.pipe'; import { ReversePipe } from './pipes/reverse.pipe';
import { PollKeyVerbosePipe } from './pipes/poll-key-verbose.pipe'; import { PollKeyVerbosePipe } from './pipes/poll-key-verbose.pipe';
import { PollPercentBasePipe } from './pipes/poll-percent-base.pipe'; import { PollPercentBasePipe } from './pipes/poll-percent-base.pipe';
import { VotingPrivacyWarningComponent } from './components/voting-privacy-warning/voting-privacy-warning.component';
/** /**
* Share Module for all "dumb" components and pipes. * Share Module for all "dumb" components and pipes.
@ -285,7 +285,8 @@ import { PollPercentBasePipe } from './pipes/poll-percent-base.pipe';
ParsePollNumberPipe, ParsePollNumberPipe,
ReversePipe, ReversePipe,
PollKeyVerbosePipe, PollKeyVerbosePipe,
PollPercentBasePipe PollPercentBasePipe,
VotingPrivacyWarningComponent
], ],
declarations: [ declarations: [
PermsDirective, PermsDirective,
@ -342,7 +343,8 @@ import { PollPercentBasePipe } from './pipes/poll-percent-base.pipe';
ParsePollNumberPipe, ParsePollNumberPipe,
ReversePipe, ReversePipe,
PollKeyVerbosePipe, PollKeyVerbosePipe,
PollPercentBasePipe PollPercentBasePipe,
VotingPrivacyWarningComponent
], ],
providers: [ providers: [
{ {
@ -373,7 +375,8 @@ import { PollPercentBasePipe } from './pipes/poll-percent-base.pipe';
ProgressSnackBarComponent, ProgressSnackBarComponent,
SuperSearchComponent, SuperSearchComponent,
MotionPollDialogComponent, MotionPollDialogComponent,
AssignmentPollDialogComponent AssignmentPollDialogComponent,
VotingPrivacyWarningComponent
] ]
}) })
export class SharedModule {} export class SharedModule {}

View File

@ -313,7 +313,7 @@ export class AssignmentDetailComponent extends BaseViewComponent implements OnIn
collectionString: ViewAssignmentPoll.COLLECTIONSTRING, collectionString: ViewAssignmentPoll.COLLECTIONSTRING,
assignment_id: this.assignment.id, assignment_id: this.assignment.id,
assignment: this.assignment, assignment: this.assignment,
...this.assignmentPollService.getDefaultPollData() ...this.assignmentPollService.getDefaultPollData(this.assignment.id)
}; };
this.pollDialog.openDialog(dialogData); this.pollDialog.openDialog(dialogData);

View File

@ -20,14 +20,14 @@
<h1>{{ poll.title }}</h1> <h1>{{ poll.title }}</h1>
<span *ngIf="poll.type !== 'analog'">{{ poll.typeVerbose | translate }}</span> <span *ngIf="poll.type !== 'analog'">{{ poll.typeVerbose | translate }}</span>
<div *ngIf="poll.stateHasVotes"> <div class="assignment-result-wrapper" *ngIf="poll.stateHasVotes">
<div class="assignment-result-wrapper"> <!-- Result Table -->
<!-- Result Table --> <div>
<table class="assignment-result-table"> <table class="assignment-result-table">
<tbody> <tbody>
<tr> <tr>
<th translate>Candidates</th> <th class="voting-option" translate>Candidates</th>
<th> <th class="result voted-yes">
<span *ngIf="!poll.isMethodY" translate> <span *ngIf="!poll.isMethodY" translate>
Yes Yes
</span> </span>
@ -35,42 +35,50 @@
Votes Votes
</span> </span>
</th> </th>
<th translate *ngIf="!poll.isMethodY">No</th> <th class="result voted-no" translate *ngIf="!poll.isMethodY">No</th>
<th translate *ngIf="poll.isMethodYNA">Abstain</th> <th class="result voted-abstain" translate *ngIf="poll.isMethodYNA">Abstain</th>
</tr> </tr>
<tr *ngFor="let row of poll.tableData" [class]="row.class"> <tr *ngFor="let row of poll.tableData" [class]="row.class">
<td> <td class="voting-option">
<span> <div>
{{ row.votingOption | pollKeyVerbose | translate }} <span>
</span> {{ row.votingOption | pollKeyVerbose | translate }}
<span class="user-subtitle" *ngIf="row.votingOptionSubtitle"> </span>
<br /> <span class="user-subtitle" *ngIf="row.votingOptionSubtitle">
{{ row.votingOptionSubtitle }} <br />
</span> {{ row.votingOptionSubtitle }}
</td> </span>
<td *ngFor="let vote of row.value"> </div>
<div class="single-result" *ngIf="vote && voteFitsMethod(vote)"> </td>
<td class="result" *ngFor="let vote of row.value">
<div
class="single-result"
[ngClass]="getVoteClass(vote)"
*ngIf="vote && voteFitsMethod(vote)"
>
<span> <span>
{{ vote.amount | parsePollNumber }}
<span *ngIf="vote.showPercent"> <span *ngIf="vote.showPercent">
{{ vote.amount | pollPercentBase: poll }} {{ vote.amount | pollPercentBase: poll }}
</span> </span>
{{ vote.amount | parsePollNumber }}
</span> </span>
</div> </div>
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div>
<!-- Result Chart --> <!-- Result Chart -->
<div class="chart-wrapper">
<os-charts <os-charts
class="assignment-result-chart" class="assignment-result-chart"
[ngClass]="chartType === 'doughnut' ? 'pie-chart' : ''"
*ngIf="chartDataSubject.value" *ngIf="chartDataSubject.value"
[type]="chartType" [type]="chartType"
[labels]="candidatesLabels" [labels]="candidatesLabels"
[data]="chartDataSubject" [data]="chartDataSubject"
[hasPadding]="false" [hasPadding]="false"
[showLegend]="false"
legendPosition="right" legendPosition="right"
></os-charts> ></os-charts>
</div> </div>
@ -112,7 +120,7 @@
</div> </div>
</div> </div>
<div *pblNgridCellDef="'votes'; row as vote" > <div *pblNgridCellDef="'votes'; row as vote">
<div class="single-vote-result" *ngFor="let candidate of vote.votes">{{ candidate }}</div> <div class="single-vote-result" *ngFor="let candidate of vote.votes">{{ candidate }}</div>
</div> </div>
</os-list-view-table> </os-list-view-table>
@ -121,21 +129,21 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Meta Infos --> <!-- Meta Infos -->
<div class="assignment-poll-meta"> <div *ngIf="poll" class="assignment-poll-meta">
<small *ngIf="poll.groups && poll.type && poll.type !== 'analog'"> <small *ngIf="poll.groups && poll.type && poll.type !== 'analog'">
{{ 'Groups' | translate }}: {{ 'Groups' | translate }}:
<span *ngFor="let group of poll.groups; let i = index"> <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> </span>
</small> </small>
<small *ngIf="poll.onehundred_percent_base"> <small *ngIf="poll.onehundred_percent_base">
{{ '100% base' | translate }}: {{ poll.percentBaseVerbose | translate }} {{ '100% base' | translate }}: {{ poll.percentBaseVerbose | translate }}
</small> </small>
</div>
</div> </div>
</ng-template> </ng-template>

View File

@ -2,24 +2,22 @@
@import '~assets/styles/poll-colors.scss'; @import '~assets/styles/poll-colors.scss';
.assignment-result-wrapper { .assignment-result-wrapper {
margin-top: 2em;
display: grid;
grid-gap: 10px;
.assignment-result-table { .assignment-result-table {
margin-top: 2em;
display: block;
overflow-x: auto;
border-collapse: collapse; border-collapse: collapse;
th { th {
text-align: left;
font-weight: initial; font-weight: initial;
} }
tr { tr {
height: 48px; height: 48px;
}
tr:last-child { td:first-child {
border-bottom: none; padding-right: 1em;
}
} }
tr.sums { tr.sums {
@ -30,39 +28,57 @@
} }
} }
.result {
text-align: right;
padding-left: 1em;
}
.voting-option {
min-width: 200px;
width: 100%;
text-align: left;
}
.user + .sums { .user + .sums {
td { td {
padding-top: 4em; padding-top: 2em;
} }
} }
.single-result {
white-space: pre;
}
} }
.pie-chart { .chart-wrapper {
margin-left: auto; margin-top: 2em;
margin-right: auto; .pie-chart {
width: 50%; margin-left: auto;
} margin-right: auto;
} width: 50%;
}
.single-vote-result + .single-vote-result {
margin-top: 1em;
}
.named-result-table {
.mat-form-field {
font-size: 14px;
width: 100%;
} }
.single-votes-table { .named-result-table {
display: block; .mat-form-field {
height: 500px; font-size: 14px;
} width: 100%;
}
.vote-field { .single-votes-table {
text-align: center; display: block;
width: 100%; height: 500px;
padding-right: 12px;
.single-vote-result + .single-vote-result {
margin-top: 1em;
}
}
.vote-field {
text-align: center;
width: 100%;
padding-right: 12px;
}
} }
} }

View File

@ -34,11 +34,9 @@ export class AssignmentPollDetailComponent extends BasePollDetailComponent<ViewA
public candidatesLabels: string[] = []; public candidatesLabels: string[] = [];
public get chartType(): ChartType { public get chartType(): ChartType {
return this._chartType; return 'stackedBar';
} }
private _chartType: ChartType = 'horizontalBar';
public constructor( public constructor(
title: Title, title: Title,
translate: TranslateService, translate: TranslateService,
@ -114,19 +112,15 @@ export class AssignmentPollDetailComponent extends BasePollDetailComponent<ViewA
} }
} }
protected initChartData(): void {
if (this.poll.isMethodY) {
this._chartType = 'doughnut';
this.chartDataSubject.next(this.pollService.generateCircleChartData(this.poll));
} else {
super.initChartData();
}
}
protected hasPerms(): boolean { protected hasPerms(): boolean {
return this.operator.hasPerms('assignments.can_manage'); return this.operator.hasPerms('assignments.can_manage');
} }
public getVoteClass(votingResult: VotingResult): string {
const cssPrefix = 'voted-';
return `${cssPrefix}${votingResult.vote}`;
}
public voteFitsMethod(result: VotingResult): boolean { public voteFitsMethod(result: VotingResult): boolean {
if (this.poll.isMethodY) { if (this.poll.isMethodY) {
if (result.vote === 'abstain' || result.vote === 'no') { if (result.vote === 'abstain' || result.vote === 'no') {

View File

@ -0,0 +1,34 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material';
import { E2EImportsModule } from 'e2e-imports.module';
import { AssignmentPollDialogComponent } from './assignment-poll-dialog.component';
describe('AssignmentPollDialogComponent', () => {
let component: AssignmentPollDialogComponent;
let fixture: ComponentFixture<AssignmentPollDialogComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule],
providers: [
{ provide: MatDialogRef, useValue: {} },
{
provide: MAT_DIALOG_DATA,
useValue: {}
}
]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(AssignmentPollDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -85,15 +85,23 @@ export class AssignmentPollDialogComponent extends BasePollDialogComponent<ViewA
public ngOnInit(): void { public ngOnInit(): void {
// TODO: not solid. // TODO: not solid.
// on new poll creation, poll.options does not exist, so we have to build a substitute from the assignment candidates // on new poll creation, poll.options does not exist, so we have to build a substitute from the assignment candidates
this.options = this.pollData.options if (this.pollData) {
? this.pollData.options if (this.pollData.options) {
: this.pollData.assignment.candidates.map( this.options = this.pollData.options;
user => ({ } else if (
user_id: user.id, this.pollData.assignment &&
user: user this.pollData.assignment.candidates &&
}), this.pollData.assignment.candidates.length
{} ) {
); this.options = this.pollData.assignment.candidates.map(
user => ({
user_id: user.id,
user: user
}),
{}
);
}
}
this.subscriptions.push( this.subscriptions.push(
this.pollForm.contentForm.get('pollmethod').valueChanges.subscribe(() => { this.pollForm.contentForm.get('pollmethod').valueChanges.subscribe(() => {

View File

@ -1,5 +1,4 @@
<ng-container *ngIf="poll"> <ng-container *ngIf="poll">
<ng-container *ngIf="vmanager.canVote(poll)"> <ng-container *ngIf="vmanager.canVote(poll)">
<!-- TODO: Someone should make this pretty --> <!-- TODO: Someone should make this pretty -->
<span *ngIf="poll.user_has_voted_valid">Your vote is valid!</span> <span *ngIf="poll.user_has_voted_valid">Your vote is valid!</span>
@ -22,7 +21,12 @@
}" }"
> >
<div class="vote-candidate-name"> <div class="vote-candidate-name">
<span *ngIf="option.user">{{ option.user.getFullName() }}</span> <span *ngIf="option.user">
<span>{{ option.user.short_name }}</span>
<div class="user-subtitle" *ngIf="option.user.getLevelAndNumber()">
{{ option.user.getLevelAndNumber() }}
</div>
</span>
<span *ngIf="!option.user">{{ 'Unknown user' | translate }}</span> <span *ngIf="!option.user">{{ 'Unknown user' | translate }}</span>
</div> </div>

View File

@ -122,12 +122,17 @@ export class AssignmentPollVoteComponent extends BasePollVoteComponent<ViewAssig
let requestData; let requestData;
if (this.poll.pollmethod === AssignmentPollMethod.Votes) { if (this.poll.pollmethod === AssignmentPollMethod.Votes) {
const pollOptionIds = this.getPollOptionIds(); const pollOptionIds = this.getPollOptionIds();
requestData = pollOptionIds.reduce((o, n) => { requestData = pollOptionIds.reduce((o, n) => {
if ((n === optionId && vote === 'Y') !== (this.currentVotes[n] === 'Yes')) { o[n] = 0;
o[n] = 1; // TODO: allow multiple votes per candidate if (this.poll.votes_amount === 1) {
} else { if (n === optionId && this.currentVotes[n] !== 'Yes') {
o[n] = 0; o[n] = 1;
}
} else if ((n === optionId) !== (this.currentVotes[n] === 'Yes')) {
o[n] = 1;
} }
return o; return o;
}, {}); }, {});
} else { } else {
@ -135,6 +140,7 @@ export class AssignmentPollVoteComponent extends BasePollVoteComponent<ViewAssig
requestData = {}; requestData = {};
requestData[optionId] = vote; requestData[optionId] = vote;
} }
this.pollRepo.vote(requestData, this.poll.id).catch(this.raiseError); this.pollRepo.vote(requestData, this.poll.id).catch(this.raiseError);
} }

View File

@ -1,4 +1,4 @@
<mat-card class="os-card" *ngIf="poll && showPoll()"> <mat-card class="os-card" *ngIf="poll">
<div class="assignment-poll-wrapper"> <div class="assignment-poll-wrapper">
<div> <div>
<!-- Title --> <!-- Title -->
@ -21,7 +21,7 @@
<!-- Buttons --> <!-- Buttons -->
<button <button
mat-icon-button mat-icon-button
*osPerms="'assignments.motions.can_manage_polls'; or: 'core.can_manage_projector'" *osPerms="['core.can_manage_projector', 'assignments.can_manage_polls']"
[matMenuTriggerFor]="pollItemMenu" [matMenuTriggerFor]="pollItemMenu"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
> >
@ -45,14 +45,25 @@
</div> </div>
</div> </div>
<div *ngIf="canSeeVotes"> <!-- Chart -->
<os-charts <div *ngIf="poll.stateHasVotes">
[class]="chartType === 'doughnut' ? 'doughnut-chart' : 'bar-chart'" <div *osPerms="'assignments.can_manage'; or: poll.isPublished">
[type]="chartType" <os-charts
[labels]="candidatesLabels" [type]="chartType"
[data]="chartDataSubject" [labels]="candidatesLabels"
[hasPadding]="false" [data]="chartDataSubject"
></os-charts> [hasPadding]="false"
[showLegend]="!poll.isMethodY"
legendPosition="right"
></os-charts>
</div>
<!-- Cannot see unpublished -->
<div *osPerms="'assignments.can_manage'; complement: true">
<span *ngIf="poll.isFinished">
{{ 'Counting is in progress' | translate }}
</span>
</div>
</div> </div>
<!-- Poll progress bar --> <!-- Poll progress bar -->

View File

@ -29,11 +29,4 @@
.publish-poll-button { .publish-poll-button {
color: $poll-publish-color; color: $poll-publish-color;
} }
.doughnut-chart {
display: block;
max-width: 300px;
margin-left: auto;
margin-right: auto;
}
} }

View File

@ -6,7 +6,6 @@ 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 { AssignmentPollRepositoryService } from 'app/core/repositories/assignments/assignment-poll-repository.service'; import { AssignmentPollRepositoryService } from 'app/core/repositories/assignments/assignment-poll-repository.service';
import { PromptService } from 'app/core/ui-services/prompt.service'; import { PromptService } from 'app/core/ui-services/prompt.service';
import { ChartType } from 'app/shared/components/charts/charts.component'; import { ChartType } from 'app/shared/components/charts/charts.component';
@ -39,7 +38,7 @@ export class AssignmentPollComponent extends BasePollComponent<ViewAssignmentPol
} }
public get chartType(): ChartType { public get chartType(): ChartType {
return this.pollService.getChartType(this.poll); return 'stackedBar';
} }
public candidatesLabels: string[] = []; public candidatesLabels: string[] = [];
@ -49,21 +48,6 @@ export class AssignmentPollComponent extends BasePollComponent<ViewAssignmentPol
*/ */
public descriptionForm: FormGroup; public descriptionForm: FormGroup;
/**
* @returns true if the user is permitted to do operations
*/
public get canManage(): boolean {
return this.operator.hasPerms('assignments.can_manage');
}
public get canSee(): boolean {
return this.operator.hasPerms('assignments.can_see');
}
public get canSeeVotes(): boolean {
return (this.canManage && this.poll.isFinished) || this.poll.isPublished;
}
/** /**
* @returns true if the description on the form differs from the poll's description * @returns true if the description on the form differs from the poll's description
*/ */
@ -80,7 +64,6 @@ export class AssignmentPollComponent extends BasePollComponent<ViewAssignmentPol
repo: AssignmentPollRepositoryService, repo: AssignmentPollRepositoryService,
pollDialog: AssignmentPollDialogService, pollDialog: AssignmentPollDialogService,
public pollService: PollService, public pollService: PollService,
private operator: OperatorService,
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
private pdfService: AssignmentPollPdfService private pdfService: AssignmentPollPdfService
) { ) {
@ -99,12 +82,4 @@ export class AssignmentPollComponent extends BasePollComponent<ViewAssignmentPol
public printBallot(): void { public printBallot(): void {
this.pdfService.printBallots(this.poll); this.pdfService.printBallots(this.poll);
} }
public showPoll(): boolean {
return (
this.operator.hasPerms('assignments.can_manage_polls') ||
this.poll.isPublished ||
(this.poll.type !== 'analog' && this.poll.isStarted)
);
}
} }

View File

@ -82,17 +82,21 @@ export class ViewAssignmentPoll extends ViewBasePoll<AssignmentPoll, AssignmentP
})); }));
tableData.push( tableData.push(
...this.sumTableKeys.map(key => ({ ...this.sumTableKeys
votingOption: key.vote, .filter(key => {
class: 'sums', return !key.hide;
value: [ })
{ .map(key => ({
amount: this[key.vote], votingOption: key.vote,
hide: key.hide, class: 'sums',
showPercent: key.showPercent value: [
} as VotingResult {
] amount: this[key.vote],
})) hide: key.hide,
showPercent: key.showPercent
} as VotingResult
]
}))
); );
return tableData; return tableData;
} }

View File

@ -54,14 +54,21 @@ export class AssignmentPollService extends PollService {
.subscribe(method => (this.defaultPollMethod = method)); .subscribe(method => (this.defaultPollMethod = method));
} }
public getDefaultPollData(): AssignmentPoll { public getDefaultPollData(contextId?: number): AssignmentPoll {
const poll = new AssignmentPoll(super.getDefaultPollData()); const poll = new AssignmentPoll({
const length = this.pollRepo.getViewModelList().filter(item => item.assignment_id === poll.assignment_id) ...super.getDefaultPollData()
.length; });
poll.title = !length ? this.translate.instant('Ballot') : `${this.translate.instant('Ballot')} (${length + 1})`; poll.title = this.translate.instant('Ballot');
poll.pollmethod = this.defaultPollMethod; poll.pollmethod = this.defaultPollMethod;
if (contextId) {
const length = this.pollRepo.getViewModelList().filter(item => item.assignment_id === contextId).length;
if (length) {
poll.title += ` (${length + 1})`;
}
}
return poll; return poll;
} }

View File

@ -34,6 +34,10 @@ export class ViewMotionPoll extends ViewBasePoll<MotionPoll, MotionPollMethod, P
return this.options[0]; return this.options[0];
} }
public get hasPresentableValues(): boolean {
return this.result.hasPresentableValues;
}
public get hasVotes(): boolean { public get hasVotes(): boolean {
return this.result && !!this.result.votes.length; return this.result && !!this.result.votes.length;
} }

View File

@ -133,7 +133,12 @@
{{ getTitleWithChanges() }} {{ getTitleWithChanges() }}
</h1> </h1>
<button mat-icon-button color="primary" (click)="toggleFavorite()"> <button
mat-icon-button
color="primary"
(click)="toggleFavorite()"
matTooltip="{{ 'Mark as personal favorite' | translate }}"
>
<mat-icon>{{ motion.star ? 'star' : 'star_border' }}</mat-icon> <mat-icon>{{ motion.star ? 'star' : 'star_border' }}</mat-icon>
</button> </button>
</div> </div>
@ -460,12 +465,16 @@
<!-- motion polls --> <!-- motion polls -->
<div *ngIf="!editMotion" class="spacer-top-20 spacer-bottom-20"> <div *ngIf="!editMotion" class="spacer-top-20 spacer-bottom-20">
<os-motion-poll *ngFor="let poll of motion.polls; trackBy: trackByIndex" [poll]="poll"></os-motion-poll> <os-motion-poll *ngFor="let poll of motion.polls; trackBy: trackByIndex" [poll]="poll"></os-motion-poll>
<div class="mat-card create-poll-button" *ngIf="perms.isAllowed('createpoll', motion)"> <button
<button mat-button (click)="openDialog()"> class="create-poll-button"
<mat-icon class="main-nav-color">poll</mat-icon> create-poll-button
<span translate>New vote</span> mat-stroked-button
</button> (click)="openDialog()"
</div> *ngIf="perms.isAllowed('createpoll', motion)"
>
<mat-icon class="main-nav-color">add</mat-icon>
<span translate>New vote</span>
</button>
</div> </div>
</div> </div>
</ng-template> </ng-template>

View File

@ -5,12 +5,7 @@ span {
} }
.create-poll-button { .create-poll-button {
margin-top: 10px; margin-bottom: 1em;
padding: 0px !important;
button {
display: block;
width: 100%;
}
} }
.extra-controls-slot { .extra-controls-slot {

View File

@ -1633,7 +1633,7 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit,
collectionString: ViewMotionPoll.COLLECTIONSTRING, collectionString: ViewMotionPoll.COLLECTIONSTRING,
motion_id: this.motion.id, motion_id: this.motion.id,
motion: this.motion, motion: this.motion,
...this.motionPollService.getDefaultPollData() ...this.motionPollService.getDefaultPollData(this.motion.id)
}; };
this.pollDialog.openDialog(dialogData); this.pollDialog.openDialog(dialogData);

View File

@ -67,9 +67,8 @@
</table> </table>
<!-- Chart --> <!-- Chart -->
<div class="doughnut-chart"> <div class="doughnut-chart" *ngIf="poll.hasPresentableValues && chartDataSubject.value">
<os-charts <os-charts
*ngIf="chartDataSubject.value"
[type]="chartType" [type]="chartType"
[data]="chartDataSubject" [data]="chartDataSubject"
[showLegend]="false" [showLegend]="false"

View File

@ -1,4 +1,4 @@
import { Component, Inject, ViewChild } from '@angular/core'; import { Component, Inject, OnInit, ViewChild } from '@angular/core';
import { FormBuilder, Validators } from '@angular/forms'; import { FormBuilder, Validators } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogRef, MatSnackBar } from '@angular/material'; import { MAT_DIALOG_DATA, MatDialogRef, MatSnackBar } from '@angular/material';
import { Title } from '@angular/platform-browser'; import { Title } from '@angular/platform-browser';
@ -15,7 +15,7 @@ import { PercentBaseVerbose } from 'app/site/polls/models/view-base-poll';
templateUrl: './motion-poll-dialog.component.html', templateUrl: './motion-poll-dialog.component.html',
styleUrls: ['./motion-poll-dialog.component.scss'] styleUrls: ['./motion-poll-dialog.component.scss']
}) })
export class MotionPollDialogComponent extends BasePollDialogComponent<ViewMotionPoll> { export class MotionPollDialogComponent extends BasePollDialogComponent<ViewMotionPoll> implements OnInit {
public PercentBaseVerbose = PercentBaseVerbose; public PercentBaseVerbose = PercentBaseVerbose;
@ViewChild('pollForm', { static: false }) @ViewChild('pollForm', { static: false })
@ -30,6 +30,9 @@ export class MotionPollDialogComponent extends BasePollDialogComponent<ViewMotio
@Inject(MAT_DIALOG_DATA) public pollData: Partial<ViewMotionPoll> @Inject(MAT_DIALOG_DATA) public pollData: Partial<ViewMotionPoll>
) { ) {
super(title, translate, matSnackbar, dialogRef); super(title, translate, matSnackbar, dialogRef);
}
public ngOnInit(): void {
this.createDialog(); this.createDialog();
} }
@ -54,12 +57,12 @@ export class MotionPollDialogComponent extends BasePollDialogComponent<ViewMotio
*/ */
private createDialog(): void { private createDialog(): void {
this.dialogVoteForm = this.fb.group({ this.dialogVoteForm = this.fb.group({
Y: ['', [Validators.min(-2)]], Y: [0, [Validators.min(-2)]],
N: ['', [Validators.min(-2)]], N: [0, [Validators.min(-2)]],
A: ['', [Validators.min(-2)]], A: [0, [Validators.min(-2)]],
votesvalid: ['', [Validators.min(-2)]], votesvalid: [0, [Validators.min(-2)]],
votesinvalid: ['', [Validators.min(-2)]], votesinvalid: [0, [Validators.min(-2)]],
votescast: ['', [Validators.min(-2)]] votescast: [0, [Validators.min(-2)]]
}); });
if (this.pollData.poll) { if (this.pollData.poll) {

View File

@ -7,8 +7,10 @@ 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 { MotionVoteRepositoryService } from 'app/core/repositories/motions/motion-vote-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 { MotionPollMethod } from 'app/shared/models/motions/motion-poll'; 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 { 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';
@ -66,7 +68,8 @@ export class MotionPollVoteComponent extends BasePollVoteComponent<ViewMotionPol
vmanager: VotingService, vmanager: VotingService,
operator: OperatorService, operator: OperatorService,
private voteRepo: MotionVoteRepositoryService, private voteRepo: MotionVoteRepositoryService,
private pollRepo: MotionPollRepositoryService private pollRepo: MotionPollRepositoryService,
private promptService: PromptService
) { ) {
super(title, translate, matSnackbar, vmanager, operator); super(title, translate, matSnackbar, vmanager, operator);
} }
@ -100,6 +103,16 @@ export class MotionPollVoteComponent extends BasePollVoteComponent<ViewMotionPol
* 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 {
this.pollRepo.vote(vote, this.poll.id).catch(this.raiseError); if (this.poll.type === PollType.Pseudoanonymous) {
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(vote, this.poll.id).catch(this.raiseError);
}
});
} else {
this.pollRepo.vote(vote, this.poll.id).catch(this.raiseError);
}
} }
} }

View File

@ -6,11 +6,19 @@
<!-- Title --> <!-- Title -->
<span class="poll-title"> <span class="poll-title">
<a [routerLink]="pollLink"> <a [routerLink]="pollLink">
{{ poll.title }} {{ poll.title | translate }}
</a> </a>
</span> </span>
<div> <div>
<span *osPerms="'motions.can_manage_polls'; and: poll.type === 'pseudoanonymous'">
<button mat-icon-button color="warn" (click)="openVotingWarning()">
<mat-icon>
warning
</mat-icon>
</button>
</span>
<!-- Subtitle --> <!-- Subtitle -->
<span *ngIf="pollService.isElectronicVotingEnabled && poll.type !== 'analog'"> <span *ngIf="pollService.isElectronicVotingEnabled && poll.type !== 'analog'">
{{ poll.typeVerbose | translate }} &middot; {{ poll.typeVerbose | translate }} &middot;
@ -18,7 +26,7 @@
<!-- State chip --> <!-- State chip -->
<span> <span>
{{ poll.stateVerbose }} {{ poll.stateVerbose | translate }}
</span> </span>
</div> </div>
</div> </div>
@ -32,13 +40,8 @@
</div> </div>
<!-- Change state button --> <!-- Change state button -->
<div *osPerms="'motions.can_manage_polls'"> <div *osPerms="'motions.can_manage_polls'; and: !hideChangeState">
<button <button mat-stroked-button [ngClass]="pollStateActions[poll.state].css" (click)="changeState(poll.nextState)">
mat-stroked-button
*ngIf="!poll.isPublished"
[ngClass]="pollStateActions[poll.state].css"
(click)="changeState(poll.nextState)"
>
<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">
{{ poll.nextStateActionVerbose | translate }} {{ poll.nextStateActionVerbose | translate }}
@ -73,22 +76,12 @@
<!-- Result Chart and legend --> <!-- Result Chart and legend -->
<div class="poll-chart-wrapper" *osPerms="'motions.can_manage_polls'; or: poll.isPublished"> <div class="poll-chart-wrapper" *osPerms="'motions.can_manage_polls'; or: poll.isPublished">
<div class="vote-legend" [routerLink]="pollLink"> <div class="vote-legend" [routerLink]="pollLink">
<div class="votes-yes"> <div *ngFor="let row of reducedPollTableData" [class]="row.votingOption">
<os-icon-container icon="thumb_up" size="large"> <os-icon-container [icon]="row.value[0].icon" size="large">
{{ voteYes | parsePollNumber }} {{ row.value[0].amount | parsePollNumber }}
{{ voteYes | pollPercentBase: poll }} <span *ngIf="row.value[0].showPercent">
</os-icon-container> {{ row.value[0].amount | pollPercentBase: poll }}
</div> </span>
<div class="votes-no">
<os-icon-container icon="thumb_down" size="large">
{{ voteNo | parsePollNumber }}
{{ voteNo | pollPercentBase: poll }}
</os-icon-container>
</div>
<div class="votes-abstain">
<os-icon-container icon="trip_origin" size="large">
{{ voteAbstain | parsePollNumber }}
{{ voteAbstain | pollPercentBase: poll }}
</os-icon-container> </os-icon-container>
</div> </div>
</div> </div>
@ -113,7 +106,7 @@
</ng-template> </ng-template>
<ng-template #emptyTemplate> <ng-template #emptyTemplate>
<div *osPerms="'motions.can_manage_polls'"> <div *osPerms="'motions.can_manage_polls'; and: poll.type === 'analog'">
{{ 'Edit to enter votes.' | translate }} {{ 'Edit to enter votes.' | translate }}
</div> </div>
</ng-template> </ng-template>

View File

@ -47,15 +47,15 @@
margin-top: 20px; margin-top: 20px;
} }
.votes-yes { .yes {
color: $votes-yes-color; color: $votes-yes-color;
} }
.votes-no { .no {
color: $votes-no-color; color: $votes-no-color;
} }
.votes-abstain { .abstain {
color: $votes-abstain-color; color: $votes-abstain-color;
} }
} }

View File

@ -6,10 +6,14 @@ 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 { PollType } from 'app/shared/models/poll/base-poll';
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';
import { MotionPollPdfService } from 'app/site/motions/services/motion-poll-pdf.service'; import { MotionPollPdfService } from 'app/site/motions/services/motion-poll-pdf.service';
import { BasePollComponent } from 'app/site/polls/components/base-poll.component'; import { BasePollComponent } from 'app/site/polls/components/base-poll.component';
import { PollTableData } from 'app/site/polls/models/view-base-poll';
import { PollService } from 'app/site/polls/services/poll.service'; import { PollService } from 'app/site/polls/services/poll.service';
/** /**
@ -28,19 +32,7 @@ export class MotionPollComponent extends BasePollComponent<ViewMotionPoll> {
@Input() @Input()
public set poll(value: ViewMotionPoll) { public set poll(value: ViewMotionPoll) {
this.initPoll(value); this.initPoll(value);
const chartData = this.pollService.generateChartData(value); const chartData = this.pollService.generateChartData(value);
for (const data of chartData) {
if (data.label === 'YES') {
this.voteYes = data.data[0];
}
if (data.label === 'NO') {
this.voteNo = data.data[0];
}
if (data.label === 'ABSTAIN') {
this.voteAbstain = data.data[0];
}
}
this.chartDataSubject.next(chartData); this.chartDataSubject.next(chartData);
} }
@ -52,48 +44,17 @@ export class MotionPollComponent extends BasePollComponent<ViewMotionPoll> {
return `/motions/polls/${this.poll.id}`; return `/motions/polls/${this.poll.id}`;
} }
/**
* Number of votes for `Yes`.
*/
public set voteYes(n: number) {
this._voteYes = n;
}
public get voteYes(): number {
return this._voteYes;
}
/**
* Number of votes for `No`.
*/
public set voteNo(n: number) {
this._voteNo = n;
}
public get voteNo(): number {
return this._voteNo;
}
/**
* Number of votes for `Abstain`.
*/
public set voteAbstain(n: number) {
this._voteAbstain = n;
}
public get voteAbstain(): number {
return this._voteAbstain;
}
public get showChart(): boolean { public get showChart(): boolean {
return this._voteYes >= 0 && this._voteNo >= 0; return this.poll.hasPresentableValues;
} }
private _voteNo: number; public get hideChangeState(): boolean {
return this.poll.isPublished || (this.poll.isCreated && this.poll.type === PollType.Analog);
}
private _voteYes: number; public get reducedPollTableData(): PollTableData[] {
return this.poll.tableData.filter(data => ['yes', 'no', 'abstain', 'votesinvalid'].includes(data.votingOption));
private _voteAbstain: number; }
/** /**
* Constructor. * Constructor.
@ -118,6 +79,10 @@ export class MotionPollComponent extends BasePollComponent<ViewMotionPoll> {
super(titleService, matSnackBar, translate, dialog, promptService, pollRepo, pollDialog); super(titleService, matSnackBar, translate, dialog, promptService, pollRepo, pollDialog);
} }
public openVotingWarning(): void {
this.dialog.open(VotingPrivacyWarningComponent, infoDialogSettings);
}
public downloadPdf(): void { public downloadPdf(): void {
this.pdfService.printBallots(this.poll); this.pdfService.printBallots(this.poll);
} }

View File

@ -146,7 +146,7 @@ export class WorkflowDetailComponent extends BaseViewComponent implements OnInit
* @param title Set the page title * @param title Set the page title
* @param translate Handle translations * @param translate Handle translations
* @param matSnackBar Showing error * @param matSnackBar Showing error
* @param promtService Promts * @param promptService Promts
* @param dialog Opening dialogs * @param dialog Opening dialogs
* @param workflowRepo The repository for workflows * @param workflowRepo The repository for workflows
* @param route Read out URL paramters * @param route Read out URL paramters
@ -155,7 +155,7 @@ export class WorkflowDetailComponent extends BaseViewComponent implements OnInit
title: Title, title: Title,
protected translate: TranslateService, // protected required for ng-translate-extract protected translate: TranslateService, // protected required for ng-translate-extract
matSnackBar: MatSnackBar, matSnackBar: MatSnackBar,
private promtService: PromptService, private promptService: PromptService,
private dialog: MatDialog, private dialog: MatDialog,
private workflowRepo: WorkflowRepositoryService, private workflowRepo: WorkflowRepositoryService,
private stateRepo: StateRepositoryService, private stateRepo: StateRepositoryService,
@ -208,7 +208,7 @@ export class WorkflowDetailComponent extends BaseViewComponent implements OnInit
} else if (result.action === 'delete') { } else if (result.action === 'delete') {
const content = this.translate.instant('Delete') + ` ${state.name}?`; const content = this.translate.instant('Delete') + ` ${state.name}?`;
this.promtService.open('Are you sure', content).then(promptResult => { this.promptService.open('Are you sure', content).then(promptResult => {
if (promptResult) { if (promptResult) {
this.stateRepo.delete(state).then(() => {}, this.raiseError); this.stateRepo.delete(state).then(() => {}, this.raiseError);
} }

View File

@ -55,13 +55,19 @@ export class MotionPollService extends PollService {
config.get<number[]>(MotionPoll.defaultGroupsConfig).subscribe(ids => (this.defaultGroupIds = ids)); config.get<number[]>(MotionPoll.defaultGroupsConfig).subscribe(ids => (this.defaultGroupIds = ids));
} }
public getDefaultPollData(): MotionPoll { public getDefaultPollData(contextId?: number): MotionPoll {
const poll = new MotionPoll(super.getDefaultPollData()); const poll = new MotionPoll(super.getDefaultPollData());
const length = this.pollRepo.getViewModelList().filter(item => item.motion_id === poll.motion_id).length;
poll.title = !length ? this.translate.instant('Vote') : `${this.translate.instant('Vote')} (${length + 1})`; poll.title = this.translate.instant('Vote');
poll.pollmethod = MotionPollMethod.YNA; poll.pollmethod = MotionPollMethod.YNA;
if (contextId) {
const length = this.pollRepo.getViewModelList().filter(item => item.motion_id === contextId).length;
if (length) {
poll.title += ` (${length + 1})`;
}
}
return poll; return poll;
} }

View File

@ -1,3 +1,4 @@
import { OnInit } from '@angular/core';
import { FormGroup } from '@angular/forms'; import { FormGroup } from '@angular/forms';
import { MatSnackBar } from '@angular/material'; import { MatSnackBar } from '@angular/material';
import { MatDialogRef } from '@angular/material/dialog'; import { MatDialogRef } from '@angular/material/dialog';
@ -13,7 +14,7 @@ import { ViewBasePoll } from '../models/view-base-poll';
/** /**
* A dialog for updating the values of a poll. * A dialog for updating the values of a poll.
*/ */
export abstract class BasePollDialogComponent<T extends ViewBasePoll> extends BaseViewComponent { export abstract class BasePollDialogComponent<T extends ViewBasePoll> extends BaseViewComponent implements OnInit {
public publishImmediately: boolean; public publishImmediately: boolean;
protected pollForm: PollFormComponent<T>; protected pollForm: PollFormComponent<T>;
@ -29,6 +30,21 @@ export abstract class BasePollDialogComponent<T extends ViewBasePoll> extends Ba
super(title, translate, matSnackbar); super(title, translate, matSnackbar);
} }
public ngOnInit(): void {
if (this.dialogRef) {
// Jasmin/Karma fails here. TODO:
this.dialogRef.keydownEvents().subscribe((event: KeyboardEvent) => {
if (event.key === 'Enter' && event.shiftKey) {
this.submitPoll();
}
if (event.key === 'Escape') {
this.dialogRef.close();
}
});
}
}
/** /**
* Submits the values from dialog. * Submits the values from dialog.
*/ */

View File

@ -9,7 +9,7 @@
</form> </form>
<!-- TODO: rather disable forms than duplicate them --> <!-- TODO: rather disable forms than duplicate them -->
<div *ngIf="data && data.state > 1" class="poll-preview-meta-info"> <div *ngIf="data && data.state" class="poll-preview-meta-info">
<span class="short-description" *ngFor="let value of pollValues"> <span class="short-description" *ngFor="let value of pollValues">
<span class="short-description-label subtitle" translate> <span class="short-description-label subtitle" translate>
{{ value[0] }} {{ value[0] }}
@ -22,13 +22,14 @@
<form [formGroup]="contentForm" class="poll-preview-meta-info-form"> <form [formGroup]="contentForm" class="poll-preview-meta-info-form">
<ng-container *ngIf="!data || !data.state || data.isCreated"> <ng-container *ngIf="!data || !data.state || data.isCreated">
<!-- Poll Type --> <!-- Poll Type -->
<mat-form-field *ngIf="pollService.isElectronicVotingEnabled"> <mat-form-field class="pollType" *ngIf="pollService.isElectronicVotingEnabled">
<mat-select [placeholder]="PollPropertyVerbose.type | translate" formControlName="type" required> <mat-select [placeholder]="PollPropertyVerbose.type | translate" formControlName="type" required>
<mat-option *ngFor="let option of pollTypes | keyvalue" [value]="option.key"> <mat-option *ngFor="let option of pollTypes | keyvalue" [value]="option.key">
{{ option.value | translate }} {{ option.value | translate }}
</mat-option> </mat-option>
</mat-select> </mat-select>
<mat-error translate>This field is required</mat-error> <mat-error translate>This field is required</mat-error>
<mat-hint (click)="openVotingWarning()" *ngIf="showNonNominalWarning"> {{ 'Not suitable for formal secret voting!' | translate }}</mat-hint>
</mat-form-field> </mat-form-field>
<!-- Groups entitled to Vote --> <!-- Groups entitled to Vote -->

View File

@ -2,6 +2,13 @@
margin: 0; margin: 0;
} }
.pollType {
.mat-hint {
color: red;
cursor: pointer;
}
}
.poll-preview-meta-info { .poll-preview-meta-info {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;

View File

@ -1,6 +1,6 @@
import { Component, Input, OnInit } from '@angular/core'; import { Component, Input, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { MatSnackBar } from '@angular/material'; import { MatDialog, MatSnackBar } from '@angular/material';
import { Title } from '@angular/platform-browser'; import { Title } from '@angular/platform-browser';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
@ -8,12 +8,13 @@ import { Observable } from 'rxjs';
import { GroupRepositoryService } from 'app/core/repositories/users/group-repository.service'; import { GroupRepositoryService } from 'app/core/repositories/users/group-repository.service';
import { ConfigService } from 'app/core/ui-services/config.service'; import { ConfigService } from 'app/core/ui-services/config.service';
import { VotingPrivacyWarningComponent } from 'app/shared/components/voting-privacy-warning/voting-privacy-warning.component';
import { AssignmentPollMethod, AssignmentPollPercentBase } from 'app/shared/models/assignments/assignment-poll'; import { AssignmentPollMethod, AssignmentPollPercentBase } from 'app/shared/models/assignments/assignment-poll';
import { PercentBase } from 'app/shared/models/poll/base-poll'; import { PercentBase } from 'app/shared/models/poll/base-poll';
import { PollType } from 'app/shared/models/poll/base-poll'; import { PollType } from 'app/shared/models/poll/base-poll';
import { infoDialogSettings } from 'app/shared/utils/dialog-settings';
import { ViewAssignmentPoll } from 'app/site/assignments/models/view-assignment-poll'; import { ViewAssignmentPoll } from 'app/site/assignments/models/view-assignment-poll';
import { BaseViewComponent } from 'app/site/base/base-view'; import { BaseViewComponent } from 'app/site/base/base-view';
import { ViewMotionPoll } from 'app/site/motions/models/view-motion-poll';
import { import {
MajorityMethodVerbose, MajorityMethodVerbose,
PollClassType, PollClassType,
@ -86,6 +87,8 @@ export class PollFormComponent<T extends ViewBasePoll> extends BaseViewComponent
public showSingleAmountHint = false; public showSingleAmountHint = false;
public showNonNominalWarning = false;
/** /**
* Constructor. Retrieves necessary metadata from the pollService, * Constructor. Retrieves necessary metadata from the pollService,
* injects the poll itself * injects the poll itself
@ -97,7 +100,8 @@ export class PollFormComponent<T extends ViewBasePoll> extends BaseViewComponent
private fb: FormBuilder, private fb: FormBuilder,
private groupRepo: GroupRepositoryService, private groupRepo: GroupRepositoryService,
public pollService: PollService, public pollService: PollService,
private configService: ConfigService private configService: ConfigService,
private dialog: MatDialog
) { ) {
super(title, translate, snackbar); super(title, translate, snackbar);
this.initContentForm(); this.initContentForm();
@ -119,7 +123,6 @@ export class PollFormComponent<T extends ViewBasePoll> extends BaseViewComponent
if (!this.data.pollmethod) { if (!this.data.pollmethod) {
this.data.pollmethod = this.configService.instant('assignment_poll_method'); this.data.pollmethod = this.configService.instant('assignment_poll_method');
} }
} else if (this.data instanceof ViewMotionPoll) {
} }
Object.keys(this.contentForm.controls).forEach(key => { Object.keys(this.contentForm.controls).forEach(key => {
@ -209,6 +212,16 @@ export class PollFormComponent<T extends ViewBasePoll> extends BaseViewComponent
private setVotesAmountCtrl(): void { private setVotesAmountCtrl(): void {
// Disable "Amounts of votes" if anonymous and yes-method // Disable "Amounts of votes" if anonymous and yes-method
const votesAmountCtrl = this.contentForm.get('votes_amount'); 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 ( if (
this.contentForm.get('type').value === PollType.Pseudoanonymous && this.contentForm.get('type').value === PollType.Pseudoanonymous &&
this.contentForm.get('pollmethod').value === 'votes' this.contentForm.get('pollmethod').value === 'votes'
@ -276,6 +289,10 @@ export class PollFormComponent<T extends ViewBasePoll> extends BaseViewComponent
}); });
} }
public openVotingWarning(): void {
this.dialog.open(VotingPrivacyWarningComponent, infoDialogSettings);
}
/** /**
* compare function used with the KeyValuePipe to display the percent bases in original order * compare function used with the KeyValuePipe to display the percent bases in original order
*/ */

View File

@ -1,12 +1,8 @@
<div *ngIf="poll" class="poll-progress-wrapper"> <div *ngIf="poll" class="poll-progress-wrapper">
<div class="motion-vote-number" *ngIf="poll.pollClassType === 'motion'"> <div class="vote-number">
<span>{{ poll.votescast }} / {{ max }}</span> <span>{{ poll.votescast }} / {{ max }}</span>
</div> </div>
<div *ngIf="poll.pollClassType === 'assignment'"> <span translate>Received votes</span>
<div>{{ 'Total' | translate }}: {{ poll.votescast }} / {{ max }},</div>
<div>{{ 'Valid' | translate }}: {{ poll.votesvalid }} / {{ max }},</div>
<div>{{ 'Invalid votes' | translate }}: {{ poll.votesinvalid }} / {{ max }}</div>
</div>
<mat-progress-bar class="voting-progress-bar" [value]="valueInPercent"></mat-progress-bar> <mat-progress-bar class="voting-progress-bar" [value]="valueInPercent"></mat-progress-bar>
</div> </div>

View File

@ -1,10 +1,8 @@
.poll-progress-wrapper { .poll-progress-wrapper {
margin: 1em 0 2em 0; margin: 1em 0 2em 0;
.voting-progress-bar {
margin-top: 1em;
}
.motion-vote-number { .vote-number {
text-align: center; text-align: center;
font-size: 150%;
} }
} }

View File

@ -4,6 +4,10 @@ import { ViewBasePoll } from './view-base-poll';
import { ViewBaseVote } from './view-base-vote'; import { ViewBaseVote } from './view-base-vote';
export class ViewBaseOption<M extends BaseOption<M> = any> extends BaseViewModel<M> { export class ViewBaseOption<M extends BaseOption<M> = any> extends BaseViewModel<M> {
public get hasPresentableValues(): boolean {
return this.yes >= 0 && this.no >= 0;
}
public get option(): M { public get option(): M {
return this._model; return this._model;
} }

View File

@ -109,17 +109,19 @@ export abstract class ViewBasePoll<
protected sumTableKeys: VotingResult[] = [ protected sumTableKeys: VotingResult[] = [
{ {
vote: 'votesvalid', vote: 'votesvalid',
hide: this.poll.votesvalid === -2,
showPercent: this.poll.isPercentBaseValidOrCast showPercent: this.poll.isPercentBaseValidOrCast
}, },
{ {
vote: 'votesinvalid', vote: 'votesinvalid',
hide: this.poll.type !== PollType.Analog, icon: 'not_interested',
showPercent: this.poll.isPercentBaseValidOrCast hide: this.poll.type !== PollType.Analog || this.poll.votesinvalid === -2,
showPercent: this.poll.isPercentBaseCast
}, },
{ {
vote: 'votescast', vote: 'votescast',
hide: this.poll.type !== PollType.Analog, hide: this.poll.type !== PollType.Analog || this.poll.votescast === -2,
showPercent: this.poll.isPercentBaseValidOrCast showPercent: this.poll.isPercentBaseCast
} }
]; ];
@ -163,7 +165,11 @@ export abstract class ViewBasePoll<
public abstract get percentBaseVerbose(): string; public abstract get percentBaseVerbose(): string;
public get showAbstainPercent(): boolean { public get showAbstainPercent(): boolean {
return this.poll.onehundred_percent_base === PercentBase.YNA; return (
this.poll.onehundred_percent_base === PercentBase.YNA ||
this.poll.onehundred_percent_base === PercentBase.Valid ||
this.poll.onehundred_percent_base === PercentBase.Cast
);
} }
public abstract readonly pollClassType: 'motion' | 'assignment'; public abstract readonly pollClassType: 'motion' | 'assignment';

View File

@ -1,10 +1,9 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { _ } from 'app/core/translate/translation-marker'; import { _ } from 'app/core/translate/translation-marker';
import { ChartData, ChartType } from 'app/shared/components/charts/charts.component'; import { ChartData, ChartDate } from 'app/shared/components/charts/charts.component';
import { AssignmentPollMethod } from 'app/shared/models/assignments/assignment-poll'; import { AssignmentPollMethod } from 'app/shared/models/assignments/assignment-poll';
import { MotionPollMethod } from 'app/shared/models/motions/motion-poll'; import { BasePoll, MajorityMethod, PercentBase, PollColor, PollType } from 'app/shared/models/poll/base-poll';
import { BasePoll, MajorityMethod, PollColor, PollType } from 'app/shared/models/poll/base-poll';
import { AssignmentPollMethodVerbose } from 'app/site/assignments/models/view-assignment-poll'; import { AssignmentPollMethodVerbose } from 'app/site/assignments/models/view-assignment-poll';
import { import {
MajorityMethodVerbose, MajorityMethodVerbose,
@ -94,7 +93,7 @@ export interface PollData {
onehundred_percent_base: string; onehundred_percent_base: string;
options: { options: {
user?: { user?: {
full_name: string; short_name: string;
}; };
yes?: number; yes?: number;
no?: number; no?: number;
@ -181,47 +180,50 @@ export abstract class PollService {
} }
public generateChartData(poll: PollData): ChartData { public generateChartData(poll: PollData): ChartData {
if (poll.pollmethod === AssignmentPollMethod.Votes) { let fields: CalculablePollKey[];
return this.generateCircleChartData(poll);
} else {
return this.generateBarChartData(poll);
}
}
public generateBarChartData(poll: PollData): ChartData { // TODO: PollData should either be `ViewBasePoll` or `BasePoll` to get SOLID
const fields = ['yes', 'no']; const isAssignment = Object.keys(poll.options[0]).includes('user');
// cast is needed because ViewBasePoll doesn't have the field `pollmethod`, no easy fix :(
if ((<any>poll).pollmethod === MotionPollMethod.YNA) { if (isAssignment) {
fields.push('abstain'); if (poll.pollmethod === AssignmentPollMethod.YNA) {
fields = ['yes', 'no', 'abstain'];
} else if (poll.pollmethod === AssignmentPollMethod.YN) {
fields = ['yes', 'no'];
} else {
fields = ['yes'];
}
} else {
if (poll.onehundred_percent_base === PercentBase.YN) {
fields = ['yes', 'no'];
} else if (poll.onehundred_percent_base === PercentBase.Cast) {
fields = ['yes', 'no', 'abstain', 'votesinvalid'];
} else {
fields = ['yes', 'no', 'abstain'];
}
} }
const data: ChartData = fields.map(key => ({
label: key.toUpperCase(), const data: ChartData = fields.map(key => {
data: poll.options.map(option => option[key]), return {
backgroundColor: PollColor[key], data: this.getResultFromPoll(poll, key),
hoverBackgroundColor: PollColor[key] label: key.toUpperCase(),
})); backgroundColor: PollColor[key],
hoverBackgroundColor: PollColor[key]
} as ChartDate;
});
return data; return data;
} }
public generateCircleChartData(poll: PollData): ChartData { /**
const data: ChartData = poll.options.map(candidate => ({ * Extracts yes-no-abstain such as valid, invalids and totals from Poll and PollData-Objects
label: candidate.user.full_name, */
data: [candidate.yes] private getResultFromPoll(poll: PollData, key: CalculablePollKey): number[] {
})); return poll[key] ? [poll[key]] : poll.options.map(option => option[key]);
return data;
}
public getChartType(poll: PollData): ChartType {
if ((<any>poll).pollmethod === AssignmentPollMethod.Votes) {
return 'doughnut';
} else {
return 'horizontalBar';
}
} }
public getChartLabels(poll: PollData): string[] { public getChartLabels(poll: PollData): string[] {
return poll.options.map(candidate => candidate.user.full_name); return poll.options.map(candidate => candidate.user.short_name);
} }
public isVoteDocumented(vote: number): boolean { public isVoteDocumented(vote: number): boolean {

View File

@ -17,7 +17,7 @@ export interface AssignmentPollSlideData extends BasePollSlideData {
options: { options: {
user: { user: {
full_name: string; short_name: string;
}; };
yes?: number; yes?: number;
no?: number; no?: number;

View File

@ -5,7 +5,7 @@
</div> </div>
<div class="charts-wrapper" *ngIf="data.data.poll.state === PollState.Published"> <div class="charts-wrapper" *ngIf="data.data.poll.state === PollState.Published">
<os-charts <os-charts
[type]="pollService.getChartType(data.data.poll)" [type]="stackedBar"
[labels]="pollService.getChartLabels(data.data.poll)" [labels]="pollService.getChartLabels(data.data.poll)"
[data]="chartDataSubject" [data]="chartDataSubject"
[hasPadding]="false" [hasPadding]="false"

View File

@ -27,7 +27,7 @@ def get_config_variables():
yield ConfigVariable( yield ConfigVariable(
name="assignment_poll_default_100_percent_base", name="assignment_poll_default_100_percent_base",
default_value="YNA", default_value="valid",
input_type="choice", input_type="choice",
label="The 100-%-base of an election result consists of", label="The 100-%-base of an election result consists of",
choices=tuple( choices=tuple(