@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.
@@ -0,0 +1,511 @@
1
+ /**
2
+ * Integration tests for option visibility filtering
3
+ *
4
+ * Tests that visibleWhen on SelectOption works correctly through
5
+ * useForma and FieldRenderer.
6
+ */
7
+
8
+ import { describe, it, expect } from "vitest";
9
+ import { renderHook, act } from "@testing-library/react";
10
+ import { useForma } from "../useForma.js";
11
+ import { createTestSpec } from "./test-utils.js";
12
+
13
+ describe("Option Visibility", () => {
14
+ // ============================================================================
15
+ // getSelectFieldProps
16
+ // ============================================================================
17
+
18
+ describe("getSelectFieldProps option filtering", () => {
19
+ it("should return all options when none have visibleWhen", () => {
20
+ const spec = createTestSpec({
21
+ fields: {
22
+ department: {
23
+ type: "select",
24
+ options: [
25
+ { value: "eng", label: "Engineering" },
26
+ { value: "hr", label: "HR" },
27
+ { value: "sales", label: "Sales" },
28
+ ],
29
+ },
30
+ },
31
+ });
32
+
33
+ const { result } = renderHook(() => useForma({ spec }));
34
+ const props = result.current.getSelectFieldProps("department");
35
+
36
+ expect(props.options).toHaveLength(3);
37
+ expect(props.options.map(o => o.value)).toEqual(["eng", "hr", "sales"]);
38
+ });
39
+
40
+ it("should filter options based on visibleWhen expressions", () => {
41
+ const spec = createTestSpec({
42
+ fields: {
43
+ experienceYears: { type: "number" },
44
+ position: {
45
+ type: "select",
46
+ options: [
47
+ { value: "intern", label: "Intern" },
48
+ { value: "junior", label: "Junior Developer", visibleWhen: "experienceYears >= 1" },
49
+ { value: "senior", label: "Senior Developer", visibleWhen: "experienceYears >= 5" },
50
+ { value: "lead", label: "Tech Lead", visibleWhen: "experienceYears >= 8" },
51
+ ],
52
+ },
53
+ },
54
+ });
55
+
56
+ const { result } = renderHook(() =>
57
+ useForma({ spec, initialData: { experienceYears: 3 } })
58
+ );
59
+
60
+ // With 3 years: intern, junior should be visible
61
+ let props = result.current.getSelectFieldProps("position");
62
+ expect(props.options.map(o => o.value)).toEqual(["intern", "junior"]);
63
+
64
+ // Change to 6 years: intern, junior, senior should be visible
65
+ act(() => {
66
+ result.current.setFieldValue("experienceYears", 6);
67
+ });
68
+
69
+ props = result.current.getSelectFieldProps("position");
70
+ expect(props.options.map(o => o.value)).toEqual(["intern", "junior", "senior"]);
71
+
72
+ // Change to 10 years: all should be visible
73
+ act(() => {
74
+ result.current.setFieldValue("experienceYears", 10);
75
+ });
76
+
77
+ props = result.current.getSelectFieldProps("position");
78
+ expect(props.options.map(o => o.value)).toEqual(["intern", "junior", "senior", "lead"]);
79
+ });
80
+
81
+ it("should filter options based on another select field value", () => {
82
+ const spec = createTestSpec({
83
+ fields: {
84
+ department: {
85
+ type: "select",
86
+ options: [
87
+ { value: "eng", label: "Engineering" },
88
+ { value: "hr", label: "HR" },
89
+ ],
90
+ },
91
+ position: {
92
+ type: "select",
93
+ options: [
94
+ { value: "dev_frontend", label: "Frontend Developer", visibleWhen: 'department = "eng"' },
95
+ { value: "dev_backend", label: "Backend Developer", visibleWhen: 'department = "eng"' },
96
+ { value: "recruiter", label: "Recruiter", visibleWhen: 'department = "hr"' },
97
+ { value: "hr_manager", label: "HR Manager", visibleWhen: 'department = "hr"' },
98
+ ],
99
+ },
100
+ },
101
+ });
102
+
103
+ const { result } = renderHook(() =>
104
+ useForma({ spec, initialData: { department: "eng" } })
105
+ );
106
+
107
+ // With Engineering selected
108
+ let props = result.current.getSelectFieldProps("position");
109
+ expect(props.options.map(o => o.value)).toEqual(["dev_frontend", "dev_backend"]);
110
+
111
+ // Switch to HR
112
+ act(() => {
113
+ result.current.setFieldValue("department", "hr");
114
+ });
115
+
116
+ props = result.current.getSelectFieldProps("position");
117
+ expect(props.options.map(o => o.value)).toEqual(["recruiter", "hr_manager"]);
118
+ });
119
+
120
+ it("should return empty options when all are hidden", () => {
121
+ const spec = createTestSpec({
122
+ fields: {
123
+ isPremium: { type: "boolean" },
124
+ premiumFeature: {
125
+ type: "select",
126
+ options: [
127
+ { value: "feature_a", label: "Feature A", visibleWhen: "isPremium = true" },
128
+ { value: "feature_b", label: "Feature B", visibleWhen: "isPremium = true" },
129
+ ],
130
+ },
131
+ },
132
+ });
133
+
134
+ const { result } = renderHook(() =>
135
+ useForma({ spec, initialData: { isPremium: false } })
136
+ );
137
+
138
+ const props = result.current.getSelectFieldProps("premiumFeature");
139
+ expect(props.options).toEqual([]);
140
+ });
141
+
142
+ it("should use computed values in visibleWhen expressions", () => {
143
+ const spec = createTestSpec({
144
+ fields: {
145
+ quantity: { type: "number" },
146
+ unitPrice: { type: "number" },
147
+ shippingMethod: {
148
+ type: "select",
149
+ options: [
150
+ { value: "standard", label: "Standard Shipping" },
151
+ { value: "express", label: "Express Shipping", visibleWhen: "computed.orderTotal >= 50" },
152
+ { value: "overnight", label: "Overnight Shipping", visibleWhen: "computed.orderTotal >= 100" },
153
+ ],
154
+ },
155
+ },
156
+ computed: {
157
+ orderTotal: { expression: "quantity * unitPrice" },
158
+ },
159
+ });
160
+
161
+ const { result } = renderHook(() =>
162
+ useForma({ spec, initialData: { quantity: 2, unitPrice: 20 } })
163
+ );
164
+
165
+ // Total = 40: only standard
166
+ let props = result.current.getSelectFieldProps("shippingMethod");
167
+ expect(props.options.map(o => o.value)).toEqual(["standard"]);
168
+
169
+ // Total = 60: standard and express
170
+ act(() => {
171
+ result.current.setFieldValue("quantity", 3);
172
+ });
173
+
174
+ props = result.current.getSelectFieldProps("shippingMethod");
175
+ expect(props.options.map(o => o.value)).toEqual(["standard", "express"]);
176
+
177
+ // Total = 120: all options
178
+ act(() => {
179
+ result.current.setFieldValue("unitPrice", 40);
180
+ });
181
+
182
+ props = result.current.getSelectFieldProps("shippingMethod");
183
+ expect(props.options.map(o => o.value)).toEqual(["standard", "express", "overnight"]);
184
+ });
185
+ });
186
+
187
+ // ============================================================================
188
+ // Multiselect
189
+ // ============================================================================
190
+
191
+ describe("multiselect option filtering", () => {
192
+ it("should filter multiselect options based on visibleWhen", () => {
193
+ const spec = createTestSpec({
194
+ fields: {
195
+ accountType: {
196
+ type: "select",
197
+ options: [
198
+ { value: "free", label: "Free" },
199
+ { value: "paid", label: "Paid" },
200
+ ],
201
+ },
202
+ features: {
203
+ type: "multiselect",
204
+ options: [
205
+ { value: "basic", label: "Basic Features" },
206
+ { value: "advanced", label: "Advanced Features", visibleWhen: 'accountType = "paid"' },
207
+ { value: "enterprise", label: "Enterprise Features", visibleWhen: 'accountType = "paid"' },
208
+ ],
209
+ },
210
+ },
211
+ });
212
+
213
+ const { result } = renderHook(() =>
214
+ useForma({ spec, initialData: { accountType: "free" } })
215
+ );
216
+
217
+ // Free account: only basic
218
+ let props = result.current.getSelectFieldProps("features");
219
+ expect(props.options.map(o => o.value)).toEqual(["basic"]);
220
+
221
+ // Paid account: all features
222
+ act(() => {
223
+ result.current.setFieldValue("accountType", "paid");
224
+ });
225
+
226
+ props = result.current.getSelectFieldProps("features");
227
+ expect(props.options.map(o => o.value)).toEqual(["basic", "advanced", "enterprise"]);
228
+ });
229
+ });
230
+
231
+ // ============================================================================
232
+ // Array item select fields
233
+ // ============================================================================
234
+
235
+ describe("array item select field filtering", () => {
236
+ it("should filter options in array item select fields using item context", () => {
237
+ const spec = createTestSpec({
238
+ fields: {
239
+ orderItems: {
240
+ type: "array",
241
+ itemFields: {
242
+ category: {
243
+ type: "select",
244
+ options: [
245
+ { value: "electronics", label: "Electronics" },
246
+ { value: "clothing", label: "Clothing" },
247
+ ],
248
+ },
249
+ addon: {
250
+ type: "select",
251
+ options: [
252
+ { value: "warranty", label: "Extended Warranty", visibleWhen: 'item.category = "electronics"' },
253
+ { value: "insurance", label: "Shipping Insurance" },
254
+ { value: "giftWrap", label: "Gift Wrap", visibleWhen: 'item.category = "clothing"' },
255
+ ],
256
+ },
257
+ },
258
+ },
259
+ },
260
+ });
261
+
262
+ const { result } = renderHook(() =>
263
+ useForma({
264
+ spec,
265
+ initialData: {
266
+ orderItems: [
267
+ { category: "electronics", addon: null },
268
+ { category: "clothing", addon: null },
269
+ ],
270
+ },
271
+ })
272
+ );
273
+
274
+ const helpers = result.current.getArrayHelpers("orderItems");
275
+
276
+ // First item (electronics): warranty and insurance
277
+ const item0Props = helpers.getItemFieldProps(0, "addon");
278
+ expect(item0Props.options?.map(o => o.value)).toEqual(["warranty", "insurance"]);
279
+
280
+ // Second item (clothing): insurance and giftWrap
281
+ const item1Props = helpers.getItemFieldProps(1, "addon");
282
+ expect(item1Props.options?.map(o => o.value)).toEqual(["insurance", "giftWrap"]);
283
+ });
284
+
285
+ it("should update array item options when item data changes", () => {
286
+ const spec = createTestSpec({
287
+ fields: {
288
+ contacts: {
289
+ type: "array",
290
+ itemFields: {
291
+ type: {
292
+ type: "select",
293
+ options: [
294
+ { value: "personal", label: "Personal" },
295
+ { value: "business", label: "Business" },
296
+ ],
297
+ },
298
+ method: {
299
+ type: "select",
300
+ options: [
301
+ { value: "email", label: "Email" },
302
+ { value: "phone", label: "Phone" },
303
+ { value: "fax", label: "Fax", visibleWhen: 'item.type = "business"' },
304
+ ],
305
+ },
306
+ },
307
+ },
308
+ },
309
+ });
310
+
311
+ const { result } = renderHook(() =>
312
+ useForma({
313
+ spec,
314
+ initialData: {
315
+ contacts: [{ type: "personal", method: null }],
316
+ },
317
+ })
318
+ );
319
+
320
+ // Initially personal: email and phone only
321
+ let helpers = result.current.getArrayHelpers("contacts");
322
+ let methodProps = helpers.getItemFieldProps(0, "method");
323
+ expect(methodProps.options?.map(o => o.value)).toEqual(["email", "phone"]);
324
+
325
+ // Change to business: fax becomes available
326
+ act(() => {
327
+ helpers.getItemFieldProps(0, "type").onChange("business");
328
+ });
329
+
330
+ helpers = result.current.getArrayHelpers("contacts");
331
+ methodProps = helpers.getItemFieldProps(0, "method");
332
+ expect(methodProps.options?.map(o => o.value)).toEqual(["email", "phone", "fax"]);
333
+ });
334
+
335
+ it("should use itemIndex in option visibleWhen expressions", () => {
336
+ const spec = createTestSpec({
337
+ fields: {
338
+ teamMembers: {
339
+ type: "array",
340
+ itemFields: {
341
+ role: {
342
+ type: "select",
343
+ options: [
344
+ { value: "member", label: "Team Member" },
345
+ { value: "lead", label: "Team Lead", visibleWhen: "itemIndex = 0" },
346
+ ],
347
+ },
348
+ },
349
+ },
350
+ },
351
+ });
352
+
353
+ const { result } = renderHook(() =>
354
+ useForma({
355
+ spec,
356
+ initialData: {
357
+ teamMembers: [{ role: null }, { role: null }, { role: null }],
358
+ },
359
+ })
360
+ );
361
+
362
+ const helpers = result.current.getArrayHelpers("teamMembers");
363
+
364
+ // First item: can be member or lead
365
+ const item0Props = helpers.getItemFieldProps(0, "role");
366
+ expect(item0Props.options?.map(o => o.value)).toEqual(["member", "lead"]);
367
+
368
+ // Second item: can only be member
369
+ const item1Props = helpers.getItemFieldProps(1, "role");
370
+ expect(item1Props.options?.map(o => o.value)).toEqual(["member"]);
371
+
372
+ // Third item: can only be member
373
+ const item2Props = helpers.getItemFieldProps(2, "role");
374
+ expect(item2Props.options?.map(o => o.value)).toEqual(["member"]);
375
+ });
376
+
377
+ it("should combine form data, computed values, and item context", () => {
378
+ const spec = createTestSpec({
379
+ fields: {
380
+ isPremiumOrder: { type: "boolean" },
381
+ lineItems: {
382
+ type: "array",
383
+ itemFields: {
384
+ productType: {
385
+ type: "select",
386
+ options: [
387
+ { value: "standard", label: "Standard" },
388
+ { value: "premium", label: "Premium" },
389
+ ],
390
+ },
391
+ shipping: {
392
+ type: "select",
393
+ options: [
394
+ { value: "standard", label: "Standard" },
395
+ { value: "express", label: "Express", visibleWhen: "isPremiumOrder = true" },
396
+ { value: "priority", label: "Priority", visibleWhen: 'isPremiumOrder = true and item.productType = "premium"' },
397
+ ],
398
+ },
399
+ },
400
+ },
401
+ },
402
+ });
403
+
404
+ const { result } = renderHook(() =>
405
+ useForma({
406
+ spec,
407
+ initialData: {
408
+ isPremiumOrder: false,
409
+ lineItems: [
410
+ { productType: "standard", shipping: null },
411
+ { productType: "premium", shipping: null },
412
+ ],
413
+ },
414
+ })
415
+ );
416
+
417
+ let helpers = result.current.getArrayHelpers("lineItems");
418
+
419
+ // Not premium order: only standard shipping for both
420
+ expect(helpers.getItemFieldProps(0, "shipping").options?.map(o => o.value)).toEqual(["standard"]);
421
+ expect(helpers.getItemFieldProps(1, "shipping").options?.map(o => o.value)).toEqual(["standard"]);
422
+
423
+ // Upgrade to premium order
424
+ act(() => {
425
+ result.current.setFieldValue("isPremiumOrder", true);
426
+ });
427
+
428
+ helpers = result.current.getArrayHelpers("lineItems");
429
+
430
+ // Standard product: standard and express
431
+ expect(helpers.getItemFieldProps(0, "shipping").options?.map(o => o.value)).toEqual(["standard", "express"]);
432
+
433
+ // Premium product: standard, express, and priority
434
+ expect(helpers.getItemFieldProps(1, "shipping").options?.map(o => o.value)).toEqual(["standard", "express", "priority"]);
435
+ });
436
+ });
437
+
438
+ // ============================================================================
439
+ // Edge cases
440
+ // ============================================================================
441
+
442
+ describe("edge cases", () => {
443
+ it("should handle field without options gracefully", () => {
444
+ const spec = createTestSpec({
445
+ fields: {
446
+ textField: { type: "text" },
447
+ },
448
+ });
449
+
450
+ const { result } = renderHook(() => useForma({ spec }));
451
+ const props = result.current.getSelectFieldProps("textField");
452
+
453
+ expect(props.options).toEqual([]);
454
+ });
455
+
456
+ it("should handle invalid visibleWhen expressions gracefully", () => {
457
+ const spec = createTestSpec({
458
+ fields: {
459
+ status: {
460
+ type: "select",
461
+ options: [
462
+ { value: "valid", label: "Valid Option" },
463
+ { value: "invalid", label: "Invalid", visibleWhen: "this is not valid FEEL syntax !!!" },
464
+ ],
465
+ },
466
+ },
467
+ });
468
+
469
+ const { result } = renderHook(() => useForma({ spec }));
470
+ const props = result.current.getSelectFieldProps("status");
471
+
472
+ // Invalid expression should hide the option (treated as false)
473
+ expect(props.options.map(o => o.value)).toEqual(["valid"]);
474
+ });
475
+
476
+ it("should preserve selected value when option becomes hidden", () => {
477
+ const spec = createTestSpec({
478
+ fields: {
479
+ level: { type: "number" },
480
+ feature: {
481
+ type: "select",
482
+ options: [
483
+ { value: "basic", label: "Basic" },
484
+ { value: "advanced", label: "Advanced", visibleWhen: "level >= 5" },
485
+ ],
486
+ },
487
+ },
488
+ });
489
+
490
+ const { result } = renderHook(() =>
491
+ useForma({ spec, initialData: { level: 5, feature: "advanced" } })
492
+ );
493
+
494
+ // Initially advanced is selected and visible
495
+ expect(result.current.data.feature).toBe("advanced");
496
+ expect(result.current.getSelectFieldProps("feature").options.map(o => o.value))
497
+ .toEqual(["basic", "advanced"]);
498
+
499
+ // Reduce level - advanced option becomes hidden but value is preserved
500
+ act(() => {
501
+ result.current.setFieldValue("level", 3);
502
+ });
503
+
504
+ // Value is preserved (validation would catch this if needed)
505
+ expect(result.current.data.feature).toBe("advanced");
506
+ // But option is no longer visible
507
+ expect(result.current.getSelectFieldProps("feature").options.map(o => o.value))
508
+ .toEqual(["basic"]);
509
+ });
510
+ });
511
+ });
package/src/types.ts CHANGED
@@ -449,6 +449,8 @@ export interface GetFieldPropsResult {
449
449
  "aria-describedby"?: string;
450
450
  /** ARIA: Indicates the field is required */
451
451
  "aria-required"?: boolean;
452
+ /** Options for select/multiselect fields (filtered by visibleWhen) */
453
+ options?: SelectOption[];
452
454
  }
