@fogpipe/forma-react 0.17.0 → 0.17.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 +29 -26
- package/dist/index.d.ts +19 -3
- package/dist/index.js +63 -38
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/FieldRenderer.tsx +12 -4
- package/src/FormRenderer.tsx +26 -9
- package/src/__tests__/FieldRenderer.test.tsx +5 -1
- package/src/__tests__/FormRenderer.test.tsx +146 -0
- package/src/__tests__/canProceed.test.ts +243 -0
- package/src/__tests__/events.test.ts +15 -5
- package/src/__tests__/useForma.test.ts +108 -5
- package/src/events.ts +4 -1
- package/src/types.ts +16 -4
- package/src/useForma.ts +48 -34
package/README.md
CHANGED
|
@@ -199,13 +199,13 @@ The event system lets you observe form lifecycle events for side effects like an
|
|
|
199
199
|
|
|
200
200
|
### Available Events
|
|
201
201
|
|
|
202
|
-
| Event | Description
|
|
203
|
-
| -------------- |
|
|
204
|
-
| `fieldChanged` | Fires after a field value changes
|
|
205
|
-
| `preSubmit` | Fires before validation; `data` is mutable
|
|
206
|
-
| `postSubmit` | Fires after submission (success, error, or invalid)
|
|
207
|
-
| `pageChanged` | Fires when wizard page changes
|
|
208
|
-
| `formReset` | Fires after `resetForm()` completes
|
|
202
|
+
| Event | Description |
|
|
203
|
+
| -------------- | --------------------------------------------------- |
|
|
204
|
+
| `fieldChanged` | Fires after a field value changes |
|
|
205
|
+
| `preSubmit` | Fires before validation; `data` is mutable |
|
|
206
|
+
| `postSubmit` | Fires after submission (success, error, or invalid) |
|
|
207
|
+
| `pageChanged` | Fires when wizard page changes |
|
|
208
|
+
| `formReset` | Fires after `resetForm()` completes |
|
|
209
209
|
|
|
210
210
|
### Declarative Registration
|
|
211
211
|
|
|
@@ -217,7 +217,10 @@ const forma = useForma({
|
|
|
217
217
|
onSubmit: handleSubmit,
|
|
218
218
|
on: {
|
|
219
219
|
fieldChanged: (event) => {
|
|
220
|
-
analytics.track("field_changed", {
|
|
220
|
+
analytics.track("field_changed", {
|
|
221
|
+
path: event.path,
|
|
222
|
+
source: event.source,
|
|
223
|
+
});
|
|
221
224
|
},
|
|
222
225
|
preSubmit: async (event) => {
|
|
223
226
|
event.data.token = await getCSRFToken();
|
|
@@ -302,28 +305,28 @@ formRef.current?.setValues({ name: "John" });
|
|
|
302
305
|
|
|
303
306
|
### useForma Methods
|
|
304
307
|
|
|
305
|
-
| Method | Description
|
|
306
|
-
| --------------------------------- |
|
|
307
|
-
| `setFieldValue(path, value)` | Set field value
|
|
308
|
-
| `setFieldTouched(path, touched?)` | Mark field as touched
|
|
309
|
-
| `setValues(values)` | Set multiple values
|
|
310
|
-
| `validateField(path)` | Validate single field
|
|
311
|
-
| `validateForm()` | Validate entire form
|
|
312
|
-
| `submitForm()` | Submit the form
|
|
313
|
-
| `resetForm()` | Reset to initial values
|
|
308
|
+
| Method | Description |
|
|
309
|
+
| --------------------------------- | -------------------------------------------- |
|
|
310
|
+
| `setFieldValue(path, value)` | Set field value |
|
|
311
|
+
| `setFieldTouched(path, touched?)` | Mark field as touched |
|
|
312
|
+
| `setValues(values)` | Set multiple values |
|
|
313
|
+
| `validateField(path)` | Validate single field |
|
|
314
|
+
| `validateForm()` | Validate entire form |
|
|
315
|
+
| `submitForm()` | Submit the form |
|
|
316
|
+
| `resetForm()` | Reset to initial values |
|
|
314
317
|
| `on(event, listener)` | Register event listener; returns unsubscribe |
|
|
315
318
|
|
|
316
319
|
### useForma Options
|
|
317
320
|
|
|
318
|
-
| Option | Type | Default | Description
|
|
319
|
-
| ---------------------- | -------------------------------- | -------- |
|
|
320
|
-
| `spec` | `Forma` | required | The Forma specification
|
|
321
|
-
| `initialData` | `Record<string, unknown>` | `{}` | Initial form values
|
|
322
|
-
| `onSubmit` | `(data) => void` | - | Submit handler
|
|
323
|
-
| `onChange` | `(data, computed) => void` | - | Change handler
|
|
324
|
-
| `validateOn` | `"change" \| "blur" \| "submit"` | `"blur"` | When to validate
|
|
325
|
-
| `referenceData` | `Record<string, unknown>` | - | Additional reference data
|
|
326
|
-
| `validationDebounceMs` | `number` | `0` | Debounce validation (ms)
|
|
321
|
+
| Option | Type | Default | Description |
|
|
322
|
+
| ---------------------- | -------------------------------- | -------- | --------------------------- |
|
|
323
|
+
| `spec` | `Forma` | required | The Forma specification |
|
|
324
|
+
| `initialData` | `Record<string, unknown>` | `{}` | Initial form values |
|
|
325
|
+
| `onSubmit` | `(data) => void` | - | Submit handler |
|
|
326
|
+
| `onChange` | `(data, computed) => void` | - | Change handler |
|
|
327
|
+
| `validateOn` | `"change" \| "blur" \| "submit"` | `"blur"` | When to validate |
|
|
328
|
+
| `referenceData` | `Record<string, unknown>` | - | Additional reference data |
|
|
329
|
+
| `validationDebounceMs` | `number` | `0` | Debounce validation (ms) |
|
|
327
330
|
| `on` | `FormaEvents` | - | Declarative event listeners |
|
|
328
331
|
|
|
329
332
|
## Error Boundary
|
package/dist/index.d.ts
CHANGED
|
@@ -102,8 +102,13 @@ interface BaseFieldProps {
|
|
|
102
102
|
required: boolean;
|
|
103
103
|
/** Whether the field is disabled */
|
|
104
104
|
disabled: boolean;
|
|
105
|
-
/** Validation errors for this field */
|
|
105
|
+
/** Validation errors for this field (always populated — use visibleErrors for display) */
|
|
106
106
|
errors: FieldError[];
|
|
107
|
+
/**
|
|
108
|
+
* Errors filtered by interaction state (touched or submitted).
|
|
109
|
+
* Use this for displaying errors in the UI to avoid showing errors on untouched fields.
|
|
110
|
+
*/
|
|
111
|
+
visibleErrors: FieldError[];
|
|
107
112
|
/** Handler for value changes */
|
|
108
113
|
onChange: (value: unknown) => void;
|
|
109
114
|
/** Handler for blur events */
|
|
@@ -384,7 +389,7 @@ interface ComponentMap {
|
|
|
384
389
|
*/
|
|
385
390
|
interface LayoutProps {
|
|
386
391
|
children: React.ReactNode;
|
|
387
|
-
onSubmit: () => void;
|
|
392
|
+
onSubmit: (e?: React.FormEvent) => void;
|
|
388
393
|
isSubmitting: boolean;
|
|
389
394
|
isValid: boolean;
|
|
390
395
|
}
|
|
@@ -513,8 +518,13 @@ interface GetFieldPropsResult {
|
|
|
513
518
|
showRequiredIndicator: boolean;
|
|
514
519
|
/** Whether field has been touched */
|
|
515
520
|
touched: boolean;
|
|
516
|
-
/** Validation errors for this field */
|
|
521
|
+
/** Validation errors for this field (always populated — use visibleErrors for display) */
|
|
517
522
|
errors: FieldError[];
|
|
523
|
+
/**
|
|
524
|
+
* Errors filtered by interaction state (touched or submitted).
|
|
525
|
+
* Use this for displaying errors in the UI to avoid showing errors on untouched fields.
|
|
526
|
+
*/
|
|
527
|
+
visibleErrors: FieldError[];
|
|
518
528
|
/** Handler for value changes */
|
|
519
529
|
onChange: (value: unknown) => void;
|
|
520
530
|
/** Handler for blur events */
|
|
@@ -631,6 +641,12 @@ interface WizardHelpers {
|
|
|
631
641
|
goToPage: (index: number) => void;
|
|
632
642
|
nextPage: () => void;
|
|
633
643
|
previousPage: () => void;
|
|
644
|
+
/**
|
|
645
|
+
* Safe "Next" handler for wizard navigation.
|
|
646
|
+
* Advances to the next page if one exists. Never triggers submission.
|
|
647
|
+
* Use this instead of conditionally calling nextPage/onSubmit in a single button.
|
|
648
|
+
*/
|
|
649
|
+
handleNext: () => void;
|
|
634
650
|
hasNextPage: boolean;
|
|
635
651
|
hasPreviousPage: boolean;
|
|
636
652
|
canProceed: boolean;
|
package/dist/index.js
CHANGED
|
@@ -443,6 +443,20 @@ function useForma(options) {
|
|
|
443
443
|
const hasNextPage = clampedPageIndex < visiblePages.length - 1;
|
|
444
444
|
const hasPreviousPage = clampedPageIndex > 0;
|
|
445
445
|
const isLastPage = clampedPageIndex === visiblePages.length - 1;
|
|
446
|
+
const advanceToNextPage = () => {
|
|
447
|
+
if (hasNextPage) {
|
|
448
|
+
const toIndex = clampedPageIndex + 1;
|
|
449
|
+
dispatch({ type: "SET_PAGE", page: toIndex });
|
|
450
|
+
const newPage = visiblePages[toIndex];
|
|
451
|
+
if (newPage) {
|
|
452
|
+
fireEvent("pageChanged", {
|
|
453
|
+
fromIndex: clampedPageIndex,
|
|
454
|
+
toIndex,
|
|
455
|
+
page: newPage
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
};
|
|
446
460
|
return {
|
|
447
461
|
pages,
|
|
448
462
|
currentPageIndex: clampedPageIndex,
|
|
@@ -461,20 +475,7 @@ function useForma(options) {
|
|
|
461
475
|
}
|
|
462
476
|
}
|
|
463
477
|
},
|
|
464
|
-
nextPage:
|
|
465
|
-
if (hasNextPage) {
|
|
466
|
-
const toIndex = clampedPageIndex + 1;
|
|
467
|
-
dispatch({ type: "SET_PAGE", page: toIndex });
|
|
468
|
-
const newPage = visiblePages[toIndex];
|
|
469
|
-
if (newPage) {
|
|
470
|
-
fireEvent("pageChanged", {
|
|
471
|
-
fromIndex: clampedPageIndex,
|
|
472
|
-
toIndex,
|
|
473
|
-
page: newPage
|
|
474
|
-
});
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
},
|
|
478
|
+
nextPage: advanceToNextPage,
|
|
478
479
|
previousPage: () => {
|
|
479
480
|
if (hasPreviousPage) {
|
|
480
481
|
const toIndex = clampedPageIndex - 1;
|
|
@@ -489,6 +490,10 @@ function useForma(options) {
|
|
|
489
490
|
}
|
|
490
491
|
}
|
|
491
492
|
},
|
|
493
|
+
// Same function as nextPage — exposed as a separate name so consumers can
|
|
494
|
+
// bind a single "Next" button without risk of accidentally triggering submission.
|
|
495
|
+
// nextPage is already a no-op on the last page.
|
|
496
|
+
handleNext: advanceToNextPage,
|
|
492
497
|
hasNextPage,
|
|
493
498
|
hasPreviousPage,
|
|
494
499
|
canProceed: (() => {
|
|
@@ -517,7 +522,15 @@ function useForma(options) {
|
|
|
517
522
|
return pageErrors.length === 0;
|
|
518
523
|
}
|
|
519
524
|
};
|
|
520
|
-
}, [
|
|
525
|
+
}, [
|
|
526
|
+
spec,
|
|
527
|
+
state.data,
|
|
528
|
+
state.currentPage,
|
|
529
|
+
computed,
|
|
530
|
+
validation,
|
|
531
|
+
visibility,
|
|
532
|
+
fireEvent
|
|
533
|
+
]);
|
|
521
534
|
useEffect(() => {
|
|
522
535
|
const events = pendingEventsRef.current;
|
|
523
536
|
if (events.length === 0) return;
|
|
@@ -621,9 +634,9 @@ function useForma(options) {
|
|
|
621
634
|
}
|
|
622
635
|
const fieldErrors = validation.errors.filter((e) => e.field === path);
|
|
623
636
|
const isTouched = state.touched[path] ?? false;
|
|
624
|
-
const
|
|
625
|
-
const
|
|
626
|
-
const
|
|
637
|
+
const shouldShowErrors = validateOn === "change" || validateOn === "blur" && isTouched || state.isSubmitted;
|
|
638
|
+
const visibleFieldErrors = shouldShowErrors ? fieldErrors : [];
|
|
639
|
+
const hasVisibleErrors = visibleFieldErrors.length > 0;
|
|
627
640
|
const isRequired = required[path] ?? false;
|
|
628
641
|
const schemaProperty = spec.schema.properties[path];
|
|
629
642
|
const isBooleanField = (schemaProperty == null ? void 0 : schemaProperty.type) === "boolean" || (fieldDef == null ? void 0 : fieldDef.type) === "boolean";
|
|
@@ -643,12 +656,13 @@ function useForma(options) {
|
|
|
643
656
|
required: isRequired,
|
|
644
657
|
showRequiredIndicator,
|
|
645
658
|
touched: isTouched,
|
|
646
|
-
errors:
|
|
659
|
+
errors: fieldErrors,
|
|
660
|
+
visibleErrors: visibleFieldErrors,
|
|
647
661
|
onChange: handlers.onChange,
|
|
648
662
|
onBlur: handlers.onBlur,
|
|
649
|
-
// ARIA accessibility attributes
|
|
650
|
-
"aria-invalid":
|
|
651
|
-
"aria-describedby":
|
|
663
|
+
// ARIA accessibility attributes (driven by visibleErrors, not all errors)
|
|
664
|
+
"aria-invalid": hasVisibleErrors || void 0,
|
|
665
|
+
"aria-describedby": hasVisibleErrors ? `${path}-error` : void 0,
|
|
652
666
|
"aria-required": isRequired || void 0,
|
|
653
667
|
// Adorner props (only for adornable field types)
|
|
654
668
|
...adornerProps,
|
|
@@ -719,7 +733,8 @@ function useForma(options) {
|
|
|
719
733
|
showRequiredIndicator: false,
|
|
720
734
|
// Item fields don't show required indicator
|
|
721
735
|
touched: isTouched,
|
|
722
|
-
errors:
|
|
736
|
+
errors: fieldErrors,
|
|
737
|
+
visibleErrors: showErrors ? fieldErrors : [],
|
|
723
738
|
onChange: handlers.onChange,
|
|
724
739
|
onBlur: handlers.onBlur,
|
|
725
740
|
options: visibleOptions
|
|
@@ -871,19 +886,10 @@ function useFormaContext() {
|
|
|
871
886
|
// src/FormRenderer.tsx
|
|
872
887
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
873
888
|
function DefaultLayout({ children, onSubmit, isSubmitting }) {
|
|
874
|
-
return /* @__PURE__ */ jsxs(
|
|
875
|
-
|
|
876
|
-
{
|
|
877
|
-
|
|
878
|
-
e.preventDefault();
|
|
879
|
-
onSubmit();
|
|
880
|
-
},
|
|
881
|
-
children: [
|
|
882
|
-
children,
|
|
883
|
-
/* @__PURE__ */ jsx("button", { type: "submit", disabled: isSubmitting, children: isSubmitting ? "Submitting..." : "Submit" })
|
|
884
|
-
]
|
|
885
|
-
}
|
|
886
|
-
);
|
|
889
|
+
return /* @__PURE__ */ jsxs("form", { onSubmit, children: [
|
|
890
|
+
children,
|
|
891
|
+
/* @__PURE__ */ jsx("button", { type: "submit", disabled: isSubmitting, children: isSubmitting ? "Submitting..." : "Submit" })
|
|
892
|
+
] });
|
|
887
893
|
}
|
|
888
894
|
function DefaultFieldWrapper({
|
|
889
895
|
fieldPath,
|
|
@@ -967,7 +973,7 @@ var FormRenderer = forwardRef(
|
|
|
967
973
|
layout: Layout = DefaultLayout,
|
|
968
974
|
fieldWrapper: FieldWrapper = DefaultFieldWrapper,
|
|
969
975
|
pageWrapper: PageWrapper = DefaultPageWrapper,
|
|
970
|
-
validateOn
|
|
976
|
+
validateOn = "blur"
|
|
971
977
|
} = props;
|
|
972
978
|
const forma = useForma({
|
|
973
979
|
spec,
|
|
@@ -1012,6 +1018,7 @@ var FormRenderer = forwardRef(
|
|
|
1012
1018
|
optionsVisibility: formaOptionsVisibility,
|
|
1013
1019
|
touched: formaTouched,
|
|
1014
1020
|
errors: formaErrors,
|
|
1021
|
+
isSubmitted: formaIsSubmitted,
|
|
1015
1022
|
setFieldValue,
|
|
1016
1023
|
setFieldTouched,
|
|
1017
1024
|
getArrayHelpers
|
|
@@ -1045,6 +1052,8 @@ var FormRenderer = forwardRef(
|
|
|
1045
1052
|
}
|
|
1046
1053
|
const errors = formaErrors.filter((e) => e.field === fieldPath);
|
|
1047
1054
|
const touched = formaTouched[fieldPath] ?? false;
|
|
1055
|
+
const showErrors = validateOn === "change" || validateOn === "blur" && touched || formaIsSubmitted;
|
|
1056
|
+
const visibleErrors = showErrors ? errors : [];
|
|
1048
1057
|
const required = formaRequired[fieldPath] ?? false;
|
|
1049
1058
|
const disabled = formaEnabled[fieldPath] === false;
|
|
1050
1059
|
const schemaProperty = spec.schema.properties[fieldPath];
|
|
@@ -1060,6 +1069,7 @@ var FormRenderer = forwardRef(
|
|
|
1060
1069
|
required,
|
|
1061
1070
|
disabled,
|
|
1062
1071
|
errors,
|
|
1072
|
+
visibleErrors,
|
|
1063
1073
|
onChange: (value) => setFieldValue(fieldPath, value),
|
|
1064
1074
|
onBlur: () => setFieldTouched(fieldPath),
|
|
1065
1075
|
// Convenience properties
|
|
@@ -1212,6 +1222,8 @@ var FormRenderer = forwardRef(
|
|
|
1212
1222
|
formaOptionsVisibility,
|
|
1213
1223
|
formaTouched,
|
|
1214
1224
|
formaErrors,
|
|
1225
|
+
formaIsSubmitted,
|
|
1226
|
+
validateOn,
|
|
1215
1227
|
setFieldValue,
|
|
1216
1228
|
setFieldTouched,
|
|
1217
1229
|
getArrayHelpers
|
|
@@ -1238,10 +1250,17 @@ var FormRenderer = forwardRef(
|
|
|
1238
1250
|
}
|
|
1239
1251
|
return /* @__PURE__ */ jsx(Fragment, { children: renderedFields });
|
|
1240
1252
|
}, [spec.pages, forma.wizard, PageWrapper, renderedFields]);
|
|
1253
|
+
const handleSubmit = useCallback2(
|
|
1254
|
+
(e) => {
|
|
1255
|
+
e == null ? void 0 : e.preventDefault();
|
|
1256
|
+
forma.submitForm();
|
|
1257
|
+
},
|
|
1258
|
+
[forma.submitForm]
|
|
1259
|
+
);
|
|
1241
1260
|
return /* @__PURE__ */ jsx(FormaContext.Provider, { value: forma, children: /* @__PURE__ */ jsx(
|
|
1242
1261
|
Layout,
|
|
1243
1262
|
{
|
|
1244
|
-
onSubmit:
|
|
1263
|
+
onSubmit: handleSubmit,
|
|
1245
1264
|
isSubmitting: forma.isSubmitting,
|
|
1246
1265
|
isValid: forma.isValid,
|
|
1247
1266
|
children: content
|
|
@@ -1307,6 +1326,7 @@ function FieldRenderer({
|
|
|
1307
1326
|
}
|
|
1308
1327
|
const errors = forma.errors.filter((e) => e.field === fieldPath);
|
|
1309
1328
|
const touched = forma.touched[fieldPath] ?? false;
|
|
1329
|
+
const visibleErrors = touched || forma.isSubmitted ? errors : [];
|
|
1310
1330
|
const required = forma.required[fieldPath] ?? false;
|
|
1311
1331
|
const disabled = forma.enabled[fieldPath] === false;
|
|
1312
1332
|
const schemaProperty = spec.schema.properties[fieldPath];
|
|
@@ -1319,6 +1339,7 @@ function FieldRenderer({
|
|
|
1319
1339
|
required,
|
|
1320
1340
|
disabled,
|
|
1321
1341
|
errors,
|
|
1342
|
+
visibleErrors,
|
|
1322
1343
|
onChange: (value) => forma.setFieldValue(fieldPath, value),
|
|
1323
1344
|
onBlur: () => forma.setFieldTouched(fieldPath),
|
|
1324
1345
|
// Convenience properties
|
|
@@ -1478,9 +1499,13 @@ function FieldRenderer({
|
|
|
1478
1499
|
const sourceValue = fieldDef.source ? forma.data[fieldDef.source] ?? forma.computed[fieldDef.source] : void 0;
|
|
1479
1500
|
const {
|
|
1480
1501
|
onChange: _onChange,
|
|
1502
|
+
// omit from display props
|
|
1481
1503
|
value: _value,
|
|
1504
|
+
// omit from display props
|
|
1482
1505
|
...displayBaseProps
|
|
1483
1506
|
} = baseProps;
|
|
1507
|
+
void _onChange;
|
|
1508
|
+
void _value;
|
|
1484
1509
|
fieldProps = {
|
|
1485
1510
|
...displayBaseProps,
|
|
1486
1511
|
fieldType: "display",
|