@fogpipe/forma-react 0.8.1 → 0.8.2
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/dist/index.js +27 -11
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/canProceed.test.ts +874 -0
- package/src/__tests__/test-utils.tsx +1 -0
- package/src/__tests__/useForma.test.ts +111 -0
- package/src/useForma.ts +38 -10
|
@@ -571,6 +571,115 @@ describe("useForma", () => {
|
|
|
571
571
|
|
|
572
572
|
expect(result.current.isSubmitted).toBe(false);
|
|
573
573
|
});
|
|
574
|
+
|
|
575
|
+
describe("validation debouncing", () => {
|
|
576
|
+
it("should debounce validation updates when validationDebounceMs is set", async () => {
|
|
577
|
+
vi.useFakeTimers();
|
|
578
|
+
|
|
579
|
+
const spec = createTestSpec({
|
|
580
|
+
fields: {
|
|
581
|
+
name: { type: "text", required: true },
|
|
582
|
+
},
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
const { result } = renderHook(() =>
|
|
586
|
+
useForma({ spec, validationDebounceMs: 100 })
|
|
587
|
+
);
|
|
588
|
+
|
|
589
|
+
// Initially invalid (required field empty)
|
|
590
|
+
expect(result.current.isValid).toBe(false);
|
|
591
|
+
|
|
592
|
+
// Fill the field - validation should be debounced
|
|
593
|
+
act(() => {
|
|
594
|
+
result.current.setFieldValue("name", "John");
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
// Immediately after change, debounced validation still shows old state
|
|
598
|
+
// (depending on implementation, this might already be updated)
|
|
599
|
+
|
|
600
|
+
// Fast-forward past debounce timeout
|
|
601
|
+
await act(async () => {
|
|
602
|
+
vi.advanceTimersByTime(150);
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
// Now validation should reflect the new state
|
|
606
|
+
expect(result.current.isValid).toBe(true);
|
|
607
|
+
|
|
608
|
+
vi.useRealTimers();
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
it("should use immediate validation on submit even when debouncing", async () => {
|
|
612
|
+
vi.useFakeTimers();
|
|
613
|
+
|
|
614
|
+
const onSubmit = vi.fn();
|
|
615
|
+
const spec = createTestSpec({
|
|
616
|
+
fields: {
|
|
617
|
+
name: { type: "text", required: true },
|
|
618
|
+
},
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
const { result } = renderHook(() =>
|
|
622
|
+
useForma({ spec, validationDebounceMs: 500, onSubmit })
|
|
623
|
+
);
|
|
624
|
+
|
|
625
|
+
// Fill the field
|
|
626
|
+
act(() => {
|
|
627
|
+
result.current.setFieldValue("name", "John");
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
// Submit immediately without waiting for debounce
|
|
631
|
+
await act(async () => {
|
|
632
|
+
await result.current.submitForm();
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
// onSubmit should be called because immediate validation passes
|
|
636
|
+
expect(onSubmit).toHaveBeenCalledWith({ name: "John" });
|
|
637
|
+
|
|
638
|
+
vi.useRealTimers();
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
it("should not call onSubmit when immediate validation fails", async () => {
|
|
642
|
+
const onSubmit = vi.fn();
|
|
643
|
+
const spec = createTestSpec({
|
|
644
|
+
fields: {
|
|
645
|
+
name: { type: "text", required: true },
|
|
646
|
+
},
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
const { result } = renderHook(() =>
|
|
650
|
+
useForma({ spec, validationDebounceMs: 100, onSubmit })
|
|
651
|
+
);
|
|
652
|
+
|
|
653
|
+
// Submit without filling required field
|
|
654
|
+
await act(async () => {
|
|
655
|
+
await result.current.submitForm();
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
// onSubmit should NOT be called
|
|
659
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
it("should work without debouncing (validationDebounceMs: 0)", () => {
|
|
663
|
+
const spec = createTestSpec({
|
|
664
|
+
fields: {
|
|
665
|
+
name: { type: "text", required: true },
|
|
666
|
+
},
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
const { result } = renderHook(() =>
|
|
670
|
+
useForma({ spec, validationDebounceMs: 0 })
|
|
671
|
+
);
|
|
672
|
+
|
|
673
|
+
expect(result.current.isValid).toBe(false);
|
|
674
|
+
|
|
675
|
+
act(() => {
|
|
676
|
+
result.current.setFieldValue("name", "John");
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
// Validation should update immediately
|
|
680
|
+
expect(result.current.isValid).toBe(true);
|
|
681
|
+
});
|
|
682
|
+
});
|
|
574
683
|
});
|
|
575
684
|
|
|
576
685
|
// ============================================================================
|
|
@@ -933,6 +1042,8 @@ describe("useForma", () => {
|
|
|
933
1042
|
const page2After = result.current.wizard?.pages.find((p) => p.id === "page2");
|
|
934
1043
|
expect(page2After?.visible).toBe(true);
|
|
935
1044
|
});
|
|
1045
|
+
|
|
1046
|
+
// Note: Comprehensive canProceed tests are in canProceed.test.ts
|
|
936
1047
|
});
|
|
937
1048
|
|
|
938
1049
|
// ============================================================================
|
package/src/useForma.ts
CHANGED
|
@@ -388,29 +388,57 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
388
388
|
|
|
389
389
|
// For navigation, only count visible pages
|
|
390
390
|
const visiblePages = pages.filter((p) => p.visible);
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
const
|
|
394
|
-
const
|
|
391
|
+
|
|
392
|
+
// Clamp currentPage to valid range (handles case where current page becomes hidden)
|
|
393
|
+
const maxPageIndex = Math.max(0, visiblePages.length - 1);
|
|
394
|
+
const clampedPageIndex = Math.min(Math.max(0, state.currentPage), maxPageIndex);
|
|
395
|
+
|
|
396
|
+
// Auto-correct page index if it's out of bounds
|
|
397
|
+
if (clampedPageIndex !== state.currentPage && visiblePages.length > 0) {
|
|
398
|
+
dispatch({ type: "SET_PAGE", page: clampedPageIndex });
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const currentPage = visiblePages[clampedPageIndex] || null;
|
|
402
|
+
const hasNextPage = clampedPageIndex < visiblePages.length - 1;
|
|
403
|
+
const hasPreviousPage = clampedPageIndex > 0;
|
|
404
|
+
const isLastPage = clampedPageIndex === visiblePages.length - 1;
|
|
395
405
|
|
|
396
406
|
return {
|
|
397
407
|
pages,
|
|
398
|
-
currentPageIndex:
|
|
408
|
+
currentPageIndex: clampedPageIndex,
|
|
399
409
|
currentPage,
|
|
400
|
-
goToPage: (index: number) =>
|
|
410
|
+
goToPage: (index: number) => {
|
|
411
|
+
// Clamp to valid range
|
|
412
|
+
const validIndex = Math.min(Math.max(0, index), maxPageIndex);
|
|
413
|
+
dispatch({ type: "SET_PAGE", page: validIndex });
|
|
414
|
+
},
|
|
401
415
|
nextPage: () => {
|
|
402
416
|
if (hasNextPage) {
|
|
403
|
-
dispatch({ type: "SET_PAGE", page:
|
|
417
|
+
dispatch({ type: "SET_PAGE", page: clampedPageIndex + 1 });
|
|
404
418
|
}
|
|
405
419
|
},
|
|
406
420
|
previousPage: () => {
|
|
407
421
|
if (hasPreviousPage) {
|
|
408
|
-
dispatch({ type: "SET_PAGE", page:
|
|
422
|
+
dispatch({ type: "SET_PAGE", page: clampedPageIndex - 1 });
|
|
409
423
|
}
|
|
410
424
|
},
|
|
411
425
|
hasNextPage,
|
|
412
426
|
hasPreviousPage,
|
|
413
|
-
canProceed:
|
|
427
|
+
canProceed: (() => {
|
|
428
|
+
if (!currentPage) return true;
|
|
429
|
+
// Get errors only for visible fields on the current page
|
|
430
|
+
const pageErrors = validation.errors.filter((e) => {
|
|
431
|
+
// Check if field is on current page (including array items like "items[0].name")
|
|
432
|
+
const isOnCurrentPage = currentPage.fields.includes(e.field) ||
|
|
433
|
+
currentPage.fields.some(f => e.field.startsWith(`${f}[`));
|
|
434
|
+
// Only count errors for visible fields
|
|
435
|
+
const isVisible = visibility[e.field] !== false;
|
|
436
|
+
// Only count actual errors, not warnings
|
|
437
|
+
const isError = e.severity === 'error';
|
|
438
|
+
return isOnCurrentPage && isVisible && isError;
|
|
439
|
+
});
|
|
440
|
+
return pageErrors.length === 0;
|
|
441
|
+
})(),
|
|
414
442
|
isLastPage,
|
|
415
443
|
touchCurrentPageFields: () => {
|
|
416
444
|
if (currentPage) {
|
|
@@ -427,7 +455,7 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
427
455
|
return pageErrors.length === 0;
|
|
428
456
|
},
|
|
429
457
|
};
|
|
430
|
-
}, [spec, state.data, state.currentPage, computed, validation]);
|
|
458
|
+
}, [spec, state.data, state.currentPage, computed, validation, visibility]);
|
|
431
459
|
|
|
432
460
|
// Helper to get value at nested path
|
|
433
461
|
const getValueAtPath = useCallback((path: string): unknown => {
|