@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
@@ -5,9 +5,9 @@ import {
5
5
  authorizationClientToken,
6
6
  type AuthorizationOptionsResponse,
7
7
  } from '@nocobase/app-plugin-authorization/client';
8
- import { screen, waitFor, within } from '@testing-library/react';
8
+ import { act, screen, waitFor, within } from '@testing-library/react';
9
9
  import userEvent from '@testing-library/user-event';
10
- import { Routes, Route } from 'react-router';
10
+ import { Routes, Route, useLocation, useNavigate } from 'react-router';
11
11
  import { expect, it, vi } from 'vitest';
12
12
  import Page from '../../client/pages/settings/default-access.js';
13
13
  import Form from '../../client/pages/settings/default-access-form.js';
@@ -552,3 +552,142 @@ it('maps a server scope validation error to the affected selector and focuses it
552
552
  expect(scope).toHaveTextContent('All records');
553
553
  expect(screen.queryByText('Private detail')).not.toBeInTheDocument();
554
554
  });
555
+
556
+ it('does not reset a title-filtered page while its metadata is still loading', async () => {
557
+ let resolveCatalog!: (value: { data: AuthorizationOptionsResponse }) => void;
558
+ const pending = new Promise<{ data: AuthorizationOptionsResponse }>(
559
+ (resolve) => {
560
+ resolveCatalog = resolve;
561
+ },
562
+ );
563
+ const resources = Array.from({ length: 25 }, (_, i) => ({
564
+ ...catalog.sections[0]!.subsections[0]!.resources[0]!,
565
+ id: `resource-${i}`,
566
+ title: `Projects ${i}`,
567
+ }));
568
+ const namedCatalog = {
569
+ ...catalog,
570
+ sections: [
571
+ {
572
+ ...catalog.sections[0]!,
573
+ subsections: [{ ...catalog.sections[0]!.subsections[0]!, resources }],
574
+ },
575
+ ],
576
+ };
577
+ const rules = resources.map((resource, i) => ({
578
+ ...existing,
579
+ key: `rule-${i}`,
580
+ resource: { type: 'composite', id: resource.id },
581
+ }));
582
+ function CurrentLocation() {
583
+ const location = useLocation();
584
+ return <output data-testid='metadata-location'>{location.search}</output>;
585
+ }
586
+ const calls = vi.fn(async ({ path }: { path: string }) =>
587
+ path === 'authorization/permissions'
588
+ ? { data: { unrestricted: true, permissions: [] } }
589
+ : path.endsWith('/options')
590
+ ? pending
591
+ : { data: rules },
592
+ );
593
+ await renderWithApp(
594
+ <>
595
+ <Page />
596
+ <CurrentLocation />
597
+ </>,
598
+ {
599
+ route: '/default-access?q=Projects&page=2&pageSize=20',
600
+ namespace,
601
+ namespaces,
602
+ services,
603
+ fetch: answerApi(calls),
604
+ },
605
+ );
606
+ await waitFor(() =>
607
+ expect(calls).toHaveBeenCalledWith(
608
+ expect.objectContaining({ path: 'authorization/defaultAccess' }),
609
+ ),
610
+ );
611
+ await act(async () => {
612
+ await new Promise((resolve) => setTimeout(resolve, 20));
613
+ });
614
+ expect(screen.getByTestId('metadata-location')).toHaveTextContent('page=2');
615
+ await act(async () => {
616
+ resolveCatalog({ data: namedCatalog });
617
+ });
618
+ expect(await screen.findByText('Page 2 of 2')).toBeVisible();
619
+ expect(screen.getByTestId('metadata-location')).toHaveTextContent('page=2');
620
+ });
621
+
622
+ it('replaces the saved editor history entry and keeps the list query when going back', async () => {
623
+ const query = '?q=Projects&pageSize=20&sort=resource&order=desc';
624
+ function History() {
625
+ const location = useLocation();
626
+ const navigate = useNavigate();
627
+ return (
628
+ <>
629
+ <output data-testid='history-location'>
630
+ {location.pathname}
631
+ {location.search}
632
+ </output>
633
+ <button onClick={() => void navigate(-1)}>Back in history</button>
634
+ </>
635
+ );
636
+ }
637
+ let patches = 0;
638
+ let rules = [existing];
639
+ await renderWithApp(
640
+ <>
641
+ {tree()}
642
+ <History />
643
+ </>,
644
+ {
645
+ route: `/default-access${query}`,
646
+ namespace,
647
+ namespaces,
648
+ services,
649
+ fetch: answerApi((request) => {
650
+ if (request.path === 'authorization/permissions')
651
+ return { data: { unrestricted: true, permissions: [] } };
652
+ if (request.path.endsWith('/options')) return { data: catalog };
653
+ if (request.method === 'PATCH') {
654
+ patches += 1;
655
+ expect(request.json).toEqual({ actions: [existing.actions[0]] });
656
+ rules = [{ ...existing, actions: [existing.actions[0]!] }];
657
+ return { data: rules[0] };
658
+ }
659
+ return { data: rules };
660
+ }),
661
+ },
662
+ );
663
+ await userEvent.click(await screen.findByRole('link', { name: 'Projects' }));
664
+ await userEvent.click(
665
+ await screen.findByRole('combobox', { name: 'Edit · Projects' }),
666
+ );
667
+ await userEvent.click(
668
+ await screen.findByRole('option', {
669
+ name: enUS.settings.defaultAccess.none,
670
+ }),
671
+ );
672
+ const save = screen.getByRole('button', { name: enUS.settings.save });
673
+ expect(save).toBeEnabled();
674
+ await userEvent.click(save);
675
+ await waitFor(() =>
676
+ expect(screen.getByTestId('history-location').textContent).toBe(
677
+ `/default-access${query}`,
678
+ ),
679
+ );
680
+ expect(patches).toBe(1);
681
+ await userEvent.click(
682
+ screen.getByRole('button', { name: 'Back in history' }),
683
+ );
684
+ await waitFor(() =>
685
+ expect(screen.getByTestId('history-location').textContent).toBe(
686
+ `/default-access${query}`,
687
+ ),
688
+ );
689
+ expect(
690
+ screen.queryByRole('button', { name: enUS.settings.save }),
691
+ ).not.toBeInTheDocument();
692
+ expect(patches).toBe(1);
693
+ });
@@ -0,0 +1,426 @@
1
+ import { answerApi, renderWithApp } from '@nocobase/app-testing/client';
2
+ import { apiClientToken, type ClientApplication } from '@nocobase/app-client';
3
+ import {
4
+ AuthorizationClient,
5
+ authorizationClientToken,
6
+ } from '@nocobase/app-plugin-authorization/client';
7
+ import { screen, waitFor, within } from '@testing-library/react';
8
+ import userEvent from '@testing-library/user-event';
9
+ import { Routes, Route, useLocation } from 'react-router';
10
+ import { expect, it, vi } from 'vitest';
11
+ import UsersPage from '../../client/pages/settings/users.js';
12
+ import UserDetail from '../../client/pages/settings/user-detail.js';
13
+ import UserForm from '../../client/pages/settings/user-form.js';
14
+ import RolesPage from '../../client/pages/settings/roles.js';
15
+ import { BackButton } from '../../client/components/back-button.js';
16
+ import enUS from '../../client/locales/en-US.js';
17
+ import zhCN from '../../client/locales/zh-CN.js';
18
+ const namespace = '@nocobase/continuity-test';
19
+ const namespaces = {
20
+ [namespace]: {
21
+ 'en-US': async () => ({ default: enUS }),
22
+ 'zh-CN': async () => ({ default: zhCN }),
23
+ },
24
+ };
25
+ const services = (app: ClientApplication) =>
26
+ app.container.instance(
27
+ authorizationClientToken,
28
+ new AuthorizationClient(app.container.resolve(apiClientToken)),
29
+ );
30
+ const options = {
31
+ data: {
32
+ roleScopes: [
33
+ {
34
+ key: 'app',
35
+ label: 'Application',
36
+ selection: 'multiple',
37
+ requiredOnCreate: false,
38
+ options: [{ value: 'sales', label: 'Sales team' }],
39
+ },
40
+ ],
41
+ },
42
+ };
43
+ const row = {
44
+ id: 'alice',
45
+ name: 'Alice',
46
+ username: 'alice',
47
+ email: 'alice@example.test',
48
+ disabledAt: null,
49
+ roleScopes: { app: ['sales', 'retired-role'] },
50
+ };
51
+ function Location() {
52
+ const location = useLocation();
53
+ return (
54
+ <output data-testid='location'>
55
+ {location.pathname}
56
+ {location.search}
57
+ </output>
58
+ );
59
+ }
60
+
61
+ it('uses role labels and paired server filters, retaining the source and ordering when clearing filters', async () => {
62
+ const calls = vi.fn(({ path }: { path: string }) =>
63
+ path === 'authorization/permissions'
64
+ ? { data: { unrestricted: false, permissions: [] } }
65
+ : path === 'users/options'
66
+ ? options
67
+ : { data: [row], meta: { total: 41 } },
68
+ );
69
+ const source = '/settings/processes/loan?tab=definition#review';
70
+ await renderWithApp(
71
+ <>
72
+ <UsersPage />
73
+ <Location />
74
+ </>,
75
+ {
76
+ route: `/settings/users?roleScope=app&role=sales&page=2&pageSize=20&order=desc&returnTo=${encodeURIComponent(source)}`,
77
+ namespace,
78
+ namespaces,
79
+ services,
80
+ fetch: answerApi(calls),
81
+ },
82
+ );
83
+ expect(await screen.findByText('Sales team, retired-role')).toBeVisible();
84
+ expect(
85
+ screen.getByRole('link', { name: 'Return to previous page' }),
86
+ ).toHaveAttribute('href', source);
87
+ expect(screen.getByText('Alice', { exact: true })).toBeVisible();
88
+ expect(screen.queryByRole('link', { name: 'Alice' })).not.toBeInTheDocument();
89
+ expect(calls).toHaveBeenCalledWith(
90
+ expect.objectContaining({
91
+ path: 'users',
92
+ query: expect.objectContaining({
93
+ roleScope: 'app',
94
+ role: 'sales',
95
+ page: '2',
96
+ order: 'desc',
97
+ }),
98
+ }),
99
+ );
100
+ await userEvent.click(screen.getByRole('button', { name: 'Clear filters' }));
101
+ await waitFor(() =>
102
+ expect(screen.getByTestId('location').textContent).not.toContain(
103
+ 'roleScope=',
104
+ ),
105
+ );
106
+ const after = screen.getByTestId('location').textContent!;
107
+ expect(after).toContain('pageSize=20');
108
+ expect(after).toContain('order=desc');
109
+ expect(after).toContain('returnTo=');
110
+ await waitFor(() =>
111
+ expect(calls).toHaveBeenCalledWith(
112
+ expect.objectContaining({
113
+ path: 'users',
114
+ query: { page: '1', pageSize: '20', sort: 'name', order: 'desc' },
115
+ }),
116
+ ),
117
+ );
118
+ });
119
+
120
+ it('does not silently discard an incomplete role filter or expose an external return link', async () => {
121
+ const calls = vi.fn(
122
+ ({ path, query }: { path: string; query?: Record<string, string> }) => {
123
+ if (path === 'authorization/permissions')
124
+ return { data: { unrestricted: false, permissions: [] } };
125
+ if (path === 'users/options') return options;
126
+ if (query?.role)
127
+ return Response.json(
128
+ { error: { code: 'INVALID_ARGUMENT', message: 'Invalid filter' } },
129
+ { status: 400 },
130
+ );
131
+ return { data: [row], meta: { total: 1 } };
132
+ },
133
+ );
134
+ await renderWithApp(<UsersPage />, {
135
+ route: '/settings/users?role=sales&returnTo=https%3A%2F%2Foutside.test',
136
+ namespace,
137
+ namespaces,
138
+ services,
139
+ fetch: answerApi(calls),
140
+ });
141
+ expect(
142
+ await screen.findByText(enUS.settings.roleFilterIncomplete),
143
+ ).toBeVisible();
144
+ await waitFor(() =>
145
+ expect(calls).toHaveBeenCalledWith(
146
+ expect.objectContaining({
147
+ path: 'users',
148
+ query: expect.objectContaining({ role: 'sales' }),
149
+ }),
150
+ ),
151
+ );
152
+ expect(screen.queryByRole('link', { name: 'Alice' })).not.toBeInTheDocument();
153
+ expect(
154
+ screen.queryByRole('link', { name: 'Return to previous page' }),
155
+ ).not.toBeInTheDocument();
156
+ expect(
157
+ calls.mock.calls
158
+ .filter(([call]) => call.path === 'users')
159
+ .every(([call]) => call.query?.role === 'sales'),
160
+ ).toBe(true);
161
+ });
162
+
163
+ it('resolves localized role descriptors through their namespace without rewriting unknown assignments', async () => {
164
+ const localized = {
165
+ data: {
166
+ roleScopes: [
167
+ {
168
+ ...options.data.roleScopes[0],
169
+ options: [
170
+ {
171
+ value: 'sales',
172
+ label: 'Fallback',
173
+ labelI18nKey: 'settings.roles',
174
+ labelI18nNs: namespace,
175
+ },
176
+ ],
177
+ },
178
+ ],
179
+ },
180
+ };
181
+ await renderWithApp(<UsersPage />, {
182
+ locale: 'zh-CN',
183
+ namespace,
184
+ namespaces,
185
+ services,
186
+ fetch: answerApi(({ path }) =>
187
+ path === 'authorization/permissions'
188
+ ? { data: { unrestricted: false, permissions: [] } }
189
+ : path === 'users/options'
190
+ ? localized
191
+ : { data: [row], meta: { total: 1 } },
192
+ ),
193
+ });
194
+ expect(
195
+ await screen.findByText(`${zhCN.settings.roles}, retired-role`),
196
+ ).toBeVisible();
197
+ });
198
+
199
+ it.each(['/settings/users', '/users'])(
200
+ 'keeps a failed edit draft, updates the actual user on retry, and closes to the same filtered list at %s',
201
+ async (base) => {
202
+ const user = userEvent.setup();
203
+ let current = row;
204
+ let fail = true;
205
+ const calls = vi.fn(
206
+ ({
207
+ path,
208
+ method,
209
+ json,
210
+ }: {
211
+ path: string;
212
+ method: string;
213
+ json?: { name: string };
214
+ }) => {
215
+ if (path === 'authorization/permissions')
216
+ return { data: { unrestricted: true, permissions: [] } };
217
+ if (path === 'users/options') return options;
218
+ if (path === 'users/alice' && method === 'PATCH') {
219
+ if (fail) {
220
+ fail = false;
221
+ return Response.json(
222
+ {
223
+ error: {
224
+ code: 'INTERNAL_SERVER_ERROR',
225
+ message: 'Unavailable',
226
+ },
227
+ },
228
+ { status: 500 },
229
+ );
230
+ }
231
+ current = { ...current, name: json!.name };
232
+ return { data: current };
233
+ }
234
+ if (path === 'users/alice') return { data: current };
235
+ return { data: [current], meta: { total: 41 } };
236
+ },
237
+ );
238
+ const search = '?page=2&pageSize=20&roleScope=app&role=sales';
239
+ await renderWithApp(
240
+ <>
241
+ <Routes>
242
+ <Route path={base} element={<UsersPage />}>
243
+ <Route path=':userId' element={<UserDetail />}>
244
+ <Route path='edit' element={<UserForm />} />
245
+ </Route>
246
+ </Route>
247
+ </Routes>
248
+ <Location />
249
+ </>,
250
+ {
251
+ route: `${base}/alice/edit${search}`,
252
+ namespace,
253
+ namespaces,
254
+ services,
255
+ fetch: answerApi(calls),
256
+ },
257
+ );
258
+ const name = await screen.findByRole('textbox', { name: 'Name *' });
259
+ await waitFor(() => expect(name).toHaveValue('Alice'));
260
+ await user.clear(name);
261
+ await user.type(name, 'Alice updated');
262
+ await user.click(
263
+ screen.getByRole('button', {
264
+ name: 'Save',
265
+ exact: true,
266
+ }),
267
+ );
268
+ expect(await screen.findByText(enUS.settings.failed)).toBeVisible();
269
+ expect(name).toHaveValue('Alice updated');
270
+ expect(screen.getByTestId('location')).toHaveTextContent('/edit');
271
+ await user.click(
272
+ screen.getByRole('button', {
273
+ name: 'Save',
274
+ exact: true,
275
+ }),
276
+ );
277
+ await waitFor(() =>
278
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument(),
279
+ );
280
+ await waitFor(() =>
281
+ expect(screen.getByTestId('location')).toHaveTextContent(
282
+ `${base}${search}`,
283
+ ),
284
+ );
285
+ expect(
286
+ await screen.findByText('Alice updated', { exact: true }),
287
+ ).toBeVisible();
288
+ },
289
+ );
290
+
291
+ it('restores a controlled role table from URL after unmounting a detail view', async () => {
292
+ const roles = Array.from({ length: 25 }, (_, i) => ({
293
+ key: `role-${i}`,
294
+ title: `Role ${String(i).padStart(2, '0')}`,
295
+ grants: [],
296
+ }));
297
+ const user = userEvent.setup();
298
+ await renderWithApp(
299
+ <>
300
+ <Routes>
301
+ <Route path='/settings/roles' element={<RolesPage />} />
302
+ <Route
303
+ path='/settings/roles/:id/edit'
304
+ element={
305
+ <BackButton
306
+ to={{
307
+ pathname: '/settings/roles',
308
+ search: '?page=2&pageSize=10&sort=title&order=desc',
309
+ }}
310
+ />
311
+ }
312
+ />
313
+ </Routes>
314
+ <Location />
315
+ </>,
316
+ {
317
+ route: '/settings/roles?page=2&pageSize=10&sort=title&order=desc',
318
+ namespace,
319
+ namespaces,
320
+ services,
321
+ fetch: answerApi(({ path }) =>
322
+ path === 'authorization/permissions'
323
+ ? { data: { unrestricted: false, permissions: [] } }
324
+ : { data: roles },
325
+ ),
326
+ },
327
+ );
328
+ const link = await screen.findByRole('link', {
329
+ name: 'Role 14',
330
+ exact: true,
331
+ });
332
+ expect(screen.getByText('Page 2 of 3')).toBeVisible();
333
+ expect(
334
+ within(screen.getAllByRole('row')[1]!).getByText('Role 14'),
335
+ ).toBeVisible();
336
+ await user.click(link);
337
+ await user.click(screen.getByRole('link', { name: 'Back' }));
338
+ expect(
339
+ await screen.findByRole('link', { name: 'Role 14', exact: true }),
340
+ ).toBeVisible();
341
+ expect(screen.getByText('Page 2 of 3')).toBeVisible();
342
+ });
343
+
344
+ it('keeps loaded users visible when role labels fail and provides retry without an empty actions menu', async () => {
345
+ await renderWithApp(<UsersPage />, {
346
+ namespace,
347
+ namespaces,
348
+ services,
349
+ fetch: answerApi(({ path }) =>
350
+ path === 'authorization/permissions'
351
+ ? { data: { unrestricted: false, permissions: [] } }
352
+ : path === 'users/options'
353
+ ? Response.json(
354
+ {
355
+ error: {
356
+ code: 'INTERNAL_SERVER_ERROR',
357
+ message: 'Unavailable',
358
+ },
359
+ },
360
+ { status: 500 },
361
+ )
362
+ : { data: [row], meta: { total: 1 } },
363
+ ),
364
+ });
365
+ expect(await screen.findByText('Alice', { exact: true })).toBeVisible();
366
+ expect(await screen.findByText('sales, retired-role')).toBeVisible();
367
+ expect(screen.getByRole('button', { name: 'Retry' })).toBeVisible();
368
+ expect(
369
+ screen.queryByRole('button', { name: 'Actions for Alice' }),
370
+ ).not.toBeInTheDocument();
371
+ });
372
+
373
+ it('refreshes a filtered list after disabling a user, corrects an empty last page and restores search focus', async () => {
374
+ let disabled = false;
375
+ const user = userEvent.setup();
376
+ await renderWithApp(
377
+ <>
378
+ <Routes>
379
+ <Route path='/settings/users' element={<UsersPage />} />
380
+ </Routes>
381
+ <Location />
382
+ </>,
383
+ {
384
+ route: '/settings/users?status=enabled&page=2&pageSize=20',
385
+ namespace,
386
+ namespaces,
387
+ services,
388
+ fetch: answerApi(({ path }) => {
389
+ if (path === 'authorization/permissions')
390
+ return { data: { unrestricted: true, permissions: [] } };
391
+ if (path === 'users/options') return options;
392
+ if (path === 'users/alice/disable') {
393
+ disabled = true;
394
+ return { data: { ...row, disabledAt: '2026-10-21T00:00:00Z' } };
395
+ }
396
+ return {
397
+ data: disabled ? [] : [row],
398
+ meta: { total: disabled ? 20 : 21 },
399
+ };
400
+ }),
401
+ },
402
+ );
403
+ await user.click(
404
+ await screen.findByRole('button', { name: 'Disable', exact: true }),
405
+ );
406
+ await user.click(
407
+ within(screen.getByRole('alertdialog')).getByRole('button', {
408
+ name: 'Disable',
409
+ exact: true,
410
+ }),
411
+ );
412
+ await waitFor(() =>
413
+ expect(
414
+ screen.queryByText('Alice', { exact: true }),
415
+ ).not.toBeInTheDocument(),
416
+ );
417
+ await waitFor(() =>
418
+ expect(screen.getByTestId('location')).toHaveTextContent('page=1'),
419
+ );
420
+ expect(screen.getByTestId('location')).toHaveTextContent('status=enabled');
421
+ await waitFor(() =>
422
+ expect(
423
+ screen.getByRole('textbox', { name: enUS.settings.userSearch }),
424
+ ).toHaveFocus(),
425
+ );
426
+ });