Vote delegations on client

Add "your vote was delegated" error

Observe operator alterations during vote

Add "canVoteFor" getter

Adjust poll progress bars
This commit is contained in:
Sean 2020-09-22 14:50:55 +02:00
parent 3ac8569712
commit 8c28b03ffc
26 changed files with 465 additions and 183 deletions

View File

@ -7,8 +7,8 @@ import { HttpService } from 'app/core/core-services/http.service';
import { RelationManagerService } from 'app/core/core-services/relation-manager.service';
import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service';
import { RelationDefinition } from 'app/core/definitions/relations';
import { VotingService } from 'app/core/ui-services/voting.service';
import { AssignmentPoll } from 'app/shared/models/assignments/assignment-poll';
import { UserVote } from 'app/shared/models/poll/base-vote';
import { ViewAssignment } from 'app/site/assignments/models/view-assignment';
import { ViewAssignmentOption } from 'app/site/assignments/models/view-assignment-option';
import { AssignmentPollTitleInformation, ViewAssignmentPoll } from 'app/site/assignments/models/view-assignment-poll';
@ -97,7 +97,6 @@ export class AssignmentPollRepositoryService extends BasePollRepositoryService<
viewModelStoreService: ViewModelStoreService,
translate: TranslateService,
relationManager: RelationManagerService,
votingService: VotingService,
http: HttpService
) {
super(
@ -110,7 +109,6 @@ export class AssignmentPollRepositoryService extends BasePollRepositoryService<
AssignmentPoll,
AssignmentPollRelations,
{},
votingService,
http
);
}
@ -123,14 +121,11 @@ export class AssignmentPollRepositoryService extends BasePollRepositoryService<
return this.translate.instant(plural ? 'Polls' : 'Poll');
};
public vote(data: VotingData, poll_id: number): Promise<void> {
let requestData;
if (data.global) {
requestData = `"${data.global}"`;
} else {
requestData = data.votes;
}
public vote(data: VotingData, poll_id: number, userId?: number): Promise<void> {
const requestData: UserVote = {
data: data.global ?? data.votes,
user_id: userId ?? undefined
};
return this.http.post(`/rest/assignments/assignment-poll/${poll_id}/vote/`, requestData);
}
}

View File

@ -7,9 +7,8 @@ import { HttpService } from 'app/core/core-services/http.service';
import { RelationManagerService } from 'app/core/core-services/relation-manager.service';
import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service';
import { RelationDefinition } from 'app/core/definitions/relations';
import { VotingService } from 'app/core/ui-services/voting.service';
import { MotionPoll } from 'app/shared/models/motions/motion-poll';
import { VoteValue } from 'app/shared/models/poll/base-vote';
import { UserVote, VoteValue } from 'app/shared/models/poll/base-vote';
import { ViewMotion } from 'app/site/motions/models/view-motion';
import { ViewMotionOption } from 'app/site/motions/models/view-motion-option';
import { MotionPollTitleInformation, ViewMotionPoll } from 'app/site/motions/models/view-motion-poll';
@ -66,7 +65,6 @@ export class MotionPollRepositoryService extends BasePollRepositoryService<
viewModelStoreService: ViewModelStoreService,
translate: TranslateService,
relationManager: RelationManagerService,
votingService: VotingService,
http: HttpService
) {
super(
@ -79,7 +77,6 @@ export class MotionPollRepositoryService extends BasePollRepositoryService<
MotionPoll,
MotionPollRelations,
{},
votingService,
http
);
}
@ -92,7 +89,11 @@ export class MotionPollRepositoryService extends BasePollRepositoryService<
return this.translate.instant(plural ? 'Polls' : 'Poll');
};
public vote(vote: VoteValue, poll_id: number): Promise<void> {
return this.http.post(`/rest/motions/motion-poll/${poll_id}/vote/`, JSON.stringify(vote));
public vote(vote: VoteValue, poll_id: number, userId?: number): Promise<void> {
const requestData: UserVote = {
data: vote,
user_id: userId ?? undefined
};
return this.http.post(`/rest/motions/motion-poll/${poll_id}/vote/`, requestData);
}
}

View File

@ -43,6 +43,18 @@ const UserRelations: RelationDefinition[] = [
ownIdKey: 'groups_id',
ownKey: 'groups',
foreignViewModel: ViewGroup
},
{
type: 'M2O',
ownIdKey: 'vote_delegated_to_id',
ownKey: 'voteDelegatedTo',
foreignViewModel: ViewUser
},
{
type: 'M2M',
ownIdKey: 'vote_delegated_from_users_id',
ownKey: 'voteDelegationsFrom',
foreignViewModel: ViewUser
}
];
@ -255,6 +267,8 @@ export class UserRepositoryService extends BaseRepository<ViewUser, User, UserTi
public async update(update: Partial<User>, viewModel: ViewUser): Promise<void> {
this.preventAlterationOnDemoUsers(viewModel);
console.log('update: ', update);
return super.update(update, viewModel);
}

View File

