Skip to content

Commit 9edf9af

Browse files
committed
test(ui): pin refused-cell marks, the whole save-bar refusal and the grid's pre-send rule check
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibxQtKRyLyL6eK14pgY
1 parent fa69e70 commit 9edf9af

8 files changed

Lines changed: 423 additions & 58 deletions
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectstack-ai/objectstack#27520 (row 6) — a refused inline save says WHY,
11+
* WHERE: the save bar says every reason whole, one line each, and the cells
12+
* the refusal names are marked.
13+
*
14+
* The server refuses a row field by field (`VALIDATION_FAILED` with one
15+
* `fields[]` entry per failed rule). The save bar used to cut that to one
16+
* truncated line, the rest only in a native tooltip, and no cell said which
17+
* value was wrong.
18+
*/
19+
import { describe, it, expect, vi } from 'vitest';
20+
import { fireEvent, waitFor, within } from '@testing-library/react';
21+
import '@testing-library/jest-dom';
22+
import { renderComponent } from './test-utils';
23+
// Registers the renderers at module scope, NOT inside a `beforeAll`; see
24+
// object-ui/no-dynamic-import-in-test-hook (objectui#3010/#3021).
25+
import '../renderers';
26+
27+
const schema = {
28+
type: 'data-table' as const,
29+
editable: true,
30+
singleClickEdit: true,
31+
columns: [
32+
{ header: 'Title', accessorKey: 'title' },
33+
{ header: 'Estimate', accessorKey: 'estimate', type: 'number' },
34+
],
35+
data: [
36+
{ id: '1', title: 'Ingest pipeline', estimate: 60 },
37+
{ id: '2', title: 'Warehouse schema', estimate: 33 },
38+
],
39+
} as any;
40+
41+
type Entry = { rowIndex: number; changes: Record<string, any>; row: any };
42+
43+
const TITLE = 'Title is required and cannot be cleared';
44+
const ESTIMATE = 'Estimate (h) must be ≥ 0';
45+
46+
/** The refusal `PATCH /data/:object/:id` answers, as `@objectstack/client` decorates it. */
47+
function fieldRefusal(fields: Array<{ field: string; message: string }>) {
48+
const message = fields.map((f) => f.message).join('; ');
49+
return Object.assign(new Error(message), {
50+
code: 'VALIDATION_FAILED',
51+
httpStatus: 400,
52+
details: { error: message, code: 'VALIDATION_FAILED', fields: fields.map((f) => ({ ...f, code: 'invalid' })) },
53+
});
54+
}
55+
56+
const cellOf = (container: HTMLElement, rowNumber: number, colNumber: number) =>
57+
container.querySelectorAll('tbody tr')[rowNumber].querySelectorAll('td')[colNumber] as HTMLElement;
58+
59+
/** Stage a new value in a body cell. */
60+
function stage(container: HTMLElement, rowNumber: number, colNumber: number, value: string) {
61+
const cell = cellOf(container, rowNumber, colNumber);
62+
fireEvent.click(cell);
63+
const input = cell.querySelector('input') as HTMLInputElement;
64+
fireEvent.change(input, { target: { value } });
65+
fireEvent.blur(input);
66+
}
67+
68+
const refusalBar = (container: HTMLElement) =>
69+
container.querySelector('[data-testid="table-save-refusal"]') as HTMLElement | null;
70+
71+
describe('data-table — a refused save says every reason and marks its cells (#27520)', () => {
72+
it('Save All: one line per reason, whole, and the two named cells of the refused row marked', async () => {
73+
const onBatchSave = vi.fn(async (entries: Entry[]) => ({
74+
failed: entries
75+
.filter((e) => e.row.id === '1')
76+
.map((e) => ({
77+
rowIndex: e.rowIndex,
78+
error: fieldRefusal([{ field: 'title', message: TITLE }, { field: 'estimate', message: ESTIMATE }]),
79+
})),
80+
}));
81+
const { container, getByText } = renderComponent({ ...schema, onBatchSave });
82+
83+
stage(container, 0, 0, '');
84+
stage(container, 0, 1, '-5');
85+
stage(container, 1, 1, '31');
86+
fireEvent.click(getByText(/Save All/));
87+
88+
await waitFor(() => expect(refusalBar(container)).not.toBeNull());
89+
const bar = refusalBar(container)!;
90+
expect(bar).toHaveAttribute('role', 'alert');
91+
expect(within(bar).getAllByRole('listitem').map((li) => li.textContent)).toEqual([TITLE, ESTIMATE]);
92+
// Said whole: not the `; `-joined summary, and not cut.
93+
expect(bar.textContent).not.toContain('; ');
94+
expect(bar.querySelector('.truncate')).toBeNull();
95+
96+
// The refused row's two cells carry their reasons; the saved row's none.
97+
expect(cellOf(container, 0, 0)).toHaveAttribute('aria-invalid', 'true');
98+
expect(cellOf(container, 0, 0)).toHaveAttribute('data-cell-error', TITLE);
99+
expect(cellOf(container, 0, 1)).toHaveAttribute('aria-invalid', 'true');
100+
expect(cellOf(container, 0, 1)).toHaveAttribute('data-cell-error', ESTIMATE);
101+
expect(container.querySelectorAll('tbody tr')[1].querySelector('[aria-invalid="true"]')).toBeNull();
102+
});
103+
104+
it('editing a marked cell takes its mark away and leaves the other', async () => {
105+
const onBatchSave = vi.fn(async (entries: Entry[]) => ({
106+
failed: entries.map((e) => ({
107+
rowIndex: e.rowIndex,
108+
error: fieldRefusal([{ field: 'title', message: TITLE }, { field: 'estimate', message: ESTIMATE }]),
109+
})),
110+
}));
111+
const { container, getByText } = renderComponent({ ...schema, onBatchSave });
112+
113+
stage(container, 0, 0, '');
114+
stage(container, 0, 1, '-5');
115+
fireEvent.click(getByText(/Save All/));
116+
await waitFor(() => expect(cellOf(container, 0, 1)).toHaveAttribute('aria-invalid', 'true'));
117+
118+
stage(container, 0, 1, '5');
119+
120+
await waitFor(() => expect(cellOf(container, 0, 1)).not.toHaveAttribute('aria-invalid'));
121+
expect(cellOf(container, 0, 0)).toHaveAttribute('aria-invalid', 'true');
122+
});
123+
124+
it('a single-row save marks the cell its refusal names, and a later save that lands clears it', async () => {
125+
const onRowSave = vi
126+
.fn()
127+
.mockRejectedValueOnce(fieldRefusal([{ field: 'estimate', message: ESTIMATE }]))
128+
.mockResolvedValueOnce(undefined);
129+
// `rowActions` draws the edit column that holds the row's own save button.
130+
const { container } = renderComponent({ ...schema, rowActions: true, onRowSave });
131+
132+
stage(container, 0, 1, '-5');
133+
// The row's own save button (the edit column's save icon).
134+
const saveRow = () =>
135+
fireEvent.click(within(container.querySelectorAll('tbody tr')[0] as HTMLElement).getByTitle(/^Save row$/i));
136+
saveRow();
137+
138+
await waitFor(() => expect(cellOf(container, 0, 1)).toHaveAttribute('data-cell-error', ESTIMATE));
139+
expect(refusalBar(container)?.textContent).toContain(ESTIMATE);
140+
141+
stage(container, 0, 1, '5');
142+
saveRow();
143+
await waitFor(() => expect(onRowSave).toHaveBeenCalledTimes(2));
144+
await waitFor(() => expect(container.querySelector('tbody [aria-invalid="true"]')).toBeNull());
145+
});
146+
147+
it('a whole-batch rejection says its reasons but marks no cell: no row is named', async () => {
148+
const onBatchSave = vi.fn(async () => {
149+
throw fieldRefusal([{ field: 'title', message: TITLE }]);
150+
});
151+
const { container, getByText } = renderComponent({ ...schema, onBatchSave });
152+
153+
stage(container, 0, 0, '');
154+
stage(container, 1, 1, '31');
155+
fireEvent.click(getByText(/Save All/));
156+
157+
await waitFor(() => expect(refusalBar(container)?.textContent).toContain(TITLE));
158+
expect(container.querySelector('tbody [aria-invalid="true"]')).toBeNull();
159+
});
160+
});

