@nocobase/app-template-default 1.0.0-beta.83 → 1.0.0-beta.85

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/.agents/rules/build-and-deploy.md +1 -1
  2. package/.agents/rules/shell.md +4 -0
  3. package/AGENTS.md +8 -2
  4. package/README.MD +10 -2
  5. package/client/components/back-button.tsx +3 -5
  6. package/client/components/data-table/data-table.tsx +86 -32
  7. package/client/components/default-access-editor.tsx +13 -2
  8. package/client/components/feedback-form.tsx +474 -0
  9. package/client/components/lifecycle-diagram.tsx +10 -10
  10. package/client/components/list-feedback.tsx +174 -0
  11. package/client/components/permission-editor.tsx +419 -226
  12. package/client/extensions/nocobase-auth-forms/password-login-form.tsx +17 -6
  13. package/client/extensions/nocobase-auth-forms/password-registration-form.tsx +24 -7
  14. package/client/extensions/nocobase-auth-forms/password-reset-form.tsx +19 -7
  15. package/client/extensions/nocobase-auth-forms/password-reset-request-form.tsx +15 -7
  16. package/client/extensions/nocobase-device-approval/device-approval.tsx +7 -2
  17. package/client/extensions/nocobase-device-approval/locales/en-US.ts +16 -0
  18. package/client/extensions/nocobase-device-approval/locales/zh-CN.ts +7 -0
  19. package/client/extensions/nocobase-file-component-ui/components/file-preview-dialog.tsx +20 -17
  20. package/client/extensions/nocobase-workflow-management/README.md +3 -1
  21. package/client/extensions/nocobase-workflow-management/definition-list.tsx +19 -7
  22. package/client/extensions/nocobase-workflow-management/execution-list.tsx +61 -48
  23. package/client/hooks/use-list-data.ts +76 -0
  24. package/client/hooks/use-list-location.ts +23 -0
  25. package/client/hooks/use-list-tabs.ts +42 -0
  26. package/client/hooks/use-table-url-state.ts +143 -30
  27. package/client/locales/system/en-US.ts +24 -1
  28. package/client/locales/system/settings-en-US.ts +8 -10
  29. package/client/locales/system/settings-zh-CN.ts +8 -8
  30. package/client/locales/system/zh-CN.ts +22 -1
  31. package/client/pages/settings/default-access-form.tsx +44 -26
  32. package/client/pages/settings/default-access.tsx +100 -93
  33. package/client/pages/settings/hooks.tsx +4 -59
  34. package/client/pages/settings/page-permissions.ts +17 -0
  35. package/client/pages/settings/permissions-model.ts +54 -59
  36. package/client/pages/settings/role-form.tsx +98 -56
  37. package/client/pages/settings/roles.tsx +69 -95
  38. package/client/pages/settings/shared.tsx +43 -33
  39. package/client/pages/settings/user-form.tsx +51 -30
  40. package/client/pages/settings/users.tsx +105 -91
  41. package/client/routing/session-preparation.tsx +10 -3
  42. package/client/runtime.ts +0 -4
  43. package/config.example.yml +24 -0
  44. package/database/main/migrations/202611020002_create_mutation_receipts.ts +19 -0
  45. package/package.json +24 -21
  46. package/server/app.ts +0 -2
  47. package/server/config/auth.ts +2 -0
  48. package/server/config/concurrency.ts +3 -0
  49. package/server/config/index.ts +3 -0
  50. package/server/config/jobs.ts +1 -13
  51. package/server/config/notification.ts +1 -1
  52. package/server/config/queue.ts +1 -7
  53. package/server/plugins.ts +2 -0
  54. package/server/providers/index.ts +4 -1
  55. package/server/providers/submission.ts +61 -0
  56. package/tests/client/auth-i18n.test.tsx +9 -0
  57. package/tests/client/back-button.test.tsx +3 -3
  58. package/tests/client/data-table.test.tsx +9 -4
  59. package/tests/client/default-access.test.tsx +99 -5
  60. package/tests/client/form-feedback.test.tsx +589 -0
  61. package/tests/client/list-feedback.test.tsx +301 -0
  62. package/tests/client/list-recovery.test.tsx +373 -0
  63. package/tests/client/list-tabs.test.tsx +121 -0
  64. package/tests/client/manual-list-retry.test.tsx +190 -0
  65. package/tests/client/page-permission-navigation.test.ts +8 -0
  66. package/tests/client/permission-editor.test.tsx +191 -21
  67. package/tests/client/permission-filter-binding.test.tsx +1 -1
  68. package/tests/client/permission-workspace.test.ts +13 -0
  69. package/tests/client/role-return.test.tsx +2 -1
  70. package/tests/client/session-preparation.test.tsx +24 -1
  71. package/tests/client/settings-continuity.test.tsx +85 -5
  72. package/tests/client/settings-feedback.test.tsx +32 -15
  73. package/tests/client/settings-permissions.test.ts +35 -0
  74. package/tests/client/settings.test.tsx +2 -1
  75. package/tests/client/table-url-state.test.tsx +117 -3
  76. package/tests/client/user-drawer.test.tsx +2 -1
  77. package/tests/client/user-permissions.test.tsx +30 -5
  78. package/tests/database/mutation-receipts.test.ts +45 -0
  79. package/tests/fixtures/form-feedback/README.md +33 -0
  80. package/tests/fixtures/form-feedback/equipment-detail.tsx +11 -0
  81. package/tests/fixtures/form-feedback/equipment-layout.tsx +15 -0
  82. package/tests/fixtures/form-feedback/equipment-migration.ts +27 -0
  83. package/tests/fixtures/form-feedback/equipment-server.ts +197 -0
  84. package/tests/fixtures/form-feedback/equipment-validation.tsx +94 -0
  85. package/tests/fixtures/form-feedback/equipment.tsx +369 -0
  86. package/tests/fixtures/form-feedback/verify-inline.mjs +174 -0
  87. package/tests/fixtures/form-feedback/verify.mjs +373 -0
  88. package/tests/fixtures/permission-alignment.html +16 -0
  89. package/tests/fixtures/permission-alignment.tsx +124 -0
  90. package/tests/fixtures/submission/README.md +35 -0
  91. package/tests/fixtures/submission/browser.test.ts +478 -0
  92. package/tests/fixtures/submission/migration.ts +61 -0
  93. package/tests/fixtures/submission/page.tsx +138 -0
  94. package/tests/fixtures/submission/playwright.config.ts +11 -0
  95. package/tests/fixtures/submission/routes.ts +208 -0
  96. package/tests/helpers/settings-client.tsx +31 -0
  97. package/tests/playwright/permission-alignment.test.ts +109 -0
  98. package/tests/server/auth-email-config.test.ts +79 -0
  99. package/tests/server/concurrency.test.ts +61 -0
  100. package/tests/server/config.test.ts +34 -14
  101. package/tests/server/member-role.test.ts +7 -1
  102. package/tests/server/submission-settings.test.ts +241 -0
  103. package/client/route-overrides.ts +0 -9
  104. package/client/source-extensions.ts +0 -19
