Create table template component

urls
Max Ehrlicher-Schmidt 4 years ago
parent 0efe4a1050
commit 314fb3c33d

@ -34,7 +34,7 @@ import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component'; import { AppComponent } from './app.component';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { LoginComponent } from './pages/login/login.component'; import { LoginComponent } from './pages/login/login.component';
import { BikesComponent, DeleteConfirmationDialog } from './pages/tables/bikes/bikes.component'; import { BikesComponent } from './pages/tables/bikes/bikes.component';
import { GraphQLModule } from './graphql.module'; import { GraphQLModule } from './graphql.module';
import { ParticipantsComponent } from './pages/tables/participants/participants.component'; import { ParticipantsComponent } from './pages/tables/participants/participants.component';
import { LendingStationsComponent } from './pages/tables/lending-stations/lending-stations.component'; import { LendingStationsComponent } from './pages/tables/lending-stations/lending-stations.component';
@ -44,7 +44,8 @@ import { MenuListItemComponent } from './components/menu-list-item/menu-list-ite
import {SidenavProfileComponent} from './components/sidenav-profile/sidenav-profile.component'; import {SidenavProfileComponent} from './components/sidenav-profile/sidenav-profile.component';
import { NavService }from './components/menu-list-item/nav.service'; import { NavService }from './components/menu-list-item/nav.service';
import { TokenInterceptor } from './helper/token.interceptor'; import { TokenInterceptor } from './helper/token.interceptor';
import { BikeComponent } from './pages/dataPages/bike/bike.component' import { BikeComponent } from './pages/dataPages/bike/bike.component';
import { TableComponent, DeleteConfirmationDialog } from './components/table/table.component'
@NgModule({ @NgModule({
@ -59,7 +60,8 @@ import { BikeComponent } from './pages/dataPages/bike/bike.component'
TableOverviewComponent, TableOverviewComponent,
CellComponent, CellComponent,
DeleteConfirmationDialog, DeleteConfirmationDialog,
BikeComponent BikeComponent,
TableComponent
], ],
imports: [ imports: [
BrowserModule, BrowserModule,

@ -0,0 +1,237 @@
<div class="table-page-wrapper">
<div class="table-control">
<button
mat-raised-button
color="primary"
class="table-control-button"
disabled
i18n
>
Alle ausgewählten Fahrräder bearbeiten
</button>
<button
mat-raised-button
class="table-control-button"
matTooltip="Tabllendaten aktualisieren. Achtung! Alle ungespeicherten Änderungen gehen verloren."
(click)="reloadTable()"
[disabled]="reloadingTable"
>
<mat-icon class="spin">sync</mat-icon>
</button>
<button
mat-raised-button
class="table-control-button"
(click)="addNewObject()"
[disabled]="reloadingTable"
>
<mat-icon class="spin">add</mat-icon>
</button>
<mat-form-field class="filter">
<mat-label>Filter</mat-label>
<input
matInput
[(ngModel)]="filter.includesString"
(input)="applyFilter()"
placeholder="Suchbegriff eingeben..."
/>
</mat-form-field>
<button
*ngIf="!filter.onlyUnsaved && countUnsavedRows() > 0"
mat-raised-button
color="accent"
class="table-control-button"
(click)="showOnlyUnsavedElements(true)"
[disabled]="reloadingTable"
i18n
>
{{ countUnsavedRows() }} ungespeicherte(s) Element(e) anzeigen
</button>
<mat-checkbox
*ngIf="filter.onlyUnsaved"
(change)="showOnlyUnsavedElements(false)"
[(ngModel)]="filter.onlyUnsaved"
>
nur ungespeicherte Elemente anzeigen
</mat-checkbox>
<mat-paginator
[pageSizeOptions]="[15, 25, 30, 50, 100]"
showFirstLastButtons
></mat-paginator>
</div>
<div class="table-container">
<table
mat-table
class="mat-elevation-z8"
matSort
cdkDropList
cdkDropListOrientation="horizontal"
(cdkDropListDropped)="drop($event)"
[dataSource]="data"
>
<!--- Note that these columns can be defined in any order.
The actual rendered columns are set as a property on the row definition" -->
<!-- Checkbox Column -->
<ng-container matColumnDef="select" sticky>
<th mat-header-cell *matHeaderCellDef>
<mat-checkbox
(change)="$event ? masterToggle() : null"
[checked]="selection.hasValue() && isAllSelected()"
[indeterminate]="selection.hasValue() && !isAllSelected()"
>
</mat-checkbox>
</th>
<td mat-cell *matCellDef="let row">
<mat-checkbox
(click)="$event.stopPropagation()"
(change)="$event ? selection.toggle(row) : null"
[checked]="selection.isSelected(row)"
>
</mat-checkbox>
</td>
</ng-container>
<!-- Other Columns -->
<ng-container
*ngFor="let column of columnInfo"
[matColumnDef]="column.name"
[sticky]="isStickyColumn(column.name)"
>
<!-- add cdkDrag to make columns draggable-->
<th mat-header-cell *matHeaderCellDef mat-sort-header>
{{ getTranslation(column.name) }}
</th>
<td mat-cell *matCellDef="let element">
<app-cell
*ngIf="
column.type === 'Boolean' ||
element.newObject ||
(!column.readonly && element.isLockedByMe);
else stringValue
"
[editable]="
(element.newObject && column.acceptedForCreation) ||
(!column.readonly && element.isLockedByMe)
"
[required]="element.newObject && column.requiredForCreation"
(validityChange)="validityChange(element, column.name, $event)"
[(value)]="element[column.name]"
[inputType]="column.type"
[link]="column.link ? column.link(element) : null"
></app-cell>
<ng-template #stringValue>
<span *ngIf="!column.link">{{ element[column.name] }}</span>
<a mat-button color="primary" *ngIf="column.link" [routerLink]="column.link(element)">{{
element[column.name]
}}</a>
</ng-template>
</td>
</ng-container>
<!-- Buttons Column -->
<ng-container matColumnDef="buttons" stickyEnd>
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let element">
<div class="button-wrapper" *ngIf="!element.newObject">
<button
mat-icon-button
(click)="edit(element)"
*ngIf="
!element.isLockedByMe &&
!element.isLocked &&
!isLoading(element.id)
"
>
<mat-icon>edit</mat-icon>
</button>
<button
mat-icon-button
[matMenuTriggerFor]="menu"
*ngIf="
!element.isLockedByMe &&
!isLoading(element.id) &&
!element.isLocked
"
>
<mat-icon>more_vert</mat-icon>
</button>
<mat-menu #menu="matMenu">
<button
mat-menu-item
(click)="openDeleteConfirmationDialog(element)"
>
<mat-icon>delete</mat-icon>Löschen
</button>
<button mat-menu-item>
<mat-icon>content_copy</mat-icon>Duplizieren
</button>
</mat-menu>
<mat-spinner
[diameter]="32"
*ngIf="isLoading(element.id)"
></mat-spinner>
<button
mat-icon-button
*ngIf="element.isLockedByMe && !isLoading(element.id)"
(click)="save(element)"
>
<mat-icon>save</mat-icon>
</button>
<button
mat-icon-button
matTooltip="Alle ungespeicherten Änderungen verwerfen."
*ngIf="element.isLockedByMe && !isLoading(element.id)"
(click)="cancel(element)"
>
<mat-icon>cancel</mat-icon>
</button>
<mat-icon
*ngIf="element.isLocked"
matTooltip="Dieser Eintrag wird gerade von einem anderen Bearbeiter editiert. Aktualisieren Sie die Tabelle, um den neuen Status abzurufen."
>locked</mat-icon
>
</div>
<div
class="button-wrapper"
*ngIf="element.newObject"
[matTooltip]="
'Nicht ausgefüllte Pflichtfelder (rot): ' +
countUnvalidFields(element)
"
>
<button
mat-icon-button
[disabled]="countUnvalidFields(element) > 0"
(click)="create(element)"
>
<mat-icon>save</mat-icon>
</button>
<button
mat-icon-button
matTooltip="Verwerfen"
(click)="deleteNewObject(element)"
>
<mat-icon>delete</mat-icon>
</button>
</div>
</td>
</ng-container>
<!-- Table Definition -->
<tr mat-header-row *matHeaderRowDef="displayedColumns; sticky: true"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
</table>
<mat-card
*ngIf="!isLoaded"
style="display: flex; justify-content: center; align-items: center"
>
<mat-spinner [diameter]="32"></mat-spinner>
</mat-card>
</div>
</div>

@ -0,0 +1,53 @@
.table-page-wrapper {
display: flex;
flex-direction: column;
height: 100%;
.table-control {
margin: 0.5em;
flex: none;
.table-control-button {
margin: 0.25em;
}
.filter {
margin-right: 0.5em;
}
.mat-paginator {
display: inline-block;
width: 50em;
margin-right: 0.5em;
}
}
.table-container {
flex: 1;
width: auto;
margin-left: 0.5em;
margin-right: 0.5em;
max-width: 100%;
overflow: auto;
table {
max-width: 100%;
margin: 0 auto;
.mat-header-cell,
.mat-footer-cell,
.mat-cell {
min-width: 3em;
box-sizing: border-box;
padding: 0 0.25em;
}
::ng-deep.mat-form-field {
width: 100%;
}
.mat-table-sticky {
filter: brightness(90%);
//opacity: 1;
}
.button-wrapper {
display: flex;
flex-direction: row;
}
}
}
}

@ -0,0 +1,364 @@
import { SelectionModel } from '@angular/cdk/collections';
import {
Component,
EventEmitter,
Input,
Output,
ViewChild,
} from '@angular/core';
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import { BikesService, CargoBikeResult } from 'src/app/services/bikes.service';
import { flatten } from 'src/app/helperFunctions/flattenObject';
import { deepen } from 'src/app/helperFunctions/deepenObject';
import { SchemaService } from 'src/app/services/schema.service';
import { logArrayInColumnInfoForm } from 'src/app/helperFunctions/logArrayInColumnInfoForm';
import { MatTableDataSource } from '@angular/material/table';
import { MatPaginator } from '@angular/material/paginator';
import { MatSort } from '@angular/material/sort';
import { MatDialog, MatDialogRef } from '@angular/material/dialog';
@Component({
selector: 'app-table',
templateUrl: './table.component.html',
styleUrls: ['./table.component.scss'],
})
export class TableComponent {
/** this array defines the columns and translations of the table and the order they are displayed */
@Input()
columnInfo: {
name: string;
translation: string;
acceptedForCreation?: boolean;
requiredForCreation?: boolean;
sticky?: boolean;
readonly?: boolean;
type?: string;
link?: (row: any) => string;
}[] = [];
@Input()
dataService: any;
@Input()
tableDataGQLType: string;
@Input()
tableDataGQLCreateInputType: string;
@Input()
tableDataGQLUpdateInputType: string;
@ViewChild(MatPaginator) paginator: MatPaginator;
@ViewChild(MatSort) sort: MatSort;
additionalColumnsFront: string[] = ['select'];
additionalColumnsBack: string[] = ['buttons'];
displayedColumns: string[] = [];
loadingRowIds: string[] = [];
/** data source of the table */
data: MatTableDataSource<any> = new MatTableDataSource();
selection = new SelectionModel<CargoBikeResult>(true, []);
reloadingTable = false;
relockingInterval = null;
@Input()
relockingIntervalDuration = 1000 * 60 * 1;
filter = { includesString: '', onlyUnsaved: false };
initialFilter = this.filter;
isLoaded = false;
@Output() createEvent = new EventEmitter();
@Output() editEvent = new EventEmitter();
@Output() saveEvent = new EventEmitter();
@Output() cancelEvent = new EventEmitter();
@Output() deleteEvent = new EventEmitter();
@Output() relockEvent = new EventEmitter();
constructor(private schemaService: SchemaService, public dialog: MatDialog) {}
ngAfterViewInit() {
this.addColumnPropertiesFromGQLSchemaToColumnInfo();
this.data.paginator = this.paginator;
this.data.sortingDataAccessor = (item, columnName) => {
if (typeof item[columnName] === 'string') {
return item[columnName].toLocaleLowerCase();
}
return item[columnName];
};
this.data.sort = this.sort;
this.data.filter = (this.filter as unknown) as string;
this.data.filterPredicate = (data, filter: any) => {
const a = !filter.onlyUnsaved || data.newObject || data.isLockedByMe;
const b =
!filter.includesString ||
Object.keys(data).some(
(k) =>
data[k] != null &&
data[k]
.toString()
.toLowerCase()
.includes(filter.includesString.toLowerCase())
);
return a && b;
};
this.columnInfo.forEach((column) =>
this.displayedColumns.push(column.name)
);
this.displayedColumns.unshift(this.additionalColumnsFront[0]);
this.displayedColumns.push(this.additionalColumnsBack[0]);
this.dataService.loadingRowIds.subscribe((rowIds) => {
this.loadingRowIds = rowIds;
});
this.dataService.tableData.subscribe((newTableDataSource) => {
this.reloadingTable = false;
const tempDataSource = [];
for (const row of newTableDataSource) {
this.isLoaded = true;
const oldRow = this.getRowById(row.id);
/** make sure to not overwrite a row that is being edited */
if (!oldRow) {
tempDataSource.push(flatten(row));
} else if (!(oldRow.isLockedByMe && row.isLockedByMe)) {
tempDataSource.push(flatten(row));
} else if (!!oldRow) {
tempDataSource.push(oldRow);
}
}
for (const oldRow of this.data.data) {
if (oldRow.newObject) {
tempDataSource.unshift(oldRow);
}
}
this.data.data = tempDataSource;
});
this.dataService.loadTableData();
this.relockingInterval = setInterval(() => {
for (const row of this.data.data) {
if (row.isLockedByMe) {
this.relock(row);
}
}
}, this.relockingIntervalDuration);
}
ngOnDestroy() {
clearInterval(this.relockingInterval);
}
addColumnPropertiesFromGQLSchemaToColumnInfo() {
for (const column of this.columnInfo) {
const typeInformation = this.schemaService.getTypeInformation(
this.tableDataGQLType,
column.name
);
column.type = column.type || typeInformation.type;
}
for (const column of this.columnInfo) {
const typeInformation = this.schemaService.getTypeInformation(
this.tableDataGQLUpdateInputType,
column.name
);
column.readonly = column.readonly || !typeInformation.isPartOfType;
}
for (const column of this.columnInfo) {
const typeInformation = this.schemaService.getTypeInformation(
this.tableDataGQLCreateInputType,
column.name
);
column.requiredForCreation = typeInformation.isRequired;
column.acceptedForCreation = typeInformation.isPartOfType;
}
}
getTranslation(propertyName: string) {
return (
this.columnInfo.find((column) => column.name === propertyName)
?.translation || propertyName
);
}
isStickyColumn(propertyName: string) {
return (
this.columnInfo.find((column) => column.name === propertyName)?.sticky ||
false
);
}
isLoading(id: string) {
return this.loadingRowIds.includes(id);
}
validityChange(row: any, columnName: string, isValid: Event) {
if (!row.FieldsValidity) {
row['FieldsValidity'] = {};
}
row['FieldsValidity'][columnName] = isValid;
}
countUnvalidFields(row: any) {
let unvalidFieldsCount = 0;
if (!row.FieldsValidity) {
return 99;
}
for (const prop in row.FieldsValidity) {
if (!row.FieldsValidity[prop]) {
unvalidFieldsCount++;
}
}
return unvalidFieldsCount;
}
reloadTable() {
this.reloadingTable = true;
this.isLoaded = false;
this.data.data = [];
this.dataService.loadTableData();
}
addNewObject() {
this.paginator.firstPage();
this.setFilter({ ...this.filter, includesString: '' });
this.resetSorting();
this.data.data = [
{ newObject: true, id: this.getNewId() },
...this.data.data,
];
}
getNewId(): string {
let id = -1;
while (this.getRowById(id.toString())) {
id--;
}
return id.toString();
}
deleteNewObject(row: any) {
this.data.data = this.data.data.filter((element) => row.id !== element.id);
}
create(row: any) {
const newRow = this.schemaService.filterObject(
this.tableDataGQLCreateInputType,
deepen(row)
);
this.createEvent.emit(newRow);
}
edit(row: any) {
this.editEvent.emit(row);
}
relock(row: any) {
this.relockEvent.emit(row);
}
countUnsavedRows(): number {
let unsavedCount = 0;
for (const row of this.data.data) {
if (row.isLockedByMe || row.newObject) {
unsavedCount++;
}
}
return unsavedCount;
}
save(row: any) {
const deepenRow = this.schemaService.filterObject(
this.tableDataGQLUpdateInputType,
deepen(row)
);
this.saveEvent.emit(deepenRow);
}
cancel(row: any) {
this.cancelEvent.emit(row);
}
delete(row: any) {
this.deleteEvent.emit(row);
}
openDeleteConfirmationDialog(row: any) {
const dialogRef = this.dialog.open(DeleteConfirmationDialog, {
width: '250px',
});
dialogRef.afterClosed().subscribe((result) => {
if (result === true) {
this.delete(row);
}
});
}
getRowById(id: string) {
return this.data.data.find((row) => row.id === id);
}
drop(event: CdkDragDrop<string[]>) {
moveItemInArray(
this.displayedColumns,
event.previousIndex + 2,
event.currentIndex + 2
); // +2 because the first 2 (selection + name) columns are not dragable
}
/** Whether the number of selected elements matches the total number of rows. */
isAllSelected() {
const numSelected = this.selection.selected.length;
const numRows = this.data.data.length;
return numSelected === numRows;
}
/** Selects all rows if they are not all selected; otherwise clear selection. */
masterToggle() {
this.isAllSelected()
? this.selection.clear()
: this.data.data.forEach((row) => this.selection.select(row));
}
showOnlyUnsavedElements(value: boolean) {
this.filter.onlyUnsaved = value;
this.filter.includesString = '';
this.applyFilter();
}
applyFilter() {
this.data.filter = ({
...this.filter,
includesString: this.filter.includesString.trim().toLowerCase(),
} as unknown) as string;
}
setFilter(filterObject) {
this.filter = filterObject;
this.applyFilter();
}
resetSorting() {
this.sort.sort({ id: null, start: 'asc', disableClear: false });
}
}
@Component({
selector: 'delete-confirmation-dialog',
templateUrl: 'delete-confirmation-dialog.html',
})
export class DeleteConfirmationDialog {
constructor(public dialogRef: MatDialogRef<DeleteConfirmationDialog>) {}
onConfirmClick(): void {
this.dialogRef.close(true);
}
onNoClick(): void {
this.dialogRef.close(false);
}
}

@ -122,8 +122,8 @@ export class BikeComponent implements OnInit {
ngOnInit(): void { ngOnInit(): void {
this.id = this.route.snapshot.paramMap.get('id'); this.id = this.route.snapshot.paramMap.get('id');
this.bikesService.loadCargoBike({ id: this.id }); this.bikesService.loadPageData({ id: this.id });
this.bikesService.bike.subscribe((data) => { this.bikesService.pageData.subscribe((data) => {
this.data = flatten(data); this.data = flatten(data);
}); });
this.bikesService.loadingBike.subscribe( this.bikesService.loadingBike.subscribe(

@ -1,236 +1,13 @@
<div class="table-page-wrapper"> <app-table
<div class="table-control"> [columnInfo]="columnInfo"
<button [dataService]="dataService"
mat-raised-button [tableDataGQLType]="tableDataGQLType"
color="primary" [tableDataGQLCreateInputType]="tableDataGQLCreateInputType"
class="table-control-button" [tableDataGQLUpdateInputType]="tableDataGQLUpdateInputType"
disabled (createEvent)="create($event)"
i18n (editEvent)="edit($event)"
> (relockEvent)="relock($event)"
Alle ausgewählten Fahrräder bearbeiten (saveEvent)="save($event)"
</button> (cancelEvent)="cancel($event)"
<button (deleteEvent)="delete($event)"
mat-raised-button ></app-table>
class="table-control-button"
matTooltip="Tabllendaten aktualisieren. Achtung! Alle ungespeicherten Änderungen gehen verloren."
(click)="reloadTable()"
[disabled]="reloadingTable"
>
<mat-icon class="spin">sync</mat-icon>
</button>
<button
mat-raised-button
class="table-control-button"
(click)="addNewObject()"
[disabled]="reloadingTable"
>
<mat-icon class="spin">add</mat-icon>
</button>
<mat-form-field class="filter">
<mat-label>Filter</mat-label>
<input
matInput
[(ngModel)]="filter.includesString"
(input)="applyFilter()"
placeholder="Suchbegriff eingeben..."
/>
</mat-form-field>
<button
*ngIf="!filter.onlyUnsaved && countUnsavedRows() > 0"
mat-raised-button
color="accent"
class="table-control-button"
(click)="showOnlyUnsavedElements(true)"
[disabled]="reloadingTable"
i18n
>
{{ countUnsavedRows() }} ungespeicherte(s) Element(e) anzeigen
</button>
<mat-checkbox
*ngIf="filter.onlyUnsaved"
(change)="showOnlyUnsavedElements(false)"
[(ngModel)]="filter.onlyUnsaved"
>
nur ungespeicherte Elemente anzeigen
</mat-checkbox>
<mat-paginator
[pageSizeOptions]="[15, 25, 30, 50, 100]"
showFirstLastButtons
></mat-paginator>
</div>
<div class="table-container">
<table
mat-table
class="mat-elevation-z8"
matSort
cdkDropList
cdkDropListOrientation="horizontal"
(cdkDropListDropped)="drop($event)"
[dataSource]="data"
>
<!--- Note that these columns can be defined in any order.
The actual rendered columns are set as a property on the row definition" -->
<!-- Checkbox Column -->
<ng-container matColumnDef="select" sticky>
<th mat-header-cell *matHeaderCellDef>
<mat-checkbox
(change)="$event ? masterToggle() : null"
[checked]="selection.hasValue() && isAllSelected()"
[indeterminate]="selection.hasValue() && !isAllSelected()"
>
</mat-checkbox>
</th>
<td mat-cell *matCellDef="let row">
<mat-checkbox
(click)="$event.stopPropagation()"
(change)="$event ? selection.toggle(row) : null"
[checked]="selection.isSelected(row)"
>
</mat-checkbox>
</td>
</ng-container>
<!-- Other Columns -->
<ng-container
*ngFor="let column of columnInfo"
[matColumnDef]="column.name"
[sticky]="isStickyColumn(column.name)"
>
<!-- add cdkDrag to make columns draggable-->
<th mat-header-cell *matHeaderCellDef mat-sort-header>
{{ getTranslation(column.name) }}
</th>
<td mat-cell *matCellDef="let element">
<app-cell
*ngIf="
column.type === 'Boolean' ||
element.newObject ||
(!column.readonly && element.isLockedByMe);
else stringValue
"
[editable]="
(element.newObject && column.acceptedForCreation) ||
(!column.readonly && element.isLockedByMe)
"
[required]="element.newObject && column.requiredForCreation"
(validityChange)="validityChange(element, column.name, $event)"
[(value)]="element[column.name]"
[inputType]="column.type"
[link]="column.link ? column.link(element) : null"
></app-cell>
<ng-template #stringValue>
<span *ngIf="!column.link">{{ element[column.name] }}</span>
<a mat-button color="primary" *ngIf="column.link" [routerLink]="column.link(element)">{{
element[column.name]
}}</a>
</ng-template>
</td>
</ng-container>
<!-- Buttons Column -->
<ng-container matColumnDef="buttons" stickyEnd>
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let element">
<div class="button-wrapper" *ngIf="!element.newObject">
<button
mat-icon-button
(click)="edit(element)"
*ngIf="
!element.isLockedByMe &&
!element.isLocked &&
!isLoading(element.id)
"
>
<mat-icon>edit</mat-icon>
</button>
<button
mat-icon-button
[matMenuTriggerFor]="menu"
*ngIf="
!element.isLockedByMe &&
!isLoading(element.id) &&
!element.isLocked
"
>
<mat-icon>more_vert</mat-icon>
</button>
<mat-menu #menu="matMenu">
<button
mat-menu-item
(click)="openDeleteConfirmationDialog(element)"
>
<mat-icon>delete</mat-icon>Löschen
</button>
<button mat-menu-item>
<mat-icon>content_copy</mat-icon>Duplizieren
</button>
</mat-menu>
<mat-spinner
[diameter]="32"
*ngIf="isLoading(element.id)"
></mat-spinner>
<button
mat-icon-button
*ngIf="element.isLockedByMe && !isLoading(element.id)"
(click)="save(element)"
>
<mat-icon>save</mat-icon>
</button>
<button
mat-icon-button
matTooltip="Alle ungespeicherten Änderungen verwerfen."
*ngIf="element.isLockedByMe && !isLoading(element.id)"
(click)="cancel(element)"
>
<mat-icon>cancel</mat-icon>
</button>
<mat-icon
*ngIf="element.isLocked"
matTooltip="Dieser Eintrag wird gerade von einem anderen Bearbeiter editiert. Aktualisieren Sie die Tabelle, um den neuen Status abzurufen."
>locked</mat-icon
>
</div>
<div
class="button-wrapper"
*ngIf="element.newObject"
[matTooltip]="
'Nicht ausgefüllte Pflichtfelder (rot): ' +
countUnvalidFields(element)
"
>
<button
mat-icon-button
[disabled]="countUnvalidFields(element) > 0"
(click)="create(element)"
>
<mat-icon>save</mat-icon>
</button>
<button
mat-icon-button
matTooltip="Verwerfen"
(click)="deleteNewObject(element)"
>
<mat-icon>delete</mat-icon>
</button>
</div>
</td>
</ng-container>
<!-- Table Definition -->
<tr mat-header-row *matHeaderRowDef="displayedColumns; sticky: true"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
</table>
<mat-card
*ngIf="!isLoaded"
style="display: flex; justify-content: center; align-items: center"
>
<mat-spinner [diameter]="32"></mat-spinner>
</mat-card>
</div>
</div>

@ -1,52 +0,0 @@
.table-page-wrapper {
display: flex;
flex-direction: column;
height: 100%;
.table-control {
margin: 0.5em;
flex: none;
.table-control-button {
margin: 0.25em;
}
.filter {
margin-right: 0.5em;
}
.mat-paginator {
display: inline-block;
width: 50em;
margin-right: 0.5em;
}
}
.table-container {
flex: 1;
width: auto;
margin-left: 0.5em;
margin-right: 0.5em;
max-width: 100%;
overflow: auto;
table {
max-width: 100%;
margin: 0 auto;
.mat-header-cell,
.mat-footer-cell,
.mat-cell {
min-width: 3em;
box-sizing: border-box;
padding: 0 0.25em;
}
::ng-deep.mat-form-field {
width: 100%;
}
.mat-table-sticky {
filter: brightness(90%);
//opacity: 1;
}
.button-wrapper {
display: flex;
flex-direction: row;
}
}
}
}

@ -5,11 +5,6 @@ import { BikesService, CargoBikeResult } from 'src/app/services/bikes.service';
import { flatten } from 'src/app/helperFunctions/flattenObject'; import { flatten } from 'src/app/helperFunctions/flattenObject';
import { deepen } from 'src/app/helperFunctions/deepenObject'; import { deepen } from 'src/app/helperFunctions/deepenObject';
import { SchemaService } from 'src/app/services/schema.service'; import { SchemaService } from 'src/app/services/schema.service';
import { logArrayInColumnInfoForm } from 'src/app/helperFunctions/logArrayInColumnInfoForm';
import { MatTableDataSource } from '@angular/material/table';
import { MatPaginator } from '@angular/material/paginator';
import { MatSort } from '@angular/material/sort';
import { MatDialog, MatDialogRef } from '@angular/material/dialog'; import { MatDialog, MatDialogRef } from '@angular/material/dialog';
@Component({ @Component({
@ -18,17 +13,7 @@ import { MatDialog, MatDialogRef } from '@angular/material/dialog';
styleUrls: ['./bikes.component.scss'], styleUrls: ['./bikes.component.scss'],
}) })
export class BikesComponent { export class BikesComponent {
/** this array defines the columns and translations of the table and the order they are displayed */ columnInfo = [
columnInfo: {
name: string;
translation: string;
acceptedForCreation?: boolean;
requiredForCreation?: boolean;
sticky?: boolean;
readonly?: boolean;
type?: string;
link?: (row: any) => string;
}[] = [
{ {
name: 'name', name: 'name',
translation: 'Name', translation: 'Name',
@ -74,7 +59,10 @@ export class BikesComponent {
name: 'dimensionsAndLoad.maxWeightLuggageRack', name: 'dimensionsAndLoad.maxWeightLuggageRack',
translation: 'max Zuladung Gepäckträger', translation: 'max Zuladung Gepäckträger',
}, },
{ name: 'dimensionsAndLoad.maxWeightTotal', translation: 'max Gesamtgewicht' }, {
name: 'dimensionsAndLoad.maxWeightTotal',
translation: 'max Gesamtgewicht',
},
{ name: 'numberOfChildren', translation: 'Anzahl Kinder' }, { name: 'numberOfChildren', translation: 'Anzahl Kinder' },
{ name: 'numberOfWheels', translation: 'Anzahl Räder' }, { name: 'numberOfWheels', translation: 'Anzahl Räder' },
{ name: 'forCargo', translation: 'für Lasten j/n' }, { name: 'forCargo', translation: 'für Lasten j/n' },
@ -92,8 +80,14 @@ export class BikesComponent {
{ name: 'security.policeCoding', translation: 'Polizei Codierung' }, { name: 'security.policeCoding', translation: 'Polizei Codierung' },
{ name: 'technicalEquipment.bicycleShift', translation: 'Schaltung' }, { name: 'technicalEquipment.bicycleShift', translation: 'Schaltung' },
{ name: 'technicalEquipment.isEBike', translation: 'E-Bike j/n' }, { name: 'technicalEquipment.isEBike', translation: 'E-Bike j/n' },
{ name: 'technicalEquipment.hasLightSystem', translation: 'Lichtanlage j/n' }, {
{ name: 'technicalEquipment.specialFeatures', translation: 'Besonderheiten' }, name: 'technicalEquipment.hasLightSystem',
translation: 'Lichtanlage j/n',
},
{
name: 'technicalEquipment.specialFeatures',
translation: 'Besonderheiten',
},
{ name: 'stickerBikeNameState', translation: 'Aufkleber Status' }, { name: 'stickerBikeNameState', translation: 'Aufkleber Status' },
{ name: 'note', translation: 'Aufkleber Kommentar' }, { name: 'note', translation: 'Aufkleber Kommentar' },
{ name: 'taxes.costCenter', translation: 'Steuern Kostenstelle' }, { name: 'taxes.costCenter', translation: 'Steuern Kostenstelle' },
@ -115,313 +109,42 @@ export class BikesComponent {
{ name: 'lendingStation.address.zip', translation: '' }, { name: 'lendingStation.address.zip', translation: '' },
]; ];
dataService: any;
tableDataGQLType: string = 'CargoBike'; tableDataGQLType: string = 'CargoBike';
tableDataGQLCreateInputType: string = 'CargoBikeCreateInput'; tableDataGQLCreateInputType: string = 'CargoBikeCreateInput';
tableDataGQLUpdateInputType: string = 'CargoBikeUpdateInput'; tableDataGQLUpdateInputType: string = 'CargoBikeUpdateInput';
@ViewChild(MatPaginator) paginator: MatPaginator;
@ViewChild(MatSort) sort: MatSort;
additionalColumnsFront: string[] = ['select'];
additionalColumnsBack: string[] = ['buttons'];
displayedColumns: string[] = [];
loadingRowIds: string[] = []; loadingRowIds: string[] = [];
/** data source of the table */
data: MatTableDataSource<any> = new MatTableDataSource();
selection = new SelectionModel<CargoBikeResult>(true, []);
reloadingTable = false;
relockingInterval = null;
relockingDuration = 1000 * 60 * 1;
filter = { includesString: '', onlyUnsaved: false };
initialFilter = this.filter;
isLoaded = false;
constructor( constructor(
private bikesService: BikesService, private bikesService: BikesService
private schemaService: SchemaService,
public dialog: MatDialog
) {} ) {}
ngAfterViewInit() { ngOnInit() {
this.addColumnPropertiesFromGQLSchemaToColumnInfo(); this.dataService = this.bikesService;
this.data.paginator = this.paginator;
this.data.sortingDataAccessor = (item, columnName) => {
if (typeof item[columnName] === 'string') {
return item[columnName].toLocaleLowerCase();
}
return item[columnName];
};
this.data.sort = this.sort;
this.data.filter = (this.filter as unknown) as string;
this.data.filterPredicate = (data, filter: any) => {
const a = !filter.onlyUnsaved || data.newObject || data.isLockedByMe;
const b =
!filter.includesString ||
Object.keys(data).some(
(k) =>
data[k] != null &&
data[k]
.toString()
.toLowerCase()
.includes(filter.includesString.toLowerCase())
);
return a && b;
};
this.columnInfo.forEach((column) =>
this.displayedColumns.push(column.name)
);
this.displayedColumns.unshift(this.additionalColumnsFront[0]);
this.displayedColumns.push(this.additionalColumnsBack[0]);
this.bikesService.loadingRowIds.subscribe((rowIds) => {
this.loadingRowIds = rowIds;
});
this.bikesService.bikes.subscribe((newTableDataSource) => {
this.reloadingTable = false;
const tempDataSource = [];
for (const row of newTableDataSource) {
this.isLoaded = true;
const oldRow = this.getRowById(row.id);
/** make sure to not overwrite a row that is being edited */
if (!oldRow) {
tempDataSource.push(flatten(row));
} else if (!(oldRow.isLockedByMe && row.isLockedByMe)) {
tempDataSource.push(flatten(row));
} else if (!!oldRow) {
tempDataSource.push(oldRow);
}
}
for (const oldRow of this.data.data) {
if (oldRow.newObject) {
tempDataSource.unshift(oldRow);
}
}
this.data.data = tempDataSource;
});
this.bikesService.loadBikes();
this.relockingInterval = setInterval(() => {
for (const row of this.data.data) {
if (row.isLockedByMe) {
this.bikesService.relockBike({ id: row.id });
}
}
}, this.relockingDuration);
}
ngOnDestroy() {
clearInterval(this.relockingInterval);
}
addColumnPropertiesFromGQLSchemaToColumnInfo() {
for (const column of this.columnInfo) {
const typeInformation = this.schemaService.getTypeInformation(
this.tableDataGQLType,
column.name
);
column.type = column.type || typeInformation.type;
}
for (const column of this.columnInfo) {
const typeInformation = this.schemaService.getTypeInformation(
this.tableDataGQLUpdateInputType,
column.name
);
column.readonly = column.readonly || !typeInformation.isPartOfType;
}
for (const column of this.columnInfo) {
const typeInformation = this.schemaService.getTypeInformation(
this.tableDataGQLCreateInputType,
column.name
);
column.requiredForCreation = typeInformation.isRequired;
column.acceptedForCreation = typeInformation.isPartOfType;
}
}
getTranslation(propertyName: string) {
return (
this.columnInfo.find((column) => column.name === propertyName)?.translation ||
propertyName
);
}
isStickyColumn(propertyName: string) {
return (
this.columnInfo.find((column) => column.name === propertyName)?.sticky ||
false
);
}
isLoading(id: string) {
return this.loadingRowIds.includes(id);
}
validityChange(row: any, columnName: string, isValid: Event) {
if (!row.FieldsValidity) {
row['FieldsValidity'] = {};
}
row['FieldsValidity'][columnName] = isValid;
}
countUnvalidFields(row: any) {
let unvalidFieldsCount = 0;
if (!row.FieldsValidity) {
return 99;
}
for (const prop in row.FieldsValidity) {
if (!row.FieldsValidity[prop]) {
unvalidFieldsCount++;
}
}
return unvalidFieldsCount;
}
reloadTable() {
this.reloadingTable = true;
this.isLoaded = false;
this.data.data = [];
this.bikesService.loadBikes();
}
addNewObject() {
this.paginator.firstPage();
this.setFilter({ ...this.filter, includesString: '' });
this.resetSorting();
this.data.data = [
{ newObject: true, id: this.getNewId() },
...this.data.data,
];
}
getNewId(): string {
let id = -1;
while (this.getRowById(id.toString())) {
id--;
}
return id.toString();
}
deleteNewObject(row: any) {
this.data.data = this.data.data.filter((element) => row.id !== element.id);
} }
create(row: any) { create(row: any) {
const newBike = this.schemaService.filterObject( this.bikesService.createBike({ bike: row });
this.tableDataGQLCreateInputType,
deepen(row)
);
this.bikesService.createBike({ bike: newBike });
} }
edit(row: CargoBikeResult) { edit(row: any) {
this.bikesService.lockBike({ id: row.id }); this.bikesService.lockBike({ id: row.id });
} }
countUnsavedRows(): number { relock(row: any) {
let unsavedCount = 0; this.bikesService.relockBike({ id: row.id });
for (const row of this.data.data) {
if (row.isLockedByMe || row.newObject) {
unsavedCount++;
}
}
return unsavedCount;
} }
save(row: CargoBikeResult) { save(row: any) {
const deepenRow = this.schemaService.filterObject( this.bikesService.updateBike({ bike: row });
this.tableDataGQLUpdateInputType,
deepen(row)
);
this.bikesService.updateBike({ bike: deepenRow });
} }
cancel(row: CargoBikeResult) { cancel(row: any) {
this.bikesService.unlockBike({ id: row.id }); this.bikesService.unlockBike({ id: row.id });
} }
delete(row: any) { delete(row: any) {
this.bikesService.deleteBike({ id: row.id }); this.bikesService.deleteBike({ id: row.id });
} }
openDeleteConfirmationDialog(row: any) {
const dialogRef = this.dialog.open(DeleteConfirmationDialog, {
width: '250px',
});
dialogRef.afterClosed().subscribe((result) => {
if (result === true) {
this.delete(row);
}
});
}
getRowById(id: string) {
return this.data.data.find((row) => row.id === id);
}
drop(event: CdkDragDrop<string[]>) {
moveItemInArray(
this.displayedColumns,
event.previousIndex + 2,
event.currentIndex + 2
); // +2 because the first 2 (selection + name) columns are not dragable
}
/** Whether the number of selected elements matches the total number of rows. */
isAllSelected() {
const numSelected = this.selection.selected.length;
const numRows = this.data.data.length;
return numSelected === numRows;
}
/** Selects all rows if they are not all selected; otherwise clear selection. */
masterToggle() {
this.isAllSelected()
? this.selection.clear()
: this.data.data.forEach((row) => this.selection.select(row));
}
showOnlyUnsavedElements(value: boolean) {
this.filter.onlyUnsaved = value;
this.filter.includesString = '';
this.applyFilter();
}
applyFilter() {
this.data.filter = ({
...this.filter,
includesString: this.filter.includesString.trim().toLowerCase(),
} as unknown) as string;
}
setFilter(filterObject) {
this.filter = filterObject;
this.applyFilter();
}
resetSorting() {
this.sort.sort({ id: null, start: 'asc', disableClear: false });
}
}
@Component({
selector: 'delete-confirmation-dialog',
templateUrl: 'delete-confirmation-dialog.html',
})
export class DeleteConfirmationDialog {
constructor(public dialogRef: MatDialogRef<DeleteConfirmationDialog>) {}
onConfirmClick(): void {
this.dialogRef.close(true);
}
onNoClick(): void {
this.dialogRef.close(false);
}
} }

@ -29,9 +29,10 @@ export type CargoBikeResult = DeepExtractTypeSkipArrays<
providedIn: 'root', providedIn: 'root',
}) })
export class BikesService { export class BikesService {
bikes: BehaviorSubject<CargoBikeResult[]> = new BehaviorSubject([]); /** CargoBikes Array */
tableData: BehaviorSubject<CargoBikeResult[]> = new BehaviorSubject([]);
loadingRowIds: BehaviorSubject<string[]> = new BehaviorSubject([]); loadingRowIds: BehaviorSubject<string[]> = new BehaviorSubject([]);
bike: BehaviorSubject<any> = new BehaviorSubject([]); pageData: BehaviorSubject<any> = new BehaviorSubject([]);
loadingBike: BehaviorSubject<boolean> = new BehaviorSubject(false); loadingBike: BehaviorSubject<boolean> = new BehaviorSubject(false);
@ -58,20 +59,20 @@ export class BikesService {
}); });
} }
loadBikes() { loadTableData() {
this.getCargoBikesGQL.fetch().subscribe((result) => { this.getCargoBikesGQL.fetch().subscribe((result) => {
this.bikes.next(result.data.cargoBikes); this.tableData.next(result.data.cargoBikes);
}); });
} }
loadCargoBike(variables: GetCargoBikeByIdQueryVariables) { loadPageData(variables: GetCargoBikeByIdQueryVariables) {
this.bike.next(null); this.pageData.next(null);
this.loadingBike.next(true); this.loadingBike.next(true);
this.getCargoBikeByIdGQL this.getCargoBikeByIdGQL
.fetch(variables) .fetch(variables)
.subscribe((result) => { .subscribe((result) => {
this.bike.next(result.data.cargoBikeById); this.pageData.next(result.data.cargoBikeById);
}) })
.add(() => { .add(() => {
this.loadingBike.next(false); this.loadingBike.next(false);
@ -84,8 +85,8 @@ export class BikesService {
.fetch(variables) .fetch(variables)
.subscribe((result) => { .subscribe((result) => {
const newBike = result.data.cargoBikeById; const newBike = result.data.cargoBikeById;
this.bikes.next( this.tableData.next(
this.bikes.value.map((bike) => this.tableData.value.map((bike) =>
newBike.id === bike.id ? newBike : bike newBike.id === bike.id ? newBike : bike
) )
); );
@ -100,8 +101,8 @@ export class BikesService {
.mutate(variables) .mutate(variables)
.subscribe((result) => { .subscribe((result) => {
const newBike = result.data.createCargoBike; const newBike = result.data.createCargoBike;
this.bikes.next( this.tableData.next(
[newBike, ...this.bikes.value] [newBike, ...this.tableData.value]
); );
}) })
} }
@ -112,8 +113,8 @@ export class BikesService {
.mutate(variables) .mutate(variables)
.subscribe((result) => { .subscribe((result) => {
const newBike = result.data.updateCargoBike; const newBike = result.data.updateCargoBike;
this.bikes.next( this.tableData.next(
this.bikes.value.map((bike) => this.tableData.value.map((bike) =>
newBike.id === bike.id ? newBike : bike newBike.id === bike.id ? newBike : bike
) )
); );
@ -129,8 +130,8 @@ export class BikesService {
.mutate(variables) .mutate(variables)
.subscribe((result) => { .subscribe((result) => {
const lockedBike = result.data.lockCargoBike; const lockedBike = result.data.lockCargoBike;
this.bikes.next( this.tableData.next(
this.bikes.value.map((bike) => this.tableData.value.map((bike) =>
lockedBike.id === bike.id ? lockedBike : bike lockedBike.id === bike.id ? lockedBike : bike
) )
); );
@ -146,8 +147,8 @@ export class BikesService {
.mutate(variables) .mutate(variables)
.subscribe((result) => { .subscribe((result) => {
const unlockedBike = result.data.unlockCargoBike; const unlockedBike = result.data.unlockCargoBike;
this.bikes.next( this.tableData.next(
this.bikes.value.map((bike) => this.tableData.value.map((bike) =>
unlockedBike.id === bike.id ? unlockedBike : bike unlockedBike.id === bike.id ? unlockedBike : bike
) )
); );
@ -163,7 +164,7 @@ export class BikesService {
.mutate(variables) .mutate(variables)
.subscribe((result) => { .subscribe((result) => {
if(result.data.deleteCargoBike) { if(result.data.deleteCargoBike) {
this.bikes.next([...this.bikes.value].filter(bike => bike.id !== variables.id)); this.tableData.next([...this.tableData.value].filter(bike => bike.id !== variables.id));
} }
}) })
.add(() => { .add(() => {

Loading…
Cancel
Save