@nocobase/app-template-default 1.0.0-beta.83 → 1.0.0-beta.85

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 (104) hide show
  1. package/.agents/rules/build-and-deploy.md +1 -1
  2. package/.agents/rules/shell.md +4 -0
  3. package/AGENTS.md +8 -2
  4. package/README.MD +10 -2
  5. package/client/components/back-button.tsx +3 -5
  6. package/client/components/data-table/data-table.tsx +86 -32
  7. package/client/components/default-access-editor.tsx +13 -2
  8. package/client/components/feedback-form.tsx +474 -0
  9. package/client/components/lifecycle-diagram.tsx +10 -10
  10. package/client/components/list-feedback.tsx +174 -0
  11. package/client/components/permission-editor.tsx +419 -226
  12. package/client/extensions/nocobase-auth-forms/password-login-form.tsx +17 -6
  13. package/client/extensions/nocobase-auth-forms/password-registration-form.tsx +24 -7
  14. package/client/extensions/nocobase-auth-forms/password-reset-form.tsx +19 -7
  15. package/client/extensions/nocobase-auth-forms/password-reset-request-form.tsx +15 -7
  16. package/client/extensions/nocobase-device-approval/device-approval.tsx +7 -2
  17. package/client/extensions/nocobase-device-approval/locales/en-US.ts +16 -0
  18. package/client/extensions/nocobase-device-approval/locales/zh-CN.ts +7 -0
  19. package/client/extensions/nocobase-file-component-ui/components/file-preview-dialog.tsx +20 -17
  20. package/client/extensions/nocobase-workflow-management/README.md +3 -1
  21. package/client/extensions/nocobase-workflow-management/definition-list.tsx +19 -7
  22. package/client/extensions/nocobase-workflow-management/execution-list.tsx +61 -48
  23. package/client/hooks/use-list-data.ts +76 -0
  24. package/client/hooks/use-list-location.ts +23 -0
  25. package/client/hooks/use-list-tabs.ts +42 -0
  26. package/client/hooks/use-table-url-state.ts +143 -30
  27. package/client/locales/system/en-US.ts +24 -1
  28. package/client/locales/system/settings-en-US.ts +8 -10
  29. package/client/locales/system/settings-zh-CN.ts +8 -8
  30. package/client/locales/system/zh-CN.ts +22 -1
  31. package/client/pages/settings/default-access-form.tsx +44 -26
  32. package/client/pages/settings/default-access.tsx +100 -93
  33. package/client/pages/settings/hooks.tsx +4 -59
  34. package/client/pages/settings/page-permissions.ts +17 -0
  35. package/client/pages/settings/permissions-model.ts +54 -59
  36. package/client/pages/settings/role-form.tsx +98 -56
  37. package/client/pages/settings/roles.tsx +69 -95
  38. package/client/pages/settings/shared.tsx +43 -33
  39. package/client/pages/settings/user-form.tsx +51 -30
  40. package/client/pages/settings/users.tsx +105 -91
  41. package/client/routing/session-preparation.tsx +10 -3
  42. package/client/runtime.ts +0 -4
  43. package/config.example.yml +24 -0
  44. package/database/main/migrations/202611020002_create_mutation_receipts.ts +19 -0
  45. package/package.json +24 -21
  46. package/server/app.ts +0 -2
  47. package/server/config/auth.ts +2 -0
  48. package/server/config/concurrency.ts +3 -0
  49. package/server/config/index.ts +3 -0
  50. package/server/config/jobs.ts +1 -13
  51. package/server/config/notification.ts +1 -1
  52. package/server/config/queue.ts +1 -7
  53. package/server/plugins.ts +2 -0
  54. package/server/providers/index.ts +4 -1
  55. package/server/providers/submission.ts +61 -0
  56. package/tests/client/auth-i18n.test.tsx +9 -0
  57. package/tests/client/back-button.test.tsx +3 -3
  58. package/tests/client/data-table.test.tsx +9 -4
  59. package/tests/client/default-access.test.tsx +99 -5
  60. package/tests/client/form-feedback.test.tsx +589 -0
  61. package/tests/client/list-feedback.test.tsx +301 -0
  62. package/tests/client/list-recovery.test.tsx +373 -0
  63. package/tests/client/list-tabs.test.tsx +121 -0
  64. package/tests/client/manual-list-retry.test.tsx +190 -0
  65. package/tests/client/page-permission-navigation.test.ts +8 -0
  66. package/tests/client/permission-editor.test.tsx +191 -21
  67. package/tests/client/permission-filter-binding.test.tsx +1 -1
  68. package/tests/client/permission-workspace.test.ts +13 -0
  69. package/tests/client/role-return.test.tsx +2 -1
  70. package/tests/client/session-preparation.test.tsx +24 -1
  71. package/tests/client/settings-continuity.test.tsx +85 -5
  72. package/tests/client/settings-feedback.test.tsx +32 -15
  73. package/tests/client/settings-permissions.test.ts +35 -0
  74. package/tests/client/settings.test.tsx +2 -1
  75. package/tests/client/table-url-state.test.tsx +117 -3
  76. package/tests/client/user-drawer.test.tsx +2 -1
  77. package/tests/client/user-permissions.test.tsx +30 -5
  78. package/tests/database/mutation-receipts.test.ts +45 -0
  79. package/tests/fixtures/form-feedback/README.md +33 -0
  80. package/tests/fixtures/form-feedback/equipment-detail.tsx +11 -0
  81. package/tests/fixtures/form-feedback/equipment-layout.tsx +15 -0
  82. package/tests/fixtures/form-feedback/equipment-migration.ts +27 -0
  83. package/tests/fixtures/form-feedback/equipment-server.ts +197 -0
  84. package/tests/fixtures/form-feedback/equipment-validation.tsx +94 -0
  85. package/tests/fixtures/form-feedback/equipment.tsx +369 -0
  86. package/tests/fixtures/form-feedback/verify-inline.mjs +174 -0
  87. package/tests/fixtures/form-feedback/verify.mjs +373 -0
  88. package/tests/fixtures/permission-alignment.html +16 -0
  89. package/tests/fixtures/permission-alignment.tsx +124 -0
  90. package/tests/fixtures/submission/README.md +35 -0
  91. package/tests/fixtures/submission/browser.test.ts +478 -0
  92. package/tests/fixtures/submission/migration.ts +61 -0
  93. package/tests/fixtures/submission/page.tsx +138 -0
  94. package/tests/fixtures/submission/playwright.config.ts +11 -0
  95. package/tests/fixtures/submission/routes.ts +208 -0
  96. package/tests/helpers/settings-client.tsx +31 -0
  97. package/tests/playwright/permission-alignment.test.ts +109 -0
  98. package/tests/server/auth-email-config.test.ts +79 -0
  99. package/tests/server/concurrency.test.ts +61 -0
  100. package/tests/server/config.test.ts +34 -14
  101. package/tests/server/member-role.test.ts +7 -1
  102. package/tests/server/submission-settings.test.ts +241 -0
  103. package/client/route-overrides.ts +0 -9
  104. package/client/source-extensions.ts +0 -19
