@petrarca/sonnet-forms 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1477,6 +1477,10 @@ FormQuantityInput.displayName = "FormQuantityInput";
1477
1477
  import { Button as Button3 } from "@petrarca/sonnet-ui";
1478
1478
  import { SimpleGroup } from "@petrarca/sonnet-ui";
1479
1479
  import { jsx as jsx10, jsxs as jsxs9 } from "react/jsx-runtime";
1480
+ function isSaveDisabled(args) {
1481
+ if (args.disabled || !args.hasChanges || args.isValidating) return true;
1482
+ return args.saveWhenInvalid === "disable" && !args.isValid;
1483
+ }
1480
1484
  function FormActions({
1481
1485
  onSave,
1482
1486
  onCancel,
@@ -1487,14 +1491,39 @@ function FormActions({
1487
1491
  saveLabel = "Save",
1488
1492
  cancelLabel = "Cancel",
1489
1493
  showCancel = true,
1494
+ isValidating = false,
1490
1495
  leadingActions
1491
1496
  }) {
1492
- const saveDisabled = disabled || !hasChanges || saveWhenInvalid === "disable" && !isValid;
1497
+ const saveDisabled = isSaveDisabled({
1498
+ disabled,
1499
+ hasChanges,
1500
+ isValid,
1501
+ isValidating,
1502
+ saveWhenInvalid
1503
+ });
1504
+ const keepFocus = (e) => e.preventDefault();
1493
1505
  return /* @__PURE__ */ jsxs9("div", { className: "flex items-center justify-between mt-4", children: [
1494
1506
  /* @__PURE__ */ jsx10("div", { className: "flex items-center gap-2", children: leadingActions }),
1495
1507
  /* @__PURE__ */ jsxs9(SimpleGroup, { justify: "flex-end", children: [
1496
- showCancel && /* @__PURE__ */ jsx10(Button3, { variant: "outline", onClick: onCancel, disabled, children: cancelLabel }),
1497
- /* @__PURE__ */ jsx10(Button3, { onClick: onSave, disabled: saveDisabled, children: saveLabel })
1508
+ showCancel && /* @__PURE__ */ jsx10(
1509
+ Button3,
1510
+ {
1511
+ variant: "outline",
1512
+ onClick: onCancel,
1513
+ onMouseDown: keepFocus,
1514
+ disabled,
1515
+ children: cancelLabel
1516
+ }
1517
+ ),
1518
+ /* @__PURE__ */ jsx10(
1519
+ Button3,
1520
+ {
1521
+ onClick: onSave,
1522
+ onMouseDown: keepFocus,
1523
+ disabled: saveDisabled,
1524
+ children: saveLabel
1525
+ }
1526
+ )
1498
1527
  ] })
1499
1528
  ] });
1500
1529
  }
@@ -1535,7 +1564,7 @@ function ExtraPropertiesCard({ properties, schema }) {
1535
1564
  var ExtraPropertiesCard_default = ExtraPropertiesCard;
1536
1565
 
1537
1566
  // src/JsonSchemaFormRenderer.tsx
1538
- import React13, { useCallback as useCallback8, useContext as useContext2, useMemo as useMemo8 } from "react";
1567
+ import React13, { useCallback as useCallback8, useContext as useContext2, useMemo as useMemo9 } from "react";
1539
1568
  import { SimpleStack } from "@petrarca/sonnet-ui";
1540
1569
  import { AlertCircle as AlertCircle2 } from "lucide-react";
1541
1570
  import {
@@ -3471,19 +3500,211 @@ function FormFieldRenderer({
3471
3500
  }
3472
3501
 
3473
3502
  // src/hooks/useFormEngine.ts
3474
- import { useState as useState9, useEffect as useEffect3, useMemo as useMemo7, useCallback as useCallback7, useRef as useRef5 } from "react";
3503
+ import { useState as useState10, useEffect as useEffect4, useMemo as useMemo8, useCallback as useCallback7, useRef as useRef6 } from "react";
3475
3504
  import { isEqual } from "@petrarca/sonnet-core";
3476
3505
  import {
3477
- getSchemaProperties,
3506
+ getSchemaProperties as getSchemaProperties4,
3478
3507
  getFieldDefault,
3479
3508
  applySchemaDefaults as applySchemaDefaults2,
3480
3509
  resolveSchema,
3481
3510
  resolveRefs,
3482
3511
  validateFormData,
3512
+ mergeFieldErrors,
3483
3513
  diffFormData,
3484
3514
  ensureFormItemIds
3485
3515
  } from "@petrarca/sonnet-core/schema";
3486
3516
 
3517
+ // src/validators/runValidators.ts
3518
+ import { getSchemaProperties as getSchemaProperties2 } from "@petrarca/sonnet-core/schema";
3519
+
3520
+ // src/validators/shared.ts
3521
+ import { getSchemaProperties } from "@petrarca/sonnet-core/schema";
3522
+ function toRefs(v) {
3523
+ if (v === void 0) return [];
3524
+ const arr = Array.isArray(v) ? v : [v];
3525
+ return arr.map((item) => typeof item === "string" ? { name: item } : item);
3526
+ }
3527
+ function escapeToken(token) {
3528
+ return token.replace(/~/g, "~0").replace(/\//g, "~1");
3529
+ }
3530
+ function fieldPointer(fieldName) {
3531
+ return `/${escapeToken(fieldName)}`;
3532
+ }
3533
+ function resolveFieldRefs(fieldName, schema, uiSchema) {
3534
+ const property = getSchemaProperties(schema)[fieldName] ?? {};
3535
+ return toRefs(
3536
+ uiSchema[fieldName]?.["x-ui-validate"] ?? property["x-ui-validate"]
3537
+ );
3538
+ }
3539
+ function toFieldErrors(result, pointer) {
3540
+ if (result == null || result === "") return [];
3541
+ if (typeof result === "string") return [{ pointer, message: result }];
3542
+ if (Array.isArray(result)) return result;
3543
+ return [];
3544
+ }
3545
+
3546
+ // src/validators/runValidators.ts
3547
+ function isKnownName(registry, name) {
3548
+ return !!(registry.field?.[name] || registry.asyncField?.[name] || registry.form?.[name] || registry.asyncForm?.[name]);
3549
+ }
3550
+ function unknownValidator(kind, name, registry) {
3551
+ const registered = [
3552
+ ...Object.keys(registry.field ?? {}),
3553
+ ...Object.keys(registry.asyncField ?? {}),
3554
+ ...Object.keys(registry.form ?? {}),
3555
+ ...Object.keys(registry.asyncForm ?? {})
3556
+ ];
3557
+ throw new Error(
3558
+ `unknown ${kind} validator '${name}' (registered: [${registered.join(", ")}])`
3559
+ );
3560
+ }
3561
+ function runFieldRefs(fieldName, data, registry, uiSchema, schema, out) {
3562
+ const refs = resolveFieldRefs(fieldName, schema, uiSchema);
3563
+ if (refs.length === 0) return;
3564
+ const pointer = fieldPointer(fieldName);
3565
+ const value = data[fieldName];
3566
+ for (const ref of refs) {
3567
+ if (!isKnownName(registry, ref.name)) {
3568
+ unknownValidator("field", ref.name, registry);
3569
+ }
3570
+ const fn = registry.field?.[ref.name];
3571
+ if (!fn) continue;
3572
+ const ctx = {
3573
+ formData: data,
3574
+ path: pointer,
3575
+ args: ref.args
3576
+ };
3577
+ out.push(...toFieldErrors(fn(value, ctx), pointer));
3578
+ }
3579
+ }
3580
+ function runFormRefs(data, registry, schema, out) {
3581
+ for (const ref of toRefs(schema["x-ui-validate"])) {
3582
+ if (!isKnownName(registry, ref.name)) {
3583
+ unknownValidator("form", ref.name, registry);
3584
+ }
3585
+ const fn = registry.form?.[ref.name];
3586
+ if (!fn) continue;
3587
+ const ctx = { formData: data, path: "", args: ref.args };
3588
+ out.push(...fn(data, ctx));
3589
+ }
3590
+ }
3591
+ function runValidators(schema, data, registry, uiSchema, includeFormValidators) {
3592
+ const out = [];
3593
+ if (!registry) return out;
3594
+ for (const fieldName of Object.keys(getSchemaProperties2(schema))) {
3595
+ runFieldRefs(fieldName, data, registry, uiSchema, schema, out);
3596
+ }
3597
+ if (includeFormValidators) {
3598
+ runFormRefs(data, registry, schema, out);
3599
+ }
3600
+ return out;
3601
+ }
3602
+
3603
+ // src/validators/useAsyncValidators.ts
3604
+ import { useEffect as useEffect3, useMemo as useMemo7, useRef as useRef5, useState as useState9 } from "react";
3605
+ import { getSchemaProperties as getSchemaProperties3 } from "@petrarca/sonnet-core/schema";
3606
+ function keyOf(value) {
3607
+ try {
3608
+ return JSON.stringify(value) ?? "undefined";
3609
+ } catch {
3610
+ return String(value);
3611
+ }
3612
+ }
3613
+ function collectFieldCandidates(schema, data, registry, uiSchema, out) {
3614
+ for (const fieldName of Object.keys(getSchemaProperties3(schema))) {
3615
+ const refs = resolveFieldRefs(fieldName, schema, uiSchema);
3616
+ const pointer = fieldPointer(fieldName);
3617
+ const value = data[fieldName];
3618
+ for (const ref of refs) {
3619
+ const fn = registry[ref.name];
3620
+ if (!fn) continue;
3621
+ const ctx = {
3622
+ formData: data,
3623
+ path: pointer,
3624
+ args: ref.args
3625
+ };
3626
+ out.push({
3627
+ id: `asyncField:${pointer}:${ref.name}`,
3628
+ key: keyOf(value),
3629
+ pointer,
3630
+ run: () => fn(value, ctx)
3631
+ });
3632
+ }
3633
+ }
3634
+ }
3635
+ function collectFormCandidates(schema, data, registry, out) {
3636
+ for (const ref of toRefs(schema["x-ui-validate"])) {
3637
+ const fn = registry[ref.name];
3638
+ if (!fn) continue;
3639
+ const ctx = { formData: data, path: "", args: ref.args };
3640
+ const basis = ref.dependsOn ? ref.dependsOn.map((f) => keyOf(data[f])).join("|") : keyOf(data);
3641
+ out.push({
3642
+ id: `asyncForm:${ref.name}`,
3643
+ key: basis,
3644
+ pointer: "",
3645
+ run: () => fn(data, ctx)
3646
+ });
3647
+ }
3648
+ }
3649
+ function collectCandidates(schema, data, registry, uiSchema, isRoot) {
3650
+ const out = [];
3651
+ if (registry.asyncField) {
3652
+ collectFieldCandidates(schema, data, registry.asyncField, uiSchema, out);
3653
+ }
3654
+ if (registry.asyncForm && isRoot) {
3655
+ collectFormCandidates(schema, data, registry.asyncForm, out);
3656
+ }
3657
+ return out;
3658
+ }
3659
+ function useAsyncValidators(schema, data, registry, uiSchema, isRoot) {
3660
+ const [entries, setEntries] = useState9({});
3661
+ const handledKey = useRef5({});
3662
+ const candidates = registry && (registry.asyncField || registry.asyncForm) ? collectCandidates(schema, data, registry, uiSchema, isRoot) : [];
3663
+ const liveIds = new Set(candidates.map((c) => c.id));
3664
+ const signature = candidates.map((c) => `${c.id}=${c.key}`).join(";");
3665
+ useEffect3(() => {
3666
+ for (const c of candidates) {
3667
+ if (handledKey.current[c.id] === c.key) continue;
3668
+ handledKey.current[c.id] = c.key;
3669
+ setEntries((e) => ({
3670
+ ...e,
3671
+ [c.id]: { key: c.key, pending: true, errors: [] }
3672
+ }));
3673
+ const settle = (errors) => {
3674
+ if (handledKey.current[c.id] !== c.key) return;
3675
+ setEntries((e) => ({
3676
+ ...e,
3677
+ [c.id]: { key: c.key, pending: false, errors }
3678
+ }));
3679
+ };
3680
+ c.run().then((r) => settle(toFieldErrors(r, c.pointer))).catch(() => settle([]));
3681
+ }
3682
+ }, [signature]);
3683
+ const liveKey = [...liveIds].sort().join(",");
3684
+ useEffect3(() => {
3685
+ setEntries((e) => {
3686
+ const kept = Object.entries(e).filter(([id]) => liveIds.has(id));
3687
+ return kept.length === Object.keys(e).length ? e : Object.fromEntries(kept);
3688
+ });
3689
+ for (const id of Object.keys(handledKey.current)) {
3690
+ if (!liveIds.has(id)) delete handledKey.current[id];
3691
+ }
3692
+ }, [liveKey]);
3693
+ let isValidating = false;
3694
+ const errorList = [];
3695
+ for (const c of candidates) {
3696
+ const entry = entries[c.id];
3697
+ if (!entry || entry.key !== c.key || entry.pending) {
3698
+ isValidating = true;
3699
+ continue;
3700
+ }
3701
+ errorList.push(...entry.errors);
3702
+ }
3703
+ const errorSig = errorList.map((e) => `${e.pointer}\0${e.message}`).join("|");
3704
+ const asyncErrors = useMemo7(() => errorList, [errorSig]);
3705
+ return { asyncErrors, isValidating };
3706
+ }
3707
+
3487
3708
  // src/hooks/orderProperties.ts
3488
3709
  function orderProperties(properties, orderArray) {
3489
3710
  const allEntries = Object.entries(properties);
@@ -3527,6 +3748,11 @@ function computeBaselineUpdate(newData, originalData, initialized, schemaPropert
3527
3748
  }
3528
3749
  return null;
3529
3750
  }
3751
+ function computeAllErrors(args) {
3752
+ const { schema, data, customSync, asyncErrors, extraErrors } = args;
3753
+ const base = validateFormData(schema, data).fieldErrors;
3754
+ return mergeFieldErrors(base, customSync, asyncErrors, extraErrors ?? []);
3755
+ }
3530
3756
  function useFormEngine({
3531
3757
  schema,
3532
3758
  data,
@@ -3541,51 +3767,54 @@ function useFormEngine({
3541
3767
  deferChanges = false,
3542
3768
  deferStateUpdates = false,
3543
3769
  allowPristineSave = false,
3544
- refResolver
3770
+ refResolver,
3771
+ extraErrors,
3772
+ validators,
3773
+ isRoot = true
3545
3774
  }) {
3546
- const refResolvedSchema = useMemo7(
3775
+ const refResolvedSchema = useMemo8(
3547
3776
  () => resolveRefs(schema, refResolver),
3548
3777
  [schema, refResolver]
3549
3778
  );
3550
- const seed = useState9(
3779
+ const seed = useState10(
3551
3780
  () => ensureFormItemIds(
3552
3781
  applySchemaDefaults2(data, refResolvedSchema),
3553
3782
  refResolvedSchema
3554
3783
  )
3555
3784
  )[0];
3556
- const [originalData, setOriginalData] = useState9(
3785
+ const [originalData, setOriginalData] = useState10(
3557
3786
  () => seed
3558
3787
  );
3559
- const [originalDataInitialized, setOriginalDataInitialized] = useState9(false);
3560
- const [nonSchemaData, setNonSchemaData] = useState9(
3788
+ const [originalDataInitialized, setOriginalDataInitialized] = useState10(false);
3789
+ const [nonSchemaData, setNonSchemaData] = useState10(
3561
3790
  () => {
3562
- const props = getSchemaProperties(refResolvedSchema);
3791
+ const props = getSchemaProperties4(refResolvedSchema);
3563
3792
  const names = Object.keys(props);
3564
3793
  return Object.fromEntries(
3565
3794
  Object.entries(data).filter(([k]) => !names.includes(k))
3566
3795
  );
3567
3796
  }
3568
3797
  );
3569
- const [formData, setFormData] = useState9(() => seed);
3570
- const formDataRef = useRef5(formData);
3798
+ const [formData, setFormData] = useState10(() => seed);
3799
+ const formDataRef = useRef6(formData);
3571
3800
  formDataRef.current = formData;
3572
- const [pendingChanges, setPendingChanges] = useState9({});
3573
- const resolvedSchema = useMemo7(
3801
+ const [pendingChanges, setPendingChanges] = useState10({});
3802
+ const resolvedSchema = useMemo8(
3574
3803
  () => resolveSchema(refResolvedSchema, formData),
3575
3804
  [refResolvedSchema, formData]
3576
3805
  );
3577
- const orderedProperties = useMemo7(() => {
3578
- const properties2 = getSchemaProperties(resolvedSchema);
3806
+ const orderedProperties = useMemo8(() => {
3807
+ const properties2 = getSchemaProperties4(resolvedSchema);
3579
3808
  const rawOrder = uiSchema["x-ui-order"] ?? resolvedSchema["x-ui-order"];
3580
3809
  const orderArray = Array.isArray(rawOrder) ? rawOrder : void 0;
3581
3810
  return orderProperties(properties2, orderArray);
3582
3811
  }, [resolvedSchema, uiSchema]);
3583
- const [initialDefaultsSent, setInitialDefaultsSent] = useState9(false);
3584
- const [edited, setEdited] = useState9({});
3585
- const [touched, setTouched] = useState9({});
3586
- const [submitAttempted, setSubmitAttempted] = useState9(false);
3587
- const properties = getSchemaProperties(resolvedSchema);
3588
- useEffect3(() => {
3812
+ const [initialDefaultsSent, setInitialDefaultsSent] = useState10(false);
3813
+ const [edited, setEdited] = useState10({});
3814
+ const [touched, setTouched] = useState10({});
3815
+ const [submitAttempted, setSubmitAttempted] = useState10(false);
3816
+ const properties = getSchemaProperties4(resolvedSchema);
3817
+ useEffect4(() => {
3589
3818
  if (!initialDefaultsSent) {
3590
3819
  const hasDefaults = Object.entries(properties).some(
3591
3820
  ([fieldName, property]) => data[fieldName] === void 0 && getFieldDefault(property) !== void 0
@@ -3596,7 +3825,7 @@ function useFormEngine({
3596
3825
  setInitialDefaultsSent(true);
3597
3826
  }
3598
3827
  }, [initialDefaultsSent, properties, data, formData, onChange]);
3599
- useEffect3(() => {
3828
+ useEffect4(() => {
3600
3829
  const currentPropertyNames = Object.keys(properties);
3601
3830
  const keysToRemove = Object.keys(formData).filter(
3602
3831
  (k) => !currentPropertyNames.includes(k)
@@ -3610,8 +3839,8 @@ function useFormEngine({
3610
3839
  if (onChange) onChange(cleanedData);
3611
3840
  }
3612
3841
  }, [properties, formData, onChange]);
3613
- const prevDataRef = useRef5(data);
3614
- useEffect3(() => {
3842
+ const prevDataRef = useRef6(data);
3843
+ useEffect4(() => {
3615
3844
  if (isEqual(prevDataRef.current, data)) return;
3616
3845
  prevDataRef.current = data;
3617
3846
  const newData = ensureFormItemIds(
@@ -3641,24 +3870,45 @@ function useFormEngine({
3641
3870
  originalData,
3642
3871
  originalDataInitialized
3643
3872
  ]);
3644
- const hasChanges = useMemo7(
3873
+ const hasChanges = useMemo8(
3645
3874
  () => !isEqual(formData, originalData),
3646
3875
  [formData, originalData]
3647
3876
  );
3648
- const displayData = useMemo7(() => {
3877
+ const displayData = useMemo8(() => {
3649
3878
  if (deferStateUpdates && Object.keys(pendingChanges).length > 0) {
3650
3879
  return { ...formData, ...pendingChanges };
3651
3880
  }
3652
3881
  return formData;
3653
3882
  }, [formData, pendingChanges, deferStateUpdates]);
3654
- const validation = useMemo7(
3655
- () => validateFormData(resolvedSchema, displayData),
3656
- [resolvedSchema, displayData]
3883
+ const customSync = useMemo8(
3884
+ () => runValidators(resolvedSchema, displayData, validators, uiSchema, isRoot),
3885
+ [resolvedSchema, displayData, validators, uiSchema, isRoot]
3886
+ );
3887
+ const { asyncErrors, isValidating } = useAsyncValidators(
3888
+ resolvedSchema,
3889
+ displayData,
3890
+ validators,
3891
+ uiSchema,
3892
+ isRoot
3657
3893
  );
3894
+ const validation = useMemo8(() => {
3895
+ const merged = computeAllErrors({
3896
+ schema: resolvedSchema,
3897
+ data: displayData,
3898
+ customSync,
3899
+ asyncErrors,
3900
+ extraErrors
3901
+ });
3902
+ return {
3903
+ fieldErrors: merged,
3904
+ errors: merged.map((e) => e.message),
3905
+ isValid: merged.length === 0
3906
+ };
3907
+ }, [resolvedSchema, displayData, customSync, asyncErrors, extraErrors]);
3658
3908
  const { isValid, fieldErrors } = validation;
3659
- const onValidationChangeRef = useRef5(onValidationChange);
3909
+ const onValidationChangeRef = useRef6(onValidationChange);
3660
3910
  onValidationChangeRef.current = onValidationChange;
3661
- useEffect3(() => {
3911
+ useEffect4(() => {
3662
3912
  onValidationChangeRef.current?.(validation.errors, validation.isValid);
3663
3913
  }, [validation]);
3664
3914
  const handleFieldChange = useCallback7(
@@ -3738,10 +3988,23 @@ function useFormEngine({
3738
3988
  const handleSave = useCallback7(() => {
3739
3989
  setSubmitAttempted(true);
3740
3990
  if (!hasChanges && !allowPristineSave) return;
3991
+ if (isValidating) return;
3741
3992
  const finalData = deferStateUpdates && Object.keys(pendingChanges).length > 0 ? { ...formData, ...pendingChanges } : formData;
3742
3993
  const completeData = { ...nonSchemaData, ...finalData };
3743
- const validation2 = validateFormData(resolvedSchema, finalData);
3744
- if (validation2.isValid && onUpdate) {
3994
+ const saveErrors = computeAllErrors({
3995
+ schema: resolvedSchema,
3996
+ data: finalData,
3997
+ customSync: runValidators(
3998
+ resolvedSchema,
3999
+ finalData,
4000
+ validators,
4001
+ uiSchema,
4002
+ isRoot
4003
+ ),
4004
+ asyncErrors,
4005
+ extraErrors
4006
+ });
4007
+ if (saveErrors.length === 0 && onUpdate) {
3745
4008
  const changed = {};
3746
4009
  for (const key of Object.keys(finalData)) {
3747
4010
  if (!isEqual(finalData[key], originalData[key])) {
@@ -3761,7 +4024,13 @@ function useFormEngine({
3761
4024
  pendingChanges,
3762
4025
  deferStateUpdates,
3763
4026
  nonSchemaData,
3764
- onUpdate
4027
+ onUpdate,
4028
+ extraErrors,
4029
+ asyncErrors,
4030
+ isValidating,
4031
+ uiSchema,
4032
+ validators,
4033
+ isRoot
3765
4034
  ]);
3766
4035
  const handleCancel = useCallback7(() => {
3767
4036
  setFormData({ ...originalData });
@@ -3778,6 +4047,7 @@ function useFormEngine({
3778
4047
  orderedProperties,
3779
4048
  hasChanges,
3780
4049
  isValid,
4050
+ isValidating,
3781
4051
  fieldErrors,
3782
4052
  touched,
3783
4053
  submitAttempted,
@@ -3940,8 +4210,11 @@ function layoutFieldsInRows(fields, rowDefs, defaultRowGap) {
3940
4210
  );
3941
4211
  return elements.concat(collectUnplacedFields(fields, placed));
3942
4212
  }
3943
- function useRootFormData(ownData) {
3944
- return useContext2(NestedFormContext)?.rootData ?? ownData;
4213
+ function rootFormData(nestedContext, ownData) {
4214
+ return nestedContext?.rootData ?? ownData;
4215
+ }
4216
+ function rootSubmitAttempted(nestedContext, ownSubmitAttempted) {
4217
+ return ownSubmitAttempted || (nestedContext?.submitAttempted ?? false);
3945
4218
  }
3946
4219
  function blurHandlerNeeded({
3947
4220
  deferStateUpdates,
@@ -3960,6 +4233,19 @@ function pointerRoot(pointer) {
3960
4233
  const token = pointer.split("/")[1] ?? "";
3961
4234
  return token.replace(/~1/g, "/").replace(/~0/g, "~");
3962
4235
  }
4236
+ function fieldRawErrors(fieldName, fieldErrors, extraErrors, gated) {
4237
+ const forField = fieldErrors.filter(
4238
+ (e) => pointerRoot(e.pointer) === fieldName
4239
+ );
4240
+ if (forField.length === 0) return void 0;
4241
+ const extraMessages = new Set(
4242
+ (extraErrors ?? []).filter((e) => pointerRoot(e.pointer) === fieldName).map((e) => e.message)
4243
+ );
4244
+ const always = forField.filter((e) => extraMessages.has(e.message)).map((e) => e.message);
4245
+ const rest = gated ? forField.filter((e) => !extraMessages.has(e.message)).map((e) => e.message) : [];
4246
+ const all = [...always, ...rest];
4247
+ return all.length > 0 ? all : void 0;
4248
+ }
3963
4249
  function groupErrorCount(tab, fieldErrors) {
3964
4250
  if (fieldErrors.length === 0) return 0;
3965
4251
  const owned = new Set(tab.rows.flatMap((row) => row.fields));
@@ -3997,6 +4283,23 @@ function HiddenErrorNotice({
3997
4283
  count === 1 ? "1 required field is in a section that is not currently shown." : `${count} required fields are in sections that are not currently shown.`
3998
4284
  ] });
3999
4285
  }
4286
+ function FormLevelErrors({
4287
+ messages
4288
+ }) {
4289
+ if (messages.length === 0) return null;
4290
+ return /* @__PURE__ */ jsx37("div", { className: "space-y-1", children: messages.map((msg, i) => /* @__PURE__ */ jsxs23(
4291
+ "p",
4292
+ {
4293
+ role: "alert",
4294
+ className: "flex items-center gap-1.5 text-sm text-destructive",
4295
+ children: [
4296
+ /* @__PURE__ */ jsx37(AlertCircle2, { className: "h-4 w-4 shrink-0" }),
4297
+ msg
4298
+ ]
4299
+ },
4300
+ i
4301
+ )) });
4302
+ }
4000
4303
  function layoutFieldsInGroups(fields, groupDefs, defaultRowGap, fieldErrors, formData, GroupTemplate) {
4001
4304
  const fieldMap = buildFieldMap(fields);
4002
4305
  const placed = /* @__PURE__ */ new Set();
@@ -4028,12 +4331,14 @@ function FormContainer({
4028
4331
  disabled,
4029
4332
  hasChanges,
4030
4333
  isValid,
4334
+ isValidating,
4031
4335
  saveWhenInvalid,
4032
4336
  saveLabel,
4033
4337
  cancelLabel,
4034
4338
  showCancel,
4035
4339
  leadingActions,
4036
4340
  hiddenErrorCount,
4341
+ formLevelErrors,
4037
4342
  children
4038
4343
  }) {
4039
4344
  if (displayContents) {
@@ -4043,6 +4348,7 @@ function FormContainer({
4043
4348
  children,
4044
4349
  showExtraProperties && /* @__PURE__ */ jsx37(ExtraPropertiesCard_default, { properties: formData, schema: resolvedSchema }),
4045
4350
  /* @__PURE__ */ jsx37(HiddenErrorNotice, { count: hiddenErrorCount }),
4351
+ /* @__PURE__ */ jsx37(FormLevelErrors, { messages: formLevelErrors }),
4046
4352
  showActions && /* @__PURE__ */ jsx37(
4047
4353
  FormActions,
4048
4354
  {
@@ -4051,6 +4357,7 @@ function FormContainer({
4051
4357
  disabled,
4052
4358
  hasChanges,
4053
4359
  isValid,
4360
+ isValidating,
4054
4361
  saveWhenInvalid,
4055
4362
  saveLabel,
4056
4363
  cancelLabel,
@@ -4070,9 +4377,12 @@ function JsonSchemaFormRenderer(props) {
4070
4377
  onUpdate,
4071
4378
  onCancel,
4072
4379
  templates,
4380
+ validators,
4073
4381
  leadingActions,
4074
4382
  idPrefix = "field"
4075
4383
  } = props;
4384
+ const nestedFormContext = useContext2(NestedFormContext);
4385
+ const isRootForm = nestedFormContext === null;
4076
4386
  const {
4077
4387
  disabled,
4078
4388
  readOnly,
@@ -4093,6 +4403,7 @@ function JsonSchemaFormRenderer(props) {
4093
4403
  orderedProperties,
4094
4404
  hasChanges,
4095
4405
  isValid,
4406
+ isValidating,
4096
4407
  fieldErrors,
4097
4408
  touched,
4098
4409
  submitAttempted,
@@ -4114,17 +4425,20 @@ function JsonSchemaFormRenderer(props) {
4114
4425
  deferChanges,
4115
4426
  deferStateUpdates,
4116
4427
  allowPristineSave,
4117
- refResolver: props.refResolver
4428
+ refResolver: props.refResolver,
4429
+ extraErrors: props.extraErrors,
4430
+ validators,
4431
+ isRoot: isRootForm
4118
4432
  });
4119
4433
  const layoutLabelPlacement = uiSchema["x-ui-layout"]?.labelPlacement;
4120
- const globalOptions = useMemo8(
4434
+ const globalOptions = useMemo9(
4121
4435
  () => ({
4122
4436
  ...layoutLabelPlacement ? { labelPlacement: layoutLabelPlacement } : {},
4123
4437
  ...uiSchema["x-ui-globalOptions"] || {}
4124
4438
  }),
4125
4439
  [layoutLabelPlacement, uiSchema]
4126
4440
  );
4127
- const resolvedTemplates = useMemo8(
4441
+ const resolvedTemplates = useMemo9(
4128
4442
  () => ({ ...DEFAULT_TEMPLATES, ...templates }),
4129
4443
  [templates]
4130
4444
  );
@@ -4134,20 +4448,26 @@ function JsonSchemaFormRenderer(props) {
4134
4448
  {
4135
4449
  ...props2,
4136
4450
  templates: resolvedTemplates,
4451
+ validators,
4137
4452
  showActions: false,
4138
4453
  showExtraProperties: false
4139
4454
  }
4140
4455
  ),
4141
- [resolvedTemplates]
4456
+ [resolvedTemplates, validators]
4457
+ );
4458
+ const rootData = rootFormData(nestedFormContext, displayData);
4459
+ const effectiveSubmitAttempted = rootSubmitAttempted(
4460
+ nestedFormContext,
4461
+ submitAttempted
4142
4462
  );
4143
- const rootData = useRootFormData(displayData);
4144
- const contextValue = useMemo8(
4463
+ const contextValue = useMemo9(
4145
4464
  () => ({
4146
4465
  renderer: nestedRenderer,
4147
4466
  templates: resolvedTemplates,
4148
- rootData
4467
+ rootData,
4468
+ submitAttempted: effectiveSubmitAttempted
4149
4469
  }),
4150
- [nestedRenderer, resolvedTemplates, rootData]
4470
+ [nestedRenderer, resolvedTemplates, rootData, effectiveSubmitAttempted]
4151
4471
  );
4152
4472
  const validationDisplay = useValidationDisplay(props.validationDisplay);
4153
4473
  const saveWhenInvalid = useSaveWhenInvalid(props.saveWhenInvalid);
@@ -4160,12 +4480,12 @@ function JsonSchemaFormRenderer(props) {
4160
4480
  const showFieldErrors = useCallback8(
4161
4481
  (fieldName) => fieldErrorsVisible(
4162
4482
  validationDisplay,
4163
- submitAttempted,
4483
+ effectiveSubmitAttempted,
4164
4484
  touched[fieldName]
4165
4485
  ),
4166
- [validationDisplay, submitAttempted, touched]
4486
+ [validationDisplay, effectiveSubmitAttempted, touched]
4167
4487
  );
4168
- const fields = useMemo8(
4488
+ const fields = useMemo9(
4169
4489
  () => orderedProperties.flatMap(([fieldName, property]) => {
4170
4490
  const rule = uiSchema[fieldName]?.["x-ui-rule"] ?? property["x-ui-rule"];
4171
4491
  const { visible, enabled } = evaluateUiRule(rule, rootData);
@@ -4188,7 +4508,12 @@ function JsonSchemaFormRenderer(props) {
4188
4508
  globalOptions,
4189
4509
  formData: displayData,
4190
4510
  idPrefix,
4191
- rawErrors: showFieldErrors(fieldName) ? fieldErrors.filter((e) => pointerRoot(e.pointer) === fieldName).map((e) => e.message) : void 0
4511
+ rawErrors: fieldRawErrors(
4512
+ fieldName,
4513
+ fieldErrors,
4514
+ props.extraErrors,
4515
+ showFieldErrors(fieldName)
4516
+ )
4192
4517
  },
4193
4518
  fieldName
4194
4519
  )
@@ -4209,6 +4534,7 @@ function JsonSchemaFormRenderer(props) {
4209
4534
  needsBlurHandler,
4210
4535
  showFieldErrors,
4211
4536
  fieldErrors,
4537
+ props.extraErrors,
4212
4538
  handleFieldChange,
4213
4539
  handleFieldBlur
4214
4540
  ]
@@ -4221,7 +4547,7 @@ function JsonSchemaFormRenderer(props) {
4221
4547
  resolvedTemplates,
4222
4548
  layoutConfig?.variant
4223
4549
  );
4224
- const layoutFields = useMemo8(() => {
4550
+ const layoutFields = useMemo9(() => {
4225
4551
  if (groupDefs && groupDefs.length > 0) {
4226
4552
  return layoutFieldsInGroups(
4227
4553
  fields,
@@ -4246,12 +4572,16 @@ function JsonSchemaFormRenderer(props) {
4246
4572
  rootData,
4247
4573
  groupTemplate
4248
4574
  ]);
4249
- const hiddenErrorCount = useMemo8(() => {
4575
+ const hiddenErrorCount = useMemo9(() => {
4250
4576
  if (!groupDefs?.length || fieldErrors.length === 0) return 0;
4251
4577
  const hidden = hiddenGroupFieldNames(groupDefs, rootData);
4252
4578
  if (hidden.size === 0) return 0;
4253
4579
  return fieldErrors.filter((e) => hidden.has(pointerRoot(e.pointer))).length;
4254
4580
  }, [groupDefs, fieldErrors, rootData]);
4581
+ const formLevelErrors = useMemo9(() => {
4582
+ if (validationDisplay !== "always" && !submitAttempted) return [];
4583
+ return fieldErrors.filter((e) => e.pointer === "").map((e) => e.message);
4584
+ }, [fieldErrors, validationDisplay, submitAttempted]);
4255
4585
  return /* @__PURE__ */ jsx37(NestedFormContext.Provider, { value: contextValue, children: /* @__PURE__ */ jsx37(
4256
4586
  FormContainer,
4257
4587
  {
@@ -4265,21 +4595,23 @@ function JsonSchemaFormRenderer(props) {
4265
4595
  disabled,
4266
4596
  hasChanges: hasChanges || allowPristineSave,
4267
4597
  isValid,
4598
+ isValidating,
4268
4599
  saveWhenInvalid,
4269
4600
  saveLabel,
4270
4601
  cancelLabel,
4271
4602
  showCancel,
4272
4603
  leadingActions,
4273
4604
  hiddenErrorCount,
4605
+ formLevelErrors,
4274
4606
  children: layoutFields
4275
4607
  }
4276
4608
  ) });
4277
4609
  }
4278
4610
 
4279
4611
  // src/hooks/useResolvedSchema.ts
4280
- import { useMemo as useMemo9 } from "react";
4612
+ import { useMemo as useMemo10 } from "react";
4281
4613
  import {
4282
- getSchemaProperties as getSchemaProperties2,
4614
+ getSchemaProperties as getSchemaProperties5,
4283
4615
  resolveSchema as resolveSchema2
4284
4616
  } from "@petrarca/sonnet-core/schema";
4285
4617
  function useResolvedSchema({
@@ -4287,12 +4619,12 @@ function useResolvedSchema({
4287
4619
  formData,
4288
4620
  uiSchema
4289
4621
  }) {
4290
- const resolvedSchema = useMemo9(
4622
+ const resolvedSchema = useMemo10(
4291
4623
  () => resolveSchema2(schema, formData),
4292
4624
  [schema, formData]
4293
4625
  );
4294
- const orderedProperties = useMemo9(() => {
4295
- const properties = getSchemaProperties2(resolvedSchema);
4626
+ const orderedProperties = useMemo10(() => {
4627
+ const properties = getSchemaProperties5(resolvedSchema);
4296
4628
  const rawOrder = uiSchema["x-ui-order"] ?? resolvedSchema["x-ui-order"];
4297
4629
  const orderArray = Array.isArray(rawOrder) ? rawOrder : void 0;
4298
4630
  return orderProperties(properties, orderArray);