@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/src/FieldRenderer.tsx
CHANGED
|
@@ -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<
|
|
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",
|
package/src/FormRenderer.tsx
CHANGED
|
@@ -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<
|
|
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={
|
|
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
|
-
{
|
|
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", () => {
|
|
41
|
-
|
|
42
|
-
|
|
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: () => {
|
|
586
|
-
|
|
591
|
+
fieldChanged: () => {
|
|
592
|
+
events.push("fieldChanged");
|
|
593
|
+
},
|
|
594
|
+
formReset: () => {
|
|
595
|
+
events.push("formReset");
|
|
596
|
+
},
|
|
587
597
|
},
|
|
588
598
|
}),
|
|
589
599
|
);
|