@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
|
@@ -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
|
-
|
|
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:
|
|
644
|
+
options: visibleOptions as SelectOption[],
|
|
633
645
|
};
|
|
634
|
-
}, [getFieldProps,
|
|
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>
|
|
653
|
-
const itemValue = item
|
|
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,
|