Cleanup Voting, enhance UI and UX

removed certain unnecesary fields
cleaned up a lot of code
redone some of the UI
some database and server adjustments
This commit is contained in:
Sean Engelhardt 2020-02-24 16:55:07 +01:00 committed by FinnStutzenstein
parent 7598fc5367
commit 97a5bb4aa6
84 changed files with 893 additions and 1255 deletions

View File

@ -80,7 +80,6 @@ export class AssignmentRepositoryService extends BaseIsAgendaItemAndListOfSpeake
private readonly restPath = '/rest/assignments/assignment/';
private readonly candidatureOtherPath = '/candidature_other/';
private readonly candidatureSelfPath = '/candidature_self/';
private readonly markElectedPath = '/mark_elected/';
/**
* Constructor for the Assignment Repository.
@ -158,26 +157,6 @@ export class AssignmentRepositoryService extends BaseIsAgendaItemAndListOfSpeake
await this.httpService.delete(this.restPath + assignment.id + this.candidatureSelfPath);
}
/**
* change the 'elected' state of an election candidate
*
* @param assignmentRelatedUser
* @param assignment
* @param elected true if the candidate is to be elected, false if unelected
*/
public async markElected(
assignmentRelatedUser: ViewAssignmentRelatedUser,
assignment: ViewAssignment,
elected: boolean
): Promise<void> {
const data = { user: assignmentRelatedUser.user_id };
if (elected) {
await this.httpService.post(this.restPath + assignment.id + this.markElectedPath, data);
} else {
await this.httpService.delete(this.restPath + assignment.id + this.markElectedPath, data);
}
}
/**
* Sends a request to sort an assignment's candidates
*

View File

@ -125,6 +125,18 @@ export class UserRepositoryService extends BaseRepository<ViewUser, User, UserTi
return name.trim();
}
public getLevelAndNumber(titleInformation: UserTitleInformation): string {
if (titleInformation.structure_level && titleInformation.number) {
return `${titleInformation.structure_level} · ${this.translate.instant('No.')} ${titleInformation.number}`;
} else if (titleInformation.structure_level) {
return titleInformation.structure_level;
} else if (titleInformation.number) {
return `${this.translate.instant('No.')} ${titleInformation.number}`;
} else {
return '';
}
}
public getVerboseName = (plural: boolean = false) => {
return this.translate.instant(plural ? 'Participants' : 'Participant');
};
@ -145,12 +157,13 @@ export class UserRepositoryService extends BaseRepository<ViewUser, User, UserTi
}
/**
* Adds teh short and full name to the view user.
* Adds the short and full name to the view user.
*/
protected createViewModelWithTitles(model: User): ViewUser {
const viewModel = super.createViewModelWithTitles(model);
viewModel.getFullName = () => this.getFullName(viewModel);
viewModel.getShortName = () => this.getShortName(viewModel);
viewModel.getLevelAndNumber = () => this.getLevelAndNumber(viewModel);
return viewModel;
}

View File

