Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/hungry-facts-lick.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@drivenets/design-system': patch
---

Add support for live and async validation in `DsTable`
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,73 @@ const confirmEdit = async () => {
await page.getByRole('button', { name: 'Confirm edit' }).click();
};

interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (reason?: unknown) => void;
}

const createDeferred = <T,>(): Deferred<T> => {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((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<string | null> => {
// 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<CommitResult>;
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<TestRow>[] = [
{
accessorKey: 'firstName',
header: 'First Name',
cell: (info) => info.getValue(),
editCell: (info: CellContext<TestRow, string>) => <DsTableEditCellText cellContext={info} />,
},
];

return (
<DsTable
data={rows}
columns={columns}
onCellEdit={(row, columnId, value, signal) => {
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();
Expand Down Expand Up @@ -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(<TableWrapper onCellEdit={onCellEdit} />);

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(<TableWrapper onCellEdit={onCellEdit} />);

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();

Expand Down Expand Up @@ -227,19 +331,22 @@ describe('DsTable Editable Cells', () => {

await page.render(<TableWrapper onCellEdit={onCellEdit} />);

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 () => {
Expand Down Expand Up @@ -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(<AsyncEditWrapper save={() => 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(
<AsyncEditWrapper save={() => 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<string | null>();

await page.render(<AsyncEditWrapper save={() => 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(
<AsyncEditWrapper
save={() => 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(<AsyncEditWrapper save={rejectWithString} onCommitted={onCommitted} />);

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<string | null>();
let signal: AbortSignal | undefined;

await page.render(
<AsyncEditWrapper
save={(_value, received) => {
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<string | null>();
let signal: AbortSignal | undefined;

await page.render(
<AsyncEditWrapper
save={(_value, received) => {
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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,14 @@ export const DsTableEditCellCheckbox = <TData extends RowData>({
cellContext,
disabled,
}: DsTableEditCellCheckboxProps<TData>) => {
const { value, setValue } = useCellEditor<TData, boolean>({
const { value, setValue, isPending } = useCellEditor<TData, boolean>({
cellContext,
});

return (
<DsCheckbox
checked={value}
disabled={disabled}
disabled={disabled || isPending}
onCheckedChange={(checked) => setValue(checked === true)}
/>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export const DsTableEditCellDate = <TData extends RowData>({
max,
placeholder,
}: DsTableEditCellDateProps<TData>) => {
const { value, setValue, error } = useCellEditor<TData, DsTableEditCellDateValue>({
const { value, setValue, error, isPending } = useCellEditor<TData, DsTableEditCellDateValue>({
cellContext,
});

Expand All @@ -34,6 +34,7 @@ export const DsTableEditCellDate = <TData extends RowData>({
min={min}
max={max}
placeholder={placeholder}
disabled={isPending}
onChange={setValue}
slotProps={
error
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export const DsTableEditCellNumber = <TData extends RowData>({
max,
step,
}: DsTableEditCellNumberProps<TData>) => {
const { value, setValue, error } = useCellEditor<TData, number>({
const { value, setValue, error, isPending } = useCellEditor<TData, number>({
cellContext,
});

Expand All @@ -44,6 +44,7 @@ export const DsTableEditCellNumber = <TData extends RowData>({
min={min}
max={max}
step={step}
disabled={isPending}
onValueChange={setValue}
/>
</TableEditFormControl>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export const DsTableEditCellSelect = <TData extends RowData>({
options,
placeholder,
}: DsTableEditCellSelectProps<TData>) => {
const { value, setValue, error } = useCellEditor<TData, string>({
const { value, setValue, error, isPending } = useCellEditor<TData, string>({
cellContext,
});

Expand All @@ -29,6 +29,7 @@ export const DsTableEditCellSelect = <TData extends RowData>({
value={value}
options={options}
placeholder={placeholder}
disabled={isPending}
onValueChange={setValue}
/>
</TableEditFormControl>
Expand Down
Loading
Loading