@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,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;
|