@fogpipe/forma-react 0.16.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.
@@ -24,6 +24,7 @@ import type {
24
24
  ArrayFieldProps,
25
25
  ArrayHelpers,
26
26
  DisplayFieldProps,
27
+ MatrixFieldProps,
27
28
  } from "./types.js";
28
29
 
29
30
  /**
@@ -131,6 +132,9 @@ export function FieldRenderer({
131
132
 
132
133
  const errors = forma.errors.filter((e) => e.field === fieldPath);
133
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 : [];
134
138
  const required = forma.required[fieldPath] ?? false;
135
139
  const disabled = forma.enabled[fieldPath] === false;
136
140
 
@@ -147,6 +151,7 @@ export function FieldRenderer({
147
151
  required,
148
152
  disabled,
149
153
  errors,
154
+ visibleErrors,
150
155
  onChange: (value: unknown) => forma.setFieldValue(fieldPath, value),
151
156
  onBlur: () => forma.setFieldTouched(fieldPath),
152
157
  // Convenience properties
@@ -175,7 +180,8 @@ export function FieldRenderer({
175
180
  | SelectFieldProps
176
181
  | MultiSelectFieldProps
177
182
  | ArrayFieldProps
178
- | DisplayFieldProps = baseProps;
183
+ | DisplayFieldProps
184
+ | MatrixFieldProps = baseProps;
179
185
 
180
186
  if (fieldType === "number") {
181
187
  const constraints = getNumberConstraints(schemaProperty);
@@ -313,17 +319,41 @@ export function FieldRenderer({
313
319
  minItems,
314
320
  maxItems,
315
321
  } as ArrayFieldProps;
322
+ } else if (fieldType === "matrix" && fieldDef.type === "matrix") {
323
+ // Matrix fields — compute visible rows from visibility engine
324
+ const matrixValue =
325
+ (baseProps.value as Record<
326
+ string,
327
+ string | number | string[] | number[]
328
+ > | null) ?? null;
329
+ const rows = fieldDef.rows.map((row) => ({
330
+ id: row.id,
331
+ label: row.label,
332
+ visible: forma.visibility[`${fieldPath}.${row.id}`] !== false,
333
+ }));
334
+ fieldProps = {
335
+ ...baseProps,
336
+ fieldType: "matrix",
337
+ value: matrixValue,
338
+ onChange: baseProps.onChange as (
339
+ value: Record<string, string | number | string[] | number[]>,
340
+ ) => void,
341
+ rows,
342
+ columns: fieldDef.columns,
343
+ multiSelect: fieldDef.multiSelect ?? false,
344
+ } as MatrixFieldProps;
316
345
  } else if (fieldType === "display" && fieldDef.type === "display") {
317
346
  // Display fields (read-only presentation content)
318
347
  const sourceValue = fieldDef.source
319
348
  ? (forma.data[fieldDef.source] ?? forma.computed[fieldDef.source])
320
349
  : undefined;
321
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
322
350
  const {
323
- onChange: _onChange,
324
- value: _value,
351
+ onChange: _onChange, // omit from display props
352
+ value: _value, // omit from display props
325
353
  ...displayBaseProps
326
354
  } = baseProps;
355
+ void _onChange;
356
+ void _value;
327
357
  fieldProps = {
328
358
  ...displayBaseProps,
329
359
  fieldType: "display",
@@ -34,6 +34,7 @@ import type {
34
34
  ArrayFieldProps,
35
35
  ArrayHelpers,
36
36
  DisplayFieldProps,
37
+ MatrixFieldProps,
37
38
  } from "./types.js";
38
39
 
39
40
  /**
@@ -85,12 +86,7 @@ export interface FormRendererHandle {
85
86
  */
