@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.
- package/.agents/rules/build-and-deploy.md +1 -1
- package/.agents/rules/shell.md +4 -0
- package/AGENTS.md +8 -2
- package/README.MD +10 -2
- package/client/components/back-button.tsx +3 -5
- package/client/components/data-table/data-table.tsx +86 -32
- package/client/components/default-access-editor.tsx +13 -2
- package/client/components/feedback-form.tsx +474 -0
- package/client/components/lifecycle-diagram.tsx +10 -10
- package/client/components/list-feedback.tsx +174 -0
- package/client/components/permission-editor.tsx +419 -226
- package/client/extensions/nocobase-auth-forms/password-login-form.tsx +17 -6
- package/client/extensions/nocobase-auth-forms/password-registration-form.tsx +24 -7
- package/client/extensions/nocobase-auth-forms/password-reset-form.tsx +19 -7
- package/client/extensions/nocobase-auth-forms/password-reset-request-form.tsx +15 -7
- package/client/extensions/nocobase-device-approval/device-approval.tsx +7 -2
- package/client/extensions/nocobase-device-approval/locales/en-US.ts +16 -0
- package/client/extensions/nocobase-device-approval/locales/zh-CN.ts +7 -0
- package/client/extensions/nocobase-file-component-ui/components/file-preview-dialog.tsx +20 -17
- package/client/extensions/nocobase-workflow-management/README.md +3 -1
- package/client/extensions/nocobase-workflow-management/definition-list.tsx +19 -7
- package/client/extensions/nocobase-workflow-management/execution-list.tsx +61 -48
- package/client/hooks/use-list-data.ts +76 -0
- package/client/hooks/use-list-location.ts +23 -0
- package/client/hooks/use-list-tabs.ts +42 -0
- package/client/hooks/use-table-url-state.ts +143 -30
- package/client/locales/system/en-US.ts +24 -1
- package/client/locales/system/settings-en-US.ts +8 -10
- package/client/locales/system/settings-zh-CN.ts +8 -8
- package/client/locales/system/zh-CN.ts +22 -1
- package/client/pages/settings/default-access-form.tsx +44 -26
- package/client/pages/settings/default-access.tsx +100 -93
- package/client/pages/settings/hooks.tsx +4 -59
- package/client/pages/settings/page-permissions.ts +17 -0
- package/client/pages/settings/permissions-model.ts +54 -59
- package/client/pages/settings/role-form.tsx +98 -56
- package/client/pages/settings/roles.tsx +69 -95
- package/client/pages/settings/shared.tsx +43 -33
- package/client/pages/settings/user-form.tsx +51 -30
- package/client/pages/settings/users.tsx +105 -91
- package/client/routing/session-preparation.tsx +10 -3
- package/client/runtime.ts +0 -4
- package/config.example.yml +24 -0
- package/database/main/migrations/202611020002_create_mutation_receipts.ts +19 -0
- package/package.json +24 -21
- package/server/app.ts +0 -2
- package/server/config/auth.ts +2 -0
- package/server/config/concurrency.ts +3 -0
- package/server/config/index.ts +3 -0
- package/server/config/jobs.ts +1 -13
- package/server/config/notification.ts +1 -1
- package/server/config/queue.ts +1 -7
- package/server/plugins.ts +2 -0
- package/server/providers/index.ts +4 -1
- package/server/providers/submission.ts +61 -0
- package/tests/client/auth-i18n.test.tsx +9 -0
- package/tests/client/back-button.test.tsx +3 -3
- package/tests/client/data-table.test.tsx +9 -4
- package/tests/client/default-access.test.tsx +99 -5
- package/tests/client/form-feedback.test.tsx +589 -0
- package/tests/client/list-feedback.test.tsx +301 -0
- package/tests/client/list-recovery.test.tsx +373 -0
- package/tests/client/list-tabs.test.tsx +121 -0
- package/tests/client/manual-list-retry.test.tsx +190 -0
- package/tests/client/page-permission-navigation.test.ts +8 -0
- package/tests/client/permission-editor.test.tsx +191 -21
- package/tests/client/permission-filter-binding.test.tsx +1 -1
- package/tests/client/permission-workspace.test.ts +13 -0
- package/tests/client/role-return.test.tsx +2 -1
- package/tests/client/session-preparation.test.tsx +24 -1
- package/tests/client/settings-continuity.test.tsx +85 -5
- package/tests/client/settings-feedback.test.tsx +32 -15
- package/tests/client/settings-permissions.test.ts +35 -0
- package/tests/client/settings.test.tsx +2 -1
- package/tests/client/table-url-state.test.tsx +117 -3
- package/tests/client/user-drawer.test.tsx +2 -1
- package/tests/client/user-permissions.test.tsx +30 -5
- package/tests/database/mutation-receipts.test.ts +45 -0
- package/tests/fixtures/form-feedback/README.md +33 -0
- package/tests/fixtures/form-feedback/equipment-detail.tsx +11 -0
- package/tests/fixtures/form-feedback/equipment-layout.tsx +15 -0
- package/tests/fixtures/form-feedback/equipment-migration.ts +27 -0
- package/tests/fixtures/form-feedback/equipment-server.ts +197 -0
- package/tests/fixtures/form-feedback/equipment-validation.tsx +94 -0
- package/tests/fixtures/form-feedback/equipment.tsx +369 -0
- package/tests/fixtures/form-feedback/verify-inline.mjs +174 -0
- package/tests/fixtures/form-feedback/verify.mjs +373 -0
- package/tests/fixtures/permission-alignment.html +16 -0
- package/tests/fixtures/permission-alignment.tsx +124 -0
- package/tests/fixtures/submission/README.md +35 -0
- package/tests/fixtures/submission/browser.test.ts +478 -0
- package/tests/fixtures/submission/migration.ts +61 -0
- package/tests/fixtures/submission/page.tsx +138 -0
- package/tests/fixtures/submission/playwright.config.ts +11 -0
- package/tests/fixtures/submission/routes.ts +208 -0
- package/tests/helpers/settings-client.tsx +31 -0
- package/tests/playwright/permission-alignment.test.ts +109 -0
- package/tests/server/auth-email-config.test.ts +79 -0
- package/tests/server/concurrency.test.ts +61 -0
- package/tests/server/config.test.ts +34 -14
- package/tests/server/member-role.test.ts +7 -1
- package/tests/server/submission-settings.test.ts +241 -0
- package/client/route-overrides.ts +0 -9
- 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
|
-
'
|
|
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='
|
|
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='
|
|
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
|
-
{
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
+
}
|