@ -1,7 +1,10 @@
import { Injectable } from '@angular/core';
import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
import { PollState, PollType } from 'app/shared/models/poll/base-poll';
import { ViewBasePoll } from 'app/site/polls/models/view-base-poll';
import { ViewUser } from 'app/site/users/models/view-user';
import { OperatorService } from '../core-services/operator.service';
export enum VotingError {
@ -9,19 +12,21 @@ export enum VotingError {
POLL_WRONG_TYPE,
USER_HAS_NO_PERMISSION,
USER_IS_ANONYMOUS,
USER_NOT_PRESENT
USER_NOT_PRESENT,
USER_HAS_DELEGATED_RIGHT
}
/**
* TODO: It appears that the only message that makes sense for the user to see it the last one.
*/
export const VotingErrorVerbose = {
1: "You can't vote on this poll right now because it's not in the 'Started' state.",
2: "You can't vote on this poll because its type is set to analog voting.",
3: "You don't have permission to vote on this poll.",
4: 'You have to be logged in to be able to vote.',
5: 'You have to be present to vote on a poll.',
6: "You have already voted on this poll. You can't change your vote in a pseudoanonymous poll."
const VotingErrorVerbose = {
1: _("You can not vote on this poll right now because it is not in the 'Started' state."),
2: _('You can not vote on this poll because its type is set to analog voting.'),
3: _('You do not not have the permission to vote on this poll.'),
4: _('You have to be logged in to be able to vote.'),
5: _('You have to be present to vote on a poll.'),
6: _('Your right to vote was delegated to another user.'),
7: _('You have already voted on this poll. You can not change your vote in a pseudoanonymous poll.')
};
@Injectable({
@ -33,8 +38,8 @@ export class VotingService {
/**
* checks whether the operator can vote on the given poll
*/
public canVote(poll: ViewBasePoll): boolean {
const error = this.getVotePermissionError(poll);
public canVote(poll: ViewBasePoll, user?: ViewUser): boolean {
const error = this.getVotePermissionError(poll, user);
return !error;
}
@ -42,12 +47,11 @@ export class VotingService {
* checks whether the operator can vote on the given poll
* @returns null if no errors exist (= user can vote) or else a VotingError
*/
public getVotePermissionError(poll: ViewBasePoll): VotingError | void {
public getVotePermissionError(poll: ViewBasePoll, user: ViewUser = this.operator.viewUser): VotingError | void {
if (this.operator.isAnonymous) {
return VotingError.USER_IS_ANONYMOUS;
}
const user = this.operator.user;
if (!poll.groups_id.intersect(user.groups_id).length) {
return VotingError.USER_HAS_NO_PERMISSION;
}
@ -57,13 +61,16 @@ export class VotingService {
if (poll.state !== PollState.Started) {
return VotingError.POLL_WRONG_STATE;
}
if (!user.is_present) {
if (!user.is_present && !this.operator.viewUser.canVoteFor(user)) {
return VotingError.USER_NOT_PRESENT;
}
if (user.isVoteRightDelegated && this.operator.user.id === user.id) {
return VotingError.USER_HAS_DELEGATED_RIGHT;
}
}
public getVotePermissionErrorVerbose(poll: ViewBasePoll): string | void {
const error = this.getVotePermissionError(poll);
public getVotePermissionErrorVerbose(poll: ViewBasePoll, user: ViewUser = this.operator.viewUser): string | void {
const error = this.getVotePermissionError(poll, user);
if (error) {
return VotingErrorVerbose[error];
}

View File

@ -29,7 +29,7 @@
</button>
</ng-container>
<ng-container *ngIf="!multiple && includeNone">
<mat-option>
<mat-option [value]="null">
{{ noneTitle | translate }}
</mat-option>
<mat-divider></mat-divider>

View File

@ -56,8 +56,9 @@ export abstract class BasePoll<
public votescast: number;
public groups_id: number[];
public majority_method: MajorityMethod;
public voted_id: number[];
public user_has_voted: boolean;
public user_has_voted_for_delegations: number[];
public pollmethod: PM;
public onehundred_percent_base: PB;

View File

@ -16,6 +16,13 @@ export const GeneralValueVerbose = {
votesabstain: 'Votes abstain'
};
export interface UserVote {
// the voting payload is hard to describe.
// Can be "VoteValue" or any userID-Number sequence in combination with any VoteValue
data: Object;
user_id?: number;
}
export abstract class BaseVote<T = any> extends BaseDecimalModel<T> {
public weight: number;
public value: VoteValue;

View File

@ -30,6 +30,8 @@ export class User extends BaseDecimalModel<User> {
public is_present: boolean;
public is_committee: boolean;
public email?: string;
public vote_delegated_to_id: number;
public vote_delegated_from_users_id: number[];
public last_email_send?: string; // ISO datetime string
public comment?: string;
public is_active?: boolean;
@ -41,6 +43,10 @@ export class User extends BaseDecimalModel<User> {
return this.vote_weight === 1;
}
public get isVoteRightDelegated(): boolean {
return !!this.vote_delegated_to_id;
}
public constructor(input?: Partial<User>) {
super(User.COLLECTIONSTRING, input);
}

View File

@ -1,5 +1,27 @@
<ng-container *ngIf="poll">
<ng-container *ngIf="vmanager.canVote(poll) && !alreadyVoted && !deliveringVote; else cannotVote">
<!-- own voting -->
<ng-container [ngTemplateOutlet]="votingArea"></ng-container>
<!-- Delegations -->
<ng-container *ngIf="user.is_present">
<div class="assignment-vote-delegation" *ngFor="let delegation of delegations">
<mat-divider></mat-divider>
<ng-container
[ngTemplateOutlet]="votingArea"
[ngTemplateOutletContext]="{ delegation: delegation }"
></ng-container>
</div>
</ng-container>
</ng-container>
<ng-template #votingArea let-delegation="delegation">
<h4 *ngIf="delegation" class="assignment-delegation-title">
<span>{{ 'Vote delegation for' | translate }}</span>
<span>&nbsp;{{ delegation.getFullName() }}</span>
</h4>
<ng-container *ngIf="canVote(delegation)">
<!-- Poll hint -->
<p *ngIf="pollHint">
<i>{{ pollHint }}</i>
@ -9,7 +31,7 @@
<h4 *ngIf="poll.pollmethod === AssignmentPollMethod.Votes && poll.votes_amount > 1">
{{ 'Available votes' | translate }}:
<b> {{ getVotesAvailable() }}/{{ poll.votes_amount }} </b>
<b> {{ getVotesAvailable(delegation) }}/{{ poll.votes_amount }} </b>
</h4>
<!-- Options and Actions -->
@ -36,14 +58,9 @@
<button
class="vote-button"
mat-raised-button
(click)="saveSingleVote(option.id, action.vote)"
[disabled]="deliveringVote"
[ngClass]="
voteRequestData.votes[option.id] === action.vote ||
voteRequestData.votes[option.id] === 1
? action.css
: ''
"
(click)="saveSingleVote(option.id, action.vote, delegation)"
[disabled]="isDeliveringVote(delegation)"
[ngClass]="getActionButtonClass(action, option, delegation)"
>
<mat-icon> {{ action.icon }}</mat-icon>
</button>
@ -64,9 +81,9 @@
<button
class="vote-button"
mat-raised-button
(click)="saveGlobalVote('N')"
[ngClass]="voteRequestData.global === 'N' ? 'voted-no' : ''"
[disabled]="deliveringVote"
(click)="saveGlobalVote('N', delegation)"
[ngClass]="getGlobalNoClass(delegation)"
[disabled]="isDeliveringVote(delegation)"
>
<mat-icon> thumb_down </mat-icon>
</button>
@ -79,8 +96,9 @@
<button
class="vote-button"
mat-raised-button
(click)="saveGlobalVote('A')"
[ngClass]="voteRequestData.global === 'A' ? 'voted-abstain' : ''"
(click)="saveGlobalVote('A', delegation)"
[ngClass]="getGlobalAbstainClass(delegation)"
[disabled]="isDeliveringVote(delegation)"
>
<mat-icon> trip_origin</mat-icon>
</button>
@ -92,39 +110,47 @@
</ng-container>
<!-- Submit Vote -->
<ng-container [ngTemplateOutlet]="sendNow"></ng-container>
<ng-container
[ngTemplateOutlet]="sendNow"
[ngTemplateOutletContext]="{ delegation: delegation }"
></ng-container>
</ng-container>
<!-- Shows the permission error -->
<ng-container *ngIf="!vmanager.canVote(poll)">
<span>{{ vmanager.getVotePermissionErrorVerbose(poll) | translate }}</span>
<ng-container
*ngIf="!canVote(delegation)"
[ngTemplateOutlet]="cannotVote"
[ngTemplateOutletContext]="{ delegation: delegation }"
>
</ng-container>
</ng-container>
</ng-template>
<ng-template #cannotVote>
<ng-template #cannotVote let-delegation="delegation">
<div class="centered-button-wrapper">
<div *ngIf="!deliveringVote">
<mat-icon class="vote-submitted">
check_circle
</mat-icon>
<!-- Success -->
<div *ngIf="hasAlreadyVoted(delegation) && !isDeliveringVote(delegation)">
<mat-icon class="vote-submitted"> check_circle </mat-icon>
<br />
<span>{{ 'Voting successful.' | translate }}</span>
</div>
<div *ngIf="deliveringVote" class="submit-vote-indicator">
<!-- Delivering -->
<div *ngIf="isDeliveringVote(delegation)" class="submit-vote-indicator">
<mat-spinner class="small-spinner"></mat-spinner>
<br />
<span>{{ 'Delivering vote... Please wait!' | translate }}</span>
</div>
<!-- Permission error error -->
<div *ngIf="!hasAlreadyVoted(delegation) && !isDeliveringVote(delegation)">
<span>{{ getVotingError(delegation) | translate }}</span>
</div>
</div>
</ng-template>
<ng-template #sendNow>
<ng-template #sendNow let-delegation="delegation">
<div class="centered-button-wrapper">
<button mat-flat-button color="accent" (click)="submitVote()">
<mat-icon>
how_to_vote
</mat-icon>
<button mat-flat-button color="accent" (click)="submitVote(delegation)">
<mat-icon> how_to_vote </mat-icon>
<span>
{{ 'Submit vote now' | translate }}
</span>

View File

@ -7,6 +7,14 @@
margin: 20px 0;
}
.assignment-vote-delegation {
margin-top: 1em;
.assignment-delegation-title {
font-weight: 500;
}
}
.yn-grid {
@extend %vote-grid-base;
grid-template-areas:

View File

@ -7,24 +7,17 @@ import { TranslateService } from '@ngx-translate/core';
import { OperatorService } from 'app/core/core-services/operator.service';
import {
AssignmentPollRepositoryService,
GlobalVote,
VotingData
GlobalVote
} from 'app/core/repositories/assignments/assignment-poll-repository.service';
import { PromptService } from 'app/core/ui-services/prompt.service';
import { VotingService } from 'app/core/ui-services/voting.service';
import { AssignmentPollMethod } from 'app/shared/models/assignments/assignment-poll';
import { PollType } from 'app/shared/models/poll/base-poll';
import { VoteValue } from 'app/shared/models/poll/base-vote';
import { ViewAssignmentOption } from 'app/site/assignments/models/view-assignment-option';
import { ViewAssignmentPoll } from 'app/site/assignments/models/view-assignment-poll';
import { BasePollVoteComponentDirective } from 'app/site/polls/components/base-poll-vote.component';
// TODO: Duplicate
interface VoteActions {
vote: VoteValue;
css: string;
icon: string;
label: string;
}
import { BasePollVoteComponentDirective, VoteOption } from 'app/site/polls/components/base-poll-vote.component';
import { ViewUser } from 'app/site/users/models/view-user';
@Component({
selector: 'os-assignment-poll-vote',
@ -35,37 +28,60 @@ interface VoteActions {
export class AssignmentPollVoteComponent extends BasePollVoteComponentDirective<ViewAssignmentPoll> implements OnInit {
public AssignmentPollMethod = AssignmentPollMethod;
public PollType = PollType;
public voteActions: VoteActions[] = [];
public voteRequestData: VotingData = {
votes: {}
};
public alreadyVoted: boolean;
public voteActions: VoteOption[] = [];
public get pollHint(): string {
return this.poll.assignment.default_poll_description;
}
public constructor(
title: Title,
protected translate: TranslateService,
matSnackbar: MatSnackBar,
operator: OperatorService,
public vmanager: VotingService,
votingService: VotingService,
private pollRepo: AssignmentPollRepositoryService,
private promptService: PromptService,
private cd: ChangeDetectorRef
) {
super(title, translate, matSnackbar, operator);
super(title, translate, matSnackbar, operator, votingService);
// observe user updates to refresh the view on dynamic changes
this.subscriptions.push(
operator.getViewUserObservable().subscribe(() => {
this.cd.markForCheck();
})
);
}
public ngOnInit(): void {
if (this.poll && !this.poll.user_has_voted) {
this.alreadyVoted = false;
this.createVotingDataObjects();
this.defineVoteOptions();
} else {
this.alreadyVoted = true;
this.cd.markForCheck();
}
public getActionButtonClass(actions: VoteOption, option: ViewAssignmentOption, user: ViewUser = this.user): string {
if (
this.voteRequestData[user.id]?.votes[option.id] === actions.vote ||
this.voteRequestData[user.id]?.votes[option.id] === 1
) {
return actions.css;
}
return '';
}
public get pollHint(): string {
return this.poll.assignment.default_poll_description;
public getGlobalAbstainClass(user: ViewUser = this.user): string {
if (this.voteRequestData[user.id]?.global === 'A') {
return 'voted-abstain';
}
return '';
}
public getGlobalNoClass(user: ViewUser = this.user): string {
if (this.voteRequestData[user.id]?.global === 'N') {
return 'voted-no';
}
return '';
}
private defineVoteOptions(): void {
@ -76,7 +92,7 @@ export class AssignmentPollVoteComponent extends BasePollVoteComponentDirective<
label: 'Yes'
});
if (this.poll.pollmethod !== AssignmentPollMethod.Votes) {
if (this.poll?.pollmethod !== AssignmentPollMethod.Votes) {
this.voteActions.push({
vote: 'N',
css: 'voted-no',
@ -85,7 +101,7 @@ export class AssignmentPollVoteComponent extends BasePollVoteComponentDirective<
});
}
if (this.poll.pollmethod === AssignmentPollMethod.YNA) {
if (this.poll?.pollmethod === AssignmentPollMethod.YNA) {
this.voteActions.push({
vote: 'A',
css: 'voted-abstain',
@ -95,40 +111,48 @@ export class AssignmentPollVoteComponent extends BasePollVoteComponentDirective<
}
}
public getVotesCount(): number {
return Object.keys(this.voteRequestData.votes).filter(key => this.voteRequestData.votes[key]).length;
public getVotesCount(user: ViewUser = this.user): number {
if (this.voteRequestData[user.id]) {
return Object.keys(this.voteRequestData[user.id].votes).filter(
key => this.voteRequestData[user.id].votes[key]
).length;
}
}
public getVotesAvailable(): number {
return this.poll.votes_amount - this.getVotesCount();
public getVotesAvailable(user: ViewUser = this.user): number {
return this.poll.votes_amount - this.getVotesCount(user);
}
private isGlobalOptionSelected(): boolean {
return !!this.voteRequestData.global;
private isGlobalOptionSelected(user: ViewUser = this.user): boolean {
return !!this.voteRequestData[user.id]?.global;
}
public async submitVote(): Promise<void> {
public async submitVote(user: ViewUser = this.user): Promise<void> {
const title = this.translate.instant('Submit selection now?');
const content = this.translate.instant('Your decision cannot be changed afterwards.');
const confirmed = await this.promptService.open(title, content);
if (confirmed) {
this.deliveringVote = true;
this.deliveringVote[user.id] = true;
this.cd.markForCheck();
this.pollRepo
.vote(this.voteRequestData, this.poll.id)
.vote(this.voteRequestData[user.id], this.poll.id, user.id)
.then(() => {
this.alreadyVoted = true;
this.alreadyVoted[user.id] = true;
})
.catch(this.raiseError)
.finally(() => {
this.deliveringVote = false;
this.deliveringVote[user.id] = false;
});
}
}
public saveSingleVote(optionId: number, vote: VoteValue): void {
if (this.isGlobalOptionSelected()) {
delete this.voteRequestData.global;
public saveSingleVote(optionId: number, vote: VoteValue, user: ViewUser = this.user): void {
if (!this.voteRequestData[user.id]) {
throw new Error('The user for your voting request does not exist');
}
if (this.isGlobalOptionSelected(user)) {
delete this.voteRequestData[user.id].global;
}
if (this.poll.pollmethod === AssignmentPollMethod.Votes) {
@ -138,10 +162,10 @@ export class AssignmentPollVoteComponent extends BasePollVoteComponentDirective<
.reduce((o, n) => {
o[n] = 0;
if (votesAmount === 1) {
if (n === optionId && this.voteRequestData.votes[n] !== 1) {
if (n === optionId && this.voteRequestData[user.id].votes[n] !== 1) {
o[n] = 1;
}
} else if ((n === optionId) !== (this.voteRequestData.votes[n] === 1)) {
} else if ((n === optionId) !== (this.voteRequestData[user.id].votes[n] === 1)) {
o[n] = 1;
}
@ -151,11 +175,11 @@ export class AssignmentPollVoteComponent extends BasePollVoteComponentDirective<
// check if you can still vote
const countedVotes = Object.keys(tmpVoteRequest).filter(key => tmpVoteRequest[key]).length;
if (countedVotes <= votesAmount) {
this.voteRequestData.votes = tmpVoteRequest;
this.voteRequestData[user.id].votes = tmpVoteRequest;
// if you have no options anymore, try to send
if (this.getVotesCount() === votesAmount) {
this.submitVote();
if (this.getVotesCount(user) === votesAmount) {
this.submitVote(user);
}
} else {
this.raiseError(
@ -164,26 +188,29 @@ export class AssignmentPollVoteComponent extends BasePollVoteComponentDirective<
}
} else {
// YN/YNA
if (this.voteRequestData.votes[optionId] && this.voteRequestData.votes[optionId] === vote) {
delete this.voteRequestData.votes[optionId];
if (
this.voteRequestData[user.id].votes[optionId] &&
this.voteRequestData[user.id].votes[optionId] === vote
) {
delete this.voteRequestData[user.id].votes[optionId];
} else {
this.voteRequestData.votes[optionId] = vote;
this.voteRequestData[user.id].votes[optionId] = vote;
}
// if you filled out every option, try to send
if (Object.keys(this.voteRequestData.votes).length === this.poll.options.length) {
this.submitVote();
if (Object.keys(this.voteRequestData[user.id].votes).length === this.poll.options.length) {
this.submitVote(user);
}
}
}
public saveGlobalVote(globalVote: GlobalVote): void {
this.voteRequestData.votes = {};
if (this.voteRequestData.global && this.voteRequestData.global === globalVote) {
delete this.voteRequestData.global;
public saveGlobalVote(globalVote: GlobalVote, user: ViewUser = this.user): void {
this.voteRequestData[user.id].votes = {};
if (this.voteRequestData[user.id].global && this.voteRequestData[user.id].global === globalVote) {
delete this.voteRequestData[user.id].global;
} else {
this.voteRequestData.global = globalVote;
this.submitVote();
this.voteRequestData[user.id].global = globalVote;
this.submitVote(user);
}
}
}

View File

@ -72,6 +72,8 @@ export class AssignmentPollComponent
this.descriptionForm = this.formBuilder.group({
description: this.poll ? this.poll.description : ''
});
console.log('the poll: ', this.poll);
}
/**

View File

@ -4,10 +4,10 @@
</p>
<div *ngIf="poll.pollClassType === 'motion'">
<os-motion-poll-vote [poll]="poll"></os-motion-poll-vote>
<os-motion-poll-vote [poll]="poll" *ngIf="poll.canBeVotedFor()"></os-motion-poll-vote>
</div>
<div *ngIf="poll.pollClassType === 'assignment'">
<os-assignment-poll-vote [poll]="poll"></os-assignment-poll-vote>
<os-assignment-poll-vote [poll]="poll" *ngIf="poll.canBeVotedFor()"></os-assignment-poll-vote>
</div>
</mat-card>

View File

@ -1,12 +1,34 @@
<ng-container *ngIf="poll && !poll.user_has_voted; else userHasVotes">
<div *ngIf="vmanager.canVote(poll) && !deliveringVote" class="vote-button-grid">
<ng-container *ngIf="poll">
<!-- own voting -->
<ng-container [ngTemplateOutlet]="votingArea"></ng-container>
<!-- Delegations -->
<ng-container *ngIf="user.is_present">
<div class="motion-vote-delegation" *ngFor="let delegation of delegations">
<mat-divider></mat-divider>
<ng-container
[ngTemplateOutlet]="votingArea"
[ngTemplateOutletContext]="{ delegation: delegation }"
></ng-container>
</div>
</ng-container>
</ng-container>
<ng-template #votingArea let-delegation="delegation">
<h4 *ngIf="delegation" class="motion-delegation-title">
<span>{{ 'Vote delegation for' | translate }}</span>
<span>&nbsp;{{ delegation.getFullName() }}</span>
</h4>
<div *ngIf="canVote(delegation)" class="vote-button-grid">
<!-- Voting -->
<div class="vote-button" *ngFor="let option of voteOptions">
<button
mat-raised-button
(click)="saveVote(option.vote)"
[ngClass]="currentVote && currentVote.vote === option.vote ? option.css : ''"
[disabled]="deliveringVote"
(click)="saveVote(option.vote, delegation)"
[ngClass]="getActionButtonClass(option, delegation)"
[disabled]="isDeliveringVote(delegation)"
>
<mat-icon> {{ option.icon }}</mat-icon>
</button>
@ -14,21 +36,33 @@
</div>
</div>
<div *ngIf="deliveringVote" class="submit-vote-indicator">
<ng-container
*ngIf="!canVote(delegation)"
[ngTemplateOutlet]="cannotVote"
[ngTemplateOutletContext]="{ delegation: delegation }"
>
</ng-container>
<!-- Delivering -->
<div *ngIf="isDeliveringVote(delegation)" class="submit-vote-indicator">
<mat-spinner class="small-spinner"></mat-spinner>
<br />
<span>{{ 'Delivering vote... Please wait!' | translate }}</span>
</div>
</ng-container>
</ng-template>
<ng-template #userHasVotes>
<div class="user-has-voted">
<ng-template #cannotVote let-delegation="delegation">
<!-- Success -->
<div *ngIf="hasAlreadyVoted(delegation) && !isDeliveringVote(delegation)" class="user-has-voted">
<div>
<mat-icon class="vote-submitted">
check_circle
</mat-icon>
<mat-icon class="vote-submitted"> check_circle </mat-icon>
<br />
<span>{{ 'Voting successful.' | translate }}</span>
</div>
</div>
<!-- Error -->
<div *ngIf="!hasAlreadyVoted(delegation) && !isDeliveringVote(delegation)">
<span>{{ getVotingError(delegation) | translate }}</span>
</div>
</ng-template>

View File

@ -8,6 +8,14 @@
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
}
.motion-vote-delegation {
margin-top: 1em;
.motion-delegation-title {
font-weight: 500;
}
}
.submit-vote-indicator {
margin-top: 1em;
text-align: center;
@ -26,6 +34,10 @@
}
}
.mat-divider-horizontal {
position: initial;
}
.user-has-voted {
display: flex;
text-align: center;

View File

@ -1,4 +1,4 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component } from '@angular/core';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Title } from '@angular/platform-browser';
@ -10,14 +10,8 @@ import { PromptService } from 'app/core/ui-services/prompt.service';
import { VotingService } from 'app/core/ui-services/voting.service';
import { VoteValue } from 'app/shared/models/poll/base-vote';
import { ViewMotionPoll } from 'app/site/motions/models/view-motion-poll';
import { BasePollVoteComponentDirective } from 'app/site/polls/components/base-poll-vote.component';
interface VoteOption {
vote?: VoteValue;
css?: string;
icon?: string;
label?: string;
}
import { BasePollVoteComponentDirective, VoteOption } from 'app/site/polls/components/base-poll-vote.component';
import { ViewUser } from 'app/site/users/models/view-user';
@Component({
selector: 'os-motion-poll-vote',
@ -25,8 +19,7 @@ interface VoteOption {
styleUrls: ['./motion-poll-vote.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class MotionPollVoteComponent extends BasePollVoteComponentDirective<ViewMotionPoll> {
public currentVote: VoteOption = {};
export class MotionPollVoteComponent extends BasePollVoteComponentDirective<ViewMotionPoll> implements OnInit {
public voteOptions: VoteOption[] = [
{
vote: 'Y',
@ -53,30 +46,56 @@ export class MotionPollVoteComponent extends BasePollVoteComponentDirective<View
translate: TranslateService,
matSnackbar: MatSnackBar,
operator: OperatorService,
public vmanager: VotingService,
public votingService: VotingService,
private pollRepo: MotionPollRepositoryService,
private promptService: PromptService,
private cd: ChangeDetectorRef
) {
super(title, translate, matSnackbar, operator);
super(title, translate, matSnackbar, operator, votingService);
// observe user updates to refresh the view on dynamic changes
this.subscriptions.push(
operator.getViewUserObservable().subscribe(() => {
this.cd.markForCheck();
})
);
}
public async saveVote(vote: VoteValue): Promise<void> {
this.currentVote.vote = vote;
public ngOnInit(): void {
this.createVotingDataObjects();
this.cd.markForCheck();
}
public getActionButtonClass(voteOption: VoteOption, user: ViewUser = this.user): string {
if (this.voteRequestData[user.id]?.vote === voteOption.vote) {
return voteOption.css;
}
return '';
}
public async saveVote(vote: VoteValue, user: ViewUser = this.user): Promise<void> {
if (this.voteRequestData[user.id]) {
this.voteRequestData[user.id].vote = vote;
const title = this.translate.instant('Submit selection now?');
const content = this.translate.instant('Your decision cannot be changed afterwards.');
const confirmed = await this.promptService.open(title, content);
if (confirmed) {
this.deliveringVote = true;
this.deliveringVote[user.id] = true;
this.cd.markForCheck();
this.pollRepo
.vote(vote, this.poll.id)
.vote(vote, this.poll.id, user.id)
.then(() => {
this.alreadyVoted[user.id] = true;
})
.catch(this.raiseError)
.finally(() => {
this.deliveringVote = false;
this.deliveringVote[user.id] = false;
this.cd.markForCheck();
});
}
}
}
}

View File

@ -60,7 +60,7 @@
<div *osPerms="'motions.can_manage_polls'; and: poll && poll.isStarted">
<os-poll-progress [poll]="poll"></os-poll-progress>
</div>
<os-motion-poll-vote [poll]="poll"></os-motion-poll-vote>
<os-motion-poll-vote [poll]="poll" *ngIf="poll.canBeVotedFor()"></os-motion-poll-vote>
</ng-container>
<!-- Detail link -->

View File

@ -5,11 +5,20 @@ import { Title } from '@angular/platform-browser';
import { TranslateService } from '@ngx-translate/core';
import { OperatorService } from 'app/core/core-services/operator.service';
import { VotingError } from 'app/core/ui-services/voting.service';
import { VotingData } from 'app/core/repositories/assignments/assignment-poll-repository.service';
import { VotingError, VotingService } from 'app/core/ui-services/voting.service';
import { VoteValue } from 'app/shared/models/poll/base-vote';
import { BaseViewComponentDirective } from 'app/site/base/base-view';
import { ViewUser } from 'app/site/users/models/view-user';
import { ViewBasePoll } from '../models/view-base-poll';
export interface VoteOption {
vote?: VoteValue;
css?: string;
icon?: string;
label?: string;
}
@Directive()
export abstract class BasePollVoteComponentDirective<V extends ViewBasePoll> extends BaseViewComponentDirective {
@Input()
@ -17,22 +26,71 @@ export abstract class BasePollVoteComponentDirective<V extends ViewBasePoll> ext
public votingErrors = VotingError;
public deliveringVote = false;
protected voteRequestData = {};
protected alreadyVoted = {};
protected deliveringVote = {};
protected user: ViewUser;
protected delegations: ViewUser[];
public constructor(
title: Title,
translate: TranslateService,
matSnackbar: MatSnackBar,
protected operator: OperatorService
operator: OperatorService,
protected votingService: VotingService
) {
super(title, translate, matSnackbar);
this.subscriptions.push(
this.operator.getViewUserObservable().subscribe(user => {
operator.getViewUserObservable().subscribe(user => {
if (user) {
this.user = user;
this.delegations = user.voteDelegationsFrom;
}
})
);
}
protected createVotingDataObjects(): void {
if (this.user) {
this.voteRequestData[this.user.id] = {
votes: {}
} as VotingData;
this.alreadyVoted[this.user.id] = this.poll.user_has_voted;
this.deliveringVote[this.user.id] = false;
}
if (this.delegations) {
for (const delegation of this.delegations) {
this.voteRequestData[delegation.id] = {
votes: {}
} as VotingData;
this.alreadyVoted[delegation.id] = this.poll.hasVotedId(delegation.id);
this.deliveringVote[delegation.id] = false;
}
}
}
public isDeliveringVote(user: ViewUser = this.user): boolean {
return this.deliveringVote[user.id] === true;
}
public hasAlreadyVoted(user: ViewUser = this.user): boolean {
return this.alreadyVoted[user.id] === true;
}
public canVote(user: ViewUser = this.user): boolean {
return (
this.votingService.canVote(this.poll, user) && !this.isDeliveringVote(user) && !this.hasAlreadyVoted(user)
);
}
public getVotingError(user: ViewUser = this.user): string | void {
console.log('error ', this.votingService.getVotePermissionErrorVerbose(this.poll, user));
return this.votingService.getVotePermissionErrorVerbose(this.poll, user);
}
}

View File

@ -39,8 +39,15 @@ export class PollProgressComponent extends BaseViewComponentDirective implements
.getViewModelListObservable()
.pipe(
map(users =>
/**
* Filter the users who would be able to vote:
* They are present or have their right to vote delegated
* They are in one of the voting groups
*/
users.filter(
user => user.is_present && this.poll.groups_id.intersect(user.groups_id).length
user =>
(user.is_present || user.isVoteRightDelegated) &&
this.poll.groups_id.intersect(user.groups_id).length
)
)
)

View File

@ -114,6 +114,10 @@ export abstract class ViewBasePoll<
public canBeVotedFor: () => boolean;
public hasVotedId(userId: number): boolean {
return this.user_has_voted_for_delegations?.includes(userId);
}
public abstract getSlide(): ProjectorElementBuildDeskriptor;
public abstract getContentObject(): BaseViewModel;

View File

@ -8,7 +8,6 @@ import { RelationManagerService } from 'app/core/core-services/relation-manager.
import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service';
import { RelationDefinition } from 'app/core/definitions/relations';
import { BaseRepository, NestedModelDescriptors } from 'app/core/repositories/base-repository';
import { VotingService } from 'app/core/ui-services/voting.service';
import { ModelConstructor } from 'app/shared/models/base/base-model';
import { BasePoll, PollState } from 'app/shared/models/poll/base-poll';
import { BaseViewModel, TitleInformation } from 'app/site/base/base-view-model';
@ -30,7 +29,6 @@ export abstract class BasePollRepositoryService<
protected baseModelCtor: ModelConstructor<M>,
protected relationDefinitions: RelationDefinition<BaseViewModel>[] = [],
protected nestedModelDescriptors: NestedModelDescriptors = {},
private votingService: VotingService,
protected http: HttpService
) {
super(
@ -53,7 +51,7 @@ export abstract class BasePollRepositoryService<
protected createViewModelWithTitles(model: M): V {
const viewModel = super.createViewModelWithTitles(model);
Object.defineProperty(viewModel, 'canBeVotedFor', {
value: () => this.votingService.canVote(viewModel)
value: () => viewModel.isStarted
});
return viewModel;
}

View File

@ -164,6 +164,16 @@
[inputListValues]="groups"
></os-search-value-selector>
</mat-form-field>
<!-- Delegate Vote -->
<mat-form-field>
<os-search-value-selector
formControlName="vote_delegated_from_users_id"
[multiple]="true"
placeholder="{{ 'Transferred voting rights from' | translate }}"
[inputListValues]="users"
></os-search-value-selector>
</mat-form-field>
</div>
<div *ngIf="isAllowed('manage')">
@ -303,6 +313,18 @@
</div>
<div *ngIf="isAllowed('manage')">
<!-- Own Vote delegations -->
<div *ngIf="user.voteDelegatedTo">
<h4>{{ 'Vote right delegated to:' | translate }}</h4>
<span>{{ user.voteDelegatedTo }}</span>
</div>
<!-- Received Vote delegations -->
<div *ngIf="user.voteDelegationsFrom && user.voteDelegationsFrom.length">
<h4>{{ 'Vote delegations from' | translate }}</h4>
<span>{{ user.voteDelegationsFrom }}</span>
</div>
<!-- Vote weight -->
<div *ngIf="user.vote_weight && showVoteWeight">
<h4>{{ 'Vote weight' | translate }}</h4>

View File

@ -71,6 +71,8 @@ export class UserDetailComponent extends BaseViewComponentDirective implements O
*/
public readonly groups: BehaviorSubject<ViewGroup[]> = new BehaviorSubject<ViewGroup[]>([]);
public readonly users: BehaviorSubject<ViewUser[]> = new BehaviorSubject<ViewUser[]>([]);
/**
* Hold the list of genders (sexes) publicly to dynamically iterate in the view
*/
@ -124,6 +126,7 @@ export class UserDetailComponent extends BaseViewComponentDirective implements O
.subscribe(active => (this.isVoteWeightActive = active));
this.groupRepo.getViewModelListObservableWithoutDefaultGroup().subscribe(this.groups);
this.users = this.repo.getViewModelListBehaviorSubject();
}
/**
@ -173,6 +176,7 @@ export class UserDetailComponent extends BaseViewComponentDirective implements O
vote_weight: [],
about_me: [''],
groups_id: [''],
vote_delegated_from_users_id: [''],
is_present: [true],
is_committee: [false],
email: ['', Validators.email],

View File

@ -62,7 +62,7 @@
>
<div class="groupsCell">
<div *ngIf="user.groups && user.groups.length">
<os-icon-container icon="people">
<os-icon-container icon="people" noWrap="true">
<span *ngFor="let group of user.groups; let last = last">
{{ group.getTitle() | translate }}<span *ngIf="!last">,</span>
</span>
@ -74,6 +74,10 @@
<div *ngIf="user.number" class="spacer-top-5">
<os-icon-container icon="perm_identity">{{ user.number }}</os-icon-container>
</div>
<div *ngIf="user.vote_delegated_to_id" class="spacer-top-5">
<os-icon-container icon="forward" noWrap="true">{{ user.voteDelegatedTo }}</os-icon-container>
</div>
</div>
</div>
@ -90,9 +94,7 @@
</mat-icon>
<!-- Has comment indicator -->
<mat-icon inline *ngIf="!!user.comment" matTooltip="{{ user.comment }}">
comment
</mat-icon>
<mat-icon inline *ngIf="!!user.comment" matTooltip="{{ user.comment }}"> comment </mat-icon>
<os-icon-container *ngIf="user.isSamlUser" icon="device_hub"
><span>{{ 'Is SAML user' | translate }}</span></os-icon-container
@ -270,6 +272,14 @@
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field>
<os-search-value-selector
[(ngModel)]="infoDialog.vote_delegated_from_users_id"
[multiple]="true"
placeholder="{{ 'Transferred voting rights from' | translate }}"
[inputListValues]="users"
></os-search-value-selector>
</mat-form-field>
<mat-form-field>
<mat-select placeholder="{{ 'Gender' | translate }}" [(ngModel)]="infoDialog.gender">
<mat-option>-</mat-option>
@ -300,6 +310,7 @@
color="accent"
[mat-dialog-close]="{
groups_id: infoDialog.groups_id,
vote_delegated_from_users_id: infoDialog.vote_delegated_from_users_id,
gender: infoDialog.gender,
number: infoDialog.number,
structure_level: infoDialog.structure_level

View File

@ -7,6 +7,7 @@ import { ActivatedRoute, Router } from '@angular/router';
import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
import { TranslateService } from '@ngx-translate/core';
import { PblColumnDefinition } from '@pebula/ngrid';
import { BehaviorSubject } from 'rxjs';
import { OperatorService, Permission } from 'app/core/core-services/operator.service';
import { StorageService } from 'app/core/core-services/storage.service';
@ -55,6 +56,11 @@ interface InfoDialog {
* Structure level for one user.
*/
structure_level: string;
/**
* Transfer voting rights
*/
vote_delegated_from_users_id: number[];
}
/**
@ -82,6 +88,8 @@ export class UserListComponent extends BaseListViewComponent<ViewUser> implement
*/
public groups: ViewGroup[];
public readonly users: BehaviorSubject<ViewUser[]> = new BehaviorSubject<ViewUser[]>([]);
/**
* The list of all genders.
*/
@ -187,6 +195,7 @@ export class UserListComponent extends BaseListViewComponent<ViewUser> implement
// enable multiSelect for this listView
this.canMultiSelect = true;
this.users = this.repo.getViewModelListBehaviorSubject();
config.get<boolean>('users_enable_presence_view').subscribe(state => (this._presenceViewConfigured = state));
config.get<boolean>('users_activate_vote_weight').subscribe(active => (this.isVoteWeightActive = active));
config.get<boolean>(this.selfPresentConfStr).subscribe(allowed => (this.allowSelfSetPresent = allowed));
@ -203,9 +212,12 @@ export class UserListComponent extends BaseListViewComponent<ViewUser> implement
// Initialize the groups
this.groups = this.groupRepo.getViewModelList().filter(group => group.id !== 1);
this.subscriptions.push(
this.groupRepo
.getViewModelListObservable()
.subscribe(groups => (this.groups = groups.filter(group => group.id !== 1)));
.subscribe(groups => (this.groups = groups.filter(group => group.id !== 1)))
);
}
/**
@ -242,7 +254,8 @@ export class UserListComponent extends BaseListViewComponent<ViewUser> implement
groups_id: user.groups_id,
gender: user.gender,
structure_level: user.structure_level,
number: user.number
number: user.number,
vote_delegated_from_users_id: user.vote_delegated_from_users_id
};
const dialogRef = this.dialog.open(this.userInfoDialog, infoDialogSettings);

View File

@ -82,9 +82,15 @@ export class ViewUser extends BaseProjectableViewModel<User> implements UserTitl
getDialogTitle: () => this.getTitle()
};
}
public canVoteFor(user: ViewUser): boolean {
return this.vote_delegated_from_users_id.includes(user.id);
}
}
interface IUserRelations {
groups: ViewGroup[];
voteDelegatedTo: ViewUser;
voteDelegationsFrom: ViewUser[];
}
export interface ViewUser extends User, IUserRelations {}