From c2c5a3cea496e9e25df0975e3f494b1fdd491e59 Mon Sep 17 00:00:00 2001 From: Ihor Romanchuk Date: Wed, 29 Jul 2026 14:56:06 +0200 Subject: [PATCH 1/2] feat(design-system): add support for live and async validation in `DsTable` [AR-77435] --- .changeset/hungry-facts-lick.md | 5 + .../ds-table-editable.browser.test.tsx | 262 +++++++++++++++++- .../ds-table-edit-cell-checkbox.tsx | 4 +- .../cell-editors/ds-table-edit-cell-date.tsx | 3 +- .../ds-table-edit-cell-number.tsx | 3 +- .../ds-table-edit-cell-select.tsx | 3 +- .../cell-editors/ds-table-edit-cell-text.tsx | 3 +- .../ds-table-editing-cell.tsx | 15 +- .../ds-table/context/ds-table-context.ts | 174 +++++++++--- .../src/components/ds-table/ds-table.types.ts | 69 ++--- .../ds-table/hooks/use-cell-editor.ts | 6 +- .../stories/ds-table-editable.stories.tsx | 167 +++++++++-- 12 files changed, 599 insertions(+), 115 deletions(-) create mode 100644 .changeset/hungry-facts-lick.md diff --git a/.changeset/hungry-facts-lick.md b/.changeset/hungry-facts-lick.md new file mode 100644 index 000000000..d30bfaf1f --- /dev/null +++ b/.changeset/hungry-facts-lick.md @@ -0,0 +1,5 @@ +--- +'@drivenets/design-system': patch +--- + +Add support for live and async validation in `DsTable` diff --git a/packages/design-system/src/components/ds-table/__tests__/ds-table-editable.browser.test.tsx b/packages/design-system/src/components/ds-table/__tests__/ds-table-editable.browser.test.tsx index e151a79f8..a49855433 100644 --- a/packages/design-system/src/components/ds-table/__tests__/ds-table-editable.browser.test.tsx +++ b/packages/design-system/src/components/ds-table/__tests__/ds-table-editable.browser.test.tsx @@ -121,6 +121,73 @@ const confirmEdit = async () => { await page.getByRole('button', { name: 'Confirm edit' }).click(); }; +interface Deferred { + promise: Promise; + resolve: (value: T) => void; + reject: (reason?: unknown) => void; +} + +const createDeferred = (): Deferred => { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +}; + +/** Rejects with a plain string (non-`Error`) to exercise the generic fallback message. */ +const rejectWithString = (): Promise => { + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + return Promise.reject('non-error reason'); +}; + +type CommitResult = string | null | undefined; + +interface AsyncEditWrapperProps { + save: (value: string, signal: AbortSignal) => CommitResult | Promise; + onCommitted?: (rowId: string, columnId: string, value: unknown) => void; +} + +/** Exercises a fallible `onCellEdit` (save-on-commit) with no separate validator. */ +const AsyncEditWrapper = ({ save, onCommitted }: AsyncEditWrapperProps) => { + const [rows, setRows] = useState(initialRows); + + const columns: ColumnDef[] = [ + { + accessorKey: 'firstName', + header: 'First Name', + cell: (info) => info.getValue(), + editCell: (info: CellContext) => , + }, + ]; + + return ( + { + const applyResult = (error: CommitResult): string | null => { + const message = error ?? null; + if (message !== null) { + return message; + } + onCommitted?.(row.id, columnId, value); + setRows((prev) => prev.map((r) => (r.id === row.id ? { ...r, [columnId]: value } : r))); + return null; + }; + + const result = save(value as string, signal); + if (result instanceof Promise) { + return result.then(applyResult); + } + return applyResult(result); + }} + /> + ); +}; + describe('DsTable Editable Cells', () => { it('text editor: double-click, type, Enter commits via onCellEdit', async () => { const onCellEdit = vi.fn(); @@ -171,6 +238,43 @@ describe('DsTable Editable Cells', () => { expect(onCellEdit).not.toHaveBeenCalled(); }); + it('live validation: error appears on keystroke and disables Confirm before any commit attempt', async () => { + const onCellEdit = vi.fn(); + + await page.render(); + + await page.elementLocator(getEditableCell(1, 1)).dblClick(); + + const input = page.getByRole('textbox').first(); + await input.clear(); + + await expect.element(page.getByText('Required')).toBeVisible(); + await expect.element(page.getByRole('button', { name: 'Confirm edit' })).toBeDisabled(); + expect(onCellEdit).not.toHaveBeenCalled(); + }); + + it('live validation: fixing the value clears the error, re-enables Confirm, and commits', async () => { + const onCellEdit = vi.fn(); + + await page.render(); + + await page.elementLocator(getEditableCell(1, 1)).dblClick(); + + const input = page.getByRole('textbox').first(); + await input.clear(); + await expect.element(page.getByRole('button', { name: 'Confirm edit' })).toBeDisabled(); + + await userEvent.keyboard('Valid'); + await expect.element(page.getByText('Required')).not.toBeInTheDocument(); + + const confirm = page.getByRole('button', { name: 'Confirm edit' }); + await expect.element(confirm).toBeEnabled(); + await confirm.click(); + + expect(onCellEdit).toHaveBeenCalledWith('1', 'firstName', 'Valid'); + await expect.element(page.getByText('Valid')).toBeVisible(); + }); + it('checkbox editor: double-click, toggle, confirm commits', async () => { const onCellEdit = vi.fn(); @@ -227,19 +331,22 @@ describe('DsTable Editable Cells', () => { await page.render(); - await page.elementLocator(getEditableCell(1, 1)).dblClick(); + // Edit the lower row and switch up: the invalid draft surfaces a live error, + // which grows the editing overlay downward, so we open a cell above it. + await page.elementLocator(getEditableCell(2, 1)).dblClick(); const input = page.getByRole('textbox').first(); await input.clear(); + await expect.element(page.getByText('Required')).toBeVisible(); - await page.elementLocator(getEditableCell(2, 1)).dblClick(); + await page.elementLocator(getEditableCell(1, 1)).dblClick(); expect(onCellEdit).not.toHaveBeenCalled(); - await expect.element(page.getByText('Tanner')).toBeVisible(); + await expect.element(page.getByText('Kevin')).toBeVisible(); const editingInput = page.getByRole('textbox').first(); await expect.element(editingInput).toBeVisible(); - await expect.element(editingInput).toHaveValue('Kevin'); + await expect.element(editingInput).toHaveValue('Tanner'); }); it('locked cell with a reason: shows the lock icon and double-click does not enter edit mode', async () => { @@ -292,3 +399,150 @@ describe('DsTable Editable Cells', () => { expect(onRowDoubleClick).not.toHaveBeenCalled(); }); }); + +describe('DsTable Async Cell Commit (fallible onCellEdit)', () => { + it('async commit resolves nothing: closes the editor and reflects the new value', async () => { + const onCommitted = vi.fn(); + + await page.render( Promise.resolve(null)} onCommitted={onCommitted} />); + + await page.elementLocator(getEditableCell(1, 1)).dblClick(); + await userEvent.keyboard('NewName'); + await userEvent.keyboard('{Enter}'); + + await expect.element(page.getByText('NewName')).toBeVisible(); + expect(onCommitted).toHaveBeenCalledWith('1', 'firstName', 'NewName'); + await expect.element(page.getByRole('textbox')).not.toBeInTheDocument(); + }); + + it('async commit resolves an error string: keeps the cell open with the message', async () => { + const onCommitted = vi.fn(); + + await page.render( + Promise.resolve('Name taken')} onCommitted={onCommitted} />, + ); + + await page.elementLocator(getEditableCell(1, 1)).dblClick(); + await userEvent.keyboard('Bad'); + await userEvent.keyboard('{Enter}'); + + await expect.element(page.getByText('Name taken')).toBeVisible(); + expect(onCommitted).not.toHaveBeenCalled(); + await expect.element(page.getByRole('textbox').first()).toBeVisible(); + }); + + it('pending commit locks the input and Confirm while Cancel stays enabled and discards the late result', async () => { + const onCommitted = vi.fn(); + const deferred = createDeferred(); + + await page.render( deferred.promise} onCommitted={onCommitted} />); + + await page.elementLocator(getEditableCell(1, 1)).dblClick(); + await userEvent.keyboard('NewName'); + await userEvent.keyboard('{Enter}'); + + const input = page.getByRole('textbox').first(); + await expect.element(input).toBeDisabled(); + await expect.element(page.getByRole('button', { name: 'Confirm edit' })).toBeDisabled(); + await expect.element(page.getByRole('button', { name: 'Cancel edit' })).toBeEnabled(); + + await page.getByRole('button', { name: 'Cancel edit' }).click(); + await expect.element(page.getByRole('textbox')).not.toBeInTheDocument(); + + // A late resolution (even an error) must not reopen the editor or surface its result. + deferred.resolve('Late error'); + + await expect.element(page.getByText('Tanner')).toBeVisible(); + await expect.element(page.getByText('Late error')).not.toBeInTheDocument(); + await expect.element(page.getByRole('textbox')).not.toBeInTheDocument(); + }); + + it('rejected commit with an Error shows the error message and keeps the cell open', async () => { + const onCommitted = vi.fn(); + + await page.render( + Promise.reject(new Error('Server unavailable'))} + onCommitted={onCommitted} + />, + ); + + await page.elementLocator(getEditableCell(1, 1)).dblClick(); + await userEvent.keyboard('NewName'); + await userEvent.keyboard('{Enter}'); + + await expect.element(page.getByText('Server unavailable')).toBeVisible(); + expect(onCommitted).not.toHaveBeenCalled(); + await expect.element(page.getByRole('textbox').first()).toBeVisible(); + }); + + it('rejected commit with a non-Error shows the fallback message and keeps the cell open', async () => { + const onCommitted = vi.fn(); + + await page.render(); + + await page.elementLocator(getEditableCell(1, 1)).dblClick(); + await userEvent.keyboard('NewName'); + await userEvent.keyboard('{Enter}'); + + await expect.element(page.getByText('Save failed')).toBeVisible(); + expect(onCommitted).not.toHaveBeenCalled(); + await expect.element(page.getByRole('textbox').first()).toBeVisible(); + }); +}); + +describe('DsTable Async Cell AbortSignal', () => { + it('aborts the commit signal when the edit is cancelled', async () => { + const deferred = createDeferred(); + let signal: AbortSignal | undefined; + + await page.render( + { + signal = received; + return deferred.promise; + }} + />, + ); + + await page.elementLocator(getEditableCell(1, 1)).dblClick(); + await userEvent.keyboard('NewName'); + await userEvent.keyboard('{Enter}'); + + await expect.element(page.getByRole('textbox').first()).toBeDisabled(); + expect(signal?.aborted).toBe(false); + + await page.getByRole('button', { name: 'Cancel edit' }).click(); + + expect(signal?.aborted).toBe(true); + }); + + it('aborts the commit signal when the user switches to another cell', async () => { + const deferred = createDeferred(); + let signal: AbortSignal | undefined; + + await page.render( + { + signal = received; + return deferred.promise; + }} + />, + ); + + await page.elementLocator(getEditableCell(1, 1)).dblClick(); + await userEvent.keyboard('NewName'); + await userEvent.keyboard('{Enter}'); + + await expect.element(page.getByRole('textbox').first()).toBeDisabled(); + expect(signal?.aborted).toBe(false); + + await page.elementLocator(getEditableCell(2, 1)).dblClick(); + + expect(signal?.aborted).toBe(true); + + const editingInput = page.getByRole('textbox').first(); + await expect.element(editingInput).toBeVisible(); + await expect.element(editingInput).toHaveValue('Kevin'); + }); +}); diff --git a/packages/design-system/src/components/ds-table/components/edit/cell-editors/ds-table-edit-cell-checkbox.tsx b/packages/design-system/src/components/ds-table/components/edit/cell-editors/ds-table-edit-cell-checkbox.tsx index 2f1701357..5ac5ffbcf 100644 --- a/packages/design-system/src/components/ds-table/components/edit/cell-editors/ds-table-edit-cell-checkbox.tsx +++ b/packages/design-system/src/components/ds-table/components/edit/cell-editors/ds-table-edit-cell-checkbox.tsx @@ -13,14 +13,14 @@ export const DsTableEditCellCheckbox = ({ cellContext, disabled, }: DsTableEditCellCheckboxProps) => { - const { value, setValue } = useCellEditor({ + const { value, setValue, isPending } = useCellEditor({ cellContext, }); return ( setValue(checked === true)} /> ); diff --git a/packages/design-system/src/components/ds-table/components/edit/cell-editors/ds-table-edit-cell-date.tsx b/packages/design-system/src/components/ds-table/components/edit/cell-editors/ds-table-edit-cell-date.tsx index 9fa29b7d0..48ddd88e8 100644 --- a/packages/design-system/src/components/ds-table/components/edit/cell-editors/ds-table-edit-cell-date.tsx +++ b/packages/design-system/src/components/ds-table/components/edit/cell-editors/ds-table-edit-cell-date.tsx @@ -22,7 +22,7 @@ export const DsTableEditCellDate = ({ max, placeholder, }: DsTableEditCellDateProps) => { - const { value, setValue, error } = useCellEditor({ + const { value, setValue, error, isPending } = useCellEditor({ cellContext, }); @@ -34,6 +34,7 @@ export const DsTableEditCellDate = ({ min={min} max={max} placeholder={placeholder} + disabled={isPending} onChange={setValue} slotProps={ error diff --git a/packages/design-system/src/components/ds-table/components/edit/cell-editors/ds-table-edit-cell-number.tsx b/packages/design-system/src/components/ds-table/components/edit/cell-editors/ds-table-edit-cell-number.tsx index b6e742b8c..508506f0e 100644 --- a/packages/design-system/src/components/ds-table/components/edit/cell-editors/ds-table-edit-cell-number.tsx +++ b/packages/design-system/src/components/ds-table/components/edit/cell-editors/ds-table-edit-cell-number.tsx @@ -20,7 +20,7 @@ export const DsTableEditCellNumber = ({ max, step, }: DsTableEditCellNumberProps) => { - const { value, setValue, error } = useCellEditor({ + const { value, setValue, error, isPending } = useCellEditor({ cellContext, }); @@ -44,6 +44,7 @@ export const DsTableEditCellNumber = ({ min={min} max={max} step={step} + disabled={isPending} onValueChange={setValue} /> diff --git a/packages/design-system/src/components/ds-table/components/edit/cell-editors/ds-table-edit-cell-select.tsx b/packages/design-system/src/components/ds-table/components/edit/cell-editors/ds-table-edit-cell-select.tsx index 8c20e4fc7..e6be38d05 100644 --- a/packages/design-system/src/components/ds-table/components/edit/cell-editors/ds-table-edit-cell-select.tsx +++ b/packages/design-system/src/components/ds-table/components/edit/cell-editors/ds-table-edit-cell-select.tsx @@ -18,7 +18,7 @@ export const DsTableEditCellSelect = ({ options, placeholder, }: DsTableEditCellSelectProps) => { - const { value, setValue, error } = useCellEditor({ + const { value, setValue, error, isPending } = useCellEditor({ cellContext, }); @@ -29,6 +29,7 @@ export const DsTableEditCellSelect = ({ value={value} options={options} placeholder={placeholder} + disabled={isPending} onValueChange={setValue} /> diff --git a/packages/design-system/src/components/ds-table/components/edit/cell-editors/ds-table-edit-cell-text.tsx b/packages/design-system/src/components/ds-table/components/edit/cell-editors/ds-table-edit-cell-text.tsx index c5d29e309..d3f9a12d9 100644 --- a/packages/design-system/src/components/ds-table/components/edit/cell-editors/ds-table-edit-cell-text.tsx +++ b/packages/design-system/src/components/ds-table/components/edit/cell-editors/ds-table-edit-cell-text.tsx @@ -19,7 +19,7 @@ export const DsTableEditCellText = ({ placeholder, maxLength, }: DsTableEditCellTextProps) => { - const { value, setValue, error } = useCellEditor({ + const { value, setValue, error, isPending } = useCellEditor({ cellContext, }); @@ -42,6 +42,7 @@ export const DsTableEditCellText = ({ value={value} placeholder={placeholder} maxLength={maxLength} + disabled={isPending} onValueChange={setValue} slots={error ? { endAdornment: } : undefined} /> diff --git a/packages/design-system/src/components/ds-table/components/edit/ds-table-editing-cell/ds-table-editing-cell.tsx b/packages/design-system/src/components/ds-table/components/edit/ds-table-editing-cell/ds-table-editing-cell.tsx index 98cd64af9..302069f0f 100644 --- a/packages/design-system/src/components/ds-table/components/edit/ds-table-editing-cell/ds-table-editing-cell.tsx +++ b/packages/design-system/src/components/ds-table/components/edit/ds-table-editing-cell/ds-table-editing-cell.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, type FormEvent } from 'react'; +import { useEffect, useRef, type SubmitEvent } from 'react'; import { flexRender } from '@tanstack/react-table'; import { DsButtonV3 } from '../../../../ds-button-v3'; import { DsStack } from '../../../../ds-stack'; @@ -7,13 +7,20 @@ import styles from './ds-table-editing-cell.module.scss'; import type { DsTableEditingCellProps } from './ds-table-editing-cell.types'; export const DsTableEditingCell = ({ cell }: DsTableEditingCellProps) => { - const { commit, cancel } = useDsTableContext(); + const { commit, cancel, editing } = useDsTableContext(); const columnDef = cell.column.columnDef; const editAnchorRef = useRef(null); - const handleSubmit = (event: FormEvent) => { + const isActiveCell = editing?.cell.id === cell.id; + const isPending = isActiveCell && editing.pending; + const hasError = isActiveCell && editing.error !== null; + + const handleSubmit = (event: SubmitEvent) => { event.preventDefault(); event.stopPropagation(); + if (isPending || hasError) { + return; + } commit(); }; @@ -62,6 +69,8 @@ export const DsTableEditingCell = ({ cell }: DsTableEditingCellPr variant="tertiary" size="small" icon="check" + loading={isPending} + disabled={hasError} aria-label="Confirm edit" onClick={(event) => { event.stopPropagation(); diff --git a/packages/design-system/src/components/ds-table/context/ds-table-context.ts b/packages/design-system/src/components/ds-table/context/ds-table-context.ts index 47b6193d2..59b0e8c47 100644 --- a/packages/design-system/src/components/ds-table/context/ds-table-context.ts +++ b/packages/design-system/src/components/ds-table/context/ds-table-context.ts @@ -6,6 +6,8 @@ export interface EditingState { cell: Cell; draftValue: TValue; error: string | null; + /** True while an async `onCellEdit` commit Promise is pending for this cell. */ + pending: boolean; } export interface DsTableContextType extends Partial> { @@ -46,12 +48,27 @@ export const useDsTableContext = (): DsTableContextType; }; +/** Shown when an async `onCellEdit` commit rejects with a non-`Error` reason. */ +const EDIT_FALLBACK_ERROR = 'Save failed'; + +const isThenable = (value: unknown): value is Promise => + typeof value === 'object' && value !== null && typeof (value as { then?: unknown }).then === 'function'; + +/** Prefer a thrown `Error`'s message; fall back to a generic string otherwise. */ +const extractErrorMessage = (reason: unknown, fallback: string): string => + reason instanceof Error && reason.message ? reason.message : fallback; + /** * Hoisted editing state for single-cell-at-a-time inline editing. * Lives on {@link DsTableContext} so cell renderers and editors share one provider. */ export const useEditingState = ( - onCellEdit?: (row: TData, columnId: string, value: TValue) => void, + onCellEdit?: ( + row: TData, + columnId: string, + value: TValue, + signal: AbortSignal, + ) => string | null | undefined | Promise, onCellValidate?: (row: TData, columnId: string, value: TValue) => string | null, ) => { const [editing, setEditing] = useState | null>(null); @@ -65,48 +82,143 @@ export const useEditingState = ( const editingRef = useRef(editing); editingRef.current = editing; - const beginEdit = useCallback((cell: Cell) => { - const current = editingRef.current; - if (current) { - const isSameCell = current.cell.row.id === cell.row.id && current.cell.column.id === cell.column.id; - if (isSameCell) { - return; - } - } - setEditing({ - cell, - draftValue: cell.getValue(), - error: null, - }); - }, []); + // Bumped whenever the active editing session changes (begin/cancel/commit). + // A pending async commit captures the current generation token and is discarded + // on resolve if it no longer matches — guards against stale writes and repeat + // commits. + const generationRef = useRef(0); - const setDraft = useCallback((value: TValue) => { - const current = editingRef.current; - if (!current) { - return; - } - setEditing({ ...current, draftValue: value, error: null }); + // AbortController for the in-flight commit attempt. Aborted when the attempt is + // superseded (Cancel/Escape or switching cells) so consumers can cancel requests. + const abortRef = useRef(null); + + const abortPending = useCallback(() => { + abortRef.current?.abort(); + abortRef.current = null; }, []); - const commit = useCallback((overrideValue?: TValue) => { + const beginEdit = useCallback( + (cell: Cell) => { + const current = editingRef.current; + if (current) { + const isSameCell = current.cell.row.id === cell.row.id && current.cell.column.id === cell.column.id; + if (isSameCell) { + return; + } + } + abortPending(); + generationRef.current += 1; + setEditing({ + cell, + draftValue: cell.getValue(), + error: null, + pending: false, + }); + }, + [abortPending], + ); + + const setDraft = useCallback((value: TValue) => { const current = editingRef.current; if (!current) { return; } - const valueToCommit = overrideValue !== undefined ? overrideValue : current.draftValue; + // Live, per-keystroke validation: surface (or clear) the inline error as the + // user types so the Confirm button can gate on it. const error = - onCellValidateRef.current?.(current.cell.row.original, current.cell.column.id, valueToCommit) ?? null; - if (error !== null) { - setEditing({ ...current, draftValue: valueToCommit, error }); - return; - } - onCellEditRef.current?.(current.cell.row.original, current.cell.column.id, valueToCommit); - setEditing(null); + onCellValidateRef.current?.(current.cell.row.original, current.cell.column.id, value) ?? null; + setEditing({ ...current, draftValue: value, error }); }, []); + const commit = useCallback( + (overrideValue?: TValue) => { + const current = editingRef.current; + if (!current || current.pending) { + return; + } + + const valueToCommit = overrideValue !== undefined ? overrideValue : current.draftValue; + + // Synchronous pre-commit gate. Also covers immediate-commit editors + // (checkbox/select) that pass an override value without going through + // setDraft, so the live error never had a chance to run. + const validationError = + onCellValidateRef.current?.(current.cell.row.original, current.cell.column.id, valueToCommit) ?? null; + if (validationError !== null) { + setEditing({ ...current, draftValue: valueToCommit, error: validationError, pending: false }); + return; + } + + generationRef.current += 1; + const token = generationRef.current; + + abortPending(); + const controller = new AbortController(); + abortRef.current = controller; + const { signal } = controller; + + const isStale = () => generationRef.current !== token; + + // Release the controller once this attempt reaches a terminal state, unless a + // newer attempt has already replaced it. + const settle = () => { + if (abortRef.current === controller) { + abortRef.current = null; + } + }; + + const showError = (error: string) => { + settle(); + if (isStale()) { + return; + } + const live = editingRef.current; + setEditing(live ? { ...live, draftValue: valueToCommit, error, pending: false } : null); + }; + + // Persist via the fallible/async commit handler. + const editResult = onCellEditRef.current?.( + current.cell.row.original, + current.cell.column.id, + valueToCommit, + signal, + ); + + const applyEditResult = (error: string | null) => { + if (error !== null) { + showError(error); + return; + } + settle(); + if (isStale()) { + return; + } + setEditing(null); + }; + + if (!isThenable(editResult)) { + applyEditResult(editResult ?? null); + return; + } + + setEditing({ ...current, draftValue: valueToCommit, error: null, pending: true }); + editResult.then( + (value) => { + applyEditResult(value ?? null); + }, + (reason: unknown) => { + applyEditResult(extractErrorMessage(reason, EDIT_FALLBACK_ERROR)); + }, + ); + }, + [abortPending], + ); + const cancel = useCallback(() => { + abortPending(); + generationRef.current += 1; setEditing(null); - }, []); + }, [abortPending]); return { editing, beginEdit, setDraft, commit, cancel }; }; diff --git a/packages/design-system/src/components/ds-table/ds-table.types.ts b/packages/design-system/src/components/ds-table/ds-table.types.ts index 288b77812..7f2141a83 100644 --- a/packages/design-system/src/components/ds-table/ds-table.types.ts +++ b/packages/design-system/src/components/ds-table/ds-table.types.ts @@ -511,53 +511,28 @@ export interface DsDataTableProps { locale?: Partial; /** - * Callback fired when an editable cell commits a new value via one of the - * `DsTableEditCell*` wrappers or the `useCellEditor` hook. The parent is - * expected to update the row data in response. - * - * Commits occur on confirm (check button or Enter). Opening another cell - * discards the previous draft without calling this callback. - * - * @param row - The row data of the cell that was edited - * @param columnId - The id of the column whose cell was edited - * @param value - The new committed value - * - * @example - * ```tsx - * const [people, setPeople] = useState(initialPeople); - * - * { - * setPeople((rows) => - * rows.map((r) => (r.id === row.id ? { ...r, [columnId]: value } : r)), - * ); - * }} - * /> - * ``` - */ - onCellEdit?: (row: TData, columnId: string, value: TValue) => void; - - /** - * Validates a cell value before it is committed. Return `null` to allow the - * commit (which fires `onCellEdit`), or an error message string to reject it - * and keep the cell in edit mode. Synchronous only. - * - * - * @param row - The row data of the cell being edited - * @param columnId - The id of the column whose cell is being edited - * @param value - The candidate value to validate - * - * @example - * ```tsx - * validateField(columnId)(value, row)} - * onCellEdit={...} - * /> - * ``` + * Commits an edited cell value. Runs when the user confirms (check button or + * Enter) after `onCellValidate` passes. This is the authoritative commit: use it + * for server-side validation and persistence. Return `void`/`null` to accept + * (closes the editor) or an error `string` to reject (keeps the cell open with + * the message); may be async. While a returned Promise is pending the editor is + * locked. `signal` is aborted when the edit is cancelled (Cancel/Escape) or + * superseded by opening another cell, so in-flight requests can be cancelled. + */ + onCellEdit?: ( + row: TData, + columnId: string, + value: TValue, + signal: AbortSignal, + ) => string | null | undefined | Promise; + + /** + * Synchronous, per-keystroke validation for the active editor. Runs live on + * every draft change and once more as a pre-commit gate before `onCellEdit`. + * Return `null` to allow or an error `string` to reject. A live error is shown + * inline and disables the Confirm button; `onCellEdit` never runs while the + * value is invalid. Keep it pure and cheap — for async or server-side checks, + * use `onCellEdit` instead. */ onCellValidate?: (row: TData, columnId: string, value: TValue) => string | null; } diff --git a/packages/design-system/src/components/ds-table/hooks/use-cell-editor.ts b/packages/design-system/src/components/ds-table/hooks/use-cell-editor.ts index 32bfb5717..fd6c29eb4 100644 --- a/packages/design-system/src/components/ds-table/hooks/use-cell-editor.ts +++ b/packages/design-system/src/components/ds-table/hooks/use-cell-editor.ts @@ -19,8 +19,10 @@ export interface UseCellEditorContainerProps { export interface UseCellEditorResult { /** The in-progress draft value for the active editor. */ value: TValue; - /** Validation error from the most recent commit attempt, if any. */ + /** Current validation error — from live `onCellValidate` or the most recent commit attempt, if any. */ error: string | null; + /** True while an async `onCellEdit` commit Promise is pending for this cell. */ + isPending: boolean; /** Update the in-progress draft value. */ setValue: (value: TValue) => void; /** Validate and commit the draft. */ @@ -48,6 +50,7 @@ export const useCellEditor = ({ ctx.editing !== null && ctx.editing.cell.row.id === row.id && ctx.editing.cell.column.id === column.id; const value = isActiveCell && ctx.editing ? ctx.editing.draftValue : getValue(); const error = isActiveCell && ctx.editing ? ctx.editing.error : null; + const isPending = isActiveCell && ctx.editing ? ctx.editing.pending : false; const setValue = (next: TValue) => { ctx.setDraft(next); @@ -68,6 +71,7 @@ export const useCellEditor = ({ return { value, error, + isPending, setValue, commit, cancel, diff --git a/packages/design-system/src/components/ds-table/stories/ds-table-editable.stories.tsx b/packages/design-system/src/components/ds-table/stories/ds-table-editable.stories.tsx index d882f9620..5b365d5d9 100644 --- a/packages/design-system/src/components/ds-table/stories/ds-table-editable.stories.tsx +++ b/packages/design-system/src/components/ds-table/stories/ds-table-editable.stories.tsx @@ -54,28 +54,6 @@ export const Editable: Story = { complicated: 'Complicated', }; - const personSchema = z - .object({ - firstName: z.string().trim().min(1, 'First name is required').max(50, 'Max 50 characters'), - lastName: z.string().trim().min(1, 'Last name is required').max(50, 'Max 50 characters'), - age: z.number().int('Whole number only').min(18, 'Must be ≥ 18').max(120, 'Must be ≤ 120'), - visits: z.number().int('Whole number only').min(0, 'Must be ≥ 0').max(10_000, 'Must be ≤ 10000'), - status: z.enum(['single', 'relationship', 'complicated']), - progress: z.number().int('Whole number only').min(0, 'Must be ≥ 0').max(100, 'Must be ≤ 100'), - }) - .refine((row) => !(row.status === 'complicated' && row.progress === 100), { - path: ['progress'], - message: 'A complicated profile can’t be 100% complete', - }); - - const validateField = (columnId: keyof Person, value: unknown, row: Person): string | null => { - const result = personSchema.safeParse({ ...row, [columnId]: value }); - if (result.success) { - return null; - } - return result.error.issues.find((issue) => issue.path[0] === columnId)?.message ?? null; - }; - const progressPresets = [25, 50, 75, 100]; const ProgressEditor = ({ cellContext }: { cellContext: CellContext }) => { @@ -179,7 +157,6 @@ export const Editable: Story = { onRowClick={fn()} primaryRowActions={[{ icon: 'delete_outline', label: 'Delete', onClick: fn() }]} secondaryRowActions={[{ icon: 'info', label: 'Details', onClick: fn() }]} - onCellValidate={(row, columnId, value) => validateField(columnId as keyof Person, value, row)} onCellEdit={(row, columnId, value) => { setData((rows) => rows.map((person) => (person.id === row.id ? { ...person, [columnId]: value } : person)), @@ -189,3 +166,147 @@ export const Editable: Story = { ); }, }; + +/** + * `onCellValidate` runs synchronously on every keystroke. It shows an inline error + * and disables the Confirm button until the value is valid. + * + * Try clearing the first name to see the error appear as you type. + */ +export const LiveValidation: Story = { + parameters: { + docs: { source: { type: 'code' } }, + }, + render: function Render(args) { + const [data, setData] = useState(defaultData); + + const personSchema = z.object({ + firstName: z.string().trim().min(1, 'First name is required').max(50, 'Max 50 characters'), + lastName: z.string().trim().min(1, 'Last name is required').max(50, 'Max 50 characters'), + }); + + const validateField = (columnId: string, value: unknown): string | null => { + const shape: Record = personSchema.shape; + const fieldSchema = shape[columnId]; + if (!fieldSchema) { + return null; + } + const result = fieldSchema.safeParse(value); + return result.success ? null : (result.error.issues[0]?.message ?? null); + }; + + const columns: ColumnDef[] = [ + { + accessorKey: 'id', + header: 'ID', + size: 60, + cell: (info) => {info.getValue() as string}, + }, + { + accessorKey: 'firstName', + header: 'First Name', + cell: (info) => info.getValue(), + editCell: (info: CellContext) => ( + + ), + }, + { + accessorKey: 'lastName', + header: 'Last Name', + cell: (info) => info.getValue(), + editCell: (info: CellContext) => ( + + ), + }, + ]; + + return ( + validateField(columnId, value)} + onCellEdit={(row, columnId, value) => { + setData((rows) => + rows.map((person) => (person.id === row.id ? { ...person, [columnId]: value } : person)), + ); + }} + /> + ); + }, +}; + +/** + * `onCellEdit` may be async: save inside it, resolve to an error `string` to keep + * the cell open or `void`/`null` to commit. The editor locks while saving, and + * `signal` aborts if you Cancel/Escape. Try `taken` to see a server-side rejection. + */ +export const ValidateOnAsyncSave: Story = { + name: 'Validate on Async Save', + parameters: { + docs: { source: { type: 'code' } }, + }, + render: function Render(args) { + const [data, setData] = useState(defaultData); + + const saveFirstName = (value: string, signal: AbortSignal): Promise => + new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + const trimmed = value.trim(); + if (trimmed.length === 0) { + resolve('First name is required'); + return; + } + if (trimmed.toLowerCase() === 'taken') { + resolve('This name is already taken'); + return; + } + resolve(null); + }, 900); + + signal.addEventListener('abort', () => { + clearTimeout(timeout); + reject(new DOMException('Aborted', 'AbortError')); + }); + }); + + const columns: ColumnDef[] = [ + { + accessorKey: 'id', + header: 'ID', + size: 60, + cell: (info) => {info.getValue() as string}, + }, + { + accessorKey: 'firstName', + header: 'First Name', + cell: (info) => info.getValue(), + editCell: (info: CellContext) => ( + + ), + }, + { + accessorKey: 'lastName', + header: 'Last Name', + cell: (info) => info.getValue(), + }, + ]; + + return ( + { + const error = await saveFirstName(value as string, signal); + if (error !== null) { + return error; + } + setData((rows) => + rows.map((person) => (person.id === row.id ? { ...person, [columnId]: value } : person)), + ); + }} + /> + ); + }, +}; From 351b51215b5fd02846d631b0b25008efbe168a3b Mon Sep 17 00:00:00 2001 From: Ihor Romanchuk Date: Wed, 29 Jul 2026 16:03:08 +0200 Subject: [PATCH 2/2] fix tests --- .../components/ds-comment-card/ds-comment-card.stories.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/design-system/src/components/ds-comment-card/ds-comment-card.stories.tsx b/packages/design-system/src/components/ds-comment-card/ds-comment-card.stories.tsx index a4dc104e3..d14a7899c 100644 --- a/packages/design-system/src/components/ds-comment-card/ds-comment-card.stories.tsx +++ b/packages/design-system/src/components/ds-comment-card/ds-comment-card.stories.tsx @@ -193,7 +193,7 @@ export const FullMessage: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const card = canvas.getByRole('button'); + const card = canvas.getByRole('button', { name: /Comment #/i }); const commentText = canvas.getByText(/resource allocation/); await expect(card).toBeInTheDocument(); @@ -222,7 +222,7 @@ export const SingleMessage: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const card = canvas.getByRole('button'); + const card = canvas.getByRole('button', { name: /Comment #/i }); const commentText = canvas.getByText(/This is a short single message comment/); await expect(card).toBeInTheDocument();