@nocobase/app-template-default 1.0.0-beta.57 → 1.0.0-beta.59

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 (69) hide show
  1. package/AGENTS.md +26 -21
  2. package/README.MD +7 -53
  3. package/cli/plugins.ts +1 -7
  4. package/client/agent-annotations-host.ts +8 -2
  5. package/client/app.ts +0 -2
  6. package/client/components/brand-loading-mark.tsx +46 -0
  7. package/client/components/brand-mark-geometry.ts +7 -0
  8. package/client/components/brand-spinner.tsx +22 -0
  9. package/client/components/brand-watermark.tsx +32 -0
  10. package/client/components/loading.tsx +4 -3
  11. package/client/components/route-overlay.tsx +15 -4
  12. package/client/components/status-badge.tsx +29 -0
  13. package/client/components/ui/alert-dialog.tsx +186 -0
  14. package/client/components/ui/card.tsx +102 -0
  15. package/client/components/ui/dialog.tsx +2 -2
  16. package/client/components/ui/radio-group.tsx +35 -0
  17. package/client/components/ui/spinner.tsx +2 -20
  18. package/client/components/ui/toast.tsx +3 -2
  19. package/client/extensions/nocobase-file-component-ui/components/file-upload-field.tsx +3 -3
  20. package/client/extensions/nocobase-inbox/inbox-item.tsx +4 -3
  21. package/client/layouts/app-layout.tsx +8 -28
  22. package/client/layouts/components/app-sidebar.tsx +4 -4
  23. package/client/layouts/components/header-actions.tsx +3 -36
  24. package/client/layouts/index.ts +0 -1
  25. package/client/lib/toaster.ts +1 -1
  26. package/client/locales/en-US.ts +31 -17
  27. package/client/locales/zh-CN.ts +24 -14
  28. package/client/pages/auth/login.tsx +1 -0
  29. package/client/pages/auth/shared.tsx +9 -9
  30. package/client/pages/home.tsx +80 -7
  31. package/client/plugins.ts +2 -6
  32. package/client/routes.ts +4 -10
  33. package/client/routing/app-router.tsx +5 -55
  34. package/client/routing/route-navigation.ts +1 -1
  35. package/client/styles.css +244 -0
  36. package/client/theme/theme-settings.tsx +22 -20
  37. package/client/theme/themes/compact.css +34 -16
  38. package/client/theme/themes/default.css +34 -16
  39. package/config.example.yml +0 -16
  40. package/eslint.config.js +0 -13
  41. package/index.html +48 -16
  42. package/package.json +24 -30
  43. package/server/config/index.ts +0 -6
  44. package/server/plugins.ts +0 -4
  45. package/tests/components/app-layout-routes.test.tsx +1 -1
  46. package/tests/components/inbox.test.tsx +0 -19
  47. package/tests/components/layout-i18n.test.tsx +5 -92
  48. package/tests/components/primitive-labels.test.tsx +61 -1
  49. package/tests/fixtures/theme-tokens.html +1 -1
  50. package/tests/fixtures/theme-tokens.tsx +7 -0
  51. package/tests/logic/app-server.test.ts +0 -15
  52. package/tests/logic/client-plugin-registry.test.ts +0 -21
  53. package/tests/logic/client-routes.test.ts +2 -6
  54. package/tests/logic/client-shell.test.tsx +11 -107
  55. package/tests/logic/client-theme.test.tsx +17 -1
  56. package/tests/logic/config.test.ts +0 -1
  57. package/tests/logic/plugin-commands.test.ts +1 -1
  58. package/tsconfig.json +1 -4
  59. package/tsconfig.server.json +1 -6
  60. package/client/components/inbox-badge.ts +0 -49
  61. package/client/components/inbox-button.tsx +0 -132
  62. package/client/components/inbox-header-button.tsx +0 -25
  63. package/client/layouts/components/surface-empty.tsx +0 -35
  64. package/client/layouts/settings-layout.tsx +0 -172
  65. package/server/config/ai.ts +0 -27
  66. package/server/config/workflow.ts +0 -24
  67. package/tests/logic/client-settings.test.tsx +0 -841
  68. package/tests/logic/dev-routes-production-build.test.ts +0 -168
  69. package/tests/logic/plugin-page-authz.test.ts +0 -76
