@stapel/forms-react 0.1.0

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 (203) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/MODULE.md +172 -0
  3. package/README.md +207 -0
  4. package/dist/api/export.d.ts +66 -0
  5. package/dist/api/export.d.ts.map +1 -0
  6. package/dist/api/export.js +138 -0
  7. package/dist/api/export.js.map +1 -0
  8. package/dist/api/extensions.d.ts +8 -0
  9. package/dist/api/extensions.d.ts.map +1 -0
  10. package/dist/api/extensions.js +8 -0
  11. package/dist/api/extensions.js.map +1 -0
  12. package/dist/api/formsApi.d.ts +113 -0
  13. package/dist/api/formsApi.d.ts.map +1 -0
  14. package/dist/api/formsApi.js +81 -0
  15. package/dist/api/formsApi.js.map +1 -0
  16. package/dist/api/generated/schema.d.ts +987 -0
  17. package/dist/api/generated/schema.d.ts.map +1 -0
  18. package/dist/api/generated/schema.js +2 -0
  19. package/dist/api/generated/schema.js.map +1 -0
  20. package/dist/api/types.d.ts +222 -0
  21. package/dist/api/types.d.ts.map +1 -0
  22. package/dist/api/types.js +14 -0
  23. package/dist/api/types.js.map +1 -0
  24. package/dist/default/ConfigField.d.ts +32 -0
  25. package/dist/default/ConfigField.d.ts.map +1 -0
  26. package/dist/default/ConfigField.js +101 -0
  27. package/dist/default/ConfigField.js.map +1 -0
  28. package/dist/default/ErrorAlert.d.ts +9 -0
  29. package/dist/default/ErrorAlert.d.ts.map +1 -0
  30. package/dist/default/ErrorAlert.js +27 -0
  31. package/dist/default/ErrorAlert.js.map +1 -0
  32. package/dist/default/FormBuilderPane.d.ts +36 -0
  33. package/dist/default/FormBuilderPane.d.ts.map +1 -0
  34. package/dist/default/FormBuilderPane.js +70 -0
  35. package/dist/default/FormBuilderPane.js.map +1 -0
  36. package/dist/default/FormsListPane.d.ts +18 -0
  37. package/dist/default/FormsListPane.d.ts.map +1 -0
  38. package/dist/default/FormsListPane.js +28 -0
  39. package/dist/default/FormsListPane.js.map +1 -0
  40. package/dist/default/ResponsesPane.d.ts +21 -0
  41. package/dist/default/ResponsesPane.d.ts.map +1 -0
  42. package/dist/default/ResponsesPane.js +118 -0
  43. package/dist/default/ResponsesPane.js.map +1 -0
  44. package/dist/default/StapelForm.d.ts +76 -0
  45. package/dist/default/StapelForm.d.ts.map +1 -0
  46. package/dist/default/StapelForm.js +118 -0
  47. package/dist/default/StapelForm.js.map +1 -0
  48. package/dist/default/fields.d.ts +11 -0
  49. package/dist/default/fields.d.ts.map +1 -0
  50. package/dist/default/fields.js +273 -0
  51. package/dist/default/fields.js.map +1 -0
  52. package/dist/default/index.d.ts +42 -0
  53. package/dist/default/index.d.ts.map +1 -0
  54. package/dist/default/index.js +37 -0
  55. package/dist/default/index.js.map +1 -0
  56. package/dist/default/slots.d.ts +59 -0
  57. package/dist/default/slots.d.ts.map +1 -0
  58. package/dist/default/slots.js +25 -0
  59. package/dist/default/slots.js.map +1 -0
  60. package/dist/default/theme.d.ts +23 -0
  61. package/dist/default/theme.d.ts.map +1 -0
  62. package/dist/default/theme.js +38 -0
  63. package/dist/default/theme.js.map +1 -0
  64. package/dist/default/types.d.ts +12 -0
  65. package/dist/default/types.d.ts.map +1 -0
  66. package/dist/default/types.js +2 -0
  67. package/dist/default/types.js.map +1 -0
  68. package/dist/flows/errors.d.ts +12 -0
  69. package/dist/flows/errors.d.ts.map +1 -0
  70. package/dist/flows/errors.js +13 -0
  71. package/dist/flows/errors.js.map +1 -0
  72. package/dist/flows/registry.d.ts +22 -0
  73. package/dist/flows/registry.d.ts.map +1 -0
  74. package/dist/flows/registry.js +20 -0
  75. package/dist/flows/registry.js.map +1 -0
  76. package/dist/headless/FormBuilder.d.ts +100 -0
  77. package/dist/headless/FormBuilder.d.ts.map +1 -0
  78. package/dist/headless/FormBuilder.js +278 -0
  79. package/dist/headless/FormBuilder.js.map +1 -0
  80. package/dist/headless/FormFill.d.ts +88 -0
  81. package/dist/headless/FormFill.d.ts.map +1 -0
  82. package/dist/headless/FormFill.js +279 -0
  83. package/dist/headless/FormFill.js.map +1 -0
  84. package/dist/headless/FormList.d.ts +34 -0
  85. package/dist/headless/FormList.d.ts.map +1 -0
  86. package/dist/headless/FormList.js +37 -0
  87. package/dist/headless/FormList.js.map +1 -0
  88. package/dist/headless/FormsProvider.d.ts +19 -0
  89. package/dist/headless/FormsProvider.d.ts.map +1 -0
  90. package/dist/headless/FormsProvider.js +15 -0
  91. package/dist/headless/FormsProvider.js.map +1 -0
  92. package/dist/headless/ResponsesTable.d.ts +82 -0
  93. package/dist/headless/ResponsesTable.d.ts.map +1 -0
  94. package/dist/headless/ResponsesTable.js +195 -0
  95. package/dist/headless/ResponsesTable.js.map +1 -0
  96. package/dist/i18n/errorsMap.d.ts +10 -0
  97. package/dist/i18n/errorsMap.d.ts.map +1 -0
  98. package/dist/i18n/errorsMap.js +20 -0
  99. package/dist/i18n/errorsMap.js.map +1 -0
  100. package/dist/i18n/es.d.ts +7 -0
  101. package/dist/i18n/es.d.ts.map +1 -0
  102. package/dist/i18n/es.js +118 -0
  103. package/dist/i18n/es.js.map +1 -0
  104. package/dist/i18n/generated/errors.es.gen.d.ts +16 -0
  105. package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -0
  106. package/dist/i18n/generated/errors.es.gen.js +79 -0
  107. package/dist/i18n/generated/errors.es.gen.js.map +1 -0
  108. package/dist/i18n/generated/errors.gen.d.ts +485 -0
  109. package/dist/i18n/generated/errors.gen.d.ts.map +1 -0
  110. package/dist/i18n/generated/errors.gen.js +246 -0
  111. package/dist/i18n/generated/errors.gen.js.map +1 -0
  112. package/dist/i18n/generated/errors.ru.gen.d.ts +16 -0
  113. package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -0
  114. package/dist/i18n/generated/errors.ru.gen.js +79 -0
  115. package/dist/i18n/generated/errors.ru.gen.js.map +1 -0
  116. package/dist/i18n/keys.d.ts +141 -0
  117. package/dist/i18n/keys.d.ts.map +1 -0
  118. package/dist/i18n/keys.js +233 -0
  119. package/dist/i18n/keys.js.map +1 -0
  120. package/dist/i18n/ru.d.ts +7 -0
  121. package/dist/i18n/ru.d.ts.map +1 -0
  122. package/dist/i18n/ru.js +127 -0
  123. package/dist/i18n/ru.js.map +1 -0
  124. package/dist/index.d.ts +61 -0
  125. package/dist/index.d.ts.map +1 -0
  126. package/dist/index.js +55 -0
  127. package/dist/index.js.map +1 -0
  128. package/dist/model/context.d.ts +11 -0
  129. package/dist/model/context.d.ts.map +1 -0
  130. package/dist/model/context.js +16 -0
  131. package/dist/model/context.js.map +1 -0
  132. package/dist/model/mutations.d.ts +135 -0
  133. package/dist/model/mutations.d.ts.map +1 -0
  134. package/dist/model/mutations.js +223 -0
  135. package/dist/model/mutations.js.map +1 -0
  136. package/dist/model/queries.d.ts +59 -0
  137. package/dist/model/queries.d.ts.map +1 -0
  138. package/dist/model/queries.js +117 -0
  139. package/dist/model/queries.js.map +1 -0
  140. package/dist/model/queryKeys.d.ts +28 -0
  141. package/dist/model/queryKeys.d.ts.map +1 -0
  142. package/dist/model/queryKeys.js +23 -0
  143. package/dist/model/queryKeys.js.map +1 -0
  144. package/dist/model/runtime.d.ts +28 -0
  145. package/dist/model/runtime.d.ts.map +1 -0
  146. package/dist/model/runtime.js +14 -0
  147. package/dist/model/runtime.js.map +1 -0
  148. package/dist/widgets/configForms.d.ts +112 -0
  149. package/dist/widgets/configForms.d.ts.map +1 -0
  150. package/dist/widgets/configForms.js +211 -0
  151. package/dist/widgets/configForms.js.map +1 -0
  152. package/dist/widgets/registry.d.ts +83 -0
  153. package/dist/widgets/registry.d.ts.map +1 -0
  154. package/dist/widgets/registry.js +27 -0
  155. package/dist/widgets/registry.js.map +1 -0
  156. package/dist/widgets/validate.d.ts +57 -0
  157. package/dist/widgets/validate.d.ts.map +1 -0
  158. package/dist/widgets/validate.js +173 -0
  159. package/dist/widgets/validate.js.map +1 -0
  160. package/llms.txt +124 -0
  161. package/manifest.json +1038 -0
  162. package/package.json +108 -0
  163. package/src/analytics/generated/events.json +7 -0
  164. package/src/api/export.ts +195 -0
  165. package/src/api/extensions.ts +7 -0
  166. package/src/api/formsApi.ts +329 -0
  167. package/src/api/generated/schema.ts +989 -0
  168. package/src/api/types.ts +276 -0
  169. package/src/default/ConfigField.tsx +227 -0
  170. package/src/default/ErrorAlert.tsx +46 -0
  171. package/src/default/FormBuilderPane.tsx +414 -0
  172. package/src/default/FormsListPane.tsx +144 -0
  173. package/src/default/ResponsesPane.tsx +332 -0
  174. package/src/default/StapelForm.tsx +377 -0
  175. package/src/default/fields.tsx +454 -0
  176. package/src/default/index.ts +71 -0
  177. package/src/default/slots.ts +78 -0
  178. package/src/default/theme.tsx +65 -0
  179. package/src/default/types.ts +12 -0
  180. package/src/flows/errors.ts +16 -0
  181. package/src/flows/registry.ts +32 -0
  182. package/src/headless/FormBuilder.tsx +453 -0
  183. package/src/headless/FormFill.tsx +396 -0
  184. package/src/headless/FormList.tsx +74 -0
  185. package/src/headless/FormsProvider.tsx +20 -0
  186. package/src/headless/ResponsesTable.tsx +341 -0
  187. package/src/i18n/errorsMap.ts +31 -0
  188. package/src/i18n/es.ts +140 -0
  189. package/src/i18n/generated/errors.es.gen.ts +85 -0
  190. package/src/i18n/generated/errors.gen.ts +277 -0
  191. package/src/i18n/generated/errors.json +583 -0
  192. package/src/i18n/generated/errors.ru.gen.ts +85 -0
  193. package/src/i18n/keys.ts +264 -0
  194. package/src/i18n/ru.ts +150 -0
  195. package/src/index.ts +184 -0
  196. package/src/model/context.tsx +28 -0
  197. package/src/model/mutations.ts +387 -0
  198. package/src/model/queries.ts +154 -0
  199. package/src/model/queryKeys.ts +61 -0
  200. package/src/model/runtime.ts +48 -0
  201. package/src/widgets/registry.ts +101 -0
  202. package/src/widgets/validate.ts +224 -0
  203. package/tsconfig.json +26 -0
