/* eslint-disable @typescript-eslint/member-ordering */ import { ReactElement } from 'react'; import { action, computed, observable, runInAction, makeObservable } from 'mobx'; import { fromPromise } from 'mobx-utils'; import { AggregateDescriptor, CompositeFilterDescriptor, GroupDescriptor, SortDescriptor, DataResult, GroupResult, DataSource, IdType, } from '@servicetitan/data-query'; import { ExcelExport, ExcelExportData, ExcelExportColumnProps, } from '@progress/kendo-react-excel-export'; import { TableExpandChangeEvent, TableFilterChangeEvent, TableGroupChangeEvent, TablePageChangeEvent, TableSortChangeEvent, } from '@servicetitan/design-system'; import { GridPDFExport as TablePDFExport } from '@progress/kendo-react-pdf'; import { WorkbookOptions } from '@progress/kendo-ooxml'; import { FormState, FieldState } from 'formstate'; import { formStateToJS } from '@servicetitan/form'; export type Selectable = { readonly selected: boolean | undefined } & T; export type Editable = { readonly inEdit: boolean | undefined } & T; export type Expandable = { readonly expanded: boolean | undefined } & T; export type Materialized = { readonly materializedPath: string } & T; type Data = DataResult['data']; export function isGroupItem( item: T | GroupResult, groups: GroupDescriptor[] ): item is GroupResult { const field: string | undefined = (item as GroupResult).field; return !!field && groups.some(g => g.field === field); } function addAggregatesToGroups(groups: GroupDescriptor[], aggregates: AggregateDescriptor[]) { return groups.map(group => ({ ...group, aggregates, })); } export type PossibleFormState = FormState<{ [P in keyof T]-?: FieldState; }>; export interface TableStateModel { skip: number; selectedIds: TId[]; expandedIds: TId[]; collapsedGroups: string[]; sort: SortDescriptor[]; filter?: CompositeFilterDescriptor; aggregates: AggregateDescriptor[]; group: GroupDescriptor[]; } export interface TableStateConstructorParams< T, TId extends IdType = never, P = never, PId extends IdType = never, > { dataSource?: DataSource | null; pageSize?: number; /** For use with tables that enable selection only. Specifies if the item is selectable in the checkbox */ isRowUnselectable?: (row: T) => boolean; /** * Initial expand state for row details * collapsed - row details are collapsed by default * expanded - row details are expanded until it will be collapsed */ rowDetailsExpandState?: 'collapsed' | 'expanded'; selectionLimit?: number; getFormState?(row: T): PossibleFormState; parent?: { row: P; tableState: TableState; }; getDetailTableState?: (row: T) => TableState | undefined; initialState?: TableStateModel; alwaysEditable?: boolean; rowIdKey?: string; } interface FetchDataParams { newSkip?: number; newSort?: SortDescriptor[]; newFilter?: CompositeFilterDescriptor | null; newAggregates?: AggregateDescriptor[]; newGroup?: GroupDescriptor[]; } interface SelectionOptions { ignoreUnselectable?: boolean; recursive?: boolean; } interface SetDataSourceParams { reset?: boolean; initialState?: TableStateModel; } interface EditingForm { form: PossibleFormState; field?: keyof T; } /** Interface for unit testing purposes.*/ export interface ITableState { totalCount?: number; selectedCount?: number; addToDataSource?: (row: T, select?: boolean) => Promise; removeFromDataSource?: (id: TId) => Promise; selectAll?: () => Promise; deselectAll?: () => void; setDataSource?: ( dataSource: DataSource | null, setDataSourceParams?: boolean | SetDataSourceParams ) => Promise; isRowUnselectable?: (row: T) => boolean; } export class TableState { @observable private innerDataSource: DataSource | null; @computed get dataSource() { return this.innerDataSource; } private traverse( items: Data>, itemCallback: (item: Selectable) => void, groupCallback?: (group: GroupResult, materializedPath: string) => void, materializedPath = '' ) { if (!items) { return; } for (const item of items) { if (isGroupItem(item, this.group)) { const groupMaterializedPath = materializedPath + `/${item.value}`; if (groupCallback) { groupCallback(item, groupMaterializedPath); } this.traverse(item.items, itemCallback, groupCallback, groupMaterializedPath); } else { itemCallback(item); } } } @computed private get originalData() { const result: Selectable[] = []; this.traverse(this.data, item => { result.push(item); }); return result; } parent?: { row: P; tableState: TableState; }; getDetailTableState?: (row: T) => TableState | undefined; @observable selectedIds = new Set(); @observable inEdit = new Map>(); @observable expandedIds = new Set(); @observable collapsedIds = new Set(); @observable collapsedGroups = new Set(); isRowUnselectable: (row: T) => boolean; // TODO: think about a better name selectionLimit: number; getFormState?(row: T): PossibleFormState; @observable data: Data> = []; @observable totalCount = 0; @observable filteredCount = 0; @observable selectedCount = 0; @observable unselectableCount = 0; @computed get unselectedCount() { return this.totalCount - this.selectedCount; } @computed get selectableCount() { return this.totalCount - this.unselectableCount; } @computed get filteredUnselectableCount() { return this.originalData.filter(this.isRowUnselectable).length; } @computed get filteredSelectableCount() { return this.originalData.length - this.filteredUnselectableCount; } @computed private get totalFilteredSelectableCountPromise() { return fromPromise( new Promise((resolve, reject) => { if (!this.dataSource) { return reject(); } this.dataSource .getData({ filter: this.filter, }) .then(filteredData => { resolve( (filteredData.data as T[]).filter(row => !this.isRowUnselectable(row)) .length ); }) .catch(reject); }) ); } @computed get totalFilteredSelectableCount(): number { return (this.totalFilteredSelectableCountPromise.value as number) || 0; } @observable sort: SortDescriptor[] = []; @observable filter: CompositeFilterDescriptor | undefined; @observable aggregates: AggregateDescriptor[] = []; @observable innerGroup: GroupDescriptor[] = []; @computed get group(): GroupDescriptor[] { return addAggregatesToGroups(this.innerGroup, this.aggregates); } set group(value: GroupDescriptor[]) { this.innerGroup = value; } @observable skip = 0; @observable pageSize?: number; private tablePdfExport: TablePDFExport | null = null; private tableExcelExport: ExcelExport | null = null; private readonly rowDetailsExpandState: 'collapsed' | 'expanded'; rowIdKey?: string; alwaysEditable: boolean; constructor({ dataSource = null, rowIdKey, pageSize, isRowUnselectable = () => false, rowDetailsExpandState = 'collapsed', selectionLimit = Infinity, getFormState, parent, getDetailTableState, initialState, alwaysEditable = false, }: TableStateConstructorParams = {}) { makeObservable(this); this.innerDataSource = dataSource; this.pageSize = pageSize; this.isRowUnselectable = isRowUnselectable; this.rowDetailsExpandState = rowDetailsExpandState; this.selectionLimit = selectionLimit; this.rowIdKey = rowIdKey; this.getFormState = getFormState; this.parent = parent; this.getDetailTableState = getDetailTableState; if (initialState) { this.importState(initialState); } this.alwaysEditable = alwaysEditable; this.fetchInitialData(); if (this.alwaysEditable) { this.editAll(); } } async addToDataSource(row: T, select?: boolean, index?: number) { if (!this.dataSource) { return; } if (!this.dataSource.addData) { throw 'missing addData in the data source'; } await this.dataSource.addData(row, index); runInAction(() => { this.totalCount += 1; if (select) { if (!this.dataSource!.idSelector) { throw 'missing idSelector in the data source'; } this.selectedIds.add(this.dataSource!.idSelector(row)); this.selectedCount += 1; } if (this.alwaysEditable) { this.edit(row); } }); await this.fetchData(); } async removeFromDataSource(id: TId): Promise { if (!this.dataSource) { return; } if (!this.dataSource.removeData) { throw 'missing removeData in the data source'; } const row = (await this.dataSource.removeData(id)) as Selectable | undefined; if (!row) { return; } runInAction(() => { this.totalCount -= 1; // Updating filteredCount here to force Table rerender after this action this.filteredCount -= 1; if (this.selectedIds.has(id)) { this.selectedIds.delete(id); this.selectedCount -= 1; } if (this.inEdit.has(id)) { this.inEdit.delete(id); } if (this.expandedIds.has(id)) { this.expandedIds.delete(id); } }); // If the current page has only one row and it's removed, fetch the previous page if it exists if (this.data.length === 1 && this.pageSize && this.skip >= this.pageSize) { await this.fetchData({ newSkip: this.skip - this.pageSize }); } else { await this.fetchData(); } return row; } @action async setRowsSelection( rows: T[], value: boolean, { ignoreUnselectable = true, recursive = true }: SelectionOptions = {} ) { if (!this.dataSource) { return; } if (!this.dataSource.idSelector) { throw 'missing idSelector in the data source'; } for (const row of rows) { if (ignoreUnselectable && this.isRowUnselectable(row)) { continue; } runInAction(() => { if (!value) { this.selectedIds.delete(this.dataSource!.idSelector!(row)); } else if (this.selectedCount < this.selectionLimit) { this.selectedIds.add(this.dataSource!.idSelector!(row)); } }); // update children selection if (recursive && this.getDetailTableState) { const detail = this.getDetailTableState(row); if (detail?.selectableCount) { // eslint-disable-next-line no-await-in-loop await detail.setAllSelection(value); } } } // update parent selection if (this.parent) { if (this.isAllPageRowsSelected) { await this.parent.tableState.setRowsSelection([this.parent.row], true, { recursive: false, }); } else { await this.parent.tableState.setRowsSelection([this.parent.row], false, { recursive: false, }); } } runInAction(() => { this.selectedCount = this.selectedIds.size; this.data = this.data.slice(); }); } async selectPage(options?: SelectionOptions) { await this.setRowsSelection(this.originalData, true, options); } async deselectPage(options?: SelectionOptions) { await this.setRowsSelection(this.originalData, false, options); } private async setAllSelection(value: boolean, options?: SelectionOptions) { if (!this.dataSource) { return; } const filteredData = await this.dataSource.getData({ filter: this.filter, }); await this.setRowsSelection(filteredData.data as T[], value, options); } async selectAll(options?: SelectionOptions) { if (this.selectionLimit !== Infinity) { throw "selectAll isn't supported for limited selection"; } await this.setAllSelection(true, options); } async deselectAll(options?: SelectionOptions) { await this.setAllSelection(false, options); } @action edit(row: T, field?: keyof T) { if (!this.dataSource) { return; } if (!this.dataSource.idSelector) { throw 'missing idSelector in the data source'; } if (!this.getFormState) { throw 'missing getFormState'; } this.inEdit.set(this.dataSource.idSelector(row), { form: this.getFormState(row), field, }); this.data = this.data.slice(); } @action async saveEdit(row: T, beforeSave?: (changed: T, field?: keyof T) => Promise) { if (!this.dataSource) { return; } if (!this.dataSource.idSelector) { throw 'missing idSelector in the data source'; } if (!this.dataSource.updateData) { throw 'missing updateData in the data source'; } const id = this.dataSource.idSelector(row); const editingForm = this.inEdit.get(id); if (!editingForm) { return; } const { form, field } = editingForm; const changed = formStateToJS(form) as unknown as T; // FIXME: incompatible types if (beforeSave) { await beforeSave(changed, field); } await this.dataSource.updateData(id, changed); if (!this.alwaysEditable) { runInAction(() => { this.inEdit.delete(id); }); } await this.fetchData(); } @action cancelEdit(row: T) { if (!this.dataSource) { return; } if (!this.dataSource.idSelector) { throw 'missing idSelector in the data source'; } if (this.alwaysEditable) { return; } this.inEdit.delete(this.dataSource.idSelector(row)); this.data = this.data.slice(); } async editAll() { if (!this.dataSource) { return; } if (!this.dataSource.idSelector) { throw 'missing idSelector in the data source'; } if (!this.getFormState) { throw 'missing getFormState'; } const rows = ( await this.dataSource.getData({ filter: this.filter, }) ).data as T[]; runInAction(() => { this.inEdit = new Map( rows.map<[TId, EditingForm]>(row => [ this.dataSource!.idSelector!(row), { form: this.getFormState!(row) }, ]) ); this.data = this.data.slice(); }); } async saveEditAll(beforeSave?: (changed: T[]) => Promise) { if (!this.dataSource) { return; } if (!this.dataSource.updateData) { throw 'missing updateData in the data source'; } const changes = Array.from(this.inEdit).map(([id, { form }]) => ({ id, changed: formStateToJS(form) as unknown as T, // FIXME: incompatible types })); if (beforeSave) { await beforeSave(changes.map(change => change.changed)); } for (const { id, changed } of changes) { // eslint-disable-next-line no-await-in-loop await this.dataSource.updateData(id, changed); } if (!this.alwaysEditable) { runInAction(() => { this.inEdit = new Map(); }); } await this.fetchData(); } @action cancelEditAll() { if (!this.dataSource) { return; } if (this.alwaysEditable) { return; } this.inEdit = new Map(); this.data = this.data.slice(); } @action reset() { this.totalCount = 0; this.filteredCount = 0; this.data = []; this.selectedCount = 0; this.selectedIds = new Set(); this.inEdit = new Map(); this.expandedIds = new Set(); this.collapsedIds = new Set(); this.collapsedGroups = new Set(); this.sort = []; this.filter = undefined; this.aggregates = []; this.innerGroup = []; } @action async setDataSource( dataSource: DataSource | null, setDataSourceParams?: SetDataSourceParams | boolean ) { this.innerDataSource = dataSource; this.skip = 0; const reset = typeof setDataSourceParams === 'boolean' ? setDataSourceParams : setDataSourceParams?.reset; const config = setDataSourceParams as SetDataSourceParams; if (reset) { this.reset(); } if (config?.initialState) { this.importState(config.initialState); } await this.fetchInitialData(); if (this.alwaysEditable) { await this.editAll(); } } @action private fetchInitialData = async () => { if (!this.dataSource) { return; } const initial = (await this.dataSource.getData({ skip: this.skip, take: this.pageSize, filter: this.filter, sort: [...this.sort], group: [...this.group], })) as DataResult>; runInAction(() => { this.totalCount = initial.total; this.filteredCount = initial.total; this.updateExpandState(initial.data); this.data = initial.data; this.unselectableCount = this.originalData.filter(this.isRowUnselectable).length; this.addPropertiesToRows(this.data); }); }; private addPropertiesToRows(data: Data>) { this.traverse( data, item => { if (!this.dataSource) { return; } if (!this.dataSource.idSelector) { return; } if (!('selected' in item)) { Object.defineProperty(item, 'selected', { get: () => this.selectedIds.has(this.dataSource!.idSelector!(item)), }); } if (!('indeterminate' in item)) { Object.defineProperty(item, 'indeterminate', { get: () => { const detail = this.getDetailTableState?.(item); if (!detail) { return false; } return detail.isSomePageRowsSelected; }, }); } if (!('inEdit' in item)) { Object.defineProperty(item, 'inEdit', { get: () => this.inEdit.has(this.dataSource!.idSelector!(item)), }); } if (!('expanded' in item)) { Object.defineProperty(item, 'expanded', { get: () => this.expandedIds.has(this.dataSource!.idSelector!(item)), }); } }, (group, materializedPath) => { if (!('materializedPath' in group)) { Object.defineProperty(group, 'materializedPath', { get: () => materializedPath, }); } if (!('expanded' in group)) { Object.defineProperty(group, 'expanded', { get: () => !this.collapsedGroups.has( (group as Materialized>).materializedPath ), }); } } ); } @action handleFilterChange = (ev: TableFilterChangeEvent) => { this.fetchData({ newFilter: ev.filter, }); }; @action handleGroupChange = (ev: TableGroupChangeEvent) => { this.collapsedGroups = new Set(); this.fetchData({ newGroup: ev.group, }); }; @action handleExpandChange = ({ dataItem, value }: TableExpandChangeEvent) => { if (isGroupItem(dataItem, this.group)) { const { materializedPath } = dataItem as Materialized>; if (value) { this.collapsedGroups.delete(materializedPath); } else { this.collapsedGroups.add(materializedPath); } } else { if (!this.dataSource) { return; } if (!this.dataSource.idSelector) { throw 'missing idSelector in the data source'; } const id = this.dataSource.idSelector(dataItem); if (value) { this.expandedIds.add(id); this.collapsedIds.delete(id); } else { this.expandedIds.delete(id); this.collapsedIds.add(id); } } this.data = this.data.slice(); }; @action handleSortChange = (ev: TableSortChangeEvent) => { this.fetchData({ newSort: ev.sort, }); }; @action handlePageChange = (ev: TablePageChangeEvent) => { if (this.pageSize !== ev.page.take) { this.pageSize = ev.page.take; this.skip = 0; } else if (this.skip === ev.page.skip) { return; } this.fetchData({ newSkip: ev.page.skip, }); }; /** Call without params if unchanged */ fetchData = async ({ newSkip, newSort, newFilter, newAggregates, newGroup, }: FetchDataParams = {}) => { const sort = newSort ?? [...this.sort]; const filter = newFilter === null ? undefined : (newFilter ?? this.filter); const aggregates = newAggregates ?? this.aggregates; const group = addAggregatesToGroups(newGroup ?? this.group, aggregates); const skip = newSort || newFilter !== undefined || newGroup ? 0 : newSkip !== undefined ? newSkip : this.skip; if (!this.dataSource) { runInAction(() => { this.sort = sort; this.filter = filter; this.aggregates = aggregates; this.innerGroup = group; }); return; } const newData = (await this.dataSource.getData({ skip, sort, filter, group: [...group], take: this.pageSize, })) as DataResult>; runInAction(() => { if (!this.dataSource) { return; } if (newFilter !== undefined) { // When a new filter is applied, deselect items that don't match the filter if (this.dataSource.getFilteredPersistentItems) { // For persistent items, deselect everything in the persistent array that's been filtered out if (newFilter !== null && this.dataSource.idSelector) { let delta = 0; const filteredData = this.dataSource.getFilteredPersistentItems( newFilter ) as Expandable>>[]; filteredData.forEach(dataItem => { const id = this.dataSource!.idSelector!(dataItem); if (this.selectedIds.delete(id)) { delta += 1; } if (!this.alwaysEditable) { this.inEdit.delete(id); } this.expandedIds.delete(id); }); this.selectedCount -= delta; } } else { // For non-peristent items, everything is deselected by default this.selectedCount = 0; } } this.updateExpandState(newData.data); this.data = newData.data; this.filteredCount = newData.total; this.sort = sort; this.filter = filter; this.aggregates = aggregates; this.innerGroup = group; this.skip = skip; this.addPropertiesToRows(this.data); }); }; isRowSelectable = (row: Selectable) => !this.isRowUnselectable(row) && (this.selectionLimit === Infinity || row.selected || this.selectedCount < this.selectionLimit); @action async toggleRowSelection(row: T, options?: SelectionOptions) { if (!this.dataSource) { return; } if (!this.dataSource.idSelector) { throw 'missing idSelector in the data source'; } await this.setRowsSelection( [row], !this.selectedIds.has(this.dataSource.idSelector(row)), options ); } @computed get isAllPageRowsSelected() { return ( this.originalData.some(row => row.selected) && this.originalData.every(row => this.isRowUnselectable(row) || row.selected) ); } @computed get isSomePageRowsSelected() { return this.originalData.some(row => row.selected) && !this.isAllPageRowsSelected; } @computed get isAllRowsSelected() { if (!this.selectedIds.size) { return false; } return this.selectedIds.size === this.totalFilteredSelectableCount; } @computed get isSomeRowsSelected() { if (!this.selectedIds.size) { return false; } return !this.isAllRowsSelected; } setTablePdfExportRef = (el: TablePDFExport | null) => (this.tablePdfExport = el); setTableExcelExportRef = (el: ExcelExport | null) => (this.tableExcelExport = el); exportPdf = async () => { if (this.dataSource && this.tablePdfExport) { const { data } = await this.dataSource.getData({}); // fetch all data this.tablePdfExport.save(data); } }; exportExcel = async ( data?: any[] | ExcelExportData | WorkbookOptions, columns?: ExcelExportColumnProps[] | ReactElement[] ) => { if (this.tableExcelExport) { this.tableExcelExport.save( data ?? (await this.filteredSortedUnpaginatedData()).data, columns ); } }; filteredSortedUnpaginatedData = () => { if (!this.dataSource) { throw 'missing dataSource'; } return this.dataSource.getData({ filter: this.filter, sort: [...this.sort], group: [...this.group], }); }; getRowsBetween = (start: Selectable, end: Selectable) => { const idSelector = this.dataSource?.idSelector; if (!idSelector) { return []; } let from: number | undefined, to: number | undefined; const startId = idSelector(start); const endId = idSelector(end); for (const [index, row] of this.originalData.entries()) { const id = idSelector(row); if (startId === id) { from = index; } if (endId === id) { to = index; } } if (from === undefined || to === undefined) { return []; } return this.originalData.slice(Math.min(from, to), Math.max(from, to) + 1); }; exportState(): TableStateModel { return { skip: this.skip, selectedIds: Array.from(this.selectedIds), expandedIds: Array.from(this.expandedIds), collapsedGroups: Array.from(this.collapsedGroups), sort: this.sort, filter: this.filter, aggregates: this.aggregates, group: this.innerGroup, }; } @action importState(data: TableStateModel) { this.skip = data.skip; this.selectedIds = new Set(data.selectedIds); this.expandedIds = new Set(data.expandedIds); this.collapsedGroups = new Set(data.collapsedGroups); this.sort = data.sort; this.filter = data.filter; this.aggregates = data.aggregates; this.innerGroup = data.group; } @action private updateExpandState(data: TableState['data']) { if (this.rowDetailsExpandState === 'expanded') { this.traverse(data, item => { const id = this.dataSource?.idSelector?.(item); if (id !== undefined && !this.collapsedIds.has(id)) { this.expandedIds.add(id); } }); } } }