@nocobase/app-template-default 1.0.0-beta.72 → 1.0.0-beta.74
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.md +1 -1
- package/README.MD +2 -0
- package/client/components/default-access-editor.tsx +183 -0
- package/client/components/permission-editor.tsx +216 -150
- package/client/components/role-list.tsx +61 -0
- package/client/lib/navigation-transform.ts +28 -1
- package/client/locales/system/settings-en-US.ts +73 -2
- package/client/locales/system/settings-zh-CN.ts +65 -2
- package/client/pages/settings/default-access-form.tsx +499 -0
- package/client/pages/settings/default-access-model.ts +154 -0
- package/client/pages/settings/default-access.tsx +407 -0
- package/client/pages/settings/permission-filters.tsx +43 -37
- package/client/pages/settings/permission-management-page.tsx +105 -0
- package/client/pages/settings/permissions-model.ts +37 -1
- package/client/pages/settings/role-form.tsx +46 -18
- package/client/pages/settings/roles.tsx +23 -43
- package/client/pages/settings/routes.ts +78 -19
- package/client/pages/settings/shared.tsx +3 -0
- package/client/pages/settings/users.tsx +1 -1
- package/client/routing/route-navigation.ts +21 -1
- package/package.json +22 -21
- package/tests/components/default-access.test.tsx +554 -0
- package/tests/components/page-harness.test.tsx +132 -112
- package/tests/components/permission-editor.test.tsx +233 -73
- package/tests/components/permission-management-tabs.test.tsx +128 -0
- package/tests/components/role-list.test.tsx +71 -0
- package/tests/components/roles.test.tsx +78 -0
- package/tests/components/user-permissions.test.tsx +12 -0
- package/tests/logic/client-routes.test.ts +8 -0
- package/tests/logic/default-access-model.test.ts +167 -0
- package/tests/logic/permission-management-routes.test.ts +64 -0
- package/tests/logic/settings-permissions.test.ts +89 -0
- package/vitest.config.ts +7 -6
|
@@ -2,58 +2,53 @@
|
|
|
2
2
|
// `useCan`, formats with `useLocale`, raises a toast and opens a child-route `RouteDialog`. The page under test is
|
|
3
3
|
// defined in this file so the template has one to run; in an application, import the real page and its child routes
|
|
4
4
|
// instead and keep the setup below.
|
|
5
|
-
import { ApiClientError, useApiClient, useToaster } from '@nocobase/app-client';
|
|
6
|
-
import { useAuthentication } from '@nocobase/app-plugin-authentication/client';
|
|
7
|
-
import { useCan } from '@nocobase/app-plugin-authorization/client';
|
|
8
|
-
import { useLocale, useTranslation } from '@nocobase/i18n/client';
|
|
9
5
|
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
6
|
+
apiClientToken,
|
|
7
|
+
ApiClientError,
|
|
8
|
+
useApiClient,
|
|
9
|
+
useToaster,
|
|
10
|
+
} from '@nocobase/app-client';
|
|
11
|
+
import {
|
|
12
|
+
AuthenticationProvider,
|
|
13
|
+
authenticationClientToken,
|
|
14
|
+
useAuthentication,
|
|
15
|
+
} from '@nocobase/app-plugin-authentication/client';
|
|
16
|
+
import {
|
|
17
|
+
AuthorizationClient,
|
|
18
|
+
authorizationClientToken,
|
|
19
|
+
useCan,
|
|
20
|
+
type AuthorizationSnapshot,
|
|
21
|
+
} from '@nocobase/app-plugin-authorization/client';
|
|
22
|
+
import {
|
|
23
|
+
answerApi,
|
|
24
|
+
renderWithApp,
|
|
25
|
+
type ApiCall,
|
|
26
|
+
} from '@nocobase/app-testing/client';
|
|
27
|
+
import { useLocale, useTranslation } from '@nocobase/i18n/client';
|
|
28
|
+
import { act, screen, waitFor } from '@testing-library/react';
|
|
14
29
|
import userEvent from '@testing-library/user-event';
|
|
15
|
-
import { type ReactElement,
|
|
16
|
-
import {
|
|
30
|
+
import { type ReactElement, useEffect, useState } from 'react';
|
|
31
|
+
import { Link, Outlet, Route, Routes, useLocation } from 'react-router';
|
|
17
32
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
18
33
|
|
|
19
34
|
import { RouteDialog } from '#components/route-dialog';
|
|
20
35
|
import { Button } from '#components/ui/button';
|
|
21
36
|
import { useRouteOverlay } from '#components/use-route-overlay';
|
|
22
37
|
|
|
38
|
+
import packageMetadata from '../../package.json' with { type: 'json' };
|
|
23
39
|
import enUS from '../../client/locales/en-US.js';
|
|
24
40
|
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
},
|
|
37
|
-
refresh: vi.fn(),
|
|
38
|
-
}));
|
|
39
|
-
|
|
40
|
-
vi.mock('@nocobase/app-client', async (original) => ({
|
|
41
|
-
// Keep the real module, so `ApiClientError` stays the class the page checks with `instanceof`.
|
|
42
|
-
...(await original<typeof import('@nocobase/app-client')>()),
|
|
43
|
-
useApiClient: () => api,
|
|
44
|
-
useToaster: () => toaster,
|
|
45
|
-
}));
|
|
46
|
-
vi.mock('@nocobase/app-plugin-authentication/client', () => ({
|
|
47
|
-
useAuthentication: () => ({ refresh }),
|
|
48
|
-
}));
|
|
49
|
-
vi.mock('@nocobase/app-plugin-authorization/client', () => ({
|
|
50
|
-
useCan: () => ({
|
|
51
|
-
can: permission.can,
|
|
52
|
-
isPending: permission.isPending,
|
|
53
|
-
error: permission.error,
|
|
54
|
-
retry: vi.fn(),
|
|
55
|
-
}),
|
|
56
|
-
}));
|
|
41
|
+
// Substitute the authentication service at its token, keeping the real provider and hooks.
|
|
42
|
+
// Authentication itself is covered by server tests that sign in through the application's real routes.
|
|
43
|
+
const getSession = vi.fn(async () => ({ data: null }));
|
|
44
|
+
let permissions: AuthorizationSnapshot;
|
|
45
|
+
let itemsResponse: unknown;
|
|
46
|
+
const api = vi.fn(({ method, path }: ApiCall) => {
|
|
47
|
+
if (method === 'GET' && path === 'authorization/permissions')
|
|
48
|
+
return { data: permissions };
|
|
49
|
+
if (method === 'GET' && path === 'items') return itemsResponse;
|
|
50
|
+
return new Response(null, { status: 404 });
|
|
51
|
+
});
|
|
57
52
|
|
|
58
53
|
// The inline page's own copy. A real page finds its keys in `client/locales/`; this one brings them, merged over the
|
|
59
54
|
// application's so the dialog's close button and the shared `status.*` keys resolve as they would there.
|
|
@@ -74,18 +69,6 @@ const copy = {
|
|
|
74
69
|
},
|
|
75
70
|
};
|
|
76
71
|
|
|
77
|
-
// The real runtime, strict: a key the resources lack fails the test instead of rendering as text.
|
|
78
|
-
const runtime = await createTestI18nRuntime({
|
|
79
|
-
application: {
|
|
80
|
-
namespace: '@nocobase/app-template-default',
|
|
81
|
-
resources: { ...enUS, ...copy },
|
|
82
|
-
},
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
function I18n({ children }: { readonly children: ReactNode }): ReactElement {
|
|
86
|
-
return <TestI18nProvider runtime={runtime}>{children}</TestI18nProvider>;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
72
|
interface Item {
|
|
90
73
|
readonly id: number;
|
|
91
74
|
readonly name: string;
|
|
@@ -130,7 +113,7 @@ function ItemsPage(): ReactElement {
|
|
|
130
113
|
content = (
|
|
131
114
|
<p role='alert'>
|
|
132
115
|
{t('status.sessionExpired')}
|
|
133
|
-
<Button onClick={() =>
|
|
116
|
+
<Button onClick={() => refreshSession()}>
|
|
134
117
|
{t('actions.signInAgain')}
|
|
135
118
|
</Button>
|
|
136
119
|
</p>
|
|
@@ -178,7 +161,7 @@ function NewItemFooter(): ReactElement {
|
|
|
178
161
|
<Button
|
|
179
162
|
onClick={() => {
|
|
180
163
|
toaster.show({ type: 'success', title: t('items.create.success') });
|
|
181
|
-
|
|
164
|
+
close();
|
|
182
165
|
}}
|
|
183
166
|
>
|
|
184
167
|
{t('actions.create')}
|
|
@@ -195,56 +178,88 @@ function NewItemPage(): ReactElement {
|
|
|
195
178
|
);
|
|
196
179
|
}
|
|
197
180
|
|
|
198
|
-
/**
|
|
199
|
-
function
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
181
|
+
/** renderWithApp owns the router; declare the page and its child routes inside it. */
|
|
182
|
+
function LocationProbe(): ReactElement {
|
|
183
|
+
const location = useLocation();
|
|
184
|
+
return <span data-testid='location'>{location.pathname}</span>;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function renderAt(route: string) {
|
|
188
|
+
return renderWithApp(
|
|
189
|
+
<AuthenticationProvider>
|
|
190
|
+
<LocationProbe />
|
|
191
|
+
<Routes>
|
|
192
|
+
<Route path='/items' element={<ItemsPage />}>
|
|
193
|
+
<Route path='new' element={<NewItemPage />} />
|
|
194
|
+
</Route>
|
|
195
|
+
</Routes>
|
|
196
|
+
</AuthenticationProvider>,
|
|
197
|
+
{
|
|
198
|
+
route,
|
|
199
|
+
namespace: packageMetadata.name,
|
|
200
|
+
namespaces: { [packageMetadata.name]: { ...enUS, ...copy } },
|
|
201
|
+
fetch: answerApi(api),
|
|
202
|
+
// These services replace the corresponding plugins for this focused page test. Do not register both.
|
|
203
|
+
services: (app) => {
|
|
204
|
+
app.container.instance(authenticationClientToken, { getSession });
|
|
205
|
+
app.container.singleton(
|
|
206
|
+
authorizationClientToken,
|
|
207
|
+
(resolver) =>
|
|
208
|
+
new AuthorizationClient(resolver.resolve(apiClientToken)),
|
|
209
|
+
);
|
|
206
210
|
},
|
|
207
|
-
|
|
208
|
-
{ initialEntries: [url] },
|
|
211
|
+
},
|
|
209
212
|
);
|
|
210
|
-
render(<RouterProvider router={router} />, { wrapper: I18n });
|
|
211
|
-
return router;
|
|
212
213
|
}
|
|
213
214
|
|
|
214
215
|
describe('page test harness', () => {
|
|
215
216
|
beforeEach(() => {
|
|
216
|
-
api.
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
217
|
+
api.mockClear();
|
|
218
|
+
getSession.mockClear();
|
|
219
|
+
permissions = {
|
|
220
|
+
unrestricted: false,
|
|
221
|
+
permissions: [
|
|
222
|
+
{ resource: { type: 'composite', id: 'items' }, actions: ['create'] },
|
|
223
|
+
],
|
|
224
|
+
};
|
|
225
|
+
itemsResponse = { data: [] };
|
|
222
226
|
});
|
|
223
227
|
|
|
224
228
|
it('loads the list with the request the page is expected to send', async () => {
|
|
225
|
-
|
|
229
|
+
itemsResponse = {
|
|
226
230
|
data: [{ id: 1, name: 'Alpha', updatedAt: '2026-01-02T00:00:00Z' }],
|
|
227
|
-
}
|
|
228
|
-
renderAt('/items');
|
|
231
|
+
};
|
|
232
|
+
await renderAt('/items');
|
|
229
233
|
|
|
230
|
-
expect(screen.getByRole('status')).toHaveTextContent(
|
|
231
|
-
enUS['status.loading'],
|
|
232
|
-
);
|
|
233
234
|
expect(await screen.findByText(/Alpha/)).toBeInTheDocument();
|
|
234
|
-
expect(api
|
|
235
|
-
|
|
236
|
-
|
|
235
|
+
expect(api).toHaveBeenCalledWith({ method: 'GET', path: 'items' });
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('shows loading until the API answers', async () => {
|
|
239
|
+
const response = Promise.withResolvers<unknown>();
|
|
240
|
+
itemsResponse = response.promise;
|
|
241
|
+
await renderAt('/items');
|
|
242
|
+
|
|
243
|
+
try {
|
|
244
|
+
expect(screen.getByRole('status')).toHaveTextContent(
|
|
245
|
+
enUS['status.loading'],
|
|
246
|
+
);
|
|
247
|
+
} finally {
|
|
248
|
+
await act(() => {
|
|
249
|
+
response.resolve({
|
|
250
|
+
data: [{ id: 1, name: 'Alpha', updatedAt: '2026-01-02T00:00:00Z' }],
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
expect(await screen.findByText(/Alpha/)).toBeInTheDocument();
|
|
237
255
|
});
|
|
238
256
|
|
|
239
257
|
it('explains a 403 without offering a retry', async () => {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
method: 'GET',
|
|
244
|
-
url: '/api/items',
|
|
245
|
-
}),
|
|
258
|
+
itemsResponse = Response.json(
|
|
259
|
+
{ error: { message: 'Forbidden' } },
|
|
260
|
+
{ status: 403 },
|
|
246
261
|
);
|
|
247
|
-
renderAt('/items');
|
|
262
|
+
await renderAt('/items');
|
|
248
263
|
|
|
249
264
|
expect(await screen.findByRole('alert')).toHaveTextContent(
|
|
250
265
|
copy.items.error.forbidden,
|
|
@@ -255,14 +270,11 @@ describe('page test harness', () => {
|
|
|
255
270
|
});
|
|
256
271
|
|
|
257
272
|
it('treats a 401 as an ended session, not as a failure to retry', async () => {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
method: 'GET',
|
|
262
|
-
url: '/api/items',
|
|
263
|
-
}),
|
|
273
|
+
itemsResponse = Response.json(
|
|
274
|
+
{ error: { message: 'Unauthorized' } },
|
|
275
|
+
{ status: 401 },
|
|
264
276
|
);
|
|
265
|
-
renderAt('/items');
|
|
277
|
+
await renderAt('/items');
|
|
266
278
|
|
|
267
279
|
expect(await screen.findByRole('alert')).toHaveTextContent(
|
|
268
280
|
copy['status.sessionExpired'],
|
|
@@ -270,36 +282,40 @@ describe('page test harness', () => {
|
|
|
270
282
|
expect(
|
|
271
283
|
screen.queryByRole('button', { name: enUS['status.retry'] }),
|
|
272
284
|
).not.toBeInTheDocument();
|
|
285
|
+
await waitFor(() => expect(getSession).toHaveBeenCalledTimes(1));
|
|
273
286
|
await userEvent.click(
|
|
274
287
|
screen.getByRole('button', { name: copy.actions.signInAgain }),
|
|
275
288
|
);
|
|
276
|
-
expect(
|
|
289
|
+
expect(getSession).toHaveBeenCalledTimes(2);
|
|
277
290
|
});
|
|
278
291
|
|
|
279
292
|
it('shows the create action only with permission', async () => {
|
|
280
|
-
|
|
281
|
-
renderAt('/items');
|
|
293
|
+
await renderAt('/items');
|
|
282
294
|
|
|
283
|
-
// A Button rendered as a Link
|
|
295
|
+
// A Button rendered as a Link is announced as a button.
|
|
284
296
|
expect(
|
|
285
297
|
await screen.findByRole('button', { name: copy.items.create.action }),
|
|
286
298
|
).toHaveAttribute('href', '/items/new');
|
|
299
|
+
expect(api).toHaveBeenCalledWith({
|
|
300
|
+
method: 'GET',
|
|
301
|
+
path: 'authorization/permissions',
|
|
302
|
+
});
|
|
287
303
|
});
|
|
288
304
|
|
|
289
305
|
it('hides the create action without permission', async () => {
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
renderAt('/items');
|
|
306
|
+
permissions = { unrestricted: false, permissions: [] };
|
|
307
|
+
const { app } = await renderAt('/items');
|
|
293
308
|
|
|
294
|
-
await
|
|
309
|
+
await act(async () => {
|
|
310
|
+
await app.container.resolve(authorizationClientToken).snapshot();
|
|
311
|
+
});
|
|
295
312
|
expect(
|
|
296
313
|
screen.queryByRole('button', { name: copy.items.create.action }),
|
|
297
314
|
).not.toBeInTheDocument();
|
|
298
315
|
});
|
|
299
316
|
|
|
300
317
|
it('opens the create dialog from its URL and closes it back to the list', async () => {
|
|
301
|
-
|
|
302
|
-
const router = renderAt('/items/new');
|
|
318
|
+
const view = await renderAt('/items/new');
|
|
303
319
|
|
|
304
320
|
expect(
|
|
305
321
|
await screen.findByRole('dialog', { name: copy.items.create.title }),
|
|
@@ -308,11 +324,15 @@ describe('page test harness', () => {
|
|
|
308
324
|
screen.getByRole('button', { name: copy.actions.create }),
|
|
309
325
|
);
|
|
310
326
|
|
|
311
|
-
expect(
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
327
|
+
expect(view.toasts()).toEqual([
|
|
328
|
+
expect.objectContaining({
|
|
329
|
+
type: 'success',
|
|
330
|
+
title: copy.items.create.success,
|
|
331
|
+
}),
|
|
332
|
+
]);
|
|
333
|
+
await waitFor(() =>
|
|
334
|
+
expect(screen.getByTestId('location')).toHaveTextContent(/^\/items$/),
|
|
335
|
+
);
|
|
316
336
|
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
|
|
317
337
|
});
|
|
318
338
|
});
|