@@ -0,0 +1,121 @@
1
+ import type { AppClientRegisteredRoute } from '@nocobase/app-client/plugins';
2
+ import { act, render, screen } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import {
5
+ createMemoryRouter,
6
+ Link,
7
+ Navigate,
8
+ Outlet,
9
+ RouterProvider,
10
+ useLocation,
11
+ } from 'react-router';
12
+ import { expect, it } from 'vitest';
13
+ import { useListTabs } from '../../client/hooks/use-list-tabs.js';
14
+ import { RouteTreeProvider } from '../../client/routing/route-context.js';
15
+
16
+ const tabs = [
17
+ { path: 'active', label: 'Active', dataset: 'active' },
18
+ { path: 'archived', label: 'Archived', dataset: 'archived' },
19
+ { path: 'cards', label: 'Cards', dataset: 'active' },
20
+ ];
21
+ function Layout() {
22
+ const state = useListTabs({
23
+ tabs,
24
+ parameterKeys: ['q', 'page', 'pageSize', 'sort', 'order'],
25
+ });
26
+ const location = useLocation();
27
+ if (state.fallback) return <Navigate to={state.fallback} replace />;
28
+ return (
29
+ <>
30
+ <output data-testid='tab'>{state.active?.label}</output>
31
+ <output data-testid='url'>
32
+ {location.pathname}
33
+ {location.search}
34
+ </output>
35
+ <output data-testid='source'>{JSON.stringify(state.source)}</output>
36
+ {tabs.map((tab) => (
37
+ <Link key={tab.path} to={state.destination(tab)}>
38
+ {tab.label}
39
+ </Link>
40
+ ))}
41
+ <Outlet />
42
+ </>
43
+ );
44
+ }
45
+ function setup(url: string) {
46
+ const router = createMemoryRouter(
47
+ [
48
+ {
49
+ element: (
50
+ <RouteTreeProvider routes={[] as AppClientRegisteredRoute[]}>
51
+ <Outlet />
52
+ </RouteTreeProvider>
53
+ ),
54
+ children: [
55
+ {
56
+ path: '/assets',
57
+ element: <Layout />,
58
+ children: [
59
+ ...tabs.map((tab) => ({
60
+ path: tab.path,
61
+ element: <Outlet />,
62
+ children: [
63
+ { index: true, element: <p>Content</p> },
64
+ { path: ':id', element: <p>Detail</p> },
65
+ ],
66
+ })),
67
+ {
68
+ path: ':unavailableTab',
69
+ element: null,
70
+ children: [{ path: ':id', element: null }],
71
+ },
72
+ ],
73
+ },
74
+ ],
75
+ },
76
+ ],
77
+ { initialEntries: [url] },
78
+ );
79
+ render(<RouterProvider router={router} />);
80
+ return router;
81
+ }
82
+
83
+ it('isolates different datasets and retains compatible display tabs and enclosing context', async () => {
84
+ const router = setup(
85
+ '/assets/active?q=drill&page=3&pageSize=20&sort=name&order=desc&parentFilter=kept',
86
+ );
87
+ await userEvent.click(screen.getByRole('link', { name: 'Cards' }));
88
+ expect(router.state.location.search).toContain('page=3');
89
+ await userEvent.click(screen.getByRole('link', { name: 'Archived' }));
90
+ expect(router.state.location.search).toBe('?parentFilter=kept');
91
+ await act(() => router.navigate(-1));
92
+ expect(screen.getByTestId('tab')).toHaveTextContent('Cards');
93
+ expect(router.state.location.search).toContain('q=drill');
94
+ });
95
+
96
+ it('retains the source tab and its query when a real detail route writes its own query', async () => {
97
+ const router = setup('/assets/archived?q=drill&page=2&sort=name&order=desc');
98
+ await act(() => router.navigate('/assets/archived/42?detailPage=7'));
99
+ expect(screen.getByTestId('tab')).toHaveTextContent('Archived');
100
+ expect(screen.getByTestId('source')).toHaveTextContent(
101
+ '"pathname":"/assets/archived","search":"?q=drill&page=2&sort=name&order=desc"',
102
+ );
103
+ });
104
+
105
+ it('replaces a removed tab with the first accessible tab without stale dataset parameters', async () => {
106
+ const router = setup(
107
+ '/assets/deleted?page=99&sort=deleted&parentFilter=kept',
108
+ );
109
+ expect(router.state.location.pathname).toBe('/assets/active');
110
+ expect(router.state.location.search).toBe('?parentFilter=kept');
111
+ expect(router.state.historyAction).toBe('REPLACE');
112
+ });
113
+
114
+ // Match the declared detail shape even after its source tab is removed.
115
+ it('returns a removed tab detail to the group default without retaining its page', async () => {
116
+ const router = setup('/assets/deleted/42?page=3&sort=name&order=desc');
117
+ await screen.findByText('Content');
118
+ expect(router.state.location.pathname).toBe('/assets/active');
119
+ expect(router.state.location.search).toBe('');
120
+ expect(screen.getByTestId('tab')).toHaveTextContent('Active');
121
+ });
@@ -0,0 +1,190 @@
1
+ import { useState } from 'react';
2
+ import { answerApi, renderWithApp } from '@nocobase/app-testing/client';
3
+ import { resolveListState } from '@nocobase/app-client';
4
+ import { act, screen, waitFor } from '@testing-library/react';
5
+ import userEvent from '@testing-library/user-event';
6
+ import { describe, expect, it } from 'vitest';
7
+ import { useSettingsData } from '../../client/pages/settings/hooks.js';
8
+
9
+ import {
10
+ ListFeedback,
11
+ ListFeedbackActions,
12
+ } from '../../client/components/list-feedback.js';
13
+ import enUS from '../../client/locales/en-US.js';
14
+
15
+ type Rows = { data: string[] };
16
+ const options = {
17
+ namespace: '@nocobase/manual-list-test',
18
+ namespaces: {
19
+ '@nocobase/manual-list-test': {
20
+ 'en-US': async () => ({ default: enUS }),
21
+ },
22
+ },
23
+ };
24
+ function deferred() {
25
+ let resolve!: (value: Rows | Response) => void;
26
+ const promise = new Promise<Rows | Response>((done) => {
27
+ resolve = done;
28
+ });
29
+ return { promise, resolve };
30
+ }
31
+ function failure(status = 500) {
32
+ return new Response(JSON.stringify({ error: { message: 'Unavailable' } }), {
33
+ status,
34
+ headers: { 'content-type': 'application/json' },
35
+ });
36
+ }
37
+ const adapters = {
38
+ useSettings: (query: string) =>
39
+ useSettingsData<Rows>(`manual-list?q=${query}&page=3&orderBy=title`),
40
+ };
41
+ function Harness({ kind }: { readonly kind: keyof typeof adapters }) {
42
+ const [query, setQuery] = useState('first');
43
+ const useData = adapters[kind];
44
+ const request = useData(query);
45
+ const state = resolveListState(request, request.data?.data.length ?? 0);
46
+ const useRetryAction = state === 'failed' || state === 'refresh-failed';
47
+ return (
48
+ <>
49
+ <div>
50
+ {useRetryAction ? (
51
+ <ListFeedbackActions
52
+ state={state}
53
+ onRetry={request.reload}
54
+ busy={request.loading}
55
+ />
56
+ ) : (
57
+ <button onClick={request.reload}>Refresh</button>
58
+ )}
59
+ <button onClick={() => setQuery('second')}>Change query</button>
60
+ <ListFeedbackActions state={state} />
61
+ </div>
62
+ <ListFeedback state={state}>
63
+ {request.data?.data.map((row) => (
64
+ <p key={row}>{row}</p>
65
+ ))}
66
+ </ListFeedback>
67
+ </>
68
+ );
69
+ }
70
+ describe.each(Object.keys(adapters) as (keyof typeof adapters)[])(
71
+ '%s manual list',
72
+ (kind) => {
73
+ it.each(['success', 'failure'] as const)(
74
+ 'retains the stale warning during retry until %s',
75
+ async (outcome) => {
76
+ let response: Rows | Response | Promise<Rows | Response> = {
77
+ data: ['Original row'],
78
+ };
79
+ const calls: string[] = [];
80
+ await renderWithApp(<Harness kind={kind} />, {
81
+ ...options,
82
+ fetch: answerApi(({ path, query }) => {
83
+ calls.push(JSON.stringify({ path, query }));
84
+ return response;
85
+ }),
86
+ });
87
+ await screen.findByText('Original row');
88
+ response = failure();
89
+ await userEvent.click(screen.getByRole('button', { name: 'Refresh' }));
90
+ await screen.findByText(enUS.listFeedback.refreshFailed);
91
+ const pending = deferred();
92
+ response = pending.promise;
93
+ await userEvent.click(
94
+ screen.getByRole('button', { name: 'Retry', exact: true }),
95
+ );
96
+ expect(screen.getByRole('alert')).toHaveTextContent(
97
+ enUS.listFeedback.refreshFailed,
98
+ );
99
+ expect(screen.getByText('Original row')).toBeVisible();
100
+ expect(
101
+ screen.getByRole('button', { name: 'Retrying…' }),
102
+ ).toBeDisabled();
103
+ expect(calls).toHaveLength(3);
104
+ expect(new Set(calls).size).toBe(1);
105
+ await act(async () =>
106
+ pending.resolve(
107
+ outcome === 'success' ? { data: ['Updated row'] } : failure(),
108
+ ),
109
+ );
110
+ if (outcome === 'success') {
111
+ await screen.findByText('Updated row');
112
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
113
+ } else {
114
+ await waitFor(() =>
115
+ expect(
116
+ screen.getByRole('button', { name: 'Retry', exact: true }),
117
+ ).toBeEnabled(),
118
+ );
119
+ expect(screen.getByRole('alert')).toHaveTextContent(
120
+ enUS.listFeedback.refreshFailed,
121
+ );
122
+ expect(screen.getByText('Original row')).toBeVisible();
123
+ }
124
+ },
125
+ );
126
+ it.each([401, 403])(
127
+ 'hides cached rows through a pending retry after %s',
128
+ async (status) => {
129
+ let response: Rows | Response | Promise<Rows | Response> = {
130
+ data: ['Private row'],
131
+ };
132
+ await renderWithApp(<Harness kind={kind} />, {
133
+ ...options,
134
+ fetch: answerApi(() => response),
135
+ });
136
+ await screen.findByText('Private row');
137
+ response = failure(status);
138
+ await userEvent.click(screen.getByRole('button', { name: 'Refresh' }));
139
+ await screen.findByText(
140
+ status === 401
141
+ ? enUS.listFeedback.unauthenticated
142
+ : enUS.listFeedback.forbidden,
143
+ );
144
+ expect(screen.queryByText('Private row')).not.toBeInTheDocument();
145
+ const pending = deferred();
146
+ response = pending.promise;
147
+ await userEvent.click(screen.getByRole('button', { name: 'Refresh' }));
148
+ expect(screen.getByRole('alert')).toHaveTextContent(
149
+ status === 401
150
+ ? enUS.listFeedback.unauthenticated
151
+ : enUS.listFeedback.forbidden,
152
+ );
153
+ expect(screen.queryByText('Private row')).not.toBeInTheDocument();
154
+ await act(async () => pending.resolve({ data: ['Authorized row'] }));
155
+ await screen.findByText('Authorized row');
156
+ },
157
+ );
158
+ it('withholds the previous query and ignores its late retry completion', async () => {
159
+ let response: Rows | Response | Promise<Rows | Response> = {
160
+ data: ['First row'],
161
+ };
162
+ await renderWithApp(<Harness kind={kind} />, {
163
+ ...options,
164
+ fetch: answerApi(() => response),
165
+ });
166
+ await screen.findByText('First row');
167
+ response = failure();
168
+ await userEvent.click(screen.getByRole('button', { name: 'Refresh' }));
169
+ await screen.findByText(enUS.listFeedback.refreshFailed);
170
+ const old = deferred();
171
+ response = old.promise;
172
+ await userEvent.click(
173
+ screen.getByRole('button', { name: 'Retry', exact: true }),
174
+ );
175
+ const current = deferred();
176
+ response = current.promise;
177
+ await userEvent.click(
178
+ screen.getByRole('button', { name: 'Change query' }),
179
+ );
180
+ expect(screen.getByRole('status')).toHaveTextContent('Loading');
181
+ expect(screen.queryByText('First row')).not.toBeInTheDocument();
182
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
183
+ await act(async () => current.resolve({ data: ['Second row'] }));
184
+ await screen.findByText('Second row');
185
+ await act(async () => old.resolve({ data: ['Obsolete row'] }));
186
+ expect(screen.queryByText('Obsolete row')).not.toBeInTheDocument();
187
+ expect(screen.getByText('Second row')).toBeVisible();
188
+ });
189
+ },
190
+ );
@@ -151,6 +151,13 @@ describe('page permission navigation projection', () => {
151
151
  ]);
