UiPath/angular-components

View on GitHub
projects/angular/components/ui-grid/src/ui-grid.component.ts

Summary

Maintainability
A
3 hrs
Test Coverage
A
96%
import isArray from 'lodash-es/isArray';
import range from 'lodash-es/range';
import {
    animationFrameScheduler,
    BehaviorSubject,
    combineLatest,
    defer,
    fromEvent,
    iif,
    merge,
    Observable,
    of,
    ReplaySubject,
    Subject,
    Subscription,
} from 'rxjs';
import {
    debounceTime,
    distinctUntilChanged,
    filter,
    map,
    observeOn,
    share,
    shareReplay,
    startWith,
    switchMap,
    take,
    takeUntil,
    tap,
    throttleTime,
} from 'rxjs/operators';

import {
    animate,
    style,
    transition,
    trigger,
} from '@angular/animations';
import { FocusOrigin } from '@angular/cdk/a11y';
import {
    AfterContentInit,
    ChangeDetectionStrategy,
    ChangeDetectorRef,
    Component,
    ContentChild,
    ContentChildren,
    ElementRef,
    EventEmitter,
    HostBinding,
    HostListener,
    Inject,
    InjectionToken,
    Input,
    NgZone,
    OnChanges,
    OnDestroy,
    OnInit,
    Optional,
    Output,
    QueryList,
    SimpleChanges,
    ViewChild,
    ViewEncapsulation,
} from '@angular/core';
import {
    MatCheckbox,
    MatCheckboxChange,
} from '@angular/material/checkbox';
import { MatTooltip } from '@angular/material/tooltip';
import { QueuedAnnouncer } from '@uipath/angular/a11y';
import { ISuggestValue } from '@uipath/angular/components/ui-suggest';

import { UiGridColumnDirective } from './body/ui-grid-column.directive';
import { UiGridExpandedRowDirective } from './body/ui-grid-expanded-row.directive';
import { UiGridLoadingDirective } from './body/ui-grid-loading.directive';
import { UiGridNoContentDirective } from './body/ui-grid-no-content.directive';
import { UiGridRowActionDirective } from './body/ui-grid-row-action.directive';
import { UiGridRowCardViewDirective } from './body/ui-grid-row-card-view.directive';
import { UiGridRowConfigDirective } from './body/ui-grid-row-config.directive';
import { UiGridCustomSearchDirective } from './components/ui-grid-search/ui-grid-custom-search.directive';
import { UiGridSearchFilterDirective } from './filters/ui-grid-search-filter.directive';
import { UiGridFooterDirective } from './footer/ui-grid-footer.directive';
import { UiGridHeaderDirective } from './header/ui-grid-header.directive';
import {
    DataManager,
    FilterManager,
    LiveAnnouncerManager,
    PerformanceMonitor,
    ResizeManager,
    ResizeManagerFactory,
    ResizeStrategy,
    SelectionManager,
    SortManager,
    UI_GRID_RESIZE_STRATEGY_STREAM,
    VisibilityManger,
} from './managers';
import { ScrollableGridResizer } from './managers/resize/strategies/scrollable-grid-resizer';
import {
    ResizableGrid,
    ResizeEmission,
} from './managers/resize/types';
import {
    GridOptions,
    IFilterModel,
    IGridDataEntry,
    ISortModel,
} from './models';
import { UiGridIntl } from './ui-grid.intl';

export const UI_GRID_OPTIONS = new InjectionToken<GridOptions<unknown>>('UiGrid DataManager options.');
const FOCUSABLE_ELEMENTS_QUERY = 'a, button:not([hidden]), input:not([hidden]), textarea, select, details, [tabindex]:not([tabindex="-1"])';
const EXCLUDED_ROW_SELECTION_ELEMENTS = ['a', 'button', 'input', 'textarea', 'select'];
const REFRESH_WIDTH = 50;
const DEFAULT_VIRTUAL_SCROLL_ITEM_SIZE = 48;
const DEFAULT_VIRTUAL_SCROLL_HIGH_DENSITY_ITEM_SIZE = 32;
const SCROLL_LIMIT_FOR_DISPLAYING_SHADOW = 10;

