Vscroll for user import

Allows to import giant sets of users as CSV.
Tested 500k. The client is fine. The python server and the SQL data base really do not like that.
This commit is contained in:
Sean 2020-09-03 17:10:43 +02:00
parent 5b63809b12
commit f3fe98436e
6 changed files with 149 additions and 188 deletions

View File

@ -17,6 +17,16 @@ $pbl-height: var(--pbl-height);
display: block; display: block;
} }
/**
* Compilcated ngrid hack: The meta row won't disappear (just like that)
* Select the first ever container pbl-ngrid-container div and hide
*/
.pbl-ngrid-container {
> div {
height: 0;
}
}
.vscroll-list-view { .vscroll-list-view {
flex: 1 1 auto; flex: 1 1 auto;
height: 100%; height: 100%;

View File

@ -5,7 +5,8 @@ import { MatTable, MatTableDataSource } from '@angular/material/table';
import { Title } from '@angular/platform-browser'; import { Title } from '@angular/platform-browser';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { auditTime } from 'rxjs/operators'; import { createDS, PblDataSource } from '@pebula/ngrid';
import { auditTime, distinctUntilChanged } from 'rxjs/operators';
import { BaseImportService, NewEntry, ValueLabelCombination } from 'app/core/ui-services/base-import.service'; import { BaseImportService, NewEntry, ValueLabelCombination } from 'app/core/ui-services/base-import.service';
import { BaseModel } from 'app/shared/models/base/base-model'; import { BaseModel } from 'app/shared/models/base/base-model';
@ -20,6 +21,11 @@ export abstract class BaseImportListComponentDirective<M extends BaseModel> exte
*/ */
public dataSource: MatTableDataSource<NewEntry<M>>; public dataSource: MatTableDataSource<NewEntry<M>>;
/**
* Data source for ngrid
*/
public vScrollDataSource: PblDataSource<NewEntry<M>>;
/** /**
* Helper function for previews * Helper function for previews
*/ */
@ -136,14 +142,23 @@ export abstract class BaseImportListComponentDirective<M extends BaseModel> exte
*/ */
public initTable(): void { public initTable(): void {
this.dataSource = new MatTableDataSource(); this.dataSource = new MatTableDataSource();
this.setFilter();
this.importer const entryObservable = this.importer.getNewEntries();
.getNewEntries() this.subscriptions.push(
.pipe(auditTime(100)) entryObservable.pipe(distinctUntilChanged(), auditTime(100)).subscribe(newEntries => {
.subscribe(newEntries => { if (newEntries?.length) {
this.dataSource.data = newEntries; this.dataSource.data = newEntries;
}
this.hasFile = newEntries.length > 0; this.hasFile = newEntries.length > 0;
}); })
);
this.vScrollDataSource = createDS<NewEntry<M>>()
.keepAlive()
.onTrigger(() => entryObservable)
.create();
this.setFilter();
} }
/** /**
@ -180,21 +195,26 @@ export abstract class BaseImportListComponentDirective<M extends BaseModel> exte
public setFilter(): void { public setFilter(): void {
this.dataSource.filter = ''; this.dataSource.filter = '';
if (this.shown === 'all') { if (this.shown === 'all') {
this.dataSource.filterPredicate = (data, filter) => { this.dataSource.filterPredicate = () => true;
return true; this.vScrollDataSource.setFilter();
};
} else if (this.shown === 'noerror') { } else if (this.shown === 'noerror') {
this.dataSource.filterPredicate = (data, filter) => { const noErrorFilter = data => {
if (data.status === 'done') { if (data.status === 'done') {
return true; return true;
} else if (data.status !== 'error') { } else if (data.status !== 'error') {
return true; return true;
} }
}; };
this.dataSource.filterPredicate = noErrorFilter;
this.vScrollDataSource.setFilter(noErrorFilter);
} else if (this.shown === 'error') { } else if (this.shown === 'error') {
this.dataSource.filterPredicate = (data, filter) => { const hasErrorFilter = data => {
return !!data.errors.length || data.hasDuplicates; return !!data.errors.length || data.hasDuplicates;
}; };
this.dataSource.filterPredicate = hasErrorFilter;
this.vScrollDataSource.setFilter(hasErrorFilter);
} }
this.dataSource.filter = 'X'; // TODO: This is just a bogus non-null string to trigger the filter this.dataSource.filter = 'X'; // TODO: This is just a bogus non-null string to trigger the filter
} }

View File

@ -51,7 +51,7 @@
</span> </span>
<br /> <br />
<div class="code red-warning-text"> <div class="code red-warning-text">
<span *ngFor="let entry of headerRow; let last = last"> <span *ngFor="let entry of headerRowDefinition; let last = last">
{{ entry | translate }}<span *ngIf="!last">, </span> {{ entry | translate }}<span *ngIf="!last">, </span>
</span> </span>
</div> </div>
@ -126,9 +126,9 @@
</mat-card> </mat-card>
<!-- preview table --> <!-- preview table -->
<mat-card *ngIf="hasFile" class="os-form-card import-table spacer-bottom-60"> <mat-card *ngIf="hasFile" class="os-form-card spacer-bottom-60">
<h3>{{ 'Preview' | translate }}</h3> <h3>{{ 'Preview' | translate }}</h3>
<div class="summary"> <div>
<!-- new entries --> <!-- new entries -->
<div *ngIf="newCount"> <div *ngIf="newCount">
&nbsp; &nbsp;
@ -151,17 +151,28 @@
<div *ngIf="newCount"> <div *ngIf="newCount">
<span>{{ 'After verifiy the preview click on "import" please (see top right).' | translate }}</span> <span>{{ 'After verifiy the preview click on "import" please (see top right).' | translate }}</span>
</div> </div>
<mat-select *ngIf="nonImportableCount" class="filter-imports" [(value)]="shown" (selectionChange)="setFilter()"> <mat-select *ngIf="nonImportableCount" class="filter-imports" [(value)]="shown" (selectionChange)="setFilter()">
<mat-option value="all">{{ 'Show all' | translate }}</mat-option> <mat-option value="all">{{ 'Show all' | translate }}</mat-option>
<mat-option value="error">{{ 'Show errors only' | translate }}</mat-option> <mat-option value="error">{{ 'Show errors only' | translate }}</mat-option>
<mat-option value="noerror">{{ 'Show correct entries only' | translate }}</mat-option> <mat-option value="noerror">{{ 'Show correct entries only' | translate }}</mat-option>
</mat-select> </mat-select>
<div class="table-container">
<table mat-table [dataSource]="dataSource" matSort> <div>
<!-- Status column --> <pbl-ngrid
<ng-container matColumnDef="status" sticky> class="import-preview-table"
<mat-header-cell *matHeaderCellDef class="first-column"></mat-header-cell> vScrollFixed="50"
<mat-cell *matCellDef="let entry" class="first-column"> [showHeader]="true"
[dataSource]="vScrollDataSource"
[columns]="columnSet"
>
<!-- ngrid template for boolean values -->
<div *pblNgridCellTypeDef="'boolean'; value as value">
<mat-checkbox disabled [checked]="value"></mat-checkbox>
</div>
<!-- special row handling for the status column -->
<div *pblNgridCellDef="'status'; row as entry">
<div *ngIf="entry.status === 'error'"> <div *ngIf="entry.status === 'error'">
<mat-icon <mat-icon
class="red-warning-text" class="red-warning-text"
@ -187,132 +198,7 @@
{{ getActionIcon(entry) }} {{ getActionIcon(entry) }}
</mat-icon> </mat-icon>
</div> </div>
</mat-cell>
</ng-container>
<!-- Title column -->
<ng-container matColumnDef="title">
<mat-header-cell *matHeaderCellDef>{{ 'Title' | translate }}</mat-header-cell>
<mat-cell *matCellDef="let entry">
<span *ngIf="nameErrors(entry)">
<mat-icon color="warn" inline matTooltip="{{ nameErrors(entry) | translate }}">
warning
</mat-icon>
&nbsp;
</span>
{{ entry.newEntry.title }}
</mat-cell>
</ng-container>
<!-- title column -->
<ng-container matColumnDef="first_name">
<mat-header-cell *matHeaderCellDef>{{ 'Given name' | translate }}</mat-header-cell>
<mat-cell *matCellDef="let entry">
<span *ngIf="nameErrors(entry)">
<mat-icon color="warn" inline matTooltip="{{ nameErrors(entry) | translate }}">
warning
</mat-icon>
&nbsp;
</span>
{{ entry.newEntry.first_name }}
</mat-cell>
</ng-container>
<ng-container matColumnDef="last_name">
<mat-header-cell *matHeaderCellDef>{{ 'Surname' | translate }}</mat-header-cell>
<mat-cell *matCellDef="let entry">
<span *ngIf="nameErrors(entry)">
<mat-icon color="warn" inline matTooltip="{{ nameErrors(entry) | translate }}">
warning
</mat-icon>
&nbsp;
</span>
{{ entry.newEntry.last_name }}
</mat-cell>
</ng-container>
<ng-container matColumnDef="structure_level">
<mat-header-cell *matHeaderCellDef>{{ 'Structure level' | translate }}</mat-header-cell>
<mat-cell *matCellDef="let entry"> {{ entry.newEntry.structure_level }} </mat-cell>
</ng-container>
<ng-container matColumnDef="number">
<mat-header-cell *matHeaderCellDef>{{ 'Participant number' | translate }}</mat-header-cell>
<mat-cell *matCellDef="let entry"> {{ entry.newEntry.number }} </mat-cell>
</ng-container>
<!-- groups column -->
<ng-container matColumnDef="groups_id">
<mat-header-cell *matHeaderCellDef>{{ 'Groups' | translate }}</mat-header-cell>
<mat-cell *matCellDef="let entry">
<div *ngIf="entry.newEntry.csvGroups.length">
<span *ngIf="hasError(entry, 'Groups')">
<mat-icon color="warn" matTooltip="{{ getVerboseError('Groups') | translate }}">
warning
</mat-icon>
</span>
<span *ngFor="let group of entry.newEntry.csvGroups">
{{ group.name }}
<mat-icon class="newBadge" color="accent" inline *ngIf="!group.id">add</mat-icon>
&nbsp;
</span>
</div> </div>
</mat-cell> </pbl-ngrid>
</ng-container>
<ng-container matColumnDef="comment">
<mat-header-cell *matHeaderCellDef>{{ 'Comment' | translate }}</mat-header-cell>
<mat-cell *matCellDef="let entry"> {{ entry.newEntry.comment }} </mat-cell>
</ng-container>
<ng-container matColumnDef="is_active">
<mat-header-cell *matHeaderCellDef>{{ 'Is active' | translate }}</mat-header-cell>
<mat-cell *matCellDef="let entry">
<mat-checkbox disabled [checked]="entry.newEntry.is_active"> </mat-checkbox>
</mat-cell>
</ng-container>
<ng-container matColumnDef="is_present">
<mat-header-cell *matHeaderCellDef>{{ 'Is present' | translate }}</mat-header-cell>
<mat-cell *matCellDef="let entry">
<mat-checkbox disabled [checked]="entry.newEntry.is_present"> </mat-checkbox>
</mat-cell>
</ng-container>
<ng-container matColumnDef="is_committee">
<mat-header-cell *matHeaderCellDef>{{ 'Is committee' | translate }}</mat-header-cell>
<mat-cell *matCellDef="let entry">
<mat-checkbox disabled [checked]="entry.newEntry.is_committee"> </mat-checkbox>
</mat-cell>
</ng-container>
<ng-container matColumnDef="default_password">
<mat-header-cell *matHeaderCellDef>{{ 'Initial password' | translate }}</mat-header-cell>
<mat-cell *matCellDef="let entry"> {{ entry.newEntry.default_password }} </mat-cell>
</ng-container>
<ng-container matColumnDef="email">
<mat-header-cell *matHeaderCellDef>{{ 'Email' | translate }}</mat-header-cell>
<mat-cell *matCellDef="let entry"> {{ entry.newEntry.email }} </mat-cell>
</ng-container>
<ng-container matColumnDef="username">
<mat-header-cell *matHeaderCellDef>{{ 'Username' | translate }}</mat-header-cell>
<mat-cell *matCellDef="let entry"> {{ entry.newEntry.username }} </mat-cell>
</ng-container>
<ng-container matColumnDef="gender">
<mat-header-cell *matHeaderCellDef>{{ 'Gender' | translate }}</mat-header-cell>
<mat-cell *matCellDef="let entry"> {{ entry.newEntry.gender }} </mat-cell>
</ng-container>
<ng-container matColumnDef="vote_weight">
<mat-header-cell *matHeaderCellDef>{{ 'Vote weight' | translate }}</mat-header-cell>
<mat-cell *matCellDef="let entry"> {{ entry.newEntry.vote_weight }} </mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="getColumnDefinition()"></mat-header-row>
<mat-row [ngClass]="getStateClass(row)" *matRowDef="let row; columns: getColumnDefinition()"> </mat-row>
</table>
</div> </div>
</mat-card> </mat-card>

View File

@ -0,0 +1,8 @@
.import-preview-table {
display: block;
height: calc(100vh - 200px);
}
.pbl-ngrid-row {
height: 50px;
}

View File

@ -1,9 +1,10 @@
import { Component } from '@angular/core'; import { Component, ViewEncapsulation } from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms'; import { FormBuilder, FormGroup } from '@angular/forms';
import { MatSnackBar } from '@angular/material/snack-bar'; import { MatSnackBar } from '@angular/material/snack-bar';
import { Title } from '@angular/platform-browser'; import { Title } from '@angular/platform-browser';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { columnFactory, PblColumnDefinition } from '@pebula/ngrid';
import { NewEntry } from 'app/core/ui-services/base-import.service'; import { NewEntry } from 'app/core/ui-services/base-import.service';
import { CsvExportService } from 'app/core/ui-services/csv-export.service'; import { CsvExportService } from 'app/core/ui-services/csv-export.service';
@ -16,12 +17,14 @@ import { UserImportService } from '../../services/user-import.service';
*/ */
@Component({ @Component({
selector: 'os-user-import-list', selector: 'os-user-import-list',
templateUrl: './user-import-list.component.html' templateUrl: './user-import-list.component.html',
styleUrls: ['./user-import-list.component.scss'],
encapsulation: ViewEncapsulation.None
}) })
export class UserImportListComponent extends BaseImportListComponentDirective<User> { export class UserImportListComponent extends BaseImportListComponentDirective<User> {
public textAreaForm: FormGroup; public textAreaForm: FormGroup;
public headerRow = [ public headerRowDefinition = [
'Title', 'Title',
'Given name', 'Given name',
'Surname', 'Surname',
@ -39,6 +42,29 @@ export class UserImportListComponent extends BaseImportListComponentDirective<Us
'Vote weight' 'Vote weight'
]; ];
private statusImportColumn: PblColumnDefinition = {
label: this.translate.instant('Status'),
prop: `status`
};
private get generateImportColumns(): PblColumnDefinition[] {
return this.importer.headerMap.map((property, index: number) => {
const singleColumnDef: PblColumnDefinition = {
label: this.translate.instant(this.headerRowDefinition[index]),
prop: `newEntry.${property}`,
type: this.guessType(property as keyof User)
};
console.log('singleColumnDef ', singleColumnDef);
return singleColumnDef;
});
}
public columnSet = columnFactory()
.default({ minWidth: 150 })
.table(this.statusImportColumn, ...this.generateImportColumns)
.build();
/** /**
* Constructor for list view bases * Constructor for list view bases
* *
@ -55,7 +81,7 @@ export class UserImportListComponent extends BaseImportListComponentDirective<Us
formBuilder: FormBuilder, formBuilder: FormBuilder,
public translate: TranslateService, public translate: TranslateService,
private exporter: CsvExportService, private exporter: CsvExportService,
importer: UserImportService protected importer: UserImportService
) { ) {
super(importer, titleService, translate, matSnackBar); super(importer, titleService, translate, matSnackBar);
this.textAreaForm = formBuilder.group({ inputtext: [''] }); this.textAreaForm = formBuilder.group({ inputtext: [''] });
@ -103,7 +129,28 @@ export class UserImportListComponent extends BaseImportListComponentDirective<Us
[null, 'Julia', 'Bloggs', 'London', null, null, null, null, null, null, null, null, 'jbloggs', 'f', 1.5], [null, 'Julia', 'Bloggs', 'London', null, null, null, null, null, null, null, null, 'jbloggs', 'f', 1.5],
[null, null, 'Executive Board', null, null, null, null, null, null, 1, null, null, 'executive', null, 2.5] [null, null, 'Executive Board', null, null, null, null, null, null, 1, null, null, 'executive', null, 2.5]
]; ];
this.exporter.dummyCSVExport(this.headerRow, rows, `${this.translate.instant('participants-example')}.csv`); this.exporter.dummyCSVExport(
this.headerRowDefinition,
rows,
`${this.translate.instant('participants-example')}.csv`
);
}
/**
* Guess the type of the property, since
* `const type = typeof User[property];`
* always returns undefined
*/
private guessType(userProperty: keyof User): 'string' | 'number' | 'boolean' {
const numberProperties: (keyof User)[] = ['id', 'vote_weight'];
const booleanProperties: (keyof User)[] = ['is_present', 'is_committee', 'is_active'];
if (numberProperties.includes(userProperty)) {
return 'number';
} else if (booleanProperties.includes(userProperty)) {
return 'boolean';
} else {
return 'string';
}
} }
/** /**
@ -125,7 +172,7 @@ export class UserImportListComponent extends BaseImportListComponentDirective<Us
* Sends the data in the text field input area to the importer * Sends the data in the text field input area to the importer
*/ */
public parseTextArea(): void { public parseTextArea(): void {
(this.importer as UserImportService).parseTextArea(this.textAreaForm.get('inputtext').value); this.importer.parseTextArea(this.textAreaForm.get('inputtext').value);
} }
/** /**

View File

@ -505,16 +505,6 @@ button.mat-menu-item.selected {
width: -webkit-fill-available; width: -webkit-fill-available;
} }
/**
* Compilcated ngrid hack: The meta row won't disappear (just like that)
* Select the first ever container pbl-ngrid-container div and hide
*/
.pbl-ngrid-container {
> div {
height: 0;
}
}
.cdk-column-menu { .cdk-column-menu {
padding: 0 16px 0 0 !important; padding: 0 16px 0 0 !important;
} }