@form-engine-ts/react 4.1.0 → 4.3.1

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
@@ -168,8 +168,15 @@ state. Text controls forward schema `minLength`, `maxLength`, and `pattern` cons
168
168
  `renderCharacterCount` can replace the default count. Guard evaluation, confirmation, receipt persistence, and provider
169
169
  submission share an in-flight lock so rapid clicks cannot submit twice.
170
170
 
171
+ Set `submissionConfirmation={{ enabled: true, renderMode: "replace" }}` to show a standard answer review before
172
+ submission even when no submission guard is configured. The default `inline` mode keeps the form visible; `replace` and
173
+ `dialog` provide alternate presentations. The standard review lists visible answers with their resolved labels and
174
+ formatted display values. `renderSubmissionConfirmation` receives these as `visibleItems`; guard confirmations continue
175
+ to receive their findings, while generic confirmations provide an empty findings array.
176
+
171
177
  The Builder basic-settings section edits source `title` and `description` through the same policy-aware action pipeline.
172
- Submission confirmation slots receive the effective message, localized schema, and visible answers. An `onSubmit` result
178
+ Submission confirmation slots receive the effective message, localized schema, visible answers, and formatted
179
+ `visibleItems`. An `onSubmit` result
173
180
  may provide `submissionId` and `submittedAt`, which Renderer copies into its receipt. Receipt stores support `getBatch`,
174
181
  and `useSubmissionReceipts` loads multiple form/version receipts for list and dashboard surfaces.
175
182
 
