diff --git a/client/src/app/core/repositories/assignments/assignment-repository.service.ts b/client/src/app/core/repositories/assignments/assignment-repository.service.ts index 7f62dd8c7..cc6943eda 100644 --- a/client/src/app/core/repositories/assignments/assignment-repository.service.ts +++ b/client/src/app/core/repositories/assignments/assignment-repository.service.ts @@ -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 { - const data = { user: userId }; - if (assignment.candidates.some(candidate => candidate.id === userId)) { + public async changeCandidate(user: ViewUser, assignment: ViewAssignment, adding?: boolean): Promise { + 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 { 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 { - 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 { + const restPath = `/rest/assignments/assignment/${assignment.id}/sort_related_users/`; + const data = { related_users: sortedCandidates }; + await this.httpService.post(restPath, data); } } diff --git a/client/src/app/core/ui-services/poll.service.ts b/client/src/app/core/ui-services/poll.service.ts index 185e81e72..23f4e8bb4 100644 --- a/client/src/app/core/ui-services/poll.service.ts +++ b/client/src/app/core/ui-services/poll.service.ts @@ -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() {} diff --git a/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.html b/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.html index 414797702..6fe3c58a2 100644 --- a/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.html +++ b/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.html @@ -8,10 +8,7 @@ >
-

- {{ assignment.getTitle() }} - {{ assignmentForm.get('title').value }} -

+

Election

New election

@@ -23,17 +20,17 @@ - -
+
+
+
- + - - - - - + + + + + + + -
- - - -
- + - +
- -
-
-
- -
- {{ candidate.full_name }} - -
-
-
-
-
- - - - - -
-
- - - -
- -
+ + + + + + +
+ +
+
+ + + + +
+
+ +
+
+ + +
+
+
+ + +
+
@@ -180,15 +182,17 @@ (keydown)="onKeyDown($event)" *ngIf="assignment && editAssignment" > - - - - +
+ + + + +
-
+
-
+
- - - +
+ + + +
- - - +
+ + + +
diff --git a/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.scss b/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.scss index e69de29bb..21b5a8a2b 100644 --- a/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.scss +++ b/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.scss @@ -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; +} diff --git a/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.ts b/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.ts index b3503d034..a71a05cef 100644 --- a/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.ts +++ b/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.ts @@ -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([]); + private availableCandidates = new BehaviorSubject([]); /** - * 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([]); @@ -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('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 { - 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 { + 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 { - await this.repo.changeCandidate(user.id, this.assignment).then(null, this.raiseError); + public async removeUser(candidate: ViewAssignmentRelatedUser): Promise { + 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 { 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 { - 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 { + 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); } } diff --git a/client/src/app/site/assignments/components/assignment-list/assignment-list.component.html b/client/src/app/site/assignments/components/assignment-list/assignment-list.component.html index 95ee37df7..2179f4fab 100644 --- a/client/src/app/site/assignments/components/assignment-list/assignment-list.component.html +++ b/client/src/app/site/assignments/components/assignment-list/assignment-list.component.html @@ -40,12 +40,12 @@ Title {{ assignment.getListTitle() }} - + Phase - {{ assignment.phase }} + {{ assignment.phaseString | translate }} diff --git a/client/src/app/site/assignments/components/assignment-poll-dialog/assignment-poll-dialog.component.scss b/client/src/app/site/assignments/components/assignment-poll-dialog/assignment-poll-dialog.component.scss index c57c41aa0..0b4d4e0f7 100644 --- a/client/src/app/site/assignments/components/assignment-poll-dialog/assignment-poll-dialog.component.scss +++ b/client/src/app/site/assignments/components/assignment-poll-dialog/assignment-poll-dialog.component.scss @@ -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; } diff --git a/client/src/app/site/assignments/components/assignment-poll-dialog/assignment-poll-dialog.component.ts b/client/src/app/site/assignments/components/assignment-poll-dialog/assignment-poll-dialog.component.ts index e9499eacb..ecfaae768 100644 --- a/client/src/app/site/assignments/components/assignment-poll-dialog/assignment-poll-dialog.component.ts +++ b/client/src/app/site/assignments/components/assignment-poll-dialog/assignment-poll-dialog.component.ts @@ -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, - @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 : ''; } } diff --git a/client/src/app/site/assignments/components/assignment-poll/assignment-poll.component.html b/client/src/app/site/assignments/components/assignment-poll/assignment-poll.component.html index 8ef843193..8fc6d4b34 100644 --- a/client/src/app/site/assignments/components/assignment-poll/assignment-poll.component.html +++ b/client/src/app/site/assignments/components/assignment-poll/assignment-poll.component.html @@ -1,119 +1,197 @@ - -

Ballot

- -
-
- - Description - -
+ +
-
- - - -
-
- -
-
- -
-
-
- - -
- -
- Majority method - - - {{ majorityChoice.display_name | translate }} - - - {{ majorityChoice.display_name | translate }} - - - + + +
+ + + +
+
+ +
+
+ + +
-
-
-
- - - -
- -
- {{ option.user.full_name }} -
- -
-
-
- {{ pollService.getLabel(vote.value) | translate }}: - {{ pollService.getSpecialLabel(vote.weight) }} - ({{ pollService.getPercent(poll, option, vote.value) }}%) +
+

+ Poll description +

+
+ + + + +
+
+

Poll method

+ {{ pollMethodName | translate }} +
+
+
+
+
+
+
Candidate
+
Votes
+
+
+ Quorum
-
- - +
+ + + {{ majorityChoice.display_name | translate }} + + + + {{ majorityChoice.display_name | translate }} + + + + +
-
- {{ pollService.yesQuorum(majorityChoice, poll, option) }} - - done - - - cancel - +
+
+
+ +
+
+ +
+ {{ option.user.full_name }} +
+ +
+
+
+
+ {{ pollService.getLabel(vote.value) | translate }}: + {{ pollService.getSpecialLabel(vote.weight) }} + ({{ pollService.getPercent(poll, option, vote.value) }}%) +
+
+ + +
+
+
+
+
+
+ {{ pollService.yesQuorum(majorityChoice, poll, option) }} + + {{ pollService.getIcon('yes') }} + {{ pollService.getIcon('no') }} + +
+
+
+ +
+
+
+
+ {{ pollService.getLabel(key) | translate }}: +
+
+ {{ pollService.getSpecialLabel(poll[key]) | translate }} +
+
-
- -
-
- {{ key | translate }}: -
-
- {{ poll[key] | precisionPipe }} - {{ pollService.getSpecialLabel(poll[key]) }} -
+
+

Candidates

+
+ {{ option.user.full_name }}
diff --git a/client/src/app/site/assignments/components/assignment-poll/assignment-poll.component.scss b/client/src/app/site/assignments/components/assignment-poll/assignment-poll.component.scss index b24fe7fa4..cdf022d07 100644 --- a/client/src/app/site/assignments/components/assignment-poll/assignment-poll.component.scss +++ b/client/src/app/site/assignments/components/assignment-poll/assignment-poll.component.scss @@ -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%; +} diff --git a/client/src/app/site/assignments/components/assignment-poll/assignment-poll.component.ts b/client/src/app/site/assignments/components/assignment-poll/assignment-poll.component.ts index ff348b0df..ee2fe28c1 100644 --- a/client/src/app/site/assignments/components/assignment-poll/assignment-poll.component.ts +++ b/client/src/app/site/assignments/components/assignment-poll/assignment-poll.component.ts @@ -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 { + 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}`; + } } diff --git a/client/src/app/site/assignments/models/view-assignment-poll.ts b/client/src/app/site/assignments/models/view-assignment-poll.ts index 9c77105a9..063757473 100644 --- a/client/src/app/site/assignments/models/view-assignment-poll.ts +++ b/client/src/app/site/assignments/models/view-assignment-poll.ts @@ -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 + ); + }) + ); + } } diff --git a/client/src/app/site/assignments/models/view-assignment-related-user.ts b/client/src/app/site/assignments/models/view-assignment-related-user.ts index 179b5b4e5..4ccce12f7 100644 --- a/client/src/app/site/assignments/models/view-assignment-related-user.ts +++ b/client/src/app/site/assignments/models/view-assignment-related-user.ts @@ -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(); + } } diff --git a/client/src/app/site/assignments/models/view-assignment.ts b/client/src/app/site/assignments/models/view-assignment.ts index fc6f7092f..7f8a8a573 100644 --- a/client/src/app/site/assignments/models/view-assignment.ts +++ b/client/src/app/site/assignments/models/view-assignment.ts @@ -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; diff --git a/client/src/app/site/assignments/services/assignment-filter.service.ts b/client/src/app/site/assignments/services/assignment-filter.service.ts index 1051ce19a..c896256aa 100644 --- a/client/src/app/site/assignments/services/assignment-filter.service.ts +++ b/client/src/app/site/assignments/services/assignment-filter.service.ts @@ -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('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); } diff --git a/client/src/app/site/assignments/services/assignment-poll.service.ts b/client/src/app/site/assignments/services/assignment-poll.service.ts index 86c3af68e..79f733509 100644 --- a/client/src/app/site/assignments/services/assignment-poll.service.ts +++ b/client/src/app/site/assignments/services/assignment-poll.service.ts @@ -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) * diff --git a/client/src/app/site/site-routing.module.ts b/client/src/app/site/site-routing.module.ts index 884c21445..d8ddbaa3e 100644 --- a/client/src/app/site/site-routing.module.ts +++ b/client/src/app/site/site-routing.module.ts @@ -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', diff --git a/openslides/assignments/apps.py b/openslides/assignments/apps.py index 8c0d97862..33c16c771 100644 --- a/openslides/assignments/apps.py +++ b/openslides/assignments/apps.py @@ -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]: """