Skip to content

Commit fa69e70

Browse files
committed
test(ui): a refused form submit is said once, by its banner
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibxQtKRyLyL6eK14pgY
1 parent aa9fc51 commit fa69e70

6 files changed

Lines changed: 60 additions & 47 deletions

‎ui/packages/components/src/renderers/form/__tests__/fieldtab-visiblewhen-6237.test.tsx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -238,8 +238,9 @@ describe('#6237 — a tab predicate gates the whole tab', () => {
238238
fireEvent.click(submitBtn());
239239

240240
await waitFor(() => expect(screen.getByText(message)).toBeTruthy());
241-
expect(toastError).toHaveBeenCalledTimes(1);
242-
expect(String(toastError.mock.calls[0][0])).toBe(message);
241+
// Said once, by the banner (objectstack-ai/objectstack#27520).
242+
expect(screen.getByRole('alert').textContent).toBe(message);
243+
expect(toastError).not.toHaveBeenCalled();
243244
// The hidden tab is never activated to "reveal" a field it cannot draw.
244245
expect(trigger()).toBeNull();
245246
expect(screen.getByRole('tab', { name: /basics/i })).toHaveAttribute('data-state', 'active');

‎ui/packages/components/src/renderers/form/__tests__/form-invalid-toast-joiner.test.tsx‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,11 +87,16 @@ function renderFormIn(language: string) {
8787
);
8888
}
8989

90+
/**
91+
* The refusal as the form says it: its banner, the one message a refused
92+
* submit raises (objectstack-ai/objectstack#27520 retired the toast copy).
93+
*/
9094
async function toastTextAfterSubmit(language: string): Promise<string> {
9195
renderFormIn(language);
9296
fireEvent.click(screen.getByRole('button', { name: /create/i }));
93-
await waitFor(() => expect(toastErrorSpy).toHaveBeenCalled());
94-
return String(toastErrorSpy.mock.calls[0][0]);
97+
const banner = await screen.findByRole('alert');
98+
expect(toastErrorSpy).not.toHaveBeenCalled();
99+
return banner.textContent ?? '';
95100
}
96101

97102
describe('form renderer — refused-submit field list joiner (objectstack#5407)', () => {

‎ui/packages/components/src/renderers/form/__tests__/form-one-toast-per-refusal-13340.test.tsx‎

Lines changed: 25 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,17 @@
77
*/
88

99
/**
10-
* A refused save raises ONE error toast (objectui#13340).
10+
* A refused save is said ONCE (objectui#13340, objectstack-ai/objectstack#27520).
1111
*
1212
* The console's record form (`ModalForm`, `RecordFormPage`'s `ObjectForm`)
1313
* hands this renderer an `onSubmit` that writes through the data source and,
1414
* on a refusal, rethrows it. This renderer is the one that reports it: the
15-
* banner above the fields, plus a toast for when the banner has scrolled out
16-
* of view. The toast is raised under the form's own outcome id, so a retry
17-
* that is refused again REPLACES the previous toast instead of stacking a
18-
* second copy beside it.
15+
* banner above the fields, brought into view each time it is raised, and no
16+
* error toast repeating it. A retry that is refused again keeps that one
17+
* banner rather than stacking a second message beside it.
1918
*
2019
* Pinned on the toast calls themselves (every `toast.error` this form makes,
21-
* undeduplicated), not on what sonner ends up showing, so a second raiser here
20+
* undeduplicated), not on what sonner ends up showing, so a raiser here
2221
* cannot hide behind sonner's id replacement.
2322
*/
2423
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
@@ -33,12 +32,14 @@ import '../../../renderers';
3332
const REFUSAL = 'A record with this value already exists';
3433

3534
let toastErrorSpy: ReturnType<typeof vi.spyOn>;
35+
let scrolled: Element[];
3636

3737
beforeEach(() => {
3838
toastErrorSpy = vi.spyOn(toast, 'error').mockImplementation(() => 'id' as any);
39-
if (!(Element.prototype as any).scrollIntoView) {
40-
(Element.prototype as any).scrollIntoView = () => {};
41-
}
39+
scrolled = [];
40+
(Element.prototype as any).scrollIntoView = function (this: Element) {
41+
scrolled.push(this);
42+
};
4243
});
4344

4445
afterEach(() => {
@@ -70,30 +71,33 @@ function renderRefusingForm() {
7071

7172
const create = () => fireEvent.click(screen.getByRole('button', { name: /create/i }));
7273

73-
describe('form renderer — one refusal, one error toast (objectui#13340)', () => {
74-
it('a refused submit raises exactly one error toast, with the server`s sentence', async () => {
74+
describe('form renderer — one refusal, one message (objectui#13340, objectstack-ai/objectstack#27520)', () => {
75+
it('a refused submit draws one banner, scrolled into view, and raises no error toast', async () => {
7576
renderRefusingForm();
7677
create();
7778

7879
await waitFor(() => expect(refuse).toHaveBeenCalledTimes(1));
79-
// The banner is the settle signal: it is set in the same catch as the toast.
80-
await screen.findByText(REFUSAL);
81-
expect(toastErrorSpy).toHaveBeenCalledTimes(1);
82-
expect(toastErrorSpy.mock.calls[0][0]).toBe(REFUSAL);
80+
// The banner is the settle signal: it is set in the same catch.
81+
const banner = (await screen.findByText(REFUSAL)).closest('[role="alert"]');
82+
expect(banner).not.toBeNull();
83+
expect(screen.getAllByRole('alert')).toHaveLength(1);
84+
await waitFor(() => expect(scrolled).toContain(banner));
85+
expect(toastErrorSpy).not.toHaveBeenCalled();
8386
});
8487

85-
it('a refused retry raises one more, under the same id, so it replaces the first', async () => {
88+
it('a refused retry keeps one banner, brings it into view again, and still raises no toast', async () => {
8689
refuse.mockClear();
8790
renderRefusingForm();
8891
create();
89-
await waitFor(() => expect(toastErrorSpy).toHaveBeenCalledTimes(1));
92+
await screen.findByText(REFUSAL);
93+
await waitFor(() => expect(scrolled.length).toBe(1));
9094

9195
create();
9296
await waitFor(() => expect(refuse).toHaveBeenCalledTimes(2));
93-
await waitFor(() => expect(toastErrorSpy).toHaveBeenCalledTimes(2));
97+
await waitFor(() => expect(scrolled.length).toBe(2));
9498

95-
const [first, second] = toastErrorSpy.mock.calls.map((c: unknown[]) => (c[1] as { id?: unknown } | undefined)?.id);
96-
expect(first).toBeDefined();
97-
expect(second).toBe(first);
99+
expect(screen.getAllByText(REFUSAL)).toHaveLength(1);
100+
expect(screen.getAllByRole('alert')).toHaveLength(1);
101+
expect(toastErrorSpy).not.toHaveBeenCalled();
98102
});
99103
});

‎ui/packages/components/src/renderers/form/__tests__/form-refusal-lines-28053.test.tsx‎

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -80,20 +80,13 @@ function bannerLines(container: HTMLElement): string[] {
8080
return items.length > 0 ? items.map((li) => li.textContent ?? '') : [banner.textContent ?? ''];
8181
}
8282

83-
/** The toast's content, drawn, as text lines. */
84-
function toastLines(): string[] {
85-
const content = toastErrorSpy.mock.calls.at(-1)![0] as React.ReactNode;
86-
if (typeof content === 'string') return [content];
87-
const { container } = render(<>{content}</>);
88-
return Array.from(container.querySelectorAll('li')).map((li) => li.textContent ?? '');
89-
}
9083

9184
const AMOUNT = 'Amount must be positive.';
9285
const NAME = 'Name is required for a forecast.';
9386
const BEST = 'Best case must be at least the amount.';
9487

9588
describe('form renderer — one entry per failed validation rule (#28053)', () => {
96-
it('two record-level rules: two banner lines and two toast lines, no joined summary', async () => {
89+
it('two record-level rules: two banner lines, no joined summary, and no toast repeating them', async () => {
9790
const onSubmit = vi.fn().mockRejectedValue(
9891
ruleRefusal([{ field: '_record', message: AMOUNT }, { field: '_record', message: NAME }]),
9992
);
@@ -103,7 +96,8 @@ describe('form renderer — one entry per failed validation rule (#28053)', () =
10396

10497
await waitFor(() => expect(container.querySelector('[role="alert"]')).toBeTruthy());
10598
expect(bannerLines(container)).toEqual([AMOUNT, NAME]);
106-
expect(toastLines()).toEqual([AMOUNT, NAME]);
99+
// The banner is the refusal's one message (objectstack-ai/objectstack#27520).
100+
expect(toastErrorSpy).not.toHaveBeenCalled();
107101
expect(container.textContent).not.toContain('; ');
108102
});
109103

@@ -119,7 +113,7 @@ describe('form renderer — one entry per failed validation rule (#28053)', () =
119113
await waitFor(() => expect(bestWrap.textContent).toContain(BEST));
120114
expect(bestWrap.querySelector('[aria-invalid="true"]')).toBeTruthy();
121115
expect(bannerLines(container)).toEqual([AMOUNT]);
122-
expect(toastLines()).toEqual([AMOUNT]);
116+
expect(toastErrorSpy).not.toHaveBeenCalled();
123117
});
124118

125119
it('a single record-level rule is unchanged: its message, once', async () => {
@@ -128,9 +122,9 @@ describe('form renderer — one entry per failed validation rule (#28053)', () =
128122

129123
submit();
130124

131-
await waitFor(() => expect(toastErrorSpy).toHaveBeenCalled());
132-
expect(toastErrorSpy.mock.calls[0][0]).toBe(AMOUNT);
125+
await waitFor(() => expect(container.querySelector('[role="alert"]')).toBeTruthy());
133126
expect(bannerLines(container)).toEqual([AMOUNT]);
127+
expect(toastErrorSpy).not.toHaveBeenCalled();
134128
expect(container.querySelector('[aria-invalid="true"]')).toBeNull();
135129
});
136130
});

‎ui/packages/components/src/renderers/form/__tests__/form-server-field-errors.test.tsx‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -150,8 +150,10 @@ describe('form renderer — server-rejected fields', () => {
150150

151151
submit();
152152

153-
await waitFor(() => expect(toastErrorSpy).toHaveBeenCalled());
154-
expect(toastErrorSpy.mock.calls[0][0]).toBe('Owner is required');
153+
// The banner is the refusal's one message (objectstack-ai/objectstack#27520).
154+
const banner = await screen.findByRole('alert');
155+
expect(banner.textContent).toBe('Owner is required');
156+
expect(toastErrorSpy).not.toHaveBeenCalled();
155157
});
156158

157159
it('a later client-side refusal retires the previous refusal`s banner and toast (objectstack#25874)', async () => {
@@ -196,7 +198,7 @@ describe('form renderer — server-rejected fields', () => {
196198

197199
submit();
198200

199-
await waitFor(() => expect(toastErrorSpy).toHaveBeenCalled());
201+
await screen.findByRole('alert');
200202
// Nothing is attributable to a field, so no input is marked or scrolled to.
201203
expect(scrollSpy).not.toHaveBeenCalled();
202204
});
@@ -242,8 +244,9 @@ describe('form renderer — unique-index refusal', () => {
242244

243245
submit();
244246

245-
await waitFor(() => expect(toastErrorSpy).toHaveBeenCalled());
246-
expect(String(toastErrorSpy.mock.calls[0][0])).toMatch(/A record with this value already exists/);
247+
const banner = await screen.findByRole('alert');
248+
expect(banner.textContent).toMatch(/A record with this value already exists/);
249+
expect(toastErrorSpy).not.toHaveBeenCalled();
247250
expect(container.querySelector('[aria-invalid="true"]')).toBeNull();
248251
});
249252
});

‎ui/packages/components/src/renderers/form/__tests__/visibleWhen-fault-refuses-submit-8069.test.tsx‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,13 @@ function renderForm(
9898
}
9999

100100
const submit = () => fireEvent.click(screen.getByRole('button', { name: /create/i }));
101-
const lastToast = () => String(toastError.mock.calls.at(-1)?.[0] ?? '');
101+
/**
102+
* The refusal as the form says it: its banner, the one message a refused
103+
* submit raises; a toast repeating it is none of its business any more
104+
* (objectstack-ai/objectstack#27520), so any toast call reads as a mismatch.
105+
*/
106+
const refusalText = () =>
107+
toastError.mock.calls.length > 0 ? '(toasted)' : (screen.queryByRole('alert')?.textContent ?? '');
102108

103109
/** A predicate the engine cannot parse: a fault whatever the record holds. */
104110
const BROKEN = 'record.stage ==';
@@ -116,7 +122,7 @@ describe('objectui#8069 — a faulted visibleWhen refuses the submit', () => {
116122

117123
submit();
118124

119-
await waitFor(() => expect(lastToast()).toMatch(/visibleWhen rule of Discount could not be evaluated/));
125+
await waitFor(() => expect(refusalText()).toMatch(/visibleWhen rule of Discount could not be evaluated/));
120126
expect(screen.getByText(/visibleWhen rule of Discount could not be evaluated/)).toBeTruthy();
121127
expect(onSubmit).not.toHaveBeenCalled();
122128
});
@@ -133,7 +139,7 @@ describe('objectui#8069 — a faulted visibleWhen refuses the submit', () => {
133139

134140
submit();
135141

136-
await waitFor(() => expect(lastToast()).toMatch(/visibleWhen rule of Discount/));
142+
await waitFor(() => expect(refusalText()).toMatch(/visibleWhen rule of Discount/));
137143
expect(toastError.mock.calls.some((c: unknown[]) => /check the highlighted fields/.test(String(c[0])))).toBe(false);
138144
expect(onSubmit).not.toHaveBeenCalled();
139145
});
@@ -153,7 +159,7 @@ describe('objectui#8069 — a faulted visibleWhen refuses the submit', () => {
153159
renderForm(base({ visibleWhen: '' }), onSubmit);
154160
expect(screen.getByLabelText('Discount')).toBeTruthy();
155161
submit();
156-
await waitFor(() => expect(lastToast()).toMatch(/visibleWhen rule of Discount could not be evaluated/));
162+
await waitFor(() => expect(refusalText()).toMatch(/visibleWhen rule of Discount could not be evaluated/));
157163
expect(onSubmit).not.toHaveBeenCalled();
158164
expect(warn.mock.calls.some((c: unknown[]) => String(c[0]).includes('[blank]'))).toBe(true);
159165
});
@@ -184,7 +190,7 @@ describe('objectui#8069 — a faulted visibleWhen refuses the submit', () => {
184190
const onSubmit = vi.fn().mockResolvedValue(undefined);
185191
renderForm(base({ visibleWhen: "previous.stage == 'won'" }), onSubmit);
186192
submit();
187-
await waitFor(() => expect(lastToast()).toMatch(/visibleWhen rule of Discount/));
193+
await waitFor(() => expect(refusalText()).toMatch(/visibleWhen rule of Discount/));
188194
expect(onSubmit).not.toHaveBeenCalled();
189195
});
190196

0 commit comments

Comments
 (0)