@fogpipe/forma-react 0.11.2 → 0.12.0-alpha.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 +45 -3
- package/dist/index.js +553 -323
- package/dist/index.js.map +1 -1
- package/package.json +6 -2
- package/src/FieldRenderer.tsx +107 -20
- package/src/FormRenderer.tsx +321 -157
- package/src/__tests__/FieldRenderer.test.tsx +136 -20
- package/src/__tests__/FormRenderer.test.tsx +264 -85
- package/src/index.ts +2 -0
- package/src/types.ts +44 -1
- package/src/useForma.ts +392 -235
package/src/FieldRenderer.tsx
CHANGED
|
@@ -6,7 +6,12 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import React from "react";
|
|
9
|
-
import type {
|
|
9
|
+
import type {
|
|
10
|
+
FieldDefinition,
|
|
11
|
+
JSONSchemaProperty,
|
|
12
|
+
SelectOption,
|
|
13
|
+
} from "@fogpipe/forma-core";
|
|
14
|
+
import { isAdornableField } from "@fogpipe/forma-core";
|
|
10
15
|
import { useFormaContext } from "./context.js";
|
|
11
16
|
import type {
|
|
12
17
|
ComponentMap,
|
|
@@ -18,6 +23,7 @@ import type {
|
|
|
18
23
|
MultiSelectFieldProps,
|
|
19
24
|
ArrayFieldProps,
|
|
20
25
|
ArrayHelpers,
|
|
26
|
+
DisplayFieldProps,
|
|
21
27
|
} from "./types.js";
|
|
22
28
|
|
|
23
29
|
/**
|
|
@@ -35,13 +41,23 @@ export interface FieldRendererProps {
|
|
|
35
41
|
/**
|
|
36
42
|
* Extract numeric constraints from JSON Schema property
|
|
37
43
|
*/
|
|
38
|
-
function getNumberConstraints(schema?: JSONSchemaProperty): {
|
|
44
|
+
function getNumberConstraints(schema?: JSONSchemaProperty): {
|
|
45
|
+
min?: number;
|
|
46
|
+
max?: number;
|
|
47
|
+
step?: number;
|
|
48
|
+
} {
|
|
39
49
|
if (!schema) return {};
|
|
40
50
|
if (schema.type !== "number" && schema.type !== "integer") return {};
|
|
41
51
|
|
|
42
52
|
// Extract min/max from schema
|
|
43
|
-
const min =
|
|
44
|
-
|
|
53
|
+
const min =
|
|
54
|
+
"minimum" in schema && typeof schema.minimum === "number"
|
|
55
|
+
? schema.minimum
|
|
56
|
+
: undefined;
|
|
57
|
+
const max =
|
|
58
|
+
"maximum" in schema && typeof schema.maximum === "number"
|
|
59
|
+
? schema.maximum
|
|
60
|
+
: undefined;
|
|
45
61
|
|
|
46
62
|
// Use multipleOf for step if defined, otherwise default to 1 for integers
|
|
47
63
|
let step: number | undefined;
|
|
@@ -57,7 +73,9 @@ function getNumberConstraints(schema?: JSONSchemaProperty): { min?: number; max?
|
|
|
57
73
|
/**
|
|
58
74
|
* Create a default item for an array field based on item field definitions
|
|
59
75
|
*/
|
|
60
|
-
function createDefaultItem(
|
|
76
|
+
function createDefaultItem(
|
|
77
|
+
itemFields: Record<string, FieldDefinition>,
|
|
78
|
+
): Record<string, unknown> {
|
|
61
79
|
const item: Record<string, unknown> = {};
|
|
62
80
|
for (const [fieldName, fieldDef] of Object.entries(itemFields)) {
|
|
63
81
|
if (fieldDef.type === "boolean") {
|
|
@@ -80,7 +98,11 @@ function createDefaultItem(itemFields: Record<string, FieldDefinition>): Record<
|
|
|
80
98
|
* <FieldRenderer fieldPath="email" components={componentMap} />
|
|
81
99
|
* ```
|
|
82
100
|
*/
|
|
83
|
-
export function FieldRenderer({
|
|
101
|
+
export function FieldRenderer({
|
|
102
|
+
fieldPath,
|
|
103
|
+
components,
|
|
104
|
+
className,
|
|
105
|
+
}: FieldRendererProps) {
|
|
84
106
|
const forma = useFormaContext();
|
|
85
107
|
const { spec } = forma;
|
|
86
108
|
|
|
@@ -91,10 +113,12 @@ export function FieldRenderer({ fieldPath, components, className }: FieldRendere
|
|
|
91
113
|
}
|
|
92
114
|
|
|
93
115
|
const isVisible = forma.visibility[fieldPath] !== false;
|
|
94
|
-
if (!isVisible)
|
|
116
|
+
if (!isVisible) {
|
|
117
|
+
return <div data-field-path={fieldPath} hidden />;
|
|
118
|
+
}
|
|
95
119
|
|
|
96
|
-
//
|
|
97
|
-
const fieldType = fieldDef.type
|
|
120
|
+
// Get field type (type is required on all field definitions)
|
|
121
|
+
const fieldType = fieldDef.type;
|
|
98
122
|
const componentKey = fieldType as keyof ComponentMap;
|
|
99
123
|
const Component = components[componentKey] || components.fallback;
|
|
100
124
|
|
|
@@ -112,6 +136,7 @@ export function FieldRenderer({ fieldPath, components, className }: FieldRendere
|
|
|
112
136
|
const schemaProperty = spec.schema.properties[fieldPath];
|
|
113
137
|
|
|
114
138
|
// Base field props
|
|
139
|
+
const isReadonly = forma.readonly[fieldPath] ?? false;
|
|
115
140
|
const baseProps: BaseFieldProps = {
|
|
116
141
|
name: fieldPath,
|
|
117
142
|
field: fieldDef,
|
|
@@ -125,13 +150,30 @@ export function FieldRenderer({ fieldPath, components, className }: FieldRendere
|
|
|
125
150
|
// Convenience properties
|
|
126
151
|
visible: true, // Always true since we already filtered for visibility
|
|
127
152
|
enabled: !disabled,
|
|
153
|
+
readonly: isReadonly,
|
|
128
154
|
label: fieldDef.label ?? fieldPath,
|
|
129
155
|
description: fieldDef.description,
|
|
130
156
|
placeholder: fieldDef.placeholder,
|
|
157
|
+
// Adorner properties (only for adornable field types)
|
|
158
|
+
...(isAdornableField(fieldDef) && {
|
|
159
|
+
prefix: fieldDef.prefix,
|
|
160
|
+
suffix: fieldDef.suffix,
|
|
161
|
+
}),
|
|
162
|
+
// Presentation variant
|
|
163
|
+
variant: fieldDef.variant,
|
|
164
|
+
variantConfig: fieldDef.variantConfig,
|
|
131
165
|
};
|
|
132
166
|
|
|
133
167
|
// Build type-specific props
|
|
134
|
-
let fieldProps:
|
|
168
|
+
let fieldProps:
|
|
169
|
+
| BaseFieldProps
|
|
170
|
+
| TextFieldProps
|
|
171
|
+
| NumberFieldProps
|
|
172
|
+
| IntegerFieldProps
|
|
173
|
+
| SelectFieldProps
|
|
174
|
+
| MultiSelectFieldProps
|
|
175
|
+
| ArrayFieldProps
|
|
176
|
+
| DisplayFieldProps = baseProps;
|
|
135
177
|
|
|
136
178
|
if (fieldType === "number") {
|
|
137
179
|
const constraints = getNumberConstraints(schemaProperty);
|
|
@@ -154,7 +196,8 @@ export function FieldRenderer({ fieldPath, components, className }: FieldRendere
|
|
|
154
196
|
} as IntegerFieldProps;
|
|
155
197
|
} else if (fieldType === "select") {
|
|
156
198
|
// Use pre-computed visible options from memoized map
|
|
157
|
-
const visibleOptions = (forma.optionsVisibility[fieldPath] ??
|
|
199
|
+
const visibleOptions = (forma.optionsVisibility[fieldPath] ??
|
|
200
|
+
[]) as SelectOption[];
|
|
158
201
|
fieldProps = {
|
|
159
202
|
...baseProps,
|
|
160
203
|
fieldType: "select",
|
|
@@ -164,7 +207,8 @@ export function FieldRenderer({ fieldPath, components, className }: FieldRendere
|
|
|
164
207
|
} as SelectFieldProps;
|
|
165
208
|
} else if (fieldType === "multiselect") {
|
|
166
209
|
// Use pre-computed visible options from memoized map
|
|
167
|
-
const visibleOptions = (forma.optionsVisibility[fieldPath] ??
|
|
210
|
+
const visibleOptions = (forma.optionsVisibility[fieldPath] ??
|
|
211
|
+
[]) as SelectOption[];
|
|
168
212
|
fieldProps = {
|
|
169
213
|
...baseProps,
|
|
170
214
|
fieldType: "multiselect",
|
|
@@ -172,7 +216,11 @@ export function FieldRenderer({ fieldPath, components, className }: FieldRendere
|
|
|
172
216
|
onChange: baseProps.onChange as (value: string[]) => void,
|
|
173
217
|
options: visibleOptions,
|
|
174
218
|
} as MultiSelectFieldProps;
|
|
175
|
-
} else if (
|
|
219
|
+
} else if (
|
|
220
|
+
fieldType === "array" &&
|
|
221
|
+
fieldDef.type === "array" &&
|
|
222
|
+
fieldDef.itemFields
|
|
223
|
+
) {
|
|
176
224
|
const arrayValue = (baseProps.value as unknown[] | undefined) ?? [];
|
|
177
225
|
const minItems = fieldDef.minItems ?? 0;
|
|
178
226
|
const maxItems = fieldDef.maxItems ?? Infinity;
|
|
@@ -202,7 +250,10 @@ export function FieldRenderer({ fieldPath, components, className }: FieldRendere
|
|
|
202
250
|
},
|
|
203
251
|
swap: (indexA: number, indexB: number) => {
|
|
204
252
|
const newArray = [...arrayValue];
|
|
205
|
-
[newArray[indexA], newArray[indexB]] = [
|
|
253
|
+
[newArray[indexA], newArray[indexB]] = [
|
|
254
|
+
newArray[indexB],
|
|
255
|
+
newArray[indexA],
|
|
256
|
+
];
|
|
206
257
|
forma.setFieldValue(fieldPath, newArray);
|
|
207
258
|
},
|
|
208
259
|
getItemFieldProps: (index: number, fieldName: string) => {
|
|
@@ -212,7 +263,9 @@ export function FieldRenderer({ fieldPath, components, className }: FieldRendere
|
|
|
212
263
|
const itemValue = item[fieldName];
|
|
213
264
|
|
|
214
265
|
// Use pre-computed visible options from memoized map
|
|
215
|
-
const visibleOptions = forma.optionsVisibility[itemPath] as
|
|
266
|
+
const visibleOptions = forma.optionsVisibility[itemPath] as
|
|
267
|
+
| SelectOption[]
|
|
268
|
+
| undefined;
|
|
216
269
|
|
|
217
270
|
return {
|
|
218
271
|
name: itemPath,
|
|
@@ -223,12 +276,16 @@ export function FieldRenderer({ fieldPath, components, className }: FieldRendere
|
|
|
223
276
|
placeholder: itemFieldDef?.placeholder,
|
|
224
277
|
visible: true,
|
|
225
278
|
enabled: !disabled,
|
|
279
|
+
readonly: forma.readonly[itemPath] ?? false,
|
|
226
280
|
required: itemFieldDef?.requiredWhen === "true",
|
|
227
281
|
touched: forma.touched[itemPath] ?? false,
|
|
228
282
|
errors: forma.errors.filter((e) => e.field === itemPath),
|
|
229
283
|
onChange: (value: unknown) => {
|
|
230
284
|
const newArray = [...arrayValue];
|
|
231
|
-
const existingItem = (newArray[index] ?? {}) as Record<
|
|
285
|
+
const existingItem = (newArray[index] ?? {}) as Record<
|
|
286
|
+
string,
|
|
287
|
+
unknown
|
|
288
|
+
>;
|
|
232
289
|
newArray[index] = { ...existingItem, [fieldName]: value };
|
|
233
290
|
forma.setFieldValue(fieldPath, newArray);
|
|
234
291
|
},
|
|
@@ -253,11 +310,34 @@ export function FieldRenderer({ fieldPath, components, className }: FieldRendere
|
|
|
253
310
|
minItems,
|
|
254
311
|
maxItems,
|
|
255
312
|
} as ArrayFieldProps;
|
|
313
|
+
} else if (fieldType === "display" && fieldDef.type === "display") {
|
|
314
|
+
// Display fields (read-only presentation content)
|
|
315
|
+
const sourceValue = fieldDef.source
|
|
316
|
+
? (forma.data[fieldDef.source] ?? forma.computed[fieldDef.source])
|
|
317
|
+
: undefined;
|
|
318
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
319
|
+
const {
|
|
320
|
+
onChange: _onChange,
|
|
321
|
+
value: _value,
|
|
322
|
+
...displayBaseProps
|
|
323
|
+
} = baseProps;
|
|
324
|
+
fieldProps = {
|
|
325
|
+
...displayBaseProps,
|
|
326
|
+
fieldType: "display",
|
|
327
|
+
content: fieldDef.content,
|
|
328
|
+
sourceValue,
|
|
329
|
+
format: fieldDef.format,
|
|
330
|
+
} as DisplayFieldProps;
|
|
256
331
|
} else {
|
|
257
332
|
// Text-based fields
|
|
258
333
|
fieldProps = {
|
|
259
334
|
...baseProps,
|
|
260
|
-
fieldType: fieldType as
|
|
335
|
+
fieldType: fieldType as
|
|
336
|
+
| "text"
|
|
337
|
+
| "email"
|
|
338
|
+
| "password"
|
|
339
|
+
| "url"
|
|
340
|
+
| "textarea",
|
|
261
341
|
value: (baseProps.value as string) ?? "",
|
|
262
342
|
onChange: baseProps.onChange as (value: string) => void,
|
|
263
343
|
};
|
|
@@ -265,11 +345,18 @@ export function FieldRenderer({ fieldPath, components, className }: FieldRendere
|
|
|
265
345
|
|
|
266
346
|
// Wrap props in { field, spec } structure for components
|
|
267
347
|
const componentProps = { field: fieldProps, spec };
|
|
268
|
-
const element = React.createElement(
|
|
348
|
+
const element = React.createElement(
|
|
349
|
+
Component as React.ComponentType<typeof componentProps>,
|
|
350
|
+
componentProps,
|
|
351
|
+
);
|
|
269
352
|
|
|
270
353
|
if (className) {
|
|
271
|
-
return
|
|
354
|
+
return (
|
|
355
|
+
<div data-field-path={fieldPath} className={className}>
|
|
356
|
+
{element}
|
|
357
|
+
</div>
|
|
358
|
+
);
|
|
272
359
|
}
|
|
273
360
|
|
|
274
|
-
return element
|
|
361
|
+
return <div data-field-path={fieldPath}>{element}</div>;
|
|
275
362
|
}
|