‎ui/packages/plugin-form/src/MasterDetailForm.outcomeToastSupersede.test.tsx‎

Lines changed: 21 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -53,15 +53,11 @@
5353
* decided, whether that retry succeeds or fails again. Driven, not assumed:
5454
* the second `it` below IS path 2, and it is green post-#7354.
5555
*
56-
* The parent `<ObjectForm>`'s own renderer also toasts a rejected write, under
57-
* its OWN `form-outcome:` id, and retires it at the top of the next submit
58-
* (objectui#7252). Before objectui#7354 that toast was already superseded and
59-
* was not what this file pinned; it was visible in the pre-fix registry below
60-
* as the second error entry. Post-#7354 it is the ONLY error entry — the
61-
* duplication objectui#7354 fixed is gone, so `distinctOnScreen` below now
62-
* sees the same one-error reality `onScreen` does; see
63-
* `MasterDetailForm.singleRefusalRaiser.test.tsx` for the pin that asserts
64-
* that directly on the raw (undeduplicated) registry.
56+
* The parent `<ObjectForm>`'s own renderer says a rejected write once, by
57+
* its banner, and raises no error toast for it (objectstack-ai/objectstack#27520);
58+
* it retires any outcome toast of its own at the top of the next submit
59+
* (objectui#7252). `MasterDetailForm.singleRefusalRaiser.test.tsx` pins the
60+
* one-message count directly.
6561
*
6662
* ## Why the pin models sonner rather than counting calls
6763
*
@@ -189,27 +185,16 @@ const save = () => fireEvent.click(screen.getByRole('button', { name: /create/i
189185
const onScreen = (): ToastEntry[] => [...toastRegistry.values()];
190186

191187
/**
192-
* What the screen SAYS, with each distinct statement counted once.
193-
*
194-
* Before objectui#7354: a refused master-detail save put the SAME refusal on
195-
* screen twice — `handleError` here raised one, and the parent
196-
* `<ObjectForm>`'s renderer raised its own for the write it re-threw. That
197-
* duplication was a separate defect from the one THIS file pins, so the
198-
* assertions below were written to deliberately not depend on HOW MANY
199-
* raisers reported an outcome — only on WHICH outcomes remain readable.
200-
*
201-
* objectui#7354 fixed that duplication (removed `handleError`'s own toast),
202-
* so today `distinctOnScreen()` and the raw `onScreen()` agree on a refused
203-
* save — there is only the one raiser left. The dedup here is kept anyway:
204-
* this file's OWN subject (a later outcome superseding an earlier one on THIS
205-
* form) never depended on how many raisers were active, and
206-
* `MasterDetailForm.singleRefusalRaiser.test.tsx` is where the raw,
207-
* undeduplicated count is the point of the assertion.
188+
* The refusal as the form says it: its banner, the one message a refused save
189+
* raises (objectstack-ai/objectstack#27520) — no error toast repeats it.
208190
*/
209-
const distinctOnScreen = (): ToastEntry[] => {
210-
const seen = new Map<string, ToastEntry>();
211-
for (const t of onScreen()) seen.set(`${t.type}:${t.message}`, t);
212-
return [...seen.values()];
191+
const refusalBanners = (): HTMLElement[] =>
192+
screen.queryAllByRole('alert').filter((el) => (el.textContent ?? '').includes(REFUSAL));
193+
194+
/** The refusal is said once, by its banner, and by no toast. */
195+
const saidOnceByBanner = () => {
196+
expect(refusalBanners()).toHaveLength(1);
197+
expect(onScreen().filter((t) => t.type === 'error')).toEqual([]);
213198
};
214199

215200
beforeEach(() => {
@@ -228,7 +213,7 @@ describe('MasterDetailForm — a later save outcome supersedes this form’s ear
228213
fireEvent.change(await headerInput(container), { target: { value: 'PO-1' } });
229214
save();
230215
await waitFor(() => expect(ds.batchTransaction).toHaveBeenCalledTimes(1));
231-
await waitFor(() => expect(distinctOnScreen()).toEqual([{ type: 'error', message: REFUSAL }]));
216+
await waitFor(saidOnceByBanner);
232217

233218
// The user corrects the input and saves again inside the refusal's lifetime.
234219
fireEvent.change(await headerInput(container), { target: { value: 'PO-1b' } });
@@ -237,6 +222,7 @@ describe('MasterDetailForm — a later save outcome supersedes this form’s ear
237222

238223
// The card's own sentence: one form, one outcome on screen.
239224
await waitFor(() => expect(onScreen()).toEqual([{ type: 'success', message: 'Created' }]));
225+
expect(refusalBanners()).toEqual([]);
240226
});
241227

242228
it('retires the refusal even when the host owns confirmation (no built-in success toast)', async () => {
@@ -251,7 +237,7 @@ describe('MasterDetailForm — a later save outcome supersedes this form’s ear
251237
fireEvent.change(await headerInput(container), { target: { value: 'PO-2' } });
252238
save();
253239
await waitFor(() => expect(ds.batchTransaction).toHaveBeenCalledTimes(1));
254-
await waitFor(() => expect(distinctOnScreen()).toEqual([{ type: 'error', message: REFUSAL }]));
240+
await waitFor(saidOnceByBanner);
255241

256242
fireEvent.change(await headerInput(container), { target: { value: 'PO-2b' } });
257243
save();
@@ -260,6 +246,7 @@ describe('MasterDetailForm — a later save outcome supersedes this form’s ear
260246

261247
// Nothing left asserting the save was refused.
262248
await waitFor(() => expect(onScreen()).toEqual([]));
249+
expect(refusalBanners()).toEqual([]);
263250
});
264251

265252
it('does not stack a second refusal when the retry is refused too', async () => {
@@ -273,15 +260,14 @@ describe('MasterDetailForm — a later save outcome supersedes this form’s ear
273260
fireEvent.change(await headerInput(container), { target: { value: 'PO-3' } });
274261
save();
275262
await waitFor(() => expect(ds.batchTransaction).toHaveBeenCalledTimes(1));
276-
await waitFor(() => expect(distinctOnScreen()).toEqual([{ type: 'error', message: REFUSAL }]));
277-
// How many raisers reported this refusal is not this file's subject; that
278-
// the SECOND attempt adds none of its own is.
263+
await waitFor(saidOnceByBanner);
264+
// That the SECOND attempt adds no message of its own is this file's subject.
279265
const afterFirstAttempt = onScreen().length;
280266

281267
fireEvent.change(await headerInput(container), { target: { value: 'PO-3b' } });
282268
save();
283269
await waitFor(() => expect(ds.batchTransaction).toHaveBeenCalledTimes(2));
284-
await waitFor(() => expect(distinctOnScreen()).toEqual([{ type: 'error', message: REFUSAL }]));
270+
await waitFor(saidOnceByBanner);
285271
expect(onScreen()).toHaveLength(afterFirstAttempt);
286272
});
287273
});

‎ui/packages/plugin-form/src/MasterDetailForm.singleRefusalRaiser.test.tsx‎

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -147,23 +147,24 @@ afterEach(() => {
147147
});
148148

149149
describe('MasterDetailForm — a refused save reports its refusal exactly once (objectui#7354)', () => {
150-
it('leaves exactly one error toast on screen, not one per raiser', async () => {
150+
it('says it once, by the form banner, with no error toast from any raiser', async () => {
151151
const ds = makeDataSource();
152152
const { container } = masterDetail(ds);
153153

154154
fireEvent.change(await headerInput(container), { target: { value: 'PO-1' } });
155155
save();
156156
await waitFor(() => expect(ds.batchTransaction).toHaveBeenCalledTimes(1));
157157

158-
// Wait for the outcome to settle, then read the raw (undeduplicated)
159-
// registry: this is the count that told the two-raiser defect apart from
160-
// the fix. Give the throw its full trip through `<ObjectForm>`'s catch
161-
// and back up into the form renderer's before asserting.
162-
await waitFor(() => expect(onScreen().length).toBeGreaterThan(0));
163-
164-
const entries = onScreen();
165-
// The lit control: the surviving entry must still be the refusal, so this
166-
// cannot pass by silencing every raiser instead of collapsing to one.
167-
expect(entries).toEqual([{ type: 'error', message: REFUSAL }]);
158+
// Wait for the outcome to settle: the throw's full trip through
159+
// `<ObjectForm>`'s catch and back up into the form renderer's, which
160+
// raises the banner (objectstack-ai/objectstack#27520).
161+
await waitFor(() =>
162+
expect(screen.queryAllByRole('alert').filter((el) => (el.textContent ?? '').includes(REFUSAL))).toHaveLength(1),
163+
);
164+
165+
// The raw (undeduplicated) registry: no raiser toasts the refusal as well.
166+
// The lit control is the banner above, so this cannot pass by silencing
167+
// every raiser.
168+
expect(onScreen().filter((entry) => entry.type === 'error')).toEqual([]);
168169
});
169170
});

‎ui/packages/plugin-form/src/MasterDetailForm.test.tsx‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ describe('MasterDetailForm — submit feedback (never silent)', () => {
128128
expect(toastSuccess).not.toHaveBeenCalled();
129129
});
130130

131-
it('shows an error toast when the atomic write fails (rollback)', async () => {
131+
it('says the failure when the atomic write fails (rollback)', async () => {
132132
const batchTransaction = vi.fn().mockRejectedValue(new Error('BATCH_ERROR: rolled back'));
133133
const ds = makeDataSource({ batchTransaction });
134134

@@ -155,7 +155,8 @@ describe('MasterDetailForm — submit feedback (never silent)', () => {
155155
fireEvent.click(screen.getByRole('button', { name: /create/i }));
156156

157157
await waitFor(() => expect(batchTransaction).toHaveBeenCalled());
158-
await waitFor(() => expect(toastError).toHaveBeenCalled()); // <- failure is surfaced
158+
// The failure is surfaced, once, by the form's banner (objectstack-ai/objectstack#27520).
159+
await waitFor(() => expect(screen.getByRole('alert')).toBeTruthy());
159160
expect(toastSuccess).not.toHaveBeenCalled();
160161
});
161162
});
@@ -211,7 +212,7 @@ describe('MasterDetailForm — non-atomic fallback (emulation via runBatchTransa
211212
expect(toastError).not.toHaveBeenCalled();
212213
});
213214

214-
it('create: a failing child create compensates by deleting the just-created parent + error toast', async () => {
215+
it('create: a failing child create compensates by deleting the just-created parent + says the failure', async () => {
215216
const create = vi.fn(async (object: string, data: any) => {
216217
if (object === 'po_line') throw new Error('child create failed');
217218
return { id: 'po1', ...data };
@@ -226,7 +227,7 @@ describe('MasterDetailForm — non-atomic fallback (emulation via runBatchTransa
226227
// Best-effort compensation removes the orphan parent that was created before
227228
// the child failed — the non-atomic path's stand-in for a rollback.
228229
await waitFor(() => expect(del).toHaveBeenCalledWith('po', 'po1'));
229-
await waitFor(() => expect(toastError).toHaveBeenCalled());
230+
await waitFor(() => expect(screen.getByRole('alert')).toBeTruthy());
230231
expect(toastSuccess).not.toHaveBeenCalled();
231232
});
232233
});

‎ui/packages/plugin-form/src/WizardForm.outcomeToastSupersede.test.tsx‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,17 @@ afterEach(() => {
172172
cleanup();
173173
});
174174

175+
/**
176+
* The refusal as the form says it: its banner, the one message a refused
177+
* submit raises (objectstack-ai/objectstack#27520) — no error toast repeats it.
178+
*/
179+
const refusalBanners = (): HTMLElement[] =>
180+
screen.queryAllByRole('alert').filter((el) => (el.textContent ?? '').includes(REFUSAL));
181+
const saidOnceByBanner = () => {
182+
expect(refusalBanners()).toHaveLength(1);
183+
expect(onScreen().filter((t) => t.type === 'error')).toEqual([]);
184+
};
185+
175186
describe('WizardForm — a later outcome supersedes this form’s earlier one', () => {
176187
it('leaves only the success toast after a refused submit is retried and accepted', async () => {
177188
const ds = makeDataSource();
@@ -185,7 +196,7 @@ describe('WizardForm — a later outcome supersedes this form’s earlier one',
185196
fireEvent.change(await inputNamed(container, 'status'), { target: { value: 'archived' } });
186197
submitStep(container);
187198
await waitFor(() => expect(ds.create).toHaveBeenCalledTimes(1));
188-
await waitFor(() => expect(onScreen()).toEqual([{ type: 'error', message: REFUSAL }]));
199+
await waitFor(saidOnceByBanner);
189200

190201
// Back to step 2 of the reported flow, change the status, and Create again.
191202
// The navigation matters: it is what proves the id survives step changes —
@@ -200,6 +211,7 @@ describe('WizardForm — a later outcome supersedes this form’s earlier one',
200211

201212
// The card's own sentence: one form, one outcome on screen.
202213
await waitFor(() => expect(onScreen()).toEqual([{ type: 'success', message: SUCCESS }]));
214+
expect(refusalBanners()).toEqual([]);
203215
});
204216

205217
it('does not stack a second refusal when the retry is refused too', async () => {
@@ -216,10 +228,10 @@ describe('WizardForm — a later outcome supersedes this form’s earlier one',
216228

217229
submitStep(container);
218230
await waitFor(() => expect(ds.create).toHaveBeenCalledTimes(1));
219-
await waitFor(() => expect(onScreen()).toHaveLength(1));
231+
await waitFor(saidOnceByBanner);
220232

221233
submitStep(container);
222234
await waitFor(() => expect(ds.create).toHaveBeenCalledTimes(2));
223-
await waitFor(() => expect(onScreen()).toEqual([{ type: 'error', message: REFUSAL }]));
235+
await waitFor(saidOnceByBanner);
224236
});
225237
});

0 commit comments

Comments
 (0)