453
455
 
454
456
  /**
package/src/useForma.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
9
- import type { Forma, FieldError, ValidationResult } from "@fogpipe/forma-core";
9
+ import type { Forma, FieldError, ValidationResult, SelectOption } from "@fogpipe/forma-core";
10
10
  import type { GetFieldPropsResult, GetSelectFieldPropsResult, GetArrayHelpersResult } from "./types.js";
11
11
  import {
12
12
  getVisibility,
@@ -15,7 +15,9 @@ import {
15
15
  validate,
16
16
  calculate,
17
17
  getPageVisibility,
18
+ getOptionsVisibility,
18
19
  } from "@fogpipe/forma-core";
20
+ import type { OptionsVisibilityResult } from "@fogpipe/forma-core";
19
21
 
20
22
  /**
21
23
  * Options for useForma hook
@@ -108,6 +110,8 @@ export interface UseFormaReturn {
108
110
  required: Record<string, boolean>;
109
111
  /** Field enabled state map */
110
112
  enabled: Record<string, boolean>;
113
+ /** Visible options for select/multiselect fields, keyed by field path */
114
+ optionsVisibility: OptionsVisibilityResult;
111
115
  /** Field touched state map */
112
116
  touched: Record<string, boolean>;
113
117
  /** Validation errors */
