@nocobase/app-template-default 1.0.0-beta.68 → 1.0.0-beta.70

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 (76) hide show
  1. package/.agents/rules/components.md +5 -1
  2. package/.agents/rules/shell.md +3 -1
  3. package/AGENTS.md +7 -3
  4. package/Dockerfile.dockerignore +0 -1
  5. package/README.MD +3 -1
  6. package/client/app.ts +15 -0
  7. package/client/components/data-table/column-header.tsx +91 -0
  8. package/client/components/data-table/data-table.tsx +216 -0
  9. package/client/components/data-table/index.ts +13 -0
  10. package/client/components/data-table/pagination.tsx +165 -0
  11. package/client/components/data-table/view-options.tsx +77 -0
  12. package/client/components/flow-diagram-css.d.ts +2 -0
  13. package/client/components/flow-diagram.tsx +674 -0
  14. package/client/components/flow-layout.ts +250 -0
  15. package/client/components/lifecycle-diagram-model.ts +217 -0
  16. package/client/components/lifecycle-diagram.tsx +310 -0
  17. package/client/components/permission-editor.tsx +589 -0
  18. package/client/components/permission-filter.tsx +455 -0
  19. package/client/components/route-overlay.tsx +26 -13
  20. package/client/components/ui/checkbox.tsx +25 -0
  21. package/client/components/ui/sidebar.tsx +1 -4
  22. package/client/components/ui/switch.tsx +30 -0
  23. package/client/components/ui/table.tsx +113 -0
  24. package/client/extensions/nocobase-workflow-management/README.md +70 -0
  25. package/client/extensions/nocobase-workflow-management/definition-list.tsx +148 -0
  26. package/client/extensions/nocobase-workflow-management/execution-detail.tsx +230 -0
  27. package/client/extensions/nocobase-workflow-management/execution-list.tsx +175 -0
  28. package/client/extensions/nocobase-workflow-management/layout.tsx +95 -0
  29. package/client/layouts/components/app-sidebar.tsx +1 -1
  30. package/client/layouts/use-sidebar-preference.ts +62 -42
  31. package/client/locales/system/en-US.ts +29 -0
  32. package/client/locales/system/settings-en-US.ts +142 -0
  33. package/client/locales/system/settings-zh-CN.ts +136 -0
  34. package/client/locales/system/zh-CN.ts +29 -0
  35. package/client/pages/auth/auth-page.css +15 -0
  36. package/client/pages/auth/login.tsx +1 -1
  37. package/client/pages/auth/shared.tsx +3 -1
  38. package/client/pages/settings/hooks.tsx +137 -0
  39. package/client/pages/settings/page-permission-titles.ts +7 -0
  40. package/client/pages/settings/page-permissions.ts +42 -0
  41. package/client/pages/settings/permission-filters.tsx +119 -0
  42. package/client/pages/settings/permissions-model.ts +260 -0
  43. package/client/pages/settings/role-form.tsx +405 -0
  44. package/client/pages/settings/roles.tsx +163 -0
  45. package/client/pages/settings/routes.ts +79 -0
  46. package/client/pages/settings/shared.tsx +119 -0
  47. package/client/pages/settings/user-detail.tsx +268 -0
  48. package/client/pages/settings/user-form.tsx +427 -0
  49. package/client/pages/settings/users.tsx +451 -0
  50. package/client/routing/app-router.tsx +4 -1
  51. package/client/routing/session-preparation.tsx +65 -0
  52. package/client/styles.css +4 -7
  53. package/package.json +27 -24
  54. package/tests/components/app-layout-routes.test.tsx +14 -7
  55. package/tests/components/data-table.test.tsx +127 -0
  56. package/tests/components/layout-i18n.test.tsx +8 -1
  57. package/tests/components/navigation-menu.test.tsx +24 -14
  58. package/tests/components/permission-editor.test.tsx +477 -0
  59. package/tests/components/permission-filter-binding.test.tsx +150 -0
  60. package/tests/components/permission-filter.test.tsx +202 -0
  61. package/tests/components/settings.test.tsx +294 -0
  62. package/tests/components/sidebar-permissions.test.tsx +12 -4
  63. package/tests/components/sidebar-preference.test.tsx +209 -14
  64. package/tests/components/user-permissions.test.tsx +142 -0
  65. package/tests/fixtures/theme-tokens.tsx +9 -1
  66. package/tests/logic/asset-url-build.test.ts +0 -1
  67. package/tests/logic/client-routes.test.ts +40 -0
  68. package/tests/logic/client-shell.test.tsx +10 -2
  69. package/tests/logic/config-example.test.ts +30 -0
  70. package/tests/logic/dev-client-watch.test.ts +8 -1
  71. package/tests/logic/page-permission-titles.test.ts +223 -0
  72. package/tests/logic/route-overlay.test.tsx +42 -0
  73. package/tests/logic/session-preparation.test.tsx +223 -0
  74. package/tests/logic/settings-permissions.test.ts +168 -0
  75. package/vite.config.ts +0 -22
  76. package/client/agent-annotations-host.ts +0 -25
