@nocobase/app-template-default 1.0.0-beta.82 → 1.0.0-beta.84

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 (77) hide show
  1. package/.agents/rules/build-and-deploy.md +1 -1
  2. package/.agents/rules/shell.md +4 -0
  3. package/AGENTS.md +9 -1
  4. package/README.MD +10 -2
  5. package/client/components/back-button.tsx +3 -5
  6. package/client/components/default-access-editor.tsx +13 -2
  7. package/client/components/feedback-form.tsx +474 -0
  8. package/client/components/lifecycle-diagram.tsx +10 -10
  9. package/client/components/permission-editor.tsx +396 -210
  10. package/client/extensions/nocobase-auth-forms/password-login-form.tsx +17 -6
  11. package/client/extensions/nocobase-auth-forms/password-registration-form.tsx +24 -7
  12. package/client/extensions/nocobase-auth-forms/password-reset-form.tsx +19 -7
  13. package/client/extensions/nocobase-auth-forms/password-reset-request-form.tsx +15 -7
  14. package/client/extensions/nocobase-device-approval/device-approval.tsx +7 -2
  15. package/client/extensions/nocobase-device-approval/locales/en-US.ts +16 -0
  16. package/client/extensions/nocobase-device-approval/locales/zh-CN.ts +7 -0
  17. package/client/extensions/nocobase-file-component-ui/components/file-preview-dialog.tsx +20 -17
  18. package/client/hooks/use-list-data.ts +77 -0
  19. package/client/hooks/use-list-location.ts +23 -0
  20. package/client/hooks/use-list-tabs.ts +42 -0
  21. package/client/hooks/use-table-url-state.ts +143 -30
  22. package/client/locales/system/en-US.ts +10 -0
  23. package/client/locales/system/settings-en-US.ts +8 -0
  24. package/client/locales/system/settings-zh-CN.ts +8 -0
  25. package/client/locales/system/zh-CN.ts +8 -0
  26. package/client/pages/settings/default-access-form.tsx +44 -26
  27. package/client/pages/settings/default-access.tsx +56 -27
  28. package/client/pages/settings/hooks.tsx +4 -59
  29. package/client/pages/settings/page-permissions.ts +17 -0
  30. package/client/pages/settings/permissions-model.ts +54 -58
  31. package/client/pages/settings/role-form.tsx +98 -56
  32. package/client/pages/settings/roles.tsx +11 -3
  33. package/client/pages/settings/shared.tsx +43 -33
  34. package/client/pages/settings/user-form.tsx +51 -30
  35. package/client/pages/settings/users.tsx +39 -27
  36. package/client/routing/session-preparation.tsx +10 -3
  37. package/database/main/migrations/202611020002_create_mutation_receipts.ts +19 -0
  38. package/package.json +13 -12
  39. package/server/providers/index.ts +4 -1
  40. package/server/providers/submission.ts +61 -0
  41. package/tests/client/auth-i18n.test.tsx +9 -0
  42. package/tests/client/back-button.test.tsx +3 -3
  43. package/tests/client/default-access.test.tsx +60 -4
  44. package/tests/client/form-feedback.test.tsx +589 -0
  45. package/tests/client/list-recovery.test.tsx +373 -0
  46. package/tests/client/list-tabs.test.tsx +121 -0
  47. package/tests/client/page-permission-navigation.test.ts +8 -0
  48. package/tests/client/permission-editor.test.tsx +191 -21
  49. package/tests/client/permission-filter-binding.test.tsx +1 -1
  50. package/tests/client/role-return.test.tsx +2 -1
  51. package/tests/client/session-preparation.test.tsx +24 -1
  52. package/tests/client/settings-continuity.test.tsx +5 -4
  53. package/tests/client/settings-feedback.test.tsx +25 -0
  54. package/tests/client/settings-permissions.test.ts +35 -0
  55. package/tests/client/settings.test.tsx +2 -1
  56. package/tests/client/table-url-state.test.tsx +117 -3
  57. package/tests/client/user-drawer.test.tsx +2 -1
  58. package/tests/client/user-permissions.test.tsx +30 -5
  59. package/tests/database/mutation-receipts.test.ts +45 -0
  60. package/tests/fixtures/form-feedback/README.md +33 -0
  61. package/tests/fixtures/form-feedback/equipment-detail.tsx +11 -0
  62. package/tests/fixtures/form-feedback/equipment-layout.tsx +15 -0
  63. package/tests/fixtures/form-feedback/equipment-migration.ts +27 -0
  64. package/tests/fixtures/form-feedback/equipment-server.ts +197 -0
  65. package/tests/fixtures/form-feedback/equipment-validation.tsx +94 -0
  66. package/tests/fixtures/form-feedback/equipment.tsx +369 -0
  67. package/tests/fixtures/form-feedback/verify-inline.mjs +174 -0
  68. package/tests/fixtures/form-feedback/verify.mjs +373 -0
  69. package/tests/fixtures/submission/README.md +35 -0
  70. package/tests/fixtures/submission/browser.test.ts +478 -0
  71. package/tests/fixtures/submission/migration.ts +61 -0
  72. package/tests/fixtures/submission/page.tsx +138 -0
  73. package/tests/fixtures/submission/playwright.config.ts +11 -0
  74. package/tests/fixtures/submission/routes.ts +208 -0
  75. package/tests/helpers/settings-client.tsx +31 -0
  76. package/tests/server/member-role.test.ts +7 -1
  77. package/tests/server/submission-settings.test.ts +241 -0
