import { action, computed, makeObservable, observable } from 'mobx'; import type { CellType, Cell, ValidationResult } from './types'; import type { FiltersMap } from '@wix/bex-core'; import type { EditableTableState } from './EditableTableState'; import { builtInCellTypes, textCellType, } from '../../components/EditableTable/cellTypes'; import { runCellValidation, validResult } from './utils'; const builtInCellTypeMap = new Map( builtInCellTypes.map((ct) => [ct.type, ct]), ); export class CellValueState { /** Pending invalid cell values, keyed by `${itemId}:${colId}`. */ readonly _pendingCells = new Map(); private readonly _parent: EditableTableState; private readonly _extraCellTypes: Map; constructor( parent: EditableTableState, extraCellTypes: Map = new Map(), ) { this._parent = parent; this._extraCellTypes = extraCellTypes; makeObservable(this, { _pendingCells: observable, _cellTypeMap: computed, clearCells: action, toggleCell: action, commitHandler: action, applyBulkRawValues: action, applyBulkValues: action, }); } // Cell-type config per column id, from the merged columns (consumer columns // plus the columns the wired source adds). A computed so source columns that // load after init still get their cell types registered, without a rebuild. get _cellTypeMap(): Map { const map = new Map(); for (const col of this._parent.allColumns) { if ( col.cellType === 'custom' && col.typeConfig && typeof col.typeConfig === 'object' && 'ViewComponent' in col.typeConfig ) { map.set(col.id, { type: 'custom', ...(col.typeConfig as Omit), }); } else { map.set( col.id, builtInCellTypeMap.get(col.cellType) ?? this._extraCellTypes.get(col.cellType) ?? textCellType, ); } } return map; } resolveCellTypeByColumnId(columnId: string): CellType { return this._cellTypeMap.get(columnId) ?? textCellType; } getCellDisplayValue(rowKey: string, columnId: string): any { const pending = this._getPendingCell(rowKey, columnId); if (pending) { return pending.value; } const column = this._parent._columnMap.get(columnId); const keyedItem = this._parent._getKeyedItem(rowKey); return column && keyedItem ? column.getValue(keyedItem.item) : undefined; } getCellValidation(rowKey: string, columnId: string): ValidationResult { const pending = this._getPendingCell(rowKey, columnId); if (pending) { return { valid: false, errors: pending.errors }; } return validResult; } isCellEditable(rowKey: string, columnId: string): boolean { const column = this._parent._columnMap.get(columnId); if (!column) { return false; } const resolved = this.resolveCellTypeByColumnId(column.id); if (resolved.editable === false || !resolved.EditComponent) { return false; } if (!column.setValue) { return false; } if (typeof column.editable === 'function') { const keyedItem = this._parent._getKeyedItem(rowKey); return keyedItem ? column.editable(keyedItem.item) : false; } return column.editable !== false; } /** Serialize a cell value to a string (for clipboard copy/cut). */ serializeCellValue(rowKey: string, columnId: string): string { const column = this._parent._columnMap.get(columnId); const keyedItem = this._parent._getKeyedItem(rowKey); if (!column || !keyedItem) { return ''; } const resolved = this.resolveCellTypeByColumnId(column.id); const pending = this._getPendingCell(rowKey, columnId); const value = pending ? pending.value : column.getValue(keyedItem.item); return resolved.serialize ? resolved.serialize(value) : String(value ?? ''); } /** Clear selected cells (Delete/Backspace). Respects cellType.clearable. */ clearCells(cells: Cell[]) { const changes: { rowKey: string; columnId: string; value: any }[] = []; for (const cell of cells) { const column = this._parent._columnMap.get(cell.columnId); if (!column?.setValue) { continue; } const resolved = this.resolveCellTypeByColumnId(column.id); if (resolved.clearable === false) { continue; } const pendingKey = this._pendingCellKey(cell.rowKey, cell.columnId); if (pendingKey) { this._pendingCells.delete(pendingKey); } const value = resolved.deserialize ? resolved.deserialize('') : ''; changes.push({ rowKey: cell.rowKey, columnId: cell.columnId, value }); } if (changes.length > 0) { this.applyBulkValues(changes); } } toggleCell(rowKey: string, columnId: string) { const column = this._parent._columnMap.get(columnId); const keyedItem = this._parent._getKeyedItem(rowKey); if (!column?.setValue || !keyedItem) { return; } const resolved = this.resolveCellTypeByColumnId(column.id); if (!resolved.toggleValue) { return; } const value = column.getValue(keyedItem.item); const newValue = resolved.toggleValue(value); const updatedItem = column.setValue(keyedItem.item, newValue); this._parent.mutation.updateItems([updatedItem]); } /** Commit handler — used as callback for CellInteractionState. */ commitHandler(editingValue: any, cell: Cell) { if (editingValue !== undefined) { const resolved = this.resolveCellTypeByColumnId(cell.columnId); const normalized = resolved.normalizeOnCommit ? resolved.normalizeOnCommit(editingValue) : editingValue; this._applyValue(cell.rowKey, cell.columnId, normalized); } } private _getPendingCell( rowKey: string, columnId: string, ): { value: any; errors: string[] } | undefined { const key = this._pendingCellKey(rowKey, columnId); return key ? this._pendingCells.get(key) : undefined; } private _pendingCellKey( rowKey: string, columnId: string, ): string | undefined { const keyedItem = this._parent._getKeyedItem(rowKey); const column = this._parent._columnMap.get(columnId); if (!keyedItem || !column) { return undefined; } return `${keyedItem.key}:${column.id}`; } /** * Deserialize a raw string to a typed value. Returns an error message * if the conversion fails */ tryDeserialize( columnId: string, rawValue: string, ): { value: any; error?: string } { const column = this._parent._columnMap.get(columnId); if (!column) { return { value: rawValue }; } const resolved = this.resolveCellTypeByColumnId(column.id); if (!resolved.deserialize) { return { value: rawValue }; } try { return { value: resolved.deserialize(rawValue) }; } catch { return { value: rawValue, error: 'Value is not compatible' }; } } /** * Deserialize and apply multiple raw string values in bulk. * Handles conversion failures by storing them as pending cells. */ applyBulkRawValues( rawChanges: { rowKey: string; columnId: string; rawValue: string }[], ) { const changes: { rowKey: string; columnId: string; value: any }[] = []; for (const { rowKey, columnId, rawValue } of rawChanges) { const { value, error } = this.tryDeserialize(columnId, rawValue); if (error) { const pendingKey = this._pendingCellKey(rowKey, columnId); if (pendingKey) { this._pendingCells.set(pendingKey, { value, errors: [error] }); } } else { changes.push({ rowKey, columnId, value }); } } this.applyBulkValues(changes); } /** * Apply multiple cell values in bulk. Validates per cell, accumulates * changes per row, and submits all modified items in a single update. */ applyBulkValues(changes: { rowKey: string; columnId: string; value: any }[]) { const rowItems = new Map(); for (const { rowKey, columnId, value } of changes) { const column = this._parent._columnMap.get(columnId); const keyedItem = this._parent._getKeyedItem(rowKey); if (!column?.setValue || !keyedItem) { continue; } const resolved = this.resolveCellTypeByColumnId(column.id); const result = runCellValidation( value, column.validation, resolved.validate, this._parent.collection?.translate, ); const pendingKey = this._pendingCellKey(rowKey, columnId); if (result.valid) { if (pendingKey) { this._pendingCells.delete(pendingKey); } const currentItem = rowItems.get(rowKey) ?? keyedItem.item; rowItems.set(rowKey, column.setValue(currentItem, value)); } else if (pendingKey) { this._pendingCells.set(pendingKey, { value, errors: result.errors ?? [], }); } } this._parent.mutation.updateItems(Array.from(rowItems.values())); } private _applyValue(rowKey: string, columnId: string, value: any) { const column = this._parent._columnMap.get(columnId); const keyedItem = this._parent._getKeyedItem(rowKey); if (!column?.setValue || !keyedItem) { return; } const resolved = this.resolveCellTypeByColumnId(column.id); const result = runCellValidation( value, column.validation, resolved.validate, this._parent.collection?.translate, ); const pendingKey = this._pendingCellKey(rowKey, columnId); if (result.valid) { if (pendingKey) { this._pendingCells.delete(pendingKey); } const updatedItem = column.setValue(keyedItem.item, value); this._parent.mutation.updateItems([updatedItem]); } else if (pendingKey) { this._pendingCells.set(pendingKey, { value, errors: result.errors ?? [], }); } } }