@ -8,7 +8,6 @@ import { PollState, PollType } from 'app/shared/models/poll/base-poll';
import { mediumDialogSettings } from 'app/shared/utils/dialog-settings';
import { BasePollDialogComponent } from 'app/site/polls/components/base-poll-dialog.component';
import { ViewBasePoll } from 'app/site/polls/models/view-base-poll';
import { PollService } from '../../site/polls/services/poll.service';
/**
* Abstract class for showing a poll dialog. Has to be subclassed to provide the right `PollService`
@ -17,42 +16,35 @@ import { PollService } from '../../site/polls/services/poll.service';
providedIn: 'root'
})
export abstract class BasePollDialogService<V extends ViewBasePoll> {
protected dialogComponent: ComponentType<BasePollDialogComponent>;
protected dialogComponent: ComponentType<BasePollDialogComponent<V>>;
public constructor(
private dialog: MatDialog,
private mapper: CollectionStringMapperService,
private service: PollService
) {}
public constructor(private dialog: MatDialog, private mapper: CollectionStringMapperService) {}
/**
* Opens the dialog to enter votes and edit the meta-info for a poll.
*
* @param data Passing the (existing or new) data for the poll
*/
public async openDialog(poll: Partial<V> & Collection): Promise<void> {
if (!poll.poll) {
this.service.fillDefaultPollData(poll);
}
public async openDialog(viewPoll: Partial<V> & Collection): Promise<void> {
const dialogRef = this.dialog.open(this.dialogComponent, {
data: poll,
data: viewPoll,
...mediumDialogSettings
});
const result = await dialogRef.afterClosed().toPromise();
if (result) {
const repo = this.mapper.getRepository(poll.collectionString);
if (!poll.poll) {
const repo = this.mapper.getRepository(viewPoll.collectionString);
if (!viewPoll.poll) {
await repo.create(result);
} else {
let update = result;
if (poll.state !== PollState.Created) {
if (viewPoll.state !== PollState.Created) {
update = {
title: result.title,
onehundred_percent_base: result.onehundred_percent_base,
majority_method: result.majority_method,
description: result.description
};
if (poll.type === PollType.Analog) {
if (viewPoll.type === PollType.Analog) {
update = {
...update,
votes: result.votes,
@ -60,7 +52,7 @@ export abstract class BasePollDialogService<V extends ViewBasePoll> {
};
}
}
await repo.patch(update, <V>poll);
await repo.patch(update, <V>viewPoll);
}
}
}

View File

@ -1,14 +0,0 @@
<div>
<mat-button-toggle-group>
<mat-button-toggle
*ngFor="let breadcrumb of breadcrumbList"
[disabled]="breadcrumb.action === null"
(click)="breadcrumb.action ? breadcrumb.action() : null"
[ngClass]="{ 'active-breadcrumb': breadcrumb.active }"
>
<span>
{{ breadcrumb.label }}
</span>
</mat-button-toggle>
</mat-button-toggle-group>
</div>

View File

@ -1,4 +0,0 @@
.active-breadcrumb {
// Theme
color: rgba($color: #317796, $alpha: 1);
}

View File

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

View File

@ -1,69 +0,0 @@
import { Component, Input, OnInit } from '@angular/core';
/**
* Describes, how one breadcrumb can look like.
*/
export interface Breadcrumb {
label: string;
action: () => any;
active?: boolean;
}
@Component({
selector: 'os-breadcrumb',
templateUrl: './breadcrumb.component.html',
styleUrls: ['./breadcrumb.component.scss']
})
export class BreadcrumbComponent implements OnInit {
/**
* A list of all breadcrumbs, that should be rendered.
*
* @param labels A list of strings or the interface `Breadcrumb`.
*/
@Input()
public set breadcrumbs(labels: string[] | Breadcrumb[]) {
this.breadcrumbList = [];
// https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-3.html#caveats
for (const breadcrumb of labels) {
if (typeof breadcrumb === 'string') {
this.breadcrumbList.push({ label: breadcrumb, action: null });
} else {
this.breadcrumbList.push(breadcrumb);
}
}
}
/**
* The current active index, if not the last one.
*
* @param index The index as number.
*/
@Input()
public set activeIndex(index: number) {
for (const breadcrumb of this.breadcrumbList) {
breadcrumb.active = false;
}
this.breadcrumbList[index].active = true;
}
/**
* The list of the breadcrumbs built by the input.
*/
public breadcrumbList: Breadcrumb[] = [];
/**
* Default constructor.
*/
public constructor() {}
/**
* OnInit.
* Sets the last breadcrumb as the active breadcrumb if not defined before.
*/
public ngOnInit(): void {
if (this.breadcrumbList.length && !this.breadcrumbList.some(breadcrumb => breadcrumb.active)) {
this.breadcrumbList[this.breadcrumbList.length - 1].active = true;
}
}
}

View File

@ -5,16 +5,22 @@
'message action'
'bar action';
grid-template-columns: auto min-content;
}
.message {
grid-area: message;
}
.mat-progress-bar-buffer {
// TODO theme
// background-color: mat-color($background, card) !important;
background-color: white !important;
}
.bar {
grid-area: bar;
}
.message {
grid-area: message;
}
.action {
grid-area: action;
.bar {
grid-area: bar;
}
.action {
grid-area: action;
}
}

View File

@ -14,6 +14,8 @@ export enum AssignmentPollMethods {
*/
export class AssignmentPoll extends BasePoll<AssignmentPoll, AssignmentOption> {
public static COLLECTIONSTRING = 'assignments/assignment-poll';
public static defaultGroupsConfig = 'assignment_poll_default_groups';
public static defaultPollMethodConfig = 'assignment_poll_method';
public id: number;
public assignment_id: number;

View File

@ -8,7 +8,6 @@ export class AssignmentRelatedUser extends BaseModel<AssignmentRelatedUser> {
public id: number;
public user_id: number;
public elected: boolean;
public assignment_id: number;
public weight: number;

View File

@ -12,6 +12,7 @@ export enum MotionPollMethods {
*/
export class MotionPoll extends BasePoll<MotionPoll, MotionOption> {
public static COLLECTIONSTRING = 'motions/motion-poll';
public static defaultGroupsConfig = 'motion_poll_default_groups';
public id: number;
public motion_id: number;

View File

@ -2,7 +2,7 @@ import { BaseDecimalModel } from '../base/base-decimal-model';
import { BaseOption } from './base-option';
export enum PollColor {
yes = '#9fd773',
yes = '#4caf50',
no = '#cc6c5b',
abstain = '#a6a6a6',
votesvalid = '#e2e2e2',
@ -76,6 +76,10 @@ export abstract class BasePoll<T = any, O extends BaseOption<any> = any> extends
return this.isFinished || this.isPublished;
}
public get nextState(): PollState {
return this.state + 1;
}
protected getDecimalFields(): (keyof BasePoll<T, O>)[] {
return ['votesvalid', 'votesinvalid', 'votescast'];
}

View File

@ -19,6 +19,6 @@ const PollValues = {
})
export class PollKeyVerbosePipe implements PipeTransform {
public transform(value: string): string {
return PollValues[value];
return PollValues[value] || value;
}
}

View File

@ -6,7 +6,7 @@ import { AssignmentPollService } from 'app/site/assignments/services/assignment-
import { MotionPollService } from 'app/site/motions/services/motion-poll.service';
import { PollPercentBasePipe } from './poll-percent-base.pipe';
fdescribe('PollPercentBasePipe', () => {
describe('PollPercentBasePipe', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule]

View File

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

View File

@ -111,7 +111,6 @@ import { GlobalSpinnerComponent } from 'app/site/common/components/global-spinne
import { HeightResizingDirective } from './directives/height-resizing.directive';
import { TrustPipe } from './pipes/trust.pipe';
import { LocalizedDatePipe } from './pipes/localized-date.pipe';
import { BreadcrumbComponent } from './components/breadcrumb/breadcrumb.component';
import { ChartsComponent } from './components/charts/charts.component';
import { CheckInputComponent } from './components/check-input/check-input.component';
import { BannerComponent } from './components/banner/banner.component';
@ -277,7 +276,6 @@ import { PollPercentBasePipe } from './pipes/poll-percent-base.pipe';
ChartsModule,
TrustPipe,
LocalizedDatePipe,
BreadcrumbComponent,
ChartsComponent,
CheckInputComponent,
BannerComponent,
@ -335,7 +333,6 @@ import { PollPercentBasePipe } from './pipes/poll-percent-base.pipe';
HeightResizingDirective,
TrustPipe,
LocalizedDatePipe,
BreadcrumbComponent,
ChartsComponent,
CheckInputComponent,
BannerComponent,

View File

@ -67,7 +67,7 @@
<!-- candidates list -->
<ng-container [ngTemplateOutlet]="candidatesTemplate"></ng-container>
<!-- closed polls -->
<ng-container *ngIf="assignment">
<ng-container *ngIf="assignment && assignment.polls.length">
<ng-container *ngFor="let poll of assignment.polls | reverse; trackBy: trackByIndex">
<os-assignment-poll [poll]="poll"> </os-assignment-poll>
</ng-container>
@ -162,7 +162,12 @@
<!-- Search for candidates -->
<div class="search-field" *ngIf="hasPerms('addOthers')">
<form
*ngIf="hasPerms('addOthers') && filteredCandidates && filteredCandidates.value.length > 0"
*ngIf="
hasPerms('addOthers') &&
filteredCandidates &&
filteredCandidates.value &&
filteredCandidates.value.length
"
[formGroup]="candidatesForm"
>
<mat-form-field>
@ -285,7 +290,6 @@
<span translate>Number poll candidates</span>
</mat-checkbox>
</div>
<!-- TODO searchValueSelector: Parent -->
</form>
</mat-card>
</ng-template>

View File

@ -1,9 +1,11 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { E2EImportsModule } from 'e2e-imports.module';
import { PollProgressComponent } from 'app/site/polls/components/poll-progress/poll-progress.component';
import { AssignmentDetailComponent } from './assignment-detail.component';
import { AssignmentPollVoteComponent } from '../assignment-poll-vote/assignment-poll-vote.component';
import { AssignmentPollComponent } from '../assignment-poll/assignment-poll.component';
import { E2EImportsModule } from '../../../../../e2e-imports.module';
describe('AssignmentDetailComponent', () => {
let component: AssignmentDetailComponent;
@ -12,7 +14,12 @@ describe('AssignmentDetailComponent', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule],
declarations: [AssignmentDetailComponent, AssignmentPollComponent, AssignmentPollVoteComponent]
declarations: [
AssignmentDetailComponent,
AssignmentPollComponent,
AssignmentPollVoteComponent,
PollProgressComponent
]
}).compileComponents();
}));

View File

@ -23,6 +23,7 @@ import { ViewTag } from 'app/site/tags/models/view-tag';
import { ViewUser } from 'app/site/users/models/view-user';
import { AssignmentPdfExportService } from '../../services/assignment-pdf-export.service';
import { AssignmentPollDialogService } from '../../services/assignment-poll-dialog.service';
import { AssignmentPollService } from '../../services/assignment-poll.service';
import { AssignmentPhases, ViewAssignment } from '../../models/view-assignment';
import { ViewAssignmentPoll } from '../../models/view-assignment-poll';
import { ViewAssignmentRelatedUser } from '../../models/view-assignment-related-user';
@ -176,7 +177,8 @@ export class AssignmentDetailComponent extends BaseViewComponent implements OnIn
private promptService: PromptService,
private pdfService: AssignmentPdfExportService,
private mediafileRepo: MediafileRepositoryService,
private pollDialog: AssignmentPollDialogService
private pollDialog: AssignmentPollDialogService,
private assignmentPollService: AssignmentPollService
) {
super(title, translate, matSnackBar);
this.subscriptions.push(
@ -306,11 +308,15 @@ export class AssignmentDetailComponent extends BaseViewComponent implements OnIn
* Creates a new Poll
*/
public openDialog(): void {
this.pollDialog.openDialog({
// TODO: That is not really a ViewObject
const dialogData = {
collectionString: ViewAssignmentPoll.COLLECTIONSTRING,
assignment_id: this.assignment.id,
assignment: this.assignment
});
assignment: this.assignment,
...this.assignmentPollService.getDefaultPollData()
};
this.pollDialog.openDialog(dialogData);
}
/**

View File

@ -0,0 +1,12 @@
@import '~@angular/material/theming';
@mixin os-assignment-poll-detail-style($theme) {
$background: map-get($theme, background);
.assignment-result-table {
border-collapse: collapse;
tr {
border-bottom: 1px solid mat-color($background, focused-button);
}
}
}

View File

@ -21,46 +21,51 @@
<span *ngIf="poll.type !== 'analog'">{{ poll.typeVerbose | translate }}</span>
<div *ngIf="poll.stateHasVotes">
<h2 translate>Result</h2>
<div class="result-wrapper">
<div [class]="chartType === 'horizontalBar' ? 'result-wrapper-bar-chart' : 'result-wrapper-pie-chart'">
<!-- Result Table -->
<mat-table class="result-table" [dataSource]="poll.tableData">
<ng-container matColumnDef="user" sticky>
<mat-header-cell *matHeaderCellDef>{{ 'Candidates' | translate }}</mat-header-cell>
<mat-cell *matCellDef="let row">{{ row.user }}</mat-cell>
</ng-container>
<div *ngIf="!isVotedPoll">
<ng-container matColumnDef="yes">
<mat-header-cell *matHeaderCellDef>{{ 'Yes' | translate }}</mat-header-cell>
<mat-cell *matCellDef="let row">{{ row.yes }}</mat-cell>
</ng-container>
<table class="assignment-result-table">
<tbody>
<tr>
<th translate>Candidates</th>
<th translate>Votes</th>
</tr>
<tr *ngFor="let row of poll.tableData">
<td>
<span>
{{ row.votingOption | pollKeyVerbose | translate }}
</span>
<span class="user-subtitle" *ngIf="row.votingOptionSubtitle">
<br />
{{ row.votingOptionSubtitle }}
</span>
</td>
<td>
<div *ngFor="let vote of row.value">
<div class="single-result" *ngIf="voteFitsMethod(vote)">
<os-icon-container *ngIf="vote.icon" [icon]="vote.icon">
{{ vote.vote | pollKeyVerbose | translate }}
</os-icon-container>
<span *ngIf="!vote.icon">
{{ vote.vote | pollKeyVerbose | translate }}
</span>
<ng-container matColumnDef="no">
<mat-header-cell *matHeaderCellDef>{{ 'No' | translate }}</mat-header-cell>
<mat-cell *matCellDef="let row">{{ row.no }}</mat-cell>
</ng-container>
<ng-container matColumnDef="abstain">
<mat-header-cell *matHeaderCellDef>{{ 'Abstain' | translate }}</mat-header-cell>
<mat-cell *matCellDef="let row">{{ row.abstain }}</mat-cell>
</ng-container>
</div>
<div *ngIf="isVotedPoll">
<ng-container matColumnDef="votes">
<mat-header-cell *matHeaderCellDef>{{ 'Votes' | translate }}</mat-header-cell>
<mat-cell *matCellDef="let row">{{ row.yes }}</mat-cell>
</ng-container>
</div>
<mat-header-row *matHeaderRowDef="columnDefinitionOverview"></mat-header-row>
<mat-row *matRowDef="let row; columns: columnDefinitionOverview"></mat-row>
</mat-table>
<span>
{{ vote.amount | parsePollNumber }}
<span *ngIf="vote.showPercent">
{{ vote.amount | pollPercentBase: poll }}
</span>
</span>
</div>
</div>
</td>
</tr>
</tbody>
</table>
<!-- Result Chart -->
<os-charts
class="result-chart"
class="assignment-result-chart"
[ngClass]="chartType === 'doughnut' ? 'pie-chart' : ''"
*ngIf="chartDataSubject.value"
[type]="chartType"
[labels]="candidatesLabels"
@ -105,7 +110,8 @@
[ngClass]="voteOptionStyle[vote.votes[option.user_id].value].css"
class="vote-field"
>
<mat-icon> {{ voteOptionStyle[vote.votes[option.user_id].value].icon }}</mat-icon> {{ vote.votes[option.user_id].valueVerbose | translate }}
<mat-icon> {{ voteOptionStyle[vote.votes[option.user_id].value].icon }}</mat-icon>
{{ vote.votes[option.user_id].valueVerbose | translate }}
</div>
</ng-container>
</ng-container>
@ -123,17 +129,18 @@
</div>
<!-- Meta Infos -->
<div class="poll-content small">
<div *ngIf="poll.groups && poll.type && poll.type !== 'analog'">
<div 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>
</div>
</small>
<div>{{ 'Required majority' | translate }}: {{ poll.majorityMethodVerbose | translate }}</div>
<div>{{ '100% base' | translate }}: {{ poll.percentBaseVerbose | translate }}</div>
<small *ngIf="poll.onehundred_percent_base">
{{ '100% base' | translate }}: {{ poll.percentBaseVerbose | translate }}
</small>
</div>
</ng-container>
</ng-template>
@ -141,7 +148,7 @@
<!-- More Menu -->
<mat-menu #pollDetailMenu="matMenu">
<os-projector-button [menuItem]="true" [object]="poll" *osPerms="'core.can_manage_projector'"></os-projector-button>
<button *osPerms="'assignments.can_manage_polls'" mat-menu-item (click)="openDialog()">
<button *osPerms="'assignments.can_manage_polls'" mat-menu-item (click)="openDialog(poll)">
<mat-icon>edit</mat-icon>
<span translate>Edit</span>
</button>

View File

@ -1,9 +1,22 @@
@import '~assets/styles/variables.scss';
@import '~assets/styles/poll-colors.scss';
.result-wrapper {
%assignment-result-wrapper {
margin-top: 2em;
display: grid;
grid-gap: 10px;
}
.result-wrapper-bar-chart {
@extend %assignment-result-wrapper;
grid-template-areas:
'results'
'chart'
'names';
}
.result-wrapper-pie-chart {
@extend %assignment-result-wrapper;
grid-template-areas:
'chart'
'results'
@ -11,7 +24,7 @@
}
@include desktop {
.result-wrapper {
.result-wrapper-pie-chart {
grid-template-areas:
'results chart'
'names names';
@ -19,13 +32,34 @@
}
}
.result-table {
.assignment-result-table {
grid-area: results;
th {
text-align: left;
font-weight: initial;
}
tr {
height: 48px;
}
tr:last-child {
border-bottom: none;
}
.single-result {
display: flex;
}
}
.result-chart {
.assignment-result-chart {
grid-area: chart;
}
.pie-chart {
max-width: 300px;
margin-left: auto;
margin-right: auto;
}
.named-result-table {
@ -36,27 +70,14 @@
}
}
.poll-content {
.assignment-poll-meta {
display: grid;
text-align: right;
padding-top: 20px;
}
.chart-wrapper {
&.flex {
display: flex;
.mat-table {
flex: 2;
.mat-column-votes {
justify-content: center;
}
}
.chart-inner-wrapper {
flex: 3;
}
}
}
.single-votes-table {
display: block;
height: 500px;
}

View File

@ -14,6 +14,7 @@ import { ViewportService } from 'app/core/ui-services/viewport.service';
import { ChartType } from 'app/shared/components/charts/charts.component';
import { AssignmentPollMethods } from 'app/shared/models/assignments/assignment-poll';
import { BasePollDetailComponent } from 'app/site/polls/components/base-poll-detail.component';
import { VotingResult } from 'app/site/polls/models/view-base-poll';
import { PollService } from 'app/site/polls/services/poll.service';
import { AssignmentPollDialogService } from '../../services/assignment-poll-dialog.service';
import { ViewAssignmentPoll } from '../../models/view-assignment-poll';
@ -43,13 +44,6 @@ export class AssignmentPollDetailComponent extends BasePollDetailComponent<ViewA
return this.poll.pollmethod === AssignmentPollMethods.Votes;
}
public get columnDefinitionOverview(): string[] {
const columns = this.isVotedPoll ? ['user', 'votes'] : ['user', 'yes', 'no'];
if (this.poll.pollmethod === AssignmentPollMethods.YNA) {
columns.splice(3, 0, 'abstain');
}
return columns;
}
private _chartType: ChartType = 'horizontalBar';
public constructor(
@ -130,9 +124,7 @@ export class AssignmentPollDetailComponent extends BasePollDetailComponent<ViewA
}
this.setVotesData(Object.values(votes));
this.candidatesLabels = this.pollService.getChartLabels(this.poll);
this.isReady = true;
}
@ -157,4 +149,17 @@ export class AssignmentPollDetailComponent extends BasePollDetailComponent<ViewA
protected hasPerms(): boolean {
return this.operator.hasPerms('assignments.can_manage');
}
public voteFitsMethod(result: VotingResult): boolean {
if (this.poll.pollmethod === AssignmentPollMethods.Votes) {
if (result.vote === 'abstain' || result.vote === 'no') {
return false;
}
} else if (this.poll.pollmethod === AssignmentPollMethods.YN) {
if (result.vote === 'abstain') {
return false;
}
}
return true;
}
}

View File

@ -1,7 +1,9 @@
<os-poll-form [data]="pollData" [pollMethods]="assignmentPollMethods" #pollForm></os-poll-form>
<!-- Analog voting -->
<ng-container *ngIf="pollForm.contentForm.get('type').value === 'analog'">
<!-- Candidate values -->
<form [formGroup]="dialogVoteForm">
<!-- Candidates -->
<div formGroupName="options">
<div *ngFor="let option of options" class="votes-grid">
<div>
@ -33,36 +35,30 @@
></os-check-input>
</div>
</form>
<mat-divider></mat-divider>
<!-- Publish Check -->
<div class="spacer-top-20">
<mat-checkbox
[(ngModel)]="publishImmediately"
(change)="publishStateChanged($event.checked)"
>
<mat-checkbox [(ngModel)]="publishImmediately" (change)="publishStateChanged($event.checked)">
<span translate>Publish immediately</span>
</mat-checkbox>
<mat-error *ngIf="!dialogVoteForm.valid" translate>
If you want to publish after creating, you have to fill at least one of the fields.
</mat-error>
</div>
<!-- Summary values -->
<!-- <div *ngFor="let sumValue of sumValues" class="sum-value">
<mat-form-field>
<input
type="number"
matInput
[value]="getSumValue(sumValue)"
(change)="setSumValue(sumValue, $event.target.value)"
/>
<mat-label>{{ pollService.getLabel(sumValue) | translate }}</mat-label>
</mat-form-field>
</div> -->
</ng-container>
<mat-divider></mat-divider>
<!-- Actions -->
<div mat-dialog-actions>
<button mat-button (click)="submitPoll()" [disabled]="!pollForm.contentForm || pollForm.contentForm.invalid || dialogVoteForm.invalid">
<!-- Save Button -->
<button
mat-button
(click)="submitPoll()"
[disabled]="!pollForm.contentForm || pollForm.contentForm.invalid || dialogVoteForm.invalid"
>
<span translate>Save</span>
</button>
<!-- Cancel Button -->
<button mat-button [mat-dialog-close]="false">
<span translate>Cancel</span>
</button>

View File

@ -11,9 +11,7 @@ import { GeneralValueVerbose, VoteValue, VoteValueVerbose } from 'app/shared/mod
import { AssignmentPollMethodsVerbose } from 'app/site/assignments/models/view-assignment-poll';
import { BasePollDialogComponent } from 'app/site/polls/components/base-poll-dialog.component';
import { PollFormComponent } from 'app/site/polls/components/poll-form/poll-form.component';
import { CalculablePollKey, PollVoteValue } from 'app/site/polls/services/poll.service';
import { ViewUser } from 'app/site/users/models/view-user';
import { ViewAssignmentOption } from '../../models/view-assignment-option';
import { ViewAssignmentPoll } from '../../models/view-assignment-poll';
type OptionsObject = { user_id: number; user: ViewUser }[];
@ -26,7 +24,7 @@ type OptionsObject = { user_id: number; user: ViewUser }[];
templateUrl: './assignment-poll-dialog.component.html',
styleUrls: ['./assignment-poll-dialog.component.scss']
})
export class AssignmentPollDialogComponent extends BasePollDialogComponent implements OnInit {
export class AssignmentPollDialogComponent extends BasePollDialogComponent<ViewAssignmentPoll> implements OnInit {
/**
* The summary values that will have fields in the dialog
*/
@ -41,7 +39,7 @@ export class AssignmentPollDialogComponent extends BasePollDialogComponent imple
public specialValues: [number, string][];
@ViewChild('pollForm', { static: true })
protected pollForm: PollFormComponent;
protected pollForm: PollFormComponent<ViewAssignmentPoll>;
/**
* vote entries for each option in this component. Is empty if method
@ -65,13 +63,14 @@ export class AssignmentPollDialogComponent extends BasePollDialogComponent imple
title: Title,
protected translate: TranslateService,
matSnackbar: MatSnackBar,
public dialogRef: MatDialogRef<BasePollDialogComponent>,
public dialogRef: MatDialogRef<BasePollDialogComponent<ViewAssignmentPoll>>,
@Inject(MAT_DIALOG_DATA) public pollData: Partial<ViewAssignmentPoll>
) {
super(title, translate, matSnackbar, dialogRef);
}
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
@ -154,85 +153,6 @@ export class AssignmentPollDialogComponent extends BasePollDialogComponent imple
}
}
/**
* Validates candidates input (every candidate has their options filled in),
* submits and closes the dialog if successful, else displays an error popup.
* TODO better validation
*/
public submit(): void {
/*const error = this.data.options.find(dataoption => {
this.optionPollKeys.some(key => {
const keyValue = dataoption.votes.find(o => o.value === key);
return !keyValue || keyValue.weight === undefined;
});
});
if (error) {
this.matSnackBar.open(
this.translate.instant('Please fill in the values for each candidate'),
this.translate.instant('OK'),
{
duration: 1000
}
);
} else {
this.dialogRef.close(this.data);
}*/
}
/**
* TODO: currently unused
*
* @param key poll option to be labeled
* @returns a label for a poll option
*/
public getLabel(key: CalculablePollKey): string {
// return this.pollService.getLabel(key);
throw new Error('TODO');
}
/**
* Updates a vote value
*
* @param value the value to update
* @param candidate the candidate for whom to update the value
* @param newData the new value
*/
public setValue(value: PollVoteValue, candidate: ViewAssignmentOption, newData: string): void {
/*const vote = candidate.votes.find(v => v.value === value);
if (vote) {
vote.weight = parseFloat(newData);
} else {
candidate.votes.push({
value: value,
weight: parseFloat(newData)
});
}*/
}
/**
* Retrieves the current value for a voting option
*
* @param value the vote value (e.g. 'Abstain')
* @param candidate the pollOption
* @returns the currently entered number or undefined if no number has been set
*/
public getValue(value: PollVoteValue, candidate: ViewAssignmentOption): number | undefined {
/*const val = candidate.votes.find(v => v.value === value);
return val ? val.weight : undefined;*/
throw new Error('TODO');
}
/**
* Retrieves a per-poll value
*
* @param value
* @returns integer or undefined
*/
public getSumValue(value: any /*SummaryPollKey*/): number | undefined {
// return this.data[value] || undefined;
throw new Error('TODO');
}
/**
* Sets a per-poll value
*

View File

@ -1,8 +1,4 @@
<ng-container *ngIf="poll">
<!-- Poll progress bar -->
<div *osPerms="'assignments.can_manage_polls'; and: poll.isStarted">
<os-poll-progress [poll]="poll"></os-poll-progress>
</div>
<ng-container *ngIf="vmanager.canVote(poll)">
<!-- TODO: Someone should make this pretty -->

View File

@ -50,7 +50,10 @@ export class AssignmentPollVoteComponent extends BasePollVoteComponent<ViewAssig
}
public ngOnInit(): void {
this.defineVoteOptions();
if (this.poll) {
this.defineVoteOptions();
}
this.subscriptions.push(
this.voteRepo.getViewModelListObservable().subscribe(votes => {
this.votes = votes;
@ -91,8 +94,6 @@ export class AssignmentPollVoteComponent extends BasePollVoteComponent<ViewAssig
}
protected updateVotes(): void {
console.log('currentVotes: ', this.currentVotes);
if (this.user && this.votes && this.poll) {
const filtered = this.votes.filter(
vote => vote.option.poll_id === this.poll.id && vote.user_id === this.user.id

View File

@ -1,64 +1,75 @@
<mat-card class="os-card" *ngIf="poll && showPoll()">
<div class="assignment-poll-wrapper">
<div class="assignment-poll-title-header">
<div>
<!-- Title -->
<mat-card-title>
<a routerLink="/assignments/polls/{{ poll.id }}">
{{ poll.title }}
</a>
</mat-card-title>
<div class="poll-properties">
<mat-chip
*osPerms="'assignments.can_manage_polls'"
class="poll-state active"
[disableRipple]="true"
[matMenuTriggerFor]="triggerMenu"
[class]="poll.stateVerbose.toLowerCase()"
[ngClass]="{ disabled: !poll.getNextStates() }"
>
<!-- Type and State -->
<div>
<span *ngIf="poll.type !== 'analog'"> {{ poll.typeVerbose | translate }} &middot; </span>
<span>
{{ poll.stateVerbose | translate }}
</mat-chip>
<span *ngIf="poll.type !== 'analog'">
{{ poll.typeVerbose | translate }}
</span>
</div>
<!-- Menu -->
<div class="poll-menu">
<!-- Buttons -->
<button
mat-icon-button
*osPerms="'assignments.motions.can_manage_polls';or: 'core.can_manage_projector'"
*osPerms="'assignments.motions.can_manage_polls'; or: 'core.can_manage_projector'"
[matMenuTriggerFor]="pollItemMenu"
(click)="$event.stopPropagation()"
>
<mat-icon>more_horiz</mat-icon>
</button>
</div>
<!-- Change state button -->
<div *osPerms="'assignments.can_manage_polls'">
<button
mat-stroked-button
*ngIf="!poll.isPublished"
[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 }}
</span>
</button>
</div>
</div>
<div *ngIf="hasVotes">
<os-charts
[class]="chartType === 'doughnut' ? 'doughnut-chart' : 'bar-chart'"
[type]="chartType"
[labels]="candidatesLabels"
[data]="chartDataSubject"
[hasPadding]="false"
></os-charts>
</div>
<!-- Poll progress bar -->
<div *osPerms="'assignments.can_manage_polls'; and: poll && poll.isStarted">
<os-poll-progress [poll]="poll"></os-poll-progress>
</div>
<os-assignment-poll-vote *ngIf="poll.canBeVotedFor" [poll]="poll"></os-assignment-poll-vote>
<div class="poll-detail-button-wrapper">
<a mat-button routerLink="/assignments/polls/{{ poll.id }}">
{{ 'More' | translate }}
<a mat-icon-button routerLink="/assignments/polls/{{ poll.id }}" matTooltip="{{ 'More' | translate }}">
<mat-icon class="small-icon">
visibility
</mat-icon>
</a>
</div>
</div>
</mat-card>
<mat-menu #triggerMenu="matMenu">
<ng-container *ngIf="poll">
<button mat-menu-item (click)="changeState(state.value)" *ngFor="let state of poll.getNextStates() | keyvalue">
<span translate>{{ state.key }}</span>
</button>
</ng-container>
</mat-menu>
<mat-menu #pollItemMenu="matMenu">
<div *osPerms="'assignments.can_manage'">
<button mat-menu-item (click)="openDialog()">
@ -75,6 +86,11 @@
<span translate>Ballot paper</span>
</button>
<mat-divider></mat-divider>
<!-- Reset Button -->
<button mat-menu-item (click)="resetState()">
<mat-icon color="warn">replay</mat-icon>
<span translate>Reset state</span>
</button>
<button mat-menu-item class="red-warning-text" (click)="onDeletePoll()">
<mat-icon>delete</mat-icon>
<span translate>Delete</span>

View File

@ -1,5 +1,6 @@
@import '~assets/styles/poll-colors.scss';
.assignment-poll-wrapper {
@import '~assets/styles/poll-common-styles.scss';
position: relative;
margin: 0 15px;
@ -9,37 +10,6 @@
right: 0;
}
.poll-properties {
margin: 4px 0;
.mat-chip {
margin: 0 4px;
&.active {
cursor: pointer;
}
}
.poll-state {
&.created {
background-color: #2196f3;
color: white;
}
&.started {
background-color: #4caf50;
color: white;
}
&.finished {
background-color: #ff5252;
color: white;
}
&.published {
background-color: #ffd800;
color: black;
}
}
}
.poll-detail-button-wrapper {
display: flex;
margin: auto 0;
@ -47,4 +17,23 @@
margin-left: auto;
}
}
.start-poll-button {
color: green !important;
}
.stop-poll-button {
color: $poll-stop-color;
}
.publish-poll-button {
color: $poll-publish-color;
}
.doughnut-chart {
display: block;
max-width: 300px;
margin-left: auto;
margin-right: auto;
}
}

View File

@ -2,6 +2,7 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { E2EImportsModule } from 'e2e-imports.module';
import { PollProgressComponent } from 'app/site/polls/components/poll-progress/poll-progress.component';
import { AssignmentPollVoteComponent } from '../assignment-poll-vote/assignment-poll-vote.component';
import { AssignmentPollComponent } from './assignment-poll.component';
@ -11,8 +12,8 @@ describe('AssignmentPollComponent', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [AssignmentPollComponent, AssignmentPollVoteComponent],
imports: [E2EImportsModule]
imports: [E2EImportsModule],
declarations: [AssignmentPollComponent, AssignmentPollVoteComponent, PollProgressComponent]
}).compileComponents();
}));

View File

@ -15,7 +15,6 @@ import { BasePollComponent } from 'app/site/polls/components/base-poll.component
import { PollService } from 'app/site/polls/services/poll.service';
import { AssignmentPollDialogService } from '../../services/assignment-poll-dialog.service';
import { AssignmentPollPdfService } from '../../services/assignment-poll-pdf.service';
import { ViewAssignmentOption } from '../../models/view-assignment-option';
import { ViewAssignmentPoll } from '../../models/view-assignment-poll';
/**
@ -52,9 +51,6 @@ export class AssignmentPollComponent extends BasePollComponent<ViewAssignmentPol
public descriptionForm: FormGroup;
/**
* permission checks.
* TODO stub
*
* @returns true if the user is permitted to do operations
*/
public get canManage(): boolean {
@ -93,9 +89,6 @@ export class AssignmentPollComponent extends BasePollComponent<ViewAssignmentPol
}
public ngOnInit(): void {
/*this.majorityChoice =
this.pollService.majorityMethods.find(method => method.value === this.pollService.defaultMajorityMethod) ||
null;*/
this.descriptionForm = this.formBuilder.group({
description: this.poll ? this.poll.description : ''
});
@ -115,70 +108,4 @@ export class AssignmentPollComponent extends BasePollComponent<ViewAssignmentPol
(this.poll.type !== 'analog' && this.poll.isStarted)
);
}
/**
* Determines whether the candidate has reached the majority needed to pass
* the quorum
*
* @param option
* @returns true if the quorum is successfully met
*/
public quorumReached(option: ViewAssignmentOption): boolean {
/*const yesValue = this.poll.pollmethod === 'votes' ? 'Votes' : 'Yes';
const amount = option.votes.find(v => v.value === yesValue).weight;
const yesQuorum = this.pollService.yesQuorum(
this.majorityChoice,
this.pollService.calculationDataFromPoll(this.poll),
option
);
return yesQuorum && amount >= yesQuorum;*/
throw new Error('TODO');
}
/**
* Mark/unmark an option as elected
*
* @param option
*/
public toggleElected(option: ViewAssignmentOption): void {
/*if (!this.operator.hasPerms('assignments.can_manage')) {
return;
}
// TODO additional conditions: assignment not finished?
const viewAssignmentRelatedUser = this.assignment.assignment_related_users.find(
user => user.user_id === option.candidate_id
);
if (viewAssignmentRelatedUser) {
this.assignmentRepo.markElected(viewAssignmentRelatedUser, this.assignment, !option.is_elected);
}*/
}
/**
* Sends the edited poll description to the server
* TODO: Better feedback
*/
public async onEditDescriptionButton(): Promise<void> {
/*const desc: string = this.descriptionForm.get('description').value;
await this.assignmentRepo.updatePoll({ description: desc }, this.poll).catch(this.raiseError);*/
}
/**
* Fetches a tooltip string about the quorum
* @param option
* @returns a translated
*/
public getQuorumReachedString(option: ViewAssignmentOption): string {
/*const name = this.translate.instant(this.majorityChoice.display_name);
const quorum = this.pollService.yesQuorum(
this.majorityChoice,
this.pollService.calculationDataFromPoll(this.poll),
option
);
const isReached = this.quorumReached(option)
? this.translate.instant('reached')
: this.translate.instant('not reached');
return `${name} (${quorum}) ${isReached}`;*/
throw new Error('TODO');
}
}

View File

@ -2,10 +2,9 @@ import { BehaviorSubject } from 'rxjs';
import { ChartData } from 'app/shared/components/charts/charts.component';
import { AssignmentPoll } from 'app/shared/models/assignments/assignment-poll';
import { PollState } from 'app/shared/models/poll/base-poll';
import { BaseViewModel } from 'app/site/base/base-view-model';
import { ProjectorElementBuildDeskriptor } from 'app/site/base/projectable';
import { PollTableData, ViewBasePoll } from 'app/site/polls/models/view-base-poll';
import { PollTableData, ViewBasePoll, VotingResult } from 'app/site/polls/models/view-base-poll';
import { ViewAssignment } from './view-assignment';
import { ViewAssignmentOption } from './view-assignment-option';
@ -35,7 +34,6 @@ export class ViewAssignmentPoll extends ViewBasePoll<AssignmentPoll> implements
}
public getSlide(): ProjectorElementBuildDeskriptor {
// TODO: update to new voting system?
return {
getBasicProjectorElement: options => ({
name: AssignmentPoll.COLLECTIONSTRING,
@ -49,27 +47,36 @@ export class ViewAssignmentPoll extends ViewBasePoll<AssignmentPoll> implements
}
public generateTableData(): PollTableData[] {
const data = this.options
.map(candidate => ({
yes: candidate.yes,
no: candidate.no,
abstain: candidate.abstain,
user: candidate.user.full_name,
showPercent: true
const tableData: PollTableData[] = this.options.map(candidate => ({
votingOption: candidate.user.short_name,
votingOptionSubtitle: candidate.user.getLevelAndNumber(),
value: this.voteTableKeys.map(
key =>
({
vote: key.vote,
amount: candidate[key.vote],
icon: key.icon,
hide: key.hide,
showPercent: key.showPercent
} as VotingResult)
)
}));
tableData.push(
...this.sumTableKeys.map(key => ({
votingOption: key.vote,
value: [
{
amount: this[key.vote],
hide: key.hide,
showPercent: key.showPercent
} as VotingResult
]
}))
.sort((a, b) => b.yes - a.yes);
);
return data;
}
/**
* Override from base poll to skip started state in analog poll type
*/
public getNextStates(): { [key: number]: string } {
if (this.poll.type === 'analog' && this.state === PollState.Created) {
return null;
}
return super.getNextStates();
return tableData;
}
}

View File

@ -4,6 +4,7 @@ import { TitleInformationWithAgendaItem } from 'app/site/base/base-view-model-wi
import { BaseViewModelWithAgendaItemAndListOfSpeakers } from 'app/site/base/base-view-model-with-agenda-item-and-list-of-speakers';
import { ProjectorElementBuildDeskriptor } from 'app/site/base/projectable';
import { ViewMediafile } from 'app/site/mediafiles/models/view-mediafile';
import { HasViewPolls } from 'app/site/polls/models/has-view-polls';
import { ViewTag } from 'app/site/tags/models/view-tag';
import { ViewUser } from 'app/site/users/models/view-user';
import { ViewAssignmentPoll } from './view-assignment-poll';
@ -102,9 +103,8 @@ export class ViewAssignment extends BaseViewModelWithAgendaItemAndListOfSpeakers
};
}
}
interface IAssignmentRelations {
interface IAssignmentRelations extends HasViewPolls<ViewAssignmentPoll> {
assignment_related_users: ViewAssignmentRelatedUser[];
polls: ViewAssignmentPoll[];
tags?: ViewTag[];
attachments?: ViewMediafile[];
}

View File

@ -185,11 +185,11 @@ export class AssignmentPdfService {
const tableData = poll.generateTableData();
for (const pollResult of tableData) {
const voteOption = this.translate.instant(this.pollKeyVerbose.transform(pollResult.votingOption));
const resultLine = this.getPollResult(pollResult, poll);
const tableLine = [
{
text: pollResult.user
text: voteOption
},
{
text: resultLine
@ -217,11 +217,13 @@ export class AssignmentPdfService {
* Converts pollData to a printable string representation
*/
private getPollResult(votingResult: PollTableData, poll: ViewAssignmentPoll): string {
const resultList = poll.pollmethodFields.map(field => {
const votingKey = this.translate.instant(this.pollKeyVerbose.transform(field));
const resultValue = this.parsePollNumber.transform(votingResult[field]);
const resultInPercent = this.pollPercentBase.transform(votingResult[field], poll);
return `${votingKey}: ${resultValue} ${resultInPercent ? resultInPercent : ''}`;
const resultList = votingResult.value.map(singleResult => {
const votingKey = this.translate.instant(this.pollKeyVerbose.transform(singleResult.vote));
const resultValue = this.parsePollNumber.transform(singleResult.amount);
const resultInPercent = this.pollPercentBase.transform(singleResult.amount, poll);
return `${votingKey}${!!votingKey ? ': ' : ''}${resultValue} ${
singleResult.showPercent && resultInPercent ? resultInPercent : ''
}`;
});
return resultList.join('\n');
}

View File

@ -4,7 +4,6 @@ import { MatDialog } from '@angular/material';
import { CollectionStringMapperService } from 'app/core/core-services/collection-string-mapper.service';
import { BasePollDialogService } from 'app/core/ui-services/base-poll-dialog.service';
import { AssignmentPollDialogComponent } from 'app/site/assignments/components/assignment-poll-dialog/assignment-poll-dialog.component';
import { AssignmentPollService } from './assignment-poll.service';
import { ViewAssignmentPoll } from '../models/view-assignment-poll';
/**
@ -16,7 +15,7 @@ import { ViewAssignmentPoll } from '../models/view-assignment-poll';
export class AssignmentPollDialogService extends BasePollDialogService<ViewAssignmentPoll> {
protected dialogComponent = AssignmentPollDialogComponent;
public constructor(dialog: MatDialog, mapper: CollectionStringMapperService, service: AssignmentPollService) {
super(dialog, mapper, service);
public constructor(dialog: MatDialog, mapper: CollectionStringMapperService) {
super(dialog, mapper);
}
}

View File

@ -113,7 +113,6 @@ export class AssignmentPollPdfService extends PollPdfService {
* @param title The identifier of the motion
* @param subtitle The actual motion title
*/
// TODO: typing of result
protected createBallot(data: AbstractPollData): object {
return {
columns: [
@ -137,7 +136,6 @@ export class AssignmentPollPdfService extends PollPdfService {
};
}
// TODO: typing of result
private createCandidateFields(poll: ViewAssignmentPoll): object {
const candidates = poll.options.sort((a, b) => {
return a.weight - b.weight;
@ -147,15 +145,23 @@ export class AssignmentPollPdfService extends PollPdfService {
? this.createBallotOption(cand.user.full_name)
: this.createYNBallotEntry(cand.user.full_name, poll.pollmethod);
});
if (poll.pollmethod === 'votes') {
const noEntry = this.createBallotOption(this.translate.instant('No'));
noEntry.margin[1] = 25;
resultObject.push(noEntry);
if (poll.global_no) {
const noEntry = this.createBallotOption(this.translate.instant('No'));
noEntry.margin[1] = 25;
resultObject.push(noEntry);
}
if (poll.global_abstain) {
const abstainEntry = this.createBallotOption(this.translate.instant('Abstain'));
abstainEntry.margin[1] = 25;
resultObject.push(abstainEntry);
}
}
return resultObject;
}
// TODO: typing of result
private createYNBallotEntry(option: string, method: AssignmentPollMethods): object {
const choices = method === 'YNA' ? ['Yes', 'No', 'Abstain'] : ['Yes', 'No'];
const columnstack = choices.map(choice => {
@ -182,7 +188,6 @@ export class AssignmentPollPdfService extends PollPdfService {
* @param poll
* @returns pdfMake definitions
*/
// TODO: typing of result
private createPollHint(poll: ViewAssignmentPoll): object {
return {
text: poll.description || '',

View File

@ -5,11 +5,9 @@ import { TranslateService } from '@ngx-translate/core';
import { ConstantsService } from 'app/core/core-services/constants.service';
import { AssignmentPollRepositoryService } from 'app/core/repositories/assignments/assignment-poll-repository.service';
import { ConfigService } from 'app/core/ui-services/config.service';
import { AssignmentPollMethods } from 'app/shared/models/assignments/assignment-poll';
import { Collection } from 'app/shared/models/base/collection';
import { AssignmentPoll, AssignmentPollMethods } from 'app/shared/models/assignments/assignment-poll';
import { MajorityMethod, PercentBase } from 'app/shared/models/poll/base-poll';
import { PollData, PollService } from 'app/site/polls/services/poll.service';
import { ViewAssignmentPoll } from '../models/view-assignment-poll';
@Injectable({
providedIn: 'root'
@ -25,6 +23,10 @@ export class AssignmentPollService extends PollService {
*/
public defaultMajorityMethod: MajorityMethod;
public defaultGroupIds: number[];
public defaultPollMethod: AssignmentPollMethods;
/**
* Constructor. Subscribes to the configuration values needed
* @param config ConfigService
@ -42,16 +44,21 @@ export class AssignmentPollService extends PollService {
config
.get<MajorityMethod>('motion_poll_default_majority_method')
.subscribe(method => (this.defaultMajorityMethod = method));
config.get<number[]>(AssignmentPoll.defaultGroupsConfig).subscribe(ids => (this.defaultGroupIds = ids));
config
.get<AssignmentPollMethods>(AssignmentPoll.defaultPollMethodConfig)
.subscribe(method => (this.defaultPollMethod = method));
}
public fillDefaultPollData(poll: Partial<ViewAssignmentPoll> & Collection): void {
super.fillDefaultPollData(poll);
public getDefaultPollData(): AssignmentPoll {
const poll = new AssignmentPoll(super.getDefaultPollData());
const length = this.pollRepo.getViewModelList().filter(item => item.assignment_id === poll.assignment_id)
.length;
poll.title = !length ? this.translate.instant('Ballot') : `${this.translate.instant('Ballot')} (${length + 1})`;
poll.pollmethod = AssignmentPollMethods.YN;
poll.assignment_id = poll.assignment_id;
poll.pollmethod = this.defaultPollMethod;
return poll;
}
private sumOptionsYN(poll: PollData): number {

View File

@ -1,9 +1,8 @@
import { MotionPoll } from 'app/shared/models/motions/motion-poll';
import { PollState } from 'app/shared/models/poll/base-poll';
import { BaseViewModel } from 'app/site/base/base-view-model';
import { ProjectorElementBuildDeskriptor } from 'app/site/base/projectable';
import { ViewMotionOption } from 'app/site/motions/models/view-motion-option';
import { PollTableData, ViewBasePoll } from 'app/site/polls/models/view-base-poll';
import { PollTableData, ViewBasePoll, VotingResult } from 'app/site/polls/models/view-base-poll';
import { ViewMotion } from './view-motion';
export interface MotionPollTitleInformation {
@ -15,58 +14,12 @@ export const MotionPollMethodsVerbose = {
YNA: 'Yes/No/Abstain'
};
interface TableKey {
vote: string;
icon?: string;
canHide: boolean;
showPercent: boolean;
}
export class ViewMotionPoll extends ViewBasePoll<MotionPoll> implements MotionPollTitleInformation {
public static COLLECTIONSTRING = MotionPoll.COLLECTIONSTRING;
protected _collectionString = MotionPoll.COLLECTIONSTRING;
public readonly pollClassType: 'assignment' | 'motion' = 'motion';
private tableKeys: TableKey[] = [
{
vote: 'yes',
icon: 'thumb_up',
canHide: false,
showPercent: true
},
{
vote: 'no',
icon: 'thumb_down',
canHide: false,
showPercent: true
},
{
vote: 'abstain',
icon: 'trip_origin',
canHide: false,
showPercent: true
}
];
private voteKeys: TableKey[] = [
{
vote: 'votesvalid',
canHide: true,
showPercent: this.poll.isPercentBaseValidOrCast
},
{
vote: 'votesinvalid',
canHide: true,
showPercent: this.poll.isPercentBaseValidOrCast
},
{
vote: 'votescast',
canHide: true,
showPercent: this.poll.isPercentBaseValidOrCast
}
];
public get result(): ViewMotionOption {
return this.options[0];
}
@ -80,22 +33,30 @@ export class ViewMotionPoll extends ViewBasePoll<MotionPoll> implements MotionPo
}
public generateTableData(): PollTableData[] {
let tableData = this.options.flatMap(vote =>
this.tableKeys.map(key => ({
key: key.vote,
value: vote[key.vote],
canHide: key.canHide,
icon: key.icon,
showPercent: key.showPercent
}))
let tableData: PollTableData[] = this.options.flatMap(vote =>
this.voteTableKeys.map(key => this.createTableDataEntry(key, vote))
);
tableData.push(
...this.voteKeys.map(key => ({ key: key.vote, value: this[key.vote], showPercent: key.showPercent }))
);
tableData = tableData.filter(entry => entry.canHide === false || entry.value || entry.value !== -2);
tableData.push(...this.sumTableKeys.map(key => this.createTableDataEntry(key)));
tableData = tableData.filter(localeTableData => !localeTableData.value.some(result => result.hide));
return tableData;
}
private createTableDataEntry(result: VotingResult, vote?: ViewMotionOption): PollTableData {
return {
votingOption: result.vote,
value: [
{
amount: vote ? vote[result.vote] : this[result.vote],
hide: result.hide,
icon: result.icon,
showPercent: result.showPercent
}
]
};
}
public getSlide(): ProjectorElementBuildDeskriptor {
return {
getBasicProjectorElement: options => ({
@ -116,16 +77,6 @@ export class ViewMotionPoll extends ViewBasePoll<MotionPoll> implements MotionPo
public anySpecialVotes(): boolean {
return this.result.yes < 0 || this.result.no < 0 || this.result.abstain < 0;
}
/**
* Override from base poll to skip started state in analog poll type
*/
public getNextStates(): { [key: number]: string } {
if (this.poll.type === 'analog' && this.state === PollState.Created) {
return null;
}
return super.getNextStates();
}
}
export interface ViewMotionPoll extends MotionPoll {

View File

@ -2,6 +2,7 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { E2EImportsModule } from 'e2e-imports.module';
import { PollProgressComponent } from 'app/site/polls/components/poll-progress/poll-progress.component';
import { ManageSubmittersComponent } from '../manage-submitters/manage-submitters.component';
import { MotionCommentsComponent } from '../motion-comments/motion-comments.component';
import { MotionDetailDiffComponent } from '../motion-detail-diff/motion-detail-diff.component';
@ -26,7 +27,8 @@ describe('MotionDetailComponent', () => {
MotionPollComponent,
MotionDetailOriginalChangeRecommendationsComponent,
MotionDetailDiffComponent,
MotionPollVoteComponent
MotionPollVoteComponent,
PollProgressComponent
]
}).compileComponents();
}));

View File

@ -64,6 +64,7 @@ import { LocalPermissionsService } from 'app/site/motions/services/local-permiss
import { MotionFilterListService } from 'app/site/motions/services/motion-filter-list.service';
import { MotionPdfExportService } from 'app/site/motions/services/motion-pdf-export.service';
import { MotionPollDialogService } from 'app/site/motions/services/motion-poll-dialog.service';
import { MotionPollService } from 'app/site/motions/services/motion-poll.service';
import { MotionSortListService } from 'app/site/motions/services/motion-sort-list.service';
import { ViewTag } from 'app/site/tags/models/view-tag';
import { ViewUser } from 'app/site/users/models/view-user';
@ -467,7 +468,8 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit,
private motionFilterService: MotionFilterListService,
private amendmentFilterService: AmendmentFilterListService,
private cd: ChangeDetectorRef,
private pollDialog: MotionPollDialogService
private pollDialog: MotionPollDialogService,
private motionPollService: MotionPollService
) {
super(title, translate, matSnackBar);
}
@ -1625,9 +1627,15 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit,
this.cd.markForCheck();
}
public openDialog(poll?: ViewMotionPoll): void {
this.pollDialog.openDialog(
poll ? poll : { collectionString: ViewMotionPoll.COLLECTIONSTRING, motion_id: this.motion.id }
);
public openDialog(): void {
// TODO: Could be simpler, requires a lot of refactoring
const dialogData = {
collectionString: ViewMotionPoll.COLLECTIONSTRING,
motion_id: this.motion.id,
motion: this.motion,
...this.motionPollService.getDefaultPollData()
};
this.pollDialog.openDialog(dialogData);
}
}

View File

@ -18,97 +18,113 @@
<ng-template #viewTemplate>
<ng-container *ngIf="poll">
<h1>{{ poll.title }}</h1>
<span *ngIf="poll.type !== 'analog'">{{ poll.typeVerbose | translate }}</span>
<div>
<!-- Subtitle -->
<span *ngIf="poll.type !== 'analog'"> {{ poll.typeVerbose | translate }} &middot; </span>
<!-- State chip -->
<span>
{{ poll.stateVerbose }}
</span>
</div>
<div *ngIf="!poll.hasVotes || !poll.stateHasVotes">{{ 'No results to show' | translate }}</div>
<div *ngIf="poll.stateHasVotes">
<h2 translate>Result</h2>
<div class="result-wrapper" *ngIf="poll.hasVotes">
<!-- Chart -->
<os-charts
class="result-chart"
*ngIf="chartDataSubject.value"
[type]="chartType"
[data]="chartDataSubject"
></os-charts>
<!-- result table -->
<table class="result-table">
<tbody>
<tr>
<th></th>
<th translate>Votes</th>
<th colspan="2" translate>Votes</th>
</tr>
<tr *ngFor="let row of poll.tableData">
<tr *ngFor="let row of poll.tableData" [class]="row.votingOption">
<!-- YNA/Valid etc -->
<td>
<os-icon-container *ngIf="row.icon" [icon]="row.icon">
{{ row.key | pollKeyVerbose | translate }}
<os-icon-container *ngIf="row.value[0].icon" [icon]="row.value[0].icon">
{{ row.votingOption | pollKeyVerbose | translate }}
</os-icon-container>
<span *ngIf="!row.icon">
{{ row.key | pollKeyVerbose | translate }}
<span *ngIf="!row.value[0].icon">
{{ row.votingOption | pollKeyVerbose | translate }}
</span>
</td>
<!-- Percent numbers -->
<td class="result-cell-definition">
{{ row.value | parsePollNumber }}
<span *ngIf="row.showPercent">
{{ row.value | pollPercentBase: poll }}
<span *ngIf="row.value[0].showPercent">
{{ row.value[0].amount | pollPercentBase: poll }}
</span>
</td>
<!-- Voices -->
<td class="result-cell-definition">
{{ row.value[0].amount | parsePollNumber }}
</td>
</tr>
</tbody>
</table>
<!-- Named table: only show if votes are present -->
<div class="named-result-table" *ngIf="poll.type === 'named'">
<h3>{{ 'Single votes' | translate }}</h3>
<os-list-view-table
[listObservable]="votesDataObservable"
[columns]="columnDefinition"
[filterProps]="filterProps"
[allowProjector]="false"
[fullScreen]="false"
[vScrollFixed]="60"
listStorageKey="motion-poll-vote"
[cssClasses]="{ 'single-votes-table': true }"
>
<!-- Header -->
<div *pblNgridHeaderCellDef="'*'; col as col">
{{ col.label | translate }}
</div>
<!-- Content -->
<div *pblNgridCellDef="'user'; row as vote">
<div *ngIf="vote.user">{{ vote.user.getFullName() }}</div>
<div *ngIf="!vote.user">{{ 'Anonymous' | translate }}</div>
</div>
<div *pblNgridCellDef="'vote'; row as vote" class="vote-cell">
<div class="vote-cell-icon-container" [ngClass]="voteOptionStyle[vote.value].css">
<mat-icon>{{ voteOptionStyle[vote.value].icon }}</mat-icon>
</div>
<div>{{ vote.valueVerbose | translate }}</div>
</div>
<!-- No Results -->
<div *pblNgridNoDataRef class="pbl-ngrid-no-data">
{{ 'The individual votes were made anonymous.' | translate }}
</div>
</os-list-view-table>
<!-- Chart -->
<div class="doughnut-chart">
<os-charts
*ngIf="chartDataSubject.value"
[type]="chartType"
[data]="chartDataSubject"
[showLegend]="false"
[hasPadding]="false"
></os-charts>
</div>
</div>
<!-- Named table: only show if votes are present -->
<div class="named-result-table" *ngIf="poll.type === 'named'">
<h2>{{ 'Single votes' | translate }}</h2>
<os-list-view-table
[listObservable]="votesDataObservable"
[columns]="columnDefinition"
[filterProps]="filterProps"
[allowProjector]="false"
[fullScreen]="true"
[vScrollFixed]="60"
listStorageKey="motion-poll-vote"
[cssClasses]="{ 'single-votes-table': true }"
>
<!-- Header -->
<div *pblNgridHeaderCellDef="'*'; col as col">
{{ col.label | translate }}
</div>
<!-- Content -->
<div *pblNgridCellDef="'user'; row as vote">
<div *ngIf="vote.user">{{ vote.user.getFullName() }}</div>
<div *ngIf="!vote.user">{{ 'Anonymous' | translate }}</div>
</div>
<div *pblNgridCellDef="'vote'; row as vote" class="vote-cell">
<div class="vote-cell-icon-container" [ngClass]="voteOptionStyle[vote.value].css">
<mat-icon>{{ voteOptionStyle[vote.value].icon }}</mat-icon>
</div>
<div>{{ vote.valueVerbose | translate }}</div>
</div>
<!-- No Results -->
<div *pblNgridNoDataRef class="pbl-ngrid-no-data">
{{ 'The individual votes were made anonymous.' | translate }}
</div>
</os-list-view-table>
</div>
</div>
<div class="poll-content small">
<div *ngIf="poll.groups && poll.type && poll.type !== 'analog'">
<div class="poll-content">
<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>
</div>
<div>{{ 'Required majority' | translate }}: {{ poll.majorityMethodVerbose | translate }}</div>
<div>{{ '100% base' | translate }}: {{ poll.percentBaseVerbose | translate }}</div>
</small>
<small>{{ '100% base' | translate }}: {{ poll.percentBaseVerbose | translate }}</small>
</div>
</ng-container>
</ng-template>
@ -116,7 +132,7 @@
<!-- More Menu -->
<mat-menu #pollDetailMenu="matMenu">
<os-projector-button [menuItem]="true" [object]="poll" *osPerms="'core.can_manage_projector'"></os-projector-button>
<button *osPerms="'motions.can_manage_polls'" mat-menu-item (click)="openDialog()">
<button *osPerms="'motions.can_manage_polls'" mat-menu-item (click)="openDialog(poll)">
<mat-icon>edit</mat-icon>
<span translate>Edit</span>
</button>

View File

@ -2,53 +2,51 @@
@import '~assets/styles/poll-colors.scss';
.poll-content {
text-align: right;
display: grid;
padding-top: 20px;
}
.result-wrapper {
display: grid;
grid-gap: 10px;
grid-template-areas:
'chart'
'results'
'names';
}
grid-gap: 2em;
margin: 2em;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
@include desktop {
.result-wrapper {
grid-template-areas:
'results chart'
'names names';
grid-template-columns: 2fr 1fr;
}
}
.result-table {
grid-area: results;
tr {
height: 48px;
min-height: 48px;
th:first-child,
td:first-child {
padding-left: 24px;
}
th:last-child,
td:last-child {
padding-right: 24px;
.result-table {
// display: block;
th {
text-align: right;
font-weight: initial;
}
.result-cell-definition {
text-align: center;
tr {
height: 48px;
border-bottom: none !important;
.result-cell-definition {
text-align: right;
}
}
.yes {
color: $votes-yes-color;
}
.no {
color: $votes-no-color;
}
.abstain {
color: $votes-abstain-color;
}
}
}
.result-chart {
grid-area: chart;
max-width: 300px;
margin-left: auto;
margin-right: auto;
.doughnut-chart {
display: block;
margin-top: auto;
margin-bottom: auto;
}
}
.named-result-table {

View File

@ -1,12 +1,4 @@
@import '~@angular/material/theming';
@mixin os-motion-poll-detail-style($theme) {
$background: map-get($theme, background);
.result-table {
border-collapse: collapse;
tr {
border-bottom: 1px solid mat-color($background, focused-button);
}
}
}

View File

@ -5,7 +5,7 @@ import { E2EImportsModule } from 'e2e-imports.module';
import { MotionPollDialogComponent } from './motion-poll-dialog.component';
fdescribe('MotionPollDialogComponent', () => {
describe('MotionPollDialogComponent', () => {
let component: MotionPollDialogComponent;
let fixture: ComponentFixture<MotionPollDialogComponent>;

View File

@ -15,18 +15,18 @@ import { PollFormComponent } from 'app/site/polls/components/poll-form/poll-form
templateUrl: './motion-poll-dialog.component.html',
styleUrls: ['./motion-poll-dialog.component.scss']
})
export class MotionPollDialogComponent extends BasePollDialogComponent {
export class MotionPollDialogComponent extends BasePollDialogComponent<ViewMotionPoll> {
public motionPollMethods = { YNA: MotionPollMethodsVerbose.YNA };
@ViewChild('pollForm', { static: false })
protected pollForm: PollFormComponent;
protected pollForm: PollFormComponent<ViewMotionPoll>;
public constructor(
private fb: FormBuilder,
title: Title,
protected translate: TranslateService,
matSnackbar: MatSnackBar,
public dialogRef: MatDialogRef<BasePollDialogComponent>,
public dialogRef: MatDialogRef<BasePollDialogComponent<ViewMotionPoll>>,
@Inject(MAT_DIALOG_DATA) public pollData: Partial<ViewMotionPoll>
) {
super(title, translate, matSnackbar, dialogRef);

View File

@ -1,10 +1,10 @@
<ng-container *ngIf="poll">
<div *osPerms="'motions.can_manage_polls';and:poll.isStarted">
<div *osPerms="'motions.can_manage_polls'; and: poll && poll.isStarted">
<os-poll-progress [poll]="poll"></os-poll-progress>
</div>
<ng-container *ngIf="vmanager.canVote(poll)">
<div *ngIf="vmanager.canVote(poll)" class="vote-button-grid">
<!-- Voting -->
<p *ngFor="let option of voteOptions">
<div class="vote-button" *ngFor="let option of voteOptions">
<button
mat-raised-button
(click)="saveVote(option.vote)"
@ -13,11 +13,6 @@
<mat-icon> {{ option.icon }}</mat-icon>
</button>
<span class="vote-label"> {{ option.label | translate }} </span>
</p>
</ng-container>
<!-- TODO most of the messages are not making sense -->
<!-- <ng-container *ngIf="!vmanager.canVote(poll)">
<span>{{ vmanager.getVotePermissionErrorVerbose(poll) | translate }}</span>
</ng-container> -->
</div>
</div>
</ng-container>

View File

@ -1,17 +1,33 @@
@import '~assets/styles/poll-colors.scss';
.vote-button-grid {
display: grid;
grid-gap: 20px;
margin-top: 2em;
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
}
.vote-button {
display: inline-grid;
grid-gap: 1em;
margin: auto;
.vote-label {
text-align: center;
}
}
.voted-yes {
background-color: $votes-yes-color;
color: $vote-active-color;
}
.voted-no {
background-color: $votes-no-color;
color: $vote-active-color;
}
.voted-abstain {
background-color: $votes-abstain-color;
}
.vote-label {
margin-left: 1em;
color: $vote-active-color;
}

View File

@ -2,6 +2,7 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { E2EImportsModule } from 'e2e-imports.module';
import { PollProgressComponent } from 'app/site/polls/components/poll-progress/poll-progress.component';
import { MotionPollVoteComponent } from './motion-poll-vote.component';
describe('MotionPollVoteComponent', () => {
@ -11,7 +12,7 @@ describe('MotionPollVoteComponent', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule],
declarations: [MotionPollVoteComponent]
declarations: [MotionPollVoteComponent, PollProgressComponent]
}).compileComponents();
}));

View File

@ -1,44 +1,68 @@
<div class="poll-preview-wrapper" *ngIf="poll && showPoll()">
<mat-card class="motion-poll-wrapper" *ngIf="poll">
<!-- Poll Infos -->
<div class="poll-title-wrapper">
<!-- Title -->
<a class="poll-title" [routerLink]="pollLink">
{{ poll.title }}
</a>
<!-- Title Area -->
<div class="poll-title-area">
<!-- Title -->
<span class="poll-title">
<a [routerLink]="pollLink">
{{ poll.title }}
</a>
</span>
<div>
<!-- Subtitle -->
<span *ngIf="pollService.isElectronicVotingEnabled && poll.type !== 'analog'">
{{ poll.typeVerbose | translate }} &middot;
</span>
<!-- State chip -->
<span>
{{ poll.stateVerbose }}
</span>
</div>
</div>
<!-- Dot Menu -->
<span class="poll-title-actions" *osPerms="'motions.can_manage_polls'">
<span class="poll-actions" *osPerms="'motions.can_manage_polls'">
<button mat-icon-button [matMenuTriggerFor]="pollDetailMenu">
<mat-icon class="small-icon">more_horiz</mat-icon>
</button>
</span>
</div>
<!-- State chip -->
<div class="poll-properties" *osPerms="'motions.can_manage_polls'">
<div *ngIf="pollService.isElectronicVotingEnabled && poll.type !== 'analog'">
{{ poll.typeVerbose | translate }}
</div>
<mat-chip
disableRipple
[matMenuTriggerFor]="triggerMenu"
class="poll-state active"
[class]="poll.stateVerbose.toLowerCase()"
[ngClass]="{ 'disabled': !poll.getNextStates() }"
>
{{ poll.stateVerbose }}
</mat-chip>
</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)"
>
<mat-icon> {{ pollStateActions[poll.state].icon }}</mat-icon>
<span class="next-state-label">
{{ poll.nextStateActionVerbose | translate }}
</span>
</button>
</div>
<!-- Results -->
<ng-container *ngIf="poll && !poll.stateHasVotes && poll.type !== 'analog'; else votingResult">
<os-motion-poll-vote [poll]="poll"></os-motion-poll-vote>
</ng-container>
</div>
<!-- Detail link -->
<div class="poll-detail-button-wrapper">
<a mat-icon-button [routerLink]="pollLink" matTooltip="{{ 'More' | translate }}">
<mat-icon class="small-icon">
visibility
</mat-icon>
</a>
</div>
</mat-card>
<ng-template #votingResult>
<div [routerLink]="pollLink" class="poll-link-wrapper">
<div class="poll-link-wrapper">
<ng-container
[ngTemplateOutlet]="poll.hasVotes && poll.stateHasVotes ? viewTemplate : emptyTemplate"
></ng-container>
@ -46,7 +70,28 @@
</ng-template>
<ng-template #viewTemplate>
<div class="poll-chart-wrapper">
<!-- 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 }}
</os-icon-container>
</div>
</div>
<div class="doughnut-chart">
<os-charts
*ngIf="showChart"
@ -57,31 +102,13 @@
>
</os-charts>
</div>
<div class="vote-legend">
<div class="votes-yes" *ngIf="pollService.isVoteDocumented(voteYes)">
<os-icon-container icon="thumb_up" size="large">
{{ voteYes | parsePollNumber }}
{{ voteYes | pollPercentBase: poll }}
</os-icon-container>
</div>
<div class="votes-no" *ngIf="pollService.isVoteDocumented(voteNo)">
<os-icon-container icon="thumb_down" size="large">
{{ voteNo | parsePollNumber }}
{{ voteNo | pollPercentBase: poll }}
</os-icon-container>
</div>
<div class="votes-abstain" *ngIf="pollService.isVoteDocumented(voteAbstain)">
<os-icon-container icon="trip_origin" size="large">
{{ voteAbstain | parsePollNumber }}
{{ voteAbstain | pollPercentBase: poll }}
</os-icon-container>
</div>
</div>
</div>
<div class="poll-detail-button-wrapper">
<a mat-button [routerLink]="pollLink">
{{ 'More' | translate }}
</a>
<!-- In Progress hint -->
<div class="motion-couting-in-progress-hint" *osPerms="'motions.can_manage_polls'; complement: true">
<span *ngIf="poll.isFinished">
{{ 'Counting is in progress' | translate }}
</span>
</div>
</ng-template>
@ -104,18 +131,16 @@
</button>
<div *osPerms="'motions.can_manage_polls'">
<mat-divider></mat-divider>
<!-- Reset Button -->
<button mat-menu-item (click)="resetState()">
<mat-icon color="warn">replay</mat-icon>
<span translate>Reset state</span>
</button>
<!-- Delete button -->
<button mat-menu-item (click)="deletePoll()">
<mat-icon color="warn">delete</mat-icon>
<span translate>Delete</span>
</button>
</div>
</mat-menu>
<!-- Select state menu -->
<mat-menu #triggerMenu="matMenu">
<ng-container *ngIf="poll">
<button mat-menu-item (click)="changeState(state.value)" *ngFor="let state of poll.getNextStates() | keyvalue">
<span translate>{{ state.key }}</span>
</button>
</ng-container>
</mat-menu>

View File

@ -4,70 +4,47 @@
outline: none;
}
.poll-preview-wrapper {
padding: 8px;
border: 1px solid rgba(0, 0, 0, 0.12);
.motion-poll-wrapper {
margin-bottom: 30px;
.poll-title {
text-decoration: none;
font-weight: 500;
}
.poll-title-wrapper {
display: grid;
grid-gap: 10px;
grid-template-areas: 'title actions';
grid-template-columns: auto min-content;
.poll-title-actions {
float: right;
}
.poll-title-area {
grid-area: title;
margin-top: 1em;
.poll-properties {
margin: 4px 0;
.mat-chip {
margin: 0 4px;
&.active {
cursor: pointer;
.poll-title {
font-size: 125%;
}
}
.poll-state {
&.created {
background-color: #2196f3;
color: white;
}
&.started {
background-color: #4caf50;
color: white;
}
&.finished {
background-color: #ff5252;
color: white;
}
&.published {
background-color: #ffd800;
color: black;
}
.poll-actions {
grid-area: actions;
}
}
.poll-chart-wrapper {
cursor: pointer;
display: grid;
grid-gap: 10px;
grid-template-areas: 'placeholder chart legend';
grid-template-columns: auto minmax(50px, 20%) auto;
grid-gap: 20px;
margin: 2em;
// try to find max scale
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
.doughnut-chart {
grid-area: chart;
display: block;
max-width: 200px;
margin-top: auto;
margin-bottom: auto;
}
.vote-legend {
grid-area: legend;
margin-top: auto;
margin-bottom: auto;
div + div {
margin-top: 10px;
margin-top: 20px;
}
.votes-yes {
@ -93,16 +70,24 @@
}
}
.poll-preview-meta-info {
span {
padding: 0 5px;
}
.next-state-label {
margin-top: auto;
margin-bottom: auto;
}
.poll-content {
padding-bottom: 8px;
.start-poll-button {
color: green !important;
}
.poll-footer {
text-align: end;
.stop-poll-button {
color: $poll-stop-color;
}
.publish-poll-button {
color: $poll-publish-color;
}
.motion-couting-in-progress-hint {
margin-top: 1em;
font-style: italic;
}

View File

@ -1,9 +1,4 @@
@import '~@angular/material/theming';
@mixin os-motion-poll-style($theme) {
$background: map-get($theme, background);
.poll-preview-wrapper {
background-color: mat-color($background, card);
}
}

View File

@ -2,6 +2,7 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { E2EImportsModule } from 'e2e-imports.module';
import { PollProgressComponent } from 'app/site/polls/components/poll-progress/poll-progress.component';
import { MotionPollVoteComponent } from '../motion-poll-vote/motion-poll-vote.component';
import { MotionPollComponent } from './motion-poll.component';
@ -11,7 +12,7 @@ describe('MotionPollComponent', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule],
declarations: [MotionPollComponent, MotionPollVoteComponent]
declarations: [MotionPollComponent, MotionPollVoteComponent, PollProgressComponent]
}).compileComponents();
}));
beforeEach(() => {

View File

@ -4,7 +4,6 @@ import { Title } from '@angular/platform-browser';
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 { PromptService } from 'app/core/ui-services/prompt.service';
import { ViewMotionPoll } from 'app/site/motions/models/view-motion-poll';
@ -114,22 +113,12 @@ export class MotionPollComponent extends BasePollComponent<ViewMotionPoll> {
public pollRepo: MotionPollRepositoryService,
pollDialog: MotionPollDialogService,
public pollService: PollService,
private operator: OperatorService,
private pdfService: MotionPollPdfService
) {
super(titleService, matSnackBar, translate, dialog, promptService, pollRepo, pollDialog);
}
public showPoll(): boolean {
return (
this.operator.hasPerms('motions.can_manage_polls') ||
this.poll.isPublished ||
(this.poll.type !== 'analog' && this.poll.isStarted)
);
}
public downloadPdf(): void {
console.log('picture_as_pdf');
this.pdfService.printBallots(this.poll);
}

View File

@ -371,13 +371,17 @@ export class MotionPdfService {
motion.polls.forEach(poll => {
if (poll.hasVotes) {
const tableData = poll.generateTableData();
tableData.forEach(votingResult => {
const resultKey = this.translate.instant(this.pollKeyVerbose.transform(votingResult.key));
const resultValue = this.parsePollNumber.transform(votingResult.value);
column1.push(`${resultKey}:`);
const votingOption = this.translate.instant(
this.pollKeyVerbose.transform(votingResult.votingOption)
);
const value = votingResult.value[0];
const resultValue = this.parsePollNumber.transform(value.amount);
column1.push(`${votingOption}:`);
column2.push(resultValue);
if (votingResult.showPercent) {
const resultInPercent = this.pollPercentBase.transform(votingResult.value, poll);
if (value.showPercent) {
const resultInPercent = this.pollPercentBase.transform(value.amount, poll);
column3.push(resultInPercent);
}
});

View File

@ -4,7 +4,6 @@ import { MatDialog } from '@angular/material';
import { CollectionStringMapperService } from 'app/core/core-services/collection-string-mapper.service';
import { BasePollDialogService } from 'app/core/ui-services/base-poll-dialog.service';
import { MotionPollDialogComponent } from 'app/site/motions/modules/motion-poll/motion-poll-dialog/motion-poll-dialog.component';
import { MotionPollService } from './motion-poll.service';
import { ViewMotionPoll } from '../models/view-motion-poll';
/**
@ -16,7 +15,7 @@ import { ViewMotionPoll } from '../models/view-motion-poll';
export class MotionPollDialogService extends BasePollDialogService<ViewMotionPoll> {
protected dialogComponent = MotionPollDialogComponent;
public constructor(dialog: MatDialog, mapper: CollectionStringMapperService, service: MotionPollService) {
super(dialog, mapper, service);
public constructor(dialog: MatDialog, mapper: CollectionStringMapperService) {
super(dialog, mapper);
}
}

View File

@ -5,10 +5,8 @@ import { TranslateService } from '@ngx-translate/core';
import { ConstantsService } from 'app/core/core-services/constants.service';
import { MotionPollRepositoryService } from 'app/core/repositories/motions/motion-poll-repository.service';
import { ConfigService } from 'app/core/ui-services/config.service';
import { Collection } from 'app/shared/models/base/collection';
import { MotionPollMethods } from 'app/shared/models/motions/motion-poll';
import { MotionPoll, MotionPollMethods } from 'app/shared/models/motions/motion-poll';
import { MajorityMethod, PercentBase } from 'app/shared/models/poll/base-poll';
import { ViewMotionPoll } from 'app/site/motions/models/view-motion-poll';
import { PollData, PollService } from 'app/site/polls/services/poll.service';
interface PollResultData {
@ -34,6 +32,8 @@ export class MotionPollService extends PollService {
*/
public defaultMajorityMethod: MajorityMethod;
public defaultGroupIds: number[];
/**
* Constructor. Subscribes to the configuration values needed
* @param config ConfigService
@ -51,15 +51,18 @@ export class MotionPollService extends PollService {
config
.get<MajorityMethod>('motion_poll_default_majority_method')
.subscribe(method => (this.defaultMajorityMethod = method));
config.get<number[]>(MotionPoll.defaultGroupsConfig).subscribe(ids => (this.defaultGroupIds = ids));
}
public fillDefaultPollData(poll: Partial<ViewMotionPoll> & Collection): void {
super.fillDefaultPollData(poll);
public getDefaultPollData(): 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.pollmethod = MotionPollMethods.YNA;
poll.motion_id = poll.motion_id;
return poll;
}
public getPercentBase(poll: PollData): number {

View File

@ -10,9 +10,7 @@ import { BehaviorSubject, from, Observable } from 'rxjs';
import { GroupRepositoryService } from 'app/core/repositories/users/group-repository.service';
import { BasePollDialogService } from 'app/core/ui-services/base-poll-dialog.service';
import { PromptService } from 'app/core/ui-services/prompt.service';
import { Breadcrumb } from 'app/shared/components/breadcrumb/breadcrumb.component';
import { ChartData, ChartType } from 'app/shared/components/charts/charts.component';
import { PollState, PollType } from 'app/shared/models/poll/base-poll';
import { BaseViewComponent } from 'app/site/base/base-view';
import { ViewGroup } from 'app/site/users/models/view-group';
import { ViewUser } from 'app/site/users/models/view-user';
@ -58,11 +56,6 @@ export abstract class BasePollDetailComponent<V extends ViewBasePoll> extends Ba
*/
public poll: V = null;
/**
* The breadcrumbs for the poll-states.
*/
public breadcrumbs: Breadcrumb[] = [];
/**
* Sets the type of the shown chart, if votes are entered.
*/
@ -143,8 +136,8 @@ export abstract class BasePollDetailComponent<V extends ViewBasePoll> extends Ba
/**
* Opens dialog for editing the poll
*/
public openDialog(): void {
this.pollDialog.openDialog(this.poll);
public openDialog(viewPoll: V): void {
this.pollDialog.openDialog(viewPoll);
}
protected onDeleted(): void {}
@ -198,7 +191,6 @@ export abstract class BasePollDetailComponent<V extends ViewBasePoll> extends Ba
this.repo.getViewModelObservable(params.id).subscribe(poll => {
if (poll) {
this.poll = poll;
this.updateBreadcrumbs();
this.onPollLoaded();
this.waitForOptions();
this.checkData();
@ -218,81 +210,4 @@ export abstract class BasePollDetailComponent<V extends ViewBasePoll> extends Ba
this.onPollWithOptionsLoaded();
}
}
/**
* Action for the different breadcrumbs.
*/
private async changeState(): Promise<void> {
this.actionWrapper(this.repo.changePollState(this.poll), this.onStateChanged);
}
/**
* Resets the state of a motion-poll.
*/
private async resetState(): Promise<void> {
this.actionWrapper(this.repo.resetPoll(this.poll), this.onStateChanged);
}
/**
* Used to execute same logic after fullfilling a promise.
*
* @param action Any promise to execute.
*
* @returns Any promise-like.
*/
private actionWrapper(action: Promise<any>, callback?: () => any): any {
action
.then(() => {
this.checkData();
if (callback) {
callback();
}
})
.catch(this.raiseError);
}
/**
* Used to change the breadcrumbs depending on the state of the given motion-poll.
*/
private updateBreadcrumbs(): void {
this.breadcrumbs = Object.values(PollState)
.filter(state => typeof state === 'string')
.map((state: string) => ({
label: state,
action: this.getBreadcrumbAction(PollState[state]),
active: this.poll ? this.poll.state === PollState[state] : false
}));
}
/**
* Depending on the state of the motion-poll, the breadcrumb has another action and state.
*
* @param state The state of the motion-poll as number.
*
* @returns An action, that is executed, if the breadcrumb is clicked, or null.
*/
private getBreadcrumbAction(state: number): () => any | null {
if (!this.poll) {
return null;
}
if (!this.hasPerms()) {
return null;
}
switch (this.poll.state) {
case PollState.Created:
return state === 2 ? () => this.changeState() : null;
case PollState.Started:
return this.poll.type !== PollType.Analog && state === 3 ? () => this.changeState() : null;
case PollState.Finished:
if (state === 1) {
return () => this.resetState();
} else if (state === 4) {
return () => this.changeState();
} else {
return null;
}
case PollState.Published:
return state === 1 ? () => this.resetState() : null;
}
}
}

View File

@ -8,14 +8,15 @@ import { TranslateService } from '@ngx-translate/core';
import { OneOfValidator } from 'app/shared/validators/one-of-validator';
import { BaseViewComponent } from 'app/site/base/base-view';
import { PollFormComponent } from './poll-form/poll-form.component';
import { ViewBasePoll } from '../models/view-base-poll';
/**
* A dialog for updating the values of a poll.
*/
export abstract class BasePollDialogComponent extends BaseViewComponent {
export abstract class BasePollDialogComponent<T extends ViewBasePoll> extends BaseViewComponent {
public publishImmediately: boolean;
protected pollForm: PollFormComponent;
protected pollForm: PollFormComponent<T>;
public dialogVoteForm: FormGroup;
@ -23,7 +24,7 @@ export abstract class BasePollDialogComponent extends BaseViewComponent {
title: Title,
protected translate: TranslateService,
matSnackbar: MatSnackBar,
public dialogRef: MatDialogRef<BasePollDialogComponent>
public dialogRef: MatDialogRef<BasePollDialogComponent<T>>
) {
super(title, translate, matSnackbar);
}

View File

@ -18,6 +18,21 @@ export abstract class BasePollComponent<V extends ViewBasePoll> extends BaseView
protected _poll: V;
public pollStateActions = {
[PollState.Created]: {
icon: 'play_arrow',
css: 'start-poll-button'
},
[PollState.Started]: {
icon: 'stop',
css: 'stop-poll-button'
},
[PollState.Finished]: {
icon: 'public',
css: 'publish-poll-button'
}
};
public constructor(
titleService: Title,
matSnackBar: MatSnackBar,
@ -41,6 +56,10 @@ export abstract class BasePollComponent<V extends ViewBasePoll> extends BaseView
}
}
public resetState(): void {
this.changeState(PollState.Created);
}
/**
* Handler for the 'delete poll' button
*/

View File

@ -7,6 +7,8 @@
</h2>
</mat-form-field>
</form>
<!-- TODO: rather disable forms than duplicate them -->
<div *ngIf="data && data.state > 1" class="poll-preview-meta-info">
<span class="short-description" *ngFor="let value of pollValues">
<span class="short-description-label subtitle" translate>

View File

@ -5,8 +5,8 @@ import { E2EImportsModule } from 'e2e-imports.module';
import { PollFormComponent } from './poll-form.component';
describe('PollFormComponent', () => {
let component: PollFormComponent;
let fixture: ComponentFixture<PollFormComponent>;
let component: PollFormComponent<any>;
let fixture: ComponentFixture<PollFormComponent<any>>;
beforeEach(async(() => {
TestBed.configureTestingModule({

View File

@ -12,6 +12,7 @@ import { PercentBase } from 'app/shared/models/poll/base-poll';
import { PollType } from 'app/shared/models/poll/base-poll';
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,
PercentBaseVerbose,
@ -28,7 +29,7 @@ import { PollService } from '../../services/poll.service';
templateUrl: './poll-form.component.html',
styleUrls: ['./poll-form.component.scss']
})
export class PollFormComponent extends BaseViewComponent implements OnInit {
export class PollFormComponent<T extends ViewBasePoll> extends BaseViewComponent implements OnInit {
/**
* The form-group for the meta-info.
*/
@ -44,7 +45,7 @@ export class PollFormComponent extends BaseViewComponent implements OnInit {
public pollMethods: { [key: string]: string };
@Input()
public data: Partial<ViewBasePoll>;
public data: Partial<T>;
/**
* The different types the poll can accept.
@ -103,18 +104,15 @@ export class PollFormComponent extends BaseViewComponent implements OnInit {
public ngOnInit(): void {
this.groupObservable = this.groupRepo.getViewModelListObservable();
const cast = <ViewAssignmentPoll>this.data;
if (cast.assignment && !cast.votes_amount) {
cast.votes_amount = cast.assignment.open_posts;
}
if (this.data) {
if (!this.data.groups_id) {
if (this.data.collectionString === ViewAssignmentPoll.COLLECTIONSTRING) {
this.data.groups_id = this.configService.instant('assignment_poll_default_groups');
} else {
this.data.groups_id = this.configService.instant('motion_poll_default_groups');
if (this.data instanceof ViewAssignmentPoll) {
if (this.data.assignment && !this.data.votes_amount) {
this.data.votes_amount = this.data.assignment.open_posts;
}
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 => {
@ -193,26 +191,31 @@ export class PollFormComponent extends BaseViewComponent implements OnInit {
* @param data Passing the properties of the poll.
*/
private updatePollValues(data: { [key: string]: any }): void {
this.pollValues = [
[this.pollService.getVerboseNameForKey('type'), this.pollService.getVerboseNameForValue('type', data.type)]
];
// show pollmethod only for assignment polls
if (this.data.pollClassType === PollClassType.Assignment) {
this.pollValues.push([
this.pollService.getVerboseNameForKey('pollmethod'),
this.pollService.getVerboseNameForValue('pollmethod', data.pollmethod)
]);
}
if (data.type !== 'analog') {
this.pollValues.push([
this.pollService.getVerboseNameForKey('groups'),
this.groupRepo.getNameForIds(...([] || (data && data.groups_id)))
]);
}
if (data.pollmethod === 'votes') {
this.pollValues.push([this.pollService.getVerboseNameForKey('votes_amount'), data.votes_amount]);
this.pollValues.push([this.pollService.getVerboseNameForKey('global_no'), data.global_no]);
this.pollValues.push([this.pollService.getVerboseNameForKey('global_abstain'), data.global_abstain]);
if (this.data) {
this.pollValues = [
[
this.pollService.getVerboseNameForKey('type'),
this.pollService.getVerboseNameForValue('type', data.type)
]
];
// show pollmethod only for assignment polls
if (this.data.pollClassType === PollClassType.Assignment) {
this.pollValues.push([
this.pollService.getVerboseNameForKey('pollmethod'),
this.pollService.getVerboseNameForValue('pollmethod', data.pollmethod)
]);
}
if (data.type !== 'analog') {
this.pollValues.push([
this.pollService.getVerboseNameForKey('groups'),
this.groupRepo.getNameForIds(...([] || (data && data.groups_id)))
]);
}
if (data.pollmethod === 'votes') {
this.pollValues.push([this.pollService.getVerboseNameForKey('votes_amount'), data.votes_amount]);
this.pollValues.push([this.pollService.getVerboseNameForKey('global_no'), data.global_no]);
this.pollValues.push([this.pollService.getVerboseNameForKey('global_abstain'), data.global_abstain]);
}
}
}

View File

@ -1,7 +1,12 @@
<span>
{{ 'Casted votes' | translate }}: {{ poll.votescast }} / {{ max }},
{{ 'valid votes' | translate}}: {{ poll.votesvalid }} / {{ max }},
{{ 'invalid votes' | translate}}: {{ poll.votesinvalid }} / {{ max }}
</span>
<div *ngIf="poll" class="poll-progress-wrapper">
<div class="motion-vote-number" *ngIf="poll.pollClassType === 'motion'">
<span>{{ poll.votescast }} / {{ max }}</span>
</div>
<mat-progress-bar class="voting-progress-bar" [value]="valueInPercent"></mat-progress-bar>
<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>
<mat-progress-bar class="voting-progress-bar" [value]="valueInPercent"></mat-progress-bar>
</div>

View File

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

View File

@ -10,7 +10,8 @@ describe('PollProgressComponent', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule]
imports: [E2EImportsModule],
declarations: [PollProgressComponent]
}).compileComponents();
}));

View File

@ -30,7 +30,11 @@ export class PollProgressComponent extends BaseViewComponent implements OnInit {
}
public get valueInPercent(): number {
return (this.poll.votesvalid / this.max) * 100;
if (this.poll) {
return (this.poll.votesvalid / this.max) * 100;
} else {
return 0;
}
}
/**
@ -38,15 +42,17 @@ export class PollProgressComponent extends BaseViewComponent implements OnInit {
* Sets the observable for groups.
*/
public ngOnInit(): void {
this.userRepo
.getViewModelListObservable()
.pipe(
map(users =>
users.filter(user => user.is_present && this.poll.groups_id.intersect(user.groups_id).length)
if (this.poll) {
this.userRepo
.getViewModelListObservable()
.pipe(
map(users =>
users.filter(user => user.is_present && this.poll.groups_id.intersect(user.groups_id).length)
)
)
)
.subscribe(users => {
this.max = users.length;
});
.subscribe(users => {
this.max = users.length;
});
}
}
}

View File

@ -0,0 +1,5 @@
import { ViewBasePoll } from './view-base-poll';
export interface HasViewPolls<T extends ViewBasePoll> {
polls: T[];
}

View File

@ -1,4 +1,4 @@
import { BasePoll, PollState } from 'app/shared/models/poll/base-poll';
import { BasePoll, PercentBase, PollType } from 'app/shared/models/poll/base-poll';
import { ViewAssignmentOption } from 'app/site/assignments/models/view-assignment-option';
import { BaseProjectableViewModel } from 'app/site/base/base-projectable-view-model';
import { BaseViewModel } from 'app/site/base/base-view-model';
@ -16,14 +16,16 @@ export enum PollClassType {
* Interface describes the possible data for the result-table.
*/
export interface PollTableData {
key?: string;
value?: number;
yes?: number;
no?: number;
abstain?: number;
user?: string;
canHide?: boolean;
votingOption: string;
votingOptionSubtitle?: string;
value: VotingResult[];
}
export interface VotingResult {
vote?: 'yes' | 'no' | 'abstain' | 'votesvalid' | 'votesinvalid' | 'votescast';
amount?: number;
icon?: string;
hide?: boolean;
showPercent?: boolean;
}
@ -35,7 +37,7 @@ export const PollClassTypeVerbose = {
export const PollStateVerbose = {
1: 'Created',
2: 'Started',
3: 'Finished',
3: 'Finished (unpublished)',
4: 'Published'
};
@ -85,6 +87,41 @@ export const PercentBaseVerbose = {
export abstract class ViewBasePoll<M extends BasePoll<M, any> = any> extends BaseProjectableViewModel<M> {
private _tableData: PollTableData[] = [];
protected voteTableKeys: VotingResult[] = [
{
vote: 'yes',
icon: 'thumb_up',
showPercent: true
},
{
vote: 'no',
icon: 'thumb_down',
showPercent: true
},
{
vote: 'abstain',
icon: 'trip_origin',
showPercent: this.showAbstainPercent
}
];
protected sumTableKeys: VotingResult[] = [
{
vote: 'votesvalid',
showPercent: this.poll.isPercentBaseValidOrCast
},
{
vote: 'votesinvalid',
hide: this.poll.type !== PollType.Analog,
showPercent: this.poll.isPercentBaseValidOrCast
},
{
vote: 'votescast',
hide: this.poll.type !== PollType.Analog,
showPercent: this.poll.isPercentBaseValidOrCast
}
];
public get tableData(): PollTableData[] {
if (!this._tableData.length) {
this._tableData = this.generateTableData();
@ -108,6 +145,10 @@ export abstract class ViewBasePoll<M extends BasePoll<M, any> = any> extends Bas
return PollStateVerbose[this.state];
}
public get nextStateActionVerbose(): string {
return PollStateChangeActionVerbose[this.nextState];
}
public get typeVerbose(): string {
return PollTypeVerbose[this.type];
}
@ -120,23 +161,14 @@ export abstract class ViewBasePoll<M extends BasePoll<M, any> = any> extends Bas
return PercentBaseVerbose[this.onehundred_percent_base];
}
public get showAbstainPercent(): boolean {
return this.onehundred_percent_base === PercentBase.YNA;
}
public abstract readonly pollClassType: 'motion' | 'assignment';
public canBeVotedFor: () => boolean;
/**
* returns a mapping "verbose_state" -> "state_id" for all valid next states
*/
public getNextStates(): { [key: number]: string } {
const next_state = (this.state % Object.keys(PollStateVerbose).length) + 1;
const states = {};
states[PollStateChangeActionVerbose[next_state]] = next_state;
if (this.state === PollState.Finished) {
states[PollStateChangeActionVerbose[PollState.Created]] = PollState.Created;
}
return states;
}
public get user_has_voted_invalid(): boolean {
return this.options.some(option => option.user_has_voted) && !this.user_has_voted_valid;
}

View File

@ -3,16 +3,14 @@ import { Injectable } from '@angular/core';
import { _ } from 'app/core/translate/translation-marker';
import { ChartData, ChartType } from 'app/shared/components/charts/charts.component';
import { AssignmentPollMethods } from 'app/shared/models/assignments/assignment-poll';
import { Collection } from 'app/shared/models/base/collection';
import { MotionPollMethods } from 'app/shared/models/motions/motion-poll';
import { MajorityMethod, PercentBase, PollColor, PollType } from 'app/shared/models/poll/base-poll';
import { BasePoll, MajorityMethod, PercentBase, PollColor, PollType } from 'app/shared/models/poll/base-poll';
import { AssignmentPollMethodsVerbose } from 'app/site/assignments/models/view-assignment-poll';
import {
MajorityMethodVerbose,
PercentBaseVerbose,
PollPropertyVerbose,
PollTypeVerbose,
ViewBasePoll
PollTypeVerbose
} from 'app/site/polls/models/view-base-poll';
import { ConstantsService } from '../../../core/core-services/constants.service';
@ -129,6 +127,11 @@ export abstract class PollService {
*/
public abstract defaultMajorityMethod: MajorityMethod;
/**
* Per default entitled to vote
*/
public abstract defaultGroupIds: number[];
/**
* The majority method currently in use
*/
@ -151,10 +154,13 @@ export abstract class PollService {
* Assigns the default poll data to the object. To be extended in subclasses
* @param poll the poll/object to fill
*/
public fillDefaultPollData(poll: Partial<ViewBasePoll> & Collection): void {
poll.onehundred_percent_base = this.defaultPercentBase;
poll.majority_method = this.defaultMajorityMethod;
poll.type = PollType.Analog;
public getDefaultPollData(): Partial<BasePoll> {
return {
onehundred_percent_base: this.defaultPercentBase,
majority_method: this.defaultMajorityMethod,
groups_id: this.defaultGroupIds,
type: PollType.Analog
};
}
public getVerboseNameForValue(key: string, value: string): string {

View File

@ -48,6 +48,7 @@ export class ViewUser extends BaseProjectableViewModel<User> implements UserTitl
// Will be set by the repository
public getFullName: () => string;
public getShortName: () => string;
public getLevelAndNumber: () => string;
/**
* Formats the category for search

View File

@ -5,7 +5,6 @@ export interface AssignmentSlideData {
open_posts: number;
assignment_related_users: {
user: string;
elected: boolean;
}[];
number_poll_candidates: boolean;
}

View File

@ -11,13 +11,11 @@
<ol *ngIf="data.data.number_poll_candidates">
<li *ngFor="let candidate of data.data.assignment_related_users">
{{ candidate.user }}
<mat-icon *ngIf="candidate.elected">star</mat-icon>
</li>
</ol>
<ul *ngIf="!data.data.number_poll_candidates">
<li *ngFor="let candidate of data.data.assignment_related_users">
{{ candidate.user }}
<mat-icon *ngIf="candidate.elected">star</mat-icon>
</li>
</ul>
</ng-container>

View File

@ -99,6 +99,10 @@
font-weight: 400;
}
.user-subtitle {
color: mat-color($foreground, secondary-text);
}
mat-card-header {
background-color: mat-color($background, app-bar);
}
@ -162,10 +166,6 @@
align-items: stretch;
}
.mat-progress-bar-buffer {
background-color: mat-color($background, card) !important;
}
.primary-foreground {
color: mat-color($primary);
}

View File

@ -1,6 +1,10 @@
/**
* Define the colors used for yes, no and abstain
*/
$votes-yes-color: #9fd773;
$votes-yes-color: #4caf50;
$votes-no-color: #cc6c5b;
$votes-abstain-color: #a6a6a6;
$vote-active-color: white;
$poll-create-color: #4caf50;
$poll-stop-color: #ff5252;
$poll-publish-color: #e6b100;

View File

@ -1,63 +0,0 @@
.poll-result {
.poll-progress-bar {
height: 5px;
width: 100%;
.mat-progress-bar {
height: 100%;
width: 100%;
}
}
.poll-progress {
display: flex;
margin-bottom: 15px;
margin-top: 15px;
mat-icon {
min-width: 40px;
margin-right: 5px;
}
.progress-container {
width: 85%;
}
}
}
.poll-progress-bar {
mat-progress-bar {
&.progress-green {
.mat-progress-bar-fill::after {
background-color: #4caf50;
}
.mat-progress-bar-buffer {
background-color: #d5ecd5;
}
}
&.progress-red {
.mat-progress-bar-fill::after {
background-color: #f44336;
}
.mat-progress-bar-buffer {
background-color: #fcd2cf;
}
}
&.progress-yellow {
.mat-progress-bar-fill::after {
background-color: #ffc107;
}
.mat-progress-bar-buffer {
background-color: #fff0c4;
}
}
}
}
.poll-quorum-line {
display: flex;
vertical-align: bottom;
.mat-button {
padding: 1px;
}
}
.main-nav-color {
color: rgba(0, 0, 0, 0.54);
}

View File

@ -30,6 +30,7 @@
@import './app/shared/components/banner/banner.component.scss-theme.scss';
@import './app/site/motions/modules/motion-poll/motion-poll/motion-poll.component.scss-theme.scss';
@import './app/site/motions/modules/motion-poll/motion-poll-detail/motion-poll-detail.component.scss-theme.scss';
@import './app/site/assignments/components/assignment-poll-detail/assignment-poll-detail-component.scss-theme.scss';
/** fonts */
@import './assets/styles/fonts.scss';
@ -60,6 +61,7 @@ $narrow-spacing: (
@include os-banner-style($theme);
@include os-motion-poll-style($theme);
@include os-motion-poll-detail-style($theme);
@include os-assignment-poll-detail-style($theme);
}
/** Load projector specific SCSS values */

View File

@ -51,12 +51,26 @@ def get_config_variables():
subgroup="Voting",
)
yield ConfigVariable(
name="assignment_poll_method",
default_value="votes",
input_type="choice",
label="Preselected poll method",
choices=tuple(
{"value": method[0], "display_name": method[1]}
for method in AssignmentPoll.POLLMETHODS
),
weight=415,
group="Elections",
subgroup="Voting",
)
yield ConfigVariable(
name="assignment_poll_add_candidates_to_list_of_speakers",
default_value=True,
input_type="boolean",
label="Put all candidates on the list of speakers",
weight=415,
weight=420,
group="Elections",
subgroup="Voting",
)

View File

@ -175,7 +175,12 @@ class Migration(migrations.Migration):
model_name="assignmentpoll",
name="pollmethod",
field=models.CharField(
choices=[("YN", "YN"), ("YNA", "YNA"), ("votes", "votes")], max_length=5
choices=[
("votes", "Yes per candidate"),
("YN", "Yes/No per candidate"),
("YNA", "Yes/No/Abstain per candidate"),
],
max_length=5,
),
),
migrations.AlterField(

View File

@ -20,4 +20,5 @@ class Migration(migrations.Migration):
migrations.RemoveField(model_name="assignmentpoll", name="votesabstain"),
migrations.RemoveField(model_name="assignmentpoll", name="votesno"),
migrations.RemoveField(model_name="assignmentpoll", name="published"),
migrations.RemoveField(model_name="assignmentrelateduser", name="elected",),
]

View File

@ -41,11 +41,6 @@ class AssignmentRelatedUser(RESTModelMixin, models.Model):
ForeinKey to the user who is related to the assignment.
"""
elected = models.BooleanField(default=False)
"""
Saves the election state of each user
"""
weight = models.IntegerField(default=0)
"""
The sort order of the candidates.
@ -141,7 +136,7 @@ class Assignment(RESTModelMixin, AgendaItemWithListOfSpeakersMixin, models.Model
settings.AUTH_USER_MODEL, through="AssignmentRelatedUser"
)
"""
Users that are candidates or elected.
Users that are candidates.
See AssignmentRelatedUser for more information.
"""
@ -180,14 +175,7 @@ class Assignment(RESTModelMixin, AgendaItemWithListOfSpeakersMixin, models.Model
"""
Queryset that represents the candidates for the assignment.
"""
return self.related_users.filter(assignmentrelateduser__elected=False)
@property
def elected(self):
"""
Queryset that represents all elected users for the assignment.
"""
return self.related_users.filter(assignmentrelateduser__elected=True)
return self.related_users.all()
def is_candidate(self, user):
"""
@ -197,14 +185,6 @@ class Assignment(RESTModelMixin, AgendaItemWithListOfSpeakersMixin, models.Model
"""
return self.candidates.filter(pk=user.pk).exists()
def is_elected(self, user):
"""
Returns True if the user is elected for this assignment.
Costs one database query.
"""
return self.elected.filter(pk=user.pk).exists()
def add_candidate(self, user):
"""
Adds the user as candidate.
@ -213,17 +193,9 @@ class Assignment(RESTModelMixin, AgendaItemWithListOfSpeakersMixin, models.Model
self.assignment_related_users.aggregate(models.Max("weight"))["weight__max"]
or 0
)
defaults = {"elected": False, "weight": weight + 1}
defaults = {"weight": weight + 1}
self.assignment_related_users.update_or_create(user=user, defaults=defaults)
def set_elected(self, user):
"""
Makes user an elected user for this assignment.
"""
self.assignment_related_users.update_or_create(
user=user, defaults={"elected": True}
)
def remove_candidate(self, user):
"""
Delete the connection from the assignment to the user.
@ -348,7 +320,11 @@ class AssignmentPoll(RESTModelMixin, BasePoll):
POLLMETHOD_YN = "YN"
POLLMETHOD_YNA = "YNA"
POLLMETHOD_VOTES = "votes"
POLLMETHODS = (("YN", "YN"), ("YNA", "YNA"), ("votes", "votes"))
POLLMETHODS = (
(POLLMETHOD_VOTES, "Yes per candidate"),
(POLLMETHOD_YN, "Yes/No per candidate"),
(POLLMETHOD_YNA, "Yes/No/Abstain per candidate"),
)
pollmethod = models.CharField(max_length=5, choices=POLLMETHODS)
PERCENT_BASE_YN = "YN"
@ -404,7 +380,7 @@ class AssignmentPoll(RESTModelMixin, BasePoll):
def create_options(self, skip_autoupdate=False):
related_users = AssignmentRelatedUser.objects.filter(
assignment__id=self.assignment.id
).exclude(elected=True)
)
for related_user in related_users:
option = AssignmentOption(

View File

@ -19,10 +19,7 @@ async def assignment_slide(
assignment = get_model(all_data, "assignments/assignment", element.get("id"))
assignment_related_users: List[Dict[str, Any]] = [
{
"user": await get_user_name(all_data, aru["user_id"]),
"elected": aru["elected"],
}
{"user": await get_user_name(all_data, aru["user_id"])}
for aru in sorted(
assignment["assignment_related_users"], key=lambda aru: aru["weight"]
)

View File

@ -45,7 +45,7 @@ class AssignmentRelatedUserSerializer(ModelSerializer):
class Meta:
model = AssignmentRelatedUser
fields = ("id", "user", "elected", "weight")
fields = ("id", "user", "weight")
class AssignmentVoteSerializer(BaseVoteSerializer):

View File

@ -32,8 +32,7 @@ class AssignmentViewSet(ModelViewSet):
API endpoint for assignments.
There are the following views: metadata, list, retrieve, create,
partial_update, update, destroy, candidature_self, candidature_other,
mark_elected and create_poll.
partial_update, update, destroy, candidature_self, candidature_other and create_poll.
"""
access_permissions = AssignmentAccessPermissions()
@ -53,7 +52,6 @@ class AssignmentViewSet(ModelViewSet):
"partial_update",
"update",
"destroy",
"mark_elected",
"sort_related_users",
):
result = has_perm(self.request.user, "assignments.can_see") and has_perm(
@ -81,8 +79,6 @@ class AssignmentViewSet(ModelViewSet):
candidature (DELETE).
"""
assignment = self.get_object()
if assignment.is_elected(request.user):
raise ValidationError({"detail": "You are already elected."})
if request.method == "POST":
message = self.nominate_self(request, assignment)
else:
@ -132,8 +128,7 @@ class AssignmentViewSet(ModelViewSet):
def get_user_from_request_data(self, request):
"""
Helper method to get a specific user from request data (not the
request.user) so that the views self.candidature_other or
self.mark_elected can play with it.
request.user) so that the view self.candidature_other can play with it.
"""
if not isinstance(request.data, dict):
raise ValidationError(
@ -172,10 +167,6 @@ class AssignmentViewSet(ModelViewSet):
return self.delete_other(request, user, assignment)
def nominate_other(self, request, user, assignment):
if assignment.is_elected(user):
raise ValidationError(
{"detail": "User {0} is already elected.", "args": [str(user)]}
)
if assignment.phase == assignment.PHASE_FINISHED:
raise ValidationError(
{
@ -209,7 +200,7 @@ class AssignmentViewSet(ModelViewSet):
"detail": "You can not delete someone's candidature to this election because it is finished."
}
)
if not assignment.is_candidate(user) and not assignment.is_elected(user):
if not assignment.is_candidate(user):
raise ValidationError(
{
"detail": "User {0} has no status in this election.",
@ -221,37 +212,6 @@ class AssignmentViewSet(ModelViewSet):
{"detail": "Candidate {0} was withdrawn successfully.", "args": [str(user)]}
)
@detail_route(methods=["post", "delete"])
def mark_elected(self, request, pk=None):
"""
View to mark other users as elected (POST) or undo this (DELETE).
The client has to send {'user': <id>}.
"""
user = self.get_user_from_request_data(request)
assignment = self.get_object()
if request.method == "POST":
if not assignment.is_candidate(user):
raise ValidationError(
{
"detail": "User {0} is not a candidate of this election.",
"args": [str(user)],
}
)
assignment.set_elected(user)
message = "User {0} was successfully elected."
else:
# request.method == 'DELETE'
if not assignment.is_elected(user):
raise ValidationError(
{
"detail": "User {0} is not an elected candidate of this election.",
"args": [str(user)],
}
)
assignment.add_candidate(user)
message = "User {0} was successfully unelected."
return Response({"detail": message, "args": [str(user)]})
@detail_route(methods=["post"])
def sort_related_users(self, request, pk=None):
"""

View File

@ -401,52 +401,3 @@ class CandidatureOther(TestCase):
)
self.assertEqual(response.status_code, 403)
class MarkElectedOtherUser(TestCase):
"""
Tests marking an elected user. We use an extra user here to show that
admin can not only mark himself but also other users.
"""
def setUp(self):
self.client = APIClient()
self.client.login(username="admin", password="admin")
self.assignment = Assignment.objects.create(
title="test_assignment_Ierohsh8rahshofiejai", open_posts=1
)
self.user = get_user_model().objects.create_user(
username="test_user_Oonei3rahji5jugh1eev",
password="test_password_aiphahb5Nah0cie4iP7o",
)
def test_mark_elected(self):
self.assignment.add_candidate(
get_user_model().objects.get(username="test_user_Oonei3rahji5jugh1eev")
)
response = self.client.post(
reverse("assignment-mark-elected", args=[self.assignment.pk]),
{"user": self.user.pk},
)
self.assertEqual(response.status_code, 200)
self.assertTrue(
Assignment.objects.get(pk=self.assignment.pk)
.elected.filter(username="test_user_Oonei3rahji5jugh1eev")
.exists()
)
def test_mark_unelected(self):
user = get_user_model().objects.get(username="test_user_Oonei3rahji5jugh1eev")
self.assignment.set_elected(user)
response = self.client.delete(
reverse("assignment-mark-elected", args=[self.assignment.pk]),
{"user": self.user.pk},
)
self.assertEqual(response.status_code, 200)
self.assertFalse(
Assignment.objects.get(pk=self.assignment.pk)
.elected.filter(username="test_user_Oonei3rahji5jugh1eev")
.exists()
)