@@ -0,0 +1,223 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ resolveAppClientContributions,
4
+ type AppClientRegisteredRoute,
5
+ } from '@nocobase/app-client/plugins';
6
+ import applicationRoutes from '../../client/routes.js';
7
+ import enUS from '../../client/locales/en-US.js';
8
+ import zhCN from '../../client/locales/zh-CN.js';
9
+ import {
10
+ pagePermissionResources,
11
+ pagePermissionTitle,
12
+ } from '../../client/pages/settings/page-permissions.js';
13
+ import { pagePermissionTitles } from '../../client/pages/settings/page-permission-titles.js';
14
+
15
+ const flatten = (
16
+ routes: readonly AppClientRegisteredRoute[],
17
+ ): AppClientRegisteredRoute[] =>
18
+ routes.flatMap((route) => [route, ...flatten(route.children ?? [])]);
19
+ const localized = (resource: unknown, key: string): unknown =>
20
+ key
21
+ .split('.')
22
+ .reduce<unknown>(
23
+ (value, part) =>
24
+ value && typeof value === 'object'
25
+ ? Reflect.get(value, part)
26
+ : undefined,
27
+ resource,
28
+ );
29
+ const application = resolveAppClientContributions([
30
+ {
31
+ packageName: 'title-check-app',
32
+ source: 'application',
33
+ routes: applicationRoutes,
34
+ },
35
+ ]);
36
+ const pages = flatten(application.routes).filter(
37
+ (route) =>
38
+ route.source === 'application' &&
39
+ route.componentLoader &&
40
+ typeof route.authz === 'object' &&
41
+ route.authz.resource.type === 'page',
42
+ );
43
+
44
+ describe('page permission labels', () => {
45
+ it('names every application page permission and translates it in both languages', () => {
46
+ const missing = pages
47
+ .filter((route) => !pagePermissionTitle(route, pagePermissionTitles))
48
+ .map((route) => route.name);
49
+ expect(
50
+ missing,
51
+ 'Add a navigation/breadcrumb title or a page-permission-titles entry; never rename authorization ids to fix labels.',
52
+ ).toEqual([]);
53
+ for (const route of pages) {
54
+ const title = pagePermissionTitle(route, pagePermissionTitles);
55
+ if (!title || typeof title === 'string')
56
+ throw new Error(`Missing permission title: ${route.name}`);
57
+ for (const locale of [enUS, zhCN]) {
58
+ const value = localized(locale, title.key);
59
+ expect(value, `${route.name}: ${title.key}`).toBeTypeOf('string');
60
+ expect(String(value).trim()).not.toBe('');
61
+ expect(value).not.toBe(title.key);
62
+ expect(value).not.toBe(route.id);
63
+ expect(String(value)).not.toContain('{{');
64
+ }
65
+ }
66
+ const names = new Set(
67
+ flatten(application.routes).map((route) => route.name),
68
+ );
69
+ for (const name of Object.keys(pagePermissionTitles))
70
+ expect(names.has(name), `Stale permission title: ${name}`).toBe(true);
71
+ });
72
+
73
+ it('preserves the complete application page-resource identity list', () => {
74
+ const expected = pages.map((route) => {
75
+ if (typeof route.authz !== 'object')
76
+ throw new Error('Expected page authorization');
77
+ return { type: 'page', id: route.authz.resource.id, actions: ['access'] };
78
+ });
79
+ expect(
80
+ pagePermissionResources(
81
+ application.routes,
82
+ pagePermissionTitles,
83
+ (path) => `Unnamed (${path})`,
84
+ ).map(({ type, id, actions }) => ({
85
+ type,
86
+ id,
87
+ actions: actions.map((action) => action.name),
88
+ })),
89
+ ).toEqual(expected);
90
+ });
91
+
92
+ const fixture = () =>
93
+ resolveAppClientContributions([
94
+ {
95
+ packageName: 'private-app',
96
+ source: 'application',
97
+ routes: [
98
+ {
99
+ parent: 'app',
100
+ routes: [
101
+ {
102
+ name: 'expenses',
103
+ path: '/expenses',
104
+ auth: 'required',
105
+ authz: {
106
+ resource: { type: 'page', id: 'legacy-expenses' },
107
+ action: 'access',
108
+ },
109
+ navigation: { title: 'expenses.title' },
110
+ componentLoader: async () => ({ default: () => null }),
111
+ children: [
112
+ {
113
+ name: 'expenseEdit',
114
+ path: ':id/edit',
115
+ authz: {
116
+ resource: { type: 'page', id: 'legacy-edit' },
117
+ action: 'access',
118
+ },
119
+ componentLoader: async () => ({ default: () => null }),
120
+ },
121
+ ],
122
+ },
123
+ ],
124
+ },
125
+ ],
126
+ },
127
+ ]);
128
+
129
+ it('names an overlay without adding navigation/breadcrumbs or changing resources', () => {
130
+ const input = fixture();
131
+ const route = input.routes[0]!.children![0]!;
132
+ const identities = flatten(input.routes).map(
133
+ ({ id, name, path, authz }) => ({ id, name, path, authz }),
134
+ );
135
+ const result = pagePermissionResources(
136
+ input.routes,
137
+ { expenseEdit: 'expenses.editTitle' },
138
+ (path) => `Unnamed (${path})`,
139
+ );
140
+ expect(result.map(({ id, actions }) => ({ id, actions }))).toEqual([
141
+ {
142
+ id: 'legacy-expenses',
143
+ actions: [{ name: 'access', title: { key: 'settings.access' } }],
144
+ },
145
+ {
146
+ id: 'legacy-edit',
147
+ actions: [{ name: 'access', title: { key: 'settings.access' } }],
148
+ },
149
+ ]);
150
+ expect(result[1]!.title).toEqual({
151
+ key: 'expenses.editTitle',
152
+ ns: 'private-app',
153
+ });
154
+ expect(route.navigation).toBeUndefined();
155
+ expect(route.breadcrumb).toBeUndefined();
156
+ expect(
157
+ flatten(input.routes).map(({ id, name, path, authz }) => ({
158
+ id,
159
+ name,
160
+ path,
161
+ authz,
162
+ })),
163
+ ).toEqual(identities);
164
+ expect(
165
+ pagePermissionResources(
166
+ input.routes,
167
+ { expenseEdit: 'expenses.editTitle' },
168
+ (path) => `Unnamed (${path})`,
169
+ ),
170
+ ).toEqual(result);
171
+ });
172
+
173
+ it('reports missing metadata and renders a localized fallback without dropping the permission', () => {
174
+ const input = fixture();
175
+ const overlay = input.routes[0]!.children![0]!;
176
+ expect(pagePermissionTitle(overlay, {})).toBeUndefined();
177
+ const resources = pagePermissionResources(
178
+ input.routes,
179
+ {},
180
+ (path) => `未命名页面(${path})`,
181
+ );
182
+ expect(resources[1]!.id).toBe('legacy-edit');
183
+ expect(resources[1]!.title).toBe('未命名页面(/expenses/:id/edit)');
184
+ expect(resources[1]!.title).not.toContain('private-app:');
185
+ });
186
+
187
+ it('does not treat object prototype properties as configured labels', () => {
188
+ const route = fixture().routes[0]!.children![0]!;
189
+ expect(
190
+ pagePermissionTitle({ ...route, name: 'constructor' }, {}),
191
+ ).toBeUndefined();
192
+ expect(
193
+ pagePermissionTitle({ ...route, name: 'toString' }, {}),
194
+ ).toBeUndefined();
195
+ });
196
+
197
+ it('does not apply application-name overrides to plugin routes or expose non-page resources', () => {
198
+ const input = fixture();
199
+ const overlay = input.routes[0]!.children![0]!;
200
+ expect(
201
+ pagePermissionTitle(
202
+ { ...overlay, source: 'plugin', packageName: 'plugin' },
203
+ { expenseEdit: 'expenses.editTitle' },
204
+ ),
205
+ ).toBeUndefined();
206
+ expect(
207
+ pagePermissionResources(
208
+ [
209
+ { ...overlay, authz: 'skip' },
210
+ {
211
+ ...overlay,
212
+ authz: {
213
+ resource: { type: 'settings', id: 'members' },
214
+ action: 'read',
215
+ },
216
+ },
217
+ ],
218
+ {},
219
+ () => 'Unnamed',
220
+ ),
221
+ ).toEqual([]);
222
+ });
223
+ });
@@ -4,6 +4,7 @@ import {
4
4
  render,
5
5
  screen,
6
6
  waitFor,
7
+ within,
7
8
  } from '@testing-library/react';
