@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,396 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import type { ReactNode } from "react";
3
+ import {
4
+ actionAvailable,
5
+ actionBlocked,
6
+ hasErrorCode,
7
+ isStapelApiError,
8
+ loadStateFromQuery,
9
+ requireLoaded,
10
+ } from "@stapel/core";
11
+ import type {
12
+ ActionAvailability,
13
+ FlowError,
14
+ LoadState,
15
+ StapelApiError,
16
+ } from "@stapel/core";
17
+ import type { FormFieldDef, PublicForm } from "../api/types.js";
18
+ import { usePublicForm } from "../model/queries.js";
19
+ import { useSubmitForm } from "../model/mutations.js";
20
+ import { resolveFormFieldWidget } from "../widgets/registry.js";
21
+ import { isBlank, validateAnswers } from "../widgets/validate.js";
22
+ import { FORMS_I18N_KEYS } from "../i18n/keys.js";
23
+
24
+ /** `error.409.forms_version_superseded` — the schema moved while the person
25
+ * was filling it in. */
26
+ const CODE_SUPERSEDED = "error.409.forms_version_superseded";
27
+
28
+ /** The bag `<FormFill>` hands its render prop (spec §7.2). */
29
+ export interface FormFillBag {
30
+ /**
31
+ * The schema read as a state a skin cannot flatten. A failed fetch renders
32
+ * the `failed` arm — NEVER an empty or missing form. Distinguish the
33
+ * verdicts from the outage with
34
+ * `hasErrorCode(error, "error.404.forms_not_found")` /
35
+ * `"error.410.forms_closed"`; anything else (network, 5xx) is "we could not
36
+ * ask", which is a different sentence from "there is no form here".
37
+ */
38
+ readonly state: LoadState<PublicForm>;
39
+ /** Current answers, keyed by slug. */
40
+ readonly values: Readonly<Record<string, unknown>>;
41
+ setValue(slug: string, value: unknown): void;
42
+ /**
43
+ * Per-field refusals, keyed by slug — the client mirror and the server's
44
+ * verdicts in one map. A server `error.400.feature_*` is routed here by its
45
+ * `params.field`; a multi-field refusal arrives in `params.fields[]` and
46
+ * every entry lands.
47
+ */
48
+ readonly fieldErrors: Readonly<Record<string, FlowError>>;
49
+ /** Why the submit button is off, when it is. */
50
+ readonly submit: ActionAvailability;
51
+ doSubmit(): void;
52
+ readonly isSubmitting: boolean;
53
+ /** Set once the server accepted the answers. */
54
+ readonly submitted: { readonly confirmation: string } | null;
55
+ /** Whole-form failures (410 closed, 429 throttled, 413 too large, the
56
+ * submission cap). A `StapelApiError` rather than a `FlowError` so a skin
57
+ * can branch with `hasErrorCode` (which reads the API dialects, not the
58
+ * render dialect). */
59
+ readonly formError: StapelApiError | null;
60
+ /**
61
+ * True after a `409 forms_version_superseded`: the schema was refetched and
62
+ * compatible answers were preserved. The skin must say so — "the form
63
+ * changed, please review and resubmit" — because silently swapping the
64
+ * fields under a person and asking them to press submit again is how a
65
+ * wrong answer gets recorded.
66
+ */
67
+ readonly superseded: boolean;
68
+ /**
69
+ * Field kinds in this schema that nothing can render (no host registration,
70
+ * no builtin widget). Non-empty means {@link submit} is blocked: silently
71
+ * skipping a possibly-REQUIRED field would fabricate an invalid submission
72
+ * and let the server refuse a field the person never saw.
73
+ */
74
+ readonly unsupportedKinds: readonly string[];
75
+ /**
76
+ * The captcha seam (spec §12 risk 3). The netintel tier decides whether a
77
+ * token is required at all, so this stays optional and the captcha layer
78
+ * refuses on its own terms; the pair only transports it. Wire it the way
79
+ * auth-react's OTP flow does — the widget hands a token, the bag puts it in
80
+ * the submit body.
81
+ */
82
+ setCaptchaToken(token: string | null): void;
83
+ readonly captchaToken: string | null;
84
+ refetch(): void;
85
+ }
86
+
87
+ /** The map without one key, or the SAME object when the key was not there —
88
+ * identity matters: returning a fresh object on every keystroke would
89
+ * re-render every field that reads `fieldErrors`. */
90
+ function withoutKey(
91
+ map: Readonly<Record<string, FlowError>>,
92
+ slug: string
93
+ ): Record<string, FlowError> {
94
+ if (map[slug] === undefined) return map as Record<string, FlowError>;
95
+ return Object.fromEntries(
96
+ Object.entries(map).filter(([key]) => key !== slug)
97
+ );
98
+ }
99
+
100
+ /** True when the widget layer can draw this field at all. */
101
+ function isRenderable(field: FormFieldDef, builtinKinds: ReadonlySet<string>): boolean {
102
+ return (
103
+ resolveFormFieldWidget(field.kind) !== null || builtinKinds.has(field.kind)
104
+ );
105
+ }
106
+
107
+ /**
108
+ * Read the per-field entries out of a server refusal.
109
+ *
110
+ * The wire shape (stapel-forms `views._maps_forms_errors`) puts the FIRST
111
+ * field error's code at the top level and the whole set under
112
+ * `params.fields[]` as `{field, code, params}`. Both are read: the array when
113
+ * present, otherwise the top-level `params.field`, so a single-field refusal
114
+ * still lands on its control.
115
+ */
116
+ function serverFieldErrors(error: StapelApiError): Record<string, FlowError> {
117
+ const out: Record<string, FlowError> = {};
118
+ const rows = error.params["fields"];
119
+ if (Array.isArray(rows)) {
120
+ for (const row of rows) {
121
+ if (row === null || typeof row !== "object") continue;
122
+ const entry: Record<string, unknown> = { ...row };
123
+ const field = entry["field"];
124
+ const code = entry["code"];
125
+ if (typeof field !== "string" || typeof code !== "string") continue;
126
+ const params = entry["params"];
127
+ out[field] = {
128
+ code,
129
+ params:
130
+ params !== null && typeof params === "object"
131
+ ? { ...(params as object) }
132
+ : {},
133
+ status: error.status,
134
+ message: undefined,
135
+ language: undefined,
136
+ };
137
+ }
138
+ }
139
+ const single = error.params["field"];
140
+ if (Object.keys(out).length === 0 && typeof single === "string") {
141
+ out[single] = {
142
+ code: error.code,
143
+ params: error.params,
144
+ status: error.status,
145
+ message: error.message,
146
+ language: error.language,
147
+ };
148
+ }
149
+ return out;
150
+ }
151
+
152
+ /** Answers worth sending: blanks are omitted (an unanswered optional field is
153
+ * not an empty answer), and `header` fields are never sent — the engine
154
+ * regenerates their DAO from config and stapel-forms rejects an answer to one
155
+ * outright (backend delta note 1). */
156
+ function answersToSend(
157
+ fields: readonly FormFieldDef[],
158
+ values: Readonly<Record<string, unknown>>
159
+ ): Record<string, unknown> {
160
+ const out: Record<string, unknown> = {};
161
+ for (const field of fields) {
162
+ if (field.kind === "header") continue;
163
+ const value = values[field.slug];
164
+ if (isBlank(value)) continue;
165
+ out[field.slug] = value;
166
+ }
167
+ return out;
168
+ }
169
+
170
+ /** Seed the answers a schema declares a value default for. Only `date`
171
+ * declares one upstream (`config.default`); seeding it matches what the
172
+ * engine would have applied, so the person sees the same value the server
173
+ * would have stored. */
174
+ function seedValues(fields: readonly FormFieldDef[]): Record<string, unknown> {
175
+ const seeded: Record<string, unknown> = {};
176
+ for (const field of fields) {
177
+ const declared = (field.config ?? {})["default"];
178
+ if (field.kind === "date" && declared !== undefined && declared !== null) {
179
+ seeded[field.slug] = declared;
180
+ }
181
+ }
182
+ return seeded;
183
+ }
184
+
185
+ /**
186
+ * Headless form fill — the anonymous respondent's whole surface, renderless.
187
+ *
188
+ * ```tsx
189
+ * <FormFill publicId="k3J…x9">
190
+ * {(bag) => matchLoad(bag.state, { loading, failed, ready })}
191
+ * </FormFill>
192
+ * ```
193
+ *
194
+ * The `/default` skin's `<StapelForm>` is one renderer over this bag; a host
195
+ * that wants its own visuals writes another and loses nothing.
196
+ */
197
+ export function FormFill(props: {
198
+ publicId: string;
199
+ /** Kinds the CALLER can draw, beyond anything in the widget registry. The
200
+ * `/default` skin passes its antd builtins here, which is what lets this
201
+ * headless component judge `unsupportedKinds` without importing the skin. */
202
+ builtinKinds?: readonly string[];
203
+ /** Called once the server accepts the answers. */
204
+ onSubmitted?: (result: { readonly confirmation: string }) => void;
205
+ children: (bag: FormFillBag) => ReactNode;
206
+ }): ReactNode {
207
+ const query = usePublicForm(props.publicId);
208
+ const submitMutation = useSubmitForm();
209
+ const state = loadStateFromQuery(query);
210
+
211
+ const [values, setValues] = useState<Record<string, unknown>>({});
212
+ const [clientErrors, setClientErrors] = useState<Record<string, FlowError>>({});
213
+ const [serverErrors, setServerErrors] = useState<Record<string, FlowError>>({});
214
+ const [formError, setFormError] = useState<StapelApiError | null>(null);
215
+ const [submitted, setSubmitted] = useState<{ confirmation: string } | null>(
216
+ null
217
+ );
218
+ const [superseded, setSuperseded] = useState(false);
219
+ const [captchaToken, setCaptchaTokenState] = useState<string | null>(null);
220
+
221
+ const form = state.status === "ready" ? state.data : undefined;
222
+ const fields = useMemo(() => form?.fields ?? [], [form]);
223
+
224
+ const builtinKinds = useMemo(
225
+ () => new Set(props.builtinKinds ?? []),
226
+ [props.builtinKinds]
227
+ );
228
+
229
+ // Seed declared defaults once per version. Keyed on `version_id` so a
230
+ // supersede re-seeds against the NEW schema rather than leaving a default
231
+ // the new version no longer declares.
232
+ const seededVersion = useRef<string | null>(null);
233
+ useEffect(() => {
234
+ if (form === undefined) return;
235
+ if (seededVersion.current === form.version_id) return;
236
+ const previous = seededVersion.current;
237
+ seededVersion.current = form.version_id;
238
+ if (previous === null) {
239
+ const seeded = seedValues(form.fields);
240
+ if (Object.keys(seeded).length > 0) {
241
+ setValues((current) => ({ ...seeded, ...current }));
242
+ }
243
+ return;
244
+ }
245
+ // A NEW version arrived under a filled-in form — the 409 refetch path.
246
+ // Keep an answer only where the slug still exists AND still has the same
247
+ // kind: a slug reused for a different kind is a different question, and
248
+ // carrying the old answer over would submit an answer to a question
249
+ // nobody was asked. Everything else is dropped, visibly, and `superseded`
250
+ // makes the skin say so.
251
+ const compatible = new Map(form.fields.map((f) => [f.slug, f.kind]));
252
+ setValues((current) => {
253
+ const kept: Record<string, unknown> = {};
254
+ for (const [slug, value] of Object.entries(current)) {
255
+ if (compatible.get(slug) !== undefined) kept[slug] = value;
256
+ }
257
+ return kept;
258
+ });
259
+ setClientErrors({});
260
+ setServerErrors({});
261
+ }, [form]);
262
+
263
+ const setValue = useCallback((slug: string, value: unknown): void => {
264
+ setValues((current) => ({ ...current, [slug]: value }));
265
+ // Clearing on edit is the point of a mirror: the person changed the
266
+ // thing that was wrong, so the old refusal is stale on both sides.
267
+ setClientErrors((current) => withoutKey(current, slug));
268
+ setServerErrors((current) => withoutKey(current, slug));
269
+ }, []);
270
+
271
+ const setCaptchaToken = useCallback((token: string | null): void => {
272
+ setCaptchaTokenState(token);
273
+ }, []);
274
+
275
+ const unsupportedKinds = useMemo(() => {
276
+ const kinds = new Set<string>();
277
+ for (const field of fields) {
278
+ if (!isRenderable(field, builtinKinds)) kinds.add(field.kind);
279
+ }
280
+ return [...kinds].sort();
281
+ }, [fields, builtinKinds]);
282
+
283
+ const fieldErrors = useMemo(
284
+ () => ({ ...clientErrors, ...serverErrors }),
285
+ [clientErrors, serverErrors]
286
+ );
287
+
288
+ const isSubmitting = submitMutation.isPending;
289
+
290
+ const submit: ActionAvailability = useMemo(() => {
291
+ if (submitted !== null) {
292
+ return actionBlocked(FORMS_I18N_KEYS.submitBlockedDone);
293
+ }
294
+ return requireLoaded(state, () => {
295
+ if (unsupportedKinds.length > 0) {
296
+ return actionBlocked(FORMS_I18N_KEYS.submitBlockedUnsupported, {
297
+ kinds: unsupportedKinds.join(", "),
298
+ });
299
+ }
300
+ if (isSubmitting) {
301
+ return actionBlocked(FORMS_I18N_KEYS.submitBlockedInFlight);
302
+ }
303
+ return actionAvailable();
304
+ });
305
+ }, [state, submitted, unsupportedKinds, isSubmitting]);
306
+
307
+ const refetch = useCallback((): void => {
308
+ void query.refetch();
309
+ }, [query]);
310
+
311
+ const doSubmit = useCallback((): void => {
312
+ if (form === undefined || submitted !== null || isSubmitting) return;
313
+
314
+ const mirrored = validateAnswers(fields, values);
315
+ setServerErrors({});
316
+ setFormError(null);
317
+ if (Object.keys(mirrored).length > 0) {
318
+ setClientErrors(mirrored);
319
+ return;
320
+ }
321
+ setClientErrors({});
322
+
323
+ submitMutation.mutate(
324
+ {
325
+ publicId: props.publicId,
326
+ body: {
327
+ answers: answersToSend(fields, values),
328
+ // Echoing the rendered version is what turns a racing publish into
329
+ // a clean 409 instead of a silent mis-validation against a schema
330
+ // the person never saw.
331
+ version_id: form.version_id,
332
+ ...(captchaToken !== null ? { captcha_token: captchaToken } : {}),
333
+ },
334
+ },
335
+ {
336
+ onSuccess: (result) => {
337
+ const confirmation =
338
+ result.confirmation ??
339
+ form.meta.confirmation_text ??
340
+ "";
341
+ setSubmitted({ confirmation });
342
+ setSuperseded(false);
343
+ props.onSubmitted?.({ confirmation });
344
+ },
345
+ onError: (caught: unknown) => {
346
+ if (!isStapelApiError(caught)) {
347
+ setFormError(null);
348
+ return;
349
+ }
350
+ if (hasErrorCode(caught, CODE_SUPERSEDED)) {
351
+ // Refetch and let the seeding effect preserve what still fits.
352
+ // The token is dropped: a captcha is spent, and replaying it
353
+ // against the next attempt would fail on the server's terms.
354
+ setSuperseded(true);
355
+ setCaptchaTokenState(null);
356
+ refetch();
357
+ return;
358
+ }
359
+ const perField = serverFieldErrors(caught);
360
+ if (Object.keys(perField).length > 0) {
361
+ setServerErrors(perField);
362
+ return;
363
+ }
364
+ setFormError(caught);
365
+ },
366
+ }
367
+ );
368
+ }, [
369
+ form,
370
+ fields,
371
+ values,
372
+ captchaToken,
373
+ isSubmitting,
374
+ submitted,
375
+ submitMutation,
376
+ props,
377
+ refetch,
378
+ ]);
379
+
380
+ return props.children({
381
+ state,
382
+ values,
383
+ setValue,
384
+ fieldErrors,
385
+ submit,
386
+ doSubmit,
387
+ isSubmitting,
388
+ submitted,
389
+ formError,
390
+ superseded,
391
+ unsupportedKinds,
392
+ setCaptchaToken,
393
+ captchaToken,
394
+ refetch,
395
+ });
396
+ }
@@ -0,0 +1,74 @@
1
+ import { useCallback, useState } from "react";
2
+ import type { ReactNode } from "react";
3
+ import { isStapelApiError, loadStateFromQuery } from "@stapel/core";
4
+ import type { LoadState, StapelApiError } from "@stapel/core";
5
+ import type { FormCreateRequest, FormRow, FormState } from "../api/types.js";
6
+ import { useForms } from "../model/queries.js";
7
+ import { useCreateForm } from "../model/mutations.js";
8
+
9
+ /** The bag `<FormList>` hands its render prop. */
10
+ export interface FormListBag {
11
+ /**
12
+ * The workspace's forms as a state a skin cannot flatten. Render with
13
+ * core's `matchList` — its four required arms are what keeps "no forms yet"
14
+ * a sentence that can only be said about a load that actually succeeded.
15
+ */
16
+ readonly state: LoadState<readonly FormRow[]>;
17
+ /** The active state filter, or `null` for all. */
18
+ readonly filter: FormState | null;
19
+ setFilter(state: FormState | null): void;
20
+ /** `forms.manage`. */
21
+ create(input: Omit<FormCreateRequest, "workspace_id">): void;
22
+ readonly isCreating: boolean;
23
+ /** The last create refusal — `error.400.forms_too_many_open` carries the
24
+ * workspace's cap in `params.limit`. */
25
+ readonly error: StapelApiError | null;
26
+ refetch(): void;
27
+ }
28
+
29
+ /**
30
+ * Headless list of a workspace's forms — the admin surface's entry point,
31
+ * renderless.
32
+ */
33
+ export function FormList(props: {
34
+ workspaceId: string;
35
+ /** Called with the new form once the server creates it, so a skin can
36
+ * navigate straight into the builder. */
37
+ onCreated?: (form: FormRow) => void;
38
+ children: (bag: FormListBag) => ReactNode;
39
+ }): ReactNode {
40
+ const [filter, setFilter] = useState<FormState | null>(null);
41
+ const [error, setError] = useState<StapelApiError | null>(null);
42
+ const query = useForms(props.workspaceId, filter ?? undefined);
43
+ const createMutation = useCreateForm();
44
+
45
+ const create = useCallback(
46
+ (input: Omit<FormCreateRequest, "workspace_id">): void => {
47
+ setError(null);
48
+ createMutation.mutate(
49
+ { ...input, workspace_id: props.workspaceId },
50
+ {
51
+ onError: (caught: unknown) => {
52
+ setError(isStapelApiError(caught) ? caught : null);
53
+ },
54
+ onSuccess: (form) => {
55
+ props.onCreated?.(form);
56
+ },
57
+ }
58
+ );
59
+ },
60
+ [createMutation, props]
61
+ );
62
+
63
+ return props.children({
64
+ state: loadStateFromQuery(query),
65
+ filter,
66
+ setFilter,
67
+ create,
68
+ isCreating: createMutation.isPending,
69
+ error,
70
+ refetch: () => {
71
+ void query.refetch();
72
+ },
73
+ });
74
+ }
@@ -0,0 +1,20 @@
1
+ import type { ReactElement, ReactNode } from "react";
2
+ import { ModuleProvider } from "../model/context.js";
3
+ import type { FormsRuntime } from "../model/runtime.js";
4
+
5
+ /**
6
+ * Provides the wired {@link FormsRuntime} to every forms hook and
7
+ * headless component below it. Bring your own visual shell — this component
8
+ * renders nothing of its own. (Core's `createModuleContext` provider, bound
9
+ * to this pair — slim wave §21/S2.)
10
+ *
11
+ * ```tsx
12
+ * const runtime = createFormsRuntime({ baseUrl: "/forms/api/v1/" });
13
+ * // give runtime.client to core's <StapelConfigProvider config={{ client }}>
14
+ * <FormsProvider runtime={runtime}>{app}</FormsProvider>
15
+ * ```
16
+ */
17
+ export const FormsProvider: (props: {
18
+ runtime: FormsRuntime;
19
+ children: ReactNode;
20
+ }) => ReactElement = ModuleProvider;