@fogpipe/forma-react 0.17.0 → 0.18.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.
Files changed (46) hide show
  1. package/README.md +111 -26
  2. package/dist/FormRenderer-D_ZVK44t.d.ts +558 -0
  3. package/dist/chunk-5K4QITFH.js +1276 -0
  4. package/dist/chunk-5K4QITFH.js.map +1 -0
  5. package/dist/defaults/index.d.ts +56 -0
  6. package/dist/defaults/index.js +895 -0
  7. package/dist/defaults/index.js.map +1 -0
  8. package/dist/defaults/styles/forma-defaults.css +696 -0
  9. package/dist/index.d.ts +13 -549
  10. package/dist/index.js +34 -1273
  11. package/dist/index.js.map +1 -1
  12. package/package.json +17 -3
  13. package/src/FieldRenderer.tsx +12 -4
  14. package/src/FormRenderer.tsx +26 -9
  15. package/src/__tests__/FieldRenderer.test.tsx +5 -1
  16. package/src/__tests__/FormRenderer.test.tsx +146 -0
  17. package/src/__tests__/canProceed.test.ts +243 -0
  18. package/src/__tests__/defaults/components.test.tsx +818 -0
  19. package/src/__tests__/defaults/integration.test.tsx +494 -0
  20. package/src/__tests__/defaults/layout.test.tsx +298 -0
  21. package/src/__tests__/events.test.ts +15 -5
  22. package/src/__tests__/useForma.test.ts +108 -5
  23. package/src/defaults/DefaultFormRenderer.tsx +43 -0
  24. package/src/defaults/componentMap.ts +45 -0
  25. package/src/defaults/components/ArrayField.tsx +183 -0
  26. package/src/defaults/components/BooleanInput.tsx +32 -0
  27. package/src/defaults/components/ComputedDisplay.tsx +26 -0
  28. package/src/defaults/components/DateInput.tsx +59 -0
  29. package/src/defaults/components/DisplayField.tsx +15 -0
  30. package/src/defaults/components/FallbackField.tsx +35 -0
  31. package/src/defaults/components/MatrixField.tsx +98 -0
  32. package/src/defaults/components/MultiSelectInput.tsx +51 -0
  33. package/src/defaults/components/NumberInput.tsx +73 -0
  34. package/src/defaults/components/ObjectField.tsx +22 -0
  35. package/src/defaults/components/SelectInput.tsx +44 -0
  36. package/src/defaults/components/TextInput.tsx +48 -0
  37. package/src/defaults/components/TextareaInput.tsx +46 -0
  38. package/src/defaults/index.ts +33 -0
  39. package/src/defaults/layout/FieldWrapper.tsx +83 -0
  40. package/src/defaults/layout/FormLayout.tsx +34 -0
  41. package/src/defaults/layout/PageWrapper.tsx +18 -0
  42. package/src/defaults/layout/WizardLayout.tsx +130 -0
  43. package/src/defaults/styles/forma-defaults.css +696 -0
  44. package/src/events.ts +4 -1
  45. package/src/types.ts +16 -4
  46. package/src/useForma.ts +48 -34
@@ -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
  });