@form-engine-ts/react 2.1.1 → 2.2.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/README.md CHANGED
@@ -55,7 +55,7 @@ batch-translation action.
55
55
 
56
56
  `useFormBuilder({ schema, onChange, policy, idFactory, factories })` exposes controlled field, option, page, condition,
57
57
  source-text, and localized-text actions. Core's `FormPolicy` enforces allowed field types, field/option limits, text and
58
- schema-byte limits, and required locales identically in browser and server code. Every mutation returns a typed
58
+ schema-byte limits, and required/allowed/maximum locale constraints identically in browser and server code. Every mutation returns a typed
59
59
  `BuilderActionResult`; invalid, empty, or duplicate generated IDs leave the schema unchanged. `BuilderFactories` injects
60
60
  initial field, option, and page shapes. `<FormBuilder>` delegates its UI mutations to this hook and accepts the same
61
61
  options. Its completion-message editors cover both source and locale text. `translationOptions` and
@@ -67,6 +67,11 @@ Visual-builder field creation uses `defaultFieldType` when it is allowed, otherw
67
67
  per-locale/property metadata to manual edits. Automatic translation defaults to `overwrite: "missing-only"`; explicitly
68
68
  pass `{ overwrite: "all" }` to replace existing translations.
69
69
 
70
+ Pass `readOnly` to keep the complete builder visible while disabling every mutation. The `features` prop independently
71
+ controls `pages`, `localization`, and `conditions` authoring surfaces; each defaults to `true`. When `allowedLocales` is
72
+ set, locale addition becomes a selector containing only unregistered allowed locales, and the action is disabled at
73
+ `maxLocales`.
74
+
70
75
  `FormRenderer` can also be used without an explicit provider:
71
76
 
