@nocobase/app-template-default 1.0.0-beta.87 → 1.0.0-beta.89
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 +3 -1
- package/.agents/rules/notification-templates.md +5 -0
- package/.agents/rules/shell.md +9 -3
- package/AGENTS.md +4 -2
- package/README.MD +11 -5
- package/client/components/back-button.tsx +37 -12
- package/client/components/body-portal.tsx +25 -0
- package/client/components/brand-loading-mark.tsx +5 -2
- package/client/components/brand-mark-geometry.ts +3 -3
- package/client/components/breadcrumbs.tsx +60 -3
- package/client/components/data-table/data-table.tsx +90 -19
- package/client/components/data-table/pagination.tsx +17 -2
- package/client/components/flow-diagram.tsx +2 -2
- package/client/components/list-feedback.tsx +31 -22
- package/client/components/loading.tsx +127 -16
- package/client/components/pending-button.tsx +111 -0
- package/client/components/role-list.tsx +7 -0
- package/client/components/route-child-page.tsx +72 -1
- package/client/components/route-overlay.tsx +149 -15
- package/client/components/task-progress.tsx +155 -0
- package/client/components/use-route-overlay.ts +8 -11
- package/client/extensions/nocobase-file-component-ui/components/file-upload-field.tsx +62 -32
- package/client/extensions/nocobase-file-component-ui/locales/de-DE.ts +2 -0
- package/client/extensions/nocobase-file-component-ui/locales/en-US.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/es-ES.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/fr-FR.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/id-ID.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/ja-JP.ts +2 -0
- package/client/extensions/nocobase-file-component-ui/locales/pt-BR.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/vi-VN.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/zh-CN.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/zh-TW.ts +1 -0
- package/client/extensions/nocobase-workflow-management/README.md +5 -5
- package/client/extensions/nocobase-workflow-management/definition-list.tsx +2 -9
- package/client/extensions/nocobase-workflow-management/execution-list.tsx +2 -4
- package/client/layouts/app-layout.tsx +8 -8
- package/client/layouts/components/sidebar-footer.tsx +18 -25
- package/client/lib/navigation-transform.ts +156 -22
- package/client/locales/system/en-US.ts +26 -5
- package/client/locales/system/settings-en-US.ts +17 -0
- package/client/locales/system/settings-zh-CN.ts +15 -0
- package/client/locales/system/zh-CN.ts +26 -5
- package/client/pages/auth/register.tsx +39 -22
- package/client/pages/auth/shared.tsx +1 -3
- package/client/pages/settings/default-access.tsx +1 -1
- package/client/pages/settings/hooks.tsx +3 -68
- package/client/pages/settings/notification-routes.ts +109 -0
- package/client/pages/settings/notification-templates.tsx +26 -0
- package/client/pages/settings/permission-management-page.tsx +15 -90
- package/client/pages/settings/roles.tsx +27 -27
- package/client/pages/settings/routes.ts +107 -101
- package/client/pages/settings/settings-home.tsx +109 -0
- package/client/pages/settings/settings-module-page.tsx +158 -0
- package/client/pages/settings/shell-routes.ts +53 -0
- package/client/pages/settings/user-detail.tsx +3 -17
- package/client/pages/settings/user-form.tsx +9 -15
- package/client/pages/settings/users.tsx +11 -27
- package/client/routes.ts +17 -1
- package/client/routing/app-router.tsx +1 -14
- package/client/routing/client-route.tsx +199 -30
- package/client/routing/navigation-destination.ts +42 -0
- package/client/routing/route-context.tsx +30 -3
- package/client/routing/route-leave-boundary.tsx +261 -0
- package/client/routing/route-navigation.ts +72 -8
- package/client/routing/route-tree.tsx +7 -12
- package/client/runtime.ts +2 -2
- package/client/styles.css +18 -1
- package/config.example.yml +1 -1
- package/package.json +13 -13
- package/server/config/auth.ts +1 -1
- package/server/providers/index.ts +2 -0
- package/server/providers/notification-management.ts +27 -0
- package/tests/client/account-permissions.test.tsx +1 -1
- package/tests/client/auth-i18n.test.tsx +1 -1
- package/tests/client/authenticated-client-fixture.test.tsx +37 -0
- package/tests/client/back-button.test.tsx +34 -0
- package/tests/client/client-auth.test.tsx +10 -1
- package/tests/client/client-route-recovery.test.tsx +93 -1
- package/tests/client/client-routes.test.ts +137 -4
- package/tests/client/client-shell.test.tsx +23 -8
- package/tests/client/foundation-integration.test.ts +38 -17
- package/tests/client/layout-i18n.test.tsx +9 -4
- package/tests/client/list-feedback.test.tsx +5 -5
- package/tests/client/list-loading.test.tsx +397 -0
- package/tests/client/loading.test.tsx +20 -11
- package/tests/client/manual-list-retry.test.tsx +2 -2
- package/tests/client/navigation-parent.test.tsx +172 -0
- package/tests/client/pending-button.test.tsx +106 -0
- package/tests/client/permission-management-tabs.test.tsx +458 -17
- package/tests/client/primitive-labels.test.tsx +2 -2
- package/tests/client/registration-feedback.test.tsx +143 -0
- package/tests/client/role-return.test.tsx +21 -3
- package/tests/client/roles.test.tsx +84 -3
- package/tests/client/route-leave-recheck.test.tsx +74 -0
- package/tests/client/route-presentation.test.tsx +557 -0
- package/tests/client/session-preparation.test.tsx +20 -17
- package/tests/client/settings-continuity.test.tsx +35 -11
- package/tests/client/settings-home.test.tsx +112 -0
- package/tests/client/settings-idle-list.test.tsx +124 -0
- package/tests/client/settings-navigation.test.ts +230 -0
- package/tests/client/user-drawer.test.tsx +3 -4
- package/tests/fixtures/dialog-footer.html +12 -0
- package/tests/fixtures/dialog-footer.tsx +215 -0
- package/tests/helpers/authenticated-client.tsx +70 -22
- package/tests/playwright/dialog-footer.test.ts +227 -0
- package/tests/server/notification-templates.test.ts +144 -0
- package/tests/client/permission-management-routes.test.ts +0 -64
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
import { renderWithApp } from '@nocobase/app-testing/client';
|
|
2
|
+
import type { ColumnDef } from '@tanstack/react-table';
|
|
3
|
+
import { act, screen, within } from '@testing-library/react';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { DataTable } from '../../client/components/data-table/index.js';
|
|
6
|
+
import enUS from '../../client/locales/en-US.js';
|
|
7
|
+
import zhCN from '../../client/locales/zh-CN.js';
|
|
8
|
+
|
|
9
|
+
const namespace = '@nocobase/list-loading-test';
|
|
10
|
+
const options = {
|
|
11
|
+
namespace,
|
|
12
|
+
namespaces: {
|
|
13
|
+
[namespace]: {
|
|
14
|
+
'en-US': async () => ({ default: enUS }),
|
|
15
|
+
'zh-CN': async () => ({ default: zhCN }),
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
const columns: ColumnDef<{ id: string }>[] = [
|
|
20
|
+
{ accessorKey: 'id', header: 'ID' },
|
|
21
|
+
];
|
|
22
|
+
const refreshing = enUS['status.refreshing'];
|
|
23
|
+
|
|
24
|
+
function Table({
|
|
25
|
+
rows,
|
|
26
|
+
loading,
|
|
27
|
+
listState,
|
|
28
|
+
total = rows.length,
|
|
29
|
+
label,
|
|
30
|
+
query,
|
|
31
|
+
}: {
|
|
32
|
+
readonly query?: string;
|
|
33
|
+
readonly rows: readonly { id: string }[];
|
|
34
|
+
readonly loading: boolean;
|
|
35
|
+
readonly listState: 'loading' | 'ready';
|
|
36
|
+
readonly total?: number;
|
|
37
|
+
readonly label?: string;
|
|
38
|
+
}) {
|
|
39
|
+
const table = (
|
|
40
|
+
<DataTable
|
|
41
|
+
columns={columns}
|
|
42
|
+
data={rows}
|
|
43
|
+
manualPagination
|
|
44
|
+
rowCount={total}
|
|
45
|
+
paginationState={{ pageIndex: 0, pageSize: 10 }}
|
|
46
|
+
onPaginationChange={() => undefined}
|
|
47
|
+
showSelectedCount={false}
|
|
48
|
+
loading={loading}
|
|
49
|
+
loadingIdentity={query}
|
|
50
|
+
listState={listState}
|
|
51
|
+
/>
|
|
52
|
+
);
|
|
53
|
+
return label ? <section aria-label={label}>{table}</section> : table;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function advance(ms: number): Promise<void> {
|
|
57
|
+
await act(() => vi.advanceTimersByTimeAsync(ms));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe('standard list loading feedback', () => {
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
// jsdom has no layout; report stable heights so the table can keep geometry.
|
|
63
|
+
vi.stubGlobal(
|
|
64
|
+
'ResizeObserver',
|
|
65
|
+
class {
|
|
66
|
+
constructor(private readonly callback: () => void) {}
|
|
67
|
+
observe(): void {
|
|
68
|
+
this.callback();
|
|
69
|
+
}
|
|
70
|
+
disconnect(): void {}
|
|
71
|
+
},
|
|
72
|
+
);
|
|
73
|
+
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(
|
|
74
|
+
function (this: HTMLElement) {
|
|
75
|
+
if (this.tagName === 'TBODY') return 420;
|
|
76
|
+
if (this.dataset.slot === 'data-table-footer') return 32;
|
|
77
|
+
return 0;
|
|
78
|
+
},
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
afterEach(() => {
|
|
82
|
+
vi.useRealTimers();
|
|
83
|
+
vi.unstubAllGlobals();
|
|
84
|
+
vi.restoreAllMocks();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('marks a first load busy at once and shows one status only after 300ms', async () => {
|
|
88
|
+
const view = await renderWithApp(
|
|
89
|
+
<Table rows={[]} loading={false} listState='ready' />,
|
|
90
|
+
options,
|
|
91
|
+
);
|
|
92
|
+
vi.useFakeTimers();
|
|
93
|
+
view.rerender(<Table rows={[]} loading listState='loading' />);
|
|
94
|
+
// The loading body is the busy region; its status sits outside it so it is announced.
|
|
95
|
+
expect(screen.getByRole('table')).not.toHaveAttribute('aria-busy', 'true');
|
|
96
|
+
expect(document.querySelector('[data-slot="loading"]')).toHaveAttribute(
|
|
97
|
+
'aria-busy',
|
|
98
|
+
'true',
|
|
99
|
+
);
|
|
100
|
+
expect(screen.getByRole('columnheader', { name: 'ID' })).toBeVisible();
|
|
101
|
+
await advance(299);
|
|
102
|
+
expect(screen.queryByRole('status')).toBeNull();
|
|
103
|
+
await advance(1);
|
|
104
|
+
expect(screen.getAllByRole('status')).toHaveLength(1);
|
|
105
|
+
expect(screen.getByRole('status')).toHaveTextContent(
|
|
106
|
+
enUS.listFeedback.loading,
|
|
107
|
+
);
|
|
108
|
+
expect(screen.getByRole('status').closest('[aria-busy="true"]')).toBeNull();
|
|
109
|
+
await advance(9700);
|
|
110
|
+
expect(screen.getByRole('status')).toHaveTextContent(
|
|
111
|
+
enUS.pendingFeedback.longWait,
|
|
112
|
+
);
|
|
113
|
+
view.rerender(
|
|
114
|
+
<Table rows={[{ id: 'Camera' }]} loading={false} listState='ready' />,
|
|
115
|
+
);
|
|
116
|
+
expect(screen.queryByRole('status')).toBeNull();
|
|
117
|
+
expect(screen.getByRole('cell', { name: 'Camera' })).toBeVisible();
|
|
118
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('keeps a paused unresolved query silent without showing cached rows or empty results', async () => {
|
|
122
|
+
const view = await renderWithApp(
|
|
123
|
+
<Table
|
|
124
|
+
rows={[{ id: 'Previous query' }]}
|
|
125
|
+
loading={false}
|
|
126
|
+
listState='ready'
|
|
127
|
+
total={45}
|
|
128
|
+
/>,
|
|
129
|
+
options,
|
|
130
|
+
);
|
|
131
|
+
const table = screen.getByRole('table');
|
|
132
|
+
vi.useFakeTimers();
|
|
133
|
+
view.rerender(
|
|
134
|
+
<Table rows={[]} loading listState='loading' query='interrupted' />,
|
|
135
|
+
);
|
|
136
|
+
await advance(10_000);
|
|
137
|
+
expect(screen.getByRole('status')).toHaveTextContent(
|
|
138
|
+
enUS.pendingFeedback.longWait,
|
|
139
|
+
);
|
|
140
|
+
view.rerender(
|
|
141
|
+
<Table
|
|
142
|
+
rows={[{ id: 'Previous query' }]}
|
|
143
|
+
loading={false}
|
|
144
|
+
listState='loading'
|
|
145
|
+
total={45}
|
|
146
|
+
/>,
|
|
147
|
+
);
|
|
148
|
+
await advance(10_000);
|
|
149
|
+
expect(screen.queryByRole('status')).toBeNull();
|
|
150
|
+
expect(document.querySelector('[aria-busy="true"]')).toBeNull();
|
|
151
|
+
expect(screen.queryByText('Previous query')).toBeNull();
|
|
152
|
+
expect(screen.queryByText(enUS.listFeedback.empty)).toBeNull();
|
|
153
|
+
expect(screen.getByRole('table')).toBe(table);
|
|
154
|
+
expect(screen.getAllByRole('cell')[0]).toHaveStyle({ height: '420px' });
|
|
155
|
+
view.rerender(
|
|
156
|
+
<Table rows={[]} loading listState='loading' query='resumed' />,
|
|
157
|
+
);
|
|
158
|
+
await advance(299);
|
|
159
|
+
expect(screen.queryByRole('status')).toBeNull();
|
|
160
|
+
await advance(1);
|
|
161
|
+
expect(screen.getByRole('status')).toHaveTextContent(
|
|
162
|
+
enUS.listFeedback.loading,
|
|
163
|
+
);
|
|
164
|
+
expect(screen.getByRole('table')).toBe(table);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('shows nothing for a fast read', async () => {
|
|
168
|
+
const view = await renderWithApp(
|
|
169
|
+
<Table rows={[]} loading listState='loading' />,
|
|
170
|
+
options,
|
|
171
|
+
);
|
|
172
|
+
vi.useFakeTimers();
|
|
173
|
+
view.rerender(<Table rows={[]} loading listState='loading' />);
|
|
174
|
+
await advance(200);
|
|
175
|
+
view.rerender(
|
|
176
|
+
<Table rows={[{ id: 'Camera' }]} loading={false} listState='ready' />,
|
|
177
|
+
);
|
|
178
|
+
await advance(1000);
|
|
179
|
+
expect(screen.queryByRole('status')).toBeNull();
|
|
180
|
+
expect(screen.getByRole('cell', { name: 'Camera' })).toBeVisible();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('keeps rows and pagination during a same-query refresh and indicates it after 300ms', async () => {
|
|
184
|
+
const rows = [{ id: 'Camera' }, { id: 'Lens' }];
|
|
185
|
+
const view = await renderWithApp(
|
|
186
|
+
<Table rows={rows} loading={false} listState='ready' total={25} />,
|
|
187
|
+
options,
|
|
188
|
+
);
|
|
189
|
+
vi.useFakeTimers();
|
|
190
|
+
view.rerender(<Table rows={rows} loading listState='ready' total={25} />);
|
|
191
|
+
expect(screen.getByRole('cell', { name: 'Camera' })).toBeVisible();
|
|
192
|
+
expect(screen.getByRole('table')).toHaveAttribute('aria-busy', 'true');
|
|
193
|
+
await advance(299);
|
|
194
|
+
expect(screen.queryByRole('status', { name: refreshing })).toBeNull();
|
|
195
|
+
await advance(1);
|
|
196
|
+
expect(screen.getByRole('status', { name: refreshing })).toBeVisible();
|
|
197
|
+
// The indicator sits outside the busy table so it can be announced.
|
|
198
|
+
expect(
|
|
199
|
+
screen.getByRole('status', { name: refreshing }).closest('table'),
|
|
200
|
+
).toBeNull();
|
|
201
|
+
expect(screen.getByRole('cell', { name: 'Lens' })).toBeVisible();
|
|
202
|
+
expect(screen.getByText('Page 1 of 3')).toBeVisible();
|
|
203
|
+
await advance(9700);
|
|
204
|
+
expect(
|
|
205
|
+
screen.getByRole('status', { name: enUS.pendingFeedback.longWait }),
|
|
206
|
+
).toBeVisible();
|
|
207
|
+
view.rerender(
|
|
208
|
+
<Table
|
|
209
|
+
rows={[{ id: 'Camera v2' }]}
|
|
210
|
+
loading={false}
|
|
211
|
+
listState='ready'
|
|
212
|
+
total={25}
|
|
213
|
+
/>,
|
|
214
|
+
);
|
|
215
|
+
expect(screen.queryByRole('status')).toBeNull();
|
|
216
|
+
expect(screen.getByRole('cell', { name: 'Camera v2' })).toBeVisible();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('hides a previous query at once but keeps its body and pagination height without its page count', async () => {
|
|
220
|
+
const previous = [{ id: 'Previous match' }];
|
|
221
|
+
const view = await renderWithApp(
|
|
222
|
+
<Table rows={previous} loading={false} listState='ready' total={45} />,
|
|
223
|
+
options,
|
|
224
|
+
);
|
|
225
|
+
expect(screen.getByText('Page 1 of 5')).toBeVisible();
|
|
226
|
+
vi.useFakeTimers();
|
|
227
|
+
// A new search: the host still holds the previous response as placeholder data.
|
|
228
|
+
view.rerender(
|
|
229
|
+
<Table rows={previous} loading listState='loading' total={45} />,
|
|
230
|
+
);
|
|
231
|
+
expect(screen.queryByText('Previous match')).toBeNull();
|
|
232
|
+
const cell = screen.getAllByRole('cell')[0]!;
|
|
233
|
+
expect(cell).toHaveStyle({ height: '420px' });
|
|
234
|
+
const footer = document.querySelector('[data-slot="data-table-footer"]')!;
|
|
235
|
+
expect(footer).not.toBeNull();
|
|
236
|
+
const count = within(footer as HTMLElement).getByText('Page 1 of 5');
|
|
237
|
+
expect(count).toHaveClass('invisible');
|
|
238
|
+
expect(count).toHaveAttribute('aria-hidden', 'true');
|
|
239
|
+
for (const button of within(footer as HTMLElement).getAllByRole('button'))
|
|
240
|
+
expect(button).toBeDisabled();
|
|
241
|
+
await advance(300);
|
|
242
|
+
expect(screen.getByRole('status')).toHaveTextContent(
|
|
243
|
+
enUS.listFeedback.loading,
|
|
244
|
+
);
|
|
245
|
+
view.rerender(
|
|
246
|
+
<Table
|
|
247
|
+
rows={[{ id: 'New match' }]}
|
|
248
|
+
loading={false}
|
|
249
|
+
listState='ready'
|
|
250
|
+
total={1}
|
|
251
|
+
/>,
|
|
252
|
+
);
|
|
253
|
+
expect(screen.getByRole('cell', { name: 'New match' })).toBeVisible();
|
|
254
|
+
expect(screen.getByText('Page 1 of 1')).toBeVisible();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('restarts the display delay for each new query without remounting the table', async () => {
|
|
258
|
+
const view = await renderWithApp(
|
|
259
|
+
<Table rows={[{ id: 'Old' }]} loading={false} listState='ready' />,
|
|
260
|
+
options,
|
|
261
|
+
);
|
|
262
|
+
vi.useFakeTimers();
|
|
263
|
+
const tableNode = screen.getByRole('table');
|
|
264
|
+
// Query A has waited past the long-wait threshold.
|
|
265
|
+
view.rerender(<Table rows={[]} loading listState='loading' query='a' />);
|
|
266
|
+
await advance(10_000);
|
|
267
|
+
expect(screen.getByRole('status')).toHaveTextContent(
|
|
268
|
+
enUS.pendingFeedback.longWait,
|
|
269
|
+
);
|
|
270
|
+
// Query B replaces it: nothing until its own 300ms, no inherited long wait.
|
|
271
|
+
view.rerender(<Table rows={[]} loading listState='loading' query='b' />);
|
|
272
|
+
expect(screen.queryByRole('status')).toBeNull();
|
|
273
|
+
await advance(299);
|
|
274
|
+
expect(screen.queryByRole('status')).toBeNull();
|
|
275
|
+
await advance(1);
|
|
276
|
+
expect(screen.getByRole('status')).toHaveTextContent(
|
|
277
|
+
enUS.listFeedback.loading,
|
|
278
|
+
);
|
|
279
|
+
await advance(9699);
|
|
280
|
+
expect(screen.getByRole('status')).toHaveTextContent(
|
|
281
|
+
enUS.listFeedback.loading,
|
|
282
|
+
);
|
|
283
|
+
await advance(1);
|
|
284
|
+
expect(screen.getByRole('status')).toHaveTextContent(
|
|
285
|
+
enUS.pendingFeedback.longWait,
|
|
286
|
+
);
|
|
287
|
+
// Query C answers within 200ms: it never flashes feedback.
|
|
288
|
+
view.rerender(<Table rows={[]} loading listState='loading' query='c' />);
|
|
289
|
+
await advance(200);
|
|
290
|
+
expect(screen.queryByRole('status')).toBeNull();
|
|
291
|
+
view.rerender(
|
|
292
|
+
<Table
|
|
293
|
+
rows={[{ id: 'C' }]}
|
|
294
|
+
loading={false}
|
|
295
|
+
listState='ready'
|
|
296
|
+
query='c'
|
|
297
|
+
/>,
|
|
298
|
+
);
|
|
299
|
+
await advance(1000);
|
|
300
|
+
expect(screen.queryByRole('status')).toBeNull();
|
|
301
|
+
// The same table stayed mounted and kept the earlier rows' height while loading.
|
|
302
|
+
expect(screen.getByRole('table')).toBe(tableNode);
|
|
303
|
+
expect(screen.getByRole('cell', { name: 'C' })).toBeVisible();
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("keeps the previous rows' geometry across consecutive query changes", async () => {
|
|
307
|
+
const view = await renderWithApp(
|
|
308
|
+
<Table rows={[{ id: 'Old' }]} loading={false} listState='ready' />,
|
|
309
|
+
options,
|
|
310
|
+
);
|
|
311
|
+
view.rerender(<Table rows={[]} loading listState='loading' query='a' />);
|
|
312
|
+
expect(screen.getAllByRole('cell')[0]).toHaveStyle({ height: '420px' });
|
|
313
|
+
view.rerender(<Table rows={[]} loading listState='loading' query='b' />);
|
|
314
|
+
expect(screen.getAllByRole('cell')[0]).toHaveStyle({ height: '420px' });
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('keeps feedback of concurrent regions independent', async () => {
|
|
318
|
+
const view = await renderWithApp(
|
|
319
|
+
<>
|
|
320
|
+
<Table
|
|
321
|
+
label='Summary'
|
|
322
|
+
rows={[{ id: 'A' }]}
|
|
323
|
+
loading={false}
|
|
324
|
+
listState='ready'
|
|
325
|
+
/>
|
|
326
|
+
<Table
|
|
327
|
+
label='Contacts'
|
|
328
|
+
rows={[{ id: 'B' }]}
|
|
329
|
+
loading={false}
|
|
330
|
+
listState='ready'
|
|
331
|
+
/>
|
|
332
|
+
</>,
|
|
333
|
+
options,
|
|
334
|
+
);
|
|
335
|
+
vi.useFakeTimers();
|
|
336
|
+
view.rerender(
|
|
337
|
+
<>
|
|
338
|
+
<Table label='Summary' rows={[{ id: 'A' }]} loading listState='ready' />
|
|
339
|
+
<Table
|
|
340
|
+
label='Contacts'
|
|
341
|
+
rows={[{ id: 'B' }]}
|
|
342
|
+
loading
|
|
343
|
+
listState='ready'
|
|
344
|
+
/>
|
|
345
|
+
</>,
|
|
346
|
+
);
|
|
347
|
+
await advance(300);
|
|
348
|
+
const region = (name: string) =>
|
|
349
|
+
within(screen.getByRole('region', { name }));
|
|
350
|
+
expect(region('Summary').getByRole('status')).toBeVisible();
|
|
351
|
+
expect(region('Contacts').getByRole('status')).toBeVisible();
|
|
352
|
+
view.rerender(
|
|
353
|
+
<>
|
|
354
|
+
<Table
|
|
355
|
+
label='Summary'
|
|
356
|
+
rows={[{ id: 'A' }]}
|
|
357
|
+
loading={false}
|
|
358
|
+
listState='ready'
|
|
359
|
+
/>
|
|
360
|
+
<Table
|
|
361
|
+
label='Contacts'
|
|
362
|
+
rows={[{ id: 'B' }]}
|
|
363
|
+
loading
|
|
364
|
+
listState='ready'
|
|
365
|
+
/>
|
|
366
|
+
</>,
|
|
367
|
+
);
|
|
368
|
+
expect(region('Summary').queryByRole('status')).toBeNull();
|
|
369
|
+
expect(region('Contacts').getByRole('status')).toBeVisible();
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
describe('retry after a refresh failure', () => {
|
|
374
|
+
afterEach(() => vi.useRealTimers());
|
|
375
|
+
|
|
376
|
+
it('announces the wait once, from the retry button', async () => {
|
|
377
|
+
const rows = [{ id: 'Camera' }];
|
|
378
|
+
const table = (loading: boolean) => (
|
|
379
|
+
<DataTable
|
|
380
|
+
columns={columns}
|
|
381
|
+
data={rows}
|
|
382
|
+
listState='refresh-failed'
|
|
383
|
+
loading={loading}
|
|
384
|
+
onRetry={() => undefined}
|
|
385
|
+
/>
|
|
386
|
+
);
|
|
387
|
+
const view = await renderWithApp(table(false), options);
|
|
388
|
+
vi.useFakeTimers();
|
|
389
|
+
view.rerender(table(true));
|
|
390
|
+
await act(() => vi.advanceTimersByTimeAsync(300));
|
|
391
|
+
expect(screen.getAllByRole('status')).toHaveLength(1);
|
|
392
|
+
expect(
|
|
393
|
+
screen.getByRole('button', { name: enUS.listFeedback.retrying }),
|
|
394
|
+
).toBeDisabled();
|
|
395
|
+
expect(screen.getByRole('cell', { name: 'Camera' })).toBeVisible();
|
|
396
|
+
});
|
|
397
|
+
});
|
|
@@ -1,25 +1,34 @@
|
|
|
1
|
-
import { render, screen } from '@testing-library/react';
|
|
2
|
-
import { describe, expect, it } from 'vitest';
|
|
3
|
-
|
|
1
|
+
import { act, render, screen } from '@testing-library/react';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
3
|
import { Loading } from '../../client/components/loading';
|
|
5
4
|
|
|
5
|
+
beforeEach(() => vi.useFakeTimers());
|
|
6
|
+
afterEach(() => vi.useRealTimers());
|
|
6
7
|
describe('Loading', () => {
|
|
7
|
-
it('
|
|
8
|
-
render(<Loading label='Loading page' />);
|
|
9
|
-
|
|
8
|
+
it('marks the region busy immediately, then announces once after 300ms', () => {
|
|
9
|
+
const view = render(<Loading label='Loading page' />);
|
|
10
|
+
expect(
|
|
11
|
+
view.container.querySelector('[data-slot="loading"]'),
|
|
12
|
+
).toHaveAttribute('aria-busy', 'true');
|
|
13
|
+
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
|
14
|
+
act(() => vi.advanceTimersByTime(300));
|
|
10
15
|
expect(
|
|
11
16
|
screen.getByRole('status', { name: 'Loading page' }),
|
|
12
17
|
).toBeInTheDocument();
|
|
13
18
|
expect(screen.getAllByRole('status')).toHaveLength(1);
|
|
19
|
+
view.rerender(<Loading pending={false} />);
|
|
20
|
+
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
|
14
21
|
});
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
expect(screen.getByRole('status')).toHaveClass(
|
|
22
|
+
it('keeps the startup container while delayed, without adding a region height by default', () => {
|
|
23
|
+
const view = render(<Loading fullscreen />);
|
|
24
|
+
expect(view.container.querySelector('[data-slot="loading"]')).toHaveClass(
|
|
20
25
|
'min-h-svh',
|
|
21
26
|
'w-full',
|
|
22
27
|
'bg-background',
|
|
23
28
|
);
|
|
29
|
+
view.rerender(<Loading />);
|
|
30
|
+
expect(
|
|
31
|
+
view.container.querySelector('[data-slot="loading"]'),
|
|
32
|
+
).not.toHaveClass('min-h-svh');
|
|
24
33
|
});
|
|
25
34
|
});
|
|
@@ -98,7 +98,7 @@ describe.each(Object.keys(adapters) as (keyof typeof adapters)[])(
|
|
|
98
98
|
);
|
|
99
99
|
expect(screen.getByText('Original row')).toBeVisible();
|
|
100
100
|
expect(
|
|
101
|
-
screen.
|
|
101
|
+
await screen.findByRole('button', { name: 'Retrying…' }),
|
|
102
102
|
).toBeDisabled();
|
|
103
103
|
expect(calls).toHaveLength(3);
|
|
104
104
|
expect(new Set(calls).size).toBe(1);
|
|
@@ -177,7 +177,7 @@ describe.each(Object.keys(adapters) as (keyof typeof adapters)[])(
|
|
|
177
177
|
await userEvent.click(
|
|
178
178
|
screen.getByRole('button', { name: 'Change query' }),
|
|
179
179
|
);
|
|
180
|
-
expect(screen.
|
|
180
|
+
expect(await screen.findByRole('status')).toHaveTextContent('Loading');
|
|
181
181
|
expect(screen.queryByText('First row')).not.toBeInTheDocument();
|
|
182
182
|
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
|
183
183
|
await act(async () => current.resolve({ data: ['Second row'] }));
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import type { AppClientRegisteredRoute } from '@nocobase/app-client/plugins';
|
|
2
|
+
import {
|
|
3
|
+
PageBreadcrumbProvider,
|
|
4
|
+
usePageBreadcrumb,
|
|
5
|
+
} from '@nocobase/app-client';
|
|
6
|
+
import {
|
|
7
|
+
TestI18nProvider,
|
|
8
|
+
createTestI18nRuntime,
|
|
9
|
+
} from '@nocobase/i18n/testing';
|
|
10
|
+
import { render, screen } from '@testing-library/react';
|
|
11
|
+
import { MemoryRouter } from 'react-router';
|
|
12
|
+
import { describe, expect, it } from 'vitest';
|
|
13
|
+
import { Breadcrumbs } from '../../client/components/breadcrumbs.js';
|
|
14
|
+
import { RouteTreeProvider } from '../../client/routing/route-context.js';
|
|
15
|
+
import { navigationDestination } from '../../client/routing/navigation-destination.js';
|
|
16
|
+
import { routeKey } from '../../client/routing/route-navigation.js';
|
|
17
|
+
import enUS from '../../client/locales/en-US.js';
|
|
18
|
+
|
|
19
|
+
const runtime = await createTestI18nRuntime({
|
|
20
|
+
application: { namespace: 'test', resources: enUS },
|
|
21
|
+
strict: false,
|
|
22
|
+
});
|
|
23
|
+
const page = (
|
|
24
|
+
id: string,
|
|
25
|
+
path: string,
|
|
26
|
+
children: AppClientRegisteredRoute[] = [],
|
|
27
|
+
): AppClientRegisteredRoute => ({
|
|
28
|
+
id,
|
|
29
|
+
name: id,
|
|
30
|
+
path,
|
|
31
|
+
packageName: 'test',
|
|
32
|
+
source: 'application',
|
|
33
|
+
auth: 'required',
|
|
34
|
+
authz: 'skip',
|
|
35
|
+
children,
|
|
36
|
+
componentLoader: async () => ({ default: () => null }),
|
|
37
|
+
});
|
|
38
|
+
const detail = page('detail', '/records/:id');
|
|
39
|
+
const parent = page('parent', '/orders');
|
|
40
|
+
const source = page('source', '/orders/completed');
|
|
41
|
+
const routes = [parent, source, detail];
|
|
42
|
+
const allowed = { denied: new Set<string>(), loading: false };
|
|
43
|
+
|
|
44
|
+
function Record() {
|
|
45
|
+
usePageBreadcrumb([
|
|
46
|
+
{ label: 'Orders', to: '/orders' },
|
|
47
|
+
{
|
|
48
|
+
label: 'Completed',
|
|
49
|
+
to: '/orders/completed?q=alpha&page=2&pageSize=20&sort=name&order=desc',
|
|
50
|
+
},
|
|
51
|
+
{ label: 'A very long record name / 长记录名称' },
|
|
52
|
+
]);
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
function trailView(access = allowed) {
|
|
56
|
+
return (
|
|
57
|
+
<TestI18nProvider runtime={runtime}>
|
|
58
|
+
<MemoryRouter basename='/main' initialEntries={['/main/records/42']}>
|
|
59
|
+
<RouteTreeProvider routes={routes} access={access}>
|
|
60
|
+
<PageBreadcrumbProvider>
|
|
61
|
+
<Breadcrumbs />
|
|
62
|
+
<Record />
|
|
63
|
+
</PageBreadcrumbProvider>
|
|
64
|
+
</RouteTreeProvider>
|
|
65
|
+
</MemoryRouter>
|
|
66
|
+
</TestI18nProvider>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
function renderTrail(access = allowed) {
|
|
70
|
+
return render(trailView(access));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe('navigation destinations', () => {
|
|
74
|
+
it.each([
|
|
75
|
+
'https://example.com/orders',
|
|
76
|
+
'//example.com/orders',
|
|
77
|
+
'/\\example.com',
|
|
78
|
+
'/orders/../orders',
|
|
79
|
+
'/orders/%2e%2e/orders',
|
|
80
|
+
'/orders/%ZZ',
|
|
81
|
+
'/missing',
|
|
82
|
+
'/main/orders',
|
|
83
|
+
'orders',
|
|
84
|
+
'javascript:alert(1)',
|
|
85
|
+
])('rejects %s', (to) => {
|
|
86
|
+
expect(navigationDestination(to, routes, allowed)).toBeUndefined();
|
|
87
|
+
});
|
|
88
|
+
it('requires a page and access to all its ancestors, and fails closed during permission refresh', () => {
|
|
89
|
+
const tree = [
|
|
90
|
+
{ ...parent, componentLoader: undefined, children: [source] },
|
|
91
|
+
];
|
|
92
|
+
expect(navigationDestination('/orders', tree, allowed)).toBeUndefined();
|
|
93
|
+
expect(
|
|
94
|
+
navigationDestination('/orders/completed', tree, {
|
|
95
|
+
...allowed,
|
|
96
|
+
denied: new Set([routeKey(parent)]),
|
|
97
|
+
}),
|
|
98
|
+
).toBeUndefined();
|
|
99
|
+
expect(
|
|
100
|
+
navigationDestination('/orders/completed', tree, {
|
|
101
|
+
...allowed,
|
|
102
|
+
loading: true,
|
|
103
|
+
}),
|
|
104
|
+
).toBeUndefined();
|
|
105
|
+
});
|
|
106
|
+
it('preserves an encoded dynamic record and list query without adding the deployment base', () => {
|
|
107
|
+
expect(
|
|
108
|
+
navigationDestination('/records/a%2Fb?q=alpha&page=2', routes, allowed),
|
|
109
|
+
).toEqual({
|
|
110
|
+
pathname: '/records/a%2Fb',
|
|
111
|
+
search: '?q=alpha&page=2',
|
|
112
|
+
hash: '',
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('parent navigation', () => {
|
|
118
|
+
it('uses the same source and state for the desktop breadcrumb and mobile parent link', () => {
|
|
119
|
+
renderTrail();
|
|
120
|
+
const desktop = screen.getByRole('link', { name: 'Completed' });
|
|
121
|
+
const mobile = screen.getByRole('link', { name: 'Back to Completed' });
|
|
122
|
+
expect(mobile).toHaveAttribute('href', desktop.getAttribute('href'));
|
|
123
|
+
expect(mobile).toHaveAttribute(
|
|
124
|
+
'href',
|
|
125
|
+
'/main/orders/completed?q=alpha&page=2&pageSize=20&sort=name&order=desc',
|
|
126
|
+
);
|
|
127
|
+
expect(
|
|
128
|
+
screen.getByText('A very long record name / 长记录名称'),
|
|
129
|
+
).toHaveAttribute('aria-current', 'page');
|
|
130
|
+
});
|
|
131
|
+
it('removes a denied parent name and falls back to the next declared accessible parent', () => {
|
|
132
|
+
renderTrail({ ...allowed, denied: new Set([routeKey(source)]) });
|
|
133
|
+
expect(screen.queryByText('Completed')).not.toBeInTheDocument();
|
|
134
|
+
expect(
|
|
135
|
+
screen.getByRole('link', { name: 'Back to Orders' }),
|
|
136
|
+
).toHaveAttribute('href', '/main/orders');
|
|
137
|
+
});
|
|
138
|
+
it('keeps the current title but exposes no parents while permissions are pending', () => {
|
|
139
|
+
renderTrail({ ...allowed, loading: true });
|
|
140
|
+
expect(screen.queryByText('Orders')).not.toBeInTheDocument();
|
|
141
|
+
expect(
|
|
142
|
+
screen.queryByRole('link', { name: /^Back to/u }),
|
|
143
|
+
).not.toBeInTheDocument();
|
|
144
|
+
expect(
|
|
145
|
+
screen.getByText('A very long record name / 长记录名称'),
|
|
146
|
+
).toHaveAttribute('aria-current', 'page');
|
|
147
|
+
});
|
|
148
|
+
it('removes parent links immediately on recheck or revocation and restores them after access returns', () => {
|
|
149
|
+
const view = renderTrail();
|
|
150
|
+
expect(
|
|
151
|
+
screen.getByRole('link', { name: 'Back to Completed' }),
|
|
152
|
+
).toBeInTheDocument();
|
|
153
|
+
view.rerender(trailView({ ...allowed, loading: true }));
|
|
154
|
+
expect(screen.queryByText('Completed')).not.toBeInTheDocument();
|
|
155
|
+
view.rerender(
|
|
156
|
+
trailView({
|
|
157
|
+
...allowed,
|
|
158
|
+
denied: new Set([routeKey(parent), routeKey(source)]),
|
|
159
|
+
}),
|
|
160
|
+
);
|
|
161
|
+
expect(
|
|
162
|
+
screen.queryByRole('link', { name: /^Back to/u }),
|
|
163
|
+
).not.toBeInTheDocument();
|
|
164
|
+
expect(
|
|
165
|
+
screen.getByText('A very long record name / 长记录名称'),
|
|
166
|
+
).toHaveAttribute('aria-current', 'page');
|
|
167
|
+
view.rerender(trailView(allowed));
|
|
168
|
+
expect(
|
|
169
|
+
screen.getByRole('link', { name: 'Back to Completed' }),
|
|
170
|
+
).toBeInTheDocument();
|
|
171
|
+
});
|
|
172
|
+
});
|