Asymmetrik/ngx-starter

View on GitHub
src/app/common/table/filter/asy-header-list-filter/asy-header-list-filter.component.ts

Summary

Maintainability
A
0 mins
Test Coverage
import { A11yModule } from '@angular/cdk/a11y';
import { CdkConnectedOverlay, CdkOverlayOrigin, OverlayModule } from '@angular/cdk/overlay';
import { NgClass, TitleCasePipe } from '@angular/common';
import {
    ChangeDetectionStrategy,
    Component,
    DestroyRef,
    Inject,
    Input,
    Optional,
    booleanAttribute,
    inject,
    input,
    numberAttribute,
    signal
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms';

import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap';
import { Observable } from 'rxjs';
import { first } from 'rxjs/operators';

import { SearchInputComponent } from '../../../search-input/search-input.component';
import {
    AsyAbstractHeaderFilterComponent,
    AsyFilterHeaderColumnDef
} from '../asy-abstract-header-filter.component';

type BuildFilterFunction = (options: ListFilterOption[], matchAll?: boolean) => object;

type LoadOptionsFunction = () => Observable<(string | string[] | ListFilterOption)[]>;

export type ListFilterOption = {
    display: string;
    value: string;
    active?: boolean;
    hide?: boolean;
};

@Component({
    selector: 'asy-header-filter[list-filter]',
    templateUrl: './asy-header-list-filter.component.html',
    styleUrls: ['./asy-header-list-filter.component.scss'],
    changeDetection: ChangeDetectionStrategy.OnPush,
    standalone: true,
    imports: [
        NgClass,
        SearchInputComponent,
        FormsModule,
        TitleCasePipe,
        CdkOverlayOrigin,
        CdkConnectedOverlay,
        A11yModule,
        OverlayModule,
        NgbTooltip
    ],
    providers: [TitleCasePipe]
})
export class AsyHeaderListFilterComponent<T> extends AsyAbstractHeaderFilterComponent<T> {
    readonly #titleCasePipe = inject(TitleCasePipe);
    readonly #destroyRef = inject(DestroyRef);

    readonly showMatch = input(false, { transform: booleanAttribute });
    readonly showSearch = input(false, { transform: booleanAttribute });
    readonly showSearchMinOptions = input(Number.MAX_SAFE_INTEGER, {
        transform: numberAttribute
    });

    readonly optionsLoading = signal(false);

    matchAll = signal(false);

    _options: ListFilterOption[];

    search = '';

    loadOptionsFunc?: LoadOptionsFunction;

    buildFilterFunc?: BuildFilterFunction;

    constructor(
        @Inject('MAT_SORT_HEADER_COLUMN_DEF')
        @Optional()
        _columnDef: AsyFilterHeaderColumnDef
    ) {
        super(_columnDef);
    }

    loadOptions() {
        if (this.loadOptionsFunc) {
            this.optionsLoading.set(true);
            this.loadOptionsFunc()
                .pipe(first(), takeUntilDestroyed(this.#destroyRef))
                .subscribe((options) => {
                    this.options = options;
                    this.optionsLoading.set(false);
                });
        }
    }

    _buildFilter() {
        if (this.buildFilterFunc) {
            return this.buildFilterFunc(this._options, this.matchAll());
        }

        const active = this._options.filter((o) => o.active).map((o) => o.value);
        if (active.length > 0) {
            if (this.showMatch() && this.matchAll()) {
                return { $and: active.map((a) => ({ [this.id]: a })) };
            }
            return { [this.id]: { $in: active } };
        }

        return {};
    }

    _buildState() {
        const active = this._options
            .filter((o) => o.active)
            .map((option) => ({
                display: option.display,
                value: option.value
            }));
        if (active.length > 0) {
            return { options: active, matchAll: this.matchAll() };
        }
        return undefined;
    }

    _clearState() {
        this.search = '';
        if (this._options) {
            for (const option of this._options) {
                option.active = false;
                option.hide = false;
            }
        }
    }

    _restoreState(state?: { matchAll: boolean; options: ListFilterOption[] }) {
        if (state) {
            this.matchAll.set(this.showMatch() && (state.matchAll ?? false));
            this._setActiveOptions(state.options);
            this.onFilterChange();
        }
    }

    onSearchOptions(search: string) {
        this.search = search;
        this._options.forEach((option: ListFilterOption) => {
            option.hide = !(
                option.display.toUpperCase().includes(search.toUpperCase()) ||
                option.value.toUpperCase().includes(search.toUpperCase())
            );
        });
    }

    @Input()
    set options(options: (string | string[] | ListFilterOption)[]) {
        const activeOptions = this._options?.filter((option) => option.active) ?? [];
        this._options = options.map((option) => {
            if (typeof option === 'string') {
                return {
                    display: this.#titleCasePipe.transform(option),
                    value: option,
                    active: false
                } as ListFilterOption;
            }
            if (this._isStringArray(option)) {
                if (option.length > 1) {
                    return {
                        display: option[0],
                        value: option[1],
                        active: option[2] === 'true'
                    } as ListFilterOption;
                }
            } else {
                return option;
            }
            throw new Error(`Invalid filter option: ${option}`);
        });

        this._setActiveOptions(activeOptions);
    }

    _setActiveOptions(activeOptions: ListFilterOption[]) {
        if (activeOptions.length > 0) {
            if (this._options?.length ?? 0 > 0) {
                activeOptions.forEach((active) => {
                    const match = this._options.find((option) => option.value === active.value);
                    if (match) {
                        match.active = true;
                    }
                });
            } else {
                this._options = activeOptions.map((option) => ({ active: true, ...option }));
            }
        }
    }

    private _isStringArray(array: string[] | ListFilterOption): array is string[] {
        return Array.isArray(array) && typeof array[0] === 'string';
    }
}