@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.
@@ -47,6 +47,7 @@ export function createTestSpec(
47
47
  enum: (fieldOptions as Array<{ value: string }>).map((o) => o.value),
48
48
  ...rest,
49
49
  };
50
+ if (required) schemaRequired.push(name);
50
51
  continue;
51
52
  }
52
53
  }
@@ -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
- const currentPage = visiblePages[state.currentPage] || null;
392
- const hasNextPage = state.currentPage < visiblePages.length - 1;
393
- const hasPreviousPage = state.currentPage > 0;
394
- const isLastPage = state.currentPage === visiblePages.length - 1;
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: state.currentPage,
408
+ currentPageIndex: clampedPageIndex,
399
409
  currentPage,
400
- goToPage: (index: number) => dispatch({ type: "SET_PAGE", page: index }),
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: state.currentPage + 1 });
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: state.currentPage - 1 });
422
+ dispatch({ type: "SET_PAGE", page: clampedPageIndex - 1 });
409
423
  }
410
424
  },
411
425
  hasNextPage,
412
426
  hasPreviousPage,
413
- canProceed: true, // TODO: Validate current page
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 => {