Angular-RU/angular-ru-sdk

View on GitHub
libs/cdk/virtual-table/src/abstract-table-builder-api.directive.ts

Summary

Maintainability
C
1 day
Test Coverage
/* eslint-disable @angular-eslint/no-input-rename */
import { CdkDragSortEvent } from '@angular/cdk/drag-drop';
import {
    AfterContentInit,
    AfterViewChecked,
    AfterViewInit,
    ApplicationRef,
    ChangeDetectorRef,
    ContentChild,
    ContentChildren,
    Directive,
    ElementRef,
    EventEmitter,
    Input,
    NgZone,
    OnChanges,
    OnDestroy,
    OnInit,
    Output,
    QueryList,
    SimpleChanges,
    ViewChild,
    ViewChildren
} from '@angular/core';
import { coerceBoolean } from '@angular-ru/cdk/coercion';
import { pathsOfObject } from '@angular-ru/cdk/object';
import { DeepPartial, Fn, Nullable, PlainObjectOf, PrimaryKey } from '@angular-ru/cdk/typings';
import { detectChanges, isNotNil, isTrue } from '@angular-ru/cdk/utils';

import { NgxColumnComponent } from './components/ngx-column/ngx-column.component';
import { NgxContextMenuComponent } from './components/ngx-context-menu/ngx-context-menu.component';
import { NgxEmptyComponent } from './components/ngx-empty/ngx-empty.component';
import { NgxFilterComponent } from './components/ngx-filter/ngx-filter.component';
import { NgxFooterComponent } from './components/ngx-footer/ngx-footer.component';
import { NgxHeaderComponent } from './components/ngx-header/ngx-header.component';
import { NgxOptionsComponent } from './components/ngx-options/ngx-options.component';
import { TABLE_GLOBAL_OPTIONS } from './config/table-global-options';
import {
    ColumnsSchema,
    ExcludePattern,
    GeneralTableSettings,
    OrderedField,
    ProduceDisableFn,
    TableUpdateSchema,
    ViewPortInfo
} from './interfaces/table-builder.external';
import { ResizeEvent } from './interfaces/table-builder.internal';
import { ContextMenuService } from './services/context-menu/context-menu.service';
import { DraggableService } from './services/draggable/draggable.service';
import { FilterDescriptor } from './services/filterable/filter-descriptor';
import { FilterWorkerEvent } from './services/filterable/filter-worker-event';
import { FilterableService } from './services/filterable/filterable.service';
import { ResizableService } from './services/resizer/resizable.service';
import { SelectionMap } from './services/selection/selection';
import { SelectionService } from './services/selection/selection.service';
import { SortableService } from './services/sortable/sortable.service';
import { NgxTableViewChangesService } from './services/table-view-changes/ngx-table-view-changes.service';
import { TemplateParserService } from './services/template-parser/template-parser.service';
import { SCROLLBAR_SIZE } from './table-builder.properties';
import { TableSortTypes } from './types/table-sort-types';

const { ROW_HEIGHT, FILTER_DELAY_TIME, TIME_IDLE, TIME_RELOAD }: typeof TABLE_GLOBAL_OPTIONS = TABLE_GLOBAL_OPTIONS;

