Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: feat(table): enhancements for table action system #182

Closed
wants to merge 7 commits into from
Closed
1 change: 1 addition & 0 deletions projects/components/table/public_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export {
PsTableTopButtonSectionDirective,
} from './src/directives/table.directives';
export { PsTableMemoryStateManager, PsTableStateManager, PsTableUrlStateManager } from './src/helper/state-manager';
export { PsTableActionStoreBase, PsTableActionStore } from './src/helper/action-store';
export { IExtendedPsTableUpdateDataInfo, IPsTableAction, IPsTableSortDefinition, IPsTableUpdateDataInfo } from './src/models';
export { IPsTableSetting, PsTableSettingsService } from './src/services/table-settings.service';
export { PsTableComponent } from './src/table.component';
Expand Down
60 changes: 51 additions & 9 deletions projects/components/table/src/data/table-data-source.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { DataSource, SelectionModel } from '@angular/cdk/collections';
import { TrackByFunction } from '@angular/core';
import { BehaviorSubject, NEVER, Observable, of, Subject, Subscription } from 'rxjs';
import { BehaviorSubject, isObservable, NEVER, Observable, of, Subject, Subscription } from 'rxjs';
import { catchError, finalize, map, take, tap } from 'rxjs/operators';
import { PsTableActionStore, PsTableActionStoreBase } from '../helper/action-store';

import { _isNumberValue } from '../helper/table.helper';
import { IExtendedPsTableUpdateDataInfo, IPsTableAction, IPsTableUpdateDataInfo, PsTableActionScope } from '../models';
import { IExtendedPsTableUpdateDataInfo, IPsTableAction, IPsTableUpdateDataInfo, PsTableAction, PsTableActionScope } from '../models';

/**
* Corresponds to `Number.MAX_SAFE_INTEGER`. Moved out into a variable here due to
* flaky browser support and the value not being defined in Closure's typings.
Expand All @@ -13,7 +16,7 @@ const MAX_SAFE_INTEGER = 9007199254740991;
export interface PsTableDataSourceOptions<TData, TTrigger = any> {
loadTrigger$?: Observable<TTrigger>;
loadDataFn: (updateInfo: IExtendedPsTableUpdateDataInfo<TTrigger>) => Observable<TData[] | IPsTableFilterResult<TData>>;
actions?: IPsTableAction<TData>[];
actions?: IPsTableAction<TData>[] | Observable<IPsTableAction<TData>[]>;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not a fan of this, this increases complexity and code size quite a bit

mode?: PsTableMode;
moreMenuThreshold?: number;
}
Expand Down Expand Up @@ -86,18 +89,29 @@ export class PsTableDataSource<T, TTrigger = any> extends DataSource<T> {
public readonly mode: PsTableMode;

/** List of actions which can be executed for a single row */
public readonly rowActions: IPsTableAction<T>[];
public get rowActions(): PsTableAction<T>[] {
return this._rowActions || [];
}
private _rowActions: PsTableAction<T>[];

/** List of actions which can be executed for a selection of rows */
public readonly listActions: IPsTableAction<T>[];
public get listActions(): PsTableAction<T>[] {
return this._listActions || [];
}
private _listActions: PsTableAction<T>[];

public readonly moreMenuThreshold: number;

/** Stores table actions.*/
private readonly _store: PsTableActionStoreBase<T> = new PsTableActionStore<T>();

/** Stream that emits when a new data array is set on the data source. */
private readonly _updateDataTrigger$: Observable<any>;

private readonly _loadData: (updateInfo: IExtendedPsTableUpdateDataInfo<TTrigger>) => Observable<T[] | IPsTableFilterResult<T>>;

private readonly _actions: IPsTableAction<T>[] | Observable<IPsTableAction<T>[]>;

/** Stream that emits when a new data array is set on the data source. */
private readonly _data: BehaviorSubject<T[]> = new BehaviorSubject<T[]>([]);

