@fogpipe/forma-react 0.12.0-alpha.2 → 0.13.0
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/README.md +75 -61
- package/dist/index.js +36 -8
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/ErrorBoundary.tsx +14 -7
- package/src/FieldRenderer.tsx +3 -1
- package/src/FormRenderer.tsx +3 -1
- package/src/__tests__/FieldRenderer.test.tsx +128 -1
- package/src/__tests__/FormRenderer.test.tsx +54 -0
- package/src/__tests__/canProceed.test.ts +141 -100
- package/src/__tests__/diabetes-trial-flow.test.ts +235 -66
- package/src/__tests__/null-handling.test.ts +27 -8
- package/src/__tests__/optionVisibility.test.tsx +199 -58
- package/src/__tests__/test-utils.tsx +26 -7
- package/src/__tests__/useForma.test.ts +244 -73
- package/src/context.ts +3 -1
- package/src/index.ts +6 -1
- package/src/types.ts +58 -14
- package/src/useForma.ts +31 -3
package/README.md
CHANGED
|
@@ -25,22 +25,28 @@ npm install @fogpipe/forma-core @fogpipe/forma-react
|
|
|
25
25
|
Components receive `{ field, spec }` props. The `field` object contains all field state and handlers:
|
|
26
26
|
|
|
27
27
|
```tsx
|
|
28
|
-
import type {
|
|
28
|
+
import type {
|
|
29
|
+
ComponentMap,
|
|
30
|
+
TextComponentProps,
|
|
31
|
+
BooleanComponentProps,
|
|
32
|
+
} from "@fogpipe/forma-react";
|
|
29
33
|
|
|
30
34
|
const TextInput = ({ field }: TextComponentProps) => (
|
|
31
35
|
<div>
|
|
32
36
|
<input
|
|
33
37
|
type="text"
|
|
34
|
-
value={field.value ||
|
|
38
|
+
value={field.value || ""}
|
|
35
39
|
onChange={(e) => field.onChange(e.target.value)}
|
|
36
40
|
onBlur={field.onBlur}
|
|
37
41
|
placeholder={field.placeholder}
|
|
38
|
-
aria-invalid={field[
|
|
39
|
-
aria-describedby={field[
|
|
40
|
-
aria-required={field[
|
|
42
|
+
aria-invalid={field["aria-invalid"]}
|
|
43
|
+
aria-describedby={field["aria-describedby"]}
|
|
44
|
+
aria-required={field["aria-required"]}
|
|
41
45
|
/>
|
|
42
46
|
{field.errors.map((e, i) => (
|
|
43
|
-
<span key={i} className="error">
|
|
47
|
+
<span key={i} className="error">
|
|
48
|
+
{e.message}
|
|
49
|
+
</span>
|
|
44
50
|
))}
|
|
45
51
|
</div>
|
|
46
52
|
);
|
|
@@ -67,21 +73,21 @@ const components: ComponentMap = {
|
|
|
67
73
|
### 2. Render the Form
|
|
68
74
|
|
|
69
75
|
```tsx
|
|
70
|
-
import { FormRenderer } from
|
|
71
|
-
import type { Forma } from
|
|
76
|
+
import { FormRenderer } from "@fogpipe/forma-react";
|
|
77
|
+
import type { Forma } from "@fogpipe/forma-core";
|
|
72
78
|
|
|
73
79
|
const myForm: Forma = {
|
|
74
80
|
meta: { title: "Contact Us" },
|
|
75
81
|
fields: [
|
|
76
82
|
{ id: "name", type: "text", label: "Name", required: true },
|
|
77
83
|
{ id: "email", type: "email", label: "Email", required: true },
|
|
78
|
-
{ id: "subscribe", type: "boolean", label: "Subscribe to newsletter" }
|
|
79
|
-
]
|
|
84
|
+
{ id: "subscribe", type: "boolean", label: "Subscribe to newsletter" },
|
|
85
|
+
],
|
|
80
86
|
};
|
|
81
87
|
|
|
82
88
|
function App() {
|
|
83
89
|
const handleSubmit = (data: Record<string, unknown>) => {
|
|
84
|
-
console.log(
|
|
90
|
+
console.log("Submitted:", data);
|
|
85
91
|
};
|
|
86
92
|
|
|
87
93
|
return (
|
|
@@ -99,7 +105,7 @@ function App() {
|
|
|
99
105
|
For custom rendering, use the `useForma` hook directly:
|
|
100
106
|
|
|
101
107
|
```tsx
|
|
102
|
-
import { useForma } from
|
|
108
|
+
import { useForma } from "@fogpipe/forma-react";
|
|
103
109
|
|
|
104
110
|
function CustomForm({ spec }: { spec: Forma }) {
|
|
105
111
|
const {
|
|
@@ -114,19 +120,24 @@ function CustomForm({ spec }: { spec: Forma }) {
|
|
|
114
120
|
submitForm,
|
|
115
121
|
} = useForma({
|
|
116
122
|
spec,
|
|
117
|
-
onSubmit: (data) => console.log(data)
|
|
123
|
+
onSubmit: (data) => console.log(data),
|
|
118
124
|
});
|
|
119
125
|
|
|
120
126
|
return (
|
|
121
|
-
<form
|
|
122
|
-
{
|
|
127
|
+
<form
|
|
128
|
+
onSubmit={(e) => {
|
|
129
|
+
e.preventDefault();
|
|
130
|
+
submitForm();
|
|
131
|
+
}}
|
|
132
|
+
>
|
|
133
|
+
{spec.fields.map((field) => {
|
|
123
134
|
if (!visibility[field.id]) return null;
|
|
124
135
|
|
|
125
136
|
return (
|
|
126
137
|
<div key={field.id}>
|
|
127
138
|
<label>{field.label}</label>
|
|
128
139
|
<input
|
|
129
|
-
value={String(data[field.id] ||
|
|
140
|
+
value={String(data[field.id] || "")}
|
|
130
141
|
onChange={(e) => setFieldValue(field.id, e.target.value)}
|
|
131
142
|
onBlur={() => setFieldTouched(field.id)}
|
|
132
143
|
/>
|
|
@@ -154,11 +165,13 @@ function WizardForm({ spec }: { spec: Forma }) {
|
|
|
154
165
|
return (
|
|
155
166
|
<div>
|
|
156
167
|
{/* Page indicator */}
|
|
157
|
-
<div>
|
|
168
|
+
<div>
|
|
169
|
+
Page {wizard.currentPageIndex + 1} of {wizard.pages.length}
|
|
170
|
+
</div>
|
|
158
171
|
|
|
159
172
|
{/* Current page fields */}
|
|
160
|
-
{wizard.currentPage?.fields.map(fieldId => {
|
|
161
|
-
const field = spec.fields.find(f => f.id === fieldId);
|
|
173
|
+
{wizard.currentPage?.fields.map((fieldId) => {
|
|
174
|
+
const field = spec.fields.find((f) => f.id === fieldId);
|
|
162
175
|
if (!field) return null;
|
|
163
176
|
// Render field...
|
|
164
177
|
})}
|
|
@@ -184,16 +197,16 @@ function WizardForm({ spec }: { spec: Forma }) {
|
|
|
184
197
|
|
|
185
198
|
### FormRenderer Props
|
|
186
199
|
|
|
187
|
-
| Prop
|
|
188
|
-
|
|
189
|
-
| `spec`
|
|
190
|
-
| `components`
|
|
191
|
-
| `initialData`
|
|
192
|
-
| `onSubmit`
|
|
193
|
-
| `onChange`
|
|
194
|
-
| `layout`
|
|
195
|
-
| `fieldWrapper` | `React.ComponentType<FieldWrapperProps>` | Custom field wrapper
|
|
196
|
-
| `validateOn`
|
|
200
|
+
| Prop | Type | Description |
|
|
201
|
+
| -------------- | ---------------------------------------- | -------------------------------- |
|
|
202
|
+
| `spec` | `Forma` | The Forma specification |
|
|
203
|
+
| `components` | `ComponentMap` | Map of field types to components |
|
|
204
|
+
| `initialData` | `Record<string, unknown>` | Initial form values |
|
|
205
|
+
| `onSubmit` | `(data) => void` | Submit handler |
|
|
206
|
+
| `onChange` | `(data, computed) => void` | Change handler |
|
|
207
|
+
| `layout` | `React.ComponentType<LayoutProps>` | Custom layout |
|
|
208
|
+
| `fieldWrapper` | `React.ComponentType<FieldWrapperProps>` | Custom field wrapper |
|
|
209
|
+
| `validateOn` | `"change" \| "blur" \| "submit"` | Validation timing |
|
|
197
210
|
|
|
198
211
|
### FormRenderer Ref
|
|
199
212
|
|
|
@@ -211,55 +224,55 @@ formRef.current?.setValues({ name: "John" });
|
|
|
211
224
|
|
|
212
225
|
### useForma Return Value
|
|
213
226
|
|
|
214
|
-
| Property
|
|
215
|
-
|
|
216
|
-
| `data`
|
|
217
|
-
| `computed`
|
|
218
|
-
| `visibility`
|
|
219
|
-
| `required`
|
|
220
|
-
| `enabled`
|
|
221
|
-
| `errors`
|
|
222
|
-
| `isValid`
|
|
223
|
-
| `isSubmitting` | `boolean`
|
|
224
|
-
| `isDirty`
|
|
225
|
-
| `wizard`
|
|
227
|
+
| Property | Type | Description |
|
|
228
|
+
| -------------- | ------------------------- | ---------------------- |
|
|
229
|
+
| `data` | `Record<string, unknown>` | Current form values |
|
|
230
|
+
| `computed` | `Record<string, unknown>` | Computed field values |
|
|
231
|
+
| `visibility` | `Record<string, boolean>` | Field visibility map |
|
|
232
|
+
| `required` | `Record<string, boolean>` | Field required state |
|
|
233
|
+
| `enabled` | `Record<string, boolean>` | Field enabled state |
|
|
234
|
+
| `errors` | `FieldError[]` | Validation errors |
|
|
235
|
+
| `isValid` | `boolean` | Form validity |
|
|
236
|
+
| `isSubmitting` | `boolean` | Submission in progress |
|
|
237
|
+
| `isDirty` | `boolean` | Any field modified |
|
|
238
|
+
| `wizard` | `WizardHelpers \| null` | Wizard navigation |
|
|
226
239
|
|
|
227
240
|
### useForma Methods
|
|
228
241
|
|
|
229
|
-
| Method
|
|
230
|
-
|
|
231
|
-
| `setFieldValue(path, value)`
|
|
232
|
-
| `setFieldTouched(path, touched?)` | Mark field as touched
|
|
233
|
-
| `setValues(values)`
|
|
234
|
-
| `validateField(path)`
|
|
235
|
-
| `validateForm()`
|
|
236
|
-
| `submitForm()`
|
|
237
|
-
| `resetForm()`
|
|
242
|
+
| Method | Description |
|
|
243
|
+
| --------------------------------- | ----------------------- |
|
|
244
|
+
| `setFieldValue(path, value)` | Set field value |
|
|
245
|
+
| `setFieldTouched(path, touched?)` | Mark field as touched |
|
|
246
|
+
| `setValues(values)` | Set multiple values |
|
|
247
|
+
| `validateField(path)` | Validate single field |
|
|
248
|
+
| `validateForm()` | Validate entire form |
|
|
249
|
+
| `submitForm()` | Submit the form |
|
|
250
|
+
| `resetForm()` | Reset to initial values |
|
|
238
251
|
|
|
239
252
|
### useForma Options
|
|
240
253
|
|
|
241
|
-
| Option
|
|
242
|
-
|
|
243
|
-
| `spec`
|
|
244
|
-
| `initialData`
|
|
245
|
-
| `onSubmit`
|
|
246
|
-
| `onChange`
|
|
247
|
-
| `validateOn`
|
|
248
|
-
| `referenceData`
|
|
249
|
-
| `validationDebounceMs` | `number`
|
|
254
|
+
| Option | Type | Default | Description |
|
|
255
|
+
| ---------------------- | -------------------------------- | -------- | ------------------------- |
|
|
256
|
+
| `spec` | `Forma` | required | The Forma specification |
|
|
257
|
+
| `initialData` | `Record<string, unknown>` | `{}` | Initial form values |
|
|
258
|
+
| `onSubmit` | `(data) => void` | - | Submit handler |
|
|
259
|
+
| `onChange` | `(data, computed) => void` | - | Change handler |
|
|
260
|
+
| `validateOn` | `"change" \| "blur" \| "submit"` | `"blur"` | When to validate |
|
|
261
|
+
| `referenceData` | `Record<string, unknown>` | - | Additional reference data |
|
|
262
|
+
| `validationDebounceMs` | `number` | `0` | Debounce validation (ms) |
|
|
250
263
|
|
|
251
264
|
## Error Boundary
|
|
252
265
|
|
|
253
266
|
Wrap forms with `FormaErrorBoundary` to catch render errors gracefully:
|
|
254
267
|
|
|
255
268
|
```tsx
|
|
256
|
-
import { FormRenderer, FormaErrorBoundary } from
|
|
269
|
+
import { FormRenderer, FormaErrorBoundary } from "@fogpipe/forma-react";
|
|
257
270
|
|
|
258
271
|
function App() {
|
|
259
272
|
return (
|
|
260
273
|
<FormaErrorBoundary
|
|
261
274
|
fallback={<div>Something went wrong with the form</div>}
|
|
262
|
-
onError={(error) => console.error(
|
|
275
|
+
onError={(error) => console.error("Form error:", error)}
|
|
263
276
|
>
|
|
264
277
|
<FormRenderer spec={myForm} components={components} />
|
|
265
278
|
</FormaErrorBoundary>
|
|
@@ -268,6 +281,7 @@ function App() {
|
|
|
268
281
|
```
|
|
269
282
|
|
|
270
283
|
The error boundary supports:
|
|
284
|
+
|
|
271
285
|
- Custom fallback UI (static or function)
|
|
272
286
|
- `onError` callback for logging
|
|
273
287
|
- `resetKey` prop to reset error state
|
package/dist/index.js
CHANGED
|
@@ -72,6 +72,15 @@ function getDefaultBooleanValues(spec) {
|
|
|
72
72
|
}
|
|
73
73
|
return defaults;
|
|
74
74
|
}
|
|
75
|
+
function getFieldDefaults(spec) {
|
|
76
|
+
const defaults = {};
|
|
77
|
+
for (const [fieldPath, fieldDef] of Object.entries(spec.fields)) {
|
|
78
|
+
if (fieldDef.defaultValue !== void 0) {
|
|
79
|
+
defaults[fieldPath] = fieldDef.defaultValue;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return defaults;
|
|
83
|
+
}
|
|
75
84
|
function useForma(options) {
|
|
76
85
|
const {
|
|
77
86
|
spec: inputSpec,
|
|
@@ -93,8 +102,11 @@ function useForma(options) {
|
|
|
93
102
|
};
|
|
94
103
|
}, [inputSpec, referenceData]);
|
|
95
104
|
const [state, dispatch] = useReducer(formReducer, {
|
|
96
|
-
data: {
|
|
97
|
-
|
|
105
|
+
data: {
|
|
106
|
+
...getDefaultBooleanValues(spec),
|
|
107
|
+
...getFieldDefaults(spec),
|
|
108
|
+
...initialData
|
|
109
|
+
},
|
|
98
110
|
touched: {},
|
|
99
111
|
isSubmitting: false,
|
|
100
112
|
isSubmitted: false,
|
|
@@ -220,8 +232,15 @@ function useForma(options) {
|
|
|
220
232
|
}
|
|
221
233
|
}, [immediateValidation, onSubmit, state.data]);
|
|
222
234
|
const resetForm = useCallback(() => {
|
|
223
|
-
dispatch({
|
|
224
|
-
|
|
235
|
+
dispatch({
|
|
236
|
+
type: "RESET",
|
|
237
|
+
initialData: {
|
|
238
|
+
...getDefaultBooleanValues(spec),
|
|
239
|
+
...getFieldDefaults(spec),
|
|
240
|
+
...initialData
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
}, [spec, initialData]);
|
|
225
244
|
const wizard = useMemo(() => {
|
|
226
245
|
if (!spec.pages || spec.pages.length === 0) return null;
|
|
227
246
|
const pageVisibility = getPageVisibility(state.data, spec, { computed });
|
|
@@ -618,7 +637,9 @@ var FormaContext = createContext(null);
|
|
|
618
637
|
function useFormaContext() {
|
|
619
638
|
const context = useContext(FormaContext);
|
|
620
639
|
if (!context) {
|
|
621
|
-
throw new Error(
|
|
640
|
+
throw new Error(
|
|
641
|
+
"useFormaContext must be used within a FormaContext.Provider"
|
|
642
|
+
);
|
|
622
643
|
}
|
|
623
644
|
return context;
|
|
624
645
|
}
|
|
@@ -699,7 +720,9 @@ function getNumberConstraints(schema) {
|
|
|
699
720
|
function createDefaultItem(itemFields) {
|
|
700
721
|
const item = {};
|
|
701
722
|
for (const [fieldName, fieldDef] of Object.entries(itemFields)) {
|
|
702
|
-
if (fieldDef.
|
|
723
|
+
if (fieldDef.defaultValue !== void 0) {
|
|
724
|
+
item[fieldName] = fieldDef.defaultValue;
|
|
725
|
+
} else if (fieldDef.type === "boolean") {
|
|
703
726
|
item[fieldName] = false;
|
|
704
727
|
} else if (fieldDef.type === "number" || fieldDef.type === "integer") {
|
|
705
728
|
item[fieldName] = null;
|
|
@@ -1006,7 +1029,9 @@ function getNumberConstraints2(schema) {
|
|
|
1006
1029
|
function createDefaultItem2(itemFields) {
|
|
1007
1030
|
const item = {};
|
|
1008
1031
|
for (const [fieldName, fieldDef] of Object.entries(itemFields)) {
|
|
1009
|
-
if (fieldDef.
|
|
1032
|
+
if (fieldDef.defaultValue !== void 0) {
|
|
1033
|
+
item[fieldName] = fieldDef.defaultValue;
|
|
1034
|
+
} else if (fieldDef.type === "boolean") {
|
|
1010
1035
|
item[fieldName] = false;
|
|
1011
1036
|
} else if (fieldDef.type === "number" || fieldDef.type === "integer") {
|
|
1012
1037
|
item[fieldName] = null;
|
|
@@ -1227,7 +1252,10 @@ function FieldRenderer({
|
|
|
1227
1252
|
// src/ErrorBoundary.tsx
|
|
1228
1253
|
import React3 from "react";
|
|
1229
1254
|
import { jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
1230
|
-
function DefaultErrorFallback({
|
|
1255
|
+
function DefaultErrorFallback({
|
|
1256
|
+
error,
|
|
1257
|
+
onReset
|
|
1258
|
+
}) {
|
|
1231
1259
|
return /* @__PURE__ */ jsxs2("div", { className: "forma-error-boundary", role: "alert", children: [
|
|
1232
1260
|
/* @__PURE__ */ jsx3("h3", { children: "Something went wrong" }),
|
|
1233
1261
|
/* @__PURE__ */ jsx3("p", { children: "An error occurred while rendering the form." }),
|