package/dist/index.cjs CHANGED
@@ -169,6 +169,61 @@ function defaultCreateOption(field, id) {
169
169
  function defaultCreatePage(id, questionIds) {
170
170
  return { id, title: "New page", questionIds };
171
171
  }
172
+ function applyFieldConstraintDefaults(field, policy) {
173
+ const constraint = policy?.fieldConstraints?.[field.type];
174
+ if (constraint === void 0) return field;
175
+ const required = constraint.fixedRequired ?? constraint.defaultRequired ?? field.required;
176
+ if (field.type === "rating") {
177
+ const ratingConstraint = "defaultMin" in constraint || "defaultMax" in constraint || "fixedMin" in constraint || "fixedMax" in constraint ? constraint : void 0;
178
+ const min = ratingConstraint !== void 0 && "fixedMin" in ratingConstraint ? ratingConstraint.fixedMin : ratingConstraint !== void 0 && "defaultMin" in ratingConstraint ? ratingConstraint.defaultMin : field.min;
179
+ const max = ratingConstraint !== void 0 && "fixedMax" in ratingConstraint ? ratingConstraint.fixedMax : ratingConstraint !== void 0 && "defaultMax" in ratingConstraint ? ratingConstraint.defaultMax : field.max;
180
+ return {
181
+ ...field,
182
+ required,
183
+ ...min === void 0 ? {} : { min },
184
+ ...max === void 0 ? {} : { max }
185
+ };
186
+ }
187
+ if ((field.type === "text" || field.type === "textarea") && "defaultMaxLength" in constraint) {
188
+ return {
189
+ ...field,
190
+ required,
191
+ ...constraint.defaultMaxLength === void 0 ? {} : { maxLength: constraint.defaultMaxLength }
192
+ };
193
+ }
194
+ return { ...field, required };
195
+ }
196
+ function fieldConstraintError(updated, policy) {
197
+ const constraint = policy?.fieldConstraints?.[updated.type];
198
+ if (constraint === void 0) return void 0;
199
+ if (constraint.fixedRequired !== void 0 && updated.required !== constraint.fixedRequired) {
200
+ return { type: "field_constraint_immutable" };
201
+ }
202
+ if (updated.type === "rating") {
203
+ if ("fixedMin" in constraint && constraint.fixedMin !== void 0 && updated.min !== constraint.fixedMin) {
204
+ return { type: "field_constraint_immutable" };
205
+ }
206
+ if ("fixedMax" in constraint && constraint.fixedMax !== void 0 && updated.max !== constraint.fixedMax) {
207
+ return { type: "field_constraint_immutable" };
208
+ }
209
+ if ("allowedMinRange" in constraint && constraint.allowedMinRange !== void 0 && typeof updated.min === "number" && (updated.min < constraint.allowedMinRange[0] || updated.min > constraint.allowedMinRange[1])) {
210
+ return { type: "field_constraint_violation", property: "min", expected: constraint.allowedMinRange[0] };
211
+ }
212
+ if ("allowedMaxRange" in constraint && constraint.allowedMaxRange !== void 0 && typeof updated.max === "number" && (updated.max < constraint.allowedMaxRange[0] || updated.max > constraint.allowedMaxRange[1])) {
213
+ return { type: "field_constraint_violation", property: "max", expected: constraint.allowedMaxRange[1] };
214
+ }
215
+ }
216
+ if ((updated.type === "text" || updated.type === "textarea") && "maxMaxLength" in constraint && constraint.maxMaxLength !== void 0 && updated.maxLength !== void 0 && updated.maxLength > constraint.maxMaxLength) {
217
+ return { type: "field_constraint_violation", property: "maxLength", expected: constraint.maxMaxLength };
218
+ }
219
+ if ((updated.type === "select" || updated.type === "radio" || updated.type === "multi-select") && "minOptions" in constraint && constraint.minOptions !== void 0 && updated.options.length < constraint.minOptions) {
220
+ return { type: "field_constraint_violation", property: "options", expected: constraint.minOptions };
221
+ }
222
+ if ((updated.type === "select" || updated.type === "radio" || updated.type === "multi-select") && "maxOptions" in constraint && constraint.maxOptions !== void 0 && updated.options.length > constraint.maxOptions) {
223
+ return { type: "field_constraint_violation", property: "options", expected: constraint.maxOptions };
224
+ }
225
+ return void 0;
226
+ }
172
227
  function move(items, sourceIndex, targetIndex) {
173
228
  if (sourceIndex < 0 || targetIndex < 0 || targetIndex >= items.length || sourceIndex === targetIndex)
174
229
  return void 0;
@@ -224,6 +279,8 @@ function useFormBuilder({
224
279
  const updated = updater(current);
225
280
  if (updated.id !== fieldId)
226
281
  return { success: false, error: { type: "invalid_id", kind: "field", id: updated.id } };
282
+ const constraintError = fieldConstraintError(updated, policy);
283
+ if (constraintError !== void 0) return { success: false, error: constraintError };
227
284
  if (policy?.allowedFieldTypes !== void 0 && !policy.allowedFieldTypes.includes(updated.type))
228
285
  return { success: false, error: { type: "disallowed_field_type", fieldType: updated.type } };
229
286
  for (const text of [updated.title, updated.description]) {
@@ -235,7 +292,7 @@ function useFormBuilder({
235
292
  onChange({ ...schema, fields: schema.fields.map((field) => field.id === fieldId ? updated : field) });
236
293
  return { success: true };
237
294
  },
238
- [onChange, policy?.allowedFieldTypes, schema, textPolicyError]
295
+ [onChange, policy, schema, textPolicyError]
239
296
  );
240
297
  const updateOption = (0, import_react.useCallback)(
241
298
  (fieldId, optionId, updater) => {
@@ -308,6 +365,7 @@ function useFormBuilder({
308
365
  return { success: false, error: { type: "invalid_id", kind: "option", id: option.id } };
309
366
  field = { ...field, options: [option] };
310
367
  }
368
+ field = applyFieldConstraintDefaults(field, policy);
311
369
  const pages = schema.pages?.map((page, index) => ({
312
370
  ...page,
313
371
  questionIds: page.id === pageId || pageId === void 0 && index === (schema.pages?.length ?? 0) - 1 ? [...page.questionIds, field.id] : page.questionIds
@@ -360,6 +418,12 @@ function useFormBuilder({
360
418
  return { success: false, error: { type: "invalid_operation", message: `Field ${fieldId} has no options.` } };
361
419
  if (policy?.maxOptionsPerField !== void 0 && field.options.length >= policy.maxOptionsPerField)
362
420
  return { success: false, error: { type: "max_options_exceeded", max: policy.maxOptionsPerField } };
421
+ const constraint = policy?.fieldConstraints?.[field.type];
422
+ if (constraint !== void 0 && "maxOptions" in constraint && constraint.maxOptions !== void 0 && field.options.length >= constraint.maxOptions)
423
+ return {
424
+ success: false,
425
+ error: { type: "field_constraint_violation", property: "options", expected: constraint.maxOptions }
426
+ };
363
427
  const ids = new Set(
364
428
  schema.fields.flatMap((item) => "options" in item ? item.options.map((option2) => option2.id) : [])
365
429
  );
@@ -376,7 +440,7 @@ function useFormBuilder({
376
440
  });
377
441
  return { success: true };
378
442
  },
379
- [createId, factories.createOption, onChange, policy?.maxOptionsPerField, schema]
443
+ [createId, factories.createOption, onChange, policy, policy?.maxOptionsPerField, schema]
380
444
  );
381
445
  const removeOption = (0, import_react.useCallback)(
382
446
  (fieldId, optionId) => {
@@ -386,6 +450,12 @@ function useFormBuilder({
386
450
  return { success: false, error: { type: "node_not_found", kind: "option", id: optionId } };
387
451
  if (field.options.length <= 1)
388
452
  return { success: false, error: { type: "invalid_operation", message: "A choice field needs one option." } };
453
+ const constraint = policy?.fieldConstraints?.[field.type];
454
+ if (constraint !== void 0 && "minOptions" in constraint && constraint.minOptions !== void 0 && field.options.length <= constraint.minOptions)
455
+ return {
456
+ success: false,
457
+ error: { type: "field_constraint_violation", property: "options", expected: constraint.minOptions }
458
+ };
389
459
  onChange({
390
460
  ...schema,
391
461
  fields: schema.fields.map(
@@ -394,7 +464,7 @@ function useFormBuilder({
394
464
  });
395
465
  return { success: true };
396
466
  },
397
- [onChange, schema]
467
+ [onChange, policy, schema]
398
468
  );
399
469
  const moveOption = (0, import_react.useCallback)(
400
470
  (fieldId, optionId, targetIndex) => {
@@ -425,7 +495,9 @@ function useFormBuilder({
425
495
  if (field === void 0) return { success: false, error: { type: "node_not_found", kind: "field", id: fieldId } };
426
496
  if (policy?.allowedFieldTypes !== void 0 && !policy.allowedFieldTypes.includes(type))
427
497
  return { success: false, error: { type: "disallowed_field_type", fieldType: type } };
428
- let transformed = (0, import_core.transformFieldType)(field, type);
498
+ let transformed = applyFieldConstraintDefaults((0, import_core.transformFieldType)(field, type), policy);
499
+ const constraintError = fieldConstraintError(transformed, policy);
500
+ if (constraintError !== void 0) return { success: false, error: constraintError };
429
501
  if (CHOICE_TYPES.includes(type) && !("options" in field) && "options" in transformed) {
430
502
  const ids = new Set(
431
503
  schema.fields.flatMap((item) => "options" in item ? item.options.map((option2) => option2.id) : [])
@@ -440,7 +512,7 @@ function useFormBuilder({
440
512
  onChange({ ...schema, fields: schema.fields.map((item) => item.id === fieldId ? transformed : item) });
441
513
  return { success: true };
442
514
  },
443
- [createId, factories.createOption, onChange, policy?.allowedFieldTypes, schema]
515
+ [createId, factories.createOption, onChange, policy, policy?.allowedFieldTypes, schema]
444
516
  );
445
517
  const addPage = (0, import_react.useCallback)(
446
518
  (questionId) => {
@@ -3495,7 +3567,8 @@ function ContextFormRenderer({
3495
3567
  beforeSubmit,
3496
3568
  onDraftSave,
3497
3569
  successRenderMode = "append",
3498
- submissionConfirmationRenderMode = "inline",
3570
+ submissionConfirmation,
3571
+ submissionConfirmationRenderMode,
3499
3572
  showHiddenFieldsInSummary = false,
3500
3573
  fieldsClassName,
3501
3574
  hideFormOnSuccess = false,
@@ -3531,6 +3604,12 @@ function ContextFormRenderer({
3531
3604
  const activeVisibleIndex = visiblePageIndexes.indexOf(currentPageIndex);
3532
3605
  const fieldIds = activePage === void 0 ? void 0 : new Set(activePage.questionIds);
3533
3606
  const visibleValues = (0, import_react5.useMemo)(() => (0, import_core4.selectVisibleAnswers)(form.schema, form.values), [form.schema, form.values]);
3607
+ const visibleItems = (0, import_react5.useMemo)(
3608
+ () => buildSubmittedItems(form.schema, form.values, form.visibility, (key) => form.translate(key), false),
3609
+ [form.schema, form.translate, form.values, form.visibility]
3610
+ );
3611
+ const confirmationRenderMode = submissionConfirmation?.renderMode ?? submissionConfirmationRenderMode ?? "inline";
3612
+ const confirmationEnabled = submissionConfirmation?.enabled === true;
3534
3613
  const submitState = confirmation === null && !guardsPending ? form.submitStatus : "confirming";
3535
3614
  const interactionLocked = submitState === "confirming" || submitState === "submitting";
3536
3615
  const isReplaceMode = successRenderMode === "replace" || hideFormOnSuccess;
@@ -3598,7 +3677,7 @@ function ContextFormRenderer({
3598
3677
  if (confirmation === null) return;
3599
3678
  const confirmButton = confirmationRef.current?.querySelector("[data-fe-confirm], button");
3600
3679
  confirmButton?.focus();
3601
- if (submissionConfirmationRenderMode !== "dialog") return;
3680
+ if (confirmationRenderMode !== "dialog") return;
3602
3681
  const onKeyDown = (event) => {
3603
3682
  if (event.key === "Escape") {
3604
3683
  event.preventDefault();
@@ -3625,7 +3704,7 @@ function ContextFormRenderer({
3625
3704
  };
3626
3705
  globalThis.addEventListener("keydown", onKeyDown);
3627
3706
  return () => globalThis.removeEventListener("keydown", onKeyDown);
3628
- }, [confirmation, focusSubmitButton, submissionConfirmationRenderMode]);
3707
+ }, [confirmation, confirmationRenderMode, focusSubmitButton]);
3629
3708
  (0, import_react5.useEffect)(() => {
3630
3709
  if (autoSaveKey === void 0 || typeof globalThis.localStorage === "undefined") return;
3631
3710
  const loadIdentity = `${autoSaveKey}:${form.schema.id}:${form.schema.version}`;
@@ -3696,21 +3775,29 @@ function ContextFormRenderer({
3696
3775
  }
3697
3776
  const validation = (0, import_core4.validateAnswers)(form.schema, form.values);
3698
3777
  const firstInvalidFieldId = validation.issues[0]?.fieldId;
3699
- if (validation.valid && !guardsConfirmed && submissionGuards.length > 0) {
3778
+ if (validation.valid && !guardsConfirmed) {
3700
3779
  rendererSubmissionInFlight.current = true;
3701
- setGuardsPending(true);
3780
+ setGuardsPending(submissionGuards.length > 0);
3702
3781
  try {
3703
- const guardResult = await runSubmissionGuards(submissionGuards);
3704
- if (guardResult.status === "block") {
3705
- setGuardMessage(guardResult.message ?? form.translate("form.submissionBlocked"));
3706
- return { status: "cancelled" };
3782
+ if (submissionGuards.length > 0) {
3783
+ const guardResult = await runSubmissionGuards(submissionGuards);
3784
+ if (guardResult.status === "block") {
3785
+ setGuardMessage(guardResult.message ?? form.translate("form.submissionBlocked"));
3786
+ return { status: "cancelled" };
3787
+ }
3788
+ if (guardResult.status === "confirm") {
3789
+ setGuardMessage(null);
3790
+ setConfirmation({
3791
+ findings: guardResult.findings,
3792
+ generic: false,
3793
+ ...guardResult.message === void 0 ? {} : { message: guardResult.message }
3794
+ });
3795
+ return { status: "cancelled" };
3796
+ }
3707
3797
  }
3708
- if (guardResult.status === "confirm") {
3798
+ if (confirmationEnabled) {
3709
3799
  setGuardMessage(null);
3710
- setConfirmation({
3711
- findings: guardResult.findings,
3712
- ...guardResult.message === void 0 ? {} : { message: guardResult.message }
3713
- });
3800
+ setConfirmation({ findings: [], generic: true });
3714
3801
  return { status: "cancelled" };
3715
3802
  }
3716
3803
  } finally {
@@ -3873,17 +3960,18 @@ function ContextFormRenderer({
3873
3960
  {
3874
3961
  ref: confirmationRef,
3875
3962
  className: "fe-submission-confirmation",
3876
- role: submissionConfirmationRenderMode === "dialog" ? void 0 : "dialog",
3963
+ role: confirmationRenderMode === "dialog" ? void 0 : "dialog",
3877
3964
  children: slots.renderSubmissionConfirmation?.({
3878
3965
  findings: confirmation?.findings ?? [],
3879
- message: confirmation?.message ?? resolveMessage("confirmSensitiveDataMessage", form.translate("form.confirmSensitiveData")),
3966
+ message: confirmation?.message ?? (confirmation?.generic === true ? form.locale.toLowerCase().startsWith("ja") ? "\u56DE\u7B54\u5185\u5BB9\u3092\u3054\u78BA\u8A8D\u306E\u3046\u3048\u3001\u9001\u4FE1\u3057\u3066\u304F\u3060\u3055\u3044\u3002" : "Please review your answers before submitting." : resolveMessage("confirmSensitiveDataMessage", form.translate("form.confirmSensitiveData"))),
3880
3967
  schema: form.schema,
3881
3968
  visibleValues,
3969
+ visibleItems,
3882
3970
  onConfirm: confirmSubmission,
3883
3971
  onCancel: cancelSubmission
3884
3972
  }) ?? /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_jsx_runtime3.Fragment, { children: [
3885
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("h2", { children: resolveMessage("confirmSensitiveDataTitle") }),
3886
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { children: confirmation?.message ?? resolveMessage("confirmSensitiveDataMessage", form.translate("form.confirmSensitiveData")) }),
3973
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("h2", { children: confirmation?.generic === true ? form.locale.toLowerCase().startsWith("ja") ? "\u56DE\u7B54\u5185\u5BB9\u306E\u78BA\u8A8D" : "Review your answers" : resolveMessage("confirmSensitiveDataTitle") }),
3974
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { children: confirmation?.message ?? (confirmation?.generic === true ? form.locale.toLowerCase().startsWith("ja") ? "\u56DE\u7B54\u5185\u5BB9\u3092\u3054\u78BA\u8A8D\u306E\u3046\u3048\u3001\u9001\u4FE1\u3057\u3066\u304F\u3060\u3055\u3044\u3002" : "Please review your answers before submitting." : resolveMessage("confirmSensitiveDataMessage", form.translate("form.confirmSensitiveData"))) }),
3887
3975
  (confirmation?.findings ?? []).length === 0 ? null : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("ul", { children: (confirmation?.findings ?? []).map((finding, index) => {
3888
3976
  const field = form.schema.fields.find((candidate) => candidate.id === finding.fieldId);
3889
3977
  const typeLabels = form.locale.toLowerCase().startsWith("ja") ? { email: "\u30E1\u30FC\u30EB\u30A2\u30C9\u30EC\u30B9", phone: "\u96FB\u8A71\u756A\u53F7", url: "URL", postal_code: "\u90F5\u4FBF\u756A\u53F7" } : { email: "Email address", phone: "Phone number", url: "URL", postal_code: "Postal code" };
@@ -3899,6 +3987,11 @@ function ContextFormRenderer({
3899
3987
  ] })
3900
3988
  ] }, `${finding.fieldId}-${finding.type}-${finding.start ?? index}`);
3901
3989
  }) }),
3990
+ confirmation?.generic === true ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("ul", { className: "fe-submission-summary", children: visibleItems.map((item) => /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("li", { children: [
3991
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { children: item.title }),
3992
+ ": ",
3993
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { children: item.displayValue })
3994
+ ] }, item.fieldId)) }) : null,
3902
3995
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { type: "button", "data-fe-confirm": "true", onClick: confirmSubmission, children: resolveMessage("confirmButton", form.translate("form.confirmSubmission")) }),
3903
3996
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { type: "button", onClick: cancelSubmission, children: resolveMessage("cancelButton", form.translate("form.cancelSubmission")) })
3904
3997
  ] })
@@ -3918,7 +4011,7 @@ function ContextFormRenderer({
3918
4011
  if (form.submitStatus === "success" && isReplaceMode) {
3919
4012
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: `fe-form ${className}`.trim(), children: completionRegion });
3920
4013
  }
3921
- if (confirmation !== null && submissionConfirmationRenderMode === "replace") {
4014
+ if (confirmation !== null && confirmationRenderMode === "replace") {
3922
4015
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: `fe-form ${className}`.trim(), children: confirmationContent });
3923
4016
  }
3924
4017
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_jsx_runtime3.Fragment, { children: [
@@ -3929,7 +4022,7 @@ function ContextFormRenderer({
3929
4022
  className: `fe-form ${className}`.trim(),
3930
4023
  noValidate: true,
3931
4024
  onSubmit: handleSubmit,
3932
- "aria-hidden": confirmation !== null && submissionConfirmationRenderMode === "dialog" ? true : void 0,
4025
+ "aria-hidden": confirmation !== null && confirmationRenderMode === "dialog" ? true : void 0,
3933
4026
  children: [
3934
4027
  slots.renderHeader?.({
3935
4028
  title: form.schema.title,
@@ -3998,7 +4091,7 @@ function ContextFormRenderer({
3998
4091
  return slots.renderFields?.({ children: fieldChildren, className: fieldClassName }) ?? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: fieldClassName, children: fieldChildren });
3999
4092
  })(),
4000
4093
  guardMessage === null ? null : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { role: "alert", children: guardMessage }),
4001
- confirmation !== null && submissionConfirmationRenderMode === "inline" ? confirmationContent : null,
4094
+ confirmation !== null && confirmationRenderMode === "inline" ? confirmationContent : null,
4002
4095
  validationIssues.length === 0 ? null : slots.renderValidationSummary?.({ issues: validationIssues }) ?? /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "fe-validation-summary", role: "alert", children: [
4003
4096
  validationIssues.length,
4004
4097
  " validation error",
@@ -4053,7 +4146,7 @@ function ContextFormRenderer({
4053
4146
  ]
4054
4147
  }
4055
4148
  ),
4056
- confirmation !== null && submissionConfirmationRenderMode === "dialog" ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: "fe-confirmation-dialog-backdrop", role: "dialog", "aria-modal": "true", children: confirmationContent }) : null
4149
+ confirmation !== null && confirmationRenderMode === "dialog" ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: "fe-confirmation-dialog-backdrop", role: "dialog", "aria-modal": "true", children: confirmationContent }) : null
4057
4150
  ] });
4058
4151
  }
4059
4152
  var RENDERER_MESSAGES = {
package/dist/index.d.cts CHANGED
@@ -60,6 +60,12 @@ type BuilderActionError = {
60
60
  } | {
61
61
  readonly type: "max_locales_exceeded";
62
62
  readonly max: number;
63
+ } | {
64
+ readonly type: "field_constraint_immutable";
65
+ } | {
66
+ readonly type: "field_constraint_violation";
67
+ readonly property: string;
68
+ readonly expected: number;
63
69
  } | {
64
70
  readonly type: "node_not_found";
65
71
  readonly kind: BuilderTextTarget["kind"];
@@ -472,6 +478,10 @@ type SubmissionGuardResult = {
472
478
  type SubmissionGuard = (schema: FormSchema, values: Record<string, unknown>) => SubmissionGuardResult | Promise<SubmissionGuardResult>;
473
479
  type FormSuccessRenderMode = "append" | "replace";
474
480
  type SubmissionConfirmationRenderMode = "inline" | "replace" | "dialog";
481
+ interface SubmissionConfirmationOptions {
482
+ readonly enabled?: boolean;
483
+ readonly renderMode?: SubmissionConfirmationRenderMode;
484
+ }
475
485
  type FormSubmitStatus = "idle" | "submitting" | "confirming" | "success" | "error";
476
486
  /** @deprecated Use FormSubmitStatus instead. */
477
487
  type FormSubmitState = "idle" | "submitting" | "confirming" | "success" | "error";
@@ -482,10 +492,11 @@ interface RenderSubmitButtonProps {
482
492
  readonly onSubmit: () => void;
483
493
  }
484
494
  interface SubmissionConfirmationSlotProps {
485
- readonly findings: readonly SensitiveDataFinding[];
495
+ readonly findings?: readonly SensitiveDataFinding[];
486
496
  readonly message?: string;
487
497
  readonly schema: FormSchema;
488
498
  readonly visibleValues: Record<string, unknown>;
499
+ readonly visibleItems?: readonly FormSubmittedAnswerItem[];
489
500
  readonly onConfirm: () => void;
490
501
  readonly onCancel: () => void;
491
502
  }
@@ -665,6 +676,8 @@ interface FormRendererPresentationProps extends SubmissionProtectionProps {
665
676
  * Defaults to "append" for backwards compatibility.
666
677
  */
667
678
  readonly successRenderMode?: FormSuccessRenderMode;
679
+ readonly submissionConfirmation?: SubmissionConfirmationOptions;
680
+ /** @deprecated Use submissionConfirmation.renderMode instead. */
668
681
  readonly submissionConfirmationRenderMode?: SubmissionConfirmationRenderMode;
669
682
  readonly showHiddenFieldsInSummary?: boolean;
670
683
  readonly fieldsClassName?: string;
@@ -691,4 +704,4 @@ interface StandaloneFormRendererProps extends FormRendererPresentationProps {
691
704
  type FormRendererProps = FormRendererPresentationProps | StandaloneFormRendererProps;
692
705
  declare function FormRenderer(props: FormRendererProps): react.JSX.Element;
693
706
 
694
- export { BUILDER_TRANSLATION_ALIASES, BUILDER_TRANSLATION_KEYS, type BeforeSubmit, type BuilderActionContext, type BuilderActionError, type BuilderActionIconType, type BuilderActionResult, type BuilderButtonProps, type BuilderCheckboxProps, type BuilderErrorMessageProps, type BuilderFactories, type BuilderFieldEditorSlotProps, type BuilderFieldsetProps, type BuilderIconButtonProps, type BuilderIdKind, type BuilderLocalizationSlotProps, type BuilderOptionEditorSlotProps, type BuilderPagesSlotProps, type BuilderPolicy, type BuilderSectionProps, type BuilderSelectOption, type BuilderSelectProps, type BuilderSlotActions, type BuilderTextAreaProps, type BuilderTextInputProps, type BuilderTextTarget, type BuilderToolbarSlotProps, type BuilderTranslationActionsSlotProps, type BuilderTranslationKey, type ComponentBaseProps, type FieldComponentProps, type FieldComponents, type FieldEditorControlsConfig, type FieldEditorHeaderSlotProps, type FieldPropertyControlMode, type FieldState, type FieldTypeSelectOptionsConfig, type FieldTypeSelectOptionsContext, type FieldTypeSelectOptionsSorter, type FieldTypeSelectOptionsTransformer, type FieldTypeSelectSlotProps, FormBuilder, type FormBuilderActions, type FormBuilderComponents, type FormBuilderFeatures, type FormBuilderOptions, type FormBuilderProps, type FormBuilderResult, type FormBuilderSectionName, type FormBuilderSlots, type FormCompletionSlotProps, type FormContextValue, type FormFieldsSlotProps, FormProvider, type FormProviderProps, FormRenderer, type FormRendererMessages, type FormRendererPresentationProps, type FormRendererProps, type FormRendererSlots, type FormServerErrorPayload, FormSubmissionError, type FormSubmitHandler, type FormSubmitState, type FormSubmitStatus, type FormSubmittedAnswerItem, type FormSuccessRenderMode, type IconButtonProps, type InputComponentProps, type LocalizationSummaryContext, type ManualTranslationContext, type ManualTranslationTarget, type RenderSubmitButtonProps, type SelectComponentProps, type StandaloneFormRendererProps, type SubmissionAttempt, type SubmissionAttemptStore, type SubmissionConfirmationRenderMode, type SubmissionConfirmationSlotProps, type SubmissionGuard, type SubmissionGuardResult, type SubmissionProtectionProps, type SubmissionReceipt, type SubmissionReceiptQuery, type SubmissionReceiptStore, type SubmitContext, type SubmitResponse, type SubmitResult, type SubmitStatus, type UseSubmissionReceiptsResult, createLocalStorageSubmissionAttemptStore, createLocalStorageSubmissionReceiptStore, isTranslationUnresolved, resolveFieldEditorControls, resolveFieldTypeSelectOptions, resolveInitialFieldType, resolveTranslation, submissionReceiptQueryKey, useField, useForm, useFormBuilder, useSubmissionReceipts };
707
+ export { BUILDER_TRANSLATION_ALIASES, BUILDER_TRANSLATION_KEYS, type BeforeSubmit, type BuilderActionContext, type BuilderActionError, type BuilderActionIconType, type BuilderActionResult, type BuilderButtonProps, type BuilderCheckboxProps, type BuilderErrorMessageProps, type BuilderFactories, type BuilderFieldEditorSlotProps, type BuilderFieldsetProps, type BuilderIconButtonProps, type BuilderIdKind, type BuilderLocalizationSlotProps, type BuilderOptionEditorSlotProps, type BuilderPagesSlotProps, type BuilderPolicy, type BuilderSectionProps, type BuilderSelectOption, type BuilderSelectProps, type BuilderSlotActions, type BuilderTextAreaProps, type BuilderTextInputProps, type BuilderTextTarget, type BuilderToolbarSlotProps, type BuilderTranslationActionsSlotProps, type BuilderTranslationKey, type ComponentBaseProps, type FieldComponentProps, type FieldComponents, type FieldEditorControlsConfig, type FieldEditorHeaderSlotProps, type FieldPropertyControlMode, type FieldState, type FieldTypeSelectOptionsConfig, type FieldTypeSelectOptionsContext, type FieldTypeSelectOptionsSorter, type FieldTypeSelectOptionsTransformer, type FieldTypeSelectSlotProps, FormBuilder, type FormBuilderActions, type FormBuilderComponents, type FormBuilderFeatures, type FormBuilderOptions, type FormBuilderProps, type FormBuilderResult, type FormBuilderSectionName, type FormBuilderSlots, type FormCompletionSlotProps, type FormContextValue, type FormFieldsSlotProps, FormProvider, type FormProviderProps, FormRenderer, type FormRendererMessages, type FormRendererPresentationProps, type FormRendererProps, type FormRendererSlots, type FormServerErrorPayload, FormSubmissionError, type FormSubmitHandler, type FormSubmitState, type FormSubmitStatus, type FormSubmittedAnswerItem, type FormSuccessRenderMode, type IconButtonProps, type InputComponentProps, type LocalizationSummaryContext, type ManualTranslationContext, type ManualTranslationTarget, type RenderSubmitButtonProps, type SelectComponentProps, type StandaloneFormRendererProps, type SubmissionAttempt, type SubmissionAttemptStore, type SubmissionConfirmationOptions, type SubmissionConfirmationRenderMode, type SubmissionConfirmationSlotProps, type SubmissionGuard, type SubmissionGuardResult, type SubmissionProtectionProps, type SubmissionReceipt, type SubmissionReceiptQuery, type SubmissionReceiptStore, type SubmitContext, type SubmitResponse, type SubmitResult, type SubmitStatus, type UseSubmissionReceiptsResult, createLocalStorageSubmissionAttemptStore, createLocalStorageSubmissionReceiptStore, isTranslationUnresolved, resolveFieldEditorControls, resolveFieldTypeSelectOptions, resolveInitialFieldType, resolveTranslation, submissionReceiptQueryKey, useField, useForm, useFormBuilder, useSubmissionReceipts };
package/dist/index.d.ts CHANGED
@@ -60,6 +60,12 @@ type BuilderActionError = {
60
60
  } | {
61
61
  readonly type: "max_locales_exceeded";
62
62
  readonly max: number;
63
+ } | {
64
+ readonly type: "field_constraint_immutable";
65
+ } | {
66
+ readonly type: "field_constraint_violation";
67
+ readonly property: string;
68
+ readonly expected: number;
63
69
  } | {
64
70
  readonly type: "node_not_found";
65
71
  readonly kind: BuilderTextTarget["kind"];
@@ -472,6 +478,10 @@ type SubmissionGuardResult = {
472
478
  type SubmissionGuard = (schema: FormSchema, values: Record<string, unknown>) => SubmissionGuardResult | Promise<SubmissionGuardResult>;
473
479
  type FormSuccessRenderMode = "append" | "replace";
474
480
  type SubmissionConfirmationRenderMode = "inline" | "replace" | "dialog";
481
+ interface SubmissionConfirmationOptions {
482
+ readonly enabled?: boolean;
483
+ readonly renderMode?: SubmissionConfirmationRenderMode;
484
+ }
475
485
  type FormSubmitStatus = "idle" | "submitting" | "confirming" | "success" | "error";
476
486
  /** @deprecated Use FormSubmitStatus instead. */
477
487
  type FormSubmitState = "idle" | "submitting" | "confirming" | "success" | "error";
@@ -482,10 +492,11 @@ interface RenderSubmitButtonProps {
482
492
  readonly onSubmit: () => void;
483
493
  }
484
494
  interface SubmissionConfirmationSlotProps {
485
- readonly findings: readonly SensitiveDataFinding[];
495
+ readonly findings?: readonly SensitiveDataFinding[];
486
496
  readonly message?: string;
487
497
  readonly schema: FormSchema;
488
498
  readonly visibleValues: Record<string, unknown>;
499
+ readonly visibleItems?: readonly FormSubmittedAnswerItem[];
489
500
  readonly onConfirm: () => void;
490
501
  readonly onCancel: () => void;
491
502
  }
@@ -665,6 +676,8 @@ interface FormRendererPresentationProps extends SubmissionProtectionProps {
665
676
  * Defaults to "append" for backwards compatibility.
666
677
  */
667
678
  readonly successRenderMode?: FormSuccessRenderMode;
679
+ readonly submissionConfirmation?: SubmissionConfirmationOptions;
680
+ /** @deprecated Use submissionConfirmation.renderMode instead. */
668
681
  readonly submissionConfirmationRenderMode?: SubmissionConfirmationRenderMode;
669
682
  readonly showHiddenFieldsInSummary?: boolean;
670
683
  readonly fieldsClassName?: string;
@@ -691,4 +704,4 @@ interface StandaloneFormRendererProps extends FormRendererPresentationProps {
691
704
  type FormRendererProps = FormRendererPresentationProps | StandaloneFormRendererProps;
692
705
  declare function FormRenderer(props: FormRendererProps): react.JSX.Element;
693
706
 
694
- export { BUILDER_TRANSLATION_ALIASES, BUILDER_TRANSLATION_KEYS, type BeforeSubmit, type BuilderActionContext, type BuilderActionError, type BuilderActionIconType, type BuilderActionResult, type BuilderButtonProps, type BuilderCheckboxProps, type BuilderErrorMessageProps, type BuilderFactories, type BuilderFieldEditorSlotProps, type BuilderFieldsetProps, type BuilderIconButtonProps, type BuilderIdKind, type BuilderLocalizationSlotProps, type BuilderOptionEditorSlotProps, type BuilderPagesSlotProps, type BuilderPolicy, type BuilderSectionProps, type BuilderSelectOption, type BuilderSelectProps, type BuilderSlotActions, type BuilderTextAreaProps, type BuilderTextInputProps, type BuilderTextTarget, type BuilderToolbarSlotProps, type BuilderTranslationActionsSlotProps, type BuilderTranslationKey, type ComponentBaseProps, type FieldComponentProps, type FieldComponents, type FieldEditorControlsConfig, type FieldEditorHeaderSlotProps, type FieldPropertyControlMode, type FieldState, type FieldTypeSelectOptionsConfig, type FieldTypeSelectOptionsContext, type FieldTypeSelectOptionsSorter, type FieldTypeSelectOptionsTransformer, type FieldTypeSelectSlotProps, FormBuilder, type FormBuilderActions, type FormBuilderComponents, type FormBuilderFeatures, type FormBuilderOptions, type FormBuilderProps, type FormBuilderResult, type FormBuilderSectionName, type FormBuilderSlots, type FormCompletionSlotProps, type FormContextValue, type FormFieldsSlotProps, FormProvider, type FormProviderProps, FormRenderer, type FormRendererMessages, type FormRendererPresentationProps, type FormRendererProps, type FormRendererSlots, type FormServerErrorPayload, FormSubmissionError, type FormSubmitHandler, type FormSubmitState, type FormSubmitStatus, type FormSubmittedAnswerItem, type FormSuccessRenderMode, type IconButtonProps, type InputComponentProps, type LocalizationSummaryContext, type ManualTranslationContext, type ManualTranslationTarget, type RenderSubmitButtonProps, type SelectComponentProps, type StandaloneFormRendererProps, type SubmissionAttempt, type SubmissionAttemptStore, type SubmissionConfirmationRenderMode, type SubmissionConfirmationSlotProps, type SubmissionGuard, type SubmissionGuardResult, type SubmissionProtectionProps, type SubmissionReceipt, type SubmissionReceiptQuery, type SubmissionReceiptStore, type SubmitContext, type SubmitResponse, type SubmitResult, type SubmitStatus, type UseSubmissionReceiptsResult, createLocalStorageSubmissionAttemptStore, createLocalStorageSubmissionReceiptStore, isTranslationUnresolved, resolveFieldEditorControls, resolveFieldTypeSelectOptions, resolveInitialFieldType, resolveTranslation, submissionReceiptQueryKey, useField, useForm, useFormBuilder, useSubmissionReceipts };
707
+ export { BUILDER_TRANSLATION_ALIASES, BUILDER_TRANSLATION_KEYS, type BeforeSubmit, type BuilderActionContext, type BuilderActionError, type BuilderActionIconType, type BuilderActionResult, type BuilderButtonProps, type BuilderCheckboxProps, type BuilderErrorMessageProps, type BuilderFactories, type BuilderFieldEditorSlotProps, type BuilderFieldsetProps, type BuilderIconButtonProps, type BuilderIdKind, type BuilderLocalizationSlotProps, type BuilderOptionEditorSlotProps, type BuilderPagesSlotProps, type BuilderPolicy, type BuilderSectionProps, type BuilderSelectOption, type BuilderSelectProps, type BuilderSlotActions, type BuilderTextAreaProps, type BuilderTextInputProps, type BuilderTextTarget, type BuilderToolbarSlotProps, type BuilderTranslationActionsSlotProps, type BuilderTranslationKey, type ComponentBaseProps, type FieldComponentProps, type FieldComponents, type FieldEditorControlsConfig, type FieldEditorHeaderSlotProps, type FieldPropertyControlMode, type FieldState, type FieldTypeSelectOptionsConfig, type FieldTypeSelectOptionsContext, type FieldTypeSelectOptionsSorter, type FieldTypeSelectOptionsTransformer, type FieldTypeSelectSlotProps, FormBuilder, type FormBuilderActions, type FormBuilderComponents, type FormBuilderFeatures, type FormBuilderOptions, type FormBuilderProps, type FormBuilderResult, type FormBuilderSectionName, type FormBuilderSlots, type FormCompletionSlotProps, type FormContextValue, type FormFieldsSlotProps, FormProvider, type FormProviderProps, FormRenderer, type FormRendererMessages, type FormRendererPresentationProps, type FormRendererProps, type FormRendererSlots, type FormServerErrorPayload, FormSubmissionError, type FormSubmitHandler, type FormSubmitState, type FormSubmitStatus, type FormSubmittedAnswerItem, type FormSuccessRenderMode, type IconButtonProps, type InputComponentProps, type LocalizationSummaryContext, type ManualTranslationContext, type ManualTranslationTarget, type RenderSubmitButtonProps, type SelectComponentProps, type StandaloneFormRendererProps, type SubmissionAttempt, type SubmissionAttemptStore, type SubmissionConfirmationOptions, type SubmissionConfirmationRenderMode, type SubmissionConfirmationSlotProps, type SubmissionGuard, type SubmissionGuardResult, type SubmissionProtectionProps, type SubmissionReceipt, type SubmissionReceiptQuery, type SubmissionReceiptStore, type SubmitContext, type SubmitResponse, type SubmitResult, type SubmitStatus, type UseSubmissionReceiptsResult, createLocalStorageSubmissionAttemptStore, createLocalStorageSubmissionReceiptStore, isTranslationUnresolved, resolveFieldEditorControls, resolveFieldTypeSelectOptions, resolveInitialFieldType, resolveTranslation, submissionReceiptQueryKey, useField, useForm, useFormBuilder, useSubmissionReceipts };
package/dist/index.js CHANGED
@@ -133,6 +133,61 @@ function defaultCreateOption(field, id) {
133
133
  function defaultCreatePage(id, questionIds) {
134
134
  return { id, title: "New page", questionIds };
135
135
  }
136
+ function applyFieldConstraintDefaults(field, policy) {
137
+ const constraint = policy?.fieldConstraints?.[field.type];
138
+ if (constraint === void 0) return field;
139
+ const required = constraint.fixedRequired ?? constraint.defaultRequired ?? field.required;
140
+ if (field.type === "rating") {
141
+ const ratingConstraint = "defaultMin" in constraint || "defaultMax" in constraint || "fixedMin" in constraint || "fixedMax" in constraint ? constraint : void 0;
142
+ const min = ratingConstraint !== void 0 && "fixedMin" in ratingConstraint ? ratingConstraint.fixedMin : ratingConstraint !== void 0 && "defaultMin" in ratingConstraint ? ratingConstraint.defaultMin : field.min;
143
+ const max = ratingConstraint !== void 0 && "fixedMax" in ratingConstraint ? ratingConstraint.fixedMax : ratingConstraint !== void 0 && "defaultMax" in ratingConstraint ? ratingConstraint.defaultMax : field.max;
144
+ return {
145
+ ...field,
146
+ required,
147
+ ...min === void 0 ? {} : { min },
148
+ ...max === void 0 ? {} : { max }
149
+ };
150
+ }
151
+ if ((field.type === "text" || field.type === "textarea") && "defaultMaxLength" in constraint) {
152
+ return {
153
+ ...field,
154
+ required,
155
+ ...constraint.defaultMaxLength === void 0 ? {} : { maxLength: constraint.defaultMaxLength }
156
+ };
157
+ }
158
+ return { ...field, required };
159
+ }
160
+ function fieldConstraintError(updated, policy) {
161
+ const constraint = policy?.fieldConstraints?.[updated.type];
162
+ if (constraint === void 0) return void 0;
163
+ if (constraint.fixedRequired !== void 0 && updated.required !== constraint.fixedRequired) {
164
+ return { type: "field_constraint_immutable" };
165
+ }
166
+ if (updated.type === "rating") {
167
+ if ("fixedMin" in constraint && constraint.fixedMin !== void 0 && updated.min !== constraint.fixedMin) {
168
+ return { type: "field_constraint_immutable" };
169
+ }
170
+ if ("fixedMax" in constraint && constraint.fixedMax !== void 0 && updated.max !== constraint.fixedMax) {
171
+ return { type: "field_constraint_immutable" };
172
+ }
173
+ if ("allowedMinRange" in constraint && constraint.allowedMinRange !== void 0 && typeof updated.min === "number" && (updated.min < constraint.allowedMinRange[0] || updated.min > constraint.allowedMinRange[1])) {
174
+ return { type: "field_constraint_violation", property: "min", expected: constraint.allowedMinRange[0] };
175
+ }
176
+ if ("allowedMaxRange" in constraint && constraint.allowedMaxRange !== void 0 && typeof updated.max === "number" && (updated.max < constraint.allowedMaxRange[0] || updated.max > constraint.allowedMaxRange[1])) {
177
+ return { type: "field_constraint_violation", property: "max", expected: constraint.allowedMaxRange[1] };
178
+ }
179
+ }
180
+ if ((updated.type === "text" || updated.type === "textarea") && "maxMaxLength" in constraint && constraint.maxMaxLength !== void 0 && updated.maxLength !== void 0 && updated.maxLength > constraint.maxMaxLength) {
181
+ return { type: "field_constraint_violation", property: "maxLength", expected: constraint.maxMaxLength };
182
+ }
183
+ if ((updated.type === "select" || updated.type === "radio" || updated.type === "multi-select") && "minOptions" in constraint && constraint.minOptions !== void 0 && updated.options.length < constraint.minOptions) {
184
+ return { type: "field_constraint_violation", property: "options", expected: constraint.minOptions };
185
+ }
186
+ if ((updated.type === "select" || updated.type === "radio" || updated.type === "multi-select") && "maxOptions" in constraint && constraint.maxOptions !== void 0 && updated.options.length > constraint.maxOptions) {
187
+ return { type: "field_constraint_violation", property: "options", expected: constraint.maxOptions };
188
+ }
189
+ return void 0;
190
+ }
136
191
  function move(items, sourceIndex, targetIndex) {
137
192
  if (sourceIndex < 0 || targetIndex < 0 || targetIndex >= items.length || sourceIndex === targetIndex)
138
193
  return void 0;
@@ -188,6 +243,8 @@ function useFormBuilder({
188
243
  const updated = updater(current);
189
244
  if (updated.id !== fieldId)
190
245
  return { success: false, error: { type: "invalid_id", kind: "field", id: updated.id } };
246
+ const constraintError = fieldConstraintError(updated, policy);
247
+ if (constraintError !== void 0) return { success: false, error: constraintError };
191
248
  if (policy?.allowedFieldTypes !== void 0 && !policy.allowedFieldTypes.includes(updated.type))
192
249
  return { success: false, error: { type: "disallowed_field_type", fieldType: updated.type } };
193
250
  for (const text of [updated.title, updated.description]) {
@@ -199,7 +256,7 @@ function useFormBuilder({
199
256
  onChange({ ...schema, fields: schema.fields.map((field) => field.id === fieldId ? updated : field) });
200
257
  return { success: true };
201
258
  },
202
- [onChange, policy?.allowedFieldTypes, schema, textPolicyError]
259
+ [onChange, policy, schema, textPolicyError]
203
260
  );
204
261
  const updateOption = useCallback(
205
262
  (fieldId, optionId, updater) => {
@@ -272,6 +329,7 @@ function useFormBuilder({
272
329
  return { success: false, error: { type: "invalid_id", kind: "option", id: option.id } };
273
330
  field = { ...field, options: [option] };
274
331
  }
332
+ field = applyFieldConstraintDefaults(field, policy);
275
333
  const pages = schema.pages?.map((page, index) => ({
276
334
  ...page,
277
335
  questionIds: page.id === pageId || pageId === void 0 && index === (schema.pages?.length ?? 0) - 1 ? [...page.questionIds, field.id] : page.questionIds
@@ -324,6 +382,12 @@ function useFormBuilder({
324
382
  return { success: false, error: { type: "invalid_operation", message: `Field ${fieldId} has no options.` } };
325
383
  if (policy?.maxOptionsPerField !== void 0 && field.options.length >= policy.maxOptionsPerField)
326
384
  return { success: false, error: { type: "max_options_exceeded", max: policy.maxOptionsPerField } };
385
+ const constraint = policy?.fieldConstraints?.[field.type];
386
+ if (constraint !== void 0 && "maxOptions" in constraint && constraint.maxOptions !== void 0 && field.options.length >= constraint.maxOptions)
387
+ return {
388
+ success: false,
389
+ error: { type: "field_constraint_violation", property: "options", expected: constraint.maxOptions }
390
+ };
327
391
  const ids = new Set(
328
392
  schema.fields.flatMap((item) => "options" in item ? item.options.map((option2) => option2.id) : [])
329
393
  );
@@ -340,7 +404,7 @@ function useFormBuilder({
340
404
  });
341
405
  return { success: true };
342
406
  },
343
- [createId, factories.createOption, onChange, policy?.maxOptionsPerField, schema]
407
+ [createId, factories.createOption, onChange, policy, policy?.maxOptionsPerField, schema]
344
408
  );
345
409
  const removeOption = useCallback(
346
410
  (fieldId, optionId) => {
@@ -350,6 +414,12 @@ function useFormBuilder({
350
414
  return { success: false, error: { type: "node_not_found", kind: "option", id: optionId } };
351
415
  if (field.options.length <= 1)
352
416
  return { success: false, error: { type: "invalid_operation", message: "A choice field needs one option." } };
417
+ const constraint = policy?.fieldConstraints?.[field.type];
418
+ if (constraint !== void 0 && "minOptions" in constraint && constraint.minOptions !== void 0 && field.options.length <= constraint.minOptions)
419
+ return {
420
+ success: false,
421
+ error: { type: "field_constraint_violation", property: "options", expected: constraint.minOptions }
422
+ };
353
423
  onChange({
354
424
  ...schema,
355
425
  fields: schema.fields.map(
@@ -358,7 +428,7 @@ function useFormBuilder({
358
428
  });
359
429
  return { success: true };
360
430
  },
361
- [onChange, schema]
431
+ [onChange, policy, schema]
362
432
  );
363
433
  const moveOption = useCallback(
364
434
  (fieldId, optionId, targetIndex) => {
@@ -389,7 +459,9 @@ function useFormBuilder({
389
459
  if (field === void 0) return { success: false, error: { type: "node_not_found", kind: "field", id: fieldId } };
390
460
  if (policy?.allowedFieldTypes !== void 0 && !policy.allowedFieldTypes.includes(type))
391
461
  return { success: false, error: { type: "disallowed_field_type", fieldType: type } };
392
- let transformed = transformFieldType(field, type);
462
+ let transformed = applyFieldConstraintDefaults(transformFieldType(field, type), policy);
463
+ const constraintError = fieldConstraintError(transformed, policy);
464
+ if (constraintError !== void 0) return { success: false, error: constraintError };
393
465
  if (CHOICE_TYPES.includes(type) && !("options" in field) && "options" in transformed) {
394
466
  const ids = new Set(
395
467
  schema.fields.flatMap((item) => "options" in item ? item.options.map((option2) => option2.id) : [])
@@ -404,7 +476,7 @@ function useFormBuilder({
404
476
  onChange({ ...schema, fields: schema.fields.map((item) => item.id === fieldId ? transformed : item) });
405
477
  return { success: true };
406
478
  },
407
- [createId, factories.createOption, onChange, policy?.allowedFieldTypes, schema]
479
+ [createId, factories.createOption, onChange, policy, policy?.allowedFieldTypes, schema]
408
480
  );
409
481
  const addPage = useCallback(
410
482
  (questionId) => {
@@ -3478,7 +3550,8 @@ function ContextFormRenderer({
3478
3550
  beforeSubmit,
3479
3551
  onDraftSave,
3480
3552
  successRenderMode = "append",
3481
- submissionConfirmationRenderMode = "inline",
3553
+ submissionConfirmation,
3554
+ submissionConfirmationRenderMode,
3482
3555
  showHiddenFieldsInSummary = false,
3483
3556
  fieldsClassName,
3484
3557
  hideFormOnSuccess = false,
@@ -3514,6 +3587,12 @@ function ContextFormRenderer({
3514
3587
  const activeVisibleIndex = visiblePageIndexes.indexOf(currentPageIndex);
3515
3588
  const fieldIds = activePage === void 0 ? void 0 : new Set(activePage.questionIds);
3516
3589
  const visibleValues = useMemo4(() => selectVisibleAnswers2(form.schema, form.values), [form.schema, form.values]);
3590
+ const visibleItems = useMemo4(
3591
+ () => buildSubmittedItems(form.schema, form.values, form.visibility, (key) => form.translate(key), false),
3592
+ [form.schema, form.translate, form.values, form.visibility]
3593
+ );
3594
+ const confirmationRenderMode = submissionConfirmation?.renderMode ?? submissionConfirmationRenderMode ?? "inline";
3595
+ const confirmationEnabled = submissionConfirmation?.enabled === true;
3517
3596
  const submitState = confirmation === null && !guardsPending ? form.submitStatus : "confirming";
3518
3597
  const interactionLocked = submitState === "confirming" || submitState === "submitting";
3519
3598
  const isReplaceMode = successRenderMode === "replace" || hideFormOnSuccess;
@@ -3581,7 +3660,7 @@ function ContextFormRenderer({
3581
3660
  if (confirmation === null) return;
3582
3661
  const confirmButton = confirmationRef.current?.querySelector("[data-fe-confirm], button");
3583
3662
  confirmButton?.focus();
3584
- if (submissionConfirmationRenderMode !== "dialog") return;
3663
+ if (confirmationRenderMode !== "dialog") return;
3585
3664
  const onKeyDown = (event) => {
3586
3665
  if (event.key === "Escape") {
3587
3666
  event.preventDefault();
@@ -3608,7 +3687,7 @@ function ContextFormRenderer({
3608
3687
  };
3609
3688
  globalThis.addEventListener("keydown", onKeyDown);
3610
3689
  return () => globalThis.removeEventListener("keydown", onKeyDown);
3611
- }, [confirmation, focusSubmitButton, submissionConfirmationRenderMode]);
3690
+ }, [confirmation, confirmationRenderMode, focusSubmitButton]);
3612
3691
  useEffect3(() => {
3613
3692
  if (autoSaveKey === void 0 || typeof globalThis.localStorage === "undefined") return;
3614
3693
  const loadIdentity = `${autoSaveKey}:${form.schema.id}:${form.schema.version}`;
@@ -3679,21 +3758,29 @@ function ContextFormRenderer({
3679
3758
  }
3680
3759
  const validation = validateAnswers2(form.schema, form.values);
3681
3760
  const firstInvalidFieldId = validation.issues[0]?.fieldId;
3682
- if (validation.valid && !guardsConfirmed && submissionGuards.length > 0) {
3761
+ if (validation.valid && !guardsConfirmed) {
3683
3762
  rendererSubmissionInFlight.current = true;
3684
- setGuardsPending(true);
3763
+ setGuardsPending(submissionGuards.length > 0);
3685
3764
  try {
3686
- const guardResult = await runSubmissionGuards(submissionGuards);
3687
- if (guardResult.status === "block") {
3688
- setGuardMessage(guardResult.message ?? form.translate("form.submissionBlocked"));
3689
- return { status: "cancelled" };
3765
+ if (submissionGuards.length > 0) {
3766
+ const guardResult = await runSubmissionGuards(submissionGuards);
3767
+ if (guardResult.status === "block") {
3768
+ setGuardMessage(guardResult.message ?? form.translate("form.submissionBlocked"));
3769
+ return { status: "cancelled" };
3770
+ }
3771
+ if (guardResult.status === "confirm") {
3772
+ setGuardMessage(null);
3773
+ setConfirmation({
3774
+ findings: guardResult.findings,
3775
+ generic: false,
3776
+ ...guardResult.message === void 0 ? {} : { message: guardResult.message }
3777
+ });
3778
+ return { status: "cancelled" };
3779
+ }
3690
3780
  }
3691
- if (guardResult.status === "confirm") {
3781
+ if (confirmationEnabled) {
3692
3782
  setGuardMessage(null);
3693
- setConfirmation({
3694
- findings: guardResult.findings,
3695
- ...guardResult.message === void 0 ? {} : { message: guardResult.message }
3696
- });
3783
+ setConfirmation({ findings: [], generic: true });
3697
3784
  return { status: "cancelled" };
3698
3785
  }
3699
3786
  } finally {
@@ -3856,17 +3943,18 @@ function ContextFormRenderer({
3856
3943
  {
3857
3944
  ref: confirmationRef,
3858
3945
  className: "fe-submission-confirmation",
3859
- role: submissionConfirmationRenderMode === "dialog" ? void 0 : "dialog",
3946
+ role: confirmationRenderMode === "dialog" ? void 0 : "dialog",
3860
3947
  children: slots.renderSubmissionConfirmation?.({
3861
3948
  findings: confirmation?.findings ?? [],
3862
- message: confirmation?.message ?? resolveMessage("confirmSensitiveDataMessage", form.translate("form.confirmSensitiveData")),
3949
+ message: confirmation?.message ?? (confirmation?.generic === true ? form.locale.toLowerCase().startsWith("ja") ? "\u56DE\u7B54\u5185\u5BB9\u3092\u3054\u78BA\u8A8D\u306E\u3046\u3048\u3001\u9001\u4FE1\u3057\u3066\u304F\u3060\u3055\u3044\u3002" : "Please review your answers before submitting." : resolveMessage("confirmSensitiveDataMessage", form.translate("form.confirmSensitiveData"))),
3863
3950
  schema: form.schema,
3864
3951
  visibleValues,
3952
+ visibleItems,
3865
3953
  onConfirm: confirmSubmission,
3866
3954
  onCancel: cancelSubmission
3867
3955
  }) ?? /* @__PURE__ */ jsxs2(Fragment3, { children: [
3868
- /* @__PURE__ */ jsx3("h2", { children: resolveMessage("confirmSensitiveDataTitle") }),
3869
- /* @__PURE__ */ jsx3("p", { children: confirmation?.message ?? resolveMessage("confirmSensitiveDataMessage", form.translate("form.confirmSensitiveData")) }),
3956
+ /* @__PURE__ */ jsx3("h2", { children: confirmation?.generic === true ? form.locale.toLowerCase().startsWith("ja") ? "\u56DE\u7B54\u5185\u5BB9\u306E\u78BA\u8A8D" : "Review your answers" : resolveMessage("confirmSensitiveDataTitle") }),
3957
+ /* @__PURE__ */ jsx3("p", { children: confirmation?.message ?? (confirmation?.generic === true ? form.locale.toLowerCase().startsWith("ja") ? "\u56DE\u7B54\u5185\u5BB9\u3092\u3054\u78BA\u8A8D\u306E\u3046\u3048\u3001\u9001\u4FE1\u3057\u3066\u304F\u3060\u3055\u3044\u3002" : "Please review your answers before submitting." : resolveMessage("confirmSensitiveDataMessage", form.translate("form.confirmSensitiveData"))) }),
3870
3958
  (confirmation?.findings ?? []).length === 0 ? null : /* @__PURE__ */ jsx3("ul", { children: (confirmation?.findings ?? []).map((finding, index) => {
3871
3959
  const field = form.schema.fields.find((candidate) => candidate.id === finding.fieldId);
3872
3960
  const typeLabels = form.locale.toLowerCase().startsWith("ja") ? { email: "\u30E1\u30FC\u30EB\u30A2\u30C9\u30EC\u30B9", phone: "\u96FB\u8A71\u756A\u53F7", url: "URL", postal_code: "\u90F5\u4FBF\u756A\u53F7" } : { email: "Email address", phone: "Phone number", url: "URL", postal_code: "Postal code" };
@@ -3882,6 +3970,11 @@ function ContextFormRenderer({
3882
3970
  ] })
3883
3971
  ] }, `${finding.fieldId}-${finding.type}-${finding.start ?? index}`);
3884
3972
  }) }),
3973
+ confirmation?.generic === true ? /* @__PURE__ */ jsx3("ul", { className: "fe-submission-summary", children: visibleItems.map((item) => /* @__PURE__ */ jsxs2("li", { children: [
3974
+ /* @__PURE__ */ jsx3("span", { children: item.title }),
3975
+ ": ",
3976
+ /* @__PURE__ */ jsx3("span", { children: item.displayValue })
3977
+ ] }, item.fieldId)) }) : null,
3885
3978
  /* @__PURE__ */ jsx3("button", { type: "button", "data-fe-confirm": "true", onClick: confirmSubmission, children: resolveMessage("confirmButton", form.translate("form.confirmSubmission")) }),
3886
3979
  /* @__PURE__ */ jsx3("button", { type: "button", onClick: cancelSubmission, children: resolveMessage("cancelButton", form.translate("form.cancelSubmission")) })
3887
3980
  ] })
@@ -3901,7 +3994,7 @@ function ContextFormRenderer({
3901
3994
  if (form.submitStatus === "success" && isReplaceMode) {
3902
3995
  return /* @__PURE__ */ jsx3("div", { className: `fe-form ${className}`.trim(), children: completionRegion });
3903
3996
  }
3904
- if (confirmation !== null && submissionConfirmationRenderMode === "replace") {
3997
+ if (confirmation !== null && confirmationRenderMode === "replace") {
3905
3998
  return /* @__PURE__ */ jsx3("div", { className: `fe-form ${className}`.trim(), children: confirmationContent });
3906
3999
  }
3907
4000
  return /* @__PURE__ */ jsxs2(Fragment3, { children: [
@@ -3912,7 +4005,7 @@ function ContextFormRenderer({
3912
4005
  className: `fe-form ${className}`.trim(),
3913
4006
  noValidate: true,
3914
4007
  onSubmit: handleSubmit,
3915
- "aria-hidden": confirmation !== null && submissionConfirmationRenderMode === "dialog" ? true : void 0,
4008
+ "aria-hidden": confirmation !== null && confirmationRenderMode === "dialog" ? true : void 0,
3916
4009
  children: [
3917
4010
  slots.renderHeader?.({
3918
4011
  title: form.schema.title,
@@ -3981,7 +4074,7 @@ function ContextFormRenderer({
3981
4074
  return slots.renderFields?.({ children: fieldChildren, className: fieldClassName }) ?? /* @__PURE__ */ jsx3("div", { className: fieldClassName, children: fieldChildren });
3982
4075
  })(),
3983
4076
  guardMessage === null ? null : /* @__PURE__ */ jsx3("div", { role: "alert", children: guardMessage }),
3984
- confirmation !== null && submissionConfirmationRenderMode === "inline" ? confirmationContent : null,
4077
+ confirmation !== null && confirmationRenderMode === "inline" ? confirmationContent : null,
3985
4078
  validationIssues.length === 0 ? null : slots.renderValidationSummary?.({ issues: validationIssues }) ?? /* @__PURE__ */ jsxs2("div", { className: "fe-validation-summary", role: "alert", children: [
3986
4079
  validationIssues.length,
3987
4080
  " validation error",
@@ -4036,7 +4129,7 @@ function ContextFormRenderer({
4036
4129
  ]
4037
4130
  }
4038
4131
  ),
4039
- confirmation !== null && submissionConfirmationRenderMode === "dialog" ? /* @__PURE__ */ jsx3("div", { className: "fe-confirmation-dialog-backdrop", role: "dialog", "aria-modal": "true", children: confirmationContent }) : null
4132
+ confirmation !== null && confirmationRenderMode === "dialog" ? /* @__PURE__ */ jsx3("div", { className: "fe-confirmation-dialog-backdrop", role: "dialog", "aria-modal": "true", children: confirmationContent }) : null
4040
4133
  ] });
4041
4134
  }
4042
4135
  var RENDERER_MESSAGES = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@form-engine-ts/react",
3
- "version": "4.1.0",
3
+ "version": "4.3.1",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -42,8 +42,8 @@
42
42
  "typescript"
43
43
  ],
44
44
  "dependencies": {
45
- "@form-engine-ts/core": "4.1.0",
46
- "@form-engine-ts/privacy": "4.1.0"
45
+ "@form-engine-ts/core": "4.3.1",
46
+ "@form-engine-ts/privacy": "4.3.1"
47
47
  },
48
48
  "peerDependencies": {
49
49
  "react": ">=18.2 <20",