added virtual scrolling for single votes tables

This commit is contained in:
Joshua Sangmeister 2020-02-06 11:51:04 +01:00 committed by FinnStutzenstein
parent 93dc78c7d6
commit d4599a435b
11 changed files with 349 additions and 105 deletions

View File

@ -14,8 +14,9 @@
<!-- vScrollAuto () -->
<pbl-ngrid
[ngClass]="cssClasses"
[vScrollFixed]="vScrollFixed"
[showHeader]="!showFilterBar"
[attr.vScrollFixed]="vScrollFixed !== -1 ? vScrollFixed : false"
[attr.vScrollAuto]="vScrollFixed === -1"
[showHeader]="!showFilterBar || !fullScreen"
matCheckboxSelection="selection"
[dataSource]="dataSource"
[columns]="columnSet"

View File

@ -47,7 +47,7 @@ export interface ColumnRestriction {
* Creates a sort-filter-bar and table with virtual scrolling, where projector and multi select is already
* embedded
*
* Takes a repository-service, a sort-service and a filter-service as an input to display data
* Takes a repository-service (or simple Observable), a sort-service and a filter-service as an input to display data
* Requires multi-select information
* Double binds selected rows
*
@ -65,6 +65,7 @@ export interface ColumnRestriction {
* <os-list-view-table
* [listObservableProvider]="motionRepo"
* [filterService]="filterService"
* [filterProps]="filterProps"
* [sortService]="sortService"
* [columns]="motionColumnDefinition"
* [restricted]="restrictedColumns"
@ -96,11 +97,17 @@ export class ListViewTableComponent<V extends BaseViewModel, M extends BaseModel
private ngrid: PblNgridComponent;
/**
* The required repository
* The required repository (prioritized over listObservable)
*/
@Input()
public listObservableProvider: HasViewModelListObservable<V>;
/**
* ...or the required observable
*/
@Input()
public listObservable: Observable<V[]>;
/**
* The currently active sorting service for the list view
*/
@ -193,6 +200,13 @@ export class ListViewTableComponent<V extends BaseViewModel, M extends BaseModel
@Input()
public vScrollFixed = 110;
/**
* Determines whether the table should have a fixed 100vh height or not.
* If not, the height must be set by the component
*/
@Input()
public fullScreen = true;
/**
* Option to apply additional classes to the virtual-scrolling-list.
*/
@ -211,8 +225,8 @@ export class ListViewTableComponent<V extends BaseViewModel, M extends BaseModel
*/
public get cssClasses(): CssClassDefinition {
const defaultClasses = {
'virtual-scroll-with-head-bar ngrid-hide-head': this.showFilterBar,
'virtual-scroll-full-page': !this.showFilterBar,
'virtual-scroll-with-head-bar ngrid-hide-head': this.fullScreen && this.showFilterBar,
'virtual-scroll-full-page': this.fullScreen && !this.showFilterBar,
multiselect: this.multiSelect
};
return Object.assign(this._cssClasses, defaultClasses);
@ -468,8 +482,10 @@ export class ListViewTableComponent<V extends BaseViewModel, M extends BaseModel
* to the used search and filter services
*/
private getListObservable(): void {
if (this.listObservableProvider) {
const listObservable = this.listObservableProvider.getViewModelListObservable();
if (this.listObservableProvider || this.listObservable) {
const listObservable = this.listObservableProvider
? this.listObservableProvider.getViewModelListObservable()
: this.listObservable;
if (this.filterService && this.sortService) {
// filtering and sorting
this.filterService.initFilters(listObservable);
@ -521,15 +537,24 @@ export class ListViewTableComponent<V extends BaseViewModel, M extends BaseModel
// custom filter predicates
if (this.filterProps && this.filterProps.length) {
for (const prop of this.filterProps) {
if (item[prop]) {
// find nested props
const split = prop.split('.');
let currValue: any = item;
for (const subProp of split) {
if (currValue) {
currValue = currValue[subProp];
}
}
if (currValue) {
let propertyAsString = '';
// If the property is a function, call it.
if (typeof item[prop] === 'function') {
propertyAsString = '' + item[prop]();
} else if (item[prop].constructor === Array) {
propertyAsString = item[prop].join('');
if (typeof currValue === 'function') {
propertyAsString = '' + currValue();
} else if (currValue.constructor === Array) {
propertyAsString = currValue.join('');
} else {
propertyAsString = '' + item[prop];
propertyAsString = '' + currValue;
}
if (propertyAsString) {
@ -647,7 +672,9 @@ export class ListViewTableComponent<V extends BaseViewModel, M extends BaseModel
* This function changes the height of the row for virtual-scrolling in the relating `.scss`-file.
*/
private changeRowHeight(): void {
document.documentElement.style.setProperty('--pbl-height', this.vScrollFixed + 'px');
if (this.vScrollFixed > 0) {
document.documentElement.style.setProperty('--pbl-height', this.vScrollFixed + 'px');
}
}
/**

View File

@ -68,40 +68,58 @@
[hasPadding]="false"
[legendPosition]="isVotedPoll ? 'right' : 'top'"
></os-charts>
<!-- Named Result -->
<div class="named-result-table" *ngIf="poll.type === 'named' && votesDataSource.data">
<h3>{{ 'Single votes' | translate }}</h3>
<mat-form-field>
<input matInput [(ngModel)]="votesDataSource.filter" placeholder="Filter" />
</mat-form-field>
<mat-table [dataSource]="votesDataSource">
<ng-container matColumnDef="users" sticky>
<mat-header-cell *matHeaderCellDef>{{ 'User' | translate }}</mat-header-cell>
<mat-cell *matCellDef="let row">
<div *ngIf="row.user">{{ row.user.getFullName() }}</div>
<div *ngIf="!row.user">{{ 'Unknown user' | translate }}</div>
</mat-cell>
</ng-container>
<ng-container
[matColumnDef]="'votes-' + option.user_id"
*ngFor="let option of poll.options"
sticky
>
<mat-header-cell *matHeaderCellDef>
<div *ngIf="option.user">{{ option.user.getFullName() }}</div>
<div *ngIf="!option.user">{{ 'Unknown user' | translate }}</div>
</mat-header-cell>
<mat-cell *matCellDef="let row">
{{ row.votes[option.user_id] }}
</mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="columnDefinitionPerName"></mat-header-row>
<mat-row *matRowDef="let row; columns: columnDefinitionPerName"></mat-row>
</mat-table>
</div>
</div>
<!-- Single Votes Table -->
<ng-container class="named-result-table" *ngIf="poll.type === 'named'">
<h3>{{ 'Single votes' | translate }}</h3>
<os-list-view-table
*ngIf="votesDataObservable"
[listObservable]="votesDataObservable"
[columns]="columnDefinitionSingleVotes"
[filterProps]="filterProps"
[allowProjector]="false"
[fullScreen]="false"
[vScrollFixed]="isVotedPoll ? -1 : 60"
listStorageKey="assignment-poll-vote"
[cssClasses]="{ 'single-votes-table': true }"
>
<!-- Header -->
<div *pblNgridHeaderCellDef="'user'; col as col">
{{ col.label | translate }}
</div>
<div *pblNgridHeaderCellDef="'*'; col as col">
{{ col.label | translate }}
</div>
<!-- Content -->
<div *pblNgridCellDef="'user'; row as vote">
<b *ngIf="vote.user">{{ vote.user.getFullName() }}</b>
<b *ngIf="!vote.user">{{ 'Anonymous' | translate }}</b>
</div>
<!-- Y/N/(A) -->
<ng-container *ngIf="poll.pollmethod !== AssignmentPollMethods.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>
</ng-container>
</ng-container>
<!-- Votes method -->
<ng-container *ngIf="poll.pollmethod === AssignmentPollMethods.Votes">
<div *pblNgridCellDef="'votes'; row as vote">
<div *ngFor="let candidate of vote.votes">{{ candidate }}</div>
</div>
</ng-container>
</os-list-view-table>
<div *ngIf="!votesDataObservable">
{{ 'The individual votes were anonymized.' | translate }}
</div>
</ng-container>
</div>
<!-- Meta Infos -->

View File

@ -1,4 +1,5 @@
@import '~assets/styles/variables.scss';
@import '~assets/styles/poll-colors.scss';
.result-wrapper {
display: grid;
@ -38,3 +39,74 @@
.poll-content {
padding-top: 20px;
}
.chart-wrapper {
&.flex {
display: flex;
.mat-table {
flex: 2;
.mat-column-votes {
justify-content: center;
}
}
.chart-inner-wrapper {
flex: 3;
}
}
}
.single-votes-table {
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 {
color: $votes-yes-color;
}
.voted-no {
color: $votes-no-color;
}
.voted-abstain {
color: $votes-abstain-color;
}
.openslides-theme .pbl-ngrid-no-data {
top: 10%;
}
.openslides-theme .pbl-ngrid-header-cell:first-child {
& {
overflow: visible;
}
&::after {
content: '';
display: block;
position: absolute;
top: 0;
right: -1px;
height: 100%;
border-right: 1px solid #e0e0e0;
}
}
.vote-field {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
padding-right: 12px;
}

View File

@ -1,14 +1,16 @@
import { Component } from '@angular/core';
import { Component, ViewEncapsulation } from '@angular/core';
import { MatSnackBar } from '@angular/material';
import { Title } from '@angular/platform-browser';
import { ActivatedRoute } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { PblColumnDefinition } from '@pebula/ngrid';
import { OperatorService } from 'app/core/core-services/operator.service';
import { AssignmentPollRepositoryService } from 'app/core/repositories/assignments/assignment-poll-repository.service';
import { GroupRepositoryService } from 'app/core/repositories/users/group-repository.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 { AssignmentPollMethods } from 'app/shared/models/assignments/assignment-poll';
import { BasePollDetailComponent } from 'app/site/polls/components/base-poll-detail.component';
@ -18,9 +20,16 @@ import { ViewAssignmentPoll } from '../../models/view-assignment-poll';
@Component({
selector: 'os-assignment-poll-detail',
templateUrl: './assignment-poll-detail.component.html',
styleUrls: ['./assignment-poll-detail.component.scss']
styleUrls: ['./assignment-poll-detail.component.scss'],
encapsulation: ViewEncapsulation.None
})
export class AssignmentPollDetailComponent extends BasePollDetailComponent<ViewAssignmentPoll> {
public AssignmentPollMethods = AssignmentPollMethods;
public columnDefinitionSingleVotes: PblColumnDefinition[];
public filterProps = ['user.getFullName'];
public isReady = false;
public candidatesLabels: string[] = [];
@ -40,9 +49,6 @@ export class AssignmentPollDetailComponent extends BasePollDetailComponent<ViewA
}
return columns;
}
public columnDefinitionPerName: string[];
private _chartType: ChartType = 'horizontalBar';
public constructor(
@ -54,17 +60,45 @@ export class AssignmentPollDetailComponent extends BasePollDetailComponent<ViewA
groupRepo: GroupRepositoryService,
prompt: PromptService,
pollDialog: AssignmentPollDialogService,
private operator: OperatorService
private operator: OperatorService,
private viewport: ViewportService
) {
super(title, translate, matSnackbar, repo, route, groupRepo, prompt, pollDialog);
}
public onPollWithOptionsLoaded(): void {
this.columnDefinitionPerName = ['users'].concat(this.poll.options.map(option => 'votes-' + option.user_id));
const votes = {};
let i = -1;
this.columnDefinitionSingleVotes = [
{
prop: 'user',
label: 'Participant',
width: '180px',
pin: this.viewport.isMobile ? undefined : 'start'
}
];
if (this.isVotedPoll) {
this.columnDefinitionSingleVotes.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) {
if (!this.isVotedPoll) {
this.columnDefinitionSingleVotes.push(
this.getVoteColumnDefinition('votes-' + option.user_id, option.user.getFullName())
);
}
for (const vote of option.votes) {
// if poll was pseudoanonymized, use a negative index to not interfere with
// possible named votes (although this should never happen)
@ -72,11 +106,24 @@ export class AssignmentPollDetailComponent extends BasePollDetailComponent<ViewA
if (!votes[userId]) {
votes[userId] = {
user: vote.user,
votes: {}
votes: this.isVotedPoll ? [] : {}
};
}
votes[userId].votes[option.user_id] =
this.poll.pollmethod === AssignmentPollMethods.Votes ? vote.weight : vote.valueVerbose;
// on votes method, we fill an array with all chosen candidates
// on YN(A) we map candidate ids to the vote
if (this.isVotedPoll) {
if (vote.weight > 0) {
if (vote.value === 'Y') {
votes[userId].votes.push(option.user.getFullName());
} else if (vote.value === 'N') {
votes[userId].votes.push(this.translate.instant('No'));
} else if (vote.value === 'A') {
votes[userId].votes.push(this.translate.instant('Abstain'));
}
}
} else {
votes[userId].votes[option.user_id] = vote;
}
}
}
@ -87,6 +134,15 @@ export class AssignmentPollDetailComponent extends BasePollDetailComponent<ViewA
this.isReady = true;
}
private getVoteColumnDefinition(prop: string, label: string): PblColumnDefinition {
return {
prop: prop,
label: label,
minWidth: 80,
width: 'auto'
};
}
protected initChartData(): void {
if (this.isVotedPoll) {
this._chartType = 'doughnut';

View File

@ -63,30 +63,38 @@
<!-- Named table: only show if votes are present -->
<div class="named-result-table" *ngIf="poll.type === 'named'">
<h3>{{ 'Single votes' | translate }}</h3>
<div *ngIf="votesDataSource.data">
<mat-form-field>
<input matInput [(ngModel)]="votesDataSource.filter" placeholder="Filter" />
</mat-form-field>
<mat-table [dataSource]="votesDataSource">
<ng-container matColumnDef="key" sticky>
<mat-header-cell *matHeaderCellDef>{{ 'Participant' | translate }}</mat-header-cell>
<mat-cell *matCellDef="let vote">
<div *ngIf="vote.user">{{ vote.user.getFullName() }}</div>
<div *ngIf="!vote.user">{{ 'Anonymous' | translate }}</div>
</mat-cell>
</ng-container>
<ng-container matColumnDef="value" sticky>
<mat-header-cell *matHeaderCellDef>{{ 'Vote' | translate }}</mat-header-cell>
<mat-cell *matCellDef="let vote">{{ vote.valueVerbose }}</mat-cell>
</ng-container>
<os-list-view-table
[listObservable]="votesDataObservable"
[columns]="columnDefinition"
[filterProps]="filterProps"
[allowProjector]="false"
[fullScreen]="false"
[vScrollFixed]="60"
listStorageKey="motion-poll-vote"
[cssClasses]="{ 'single-votes-table': true }"
>
<!-- Header -->
<div *pblNgridHeaderCellDef="'*'; col as col">
{{ col.label | translate }}
</div>
<mat-header-row *matHeaderRowDef="columnDefinition"></mat-header-row>
<mat-row *matRowDef="let vote; columns: columnDefinition"></mat-row>
</mat-table>
</div>
<div *ngIf="!votesDataSource.data">
{{ 'The individual votes were made anonymous.' | translate }}
</div>
<!-- Content -->
<div *pblNgridCellDef="'user'; row as vote">
<div *ngIf="vote.user">{{ vote.user.getFullName() }}</div>
<div *ngIf="!vote.user">{{ 'Anonymous' | translate }}</div>
</div>
<div *pblNgridCellDef="'vote'; row as vote" class="vote-cell">
<div class="vote-cell-icon-container" [ngClass]="voteOptionStyle[vote.value].css">
<mat-icon>{{ voteOptionStyle[vote.value].icon }}</mat-icon>
</div>
<div>{{ vote.valueVerbose | translate }}</div>
</div>
<!-- No Results -->
<div *pblNgridNoDataRef class="pbl-ngrid-no-data">
{{ 'The individual votes were made anonymous.' | translate }}
</div>
</os-list-view-table>
</div>
</div>
</div>

View File

@ -1,4 +1,5 @@
@import '~assets/styles/variables.scss';
@import '~assets/styles/poll-colors.scss';
.poll-content {
padding-top: 20px;
@ -56,4 +57,42 @@
font-size: 14px;
width: 100%;
}
.vote-cell {
display: flex;
align-items: center;
.vote-cell-icon-container {
display: flex;
align-items: center;
margin-right: 7px;
}
}
}
.single-votes-table {
height: 500px;
}
.openslides-theme os-list-view-table os-sort-filter-bar .custom-table-header {
&,
.action-buttons .input-container input {
background: white;
}
}
.voted-yes {
color: $votes-yes-color;
}
.voted-no {
color: $votes-no-color;
}
.voted-abstain {
color: $votes-abstain-color;
}
.openslides-theme .pbl-ngrid-no-data {
top: 10%;
}

View File

@ -1,9 +1,10 @@
import { Component, OnInit } from '@angular/core';
import { Component, OnInit, ViewEncapsulation } from '@angular/core';
import { MatSnackBar } from '@angular/material';
import { Title } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { PblColumnDefinition } from '@pebula/ngrid';
import { OperatorService } from 'app/core/core-services/operator.service';
import { MotionPollRepositoryService } from 'app/core/repositories/motions/motion-poll-repository.service';
@ -18,11 +19,24 @@ import { BasePollDetailComponent } from 'app/site/polls/components/base-poll-det
@Component({
selector: 'os-motion-poll-detail',
templateUrl: './motion-poll-detail.component.html',
styleUrls: ['./motion-poll-detail.component.scss']
styleUrls: ['./motion-poll-detail.component.scss'],
encapsulation: ViewEncapsulation.None
})
export class MotionPollDetailComponent extends BasePollDetailComponent<ViewMotionPoll> implements OnInit {
public motion: ViewMotion;
public columnDefinition = ['key', 'value'];
public columnDefinition: PblColumnDefinition[] = [
{
prop: 'user',
width: 'auto',
label: 'Participant'
},
{
prop: 'vote',
width: 'auto',
label: 'Vote'
}
];
public filterProps = ['user.getFullName', 'valueVerbose'];
public set chartType(type: ChartType) {
this._chartType = type;

View File

@ -1,11 +1,11 @@
import { OnInit } from '@angular/core';
import { MatSnackBar, MatTableDataSource } from '@angular/material';
import { MatSnackBar } from '@angular/material';
import { Title } from '@angular/platform-browser';
import { ActivatedRoute } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { Label } from 'ng2-charts';
import { BehaviorSubject, Observable } from 'rxjs';
import { BehaviorSubject, from, Observable } from 'rxjs';
import { GroupRepositoryService } from 'app/core/repositories/users/group-repository.service';
import { BasePollDialogService } from 'app/core/ui-services/base-poll-dialog.service';
@ -34,6 +34,24 @@ export abstract class BasePollDetailComponent<V extends ViewBasePoll> extends Ba
*/
public groupObservable: Observable<ViewGroup[]> = null;
/**
* Details for the iconification of the votes
*/
public voteOptionStyle = {
Y: {
css: 'voted-yes',
icon: 'thumb_up'
},
N: {
css: 'voted-no',
icon: 'thumb_down'
},
A: {
css: 'voted-abstain',
icon: 'trip_origin'
}
};
/**
* The reference to the poll.
*/
@ -59,8 +77,8 @@ export abstract class BasePollDetailComponent<V extends ViewBasePoll> extends Ba
*/
public chartDataSubject: BehaviorSubject<ChartData> = new BehaviorSubject(null);
// The datasource for the votes-per-user table
public votesDataSource: MatTableDataSource<BaseVoteData> = new MatTableDataSource();
// The observable for the votes-per-user table
public votesDataObservable: Observable<BaseVoteData[]>;
/**
* Constructor
@ -88,7 +106,6 @@ export abstract class BasePollDetailComponent<V extends ViewBasePoll> extends Ba
protected pollDialog: BasePollDialogService<V>
) {
super(title, translate, matSnackbar);
this.votesDataSource.filterPredicate = this.dataSourceFilterPredicate;
}
/**
@ -141,26 +158,14 @@ export abstract class BasePollDetailComponent<V extends ViewBasePoll> extends Ba
protected abstract hasPerms(): boolean;
// custom filter for the data source: only search in usernames
protected dataSourceFilterPredicate(data: BaseVoteData, filter: string): boolean {
return (
data.user &&
data.user
.getFullName()
.trim()
.toLowerCase()
.indexOf(filter.trim().toLowerCase()) !== -1
);
}
/**
* sets the votes data only if the poll wasn't pseudoanonymized
*/
protected setVotesData(data: BaseVoteData[]): void {
if (data.every(voteDate => !voteDate.user)) {
this.votesDataSource.data = null;
this.votesDataObservable = null;
} else {
this.votesDataSource.data = data;
this.votesDataObservable = from([data]);
}
}

View File

@ -1,3 +1,3 @@
<span> {{ 'Casted votes' }}: {{ poll.voted_id.length }} / {{ max }} </span>
<span> {{ 'Cast votes' }}: {{ poll.voted_id.length }} / {{ max }} </span>
<mat-progress-bar class="voting-progress-bar" [value]="valueInPercent"></mat-progress-bar>

View File

@ -158,6 +158,10 @@
background-color: rgba(0, 0, 0, 0.025);
}
.pbl-ngrid-header-row, .pbl-ngrid-row {
align-items: stretch;
}
.mat-progress-bar-buffer {
background-color: mat-color($background, card) !important;
}