@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,387 @@
1
+ import { useCallback, useState } from "react";
2
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
3
+ import type {
4
+ UseMutationOptions,
5
+ UseMutationResult,
6
+ } from "@tanstack/react-query";
7
+ import { isStapelApiError } from "@stapel/core";
8
+ import type { StapelApiError } from "@stapel/core";
9
+ import { concatCsvPages } from "../api/export.js";
10
+ import type {
11
+ FormCreateRequest,
12
+ FormPatchRequest,
13
+ FormRow,
14
+ FormSchema,
15
+ FormState,
16
+ PublishResult,
17
+ ResendRequest,
18
+ ResendResult,
19
+ SubmitRequest,
20
+ SubmitResult,
21
+ } from "../api/types.js";
22
+ import { useFormsApi } from "./context.js";
23
+ import { formsQueryKeys } from "./queryKeys.js";
24
+
25
+ /**
26
+ * Write hooks (frontend-standard §2 — mutations invalidate on success).
27
+ *
28
+ * A forms write shifts several cached reads at once — a publish moves the
29
+ * form's `active_version`, adds a version row, AND changes what the public
30
+ * link serves; a rotate-link changes the very key the public read is cached
31
+ * under — so each admin mutation invalidates the module root
32
+ * (`formsQueryKeys.all`) rather than guessing which entries moved. Guessing
33
+ * is how `rotateLink` would leave a stale schema cached under an id that no
34
+ * longer resolves.
35
+ *
36
+ * Options are built as typed `UseMutationOptions` objects (not call-site
37
+ * generics) so `void`/error types stay in reference position.
38
+ */
39
+
40
+ function useInvalidateModule(): () => void {
41
+ const queryClient = useQueryClient();
42
+ return () => {
43
+ void queryClient.invalidateQueries({ queryKey: formsQueryKeys.all });
44
+ };
45
+ }
46
+
47
+ // ── the anonymous write ──────────────────────────────────────────────────────
48
+
49
+ /** Variables for {@link useSubmitForm}. */
50
+ export interface SubmitVariables {
51
+ readonly publicId: string;
52
+ readonly body: SubmitRequest;
53
+ }
54
+
55
+ /**
56
+ * `POST /public/<public_id>/submissions/` — the anonymous submit.
57
+ *
58
+ * Deliberately does NOT invalidate anything: an anonymous respondent holds no
59
+ * admin cache to refresh, and the one read they do hold (the schema) is
60
+ * unchanged by their own submission. The 409-superseded refetch is driven by
61
+ * `<FormFill>`, which knows to preserve the values first.
62
+ *
63
+ * `retry: false` — a resubmit is not idempotent. A retried POST that the
64
+ * server actually received is a duplicate response in somebody's spreadsheet.
65
+ */
66
+ export function useSubmitForm(): UseMutationResult<
67
+ SubmitResult,
68
+ StapelApiError,
69
+ SubmitVariables
70
+ > {
71
+ const api = useFormsApi();
72
+ const options: UseMutationOptions<
73
+ SubmitResult,
74
+ StapelApiError,
75
+ SubmitVariables
76
+ > = {
77
+ mutationFn: (vars) => api.submit(vars.publicId, vars.body),
78
+ retry: false,
79
+ };
80
+ return useMutation(options);
81
+ }
82
+
83
+ // ── admin: the form lifecycle ────────────────────────────────────────────────
84
+
85
+ /** `POST /forms` — create a form (it starts in `draft`). `forms.manage`. */
86
+ export function useCreateForm(): UseMutationResult<
87
+ FormRow,
88
+ StapelApiError,
89
+ FormCreateRequest
90
+ > {
91
+ const api = useFormsApi();
92
+ const invalidate = useInvalidateModule();
93
+ const options: UseMutationOptions<FormRow, StapelApiError, FormCreateRequest> =
94
+ {
95
+ mutationFn: (body) => api.createForm(body),
96
+ onSuccess: invalidate,
97
+ };
98
+ return useMutation(options);
99
+ }
100
+
101
+ /** Variables for {@link useUpdateForm}. */
102
+ export interface UpdateFormVariables {
103
+ readonly workspaceId: string;
104
+ readonly formId: string;
105
+ readonly patch: FormPatchRequest;
106
+ }
107
+
108
+ /** `PATCH /forms/<id>` — title and notification targets. `forms.manage`. */
109
+ export function useUpdateForm(): UseMutationResult<
110
+ FormRow,
111
+ StapelApiError,
112
+ UpdateFormVariables
113
+ > {
114
+ const api = useFormsApi();
115
+ const invalidate = useInvalidateModule();
116
+ const options: UseMutationOptions<
117
+ FormRow,
118
+ StapelApiError,
119
+ UpdateFormVariables
120
+ > = {
121
+ mutationFn: (vars) => api.patchForm(vars.workspaceId, vars.formId, vars.patch),
122
+ onSuccess: invalidate,
123
+ };
124
+ return useMutation(options);
125
+ }
126
+
127
+ /** Variables for the form-scoped mutations that take no body. */
128
+ export interface FormRef {
129
+ readonly workspaceId: string;
130
+ readonly formId: string;
131
+ }
132
+
133
+ /** `DELETE /forms/<id>` — soft-delete. `forms.manage`. */
134
+ export function useDeleteForm(): UseMutationResult<
135
+ void,
136
+ StapelApiError,
137
+ FormRef
138
+ > {
139
+ const api = useFormsApi();
140
+ const invalidate = useInvalidateModule();
141
+ const options: UseMutationOptions<void, StapelApiError, FormRef> = {
142
+ mutationFn: (vars) => api.deleteForm(vars.workspaceId, vars.formId),
143
+ onSuccess: invalidate,
144
+ };
145
+ return useMutation(options);
146
+ }
147
+
148
+ /** Variables for {@link useSaveDraft}. */
149
+ export interface SaveDraftVariables extends FormRef {
150
+ readonly schema: FormSchema;
151
+ }
152
+
153
+ /**
154
+ * `PUT /forms/<id>/draft` — replace the builder's scratchpad. `forms.manage`.
155
+ *
156
+ * A draft save is NOT a publish: what respondents see does not move until
157
+ * {@link usePublishForm} freezes the draft into the next immutable version.
158
+ * That separation is the whole reason a live form can be edited safely.
159
+ */
160
+ export function useSaveDraft(): UseMutationResult<
161
+ FormRow,
162
+ StapelApiError,
163
+ SaveDraftVariables
164
+ > {
165
+ const api = useFormsApi();
166
+ const invalidate = useInvalidateModule();
167
+ const options: UseMutationOptions<FormRow, StapelApiError, SaveDraftVariables> =
168
+ {
169
+ mutationFn: (vars) =>
170
+ api.putDraft(vars.workspaceId, vars.formId, vars.schema),
171
+ onSuccess: invalidate,
172
+ };
173
+ return useMutation(options);
174
+ }
175
+
176
+ /**
177
+ * `POST /forms/<id>/publish` — freeze the draft into the next version.
178
+ * `forms.manage`.
179
+ *
180
+ * Refuses a schema the engine will not accept, per-field where it can:
181
+ * `error.400.forms_empty_schema`, `..._duplicate_slug` (`params.slug`),
182
+ * `..._kind_not_allowed` (`params.kind`), `..._too_many_fields`, and
183
+ * `..._invalid_schema` with `params.key` for a config key the type's
184
+ * dataclass does not know — which is a cap that would otherwise silently not
185
+ * exist (backend delta note 1).
186
+ */
187
+ export function usePublishForm(): UseMutationResult<
188
+ PublishResult,
189
+ StapelApiError,
190
+ FormRef
191
+ > {
192
+ const api = useFormsApi();
193
+ const invalidate = useInvalidateModule();
194
+ const options: UseMutationOptions<PublishResult, StapelApiError, FormRef> = {
195
+ mutationFn: (vars) => api.publish(vars.workspaceId, vars.formId),
196
+ onSuccess: invalidate,
197
+ };
198
+ return useMutation(options);
199
+ }
200
+
201
+ /** Variables for {@link useSetFormState}. */
202
+ export interface SetFormStateVariables extends FormRef {
203
+ readonly state: FormState;
204
+ }
205
+
206
+ /** `POST /forms/<id>/state` — open / close / back to draft. `forms.manage`. */
207
+ export function useSetFormState(): UseMutationResult<
208
+ FormRow,
209
+ StapelApiError,
210
+ SetFormStateVariables
211
+ > {
212
+ const api = useFormsApi();
213
+ const invalidate = useInvalidateModule();
214
+ const options: UseMutationOptions<
215
+ FormRow,
216
+ StapelApiError,
217
+ SetFormStateVariables
218
+ > = {
219
+ mutationFn: (vars) => api.setState(vars.workspaceId, vars.formId, vars.state),
220
+ onSuccess: invalidate,
221
+ };
222
+ return useMutation(options);
223
+ }
224
+
225
+ /**
226
+ * `POST /forms/<id>/rotate-link` — mint a new `public_id`. `forms.manage`.
227
+ *
228
+ * The old link stops resolving (a uniform `error.404.forms_not_found`, so a
229
+ * probe cannot tell a rotated link from one that never existed). This is why
230
+ * every admin mutation invalidates the module root: the public read is cached
231
+ * BY the token this call replaces.
232
+ */
233
+ export function useRotateLink(): UseMutationResult<
234
+ FormRow,
235
+ StapelApiError,
236
+ FormRef
237
+ > {
238
+ const api = useFormsApi();
239
+ const invalidate = useInvalidateModule();
240
+ const options: UseMutationOptions<FormRow, StapelApiError, FormRef> = {
241
+ mutationFn: (vars) => api.rotateLink(vars.workspaceId, vars.formId),
242
+ onSuccess: invalidate,
243
+ };
244
+ return useMutation(options);
245
+ }
246
+
247
+ // ── admin: responses ─────────────────────────────────────────────────────────
248
+
249
+ /** Variables for the submission-scoped mutations. */
250
+ export interface SubmissionRef {
251
+ readonly workspaceId: string;
252
+ readonly submissionId: string;
253
+ }
254
+
255
+ /** `DELETE /submissions/<id>` — erase one response.
256
+ * `forms.responses.manage`. */
257
+ export function useDeleteSubmission(): UseMutationResult<
258
+ void,
259
+ StapelApiError,
260
+ SubmissionRef
261
+ > {
262
+ const api = useFormsApi();
263
+ const invalidate = useInvalidateModule();
264
+ const options: UseMutationOptions<void, StapelApiError, SubmissionRef> = {
265
+ mutationFn: (vars) =>
266
+ api.deleteSubmission(vars.workspaceId, vars.submissionId),
267
+ onSuccess: invalidate,
268
+ };
269
+ return useMutation(options);
270
+ }
271
+
272
+ /** Variables for {@link useResendSubmission}. */
273
+ export interface ResendVariables extends SubmissionRef {
274
+ /**
275
+ * Destination override. Given either list, the form's configured targets
276
+ * are **replaced**, not supplemented (backend delta note 7) — "send this one
277
+ * to legal" must not also re-send it to everybody who already received it.
278
+ * Omit to use the form's own targets.
279
+ */
280
+ readonly override?: ResendRequest;
281
+ }
282
+
283
+ /**
284
+ * `POST /submissions/<id>/resend` — re-deliver one response.
285
+ * `forms.responses.manage`.
286
+ *
287
+ * Admin-initiated, so it is NOT subject to `NOTIFY_COOLDOWN_SECONDS` (that
288
+ * cooldown suppresses respondent-triggered auto-notifies; a resend is an
289
+ * explicit operator act). Reads no cache and invalidates none — delivery is
290
+ * not state this pair holds.
291
+ */
292
+ export function useResendSubmission(): UseMutationResult<
293
+ ResendResult,
294
+ StapelApiError,
295
+ ResendVariables
296
+ > {
297
+ const api = useFormsApi();
298
+ const options: UseMutationOptions<
299
+ ResendResult,
300
+ StapelApiError,
301
+ ResendVariables
302
+ > = {
303
+ mutationFn: (vars) =>
304
+ api.resendSubmission(vars.workspaceId, vars.submissionId, vars.override),
305
+ retry: false,
306
+ };
307
+ return useMutation(options);
308
+ }
309
+
310
+ // ── admin: CSV export ────────────────────────────────────────────────────────
311
+
312
+ /** The state of a running CSV export. */
313
+ export interface CsvExportBag {
314
+ /** Drive the export to completion; resolves to the whole CSV text. */
315
+ run(params: {
316
+ readonly workspaceId: string;
317
+ readonly formId: string;
318
+ readonly version?: number;
319
+ }): Promise<string>;
320
+ readonly isExporting: boolean;
321
+ /** Pages fetched so far — a coarse but honest progress signal for a long
322
+ * export (the server never tells us the total). */
323
+ readonly pagesFetched: number;
324
+ readonly error: StapelApiError | null;
325
+ }
326
+
327
+ /**
328
+ * The paged CSV export, driven to completion.
329
+ *
330
+ * The server streams a bounded page per request and hands back the
331
+ * continuation cursor in the `X-Forms-Next-Before` response header — never in
332
+ * the body, which is a spreadsheet (backend delta note 6). This hook follows
333
+ * that header until it is absent, concatenates the pages keeping the header
334
+ * row exactly once, and returns the whole CSV as text. The cursor is passed
335
+ * back VERBATIM; re-formatting it is what made the second page a silent 400
336
+ * during the backend build.
337
+ *
338
+ * The result is text, not a download: handing the caller a string keeps this
339
+ * hook usable in a test, in a node script, and in a browser, and leaves the
340
+ * "make it a file" decision (which is a DOM act) to the skin.
341
+ */
342
+ export function useCsvExport(): CsvExportBag {
343
+ const api = useFormsApi();
344
+ const [isExporting, setExporting] = useState(false);
345
+ const [pagesFetched, setPagesFetched] = useState(0);
346
+ const [error, setError] = useState<StapelApiError | null>(null);
347
+
348
+ const run = useCallback(
349
+ async (params: {
350
+ readonly workspaceId: string;
351
+ readonly formId: string;
352
+ readonly version?: number;
353
+ }): Promise<string> => {
354
+ setExporting(true);
355
+ setPagesFetched(0);
356
+ setError(null);
357
+ const pages: string[] = [];
358
+ let before: string | undefined;
359
+ try {
360
+ for (;;) {
361
+ const page = await api.exportSubmissions({
362
+ workspaceId: params.workspaceId,
363
+ formId: params.formId,
364
+ ...(params.version !== undefined ? { version: params.version } : {}),
365
+ ...(before !== undefined ? { before } : {}),
366
+ });
367
+ pages.push(page.csv);
368
+ setPagesFetched(pages.length);
369
+ if (page.nextBefore === null) break;
370
+ before = page.nextBefore;
371
+ }
372
+ return concatCsvPages(pages);
373
+ } catch (caught) {
374
+ // Not `caught as StapelApiError`: a network fault carries neither
375
+ // `.code` nor `.status`, and claiming otherwise makes the skin render
376
+ // `undefined` as a sentence. `null` means "we cannot describe it".
377
+ setError(isStapelApiError(caught) ? caught : null);
378
+ throw caught;
379
+ } finally {
380
+ setExporting(false);
381
+ }
382
+ },
383
+ [api]
384
+ );
385
+
386
+ return { run, isExporting, pagesFetched, error };
387
+ }
@@ -0,0 +1,154 @@
1
+ import { useQuery } from "@tanstack/react-query";
2
+ import type { UseQueryResult } from "@tanstack/react-query";
3
+ import { useActiveSessionReady } from "@stapel/core";
4
+ import type { StapelApiError } from "@stapel/core";
5
+ import type {
6
+ FieldKindCatalogue,
7
+ FormRow,
8
+ FormState,
9
+ FormVersion,
10
+ PublicForm,
11
+ Submission,
12
+ SubmissionListParams,
13
+ } from "../api/types.js";
14
+ import { useFormsApi } from "./context.js";
15
+ import { formsQueryKeys } from "./queryKeys.js";
16
+
17
+ /**
18
+ * Read hooks over the forms API (frontend-standard §2 — read hooks). Keys are
19
+ * namespaced (see `formsQueryKeys`).
20
+ *
21
+ * SESSION GATING, and the one hook that is deliberately NOT gated. Every
22
+ * ADMIN read is gated on {@link useActiveSessionReady} (owner-diagnosed live
23
+ * incident, 2026-07-17): a list hook with no natural `enabled` condition is
24
+ * exactly the shape that raced a still-bootstrapping session.
25
+ * {@link usePublicForm} is the documented exception core's own doc comment
26
+ * carves out ("or be unconditionally safe pre-session, e.g. a public GET"):
27
+ * the endpoint is anonymous, and gating it would make an embedded form on a
28
+ * marketing page wait for a login bootstrap it has no stake in — a form that
29
+ * renders late for a visitor who will never sign in.
30
+ */
31
+
32
+ /**
33
+ * The active schema behind a public link — the anonymous read `<StapelForm>`
34
+ * and `<FormFill>` are built on.
35
+ *
36
+ * `retry: false`: the refusals here are VERDICTS, not blips
37
+ * (`error.404.forms_not_found`, `error.410.forms_closed`), and retrying a 404
38
+ * three times only delays the moment the skin can say which one it was. A
39
+ * genuine outage is retried by the person, through the bag's `refetch`.
40
+ */
41
+ export function usePublicForm(
42
+ publicId: string
43
+ ): UseQueryResult<PublicForm, StapelApiError> {
44
+ const api = useFormsApi();
45
+ return useQuery({
46
+ queryKey: formsQueryKeys.publicForm(publicId),
47
+ queryFn: () => api.getPublicForm(publicId),
48
+ enabled: publicId.length > 0,
49
+ retry: false,
50
+ });
51
+ }
52
+
53
+ /**
54
+ * The field kinds a form may be built from, with their config forms.
55
+ * `forms.manage`.
56
+ *
57
+ * `staleTime: Infinity`: a deployment's registered feature types change when
58
+ * somebody deploys, not while an admin edits a form, so refetching this on
59
+ * every window focus would be pure noise behind the builder.
60
+ */
61
+ export function useFieldKinds(
62
+ workspaceId: string
63
+ ): UseQueryResult<FieldKindCatalogue, StapelApiError> {
64
+ const api = useFormsApi();
65
+ const sessionReady = useActiveSessionReady();
66
+ return useQuery({
67
+ queryKey: formsQueryKeys.fieldKinds(workspaceId),
68
+ queryFn: () => api.listFieldKinds(workspaceId),
69
+ enabled: sessionReady && workspaceId.length > 0,
70
+ staleTime: Number.POSITIVE_INFINITY,
71
+ });
72
+ }
73
+
74
+ /** The workspace's forms, optionally filtered by state. `forms.view`. */
75
+ export function useForms(
76
+ workspaceId: string,
77
+ state?: FormState
78
+ ): UseQueryResult<readonly FormRow[], StapelApiError> {
79
+ const api = useFormsApi();
80
+ const sessionReady = useActiveSessionReady();
81
+ return useQuery({
82
+ queryKey: formsQueryKeys.forms(workspaceId, state),
83
+ queryFn: () => api.listForms(workspaceId, state),
84
+ enabled: sessionReady && workspaceId.length > 0,
85
+ });
86
+ }
87
+
88
+ /** One form, as its workspace's admins see it. `forms.view`. */
89
+ export function useForm(
90
+ workspaceId: string,
91
+ formId: string
92
+ ): UseQueryResult<FormRow, StapelApiError> {
93
+ const api = useFormsApi();
94
+ const sessionReady = useActiveSessionReady();
95
+ return useQuery({
96
+ queryKey: formsQueryKeys.form(workspaceId, formId),
97
+ queryFn: () => api.getForm(workspaceId, formId),
98
+ enabled: sessionReady && workspaceId.length > 0 && formId.length > 0,
99
+ });
100
+ }
101
+
102
+ /**
103
+ * Every published version of a form. `forms.view`.
104
+ *
105
+ * This is what makes per-version column sets possible in `<ResponsesTable>`:
106
+ * a submission records WHICH schema it answered, so the reviewer's columns
107
+ * come from that version rather than from today's fields.
108
+ */
109
+ export function useFormVersions(
110
+ workspaceId: string,
111
+ formId: string
112
+ ): UseQueryResult<readonly FormVersion[], StapelApiError> {
113
+ const api = useFormsApi();
114
+ const sessionReady = useActiveSessionReady();
115
+ return useQuery({
116
+ queryKey: formsQueryKeys.versions(workspaceId, formId),
117
+ queryFn: () => api.listVersions(workspaceId, formId),
118
+ enabled: sessionReady && workspaceId.length > 0 && formId.length > 0,
119
+ });
120
+ }
121
+
122
+ /**
123
+ * One keyset page of responses. `forms.responses.view`.
124
+ *
125
+ * Keyset, not offset: `before` is the previous page's last `submitted_at`.
126
+ * Each cursor is its own cache entry (see `formsQueryKeys.submissions`), which
127
+ * is what makes paging back instant instead of a re-fetch.
128
+ */
129
+ export function useSubmissions(
130
+ params: SubmissionListParams
131
+ ): UseQueryResult<readonly Submission[], StapelApiError> {
132
+ const api = useFormsApi();
133
+ const sessionReady = useActiveSessionReady();
134
+ return useQuery({
135
+ queryKey: formsQueryKeys.submissions(params),
136
+ queryFn: () => api.listSubmissions(params),
137
+ enabled:
138
+ sessionReady && params.workspaceId.length > 0 && params.formId.length > 0,
139
+ });
140
+ }
141
+
142
+ /** One response in full. `forms.responses.view`. */
143
+ export function useSubmission(
144
+ workspaceId: string,
145
+ submissionId: string
146
+ ): UseQueryResult<Submission, StapelApiError> {
147
+ const api = useFormsApi();
148
+ const sessionReady = useActiveSessionReady();
149
+ return useQuery({
150
+ queryKey: formsQueryKeys.submission(workspaceId, submissionId),
151
+ queryFn: () => api.getSubmission(workspaceId, submissionId),
152
+ enabled: sessionReady && workspaceId.length > 0 && submissionId.length > 0,
153
+ });
154
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Namespaced TanStack Query keys (frontend-standard §2 — namespaced keys).
3
+ * Everything under the `"forms"` root so a host can invalidate the whole
4
+ * module or match a single resource. Explicit tuple return types satisfy
5
+ * `--isolatedDeclarations`. One entry per read-operation.
6
+ *
7
+ * Invalidation shape: a schema-shifting mutation (draft, publish, rotate-link)
8
+ * invalidates the form AND its public read — the public key is keyed by
9
+ * `public_id`, which `rotateLink` changes, so the root is invalidated instead
10
+ * of a stale id. A response mutation (delete, resend) invalidates the
11
+ * submission list, and delete also the form (its `submission_count` moved).
12
+ */
13
+ import type { SubmissionListParams } from "../api/types.js";
14
+
15
+ const ROOT = "forms" as const;
16
+
17
+ export const formsQueryKeys: {
18
+ readonly all: readonly ["forms"];
19
+ /** The anonymous schema read, keyed by the public token — NOT by the row
20
+ * id, which an anonymous respondent never learns. */
21
+ publicForm(publicId: string): readonly ["forms", "public", string];
22
+ /** The builder's field-kind catalogue. Workspace-scoped because the
23
+ * endpoint is — a deployment's registered types are not global knowledge. */
24
+ fieldKinds(workspaceId: string): readonly ["forms", "field-kinds", string];
25
+ forms(
26
+ workspaceId: string,
27
+ state?: string
28
+ ): readonly ["forms", "list", string, string | null];
29
+ form(workspaceId: string, formId: string): readonly ["forms", "form", string, string];
30
+ versions(
31
+ workspaceId: string,
32
+ formId: string
33
+ ): readonly ["forms", "versions", string, string];
34
+ submissions(
35
+ params: SubmissionListParams
36
+ ): readonly ["forms", "submissions", SubmissionListParams];
37
+ submission(
38
+ workspaceId: string,
39
+ submissionId: string
40
+ ): readonly ["forms", "submission", string, string];
41
+ } = {
42
+ all: [ROOT],
43
+ publicForm: (publicId) => [ROOT, "public", publicId],
44
+ fieldKinds: (workspaceId) => [ROOT, "field-kinds", workspaceId],
45
+ // `state` is part of the key (a filtered list is a different read surface,
46
+ // not the same list) and normalized to `null` so an absent filter and an
47
+ // explicit `undefined` cannot cache twice.
48
+ forms: (workspaceId, state) => [ROOT, "list", workspaceId, state ?? null],
49
+ form: (workspaceId, formId) => [ROOT, "form", workspaceId, formId],
50
+ versions: (workspaceId, formId) => [ROOT, "versions", workspaceId, formId],
51
+ // The params object rides the key: a version filter and a page size are
52
+ // distinct reads. `before` is deliberately included — keyset pages are
53
+ // cached per cursor, which is what makes "back" instant.
54
+ submissions: (params) => [ROOT, "submissions", params],
55
+ submission: (workspaceId, submissionId) => [
56
+ ROOT,
57
+ "submission",
58
+ workspaceId,
59
+ submissionId,
60
+ ],
61
+ };
@@ -0,0 +1,48 @@
1
+ import { createModuleRuntime } from "@stapel/core";
2
+ import type { CreateModuleRuntimeOptions, ModuleRuntime } from "@stapel/core";
3
+ import { createFormsApi } from "../api/formsApi.js";
4
+ import type { FormsApi } from "../api/formsApi.js";
5
+
6
+ /**
7
+ * The wired forms runtime — core's `ModuleRuntime` bound to this pair's API
8
+ * (slim wave §21/S2: the plumbing lives once in `@stapel/core`'s
9
+ * `createModuleRuntime`/`createModuleContext`; this module only binds the
10
+ * module-prefixed names). The returned `client` is what the host injects into
11
+ * core's `StapelConfigProvider` (as the default or the `"forms"` module
12
+ * client), preserving the client-injection fork seam (frontend-standard §7.2).
13
+ *
14
+ * ANONYMOUS EMBED. The two public routes need no session, so a render-only
15
+ * host page can build this runtime and nothing else:
16
+ *
17
+ * ```tsx
18
+ * const runtime = createFormsRuntime({ baseUrl: "/forms/api/v1/" });
19
+ * <FormsProvider runtime={runtime}><StapelForm publicId="k3J…x9" /></FormsProvider>
20
+ * ```
21
+ *
22
+ * Auth token/refresh and the verification-403 seam are supplied by the host's
23
+ * auth runtime on the shared client when the ADMIN surface is in use — this
24
+ * pair does not re-implement them. The runtime's fetch/credentials/
25
+ * defaultHeaders are ALSO forwarded to the pair's raw CSV surface
26
+ * (`api/export.ts`), which cannot ride the JSON client.
27
+ */
28
+ export type FormsRuntime = ModuleRuntime<FormsApi>;
29
+
30
+ export type CreateFormsRuntimeOptions = CreateModuleRuntimeOptions;
31
+
32
+ export function createFormsRuntime(
33
+ options: CreateFormsRuntimeOptions
34
+ ): FormsRuntime {
35
+ return createModuleRuntime(
36
+ (client) =>
37
+ createFormsApi(client, {
38
+ ...(options.fetch !== undefined ? { fetch: options.fetch } : {}),
39
+ ...(options.credentials !== undefined
40
+ ? { credentials: options.credentials }
41
+ : {}),
42
+ ...(options.defaultHeaders !== undefined
43
+ ? { defaultHeaders: options.defaultHeaders }
44
+ : {}),
45
+ }),
46
+ options
47
+ );
48
+ }