@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.
- package/dist/index.d.ts +5 -1
- package/dist/index.js +29 -17
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/FieldRenderer.tsx +16 -7
- package/src/FormRenderer.tsx +4 -3
- package/src/__tests__/FormRenderer.test.tsx +186 -0
- package/src/__tests__/optionVisibility.test.tsx +511 -0
- package/src/types.ts +2 -0
- package/src/useForma.ts +25 -8
package/src/FieldRenderer.tsx
CHANGED
|
@@ -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:
|
|
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:
|
|
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
|
|
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
|
|
223
|
-
newArray[index] = { ...
|
|
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:
|
|
238
|
+
options: visibleOptions,
|
|
230
239
|
};
|
|
231
240
|
},
|
|
232
241
|
minItems,
|
package/src/FormRenderer.tsx
CHANGED
|
@@ -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
|
// ============================================================================
|