@@ -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
  });
@@ -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,
@@ -19,7 +19,7 @@ import {
19
19
  createTestI18nRuntime,
20
20
  } from '@nocobase/i18n/testing';
21
21
  import { act, fireEvent, render, screen } from '@testing-library/react';
22
- import { useEffect } from 'react';
22
+ import { useEffect, useState } from 'react';
23
23
  import { MemoryRouter, Outlet } from 'react-router';
24
24
  import { afterEach, expect, it, vi } from 'vitest';
25
25
  import { AppRouter } from '../../client/routing/app-router.js';
@@ -80,6 +80,7 @@ async function setup() {
80
80
  const accepted = vi.fn();
81
81
  const old = Promise.withResolvers<void>();
82
82
  function Page() {
83
+ const [privateFilter, setPrivateFilter] = useState('');
83
84
  useEffect(() => {
84
85
  let active = true;
85
86
  mounted();
@@ -96,6 +97,11 @@ async function setup() {
96
97
  <DialogContent>
97
98
  <DialogTitle>Protected dialog</DialogTitle>
98
99
  <DialogDescription>Protected content</DialogDescription>
100
+ <input
101
+ aria-label='Private filter'
102
+ value={privateFilter}
103
+ onChange={(event) => setPrivateFilter(event.target.value)}
104
+ />
99
105
  </DialogContent>
100
106
  </Dialog>
101
107
  );
@@ -170,6 +176,10 @@ async function setup() {
170
176
  session = null;
171
177
  await act(async () => refresh());
172
178
  },
179
+ switchUser: async () => {
180
+ session = { user: { id: 'other' }, session: { id: 'other-session' } };
181
+ await act(async () => refresh());
182
+ },
173
183
  };
174
184
  }
175
185
 
@@ -195,6 +205,19 @@ it('mounts only after the initial handshake and preserves runtime fail-closed po
195
205
  expect(await screen.findByText('Access denied')).toBeVisible();
196
206
  });
197
207
 
208
+ it('clears mounted private list memory when the signed-in identity changes', async () => {
209
+ const probe = await setup();
210
+ await probe.open();
211
+ const filter = await screen.findByRole('textbox', { name: 'Private filter' });
212
+ fireEvent.change(filter, { target: { value: 'private condition' } });
213
+ expect(filter).toHaveValue('private condition');
214
+ await probe.switchUser();
215
+ await probe.open();
216
+ expect(
217
+ await screen.findByRole('textbox', { name: 'Private filter' }),
218
+ ).toHaveValue('');
219
+ });
220
+
198
221
  it('keeps a timed-out session closed, supports retry, and reports sign-out failure', async () => {
199
222
  vi.useFakeTimers();
200
223
  const probe = await setup();
@@ -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,
@@ -282,9 +283,9 @@ it.each(['/settings/users', '/users'])(
282
283
  `${base}${search}`,
283
284
  ),
284
285
  );
285
- expect(
286
- await screen.findByText('Alice updated', { exact: true }),
287
- ).toBeVisible();
286
+ await waitFor(() =>
287
+ expect(screen.getByText('Alice updated', { exact: true })).toBeVisible(),
288
+ );
288
289
  },
289
290
  );
290
291
 
@@ -10,6 +10,7 @@ import { describe, expect, it, vi } from 'vitest';
10
10
  import { Routes, Route } from 'react-router';
11
11
  import UsersPage from '../../client/pages/settings/users.js';
12
12
  import RolesPage from '../../client/pages/settings/roles.js';
13
+ import { ListRecoveryError } from '../../client/hooks/use-table-url-state.js';
13
14
  import { SettingsError } from '../../client/pages/settings/shared.js';
14
15
  import enUS from '../../client/locales/en-US.js';
15
16
  import zhCN from '../../client/locales/zh-CN.js';
@@ -151,6 +152,30 @@ describe.each([
151
152
  expect(retry).not.toHaveBeenCalled();
152
153
  });
153
154
 
