@fogpipe/forma-react 0.11.1 → 0.12.0-alpha.1
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 +44 -2
- package/dist/index.js +76 -15
- package/dist/index.js.map +1 -1
- package/package.json +6 -2
- package/src/FieldRenderer.tsx +29 -4
- package/src/FormRenderer.tsx +34 -8
- package/src/__tests__/FormRenderer.test.tsx +186 -0
- package/src/index.ts +2 -0
- package/src/types.ts +44 -1
- package/src/useForma.ts +30 -6
package/src/FormRenderer.tsx
CHANGED
|
@@ -6,10 +6,11 @@
|
|
|
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
|
+
import { isAdornableField, isSelectionField } from "@fogpipe/forma-core";
|
|
10
11
|
import { useForma } from "./useForma.js";
|
|
11
12
|
import { FormaContext } from "./context.js";
|
|
12
|
-
import type { ComponentMap, LayoutProps, FieldWrapperProps, PageWrapperProps, BaseFieldProps, TextFieldProps, NumberFieldProps, SelectFieldProps, ArrayFieldProps, ArrayHelpers } from "./types.js";
|
|
13
|
+
import type { ComponentMap, LayoutProps, FieldWrapperProps, PageWrapperProps, BaseFieldProps, TextFieldProps, NumberFieldProps, SelectFieldProps, ArrayFieldProps, ArrayHelpers, DisplayFieldProps } from "./types.js";
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* Props for FormRenderer component
|
|
@@ -247,8 +248,8 @@ export const FormRenderer = forwardRef<FormRendererHandle, FormRendererProps>(
|
|
|
247
248
|
const isVisible = forma.visibility[fieldPath] !== false;
|
|
248
249
|
if (!isVisible) return null;
|
|
249
250
|
|
|
250
|
-
//
|
|
251
|
-
const fieldType = fieldDef.type
|
|
251
|
+
// Get field type (type is required on all field definitions)
|
|
252
|
+
const fieldType = fieldDef.type;
|
|
252
253
|
const componentKey = fieldType as keyof ComponentMap;
|
|
253
254
|
const Component = components[componentKey] || components.fallback;
|
|
254
255
|
|
|
@@ -273,6 +274,7 @@ export const FormRenderer = forwardRef<FormRendererHandle, FormRendererProps>(
|
|
|
273
274
|
const showRequiredIndicator = required && (!isBooleanField || hasValidationRules);
|
|
274
275
|
|
|
275
276
|
// Base field props
|
|
277
|
+
const isReadonly = forma.readonly[fieldPath] ?? false;
|
|
276
278
|
const baseProps: BaseFieldProps = {
|
|
277
279
|
name: fieldPath,
|
|
278
280
|
field: fieldDef,
|
|
@@ -286,13 +288,22 @@ export const FormRenderer = forwardRef<FormRendererHandle, FormRendererProps>(
|
|
|
286
288
|
// Convenience properties
|
|
287
289
|
visible: true, // Always true since we already filtered for visibility
|
|
288
290
|
enabled: !disabled,
|
|
291
|
+
readonly: isReadonly,
|
|
289
292
|
label: fieldDef.label ?? fieldPath,
|
|
290
293
|
description: fieldDef.description,
|
|
291
294
|
placeholder: fieldDef.placeholder,
|
|
295
|
+
// Adorner properties (only for adornable field types)
|
|
296
|
+
...(isAdornableField(fieldDef) && {
|
|
297
|
+
prefix: fieldDef.prefix,
|
|
298
|
+
suffix: fieldDef.suffix,
|
|
299
|
+
}),
|
|
300
|
+
// Presentation variant
|
|
301
|
+
variant: fieldDef.variant,
|
|
302
|
+
variantConfig: fieldDef.variantConfig,
|
|
292
303
|
};
|
|
293
304
|
|
|
294
305
|
// Build type-specific props
|
|
295
|
-
let fieldProps: BaseFieldProps | TextFieldProps | NumberFieldProps | SelectFieldProps | ArrayFieldProps = baseProps;
|
|
306
|
+
let fieldProps: BaseFieldProps | TextFieldProps | NumberFieldProps | SelectFieldProps | ArrayFieldProps | DisplayFieldProps = baseProps;
|
|
296
307
|
|
|
297
308
|
if (fieldType === "number" || fieldType === "integer") {
|
|
298
309
|
const constraints = getNumberConstraints(schemaProperty);
|
|
@@ -304,14 +315,15 @@ export const FormRenderer = forwardRef<FormRendererHandle, FormRendererProps>(
|
|
|
304
315
|
...constraints,
|
|
305
316
|
} as NumberFieldProps;
|
|
306
317
|
} else if (fieldType === "select" || fieldType === "multiselect") {
|
|
318
|
+
const selectOptions = isSelectionField(fieldDef) ? fieldDef.options : [];
|
|
307
319
|
fieldProps = {
|
|
308
320
|
...baseProps,
|
|
309
321
|
fieldType,
|
|
310
322
|
value: baseProps.value as string | string[] | null,
|
|
311
323
|
onChange: baseProps.onChange as (value: string | string[] | null) => void,
|
|
312
|
-
options:
|
|
324
|
+
options: forma.optionsVisibility[fieldPath] ?? selectOptions ?? [],
|
|
313
325
|
} as SelectFieldProps;
|
|
314
|
-
} else if (fieldType === "array" && fieldDef.itemFields) {
|
|
326
|
+
} else if (fieldType === "array" && fieldDef.type === "array" && fieldDef.itemFields) {
|
|
315
327
|
const arrayValue = Array.isArray(baseProps.value) ? baseProps.value : [];
|
|
316
328
|
const minItems = fieldDef.minItems ?? 0;
|
|
317
329
|
const maxItems = fieldDef.maxItems ?? Infinity;
|
|
@@ -330,11 +342,12 @@ export const FormRenderer = forwardRef<FormRendererHandle, FormRendererProps>(
|
|
|
330
342
|
const getItemFieldPropsExtended = (index: number, fieldName: string) => {
|
|
331
343
|
const baseProps = baseHelpers.getItemFieldProps(index, fieldName);
|
|
332
344
|
const itemFieldDef = itemFieldDefs[fieldName];
|
|
345
|
+
const itemPath = `${fieldPath}[${index}].${fieldName}`;
|
|
333
346
|
return {
|
|
334
347
|
...baseProps,
|
|
335
348
|
itemIndex: index,
|
|
336
349
|
fieldName,
|
|
337
|
-
options: itemFieldDef
|
|
350
|
+
options: (forma.optionsVisibility[itemPath] as SelectOption[] | undefined) ?? (itemFieldDef && isSelectionField(itemFieldDef) ? itemFieldDef.options : undefined),
|
|
338
351
|
};
|
|
339
352
|
};
|
|
340
353
|
|
|
@@ -361,6 +374,19 @@ export const FormRenderer = forwardRef<FormRendererHandle, FormRendererProps>(
|
|
|
361
374
|
minItems,
|
|
362
375
|
maxItems,
|
|
363
376
|
} as ArrayFieldProps;
|
|
377
|
+
} else if (fieldType === "display" && fieldDef.type === "display") {
|
|
378
|
+
// Display fields (read-only presentation content)
|
|
379
|
+
// Resolve source value if the display field has a source property
|
|
380
|
+
const sourceValue = fieldDef.source ? forma.data[fieldDef.source] ?? forma.computed[fieldDef.source] : undefined;
|
|
381
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
382
|
+
const { onChange: _onChange, value: _value, ...displayBaseProps } = baseProps;
|
|
383
|
+
fieldProps = {
|
|
384
|
+
...displayBaseProps,
|
|
385
|
+
fieldType: "display",
|
|
386
|
+
content: fieldDef.content,
|
|
387
|
+
sourceValue,
|
|
388
|
+
format: fieldDef.format,
|
|
389
|
+
} as DisplayFieldProps;
|
|
364
390
|
} else {
|
|
365
391
|
// Text-based fields
|
|
366
392
|
fieldProps = {
|
|
@@ -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
|
// ============================================================================
|
package/src/index.ts
CHANGED
package/src/types.ts
CHANGED
|
@@ -31,12 +31,22 @@ export interface BaseFieldProps {
|
|
|
31
31
|
visible: boolean;
|
|
32
32
|
/** Whether field is enabled (inverse of disabled) */
|
|
33
33
|
enabled: boolean;
|
|
34
|
+
/** Whether field is readonly (visible, not editable, value still submitted) */
|
|
35
|
+
readonly: boolean;
|
|
34
36
|
/** Display label from field definition */
|
|
35
37
|
label: string;
|
|
36
38
|
/** Help text or description from field definition */
|
|
37
39
|
description?: string;
|
|
38
40
|
/** Placeholder text from field definition */
|
|
39
41
|
placeholder?: string;
|
|
42
|
+
/** Prefix adorner text (e.g., "$") - only for adornable field types */
|
|
43
|
+
prefix?: string;
|
|
44
|
+
/** Suffix adorner text (e.g., "kg") - only for adornable field types */
|
|
45
|
+
suffix?: string;
|
|
46
|
+
/** Presentation variant hint (e.g., "slider", "radio", "nps") */
|
|
47
|
+
variant?: string;
|
|
48
|
+
/** Variant-specific configuration */
|
|
49
|
+
variantConfig?: Record<string, unknown>;
|
|
40
50
|
}
|
|
41
51
|
|
|
42
52
|
/**
|
|
@@ -239,6 +249,22 @@ export interface ArrayItemFieldProps extends Omit<BaseFieldProps, "value" | "onC
|
|
|
239
249
|
fieldName: string;
|
|
240
250
|
}
|
|
241
251
|
|
|
252
|
+
/**
|
|
253
|
+
* Props for display fields (read-only presentation content)
|
|
254
|
+
*/
|
|
255
|
+
export interface DisplayFieldProps extends Omit<BaseFieldProps, "value" | "onChange"> {
|
|
256
|
+
fieldType: "display";
|
|
257
|
+
/** Static content (markdown/text) */
|
|
258
|
+
content?: string;
|
|
259
|
+
/** Computed source value (resolved by useForma from display field's source property) */
|
|
260
|
+
sourceValue?: unknown;
|
|
261
|
+
/** Display format string */
|
|
262
|
+
format?: string;
|
|
263
|
+
/** No onChange - display fields are read-only */
|
|
264
|
+
onChange?: never;
|
|
265
|
+
value?: never;
|
|
266
|
+
}
|
|
267
|
+
|
|
242
268
|
/**
|
|
243
269
|
* Union of all field prop types
|
|
244
270
|
*/
|
|
@@ -253,7 +279,8 @@ export type FieldProps =
|
|
|
253
279
|
| MultiSelectFieldProps
|
|
254
280
|
| ArrayFieldProps
|
|
255
281
|
| ObjectFieldProps
|
|
256
|
-
| ComputedFieldProps
|
|
282
|
+
| ComputedFieldProps
|
|
283
|
+
| DisplayFieldProps;
|
|
257
284
|
|
|
258
285
|
/**
|
|
259
286
|
* Map of field types to React components
|
|
@@ -275,6 +302,7 @@ export interface ComponentMap {
|
|
|
275
302
|
array?: React.ComponentType<ArrayComponentProps>;
|
|
276
303
|
object?: React.ComponentType<ObjectComponentProps>;
|
|
277
304
|
computed?: React.ComponentType<ComputedComponentProps>;
|
|
305
|
+
display?: React.ComponentType<DisplayComponentProps>;
|
|
278
306
|
fallback?: React.ComponentType<FieldComponentProps>;
|
|
279
307
|
}
|
|
280
308
|
|
|
@@ -382,6 +410,11 @@ export interface ComputedComponentProps {
|
|
|
382
410
|
spec: Forma;
|
|
383
411
|
}
|
|
384
412
|
|
|
413
|
+
export interface DisplayComponentProps {
|
|
414
|
+
field: DisplayFieldProps;
|
|
415
|
+
spec: Forma;
|
|
416
|
+
}
|
|
417
|
+
|
|
385
418
|
/**
|
|
386
419
|
* Generic field component props (for fallback/dynamic components)
|
|
387
420
|
*/
|
|
@@ -427,6 +460,8 @@ export interface GetFieldPropsResult {
|
|
|
427
460
|
visible: boolean;
|
|
428
461
|
/** Whether field is enabled (not disabled) */
|
|
429
462
|
enabled: boolean;
|
|
463
|
+
/** Whether field is readonly (visible, not editable, value still submitted) */
|
|
464
|
+
readonly: boolean;
|
|
430
465
|
/** Whether field is required (for validation) */
|
|
431
466
|
required: boolean;
|
|
432
467
|
/**
|
|
@@ -451,6 +486,14 @@ export interface GetFieldPropsResult {
|
|
|
451
486
|
"aria-required"?: boolean;
|
|
452
487
|
/** Options for select/multiselect fields (filtered by visibleWhen) */
|
|
453
488
|
options?: SelectOption[];
|
|
489
|
+
/** Prefix adorner text (e.g., "$") */
|
|
490
|
+
prefix?: string;
|
|
491
|
+
/** Suffix adorner text (e.g., "kg") */
|
|
492
|
+
suffix?: string;
|
|
493
|
+
/** Presentation variant hint */
|
|
494
|
+
variant?: string;
|
|
495
|
+
/** Variant-specific configuration */
|
|
496
|
+
variantConfig?: Record<string, unknown>;
|
|
454
497
|
}
|
|
455
498
|
|
|
456
499
|
/**
|
package/src/useForma.ts
CHANGED
|
@@ -7,11 +7,13 @@
|
|
|
7
7
|
|
|
8
8
|
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
|
|
9
9
|
import type { Forma, FieldError, ValidationResult, SelectOption } from "@fogpipe/forma-core";
|
|
10
|
+
import { isAdornableField } from "@fogpipe/forma-core";
|
|
10
11
|
import type { GetFieldPropsResult, GetSelectFieldPropsResult, GetArrayHelpersResult } from "./types.js";
|
|
11
12
|
import {
|
|
12
13
|
getVisibility,
|
|
13
14
|
getRequired,
|
|
14
15
|
getEnabled,
|
|
16
|
+
getReadonly,
|
|
15
17
|
validate,
|
|
16
18
|
calculate,
|
|
17
19
|
getPageVisibility,
|
|
@@ -110,6 +112,8 @@ export interface UseFormaReturn {
|
|
|
110
112
|
required: Record<string, boolean>;
|
|
111
113
|
/** Field enabled state map */
|
|
112
114
|
enabled: Record<string, boolean>;
|
|
115
|
+
/** Field readonly state map */
|
|
116
|
+
readonly: Record<string, boolean>;
|
|
113
117
|
/** Visible options for select/multiselect fields, keyed by field path */
|
|
114
118
|
optionsVisibility: OptionsVisibilityResult;
|
|
115
119
|
/** Field touched state map */
|
|
@@ -272,6 +276,12 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
272
276
|
[state.data, spec, computed]
|
|
273
277
|
);
|
|
274
278
|
|
|
279
|
+
// Calculate readonly state
|
|
280
|
+
const readonly = useMemo(
|
|
281
|
+
() => getReadonly(state.data, spec, { computed }),
|
|
282
|
+
[state.data, spec, computed]
|
|
283
|
+
);
|
|
284
|
+
|
|
275
285
|
// Calculate visible options for all select/multiselect fields (memoized)
|
|
276
286
|
const optionsVisibility = useMemo(
|
|
277
287
|
() => getOptionsVisibility(state.data, spec, { computed }),
|
|
@@ -543,7 +553,7 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
543
553
|
// Also include array item field patterns
|
|
544
554
|
for (const fieldId of spec.fieldOrder) {
|
|
545
555
|
const fieldDef = spec.fields[fieldId];
|
|
546
|
-
if (fieldDef?.itemFields) {
|
|
556
|
+
if (fieldDef?.type === "array" && fieldDef.itemFields) {
|
|
547
557
|
for (const key of fieldHandlers.current.keys()) {
|
|
548
558
|
if (key.startsWith(`${fieldId}[`)) {
|
|
549
559
|
validFields.add(key);
|
|
@@ -610,6 +620,11 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
610
620
|
const hasValidationRules = (fieldDef?.validations?.length ?? 0) > 0;
|
|
611
621
|
const showRequiredIndicator = isRequired && (!isBooleanField || hasValidationRules);
|
|
612
622
|
|
|
623
|
+
// Pass through adorner props for adornable field types
|
|
624
|
+
const adornerProps = fieldDef && isAdornableField(fieldDef)
|
|
625
|
+
? { prefix: fieldDef.prefix, suffix: fieldDef.suffix }
|
|
626
|
+
: {};
|
|
627
|
+
|
|
613
628
|
return {
|
|
614
629
|
name: path,
|
|
615
630
|
value: getValueAtPath(path),
|
|
@@ -619,6 +634,7 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
619
634
|
placeholder: fieldDef?.placeholder,
|
|
620
635
|
visible: visibility[path] !== false,
|
|
621
636
|
enabled: enabled[path] !== false,
|
|
637
|
+
readonly: readonly[path] ?? false,
|
|
622
638
|
required: isRequired,
|
|
623
639
|
showRequiredIndicator,
|
|
624
640
|
touched: isTouched,
|
|
@@ -629,8 +645,13 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
629
645
|
"aria-invalid": hasErrors || undefined,
|
|
630
646
|
"aria-describedby": hasErrors ? `${path}-error` : undefined,
|
|
631
647
|
"aria-required": isRequired || undefined,
|
|
648
|
+
// Adorner props (only for adornable field types)
|
|
649
|
+
...adornerProps,
|
|
650
|
+
// Presentation variant
|
|
651
|
+
variant: fieldDef?.variant,
|
|
652
|
+
variantConfig: fieldDef?.variantConfig,
|
|
632
653
|
};
|
|
633
|
-
}, [spec, state.touched, state.isSubmitted, visibility, enabled, required, validation.errors, validateOn, getValueAtPath, getFieldHandlers]);
|
|
654
|
+
}, [spec, state.touched, state.isSubmitted, visibility, enabled, readonly, required, validation.errors, validateOn, getValueAtPath, getFieldHandlers]);
|
|
634
655
|
|
|
635
656
|
// Get select field props - uses pre-computed optionsVisibility map
|
|
636
657
|
const getSelectFieldProps = useCallback((path: string): GetSelectFieldPropsResult => {
|
|
@@ -649,15 +670,16 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
649
670
|
const getArrayHelpers = useCallback((path: string): GetArrayHelpersResult => {
|
|
650
671
|
const fieldDef = spec.fields[path];
|
|
651
672
|
const currentValue = (getValueAtPath(path) as unknown[]) ?? [];
|
|
652
|
-
const
|
|
653
|
-
const
|
|
673
|
+
const arrayDef = fieldDef?.type === "array" ? fieldDef : undefined;
|
|
674
|
+
const minItems = arrayDef?.minItems ?? 0;
|
|
675
|
+
const maxItems = arrayDef?.maxItems ?? Infinity;
|
|
654
676
|
|
|
655
677
|
const canAdd = currentValue.length < maxItems;
|
|
656
678
|
const canRemove = currentValue.length > minItems;
|
|
657
679
|
|
|
658
680
|
const getItemFieldProps = (index: number, fieldName: string): GetFieldPropsResult => {
|
|
659
681
|
const itemPath = `${path}[${index}].${fieldName}`;
|
|
660
|
-
const itemFieldDef =
|
|
682
|
+
const itemFieldDef = arrayDef?.itemFields?.[fieldName];
|
|
661
683
|
const handlers = getFieldHandlers(itemPath);
|
|
662
684
|
|
|
663
685
|
// Get item value
|
|
@@ -680,6 +702,7 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
680
702
|
placeholder: itemFieldDef?.placeholder,
|
|
681
703
|
visible: true,
|
|
682
704
|
enabled: enabled[path] !== false,
|
|
705
|
+
readonly: readonly[itemPath] ?? false,
|
|
683
706
|
required: false, // TODO: Evaluate item field required
|
|
684
707
|
showRequiredIndicator: false, // Item fields don't show required indicator
|
|
685
708
|
touched: isTouched,
|
|
@@ -728,7 +751,7 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
728
751
|
canAdd,
|
|
729
752
|
canRemove,
|
|
730
753
|
};
|
|
731
|
-
}, [spec.fields, getValueAtPath, setValueAtPath, getFieldHandlers, enabled, state.touched, state.isSubmitted, validation.errors, validateOn, optionsVisibility]);
|
|
754
|
+
}, [spec.fields, getValueAtPath, setValueAtPath, getFieldHandlers, enabled, readonly, state.touched, state.isSubmitted, validation.errors, validateOn, optionsVisibility]);
|
|
732
755
|
|
|
733
756
|
return {
|
|
734
757
|
data: state.data,
|
|
@@ -736,6 +759,7 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
736
759
|
visibility,
|
|
737
760
|
required,
|
|
738
761
|
enabled,
|
|
762
|
+
readonly,
|
|
739
763
|
optionsVisibility,
|
|
740
764
|
touched: state.touched,
|
|
741
765
|
errors: validation.errors,
|