src/app/core/common-components/entities-table/data-source/entities-table-data-source.ts
Abstract data source for tables that handles all data handling internally. Depending on the environment all data might be in-memory or only requested from the server on demand.
MatTableDataSource<TableRow<T>>
No results matching.
Properties |
|
Methods |
|
Accessors |
Protected
constructor()
|
| allRecords |
Type : unknown
|
Default value : signal<T[]>([])
|
| dataFilter |
Type : unknown
|
Default value : signal<DataFilter<T>>({})
|
| displayedData |
Type : unknown
|
Default value : signal<TableRow<T>[]>([])
|
|
The rows handed to the table as input data, in the order they were loaded. Sorting and pagination are applied on top of this by the table, see renderedRows. |
| Protected Readonly entityMapper |
Type : unknown
|
Default value : inject(EntityMapperService)
|
| filteredRecords |
Type : unknown
|
Default value : signal<T[]>([])
|
| isLoading |
Type : unknown
|
Default value : signal(false)
|
| loadRecordConfig |
Type : unknown
|
Default value : signal<LoadRecordConfig<T>>(undefined)
|
| Readonly renderedRows |
Type : unknown
|
Default value : toSignal(this.connect(), { requireSync: true })
|
|
The rows as currently rendered by the table: filtered, sorted and reduced to the current page. |
| sortValueFns |
Type : unknown
|
Default value : signal<SortValueFns<T>>({})
|
| Abstract getAllData | ||||||||
getAllData(filtered?: boolean)
|
||||||||
|
Load the full set of records (independent of the currently displayed page), for use cases like export that need all data rather than only what is currently rendered.
Parameters :
Returns :
Promise<T[]>
|
| Protected listenToEntityUpdates |
listenToEntityUpdates()
|
|
Returns :
void
|
| Protected Abstract loadRecords |
loadRecords()
|
|
Actually (re)load the records. Implemented by the concrete data source.
Returns :
Promise<any>
|
| Protected Abstract processEntityUpdate | ||||||
processEntityUpdate(updatedEntity: UpdatedEntity<T>)
|
||||||
|
Parameters :
Returns :
Promise<any>
|
| Protected setRecords |
setRecords()
|
|
Request a (re)load of the records for the current config/filter/sort/page. All the triggers that fire while a view initializes are debounced into a single request, so opening a list does not send several redundant DB requests (and the request already uses the fully resolved filter/sort/page). isLoading is set immediately so the table can show a progress indicator until the load finishes.
Returns :
Promise<any>
a promise that resolves once the resulting load has completed. |
| data | ||||||
getdata()
|
||||||
setdata(data: TableRow<T>[])
|
||||||
|
Parameters :
Returns :
void
|
import { MatTableDataSource } from "@angular/material/table";
import { TableRow } from "#src/app/core/common-components/entities-table/table-row";
import { Entity, EntityConstructor } from "#src/app/core/entity/model/entity";
import { DestroyRef, effect, inject, signal } from "@angular/core";
import { DataFilter } from "#src/app/core/filter/filters/filters";
import { SortValueFns } from "#src/app/core/common-components/entities-table/table-sort/table-sort";
import { LoaderMethod } from "#src/app/core/entity/entity-special-loader/entity-special-loader.service";
import { UpdatedEntity } from "#src/app/core/entity/model/entity-update";
import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop";
import { Subject, Subscription } from "rxjs";
import { debounceTime } from "rxjs/operators";
import { EntityMapperService } from "#src/app/core/entity/entity-mapper/entity-mapper.service";
import { BulkOperationStateService } from "#src/app/core/entity/entity-actions/bulk-operation-state.service";
import { Logging } from "#src/app/core/logging/logging.service";
import {
MatSnackBar,
MatSnackBarRef,
TextOnlySnackBar,
} from "@angular/material/snack-bar";
/**
* Configuration object that is necessary if data should be loaded by the datasource
*/
export interface LoadRecordConfig<T extends Entity> {
/**
* Constructor of the entity type to be loaded
*/
entityCtr: EntityConstructor<T>;
/**
* Set this if entities only related to another entity should be loaded
*/
forEntity?: Entity;
/**
* Property through which the relation to `forEntity` can be resolved,
* or several candidates if it cannot be determined unambiguously
*/
relationProperty?: string | string[];
/**
* Select if a special loader method should be used for this entity
*/
loaderMethod?: LoaderMethod;
}
/**
* Abstract data source for tables that handles all data handling internally.
* Depending on the environment all data might be in-memory or only requested from the server on demand.
*/
export abstract class EntitiesTableDataSource<
T extends Entity,
> extends MatTableDataSource<TableRow<T>> {
private readonly destroyRef = inject(DestroyRef);
protected readonly entityMapper = inject(EntityMapperService);
private readonly bulkOperationState = inject(BulkOperationStateService);
private readonly snackBar = inject(MatSnackBar);
/** Reference to the currently shown "could not load data" toast, if any. */
private loadErrorSnackBarRef?: MatSnackBarRef<TextOnlySnackBar>;
dataFilter = signal<DataFilter<T>>({});
sortValueFns = signal<SortValueFns<T>>({});
allRecords = signal<T[]>([]);
filteredRecords = signal<T[]>([]);
/**
* The rows handed to the table as input data, in the order they were loaded.
* Sorting and pagination are applied on top of this by the table, see {@link renderedRows}.
*/
displayedData = signal<TableRow<T>[]>([]);
/**
* The rows as currently rendered by the table:
* filtered, sorted and reduced to the current page.
*/
readonly renderedRows = toSignal(this.connect(), { requireSync: true });
loadRecordConfig = signal<LoadRecordConfig<T>>(undefined);
isLoading = signal(false);
// NOTE: overriding only the setter would hide the inherited `get data`,
// so `dataSource.data` would return `undefined`. Provide both accessors.
override get data(): TableRow<T>[] {
return super.data;
}
override set data(data: TableRow<T>[]) {
// expose signal containing current data
this.displayedData.set(data);
super.data = data;
}
// Make sure only one update subscription is active
private updateSubscription: Subscription;
protected constructor() {
super();
effect(() => {
this.data = this.filteredRecords().map((record) => ({ record }));
});
effect(() => {
if (this.loadRecordConfig()) {
// If config is provided, this class loads the data and listens to updates
this.setRecords();
this.listenToEntityUpdates();
}
});
effect(() => {
// got to first page if filter changes
this.dataFilter();
this.paginator?.firstPage();
});
// Coalesce the many triggers that fire while a view initializes
// (config, default + url-param filters, sort, paginator) into a single load.
this.reloadTrigger
.pipe(debounceTime(0), takeUntilDestroyed(this.destroyRef))
.subscribe(() => this.executeLoad());
}
/** Emits whenever a (re)load of the records is requested, see {@link setRecords}. */
private readonly reloadTrigger = new Subject<void>();
/** Awaiters of the currently scheduled (not yet executed) load. */
private pendingReload?: {
/** Will resolve with the requested data */
promise: Promise<any>;
/** Resolver function for the above promise */
resolve: (value: Promise<any>) => void;
};
/**
* Request a (re)load of the records for the current config/filter/sort/page.
*
* All the triggers that fire while a view initializes are debounced into a
* single request, so opening a list does not send several redundant DB
* requests (and the request already uses the fully resolved filter/sort/page).
* {@link isLoading} is set immediately so the table can show a progress
* indicator until the load finishes.
*
* @returns a promise that resolves once the resulting load has completed.
*/
protected setRecords(): Promise<any> {
this.isLoading.set(true);
if (!this.pendingReload) {
let resolve!: (value: Promise<any>) => void;
const promise = new Promise((res) => (resolve = res));
this.pendingReload = { promise, resolve };
}
this.reloadTrigger.next();
return this.pendingReload.promise;
}
private executeLoad(): void {
const reload = this.pendingReload;
this.pendingReload = undefined;
const load = this.loadRecords()
.then((data) => {
// a previous failure has now recovered
this.loadErrorSnackBarRef?.dismiss();
this.loadErrorSnackBarRef = undefined;
return data;
})
.catch((err) => {
Logging.error(
"Error loading data in datasource",
err,
this.loadRecordConfig(),
);
this.showLoadErrorToast();
return [];
})
.finally(() => this.isLoading.set(false));
// let awaiters (e.g. bulk operations) adopt the actual load's outcome
reload?.resolve(load);
}
/**
* Notify the user that the list could not be loaded and offer a manual retry.
* The retry routes through {@link setRecords} so it reuses the normal
* (debounced, `isLoading`-tracked) reload path.
*/
private showLoadErrorToast(): void {
this.loadErrorSnackBarRef?.dismiss();
this.loadErrorSnackBarRef = this.snackBar.open(
$localize`:Table data loading failed:Could not load the list. Please check your internet connection.`,
$localize`:Retry loading table data:Retry`,
{ duration: 3600000 },
);
this.loadErrorSnackBarRef
.onAction()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => this.setRecords());
}
/** Actually (re)load the records. Implemented by the concrete data source. */
protected abstract loadRecords(): Promise<any>;
/**
* Load the full set of records (independent of the currently displayed page),
* for use cases like export that need all data rather than only what is currently rendered.
* @param filtered Whether to apply the current `dataFilter`, or return unfiltered records
*/
abstract getAllData(filtered?: boolean): Promise<T[]>;
protected listenToEntityUpdates() {
const entityConstructor = this.loadRecordConfig().entityCtr;
if (!entityConstructor) {
return;
}
this.updateSubscription?.unsubscribe();
this.updateSubscription = this.entityMapper
.receiveUpdates(entityConstructor)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((update) => this.handleUpdate(update));
}
private handleUpdate(updatedEntity: UpdatedEntity<T>) {
if (this.bulkOperationState.isBulkOperationInProgress()) {
return this.handleUpdateDuringBulkOperation(updatedEntity);
}
return this.processEntityUpdate(updatedEntity);
}
protected abstract processEntityUpdate(
updatedEntity: UpdatedEntity<T>,
): Promise<any>;
private async handleUpdateDuringBulkOperation(
updatedEntity: UpdatedEntity<T>,
) {
//buffer updates during bulk operations to avoid UI performance issues
const inProgress = this.bulkOperationState.updateBulkOperationProgress(
updatedEntity,
false,
);
if (!inProgress) {
// reload the list once
await this.setRecords();
// Use setTimeout and requestAnimationFrame to detect when UI rendering is complete and inform the bulk action update
setTimeout(() => {
requestAnimationFrame(() => {
this.bulkOperationState.completeBulkOperation();
});
});
}
}
}