@nocobase/app-template-default 1.0.0-beta.88 → 1.0.0-beta.90

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 (103) hide show
  1. package/.agents/rules/components.md +3 -1
  2. package/.agents/rules/shell.md +12 -2
  3. package/AGENTS.md +1 -1
  4. package/LICENSE.txt +129 -0
  5. package/client/components/back-button.tsx +37 -12
  6. package/client/components/brand-loading-mark.tsx +5 -2
  7. package/client/components/brand-mark-geometry.ts +3 -3
  8. package/client/components/breadcrumbs.tsx +47 -3
  9. package/client/components/data-table/data-table.tsx +90 -19
  10. package/client/components/data-table/pagination.tsx +17 -2
  11. package/client/components/list-feedback.tsx +31 -22
  12. package/client/components/loading.tsx +127 -16
  13. package/client/components/pending-button.tsx +111 -0
  14. package/client/components/role-list.tsx +7 -0
  15. package/client/components/route-child-page.tsx +72 -1
  16. package/client/components/route-overlay.tsx +149 -15
  17. package/client/components/task-progress.tsx +155 -0
  18. package/client/components/use-route-overlay.ts +8 -11
  19. package/client/extensions/nocobase-file-component-ui/components/file-upload-field.tsx +62 -32
  20. package/client/extensions/nocobase-file-component-ui/locales/de-DE.ts +2 -0
  21. package/client/extensions/nocobase-file-component-ui/locales/en-US.ts +1 -0
  22. package/client/extensions/nocobase-file-component-ui/locales/es-ES.ts +1 -0
  23. package/client/extensions/nocobase-file-component-ui/locales/fr-FR.ts +1 -0
  24. package/client/extensions/nocobase-file-component-ui/locales/id-ID.ts +1 -0
  25. package/client/extensions/nocobase-file-component-ui/locales/ja-JP.ts +2 -0
  26. package/client/extensions/nocobase-file-component-ui/locales/pt-BR.ts +1 -0
  27. package/client/extensions/nocobase-file-component-ui/locales/vi-VN.ts +1 -0
  28. package/client/extensions/nocobase-file-component-ui/locales/zh-CN.ts +1 -0
  29. package/client/extensions/nocobase-file-component-ui/locales/zh-TW.ts +1 -0
  30. package/client/extensions/nocobase-workflow-management/README.md +5 -5
  31. package/client/extensions/nocobase-workflow-management/definition-list.tsx +2 -9
  32. package/client/extensions/nocobase-workflow-management/execution-list.tsx +2 -4
  33. package/client/layouts/app-layout.tsx +0 -4
  34. package/client/lib/navigation-transform.ts +29 -1
  35. package/client/locales/system/en-US.ts +23 -0
  36. package/client/locales/system/zh-CN.ts +23 -0
  37. package/client/pages/home/index.tsx +66 -51
  38. package/client/pages/settings/database-explorer/en-US.ts +33 -0
  39. package/client/pages/settings/database-explorer/index.tsx +358 -0
  40. package/client/pages/settings/database-explorer/routes.ts +14 -0
  41. package/client/pages/settings/database-explorer/zh-CN.ts +30 -0
  42. package/client/pages/settings/default-access.tsx +1 -1
  43. package/client/pages/settings/roles.tsx +27 -27
  44. package/client/pages/settings/routes.ts +15 -7
  45. package/client/pages/settings/user-detail.tsx +3 -17
  46. package/client/pages/settings/user-form.tsx +9 -15
  47. package/client/pages/settings/users.tsx +11 -27
  48. package/client/plugins.ts +8 -1
  49. package/client/routes.ts +2 -1
  50. package/client/routing/client-route.tsx +199 -37
  51. package/client/routing/navigation-destination.ts +42 -0
  52. package/client/routing/route-leave-boundary.tsx +13 -1
  53. package/client/styles.css +18 -1
  54. package/config.example.yml +17 -11
  55. package/index.html +8 -11
  56. package/package.json +27 -27
  57. package/server/config/auth.ts +1 -1
  58. package/server/config/index.ts +3 -0
  59. package/server/config/jobs.ts +16 -8
  60. package/server/config/queue.ts +14 -13
  61. package/server/config/realtime.ts +4 -0
  62. package/tests/client/auth-i18n.test.tsx +1 -1
  63. package/tests/client/authenticated-client-fixture.test.tsx +37 -0
  64. package/tests/client/back-button.test.tsx +34 -0
  65. package/tests/client/client-route-recovery.test.tsx +93 -1
  66. package/tests/client/client-routes.test.ts +5 -3
  67. package/tests/client/database-explorer-integration.test.tsx +30 -0
  68. package/tests/client/database-explorer.test.tsx +152 -0
  69. package/tests/client/foundation-integration.test.ts +42 -29
  70. package/tests/client/home.test.tsx +26 -85
  71. package/tests/client/layout-i18n.test.tsx +83 -2
  72. package/tests/client/list-feedback.test.tsx +5 -5
  73. package/tests/client/list-loading.test.tsx +397 -0
  74. package/tests/client/loading.test.tsx +20 -11
  75. package/tests/client/manual-list-retry.test.tsx +2 -2
  76. package/tests/client/navigation-parent.test.tsx +172 -0
  77. package/tests/client/pending-button.test.tsx +106 -0
  78. package/tests/client/permission-management-tabs.test.tsx +48 -12
  79. package/tests/client/primitive-labels.test.tsx +2 -2
  80. package/tests/client/role-return.test.tsx +21 -3
  81. package/tests/client/roles.test.tsx +84 -3
  82. package/tests/client/route-leave-recheck.test.tsx +74 -0
  83. package/tests/client/route-presentation.test.tsx +557 -0
  84. package/tests/client/session-preparation.test.tsx +20 -17
  85. package/tests/client/settings-continuity.test.tsx +35 -11
  86. package/tests/client/settings-home.test.tsx +4 -2
  87. package/tests/client/settings-idle-list.test.tsx +124 -0
  88. package/tests/client/settings-navigation.test.ts +20 -0
  89. package/tests/client/user-drawer.test.tsx +3 -4
  90. package/tests/fixtures/dialog-footer.html +12 -0
  91. package/tests/fixtures/dialog-footer.tsx +215 -0
  92. package/tests/helpers/authenticated-client.tsx +77 -22
  93. package/tests/playwright/dialog-footer.test.ts +227 -0
  94. package/tests/project/bootstrap-loading.test.ts +60 -0
  95. package/tests/server/app-server.test.ts +18 -12
  96. package/tests/server/auth-email-config.test.ts +21 -0
  97. package/tests/server/backends-config.test.ts +118 -0
  98. package/tests/server/concurrency.test.ts +1 -1
  99. package/tests/server/config.test.ts +22 -14
  100. package/tests/server/lifecycle.test.ts +2 -2
  101. package/tests/server/realtime-config.test.ts +114 -0
  102. package/client/pages/home/capabilities.ts +0 -62
  103. package/client/pages/home/prompt-dialog.tsx +0 -122
