@nocobase/app-template-default 1.0.0-beta.84 → 1.0.0-beta.86
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/client/components/data-table/data-table.tsx +86 -32
- package/client/components/list-feedback.tsx +174 -0
- package/client/components/permission-editor.tsx +40 -33
- 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 +1 -2
- package/client/locales/system/en-US.ts +14 -1
- package/client/locales/system/settings-en-US.ts +0 -10
- package/client/locales/system/settings-zh-CN.ts +0 -8
- package/client/locales/system/zh-CN.ts +14 -1
- package/client/pages/settings/default-access.tsx +50 -72
- package/client/pages/settings/permissions-model.ts +0 -1
- package/client/pages/settings/roles.tsx +63 -97
- package/client/pages/settings/users.tsx +72 -70
- package/client/runtime.ts +0 -4
- package/config.example.yml +24 -0
- package/package.json +33 -31
- 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/tests/client/data-table.test.tsx +9 -4
- package/tests/client/default-access.test.tsx +43 -5
- package/tests/client/list-feedback.test.tsx +301 -0
- package/tests/client/manual-list-retry.test.tsx +190 -0
- package/tests/client/permission-workspace.test.ts +13 -0
- package/tests/client/settings-continuity.test.tsx +80 -1
- package/tests/client/settings-feedback.test.tsx +7 -15
- package/tests/fixtures/permission-alignment.html +16 -0
- package/tests/fixtures/permission-alignment.tsx +124 -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 +125 -0
- package/tests/server/config.test.ts +34 -14
- 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,190 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { answerApi, renderWithApp } from '@nocobase/app-testing/client';
|
|
3
|
+
import { resolveListState } from '@nocobase/app-client';
|
|
4
|
+
import { act, screen, waitFor } from '@testing-library/react';
|
|
5
|
+
import userEvent from '@testing-library/user-event';
|
|
6
|
+
import { describe, expect, it } from 'vitest';
|
|
7
|
+
import { useSettingsData } from '../../client/pages/settings/hooks.js';
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
ListFeedback,
|
|
11
|
+
ListFeedbackActions,
|
|
12
|
+
} from '../../client/components/list-feedback.js';
|
|
13
|
+
import enUS from '../../client/locales/en-US.js';
|
|
14
|
+
|
|
15
|
+
type Rows = { data: string[] };
|
|
16
|
+
const options = {
|
|
17
|
+
namespace: '@nocobase/manual-list-test',
|
|
18
|
+
namespaces: {
|
|
19
|
+
'@nocobase/manual-list-test': {
|
|
20
|
+
'en-US': async () => ({ default: enUS }),
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
function deferred() {
|
|
25
|
+
let resolve!: (value: Rows | Response) => void;
|
|
26
|
+
const promise = new Promise<Rows | Response>((done) => {
|
|
27
|
+
resolve = done;
|
|
28
|
+
});
|
|
29
|
+
return { promise, resolve };
|
|
30
|
+
}
|
|
31
|
+
function failure(status = 500) {
|
|
32
|
+
return new Response(JSON.stringify({ error: { message: 'Unavailable' } }), {
|
|
33
|
+
status,
|
|
34
|
+
headers: { 'content-type': 'application/json' },
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
const adapters = {
|
|
38
|
+
useSettings: (query: string) =>
|
|
39
|
+
useSettingsData<Rows>(`manual-list?q=${query}&page=3&orderBy=title`),
|
|
40
|
+
};
|
|
41
|
+
function Harness({ kind }: { readonly kind: keyof typeof adapters }) {
|
|
42
|
+
const [query, setQuery] = useState('first');
|
|
43
|
+
const useData = adapters[kind];
|
|
44
|
+
const request = useData(query);
|
|
45
|
+
const state = resolveListState(request, request.data?.data.length ?? 0);
|
|
46
|
+
const useRetryAction = state === 'failed' || state === 'refresh-failed';
|
|
47
|
+
return (
|
|
48
|
+
<>
|
|
49
|
+
<div>
|
|
50
|
+
{useRetryAction ? (
|
|
51
|
+
<ListFeedbackActions
|
|
52
|
+
state={state}
|
|
53
|
+
onRetry={request.reload}
|
|
54
|
+
busy={request.loading}
|
|
55
|
+
/>
|
|
56
|
+
) : (
|
|
57
|
+
<button onClick={request.reload}>Refresh</button>
|
|
58
|
+
)}
|
|
59
|
+
<button onClick={() => setQuery('second')}>Change query</button>
|
|
60
|
+
<ListFeedbackActions state={state} />
|
|
61
|
+
</div>
|
|
62
|
+
<ListFeedback state={state}>
|
|
63
|
+
{request.data?.data.map((row) => (
|
|
64
|
+
<p key={row}>{row}</p>
|
|
65
|
+
))}
|
|
66
|
+
</ListFeedback>
|
|
67
|
+
</>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
describe.each(Object.keys(adapters) as (keyof typeof adapters)[])(
|
|
71
|
+
'%s manual list',
|
|
72
|
+
(kind) => {
|
|
73
|
+
it.each(['success', 'failure'] as const)(
|
|
74
|
+
'retains the stale warning during retry until %s',
|
|
75
|
+
async (outcome) => {
|
|
76
|
+
let response: Rows | Response | Promise<Rows | Response> = {
|
|
77
|
+
data: ['Original row'],
|
|
78
|
+
};
|
|
79
|
+
const calls: string[] = [];
|
|
80
|
+
await renderWithApp(<Harness kind={kind} />, {
|
|
81
|
+
...options,
|
|
82
|
+
fetch: answerApi(({ path, query }) => {
|
|
83
|
+
calls.push(JSON.stringify({ path, query }));
|
|
84
|
+
return response;
|
|
85
|
+
}),
|
|
86
|
+
});
|
|
87
|
+
await screen.findByText('Original row');
|
|
88
|
+
response = failure();
|
|
89
|
+
await userEvent.click(screen.getByRole('button', { name: 'Refresh' }));
|
|
90
|
+
await screen.findByText(enUS.listFeedback.refreshFailed);
|
|
91
|
+
const pending = deferred();
|
|
92
|
+
response = pending.promise;
|
|
93
|
+
await userEvent.click(
|
|
94
|
+
screen.getByRole('button', { name: 'Retry', exact: true }),
|
|
95
|
+
);
|
|
96
|
+
expect(screen.getByRole('alert')).toHaveTextContent(
|
|
97
|
+
enUS.listFeedback.refreshFailed,
|
|
98
|
+
);
|
|
99
|
+
expect(screen.getByText('Original row')).toBeVisible();
|
|
100
|
+
expect(
|
|
101
|
+
screen.getByRole('button', { name: 'Retrying…' }),
|
|
102
|
+
).toBeDisabled();
|
|
103
|
+
expect(calls).toHaveLength(3);
|
|
104
|
+
expect(new Set(calls).size).toBe(1);
|
|
105
|
+
await act(async () =>
|
|
106
|
+
pending.resolve(
|
|
107
|
+
outcome === 'success' ? { data: ['Updated row'] } : failure(),
|
|
108
|
+
),
|
|
109
|
+
);
|
|
110
|
+
if (outcome === 'success') {
|
|
111
|
+
await screen.findByText('Updated row');
|
|
112
|
+
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
|
113
|
+
} else {
|
|
114
|
+
await waitFor(() =>
|
|
115
|
+
expect(
|
|
116
|
+
screen.getByRole('button', { name: 'Retry', exact: true }),
|
|
117
|
+
).toBeEnabled(),
|
|
118
|
+
);
|
|
119
|
+
expect(screen.getByRole('alert')).toHaveTextContent(
|
|
120
|
+
enUS.listFeedback.refreshFailed,
|
|
121
|
+
);
|
|
122
|
+
expect(screen.getByText('Original row')).toBeVisible();
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
);
|
|
126
|
+
it.each([401, 403])(
|
|
127
|
+
'hides cached rows through a pending retry after %s',
|
|
128
|
+
async (status) => {
|
|
129
|
+
let response: Rows | Response | Promise<Rows | Response> = {
|
|
130
|
+
data: ['Private row'],
|
|
131
|
+
};
|
|
132
|
+
await renderWithApp(<Harness kind={kind} />, {
|
|
133
|
+
...options,
|
|
134
|
+
fetch: answerApi(() => response),
|
|
135
|
+
});
|
|
136
|
+
await screen.findByText('Private row');
|
|
137
|
+
response = failure(status);
|
|
138
|
+
await userEvent.click(screen.getByRole('button', { name: 'Refresh' }));
|
|
139
|
+
await screen.findByText(
|
|
140
|
+
status === 401
|
|
141
|
+
? enUS.listFeedback.unauthenticated
|
|
142
|
+
: enUS.listFeedback.forbidden,
|
|
143
|
+
);
|
|
144
|
+
expect(screen.queryByText('Private row')).not.toBeInTheDocument();
|
|
145
|
+
const pending = deferred();
|
|
146
|
+
response = pending.promise;
|
|
147
|
+
await userEvent.click(screen.getByRole('button', { name: 'Refresh' }));
|
|
148
|
+
expect(screen.getByRole('alert')).toHaveTextContent(
|
|
149
|
+
status === 401
|
|
150
|
+
? enUS.listFeedback.unauthenticated
|
|
151
|
+
: enUS.listFeedback.forbidden,
|
|
152
|
+
);
|
|
153
|
+
expect(screen.queryByText('Private row')).not.toBeInTheDocument();
|
|
154
|
+
await act(async () => pending.resolve({ data: ['Authorized row'] }));
|
|
155
|
+
await screen.findByText('Authorized row');
|
|
156
|
+
},
|
|
157
|
+
);
|
|
158
|
+
it('withholds the previous query and ignores its late retry completion', async () => {
|
|
159
|
+
let response: Rows | Response | Promise<Rows | Response> = {
|
|
160
|
+
data: ['First row'],
|
|
161
|
+
};
|
|
162
|
+
await renderWithApp(<Harness kind={kind} />, {
|
|
163
|
+
...options,
|
|
164
|
+
fetch: answerApi(() => response),
|
|
165
|
+
});
|
|
166
|
+
await screen.findByText('First row');
|
|
167
|
+
response = failure();
|
|
168
|
+
await userEvent.click(screen.getByRole('button', { name: 'Refresh' }));
|
|
169
|
+
await screen.findByText(enUS.listFeedback.refreshFailed);
|
|
170
|
+
const old = deferred();
|
|
171
|
+
response = old.promise;
|
|
172
|
+
await userEvent.click(
|
|
173
|
+
screen.getByRole('button', { name: 'Retry', exact: true }),
|
|
174
|
+
);
|
|
175
|
+
const current = deferred();
|
|
176
|
+
response = current.promise;
|
|
177
|
+
await userEvent.click(
|
|
178
|
+
screen.getByRole('button', { name: 'Change query' }),
|
|
179
|
+
);
|
|
180
|
+
expect(screen.getByRole('status')).toHaveTextContent('Loading');
|
|
181
|
+
expect(screen.queryByText('First row')).not.toBeInTheDocument();
|
|
182
|
+
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
|
183
|
+
await act(async () => current.resolve({ data: ['Second row'] }));
|
|
184
|
+
await screen.findByText('Second row');
|
|
185
|
+
await act(async () => old.resolve({ data: ['Obsolete row'] }));
|
|
186
|
+
expect(screen.queryByText('Obsolete row')).not.toBeInTheDocument();
|
|
187
|
+
expect(screen.getByText('Second row')).toBeVisible();
|
|
188
|
+
});
|
|
189
|
+
},
|
|
190
|
+
);
|
|
@@ -117,6 +117,19 @@ const configurationRoutes = [
|
|
|
117
117
|
] as unknown as AppClientRegisteredRoute[];
|
|
118
118
|
|
|
119
119
|
describe('application-owned permission presentation', () => {
|
|
120
|
+
it('omits the generic Page access description and retains other section descriptions', () => {
|
|
121
|
+
const sections = permissionSections(source, [], (value) =>
|
|
122
|
+
typeof value === 'string' ? value : value.key,
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
expect(
|
|
126
|
+
sections.find((section) => section.id === 'pages')?.description,
|
|
127
|
+
).toBe(undefined);
|
|
128
|
+
expect(
|
|
129
|
+
sections.find((section) => section.id === 'business')?.description,
|
|
130
|
+
).toBe('settings.businessOperationsHint');
|
|
131
|
+
});
|
|
132
|
+
|
|
120
133
|
it('can hide a created-record selector while retaining a related-record selector and stored policy', () => {
|
|
121
134
|
const resource = {
|
|
122
135
|
type: 'composite',
|
|
@@ -5,7 +5,13 @@ import {
|
|
|
5
5
|
AuthorizationClient,
|
|
6
6
|
authorizationClientToken,
|
|
7
7
|
} from '@nocobase/app-plugin-authorization/client';
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
act,
|
|
10
|
+
fireEvent,
|
|
11
|
+
screen,
|
|
12
|
+
waitFor,
|
|
13
|
+
within,
|
|
14
|
+
} from '@testing-library/react';
|
|
9
15
|
import userEvent from '@testing-library/user-event';
|
|
10
16
|
import { Routes, Route, useLocation } from 'react-router';
|
|
11
17
|
import { expect, it, vi } from 'vitest';
|
|
@@ -59,6 +65,79 @@ function Location() {
|
|
|
59
65
|
);
|
|
60
66
|
}
|
|
61
67
|
|
|
68
|
+
it.each(['fast', 'slow'] as const)(
|
|
69
|
+
'keeps the user table on drawer close and only indicates a slow refresh (%s)',
|
|
70
|
+
async (speed) => {
|
|
71
|
+
const user = userEvent.setup();
|
|
72
|
+
let refresh = false;
|
|
73
|
+
let complete!: () => void;
|
|
74
|
+
const response = new Promise<void>((resolve) => {
|
|
75
|
+
complete = resolve;
|
|
76
|
+
});
|
|
77
|
+
await renderWithApp(
|
|
78
|
+
<Routes>
|
|
79
|
+
<Route path='/settings/users' element={<UsersPage />}>
|
|
80
|
+
<Route path=':userId' element={<UserDetail />}>
|
|
81
|
+
<Route path='edit' element={<UserForm />} />
|
|
82
|
+
</Route>
|
|
83
|
+
</Route>
|
|
84
|
+
</Routes>,
|
|
85
|
+
{
|
|
86
|
+
route: '/settings/users',
|
|
87
|
+
namespace,
|
|
88
|
+
namespaces,
|
|
89
|
+
services,
|
|
90
|
+
fetch: answerApi(async ({ path }) => {
|
|
91
|
+
if (path === 'authorization/permissions')
|
|
92
|
+
return { data: { unrestricted: true, permissions: [] } };
|
|
93
|
+
if (path === 'users/options') return options;
|
|
94
|
+
if (path === 'users/alice') return { data: row };
|
|
95
|
+
if (refresh) await response;
|
|
96
|
+
return {
|
|
97
|
+
data: [{ ...row, name: refresh ? 'Alice refreshed' : row.name }],
|
|
98
|
+
meta: { total: 1 },
|
|
99
|
+
};
|
|
100
|
+
}),
|
|
101
|
+
},
|
|
102
|
+
);
|
|
103
|
+
await user.click(await screen.findByRole('link', { name: 'Alice' }));
|
|
104
|
+
await waitFor(() =>
|
|
105
|
+
expect(screen.getByRole('textbox', { name: 'Name *' })).toHaveValue(
|
|
106
|
+
'Alice',
|
|
107
|
+
),
|
|
108
|
+
);
|
|
109
|
+
refresh = true;
|
|
110
|
+
vi.useFakeTimers();
|
|
111
|
+
try {
|
|
112
|
+
await act(() => vi.advanceTimersByTimeAsync(1000));
|
|
113
|
+
expect(screen.queryByText(enUS.settings.loading)).not.toBeInTheDocument();
|
|
114
|
+
await act(async () => {
|
|
115
|
+
fireEvent.click(
|
|
116
|
+
screen.getByRole('button', { name: 'Cancel', exact: true }),
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
expect(screen.getByRole('link', { name: 'Alice' })).toBeVisible();
|
|
120
|
+
expect(screen.queryByText(enUS.settings.loading)).not.toBeInTheDocument();
|
|
121
|
+
if (speed === 'slow') {
|
|
122
|
+
await act(() => vi.advanceTimersByTimeAsync(1000));
|
|
123
|
+
expect(screen.getByText(enUS.settings.loading)).toBeVisible();
|
|
124
|
+
expect(screen.getByRole('link', { name: 'Alice' })).toBeVisible();
|
|
125
|
+
}
|
|
126
|
+
await act(async () => {
|
|
127
|
+
complete();
|
|
128
|
+
await response;
|
|
129
|
+
});
|
|
130
|
+
await act(() => vi.advanceTimersByTimeAsync(1000));
|
|
131
|
+
expect(
|
|
132
|
+
screen.getByRole('link', { name: 'Alice refreshed' }),
|
|
133
|
+
).toBeVisible();
|
|
134
|
+
expect(screen.queryByText(enUS.settings.loading)).not.toBeInTheDocument();
|
|
135
|
+
} finally {
|
|
136
|
+
vi.useRealTimers();
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
);
|
|
140
|
+
|
|
62
141
|
it('uses role labels and paired server filters, retaining the source and ordering when clearing filters', async () => {
|
|
63
142
|
const calls = vi.fn(({ path }: { path: string }) =>
|
|
64
143
|
path === 'authorization/permissions'
|
|
@@ -41,7 +41,7 @@ describe.each([
|
|
|
41
41
|
['zh-CN', zhCN],
|
|
42
42
|
] as const)('%s', (language, copy) => {
|
|
43
43
|
it.each(['users', 'roles'] as const)(
|
|
44
|
-
'
|
|
44
|
+
'keeps the compact empty status and hides creation for a read-only %s list',
|
|
45
45
|
async (kind) => {
|
|
46
46
|
await renderWithApp(
|
|
47
47
|
<Routes>
|
|
@@ -64,13 +64,9 @@ describe.each([
|
|
|
64
64
|
}),
|
|
65
65
|
},
|
|
66
66
|
);
|
|
67
|
-
expect(
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
? copy.settings.usersReadOnlyEmpty
|
|
71
|
-
: copy.settings.rolesReadOnlyEmpty,
|
|
72
|
-
),
|
|
73
|
-
).toBeVisible();
|
|
67
|
+
expect((await screen.findByRole('status')).textContent).toBe(
|
|
68
|
+
copy.listFeedback.empty,
|
|
69
|
+
);
|
|
74
70
|
expect(
|
|
75
71
|
screen.queryByRole('button', {
|
|
76
72
|
name:
|
|
@@ -119,13 +115,9 @@ describe.each([
|
|
|
119
115
|
kind === 'users' ? copy.settings.newUser : copy.settings.newRole,
|
|
120
116
|
}),
|
|
121
117
|
).toHaveLength(1);
|
|
122
|
-
expect(
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
? copy.settings.usersReadOnlyEmpty
|
|
126
|
-
: copy.settings.rolesReadOnlyEmpty,
|
|
127
|
-
),
|
|
128
|
-
).not.toBeInTheDocument();
|
|
118
|
+
expect(screen.getByRole('status').textContent).toBe(
|
|
119
|
+
copy.listFeedback.empty,
|
|
120
|
+
);
|
|
129
121
|
},
|
|
130
122
|
);
|
|
131
123
|
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<link rel="icon" href="data:," />
|
|
7
|
+
<title>Permission alignment verification</title>
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="root"></div>
|
|
11
|
+
<script
|
|
12
|
+
type="module"
|
|
13
|
+
src="/tests/fixtures/permission-alignment.tsx"
|
|
14
|
+
></script>
|
|
15
|
+
</body>
|
|
16
|
+
</html>
|