@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/dist/index.js CHANGED
@@ -8,6 +8,74 @@ import {
8
8
  useState
9
9
  } from "react";
10
10
  import { isAdornableField } from "@fogpipe/forma-core";
11
+
12
+ // src/events.ts
13
+ var FormaEventEmitter = class {
14
+ listeners = /* @__PURE__ */ new Map();
15
+ /**
16
+ * Register a listener for an event. Returns an unsubscribe function.
17
+ */
18
+ on(event, listener) {
19
+ if (!this.listeners.has(event)) {
20
+ this.listeners.set(event, /* @__PURE__ */ new Set());
21
+ }
22
+ this.listeners.get(event).add(listener);
23
+ return () => {
24
+ const set = this.listeners.get(event);
25
+ if (set) {
26
+ set.delete(listener);
27
+ if (set.size === 0) {
28
+ this.listeners.delete(event);
29
+ }
30
+ }
31
+ };
32
+ }
33
+ /**
34
+ * Fire an event synchronously. Listener errors are caught and logged
35
+ * to prevent one listener from breaking others.
36
+ */
37
+ fire(event, payload) {
38
+ const set = this.listeners.get(event);
39
+ if (!set || set.size === 0) return;
40
+ for (const listener of set) {
41
+ try {
42
+ listener(payload);
43
+ } catch (error) {
44
+ console.error(`[forma] Error in "${event}" event listener:`, error);
45
+ }
46
+ }
47
+ }
48
+ /**
49
+ * Fire an event and await all async listeners sequentially.
50
+ * Used for preSubmit where handlers can be async.
51
+ */
52
+ async fireAsync(event, payload) {
53
+ const set = this.listeners.get(event);
54
+ if (!set || set.size === 0) return;
55
+ for (const listener of set) {
56
+ try {
57
+ await listener(payload);
58
+ } catch (error) {
59
+ console.error(`[forma] Error in "${event}" event listener:`, error);
60
+ }
61
+ }
62
+ }
63
+ /**
64
+ * Check if any listeners are registered for an event.
65
+ */
66
+ hasListeners(event) {
67
+ const set = this.listeners.get(event);
68
+ return set !== void 0 && set.size > 0;
69
+ }
70
+ /**
71
+ * Remove all listeners. Called on cleanup.
72
+ */
73
+ clear() {
74
+ this.listeners.clear();
75
+ }
76
+ };
77
+
78
+ // src/useForma.ts
11
79
  import {
12
80
  getVisibility,
13
81
  getRequired,
@@ -72,6 +140,15 @@ function getDefaultBooleanValues(spec) {
72
140
  }
73
141
  return defaults;
74
142
  }