@@ -0,0 +1,358 @@
1
+ import { ApiClientError, useApiClient } from '@nocobase/app-client';
2
+ import { DatabaseExplorerClient } from '@nocobase/app-plugin-database-explorer/client';
3
+ import { useTranslation } from '@nocobase/i18n/client';
4
+ import {
5
+ useCallback,
6
+ useEffect,
7
+ useMemo,
8
+ useState,
9
+ type ReactNode,
10
+ } from 'react';
11
+ import { Link, useSearchParams } from 'react-router';
12
+ import { PageContainer } from '#components/page-container';
13
+ import { PageHeader } from '#components/page-header';
14
+ import { Button } from '#components/ui/button';
15
+ import { Input } from '#components/ui/input';
16
+ import {
17
+ Select,
18
+ SelectContent,
19
+ SelectItem,
20
+ SelectTrigger,
21
+ SelectValue,
22
+ } from '#components/ui/select';
23
+ import {
24
+ Table,
25
+ TableBody,
26
+ TableCell,
27
+ TableHead,
28
+ TableHeader,
29
+ TableRow,
30
+ } from '#components/ui/table';
31
+
32
+ /** A keyed read boundary prevents a late response from replacing a newer selection. */
33
+ function Read<T>({
34
+ load,
35
+ children,
36
+ }: {
37
+ load: () => Promise<T>;
38
+ children: (data: T) => ReactNode;
39
+ }) {
40
+ const { t } = useTranslation();
41
+ const [attempt, setAttempt] = useState(0);
42
+ const [result, setResult] = useState<{ data: T } | { error: unknown }>();
43
+ useEffect(() => {
44
+ let active = true;
45
+ load().then(
46
+ (data) => {
47
+ if (active) setResult({ data });
48
+ },
49
+ (error: unknown) => {
50
+ if (active) setResult({ error });
51
+ },
52
+ );
53
+ return () => {
54
+ active = false;
55
+ };
56
+ }, [load, attempt]);
57
+ if (!result)
58
+ return (
59
+ <p role='status' className='p-4 text-sm text-muted-foreground'>
60
+ {t('databaseExplorer.loading')}
61
+ </p>
62
+ );
63
+ if ('error' in result) {
64
+ const status =
65
+ result.error instanceof ApiClientError ? result.error.status : undefined;
66
+ const key =
67
+ status === 401 ? 'expired' : status === 403 ? 'forbidden' : 'failed';
68
+ return (
69
+ <div role='alert' className='space-y-3 p-4'>
70
+ <p>{t(`databaseExplorer.${key}`)}</p>
71
+ {status === 401 ? (
72
+ <Link to='/login'>{t('databaseExplorer.signIn')}</Link>
73
+ ) : status !== 403 ? (
74
+ <Button
75
+ variant='outline'
76
+ onClick={() => {
77
+ setResult(undefined);
78
+ setAttempt((value) => value + 1);
79
+ }}
80
+ >
81
+ {t('databaseExplorer.retry')}
82
+ </Button>
83
+ ) : null}
84
+ </div>
85
+ );
86
+ }
87
+ return children(result.data);
88
+ }
89
+
90
+ export default function DatabaseExplorerPage() {
91
+ const { t } = useTranslation();
92
+ const api = useApiClient();
93
+ const client = useMemo(() => new DatabaseExplorerClient(api), [api]);
94
+ const load = useCallback(() => client.connections(), [client]);
95
+ const [params, setParams] = useSearchParams();
96
+ return (
97
+ <PageContainer className='space-y-6'>
98
+ <PageHeader
99
+ title={t('databaseExplorer.title')}
100
+ description={t('databaseExplorer.description')}
101
+ />
102
+ <Read load={load}>
103
+ {({ items, default: defaultConnection }) => {
104
+ if (!items.length)
105
+ return <p>{t('databaseExplorer.emptyConnections')}</p>;
106
+ const connection =
107
+ params.get('connection') ?? defaultConnection ?? items[0].name;
108
+ return (
109
+ <div className='space-y-6'>
110
+ <div className='max-w-sm space-y-2'>
111
+ <label
112
+ htmlFor='database-connection'
113
+ className='text-sm font-medium'
114
+ >
115
+ {t('databaseExplorer.connections')}
116
+ </label>
117
+ <Select
118
+ value={connection}
119
+ onValueChange={(value) => {
120
+ if (value) setParams({ connection: value });
121
+ }}
122
+ >
123
+ <SelectTrigger id='database-connection' className='w-full'>
124
+ <SelectValue />
125
+ </SelectTrigger>
126
+ <SelectContent>
127
+ {items.map((item) => (
128
+ <SelectItem key={item.name} value={item.name}>
129
+ {item.name} · {item.dialect}
130
+ {item.isDefault
131
+ ? ` · ${t('databaseExplorer.defaultConnection')}`
132
+ : ''}
133
+ </SelectItem>
134
+ ))}
135
+ </SelectContent>
136
+ </Select>
137
+ </div>
138
+ <ConnectionTables
139
+ key={connection}
140
+ client={client}
141
+ connection={connection}
142
+ />
143
+ </div>
144
+ );
145
+ }}
146
+ </Read>
147
+ </PageContainer>
148
+ );
149
+ }
150
+
151
+ function ConnectionTables({
152
+ client,
153
+ connection,
154
+ }: {
155
+ client: DatabaseExplorerClient;
156
+ connection: string;
157
+ }) {
158
+ const { t } = useTranslation();
159
+ const [params, setParams] = useSearchParams();
160
+ const selected = params.get('table');
161
+ const query = params.get('q') ?? '';
162
+ const load = useCallback(
163
+ () => client.allCollections(connection),
164
+ [client, connection],
165
+ );
166
+ return (
167
+ <div className='grid min-w-0 gap-6 lg:grid-cols-[minmax(14rem,1fr)_minmax(0,3fr)]'>
168
+ <section
169
+ aria-label={t('databaseExplorer.tables')}
170
+ className='min-w-0 space-y-3'
171
+ >
172
+ <h2 className='font-heading text-base font-semibold'>
173
+ {t('databaseExplorer.tables')}
174
+ </h2>
175
+ <Input
176
+ aria-label={t('databaseExplorer.searchTables')}
177
+ placeholder={t('databaseExplorer.searchTables')}
178
+ value={query}
179
+ onChange={(event) => {
180
+ setParams(
181
+ (previous) => {
182
+ const next = new URLSearchParams(previous);
183
+ next.set('q', event.target.value);
184
+ return next;
185
+ },
186
+ { replace: true },
187
+ );
188
+ }}
189
+ />
190
+ <Read load={load}>
191
+ {({ items, truncated }) => {
192
+ const matches = items.filter((item) =>
193
+ `${item.name} ${item.tableName} ${item.title ?? ''}`
194
+ .toLocaleLowerCase()
195
+ .includes(query.toLocaleLowerCase()),
196
+ );
197
+ return (
198
+ <>
199
+ {truncated ? (
200
+ <p role='status' className='text-sm text-muted-foreground'>
201
+ {t('databaseExplorer.truncated')}
202
+ </p>
203
+ ) : null}
204
+ {matches.length ? (
205
+ <ul className='max-h-96 space-y-1 overflow-auto rounded-lg border p-2'>
206
+ {matches.map((item) => (
207
+ <li key={item.name}>
208
+ <Button
209
+ variant={
210
+ selected === item.name ? 'secondary' : 'ghost'
211
+ }
212
+ className='h-auto w-full justify-start whitespace-normal break-all text-left'
213
+ aria-pressed={selected === item.name}
214
+ onClick={() => {
215
+ setParams((previous) => {
216
+ const next = new URLSearchParams(previous);
217
+ next.set('connection', connection);
218
+ next.set('table', item.name);
219
+ return next;
220
+ });
221
+ }}
222
+ >
223
+ {item.title
224
+ ? `${item.title} (${item.name})`
225
+ : item.name}
226
+ </Button>
227
+ </li>
228
+ ))}
229
+ </ul>
230
+ ) : (
231
+ <p className='text-sm text-muted-foreground'>
232
+ {t('databaseExplorer.emptyTables')}
233
+ </p>
234
+ )}
235
+ </>
236
+ );
237
+ }}
238
+ </Read>
239
+ </section>
240
+ <section
241
+ aria-label={t('databaseExplorer.fields')}
242
+ className='min-w-0 space-y-3'
243
+ >
244
+ {selected ? (
245
+ <CollectionFields
246
+ key={selected}
247
+ client={client}
248
+ connection={connection}
249
+ collection={selected}
250
+ />
251
+ ) : (
252
+ <p className='rounded-lg border border-dashed p-6 text-sm text-muted-foreground'>
253
+ {t('databaseExplorer.selectTable')}
254
+ </p>
255
+ )}
256
+ </section>
257
+ </div>
258
+ );
259
+ }
260
+
261
+ function CollectionFields({
262
+ client,
263
+ connection,
264
+ collection,
265
+ }: {
266
+ client: DatabaseExplorerClient;
267
+ connection: string;
268
+ collection: string;
269
+ }) {
270
+ const { t } = useTranslation();
271
+ const [query, setQuery] = useState('');
272
+ const load = useCallback(
273
+ () => client.collection(connection, collection),
274
+ [client, connection, collection],
275
+ );
276
+ return (
277
+ <>
278
+ <h2 className='break-all font-heading text-base font-semibold'>
279
+ {collection}
280
+ </h2>
281
+ <Input
282
+ aria-label={t('databaseExplorer.searchFields')}
283
+ placeholder={t('databaseExplorer.searchFields')}
284
+ value={query}
285
+ onChange={(event) => setQuery(event.target.value)}
286
+ />
287
+ <Read load={load}>
288
+ {({ collection: artifact }) => {
289
+ const fields = (artifact.collection.fields ?? []).filter((field) =>
290
+ `${field.name} ${field.title ?? ''}`
291
+ .toLocaleLowerCase()
292
+ .includes(query.toLocaleLowerCase()),
293
+ );
294
+ return (
295
+ <>
296
+ {artifact.warnings.length ? (
297
+ <p role='status' className='text-sm text-muted-foreground'>
298
+ {t('databaseExplorer.warnings')}
299
+ </p>
300
+ ) : null}
301
+ {fields.length ? (
302
+ <div className='overflow-hidden rounded-lg border'>
303
+ <Table>
304
+ <TableHeader>
305
+ <TableRow>
306
+ {[
307
+ 'name',
308
+ 'type',
309
+ 'nullable',
310
+ 'primaryKey',
311
+ 'target',
312
+ ].map((key) => (
313
+ <TableHead key={key}>
314
+ {t(`databaseExplorer.${key}`)}
315
+ </TableHead>
316
+ ))}
317
+ </TableRow>
318
+ </TableHeader>
319
+ <TableBody>
320
+ {fields.map((field) => (
321
+ <TableRow key={field.name}>
322
+ <TableCell className='font-medium'>
323
+ {field.name}
324
+ {field.title ? (
325
+ <span className='block text-muted-foreground'>
326
+ {field.title}
327
+ </span>
328
+ ) : null}
329
+ </TableCell>
330
+ <TableCell>{field.type}</TableCell>
331
+ <TableCell>
332
+ {t(
333
+ `databaseExplorer.${field.nullable === undefined ? 'unknown' : field.nullable ? 'yes' : 'no'}`,
334
+ )}
335
+ </TableCell>
336
+ <TableCell>
337
+ {t(
338
+ `databaseExplorer.${field.primaryKey ? 'yes' : 'no'}`,
339
+ )}
340
+ </TableCell>
341
+ <TableCell>{field.target ?? '—'}</TableCell>
342
+ </TableRow>
343
+ ))}
344
+ </TableBody>
345
+ </Table>
346
+ </div>
347
+ ) : (
348
+ <p className='text-sm text-muted-foreground'>
349
+ {t('databaseExplorer.emptyFields')}
350
+ </p>
351
+ )}
352
+ </>
353
+ );
354
+ }}
355
+ </Read>
356
+ </>
357
+ );
358
+ }
@@ -0,0 +1,14 @@
1
+ import { defineAppRoutes } from '@nocobase/app-client/plugins';
2
+ import { DATABASE_EXPLORER_ACCESS } from '@nocobase/app-plugin-database-explorer/client';
3
+
4
+ export default defineAppRoutes([
5
+ {
6
+ parent: 'settingsSystem',
7
+ name: 'databaseExplorer',
8
+ path: '/database-explorer',
9
+ auth: 'required',
10
+ authz: DATABASE_EXPLORER_ACCESS,
11
+ breadcrumb: { title: 'databaseExplorer.title' },
12
+ componentLoader: () => import('./index.js'),
13
+ },
14
+ ]);
@@ -0,0 +1,30 @@
1
+ export default {
2
+ title: '数据库浏览器',
3
+ description: '查看应用的数据库连接、数据表和字段。',
4
+ connections: '数据库连接',
5
+ defaultConnection: '默认',
6
+ tables: '数据表',
7
+ searchTables: '搜索数据表',
8
+ fields: '字段',
9
+ searchFields: '搜索字段',
10
+ name: '名称',
11
+ type: '类型',
12
+ nullable: '允许为空',
13
+ primaryKey: '主键',
14
+ target: '关联表',
15
+ yes: '是',
16
+ no: '否',
17
+ unknown: '未指定',
18
+ emptyConnections: '尚未配置数据库连接。',
19
+ emptyTables: '没有找到数据表。',
20
+ emptyFields: '没有找到字段。',
21
+ selectTable: '选择数据表以查看字段。',
22
+ loading: '正在加载…',
23
+ failed: '无法加载数据库信息。',
24
+ forbidden: '你没有浏览此数据库的权限。',
25
+ expired: '登录已过期,请重新登录后继续。',
26
+ signIn: '登录',
27
+ retry: '重试',
28
+ truncated: '当前仅加载了部分数据表,请缩小数据库范围后查看其余数据表。',
29
+ warnings: '部分字段未能完整解析,显示的结构可能不完整。',
30
+ };
@@ -399,7 +399,7 @@ export default function DefaultAccessPage(): ReactElement {
399
399
  getRowId={(rule) => rule.key}
400
400
  showSelectedCount={false}
401
401
  pageSize={20}
402
- loading={rules.loading || catalog.loading}
402
+ loading={active && (rules.loading || catalog.loading)}
403
403
  listState={listState}
404
404
  onRetry={() => {
405
405
  rules.reload();
@@ -12,7 +12,6 @@ import { PlusIcon } from 'lucide-react';
12
12
  import { PermissionManagementPage } from './permission-management-page.js';
13
13
  import { Input } from '#components/ui/input';
14
14
  import { Button } from '#components/ui/button';
15
- import { Spinner } from '#components/ui/spinner';
16
15
  import { useTableUrlState } from '#hooks/use-table-url-state';
17
16
  import { RoleList } from '#components/role-list';
18
17
  import { ListFeedback, ListFeedbackActions } from '#components/list-feedback';
@@ -123,34 +122,35 @@ export default function RolesPage(): ReactElement {
123
122
  state={listState}
124
123
  onClear={clearSearch}
125
124
  onRetry={request.reload}
126
- busy={request.loading}
125
+ busy={active && request.loading}
127
126
  />
128
- {active && request.loading && request.data ? (
129
- <Spinner className='text-muted-foreground' />
130
- ) : null}
131
127
  </div>
132
- <ListFeedback state={listState}>
133
- <RoleList
134
- tableState={tableState}
135
- ariaLabel={t('settings.roles')}
136
- roles={rows.map((role) => ({
137
- key: role.key,
138
- title: text(role.title ?? role.key),
139
- to: {
140
- pathname: `${encodeURIComponent(role.key)}/edit`,
141
- search: location.search,
142
- },
143
- protected: Boolean(role.protection),
144
- }))}
145
- labels={{
146
- name: t('settings.roleName'),
147
- key: t('settings.key'),
148
- type: t('settings.roleType'),
149
- protected: t('settings.protected'),
150
- custom: t('settings.custom'),
151
- }}
152
- />
153
- </ListFeedback>
128
+ {active || listState !== 'loading' ? (
129
+ <ListFeedback state={listState}>
130
+ <RoleList
131
+ tableState={tableState}
132
+ // After a refresh failure the toolbar's retry button owns the wait.
133
+ loading={active && request.loading && listState === 'ready'}
134
+ ariaLabel={t('settings.roles')}
135
+ roles={rows.map((role) => ({
136
+ key: role.key,
137
+ title: text(role.title ?? role.key),
138
+ to: {
139
+ pathname: `${encodeURIComponent(role.key)}/edit`,
140
+ search: location.search,
141
+ },
142
+ protected: Boolean(role.protection),
143
+ }))}
144
+ labels={{
145
+ name: t('settings.roleName'),
146
+ key: t('settings.key'),
147
+ type: t('settings.roleType'),
148
+ protected: t('settings.protected'),
149
+ custom: t('settings.custom'),
150
+ }}
151
+ />
152
+ </ListFeedback>
153
+ ) : null}
154
154
  </PermissionManagementPage>
155
155
  <Outlet context={{ reload: request.reload }} />
156
156
  </>
@@ -24,23 +24,27 @@ const systemSettingsRoutes: AppClientRouteContribution = defineAppRoutes([
24
24
  breadcrumb: { title: 'settings.users' },
25
25
  componentLoader: () => import('./users.js'),
26
26
  children: [
27
+ // The forms declare their presentation, so the dialog or drawer opens at once and the wait for the form's module,
28
+ // its authorization and its data stays inside it instead of under the list.
27
29
  {
28
30
  name: 'settingsUserCreate',
29
31
  path: 'create',
30
32
  authz: { resource: { type: 'user', id: '*' }, action: 'create' },
33
+ presentation: { kind: 'dialog', title: 'settings.newUser' },
31
34
  componentLoader: () => import('./user-form.js'),
32
35
  },
36
+ // A sibling rather than the parent of the editor, so closing the drawer returns to the list instead of to this
37
+ // route, which only forwards older detail links to the editor.
33
38
  {
34
39
  name: 'settingsUser',
35
40
  path: ':userId',
36
41
  componentLoader: () => import('./user-detail.js'),
37
- children: [
38
- {
39
- name: 'settingsUserEdit',
40
- path: 'edit',
41
- componentLoader: () => import('./user-form.js'),
42
- },
43
- ],
42
+ },
43
+ {
44
+ name: 'settingsUserEdit',
45
+ path: ':userId/edit',
46
+ presentation: { kind: 'drawer', title: 'settings.editUser' },
47
+ componentLoader: () => import('./user-form.js'),
44
48
  },
45
49
  ],
46
50
  },
@@ -80,12 +84,14 @@ const systemSettingsRoutes: AppClientRouteContribution = defineAppRoutes([
80
84
  action: 'create',
81
85
  },
82
86
  breadcrumb: { title: 'settings.newRole' },
87
+ presentation: { kind: 'child-page' },
83
88
  componentLoader: () => import('./role-form.js'),
84
89
  },
85
90
  {
86
91
  name: 'settingsRoleEdit',
87
92
  path: ':key/edit',
88
93
  breadcrumb: { title: 'settings.editRole' },
94
+ presentation: { kind: 'child-page' },
89
95
  componentLoader: () => import('./role-form.js'),
90
96
  },
91
97
  ],
@@ -119,6 +125,7 @@ const systemSettingsRoutes: AppClientRouteContribution = defineAppRoutes([
119
125
  action: 'create',
120
126
  },
121
127
  breadcrumb: { title: 'settings.defaultAccess.new' },
128
+ presentation: { kind: 'child-page' },
122
129
  componentLoader: () => import('./default-access-form.js'),
123
130
  },
124
131
  // Read permission opens the full rule even when the viewer may not update it.
@@ -126,6 +133,7 @@ const systemSettingsRoutes: AppClientRouteContribution = defineAppRoutes([
126
133
  name: 'settingsDefaultAccessEdit',
127
134
  path: ':key/edit',
128
135
  breadcrumb: { title: 'settings.defaultAccess.edit' },
136
+ presentation: { kind: 'child-page' },
129
137
  componentLoader: () => import('./default-access-form.js'),
130
138
  },
131
139
  ],
@@ -1,24 +1,10 @@
1
1
  import type { ReactElement } from 'react';
2
- import {
3
- Navigate,
4
- useLocation,
5
- useOutlet,
6
- useOutletContext,
7
- useResolvedPath,
8
- } from 'react-router';
9
- import type { ManagedUser } from '@nocobase/app-plugin-users/client/user-client';
2
+ import { Navigate, useLocation } from 'react-router';
10
3
 
11
- /** Keep stored route identifiers and old detail links while using one editor. */
4
+ /** Keeps stored route identifiers and old detail links working by forwarding them to the editor. */
12
5
  export default function UserDetail(): ReactElement {
13
- const parent = useOutletContext<{
14
- reload: (updated?: ManagedUser) => void;
15
- }>();
16
6
  const location = useLocation();
17
- const listPath = useResolvedPath('..').pathname;
18
- const outlet = useOutlet({ ...parent, listPath });
19
7
  return (
20
- outlet ?? (
21
- <Navigate to={{ pathname: 'edit', search: location.search }} replace />
22
- )
8
+ <Navigate to={{ pathname: 'edit', search: location.search }} replace />
23
9
  );
24
10
  }
@@ -1,3 +1,4 @@
1
+ import { PendingButton } from '#components/pending-button';
1
2
  import { FeedbackForm } from '#components/feedback-form';
2
3
  import { useAuthentication } from '@nocobase/app-plugin-authentication/client';
3
4
  import { useSubmission } from '@nocobase/app-client';
@@ -197,7 +198,6 @@ function UserEditor(): ReactElement {
197
198
  const location = useLocation();
198
199
  const parent = useOutletContext<{
199
200
  reload: (updated?: ManagedUser) => void;
200
- listPath?: string;
201
201
  }>();
202
202
  const data = useSettingsData<{ data: ManagedUser }>(
203
203
  userId ? `users/${encodeURIComponent(userId)}` : 'users/options',
@@ -281,11 +281,8 @@ function UserEditor(): ReactElement {
281
281
  const [serverErrors, setServerErrors] = useState<Record<string, string>>({});
282
282
  const [submitted, setSubmitted] = useState(false);
283
283
 
284
- const parentPath = useResolvedPath('..').pathname;
285
- const closeTo = {
286
- pathname: creating ? parentPath : (parent.listPath ?? parentPath),
287
- search: location.search,
288
- };
284
+ const listPath = useResolvedPath('..').pathname;
285
+ const closeTo = { pathname: listPath, search: location.search };
289
286
  const fieldError = (name: keyof typeof fields): string | undefined => {
290
287
  if (!submitted) return;
291
288
  if (serverErrors[name]) return serverErrors[name];
@@ -436,7 +433,9 @@ function UserEditor(): ReactElement {
436
433
  {t('settings.cancel')}
437
434
  </Button>
438
435
  {allowed && (
439
- <Button
436
+ <PendingButton
437
+ busy={busy}
438
+ pendingLabel={t('settings.saving')}
440
439
  form={formId}
441
440
  type='submit'
442
441
  disabled={
@@ -455,14 +454,8 @@ function UserEditor(): ReactElement {
455
454
  (options.loading || Boolean(options.error) || !options.data))
456
455
  }
457
456
  >
458
- {t(
459
- busy
460
- ? 'settings.saving'
461
- : creating
462
- ? 'settings.create'
463
- : 'settings.save',
464
- )}
465
- </Button>
457
+ {t(creating ? 'settings.create' : 'settings.save')}
458
+ </PendingButton>
466
459
  )}
467
460
  </>
468
461
  );
@@ -475,6 +468,7 @@ function UserEditor(): ReactElement {
475
468
  ) : (
476
469
  !data.error && (
477
470
  <FeedbackForm
471
+ aria-busy={busy}
478
472
  submitting={busy}
479
473
  error={error}
480
474
  errorContent={<SettingsError error={error} inline />}