Expand All @@ -109,6 +123,11 @@ export class PsTableDataSource<T, TTrigger = any> extends DataSource<T> {

private _lastLoadTriggerData: TTrigger = null;

/**
* Subscription to the result of the _actions observable.
*/
private _loadActionsSubscription = Subscription.EMPTY;

/**
* Subscription to the result of the loadData function.
*/
Expand Down Expand Up @@ -143,10 +162,7 @@ export class PsTableDataSource<T, TTrigger = any> extends DataSource<T> {
'loadDataFn' in optionsOrLoadDataFn ? optionsOrLoadDataFn : { loadDataFn: optionsOrLoadDataFn, actions: [], mode: mode };

this.mode = options.mode || 'client';
// tslint:disable-next-line:no-bitwise
this.rowActions = options.actions?.filter((a) => a.scope & PsTableActionScope.row) || [];
// tslint:disable-next-line:no-bitwise
this.listActions = options.actions?.filter((a) => a.scope & PsTableActionScope.list) || [];
this._actions = options.actions;
this._updateDataTrigger$ = options.loadTrigger$ || NEVER;
this._loadData = options.loadDataFn;
this.moreMenuThreshold = options.moreMenuThreshold ?? 3;
Expand Down Expand Up @@ -298,6 +314,30 @@ export class PsTableDataSource<T, TTrigger = any> extends DataSource<T> {
return data;
}

public updateActions(): void {
if (!this._actions) {
return;
}

const setActions = (actions: PsTableAction<T>[]) => {
this._rowActions = actions?.filter((a) => a.scope & PsTableActionScope.row) || [];
this._listActions = actions?.filter((a) => a.scope & PsTableActionScope.list) || [];
};

if (Array.isArray(this._actions) && this._actions.length) {
setActions(this._actions.map((x) => this._store.get(x)));
} else if (isObservable(this._actions)) {
this._loadActionsSubscription?.unsubscribe();
this._loadActionsSubscription = this._actions
.pipe(
take(1),
map((x) => x.map((y) => this._store.get(y))),
finalize(() => this._internalDetectChanges.next())
)
.subscribe((actions) => setActions(actions));
}
}

/**
* Reloads the data
*/
Expand Down Expand Up @@ -371,6 +411,7 @@ export class PsTableDataSource<T, TTrigger = any> extends DataSource<T> {
this._lastLoadTriggerData = data;
this.updateData();
});
this.updateActions();
return this._renderData;
}

Expand All @@ -382,6 +423,7 @@ export class PsTableDataSource<T, TTrigger = any> extends DataSource<T> {
this._renderChangesSubscription.unsubscribe();
this._updateDataTriggerSub.unsubscribe();
this._loadDataSubscription.unsubscribe();
this._loadActionsSubscription.unsubscribe();
}

/**
Expand Down
Empty file.
28 changes: 28 additions & 0 deletions projects/components/table/src/helper/action-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { IPsTableAction, PsTableAction } from '../../src/models';

export abstract class PsTableActionStoreBase<T> {
abstract get(declaration: IPsTableAction<T>): PsTableAction<T>;
}

export class PsTableActionStore<T> extends PsTableActionStoreBase<T> {
private readonly _store = new WeakMap<IPsTableAction<T>, PsTableAction<T>>();

/**
* Gets the existing PsTableAction object by the declaration (IPsTableAction<T>) from the store or creates a new one.
*
* Because actions can have observables as children, we cannot create for each declaration the coresponding PsTableAction<T> at the beginning.
* This forces us to create the PsTableAction<T> dynamicly. But we do not want to execute the same observables multiple times, so we cache them.
* Otherwise the observable would be executed for each row.
*
* @param declaration Action declaration information
* @returns Existing or new PsTableAction
*/
public get(declaration: IPsTableAction<T>): PsTableAction<T> {
if (!this._store.has(declaration)) {
const action = new PsTableAction<T>(declaration, this);
this._store.set(declaration, action);
}

return this._store.get(declaration);
}
}
62 changes: 60 additions & 2 deletions projects/components/table/src/models.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import { isObservable, Observable, of } from 'rxjs';
import { map, shareReplay, switchMap, tap } from 'rxjs/operators';
import { PsTableActionStoreBase } from './helper/action-store';

export interface IPsTableSortDefinition {
prop: string;
displayName: string;
Expand All @@ -17,7 +21,7 @@ export interface IExtendedPsTableUpdateDataInfo<TTrigger> extends IPsTableUpdate
triggerData: TTrigger;
}

