@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.
@@ -132,6 +132,9 @@ export function FieldRenderer({
132
132
 
133
133
  const errors = forma.errors.filter((e) => e.field === fieldPath);
134
134
  const touched = forma.touched[fieldPath] ?? false;
135
+ // FieldRenderer doesn't have access to validateOn (it's a FormRenderer prop),
136
+ // so it uses the default "blur" behavior: show errors after touch or submit.
137
+ const visibleErrors = touched || forma.isSubmitted ? errors : [];
135
138
  const required = forma.required[fieldPath] ?? false;
136
139
  const disabled = forma.enabled[fieldPath] === false;
137
140
 
@@ -148,6 +151,7 @@ export function FieldRenderer({
148
151
  required,
149
152
  disabled,
150
153
  errors,
154
+ visibleErrors,
151
155
  onChange: (value: unknown) => forma.setFieldValue(fieldPath, value),
152
156
  onBlur: () => forma.setFieldTouched(fieldPath),
153
157
  // Convenience properties
@@ -318,7 +322,10 @@ export function FieldRenderer({
318
322
  } else if (fieldType === "matrix" && fieldDef.type === "matrix") {
319
323
  // Matrix fields — compute visible rows from visibility engine
320
324
  const matrixValue =
321
- (baseProps.value as Record<string, string | number | string[] | number[]> | null) ?? null;
325
+ (baseProps.value as Record<
326
+ string,
327
+ string | number | string[] | number[]
328
+ > | null) ?? null;
322
329
  const rows = fieldDef.rows.map((row) => ({
323
330
  id: row.id,
324
331
  label: row.label,
@@ -340,12 +347,13 @@ export function FieldRenderer({
340
347
  const sourceValue = fieldDef.source
341
348
  ? (forma.data[fieldDef.source] ?? forma.computed[fieldDef.source])
342
349
  : undefined;
343
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
344
350
  const {
345
- onChange: _onChange,
346
- value: _value,
351
+ onChange: _onChange, // omit from display props
352
+ value: _value, // omit from display props
347
353
  ...displayBaseProps
348
354
  } = baseProps;
355
+ void _onChange;
356
+ void _value;
349
357
  fieldProps = {
350
358
  ...displayBaseProps,
351
359
  fieldType: "display",
@@ -86,12 +86,7 @@ export interface FormRendererHandle {
86
86
  */
87
87
  function DefaultLayout({ children, onSubmit, isSubmitting }: LayoutProps) {
88
88
  return (
89
- <form
90
- onSubmit={(e) => {
91
- e.preventDefault();
92
- onSubmit();
93
- }}
94
- >
89
+ <form onSubmit={onSubmit}>
95
90
  {children}
96
91
  <button type="submit" disabled={isSubmitting}>
97
92
  {isSubmitting ? "Submitting..." : "Submit"}
@@ -242,7 +237,7 @@ export const FormRenderer = forwardRef<FormRendererHandle, FormRendererProps>(
242
237
  layout: Layout = DefaultLayout,
243
238
  fieldWrapper: FieldWrapper = DefaultFieldWrapper,
244
239
  pageWrapper: PageWrapper = DefaultPageWrapper,
245
- validateOn,
240
+ validateOn = "blur",
246
241
  } = props;
247
242
 
248
243
  const forma = useForma({
@@ -299,6 +294,7 @@ export const FormRenderer = forwardRef<FormRendererHandle, FormRendererProps>(
299
294
  optionsVisibility: formaOptionsVisibility,
300
295
  touched: formaTouched,
301
296
  errors: formaErrors,
297
+ isSubmitted: formaIsSubmitted,
302
298
  setFieldValue,
303
299
  setFieldTouched,
304
300
  getArrayHelpers,
@@ -342,6 +338,11 @@ export const FormRenderer = forwardRef<FormRendererHandle, FormRendererProps>(
342
338
 
343
339
  const errors = formaErrors.filter((e) => e.field === fieldPath);
344
340
  const touched = formaTouched[fieldPath] ?? false;
341
+ const showErrors =
342
+ validateOn === "change" ||
343
+ (validateOn === "blur" && touched) ||
344
+ formaIsSubmitted;
345
+ const visibleErrors = showErrors ? errors : [];
345
346
  const required = formaRequired[fieldPath] ?? false;
346
347
  const disabled = formaEnabled[fieldPath] === false;
347
348
 
@@ -367,6 +368,7 @@ export const FormRenderer = forwardRef<FormRendererHandle, FormRendererProps>(
367
368
  required,
368
369
  disabled,
369
370
  errors,
371
+ visibleErrors,
370
372
  onChange: (value: unknown) => setFieldValue(fieldPath, value),
371
373
  onBlur: () => setFieldTouched(fieldPath),
372
374
  // Convenience properties
@@ -487,7 +489,10 @@ export const FormRenderer = forwardRef<FormRendererHandle, FormRendererProps>(
487
489
  } as ArrayFieldProps;
488
490
  } else if (fieldType === "matrix" && fieldDef.type === "matrix") {
489
491
  const matrixValue =
490
- (baseProps.value as Record<string, string | number | string[] | number[]> | null) ?? null;
492
+ (baseProps.value as Record<
493
+ string,
494
+ string | number | string[] | number[]
495
+ > | null) ?? null;
491
496
  const rows = fieldDef.rows.map((row) => ({
492
497
  id: row.id,
493
498
  label: row.label,
@@ -574,6 +579,8 @@ export const FormRenderer = forwardRef<FormRendererHandle, FormRendererProps>(
574
579
  formaOptionsVisibility,
575
580
  formaTouched,
576
581
  formaErrors,
582
+ formaIsSubmitted,
583
+ validateOn,
577
584
  setFieldValue,
578
585
  setFieldTouched,
579
586
  getArrayHelpers,
@@ -607,10 +614,20 @@ export const FormRenderer = forwardRef<FormRendererHandle, FormRendererProps>(
607
614
  return <>{renderedFields}</>;
608
615
  }, [spec.pages, forma.wizard, PageWrapper, renderedFields]);
609
616
 
617
+ // Wrap submitForm to always call preventDefault when invoked from a form event.
618
+ // This prevents page refreshes when consumers put onSubmit on a <form> element.
619
+ const handleSubmit = useCallback(
620
+ (e?: React.FormEvent) => {
621
+ e?.preventDefault();
622
+ forma.submitForm();
623
+ },
624
+ [forma.submitForm],
625
+ );
626
+
610
627
  return (
611
628
  <FormaContext.Provider value={forma}>
612
629
  <Layout
613
- onSubmit={forma.submitForm}
630
+ onSubmit={handleSubmit}
614
631
  isSubmitting={forma.isSubmitting}
615
632
  isValid={forma.isValid}
616
633
  >
@@ -815,7 +815,11 @@ describe("FieldRenderer", () => {
815
815
  label: "Rating",
816
816
  rows: [
817
817
  { id: "speed", label: "Speed" },
818
- { id: "quality", label: "Quality", visibleWhen: "show_quality = true" },
818
+ {
819
+ id: "quality",
820
+ label: "Quality",
821
+ visibleWhen: "show_quality = true",
822
+ },
819
823
  ],
820
824
  columns: [
821
825
  { value: 1, label: "Poor" },
@@ -1386,4 +1386,150 @@ describe("FormRenderer", () => {
1386
1386
  });
1387
1387
  });
1388
1388
  });
1389
+
1390
+ // ============================================================================
1391
+ // onSubmit preventDefault
1392
+ // ============================================================================
1393
+
1394
+ describe("onSubmit preventDefault", () => {
1395
+ it("should call preventDefault when onSubmit is invoked with a form event", async () => {
1396
+ const submitHandler = vi.fn();
1397
+
1398
+ // Custom layout that puts onSubmit directly on a <form> element
1399
+ function CustomLayout({ children, onSubmit }: LayoutProps) {
1400
+ return (
1401
+ <form onSubmit={onSubmit} data-testid="form">
1402
+ {children}
1403
+ <button type="submit">Submit</button>
1404
+ </form>
1405
+ );
1406
+ }
1407
+
1408
+ const spec = createTestSpec({
1409
+ fields: {
1410
+ name: { type: "text", label: "Name" },
1411
+ },
1412
+ });
1413
+
1414
+ render(
1415
+ <FormRenderer
1416
+ spec={spec}
1417
+ components={createTestComponentMap()}
1418
+ layout={CustomLayout}
1419
+ onSubmit={submitHandler}
1420
+ initialData={{ name: "John" }}
1421
+ />,
1422
+ );
1423
+
1424
+ const form = screen.getByTestId("form");
1425
+
1426
+ // Create a real submit event and spy on preventDefault
1427
+ const preventDefaultSpy = vi.fn();
1428
+ const submitEvent = new Event("submit", {
1429
+ bubbles: true,
1430
+ cancelable: true,
1431
+ });
1432
+ Object.defineProperty(submitEvent, "preventDefault", {
1433
+ value: preventDefaultSpy,
1434
+ });
1435
+
1436
+ await act(async () => {
1437
+ form.dispatchEvent(submitEvent);
1438
+ });
1439
+
1440
+ // preventDefault should have been called by the wrapper
1441
+ expect(preventDefaultSpy).toHaveBeenCalled();
1442
+ });
1443
+
1444
+ it("should work when onSubmit is called without an event (programmatic)", async () => {
1445
+ const submitHandler = vi.fn();
1446
+
1447
+ // Custom layout that calls onSubmit programmatically (no event)
1448
+ function CustomLayout({ children, onSubmit }: LayoutProps) {
1449
+ return (
1450
+ <div>
1451
+ {children}
1452
+ <button
1453
+ type="button"
1454
+ onClick={() => onSubmit()}
1455
+ data-testid="submit-btn"
1456
+ >
1457
+ Submit
1458
+ </button>
1459
+ </div>
1460
+ );
1461
+ }
1462
+
1463
+ const spec = createTestSpec({
1464
+ fields: {
1465
+ name: { type: "text", label: "Name" },
1466
+ },
1467
+ });
1468
+
1469
+ render(
1470
+ <FormRenderer
1471
+ spec={spec}
1472
+ components={createTestComponentMap()}
1473
+ layout={CustomLayout}
1474
+ onSubmit={submitHandler}
1475
+ initialData={{ name: "John" }}
1476
+ />,
1477
+ );
1478
+
1479
+ await act(async () => {
1480
+ screen.getByTestId("submit-btn").click();
1481
+ });
1482
+
1483
+ await waitFor(() => {
1484
+ expect(submitHandler).toHaveBeenCalledWith({ name: "John" });
1485
+ });
1486
+ });
1487
+
1488
+ it("LayoutProps.onSubmit accepts an optional event parameter", () => {
1489
+ // Type-level test: verify the signature compiles
1490
+ const _layout: React.FC<LayoutProps> = ({ onSubmit }) => {
1491
+ return (
1492
+ <form onSubmit={onSubmit}>
1493
+ <button onClick={() => onSubmit()}>Submit</button>
1494
+ </form>
1495
+ );
1496
+ };
1497
+ expect(_layout).toBeDefined();
1498
+ });
1499
+ });
1500
+
1501
+ // ============================================================================
1502
+ // visibleErrors
1503
+ // ============================================================================
1504
+
1505
+ describe("visibleErrors", () => {
1506
+ it("FormRenderer passes visibleErrors to components", () => {
1507
+ const spec = createTestSpec({
1508
+ fields: {
1509
+ name: { type: "text", label: "Name", required: true },
1510
+ },
1511
+ });
1512
+
1513
+ let capturedVisibleErrors: unknown[] | undefined;
1514
+
1515
+ const components = {
1516
+ ...createTestComponentMap(),
1517
+ text: ({ field: props }: { field: { visibleErrors?: unknown[] } }) => {
1518
+ capturedVisibleErrors = props.visibleErrors;
1519
+ return <div data-testid="text-field">text</div>;
1520
+ },
1521
+ };
1522
+
1523
+ render(
1524
+ <FormRenderer
1525
+ spec={spec}
1526
+ components={components as ReturnType<typeof createTestComponentMap>}
1527
+ validateOn="blur"
1528
+ />,
1529
+ );
1530
+
1531
+ // visibleErrors should be an empty array (field not touched, validateOn=blur)
1532
+ expect(capturedVisibleErrors).toEqual([]);
1533
+ });
1534
+ });
1389
1535
  });
@@ -915,4 +915,247 @@ describe("canProceed", () => {
915
915
  expect(result.current.wizard?.currentPage?.id).toBe("page1");
916
916
  });
917
917
  });
918
+
919
+ describe("handleNext - safe wizard navigation", () => {
920
+ it("handleNext advances page but does NOT call onSubmit", async () => {
921
+ const submitHandler = vi.fn();
922
+
923
+ const spec = createTestSpec({
924
+ fields: {
925
+ name: { type: "text", label: "Name" },
926
+ email: { type: "email", label: "Email" },
927
+ phone: { type: "text", label: "Phone" },
928
+ },
929
+ pages: [
930
+ { id: "page1", title: "Page 1", fields: ["name"] },
931
+ { id: "page2", title: "Page 2", fields: ["email"] },
932
+ { id: "page3", title: "Page 3", fields: ["phone"] },
933
+ ],
934
+ });
935
+
936
+ const { result } = renderHook(() =>
937
+ useForma({
938
+ spec,
939
+ initialData: {
940
+ name: "John",
941
+ email: "john@test.com",
942
+ phone: "123",
943
+ },
944
+ onSubmit: submitHandler,
945
+ }),
946
+ );
947
+
948
+ // On page 1
949
+ expect(result.current.wizard?.currentPageIndex).toBe(0);
950
+ expect(result.current.wizard?.isLastPage).toBe(false);
951
+
952
+ // Use handleNext from page 1 → page 2
953
+ act(() => {
954
+ result.current.wizard?.handleNext();
955
+ });
956
+
957
+ expect(result.current.wizard?.currentPageIndex).toBe(1);
958
+ expect(submitHandler).not.toHaveBeenCalled();
959
+
960
+ // Use handleNext from page 2 → page 3 (last page)
961
+ act(() => {
962
+ result.current.wizard?.handleNext();
963
+ });
964
+
965
+ expect(result.current.wizard?.currentPageIndex).toBe(2);
966
+ expect(result.current.wizard?.isLastPage).toBe(true);
967
+ // Critically: onSubmit was NOT called
968
+ expect(submitHandler).not.toHaveBeenCalled();
969
+ });
970
+
971
+ it("handleNext does nothing on the last page", () => {
972
+ const spec = createTestSpec({
973
+ fields: {
974
+ name: { type: "text", label: "Name" },
975
+ email: { type: "email", label: "Email" },
976
+ },
977
+ pages: [
978
+ { id: "page1", title: "Page 1", fields: ["name"] },
979
+ { id: "page2", title: "Page 2", fields: ["email"] },
980
+ ],
981
+ });
982
+
983
+ const { result } = renderHook(() => useForma({ spec }));
984
+
985
+ // Navigate to last page
986
+ act(() => {
987
+ result.current.wizard?.nextPage();
988
+ });
989
+ expect(result.current.wizard?.isLastPage).toBe(true);
990
+
991
+ // handleNext on last page — should stay on last page
992
+ act(() => {
993
+ result.current.wizard?.handleNext();
994
+ });
995
+ expect(result.current.wizard?.currentPageIndex).toBe(1);
996
+ expect(result.current.wizard?.isLastPage).toBe(true);
997
+ });
998
+
999
+ it("nextPage() from second-to-last page does not trigger submitForm", () => {
1000
+ const submitHandler = vi.fn();
1001
+
1002
+ const spec = createTestSpec({
1003
+ fields: {
1004
+ name: { type: "text", label: "Name" },
1005
+ email: { type: "email", label: "Email" },
1006
+ phone: { type: "text", label: "Phone" },
1007
+ },
1008
+ pages: [
1009
+ { id: "page1", title: "Page 1", fields: ["name"] },
1010
+ { id: "page2", title: "Page 2", fields: ["email"] },
1011
+ { id: "page3", title: "Page 3", fields: ["phone"] },
1012
+ ],
1013
+ });
1014
+
1015
+ const { result } = renderHook(() =>
1016
+ useForma({
1017
+ spec,
1018
+ initialData: { name: "John", email: "test@test.com" },
1019
+ onSubmit: submitHandler,
1020
+ }),
1021
+ );
1022
+
1023
+ // Navigate to page 2 (second-to-last)
1024
+ act(() => {
1025
+ result.current.wizard?.nextPage();
1026
+ });
1027
+ expect(result.current.wizard?.currentPageIndex).toBe(1);
1028
+
1029
+ // Navigate from page 2 → page 3 (last)
1030
+ act(() => {
1031
+ result.current.wizard?.nextPage();
1032
+ });
1033
+
1034
+ // Should be on last page but NOT have submitted
1035
+ expect(result.current.wizard?.currentPageIndex).toBe(2);
1036
+ expect(result.current.wizard?.isLastPage).toBe(true);
1037
+ expect(submitHandler).not.toHaveBeenCalled();
1038
+ });
1039
+ });
1040
+
1041
+ describe("untouched required fields", () => {
1042
+ it("canProceed is false when required fields have no initialData", () => {
1043
+ const spec = createTestSpec({
1044
+ fields: {
1045
+ name: { type: "text", label: "Name", required: true },
1046
+ email: { type: "email", label: "Email", required: true },
1047
+ },
1048
+ pages: [{ id: "page1", title: "Page 1", fields: ["name", "email"] }],
1049
+ });
1050
+
1051
+ // No initialData at all
1052
+ const { result } = renderHook(() => useForma({ spec }));
1053
+
1054
+ // canProceed should be false immediately — fields are untouched and empty
1055
+ expect(result.current.wizard?.canProceed).toBe(false);
1056
+ expect(result.current.touched.name).toBeUndefined();
1057
+ expect(result.current.touched.email).toBeUndefined();
1058
+ });
1059
+
1060
+ it("canProceed is false with explicit empty initialData", () => {
1061
+ const spec = createTestSpec({
1062
+ fields: {
1063
+ name: { type: "text", label: "Name", required: true },
1064
+ email: { type: "email", label: "Email", required: true },
1065
+ },
1066
+ pages: [{ id: "page1", title: "Page 1", fields: ["name", "email"] }],
1067
+ });
1068
+
1069
+ const { result } = renderHook(() => useForma({ spec, initialData: {} }));
1070
+
1071
+ expect(result.current.wizard?.canProceed).toBe(false);
1072
+ });
1073
+
1074
+ it("canProceed becomes true when user fills all required fields", () => {
1075
+ const spec = createTestSpec({
1076
+ fields: {
1077
+ name: { type: "text", label: "Name", required: true },
1078
+ email: { type: "email", label: "Email", required: true },
1079
+ },
1080
+ pages: [{ id: "page1", title: "Page 1", fields: ["name", "email"] }],
1081
+ });
1082
+
1083
+ const { result } = renderHook(() => useForma({ spec }));
1084
+
1085
+ expect(result.current.wizard?.canProceed).toBe(false);
1086
+
1087
+ act(() => {
1088
+ result.current.setFieldValue("name", "John");
1089
+ });
1090
+ // Still false — email is empty
1091
+ expect(result.current.wizard?.canProceed).toBe(false);
1092
+
1093
+ act(() => {
1094
+ result.current.setFieldValue("email", "john@example.com");
1095
+ });
1096
+ expect(result.current.wizard?.canProceed).toBe(true);
1097
+ });
1098
+
1099
+ it("canProceed is false for required number field with undefined value", () => {
1100
+ const spec = createTestSpec({
1101
+ fields: {
1102
+ age: { type: "number", label: "Age", required: true },
1103
+ },
1104
+ pages: [{ id: "page1", title: "Page 1", fields: ["age"] }],
1105
+ });
1106
+
1107
+ const { result } = renderHook(() => useForma({ spec }));
1108
+
1109
+ expect(result.current.wizard?.canProceed).toBe(false);
1110
+ });
1111
+
1112
+ it("canProceed is false for required select field with no selection", () => {
1113
+ const spec = createTestSpec({
1114
+ fields: {
1115
+ country: {
1116
+ type: "select",
1117
+ label: "Country",
1118
+ required: true,
1119
+ options: [
1120
+ { label: "USA", value: "us" },
1121
+ { label: "Canada", value: "ca" },
1122
+ ],
1123
+ },
1124
+ },
1125
+ pages: [{ id: "page1", title: "Page 1", fields: ["country"] }],
1126
+ });
1127
+
1128
+ const { result } = renderHook(() => useForma({ spec }));
1129
+
1130
+ expect(result.current.wizard?.canProceed).toBe(false);
1131
+ });
1132
+
1133
+ it("touchCurrentPageFields then canProceed still works as expected", () => {
1134
+ const spec = createTestSpec({
1135
+ fields: {
1136
+ name: { type: "text", label: "Name", required: true },
1137
+ },
1138
+ pages: [{ id: "page1", title: "Page 1", fields: ["name"] }],
1139
+ });
1140
+
1141
+ const { result } = renderHook(() =>
1142
+ useForma({ spec, validateOn: "blur" }),
1143
+ );
1144
+
1145
+ // canProceed is false before touching
1146
+ expect(result.current.wizard?.canProceed).toBe(false);
1147
+
1148
+ // Touch all fields — canProceed should still be false (empty field)
1149
+ act(() => {
1150
+ result.current.wizard?.touchCurrentPageFields();
1151
+ });
1152
+ expect(result.current.wizard?.canProceed).toBe(false);
1153
+
1154
+ // Fill the field
1155
+ act(() => {
1156
+ result.current.setFieldValue("name", "John");
1157
+ });
1158
+ expect(result.current.wizard?.canProceed).toBe(true);
1159
+ });
1160
+ });
918
1161
  });
@@ -37,9 +37,15 @@ describe("FormaEventEmitter", () => {
37
37
 
38
38
  it("should fire multiple listeners in registration order", () => {
39
39
  const calls: number[] = [];
40
- emitter.on("fieldChanged", () => { calls.push(1); });
41
- emitter.on("fieldChanged", () => { calls.push(2); });
42
- emitter.on("fieldChanged", () => { calls.push(3); });
40
+ emitter.on("fieldChanged", () => {
41
+ calls.push(1);
42
+ });
43
+ emitter.on("fieldChanged", () => {
44
+ calls.push(2);
45
+ });
46
+ emitter.on("fieldChanged", () => {
47
+ calls.push(3);
48
+ });
43
49
 
44
50
  emitter.fire("fieldChanged", {
45
51
  path: "x",
@@ -582,8 +588,12 @@ describe("formReset event", () => {
582
588
  spec,
583
589
  initialData: { name: "initial" },
584
590
  on: {
585
- fieldChanged: () => { events.push("fieldChanged"); },
586
- formReset: () => { events.push("formReset"); },
591
+ fieldChanged: () => {
592
+ events.push("fieldChanged");
593
+ },
594
+ formReset: () => {
595
+ events.push("formReset");
596
+ },
587
597
  },
588
598
  }),
589
599
  );