@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.
@@ -6,7 +6,12 @@
6
6
  */
7
7
 
8
8
  import React from "react";
9
- import type { FieldDefinition, JSONSchemaProperty, SelectOption } from "@fogpipe/forma-core";
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): { min?: number; max?: number; step?: number } {
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 = "minimum" in schema && typeof schema.minimum === "number" ? schema.minimum : undefined;
44
- const max = "maximum" in schema && typeof schema.maximum === "number" ? schema.maximum : undefined;
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(itemFields: Record<string, FieldDefinition>): Record<string, unknown> {
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({ fieldPath, components, className }: FieldRendererProps) {
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) return null;
116
+ if (!isVisible) {
117
+ return <div data-field-path={fieldPath} hidden />;
118
+ }
95
119
 
96
- // Infer field type
97
- const fieldType = fieldDef.type || (fieldDef.itemFields ? "array" : "text");
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: BaseFieldProps | TextFieldProps | NumberFieldProps | IntegerFieldProps | SelectFieldProps | MultiSelectFieldProps | ArrayFieldProps = baseProps;
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] ?? []) as SelectOption[];
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] ?? []) as SelectOption[];
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 (fieldType === "array" && fieldDef.itemFields) {
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]] = [newArray[indexB], newArray[indexA]];
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 SelectOption[] | undefined;
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<string, unknown>;
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 "text" | "email" | "password" | "url" | "textarea",
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(Component as React.ComponentType<typeof componentProps>, componentProps);
348
+ const element = React.createElement(
349
+ Component as React.ComponentType<typeof componentProps>,
350
+ componentProps,
351
+ );
269
352
 
270
353
  if (className) {
271
- return <div className={className}>{element}</div>;
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
  }