86
87
  function DefaultLayout({ children, onSubmit, isSubmitting }: LayoutProps) {
87
88
  return (
88
- <form
89
- onSubmit={(e) => {
90
- e.preventDefault();
91
- onSubmit();
92
- }}
93
- >
89
+ <form onSubmit={onSubmit}>
94
90
  {children}
95
91
  <button type="submit" disabled={isSubmitting}>
96
92
  {isSubmitting ? "Submitting..." : "Submit"}
@@ -241,7 +237,7 @@ export const FormRenderer = forwardRef<FormRendererHandle, FormRendererProps>(
241
237
  layout: Layout = DefaultLayout,
242
238
  fieldWrapper: FieldWrapper = DefaultFieldWrapper,
243
239
  pageWrapper: PageWrapper = DefaultPageWrapper,
244
- validateOn,
240
+ validateOn = "blur",
245
241
  } = props;
246
242
 
247
243
  const forma = useForma({
@@ -298,6 +294,7 @@ export const FormRenderer = forwardRef<FormRendererHandle, FormRendererProps>(
298
294
  optionsVisibility: formaOptionsVisibility,
299
295
  touched: formaTouched,
300
296
  errors: formaErrors,
297
+ isSubmitted: formaIsSubmitted,
301
298
  setFieldValue,
302
299
  setFieldTouched,
303
300
  getArrayHelpers,
@@ -341,6 +338,11 @@ export const FormRenderer = forwardRef<FormRendererHandle, FormRendererProps>(
341
338
 
342
339
  const errors = formaErrors.filter((e) => e.field === fieldPath);
343
340
  const touched = formaTouched[fieldPath] ?? false;
341
+ const showErrors =
342
+ validateOn === "change" ||
343
+ (validateOn === "blur" && touched) ||
344
+ formaIsSubmitted;
345
+ const visibleErrors = showErrors ? errors : [];
344
346
  const required = formaRequired[fieldPath] ?? false;
345
347
  const disabled = formaEnabled[fieldPath] === false;
346
348
 
@@ -366,6 +368,7 @@ export const FormRenderer = forwardRef<FormRendererHandle, FormRendererProps>(
366
368
  required,
367
369
  disabled,
368
370
  errors,
371
+ visibleErrors,
369
372
  onChange: (value: unknown) => setFieldValue(fieldPath, value),
370
373
  onBlur: () => setFieldTouched(fieldPath),
371
374
  // Convenience properties
@@ -392,7 +395,8 @@ export const FormRenderer = forwardRef<FormRendererHandle, FormRendererProps>(
392
395
  | NumberFieldProps
393
396
  | SelectFieldProps
394
397
  | ArrayFieldProps
395
- | DisplayFieldProps = baseProps;
398
+ | DisplayFieldProps
399
+ | MatrixFieldProps = baseProps;
396
400
 
397
401
  if (fieldType === "number" || fieldType === "integer") {
398
402
  const constraints = getNumberConstraints(schemaProperty);
@@ -483,6 +487,28 @@ export const FormRenderer = forwardRef<FormRendererHandle, FormRendererProps>(
483
487
  minItems,
484
488
  maxItems,
485
489
  } as ArrayFieldProps;
490
+ } else if (fieldType === "matrix" && fieldDef.type === "matrix") {
491
+ const matrixValue =
492
+ (baseProps.value as Record<
493
+ string,
494
+ string | number | string[] | number[]
495
+ > | null) ?? null;
496
+ const rows = fieldDef.rows.map((row) => ({
497
+ id: row.id,
498
+ label: row.label,
499
+ visible: formaVisibility[`${fieldPath}.${row.id}`] !== false,
500
+ }));
501
+ fieldProps = {
502
+ ...baseProps,
503
+ fieldType: "matrix",
504
+ value: matrixValue,
505
+ onChange: baseProps.onChange as (
506
+ value: Record<string, string | number | string[] | number[]>,
507
+ ) => void,
508
+ rows,
509
+ columns: fieldDef.columns,
510
+ multiSelect: fieldDef.multiSelect ?? false,
511
+ } as MatrixFieldProps;
486
512
  } else if (fieldType === "display" && fieldDef.type === "display") {
487
513
  // Display fields (read-only presentation content)
488
514
  // Resolve source value if the display field has a source property
@@ -553,6 +579,8 @@ export const FormRenderer = forwardRef<FormRendererHandle, FormRendererProps>(
553
579
  formaOptionsVisibility,
554
580
  formaTouched,
555
581
  formaErrors,
582
+ formaIsSubmitted,
583
+ validateOn,
556
584
  setFieldValue,
557
585
  setFieldTouched,
558
586
  getArrayHelpers,
@@ -586,10 +614,20 @@ export const FormRenderer = forwardRef<FormRendererHandle, FormRendererProps>(
586
614
  return <>{renderedFields}</>;
587
615
  }, [spec.pages, forma.wizard, PageWrapper, renderedFields]);
588
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
+
589
627
  return (
590
628
  <FormaContext.Provider value={forma}>
591
629
  <Layout
592
- onSubmit={forma.submitForm}
630
+ onSubmit={handleSubmit}
593
631
  isSubmitting={forma.isSubmitting}
594
632
  isValid={forma.isValid}
595
633
  >
@@ -22,6 +22,7 @@ import type {
22
22
  ArrayComponentProps,
23
23
  ComponentMap,
24
24
  LayoutProps,
25
+ MatrixComponentProps,
25
26
  NumberComponentProps,
26
27
  IntegerComponentProps,
27
28
  } from "../types.js";
@@ -714,4 +715,189 @@ describe("FieldRenderer", () => {
714
715
  });
715
716
  });
716
717
  });
718
+
719
+ // ============================================================================
720
+ // Matrix Field Rendering
721
+ // ============================================================================
722
+
723
+ describe("matrix field rendering", () => {
724
+ it("should pass matrix props to matrix component", () => {
725
+ let capturedProps: MatrixComponentProps["field"] | null = null;
726
+
727
+ const spec: Forma = {
728
+ version: "1.0",
729
+ meta: { id: "test", title: "Test" },
730
+ schema: {
731
+ type: "object",
732
+ properties: {
733
+ rating: {
734
+ type: "object",
735
+ properties: {
736
+ speed: { type: "integer", enum: [1, 2, 3] },
737
+ quality: { type: "integer", enum: [1, 2, 3] },
738
+ },
739
+ },
740
+ },
741
+ },
742
+ fields: {
743
+ rating: {
744
+ type: "matrix",
745
+ label: "Rating",
746
+ rows: [
747
+ { id: "speed", label: "Speed" },
748
+ { id: "quality", label: "Quality" },
749
+ ],
750
+ columns: [
751
+ { value: 1, label: "Poor" },
752
+ { value: 2, label: "OK" },
753
+ { value: 3, label: "Great" },
754
+ ],
755
+ multiSelect: false,
756
+ },
757
+ },
758
+ fieldOrder: ["rating"],
759
+ };
760
+
761
+ const components: ComponentMap = {
762
+ ...createTestComponentMap(),
763
+ matrix: ({ field: props }: MatrixComponentProps) => {
764
+ capturedProps = props;
765
+ return <div data-testid="matrix-field">matrix</div>;
766
+ },
767
+ };
768
+
769
+ render(
770
+ <FormRenderer
771
+ spec={spec}
772
+ initialData={{ rating: { speed: 2, quality: 3 } }}
773
+ components={components}
774
+ />,
775
+ );
776
+
777
+ expect(capturedProps).not.toBeNull();
778
+ expect(capturedProps!.fieldType).toBe("matrix");
779
+ expect(capturedProps!.value).toEqual({ speed: 2, quality: 3 });
780
+ expect(capturedProps!.rows).toEqual([
781
+ { id: "speed", label: "Speed", visible: true },
782
+ { id: "quality", label: "Quality", visible: true },
783
+ ]);
784
+ expect(capturedProps!.columns).toEqual([
785
+ { value: 1, label: "Poor" },
786
+ { value: 2, label: "OK" },
787
+ { value: 3, label: "Great" },
788
+ ]);
789
+ expect(capturedProps!.multiSelect).toBe(false);
790
+ });
791
+
792
+ it("should compute row visibility from visibleWhen expressions", () => {
793
+ let capturedProps: MatrixComponentProps["field"] | null = null;
794
+
795
+ const spec: Forma = {
796
+ version: "1.0",
797
+ meta: { id: "test", title: "Test" },
798
+ schema: {
799
+ type: "object",
800
+ properties: {
801
+ show_quality: { type: "boolean" },
802
+ rating: {
803
+ type: "object",
804
+ properties: {
805
+ speed: { type: "integer", enum: [1, 2, 3] },
806
+ quality: { type: "integer", enum: [1, 2, 3] },
807
+ },
808
+ },
809
+ },
810
+ },
811
+ fields: {
812
+ show_quality: { type: "boolean", label: "Show Quality" },
813
+ rating: {
814
+ type: "matrix",
815
+ label: "Rating",
816
+ rows: [
817
+ { id: "speed", label: "Speed" },
818
+ {
819
+ id: "quality",
820
+ label: "Quality",
821
+ visibleWhen: "show_quality = true",
822
+ },
823
+ ],
824
+ columns: [
825
+ { value: 1, label: "Poor" },
826
+ { value: 2, label: "OK" },
827
+ { value: 3, label: "Great" },
828
+ ],
829
+ },
830
+ },
831
+ fieldOrder: ["show_quality", "rating"],
832
+ };
833
+
834
+ const components: ComponentMap = {
835
+ ...createTestComponentMap(),
836
+ matrix: ({ field: props }: MatrixComponentProps) => {
837
+ capturedProps = props;
838
+ return <div data-testid="matrix-field">matrix</div>;
839
+ },
840
+ };
841
+
842
+ render(
843
+ <FormRenderer
844
+ spec={spec}
845
+ initialData={{ show_quality: false }}
846
+ components={components}
847
+ />,
848
+ );
849
+
850
+ expect(capturedProps).not.toBeNull();
851
+ expect(capturedProps!.rows).toEqual([
852
+ { id: "speed", label: "Speed", visible: true },
853
+ { id: "quality", label: "Quality", visible: false },
854
+ ]);
855
+ });
856
+
857
+ it("should default multiSelect to false when not specified", () => {
858
+ let capturedProps: MatrixComponentProps["field"] | null = null;
859
+
860
+ const spec: Forma = {
861
+ version: "1.0",
862
+ meta: { id: "test", title: "Test" },
863
+ schema: {
864
+ type: "object",
865
+ properties: {
866
+ rating: {
867
+ type: "object",
868
+ properties: {
869
+ a: { type: "integer", enum: [1, 2] },
870
+ },
871
+ },
872
+ },
873
+ },
874
+ fields: {
875
+ rating: {
876
+ type: "matrix",
877
+ label: "Rating",
878
+ rows: [{ id: "a", label: "A" }],
879
+ columns: [
880
+ { value: 1, label: "1" },
881
+ { value: 2, label: "2" },
882
+ ],
883
+ // multiSelect not set
884
+ },
885
+ },
886
+ fieldOrder: ["rating"],
887
+ };
888
+
889
+ const components: ComponentMap = {
890
+ ...createTestComponentMap(),
891
+ matrix: ({ field: props }: MatrixComponentProps) => {
892
+ capturedProps = props;
893
+ return <div data-testid="matrix-field">matrix</div>;
894
+ },
895
+ };
896
+
897
+ render(<FormRenderer spec={spec} components={components} />);
898
+
899
+ expect(capturedProps).not.toBeNull();
900
+ expect(capturedProps!.multiSelect).toBe(false);
901
+ });
902
+ });
717
903
  });
@@ -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
  });