@@ -268,6 +272,12 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
268
272
  [state.data, spec, computed]
269
273
  );
270
274
 
275
+ // Calculate visible options for all select/multiselect fields (memoized)
276
+ const optionsVisibility = useMemo(
277
+ () => getOptionsVisibility(state.data, spec, { computed }),
278
+ [state.data, spec, computed]
279
+ );
280
+
271
281
  // Validate form - compute immediate result
272
282
  const immediateValidation = useMemo(
273
283
  () => validate(state.data, spec, { computed, onlyVisible: true }),
@@ -622,16 +632,18 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
622
632
  };
623
633
  }, [spec, state.touched, state.isSubmitted, visibility, enabled, required, validation.errors, validateOn, getValueAtPath, getFieldHandlers]);
624
634
 
625
- // Get select field props
635
+ // Get select field props - uses pre-computed optionsVisibility map
626
636
  const getSelectFieldProps = useCallback((path: string): GetSelectFieldPropsResult => {
627
637
  const baseProps = getFieldProps(path);
628
- const fieldDef = spec.fields[path];
638
+
639
+ // Look up pre-computed visible options from memoized map
640
+ const visibleOptions = optionsVisibility[path] ?? [];
629
641
 
630
642
  return {
631
643
  ...baseProps,
632
- options: fieldDef?.options ?? [],
644
+ options: visibleOptions as SelectOption[],
633
645
  };
634
- }, [getFieldProps, spec.fields]);
646
+ }, [getFieldProps, optionsVisibility]);
635
647
 
