@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
|
@@ -1,42 +1,237 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { createApp } from '../../client/app.js';
|
|
2
|
+
import { createAppClientConfig } from '@nocobase/app-client';
|
|
3
|
+
import { defineClientPlugins } from '@nocobase/app-client/plugins';
|
|
4
|
+
import {
|
|
5
|
+
defineAppRuntime,
|
|
6
|
+
resolveAppRuntime,
|
|
7
|
+
} from '@nocobase/app-client/runtime';
|
|
8
|
+
import { createElement, type PropsWithChildren } from 'react';
|
|
9
|
+
import {
|
|
10
|
+
act,
|
|
11
|
+
fireEvent,
|
|
12
|
+
render,
|
|
13
|
+
renderHook,
|
|
14
|
+
screen,
|
|
15
|
+
} from '@testing-library/react';
|
|
16
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
17
|
+
import {
|
|
18
|
+
createSidebarPreferenceStore,
|
|
19
|
+
createAppSidebarPreferenceStore,
|
|
20
|
+
SidebarPreferenceProvider,
|
|
21
|
+
useSidebarPreference,
|
|
22
|
+
} from '../../client/layouts/use-sidebar-preference.js';
|
|
23
|
+
import {
|
|
24
|
+
SidebarProvider,
|
|
25
|
+
useSidebar,
|
|
26
|
+
} from '../../client/components/ui/sidebar.js';
|
|
4
27
|
|
|
5
|
-
const key = 'nocobase:sidebar:collapsed';
|
|
28
|
+
const key = 'nocobase:sidebar:collapsed:app:/main';
|
|
29
|
+
function wrapper(store: ReturnType<typeof createSidebarPreferenceStore>) {
|
|
30
|
+
return function PreferenceWrapper({ children }: PropsWithChildren) {
|
|
31
|
+
return createElement(SidebarPreferenceProvider, { value: store }, children);
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function hook(scope = 'app:/main') {
|
|
35
|
+
return renderHook(() => useSidebarPreference(), {
|
|
36
|
+
wrapper: wrapper(createSidebarPreferenceStore(scope)),
|
|
37
|
+
});
|
|
38
|
+
}
|
|
6
39
|
|
|
7
|
-
describe('
|
|
8
|
-
beforeEach(() =>
|
|
40
|
+
describe('application sidebar preference', () => {
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
localStorage.clear();
|
|
43
|
+
vi.stubGlobal('matchMedia', () => ({
|
|
44
|
+
matches: false,
|
|
45
|
+
addEventListener: vi.fn(),
|
|
46
|
+
removeEventListener: vi.fn(),
|
|
47
|
+
}));
|
|
48
|
+
});
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
vi.restoreAllMocks();
|
|
51
|
+
vi.unstubAllGlobals();
|
|
52
|
+
});
|
|
9
53
|
|
|
10
54
|
it('shares changes across layouts and restores them after remount', () => {
|
|
11
|
-
const first =
|
|
12
|
-
const second =
|
|
55
|
+
const first = hook();
|
|
56
|
+
const second = hook();
|
|
13
57
|
expect(first.result.current[0]).toBe(false);
|
|
14
58
|
act(() => first.result.current[1]((value) => !value));
|
|
15
59
|
expect(second.result.current[0]).toBe(true);
|
|
16
60
|
expect(localStorage.getItem(key)).toBe('true');
|
|
17
61
|
first.unmount();
|
|
18
62
|
second.unmount();
|
|
19
|
-
const restored =
|
|
63
|
+
const restored = hook();
|
|
20
64
|
expect(restored.result.current[0]).toBe(true);
|
|
21
65
|
act(() => restored.result.current[1](false));
|
|
22
66
|
expect(localStorage.getItem(key)).toBe('false');
|
|
23
67
|
});
|
|
24
68
|
|
|
25
|
-
it('
|
|
69
|
+
it('follows its own cross-tab events and clear, ignoring other application keys', () => {
|
|
26
70
|
localStorage.setItem(key, 'true');
|
|
27
|
-
const { result } =
|
|
71
|
+
const { result } = hook();
|
|
28
72
|
expect(result.current[0]).toBe(true);
|
|
29
73
|
act(() => {
|
|
30
74
|
localStorage.removeItem(key);
|
|
75
|
+
window.dispatchEvent(
|
|
76
|
+
new StorageEvent('storage', {
|
|
77
|
+
key: 'nocobase:sidebar:collapsed:app:/other',
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
expect(result.current[0]).toBe(true);
|
|
82
|
+
act(() => window.dispatchEvent(new StorageEvent('storage', { key })));
|
|
83
|
+
expect(result.current[0]).toBe(false);
|
|
84
|
+
act(() => {
|
|
85
|
+
localStorage.setItem(key, 'true');
|
|
31
86
|
window.dispatchEvent(new StorageEvent('storage', { key }));
|
|
32
87
|
});
|
|
88
|
+
expect(result.current[0]).toBe(true);
|
|
89
|
+
act(() => {
|
|
90
|
+
localStorage.clear();
|
|
91
|
+
window.dispatchEvent(new StorageEvent('storage', { key: null }));
|
|
92
|
+
});
|
|
33
93
|
expect(result.current[0]).toBe(false);
|
|
34
94
|
});
|
|
35
95
|
|
|
36
|
-
it('
|
|
37
|
-
|
|
38
|
-
|
|
96
|
+
it.each(['false', 'invalid'])(
|
|
97
|
+
'defaults to expanded for stored %s',
|
|
98
|
+
(value) => {
|
|
99
|
+
localStorage.setItem(key, value);
|
|
100
|
+
expect(hook().result.current[0]).toBe(false);
|
|
101
|
+
},
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
it('does not migrate the old shared preference', () => {
|
|
105
|
+
localStorage.setItem('nocobase:sidebar:collapsed', 'true');
|
|
106
|
+
expect(hook().result.current[0]).toBe(false);
|
|
107
|
+
expect(localStorage.getItem(key)).toBeNull();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('isolates Studio and multiple applications, including an application named studio', () => {
|
|
111
|
+
const scopes = ['studio', 'app:/studio', 'app:/a', 'app:/b'];
|
|
112
|
+
const hooks = scopes.map((scope) => hook(scope));
|
|
113
|
+
hooks.forEach((item, index) => {
|
|
114
|
+
act(() => item.result.current[1](true));
|
|
115
|
+
hooks.forEach((other, otherIndex) =>
|
|
116
|
+
expect(other.result.current[0]).toBe(otherIndex <= index),
|
|
117
|
+
);
|
|
118
|
+
scopes.forEach((scope, otherIndex) =>
|
|
119
|
+
expect(
|
|
120
|
+
localStorage.getItem(`nocobase:sidebar:collapsed:${scope}`),
|
|
121
|
+
).toBe(otherIndex <= index ? 'true' : null),
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
act(() => hooks[0].result.current[1](false));
|
|
125
|
+
expect(hooks.map((item) => item.result.current[0])).toEqual([
|
|
39
126
|
false,
|
|
127
|
+
true,
|
|
128
|
+
true,
|
|
129
|
+
true,
|
|
130
|
+
]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('isolates memory fallback when storage writes fail', () => {
|
|
134
|
+
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
|
135
|
+
throw new Error('blocked');
|
|
136
|
+
});
|
|
137
|
+
const store = createSidebarPreferenceStore('app:/blocked');
|
|
138
|
+
const first = renderHook(() => useSidebarPreference(), {
|
|
139
|
+
wrapper: wrapper(store),
|
|
140
|
+
});
|
|
141
|
+
const second = renderHook(() => useSidebarPreference(), {
|
|
142
|
+
wrapper: wrapper(store),
|
|
143
|
+
});
|
|
144
|
+
const other = hook('app:/other');
|
|
145
|
+
act(() => first.result.current[1](true));
|
|
146
|
+
expect(second.result.current[0]).toBe(true);
|
|
147
|
+
expect(other.result.current[0]).toBe(false);
|
|
148
|
+
act(() => other.result.current[1](true));
|
|
149
|
+
act(() => first.result.current[1](false));
|
|
150
|
+
expect(second.result.current[0]).toBe(false);
|
|
151
|
+
expect(other.result.current[0]).toBe(true);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('does not write a shared cookie when the real SidebarProvider toggles', () => {
|
|
155
|
+
const cookie = vi.spyOn(document, 'cookie', 'set');
|
|
156
|
+
function Toggle() {
|
|
157
|
+
const { toggleSidebar, state } = useSidebar();
|
|
158
|
+
return <button onClick={toggleSidebar}>{state}</button>;
|
|
159
|
+
}
|
|
160
|
+
render(
|
|
161
|
+
<SidebarProvider>
|
|
162
|
+
<Toggle />
|
|
163
|
+
</SidebarProvider>,
|
|
40
164
|
);
|
|
165
|
+
fireEvent.click(screen.getByRole('button', { name: 'expanded' }));
|
|
166
|
+
expect(
|
|
167
|
+
screen.getByRole('button', { name: 'collapsed' }),
|
|
168
|
+
).toBeInTheDocument();
|
|
169
|
+
expect(cookie).not.toHaveBeenCalled();
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe('runtime sidebar scope', () => {
|
|
174
|
+
it.each(['', '/', '///', 'a', '/a/', '/Team/Nested///'])(
|
|
175
|
+
'normalizes only boundary slashes for %s',
|
|
176
|
+
(basename) => {
|
|
177
|
+
localStorage.clear();
|
|
178
|
+
const store = createAppSidebarPreferenceStore(basename);
|
|
179
|
+
store.setCollapsed(true);
|
|
180
|
+
const normalized = '/' + basename.replace(/^\/+|\/+$/g, '');
|
|
181
|
+
expect(
|
|
182
|
+
localStorage.getItem(`nocobase:sidebar:collapsed:app:${normalized}`),
|
|
183
|
+
).toBe('true');
|
|
184
|
+
},
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
it('provides stable, independent stores from each actual runtime basename', async () => {
|
|
188
|
+
localStorage.clear();
|
|
189
|
+
async function application(basename: string) {
|
|
190
|
+
const runtime = await resolveAppRuntime(
|
|
191
|
+
defineAppRuntime({
|
|
192
|
+
packageName: '@example/app',
|
|
193
|
+
createAppConfig: createAppClientConfig,
|
|
194
|
+
serviceProviders: [],
|
|
195
|
+
reactProviders: [],
|
|
196
|
+
routes: [],
|
|
197
|
+
plugins: defineClientPlugins([]),
|
|
198
|
+
}),
|
|
199
|
+
{ rawConfig: { app: { basePath: basename } } },
|
|
200
|
+
);
|
|
201
|
+
const app = createApp(runtime);
|
|
202
|
+
await app.start();
|
|
203
|
+
return app;
|
|
204
|
+
}
|
|
205
|
+
const a = await application('/studio');
|
|
206
|
+
const b = await application('/Team/Nested');
|
|
207
|
+
try {
|
|
208
|
+
const providerA = a.renderConfig.reactProviders![1];
|
|
209
|
+
const providerB = b.renderConfig.reactProviders![1];
|
|
210
|
+
const first = renderHook(() => useSidebarPreference(), {
|
|
211
|
+
wrapper: providerA,
|
|
212
|
+
});
|
|
213
|
+
const second = renderHook(() => useSidebarPreference(), {
|
|
214
|
+
wrapper: providerA,
|
|
215
|
+
});
|
|
216
|
+
const other = renderHook(() => useSidebarPreference(), {
|
|
217
|
+
wrapper: providerB,
|
|
218
|
+
});
|
|
219
|
+
act(() => first.result.current[1](true));
|
|
220
|
+
expect(second.result.current[0]).toBe(true);
|
|
221
|
+
expect(other.result.current[0]).toBe(false);
|
|
222
|
+
expect(
|
|
223
|
+
localStorage.getItem('nocobase:sidebar:collapsed:app:/studio'),
|
|
224
|
+
).toBe('true');
|
|
225
|
+
act(() => other.result.current[1](true));
|
|
226
|
+
expect(
|
|
227
|
+
localStorage.getItem('nocobase:sidebar:collapsed:app:/Team/Nested'),
|
|
228
|
+
).toBe('true');
|
|
229
|
+
first.unmount();
|
|
230
|
+
second.unmount();
|
|
231
|
+
other.unmount();
|
|
232
|
+
} finally {
|
|
233
|
+
await a.shutdown();
|
|
234
|
+
await b.shutdown();
|
|
235
|
+
}
|
|
41
236
|
});
|
|
42
237
|
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { StatusBadge } from '#components/status-badge';
|
|
5
|
+
|
|
6
|
+
// The two badge sizes are drawn by client/styles.css from `data-size`, which jsdom does not apply, so this checks the
|
|
7
|
+
// attribute that selects them.
|
|
8
|
+
describe('StatusBadge', () => {
|
|
9
|
+
it('is the default badge unless asked for the small one', () => {
|
|
10
|
+
render(
|
|
11
|
+
<>
|
|
12
|
+
<StatusBadge tone='success'>Done</StatusBadge>
|
|
13
|
+
<StatusBadge tone='warning' size='sm'>
|
|
14
|
+
Waiting
|
|
15
|
+
</StatusBadge>
|
|
16
|
+
</>,
|
|
17
|
+
);
|
|
18
|
+
expect(screen.getByText('Done')).not.toHaveAttribute('data-size');
|
|
19
|
+
expect(screen.getByText('Waiting')).toHaveAttribute('data-size', 'sm');
|
|
20
|
+
expect(screen.getByText('Waiting')).toHaveAttribute('data-slot', 'badge');
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
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
|
+
type PermissionGrant,
|
|
7
|
+
} from '@nocobase/app-plugin-authorization/client';
|
|
8
|
+
import { screen, waitFor } from '@testing-library/react';
|
|
9
|
+
import userEvent from '@testing-library/user-event';
|
|
10
|
+
import { Routes, Route } from 'react-router';
|
|
11
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
12
|
+
import RoleForm from '../../client/pages/settings/role-form.js';
|
|
13
|
+
import RolesPage from '../../client/pages/settings/roles.js';
|
|
14
|
+
import enUS from '../../client/locales/en-US.js';
|
|
15
|
+
import zhCN from '../../client/locales/zh-CN.js';
|
|
16
|
+
|
|
17
|
+
const namespace = '@nocobase/user-permissions-test';
|
|
18
|
+
const namespaces = {
|
|
19
|
+
[namespace]: {
|
|
20
|
+
'en-US': async () => ({ default: enUS }),
|
|
21
|
+
'zh-CN': async () => ({ default: zhCN }),
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
describe('user permission configuration', () => {
|
|
26
|
+
it.each(['en-US', 'zh-CN'] as const)(
|
|
27
|
+
'saves, reloads and revokes the missing actions in %s without replacing existing grants',
|
|
28
|
+
async (locale) => {
|
|
29
|
+
const user = userEvent.setup();
|
|
30
|
+
const words = locale === 'zh-CN' ? zhCN.settings : enUS.settings;
|
|
31
|
+
const role = {
|
|
32
|
+
key: 'operators',
|
|
33
|
+
title: 'Operators',
|
|
34
|
+
revision: 'original',
|
|
35
|
+
grants: [
|
|
36
|
+
{
|
|
37
|
+
resource: { type: 'user', id: '*' },
|
|
38
|
+
actions: [{ action: 'read' }, { action: 'disable' }],
|
|
39
|
+
},
|
|
40
|
+
] as readonly PermissionGrant[],
|
|
41
|
+
};
|
|
42
|
+
const calls = vi.fn(
|
|
43
|
+
({
|
|
44
|
+
path,
|
|
45
|
+
method,
|
|
46
|
+
json,
|
|
47
|
+
}: {
|
|
48
|
+
path: string;
|
|
49
|
+
method: string;
|
|
50
|
+
json?: {
|
|
51
|
+
grants: readonly PermissionGrant[];
|
|
52
|
+
expectedRevision?: string;
|
|
53
|
+
};
|
|
54
|
+
}) => {
|
|
55
|
+
if (path === 'authorization/permissions')
|
|
56
|
+
return { data: { unrestricted: true, permissions: [] } };
|
|
57
|
+
if (path.endsWith('/options'))
|
|
58
|
+
return {
|
|
59
|
+
data: {
|
|
60
|
+
sections: [],
|
|
61
|
+
recordAccess: [],
|
|
62
|
+
subjectTypes: [],
|
|
63
|
+
collections: [],
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
if (path === 'authorization/permissionSets') return { data: [role] };
|
|
67
|
+
if (method === 'PATCH' && json) {
|
|
68
|
+
role.grants = json.grants;
|
|
69
|
+
role.revision = 'updated';
|
|
70
|
+
}
|
|
71
|
+
return { data: role };
|
|
72
|
+
},
|
|
73
|
+
);
|
|
74
|
+
const mount = () =>
|
|
75
|
+
renderWithApp(
|
|
76
|
+
<Routes>
|
|
77
|
+
<Route path='/settings/roles' element={<RolesPage />}>
|
|
78
|
+
<Route path=':key/edit' element={<RoleForm />} />
|
|
79
|
+
</Route>
|
|
80
|
+
</Routes>,
|
|
81
|
+
{
|
|
82
|
+
route: '/settings/roles/operators/edit',
|
|
83
|
+
namespace,
|
|
84
|
+
namespaces,
|
|
85
|
+
locale,
|
|
86
|
+
services: (app: ClientApplication) =>
|
|
87
|
+
app.container.instance(
|
|
88
|
+
authorizationClientToken,
|
|
89
|
+
new AuthorizationClient(app.container.resolve(apiClientToken)),
|
|
90
|
+
),
|
|
91
|
+
fetch: answerApi(calls),
|
|
92
|
+
},
|
|
93
|
+
);
|
|
94
|
+
const first = await mount();
|
|
95
|
+
const labels = [
|
|
96
|
+
words.inviteUsers,
|
|
97
|
+
words.deleteUsers,
|
|
98
|
+
words.resetUserPassword,
|
|
99
|
+
];
|
|
100
|
+
for (const label of labels) {
|
|
101
|
+
const checkbox = await screen.findByRole('checkbox', { name: label });
|
|
102
|
+
await waitFor(() =>
|
|
103
|
+
expect(checkbox).not.toHaveAttribute('aria-disabled', 'true'),
|
|
104
|
+
);
|
|
105
|
+
expect(checkbox).not.toBeChecked();
|
|
106
|
+
await user.click(checkbox);
|
|
107
|
+
}
|
|
108
|
+
await user.click(
|
|
109
|
+
screen.getByRole('button', { name: words.save, exact: true }),
|
|
110
|
+
);
|
|
111
|
+
await waitFor(() =>
|
|
112
|
+
expect(calls).toHaveBeenCalledWith(
|
|
113
|
+
expect.objectContaining({
|
|
114
|
+
method: 'PATCH',
|
|
115
|
+
json: expect.objectContaining({ expectedRevision: 'original' }),
|
|
116
|
+
}),
|
|
117
|
+
),
|
|
118
|
+
);
|
|
119
|
+
const all = ['read', 'disable', 'invite', 'delete', 'reset-password'];
|
|
120
|
+
expect(role.grants[0]?.actions.map((action) => action.action)).toEqual(
|
|
121
|
+
all,
|
|
122
|
+
);
|
|
123
|
+
first.unmount();
|
|
124
|
+
await mount();
|
|
125
|
+
for (const label of labels)
|
|
126
|
+
expect(
|
|
127
|
+
await screen.findByRole('checkbox', { name: label }),
|
|
128
|
+
).toBeChecked();
|
|
129
|
+
await user.click(
|
|
130
|
+
screen.getByRole('checkbox', { name: words.deleteUsers }),
|
|
131
|
+
);
|
|
132
|
+
await user.click(
|
|
133
|
+
screen.getByRole('button', { name: words.save, exact: true }),
|
|
134
|
+
);
|
|
135
|
+
await waitFor(() =>
|
|
136
|
+
expect(role.grants[0]?.actions.map((action) => action.action)).toEqual(
|
|
137
|
+
all.filter((action) => action !== 'delete'),
|
|
138
|
+
),
|
|
139
|
+
);
|
|
140
|
+
},
|
|
141
|
+
);
|
|
142
|
+
});
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createAppSidebarPreferenceStore,
|
|
3
|
+
SidebarPreferenceProvider,
|
|
4
|
+
} from '../../client/layouts/use-sidebar-preference.js';
|
|
1
5
|
import { Refine } from '@refinedev/core';
|
|
2
6
|
import { createRoot } from 'react-dom/client';
|
|
3
7
|
import { MemoryRouter } from 'react-router';
|
|
@@ -114,5 +118,9 @@ export default function Fixture() {
|
|
|
114
118
|
}
|
|
115
119
|
|
|
116
120
|
const root = createRoot(document.getElementById('root')!);
|
|
117
|
-
root.render(
|
|
121
|
+
root.render(
|
|
122
|
+
<SidebarPreferenceProvider value={createAppSidebarPreferenceStore('/main')}>
|
|
123
|
+
<Fixture />
|
|
124
|
+
</SidebarPreferenceProvider>,
|
|
125
|
+
);
|
|
118
126
|
import.meta.hot?.dispose(() => root.unmount());
|
|
@@ -55,7 +55,6 @@ describe('asset URLs in the application build', () => {
|
|
|
55
55
|
'uses the compiled asset base while keeping API URLs on the runtime mount (%s)',
|
|
56
56
|
async (cdn) => {
|
|
57
57
|
vi.stubEnv('CDN_BASE_URL', cdn);
|
|
58
|
-
vi.stubEnv('AGENT_ANNOTATIONS_ENABLED', 'false');
|
|
59
58
|
// The same build must work when the deployment chooses a different mount path.
|
|
60
59
|
vi.stubEnv('APP_BASE_PATH', '/build-machine');
|
|
61
60
|
const result = await build({
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
} from '@nocobase/app-client/plugins';
|
|
6
6
|
import { describe, expect, it } from 'vitest';
|
|
7
7
|
|
|
8
|
+
import systemSettingsRoutes from '../../client/pages/settings/routes.js';
|
|
8
9
|
import applicationRoutes from '../../client/routes.ts';
|
|
9
10
|
|
|
10
11
|
describe('app client routes', () => {
|
|
@@ -57,6 +58,45 @@ describe('app client routes', () => {
|
|
|
57
58
|
]);
|
|
58
59
|
});
|
|
59
60
|
|
|
61
|
+
it('keeps a bare application home-only and supports settings registration as business capabilities are developed', async () => {
|
|
62
|
+
expect(
|
|
63
|
+
pagePaths(resolveRoutes().routes).some((path) =>
|
|
64
|
+
path.startsWith('/settings'),
|
|
65
|
+
),
|
|
66
|
+
).toBe(false);
|
|
67
|
+
const built = resolveAppClientContributions([
|
|
68
|
+
{
|
|
69
|
+
packageName: '@nocobase/app-template-default',
|
|
70
|
+
routes: [...applicationRoutes, systemSettingsRoutes],
|
|
71
|
+
source: 'application',
|
|
72
|
+
},
|
|
73
|
+
]);
|
|
74
|
+
expect(pageAuthorizations(built.routes)).toEqual([
|
|
75
|
+
{ name: 'home', authorizedAs: null },
|
|
76
|
+
{ name: 'inbox', authorizedAs: null },
|
|
77
|
+
{ name: 'settingsUsers', authorizedAs: 'user:*' },
|
|
78
|
+
{ name: 'settingsUserCreate', authorizedAs: 'user:*' },
|
|
79
|
+
{ name: 'settingsUser', authorizedAs: 'user:*' },
|
|
80
|
+
{ name: 'settingsUserEdit', authorizedAs: 'user:*' },
|
|
81
|
+
{
|
|
82
|
+
name: 'settingsRoles',
|
|
83
|
+
authorizedAs: 'settings:authorization.permission-sets',
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
name: 'settingsRoleCreate',
|
|
87
|
+
authorizedAs: 'settings:authorization.permission-sets',
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
name: 'settingsRoleEdit',
|
|
91
|
+
authorizedAs: 'settings:authorization.permission-sets',
|
|
92
|
+
},
|
|
93
|
+
]);
|
|
94
|
+
for (const loader of componentLoadersIn(built.routes))
|
|
95
|
+
await expect(loader()).resolves.toMatchObject({
|
|
96
|
+
default: expect.any(Function),
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
60
100
|
it('keeps the inbox out of the application navigation', () => {
|
|
61
101
|
// The inbox is reached from the header's inbox button, so it deliberately declares no menu entry.
|
|
62
102
|
expect(
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createAppSidebarPreferenceStore,
|
|
3
|
+
SidebarPreferenceProvider,
|
|
4
|
+
} from '../../client/layouts/use-sidebar-preference.js';
|
|
1
5
|
import {
|
|
2
6
|
apiClientToken,
|
|
3
7
|
ClientApplicationContext,
|
|
@@ -305,7 +309,11 @@ function renderApplication(
|
|
|
305
309
|
<AuthenticationProvider>
|
|
306
310
|
<MemoryRouter initialEntries={[initialEntry]}>
|
|
307
311
|
<AppThemeProvider>
|
|
308
|
-
<
|
|
312
|
+
<SidebarPreferenceProvider
|
|
313
|
+
value={createAppSidebarPreferenceStore('/main')}
|
|
314
|
+
>
|
|
315
|
+
<AppRouter clientRoutes={clientRoutes} />
|
|
316
|
+
</SidebarPreferenceProvider>
|
|
309
317
|
</AppThemeProvider>
|
|
310
318
|
</MemoryRouter>
|
|
311
319
|
</AuthenticationProvider>
|
|
@@ -318,7 +326,7 @@ function createTestAuthClient(authenticated: boolean) {
|
|
|
318
326
|
getSession: vi.fn().mockResolvedValue({
|
|
319
327
|
data: authenticated
|
|
320
328
|
? {
|
|
321
|
-
session:
|
|
329
|
+
session: { id: 'session-1' },
|
|
322
330
|
user: {
|
|
323
331
|
email: 'alice@example.com',
|
|
324
332
|
id: '1',
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
|
|
5
|
+
import { describe, expect, it } from 'vitest';
|
|
6
|
+
|
|
7
|
+
// `pnpm nocobase config init` writes config.yml from this file: it replaces the instruction on the first line with
|
|
8
|
+
// its own header and fills the placeholder key of the live `secrets` section, keeping everything else verbatim. These
|
|
9
|
+
// are the parts of the example it relies on.
|
|
10
|
+
describe('config.example.yml', () => {
|
|
11
|
+
const read = () =>
|
|
12
|
+
readFile(new URL('../../config.example.yml', import.meta.url), 'utf8');
|
|
13
|
+
|
|
14
|
+
it('opens with the instruction config init replaces', async () => {
|
|
15
|
+
expect((await read()).split('\n')[0]).toMatch(
|
|
16
|
+
/^# Copy this file to config\.yml\b/u,
|
|
17
|
+
);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('carries a live secrets section whose first key is a placeholder', async () => {
|
|
21
|
+
expect(await read()).toContain(
|
|
22
|
+
[
|
|
23
|
+
'secrets:',
|
|
24
|
+
' keys:',
|
|
25
|
+
' - version: 1',
|
|
26
|
+
' key: replace-with-a-unique-secret',
|
|
27
|
+
].join('\n'),
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -60,7 +60,8 @@ describe('development client file watching', () => {
|
|
|
60
60
|
'junction',
|
|
61
61
|
);
|
|
62
62
|
|
|
63
|
-
|
|
63
|
+
// A leftover opt-in from an existing application must not restore the removed toolbar.
|
|
64
|
+
vi.stubEnv('AGENT_ANNOTATIONS_ENABLED', 'true');
|
|
64
65
|
vi.stubEnv('PROXY_TARGET_URL', '');
|
|
65
66
|
// `pnpm dev` always passes the mount path; the Vite preset refuses to serve without one.
|
|
66
67
|
vi.stubEnv('APP_BASE_PATH', '/main');
|
|
@@ -77,6 +78,12 @@ describe('development client file watching', () => {
|
|
|
77
78
|
watch: { usePolling: true, interval: 20 },
|
|
78
79
|
},
|
|
79
80
|
});
|
|
81
|
+
const html = await server.transformIndexHtml(
|
|
82
|
+
'/main/',
|
|
83
|
+
'<!doctype html><html><head></head><body></body></html>',
|
|
84
|
+
);
|
|
85
|
+
expect(html).not.toContain('agent-annotations');
|
|
86
|
+
|
|
80
87
|
const watcher = server.watcher;
|
|
81
88
|
const changes: string[] = [];
|
|
82
89
|
watcher.on('change', (file) => changes.push(file));
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
render,
|
|
5
5
|
screen,
|
|
6
6
|
waitFor,
|
|
7
|
+
within,
|
|
7
8
|
} from '@testing-library/react';
|
|
8
9
|
import userEvent from '@testing-library/user-event';
|
|
9
10
|
import {
|
|
@@ -158,6 +159,47 @@ describe('route overlays', () => {
|
|
|
158
159
|
});
|
|
159
160
|
});
|
|
160
161
|
|
|
162
|
+
describe('route overlay presentation', () => {
|
|
163
|
+
it('composes the dialog primitives so it follows the dialog styles', async () => {
|
|
164
|
+
setup();
|
|
165
|
+
const dialog = await screen.findByRole('dialog', { name: 'Edit' });
|
|
166
|
+
expect(dialog).toHaveAttribute('data-slot', 'route-overlay-content');
|
|
167
|
+
expect(dialog).toHaveAttribute('data-presentation', 'dialog');
|
|
168
|
+
const header = dialog.querySelector(
|
|
169
|
+
':scope > [data-slot="route-overlay-header"] > [data-slot="dialog-header"]',
|
|
170
|
+
);
|
|
171
|
+
expect(
|
|
172
|
+
header?.querySelector('[data-slot="dialog-title"]'),
|
|
173
|
+
).toHaveTextContent('Edit');
|
|
174
|
+
expect(
|
|
175
|
+
dialog.querySelector(':scope > [data-slot="route-overlay-body"]'),
|
|
176
|
+
).toContainElement(screen.getByRole('textbox', { name: 'Name' }));
|
|
177
|
+
const footer = dialog.querySelector(
|
|
178
|
+
':scope > [data-slot="route-overlay-footer"] > [data-slot="dialog-footer"]',
|
|
179
|
+
);
|
|
180
|
+
expect(footer).toContainElement(
|
|
181
|
+
screen.getByRole('button', { name: 'Cancel' }),
|
|
182
|
+
);
|
|
183
|
+
// The footer sits at the panel's edges, so the margins it uses inside `DialogContent` are reset.
|
|
184
|
+
expect(footer).toHaveClass('mx-0', 'mb-0');
|
|
185
|
+
expect(footer).not.toHaveClass('-mx-6', '-mb-6');
|
|
186
|
+
expect(
|
|
187
|
+
dialog.querySelector(':scope > [data-slot="dialog-close"]'),
|
|
188
|
+
).toHaveAccessibleName('Close');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('presents a drawer with the same regions', async () => {
|
|
192
|
+
setup(undefined, undefined, true);
|
|
193
|
+
const drawer = await screen.findByRole('dialog', { name: 'Details' });
|
|
194
|
+
expect(drawer).toHaveAttribute('data-presentation', 'drawer');
|
|
195
|
+
expect(
|
|
196
|
+
drawer.querySelector(
|
|
197
|
+
':scope > [data-slot="route-overlay-footer"] > [data-slot="dialog-footer"]',
|
|
198
|
+
),
|
|
199
|
+
).toContainElement(within(drawer).getByRole('button', { name: 'Cancel' }));
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
161
203
|
describe('route overlay interactions', () => {
|
|
162
204
|
it('renders an owning backdrop for the nested layer and dismisses only that layer', async () => {
|
|
163
205
|
const user = userEvent.setup();
|