import { action, computed, makeObservable, observable } from 'mobx'; import { CollectionOptimisticActions, CollectionState, ConditionalModalState, FiltersMap, SortOrder, WixPatternsContainer, } from '@wix/bex-core'; import type { MessageModalLayoutProps } from '@wix/design-system'; import type { KeyedItem } from '@wix/bex-core'; import { ToolbarCollectionState } from '../ToolbarCollectionState'; import { TableState } from '../TableState'; import { ICollectionComponentState } from '../ICollectionComponentState'; import { CellInteractionState } from './CellInteractionState'; import { CellValueState } from './CellValueState'; import { ClipboardState } from './ClipboardState'; import { MutationState } from './MutationState'; import type { EditableTableMutationHandlers } from './MutationState'; import type { Cell, CellType, EditableColumn, ValidationResult } from './types'; import { buildEditableFieldColumns } from '../buildEditableFieldColumns'; export interface EditableTablePermissions { addItemDisabled?: boolean; updateItemDisabled?: boolean; removeItemDisabled?: boolean; } export interface EditableTableStateParams { readonly collection: CollectionState; readonly container: WixPatternsContainer; readonly toolbar?: ToolbarCollectionState; readonly permissions?: EditableTablePermissions; readonly mutations: EditableTableMutationHandlers; readonly createOptimisticActions: ( collection: CollectionState, ) => CollectionOptimisticActions; /** * Additional cell types to register alongside the built-in ones. * Augment `CellTypeConfigMap` to use these types on `EditableColumn.cellType`. */ readonly extraCellTypes?: CellType[]; } export interface EditableTableStatePublicAPI { /** Underlying CollectionState instance */ readonly collection: CollectionState; /** Underlying ToolbarCollectionState instance */ readonly toolbar: ToolbarCollectionState; /** Reset active filters & sorting and adds new items to the collection */ onAddItemActionComplete(items: T[]): void; /** Sort by column */ sort: ( columnId: string, params?: { forceDirection?: SortOrder; multiple?: boolean }, ) => void; } export class EditableTableState implements ICollectionComponentState, EditableTableStatePublicAPI { readonly toolbar: ToolbarCollectionState; readonly tableState: TableState; readonly cellInteraction: CellInteractionState; readonly cellValue: CellValueState; readonly clipboard: ClipboardState; readonly mutation: MutationState; readonly deleteConfirmModal = new ConditionalModalState< Partial >(); addItemDisabled = false; updateItemDisabled = false; removeItemDisabled = false; _providedColumns: EditableColumn[] = []; constructor(params: EditableTableStateParams) { this.toolbar = params.toolbar ?? new ToolbarCollectionState({ collection: params.collection, container: params.container, componentType: 'EditableTable', }); this.tableState = new TableState({ collection: params.collection, container: params.container, toolbar: this.toolbar, }); this.cellValue = new CellValueState( this, new Map(params.extraCellTypes?.map((ct) => [ct.type, ct])), ); this.cellInteraction = new CellInteractionState( (editingValue, cell) => { this.cellValue.commitHandler(editingValue, cell); }, this, ); this.clipboard = new ClipboardState(this); this.mutation = new MutationState( this, params.mutations, params.createOptimisticActions, ); makeObservable(this, { addItemDisabled: observable, updateItemDisabled: observable, removeItemDisabled: observable, _providedColumns: observable.ref, editableColumns: computed, sourceColumns: computed, allColumns: computed, _columnMap: computed, init: action, confirmDeleteItems: action, }); if (params.permissions) { this.addItemDisabled = params.permissions.addItemDisabled ?? false; this.updateItemDisabled = params.permissions.updateItemDisabled ?? false; this.removeItemDisabled = params.permissions.removeItemDisabled ?? false; } } get collection() { return this.tableState.collection; } get keyedItems() { return this.tableState.keyedItems; } get visibleColumns() { return this.tableState.visibleColumns; } get itemsToRenderCount() { return this.tableState.itemsToRenderCount; } /** Editable columns built from the wired fields source, once it loads. */ get sourceColumns(): EditableColumn[] { const source = this.toolbar.collectionFieldsSource; if (!source || !source.initTask.status.isSuccess) { return []; } return buildEditableFieldColumns( source.fieldsSource, ) as EditableColumn[]; } /** * Consumer columns plus the columns built from the fields source, merged the * same way the toolbar merges its columns (ToolbarCollectionState.columns). * Consumer and source columns are expected to be disjoint by id. */ get allColumns(): EditableColumn[] { return [...this._providedColumns, ...this.sourceColumns]; } get _columnMap(): Map> { return new Map(this.allColumns.map((c) => [c.id, c])); } _getKeyedItem(key: string): KeyedItem | undefined | null { return this.collection.getKeyedItem(key); } _toIndices(pos: Cell): { row: number; col: number } | null { const row = this.keyedItems.findIndex((ki) => ki.key === pos.rowKey); const col = this.editableColumns.findIndex((c) => c.id === pos.columnId); return row === -1 || col === -1 ? null : { row, col }; } _toPosition(row: number, col: number): Cell | null { const ki = this.keyedItems[row]; const c = this.editableColumns[col]; return ki && c ? { rowKey: ki.key, columnId: c.id } : null; } onAddItemActionComplete = (items: T[]) => { return this.tableState.onAddItemActionComplete(items); }; onAddItemClick = (origin?: string) => { this.toolbar.toolbarBIReporter.onAddItemClick(origin); }; get editableColumns(): EditableColumn[] { const visibleIds = this.visibleColumns.map((c) => c.id); if (!visibleIds.length) { return this.allColumns; } return visibleIds .map((id) => this.allColumns.find((c) => c.id === id)) .filter((c): c is EditableColumn => c !== undefined); } init(columns: EditableColumn[]) { this._providedColumns = columns; return this.mutation.init(); } addItem(afterItemKey?: string): string { return this.mutation.addItem(afterItemKey); } duplicateItem(rowKey: string) { this.mutation.duplicateItem(rowKey); } deleteItems(rowKeys: string[]) { this.mutation.deleteItems(rowKeys); } confirmDeleteItems(rowKeys: string[]) { const { translate: t } = this.collection; this.deleteConfirmModal.open({ theme: 'destructive', title: t('cairo.deleteModal.title'), content: t('cairo.deleteModal.body'), primaryButtonText: t('cairo.delete.button'), secondaryButtonText: t('cairo.cancel.button'), primaryButtonOnClick: () => { this.deleteItems(rowKeys); }, }); } resolveCellTypeByColumnId(columnId: string) { return this.cellValue.resolveCellTypeByColumnId(columnId); } getCellDisplayValue(rowKey: string, columnId: string): any { return this.cellValue.getCellDisplayValue(rowKey, columnId); } getCellValidation(rowKey: string, columnId: string): ValidationResult { return this.cellValue.getCellValidation(rowKey, columnId); } isCellEditable(rowKey: string, columnId: string): boolean { return this.cellValue.isCellEditable(rowKey, columnId); } clearCells(cells: Cell[]) { this.cellValue.clearCells(cells); } handleCellClick(rowKey: string, columnId: string, shiftKey: boolean) { if (this.cellInteraction.isEditing) { if (this.cellInteraction.isCellEditing(rowKey, columnId)) { // Click landed inside the active editor — let the editor handle it. return; } // Shift-click while editing: ignore — extending a range selection during // an active edit is not a meaningful action. if (shiftKey) { return; } // Plain click on a different cell: commit the edit and move focus. this.cellInteraction.commitEdit(); // If the target cell wants edit-on-click, enter edit directly. const col = this._columnMap.get(columnId); const res = col ? this.resolveCellTypeByColumnId(col.id) : null; if (res?.editOnClick) { const val = this.getCellDisplayValue(rowKey, columnId); if (res.editOnClick(val) && this.isCellEditable(rowKey, columnId)) { this.cellInteraction.focusCell(rowKey, columnId); this.cellInteraction.startEdit(rowKey, columnId, 'click'); return; } } this.cellInteraction.focusCell(rowKey, columnId); return; } const column = this._columnMap.get(columnId); const resolved = column ? this.resolveCellTypeByColumnId(column.id) : null; if (resolved?.toggleValue || resolved?.showEditWhenFocused) { // Toggle/showEditWhenFocused cells: click focuses the cell and shows the // EditComponent affordance. Keyboard navigation remains active. this.cellInteraction.focusCell(rowKey, columnId); return; } if (resolved?.editOnClick) { const value = this.getCellDisplayValue(rowKey, columnId); if ( resolved.editOnClick(value) && this.isCellEditable(rowKey, columnId) ) { this.cellInteraction.focusCell(rowKey, columnId); this.cellInteraction.startEdit(rowKey, columnId, 'click'); return; } } if (shiftKey && this.cellInteraction.selectionAnchor) { this.cellInteraction.selectRange(this.cellInteraction.selectionAnchor, { rowKey, columnId, }); } else { this.cellInteraction.focusCell(rowKey, columnId); } } handleCellDoubleClick(rowKey: string, columnId: string) { if (this.updateItemDisabled) { return; } const column = this._columnMap.get(columnId); const resolved = column ? this.resolveCellTypeByColumnId(column.id) : null; // Toggle-value cells (e.g. boolean) activate on single click, not double-click. // Entering edit mode on double-click would apply the editing box-shadow // without any EditComponent being shown, causing a visual border glitch. if (resolved?.toggleValue || resolved?.showEditWhenFocused) { return; } if (this.isCellEditable(rowKey, columnId)) { this.cellInteraction.startEdit(rowKey, columnId, 'doubleClick'); } } sort = ( columnId: string, { forceDirection, multiple, }: { forceDirection?: SortOrder; multiple?: boolean } = {}, ) => { this.collection.sort( { id: columnId }, { forceDirection, clearResult: true, multiple }, ); }; }