@@ -1,841 +0,0 @@
1
- import type {
2
- AppClientRegisteredRoute,
3
- AppClientRegisteredSetting,
4
- AppClientRegisteredSettingGroup,
5
- AppClientSettingIcon,
6
- } from '@nocobase/app-client/plugins';
7
- import {
8
- ClientApplicationContext,
9
- type ClientApplication,
10
- } from '@nocobase/app-client';
11
- import {
12
- AuthenticationProvider,
13
- authenticationClientToken,
14
- } from '@nocobase/app-plugin-authentication/client';
15
- import {
16
- AuthorizationClient,
17
- authorizationClientToken,
18
- } from '@nocobase/app-plugin-authorization/client';
19
- import {
20
- fireEvent,
21
- render,
22
- screen,
23
- waitFor,
24
- within,
25
- } from '@testing-library/react';
26
- import type { ReactElement } from 'react';
27
- import { MemoryRouter, Outlet, useParams } from 'react-router';
28
- import { beforeEach, describe, expect, it, vi } from 'vitest';
29
-
30
- import { AppRouter } from '../../client/routing/app-router.tsx';
31
- import { HeaderActions } from '../../client/layouts/components/header-actions.tsx';
32
- import { AppThemeProvider } from '../../client/theme/index.ts';
33
-
34
- // The header's inbox button reads the in-app notification plugin's API, which these tests do not serve; it is
35
- // covered by tests/components/inbox.test.tsx.
36
- vi.mock('../../client/components/inbox-header-button', () => ({
37
- InboxHeaderButton: () => null,
38
- }));
39
-
40
- function WorkflowDetailTestPage(): ReactElement {
41
- return <h2>Workflow detail {useParams().workflowId}</h2>;
42
- }
43
-
44
- // Loading a lazy route and rendering its error boundary can take longer than Testing Library's one-second default on
45
- // a loaded CI runner. A page that never renders still fails, well inside the 30-second test timeout.
46
- const ROUTE_LOAD_TIMEOUT = { timeout: 10_000 };
47
-
48
- /** The sidebar's entries; the header's breadcrumb names the current page as well. */
49
- const sidebar = (): HTMLElement =>
50
- document.querySelector<HTMLElement>('[data-slot=sidebar]') ?? document.body;
51
- const findSidebarLink = (name: string): Promise<HTMLElement> =>
52
- waitFor(() => within(sidebar()).getByRole('link', { name }));
53
-
54
- describe('settings centre', () => {
55
- beforeEach(() => {
56
- window.matchMedia = vi.fn().mockImplementation((query: string) => ({
57
- addEventListener: vi.fn(),
58
- addListener: vi.fn(),
59
- dispatchEvent: vi.fn(),
60
- matches:
61
- query === '(prefers-color-scheme: dark)' ||
62
- query === '(min-width: 768px)',
63
- media: query,
64
- onchange: null,
65
- removeEventListener: vi.fn(),
66
- removeListener: vi.fn(),
67
- }));
68
- });
69
-
70
- it.each(['settings'] as const)(
71
- 'opens a healthy %s page after another page fails to load',
72
- async (surface) => {
73
- const broken: AppClientRegisteredRoute = {
74
- id: 'broken',
75
- name: 'broken',
76
- path: `/${surface}/broken`,
77
- auth: 'required',
78
- authz: 'skip',
79
- packageName: 'test',
80
- source: 'application',
81
- navigation: { title: 'Broken page' },
82
- componentLoader: async () => {
83
- throw new Error('Module unavailable');
84
- },
85
- };
86
- const healthy: AppClientRegisteredRoute = {
87
- ...broken,
88
- id: 'healthy',
89
- name: 'healthy',
90
- path: `/${surface}/healthy`,
91
- navigation: { title: 'Healthy page' },
92
- componentLoader: async () => ({
93
- default: () => <h2>Healthy content</h2>,
94
- }),
95
- };
96
- const tree = [broken, healthy];
97
- renderApp(
98
- <AppRouter
99
- clientRoutes={[]}
100
- settingsRouteTree={tree}
101
- devRouteTree={[]}
102
- />,
103
- broken.path,
104
- tree,
105
- );
106
- expect(
107
- await screen.findByText('Unable to load page', {}, ROUTE_LOAD_TIMEOUT),
108
- ).toBeVisible();
109
- fireEvent.click(screen.getByRole('link', { name: 'Healthy page' }));
110
- expect(
111
- await screen.findByText('Healthy content', {}, ROUTE_LOAD_TIMEOUT),
112
- ).toBeVisible();
113
- expect(screen.queryByText('Unable to load page')).not.toBeInTheDocument();
114
- },
115
- );
116
-
117
- it('shows the Settings entry when enabled by its layout', async () => {
118
- renderApp(
119
- <HeaderActions showSettings />,
120
- '/',
121
- toRouteTree(SETTINGS, GROUPS),
122
- );
123
-
124
- expect(
125
- await screen.findByRole('link', { name: 'Settings' }),
126
- ).toHaveAttribute('href', '/settings');
127
- });
128
-
129
- it('renders the requested setting with a grouped navigation of the rest', async () => {
130
- renderSettings('/settings/authorization/default-access');
131
-
132
- expect(await screen.findByText('Default Access page')).toBeVisible();
133
- expect(screen.getByRole('navigation', { name: 'Settings' })).toBeVisible();
134
- expect(screen.getByText('Authorization')).toBeVisible();
135
- expect(
136
- screen.getByRole('link', { name: 'Workflow General' }),
137
- ).toBeVisible();
138
- expect(
139
- screen.getAllByRole('link', { name: 'Default Access' })[0],
140
- ).toHaveAttribute('aria-current', 'page');
141
- expect(
142
- screen.getAllByRole('link', { name: 'Permission Sets' })[0],
143
- ).not.toHaveAttribute('aria-current');
144
- expect(
145
- screen.getAllByRole('link', { name: 'Back to app' })[0],
146
- ).toHaveAttribute('href', '/');
147
- });
148
-
149
- it('keeps the header entries visible inside settings', async () => {
150
- renderSettings('/settings/authorization/permission-sets');
151
- await screen.findByText('Permission Sets page');
152
-
153
- expect(
154
- await screen.findByRole('button', { name: 'Appearance' }),
155
- ).toBeVisible();
156
- // The account menu exposes user details in its panel without a native tooltip.
157
- expect(
158
- await screen.findByRole('button', { name: 'Open account menu' }),
159
- ).not.toHaveAttribute('title');
160
- expect(screen.getByRole('link', { name: 'Settings' })).toBeVisible();
161
- expect(
162
- screen.getAllByRole('link', { name: 'Back to app' })[0],
163
- ).toHaveAttribute('href', '/');
164
- });
165
-
166
- it('renders the icon a setting declares, and copes with one that declares none', async () => {
167
- renderSettings('/settings/authorization/permission-sets');
168
- await screen.findByText('Permission Sets page');
169
-
170
- const iconOf = (name: string) =>
171
- screen
172
- .getAllByRole('link', { name })[0]
173
- .querySelector('[data-testid="setting-icon"]');
174
-
175
- expect(iconOf('Permission Sets')).toBeInTheDocument();
176
- expect(iconOf('Default Access')).not.toBeInTheDocument();
177
- });
178
-
179
- it('opens the group holding the current page and collapses it on demand', async () => {
180
- renderSettings('/settings/authorization/default-access');
181
- await screen.findByText('Default Access page');
182
-
183
- const group = screen.getByRole('button', { name: 'Authorization' });
184
- expect(group).toHaveAttribute('aria-expanded', 'true');
185
-
186
- fireEvent.click(screen.getByText('Authorization'));
187
- expect(group).toHaveAttribute('aria-expanded', 'false');
188
- });
189
-
190
- it('renders an ungrouped page as a flat row rather than a disclosure', async () => {
191
- renderSettings('/settings/workflow');
192
- await screen.findByText('Workflow General page');
193
-
194
- const link = screen.getAllByRole('link', { name: 'Workflow General' })[0];
195
- expect(link).toHaveAttribute('aria-current', 'page');
196
- expect(link.closest('details')).toBeNull();
197
- });
198
-
199
- it('renders a nested detail route inside settings and keeps its parent selected', async () => {
200
- renderSettings('/settings/workflow/item-1', undefined, SETTINGS, GROUPS, [
201
- {
202
- auth: 'required',
203
- authz: 'skip',
204
- id: '@nocobase/app-plugin-test:workflow-detail',
205
- name: 'workflow-detail',
206
- packageName: '@nocobase/app-plugin-test',
207
- path: '/settings/workflow/:workflowId',
208
- source: 'plugin',
209
- componentLoader: async () => ({
210
- default: WorkflowDetailTestPage,
211
- }),
212
- },
213
- ]);
214
-
215
- expect(await screen.findByText('Workflow detail item-1')).toBeVisible();
216
- expect(
217
- screen.getAllByRole('link', { name: 'Workflow General' })[0],
218
- ).toHaveAttribute('aria-current', 'page');
219
- });
220
-
221
- it('retains the default App permission for pages mounted inside settings', async () => {
222
- const loader = vi.fn(async () => ({ default: () => <h3>App overlay</h3> }));
223
- renderSettings(
224
- '/settings/overlay',
225
- { can: async ({ resource }) => resource.id !== 'overlay' },
226
- [],
227
- [],
228
- [
229
- {
230
- auth: 'required',
231
- authz: {
232
- resource: { type: 'page', id: 'overlay' },
233
- action: 'access',
234
- },
235
- id: 'overlay',
236
- name: 'overlay',
237
- packageName: 'test',
238
- source: 'plugin',
239
- path: '/settings/overlay',
240
- componentLoader: loader,
241
- },
242
- ],
243
- );
244
- expect(await screen.findByText('Access denied')).toBeVisible();
245
- expect(loader).not.toHaveBeenCalled();
246
- });
247
-
248
- it.each([
249
- { unrestricted: false, opens: false },
250
- { unrestricted: true, opens: true },
251
- ])(
252
- 'opens and lists an unrestricted-only page only for unrestricted identities: %j',
253
- async ({ unrestricted, opens }) => {
254
- const loader = vi.fn(async () => ({
255
- default: () => <h3>Root only content</h3>,
256
- }));
257
- const page = (
258
- name: string,
259
- authz: AppClientRegisteredRoute['authz'],
260
- componentLoader: AppClientRegisteredRoute['componentLoader'],
261
- ): AppClientRegisteredRoute => ({
262
- auth: 'required',
263
- authz,
264
- id: name,
265
- name,
266
- packageName: 'test',
267
- source: 'plugin',
268
- path: `/settings/${name}`,
269
- navigation: { title: name },
270
- componentLoader,
271
- });
272
- const snapshot = new AuthorizationClient({
273
- request: async () => ({ data: { unrestricted, permissions: [] } }),
274
- } as never);
275
- renderSettings(
276
- '/settings/root-only',
277
- { can: (requirement) => snapshot.can(requirement) },
278
- [],
279
- [],
280
- [],
281
- [
282
- page('open', 'skip', async () => ({ default: () => null })),
283
- page('root-only', 'unrestricted', loader),
284
- ],
285
- );
286
- expect(await findSidebarLink('open')).toBeVisible();
287
- if (opens) {
288
- expect(await screen.findByText('Root only content')).toBeVisible();
289
- expect(
290
- within(sidebar()).getByRole('link', { name: 'root-only' }),
291
- ).toBeVisible();
292
- } else {
293
- // Hidden from the menu, so the settings centre moves on to a page the user may open.
294
- await waitFor(() =>
295
- expect(
296
- within(sidebar()).getByRole('link', { name: 'open' }),
297
- ).toHaveAttribute('aria-current', 'page'),
298
- );
299
- expect(
300
- within(sidebar()).queryByRole('link', { name: 'root-only' }),
301
- ).not.toBeInTheDocument();
302
- expect(screen.queryByText('Root only content')).not.toBeInTheDocument();
303
- expect(loader).not.toHaveBeenCalled();
304
- }
305
- },
306
- );
307
-
308
- it('denies an unrestricted-only page opened by URL to anyone else', async () => {
309
- const loader = vi.fn(async () => ({ default: () => <h3>Root page</h3> }));
310
- const snapshot = new AuthorizationClient({
311
- request: async () => ({ data: { unrestricted: false, permissions: [] } }),
312
- } as never);
313
- renderSettings(
314
- '/settings/root-page',
315
- { can: (requirement) => snapshot.can(requirement) },
316
- [],
317
- [],
318
- [
319
- {
320
- auth: 'required',
321
- authz: 'unrestricted',
322
- id: 'root-page',
323
- name: 'root-page',
324
- packageName: 'test',
325
- source: 'plugin',
326
- path: '/settings/root-page',
327
- componentLoader: loader,
328
- },
329
- ],
330
- );
331
- expect(await screen.findByText('Access denied')).toBeVisible();
332
- expect(loader).not.toHaveBeenCalled();
333
- });
334
-
335
- it('does not bypass a denied parent when its child skips authorization', async () => {
336
- const parentLoader = vi.fn(async () => ({ default: () => <Outlet /> }));
337
- const childLoader = vi.fn(async () => ({
338
- default: () => <h3>Skipped child</h3>,
339
- }));
340
- const parent: AppClientRegisteredRoute = {
341
- auth: 'required',
342
- authz: { resource: { type: 'page', id: 'parent' }, action: 'access' },
343
- id: 'parent',
344
- name: 'parent',
345
- path: '/settings/parent',
346
- packageName: 'test',
347
- source: 'plugin',
348
- componentLoader: parentLoader,
349
- children: [
350
- {
351
- auth: 'required',
352
- authz: 'skip',
353
- id: 'child',
354
- name: 'child',
355
- path: '/settings/parent/child',
356
- packageName: 'test',
357
- source: 'plugin',
358
- navigation: { title: 'Skipped child' },
359
- componentLoader: childLoader,
360
- },
361
- ],
362
- };
363
- renderSettings(
364
- '/settings/parent/child',
365
- { can: async () => false },
366
- [],
367
- [],
368
- [],
369
- [parent],
370
- );
371
- expect(await screen.findByText('No settings available')).toBeVisible();
372
- expect(
373
- screen.queryByRole('link', { name: 'Skipped child' }),
374
- ).not.toBeInTheDocument();
375
- expect(parentLoader).not.toHaveBeenCalled();
376
- expect(childLoader).not.toHaveBeenCalled();
377
- });
378
-
379
- it('keeps the parent layout when a nested page denies access', async () => {
380
- const loader = vi.fn(async () => ({
381
- default: () => <h3>Secret child</h3>,
382
- }));
383
- const parent: AppClientRegisteredRoute = {
384
- auth: 'required',
385
- authz: 'skip',
386
- id: 'parent',
387
- name: 'parent',
388
- packageName: 'test',
389
- source: 'plugin',
390
- path: '/settings/parent',
391
- navigation: { title: 'Parent' },
392
- componentLoader: async () => ({
393
- default: () => (
394
- <>
395
- <h2>Parent layout</h2>
396
- <Outlet />
397
- </>
398
- ),
399
- }),
400
- children: [
401
- {
402
- auth: 'required',
403
- id: 'child',
404
- name: 'child',
405
- packageName: 'test',
406
- source: 'plugin',
407
- path: '/settings/parent/child',
408
- authz: { resource: { type: 'page', id: 'secret' }, action: 'access' },
409
- componentLoader: loader,
410
- },
411
- ],
412
- };
413
- renderSettings(
414
- '/settings/parent/child',
415
- { can: async ({ resource }) => resource.id !== 'secret' },
416
- [],
417
- [],
418
- [],
419
- [parent],
420
- );
421
- expect(await screen.findByText('Access denied')).toBeVisible();
422
- expect(screen.getByText('Parent layout')).toBeVisible();
423
- expect(loader).not.toHaveBeenCalled();
424
- });
425
-
426
- it('keeps developer pages inside their surface when wrapped in a pathless App group', async () => {
427
- renderSettings(
428
- '/settings/grouped',
429
- undefined,
430
- [],
431
- [],
432
- [
433
- {
434
- auth: 'required',
435
- authz: 'skip',
436
- id: 'group',
437
- name: 'group',
438
- path: '/',
439
- source: 'plugin',
440
- packageName: 'test',
441
- children: [
442
- {
443
- auth: 'required',
444
- authz: 'skip',
445
- id: 'page',
446
- name: 'page',
447
- path: '/settings/grouped',
448
- source: 'plugin',
449
- packageName: 'test',
450
- componentLoader: async () => ({
451
- default: () => <h2>Grouped surface page</h2>,
452
- }),
453
- },
454
- ],
455
- },
456
- ],
457
- );
458
- expect(await screen.findByText('Grouped surface page')).toBeVisible();
459
- expect(screen.getByRole('navigation', { name: 'Settings' })).toBeVisible();
460
- });
461
-
462
- it('drops a group whose every page the user is denied', async () => {
463
- renderSettings('/settings', {
464
- can: async ({ resource }) =>
465
- !(
466
- resource.type === 'settings' &&
467
- resource.id.startsWith('authorization.')
468
- ),
469
- });
470
-
471
- expect(await screen.findByText('Workflow General page')).toBeVisible();
472
- expect(screen.queryByText('Authorization')).not.toBeInTheDocument();
473
- });
474
-
475
- it('shows a group icon beside its title', async () => {
476
- renderSettings('/settings/authorization/default-access');
477
- await screen.findByText('Default Access page');
478
-
479
- expect(
480
- screen
481
- .getByText('Authorization')
482
- .closest('button')
483
- ?.querySelector('[data-testid="setting-icon"]'),
484
- ).toBeInTheDocument();
485
- });
486
-
487
- it('redirects /settings itself to the first setting the user can open', async () => {
488
- renderSettings('/settings');
489
-
490
- expect(await screen.findByText('Permission Sets page')).toBeVisible();
491
- });
492
-
493
- it('sends an unknown settings path to the first accessible setting', async () => {
494
- renderSettings('/settings/nothing/here');
495
-
496
- expect(await screen.findByText('Permission Sets page')).toBeVisible();
497
- });
498
-
499
- it('hides a setting the authorization client denies, and does not land on it', async () => {
500
- renderSettings('/settings/authorization/permission-sets', {
501
- can: async ({ resource }) =>
502
- resource.id !== 'authorization.permission-sets',
503
- });
504
-
505
- // The denied setting is neither reachable directly nor listed, so the redirect falls through to the next one.
506
- expect(await screen.findByText('Default Access page')).toBeVisible();
507
- expect(
508
- screen.queryByRole('link', { name: 'Permission Sets' }),
509
- ).not.toBeInTheDocument();
510
- });
511
-
512
- it('treats a client that throws as a denial', async () => {
513
- renderSettings('/settings/authorization/permission-sets', {
514
- can: async ({ resource }) => {
515
- if (resource.id === 'authorization.permission-sets') {
516
- throw new Error('provider unavailable');
517
- }
518
- return true;
519
- },
520
- });
521
-
522
- expect(await screen.findByText('Default Access page')).toBeVisible();
523
- expect(
524
- screen.queryByRole('link', { name: 'Permission Sets' }),
525
- ).not.toBeInTheDocument();
526
- });
527
-
528
- it('explains itself when every setting is denied', async () => {
529
- renderSettings(
530
- '/settings',
531
- { can: async () => false },
532
- SETTINGS.filter((setting) => setting.authz !== 'skip'),
533
- );
534
-
535
- expect(
536
- await screen.findByRole('heading', { name: 'No settings available' }),
537
- ).toBeVisible();
538
- });
539
-
540
- it('leaves a setting without an access rule visible even when the provider denies everything', async () => {
541
- renderSettings('/settings', { can: async () => false });
542
-
543
- // `workflow/general` declares no access rule, so reaching the settings centre is the only check it has.
544
- expect(await screen.findByText('Workflow General page')).toBeVisible();
545
- });
546
-
547
- it('keeps ungoverned settings visible when no plugin registered a provider', async () => {
548
- renderSettings(
549
- '/settings',
550
- undefined,
551
- [createSetting('general', 'General')],
552
- [],
553
- );
554
-
555
- expect(await screen.findByText('General page')).toBeVisible();
556
- });
557
-
558
- it('navigates from the small-screen select without reloading the page', async () => {
559
- renderSettings('/settings/authorization/permission-sets');
560
- await screen.findByText('Permission Sets page');
561
-
562
- fireEvent.change(screen.getByLabelText('Settings page'), {
563
- target: { value: '/settings/workflow' },
564
- });
565
-
566
- expect(await screen.findByText('Workflow General page')).toBeVisible();
567
- });
568
-
569
- it('builds nav entries in declaration order, emitting each group once', () => {
570
- const first = createSetting('a', 'A', 'g1');
571
- const second = createSetting('b', 'B');
572
- const third = createSetting('c', 'C', 'g1');
573
- const group: AppClientRegisteredSettingGroup = {
574
- id: 'g1',
575
- packageName: '@nocobase/app-plugin-test',
576
- settings: [first, third],
577
- source: 'plugin',
578
- title: 'Group One',
579
- };
580
-
581
- expect(
582
- buildNavEntries([first, second, third], [group]).map((entry) =>
583
- entry.kind === 'group'
584
- ? ['group', entry.group.settings.map((s) => s.id)]
585
- : ['page', entry.setting.id],
586
- ),
587
- ).toEqual([
588
- ['group', ['a', 'c']],
589
- ['page', 'b'],
590
- ]);
591
- });
592
-
593
- it('renders a page flat when it names a group nobody registered', () => {
594
- const orphan = createSetting('a', 'A', 'missing');
595
-
596
- expect(buildNavEntries([orphan], [])).toEqual([
597
- { kind: 'page', setting: orphan },
598
- ]);
599
- });
600
- });
601
-
602
- const ICON: AppClientSettingIcon = ({ className }) => (
603
- <svg className={className} data-testid='setting-icon' />
604
- );
605
-
606
- const AUTHORIZATION: AppClientRegisteredSettingGroup = {
607
- icon: ICON,
608
- id: 'authorization',
609
- packageName: '@nocobase/app-plugin-test',
610
- settings: [
611
- createSetting(
612
- 'permission-sets',
613
- 'Permission Sets',
614
- 'authorization',
615
- 'authorization.permission-sets',
616
- ICON,
617
- ),
618
- createSetting(
619
- 'default-access',
620
- 'Default Access',
621
- 'authorization',
622
- 'authorization.default-access',
623
- ),
624
- ],
625
- source: 'plugin',
626
- surface: 'settings',
627
- title: 'Authorization',
628
- };
629
-
630
- // A group's pages are also in the flat list; that is what the router mounts.
631
- const SETTINGS: readonly AppClientRegisteredSetting[] = [
632
- ...AUTHORIZATION.settings,
633
- createSetting('workflow', 'Workflow General'),
634
- ];
635
-
636
- const GROUPS: readonly AppClientRegisteredSettingGroup[] = [AUTHORIZATION];
637
-
638
- function renderSettings(
639
- initialEntry: string,
640
- authorization?: Pick<AuthorizationClient, 'can'>,
641
- settings: readonly AppClientRegisteredSetting[] = SETTINGS,
642
- groups: readonly AppClientRegisteredSettingGroup[] = GROUPS,
643
- routes: readonly AppClientRegisteredRoute[] = [],
644
- tree?: readonly AppClientRegisteredRoute[],
645
- ): void {
646
- const settingsRouteTree = tree ?? toRouteTree(settings, groups);
647
- renderWithAuthentication(
648
- <MemoryRouter initialEntries={[initialEntry]}>
649
- <AppThemeProvider>
650
- <AppRouter
651
- devRouteTree={[]}
652
- clientRoutes={routes}
653
- settingsRouteTree={settingsRouteTree}
654
- />
655
- </AppThemeProvider>
656
- </MemoryRouter>,
657
- settingsRouteTree,
658
- authorization,
659
- );
660
- }
661
-
662
- /** Renders a router subtree the way renderSettings does, for a surface other than the settings centre. */
663
- function renderApp(
664
- element: ReactElement,
665
- initialEntry: string,
666
- settingsRouteTree: readonly AppClientRegisteredRoute[] = [],
667
- ): void {
668
- renderWithAuthentication(
669
- <MemoryRouter initialEntries={[initialEntry]}>
670
- <AppThemeProvider>{element}</AppThemeProvider>
671
- </MemoryRouter>,
672
- settingsRouteTree,
673
- );
674
- }
675
-
676
- function renderWithAuthentication(
677
- element: ReactElement,
678
- settingsRouteTree: readonly AppClientRegisteredRoute[],
679
- authorization?: Pick<AuthorizationClient, 'can'>,
680
- ): void {
681
- const authClient = {
682
- getSession: vi.fn().mockResolvedValue({
683
- data: {
684
- session: null,
685
- user: {
686
- email: 'alice@example.com',
687
- id: '1',
688
- image: null,
689
- name: 'Alice',
690
- },
691
- },
692
- }),
693
- signOut: vi.fn().mockResolvedValue({ data: null }),
694
- };
695
- const authorizationClient = new AuthorizationClient({
696
- request: vi.fn(),
697
- } as never);
698
- vi.spyOn(authorizationClient, 'can').mockImplementation(
699
- (request) => authorization?.can(request) ?? Promise.resolve(true),
700
- );
701
- const registered = new Map<unknown, unknown>([
702
- [authenticationClientToken, authClient],
703
- [authorizationClientToken, authorizationClient],
704
- ]);
705
- const app = {
706
- runtime: { settingsRouteTree },
707
- services: {
708
- has: (token: unknown) => registered.has(token),
709
- resolve: (token: unknown) => {
710
- if (registered.has(token)) return registered.get(token);
711
- throw new Error(`Unexpected service token: ${String(token)}`);
712
- },
713
- },
714
- } as unknown as ClientApplication;
715
-
716
- render(
717
- <ClientApplicationContext.Provider value={app}>
718
- <AuthenticationProvider>{element}</AuthenticationProvider>
719
- </ClientApplicationContext.Provider>,
720
- );
721
- }
722
-
723
- function createSetting(
724
- id: string,
725
- title: string,
726
- groupId?: string,
727
- accessResource?: string,
728
- icon?: AppClientSettingIcon,
729
- ): AppClientRegisteredSetting {
730
- return {
731
- ...(accessResource === undefined
732
- ? { authz: 'skip' as const }
733
- : {
734
- authz: {
735
- resource: { type: 'settings', id: accessResource },
736
- action: 'read',
737
- },
738
- }),
739
- ...(icon === undefined ? {} : { icon }),
740
- ...(groupId === undefined ? {} : { groupId }),
741
- id,
742
- packageName: '@nocobase/app-plugin-test',
743
- pageLoader: async () => ({
744
- default: (): ReactElement => <h2>{title} page</h2>,
745
- }),
746
- path:
747
- groupId === undefined ? `/settings/${id}` : `/settings/${groupId}/${id}`,
748
- source: 'plugin',
749
- surface: 'settings',
750
- title,
751
- };
752
- }
753
-
754
- function toRouteTree(
755
- settings: readonly AppClientRegisteredSetting[],
756
- groups: readonly AppClientRegisteredSettingGroup[],
757
- ): AppClientRegisteredRoute[] {
758
- const page = (
759
- setting: AppClientRegisteredSetting,
760
- ): AppClientRegisteredRoute => ({
761
- id: setting.id,
762
- name: setting.id,
763
- path: setting.path,
764
- auth: 'required',
765
- packageName: setting.packageName,
766
- source: setting.source,
767
- componentLoader: setting.pageLoader,
768
- authz: setting.authz,
769
- ...(setting.navigation !== false
770
- ? { navigation: { title: setting.title, icon: setting.icon } }
771
- : {}),
772
- });
773
- return buildNavEntries(settings, groups)
774
- .map((entry): AppClientRegisteredRoute =>
775
- entry.kind === 'page'
776
- ? page(entry.setting)
777
- : {
778
- id: entry.group.id,
779
- name: entry.group.id,
780
- path: '/settings',
781
- auth: 'required',
782
- authz: 'skip',
783
- packageName: entry.group.packageName,
784
- source: entry.group.source,
785
- navigation: { title: entry.group.title, icon: entry.group.icon },
786
- children: entry.group.settings.map(page),
787
- },
788
- )
789
- .concat(
790
- settings.filter((setting) => setting.navigation === false).map(page),
791
- );
792
- }
793
-
794
- type SurfaceNavEntry =
795
- | { kind: 'group'; group: AppClientRegisteredSettingGroup }
796
- | { kind: 'page'; setting: AppClientRegisteredSetting };
797
-
798
- export function buildNavEntries(
799
- visible: readonly AppClientRegisteredSetting[],
800
- groups: readonly AppClientRegisteredSettingGroup[],
801
- ): readonly SurfaceNavEntry[] {
802
- const visiblePaths = new Set(
803
- visible
804
- .filter((setting) => setting.navigation !== false)
805
- .map((setting) => setting.path),
806
- );
807
- const groupsById = new Map(groups.map((group) => [group.id, group]));
808
- const entries: SurfaceNavEntry[] = [];
809
- const seenGroups = new Set<string>();
810
-
811
- for (const setting of visible) {
812
- if (setting.navigation === false) {
813
- continue;
814
- }
815
- if (setting.groupId === undefined) {
816
- entries.push({ kind: 'page', setting });
817
- continue;
818
- }
819
- if (seenGroups.has(setting.groupId)) {
820
- continue;
821
- }
822
- const group = groupsById.get(setting.groupId);
823
- if (!group) {
824
- // A page naming a group nobody registered still has to be reachable, so it renders flat rather than vanishing.
825
- entries.push({ kind: 'page', setting });
826
- continue;
827
- }
828
- seenGroups.add(group.id);
829
- entries.push({
830
- kind: 'group',
831
- group: {
832
- ...group,
833
- settings: group.settings.filter((child) =>
834
- visiblePaths.has(child.path),
835
- ),
836
- },
837
- });
838
- }
839
-
840
- return entries;
841
- }