@@ -0,0 +1,589 @@
1
+ import { ApiClientError } from '@nocobase/app-client';
2
+ import { useFieldArray, useForm } from 'react-hook-form';
3
+ import { useState } from 'react';
4
+ import { I18nRuntime } from '@nocobase/i18n';
5
+ import { I18nProvider } from '@nocobase/i18n/client';
6
+ import {
7
+ act,
8
+ fireEvent,
9
+ render,
10
+ screen,
11
+ waitFor,
12
+ within,
13
+ } from '@testing-library/react';
14
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
15
+ import { FeedbackForm } from '../../client/components/feedback-form.js';
16
+ import { PasswordRegistrationForm } from '../../client/extensions/nocobase-auth-forms/password-registration-form.js';
17
+ import locales from '../../client/locales/index.js';
18
+
19
+ async function mount(children: React.ReactNode) {
20
+ const runtime = new I18nRuntime({
21
+ applicationNamespace: 'app',
22
+ defaultLocale: 'en-US',
23
+ locales: ['en-US', 'zh-CN'],
24
+ });
25
+ runtime.registerApplicationNamespace('app', locales);
26
+ await runtime.init('en-US');
27
+ const view = render(
28
+ <I18nProvider runtime={runtime}>{children}</I18nProvider>,
29
+ );
30
+ return { ...view, runtime };
31
+ }
32
+ beforeEach(() => {
33
+ vi.spyOn(HTMLElement.prototype, 'getClientRects').mockReturnValue([
34
+ {},
35
+ ] as unknown as DOMRectList);
36
+ HTMLElement.prototype.scrollIntoView = vi.fn();
37
+ });
38
+
39
+ describe('feedback forms', () => {
40
+ it('keeps trusted business copy in one form message and clears it after retry', async () => {
41
+ function BusinessForm() {
42
+ const [error, setError] = useState<unknown>();
43
+ const [attempts, setAttempts] = useState(0);
44
+ return (
45
+ <FeedbackForm
46
+ error={error}
47
+ errorContent='Business rule: review the request.'
48
+ onSubmit={(event) => {
49
+ event.preventDefault();
50
+ setAttempts(attempts + 1);
51
+ setError(
52
+ attempts === 0 ? new Error('private server details') : undefined,
53
+ );
54
+ }}
55
+ >
56
+ <label htmlFor='business-name'>Name</label>
57
+ <input id='business-name' name='name' defaultValue='Draft' />
58
+ <button>Save</button>
59
+ </FeedbackForm>
60
+ );
61
+ }
62
+ await mount(<BusinessForm />);
63
+ fireEvent.click(screen.getByText('Save'));
64
+ await waitFor(() =>
65
+ expect(screen.getByRole('alert')).toHaveTextContent(
66
+ 'Business rule: review the request.',
67
+ ),
68
+ );
69
+ expect(screen.getAllByRole('alert')).toHaveLength(1);
70
+ expect(screen.getByLabelText('Name')).toHaveValue('Draft');
71
+ expect(document.body.textContent).not.toContain('private server details');
72
+ fireEvent.change(screen.getByLabelText('Name'), {
73
+ target: { value: 'Corrected' },
74
+ });
75
+ expect(screen.getByLabelText('Name')).toHaveValue('Corrected');
76
+ fireEvent.click(screen.getByText('Save'));
77
+ await waitFor(() =>
78
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument(),
79
+ );
80
+ });
81
+
82
+ it('ignores a superseded rejection without moving focus on the next edit', async () => {
83
+ let rejectFirst!: (error: unknown) => void;
84
+ let attempts = 0;
85
+ await mount(
86
+ <FeedbackForm
87
+ onSubmit={async (event) => {
88
+ event.preventDefault();
89
+ if (++attempts === 1)
90
+ return new Promise<void>((_resolve, reject) => {
91
+ rejectFirst = reject;
92
+ });
93
+ throw new ApiClientError('private', {
94
+ status: 400,
95
+ method: 'POST',
96
+ url: '/api/items',
97
+ payload: {
98
+ error: {
99
+ fieldViolations: [{ field: 'first' }, { field: 'second' }],
100
+ },
101
+ },
102
+ });
103
+ }}
104
+ >
105
+ <div>
106
+ <label htmlFor='overlap-first'>First</label>
107
+ <input id='overlap-first' name='first' defaultValue='draft' />
108
+ </div>
109
+ <div>
110
+ <label htmlFor='overlap-second'>Second</label>
111
+ <input id='overlap-second' name='second' defaultValue='draft' />
112
+ </div>
113
+ <button>Save</button>
114
+ </FeedbackForm>,
115
+ );
116
+ fireEvent.click(screen.getByText('Save'));
117
+ fireEvent.click(screen.getByText('Save'));
118
+ await waitFor(() => expect(screen.getByLabelText('First')).toHaveFocus());
119
+ await act(() => {
120
+ rejectFirst(new TypeError('obsolete request'));
121
+ });
122
+ fireEvent.change(screen.getByLabelText('First'), {
123
+ target: { value: 'corrected' },
124
+ });
125
+ expect(screen.getByLabelText('First')).toHaveFocus();
126
+ });
127
+
128
+ it('adapts RHF validation, preserves business copy and keeps focus while a field is being corrected', async () => {
129
+ function RhfForm() {
130
+ const form = useForm({
131
+ defaultValues: { first: '', second: '' },
132
+ shouldFocusError: false,
133
+ });
134
+ return (
135
+ <FeedbackForm
136
+ noValidate
137
+ submitting={form.formState.isSubmitting}
138
+ validationErrors={form.formState.errors}
139
+ onSubmit={form.handleSubmit(() => {})}
140
+ >
141
+ <div>
142
+ <label htmlFor='rhf-first'>First field</label>
143
+ <input
144
+ id='rhf-first'
145
+ {...form.register('first', {
146
+ required: 'Business rule: enter a first value.',
147
+ })}
148
+ />
149
+ </div>
150
+ <div>
151
+ <label htmlFor='rhf-second'>Second field</label>
152
+ <input
153
+ id='rhf-second'
154
+ {...form.register('second', {
155
+ required: 'Business rule: enter a second value.',
156
+ })}
157
+ />
158
+ </div>
159
+ <button>Save</button>
160
+ </FeedbackForm>
161
+ );
162
+ }
163
+ await mount(<RhfForm />);
164
+ fireEvent.click(screen.getByText('Save'));
165
+ await waitFor(() =>
166
+ expect(screen.getByLabelText('First field')).toHaveFocus(),
167
+ );
168
+ const first = screen.getByLabelText('First field');
169
+ const id = first.getAttribute('aria-describedby')!;
170
+ expect(document.getElementById(id)).toHaveTextContent(
171
+ 'Business rule: enter a first value.',
172
+ );
173
+ fireEvent.change(first, { target: { value: 'corrected' } });
174
+ await waitFor(() => expect(first).not.toHaveAttribute('aria-invalid'));
175
+ expect(first).toHaveFocus();
176
+ });
177
+
178
+ it('shows inline native errors, preserves help associations and focuses by layout order', async () => {
179
+ const save = vi.fn();
180
+ await mount(
181
+ <FeedbackForm
182
+ onSubmit={(event) => {
183
+ event.preventDefault();
184
+ save();
185
+ }}
186
+ >
187
+ <div>
188
+ <label htmlFor='first'>First</label>
189
+ <input name='first' id='first' required aria-describedby='help' />
190
+ <p id='help'>Help</p>
191
+ </div>
192
+ <div>
193
+ <label htmlFor='last'>Last</label>
194
+ <input name='last' id='last' required />
195
+ </div>
196
+ <button>Save</button>
197
+ </FeedbackForm>,
198
+ );
199
+ fireEvent.click(screen.getByText('Save'));
200
+ await waitFor(() => expect(screen.getByLabelText('First')).toHaveFocus());
201
+ const first = screen.getByLabelText('First');
202
+ expect(first.getAttribute('aria-describedby')).toContain('help');
203
+ const ids = first.getAttribute('aria-describedby')!.split(' ');
204
+ expect(
205
+ ids.some(
206
+ (id) =>
207
+ document.getElementById(id)?.textContent ===
208
+ 'Constraints not satisfied',
209
+ ),
210
+ ).toBe(true);
211
+ expect(save).not.toHaveBeenCalled();
212
+ fireEvent.change(first, { target: { value: 'retained' } });
213
+ expect(first).not.toHaveAttribute('aria-invalid');
214
+ expect(first).toHaveAttribute('aria-describedby', 'help');
215
+ });
216
+
217
+ it('retains input and selected files through request failure, then retries successfully', async () => {
218
+ let fail = true;
219
+ const saved = vi.fn();
220
+ await mount(
221
+ <FeedbackForm
222
+ onSubmit={async (event) => {
223
+ event.preventDefault();
224
+ if (fail) throw new TypeError('network internals');
225
+ saved();
226
+ }}
227
+ >
228
+ <div>
229
+ <label htmlFor='name'>Name</label>
230
+ <input
231
+ id='name'
232
+ name='name'
233
+ required
234
+ defaultValue='Custom equipment'
235
+ />
236
+ </div>
237
+ <label htmlFor='file'>Attachment</label>
238
+ <input id='file' name='attachment' type='file' />
239
+ <button>Save</button>
240
+ </FeedbackForm>,
241
+ );
242
+ const upload = new File(['equipment'], 'equipment.txt', {
243
+ type: 'text/plain',
244
+ });
245
+ fireEvent.change(screen.getByLabelText('Attachment'), {
246
+ target: { files: [upload] },
247
+ });
248
+ fireEvent.click(screen.getByText('Save'));
249
+ await waitFor(() => expect(screen.getByRole('alert')).toHaveFocus());
250
+ expect(screen.getByRole('alert')).toHaveTextContent('Unable to connect');
251
+ expect(screen.getByLabelText('Name')).toHaveValue('Custom equipment');
252
+ expect(
253
+ (screen.getByLabelText('Attachment') as HTMLInputElement).files?.[0],
254
+ ).toBe(upload);
255
+ fail = false;
256
+ fireEvent.click(screen.getByText('Save'));
257
+ await waitFor(() => expect(saved).toHaveBeenCalledOnce());
258
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
259
+ });
260
+
261
+ it('maps structured errors without displaying server details and associates inline text', async () => {
262
+ await mount(
263
+ <FeedbackForm
264
+ onSubmit={async (event) => {
265
+ event.preventDefault();
266
+ throw new ApiClientError('private stack', {
267
+ status: 400,
268
+ method: 'POST',
269
+ url: '/api/equipment',
270
+ payload: {
271
+ error: {
272
+ fieldViolations: [
273
+ { field: 'name', description: 'private payload' },
274
+ ],
275
+ },
276
+ },
277
+ });
278
+ }}
279
+ >
280
+ <div>
281
+ <label htmlFor='name'>Name</label>
282
+ <input name='name' id='name' defaultValue='Equipment' />
283
+ </div>
284
+ <button>Save</button>
285
+ </FeedbackForm>,
286
+ );
287
+ fireEvent.click(screen.getByText('Save'));
288
+ await waitFor(() => expect(screen.getByLabelText('Name')).toHaveFocus());
289
+ expect(screen.getByLabelText('Name')).toHaveAttribute(
290
+ 'aria-invalid',
291
+ 'true',
292
+ );
293
+ const errorId = screen
294
+ .getByLabelText('Name')
295
+ .getAttribute('aria-describedby')!;
296
+ expect(document.getElementById(errorId)).toHaveTextContent(
297
+ 'This value was rejected',
298
+ );
299
+ expect(document.body.textContent).not.toContain('private');
300
+ });
301
+
302
+ it('keeps password mismatch copy and focuses confirmation without resetting other values', async () => {
303
+ const submit = vi.fn();
304
+ await mount(<PasswordRegistrationForm onSubmit={submit} />);
305
+ for (const [label, value] of [
306
+ ['Name', 'Test'],
307
+ ['Username', 'tester'],
308
+ ['Email', 'test@example.test'],
309
+ ['Password', 'password123'],
310
+ ['Confirm password', 'different'],
311
+ ])
312
+ fireEvent.change(screen.getByLabelText(label!), { target: { value } });
313
+ fireEvent.click(screen.getByText('Create account'));
314
+ await waitFor(() =>
315
+ expect(screen.getByLabelText('Confirm password')).toHaveFocus(),
316
+ );
317
+ expect(screen.getByLabelText('Name')).toHaveValue('Test');
318
+ expect(submit).not.toHaveBeenCalled();
319
+ expect(
320
+ screen.getAllByText("Passwords don't match.").length,
321
+ ).toBeGreaterThan(0);
322
+ });
323
+
324
+ it('localizes form errors without replacing business translations', async () => {
325
+ const { runtime } = await mount(
326
+ <FeedbackForm
327
+ onSubmit={async (event) => {
328
+ event.preventDefault();
329
+ throw new Error('private');
330
+ }}
331
+ >
332
+ <button>Save</button>
333
+ </FeedbackForm>,
334
+ );
335
+ await act(() => runtime.changeLanguage('zh-CN'));
336
+ fireEvent.click(screen.getByText('Save'));
337
+ await waitFor(() =>
338
+ expect(screen.getByRole('alert')).toHaveTextContent('保存失败'),
339
+ );
340
+ });
341
+ it('shows real RHF field-array rules, focuses its editable entry and keeps form errors visible without a field', async () => {
342
+ const saved = vi.fn();
343
+ function ArrayForm() {
344
+ const form = useForm<{ items: { name: string }[] }>({
345
+ defaultValues: { items: [{ name: 'Laptop' }] },
346
+ shouldFocusError: false,
347
+ });
348
+ const rows = useFieldArray({
349
+ control: form.control,
350
+ name: 'items',
351
+ rules: { minLength: { value: 2, message: 'Add at least two items.' } },
352
+ });
353
+ return (
354
+ <FeedbackForm
355
+ noValidate
356
+ validationErrors={form.formState.errors}
357
+ submitting={form.formState.isSubmitting}
358
+ onSubmit={form.handleSubmit(saved)}
359
+ >
360
+ <div>
361
+ <button
362
+ type='button'
363
+ data-field-path='items'
364
+ aria-label='Add equipment'
365
+ onClick={() => rows.append({ name: 'Projector' })}
366
+ >
367
+ Add equipment
368
+ </button>
369
+ </div>
370
+ {rows.fields.map((row, i) => (
371
+ <input
372
+ key={row.id}
373
+ aria-label={`Equipment ${i}`}
374
+ {...form.register(`items.${i}.name`)}
375
+ />
376
+ ))}
377
+ <button>Save</button>
378
+ </FeedbackForm>
379
+ );
380
+ }
381
+ const view = await mount(<ArrayForm />);
382
+ fireEvent.click(screen.getByText('Save'));
383
+ await waitFor(() =>
384
+ expect(screen.getByLabelText('Add equipment')).toHaveFocus(),
385
+ );
386
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
387
+ expect(screen.queryByRole('list')).not.toBeInTheDocument();
388
+ expect(screen.getByText('Add at least two items.')).toBeVisible();
389
+ fireEvent.click(screen.getByText('Save'));
390
+ await waitFor(() =>
391
+ expect(screen.getAllByText('Add at least two items.')).toHaveLength(1),
392
+ );
393
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
394
+ expect(saved).not.toHaveBeenCalled();
395
+ fireEvent.click(screen.getByLabelText('Add equipment'));
396
+ fireEvent.click(screen.getByText('Save'));
397
+ await waitFor(() => expect(saved).toHaveBeenCalledOnce());
398
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
399
+ view.unmount();
400
+ await mount(
401
+ <FeedbackForm
402
+ validationErrors={{
403
+ items: { root: { message: 'A row is required.' } },
404
+ }}
405
+ onSubmit={() => {}}
406
+ >
407
+ <button>Save</button>
408
+ </FeedbackForm>,
409
+ );
410
+ fireEvent.click(screen.getByText('Save'));
411
+ await waitFor(() => expect(screen.getByRole('alert')).toHaveFocus());
412
+ expect(screen.getByRole('alert')).toHaveTextContent(
413
+ 'Check the highlighted fields',
414
+ );
415
+ });
416
+
417
+ it('places a group error beside its editable entry without a top message or navigation', async () => {
418
+ await mount(
419
+ <FeedbackForm
420
+ noValidate
421
+ validationErrors={{
422
+ items: { root: { message: 'Add at least two equipment items.' } },
423
+ }}
424
+ onSubmit={() => {}}
425
+ >
426
+ <h2 id='equipment-group'>Equipment group</h2>
427
+ <button
428
+ type='button'
429
+ data-field-path='items'
430
+ aria-labelledby='equipment-group'
431
+ >
432
+ Add equipment
433
+ </button>
434
+ <button>Save</button>
435
+ </FeedbackForm>,
436
+ );
437
+ fireEvent.click(screen.getByText('Save'));
438
+ const entry = screen.getByRole('button', { name: 'Equipment group' });
439
+ await waitFor(() => expect(entry).toHaveFocus());
440
+ const id = entry.getAttribute('aria-describedby')!;
441
+ expect(document.getElementById(id)).toHaveTextContent(
442
+ 'Add at least two equipment items.',
443
+ );
444
+ expect(entry.parentElement).toContainElement(document.getElementById(id));
445
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
446
+ expect(screen.queryByRole('list')).not.toBeInTheDocument();
447
+ });
448
+
449
+ it('keeps unknown and inaccessible request errors at form level without leaking paths, labels or descriptions', async () => {
450
+ await mount(
451
+ <FeedbackForm
452
+ onSubmit={() => {
453
+ throw new ApiClientError('private stack', {
454
+ status: 400,
455
+ method: 'POST',
456
+ url: '/api/items',
457
+ payload: {
458
+ error: {
459
+ fieldViolations: [
460
+ { field: 'secret', description: 'Private equipment value' },
461
+ { field: 'unknown', description: 'Secret fix' },
462
+ ],
463
+ },
464
+ },
465
+ });
466
+ }}
467
+ >
468
+ <div hidden>
469
+ <label htmlFor='secret'>Restricted equipment</label>
470
+ <input id='secret' name='secret' />
471
+ </div>
472
+ <button>Save</button>
473
+ </FeedbackForm>,
474
+ );
475
+ fireEvent.click(screen.getByText('Save'));
476
+ await waitFor(() => expect(screen.getByRole('alert')).toHaveFocus());
477
+ const summary = screen.getByRole('alert');
478
+ expect(within(summary).queryByRole('button')).not.toBeInTheDocument();
479
+ expect(summary).toHaveTextContent(
480
+ 'Unable to save. Your input has been kept. Try again.',
481
+ );
482
+ expect(summary.textContent).not.toMatch(
483
+ /Restricted|Private|Secret|unknown/,
484
+ );
485
+ });
486
+
487
+ it('keeps trusted controlled errors associated once and respects corrected attributes and new help', async () => {
488
+ function ControlledForm() {
489
+ const [invalid, setInvalid] = useState(false);
490
+ const [edited, setEdited] = useState(false);
491
+ return (
492
+ <FeedbackForm noValidate onSubmit={() => setInvalid(true)}>
493
+ <div data-slot='field'>
494
+ <label htmlFor='controlled'>Name</label>
495
+ <input
496
+ id='controlled'
497
+ name='name'
498
+ defaultValue='taken'
499
+ aria-invalid={invalid}
500
+ aria-describedby={edited ? 'new-help' : 'help'}
501
+ onChange={() => {
502
+ setInvalid(false);
503
+ setEdited(true);
504
+ }}
505
+ />
506
+ <p id='help'>Original help</p>
507
+ <p id='new-help'>Updated help</p>
508
+ {invalid && <p data-slot='field-error'>Choose a unique name.</p>}
509
+ </div>
510
+ <button>Save</button>
511
+ </FeedbackForm>
512
+ );
513
+ }
514
+ await mount(<ControlledForm />);
515
+ fireEvent.click(screen.getByText('Save'));
516
+ const input = screen.getByLabelText('Name');
517
+ await waitFor(() => expect(input).toHaveFocus());
518
+ expect(screen.getAllByText('Choose a unique name.')).toHaveLength(1);
519
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
520
+ const ids = input.getAttribute('aria-describedby')!.split(' ');
521
+ expect(
522
+ ids.some(
523
+ (id) =>
524
+ document.getElementById(id)?.textContent === 'Choose a unique name.',
525
+ ),
526
+ ).toBe(true);
527
+ expect(document.querySelector('[data-form-feedback-error]')).toBeNull();
528
+ fireEvent.change(input, { target: { value: 'unique' } });
529
+ expect(input).toHaveAttribute('aria-invalid', 'false');
530
+ expect(input).toHaveAttribute('aria-describedby', 'new-help');
531
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
532
+ expect(input).toHaveValue('unique');
533
+ fireEvent.click(screen.getByText('Save'));
534
+ await waitFor(() => expect(input).toHaveAttribute('aria-invalid', 'true'));
535
+ fireEvent.change(input, { target: { value: 'another' } });
536
+ expect(input).toHaveAttribute('aria-invalid', 'false');
537
+ });
538
+ it('uses a safe focusable form message when a registered error has no editable target', async () => {
539
+ await mount(
540
+ <FeedbackForm
541
+ noValidate
542
+ validationErrors={{ name: { message: 'Business rule.' } }}
543
+ onSubmit={() => {}}
544
+ >
545
+ <label htmlFor='disabled-name'>Name</label>
546
+ <input
547
+ id='disabled-name'
548
+ name='name'
549
+ disabled
550
+ defaultValue='Retained'
551
+ />
552
+ <button>Save</button>
553
+ </FeedbackForm>,
554
+ );
555
+ fireEvent.click(screen.getByText('Save'));
556
+ await waitFor(() => expect(screen.getByRole('alert')).toHaveFocus());
557
+ expect(screen.getByRole('alert')).toHaveTextContent('Unable to save.');
558
+ expect(screen.queryByRole('list')).not.toBeInTheDocument();
559
+ expect(screen.getByText('Business rule.')).toBeVisible();
560
+ expect(screen.getByLabelText('Name')).toHaveValue('Retained');
561
+ });
562
+
563
+ it('shows an independent unmapped failure alongside inline errors and keeps it after correction', async () => {
564
+ await mount(
565
+ <FeedbackForm
566
+ noValidate
567
+ validationErrors={{
568
+ name: { message: 'Choose another name.' },
569
+ unknown: { message: 'Private rule' },
570
+ }}
571
+ onSubmit={() => {}}
572
+ >
573
+ <label htmlFor='mixed'>Name</label>
574
+ <input id='mixed' name='name' defaultValue='Retained' />
575
+ <button>Save</button>
576
+ </FeedbackForm>,
577
+ );
578
+ fireEvent.click(screen.getByText('Save'));
579
+ await waitFor(() => expect(screen.getByLabelText('Name')).toHaveFocus());
580
+ expect(screen.getByRole('alert')).not.toHaveTextContent('Private rule');
581
+ expect(screen.getByText('Choose another name.')).toBeVisible();
582
+ fireEvent.change(screen.getByLabelText('Name'), {
583
+ target: { value: 'Corrected' },
584
+ });
585
+ expect(screen.getByRole('alert')).toBeVisible();
586
+ expect(screen.queryByText('Choose another name.')).not.toBeInTheDocument();
587
+ expect(screen.queryByRole('list')).not.toBeInTheDocument();
588
+ });
589
+ });