@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.
Files changed (104) hide show
  1. package/.agents/rules/build-and-deploy.md +1 -1
  2. package/.agents/rules/shell.md +4 -0
  3. package/AGENTS.md +8 -2
  4. package/README.MD +10 -2
  5. package/client/components/back-button.tsx +3 -5
  6. package/client/components/data-table/data-table.tsx +86 -32
  7. package/client/components/default-access-editor.tsx +13 -2
  8. package/client/components/feedback-form.tsx +474 -0
  9. package/client/components/lifecycle-diagram.tsx +10 -10
  10. package/client/components/list-feedback.tsx +174 -0
  11. package/client/components/permission-editor.tsx +419 -226
  12. package/client/extensions/nocobase-auth-forms/password-login-form.tsx +17 -6
  13. package/client/extensions/nocobase-auth-forms/password-registration-form.tsx +24 -7
  14. package/client/extensions/nocobase-auth-forms/password-reset-form.tsx +19 -7
  15. package/client/extensions/nocobase-auth-forms/password-reset-request-form.tsx +15 -7
  16. package/client/extensions/nocobase-device-approval/device-approval.tsx +7 -2
  17. package/client/extensions/nocobase-device-approval/locales/en-US.ts +16 -0
  18. package/client/extensions/nocobase-device-approval/locales/zh-CN.ts +7 -0
  19. package/client/extensions/nocobase-file-component-ui/components/file-preview-dialog.tsx +20 -17
  20. package/client/extensions/nocobase-workflow-management/README.md +3 -1
  21. package/client/extensions/nocobase-workflow-management/definition-list.tsx +19 -7
  22. package/client/extensions/nocobase-workflow-management/execution-list.tsx +61 -48
  23. package/client/hooks/use-list-data.ts +76 -0
  24. package/client/hooks/use-list-location.ts +23 -0
  25. package/client/hooks/use-list-tabs.ts +42 -0
  26. package/client/hooks/use-table-url-state.ts +143 -30
  27. package/client/locales/system/en-US.ts +24 -1
  28. package/client/locales/system/settings-en-US.ts +8 -10
  29. package/client/locales/system/settings-zh-CN.ts +8 -8
  30. package/client/locales/system/zh-CN.ts +22 -1
  31. package/client/pages/settings/default-access-form.tsx +44 -26
  32. package/client/pages/settings/default-access.tsx +100 -93
  33. package/client/pages/settings/hooks.tsx +4 -59
  34. package/client/pages/settings/page-permissions.ts +17 -0
  35. package/client/pages/settings/permissions-model.ts +54 -59
  36. package/client/pages/settings/role-form.tsx +98 -56
  37. package/client/pages/settings/roles.tsx +69 -95
  38. package/client/pages/settings/shared.tsx +43 -33
  39. package/client/pages/settings/user-form.tsx +51 -30
  40. package/client/pages/settings/users.tsx +105 -91
  41. package/client/routing/session-preparation.tsx +10 -3
  42. package/client/runtime.ts +0 -4
  43. package/config.example.yml +24 -0
  44. package/database/main/migrations/202611020002_create_mutation_receipts.ts +19 -0
  45. package/package.json +24 -21
  46. package/server/app.ts +0 -2
  47. package/server/config/auth.ts +2 -0
  48. package/server/config/concurrency.ts +3 -0
  49. package/server/config/index.ts +3 -0
  50. package/server/config/jobs.ts +1 -13
  51. package/server/config/notification.ts +1 -1
  52. package/server/config/queue.ts +1 -7
  53. package/server/plugins.ts +2 -0
  54. package/server/providers/index.ts +4 -1
  55. package/server/providers/submission.ts +61 -0
  56. package/tests/client/auth-i18n.test.tsx +9 -0
  57. package/tests/client/back-button.test.tsx +3 -3
  58. package/tests/client/data-table.test.tsx +9 -4
  59. package/tests/client/default-access.test.tsx +99 -5
  60. package/tests/client/form-feedback.test.tsx +589 -0
  61. package/tests/client/list-feedback.test.tsx +301 -0
  62. package/tests/client/list-recovery.test.tsx +373 -0
  63. package/tests/client/list-tabs.test.tsx +121 -0
  64. package/tests/client/manual-list-retry.test.tsx +190 -0
  65. package/tests/client/page-permission-navigation.test.ts +8 -0
  66. package/tests/client/permission-editor.test.tsx +191 -21
  67. package/tests/client/permission-filter-binding.test.tsx +1 -1
  68. package/tests/client/permission-workspace.test.ts +13 -0
  69. package/tests/client/role-return.test.tsx +2 -1
  70. package/tests/client/session-preparation.test.tsx +24 -1
  71. package/tests/client/settings-continuity.test.tsx +85 -5
  72. package/tests/client/settings-feedback.test.tsx +32 -15
  73. package/tests/client/settings-permissions.test.ts +35 -0
  74. package/tests/client/settings.test.tsx +2 -1
  75. package/tests/client/table-url-state.test.tsx +117 -3
  76. package/tests/client/user-drawer.test.tsx +2 -1
  77. package/tests/client/user-permissions.test.tsx +30 -5
  78. package/tests/database/mutation-receipts.test.ts +45 -0
  79. package/tests/fixtures/form-feedback/README.md +33 -0
  80. package/tests/fixtures/form-feedback/equipment-detail.tsx +11 -0
  81. package/tests/fixtures/form-feedback/equipment-layout.tsx +15 -0
  82. package/tests/fixtures/form-feedback/equipment-migration.ts +27 -0
  83. package/tests/fixtures/form-feedback/equipment-server.ts +197 -0
  84. package/tests/fixtures/form-feedback/equipment-validation.tsx +94 -0
  85. package/tests/fixtures/form-feedback/equipment.tsx +369 -0
  86. package/tests/fixtures/form-feedback/verify-inline.mjs +174 -0
  87. package/tests/fixtures/form-feedback/verify.mjs +373 -0
  88. package/tests/fixtures/permission-alignment.html +16 -0
  89. package/tests/fixtures/permission-alignment.tsx +124 -0
  90. package/tests/fixtures/submission/README.md +35 -0
  91. package/tests/fixtures/submission/browser.test.ts +478 -0
  92. package/tests/fixtures/submission/migration.ts +61 -0
  93. package/tests/fixtures/submission/page.tsx +138 -0
  94. package/tests/fixtures/submission/playwright.config.ts +11 -0
  95. package/tests/fixtures/submission/routes.ts +208 -0
  96. package/tests/helpers/settings-client.tsx +31 -0
  97. package/tests/playwright/permission-alignment.test.ts +109 -0
  98. package/tests/server/auth-email-config.test.ts +79 -0
  99. package/tests/server/concurrency.test.ts +61 -0
  100. package/tests/server/config.test.ts +34 -14
  101. package/tests/server/member-role.test.ts +7 -1
  102. package/tests/server/submission-settings.test.ts +241 -0
  103. package/client/route-overrides.ts +0 -9
  104. package/client/source-extensions.ts +0 -19