@Directive()
export abstract class AbstractTableBuilderApiDirective<T>
    implements OnChanges, OnInit, AfterViewInit, AfterContentInit, AfterViewChecked, OnDestroy
{
    public abstract readonly templateParser: TemplateParserService<T>;
    public abstract readonly selection: SelectionService<T>;
    public abstract readonly cd: ChangeDetectorRef;
    public abstract readonly resize: ResizableService;
    public abstract readonly sortable: SortableService<T>;
    public abstract readonly contextMenu: ContextMenuService<T>;
    public abstract readonly filterable: FilterableService<T>;
    public abstract readonly ngZone: NgZone;
    protected abstract readonly app: ApplicationRef;
    protected abstract readonly viewChanges: NgxTableViewChangesService;
    protected abstract readonly draggable: DraggableService<T>;
    private filterIdTask: Nullable<number> = null;
    private idleDetectChangesId: Nullable<number> = null;
    private columnFrameId: Nullable<number> = null;
    private onChangesId: number = 0;
    private _headHeight: Nullable<number> = null;
    private _rowHeight: Nullable<number> = null;
    protected originalSource: Nullable<T[]> = null;
    protected renderedKeys: string[] = [];
    protected isDragMoving: boolean = false;
    @Input() public height: Nullable<string | number> = null;
    @Input() public width: Nullable<string | number> = null;
    @Input() public source: Nullable<T[]> = null;
    @Input() public keys: string[] = [];
    @Input() public striped: boolean = true;
    @Input() public name: Nullable<string> = null;
    @Input('skip-sort') public skipSort: boolean | string = false;
    @Input('sort-types') public sortTypes: TableSortTypes = null;
    @Input('filter-definition') public filterDefinition: Nullable<FilterDescriptor[]> = [];
    @Input('exclude-keys') public excludeKeys: ExcludePattern<T>[] = [];
    @Input('auto-width') public autoWidth: boolean | string = false;
    @Input('auto-height') public autoHeightDetect: boolean = true;
    @Input('native-scrollbar') public nativeScrollbar: boolean = false;
    @Input('primary-key') public primaryKey: string = PrimaryKey.ID;
    @Input('vertical-border') public verticalBorder: boolean = true;
    @Input('enable-selection') public enableSelection: boolean | string = false;
    @Input('enable-filtering') public enableFiltering: boolean | string = false;
    @Input('disable-deep-path') public disableDeepPath: boolean = false;
    @Input('produce-disable-fn') public produceDisableFn: ProduceDisableFn<T> = null;
    @Input('row-css-classes') public rowCssClasses: PlainObjectOf<string[]> = {};
    @Input('schema-columns') public schemaColumns: Nullable<TableUpdateSchema> = null;
    @Input('schema-version') public schemaVersion: number = 1;
    @Input('is-virtual-table') public isVirtualTable: boolean = true;
    @Output() public readonly afterRendered: EventEmitter<boolean> = new EventEmitter();
    @Output() public readonly schemaChanges: EventEmitter<TableUpdateSchema> = new EventEmitter();
    // TODO: should be rename (breaking changes)
    // eslint-disable-next-line @angular-eslint/no-output-on-prefix
    @Output() public readonly onChanges: EventEmitter<Nullable<T[]>> = new EventEmitter();
    @Output() public readonly sortChanges: EventEmitter<OrderedField[]> = new EventEmitter();
    @ContentChild(NgxOptionsComponent, { static: false }) public columnOptions: Nullable<NgxOptionsComponent> = null;

    @ContentChildren(NgxColumnComponent) public columnTemplates: Nullable<QueryList<NgxColumnComponent<T>>> = null;

    @ContentChild(NgxContextMenuComponent, { static: false })
    public contextMenuTemplate: Nullable<NgxContextMenuComponent<T>> = null;

    @ContentChild(NgxEmptyComponent, { read: ElementRef }) public ngxEmptyContent: Nullable<ElementRef> = null;

    @ContentChild(NgxHeaderComponent, { static: false }) public headerTemplate: Nullable<NgxHeaderComponent> = null;

    @ContentChild(NgxFooterComponent, { static: false }) public footerTemplate: Nullable<NgxFooterComponent> = null;

    @ContentChild(NgxFilterComponent, { static: false })
    public filterTemplate: Nullable<NgxFilterComponent<T>> = null;

    @ViewChild('tableViewport', { static: true }) public scrollContainer!: ElementRef<HTMLElement>;

    @ViewChildren('column', { read: false }) public columnList!: QueryList<ElementRef<HTMLDivElement>>;

    public scrollbarWidth: number = SCROLLBAR_SIZE;
    public columnListWidth: number = 0;
    public viewPortInfo: ViewPortInfo = {};
    public tableViewportChecked: boolean = true;
    /**
     * @description: the custom names of the column list to be displayed in the view.
     * @example:
     *    <table-builder #table
     *        [source]="[{ id: 1, name: 'hello', value: 'world', description: 'text' }, ...]"
     *        [exclude]="[ 'description' ]">
     *      <ngx-column *ngFor="let key of table.modelColumnKeys"></ngx-column>
     *    </table-builder>
     *    ------------------------
     *    modelColumnKeys === [ 'id', 'hello', 'value' ]
     */
    public modelColumnKeys: string[] = [];
    /**
     * @description: the custom names of the column list to be displayed in the view.
     * @example:
     *    <table-builder [keys]=[ 'id', 'description', 'name', 'description' ] />
     *    customModelColumnsKeys === [ 'id', 'description', 'name', 'description' ]
     *    ------------------------
     *    <table-builder [keys]=[ 'id', 'description', 'name', 'description' ] [exclude]=[ 'id', 'description' ] />
     *    customModelColumnsKeys === [ 'name' ]
     */
    public customModelColumnsKeys: string[] = [];
    public isDragging: PlainObjectOf<boolean> = {};
    public accessDragging: boolean = false;
    public filteringRun: boolean = false;

    /**
     * @description - <table-builder [keys]=[ 'id', 'value', 'id', 'position', 'value' ] />
     * returned unique displayed columns [ 'id', 'value', 'position' ]
     */
    public get displayedColumns(): string[] {
        return Object.keys(this.templateParser.compiledTemplates);
    }

    public get visibleColumns(): string[] {
        return this.columnSchema
            .filter((column: ColumnsSchema): boolean => column.isVisible)
            .map((column: ColumnsSchema): string => column.key!);
    }

    /**
     * @description - <table-builder [keys]=[ 'id', 'value', 'id', 'position', 'value' ] />
     * returned ordered displayed columns [ 'id', 'value', 'id', 'position', 'value' ]
     */
    public get positionColumns(): string[] {
        return this.columnSchema.map((column: ColumnsSchema): string => column.key!);
    }

    public get columnSchema(): ColumnsSchema[] {
        return this.templateParser.schema?.columns ?? [];
    }

    /**
     * todo caretaker note
     * @deprecated - remove in v13
     * remove and replace with AbstractTableBuilderApiDirective#getSelectedItems
     * while table refactoring
     */
    public get selectedItems(): T[] {
        return this.getSelectedItems();
    }

    public get selectionModel(): SelectionMap<T> {
        return this.selection.selectionModel;
    }

    public get clientRowHeight(): number {
        return this._rowHeight ?? ROW_HEIGHT;
    }

    public get columnVirtualHeight(): number {
        return this.sourceRef.length * this.clientRowHeight;
    }

    public get columnHeight(): number {
        return this.size * this.clientRowHeight + this.headLineHeight;
    }

    public get headLineHeight(): number {
        return isNotNil(this._headHeight) ? this._headHeight : this.clientRowHeight;
    }

    public get size(): number {
        return this.sourceRef.length;
    }

    /**
     * @description Returns a list of displayed table entries, including filters and sorting.
     */
    public get sourceRef(): T[] {
        return this.source ?? [];
    }

    public get firstItem(): T {
        // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
        return this.sourceRef[0] || ({} as T);
    }

    public get lastItem(): T {
        // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
        return this.sourceRef[this.sourceRef.length - 1] || ({} as T);
    }

    /**
     * @description Returns the entire list of table entries, excluding filters and sorting.
     */
    public get originalSourceRef(): T[] {
        return this.originalSource ?? this.source ?? [];
    }

    public get isEnableFiltering(): boolean {
        return this.enableFiltering !== false;
    }

    public get isSkippedInternalSort(): boolean {
        return this.skipSort !== false;
    }

    public get isEnableAutoWidthColumn(): boolean {
        return this.autoWidth !== false;
    }

    public get isEnableSelection(): boolean {
        return this.enableSelection !== false;
    }

    @Input('head-height')
    public set headHeight(value: string | number) {
        this._headHeight = parseInt(value as string);
    }

    @Input('row-height')
    public set rowHeight(value: Nullable<string | number>) {
        this._rowHeight = parseInt((value ?? ROW_HEIGHT) as string);
    }

    private get shouldBeFiltered(): boolean {
        return this.filterable.filterValueExist && this.isEnableFiltering;
    }

    private get shouldBeSorted(): boolean {
        return this.sortable.notEmpty;
    }

    private get expanded(): Nullable<boolean> {
        return coerceBoolean(this.headerTemplate?.expandablePanel) && isNotNil(this.headerTemplate?.expanded)
            ? coerceBoolean(this.headerTemplate?.expanded)
            : null;
    }

    public abstract setSource(source: Nullable<T[]>): void;
    public abstract markDirtyCheck(): void;
    public abstract markForCheck(): void;
    public abstract markTemplateContentCheck(): void;
    public abstract ngOnChanges(changes: SimpleChanges): void;
    public abstract ngOnInit(): void;
    public abstract ngAfterContentInit(): void;
    public abstract ngAfterViewInit(): void;
    public abstract ngAfterViewChecked(): void;
    public abstract ngOnDestroy(): void;
    public abstract calculateViewport(force: boolean): void;
    protected abstract updateViewportInfo(start: number, end: number): void;

    public recheckViewportChecked(): void {
        this.tableViewportChecked = !this.tableViewportChecked;
        this.idleDetectChanges();
    }

    public enableDragging(key: Nullable<string>): void {
        if (isNotNil(key) && isTrue(this.templateParser.compiledTemplates[key]?.draggable)) {
            this.accessDragging = true;
            detectChanges(this.cd);
        }
    }

    public disableDragging(): void {
        if (this.accessDragging) {
            this.accessDragging = false;
            detectChanges(this.cd);
        }
    }

    public resizeColumn({ event, key }: ResizeEvent, column: HTMLDivElement): void {
        this.recheckViewportChecked();
        this.disableDragging();

        this.resize.resize(
            event as MouseEvent,
            column,
            (width: number): void => this.calculateWidth(key, width),
            (): void => this.afterCalculateWidth()
        );

        event.preventDefault();
    }

    public filter(after?: Fn): void {
        this.ngZone.runOutsideAngular((): void => {
            window.clearInterval(this.filterIdTask ?? 0);
            this.filteringRun = true;
            detectChanges(this.cd);
            // eslint-disable-next-line no-restricted-properties
            this.filterIdTask = window.setTimeout((): void => {
                this.sortAndFilter().then((): void => {
                    this.reCheckDefinitions();
                    after?.();

                    this.filteringRun = false;
                    detectChanges(this.cd);
                });
            }, FILTER_DELAY_TIME);
        });
    }

    public async sortAndFilter(): Promise<void> {
        await this.sortAndFilterOriginalSource();
        this.selection.rows = this.source;

        // TODO: need research this code, because we have problem with recursive update,
        //  when page have more than one tables
        this.ngZone.runOutsideAngular((): void => {
            window.clearTimeout(this.onChangesId);
            // eslint-disable-next-line no-restricted-properties
            this.onChangesId = window.setTimeout((): void => {
                this.onChanges.emit(this.sourceRef);
            });
        });
    }

    public sortByKey(key: string): void {
        this.sortable.updateSortKey(key);
        this.sortAndFilter().then((): void => this.reCheckDefinitions());
    }

    public drop({ previousIndex, currentIndex }: CdkDragSortEvent): void {
        const previousKey: Nullable<string> = this.visibleColumns[previousIndex];
        const currentKey: Nullable<string> = this.visibleColumns[currentIndex];

        this.isDragMoving = false;
        this.draggable.drop(previousKey, currentKey);
        this.changeSchema();
    }

    public changeSchema(defaultColumns: Nullable<DeepPartial<ColumnsSchema>[]> = null): void {
        const renderedColumns: Nullable<DeepPartial<ColumnsSchema>[]> = this.templateParser.schema?.exportColumns();
        const columns: Nullable<DeepPartial<ColumnsSchema>[]> = defaultColumns ?? renderedColumns;
        const generalTableSettings: GeneralTableSettings = {
            expanded: this.expanded
        };

        if (isNotNil(columns)) {
            const updateSchema: TableUpdateSchema = {
                columns,
                generalTableSettings,
                name: this.name,
                version: this.schemaVersion
            };

            this.viewChanges.update(updateSchema);
            this.schemaChanges.emit(updateSchema);
            this.idleDetectChanges();
        }
    }

    public idleDetectChanges(): void {
        this.ngZone.runOutsideAngular((): void => {
            window.cancelAnimationFrame(this.idleDetectChangesId ?? 0);
            this.idleDetectChangesId = window.requestAnimationFrame((): void => detectChanges(this.cd));
        });
    }

    public getSelectedItems(): T[] {
        return this.sourceRef.filter((item: T): boolean =>
            isNotNil(this.selectionModel.entries[(item as any)[this.primaryKey]])
        );
    }

    protected reCheckDefinitions(): void {
        this.filterable.definition = { ...this.filterable.definition };
        this.filterable.changeFilteringStatus();
        this.calculateViewport(true);
        // eslint-disable-next-line no-restricted-properties
        window.setTimeout((): void => this.app.tick(), TIME_RELOAD);
    }

    protected forceCalculateViewport(): void {
        this.updateViewportInfo(this.viewPortInfo.startIndex ?? 0, this.viewPortInfo.endIndex ?? 0);
    }

    /**
     * @description: returns the number of keys in the model
     * @example: <table-builder [source]=[{ id: 1, name: 'hello' }, ...] /> a value of 2 will be returned
     */
    protected getKeys(): string[] {
        return Object.keys(this.firstItem);
    }

    /**
     * @see AbstractTableBuilderApiDirective#customModelColumnsKeys for further information
     */
    protected generateCustomModelColumnsKeys(): string[] {
        return this.excluding(this.keys);
    }

    /**
     * @see AbstractTableBuilderApiDirective#modelColumnKeys for further information
     */
    protected generateModelColumnKeys(): string[] {
        return this.excluding(this.getModelKeys());
    }

    protected getModelKeys(): string[] {
        return pathsOfObject(this.firstItem);
    }

    protected calculateColumnWidthSummary(): void {
        if (this.isDragMoving) {
            return;
        }

        this.ngZone.runOutsideAngular((): void => {
            clearInterval(this.columnFrameId ?? 0);
            // eslint-disable-next-line no-restricted-properties
            this.columnFrameId = window.setTimeout((): void => {
                let width: number = 0;

                for (const element of this.columnList) {
                    width += element.nativeElement.offsetWidth;
                }

                this.columnListWidth = width;
                this.idleDetectChanges();
            }, TIME_IDLE);
        });
    }

    private async sortAndFilterOriginalSource(): Promise<void> {
        this.source = this.originalSource ?? [];

        if (this.shouldBeFiltered) {
            const filter: FilterWorkerEvent<T> = await this.filterable.filter(this.source);

            this.source = filter.source;
            filter.fireSelection();
        }

        if (this.shouldBeSorted) {
            this.source = await this.sortable.sort(this.source);
        }
    }

    private calculateWidth(key: string, width: number): void {
        this.isDragging[key] = true;
        this.calculateColumnWidthSummary();
        this.onMouseResizeColumn(key, width);
    }

    private afterCalculateWidth(): void {
        this.isDragging = {};
        this.recheckViewportChecked();
        this.changeSchema();
    }

    private onMouseResizeColumn(key: string, width: number): void {
        this.templateParser.mutateColumnSchema(key, { width });
        this.idleDetectChanges();
    }

    private excluding(keys: string[]): string[] {
        return this.excludeKeys.length > 0
            ? keys.filter(
                  (key: string): boolean =>
                      !this.excludeKeys.some((excludeKey: ExcludePattern<T>): boolean =>
                          excludeKey instanceof RegExp ? Boolean(key.match(excludeKey)) : key === excludeKey
                      )
              )
            : keys;
    }
}