155
+ it.each([false, true])(
156
+ 'preserves bounded recovery feedback with inline=%s',
157
+ async (inline) => {
158
+ const retry = vi.fn();
159
+ const message = 'The list changed repeatedly. Refresh to try again.';
160
+ await renderWithApp(
161
+ <SettingsError
162
+ error={new ListRecoveryError(message)}
163
+ retry={retry}
164
+ inline={inline}
165
+ />,
166
+ { locale: language, namespace, namespaces },
167
+ );
168
+ expect(screen.getByText(message)).toBeVisible();
169
+ expect(screen.queryByText(copy.settings.failed)).not.toBeInTheDocument();
170
+ expect(screen.queryAllByRole('alert')).toHaveLength(inline ? 0 : 1);
171
+ expect(retry).not.toHaveBeenCalled();
172
+ await userEvent.click(
173
+ screen.getByRole('button', { name: copy.settings.retry }),
174
+ );
175
+ expect(retry).toHaveBeenCalledTimes(1);
176
+ },
177
+ );
178
+
154
179
  it('keeps a retry for temporary failures and performs it only on demand', async () => {
155
180
  const retry = vi.fn();
156
181
  await renderWithApp(
@@ -332,3 +332,38 @@ it('shows a shared plugin action explanation once without duplicating row hints'
332
332
  );
333
333
  expect(result[0]?.groups[0]?.rows.every((row) => !row.hint)).toBe(true);
334
334
  });
335
+
336
+ it('keeps scope metadata while ungranted without creating a grant or selecting its default', () => {
337
+ const catalog = {
338
+ sections: [
339
+ {
340
+ name: 'business',
341
+ title: 'Business',
342
+ subsections: [{ name: 'orders', resources: [resource] }],
343
+ },
344
+ ],
345
+ recordAccess: [
346
+ { key: 'mine', title: 'Own' },
347
+ { key: 'all', title: 'All' },
348
+ ],
349
+ } as unknown as Parameters<typeof permissionSections>[0];
350
+ const grants: Parameters<typeof permissionSections>[1] = [];
351
+ const row = permissionSections(catalog, grants, (value) =>
352
+ typeof value === 'string' ? value : value.key,
353
+ )[0]!.groups[0]!.rows[0]!;
354
+ expect(row.enabled).toBe(false);
355
+ expect(row.scopes?.[0]).toMatchObject({
356
+ id: JSON.stringify(['composite', 'orders', 'edit', 'records']),
357
+ level: '',
358
+ });
359
+ expect(grants).toEqual([]);
360
+ const enabled = permissionSections(
361
+ catalog,
362
+ setPermission(grants, resource, 'edit', true),
363
+ (value) => (typeof value === 'string' ? value : value.key),
364
+ )[0]!.groups[0]!.rows[0]!;
365
+ expect(enabled.scopes?.map((scope) => scope.id)).toEqual(
366
+ row.scopes?.map((scope) => scope.id),
367
+ );
368
+ expect(enabled.scopes?.[0]?.level).toBe('mine');
369
+ });
@@ -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,
@@ -4,11 +4,24 @@ import userEvent from '@testing-library/user-event';
4
4
  import { useState } from 'react';
5
5
  import { useLocation, useNavigate } from 'react-router';
6
6
  import { expect, it } from 'vitest';
7
- import { useTableUrlState } from '../../client/hooks/use-table-url-state.js';
7
+ import {
8
+ readTableUrlState,
9
+ useTableUrlState,
10
+ } from '../../client/hooks/use-table-url-state.js';
8
11
 
9
- function Probe() {
12
+ function Probe({
13
+ sortFormat = 'sort-order',
14
+ }: {
15
+ sortFormat?: 'sort-order' | 'orderBy';
16
+ }) {
10
17
  const [count, setCount] = useState<number>();
11
- const state = useTableUrlState({ sortColumns: ['name'], rowCount: count });
18
+ const [active, setActive] = useState(true);
19
+ const state = useTableUrlState({
20
+ sortColumns: ['name', 'updatedAt'],
21
+ rowCount: count,
22
+ active,
23
+ sortFormat,
24
+ });
12
25
  const location = useLocation();
13
26
  const navigate = useNavigate();
14
27
  return (
@@ -20,7 +33,27 @@ function Probe() {
20
33
  </output>
21
34
  <output data-testid='url'>{location.search}</output>
22
35
  <button onClick={() => setCount(25)}>Loaded</button>
36
+ <button onClick={() => setActive((value) => !value)}>
37
+ Toggle active
38
+ </button>
23
39
  <button onClick={() => setCount(10)}>Removed</button>
40
+ <button
41
+ onClick={() =>
42
+ state.onSortingChange([
43
+ { id: 'name', desc: true },
44
+ { id: 'updatedAt', desc: false },
45
+ ])
46
+ }
47
+ >
48
+ Multi sort
49
+ </button>
50
+ <button
51
+ onClick={() =>
52
+ state.onPaginationChange((old) => ({ ...old, pageSize: 20 }))
53
+ }
54
+ >
55
+ Resize
56
+ </button>
24
57
  <button
25
58
  onClick={() => state.onSortingChange([{ id: 'name', desc: true }])}
26
59
  >
@@ -93,3 +126,84 @@ it('allows browser Back to restore the preceding page', async () => {
93
126
  expect(screen.getByTestId('state')).toHaveTextContent('"pageIndex":2');
94
127
  expect(screen.getByTestId('url')).toHaveTextContent('q=kept');
95
128
  });
129
+
130
+ it('starts at page one after resizing even when the table supplies an old page index', async () => {
131
+ await renderWithApp(<Probe />, {
132
+ route: '/?page=4&pageSize=10&sort=name&order=desc&q=kept',
133
+ });
134
+ await userEvent.click(screen.getByRole('button', { name: 'Resize' }));
135
+ expect(screen.getByTestId('state')).toHaveTextContent(
136
+ '"pageIndex":0,"pageSize":20',
137
+ );
138
+ expect(screen.getByTestId('url')).toHaveTextContent('order=desc');
139
+ expect(screen.getByTestId('url')).toHaveTextContent('q=kept');
140
+ });
141
+
142
+ it('preserves ordered multi-column orderBy and falls back on invalid columns and directions', () => {
143
+ const options = {
144
+ sortColumns: ['name', 'updatedAt'],
145
+ sortFormat: 'orderBy' as const,
146
+ defaultSorting: [{ id: 'updatedAt', desc: true }],
147
+ };
148
+ expect(
149
+ readTableUrlState(
150
+ new URLSearchParams('orderBy=name,updatedAt desc&page=2'),
151
+ options,
152
+ ),
153
+ ).toEqual({
154
+ paginationState: { pageIndex: 1, pageSize: 10 },
155
+ sortingState: [
156
+ { id: 'name', desc: false },
157
+ { id: 'updatedAt', desc: true },
158
+ ],
159
+ });
160
+ for (const value of [
161
+ 'name sideways',
162
+ 'deleted desc',
163
+ 'name,deleted',
164
+ 'name desc extra',
165
+ ]) {
166
+ expect(
167
+ readTableUrlState(new URLSearchParams({ orderBy: value }), options)
168
+ .sortingState,
169
+ ).toEqual(options.defaultSorting);
170
+ }
171
+ });
172
+
173
+ it('writes and restores the complete existing orderBy sequence', async () => {
174
+ await renderWithApp(<Probe sortFormat='orderBy' />, {
175
+ route: '/?page=3&pageSize=20&q=kept',
176
+ });
177
+ await userEvent.click(screen.getByRole('button', { name: 'Multi sort' }));
178
+ expect(screen.getByTestId('state')).toHaveTextContent(
179
+ '[{"id":"name","desc":true},{"id":"updatedAt","desc":false}]',
180
+ );
181
+ expect(screen.getByTestId('state')).toHaveTextContent('"pageIndex":0');
182
+ await userEvent.click(screen.getByRole('button', { name: 'Next' }));
183
+ expect(screen.getByTestId('url')).toHaveTextContent(
184
+ 'orderBy=name+desc%2CupdatedAt',
185
+ );
186
+ expect(screen.getByTestId('url')).toHaveTextContent('q=kept');
187
+ });
188
+
189
+ it('keeps ordered multi-sort while deferred deletion recovery corrects the reactivated list', async () => {
190
+ await renderWithApp(<Probe sortFormat='orderBy' />, {
191
+ route: '/?page=5&pageSize=10&orderBy=name%20desc,updatedAt&q=kept&tab=open',
192
+ });
193
+ await userEvent.click(screen.getByRole('button', { name: 'Toggle active' }));
194
+ await userEvent.click(screen.getByRole('button', { name: 'Loaded' }));
195
+ expect(screen.getByTestId('state')).toHaveTextContent('"pageIndex":4');
196
+ await userEvent.click(screen.getByRole('button', { name: 'Toggle active' }));
197
+ await waitFor(() =>
198
+ expect(screen.getByTestId('state')).toHaveTextContent('"pageIndex":2'),
199
+ );
200
+ expect(screen.getByTestId('state')).toHaveTextContent(
201
+ '[{"id":"name","desc":true},{"id":"updatedAt","desc":false}]',
202
+ );
203
+ const params = new URLSearchParams(
204
+ screen.getByTestId('url').textContent ?? '',
205
+ );
206
+ expect(params.get('orderBy')).toBe('name desc,updatedAt');
207
+ expect(params.get('q')).toBe('kept');
208
+ expect(params.get('tab')).toBe('open');
209
+ });
@@ -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,