@nocobase/app-template-default 1.0.0-beta.83 → 1.0.0-beta.85
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/rules/build-and-deploy.md +1 -1
- package/.agents/rules/shell.md +4 -0
- package/AGENTS.md +8 -2
- package/README.MD +10 -2
- package/client/components/back-button.tsx +3 -5
- package/client/components/data-table/data-table.tsx +86 -32
- package/client/components/default-access-editor.tsx +13 -2
- package/client/components/feedback-form.tsx +474 -0
- package/client/components/lifecycle-diagram.tsx +10 -10
- package/client/components/list-feedback.tsx +174 -0
- package/client/components/permission-editor.tsx +419 -226
- package/client/extensions/nocobase-auth-forms/password-login-form.tsx +17 -6
- package/client/extensions/nocobase-auth-forms/password-registration-form.tsx +24 -7
- package/client/extensions/nocobase-auth-forms/password-reset-form.tsx +19 -7
- package/client/extensions/nocobase-auth-forms/password-reset-request-form.tsx +15 -7
- package/client/extensions/nocobase-device-approval/device-approval.tsx +7 -2
- package/client/extensions/nocobase-device-approval/locales/en-US.ts +16 -0
- package/client/extensions/nocobase-device-approval/locales/zh-CN.ts +7 -0
- package/client/extensions/nocobase-file-component-ui/components/file-preview-dialog.tsx +20 -17
- package/client/extensions/nocobase-workflow-management/README.md +3 -1
- package/client/extensions/nocobase-workflow-management/definition-list.tsx +19 -7
- package/client/extensions/nocobase-workflow-management/execution-list.tsx +61 -48
- package/client/hooks/use-list-data.ts +76 -0
- package/client/hooks/use-list-location.ts +23 -0
- package/client/hooks/use-list-tabs.ts +42 -0
- package/client/hooks/use-table-url-state.ts +143 -30
- package/client/locales/system/en-US.ts +24 -1
- package/client/locales/system/settings-en-US.ts +8 -10
- package/client/locales/system/settings-zh-CN.ts +8 -8
- package/client/locales/system/zh-CN.ts +22 -1
- package/client/pages/settings/default-access-form.tsx +44 -26
- package/client/pages/settings/default-access.tsx +100 -93
- package/client/pages/settings/hooks.tsx +4 -59
- package/client/pages/settings/page-permissions.ts +17 -0
- package/client/pages/settings/permissions-model.ts +54 -59
- package/client/pages/settings/role-form.tsx +98 -56
- package/client/pages/settings/roles.tsx +69 -95
- package/client/pages/settings/shared.tsx +43 -33
- package/client/pages/settings/user-form.tsx +51 -30
- package/client/pages/settings/users.tsx +105 -91
- package/client/routing/session-preparation.tsx +10 -3
- package/client/runtime.ts +0 -4
- package/config.example.yml +24 -0
- package/database/main/migrations/202611020002_create_mutation_receipts.ts +19 -0
- package/package.json +24 -21
- package/server/app.ts +0 -2
- package/server/config/auth.ts +2 -0
- package/server/config/concurrency.ts +3 -0
- package/server/config/index.ts +3 -0
- package/server/config/jobs.ts +1 -13
- package/server/config/notification.ts +1 -1
- package/server/config/queue.ts +1 -7
- package/server/plugins.ts +2 -0
- package/server/providers/index.ts +4 -1
- package/server/providers/submission.ts +61 -0
- package/tests/client/auth-i18n.test.tsx +9 -0
- package/tests/client/back-button.test.tsx +3 -3
- package/tests/client/data-table.test.tsx +9 -4
- package/tests/client/default-access.test.tsx +99 -5
- package/tests/client/form-feedback.test.tsx +589 -0
- package/tests/client/list-feedback.test.tsx +301 -0
- package/tests/client/list-recovery.test.tsx +373 -0
- package/tests/client/list-tabs.test.tsx +121 -0
- package/tests/client/manual-list-retry.test.tsx +190 -0
- package/tests/client/page-permission-navigation.test.ts +8 -0
- package/tests/client/permission-editor.test.tsx +191 -21
- package/tests/client/permission-filter-binding.test.tsx +1 -1
- package/tests/client/permission-workspace.test.ts +13 -0
- package/tests/client/role-return.test.tsx +2 -1
- package/tests/client/session-preparation.test.tsx +24 -1
- package/tests/client/settings-continuity.test.tsx +85 -5
- package/tests/client/settings-feedback.test.tsx +32 -15
- package/tests/client/settings-permissions.test.ts +35 -0
- package/tests/client/settings.test.tsx +2 -1
- package/tests/client/table-url-state.test.tsx +117 -3
- package/tests/client/user-drawer.test.tsx +2 -1
- package/tests/client/user-permissions.test.tsx +30 -5
- package/tests/database/mutation-receipts.test.ts +45 -0
- package/tests/fixtures/form-feedback/README.md +33 -0
- package/tests/fixtures/form-feedback/equipment-detail.tsx +11 -0
- package/tests/fixtures/form-feedback/equipment-layout.tsx +15 -0
- package/tests/fixtures/form-feedback/equipment-migration.ts +27 -0
- package/tests/fixtures/form-feedback/equipment-server.ts +197 -0
- package/tests/fixtures/form-feedback/equipment-validation.tsx +94 -0
- package/tests/fixtures/form-feedback/equipment.tsx +369 -0
- package/tests/fixtures/form-feedback/verify-inline.mjs +174 -0
- package/tests/fixtures/form-feedback/verify.mjs +373 -0
- package/tests/fixtures/permission-alignment.html +16 -0
- package/tests/fixtures/permission-alignment.tsx +124 -0
- package/tests/fixtures/submission/README.md +35 -0
- package/tests/fixtures/submission/browser.test.ts +478 -0
- package/tests/fixtures/submission/migration.ts +61 -0
- package/tests/fixtures/submission/page.tsx +138 -0
- package/tests/fixtures/submission/playwright.config.ts +11 -0
- package/tests/fixtures/submission/routes.ts +208 -0
- package/tests/helpers/settings-client.tsx +31 -0
- package/tests/playwright/permission-alignment.test.ts +109 -0
- package/tests/server/auth-email-config.test.ts +79 -0
- package/tests/server/concurrency.test.ts +61 -0
- package/tests/server/config.test.ts +34 -14
- package/tests/server/member-role.test.ts +7 -1
- package/tests/server/submission-settings.test.ts +241 -0
- package/client/route-overrides.ts +0 -9
- package/client/source-extensions.ts +0 -19
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { AppClientRegisteredRoute } from '@nocobase/app-client/plugins';
|
|
2
|
+
import { act, render, screen } from '@testing-library/react';
|
|
3
|
+
import userEvent from '@testing-library/user-event';
|
|
4
|
+
import {
|
|
5
|
+
createMemoryRouter,
|
|
6
|
+
Link,
|
|
7
|
+
Navigate,
|
|
8
|
+
Outlet,
|
|
9
|
+
RouterProvider,
|
|
10
|
+
useLocation,
|
|
11
|
+
} from 'react-router';
|
|
12
|
+
import { expect, it } from 'vitest';
|
|
13
|
+
import { useListTabs } from '../../client/hooks/use-list-tabs.js';
|
|
14
|
+
import { RouteTreeProvider } from '../../client/routing/route-context.js';
|
|
15
|
+
|
|
16
|
+
const tabs = [
|
|
17
|
+
{ path: 'active', label: 'Active', dataset: 'active' },
|
|
18
|
+
{ path: 'archived', label: 'Archived', dataset: 'archived' },
|
|
19
|
+
{ path: 'cards', label: 'Cards', dataset: 'active' },
|
|
20
|
+
];
|
|
21
|
+
function Layout() {
|
|
22
|
+
const state = useListTabs({
|
|
23
|
+
tabs,
|
|
24
|
+
parameterKeys: ['q', 'page', 'pageSize', 'sort', 'order'],
|
|
25
|
+
});
|
|
26
|
+
const location = useLocation();
|
|
27
|
+
if (state.fallback) return <Navigate to={state.fallback} replace />;
|
|
28
|
+
return (
|
|
29
|
+
<>
|
|
30
|
+
<output data-testid='tab'>{state.active?.label}</output>
|
|
31
|
+
<output data-testid='url'>
|
|
32
|
+
{location.pathname}
|
|
33
|
+
{location.search}
|
|
34
|
+
</output>
|
|
35
|
+
<output data-testid='source'>{JSON.stringify(state.source)}</output>
|
|
36
|
+
{tabs.map((tab) => (
|
|
37
|
+
<Link key={tab.path} to={state.destination(tab)}>
|
|
38
|
+
{tab.label}
|
|
39
|
+
</Link>
|
|
40
|
+
))}
|
|
41
|
+
<Outlet />
|
|
42
|
+
</>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
function setup(url: string) {
|
|
46
|
+
const router = createMemoryRouter(
|
|
47
|
+
[
|
|
48
|
+
{
|
|
49
|
+
element: (
|
|
50
|
+
<RouteTreeProvider routes={[] as AppClientRegisteredRoute[]}>
|
|
51
|
+
<Outlet />
|
|
52
|
+
</RouteTreeProvider>
|
|
53
|
+
),
|
|
54
|
+
children: [
|
|
55
|
+
{
|
|
56
|
+
path: '/assets',
|
|
57
|
+
element: <Layout />,
|
|
58
|
+
children: [
|
|
59
|
+
...tabs.map((tab) => ({
|
|
60
|
+
path: tab.path,
|
|
61
|
+
element: <Outlet />,
|
|
62
|
+
children: [
|
|
63
|
+
{ index: true, element: <p>Content</p> },
|
|
64
|
+
{ path: ':id', element: <p>Detail</p> },
|
|
65
|
+
],
|
|
66
|
+
})),
|
|
67
|
+
{
|
|
68
|
+
path: ':unavailableTab',
|
|
69
|
+
element: null,
|
|
70
|
+
children: [{ path: ':id', element: null }],
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
{ initialEntries: [url] },
|
|
78
|
+
);
|
|
79
|
+
render(<RouterProvider router={router} />);
|
|
80
|
+
return router;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
it('isolates different datasets and retains compatible display tabs and enclosing context', async () => {
|
|
84
|
+
const router = setup(
|
|
85
|
+
'/assets/active?q=drill&page=3&pageSize=20&sort=name&order=desc&parentFilter=kept',
|
|
86
|
+
);
|
|
87
|
+
await userEvent.click(screen.getByRole('link', { name: 'Cards' }));
|
|
88
|
+
expect(router.state.location.search).toContain('page=3');
|
|
89
|
+
await userEvent.click(screen.getByRole('link', { name: 'Archived' }));
|
|
90
|
+
expect(router.state.location.search).toBe('?parentFilter=kept');
|
|
91
|
+
await act(() => router.navigate(-1));
|
|
92
|
+
expect(screen.getByTestId('tab')).toHaveTextContent('Cards');
|
|
93
|
+
expect(router.state.location.search).toContain('q=drill');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('retains the source tab and its query when a real detail route writes its own query', async () => {
|
|
97
|
+
const router = setup('/assets/archived?q=drill&page=2&sort=name&order=desc');
|
|
98
|
+
await act(() => router.navigate('/assets/archived/42?detailPage=7'));
|
|
99
|
+
expect(screen.getByTestId('tab')).toHaveTextContent('Archived');
|
|
100
|
+
expect(screen.getByTestId('source')).toHaveTextContent(
|
|
101
|
+
'"pathname":"/assets/archived","search":"?q=drill&page=2&sort=name&order=desc"',
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('replaces a removed tab with the first accessible tab without stale dataset parameters', async () => {
|
|
106
|
+
const router = setup(
|
|
107
|
+
'/assets/deleted?page=99&sort=deleted&parentFilter=kept',
|
|
108
|
+
);
|
|
109
|
+
expect(router.state.location.pathname).toBe('/assets/active');
|
|
110
|
+
expect(router.state.location.search).toBe('?parentFilter=kept');
|
|
111
|
+
expect(router.state.historyAction).toBe('REPLACE');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Match the declared detail shape even after its source tab is removed.
|
|
115
|
+
it('returns a removed tab detail to the group default without retaining its page', async () => {
|
|
116
|
+
const router = setup('/assets/deleted/42?page=3&sort=name&order=desc');
|
|
117
|
+
await screen.findByText('Content');
|
|
118
|
+
expect(router.state.location.pathname).toBe('/assets/active');
|
|
119
|
+
expect(router.state.location.search).toBe('');
|
|
120
|
+
expect(screen.getByTestId('tab')).toHaveTextContent('Active');
|
|
121
|
+
});
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { answerApi, renderWithApp } from '@nocobase/app-testing/client';
|
|
3
|
+
import { resolveListState } from '@nocobase/app-client';
|
|
4
|
+
import { act, screen, waitFor } from '@testing-library/react';
|
|
5
|
+
import userEvent from '@testing-library/user-event';
|
|
6
|
+
import { describe, expect, it } from 'vitest';
|
|
7
|
+
import { useSettingsData } from '../../client/pages/settings/hooks.js';
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
ListFeedback,
|
|
11
|
+
ListFeedbackActions,
|
|
12
|
+
} from '../../client/components/list-feedback.js';
|
|
13
|
+
import enUS from '../../client/locales/en-US.js';
|
|
14
|
+
|
|
15
|
+
type Rows = { data: string[] };
|
|
16
|
+
const options = {
|
|
17
|
+
namespace: '@nocobase/manual-list-test',
|
|
18
|
+
namespaces: {
|
|
19
|
+
'@nocobase/manual-list-test': {
|
|
20
|
+
'en-US': async () => ({ default: enUS }),
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
function deferred() {
|
|
25
|
+
let resolve!: (value: Rows | Response) => void;
|
|
26
|
+
const promise = new Promise<Rows | Response>((done) => {
|
|
27
|
+
resolve = done;
|
|
28
|
+
});
|
|
29
|
+
return { promise, resolve };
|
|
30
|
+
}
|
|
31
|
+
function failure(status = 500) {
|
|
32
|
+
return new Response(JSON.stringify({ error: { message: 'Unavailable' } }), {
|
|
33
|
+
status,
|
|
34
|
+
headers: { 'content-type': 'application/json' },
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
const adapters = {
|
|
38
|
+
useSettings: (query: string) =>
|
|
39
|
+
useSettingsData<Rows>(`manual-list?q=${query}&page=3&orderBy=title`),
|
|
40
|
+
};
|
|
41
|
+
function Harness({ kind }: { readonly kind: keyof typeof adapters }) {
|
|
42
|
+
const [query, setQuery] = useState('first');
|
|
43
|
+
const useData = adapters[kind];
|
|
44
|
+
const request = useData(query);
|
|
45
|
+
const state = resolveListState(request, request.data?.data.length ?? 0);
|
|
46
|
+
const useRetryAction = state === 'failed' || state === 'refresh-failed';
|
|
47
|
+
return (
|
|
48
|
+
<>
|
|
49
|
+
<div>
|
|
50
|
+
{useRetryAction ? (
|
|
51
|
+
<ListFeedbackActions
|
|
52
|
+
state={state}
|
|
53
|
+
onRetry={request.reload}
|
|
54
|
+
busy={request.loading}
|
|
55
|
+
/>
|
|
56
|
+
) : (
|
|
57
|
+
<button onClick={request.reload}>Refresh</button>
|
|
58
|
+
)}
|
|
59
|
+
<button onClick={() => setQuery('second')}>Change query</button>
|
|
60
|
+
<ListFeedbackActions state={state} />
|
|
61
|
+
</div>
|
|
62
|
+
<ListFeedback state={state}>
|
|
63
|
+
{request.data?.data.map((row) => (
|
|
64
|
+
<p key={row}>{row}</p>
|
|
65
|
+
))}
|
|
66
|
+
</ListFeedback>
|
|
67
|
+
</>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
describe.each(Object.keys(adapters) as (keyof typeof adapters)[])(
|
|
71
|
+
'%s manual list',
|
|
72
|
+
(kind) => {
|
|
73
|
+
it.each(['success', 'failure'] as const)(
|
|
74
|
+
'retains the stale warning during retry until %s',
|
|
75
|
+
async (outcome) => {
|
|
76
|
+
let response: Rows | Response | Promise<Rows | Response> = {
|
|
77
|
+
data: ['Original row'],
|
|
78
|
+
};
|
|
79
|
+
const calls: string[] = [];
|
|
80
|
+
await renderWithApp(<Harness kind={kind} />, {
|
|
81
|
+
...options,
|
|
82
|
+
fetch: answerApi(({ path, query }) => {
|
|
83
|
+
calls.push(JSON.stringify({ path, query }));
|
|
84
|
+
return response;
|
|
85
|
+
}),
|
|
86
|
+
});
|
|
87
|
+
await screen.findByText('Original row');
|
|
88
|
+
response = failure();
|
|
89
|
+
await userEvent.click(screen.getByRole('button', { name: 'Refresh' }));
|
|
90
|
+
await screen.findByText(enUS.listFeedback.refreshFailed);
|
|
91
|
+
const pending = deferred();
|
|
92
|
+
response = pending.promise;
|
|
93
|
+
await userEvent.click(
|
|
94
|
+
screen.getByRole('button', { name: 'Retry', exact: true }),
|
|
95
|
+
);
|
|
96
|
+
expect(screen.getByRole('alert')).toHaveTextContent(
|
|
97
|
+
enUS.listFeedback.refreshFailed,
|
|
98
|
+
);
|
|
99
|
+
expect(screen.getByText('Original row')).toBeVisible();
|
|
100
|
+
expect(
|
|
101
|
+
screen.getByRole('button', { name: 'Retrying…' }),
|
|
102
|
+
).toBeDisabled();
|
|
103
|
+
expect(calls).toHaveLength(3);
|
|
104
|
+
expect(new Set(calls).size).toBe(1);
|
|
105
|
+
await act(async () =>
|
|
106
|
+
pending.resolve(
|
|
107
|
+
outcome === 'success' ? { data: ['Updated row'] } : failure(),
|
|
108
|
+
),
|
|
109
|
+
);
|
|
110
|
+
if (outcome === 'success') {
|
|
111
|
+
await screen.findByText('Updated row');
|
|
112
|
+
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
|
113
|
+
} else {
|
|
114
|
+
await waitFor(() =>
|
|
115
|
+
expect(
|
|
116
|
+
screen.getByRole('button', { name: 'Retry', exact: true }),
|
|
117
|
+
).toBeEnabled(),
|
|
118
|
+
);
|
|
119
|
+
expect(screen.getByRole('alert')).toHaveTextContent(
|
|
120
|
+
enUS.listFeedback.refreshFailed,
|
|
121
|
+
);
|
|
122
|
+
expect(screen.getByText('Original row')).toBeVisible();
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
);
|
|
126
|
+
it.each([401, 403])(
|
|
127
|
+
'hides cached rows through a pending retry after %s',
|
|
128
|
+
async (status) => {
|
|
129
|
+
let response: Rows | Response | Promise<Rows | Response> = {
|
|
130
|
+
data: ['Private row'],
|
|
131
|
+
};
|
|
132
|
+
await renderWithApp(<Harness kind={kind} />, {
|
|
133
|
+
...options,
|
|
134
|
+
fetch: answerApi(() => response),
|
|
135
|
+
});
|
|
136
|
+
await screen.findByText('Private row');
|
|
137
|
+
response = failure(status);
|
|
138
|
+
await userEvent.click(screen.getByRole('button', { name: 'Refresh' }));
|
|
139
|
+
await screen.findByText(
|
|
140
|
+
status === 401
|
|
141
|
+
? enUS.listFeedback.unauthenticated
|
|
142
|
+
: enUS.listFeedback.forbidden,
|
|
143
|
+
);
|
|
144
|
+
expect(screen.queryByText('Private row')).not.toBeInTheDocument();
|
|
145
|
+
const pending = deferred();
|
|
146
|
+
response = pending.promise;
|
|
147
|
+
await userEvent.click(screen.getByRole('button', { name: 'Refresh' }));
|
|
148
|
+
expect(screen.getByRole('alert')).toHaveTextContent(
|
|
149
|
+
status === 401
|
|
150
|
+
? enUS.listFeedback.unauthenticated
|
|
151
|
+
: enUS.listFeedback.forbidden,
|
|
152
|
+
);
|
|
153
|
+
expect(screen.queryByText('Private row')).not.toBeInTheDocument();
|
|
154
|
+
await act(async () => pending.resolve({ data: ['Authorized row'] }));
|
|
155
|
+
await screen.findByText('Authorized row');
|
|
156
|
+
},
|
|
157
|
+
);
|
|
158
|
+
it('withholds the previous query and ignores its late retry completion', async () => {
|
|
159
|
+
let response: Rows | Response | Promise<Rows | Response> = {
|
|
160
|
+
data: ['First row'],
|
|
161
|
+
};
|
|
162
|
+
await renderWithApp(<Harness kind={kind} />, {
|
|
163
|
+
...options,
|
|
164
|
+
fetch: answerApi(() => response),
|
|
165
|
+
});
|
|
166
|
+
await screen.findByText('First row');
|
|
167
|
+
response = failure();
|
|
168
|
+
await userEvent.click(screen.getByRole('button', { name: 'Refresh' }));
|
|
169
|
+
await screen.findByText(enUS.listFeedback.refreshFailed);
|
|
170
|
+
const old = deferred();
|
|
171
|
+
response = old.promise;
|
|
172
|
+
await userEvent.click(
|
|
173
|
+
screen.getByRole('button', { name: 'Retry', exact: true }),
|
|
174
|
+
);
|
|
175
|
+
const current = deferred();
|
|
176
|
+
response = current.promise;
|
|
177
|
+
await userEvent.click(
|
|
178
|
+
screen.getByRole('button', { name: 'Change query' }),
|
|
179
|
+
);
|
|
180
|
+
expect(screen.getByRole('status')).toHaveTextContent('Loading');
|
|
181
|
+
expect(screen.queryByText('First row')).not.toBeInTheDocument();
|
|
182
|
+
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
|
183
|
+
await act(async () => current.resolve({ data: ['Second row'] }));
|
|
184
|
+
await screen.findByText('Second row');
|
|
185
|
+
await act(async () => old.resolve({ data: ['Obsolete row'] }));
|
|
186
|
+
expect(screen.queryByText('Obsolete row')).not.toBeInTheDocument();
|
|
187
|
+
expect(screen.getByText('Second row')).toBeVisible();
|
|
188
|
+
});
|
|
189
|
+
},
|
|
190
|
+
);
|
|
@@ -151,6 +151,13 @@ describe('page permission navigation projection', () => {
|
|
|
151
151
|
]);
|
|
152
152
|
expect(result[0]?.children?.[1]?.children?.[0]?.label).toBe('Warehouses');
|
|
153
153
|
expect(result[1]?.rowId).toBe('["page","legacy-loans","access"]');
|
|
154
|
+
expect(result[1]?.path).toBe('/loans');
|
|
155
|
+
expect(result[0]?.children?.[0]?.path).toBe('/materials');
|
|
156
|
+
expect(
|
|
157
|
+
flatten(result).find(
|
|
158
|
+
(node) => node.rowId === '["page","legacy-audit","access"]',
|
|
159
|
+
)?.path,
|
|
160
|
+
).toBe('/loans/audit');
|
|
154
161
|
expect(result[1]?.children?.[0]?.rowId).toBe(
|
|
155
162
|
'["page","legacy-reviews","access"]',
|
|
156
163
|
);
|
|
@@ -185,6 +192,7 @@ describe('page permission navigation projection', () => {
|
|
|
185
192
|
{
|
|
186
193
|
id: 'page:legacy-audit',
|
|
187
194
|
label: 'Audit details',
|
|
195
|
+
path: '/loans/audit',
|
|
188
196
|
rowId: '["page","legacy-audit","access"]',
|
|
189
197
|
},
|
|
190
198
|
]);
|
|
@@ -60,13 +60,7 @@ describe('application-owned permission workspace', () => {
|
|
|
60
60
|
onLevelChange={vi.fn()}
|
|
61
61
|
/>,
|
|
62
62
|
);
|
|
63
|
-
const checkboxes = screen
|
|
64
|
-
.getAllByRole('checkbox')
|
|
65
|
-
.filter(
|
|
66
|
-
(item) =>
|
|
67
|
-
item.getAttribute('aria-label') !== 'Enabled only' &&
|
|
68
|
-
item.id !== screen.getByRole('checkbox', { name: 'Enabled only' }).id,
|
|
69
|
-
);
|
|
63
|
+
const checkboxes = screen.getAllByRole('checkbox');
|
|
70
64
|
expect(checkboxes.map((item) => item.textContent)).toHaveLength(2);
|
|
71
65
|
expect(checkboxes[0]).toHaveAccessibleName('Approve order');
|
|
72
66
|
expect(checkboxes[1]).toHaveAccessibleName('Withdraw order');
|
|
@@ -74,7 +68,7 @@ describe('application-owned permission workspace', () => {
|
|
|
74
68
|
screen.queryByRole('checkbox', { name: 'Configure numbering' }),
|
|
75
69
|
).not.toBeInTheDocument();
|
|
76
70
|
expect(
|
|
77
|
-
screen.getByRole('
|
|
71
|
+
screen.getByRole('tablist', { name: 'Permission categories' }),
|
|
78
72
|
).toBeVisible();
|
|
79
73
|
expect(
|
|
80
74
|
screen.getByText('Confirm the order and reserve stock.'),
|
|
@@ -99,7 +93,7 @@ describe('application-owned permission workspace', () => {
|
|
|
99
93
|
expect(screen.getByRole('status')).toHaveTextContent(
|
|
100
94
|
'Ask the application developer',
|
|
101
95
|
);
|
|
102
|
-
expect(screen.
|
|
96
|
+
expect(screen.queryAllByRole('checkbox')).toHaveLength(0);
|
|
103
97
|
expect(screen.queryByRole('heading', { level: 2 })).not.toBeInTheDocument();
|
|
104
98
|
});
|
|
105
99
|
|
|
@@ -149,11 +143,11 @@ describe('application-owned permission workspace', () => {
|
|
|
149
143
|
onLevelChange={vi.fn()}
|
|
150
144
|
/>,
|
|
151
145
|
);
|
|
152
|
-
const category = screen.getByRole('
|
|
146
|
+
const category = screen.getByRole('tab', {
|
|
153
147
|
name: 'Application configuration',
|
|
154
148
|
});
|
|
155
149
|
await user.click(category);
|
|
156
|
-
expect(category).toHaveAttribute('aria-
|
|
150
|
+
expect(category).toHaveAttribute('aria-selected', 'true');
|
|
157
151
|
expect(
|
|
158
152
|
screen.getByRole('checkbox', { name: 'Configure numbering' }),
|
|
159
153
|
).toBeVisible();
|
|
@@ -168,9 +162,9 @@ describe('application-owned permission workspace', () => {
|
|
|
168
162
|
expect(
|
|
169
163
|
screen.getByRole('checkbox', { name: 'Configure numbering' }),
|
|
170
164
|
).toBeVisible();
|
|
171
|
-
expect(category).toHaveAttribute('aria-
|
|
165
|
+
expect(category).toHaveAttribute('aria-selected', 'true');
|
|
172
166
|
await user.type(screen.getByRole('textbox'), 'missing');
|
|
173
|
-
await user.click(screen.getByRole('
|
|
167
|
+
await user.click(screen.getByRole('tab', { name: 'Business actions' }));
|
|
174
168
|
expect(screen.getByRole('textbox')).toHaveValue('');
|
|
175
169
|
expect(
|
|
176
170
|
screen.getByRole('checkbox', { name: 'Approve order' }),
|
|
@@ -233,7 +227,7 @@ describe('application-owned permission workspace', () => {
|
|
|
233
227
|
);
|
|
234
228
|
}
|
|
235
229
|
render(<Draft />);
|
|
236
|
-
await user.click(screen.getByRole('
|
|
230
|
+
await user.click(screen.getByRole('tab', { name: 'Granted' }));
|
|
237
231
|
await user.click(screen.getByRole('checkbox', { name: 'Approve' }));
|
|
238
232
|
expect(screen.getByRole('textbox')).toHaveFocus();
|
|
239
233
|
expect(screen.getByRole('status')).toHaveTextContent(
|
|
@@ -321,13 +315,13 @@ describe('application-owned permission workspace', () => {
|
|
|
321
315
|
await user.keyboard(' ');
|
|
322
316
|
expect(change).toHaveBeenCalledTimes(2);
|
|
323
317
|
expect(change).toHaveBeenLastCalledWith('withdraw', true);
|
|
324
|
-
screen.getAllByRole('
|
|
318
|
+
screen.getAllByRole('tab', { name: 'Granted' })[0]!.focus();
|
|
325
319
|
await user.keyboard(' ');
|
|
326
320
|
expect(change).toHaveBeenCalledTimes(2);
|
|
327
321
|
await user.type(screen.getAllByRole('textbox')[0]!, 'missing');
|
|
328
322
|
await user.click(screen.getByRole('button', { name: 'Clear filters' }));
|
|
329
323
|
await user.click(
|
|
330
|
-
screen.getAllByRole('
|
|
324
|
+
screen.getAllByRole('tab', { name: 'Application configuration' })[0]!,
|
|
331
325
|
);
|
|
332
326
|
screen.getByRole('checkbox', { name: 'Configure numbering' }).focus();
|
|
333
327
|
await user.keyboard(' ');
|
|
@@ -543,7 +537,12 @@ const treeSection: PermissionEditorSection = {
|
|
|
543
537
|
label: 'Catalog',
|
|
544
538
|
rowId: 'catalog',
|
|
545
539
|
children: [
|
|
546
|
-
{
|
|
540
|
+
{
|
|
541
|
+
id: 'books-node',
|
|
542
|
+
label: 'Books',
|
|
543
|
+
rowId: 'books',
|
|
544
|
+
path: '/library/books',
|
|
545
|
+
},
|
|
547
546
|
{
|
|
548
547
|
id: 'review-group',
|
|
549
548
|
label: 'Approvals',
|
|
@@ -684,7 +683,7 @@ describe('application-owned page navigation', () => {
|
|
|
684
683
|
);
|
|
685
684
|
}
|
|
686
685
|
render(<ControlledTree />);
|
|
687
|
-
await user.click(screen.getByRole('
|
|
686
|
+
await user.click(screen.getByRole('tab', { name: 'Granted' }));
|
|
688
687
|
expect(screen.getByText('Library')).toBeVisible();
|
|
689
688
|
expect(screen.getByText('Catalog')).toBeVisible();
|
|
690
689
|
expect(screen.queryByText('Approvals')).not.toBeInTheDocument();
|
|
@@ -711,9 +710,7 @@ describe('application-owned page navigation', () => {
|
|
|
711
710
|
/>,
|
|
712
711
|
);
|
|
713
712
|
expect(screen.getAllByRole('list')).toHaveLength(1);
|
|
714
|
-
expect(
|
|
715
|
-
screen.queryByRole('button', { name: /Collapse|Expand/ }),
|
|
716
|
-
).not.toBeInTheDocument();
|
|
713
|
+
expect(screen.getByRole('button', { name: 'Collapse all' })).toBeDisabled();
|
|
717
714
|
rerender(
|
|
718
715
|
<PermissionEditor
|
|
719
716
|
sections={[treeSection]}
|
|
@@ -738,3 +735,176 @@ describe('application-owned page navigation', () => {
|
|
|
738
735
|
expect(change).not.toHaveBeenCalled();
|
|
739
736
|
});
|
|
740
737
|
});
|
|
738
|
+
|
|
739
|
+
describe('compact permission controls', () => {
|
|
740
|
+
const leaves: PermissionEditorSection = {
|
|
741
|
+
id: 'pages',
|
|
742
|
+
title: 'Pages',
|
|
743
|
+
groups: [
|
|
744
|
+
{
|
|
745
|
+
id: 'borrowing',
|
|
746
|
+
title: 'Borrowing',
|
|
747
|
+
accessOnly: true,
|
|
748
|
+
rows: [{ id: 'borrow', label: 'Access', enabled: true }],
|
|
749
|
+
},
|
|
750
|
+
{
|
|
751
|
+
id: 'catalog',
|
|
752
|
+
title: 'Catalog',
|
|
753
|
+
accessOnly: true,
|
|
754
|
+
rows: [{ id: 'browse', label: 'Access', enabled: false }],
|
|
755
|
+
},
|
|
756
|
+
],
|
|
757
|
+
};
|
|
758
|
+
|
|
759
|
+
it('keeps an all-leaf section visible and excludes its rows from bulk disclosure', async () => {
|
|
760
|
+
const user = userEvent.setup();
|
|
761
|
+
const change = vi.fn();
|
|
762
|
+
render(
|
|
763
|
+
<PermissionEditor
|
|
764
|
+
sections={[leaves]}
|
|
765
|
+
onEnabledChange={change}
|
|
766
|
+
onLevelChange={vi.fn()}
|
|
767
|
+
/>,
|
|
768
|
+
);
|
|
769
|
+
const collapse = screen.getByRole('button', { name: 'Collapse all' });
|
|
770
|
+
expect(collapse).toBeDisabled();
|
|
771
|
+
await user.click(collapse);
|
|
772
|
+
expect(screen.getByRole('checkbox', { name: 'Borrowing' })).toBeVisible();
|
|
773
|
+
expect(screen.getByRole('checkbox', { name: 'Borrowing' })).toBeChecked();
|
|
774
|
+
expect(screen.getByRole('checkbox', { name: 'Catalog' })).not.toBeChecked();
|
|
775
|
+
expect(
|
|
776
|
+
screen.queryByRole('button', { name: 'Expand all' }),
|
|
777
|
+
).not.toBeInTheDocument();
|
|
778
|
+
expect(change).not.toHaveBeenCalled();
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
it('collapses only titled groups in a mixed section while leaves stay reachable and unchanged', async () => {
|
|
782
|
+
const user = userEvent.setup();
|
|
783
|
+
const change = vi.fn();
|
|
784
|
+
render(
|
|
785
|
+
<PermissionEditor
|
|
786
|
+
sections={[
|
|
787
|
+
{ ...leaves, groups: [...leaves.groups, ...sections[0]!.groups] },
|
|
788
|
+
]}
|
|
789
|
+
onEnabledChange={change}
|
|
790
|
+
onLevelChange={vi.fn()}
|
|
791
|
+
/>,
|
|
792
|
+
);
|
|
793
|
+
for (const action of ['Collapse all', 'Expand all']) {
|
|
794
|
+
await user.click(screen.getByRole('button', { name: action }));
|
|
795
|
+
for (const name of ['Borrowing', 'Catalog']) {
|
|
796
|
+
const checkbox = screen.getByRole('checkbox', { name });
|
|
797
|
+
expect(checkbox).toBeVisible();
|
|
798
|
+
checkbox.focus();
|
|
799
|
+
expect(checkbox).toHaveFocus();
|
|
800
|
+
}
|
|
801
|
+
expect(screen.getByRole('checkbox', { name: 'Borrowing' })).toBeChecked();
|
|
802
|
+
expect(
|
|
803
|
+
screen.getByRole('checkbox', { name: 'Catalog' }),
|
|
804
|
+
).not.toBeChecked();
|
|
805
|
+
if (action === 'Collapse all') {
|
|
806
|
+
expect(
|
|
807
|
+
screen.getByRole('button', { name: 'Expand Orders' }),
|
|
808
|
+
).toBeVisible();
|
|
809
|
+
expect(
|
|
810
|
+
screen.queryByRole('checkbox', { name: 'Approve order' }),
|
|
811
|
+
).not.toBeInTheDocument();
|
|
812
|
+
} else {
|
|
813
|
+
expect(
|
|
814
|
+
screen.getByRole('checkbox', { name: 'Approve order' }),
|
|
815
|
+
).toBeChecked();
|
|
816
|
+
expect(
|
|
817
|
+
screen.getByRole('checkbox', { name: 'Withdraw order' }),
|
|
818
|
+
).not.toBeChecked();
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
expect(change).not.toHaveBeenCalled();
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
it('ignores an existing collapsed key when a group becomes an access-only leaf', async () => {
|
|
825
|
+
const user = userEvent.setup();
|
|
826
|
+
const change = vi.fn();
|
|
827
|
+
const props = { onEnabledChange: change, onLevelChange: vi.fn() };
|
|
828
|
+
const { rerender } = render(
|
|
829
|
+
<PermissionEditor
|
|
830
|
+
{...props}
|
|
831
|
+
sections={[
|
|
832
|
+
{
|
|
833
|
+
...leaves,
|
|
834
|
+
groups: leaves.groups.map((group) => ({
|
|
835
|
+
...group,
|
|
836
|
+
accessOnly: false,
|
|
837
|
+
})),
|
|
838
|
+
},
|
|
839
|
+
]}
|
|
840
|
+
/>,
|
|
841
|
+
);
|
|
842
|
+
await user.click(screen.getByRole('button', { name: 'Collapse all' }));
|
|
843
|
+
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument();
|
|
844
|
+
rerender(<PermissionEditor {...props} sections={[leaves]} />);
|
|
845
|
+
expect(screen.getByRole('checkbox', { name: 'Borrowing' })).toBeVisible();
|
|
846
|
+
expect(screen.getByRole('checkbox', { name: 'Borrowing' })).toBeChecked();
|
|
847
|
+
expect(screen.getByRole('checkbox', { name: 'Catalog' })).not.toBeChecked();
|
|
848
|
+
expect(screen.getByRole('button', { name: 'Collapse all' })).toBeDisabled();
|
|
849
|
+
expect(change).not.toHaveBeenCalled();
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
it('filters ungranted rows globally, resets without editing, and searches actual routes', async () => {
|
|
853
|
+
const user = userEvent.setup();
|
|
854
|
+
const change = vi.fn();
|
|
855
|
+
render(
|
|
856
|
+
<PermissionEditor
|
|
857
|
+
sections={[treeSection, ...sections]}
|
|
858
|
+
onEnabledChange={change}
|
|
859
|
+
onLevelChange={vi.fn()}
|
|
860
|
+
/>,
|
|
861
|
+
);
|
|
862
|
+
await user.click(screen.getByRole('tab', { name: 'Ungranted' }));
|
|
863
|
+
expect(
|
|
864
|
+
screen.getByRole('checkbox', { name: 'Review loans' }),
|
|
865
|
+
).not.toBeChecked();
|
|
866
|
+
expect(
|
|
867
|
+
screen.getByRole('checkbox', { name: 'Configure numbering' }),
|
|
868
|
+
).not.toBeChecked();
|
|
869
|
+
expect(
|
|
870
|
+
screen.queryByRole('checkbox', { name: 'Books' }),
|
|
871
|
+
).not.toBeInTheDocument();
|
|
872
|
+
expect(screen.getByText('2 of 4 enabled')).toBeVisible();
|
|
873
|
+
await user.click(screen.getByRole('button', { name: 'Clear filters' }));
|
|
874
|
+
await user.type(screen.getByRole('textbox'), '/library/books');
|
|
875
|
+
expect(screen.getByRole('checkbox', { name: 'Books' })).toBeChecked();
|
|
876
|
+
expect(screen.getByText('/library/books')).toBeVisible();
|
|
877
|
+
expect(
|
|
878
|
+
screen.queryByRole('checkbox', { name: 'Review loans' }),
|
|
879
|
+
).not.toBeInTheDocument();
|
|
880
|
+
expect(change).not.toHaveBeenCalled();
|
|
881
|
+
});
|
|
882
|
+
it('collapses resources without mutating grants and restores disclosure after search', async () => {
|
|
883
|
+
const user = userEvent.setup();
|
|
884
|
+
const change = vi.fn();
|
|
885
|
+
render(
|
|
886
|
+
<PermissionEditor
|
|
887
|
+
sections={sections}
|
|
888
|
+
onEnabledChange={change}
|
|
889
|
+
onLevelChange={vi.fn()}
|
|
890
|
+
/>,
|
|
891
|
+
);
|
|
892
|
+
await user.click(screen.getByRole('button', { name: 'Collapse all' }));
|
|
893
|
+
expect(
|
|
894
|
+
screen.queryByRole('checkbox', { name: 'Approve order' }),
|
|
895
|
+
).not.toBeInTheDocument();
|
|
896
|
+
await user.type(screen.getByRole('textbox'), 'reserve');
|
|
897
|
+
expect(
|
|
898
|
+
screen.getByRole('checkbox', { name: 'Approve order' }),
|
|
899
|
+
).toBeChecked();
|
|
900
|
+
await user.click(screen.getByRole('button', { name: 'Clear filters' }));
|
|
901
|
+
expect(
|
|
902
|
+
screen.queryByRole('checkbox', { name: 'Approve order' }),
|
|
903
|
+
).not.toBeInTheDocument();
|
|
904
|
+
await user.click(screen.getByRole('button', { name: 'Expand all' }));
|
|
905
|
+
expect(
|
|
906
|
+
screen.getByRole('checkbox', { name: 'Approve order' }),
|
|
907
|
+
).toBeChecked();
|
|
908
|
+
expect(change).not.toHaveBeenCalled();
|
|
909
|
+
});
|
|
910
|
+
});
|
|
@@ -127,7 +127,7 @@ it('collects native custom filter parameters before changing the draft, then sav
|
|
|
127
127
|
);
|
|
128
128
|
}
|
|
129
129
|
render(<Role />);
|
|
130
|
-
await user.click(screen.getByRole('
|
|
130
|
+
await user.click(screen.getByRole('tab', { name: 'Configuration' }));
|
|
131
131
|
const scope = screen.getByRole('combobox', {
|
|
132
132
|
name: 'How far View · Records reaches',
|
|
133
133
|
});
|
|
@@ -117,6 +117,19 @@ const configurationRoutes = [
|
|
|
117
117
|
] as unknown as AppClientRegisteredRoute[];
|
|
118
118
|
|
|
119
119
|
describe('application-owned permission presentation', () => {
|
|
120
|
+
it('omits the generic Page access description and retains other section descriptions', () => {
|
|
121
|
+
const sections = permissionSections(source, [], (value) =>
|
|
122
|
+
typeof value === 'string' ? value : value.key,
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
expect(
|
|
126
|
+
sections.find((section) => section.id === 'pages')?.description,
|
|
127
|
+
).toBe(undefined);
|
|
128
|
+
expect(
|
|
129
|
+
sections.find((section) => section.id === 'business')?.description,
|
|
130
|
+
).toBe('settings.businessOperationsHint');
|
|
131
|
+
});
|
|
132
|
+
|
|
120
133
|
it('can hide a created-record selector while retaining a related-record selector and stored policy', () => {
|
|
121
134
|
const resource = {
|
|
122
135
|
type: 'composite',
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { renderSettingsClient as renderWithApp } from '../helpers/settings-client.js';
|
|
2
|
+
import { answerApi } from '@nocobase/app-testing/client';
|
|
2
3
|
import { apiClientToken, type ClientApplication } from '@nocobase/app-client';
|
|
3
4
|
import {
|
|
4
5
|
AuthorizationClient,
|