@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.
- package/.agents/rules/build-and-deploy.md +1 -1
- package/.agents/rules/shell.md +4 -0
- package/AGENTS.md +9 -1
- package/README.MD +10 -2
- package/client/components/back-button.tsx +3 -5
- package/client/components/default-access-editor.tsx +13 -2
- package/client/components/feedback-form.tsx +474 -0
- package/client/components/lifecycle-diagram.tsx +10 -10
- package/client/components/permission-editor.tsx +396 -210
- package/client/extensions/nocobase-auth-forms/password-login-form.tsx +17 -6
- package/client/extensions/nocobase-auth-forms/password-registration-form.tsx +24 -7
- package/client/extensions/nocobase-auth-forms/password-reset-form.tsx +19 -7
- package/client/extensions/nocobase-auth-forms/password-reset-request-form.tsx +15 -7
- package/client/extensions/nocobase-device-approval/device-approval.tsx +7 -2
- package/client/extensions/nocobase-device-approval/locales/en-US.ts +16 -0
- package/client/extensions/nocobase-device-approval/locales/zh-CN.ts +7 -0
- package/client/extensions/nocobase-file-component-ui/components/file-preview-dialog.tsx +20 -17
- package/client/hooks/use-list-data.ts +77 -0
- package/client/hooks/use-list-location.ts +23 -0
- package/client/hooks/use-list-tabs.ts +42 -0
- package/client/hooks/use-table-url-state.ts +143 -30
- package/client/locales/system/en-US.ts +10 -0
- package/client/locales/system/settings-en-US.ts +8 -0
- package/client/locales/system/settings-zh-CN.ts +8 -0
- package/client/locales/system/zh-CN.ts +8 -0
- package/client/pages/settings/default-access-form.tsx +44 -26
- package/client/pages/settings/default-access.tsx +56 -27
- package/client/pages/settings/hooks.tsx +4 -59
- package/client/pages/settings/page-permissions.ts +17 -0
- package/client/pages/settings/permissions-model.ts +54 -58
- package/client/pages/settings/role-form.tsx +98 -56
- package/client/pages/settings/roles.tsx +11 -3
- package/client/pages/settings/shared.tsx +43 -33
- package/client/pages/settings/user-form.tsx +51 -30
- package/client/pages/settings/users.tsx +39 -27
- package/client/routing/session-preparation.tsx +10 -3
- package/database/main/migrations/202611020002_create_mutation_receipts.ts +19 -0
- package/package.json +13 -12
- package/server/providers/index.ts +4 -1
- package/server/providers/submission.ts +61 -0
- package/tests/client/auth-i18n.test.tsx +9 -0
- package/tests/client/back-button.test.tsx +3 -3
- package/tests/client/default-access.test.tsx +60 -4
- package/tests/client/form-feedback.test.tsx +589 -0
- package/tests/client/list-recovery.test.tsx +373 -0
- package/tests/client/list-tabs.test.tsx +121 -0
- package/tests/client/page-permission-navigation.test.ts +8 -0
- package/tests/client/permission-editor.test.tsx +191 -21
- package/tests/client/permission-filter-binding.test.tsx +1 -1
- package/tests/client/role-return.test.tsx +2 -1
- package/tests/client/session-preparation.test.tsx +24 -1
- package/tests/client/settings-continuity.test.tsx +5 -4
- package/tests/client/settings-feedback.test.tsx +25 -0
- package/tests/client/settings-permissions.test.ts +35 -0
- package/tests/client/settings.test.tsx +2 -1
- package/tests/client/table-url-state.test.tsx +117 -3
- package/tests/client/user-drawer.test.tsx +2 -1
- package/tests/client/user-permissions.test.tsx +30 -5
- package/tests/database/mutation-receipts.test.ts +45 -0
- package/tests/fixtures/form-feedback/README.md +33 -0
- package/tests/fixtures/form-feedback/equipment-detail.tsx +11 -0
- package/tests/fixtures/form-feedback/equipment-layout.tsx +15 -0
- package/tests/fixtures/form-feedback/equipment-migration.ts +27 -0
- package/tests/fixtures/form-feedback/equipment-server.ts +197 -0
- package/tests/fixtures/form-feedback/equipment-validation.tsx +94 -0
- package/tests/fixtures/form-feedback/equipment.tsx +369 -0
- package/tests/fixtures/form-feedback/verify-inline.mjs +174 -0
- package/tests/fixtures/form-feedback/verify.mjs +373 -0
- package/tests/fixtures/submission/README.md +35 -0
- package/tests/fixtures/submission/browser.test.ts +478 -0
- package/tests/fixtures/submission/migration.ts +61 -0
- package/tests/fixtures/submission/page.tsx +138 -0
- package/tests/fixtures/submission/playwright.config.ts +11 -0
- package/tests/fixtures/submission/routes.ts +208 -0
- package/tests/helpers/settings-client.tsx +31 -0
- package/tests/server/member-role.test.ts +7 -1
- 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('
|
|
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.
|
|
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('
|
|
146
|
+
const category = screen.getByRole('tab', {
|
|
153
147
|
name: 'Application configuration',
|
|
154
148
|
});
|
|
155
149
|
await user.click(category);
|
|
156
|
-
expect(category).toHaveAttribute('aria-
|
|
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-
|
|
165
|
+
expect(category).toHaveAttribute('aria-selected', 'true');
|
|
172
166
|
await user.type(screen.getByRole('textbox'), 'missing');
|
|
173
|
-
await user.click(screen.getByRole('
|
|
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('
|
|
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('
|
|
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('
|
|
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
|
-
{
|
|
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('
|
|
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('
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
)
|
|
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 {
|
|
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 {
|
|
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
|
|
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 {
|
|
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,
|