@nocobase/app-template-default 1.0.0-beta.75 → 1.0.0-beta.76

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 (43) hide show
  1. package/.agents/rules/build-and-deploy.md +4 -0
  2. package/.agents/rules/components.md +3 -1
  3. package/README.MD +2 -2
  4. package/client/components/data-table/data-table.tsx +2 -0
  5. package/client/components/data-table/pagination.tsx +6 -6
  6. package/client/components/flow-diagram.tsx +645 -83
  7. package/client/components/flow-layout.ts +117 -7
  8. package/client/components/lifecycle-diagram.tsx +175 -37
  9. package/client/components/record-link.tsx +20 -0
  10. package/client/components/required-role-setup.tsx +93 -0
  11. package/client/components/role-list.tsx +21 -5
  12. package/client/components/setup-readiness-state.ts +135 -0
  13. package/client/components/setup-readiness.tsx +91 -0
  14. package/client/components/ui/combobox.tsx +300 -0
  15. package/client/extensions/nocobase-workflow-management/README.md +3 -1
  16. package/client/extensions/nocobase-workflow-management/definition-list.tsx +19 -13
  17. package/client/extensions/nocobase-workflow-management/layout.tsx +1 -1
  18. package/client/hooks/use-table-url-state.ts +93 -0
  19. package/client/lib/required-role-setup.ts +98 -0
  20. package/client/lib/return-to.ts +19 -0
  21. package/client/locales/system/en-US.ts +10 -0
  22. package/client/locales/system/settings-en-US.ts +37 -1
  23. package/client/locales/system/settings-zh-CN.ts +33 -2
  24. package/client/locales/system/zh-CN.ts +10 -0
  25. package/client/pages/settings/default-access-form.tsx +4 -1
  26. package/client/pages/settings/default-access.tsx +40 -8
  27. package/client/pages/settings/role-form.tsx +1 -1
  28. package/client/pages/settings/roles.tsx +44 -6
  29. package/client/pages/settings/shared.tsx +15 -13
  30. package/client/pages/settings/user-detail.tsx +15 -259
  31. package/client/pages/settings/user-form.tsx +299 -147
  32. package/client/pages/settings/users.tsx +211 -123
  33. package/config.example.yml +6 -5
  34. package/package.json +11 -11
  35. package/tests/components/default-access.test.tsx +141 -2
  36. package/tests/components/settings-continuity.test.tsx +426 -0
  37. package/tests/components/settings-feedback.test.tsx +304 -0
  38. package/tests/components/settings.test.tsx +1 -1
  39. package/tests/components/setup-readiness.test.tsx +370 -0
  40. package/tests/components/table-url-state.test.tsx +95 -0
  41. package/tests/components/user-drawer.test.tsx +526 -0
  42. package/tests/logic/return-to.test.ts +19 -0
  43. package/tsconfig.json +1 -0
@@ -12,39 +12,55 @@ import {
12
12
  type UserRoleScopeOption,
13
13
  } from '@nocobase/app-plugin-users/client/user-client';
14
14
  import { useTranslation } from '@nocobase/i18n/client';
15
- import { useEffect, useMemo, useRef, useState, type ReactElement } from 'react';
15
+ import {
16
+ useEffect,
17
+ useId,
18
+ useMemo,
19
+ useRef,
20
+ useState,
21
+ type ReactElement,
22
+ } from 'react';
16
23
  import {
17
24
  useLocation,
18
25
  useNavigate,
19
26
  useOutletContext,
20
27
  useParams,
28
+ useResolvedPath,
21
29
  } from 'react-router';
22
30
  import { RouteDialog } from '#components/route-dialog';
31
+ import { RouteDrawer } from '#components/route-drawer';
23
32
  import { Button } from '#components/ui/button';
24
33
  import { Input } from '#components/ui/input';
25
34
  import { Label } from '#components/ui/label';