636
648
  // Get array helpers
637
649
  const getArrayHelpers = useCallback((path: string): GetArrayHelpersResult => {
@@ -649,13 +661,16 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
649
661
  const handlers = getFieldHandlers(itemPath);
650
662
 
651
663
  // Get item value
652
- const item = currentValue[index] as Record<string, unknown> | undefined;
653
- const itemValue = item?.[fieldName];
664
+ const item = (currentValue[index] as Record<string, unknown>) ?? {};
665
+ const itemValue = item[fieldName];
654
666
 
655
667
  const fieldErrors = validation.errors.filter((e) => e.field === itemPath);
656
668
  const isTouched = state.touched[itemPath] ?? false;
657
669
  const showErrors = validateOn === "change" || (validateOn === "blur" && isTouched) || state.isSubmitted;
658
670
 
671
+ // Look up pre-computed visible options from memoized map
672
+ const visibleOptions = optionsVisibility[itemPath] as SelectOption[] | undefined;
673
+
659
674
  return {
660
675
  name: itemPath,
661
676
  value: itemValue,
@@ -671,6 +686,7 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
671
686
  errors: showErrors ? fieldErrors : [],
672
687
  onChange: handlers.onChange,
673
688
  onBlur: handlers.onBlur,
689
+ options: visibleOptions,
674
690
  };
675
691
  };
676
692
 
@@ -712,7 +728,7 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
712
728
  canAdd,
713
729
  canRemove,
714
730
  };
715
- }, [spec.fields, getValueAtPath, setValueAtPath, getFieldHandlers, enabled, state.touched, state.isSubmitted, validation.errors, validateOn]);
731
+ }, [spec.fields, getValueAtPath, setValueAtPath, getFieldHandlers, enabled, state.touched, state.isSubmitted, validation.errors, validateOn, optionsVisibility]);
716
732
 
717
733
  return {
718
734
  data: state.data,
@@ -720,6 +736,7 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
720
736
  visibility,
721
737
  required,
722
738
  enabled,
739
+ optionsVisibility,
723
740
  touched: state.touched,
724
741
  errors: validation.errors,
725
742
  isValid: validation.valid,