@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.
- package/README.md +111 -26
- package/dist/FormRenderer-D_ZVK44t.d.ts +558 -0
- package/dist/chunk-5K4QITFH.js +1276 -0
- package/dist/chunk-5K4QITFH.js.map +1 -0
- package/dist/defaults/index.d.ts +56 -0
- package/dist/defaults/index.js +895 -0
- package/dist/defaults/index.js.map +1 -0
- package/dist/defaults/styles/forma-defaults.css +696 -0
- package/dist/index.d.ts +13 -549
- package/dist/index.js +34 -1273
- package/dist/index.js.map +1 -1
- package/package.json +17 -3
- 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__/defaults/components.test.tsx +818 -0
- package/src/__tests__/defaults/integration.test.tsx +494 -0
- package/src/__tests__/defaults/layout.test.tsx +298 -0
- package/src/__tests__/events.test.ts +15 -5
- package/src/__tests__/useForma.test.ts +108 -5
- package/src/defaults/DefaultFormRenderer.tsx +43 -0
- package/src/defaults/componentMap.ts +45 -0
- package/src/defaults/components/ArrayField.tsx +183 -0
- package/src/defaults/components/BooleanInput.tsx +32 -0
- package/src/defaults/components/ComputedDisplay.tsx +26 -0
- package/src/defaults/components/DateInput.tsx +59 -0
- package/src/defaults/components/DisplayField.tsx +15 -0
- package/src/defaults/components/FallbackField.tsx +35 -0
- package/src/defaults/components/MatrixField.tsx +98 -0
- package/src/defaults/components/MultiSelectInput.tsx +51 -0
- package/src/defaults/components/NumberInput.tsx +73 -0
- package/src/defaults/components/ObjectField.tsx +22 -0
- package/src/defaults/components/SelectInput.tsx +44 -0
- package/src/defaults/components/TextInput.tsx +48 -0
- package/src/defaults/components/TextareaInput.tsx +46 -0
- package/src/defaults/index.ts +33 -0
- package/src/defaults/layout/FieldWrapper.tsx +83 -0
- package/src/defaults/layout/FormLayout.tsx +34 -0
- package/src/defaults/layout/PageWrapper.tsx +18 -0
- package/src/defaults/layout/WizardLayout.tsx +130 -0
- package/src/defaults/styles/forma-defaults.css +696 -0
- package/src/events.ts +4 -1
- package/src/types.ts +16 -4
- 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
|
});
|