72
77
  ```tsx
package/dist/index.cjs CHANGED
@@ -162,10 +162,16 @@ function useFormBuilder({
162
162
  if (pages === void 0) return { success: false, error: { type: "node_not_found", kind: "page", id: pageId } };
163
163
  const updated = updater(page);
164
164
  if (updated.id !== pageId) return { success: false, error: { type: "invalid_id", kind: "page", id: updated.id } };
165
+ for (const text of [updated.title, updated.description]) {
166
+ if (text !== void 0) {
167
+ const error = textPolicyError(text);
168
+ if (error !== void 0) return error;
169
+ }
170
+ }
165
171
  onChange({ ...schema, pages: pages.map((candidate) => candidate.id === pageId ? updated : candidate) });
166
172
  return { success: true };
167
173
  },
168
- [onChange, schema]
174
+ [onChange, schema, textPolicyError]
169
175
  );
170
176
  const addField = (0, import_react.useCallback)(
171
177
  (type, pageId) => {
@@ -584,6 +590,17 @@ function useFormBuilder({
584
590
  const normalized = locale.trim();
585
591
  if (normalized.length === 0)
586
592
  return { success: false, error: { type: "invalid_operation", message: "Locale must not be empty." } };
593
+ if (policy?.allowedLocales !== void 0 && !policy.allowedLocales.includes(normalized)) {
594
+ return { success: false, error: { type: "disallowed_locale", locale: normalized } };
595
+ }
596
+ const registeredLocales = /* @__PURE__ */ new Set([
597
+ ...schema.defaultLocale === void 0 ? [] : [schema.defaultLocale],
598
+ ...schema.supportedLocales ?? []
599
+ ]);
600
+ if (registeredLocales.has(normalized)) return { success: true };
601
+ if (policy?.maxLocales !== void 0 && registeredLocales.size >= policy.maxLocales) {
602
+ return { success: false, error: { type: "max_locales_exceeded", max: policy.maxLocales } };
603
+ }
587
604
  onChange({
588
605
  ...schema,
589
606
  supportedLocales: [
@@ -596,13 +613,23 @@ function useFormBuilder({
596
613
  });
597
614
  return { success: true };
598
615
  },
599
- [onChange, schema]
616
+ [onChange, policy?.allowedLocales, policy?.maxLocales, schema]
600
617
  );
601
618
  const setDefaultLocale = (0, import_react.useCallback)(
602
619
  (locale) => {
603
620
  const normalized = locale.trim();
604
621
  if (normalized.length === 0)
605
622
  return { success: false, error: { type: "invalid_operation", message: "Locale must not be empty." } };
623
+ if (policy?.allowedLocales !== void 0 && !policy.allowedLocales.includes(normalized)) {
624
+ return { success: false, error: { type: "disallowed_locale", locale: normalized } };
625
+ }
626
+ const registeredLocales = /* @__PURE__ */ new Set([
627
+ ...schema.defaultLocale === void 0 ? [] : [schema.defaultLocale],
628
+ ...schema.supportedLocales ?? []
629
+ ]);
630
+ if (!registeredLocales.has(normalized) && policy?.maxLocales !== void 0 && registeredLocales.size >= policy.maxLocales) {
631
+ return { success: false, error: { type: "max_locales_exceeded", max: policy.maxLocales } };
632
+ }
606
633
  onChange({
607
634
  ...schema,
608
635
  defaultLocale: normalized,
@@ -610,7 +637,7 @@ function useFormBuilder({
610
637
  });
611
638
  return { success: true };
612
639
  },
613
- [onChange, schema]
640
+ [onChange, policy?.allowedLocales, policy?.maxLocales, schema]
614
641
  );
615
642
  const validationIssues = (0, import_react.useMemo)(() => {
616
643
  const result = (0, import_core.validateFormSchema)(schema, policy === void 0 ? {} : { policy });
@@ -797,7 +824,9 @@ function FormBuilder({
797
824
  className = "",
798
825
  defaultFieldType,
799
826
  onActionError,
800
- createManualTranslationMetadata
827
+ createManualTranslationMetadata,
828
+ readOnly = false,
829
+ features
801
830
  }) {
802
831
  const headless = useFormBuilder({
803
832
  schema,
@@ -815,28 +844,33 @@ function FormBuilder({
815
844
  const translated = translator?.translate(key, locale, params);
816
845
  return translated === void 0 ? interpolate(BUILDER_DEFAULTS[key] ?? key, params) : translated;
817
846
  };
818
- const executeAction = (result, context) => {
847
+ const pagesEnabled = features?.pages ?? true;
848
+ const localizationEnabled = features?.localization ?? true;
849
+ const conditionsEnabled = features?.conditions ?? true;
850
+ const executeAction = (run, context) => {
851
+ if (readOnly) return { success: true };
852
+ const result = run();
819
853
  if (!result.success) onActionError?.(result.error, context);
820
854
  return result;
821
855
  };
822
- const updateField = (fieldId, updater, params) => executeAction(headless.updateField(fieldId, updater), {
856
+ const updateField = (fieldId, updater, params) => executeAction(() => headless.updateField(fieldId, updater), {
823
857
  action: "updateField",
824
858
  targetId: fieldId,
825
859
  ...params === void 0 ? {} : { params }
826
860
  });
827
- const changeType = (fieldId, type) => executeAction(headless.changeFieldType(fieldId, type), {
861
+ const changeType = (fieldId, type) => executeAction(() => headless.changeFieldType(fieldId, type), {
828
862
  action: "changeFieldType",
829
863
  targetId: fieldId,
830
864
  params: { type }
831
865
  });
832
- const removeField = (fieldId) => executeAction(headless.removeField(fieldId), { action: "removeField", targetId: fieldId });
866
+ const removeField = (fieldId) => executeAction(() => headless.removeField(fieldId), { action: "removeField", targetId: fieldId });
833
867
  const initialFieldType = resolveInitialFieldType(defaultFieldType, policy?.allowedFieldTypes);
834
868
  const maxFieldsReached = policy?.maxFields !== void 0 && schema.fields.length >= policy.maxFields;
835
869
  const moveField = (index, offset) => {
836
870
  const target = index + offset;
837
871
  const field = schema.fields[index];
838
872
  if (field !== void 0) {
839
- executeAction(headless.moveField(field.id, target), {
873
+ executeAction(() => headless.moveField(field.id, target), {
840
874
  action: "moveField",
841
875
  targetId: field.id,
842
876
  params: { targetIndex: target }
@@ -845,10 +879,10 @@ function FormBuilder({
845
879
  };
846
880
  const addField = () => {
847
881
  if (initialFieldType === null) return;
848
- executeAction(headless.addField(initialFieldType), { action: "addField" });
882
+ executeAction(() => headless.addField(initialFieldType), { action: "addField" });
849
883
  };
850
884
  const enablePages = () => {
851
- if (schema.pages === void 0) executeAction(headless.addPage(), { action: "addPage" });
885
+ if (schema.pages === void 0) executeAction(() => headless.addPage(), { action: "addPage" });
852
886
  };
853
887
  const pageForField = (fieldId) => schema.pages?.find((page) => page.questionIds.includes(fieldId));
854
888
  const movablePageQuestions = schema.pages?.flatMap((page) => page.questionIds.length > 1 ? page.questionIds : []) ?? [];
@@ -859,7 +893,7 @@ function FormBuilder({
859
893
  }
860
894
  const questionId = movablePageQuestions.includes(newPageQuestionId) ? newPageQuestionId : movablePageQuestions[0];
861
895
  if (questionId === void 0) return;
862
- executeAction(headless.addPage(questionId), {
896
+ executeAction(() => headless.addPage(questionId), {
863
897
  action: "addPage",
864
898
  targetId: questionId,
865
899
  params: { questionId }
@@ -868,13 +902,14 @@ function FormBuilder({
868
902
  };
869
903
  const removePage = (pageIndex) => {
870
904
  const page = schema.pages?.[pageIndex];
871
- if (page !== void 0) executeAction(headless.removePage(page.id), { action: "removePage", targetId: page.id });
905
+ if (page !== void 0)
906
+ executeAction(() => headless.removePage(page.id), { action: "removePage", targetId: page.id });
872
907
  };
873
908
  const movePage = (pageIndex, offset) => {
874
909
  const target = pageIndex + offset;
875
910
  const page = schema.pages?.[pageIndex];
876
911
  if (page !== void 0) {
877
- executeAction(headless.movePage(page.id, target), {
912
+ executeAction(() => headless.movePage(page.id, target), {
878
913
  action: "movePage",
879
914
  targetId: page.id,
880
915
  params: { targetIndex: target }
@@ -882,24 +917,36 @@ function FormBuilder({
882
917
  }
883
918
  };
884
919
  const updatePage = (pageId, update) => {
885
- headless.updatePage(pageId, update);
920
+ executeAction(() => headless.updatePage(pageId, update), {
921
+ action: "updatePage",
922
+ targetId: pageId
923
+ });
886
924
  };
887
925
  const assignFieldToPage = (fieldId, pageId) => {
888
- executeAction(headless.assignFieldToPage(fieldId, pageId), {
926
+ executeAction(() => headless.assignFieldToPage(fieldId, pageId), {
889
927
  action: "assignFieldToPage",
890
928
  targetId: fieldId,
891
929
  params: { pageId }
892
930
  });
893
931
  };
894
932
  const addLocale = () => {
933
+ if (readOnly) return;
895
934
  const normalized = newLocale.trim();
896
935
  if (normalized.length === 0) return;
897
- headless.addLocale(normalized);
936
+ const result = executeAction(() => headless.addLocale(normalized), {
937
+ action: "addLocale",
938
+ params: { locale: normalized }
939
+ });
940
+ if (!result.success) return;
898
941
  setEditingLocale(normalized);
899
942
  setNewLocale("");
900
943
  };
944
+ const setDefaultLocale = (locale2) => executeAction(() => headless.setDefaultLocale(locale2), {
945
+ action: "setDefaultLocale",
946
+ params: { locale: locale2 }
947
+ });
901
948
  const translateAll = async () => {
902
- if (translationAdapter === void 0 || editingLocale.length === 0) return;
949
+ if (readOnly || translationAdapter === void 0 || editingLocale.length === 0) return;
903
950
  setIsTranslating(true);
904
951
  setTranslationError(null);
905
952
  try {
@@ -916,10 +963,11 @@ function FormBuilder({
916
963
  }
917
964
  };
918
965
  const updateManualTranslation = (context) => {
966
+ if (readOnly) return;
919
967
  const metadata = createManualTranslationMetadata?.(context);
920
968
  const target = context.kind === "form" ? { kind: "form" } : { kind: context.kind, id: context.nodeId };
921
969
  executeAction(
922
- headless.setLocaleTranslation(
970
+ () => headless.setLocaleTranslation(
923
971
  context.locale,
924
972
  target,
925
973
  context.property,
@@ -945,41 +993,51 @@ function FormBuilder({
945
993
  ...schema.translationMetadata?.[editingLocale]?.[property] === void 0 ? {} : { existingTranslationMetadata: schema.translationMetadata[editingLocale]?.[property] }
946
994
  });
947
995
  };
948
- const setSourceText = (target, property, text) => executeAction(headless.setSourceText(target, property, text), {
996
+ const setSourceText = (target, property, text) => executeAction(() => headless.setSourceText(target, property, text), {
949
997
  action: "setSourceText",
950
998
  ...target.id === void 0 ? {} : { targetId: target.id },
951
999
  params: { kind: target.kind, property }
952
1000
  });
953
- const updateOption = (fieldId, optionId, label) => executeAction(
954
- headless.updateOption(fieldId, optionId, (option) => ({ ...option, label })),
955
- { action: "updateOption", targetId: optionId, params: { fieldId } }
956
- );
957
- const addOption = (fieldId) => executeAction(headless.addOption(fieldId), { action: "addOption", targetId: fieldId });
958
- const removeOption = (fieldId, optionId) => executeAction(headless.removeOption(fieldId, optionId), {
1001
+ const updateOption = (fieldId, optionId, label) => executeAction(() => headless.updateOption(fieldId, optionId, (option) => ({ ...option, label })), {
1002
+ action: "updateOption",
1003
+ targetId: optionId,
1004
+ params: { fieldId }
1005
+ });
1006
+ const addOption = (fieldId) => executeAction(() => headless.addOption(fieldId), { action: "addOption", targetId: fieldId });
1007
+ const removeOption = (fieldId, optionId) => executeAction(() => headless.removeOption(fieldId, optionId), {
959
1008
  action: "removeOption",
960
1009
  targetId: optionId,
961
1010
  params: { fieldId }
962
1011
  });
963
- const moveOption = (fieldId, optionId, targetIndex) => executeAction(headless.moveOption(fieldId, optionId, targetIndex), {
1012
+ const moveOption = (fieldId, optionId, targetIndex) => executeAction(() => headless.moveOption(fieldId, optionId, targetIndex), {
964
1013
  action: "moveOption",
965
1014
  targetId: optionId,
966
1015
  params: { fieldId, targetIndex }
967
1016
  });
968
- const setDisplayCondition = (fieldId, condition) => executeAction(headless.setDisplayCondition(fieldId, condition), {
1017
+ const setDisplayCondition = (fieldId, condition) => executeAction(() => headless.setDisplayCondition(fieldId, condition), {
969
1018
  action: "setDisplayCondition",
970
1019
  targetId: fieldId,
971
1020
  ...condition === void 0 ? {} : { params: { condition } }
972
1021
  });
973
- return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
1022
+ const registeredLocales = /* @__PURE__ */ new Set([
1023
+ ...schema.defaultLocale === void 0 ? [] : [schema.defaultLocale],
1024
+ ...schema.supportedLocales ?? []
1025
+ ]);
1026
+ const availableAllowedLocales = policy?.allowedLocales?.filter((candidate) => !registeredLocales.has(candidate));
1027
+ const localeLimitReached = policy?.maxLocales !== void 0 && registeredLocales.size >= policy.maxLocales;
1028
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
974
1029
  "section",
975
1030
  {
976
1031
  className: `form-engine-builder ${className}`.trim(),
977
1032
  "aria-label": translate("builder.formBuilder"),
978
1033
  onClickCapture: (event) => {
1034
+ if (readOnly) return;
979
1035
  if (!(event.target instanceof HTMLElement)) return;
980
1036
  const actionTarget = event.target.closest("[data-builder-action]");
981
1037
  if (actionTarget?.dataset.builderAction === "addField" && maxFieldsReached && initialFieldType !== null)
982
1038
  addField();
1039
+ if (actionTarget?.dataset.builderAction === "addLocale" && localeLimitReached && newLocale.trim().length > 0)
1040
+ addLocale();
983
1041
  if (actionTarget?.dataset.builderAction !== "addOption") return;
984
1042
  const fieldId = actionTarget.dataset.targetId;
985
1043
  const field = schema.fields.find((candidate) => candidate.id === fieldId);
@@ -987,12 +1045,14 @@ function FormBuilder({
987
1045
  addOption(fieldId);
988
1046
  }
989
1047
  },
990
- children: [
991
- /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("section", { className: "form-engine-builder__pages", "aria-labelledby": "builder-pages-heading", children: [
1048
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("fieldset", { className: "form-engine-builder__controls", disabled: readOnly, children: [
1049
+ pagesEnabled ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("section", { className: "form-engine-builder__pages", "aria-labelledby": "builder-pages-heading", children: [
992
1050
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("h2", { id: "builder-pages-heading", children: translate("builder.pages") }),
993
1051
  schema.pages === void 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", onClick: enablePages, children: translate("builder.enablePages") }) : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
994
1052
  schema.pages.map((page, pageIndex) => {
995
- const priorQuestionIds = new Set(schema.pages?.slice(0, pageIndex).flatMap((item) => item.questionIds));
1053
+ const priorQuestionIds = new Set(
1054
+ schema.pages?.slice(0, pageIndex).flatMap((item) => item.questionIds)
1055
+ );
996
1056
  const availableSources = schema.fields.filter((field) => priorQuestionIds.has(field.id));
997
1057
  const source = schema.fields.find((field) => field.id === page.displayCondition?.questionId);
998
1058
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("fieldset", { className: "form-engine-builder__page", children: [
@@ -1056,7 +1116,7 @@ function FormBuilder({
1056
1116
  )
1057
1117
  ] })
1058
1118
  ] }),
1059
- editingLocale.length === 0 ? null : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "form-engine-builder__translation-editor", children: [
1119
+ !localizationEnabled || editingLocale.length === 0 ? null : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "form-engine-builder__translation-editor", children: [
1060
1120
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("strong", { children: editingLocale }),
1061
1121
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "form-engine-builder__grid", children: [
1062
1122
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { children: [
@@ -1101,7 +1161,7 @@ function FormBuilder({
1101
1161
  ] })
1102
1162
  ] })
1103
1163
  ] }),
1104
- /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "form-engine-builder__condition", children: [
1164
+ conditionsEnabled ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "form-engine-builder__condition", children: [
1105
1165
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { children: [
1106
1166
  translate("builder.pageCondition"),
1107
1167
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
@@ -1142,7 +1202,11 @@ function FormBuilder({
1142
1202
  const operator = event.currentTarget.value;
1143
1203
  updatePage(page.id, (current) => ({
1144
1204
  ...current,
1145
- displayCondition: conditionWithValue(source.id, operator, defaultConditionValue(source))
1205
+ displayCondition: conditionWithValue(
1206
+ source.id,
1207
+ operator,
1208
+ defaultConditionValue(source)
1209
+ )
1146
1210
  }));
1147
1211
  },
1148
1212
  children: conditionOperators(source).map((operator) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("option", { value: operator, children: translate(operatorKey(operator)) }, operator))
@@ -1158,7 +1222,7 @@ function FormBuilder({
1158
1222
  }
1159
1223
  )
1160
1224
  ] }) : null
1161
- ] })
1225
+ ] }) : null
1162
1226
  ] }, page.id);
1163
1227
  }),
1164
1228
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "form-engine-builder__page-add", children: [
@@ -1180,8 +1244,8 @@ function FormBuilder({
1180
1244
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", disabled: movablePageQuestions.length === 0, onClick: addPage, children: translate("builder.addPage") })
1181
1245
  ] })
1182
1246
  ] })
1183
- ] }),
1184
- /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("section", { className: "form-engine-builder__localization", "aria-labelledby": "builder-localization-heading", children: [
1247
+ ] }) : null,
1248
+ localizationEnabled ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("section", { className: "form-engine-builder__localization", "aria-labelledby": "builder-localization-heading", children: [
1185
1249
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("h2", { id: "builder-localization-heading", children: translate("builder.localization") }),
1186
1250
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { children: [
1187
1251
  translate("builder.completionMessage"),
@@ -1200,15 +1264,42 @@ function FormBuilder({
1200
1264
  "input",
1201
1265
  {
1202
1266
  value: schema.defaultLocale ?? "",
1203
- onChange: (event) => headless.setDefaultLocale(event.currentTarget.value)
1267
+ onChange: (event) => setDefaultLocale(event.currentTarget.value)
1204
1268
  }
1205
1269
  )
1206
1270
  ] }),
1207
- /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { children: [
1271
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { htmlFor: "builder-new-locale", children: [
1208
1272
  translate("builder.addLocale"),
1209
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("input", { value: newLocale, onChange: (event) => setNewLocale(event.currentTarget.value) })
1273
+ availableAllowedLocales === void 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1274
+ "input",
1275
+ {
1276
+ id: "builder-new-locale",
1277
+ value: newLocale,
1278
+ onChange: (event) => setNewLocale(event.currentTarget.value)
1279
+ }
1280
+ ) : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
1281
+ "select",
1282
+ {
1283
+ id: "builder-new-locale",
1284
+ value: newLocale,
1285
+ onChange: (event) => setNewLocale(event.currentTarget.value),
1286
+ children: [
1287
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("option", { value: "", children: "\u2014" }),
1288
+ availableAllowedLocales.map((candidate) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("option", { value: candidate, children: candidate }, candidate))
1289
+ ]
1290
+ }
1291
+ )
1210
1292
  ] }),
1211
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", onClick: addLocale, children: translate("builder.addLocale") }),
1293
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1294
+ "button",
1295
+ {
1296
+ type: "button",
1297
+ "data-builder-action": "addLocale",
1298
+ disabled: newLocale.trim().length === 0 || localeLimitReached,
1299
+ onClick: addLocale,
1300
+ children: translate("builder.addLocale")
1301
+ }
1302
+ ),
1212
1303
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { children: [
1213
1304
  translate("builder.editLocale"),
1214
1305
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("select", { value: editingLocale, onChange: (event) => setEditingLocale(event.currentTarget.value), children: [
@@ -1260,7 +1351,7 @@ function FormBuilder({
1260
1351
  )
1261
1352
  ] })
1262
1353
  ] })
1263
- ] }),
1354
+ ] }) : null,
1264
1355
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "form-engine-builder__list", children: schema.fields.map((field, index) => {
1265
1356
  const condition = field.displayCondition;
1266
1357
  const source = condition === void 0 ? void 0 : schema.fields.find((item) => item.id === condition.questionId);
@@ -1339,7 +1430,7 @@ function FormBuilder({
1339
1430
  translate("builder.required")
1340
1431
  ] })
1341
1432
  ] }),
1342
- schema.pages === void 0 ? null : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { children: [
1433
+ !pagesEnabled || schema.pages === void 0 ? null : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { children: [
1343
1434
  translate("builder.questionPage"),
1344
1435
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1345
1436
  "select",
@@ -1350,7 +1441,7 @@ function FormBuilder({
1350
1441
  }
1351
1442
  )
1352
1443
  ] }),
1353
- editingLocale.length === 0 ? null : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "form-engine-builder__translation-editor", children: [
1444
+ !localizationEnabled || editingLocale.length === 0 ? null : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "form-engine-builder__translation-editor", children: [
1354
1445
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("strong", { children: editingLocale }),
1355
1446
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "form-engine-builder__grid", children: [
1356
1447
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { children: [
@@ -1510,7 +1601,7 @@ function FormBuilder({
1510
1601
  }
1511
1602
  )
1512
1603
  ] }) : null,
1513
- /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "form-engine-builder__condition", children: [
1604
+ conditionsEnabled ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "form-engine-builder__condition", children: [
1514
1605
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { children: [
1515
1606
  translate("builder.displayCondition"),
1516
1607
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
@@ -1561,7 +1652,7 @@ function FormBuilder({
1561
1652
  }
1562
1653
  )
1563
1654
  ] }) : null
1564
- ] })
1655
+ ] }) : null
1565
1656
  ] }, field.id);
1566
1657
  }) }),
1567
1658
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
@@ -1575,7 +1666,7 @@ function FormBuilder({
1575
1666
  children: translate("builder.addQuestion")
1576
1667
  }
1577
1668
  )
1578
- ]
1669
+ ] })
1579
1670
  }
1580
1671
  );
1581
1672
  }
package/dist/index.d.cts CHANGED
@@ -37,6 +37,12 @@ type BuilderActionError = {
37
37
  } | {
38
38
  readonly type: "disallowed_field_type";
39
39
  readonly fieldType: QuestionType;
40
+ } | {
41
+ readonly type: "disallowed_locale";
42
+ readonly locale: string;
43
+ } | {
44
+ readonly type: "max_locales_exceeded";
45
+ readonly max: number;
40
46
  } | {
41
47
  readonly type: "node_not_found";
42
48
  readonly kind: BuilderTextTarget["kind"];
@@ -79,7 +85,7 @@ interface FormBuilderResult {
79
85
  declare function useFormBuilder({ schema, onChange, policy, idFactory, factories }: FormBuilderOptions): FormBuilderResult;
80
86
 
81
87
  interface BuilderActionContext {
82
- readonly action: "addField" | "removeField" | "moveField" | "changeFieldType" | "updateField" | "addOption" | "removeOption" | "moveOption" | "updateOption" | "addPage" | "removePage" | "movePage" | "assignFieldToPage" | "setDisplayCondition" | "setSourceText" | "setLocaleTranslation";
88
+ readonly action: "addField" | "removeField" | "moveField" | "changeFieldType" | "updateField" | "addOption" | "removeOption" | "moveOption" | "updateOption" | "addPage" | "removePage" | "movePage" | "updatePage" | "assignFieldToPage" | "addLocale" | "setDefaultLocale" | "setDisplayCondition" | "setSourceText" | "setLocaleTranslation";
83
89
  readonly targetId?: string;
84
90
  readonly params?: Record<string, unknown>;
85
91
  }
@@ -145,6 +151,11 @@ interface FormRendererSlots {
145
151
  type BeforeSubmit = (values: Readonly<Record<string, unknown>>) => "continue" | "cancel" | Promise<"continue" | "cancel">;
146
152
 
147
153
  declare function resolveInitialFieldType(defaultType?: QuestionType, allowedTypes?: readonly QuestionType[]): QuestionType | null;
154
+ interface FormBuilderFeatures {
155
+ readonly pages?: boolean;
156
+ readonly localization?: boolean;
157
+ readonly conditions?: boolean;
158
+ }
148
159
  interface FormBuilderProps {
149
160
  readonly schema: FormSchema;
150
161
  readonly onChange: (newSchema: FormSchema) => void;
@@ -160,8 +171,10 @@ interface FormBuilderProps {
160
171
  readonly defaultFieldType?: QuestionType;
161
172
  readonly onActionError?: (error: BuilderActionError, context: BuilderActionContext) => void;
162
173
  readonly createManualTranslationMetadata?: (context: ManualTranslationContext) => Readonly<Record<string, JsonValue>> | undefined;
174
+ readonly readOnly?: boolean;
175
+ readonly features?: FormBuilderFeatures;
163
176
  }
164
- declare function FormBuilder({ schema, onChange, locale, translator, translationAdapter, translationOptions, onTranslationReport, policy, idFactory, factories, className, defaultFieldType, onActionError, createManualTranslationMetadata }: FormBuilderProps): react.JSX.Element;
177
+ declare function FormBuilder({ schema, onChange, locale, translator, translationAdapter, translationOptions, onTranslationReport, policy, idFactory, factories, className, defaultFieldType, onActionError, createManualTranslationMetadata, readOnly, features }: FormBuilderProps): react.JSX.Element;
165
178
 
166
179
  type SubmitStatus = "idle" | "submitting" | "success" | "error";
167
180
  interface FormContextValue {
@@ -233,4 +246,4 @@ interface StandaloneFormRendererProps extends FormRendererPresentationProps {
233
246
  type FormRendererProps = FormRendererPresentationProps | StandaloneFormRendererProps;
234
247
  declare function FormRenderer(props: FormRendererProps): react.JSX.Element;
235
248
 
236
- export { type BeforeSubmit, type BuilderActionContext, type BuilderActionError, type BuilderActionResult, type BuilderFactories, type BuilderIdKind, type BuilderPolicy, type BuilderTextTarget, type FieldComponentProps, type FieldComponents, type FieldState, FormBuilder, type FormBuilderOptions, type FormBuilderProps, type FormBuilderResult, type FormContextValue, FormProvider, type FormProviderProps, FormRenderer, type FormRendererPresentationProps, type FormRendererProps, type FormRendererSlots, type ManualTranslationContext, type StandaloneFormRendererProps, type SubmitResult, type SubmitStatus, resolveInitialFieldType, useField, useForm, useFormBuilder };
249
+ export { type BeforeSubmit, type BuilderActionContext, type BuilderActionError, type BuilderActionResult, type BuilderFactories, type BuilderIdKind, type BuilderPolicy, type BuilderTextTarget, type FieldComponentProps, type FieldComponents, type FieldState, FormBuilder, type FormBuilderFeatures, type FormBuilderOptions, type FormBuilderProps, type FormBuilderResult, type FormContextValue, FormProvider, type FormProviderProps, FormRenderer, type FormRendererPresentationProps, type FormRendererProps, type FormRendererSlots, type ManualTranslationContext, type StandaloneFormRendererProps, type SubmitResult, type SubmitStatus, resolveInitialFieldType, useField, useForm, useFormBuilder };
package/dist/index.d.ts CHANGED
@@ -37,6 +37,12 @@ type BuilderActionError = {
37
37
  } | {
38
38
  readonly type: "disallowed_field_type";
39
39
  readonly fieldType: QuestionType;
40
+ } | {
41
+ readonly type: "disallowed_locale";
42
+ readonly locale: string;
43
+ } | {
44
+ readonly type: "max_locales_exceeded";
45
+ readonly max: number;
40
46
  } | {
41
47
  readonly type: "node_not_found";
42
48
  readonly kind: BuilderTextTarget["kind"];
@@ -79,7 +85,7 @@ interface FormBuilderResult {
79
85
  declare function useFormBuilder({ schema, onChange, policy, idFactory, factories }: FormBuilderOptions): FormBuilderResult;
80
86
 
81
87
  interface BuilderActionContext {
82
- readonly action: "addField" | "removeField" | "moveField" | "changeFieldType" | "updateField" | "addOption" | "removeOption" | "moveOption" | "updateOption" | "addPage" | "removePage" | "movePage" | "assignFieldToPage" | "setDisplayCondition" | "setSourceText" | "setLocaleTranslation";
88
+ readonly action: "addField" | "removeField" | "moveField" | "changeFieldType" | "updateField" | "addOption" | "removeOption" | "moveOption" | "updateOption" | "addPage" | "removePage" | "movePage" | "updatePage" | "assignFieldToPage" | "addLocale" | "setDefaultLocale" | "setDisplayCondition" | "setSourceText" | "setLocaleTranslation";
83
89
  readonly targetId?: string;
84
90
  readonly params?: Record<string, unknown>;
85
91
  }
@@ -145,6 +151,11 @@ interface FormRendererSlots {
145
151
  type BeforeSubmit = (values: Readonly<Record<string, unknown>>) => "continue" | "cancel" | Promise<"continue" | "cancel">;
146
152
 
147
153
  declare function resolveInitialFieldType(defaultType?: QuestionType, allowedTypes?: readonly QuestionType[]): QuestionType | null;
154
+ interface FormBuilderFeatures {
155
+ readonly pages?: boolean;
156
+ readonly localization?: boolean;
157
+ readonly conditions?: boolean;
158
+ }
148
159
  interface FormBuilderProps {
149
160
  readonly schema: FormSchema;
150
161
  readonly onChange: (newSchema: FormSchema) => void;
@@ -160,8 +171,10 @@ interface FormBuilderProps {
160
171
  readonly defaultFieldType?: QuestionType;
161
172
  readonly onActionError?: (error: BuilderActionError, context: BuilderActionContext) => void;
162
173
  readonly createManualTranslationMetadata?: (context: ManualTranslationContext) => Readonly<Record<string, JsonValue>> | undefined;
174
+ readonly readOnly?: boolean;
175
+ readonly features?: FormBuilderFeatures;
163
176
  }
164
- declare function FormBuilder({ schema, onChange, locale, translator, translationAdapter, translationOptions, onTranslationReport, policy, idFactory, factories, className, defaultFieldType, onActionError, createManualTranslationMetadata }: FormBuilderProps): react.JSX.Element;
177
+ declare function FormBuilder({ schema, onChange, locale, translator, translationAdapter, translationOptions, onTranslationReport, policy, idFactory, factories, className, defaultFieldType, onActionError, createManualTranslationMetadata, readOnly, features }: FormBuilderProps): react.JSX.Element;
165
178
 
166
179
  type SubmitStatus = "idle" | "submitting" | "success" | "error";
167
180
  interface FormContextValue {
@@ -233,4 +246,4 @@ interface StandaloneFormRendererProps extends FormRendererPresentationProps {
233
246
  type FormRendererProps = FormRendererPresentationProps | StandaloneFormRendererProps;
234
247
  declare function FormRenderer(props: FormRendererProps): react.JSX.Element;
235
248
 
236
- export { type BeforeSubmit, type BuilderActionContext, type BuilderActionError, type BuilderActionResult, type BuilderFactories, type BuilderIdKind, type BuilderPolicy, type BuilderTextTarget, type FieldComponentProps, type FieldComponents, type FieldState, FormBuilder, type FormBuilderOptions, type FormBuilderProps, type FormBuilderResult, type FormContextValue, FormProvider, type FormProviderProps, FormRenderer, type FormRendererPresentationProps, type FormRendererProps, type FormRendererSlots, type ManualTranslationContext, type StandaloneFormRendererProps, type SubmitResult, type SubmitStatus, resolveInitialFieldType, useField, useForm, useFormBuilder };
249
+ export { type BeforeSubmit, type BuilderActionContext, type BuilderActionError, type BuilderActionResult, type BuilderFactories, type BuilderIdKind, type BuilderPolicy, type BuilderTextTarget, type FieldComponentProps, type FieldComponents, type FieldState, FormBuilder, type FormBuilderFeatures, type FormBuilderOptions, type FormBuilderProps, type FormBuilderResult, type FormContextValue, FormProvider, type FormProviderProps, FormRenderer, type FormRendererPresentationProps, type FormRendererProps, type FormRendererSlots, type ManualTranslationContext, type StandaloneFormRendererProps, type SubmitResult, type SubmitStatus, resolveInitialFieldType, useField, useForm, useFormBuilder };
package/dist/index.js CHANGED
@@ -135,10 +135,16 @@ function useFormBuilder({
135
135
  if (pages === void 0) return { success: false, error: { type: "node_not_found", kind: "page", id: pageId } };
136
136
  const updated = updater(page);
137
137
  if (updated.id !== pageId) return { success: false, error: { type: "invalid_id", kind: "page", id: updated.id } };
138
+ for (const text of [updated.title, updated.description]) {
139
+ if (text !== void 0) {
140
+ const error = textPolicyError(text);
141
+ if (error !== void 0) return error;
142
+ }
143
+ }
138
144
  onChange({ ...schema, pages: pages.map((candidate) => candidate.id === pageId ? updated : candidate) });
139
145
  return { success: true };
140
146
  },
141
- [onChange, schema]
147
+ [onChange, schema, textPolicyError]
142
148
  );
143
149
  const addField = useCallback(
144
150
  (type, pageId) => {
@@ -557,6 +563,17 @@ function useFormBuilder({
557
563
  const normalized = locale.trim();
558
564
  if (normalized.length === 0)
559
565
  return { success: false, error: { type: "invalid_operation", message: "Locale must not be empty." } };
566
+ if (policy?.allowedLocales !== void 0 && !policy.allowedLocales.includes(normalized)) {
567
+ return { success: false, error: { type: "disallowed_locale", locale: normalized } };
568
+ }
569
+ const registeredLocales = /* @__PURE__ */ new Set([
570
+ ...schema.defaultLocale === void 0 ? [] : [schema.defaultLocale],
571
+ ...schema.supportedLocales ?? []
572
+ ]);
573
+ if (registeredLocales.has(normalized)) return { success: true };
574
+ if (policy?.maxLocales !== void 0 && registeredLocales.size >= policy.maxLocales) {
575
+ return { success: false, error: { type: "max_locales_exceeded", max: policy.maxLocales } };
576
+ }
560
577
  onChange({
561
578
  ...schema,
562
579
  supportedLocales: [
@@ -569,13 +586,23 @@ function useFormBuilder({
569
586
  });
570
587
  return { success: true };
571
588
  },
572
- [onChange, schema]
589
+ [onChange, policy?.allowedLocales, policy?.maxLocales, schema]
573
590
  );
574
591
  const setDefaultLocale = useCallback(
575
592
  (locale) => {
576
593
  const normalized = locale.trim();
577
594
  if (normalized.length === 0)
578
595
  return { success: false, error: { type: "invalid_operation", message: "Locale must not be empty." } };
596
+ if (policy?.allowedLocales !== void 0 && !policy.allowedLocales.includes(normalized)) {
597
+ return { success: false, error: { type: "disallowed_locale", locale: normalized } };
598
+ }
599
+ const registeredLocales = /* @__PURE__ */ new Set([
600
+ ...schema.defaultLocale === void 0 ? [] : [schema.defaultLocale],
601
+ ...schema.supportedLocales ?? []
602
+ ]);
603
+ if (!registeredLocales.has(normalized) && policy?.maxLocales !== void 0 && registeredLocales.size >= policy.maxLocales) {
604
+ return { success: false, error: { type: "max_locales_exceeded", max: policy.maxLocales } };
605
+ }
579
606
  onChange({
580
607
  ...schema,
581
608
  defaultLocale: normalized,
@@ -583,7 +610,7 @@ function useFormBuilder({
583
610
  });
584
611
  return { success: true };
585
612
  },
586
- [onChange, schema]
613
+ [onChange, policy?.allowedLocales, policy?.maxLocales, schema]
587
614
  );
588
615
  const validationIssues = useMemo(() => {
589
616
  const result = validateFormSchema(schema, policy === void 0 ? {} : { policy });
@@ -770,7 +797,9 @@ function FormBuilder({
770
797
  className = "",
771
798
  defaultFieldType,
772
799
  onActionError,
773
- createManualTranslationMetadata
800
+ createManualTranslationMetadata,
801
+ readOnly = false,
802
+ features
774
803
  }) {
775
804
  const headless = useFormBuilder({
776
805
  schema,
@@ -788,28 +817,33 @@ function FormBuilder({
788
817
  const translated = translator?.translate(key, locale, params);
789
818
  return translated === void 0 ? interpolate(BUILDER_DEFAULTS[key] ?? key, params) : translated;
790
819
  };
791
- const executeAction = (result, context) => {
820
+ const pagesEnabled = features?.pages ?? true;
821
+ const localizationEnabled = features?.localization ?? true;
822
+ const conditionsEnabled = features?.conditions ?? true;
823
+ const executeAction = (run, context) => {
824
+ if (readOnly) return { success: true };
825
+ const result = run();
792
826
  if (!result.success) onActionError?.(result.error, context);
793
827
  return result;
794
828
  };
795
- const updateField = (fieldId, updater, params) => executeAction(headless.updateField(fieldId, updater), {
829
+ const updateField = (fieldId, updater, params) => executeAction(() => headless.updateField(fieldId, updater), {
796
830
  action: "updateField",
797
831
  targetId: fieldId,
798
832
  ...params === void 0 ? {} : { params }
799
833
  });
800
- const changeType = (fieldId, type) => executeAction(headless.changeFieldType(fieldId, type), {
834
+ const changeType = (fieldId, type) => executeAction(() => headless.changeFieldType(fieldId, type), {
801
835
  action: "changeFieldType",
802
836
  targetId: fieldId,
803
837
  params: { type }
804
838
  });
805
- const removeField = (fieldId) => executeAction(headless.removeField(fieldId), { action: "removeField", targetId: fieldId });
839
+ const removeField = (fieldId) => executeAction(() => headless.removeField(fieldId), { action: "removeField", targetId: fieldId });
806
840
  const initialFieldType = resolveInitialFieldType(defaultFieldType, policy?.allowedFieldTypes);
807
841
  const maxFieldsReached = policy?.maxFields !== void 0 && schema.fields.length >= policy.maxFields;
808
842
  const moveField = (index, offset) => {
809
843
  const target = index + offset;
810
844
  const field = schema.fields[index];
811
845
  if (field !== void 0) {
812
- executeAction(headless.moveField(field.id, target), {
846
+ executeAction(() => headless.moveField(field.id, target), {
813
847
  action: "moveField",
814
848
  targetId: field.id,
815
849
  params: { targetIndex: target }
@@ -818,10 +852,10 @@ function FormBuilder({
818
852
  };
819
853
  const addField = () => {
820
854
  if (initialFieldType === null) return;
821
- executeAction(headless.addField(initialFieldType), { action: "addField" });
855
+ executeAction(() => headless.addField(initialFieldType), { action: "addField" });
822
856
  };
823
857
  const enablePages = () => {
824
- if (schema.pages === void 0) executeAction(headless.addPage(), { action: "addPage" });
858
+ if (schema.pages === void 0) executeAction(() => headless.addPage(), { action: "addPage" });
825
859
  };
826
860
  const pageForField = (fieldId) => schema.pages?.find((page) => page.questionIds.includes(fieldId));
827
861
  const movablePageQuestions = schema.pages?.flatMap((page) => page.questionIds.length > 1 ? page.questionIds : []) ?? [];
@@ -832,7 +866,7 @@ function FormBuilder({
832
866
  }
833
867
  const questionId = movablePageQuestions.includes(newPageQuestionId) ? newPageQuestionId : movablePageQuestions[0];
834
868
  if (questionId === void 0) return;
835
- executeAction(headless.addPage(questionId), {
869
+ executeAction(() => headless.addPage(questionId), {
836
870
  action: "addPage",
837
871
  targetId: questionId,
838
872
  params: { questionId }
@@ -841,13 +875,14 @@ function FormBuilder({
841
875
  };
842
876
  const removePage = (pageIndex) => {
843
877
  const page = schema.pages?.[pageIndex];
844
- if (page !== void 0) executeAction(headless.removePage(page.id), { action: "removePage", targetId: page.id });
878
+ if (page !== void 0)
879
+ executeAction(() => headless.removePage(page.id), { action: "removePage", targetId: page.id });
845
880
  };
846
881
  const movePage = (pageIndex, offset) => {
847
882
  const target = pageIndex + offset;
848
883
  const page = schema.pages?.[pageIndex];
849
884
  if (page !== void 0) {
850
- executeAction(headless.movePage(page.id, target), {
885
+ executeAction(() => headless.movePage(page.id, target), {
851
886
  action: "movePage",
852
887
  targetId: page.id,
853
888
  params: { targetIndex: target }
@@ -855,24 +890,36 @@ function FormBuilder({
855
890
  }
856
891
  };
857
892
  const updatePage = (pageId, update) => {
858
- headless.updatePage(pageId, update);
893
+ executeAction(() => headless.updatePage(pageId, update), {
894
+ action: "updatePage",
895
+ targetId: pageId
896
+ });
859
897
  };
860
898
  const assignFieldToPage = (fieldId, pageId) => {
861
- executeAction(headless.assignFieldToPage(fieldId, pageId), {
899
+ executeAction(() => headless.assignFieldToPage(fieldId, pageId), {
862
900
  action: "assignFieldToPage",
863
901
  targetId: fieldId,
864
902
  params: { pageId }
865
903
  });
866
904
  };
867
905
  const addLocale = () => {
906
+ if (readOnly) return;
868
907
  const normalized = newLocale.trim();
869
908
  if (normalized.length === 0) return;
870
- headless.addLocale(normalized);
909
+ const result = executeAction(() => headless.addLocale(normalized), {
910
+ action: "addLocale",
911
+ params: { locale: normalized }
912
+ });
913
+ if (!result.success) return;
871
914
  setEditingLocale(normalized);
872
915
  setNewLocale("");
873
916
  };
917
+ const setDefaultLocale = (locale2) => executeAction(() => headless.setDefaultLocale(locale2), {
918
+ action: "setDefaultLocale",
919
+ params: { locale: locale2 }
920
+ });
874
921
  const translateAll = async () => {
875
- if (translationAdapter === void 0 || editingLocale.length === 0) return;
922
+ if (readOnly || translationAdapter === void 0 || editingLocale.length === 0) return;
876
923
  setIsTranslating(true);
877
924
  setTranslationError(null);
878
925
  try {
@@ -889,10 +936,11 @@ function FormBuilder({
889
936
  }
890
937
  };
891
938
  const updateManualTranslation = (context) => {
939
+ if (readOnly) return;
892
940
  const metadata = createManualTranslationMetadata?.(context);
893
941
  const target = context.kind === "form" ? { kind: "form" } : { kind: context.kind, id: context.nodeId };
894
942
  executeAction(
895
- headless.setLocaleTranslation(
943
+ () => headless.setLocaleTranslation(
896
944
  context.locale,
897
945
  target,
898
946
  context.property,
@@ -918,41 +966,51 @@ function FormBuilder({
918
966
  ...schema.translationMetadata?.[editingLocale]?.[property] === void 0 ? {} : { existingTranslationMetadata: schema.translationMetadata[editingLocale]?.[property] }
919
967
  });
920
968
  };
921
- const setSourceText = (target, property, text) => executeAction(headless.setSourceText(target, property, text), {
969
+ const setSourceText = (target, property, text) => executeAction(() => headless.setSourceText(target, property, text), {
922
970
  action: "setSourceText",
923
971
  ...target.id === void 0 ? {} : { targetId: target.id },
924
972
  params: { kind: target.kind, property }
925
973
  });
926
- const updateOption = (fieldId, optionId, label) => executeAction(
927
- headless.updateOption(fieldId, optionId, (option) => ({ ...option, label })),
928
- { action: "updateOption", targetId: optionId, params: { fieldId } }
929
- );
930
- const addOption = (fieldId) => executeAction(headless.addOption(fieldId), { action: "addOption", targetId: fieldId });
931
- const removeOption = (fieldId, optionId) => executeAction(headless.removeOption(fieldId, optionId), {
974
+ const updateOption = (fieldId, optionId, label) => executeAction(() => headless.updateOption(fieldId, optionId, (option) => ({ ...option, label })), {
975
+ action: "updateOption",
976
+ targetId: optionId,
977
+ params: { fieldId }
978
+ });
979
+ const addOption = (fieldId) => executeAction(() => headless.addOption(fieldId), { action: "addOption", targetId: fieldId });
980
+ const removeOption = (fieldId, optionId) => executeAction(() => headless.removeOption(fieldId, optionId), {
932
981
  action: "removeOption",
933
982
  targetId: optionId,
934
983
  params: { fieldId }
935
984
  });
936
- const moveOption = (fieldId, optionId, targetIndex) => executeAction(headless.moveOption(fieldId, optionId, targetIndex), {
985
+ const moveOption = (fieldId, optionId, targetIndex) => executeAction(() => headless.moveOption(fieldId, optionId, targetIndex), {
937
986
  action: "moveOption",
938
987
  targetId: optionId,
939
988
  params: { fieldId, targetIndex }
940
989
  });
941
- const setDisplayCondition = (fieldId, condition) => executeAction(headless.setDisplayCondition(fieldId, condition), {
990
+ const setDisplayCondition = (fieldId, condition) => executeAction(() => headless.setDisplayCondition(fieldId, condition), {
942
991
  action: "setDisplayCondition",
943
992
  targetId: fieldId,
944
993
  ...condition === void 0 ? {} : { params: { condition } }
945
994
  });
946
- return /* @__PURE__ */ jsxs(
995
+ const registeredLocales = /* @__PURE__ */ new Set([
996
+ ...schema.defaultLocale === void 0 ? [] : [schema.defaultLocale],
997
+ ...schema.supportedLocales ?? []
998
+ ]);
999
+ const availableAllowedLocales = policy?.allowedLocales?.filter((candidate) => !registeredLocales.has(candidate));
1000
+ const localeLimitReached = policy?.maxLocales !== void 0 && registeredLocales.size >= policy.maxLocales;
1001
+ return /* @__PURE__ */ jsx(
947
1002
  "section",
948
1003
  {
949
1004
  className: `form-engine-builder ${className}`.trim(),
950
1005
  "aria-label": translate("builder.formBuilder"),
951
1006
  onClickCapture: (event) => {
1007
+ if (readOnly) return;
952
1008
  if (!(event.target instanceof HTMLElement)) return;
953
1009
  const actionTarget = event.target.closest("[data-builder-action]");
954
1010
  if (actionTarget?.dataset.builderAction === "addField" && maxFieldsReached && initialFieldType !== null)
955
1011
  addField();
1012
+ if (actionTarget?.dataset.builderAction === "addLocale" && localeLimitReached && newLocale.trim().length > 0)
1013
+ addLocale();
956
1014
  if (actionTarget?.dataset.builderAction !== "addOption") return;
957
1015
  const fieldId = actionTarget.dataset.targetId;
958
1016
  const field = schema.fields.find((candidate) => candidate.id === fieldId);
@@ -960,12 +1018,14 @@ function FormBuilder({
960
1018
  addOption(fieldId);
961
1019
  }
962
1020
  },
963
- children: [
964
- /* @__PURE__ */ jsxs("section", { className: "form-engine-builder__pages", "aria-labelledby": "builder-pages-heading", children: [
1021
+ children: /* @__PURE__ */ jsxs("fieldset", { className: "form-engine-builder__controls", disabled: readOnly, children: [
1022
+ pagesEnabled ? /* @__PURE__ */ jsxs("section", { className: "form-engine-builder__pages", "aria-labelledby": "builder-pages-heading", children: [
965
1023
  /* @__PURE__ */ jsx("h2", { id: "builder-pages-heading", children: translate("builder.pages") }),
966
1024
  schema.pages === void 0 ? /* @__PURE__ */ jsx("button", { type: "button", onClick: enablePages, children: translate("builder.enablePages") }) : /* @__PURE__ */ jsxs(Fragment, { children: [
967
1025
  schema.pages.map((page, pageIndex) => {
968
- const priorQuestionIds = new Set(schema.pages?.slice(0, pageIndex).flatMap((item) => item.questionIds));
1026
+ const priorQuestionIds = new Set(
1027
+ schema.pages?.slice(0, pageIndex).flatMap((item) => item.questionIds)
1028
+ );
969
1029
  const availableSources = schema.fields.filter((field) => priorQuestionIds.has(field.id));
970
1030
  const source = schema.fields.find((field) => field.id === page.displayCondition?.questionId);
971
1031
  return /* @__PURE__ */ jsxs("fieldset", { className: "form-engine-builder__page", children: [
@@ -1029,7 +1089,7 @@ function FormBuilder({
1029
1089
  )
1030
1090
  ] })
1031
1091
  ] }),
1032
- editingLocale.length === 0 ? null : /* @__PURE__ */ jsxs("div", { className: "form-engine-builder__translation-editor", children: [
1092
+ !localizationEnabled || editingLocale.length === 0 ? null : /* @__PURE__ */ jsxs("div", { className: "form-engine-builder__translation-editor", children: [
1033
1093
  /* @__PURE__ */ jsx("strong", { children: editingLocale }),
1034
1094
  /* @__PURE__ */ jsxs("div", { className: "form-engine-builder__grid", children: [
1035
1095
  /* @__PURE__ */ jsxs("label", { children: [
@@ -1074,7 +1134,7 @@ function FormBuilder({
1074
1134
  ] })
1075
1135
  ] })
1076
1136
  ] }),
1077
- /* @__PURE__ */ jsxs("div", { className: "form-engine-builder__condition", children: [
1137
+ conditionsEnabled ? /* @__PURE__ */ jsxs("div", { className: "form-engine-builder__condition", children: [
1078
1138
  /* @__PURE__ */ jsxs("label", { children: [
1079
1139
  translate("builder.pageCondition"),
1080
1140
  /* @__PURE__ */ jsxs(
@@ -1115,7 +1175,11 @@ function FormBuilder({
1115
1175
  const operator = event.currentTarget.value;
1116
1176
  updatePage(page.id, (current) => ({
1117
1177
  ...current,
1118
- displayCondition: conditionWithValue(source.id, operator, defaultConditionValue(source))
1178
+ displayCondition: conditionWithValue(
1179
+ source.id,
1180
+ operator,
1181
+ defaultConditionValue(source)
1182
+ )
1119
1183
  }));
1120
1184
  },
1121
1185
  children: conditionOperators(source).map((operator) => /* @__PURE__ */ jsx("option", { value: operator, children: translate(operatorKey(operator)) }, operator))
@@ -1131,7 +1195,7 @@ function FormBuilder({
1131
1195
  }
1132
1196
  )
1133
1197
  ] }) : null
1134
- ] })
1198
+ ] }) : null
1135
1199
  ] }, page.id);
1136
1200
  }),
1137
1201
  /* @__PURE__ */ jsxs("div", { className: "form-engine-builder__page-add", children: [
@@ -1153,8 +1217,8 @@ function FormBuilder({
1153
1217
  /* @__PURE__ */ jsx("button", { type: "button", disabled: movablePageQuestions.length === 0, onClick: addPage, children: translate("builder.addPage") })
1154
1218
  ] })
1155
1219
  ] })
1156
- ] }),
1157
- /* @__PURE__ */ jsxs("section", { className: "form-engine-builder__localization", "aria-labelledby": "builder-localization-heading", children: [
1220
+ ] }) : null,
1221
+ localizationEnabled ? /* @__PURE__ */ jsxs("section", { className: "form-engine-builder__localization", "aria-labelledby": "builder-localization-heading", children: [
1158
1222
  /* @__PURE__ */ jsx("h2", { id: "builder-localization-heading", children: translate("builder.localization") }),
1159
1223
  /* @__PURE__ */ jsxs("label", { children: [
1160
1224
  translate("builder.completionMessage"),
@@ -1173,15 +1237,42 @@ function FormBuilder({
1173
1237
  "input",
1174
1238
  {
1175
1239
  value: schema.defaultLocale ?? "",
1176
- onChange: (event) => headless.setDefaultLocale(event.currentTarget.value)
1240
+ onChange: (event) => setDefaultLocale(event.currentTarget.value)
1177
1241
  }
1178
1242
  )
1179
1243
  ] }),
1180
- /* @__PURE__ */ jsxs("label", { children: [
1244
+ /* @__PURE__ */ jsxs("label", { htmlFor: "builder-new-locale", children: [
1181
1245
  translate("builder.addLocale"),
1182
- /* @__PURE__ */ jsx("input", { value: newLocale, onChange: (event) => setNewLocale(event.currentTarget.value) })
1246
+ availableAllowedLocales === void 0 ? /* @__PURE__ */ jsx(
1247
+ "input",
1248
+ {
1249
+ id: "builder-new-locale",
1250
+ value: newLocale,
1251
+ onChange: (event) => setNewLocale(event.currentTarget.value)
1252
+ }
1253
+ ) : /* @__PURE__ */ jsxs(
1254
+ "select",
1255
+ {
1256
+ id: "builder-new-locale",
1257
+ value: newLocale,
1258
+ onChange: (event) => setNewLocale(event.currentTarget.value),
1259
+ children: [
1260
+ /* @__PURE__ */ jsx("option", { value: "", children: "\u2014" }),
1261
+ availableAllowedLocales.map((candidate) => /* @__PURE__ */ jsx("option", { value: candidate, children: candidate }, candidate))
1262
+ ]
1263
+ }
1264
+ )
1183
1265
  ] }),
1184
- /* @__PURE__ */ jsx("button", { type: "button", onClick: addLocale, children: translate("builder.addLocale") }),
1266
+ /* @__PURE__ */ jsx(
1267
+ "button",
1268
+ {
1269
+ type: "button",
1270
+ "data-builder-action": "addLocale",
1271
+ disabled: newLocale.trim().length === 0 || localeLimitReached,
1272
+ onClick: addLocale,
1273
+ children: translate("builder.addLocale")
1274
+ }
1275
+ ),
1185
1276
  /* @__PURE__ */ jsxs("label", { children: [
1186
1277
  translate("builder.editLocale"),
1187
1278
  /* @__PURE__ */ jsxs("select", { value: editingLocale, onChange: (event) => setEditingLocale(event.currentTarget.value), children: [
@@ -1233,7 +1324,7 @@ function FormBuilder({
1233
1324
  )
1234
1325
  ] })
1235
1326
  ] })
1236
- ] }),
1327
+ ] }) : null,
1237
1328
  /* @__PURE__ */ jsx("div", { className: "form-engine-builder__list", children: schema.fields.map((field, index) => {
1238
1329
  const condition = field.displayCondition;
1239
1330
  const source = condition === void 0 ? void 0 : schema.fields.find((item) => item.id === condition.questionId);
@@ -1312,7 +1403,7 @@ function FormBuilder({
1312
1403
  translate("builder.required")
1313
1404
  ] })
1314
1405
  ] }),
1315
- schema.pages === void 0 ? null : /* @__PURE__ */ jsxs("label", { children: [
1406
+ !pagesEnabled || schema.pages === void 0 ? null : /* @__PURE__ */ jsxs("label", { children: [
1316
1407
  translate("builder.questionPage"),
1317
1408
  /* @__PURE__ */ jsx(
1318
1409
  "select",
@@ -1323,7 +1414,7 @@ function FormBuilder({
1323
1414
  }
1324
1415
  )
1325
1416
  ] }),
1326
- editingLocale.length === 0 ? null : /* @__PURE__ */ jsxs("div", { className: "form-engine-builder__translation-editor", children: [
1417
+ !localizationEnabled || editingLocale.length === 0 ? null : /* @__PURE__ */ jsxs("div", { className: "form-engine-builder__translation-editor", children: [
1327
1418
  /* @__PURE__ */ jsx("strong", { children: editingLocale }),
1328
1419
  /* @__PURE__ */ jsxs("div", { className: "form-engine-builder__grid", children: [
1329
1420
  /* @__PURE__ */ jsxs("label", { children: [
@@ -1483,7 +1574,7 @@ function FormBuilder({
1483
1574
  }
1484
1575
  )
1485
1576
  ] }) : null,
1486
- /* @__PURE__ */ jsxs("div", { className: "form-engine-builder__condition", children: [
1577
+ conditionsEnabled ? /* @__PURE__ */ jsxs("div", { className: "form-engine-builder__condition", children: [
1487
1578
  /* @__PURE__ */ jsxs("label", { children: [
1488
1579
  translate("builder.displayCondition"),
1489
1580
  /* @__PURE__ */ jsxs(
@@ -1534,7 +1625,7 @@ function FormBuilder({
1534
1625
  }
1535
1626
  )
1536
1627
  ] }) : null
1537
- ] })
1628
+ ] }) : null
1538
1629
  ] }, field.id);
1539
1630
  }) }),
1540
1631
  /* @__PURE__ */ jsx(
@@ -1548,7 +1639,7 @@ function FormBuilder({
1548
1639
  children: translate("builder.addQuestion")
1549
1640
  }
1550
1641
  )
1551
- ]
1642
+ ] })
1552
1643
  }
1553
1644
  );
1554
1645
  }
package/dist/styles.css CHANGED
@@ -175,6 +175,13 @@
175
175
  display: grid;
176
176
  gap: 1rem;
177
177
  }
178
+ .form-engine-builder__controls {
179
+ border: 0;
180
+ margin: 0;
181
+ min-width: 0;
182
+ padding: 0;
183
+ width: 100%;
184
+ }
178
185
  .form-engine-builder__list {
179
186
  display: grid;
180
187
  gap: 1rem;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@form-engine-ts/react",
3
- "version": "2.1.1",
3
+ "version": "2.2.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -42,7 +42,7 @@
42
42
  "typescript"
43
43
  ],
44
44
  "dependencies": {
45
- "@form-engine-ts/core": "2.1.1"
45
+ "@form-engine-ts/core": "2.2.0"
46
46
  },
47
47
  "peerDependencies": {
48
48
  "react": ">=18.2 <20",