@nocobase/app-template-default 1.0.0-beta.83 → 1.0.0-beta.84
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/rules/build-and-deploy.md +1 -1
- package/.agents/rules/shell.md +4 -0
- package/AGENTS.md +7 -1
- package/README.MD +10 -2
- package/client/components/back-button.tsx +3 -5
- package/client/components/default-access-editor.tsx +13 -2
- package/client/components/feedback-form.tsx +474 -0
- package/client/components/lifecycle-diagram.tsx +10 -10
- package/client/components/permission-editor.tsx +396 -210
- package/client/extensions/nocobase-auth-forms/password-login-form.tsx +17 -6
- package/client/extensions/nocobase-auth-forms/password-registration-form.tsx +24 -7
- package/client/extensions/nocobase-auth-forms/password-reset-form.tsx +19 -7
- package/client/extensions/nocobase-auth-forms/password-reset-request-form.tsx +15 -7
- package/client/extensions/nocobase-device-approval/device-approval.tsx +7 -2
- package/client/extensions/nocobase-device-approval/locales/en-US.ts +16 -0
- package/client/extensions/nocobase-device-approval/locales/zh-CN.ts +7 -0
- package/client/extensions/nocobase-file-component-ui/components/file-preview-dialog.tsx +20 -17
- package/client/hooks/use-list-data.ts +77 -0
- package/client/hooks/use-list-location.ts +23 -0
- package/client/hooks/use-list-tabs.ts +42 -0
- package/client/hooks/use-table-url-state.ts +143 -30
- package/client/locales/system/en-US.ts +10 -0
- package/client/locales/system/settings-en-US.ts +8 -0
- package/client/locales/system/settings-zh-CN.ts +8 -0
- package/client/locales/system/zh-CN.ts +8 -0
- package/client/pages/settings/default-access-form.tsx +44 -26
- package/client/pages/settings/default-access.tsx +56 -27
- package/client/pages/settings/hooks.tsx +4 -59
- package/client/pages/settings/page-permissions.ts +17 -0
- package/client/pages/settings/permissions-model.ts +54 -58
- package/client/pages/settings/role-form.tsx +98 -56
- package/client/pages/settings/roles.tsx +11 -3
- package/client/pages/settings/shared.tsx +43 -33
- package/client/pages/settings/user-form.tsx +51 -30
- package/client/pages/settings/users.tsx +39 -27
- package/client/routing/session-preparation.tsx +10 -3
- package/database/main/migrations/202611020002_create_mutation_receipts.ts +19 -0
- package/package.json +13 -12
- package/server/providers/index.ts +4 -1
- package/server/providers/submission.ts +61 -0
- package/tests/client/auth-i18n.test.tsx +9 -0
- package/tests/client/back-button.test.tsx +3 -3
- package/tests/client/default-access.test.tsx +60 -4
- package/tests/client/form-feedback.test.tsx +589 -0
- package/tests/client/list-recovery.test.tsx +373 -0
- package/tests/client/list-tabs.test.tsx +121 -0
- package/tests/client/page-permission-navigation.test.ts +8 -0
- package/tests/client/permission-editor.test.tsx +191 -21
- package/tests/client/permission-filter-binding.test.tsx +1 -1
- package/tests/client/role-return.test.tsx +2 -1
- package/tests/client/session-preparation.test.tsx +24 -1
- package/tests/client/settings-continuity.test.tsx +5 -4
- package/tests/client/settings-feedback.test.tsx +25 -0
- package/tests/client/settings-permissions.test.ts +35 -0
- package/tests/client/settings.test.tsx +2 -1
- package/tests/client/table-url-state.test.tsx +117 -3
- package/tests/client/user-drawer.test.tsx +2 -1
- package/tests/client/user-permissions.test.tsx +30 -5
- package/tests/database/mutation-receipts.test.ts +45 -0
- package/tests/fixtures/form-feedback/README.md +33 -0
- package/tests/fixtures/form-feedback/equipment-detail.tsx +11 -0
- package/tests/fixtures/form-feedback/equipment-layout.tsx +15 -0
- package/tests/fixtures/form-feedback/equipment-migration.ts +27 -0
- package/tests/fixtures/form-feedback/equipment-server.ts +197 -0
- package/tests/fixtures/form-feedback/equipment-validation.tsx +94 -0
- package/tests/fixtures/form-feedback/equipment.tsx +369 -0
- package/tests/fixtures/form-feedback/verify-inline.mjs +174 -0
- package/tests/fixtures/form-feedback/verify.mjs +373 -0
- package/tests/fixtures/submission/README.md +35 -0
- package/tests/fixtures/submission/browser.test.ts +478 -0
- package/tests/fixtures/submission/migration.ts +61 -0
- package/tests/fixtures/submission/page.tsx +138 -0
- package/tests/fixtures/submission/playwright.config.ts +11 -0
- package/tests/fixtures/submission/routes.ts +208 -0
- package/tests/helpers/settings-client.tsx +31 -0
- package/tests/server/member-role.test.ts +7 -1
- package/tests/server/submission-settings.test.ts +241 -0
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import { RouteChildPage } from '#components/route-child-page';
|
|
2
|
+
// Test-only business customization. Install in an isolated generated application's client/pages/.
|
|
3
|
+
import { FormFeedback, useApiClient } from '@nocobase/app-client';
|
|
4
|
+
import { useTranslation } from '@nocobase/i18n/client';
|
|
5
|
+
import {
|
|
6
|
+
useEffect,
|
|
7
|
+
useLayoutEffect,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
type ReactElement,
|
|
11
|
+
} from 'react';
|
|
12
|
+
import { FeedbackForm } from '#components/feedback-form';
|
|
13
|
+
import { Button } from '#components/ui/button';
|
|
14
|
+
import { Input } from '#components/ui/input';
|
|
15
|
+
import { RouteDialog } from '#components/route-dialog';
|
|
16
|
+
import { RouteDrawer } from '#components/route-drawer';
|
|
17
|
+
import { useLocation, useNavigate } from 'react-router';
|
|
18
|
+
import { PageContainer } from '#components/page-container';
|
|
19
|
+
|
|
20
|
+
interface Row {
|
|
21
|
+
key: string;
|
|
22
|
+
name: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export default function EquipmentFixture(): ReactElement {
|
|
26
|
+
const api = useApiClient();
|
|
27
|
+
const { t } = useTranslation();
|
|
28
|
+
const [name, setName] = useState('Equipment draft');
|
|
29
|
+
const [department, setDepartment] = useState('Operations');
|
|
30
|
+
const [rows, setRows] = useState<Row[]>([
|
|
31
|
+
{ key: 'camera', name: 'Camera' },
|
|
32
|
+
{ key: 'tripod', name: 'Tripod' },
|
|
33
|
+
]);
|
|
34
|
+
const [tab, setTab] = useState('details');
|
|
35
|
+
const [expanded, setExpanded] = useState(false);
|
|
36
|
+
const [hidden, setHidden] = useState(false);
|
|
37
|
+
const [disabled, setDisabled] = useState(false);
|
|
38
|
+
const [mode, setMode] = useState('structured');
|
|
39
|
+
const location = useLocation();
|
|
40
|
+
const navigate = useNavigate();
|
|
41
|
+
const container = location.pathname.endsWith('/drawer')
|
|
42
|
+
? 'drawer'
|
|
43
|
+
: location.pathname.endsWith('/dialog')
|
|
44
|
+
? 'dialog'
|
|
45
|
+
: location.pathname.endsWith('/edit')
|
|
46
|
+
? 'detail-edit'
|
|
47
|
+
: 'page';
|
|
48
|
+
const [request, setRequest] = useState<
|
|
49
|
+
{ id: string; workflow: string } | undefined
|
|
50
|
+
>();
|
|
51
|
+
const [saved, setSaved] = useState(false);
|
|
52
|
+
const [record, setRecord] = useState<unknown>();
|
|
53
|
+
const [feedback] = useState(
|
|
54
|
+
() =>
|
|
55
|
+
new FormFeedback({
|
|
56
|
+
messages: {
|
|
57
|
+
invalid: 'Check the fields.',
|
|
58
|
+
failed: 'Unable to save.',
|
|
59
|
+
denied: 'Access denied.',
|
|
60
|
+
network: 'Check your connection.',
|
|
61
|
+
},
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
const liveRef = useRef({ rows, hidden, disabled });
|
|
65
|
+
const revealWaitersRef = useRef<(() => void)[]>([]);
|
|
66
|
+
useLayoutEffect(() => {
|
|
67
|
+
liveRef.current = { rows, hidden, disabled };
|
|
68
|
+
if (tab === 'items' && expanded) {
|
|
69
|
+
for (const resolve of revealWaitersRef.current.splice(0)) resolve();
|
|
70
|
+
}
|
|
71
|
+
}, [rows, hidden, disabled, tab, expanded]);
|
|
72
|
+
const anchorsRef = useRef(new Map<string, HTMLDivElement>());
|
|
73
|
+
const inputsRef = useRef(new Map<string, HTMLInputElement>());
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
const remove = ['camera', 'tripod'].map((key) =>
|
|
76
|
+
feedback.register({
|
|
77
|
+
id: key,
|
|
78
|
+
path: () =>
|
|
79
|
+
`items.${liveRef.current.rows.findIndex((row) => row.key === key)}.name`,
|
|
80
|
+
value: () => liveRef.current.rows.find((row) => row.key === key)?.name,
|
|
81
|
+
label: () => (key === 'camera' ? 'Camera item' : 'Tripod item'),
|
|
82
|
+
anchor: () => anchorsRef.current.get(key) ?? null,
|
|
83
|
+
control: () => inputsRef.current.get(key) ?? null,
|
|
84
|
+
accessible: () =>
|
|
85
|
+
!liveRef.current.hidden &&
|
|
86
|
+
liveRef.current.rows.some((row) => row.key === key),
|
|
87
|
+
editable: () => !liveRef.current.disabled,
|
|
88
|
+
reveal: () => {
|
|
89
|
+
if (inputsRef.current.has(key)) return Promise.resolve();
|
|
90
|
+
return new Promise<void>((resolve) => {
|
|
91
|
+
revealWaitersRef.current.push(resolve);
|
|
92
|
+
setTab('items');
|
|
93
|
+
setExpanded(true);
|
|
94
|
+
});
|
|
95
|
+
},
|
|
96
|
+
}),
|
|
97
|
+
);
|
|
98
|
+
return () => remove.forEach((dispose) => dispose());
|
|
99
|
+
}, [feedback]);
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
void api
|
|
102
|
+
.request<{ data: unknown }>({ path: 'equipmentFixture' })
|
|
103
|
+
.then((result) => setRecord(result.data));
|
|
104
|
+
}, [api]);
|
|
105
|
+
const content = (
|
|
106
|
+
<FeedbackForm
|
|
107
|
+
feedback={feedback}
|
|
108
|
+
fieldMessage={({ reason }) =>
|
|
109
|
+
reason === 'DEPARTMENT'
|
|
110
|
+
? t('equipment.departmentError')
|
|
111
|
+
: t('equipment.itemError')
|
|
112
|
+
}
|
|
113
|
+
className='flex flex-col gap-4'
|
|
114
|
+
onSubmit={async (event) => {
|
|
115
|
+
event.preventDefault();
|
|
116
|
+
setSaved(false);
|
|
117
|
+
if (mode === 'network')
|
|
118
|
+
throw new TypeError('Test connection interruption');
|
|
119
|
+
const result = await api.request<{
|
|
120
|
+
data: { id: string; workflow: string };
|
|
121
|
+
}>({
|
|
122
|
+
path: 'equipmentFixture',
|
|
123
|
+
method: 'POST',
|
|
124
|
+
json: {
|
|
125
|
+
mode,
|
|
126
|
+
name,
|
|
127
|
+
department,
|
|
128
|
+
items: rows,
|
|
129
|
+
attachment: 'safety-check.pdf',
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
setRequest(result.data);
|
|
133
|
+
setSaved(true);
|
|
134
|
+
}}
|
|
135
|
+
>
|
|
136
|
+
<div>
|
|
137
|
+
<label htmlFor='equipment-name'>{t('equipment.name')}</label>
|
|
138
|
+
<Input
|
|
139
|
+
id='equipment-name'
|
|
140
|
+
name='name'
|
|
141
|
+
required
|
|
142
|
+
value={name}
|
|
143
|
+
aria-describedby='name-help'
|
|
144
|
+
onChange={(event) => setName(event.target.value)}
|
|
145
|
+
/>
|
|
146
|
+
<p id='name-help'>{t('equipment.help')}</p>
|
|
147
|
+
</div>
|
|
148
|
+
<div>
|
|
149
|
+
<label htmlFor='department'>{t('equipment.department')}</label>
|
|
150
|
+
<Input
|
|
151
|
+
id='department'
|
|
152
|
+
name='department'
|
|
153
|
+
required
|
|
154
|
+
value={department}
|
|
155
|
+
onChange={(event) => setDepartment(event.target.value)}
|
|
156
|
+
/>
|
|
157
|
+
</div>
|
|
158
|
+
<div>
|
|
159
|
+
<label htmlFor='attachment'>{t('equipment.attachment')}</label>
|
|
160
|
+
<Input id='attachment' name='attachment' type='file' />
|
|
161
|
+
<p>{t('equipment.retainedAttachment')}: safety-check.pdf</p>
|
|
162
|
+
</div>
|
|
163
|
+
<div
|
|
164
|
+
className='flex gap-2'
|
|
165
|
+
role='tablist'
|
|
166
|
+
aria-label='Equipment sections'
|
|
167
|
+
>
|
|
168
|
+
<Button
|
|
169
|
+
type='button'
|
|
170
|
+
role='tab'
|
|
171
|
+
aria-selected={tab === 'details'}
|
|
172
|
+
onClick={() => setTab('details')}
|
|
173
|
+
>
|
|
174
|
+
Details
|
|
175
|
+
</Button>
|
|
176
|
+
<Button
|
|
177
|
+
type='button'
|
|
178
|
+
role='tab'
|
|
179
|
+
aria-selected={tab === 'items'}
|
|
180
|
+
onClick={() => setTab('items')}
|
|
181
|
+
>
|
|
182
|
+
Items
|
|
183
|
+
</Button>
|
|
184
|
+
</div>
|
|
185
|
+
<div
|
|
186
|
+
className='h-48 overflow-y-auto rounded-lg border p-4'
|
|
187
|
+
aria-label='Scrollable equipment fields'
|
|
188
|
+
>
|
|
189
|
+
<div className='min-h-96'>
|
|
190
|
+
<p>{t('equipment.longForm')}</p>
|
|
191
|
+
</div>
|
|
192
|
+
<Button
|
|
193
|
+
type='button'
|
|
194
|
+
aria-expanded={expanded}
|
|
195
|
+
onClick={() => setExpanded(!expanded)}
|
|
196
|
+
>
|
|
197
|
+
Borrowed items
|
|
198
|
+
</Button>
|
|
199
|
+
{rows.map((row, index) => (
|
|
200
|
+
<div
|
|
201
|
+
key={row.key}
|
|
202
|
+
ref={(node) => {
|
|
203
|
+
if (node) anchorsRef.current.set(row.key, node);
|
|
204
|
+
else anchorsRef.current.delete(row.key);
|
|
205
|
+
}}
|
|
206
|
+
>
|
|
207
|
+
{tab === 'items' && expanded && !hidden && (
|
|
208
|
+
<div>
|
|
209
|
+
<label htmlFor={`row-${row.key}`}>
|
|
210
|
+
{row.key === 'camera' ? 'Camera item' : 'Tripod item'}
|
|
211
|
+
</label>
|
|
212
|
+
<Input
|
|
213
|
+
id={`row-${row.key}`}
|
|
214
|
+
name={`items.${index}.name`}
|
|
215
|
+
value={row.name}
|
|
216
|
+
disabled={disabled}
|
|
217
|
+
ref={(node) => {
|
|
218
|
+
if (node) inputsRef.current.set(row.key, node);
|
|
219
|
+
else inputsRef.current.delete(row.key);
|
|
220
|
+
}}
|
|
221
|
+
onChange={(event) => {
|
|
222
|
+
feedback.clear(row.key);
|
|
223
|
+
setRows(
|
|
224
|
+
rows.map((entry) =>
|
|
225
|
+
entry.key === row.key
|
|
226
|
+
? { ...entry, name: event.target.value }
|
|
227
|
+
: entry,
|
|
228
|
+
),
|
|
229
|
+
);
|
|
230
|
+
}}
|
|
231
|
+
/>
|
|
232
|
+
</div>
|
|
233
|
+
)}
|
|
234
|
+
</div>
|
|
235
|
+
))}
|
|
236
|
+
</div>
|
|
237
|
+
<div className='flex flex-wrap gap-2'>
|
|
238
|
+
<Button
|
|
239
|
+
type='button'
|
|
240
|
+
variant='outline'
|
|
241
|
+
onClick={() => setRows([...rows].reverse())}
|
|
242
|
+
>
|
|
243
|
+
Reorder rows
|
|
244
|
+
</Button>
|
|
245
|
+
<Button
|
|
246
|
+
type='button'
|
|
247
|
+
variant='outline'
|
|
248
|
+
onClick={() => setRows(rows.filter((row) => row.key !== 'camera'))}
|
|
249
|
+
>
|
|
250
|
+
Delete camera row
|
|
251
|
+
</Button>
|
|
252
|
+
<Button
|
|
253
|
+
type='button'
|
|
254
|
+
variant='outline'
|
|
255
|
+
onClick={() => setHidden(!hidden)}
|
|
256
|
+
>
|
|
257
|
+
Toggle field access
|
|
258
|
+
</Button>
|
|
259
|
+
<Button
|
|
260
|
+
type='button'
|
|
261
|
+
variant='outline'
|
|
262
|
+
onClick={() => setDisabled(!disabled)}
|
|
263
|
+
>
|
|
264
|
+
Toggle disabled
|
|
265
|
+
</Button>
|
|
266
|
+
</div>
|
|
267
|
+
<label>
|
|
268
|
+
{t('equipment.response')}
|
|
269
|
+
<select
|
|
270
|
+
aria-label='Response'
|
|
271
|
+
value={mode}
|
|
272
|
+
onChange={(event) => setMode(event.target.value)}
|
|
273
|
+
>
|
|
274
|
+
{[
|
|
275
|
+
'structured',
|
|
276
|
+
'business',
|
|
277
|
+
'unknown',
|
|
278
|
+
'denied',
|
|
279
|
+
'network',
|
|
280
|
+
'success',
|
|
281
|
+
].map((value) => (
|
|
282
|
+
<option key={value}>{value}</option>
|
|
283
|
+
))}
|
|
284
|
+
</select>
|
|
285
|
+
</label>
|
|
286
|
+
<Button type='submit'>{t('equipment.save')}</Button>
|
|
287
|
+
{saved && <p role='status'>{t('equipment.saved')}</p>}
|
|
288
|
+
{request && (
|
|
289
|
+
<div>
|
|
290
|
+
<p data-testid='workflow-status'>
|
|
291
|
+
{request.id}: {request.workflow}
|
|
292
|
+
</p>
|
|
293
|
+
{request.workflow === 'submitted' && (
|
|
294
|
+
<Button
|
|
295
|
+
type='button'
|
|
296
|
+
onClick={async () => {
|
|
297
|
+
const result = await api.request<{
|
|
298
|
+
data: { id: string; workflow: string };
|
|
299
|
+
}>({
|
|
300
|
+
path: 'equipmentFixture',
|
|
301
|
+
method: 'POST',
|
|
302
|
+
json: {
|
|
303
|
+
mode: 'approve',
|
|
304
|
+
recordId: request.id,
|
|
305
|
+
name,
|
|
306
|
+
department,
|
|
307
|
+
items: rows,
|
|
308
|
+
attachment: 'safety-check.pdf',
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
setRequest(result.data);
|
|
312
|
+
}}
|
|
313
|
+
>
|
|
314
|
+
Approve request
|
|
315
|
+
</Button>
|
|
316
|
+
)}
|
|
317
|
+
</div>
|
|
318
|
+
)}
|
|
319
|
+
</FeedbackForm>
|
|
320
|
+
);
|
|
321
|
+
return (
|
|
322
|
+
<PageContainer>
|
|
323
|
+
<h1 className='text-xl font-semibold'>{t('equipment.title')}</h1>
|
|
324
|
+
<p data-testid='custom-style' className='font-serif'>
|
|
325
|
+
{t('equipment.customCopy')}
|
|
326
|
+
</p>
|
|
327
|
+
<pre data-testid='retained-record'>{JSON.stringify(record)}</pre>
|
|
328
|
+
<label>
|
|
329
|
+
Container
|
|
330
|
+
<select
|
|
331
|
+
aria-label='Container'
|
|
332
|
+
value={container}
|
|
333
|
+
onChange={(event) =>
|
|
334
|
+
void navigate(
|
|
335
|
+
event.target.value === 'page'
|
|
336
|
+
? '/equipment'
|
|
337
|
+
: `/equipment/${event.target.value === 'detail-edit' ? 'retained/edit' : event.target.value}`,
|
|
338
|
+
)
|
|
339
|
+
}
|
|
340
|
+
>
|
|
341
|
+
{['page', 'dialog', 'drawer', 'detail-edit'].map((value) => (
|
|
342
|
+
<option key={value}>{value}</option>
|
|
343
|
+
))}
|
|
344
|
+
</select>
|
|
345
|
+
</label>
|
|
346
|
+
{container === 'dialog' ? (
|
|
347
|
+
<RouteDialog
|
|
348
|
+
title='Equipment borrowing'
|
|
349
|
+
description='Customized equipment request'
|
|
350
|
+
closeTo='/equipment'
|
|
351
|
+
>
|
|
352
|
+
{content}
|
|
353
|
+
</RouteDialog>
|
|
354
|
+
) : container === 'drawer' ? (
|
|
355
|
+
<RouteDrawer
|
|
356
|
+
title='Equipment borrowing'
|
|
357
|
+
description='Customized equipment request'
|
|
358
|
+
closeTo='/equipment'
|
|
359
|
+
>
|
|
360
|
+
{content}
|
|
361
|
+
</RouteDrawer>
|
|
362
|
+
) : container === 'detail-edit' ? (
|
|
363
|
+
<RouteChildPage>{content}</RouteChildPage>
|
|
364
|
+
) : (
|
|
365
|
+
<section aria-label='Equipment form'>{content}</section>
|
|
366
|
+
)}
|
|
367
|
+
</PageContainer>
|
|
368
|
+
);
|
|
369
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import process from 'node:process';
|
|
5
|
+
import { Buffer } from 'node:buffer';
|
|
6
|
+
import { log } from 'node:console';
|
|
7
|
+
const require = createRequire(path.join(process.cwd(), 'package.json'));
|
|
8
|
+
const { chromium, expect } = require('@playwright/test');
|
|
9
|
+
const [base, out, name] = process.argv.slice(2);
|
|
10
|
+
if (!/^http:\/\/127\.0\.0\.1:\d+\/main\/$/.test(base ?? ''))
|
|
11
|
+
throw new Error('Supply the isolated application entry.');
|
|
12
|
+
fs.mkdirSync(out, { recursive: true });
|
|
13
|
+
const browser = await chromium.launch();
|
|
14
|
+
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
|
|
15
|
+
const pageErrors = [];
|
|
16
|
+
page.on('pageerror', (error) => pageErrors.push(error.message));
|
|
17
|
+
const shot = (point) =>
|
|
18
|
+
page.screenshot({
|
|
19
|
+
path: path.join(out, `${name}-${point}.png`),
|
|
20
|
+
fullPage: true,
|
|
21
|
+
});
|
|
22
|
+
try {
|
|
23
|
+
await page.goto(base + 'login');
|
|
24
|
+
await page
|
|
25
|
+
.getByLabel('Username or email')
|
|
26
|
+
.fill(process.env.TEST_ADMIN_USERNAME ?? 'nocobase');
|
|
27
|
+
await page
|
|
28
|
+
.getByLabel('Password', { exact: true })
|
|
29
|
+
.fill(process.env.TEST_ADMIN_PASSWORD ?? 'admin123');
|
|
30
|
+
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
|
|
31
|
+
await expect(page.getByLabel('Username or email')).not.toBeVisible();
|
|
32
|
+
await page.goto(base + 'equipment-validation');
|
|
33
|
+
const group = page.getByRole('form', {
|
|
34
|
+
name: 'Equipment group',
|
|
35
|
+
exact: true,
|
|
36
|
+
});
|
|
37
|
+
const save = group.getByRole('button', {
|
|
38
|
+
name: 'Save equipment group',
|
|
39
|
+
exact: true,
|
|
40
|
+
});
|
|
41
|
+
const entry = group.getByRole('button', {
|
|
42
|
+
name: 'Equipment group',
|
|
43
|
+
exact: true,
|
|
44
|
+
});
|
|
45
|
+
await save.click();
|
|
46
|
+
await expect(entry).toBeFocused();
|
|
47
|
+
await expect(group.getByRole('alert')).toHaveCount(0);
|
|
48
|
+
await expect(group.getByRole('list')).toHaveCount(0);
|
|
49
|
+
const errorId = await entry.getAttribute('aria-describedby');
|
|
50
|
+
await expect(page.locator(`[id="${errorId}"]`)).toHaveText(
|
|
51
|
+
'Add at least two equipment items.',
|
|
52
|
+
);
|
|
53
|
+
await save.click();
|
|
54
|
+
await expect(
|
|
55
|
+
group.getByText('Add at least two equipment items.', { exact: true }),
|
|
56
|
+
).toHaveCount(1);
|
|
57
|
+
await expect(group.getByLabel('Equipment item 1')).toHaveValue('Camera');
|
|
58
|
+
await shot('group-inline');
|
|
59
|
+
await entry.click();
|
|
60
|
+
await save.click();
|
|
61
|
+
await expect(group.getByRole('status')).toHaveText('Equipment group saved.');
|
|
62
|
+
await expect(group.locator('[data-form-feedback-error]')).toHaveCount(0);
|
|
63
|
+
const unique = page.getByLabel('Unique name', { exact: true });
|
|
64
|
+
await page
|
|
65
|
+
.getByRole('button', { name: 'Save unique name', exact: true })
|
|
66
|
+
.click();
|
|
67
|
+
const controlled = page.getByRole('form', {
|
|
68
|
+
name: 'Unique equipment',
|
|
69
|
+
exact: true,
|
|
70
|
+
});
|
|
71
|
+
await expect(unique).toBeFocused();
|
|
72
|
+
await expect(controlled.getByRole('alert')).toHaveCount(0);
|
|
73
|
+
await expect(
|
|
74
|
+
controlled.getByText('Choose a unique equipment name.', { exact: true }),
|
|
75
|
+
).toHaveCount(1);
|
|
76
|
+
await unique.fill('Retained correction');
|
|
77
|
+
await expect(
|
|
78
|
+
controlled.getByText('Choose a unique equipment name.', { exact: true }),
|
|
79
|
+
).toHaveCount(0);
|
|
80
|
+
await expect(unique).toHaveAttribute('aria-invalid', 'false');
|
|
81
|
+
await expect(unique).toHaveAttribute('aria-describedby', 'updated-help');
|
|
82
|
+
await expect(unique).toHaveValue('Retained correction');
|
|
83
|
+
await shot('corrected-inline');
|
|
84
|
+
await page.goto(base + 'equipment/drawer');
|
|
85
|
+
const draft = page.getByLabel('Equipment name', { exact: true });
|
|
86
|
+
await draft.fill('Retained equipment draft');
|
|
87
|
+
const file = page.getByLabel('Attachment', { exact: true });
|
|
88
|
+
await file.setInputFiles({
|
|
89
|
+
name: 'equipment.txt',
|
|
90
|
+
mimeType: 'text/plain',
|
|
91
|
+
buffer: Buffer.from('retained equipment'),
|
|
92
|
+
});
|
|
93
|
+
const submit = page.getByRole('button', {
|
|
94
|
+
name: 'Save request',
|
|
95
|
+
exact: true,
|
|
96
|
+
});
|
|
97
|
+
await submit.click();
|
|
98
|
+
await expect(page.locator('#row-camera')).toBeFocused();
|
|
99
|
+
await expect(
|
|
100
|
+
page.getByRole('tab', { name: 'Items', exact: true }),
|
|
101
|
+
).toHaveAttribute('aria-selected', 'true');
|
|
102
|
+
await expect(
|
|
103
|
+
page.getByRole('button', { name: 'Borrowed items', exact: true }),
|
|
104
|
+
).toHaveAttribute('aria-expanded', 'true');
|
|
105
|
+
await expect(page.getByRole('alert')).toHaveCount(0);
|
|
106
|
+
await expect(page.locator('[data-form-feedback-error]')).toHaveCount(2);
|
|
107
|
+
expect(page.url()).toBe(base + 'equipment/drawer');
|
|
108
|
+
await page.locator('#row-camera').fill('Corrected camera');
|
|
109
|
+
await expect(page.locator('[data-form-feedback-error]')).toHaveCount(1);
|
|
110
|
+
await expect(page.locator('#row-tripod')).toHaveAttribute(
|
|
111
|
+
'aria-invalid',
|
|
112
|
+
'true',
|
|
113
|
+
);
|
|
114
|
+
await expect(draft).toHaveValue('Retained equipment draft');
|
|
115
|
+
expect(await file.evaluate((input) => input.files[0].name)).toBe(
|
|
116
|
+
'equipment.txt',
|
|
117
|
+
);
|
|
118
|
+
await shot('drawer-inline');
|
|
119
|
+
await page.locator('#row-tripod').fill('Corrected tripod');
|
|
120
|
+
await expect(page.locator('[data-form-feedback-error]')).toHaveCount(0);
|
|
121
|
+
await page.getByLabel('Response', { exact: true }).selectOption('success');
|
|
122
|
+
await submit.click();
|
|
123
|
+
await expect(page.getByRole('status')).toHaveText(
|
|
124
|
+
'Saved; workflow submitted.',
|
|
125
|
+
);
|
|
126
|
+
await expect(page.getByRole('alert')).toHaveCount(0);
|
|
127
|
+
await page.getByLabel('Response', { exact: true }).selectOption('unknown');
|
|
128
|
+
await submit.click();
|
|
129
|
+
await expect(page.getByRole('alert')).toBeFocused();
|
|
130
|
+
await expect(page.getByRole('alert')).toContainText('Unable to save.');
|
|
131
|
+
await expect(page.getByRole('alert').getByRole('button')).toHaveCount(0);
|
|
132
|
+
await expect(page.getByRole('alert')).not.toContainText('Private');
|
|
133
|
+
await shot('form-error');
|
|
134
|
+
await page.getByLabel('Response', { exact: true }).selectOption('network');
|
|
135
|
+
await submit.click();
|
|
136
|
+
await expect(page.getByRole('alert')).toContainText('Unable to connect.');
|
|
137
|
+
await expect(draft).toHaveValue('Retained equipment draft');
|
|
138
|
+
await page.getByLabel('Response', { exact: true }).selectOption('denied');
|
|
139
|
+
await submit.click();
|
|
140
|
+
await expect(page.getByRole('alert')).toContainText('current access');
|
|
141
|
+
await page.getByLabel('Response', { exact: true }).selectOption('structured');
|
|
142
|
+
await page
|
|
143
|
+
.getByRole('button', { name: 'Toggle disabled', exact: true })
|
|
144
|
+
.click();
|
|
145
|
+
await submit.click();
|
|
146
|
+
await expect(page.getByRole('alert')).toBeFocused();
|
|
147
|
+
await expect(page.getByRole('alert')).toContainText('Unable to save.');
|
|
148
|
+
await expect(page.getByRole('alert').getByRole('button')).toHaveCount(0);
|
|
149
|
+
expect(pageErrors).toEqual([]);
|
|
150
|
+
fs.writeFileSync(
|
|
151
|
+
path.join(out, `${name}-inline-checks.json`),
|
|
152
|
+
JSON.stringify(
|
|
153
|
+
{
|
|
154
|
+
base,
|
|
155
|
+
checked: [
|
|
156
|
+
'array-level error is inline only and first editable entry focused',
|
|
157
|
+
'repeated submission and successful group retry clean errors',
|
|
158
|
+
'controlled field reason, correction and help association',
|
|
159
|
+
'real drawer remains open and regions reveal before row focus',
|
|
160
|
+
'row reasons are inline only and clear independently',
|
|
161
|
+
'draft, file and successful request retry retained',
|
|
162
|
+
'unknown, network and permission failures keep safe form copy',
|
|
163
|
+
'unavailable field focus uses a safe accessible fallback',
|
|
164
|
+
],
|
|
165
|
+
pageErrors,
|
|
166
|
+
},
|
|
167
|
+
null,
|
|
168
|
+
2,
|
|
169
|
+
),
|
|
170
|
+
);
|
|
171
|
+
log(`${name}: inline feedback browser checks passed`);
|
|
172
|
+
} finally {
|
|
173
|
+
await browser.close();
|
|
174
|
+
}
|