export const enum PsTableActionScope {
export enum PsTableActionScope {
row = 1,
list = 2,
// tslint:disable-next-line:no-bitwise
Expand All @@ -27,10 +31,64 @@ export const enum PsTableActionScope {
export interface IPsTableAction<T> {
label: string;
icon: string;
isSvgIcon?: boolean;
iconColor?: string;
children?: IPsTableAction<T>[];
children?: IPsTableAction<T>[] | Observable<IPsTableAction<T>[]>;
scope: PsTableActionScope;
isDisabledFn?: (items: T[]) => boolean;
isHiddenFn?: (items: T[]) => boolean;
actionFn?: (items: T[]) => void;
routerLink?: (item: T) => [];
routerLinkQueryParams?: (item: T) => { [key: string]: any };
}

export class PsTableAction<T> {
public readonly label: string;
public readonly icon: string;
public readonly isSvgIcon: boolean;
public readonly iconColor?: string;
public readonly scope: PsTableActionScope;
public readonly isDisabledFn?: (items: T[]) => boolean;
public readonly isHiddenFn?: (items: T[]) => boolean;
public readonly actionFn?: (items: T[]) => void;
public readonly isObservable: boolean;
public readonly hasChildren: boolean;
public readonly routerLink?: (item: T) => string[];
public readonly routerLinkQueryParams?: (item: T) => { [key: string]: any };
public children$: Observable<PsTableAction<T>[]>;

public get isLoading(): boolean {
return this._isLoading;
}
private _isLoading = false;

constructor(declaration: IPsTableAction<T>, store: PsTableActionStoreBase<T>) {
this.label = declaration.label;
this.icon = declaration.icon;
this.isSvgIcon = declaration.isSvgIcon === true;
this.iconColor = declaration.iconColor;
this.scope = declaration.scope;
this.isDisabledFn = declaration.isDisabledFn;
this.isHiddenFn = declaration.isHiddenFn;
this.actionFn = declaration.actionFn;
this.routerLink = declaration.routerLink;
this.routerLinkQueryParams = declaration.routerLinkQueryParams;

this.isObservable = isObservable(declaration.children);
this.hasChildren = Array.isArray(declaration.children) || this.isObservable;

if (this.hasChildren) {
this.children$ = isObservable(declaration.children)
? of(void 0).pipe(
tap(() => (this._isLoading = true)),
switchMap(() => declaration.children as Observable<IPsTableAction<T>[]>),
tap(() => (this._isLoading = false)),
shareReplay({ bufferSize: 1, refCount: false }),
map((x) => x.map((y) => store.get(y) as PsTableAction<T>) || [])
)
: of(declaration.children.map((x) => store.get(x) as PsTableAction<T>));
} else {
this.children$ = of([]);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Pipe, PipeTransform } from '@angular/core';
import { IPsTableAction } from '../models';

import { PsTableAction } from '../models';

/**
* Filters out hidden actions
Expand All @@ -9,8 +10,8 @@ import { IPsTableAction } from '../models';
pure: true,
})
export class PsTableActionsToRenderPipe implements PipeTransform {
transform<T>(actions: IPsTableAction<T>[], ...args: [T | T[]]): any {
transform<T>(actions: PsTableAction<T>[], ...args: [T | T[]]): any {
const elements = Array.isArray(args[0]) ? args[0] : [args[0]];
return actions.filter((a) => !a.isHiddenFn || !a.isHiddenFn(elements));
return actions?.filter((a) => !a.isHiddenFn || !a.isHiddenFn(elements)) || [];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,40 @@
<mat-menu #menu>
<ng-template matMenuContent>
<ng-container *ngFor="let action of actions">
<button
*ngIf="!action.children; else branch"
mat-menu-item
[disabled]="action.isDisabledFn && action.isDisabledFn(items)"
(click)="action.actionFn(items)"
>
<mat-icon [style.color]="action.iconColor">{{ action.icon }}</mat-icon>
{{ action.label }}
</button>
<ng-container *ngIf="action.isLoading || !action.hasChildren; else branch">
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the changes here could have a substantial performance impact. all that s ifs and templates are the reason for the current performance problems. with this we have even more of them.

<a
*ngIf="action.routerLink && scope === psTableActionScopes.row; else actionButton"
mat-menu-item
[disabled]="action.isLoading || (action.isDisabledFn && action.isDisabledFn(items))"
[routerLink]="action.routerLink(items[0])"
[queryParams]="(action.routerLinkQueryParams && action.routerLinkQueryParams(items[0])) || undefined"
>
<ng-container [ngTemplateOutlet]="actionIcon" [ngTemplateOutletContext]="{ $implicit: action }"></ng-container>
{{ action.label }}
</a>
<ng-template #actionButton>
<button
*ngIf="action.isLoading || !action.hasChildren; else branch"
mat-menu-item
[disabled]="action.isLoading || (action.isDisabledFn && action.isDisabledFn(items))"
(click)="action.actionFn && action.actionFn(items)"
>
<ng-container [ngTemplateOutlet]="actionIcon" [ngTemplateOutletContext]="{ $implicit: action }"></ng-container>
{{ action.label }}
</button>
</ng-template>
</ng-container>
<ng-template #branch>
<button mat-menu-item [matMenuTriggerFor]="innerPanel.menu">
<mat-icon [style.color]="action.iconColor">{{ action.icon }}</mat-icon>
<ng-container [ngTemplateOutlet]="actionIcon" [ngTemplateOutletContext]="{ $implicit: action }"></ng-container>
{{ action.label }}
</button>
<ps-table-actions #innerPanel [actions]="action.children | psTableActionsToRender: items" [items]="items"></ps-table-actions>
<ps-table-actions
#innerPanel
[actions]="action.children$ | async | psTableActionsToRender: items"
[items]="items"
[scope]="scope"
></ps-table-actions>
</ng-template>
</ng-container>
<!-- ng-content is deprecated here, as it doesn't work correctly, if it contains a mat-menu-item with a child met-menu -->
Expand All @@ -34,3 +53,11 @@
</button>
</ng-template>
</mat-menu>

<ng-template #actionIcon let-action>
<mat-icon *ngIf="!action.isLoading && !action.isSvgIcon" [style.color]="action.iconColor">{{ action.icon }}</mat-icon>
<mat-icon *ngIf="!action.isLoading && action.isSvgIcon" [style.color]="action.iconColor" [svgIcon]="action.icon"></mat-icon>
<mat-icon *ngIf="action.isLoading">
<mat-spinner mode="indeterminate" diameter="24"></mat-spinner>
</mat-icon>
</ng-template>
Original file line number Diff line number Diff line change
@@ -1,22 +1,51 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, ViewChild } from '@angular/core';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnDestroy, Output, ViewChild } from '@angular/core';
import { MatMenu } from '@angular/material/menu';
import { IPsTableIntlTexts } from '@prosoft/components/core';
import { IPsTableAction } from '../models';
import { merge, Subscription } from 'rxjs';
import { tap } from 'rxjs/operators';

import { PsTableAction, PsTableActionScope } from '../models';

@Component({
selector: 'ps-table-actions',
templateUrl: './table-actions.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PsTableActionsComponent {
@Input() public actions: IPsTableAction<unknown>[];
export class PsTableActionsComponent implements OnDestroy {
@Input()
public get actions(): PsTableAction<unknown>[] {
return this._actions;
}
public set actions(value: PsTableAction<unknown>[]) {
this._actions = value || [];
this._subscription?.unsubscribe();
const observables = this.actions?.filter((x) => x.isObservable).map((x) => x.children$);

if (observables) {
this._subscription = merge(observables)
.pipe(tap(() => this.cd.markForCheck()))
.subscribe();
}
}
private _actions: PsTableAction<unknown>[];
@Input() public items: unknown[];
@Input() public refreshable: boolean;
@Input() public settingsEnabled: boolean;
@Input() public intl: IPsTableIntlTexts;
@Input() public scope: PsTableActionScope;

public psTableActionScopes = PsTableActionScope;

@Output() public refreshData = new EventEmitter<void>();
@Output() public showSettings = new EventEmitter<void>();

@ViewChild('menu', { static: true }) menu: MatMenu;

private _subscription: Subscription = Subscription.EMPTY;

constructor(private cd: ChangeDetectorRef) {}

public ngOnDestroy(): void {
this._subscription?.unsubscribe();
}
}
Loading