@form-engine-ts/react 2.0.0 → 2.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/dist/index.js CHANGED
@@ -1,31 +1,36 @@
1
1
  // src/builder.tsx
2
2
  import {
3
- populateSchemaTranslations,
4
- sanitizeSchema
3
+ populateSchemaTranslations
5
4
  } from "@form-engine-ts/core";
6
5
  import { useState } from "react";
7
6
 
8
7
  // src/hooks/useFormBuilder.ts
9
8
  import {
9
+ transformFieldType,
10
10
  validateFormSchema
11
11
  } from "@form-engine-ts/core";
12
12
  import { useCallback, useMemo } from "react";
13
13
  var DEFAULT_PREFIXES = { field: "q", option: "opt", page: "page" };
14
+ var CHOICE_TYPES = ["select", "radio", "multi-select"];
14
15
  function defaultIdFactory(kind, existingIds) {
15
16
  const prefix = DEFAULT_PREFIXES[kind];
16
17
  let id;
17
- do {
18
+ do
18
19
  id = `${prefix}_${globalThis.crypto.randomUUID().slice(0, 8)}`;
19
- } while (existingIds.has(id));
20
+ while (existingIds.has(id));
20
21
  return id;
21
22
  }
22
- function newField(type, id, optionId) {
23
+ function defaultCreateField(type, id) {
23
24
  const base = { id, title: "New question", required: false };
24
- if (type === "text" || type === "textarea") return { ...base, type };
25
- if (type === "number") return { ...base, type };
25
+ if (type === "text" || type === "textarea" || type === "number" || type === "checkbox") return { ...base, type };
26
26
  if (type === "rating") return { ...base, type, min: 1, max: 5 };
27
- if (type === "checkbox") return { ...base, type };
28
- return optionId === void 0 ? void 0 : { ...base, type, options: [{ id: optionId, label: "Option 1" }] };
27
+ return { ...base, type, options: [] };
28
+ }
29
+ function defaultCreateOption(field, id) {
30
+ return { id, label: `Option ${"options" in field ? field.options.length + 1 : 1}` };
31
+ }
32
+ function defaultCreatePage(id, questionIds) {
33
+ return { id, title: "New page", questionIds };
29
34
  }
30
35
  function move(items, sourceIndex, targetIndex) {
31
36
  if (sourceIndex < 0 || targetIndex < 0 || targetIndex >= items.length || sourceIndex === targetIndex)
@@ -36,334 +41,553 @@ function move(items, sourceIndex, targetIndex) {
36
41
  result.splice(targetIndex, 0, item);
37
42
  return result;
38
43
  }
39
- function addPolicyIssues(schema, policy, issues) {
40
- if (policy?.maxFields !== void 0 && schema.fields.length > policy.maxFields) {
41
- issues.push({
42
- path: "fields",
43
- code: "max_fields_exceeded",
44
- message: `At most ${policy.maxFields} fields are allowed.`
45
- });
46
- }
47
- schema.fields.forEach((field, index) => {
48
- if (policy?.allowedFieldTypes !== void 0 && !policy.allowedFieldTypes.includes(field.type)) {
49
- issues.push({
50
- path: `fields[${index}].type`,
51
- code: "disallowed_field_type",
52
- message: `Field type ${field.type} is not allowed.`
53
- });
54
- }
55
- if (policy?.maxTextLength !== void 0) {
56
- for (const [property, text] of [
57
- ["title", field.title],
58
- ["description", field.description]
59
- ]) {
60
- if (text !== void 0 && text.length > policy.maxTextLength) {
61
- issues.push({
62
- path: `fields[${index}].${property}`,
63
- code: "max_text_length_exceeded",
64
- message: `Text must be at most ${policy.maxTextLength} characters.`
65
- });
66
- }
67
- }
68
- }
69
- if (policy?.maxOptionsPerField !== void 0 && "options" in field && field.options.length > policy.maxOptionsPerField) {
70
- issues.push({
71
- path: `fields[${index}].options`,
72
- code: "max_options_exceeded",
73
- message: `At most ${policy.maxOptionsPerField} options are allowed.`
74
- });
75
- }
76
- });
77
- for (const locale of policy?.requiredLocales ?? []) {
78
- if (!(schema.supportedLocales ?? []).includes(locale)) {
79
- issues.push({
80
- path: "supportedLocales",
81
- code: "required_locale_missing",
82
- message: `Required locale ${locale} is missing.`
83
- });
84
- }
85
- if (locale === schema.defaultLocale) continue;
86
- const requiredTranslations = [
87
- { path: `translations.${locale}.title`, value: schema.translations?.[locale]?.title },
88
- ...schema.fields.map((field, index) => ({
89
- path: `fields[${index}].translations.${locale}.title`,
90
- value: field.translations?.[locale]?.title
91
- })),
92
- ...schema.fields.flatMap(
93
- (field, fieldIndex) => "options" in field ? field.options.map((option, optionIndex) => ({
94
- path: `fields[${fieldIndex}].options[${optionIndex}].translations.${locale}`,
95
- value: option.translations?.[locale]
96
- })) : []
97
- ),
98
- ...schema.pages?.flatMap(
99
- (page, pageIndex) => page.title === void 0 ? [] : [{ path: `pages[${pageIndex}].translations.${locale}.title`, value: page.translations?.[locale]?.title }]
100
- ) ?? []
101
- ];
102
- for (const translation of requiredTranslations) {
103
- if (translation.value === void 0 || translation.value.trim().length === 0) {
104
- issues.push({
105
- path: translation.path,
106
- code: "required_translation_missing",
107
- message: `A translation for required locale ${locale} is missing.`
108
- });
109
- }
110
- }
111
- }
44
+ function withoutDisplayCondition(field) {
45
+ const { displayCondition: _displayCondition, ...rest } = field;
46
+ return rest;
47
+ }
48
+ function removeLocalizedProperty(translations, locale, property) {
49
+ const current = translations?.[locale];
50
+ if (current === void 0) return translations;
51
+ const { [property]: _removed, ...remaining } = current;
52
+ return { ...translations, [locale]: remaining };
53
+ }
54
+ function setTranslationMetadata(node, locale, property, metadata, remove) {
55
+ const localeMetadata = node.translationMetadata?.[locale];
56
+ if (metadata === void 0 && !remove) return node;
57
+ const nextLocaleMetadata = remove ? Object.fromEntries(Object.entries(localeMetadata ?? {}).filter(([key]) => key !== property)) : { ...localeMetadata, [property]: metadata ?? {} };
58
+ return { ...node, translationMetadata: { ...node.translationMetadata, [locale]: nextLocaleMetadata } };
59
+ }
60
+ function failedId(kind, result) {
61
+ return { success: false, error: result.error ?? { type: "invalid_id", kind, id: "" } };
112
62
  }
113
63
  function useFormBuilder({
114
64
  schema,
115
65
  onChange,
116
66
  policy,
117
- idFactory = defaultIdFactory
67
+ idFactory = defaultIdFactory,
68
+ factories = {}
118
69
  }) {
119
70
  const createId = useCallback(
120
71
  (kind, existingIds) => {
121
- const id = idFactory(kind, existingIds).trim();
122
- return id.length > 0 && !existingIds.has(id) ? id : void 0;
72
+ const rawId = idFactory(kind, existingIds);
73
+ const id = rawId.trim();
74
+ return id.length > 0 && !existingIds.has(id) ? { id } : { error: { type: "invalid_id", kind, id: rawId } };
123
75
  },
124
76
  [idFactory]
125
77
  );
78
+ const textPolicyError = useCallback(
79
+ (text) => policy?.maxTextLength !== void 0 && text.length > policy.maxTextLength ? { success: false, error: { type: "max_text_length_exceeded", max: policy.maxTextLength } } : void 0,
80
+ [policy?.maxTextLength]
81
+ );
82
+ const updateField = useCallback(
83
+ (fieldId, updater) => {
84
+ const current = schema.fields.find((field) => field.id === fieldId);
85
+ if (current === void 0)
86
+ return { success: false, error: { type: "node_not_found", kind: "field", id: fieldId } };
87
+ const updated = updater(current);
88
+ if (updated.id !== fieldId)
89
+ return { success: false, error: { type: "invalid_id", kind: "field", id: updated.id } };
90
+ if (policy?.allowedFieldTypes !== void 0 && !policy.allowedFieldTypes.includes(updated.type))
91
+ return { success: false, error: { type: "disallowed_field_type", fieldType: updated.type } };
92
+ for (const text of [updated.title, updated.description]) {
93
+ if (text !== void 0) {
94
+ const error = textPolicyError(text);
95
+ if (error !== void 0) return error;
96
+ }
97
+ }
98
+ onChange({ ...schema, fields: schema.fields.map((field) => field.id === fieldId ? updated : field) });
99
+ return { success: true };
100
+ },
101
+ [onChange, policy?.allowedFieldTypes, schema, textPolicyError]
102
+ );
103
+ const updateOption = useCallback(
104
+ (fieldId, optionId, updater) => {
105
+ const field = schema.fields.find((candidate) => candidate.id === fieldId);
106
+ if (field === void 0) return { success: false, error: { type: "node_not_found", kind: "field", id: fieldId } };
107
+ if (!("options" in field))
108
+ return { success: false, error: { type: "invalid_operation", message: `Field ${fieldId} has no options.` } };
109
+ const current = field.options.find((option) => option.id === optionId);
110
+ if (current === void 0)
111
+ return { success: false, error: { type: "node_not_found", kind: "option", id: optionId } };
112
+ const updated = updater(current);
113
+ if (updated.id !== optionId)
114
+ return { success: false, error: { type: "invalid_id", kind: "option", id: updated.id } };
115
+ const error = textPolicyError(updated.label);
116
+ if (error !== void 0) return error;
117
+ onChange({
118
+ ...schema,
119
+ fields: schema.fields.map(
120
+ (candidate) => candidate.id === fieldId && "options" in candidate ? {
121
+ ...candidate,
122
+ options: candidate.options.map((option) => option.id === optionId ? updated : option)
123
+ } : candidate
124
+ )
125
+ });
126
+ return { success: true };
127
+ },
128
+ [onChange, schema, textPolicyError]
129
+ );
130
+ const updatePage = useCallback(
131
+ (pageId, updater) => {
132
+ const page = schema.pages?.find((candidate) => candidate.id === pageId);
133
+ if (page === void 0) return { success: false, error: { type: "node_not_found", kind: "page", id: pageId } };
134
+ const pages = schema.pages;
135
+ if (pages === void 0) return { success: false, error: { type: "node_not_found", kind: "page", id: pageId } };
136
+ const updated = updater(page);
137
+ if (updated.id !== pageId) return { success: false, error: { type: "invalid_id", kind: "page", id: updated.id } };
138
+ onChange({ ...schema, pages: pages.map((candidate) => candidate.id === pageId ? updated : candidate) });
139
+ return { success: true };
140
+ },
141
+ [onChange, schema]
142
+ );
126
143
  const addField = useCallback(
127
144
  (type, pageId) => {
128
- if (policy?.allowedFieldTypes !== void 0 && !policy.allowedFieldTypes.includes(type)) {
145
+ if (policy?.allowedFieldTypes !== void 0 && !policy.allowedFieldTypes.includes(type))
129
146
  return { success: false, error: { type: "disallowed_field_type", fieldType: type } };
130
- }
131
- if (policy?.maxFields !== void 0 && schema.fields.length >= policy.maxFields) {
147
+ if (policy?.maxFields !== void 0 && schema.fields.length >= policy.maxFields)
132
148
  return { success: false, error: { type: "max_fields_exceeded", max: policy.maxFields } };
133
- }
134
- const fieldId = createId("field", new Set(schema.fields.map((field2) => field2.id)));
135
- const needsOption = ["select", "radio", "multi-select"].includes(type);
136
- const optionId = needsOption ? createId(
137
- "option",
138
- new Set(
139
- schema.fields.flatMap((field2) => "options" in field2 ? field2.options.map((option) => option.id) : [])
140
- )
141
- ) : void 0;
142
- if (fieldId === void 0 || needsOption && optionId === void 0) {
143
- return {
144
- success: false,
145
- error: { type: "invalid_operation", message: "idFactory returned a duplicate or empty ID." }
146
- };
147
- }
148
- const field = newField(type, fieldId, optionId);
149
- if (field === void 0) {
150
- return { success: false, error: { type: "invalid_operation", message: "Could not create the field." } };
149
+ if (pageId !== void 0 && !schema.pages?.some((page) => page.id === pageId))
150
+ return { success: false, error: { type: "node_not_found", kind: "page", id: pageId } };
151
+ const fieldIds = new Set(schema.fields.map((field2) => field2.id));
152
+ const fieldId = createId("field", fieldIds);
153
+ if (fieldId.id === void 0) return failedId("field", fieldId);
154
+ let field = (factories.createField ?? defaultCreateField)(type, fieldId.id);
155
+ if (field.id !== fieldId.id || field.type !== type || fieldIds.has(field.id))
156
+ return { success: false, error: { type: "invalid_id", kind: "field", id: field.id } };
157
+ if (CHOICE_TYPES.includes(type) && "options" in field && field.options.length === 0) {
158
+ const optionIds = new Set(
159
+ schema.fields.flatMap((item) => "options" in item ? item.options.map((option2) => option2.id) : [])
160
+ );
161
+ const optionId = createId("option", optionIds);
162
+ if (optionId.id === void 0) return failedId("option", optionId);
163
+ const option = (factories.createOption ?? defaultCreateOption)(field, optionId.id);
164
+ if (option.id !== optionId.id || optionIds.has(option.id))
165
+ return { success: false, error: { type: "invalid_id", kind: "option", id: option.id } };
166
+ field = { ...field, options: [option] };
151
167
  }
152
168
  const pages = schema.pages?.map((page, index) => ({
153
169
  ...page,
154
- questionIds: page.id === pageId || pageId === void 0 && index === (schema.pages?.length ?? 0) - 1 ? [...page.questionIds, fieldId] : page.questionIds
170
+ questionIds: page.id === pageId || pageId === void 0 && index === (schema.pages?.length ?? 0) - 1 ? [...page.questionIds, field.id] : page.questionIds
155
171
  }));
156
- if (schema.pages !== void 0 && !schema.pages.some((page) => page.id === pageId) && pageId !== void 0) {
157
- return { success: false, error: { type: "invalid_operation", message: `Unknown page: ${pageId}` } };
158
- }
159
172
  onChange({ ...schema, fields: [...schema.fields, field], ...pages === void 0 ? {} : { pages } });
160
173
  return { success: true };
161
174
  },
162
- [createId, onChange, policy, schema]
175
+ [createId, factories, onChange, policy, schema]
163
176
  );
164
177
  const removeField = useCallback(
165
178
  (fieldId) => {
166
- if (schema.fields.length <= 1 || !schema.fields.some((field) => field.id === fieldId)) return;
167
- const fields = schema.fields.filter((field) => field.id !== fieldId).map(
168
- (field) => field.displayCondition?.questionId === fieldId ? (({ displayCondition: _condition, ...candidate }) => candidate)(field) : field
169
- );
170
- const remainingPages = schema.pages?.map((page) => ({ ...page, questionIds: page.questionIds.filter((id) => id !== fieldId) })).filter((page) => page.questionIds.length > 0);
171
- if (schema.pages !== void 0 && remainingPages?.length === 0) {
172
- const { pages: _pages, ...singlePageSchema } = schema;
173
- onChange({ ...singlePageSchema, fields });
174
- } else {
175
- onChange({ ...schema, fields, ...remainingPages === void 0 ? {} : { pages: remainingPages } });
176
- }
179
+ if (!schema.fields.some((field) => field.id === fieldId))
180
+ return { success: false, error: { type: "node_not_found", kind: "field", id: fieldId } };
181
+ if (schema.fields.length <= 1)
182
+ return { success: false, error: { type: "invalid_operation", message: "A form must contain one field." } };
183
+ const fields = schema.fields.filter((field) => field.id !== fieldId).map((field) => field.displayCondition?.questionId === fieldId ? withoutDisplayCondition(field) : field);
184
+ const pages = schema.pages?.map((page) => ({ ...page, questionIds: page.questionIds.filter((id) => id !== fieldId) })).filter((page) => page.questionIds.length > 0);
185
+ if (schema.pages !== void 0 && pages?.length === 0) {
186
+ const { pages: _pages, ...single } = schema;
187
+ onChange({ ...single, fields });
188
+ } else onChange({ ...schema, fields, ...pages === void 0 ? {} : { pages } });
189
+ return { success: true };
177
190
  },
178
191
  [onChange, schema]
179
192
  );
180
193
  const moveField = useCallback(
181
194
  (fieldId, targetIndex) => {
182
- const fields = move(
183
- schema.fields,
184
- schema.fields.findIndex((field) => field.id === fieldId),
185
- targetIndex
186
- );
187
- if (fields !== void 0) {
188
- const indexById = new Map(fields.map((field, index) => [field.id, index]));
189
- const safeFields = fields.map((field, index) => {
190
- const sourceIndex = field.displayCondition === void 0 ? void 0 : indexById.get(field.displayCondition.questionId);
191
- if (field.displayCondition === void 0 || sourceIndex !== void 0 && sourceIndex < index) return field;
192
- const { displayCondition: _condition, ...withoutCondition } = field;
193
- return withoutCondition;
194
- });
195
- onChange({ ...schema, fields: safeFields });
196
- }
195
+ const sourceIndex = schema.fields.findIndex((field) => field.id === fieldId);
196
+ if (sourceIndex < 0) return { success: false, error: { type: "node_not_found", kind: "field", id: fieldId } };
197
+ const fields = move(schema.fields, sourceIndex, targetIndex);
198
+ if (fields === void 0)
199
+ return { success: false, error: { type: "invalid_operation", message: "Invalid field position." } };
200
+ const indexById = new Map(fields.map((field, index) => [field.id, index]));
201
+ onChange({
202
+ ...schema,
203
+ fields: fields.map((field, index) => {
204
+ const source = field.displayCondition?.questionId;
205
+ return source === void 0 || (indexById.get(source) ?? index) < index ? field : withoutDisplayCondition(field);
206
+ })
207
+ });
208
+ return { success: true };
197
209
  },
198
210
  [onChange, schema]
199
211
  );
200
- const updateField = useCallback(
201
- (fieldId, updater) => {
202
- const current = schema.fields.find((field) => field.id === fieldId);
203
- if (current === void 0) return;
204
- const updated = updater(current);
205
- if (updated.id !== fieldId) return;
206
- if (policy?.allowedFieldTypes !== void 0 && !policy.allowedFieldTypes.includes(updated.type)) return;
207
- const maxTextLength = policy?.maxTextLength;
208
- if (maxTextLength !== void 0 && [updated.title, updated.description].some((text) => text !== void 0 && text.length > maxTextLength)) {
209
- return;
210
- }
211
- onChange({ ...schema, fields: schema.fields.map((field) => field.id === fieldId ? updated : field) });
212
- },
213
- [onChange, policy, schema]
214
- );
215
212
  const addOption = useCallback(
216
213
  (fieldId) => {
217
214
  const field = schema.fields.find((candidate) => candidate.id === fieldId);
218
- if (field === void 0 || !("options" in field)) {
215
+ if (field === void 0) return { success: false, error: { type: "node_not_found", kind: "field", id: fieldId } };
216
+ if (!("options" in field))
219
217
  return { success: false, error: { type: "invalid_operation", message: `Field ${fieldId} has no options.` } };
220
- }
221
- if (policy?.maxOptionsPerField !== void 0 && field.options.length >= policy.maxOptionsPerField) {
218
+ if (policy?.maxOptionsPerField !== void 0 && field.options.length >= policy.maxOptionsPerField)
222
219
  return { success: false, error: { type: "max_options_exceeded", max: policy.maxOptionsPerField } };
223
- }
224
- const existingIds = new Set(
225
- schema.fields.flatMap(
226
- (candidate) => "options" in candidate ? candidate.options.map((option2) => option2.id) : []
227
- )
220
+ const ids = new Set(
221
+ schema.fields.flatMap((item) => "options" in item ? item.options.map((option2) => option2.id) : [])
228
222
  );
229
- const optionId = createId("option", existingIds);
230
- if (optionId === void 0) {
231
- return {
232
- success: false,
233
- error: { type: "invalid_operation", message: "idFactory returned a duplicate or empty ID." }
234
- };
235
- }
236
- const option = { id: optionId, label: `Option ${field.options.length + 1}` };
223
+ const id = createId("option", ids);
224
+ if (id.id === void 0) return failedId("option", id);
225
+ const option = (factories.createOption ?? defaultCreateOption)(field, id.id);
226
+ if (option.id !== id.id || ids.has(option.id))
227
+ return { success: false, error: { type: "invalid_id", kind: "option", id: option.id } };
237
228
  onChange({
238
229
  ...schema,
239
230
  fields: schema.fields.map(
240
- (candidate) => candidate.id === fieldId && "options" in candidate ? { ...candidate, options: [...candidate.options, option] } : candidate
231
+ (item) => item.id === fieldId && "options" in item ? { ...item, options: [...item.options, option] } : item
241
232
  )
242
233
  });
243
234
  return { success: true };
244
235
  },
245
- [createId, onChange, policy, schema]
236
+ [createId, factories.createOption, onChange, policy?.maxOptionsPerField, schema]
246
237
  );
247
238
  const removeOption = useCallback(
248
239
  (fieldId, optionId) => {
249
- const field = schema.fields.find((candidate) => candidate.id === fieldId);
250
- if (field === void 0 || !("options" in field) || field.options.length <= 1) return;
240
+ const field = schema.fields.find((item) => item.id === fieldId);
241
+ if (field === void 0) return { success: false, error: { type: "node_not_found", kind: "field", id: fieldId } };
242
+ if (!("options" in field) || !field.options.some((option) => option.id === optionId))
243
+ return { success: false, error: { type: "node_not_found", kind: "option", id: optionId } };
244
+ if (field.options.length <= 1)
245
+ return { success: false, error: { type: "invalid_operation", message: "A choice field needs one option." } };
251
246
  onChange({
252
247
  ...schema,
253
248
  fields: schema.fields.map(
254
- (candidate) => candidate.id === fieldId && "options" in candidate ? { ...candidate, options: candidate.options.filter((option) => option.id !== optionId) } : candidate
249
+ (item) => item.id === fieldId && "options" in item ? { ...item, options: item.options.filter((option) => option.id !== optionId) } : item
255
250
  )
256
251
  });
252
+ return { success: true };
257
253
  },
258
254
  [onChange, schema]
259
255
  );
260
256
  const moveOption = useCallback(
261
257
  (fieldId, optionId, targetIndex) => {
262
- const field = schema.fields.find((candidate) => candidate.id === fieldId);
263
- if (field === void 0 || !("options" in field)) return;
258
+ const field = schema.fields.find((item) => item.id === fieldId);
259
+ if (field === void 0) return { success: false, error: { type: "node_not_found", kind: "field", id: fieldId } };
260
+ if (!("options" in field))
261
+ return { success: false, error: { type: "invalid_operation", message: `Field ${fieldId} has no options.` } };
264
262
  const options = move(
265
263
  field.options,
266
264
  field.options.findIndex((option) => option.id === optionId),
267
265
  targetIndex
268
266
  );
269
- if (options === void 0) return;
267
+ if (options === void 0)
268
+ return { success: false, error: { type: "invalid_operation", message: "Invalid option position." } };
270
269
  onChange({
271
270
  ...schema,
272
271
  fields: schema.fields.map(
273
- (candidate) => candidate.id === fieldId && "options" in candidate ? { ...candidate, options } : candidate
272
+ (item) => item.id === fieldId && "options" in item ? { ...item, options } : item
274
273
  )
275
274
  });
275
+ return { success: true };
276
276
  },
277
277
  [onChange, schema]
278
278
  );
279
+ const changeFieldType = useCallback(
280
+ (fieldId, type) => {
281
+ const field = schema.fields.find((item) => item.id === fieldId);
282
+ if (field === void 0) return { success: false, error: { type: "node_not_found", kind: "field", id: fieldId } };
283
+ if (policy?.allowedFieldTypes !== void 0 && !policy.allowedFieldTypes.includes(type))
284
+ return { success: false, error: { type: "disallowed_field_type", fieldType: type } };
285
+ let transformed = transformFieldType(field, type);
286
+ if (CHOICE_TYPES.includes(type) && !("options" in field) && "options" in transformed) {
287
+ const ids = new Set(
288
+ schema.fields.flatMap((item) => "options" in item ? item.options.map((option2) => option2.id) : [])
289
+ );
290
+ const id = createId("option", ids);
291
+ if (id.id === void 0) return failedId("option", id);
292
+ const option = (factories.createOption ?? defaultCreateOption)(transformed, id.id);
293
+ if (option.id !== id.id || ids.has(option.id))
294
+ return { success: false, error: { type: "invalid_id", kind: "option", id: option.id } };
295
+ transformed = { ...transformed, options: [option] };
296
+ }
297
+ onChange({ ...schema, fields: schema.fields.map((item) => item.id === fieldId ? transformed : item) });
298
+ return { success: true };
299
+ },
300
+ [createId, factories.createOption, onChange, policy?.allowedFieldTypes, schema]
301
+ );
279
302
  const addPage = useCallback(
280
303
  (questionId) => {
281
- const existingIds = new Set(schema.pages?.map((page) => page.id) ?? []);
282
- const pageId = createId("page", existingIds);
283
- if (pageId === void 0) return;
284
- if (schema.pages === void 0) {
285
- onChange({ ...schema, pages: [{ id: pageId, questionIds: schema.fields.map((field) => field.id) }] });
286
- return;
287
- }
288
- const movableQuestionId = questionId ?? schema.pages.find((page) => page.questionIds.length > 1)?.questionIds.at(-1);
289
- if (movableQuestionId === void 0) return;
290
- const sourcePage = schema.pages.find((page) => page.questionIds.includes(movableQuestionId));
291
- if (sourcePage === void 0 || sourcePage.questionIds.length <= 1) return;
292
- const pages = [
293
- ...schema.pages.map((page) => ({
294
- ...page,
295
- questionIds: page.questionIds.filter((id) => id !== movableQuestionId)
304
+ const ids = new Set(schema.pages?.map((page2) => page2.id) ?? []);
305
+ const id = createId("page", ids);
306
+ if (id.id === void 0) return failedId("page", id);
307
+ const questionIds = schema.pages === void 0 ? schema.fields.map((field) => field.id) : [questionId ?? schema.pages.find((page2) => page2.questionIds.length > 1)?.questionIds.at(-1)].filter(
308
+ (value) => value !== void 0
309
+ );
310
+ if (questionIds.length === 0)
311
+ return { success: false, error: { type: "invalid_operation", message: "No question can be moved." } };
312
+ const source = schema.pages?.find((page2) => page2.questionIds.includes(questionIds[0] ?? ""));
313
+ if (schema.pages !== void 0 && (source === void 0 || source.questionIds.length <= 1))
314
+ return { success: false, error: { type: "invalid_operation", message: "A page cannot be left empty." } };
315
+ const page = (factories.createPage ?? defaultCreatePage)(id.id, [...questionIds]);
316
+ if (page.id !== id.id || ids.has(page.id))
317
+ return { success: false, error: { type: "invalid_id", kind: "page", id: page.id } };
318
+ const pages = schema.pages === void 0 ? [page] : [
319
+ ...schema.pages.map((item) => ({
320
+ ...item,
321
+ questionIds: item.questionIds.filter((fieldId) => !questionIds.includes(fieldId))
296
322
  })),
297
- { id: pageId, questionIds: [movableQuestionId] }
323
+ page
298
324
  ];
299
325
  onChange({ ...schema, pages });
326
+ return { success: true };
300
327
  },
301
- [createId, onChange, schema]
328
+ [createId, factories.createPage, onChange, schema]
302
329
  );
303
330
  const removePage = useCallback(
304
331
  (pageId) => {
305
- if (schema.pages === void 0) return;
306
- const index = schema.pages.findIndex((page) => page.id === pageId);
307
- const removed = schema.pages[index];
308
- if (removed === void 0) return;
309
- if (schema.pages.length === 1) {
310
- const { pages: _pages, ...singlePage } = schema;
311
- onChange(singlePage);
312
- return;
332
+ const index = schema.pages?.findIndex((page) => page.id === pageId) ?? -1;
333
+ const removed = schema.pages?.[index];
334
+ if (removed === void 0) return { success: false, error: { type: "node_not_found", kind: "page", id: pageId } };
335
+ const currentPages = schema.pages;
336
+ if (currentPages === void 0)
337
+ return { success: false, error: { type: "node_not_found", kind: "page", id: pageId } };
338
+ if ((schema.pages?.length ?? 0) === 1) {
339
+ const { pages: _pages, ...single } = schema;
340
+ onChange(single);
341
+ return { success: true };
313
342
  }
314
343
  const targetIndex = index === 0 ? 1 : index - 1;
315
344
  onChange({
316
345
  ...schema,
317
- pages: schema.pages.map(
346
+ pages: currentPages.map(
318
347
  (page, pageIndex) => pageIndex === targetIndex ? { ...page, questionIds: [...page.questionIds, ...removed.questionIds] } : page
319
348
  ).filter((page) => page.id !== pageId)
320
349
  });
350
+ return { success: true };
321
351
  },
322
352
  [onChange, schema]
323
353
  );
324
- const setLocaleTranslation = useCallback(
325
- (locale, target, property, text) => {
326
- if (locale.trim().length === 0 || policy?.maxTextLength !== void 0 && text.length > policy.maxTextLength)
327
- return;
328
- const supportedLocales = [.../* @__PURE__ */ new Set([...schema.supportedLocales ?? [], locale])];
329
- if (target === "form" && ["title", "description", "completionMessage"].includes(property)) {
330
- onChange({
331
- ...schema,
332
- supportedLocales,
333
- translations: { ...schema.translations, [locale]: { ...schema.translations?.[locale], [property]: text } }
354
+ const movePage = useCallback(
355
+ (pageId, targetIndex) => {
356
+ const sourceIndex = schema.pages?.findIndex((page) => page.id === pageId) ?? -1;
357
+ if (sourceIndex < 0) return { success: false, error: { type: "node_not_found", kind: "page", id: pageId } };
358
+ const pages = move(schema.pages ?? [], sourceIndex, targetIndex);
359
+ if (pages === void 0)
360
+ return { success: false, error: { type: "invalid_operation", message: "Invalid page position." } };
361
+ const assignment = new Map(pages.flatMap((page, index) => page.questionIds.map((id) => [id, index])));
362
+ onChange({
363
+ ...schema,
364
+ pages: pages.map((page, index) => {
365
+ const source = page.displayCondition?.questionId;
366
+ if (source === void 0 || (assignment.get(source) ?? index) < index) return page;
367
+ const { displayCondition: _condition, ...rest } = page;
368
+ return rest;
369
+ })
370
+ });
371
+ return { success: true };
372
+ },
373
+ [onChange, schema]
374
+ );
375
+ const assignFieldToPage = useCallback(
376
+ (fieldId, pageId) => {
377
+ if (!schema.fields.some((field) => field.id === fieldId))
378
+ return { success: false, error: { type: "node_not_found", kind: "field", id: fieldId } };
379
+ if (pageId === null) {
380
+ if (schema.pages !== void 0) {
381
+ const { pages: _pages, ...single } = schema;
382
+ onChange(single);
383
+ }
384
+ return { success: true };
385
+ }
386
+ if (!schema.pages?.some((page) => page.id === pageId))
387
+ return { success: false, error: { type: "node_not_found", kind: "page", id: pageId } };
388
+ const pages = schema.pages.map((page) => ({
389
+ ...page,
390
+ questionIds: page.id === pageId ? schema.fields.filter((field) => page.questionIds.includes(field.id) || field.id === fieldId).map((field) => field.id) : page.questionIds.filter((id) => id !== fieldId)
391
+ })).filter((page) => page.questionIds.length > 0);
392
+ onChange({ ...schema, pages });
393
+ return { success: true };
394
+ },
395
+ [onChange, schema]
396
+ );
397
+ const setDisplayCondition = useCallback(
398
+ (fieldId, condition) => {
399
+ const index = schema.fields.findIndex((field) => field.id === fieldId);
400
+ if (index < 0) return { success: false, error: { type: "node_not_found", kind: "field", id: fieldId } };
401
+ if (condition !== void 0) {
402
+ const sourceIndex = schema.fields.findIndex((field) => field.id === condition.questionId);
403
+ if (sourceIndex < 0 || sourceIndex >= index)
404
+ return {
405
+ success: false,
406
+ error: { type: "invalid_operation", message: "Conditions require an earlier field." }
407
+ };
408
+ }
409
+ onChange({
410
+ ...schema,
411
+ fields: schema.fields.map(
412
+ (field) => field.id !== fieldId ? field : condition === void 0 ? withoutDisplayCondition(field) : { ...field, displayCondition: condition }
413
+ )
414
+ });
415
+ return { success: true };
416
+ },
417
+ [onChange, schema]
418
+ );
419
+ const setSourceText = useCallback(
420
+ (target, property, text) => {
421
+ const error = textPolicyError(text);
422
+ if (error !== void 0) return error;
423
+ if (target.kind === "form") {
424
+ if (!["title", "description", "completionMessage"].includes(property))
425
+ return {
426
+ success: false,
427
+ error: { type: "invalid_operation", message: `Unsupported form property: ${property}` }
428
+ };
429
+ if (property === "title") onChange({ ...schema, title: text });
430
+ else if (property === "description") {
431
+ const { description: _description, ...withoutDescription } = schema;
432
+ onChange(text.length === 0 ? withoutDescription : { ...schema, description: text });
433
+ } else {
434
+ const { completionMessage: _completionMessage, ...withoutCompletionMessage } = schema;
435
+ onChange(text.length === 0 ? withoutCompletionMessage : { ...schema, completionMessage: text });
436
+ }
437
+ return { success: true };
438
+ }
439
+ if (target.id === void 0)
440
+ return { success: false, error: { type: "invalid_operation", message: "A target ID is required." } };
441
+ if (target.kind === "field") {
442
+ if (!["title", "description"].includes(property))
443
+ return {
444
+ success: false,
445
+ error: { type: "invalid_operation", message: `Unsupported field property: ${property}` }
446
+ };
447
+ return updateField(target.id, (field) => {
448
+ if (property === "title") return { ...field, title: text };
449
+ const { description: _description, ...withoutDescription } = field;
450
+ return text.length === 0 ? withoutDescription : { ...field, description: text };
334
451
  });
335
- return;
336
452
  }
337
- const fields = schema.fields.map((field) => {
338
- if (field.id === target && ["title", "description"].includes(property)) {
453
+ if (target.kind === "option") {
454
+ if (property !== "label")
455
+ return {
456
+ success: false,
457
+ error: { type: "invalid_operation", message: `Unsupported option property: ${property}` }
458
+ };
459
+ for (const field of schema.fields)
460
+ if ("options" in field && field.options.some((option) => option.id === target.id))
461
+ return updateOption(field.id, target.id, (option) => ({ ...option, label: text }));
462
+ return { success: false, error: { type: "node_not_found", kind: "option", id: target.id } };
463
+ }
464
+ if (!["title", "description"].includes(property))
465
+ return {
466
+ success: false,
467
+ error: { type: "invalid_operation", message: `Unsupported page property: ${property}` }
468
+ };
469
+ return updatePage(target.id, (page) => {
470
+ if (property === "title") {
471
+ const { title: _title, ...withoutTitle } = page;
472
+ return text.length === 0 ? withoutTitle : { ...page, title: text };
473
+ }
474
+ const { description: _description, ...withoutDescription } = page;
475
+ return text.length === 0 ? withoutDescription : { ...page, description: text };
476
+ });
477
+ },
478
+ [onChange, schema, textPolicyError, updateField, updateOption, updatePage]
479
+ );
480
+ const setLocaleTranslation = useCallback(
481
+ (locale, target, property, text, options = {}) => {
482
+ const normalized = locale.trim();
483
+ if (normalized.length === 0)
484
+ return { success: false, error: { type: "invalid_operation", message: "Locale must not be empty." } };
485
+ const error = textPolicyError(text);
486
+ if (error !== void 0) return error;
487
+ const supportedLocales = [.../* @__PURE__ */ new Set([...schema.supportedLocales ?? [], normalized])];
488
+ const remove = text.length === 0;
489
+ if (target.kind === "form") {
490
+ if (!["title", "description", "completionMessage"].includes(property))
339
491
  return {
340
- ...field,
341
- translations: { ...field.translations, [locale]: { ...field.translations?.[locale], [property]: text } }
492
+ success: false,
493
+ error: { type: "invalid_operation", message: `Unsupported form property: ${property}` }
342
494
  };
495
+ const key = property;
496
+ const translations = remove ? removeLocalizedProperty(schema.translations, normalized, key) : { ...schema.translations, [normalized]: { ...schema.translations?.[normalized], [key]: text } };
497
+ onChange(
498
+ setTranslationMetadata(
499
+ { ...schema, supportedLocales, ...translations === void 0 ? {} : { translations } },
500
+ normalized,
501
+ property,
502
+ options.metadata,
503
+ remove
504
+ )
505
+ );
506
+ return { success: true };
507
+ }
508
+ if (target.id === void 0)
509
+ return { success: false, error: { type: "invalid_operation", message: "A target ID is required." } };
510
+ let found = false;
511
+ const fields = schema.fields.map((field) => {
512
+ if (target.kind === "field" && field.id === target.id && ["title", "description"].includes(property)) {
513
+ found = true;
514
+ const key = property;
515
+ const translations = remove ? removeLocalizedProperty(field.translations, normalized, key) : { ...field.translations, [normalized]: { ...field.translations?.[normalized], [key]: text } };
516
+ return setTranslationMetadata(
517
+ { ...field, ...translations === void 0 ? {} : { translations } },
518
+ normalized,
519
+ property,
520
+ options.metadata,
521
+ remove
522
+ );
343
523
  }
344
- if (!("options" in field) || property !== "label") return field;
524
+ if (target.kind !== "option" || property !== "label" || !("options" in field)) return field;
345
525
  return {
346
526
  ...field,
347
- options: field.options.map(
348
- (option) => option.id === target ? { ...option, translations: { ...option.translations, [locale]: text } } : option
349
- )
527
+ options: field.options.map((option) => {
528
+ if (option.id !== target.id) return option;
529
+ found = true;
530
+ const translations = remove ? Object.fromEntries(Object.entries(option.translations ?? {}).filter(([key]) => key !== normalized)) : { ...option.translations, [normalized]: text };
531
+ return setTranslationMetadata({ ...option, translations }, normalized, property, options.metadata, remove);
532
+ })
350
533
  };
351
534
  });
352
- const pages = schema.pages?.map(
353
- (page) => page.id === target && ["title", "description"].includes(property) ? {
354
- ...page,
355
- translations: { ...page.translations, [locale]: { ...page.translations?.[locale], [property]: text } }
356
- } : page
357
- );
535
+ const pages = schema.pages?.map((page) => {
536
+ if (target.kind !== "page" || page.id !== target.id || !["title", "description"].includes(property))
537
+ return page;
538
+ found = true;
539
+ const key = property;
540
+ const translations = remove ? removeLocalizedProperty(page.translations, normalized, key) : { ...page.translations, [normalized]: { ...page.translations?.[normalized], [key]: text } };
541
+ return setTranslationMetadata(
542
+ { ...page, ...translations === void 0 ? {} : { translations } },
543
+ normalized,
544
+ property,
545
+ options.metadata,
546
+ remove
547
+ );
548
+ });
549
+ if (!found) return { success: false, error: { type: "node_not_found", kind: target.kind, id: target.id } };
358
550
  onChange({ ...schema, supportedLocales, fields, ...pages === void 0 ? {} : { pages } });
551
+ return { success: true };
359
552
  },
360
- [onChange, policy, schema]
553
+ [onChange, schema, textPolicyError]
554
+ );
555
+ const addLocale = useCallback(
556
+ (locale) => {
557
+ const normalized = locale.trim();
558
+ if (normalized.length === 0)
559
+ return { success: false, error: { type: "invalid_operation", message: "Locale must not be empty." } };
560
+ onChange({
561
+ ...schema,
562
+ supportedLocales: [
563
+ .../* @__PURE__ */ new Set([
564
+ ...schema.defaultLocale === void 0 ? [] : [schema.defaultLocale],
565
+ ...schema.supportedLocales ?? [],
566
+ normalized
567
+ ])
568
+ ]
569
+ });
570
+ return { success: true };
571
+ },
572
+ [onChange, schema]
573
+ );
574
+ const setDefaultLocale = useCallback(
575
+ (locale) => {
576
+ const normalized = locale.trim();
577
+ if (normalized.length === 0)
578
+ return { success: false, error: { type: "invalid_operation", message: "Locale must not be empty." } };
579
+ onChange({
580
+ ...schema,
581
+ defaultLocale: normalized,
582
+ supportedLocales: [.../* @__PURE__ */ new Set([normalized, ...schema.supportedLocales ?? []])]
583
+ });
584
+ return { success: true };
585
+ },
586
+ [onChange, schema]
361
587
  );
362
588
  const validationIssues = useMemo(() => {
363
- const result = validateFormSchema(schema);
364
- const issues = result.valid ? [] : [...result.issues];
365
- addPolicyIssues(schema, policy, issues);
366
- return issues;
589
+ const result = validateFormSchema(schema, policy === void 0 ? {} : { policy });
590
+ return result.valid ? [] : result.issues;
367
591
  }, [policy, schema]);
368
592
  return {
369
593
  schema,
@@ -371,12 +595,21 @@ function useFormBuilder({
371
595
  removeField,
372
596
  moveField,
373
597
  updateField,
598
+ changeFieldType,
374
599
  addOption,
600
+ updateOption,
375
601
  removeOption,
376
602
  moveOption,
377
603
  addPage,
604
+ updatePage,
378
605
  removePage,
606
+ movePage,
607
+ assignFieldToPage,
608
+ setDisplayCondition,
609
+ setSourceText,
379
610
  setLocaleTranslation,
611
+ addLocale,
612
+ setDefaultLocale,
380
613
  validationIssues
381
614
  };
382
615
  }
@@ -402,6 +635,7 @@ var BUILDER_DEFAULTS = {
402
635
  "builder.questionTitle": "\u8CEA\u554F\u6587 / Question Title",
403
636
  "builder.questionTitlePlaceholder": "Example: Tell us what we could improve",
404
637
  "builder.newQuestionTitle": "New question",
638
+ "builder.completionMessage": "Completion message",
405
639
  "builder.type": "Type",
406
640
  "builder.required": "Required",
407
641
  "builder.minimum": "Minimum",
@@ -460,33 +694,6 @@ function fieldTypeKey(type) {
460
694
  function operatorKey(operator) {
461
695
  return `builder.operator.${operator}`;
462
696
  }
463
- function createUniqueId(prefix, existingIds) {
464
- let id;
465
- do {
466
- id = `${prefix}_${globalThis.crypto.randomUUID().slice(0, 8)}`;
467
- } while (existingIds.has(id));
468
- return id;
469
- }
470
- function baseField(field, type) {
471
- return {
472
- id: field.id,
473
- type,
474
- title: field.title,
475
- ...field.description === void 0 ? {} : { description: field.description },
476
- ...field.translationKey === void 0 ? {} : { translationKey: field.translationKey },
477
- required: field.required,
478
- ...field.displayCondition === void 0 ? {} : { displayCondition: field.displayCondition }
479
- };
480
- }
481
- function normalizeField(field, type, newOptionLabel) {
482
- const base = baseField(field, type);
483
- if (type === "text" || type === "textarea") return { ...base, type };
484
- if (type === "number") return { ...base, type };
485
- if (type === "rating") return { ...base, type, min: 1, max: 5 };
486
- if (type === "checkbox") return { ...base, type };
487
- const options = "options" in field && field.options.length > 0 ? field.options : [{ id: createUniqueId("opt", /* @__PURE__ */ new Set()), label: newOptionLabel }];
488
- return { ...base, type, options };
489
- }
490
697
  function defaultConditionValue(field) {
491
698
  if (field.type === "checkbox") return true;
492
699
  if (field.type === "number" || field.type === "rating") return field.min ?? 1;
@@ -500,30 +707,6 @@ function conditionOperators(field) {
500
707
  }
501
708
  return ["equals", "not_equals", "not_empty"];
502
709
  }
503
- function withoutDisplayCondition(field) {
504
- const { displayCondition: _displayCondition, ...rest } = field;
505
- return rest;
506
- }
507
- function sanitizeBuilderSchema(schema) {
508
- const sanitized = sanitizeSchema(schema);
509
- const indexById = new Map(sanitized.fields.map((field, index) => [field.id, index]));
510
- const fields = sanitized.fields.map((field, index) => {
511
- const sourceId = field.displayCondition?.questionId;
512
- if (sourceId === void 0) return field;
513
- const sourceIndex = indexById.get(sourceId);
514
- return sourceIndex !== void 0 && sourceIndex < index ? field : withoutDisplayCondition(field);
515
- });
516
- return {
517
- ...sanitized,
518
- fields,
519
- ...sanitized.pages === void 0 ? {} : {
520
- pages: sanitized.pages.map((page) => ({
521
- ...page,
522
- questionIds: fields.filter((field) => page.questionIds.includes(field.id)).map((field) => field.id)
523
- }))
524
- }
525
- };
526
- }
527
710
  function conditionWithValue(questionId, operator, value) {
528
711
  return operator === "not_empty" ? { questionId, operator } : { questionId, operator, value };
529
712
  }
@@ -571,14 +754,18 @@ function FormBuilder({
571
754
  locale = "en",
572
755
  translator,
573
756
  translationAdapter,
757
+ translationOptions,
758
+ onTranslationReport,
574
759
  policy,
575
- idFactory
760
+ idFactory,
761
+ factories
576
762
  }) {
577
763
  const headless = useFormBuilder({
578
764
  schema,
579
765
  onChange,
580
766
  ...policy === void 0 ? {} : { policy },
581
- ...idFactory === void 0 ? {} : { idFactory }
767
+ ...idFactory === void 0 ? {} : { idFactory },
768
+ ...factories === void 0 ? {} : { factories }
582
769
  });
583
770
  const [newPageQuestionId, setNewPageQuestionId] = useState("");
584
771
  const [newLocale, setNewLocale] = useState("");
@@ -589,48 +776,17 @@ function FormBuilder({
589
776
  const translated = translator?.translate(key, locale, params);
590
777
  return translated === void 0 ? interpolate(BUILDER_DEFAULTS[key] ?? key, params) : translated;
591
778
  };
592
- const emitSchema = (candidate) => onChange(sanitizeBuilderSchema(candidate));
593
779
  const updateField = headless.updateField;
594
- const changeType = (fieldId, type) => {
595
- emitSchema({
596
- ...schema,
597
- fields: schema.fields.map((field) => {
598
- if (field.id === fieldId) {
599
- return normalizeField(field, type, translate("builder.newOptionLabel", { index: 1 }));
600
- }
601
- if (field.displayCondition?.questionId === fieldId) {
602
- const { displayCondition: _condition, ...withoutCondition } = field;
603
- return withoutCondition;
604
- }
605
- return field;
606
- })
607
- });
608
- };
780
+ const changeType = headless.changeFieldType;
609
781
  const removeField = headless.removeField;
610
782
  const moveField = (index, offset) => {
611
783
  const target = index + offset;
612
- if (target < 0 || target >= schema.fields.length) return;
613
- const fields = [...schema.fields];
614
- const current = fields[index];
615
- const other = fields[target];
616
- if (current === void 0 || other === void 0) return;
617
- fields[index] = other;
618
- fields[target] = current;
619
- emitSchema({ ...schema, fields });
784
+ const field = schema.fields[index];
785
+ if (field !== void 0) headless.moveField(field.id, target);
620
786
  };
621
787
  const addField = () => headless.addField("text");
622
788
  const enablePages = () => {
623
- if (schema.pages !== void 0) return;
624
- emitSchema({
625
- ...schema,
626
- pages: [
627
- {
628
- id: createUniqueId("page", /* @__PURE__ */ new Set()),
629
- title: translate("builder.newPage"),
630
- questionIds: schema.fields.map((field) => field.id)
631
- }
632
- ]
633
- });
789
+ if (schema.pages === void 0) headless.addPage();
634
790
  };
635
791
  const pageForField = (fieldId) => schema.pages?.find((page) => page.questionIds.includes(fieldId));
636
792
  const movablePageQuestions = schema.pages?.flatMap((page) => page.questionIds.length > 1 ? page.questionIds : []) ?? [];
@@ -641,73 +797,28 @@ function FormBuilder({
641
797
  }
642
798
  const questionId = movablePageQuestions.includes(newPageQuestionId) ? newPageQuestionId : movablePageQuestions[0];
643
799
  if (questionId === void 0) return;
644
- const pageId = createUniqueId("page", new Set(schema.pages.map((page) => page.id)));
645
- emitSchema({
646
- ...schema,
647
- pages: [
648
- ...schema.pages.map((page) => ({
649
- ...page,
650
- questionIds: page.questionIds.filter((id) => id !== questionId)
651
- })),
652
- { id: pageId, title: translate("builder.newPage"), questionIds: [questionId] }
653
- ]
654
- });
800
+ headless.addPage(questionId);
655
801
  setNewPageQuestionId("");
656
802
  };
657
803
  const removePage = (pageIndex) => {
658
- if (schema.pages === void 0) return;
659
- const removed = schema.pages[pageIndex];
660
- if (removed === void 0) return;
661
- if (schema.pages.length === 1) {
662
- const { pages: _pages, ...singlePage } = schema;
663
- emitSchema(singlePage);
664
- return;
665
- }
666
- const targetIndex = pageIndex === 0 ? 1 : pageIndex - 1;
667
- emitSchema({
668
- ...schema,
669
- pages: schema.pages.map(
670
- (page, index) => index === targetIndex ? { ...page, questionIds: [...page.questionIds, ...removed.questionIds] } : page
671
- ).filter((_page, index) => index !== pageIndex)
672
- });
804
+ const page = schema.pages?.[pageIndex];
805
+ if (page !== void 0) headless.removePage(page.id);
673
806
  };
674
807
  const movePage = (pageIndex, offset) => {
675
- if (schema.pages === void 0) return;
676
808
  const target = pageIndex + offset;
677
- if (target < 0 || target >= schema.pages.length) return;
678
- const pages = [...schema.pages];
679
- const current = pages[pageIndex];
680
- const other = pages[target];
681
- if (current === void 0 || other === void 0) return;
682
- pages[pageIndex] = other;
683
- pages[target] = current;
684
- emitSchema({ ...schema, pages });
809
+ const page = schema.pages?.[pageIndex];
810
+ if (page !== void 0) headless.movePage(page.id, target);
685
811
  };
686
812
  const updatePage = (pageId, update) => {
687
- if (schema.pages === void 0) return;
688
- emitSchema({ ...schema, pages: schema.pages.map((page) => page.id === pageId ? update(page) : page) });
813
+ headless.updatePage(pageId, update);
689
814
  };
690
815
  const assignFieldToPage = (fieldId, pageId) => {
691
- if (schema.pages === void 0) return;
692
- emitSchema({
693
- ...schema,
694
- pages: schema.pages.map((page) => ({
695
- ...page,
696
- questionIds: page.id === pageId ? schema.fields.filter((field) => page.questionIds.includes(field.id) || field.id === fieldId).map((field) => field.id) : page.questionIds.filter((id) => id !== fieldId)
697
- })).filter((page) => page.questionIds.length > 0)
698
- });
816
+ headless.assignFieldToPage(fieldId, pageId);
699
817
  };
700
818
  const addLocale = () => {
701
819
  const normalized = newLocale.trim();
702
820
  if (normalized.length === 0) return;
703
- const supportedLocales = [
704
- .../* @__PURE__ */ new Set([
705
- ...schema.defaultLocale === void 0 ? [] : [schema.defaultLocale],
706
- ...schema.supportedLocales ?? [],
707
- normalized
708
- ])
709
- ];
710
- emitSchema({ ...schema, supportedLocales });
821
+ headless.addLocale(normalized);
711
822
  setEditingLocale(normalized);
712
823
  setNewLocale("");
713
824
  };
@@ -716,10 +827,14 @@ function FormBuilder({
716
827
  setIsTranslating(true);
717
828
  setTranslationError(null);
718
829
  try {
719
- const populated = await populateSchemaTranslations(schema, [editingLocale], translationAdapter, {
720
- overwrite: "all"
721
- });
830
+ const populated = await populateSchemaTranslations(
831
+ schema,
832
+ [editingLocale],
833
+ translationAdapter,
834
+ translationOptions ?? { overwrite: "all" }
835
+ );
722
836
  onChange(populated.schema);
837
+ onTranslationReport?.(populated.report);
723
838
  } catch (cause) {
724
839
  setTranslationError(cause instanceof Error ? cause.message : String(cause));
725
840
  } finally {
@@ -728,18 +843,7 @@ function FormBuilder({
728
843
  };
729
844
  const updateFormTranslation = (key, value) => {
730
845
  if (editingLocale.length === 0) return;
731
- const current = schema.translations?.[editingLocale];
732
- const next = key === "title" ? value.length === 0 ? { description: current?.description } : { ...current, title: value } : value.length === 0 ? { title: current?.title } : { ...current, description: value };
733
- emitSchema({
734
- ...schema,
735
- translations: {
736
- ...schema.translations,
737
- [editingLocale]: {
738
- ...next.title === void 0 ? {} : { title: next.title },
739
- ...next.description === void 0 ? {} : { description: next.description }
740
- }
741
- }
742
- });
846
+ headless.setLocaleTranslation(editingLocale, { kind: "form" }, key, value);
743
847
  };
744
848
  return /* @__PURE__ */ jsxs("section", { className: "form-engine-builder", "aria-label": translate("builder.formBuilder"), children: [
745
849
  /* @__PURE__ */ jsxs("section", { className: "form-engine-builder__pages", "aria-labelledby": "builder-pages-heading", children: [
@@ -819,19 +923,12 @@ function FormBuilder({
819
923
  "input",
820
924
  {
821
925
  value: page.translations?.[editingLocale]?.title ?? "",
822
- onChange: (event) => {
823
- const value = event.currentTarget.value;
824
- updatePage(page.id, (current) => ({
825
- ...current,
826
- translations: {
827
- ...current.translations,
828
- [editingLocale]: {
829
- ...value.length === 0 ? {} : { title: value },
830
- ...current.translations?.[editingLocale]?.description === void 0 ? {} : { description: current.translations[editingLocale]?.description }
831
- }
832
- }
833
- }));
834
- }
926
+ onChange: (event) => headless.setLocaleTranslation(
927
+ editingLocale,
928
+ { kind: "page", id: page.id },
929
+ "title",
930
+ event.currentTarget.value
931
+ )
835
932
  }
836
933
  )
837
934
  ] }),
@@ -841,19 +938,12 @@ function FormBuilder({
841
938
  "input",
842
939
  {
843
940
  value: page.translations?.[editingLocale]?.description ?? "",
844
- onChange: (event) => {
845
- const value = event.currentTarget.value;
846
- updatePage(page.id, (current) => ({
847
- ...current,
848
- translations: {
849
- ...current.translations,
850
- [editingLocale]: {
851
- ...current.translations?.[editingLocale]?.title === void 0 ? {} : { title: current.translations[editingLocale]?.title },
852
- ...value.length === 0 ? {} : { description: value }
853
- }
854
- }
855
- }));
856
- }
941
+ onChange: (event) => headless.setLocaleTranslation(
942
+ editingLocale,
943
+ { kind: "page", id: page.id },
944
+ "description",
945
+ event.currentTarget.value
946
+ )
857
947
  }
858
948
  )
859
949
  ] })
@@ -941,6 +1031,16 @@ function FormBuilder({
941
1031
  ] }),
942
1032
  /* @__PURE__ */ jsxs("section", { className: "form-engine-builder__localization", "aria-labelledby": "builder-localization-heading", children: [
943
1033
  /* @__PURE__ */ jsx("h2", { id: "builder-localization-heading", children: translate("builder.localization") }),
1034
+ /* @__PURE__ */ jsxs("label", { children: [
1035
+ translate("builder.completionMessage"),
1036
+ /* @__PURE__ */ jsx(
1037
+ "input",
1038
+ {
1039
+ value: schema.completionMessage ?? "",
1040
+ onChange: (event) => headless.setSourceText({ kind: "form" }, "completionMessage", event.currentTarget.value)
1041
+ }
1042
+ )
1043
+ ] }),
944
1044
  /* @__PURE__ */ jsxs("div", { className: "form-engine-builder__grid", children: [
945
1045
  /* @__PURE__ */ jsxs("label", { children: [
946
1046
  translate("builder.defaultLocale"),
@@ -948,16 +1048,7 @@ function FormBuilder({
948
1048
  "input",
949
1049
  {
950
1050
  value: schema.defaultLocale ?? "",
951
- onChange: (event) => {
952
- const value = event.currentTarget.value.trim();
953
- emitSchema(
954
- value.length === 0 ? schema : {
955
- ...schema,
956
- defaultLocale: value,
957
- supportedLocales: [.../* @__PURE__ */ new Set([value, ...schema.supportedLocales ?? []])]
958
- }
959
- );
960
- }
1051
+ onChange: (event) => headless.setDefaultLocale(event.currentTarget.value)
961
1052
  }
962
1053
  )
963
1054
  ] }),
@@ -1005,6 +1096,16 @@ function FormBuilder({
1005
1096
  onChange: (event) => updateFormTranslation("description", event.currentTarget.value)
1006
1097
  }
1007
1098
  )
1099
+ ] }),
1100
+ /* @__PURE__ */ jsxs("label", { children: [
1101
+ translate("builder.completionMessage"),
1102
+ /* @__PURE__ */ jsx(
1103
+ "input",
1104
+ {
1105
+ value: schema.translations?.[editingLocale]?.completionMessage ?? "",
1106
+ onChange: (event) => updateFormTranslation("completionMessage", event.currentTarget.value)
1107
+ }
1108
+ )
1008
1109
  ] })
1009
1110
  ] })
1010
1111
  ] }),
@@ -1106,19 +1207,12 @@ function FormBuilder({
1106
1207
  "input",
1107
1208
  {
1108
1209
  value: field.translations?.[editingLocale]?.title ?? "",
1109
- onChange: (event) => {
1110
- const value = event.currentTarget.value;
1111
- updateField(field.id, (current) => ({
1112
- ...current,
1113
- translations: {
1114
- ...current.translations,
1115
- [editingLocale]: {
1116
- ...value.length === 0 ? {} : { title: value },
1117
- ...current.translations?.[editingLocale]?.description === void 0 ? {} : { description: current.translations[editingLocale]?.description }
1118
- }
1119
- }
1120
- }));
1121
- }
1210
+ onChange: (event) => headless.setLocaleTranslation(
1211
+ editingLocale,
1212
+ { kind: "field", id: field.id },
1213
+ "title",
1214
+ event.currentTarget.value
1215
+ )
1122
1216
  }
1123
1217
  )
1124
1218
  ] }),
@@ -1128,19 +1222,12 @@ function FormBuilder({
1128
1222
  "input",
1129
1223
  {
1130
1224
  value: field.translations?.[editingLocale]?.description ?? "",
1131
- onChange: (event) => {
1132
- const value = event.currentTarget.value;
1133
- updateField(field.id, (current) => ({
1134
- ...current,
1135
- translations: {
1136
- ...current.translations,
1137
- [editingLocale]: {
1138
- ...current.translations?.[editingLocale]?.title === void 0 ? {} : { title: current.translations[editingLocale]?.title },
1139
- ...value.length === 0 ? {} : { description: value }
1140
- }
1141
- }
1142
- }));
1143
- }
1225
+ onChange: (event) => headless.setLocaleTranslation(
1226
+ editingLocale,
1227
+ { kind: "field", id: field.id },
1228
+ "description",
1229
+ event.currentTarget.value
1230
+ )
1144
1231
  }
1145
1232
  )
1146
1233
  ] })
@@ -1154,26 +1241,12 @@ function FormBuilder({
1154
1241
  "input",
1155
1242
  {
1156
1243
  value: option.translations?.[editingLocale] ?? "",
1157
- onChange: (event) => {
1158
- const value = event.currentTarget.value;
1159
- updateField(field.id, (current) => {
1160
- if (!("options" in current)) return current;
1161
- return {
1162
- ...current,
1163
- options: current.options.map(
1164
- (candidate) => candidate.id === option.id ? {
1165
- ...candidate,
1166
- translations: Object.fromEntries([
1167
- ...Object.entries(candidate.translations ?? {}).filter(
1168
- ([localeKey]) => localeKey !== editingLocale
1169
- ),
1170
- ...value.length === 0 ? [] : [[editingLocale, value]]
1171
- ])
1172
- } : candidate
1173
- )
1174
- };
1175
- });
1176
- }
1244
+ onChange: (event) => headless.setLocaleTranslation(
1245
+ editingLocale,
1246
+ { kind: "option", id: option.id },
1247
+ "label",
1248
+ event.currentTarget.value
1249
+ )
1177
1250
  }
1178
1251
  )
1179
1252
  ] }, option.id)) : null
@@ -1225,17 +1298,10 @@ function FormBuilder({
1225
1298
  "aria-label": translate("builder.optionLabel", { index: optionIndex + 1 }),
1226
1299
  value: option.label,
1227
1300
  placeholder: translate("builder.optionLabelPlaceholder"),
1228
- onChange: (event) => updateField(field.id, (current) => {
1229
- if (!("options" in current)) return current;
1230
- const label = event.currentTarget.value;
1231
- if (label.trim().length === 0) return current;
1232
- return {
1233
- ...current,
1234
- options: current.options.map(
1235
- (item, itemIndex) => itemIndex === optionIndex ? { ...item, label } : item
1236
- )
1237
- };
1238
- })
1301
+ onChange: (event) => event.currentTarget.value.trim().length === 0 ? void 0 : headless.updateOption(field.id, option.id, (item) => ({
1302
+ ...item,
1303
+ label: event.currentTarget.value
1304
+ }))
1239
1305
  }
1240
1306
  ),
1241
1307
  /* @__PURE__ */ jsx(
@@ -1267,17 +1333,14 @@ function FormBuilder({
1267
1333
  value: condition?.questionId ?? "",
1268
1334
  onChange: (event) => {
1269
1335
  const selected = schema.fields.find((item) => item.id === event.currentTarget.value);
1270
- updateField(field.id, (current) => {
1271
- if (selected === void 0) {
1272
- const { displayCondition: _condition, ...withoutCondition } = current;
1273
- return withoutCondition;
1274
- }
1275
- const operator = conditionOperators(selected)[0] ?? "not_empty";
1276
- return {
1277
- ...current,
1278
- displayCondition: conditionWithValue(selected.id, operator, defaultConditionValue(selected))
1279
- };
1280
- });
1336
+ headless.setDisplayCondition(
1337
+ field.id,
1338
+ selected === void 0 ? void 0 : conditionWithValue(
1339
+ selected.id,
1340
+ conditionOperators(selected)[0] ?? "not_empty",
1341
+ defaultConditionValue(selected)
1342
+ )
1343
+ );
1281
1344
  },
1282
1345
  children: [
1283
1346
  /* @__PURE__ */ jsx("option", { value: "", children: translate("builder.alwaysVisible") }),
@@ -1294,10 +1357,10 @@ function FormBuilder({
1294
1357
  value: condition.operator,
1295
1358
  onChange: (event) => {
1296
1359
  const operator = event.currentTarget.value;
1297
- updateField(field.id, (current) => ({
1298
- ...current,
1299
- displayCondition: conditionWithValue(source.id, operator, defaultConditionValue(source))
1300
- }));
1360
+ headless.setDisplayCondition(
1361
+ field.id,
1362
+ conditionWithValue(source.id, operator, defaultConditionValue(source))
1363
+ );
1301
1364
  },
1302
1365
  children: conditionOperators(source).map((operator) => /* @__PURE__ */ jsx("option", { value: operator, children: translate(operatorKey(operator)) }, operator))
1303
1366
  }
@@ -1307,7 +1370,7 @@ function FormBuilder({
1307
1370
  {
1308
1371
  source,
1309
1372
  condition,
1310
- onChange: (next) => updateField(field.id, (current) => ({ ...current, displayCondition: next })),
1373
+ onChange: (next) => headless.setDisplayCondition(field.id, next),
1311
1374
  translate
1312
1375
  }
1313
1376
  )
@@ -1859,8 +1922,14 @@ function ContextFormRenderer({
1859
1922
  ] }),
1860
1923
  draftRestored ? /* @__PURE__ */ jsx3("span", { className: "form-draft-badge", children: form.translate("form.draftRestored") }) : null
1861
1924
  ] }),
1862
- activePage?.title === void 0 ? null : /* @__PURE__ */ jsx3("h2", { className: "fe-page-title", children: activePage.title }),
1863
- activePage?.description === void 0 ? null : /* @__PURE__ */ jsx3("p", { className: "fe-page-description", children: activePage.description }),
1925
+ activePage === void 0 ? null : slots.renderPageHeader?.({
1926
+ page: activePage,
1927
+ pageIndex: activeVisibleIndex,
1928
+ totalPages: visiblePageIndexes.length
1929
+ }) ?? /* @__PURE__ */ jsxs2("div", { className: "fe-page-header", children: [
1930
+ activePage.title === void 0 ? null : /* @__PURE__ */ jsx3("h2", { className: "fe-page-title", children: activePage.title }),
1931
+ activePage.description === void 0 ? null : /* @__PURE__ */ jsx3("p", { className: "fe-page-description", children: activePage.description })
1932
+ ] }),
1864
1933
  /* @__PURE__ */ jsx3("div", { className: "fe-fields", children: form.schema.fields.filter((field) => form.visibility[field.id] === true && (fieldIds === void 0 || fieldIds.has(field.id))).map((field) => {
1865
1934
  const error = form.errors[field.id];
1866
1935
  const props = {
@@ -1928,7 +1997,7 @@ function ContextFormRenderer({
1928
1997
  form.submitStatus === "success" ? slots.renderCompletion?.({
1929
1998
  message: form.schema.completionMessage ?? (successMessageKey === void 0 ? "Submitted." : form.translate(successMessageKey))
1930
1999
  }) ?? /* @__PURE__ */ jsx3("div", { role: "status", children: form.schema.completionMessage ?? (successMessageKey === void 0 ? "Submitted." : form.translate(successMessageKey)) }) : null,
1931
- form.submitStatus === "error" && form.submitError !== null && errorMessageKey !== void 0 ? /* @__PURE__ */ jsx3("div", { role: "alert", children: form.translate(errorMessageKey) }) : null
2000
+ form.submitStatus === "error" && form.submitError !== null ? slots.renderSubmitError?.({ error: form.submitError, onRetry: () => void submitValues() }) ?? (errorMessageKey === void 0 ? null : /* @__PURE__ */ jsx3("div", { role: "alert", children: form.translate(errorMessageKey) })) : null
1932
2001
  ] })
1933
2002
  ] });
1934
2003
  }