@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.
- package/CHANGELOG.md +62 -0
- package/MODULE.md +172 -0
- package/README.md +207 -0
- package/dist/api/export.d.ts +66 -0
- package/dist/api/export.d.ts.map +1 -0
- package/dist/api/export.js +138 -0
- package/dist/api/export.js.map +1 -0
- package/dist/api/extensions.d.ts +8 -0
- package/dist/api/extensions.d.ts.map +1 -0
- package/dist/api/extensions.js +8 -0
- package/dist/api/extensions.js.map +1 -0
- package/dist/api/formsApi.d.ts +113 -0
- package/dist/api/formsApi.d.ts.map +1 -0
- package/dist/api/formsApi.js +81 -0
- package/dist/api/formsApi.js.map +1 -0
- package/dist/api/generated/schema.d.ts +987 -0
- package/dist/api/generated/schema.d.ts.map +1 -0
- package/dist/api/generated/schema.js +2 -0
- package/dist/api/generated/schema.js.map +1 -0
- package/dist/api/types.d.ts +222 -0
- package/dist/api/types.d.ts.map +1 -0
- package/dist/api/types.js +14 -0
- package/dist/api/types.js.map +1 -0
- package/dist/default/ConfigField.d.ts +32 -0
- package/dist/default/ConfigField.d.ts.map +1 -0
- package/dist/default/ConfigField.js +101 -0
- package/dist/default/ConfigField.js.map +1 -0
- package/dist/default/ErrorAlert.d.ts +9 -0
- package/dist/default/ErrorAlert.d.ts.map +1 -0
- package/dist/default/ErrorAlert.js +27 -0
- package/dist/default/ErrorAlert.js.map +1 -0
- package/dist/default/FormBuilderPane.d.ts +36 -0
- package/dist/default/FormBuilderPane.d.ts.map +1 -0
- package/dist/default/FormBuilderPane.js +70 -0
- package/dist/default/FormBuilderPane.js.map +1 -0
- package/dist/default/FormsListPane.d.ts +18 -0
- package/dist/default/FormsListPane.d.ts.map +1 -0
- package/dist/default/FormsListPane.js +28 -0
- package/dist/default/FormsListPane.js.map +1 -0
- package/dist/default/ResponsesPane.d.ts +21 -0
- package/dist/default/ResponsesPane.d.ts.map +1 -0
- package/dist/default/ResponsesPane.js +118 -0
- package/dist/default/ResponsesPane.js.map +1 -0
- package/dist/default/StapelForm.d.ts +76 -0
- package/dist/default/StapelForm.d.ts.map +1 -0
- package/dist/default/StapelForm.js +118 -0
- package/dist/default/StapelForm.js.map +1 -0
- package/dist/default/fields.d.ts +11 -0
- package/dist/default/fields.d.ts.map +1 -0
- package/dist/default/fields.js +273 -0
- package/dist/default/fields.js.map +1 -0
- package/dist/default/index.d.ts +42 -0
- package/dist/default/index.d.ts.map +1 -0
- package/dist/default/index.js +37 -0
- package/dist/default/index.js.map +1 -0
- package/dist/default/slots.d.ts +59 -0
- package/dist/default/slots.d.ts.map +1 -0
- package/dist/default/slots.js +25 -0
- package/dist/default/slots.js.map +1 -0
- package/dist/default/theme.d.ts +23 -0
- package/dist/default/theme.d.ts.map +1 -0
- package/dist/default/theme.js +38 -0
- package/dist/default/theme.js.map +1 -0
- package/dist/default/types.d.ts +12 -0
- package/dist/default/types.d.ts.map +1 -0
- package/dist/default/types.js +2 -0
- package/dist/default/types.js.map +1 -0
- package/dist/flows/errors.d.ts +12 -0
- package/dist/flows/errors.d.ts.map +1 -0
- package/dist/flows/errors.js +13 -0
- package/dist/flows/errors.js.map +1 -0
- package/dist/flows/registry.d.ts +22 -0
- package/dist/flows/registry.d.ts.map +1 -0
- package/dist/flows/registry.js +20 -0
- package/dist/flows/registry.js.map +1 -0
- package/dist/headless/FormBuilder.d.ts +100 -0
- package/dist/headless/FormBuilder.d.ts.map +1 -0
- package/dist/headless/FormBuilder.js +278 -0
- package/dist/headless/FormBuilder.js.map +1 -0
- package/dist/headless/FormFill.d.ts +88 -0
- package/dist/headless/FormFill.d.ts.map +1 -0
- package/dist/headless/FormFill.js +279 -0
- package/dist/headless/FormFill.js.map +1 -0
- package/dist/headless/FormList.d.ts +34 -0
- package/dist/headless/FormList.d.ts.map +1 -0
- package/dist/headless/FormList.js +37 -0
- package/dist/headless/FormList.js.map +1 -0
- package/dist/headless/FormsProvider.d.ts +19 -0
- package/dist/headless/FormsProvider.d.ts.map +1 -0
- package/dist/headless/FormsProvider.js +15 -0
- package/dist/headless/FormsProvider.js.map +1 -0
- package/dist/headless/ResponsesTable.d.ts +82 -0
- package/dist/headless/ResponsesTable.d.ts.map +1 -0
- package/dist/headless/ResponsesTable.js +195 -0
- package/dist/headless/ResponsesTable.js.map +1 -0
- package/dist/i18n/errorsMap.d.ts +10 -0
- package/dist/i18n/errorsMap.d.ts.map +1 -0
- package/dist/i18n/errorsMap.js +20 -0
- package/dist/i18n/errorsMap.js.map +1 -0
- package/dist/i18n/es.d.ts +7 -0
- package/dist/i18n/es.d.ts.map +1 -0
- package/dist/i18n/es.js +118 -0
- package/dist/i18n/es.js.map +1 -0
- package/dist/i18n/generated/errors.es.gen.d.ts +16 -0
- package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.es.gen.js +79 -0
- package/dist/i18n/generated/errors.es.gen.js.map +1 -0
- package/dist/i18n/generated/errors.gen.d.ts +485 -0
- package/dist/i18n/generated/errors.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.gen.js +246 -0
- package/dist/i18n/generated/errors.gen.js.map +1 -0
- package/dist/i18n/generated/errors.ru.gen.d.ts +16 -0
- package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.ru.gen.js +79 -0
- package/dist/i18n/generated/errors.ru.gen.js.map +1 -0
- package/dist/i18n/keys.d.ts +141 -0
- package/dist/i18n/keys.d.ts.map +1 -0
- package/dist/i18n/keys.js +233 -0
- package/dist/i18n/keys.js.map +1 -0
- package/dist/i18n/ru.d.ts +7 -0
- package/dist/i18n/ru.d.ts.map +1 -0
- package/dist/i18n/ru.js +127 -0
- package/dist/i18n/ru.js.map +1 -0
- package/dist/index.d.ts +61 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +55 -0
- package/dist/index.js.map +1 -0
- package/dist/model/context.d.ts +11 -0
- package/dist/model/context.d.ts.map +1 -0
- package/dist/model/context.js +16 -0
- package/dist/model/context.js.map +1 -0
- package/dist/model/mutations.d.ts +135 -0
- package/dist/model/mutations.d.ts.map +1 -0
- package/dist/model/mutations.js +223 -0
- package/dist/model/mutations.js.map +1 -0
- package/dist/model/queries.d.ts +59 -0
- package/dist/model/queries.d.ts.map +1 -0
- package/dist/model/queries.js +117 -0
- package/dist/model/queries.js.map +1 -0
- package/dist/model/queryKeys.d.ts +28 -0
- package/dist/model/queryKeys.d.ts.map +1 -0
- package/dist/model/queryKeys.js +23 -0
- package/dist/model/queryKeys.js.map +1 -0
- package/dist/model/runtime.d.ts +28 -0
- package/dist/model/runtime.d.ts.map +1 -0
- package/dist/model/runtime.js +14 -0
- package/dist/model/runtime.js.map +1 -0
- package/dist/widgets/configForms.d.ts +112 -0
- package/dist/widgets/configForms.d.ts.map +1 -0
- package/dist/widgets/configForms.js +211 -0
- package/dist/widgets/configForms.js.map +1 -0
- package/dist/widgets/registry.d.ts +83 -0
- package/dist/widgets/registry.d.ts.map +1 -0
- package/dist/widgets/registry.js +27 -0
- package/dist/widgets/registry.js.map +1 -0
- package/dist/widgets/validate.d.ts +57 -0
- package/dist/widgets/validate.d.ts.map +1 -0
- package/dist/widgets/validate.js +173 -0
- package/dist/widgets/validate.js.map +1 -0
- package/llms.txt +124 -0
- package/manifest.json +1038 -0
- package/package.json +108 -0
- package/src/analytics/generated/events.json +7 -0
- package/src/api/export.ts +195 -0
- package/src/api/extensions.ts +7 -0
- package/src/api/formsApi.ts +329 -0
- package/src/api/generated/schema.ts +989 -0
- package/src/api/types.ts +276 -0
- package/src/default/ConfigField.tsx +227 -0
- package/src/default/ErrorAlert.tsx +46 -0
- package/src/default/FormBuilderPane.tsx +414 -0
- package/src/default/FormsListPane.tsx +144 -0
- package/src/default/ResponsesPane.tsx +332 -0
- package/src/default/StapelForm.tsx +377 -0
- package/src/default/fields.tsx +454 -0
- package/src/default/index.ts +71 -0
- package/src/default/slots.ts +78 -0
- package/src/default/theme.tsx +65 -0
- package/src/default/types.ts +12 -0
- package/src/flows/errors.ts +16 -0
- package/src/flows/registry.ts +32 -0
- package/src/headless/FormBuilder.tsx +453 -0
- package/src/headless/FormFill.tsx +396 -0
- package/src/headless/FormList.tsx +74 -0
- package/src/headless/FormsProvider.tsx +20 -0
- package/src/headless/ResponsesTable.tsx +341 -0
- package/src/i18n/errorsMap.ts +31 -0
- package/src/i18n/es.ts +140 -0
- package/src/i18n/generated/errors.es.gen.ts +85 -0
- package/src/i18n/generated/errors.gen.ts +277 -0
- package/src/i18n/generated/errors.json +583 -0
- package/src/i18n/generated/errors.ru.gen.ts +85 -0
- package/src/i18n/keys.ts +264 -0
- package/src/i18n/ru.ts +150 -0
- package/src/index.ts +184 -0
- package/src/model/context.tsx +28 -0
- package/src/model/mutations.ts +387 -0
- package/src/model/queries.ts +154 -0
- package/src/model/queryKeys.ts +61 -0
- package/src/model/runtime.ts +48 -0
- package/src/widgets/registry.ts +101 -0
- package/src/widgets/validate.ts +224 -0
- 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
|
+
}
|