Merge pull request #4577 from MaximilianKrambach/assignmentLayout

Assignments layout and fixes
This commit is contained in:
Sean 2019-04-16 11:31:33 +02:00 committed by GitHub
commit f622d9b546
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 841 additions and 458 deletions

View File

@ -93,33 +93,40 @@ export class AssignmentRepositoryService extends BaseAgendaContentObjectReposito
private createViewAssignmentRelatedUsers( private createViewAssignmentRelatedUsers(
assignmentRelatedUsers: AssignmentRelatedUser[] assignmentRelatedUsers: AssignmentRelatedUser[]
): ViewAssignmentRelatedUser[] { ): ViewAssignmentRelatedUser[] {
return assignmentRelatedUsers.map(aru => { return assignmentRelatedUsers
const user = this.viewModelStoreService.get(ViewUser, aru.user_id); .map(aru => {
return new ViewAssignmentRelatedUser(aru, user); 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[] { private createViewAssignmentPolls(assignmentPolls: AssignmentPoll[]): ViewAssignmentPoll[] {
return assignmentPolls.map(poll => { return assignmentPolls.map(poll => {
const options = poll.options.map(option => { const options = poll.options
const user = this.viewModelStoreService.get(ViewUser, option.candidate_id); .map(option => {
return new ViewAssignmentPollOption(option, user); 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); 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 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> { public async changeCandidate(user: ViewUser, assignment: ViewAssignment, adding?: boolean): Promise<void> {
const data = { user: userId }; const data = { user: user.id };
if (assignment.candidates.some(candidate => candidate.id === userId)) { if (assignment.candidates.some(candidate => candidate.id === user.id) && adding !== true) {
await this.httpService.delete(this.restPath + assignment.id + this.candidatureOtherPath, data); 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); 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> { public async addPoll(assignment: ViewAssignment): Promise<void> {
await this.httpService.post(this.restPath + assignment.id + this.createPollPath); 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 => { const votes = poll.options.map(option => {
switch (poll.pollmethod) { switch (poll.pollmethod) {
case 'votes': case 'votes':
return { Votes: option.votes.find(v => v.value === 'Yes').weight }; return { Votes: option.votes.find(v => v.value === 'Votes').weight };
case 'yn': case 'yn':
return { return {
Yes: option.votes.find(v => v.value === 'Yes').weight, Yes: option.votes.find(v => v.value === 'Yes').weight,
@ -232,16 +240,14 @@ export class AssignmentRepositoryService extends BaseAgendaContentObjectReposito
} }
/** /**
* Sorting the candidates * Sends a request to sort an assignment's candidates
* TODO untested stub
* *
* @param sortedCandidates * @param sortedCandidates the id of the assignment related users (note: NOT viewUsers)
* @param assignment * @param assignment
*/ */
public async sortCandidates(sortedCandidates: any[], assignment: ViewAssignment): Promise<void> { public async sortCandidates(sortedCandidates: number[], assignment: ViewAssignment): Promise<void> {
throw Error('TODO'); const restPath = `/rest/assignments/assignment/${assignment.id}/sort_related_users/`;
// const restPath = `/rest/assignments/assignment/${assignment.id}/sort_related_users`; const data = { related_users: sortedCandidates };
// const data = { related_users: sortedCandidates }; await this.httpService.post(restPath, data);
// 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. * The possible keys of a poll object that represent numbers.
* TODO Should be 'key of MotionPoll if type of key is number' * TODO Should be 'key of MotionPoll|AssinmentPoll if type of key is number'
* TODO: normalize MotionPoll model and other poll models
*/ */
export type CalculablePollKey = 'votesvalid' | 'votesinvalid' | 'votescast' | 'yes' | 'no' | 'abstain'; 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 * TODO: may be obsolete if the server switches to lower case only
* (lower case variants are already in CalculablePollKey) * (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 * Interface representing possible majority calculation methods. The implementing
* calc function should return an integer number that must be reached for the * 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 { export interface MajorityMethod {
value: string; value: string;
@ -33,17 +32,26 @@ export const PollMajorityMethod: MajorityMethod[] = [
{ {
value: 'simple_majority', value: 'simple_majority',
display_name: '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', value: 'two-thirds_majority',
display_name: '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', value: 'three-quarters_majority',
display_name: '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', value: 'disabled',
@ -95,6 +103,7 @@ export abstract class PollService {
/** /**
* empty constructor * empty constructor
*
*/ */
public constructor() {} public constructor() {}

View File

@ -8,10 +8,7 @@
> >
<!-- Title --> <!-- Title -->
<div class="title-slot"> <div class="title-slot">
<h2 *ngIf="assignment && !newAssignment"> <h2 *ngIf="!newAssignment" translate>Election</h2>
<span *ngIf="!editAssignment">{{ assignment.getTitle() }}</span>
<span *ngIf="editAssignment">{{ assignmentForm.get('title').value }}</span>
</h2>
<h2 *ngIf="newAssignment" translate>New election</h2> <h2 *ngIf="newAssignment" translate>New election</h2>
</div> </div>
@ -23,17 +20,17 @@
</div> </div>
<mat-menu #assignmentDetailMenu="matMenu"> <mat-menu #assignmentDetailMenu="matMenu">
<!-- delete -->
<!-- print, edit, delete -->
<div *ngIf="assignment"> <div *ngIf="assignment">
<!-- PDF --> <!-- PDF -->
<button mat-menu-item (click)="onDownloadPdf()"> <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> <mat-icon>picture_as_pdf</mat-icon>
<span translate>PDF</span> <span translate>PDF</span>
</button> </button>
<mat-divider></mat-divider> <mat-divider></mat-divider>
<!-- Delete --> <!-- Delete -->
</div>
<div *ngIf="assignment && hasPerms('manage')">
<button mat-menu-item class="red-warning-text" (click)="onDeleteAssignmentButton()"> <button mat-menu-item class="red-warning-text" (click)="onDeleteAssignmentButton()">
<mat-icon>delete</mat-icon> <mat-icon>delete</mat-icon>
<span translate>Delete</span> <span translate>Delete</span>
@ -43,12 +40,6 @@
</os-head-bar> </os-head-bar>
<div class="content-container"> <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> <ng-container *ngIf="vp.isMobile; then mobileView; else desktopView"></ng-container>
</div> </div>
@ -57,119 +48,130 @@
</ng-template> </ng-template>
<ng-template #desktopView> <ng-template #desktopView>
<mat-card class="os-card"> <div *ngIf="editAssignment">
<div *ngIf="editAssignment"> <ng-container [ngTemplateOutlet]="assignmentFormTemplate"></ng-container>
<ng-container [ngTemplateOutlet]="assignmentFormTemplate"></ng-container> </div>
</div> <div *ngIf="!editAssignment">
<div *ngIf="!editAssignment"> <ng-container [ngTemplateOutlet]="metaInfoTemplate"></ng-container>
<mat-card class="os-card"> <ng-container [ngTemplateOutlet]="contentTemplate"></ng-container>
<ng-container [ngTemplateOutlet]="metaInfoTemplate"></ng-container> </div>
</mat-card>
<mat-card class="os-card">
<ng-container [ngTemplateOutlet]="contentTemplate"></ng-container>
</mat-card>
</div>
</mat-card>
</ng-template> </ng-template>
<ng-template #metaInfoTemplate> <ng-template #metaInfoTemplate>
<span translate>Meta information</span> <mat-card class="os-card" *ngIf="assignment">
<div *ngIf="assignment; &quot;flex-spaced&quot;"> <h1>{{ assignment.getTitle() }}</h1>
<div *ngIf="assignment">
<div *ngIf="assignment.assignment.description" [innerHTML]="assignment.assignment.description"></div>
</div>
<div> <div>
<span translate>Number of persons to be elected</span>:&nbsp; <span translate>Number of persons to be elected</span>:&nbsp;
<span>{{ assignment.assignment.open_posts }}</span> <span>{{ assignment.assignment.open_posts }}</span>
</div> </div>
<div> <div>
<span> {{ phaseString | translate }}</span> <span translate>Phase</span>:&nbsp;
<mat-form-field> <mat-basic-chip *ngIf="hasPerms('manage')" [matMenuTriggerFor]="phaseMenu" class="bluegrey" disableRipple>
<mat-label translate>Phase</mat-label> {{ assignment.phaseString | translate }}
<mat-select </mat-basic-chip>
class="selection" <mat-basic-chip *ngIf="!hasPerms('manage')" class="bluegrey" disableRipple>
[disabled]="!hasPerms('manage')" {{ assignment.phaseString | translate }}
(selectionChange)="setPhase($event)" </mat-basic-chip>
[value]="assignment.phase" <mat-menu #phaseMenu="matMenu">
> <button *ngFor="let option of phaseOptions" mat-menu-item (click)="onSetPhaseButton(option.value)">
<mat-option *ngFor="let option of phaseOptions" [value]="option.value"> {{ option.display_name | translate }}
{{ option.display_name | translate }} </button>
</mat-option> </mat-menu>
</mat-select>
</mat-form-field>
</div> </div>
</div> </mat-card>
</ng-template> </ng-template>
<ng-template #contentTemplate> <ng-template #contentTemplate>
<ng-container *ngIf="assignment && assignment.phase !== 2" [ngTemplateOutlet]="candidatesTemplate"> <mat-card class="os-card">
</ng-container> <ng-container *ngIf="assignment && !assignment.isFinished" [ngTemplateOutlet]="candidatesTemplate">
<!-- TODO related agenda item to create/updade: internal status; parent_id ? --> </ng-container>
<ng-container [ngTemplateOutlet]="pollTemplate"></ng-container> <!-- TODO related agenda item to create/updade: internal status; parent_id ? -->
<!-- TODO different status/display if finished --> <ng-container [ngTemplateOutlet]="pollTemplate"></ng-container>
<!-- TODO different status/display if finished -->
</mat-card>
</ng-template> </ng-template>
<!-- poll template --> <!-- poll template -->
<ng-template #pollTemplate> <ng-template #pollTemplate>
<div> <mat-tab-group
<!-- TODO: header. New poll button to the right, and with icon --> (selectedTabChange)="onTabChange()"
<!-- new poll button --> *ngIf="assignment && assignment.polls && assignment.polls.length"
<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">
<!-- TODO avoid animation/switching on update --> <!-- 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> <os-assignment-poll [assignment]="assignment" [poll]="poll"> </os-assignment-poll>
</mat-tab> </mat-tab>
</mat-tab-group> </mat-tab-group>
</ng-template> </ng-template>
<ng-template #candidatesTemplate> <ng-template #candidatesTemplate>
<!-- TODO two columns here: Left candidates; right add/remove --> <!-- candidates -->
<div> <div class="candidates-list" *ngIf="assignment && assignment.assignmentRelatedUsers && assignment.assignmentRelatedUsers.length > 0">
<div> <os-sorting-list
<div *ngIf="assignment && assignment.candidates"> [input]="assignment.assignmentRelatedUsers"
<!-- TODO: Sorting --> [live]="true"
<div *ngFor="let candidate of assignment.candidates"> [count]="true"
<span>{{ candidate.full_name }}</span> [enable]="hasPerms('addOthers')"
<button mat-button *ngIf="hasPerms('addOthers')" (click)="removeUser(candidate)">Remove</button> (sortEvent)="onSortingChange($event)"
</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()"
> >
<span class="upper" translate>Add me</span> <!-- implicit item references into the component using ng-template slot -->
</button> <ng-template let-item>
<br /> <span *ngIf="hasPerms('addOthers')">
<button type="button" mat-button *ngIf="hasPerms('addSelf') && isSelfCandidate" (click)="removeSelf()"> <button
<span translate>Remove me</span> mat-icon-button
</button> matTooltip="{{ 'Remove candidate' | translate }}"
<br /> (click)="removeUser(item)"
>
<mat-icon>clear</mat-icon>
</button>
</span>
</ng-template>
</os-sorting-list>
</div> </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>
<ng-template #assignmentFormTemplate> <ng-template #assignmentFormTemplate>
@ -180,15 +182,17 @@
(keydown)="onKeyDown($event)" (keydown)="onKeyDown($event)"
*ngIf="assignment && editAssignment" *ngIf="assignment && editAssignment"
> >
<!-- title --> <div>
<mat-form-field> <!-- title -->
<input <mat-form-field class="full-width">
matInput <input
placeholder="{{ 'Title' | translate }}" matInput
formControlName="title" placeholder="{{ 'Title' | translate }}"
[value]="assignmentCopy.title || ''" formControlName="title"
/> [value]="assignmentCopy.getTitle() || ''"
</mat-form-field> />
</mat-form-field>
</div>
<!-- description: HTML Editor --> <!-- description: HTML Editor -->
<editor <editor
@ -209,7 +213,7 @@
</div> </div>
<!-- searchValueSelector: tags --> <!-- searchValueSelector: tags -->
<div class="content-field"> <div class="content-field" *ngIf="tagsAvailable">
<os-search-value-selector <os-search-value-selector
ngDefaultControl ngDefaultControl
[form]="assignmentForm" [form]="assignmentForm"
@ -222,7 +226,7 @@
</div> </div>
<!-- searchValueSelector:agendaItem --> <!-- searchValueSelector:agendaItem -->
<div class="content-field"> <div class="content-field" *ngIf="parentsAvailable">
<os-search-value-selector <os-search-value-selector
ngDefaultControl ngDefaultControl
[form]="assignmentForm" [form]="assignmentForm"
@ -235,23 +239,27 @@
</div> </div>
<!-- poll_description_default --> <!-- poll_description_default -->
<mat-form-field> <div>
<input <mat-form-field>
matInput <input
placeholder="{{ 'Default comment on the ballot paper' | translate }}" matInput
formControlName="poll_description_default" placeholder="{{ 'Default comment on the ballot paper' | translate }}"
[value]="assignmentCopy.assignment.poll_description_default || ''" formControlName="poll_description_default"
/> [value]="assignmentCopy.assignment.poll_description_default || ''"
</mat-form-field> />
</mat-form-field>
</div>
<!-- open posts: number --> <!-- open posts: number -->
<mat-form-field> <div>
<input <mat-form-field>
matInput <input
placeholder="{{ 'Number of persons to be elected' | translate }}" matInput
formControlName="open_posts" placeholder="{{ 'Number of persons to be elected' | translate }}"
[value]="assignmentCopy.assignment.open_posts || null" formControlName="open_posts"
/> [value]="assignmentCopy.assignment.open_posts || null"
</mat-form-field> />
</mat-form-field>
</div>
<!-- TODO searchValueSelector: Parent --> <!-- TODO searchValueSelector: Parent -->
</form> </form>
</div> </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 { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms'; import { FormBuilder, FormGroup } from '@angular/forms';
import { MatSnackBar, MatSelectChange } from '@angular/material'; import { MatSnackBar } from '@angular/material';
import { Router, ActivatedRoute } from '@angular/router'; import { Router, ActivatedRoute } from '@angular/router';
import { Title } from '@angular/platform-browser'; import { Title } from '@angular/platform-browser';
@ -8,22 +8,22 @@ import { TranslateService } from '@ngx-translate/core';
import { BehaviorSubject } from 'rxjs'; import { BehaviorSubject } from 'rxjs';
import { Assignment } from 'app/shared/models/assignments/assignment'; 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 { AssignmentPollService } from '../../services/assignment-poll.service';
import { AssignmentRepositoryService } from 'app/core/repositories/assignments/assignment-repository.service'; import { AssignmentRepositoryService } from 'app/core/repositories/assignments/assignment-repository.service';
import { BaseViewComponent } from 'app/site/base/base-view'; 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 { ItemRepositoryService } from 'app/core/repositories/agenda/item-repository.service';
import { LocalPermissionsService } from 'app/site/motions/services/local-permissions.service'; import { LocalPermissionsService } from 'app/site/motions/services/local-permissions.service';
import { OperatorService } from 'app/core/core-services/operator.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 { TagRepositoryService } from 'app/core/repositories/tags/tag-repository.service';
import { UserRepositoryService } from 'app/core/repositories/users/user-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 { ViewItem } from 'app/site/agenda/models/view-item';
import { ViewportService } from 'app/core/ui-services/viewport.service'; import { ViewportService } from 'app/core/ui-services/viewport.service';
import { ViewTag } from 'app/site/tags/models/view-tag'; import { ViewTag } from 'app/site/tags/models/view-tag';
import { ViewUser } from 'app/site/users/models/view-user'; import { ViewUser } from 'app/site/users/models/view-user';
import { PromptService } from 'app/core/ui-services/prompt.service';
/** /**
* Component for the assignment detail view * 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 * 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) * List of users available as candidates (used as raw data for {@link filteredCandidates})
* TODO Candidates already in the list should be filtered out
*/ */
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[]>([]); public filteredCandidates = new BehaviorSubject<ViewUser[]>([]);
@ -88,6 +89,8 @@ export class AssignmentDetailComponent extends BaseViewComponent implements OnIn
*/ */
public set assignment(assignment: ViewAssignment) { public set assignment(assignment: ViewAssignment) {
this._assignment = assignment; this._assignment = assignment;
this.filterCandidates();
if (this.assignment.polls.length) { if (this.assignment.polls.length) {
this.assignment.polls.forEach(poll => { this.assignment.polls.forEach(poll => {
poll.pollBase = this.pollService.getBaseAmount(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 * Checks if there are any tags available
*
* @returns a matching string (untranslated)
*/ */
public get phaseString(): string { public get tagsAvailable(): boolean {
const mapping = this.phaseOptions.find(ph => ph.value === this.assignment.phase); return this.tagsObserver.getValue().length > 0;
return mapping ? mapping.display_name : '';
} }
/** /**
* 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 title
* @param translate * @param translate
@ -145,7 +152,6 @@ export class AssignmentDetailComponent extends BaseViewComponent implements OnIn
* @param formBuilder * @param formBuilder
* @param repo * @param repo
* @param userRepo * @param userRepo
* @param constants
* @param pollService * @param pollService
* @param agendaRepo * @param agendaRepo
* @param tagRepo * @param tagRepo
@ -163,17 +169,19 @@ export class AssignmentDetailComponent extends BaseViewComponent implements OnIn
formBuilder: FormBuilder, formBuilder: FormBuilder,
public repo: AssignmentRepositoryService, public repo: AssignmentRepositoryService,
private userRepo: UserRepositoryService, private userRepo: UserRepositoryService,
private constants: ConstantsService,
public pollService: AssignmentPollService, public pollService: AssignmentPollService,
private agendaRepo: ItemRepositoryService, private agendaRepo: ItemRepositoryService,
private tagRepo: TagRepositoryService, private tagRepo: TagRepositoryService,
private promptService: PromptService private promptService: PromptService
) { ) {
super(title, translate, matSnackBar); super(title, translate, matSnackBar);
/* Server side constants for phases */ this.subscriptions.push(
this.constants.get<AssignmentPhase[]>('AssignmentPhases').subscribe(phases => (this.phaseOptions = phases)); /* List of eligible users */
/* List of eligible users */ this.userRepo.getViewModelListObservable().subscribe(users => {
this.userRepo.getViewModelListObservable().subscribe(users => this.availableCandidates.next(users)); this.availableCandidates.next(users);
this.filterCandidates();
})
);
this.assignmentForm = formBuilder.group({ this.assignmentForm = formBuilder.group({
phase: null, phase: null,
tags_id: [], tags_id: [],
@ -184,7 +192,7 @@ export class AssignmentDetailComponent extends BaseViewComponent implements OnIn
agenda_item_id: '' // create agenda item agenda_item_id: '' // create agenda item
}); });
this.candidatesForm = formBuilder.group({ this.candidatesForm = formBuilder.group({
candidate: null userId: null
}); });
} }
@ -201,9 +209,9 @@ export class AssignmentDetailComponent extends BaseViewComponent implements OnIn
* Permission check for interactions. * Permission check for interactions.
* *
* Current operations supported: * Current operations supported:
* - addSelf: the user can add themself to the list of candidates * - addSelf: the user can add/remove themself to the list of candidates
* - addOthers: the user can add other candidates * - addOthers: the user can add/remove other candidates
* - createPoll: the user can add/edit election poll (requires candidates to be present) * - 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) * - manage: the user has general manage permissions (i.e. editing the assignment metaInfo)
* *
* @param operation the action requested * @param operation the action requested
@ -213,20 +221,28 @@ export class AssignmentDetailComponent extends BaseViewComponent implements OnIn
const isManager = this.operator.hasPerms('assignments.can_manage'); const isManager = this.operator.hasPerms('assignments.can_manage');
switch (operation) { switch (operation) {
case 'addSelf': case 'addSelf':
if (isManager && this.assignment.phase !== 2) { if (isManager && !this.assignment.isFinished) {
return true; return true;
} else { } 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': case 'addOthers':
if (isManager && this.assignment.phase !== 2) { if (isManager && !this.assignment.isFinished) {
return true; return true;
} else { } 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': case 'createPoll':
return ( return (
isManager && this.assignment && this.assignment.phase !== 2 && this.assignment.candidateAmount > 0 isManager && this.assignment && !this.assignment.isFinished && this.assignment.candidateAmount > 0
); );
case 'manage': case 'manage':
return isManager; return isManager;
@ -261,6 +277,7 @@ export class AssignmentDetailComponent extends BaseViewComponent implements OnIn
private patchForm(assignment: ViewAssignment): void { private patchForm(assignment: ViewAssignment): void {
this.assignmentCopy = assignment; this.assignmentCopy = assignment;
this.assignmentForm.patchValue({ this.assignmentForm.patchValue({
title: assignment.title || '',
tags_id: assignment.assignment.tags_id || [], tags_id: assignment.assignment.tags_id || [],
agendaItem: assignment.assignment.agenda_item_id || null, agendaItem: assignment.assignment.agenda_item_id || null,
phase: assignment.phase, // todo default: 0? 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> { public async addUser(userId: number): Promise<void> {
const candId = this.candidatesForm.get('candidate').value; const user = this.userRepo.getViewModel(userId);
this.candidatesForm.setValue({ candidate: null }); if (user) {
if (candId) { await this.repo.changeCandidate(user, this.assignment, true).then(null, this.raiseError);
await this.repo.changeCandidate(candId, this.assignment).then(null, this.raiseError);
} }
} }
/** /**
* Removes a user from the list of candidates * 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> { public async removeUser(candidate: ViewAssignmentRelatedUser): Promise<void> {
await this.repo.changeCandidate(user.id, this.assignment).then(null, this.raiseError); 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 { } else {
this.newAssignment = true; this.newAssignment = true;
// TODO set defaults? // TODO set defaults?
@ -357,8 +383,7 @@ export class AssignmentDetailComponent extends BaseViewComponent implements OnIn
public async onDeleteAssignmentButton(): Promise<void> { public async onDeleteAssignmentButton(): Promise<void> {
const title = this.translate.instant('Are you sure you want to delete this election?'); const title = this.translate.instant('Are you sure you want to delete this election?');
if (await this.promptService.open(title, this.assignment.getTitle())) { if (await this.promptService.open(title, this.assignment.getTitle())) {
await this.repo.delete(this.assignment); this.repo.delete(this.assignment).then(() => this.router.navigate(['../']), this.raiseError);
this.router.navigate(['../assignments/']);
} }
} }
@ -374,12 +399,10 @@ export class AssignmentDetailComponent extends BaseViewComponent implements OnIn
* TODO: only with existing assignments, else it should fail * TODO: only with existing assignments, else it should fail
* TODO check permissions and conditions * TODO check permissions and conditions
* *
* @param event * @param value the phase to set
*/ */
public async setPhase(event: MatSelectChange): Promise<void> { public async onSetPhaseButton(value: number): Promise<void> {
if (!this.newAssignment && this.phaseOptions.find(option => option.value === event.value)) { this.repo.update({ phase: value }, this.assignment).then(null, this.raiseError);
this.repo.update({ phase: event.value }, this.assignment).then(null, this.raiseError);
}
} }
public onDownloadPdf(): void { public onDownloadPdf(): void {
@ -426,11 +449,48 @@ export class AssignmentDetailComponent extends BaseViewComponent implements OnIn
/** /**
* Assemble a meaningful label for the poll * 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 { 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')} ${index + 1}`;
const title = this.translate.instant('Ballot'); if (poll.published) {
return `${title} ${index + 1} (${pubState})`; 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-header-cell *matHeaderCellDef mat-sort-header>Title</mat-header-cell>
<mat-cell *matCellDef="let assignment">{{ assignment.getListTitle() }}</mat-cell> <mat-cell *matCellDef="let assignment">{{ assignment.getListTitle() }}</mat-cell>
</ng-container> </ng-container>
<!-- pahse column--> <!-- phase column-->
<ng-container matColumnDef="phase"> <ng-container matColumnDef="phase">
<mat-header-cell *matHeaderCellDef mat-sort-header>Phase</mat-header-cell> <mat-header-cell *matHeaderCellDef mat-sort-header>Phase</mat-header-cell>
<mat-cell *matCellDef="let assignment"> <mat-cell *matCellDef="let assignment">
<mat-chip-list> <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-chip-list>
</mat-cell> </mat-cell>
<button mat-menu-item (click)="selectAll()"> <button mat-menu-item (click)="selectAll()">
@ -62,7 +62,7 @@
<mat-header-cell *matHeaderCellDef mat-sort-header>Candidates</mat-header-cell> <mat-header-cell *matHeaderCellDef mat-sort-header>Candidates</mat-header-cell>
<mat-cell *matCellDef="let assignment"> <mat-cell *matCellDef="let assignment">
<mat-chip-list> <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-chip-list>
</mat-cell> </mat-cell>
</ng-container> </ng-container>

View File

@ -1,7 +1,7 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { AssignmentListComponent } from './assignment-list.component'; import { AssignmentListComponent } from './assignment-list.component';
import { E2EImportsModule } from '../../../../../e2e-imports.module'; import { E2EImportsModule } from 'e2e-imports.module';
describe('AssignmentListComponent', () => { describe('AssignmentListComponent', () => {
let component: 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 { OperatorService } from 'app/core/core-services/operator.service';
import { PromptService } from 'app/core/ui-services/prompt.service'; import { PromptService } from 'app/core/ui-services/prompt.service';
import { StorageService } from 'app/core/core-services/storage.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 * List view for the assignments
@ -24,6 +24,11 @@ import { ViewAssignment } from '../../models/view-assignment';
styleUrls: ['./assignment-list.component.scss'] styleUrls: ['./assignment-list.component.scss']
}) })
export class AssignmentListComponent extends ListViewBaseComponent<ViewAssignment, Assignment> implements OnInit { export class AssignmentListComponent extends ListViewBaseComponent<ViewAssignment, Assignment> implements OnInit {
/**
* The different phases of an assignment. Info is fetched from server
*/
public phaseOptions = AssignmentPhases;
/** /**
* Constructor. * Constructor.
* *

View File

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

View File

@ -13,7 +13,52 @@
} }
} }
.votes { .candidate-name {
display: flex; word-wrap: break-word;
justify-content: space-between; 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 { MatDialogRef, MAT_DIALOG_DATA, MatSnackBar } from '@angular/material';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { AssignmentPollOption } from 'app/shared/models/assignments/assignment-poll-option';
import { AssignmentPollService } from '../../services/assignment-poll.service'; import { AssignmentPollService } from '../../services/assignment-poll.service';
import { CalculablePollKey, PollVoteValue } from 'app/core/ui-services/poll.service'; import { CalculablePollKey, PollVoteValue } from 'app/core/ui-services/poll.service';
import { AssignmentPoll } from 'app/shared/models/assignments/assignment-poll'; import { UserRepositoryService } from 'app/core/repositories/users/user-repository.service';
import { ViewUser } from 'app/site/users/models/view-user'; import { ViewAssignmentPoll } from '../../models/view-assignment-poll';
import { AssignmentPollOption } from 'app/shared/models/assignments/assignment-poll-option'; import { ViewAssignmentPollOption } from '../../models/view-assignment-poll-option';
/** /**
* Vote entries included once for summary (e.g. total votes cast) * 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. * 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'] styleUrls: ['./assignment-poll-dialog.component.scss']
}) })
export class AssignmentPollDialogComponent { 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. * List of accepted special non-numerical values.
* See {@link PollService.specialPollVotes} * See {@link PollService.specialPollVotes}
@ -40,15 +46,16 @@ export class AssignmentPollDialogComponent {
*/ */
public constructor( public constructor(
public dialogRef: MatDialogRef<AssignmentPollDialogComponent>, 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 matSnackBar: MatSnackBar,
private translate: TranslateService, private translate: TranslateService,
private pollService: AssignmentPollService public pollService: AssignmentPollService,
private userRepo: UserRepositoryService
) { ) {
this.specialValues = this.pollService.specialPollVotes; this.specialValues = this.pollService.specialPollVotes;
switch (this.data.poll.pollmethod) { switch (this.data.pollmethod) {
case 'votes': case 'votes':
this.optionPollKeys = ['Yes']; this.optionPollKeys = ['Votes'];
break; break;
case 'yn': case 'yn':
this.optionPollKeys = ['Yes', 'No']; this.optionPollKeys = ['Yes', 'No'];
@ -73,7 +80,7 @@ export class AssignmentPollDialogComponent {
* TODO better validation * TODO better validation
*/ */
public submit(): void { public submit(): void {
const error = this.data.poll.options.find(dataoption => { const error = this.data.options.find(dataoption => {
for (const key of this.optionPollKeys) { for (const key of this.optionPollKeys) {
const keyValue = dataoption.votes.find(o => o.value === key); const keyValue = dataoption.votes.find(o => o.value === key);
if (!keyValue || keyValue.weight === undefined) { if (!keyValue || keyValue.weight === undefined) {
@ -90,7 +97,7 @@ export class AssignmentPollDialogComponent {
} }
); );
} else { } else {
this.dialogRef.close(this.data.poll); this.dialogRef.close(this.data);
} }
} }
@ -104,18 +111,6 @@ export class AssignmentPollDialogComponent {
return this.pollService.getLabel(key); 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 * Updates a vote value
* *
@ -123,14 +118,14 @@ export class AssignmentPollDialogComponent {
* @param candidate the candidate for whom to update the value * @param candidate the candidate for whom to update the value
* @param newData the new 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); const vote = candidate.votes.find(v => v.value === value);
if (vote) { if (vote) {
vote.weight = parseInt(newData, 10); vote.weight = parseFloat(newData);
} else { } else {
candidate.votes.push({ candidate.votes.push({
value: value, value: value,
weight: parseInt(newData, 10) weight: parseFloat(newData)
}); });
} }
} }
@ -153,8 +148,8 @@ export class AssignmentPollDialogComponent {
* @param value * @param value
* @returns integer or undefined * @returns integer or undefined
*/ */
public getSumValue(value: summaryPollKeys): number | undefined { public getSumValue(value: summaryPollKey): number | undefined {
return this.data.poll[value] || undefined; return this.data[value] || undefined;
} }
/** /**
@ -163,7 +158,23 @@ export class AssignmentPollDialogComponent {
* @param value * @param value
* @param weight * @param weight
*/ */
public setSumValue(value: summaryPollKeys, weight: string): void { public setSumValue(value: summaryPollKey, weight: string): void {
this.data.poll[value] = parseInt(weight, 10); 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 ...' --> <mat-card class="os-card" *ngIf="poll">
<h3 translate>Ballot</h3> <div class="flex-spaced poll-menu">
<mat-card class="os-card">
<div class="flex-spaced">
<div>
<!-- *ngIf="poll.description"> -->
Description
<!-- {{ poll.description}} -->
</div>
<!-- Buttons --> <!-- Buttons -->
<div *osPerms="'assignments.can_manage'"> <button
<button mat-button (click)="printBallot()"> mat-icon-button
<span translate>Print ballot paper</span> *osPerms="'assignments.can_manage'; &quot;core.can_manage_projector&quot;"
</button> [matMenuTriggerFor]="pollItemMenu"
<button mat-button [disabled]="assignment.phase == 2" (click)="enterVotes()"> (click)="$event.stopPropagation()"
<span translate>Enter votes</span> >
</button> <mat-icon>more_vert</mat-icon>
<button mat-button (click)="togglePublished()"> </button>
<span *ngIf="!poll.published" translate>Publish</span> <mat-menu #pollItemMenu="matMenu">
<span *ngIf="poll.published" translate>Unpublish</span> <div *osPerms="'assignments.can_manage'">
</button> <button mat-menu-item (click)="printBallot()">
</div> <mat-icon>local_printshop</mat-icon>
<div *osPerms="'core.can_manage_projector'"> <span translate>Print ballot paper</span>
<button mat-button> </button>
<span translate>Project</span> <button mat-menu-item *ngIf="!assignment.isFinished" (click)="enterVotes()">
<!-- os-projector-button ?--> <mat-icon>edit</mat-icon>
</button> <span translate>Enter votes</span>
</div> </button>
<div *osPerms="'assignments.can_manage'"> <button mat-menu-item (click)="togglePublished()">
<button mat-button class="red-warning-text" (click)="onDeletePoll(poll)"> <mat-icon>
<span translate>Delete</span> {{ poll.published ? 'visibility_off' : 'visibility' }}
</button> </mat-icon>
</div> <span *ngIf="!poll.published" translate>Publish</span>
</div> <span *ngIf="poll.published" translate>Unpublish</span>
<div> </button>
<!-- text input 'set hint for pballot paper' --> </div>
<!-- submit: setBallotHint(poll) --> <div *osPerms="'core.can_manage_projector'">
</div> <button mat-menu-item>
<mat-icon>videocam</mat-icon>
<div *ngIf="pollService.majorityMethods && majorityChoice"> <span translate>Project</span>
<span translate>Majority method</span> <!-- os-projector-button ?-->
</button>
<mat-basic-chip *ngIf="canManage" [matMenuTriggerFor]="majorityMenu" class="grey" disableRipple> </div>
{{ majorityChoice.display_name | translate }} <div *osPerms="'assignments.can_manage'">
</mat-basic-chip> <mat-divider></mat-divider>
<mat-basic-chip *ngIf="!canManage" class="grey" disableRipple> <button mat-menu-item class="red-warning-text" (click)="onDeletePoll()">
{{ majorityChoice.display_name | translate }} <mat-icon>delete</mat-icon>
</mat-basic-chip> <span translate>Delete</span>
<mat-menu #majorityMenu="matMenu"> </button>
<button mat-menu-item *ngFor="let method of pollService.majorityMethods" (click)="setMajority(method)"> </div>
<mat-icon *ngIf="method.value === majorityChoice.value">
check
</mat-icon>
{{ method.display_name | translate }}
</button>
</mat-menu> </mat-menu>
</div> </div>
<div class="on-transition-fade" *ngIf="poll && poll.options"> <div>
<div *ngFor="let option of poll.options" class="flex-spaced"> <h4>
<div *ngIf="poll.published"> <span translate>Poll description</span>
<mat-checkbox [checked]="option.is_elected" (change)="toggleElected(option)"> </h4>
<!-- TODO mark/unkmark elected osPerms --> <div [formGroup]="descriptionForm">
</mat-checkbox> <mat-form-field class="wide">
</div> <input matInput formControlName="description" [disabled]="!canManage" />
<!-- candidate Name --> </mat-form-field>
<div> <button
{{ option.user.full_name }} mat-icon-button
</div> [disabled]="!dirtyDescription"
<!-- Votes --> *ngIf="canManage"
<div *ngIf="poll.published && poll.has_votes"> (click)="onEditDescriptionButton()"
<div *ngFor="let vote of option.votes"> >
<div class="poll-progress on-transition-fade"> <mat-icon inline>edit</mat-icon>
<span>{{ pollService.getLabel(vote.value) | translate }}:</span> </button>
{{ pollService.getSpecialLabel(vote.weight) }} </div>
<span *ngIf="!pollService.isAbstractOption(poll, option)" <div class="spacer-bottom-10">
>({{ pollService.getPercent(poll, option, vote.value) }}%)</span <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>
<div *ngIf="!pollService.isAbstractOption(poll, option)" class="poll-progress-bar"> <div>
<mat-progress-bar <!-- manager majority chip (menu trigger) -->
mode="determinate" <mat-basic-chip *ngIf="canManage" [matMenuTriggerFor]="majorityMenu" class="grey" disableRipple>
[value]="pollService.getPercent(poll, option, vote.value)" {{ majorityChoice.display_name | translate }}
[ngClass]="pollService.getProgressBarColor(vote.value)" </mat-basic-chip>
> <!-- non-manager (menuless) majority chip -->
</mat-progress-bar> <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>
</div> </div>
<div <div *ngFor="let option of poll.options" class="poll-grid poll-border">
*ngIf=" <div>
poll.has_votes && <div>
poll.published && <button
majorityChoice && type="button"
majorityChoice.value !== 'disabled' && mat-icon-button
!pollService.isAbstractOption(poll, option) (click)="toggleElected(option)"
" [disabled]="!canManage || assignment.isFinished"
> disableRipple
<span>{{ pollService.yesQuorum(majorityChoice, poll, option) }}</span> >
<span *ngIf="quorumReached(option)" class="green-text"> <mat-icon
<mat-icon>done</mat-icon> *ngIf="option.is_elected"
</span> class="top-aligned green-text"
<span *ngIf="!quorumReached(option)" class="red-warning-text"> matTooltip="{{ 'Elected' | translate }}"
<mat-icon>cancel</mat-icon> >check_box</mat-icon
</span> >
<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> </div>
<div> <div *ngIf="!pollData">
<!-- summary --> <h4 translate>Candidates</h4>
<div *ngFor="let key of pollValues"> <div *ngFor="let option of poll.options">
<div> <span class="accent"> {{ option.user.full_name }}</span>
<span>{{ key | translate }}</span>:
</div>
<div>
{{ poll[key] | precisionPipe }}
{{ pollService.getSpecialLabel(poll[key]) }}
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -59,3 +59,36 @@
padding: 1px; 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 { Component, OnInit, Input } from '@angular/core';
import { FormGroup, FormBuilder } from '@angular/forms';
import { MatDialog, MatSnackBar } from '@angular/material'; import { MatDialog, MatSnackBar } from '@angular/material';
import { TranslateService } from '@ngx-translate/core'; 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 { AssignmentPollDialogComponent } from '../assignment-poll-dialog/assignment-poll-dialog.component';
import { AssignmentPollService } from '../../services/assignment-poll.service'; import { AssignmentPollService } from '../../services/assignment-poll.service';
import { AssignmentRepositoryService } from 'app/core/repositories/assignments/assignment-repository.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 { MajorityMethod, CalculablePollKey } from 'app/core/ui-services/poll.service';
import { OperatorService } from 'app/core/core-services/operator.service'; import { OperatorService } from 'app/core/core-services/operator.service';
import { AssignmentPoll } from 'app/shared/models/assignments/assignment-poll'; import { AssignmentPoll } from 'app/shared/models/assignments/assignment-poll';
import { PromptService } from 'app/core/ui-services/prompt.service'; 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 { 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 { 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 * Component for a single assignment poll. Used in assignment detail view
@ -37,6 +38,11 @@ export class AssignmentPollComponent extends BaseViewComponent implements OnInit
@Input() @Input()
public poll: ViewAssignmentPoll; public poll: ViewAssignmentPoll;
/**
* Form for updating the poll's description
*/
public descriptionForm: FormGroup;
/** /**
* The selected Majority method to display quorum calculations. Will be * The selected Majority method to display quorum calculations. Will be
* set/changed by the user * 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); 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 * Gets the translated poll method name
* *
@ -90,6 +113,8 @@ export class AssignmentPollComponent extends BaseViewComponent implements OnInit
/** /**
* constructor. Does nothing * constructor. Does nothing
* *
* @param titleService
* @param matSnackBar
* @param pollService poll related calculations * @param pollService poll related calculations
* @param operator permission checks * @param operator permission checks
* @param assignmentRepo The repository to the assignments * @param assignmentRepo The repository to the assignments
@ -105,7 +130,8 @@ export class AssignmentPollComponent extends BaseViewComponent implements OnInit
private assignmentRepo: AssignmentRepositoryService, private assignmentRepo: AssignmentRepositoryService,
public translate: TranslateService, public translate: TranslateService,
public dialog: MatDialog, public dialog: MatDialog,
private promptService: PromptService private promptService: PromptService,
private formBuilder: FormBuilder
) { ) {
super(titleService, translate, matSnackBar); super(titleService, translate, matSnackBar);
} }
@ -117,6 +143,9 @@ export class AssignmentPollComponent extends BaseViewComponent implements OnInit
this.majorityChoice = this.majorityChoice =
this.pollService.majorityMethods.find(method => method.value === this.pollService.defaultMajorityMethod) || this.pollService.majorityMethods.find(method => method.value === this.pollService.defaultMajorityMethod) ||
null; 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) * closes successfully (validation is done there)
*/ */
public enterVotes(): void { 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, { 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', maxHeight: '90vh',
minWidth: '300px', minWidth: '300px',
maxWidth: '80vw', maxWidth: '80vw',
@ -213,4 +237,27 @@ export class AssignmentPollComponent extends BaseViewComponent implements OnInit
this.assignmentRepo.markElected(viewAssignmentRelatedUser, this.assignment, !option.is_elected); 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 { AssignmentPoll } from 'app/shared/models/assignments/assignment-poll';
import { AssignmentPollMethod } from '../services/assignment-poll.service'; import { AssignmentPollMethod } from '../services/assignment-poll.service';
import { ViewAssignmentPollOption } from './view-assignment-poll-option'; import { ViewAssignmentPollOption } from './view-assignment-poll-option';
import { AssignmentPollOption } from 'app/shared/models/assignments/assignment-poll-option';
export class ViewAssignmentPoll implements Identifiable, Updateable { export class ViewAssignmentPoll implements Identifiable, Updateable {
private _assignmentPoll: AssignmentPoll; private _assignmentPoll: AssignmentPoll;
@ -37,14 +38,25 @@ export class ViewAssignmentPoll implements Identifiable, Updateable {
return this.poll.votesvalid; return this.poll.votesvalid;
} }
public set votesvalid(amount: number) {
this.poll.votesvalid = amount;
}
public get votesinvalid(): number { public get votesinvalid(): number {
return this.poll.votesinvalid; return this.poll.votesinvalid;
} }
public set votesinvalid(amount: number) {
this.poll.votesinvalid = amount;
}
public get votescast(): number { public get votescast(): number {
return this.poll.votescast; return this.poll.votescast;
} }
public set votescast(amount: number) {
this.poll.votescast = amount;
}
public get has_votes(): boolean { public get has_votes(): boolean {
return this.poll.has_votes; return this.poll.has_votes;
} }
@ -68,4 +80,22 @@ export class ViewAssignmentPoll implements Identifiable, Updateable {
public updateDependencies(update: BaseViewModel): void { public updateDependencies(update: BaseViewModel): void {
this.options.forEach(option => option.updateDependencies(update)); 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 { 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 { 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 { 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 _assignmentRelatedUser: AssignmentRelatedUser;
private _user?: ViewUser; private _user?: ViewUser;
@ -46,4 +47,12 @@ export class ViewAssignmentRelatedUser implements Updateable, Identifiable {
this._user = update; 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 { ViewAssignmentRelatedUser } from './view-assignment-related-user';
import { ViewAssignmentPoll } from './view-assignment-poll'; import { ViewAssignmentPoll } from './view-assignment-poll';
export interface AssignmentPhase { /**
value: number; * A constant containing all possible assignment phases and their different
display_name: string; * 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 { export class ViewAssignment extends BaseAgendaViewModel {
public static COLLECTIONSTRING = Assignment.COLLECTIONSTRING; public static COLLECTIONSTRING = Assignment.COLLECTIONSTRING;
@ -62,12 +80,37 @@ export class ViewAssignment extends BaseAgendaViewModel {
return this.assignment.phase; 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 { public get candidateAmount(): number {
return this._assignmentRelatedUsers ? this._assignmentRelatedUsers.length : 0; return this._assignmentRelatedUsers ? this._assignmentRelatedUsers.length : 0;
} }
/** /**
* This is set by the repository * Constructor. Is set by the repository
*/ */
public getVerboseName; public getVerboseName;
public getAgendaTitle; 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 { Assignment } from 'app/shared/models/assignments/assignment';
import { BaseFilterListService, OsFilter } from 'app/core/ui-services/base-filter-list.service'; import { BaseFilterListService, OsFilter } from 'app/core/ui-services/base-filter-list.service';
import { StorageService } from 'app/core/core-services/storage.service'; import { StorageService } from 'app/core/core-services/storage.service';
import { ViewAssignment, AssignmentPhase } from '../models/view-assignment'; import { ViewAssignment, AssignmentPhases } from '../models/view-assignment';
import { ConstantsService } from 'app/core/core-services/constants.service';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@ -37,11 +36,7 @@ export class AssignmentFilterListService extends BaseFilterListService<Assignmen
* @param assignmentRepo Repository * @param assignmentRepo Repository
* @param constants the openslides constant service to get the assignment options * @param constants the openslides constant service to get the assignment options
*/ */
public constructor( public constructor(store: StorageService, assignmentRepo: AssignmentRepositoryService) {
store: StorageService,
assignmentRepo: AssignmentRepositoryService,
private constants: ConstantsService
) {
super(store, assignmentRepo); super(store, assignmentRepo);
this.createPhaseOptions(); this.createPhaseOptions();
} }
@ -51,14 +46,8 @@ export class AssignmentFilterListService extends BaseFilterListService<Assignmen
* constants * constants
*/ */
private createPhaseOptions(): void { private createPhaseOptions(): void {
this.constants.get<AssignmentPhase[]>('AssignmentPhases').subscribe(phases => { this.phaseFilter.options = AssignmentPhases.map(ap => {
this.phaseFilter.options = phases.map(ph => { return { label: ap.display_name, condition: ap.value, isActive: false };
return {
label: ph.display_name,
condition: ph.value,
isActive: false
};
});
}); });
this.updateFilterDefinitions(this.filterOptions); this.updateFilterDefinitions(this.filterOptions);
} }

View File

@ -117,6 +117,25 @@ export class AssignmentPollService extends PollService {
return Math.round(((vote.weight * 100) / base) * 100) / 100; 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) * 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', path: 'assignments',
loadChildren: './assignments/assignments.module#AssignmentsModule', loadChildren: './assignments/assignments.module#AssignmentsModule',
data: { basePerm: 'assignment.can_see' } data: { basePerm: 'assignments.can_see' }
}, },
{ {
path: 'mediafiles', 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 django.apps import AppConfig
from mypy_extensions import TypedDict
class AssignmentsAppConfig(AppConfig): class AssignmentsAppConfig(AppConfig):
@ -51,14 +50,6 @@ class AssignmentsAppConfig(AppConfig):
""" """
yield self.get_model("Assignment") 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]: def required_users(element: Dict[str, Any]) -> Set[int]:
""" """