26
- import { Switch } from '#components/ui/switch';
27
35
  import {
28
- Select,
29
- SelectContent,
30
- SelectItem,
31
- SelectTrigger,
32
- SelectValue,
33
- } from '#components/ui/select';
34
- import { SettingsError, SettingsLoading } from './shared.js';
36
+ Combobox,
37
+ ComboboxTrigger,
38
+ ComboboxInput,
39
+ ComboboxContent,
40
+ ComboboxList,
41
+ ComboboxItem,
42
+ ComboboxEmpty,
43
+ } from '#components/ui/combobox';
44
+ import { ConfirmAction, SettingsError, SettingsLoading } from './shared.js';
35
45
 
36
46
  export function RoleSelection({
37
47
  scope,
38
48
  value,
39
49
  onChange,
40
50
  disabled = false,
51
+ id,
52
+ error,
41
53
  }: {
42
54
  readonly scope: UserRoleScopeOption;
43
55
  readonly value: UserRoleValue;
44
56
  readonly onChange: (value: UserRoleValue) => void;
45
57
  readonly disabled?: boolean;
58
+ readonly id?: string;
59
+ readonly error?: string;
46
60
  }): ReactElement {
47
61
  const { t } = useTranslation();
62
+ const generatedId = useId();
63
+ const triggerId = id ?? generatedId;
48
64
  const label = scope.labelI18nKey
49
65
  ? t(scope.labelI18nKey, {
50
66
  ns: scope.labelI18nNs,
@@ -58,76 +74,106 @@ export function RoleSelection({
58
74
  defaultValue: option.label,
59
75
  })
60
76
  : option.label;
77
+ const selected =
78
+ typeof value === 'string' ? (value ? [value] : []) : [...value];
79
+ const items = [
80
+ ...scope.options.map((option) => option.value),
81
+ ...selected.filter(
82
+ (key) => !scope.options.some((option) => option.value === key),
83
+ ),
84
+ ];
85
+ const itemLabel = (key: string) => {
86
+ const option = scope.options.find((item) => item.value === key);
87
+ return option ? text(option) : key;
88
+ };
89
+ const locked =
90
+ disabled ||
91
+ (scope.selection === 'single' &&
92
+ scope.options.some(
93
+ (option) =>
94
+ selected.includes(option.value) && option.removable === false,
95
+ ));
96
+ const summary = selected.length
97
+ ? selected.map(itemLabel).join(', ')
98
+ : t('settings.selectRoles');
61
99
  return (
62
100
  <div className='flex flex-col gap-2'>
63
- <Label>
101
+ <Label htmlFor={triggerId}>
64
102
  {label}
65
103
  {scope.requiredOnCreate ? ' *' : ''}
66
104
  </Label>
67
- {scope.selection === 'single' ? (
68
- <Select
69
- items={scope.options.map((option) => ({
70
- value: option.value,
71
- label: text(option),
72
- }))}
73
- value={typeof value === 'string' ? value : ''}
74
- disabled={
75
- disabled ||
105
+ <Combobox
106
+ items={items}
107
+ itemToStringLabel={itemLabel}
108
+ multiple={scope.selection === 'multiple'}
109
+ value={scope.selection === 'single' ? (selected[0] ?? null) : selected}
110
+ disabled={locked}
111
+ onValueChange={(next) => {
112
+ const proposed =
113
+ next === null ? [] : typeof next === 'string' ? [next] : next;
114
+ if (
76
115
  scope.options.some(
77
- (option) => option.value === value && option.removable === false,
116
+ (option) =>
117
+ (selected.includes(option.value) &&
118
+ !proposed.includes(option.value) &&
119
+ option.removable === false) ||
120
+ (!selected.includes(option.value) &&
121
+ proposed.includes(option.value) &&
122
+ option.assignable === false),
78
123
  )
124
+ )
125
+ return;
126
+ onChange(
127
+ scope.selection === 'single' ? (proposed[0] ?? '') : proposed,
128
+ );
129
+ }}
130
+ >
131
+ <ComboboxTrigger
132
+ id={triggerId}
133
+ aria-label={label}
134
+ aria-invalid={Boolean(error)}
135
+ aria-describedby={error ? `${triggerId}-error` : undefined}
136
+ disabled={locked}
137
+ render={
138
+ <Button
139
+ variant='outline'
140
+ className='w-full justify-between font-normal'
141
+ />
79
142
  }
80
- onValueChange={(next) => {
81
- if (next !== null) onChange(next);
82
- }}
83
143
  >
84
- <SelectTrigger aria-label={label} className='w-full'>
85
- <SelectValue />
86
- </SelectTrigger>
87
- <SelectContent className='w-auto max-w-[min(var(--container-sm),var(--available-width))] min-w-(--anchor-width) [&_[data-slot=select-item]>:first-child]:whitespace-normal'>
88
- {scope.options.map((option) => (
89
- <SelectItem
90
- key={option.value}
91
- value={option.value}
92
- disabled={option.assignable === false && value !== option.value}
93
- >
94
- {text(option)}
95
- </SelectItem>
96
- ))}
97
- </SelectContent>
98
- </Select>
99
- ) : (
100
- <div className='flex flex-col gap-2 rounded-lg border p-3'>
101
- {scope.options.map((option) => {
102
- const current = typeof value === 'string' ? [] : value;
103
- const checked = current.includes(option.value);
104
- return (
105
- <label
106
- key={option.value}
107
- className='flex items-center gap-3 text-sm'
108
- >
109
- <Switch
110
- checked={checked}
144
+ <span className='min-w-0 truncate' title={summary}>
145
+ {summary}
146
+ </span>
147
+ </ComboboxTrigger>
148
+ <ComboboxContent>
149
+ <ComboboxInput
150
+ aria-label={t('settings.searchRoles')}
151
+ placeholder={t('settings.searchRoles')}
152
+ showTrigger={false}
153
+ />
154
+ <ComboboxEmpty>{t('settings.noRolesFound')}</ComboboxEmpty>
155
+ <ComboboxList>
156
+ {(key: string) => {
157
+ const option = scope.options.find((item) => item.value === key);
158
+ return (
159
+ <ComboboxItem
160
+ key={key}
161
+ value={key}
111
162
  disabled={
112
- disabled ||
113
- (checked
114
- ? option.removable === false
115
- : option.assignable === false)
116
- }
117
- onCheckedChange={(next) =>
118
- onChange(
119
- next
120
- ? [...current, option.value]
121
- : current.filter((item) => item !== option.value),
122
- )
163
+ selected.includes(key)
164
+ ? option?.removable === false
165
+ : option?.assignable === false
123
166
  }
124
- />
125
- {text(option)}
126
- </label>
127
- );
128
- })}
129
- </div>
130
- )}
167
+ >
168
+ <span className='min-w-0 whitespace-normal break-words'>
169
+ {itemLabel(key)}
170
+ </span>
171
+ </ComboboxItem>
172
+ );
173
+ }}
174
+ </ComboboxList>
175
+ </ComboboxContent>
176
+ </Combobox>
131
177
  </div>
132
178
  );
133
179
  }
@@ -143,6 +189,7 @@ function UserEditor(): ReactElement {
143
189
  const location = useLocation();
144
190
  const parent = useOutletContext<{
145
191
  reload: (updated?: ManagedUser) => void;
192
+ listPath?: string;
146
193
  }>();
147
194
  const data = useSettingsData<{ data: ManagedUser }>(
148
195
  userId ? `users/${encodeURIComponent(userId)}` : 'users/options',
@@ -175,7 +222,38 @@ function UserEditor(): ReactElement {
175
222
  email: initial?.email ?? '',
176
223
  password: '',
177
224
  };
178
- const [roles, setRoles] = useState<Record<string, UserRoleValue>>({});
225
+ const [roleDraft, setRoleDraft] = useState<Record<string, UserRoleValue>>({});
226
+ const roles = { ...(initial?.roleScopes ?? {}), ...roleDraft };
227
+ const changedRoles = Object.fromEntries(
228
+ Object.entries(roleDraft).filter(
229
+ ([key, value]) =>
230
+ JSON.stringify(value) !==
231
+ JSON.stringify(
232
+ initial?.roleScopes[key] ?? (typeof value === 'string' ? '' : []),
233
+ ),
234
+ ),
235
+ );
236
+ const removedRoles = (options.data?.data.roleScopes ?? []).flatMap(
237
+ (scope) => {
238
+ const before = initial?.roleScopes[scope.key] ?? [];
239
+ const after = roles[scope.key] ?? [];
240
+ return (typeof before === 'string' ? [before] : before)
241
+ .filter(
242
+ (key) => !(typeof after === 'string' ? [after] : after).includes(key),
243
+ )
244
+ .map((key) => {
245
+ const option = scope.options.find((item) => item.value === key);
246
+ return option?.labelI18nKey
247
+ ? t(option.labelI18nKey, {
248
+ ns: option.labelI18nNs,
249
+ defaultValue: option.label,
250
+ })
251
+ : (option?.label ?? key);
252
+ });
253
+ },
254
+ );
255
+ const [confirmRoles, setConfirmRoles] = useState(false);
256
+ const formRef = useRef<HTMLFormElement>(null);
179
257
  const [dirty, setDirty] = useState(false);
180
258
  const [busy, setBusy] = useState(false);
181
259
  const leave = useSettingsLeave(dirty, busy);
@@ -183,10 +261,9 @@ function UserEditor(): ReactElement {
183
261
  const [serverErrors, setServerErrors] = useState<Record<string, string>>({});
184
262
  const [submitted, setSubmitted] = useState(false);
185
263
 
264
+ const parentPath = useResolvedPath('..').pathname;
186
265
  const closeTo = {
187
- pathname: creating
188
- ? '/settings/users'
189
- : `/settings/users/${encodeURIComponent(userId)}`,
266
+ pathname: creating ? parentPath : (parent.listPath ?? parentPath),
190
267
  search: location.search,
191
268
  };
192
269
  const fieldError = (name: keyof typeof fields): string | undefined => {
@@ -201,10 +278,10 @@ function UserEditor(): ReactElement {
201
278
  return t('settings.invalidEmail');
202
279
  return;
203
280
  };
204
- const save = async (event: React.FormEvent<HTMLFormElement>) => {
205
- event.preventDefault();
281
+ const save = async (form: HTMLFormElement, confirmed = false) => {
282
+ if (busy || !(creating ? canCreate && canAssign : canUpdate || canAssign))
283
+ return;
206
284
  setSubmitted(true);
207
- const form = event.currentTarget;
208
285
  if (
209
286
  !fields.name.trim() ||
210
287
  !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(fields.email) ||
@@ -213,7 +290,12 @@ function UserEditor(): ReactElement {
213
290
  form.querySelector<HTMLInputElement>('input:invalid')?.focus();
214
291
  return;
215
292
  }
216
- if (data.error || (creating && (options.error || !options.data))) return;
293
+ if (
294
+ data.error ||
295
+ data.loading ||
296
+ (canAssign && (options.loading || options.error || !options.data))
297
+ )
298
+ return;
217
299
  if (creating) {
218
300
  const missing = (options.data?.data.roleScopes ?? []).filter(
219
301
  (scope) => scope.requiredOnCreate && !roles[scope.key]?.length,
@@ -224,9 +306,20 @@ function UserEditor(): ReactElement {
224
306
  missing.map((scope) => [scope.key, t('settings.required')]),
225
307
  ),
226
308
  );
309
+ document
310
+ .getElementById(
311
+ `${formId}-roles-${encodeURIComponent(missing[0].key)}`,
312
+ )
313
+ ?.focus();
227
314
  return;
228
315
  }
229
316
  }
317
+ if (!creating && !canUpdate && Object.keys(changedRoles).length === 0)
318
+ return;
319
+ if (!creating && removedRoles.length > 0 && !confirmed) {
320
+ setConfirmRoles(true);
321
+ return;
322
+ }
230
323
  setBusy(true);
231
324
  setError(undefined);
232
325
  setServerErrors({});
@@ -244,9 +337,16 @@ function UserEditor(): ReactElement {
244
337
  });
245
338
  else
246
339
  updated = await client.update(userId, {
247
- name: fields.name.trim(),
248
- email: fields.email.trim(),
249
- username: fields.username.trim() || null,
340
+ ...(canUpdate
341
+ ? {
342
+ name: fields.name.trim(),
343
+ email: fields.email.trim(),
344
+ username: fields.username.trim() || null,
345
+ }
346
+ : {}),
347
+ ...(canAssign && Object.keys(changedRoles).length
348
+ ? { roleScopes: changedRoles }
349
+ : {}),
250
350
  });
251
351
  setDirty(false);
252
352
  parent.reload(updated);
@@ -256,9 +356,11 @@ function UserEditor(): ReactElement {
256
356
  name: fields.name,
257
357
  }),
258
358
  });
359
+ setConfirmRoles(false);
259
360
  leave.allow();
260
- void navigate(closeTo);
361
+ void navigate(closeTo, { replace: true });
261
362
  } catch (next) {
363
+ setConfirmRoles(false);
262
364
  if (
263
365
  next instanceof ApiClientError &&
264
366
  [
@@ -284,51 +386,61 @@ function UserEditor(): ReactElement {
284
386
  setBusy(false);
285
387
  }
286
388
  };
287
- const allowed = creating ? canCreate && canAssign : canUpdate;
389
+ const allowed = creating ? canCreate && canAssign : canUpdate || canAssign;
288
390
  const formId = creating ? 'new-user' : 'edit-user';
289
- return (
290
- <RouteDialog
291
- className={creating ? undefined : 'sm:max-w-md'}
292
- title={t(creating ? 'settings.newUser' : 'settings.editUser')}
293
- closeTo={closeTo}
294
- beforeClose={leave.guard}
295
- footer={
296
- <>
297
- <Button
298
- variant='outline'
299
- disabled={busy}
300
- onClick={() => void navigate(closeTo)}
301
- >
302
- {t('settings.cancel')}
303
- </Button>
304
- {allowed && (
305
- <Button
306
- form={formId}
307
- type='submit'
308
- disabled={
309
- busy ||
310
- Boolean(data.error) ||
311
- (!creating && data.loading) ||
312
- (creating &&
313
- (options.loading || Boolean(options.error) || !options.data))
314
- }
315
- >
316
- {t(
317
- busy
318
- ? 'settings.saving'
319
- : creating
320
- ? 'settings.create'
321
- : 'settings.save',
322
- )}
323
- </Button>
391
+ const focusedRef = useRef(false);
392
+ useEffect(() => {
393
+ if (creating || data.loading || data.error || focusedRef.current) return;
394
+ focusedRef.current = true;
395
+ const frame = requestAnimationFrame(() =>
396
+ document.getElementById(`${formId}-name`)?.focus(),
397
+ );
398
+ return () => cancelAnimationFrame(frame);
399
+ }, [creating, data.error, data.loading, formId]);
400
+ const actions = (
401
+ <>
402
+ <Button
403
+ variant='outline'
404
+ disabled={busy}
405
+ onClick={() => void navigate(closeTo, { replace: true })}
406
+ >
407
+ {t('settings.cancel')}
408
+ </Button>
409
+ {allowed && (
410
+ <Button
411
+ form={formId}
412
+ type='submit'
413
+ disabled={
414
+ busy ||
415
+ (!creating &&
416
+ (!dirty ||
417
+ (!canUpdate && Object.keys(changedRoles).length === 0))) ||
418
+ Boolean(data.error) ||
419
+ (!creating &&
420
+ (data.loading ||
421
+ (canAssign &&
422
+ (options.loading ||
423
+ Boolean(options.error) ||
424
+ !options.data)))) ||
425
+ (creating &&
426
+ (options.loading || Boolean(options.error) || !options.data))
427
+ }
428
+ >
429
+ {t(
430
+ busy
431
+ ? 'settings.saving'
432
+ : creating
433
+ ? 'settings.create'
434
+ : 'settings.save',
324
435
  )}
325
- </>
326
- }
327
- >
328
- <SettingsError error={data.error} retry={data.reload} />
329
- {creating && (
330
- <SettingsError error={options.error} retry={options.reload} />
436
+ </Button>
331
437
  )}
438
+ </>
439
+ );
440
+ const body = (
441
+ <>
442
+ <SettingsError error={data.error} retry={data.reload} />
443
+ <SettingsError error={options.error} retry={options.reload} />
332
444
  <SettingsError error={error} />
333
445
  {!creating && data.loading ? (
334
446
  <SettingsLoading />
@@ -336,7 +448,11 @@ function UserEditor(): ReactElement {
336
448
  !data.error && (
337
449
  <form
338
450
  id={formId}
339
- onSubmit={(event) => void save(event)}
451
+ ref={formRef}
452
+ onSubmit={(event) => {
453
+ event.preventDefault();
454
+ void save(event.currentTarget);
455
+ }}
340
456
  noValidate
341
457
  className='flex flex-col gap-4'
342
458
  >
@@ -365,7 +481,7 @@ function UserEditor(): ReactElement {
365
481
  : 'text'
366
482
  }
367
483
  autoComplete={name === 'password' ? 'new-password' : 'off'}
368
- disabled={!allowed || busy}
484
+ disabled={!(creating ? allowed : canUpdate) || busy}
369
485
  value={fields[name]}
370
486
  aria-invalid={Boolean(fieldError(name))}
371
487
  aria-describedby={
@@ -390,33 +506,69 @@ function UserEditor(): ReactElement {
390
506
  )}
391
507
  </div>
392
508
  ))}
393
- {creating &&
394
- options.data?.data.roleScopes.map((scope) => (
395
- <div key={scope.key}>
396
- <RoleSelection
397
- scope={scope}
398
- disabled={!allowed || busy}
399
- value={
400
- roles[scope.key] ??
401
- (scope.selection === 'single' ? '' : [])
402
- }
403
- onChange={(value) => {
404
- setRoles((old) => ({ ...old, [scope.key]: value }));
405
- setServerErrors((old) => ({ ...old, [scope.key]: '' }));
406
- setDirty(true);
407
- }}
408
- />
409
- {serverErrors[scope.key] && (
410
- <p className='text-xs text-destructive'>
411
- {serverErrors[scope.key]}
412
- </p>
413
- )}
414
- </div>
415
- ))}
509
+ {options.data?.data.roleScopes.map((scope) => (
510
+ <div key={scope.key}>
511
+ <RoleSelection
512
+ scope={scope}
513
+ id={`${formId}-roles-${encodeURIComponent(scope.key)}`}
514
+ error={serverErrors[scope.key]}
515
+ disabled={!canAssign || busy}
516
+ value={
517
+ roles[scope.key] ?? (scope.selection === 'single' ? '' : [])
518
+ }
519
+ onChange={(value) => {
520
+ setRoleDraft((old) => ({ ...old, [scope.key]: value }));
521
+ setServerErrors((old) => ({ ...old, [scope.key]: '' }));
522
+ setDirty(true);
523
+ }}
524
+ />
525
+ {serverErrors[scope.key] && (
526
+ <p
527
+ id={`${formId}-roles-${encodeURIComponent(scope.key)}-error`}
528
+ className='text-xs text-destructive'
529
+ >
530
+ {serverErrors[scope.key]}
531
+ </p>
532
+ )}
533
+ </div>
534
+ ))}
416
535
  </form>
417
536
  )
418
537
  )}
419
538
  {leave.dialog}
539
+ </>
540
+ );
541
+ if (!creating)
542
+ return (
543
+ <RouteDrawer
544
+ title={t('settings.editUser')}
545
+ description={initial?.name}
546
+ closeTo={closeTo}
547
+ beforeClose={leave.guard}
548
+ footer={actions}
549
+ >
550
+ {body}
551
+ <ConfirmAction
552
+ open={confirmRoles}
553
+ title={t('settings.revokeRoleTitle', { name: fields.name })}
554
+ description={`${t('settings.revokeRoleHint')} ${removedRoles.join(', ')}`}
555
+ confirmLabel={t('settings.save')}
556
+ busy={busy}
557
+ onCancel={() => setConfirmRoles(false)}
558
+ onConfirm={() => {
559
+ if (formRef.current) void save(formRef.current, true);
560
+ }}
561
+ />
562
+ </RouteDrawer>
563
+ );
564
+ return (
565
+ <RouteDialog
566
+ title={t('settings.newUser')}
567
+ closeTo={closeTo}
568
+ beforeClose={leave.guard}
569
+ footer={actions}
570
+ >
571
+ {body}
420
572
  </RouteDialog>
421
573
  );
422
574
  }