@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,88 @@
|
|
|
1
|
+
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { revealScrollbarsWhileScrolling } from '../../client/lib/scrollbar';
|
|
4
|
+
|
|
5
|
+
describe('revealScrollbarsWhileScrolling', () => {
|
|
6
|
+
beforeAll(() => revealScrollbarsWhileScrolling());
|
|
7
|
+
afterEach(() => vi.useRealTimers());
|
|
8
|
+
|
|
9
|
+
function scroll(target: EventTarget) {
|
|
10
|
+
target.dispatchEvent(new Event('scroll'));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
it('marks a scrolling element until it has been still for a moment', () => {
|
|
14
|
+
vi.useFakeTimers();
|
|
15
|
+
const list = document.createElement('div');
|
|
16
|
+
document.body.append(list);
|
|
17
|
+
scroll(list);
|
|
18
|
+
expect(list).toHaveAttribute('data-scrolling');
|
|
19
|
+
vi.advanceTimersByTime(799);
|
|
20
|
+
expect(list).toHaveAttribute('data-scrolling');
|
|
21
|
+
vi.advanceTimersByTime(1);
|
|
22
|
+
expect(list).not.toHaveAttribute('data-scrolling');
|
|
23
|
+
list.remove();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('keeps the mark while scrolling goes on', () => {
|
|
27
|
+
vi.useFakeTimers();
|
|
28
|
+
const list = document.createElement('div');
|
|
29
|
+
document.body.append(list);
|
|
30
|
+
scroll(list);
|
|
31
|
+
vi.advanceTimersByTime(600);
|
|
32
|
+
scroll(list);
|
|
33
|
+
vi.advanceTimersByTime(600);
|
|
34
|
+
expect(list).toHaveAttribute('data-scrolling');
|
|
35
|
+
vi.advanceTimersByTime(200);
|
|
36
|
+
expect(list).not.toHaveAttribute('data-scrolling');
|
|
37
|
+
list.remove();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('marks the page root when the page itself scrolls', () => {
|
|
41
|
+
vi.useFakeTimers();
|
|
42
|
+
scroll(document);
|
|
43
|
+
expect(
|
|
44
|
+
document.scrollingElement ?? document.documentElement,
|
|
45
|
+
).toHaveAttribute('data-scrolling');
|
|
46
|
+
vi.advanceTimersByTime(800);
|
|
47
|
+
expect(
|
|
48
|
+
document.scrollingElement ?? document.documentElement,
|
|
49
|
+
).not.toHaveAttribute('data-scrolling');
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('revealScrollbarsWhileScrolling near an edge', () => {
|
|
54
|
+
function scroller(): HTMLDivElement {
|
|
55
|
+
const element = document.createElement('div');
|
|
56
|
+
element.style.overflowY = 'auto';
|
|
57
|
+
Object.defineProperty(element, 'scrollHeight', { value: 500 });
|
|
58
|
+
Object.defineProperty(element, 'clientHeight', { value: 100 });
|
|
59
|
+
element.getBoundingClientRect = () =>
|
|
60
|
+
({ left: 0, top: 0, right: 200, bottom: 100 }) as DOMRect;
|
|
61
|
+
document.body.append(element);
|
|
62
|
+
return element;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function move(target: Element, clientX: number) {
|
|
66
|
+
const event = new MouseEvent('pointermove', {
|
|
67
|
+
bubbles: true,
|
|
68
|
+
clientX,
|
|
69
|
+
clientY: 50,
|
|
70
|
+
});
|
|
71
|
+
Object.defineProperty(event, 'pointerType', { value: 'mouse' });
|
|
72
|
+
target.dispatchEvent(event);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
it('shows the bar while the mouse is near the edge it runs along', () => {
|
|
76
|
+
vi.useFakeTimers();
|
|
77
|
+
const list = scroller();
|
|
78
|
+
move(list, 100);
|
|
79
|
+
vi.advanceTimersByTime(20);
|
|
80
|
+
expect(list).not.toHaveAttribute('data-scrolling');
|
|
81
|
+
move(list, 195);
|
|
82
|
+
vi.advanceTimersByTime(20);
|
|
83
|
+
expect(list).toHaveAttribute('data-scrolling');
|
|
84
|
+
vi.advanceTimersByTime(800);
|
|
85
|
+
expect(list).not.toHaveAttribute('data-scrolling');
|
|
86
|
+
list.remove();
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ClientApplicationContext,
|
|
3
|
+
realtimeClientToken,
|
|
4
|
+
type ClientApplication,
|
|
5
|
+
} from '@nocobase/app-client';
|
|
6
|
+
import {
|
|
7
|
+
AuthenticationProvider,
|
|
8
|
+
authenticationClientToken,
|
|
9
|
+
useAuthentication,
|
|
10
|
+
} from '@nocobase/app-plugin-authentication/client';
|
|
11
|
+
import {
|
|
12
|
+
AuthorizationClient,
|
|
13
|
+
authorizationClientToken,
|
|
14
|
+
} from '@nocobase/app-plugin-authorization/client';
|
|
15
|
+
import { reactProviders } from '@nocobase/app-plugin-authorization/client/react-providers';
|
|
16
|
+
import { ServiceContainer } from '@nocobase/service-provider';
|
|
17
|
+
import {
|
|
18
|
+
TestI18nProvider,
|
|
19
|
+
createTestI18nRuntime,
|
|
20
|
+
} from '@nocobase/i18n/testing';
|
|
21
|
+
import { act, fireEvent, render, screen } from '@testing-library/react';
|
|
22
|
+
import { useEffect } from 'react';
|
|
23
|
+
import { MemoryRouter, Outlet } from 'react-router';
|
|
24
|
+
import { afterEach, expect, it, vi } from 'vitest';
|
|
25
|
+
import { AppRouter } from '../../client/routing/app-router.js';
|
|
26
|
+
import {
|
|
27
|
+
Dialog,
|
|
28
|
+
DialogContent,
|
|
29
|
+
DialogTitle,
|
|
30
|
+
DialogDescription,
|
|
31
|
+
} from '../../client/components/ui/dialog.js';
|
|
32
|
+
import enUS from '../../client/locales/en-US.js';
|
|
33
|
+
|
|
34
|
+
const AuthorizationProvider = reactProviders[0].component;
|
|
35
|
+
vi.mock('../../client/layouts/app-layout.js', () => ({
|
|
36
|
+
AppLayout: () => <Outlet />,
|
|
37
|
+
}));
|
|
38
|
+
vi.mock('../../client/routing/standalone-page-layout.js', () => ({
|
|
39
|
+
StandalonePageLayout: () => <Outlet />,
|
|
40
|
+
}));
|
|
41
|
+
const runtime = await createTestI18nRuntime({
|
|
42
|
+
application: { namespace: 'test', resources: enUS },
|
|
43
|
+
});
|
|
44
|
+
afterEach(() => vi.useRealTimers());
|
|
45
|
+
|
|
46
|
+
async function setup() {
|
|
47
|
+
const allowed = {
|
|
48
|
+
data: {
|
|
49
|
+
unrestricted: false,
|
|
50
|
+
permissions: [
|
|
51
|
+
{ resource: { type: 'page', id: 'probe' }, actions: ['access'] },
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
const request = vi.fn().mockResolvedValue(allowed);
|
|
56
|
+
const authz = new AuthorizationClient({ request } as never);
|
|
57
|
+
const services = new ServiceContainer();
|
|
58
|
+
const listeners = new Set<() => void>();
|
|
59
|
+
const realtime = {
|
|
60
|
+
active: true,
|
|
61
|
+
reconnect: vi.fn(),
|
|
62
|
+
onOpen: (listener: () => void) => {
|
|
63
|
+
listeners.add(listener);
|
|
64
|
+
return () => {
|
|
65
|
+
listeners.delete(listener);
|
|
66
|
+
};
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
const signOut = vi.fn().mockResolvedValue({ error: { message: 'Offline' } });
|
|
70
|
+
let session: unknown = { user: { id: 'user' }, session: { id: 'session' } };
|
|
71
|
+
const getSession = vi.fn(async () => ({ data: session }));
|
|
72
|
+
services.instance(authenticationClientToken, {
|
|
73
|
+
getSession,
|
|
74
|
+
signOut,
|
|
75
|
+
} as never);
|
|
76
|
+
services.instance(realtimeClientToken, realtime as never);
|
|
77
|
+
services.instance(authorizationClientToken, authz);
|
|
78
|
+
const mounted = vi.fn();
|
|
79
|
+
const cleaned = vi.fn();
|
|
80
|
+
const accepted = vi.fn();
|
|
81
|
+
const old = Promise.withResolvers<void>();
|
|
82
|
+
function Page() {
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
let active = true;
|
|
85
|
+
mounted();
|
|
86
|
+
void old.promise.then(() => {
|
|
87
|
+
if (active) accepted();
|
|
88
|
+
});
|
|
89
|
+
return () => {
|
|
90
|
+
active = false;
|
|
91
|
+
cleaned();
|
|
92
|
+
};
|
|
93
|
+
}, []);
|
|
94
|
+
return (
|
|
95
|
+
<Dialog open>
|
|
96
|
+
<DialogContent>
|
|
97
|
+
<DialogTitle>Protected dialog</DialogTitle>
|
|
98
|
+
<DialogDescription>Protected content</DialogDescription>
|
|
99
|
+
</DialogContent>
|
|
100
|
+
</Dialog>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
let refresh!: () => Promise<void>;
|
|
104
|
+
function Capture() {
|
|
105
|
+
refresh = useAuthentication().refresh;
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
const route = {
|
|
109
|
+
id: 'probe',
|
|
110
|
+
name: 'probe',
|
|
111
|
+
path: '/',
|
|
112
|
+
auth: 'required',
|
|
113
|
+
authz: { resource: { type: 'page', id: 'probe' }, action: 'access' },
|
|
114
|
+
packageName: 'test',
|
|
115
|
+
source: 'application',
|
|
116
|
+
componentLoader: async () => ({ default: Page }),
|
|
117
|
+
} as const;
|
|
118
|
+
render(
|
|
119
|
+
<TestI18nProvider runtime={runtime}>
|
|
120
|
+
<ClientApplicationContext.Provider
|
|
121
|
+
value={{ services } as unknown as ClientApplication}
|
|
122
|
+
>
|
|
123
|
+
<AuthenticationProvider>
|
|
124
|
+
<Capture />
|
|
125
|
+
<AuthorizationProvider>
|
|
126
|
+
<MemoryRouter>
|
|
127
|
+
<AppRouter
|
|
128
|
+
clientRoutes={[
|
|
129
|
+
route,
|
|
130
|
+
{
|
|
131
|
+
id: 'login',
|
|
132
|
+
name: 'login',
|
|
133
|
+
path: '/login',
|
|
134
|
+
auth: 'guest',
|
|
135
|
+
authz: 'skip',
|
|
136
|
+
source: 'application',
|
|
137
|
+
packageName: 'test',
|
|
138
|
+
componentLoader: async () => ({
|
|
139
|
+
default: () => <p>Login route</p>,
|
|
140
|
+
}),
|
|
141
|
+
},
|
|
142
|
+
]}
|
|
143
|
+
/>
|
|
144
|
+
</MemoryRouter>
|
|
145
|
+
</AuthorizationProvider>
|
|
146
|
+
</AuthenticationProvider>
|
|
147
|
+
</ClientApplicationContext.Provider>
|
|
148
|
+
</TestI18nProvider>,
|
|
149
|
+
);
|
|
150
|
+
await act(async () => {});
|
|
151
|
+
// Register after preparation to prove ordering does not depend on effects.
|
|
152
|
+
listeners.add(() => authz.invalidate());
|
|
153
|
+
return {
|
|
154
|
+
allowed,
|
|
155
|
+
request,
|
|
156
|
+
authz,
|
|
157
|
+
realtime,
|
|
158
|
+
signOut,
|
|
159
|
+
getSession,
|
|
160
|
+
mounted,
|
|
161
|
+
cleaned,
|
|
162
|
+
accepted,
|
|
163
|
+
old,
|
|
164
|
+
open: async () => {
|
|
165
|
+
await act(async () => {
|
|
166
|
+
for (const listener of listeners) listener();
|
|
167
|
+
});
|
|
168
|
+
},
|
|
169
|
+
logout: async () => {
|
|
170
|
+
session = null;
|
|
171
|
+
await act(async () => refresh());
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
it('mounts only after the initial handshake and preserves runtime fail-closed portal cleanup', async () => {
|
|
177
|
+
const probe = await setup();
|
|
178
|
+
expect(probe.mounted).not.toHaveBeenCalled();
|
|
179
|
+
expect(probe.request).not.toHaveBeenCalled();
|
|
180
|
+
await probe.open();
|
|
181
|
+
await screen.findByText('Protected dialog');
|
|
182
|
+
expect(probe.mounted).toHaveBeenCalledOnce();
|
|
183
|
+
expect(probe.request).toHaveBeenCalledOnce();
|
|
184
|
+
const next = Promise.withResolvers<typeof probe.allowed>();
|
|
185
|
+
probe.request.mockReturnValueOnce(next.promise);
|
|
186
|
+
await probe.open();
|
|
187
|
+
expect(screen.queryByRole('dialog')).toBeNull();
|
|
188
|
+
expect(probe.cleaned).toHaveBeenCalledOnce();
|
|
189
|
+
await act(async () => probe.old.resolve());
|
|
190
|
+
expect(probe.accepted).not.toHaveBeenCalled();
|
|
191
|
+
await act(async () =>
|
|
192
|
+
next.resolve({ data: { unrestricted: false, permissions: [] } }),
|
|
193
|
+
);
|
|
194
|
+
expect(screen.queryByRole('dialog')).toBeNull();
|
|
195
|
+
expect(await screen.findByText('Access denied')).toBeVisible();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('keeps a timed-out session closed, supports retry, and reports sign-out failure', async () => {
|
|
199
|
+
vi.useFakeTimers();
|
|
200
|
+
const probe = await setup();
|
|
201
|
+
await act(async () => vi.advanceTimersByTime(10_000));
|
|
202
|
+
expect(screen.getByText('Unable to connect')).toBeVisible();
|
|
203
|
+
fireEvent.click(screen.getByRole('button', { name: 'Sign out' }));
|
|
204
|
+
await act(async () => {});
|
|
205
|
+
expect(screen.getByRole('alert')).toHaveTextContent('Unable to sign out');
|
|
206
|
+
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
|
|
207
|
+
expect(probe.realtime.reconnect).toHaveBeenCalledTimes(2);
|
|
208
|
+
expect(screen.queryByText('Unable to connect')).toBeNull();
|
|
209
|
+
await probe.open();
|
|
210
|
+
expect(probe.mounted).toHaveBeenCalledOnce();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('can sign out of a failed initialization without a working realtime connection', async () => {
|
|
214
|
+
vi.useFakeTimers();
|
|
215
|
+
const probe = await setup();
|
|
216
|
+
await act(async () => vi.advanceTimersByTime(10_000));
|
|
217
|
+
probe.signOut.mockResolvedValueOnce({});
|
|
218
|
+
probe.getSession.mockResolvedValueOnce({ data: null });
|
|
219
|
+
fireEvent.click(screen.getByRole('button', { name: 'Sign out' }));
|
|
220
|
+
await act(async () => {});
|
|
221
|
+
expect(screen.getByText('Login route')).toBeVisible();
|
|
222
|
+
expect(probe.mounted).not.toHaveBeenCalled();
|
|
223
|
+
});
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
setPermission,
|
|
4
|
+
setPermissions,
|
|
5
|
+
permissionSections,
|
|
6
|
+
setScope,
|
|
7
|
+
type CatalogResource,
|
|
8
|
+
} from '../../client/pages/settings/permissions-model.js';
|
|
9
|
+
const resource: CatalogResource = {
|
|
10
|
+
type: 'composite',
|
|
11
|
+
id: 'orders',
|
|
12
|
+
title: 'Orders',
|
|
13
|
+
actions: [{ name: 'edit', title: 'Edit' }],
|
|
14
|
+
dataScopes: {
|
|
15
|
+
edit: [
|
|
16
|
+
{
|
|
17
|
+
key: 'records',
|
|
18
|
+
title: 'Records',
|
|
19
|
+
collection: 'orders',
|
|
20
|
+
fields: [],
|
|
21
|
+
recordAccess: ['mine', 'all'],
|
|
22
|
+
defaultValue: 'mine',
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
describe('settings permission drafts', () => {
|
|
28
|
+
it('adds only the explicit action at its registered default and keeps unrelated policies intact', () => {
|
|
29
|
+
const untouched = {
|
|
30
|
+
resource: { type: 'settings', id: 'notification' },
|
|
31
|
+
actions: [{ action: 'read', policy: { type: 'custom', region: 'east' } }],
|
|
32
|
+
};
|
|
33
|
+
const original = [untouched];
|
|
34
|
+
const next = setPermission(original, resource, 'edit', true);
|
|
35
|
+
expect(next).toContainEqual(untouched);
|
|
36
|
+
expect(next[1]?.actions).toEqual([
|
|
37
|
+
{
|
|
38
|
+
action: 'edit',
|
|
39
|
+
policy: { type: 'composite', scopes: { records: 'mine' } },
|
|
40
|
+
},
|
|
41
|
+
]);
|
|
42
|
+
expect(original).toEqual([untouched]);
|
|
43
|
+
expect(setPermission(next, resource, 'edit', false)).toEqual([untouched]);
|
|
44
|
+
});
|
|
45
|
+
it('changes one scope without losing a different scope or custom grant', () => {
|
|
46
|
+
const original = [
|
|
47
|
+
{
|
|
48
|
+
resource: { type: 'composite', id: 'orders' },
|
|
49
|
+
actions: [
|
|
50
|
+
{
|
|
51
|
+
action: 'edit',
|
|
52
|
+
policy: {
|
|
53
|
+
type: 'composite',
|
|
54
|
+
scopes: {
|
|
55
|
+
records: 'mine',
|
|
56
|
+
attachments: { selected: ['file-1'] },
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
{ action: 'approve' },
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
const next = setScope(
|
|
65
|
+
original,
|
|
66
|
+
JSON.stringify(['composite', 'orders', 'edit', 'records']),
|
|
67
|
+
'all',
|
|
68
|
+
);
|
|
69
|
+
expect(next[0]?.actions[0]?.policy).toMatchObject({
|
|
70
|
+
scopes: { records: 'all', attachments: { selected: ['file-1'] } },
|
|
71
|
+
});
|
|
72
|
+
expect(next[0]?.actions[1]).toEqual({ action: 'approve' });
|
|
73
|
+
expect(original[0]?.actions[0]?.policy.scopes.records).toBe('mine');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('keeps action scopes inline and folds a visible batch without resetting existing policies', () => {
|
|
78
|
+
const catalog = {
|
|
79
|
+
sections: [
|
|
80
|
+
{
|
|
81
|
+
name: 'business',
|
|
82
|
+
title: 'Business',
|
|
83
|
+
subsections: [
|
|
84
|
+
{
|
|
85
|
+
name: 'orders',
|
|
86
|
+
title: 'Orders',
|
|
87
|
+
resources: [
|
|
88
|
+
{
|
|
89
|
+
...resource,
|
|
90
|
+
actions: [
|
|
91
|
+
...resource.actions,
|
|
92
|
+
{ name: 'export', title: 'Export' },
|
|
93
|
+
],
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
},
|
|
99
|
+
],
|
|
100
|
+
recordAccess: [
|
|
101
|
+
{ key: 'mine', title: 'Mine' },
|
|
102
|
+
{ key: 'all', title: 'All' },
|
|
103
|
+
],
|
|
104
|
+
} as unknown as Parameters<typeof permissionSections>[0];
|
|
105
|
+
const unrelated = {
|
|
106
|
+
resource: { type: 'unknown', id: 'legacy' },
|
|
107
|
+
actions: [{ action: 'custom', policy: { type: 'custom', keep: true } }],
|
|
108
|
+
};
|
|
109
|
+
const original = [...setPermission([], resource, 'edit', true), unrelated];
|
|
110
|
+
const before = JSON.stringify(original);
|
|
111
|
+
const changed = setPermissions(original, catalog, [
|
|
112
|
+
{ id: JSON.stringify(['composite', 'orders', 'export']), enabled: true },
|
|
113
|
+
{ id: JSON.stringify(['composite', 'orders', 'edit']), enabled: true },
|
|
114
|
+
{
|
|
115
|
+
id: JSON.stringify(['composite', 'orders', 'notRegistered']),
|
|
116
|
+
enabled: true,
|
|
117
|
+
},
|
|
118
|
+
]);
|
|
119
|
+
expect(changed).toContainEqual(unrelated);
|
|
120
|
+
expect(changed[0]?.actions).toEqual([
|
|
121
|
+
...original[0]!.actions,
|
|
122
|
+
{ action: 'export' },
|
|
123
|
+
]);
|
|
124
|
+
expect(JSON.stringify(original)).toBe(before);
|
|
125
|
+
const result = permissionSections(catalog, changed, (value) =>
|
|
126
|
+
typeof value === 'string' ? value : value.key,
|
|
127
|
+
);
|
|
128
|
+
expect(result[0]?.groups[0]?.rows).toHaveLength(2);
|
|
129
|
+
expect(result[0]?.groups[0]?.rows[0]?.scopes?.[0]).toMatchObject({
|
|
130
|
+
label: 'Records',
|
|
131
|
+
level: 'mine',
|
|
132
|
+
id: JSON.stringify(['composite', 'orders', 'edit', 'records']),
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('replaces a raw collection scope caption with localized data-range text', () => {
|
|
137
|
+
const scopedResource = {
|
|
138
|
+
...resource,
|
|
139
|
+
dataScopes: {
|
|
140
|
+
edit: [{ ...resource.dataScopes!.edit![0]!, title: 'orders' }],
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
const catalog = {
|
|
144
|
+
sections: [
|
|
145
|
+
{
|
|
146
|
+
name: 's',
|
|
147
|
+
title: 'Business',
|
|
148
|
+
subsections: [
|
|
149
|
+
{ name: 'orders', title: 'Orders', resources: [scopedResource] },
|
|
150
|
+
],
|
|
151
|
+
},
|
|
152
|
+
],
|
|
153
|
+
recordAccess: [{ key: 'mine', title: 'Mine' }],
|
|
154
|
+
} as unknown as Parameters<typeof permissionSections>[0];
|
|
155
|
+
const result = permissionSections(
|
|
156
|
+
catalog,
|
|
157
|
+
setPermission([], scopedResource, 'edit', true),
|
|
158
|
+
(value) =>
|
|
159
|
+
typeof value === 'string'
|
|
160
|
+
? value
|
|
161
|
+
: value.key === 'settings.recordScope'
|
|
162
|
+
? 'Record scope'
|
|
163
|
+
: value.key,
|
|
164
|
+
);
|
|
165
|
+
expect(result[0]?.groups[0]?.rows[0]?.scopes?.[0]?.label).toBe(
|
|
166
|
+
'Record scope',
|
|
167
|
+
);
|
|
168
|
+
});
|
package/vite.config.ts
CHANGED
|
@@ -1,19 +1,11 @@
|
|
|
1
1
|
import { createAppViteConfig } from '@nocobase/dev-config/vite/app';
|
|
2
2
|
import { loadAppVitePlugins } from '@nocobase/dev-config/vite/plugins';
|
|
3
|
-
import agentAnnotations from '@gchust/agent-annotations/vite';
|
|
4
3
|
import path from 'path';
|
|
5
4
|
import {
|
|
6
5
|
createDevClientConfigPlugin,
|
|
7
6
|
createDevProxy,
|
|
8
7
|
} from '@nocobase/app-cli/dev/proxy';
|
|
9
8
|
|
|
10
|
-
const AGENT_ANNOTATIONS_DISABLED_VALUES = new Set(['false', '0', 'no', 'off']);
|
|
11
|
-
|
|
12
|
-
function isAgentAnnotationsEnabled(value: string | undefined): boolean {
|
|
13
|
-
const normalized = value?.trim().toLowerCase();
|
|
14
|
-
return !normalized || !AGENT_ANNOTATIONS_DISABLED_VALUES.has(normalized);
|
|
15
|
-
}
|
|
16
|
-
|
|
17
9
|
const numberFromEnv = (value: string | undefined): number | undefined => {
|
|
18
10
|
const parsed = Number(value);
|
|
19
11
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
|
@@ -32,9 +24,6 @@ export default createAppViteConfig(async (environment) => {
|
|
|
32
24
|
command === 'serve'
|
|
33
25
|
? createDevClientConfigPlugin(appBase, env.PROXY_TARGET_URL)
|
|
34
26
|
: undefined;
|
|
35
|
-
const annotationsEnabled = isAgentAnnotationsEnabled(
|
|
36
|
-
env.AGENT_ANNOTATIONS_ENABLED,
|
|
37
|
-
);
|
|
38
27
|
const viteHmrHost = env.APP_VITE_HMR_HOST;
|
|
39
28
|
const viteDevPort = numberFromEnv(env.APP_VITE_DEV_PORT) ?? 5173;
|
|
40
29
|
|
|
@@ -44,16 +33,6 @@ export default createAppViteConfig(async (environment) => {
|
|
|
44
33
|
plugins: [
|
|
45
34
|
...(await loadAppVitePlugins({ appRoot: __dirname, environment })),
|
|
46
35
|
...(devClientConfig ? [devClientConfig] : []),
|
|
47
|
-
...(annotationsEnabled
|
|
48
|
-
? [
|
|
49
|
-
agentAnnotations({
|
|
50
|
-
root: __dirname,
|
|
51
|
-
clientExtensions: [
|
|
52
|
-
path.resolve(__dirname, 'client/agent-annotations-host.ts'),
|
|
53
|
-
],
|
|
54
|
-
}),
|
|
55
|
-
]
|
|
56
|
-
: []),
|
|
57
36
|
],
|
|
58
37
|
server: {
|
|
59
38
|
proxy:
|
|
@@ -62,7 +41,6 @@ export default createAppViteConfig(async (environment) => {
|
|
|
62
41
|
: undefined,
|
|
63
42
|
watch: {
|
|
64
43
|
ignored: [
|
|
65
|
-
'**/.agent-annotations/**',
|
|
66
44
|
'**/storage/**',
|
|
67
45
|
// Vite only excludes dist/client automatically. The server build and
|
|
68
46
|
// vendored packages also live in dist and can exhaust file watchers.
|
package/vitest.config.ts
CHANGED
|
@@ -15,6 +15,7 @@ export default createReactVitestConfig({
|
|
|
15
15
|
// together with every client that resolves the authentication tokens and provider.
|
|
16
16
|
/@nocobase\/app-plugin-(?:authentication|api-keys|users)\/(?:dist\/)?client\//u,
|
|
17
17
|
/@nocobase\/app-plugin-authorization\/(?:dist\/)?client\//u,
|
|
18
|
+
/@nocobase\/app-plugin-(?:lifecycle-example|office-flows-example)\/(?:dist\/)?client\//u,
|
|
18
19
|
/@nocobase\/app-plugin-notification-in-app\/(?:dist\/)?client\//u,
|
|
19
20
|
],
|
|
20
21
|
},
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
import { defineClientExtension } from '@gchust/agent-annotations/extension';
|
|
2
|
-
|
|
3
|
-
export default defineClientExtension({
|
|
4
|
-
id: 'nocobase.theme',
|
|
5
|
-
apiVersion: 1,
|
|
6
|
-
host: {
|
|
7
|
-
theme: () =>
|
|
8
|
-
document.documentElement.classList.contains('dark') ? 'dark' : 'light',
|
|
9
|
-
brandColor: () => {
|
|
10
|
-
const primary = getComputedStyle(document.documentElement)
|
|
11
|
-
.getPropertyValue('--primary')
|
|
12
|
-
.trim();
|
|
13
|
-
// The annotation host accepts only #RRGGBB; custom themes may use other CSS color formats.
|
|
14
|
-
return /^#[\da-f]{6}$/i.test(primary) ? primary : '#002ad1';
|
|
15
|
-
},
|
|
16
|
-
subscribe(listener) {
|
|
17
|
-
const observer = new MutationObserver(listener);
|
|
18
|
-
observer.observe(document.documentElement, {
|
|
19
|
-
attributeFilter: ['class', 'data-theme'],
|
|
20
|
-
attributes: true,
|
|
21
|
-
});
|
|
22
|
-
return () => observer.disconnect();
|
|
23
|
-
},
|
|
24
|
-
},
|
|
25
|
-
});
|