@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,474 @@
1
+ import {
2
+ FormFeedback,
3
+ formFeedbackErrors,
4
+ type FormFeedbackAttempt,
5
+ type FormFeedbackField,
6
+ type FormFeedbackOptions,
7
+ type FormFeedbackState,
8
+ } from '@nocobase/app-client';
9
+ import { useTranslation } from '@nocobase/i18n/client';
10
+ import {
11
+ useEffect,
12
+ useId,
13
+ useLayoutEffect,
14
+ useMemo,
15
+ useReducer,
16
+ useRef,
17
+ useState,
18
+ useSyncExternalStore,
19
+ type ComponentProps,
20
+ type ReactElement,
21
+ type ReactNode,
22
+ } from 'react';
23
+ import { createPortal } from 'react-dom';
24
+
25
+ export interface FeedbackFormProps extends Omit<
26
+ ComponentProps<'form'>,
27
+ 'onSubmit'
28
+ > {
29
+ readonly onSubmit?: (
30
+ event: React.FormEvent<HTMLFormElement>,
31
+ ) => void | Promise<void>;
32
+ /** Pass the caught request error; input state continues to belong to the caller. */
33
+ readonly error?: unknown;
34
+ /** Trusted application copy/actions for a request failure; never pass raw server content. */
35
+ readonly errorContent?: ReactNode;
36
+ readonly submitting?: boolean;
37
+ /** For unmounted tabs, repeated rows and extension controls, register stable descriptors. */
38
+ readonly feedback?: FormFeedback;
39
+ readonly fieldMessage?: FormFeedbackOptions['fieldMessage'];
40
+ /** React Hook Form errors. Set shouldFocusError: false so layout order owns focus. */
41
+ readonly validationErrors?: unknown;
42
+ }
43
+
44
+ /** Native fields use name (or data-field-path). Custom controls use a FormFeedback descriptor. */
45
+ export function FeedbackForm({
46
+ children,
47
+ onSubmit,
48
+ error,
49
+ errorContent,
50
+ feedback: supplied,
51
+ fieldMessage,
52
+ validationErrors,
53
+ noValidate,
54
+ submitting = false,
55
+ ref,
56
+ ...props
57
+ }: FeedbackFormProps): ReactElement {
58
+ const { t } = useTranslation();
59
+ const options = useMemo<FormFeedbackOptions>(
60
+ () => ({
61
+ messages: {
62
+ field: t('formFeedback.field', {
63
+ defaultValue: 'This value was rejected. Check it and try again.',
64
+ }),
65
+ invalid: t('formFeedback.invalid', {
66
+ defaultValue: 'Check the highlighted fields and try again.',
67
+ }),
68
+ failed: t('formFeedback.failed', {
69
+ defaultValue: 'Unable to save. Your input has been kept. Try again.',
70
+ }),
71
+ network: t('formFeedback.network', {
72
+ defaultValue:
73
+ 'Unable to connect. Check your connection and try again. Your input has been kept.',
74
+ }),
75
+ denied: t('formFeedback.denied', {
76
+ defaultValue:
77
+ 'You cannot submit this form with your current access. Your input has been kept.',
78
+ }),
79
+ },
80
+ ...(fieldMessage ? { fieldMessage } : {}),
81
+ }),
82
+ [t, fieldMessage],
83
+ );
84
+ const [owned] = useState(() => new FormFeedback(options));
85
+ const feedback = supplied ?? owned;
86
+ useLayoutEffect(() => {
87
+ feedback.configure(options);
88
+ }, [feedback, options]);
89
+ const state = useSyncExternalStore(
90
+ feedback.subscribe,
91
+ feedback.getSnapshot,
92
+ feedback.getSnapshot,
93
+ );
94
+ const [unfocusableState, setUnfocusableState] = useState<FormFeedbackState>();
95
+ const formRef = useRef<HTMLFormElement>(null);
96
+ const messageRef = useRef<HTMLDivElement>(null);
97
+ const attemptRef = useRef<FormFeedbackAttempt | undefined>(undefined);
98
+ const prefixId = useId();
99
+ const localValidationRef = useRef(false);
100
+ const focusPendingRef = useRef(false);
101
+ const controlsRef = useRef(
102
+ new Map<
103
+ HTMLElement,
104
+ { field: FormFeedbackField; unregister: () => void }
105
+ >(),
106
+ );
107
+ const generatedRef = useRef(0);
108
+ const [, redraw] = useReducer((value: number) => value + 1, 0);
109
+
110
+ useEffect(() => {
111
+ const element = formRef.current;
112
+ if (!element) return;
113
+ const registered = controlsRef.current;
114
+ const collect = () => {
115
+ for (const [control, entry] of registered)
116
+ if (!element.contains(control)) {
117
+ entry.unregister();
118
+ registered.delete(control);
119
+ }
120
+ for (const control of element.querySelectorAll<HTMLElement>(
121
+ 'input[name], textarea[name], select[name], [data-field-path]',
122
+ )) {
123
+ if (registered.has(control)) continue;
124
+ // Application descriptors take precedence for custom/virtualized fields.
125
+ const path = () =>
126
+ control.dataset.fieldPath ?? control.getAttribute('name') ?? '';
127
+ if (feedback.hasPath(path())) continue;
128
+ const id = `${prefixId}-field-${++generatedRef.current}`;
129
+ const field: FormFeedbackField = {
130
+ id,
131
+ path,
132
+ label: () => fieldLabel(control),
133
+ anchor: () => control,
134
+ control: () => control,
135
+ value: () => {
136
+ if (control instanceof HTMLInputElement) {
137
+ if (control.type === 'file')
138
+ return Array.from(control.files ?? []);
139
+ if (control.type === 'checkbox' || control.type === 'radio')
140
+ return control.checked;
141
+ }
142
+ return 'value' in control ? control.value : control.textContent;
143
+ },
144
+ accessible: () =>
145
+ element.contains(control) &&
146
+ !control.closest(
147
+ '[data-feedback-hidden], [hidden], [inert], [aria-hidden="true"]',
148
+ ) &&
149
+ ancestors(control).every((element) => {
150
+ const style = getComputedStyle(element);
151
+ return style.display !== 'none' && style.visibility !== 'hidden';
152
+ }) &&
153
+ !(control instanceof HTMLInputElement && control.type === 'hidden'),
154
+ editable: () =>
155
+ !control.matches(':disabled, [aria-disabled="true"], [readonly]'),
156
+ reveal: () => {
157
+ for (const details of ancestors(control))
158
+ if (details instanceof HTMLDetailsElement) details.open = true;
159
+ },
160
+ };
161
+ registered.set(control, {
162
+ field,
163
+ unregister: feedback.register(field),
164
+ });
165
+ }
166
+ };
167
+ collect();
168
+ const observer = new MutationObserver(() => {
169
+ collect();
170
+ redraw();
171
+ });
172
+ observer.observe(element, {
173
+ childList: true,
174
+ subtree: true,
175
+ attributes: true,
176
+ attributeFilter: [
177
+ 'hidden',
178
+ 'inert',
179
+ 'aria-hidden',
180
+ 'disabled',
181
+ 'data-feedback-hidden',
182
+ ],
183
+ });
184
+ const change = (event: Event) => {
185
+ const control = event.target;
186
+ if (control instanceof HTMLElement) {
187
+ const entry = registered.get(control);
188
+ if (entry) feedback.clear(entry.field.id);
189
+ else feedback.clearControl(control);
190
+ }
191
+ };
192
+ element.addEventListener('input', change);
193
+ element.addEventListener('change', change);
194
+ return () => {
195
+ observer.disconnect();
196
+ element.removeEventListener('input', change);
197
+ element.removeEventListener('change', change);
198
+ for (const entry of registered.values()) entry.unregister();
199
+ registered.clear();
200
+ };
201
+ }, [feedback, prefixId]);
202
+
203
+ useEffect(() => {
204
+ if (!error || !attemptRef.current) return;
205
+ if (feedback.getAttemptSequence() !== attemptRef.current.sequence) return;
206
+ focusPendingRef.current = true;
207
+ feedback.failure(error, attemptRef.current);
208
+ }, [error, feedback]);
209
+
210
+ useEffect(() => {
211
+ if (
212
+ (state.message || state.fields.length) &&
213
+ focusPendingRef.current &&
214
+ !submitting
215
+ ) {
216
+ focusPendingRef.current = false;
217
+ void feedback.focus(messageRef.current, undefined, () => {
218
+ if (!state.message && feedback.getSnapshot() === state)
219
+ setUnfocusableState(state);
220
+ });
221
+ }
222
+ }, [state, feedback, submitting]);
223
+
224
+ // Existing controlled validators keep their messages and state. Adopt their invalid controls once per submission.
225
+ useEffect(() => {
226
+ if (
227
+ validationErrors !== undefined ||
228
+ !localValidationRef.current ||
229
+ !attemptRef.current
230
+ )
231
+ return;
232
+ if (state.fields.length) {
233
+ localValidationRef.current = false;
234
+ return;
235
+ }
236
+ const invalid = [...controlsRef.current.keys()].filter(
237
+ (control) => control.getAttribute('aria-invalid') === 'true',
238
+ );
239
+ if (!invalid.length) return;
240
+ localValidationRef.current = false;
241
+ focusPendingRef.current = true;
242
+ feedback.invalid(
243
+ invalid.map((control) => {
244
+ return {
245
+ field:
246
+ control.dataset.fieldPath ?? control.getAttribute('name') ?? '',
247
+ message:
248
+ (existingFieldError(control)?.textContent ||
249
+ options.messages.field) ??
250
+ options.messages.invalid,
251
+ };
252
+ }),
253
+ attemptRef.current,
254
+ );
255
+ });
256
+
257
+ const validationKey = JSON.stringify(formFeedbackErrors(validationErrors));
258
+ const attemptSequence = feedback.getAttemptSequence();
259
+ useEffect(() => {
260
+ if (
261
+ !localValidationRef.current ||
262
+ submitting ||
263
+ !attemptSequence ||
264
+ !attemptRef.current
265
+ )
266
+ return;
267
+ localValidationRef.current = false;
268
+ if (validationKey === '[]') return;
269
+ const errors = formFeedbackErrors(validationErrors);
270
+ if (errors.length) {
271
+ focusPendingRef.current = true;
272
+ feedback.invalid(errors, attemptRef.current);
273
+ }
274
+ }, [validationErrors, validationKey, feedback, attemptSequence, submitting]);
275
+
276
+ const errors = feedback.errors();
277
+ const inaccessible = state.fields.length > 0 && errors.length === 0;
278
+ const formMessage =
279
+ state.message ||
280
+ (inaccessible || unfocusableState === state
281
+ ? options.messages.failed
282
+ : undefined);
283
+ useLayoutEffect(() => {
284
+ if (inaccessible || unfocusableState === state) messageRef.current?.focus();
285
+ }, [state, inaccessible, unfocusableState]);
286
+ useLayoutEffect(() => {
287
+ const restore: (() => void)[] = [];
288
+ for (const entry of errors) {
289
+ const control = feedback.field(entry.id)?.control();
290
+ if (!control) continue;
291
+ const describedBy = control.getAttribute('aria-describedby');
292
+ const invalid = control.getAttribute('aria-invalid');
293
+ const ownsInvalid = invalid !== 'true';
294
+ const existing = existingFieldError(control);
295
+ const previousId = existing?.getAttribute('id');
296
+ const id = existing?.id || `${entry.id}-error`;
297
+ const ownsDescription = !(describedBy?.split(/\s+/) ?? []).includes(id);
298
+ if (existing && !existing.id) existing.id = id;
299
+ control.setAttribute('aria-invalid', 'true');
300
+ control.setAttribute(
301
+ 'aria-describedby',
302
+ [...new Set([...(describedBy?.split(/\s+/) ?? []), id])]
303
+ .filter(Boolean)
304
+ .join(' '),
305
+ );
306
+ restore.push(() => {
307
+ if (existing && previousId === null && existing.id === id)
308
+ existing.removeAttribute('id');
309
+ // React may already have committed new caller-owned attributes. Remove only our additions.
310
+ if (ownsDescription) {
311
+ const remaining = (control.getAttribute('aria-describedby') ?? '')
312
+ .split(/\s+/)
313
+ .filter((value) => value && value !== id);
314
+ if (remaining.length)
315
+ control.setAttribute('aria-describedby', remaining.join(' '));
316
+ else control.removeAttribute('aria-describedby');
317
+ }
318
+ if (ownsInvalid && control.getAttribute('aria-invalid') === 'true') {
319
+ if (invalid === null) control.removeAttribute('aria-invalid');
320
+ else control.setAttribute('aria-invalid', invalid);
321
+ }
322
+ });
323
+ }
324
+ return () => {
325
+ for (const reset of restore) reset();
326
+ };
327
+ });
328
+
329
+ return (
330
+ <form
331
+ {...props}
332
+ noValidate
333
+ ref={(element) => {
334
+ formRef.current = element;
335
+ if (typeof ref === 'function') return ref(element);
336
+ if (ref) ref.current = element;
337
+ }}
338
+ onSubmit={(event) => {
339
+ event.preventDefault();
340
+ const current = feedback.begin();
341
+ attemptRef.current = current;
342
+ localValidationRef.current = true;
343
+ const invalid = [...event.currentTarget.elements].filter(
344
+ (
345
+ control,
346
+ ): control is
347
+ HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement =>
348
+ (control instanceof HTMLInputElement ||
349
+ control instanceof HTMLSelectElement ||
350
+ control instanceof HTMLTextAreaElement) &&
351
+ !noValidate &&
352
+ control.willValidate &&
353
+ !control.validity.valid,
354
+ );
355
+ if (invalid.length) {
356
+ event.preventDefault();
357
+ focusPendingRef.current = true;
358
+ feedback.invalid(
359
+ invalid.map((control) => ({
360
+ field: control.dataset.fieldPath ?? control.name,
361
+ message: control.validationMessage,
362
+ })),
363
+ current,
364
+ );
365
+ return;
366
+ }
367
+ try {
368
+ const result = onSubmit?.(event);
369
+ if (result)
370
+ void result.catch((next: unknown) => {
371
+ if (feedback.getAttemptSequence() !== current.sequence) return;
372
+ focusPendingRef.current = true;
373
+ feedback.failure(next, current);
374
+ });
375
+ } catch (next) {
376
+ if (feedback.getAttemptSequence() !== current.sequence) return;
377
+ focusPendingRef.current = true;
378
+ feedback.failure(next, current);
379
+ }
380
+ }}
381
+ >
382
+ {(formMessage ||
383
+ (!attemptSequence && Boolean(error) && errorContent != null)) && (
384
+ <div
385
+ ref={messageRef}
386
+ role='alert'
387
+ tabIndex={-1}
388
+ className='space-y-2 text-sm text-destructive'
389
+ >
390
+ {error && errors.length === 0 && errorContent != null ? (
391
+ errorContent
392
+ ) : (
393
+ <p>{formMessage}</p>
394
+ )}
395
+ </div>
396
+ )}
397
+ {children}
398
+ {errors.map((entry) => {
399
+ const control = feedback.field(entry.id)?.control();
400
+ return control?.parentElement && !existingFieldError(control)
401
+ ? createPortal(
402
+ <p
403
+ id={`${entry.id}-error`}
404
+ data-form-feedback-error
405
+ className='text-sm text-destructive'
406
+ >
407
+ {entry.message}
408
+ </p>,
409
+ control.parentElement,
410
+ entry.id,
411
+ )
412
+ : null;
413
+ })}
414
+ </form>
415
+ );
416
+ }
417
+
418
+ function ancestors(control: HTMLElement): HTMLElement[] {
419
+ const result: HTMLElement[] = [];
420
+ for (
421
+ let parent = control.parentElement;
422
+ parent;
423
+ parent = parent.parentElement
424
+ )
425
+ result.unshift(parent);
426
+ return result;
427
+ }
428
+
429
+ /** Only explicitly associated application error nodes are trusted, never arbitrary nearby text. */
430
+ function existingFieldError(control: HTMLElement): HTMLElement | undefined {
431
+ const errorIds = (control.getAttribute('aria-errormessage') ?? '').split(
432
+ /\s+/,
433
+ );
434
+ const described = (control.getAttribute('aria-describedby') ?? '')
435
+ .split(/\s+/)
436
+ .map((id) => control.ownerDocument.getElementById(id))
437
+ .filter((node) =>
438
+ node?.matches('[data-slot="field-error"], [data-feedback-field-error]'),
439
+ );
440
+ const slot = control
441
+ .closest('[data-slot="field"]')
442
+ ?.querySelector<HTMLElement>('[data-slot="field-error"]');
443
+ return [
444
+ ...errorIds.map((id) => control.ownerDocument.getElementById(id)),
445
+ ...described,
446
+ slot,
447
+ ].find((node): node is HTMLElement =>
448
+ Boolean(node && !node.hasAttribute('data-form-feedback-error')),
449
+ );
450
+ }
451
+
452
+ /** Resolve explicit accessible names; group entry buttons may use their visible text. */
453
+ function fieldLabel(control: HTMLElement): string {
454
+ const labelledBy = (control.getAttribute('aria-labelledby') ?? '')
455
+ .split(/\s+/)
456
+ .map((id) => control.ownerDocument.getElementById(id)?.textContent?.trim())
457
+ .filter(Boolean)
458
+ .join(' ');
459
+ if (labelledBy) return labelledBy;
460
+ const ariaLabel = control.getAttribute('aria-label')?.trim();
461
+ if (ariaLabel) return ariaLabel;
462
+ if ('labels' in control) {
463
+ const labels = Array.from(
464
+ (control as HTMLInputElement).labels ?? [],
465
+ (label) => label.textContent?.trim(),
466
+ )
467
+ .filter(Boolean)
468
+ .join(' ');
469
+ if (labels) return labels;
470
+ }
471
+ return control.matches('button, [role="button"]')
472
+ ? (control.textContent?.trim() ?? '')
473
+ : '';
474
+ }
@@ -362,22 +362,23 @@ function NodeConfigurationActions({
362
362
  const choices = direct ? selections.slice(1) : selections;
363
363
  const label = t('flowDiagram.configure', { defaultValue: 'Configure' });
364
364
  const className =
365
- 'h-6 border border-primary/15 bg-primary/5 px-1.5 text-xs text-primary hover:bg-primary/10';
365
+ 'shrink-0 border border-primary/15 bg-primary/5 text-primary hover:bg-primary/10';
366
366
  return (
367
367
  <>
368
368
  {direct && (
369
369
  <Button
370
370
  type='button'
371
371
  variant='ghost'
372
- size='sm'
372
+ size='icon-xs'
373
373
  className={className}
374
+ aria-label={configuration.label(direct.target)}
375
+ title={configuration.label(direct.target)}
374
376
  onClick={(event) => {
375
377
  event.stopPropagation();
376
378
  configuration.onConfigure(direct);
377
379
  }}
378
380
  >
379
381
  <Settings2Icon aria-hidden />
380
- {configuration.label(direct.target)}
381
382
  </Button>
382
383
  )}
383
384
  {choices.length > 0 && (
@@ -402,20 +403,19 @@ function NodeConfigurationActions({
402
403
  <Button
403
404
  type='button'
404
405
  variant='ghost'
405
- size='sm'
406
+ size='icon-xs'
406
407
  className={className}
407
408
  aria-label={`${label}: ${title(state)}`}
409
+ title={`${label}: ${title(state)}`}
408
410
  onClick={(event) => event.stopPropagation()}
409
411
  />
410
412
  }
411
413
  >
412
- {!direct && (
413
- <>
414
- <Settings2Icon aria-hidden />
415
- {label}
416
- </>
414
+ {direct ? (
415
+ <ChevronDownIcon aria-hidden />
416
+ ) : (
417
+ <Settings2Icon aria-hidden />
417
418
  )}
418
- <ChevronDownIcon aria-hidden />
419
419
  </PopoverTrigger>
420
420
  <PopoverContent
421
421
  align='end'
@@ -0,0 +1,174 @@
1
+ import type { ListState } from '@nocobase/app-client';
2
+ import { useTranslation } from '@nocobase/i18n/client';
3
+ import {
4
+ CircleAlert,
5
+ Inbox,
6
+ LoaderCircle,
7
+ LockKeyhole,
8
+ LogIn,
9
+ RefreshCw,
10
+ SearchX,
11
+ X,
12
+ } from 'lucide-react';
13
+ import { Fragment, type ReactElement, type ReactNode } from 'react';
14
+ import { Link } from 'react-router';
15
+ import { Button } from '#components/ui/button';
16
+ import { Alert } from '#components/ui/alert';
17
+ import {
18
+ Empty,
19
+ EmptyContent,
20
+ EmptyDescription,
21
+ EmptyMedia,
22
+ EmptyTitle,
23
+ } from '#components/ui/empty';
24
+
25
+ const feedback = {
26
+ loading: ['listFeedback.loading', 'Loading', LoaderCircle],
27
+ empty: ['listFeedback.empty', 'No data yet', Inbox],
28
+ 'no-results': ['listFeedback.noResults', 'No results', SearchX],
29
+ forbidden: ['listFeedback.forbidden', 'Access denied', LockKeyhole],
30
+ unauthenticated: ['listFeedback.unauthenticated', 'Session expired', LogIn],
31
+ failed: ['listFeedback.failed', 'Unable to load list', CircleAlert],
32
+ } as const;
33
+
34
+ export interface ListFeedbackProps {
35
+ readonly state: ListState;
36
+ readonly children?: ReactNode;
37
+ /** Business empty content applies only after a successful unfiltered read. */
38
+ readonly empty?: ReactNode;
39
+ /** Optional business-specific action; the standard create action belongs in the page header. */
40
+ readonly emptyAction?: ReactNode;
41
+ }
42
+
43
+ export interface ListFeedbackActionsProps {
44
+ readonly state: ListState;
45
+ readonly onClear?: () => void;
46
+ readonly onRetry?: () => void;
47
+ readonly busy?: boolean;
48
+ }
49
+
50
+ /** Render state-appropriate recovery controls inside the host's existing toolbar. */
51
+ export function ListFeedbackActions({
52
+ state,
53
+ onClear,
54
+ onRetry,
55
+ busy = false,
56
+ }: ListFeedbackActionsProps): ReactElement | null {
57
+ const { t } = useTranslation();
58
+
59
+ if (state === 'no-results' && onClear) {
60
+ return (
61
+ <Button variant='outline' onClick={onClear}>
62
+ <X aria-hidden='true' />
63
+ {t('listFeedback.clear', { defaultValue: 'Clear filters' })}
64
+ </Button>
65
+ );
66
+ }
67
+
68
+ if ((state === 'failed' || state === 'refresh-failed') && onRetry) {
69
+ return (
70
+ <Button variant='outline' disabled={busy} onClick={onRetry}>
71
+ {busy ? (
72
+ <LoaderCircle
73
+ aria-hidden='true'
74
+ className='motion-safe:animate-spin'
75
+ />
76
+ ) : (
77
+ <RefreshCw aria-hidden='true' />
78
+ )}
79
+ {busy
80
+ ? t('listFeedback.retrying', { defaultValue: 'Retrying…' })
81
+ : t('listFeedback.retry', { defaultValue: 'Retry' })}
82
+ </Button>
83
+ );
84
+ }
85
+
86
+ if (state === 'unauthenticated') {
87
+ return (
88
+ <Button
89
+ variant='outline'
90
+ nativeButton={false}
91
+ render={<Link to='/login' />}
92
+ >
93
+ <LogIn aria-hidden='true' />
94
+ {t('listFeedback.signIn', { defaultValue: 'Sign in' })}
95
+ </Button>
96
+ );
97
+ }
98
+
99
+ return null;
100
+ }
101
+
102
+ /** Shared by tables and card lists. Authorization is decided by the host. */
103
+ export function ListFeedback({
104
+ state,
105
+ children,
106
+ empty,
107
+ emptyAction,
108
+ }: ListFeedbackProps): ReactElement {
109
+ const { t } = useTranslation();
110
+
111
+ if (state === 'ready') {
112
+ return <Fragment key='list-feedback-content'>{children}</Fragment>;
113
+ }
114
+
115
+ if (state === 'refresh-failed') {
116
+ return (
117
+ <>
118
+ <Alert
119
+ className='flex min-w-0 items-center gap-2 border-border px-3 py-2 text-sm'
120
+ role='alert'
121
+ >
122
+ <CircleAlert
123
+ aria-hidden='true'
124
+ className='size-4 shrink-0 text-destructive'
125
+ />
126
+ <p className='min-w-0 flex-1 wrap-anywhere'>
127
+ {t('listFeedback.refreshFailed', {
128
+ defaultValue: 'Refresh failed; showing previous data',
129
+ })}
130
+ </p>
131
+ </Alert>
132
+ <Fragment key='list-feedback-content'>{children}</Fragment>
133
+ </>
134
+ );
135
+ }
136
+
137
+ const [key, fallback, Icon] = feedback[state];
138
+ const isError =
139
+ state === 'failed' || state === 'forbidden' || state === 'unauthenticated';
140
+ const showCustomEmpty =
141
+ state === 'empty' && (empty !== undefined || emptyAction !== undefined);
142
+
143
+ return (
144
+ <>
145
+ <Empty
146
+ role={isError ? 'alert' : 'status'}
147
+ className='min-h-24 flex-row flex-none gap-2 rounded-none border-0 px-4 py-6 text-pretty text-card-foreground'
148
+ aria-busy={state === 'loading'}
149
+ >
150
+ <EmptyMedia className='mb-0 size-4 text-muted-foreground'>
151
+ <Icon
152
+ aria-hidden='true'
153
+ className={
154
+ state === 'loading' ? 'size-4 motion-safe:animate-spin' : 'size-4'
155
+ }
156
+ />
157
+ </EmptyMedia>
158
+ <EmptyTitle className='text-sm font-medium text-muted-foreground'>
159
+ {t(key, { defaultValue: fallback })}
160
+ </EmptyTitle>
161
+ </Empty>
162
+ {showCustomEmpty ? (
163
+ <EmptyContent className='max-w-full px-4 pb-4 text-center'>
164
+ {empty !== undefined ? (
165
+ <EmptyDescription className='max-w-md wrap-anywhere'>
166
+ {empty}
167
+ </EmptyDescription>
168
+ ) : null}
169
+ {emptyAction}
170
+ </EmptyContent>
171
+ ) : null}
172
+ </>
173
+ );
174
+ }