@fogpipe/forma-react 0.18.0 → 0.19.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.
@@ -18,6 +18,7 @@ import type {
18
18
  ValidationResult,
19
19
  JSONSchemaProperty,
20
20
  SelectOption,
21
+ FormatOptions,
21
22
  } from "@fogpipe/forma-core";
22
23
  import { isAdornableField, isSelectionField } from "@fogpipe/forma-core";
23
24
  import { useForma } from "./useForma.js";
@@ -34,6 +35,7 @@ import type {
34
35
  ArrayFieldProps,
35
36
  ArrayHelpers,
36
37
  DisplayFieldProps,
38
+ ComputedFieldProps,
37
39
  MatrixFieldProps,
38
40
  } from "./types.js";
39
41
 
@@ -64,6 +66,8 @@ export interface FormRendererProps {
64
66
  validateOn?: "change" | "blur" | "submit";
65
67
  /** Current page for controlled wizard */
66
68
  page?: number;
69
+ /** Format options for number/currency/date display (overrides spec.meta.locale/currency) */
70
+ formatOptions?: FormatOptions;
67
71
  }
68
72
 
69
73
  /**
@@ -240,6 +244,13 @@ export const FormRenderer = forwardRef<FormRendererHandle, FormRendererProps>(
240
244
  validateOn = "blur",
241
245
  } = props;
242
246
 
247
+ // Resolve format options: prop override > spec.meta > defaults
248
+ const resolvedFormatOptions = useMemo<FormatOptions>(() => ({
249
+ locale: props.formatOptions?.locale ?? spec.meta.locale,
250
+ currency: props.formatOptions?.currency ?? spec.meta.currency,
251
+ nullDisplay: props.formatOptions?.nullDisplay ?? "—",
252
+ }), [props.formatOptions, spec.meta.locale, spec.meta.currency]);
253
+
243
254
  const forma = useForma({
244
255
  spec,
245
256
  initialData,
@@ -396,6 +407,7 @@ export const FormRenderer = forwardRef<FormRendererHandle, FormRendererProps>(
396
407
  | SelectFieldProps
397
408
  | ArrayFieldProps
398
409
  | DisplayFieldProps
410
+ | ComputedFieldProps
399
411
  | MatrixFieldProps = baseProps;
400
412
 
401
413
  if (fieldType === "number" || fieldType === "integer") {
@@ -515,6 +527,13 @@ export const FormRenderer = forwardRef<FormRendererHandle, FormRendererProps>(
515
527
  const sourceValue = fieldDef.source
516
528
  ? (formaData[fieldDef.source] ?? formaComputed[fieldDef.source])
517
529
  : undefined;
530
+ // Resolve format: display field's own format takes priority,
531
+ // fall back to the source computed field's format
532
+ const format =
533
+ fieldDef.format ??
534
+ (fieldDef.source
535
+ ? spec.computed?.[fieldDef.source]?.format
536
+ : undefined);
518
537
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
519
538
  const {
520
539
  onChange: _onChange,
@@ -526,8 +545,22 @@ export const FormRenderer = forwardRef<FormRendererHandle, FormRendererProps>(
526
545
  fieldType: "display",
527
546
  content: fieldDef.content,
528
547
  sourceValue,
529
- format: fieldDef.format,
548
+ format,
549
+ formatOptions: resolvedFormatOptions,
530
550
  } as DisplayFieldProps;
551
+ } else if (fieldType === "computed" && fieldDef.type === "computed") {
552
+ // Computed fields (read-only calculated values)
553
+ const computedDef = spec.computed?.[fieldPath];
554
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
555
+ const { onChange: _onChangeC, ...computedBaseProps } = baseProps;
556
+ fieldProps = {
557
+ ...computedBaseProps,
558
+ fieldType: "computed",
559
+ value: formaComputed[fieldPath],
560
+ expression: computedDef?.expression ?? "",
561
+ format: computedDef?.format,
562
+ formatOptions: resolvedFormatOptions,
563
+ } as ComputedFieldProps;
531
564
  } else {
532
565
  // Text-based fields
533
566
  fieldProps = {
@@ -581,6 +614,7 @@ export const FormRenderer = forwardRef<FormRendererHandle, FormRendererProps>(
581
614
  formaErrors,
582
615
  formaIsSubmitted,
583
616
  validateOn,
617
+ resolvedFormatOptions,
584
618
  setFieldValue,
585
619
  setFieldTouched,
586
620
  getArrayHelpers,
@@ -671,6 +671,120 @@ describe("ComputedDisplay", () => {
671
671
  );
672
672
  expect(screen.getByText('{"a":1}')).toBeInTheDocument();
673
673
  });
674
+
675
+ it("formats value with currency format", () => {
676
+ render(
677
+ <ComputedDisplay
678
+ field={makeComputedProps({ value: 1234.56, format: "currency" })}
679
+ spec={mockSpec}
680
+ />,
681
+ );
682
+ expect(screen.getByText("$1,234.56")).toBeInTheDocument();
683
+ });
684
+
685
+ it("formats value with percent format", () => {
686
+ render(
687
+ <ComputedDisplay
688
+ field={makeComputedProps({ value: 0.5, format: "percent" })}
689
+ spec={mockSpec}
690
+ />,
691
+ );
692
+ expect(screen.getByText("50%")).toBeInTheDocument();
693
+ });
694
+
695
+ it("formats value with decimal format", () => {
696
+ render(
697
+ <ComputedDisplay
698
+ field={makeComputedProps({ value: 99.999, format: "decimal(2)" })}
699
+ spec={mockSpec}
700
+ />,
701
+ );
702
+ expect(screen.getByText("100.00")).toBeInTheDocument();
703
+ });
704
+
705
+ it("still shows em-dash for null even with format", () => {
706
+ render(
707
+ <ComputedDisplay
708
+ field={makeComputedProps({ value: null, format: "currency" })}
709
+ spec={mockSpec}
710
+ />,
711
+ );
712
+ expect(screen.getByText("\u2014")).toBeInTheDocument();
713
+ });
714
+
715
+ it("still shows em-dash for undefined even with format", () => {
716
+ render(
717
+ <ComputedDisplay
718
+ field={makeComputedProps({ value: undefined, format: "currency" })}
719
+ spec={mockSpec}
720
+ />,
721
+ );
722
+ expect(screen.getByText("\u2014")).toBeInTheDocument();
723
+ });
724
+
725
+ it("renders plain string without format", () => {
726
+ render(
727
+ <ComputedDisplay
728
+ field={makeComputedProps({ value: 42 })}
729
+ spec={mockSpec}
730
+ />,
731
+ );
732
+ expect(screen.getByText("42")).toBeInTheDocument();
733
+ });
734
+
735
+ it("formats zero with currency (does not show em-dash)", () => {
736
+ render(
737
+ <ComputedDisplay
738
+ field={makeComputedProps({ value: 0, format: "currency" })}
739
+ spec={mockSpec}
740
+ />,
741
+ );
742
+ expect(screen.getByText("$0.00")).toBeInTheDocument();
743
+ });
744
+
745
+ it("formats boolean without format as string", () => {
746
+ render(
747
+ <ComputedDisplay
748
+ field={makeComputedProps({ value: false })}
749
+ spec={mockSpec}
750
+ />,
751
+ );
752
+ expect(screen.getByText("false")).toBeInTheDocument();
753
+ });
754
+
755
+ it("formats currency with locale/currency from formatOptions", () => {
756
+ render(
757
+ <ComputedDisplay
758
+ field={makeComputedProps({
759
+ value: 25444.40,
760
+ format: "currency",
761
+ formatOptions: { locale: "sv-SE", currency: "SEK" },
762
+ })}
763
+ spec={mockSpec}
764
+ />,
765
+ );
766
+ const text = screen.getByText((content) =>
767
+ content.includes("25") && content.includes("444") && content.includes("kr"),
768
+ );
769
+ expect(text).toBeInTheDocument();
770
+ });
771
+
772
+ it("formats currency with EUR locale from formatOptions", () => {
773
+ render(
774
+ <ComputedDisplay
775
+ field={makeComputedProps({
776
+ value: 1000,
777
+ format: "currency",
778
+ formatOptions: { locale: "de-DE", currency: "EUR" },
779
+ })}
780
+ spec={mockSpec}
781
+ />,
782
+ );
783
+ const text = screen.getByText((content) =>
784
+ content.includes("1.000") && content.includes("€"),
785
+ );
786
+ expect(text).toBeInTheDocument();
787
+ });
674
788
  });
675
789
 
676
790
  // ============================================================================
@@ -707,6 +821,148 @@ describe("DisplayField", () => {
707
821
  );
708
822
  expect(screen.getByText("Dynamic value")).toBeInTheDocument();
709
823
  });
824
+
825
+ it("formats sourceValue with currency format", () => {
826
+ render(
827
+ <DisplayField
828
+ field={makeDisplayProps({
829
+ sourceValue: 1234.56,
830
+ format: "currency",
831
+ content: "Fallback",
832
+ })}
833
+ spec={mockSpec}
834
+ />,
835
+ );
836
+ expect(screen.getByText("$1,234.56")).toBeInTheDocument();
837
+ });
838
+
839
+ it("formats sourceValue with percent format", () => {
840
+ render(
841
+ <DisplayField
842
+ field={makeDisplayProps({
843
+ sourceValue: 0.75,
844
+ format: "percent",
845
+ content: "Fallback",
846
+ })}
847
+ spec={mockSpec}
848
+ />,
849
+ );
850
+ expect(screen.getByText("75%")).toBeInTheDocument();
851
+ });
852
+
853
+ it("formats sourceValue with decimal format", () => {
854
+ render(
855
+ <DisplayField
856
+ field={makeDisplayProps({
857
+ sourceValue: 123.456,
858
+ format: "decimal(1)",
859
+ content: "Fallback",
860
+ })}
861
+ spec={mockSpec}
862
+ />,
863
+ );
864
+ expect(screen.getByText("123.5")).toBeInTheDocument();
865
+ });
866
+
867
+ it("shows em-dash for null sourceValue with format", () => {
868
+ render(
869
+ <DisplayField
870
+ field={makeDisplayProps({
871
+ sourceValue: null,
872
+ format: "currency",
873
+ content: "Fallback",
874
+ })}
875
+ spec={mockSpec}
876
+ />,
877
+ );
878
+ expect(screen.getByText("\u2014")).toBeInTheDocument();
879
+ });
880
+
881
+ it("shows em-dash for undefined sourceValue with format", () => {
882
+ render(
883
+ <DisplayField
884
+ field={makeDisplayProps({
885
+ sourceValue: undefined,
886
+ format: "currency",
887
+ })}
888
+ spec={mockSpec}
889
+ />,
890
+ );
891
+ // sourceValue is undefined → falls through to content
892
+ expect(screen.getByText("Hello world")).toBeInTheDocument();
893
+ });
894
+
895
+ it("falls back to string for non-numeric sourceValue with currency format", () => {
896
+ render(
897
+ <DisplayField
898
+ field={makeDisplayProps({
899
+ sourceValue: "not a number",
900
+ format: "currency",
901
+ })}
902
+ spec={mockSpec}
903
+ />,
904
+ );
905
+ expect(screen.getByText("not a number")).toBeInTheDocument();
906
+ });
907
+
908
+ it("formats sourceValue without format as plain string", () => {
909
+ render(
910
+ <DisplayField
911
+ field={makeDisplayProps({
912
+ sourceValue: 42,
913
+ })}
914
+ spec={mockSpec}
915
+ />,
916
+ );
917
+ expect(screen.getByText("42")).toBeInTheDocument();
918
+ });
919
+
920
+ it("formats zero sourceValue with currency (does not skip rendering)", () => {
921
+ render(
922
+ <DisplayField
923
+ field={makeDisplayProps({
924
+ sourceValue: 0,
925
+ format: "currency",
926
+ })}
927
+ spec={mockSpec}
928
+ />,
929
+ );
930
+ expect(screen.getByText("$0.00")).toBeInTheDocument();
931
+ });
932
+
933
+ it("formats currency with locale/currency from formatOptions", () => {
934
+ render(
935
+ <DisplayField
936
+ field={makeDisplayProps({
937
+ sourceValue: 209550,
938
+ format: "currency",
939
+ formatOptions: { locale: "sv-SE", currency: "SEK" },
940
+ })}
941
+ spec={mockSpec}
942
+ />,
943
+ );
944
+ const text = screen.getByText((content) =>
945
+ content.includes("209") && content.includes("550") && content.includes("kr"),
946
+ );
947
+ expect(text).toBeInTheDocument();
948
+ });
949
+
950
+ it("formats currency with EUR locale", () => {
951
+ render(
952
+ <DisplayField
953
+ field={makeDisplayProps({
954
+ sourceValue: 1234.56,
955
+ format: "currency",
956
+ formatOptions: { locale: "de-DE", currency: "EUR" },
957
+ })}
958
+ spec={mockSpec}
959
+ />,
960
+ );
961
+ const text = screen.getByText((content) =>
962
+ content.includes("1.234,56") || (content.includes("1234") && content.includes("€")),
963
+ );
964
+ expect(text).toBeInTheDocument();
965
+ });
710
966
  });
711
967
 
712
968
  // ============================================================================
@@ -491,4 +491,136 @@ describe("Disabled field integration", () => {
491
491
 
492
492
  expect(screen.getByLabelText("Name")).toBeDisabled();
493
493
  });
494
+
495
+ it("display field inherits format from source computed field", () => {
496
+ const spec = createTestSpec({
497
+ fields: {
498
+ totalDisplay: {
499
+ type: "display",
500
+ label: "Total",
501
+ source: "total",
502
+ },
503
+ },
504
+ computed: {
505
+ total: {
506
+ expression: "42",
507
+ format: "currency",
508
+ },
509
+ },
510
+ });
511
+
512
+ // Provide initial data with the computed value already resolved
513
+ render(
514
+ <DefaultFormRenderer
515
+ spec={spec}
516
+ onSubmit={vi.fn()}
517
+ initialData={{ total: 1234.56 }}
518
+ />,
519
+ );
520
+
521
+ // The display field should inherit "currency" format from computed.total
522
+ expect(screen.getByText("$1,234.56")).toBeInTheDocument();
523
+ });
524
+
525
+ it("display field's own format takes priority over computed source format", () => {
526
+ const spec = createTestSpec({
527
+ fields: {
528
+ totalDisplay: {
529
+ type: "display",
530
+ label: "Total",
531
+ source: "total",
532
+ format: "percent",
533
+ },
534
+ },
535
+ computed: {
536
+ total: {
537
+ expression: "42",
538
+ format: "currency",
539
+ },
540
+ },
541
+ });
542
+
543
+ render(
544
+ <DefaultFormRenderer
545
+ spec={spec}
546
+ onSubmit={vi.fn()}
547
+ initialData={{ total: 0.75 }}
548
+ />,
549
+ );
550
+
551
+ // Display field's own format "percent" should win over computed "currency"
552
+ expect(screen.getByText("75%")).toBeInTheDocument();
553
+ });
554
+ });
555
+
556
+ // ============================================================================
557
+ // Spec-Level Locale/Currency Integration
558
+ // ============================================================================
559
+ describe("Spec-level locale/currency", () => {
560
+ it("display field uses spec.meta.locale and spec.meta.currency for formatting", () => {
561
+ const spec = createTestSpec({
562
+ meta: { locale: "sv-SE", currency: "SEK" },
563
+ fields: {
564
+ totalDisplay: {
565
+ type: "display",
566
+ label: "Total",
567
+ source: "total",
568
+ },
569
+ },
570
+ computed: {
571
+ total: {
572
+ expression: "42",
573
+ format: "currency",
574
+ },
575
+ },
576
+ });
577
+
578
+ render(
579
+ <DefaultFormRenderer
580
+ spec={spec}
581
+ onSubmit={vi.fn()}
582
+ initialData={{ total: 209550 }}
583
+ />,
584
+ );
585
+
586
+ // Should render with Swedish locale and SEK currency, not USD
587
+ const text = screen.getByText((content) =>
588
+ content.includes("209") && content.includes("550") && content.includes("kr"),
589
+ );
590
+ expect(text).toBeInTheDocument();
591
+ });
592
+
593
+ it("formatOptions prop overrides spec.meta locale/currency", () => {
594
+ const spec = createTestSpec({
595
+ meta: { locale: "sv-SE", currency: "SEK" },
596
+ fields: {
597
+ totalDisplay: {
598
+ type: "display",
599
+ label: "Total",
600
+ source: "total",
601
+ },
602
+ },
603
+ computed: {
604
+ total: {
605
+ expression: "42",
606
+ format: "currency",
607
+ },
608
+ },
609
+ });
610
+
611
+ render(
612
+ <DefaultFormRenderer
613
+ spec={spec}
614
+ onSubmit={vi.fn()}
615
+ initialData={{ total: 1000 }}
616
+ formatOptions={{ locale: "de-DE", currency: "EUR" }}
617
+ />,
618
+ );
619
+
620
+ // Should render with German locale and EUR, overriding spec meta
621
+ const text = screen.getByText((content) =>
622
+ content.includes("1.000") && content.includes("€"),
623
+ );
624
+ expect(text).toBeInTheDocument();
625
+ });
494
626
  });
@@ -25,12 +25,13 @@ export function createTestSpec(
25
25
  options: {
26
26
  fields?: Record<string, { type: string; [key: string]: unknown }>;
27
27
  fieldOrder?: string[];
28
- computed?: Record<string, { expression: string }>;
28
+ computed?: Record<string, { expression: string; format?: string }>;
29
29
  pages?: PageDefinition[];
30
30
  referenceData?: Record<string, unknown>;
31
+ meta?: Partial<Forma["meta"]>;
31
32
  } = {},
32
33
  ): Forma {
33
- const { fields = {}, fieldOrder, computed, pages, referenceData } = options;
34
+ const { fields = {}, fieldOrder, computed, pages, referenceData, meta } = options;
34
35
 
35
36
  // Build schema from fields
36
37
  const schemaProperties: Record<string, unknown> = {};
@@ -113,6 +114,7 @@ export function createTestSpec(
113
114
  meta: {
114
115
  id: "test-form",
115
116
  title: "Test Form",
117
+ ...meta,
116
118
  },
117
119
  schema: {
118
120
  type: "object",
@@ -1,4 +1,5 @@
1
1
  import React from "react";
2
+ import { formatValue } from "@fogpipe/forma-core";
2
3
  import type { ComputedComponentProps } from "../../types.js";
3
4
 
4
5
  export function ComputedDisplay({ field }: ComputedComponentProps) {
@@ -12,7 +13,7 @@ export function ComputedDisplay({ field }: ComputedComponentProps) {
12
13
  displayValue = String(field.value);
13
14
  }
14
15
  } else {
15
- displayValue = String(field.value);
16
+ displayValue = formatValue(field.value, field.format, field.formatOptions);
16
17
  }
17
18
 
18
19
  return (
@@ -1,10 +1,17 @@
1
1
  import React from "react";
2
+ import type { FormatOptions } from "@fogpipe/forma-core";
3
+ import { formatValue } from "@fogpipe/forma-core";
2
4
  import type { DisplayComponentProps } from "../../types.js";
3
5
 
6
+ const FORMAT_DEFAULTS: FormatOptions = { nullDisplay: "\u2014" };
7
+
4
8
  export function DisplayField({ field }: DisplayComponentProps) {
9
+ const options = field.formatOptions
10
+ ? { ...FORMAT_DEFAULTS, ...field.formatOptions }
11
+ : FORMAT_DEFAULTS;
5
12
  const content =
6
13
  field.sourceValue !== undefined
7
- ? String(field.sourceValue)
14
+ ? formatValue(field.sourceValue, field.format, options)
8
15
  : field.content;
9
16
 
10
17
  return (
package/src/types.ts CHANGED
@@ -6,6 +6,7 @@ import type {
6
6
  Forma,
7
7
  FieldDefinition,
8
8
  FieldError,
9
+ FormatOptions,
9
10
  MatrixColumn,
10
11
  SelectOption,
11
12
  } from "@fogpipe/forma-core";
@@ -276,6 +277,10 @@ export interface ComputedFieldProps extends Omit<BaseFieldProps, "onChange"> {
276
277
  fieldType: "computed";
277
278
  value: unknown;
278
279
  expression: string;
280
+ /** Display format string (e.g., "currency", "percent", "decimal(2)") */
281
+ format?: string;
282
+ /** Resolved format options (locale, currency) for number/date formatting */
283
+ formatOptions?: FormatOptions;
279
284
  onChange?: never;
280
285
  }
281
286
 
@@ -312,6 +317,8 @@ export interface DisplayFieldProps extends Omit<
312
317
  sourceValue?: unknown;
313
318
  /** Display format string */
314
319
  format?: string;
320
+ /** Resolved format options (locale, currency) for number/date formatting */
321
+ formatOptions?: FormatOptions;
315
322
  /** No onChange - display fields are read-only */
316
323
  onChange?: never;
317
324
  value?: never;