@bgroup/wise-form 1.0.5 → 1.0.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bgroup/wise-form",
3
- "version": "1.0.5",
3
+ "version": "1.0.6",
4
4
  "description": "Wise Form - A reactive form library",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -2,10 +2,20 @@ import React from 'react';
2
2
  import { useModel } from './hooks/use-model';
3
3
  import { WiseFormContext } from './context';
4
4
  import { useTypes } from './hooks/use-types';
5
+ import type { FormModel } from '@bgroup/wise-form/models';
5
6
 
6
7
  import { IWiseFormSpecs } from '../interfaces/wise-form-specs';
7
8
  import { Containers } from './components/containers';
8
9
 
10
+ /**
11
+ * Interfaz extendida para FormModel que puede tener el método onSubmit
12
+ * Esto permite que las implementaciones extendidas de FormModel (como en frontend)
13
+ * puedan usar el método onSubmit sin errores de tipado
14
+ */
15
+ interface IFormModelWithSubmit extends FormModel {
16
+ onSubmit?: (event: Event | React.FormEvent) => Promise<{ status: boolean; error?: Error }> | void;
17
+ }
18
+
9
19
  export function WiseForm({ children, settings, types, model }: IWiseFormSpecs): JSX.Element {
10
20
  const { ready, model: instance, type, styles, items } = useModel(settings, model);
11
21
  const formTypes = useTypes(types);
@@ -30,10 +40,14 @@ export function WiseForm({ children, settings, types, model }: IWiseFormSpecs):
30
40
 
31
41
  const onSubmit = (event: React.FormEvent) => {
32
42
  event.preventDefault();
33
- // Form submission can be handled via callbacks or custom handlers
34
- if (instance.callbacks?.onSubmit) {
35
- instance.callbacks.onSubmit({ form: instance, event });
43
+
44
+ // Verificar si la instancia tiene el método onSubmit (implementación extendida)
45
+ const modelWithSubmit = instance as IFormModelWithSubmit;
46
+ if (typeof modelWithSubmit.onSubmit === 'function') {
47
+ modelWithSubmit.onSubmit(event);
48
+ return;
36
49
  }
50
+
37
51
  };
38
52
 
39
53
  const value = {
@@ -97,7 +97,12 @@ export class FormulaArray {
97
97
 
98
98
  async calculate() {
99
99
  const formulaField = this.#plugin.form.getField(this.name);
100
- if (!formulaField) return;
100
+ if (!formulaField) {
101
+ console.warn(
102
+ `[WiseForm.FormulaArray] Formula field "${this.name}" not found. Check that the field exists in the form configuration.`
103
+ );
104
+ return;
105
+ }
101
106
  const value = formulaField[this.#specs.propertyValue || 'entries']
102
107
  if (!value || !Array.isArray(value) || !value.length) {
103
108
  formulaField.set({ [this.#specs.propertyValue]: [] });
@@ -100,28 +100,54 @@ export class BaseWiseModel extends ReactiveModel<IBaseWiseModel> {
100
100
  /**
101
101
  * Retrieves a field or nested wrapper by name. Supports dot notation for accessing deeply nested fields.
102
102
  * @param {string} name - The name of the field or nested wrapper to retrieve.
103
+ * @param {Set<BaseWiseModel | WrappedFormModel>} visited - Set of already visited models to prevent infinite recursion.
103
104
  * @returns {FormField | WrappedFormModel | undefined} The requested instance, or undefined if not found.
104
105
  */
105
- getField(name: string) {
106
- if (!name) return console.warn('You need to provide a name to get a field in form ', this.#settings.name);
106
+ getField(name: string, visited: Set<BaseWiseModel | WrappedFormModel> = new Set()) {
107
+ if (!name) {
108
+ console.warn('[WiseForm.getField] Empty field name provided in form', this.#settings.name);
109
+ return;
110
+ }
111
+
112
+ // Protección contra recursión circular
113
+ if (visited.has(this)) {
114
+ console.error(
115
+ `[WiseForm.getField] Circular reference detected in form "${this.#settings.name}" while searching for field "${name}". This usually indicates a configuration issue with nested wrappers.`
116
+ );
117
+ console.log(
118
+ `[WiseForm.getField] PROTECTION ACTIVE: Circular reference prevented. Form: "${this.#settings.name}", Field: "${name}", Visited models count: ${visited.size}`
119
+ );
120
+ return undefined;
121
+ }
122
+ visited.add(this);
107
123
 
108
124
  if (!name.includes('.')) {
109
125
  let field = this.#fields.get(name);
110
126
 
111
127
  if (!field) {
112
128
  this.#wrappers.forEach(item => {
113
- const foundField = item.getField(name);
114
- if (foundField) field = foundField;
129
+ if (!visited.has(item)) {
130
+ const foundField = item.getField(name, visited);
131
+ if (foundField) field = foundField;
132
+ }
115
133
  });
116
134
  }
117
135
  return field;
118
136
  }
119
137
 
138
+ // Dot notation path
120
139
  const [wrapperName, ...others] = name.split('.');
121
140
  const currentWrapper = this.#wrappers.get(wrapperName);
122
141
 
142
+ if (!currentWrapper) {
143
+ console.warn(
144
+ `[WiseForm.getField] Wrapper "${wrapperName}" not found in form "${this.#settings.name}" while searching for "${name}". Available wrappers: ${Array.from(this.#wrappers.keys()).join(', ') || 'none'}`
145
+ );
146
+ return undefined;
147
+ }
148
+
123
149
  const otherWrapper = others.join('.');
124
- return currentWrapper.getField(otherWrapper);
150
+ return currentWrapper.getField(otherWrapper, visited);
125
151
  }
126
152
 
127
153
  /**
@@ -2,101 +2,179 @@ import type { FormField } from './field';
2
2
  import type { FormModel } from './model';
3
3
  import { CallbackFunction, ICallbackProps } from './types/callbacks';
4
4
 
5
+ /**
6
+ * Maximum number of recursive executions allowed for a callback before preventing further execution.
7
+ * This can be overridden if specific use cases require a higher limit.
8
+ */
9
+ export const MAX_CALLBACK_RECURSION_DEPTH = 3;
10
+
11
+ /**
12
+ * Map to track callback execution depth per unique execution context.
13
+ * Key format: "callbackName:fieldName:dependencyName" or "callbackName:fieldName" if no dependency
14
+ */
15
+ const callbackExecutionDepth = new Map<string, number>();
16
+
17
+ /**
18
+ * Set to track callbacks currently executing (prevents concurrent executions of the same callback)
19
+ */
20
+ const currentlyExecutingCallbacks = new Set<string>();
21
+
5
22
  export class CallbackManager {
6
- #field: FormField;
7
- #model: FormModel;
8
- #callbacks: CallbackFunction[] = [];
9
- #listeners = [];
10
- constructor(model, field) {
11
- this.#field = field;
12
- this.#model = model;
13
- this.#callbacks = model.callbacks;
14
- this.initialize();
23
+ #field: FormField;
24
+ #model: FormModel;
25
+ #callbacks: CallbackFunction[] = [];
26
+ #listeners = [];
27
+ constructor(model, field) {
28
+ this.#field = field;
29
+ this.#model = model;
30
+ this.#callbacks = model.callbacks;
31
+ this.initialize();
32
+ }
33
+
34
+ initialize() {
35
+ const instance = this.#field;
36
+ const checkField = async settings => {
37
+ const dependency = this.#model.getField(this.#model.getFieldName(settings.field));
38
+ await dependency.isReady;
39
+ const required = ['field', 'callback'];
40
+ required.forEach(prop => {
41
+ if (!settings[prop]) throw new Error(`${settings?.field} is missing ${prop}`);
42
+ });
43
+
44
+ if (!dependency) throw new Error(`${settings?.field} is not a registered field`);
45
+
46
+ if (!this.#callbacks[settings.callback]) {
47
+ throw new Error(`${settings.callback} is not a registered callback ${settings.name}`);
48
+ }
49
+
50
+ // saved in listener array to be able to remove the listener if is required.
51
+ const event = settings.event || 'value.change';
52
+ const caller = () => this.executeCallback(settings);
53
+ this.#listeners.push(caller);
54
+ dependency.on(event, caller);
55
+
56
+ //callback({ dependency, settings, field: instance, form: this });
57
+ };
58
+
59
+ instance?.specs?.dependentOn.forEach(checkField);
60
+ }
61
+
62
+ /**
63
+ * Generates a unique execution key for tracking callback recursion.
64
+ * Format: "callbackName:fieldName:dependencyName"
65
+ */
66
+ #getExecutionKey(callbackName: string, fieldName: string, dependencyName?: string): string {
67
+ const fieldNameStr = fieldName || 'unknown';
68
+ const dependencyNameStr = dependencyName || 'unknown';
69
+ return `${callbackName}:${fieldNameStr}:${dependencyNameStr}`;
70
+ }
71
+
72
+ executeCallback = async settings => {
73
+ if (!settings) {
74
+ console.warn('the field does not have dependentOn settings');
75
+ return;
15
76
  }
16
77
 
17
- initialize() {
18
- const instance = this.#field;
19
- const checkField = async (settings) => {
20
- const dependency = this.#model.getField(
21
- this.#model.getFieldName(settings.field)
22
- );
23
- await dependency.isReady;
24
- const required = ['field', 'callback'];
25
- required.forEach((prop) => {
26
- if (!settings[prop])
27
- throw new Error(`${settings?.field} is missing ${prop}`);
28
- });
29
-
30
- if (!dependency)
31
- throw new Error(`${settings?.field} is not a registered field`);
32
-
33
- if (!this.#callbacks[settings.callback]) {
34
- throw new Error(
35
- `${settings.callback} is not a registered callback ${settings.name}`
36
- );
37
- }
38
-
39
- // saved in listener array to be able to remove the listener if is required.
40
- const event = settings.event || 'value.change';
41
- const caller = () => this.executeCallback(settings);
42
- this.#listeners.push(caller);
43
- dependency.on(event, caller);
44
-
45
- //callback({ dependency, settings, field: instance, form: this });
46
- };
47
-
48
- instance?.specs?.dependentOn.forEach(checkField);
78
+ const callbackName = settings.callback;
79
+ if (!callbackName) {
80
+ console.warn('[CallbackManager] executeCallback called without callback name in settings');
81
+ return;
49
82
  }
50
83
 
51
- executeCallback = async (settings) => {
52
- const params: ICallbackProps = {
53
- ...settings,
54
- form: this.#model,
55
- field: this.#field,
56
-
57
- settings,
58
- };
59
- if (!settings) {
60
- console.warn('the field does not have dependentOn settings');
61
- }
62
- const callback: CallbackFunction = this.#callbacks[settings.callback];
63
-
64
- const dependency = this.#model.getField(
65
- this.#model.getFieldName(settings.field)
66
- );
67
- await dependency.isReady;
68
- const fields = { [dependency.name]: dependency };
69
- if (settings.hasOwnProperty('fields')) {
70
- for (const field of settings.fields) {
71
- const instance = this.#model.getField(
72
- this.#model.getFieldName(field)
73
- );
74
- if (instance) await instance.isReady;
75
- const propName =
76
- typeof field === 'string' ? field : field.alias;
77
- fields[propName] = instance;
78
- }
79
-
80
- params.fields = fields;
81
- }
82
- params.dependency = dependency;
83
-
84
- //global wiseForm params
85
- if (settings.hasOwnProperty('params')) {
86
- const specs = {};
87
- settings.params.forEach((param) => {
88
- if (!this.#model?.getParams(param)) {
89
- console.warn(
90
- `param ${param} is not registered in the form`
91
- );
92
- return;
93
- }
94
-
95
- specs[param] = this.#model.getParams(param);
96
- });
97
- params.specs = specs;
84
+ const fieldName = (this.#field as { name?: string })?.name || 'unknown';
85
+ const dependencyFieldName = typeof settings.field === 'string' ? settings.field : settings.field?.field || 'unknown';
86
+
87
+ const executionKey = this.#getExecutionKey(callbackName, fieldName, dependencyFieldName);
88
+
89
+ // Check if already executing (prevent concurrent executions)
90
+ if (currentlyExecutingCallbacks.has(executionKey)) {
91
+ console.warn(
92
+ `[CallbackManager] Callback "${callbackName}" is already executing for field "${fieldName}" with dependency "${dependencyFieldName}". Skipping concurrent execution.`
93
+ );
94
+ return;
95
+ }
96
+
97
+ // Check recursion depth
98
+ const currentDepth = callbackExecutionDepth.get(executionKey) || 0;
99
+ const maxDepth = settings.maxRecursionDepth !== undefined ? settings.maxRecursionDepth : MAX_CALLBACK_RECURSION_DEPTH;
100
+
101
+ if (currentDepth >= maxDepth) {
102
+ console.error(
103
+ `[CallbackManager] RECURSION DETECTED: Callback "${callbackName}" exceeded maximum recursion depth of ${maxDepth}`,
104
+ `\n Field: ${fieldName}`,
105
+ `\n Dependency: ${dependencyFieldName}`,
106
+ `\n Current depth: ${currentDepth + 1}`,
107
+ `\n Execution key: ${executionKey}`,
108
+ `\n This indicates a circular dependency in callbacks. Please review the form configuration.`
109
+ );
110
+ return;
111
+ }
112
+
113
+ // Mark as currently executing and increment depth
114
+ currentlyExecutingCallbacks.add(executionKey);
115
+ callbackExecutionDepth.set(executionKey, currentDepth + 1);
116
+
117
+ try {
118
+ const params: ICallbackProps = {
119
+ ...settings,
120
+ form: this.#model,
121
+ field: this.#field,
122
+ settings,
123
+ };
124
+
125
+ const callback: CallbackFunction = this.#callbacks[callbackName];
126
+
127
+ if (!callback) {
128
+ console.error(`[CallbackManager] Callback "${callbackName}" not found in registered callbacks`);
129
+ return;
130
+ }
131
+
132
+ const dependency = this.#model.getField(this.#model.getFieldName(settings.field));
133
+ await dependency.isReady;
134
+ const fields = { [dependency.name]: dependency };
135
+ if (settings.hasOwnProperty('fields')) {
136
+ for (const field of settings.fields) {
137
+ const instance = this.#model.getField(this.#model.getFieldName(field));
138
+ if (instance) await instance.isReady;
139
+ const propName = typeof field === 'string' ? field : field.alias;
140
+ fields[propName] = instance;
98
141
  }
99
- callback(params);
100
- };
101
- }
102
142
 
143
+ params.fields = fields;
144
+ }
145
+ params.dependency = dependency;
146
+
147
+ //global wiseForm params
148
+ if (settings.hasOwnProperty('params')) {
149
+ const specs = {};
150
+ settings.params.forEach(param => {
151
+ if (!this.#model?.getParams(param)) {
152
+ console.warn(`param ${param} is not registered in the form`);
153
+ return;
154
+ }
155
+
156
+ specs[param] = this.#model.getParams(param);
157
+ });
158
+ params.specs = specs;
159
+ }
160
+
161
+ // Execute the callback (handle both sync and async callbacks)
162
+ const result = callback(params) as any;
163
+ if (result && typeof result.then === 'function') {
164
+ await result;
165
+ }
166
+ } catch (error) {
167
+ console.error(`[CallbackManager] Error executing callback "${callbackName}":`, error);
168
+ } finally {
169
+ // Clean up execution tracking
170
+ currentlyExecutingCallbacks.delete(executionKey);
171
+
172
+ const finalDepth = callbackExecutionDepth.get(executionKey) || 0;
173
+ if (finalDepth <= 1) {
174
+ callbackExecutionDepth.delete(executionKey);
175
+ } else {
176
+ callbackExecutionDepth.set(executionKey, finalDepth - 1);
177
+ }
178
+ }
179
+ };
180
+ }
@@ -131,32 +131,57 @@ class WrappedFormModel extends BaseWiseModel {
131
131
  /**
132
132
  * Retrieves a field or nested wrapper by name. Supports dot notation for accessing deeply nested fields.
133
133
  * @param {string} name - The name of the field or nested wrapper to retrieve.
134
+ * @param {Set<BaseWiseModel | WrappedFormModel>} visited - Set of already visited wrappers to prevent infinite recursion.
134
135
  * @returns {FormField | WrappedFormModel | undefined} The requested instance, or undefined if not found.
135
136
  */
136
- getField(name: string) {
137
- if (!name)
138
- return console.warn(
139
- 'You need to provide a name to get a field in form ',
137
+ getField(name: string, visited: Set<BaseWiseModel | WrappedFormModel> = new Set()) {
138
+ if (!name) {
139
+ console.warn(
140
+ '[WiseForm.getField] Empty field name provided in wrapper',
140
141
  this.settings.name
141
142
  );
143
+ return;
144
+ }
145
+
146
+ // Protección contra recursión circular
147
+ if (visited.has(this)) {
148
+ console.error(
149
+ `[WiseForm.getField] Circular reference detected in wrapper "${this.settings.name}" while searching for field "${name}". This usually indicates a configuration issue with nested wrappers.`
150
+ );
151
+ console.log(
152
+ `[WiseForm.getField] PROTECTION ACTIVE: Circular reference prevented. Wrapper: "${this.settings.name}", Field: "${name}", Visited wrappers count: ${visited.size}`
153
+ );
154
+ return undefined;
155
+ }
156
+ visited.add(this);
142
157
 
143
158
  if (!name.includes('.')) {
144
159
  let field = this.fields.get(name);
145
160
 
146
161
  if (!field) {
147
162
  this.wrappers.forEach((item) => {
148
- const foundField = item.getField(name);
149
- if (foundField) field = foundField;
163
+ if (!visited.has(item)) {
164
+ const foundField = item.getField(name, visited);
165
+ if (foundField) field = foundField;
166
+ }
150
167
  });
151
168
  }
152
169
  return field;
153
170
  }
154
171
 
172
+ // Dot notation path
155
173
  const [wrapperName, ...others] = name.split('.');
156
174
  const currentWrapper = this.wrappers.get(wrapperName);
157
175
 
176
+ if (!currentWrapper) {
177
+ console.warn(
178
+ `[WiseForm.getField] Wrapper "${wrapperName}" not found in "${this.settings.name}" while searching for "${name}". Available wrappers: ${Array.from(this.wrappers.keys()).join(', ') || 'none'}`
179
+ );
180
+ return undefined;
181
+ }
182
+
158
183
  const otherWrapper = others.join('.');
159
- return currentWrapper.getField(otherWrapper);
184
+ return currentWrapper.getField(otherWrapper, visited);
160
185
  }
161
186
 
162
187
  /**