Enhance table layouts

Enhance the result table layout for assignments
This commit is contained in:
Sean Engelhardt 2020-02-27 17:08:36 +01:00 committed by FinnStutzenstein
parent 9d7028ea5f
commit 53b9ce73f2
9 changed files with 159 additions and 182 deletions

View File

@ -215,6 +215,10 @@ export class ChartsComponent extends BaseViewComponent {
*/ */
@Input() @Input()
public pieChartOptions: ChartOptions = { public pieChartOptions: ChartOptions = {
responsive: true,
legend: {
position: 'left'
},
aspectRatio: 1 aspectRatio: 1
}; };

View File

@ -194,6 +194,12 @@ export class ListViewTableComponent<V extends BaseViewModel, M extends BaseModel
@Input() @Input()
public showListOfSpeakers = true; public showListOfSpeakers = true;
/**
* To optionally hide the menu slot
*/
@Input()
public showMenu = true;
/** /**
* Fix value for the height of the rows in the virtual-scroll-list. * Fix value for the height of the rows in the virtual-scroll-list.
*/ */
@ -347,7 +353,7 @@ export class ListViewTableComponent<V extends BaseViewModel, M extends BaseModel
hidden.push('selection'); hidden.push('selection');
} }
if (!this.alwaysShowMenu && !this.isMobile) { if ((!this.alwaysShowMenu && !this.isMobile) || !this.showMenu) {
hidden.push('menu'); hidden.push('menu');
} }

View File