@@ -0,0 +1,197 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import type { Application } from '@nocobase/app-server/application';
3
+ // Test-only routes for an isolated application; authentication and authorization remain native.
4
+ import { authenticationToken } from '@nocobase/app-plugin-authentication';
5
+ import { databaseManagerToken } from '@nocobase/db';
6
+ import {
7
+ ApiError,
8
+ apiErrorHandler,
9
+ apiValidator,
10
+ apiErrorResponse,
11
+ dataResponse,
12
+ describeRoute,
13
+ defineApiRoutes,
14
+ } from '@nocobase/app-server/router';
15
+ import {
16
+ authorizationToken,
17
+ type AuthorizationEnv,
18
+ } from '@nocobase/app-plugin-authorization';
19
+ import { Hono } from 'hono';
20
+ import { z } from 'zod';
21
+
22
+ export const equipmentRoutes = defineApiRoutes<Application>((app) => {
23
+ const routes = new Hono<AuthorizationEnv>();
24
+ const auth = app.container.resolve(authenticationToken);
25
+ const db = app.container.resolve(databaseManagerToken);
26
+ routes.use(
27
+ '/equipmentFixture',
28
+ auth.required(),
29
+ app.container.resolve(authorizationToken).middleware(),
30
+ );
31
+ routes.onError(apiErrorHandler);
32
+ const schema = z.object({
33
+ mode: z.string(),
34
+ name: z.string(),
35
+ department: z.string(),
36
+ items: z.array(z.object({ key: z.string(), name: z.string() })),
37
+ attachment: z.string(),
38
+ recordId: z.string().optional(),
39
+ });
40
+ const description = {
41
+ tags: ['Equipment fixture'],
42
+ summary: 'Equipment verification fixture',
43
+ responses: {
44
+ 200: dataResponse(z.unknown()),
45
+ 400: apiErrorResponse(400),
46
+ 401: apiErrorResponse(401),
47
+ 403: apiErrorResponse(403),
48
+ },
49
+ };
50
+ routes.get(
51
+ '/equipmentFixture',
52
+ describeRoute({ ...description, operationId: 'equipmentFixtureGet' }),
53
+ async (c) => {
54
+ await c.get('authz').require({
55
+ resource: { type: 'user', id: '*' },
56
+ action: 'create',
57
+ });
58
+ const repository = db.repository('equipmentFixture');
59
+ if (!(await repository.findOne({ filter: { id: 'retained' } })))
60
+ await repository.createOne({
61
+ values: {
62
+ id: 'retained',
63
+ name: 'Legacy camera',
64
+ department: 'Operations',
65
+ attachment: 'safety-check.pdf',
66
+ workflow: 'approved',
67
+ customField: 'asset-714',
68
+ },
69
+ });
70
+ return c.json({
71
+ data: await repository.findOne({ filter: { id: 'retained' } }),
72
+ requests: await repository.findMany({}),
73
+ history: await db.repository('equipmentFixtureHistory').findMany({}),
74
+ });
75
+ },
76
+ );
77
+ routes.post(
78
+ '/equipmentFixture',
79
+ describeRoute({ ...description, operationId: 'equipmentFixtureSave' }),
80
+ apiValidator('json', schema),
81
+ async (c) => {
82
+ await c.get('authz').require({
83
+ resource: { type: 'user', id: '*' },
84
+ action: 'create',
85
+ });
86
+ const input = c.req.valid('json');
87
+ if (input.mode === 'denied')
88
+ throw new ApiError({
89
+ status: 'PERMISSION_DENIED',
90
+ reason: 'RESTRICTED',
91
+ domain: 'equipment-fixture',
92
+ message: 'Private equipment details',
93
+ });
94
+ if (input.mode !== 'success' && input.mode !== 'approve')
95
+ throw new ApiError({
96
+ status: 'INVALID_ARGUMENT',
97
+ reason: 'EQUIPMENT_INVALID',
98
+ domain: 'equipment-fixture',
99
+ message: 'Private developer stack',
100
+ fieldViolations:
101
+ input.mode === 'structured'
102
+ ? [
103
+ {
104
+ field: 'items[1].name',
105
+ description: 'Private item value',
106
+ reason: 'ITEM',
107
+ },
108
+ {
109
+ field: 'items.0.name',
110
+ description: 'Private item value',
111
+ reason: 'ITEM',
112
+ },
113
+ ]
114
+ : input.mode === 'business'
115
+ ? [
116
+ {
117
+ field: 'department',
118
+ reason: 'DEPARTMENT',
119
+ description: 'Private business detail',
120
+ },
121
+ ]
122
+ : [
123
+ {
124
+ field: 'privateSalary',
125
+ description: 'Secret salary 123',
126
+ },
127
+ ],
128
+ });
129
+ return db.transaction(async (connection) => {
130
+ const repository = connection.repository('equipmentFixture');
131
+ const history = connection.repository('equipmentFixtureHistory');
132
+ if (input.mode === 'approve') {
133
+ const request = await repository.findOne({
134
+ filter: { id: input.recordId, workflow: 'submitted' },
135
+ });
136
+ if (!request)
137
+ throw new ApiError({
138
+ status: 'INVALID_ARGUMENT',
139
+ reason: 'NOT_SUBMITTED',
140
+ domain: 'equipment-fixture',
141
+ message: 'Only submitted requests can be approved.',
142
+ });
143
+ await repository.updateMany({
144
+ filter: { id: input.recordId, workflow: 'submitted' },
145
+ values: { workflow: 'approved' },
146
+ });
147
+ await history.createOne({
148
+ values: {
149
+ id: randomUUID(),
150
+ recordId: input.recordId,
151
+ event: 'approved',
152
+ actor: 'native-administrator',
153
+ },
154
+ });
155
+ return c.json({
156
+ data: await repository.findOne({ filter: { id: input.recordId } }),
157
+ });
158
+ }
159
+ if (input.department !== 'Operations')
160
+ throw new ApiError({
161
+ status: 'INVALID_ARGUMENT',
162
+ reason: 'DEPARTMENT',
163
+ domain: 'equipment-fixture',
164
+ message: 'Private business detail',
165
+ fieldViolations: [
166
+ {
167
+ field: 'department',
168
+ reason: 'DEPARTMENT',
169
+ description: 'Private business detail',
170
+ },
171
+ ],
172
+ });
173
+ const id = randomUUID();
174
+ await repository.createOne({
175
+ values: {
176
+ id,
177
+ name: input.name,
178
+ department: input.department,
179
+ attachment: input.attachment,
180
+ workflow: 'submitted',
181
+ customField: 'asset-714',
182
+ },
183
+ });
184
+ await history.createOne({
185
+ values: {
186
+ id: randomUUID(),
187
+ recordId: id,
188
+ event: 'submitted',
189
+ actor: 'native-administrator',
190
+ },
191
+ });
192
+ return c.json({ data: await repository.findOne({ filter: { id } }) });
193
+ });
194
+ },
195
+ );
196
+ return new Hono().route('/', routes);
197
+ });
@@ -0,0 +1,94 @@
1
+ import { useState, type ReactElement } from 'react';
2
+ import { useForm, useFieldArray } from 'react-hook-form';
3
+ import { FeedbackForm } from '#components/feedback-form';
4
+ import { PageContainer } from '#components/page-container';
5
+ import { Button } from '#components/ui/button';
6
+ import { Input } from '#components/ui/input';
7
+
8
+ // Isolated browser regression workload; all feedback behavior comes from the selected template component.
9
+ export default function EquipmentRegressions(): ReactElement {
10
+ const form = useForm<{ items: { name: string }[] }>({
11
+ defaultValues: { items: [{ name: 'Camera' }] },
12
+ shouldFocusError: false,
13
+ });
14
+ const rows = useFieldArray({
15
+ control: form.control,
16
+ name: 'items',
17
+ rules: {
18
+ minLength: { value: 2, message: 'Add at least two equipment items.' },
19
+ },
20
+ });
21
+ const [saved, setSaved] = useState(false);
22
+ const [invalid, setInvalid] = useState(false);
23
+ const [corrected, setCorrected] = useState(false);
24
+ return (
25
+ <PageContainer>
26
+ <h1 className='text-xl font-semibold'>Equipment validation</h1>
27
+ <div className='grid gap-8 max-w-2xl'>
28
+ <FeedbackForm
29
+ aria-label='Equipment group'
30
+ className='space-y-4'
31
+ noValidate
32
+ validationErrors={form.formState.errors}
33
+ submitting={form.formState.isSubmitting}
34
+ onSubmit={form.handleSubmit(() => setSaved(true))}
35
+ >
36
+ <h2 id='equipment-group-label' className='text-lg font-semibold'>
37
+ Equipment group
38
+ </h2>
39
+ <div>
40
+ <Button
41
+ type='button'
42
+ data-field-path='items'
43
+ aria-labelledby='equipment-group-label'
44
+ onClick={() => rows.append({ name: 'Projector' })}
45
+ >
46
+ Add equipment
47
+ </Button>
48
+ </div>
49
+ {rows.fields.map((row, i) => (
50
+ <div key={row.id}>
51
+ <label htmlFor={'group-' + row.id}>Equipment item {i + 1}</label>
52
+ <Input
53
+ id={'group-' + row.id}
54
+ {...form.register(`items.${i}.name`)}
55
+ />
56
+ </div>
57
+ ))}
58
+ <Button type='submit'>Save equipment group</Button>
59
+ {saved && <p role='status'>Equipment group saved.</p>}
60
+ </FeedbackForm>
61
+ <FeedbackForm
62
+ aria-label='Unique equipment'
63
+ className='space-y-4'
64
+ noValidate
65
+ onSubmit={() => setInvalid(true)}
66
+ >
67
+ <h2 className='text-lg'>Unique equipment name</h2>
68
+ <div data-slot='field'>
69
+ <label htmlFor='unique-equipment'>Unique name</label>
70
+ <Input
71
+ id='unique-equipment'
72
+ name='uniqueName'
73
+ defaultValue='Existing camera'
74
+ aria-invalid={invalid}
75
+ aria-describedby={corrected ? 'updated-help' : 'original-help'}
76
+ onChange={() => {
77
+ setInvalid(false);
78
+ setCorrected(true);
79
+ }}
80
+ />
81
+ <p id='original-help'>Use a unique inventory name.</p>
82
+ <p id='updated-help'>Corrected input is retained.</p>
83
+ {invalid && (
84
+ <p data-slot='field-error' className='text-destructive'>
85
+ Choose a unique equipment name.
86
+ </p>
87
+ )}
88
+ </div>
89
+ <Button type='submit'>Save unique name</Button>
90
+ </FeedbackForm>
91
+ </div>
92
+ </PageContainer>
93
+ );
94
+ }
@@ -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
+ }