143
+ function getFieldDefaults(spec) {
144
+ const defaults = {};
145
+ for (const [fieldPath, fieldDef] of Object.entries(spec.fields)) {
146
+ if (fieldDef.defaultValue !== void 0) {
147
+ defaults[fieldPath] = fieldDef.defaultValue;
148
+ }
149
+ }
150
+ return defaults;
151
+ }
75
152
  function useForma(options) {
76
153
  const {
77
154
  spec: inputSpec,
@@ -80,7 +157,8 @@ function useForma(options) {
80
157
  onChange,
81
158
  validateOn = "blur",
82
159
  referenceData,
83
- validationDebounceMs = 0
160
+ validationDebounceMs = 0,
161
+ on: onEvents
84
162
  } = options;
85
163
  const spec = useMemo(() => {
86
164
  if (!referenceData) return inputSpec;
@@ -93,8 +171,11 @@ function useForma(options) {
93
171
  };
94
172
  }, [inputSpec, referenceData]);
95
173
  const [state, dispatch] = useReducer(formReducer, {
96
- data: { ...getDefaultBooleanValues(spec), ...initialData },
97
- // Boolean defaults merged UNDER initialData
174
+ data: {
175
+ ...getDefaultBooleanValues(spec),
176
+ ...getFieldDefaults(spec),
177
+ ...initialData
178
+ },
98
179
  touched: {},
99
180
  isSubmitting: false,
100
181
  isSubmitted: false,
@@ -104,6 +185,30 @@ function useForma(options) {
104
185
  const stateDataRef = useRef(state.data);
105
186
  stateDataRef.current = state.data;
106
187
  const hasInitialized = useRef(false);
188
+ const emitterRef = useRef(new FormaEventEmitter());
189
+ const onEventsRef = useRef(onEvents);
190
+ onEventsRef.current = onEvents;
191
+ const pendingEventsRef = useRef([]);
192
+ const isFiringEventsRef = useRef(false);
193
+ useEffect(() => {
194
+ const emitter = emitterRef.current;
195
+ return () => {
196
+ emitter.clear();
197
+ };
198
+ }, []);
199
+ const fireEvent = useCallback(
200
+ (event, payload) => {
201
+ var _a;
202
+ try {
203
+ const handler = (_a = onEventsRef.current) == null ? void 0 : _a[event];
204
+ if (handler) handler(payload);
205
+ } catch (error) {
206
+ console.error(`[forma] Error in "${event}" event handler:`, error);
207
+ }
208
+ emitterRef.current.fire(event, payload);
209
+ },
210
+ []
211
+ );
107
212
  const computed = useMemo(
108
213
  () => calculate(state.data, spec),
109
214
  [state.data, spec]
@@ -184,21 +289,49 @@ function useForma(options) {
184
289
  },
185
290
  [state.data]
186
291
  );
292
+ const getValueAtPath = useCallback((path) => {
293
+ const parts = path.replace(/\[(\d+)\]/g, ".$1").split(".");
294
+ let value = stateDataRef.current;
295
+ for (const part of parts) {
296
+ if (value === null || value === void 0) return void 0;
297
+ value = value[part];
298
+ }
299
+ return value;
300
+ }, []);
301
+ const queueFieldChangedEvent = useCallback(
302
+ (path, value, source) => {
303
+ if (isFiringEventsRef.current) return;
304
+ const previousValue = getValueAtPath(path);
305
+ if (previousValue === value) return;
306
+ pendingEventsRef.current.push({
307
+ event: "fieldChanged",
308
+ payload: { path, value, previousValue, source }
309
+ });
310
+ },
311
+ [getValueAtPath]
312
+ );
187
313
  const setFieldValue = useCallback(
188
314
  (path, value) => {
315
+ queueFieldChangedEvent(path, value, "user");
189
316
  setNestedValue(path, value);
190
317
  if (validateOn === "change") {
191
318
  dispatch({ type: "SET_FIELD_TOUCHED", field: path, touched: true });
192
319
  }
193
320
  },
194
- [validateOn, setNestedValue]
321
+ [validateOn, setNestedValue, queueFieldChangedEvent]
195
322
  );
196
323
  const setFieldTouched = useCallback((path, touched = true) => {
197
324
  dispatch({ type: "SET_FIELD_TOUCHED", field: path, touched });
198
325
  }, []);
199
- const setValues = useCallback((values) => {
200
- dispatch({ type: "SET_VALUES", values });
201
- }, []);
326
+ const setValues = useCallback(
327
+ (values) => {
328
+ for (const [key, value] of Object.entries(values)) {
329
+ queueFieldChangedEvent(key, value, "setValues");
330
+ }
331
+ dispatch({ type: "SET_VALUES", values });
332
+ },
333
+ [queueFieldChangedEvent]
334
+ );
202
335
  const validateField = useCallback(
203
336
  (path) => {
204
337
  return validation.errors.filter((e) => e.field === path);
@@ -209,19 +342,84 @@ function useForma(options) {
209
342
  return validation;
210
343
  }, [validation]);
211
344
  const submitForm = useCallback(async () => {
345
+ var _a;
212
346
  dispatch({ type: "SET_SUBMITTING", isSubmitting: true });
347
+ const submissionData = { ...state.data };
348
+ let postSubmitPayload;
213
349
  try {
214
- if (immediateValidation.valid && onSubmit) {
215
- await onSubmit(state.data);
350
+ const preSubmitPayload = {
351
+ data: submissionData,
352
+ computed: { ...computed }
353
+ };
354
+ const declarativePreSubmit = (_a = onEventsRef.current) == null ? void 0 : _a.preSubmit;
355
+ if (declarativePreSubmit) {
356
+ await declarativePreSubmit(preSubmitPayload);
357
+ }
358
+ if (emitterRef.current.hasListeners("preSubmit")) {
359
+ await emitterRef.current.fireAsync("preSubmit", preSubmitPayload);
360
+ }
361
+ if (!immediateValidation.valid) {
362
+ postSubmitPayload = {
363
+ data: submissionData,
364
+ success: false,
365
+ validationErrors: immediateValidation.errors
366
+ };
367
+ } else if (onSubmit) {
368
+ try {
369
+ await onSubmit(submissionData);
370
+ postSubmitPayload = { data: submissionData, success: true };
371
+ } catch (error) {
372
+ postSubmitPayload = {
373
+ data: submissionData,
374
+ success: false,
375
+ error: error instanceof Error ? error : new Error(String(error))
376
+ };
377
+ }
378
+ } else {
379
+ postSubmitPayload = { data: submissionData, success: true };
216
380
  }
217
381
  dispatch({ type: "SET_SUBMITTED", isSubmitted: true });
218
382
  } finally {
219
383
  dispatch({ type: "SET_SUBMITTING", isSubmitting: false });
384
+ if (postSubmitPayload) {
385
+ fireEvent("postSubmit", postSubmitPayload);
386
+ }
220
387
  }
221
- }, [immediateValidation, onSubmit, state.data]);
388
+ }, [immediateValidation, onSubmit, state.data, computed, fireEvent]);
222
389
  const resetForm = useCallback(() => {
223
- dispatch({ type: "RESET", initialData });
224
- }, [initialData]);
390
+ const resetData = {
391
+ ...getDefaultBooleanValues(spec),
392
+ ...getFieldDefaults(spec),
393
+ ...initialData
394
+ };
395
+ if (!isFiringEventsRef.current) {
396
+ const currentData = stateDataRef.current;
397
+ const allKeys = /* @__PURE__ */ new Set([
398
+ ...Object.keys(currentData),
399
+ ...Object.keys(resetData)
400
+ ]);
401
+ for (const key of allKeys) {
402
+ const currentVal = currentData[key];
403
+ const resetVal = resetData[key];
404
+ if (currentVal !== resetVal) {
405
+ pendingEventsRef.current.push({
406
+ event: "fieldChanged",
407
+ payload: {
408
+ path: key,
409
+ value: resetVal,
410
+ previousValue: currentVal,
411
+ source: "reset"
412
+ }
413
+ });
414
+ }
415
+ }
416
+ pendingEventsRef.current.push({
417
+ event: "formReset",
418
+ payload: {}
419
+ });
420
+ }
421
+ dispatch({ type: "RESET", initialData: resetData });
422
+ }, [spec, initialData]);
225
423
  const wizard = useMemo(() => {
226
424
  if (!spec.pages || spec.pages.length === 0) return null;
227
425
  const pageVisibility = getPageVisibility(state.data, spec, { computed });
@@ -251,16 +449,44 @@ function useForma(options) {
251
449
  currentPage,
252
450
  goToPage: (index) => {
253
451
  const validIndex = Math.min(Math.max(0, index), maxPageIndex);
254
- dispatch({ type: "SET_PAGE", page: validIndex });
452
+ if (validIndex !== clampedPageIndex) {
453
+ dispatch({ type: "SET_PAGE", page: validIndex });
454
+ const newPage = visiblePages[validIndex];
455
+ if (newPage) {
456
+ fireEvent("pageChanged", {
457
+ fromIndex: clampedPageIndex,
458
+ toIndex: validIndex,
459
+ page: newPage
460
+ });
461
+ }
462
+ }
255
463
  },
256
464
  nextPage: () => {
257
465
  if (hasNextPage) {
258
- dispatch({ type: "SET_PAGE", page: clampedPageIndex + 1 });
466
+ const toIndex = clampedPageIndex + 1;
467
+ dispatch({ type: "SET_PAGE", page: toIndex });
468
+ const newPage = visiblePages[toIndex];
469
+ if (newPage) {
470
+ fireEvent("pageChanged", {
471
+ fromIndex: clampedPageIndex,
472
+ toIndex,
473
+ page: newPage
474
+ });
475
+ }
259
476
  }
260
477
  },
261
478
  previousPage: () => {
262
479
  if (hasPreviousPage) {
263
- dispatch({ type: "SET_PAGE", page: clampedPageIndex - 1 });
480
+ const toIndex = clampedPageIndex - 1;
481
+ dispatch({ type: "SET_PAGE", page: toIndex });
482
+ const newPage = visiblePages[toIndex];
483
+ if (newPage) {
484
+ fireEvent("pageChanged", {
485
+ fromIndex: clampedPageIndex,
486
+ toIndex,
487
+ page: newPage
488
+ });
489
+ }
264
490
  }
265
491
  },
266
492
  hasNextPage,
@@ -291,40 +517,51 @@ function useForma(options) {
291
517
  return pageErrors.length === 0;
292
518
  }
293
519
  };
294
- }, [spec, state.data, state.currentPage, computed, validation, visibility]);
295
- const getValueAtPath = useCallback((path) => {
296
- const parts = path.replace(/\[(\d+)\]/g, ".$1").split(".");
297
- let value = stateDataRef.current;
298
- for (const part of parts) {
299
- if (value === null || value === void 0) return void 0;
300
- value = value[part];
301
- }
302
- return value;
303
- }, []);
304
- const setValueAtPath = useCallback((path, value) => {
305
- const parts = path.replace(/\[(\d+)\]/g, ".$1").split(".");
306
- if (parts.length === 1) {
307
- dispatch({ type: "SET_FIELD_VALUE", field: path, value });
308
- return;
309
- }
310
- const newData = { ...stateDataRef.current };
311
- let current = newData;
312
- for (let i = 0; i < parts.length - 1; i++) {
313
- const part = parts[i];
314
- const nextPart = parts[i + 1];
315
- const isNextArrayIndex = /^\d+$/.test(nextPart);
316
- if (current[part] === void 0) {
317
- current[part] = isNextArrayIndex ? [] : {};
318
- } else if (Array.isArray(current[part])) {
319
- current[part] = [...current[part]];
320
- } else {
321
- current[part] = { ...current[part] };
520
+ }, [spec, state.data, state.currentPage, computed, validation, visibility, fireEvent]);
521
+ useEffect(() => {
522
+ const events = pendingEventsRef.current;
523
+ if (events.length === 0) return;
524
+ pendingEventsRef.current = [];
525
+ isFiringEventsRef.current = true;
526
+ try {
527
+ for (const pending of events) {
528
+ fireEvent(
529
+ pending.event,
530
+ pending.payload
531
+ );
322
532
  }
323
- current = current[part];
533
+ } finally {
534
+ isFiringEventsRef.current = false;
324
535
  }
325
- current[parts[parts.length - 1]] = value;
326
- dispatch({ type: "SET_VALUES", values: newData });
327
- }, []);
536
+ });
537
+ const setValueAtPath = useCallback(
538
+ (path, value) => {
539
+ queueFieldChangedEvent(path, value, "user");
540
+ const parts = path.replace(/\[(\d+)\]/g, ".$1").split(".");
541
+ if (parts.length === 1) {
542
+ dispatch({ type: "SET_FIELD_VALUE", field: path, value });
543
+ return;
544
+ }
545
+ const newData = { ...stateDataRef.current };
546
+ let current = newData;
547
+ for (let i = 0; i < parts.length - 1; i++) {
548
+ const part = parts[i];
549
+ const nextPart = parts[i + 1];
550
+ const isNextArrayIndex = /^\d+$/.test(nextPart);
551
+ if (current[part] === void 0) {
552
+ current[part] = isNextArrayIndex ? [] : {};
553
+ } else if (Array.isArray(current[part])) {
554
+ current[part] = [...current[part]];
555
+ } else {
556
+ current[part] = { ...current[part] };
557
+ }
558
+ current = current[part];
559
+ }
560
+ current[parts[parts.length - 1]] = value;
561
+ dispatch({ type: "SET_VALUES", values: newData });
562
+ },
563
+ [queueFieldChangedEvent]
564
+ );
328
565
  const fieldHandlers = useRef(/* @__PURE__ */ new Map());
329
566
  useEffect(() => {
330
567
  const validFields = new Set(spec.fieldOrder);
@@ -544,6 +781,10 @@ function useForma(options) {
544
781
  optionsVisibility
545
782
  ]
546
783
  );
784
+ const on = useCallback(
785
+ (event, listener) => emitterRef.current.on(event, listener),
786
+ []
787
+ );
547
788
  return useMemo(
548
789
  () => ({
549
790
  data: state.data,
@@ -568,6 +809,7 @@ function useForma(options) {
568
809
  validateForm,
569
810
  submitForm,
570
811
  resetForm,
812
+ on,
571
813
  getFieldProps,
572
814
  getSelectFieldProps,
573
815
  getArrayHelpers
@@ -595,6 +837,7 @@ function useForma(options) {
595
837
  validateForm,
596
838
  submitForm,
597
839
  resetForm,
840
+ on,
598
841
  getFieldProps,
599
842
  getSelectFieldProps,
600
843
  getArrayHelpers
@@ -618,7 +861,9 @@ var FormaContext = createContext(null);
618
861
  function useFormaContext() {
619
862
  const context = useContext(FormaContext);
620
863
  if (!context) {
621
- throw new Error("useFormaContext must be used within a FormaContext.Provider");
864
+ throw new Error(
865
+ "useFormaContext must be used within a FormaContext.Provider"
866
+ );
622
867
  }
623
868
  return context;
624
869
  }
@@ -699,7 +944,9 @@ function getNumberConstraints(schema) {
699
944
  function createDefaultItem(itemFields) {
700
945
  const item = {};
701
946
  for (const [fieldName, fieldDef] of Object.entries(itemFields)) {
702
- if (fieldDef.type === "boolean") {
947
+ if (fieldDef.defaultValue !== void 0) {
948
+ item[fieldName] = fieldDef.defaultValue;
949
+ } else if (fieldDef.type === "boolean") {
703
950
  item[fieldName] = false;
704
951
  } else if (fieldDef.type === "number" || fieldDef.type === "integer") {
705
952
  item[fieldName] = null;
@@ -1006,7 +1253,9 @@ function getNumberConstraints2(schema) {
1006
1253
  function createDefaultItem2(itemFields) {
1007
1254
  const item = {};
1008
1255
  for (const [fieldName, fieldDef] of Object.entries(itemFields)) {
1009
- if (fieldDef.type === "boolean") {
1256
+ if (fieldDef.defaultValue !== void 0) {
1257
+ item[fieldName] = fieldDef.defaultValue;
1258
+ } else if (fieldDef.type === "boolean") {
1010
1259
  item[fieldName] = false;
1011
1260
  } else if (fieldDef.type === "number" || fieldDef.type === "integer") {
1012
1261
  item[fieldName] = null;
@@ -1227,7 +1476,10 @@ function FieldRenderer({
1227
1476
  // src/ErrorBoundary.tsx
1228
1477
  import React3 from "react";
1229
1478
  import { jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
1230
- function DefaultErrorFallback({ error, onReset }) {
1479
+ function DefaultErrorFallback({
1480
+ error,
1481
+ onReset
1482
+ }) {
1231
1483
  return /* @__PURE__ */ jsxs2("div", { className: "forma-error-boundary", role: "alert", children: [
1232
1484
  /* @__PURE__ */ jsx3("h3", { children: "Something went wrong" }),
1233
1485
  /* @__PURE__ */ jsx3("p", { children: "An error occurred while rendering the form." }),