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');
_('Custom number of ballot papers');
_('Voting');
_('Click here to vote');
// subgroup PDF export
_('PDF export');
_('Title for PDF documents of motions');

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -199,14 +199,24 @@ export class ChartsComponent extends BaseViewComponent {
labels: {}
},
scales: {
xAxes: [{ ticks: { beginAtZero: true, stepSize: 1 } }],
yAxes: [{ ticks: { beginAtZero: true } }]
},
plugins: {
datalabels: {
anchor: 'end',
align: 'end'
}
xAxes: [
{
gridLines: {
drawOnChartArea: false
},
ticks: { beginAtZero: true, stepSize: 1 },
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);
}
/**
* 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 {
if (!this.chartData.every(date => date.barThickness && date.maxBarThickness)) {
this.chartData = this.chartData.map(chartDate => ({
@ -284,7 +284,6 @@ export class ChartsComponent extends BaseViewComponent {
fontSize: 14,
boxWidth: 40
};
break;
}
this.cd.detectChanges();
}
@ -292,7 +291,6 @@ export class ChartsComponent extends BaseViewComponent {
private checkChartType(chartType?: ChartType): void {
let type = chartType || this._type;
if (type === 'stackedBar') {
this.setupStackedBar();
this.setupBar();
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
*/
public writeValue(obj: string | number): void {
if (obj) {
if (obj === this.checkboxValue) {
this.checkboxStateChanged(true);
} else {
this.contentForm.patchValue(obj);
}
if (obj && obj === this.checkboxValue) {
this.checkboxStateChanged(true);
} else {
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;
}
public get isPercentBaseCast(): boolean {
return this.onehundred_percent_base === PercentBase.Cast;
}
/**
* 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 { CheckInputComponent } from './components/check-input/check-input.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 { 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';
@ -122,6 +121,7 @@ import { ParsePollNumberPipe } from './pipes/parse-poll-number.pipe';
import { ReversePipe } from './pipes/reverse.pipe';
import { PollKeyVerbosePipe } from './pipes/poll-key-verbose.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.
@ -285,7 +285,8 @@ import { PollPercentBasePipe } from './pipes/poll-percent-base.pipe';
ParsePollNumberPipe,
ReversePipe,
PollKeyVerbosePipe,
PollPercentBasePipe
PollPercentBasePipe,
VotingPrivacyWarningComponent
],
declarations: [
PermsDirective,
@ -342,7 +343,8 @@ import { PollPercentBasePipe } from './pipes/poll-percent-base.pipe';
ParsePollNumberPipe,
ReversePipe,
PollKeyVerbosePipe,
PollPercentBasePipe
PollPercentBasePipe,
VotingPrivacyWarningComponent
],
providers: [
{
@ -373,7 +375,8 @@ import { PollPercentBasePipe } from './pipes/poll-percent-base.pipe';
ProgressSnackBarComponent,
SuperSearchComponent,
MotionPollDialogComponent,
AssignmentPollDialogComponent
AssignmentPollDialogComponent,
VotingPrivacyWarningComponent
]
})
export class SharedModule {}

View File

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

View File

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

View File

@ -2,24 +2,22 @@
@import '~assets/styles/poll-colors.scss';
.assignment-result-wrapper {
margin-top: 2em;
display: grid;
grid-gap: 10px;
.assignment-result-table {
margin-top: 2em;
display: block;
overflow-x: auto;
border-collapse: collapse;
th {
text-align: left;
font-weight: initial;
}
tr {
height: 48px;
}
tr:last-child {
border-bottom: none;
td:first-child {
padding-right: 1em;
}
}
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 {
td {
padding-top: 4em;
padding-top: 2em;
}
}
.single-result {
white-space: pre;
}
}
.pie-chart {
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%;
.chart-wrapper {
margin-top: 2em;
.pie-chart {
margin-left: auto;
margin-right: auto;
width: 50%;
}
}
.single-votes-table {
display: block;
height: 500px;
}
.named-result-table {
.mat-form-field {
font-size: 14px;
width: 100%;
}
.vote-field {
text-align: center;
width: 100%;
padding-right: 12px;
.single-votes-table {
display: block;
height: 500px;
.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 get chartType(): ChartType {
return this._chartType;
return 'stackedBar';
}
private _chartType: ChartType = 'horizontalBar';
public constructor(
title: Title,
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 {
return this.operator.hasPerms('assignments.can_manage');
}
public getVoteClass(votingResult: VotingResult): string {
const cssPrefix = 'voted-';
return `${cssPrefix}${votingResult.vote}`;
}
public voteFitsMethod(result: VotingResult): boolean {
if (this.poll.isMethodY) {
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 {
// TODO: not solid.
// 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
? this.pollData.options
: this.pollData.assignment.candidates.map(
user => ({
user_id: user.id,
user: user
}),
{}
);
if (this.pollData) {
if (this.pollData.options) {
this.options = this.pollData.options;
} else if (
this.pollData.assignment &&
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.pollForm.contentForm.get('pollmethod').valueChanges.subscribe(() => {

View File

@ -1,5 +1,4 @@
<ng-container *ngIf="poll">
<ng-container *ngIf="vmanager.canVote(poll)">
<!-- TODO: Someone should make this pretty -->
<span *ngIf="poll.user_has_voted_valid">Your vote is valid!</span>
@ -22,7 +21,12 @@
}"
>
<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>
</div>

View File

@ -122,12 +122,17 @@ export class AssignmentPollVoteComponent extends BasePollVoteComponent<ViewAssig
let requestData;
if (this.poll.pollmethod === AssignmentPollMethod.Votes) {
const pollOptionIds = this.getPollOptionIds();
requestData = pollOptionIds.reduce((o, n) => {
if ((n === optionId && vote === 'Y') !== (this.currentVotes[n] === 'Yes')) {
o[n] = 1; // TODO: allow multiple votes per candidate
} else {
o[n] = 0;
o[n] = 0;
if (this.poll.votes_amount === 1) {
if (n === optionId && this.currentVotes[n] !== 'Yes') {
o[n] = 1;
}
} else if ((n === optionId) !== (this.currentVotes[n] === 'Yes')) {
o[n] = 1;
}
return o;
}, {});
} else {
@ -135,6 +140,7 @@ export class AssignmentPollVoteComponent extends BasePollVoteComponent<ViewAssig
requestData = {};
requestData[optionId] = vote;
}
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>
<!-- Title -->
@ -21,7 +21,7 @@
<!-- Buttons -->
<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"
(click)="$event.stopPropagation()"
>
@ -45,14 +45,25 @@
</div>
</div>
<div *ngIf="canSeeVotes">
<os-charts
[class]="chartType === 'doughnut' ? 'doughnut-chart' : 'bar-chart'"
[type]="chartType"
[labels]="candidatesLabels"
[data]="chartDataSubject"
[hasPadding]="false"
></os-charts>
<!-- Chart -->
<div *ngIf="poll.stateHasVotes">
<div *osPerms="'assignments.can_manage'; or: poll.isPublished">
<os-charts
[type]="chartType"
[labels]="candidatesLabels"
[data]="chartDataSubject"
[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>
<!-- Poll progress bar -->

View File

@ -29,11 +29,4 @@
.publish-poll-button {
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 { OperatorService } from 'app/core/core-services/operator.service';
import { AssignmentPollRepositoryService } from 'app/core/repositories/assignments/assignment-poll-repository.service';
import { PromptService } from 'app/core/ui-services/prompt.service';
import { ChartType } from 'app/shared/components/charts/charts.component';
@ -39,7 +38,7 @@ export class AssignmentPollComponent extends BasePollComponent<ViewAssignmentPol
}
public get chartType(): ChartType {
return this.pollService.getChartType(this.poll);
return 'stackedBar';
}
public candidatesLabels: string[] = [];
@ -49,21 +48,6 @@ export class AssignmentPollComponent extends BasePollComponent<ViewAssignmentPol
*/
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
*/
@ -80,7 +64,6 @@ export class AssignmentPollComponent extends BasePollComponent<ViewAssignmentPol
repo: AssignmentPollRepositoryService,
pollDialog: AssignmentPollDialogService,
public pollService: PollService,
private operator: OperatorService,
private formBuilder: FormBuilder,
private pdfService: AssignmentPollPdfService
) {
@ -99,12 +82,4 @@ export class AssignmentPollComponent extends BasePollComponent<ViewAssignmentPol
public printBallot(): void {
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(
...this.sumTableKeys.map(key => ({
votingOption: key.vote,
class: 'sums',
value: [
{
amount: this[key.vote],
hide: key.hide,
showPercent: key.showPercent
} as VotingResult
]
}))
...this.sumTableKeys
.filter(key => {
return !key.hide;
})
.map(key => ({
votingOption: key.vote,
class: 'sums',
value: [
{
amount: this[key.vote],
hide: key.hide,
showPercent: key.showPercent
} as VotingResult
]
}))
);
return tableData;
}

View File

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

View File

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

View File

@ -133,7 +133,12 @@
{{ getTitleWithChanges() }}
</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>
</button>
</div>
@ -460,12 +465,16 @@
<!-- motion polls -->
<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>
<div class="mat-card create-poll-button" *ngIf="perms.isAllowed('createpoll', motion)">
<button mat-button (click)="openDialog()">
<mat-icon class="main-nav-color">poll</mat-icon>
<span translate>New vote</span>
</button>
</div>
<button
class="create-poll-button"
create-poll-button
mat-stroked-button
(click)="openDialog()"
*ngIf="perms.isAllowed('createpoll', motion)"
>
<mat-icon class="main-nav-color">add</mat-icon>
<span translate>New vote</span>
</button>
</div>
</div>
</ng-template>

View File

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

View File

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

View File

@ -67,9 +67,8 @@
</table>
<!-- Chart -->
<div class="doughnut-chart">
<div class="doughnut-chart" *ngIf="poll.hasPresentableValues && chartDataSubject.value">
<os-charts
*ngIf="chartDataSubject.value"
[type]="chartType"
[data]="chartDataSubject"
[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 { MAT_DIALOG_DATA, MatDialogRef, MatSnackBar } from '@angular/material';
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',
styleUrls: ['./motion-poll-dialog.component.scss']
})
export class MotionPollDialogComponent extends BasePollDialogComponent<ViewMotionPoll> {
export class MotionPollDialogComponent extends BasePollDialogComponent<ViewMotionPoll> implements OnInit {
public PercentBaseVerbose = PercentBaseVerbose;
@ViewChild('pollForm', { static: false })
@ -30,6 +30,9 @@ export class MotionPollDialogComponent extends BasePollDialogComponent<ViewMotio
@Inject(MAT_DIALOG_DATA) public pollData: Partial<ViewMotionPoll>
) {
super(title, translate, matSnackbar, dialogRef);
}
public ngOnInit(): void {
this.createDialog();
}
@ -54,12 +57,12 @@ export class MotionPollDialogComponent extends BasePollDialogComponent<ViewMotio
*/
private createDialog(): void {
this.dialogVoteForm = this.fb.group({
Y: ['', [Validators.min(-2)]],
N: ['', [Validators.min(-2)]],
A: ['', [Validators.min(-2)]],
votesvalid: ['', [Validators.min(-2)]],
votesinvalid: ['', [Validators.min(-2)]],
votescast: ['', [Validators.min(-2)]]
Y: [0, [Validators.min(-2)]],
N: [0, [Validators.min(-2)]],
A: [0, [Validators.min(-2)]],
votesvalid: [0, [Validators.min(-2)]],
votesinvalid: [0, [Validators.min(-2)]],
votescast: [0, [Validators.min(-2)]]
});
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 { 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 { 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 { ViewMotionVote } from 'app/site/motions/models/view-motion-vote';
import { BasePollVoteComponent } from 'app/site/polls/components/base-poll-vote.component';
@ -66,7 +68,8 @@ export class MotionPollVoteComponent extends BasePollVoteComponent<ViewMotionPol
vmanager: VotingService,
operator: OperatorService,
private voteRepo: MotionVoteRepositoryService,
private pollRepo: MotionPollRepositoryService
private pollRepo: MotionPollRepositoryService,
private promptService: PromptService
) {
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
*/
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 -->
<span class="poll-title">
<a [routerLink]="pollLink">
{{ poll.title }}
{{ poll.title | translate }}
</a>
</span>
<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 -->
<span *ngIf="pollService.isElectronicVotingEnabled && poll.type !== 'analog'">
{{ poll.typeVerbose | translate }} &middot;
@ -18,7 +26,7 @@
<!-- State chip -->
<span>
{{ poll.stateVerbose }}
{{ poll.stateVerbose | translate }}
</span>
</div>
</div>
@ -32,13 +40,8 @@
</div>
<!-- Change state button -->
<div *osPerms="'motions.can_manage_polls'">
<button
mat-stroked-button
*ngIf="!poll.isPublished"
[ngClass]="pollStateActions[poll.state].css"
(click)="changeState(poll.nextState)"
>
<div *osPerms="'motions.can_manage_polls'; and: !hideChangeState">
<button mat-stroked-button [ngClass]="pollStateActions[poll.state].css" (click)="changeState(poll.nextState)">
<mat-icon> {{ pollStateActions[poll.state].icon }}</mat-icon>
<span class="next-state-label">
{{ poll.nextStateActionVerbose | translate }}
@ -73,22 +76,12 @@
<!-- Result Chart and legend -->
<div class="poll-chart-wrapper" *osPerms="'motions.can_manage_polls'; or: poll.isPublished">
<div class="vote-legend" [routerLink]="pollLink">
<div class="votes-yes">
<os-icon-container icon="thumb_up" size="large">
{{ voteYes | parsePollNumber }}
{{ voteYes | pollPercentBase: poll }}
</os-icon-container>
</div>
<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 }}
<div *ngFor="let row of reducedPollTableData" [class]="row.votingOption">
<os-icon-container [icon]="row.value[0].icon" size="large">
{{ row.value[0].amount | parsePollNumber }}
<span *ngIf="row.value[0].showPercent">
{{ row.value[0].amount | pollPercentBase: poll }}
</span>
</os-icon-container>
</div>
</div>
@ -113,7 +106,7 @@
</ng-template>
<ng-template #emptyTemplate>
<div *osPerms="'motions.can_manage_polls'">
<div *osPerms="'motions.can_manage_polls'; and: poll.type === 'analog'">
{{ 'Edit to enter votes.' | translate }}
</div>
</ng-template>

View File

@ -47,15 +47,15 @@
margin-top: 20px;
}
.votes-yes {
.yes {
color: $votes-yes-color;
}
.votes-no {
.no {
color: $votes-no-color;
}
.votes-abstain {
.abstain {
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 { 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 { MotionPollDialogService } from 'app/site/motions/services/motion-poll-dialog.service';
import { MotionPollPdfService } from 'app/site/motions/services/motion-poll-pdf.service';
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';
/**
@ -28,19 +32,7 @@ export class MotionPollComponent extends BasePollComponent<ViewMotionPoll> {
@Input()
public set poll(value: ViewMotionPoll) {
this.initPoll(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);
}
@ -52,48 +44,17 @@ export class MotionPollComponent extends BasePollComponent<ViewMotionPoll> {
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 {
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;
private _voteAbstain: number;
public get reducedPollTableData(): PollTableData[] {
return this.poll.tableData.filter(data => ['yes', 'no', 'abstain', 'votesinvalid'].includes(data.votingOption));
}
/**
* Constructor.
@ -118,6 +79,10 @@ export class MotionPollComponent extends BasePollComponent<ViewMotionPoll> {
super(titleService, matSnackBar, translate, dialog, promptService, pollRepo, pollDialog);
}
public openVotingWarning(): void {
this.dialog.open(VotingPrivacyWarningComponent, infoDialogSettings);
}
public downloadPdf(): void {
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 translate Handle translations
* @param matSnackBar Showing error
* @param promtService Promts
* @param promptService Promts
* @param dialog Opening dialogs
* @param workflowRepo The repository for workflows
* @param route Read out URL paramters
@ -155,7 +155,7 @@ export class WorkflowDetailComponent extends BaseViewComponent implements OnInit
title: Title,
protected translate: TranslateService, // protected required for ng-translate-extract
matSnackBar: MatSnackBar,
private promtService: PromptService,
private promptService: PromptService,
private dialog: MatDialog,
private workflowRepo: WorkflowRepositoryService,
private stateRepo: StateRepositoryService,
@ -208,7 +208,7 @@ export class WorkflowDetailComponent extends BaseViewComponent implements OnInit
} else if (result.action === 'delete') {
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) {
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));
}
public getDefaultPollData(): MotionPoll {
public getDefaultPollData(contextId?: number): MotionPoll {
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;
if (contextId) {
const length = this.pollRepo.getViewModelList().filter(item => item.motion_id === contextId).length;
if (length) {
poll.title += ` (${length + 1})`;
}
}
return poll;
}

View File

@ -1,3 +1,4 @@
import { OnInit } from '@angular/core';
import { FormGroup } from '@angular/forms';
import { MatSnackBar } from '@angular/material';
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.
*/
export abstract class BasePollDialogComponent<T extends ViewBasePoll> extends BaseViewComponent {
export abstract class BasePollDialogComponent<T extends ViewBasePoll> extends BaseViewComponent implements OnInit {
public publishImmediately: boolean;
protected pollForm: PollFormComponent<T>;
@ -29,6 +30,21 @@ export abstract class BasePollDialogComponent<T extends ViewBasePoll> extends Ba
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.
*/

View File

@ -9,7 +9,7 @@
</form>
<!-- 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-label subtitle" translate>
{{ value[0] }}
@ -22,13 +22,14 @@
<form [formGroup]="contentForm" class="poll-preview-meta-info-form">
<ng-container *ngIf="!data || !data.state || data.isCreated">
<!-- 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-option *ngFor="let option of pollTypes | keyvalue" [value]="option.key">
{{ option.value | translate }}
</mat-option>
</mat-select>
<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>
<!-- Groups entitled to Vote -->

View File

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

View File

@ -1,6 +1,6 @@
import { Component, Input, OnInit } from '@angular/core';
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 { TranslateService } from '@ngx-translate/core';
@ -8,12 +8,13 @@ import { Observable } from 'rxjs';
import { GroupRepositoryService } from 'app/core/repositories/users/group-repository.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 { PercentBase } 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 { BaseViewComponent } from 'app/site/base/base-view';
import { ViewMotionPoll } from 'app/site/motions/models/view-motion-poll';
import {
MajorityMethodVerbose,
PollClassType,
@ -86,6 +87,8 @@ export class PollFormComponent<T extends ViewBasePoll> extends BaseViewComponent
public showSingleAmountHint = false;
public showNonNominalWarning = false;
/**
* Constructor. Retrieves necessary metadata from the pollService,
* injects the poll itself
@ -97,7 +100,8 @@ export class PollFormComponent<T extends ViewBasePoll> extends BaseViewComponent
private fb: FormBuilder,
private groupRepo: GroupRepositoryService,
public pollService: PollService,
private configService: ConfigService
private configService: ConfigService,
private dialog: MatDialog
) {
super(title, translate, snackbar);
this.initContentForm();
@ -119,7 +123,6 @@ export class PollFormComponent<T extends ViewBasePoll> extends BaseViewComponent
if (!this.data.pollmethod) {
this.data.pollmethod = this.configService.instant('assignment_poll_method');
}
} else if (this.data instanceof ViewMotionPoll) {
}
Object.keys(this.contentForm.controls).forEach(key => {
@ -209,6 +212,16 @@ export class PollFormComponent<T extends ViewBasePoll> extends BaseViewComponent
private setVotesAmountCtrl(): void {
// Disable "Amounts of votes" if anonymous and yes-method
const votesAmountCtrl = this.contentForm.get('votes_amount');
if (this.contentForm.get('type').value === PollType.Pseudoanonymous) {
this.showNonNominalWarning = true;
} else {
this.showNonNominalWarning = false;
}
/**
* TODO: Not required when batch sending works again
*/
if (
this.contentForm.get('type').value === PollType.Pseudoanonymous &&
this.contentForm.get('pollmethod').value === 'votes'
@ -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
*/

View File

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

View File

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

View File

@ -4,6 +4,10 @@ import { ViewBasePoll } from './view-base-poll';
import { ViewBaseVote } from './view-base-vote';
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 {
return this._model;
}

View File

@ -109,17 +109,19 @@ export abstract class ViewBasePoll<
protected sumTableKeys: VotingResult[] = [
{
vote: 'votesvalid',
hide: this.poll.votesvalid === -2,
showPercent: this.poll.isPercentBaseValidOrCast
},
{
vote: 'votesinvalid',
hide: this.poll.type !== PollType.Analog,
showPercent: this.poll.isPercentBaseValidOrCast
icon: 'not_interested',
hide: this.poll.type !== PollType.Analog || this.poll.votesinvalid === -2,
showPercent: this.poll.isPercentBaseCast
},
{
vote: 'votescast',
hide: this.poll.type !== PollType.Analog,
showPercent: this.poll.isPercentBaseValidOrCast
hide: this.poll.type !== PollType.Analog || this.poll.votescast === -2,
showPercent: this.poll.isPercentBaseCast
}
];
@ -163,7 +165,11 @@ export abstract class ViewBasePoll<
public abstract get percentBaseVerbose(): string;
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';

View File

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

View File

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

View File

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

View File

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