@Component({
    selector: 'ui-grid',
    templateUrl: './ui-grid.component.html',
    styleUrls: [
        './ui-grid.component.scss',
    ],
    animations: [
        trigger('filters-container', [
            transition(':enter', [
                style({
                    minHeight: '0',
                    height: '0',
                    opacity: '0',
                }),
                animate('0.15s ease-in', style({
                    opacity: '*',
                    minHeight: '*',
                    height: '*',
                    display: '*',
                })),
            ]),
            transition(':leave', [
                style({
                    minHeight: '*',
                    height: '*',
                }),
                animate('0.15s ease-in', style({
                    opacity: '0',
                    minHeight: '0',
                    height: '0',
                })),
            ]),
        ]),
    ],
    providers: [
        {
            provide: UI_GRID_RESIZE_STRATEGY_STREAM,
            useFactory: () => new BehaviorSubject(ResizeStrategy.ImmediateNeighbourHalt),
        },
    ],
    changeDetection: ChangeDetectionStrategy.OnPush,
    encapsulation: ViewEncapsulation.None,
})
export class UiGridComponent<T extends IGridDataEntry>
    extends ResizableGrid<T>
    implements AfterContentInit, OnChanges, OnDestroy, OnInit {
    /**
     * The data list that needs to be rendered within the grid.
     *
     * NOTE: to have access to all functionality, we recommend that entities display in the grid implement the IGridDataEntry interface.
     *
     * @param value The list that needs to rendered.
     */
    @Input()
    set data(value: T[] | null) {
        this._performanceMonitor.reset();
        this.dataManager.update(value);
    }

    /**
     * Marks the grid resizing state.
     *
     */
    @HostBinding('class.ui-grid-state-resizing')
    @Input()
    get isResizing() {
        return this.resizeManager.isResizing;
    }

    /**
     * Marks the grid projected state.
     *
     */
    @HostBinding('class.ui-grid-state-projected')
    @Input()
    isProjected: boolean;

    /**
     * Set the grid in high density state.
     *
     */
    @HostBinding('class.ui-grid-state-high-density')
    @Input()
    hasHighDensity = false;

    /**
     * Determines if all of the items are currently checked.
     *
     */
    get isEveryVisibleRowChecked() {
        return !!this.dataManager.length &&
            this.dataManager.every(row => this.selectionManager.isSelected(row!));
    }

    /**
     * Determines if there's a value selected within the currently rendered items (used for multi-page selection).
     *
     */
    get hasValueOnVisiblePage() {
        return this.dataManager.some(row => this.selectionManager.isSelected(row!));
    }

    /**
     * The desired resize strategy.
     *
     * FIXME: Currently only `ImmediateNeighbourHalt` is stable.
     *
     */
    @Input()
    set resizeStrategy(value: ResizeStrategy | null) {
        if (value === this._resizeStrategy) { return; }

        if (value != null) {
            this._resizeStrategy = value;

            this._resizeStrategyStream$.next(value);
            if (this._resizeStrategy != null) {
                this.resizeManager.destroy();
            }
            this._initResizeManager();
        }
    }
    get resizeStrategy() {
        return this._resizeStrategy;
    }

    /**
     * Marks the grid loading state.
     *
     */
    @HostBinding('class.ui-grid-state-loading')
    @Input()
    loading = false;

    /**
     * Marks the grid enabled state.
     *
     */
    @HostBinding('class.ui-grid-state-disabled')
    @Input()
    disabled = false;

    /**
     * Configure if the grid search filters are eager or on open.
     *
     */
    @Input()
    set collapseFiltersCount(count: number) {
        if (count === this._collapseFiltersCount$.value) { return; }
        this._collapseFiltersCount$.next(count);
    }
    get collapseFiltersCount() {
        return this._collapseFiltersCount$.value;
    }

    /**
     * Configure if the grid search filters are eager or on open.
     *
     */
    @Input()
    set fetchStrategy(fetchStrategy: 'eager' | 'onOpen') {
        if (fetchStrategy === this.fetchStrategy) { return; }
        this._fetchStrategy = fetchStrategy;
    }
    get fetchStrategy() {
        return this._fetchStrategy;
    }

    /**
     * Configure if the grid allows item selection.
     *
     */
    @Input()
    selectable = true;

    /**
     * Configure if the grid allows radio button selection for its items.
     *
     */
    @Input()
    singleSelectable = false;

    /**
     * Configure if the grid selects entity on row click.
     *
     */
    @Input()
    shouldSelectOnRowClick = false;

    /**
     * Option to have collapsible filters.
     *
     * @deprecated - use `[collapseFiltersCount]="0" to render collapsed or leave out to always render inline`
     */
    @Input()
    set collapsibleFilters(collapse: boolean) {
        this._collapseFiltersCount$.next(collapse ? 0 : Number.POSITIVE_INFINITY);
    }
    get collapsibleFilters() {
        return !this._collapseFiltersCount$.value;
    }

    /**
     * Configure if the grid allows to toggle column visibility.
     *
     */
    @Input()
    toggleColumns = false;

    /**
     * Configure if the grid allows multi-page selection.
     *
     */
    @HostBinding('class.ui-grid-mode-multi-select')
    @Input()
    multiPageSelect = false;

    /**
     * Configure if the grid is refreshable.
     *
     */
    @Input()
    refreshable = true;

    /**
     * Configure if `virtualScroll` is enabled. Incompatible with scrollable resize strategy.
     *
     */
    @Input()
    set virtualScroll(value: boolean) {
        this._virtualScroll = value;
    }
    get virtualScroll() {
        return this._virtualScroll;
    }

    /**
     * Configure the row item size for virtualScroll
     *
     */
    @Input()
    rowSize: number;

    /**
     * Show paint time stats
     *
     */
    @Input()
    showPaintTime = false;

    /**
     * Provide a custom `noDataMessage`.
     *
     */
    @Input()
    noDataMessage?: string;

    /**
     * Set the expanded entry.
     *
     * @deprecated Use `expandedEntries` instead.
     */
    @Input()
    set expandedEntry(entry: T | undefined) {
        this.expandedEntries = entry;
    }
    get expandedEntry() {
        return this._expandedEntries[0];
    }

    /**
     * Set the expanded entry / entries.
     *
     */
    @Input()
    set expandedEntries(entry: T | T[] | undefined) {
        if (!entry) {
            this._expandedEntries = [];
            return;
        }
        this._expandedEntries = Array.isArray(entry) ? entry : [entry];
    }
    get expandedEntries() {
        return this._expandedEntries;
    }

    /**
     * Configure if the expanded entry should replace the active row, or add a new row with the expanded view.
     *
     */
    @Input()
    expandMode: 'preserve' | 'collapse' = 'collapse';

    /**
     * Configure if ui-grid-header-row should be visible, by default it is visible
     *
     */
    @Input()
    showHeaderRow = true;

    /**
     * Configure a function that receives the whole grid row, and returns
     * disabled message if the row should not be selectable
     *
     */
    @Input()
    disableSelectionByEntry: (entry: T) => null | string;

    @Input()
    set customFilterValue(customValue: IFilterModel<T>[]) {
        if (!Array.isArray(customValue) || !customValue.length) { return; }
        this.filterManager.updateCustomFilters(customValue);
    }

    /**
     * Configure if Card view should be used
     *
     */
    @Input()
    useCardView = false;

    /**
     * If the grid allows highlighting of a row
     *
     */
    @Input()
    allowHighlight = false;

    /**
     * Id of the entity that should be highlighted
     *
     */
    @Input()
    set highlightedEntityId(value: string | null) {
        if (value != null) {
            this.highlightedEntityId$.next(value);
        }
    }

    /**
     * Maximum number of active filter values before the filter selection is disabled
     *
     */
    @Input()
    set maxSelectedFilterValues(value: number) {
        this.maxSelectedFilterValues$.next(value);
    }
    /**
     * Configure if the pagination should be selectable
     *
     */
    @Input()
    selectablePageIndex: boolean;

    /**
     * Configure if the filter containers should be swapped
     *
     */
    @Input()
    swapFilterContainers = false;

    /**
     * Emits an event with the sort model when a column sort changes.
     *
     */
    @Output()
    sortChange = new EventEmitter<ISortModel<T>>();

    /**
     * Emits an event when user click the refresh button.
     *
     */
    @Output()
    refresh = new EventEmitter<void>();

    /**
     * Emits an event once the grid has been rendered.
     *
     */
    @Output()
    rendered = new EventEmitter<void>();

    /**
     * Emits an event once the grid has been rendered.
     *
     */
    @Output()
    resizeEnd = new EventEmitter<void>();

    @Output()
    removeCustomFilter = new EventEmitter<void>();

    /**
     * Emits an event when a row is clicked.
     *
     */
    @Output()
    rowClick = new EventEmitter<{ event: Event; row: T }>();

    /**
     * Emits the resize initial & final percentage widths of the resized columns
     *
     */
    @Output()
    resizeEmissions = new EventEmitter<ResizeEmission>();

    /**
     * Emits the column definitions when their definition changes.
     *
     */
    columns$ = new BehaviorSubject<UiGridColumnDirective<T>[]>([]);

    /**
     * Row configuration directive reference.
     *
     * @ignore
     */
    @ContentChild(UiGridRowConfigDirective, {
        static: true,
    })
    rowConfig?: UiGridRowConfigDirective<T>;

    /**
     * Row action directive reference.
     *
     * @ignore
     */
    @ContentChild(UiGridRowActionDirective, {
        static: true,
    })
    actions?: UiGridRowActionDirective;

    /**
     * Footer directive reference.
     *
     * @ignore
     */
    @ContentChild(UiGridFooterDirective, {
        static: true,
    })
    footer?: UiGridFooterDirective;

    /**
     * Header directive reference.
     *
     * @ignore
     */
    @ContentChild(UiGridHeaderDirective, {
        static: true,
    })
    header?: UiGridHeaderDirective<T>;

    /**
     * Custom search directive reference.
     *
     * @ignore
     */
    @ContentChild(UiGridCustomSearchDirective, {
        static: true,
    })
    search?: UiGridCustomSearchDirective;

    /**
     * Column directive reference list.
     *
     * @ignore
     */
    @ContentChildren(UiGridColumnDirective)
    get columns() {
        return this._columns;
    }
    set columns(value: QueryList<UiGridColumnDirective<T>>) {
        this._columns = value;

        if (this.isScrollable) {
            const stickyColumns = value.filter(c => c.isSticky);
            const freeColumns = value.filter(c => !c.isSticky);
            this._columns.reset([...stickyColumns, ...freeColumns]);
        }
    }

    /**
     * Expanded row template reference.
     *
     * @ignore
     */
    @ContentChild(UiGridExpandedRowDirective, {
        static: true,
    })
    expandedRow?: UiGridExpandedRowDirective;

    /**
     * No content custom template reference.
     *
     * @ignore
     */
    @ContentChild(UiGridNoContentDirective, {
        static: true,
    })
    noContent?: UiGridNoContentDirective;

    /**
     * Custom loading template reference.
     *
     * @ignore
     */
    @ContentChild(UiGridLoadingDirective, {
        static: true,
    })
    loadingState?: UiGridLoadingDirective;

    /**
     * Custom card view template reference.
     *
     * @ignore
     */
    @ContentChild(UiGridRowCardViewDirective, {
        static: true,
    })
    cardTemplate?: UiGridRowCardViewDirective<T>;
    /**
     * Reference to the grid action buttons container
     *
     * @ignore
     */
    @ViewChild('gridActionButtons')
    gridActionButtons!: ElementRef;

    /**
     * Reference to select all available rows checkbox
     *
     * @ignore
     */
    @ViewChild('selectAvailableRowsCheckbox')
    selectAvailableRowsCheckbox?: MatCheckbox;

    /**
     * Toggle filters row display state
     *
     */
    showFilters = false;

    /**
     * Live announcer manager, used to emit notification via `aria-live`.
     *
     */
    liveAnnouncerManager?: LiveAnnouncerManager<T>;

    /**
     * Selection manager, used to manage grid selection states.
     *
     */
    selectionManager = new SelectionManager<T>();

    /**
     * Data manager, used to optimize row rendering.
     *
     */
    dataManager = new DataManager<T>(this._gridOptions);

    /**
     * Filter manager, used to manage filter state changes.
     *
     */
    filterManager = new FilterManager<T>();

    /**
     * Visibility manager, used to manage visibility of columns.
     *
     */
    visibilityManager = new VisibilityManger<T>();

    /**
     * Sort manager, used to manage sort state changes.
     *
     */
    sortManager = new SortManager<T>();

    /**
     * Resize manager, used to compute resized column states.
     *
     */
    resizeManager!: ResizeManager<T>;

    /**
     * @ignore
     */
    paintTime$: Observable<string>;

    /**
     * Emits with information whether filters are defined.
     *
     */
    isAnyFilterDefined$ = new BehaviorSubject<boolean>(false);

    /**
     * Emits with information whether any filter is visible.
     *
     */
    hasAnyFiltersVisible$: Observable<boolean>;

    /**
     * Emits with information whether the dvider for toggle columns should be displayed
     *
     */
    displayToggleColumnsDivider$?: Observable<boolean>;

    /**
     * Emits the visible column definitions when their definition changes.
     *
     */
    visible$ = this.visibilityManager.columns$;

    /**
     * Emits when the visible columns menu has been opened or closed
     *
     */
    visibleColumnsToggle$ = new BehaviorSubject<boolean>(false);

    /**
     * Returns the scroll size, in order to compensate for the scrollbar.
     *
     * @deprecated
     */
    scrollCompensationWidth = 0;

    /**
     * Whether column header is focused.
     *
     */
    focusedColumnHeader = false;

    /**
     * Whether the grid allows horizontal scroll or not.
     *
     */
    get isScrollable() {
        return this.resizeStrategy === ResizeStrategy.ScrollableGrid && !this.virtualScroll;
    }

    /**
     * The width of selectable column.
     *
     */
    selectionColumnWidth = 50;

    /**
     * Visibile columns emissions partitioned in sticky and free columns.
     *
     */
    partitionedVisibleColumns$ = this.visible$.pipe(
        map(columns => {
            const free = columns.filter(c => !c.isSticky || !this.isScrollable);
            const sticky = columns.filter(c => c.isSticky && this.isScrollable);
            return ({
                stickyColumns: free.length ? sticky : [],
                freeColumns: free.length ? free : sticky,
            });
        }),
    );

    /**
     * Emits current max selected filter values count
     *
     */
    maxSelectedFilterValues$ = new BehaviorSubject(Infinity);

    /**
     * Emits the id of the entity that should be highlighted.
     *
     */
    highlightedEntityId$ = new ReplaySubject<string | number>(1);

    /**
     * @internal
     * @ignore
     */
    scrollCompensationWidth$ = this.dataManager.data$.pipe(
        map(data => data.length),
        distinctUntilChanged(),
        observeOn(animationFrameScheduler),
        debounceTime(0),
        map(() => this._ref.nativeElement.querySelector('.ui-grid-viewport')),
        map(view => view ? view.offsetWidth - view.clientWidth : 0),
        // eslint-disable-next-line import/no-deprecated
        tap(compensationWidth => this.scrollCompensationWidth = compensationWidth),
    );

    hasSelection$ = this.selectionManager.hasValue$.pipe(
        tap(hasSelection => {
            if (hasSelection && !!this.header?.actionButtons?.length) {
                this._announceGridHeaderActions();
            }
        }),
        share(),
    );

    renderedColumns$ = this.visible$.pipe(
        map(columns => {
            const firstIndex = columns.findIndex(c => c.primary);
            const rowHeaderIndex = firstIndex > -1 ? firstIndex : 0;

            const mappedColumns = columns.map((directive, index) => ({
                directive,
                role: index === rowHeaderIndex ? 'rowheader' : 'gridcell',
            }));

            const free = mappedColumns.filter(c => !c.directive.isSticky || !this.isScrollable);
            const sticky = mappedColumns.filter(c => c.directive.isSticky && this.isScrollable);
            return ({
                stickyColumns: free.length ? sticky : [],
                freeColumns: free.length ? free : sticky,
            });
        }),
    );

    stickyColumnsSum$ = this.visible$.pipe(
        switchMap(columns => combineLatest(columns.filter(c => c.isSticky).map(c => c.widthPx$)).pipe(
            map(widths => widths.reduce((acc, curr) => acc + curr, 0)),
        )),
        shareReplay(1),
    );

    shouldDisplayContainerShadow$ = defer(() => merge(
        fromEvent(this._ref.nativeElement.querySelector('.ui-grid-table-container')!, 'scroll').pipe(
            throttleTime(50, undefined, { trailing: true }),
            map((event: any) => {
                const { scrollWidth, scrollLeft, clientWidth } = event.target;
                return Math.abs(scrollWidth - clientWidth - scrollLeft) >= SCROLL_LIMIT_FOR_DISPLAYING_SHADOW;
            }),
        ),
        this.isOverflown$,
    )).pipe(
        distinctUntilChanged(),
        shareReplay(),
    );

    areFilersCollapsed$: Observable<boolean>;

    /**
     * Determines if the multi-page selection row should be displayed.
     *
     */
    get showMultiPageSelectionInfo() {
        return this.multiPageSelect &&
            !this.dataManager.pristine &&
            (
                this.dataManager.length ||
                this.selectionManager.selected.length
            );
    }

    deficit$ = new BehaviorSubject(0);
    containerWidth = 0;

    minWidth$ = defer(() => merge(
        this.visible$,
        this.resizeManager.widthChange$,
        this.resizeManager.resize$.pipe(
            tap(() => queueMicrotask(() => this._cd.detectChanges())),
        ),
    ).pipe(
        map(() => this._computeMinWidth()),
        tap(minWidth => {
            this.containerWidth = this._ref.nativeElement.getBoundingClientRect().width;
            this.deficit$.next(Math.round(Math.max(0, this.containerWidth - minWidth)));
        }),
        tap(() => { this._cd.detectChanges(); }),
    )).pipe(
        shareReplay(1),
    );

    isOverflown$ = iif(() => this.isScrollable, this.minWidth$.pipe(
        map(minWidth => this._isOverflown(minWidth)),
        distinctUntilChanged(),
        shareReplay(1),
    ), of(false));

    tableOverflowStyle$ = this.isOverflown$.pipe(
        map(value => value ? 'visible' : 'hidden'),
    );

    disableFilterSelection$ = defer(() => this.filterManager.activeFilterValueCount$.pipe(
        switchMap(count => this.maxSelectedFilterValues$
            .pipe(
                map(max => count >= max),
        )),
        distinctUntilChanged(),
    )).pipe(shareReplay(1));

    readonly Infinity = Infinity;
    protected _destroyed$ = new Subject<void>();
    protected _columnChanges$: Observable<SimpleChanges>;

    private _fetchStrategy!: 'eager' | 'onOpen';
    private _collapseFiltersCount$!: BehaviorSubject<number>;
    private _resizeStrategy = ResizeStrategy.ImmediateNeighbourHalt;
    private _performanceMonitor: PerformanceMonitor;
    private _configure$ = new Subject<void>();
    private _isShiftPressed = false;
    private _lastCheckboxIdx = 0;
    private _resizeSubscription$: null | Subscription = null;
    private _containerWidthChangeSubscription$: null | Subscription = null;
    private _expandedEntries: T[] = [];
    private _columns!: QueryList<UiGridColumnDirective<T>>;
    private _virtualScroll = false;

    /**
     * @ignore
     */
    constructor(
        @Optional()
        public intl: UiGridIntl,
        protected _ref: ElementRef,
        protected _cd: ChangeDetectorRef,
        private _zone: NgZone,
        private _queuedAnnouncer: QueuedAnnouncer,
        @Inject(UI_GRID_RESIZE_STRATEGY_STREAM)
        private _resizeStrategyStream$: BehaviorSubject<ResizeStrategy>,
        @Inject(UI_GRID_OPTIONS)
        @Optional()
        private _gridOptions?: GridOptions<T>,
    ) {
        super();

        this.disableSelectionByEntry = () => null;
        this._fetchStrategy = _gridOptions?.fetchStrategy ?? 'onOpen';
        this.rowSize = _gridOptions?.rowSize ?? -1;
        this.hasHighDensity = this._gridOptions?.hasHighDensity ?? false;
        this._collapseFiltersCount$ = new BehaviorSubject(
            _gridOptions?.collapseFiltersCount ?? (_gridOptions?.collapsibleFilters === true ? 0 : Number.POSITIVE_INFINITY),
        );
        this.selectablePageIndex = _gridOptions?.selectablePageIndex ?? false;

        this.isProjected = this._ref.nativeElement.classList.contains('ui-grid-state-responsive');

        this.intl = intl || new UiGridIntl();

        this._columnChanges$ =
            this.rendered.pipe(
                switchMap(() => merge(
                    ...this.columns.map(column =>
                        column.change$,
                    )),
                ),
                debounceTime(10),
                tap(() => this.isResizing && this.resizeManager.stop()),
            );

        const visibleFilterCount$ = this.rendered.pipe(
            switchMap(() => this.columns.changes),
            startWith('Initial emission'),
            switchMap(() =>
                combineLatest(this.columns.map((column: UiGridColumnDirective<T>) =>
                    column.dropdown?.visible$ ?? column.searchableDropdown?.visible$ ?? of(false),
                )),
            ),
            map(areVisible => areVisible.filter(visible => visible).length),
            distinctUntilChanged(),
            shareReplay(),
        );

        this.hasAnyFiltersVisible$ = visibleFilterCount$.pipe(
            map(Boolean),
            distinctUntilChanged(),
        );

        this.areFilersCollapsed$ = combineLatest([
            visibleFilterCount$,
            this._collapseFiltersCount$,
        ]).pipe(
            map(([visible, minCollapse]) => visible > minCollapse),
            distinctUntilChanged(),
        );

        const sort$ = this.sortManager
            .sort$
            .pipe(
                tap(ev => this.sortChange.emit(ev)),
            );

        const inputChanges$ = merge(
            this.intl.changes,
            this._configure$,
            this._columnChanges$,
        ).pipe(
            map(() => this.columns.toArray()),
            tap(columns => this.filterManager.columns = columns),
            tap(columns => this.sortManager.columns = columns),
            tap(columns => this.visibilityManager.columns = columns),
            tap(columns => this.columns$.next(columns)),
            tap(columns => this.isAnyFilterDefined$.next(
                columns.some(c => !!c.dropdown || !!c.searchableDropdown),
            )),
        );

        const data$ = this.dataManager.data$.pipe(
            tap(_ => this._lastCheckboxIdx = 0),
        );

        const selection$ = this.selectionManager.changed$.pipe(
            tap(_ => this._cd.markForCheck()),
        );

        merge(
            sort$,
            inputChanges$,
            data$,
            selection$,
        ).pipe(
            takeUntil(this._destroyed$),
        ).subscribe();

        this._initResizeManager();
        this.resizeStrategy = _gridOptions?.resizeStrategy ?? ResizeStrategy.ImmediateNeighbourHalt;
        this._performanceMonitor = new PerformanceMonitor(_ref.nativeElement);
        this.paintTime$ = this._performanceMonitor.paintTime$;

        this.selectionManager.hasValue$.pipe(
            filter(hasValue => !hasValue && this.selectAvailableRowsCheckbox?.checked === true),
            takeUntil(this._destroyed$),
        ).subscribe(() => this.selectAvailableRowsCheckbox!.checked = false);

        this._initDisplayToggleColumnsDivider();
    }

    /**
     * Clear search term, filters and sorting and emits true after.
     */
    @Input()
    reset: () => Observable<boolean> = () => {
        if (this.header) {
            this.header.searchValue = '';
        }
        this.sortManager.clear();
        this.filterManager.clear();
        return of(true);
    };

    ngOnInit(): void {
        this._setInitialRowSize();
    }

    /**
     * @ignore
     */
    ngAfterContentInit() {
        this.selectionManager.disableSelectionByEntry = this.disableSelectionByEntry;

        this.liveAnnouncerManager = new LiveAnnouncerManager(
            msg => this._queuedAnnouncer.enqueue(msg),
            this.intl,
            this.dataManager.data$,
            this.sortManager.sort$.pipe(
                filter(({ userEvent }) => !!userEvent),
            ),
            this.refresh,
            this.footer?.pageChange,
        );

        this._configure$.next();

        this._zone.onStable.pipe(
            take(1),
        ).subscribe(() => {
            // ensure everything is painted once initial rendering is done
            // a lot of templates loaded lazily, this is required
            // to ensure everything is drawn once the grid is initialized
            this._cd.markForCheck();

            this.rendered.next();
        });

        this.columns.changes
            .pipe(
                takeUntil(this._destroyed$),
            ).subscribe(
                () => this._configure$.next(),
            );

        this.resizeManager.resizeEmissions$.pipe(
            takeUntil(this._destroyed$),
        ).subscribe(resizeEmissions => this.resizeEmissions.next(resizeEmissions));
    }

    /**
     * @ignore
     */
    ngOnChanges(changes: SimpleChanges) {
        const selectableChange = changes.selectable;
        if (
            selectableChange &&
            !selectableChange.firstChange &&
            selectableChange.previousValue !== selectableChange.currentValue
        ) {
            this.selectionManager.clear();
            this._configure$.next();
        }

        const dataChange = changes.data;
        if (
            dataChange &&
            !dataChange.firstChange &&
            !this.multiPageSelect
        ) {
            this._performanceMonitor.reset();
            this.selectionManager.clear();
        }
    }

    /**
     * @ignore
     */
    ngOnDestroy() {
        this.sortChange.complete();
        this.rendered.complete();
        this.columns$.complete();
        this.isAnyFilterDefined$.complete();

        this.dataManager.destroy();
        this.resizeManager.destroy();
        this.sortManager.destroy();
        this.selectionManager.destroy();
        this.filterManager.destroy();
        this.visibilityManager.destroy();

        if (this.liveAnnouncerManager) {
            this.liveAnnouncerManager.destroy();
        }

        this._performanceMonitor.destroy();

        this._destroyed$.next();
        this._destroyed$.complete();
        this._configure$.complete();
    }

    /**
     * Marks if the `Shift` key is pressed.
     */
    @HostListener('document:keydown', ['$event'])
    handleKeyDown(event: KeyboardEvent): void {
        this._isShiftPressed = event.shiftKey;
    }

    @HostListener('document:keyup', ['$event'])
    handleKeyUp(event: KeyboardEvent): void {
        this._isShiftPressed = event.shiftKey;
    }

    /**
     * Handles row selection, and reacts if the `Shift` key is pressed.
     *
     * @param idx The clicked row index.
     * @param entry The entry associated to the selected row.
     */
    handleSelection(idx: number, entry: T) {
        if (!this._isShiftPressed) {
            this._lastCheckboxIdx = idx;
            this.selectionManager.toggle(entry);
            return;
        }

        const min = Math.min(this._lastCheckboxIdx, idx);
        const max = Math.max(idx, this._lastCheckboxIdx);

        const rowsForSelection = range(min, max + 1)
            .map(this.dataManager.get);
        const rowsForDeselection = this.dataManager.data$.getValue()
            .filter(row => !rowsForSelection.find(rowForSelection => rowForSelection.id === row.id));

        /**
         * To be consistent with the browser, if we click on a row
         * that was already selected, we unselect it, sync with DOM (detectChanges),
         * then we select it again (it's included in rowsForSelection).
         */
        if (this.selectionManager.isSelected(entry)) {
            this.selectionManager.deselect(entry);
            this._cd.detectChanges();
        }

        this.selectionManager.select(...rowsForSelection.filter(row => !this.selectionManager.isSelected(row)));
        this.selectionManager.deselect(...rowsForDeselection.filter(row => this.selectionManager.isSelected(row)));

        this._cd.detectChanges();
    }

    /**
     * Toggles the row selection state.
     *
     */
    toggle(ev: MatCheckboxChange) {
        if (ev.checked) {
            this.dataManager.forEach(row => this.selectionManager.select(row!));
        } else {
            this._lastCheckboxIdx = 0;
            this.dataManager.forEach(row => this.selectionManager.deselect(row!));
        }
    }

    /**
     * Determines the `checkbox` `matToolTip`.
     *
     * @param [row] The row for which the label is computed.
     */
    checkboxTooltip(row?: T): string {
        if (!row) {
            return this.intl.checkboxTooltip(this.isEveryVisibleRowChecked);
        }
        if (this.singleSelectable && this.selectionManager.isSelected(row)) { return this.intl.radioButtonSelectedRowMessage; }

        return this.intl.checkboxTooltip(this.selectionManager.isSelected(row), this.dataManager.indexOf(row));
    }

    /**
     * Determines the `checkbox` aria-label`.
     * **DEPRECATED**
     *
     * @param [row] The row for which the label is computed.
     */
    checkboxLabel(row?: T): string {
        if (!row) {
            return `${this.isEveryVisibleRowChecked ? 'select' : 'deselect'} all`;
        }
        return `${this.selectionManager.isSelected(row) ? 'deselect' : 'select'} row ${this.dataManager.indexOf(row)}`;
    }

    focusRowHeader() {
        this.gridActionButtons?.nativeElement.querySelector(FOCUSABLE_ELEMENTS_QUERY)?.focus();
    }

    clearCustomFilter() {
        this.removeCustomFilter.emit();
        this.filterManager.clearCustomFilters();
    }

    isRowExpanded(rowId?: IGridDataEntry['id']) {
        if (rowId == null) {
            return false;
        }

        return this._expandedEntries.some(el => el.id === rowId);
    }

    onRowClick(event: Event, row: T) {
        if (this._isNonInteractiveElementClick(event)) {
            this.highlightedEntityId$.next(row.id);
            this._selectRowOnClick(row);
        }

        this.rowClick.emit({
            event,
            row,
        });
    }

    checkIndeterminateState(indeterminateState: boolean) {
        // If the grid has disabled rows the indeterminate can be set to false and still not have all the rows selected,
        // in that case we set the indeterminate to true
        if (
            !indeterminateState &&
            this.selectAvailableRowsCheckbox &&
            this.hasValueOnVisiblePage &&
            !this.isEveryVisibleRowChecked
        ) {
            this.selectAvailableRowsCheckbox.indeterminate = true;
        }
    }

    searchableDropdownValue(searchableDropdown: UiGridSearchFilterDirective<T>): ISuggestValue[] {
        if (searchableDropdown.value) {
            if (searchableDropdown.multiple) {
                return searchableDropdown.value as ISuggestValue[];
            }
            return [searchableDropdown.value as ISuggestValue];
        }
        return [];
    }

    getColumnName(column: UiGridColumnDirective<T>, prefix = 'ui-grid-dropdown-filter') {
        return prefix + '-' + ((column.property as string) ?? 'na');
    }

    isFilterApplied(column: UiGridColumnDirective<T>) {
        const searchableHasValue = column.searchableDropdown?.value != null &&
            (!column.searchableDropdown.multiple || (column.searchableDropdown.value as []).length > 0);

        const dropdownHasValue = column.dropdown?.value != null && column.dropdown.hasValue &&
            (isArray(column.dropdown.value) || column.dropdown!.value.value !== column.dropdown.emptyStateValue);

        return dropdownHasValue || searchableHasValue;
    }

    triggerColumnHeaderTooltip(event: FocusOrigin, tooltip: MatTooltip) {
        if (event === 'keyboard') {
            this.focusedColumnHeader = true;
            tooltip.show();
        }
    }

    hideColumnHeaderTooltip(tooltip: MatTooltip) {
        tooltip.hide();
        this.focusedColumnHeader = false;
    }

    rowSelected(row: T) {
        this.selectionManager.clear();
        this.selectionManager.select(row);
    }

    private _announceGridHeaderActions() {
        this._queuedAnnouncer.enqueue(this.intl.gridHeaderActionsNotice);
    }

    private _initResizeManager() {
        this._resizeSubscription$?.unsubscribe();
        this._containerWidthChangeSubscription$?.unsubscribe();
        this.resizeManager = ResizeManagerFactory(this._resizeStrategy, this);
        this._resizeSubscription$ = this.resizeManager.resizeEnd$.subscribe((resizeInfo) => {
            if (resizeInfo) {
                const gridHeaderCellElement = resizeInfo.element;
                gridHeaderCellElement.focus();
            }

            this.resizeEnd.emit();
        });

        if (this.isScrollable) {
            this._containerWidthChangeSubscription$ = this.resizeManager.widthChange$.pipe(
                distinctUntilChanged(),
                tap(width => {
                    (this.resizeManager as ScrollableGridResizer<T>).limitStickyWidthCoverage(width);
                }),
                takeUntil(this._destroyed$),
            ).subscribe();
        }
    }

    private _initDisplayToggleColumnsDivider() {
        this.displayToggleColumnsDivider$ = combineLatest([this.hasAnyFiltersVisible$, this.filterManager.hasCustomFilter$]).pipe(
            map(([hasAnyFilterVisible, hasCustomFilters]) => hasAnyFilterVisible || hasCustomFilters),
        );
    }

    private _computeMinWidth(columns?: UiGridColumnDirective<T>[]) {
        const cols = (columns ?? this.columns?.toArray() ?? []).filter(c => c.visible);

        const widthsPxSum = cols.reduce((acc, column) => {
            acc += column.widthPx$.value;
            return acc;
        }, 0);

        return widthsPxSum + this._otherActionsWidth;
    }

    private get _otherActionsWidth() {
        return (this.selectable || this.singleSelectable
            ? this.selectionColumnWidth
            : 0) + REFRESH_WIDTH;
    }

    private _isOverflown(minWidth: number) {
        const gridWidth = this._ref.nativeElement.getBoundingClientRect().width;
        return minWidth > gridWidth;
    }

    private _isNonInteractiveElementClick(event: Event) {
        return (event.target instanceof Element) &&
            !EXCLUDED_ROW_SELECTION_ELEMENTS.find(el => (event.target as Element).closest(el));
    }

    private _selectRowOnClick(row: T) {
        if (this.shouldSelectOnRowClick) {
            if (this.singleSelectable) {
                this.rowSelected(row);
            } else {
                this.selectionManager.toggle(row);
            }
        }
    }

    private _setInitialRowSize() {
        if (this.rowSize === -1) {
            this.rowSize = this.hasHighDensity ?
                DEFAULT_VIRTUAL_SCROLL_HIGH_DENSITY_ITEM_SIZE :
                DEFAULT_VIRTUAL_SCROLL_ITEM_SIZE;
        }
    }
}