@fogpipe/forma-react 0.12.0 → 0.14.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 +140 -61
- package/dist/index.d.ts +90 -1
- package/dist/index.js +303 -51
- 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__/events.test.ts +752 -0
- 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/events.ts +186 -0
- package/src/index.ts +11 -1
- package/src/types.ts +65 -14
- package/src/useForma.ts +292 -53
package/src/context.ts
CHANGED
|
@@ -17,7 +17,9 @@ export const FormaContext = createContext<UseFormaReturn | null>(null);
|
|
|
17
17
|
export function useFormaContext(): UseFormaReturn {
|
|
18
18
|
const context = useContext(FormaContext);
|
|
19
19
|
if (!context) {
|
|
20
|
-
throw new Error(
|
|
20
|
+
throw new Error(
|
|
21
|
+
"useFormaContext must be used within a FormaContext.Provider",
|
|
22
|
+
);
|
|
21
23
|
}
|
|
22
24
|
return context;
|
|
23
25
|
}
|
package/src/events.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Event system for forma-react
|
|
3
|
+
*
|
|
4
|
+
* Lightweight event emitter for form lifecycle events.
|
|
5
|
+
* Events are for side effects (analytics, data injection, external state sync)
|
|
6
|
+
* — they do not trigger React re-renders.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { FieldError } from "@fogpipe/forma-core";
|
|
10
|
+
import type { PageState } from "./useForma.js";
|
|
11
|
+
|
|
12
|
+
// ============================================================================
|
|
13
|
+
// Event Type Definitions
|
|
14
|
+
// ============================================================================
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Map of all forma event names to their payload types.
|
|
18
|
+
*/
|
|
19
|
+
export interface FormaEventMap {
|
|
20
|
+
/**
|
|
21
|
+
* Fires after a field value changes via user input, setFieldValue, setValues, or resetForm.
|
|
22
|
+
* Does NOT fire for computed/calculated field changes or on initial mount.
|
|
23
|
+
*/
|
|
24
|
+
fieldChanged: {
|
|
25
|
+
/** Field path (e.g., "age" or "medications[0].dosage") */
|
|
26
|
+
path: string;
|
|
27
|
+
/** New value */
|
|
28
|
+
value: unknown;
|
|
29
|
+
/** Value before the change */
|
|
30
|
+
previousValue: unknown;
|
|
31
|
+
/** What triggered the change */
|
|
32
|
+
source: "user" | "reset" | "setValues";
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Fires at the start of submitForm(), before validation.
|
|
37
|
+
* The `data` object is mutable — consumers can inject extra fields.
|
|
38
|
+
* Async handlers are awaited before proceeding to validation.
|
|
39
|
+
*/
|
|
40
|
+
preSubmit: {
|
|
41
|
+
/** Mutable form data — add/modify fields here */
|
|
42
|
+
data: Record<string, unknown>;
|
|
43
|
+
/** Read-only snapshot of computed values */
|
|
44
|
+
computed: Record<string, unknown>;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Fires after onSubmit resolves/rejects or after validation failure.
|
|
49
|
+
*/
|
|
50
|
+
postSubmit: {
|
|
51
|
+
/** The submitted data (reflects any preSubmit mutations) */
|
|
52
|
+
data: Record<string, unknown>;
|
|
53
|
+
/** Whether submission succeeded */
|
|
54
|
+
success: boolean;
|
|
55
|
+
/** Present when onSubmit threw an error */
|
|
56
|
+
error?: Error;
|
|
57
|
+
/** Present when validation failed (onSubmit was never called) */
|
|
58
|
+
validationErrors?: FieldError[];
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Fires when the wizard page changes via nextPage, previousPage, or goToPage.
|
|
63
|
+
* Does NOT fire on initial render or automatic page clamping.
|
|
64
|
+
*/
|
|
65
|
+
pageChanged: {
|
|
66
|
+
/** Previous page index */
|
|
67
|
+
fromIndex: number;
|
|
68
|
+
/** New page index */
|
|
69
|
+
toIndex: number;
|
|
70
|
+
/** The new current page */
|
|
71
|
+
page: PageState;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Fires after resetForm() completes and state is back to initial values.
|
|
76
|
+
*/
|
|
77
|
+
formReset: Record<string, never>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ============================================================================
|
|
81
|
+
// Helper Types
|
|
82
|
+
// ============================================================================
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Listener function type for a specific event.
|
|
86
|
+
*/
|
|
87
|
+
export type FormaEventListener<K extends keyof FormaEventMap> = (
|
|
88
|
+
event: FormaEventMap[K],
|
|
89
|
+
) => void | Promise<void>;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Declarative event listener map for useForma `on` option.
|
|
93
|
+
*/
|
|
94
|
+
export type FormaEvents = Partial<{
|
|
95
|
+
[K in keyof FormaEventMap]: FormaEventListener<K>;
|
|
96
|
+
}>;
|
|
97
|
+
|
|
98
|
+
// ============================================================================
|
|
99
|
+
// Event Emitter
|
|
100
|
+
// ============================================================================
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Lightweight event emitter for forma lifecycle events.
|
|
104
|
+
* Uses Map<string, Set<listener>> internally — no external dependencies.
|
|
105
|
+
*/
|
|
106
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
107
|
+
type AnyListener = (...args: any[]) => void | Promise<void>;
|
|
108
|
+
|
|
109
|
+
export class FormaEventEmitter {
|
|
110
|
+
private listeners = new Map<string, Set<AnyListener>>();
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Register a listener for an event. Returns an unsubscribe function.
|
|
114
|
+
*/
|
|
115
|
+
on<K extends keyof FormaEventMap>(
|
|
116
|
+
event: K,
|
|
117
|
+
listener: FormaEventListener<K>,
|
|
118
|
+
): () => void {
|
|
119
|
+
if (!this.listeners.has(event)) {
|
|
120
|
+
this.listeners.set(event, new Set());
|
|
121
|
+
}
|
|
122
|
+
this.listeners.get(event)!.add(listener);
|
|
123
|
+
|
|
124
|
+
return () => {
|
|
125
|
+
const set = this.listeners.get(event);
|
|
126
|
+
if (set) {
|
|
127
|
+
set.delete(listener);
|
|
128
|
+
if (set.size === 0) {
|
|
129
|
+
this.listeners.delete(event);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Fire an event synchronously. Listener errors are caught and logged
|
|
137
|
+
* to prevent one listener from breaking others.
|
|
138
|
+
*/
|
|
139
|
+
fire<K extends keyof FormaEventMap>(event: K, payload: FormaEventMap[K]): void {
|
|
140
|
+
const set = this.listeners.get(event);
|
|
141
|
+
if (!set || set.size === 0) return;
|
|
142
|
+
|
|
143
|
+
for (const listener of set) {
|
|
144
|
+
try {
|
|
145
|
+
listener(payload);
|
|
146
|
+
} catch (error) {
|
|
147
|
+
console.error(`[forma] Error in "${event}" event listener:`, error);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Fire an event and await all async listeners sequentially.
|
|
154
|
+
* Used for preSubmit where handlers can be async.
|
|
155
|
+
*/
|
|
156
|
+
async fireAsync<K extends keyof FormaEventMap>(
|
|
157
|
+
event: K,
|
|
158
|
+
payload: FormaEventMap[K],
|
|
159
|
+
): Promise<void> {
|
|
160
|
+
const set = this.listeners.get(event);
|
|
161
|
+
if (!set || set.size === 0) return;
|
|
162
|
+
|
|
163
|
+
for (const listener of set) {
|
|
164
|
+
try {
|
|
165
|
+
await listener(payload);
|
|
166
|
+
} catch (error) {
|
|
167
|
+
console.error(`[forma] Error in "${event}" event listener:`, error);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Check if any listeners are registered for an event.
|
|
174
|
+
*/
|
|
175
|
+
hasListeners(event: keyof FormaEventMap): boolean {
|
|
176
|
+
const set = this.listeners.get(event);
|
|
177
|
+
return set !== undefined && set.size > 0;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Remove all listeners. Called on cleanup.
|
|
182
|
+
*/
|
|
183
|
+
clear(): void {
|
|
184
|
+
this.listeners.clear();
|
|
185
|
+
}
|
|
186
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -20,7 +20,12 @@ export type {
|
|
|
20
20
|
|
|
21
21
|
// Hook
|
|
22
22
|
export { useForma } from "./useForma.js";
|
|
23
|
-
export type {
|
|
23
|
+
export type {
|
|
24
|
+
UseFormaOptions,
|
|
25
|
+
UseFormaReturn,
|
|
26
|
+
PageState,
|
|
27
|
+
WizardHelpers,
|
|
28
|
+
} from "./useForma.js";
|
|
24
29
|
|
|
25
30
|
// Components
|
|
26
31
|
export { FormRenderer } from "./FormRenderer.js";
|
|
@@ -90,4 +95,9 @@ export type {
|
|
|
90
95
|
LayoutProps,
|
|
91
96
|
FieldWrapperProps,
|
|
92
97
|
PageWrapperProps,
|
|
98
|
+
|
|
99
|
+
// Event types
|
|
100
|
+
FormaEventMap,
|
|
101
|
+
FormaEvents,
|
|
102
|
+
FormaEventListener,
|
|
93
103
|
} from "./types.js";
|
package/src/types.ts
CHANGED
|
@@ -2,7 +2,12 @@
|
|
|
2
2
|
* Type definitions for forma-react components
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import type {
|
|
5
|
+
import type {
|
|
6
|
+
Forma,
|
|
7
|
+
FieldDefinition,
|
|
8
|
+
FieldError,
|
|
9
|
+
SelectOption,
|
|
10
|
+
} from "@fogpipe/forma-core";
|
|
6
11
|
|
|
7
12
|
/**
|
|
8
13
|
* Base props shared by all field components
|
|
@@ -52,7 +57,10 @@ export interface BaseFieldProps {
|
|
|
52
57
|
/**
|
|
53
58
|
* Props for text-based fields (text, email, password, url, textarea)
|
|
54
59
|
*/
|
|
55
|
-
export interface TextFieldProps extends Omit<
|
|
60
|
+
export interface TextFieldProps extends Omit<
|
|
61
|
+
BaseFieldProps,
|
|
62
|
+
"value" | "onChange"
|
|
63
|
+
> {
|
|
56
64
|
fieldType: "text" | "email" | "password" | "url" | "textarea";
|
|
57
65
|
value: string;
|
|
58
66
|
onChange: (value: string) => void;
|
|
@@ -61,7 +69,10 @@ export interface TextFieldProps extends Omit<BaseFieldProps, "value" | "onChange
|
|
|
61
69
|
/**
|
|
62
70
|
* Props for number fields
|
|
63
71
|
*/
|
|
64
|
-
export interface NumberFieldProps extends Omit<
|
|
72
|
+
export interface NumberFieldProps extends Omit<
|
|
73
|
+
BaseFieldProps,
|
|
74
|
+
"value" | "onChange"
|
|
75
|
+
> {
|
|
65
76
|
fieldType: "number";
|
|
66
77
|
value: number | null;
|
|
67
78
|
onChange: (value: number | null) => void;
|
|
@@ -73,7 +84,10 @@ export interface NumberFieldProps extends Omit<BaseFieldProps, "value" | "onChan
|
|
|
73
84
|
/**
|
|
74
85
|
* Props for integer fields
|
|
75
86
|
*/
|
|
76
|
-
export interface IntegerFieldProps extends Omit<
|
|
87
|
+
export interface IntegerFieldProps extends Omit<
|
|
88
|
+
BaseFieldProps,
|
|
89
|
+
"value" | "onChange"
|
|
90
|
+
> {
|
|
77
91
|
fieldType: "integer";
|
|
78
92
|
value: number | null;
|
|
79
93
|
onChange: (value: number | null) => void;
|
|
@@ -85,7 +99,10 @@ export interface IntegerFieldProps extends Omit<BaseFieldProps, "value" | "onCha
|
|
|
85
99
|
/**
|
|
86
100
|
* Props for boolean fields
|
|
87
101
|
*/
|
|
88
|
-
export interface BooleanFieldProps extends Omit<
|
|
102
|
+
export interface BooleanFieldProps extends Omit<
|
|
103
|
+
BaseFieldProps,
|
|
104
|
+
"value" | "onChange"
|
|
105
|
+
> {
|
|
89
106
|
fieldType: "boolean";
|
|
90
107
|
value: boolean;
|
|
91
108
|
onChange: (value: boolean) => void;
|
|
@@ -94,7 +111,10 @@ export interface BooleanFieldProps extends Omit<BaseFieldProps, "value" | "onCha
|
|
|
94
111
|
/**
|
|
95
112
|
* Props for date fields
|
|
96
113
|
*/
|
|
97
|
-
export interface DateFieldProps extends Omit<
|
|
114
|
+
export interface DateFieldProps extends Omit<
|
|
115
|
+
BaseFieldProps,
|
|
116
|
+
"value" | "onChange"
|
|
117
|
+
> {
|
|
98
118
|
fieldType: "date";
|
|
99
119
|
value: string | null;
|
|
100
120
|
onChange: (value: string | null) => void;
|
|
@@ -103,7 +123,10 @@ export interface DateFieldProps extends Omit<BaseFieldProps, "value" | "onChange
|
|
|
103
123
|
/**
|
|
104
124
|
* Props for datetime fields
|
|
105
125
|
*/
|
|
106
|
-
export interface DateTimeFieldProps extends Omit<
|
|
126
|
+
export interface DateTimeFieldProps extends Omit<
|
|
127
|
+
BaseFieldProps,
|
|
128
|
+
"value" | "onChange"
|
|
129
|
+
> {
|
|
107
130
|
fieldType: "datetime";
|
|
108
131
|
value: string | null;
|
|
109
132
|
onChange: (value: string | null) => void;
|
|
@@ -112,7 +135,10 @@ export interface DateTimeFieldProps extends Omit<BaseFieldProps, "value" | "onCh
|
|
|
112
135
|
/**
|
|
113
136
|
* Props for select fields (single selection)
|
|
114
137
|
*/
|
|
115
|
-
export interface SelectFieldProps extends Omit<
|
|
138
|
+
export interface SelectFieldProps extends Omit<
|
|
139
|
+
BaseFieldProps,
|
|
140
|
+
"value" | "onChange"
|
|
141
|
+
> {
|
|
116
142
|
fieldType: "select";
|
|
117
143
|
value: string | null;
|
|
118
144
|
onChange: (value: string | null) => void;
|
|
@@ -122,7 +148,10 @@ export interface SelectFieldProps extends Omit<BaseFieldProps, "value" | "onChan
|
|
|
122
148
|
/**
|
|
123
149
|
* Props for multi-select fields
|
|
124
150
|
*/
|
|
125
|
-
export interface MultiSelectFieldProps extends Omit<
|
|
151
|
+
export interface MultiSelectFieldProps extends Omit<
|
|
152
|
+
BaseFieldProps,
|
|
153
|
+
"value" | "onChange"
|
|
154
|
+
> {
|
|
126
155
|
fieldType: "multiselect";
|
|
127
156
|
value: string[];
|
|
128
157
|
onChange: (value: string[]) => void;
|
|
@@ -189,7 +218,10 @@ export interface ArrayHelpers {
|
|
|
189
218
|
/** Swap items at two indices */
|
|
190
219
|
swap: (indexA: number, indexB: number) => void;
|
|
191
220
|
/** Get field props for an item field */
|
|
192
|
-
getItemFieldProps: (
|
|
221
|
+
getItemFieldProps: (
|
|
222
|
+
index: number,
|
|
223
|
+
fieldName: string,
|
|
224
|
+
) => ArrayItemFieldPropsResult;
|
|
193
225
|
/** Minimum number of items allowed */
|
|
194
226
|
minItems: number;
|
|
195
227
|
/** Maximum number of items allowed */
|
|
@@ -203,7 +235,10 @@ export interface ArrayHelpers {
|
|
|
203
235
|
/**
|
|
204
236
|
* Props for array fields
|
|
205
237
|
*/
|
|
206
|
-
export interface ArrayFieldProps extends Omit<
|
|
238
|
+
export interface ArrayFieldProps extends Omit<
|
|
239
|
+
BaseFieldProps,
|
|
240
|
+
"value" | "onChange"
|
|
241
|
+
> {
|
|
207
242
|
fieldType: "array";
|
|
208
243
|
value: unknown[];
|
|
209
244
|
onChange: (value: unknown[]) => void;
|
|
@@ -217,7 +252,10 @@ export interface ArrayFieldProps extends Omit<BaseFieldProps, "value" | "onChang
|
|
|
217
252
|
/**
|
|
218
253
|
* Props for object fields
|
|
219
254
|
*/
|
|
220
|
-
export interface ObjectFieldProps extends Omit<
|
|
255
|
+
export interface ObjectFieldProps extends Omit<
|
|
256
|
+
BaseFieldProps,
|
|
257
|
+
"value" | "onChange"
|
|
258
|
+
> {
|
|
221
259
|
fieldType: "object";
|
|
222
260
|
value: Record<string, unknown>;
|
|
223
261
|
onChange: (value: Record<string, unknown>) => void;
|
|
@@ -236,7 +274,10 @@ export interface ComputedFieldProps extends Omit<BaseFieldProps, "onChange"> {
|
|
|
236
274
|
/**
|
|
237
275
|
* Props for array item fields (within array context)
|
|
238
276
|
*/
|
|
239
|
-
export interface ArrayItemFieldProps extends Omit<
|
|
277
|
+
export interface ArrayItemFieldProps extends Omit<
|
|
278
|
+
BaseFieldProps,
|
|
279
|
+
"value" | "onChange"
|
|
280
|
+
> {
|
|
240
281
|
/** The field type */
|
|
241
282
|
fieldType: string;
|
|
242
283
|
/** Current value */
|
|
@@ -252,7 +293,10 @@ export interface ArrayItemFieldProps extends Omit<BaseFieldProps, "value" | "onC
|
|
|
252
293
|
/**
|
|
253
294
|
* Props for display fields (read-only presentation content)
|
|
254
295
|
*/
|
|
255
|
-
export interface DisplayFieldProps extends Omit<
|
|
296
|
+
export interface DisplayFieldProps extends Omit<
|
|
297
|
+
BaseFieldProps,
|
|
298
|
+
"value" | "onChange"
|
|
299
|
+
> {
|
|
256
300
|
fieldType: "display";
|
|
257
301
|
/** Static content (markdown/text) */
|
|
258
302
|
content?: string;
|
|
@@ -432,6 +476,13 @@ export type { UseFormaReturn as FormState } from "./useForma.js";
|
|
|
432
476
|
export type { UseFormaOptions } from "./useForma.js";
|
|
433
477
|
export type { PageState, WizardHelpers } from "./useForma.js";
|
|
434
478
|
|
|
479
|
+
// Re-export event types
|
|
480
|
+
export type {
|
|
481
|
+
FormaEventMap,
|
|
482
|
+
FormaEvents,
|
|
483
|
+
FormaEventListener,
|
|
484
|
+
} from "./events.js";
|
|
485
|
+
|
|
435
486
|
// Re-export ValidationResult for convenience
|
|
436
487
|
export type { ValidationResult } from "@fogpipe/forma-core";
|
|
437
488
|
|