@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,332 @@
1
+ /**
2
+ * `<ResponsesPane>` — the response review surface (spec §8.2).
3
+ *
4
+ * Per-version column sets, a version filter, a detail drawer, delete under
5
+ * `forms.responses.manage`, resend with an optional destination override, and
6
+ * CSV export driven by the `X-Forms-Next-Before` header cursor.
7
+ *
8
+ * ── Two things this surface deliberately does not do ───────────────────────
9
+ *
10
+ * 1. **No live counts over a socket.** Refetch only. A
11
+ * `forms:ws:<workspace_id>` Signal stream is reserved naming for when the
12
+ * stapel-realtime substrate lands; forms does not build a socket, and that
13
+ * is a lint boundary rather than an unfinished feature.
14
+ * 2. **No client-side CSV escaping.** The formula-injection guard (a `'`
15
+ * prefix on a leading `= + - @`) lives SERVER-side, so every consumer
16
+ * inherits it — a second escape here would double-prefix the cells the
17
+ * server already fixed.
18
+ */
19
+ import { useState } from "react";
20
+ import type { ReactElement } from "react";
21
+ import {
22
+ Button,
23
+ Drawer,
24
+ Empty,
25
+ Flex,
26
+ Input,
27
+ Popconfirm,
28
+ Select,
29
+ Space,
30
+ Spin,
31
+ Table,
32
+ Tag,
33
+ Typography,
34
+ } from "antd";
35
+ import {
36
+ matchLoad,
37
+ toFlowError,
38
+ useActionGate,
39
+ useDescribeFlowError,
40
+ useT,
41
+ } from "@stapel/core";
42
+ import type { Submission } from "../api/types.js";
43
+ import { ResponsesTable } from "../headless/ResponsesTable.js";
44
+ import type {
45
+ ResponseColumn,
46
+ ResponsesTableBag,
47
+ } from "../headless/ResponsesTable.js";
48
+ import { FormsSkinTheme } from "./theme.js";
49
+ import { ErrorAlert } from "./ErrorAlert.js";
50
+ import { resolveFormsSkinComponent } from "./slots.js";
51
+ import type { ThemeModeProp } from "./types.js";
52
+ import { FORMS_I18N_KEYS } from "../i18n/keys.js";
53
+
54
+ /** Props of the `"responses.cell"` slot. */
55
+ export interface ResponseCellSlotProps {
56
+ readonly column: ResponseColumn;
57
+ readonly row: Submission;
58
+ readonly value: unknown;
59
+ }
60
+
61
+ /** Props of the `"responses.toolbar"` slot. */
62
+ export interface ResponsesToolbarSlotProps {
63
+ readonly bag: ResponsesTableBag;
64
+ }
65
+
66
+ /** Render one answer. Objects and arrays are JSON, not `[object Object]` —
67
+ * a reviewer looking at a `convertible_unit` or a multi-select must be able
68
+ * to read what was actually submitted. */
69
+ function renderAnswer(value: unknown): string {
70
+ if (value === undefined || value === null) return "";
71
+ if (Array.isArray(value)) return value.map((entry) => renderAnswer(entry)).join(", ");
72
+ if (typeof value === "object") return JSON.stringify(value);
73
+ return String(value);
74
+ }
75
+
76
+ function Cell(props: ResponseCellSlotProps): ReactElement {
77
+ const Slot = resolveFormsSkinComponent<ResponseCellSlotProps>("responses.cell");
78
+ if (Slot) return <Slot {...props} />;
79
+ return <span>{renderAnswer(props.value)}</span>;
80
+ }
81
+
82
+ function Toolbar(props: ResponsesToolbarSlotProps): ReactElement {
83
+ const t = useT();
84
+ const { bag } = props;
85
+ const nextGate = useActionGate(bag.nextPage);
86
+ const prevGate = useActionGate(bag.prevPage);
87
+ const Slot = resolveFormsSkinComponent<ResponsesToolbarSlotProps>(
88
+ "responses.toolbar"
89
+ );
90
+ if (Slot) return <Slot {...props} />;
91
+
92
+ const versionOptions = matchLoad(bag.versions, {
93
+ loading: () => [],
94
+ failed: () => [],
95
+ ready: (versions) =>
96
+ versions.map((v) => ({
97
+ value: v.version,
98
+ label: `${t(FORMS_I18N_KEYS.responsesVersion)} ${v.version}`,
99
+ })),
100
+ });
101
+
102
+ return (
103
+ <Flex gap={8} wrap align="center">
104
+ <Select<number | null>
105
+ style={{ width: 180 }}
106
+ value={bag.version}
107
+ onChange={(next) => bag.setVersion(next)}
108
+ data-testid="forms-responses-version"
109
+ options={[
110
+ { value: null, label: t(FORMS_I18N_KEYS.responsesAllVersions) },
111
+ ...versionOptions,
112
+ ]}
113
+ />
114
+ <Button
115
+ disabled={prevGate.disabled}
116
+ data-analytics="none"
117
+ data-analytics-reason="keyset paging of a read; no flow to step"
118
+ onClick={bag.goPrevPage}
119
+ >
120
+ {t(FORMS_I18N_KEYS.responsesPrev)}
121
+ </Button>
122
+ <Button
123
+ disabled={nextGate.disabled}
124
+ data-analytics="none"
125
+ data-analytics-reason="keyset paging of a read; no flow to step"
126
+ data-testid="forms-responses-next"
127
+ onClick={bag.goNextPage}
128
+ >
129
+ {t(FORMS_I18N_KEYS.responsesNext)}
130
+ </Button>
131
+ <Button
132
+ loading={bag.isExporting}
133
+ data-analytics="flow"
134
+ data-testid="forms-responses-export"
135
+ onClick={() => {
136
+ void bag.exportCsv();
137
+ }}
138
+ >
139
+ {bag.isExporting
140
+ ? t(FORMS_I18N_KEYS.responsesExporting, { pages: bag.exportPages })
141
+ : t(FORMS_I18N_KEYS.responsesExport)}
142
+ </Button>
143
+ {nextGate.reason !== undefined && (
144
+ <Typography.Text type="secondary">{nextGate.reason}</Typography.Text>
145
+ )}
146
+ </Flex>
147
+ );
148
+ }
149
+
150
+ function DetailDrawer(props: { bag: ResponsesTableBag }): ReactElement {
151
+ const t = useT();
152
+ const { bag } = props;
153
+ const [override, setOverride] = useState("");
154
+ const row = bag.selected;
155
+
156
+ return (
157
+ <Drawer
158
+ open={row !== null}
159
+ onClose={() => bag.select(null)}
160
+ title={t(FORMS_I18N_KEYS.responsesDetail)}
161
+ width={480}
162
+ data-testid="forms-responses-drawer"
163
+ >
164
+ {row !== null && (
165
+ <Flex vertical gap={12}>
166
+ <Typography.Text type="secondary">
167
+ {t(FORMS_I18N_KEYS.responsesVersion)} {row.version} ·{" "}
168
+ {row.submitted_at}
169
+ </Typography.Text>
170
+ {row.erased_at != null && <Tag>{t(FORMS_I18N_KEYS.responsesErased)}</Tag>}
171
+ {Object.entries(row.answers).map(([slug, value]) => (
172
+ <div key={slug}>
173
+ <Typography.Text strong>{slug}</Typography.Text>
174
+ <br />
175
+ <Typography.Text>{renderAnswer(value)}</Typography.Text>
176
+ </div>
177
+ ))}
178
+
179
+ <Typography.Text type="secondary">
180
+ {t(FORMS_I18N_KEYS.responsesResendOverrideHint)}
181
+ </Typography.Text>
182
+ <Input
183
+ placeholder={t(FORMS_I18N_KEYS.responsesResendOverride)}
184
+ value={override}
185
+ onChange={(event) => setOverride(event.target.value)}
186
+ data-testid="forms-resend-override"
187
+ />
188
+ <Space>
189
+ <Button
190
+ loading={bag.isResending}
191
+ data-analytics="flow"
192
+ data-testid="forms-resend"
193
+ onClick={() => {
194
+ const recipients = override
195
+ .split(",")
196
+ .map((entry) => entry.trim())
197
+ .filter((entry) => entry.length > 0);
198
+ // Only send an override when the operator typed one: an empty
199
+ // list would REPLACE the form's targets with nothing.
200
+ bag.resend(
201
+ row.id,
202
+ recipients.length > 0 ? { recipients } : undefined
203
+ );
204
+ }}
205
+ >
206
+ {t(FORMS_I18N_KEYS.responsesResend)}
207
+ </Button>
208
+ <Popconfirm
209
+ title={t(FORMS_I18N_KEYS.responsesDeleteConfirm)}
210
+ onConfirm={() => bag.remove(row.id)}
211
+ >
212
+ <Button danger loading={bag.isRemoving} data-analytics="flow">
213
+ {t(FORMS_I18N_KEYS.responsesDelete)}
214
+ </Button>
215
+ </Popconfirm>
216
+ </Space>
217
+ {bag.lastResendCount !== null && (
218
+ <Typography.Text type="success" data-testid="forms-resend-sent">
219
+ {t(FORMS_I18N_KEYS.responsesResendSent, {
220
+ count: bag.lastResendCount,
221
+ })}
222
+ </Typography.Text>
223
+ )}
224
+ </Flex>
225
+ )}
226
+ </Drawer>
227
+ );
228
+ }
229
+
230
+ export interface ResponsesPaneProps extends ThemeModeProp {
231
+ readonly workspaceId: string;
232
+ readonly formId: string;
233
+ readonly limit?: number;
234
+ }
235
+
236
+ export function ResponsesPane(props: ResponsesPaneProps): ReactElement {
237
+ const t = useT();
238
+ const describe = useDescribeFlowError();
239
+
240
+ return (
241
+ <FormsSkinTheme {...(props.mode !== undefined ? { mode: props.mode } : {})}>
242
+ <ResponsesTable
243
+ workspaceId={props.workspaceId}
244
+ formId={props.formId}
245
+ {...(props.limit !== undefined ? { limit: props.limit } : {})}
246
+ >
247
+ {(bag) => (
248
+ <Flex vertical gap={16}>
249
+ <Typography.Title level={4}>
250
+ {t(FORMS_I18N_KEYS.responsesTitle)}
251
+ </Typography.Title>
252
+ <Toolbar bag={bag} />
253
+ {bag.error !== null && (
254
+ <ErrorAlert
255
+ testId="forms-responses-error"
256
+ error={describe(toFlowError(bag.error))}
257
+ />
258
+ )}
259
+ {matchLoad(bag.state, {
260
+ loading: () => (
261
+ <Flex justify="center" style={{ padding: 24 }}>
262
+ <Spin data-testid="forms-responses-loading" />
263
+ </Flex>
264
+ ),
265
+ // "We could not load the responses" — never an empty grid,
266
+ // which would read as "nobody answered".
267
+ failed: (error) => (
268
+ <ErrorAlert
269
+ testId="forms-responses-failed"
270
+ error={{
271
+ ...describe(toFlowError(error)),
272
+ message: t(FORMS_I18N_KEYS.responsesLoadFailed),
273
+ }}
274
+ action={
275
+ <Button
276
+ size="small"
277
+ onClick={bag.refetch}
278
+ data-analytics="none"
279
+ data-analytics-reason="retry of a failed read; no flow to step"
280
+ >
281
+ {t(FORMS_I18N_KEYS.fillRetry)}
282
+ </Button>
283
+ }
284
+ />
285
+ ),
286
+ ready: (view) =>
287
+ view.rows.length === 0 ? (
288
+ <Empty
289
+ data-testid="forms-responses-empty"
290
+ description={t(FORMS_I18N_KEYS.responsesEmpty)}
291
+ />
292
+ ) : (
293
+ <Table<Submission>
294
+ size="small"
295
+ rowKey="id"
296
+ data-testid="forms-responses-table"
297
+ dataSource={[...view.rows]}
298
+ pagination={false}
299
+ onRow={(row) => ({ onClick: () => bag.select(row) })}
300
+ columns={[
301
+ {
302
+ title: t(FORMS_I18N_KEYS.responsesSubmittedAt),
303
+ dataIndex: "submitted_at",
304
+ key: "submitted_at",
305
+ },
306
+ {
307
+ title: t(FORMS_I18N_KEYS.responsesVersion),
308
+ dataIndex: "version",
309
+ key: "version",
310
+ },
311
+ ...view.columns.map((column) => ({
312
+ title: column.title,
313
+ key: column.slug,
314
+ render: (_: unknown, row: Submission) => (
315
+ <Cell
316
+ column={column}
317
+ row={row}
318
+ value={row.answers[column.slug]}
319
+ />
320
+ ),
321
+ })),
322
+ ]}
323
+ />
324
+ ),
325
+ })}
326
+ <DetailDrawer bag={bag} />
327
+ </Flex>
328
+ )}
329
+ </ResponsesTable>
330
+ </FormsSkinTheme>
331
+ );
332
+ }
@@ -0,0 +1,377 @@
1
+ /**
2
+ * `<StapelForm>` — the whole answer to the owner's ask: *"the host page says
3
+ * 'put form &lt;id&gt; here', the library fetches the schema and renders it,
4
+ * default skin or override styles."*
5
+ *
6
+ * ```tsx
7
+ * const runtime = createFormsRuntime({ baseUrl: "/forms/api/v1/" });
8
+ * <FormsProvider runtime={runtime}>
9
+ * <StapelForm publicId="k3J…x9" />
10
+ * </FormsProvider>
11
+ * ```
12
+ *
13
+ * Nothing else is required — no session, no workspace id, no auth client. The
14
+ * component is a renderer over `<FormFill>`'s bag and holds no state of its
15
+ * own, so everything it does is reachable headlessly too.
16
+ *
17
+ * ── LoadState is the law here, visibly ─────────────────────────────────────
18
+ *
19
+ * The failed arm distinguishes THREE things a lesser form collapses into one
20
+ * blank page: "this link is not valid" (404), "this form is closed" (410),
21
+ * and "we could not ask" (network / 5xx). Only the first two are statements
22
+ * about the form; the third is a statement about us, and it offers a retry
23
+ * instead of blaming the person's link. There is no code path in this file
24
+ * that renders an empty form because a fetch failed — `matchLoad`'s three
25
+ * required arms make writing one a compile error.
26
+ */
27
+ import type { ReactElement, ReactNode } from "react";
28
+ import { Alert, Button, Flex, Form, Spin, Typography } from "antd";
29
+ import {
30
+ hasErrorCode,
31
+ matchLoad,
32
+ toFlowError,
33
+ useActionGate,
34
+ useDescribeFlowError,
35
+ useFormatFlowError,
36
+ useT,
37
+ } from "@stapel/core";
38
+ import type { FlowError, ThemeModeProp } from "./types.js";
39
+ import { FormFill } from "../headless/FormFill.js";
40
+ import type { FormFillBag } from "../headless/FormFill.js";
41
+ import type { FormFieldDef } from "../api/types.js";
42
+ import { resolveFormFieldWidget } from "../widgets/registry.js";
43
+ import { BUILTIN_FIELD_KINDS, BUILTIN_FIELD_WIDGETS } from "./fields.js";
44
+ import { FormsSkinTheme } from "./theme.js";
45
+ import { ErrorAlert } from "./ErrorAlert.js";
46
+ import { resolveFormsSkinComponent } from "./slots.js";
47
+ import { FORMS_I18N_KEYS } from "../i18n/keys.js";
48
+
49
+ /** Stable per-field DOM id, so `<label for>` reaches the control. Slugs are
50
+ * `[a-z0-9_]`-shaped by the engine, so this needs no further escaping. */
51
+ function fieldControlId(slug: string): string {
52
+ return `forms-field-${slug}`;
53
+ }
54
+
55
+ const CODE_NOT_FOUND = "error.404.forms_not_found";
56
+ const CODE_CLOSED = "error.410.forms_closed";
57
+
58
+ /** Props of the `"fill.fieldRow"` slot. */
59
+ export interface FieldRowSlotProps {
60
+ readonly field: FormFieldDef;
61
+ /** DOM id of the control this row labels. */
62
+ readonly controlId: string;
63
+ readonly control: ReactNode;
64
+ readonly error: FlowError | undefined;
65
+ readonly required: boolean;
66
+ }
67
+
68
+ /** Props of the `"fill.submitBar"` slot. */
69
+ export interface SubmitBarSlotProps {
70
+ readonly bag: FormFillBag;
71
+ readonly label: string;
72
+ readonly disabled: boolean;
73
+ readonly reason: string | undefined;
74
+ readonly detail: string | undefined;
75
+ onSubmit(): void;
76
+ }
77
+
78
+ /** Props of the `"fill.confirmation"` slot. */
79
+ export interface ConfirmationSlotProps {
80
+ readonly confirmation: string;
81
+ }
82
+
83
+ /** Props of the `"fill.unsupportedField"` slot. */
84
+ export interface UnsupportedFieldSlotProps {
85
+ readonly field: FormFieldDef;
86
+ }
87
+
88
+ function UnsupportedField(props: UnsupportedFieldSlotProps): ReactElement {
89
+ const t = useT();
90
+ const Slot = resolveFormsSkinComponent<UnsupportedFieldSlotProps>(
91
+ "fill.unsupportedField"
92
+ );
93
+ if (Slot) return <Slot {...props} />;
94
+ return (
95
+ <Alert
96
+ type="warning"
97
+ showIcon
98
+ data-testid="forms-unsupported-field"
99
+ message={props.field.name ?? props.field.slug}
100
+ description={t(FORMS_I18N_KEYS.fillUnsupportedField, {
101
+ kind: props.field.kind,
102
+ })}
103
+ />
104
+ );
105
+ }
106
+
107
+ /**
108
+ * `Form.Item` error props for a flow error, spread so no `undefined` is
109
+ * passed under `exactOptionalPropertyTypes` — the fleet's `useFieldError`
110
+ * convention (auth-react `default/panels.tsx`), and the reason a server
111
+ * `error.400.feature_*` lands ON the control that caused it: `<FormFill>`
112
+ * keys `fieldErrors` by the refusal's own `params.field`.
113
+ */
114
+ function useFieldError(): (
115
+ e: FlowError | undefined
116
+ ) => { validateStatus: "error"; help: string } | Record<string, never> {
117
+ const format = useFormatFlowError();
118
+ return (e) => (e ? { validateStatus: "error", help: format(e) } : {});
119
+ }
120
+
121
+ function FieldRow(props: FieldRowSlotProps): ReactElement {
122
+ const fieldError = useFieldError();
123
+ const Slot = resolveFormsSkinComponent<FieldRowSlotProps>("fill.fieldRow");
124
+ if (Slot) return <Slot {...props} />;
125
+ // A header is a caption: it gets no label, no colon, no required marker —
126
+ // rendering one inside a labelled Form.Item would make a section heading
127
+ // look like a question.
128
+ if (props.field.kind === "header") {
129
+ return <Form.Item style={{ marginBottom: 8 }}>{props.control}</Form.Item>;
130
+ }
131
+ return (
132
+ <Form.Item
133
+ label={props.field.name ?? props.field.slug}
134
+ htmlFor={props.controlId}
135
+ required={props.required}
136
+ {...fieldError(props.error)}
137
+ >
138
+ {props.control}
139
+ </Form.Item>
140
+ );
141
+ }
142
+
143
+ function FieldControl(props: {
144
+ field: FormFieldDef;
145
+ bag: FormFillBag;
146
+ controlId: string;
147
+ }): ReactElement {
148
+ const { field, bag } = props;
149
+ // The ladder: a host's explicit registration outranks the skin's builtin.
150
+ const Widget =
151
+ resolveFormFieldWidget(field.kind) ?? BUILTIN_FIELD_WIDGETS[field.kind];
152
+ if (Widget === undefined) return <UnsupportedField field={field} />;
153
+ return (
154
+ <Widget
155
+ id={props.controlId}
156
+ field={field}
157
+ value={bag.values[field.slug]}
158
+ onChange={(value) => bag.setValue(field.slug, value)}
159
+ error={bag.fieldErrors[field.slug]}
160
+ disabled={bag.isSubmitting}
161
+ />
162
+ );
163
+ }
164
+
165
+ function Confirmation(props: ConfirmationSlotProps): ReactElement {
166
+ const t = useT();
167
+ const Slot = resolveFormsSkinComponent<ConfirmationSlotProps>(
168
+ "fill.confirmation"
169
+ );
170
+ if (Slot) return <Slot {...props} />;
171
+ return (
172
+ <Alert
173
+ type="success"
174
+ showIcon
175
+ data-testid="forms-confirmation"
176
+ message={
177
+ props.confirmation.length > 0
178
+ ? props.confirmation
179
+ : t(FORMS_I18N_KEYS.fillThanks)
180
+ }
181
+ />
182
+ );
183
+ }
184
+
185
+ function SubmitBar(props: { bag: FormFillBag; label: string }): ReactElement {
186
+ const gate = useActionGate(props.bag.submit);
187
+ const Slot = resolveFormsSkinComponent<SubmitBarSlotProps>("fill.submitBar");
188
+ const slotProps: SubmitBarSlotProps = {
189
+ bag: props.bag,
190
+ label: props.label,
191
+ disabled: gate.disabled,
192
+ reason: gate.reason,
193
+ detail: gate.detail,
194
+ onSubmit: props.bag.doSubmit,
195
+ };
196
+ if (Slot) return <Slot {...slotProps} />;
197
+ return (
198
+ <Flex vertical gap={4} align="flex-start">
199
+ <Button
200
+ type="primary"
201
+ htmlType="submit"
202
+ loading={props.bag.isSubmitting}
203
+ disabled={gate.disabled}
204
+ data-analytics="flow"
205
+ data-testid="forms-submit"
206
+ onClick={props.bag.doSubmit}
207
+ >
208
+ {props.label}
209
+ </Button>
210
+ {/* The reason is TEXT, not a tooltip: a disabled button receives no
211
+ pointer events in any browser, so a tooltip on it is a reason
212
+ nobody can read (core's actionGate module header). */}
213
+ {gate.reason !== undefined && (
214
+ <Typography.Text type="secondary" data-testid="forms-submit-blocked">
215
+ {gate.reason}
216
+ </Typography.Text>
217
+ )}
218
+ {gate.detail !== undefined && (
219
+ <Typography.Text type="secondary" style={{ fontSize: 12 }}>
220
+ {gate.detail}
221
+ </Typography.Text>
222
+ )}
223
+ </Flex>
224
+ );
225
+ }
226
+
227
+ export interface StapelFormProps extends ThemeModeProp {
228
+ /** The non-enumerable public token the host embeds. */
229
+ readonly publicId: string;
230
+ /** Show the schema's `meta.title` above the fields. Default true. */
231
+ readonly showTitle?: boolean;
232
+ /** Override the submit button's text (otherwise `meta.submit_label`, then
233
+ * the pair's own copy). */
234
+ readonly submitLabel?: string;
235
+ /**
236
+ * Rendered above the submit bar — the interactive-captcha seam. The netintel
237
+ * tier decides whether a token is needed at all, so this stays optional and
238
+ * the widget calls `setCaptchaToken` when it has one.
239
+ */
240
+ readonly captcha?: (bag: FormFillBag) => ReactNode;
241
+ readonly onSubmitted?: (result: { readonly confirmation: string }) => void;
242
+ }
243
+
244
+ export function StapelForm(props: StapelFormProps): ReactElement {
245
+ const t = useT();
246
+ const describe = useDescribeFlowError();
247
+
248
+ return (
249
+ <FormsSkinTheme {...(props.mode !== undefined ? { mode: props.mode } : {})}>
250
+ <FormFill
251
+ publicId={props.publicId}
252
+ builtinKinds={BUILTIN_FIELD_KINDS}
253
+ {...(props.onSubmitted ? { onSubmitted: props.onSubmitted } : {})}
254
+ >
255
+ {(bag) => {
256
+ if (bag.submitted !== null) {
257
+ return <Confirmation confirmation={bag.submitted.confirmation} />;
258
+ }
259
+ return matchLoad(bag.state, {
260
+ loading: () => (
261
+ <Flex justify="center" style={{ padding: 24 }}>
262
+ <Spin data-testid="forms-loading" aria-label={t(FORMS_I18N_KEYS.fillLoading)} />
263
+ </Flex>
264
+ ),
265
+ // The three-way split. `hasErrorCode` reads the API dialect, so
266
+ // this works whether the throw was a StapelApiError or a bare
267
+ // envelope — and a network fault, which carries NEITHER code,
268
+ // correctly falls through to "we could not ask".
269
+ failed: (error) => {
270
+ if (hasErrorCode(error, CODE_NOT_FOUND)) {
271
+ return (
272
+ <Alert
273
+ type="error"
274
+ showIcon
275
+ data-testid="forms-not-found"
276
+ message={t(FORMS_I18N_KEYS.fillNotFound)}
277
+ />
278
+ );
279
+ }
280
+ if (hasErrorCode(error, CODE_CLOSED)) {
281
+ return (
282
+ <Alert
283
+ type="info"
284
+ showIcon
285
+ data-testid="forms-closed"
286
+ message={t(FORMS_I18N_KEYS.fillClosed)}
287
+ />
288
+ );
289
+ }
290
+ return (
291
+ <ErrorAlert
292
+ testId="forms-load-failed"
293
+ error={{
294
+ ...describe(toFlowError(error)),
295
+ message: t(FORMS_I18N_KEYS.fillLoadFailed),
296
+ }}
297
+ action={
298
+ <Button size="small" onClick={bag.refetch} data-analytics="none"
299
+ data-analytics-reason="retry of a failed read; no flow to step">
300
+ {t(FORMS_I18N_KEYS.fillRetry)}
301
+ </Button>
302
+ }
303
+ />
304
+ );
305
+ },
306
+ ready: (form) => {
307
+ const label =
308
+ props.submitLabel ??
309
+ form.meta.submit_label ??
310
+ t(FORMS_I18N_KEYS.fillSubmit);
311
+ return (
312
+ <Form
313
+ layout="vertical"
314
+ data-testid="forms-form"
315
+ onFinish={bag.doSubmit}
316
+ >
317
+ {props.showTitle !== false &&
318
+ form.meta.title !== undefined && (
319
+ <Typography.Title level={3}>
320
+ {form.meta.title}
321
+ </Typography.Title>
322
+ )}
323
+ {form.meta.description !== undefined && (
324
+ <Typography.Paragraph type="secondary">
325
+ {form.meta.description}
326
+ </Typography.Paragraph>
327
+ )}
328
+
329
+ {/* A schema that changed under the person is announced, not
330
+ swapped in silently — they must re-read before resubmitting. */}
331
+ {bag.superseded && (
332
+ <Alert
333
+ type="warning"
334
+ showIcon
335
+ style={{ marginBottom: 16 }}
336
+ data-testid="forms-superseded"
337
+ message={t(FORMS_I18N_KEYS.fillSuperseded)}
338
+ />
339
+ )}
340
+
341
+ {form.fields.map((field) => (
342
+ <FieldRow
343
+ key={field.slug}
344
+ field={field}
345
+ controlId={fieldControlId(field.slug)}
346
+ required={field.mandatory === true}
347
+ error={bag.fieldErrors[field.slug]}
348
+ control={
349
+ <FieldControl
350
+ field={field}
351
+ bag={bag}
352
+ controlId={fieldControlId(field.slug)}
353
+ />
354
+ }
355
+ />
356
+ ))}
357
+
358
+ {bag.formError !== null && (
359
+ <ErrorAlert
360
+ testId="forms-form-error"
361
+ style={{ marginBottom: 16 }}
362
+ error={describe(toFlowError(bag.formError))}
363
+ />
364
+ )}
365
+
366
+ {props.captcha?.(bag)}
367
+
368
+ <SubmitBar bag={bag} label={label} />
369
+ </Form>
370
+ );
371
+ },
372
+ });
373
+ }}
374
+ </FormFill>
375
+ </FormsSkinTheme>
376
+ );
377
+ }