@@ -0,0 +1,414 @@
1
+ /**
2
+ * `<FormBuilderPane>` — the workspace admin's authoring surface.
3
+ *
4
+ * The §8 verdict in one component: form authoring is a REACT surface over
5
+ * capability-gated REST, because in this fleet "admin" means a workspace
6
+ * role and only REST reaches a workspace role — Django admin is a staff-only
7
+ * peephole that the actual form authors cannot open.
8
+ *
9
+ * The §8 scope discipline is visible in the code: there is no per-kind form
10
+ * anywhere here. A field's options come from `widgets/configForms.ts` and are
11
+ * drawn by `<ConfigField>`, chosen by config-widget kind. "Rich per-kind
12
+ * config editors and drag-reorder polish are explicitly allowed to be ugly in
13
+ * v1" — so reordering is two buttons, not a drag surface.
14
+ */
15
+ import type { ReactElement } from "react";
16
+ import {
17
+ Alert,
18
+ Button,
19
+ Card,
20
+ Collapse,
21
+ Empty,
22
+ Flex,
23
+ Form,
24
+ Input,
25
+ Select,
26
+ Space,
27
+ Spin,
28
+ Switch,
29
+ Tag,
30
+ Typography,
31
+ } from "antd";
32
+ import {
33
+ matchList,
34
+ matchLoad,
35
+ toFlowError,
36
+ useActionGate,
37
+ useDescribeFlowError,
38
+ useT,
39
+ } from "@stapel/core";
40
+ import type { ConfigFieldSpec, FormRow, FormState } from "../api/types.js";
41
+ import { FormBuilder } from "../headless/FormBuilder.js";
42
+ import type { BuilderField, FormBuilderBag } from "../headless/FormBuilder.js";
43
+ import { ConfigField } from "./ConfigField.js";
44
+ import { FormsSkinTheme } from "./theme.js";
45
+ import { ErrorAlert } from "./ErrorAlert.js";
46
+ import { resolveFormsSkinComponent } from "./slots.js";
47
+ import type { ThemeModeProp } from "./types.js";
48
+ import { FORMS_I18N_KEYS } from "../i18n/keys.js";
49
+
50
+ /** Props of the `"builder.toolbar"` slot. */
51
+ export interface BuilderToolbarSlotProps {
52
+ readonly bag: FormBuilderBag;
53
+ readonly row: FormRow;
54
+ }
55
+
56
+ /** Props of the `"builder.fieldRow"` slot. */
57
+ export interface BuilderFieldRowSlotProps {
58
+ readonly entry: BuilderField;
59
+ readonly index: number;
60
+ readonly total: number;
61
+ readonly bag: FormBuilderBag;
62
+ }
63
+
64
+ function FieldEditor(props: BuilderFieldRowSlotProps): ReactElement {
65
+ const t = useT();
66
+ const Slot = resolveFormsSkinComponent<BuilderFieldRowSlotProps>(
67
+ "builder.fieldRow"
68
+ );
69
+ if (Slot) return <Slot {...props} />;
70
+
71
+ const { entry, index, total, bag } = props;
72
+ const { field } = entry;
73
+ const config = field.config ?? {};
74
+
75
+ return (
76
+ <Card
77
+ size="small"
78
+ data-testid={`forms-builder-field-${field.slug}`}
79
+ title={
80
+ <Flex align="center" gap={8}>
81
+ <Tag>{field.kind}</Tag>
82
+ <Typography.Text strong>{field.name || field.slug}</Typography.Text>
83
+ </Flex>
84
+ }
85
+ extra={
86
+ <Space>
87
+ <Button
88
+ size="small"
89
+ disabled={index === 0}
90
+ data-analytics="none"
91
+ data-analytics-reason="local draft reorder; the save mutation is the tracked step"
92
+ onClick={() => bag.moveField(field.slug, index - 1)}
93
+ >
94
+ {t(FORMS_I18N_KEYS.builderMoveUp)}
95
+ </Button>
96
+ <Button
97
+ size="small"
98
+ disabled={index === total - 1}
99
+ data-analytics="none"
100
+ data-analytics-reason="local draft reorder; the save mutation is the tracked step"
101
+ onClick={() => bag.moveField(field.slug, index + 1)}
102
+ >
103
+ {t(FORMS_I18N_KEYS.builderMoveDown)}
104
+ </Button>
105
+ <Button
106
+ size="small"
107
+ danger
108
+ data-analytics="none"
109
+ data-analytics-reason="local draft edit; the save mutation is the tracked step"
110
+ onClick={() => bag.removeField(field.slug)}
111
+ >
112
+ {t(FORMS_I18N_KEYS.builderRemoveField)}
113
+ </Button>
114
+ </Space>
115
+ }
116
+ >
117
+ <Form layout="vertical" size="small">
118
+ <Form.Item label={t(FORMS_I18N_KEYS.builderFieldSlug)}>
119
+ <Input
120
+ value={field.slug}
121
+ onChange={(event) =>
122
+ bag.updateField(field.slug, { slug: event.target.value })
123
+ }
124
+ />
125
+ </Form.Item>
126
+ <Form.Item label={t(FORMS_I18N_KEYS.builderFieldLabel)}>
127
+ <Input
128
+ value={field.name ?? ""}
129
+ onChange={(event) =>
130
+ bag.updateField(field.slug, { name: event.target.value })
131
+ }
132
+ />
133
+ </Form.Item>
134
+ {/* A header is a caption, so "required" would be meaningless on it. */}
135
+ {field.kind !== "header" && (
136
+ <Form.Item label={t(FORMS_I18N_KEYS.builderFieldRequired)}>
137
+ <Switch
138
+ checked={field.mandatory === true}
139
+ onChange={(checked) =>
140
+ bag.updateField(field.slug, { mandatory: checked })
141
+ }
142
+ />
143
+ </Form.Item>
144
+ )}
145
+
146
+ {entry.builderLess ? (
147
+ <Alert
148
+ type="info"
149
+ showIcon
150
+ data-testid="forms-builder-less"
151
+ // Two different facts, worded differently: the registry does not
152
+ // carry this kind at all, versus it carries it and it simply has
153
+ // no options. Collapsing them would tell an admin their schema is
154
+ // fine when the deployment cannot render the field.
155
+ message={t(
156
+ entry.kindInfo === undefined || entry.kindInfo.registered === false
157
+ ? FORMS_I18N_KEYS.builderKindUnregistered
158
+ : FORMS_I18N_KEYS.builderBuilderLess
159
+ )}
160
+ />
161
+ ) : (
162
+ <Collapse
163
+ size="small"
164
+ ghost
165
+ items={[
166
+ {
167
+ key: "config",
168
+ label: t(FORMS_I18N_KEYS.builderFieldKind),
169
+ children: (
170
+ <>
171
+ {entry.configFields.map((spec: ConfigFieldSpec) => (
172
+ <Form.Item key={spec.name} label={spec.name}>
173
+ <ConfigField
174
+ spec={spec}
175
+ value={config[spec.name]}
176
+ disabled={false}
177
+ onChange={(next) =>
178
+ bag.setFieldConfig(field.slug, spec.name, next)
179
+ }
180
+ />
181
+ </Form.Item>
182
+ ))}
183
+ </>
184
+ ),
185
+ },
186
+ ]}
187
+ />
188
+ )}
189
+ </Form>
190
+ </Card>
191
+ );
192
+ }
193
+
194
+ function Toolbar(props: BuilderToolbarSlotProps): ReactElement {
195
+ const t = useT();
196
+ const saveGate = useActionGate(props.bag.save);
197
+ const publishGate = useActionGate(props.bag.publish);
198
+ const Slot = resolveFormsSkinComponent<BuilderToolbarSlotProps>(
199
+ "builder.toolbar"
200
+ );
201
+ if (Slot) return <Slot {...props} />;
202
+
203
+ const { bag, row } = props;
204
+ const stateLabel: Readonly<Record<FormState, string>> = {
205
+ draft: t(FORMS_I18N_KEYS.builderStateDraft),
206
+ open: t(FORMS_I18N_KEYS.builderStateOpen),
207
+ closed: t(FORMS_I18N_KEYS.builderStateClosed),
208
+ };
209
+
210
+ return (
211
+ <Flex vertical gap={8}>
212
+ <Flex gap={8} wrap align="center">
213
+ <Button
214
+ type="default"
215
+ loading={bag.isSaving}
216
+ disabled={saveGate.disabled}
217
+ data-analytics="flow"
218
+ data-testid="forms-builder-save"
219
+ onClick={bag.doSave}
220
+ >
221
+ {t(FORMS_I18N_KEYS.builderSave)}
222
+ </Button>
223
+ <Button
224
+ type="primary"
225
+ loading={bag.isPublishing}
226
+ disabled={publishGate.disabled}
227
+ data-analytics="flow"
228
+ data-testid="forms-builder-publish"
229
+ onClick={bag.doPublish}
230
+ >
231
+ {t(FORMS_I18N_KEYS.builderPublish)}
232
+ </Button>
233
+ <Select<FormState>
234
+ style={{ width: 140 }}
235
+ value={row.state as FormState}
236
+ onChange={(next) => bag.setState(next)}
237
+ options={(["draft", "open", "closed"] as const).map((s) => ({
238
+ value: s,
239
+ label: stateLabel[s],
240
+ }))}
241
+ />
242
+ <Button
243
+ data-analytics="flow"
244
+ data-testid="forms-builder-rotate"
245
+ onClick={bag.rotateLink}
246
+ >
247
+ {t(FORMS_I18N_KEYS.builderRotateLink)}
248
+ </Button>
249
+ </Flex>
250
+ {/* Reasons as text beside the controls, never as tooltips on disabled
251
+ buttons — those receive no pointer events. */}
252
+ {saveGate.reason !== undefined && (
253
+ <Typography.Text type="secondary">{saveGate.reason}</Typography.Text>
254
+ )}
255
+ {publishGate.reason !== undefined && (
256
+ <Typography.Text type="secondary" data-testid="forms-publish-blocked">
257
+ {publishGate.reason}
258
+ </Typography.Text>
259
+ )}
260
+ <Typography.Text type="secondary" copyable={{ text: row.public_id }}>
261
+ {t(FORMS_I18N_KEYS.builderPublicLink)}: {row.public_id}
262
+ </Typography.Text>
263
+ </Flex>
264
+ );
265
+ }
266
+
267
+ export interface FormBuilderPaneProps extends ThemeModeProp {
268
+ readonly workspaceId: string;
269
+ readonly formId: string;
270
+ }
271
+
272
+ export function FormBuilderPane(props: FormBuilderPaneProps): ReactElement {
273
+ const t = useT();
274
+ const describe = useDescribeFlowError();
275
+
276
+ return (
277
+ <FormsSkinTheme {...(props.mode !== undefined ? { mode: props.mode } : {})}>
278
+ <FormBuilder workspaceId={props.workspaceId} formId={props.formId}>
279
+ {(bag) =>
280
+ matchLoad(bag.state, {
281
+ loading: () => (
282
+ <Flex justify="center" style={{ padding: 24 }}>
283
+ <Spin data-testid="forms-builder-loading" />
284
+ </Flex>
285
+ ),
286
+ // Never "this form does not exist" — a failed read is a failed
287
+ // read, and it offers a retry.
288
+ failed: (error) => (
289
+ <ErrorAlert
290
+ testId="forms-builder-failed"
291
+ error={describe(toFlowError(error))}
292
+ action={
293
+ <Button
294
+ size="small"
295
+ onClick={bag.refetch}
296
+ data-analytics="none"
297
+ data-analytics-reason="retry of a failed read; no flow to step"
298
+ >
299
+ {t(FORMS_I18N_KEYS.fillRetry)}
300
+ </Button>
301
+ }
302
+ />
303
+ ),
304
+ ready: (row) => (
305
+ <Flex vertical gap={16}>
306
+ <Typography.Title level={4}>
307
+ {t(FORMS_I18N_KEYS.builderTitle)}
308
+ </Typography.Title>
309
+
310
+ <Toolbar bag={bag} row={row} />
311
+
312
+ {bag.error !== null && (
313
+ <ErrorAlert
314
+ testId="forms-builder-error"
315
+ error={describe(toFlowError(bag.error))}
316
+ />
317
+ )}
318
+
319
+ <Form layout="vertical">
320
+ <Form.Item label={t(FORMS_I18N_KEYS.builderMetaTitle)}>
321
+ <Input
322
+ value={bag.meta.title ?? ""}
323
+ onChange={(event) =>
324
+ bag.setMeta({ title: event.target.value })
325
+ }
326
+ />
327
+ </Form.Item>
328
+ <Form.Item label={t(FORMS_I18N_KEYS.builderMetaDescription)}>
329
+ <Input.TextArea
330
+ autoSize={{ minRows: 2, maxRows: 5 }}
331
+ value={bag.meta.description ?? ""}
332
+ onChange={(event) =>
333
+ bag.setMeta({ description: event.target.value })
334
+ }
335
+ />
336
+ </Form.Item>
337
+ <Form.Item label={t(FORMS_I18N_KEYS.builderMetaSubmitLabel)}>
338
+ <Input
339
+ value={bag.meta.submit_label ?? ""}
340
+ onChange={(event) =>
341
+ bag.setMeta({ submit_label: event.target.value })
342
+ }
343
+ />
344
+ </Form.Item>
345
+ <Form.Item label={t(FORMS_I18N_KEYS.builderMetaConfirmation)}>
346
+ <Input
347
+ value={bag.meta.confirmation_text ?? ""}
348
+ onChange={(event) =>
349
+ bag.setMeta({ confirmation_text: event.target.value })
350
+ }
351
+ />
352
+ </Form.Item>
353
+ </Form>
354
+
355
+ {bag.fields.length === 0 ? (
356
+ <Empty description={t(FORMS_I18N_KEYS.builderEmpty)} />
357
+ ) : (
358
+ <Flex vertical gap={12}>
359
+ {bag.fields.map((entry, index) => (
360
+ <FieldEditor
361
+ key={entry.field.slug}
362
+ entry={entry}
363
+ index={index}
364
+ total={bag.fields.length}
365
+ bag={bag}
366
+ />
367
+ ))}
368
+ </Flex>
369
+ )}
370
+
371
+ {/* The catalogue is a LoadState of its own: a builder whose
372
+ dictionary failed to load must say so, not render zero
373
+ buttons and imply this deployment has no field kinds. */}
374
+ {matchList(bag.availableKinds, {
375
+ loading: () => <Spin size="small" data-testid="forms-kinds-loading" />,
376
+ failed: (error) => (
377
+ <ErrorAlert
378
+ testId="forms-kinds-failed"
379
+ error={{
380
+ ...describe(toFlowError(error)),
381
+ message: t(FORMS_I18N_KEYS.builderKindsFailed),
382
+ }}
383
+ />
384
+ ),
385
+ empty: () => (
386
+ <Typography.Text type="secondary" data-testid="forms-kinds-empty">
387
+ {t(FORMS_I18N_KEYS.builderNoKinds)}
388
+ </Typography.Text>
389
+ ),
390
+ ready: (kinds) => (
391
+ <Flex gap={8} wrap>
392
+ {kinds.map((kind) => (
393
+ <Button
394
+ key={kind.kind}
395
+ size="small"
396
+ data-analytics="none"
397
+ data-analytics-reason="local draft edit; the save mutation is the tracked step"
398
+ data-testid={`forms-builder-add-${kind.kind}`}
399
+ onClick={() => bag.addField(kind.kind)}
400
+ >
401
+ {t(FORMS_I18N_KEYS.builderAddField)}: {kind.kind}
402
+ </Button>
403
+ ))}
404
+ </Flex>
405
+ ),
406
+ })}
407
+ </Flex>
408
+ ),
409
+ })
410
+ }
411
+ </FormBuilder>
412
+ </FormsSkinTheme>
413
+ );
414
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * `<FormsListPane>` — the admin surface's entry point: the workspace's forms,
3
+ * and the button that makes another one.
4
+ *
5
+ * Rendered through core's `matchList`, whose FOUR required arms are the point:
6
+ * "no forms in this workspace yet" is reachable only from a load that
7
+ * actually succeeded, so that sentence can never be said about an outage.
8
+ */
9
+ import type { ReactElement } from "react";
10
+ import { Button, Empty, Flex, List, Segmented, Spin, Tag, Typography } from "antd";
11
+ import {
12
+ matchList,
13
+ toFlowError,
14
+ useDescribeFlowError,
15
+ useT,
16
+ } from "@stapel/core";
17
+ import type { FormRow, FormState } from "../api/types.js";
18
+ import { FormList } from "../headless/FormList.js";
19
+ import { FormsSkinTheme } from "./theme.js";
20
+ import { ErrorAlert } from "./ErrorAlert.js";
21
+ import type { ThemeModeProp } from "./types.js";
22
+ import { FORMS_I18N_KEYS } from "../i18n/keys.js";
23
+
24
+ export interface FormsListPaneProps extends ThemeModeProp {
25
+ readonly workspaceId: string;
26
+ /** Called when a row is activated — the host routes to the builder. */
27
+ readonly onOpen?: (form: FormRow) => void;
28
+ }
29
+
30
+ export function FormsListPane(props: FormsListPaneProps): ReactElement {
31
+ const t = useT();
32
+ const describe = useDescribeFlowError();
33
+
34
+ return (
35
+ <FormsSkinTheme {...(props.mode !== undefined ? { mode: props.mode } : {})}>
36
+ <FormList
37
+ workspaceId={props.workspaceId}
38
+ {...(props.onOpen ? { onCreated: props.onOpen } : {})}
39
+ >
40
+ {(bag) => (
41
+ <Flex vertical gap={16}>
42
+ <Flex justify="space-between" align="center" wrap gap={8}>
43
+ <Typography.Title level={4} style={{ margin: 0 }}>
44
+ {t(FORMS_I18N_KEYS.listTitle)}
45
+ </Typography.Title>
46
+ <Flex gap={8} align="center">
47
+ <Segmented<string>
48
+ value={bag.filter ?? "all"}
49
+ onChange={(next) =>
50
+ bag.setFilter(next === "all" ? null : (next as FormState))
51
+ }
52
+ options={[
53
+ { value: "all", label: t(FORMS_I18N_KEYS.responsesAllVersions) },
54
+ { value: "draft", label: t(FORMS_I18N_KEYS.builderStateDraft) },
55
+ { value: "open", label: t(FORMS_I18N_KEYS.builderStateOpen) },
56
+ { value: "closed", label: t(FORMS_I18N_KEYS.builderStateClosed) },
57
+ ]}
58
+ />
59
+ <Button
60
+ type="primary"
61
+ loading={bag.isCreating}
62
+ data-analytics="flow"
63
+ data-testid="forms-list-create"
64
+ onClick={() =>
65
+ bag.create({ title: t(FORMS_I18N_KEYS.listNewTitle) })
66
+ }
67
+ >
68
+ {t(FORMS_I18N_KEYS.listCreate)}
69
+ </Button>
70
+ </Flex>
71
+ </Flex>
72
+
73
+ {bag.error !== null && (
74
+ <ErrorAlert
75
+ testId="forms-list-error"
76
+ error={describe(toFlowError(bag.error))}
77
+ />
78
+ )}
79
+
80
+ {matchList(bag.state, {
81
+ loading: () => (
82
+ <Flex justify="center" style={{ padding: 24 }}>
83
+ <Spin data-testid="forms-list-loading" />
84
+ </Flex>
85
+ ),
86
+ failed: (error) => (
87
+ <ErrorAlert
88
+ testId="forms-list-failed"
89
+ error={{
90
+ ...describe(toFlowError(error)),
91
+ message: t(FORMS_I18N_KEYS.listLoadFailed),
92
+ }}
93
+ action={
94
+ <Button
95
+ size="small"
96
+ onClick={bag.refetch}
97
+ data-analytics="none"
98
+ data-analytics-reason="retry of a failed read; no flow to step"
99
+ >
100
+ {t(FORMS_I18N_KEYS.fillRetry)}
101
+ </Button>
102
+ }
103
+ />
104
+ ),
105
+ empty: () => (
106
+ <Empty
107
+ data-testid="forms-list-empty"
108
+ description={t(FORMS_I18N_KEYS.listEmpty)}
109
+ />
110
+ ),
111
+ ready: (rows) => (
112
+ <List<FormRow>
113
+ data-testid="forms-list"
114
+ dataSource={[...rows]}
115
+ renderItem={(row) => (
116
+ <List.Item
117
+ key={row.id}
118
+ onClick={() => props.onOpen?.(row)}
119
+ // Navigation into the builder, not a tracked outcome:
120
+ // the host owns its own routing analytics, and this
121
+ // pair emitting a funnel step for "opened a row" would
122
+ // put a forms.* event in somebody else's funnel.
123
+ data-analytics="none"
124
+ data-analytics-reason="host-owned navigation; no forms flow is stepped"
125
+ style={{ cursor: props.onOpen ? "pointer" : "default" }}
126
+ >
127
+ <List.Item.Meta
128
+ title={row.title}
129
+ description={t(FORMS_I18N_KEYS.listSubmissionCount, {
130
+ count: row.submission_count ?? 0,
131
+ })}
132
+ />
133
+ <Tag>{row.state}</Tag>
134
+ </List.Item>
135
+ )}
136
+ />
137
+ ),
138
+ })}
139
+ </Flex>
140
+ )}
141
+ </FormList>
142
+ </FormsSkinTheme>
143
+ );
144
+ }