@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.cjs CHANGED
@@ -37,21 +37,26 @@ var import_react2 = require("react");
37
37
  var import_core = require("@form-engine-ts/core");
38
38
  var import_react = require("react");
39
39
  var DEFAULT_PREFIXES = { field: "q", option: "opt", page: "page" };
40
+ var CHOICE_TYPES = ["select", "radio", "multi-select"];
40
41
  function defaultIdFactory(kind, existingIds) {
41
42
  const prefix = DEFAULT_PREFIXES[kind];
42
43
  let id;
43
- do {
44
+ do
44
45
  id = `${prefix}_${globalThis.crypto.randomUUID().slice(0, 8)}`;
45
- } while (existingIds.has(id));
46
+ while (existingIds.has(id));
46
47
  return id;
47
48
  }
48
- function newField(type, id, optionId) {
49
+ function defaultCreateField(type, id) {
49
50
  const base = { id, title: "New question", required: false };
50
- if (type === "text" || type === "textarea") return { ...base, type };
51
- if (type === "number") return { ...base, type };
51
+ if (type === "text" || type === "textarea" || type === "number" || type === "checkbox") return { ...base, type };
52
52
  if (type === "rating") return { ...base, type, min: 1, max: 5 };
53
- if (type === "checkbox") return { ...base, type };
54
- return optionId === void 0 ? void 0 : { ...base, type, options: [{ id: optionId, label: "Option 1" }] };
53
+ return { ...base, type, options: [] };
54
+ }
55
+ function defaultCreateOption(field, id) {
56
+ return { id, label: `Option ${"options" in field ? field.options.length + 1 : 1}` };
57
+ }
58
+ function defaultCreatePage(id, questionIds) {
59
+ return { id, title: "New page", questionIds };
55
60
  }
56
61
  function move(items, sourceIndex, targetIndex) {
57
62
  if (sourceIndex < 0 || targetIndex < 0 || targetIndex >= items.length || sourceIndex === targetIndex)
@@ -62,334 +67,553 @@ function move(items, sourceIndex, targetIndex) {
62
67
  result.splice(targetIndex, 0, item);
63
68
  return result;
64
69
  }
65
- function addPolicyIssues(schema, policy, issues) {
66
- if (policy?.maxFields !== void 0 && schema.fields.length > policy.maxFields) {
67
- issues.push({
68
- path: "fields",
69
- code: "max_fields_exceeded",
70
- message: `At most ${policy.maxFields} fields are allowed.`
71
- });
72
- }
73
- schema.fields.forEach((field, index) => {
74
- if (policy?.allowedFieldTypes !== void 0 && !policy.allowedFieldTypes.includes(field.type)) {
75
- issues.push({
76
- path: `fields[${index}].type`,
77
- code: "disallowed_field_type",
78
- message: `Field type ${field.type} is not allowed.`
79
- });
80
- }
81
- if (policy?.maxTextLength !== void 0) {
82
- for (const [property, text] of [
83
- ["title", field.title],
84
- ["description", field.description]
85
- ]) {
86
- if (text !== void 0 && text.length > policy.maxTextLength) {
87
- issues.push({
88
- path: `fields[${index}].${property}`,
89
- code: "max_text_length_exceeded",
90
- message: `Text must be at most ${policy.maxTextLength} characters.`
91
- });
92
- }
93
- }
94
- }
95
- if (policy?.maxOptionsPerField !== void 0 && "options" in field && field.options.length > policy.maxOptionsPerField) {
96
- issues.push({
97
- path: `fields[${index}].options`,
98
- code: "max_options_exceeded",
99
- message: `At most ${policy.maxOptionsPerField} options are allowed.`
100
- });
101
- }
102
- });
103
- for (const locale of policy?.requiredLocales ?? []) {
104
- if (!(schema.supportedLocales ?? []).includes(locale)) {
105
- issues.push({
106
- path: "supportedLocales",
107
- code: "required_locale_missing",
108
- message: `Required locale ${locale} is missing.`
109
- });
110
- }
111
- if (locale === schema.defaultLocale) continue;
112
- const requiredTranslations = [
113
- { path: `translations.${locale}.title`, value: schema.translations?.[locale]?.title },
114
- ...schema.fields.map((field, index) => ({
115
- path: `fields[${index}].translations.${locale}.title`,
116
- value: field.translations?.[locale]?.title
117
- })),
118
- ...schema.fields.flatMap(
119
- (field, fieldIndex) => "options" in field ? field.options.map((option, optionIndex) => ({
120
- path: `fields[${fieldIndex}].options[${optionIndex}].translations.${locale}`,
121
- value: option.translations?.[locale]
122
- })) : []
123
- ),
124
- ...schema.pages?.flatMap(
125
- (page, pageIndex) => page.title === void 0 ? [] : [{ path: `pages[${pageIndex}].translations.${locale}.title`, value: page.translations?.[locale]?.title }]
126
- ) ?? []
127
- ];
128
- for (const translation of requiredTranslations) {
129
- if (translation.value === void 0 || translation.value.trim().length === 0) {
130
- issues.push({
131
- path: translation.path,
132
- code: "required_translation_missing",
133
- message: `A translation for required locale ${locale} is missing.`
134
- });
135
- }
136
- }
137
- }
70
+ function withoutDisplayCondition(field) {
71
+ const { displayCondition: _displayCondition, ...rest } = field;
72
+ return rest;
73
+ }
74
+ function removeLocalizedProperty(translations, locale, property) {
75
+ const current = translations?.[locale];
76
+ if (current === void 0) return translations;
77
+ const { [property]: _removed, ...remaining } = current;
78
+ return { ...translations, [locale]: remaining };
79
+ }
80
+ function setTranslationMetadata(node, locale, property, metadata, remove) {
81
+ const localeMetadata = node.translationMetadata?.[locale];
82
+ if (metadata === void 0 && !remove) return node;
83
+ const nextLocaleMetadata = remove ? Object.fromEntries(Object.entries(localeMetadata ?? {}).filter(([key]) => key !== property)) : { ...localeMetadata, [property]: metadata ?? {} };
84
+ return { ...node, translationMetadata: { ...node.translationMetadata, [locale]: nextLocaleMetadata } };
85
+ }
86
+ function failedId(kind, result) {
87
+ return { success: false, error: result.error ?? { type: "invalid_id", kind, id: "" } };
138
88
  }
139
89
  function useFormBuilder({
140
90
  schema,
141
91
  onChange,
142
92
  policy,
143
- idFactory = defaultIdFactory
93
+ idFactory = defaultIdFactory,
94
+ factories = {}
144
95
  }) {
145
96
  const createId = (0, import_react.useCallback)(
146
97
  (kind, existingIds) => {
147
- const id = idFactory(kind, existingIds).trim();
148
- return id.length > 0 && !existingIds.has(id) ? id : void 0;
98
+ const rawId = idFactory(kind, existingIds);
99
+ const id = rawId.trim();
100
+ return id.length > 0 && !existingIds.has(id) ? { id } : { error: { type: "invalid_id", kind, id: rawId } };
149
101
  },
150
102
  [idFactory]
151
103
  );
104
+ const textPolicyError = (0, import_react.useCallback)(
105
+ (text) => policy?.maxTextLength !== void 0 && text.length > policy.maxTextLength ? { success: false, error: { type: "max_text_length_exceeded", max: policy.maxTextLength } } : void 0,
106
+ [policy?.maxTextLength]
107
+ );
108
+ const updateField = (0, import_react.useCallback)(
109
+ (fieldId, updater) => {
110
+ const current = schema.fields.find((field) => field.id === fieldId);
111
+ if (current === void 0)
112
+ return { success: false, error: { type: "node_not_found", kind: "field", id: fieldId } };
113
+ const updated = updater(current);
114
+ if (updated.id !== fieldId)
115
+ return { success: false, error: { type: "invalid_id", kind: "field", id: updated.id } };
116
+ if (policy?.allowedFieldTypes !== void 0 && !policy.allowedFieldTypes.includes(updated.type))
117
+ return { success: false, error: { type: "disallowed_field_type", fieldType: updated.type } };
118
+ for (const text of [updated.title, updated.description]) {
119
+ if (text !== void 0) {
120
+ const error = textPolicyError(text);
121
+ if (error !== void 0) return error;
122
+ }
123
+ }
124
+ onChange({ ...schema, fields: schema.fields.map((field) => field.id === fieldId ? updated : field) });
125
+ return { success: true };
126
+ },
127
+ [onChange, policy?.allowedFieldTypes, schema, textPolicyError]
128
+ );
129
+ const updateOption = (0, import_react.useCallback)(
130
+ (fieldId, optionId, updater) => {
131
+ const field = schema.fields.find((candidate) => candidate.id === fieldId);
132
+ if (field === void 0) return { success: false, error: { type: "node_not_found", kind: "field", id: fieldId } };
133
+ if (!("options" in field))
134
+ return { success: false, error: { type: "invalid_operation", message: `Field ${fieldId} has no options.` } };
135
+ const current = field.options.find((option) => option.id === optionId);
136
+ if (current === void 0)
137
+ return { success: false, error: { type: "node_not_found", kind: "option", id: optionId } };
138
+ const updated = updater(current);
139
+ if (updated.id !== optionId)
140
+ return { success: false, error: { type: "invalid_id", kind: "option", id: updated.id } };
141
+ const error = textPolicyError(updated.label);
142
+ if (error !== void 0) return error;
143
+ onChange({
144
+ ...schema,
145
+ fields: schema.fields.map(
146
+ (candidate) => candidate.id === fieldId && "options" in candidate ? {
147
+ ...candidate,
148
+ options: candidate.options.map((option) => option.id === optionId ? updated : option)
149
+ } : candidate
150
+ )
151
+ });
152
+ return { success: true };
153
+ },
154
+ [onChange, schema, textPolicyError]
155
+ );
156
+ const updatePage = (0, import_react.useCallback)(
157
+ (pageId, updater) => {
158
+ const page = schema.pages?.find((candidate) => candidate.id === pageId);
159
+ if (page === void 0) return { success: false, error: { type: "node_not_found", kind: "page", id: pageId } };
160
+ const pages = schema.pages;
161
+ if (pages === void 0) return { success: false, error: { type: "node_not_found", kind: "page", id: pageId } };
162
+ const updated = updater(page);
163
+ if (updated.id !== pageId) return { success: false, error: { type: "invalid_id", kind: "page", id: updated.id } };
164
+ onChange({ ...schema, pages: pages.map((candidate) => candidate.id === pageId ? updated : candidate) });
165
+ return { success: true };
166
+ },
167
+ [onChange, schema]
168
+ );
152
169
  const addField = (0, import_react.useCallback)(
153
170
  (type, pageId) => {
154
- if (policy?.allowedFieldTypes !== void 0 && !policy.allowedFieldTypes.includes(type)) {
171
+ if (policy?.allowedFieldTypes !== void 0 && !policy.allowedFieldTypes.includes(type))
155
172
  return { success: false, error: { type: "disallowed_field_type", fieldType: type } };
156
- }
157
- if (policy?.maxFields !== void 0 && schema.fields.length >= policy.maxFields) {
173
+ if (policy?.maxFields !== void 0 && schema.fields.length >= policy.maxFields)
158
174
  return { success: false, error: { type: "max_fields_exceeded", max: policy.maxFields } };
159
- }
160
- const fieldId = createId("field", new Set(schema.fields.map((field2) => field2.id)));
161
- const needsOption = ["select", "radio", "multi-select"].includes(type);
162
- const optionId = needsOption ? createId(
163
- "option",
164
- new Set(
165
- schema.fields.flatMap((field2) => "options" in field2 ? field2.options.map((option) => option.id) : [])
166
- )
167
- ) : void 0;
168
- if (fieldId === void 0 || needsOption && optionId === void 0) {
169
- return {
170
- success: false,
171
- error: { type: "invalid_operation", message: "idFactory returned a duplicate or empty ID." }
172
- };
173
- }
174
- const field = newField(type, fieldId, optionId);
175
- if (field === void 0) {
176
- return { success: false, error: { type: "invalid_operation", message: "Could not create the field." } };
175
+ if (pageId !== void 0 && !schema.pages?.some((page) => page.id === pageId))
176
+ return { success: false, error: { type: "node_not_found", kind: "page", id: pageId } };
177
+ const fieldIds = new Set(schema.fields.map((field2) => field2.id));
178
+ const fieldId = createId("field", fieldIds);
179
+ if (fieldId.id === void 0) return failedId("field", fieldId);
180
+ let field = (factories.createField ?? defaultCreateField)(type, fieldId.id);
181
+ if (field.id !== fieldId.id || field.type !== type || fieldIds.has(field.id))
182
+ return { success: false, error: { type: "invalid_id", kind: "field", id: field.id } };
183
+ if (CHOICE_TYPES.includes(type) && "options" in field && field.options.length === 0) {
184
+ const optionIds = new Set(
185
+ schema.fields.flatMap((item) => "options" in item ? item.options.map((option2) => option2.id) : [])
186
+ );
187
+ const optionId = createId("option", optionIds);
188
+ if (optionId.id === void 0) return failedId("option", optionId);
189
+ const option = (factories.createOption ?? defaultCreateOption)(field, optionId.id);
190
+ if (option.id !== optionId.id || optionIds.has(option.id))
191
+ return { success: false, error: { type: "invalid_id", kind: "option", id: option.id } };
192
+ field = { ...field, options: [option] };
177
193
  }
178
194
  const pages = schema.pages?.map((page, index) => ({
179
195
  ...page,
180
- questionIds: page.id === pageId || pageId === void 0 && index === (schema.pages?.length ?? 0) - 1 ? [...page.questionIds, fieldId] : page.questionIds
196
+ questionIds: page.id === pageId || pageId === void 0 && index === (schema.pages?.length ?? 0) - 1 ? [...page.questionIds, field.id] : page.questionIds
181
197
  }));
182
- if (schema.pages !== void 0 && !schema.pages.some((page) => page.id === pageId) && pageId !== void 0) {
183
- return { success: false, error: { type: "invalid_operation", message: `Unknown page: ${pageId}` } };
184
- }
185
198
  onChange({ ...schema, fields: [...schema.fields, field], ...pages === void 0 ? {} : { pages } });
186
199
  return { success: true };
187
200
  },
188
- [createId, onChange, policy, schema]
201
+ [createId, factories, onChange, policy, schema]
189
202
  );
190
203
  const removeField = (0, import_react.useCallback)(
191
204
  (fieldId) => {
192
- if (schema.fields.length <= 1 || !schema.fields.some((field) => field.id === fieldId)) return;
193
- const fields = schema.fields.filter((field) => field.id !== fieldId).map(
194
- (field) => field.displayCondition?.questionId === fieldId ? (({ displayCondition: _condition, ...candidate }) => candidate)(field) : field
195
- );
196
- const remainingPages = schema.pages?.map((page) => ({ ...page, questionIds: page.questionIds.filter((id) => id !== fieldId) })).filter((page) => page.questionIds.length > 0);
197
- if (schema.pages !== void 0 && remainingPages?.length === 0) {
198
- const { pages: _pages, ...singlePageSchema } = schema;
199
- onChange({ ...singlePageSchema, fields });
200
- } else {
201
- onChange({ ...schema, fields, ...remainingPages === void 0 ? {} : { pages: remainingPages } });
202
- }
205
+ if (!schema.fields.some((field) => field.id === fieldId))
206
+ return { success: false, error: { type: "node_not_found", kind: "field", id: fieldId } };
207
+ if (schema.fields.length <= 1)
208
+ return { success: false, error: { type: "invalid_operation", message: "A form must contain one field." } };
209
+ const fields = schema.fields.filter((field) => field.id !== fieldId).map((field) => field.displayCondition?.questionId === fieldId ? withoutDisplayCondition(field) : field);
210
+ const pages = schema.pages?.map((page) => ({ ...page, questionIds: page.questionIds.filter((id) => id !== fieldId) })).filter((page) => page.questionIds.length > 0);
211
+ if (schema.pages !== void 0 && pages?.length === 0) {
212
+ const { pages: _pages, ...single } = schema;
213
+ onChange({ ...single, fields });
214
+ } else onChange({ ...schema, fields, ...pages === void 0 ? {} : { pages } });
215
+ return { success: true };
203
216
  },
204
217
  [onChange, schema]
205
218
  );
206
219
  const moveField = (0, import_react.useCallback)(
207
220
  (fieldId, targetIndex) => {
208
- const fields = move(
209
- schema.fields,
210
- schema.fields.findIndex((field) => field.id === fieldId),
211
- targetIndex
212
- );
213
- if (fields !== void 0) {
214
- const indexById = new Map(fields.map((field, index) => [field.id, index]));
215
- const safeFields = fields.map((field, index) => {
216
- const sourceIndex = field.displayCondition === void 0 ? void 0 : indexById.get(field.displayCondition.questionId);
217
- if (field.displayCondition === void 0 || sourceIndex !== void 0 && sourceIndex < index) return field;
218
- const { displayCondition: _condition, ...withoutCondition } = field;
219
- return withoutCondition;
220
- });
221
- onChange({ ...schema, fields: safeFields });
222
- }
221
+ const sourceIndex = schema.fields.findIndex((field) => field.id === fieldId);
222
+ if (sourceIndex < 0) return { success: false, error: { type: "node_not_found", kind: "field", id: fieldId } };
223
+ const fields = move(schema.fields, sourceIndex, targetIndex);
224
+ if (fields === void 0)
225
+ return { success: false, error: { type: "invalid_operation", message: "Invalid field position." } };
226
+ const indexById = new Map(fields.map((field, index) => [field.id, index]));
227
+ onChange({
228
+ ...schema,
229
+ fields: fields.map((field, index) => {
230
+ const source = field.displayCondition?.questionId;
231
+ return source === void 0 || (indexById.get(source) ?? index) < index ? field : withoutDisplayCondition(field);
232
+ })
233
+ });
234
+ return { success: true };
223
235
  },
224
236
  [onChange, schema]
225
237
  );
226
- const updateField = (0, import_react.useCallback)(
227
- (fieldId, updater) => {
228
- const current = schema.fields.find((field) => field.id === fieldId);
229
- if (current === void 0) return;
230
- const updated = updater(current);
231
- if (updated.id !== fieldId) return;
232
- if (policy?.allowedFieldTypes !== void 0 && !policy.allowedFieldTypes.includes(updated.type)) return;
233
- const maxTextLength = policy?.maxTextLength;
234
- if (maxTextLength !== void 0 && [updated.title, updated.description].some((text) => text !== void 0 && text.length > maxTextLength)) {
235
- return;
236
- }
237
- onChange({ ...schema, fields: schema.fields.map((field) => field.id === fieldId ? updated : field) });
238
- },
239
- [onChange, policy, schema]
240
- );
241
238
  const addOption = (0, import_react.useCallback)(
242
239
  (fieldId) => {
243
240
  const field = schema.fields.find((candidate) => candidate.id === fieldId);
244
- if (field === void 0 || !("options" in field)) {
241
+ if (field === void 0) return { success: false, error: { type: "node_not_found", kind: "field", id: fieldId } };
242
+ if (!("options" in field))
245
243
  return { success: false, error: { type: "invalid_operation", message: `Field ${fieldId} has no options.` } };
246
- }
247
- if (policy?.maxOptionsPerField !== void 0 && field.options.length >= policy.maxOptionsPerField) {
244
+ if (policy?.maxOptionsPerField !== void 0 && field.options.length >= policy.maxOptionsPerField)
248
245
  return { success: false, error: { type: "max_options_exceeded", max: policy.maxOptionsPerField } };
249
- }
250
- const existingIds = new Set(
251
- schema.fields.flatMap(
252
- (candidate) => "options" in candidate ? candidate.options.map((option2) => option2.id) : []
253
- )
246
+ const ids = new Set(
247
+ schema.fields.flatMap((item) => "options" in item ? item.options.map((option2) => option2.id) : [])
254
248
  );
255
- const optionId = createId("option", existingIds);
256
- if (optionId === void 0) {
257
- return {
258
- success: false,
259
- error: { type: "invalid_operation", message: "idFactory returned a duplicate or empty ID." }
260
- };
261
- }
262
- const option = { id: optionId, label: `Option ${field.options.length + 1}` };
249
+ const id = createId("option", ids);
250
+ if (id.id === void 0) return failedId("option", id);
251
+ const option = (factories.createOption ?? defaultCreateOption)(field, id.id);
252
+ if (option.id !== id.id || ids.has(option.id))
253
+ return { success: false, error: { type: "invalid_id", kind: "option", id: option.id } };
263
254
  onChange({
264
255
  ...schema,
265
256
  fields: schema.fields.map(
266
- (candidate) => candidate.id === fieldId && "options" in candidate ? { ...candidate, options: [...candidate.options, option] } : candidate
257
+ (item) => item.id === fieldId && "options" in item ? { ...item, options: [...item.options, option] } : item
267
258
  )
268
259
  });
269
260
  return { success: true };
270
261
  },
271
- [createId, onChange, policy, schema]
262
+ [createId, factories.createOption, onChange, policy?.maxOptionsPerField, schema]
272
263
  );
273
264
  const removeOption = (0, import_react.useCallback)(
274
265
  (fieldId, optionId) => {
275
- const field = schema.fields.find((candidate) => candidate.id === fieldId);
276
- if (field === void 0 || !("options" in field) || field.options.length <= 1) return;
266
+ const field = schema.fields.find((item) => item.id === fieldId);
267
+ if (field === void 0) return { success: false, error: { type: "node_not_found", kind: "field", id: fieldId } };
268
+ if (!("options" in field) || !field.options.some((option) => option.id === optionId))
269
+ return { success: false, error: { type: "node_not_found", kind: "option", id: optionId } };
270
+ if (field.options.length <= 1)
271
+ return { success: false, error: { type: "invalid_operation", message: "A choice field needs one option." } };
277
272
  onChange({
278
273
  ...schema,
279
274
  fields: schema.fields.map(
280
- (candidate) => candidate.id === fieldId && "options" in candidate ? { ...candidate, options: candidate.options.filter((option) => option.id !== optionId) } : candidate
275
+ (item) => item.id === fieldId && "options" in item ? { ...item, options: item.options.filter((option) => option.id !== optionId) } : item
281
276
  )
282
277
  });
278
+ return { success: true };
283
279
  },
284
280
  [onChange, schema]
285
281
  );
286
282
  const moveOption = (0, import_react.useCallback)(
287
283
  (fieldId, optionId, targetIndex) => {
288
- const field = schema.fields.find((candidate) => candidate.id === fieldId);
289
- if (field === void 0 || !("options" in field)) return;
284
+ const field = schema.fields.find((item) => item.id === fieldId);
285
+ if (field === void 0) return { success: false, error: { type: "node_not_found", kind: "field", id: fieldId } };
286
+ if (!("options" in field))
287
+ return { success: false, error: { type: "invalid_operation", message: `Field ${fieldId} has no options.` } };
290
288
  const options = move(
291
289
  field.options,
292
290
  field.options.findIndex((option) => option.id === optionId),
293
291
  targetIndex
294
292
  );
295
- if (options === void 0) return;
293
+ if (options === void 0)
294
+ return { success: false, error: { type: "invalid_operation", message: "Invalid option position." } };
296
295
  onChange({
297
296
  ...schema,
298
297
  fields: schema.fields.map(
299
- (candidate) => candidate.id === fieldId && "options" in candidate ? { ...candidate, options } : candidate
298
+ (item) => item.id === fieldId && "options" in item ? { ...item, options } : item
300
299
  )
301
300
  });
301
+ return { success: true };
302
302
  },
303
303
  [onChange, schema]
304
304
  );
305
+ const changeFieldType = (0, import_react.useCallback)(
306
+ (fieldId, type) => {
307
+ const field = schema.fields.find((item) => item.id === fieldId);
308
+ if (field === void 0) return { success: false, error: { type: "node_not_found", kind: "field", id: fieldId } };
309
+ if (policy?.allowedFieldTypes !== void 0 && !policy.allowedFieldTypes.includes(type))
310
+ return { success: false, error: { type: "disallowed_field_type", fieldType: type } };
311
+ let transformed = (0, import_core.transformFieldType)(field, type);
312
+ if (CHOICE_TYPES.includes(type) && !("options" in field) && "options" in transformed) {
313
+ const ids = new Set(
314
+ schema.fields.flatMap((item) => "options" in item ? item.options.map((option2) => option2.id) : [])
315
+ );
316
+ const id = createId("option", ids);
317
+ if (id.id === void 0) return failedId("option", id);
318
+ const option = (factories.createOption ?? defaultCreateOption)(transformed, id.id);
319
+ if (option.id !== id.id || ids.has(option.id))
320
+ return { success: false, error: { type: "invalid_id", kind: "option", id: option.id } };
321
+ transformed = { ...transformed, options: [option] };
322
+ }
323
+ onChange({ ...schema, fields: schema.fields.map((item) => item.id === fieldId ? transformed : item) });
324
+ return { success: true };
325
+ },
326
+ [createId, factories.createOption, onChange, policy?.allowedFieldTypes, schema]
327
+ );
305
328
  const addPage = (0, import_react.useCallback)(
306
329
  (questionId) => {
307
- const existingIds = new Set(schema.pages?.map((page) => page.id) ?? []);
308
- const pageId = createId("page", existingIds);
309
- if (pageId === void 0) return;
310
- if (schema.pages === void 0) {
311
- onChange({ ...schema, pages: [{ id: pageId, questionIds: schema.fields.map((field) => field.id) }] });
312
- return;
313
- }
314
- const movableQuestionId = questionId ?? schema.pages.find((page) => page.questionIds.length > 1)?.questionIds.at(-1);
315
- if (movableQuestionId === void 0) return;
316
- const sourcePage = schema.pages.find((page) => page.questionIds.includes(movableQuestionId));
317
- if (sourcePage === void 0 || sourcePage.questionIds.length <= 1) return;
318
- const pages = [
319
- ...schema.pages.map((page) => ({
320
- ...page,
321
- questionIds: page.questionIds.filter((id) => id !== movableQuestionId)
330
+ const ids = new Set(schema.pages?.map((page2) => page2.id) ?? []);
331
+ const id = createId("page", ids);
332
+ if (id.id === void 0) return failedId("page", id);
333
+ 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(
334
+ (value) => value !== void 0
335
+ );
336
+ if (questionIds.length === 0)
337
+ return { success: false, error: { type: "invalid_operation", message: "No question can be moved." } };
338
+ const source = schema.pages?.find((page2) => page2.questionIds.includes(questionIds[0] ?? ""));
339
+ if (schema.pages !== void 0 && (source === void 0 || source.questionIds.length <= 1))
340
+ return { success: false, error: { type: "invalid_operation", message: "A page cannot be left empty." } };
341
+ const page = (factories.createPage ?? defaultCreatePage)(id.id, [...questionIds]);
342
+ if (page.id !== id.id || ids.has(page.id))
343
+ return { success: false, error: { type: "invalid_id", kind: "page", id: page.id } };
344
+ const pages = schema.pages === void 0 ? [page] : [
345
+ ...schema.pages.map((item) => ({
346
+ ...item,
347
+ questionIds: item.questionIds.filter((fieldId) => !questionIds.includes(fieldId))
322
348
  })),
323
- { id: pageId, questionIds: [movableQuestionId] }
349
+ page
324
350
  ];
325
351
  onChange({ ...schema, pages });
352
+ return { success: true };
326
353
  },
327
- [createId, onChange, schema]
354
+ [createId, factories.createPage, onChange, schema]
328
355
  );
329
356
  const removePage = (0, import_react.useCallback)(
330
357
  (pageId) => {
331
- if (schema.pages === void 0) return;
332
- const index = schema.pages.findIndex((page) => page.id === pageId);
333
- const removed = schema.pages[index];
334
- if (removed === void 0) return;
335
- if (schema.pages.length === 1) {
336
- const { pages: _pages, ...singlePage } = schema;
337
- onChange(singlePage);
338
- return;
358
+ const index = schema.pages?.findIndex((page) => page.id === pageId) ?? -1;
359
+ const removed = schema.pages?.[index];
360
+ if (removed === void 0) return { success: false, error: { type: "node_not_found", kind: "page", id: pageId } };
361
+ const currentPages = schema.pages;
362
+ if (currentPages === void 0)
363
+ return { success: false, error: { type: "node_not_found", kind: "page", id: pageId } };
364
+ if ((schema.pages?.length ?? 0) === 1) {
365
+ const { pages: _pages, ...single } = schema;
366
+ onChange(single);
367
+ return { success: true };
339
368
  }
340
369
  const targetIndex = index === 0 ? 1 : index - 1;
341
370
  onChange({
342
371
  ...schema,
343
- pages: schema.pages.map(
372
+ pages: currentPages.map(
344
373
  (page, pageIndex) => pageIndex === targetIndex ? { ...page, questionIds: [...page.questionIds, ...removed.questionIds] } : page
345
374
  ).filter((page) => page.id !== pageId)
346
375
  });
376
+ return { success: true };
347
377
  },
348
378
  [onChange, schema]
349
379
  );
350
- const setLocaleTranslation = (0, import_react.useCallback)(
351
- (locale, target, property, text) => {
352
- if (locale.trim().length === 0 || policy?.maxTextLength !== void 0 && text.length > policy.maxTextLength)
353
- return;
354
- const supportedLocales = [.../* @__PURE__ */ new Set([...schema.supportedLocales ?? [], locale])];
355
- if (target === "form" && ["title", "description", "completionMessage"].includes(property)) {
356
- onChange({
357
- ...schema,
358
- supportedLocales,
359
- translations: { ...schema.translations, [locale]: { ...schema.translations?.[locale], [property]: text } }
380
+ const movePage = (0, import_react.useCallback)(
381
+ (pageId, targetIndex) => {
382
+ const sourceIndex = schema.pages?.findIndex((page) => page.id === pageId) ?? -1;
383
+ if (sourceIndex < 0) return { success: false, error: { type: "node_not_found", kind: "page", id: pageId } };
384
+ const pages = move(schema.pages ?? [], sourceIndex, targetIndex);
385
+ if (pages === void 0)
386
+ return { success: false, error: { type: "invalid_operation", message: "Invalid page position." } };
387
+ const assignment = new Map(pages.flatMap((page, index) => page.questionIds.map((id) => [id, index])));
388
+ onChange({
389
+ ...schema,
390
+ pages: pages.map((page, index) => {
391
+ const source = page.displayCondition?.questionId;
392
+ if (source === void 0 || (assignment.get(source) ?? index) < index) return page;
393
+ const { displayCondition: _condition, ...rest } = page;
394
+ return rest;
395
+ })
396
+ });
397
+ return { success: true };
398
+ },
399
+ [onChange, schema]
400
+ );
401
+ const assignFieldToPage = (0, import_react.useCallback)(
402
+ (fieldId, pageId) => {
403
+ if (!schema.fields.some((field) => field.id === fieldId))
404
+ return { success: false, error: { type: "node_not_found", kind: "field", id: fieldId } };
405
+ if (pageId === null) {
406
+ if (schema.pages !== void 0) {
407
+ const { pages: _pages, ...single } = schema;
408
+ onChange(single);
409
+ }
410
+ return { success: true };
411
+ }
412
+ if (!schema.pages?.some((page) => page.id === pageId))
413
+ return { success: false, error: { type: "node_not_found", kind: "page", id: pageId } };
414
+ const pages = schema.pages.map((page) => ({
415
+ ...page,
416
+ 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)
417
+ })).filter((page) => page.questionIds.length > 0);
418
+ onChange({ ...schema, pages });
419
+ return { success: true };
420
+ },
421
+ [onChange, schema]
422
+ );
423
+ const setDisplayCondition = (0, import_react.useCallback)(
424
+ (fieldId, condition) => {
425
+ const index = schema.fields.findIndex((field) => field.id === fieldId);
426
+ if (index < 0) return { success: false, error: { type: "node_not_found", kind: "field", id: fieldId } };
427
+ if (condition !== void 0) {
428
+ const sourceIndex = schema.fields.findIndex((field) => field.id === condition.questionId);
429
+ if (sourceIndex < 0 || sourceIndex >= index)
430
+ return {
431
+ success: false,
432
+ error: { type: "invalid_operation", message: "Conditions require an earlier field." }
433
+ };
434
+ }
435
+ onChange({
436
+ ...schema,
437
+ fields: schema.fields.map(
438
+ (field) => field.id !== fieldId ? field : condition === void 0 ? withoutDisplayCondition(field) : { ...field, displayCondition: condition }
439
+ )
440
+ });
441
+ return { success: true };
442
+ },
443
+ [onChange, schema]
444
+ );
445
+ const setSourceText = (0, import_react.useCallback)(
446
+ (target, property, text) => {
447
+ const error = textPolicyError(text);
448
+ if (error !== void 0) return error;
449
+ if (target.kind === "form") {
450
+ if (!["title", "description", "completionMessage"].includes(property))
451
+ return {
452
+ success: false,
453
+ error: { type: "invalid_operation", message: `Unsupported form property: ${property}` }
454
+ };
455
+ if (property === "title") onChange({ ...schema, title: text });
456
+ else if (property === "description") {
457
+ const { description: _description, ...withoutDescription } = schema;
458
+ onChange(text.length === 0 ? withoutDescription : { ...schema, description: text });
459
+ } else {
460
+ const { completionMessage: _completionMessage, ...withoutCompletionMessage } = schema;
461
+ onChange(text.length === 0 ? withoutCompletionMessage : { ...schema, completionMessage: text });
462
+ }
463
+ return { success: true };
464
+ }
465
+ if (target.id === void 0)
466
+ return { success: false, error: { type: "invalid_operation", message: "A target ID is required." } };
467
+ if (target.kind === "field") {
468
+ if (!["title", "description"].includes(property))
469
+ return {
470
+ success: false,
471
+ error: { type: "invalid_operation", message: `Unsupported field property: ${property}` }
472
+ };
473
+ return updateField(target.id, (field) => {
474
+ if (property === "title") return { ...field, title: text };
475
+ const { description: _description, ...withoutDescription } = field;
476
+ return text.length === 0 ? withoutDescription : { ...field, description: text };
360
477
  });
361
- return;
362
478
  }
363
- const fields = schema.fields.map((field) => {
364
- if (field.id === target && ["title", "description"].includes(property)) {
479
+ if (target.kind === "option") {
480
+ if (property !== "label")
481
+ return {
482
+ success: false,
483
+ error: { type: "invalid_operation", message: `Unsupported option property: ${property}` }
484
+ };
485
+ for (const field of schema.fields)
486
+ if ("options" in field && field.options.some((option) => option.id === target.id))
487
+ return updateOption(field.id, target.id, (option) => ({ ...option, label: text }));
488
+ return { success: false, error: { type: "node_not_found", kind: "option", id: target.id } };
489
+ }
490
+ if (!["title", "description"].includes(property))
491
+ return {
492
+ success: false,
493
+ error: { type: "invalid_operation", message: `Unsupported page property: ${property}` }
494
+ };
495
+ return updatePage(target.id, (page) => {
496
+ if (property === "title") {
497
+ const { title: _title, ...withoutTitle } = page;
498
+ return text.length === 0 ? withoutTitle : { ...page, title: text };
499
+ }
500
+ const { description: _description, ...withoutDescription } = page;
501
+ return text.length === 0 ? withoutDescription : { ...page, description: text };
502
+ });
503
+ },
504
+ [onChange, schema, textPolicyError, updateField, updateOption, updatePage]
505
+ );
506
+ const setLocaleTranslation = (0, import_react.useCallback)(
507
+ (locale, target, property, text, options = {}) => {
508
+ const normalized = locale.trim();
509
+ if (normalized.length === 0)
510
+ return { success: false, error: { type: "invalid_operation", message: "Locale must not be empty." } };
511
+ const error = textPolicyError(text);
512
+ if (error !== void 0) return error;
513
+ const supportedLocales = [.../* @__PURE__ */ new Set([...schema.supportedLocales ?? [], normalized])];
514
+ const remove = text.length === 0;
515
+ if (target.kind === "form") {
516
+ if (!["title", "description", "completionMessage"].includes(property))
365
517
  return {
366
- ...field,
367
- translations: { ...field.translations, [locale]: { ...field.translations?.[locale], [property]: text } }
518
+ success: false,
519
+ error: { type: "invalid_operation", message: `Unsupported form property: ${property}` }
368
520
  };
521
+ const key = property;
522
+ const translations = remove ? removeLocalizedProperty(schema.translations, normalized, key) : { ...schema.translations, [normalized]: { ...schema.translations?.[normalized], [key]: text } };
523
+ onChange(
524
+ setTranslationMetadata(
525
+ { ...schema, supportedLocales, ...translations === void 0 ? {} : { translations } },
526
+ normalized,
527
+ property,
528
+ options.metadata,
529
+ remove
530
+ )
531
+ );
532
+ return { success: true };
533
+ }
534
+ if (target.id === void 0)
535
+ return { success: false, error: { type: "invalid_operation", message: "A target ID is required." } };
536
+ let found = false;
537
+ const fields = schema.fields.map((field) => {
538
+ if (target.kind === "field" && field.id === target.id && ["title", "description"].includes(property)) {
539
+ found = true;
540
+ const key = property;
541
+ const translations = remove ? removeLocalizedProperty(field.translations, normalized, key) : { ...field.translations, [normalized]: { ...field.translations?.[normalized], [key]: text } };
542
+ return setTranslationMetadata(
543
+ { ...field, ...translations === void 0 ? {} : { translations } },
544
+ normalized,
545
+ property,
546
+ options.metadata,
547
+ remove
548
+ );
369
549
  }
370
- if (!("options" in field) || property !== "label") return field;
550
+ if (target.kind !== "option" || property !== "label" || !("options" in field)) return field;
371
551
  return {
372
552
  ...field,
373
- options: field.options.map(
374
- (option) => option.id === target ? { ...option, translations: { ...option.translations, [locale]: text } } : option
375
- )
553
+ options: field.options.map((option) => {
554
+ if (option.id !== target.id) return option;
555
+ found = true;
556
+ const translations = remove ? Object.fromEntries(Object.entries(option.translations ?? {}).filter(([key]) => key !== normalized)) : { ...option.translations, [normalized]: text };
557
+ return setTranslationMetadata({ ...option, translations }, normalized, property, options.metadata, remove);
558
+ })
376
559
  };
377
560
  });
378
- const pages = schema.pages?.map(
379
- (page) => page.id === target && ["title", "description"].includes(property) ? {
380
- ...page,
381
- translations: { ...page.translations, [locale]: { ...page.translations?.[locale], [property]: text } }
382
- } : page
383
- );
561
+ const pages = schema.pages?.map((page) => {
562
+ if (target.kind !== "page" || page.id !== target.id || !["title", "description"].includes(property))
563
+ return page;
564
+ found = true;
565
+ const key = property;
566
+ const translations = remove ? removeLocalizedProperty(page.translations, normalized, key) : { ...page.translations, [normalized]: { ...page.translations?.[normalized], [key]: text } };
567
+ return setTranslationMetadata(
568
+ { ...page, ...translations === void 0 ? {} : { translations } },
569
+ normalized,
570
+ property,
571
+ options.metadata,
572
+ remove
573
+ );
574
+ });
575
+ if (!found) return { success: false, error: { type: "node_not_found", kind: target.kind, id: target.id } };
384
576
  onChange({ ...schema, supportedLocales, fields, ...pages === void 0 ? {} : { pages } });
577
+ return { success: true };
385
578
  },
386
- [onChange, policy, schema]
579
+ [onChange, schema, textPolicyError]
580
+ );
581
+ const addLocale = (0, import_react.useCallback)(
582
+ (locale) => {
583
+ const normalized = locale.trim();
584
+ if (normalized.length === 0)
585
+ return { success: false, error: { type: "invalid_operation", message: "Locale must not be empty." } };
586
+ onChange({
587
+ ...schema,
588
+ supportedLocales: [
589
+ .../* @__PURE__ */ new Set([
590
+ ...schema.defaultLocale === void 0 ? [] : [schema.defaultLocale],
591
+ ...schema.supportedLocales ?? [],
592
+ normalized
593
+ ])
594
+ ]
595
+ });
596
+ return { success: true };
597
+ },
598
+ [onChange, schema]
599
+ );
600
+ const setDefaultLocale = (0, import_react.useCallback)(
601
+ (locale) => {
602
+ const normalized = locale.trim();
603
+ if (normalized.length === 0)
604
+ return { success: false, error: { type: "invalid_operation", message: "Locale must not be empty." } };
605
+ onChange({
606
+ ...schema,
607
+ defaultLocale: normalized,
608
+ supportedLocales: [.../* @__PURE__ */ new Set([normalized, ...schema.supportedLocales ?? []])]
609
+ });
610
+ return { success: true };
611
+ },
612
+ [onChange, schema]
387
613
  );
388
614
  const validationIssues = (0, import_react.useMemo)(() => {
389
- const result = (0, import_core.validateFormSchema)(schema);
390
- const issues = result.valid ? [] : [...result.issues];
391
- addPolicyIssues(schema, policy, issues);
392
- return issues;
615
+ const result = (0, import_core.validateFormSchema)(schema, policy === void 0 ? {} : { policy });
616
+ return result.valid ? [] : result.issues;
393
617
  }, [policy, schema]);
394
618
  return {
395
619
  schema,
@@ -397,12 +621,21 @@ function useFormBuilder({
397
621
  removeField,
398
622
  moveField,
399
623
  updateField,
624
+ changeFieldType,
400
625
  addOption,
626
+ updateOption,
401
627
  removeOption,
402
628
  moveOption,
403
629
  addPage,
630
+ updatePage,
404
631
  removePage,
632
+ movePage,
633
+ assignFieldToPage,
634
+ setDisplayCondition,
635
+ setSourceText,
405
636
  setLocaleTranslation,
637
+ addLocale,
638
+ setDefaultLocale,
406
639
  validationIssues
407
640
  };
408
641
  }
@@ -428,6 +661,7 @@ var BUILDER_DEFAULTS = {
428
661
  "builder.questionTitle": "\u8CEA\u554F\u6587 / Question Title",
429
662
  "builder.questionTitlePlaceholder": "Example: Tell us what we could improve",
430
663
  "builder.newQuestionTitle": "New question",
664
+ "builder.completionMessage": "Completion message",
431
665
  "builder.type": "Type",
432
666
  "builder.required": "Required",
433
667
  "builder.minimum": "Minimum",
@@ -486,33 +720,6 @@ function fieldTypeKey(type) {
486
720
  function operatorKey(operator) {
487
721
  return `builder.operator.${operator}`;
488
722
  }
489
- function createUniqueId(prefix, existingIds) {
490
- let id;
491
- do {
492
- id = `${prefix}_${globalThis.crypto.randomUUID().slice(0, 8)}`;
493
- } while (existingIds.has(id));
494
- return id;
495
- }
496
- function baseField(field, type) {
497
- return {
498
- id: field.id,
499
- type,
500
- title: field.title,
501
- ...field.description === void 0 ? {} : { description: field.description },
502
- ...field.translationKey === void 0 ? {} : { translationKey: field.translationKey },
503
- required: field.required,
504
- ...field.displayCondition === void 0 ? {} : { displayCondition: field.displayCondition }
505
- };
506
- }
507
- function normalizeField(field, type, newOptionLabel) {
508
- const base = baseField(field, type);
509
- if (type === "text" || type === "textarea") return { ...base, type };
510
- if (type === "number") return { ...base, type };
511
- if (type === "rating") return { ...base, type, min: 1, max: 5 };
512
- if (type === "checkbox") return { ...base, type };
513
- const options = "options" in field && field.options.length > 0 ? field.options : [{ id: createUniqueId("opt", /* @__PURE__ */ new Set()), label: newOptionLabel }];
514
- return { ...base, type, options };
515
- }
516
723
  function defaultConditionValue(field) {
517
724
  if (field.type === "checkbox") return true;
518
725
  if (field.type === "number" || field.type === "rating") return field.min ?? 1;
@@ -526,30 +733,6 @@ function conditionOperators(field) {
526
733
  }
527
734
  return ["equals", "not_equals", "not_empty"];
528
735
  }
529
- function withoutDisplayCondition(field) {
530
- const { displayCondition: _displayCondition, ...rest } = field;
531
- return rest;
532
- }
533
- function sanitizeBuilderSchema(schema) {
534
- const sanitized = (0, import_core2.sanitizeSchema)(schema);
535
- const indexById = new Map(sanitized.fields.map((field, index) => [field.id, index]));
536
- const fields = sanitized.fields.map((field, index) => {
537
- const sourceId = field.displayCondition?.questionId;
538
- if (sourceId === void 0) return field;
539
- const sourceIndex = indexById.get(sourceId);
540
- return sourceIndex !== void 0 && sourceIndex < index ? field : withoutDisplayCondition(field);
541
- });
542
- return {
543
- ...sanitized,
544
- fields,
545
- ...sanitized.pages === void 0 ? {} : {
546
- pages: sanitized.pages.map((page) => ({
547
- ...page,
548
- questionIds: fields.filter((field) => page.questionIds.includes(field.id)).map((field) => field.id)
549
- }))
550
- }
551
- };
552
- }
553
736
  function conditionWithValue(questionId, operator, value) {
554
737
  return operator === "not_empty" ? { questionId, operator } : { questionId, operator, value };
555
738
  }
@@ -597,14 +780,18 @@ function FormBuilder({
597
780
  locale = "en",
598
781
  translator,
599
782
  translationAdapter,
783
+ translationOptions,
784
+ onTranslationReport,
600
785
  policy,
601
- idFactory
786
+ idFactory,
787
+ factories
602
788
  }) {
603
789
  const headless = useFormBuilder({
604
790
  schema,
605
791
  onChange,
606
792
  ...policy === void 0 ? {} : { policy },
607
- ...idFactory === void 0 ? {} : { idFactory }
793
+ ...idFactory === void 0 ? {} : { idFactory },
794
+ ...factories === void 0 ? {} : { factories }
608
795
  });
609
796
  const [newPageQuestionId, setNewPageQuestionId] = (0, import_react2.useState)("");
610
797
  const [newLocale, setNewLocale] = (0, import_react2.useState)("");
@@ -615,48 +802,17 @@ function FormBuilder({
615
802
  const translated = translator?.translate(key, locale, params);
616
803
  return translated === void 0 ? interpolate(BUILDER_DEFAULTS[key] ?? key, params) : translated;
617
804
  };
618
- const emitSchema = (candidate) => onChange(sanitizeBuilderSchema(candidate));
619
805
  const updateField = headless.updateField;
620
- const changeType = (fieldId, type) => {
621
- emitSchema({
622
- ...schema,
623
- fields: schema.fields.map((field) => {
624
- if (field.id === fieldId) {
625
- return normalizeField(field, type, translate("builder.newOptionLabel", { index: 1 }));
626
- }
627
- if (field.displayCondition?.questionId === fieldId) {
628
- const { displayCondition: _condition, ...withoutCondition } = field;
629
- return withoutCondition;
630
- }
631
- return field;
632
- })
633
- });
634
- };
806
+ const changeType = headless.changeFieldType;
635
807
  const removeField = headless.removeField;
636
808
  const moveField = (index, offset) => {
637
809
  const target = index + offset;
638
- if (target < 0 || target >= schema.fields.length) return;
639
- const fields = [...schema.fields];
640
- const current = fields[index];
641
- const other = fields[target];
642
- if (current === void 0 || other === void 0) return;
643
- fields[index] = other;
644
- fields[target] = current;
645
- emitSchema({ ...schema, fields });
810
+ const field = schema.fields[index];
811
+ if (field !== void 0) headless.moveField(field.id, target);
646
812
  };
647
813
  const addField = () => headless.addField("text");
648
814
  const enablePages = () => {
649
- if (schema.pages !== void 0) return;
650
- emitSchema({
651
- ...schema,
652
- pages: [
653
- {
654
- id: createUniqueId("page", /* @__PURE__ */ new Set()),
655
- title: translate("builder.newPage"),
656
- questionIds: schema.fields.map((field) => field.id)
657
- }
658
- ]
659
- });
815
+ if (schema.pages === void 0) headless.addPage();
660
816
  };
661
817
  const pageForField = (fieldId) => schema.pages?.find((page) => page.questionIds.includes(fieldId));
662
818
  const movablePageQuestions = schema.pages?.flatMap((page) => page.questionIds.length > 1 ? page.questionIds : []) ?? [];
@@ -667,73 +823,28 @@ function FormBuilder({
667
823
  }
668
824
  const questionId = movablePageQuestions.includes(newPageQuestionId) ? newPageQuestionId : movablePageQuestions[0];
669
825
  if (questionId === void 0) return;
670
- const pageId = createUniqueId("page", new Set(schema.pages.map((page) => page.id)));
671
- emitSchema({
672
- ...schema,
673
- pages: [
674
- ...schema.pages.map((page) => ({
675
- ...page,
676
- questionIds: page.questionIds.filter((id) => id !== questionId)
677
- })),
678
- { id: pageId, title: translate("builder.newPage"), questionIds: [questionId] }
679
- ]
680
- });
826
+ headless.addPage(questionId);
681
827
  setNewPageQuestionId("");
682
828
  };
683
829
  const removePage = (pageIndex) => {
684
- if (schema.pages === void 0) return;
685
- const removed = schema.pages[pageIndex];
686
- if (removed === void 0) return;
687
- if (schema.pages.length === 1) {
688
- const { pages: _pages, ...singlePage } = schema;
689
- emitSchema(singlePage);
690
- return;
691
- }
692
- const targetIndex = pageIndex === 0 ? 1 : pageIndex - 1;
693
- emitSchema({
694
- ...schema,
695
- pages: schema.pages.map(
696
- (page, index) => index === targetIndex ? { ...page, questionIds: [...page.questionIds, ...removed.questionIds] } : page
697
- ).filter((_page, index) => index !== pageIndex)
698
- });
830
+ const page = schema.pages?.[pageIndex];
831
+ if (page !== void 0) headless.removePage(page.id);
699
832
  };
700
833
  const movePage = (pageIndex, offset) => {
701
- if (schema.pages === void 0) return;
702
834
  const target = pageIndex + offset;
703
- if (target < 0 || target >= schema.pages.length) return;
704
- const pages = [...schema.pages];
705
- const current = pages[pageIndex];
706
- const other = pages[target];
707
- if (current === void 0 || other === void 0) return;
708
- pages[pageIndex] = other;
709
- pages[target] = current;
710
- emitSchema({ ...schema, pages });
835
+ const page = schema.pages?.[pageIndex];
836
+ if (page !== void 0) headless.movePage(page.id, target);
711
837
  };
712
838
  const updatePage = (pageId, update) => {
713
- if (schema.pages === void 0) return;
714
- emitSchema({ ...schema, pages: schema.pages.map((page) => page.id === pageId ? update(page) : page) });
839
+ headless.updatePage(pageId, update);
715
840
  };
716
841
  const assignFieldToPage = (fieldId, pageId) => {
717
- if (schema.pages === void 0) return;
718
- emitSchema({
719
- ...schema,
720
- pages: schema.pages.map((page) => ({
721
- ...page,
722
- 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)
723
- })).filter((page) => page.questionIds.length > 0)
724
- });
842
+ headless.assignFieldToPage(fieldId, pageId);
725
843
  };
726
844
  const addLocale = () => {
727
845
  const normalized = newLocale.trim();
728
846
  if (normalized.length === 0) return;
729
- const supportedLocales = [
730
- .../* @__PURE__ */ new Set([
731
- ...schema.defaultLocale === void 0 ? [] : [schema.defaultLocale],
732
- ...schema.supportedLocales ?? [],
733
- normalized
734
- ])
735
- ];
736
- emitSchema({ ...schema, supportedLocales });
847
+ headless.addLocale(normalized);
737
848
  setEditingLocale(normalized);
738
849
  setNewLocale("");
739
850
  };
@@ -742,10 +853,14 @@ function FormBuilder({
742
853
  setIsTranslating(true);
743
854
  setTranslationError(null);
744
855
  try {
745
- const populated = await (0, import_core2.populateSchemaTranslations)(schema, [editingLocale], translationAdapter, {
746
- overwrite: "all"
747
- });
856
+ const populated = await (0, import_core2.populateSchemaTranslations)(
857
+ schema,
858
+ [editingLocale],
859
+ translationAdapter,
860
+ translationOptions ?? { overwrite: "all" }
861
+ );
748
862
  onChange(populated.schema);
863
+ onTranslationReport?.(populated.report);
749
864
  } catch (cause) {
750
865
  setTranslationError(cause instanceof Error ? cause.message : String(cause));
751
866
  } finally {
@@ -754,18 +869,7 @@ function FormBuilder({
754
869
  };
755
870
  const updateFormTranslation = (key, value) => {
756
871
  if (editingLocale.length === 0) return;
757
- const current = schema.translations?.[editingLocale];
758
- const next = key === "title" ? value.length === 0 ? { description: current?.description } : { ...current, title: value } : value.length === 0 ? { title: current?.title } : { ...current, description: value };
759
- emitSchema({
760
- ...schema,
761
- translations: {
762
- ...schema.translations,
763
- [editingLocale]: {
764
- ...next.title === void 0 ? {} : { title: next.title },
765
- ...next.description === void 0 ? {} : { description: next.description }
766
- }
767
- }
768
- });
872
+ headless.setLocaleTranslation(editingLocale, { kind: "form" }, key, value);
769
873
  };
770
874
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("section", { className: "form-engine-builder", "aria-label": translate("builder.formBuilder"), children: [
771
875
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("section", { className: "form-engine-builder__pages", "aria-labelledby": "builder-pages-heading", children: [
@@ -845,19 +949,12 @@ function FormBuilder({
845
949
  "input",
846
950
  {
847
951
  value: page.translations?.[editingLocale]?.title ?? "",
848
- onChange: (event) => {
849
- const value = event.currentTarget.value;
850
- updatePage(page.id, (current) => ({
851
- ...current,
852
- translations: {
853
- ...current.translations,
854
- [editingLocale]: {
855
- ...value.length === 0 ? {} : { title: value },
856
- ...current.translations?.[editingLocale]?.description === void 0 ? {} : { description: current.translations[editingLocale]?.description }
857
- }
858
- }
859
- }));
860
- }
952
+ onChange: (event) => headless.setLocaleTranslation(
953
+ editingLocale,
954
+ { kind: "page", id: page.id },
955
+ "title",
956
+ event.currentTarget.value
957
+ )
861
958
  }
862
959
  )
863
960
  ] }),
@@ -867,19 +964,12 @@ function FormBuilder({
867
964
  "input",
868
965
  {
869
966
  value: page.translations?.[editingLocale]?.description ?? "",
870
- onChange: (event) => {
871
- const value = event.currentTarget.value;
872
- updatePage(page.id, (current) => ({
873
- ...current,
874
- translations: {
875
- ...current.translations,
876
- [editingLocale]: {
877
- ...current.translations?.[editingLocale]?.title === void 0 ? {} : { title: current.translations[editingLocale]?.title },
878
- ...value.length === 0 ? {} : { description: value }
879
- }
880
- }
881
- }));
882
- }
967
+ onChange: (event) => headless.setLocaleTranslation(
968
+ editingLocale,
969
+ { kind: "page", id: page.id },
970
+ "description",
971
+ event.currentTarget.value
972
+ )
883
973
  }
884
974
  )
885
975
  ] })
@@ -967,6 +1057,16 @@ function FormBuilder({
967
1057
  ] }),
968
1058
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("section", { className: "form-engine-builder__localization", "aria-labelledby": "builder-localization-heading", children: [
969
1059
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("h2", { id: "builder-localization-heading", children: translate("builder.localization") }),
1060
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { children: [
1061
+ translate("builder.completionMessage"),
1062
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1063
+ "input",
1064
+ {
1065
+ value: schema.completionMessage ?? "",
1066
+ onChange: (event) => headless.setSourceText({ kind: "form" }, "completionMessage", event.currentTarget.value)
1067
+ }
1068
+ )
1069
+ ] }),
970
1070
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "form-engine-builder__grid", children: [
971
1071
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { children: [
972
1072
  translate("builder.defaultLocale"),
@@ -974,16 +1074,7 @@ function FormBuilder({
974
1074
  "input",
975
1075
  {
976
1076
  value: schema.defaultLocale ?? "",
977
- onChange: (event) => {
978
- const value = event.currentTarget.value.trim();
979
- emitSchema(
980
- value.length === 0 ? schema : {
981
- ...schema,
982
- defaultLocale: value,
983
- supportedLocales: [.../* @__PURE__ */ new Set([value, ...schema.supportedLocales ?? []])]
984
- }
985
- );
986
- }
1077
+ onChange: (event) => headless.setDefaultLocale(event.currentTarget.value)
987
1078
  }
988
1079
  )
989
1080
  ] }),
@@ -1031,6 +1122,16 @@ function FormBuilder({
1031
1122
  onChange: (event) => updateFormTranslation("description", event.currentTarget.value)
1032
1123
  }
1033
1124
  )
1125
+ ] }),
1126
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { children: [
1127
+ translate("builder.completionMessage"),
1128
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1129
+ "input",
1130
+ {
1131
+ value: schema.translations?.[editingLocale]?.completionMessage ?? "",
1132
+ onChange: (event) => updateFormTranslation("completionMessage", event.currentTarget.value)
1133
+ }
1134
+ )
1034
1135
  ] })
1035
1136
  ] })
1036
1137
  ] }),
@@ -1132,19 +1233,12 @@ function FormBuilder({
1132
1233
  "input",
1133
1234
  {
1134
1235
  value: field.translations?.[editingLocale]?.title ?? "",
1135
- onChange: (event) => {
1136
- const value = event.currentTarget.value;
1137
- updateField(field.id, (current) => ({
1138
- ...current,
1139
- translations: {
1140
- ...current.translations,
1141
- [editingLocale]: {
1142
- ...value.length === 0 ? {} : { title: value },
1143
- ...current.translations?.[editingLocale]?.description === void 0 ? {} : { description: current.translations[editingLocale]?.description }
1144
- }
1145
- }
1146
- }));
1147
- }
1236
+ onChange: (event) => headless.setLocaleTranslation(
1237
+ editingLocale,
1238
+ { kind: "field", id: field.id },
1239
+ "title",
1240
+ event.currentTarget.value
1241
+ )
1148
1242
  }
1149
1243
  )
1150
1244
  ] }),
@@ -1154,19 +1248,12 @@ function FormBuilder({
1154
1248
  "input",
1155
1249
  {
1156
1250
  value: field.translations?.[editingLocale]?.description ?? "",
1157
- onChange: (event) => {
1158
- const value = event.currentTarget.value;
1159
- updateField(field.id, (current) => ({
1160
- ...current,
1161
- translations: {
1162
- ...current.translations,
1163
- [editingLocale]: {
1164
- ...current.translations?.[editingLocale]?.title === void 0 ? {} : { title: current.translations[editingLocale]?.title },
1165
- ...value.length === 0 ? {} : { description: value }
1166
- }
1167
- }
1168
- }));
1169
- }
1251
+ onChange: (event) => headless.setLocaleTranslation(
1252
+ editingLocale,
1253
+ { kind: "field", id: field.id },
1254
+ "description",
1255
+ event.currentTarget.value
1256
+ )
1170
1257
  }
1171
1258
  )
1172
1259
  ] })
@@ -1180,26 +1267,12 @@ function FormBuilder({
1180
1267
  "input",
1181
1268
  {
1182
1269
  value: option.translations?.[editingLocale] ?? "",
1183
- onChange: (event) => {
1184
- const value = event.currentTarget.value;
1185
- updateField(field.id, (current) => {
1186
- if (!("options" in current)) return current;
1187
- return {
1188
- ...current,
1189
- options: current.options.map(
1190
- (candidate) => candidate.id === option.id ? {
1191
- ...candidate,
1192
- translations: Object.fromEntries([
1193
- ...Object.entries(candidate.translations ?? {}).filter(
1194
- ([localeKey]) => localeKey !== editingLocale
1195
- ),
1196
- ...value.length === 0 ? [] : [[editingLocale, value]]
1197
- ])
1198
- } : candidate
1199
- )
1200
- };
1201
- });
1202
- }
1270
+ onChange: (event) => headless.setLocaleTranslation(
1271
+ editingLocale,
1272
+ { kind: "option", id: option.id },
1273
+ "label",
1274
+ event.currentTarget.value
1275
+ )
1203
1276
  }
1204
1277
  )
1205
1278
  ] }, option.id)) : null
@@ -1251,17 +1324,10 @@ function FormBuilder({
1251
1324
  "aria-label": translate("builder.optionLabel", { index: optionIndex + 1 }),
1252
1325
  value: option.label,
1253
1326
  placeholder: translate("builder.optionLabelPlaceholder"),
1254
- onChange: (event) => updateField(field.id, (current) => {
1255
- if (!("options" in current)) return current;
1256
- const label = event.currentTarget.value;
1257
- if (label.trim().length === 0) return current;
1258
- return {
1259
- ...current,
1260
- options: current.options.map(
1261
- (item, itemIndex) => itemIndex === optionIndex ? { ...item, label } : item
1262
- )
1263
- };
1264
- })
1327
+ onChange: (event) => event.currentTarget.value.trim().length === 0 ? void 0 : headless.updateOption(field.id, option.id, (item) => ({
1328
+ ...item,
1329
+ label: event.currentTarget.value
1330
+ }))
1265
1331
  }
1266
1332
  ),
1267
1333
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
@@ -1293,17 +1359,14 @@ function FormBuilder({
1293
1359
  value: condition?.questionId ?? "",
1294
1360
  onChange: (event) => {
1295
1361
  const selected = schema.fields.find((item) => item.id === event.currentTarget.value);
1296
- updateField(field.id, (current) => {
1297
- if (selected === void 0) {
1298
- const { displayCondition: _condition, ...withoutCondition } = current;
1299
- return withoutCondition;
1300
- }
1301
- const operator = conditionOperators(selected)[0] ?? "not_empty";
1302
- return {
1303
- ...current,
1304
- displayCondition: conditionWithValue(selected.id, operator, defaultConditionValue(selected))
1305
- };
1306
- });
1362
+ headless.setDisplayCondition(
1363
+ field.id,
1364
+ selected === void 0 ? void 0 : conditionWithValue(
1365
+ selected.id,
1366
+ conditionOperators(selected)[0] ?? "not_empty",
1367
+ defaultConditionValue(selected)
1368
+ )
1369
+ );
1307
1370
  },
1308
1371
  children: [
1309
1372
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("option", { value: "", children: translate("builder.alwaysVisible") }),
@@ -1320,10 +1383,10 @@ function FormBuilder({
1320
1383
  value: condition.operator,
1321
1384
  onChange: (event) => {
1322
1385
  const operator = event.currentTarget.value;
1323
- updateField(field.id, (current) => ({
1324
- ...current,
1325
- displayCondition: conditionWithValue(source.id, operator, defaultConditionValue(source))
1326
- }));
1386
+ headless.setDisplayCondition(
1387
+ field.id,
1388
+ conditionWithValue(source.id, operator, defaultConditionValue(source))
1389
+ );
1327
1390
  },
1328
1391
  children: conditionOperators(source).map((operator) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("option", { value: operator, children: translate(operatorKey(operator)) }, operator))
1329
1392
  }
@@ -1333,7 +1396,7 @@ function FormBuilder({
1333
1396
  {
1334
1397
  source,
1335
1398
  condition,
1336
- onChange: (next) => updateField(field.id, (current) => ({ ...current, displayCondition: next })),
1399
+ onChange: (next) => headless.setDisplayCondition(field.id, next),
1337
1400
  translate
1338
1401
  }
1339
1402
  )
@@ -1868,8 +1931,14 @@ function ContextFormRenderer({
1868
1931
  ] }),
1869
1932
  draftRestored ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { className: "form-draft-badge", children: form.translate("form.draftRestored") }) : null
1870
1933
  ] }),
1871
- activePage?.title === void 0 ? null : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("h2", { className: "fe-page-title", children: activePage.title }),
1872
- activePage?.description === void 0 ? null : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { className: "fe-page-description", children: activePage.description }),
1934
+ activePage === void 0 ? null : slots.renderPageHeader?.({
1935
+ page: activePage,
1936
+ pageIndex: activeVisibleIndex,
1937
+ totalPages: visiblePageIndexes.length
1938
+ }) ?? /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "fe-page-header", children: [
1939
+ activePage.title === void 0 ? null : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("h2", { className: "fe-page-title", children: activePage.title }),
1940
+ activePage.description === void 0 ? null : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { className: "fe-page-description", children: activePage.description })
1941
+ ] }),
1873
1942
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: "fe-fields", children: form.schema.fields.filter((field) => form.visibility[field.id] === true && (fieldIds === void 0 || fieldIds.has(field.id))).map((field) => {
1874
1943
  const error = form.errors[field.id];
1875
1944
  const props = {
@@ -1937,7 +2006,7 @@ function ContextFormRenderer({
1937
2006
  form.submitStatus === "success" ? slots.renderCompletion?.({
1938
2007
  message: form.schema.completionMessage ?? (successMessageKey === void 0 ? "Submitted." : form.translate(successMessageKey))
1939
2008
  }) ?? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { role: "status", children: form.schema.completionMessage ?? (successMessageKey === void 0 ? "Submitted." : form.translate(successMessageKey)) }) : null,
1940
- form.submitStatus === "error" && form.submitError !== null && errorMessageKey !== void 0 ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { role: "alert", children: form.translate(errorMessageKey) }) : null
2009
+ form.submitStatus === "error" && form.submitError !== null ? slots.renderSubmitError?.({ error: form.submitError, onRetry: () => void submitValues() }) ?? (errorMessageKey === void 0 ? null : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { role: "alert", children: form.translate(errorMessageKey) })) : null
1941
2010
  ] })
1942
2011
  ] });
1943
2012
  }