@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,301 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { renderWithApp } from '@nocobase/app-testing/client';
|
|
3
|
+
import type { ColumnDef } from '@tanstack/react-table';
|
|
4
|
+
import { screen, within } from '@testing-library/react';
|
|
5
|
+
import userEvent from '@testing-library/user-event';
|
|
6
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
7
|
+
import {
|
|
8
|
+
ListFeedback,
|
|
9
|
+
ListFeedbackActions,
|
|
10
|
+
} from '../../client/components/list-feedback.js';
|
|
11
|
+
import { DataTable } from '../../client/components/data-table/index.js';
|
|
12
|
+
import enUS from '../../client/locales/en-US.js';
|
|
13
|
+
import zhCN from '../../client/locales/zh-CN.js';
|
|
14
|
+
|
|
15
|
+
const namespace = '@nocobase/list-feedback-test';
|
|
16
|
+
const namespaces = {
|
|
17
|
+
[namespace]: {
|
|
18
|
+
'en-US': async () => ({ default: enUS }),
|
|
19
|
+
'zh-CN': async () => ({ default: zhCN }),
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
const options = { namespace, namespaces };
|
|
23
|
+
|
|
24
|
+
function RetainedEditor() {
|
|
25
|
+
const [value, setValue] = useState(0);
|
|
26
|
+
return <button onClick={() => setValue(value + 1)}>Edited {value}</button>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('shared table and card feedback', () => {
|
|
30
|
+
it('keeps mounted business content and local edits through a refresh failure and recovery', async () => {
|
|
31
|
+
const view = await renderWithApp(
|
|
32
|
+
<ListFeedback state='ready'>
|
|
33
|
+
<RetainedEditor />
|
|
34
|
+
</ListFeedback>,
|
|
35
|
+
options,
|
|
36
|
+
);
|
|
37
|
+
await userEvent
|
|
38
|
+
.setup()
|
|
39
|
+
.click(screen.getByRole('button', { name: 'Edited 0' }));
|
|
40
|
+
view.rerender(
|
|
41
|
+
<ListFeedback state='refresh-failed'>
|
|
42
|
+
<RetainedEditor />
|
|
43
|
+
</ListFeedback>,
|
|
44
|
+
);
|
|
45
|
+
expect(screen.getByRole('button', { name: 'Edited 1' })).toBeVisible();
|
|
46
|
+
expect(screen.getByRole('alert').textContent).toBe(
|
|
47
|
+
enUS.listFeedback.refreshFailed,
|
|
48
|
+
);
|
|
49
|
+
view.rerender(
|
|
50
|
+
<ListFeedback state='ready'>
|
|
51
|
+
<RetainedEditor />
|
|
52
|
+
</ListFeedback>,
|
|
53
|
+
);
|
|
54
|
+
expect(screen.getByRole('button', { name: 'Edited 1' })).toBeVisible();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('clears local column filters through the table toolbar and resets its input', async () => {
|
|
58
|
+
const user = userEvent.setup();
|
|
59
|
+
await renderWithApp(
|
|
60
|
+
<DataTable
|
|
61
|
+
data={[{ id: 'Camera' }]}
|
|
62
|
+
columns={[{ accessorKey: 'id', header: 'ID' }]}
|
|
63
|
+
listState='ready'
|
|
64
|
+
rowCount={1}
|
|
65
|
+
toolbar={(table) => (
|
|
66
|
+
<input
|
|
67
|
+
aria-label='Camera filter'
|
|
68
|
+
value={String(table.getColumn('id')?.getFilterValue() ?? '')}
|
|
69
|
+
onChange={(event) =>
|
|
70
|
+
table.getColumn('id')?.setFilterValue(event.target.value)
|
|
71
|
+
}
|
|
72
|
+
/>
|
|
73
|
+
)}
|
|
74
|
+
/>,
|
|
75
|
+
options,
|
|
76
|
+
);
|
|
77
|
+
await user.type(screen.getByLabelText('Camera filter'), 'missing');
|
|
78
|
+
const status = screen.getByRole('status');
|
|
79
|
+
expect(status.textContent).toBe(enUS.listFeedback.noResults);
|
|
80
|
+
expect(status.closest('td')).not.toBeNull();
|
|
81
|
+
expect(within(status).queryByRole('button')).not.toBeInTheDocument();
|
|
82
|
+
expect(screen.getByRole('table')).toBeVisible();
|
|
83
|
+
expect(screen.getByRole('columnheader', { name: 'ID' })).toBeVisible();
|
|
84
|
+
await user.click(screen.getByRole('button', { name: 'Clear filters' }));
|
|
85
|
+
expect(screen.getByLabelText('Camera filter')).toHaveValue('');
|
|
86
|
+
expect(screen.getByRole('cell', { name: 'Camera' })).toBeVisible();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('keeps loading and denied states distinct and hides cached rows on denial', async () => {
|
|
90
|
+
const view = await renderWithApp(
|
|
91
|
+
<DataTable
|
|
92
|
+
data={[]}
|
|
93
|
+
columns={[{ accessorKey: 'id', header: 'ID' }]}
|
|
94
|
+
loading
|
|
95
|
+
emptyMessage='Business empty'
|
|
96
|
+
emptyAction={<button>Create record</button>}
|
|
97
|
+
/>,
|
|
98
|
+
options,
|
|
99
|
+
);
|
|
100
|
+
expect(screen.getByRole('status').textContent).toBe(
|
|
101
|
+
enUS.listFeedback.loading,
|
|
102
|
+
);
|
|
103
|
+
expect(screen.getByRole('columnheader', { name: 'ID' })).toBeVisible();
|
|
104
|
+
expect(screen.queryByText('Business empty')).not.toBeInTheDocument();
|
|
105
|
+
expect(screen.queryByText('Create record')).not.toBeInTheDocument();
|
|
106
|
+
view.rerender(
|
|
107
|
+
<DataTable
|
|
108
|
+
data={[{ id: 'Private row' }]}
|
|
109
|
+
columns={[{ accessorKey: 'id', header: 'ID' }]}
|
|
110
|
+
listState='forbidden'
|
|
111
|
+
emptyMessage='Business empty'
|
|
112
|
+
/>,
|
|
113
|
+
);
|
|
114
|
+
expect(screen.getByRole('alert').textContent).toBe(
|
|
115
|
+
enUS.listFeedback.forbidden,
|
|
116
|
+
);
|
|
117
|
+
expect(screen.getByRole('columnheader', { name: 'ID' })).toBeVisible();
|
|
118
|
+
expect(screen.queryByText('Private row')).not.toBeInTheDocument();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('keeps table structure and places each non-ready state in its body', async () => {
|
|
122
|
+
const user = userEvent.setup();
|
|
123
|
+
const columns: ColumnDef<{ id: string }>[] = [
|
|
124
|
+
{ accessorKey: 'id', header: 'ID' },
|
|
125
|
+
];
|
|
126
|
+
const toolbar = () => <button type='button'>Search tools</button>;
|
|
127
|
+
const view = await renderWithApp(
|
|
128
|
+
<DataTable
|
|
129
|
+
columns={columns}
|
|
130
|
+
data={[]}
|
|
131
|
+
listState='loading'
|
|
132
|
+
toolbar={toolbar}
|
|
133
|
+
onClearFilters={() => undefined}
|
|
134
|
+
onRetry={() => undefined}
|
|
135
|
+
/>,
|
|
136
|
+
options,
|
|
137
|
+
);
|
|
138
|
+
const states = [
|
|
139
|
+
['loading', 'status'],
|
|
140
|
+
['empty', 'status'],
|
|
141
|
+
['no-results', 'status'],
|
|
142
|
+
['forbidden', 'alert'],
|
|
143
|
+
['unauthenticated', 'alert'],
|
|
144
|
+
['failed', 'alert'],
|
|
145
|
+
] as const;
|
|
146
|
+
for (const [state, role] of states) {
|
|
147
|
+
view.rerender(
|
|
148
|
+
<DataTable
|
|
149
|
+
columns={columns}
|
|
150
|
+
data={[]}
|
|
151
|
+
listState={state}
|
|
152
|
+
toolbar={toolbar}
|
|
153
|
+
onClearFilters={() => undefined}
|
|
154
|
+
onRetry={() => undefined}
|
|
155
|
+
/>,
|
|
156
|
+
);
|
|
157
|
+
expect(screen.getByRole('table')).toBeVisible();
|
|
158
|
+
expect(screen.getByRole('columnheader', { name: 'ID' })).toBeVisible();
|
|
159
|
+
expect(
|
|
160
|
+
screen.getByRole('button', { name: 'Search tools' }),
|
|
161
|
+
).toBeVisible();
|
|
162
|
+
expect(screen.getByRole(role).closest('td')).not.toBeNull();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
view.rerender(
|
|
166
|
+
<DataTable
|
|
167
|
+
columns={columns}
|
|
168
|
+
data={[{ id: 'Previous result' }]}
|
|
169
|
+
listState='refresh-failed'
|
|
170
|
+
toolbar={toolbar}
|
|
171
|
+
onRetry={() => undefined}
|
|
172
|
+
/>,
|
|
173
|
+
);
|
|
174
|
+
expect(screen.getByRole('alert').closest('td')).not.toBeNull();
|
|
175
|
+
expect(screen.getByText('Previous result')).toBeVisible();
|
|
176
|
+
expect(screen.getByRole('button', { name: 'Search tools' })).toBeVisible();
|
|
177
|
+
await user.click(screen.getByRole('button', { name: 'Retry' }));
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('renders each built-in state as one icon and one short line without inline actions', async () => {
|
|
181
|
+
const view = await renderWithApp(<ListFeedback state='loading' />, options);
|
|
182
|
+
const states = [
|
|
183
|
+
['loading', 'status'],
|
|
184
|
+
['empty', 'status'],
|
|
185
|
+
['no-results', 'status'],
|
|
186
|
+
['forbidden', 'alert'],
|
|
187
|
+
['unauthenticated', 'alert'],
|
|
188
|
+
['failed', 'alert'],
|
|
189
|
+
] as const;
|
|
190
|
+
for (const [state, role] of states) {
|
|
191
|
+
view.rerender(<ListFeedback state={state} />);
|
|
192
|
+
const status = screen.getByRole(role);
|
|
193
|
+
expect(status.textContent).toBe(
|
|
194
|
+
enUS.listFeedback[state === 'no-results' ? 'noResults' : state],
|
|
195
|
+
);
|
|
196
|
+
expect(status.querySelector('svg')).toHaveAttribute(
|
|
197
|
+
'aria-hidden',
|
|
198
|
+
'true',
|
|
199
|
+
);
|
|
200
|
+
expect(within(status).queryByRole('button')).not.toBeInTheDocument();
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('preserves custom empty content and its intentional business action', async () => {
|
|
205
|
+
await renderWithApp(
|
|
206
|
+
<ListFeedback
|
|
207
|
+
state='empty'
|
|
208
|
+
empty='Business empty'
|
|
209
|
+
emptyAction={<button>Create record</button>}
|
|
210
|
+
/>,
|
|
211
|
+
options,
|
|
212
|
+
);
|
|
213
|
+
expect(screen.getByText(enUS.listFeedback.empty)).toBeVisible();
|
|
214
|
+
expect(screen.getByText('Business empty')).toBeVisible();
|
|
215
|
+
expect(screen.getByRole('button', { name: 'Create record' })).toBeVisible();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('clears filtered results from the host toolbar with keyboard access', async () => {
|
|
219
|
+
const clear = vi.fn();
|
|
220
|
+
await renderWithApp(
|
|
221
|
+
<>
|
|
222
|
+
<div>
|
|
223
|
+
<ListFeedbackActions state='no-results' onClear={clear} />
|
|
224
|
+
</div>
|
|
225
|
+
<ListFeedback state='no-results' />
|
|
226
|
+
</>,
|
|
227
|
+
{ ...options, locale: 'zh-CN' },
|
|
228
|
+
);
|
|
229
|
+
const status = screen.getByRole('status');
|
|
230
|
+
expect(status.textContent).toBe(zhCN.listFeedback.noResults);
|
|
231
|
+
expect(within(status).queryByRole('button')).not.toBeInTheDocument();
|
|
232
|
+
const button = screen.getByRole('button', {
|
|
233
|
+
name: zhCN.listFeedback.clear,
|
|
234
|
+
});
|
|
235
|
+
button.focus();
|
|
236
|
+
await userEvent.setup().keyboard('{Enter}');
|
|
237
|
+
expect(clear).toHaveBeenCalledOnce();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('keeps one stale-data line while showing old content and places retry outside it', async () => {
|
|
241
|
+
const retry = vi.fn();
|
|
242
|
+
await renderWithApp(
|
|
243
|
+
<>
|
|
244
|
+
<div>
|
|
245
|
+
<ListFeedbackActions state='refresh-failed' onRetry={retry} />
|
|
246
|
+
</div>
|
|
247
|
+
<ListFeedback state='refresh-failed'>
|
|
248
|
+
<p>Retained equipment record</p>
|
|
249
|
+
</ListFeedback>
|
|
250
|
+
</>,
|
|
251
|
+
options,
|
|
252
|
+
);
|
|
253
|
+
const alert = screen.getByRole('alert');
|
|
254
|
+
expect(alert.textContent).toBe(enUS.listFeedback.refreshFailed);
|
|
255
|
+
expect(within(alert).queryByRole('button')).not.toBeInTheDocument();
|
|
256
|
+
expect(screen.getByText('Retained equipment record')).toBeVisible();
|
|
257
|
+
await userEvent
|
|
258
|
+
.setup()
|
|
259
|
+
.click(screen.getByRole('button', { name: 'Retry' }));
|
|
260
|
+
expect(retry).toHaveBeenCalledOnce();
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('keeps retry busy feedback in the toolbar', async () => {
|
|
264
|
+
await renderWithApp(
|
|
265
|
+
<>
|
|
266
|
+
<div>
|
|
267
|
+
<ListFeedbackActions state='failed' onRetry={vi.fn()} busy />
|
|
268
|
+
</div>
|
|
269
|
+
<ListFeedback state='failed' />
|
|
270
|
+
</>,
|
|
271
|
+
options,
|
|
272
|
+
);
|
|
273
|
+
expect(screen.getByRole('alert').textContent).toBe(
|
|
274
|
+
enUS.listFeedback.failed,
|
|
275
|
+
);
|
|
276
|
+
expect(
|
|
277
|
+
screen.getByRole('button', { name: enUS.listFeedback.retrying }),
|
|
278
|
+
).toBeDisabled();
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('uses the existing login route outside the session status', async () => {
|
|
282
|
+
await renderWithApp(
|
|
283
|
+
<>
|
|
284
|
+
<div>
|
|
285
|
+
<ListFeedbackActions state='unauthenticated' />
|
|
286
|
+
</div>
|
|
287
|
+
<ListFeedback state='unauthenticated'>
|
|
288
|
+
<p>Private record</p>
|
|
289
|
+
</ListFeedback>
|
|
290
|
+
</>,
|
|
291
|
+
options,
|
|
292
|
+
);
|
|
293
|
+
expect(screen.getByRole('alert').textContent).toBe(
|
|
294
|
+
enUS.listFeedback.unauthenticated,
|
|
295
|
+
);
|
|
296
|
+
expect(screen.queryByText('Private record')).not.toBeInTheDocument();
|
|
297
|
+
expect(
|
|
298
|
+
screen.getByRole('button', { name: enUS.listFeedback.signIn }),
|
|
299
|
+
).toHaveAttribute('href', '/login');
|
|
300
|
+
});
|
|
301
|
+
});
|
|
@@ -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
|
+
});
|