@ -38,6 +38,18 @@ export class AssignmentPoll extends BasePoll<
public global_abstain: boolean; public global_abstain: boolean;
public description: string; public description: string;
public get isMethodY(): boolean {
return this.pollmethod === AssignmentPollMethod.Votes;
}
public get isMethodYN(): boolean {
return this.pollmethod === AssignmentPollMethod.YN;
}
public get isMethodYNA(): boolean {
return this.pollmethod === AssignmentPollMethod.YNA;
}
public get pollmethodFields(): CalculablePollKey[] { public get pollmethodFields(): CalculablePollKey[] {
if (this.pollmethod === AssignmentPollMethod.YN) { if (this.pollmethod === AssignmentPollMethod.YN) {
return ['yes', 'no']; return ['yes', 'no'];

View File

@ -16,20 +16,29 @@
<!-- Detailview for poll --> <!-- Detailview for poll -->
<ng-template #viewTemplate> <ng-template #viewTemplate>
<ng-container *ngIf="isReady"> <div *ngIf="isReady">
<h1>{{ poll.title }}</h1> <h1>{{ poll.title }}</h1>
<span *ngIf="poll.type !== 'analog'">{{ poll.typeVerbose | translate }}</span> <span *ngIf="poll.type !== 'analog'">{{ poll.typeVerbose | translate }}</span>
<div *ngIf="poll.stateHasVotes"> <div *ngIf="poll.stateHasVotes">
<div [class]="chartType === 'horizontalBar' ? 'result-wrapper-bar-chart' : 'result-wrapper-pie-chart'"> <div class="assignment-result-wrapper">
<!-- Result Table --> <!-- Result Table -->
<table class="assignment-result-table"> <table class="assignment-result-table">
<tbody> <tbody>
<tr> <tr>
<th translate>Candidates</th> <th translate>Candidates</th>
<th translate>Votes</th> <th>
<span *ngIf="!poll.isMethodY" translate>
Yes
</span>
<span *ngIf="poll.isMethodY" translate>
Votes
</span>
</th>
<th translate *ngIf="!poll.isMethodY">No</th>
<th translate *ngIf="poll.isMethodYNA">Abstain</th>
</tr> </tr>
<tr *ngFor="let row of poll.tableData"> <tr *ngFor="let row of poll.tableData" [class]="row.class">
<td> <td>
<span> <span>
{{ row.votingOption | pollKeyVerbose | translate }} {{ row.votingOption | pollKeyVerbose | translate }}
@ -39,23 +48,14 @@
{{ row.votingOptionSubtitle }} {{ row.votingOptionSubtitle }}
</span> </span>
</td> </td>
<td> <td *ngFor="let vote of row.value">
<div *ngFor="let vote of row.value"> <div class="single-result" *ngIf="vote && voteFitsMethod(vote)">
<div class="single-result" *ngIf="voteFitsMethod(vote)"> <span>
<os-icon-container *ngIf="vote.icon" [icon]="vote.icon"> {{ vote.amount | parsePollNumber }}
{{ vote.vote | pollKeyVerbose | translate }} <span *ngIf="vote.showPercent">
</os-icon-container> {{ vote.amount | pollPercentBase: poll }}
<span *ngIf="!vote.icon">
{{ vote.vote | pollKeyVerbose | translate }}
</span> </span>
</span>
<span>
{{ vote.amount | parsePollNumber }}
<span *ngIf="vote.showPercent">
{{ vote.amount | pollPercentBase: poll }}
</span>
</span>
</div>
</div> </div>
</td> </td>
</tr> </tr>
@ -71,12 +71,12 @@
[labels]="candidatesLabels" [labels]="candidatesLabels"
[data]="chartDataSubject" [data]="chartDataSubject"
[hasPadding]="false" [hasPadding]="false"
[legendPosition]="isVotedPoll ? 'right' : 'top'" legendPosition="right"
></os-charts> ></os-charts>
</div> </div>
<!-- Single Votes Table --> <!-- Single Votes Table -->
<ng-container class="named-result-table" *ngIf="poll.type === 'named'"> <div class="named-result-table" *ngIf="poll.type === 'named'">
<h3>{{ 'Single votes' | translate }}</h3> <h3>{{ 'Single votes' | translate }}</h3>
<os-list-view-table <os-list-view-table
*ngIf="votesDataObservable" *ngIf="votesDataObservable"
@ -85,47 +85,41 @@
[filterProps]="filterProps" [filterProps]="filterProps"
[allowProjector]="false" [allowProjector]="false"
[fullScreen]="false" [fullScreen]="false"
[vScrollFixed]="isVotedPoll ? -1 : 60" [vScrollFixed]="-1"
listStorageKey="assignment-poll-vote" listStorageKey="assignment-poll-vote"
[showListOfSpeakers]="false"
[showMenu]="false"
[cssClasses]="{ 'single-votes-table': true }" [cssClasses]="{ 'single-votes-table': true }"
> >
<!-- Header --> <!-- Header -->
<div *pblNgridHeaderCellDef="'user'; col as col"> <div *pblNgridHeaderCellDef="'user'; col as col">
{{ col.label | translate }} {{ col.label | translate }}
</div> </div>
<div *pblNgridHeaderCellDef="'*'; col as col"> <div *pblNgridHeaderCellDef="'votes'; col as col">
{{ col.label | translate }} {{ col.label | translate }}
</div> </div>
<!-- Content --> <!-- Content -->
<div *pblNgridCellDef="'user'; row as vote"> <div *pblNgridCellDef="'user'; row as vote">
<b *ngIf="vote.user">{{ vote.user.getFullName() }}</b> <div *ngIf="vote.user">
<b *ngIf="!vote.user">{{ 'Anonymous' | translate }}</b> {{ vote.user.getShortName() }}
</div> <div class="user-subtitle" *ngIf="vote.user.getLevelAndNumber()">
<!-- Y/N/(A) --> {{ vote.user.getLevelAndNumber() }}
<ng-container *ngIf="poll.pollmethod !== AssignmentPollMethod.Votes">
<ng-container *ngFor="let option of poll.options">
<div
*pblNgridCellDef="'votes-' + option.user_id; row as vote"
[ngClass]="voteOptionStyle[vote.votes[option.user_id].value].css"
class="vote-field"
>
<mat-icon> {{ voteOptionStyle[vote.votes[option.user_id].value].icon }}</mat-icon>
{{ vote.votes[option.user_id].valueVerbose | translate }}
</div> </div>
</ng-container>
</ng-container>
<!-- Votes method -->
<ng-container *ngIf="poll.pollmethod === AssignmentPollMethod.Votes">
<div *pblNgridCellDef="'votes'; row as vote">
<div *ngFor="let candidate of vote.votes">{{ candidate }}</div>
</div> </div>
</ng-container> <div *ngIf="!vote.user">
{{ 'Anonymous' | translate }}
</div>
</div>
<div *pblNgridCellDef="'votes'; row as vote" >
<div class="single-vote-result" *ngFor="let candidate of vote.votes">{{ candidate }}</div>
</div>
</os-list-view-table> </os-list-view-table>
<div *ngIf="!votesDataObservable"> <div *ngIf="!votesDataObservable">
{{ 'The individual votes were anonymized.' | translate }} {{ 'The individual votes were anonymized.' | translate }}
</div> </div>
</ng-container> </div>
</div> </div>
<!-- Meta Infos --> <!-- Meta Infos -->
@ -142,7 +136,7 @@
{{ '100% base' | translate }}: {{ poll.percentBaseVerbose | translate }} {{ '100% base' | translate }}: {{ poll.percentBaseVerbose | translate }}
</small> </small>
</div> </div>
</ng-container> </div>
</ng-template> </ng-template>
<!-- More Menu --> <!-- More Menu -->

View File

@ -1,73 +1,69 @@
@import '~assets/styles/variables.scss'; @import '~assets/styles/variables.scss';
@import '~assets/styles/poll-colors.scss'; @import '~assets/styles/poll-colors.scss';
%assignment-result-wrapper { .assignment-result-wrapper {
margin-top: 2em; margin-top: 2em;
display: grid; display: grid;
grid-gap: 10px; grid-gap: 10px;
}
.result-wrapper-bar-chart { .assignment-result-table {
@extend %assignment-result-wrapper; border-collapse: collapse;
grid-template-areas:
'results'
'chart'
'names';
}
.result-wrapper-pie-chart { th {
@extend %assignment-result-wrapper; text-align: left;
grid-template-areas: font-weight: initial;
'chart' }
'results'
'names';
}
@include desktop { tr {
.result-wrapper-pie-chart { height: 48px;
grid-template-areas: }
'results chart'
'names names'; tr:last-child {
grid-template-columns: 2fr 1fr; border-bottom: none;
}
tr.sums {
border-bottom: none;
td {
padding-top: 1em;
padding-bottom: 1em;
}
}
.user + .sums {
td {
padding-top: 4em;
}
}
}
.pie-chart {
margin-left: auto;
margin-right: auto;
width: 50%;
} }
} }
.assignment-result-table { .single-vote-result + .single-vote-result {
grid-area: results; margin-top: 1em;
th {
text-align: left;
font-weight: initial;
}
tr {
height: 48px;
}
tr:last-child {
border-bottom: none;
}
.single-result {
display: flex;
}
}
.assignment-result-chart {
grid-area: chart;
}
.pie-chart {
max-width: 300px;
margin-left: auto;
margin-right: auto;
} }
.named-result-table { .named-result-table {
grid-area: names;
.mat-form-field { .mat-form-field {
font-size: 14px; font-size: 14px;
width: 100%; width: 100%;
} }
.single-votes-table {
display: block;
height: 500px;
}
.vote-field {
text-align: center;
width: 100%;
padding-right: 12px;
}
} }
.assignment-poll-meta { .assignment-poll-meta {
@ -76,22 +72,6 @@
padding-top: 20px; padding-top: 20px;
} }
.single-votes-table {
display: block;
height: 500px;
}
.openslides-theme .pbl-ngrid-row:hover {
background-color: #f9f9f9;
}
.openslides-theme os-list-view-table os-sort-filter-bar .custom-table-header {
&,
.action-buttons .input-container input {
background: white;
}
}
.voted-yes { .voted-yes {
color: $votes-yes-color; color: $votes-yes-color;
} }
@ -104,6 +84,18 @@
color: $votes-abstain-color; color: $votes-abstain-color;
} }
// theme
.openslides-theme .pbl-ngrid-row:hover {
background-color: #f9f9f9;
}
.openslides-theme os-list-view-table os-sort-filter-bar .custom-table-header {
&,
.action-buttons .input-container input {
background: white;
}
}
.openslides-theme .pbl-ngrid-no-data { .openslides-theme .pbl-ngrid-no-data {
top: 10%; top: 10%;
} }
@ -123,11 +115,3 @@
border-right: 1px solid #e0e0e0; border-right: 1px solid #e0e0e0;
} }
} }
.vote-field {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
padding-right: 12px;
}

