@fogpipe/forma-react 0.10.4 → 0.11.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.
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import React from "react";
9
- import type { FieldDefinition, JSONSchemaProperty } from "@fogpipe/forma-core";
9
+ import type { FieldDefinition, JSONSchemaProperty, SelectOption } from "@fogpipe/forma-core";
10
10
  import { useFormaContext } from "./context.js";
11
11
  import type {
12
12
  ComponentMap,
@@ -153,20 +153,24 @@ export function FieldRenderer({ fieldPath, components, className }: FieldRendere
153
153
  max: constraints.max,
154
154
  } as IntegerFieldProps;
155
155
  } else if (fieldType === "select") {
156
+ // Use pre-computed visible options from memoized map
157
+ const visibleOptions = (forma.optionsVisibility[fieldPath] ?? []) as SelectOption[];
156
158
  fieldProps = {
157
159
  ...baseProps,
158
160
  fieldType: "select",
159
161
  value: baseProps.value as string | null,
160
162
  onChange: baseProps.onChange as (value: string | null) => void,
161
- options: fieldDef.options ?? [],
163
+ options: visibleOptions,
162
164
  } as SelectFieldProps;
163
165
  } else if (fieldType === "multiselect") {
166
+ // Use pre-computed visible options from memoized map
167
+ const visibleOptions = (forma.optionsVisibility[fieldPath] ?? []) as SelectOption[];
164
168
  fieldProps = {
165
169
  ...baseProps,
166
170
  fieldType: "multiselect",
167
171
  value: (baseProps.value as string[] | undefined) ?? [],
168
172
  onChange: baseProps.onChange as (value: string[]) => void,
169
- options: fieldDef.options ?? [],
173
+ options: visibleOptions,
170
174
  } as MultiSelectFieldProps;
171
175
  } else if (fieldType === "array" && fieldDef.itemFields) {
172
176
  const arrayValue = (baseProps.value as unknown[] | undefined) ?? [];
@@ -204,7 +208,12 @@ export function FieldRenderer({ fieldPath, components, className }: FieldRendere
204
208
  getItemFieldProps: (index: number, fieldName: string) => {
205
209
  const itemFieldDef = itemFieldDefs[fieldName];
206
210
  const itemPath = `${fieldPath}[${index}].${fieldName}`;
207
- const itemValue = (arrayValue[index] as Record<string, unknown>)?.[fieldName];
211
+ const item = (arrayValue[index] as Record<string, unknown>) ?? {};
212
+ const itemValue = item[fieldName];
213
+
214
+ // Use pre-computed visible options from memoized map
215
+ const visibleOptions = forma.optionsVisibility[itemPath] as SelectOption[] | undefined;
216
+
208
217
  return {
209
218
  name: itemPath,
210
219
  value: itemValue,
@@ -219,14 +228,14 @@ export function FieldRenderer({ fieldPath, components, className }: FieldRendere
219
228
  errors: forma.errors.filter((e) => e.field === itemPath),
220
229
  onChange: (value: unknown) => {
221
230
  const newArray = [...arrayValue];
222
- const item = (newArray[index] ?? {}) as Record<string, unknown>;
223
- newArray[index] = { ...item, [fieldName]: value };
231
+ const existingItem = (newArray[index] ?? {}) as Record<string, unknown>;
232
+ newArray[index] = { ...existingItem, [fieldName]: value };
224
233
  forma.setFieldValue(fieldPath, newArray);
225
234
  },
226
235
  onBlur: () => forma.setFieldTouched(itemPath),
227
236
  itemIndex: index,
228
237
  fieldName,
229
- options: itemFieldDef?.options,
238
+ options: visibleOptions,
230
239
  };
231
240
  },
232
241
  minItems,
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import React, { forwardRef, useImperativeHandle, useRef, useMemo, useCallback } from "react";
9
- import type { Forma, FieldDefinition, ValidationResult, JSONSchemaProperty } from "@fogpipe/forma-core";
9
+ import type { Forma, FieldDefinition, ValidationResult, JSONSchemaProperty, SelectOption } from "@fogpipe/forma-core";
10
10
  import { useForma } from "./useForma.js";
11
11
  import { FormaContext } from "./context.js";
12
12
  import type { ComponentMap, LayoutProps, FieldWrapperProps, PageWrapperProps, BaseFieldProps, TextFieldProps, NumberFieldProps, SelectFieldProps, ArrayFieldProps, ArrayHelpers } from "./types.js";
@@ -309,7 +309,7 @@ export const FormRenderer = forwardRef<FormRendererHandle, FormRendererProps>(
309
309
  fieldType,
310
310
  value: baseProps.value as string | string[] | null,
311
311
  onChange: baseProps.onChange as (value: string | string[] | null) => void,
312
- options: fieldDef.options ?? [],
312
+ options: forma.optionsVisibility[fieldPath] ?? fieldDef.options ?? [],
313
313
  } as SelectFieldProps;
314
314
  } else if (fieldType === "array" && fieldDef.itemFields) {
315
315
  const arrayValue = Array.isArray(baseProps.value) ? baseProps.value : [];
@@ -330,11 +330,12 @@ export const FormRenderer = forwardRef<FormRendererHandle, FormRendererProps>(
330
330
  const getItemFieldPropsExtended = (index: number, fieldName: string) => {
331
331
  const baseProps = baseHelpers.getItemFieldProps(index, fieldName);
332
332
  const itemFieldDef = itemFieldDefs[fieldName];
333
+ const itemPath = `${fieldPath}[${index}].${fieldName}`;
333
334
  return {
334
335
  ...baseProps,
335
336
  itemIndex: index,
336
337
  fieldName,
337
- options: itemFieldDef?.options,
338
+ options: (forma.optionsVisibility[itemPath] as SelectOption[] | undefined) ?? itemFieldDef?.options,
338
339
  };
339
340
  };
340
341
 
@@ -735,6 +735,192 @@ describe("FormRenderer", () => {
735
735
  });
736
736
  });
737
737
 
738
+ // ============================================================================
739
+ // Option Visibility (visibleWhen on select options)
740
+ // ============================================================================
741
+
742
+ describe("option visibility in FormRenderer", () => {
743
+ it("should filter select options based on visibleWhen expressions", async () => {
744
+ const user = userEvent.setup();
745
+ const spec = createTestSpec({
746
+ fields: {
747
+ department: {
748
+ type: "select",
749
+ label: "Department",
750
+ options: [
751
+ { value: "engineering", label: "Engineering" },
752
+ { value: "sales", label: "Sales" },
753
+ ],
754
+ },
755
+ position: {
756
+ type: "select",
757
+ label: "Position",
758
+ options: [
759
+ { value: "dev", label: "Developer", visibleWhen: 'department = "engineering"' },
760
+ { value: "qa", label: "QA Engineer", visibleWhen: 'department = "engineering"' },
761
+ { value: "rep", label: "Sales Rep", visibleWhen: 'department = "sales"' },
762
+ { value: "mgr", label: "Sales Manager", visibleWhen: 'department = "sales"' },
763
+ ],
764
+ },
765
+ },
766
+ });
767
+
768
+ render(
769
+ <FormRenderer
770
+ spec={spec}
771
+ components={createTestComponentMap()}
772
+ />
773
+ );
774
+
775
+ // Initially no department selected - no position options should show
776
+ const positionSelect = screen.getByTestId("field-position").querySelector("select")!;
777
+ // Only the placeholder "Select..." option should be present
778
+ expect(positionSelect.querySelectorAll("option")).toHaveLength(1);
779
+
780
+ // Select Engineering department
781
+ const departmentSelect = screen.getByTestId("field-department").querySelector("select")!;
782
+ await user.selectOptions(departmentSelect, "engineering");
783
+
784
+ // Now only engineering positions should show
785
+ await waitFor(() => {
786
+ const options = positionSelect.querySelectorAll("option");
787
+ // placeholder + 2 engineering options
788
+ expect(options).toHaveLength(3);
789
+ expect(screen.getByText("Developer")).toBeInTheDocument();
790
+ expect(screen.getByText("QA Engineer")).toBeInTheDocument();
791
+ expect(screen.queryByText("Sales Rep")).not.toBeInTheDocument();
792
+ expect(screen.queryByText("Sales Manager")).not.toBeInTheDocument();
793
+ });
794
+
795
+ // Switch to Sales department
796
+ await user.selectOptions(departmentSelect, "sales");
797
+
798
+ // Now only sales positions should show
799
+ await waitFor(() => {
800
+ const options = positionSelect.querySelectorAll("option");
801
+ // placeholder + 2 sales options
802
+ expect(options).toHaveLength(3);
803
+ expect(screen.getByText("Sales Rep")).toBeInTheDocument();
804
+ expect(screen.getByText("Sales Manager")).toBeInTheDocument();
805
+ expect(screen.queryByText("Developer")).not.toBeInTheDocument();
806
+ expect(screen.queryByText("QA Engineer")).not.toBeInTheDocument();
807
+ });
808
+ });
809
+
810
+ it("should show all options when none have visibleWhen", () => {
811
+ const spec = createTestSpec({
812
+ fields: {
813
+ color: {
814
+ type: "select",
815
+ label: "Color",
816
+ options: [
817
+ { value: "red", label: "Red" },
818
+ { value: "blue", label: "Blue" },
819
+ { value: "green", label: "Green" },
820
+ ],
821
+ },
822
+ },
823
+ });
824
+
825
+ render(
826
+ <FormRenderer
827
+ spec={spec}
828
+ components={createTestComponentMap()}
829
+ />
830
+ );
831
+
832
+ expect(screen.getByText("Red")).toBeInTheDocument();
833
+ expect(screen.getByText("Blue")).toBeInTheDocument();
834
+ expect(screen.getByText("Green")).toBeInTheDocument();
835
+ });
836
+
837
+ it("should filter multiselect options based on visibleWhen expressions", async () => {
838
+ const user = userEvent.setup();
839
+ const spec = createTestSpec({
840
+ fields: {
841
+ tier: {
842
+ type: "select",
843
+ label: "Tier",
844
+ options: [
845
+ { value: "basic", label: "Basic" },
846
+ { value: "premium", label: "Premium" },
847
+ ],
848
+ },
849
+ features: {
850
+ type: "multiselect",
851
+ label: "Features",
852
+ options: [
853
+ { value: "email", label: "Email Support" },
854
+ { value: "phone", label: "Phone Support", visibleWhen: 'tier = "premium"' },
855
+ { value: "priority", label: "Priority Queue", visibleWhen: 'tier = "premium"' },
856
+ ],
857
+ },
858
+ },
859
+ });
860
+
861
+ render(
862
+ <FormRenderer
863
+ spec={spec}
864
+ components={createTestComponentMap()}
865
+ />
866
+ );
867
+
868
+ // Initially no tier selected - only non-conditional option visible
869
+ const featuresSelect = screen.getByTestId("field-features").querySelector("select")!;
870
+ await waitFor(() => {
871
+ // placeholder + 1 option without visibleWhen
872
+ expect(featuresSelect.querySelectorAll("option")).toHaveLength(2);
873
+ expect(screen.getByText("Email Support")).toBeInTheDocument();
874
+ expect(screen.queryByText("Phone Support")).not.toBeInTheDocument();
875
+ });
876
+
877
+ // Select Premium tier
878
+ const tierSelect = screen.getByTestId("field-tier").querySelector("select")!;
879
+ await user.selectOptions(tierSelect, "premium");
880
+
881
+ // All options should now show
882
+ await waitFor(() => {
883
+ // placeholder + 3 options
884
+ expect(featuresSelect.querySelectorAll("option")).toHaveLength(4);
885
+ expect(screen.getByText("Email Support")).toBeInTheDocument();
886
+ expect(screen.getByText("Phone Support")).toBeInTheDocument();
887
+ expect(screen.getByText("Priority Queue")).toBeInTheDocument();
888
+ });
889
+ });
890
+
891
+ it("should return empty options when all are filtered out", async () => {
892
+ const spec = createTestSpec({
893
+ fields: {
894
+ category: {
895
+ type: "select",
896
+ label: "Category",
897
+ options: [
898
+ { value: "a", label: "Option A", visibleWhen: 'toggle = true' },
899
+ { value: "b", label: "Option B", visibleWhen: 'toggle = true' },
900
+ ],
901
+ },
902
+ toggle: {
903
+ type: "boolean",
904
+ label: "Toggle",
905
+ },
906
+ },
907
+ });
908
+
909
+ render(
910
+ <FormRenderer
911
+ spec={spec}
912
+ initialData={{ toggle: false }}
913
+ components={createTestComponentMap()}
914
+ />
915
+ );
916
+
917
+ // All options have visibleWhen that evaluates to false
918
+ const categorySelect = screen.getByTestId("field-category").querySelector("select")!;
919
+ // Only placeholder
920
+ expect(categorySelect.querySelectorAll("option")).toHaveLength(1);
921
+ });
922
+ });
923
+
738
924
  // ============================================================================
739
925
  // Array Field Interactions
740
926
  // ============================================================================