8
9
  import userEvent from '@testing-library/user-event';
9
10
  import {
@@ -158,6 +159,47 @@ describe('route overlays', () => {
158
159
  });
159
160
  });
160
161
 
162
+ describe('route overlay presentation', () => {
163
+ it('composes the dialog primitives so it follows the dialog styles', async () => {
164
+ setup();
165
+ const dialog = await screen.findByRole('dialog', { name: 'Edit' });
166
+ expect(dialog).toHaveAttribute('data-slot', 'route-overlay-content');
167
+ expect(dialog).toHaveAttribute('data-presentation', 'dialog');
168
+ const header = dialog.querySelector(
169
+ ':scope > [data-slot="route-overlay-header"] > [data-slot="dialog-header"]',
170
+ );
171
+ expect(
172
+ header?.querySelector('[data-slot="dialog-title"]'),
173
+ ).toHaveTextContent('Edit');
174
+ expect(
175
+ dialog.querySelector(':scope > [data-slot="route-overlay-body"]'),
176
+ ).toContainElement(screen.getByRole('textbox', { name: 'Name' }));
177
+ const footer = dialog.querySelector(
178
+ ':scope > [data-slot="route-overlay-footer"] > [data-slot="dialog-footer"]',
179
+ );
180
+ expect(footer).toContainElement(
181
+ screen.getByRole('button', { name: 'Cancel' }),
182
+ );
183
+ // The footer sits at the panel's edges, so the margins it uses inside `DialogContent` are reset.
184
+ expect(footer).toHaveClass('mx-0', 'mb-0');
185
+ expect(footer).not.toHaveClass('-mx-6', '-mb-6');
186
+ expect(
187
+ dialog.querySelector(':scope > [data-slot="dialog-close"]'),
188
+ ).toHaveAccessibleName('Close');
189
+ });
190
+
191
+ it('presents a drawer with the same regions', async () => {
192
+ setup(undefined, undefined, true);
193
+ const drawer = await screen.findByRole('dialog', { name: 'Details' });
194
+ expect(drawer).toHaveAttribute('data-presentation', 'drawer');
195
+ expect(
196
+ drawer.querySelector(
197
+ ':scope > [data-slot="route-overlay-footer"] > [data-slot="dialog-footer"]',
198
+ ),
199
+ ).toContainElement(within(drawer).getByRole('button', { name: 'Cancel' }));
200
+ });
201
+ });
202
+
161
203
  describe('route overlay interactions', () => {
162
204
  it('renders an owning backdrop for the nested layer and dismisses only that layer', async () => {
163
205
  const user = userEvent.setup();
@@ -0,0 +1,223 @@
1
+ import {
2
+ ClientApplicationContext,
3
+ realtimeClientToken,
4
+ type ClientApplication,
5
+ } from '@nocobase/app-client';
6
+ import {
7
+ AuthenticationProvider,
8
+ authenticationClientToken,
9
+ useAuthentication,
10
+ } from '@nocobase/app-plugin-authentication/client';
11
+ import {
12
+ AuthorizationClient,
13
+ authorizationClientToken,
14
+ } from '@nocobase/app-plugin-authorization/client';
15
+ import { reactProviders } from '@nocobase/app-plugin-authorization/client/react-providers';
16
+ import { ServiceContainer } from '@nocobase/service-provider';
17
+ import {
18
+ TestI18nProvider,
19
+ createTestI18nRuntime,
20
+ } from '@nocobase/i18n/testing';
21
+ import { act, fireEvent, render, screen } from '@testing-library/react';
22
+ import { useEffect } from 'react';
23
+ import { MemoryRouter, Outlet } from 'react-router';
24
+ import { afterEach, expect, it, vi } from 'vitest';
25
+ import { AppRouter } from '../../client/routing/app-router.js';
26
+ import {
27
+ Dialog,
28
+ DialogContent,
29
+ DialogTitle,
30
+ DialogDescription,
31
+ } from '../../client/components/ui/dialog.js';
32
+ import enUS from '../../client/locales/en-US.js';
33
+
34
+ const AuthorizationProvider = reactProviders[0].component;
35
+ vi.mock('../../client/layouts/app-layout.js', () => ({
36
+ AppLayout: () => <Outlet />,
37
+ }));
38
+ vi.mock('../../client/routing/standalone-page-layout.js', () => ({
39
+ StandalonePageLayout: () => <Outlet />,
40
+ }));
41
+ const runtime = await createTestI18nRuntime({
42
+ application: { namespace: 'test', resources: enUS },
43
+ });
44
+ afterEach(() => vi.useRealTimers());
45
+
46
+ async function setup() {
47
+ const allowed = {
48
+ data: {
49
+ unrestricted: false,
50
+ permissions: [
51
+ { resource: { type: 'page', id: 'probe' }, actions: ['access'] },
52
+ ],
53
+ },
54
+ };
55
+ const request = vi.fn().mockResolvedValue(allowed);
56
+ const authz = new AuthorizationClient({ request } as never);
57
+ const services = new ServiceContainer();
58
+ const listeners = new Set<() => void>();
59
+ const realtime = {
60
+ active: true,
61
+ reconnect: vi.fn(),
62
+ onOpen: (listener: () => void) => {
63
+ listeners.add(listener);
64
+ return () => {
65
+ listeners.delete(listener);
66
+ };
67
+ },
68
+ };
69
+ const signOut = vi.fn().mockResolvedValue({ error: { message: 'Offline' } });
70
+ let session: unknown = { user: { id: 'user' }, session: { id: 'session' } };
71
+ const getSession = vi.fn(async () => ({ data: session }));
72
+ services.instance(authenticationClientToken, {
73
+ getSession,
74
+ signOut,
75
+ } as never);
76
+ services.instance(realtimeClientToken, realtime as never);
77
+ services.instance(authorizationClientToken, authz);
78
+ const mounted = vi.fn();
79
+ const cleaned = vi.fn();
80
+ const accepted = vi.fn();
81
+ const old = Promise.withResolvers<void>();
82
+ function Page() {
83
+ useEffect(() => {
84
+ let active = true;
85
+ mounted();
86
+ void old.promise.then(() => {
87
+ if (active) accepted();
88
+ });
89
+ return () => {
90
+ active = false;
91
+ cleaned();
92
+ };
93
+ }, []);
94
+ return (
95
+ <Dialog open>
96
+ <DialogContent>
97
+ <DialogTitle>Protected dialog</DialogTitle>
98
+ <DialogDescription>Protected content</DialogDescription>
99
+ </DialogContent>
100
+ </Dialog>
101
+ );
102
+ }
103
+ let refresh!: () => Promise<void>;
104
+ function Capture() {
105
+ refresh = useAuthentication().refresh;
106
+ return null;
107
+ }
108
+ const route = {
109
+ id: 'probe',
110
+ name: 'probe',
111
+ path: '/',
112
+ auth: 'required',
113
+ authz: { resource: { type: 'page', id: 'probe' }, action: 'access' },
114
+ packageName: 'test',
115
+ source: 'application',
116
+ componentLoader: async () => ({ default: Page }),
117
+ } as const;
118
+ render(
119
+ <TestI18nProvider runtime={runtime}>
120
+ <ClientApplicationContext.Provider
121
+ value={{ services } as unknown as ClientApplication}
122
+ >
123
+ <AuthenticationProvider>
124
+ <Capture />
125
+ <AuthorizationProvider>
126
+ <MemoryRouter>
127
+ <AppRouter
128
+ clientRoutes={[
129
+ route,
130
+ {
131
+ id: 'login',
132
+ name: 'login',
133
+ path: '/login',
134
+ auth: 'guest',
135
+ authz: 'skip',
136
+ source: 'application',
137
+ packageName: 'test',
138
+ componentLoader: async () => ({
139
+ default: () => <p>Login route</p>,
140
+ }),
141
+ },
142
+ ]}
143
+ />
144
+ </MemoryRouter>
145
+ </AuthorizationProvider>
146
+ </AuthenticationProvider>
147
+ </ClientApplicationContext.Provider>
148
+ </TestI18nProvider>,
149
+ );
150
+ await act(async () => {});
151
+ // Register after preparation to prove ordering does not depend on effects.
152
+ listeners.add(() => authz.invalidate());
153
+ return {
154
+ allowed,
155
+ request,
156
+ authz,
157
+ realtime,
158
+ signOut,
159
+ getSession,
160
+ mounted,
161
+ cleaned,
162
+ accepted,
163
+ old,
164
+ open: async () => {
165
+ await act(async () => {
166
+ for (const listener of listeners) listener();
167
+ });
168
+ },
169
+ logout: async () => {
170
+ session = null;
171
+ await act(async () => refresh());
172
+ },
173
+ };
174
+ }
175
+
176
+ it('mounts only after the initial handshake and preserves runtime fail-closed portal cleanup', async () => {
177
+ const probe = await setup();
178
+ expect(probe.mounted).not.toHaveBeenCalled();
179
+ expect(probe.request).not.toHaveBeenCalled();
180
+ await probe.open();
181
+ await screen.findByText('Protected dialog');
182
+ expect(probe.mounted).toHaveBeenCalledOnce();
183
+ expect(probe.request).toHaveBeenCalledOnce();
184
+ const next = Promise.withResolvers<typeof probe.allowed>();
185
+ probe.request.mockReturnValueOnce(next.promise);
186
+ await probe.open();
187
+ expect(screen.queryByRole('dialog')).toBeNull();
188
+ expect(probe.cleaned).toHaveBeenCalledOnce();
189
+ await act(async () => probe.old.resolve());
190
+ expect(probe.accepted).not.toHaveBeenCalled();
191
+ await act(async () =>
192
+ next.resolve({ data: { unrestricted: false, permissions: [] } }),
193
+ );
194
+ expect(screen.queryByRole('dialog')).toBeNull();
195
+ expect(await screen.findByText('Access denied')).toBeVisible();
196
+ });
197
+
198
+ it('keeps a timed-out session closed, supports retry, and reports sign-out failure', async () => {
199
+ vi.useFakeTimers();
200
+ const probe = await setup();
201
+ await act(async () => vi.advanceTimersByTime(10_000));
202
+ expect(screen.getByText('Unable to connect')).toBeVisible();
203
+ fireEvent.click(screen.getByRole('button', { name: 'Sign out' }));
204
+ await act(async () => {});
205
+ expect(screen.getByRole('alert')).toHaveTextContent('Unable to sign out');
206
+ fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
207
+ expect(probe.realtime.reconnect).toHaveBeenCalledTimes(2);
208
+ expect(screen.queryByText('Unable to connect')).toBeNull();
209
+ await probe.open();
210
+ expect(probe.mounted).toHaveBeenCalledOnce();
211
+ });
212
+
213
+ it('can sign out of a failed initialization without a working realtime connection', async () => {
214
+ vi.useFakeTimers();
215
+ const probe = await setup();
216
+ await act(async () => vi.advanceTimersByTime(10_000));
217
+ probe.signOut.mockResolvedValueOnce({});
218
+ probe.getSession.mockResolvedValueOnce({ data: null });
219
+ fireEvent.click(screen.getByRole('button', { name: 'Sign out' }));
220
+ await act(async () => {});
221
+ expect(screen.getByText('Login route')).toBeVisible();
222
+ expect(probe.mounted).not.toHaveBeenCalled();
223
+ });
@@ -0,0 +1,168 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ setPermission,
4
+ setPermissions,
5
+ permissionSections,
6
+ setScope,
7
+ type CatalogResource,
8
+ } from '../../client/pages/settings/permissions-model.js';
9
+ const resource: CatalogResource = {
10
+ type: 'composite',
11
+ id: 'orders',
12
+ title: 'Orders',
13
+ actions: [{ name: 'edit', title: 'Edit' }],
14
+ dataScopes: {
15
+ edit: [
16
+ {
17
+ key: 'records',
18
+ title: 'Records',
19
+ collection: 'orders',
20
+ fields: [],
21
+ recordAccess: ['mine', 'all'],
22
+ defaultValue: 'mine',
23
+ },
24
+ ],
25
+ },
26
+ };
27
+ describe('settings permission drafts', () => {
28
+ it('adds only the explicit action at its registered default and keeps unrelated policies intact', () => {
29
+ const untouched = {
30
+ resource: { type: 'settings', id: 'notification' },
31
+ actions: [{ action: 'read', policy: { type: 'custom', region: 'east' } }],
32
+ };
33
+ const original = [untouched];
34
+ const next = setPermission(original, resource, 'edit', true);
35
+ expect(next).toContainEqual(untouched);
36
+ expect(next[1]?.actions).toEqual([
37
+ {
38
+ action: 'edit',
39
+ policy: { type: 'composite', scopes: { records: 'mine' } },
40
+ },
41
+ ]);
42
+ expect(original).toEqual([untouched]);
43
+ expect(setPermission(next, resource, 'edit', false)).toEqual([untouched]);
44
+ });
45
+ it('changes one scope without losing a different scope or custom grant', () => {
46
+ const original = [
47
+ {
48
+ resource: { type: 'composite', id: 'orders' },
49
+ actions: [
50
+ {
51
+ action: 'edit',
52
+ policy: {
53
+ type: 'composite',
54
+ scopes: {
55
+ records: 'mine',
56
+ attachments: { selected: ['file-1'] },
57
+ },
58
+ },
59
+ },
60
+ { action: 'approve' },
61
+ ],
62
+ },
63
+ ];
64
+ const next = setScope(
65
+ original,
66
+ JSON.stringify(['composite', 'orders', 'edit', 'records']),
67
+ 'all',
68
+ );
69
+ expect(next[0]?.actions[0]?.policy).toMatchObject({
70
+ scopes: { records: 'all', attachments: { selected: ['file-1'] } },
71
+ });
72
+ expect(next[0]?.actions[1]).toEqual({ action: 'approve' });
73
+ expect(original[0]?.actions[0]?.policy.scopes.records).toBe('mine');
74
+ });
75
+ });
76
+
77
+ it('keeps action scopes inline and folds a visible batch without resetting existing policies', () => {
78
+ const catalog = {
79
+ sections: [
80
+ {
81
+ name: 'business',
82
+ title: 'Business',
83
+ subsections: [
84
+ {
85
+ name: 'orders',
86
+ title: 'Orders',
87
+ resources: [
88
+ {
89
+ ...resource,
90
+ actions: [
91
+ ...resource.actions,
92
+ { name: 'export', title: 'Export' },
93
+ ],
94
+ },
95
+ ],
96
+ },
97
+ ],
98
+ },
99
+ ],
100
+ recordAccess: [
101
+ { key: 'mine', title: 'Mine' },
102
+ { key: 'all', title: 'All' },
103
+ ],
104
+ } as unknown as Parameters<typeof permissionSections>[0];
105
+ const unrelated = {
106
+ resource: { type: 'unknown', id: 'legacy' },
107
+ actions: [{ action: 'custom', policy: { type: 'custom', keep: true } }],
108
+ };
109
+ const original = [...setPermission([], resource, 'edit', true), unrelated];
110
+ const before = JSON.stringify(original);
111
+ const changed = setPermissions(original, catalog, [
112
+ { id: JSON.stringify(['composite', 'orders', 'export']), enabled: true },
113
+ { id: JSON.stringify(['composite', 'orders', 'edit']), enabled: true },
114
+ {
115
+ id: JSON.stringify(['composite', 'orders', 'notRegistered']),
116
+ enabled: true,
117
+ },
118
+ ]);
119
+ expect(changed).toContainEqual(unrelated);
120
+ expect(changed[0]?.actions).toEqual([
121
+ ...original[0]!.actions,
122
+ { action: 'export' },
123
+ ]);
124
+ expect(JSON.stringify(original)).toBe(before);
125
+ const result = permissionSections(catalog, changed, (value) =>
126
+ typeof value === 'string' ? value : value.key,
127
+ );
128
+ expect(result[0]?.groups[0]?.rows).toHaveLength(2);
129
+ expect(result[0]?.groups[0]?.rows[0]?.scopes?.[0]).toMatchObject({
130
+ label: 'Records',
131
+ level: 'mine',
132
+ id: JSON.stringify(['composite', 'orders', 'edit', 'records']),
133
+ });
134
+ });
135
+
136
+ it('replaces a raw collection scope caption with localized data-range text', () => {
137
+ const scopedResource = {
138
+ ...resource,
139
+ dataScopes: {
140
+ edit: [{ ...resource.dataScopes!.edit![0]!, title: 'orders' }],
141
+ },
142
+ };
143
+ const catalog = {
144
+ sections: [
145
+ {
146
+ name: 's',
147
+ title: 'Business',
148
+ subsections: [
149
+ { name: 'orders', title: 'Orders', resources: [scopedResource] },
150
+ ],
151
+ },
152
+ ],
153
+ recordAccess: [{ key: 'mine', title: 'Mine' }],
154
+ } as unknown as Parameters<typeof permissionSections>[0];
155
+ const result = permissionSections(
156
+ catalog,
157
+ setPermission([], scopedResource, 'edit', true),
158
+ (value) =>
159
+ typeof value === 'string'
160
+ ? value
161
+ : value.key === 'settings.recordScope'
162
+ ? 'Record scope'
163
+ : value.key,
164
+ );
165
+ expect(result[0]?.groups[0]?.rows[0]?.scopes?.[0]?.label).toBe(
166
+ 'Record scope',
167
+ );
168
+ });