assignment improvements

- layouting
- fix one-vote polls
- filter candidate list
- candidate sorting
- fix adding/removing of candidates
- avoid ui jumping
- fix quorum calculations
This commit is contained in:
Maximilian Krambach 2019-04-08 10:32:18 +02:00
parent 0f1df91915
commit 9dfac94099
21 changed files with 841 additions and 458 deletions

View File

@ -93,33 +93,40 @@ export class AssignmentRepositoryService extends BaseAgendaContentObjectReposito
private createViewAssignmentRelatedUsers(
assignmentRelatedUsers: AssignmentRelatedUser[]
): ViewAssignmentRelatedUser[] {
return assignmentRelatedUsers.map(aru => {
const user = this.viewModelStoreService.get(ViewUser, aru.user_id);
return new ViewAssignmentRelatedUser(aru, user);
});
return assignmentRelatedUsers
.map(aru => {
const user = this.viewModelStoreService.get(ViewUser, aru.user_id);
return new ViewAssignmentRelatedUser(aru, user);
})
.sort((a, b) => a.weight - b.weight);
}
private createViewAssignmentPolls(assignmentPolls: AssignmentPoll[]): ViewAssignmentPoll[] {
return assignmentPolls.map(poll => {
const options = poll.options.map(option => {
const user = this.viewModelStoreService.get(ViewUser, option.candidate_id);
return new ViewAssignmentPollOption(option, user);
});
const options = poll.options
.map(option => {
const user = this.viewModelStoreService.get(ViewUser, option.candidate_id);
return new ViewAssignmentPollOption(option, user);
})
.sort((a, b) => a.weight - b.weight);
return new ViewAssignmentPoll(poll, options);
});
}
/**
* Adds another user as a candidate
* Adds/removes another user to/from the candidates list of an assignment
*
* @param userId User id of a candidate
* @param user A ViewUser
* @param assignment The assignment to add the candidate to
* @param adding optional boolean to force an add (true)/ remove (false)
* of the candidate. Else, the candidate will be added if not on the list,
* and removed if on the list
*/
public async changeCandidate(userId: number, assignment: ViewAssignment): Promise<void> {
const data = { user: userId };
if (assignment.candidates.some(candidate => candidate.id === userId)) {
public async changeCandidate(user: ViewUser, assignment: ViewAssignment, adding?: boolean): Promise<void> {
const data = { user: user.id };
if (assignment.candidates.some(candidate => candidate.id === user.id) && adding !== true) {
await this.httpService.delete(this.restPath + assignment.id + this.candidatureOtherPath, data);
} else {
} else if (adding !== false) {
await this.httpService.post(this.restPath + assignment.id + this.candidatureOtherPath, data);
}
}
@ -149,6 +156,7 @@ export class AssignmentRepositoryService extends BaseAgendaContentObjectReposito
*/
public async addPoll(assignment: ViewAssignment): Promise<void> {
await this.httpService.post(this.restPath + assignment.id + this.createPollPath);
// TODO: change current tab to new poll
}
/**
@ -185,7 +193,7 @@ export class AssignmentRepositoryService extends BaseAgendaContentObjectReposito
const votes = poll.options.map(option => {
switch (poll.pollmethod) {
case 'votes':
return { Votes: option.votes.find(v => v.value === 'Yes').weight };
return { Votes: option.votes.find(v => v.value === 'Votes').weight };
case 'yn':
return {
Yes: option.votes.find(v => v.value === 'Yes').weight,
@ -232,16 +240,14 @@ export class AssignmentRepositoryService extends BaseAgendaContentObjectReposito
}
/**
* Sorting the candidates
* TODO untested stub
* Sends a request to sort an assignment's candidates
*
* @param sortedCandidates
* @param sortedCandidates the id of the assignment related users (note: NOT viewUsers)
* @param assignment
*/
public async sortCandidates(sortedCandidates: any[], assignment: ViewAssignment): Promise<void> {
throw Error('TODO');
// const restPath = `/rest/assignments/assignment/${assignment.id}/sort_related_users`;
// const data = { related_users: sortedCandidates };
// await this.httpService.post(restPath, data);
public async sortCandidates(sortedCandidates: number[], assignment: ViewAssignment): Promise<void> {
const restPath = `/rest/assignments/assignment/${assignment.id}/sort_related_users/`;
const data = { related_users: sortedCandidates };
await this.httpService.post(restPath, data);
}
}

View File

@ -4,8 +4,7 @@ import { _ } from 'app/core/translate/translation-marker';
/**
* The possible keys of a poll object that represent numbers.
* TODO Should be 'key of MotionPoll if type of key is number'
* TODO: normalize MotionPoll model and other poll models
* TODO Should be 'key of MotionPoll|AssinmentPoll if type of key is number'
*/
export type CalculablePollKey = 'votesvalid' | 'votesinvalid' | 'votescast' | 'yes' | 'no' | 'abstain';
@ -13,12 +12,12 @@ export type CalculablePollKey = 'votesvalid' | 'votesinvalid' | 'votescast' | 'y
* TODO: may be obsolete if the server switches to lower case only
* (lower case variants are already in CalculablePollKey)
*/
export type PollVoteValue = 'Yes' | 'No' | 'Abstain';
export type PollVoteValue = 'Yes' | 'No' | 'Abstain' | 'Votes';
/**
* Interface representing possible majority calculation methods. The implementing
* calc function should return an integer number that must be reached for the
* option to reach the quorum, or null if disabled
* option to successfully fulfill the quorum, or null if disabled
*/
export interface MajorityMethod {
value: string;
@ -33,17 +32,26 @@ export const PollMajorityMethod: MajorityMethod[] = [
{
value: 'simple_majority',
display_name: 'Simple majority',
calc: base => Math.ceil(base * 0.5)
calc: base => {
const q = base * 0.5;
return Number.isInteger(q) ? q + 1 : Math.ceil(q);
}
},
{
value: 'two-thirds_majority',
display_name: 'Two-thirds majority',
calc: base => Math.ceil((base / 3) * 2)
calc: base => {
const q = (base / 3) * 2;
return Number.isInteger(q) ? q + 1 : Math.ceil(q);
}
},
{
value: 'three-quarters_majority',
display_name: 'Three-quarters majority',
calc: base => Math.ceil((base / 4) * 3)
calc: base => {
const q = (base / 4) * 3;
return Number.isInteger(q) ? q + 1 : Math.ceil(q);
}
},
{
value: 'disabled',
@ -95,6 +103,7 @@ export abstract class PollService {
/**
* empty constructor
*
*/
public constructor() {}

View File

@ -8,10 +8,7 @@
>
<!-- Title -->
<div class="title-slot">
<h2 *ngIf="assignment && !newAssignment">
<span *ngIf="!editAssignment">{{ assignment.getTitle() }}</span>
<span *ngIf="editAssignment">{{ assignmentForm.get('title').value }}</span>
</h2>
<h2 *ngIf="!newAssignment" translate>Election</h2>
<h2 *ngIf="newAssignment" translate>New election</h2>
</div>
@ -23,17 +20,17 @@
</div>
<mat-menu #assignmentDetailMenu="matMenu">
<!-- delete -->
<!-- print, edit, delete -->
<div *ngIf="assignment">
<!-- PDF -->
<button mat-menu-item (click)="onDownloadPdf()">
<!-- TODO: results or descritipon. Results if published -->
<!-- TODO: results or description. Results if published -->
<mat-icon>picture_as_pdf</mat-icon>
<span translate>PDF</span>
</button>
<mat-divider></mat-divider>
<!-- Delete -->
</div>
<div *ngIf="assignment && hasPerms('manage')">
<button mat-menu-item class="red-warning-text" (click)="onDeleteAssignmentButton()">
<mat-icon>delete</mat-icon>
<span translate>Delete</span>
@ -43,12 +40,6 @@
</os-head-bar>
<div class="content-container">
<!-- Title -->
<div class="title on-transition-fade" *ngIf="assignment && !editAssignment">
<div class="title-line">
<h1>{{ assignment.getTitle() }}</h1>
</div>
</div>
<ng-container *ngIf="vp.isMobile; then mobileView; else desktopView"></ng-container>
</div>
@ -57,119 +48,130 @@
</ng-template>
<ng-template #desktopView>
<mat-card class="os-card">
<div *ngIf="editAssignment">
<ng-container [ngTemplateOutlet]="assignmentFormTemplate"></ng-container>
</div>
<div *ngIf="!editAssignment">
<mat-card class="os-card">
<ng-container [ngTemplateOutlet]="metaInfoTemplate"></ng-container>
</mat-card>
<mat-card class="os-card">
<ng-container [ngTemplateOutlet]="contentTemplate"></ng-container>
</mat-card>
</div>
</mat-card>
<div *ngIf="editAssignment">
<ng-container [ngTemplateOutlet]="assignmentFormTemplate"></ng-container>
</div>
<div *ngIf="!editAssignment">
<ng-container [ngTemplateOutlet]="metaInfoTemplate"></ng-container>
<ng-container [ngTemplateOutlet]="contentTemplate"></ng-container>
</div>
</ng-template>
<ng-template #metaInfoTemplate>
<span translate>Meta information</span>
<div *ngIf="assignment; &quot;flex-spaced&quot;">
<mat-card class="os-card" *ngIf="assignment">
<h1>{{ assignment.getTitle() }}</h1>
<div *ngIf="assignment">
<div *ngIf="assignment.assignment.description" [innerHTML]="assignment.assignment.description"></div>
</div>
<div>
<span translate>Number of persons to be elected</span>:&nbsp;
<span>{{ assignment.assignment.open_posts }}</span>
</div>
<div>
<span> {{ phaseString | translate }}</span>
<mat-form-field>
<mat-label translate>Phase</mat-label>
<mat-select
class="selection"
[disabled]="!hasPerms('manage')"
(selectionChange)="setPhase($event)"
[value]="assignment.phase"
>
<mat-option *ngFor="let option of phaseOptions" [value]="option.value">
{{ option.display_name | translate }}
</mat-option>
</mat-select>
</mat-form-field>
<span translate>Phase</span>:&nbsp;
<mat-basic-chip *ngIf="hasPerms('manage')" [matMenuTriggerFor]="phaseMenu" class="bluegrey" disableRipple>
{{ assignment.phaseString | translate }}
</mat-basic-chip>
<mat-basic-chip *ngIf="!hasPerms('manage')" class="bluegrey" disableRipple>
{{ assignment.phaseString | translate }}
</mat-basic-chip>
<mat-menu #phaseMenu="matMenu">
<button *ngFor="let option of phaseOptions" mat-menu-item (click)="onSetPhaseButton(option.value)">
{{ option.display_name | translate }}
</button>
</mat-menu>
</div>
</div>
</mat-card>
</ng-template>
<ng-template #contentTemplate>
<ng-container *ngIf="assignment && assignment.phase !== 2" [ngTemplateOutlet]="candidatesTemplate">
</ng-container>
<!-- TODO related agenda item to create/updade: internal status; parent_id ? -->
<ng-container [ngTemplateOutlet]="pollTemplate"></ng-container>
<!-- TODO different status/display if finished -->
<mat-card class="os-card">
<ng-container *ngIf="assignment && !assignment.isFinished" [ngTemplateOutlet]="candidatesTemplate">
</ng-container>
<!-- TODO related agenda item to create/updade: internal status; parent_id ? -->
<ng-container [ngTemplateOutlet]="pollTemplate"></ng-container>
<!-- TODO different status/display if finished -->
</mat-card>
</ng-template>
<!-- poll template -->
<ng-template #pollTemplate>
<div>
<!-- TODO: header. New poll button to the right, and with icon -->
<!-- new poll button -->
<button type="button" mat-button *ngIf="hasPerms('createPoll')" (click)="createPoll()">
<span translate>Create poll</span>
</button>
</div>
<mat-tab-group (selectedTabChange)="onTabChange()" *ngIf="assignment && assignment.polls">
<mat-tab-group
(selectedTabChange)="onTabChange()"
*ngIf="assignment && assignment.polls && assignment.polls.length"
>
<!-- TODO avoid animation/switching on update -->
<mat-tab *ngFor="let poll of assignment.polls; let i = index" [label]="getPollLabel(poll, i)">
<mat-tab
*ngFor="let poll of assignment.polls; let i = index; trackBy: trackByIndex"
[label]="getPollLabel(poll, i)"
>
<os-assignment-poll [assignment]="assignment" [poll]="poll"> </os-assignment-poll>
</mat-tab>
</mat-tab-group>
</ng-template>
<ng-template #candidatesTemplate>
<!-- TODO two columns here: Left candidates; right add/remove -->
<div>
<div>
<div *ngIf="assignment && assignment.candidates">
<!-- TODO: Sorting -->
<div *ngFor="let candidate of assignment.candidates">
<span>{{ candidate.full_name }}</span>
<button mat-button *ngIf="hasPerms('addOthers')" (click)="removeUser(candidate)">Remove</button>
</div>
</div>
</div>
<div>
<div *ngIf="hasPerms('addOthers')">
<!-- candidates: if open: Select list, sortable (like list of speakers) -->
<os-search-value-selector
*ngIf="hasPerms('addOthers')"
ngDefaultControl
placeholder="Add candidate"
[formControl]="candidatesForm.get('candidate')"
[InputListValues]="availableCandidates"
[form]="candidatesForm"
>
</os-search-value-selector>
<button mat-button [disabled]="!candidatesForm.get('candidate').value" (click)="addUser()">
<span translate>add</span>
</button>
<!-- TODO disable if no user set; filter out users already in list of candidates -->
</div>
</div>
<!-- class="spacer-top-10" -->
<button
type="button"
mat-button
color="primary"
*ngIf="hasPerms('addSelf') && !isSelfCandidate"
(click)="addSelf()"
<!-- candidates -->
<div class="candidates-list" *ngIf="assignment && assignment.assignmentRelatedUsers && assignment.assignmentRelatedUsers.length > 0">
<os-sorting-list
[input]="assignment.assignmentRelatedUsers"
[live]="true"
[count]="true"
[enable]="hasPerms('addOthers')"
(sortEvent)="onSortingChange($event)"
>
<span class="upper" translate>Add me</span>
</button>
<br />
<button type="button" mat-button *ngIf="hasPerms('addSelf') && isSelfCandidate" (click)="removeSelf()">
<span translate>Remove me</span>
</button>
<br />
<!-- implicit item references into the component using ng-template slot -->
<ng-template let-item>
<span *ngIf="hasPerms('addOthers')">
<button
mat-icon-button
matTooltip="{{ 'Remove candidate' | translate }}"
(click)="removeUser(item)"
>
<mat-icon>clear</mat-icon>
</button>
</span>
</ng-template>
</os-sorting-list>
</div>
<!-- Search for candidates -->
<div *ngIf="hasPerms('addOthers')">
<form *ngIf="filteredCandidates && filteredCandidates.value.length > 0" [formGroup]="candidatesForm">
<os-search-value-selector
*ngIf="hasPerms('addOthers')"
ngDefaultControl
listname="{{ 'Select a new candidate' | translate }}"
[formControl]="candidatesForm.get('userId')"
[InputListValues]="filteredCandidates"
[form]="candidatesForm"
[multiple]="false"
>
<!-- TODO: better class here: wider -->
<!-- TODO: Performance check: something seems off here with filteredCandidates -->
</os-search-value-selector>
</form>
</div>
<!-- Add me and remove me if OP has correct permission -->
<div *ngIf="assignment && hasPerms('addSelf') && assignment.candidates" class="add-self-buttons">
<div>
<button mat-stroked-button (click)="addSelf()" *ngIf="!isSelfCandidate">
<mat-icon>add</mat-icon>
<span translate>Add me</span>
</button>
<button mat-stroked-button (click)="removeSelf()" *ngIf="isSelfCandidate">
<mat-icon>remove</mat-icon>
<span translate>Remove me</span>
</button>
</div>
</div>
<div *ngIf="assignment && hasPerms('createPoll')">
<mat-divider></mat-divider>
<button mat-button (click)="createPoll()">
<mat-icon color="primary">poll</mat-icon>
<span translate>New poll</span>
</button>
</div>
<mat-divider *ngIf="assignment && assignment.polls && assignment.polls.length"> </mat-divider>
</ng-template>
<ng-template #assignmentFormTemplate>
@ -180,15 +182,17 @@
(keydown)="onKeyDown($event)"
*ngIf="assignment && editAssignment"
>
<!-- title -->
<mat-form-field>
<input
matInput
placeholder="{{ 'Title' | translate }}"
formControlName="title"
[value]="assignmentCopy.title || ''"
/>
</mat-form-field>
<div>
<!-- title -->
<mat-form-field class="full-width">
<input
matInput
placeholder="{{ 'Title' | translate }}"
formControlName="title"
[value]="assignmentCopy.getTitle() || ''"
/>
</mat-form-field>
</div>
<!-- description: HTML Editor -->
<editor
@ -209,7 +213,7 @@
</div>
<!-- searchValueSelector: tags -->
<div class="content-field">
<div class="content-field" *ngIf="tagsAvailable">
<os-search-value-selector
ngDefaultControl
[form]="assignmentForm"
@ -222,7 +226,7 @@
</div>
<!-- searchValueSelector:agendaItem -->
<div class="content-field">
<div class="content-field" *ngIf="parentsAvailable">
<os-search-value-selector
ngDefaultControl
[form]="assignmentForm"
@ -235,23 +239,27 @@
</div>
<!-- poll_description_default -->
<mat-form-field>
<input
matInput
placeholder="{{ 'Default comment on the ballot paper' | translate }}"
formControlName="poll_description_default"
[value]="assignmentCopy.assignment.poll_description_default || ''"
/>
</mat-form-field>
<div>
<mat-form-field>
<input
matInput
placeholder="{{ 'Default comment on the ballot paper' | translate }}"
formControlName="poll_description_default"
[value]="assignmentCopy.assignment.poll_description_default || ''"
/>
</mat-form-field>
</div>
<!-- open posts: number -->
<mat-form-field>
<input
matInput
placeholder="{{ 'Number of persons to be elected' | translate }}"
formControlName="open_posts"
[value]="assignmentCopy.assignment.open_posts || null"
/>
</mat-form-field>
<div>
<mat-form-field>
<input
matInput
placeholder="{{ 'Number of persons to be elected' | translate }}"
formControlName="open_posts"
[value]="assignmentCopy.assignment.open_posts || null"
/>
</mat-form-field>
</div>
<!-- TODO searchValueSelector: Parent -->
</form>
</div>

View File

@ -0,0 +1,33 @@
.full-width {
width: 100%;
}
.candidates {
width: 60%;
}
.candidate {
display: flex;
width: 100%;
border-bottom: 1px solid lightgray;
vertical-align: top;
.name {
word-wrap: break-word;
flex: 1;
}
}
.top-aligned {
position: absolute;
top: 0;
left: 0;
}
// TODO: same as .waiting-list in list-of-speakers
.candidates-list {
padding: 10px 25px 0 25px;
}
// TODO: duplicate in list-of-speakers
.add-self-buttons {
padding: 15px 0 20px 25px;
}

View File

@ -1,6 +1,6 @@
import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms';
import { MatSnackBar, MatSelectChange } from '@angular/material';
import { MatSnackBar } from '@angular/material';
import { Router, ActivatedRoute } from '@angular/router';
import { Title } from '@angular/platform-browser';
@ -8,22 +8,22 @@ import { TranslateService } from '@ngx-translate/core';
import { BehaviorSubject } from 'rxjs';
import { Assignment } from 'app/shared/models/assignments/assignment';
import { AssignmentPoll } from 'app/shared/models/assignments/assignment-poll';
import { AssignmentPollService } from '../../services/assignment-poll.service';
import { AssignmentRepositoryService } from 'app/core/repositories/assignments/assignment-repository.service';
import { BaseViewComponent } from 'app/site/base/base-view';
import { ConstantsService } from 'app/core/core-services/constants.service';
import { ItemRepositoryService } from 'app/core/repositories/agenda/item-repository.service';
import { LocalPermissionsService } from 'app/site/motions/services/local-permissions.service';
import { OperatorService } from 'app/core/core-services/operator.service';
import { AssignmentPoll } from 'app/shared/models/assignments/assignment-poll';
import { PromptService } from 'app/core/ui-services/prompt.service';
import { TagRepositoryService } from 'app/core/repositories/tags/tag-repository.service';
import { UserRepositoryService } from 'app/core/repositories/users/user-repository.service';
import { ViewAssignment, AssignmentPhase } from '../../models/view-assignment';
import { ViewAssignment, AssignmentPhases } from '../../models/view-assignment';
import { ViewAssignmentRelatedUser } from '../../models/view-assignment-related-user';
import { ViewItem } from 'app/site/agenda/models/view-item';
import { ViewportService } from 'app/core/ui-services/viewport.service';
import { ViewTag } from 'app/site/tags/models/view-tag';
import { ViewUser } from 'app/site/users/models/view-user';
import { PromptService } from 'app/core/ui-services/prompt.service';
/**
* Component for the assignment detail view
@ -47,16 +47,17 @@ export class AssignmentDetailComponent extends BaseViewComponent implements OnIn
/**
* The different phases of an assignment. Info is fetched from server
*/
public phaseOptions: AssignmentPhase[] = [];
public phaseOptions = AssignmentPhases;
/**
* List of users (used in searchValueSelector for candidates)
* TODO Candidates already in the list should be filtered out
* List of users available as candidates (used as raw data for {@link filteredCandidates})
*/
public availableCandidates = new BehaviorSubject<ViewUser[]>([]);
private availableCandidates = new BehaviorSubject<ViewUser[]>([]);
/**
* TODO a filtered list (excluding users already in this.assignment.candidates)
* A BehaviourSubject with a filtered list of users (excluding users already
* in the list of candidates). It is updated each time {@link filterCandidates}
* is called (triggered by autoupdates)
*/
public filteredCandidates = new BehaviorSubject<ViewUser[]>([]);
@ -88,6 +89,8 @@ export class AssignmentDetailComponent extends BaseViewComponent implements OnIn
*/
public set assignment(assignment: ViewAssignment) {
this._assignment = assignment;
this.filterCandidates();
if (this.assignment.polls.length) {
this.assignment.polls.forEach(poll => {
poll.pollBase = this.pollService.getBaseAmount(poll);
@ -122,17 +125,21 @@ export class AssignmentDetailComponent extends BaseViewComponent implements OnIn
}
/**
* gets the current assignment phase as string
*
* @returns a matching string (untranslated)
* Checks if there are any tags available
*/
public get phaseString(): string {
const mapping = this.phaseOptions.find(ph => ph.value === this.assignment.phase);
return mapping ? mapping.display_name : '';
public get tagsAvailable(): boolean {
return this.tagsObserver.getValue().length > 0;
}
/**
* Constructor. Build forms and subscribe to needed configs, constants and updates
* Checks if there are any tags available
*/
public get parentsAvailable(): boolean {
return this.agendaObserver.getValue().length > 0;
}
/**
* Constructor. Build forms and subscribe to needed configs and updates
*
* @param title
* @param translate
@ -145,7 +152,6 @@ export class AssignmentDetailComponent extends BaseViewComponent implements OnIn
* @param formBuilder
* @param repo
* @param userRepo
* @param constants
* @param pollService
* @param agendaRepo
* @param tagRepo
@ -163,17 +169,19 @@ export class AssignmentDetailComponent extends BaseViewComponent implements OnIn
formBuilder: FormBuilder,
public repo: AssignmentRepositoryService,
private userRepo: UserRepositoryService,
private constants: ConstantsService,
public pollService: AssignmentPollService,
private agendaRepo: ItemRepositoryService,
private tagRepo: TagRepositoryService,
private promptService: PromptService
) {
super(title, translate, matSnackBar);
/* Server side constants for phases */
this.constants.get<AssignmentPhase[]>('AssignmentPhases').subscribe(phases => (this.phaseOptions = phases));
/* List of eligible users */
this.userRepo.getViewModelListObservable().subscribe(users => this.availableCandidates.next(users));
this.subscriptions.push(
/* List of eligible users */
this.userRepo.getViewModelListObservable().subscribe(users => {
this.availableCandidates.next(users);
this.filterCandidates();
})
);
this.assignmentForm = formBuilder.group({
phase: null,
tags_id: [],
@ -184,7 +192,7 @@ export class AssignmentDetailComponent extends BaseViewComponent implements OnIn
agenda_item_id: '' // create agenda item
});
this.candidatesForm = formBuilder.group({
candidate: null
userId: null
});
}
@ -201,9 +209,9 @@ export class AssignmentDetailComponent extends BaseViewComponent implements OnIn
* Permission check for interactions.
*
* Current operations supported:
* - addSelf: the user can add themself to the list of candidates
* - addOthers: the user can add other candidates
* - createPoll: the user can add/edit election poll (requires candidates to be present)
* - addSelf: the user can add/remove themself to the list of candidates
* - addOthers: the user can add/remove other candidates
* - createPoll: the user can add/edit an election poll (requires candidates to be present)
* - manage: the user has general manage permissions (i.e. editing the assignment metaInfo)
*
* @param operation the action requested
@ -213,20 +221,28 @@ export class AssignmentDetailComponent extends BaseViewComponent implements OnIn
const isManager = this.operator.hasPerms('assignments.can_manage');
switch (operation) {
case 'addSelf':
if (isManager && this.assignment.phase !== 2) {
if (isManager && !this.assignment.isFinished) {
return true;
} else {
return this.assignment.phase === 0 && this.operator.hasPerms('assignments.can_nominate_self');
return (
this.assignment.isSearchingForCandidates &&
this.operator.hasPerms('assignments.can_nominate_self') &&
!this.assignment.isFinished
);
}
case 'addOthers':
if (isManager && this.assignment.phase !== 2) {
if (isManager && !this.assignment.isFinished) {
return true;
} else {
return this.assignment.phase === 0 && this.operator.hasPerms('assignments.can_nominate_others');
return (
this.assignment.isSearchingForCandidates &&
this.operator.hasPerms('assignments.can_nominate_others') &&
!this.assignment.isFinished
);
}
case 'createPoll':
return (
isManager && this.assignment && this.assignment.phase !== 2 && this.assignment.candidateAmount > 0
isManager && this.assignment && !this.assignment.isFinished && this.assignment.candidateAmount > 0
);
case 'manage':
return isManager;
@ -261,6 +277,7 @@ export class AssignmentDetailComponent extends BaseViewComponent implements OnIn
private patchForm(assignment: ViewAssignment): void {
this.assignmentCopy = assignment;
this.assignmentForm.patchValue({
title: assignment.title || '',
tags_id: assignment.assignment.tags_id || [],
agendaItem: assignment.assignment.agenda_item_id || null,
phase: assignment.phase, // todo default: 0?
@ -304,23 +321,24 @@ export class AssignmentDetailComponent extends BaseViewComponent implements OnIn
}
/**
* Adds a user to the list of candidates
* Adds the user from the candidates form to the list of candidates
*
* @param userId the id of a ViewUser
*/
public async addUser(): Promise<void> {
const candId = this.candidatesForm.get('candidate').value;
this.candidatesForm.setValue({ candidate: null });
if (candId) {
await this.repo.changeCandidate(candId, this.assignment).then(null, this.raiseError);
public async addUser(userId: number): Promise<void> {
const user = this.userRepo.getViewModel(userId);
if (user) {
await this.repo.changeCandidate(user, this.assignment, true).then(null, this.raiseError);
}
}
/**
* Removes a user from the list of candidates
*
* @param user Assignment User
* @param candidate A ViewAssignmentUser currently in the list of related users
*/
public async removeUser(user: ViewUser): Promise<void> {
await this.repo.changeCandidate(user.id, this.assignment).then(null, this.raiseError);
public async removeUser(candidate: ViewAssignmentRelatedUser): Promise<void> {
await this.repo.changeCandidate(candidate.user, this.assignment, false).then(null, this.raiseError);
}
/**
@ -342,6 +360,14 @@ export class AssignmentDetailComponent extends BaseViewComponent implements OnIn
}
})
);
this.subscriptions.push(
this.candidatesForm.valueChanges.subscribe(formResult => {
// resetting a form triggers a form.next(null) - check if data is present
if (formResult && formResult.userId) {
this.addUser(formResult.userId);
}
})
);
} else {
this.newAssignment = true;
// TODO set defaults?
@ -357,8 +383,7 @@ export class AssignmentDetailComponent extends BaseViewComponent implements OnIn
public async onDeleteAssignmentButton(): Promise<void> {
const title = this.translate.instant('Are you sure you want to delete this election?');
if (await this.promptService.open(title, this.assignment.getTitle())) {
await this.repo.delete(this.assignment);
this.router.navigate(['../assignments/']);
this.repo.delete(this.assignment).then(() => this.router.navigate(['../']), this.raiseError);
}
}
@ -374,12 +399,10 @@ export class AssignmentDetailComponent extends BaseViewComponent implements OnIn
* TODO: only with existing assignments, else it should fail
* TODO check permissions and conditions
*
* @param event
* @param value the phase to set
*/
public async setPhase(event: MatSelectChange): Promise<void> {
if (!this.newAssignment && this.phaseOptions.find(option => option.value === event.value)) {
this.repo.update({ phase: event.value }, this.assignment).then(null, this.raiseError);
}
public async onSetPhaseButton(value: number): Promise<void> {
this.repo.update({ phase: value }, this.assignment).then(null, this.raiseError);
}
public onDownloadPdf(): void {
@ -426,11 +449,48 @@ export class AssignmentDetailComponent extends BaseViewComponent implements OnIn
/**
* Assemble a meaningful label for the poll
* TODO (currently e.g. 'Ballot 10 (unublished)')
* Published polls will look like 'Ballot 2 (published)'
* other polls will be named 'Ballot 2' for normal users, with the hint
* '(unpulished)' appended for manager users
*
* @param poll
* @param index the index of the poll relative to the assignment
*/
public getPollLabel(poll: AssignmentPoll, index: number): string {
const pubState = poll.published ? this.translate.instant('published') : this.translate.instant('unpublished');
const title = this.translate.instant('Ballot');
return `${title} ${index + 1} (${pubState})`;
const title = `${this.translate.instant('Ballot')} ${index + 1}`;
if (poll.published) {
return title + ` (${this.translate.instant('published')})`;
} else {
if (this.hasPerms('manage')) {
return title + ` (${this.translate.instant('unpublished')})`;
} else {
return title;
}
}
}
/**
* Triggers an update of the filter for the list of available candidates
* (triggered on an autoupdate of either users or the assignment)
*/
private filterCandidates(): void {
if (!this.assignment || !this.assignment.candidates) {
this.filteredCandidates.next(this.availableCandidates.getValue());
} else {
this.filteredCandidates.next(
this.availableCandidates
.getValue()
.filter(u => !this.assignment.candidates.some(cand => cand.id === u.id))
);
}
}
/**
* Triggers an update of the sorting.
*/
public onSortingChange(listInNewOrder: ViewAssignmentRelatedUser[]): void {
this.repo
.sortCandidates(listInNewOrder.map(relatedUser => relatedUser.id), this.assignment)
.then(null, this.raiseError);
}
}

View File

@ -40,12 +40,12 @@
<mat-header-cell *matHeaderCellDef mat-sort-header>Title</mat-header-cell>
<mat-cell *matCellDef="let assignment">{{ assignment.getListTitle() }}</mat-cell>
</ng-container>
<!-- pahse column-->
<!-- phase column-->
<ng-container matColumnDef="phase">
<mat-header-cell *matHeaderCellDef mat-sort-header>Phase</mat-header-cell>
<mat-cell *matCellDef="let assignment">
<mat-chip-list>
<mat-chip color="primary" selected>{{ assignment.phase }}</mat-chip>
<mat-chip color="primary" selected>{{ assignment.phaseString | translate }}</mat-chip>
</mat-chip-list>
</mat-cell>
<button mat-menu-item (click)="selectAll()">
@ -62,7 +62,7 @@
<mat-header-cell *matHeaderCellDef mat-sort-header>Candidates</mat-header-cell>
<mat-cell *matCellDef="let assignment">
<mat-chip-list>
<mat-chip color="accent" selected>{{ assignment.candidateAmount }}</mat-chip>
<mat-chip color="accent" selected matTooltip="{{ 'Number of candidates' | translate }}">{{ assignment.candidateAmount }}</mat-chip>
</mat-chip-list>
</mat-cell>
</ng-container>

View File

@ -1,7 +1,7 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { AssignmentListComponent } from './assignment-list.component';
import { E2EImportsModule } from '../../../../../e2e-imports.module';
import { E2EImportsModule } from 'e2e-imports.module';
describe('AssignmentListComponent', () => {
let component: AssignmentListComponent;

View File

@ -13,7 +13,7 @@ import { ListViewBaseComponent } from 'app/site/base/list-view-base';
import { OperatorService } from 'app/core/core-services/operator.service';
import { PromptService } from 'app/core/ui-services/prompt.service';
import { StorageService } from 'app/core/core-services/storage.service';
import { ViewAssignment } from '../../models/view-assignment';
import { ViewAssignment, AssignmentPhases } from '../../models/view-assignment';
/**
* List view for the assignments
@ -24,6 +24,11 @@ import { ViewAssignment } from '../../models/view-assignment';
styleUrls: ['./assignment-list.component.scss']
})
export class AssignmentListComponent extends ListViewBaseComponent<ViewAssignment, Assignment> implements OnInit {
/**
* The different phases of an assignment. Info is fetched from server
*/
public phaseOptions = AssignmentPhases;
/**
* Constructor.
*

View File

@ -1,73 +1,40 @@
<h2 translate>Voting result</h2>
<div class="meta-text">
<span translate>Special values</span>:<br />
<mat-chip>-1</mat-chip>&nbsp;=&nbsp; <span translate>majority</span><br />
<mat-chip>-1</mat-chip>&nbsp;=&nbsp; <span translate>majority</span>&nbsp;
<mat-chip color="accent">-2</mat-chip>&nbsp;=&nbsp;
<span translate>undocumented</span>
</div>
<div>
<div class="flex-spaced" *ngFor="let candidate of data.poll.options">
<div>
{{ getName(candidate.candidate_id) }}
<div class="spacer-top-10"></div>
<div class="width-600">
<!-- Candidate values -->
<div [ngClass]="getGridClass()" *ngFor="let candidate of data.options">
<div class="candidate-name">
{{ candidate.user.full_name }}
</div>
<div class="votes">
<div *ngFor="let key of optionPollKeys" class="votes">
<mat-form-field>
<input
type="number"
matInput
[value]="getValue('Yes', candidate)"
(change)="setValue('Yes', candidate, $event.target.value)"
/>
<mat-label *ngIf="data.poll.pollmethod !== 'votes'" translate>Yes</mat-label>
</mat-form-field>
<mat-form-field *ngIf="data.poll.pollmethod !== 'votes'">
<input
type="number"
matInput
[value]="getValue('No', candidate)"
(change)="setValue('No', candidate, $event.target.value)"
/>
<mat-label translate>No</mat-label>
</mat-form-field>
<mat-form-field *ngIf="data.poll.pollmethod === 'yna'">
<input
type="number"
matInput
[value]="getValue('Abstain', candidate)"
(change)="setValue('Abstain', candidate, $event.target.value)"
/>
<mat-label translate>Abstain</mat-label>
<input type="number"
matInput
[value]="getValue(key, candidate)"
(change)="setValue(key, candidate, $event.target.value)"
/>
<mat-label> {{ key | translate }}</mat-label>
</mat-form-field>
</div>
<mat-divider *ngIf="data.poll.pollmethod !== 'votes'"></mat-divider>
</div>
<mat-form-field>
<input
type="number"
matInput
[value]="getSumValue('votesvalid')"
(change)="setSumValue('votesvalid', $event.target.value)"
/>
<mat-label translate>Valid votes</mat-label>
</mat-form-field>
<mat-form-field>
<input
type="number"
matInput
[value]="getSumValue('votesinvalid')"
(change)="setSumValue('votesinvalid', $event.target.value)"
/>
<mat-label translate>Invalid votes</mat-label>
</mat-form-field>
<mat-form-field>
<input
type="number"
matInput
[value]="getSumValue('votescast')"
(change)="setSumValue('votescast', $event.target.value)"
/>
<mat-label translate>Total votes</mat-label>
</mat-form-field>
<!-- 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) }}</mat-label>
</mat-form-field>
</div>
</div>
<div class="submit-buttons">
<button mat-button (click)="submit()">{{ 'Save' | translate }}</button>

View File

@ -13,7 +13,52 @@
}
}
.votes {
display: flex;
justify-content: space-between;
.candidate-name {
word-wrap: break-word;
width: 100%;
border-bottom: 1px solid grey;
}
.votes-grid-1 {
display: grid;
grid-gap: 5px;
margin-bottom: 10px;
grid-template-columns: auto 60px;
align-items: center;
.mat-form-field {
width: 100%;
}
}
// TODO: more elegant way. Only grid-template-columns is different
.votes-grid-2 {
display: grid;
grid-gap: 5px;
margin-bottom: 10px;
align-items: center;
grid-template-columns: auto 60px 60px;
.mat-form-field {
width: 100%;
}
}
// TODO: more elegant way. Only grid-template-columns is different
.votes-grid-3 {
display: grid;
grid-gap: 5px;
margin-bottom: 10px;
align-items: center;
grid-template-columns: auto 60px 60px 60px;
.mat-form-field {
width: 100%;
}
}
.sum-value {
display: flex;
justify-content: flex-end;
}
.width-600 {
max-width: 600px;
}

View File

@ -2,16 +2,17 @@ import { Component, Inject } from '@angular/core';
import { MatDialogRef, MAT_DIALOG_DATA, MatSnackBar } from '@angular/material';
import { TranslateService } from '@ngx-translate/core';
import { AssignmentPollOption } from 'app/shared/models/assignments/assignment-poll-option';
import { AssignmentPollService } from '../../services/assignment-poll.service';
import { CalculablePollKey, PollVoteValue } from 'app/core/ui-services/poll.service';
import { AssignmentPoll } from 'app/shared/models/assignments/assignment-poll';
import { ViewUser } from 'app/site/users/models/view-user';
import { AssignmentPollOption } from 'app/shared/models/assignments/assignment-poll-option';
import { UserRepositoryService } from 'app/core/repositories/users/user-repository.service';
import { ViewAssignmentPoll } from '../../models/view-assignment-poll';
import { ViewAssignmentPollOption } from '../../models/view-assignment-poll-option';
/**
* Vote entries included once for summary (e.g. total votes cast)
*/
type summaryPollKeys = 'votescast' | 'votesvalid' | 'votesinvalid';
type summaryPollKey = 'votescast' | 'votesvalid' | 'votesinvalid';
/**
* A dialog for updating the values of an assignment-related poll.
@ -22,6 +23,11 @@ type summaryPollKeys = 'votescast' | 'votesvalid' | 'votesinvalid';
styleUrls: ['./assignment-poll-dialog.component.scss']
})
export class AssignmentPollDialogComponent {
/**
* The summary values that will have fields in the dialog
*/
public sumValues: summaryPollKey[] = ['votesvalid', 'votesinvalid', 'votescast'];
/**
* List of accepted special non-numerical values.
* See {@link PollService.specialPollVotes}
@ -40,15 +46,16 @@ export class AssignmentPollDialogComponent {
*/
public constructor(
public dialogRef: MatDialogRef<AssignmentPollDialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: { poll: AssignmentPoll; users: ViewUser[] },
@Inject(MAT_DIALOG_DATA) public data: ViewAssignmentPoll,
private matSnackBar: MatSnackBar,
private translate: TranslateService,
private pollService: AssignmentPollService
public pollService: AssignmentPollService,
private userRepo: UserRepositoryService
) {
this.specialValues = this.pollService.specialPollVotes;
switch (this.data.poll.pollmethod) {
switch (this.data.pollmethod) {
case 'votes':
this.optionPollKeys = ['Yes'];
this.optionPollKeys = ['Votes'];
break;
case 'yn':
this.optionPollKeys = ['Yes', 'No'];
@ -73,7 +80,7 @@ export class AssignmentPollDialogComponent {
* TODO better validation
*/
public submit(): void {
const error = this.data.poll.options.find(dataoption => {
const error = this.data.options.find(dataoption => {
for (const key of this.optionPollKeys) {
const keyValue = dataoption.votes.find(o => o.value === key);
if (!keyValue || keyValue.weight === undefined) {
@ -90,7 +97,7 @@ export class AssignmentPollDialogComponent {
}
);
} else {
this.dialogRef.close(this.data.poll);
this.dialogRef.close(this.data);
}
}
@ -104,18 +111,6 @@ export class AssignmentPollDialogComponent {
return this.pollService.getLabel(key);
}
/**
* Get the (full) name of a pollOption candidate
*
* @param the id of the candidate
* @returns the full_name property
*/
public getName(candidateId: number): string {
const user = this.data.users.find(c => c.id === candidateId);
return user ? user.full_name : 'unknown user';
// TODO error handling
}
/**
* Updates a vote value
*
@ -123,14 +118,14 @@ export class AssignmentPollDialogComponent {
* @param candidate the candidate for whom to update the value
* @param newData the new value
*/
public setValue(value: PollVoteValue, candidate: AssignmentPollOption, newData: string): void {
public setValue(value: PollVoteValue, candidate: ViewAssignmentPollOption, newData: string): void {
const vote = candidate.votes.find(v => v.value === value);
if (vote) {
vote.weight = parseInt(newData, 10);
vote.weight = parseFloat(newData);
} else {
candidate.votes.push({
value: value,
weight: parseInt(newData, 10)
weight: parseFloat(newData)
});
}
}
@ -153,8 +148,8 @@ export class AssignmentPollDialogComponent {
* @param value
* @returns integer or undefined
*/
public getSumValue(value: summaryPollKeys): number | undefined {
return this.data.poll[value] || undefined;
public getSumValue(value: summaryPollKey): number | undefined {
return this.data[value] || undefined;
}
/**
@ -163,7 +158,23 @@ export class AssignmentPollDialogComponent {
* @param value
* @param weight
*/
public setSumValue(value: summaryPollKeys, weight: string): void {
this.data.poll[value] = parseInt(weight, 10);
public setSumValue(value: summaryPollKey, weight: string): void {
this.data[value] = parseFloat(weight);
}
public getGridClass(): string {
return `votes-grid-${this.optionPollKeys.length}`;
}
/**
* Fetches the name for a poll option
* TODO: observable. Note that the assignment.related_user may not contain the user (anymore?)
*
* @param option Any poll option
* @returns the full_name for the candidate
*/
public getCandidateName(option: AssignmentPollOption): string {
const user = this.userRepo.getViewModel(option.candidate_id);
return user ? user.full_name : '';
}
}

View File

@ -1,119 +1,197 @@
<!-- label: 'Ballot 1 ...' -->
<h3 translate>Ballot</h3>
<mat-card class="os-card">
<div class="flex-spaced">
<div>
<!-- *ngIf="poll.description"> -->
Description
<!-- {{ poll.description}} -->
</div>
<mat-card class="os-card" *ngIf="poll">
<div class="flex-spaced poll-menu">
<!-- Buttons -->
<div *osPerms="'assignments.can_manage'">
<button mat-button (click)="printBallot()">
<span translate>Print ballot paper</span>
</button>
<button mat-button [disabled]="assignment.phase == 2" (click)="enterVotes()">
<span translate>Enter votes</span>
</button>
<button mat-button (click)="togglePublished()">
<span *ngIf="!poll.published" translate>Publish</span>
<span *ngIf="poll.published" translate>Unpublish</span>
</button>
</div>
<div *osPerms="'core.can_manage_projector'">
<button mat-button>
<span translate>Project</span>
<!-- os-projector-button ?-->
</button>
</div>
<div *osPerms="'assignments.can_manage'">
<button mat-button class="red-warning-text" (click)="onDeletePoll(poll)">
<span translate>Delete</span>
</button>
</div>
</div>
<div>
<!-- text input 'set hint for pballot paper' -->
<!-- submit: setBallotHint(poll) -->
</div>
<div *ngIf="pollService.majorityMethods && majorityChoice">
<span translate>Majority method</span>
<mat-basic-chip *ngIf="canManage" [matMenuTriggerFor]="majorityMenu" class="grey" disableRipple>
{{ majorityChoice.display_name | translate }}
</mat-basic-chip>
<mat-basic-chip *ngIf="!canManage" class="grey" disableRipple>
{{ majorityChoice.display_name | translate }}
</mat-basic-chip>
<mat-menu #majorityMenu="matMenu">
<button mat-menu-item *ngFor="let method of pollService.majorityMethods" (click)="setMajority(method)">
<mat-icon *ngIf="method.value === majorityChoice.value">
check
</mat-icon>
{{ method.display_name | translate }}
</button>
<button
mat-icon-button
*osPerms="'assignments.can_manage'; &quot;core.can_manage_projector&quot;"
[matMenuTriggerFor]="pollItemMenu"
(click)="$event.stopPropagation()"
>
<mat-icon>more_vert</mat-icon>
</button>
<mat-menu #pollItemMenu="matMenu">
<div *osPerms="'assignments.can_manage'">
<button mat-menu-item (click)="printBallot()">
<mat-icon>local_printshop</mat-icon>
<span translate>Print ballot paper</span>
</button>
<button mat-menu-item *ngIf="!assignment.isFinished" (click)="enterVotes()">
<mat-icon>edit</mat-icon>
<span translate>Enter votes</span>
</button>
<button mat-menu-item (click)="togglePublished()">
<mat-icon>
{{ poll.published ? 'visibility_off' : 'visibility' }}
</mat-icon>
<span *ngIf="!poll.published" translate>Publish</span>
<span *ngIf="poll.published" translate>Unpublish</span>
</button>
</div>
<div *osPerms="'core.can_manage_projector'">
<button mat-menu-item>
<mat-icon>videocam</mat-icon>
<span translate>Project</span>
<!-- os-projector-button ?-->
</button>
</div>
<div *osPerms="'assignments.can_manage'">
<mat-divider></mat-divider>
<button mat-menu-item class="red-warning-text" (click)="onDeletePoll()">
<mat-icon>delete</mat-icon>
<span translate>Delete</span>
</button>
</div>
</mat-menu>
</div>
<div class="on-transition-fade" *ngIf="poll && poll.options">
<div *ngFor="let option of poll.options" class="flex-spaced">
<div *ngIf="poll.published">
<mat-checkbox [checked]="option.is_elected" (change)="toggleElected(option)">
<!-- TODO mark/unkmark elected osPerms -->
</mat-checkbox>
</div>
<!-- candidate Name -->
<div>
{{ option.user.full_name }}
</div>
<!-- Votes -->
<div *ngIf="poll.published && poll.has_votes">
<div *ngFor="let vote of option.votes">
<div class="poll-progress on-transition-fade">
<span>{{ pollService.getLabel(vote.value) | translate }}:</span>
{{ pollService.getSpecialLabel(vote.weight) }}
<span *ngIf="!pollService.isAbstractOption(poll, option)"
>({{ pollService.getPercent(poll, option, vote.value) }}%)</span
>
<div>
<h4>
<span translate>Poll description</span>
</h4>
<div [formGroup]="descriptionForm">
<mat-form-field class="wide">
<input matInput formControlName="description" [disabled]="!canManage" />
</mat-form-field>
<button
mat-icon-button
[disabled]="!dirtyDescription"
*ngIf="canManage"
(click)="onEditDescriptionButton()"
>
<mat-icon inline>edit</mat-icon>
</button>
</div>
<div class="spacer-bottom-10">
<h4 translate>Poll method</h4>
<span>{{ pollMethodName | translate }}</span>
</div>
</div>
<div class="on-transition-fade" *ngIf="poll.options">
<div *ngIf="pollData">
<div class="poll-grid">
<div></div>
<div><span class="table-view-list-title" translate>Candidate</span></div>
<div><span class="table-view-list-title" translate>Votes</span></div>
<div *ngIf="pollService.majorityMethods && majorityChoice">
<div>
<span class="table-view-list-title" translate>Quorum</span>
</div>
<div *ngIf="!pollService.isAbstractOption(poll, option)" class="poll-progress-bar">
<mat-progress-bar
mode="determinate"
[value]="pollService.getPercent(poll, option, vote.value)"
[ngClass]="pollService.getProgressBarColor(vote.value)"
>
</mat-progress-bar>
<div>
<!-- manager majority chip (menu trigger) -->
<mat-basic-chip *ngIf="canManage" [matMenuTriggerFor]="majorityMenu" class="grey" disableRipple>
{{ majorityChoice.display_name | translate }}
</mat-basic-chip>
<!-- non-manager (menuless) majority chip -->
<mat-basic-chip *ngIf="!canManage" class="grey" disableRipple>
{{ majorityChoice.display_name | translate }}
</mat-basic-chip>
<!-- menu for selecting quorum choices -->
<mat-menu #majorityMenu="matMenu">
<button
mat-menu-item
*ngFor="let method of pollService.majorityMethods"
(click)="setMajority(method)"
>
<mat-icon *ngIf="method.value === majorityChoice.value">
check
</mat-icon>
{{ method.display_name | translate }}
</button>
</mat-menu>
</div>
</div>
</div>
<div
*ngIf="
poll.has_votes &&
poll.published &&
majorityChoice &&
majorityChoice.value !== 'disabled' &&
!pollService.isAbstractOption(poll, option)
"
>
<span>{{ pollService.yesQuorum(majorityChoice, poll, option) }}</span>
<span *ngIf="quorumReached(option)" class="green-text">
<mat-icon>done</mat-icon>
</span>
<span *ngIf="!quorumReached(option)" class="red-warning-text">
<mat-icon>cancel</mat-icon>
</span>
<div *ngFor="let option of poll.options" class="poll-grid poll-border">
<div>
<div>
<button
type="button"
mat-icon-button
(click)="toggleElected(option)"
[disabled]="!canManage || assignment.isFinished"
disableRipple
>
<mat-icon
*ngIf="option.is_elected"
class="top-aligned green-text"
matTooltip="{{ 'Elected' | translate }}"
>check_box</mat-icon
>
<mat-icon
*ngIf="!option.is_elected && canManage && !assignment.isFinished"
class="top-aligned primary"
matTooltip="{{ 'Not elected' | translate }}"
>
check_box_outline_blank</mat-icon
>
</button>
</div>
</div>
<!-- candidate Name -->
<div class="candidate-name">
{{ option.user.full_name }}
</div>
<!-- Votes -->
<div>
<div *ngIf="poll.has_votes">
<div *ngFor="let vote of option.votes" class="spacer-bottom-10">
<div class="poll-progress on-transition-fade">
<span *ngIf="vote.value !== 'Votes'"
>{{ pollService.getLabel(vote.value) | translate }}:</span
>
{{ pollService.getSpecialLabel(vote.weight) }}
<span *ngIf="!pollService.isAbstractOption(poll, option)"
>({{ pollService.getPercent(poll, option, vote.value) }}%)</span
>
</div>
<div *ngIf="!pollService.isAbstractOption(poll, option)" class="poll-progress-bar">
<mat-progress-bar
mode="determinate"
[value]="pollService.getPercent(poll, option, vote.value)"
[ngClass]="pollService.getProgressBarColor(vote.value)"
>
</mat-progress-bar>
</div>
</div>
</div>
</div>
<div>
<div
*ngIf="
poll.has_votes &&
majorityChoice &&
majorityChoice.value !== 'disabled' &&
!pollService.isAbstractOption(poll, option)
"
class="poll-quorum"
>
<span>{{ pollService.yesQuorum(majorityChoice, poll, option) }}</span>
<span [ngClass]="quorumReached(option) ? 'green-text' : 'red-warning-text'"
matTooltip="{{ getQuorumReachedString(option) }}"
>
<mat-icon *ngIf="quorumReached(option)">{{ pollService.getIcon('yes') }}</mat-icon>
<mat-icon *ngIf="!quorumReached(option)">{{ pollService.getIcon('no') }}</mat-icon>
</span>
</div>
</div>
</div>
<!-- summary -->
<div>
<div *ngFor="let key of pollValues" class="poll-grid">
<div></div>
<div class="candidate-name">
<span>{{ pollService.getLabel(key) | translate }}</span
>:
</div>
<div>
{{ pollService.getSpecialLabel(poll[key]) | translate }}
</div>
</div>
</div>
</div>
<div>
<!-- summary -->
<div *ngFor="let key of pollValues">
<div>
<span>{{ key | translate }}</span>:
</div>
<div>
{{ poll[key] | precisionPipe }}
{{ pollService.getSpecialLabel(poll[key]) }}
</div>
<div *ngIf="!pollData">
<h4 translate>Candidates</h4>
<div *ngFor="let option of poll.options">
<span class="accent"> {{ option.user.full_name }}</span>
</div>
</div>
</div>

View File

@ -59,3 +59,36 @@
padding: 1px;
}
}
.poll-grid {
display: grid;
grid-gap: 5px;
padding: 5px;
grid-template-columns: 30px auto 250px 150px;
.candidate-name {
word-wrap: break-word;
}
}
.poll-border {
border: 1px solid lightgray;
}
.poll-menu {
justify-content: flex-end;
}
.poll-quorum {
text-align: right;
margin-right: 10px;
mat-icon {
vertical-align: middle;
font-size: 100%;
}
}
.top-aligned {
position: absolute;
top: 0;
left: 0;
}
.wide {
width: 90%;
}

View File

@ -1,4 +1,5 @@
import { Component, OnInit, Input } from '@angular/core';
import { FormGroup, FormBuilder } from '@angular/forms';
import { MatDialog, MatSnackBar } from '@angular/material';
import { TranslateService } from '@ngx-translate/core';
@ -6,15 +7,15 @@ import { TranslateService } from '@ngx-translate/core';
import { AssignmentPollDialogComponent } from '../assignment-poll-dialog/assignment-poll-dialog.component';
import { AssignmentPollService } from '../../services/assignment-poll.service';
import { AssignmentRepositoryService } from 'app/core/repositories/assignments/assignment-repository.service';
import { BaseViewComponent } from 'app/site/base/base-view';
import { MajorityMethod, CalculablePollKey } from 'app/core/ui-services/poll.service';
import { OperatorService } from 'app/core/core-services/operator.service';
import { AssignmentPoll } from 'app/shared/models/assignments/assignment-poll';
import { PromptService } from 'app/core/ui-services/prompt.service';
import { ViewAssignment } from '../../models/view-assignment';
import { BaseViewComponent } from 'app/site/base/base-view';
import { Title } from '@angular/platform-browser';
import { ViewAssignmentPollOption } from '../../models/view-assignment-poll-option';
import { ViewAssignment } from '../../models/view-assignment';
import { ViewAssignmentPoll } from '../../models/view-assignment-poll';
import { ViewAssignmentPollOption } from '../../models/view-assignment-poll-option';
/**
* Component for a single assignment poll. Used in assignment detail view
@ -37,6 +38,11 @@ export class AssignmentPollComponent extends BaseViewComponent implements OnInit
@Input()
public poll: ViewAssignmentPoll;
/**
* Form for updating the poll's description
*/
public descriptionForm: FormGroup;
/**
* The selected Majority method to display quorum calculations. Will be
* set/changed by the user
@ -63,6 +69,23 @@ export class AssignmentPollComponent extends BaseViewComponent implements OnInit
return this.pollService.pollValues.filter(name => this.poll[name] !== undefined);
}
/**
* @returns true if the description on the form differs from the poll's description
*/
public get dirtyDescription(): boolean {
return this.descriptionForm.get('description').value !== this.poll.description;
}
/**
* @returns true if vote results can be seen by the user
*/
public get pollData(): boolean {
if (!this.poll.has_votes) {
return false;
}
return this.poll.published || this.canManage;
}
/**
* Gets the translated poll method name
*
@ -90,6 +113,8 @@ export class AssignmentPollComponent extends BaseViewComponent implements OnInit
/**
* constructor. Does nothing
*
* @param titleService
* @param matSnackBar
* @param pollService poll related calculations
* @param operator permission checks
* @param assignmentRepo The repository to the assignments
@ -105,7 +130,8 @@ export class AssignmentPollComponent extends BaseViewComponent implements OnInit
private assignmentRepo: AssignmentRepositoryService,
public translate: TranslateService,
public dialog: MatDialog,
private promptService: PromptService
private promptService: PromptService,
private formBuilder: FormBuilder
) {
super(titleService, translate, matSnackBar);
}
@ -117,6 +143,9 @@ export class AssignmentPollComponent extends BaseViewComponent implements OnInit
this.majorityChoice =
this.pollService.majorityMethods.find(method => method.value === this.pollService.defaultMajorityMethod) ||
null;
this.descriptionForm = this.formBuilder.group({
description: this.poll ? this.poll.description : ''
});
}
/**
@ -159,14 +188,9 @@ export class AssignmentPollComponent extends BaseViewComponent implements OnInit
* closes successfully (validation is done there)
*/
public enterVotes(): void {
// TODO deep copy of this.poll (JSON parse is ugly workaround)
// or sending just copy of the options
const data = {
poll: JSON.parse(JSON.stringify(this.poll)),
users: this.assignment.candidates // used to get the names of the users
};
const dialogRef = this.dialog.open(AssignmentPollDialogComponent, {
data: data,
// TODO deep copy of this.poll (JSON parse is ugly workaround) or sending just copy of the options
data: this.poll.copy(),
maxHeight: '90vh',
minWidth: '300px',
maxWidth: '80vw',
@ -213,4 +237,27 @@ export class AssignmentPollComponent extends BaseViewComponent implements OnInit
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).then(null, this.raiseError);
}
/**
* Fetches a tooltip string about the quorum
* @param option
* @returns a translated
*/
public getQuorumReachedString(option: ViewAssignmentPollOption): string {
const name = this.translate.instant(this.majorityChoice.display_name);
const quorum = this.pollService.yesQuorum(this.majorityChoice, this.poll, option);
const isReached = this.quorumReached(option)
? this.translate.instant('reached')
: this.translate.instant('not reached');
return `${name} (${quorum}) ${isReached}`;
}
}

View File

@ -4,6 +4,7 @@ import { Identifiable } from 'app/shared/models/base/identifiable';
import { AssignmentPoll } from 'app/shared/models/assignments/assignment-poll';
import { AssignmentPollMethod } from '../services/assignment-poll.service';
import { ViewAssignmentPollOption } from './view-assignment-poll-option';
import { AssignmentPollOption } from 'app/shared/models/assignments/assignment-poll-option';
export class ViewAssignmentPoll implements Identifiable, Updateable {
private _assignmentPoll: AssignmentPoll;
@ -37,14 +38,25 @@ export class ViewAssignmentPoll implements Identifiable, Updateable {
return this.poll.votesvalid;
}
public set votesvalid(amount: number) {
this.poll.votesvalid = amount;
}
public get votesinvalid(): number {
return this.poll.votesinvalid;
}
public set votesinvalid(amount: number) {
this.poll.votesinvalid = amount;
}
public get votescast(): number {
return this.poll.votescast;
}
public set votescast(amount: number) {
this.poll.votescast = amount;
}
public get has_votes(): boolean {
return this.poll.has_votes;
}
@ -68,4 +80,22 @@ export class ViewAssignmentPoll implements Identifiable, Updateable {
public updateDependencies(update: BaseViewModel): void {
this.options.forEach(option => option.updateDependencies(update));
}
/**
* Creates a copy with deep-copy on all changing numerical values,
* but intact uncopied references to the users
*
* TODO check and review
*/
public copy(): ViewAssignmentPoll {
return new ViewAssignmentPoll(
new AssignmentPoll(JSON.parse(JSON.stringify(this._assignmentPoll))),
this._assignmentPollOptions.map(option => {
return new ViewAssignmentPollOption(
new AssignmentPollOption(JSON.parse(JSON.stringify(option.option))),
option.user
);
})
);
}
}

View File

@ -1,10 +1,11 @@
import { AssignmentRelatedUser } from 'app/shared/models/assignments/assignment-related-user';
import { ViewUser } from 'app/site/users/models/view-user';
import { BaseViewModel } from 'app/site/base/base-view-model';
import { Updateable } from 'app/site/base/updateable';
import { Displayable } from 'app/site/base/displayable';
import { Identifiable } from 'app/shared/models/base/identifiable';
import { Updateable } from 'app/site/base/updateable';
import { ViewUser } from 'app/site/users/models/view-user';
export class ViewAssignmentRelatedUser implements Updateable, Identifiable {
export class ViewAssignmentRelatedUser implements Updateable, Identifiable, Displayable {
private _assignmentRelatedUser: AssignmentRelatedUser;
private _user?: ViewUser;
@ -46,4 +47,12 @@ export class ViewAssignmentRelatedUser implements Updateable, Identifiable {
this._user = update;
}
}
public getTitle(): string {
return this.user ? this.user.getTitle() : '';
}
public getListTitle(): string {
return this.getTitle();
}
}

View File

@ -9,10 +9,28 @@ import { BaseViewModel } from 'app/site/base/base-view-model';
import { ViewAssignmentRelatedUser } from './view-assignment-related-user';
import { ViewAssignmentPoll } from './view-assignment-poll';
export interface AssignmentPhase {
value: number;
display_name: string;
}
/**
* A constant containing all possible assignment phases and their different
* representations as numerical value, string as used in server, and the display
* name.
*/
export const AssignmentPhases: { name: string; value: number; display_name: string }[] = [
{
name: 'PHASE_SEARCH',
value: 0,
display_name: 'Searching for candidates'
},
{
name: 'PHASE_VOTING',
value: 1,
display_name: 'Voting'
},
{
name: 'PHASE_FINISHED',
value: 2,
display_name: 'Finished'
}
];
export class ViewAssignment extends BaseAgendaViewModel {
public static COLLECTIONSTRING = Assignment.COLLECTIONSTRING;
@ -62,12 +80,37 @@ export class ViewAssignment extends BaseAgendaViewModel {
return this.assignment.phase;
}
public get phaseString(): string {
const phase = AssignmentPhases.find(ap => ap.value === this.assignment.phase);
return phase ? phase.display_name : '';
}
/**
* @returns true if the assignment is in the 'finished' state
* (not accepting votes or candidates anymore)
*/
public get isFinished(): boolean {
const finishedState = AssignmentPhases.find(ap => ap.name === 'PHASE_FINISHED');
return this.phase === finishedState.value;
}
/**
* @returns true if the assignment is in the 'searching' state
*/
public get isSearchingForCandidates(): boolean {
const searchState = AssignmentPhases.find(ap => ap.name === 'PHASE_SEARCH');
return this.phase === searchState.value;
}
/**
* @returns the amount of candidates in the assignment's candidate list
*/
public get candidateAmount(): number {
return this._assignmentRelatedUsers ? this._assignmentRelatedUsers.length : 0;
}
/**
* This is set by the repository
* Constructor. Is set by the repository
*/
public getVerboseName;
public getAgendaTitle;

View File

@ -4,8 +4,7 @@ import { AssignmentRepositoryService } from 'app/core/repositories/assignments/a
import { Assignment } from 'app/shared/models/assignments/assignment';
import { BaseFilterListService, OsFilter } from 'app/core/ui-services/base-filter-list.service';
import { StorageService } from 'app/core/core-services/storage.service';
import { ViewAssignment, AssignmentPhase } from '../models/view-assignment';
import { ConstantsService } from 'app/core/core-services/constants.service';
import { ViewAssignment, AssignmentPhases } from '../models/view-assignment';
@Injectable({
providedIn: 'root'
@ -37,11 +36,7 @@ export class AssignmentFilterListService extends BaseFilterListService<Assignmen
* @param assignmentRepo Repository
* @param constants the openslides constant service to get the assignment options
*/
public constructor(
store: StorageService,
assignmentRepo: AssignmentRepositoryService,
private constants: ConstantsService
) {
public constructor(store: StorageService, assignmentRepo: AssignmentRepositoryService) {
super(store, assignmentRepo);
this.createPhaseOptions();
}
@ -51,14 +46,8 @@ export class AssignmentFilterListService extends BaseFilterListService<Assignmen
* constants
*/
private createPhaseOptions(): void {
this.constants.get<AssignmentPhase[]>('AssignmentPhases').subscribe(phases => {
this.phaseFilter.options = phases.map(ph => {
return {
label: ph.display_name,
condition: ph.value,
isActive: false
};
});
this.phaseFilter.options = AssignmentPhases.map(ap => {
return { label: ap.display_name, condition: ap.value, isActive: false };
});
this.updateFilterDefinitions(this.filterOptions);
}

View File

@ -117,6 +117,25 @@ export class AssignmentPollService extends PollService {
return Math.round(((vote.weight * 100) / base) * 100) / 100;
}
/**
* get the percentage for a non-abstract per-poll value
* TODO: similar code to getPercent. Mergeable?
*
* @param poll the poll this value refers to
* @param value a per-poll value (e.g. 'votesvalid')
* @returns a percentage number with two digits, null if the value cannot be calculated
*/
public getValuePercent(poll: ViewAssignmentPoll, value: CalculablePollKey): number | null {
if (!poll.pollBase) {
return null;
}
const amount = poll[value];
if (amount === undefined || amount < 0) {
return null;
}
return Math.round(((amount * 100) / poll.pollBase) * 100) / 100;
}
/**
* Check if the option in a poll is abstract (percentages should not be calculated)
*

View File

@ -26,7 +26,7 @@ const routes: Routes = [
{
path: 'assignments',
loadChildren: './assignments/assignments.module#AssignmentsModule',
data: { basePerm: 'assignment.can_see' }
data: { basePerm: 'assignments.can_see' }
},
{
path: 'mediafiles',

View File

@ -1,7 +1,6 @@
from typing import Any, Dict, List, Set
from typing import Any, Dict, Set
from django.apps import AppConfig
from mypy_extensions import TypedDict
class AssignmentsAppConfig(AppConfig):
@ -51,14 +50,6 @@ class AssignmentsAppConfig(AppConfig):
"""
yield self.get_model("Assignment")
def get_angular_constants(self):
assignment = self.get_model("Assignment")
Item = TypedDict("Item", {"value": int, "display_name": str})
phases: List[Item] = []
for phase in assignment.PHASES:
phases.append({"value": phase[0], "display_name": phase[1]})
return {"AssignmentPhases": phases}
def required_users(element: Dict[str, Any]) -> Set[int]:
"""