@nocobase/app-template-default 1.0.0-beta.67 → 1.0.0-beta.69
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/components.md +2 -2
- package/.agents/rules/shell.md +5 -1
- package/AGENTS.md +5 -3
- package/Dockerfile.dockerignore +0 -1
- package/README.MD +3 -1
- package/client/app.ts +15 -0
- package/client/components/data-table/column-header.tsx +91 -0
- package/client/components/data-table/data-table.tsx +216 -0
- package/client/components/data-table/index.ts +13 -0
- package/client/components/data-table/pagination.tsx +165 -0
- package/client/components/data-table/view-options.tsx +77 -0
- package/client/components/page-header.tsx +1 -1
- package/client/components/permission-editor.tsx +589 -0
- package/client/components/permission-filter.tsx +455 -0
- package/client/components/route-overlay.tsx +26 -13
- package/client/components/status-badge.tsx +7 -1
- package/client/components/ui/checkbox.tsx +25 -0
- package/client/components/ui/sidebar.tsx +1 -4
- package/client/components/ui/switch.tsx +30 -0
- package/client/components/ui/table.tsx +113 -0
- package/client/extensions/nocobase-inbox/inbox-item.tsx +44 -58
- package/client/extensions/nocobase-inbox/inbox-list.tsx +73 -75
- package/client/index.tsx +4 -0
- package/client/layouts/components/app-sidebar.tsx +6 -6
- package/client/layouts/components/layout-header.tsx +1 -1
- package/client/layouts/components/navigation-menu.tsx +223 -12
- package/client/layouts/components/sidebar-footer.tsx +1 -1
- package/client/layouts/components/user-menu.tsx +1 -1
- package/client/layouts/use-sidebar-preference.ts +62 -42
- package/client/lib/navigation-transform.ts +8 -0
- package/client/lib/scrollbar.ts +88 -0
- package/client/locales/system/en-US.ts +11 -0
- package/client/locales/system/settings-en-US.ts +141 -0
- package/client/locales/system/settings-zh-CN.ts +135 -0
- package/client/locales/system/zh-CN.ts +22 -12
- package/client/pages/auth/auth-page.css +52 -0
- package/client/pages/auth/login.tsx +1 -1
- package/client/pages/auth/reset-password.tsx +57 -14
- package/client/pages/auth/shared.tsx +26 -24
- package/client/pages/home/index.tsx +2 -2
- package/client/pages/settings/hooks.tsx +137 -0
- package/client/pages/settings/permission-filters.tsx +119 -0
- package/client/pages/settings/permissions-model.ts +260 -0
- package/client/pages/settings/role-form.tsx +426 -0
- package/client/pages/settings/roles.tsx +163 -0
- package/client/pages/settings/routes.ts +79 -0
- package/client/pages/settings/shared.tsx +119 -0
- package/client/pages/settings/user-detail.tsx +268 -0
- package/client/pages/settings/user-form.tsx +427 -0
- package/client/pages/settings/users.tsx +451 -0
- package/client/routing/app-router.tsx +4 -1
- package/client/routing/route-navigation.ts +10 -1
- package/client/routing/session-preparation.tsx +65 -0
- package/client/styles.css +257 -24
- package/client/theme/theme-settings.tsx +12 -7
- package/client/theme/themes/compact.css +9 -14
- package/client/theme/themes/default.css +7 -12
- package/package.json +26 -25
- package/server/app.ts +2 -0
- package/server/config/notification.ts +4 -7
- package/tests/components/app-layout-routes.test.tsx +14 -7
- package/tests/components/auth-i18n.test.tsx +57 -5
- package/tests/components/data-table.test.tsx +127 -0
- package/tests/components/layout-i18n.test.tsx +10 -3
- package/tests/components/navigation-menu.test.tsx +155 -16
- package/tests/components/permission-editor.test.tsx +477 -0
- package/tests/components/permission-filter-binding.test.tsx +150 -0
- package/tests/components/permission-filter.test.tsx +202 -0
- package/tests/components/settings.test.tsx +294 -0
- package/tests/components/sidebar-permissions.test.tsx +17 -19
- package/tests/components/sidebar-preference.test.tsx +209 -14
- package/tests/components/status-badge.test.tsx +22 -0
- package/tests/components/user-permissions.test.tsx +142 -0
- package/tests/fixtures/theme-tokens.tsx +9 -1
- package/tests/logic/asset-url-build.test.ts +0 -1
- package/tests/logic/client-routes.test.ts +40 -0
- package/tests/logic/client-shell.test.tsx +10 -2
- package/tests/logic/config-example.test.ts +30 -0
- package/tests/logic/dev-client-watch.test.ts +8 -1
- package/tests/logic/route-overlay.test.tsx +42 -0
- package/tests/logic/scrollbar.test.ts +88 -0
- package/tests/logic/session-preparation.test.tsx +223 -0
- package/tests/logic/settings-permissions.test.ts +168 -0
- package/tests/logic/theme-tokens.test.ts +1 -0
- package/vite.config.ts +0 -22
- package/vitest.config.ts +1 -0
- package/client/agent-annotations-host.ts +0 -25
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { render, screen, waitFor } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { expect, it, vi } from 'vitest';
|
|
5
|
+
import {
|
|
6
|
+
PermissionFilter,
|
|
7
|
+
type PermissionFilterLabels,
|
|
8
|
+
} from '#components/permission-filter';
|
|
9
|
+
const labels: PermissionFilterLabels = {
|
|
10
|
+
limit: 'Up to 20 conditions',
|
|
11
|
+
configure: 'Conditions',
|
|
12
|
+
title: 'Configure filter',
|
|
13
|
+
description: 'Apply then save role',
|
|
14
|
+
all: 'All conditions',
|
|
15
|
+
any: 'Any condition',
|
|
16
|
+
field: 'Field',
|
|
17
|
+
operator: 'Operator',
|
|
18
|
+
valueType: 'Value type',
|
|
19
|
+
value: 'Value',
|
|
20
|
+
text: 'Text',
|
|
21
|
+
number: 'Number',
|
|
22
|
+
boolean: 'Boolean',
|
|
23
|
+
true: 'Yes',
|
|
24
|
+
false: 'No',
|
|
25
|
+
add: 'Add condition',
|
|
26
|
+
remove: 'Remove',
|
|
27
|
+
cancel: 'Cancel',
|
|
28
|
+
apply: 'Apply',
|
|
29
|
+
invalid: 'Complete all conditions',
|
|
30
|
+
advanced: 'Advanced conditions are preserved',
|
|
31
|
+
reconfigure: 'Reconfigure',
|
|
32
|
+
operators: {
|
|
33
|
+
$eq: 'Equals',
|
|
34
|
+
$ne: 'Not equal',
|
|
35
|
+
$includes: 'Contains',
|
|
36
|
+
$gt: 'Greater than',
|
|
37
|
+
$gte: 'At least',
|
|
38
|
+
$lt: 'Less than',
|
|
39
|
+
$lte: 'At most',
|
|
40
|
+
$empty: 'Empty',
|
|
41
|
+
$notEmpty: 'Not empty',
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
const fields = [
|
|
45
|
+
{ value: 'department', label: 'Department' },
|
|
46
|
+
{ value: 'amountCents', label: 'Amount (cents)' },
|
|
47
|
+
];
|
|
48
|
+
it('validates a filter, prevents Enter submitting the role and emits a native typed filter', async () => {
|
|
49
|
+
const user = userEvent.setup(),
|
|
50
|
+
apply = vi.fn(),
|
|
51
|
+
submit = vi.fn((event) => event.preventDefault());
|
|
52
|
+
function Draft() {
|
|
53
|
+
const [open, setOpen] = useState(true);
|
|
54
|
+
return (
|
|
55
|
+
<form onSubmit={submit}>
|
|
56
|
+
<PermissionFilter
|
|
57
|
+
open={open}
|
|
58
|
+
onOpenChange={setOpen}
|
|
59
|
+
value={undefined}
|
|
60
|
+
fields={fields}
|
|
61
|
+
labels={labels}
|
|
62
|
+
onApply={apply}
|
|
63
|
+
/>
|
|
64
|
+
</form>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
render(<Draft />);
|
|
68
|
+
expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled();
|
|
69
|
+
await user.type(screen.getByRole('textbox', { name: 'Value 1' }), 'Finance');
|
|
70
|
+
await user.keyboard('{Enter}');
|
|
71
|
+
expect(submit).not.toHaveBeenCalled();
|
|
72
|
+
await user.click(screen.getByRole('button', { name: 'Add condition' }));
|
|
73
|
+
await user.click(screen.getByRole('combobox', { name: 'Field 2' }));
|
|
74
|
+
await user.click(
|
|
75
|
+
await screen.findByRole('option', { name: 'Amount (cents)' }),
|
|
76
|
+
);
|
|
77
|
+
await user.click(screen.getByRole('combobox', { name: 'Operator 2' }));
|
|
78
|
+
await user.click(await screen.findByRole('option', { name: 'At least' }));
|
|
79
|
+
await user.type(
|
|
80
|
+
screen.getByRole('textbox', { name: 'Value 2' }),
|
|
81
|
+
'not a number',
|
|
82
|
+
);
|
|
83
|
+
expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled();
|
|
84
|
+
await user.clear(screen.getByRole('textbox', { name: 'Value 2' }));
|
|
85
|
+
await user.type(screen.getByRole('textbox', { name: 'Value 2' }), '500000');
|
|
86
|
+
await user.click(screen.getByRole('button', { name: 'Apply' }));
|
|
87
|
+
expect(apply).toHaveBeenCalledExactlyOnceWith({
|
|
88
|
+
kind: 'group',
|
|
89
|
+
logic: 'and',
|
|
90
|
+
items: [
|
|
91
|
+
{
|
|
92
|
+
kind: 'condition',
|
|
93
|
+
path: ['department'],
|
|
94
|
+
operator: '$eq',
|
|
95
|
+
value: 'Finance',
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
kind: 'condition',
|
|
99
|
+
path: ['amountCents'],
|
|
100
|
+
operator: '$gte',
|
|
101
|
+
value: 500000,
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
});
|
|
105
|
+
expect(submit).not.toHaveBeenCalled();
|
|
106
|
+
});
|
|
107
|
+
it('keeps advanced existing filters until explicit reconfiguration and cancellation never applies', async () => {
|
|
108
|
+
const user = userEvent.setup(),
|
|
109
|
+
apply = vi.fn(),
|
|
110
|
+
close = vi.fn();
|
|
111
|
+
render(
|
|
112
|
+
<PermissionFilter
|
|
113
|
+
open
|
|
114
|
+
onOpenChange={close}
|
|
115
|
+
value={{
|
|
116
|
+
kind: 'group',
|
|
117
|
+
logic: 'or',
|
|
118
|
+
items: [{ kind: 'group', logic: 'and', items: [] }],
|
|
119
|
+
}}
|
|
120
|
+
fields={fields}
|
|
121
|
+
labels={labels}
|
|
122
|
+
onApply={apply}
|
|
123
|
+
/>,
|
|
124
|
+
);
|
|
125
|
+
await waitFor(() => expect(screen.getByText(labels.advanced)).toBeVisible());
|
|
126
|
+
expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled();
|
|
127
|
+
await user.click(screen.getByRole('button', { name: 'Cancel' }));
|
|
128
|
+
expect(close).toHaveBeenCalledWith(false);
|
|
129
|
+
expect(apply).not.toHaveBeenCalled();
|
|
130
|
+
});
|
|
131
|
+
it('restores existing values, requires nonempty conditions and preserves typed boolean values', async () => {
|
|
132
|
+
const user = userEvent.setup(),
|
|
133
|
+
apply = vi.fn();
|
|
134
|
+
render(
|
|
135
|
+
<PermissionFilter
|
|
136
|
+
open
|
|
137
|
+
onOpenChange={vi.fn()}
|
|
138
|
+
value={{
|
|
139
|
+
kind: 'condition',
|
|
140
|
+
path: ['department'],
|
|
141
|
+
operator: '$eq',
|
|
142
|
+
value: 'Finance',
|
|
143
|
+
}}
|
|
144
|
+
fields={fields}
|
|
145
|
+
labels={labels}
|
|
146
|
+
onApply={apply}
|
|
147
|
+
/>,
|
|
148
|
+
);
|
|
149
|
+
expect(screen.getByRole('textbox', { name: 'Value 1' })).toHaveValue(
|
|
150
|
+
'Finance',
|
|
151
|
+
);
|
|
152
|
+
await user.click(screen.getByRole('button', { name: 'Remove' }));
|
|
153
|
+
expect(screen.getByRole('button', { name: 'Add condition' })).toHaveFocus();
|
|
154
|
+
expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled();
|
|
155
|
+
await user.click(screen.getByRole('button', { name: 'Add condition' }));
|
|
156
|
+
await user.click(screen.getByRole('combobox', { name: 'Value type 1' }));
|
|
157
|
+
await user.click(await screen.findByRole('option', { name: 'Boolean' }));
|
|
158
|
+
await user.click(screen.getByRole('combobox', { name: 'Value 1' }));
|
|
159
|
+
await user.click(await screen.findByRole('option', { name: 'No' }));
|
|
160
|
+
await user.click(screen.getByRole('button', { name: 'Apply' }));
|
|
161
|
+
expect(apply.mock.calls[0]?.[0].items[0].value).toBe(false);
|
|
162
|
+
});
|
|
163
|
+
it('keeps readonly configuration disabled', () => {
|
|
164
|
+
render(
|
|
165
|
+
<PermissionFilter
|
|
166
|
+
open={false}
|
|
167
|
+
onOpenChange={vi.fn()}
|
|
168
|
+
value={undefined}
|
|
169
|
+
fields={fields}
|
|
170
|
+
labels={labels}
|
|
171
|
+
onApply={vi.fn()}
|
|
172
|
+
disabled
|
|
173
|
+
/>,
|
|
174
|
+
);
|
|
175
|
+
expect(screen.getByRole('button', { name: 'Conditions' })).toBeDisabled();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('keeps the condition limit explained and restores focus when removing a row at the limit', async () => {
|
|
179
|
+
render(
|
|
180
|
+
<PermissionFilter
|
|
181
|
+
open
|
|
182
|
+
onOpenChange={vi.fn()}
|
|
183
|
+
value={{
|
|
184
|
+
kind: 'group',
|
|
185
|
+
logic: 'and',
|
|
186
|
+
items: Array.from({ length: 20 }, () => ({
|
|
187
|
+
kind: 'condition',
|
|
188
|
+
path: ['department'],
|
|
189
|
+
operator: '$eq',
|
|
190
|
+
value: 'Finance',
|
|
191
|
+
})),
|
|
192
|
+
}}
|
|
193
|
+
fields={fields}
|
|
194
|
+
labels={labels}
|
|
195
|
+
onApply={vi.fn()}
|
|
196
|
+
/>,
|
|
197
|
+
);
|
|
198
|
+
expect(screen.getByRole('button', { name: 'Add condition' })).toBeDisabled();
|
|
199
|
+
await waitFor(() => expect(screen.getByText(labels.limit)).toBeVisible());
|
|
200
|
+
await userEvent.click(screen.getAllByRole('button', { name: 'Remove' })[0]!);
|
|
201
|
+
expect(screen.getByRole('button', { name: 'Add condition' })).toHaveFocus();
|
|
202
|
+
});
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { answerApi, renderWithApp } from '@nocobase/app-testing/client';
|
|
2
|
+
import { apiClientToken, type ClientApplication } from '@nocobase/app-client';
|
|
3
|
+
import {
|
|
4
|
+
AuthorizationClient,
|
|
5
|
+
authorizationClientToken,
|
|
6
|
+
} from '@nocobase/app-plugin-authorization/client';
|
|
7
|
+
import { act, fireEvent, screen, waitFor } from '@testing-library/react';
|
|
8
|
+
import userEvent from '@testing-library/user-event';
|
|
9
|
+
import { Routes, Route, useNavigate } from 'react-router';
|
|
10
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
11
|
+
import UserForm from '../../client/pages/settings/user-form.js';
|
|
12
|
+
import UsersPage from '../../client/pages/settings/users.js';
|
|
13
|
+
import RoleForm from '../../client/pages/settings/role-form.js';
|
|
14
|
+
import RolesPage from '../../client/pages/settings/roles.js';
|
|
15
|
+
import enUS from '../../client/locales/en-US.js';
|
|
16
|
+
import zhCN from '../../client/locales/zh-CN.js';
|
|
17
|
+
const settingsNamespace = '@nocobase/settings-test';
|
|
18
|
+
const userRow = {
|
|
19
|
+
id: 'alice',
|
|
20
|
+
name: 'Alice',
|
|
21
|
+
email: 'alice@example.test',
|
|
22
|
+
disabledAt: null,
|
|
23
|
+
roleScopes: { app: ['sales'] },
|
|
24
|
+
};
|
|
25
|
+
const services = (app: ClientApplication) =>
|
|
26
|
+
app.container.instance(
|
|
27
|
+
authorizationClientToken,
|
|
28
|
+
new AuthorizationClient(app.container.resolve(apiClientToken)),
|
|
29
|
+
);
|
|
30
|
+
const namespaces = {
|
|
31
|
+
[settingsNamespace]: {
|
|
32
|
+
'en-US': async () => ({ default: enUS }),
|
|
33
|
+
'zh-CN': async () => ({ default: zhCN }),
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
describe('template system settings', () => {
|
|
38
|
+
it('loads real client data, searches without losing typed input and hides actions without permission', async () => {
|
|
39
|
+
const user = userEvent.setup();
|
|
40
|
+
const calls = vi.fn(({ path }: { path: string }) =>
|
|
41
|
+
path === 'authorization/permissions'
|
|
42
|
+
? { data: { unrestricted: false, permissions: [] } }
|
|
43
|
+
: { data: [userRow], meta: { total: 1 } },
|
|
44
|
+
);
|
|
45
|
+
await renderWithApp(<UsersPage />, {
|
|
46
|
+
namespace: settingsNamespace,
|
|
47
|
+
namespaces,
|
|
48
|
+
services,
|
|
49
|
+
fetch: answerApi(calls),
|
|
50
|
+
});
|
|
51
|
+
expect(await screen.findByRole('link', { name: 'Alice' })).toBeVisible();
|
|
52
|
+
expect(
|
|
53
|
+
screen.queryByRole('link', { name: 'New user' }),
|
|
54
|
+
).not.toBeInTheDocument();
|
|
55
|
+
const search = screen.getByRole('textbox', {
|
|
56
|
+
name: enUS.settings.userSearch,
|
|
57
|
+
});
|
|
58
|
+
await user.type(search, 'Ali');
|
|
59
|
+
await waitFor(() =>
|
|
60
|
+
expect(calls).toHaveBeenCalledWith(
|
|
61
|
+
expect.objectContaining({
|
|
62
|
+
path: 'users',
|
|
63
|
+
query: expect.objectContaining({ q: 'Ali' }),
|
|
64
|
+
}),
|
|
65
|
+
),
|
|
66
|
+
);
|
|
67
|
+
expect(search).toHaveValue('Ali');
|
|
68
|
+
});
|
|
69
|
+
it('waits for Chinese composition and restores the textbox from history query changes', async () => {
|
|
70
|
+
const calls = vi.fn(({ path }: { path: string }) =>
|
|
71
|
+
path === 'authorization/permissions'
|
|
72
|
+
? { data: { unrestricted: false, permissions: [] } }
|
|
73
|
+
: { data: [userRow], meta: { total: 1 } },
|
|
74
|
+
);
|
|
75
|
+
function HistoryControls() {
|
|
76
|
+
const navigate = useNavigate();
|
|
77
|
+
return (
|
|
78
|
+
<button onClick={() => void navigate('?q=Bob')}>External query</button>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
await renderWithApp(
|
|
82
|
+
<>
|
|
83
|
+
<UsersPage />
|
|
84
|
+
<HistoryControls />
|
|
85
|
+
</>,
|
|
86
|
+
{
|
|
87
|
+
namespace: settingsNamespace,
|
|
88
|
+
namespaces,
|
|
89
|
+
services,
|
|
90
|
+
fetch: answerApi(calls),
|
|
91
|
+
},
|
|
92
|
+
);
|
|
93
|
+
const search = screen.getByRole('textbox', {
|
|
94
|
+
name: enUS.settings.userSearch,
|
|
95
|
+
});
|
|
96
|
+
fireEvent.compositionStart(search);
|
|
97
|
+
fireEvent.change(search, { target: { value: '张三' } });
|
|
98
|
+
await act(async () => {
|
|
99
|
+
await new Promise((resolve) => setTimeout(resolve, 350));
|
|
100
|
+
});
|
|
101
|
+
expect(
|
|
102
|
+
calls.mock.calls.some(
|
|
103
|
+
([call]) => Reflect.get(call, 'query')?.q === '张三',
|
|
104
|
+
),
|
|
105
|
+
).toBe(false);
|
|
106
|
+
fireEvent.compositionEnd(search);
|
|
107
|
+
await waitFor(() =>
|
|
108
|
+
expect(calls).toHaveBeenCalledWith(
|
|
109
|
+
expect.objectContaining({
|
|
110
|
+
path: 'users',
|
|
111
|
+
query: expect.objectContaining({ q: '张三' }),
|
|
112
|
+
}),
|
|
113
|
+
),
|
|
114
|
+
);
|
|
115
|
+
await userEvent.click(
|
|
116
|
+
screen.getByRole('button', { name: 'External query' }),
|
|
117
|
+
);
|
|
118
|
+
await waitFor(() => expect(search).toHaveValue('Bob'));
|
|
119
|
+
});
|
|
120
|
+
it('shows failed role options with retry and prevents incomplete saves', async () => {
|
|
121
|
+
const calls = vi.fn(({ path }: { path: string }) => {
|
|
122
|
+
if (path === 'authorization/permissions')
|
|
123
|
+
return { data: { unrestricted: true, permissions: [] } };
|
|
124
|
+
if (path.endsWith('/options'))
|
|
125
|
+
return Response.json(
|
|
126
|
+
{ error: { code: 'INTERNAL_SERVER_ERROR', message: 'Unavailable' } },
|
|
127
|
+
{ status: 500 },
|
|
128
|
+
);
|
|
129
|
+
return { data: [{ key: 'sales', title: 'Sales', grants: [] }] };
|
|
130
|
+
});
|
|
131
|
+
await renderWithApp(
|
|
132
|
+
<Routes>
|
|
133
|
+
<Route path='/settings/roles' element={<RolesPage />}>
|
|
134
|
+
<Route path='create' element={<RoleForm />} />
|
|
135
|
+
</Route>
|
|
136
|
+
</Routes>,
|
|
137
|
+
{
|
|
138
|
+
route: '/settings/roles/create',
|
|
139
|
+
namespace: settingsNamespace,
|
|
140
|
+
namespaces,
|
|
141
|
+
services,
|
|
142
|
+
fetch: answerApi(calls),
|
|
143
|
+
},
|
|
144
|
+
);
|
|
145
|
+
expect((await screen.findAllByText(enUS.settings.failed))[0]).toBeVisible();
|
|
146
|
+
expect(
|
|
147
|
+
screen.queryByRole('button', { name: 'Save', exact: true }),
|
|
148
|
+
).not.toBeInTheDocument();
|
|
149
|
+
expect(
|
|
150
|
+
screen.getAllByRole('button', { name: 'Retry' }).length,
|
|
151
|
+
).toBeGreaterThan(0);
|
|
152
|
+
expect(
|
|
153
|
+
calls.mock.calls.some(([call]) => Reflect.get(call, 'method') === 'POST'),
|
|
154
|
+
).toBe(false);
|
|
155
|
+
});
|
|
156
|
+
it('keeps user creation disabled and displays a retry when role options fail', async () => {
|
|
157
|
+
const calls = vi.fn(({ path }: { path: string }) => {
|
|
158
|
+
if (path === 'authorization/permissions')
|
|
159
|
+
return { data: { unrestricted: true, permissions: [] } };
|
|
160
|
+
if (path.endsWith('/options'))
|
|
161
|
+
return Response.json(
|
|
162
|
+
{ error: { code: 'INTERNAL_SERVER_ERROR', message: 'Unavailable' } },
|
|
163
|
+
{ status: 500 },
|
|
164
|
+
);
|
|
165
|
+
return { data: [userRow], meta: { total: 1 } };
|
|
166
|
+
});
|
|
167
|
+
await renderWithApp(
|
|
168
|
+
<Routes>
|
|
169
|
+
<Route path='/settings/users' element={<UsersPage />}>
|
|
170
|
+
<Route path='create' element={<UserForm />} />
|
|
171
|
+
</Route>
|
|
172
|
+
</Routes>,
|
|
173
|
+
{
|
|
174
|
+
route: '/settings/users/create',
|
|
175
|
+
namespace: settingsNamespace,
|
|
176
|
+
namespaces,
|
|
177
|
+
services,
|
|
178
|
+
fetch: answerApi(calls),
|
|
179
|
+
},
|
|
180
|
+
);
|
|
181
|
+
expect((await screen.findAllByText(enUS.settings.failed))[0]).toBeVisible();
|
|
182
|
+
expect(
|
|
183
|
+
screen.getByRole('button', { name: 'Create', exact: true }),
|
|
184
|
+
).toBeDisabled();
|
|
185
|
+
expect(
|
|
186
|
+
screen.getAllByRole('button', { name: 'Retry' }).length,
|
|
187
|
+
).toBeGreaterThan(0);
|
|
188
|
+
});
|
|
189
|
+
it('preserves the role draft on a conditional save conflict and submits the original revision', async () => {
|
|
190
|
+
const user = userEvent.setup();
|
|
191
|
+
const calls = vi.fn(
|
|
192
|
+
({ path, method }: { path: string; method: string }) => {
|
|
193
|
+
if (path === 'authorization/permissions')
|
|
194
|
+
return { data: { unrestricted: true, permissions: [] } };
|
|
195
|
+
if (path.endsWith('/options'))
|
|
196
|
+
return {
|
|
197
|
+
data: {
|
|
198
|
+
sections: [
|
|
199
|
+
{
|
|
200
|
+
name: 'settings',
|
|
201
|
+
title: 'Settings',
|
|
202
|
+
order: 0,
|
|
203
|
+
subsections: [
|
|
204
|
+
{
|
|
205
|
+
name: 'settings',
|
|
206
|
+
title: 'Settings',
|
|
207
|
+
resources: [
|
|
208
|
+
{
|
|
209
|
+
type: 'settings',
|
|
210
|
+
id: 'files',
|
|
211
|
+
title: 'Files',
|
|
212
|
+
actions: [{ name: 'read', title: 'Read files' }],
|
|
213
|
+
},
|
|
214
|
+
],
|
|
215
|
+
},
|
|
216
|
+
],
|
|
217
|
+
},
|
|
218
|
+
],
|
|
219
|
+
recordAccess: [],
|
|
220
|
+
subjectTypes: [],
|
|
221
|
+
collections: [],
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
if (path === 'authorization/permissionSets')
|
|
225
|
+
return {
|
|
226
|
+
data: [
|
|
227
|
+
{
|
|
228
|
+
key: 'sales',
|
|
229
|
+
title: 'Sales',
|
|
230
|
+
grants: [],
|
|
231
|
+
revision: 'version-1',
|
|
232
|
+
},
|
|
233
|
+
],
|
|
234
|
+
};
|
|
235
|
+
if (method === 'PATCH')
|
|
236
|
+
return Response.json(
|
|
237
|
+
{
|
|
238
|
+
error: {
|
|
239
|
+
code: 'ALREADY_EXISTS',
|
|
240
|
+
message: 'Conflict',
|
|
241
|
+
reason: 'PERMISSION_SET_CONFLICT',
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
{ status: 409 },
|
|
245
|
+
);
|
|
246
|
+
return {
|
|
247
|
+
data: {
|
|
248
|
+
key: 'sales',
|
|
249
|
+
title: 'Sales',
|
|
250
|
+
grants: [],
|
|
251
|
+
revision: 'version-1',
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
},
|
|
255
|
+
);
|
|
256
|
+
await renderWithApp(
|
|
257
|
+
<Routes>
|
|
258
|
+
<Route path='/settings/roles' element={<RolesPage />}>
|
|
259
|
+
<Route path=':key/edit' element={<RoleForm />} />
|
|
260
|
+
</Route>
|
|
261
|
+
</Routes>,
|
|
262
|
+
{
|
|
263
|
+
route: '/settings/roles/sales/edit',
|
|
264
|
+
namespace: settingsNamespace,
|
|
265
|
+
namespaces,
|
|
266
|
+
services,
|
|
267
|
+
fetch: answerApi(calls),
|
|
268
|
+
},
|
|
269
|
+
);
|
|
270
|
+
await user.click(await screen.findByRole('button', { name: /^Settings/ }));
|
|
271
|
+
const toggle = await screen.findByRole('checkbox', { name: 'Read files' });
|
|
272
|
+
await waitFor(() =>
|
|
273
|
+
expect(toggle).not.toHaveAttribute('aria-disabled', 'true'),
|
|
274
|
+
);
|
|
275
|
+
await user.click(toggle);
|
|
276
|
+
await user.click(screen.getByRole('button', { name: 'Save', exact: true }));
|
|
277
|
+
expect(await screen.findByText(enUS.settings.conflict)).toBeVisible();
|
|
278
|
+
expect(toggle).toBeChecked();
|
|
279
|
+
expect(calls).toHaveBeenCalledWith(
|
|
280
|
+
expect.objectContaining({
|
|
281
|
+
method: 'PATCH',
|
|
282
|
+
json: expect.objectContaining({
|
|
283
|
+
expectedRevision: 'version-1',
|
|
284
|
+
grants: [
|
|
285
|
+
{
|
|
286
|
+
resource: { type: 'settings', id: 'files' },
|
|
287
|
+
actions: [{ action: 'read' }],
|
|
288
|
+
},
|
|
289
|
+
],
|
|
290
|
+
}),
|
|
291
|
+
}),
|
|
292
|
+
);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createAppSidebarPreferenceStore,
|
|
3
|
+
SidebarPreferenceProvider,
|
|
4
|
+
} from '../../client/layouts/use-sidebar-preference.js';
|
|
1
5
|
import type { AppClientRegisteredRoute } from '@nocobase/app-client/plugins';
|
|
2
|
-
import { act, render, screen, waitFor
|
|
3
|
-
import userEvent from '@testing-library/user-event';
|
|
6
|
+
import { act, render, screen, waitFor } from '@testing-library/react';
|
|
4
7
|
import { MemoryRouter } from 'react-router';
|
|
5
8
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
6
9
|
import {
|
|
@@ -10,6 +13,8 @@ import {
|
|
|
10
13
|
import { NavigationMenu } from '../../client/layouts/components/navigation-menu';
|
|
11
14
|
import { AppSidebarProvider } from '../../client/layouts/components/app-sidebar';
|
|
12
15
|
|
|
16
|
+
const sidebarPreference = createAppSidebarPreferenceStore('/main');
|
|
17
|
+
|
|
13
18
|
const auth = vi.hoisted(() => ({ revision: 0, client: { can: vi.fn() } }));
|
|
14
19
|
vi.mock('@nocobase/app-plugin-authorization/client', () => ({
|
|
15
20
|
useAuthorizationClient: () => auth.client,
|
|
@@ -63,16 +68,18 @@ describe.each(['expanded', 'collapsed', 'mobile'])(
|
|
|
63
68
|
it('hides pending and denied entries, removes empty groups, and refreshes open menus', async () => {
|
|
64
69
|
if (mode === 'mobile') vi.stubGlobal('innerWidth', 390);
|
|
65
70
|
if (mode === 'collapsed')
|
|
66
|
-
localStorage.setItem('nocobase:sidebar:collapsed', 'true');
|
|
71
|
+
localStorage.setItem('nocobase:sidebar:collapsed:app:/main', 'true');
|
|
67
72
|
const resolve: ((allowed: boolean) => void)[] = [];
|
|
68
73
|
auth.client.can.mockImplementation(
|
|
69
74
|
() => new Promise<boolean>((done) => resolve.push(done)),
|
|
70
75
|
);
|
|
71
76
|
const tree = () => (
|
|
72
77
|
<MemoryRouter>
|
|
73
|
-
<
|
|
74
|
-
<
|
|
75
|
-
|
|
78
|
+
<SidebarPreferenceProvider value={sidebarPreference}>
|
|
79
|
+
<AppSidebarProvider>
|
|
80
|
+
<Menu />
|
|
81
|
+
</AppSidebarProvider>
|
|
82
|
+
</SidebarPreferenceProvider>
|
|
76
83
|
</MemoryRouter>
|
|
77
84
|
);
|
|
78
85
|
const { rerender } = render(tree());
|
|
@@ -80,19 +87,10 @@ describe.each(['expanded', 'collapsed', 'mobile'])(
|
|
|
80
87
|
await act(async () => {
|
|
81
88
|
resolve.forEach((done, i) => done(i === 0));
|
|
82
89
|
});
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
await
|
|
86
|
-
|
|
87
|
-
within(await screen.findByRole('dialog')).getByRole('link', {
|
|
88
|
-
name: 'Allowed',
|
|
89
|
-
}),
|
|
90
|
-
).toHaveAttribute('href', '/Allowed');
|
|
91
|
-
} else {
|
|
92
|
-
expect(
|
|
93
|
-
await screen.findByRole('link', { name: 'Allowed' }),
|
|
94
|
-
).toBeVisible();
|
|
95
|
-
}
|
|
90
|
+
// Group is a section: its entries show in every mode, as icons in the collapsed sidebar.
|
|
91
|
+
expect(
|
|
92
|
+
await screen.findByRole('link', { name: 'Allowed' }),
|
|
93
|
+
).toHaveAttribute('href', '/Allowed');
|
|
96
94
|
expect(screen.queryByText('Denied')).not.toBeInTheDocument();
|
|
97
95
|
expect(screen.queryByText('Empty')).not.toBeInTheDocument();
|
|
98
96
|
auth.client.can.mockResolvedValue(false);
|