View File

@ -11,9 +11,7 @@ import { AssignmentPollRepositoryService } from 'app/core/repositories/assignmen
import { AssignmentVoteRepositoryService } from 'app/core/repositories/assignments/assignment-vote-repository.service'; import { AssignmentVoteRepositoryService } from 'app/core/repositories/assignments/assignment-vote-repository.service';
import { GroupRepositoryService } from 'app/core/repositories/users/group-repository.service'; import { GroupRepositoryService } from 'app/core/repositories/users/group-repository.service';
import { PromptService } from 'app/core/ui-services/prompt.service'; import { PromptService } from 'app/core/ui-services/prompt.service';
import { ViewportService } from 'app/core/ui-services/viewport.service';
import { ChartType } from 'app/shared/components/charts/charts.component'; import { ChartType } from 'app/shared/components/charts/charts.component';
import { AssignmentPollMethod } from 'app/shared/models/assignments/assignment-poll';
import { BasePollDetailComponent } from 'app/site/polls/components/base-poll-detail.component'; import { BasePollDetailComponent } from 'app/site/polls/components/base-poll-detail.component';
import { VotingResult } from 'app/site/polls/models/view-base-poll'; import { VotingResult } from 'app/site/polls/models/view-base-poll';
import { PollService } from 'app/site/polls/services/poll.service'; import { PollService } from 'app/site/polls/services/poll.service';
@ -27,8 +25,6 @@ import { ViewAssignmentPoll } from '../../models/view-assignment-poll';
encapsulation: ViewEncapsulation.None encapsulation: ViewEncapsulation.None
}) })
export class AssignmentPollDetailComponent extends BasePollDetailComponent<ViewAssignmentPoll> { export class AssignmentPollDetailComponent extends BasePollDetailComponent<ViewAssignmentPoll> {
public AssignmentPollMethod = AssignmentPollMethod;
public columnDefinitionSingleVotes: PblColumnDefinition[]; public columnDefinitionSingleVotes: PblColumnDefinition[];
public filterProps = ['user.getFullName']; public filterProps = ['user.getFullName'];
@ -41,10 +37,6 @@ export class AssignmentPollDetailComponent extends BasePollDetailComponent<ViewA
return this._chartType; return this._chartType;
} }
public get isVotedPoll(): boolean {
return this.poll.pollmethod === AssignmentPollMethod.Votes;
}
private _chartType: ChartType = 'horizontalBar'; private _chartType: ChartType = 'horizontalBar';
public constructor( public constructor(
@ -58,67 +50,48 @@ export class AssignmentPollDetailComponent extends BasePollDetailComponent<ViewA
pollDialog: AssignmentPollDialogService, pollDialog: AssignmentPollDialogService,
pollService: PollService, pollService: PollService,
votesRepo: AssignmentVoteRepositoryService, votesRepo: AssignmentVoteRepositoryService,
private operator: OperatorService, private operator: OperatorService
private viewport: ViewportService
) { ) {
super(title, translate, matSnackbar, repo, route, groupRepo, prompt, pollDialog, pollService, votesRepo); super(title, translate, matSnackbar, repo, route, groupRepo, prompt, pollDialog, pollService, votesRepo);
} }
protected createVotesData(): void { protected createVotesData(): void {
const votes = {}; const votes = {};
let i = -1;
const definitions: PblColumnDefinition[] = [ const definitions: PblColumnDefinition[] = [
{ {
prop: 'user', prop: 'user',
label: 'Participant', label: 'Participant',
width: '180px', width: '40%',
pin: this.viewport.isMobile ? undefined : 'start' minWidth: 300
},
{
prop: 'votes',
label: 'Votes',
width: '60%',
minWidth: 300
} }
]; ];
if (this.isVotedPoll) {
definitions.push(this.getVoteColumnDefinition('votes', 'Votes'));
}
/**
* builds an object of the following form:
* {
* userId: {
* user: ViewUser,
* votes: { candidateId: voteValue } // for YN(A)
* | candidate_name[] // for Votes
* }
* }
*/
for (const option of this.poll.options) { for (const option of this.poll.options) {
if (!this.isVotedPoll) {
definitions.push(this.getVoteColumnDefinition('votes-' + option.user_id, option.user.getFullName()));
}
for (const vote of option.votes) { for (const vote of option.votes) {
// if poll was pseudoanonymized, use a negative index to not interfere with const userId = vote.user_id;
// possible named votes (although this should never happen)
const userId = vote.user_id || --i;
if (!votes[userId]) { if (!votes[userId]) {
votes[userId] = { votes[userId] = {
user: vote.user, user: vote.user,
votes: this.isVotedPoll ? [] : {} votes: []
}; };
} }
// on votes method, we fill an array with all chosen candidates
// on YN(A) we map candidate ids to the vote if (vote.weight > 0) {
if (this.isVotedPoll) { if (this.poll.isMethodY) {
if (vote.weight > 0) {
if (vote.value === 'Y') { if (vote.value === 'Y') {
votes[userId].votes.push(option.user.getFullName()); votes[userId].votes.push(option.user.getFullName());
} else if (vote.value === 'N') { } else {
votes[userId].votes.push(this.translate.instant('No')); votes[userId].votes.push(this.voteValueToLabel(vote.value));
} else if (vote.value === 'A') {
votes[userId].votes.push(this.translate.instant('Abstain'));
} }
} else {
votes[userId].votes.push(`${option.user.getShortName()}: ${this.voteValueToLabel(vote.value)}`);
} }
} else {
votes[userId].votes[option.user_id] = vote;
} }
} }
} }
@ -129,17 +102,20 @@ export class AssignmentPollDetailComponent extends BasePollDetailComponent<ViewA
this.isReady = true; this.isReady = true;
} }
private getVoteColumnDefinition(prop: string, label: string): PblColumnDefinition { private voteValueToLabel(vote: 'Y' | 'N' | 'A'): string {
return { if (vote === 'Y') {
prop: prop, return this.translate.instant('Yes');
label: label, } else if (vote === 'N') {
minWidth: 80, return this.translate.instant('No');
width: 'auto' } else if (vote === 'A') {
}; return this.translate.instant('Abstain');
} else {
throw new Error(`voteValueToLabel received illegal arguments: ${vote}`);
}
} }
protected initChartData(): void { protected initChartData(): void {
if (this.isVotedPoll) { if (this.poll.isMethodY) {
this._chartType = 'doughnut'; this._chartType = 'doughnut';
this.chartDataSubject.next(this.pollService.generateCircleChartData(this.poll)); this.chartDataSubject.next(this.pollService.generateCircleChartData(this.poll));
} else { } else {
@ -152,11 +128,11 @@ export class AssignmentPollDetailComponent extends BasePollDetailComponent<ViewA
} }
public voteFitsMethod(result: VotingResult): boolean { public voteFitsMethod(result: VotingResult): boolean {
if (this.poll.pollmethod === AssignmentPollMethod.Votes) { if (this.poll.isMethodY) {
if (result.vote === 'abstain' || result.vote === 'no') { if (result.vote === 'abstain' || result.vote === 'no') {
return false; return false;
} }
} else if (this.poll.pollmethod === AssignmentPollMethod.YN) { } else if (this.poll.isMethodYN) {
if (result.vote === 'abstain') { if (result.vote === 'abstain') {
return false; return false;
} }

View File

@ -68,7 +68,7 @@ export class ViewAssignmentPoll extends ViewBasePoll<AssignmentPoll, AssignmentP
const tableData: PollTableData[] = this.options.map(candidate => ({ const tableData: PollTableData[] = this.options.map(candidate => ({
votingOption: candidate.user.short_name, votingOption: candidate.user.short_name,
votingOptionSubtitle: candidate.user.getLevelAndNumber(), votingOptionSubtitle: candidate.user.getLevelAndNumber(),
class: 'user',
value: this.voteTableKeys.map( value: this.voteTableKeys.map(
key => key =>
({ ({
@ -84,6 +84,7 @@ export class ViewAssignmentPoll extends ViewBasePoll<AssignmentPoll, AssignmentP
tableData.push( tableData.push(
...this.sumTableKeys.map(key => ({ ...this.sumTableKeys.map(key => ({
votingOption: key.vote, votingOption: key.vote,
class: 'sums',
value: [ value: [
{ {
amount: this[key.vote], amount: this[key.vote],
@ -93,7 +94,6 @@ export class ViewAssignmentPoll extends ViewBasePoll<AssignmentPoll, AssignmentP
] ]
})) }))
); );
return tableData; return tableData;
} }
} }

View File

@ -4,7 +4,7 @@ import { E2EImportsModule } from 'e2e-imports.module';
import { PollFormComponent } from './poll-form.component'; import { PollFormComponent } from './poll-form.component';
fdescribe('PollFormComponent', () => { describe('PollFormComponent', () => {
let component: PollFormComponent<any>; let component: PollFormComponent<any>;
let fixture: ComponentFixture<PollFormComponent<any>>; let fixture: ComponentFixture<PollFormComponent<any>>;

View File

@ -17,6 +17,7 @@ export enum PollClassType {
export interface PollTableData { export interface PollTableData {
votingOption: string; votingOption: string;
votingOptionSubtitle?: string; votingOptionSubtitle?: string;
class?: string;
value: VotingResult[]; value: VotingResult[];
} }
@ -162,10 +163,10 @@ export abstract class ViewBasePoll<
public abstract get percentBaseVerbose(): string; public abstract get percentBaseVerbose(): string;
public get showAbstainPercent(): boolean { public get showAbstainPercent(): boolean {
return this.onehundred_percent_base === PercentBase.YNA; return this.poll.onehundred_percent_base === PercentBase.YNA;
} }
public abstract readonly pollClassType: PollClassType; public abstract readonly pollClassType: 'motion' | 'assignment';
public canBeVotedFor: () => boolean; public canBeVotedFor: () => boolean;