@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/useForma.ts
CHANGED
|
@@ -25,6 +25,8 @@ import type {
|
|
|
25
25
|
GetSelectFieldPropsResult,
|
|
26
26
|
GetArrayHelpersResult,
|
|
27
27
|
} from "./types.js";
|
|
28
|
+
import { FormaEventEmitter } from "./events.js";
|
|
29
|
+
import type { FormaEventMap, FormaEvents } from "./events.js";
|
|
28
30
|
import {
|
|
29
31
|
getVisibility,
|
|
30
32
|
getRequired,
|
|
@@ -62,6 +64,12 @@ export interface UseFormaOptions {
|
|
|
62
64
|
* Set to 0 (default) for immediate validation.
|
|
63
65
|
*/
|
|
64
66
|
validationDebounceMs?: number;
|
|
67
|
+
/**
|
|
68
|
+
* Declarative event listeners for form lifecycle events.
|
|
69
|
+
* Listeners are stable for the lifetime of the hook — the latest
|
|
70
|
+
* callback is always invoked via refs, without causing dependency changes.
|
|
71
|
+
*/
|
|
72
|
+
on?: FormaEvents;
|
|
65
73
|
}
|
|
66
74
|
|
|
67
75
|
/**
|
|
@@ -167,6 +175,15 @@ export interface UseFormaReturn {
|
|
|
167
175
|
/** Reset the form */
|
|
168
176
|
resetForm: () => void;
|
|
169
177
|
|
|
178
|
+
/**
|
|
179
|
+
* Register an imperative event listener. Returns an unsubscribe function.
|
|
180
|
+
* Multiple listeners per event are supported; they fire in registration order.
|
|
181
|
+
*/
|
|
182
|
+
on: <K extends keyof FormaEventMap>(
|
|
183
|
+
event: K,
|
|
184
|
+
listener: (payload: FormaEventMap[K]) => void | Promise<void>,
|
|
185
|
+
) => () => void;
|
|
186
|
+
|
|
170
187
|
// Helper methods for getting field props
|
|
171
188
|
/** Get props for any field */
|
|
172
189
|
getFieldProps: (path: string) => GetFieldPropsResult;
|
|
@@ -237,6 +254,23 @@ function getDefaultBooleanValues(spec: Forma): Record<string, boolean> {
|
|
|
237
254
|
return defaults;
|
|
238
255
|
}
|
|
239
256
|
|
|
257
|
+
/**
|
|
258
|
+
* Get default values from field definitions.
|
|
259
|
+
* Collects `defaultValue` from each field that specifies one.
|
|
260
|
+
* These are applied after boolean defaults but before initialData,
|
|
261
|
+
* so explicit defaults override type-implicit defaults,
|
|
262
|
+
* and runtime initialData overrides everything.
|
|
263
|
+
*/
|
|
264
|
+
function getFieldDefaults(spec: Forma): Record<string, unknown> {
|
|
265
|
+
const defaults: Record<string, unknown> = {};
|
|
266
|
+
for (const [fieldPath, fieldDef] of Object.entries(spec.fields)) {
|
|
267
|
+
if (fieldDef.defaultValue !== undefined) {
|
|
268
|
+
defaults[fieldPath] = fieldDef.defaultValue;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return defaults;
|
|
272
|
+
}
|
|
273
|
+
|
|
240
274
|
/**
|
|
241
275
|
* Main Forma hook
|
|
242
276
|
*/
|
|
@@ -249,6 +283,7 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
249
283
|
validateOn = "blur",
|
|
250
284
|
referenceData,
|
|
251
285
|
validationDebounceMs = 0,
|
|
286
|
+
on: onEvents,
|
|
252
287
|
} = options;
|
|
253
288
|
|
|
254
289
|
// Merge referenceData from options with spec.referenceData
|
|
@@ -264,7 +299,11 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
264
299
|
}, [inputSpec, referenceData]);
|
|
265
300
|
|
|
266
301
|
const [state, dispatch] = useReducer(formReducer, {
|
|
267
|
-
data: {
|
|
302
|
+
data: {
|
|
303
|
+
...getDefaultBooleanValues(spec),
|
|
304
|
+
...getFieldDefaults(spec),
|
|
305
|
+
...initialData,
|
|
306
|
+
},
|
|
268
307
|
touched: {},
|
|
269
308
|
isSubmitting: false,
|
|
270
309
|
isSubmitted: false,
|
|
@@ -279,6 +318,42 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
279
318
|
// Track if we've initialized (to avoid calling onChange on first render)
|
|
280
319
|
const hasInitialized = useRef(false);
|
|
281
320
|
|
|
321
|
+
// ── Event system ──────────────────────────────────────────────────────
|
|
322
|
+
const emitterRef = useRef(new FormaEventEmitter());
|
|
323
|
+
const onEventsRef = useRef(onEvents);
|
|
324
|
+
onEventsRef.current = onEvents;
|
|
325
|
+
const pendingEventsRef = useRef<
|
|
326
|
+
Array<{ event: keyof FormaEventMap; payload: unknown }>
|
|
327
|
+
>([]);
|
|
328
|
+
const isFiringEventsRef = useRef(false);
|
|
329
|
+
|
|
330
|
+
// Cleanup emitter on unmount
|
|
331
|
+
useEffect(() => {
|
|
332
|
+
const emitter = emitterRef.current;
|
|
333
|
+
return () => {
|
|
334
|
+
emitter.clear();
|
|
335
|
+
};
|
|
336
|
+
}, []);
|
|
337
|
+
|
|
338
|
+
// Helper: fire an event to both declarative `on` handlers and imperative listeners
|
|
339
|
+
const fireEvent = useCallback(
|
|
340
|
+
<K extends keyof FormaEventMap>(
|
|
341
|
+
event: K,
|
|
342
|
+
payload: FormaEventMap[K],
|
|
343
|
+
) => {
|
|
344
|
+
// Declarative handler (via ref for latest callback)
|
|
345
|
+
try {
|
|
346
|
+
const handler = onEventsRef.current?.[event];
|
|
347
|
+
if (handler) (handler as (p: FormaEventMap[K]) => void)(payload);
|
|
348
|
+
} catch (error) {
|
|
349
|
+
console.error(`[forma] Error in "${event}" event handler:`, error);
|
|
350
|
+
}
|
|
351
|
+
// Imperative listeners
|
|
352
|
+
emitterRef.current.fire(event, payload);
|
|
353
|
+
},
|
|
354
|
+
[],
|
|
355
|
+
);
|
|
356
|
+
|
|
282
357
|
// Calculate computed values
|
|
283
358
|
const computed = useMemo(
|
|
284
359
|
() => calculate(state.data, spec),
|
|
@@ -404,24 +479,62 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
404
479
|
[state.data],
|
|
405
480
|
);
|
|
406
481
|
|
|
482
|
+
// Helper to get value at nested path
|
|
483
|
+
// Uses stateDataRef to always access current state, avoiding stale closure issues
|
|
484
|
+
const getValueAtPath = useCallback((path: string): unknown => {
|
|
485
|
+
// Handle array index notation: "items[0].name" -> ["items", "0", "name"]
|
|
486
|
+
const parts = path.replace(/\[(\d+)\]/g, ".$1").split(".");
|
|
487
|
+
let value: unknown = stateDataRef.current;
|
|
488
|
+
for (const part of parts) {
|
|
489
|
+
if (value === null || value === undefined) return undefined;
|
|
490
|
+
value = (value as Record<string, unknown>)[part];
|
|
491
|
+
}
|
|
492
|
+
return value;
|
|
493
|
+
}, []); // No dependencies - uses ref for current state
|
|
494
|
+
|
|
495
|
+
// Queue a fieldChanged event (captures previousValue from current state ref)
|
|
496
|
+
const queueFieldChangedEvent = useCallback(
|
|
497
|
+
(
|
|
498
|
+
path: string,
|
|
499
|
+
value: unknown,
|
|
500
|
+
source: "user" | "reset" | "setValues",
|
|
501
|
+
) => {
|
|
502
|
+
if (isFiringEventsRef.current) return; // recursion guard
|
|
503
|
+
const previousValue = getValueAtPath(path);
|
|
504
|
+
if (previousValue === value) return; // no actual change
|
|
505
|
+
pendingEventsRef.current.push({
|
|
506
|
+
event: "fieldChanged",
|
|
507
|
+
payload: { path, value, previousValue, source },
|
|
508
|
+
});
|
|
509
|
+
},
|
|
510
|
+
[getValueAtPath],
|
|
511
|
+
);
|
|
512
|
+
|
|
407
513
|
// Actions
|
|
408
514
|
const setFieldValue = useCallback(
|
|
409
515
|
(path: string, value: unknown) => {
|
|
516
|
+
queueFieldChangedEvent(path, value, "user");
|
|
410
517
|
setNestedValue(path, value);
|
|
411
518
|
if (validateOn === "change") {
|
|
412
519
|
dispatch({ type: "SET_FIELD_TOUCHED", field: path, touched: true });
|
|
413
520
|
}
|
|
414
521
|
},
|
|
415
|
-
[validateOn, setNestedValue],
|
|
522
|
+
[validateOn, setNestedValue, queueFieldChangedEvent],
|
|
416
523
|
);
|
|
417
524
|
|
|
418
525
|
const setFieldTouched = useCallback((path: string, touched = true) => {
|
|
419
526
|
dispatch({ type: "SET_FIELD_TOUCHED", field: path, touched });
|
|
420
527
|
}, []);
|
|
421
528
|
|
|
422
|
-
const setValues = useCallback(
|
|
423
|
-
|
|
424
|
-
|
|
529
|
+
const setValues = useCallback(
|
|
530
|
+
(values: Record<string, unknown>) => {
|
|
531
|
+
for (const [key, value] of Object.entries(values)) {
|
|
532
|
+
queueFieldChangedEvent(key, value, "setValues");
|
|
533
|
+
}
|
|
534
|
+
dispatch({ type: "SET_VALUES", values });
|
|
535
|
+
},
|
|
536
|
+
[queueFieldChangedEvent],
|
|
537
|
+
);
|
|
425
538
|
|
|
426
539
|
const validateField = useCallback(
|
|
427
540
|
(path: string): FieldError[] => {
|
|
@@ -436,20 +549,97 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
436
549
|
|
|
437
550
|
const submitForm = useCallback(async () => {
|
|
438
551
|
dispatch({ type: "SET_SUBMITTING", isSubmitting: true });
|
|
552
|
+
|
|
553
|
+
const submissionData = { ...state.data };
|
|
554
|
+
let postSubmitPayload: FormaEventMap["postSubmit"] | undefined;
|
|
555
|
+
|
|
439
556
|
try {
|
|
557
|
+
// Fire preSubmit (async, inline — listeners can mutate submissionData)
|
|
558
|
+
const preSubmitPayload = {
|
|
559
|
+
data: submissionData,
|
|
560
|
+
computed: { ...computed },
|
|
561
|
+
};
|
|
562
|
+
// Declarative handler
|
|
563
|
+
const declarativePreSubmit = onEventsRef.current?.preSubmit;
|
|
564
|
+
if (declarativePreSubmit) {
|
|
565
|
+
await declarativePreSubmit(preSubmitPayload);
|
|
566
|
+
}
|
|
567
|
+
// Imperative listeners
|
|
568
|
+
if (emitterRef.current.hasListeners("preSubmit")) {
|
|
569
|
+
await emitterRef.current.fireAsync("preSubmit", preSubmitPayload);
|
|
570
|
+
}
|
|
571
|
+
|
|
440
572
|
// Always use immediate validation on submit to ensure accurate result
|
|
441
|
-
if (immediateValidation.valid
|
|
442
|
-
|
|
573
|
+
if (!immediateValidation.valid) {
|
|
574
|
+
postSubmitPayload = {
|
|
575
|
+
data: submissionData,
|
|
576
|
+
success: false,
|
|
577
|
+
validationErrors: immediateValidation.errors,
|
|
578
|
+
};
|
|
579
|
+
} else if (onSubmit) {
|
|
580
|
+
try {
|
|
581
|
+
await onSubmit(submissionData);
|
|
582
|
+
postSubmitPayload = { data: submissionData, success: true };
|
|
583
|
+
} catch (error) {
|
|
584
|
+
postSubmitPayload = {
|
|
585
|
+
data: submissionData,
|
|
586
|
+
success: false,
|
|
587
|
+
error:
|
|
588
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
} else {
|
|
592
|
+
postSubmitPayload = { data: submissionData, success: true };
|
|
443
593
|
}
|
|
594
|
+
|
|
444
595
|
dispatch({ type: "SET_SUBMITTED", isSubmitted: true });
|
|
445
596
|
} finally {
|
|
446
597
|
dispatch({ type: "SET_SUBMITTING", isSubmitting: false });
|
|
598
|
+
// Fire postSubmit after state updates
|
|
599
|
+
if (postSubmitPayload) {
|
|
600
|
+
fireEvent("postSubmit", postSubmitPayload);
|
|
601
|
+
}
|
|
447
602
|
}
|
|
448
|
-
}, [immediateValidation, onSubmit, state.data]);
|
|
603
|
+
}, [immediateValidation, onSubmit, state.data, computed, fireEvent]);
|
|
449
604
|
|
|
450
605
|
const resetForm = useCallback(() => {
|
|
451
|
-
|
|
452
|
-
|
|
606
|
+
const resetData = {
|
|
607
|
+
...getDefaultBooleanValues(spec),
|
|
608
|
+
...getFieldDefaults(spec),
|
|
609
|
+
...initialData,
|
|
610
|
+
};
|
|
611
|
+
|
|
612
|
+
// Queue fieldChanged for each field that actually changes
|
|
613
|
+
if (!isFiringEventsRef.current) {
|
|
614
|
+
const currentData = stateDataRef.current;
|
|
615
|
+
const allKeys = new Set([
|
|
616
|
+
...Object.keys(currentData),
|
|
617
|
+
...Object.keys(resetData),
|
|
618
|
+
]);
|
|
619
|
+
for (const key of allKeys) {
|
|
620
|
+
const currentVal = currentData[key];
|
|
621
|
+
const resetVal = resetData[key];
|
|
622
|
+
if (currentVal !== resetVal) {
|
|
623
|
+
pendingEventsRef.current.push({
|
|
624
|
+
event: "fieldChanged",
|
|
625
|
+
payload: {
|
|
626
|
+
path: key,
|
|
627
|
+
value: resetVal,
|
|
628
|
+
previousValue: currentVal,
|
|
629
|
+
source: "reset" as const,
|
|
630
|
+
},
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
// Queue formReset (fires after fieldChanged events)
|
|
635
|
+
pendingEventsRef.current.push({
|
|
636
|
+
event: "formReset",
|
|
637
|
+
payload: {} as FormaEventMap["formReset"],
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
dispatch({ type: "RESET", initialData: resetData });
|
|
642
|
+
}, [spec, initialData]);
|
|
453
643
|
|
|
454
644
|
// Wizard helpers
|
|
455
645
|
const wizard = useMemo((): WizardHelpers | null => {
|
|
@@ -491,18 +681,45 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
491
681
|
currentPageIndex: clampedPageIndex,
|
|
492
682
|
currentPage,
|
|
493
683
|
goToPage: (index: number) => {
|
|
494
|
-
// Clamp to valid range
|
|
495
684
|
const validIndex = Math.min(Math.max(0, index), maxPageIndex);
|
|
496
|
-
|
|
685
|
+
if (validIndex !== clampedPageIndex) {
|
|
686
|
+
dispatch({ type: "SET_PAGE", page: validIndex });
|
|
687
|
+
const newPage = visiblePages[validIndex];
|
|
688
|
+
if (newPage) {
|
|
689
|
+
fireEvent("pageChanged", {
|
|
690
|
+
fromIndex: clampedPageIndex,
|
|
691
|
+
toIndex: validIndex,
|
|
692
|
+
page: newPage,
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
}
|
|
497
696
|
},
|
|
498
697
|
nextPage: () => {
|
|
499
698
|
if (hasNextPage) {
|
|
500
|
-
|
|
699
|
+
const toIndex = clampedPageIndex + 1;
|
|
700
|
+
dispatch({ type: "SET_PAGE", page: toIndex });
|
|
701
|
+
const newPage = visiblePages[toIndex];
|
|
702
|
+
if (newPage) {
|
|
703
|
+
fireEvent("pageChanged", {
|
|
704
|
+
fromIndex: clampedPageIndex,
|
|
705
|
+
toIndex,
|
|
706
|
+
page: newPage,
|
|
707
|
+
});
|
|
708
|
+
}
|
|
501
709
|
}
|
|
502
710
|
},
|
|
503
711
|
previousPage: () => {
|
|
504
712
|
if (hasPreviousPage) {
|
|
505
|
-
|
|
713
|
+
const toIndex = clampedPageIndex - 1;
|
|
714
|
+
dispatch({ type: "SET_PAGE", page: toIndex });
|
|
715
|
+
const newPage = visiblePages[toIndex];
|
|
716
|
+
if (newPage) {
|
|
717
|
+
fireEvent("pageChanged", {
|
|
718
|
+
fromIndex: clampedPageIndex,
|
|
719
|
+
toIndex,
|
|
720
|
+
page: newPage,
|
|
721
|
+
});
|
|
722
|
+
}
|
|
506
723
|
}
|
|
507
724
|
},
|
|
508
725
|
hasNextPage,
|
|
@@ -539,53 +756,63 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
539
756
|
return pageErrors.length === 0;
|
|
540
757
|
},
|
|
541
758
|
};
|
|
542
|
-
}, [spec, state.data, state.currentPage, computed, validation, visibility]);
|
|
759
|
+
}, [spec, state.data, state.currentPage, computed, validation, visibility, fireEvent]);
|
|
543
760
|
|
|
544
|
-
//
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
761
|
+
// Flush pending events after render (fieldChanged, formReset)
|
|
762
|
+
useEffect(() => {
|
|
763
|
+
const events = pendingEventsRef.current;
|
|
764
|
+
if (events.length === 0) return;
|
|
765
|
+
pendingEventsRef.current = [];
|
|
766
|
+
|
|
767
|
+
isFiringEventsRef.current = true;
|
|
768
|
+
try {
|
|
769
|
+
for (const pending of events) {
|
|
770
|
+
fireEvent(
|
|
771
|
+
pending.event as keyof FormaEventMap,
|
|
772
|
+
pending.payload as FormaEventMap[keyof FormaEventMap],
|
|
773
|
+
);
|
|
774
|
+
}
|
|
775
|
+
} finally {
|
|
776
|
+
isFiringEventsRef.current = false;
|
|
553
777
|
}
|
|
554
|
-
|
|
555
|
-
}, []); // No dependencies - uses ref for current state
|
|
778
|
+
});
|
|
556
779
|
|
|
557
780
|
// Helper to set value at nested path
|
|
558
781
|
// Uses stateDataRef to always access current state, avoiding stale closure issues
|
|
559
|
-
const setValueAtPath = useCallback(
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
const newData = { ...stateDataRef.current };
|
|
569
|
-
let current: Record<string, unknown> = newData;
|
|
570
|
-
|
|
571
|
-
for (let i = 0; i < parts.length - 1; i++) {
|
|
572
|
-
const part = parts[i];
|
|
573
|
-
const nextPart = parts[i + 1];
|
|
574
|
-
const isNextArrayIndex = /^\d+$/.test(nextPart);
|
|
782
|
+
const setValueAtPath = useCallback(
|
|
783
|
+
(path: string, value: unknown): void => {
|
|
784
|
+
queueFieldChangedEvent(path, value, "user");
|
|
785
|
+
// For nested paths, we need to build the nested structure
|
|
786
|
+
const parts = path.replace(/\[(\d+)\]/g, ".$1").split(".");
|
|
787
|
+
if (parts.length === 1) {
|
|
788
|
+
dispatch({ type: "SET_FIELD_VALUE", field: path, value });
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
575
791
|
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
792
|
+
// Build nested object from CURRENT state via ref (not stale closure)
|
|
793
|
+
const newData = { ...stateDataRef.current };
|
|
794
|
+
let current: Record<string, unknown> = newData;
|
|
795
|
+
|
|
796
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
797
|
+
const part = parts[i];
|
|
798
|
+
const nextPart = parts[i + 1];
|
|
799
|
+
const isNextArrayIndex = /^\d+$/.test(nextPart);
|
|
800
|
+
|
|
801
|
+
if (current[part] === undefined) {
|
|
802
|
+
current[part] = isNextArrayIndex ? [] : {};
|
|
803
|
+
} else if (Array.isArray(current[part])) {
|
|
804
|
+
current[part] = [...(current[part] as unknown[])];
|
|
805
|
+
} else {
|
|
806
|
+
current[part] = { ...(current[part] as Record<string, unknown>) };
|
|
807
|
+
}
|
|
808
|
+
current = current[part] as Record<string, unknown>;
|
|
582
809
|
}
|
|
583
|
-
current = current[part] as Record<string, unknown>;
|
|
584
|
-
}
|
|
585
810
|
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
811
|
+
current[parts[parts.length - 1]] = value;
|
|
812
|
+
dispatch({ type: "SET_VALUES", values: newData });
|
|
813
|
+
},
|
|
814
|
+
[queueFieldChangedEvent],
|
|
815
|
+
);
|
|
589
816
|
|
|
590
817
|
// Memoized onChange/onBlur handlers for fields
|
|
591
818
|
const fieldHandlers = useRef<
|
|
@@ -857,6 +1084,16 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
857
1084
|
],
|
|
858
1085
|
);
|
|
859
1086
|
|
|
1087
|
+
// Stable reference for imperative event subscription — only depends on the
|
|
1088
|
+
// emitter ref, so consumers can safely use it as a useEffect dependency.
|
|
1089
|
+
const on = useCallback(
|
|
1090
|
+
<K extends keyof FormaEventMap>(
|
|
1091
|
+
event: K,
|
|
1092
|
+
listener: (payload: FormaEventMap[K]) => void | Promise<void>,
|
|
1093
|
+
) => emitterRef.current.on(event, listener),
|
|
1094
|
+
[],
|
|
1095
|
+
);
|
|
1096
|
+
|
|
860
1097
|
return useMemo(
|
|
861
1098
|
(): UseFormaReturn => ({
|
|
862
1099
|
data: state.data,
|
|
@@ -881,6 +1118,7 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
881
1118
|
validateForm,
|
|
882
1119
|
submitForm,
|
|
883
1120
|
resetForm,
|
|
1121
|
+
on,
|
|
884
1122
|
getFieldProps,
|
|
885
1123
|
getSelectFieldProps,
|
|
886
1124
|
getArrayHelpers,
|
|
@@ -908,6 +1146,7 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
908
1146
|
validateForm,
|
|
909
1147
|
submitForm,
|
|
910
1148
|
resetForm,
|
|
1149
|
+
on,
|
|
911
1150
|
getFieldProps,
|
|
912
1151
|
getSelectFieldProps,
|
|
913
1152
|
getArrayHelpers,
|