@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,16 @@
1
+ import { toFlowError as coreToFlowError } from "@stapel/core";
2
+ import type { FlowError } from "@stapel/core";
3
+
4
+ export type { FlowError } from "@stapel/core";
5
+ export { isErrorCode } from "@stapel/core";
6
+
7
+ /**
8
+ * Fold any thrown value into a {@link FlowError} using this pair's own
9
+ * module-scoped fallback key (`forms.error.unknown`, an en string in
10
+ * {@link formsI18nBundleEn}) so a non-`StapelApiError` fault still renders
11
+ * real copy rather than a raw key. The primitive lives in `@stapel/core`
12
+ * (frontend-core-architecture §4b); this wrapper only pins the fallback.
13
+ */
14
+ export function toFlowError(error: unknown): FlowError {
15
+ return coreToFlowError(error, "forms.error.unknown");
16
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Zero-flow registry shim (slim wave §21/S3). stapel-forms annotates no
3
+ * `@flow_step` yet — its backend `docs/flows.json` carries no `forms.*`
4
+ * flows, so `gen:flows` skips emission for this pair (no `flows/generated/`
5
+ * scaffolding). This hand-written shim preserves the pair's public
6
+ * flow-registry surface at its zero-flow shape.
7
+ *
8
+ * When the backend annotates flows, `pnpm gen:flows` emits
9
+ * `./generated/flows.gen.ts` again — replace these exports with re-exports
10
+ * from it (the shapes match by construction) and delete this file.
11
+ */
12
+ export const FORMS_FLOWS = {} as const;
13
+
14
+ /** Canonical flow ids present in flows.json (none yet — see above). */
15
+ export type FormsFlowId = keyof typeof FORMS_FLOWS;
16
+
17
+ export type FormsFlowSpec = (typeof FORMS_FLOWS)[FormsFlowId];
18
+
19
+ export interface FlowEndpoint {
20
+ readonly method: string;
21
+ readonly path: string;
22
+ }
23
+
24
+ /** All HTTP endpoints a flow touches, in step order (for the contract test / MSW). */
25
+ export function flowEndpoints(id: FormsFlowId): readonly FlowEndpoint[] {
26
+ // Same widened body as the generated registry's — valid for the zero-flow
27
+ // shape AND correct once flows exist.
28
+ const spec = FORMS_FLOWS[id] as
29
+ | { readonly steps: readonly { readonly endpoints: readonly FlowEndpoint[] }[] }
30
+ | undefined;
31
+ return spec ? spec.steps.flatMap((s) => s.endpoints) : [];
32
+ }
@@ -0,0 +1,453 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import type { ReactNode } from "react";
3
+ import {
4
+ actionAvailable,
5
+ actionBlocked,
6
+ isStapelApiError,
7
+ loadStateFromQuery,
8
+ mapLoad,
9
+ requireLoaded,
10
+ } from "@stapel/core";
11
+ import type {
12
+ ActionAvailability,
13
+ LoadState,
14
+ StapelApiError,
15
+ } from "@stapel/core";
16
+ import type {
17
+ ConfigFieldSpec,
18
+ FieldKind,
19
+ FormFieldDef,
20
+ FormRow,
21
+ FormSchema,
22
+ FormSchemaMeta,
23
+ FormState,
24
+ } from "../api/types.js";
25
+ import { useFieldKinds, useForm } from "../model/queries.js";
26
+ import {
27
+ usePublishForm,
28
+ useRotateLink,
29
+ useSaveDraft,
30
+ useSetFormState,
31
+ } from "../model/mutations.js";
32
+ import { FORMS_I18N_KEYS } from "../i18n/keys.js";
33
+
34
+ /** One row of the builder's field list, with everything the UI needs to draw
35
+ * and judge it. */
36
+ export interface BuilderField {
37
+ readonly field: FormFieldDef;
38
+ /**
39
+ * The kind's entry in the server's catalogue, or `undefined` when the
40
+ * catalogue has not loaded yet or does not list this kind at all (a stored
41
+ * schema can outlive a host's `FIELD_KINDS` allowlist).
42
+ */
43
+ readonly kindInfo: FieldKind | undefined;
44
+ /** The kind's config declaration, straight from
45
+ * `stapel_attributes.config_form()` via `GET /field-kinds`. Empty for a
46
+ * builder-less kind. */
47
+ readonly configFields: readonly ConfigFieldSpec[];
48
+ /**
49
+ * True when this field's options cannot be edited here. TWO server signals
50
+ * feed it, and they are different facts:
51
+ *
52
+ * - `registered: false` — the host allowlisted a kind the attributes
53
+ * registry does not carry. The field is still listed, because a stored
54
+ * schema may already use it and a builder that dropped the kind would
55
+ * silently drop the field.
56
+ * - `fields: []` — the kind is registered but declares no config form at
57
+ * all (this is how `convertible_unit` arrives).
58
+ *
59
+ * Either way the field stays LISTED, reorderable and removable, and stays
60
+ * authorable through the draft PUT.
61
+ */
62
+ readonly builderLess: boolean;
63
+ /** Config-widget kinds in this field's declaration that the SKIN has no
64
+ * editor for. Named so the UI can say which options it is not showing rather
65
+ * than presenting a partial form as a complete one. */
66
+ readonly unsupportedConfigKeys: readonly string[];
67
+ }
68
+
69
+ /** The bag `<FormBuilder>` hands its render prop (spec §8). */
70
+ export interface FormBuilderBag {
71
+ /** The form row. A failed read is never "no such form" — LoadState law. */
72
+ readonly state: LoadState<FormRow>;
73
+ /** The draft under edit: the row's `draft_schema` when there is one, else
74
+ * the active version's schema as a starting point, else empty. */
75
+ readonly fields: readonly BuilderField[];
76
+ readonly meta: FormSchemaMeta;
77
+ /** True when the local draft differs from what the server holds. */
78
+ readonly isDirty: boolean;
79
+
80
+ addField(kind: string): void;
81
+ removeField(slug: string): void;
82
+ /** Move a field to a new index (drag-reorder, or a pair of arrow buttons —
83
+ * field ORDER is schema order). */
84
+ moveField(slug: string, toIndex: number): void;
85
+ /** Patch a field's own attributes (`slug`, `name`, `mandatory`, …). */
86
+ updateField(slug: string, patch: Partial<FormFieldDef>): void;
87
+ /** Set one config key of one field. `undefined` REMOVES the key, which is
88
+ * not the same as writing `null`: the engine reads an absent key as "use my
89
+ * own default". */
90
+ setFieldConfig(slug: string, key: string, value: unknown): void;
91
+ setMeta(patch: Partial<FormSchemaMeta>): void;
92
+
93
+ /**
94
+ * The kinds the builder may offer, from the server's catalogue: allowed by
95
+ * this deployment, carried by the attributes registry, and declaring a
96
+ * config form. `LoadState` rather than a bare array — a catalogue that
97
+ * failed to load must not read as "this deployment has no field kinds".
98
+ */
99
+ readonly availableKinds: LoadState<readonly FieldKind[]>;
100
+ /** The config-WIDGET vocabulary (`config_form.FIELD_KINDS`) and the params
101
+ * each widget understands, for a skin rendering the config rows. */
102
+ readonly configWidgets: Readonly<Record<string, readonly string[]>>;
103
+
104
+ readonly save: ActionAvailability;
105
+ doSave(): void;
106
+ readonly publish: ActionAvailability;
107
+ doPublish(): void;
108
+ readonly isSaving: boolean;
109
+ readonly isPublishing: boolean;
110
+
111
+ setState(next: FormState): void;
112
+ rotateLink(): void;
113
+
114
+ /** The last refusal from save/publish/state/rotate — a `StapelApiError` so
115
+ * the skin can branch with `hasErrorCode` (`forms_duplicate_slug` carries
116
+ * `params.slug`, `forms_invalid_schema` carries `params.key`, …). */
117
+ readonly error: StapelApiError | null;
118
+ refetch(): void;
119
+ }
120
+
121
+ /**
122
+ * The config-WIDGET kinds the `/default` skin can draw (`ConfigField`).
123
+ *
124
+ * Upstream's widget vocabulary is 13 entries; the skin implements 11. The two
125
+ * it does not — `hierarchical_options` (a tree editor) and `timestamp_array` —
126
+ * make the individual config ROW unrenderable, not the whole kind, so a field
127
+ * declaring one still gets its other options and the UI names what it is not
128
+ * showing. Kept here rather than imported from `/default` so the headless bag
129
+ * can report it without pulling antd into the main bundle.
130
+ */
131
+ const SKIN_CONFIG_WIDGETS: ReadonlySet<string> = new Set([
132
+ "number",
133
+ "text",
134
+ "checkbox",
135
+ "translatable_text",
136
+ "number_options",
137
+ "string_options",
138
+ "color_options",
139
+ "select",
140
+ "select_options_with_default",
141
+ "max_selected_dropdown",
142
+ "timestamp",
143
+ ]);
144
+
145
+ /**
146
+ * The config a freshly-added field starts with: every default the SERVER's
147
+ * declaration carries, and nothing else.
148
+ *
149
+ * Keys with no declared default stay ABSENT rather than written as `null` —
150
+ * the engine reads an absent key as "use my own default", and writing one
151
+ * changes stored behaviour.
152
+ */
153
+ function defaultConfigFor(kind: FieldKind | undefined): Record<string, unknown> {
154
+ const config: Record<string, unknown> = {};
155
+ for (const spec of kind?.fields ?? []) {
156
+ if (spec.default !== undefined) config[spec.name] = spec.default;
157
+ }
158
+ return config;
159
+ }
160
+
161
+ /** A slug that does not collide with anything already in the draft. */
162
+ function freeSlug(kind: string, taken: ReadonlySet<string>): string {
163
+ const base = kind === "header" ? "heading" : kind;
164
+ for (let n = 1; ; n += 1) {
165
+ const candidate = `${base}_${n}`;
166
+ if (!taken.has(candidate)) return candidate;
167
+ }
168
+ }
169
+
170
+ /** The draft a form starts from: its own scratchpad if it has one, otherwise
171
+ * nothing. The ACTIVE version is deliberately not copied in — editing a live
172
+ * form starts from the draft the admin last saved, and a form with no draft is
173
+ * a form nobody has started editing. */
174
+ function initialSchema(row: FormRow | undefined): FormSchema {
175
+ return row?.draft_schema ?? { fields: [], meta: {} };
176
+ }
177
+
178
+ /**
179
+ * Headless form builder — the authoring surface, renderless and DATA-DRIVEN.
180
+ *
181
+ * There is no per-kind hand-written form anywhere in this component or in the
182
+ * skin that renders it: a field's options come from `GET /field-kinds`, which
183
+ * serves `stapel_attributes.config_form()` verbatim. Until stapel-forms 0.2.0
184
+ * there was no such route and the pair had to mirror those declarations in
185
+ * TypeScript — a table that drifts silently. Reading the registry makes the
186
+ * declaration the single source of truth again: a type registered through
187
+ * `EXTRA_TYPES` shows up here with no client release at all (spec §8).
188
+ */
189
+ export function FormBuilder(props: {
190
+ workspaceId: string;
191
+ formId: string;
192
+ children: (bag: FormBuilderBag) => ReactNode;
193
+ }): ReactNode {
194
+ const query = useForm(props.workspaceId, props.formId);
195
+ const state = loadStateFromQuery(query);
196
+ const row = state.status === "ready" ? state.data : undefined;
197
+
198
+ const kindsQuery = useFieldKinds(props.workspaceId);
199
+ const kindsState = loadStateFromQuery(kindsQuery);
200
+ const catalogue = kindsState.status === "ready" ? kindsState.data : undefined;
201
+ const kindsBySlug = useMemo(() => {
202
+ const map = new Map<string, FieldKind>();
203
+ for (const kind of catalogue?.kinds ?? []) map.set(kind.kind, kind);
204
+ return map;
205
+ }, [catalogue]);
206
+
207
+ const saveMutation = useSaveDraft();
208
+ const publishMutation = usePublishForm();
209
+ const stateMutation = useSetFormState();
210
+ const rotateMutation = useRotateLink();
211
+
212
+ const [schema, setSchema] = useState<FormSchema>(() => initialSchema(row));
213
+ const [error, setError] = useState<StapelApiError | null>(null);
214
+
215
+ // Adopt the server's draft when the form first loads, and after any save —
216
+ // but never mid-edit, which would throw away what the admin is typing. The
217
+ // ref tracks WHICH server draft has been adopted, so a background refetch
218
+ // that returns the same draft is not an adoption event.
219
+ const adopted = useRef<string | null>(null);
220
+ useEffect(() => {
221
+ if (row === undefined) return;
222
+ const fingerprint = JSON.stringify(row.draft_schema ?? null);
223
+ if (adopted.current === fingerprint) return;
224
+ adopted.current = fingerprint;
225
+ setSchema(initialSchema(row));
226
+ }, [row]);
227
+
228
+ const isDirty = useMemo(
229
+ () => JSON.stringify(schema) !== JSON.stringify(initialSchema(row)),
230
+ [schema, row]
231
+ );
232
+
233
+ const fields = useMemo<readonly BuilderField[]>(
234
+ () =>
235
+ schema.fields.map((field) => {
236
+ const kindInfo = kindsBySlug.get(field.kind);
237
+ const configFields = kindInfo?.fields ?? [];
238
+ return {
239
+ field,
240
+ kindInfo,
241
+ configFields,
242
+ // Both server signals collapse to one rendering decision here, but
243
+ // they are reported separately on `kindInfo` so a skin can word them
244
+ // differently: "this deployment does not know this kind" is not the
245
+ // same news as "this kind has no options".
246
+ builderLess:
247
+ kindInfo === undefined ||
248
+ kindInfo.registered === false ||
249
+ configFields.length === 0,
250
+ unsupportedConfigKeys: configFields
251
+ .filter((spec) => !SKIN_CONFIG_WIDGETS.has(spec.kind))
252
+ .map((spec) => spec.name),
253
+ };
254
+ }),
255
+ [schema, kindsBySlug]
256
+ );
257
+
258
+ const addField = useCallback(
259
+ (kind: string): void => {
260
+ setSchema((current) => {
261
+ const taken = new Set(current.fields.map((f) => f.slug));
262
+ const config = defaultConfigFor(kindsBySlug.get(kind));
263
+ const field: FormFieldDef = {
264
+ slug: freeSlug(kind, taken),
265
+ kind,
266
+ name: "",
267
+ mandatory: false,
268
+ ...(Object.keys(config).length > 0 ? { config } : {}),
269
+ };
270
+ return { ...current, fields: [...current.fields, field] };
271
+ });
272
+ setError(null);
273
+ },
274
+ [kindsBySlug]
275
+ );
276
+
277
+ const removeField = useCallback((slug: string): void => {
278
+ setSchema((current) => ({
279
+ ...current,
280
+ fields: current.fields.filter((f) => f.slug !== slug),
281
+ }));
282
+ setError(null);
283
+ }, []);
284
+
285
+ const moveField = useCallback((slug: string, toIndex: number): void => {
286
+ setSchema((current) => {
287
+ const from = current.fields.findIndex((f) => f.slug === slug);
288
+ if (from === -1) return current;
289
+ const moving = current.fields[from];
290
+ if (moving === undefined) return current;
291
+ const rest = current.fields.filter((_, index) => index !== from);
292
+ const clamped = Math.max(0, Math.min(toIndex, rest.length));
293
+ return {
294
+ ...current,
295
+ fields: [...rest.slice(0, clamped), moving, ...rest.slice(clamped)],
296
+ };
297
+ });
298
+ setError(null);
299
+ }, []);
300
+
301
+ const updateField = useCallback(
302
+ (slug: string, patch: Partial<FormFieldDef>): void => {
303
+ setSchema((current) => ({
304
+ ...current,
305
+ fields: current.fields.map((f) =>
306
+ f.slug === slug ? { ...f, ...patch } : f
307
+ ),
308
+ }));
309
+ setError(null);
310
+ },
311
+ []
312
+ );
313
+
314
+ const setFieldConfig = useCallback(
315
+ (slug: string, key: string, value: unknown): void => {
316
+ setSchema((current) => ({
317
+ ...current,
318
+ fields: current.fields.map((f) => {
319
+ if (f.slug !== slug) return f;
320
+ // `undefined` REMOVES the key. An absent config key means "the
321
+ // engine's own default"; writing null means "this value", and the
322
+ // two are different stored schemas. Rebuilt by filtering rather
323
+ // than `delete` (a dynamic delete deoptimizes the object shape).
324
+ const current = f.config ?? {};
325
+ const config: Record<string, unknown> = Object.fromEntries(
326
+ Object.entries(current).filter(([existing]) => existing !== key)
327
+ );
328
+ if (value !== undefined) config[key] = value;
329
+ return { ...f, config };
330
+ }),
331
+ }));
332
+ setError(null);
333
+ },
334
+ []
335
+ );
336
+
337
+ const setMeta = useCallback((patch: Partial<FormSchemaMeta>): void => {
338
+ setSchema((current) => ({ ...current, meta: { ...current.meta, ...patch } }));
339
+ setError(null);
340
+ }, []);
341
+
342
+ const isSaving = saveMutation.isPending;
343
+ const isPublishing = publishMutation.isPending;
344
+
345
+ const save: ActionAvailability = useMemo(
346
+ () =>
347
+ requireLoaded(state, () => {
348
+ if (isSaving) return actionBlocked(FORMS_I18N_KEYS.builderSaving);
349
+ if (!isDirty) return actionBlocked(FORMS_I18N_KEYS.builderNoChanges);
350
+ return actionAvailable();
351
+ }),
352
+ [state, isSaving, isDirty]
353
+ );
354
+
355
+ const publish: ActionAvailability = useMemo(
356
+ () =>
357
+ requireLoaded(state, () => {
358
+ if (isPublishing) return actionBlocked(FORMS_I18N_KEYS.builderPublishing);
359
+ if (schema.fields.length === 0) {
360
+ return actionBlocked(FORMS_I18N_KEYS.builderEmptySchema);
361
+ }
362
+ // Publishing what the server holds, not what is on screen: an unsaved
363
+ // draft would publish the PREVIOUS text while the admin looks at the
364
+ // new one. Save first, then publish — two acts, in that order.
365
+ if (isDirty) return actionBlocked(FORMS_I18N_KEYS.builderUnsavedDraft);
366
+ return actionAvailable();
367
+ }),
368
+ [state, isPublishing, isDirty, schema.fields.length]
369
+ );
370
+
371
+ const onError = useCallback((caught: unknown): void => {
372
+ // See mutations.ts: a fault that is not a StapelApiError has no code to
373
+ // show, and pretending it does renders `undefined` at the person.
374
+ setError(isStapelApiError(caught) ? caught : null);
375
+ }, []);
376
+
377
+ const ref = useMemo(
378
+ () => ({ workspaceId: props.workspaceId, formId: props.formId }),
379
+ [props.workspaceId, props.formId]
380
+ );
381
+
382
+ const doSave = useCallback((): void => {
383
+ if (!save.available) return;
384
+ setError(null);
385
+ saveMutation.mutate({ ...ref, schema }, { onError });
386
+ }, [save, saveMutation, ref, schema, onError]);
387
+
388
+ const doPublish = useCallback((): void => {
389
+ if (!publish.available) return;
390
+ setError(null);
391
+ publishMutation.mutate(ref, { onError });
392
+ }, [publish, publishMutation, ref, onError]);
393
+
394
+ const setFormState = useCallback(
395
+ (next: FormState): void => {
396
+ setError(null);
397
+ stateMutation.mutate({ ...ref, state: next }, { onError });
398
+ },
399
+ [stateMutation, ref, onError]
400
+ );
401
+
402
+ const rotateLink = useCallback((): void => {
403
+ setError(null);
404
+ rotateMutation.mutate(ref, { onError });
405
+ }, [rotateMutation, ref, onError]);
406
+
407
+ const refetch = useCallback((): void => {
408
+ void query.refetch();
409
+ }, [query]);
410
+
411
+ // Offer only what this deployment can actually build with: allowed by its
412
+ // FIELD_KINDS setting, carried by the attributes registry, and declaring a
413
+ // config form. A kind failing any of those is still RENDERABLE and still
414
+ // authorable through the draft PUT — it is just not something the builder
415
+ // can hand somebody a button for.
416
+ const availableKinds: LoadState<readonly FieldKind[]> = useMemo(
417
+ () =>
418
+ mapLoad(kindsState, (cat) =>
419
+ cat.kinds.filter(
420
+ (kind) =>
421
+ kind.allowed &&
422
+ kind.registered &&
423
+ (kind.fields?.length ?? 0) > 0
424
+ )
425
+ ),
426
+ [kindsState]
427
+ );
428
+
429
+ return props.children({
430
+ state,
431
+ fields,
432
+ meta: schema.meta ?? {},
433
+ isDirty,
434
+ addField,
435
+ removeField,
436
+ moveField,
437
+ updateField,
438
+ setFieldConfig,
439
+ setMeta,
440
+ availableKinds,
441
+ configWidgets: catalogue?.configWidgets ?? {},
442
+ save,
443
+ doSave,
444
+ publish,
445
+ doPublish,
446
+ isSaving,
447
+ isPublishing,
448
+ setState: setFormState,
449
+ rotateLink,
450
+ error,
451
+ refetch,
452
+ });
453
+ }