@nocobase/app-template-default 1.0.0-beta.83 → 1.0.0-beta.84
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 +7 -1
- package/README.MD +10 -2
- package/client/components/back-button.tsx +3 -5
- 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/permission-editor.tsx +396 -210
- 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/hooks/use-list-data.ts +77 -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 +10 -0
- package/client/locales/system/settings-en-US.ts +8 -0
- package/client/locales/system/settings-zh-CN.ts +8 -0
- package/client/locales/system/zh-CN.ts +8 -0
- package/client/pages/settings/default-access-form.tsx +44 -26
- package/client/pages/settings/default-access.tsx +56 -27
- 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 -58
- package/client/pages/settings/role-form.tsx +98 -56
- package/client/pages/settings/roles.tsx +11 -3
- package/client/pages/settings/shared.tsx +43 -33
- package/client/pages/settings/user-form.tsx +51 -30
- package/client/pages/settings/users.tsx +39 -27
- package/client/routing/session-preparation.tsx +10 -3
- package/database/main/migrations/202611020002_create_mutation_receipts.ts +19 -0
- package/package.json +13 -12
- 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/default-access.test.tsx +60 -4
- package/tests/client/form-feedback.test.tsx +589 -0
- package/tests/client/list-recovery.test.tsx +373 -0
- package/tests/client/list-tabs.test.tsx +121 -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/role-return.test.tsx +2 -1
- package/tests/client/session-preparation.test.tsx +24 -1
- package/tests/client/settings-continuity.test.tsx +5 -4
- package/tests/client/settings-feedback.test.tsx +25 -0
- 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/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/server/member-role.test.ts +7 -1
- package/tests/server/submission-settings.test.ts +241 -0
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import {
|
|
2
|
+
answerApi,
|
|
3
|
+
renderWithApp,
|
|
4
|
+
type ApiCall,
|
|
5
|
+
} from '@nocobase/app-testing/client';
|
|
6
|
+
import { useApiClient } from '@nocobase/app-client';
|
|
7
|
+
import { act, screen, waitFor } from '@testing-library/react';
|
|
8
|
+
import userEvent from '@testing-library/user-event';
|
|
9
|
+
import {
|
|
10
|
+
Link,
|
|
11
|
+
Outlet,
|
|
12
|
+
Route,
|
|
13
|
+
Routes,
|
|
14
|
+
useLocation,
|
|
15
|
+
useNavigate,
|
|
16
|
+
useOutletContext,
|
|
17
|
+
} from 'react-router';
|
|
18
|
+
import { useState } from 'react';
|
|
19
|
+
import { expect, it, vi } from 'vitest';
|
|
20
|
+
import { useListData } from '../../client/hooks/use-list-data.js';
|
|
21
|
+
import { useListLocation } from '../../client/hooks/use-list-location.js';
|
|
22
|
+
import { useTableUrlState } from '../../client/hooks/use-table-url-state.js';
|
|
23
|
+
import enUS from '../../client/locales/en-US.js';
|
|
24
|
+
|
|
25
|
+
interface Page {
|
|
26
|
+
data: string[];
|
|
27
|
+
meta: { total: number };
|
|
28
|
+
}
|
|
29
|
+
function List() {
|
|
30
|
+
const api = useApiClient();
|
|
31
|
+
const { params, active } = useListLocation();
|
|
32
|
+
const request = useListData<Page>(`items?${params}`, {
|
|
33
|
+
active,
|
|
34
|
+
keepPreviousData: true,
|
|
35
|
+
});
|
|
36
|
+
const state = useTableUrlState({
|
|
37
|
+
sortColumns: ['name'],
|
|
38
|
+
active,
|
|
39
|
+
revision: request.revision,
|
|
40
|
+
rowCount:
|
|
41
|
+
!request.loading && !request.error ? request.data?.meta.total : undefined,
|
|
42
|
+
});
|
|
43
|
+
const [failed, setFailed] = useState(0);
|
|
44
|
+
const remove = async (ids: string[]) => {
|
|
45
|
+
const results = await Promise.allSettled(
|
|
46
|
+
ids.map((id) => api.request({ path: `items/${id}`, method: 'DELETE' })),
|
|
47
|
+
);
|
|
48
|
+
setFailed(results.filter((result) => result.status === 'rejected').length);
|
|
49
|
+
if (results.some((result) => result.status === 'fulfilled'))
|
|
50
|
+
request.reload();
|
|
51
|
+
};
|
|
52
|
+
return (
|
|
53
|
+
<>
|
|
54
|
+
<output data-testid='rows'>{request.data?.data.join(',')}</output>
|
|
55
|
+
<output data-testid='total'>{request.data?.meta.total}</output>
|
|
56
|
+
<output data-testid='failed'>{failed}</output>
|
|
57
|
+
<output data-testid='error'>
|
|
58
|
+
{state.error?.message ?? (request.error ? 'Read failed' : '')}
|
|
59
|
+
</output>
|
|
60
|
+
<button onClick={request.reload}>Refresh</button>
|
|
61
|
+
<button onClick={() => void remove(['last'])}>Delete one</button>
|
|
62
|
+
<button onClick={() => void remove(['last', 'protected'])}>
|
|
63
|
+
Delete batch
|
|
64
|
+
</button>
|
|
65
|
+
<Link to={{ pathname: 'last', search: `?${params}` }}>Detail</Link>
|
|
66
|
+
<Link to='?page=2&pageSize=10&q=new'>Change query</Link>
|
|
67
|
+
<Outlet context={{ reload: request.reload }} />
|
|
68
|
+
</>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
function Detail() {
|
|
72
|
+
const api = useApiClient();
|
|
73
|
+
const { reload } = useOutletContext<{ reload: () => void }>();
|
|
74
|
+
const navigate = useNavigate();
|
|
75
|
+
const location = useLocation();
|
|
76
|
+
return (
|
|
77
|
+
<button
|
|
78
|
+
onClick={() =>
|
|
79
|
+
void api.request({ path: 'items/last', method: 'DELETE' }).then(() => {
|
|
80
|
+
reload();
|
|
81
|
+
navigate({ pathname: '..', search: location.search });
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
>
|
|
85
|
+
Delete in detail
|
|
86
|
+
</button>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
function Location() {
|
|
90
|
+
const location = useLocation();
|
|
91
|
+
return (
|
|
92
|
+
<output data-testid='url'>
|
|
93
|
+
{location.pathname}
|
|
94
|
+
{location.search}
|
|
95
|
+
</output>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
async function mount(
|
|
99
|
+
answer: (call: ApiCall) => unknown,
|
|
100
|
+
route = '/items?page=3&pageSize=10&q=loan&status=open&sort=name&order=desc&tab=active',
|
|
101
|
+
) {
|
|
102
|
+
return renderWithApp(
|
|
103
|
+
<>
|
|
104
|
+
<Routes>
|
|
105
|
+
<Route path='/items' element={<List />}>
|
|
106
|
+
<Route path=':id' element={<Detail />} />
|
|
107
|
+
</Route>
|
|
108
|
+
</Routes>
|
|
109
|
+
<Location />
|
|
110
|
+
</>,
|
|
111
|
+
{
|
|
112
|
+
route,
|
|
113
|
+
namespace: 'list-test',
|
|
114
|
+
namespaces: { 'list-test': { 'en-US': async () => ({ default: enUS }) } },
|
|
115
|
+
fetch: answerApi(answer),
|
|
116
|
+
},
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
it.each([
|
|
120
|
+
{ total: 20, target: 2 },
|
|
121
|
+
{ total: 0, target: 1 },
|
|
122
|
+
{ total: 21, target: 3 },
|
|
123
|
+
])(
|
|
124
|
+
'uses the latest matching total $total after deletion',
|
|
125
|
+
async ({ total, target }) => {
|
|
126
|
+
let deleted = false;
|
|
127
|
+
const calls = vi.fn(({ method }: ApiCall) => {
|
|
128
|
+
if (method === 'DELETE') {
|
|
129
|
+
deleted = true;
|
|
130
|
+
return { data: null };
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
data: deleted ? (total ? ['remaining'] : []) : ['last'],
|
|
134
|
+
meta: { total: deleted ? total : 21 },
|
|
135
|
+
};
|
|
136
|
+
});
|
|
137
|
+
await mount(calls);
|
|
138
|
+
await screen.findByText('last');
|
|
139
|
+
await userEvent.click(screen.getByText('Delete one'));
|
|
140
|
+
await waitFor(() =>
|
|
141
|
+
expect(screen.getByTestId('url')).toHaveTextContent(
|
|
142
|
+
`page=${target}&pageSize=10&q=loan&status=open&sort=name&order=desc&tab=active`,
|
|
143
|
+
),
|
|
144
|
+
);
|
|
145
|
+
await waitFor(() =>
|
|
146
|
+
expect(screen.getByTestId('total')).toHaveTextContent(String(total)),
|
|
147
|
+
);
|
|
148
|
+
expect(
|
|
149
|
+
calls.mock.calls.filter(([call]) => call.method === 'GET'),
|
|
150
|
+
).toHaveLength(target === 3 ? 2 : 3);
|
|
151
|
+
},
|
|
152
|
+
);
|
|
153
|
+
it('refills a still valid first page and retains failed batch records', async () => {
|
|
154
|
+
let deleted = false;
|
|
155
|
+
await mount(({ method, path }) => {
|
|
156
|
+
if (method === 'DELETE') {
|
|
157
|
+
if (path.endsWith('protected'))
|
|
158
|
+
return Response.json(
|
|
159
|
+
{ error: { code: 'FORBIDDEN', message: 'Protected' } },
|
|
160
|
+
{ status: 403 },
|
|
161
|
+
);
|
|
162
|
+
deleted = true;
|
|
163
|
+
return { data: null };
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
data: deleted ? ['protected', 'next'] : ['last', 'protected'],
|
|
167
|
+
meta: { total: deleted ? 20 : 21 },
|
|
168
|
+
};
|
|
169
|
+
}, '/items?page=1&pageSize=10&q=loan');
|
|
170
|
+
await screen.findByText('last,protected');
|
|
171
|
+
await userEvent.click(screen.getByText('Delete batch'));
|
|
172
|
+
await waitFor(() =>
|
|
173
|
+
expect(screen.getByTestId('rows')).toHaveTextContent('protected,next'),
|
|
174
|
+
);
|
|
175
|
+
expect(screen.getByTestId('failed')).toHaveTextContent('1');
|
|
176
|
+
expect(screen.getByTestId('url')).toHaveTextContent(
|
|
177
|
+
'page=1&pageSize=10&q=loan',
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
it('keeps rows, count and URL on a failed deletion', async () => {
|
|
181
|
+
const calls = vi.fn(({ method }: ApiCall) =>
|
|
182
|
+
method === 'DELETE'
|
|
183
|
+
? Response.json(
|
|
184
|
+
{ error: { code: 'FORBIDDEN', message: 'Protected' } },
|
|
185
|
+
{ status: 403 },
|
|
186
|
+
)
|
|
187
|
+
: { data: ['last'], meta: { total: 21 } },
|
|
188
|
+
);
|
|
189
|
+
await mount(calls);
|
|
190
|
+
await screen.findByText('last');
|
|
191
|
+
await userEvent.click(screen.getByText('Delete one'));
|
|
192
|
+
await waitFor(() =>
|
|
193
|
+
expect(screen.getByTestId('failed')).toHaveTextContent('1'),
|
|
194
|
+
);
|
|
195
|
+
expect(screen.getByTestId('rows')).toHaveTextContent('last');
|
|
196
|
+
expect(screen.getByTestId('total')).toHaveTextContent('21');
|
|
197
|
+
expect(screen.getByTestId('url')).toHaveTextContent('page=3');
|
|
198
|
+
expect(
|
|
199
|
+
calls.mock.calls.filter(([call]) => call.method === 'GET'),
|
|
200
|
+
).toHaveLength(1);
|
|
201
|
+
});
|
|
202
|
+
it('defers detail refresh until returning, then replaces the invalid source page', async () => {
|
|
203
|
+
let deleted = false;
|
|
204
|
+
const calls = vi.fn(({ method }: ApiCall) => {
|
|
205
|
+
if (method === 'DELETE') {
|
|
206
|
+
deleted = true;
|
|
207
|
+
return { data: null };
|
|
208
|
+
}
|
|
209
|
+
return {
|
|
210
|
+
data: deleted ? ['remaining'] : ['last'],
|
|
211
|
+
meta: { total: deleted ? 20 : 21 },
|
|
212
|
+
};
|
|
213
|
+
});
|
|
214
|
+
await mount(calls);
|
|
215
|
+
await screen.findByText('last');
|
|
216
|
+
await userEvent.click(screen.getByText('Detail'));
|
|
217
|
+
expect(screen.getByTestId('url')).toHaveTextContent('/items/last?page=3');
|
|
218
|
+
expect(
|
|
219
|
+
calls.mock.calls.filter(([call]) => call.method === 'GET'),
|
|
220
|
+
).toHaveLength(1);
|
|
221
|
+
await userEvent.click(screen.getByText('Delete in detail'));
|
|
222
|
+
await waitFor(() =>
|
|
223
|
+
expect(screen.getByTestId('url')).toHaveTextContent('/items?page=2'),
|
|
224
|
+
);
|
|
225
|
+
expect(screen.getByTestId('url')).toHaveTextContent(
|
|
226
|
+
'q=loan&status=open&sort=name&order=desc&tab=active',
|
|
227
|
+
);
|
|
228
|
+
await waitFor(() =>
|
|
229
|
+
expect(screen.getByTestId('rows')).toHaveTextContent('remaining'),
|
|
230
|
+
);
|
|
231
|
+
expect(
|
|
232
|
+
calls.mock.calls.filter(([call]) => call.method === 'GET'),
|
|
233
|
+
).toHaveLength(3);
|
|
234
|
+
});
|
|
235
|
+
it('stops after two corrective reads and allows an explicit retry', async () => {
|
|
236
|
+
let stable = false;
|
|
237
|
+
const calls = vi.fn(({ query }: ApiCall) => ({
|
|
238
|
+
data: stable ? ['valid'] : [],
|
|
239
|
+
meta: { total: stable ? 1000 : (Number(query.page) - 2) * 10 },
|
|
240
|
+
}));
|
|
241
|
+
await mount(calls, '/items?page=100&pageSize=10');
|
|
242
|
+
await screen.findByText(enUS.settings.listChanged);
|
|
243
|
+
expect(calls).toHaveBeenCalledTimes(3);
|
|
244
|
+
expect(screen.getByTestId('url')).toHaveTextContent('page=96');
|
|
245
|
+
stable = true;
|
|
246
|
+
await userEvent.click(screen.getByText('Refresh'));
|
|
247
|
+
await waitFor(() =>
|
|
248
|
+
expect(screen.getByTestId('rows')).toHaveTextContent('valid'),
|
|
249
|
+
);
|
|
250
|
+
expect(screen.getByTestId('error')).toBeEmptyDOMElement();
|
|
251
|
+
});
|
|
252
|
+
it('ignores a pre-delete response even when its transport finishes after reload', async () => {
|
|
253
|
+
let finish!: (value: unknown) => void;
|
|
254
|
+
let reads = 0;
|
|
255
|
+
await mount(({ method }) => {
|
|
256
|
+
if (method === 'DELETE') return { data: null };
|
|
257
|
+
reads++;
|
|
258
|
+
if (reads === 1)
|
|
259
|
+
return new Promise((resolve) => {
|
|
260
|
+
finish = resolve;
|
|
261
|
+
});
|
|
262
|
+
return { data: ['remaining'], meta: { total: 20 } };
|
|
263
|
+
});
|
|
264
|
+
await waitFor(() => expect(reads).toBe(1));
|
|
265
|
+
await userEvent.click(screen.getByText('Delete one'));
|
|
266
|
+
await waitFor(() =>
|
|
267
|
+
expect(screen.getByTestId('url')).toHaveTextContent('page=2'),
|
|
268
|
+
);
|
|
269
|
+
await act(async () => {
|
|
270
|
+
finish({ data: ['deleted-old'], meta: { total: 21 } });
|
|
271
|
+
});
|
|
272
|
+
expect(screen.getByTestId('rows')).toHaveTextContent('remaining');
|
|
273
|
+
expect(screen.getByTestId('rows')).not.toHaveTextContent('deleted-old');
|
|
274
|
+
expect(screen.getByTestId('url')).toHaveTextContent('page=2');
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('keeps the valid source on a failed refresh and corrects only after a successful retry', async () => {
|
|
278
|
+
let reads = 0;
|
|
279
|
+
await mount(() => {
|
|
280
|
+
reads++;
|
|
281
|
+
if (reads === 2)
|
|
282
|
+
return Response.json(
|
|
283
|
+
{ error: { code: 'INTERNAL_SERVER_ERROR', message: 'Read failed' } },
|
|
284
|
+
{ status: 500 },
|
|
285
|
+
);
|
|
286
|
+
return {
|
|
287
|
+
data: [reads === 1 ? 'last' : 'remaining'],
|
|
288
|
+
meta: { total: reads === 1 ? 21 : 20 },
|
|
289
|
+
};
|
|
290
|
+
});
|
|
291
|
+
await screen.findByText('last');
|
|
292
|
+
await userEvent.click(screen.getByText('Refresh'));
|
|
293
|
+
await screen.findByText('Read failed');
|
|
294
|
+
expect(screen.getByTestId('url')).toHaveTextContent('page=3');
|
|
295
|
+
expect(screen.getByTestId('rows')).toHaveTextContent('last');
|
|
296
|
+
await userEvent.click(screen.getByText('Refresh'));
|
|
297
|
+
await waitFor(() =>
|
|
298
|
+
expect(screen.getByTestId('url')).toHaveTextContent('page=2'),
|
|
299
|
+
);
|
|
300
|
+
await waitFor(() =>
|
|
301
|
+
expect(screen.getByTestId('rows')).toHaveTextContent('remaining'),
|
|
302
|
+
);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('refreshes once after a wholly successful batch, then corrects the last page', async () => {
|
|
306
|
+
let deleted = false;
|
|
307
|
+
const calls = vi.fn(({ method }: ApiCall) => {
|
|
308
|
+
if (method === 'DELETE') {
|
|
309
|
+
deleted = true;
|
|
310
|
+
return { data: null };
|
|
311
|
+
}
|
|
312
|
+
return {
|
|
313
|
+
data: deleted ? ['remaining'] : ['last', 'protected'],
|
|
314
|
+
meta: { total: deleted ? 19 : 22 },
|
|
315
|
+
};
|
|
316
|
+
});
|
|
317
|
+
await mount(calls);
|
|
318
|
+
await screen.findByText('last,protected');
|
|
319
|
+
await userEvent.click(screen.getByText('Delete batch'));
|
|
320
|
+
await waitFor(() =>
|
|
321
|
+
expect(screen.getByTestId('url')).toHaveTextContent('page=2'),
|
|
322
|
+
);
|
|
323
|
+
await waitFor(() =>
|
|
324
|
+
expect(screen.getByTestId('rows')).toHaveTextContent('remaining'),
|
|
325
|
+
);
|
|
326
|
+
expect(
|
|
327
|
+
calls.mock.calls.filter(([call]) => call.method === 'GET'),
|
|
328
|
+
).toHaveLength(3);
|
|
329
|
+
expect(screen.getByTestId('failed')).toHaveTextContent('0');
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('does not let an old filtered response correct a newer query', async () => {
|
|
333
|
+
let finish!: (value: unknown) => void;
|
|
334
|
+
await mount(({ query }) =>
|
|
335
|
+
query.q === 'new'
|
|
336
|
+
? { data: ['fresh-query'], meta: { total: 21 } }
|
|
337
|
+
: new Promise((resolve) => {
|
|
338
|
+
finish = resolve;
|
|
339
|
+
}),
|
|
340
|
+
);
|
|
341
|
+
await waitFor(() => expect(finish).toBeDefined());
|
|
342
|
+
await userEvent.click(screen.getByText('Change query'));
|
|
343
|
+
await screen.findByText('fresh-query');
|
|
344
|
+
await act(async () => finish({ data: [], meta: { total: 0 } }));
|
|
345
|
+
expect(screen.getByTestId('url')).toHaveTextContent(
|
|
346
|
+
'page=2&pageSize=10&q=new',
|
|
347
|
+
);
|
|
348
|
+
expect(screen.getByTestId('rows')).toHaveTextContent('fresh-query');
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('suspends a root list while its child covers it', async () => {
|
|
352
|
+
const calls = vi.fn(() => ({ data: ['last'], meta: { total: 21 } }));
|
|
353
|
+
await renderWithApp(
|
|
354
|
+
<>
|
|
355
|
+
<Routes>
|
|
356
|
+
<Route path='/' element={<List />}>
|
|
357
|
+
<Route path='last' element={<Detail />} />
|
|
358
|
+
</Route>
|
|
359
|
+
</Routes>
|
|
360
|
+
<Location />
|
|
361
|
+
</>,
|
|
362
|
+
{
|
|
363
|
+
route: '/?page=3&pageSize=10',
|
|
364
|
+
namespace: 'list-test',
|
|
365
|
+
namespaces: { 'list-test': { 'en-US': async () => ({ default: enUS }) } },
|
|
366
|
+
fetch: answerApi(calls),
|
|
367
|
+
},
|
|
368
|
+
);
|
|
369
|
+
await screen.findByText('last');
|
|
370
|
+
await userEvent.click(screen.getByText('Detail'));
|
|
371
|
+
expect(screen.getByTestId('url')).toHaveTextContent('/last?page=3');
|
|
372
|
+
expect(calls).toHaveBeenCalledTimes(1);
|
|
373
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -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
|
]);
|