152
152
  expect(result[0]?.children?.[1]?.children?.[0]?.label).toBe('Warehouses');
153
153
  expect(result[1]?.rowId).toBe('["page","legacy-loans","access"]');
154
+ expect(result[1]?.path).toBe('/loans');
155
+ expect(result[0]?.children?.[0]?.path).toBe('/materials');
156
+ expect(
157
+ flatten(result).find(
158
+ (node) => node.rowId === '["page","legacy-audit","access"]',
159
+ )?.path,
160
+ ).toBe('/loans/audit');
154
161
  expect(result[1]?.children?.[0]?.rowId).toBe(
155
162
  '["page","legacy-reviews","access"]',
156
163
  );
@@ -185,6 +192,7 @@ describe('page permission navigation projection', () => {
185
192
  {
186
193
  id: 'page:legacy-audit',
187
194
  label: 'Audit details',
195
+ path: '/loans/audit',
188
196
  rowId: '["page","legacy-audit","access"]',
189
197
  },
190
198
  ]);
@@ -60,13 +60,7 @@ describe('application-owned permission workspace', () => {
60
60
  onLevelChange={vi.fn()}
61
61
  />,
62
62
  );
63
- const checkboxes = screen
64
- .getAllByRole('checkbox')
65
- .filter(
66
- (item) =>
67
- item.getAttribute('aria-label') !== 'Enabled only' &&
68
- item.id !== screen.getByRole('checkbox', { name: 'Enabled only' }).id,
69
- );
63
+ const checkboxes = screen.getAllByRole('checkbox');
70
64
  expect(checkboxes.map((item) => item.textContent)).toHaveLength(2);
71
65
  expect(checkboxes[0]).toHaveAccessibleName('Approve order');
72
66
  expect(checkboxes[1]).toHaveAccessibleName('Withdraw order');
@@ -74,7 +68,7 @@ describe('application-owned permission workspace', () => {
74
68
  screen.queryByRole('checkbox', { name: 'Configure numbering' }),
75
69
  ).not.toBeInTheDocument();
76
70
  expect(
77
- screen.getByRole('navigation', { name: 'Permission categories' }),
71
+ screen.getByRole('tablist', { name: 'Permission categories' }),
78
72
  ).toBeVisible();
79
73
  expect(
80
74
  screen.getByText('Confirm the order and reserve stock.'),
@@ -99,7 +93,7 @@ describe('application-owned permission workspace', () => {
99
93
  expect(screen.getByRole('status')).toHaveTextContent(
100
94
  'Ask the application developer',
101
95
  );
102
- expect(screen.getAllByRole('checkbox')).toHaveLength(1);
96
+ expect(screen.queryAllByRole('checkbox')).toHaveLength(0);
103
97
  expect(screen.queryByRole('heading', { level: 2 })).not.toBeInTheDocument();
104
98
  });
105
99
 
@@ -149,11 +143,11 @@ describe('application-owned permission workspace', () => {
149
143
  onLevelChange={vi.fn()}
150
144
  />,
151
145
  );
152
- const category = screen.getByRole('button', {
146
+ const category = screen.getByRole('tab', {
153
147
  name: 'Application configuration',
154
148
  });
155
149
  await user.click(category);
156
- expect(category).toHaveAttribute('aria-current', 'page');
150
+ expect(category).toHaveAttribute('aria-selected', 'true');
157
151
  expect(
158
152
  screen.getByRole('checkbox', { name: 'Configure numbering' }),
159
153
  ).toBeVisible();
@@ -168,9 +162,9 @@ describe('application-owned permission workspace', () => {
168
162
  expect(
169
163
  screen.getByRole('checkbox', { name: 'Configure numbering' }),
170
164
  ).toBeVisible();
171
- expect(category).toHaveAttribute('aria-current', 'page');
165
+ expect(category).toHaveAttribute('aria-selected', 'true');
172
166
  await user.type(screen.getByRole('textbox'), 'missing');
173
- await user.click(screen.getByRole('button', { name: 'Business actions' }));
167
+ await user.click(screen.getByRole('tab', { name: 'Business actions' }));
174
168
  expect(screen.getByRole('textbox')).toHaveValue('');
175
169
  expect(
176
170
  screen.getByRole('checkbox', { name: 'Approve order' }),
@@ -233,7 +227,7 @@ describe('application-owned permission workspace', () => {
233
227
  );
234
228
  }
235
229
  render(<Draft />);
236
- await user.click(screen.getByRole('checkbox', { name: 'Enabled only' }));
230
+ await user.click(screen.getByRole('tab', { name: 'Granted' }));
237
231
  await user.click(screen.getByRole('checkbox', { name: 'Approve' }));
238
232
  expect(screen.getByRole('textbox')).toHaveFocus();
239
233
  expect(screen.getByRole('status')).toHaveTextContent(
@@ -321,13 +315,13 @@ describe('application-owned permission workspace', () => {
321
315
  await user.keyboard(' ');
322
316
  expect(change).toHaveBeenCalledTimes(2);
323
317
  expect(change).toHaveBeenLastCalledWith('withdraw', true);
324
- screen.getAllByRole('checkbox', { name: 'Enabled only' })[0]!.focus();
318
+ screen.getAllByRole('tab', { name: 'Granted' })[0]!.focus();
325
319
  await user.keyboard(' ');
326
320
  expect(change).toHaveBeenCalledTimes(2);
327
321
  await user.type(screen.getAllByRole('textbox')[0]!, 'missing');
328
322
  await user.click(screen.getByRole('button', { name: 'Clear filters' }));
329
323
  await user.click(
330
- screen.getAllByRole('button', { name: 'Application configuration' })[0]!,
324
+ screen.getAllByRole('tab', { name: 'Application configuration' })[0]!,
331
325
  );
332
326
  screen.getByRole('checkbox', { name: 'Configure numbering' }).focus();
333
327
  await user.keyboard(' ');
@@ -543,7 +537,12 @@ const treeSection: PermissionEditorSection = {
543
537
  label: 'Catalog',
544
538
  rowId: 'catalog',
545
539
  children: [
546
- { id: 'books-node', label: 'Books', rowId: 'books' },
540
+ {
541
+ id: 'books-node',
542
+ label: 'Books',
543
+ rowId: 'books',
544
+ path: '/library/books',
545
+ },
547
546
  {
548
547
  id: 'review-group',
549
548
  label: 'Approvals',
@@ -684,7 +683,7 @@ describe('application-owned page navigation', () => {
684
683
  );
685
684
  }
686
685
  render(<ControlledTree />);
687
- await user.click(screen.getByRole('checkbox', { name: 'Enabled only' }));
686
+ await user.click(screen.getByRole('tab', { name: 'Granted' }));
688
687
  expect(screen.getByText('Library')).toBeVisible();
689
688
  expect(screen.getByText('Catalog')).toBeVisible();
690
689
  expect(screen.queryByText('Approvals')).not.toBeInTheDocument();
@@ -711,9 +710,7 @@ describe('application-owned page navigation', () => {
711
710
  />,
712
711
  );
713
712
  expect(screen.getAllByRole('list')).toHaveLength(1);
714
- expect(
715
- screen.queryByRole('button', { name: /Collapse|Expand/ }),
716
- ).not.toBeInTheDocument();
713
+ expect(screen.getByRole('button', { name: 'Collapse all' })).toBeDisabled();
717
714
  rerender(
718
715
  <PermissionEditor
719
716
  sections={[treeSection]}
@@ -738,3 +735,176 @@ describe('application-owned page navigation', () => {
738
735
  expect(change).not.toHaveBeenCalled();
739
736
  });
740
737
  });
738
+
739
+ describe('compact permission controls', () => {
740
+ const leaves: PermissionEditorSection = {
741
+ id: 'pages',
742
+ title: 'Pages',
743
+ groups: [
744
+ {
745
+ id: 'borrowing',
746
+ title: 'Borrowing',
747
+ accessOnly: true,
748
+ rows: [{ id: 'borrow', label: 'Access', enabled: true }],
749
+ },
750
+ {
751
+ id: 'catalog',
752
+ title: 'Catalog',
753
+ accessOnly: true,
754
+ rows: [{ id: 'browse', label: 'Access', enabled: false }],
755
+ },
756
+ ],
757
+ };
758
+
759
+ it('keeps an all-leaf section visible and excludes its rows from bulk disclosure', async () => {
760
+ const user = userEvent.setup();
761
+ const change = vi.fn();
762
+ render(
763
+ <PermissionEditor
764
+ sections={[leaves]}
765
+ onEnabledChange={change}
766
+ onLevelChange={vi.fn()}
767
+ />,
768
+ );
769
+ const collapse = screen.getByRole('button', { name: 'Collapse all' });
770
+ expect(collapse).toBeDisabled();
771
+ await user.click(collapse);
772
+ expect(screen.getByRole('checkbox', { name: 'Borrowing' })).toBeVisible();
773
+ expect(screen.getByRole('checkbox', { name: 'Borrowing' })).toBeChecked();
774
+ expect(screen.getByRole('checkbox', { name: 'Catalog' })).not.toBeChecked();
775
+ expect(
776
+ screen.queryByRole('button', { name: 'Expand all' }),
777
+ ).not.toBeInTheDocument();
778
+ expect(change).not.toHaveBeenCalled();
779
+ });
780
+
781
+ it('collapses only titled groups in a mixed section while leaves stay reachable and unchanged', async () => {
782
+ const user = userEvent.setup();
783
+ const change = vi.fn();
784
+ render(
785
+ <PermissionEditor
786
+ sections={[
787
+ { ...leaves, groups: [...leaves.groups, ...sections[0]!.groups] },
788
+ ]}
789
+ onEnabledChange={change}
790
+ onLevelChange={vi.fn()}
791
+ />,
792
+ );
793
+ for (const action of ['Collapse all', 'Expand all']) {
794
+ await user.click(screen.getByRole('button', { name: action }));
795
+ for (const name of ['Borrowing', 'Catalog']) {
796
+ const checkbox = screen.getByRole('checkbox', { name });
797
+ expect(checkbox).toBeVisible();
798
+ checkbox.focus();
799
+ expect(checkbox).toHaveFocus();
800
+ }
801
+ expect(screen.getByRole('checkbox', { name: 'Borrowing' })).toBeChecked();
802
+ expect(
803
+ screen.getByRole('checkbox', { name: 'Catalog' }),
804
+ ).not.toBeChecked();
805
+ if (action === 'Collapse all') {
806
+ expect(
807
+ screen.getByRole('button', { name: 'Expand Orders' }),
808
+ ).toBeVisible();
809
+ expect(
810
+ screen.queryByRole('checkbox', { name: 'Approve order' }),
811
+ ).not.toBeInTheDocument();
812
+ } else {
813
+ expect(
814
+ screen.getByRole('checkbox', { name: 'Approve order' }),
815
+ ).toBeChecked();
816
+ expect(
817
+ screen.getByRole('checkbox', { name: 'Withdraw order' }),
818
+ ).not.toBeChecked();
819
+ }
820
+ }
821
+ expect(change).not.toHaveBeenCalled();
822
+ });
823
+
824
+ it('ignores an existing collapsed key when a group becomes an access-only leaf', async () => {
825
+ const user = userEvent.setup();
826
+ const change = vi.fn();
827
+ const props = { onEnabledChange: change, onLevelChange: vi.fn() };
828
+ const { rerender } = render(
829
+ <PermissionEditor
830
+ {...props}
831
+ sections={[
832
+ {
833
+ ...leaves,
834
+ groups: leaves.groups.map((group) => ({
835
+ ...group,
836
+ accessOnly: false,
837
+ })),
838
+ },
839
+ ]}
840
+ />,
841
+ );
842
+ await user.click(screen.getByRole('button', { name: 'Collapse all' }));
843
+ expect(screen.queryByRole('checkbox')).not.toBeInTheDocument();
844
+ rerender(<PermissionEditor {...props} sections={[leaves]} />);
845
+ expect(screen.getByRole('checkbox', { name: 'Borrowing' })).toBeVisible();
846
+ expect(screen.getByRole('checkbox', { name: 'Borrowing' })).toBeChecked();
847
+ expect(screen.getByRole('checkbox', { name: 'Catalog' })).not.toBeChecked();
848
+ expect(screen.getByRole('button', { name: 'Collapse all' })).toBeDisabled();
849
+ expect(change).not.toHaveBeenCalled();
850
+ });
851
+
852
+ it('filters ungranted rows globally, resets without editing, and searches actual routes', async () => {
853
+ const user = userEvent.setup();
854
+ const change = vi.fn();
855
+ render(
856
+ <PermissionEditor
857
+ sections={[treeSection, ...sections]}
858
+ onEnabledChange={change}
859
+ onLevelChange={vi.fn()}
860
+ />,
861
+ );
862
+ await user.click(screen.getByRole('tab', { name: 'Ungranted' }));
863
+ expect(
864
+ screen.getByRole('checkbox', { name: 'Review loans' }),
865
+ ).not.toBeChecked();
866
+ expect(
867
+ screen.getByRole('checkbox', { name: 'Configure numbering' }),
868
+ ).not.toBeChecked();
869
+ expect(
870
+ screen.queryByRole('checkbox', { name: 'Books' }),
871
+ ).not.toBeInTheDocument();
872
+ expect(screen.getByText('2 of 4 enabled')).toBeVisible();
873
+ await user.click(screen.getByRole('button', { name: 'Clear filters' }));
874
+ await user.type(screen.getByRole('textbox'), '/library/books');
875
+ expect(screen.getByRole('checkbox', { name: 'Books' })).toBeChecked();
876
+ expect(screen.getByText('/library/books')).toBeVisible();
877
+ expect(
878
+ screen.queryByRole('checkbox', { name: 'Review loans' }),
879
+ ).not.toBeInTheDocument();
880
+ expect(change).not.toHaveBeenCalled();
881
+ });
882
+ it('collapses resources without mutating grants and restores disclosure after search', async () => {
883
+ const user = userEvent.setup();
884
+ const change = vi.fn();
885
+ render(
886
+ <PermissionEditor
887
+ sections={sections}
888
+ onEnabledChange={change}
889
+ onLevelChange={vi.fn()}
890
+ />,
891
+ );
892
+ await user.click(screen.getByRole('button', { name: 'Collapse all' }));
893
+ expect(
894
+ screen.queryByRole('checkbox', { name: 'Approve order' }),
895
+ ).not.toBeInTheDocument();
896
+ await user.type(screen.getByRole('textbox'), 'reserve');
897
+ expect(
898
+ screen.getByRole('checkbox', { name: 'Approve order' }),
899
+ ).toBeChecked();
900
+ await user.click(screen.getByRole('button', { name: 'Clear filters' }));
901
+ expect(
902
+ screen.queryByRole('checkbox', { name: 'Approve order' }),
903
+ ).not.toBeInTheDocument();
904
+ await user.click(screen.getByRole('button', { name: 'Expand all' }));
905
+ expect(
906
+ screen.getByRole('checkbox', { name: 'Approve order' }),
907
+ ).toBeChecked();
908
+ expect(change).not.toHaveBeenCalled();
909
+ });
910
+ });
@@ -127,7 +127,7 @@ it('collects native custom filter parameters before changing the draft, then sav
127
127
  );
128
128
  }
129
129
  render(<Role />);
130
- await user.click(screen.getByRole('button', { name: 'Configuration' }));
130
+ await user.click(screen.getByRole('tab', { name: 'Configuration' }));
131
131
  const scope = screen.getByRole('combobox', {
132
132
  name: 'How far View · Records reaches',
133
133
  });
@@ -117,6 +117,19 @@ const configurationRoutes = [
117
117
  ] as unknown as AppClientRegisteredRoute[];
118
118
 
119
119
  describe('application-owned permission presentation', () => {
120
+ it('omits the generic Page access description and retains other section descriptions', () => {
121
+ const sections = permissionSections(source, [], (value) =>
122
+ typeof value === 'string' ? value : value.key,
123
+ );
124
+
125
+ expect(
126
+ sections.find((section) => section.id === 'pages')?.description,
127
+ ).toBe(undefined);
128
+ expect(
129
+ sections.find((section) => section.id === 'business')?.description,
130
+ ).toBe('settings.businessOperationsHint');
131
+ });
132
+
120
133
  it('can hide a created-record selector while retaining a related-record selector and stored policy', () => {
121
134
  const resource = {
122
135
  type: 'composite',
@@ -1,4 +1,5 @@
1
- import { answerApi, renderWithApp } from '@nocobase/app-testing/client';
1
+ import { renderSettingsClient as renderWithApp } from '../helpers/settings-client.js';
2
+ import { answerApi } from '@nocobase/app-testing/client';
2
3
  import { apiClientToken, type ClientApplication } from '@nocobase/app-client';
3
4
  import {
4
5
  AuthorizationClient,