rhf-dynamic-forms 1.1.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 +692 -0
- package/dist/index.cjs +1611 -0
- package/dist/index.d.cts +1124 -0
- package/dist/index.d.mts +1124 -0
- package/dist/index.mjs +1557 -0
- package/package.json +82 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,1611 @@
|
|
|
1
|
+
Object.defineProperty(exports, '__esModule', { value: true });
|
|
2
|
+
//#region rolldown:runtime
|
|
3
|
+
var __create = Object.create;
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
8
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
+
var __copyProps = (to, from, except, desc) => {
|
|
10
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
11
|
+
for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
|
|
12
|
+
key = keys[i];
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except) {
|
|
14
|
+
__defProp(to, key, {
|
|
15
|
+
get: ((k) => from[k]).bind(null, key),
|
|
16
|
+
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return to;
|
|
22
|
+
};
|
|
23
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
|
|
24
|
+
value: mod,
|
|
25
|
+
enumerable: true
|
|
26
|
+
}) : target, mod));
|
|
27
|
+
|
|
28
|
+
//#endregion
|
|
29
|
+
let react = require("react");
|
|
30
|
+
let react_hook_form = require("react-hook-form");
|
|
31
|
+
let react_jsx_runtime = require("react/jsx-runtime");
|
|
32
|
+
let zod = require("zod");
|
|
33
|
+
let _hookform_resolvers_zod = require("@hookform/resolvers/zod");
|
|
34
|
+
let json_logic_js = require("json-logic-js");
|
|
35
|
+
json_logic_js = __toESM(json_logic_js);
|
|
36
|
+
|
|
37
|
+
//#region src/context/DynamicFormContext.tsx
|
|
38
|
+
/**
|
|
39
|
+
* Context for sharing form state and configuration with child components.
|
|
40
|
+
*
|
|
41
|
+
* This context is set up by the DynamicForm component and consumed by
|
|
42
|
+
* field renderers and other internal components.
|
|
43
|
+
*/
|
|
44
|
+
const DynamicFormContext = (0, react.createContext)(null);
|
|
45
|
+
DynamicFormContext.displayName = "DynamicFormContext";
|
|
46
|
+
|
|
47
|
+
//#endregion
|
|
48
|
+
//#region src/customComponents/ConfigurationError.ts
|
|
49
|
+
var ConfigurationError$1 = class ConfigurationError$1 extends Error {
|
|
50
|
+
path;
|
|
51
|
+
component;
|
|
52
|
+
constructor(message, path, component) {
|
|
53
|
+
super(message);
|
|
54
|
+
this.name = "ConfigurationError";
|
|
55
|
+
this.path = path;
|
|
56
|
+
this.component = component;
|
|
57
|
+
const ErrorWithCapture = Error;
|
|
58
|
+
if (ErrorWithCapture.captureStackTrace) ErrorWithCapture.captureStackTrace(this, ConfigurationError$1);
|
|
59
|
+
}
|
|
60
|
+
static formatMessage(baseMessage, path, component) {
|
|
61
|
+
const parts = [];
|
|
62
|
+
if (component) parts.push(`Component "${component}"`);
|
|
63
|
+
if (path) parts.push(`at ${path}`);
|
|
64
|
+
if (parts.length > 0) return `${parts.join(" ")}: ${baseMessage}`;
|
|
65
|
+
return baseMessage;
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
//#endregion
|
|
70
|
+
//#region src/customComponents/defineCustomComponent.ts
|
|
71
|
+
/**
|
|
72
|
+
* Type-safe helper for defining custom components.
|
|
73
|
+
* Provides TypeScript inference for component props based on propsSchema.
|
|
74
|
+
*
|
|
75
|
+
* @example
|
|
76
|
+
* ```ts
|
|
77
|
+
* const RatingField = defineCustomComponent({
|
|
78
|
+
* component: RatingFieldComponent,
|
|
79
|
+
* propsSchema: z.object({ maxStars: z.number() }),
|
|
80
|
+
* defaultProps: { maxStars: 5 },
|
|
81
|
+
* });
|
|
82
|
+
* ```
|
|
83
|
+
*/
|
|
84
|
+
function defineCustomComponent(definition) {
|
|
85
|
+
return definition;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
//#endregion
|
|
89
|
+
//#region src/customComponents/types.ts
|
|
90
|
+
const isCustomComponentDefinition = (entry) => {
|
|
91
|
+
return typeof entry === "object" && entry !== null && "component" in entry && typeof entry.component === "function";
|
|
92
|
+
};
|
|
93
|
+
const normalizeComponentDefinition = (entry, name) => {
|
|
94
|
+
if (isCustomComponentDefinition(entry)) return {
|
|
95
|
+
...entry,
|
|
96
|
+
displayName: entry.displayName ?? name
|
|
97
|
+
};
|
|
98
|
+
return {
|
|
99
|
+
component: entry,
|
|
100
|
+
displayName: name
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
//#endregion
|
|
105
|
+
//#region src/customComponents/validateCustomElement.ts
|
|
106
|
+
/**
|
|
107
|
+
* Validate custom element against its component definition.
|
|
108
|
+
*/
|
|
109
|
+
function validateCustomElement(element, registry, path) {
|
|
110
|
+
const entry = registry[element.component];
|
|
111
|
+
if (!entry) {
|
|
112
|
+
const available = Object.keys(registry);
|
|
113
|
+
const availableMessage = available.length > 0 ? `Available components: ${available.join(", ")}` : "No custom components registered.";
|
|
114
|
+
throw new ConfigurationError$1(`Unknown custom component "${element.component}" at ${path}. ${availableMessage}`, path, element.component);
|
|
115
|
+
}
|
|
116
|
+
const definition = normalizeComponentDefinition(entry, element.component);
|
|
117
|
+
const mergedProps = {
|
|
118
|
+
...definition.defaultProps,
|
|
119
|
+
...element.componentProps
|
|
120
|
+
};
|
|
121
|
+
if (definition.propsSchema) {
|
|
122
|
+
const result = definition.propsSchema.safeParse(mergedProps);
|
|
123
|
+
if (!result.success) throw new ConfigurationError$1(`Invalid props for "${definition.displayName || element.component}" at ${path}:\n${result.error.issues.map((issue) => ` - ${issue.path.join(".") || "root"}: ${issue.message}`).join("\n")}`, path, element.component);
|
|
124
|
+
return {
|
|
125
|
+
...element,
|
|
126
|
+
componentProps: result.data,
|
|
127
|
+
__definition: definition
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
...element,
|
|
132
|
+
componentProps: mergedProps,
|
|
133
|
+
__definition: definition
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
function isCustomElement(element) {
|
|
137
|
+
return typeof element === "object" && element !== null && "type" in element && element.type === "custom" && "component" in element && typeof element.component === "string";
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
//#endregion
|
|
141
|
+
//#region src/customComponents/validateConfiguration.ts
|
|
142
|
+
/**
|
|
143
|
+
* Recursively validate all custom elements in a form configuration.
|
|
144
|
+
*/
|
|
145
|
+
function validateCustomComponents(config, registry = {}) {
|
|
146
|
+
const validatedElements = validateElements(config.elements, registry, "elements");
|
|
147
|
+
return {
|
|
148
|
+
...config,
|
|
149
|
+
elements: validatedElements
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
function validateElements(elements, registry, basePath) {
|
|
153
|
+
return elements.map((element, index) => {
|
|
154
|
+
return validateElement(element, registry, `${basePath}[${index}]`);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
function validateElement(element, registry, path) {
|
|
158
|
+
if (isCustomElement(element)) return validateCustomElement(element, registry, path);
|
|
159
|
+
if (isContainerElement$1(element)) return validateContainer(element, registry, path);
|
|
160
|
+
if (isColumnElement$1(element)) return validateColumn(element, registry, path);
|
|
161
|
+
return element;
|
|
162
|
+
}
|
|
163
|
+
function validateContainer(container, registry, path) {
|
|
164
|
+
const validatedColumns = container.columns.map((column, index) => validateColumn(column, registry, `${path}.columns[${index}]`));
|
|
165
|
+
return {
|
|
166
|
+
...container,
|
|
167
|
+
columns: validatedColumns
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
function validateColumn(column, registry, path) {
|
|
171
|
+
const validatedElements = validateElements(column.elements, registry, `${path}.elements`);
|
|
172
|
+
return {
|
|
173
|
+
...column,
|
|
174
|
+
elements: validatedElements
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
function isContainerElement$1(element) {
|
|
178
|
+
return element.type === "container";
|
|
179
|
+
}
|
|
180
|
+
function isColumnElement$1(element) {
|
|
181
|
+
return element.type === "column";
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
//#endregion
|
|
185
|
+
//#region src/types/elements.ts
|
|
186
|
+
/**
|
|
187
|
+
* Type guard to check if an element is a field element.
|
|
188
|
+
*/
|
|
189
|
+
const isFieldElement = (element) => element.type === "text" || element.type === "email" || element.type === "boolean" || element.type === "phone" || element.type === "date" || element.type === "select" || element.type === "array" || element.type === "custom";
|
|
190
|
+
/**
|
|
191
|
+
* Type guard to check if an element is an array field element.
|
|
192
|
+
*/
|
|
193
|
+
const isArrayFieldElement = (element) => element.type === "array";
|
|
194
|
+
/**
|
|
195
|
+
* Type guard to check if an element is a container element.
|
|
196
|
+
*/
|
|
197
|
+
const isContainerElement = (element) => element.type === "container";
|
|
198
|
+
/**
|
|
199
|
+
* Type guard to check if an element is a column element.
|
|
200
|
+
*/
|
|
201
|
+
const isColumnElement = (element) => element.type === "column";
|
|
202
|
+
/**
|
|
203
|
+
* Type guard to check if an element is a custom field element.
|
|
204
|
+
*/
|
|
205
|
+
const isCustomFieldElement = (element) => element.type === "custom";
|
|
206
|
+
|
|
207
|
+
//#endregion
|
|
208
|
+
//#region src/hooks/useDynamicFormContext.ts
|
|
209
|
+
/**
|
|
210
|
+
* Hook to access the DynamicForm context.
|
|
211
|
+
*
|
|
212
|
+
* Must be used within a DynamicForm component.
|
|
213
|
+
* Throws an error if used outside of the form context.
|
|
214
|
+
*
|
|
215
|
+
* @returns The DynamicFormContext value
|
|
216
|
+
* @throws Error if used outside of DynamicForm
|
|
217
|
+
*
|
|
218
|
+
* @example
|
|
219
|
+
* ```tsx
|
|
220
|
+
* function MyCustomField({ config }) {
|
|
221
|
+
* const { form, fieldComponents } = useDynamicFormContext();
|
|
222
|
+
*
|
|
223
|
+
* const value = form.watch(config.name);
|
|
224
|
+
* // ... render field
|
|
225
|
+
* }
|
|
226
|
+
* ```
|
|
227
|
+
*/
|
|
228
|
+
const useDynamicFormContext = () => {
|
|
229
|
+
const context = (0, react.useContext)(DynamicFormContext);
|
|
230
|
+
if (!context) throw new Error("useDynamicFormContext must be used within a DynamicForm component. Make sure your component is a child of <DynamicForm>.");
|
|
231
|
+
return context;
|
|
232
|
+
};
|
|
233
|
+
/**
|
|
234
|
+
* Hook to safely access the DynamicForm context.
|
|
235
|
+
* Returns null if used outside of the form context instead of throwing.
|
|
236
|
+
*
|
|
237
|
+
* @returns The DynamicFormContext value or null
|
|
238
|
+
*
|
|
239
|
+
* @example
|
|
240
|
+
* ```tsx
|
|
241
|
+
* function MaybeInForm() {
|
|
242
|
+
* const context = useDynamicFormContextSafe();
|
|
243
|
+
*
|
|
244
|
+
* if (!context) {
|
|
245
|
+
* return <span>Not in a form</span>;
|
|
246
|
+
* }
|
|
247
|
+
*
|
|
248
|
+
* return <span>In a form!</span>;
|
|
249
|
+
* }
|
|
250
|
+
* ```
|
|
251
|
+
*/
|
|
252
|
+
const useDynamicFormContextSafe = () => {
|
|
253
|
+
return (0, react.useContext)(DynamicFormContext);
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
//#endregion
|
|
257
|
+
//#region src/components/ContainerRenderer.tsx
|
|
258
|
+
/**
|
|
259
|
+
* Default container styles using flexbox.
|
|
260
|
+
*/
|
|
261
|
+
const defaultContainerStyle = {
|
|
262
|
+
display: "flex",
|
|
263
|
+
gap: "16px",
|
|
264
|
+
flexWrap: "wrap"
|
|
265
|
+
};
|
|
266
|
+
/**
|
|
267
|
+
* Default container component used when no custom container is provided.
|
|
268
|
+
* Receives config and children props as per ContainerProps interface.
|
|
269
|
+
*/
|
|
270
|
+
const DefaultContainer = ({ children }) => {
|
|
271
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
272
|
+
style: defaultContainerStyle,
|
|
273
|
+
children
|
|
274
|
+
});
|
|
275
|
+
};
|
|
276
|
+
/**
|
|
277
|
+
* Renders a container element with its columns.
|
|
278
|
+
*
|
|
279
|
+
* The ContainerRenderer:
|
|
280
|
+
* 1. Checks visibility (Phase 4 - currently all containers are visible)
|
|
281
|
+
* 2. Looks up custom container component if specified
|
|
282
|
+
* 3. Renders columns as children using ColumnRenderer
|
|
283
|
+
*
|
|
284
|
+
* @example
|
|
285
|
+
* ```tsx
|
|
286
|
+
* <ContainerRenderer
|
|
287
|
+
* config={{
|
|
288
|
+
* type: 'container',
|
|
289
|
+
* columns: [
|
|
290
|
+
* { type: 'column', width: '50%', elements: [...] },
|
|
291
|
+
* { type: 'column', width: '50%', elements: [...] }
|
|
292
|
+
* ]
|
|
293
|
+
* }}
|
|
294
|
+
* />
|
|
295
|
+
* ```
|
|
296
|
+
*/
|
|
297
|
+
const ContainerRenderer = ({ config }) => {
|
|
298
|
+
const { customContainers } = useDynamicFormContext();
|
|
299
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(customContainers?.default ?? DefaultContainer, {
|
|
300
|
+
config,
|
|
301
|
+
children: config.columns.map((column, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ColumnRenderer, { config: column }, `column-${index}`))
|
|
302
|
+
});
|
|
303
|
+
};
|
|
304
|
+
ContainerRenderer.displayName = "ContainerRenderer";
|
|
305
|
+
|
|
306
|
+
//#endregion
|
|
307
|
+
//#region src/components/FieldRenderer.tsx
|
|
308
|
+
const CustomFieldRenderer = ({ config, field, fieldState, formValues, setValue }) => {
|
|
309
|
+
const { customComponents } = useDynamicFormContext();
|
|
310
|
+
if (config.type !== "custom") return null;
|
|
311
|
+
const customConfig = config;
|
|
312
|
+
const entry = customComponents[customConfig.component];
|
|
313
|
+
if (!entry) {
|
|
314
|
+
console.warn(`No custom component registered for: "${customConfig.component}". Make sure to pass it in the customComponents prop.`);
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
const FieldComponent = normalizeComponentDefinition(entry, customConfig.component).component;
|
|
318
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FieldComponent, {
|
|
319
|
+
componentProps: customConfig.componentProps ?? {},
|
|
320
|
+
config: customConfig,
|
|
321
|
+
field,
|
|
322
|
+
fieldState,
|
|
323
|
+
formValues,
|
|
324
|
+
setValue
|
|
325
|
+
});
|
|
326
|
+
};
|
|
327
|
+
const StandardFieldRenderer = ({ config, field, fieldState, formValues, setValue }) => {
|
|
328
|
+
const { fieldComponents } = useDynamicFormContext();
|
|
329
|
+
const FieldComponent = fieldComponents[config.type];
|
|
330
|
+
if (!FieldComponent) {
|
|
331
|
+
console.warn(`No field component registered for type: "${config.type}". Make sure to provide all field types in the fieldComponents prop.`);
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FieldComponent, {
|
|
335
|
+
config,
|
|
336
|
+
field,
|
|
337
|
+
fieldState,
|
|
338
|
+
formValues,
|
|
339
|
+
setValue
|
|
340
|
+
});
|
|
341
|
+
};
|
|
342
|
+
const FieldRenderer = ({ config }) => {
|
|
343
|
+
const { form, visibility, fieldWrapper } = useDynamicFormContext();
|
|
344
|
+
const { field, fieldState } = (0, react_hook_form.useController)({
|
|
345
|
+
name: config.name,
|
|
346
|
+
control: form.control
|
|
347
|
+
});
|
|
348
|
+
if (!(visibility[config.name] !== false)) return null;
|
|
349
|
+
const formValues = form.getValues();
|
|
350
|
+
const setValue = (name, value) => form.setValue(name, value);
|
|
351
|
+
let fieldElement;
|
|
352
|
+
if (isCustomFieldElement(config)) fieldElement = /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CustomFieldRenderer, {
|
|
353
|
+
config,
|
|
354
|
+
field,
|
|
355
|
+
fieldState,
|
|
356
|
+
formValues,
|
|
357
|
+
setValue
|
|
358
|
+
});
|
|
359
|
+
else fieldElement = /* @__PURE__ */ (0, react_jsx_runtime.jsx)(StandardFieldRenderer, {
|
|
360
|
+
config,
|
|
361
|
+
field,
|
|
362
|
+
fieldState,
|
|
363
|
+
formValues,
|
|
364
|
+
setValue
|
|
365
|
+
});
|
|
366
|
+
if (fieldWrapper) return fieldWrapper({
|
|
367
|
+
name: config.name,
|
|
368
|
+
config,
|
|
369
|
+
fieldState,
|
|
370
|
+
value: field.value,
|
|
371
|
+
formValues,
|
|
372
|
+
setValue
|
|
373
|
+
}, fieldElement);
|
|
374
|
+
return fieldElement;
|
|
375
|
+
};
|
|
376
|
+
FieldRenderer.displayName = "FieldRenderer";
|
|
377
|
+
|
|
378
|
+
//#endregion
|
|
379
|
+
//#region src/components/ElementRenderer.tsx
|
|
380
|
+
/**
|
|
381
|
+
* Dispatches rendering to the appropriate component based on element type.
|
|
382
|
+
*
|
|
383
|
+
* Supports field elements (Phase 1) and container/column layouts (Phase 2).
|
|
384
|
+
*
|
|
385
|
+
* @example
|
|
386
|
+
* ```tsx
|
|
387
|
+
* // Field element
|
|
388
|
+
* <ElementRenderer element={{ type: 'text', name: 'name', label: 'Name' }} />
|
|
389
|
+
*
|
|
390
|
+
* // Container element with columns
|
|
391
|
+
* <ElementRenderer element={{
|
|
392
|
+
* type: 'container',
|
|
393
|
+
* columns: [
|
|
394
|
+
* { type: 'column', width: '50%', elements: [...] }
|
|
395
|
+
* ]
|
|
396
|
+
* }} />
|
|
397
|
+
* ```
|
|
398
|
+
*/
|
|
399
|
+
const ElementRenderer = ({ element }) => {
|
|
400
|
+
if (isFieldElement(element)) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FieldRenderer, { config: element });
|
|
401
|
+
if (isContainerElement(element)) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ContainerRenderer, { config: element });
|
|
402
|
+
if (isColumnElement(element)) {
|
|
403
|
+
console.warn("Column elements should not be rendered directly. They should be children of a container element.");
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
console.warn(`Unknown element type: ${element.type}`);
|
|
407
|
+
return null;
|
|
408
|
+
};
|
|
409
|
+
ElementRenderer.displayName = "ElementRenderer";
|
|
410
|
+
|
|
411
|
+
//#endregion
|
|
412
|
+
//#region src/components/ColumnRenderer.tsx
|
|
413
|
+
/**
|
|
414
|
+
* Renders a column element with its nested form elements.
|
|
415
|
+
*
|
|
416
|
+
* The ColumnRenderer:
|
|
417
|
+
* 1. Applies the configured width to the column wrapper
|
|
418
|
+
* 2. Recursively renders nested elements via ElementRenderer
|
|
419
|
+
*
|
|
420
|
+
* Columns support nested containers, enabling complex layout hierarchies.
|
|
421
|
+
*
|
|
422
|
+
* @example
|
|
423
|
+
* ```tsx
|
|
424
|
+
* <ColumnRenderer
|
|
425
|
+
* config={{
|
|
426
|
+
* type: 'column',
|
|
427
|
+
* width: '50%',
|
|
428
|
+
* elements: [
|
|
429
|
+
* { type: 'text', name: 'firstName', label: 'First Name' },
|
|
430
|
+
* { type: 'text', name: 'lastName', label: 'Last Name' }
|
|
431
|
+
* ]
|
|
432
|
+
* }}
|
|
433
|
+
* />
|
|
434
|
+
* ```
|
|
435
|
+
*/
|
|
436
|
+
const ColumnRenderer = ({ config }) => {
|
|
437
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
438
|
+
style: {
|
|
439
|
+
flex: `0 1 ${config.width}`,
|
|
440
|
+
maxWidth: config.width,
|
|
441
|
+
minWidth: 0,
|
|
442
|
+
boxSizing: "border-box"
|
|
443
|
+
},
|
|
444
|
+
children: config.elements.map((element, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ElementRenderer, { element }, "name" in element ? element.name : `element-${index}`))
|
|
445
|
+
});
|
|
446
|
+
};
|
|
447
|
+
ColumnRenderer.displayName = "ColumnRenderer";
|
|
448
|
+
|
|
449
|
+
//#endregion
|
|
450
|
+
//#region src/components/FormRenderer.tsx
|
|
451
|
+
/**
|
|
452
|
+
* Renders all form elements from the configuration.
|
|
453
|
+
*
|
|
454
|
+
* Maps over the elements array and renders each element using ElementRenderer.
|
|
455
|
+
* Elements are rendered vertically (one under another) in Phase 1.
|
|
456
|
+
*
|
|
457
|
+
* @example
|
|
458
|
+
* ```tsx
|
|
459
|
+
* const elements = [
|
|
460
|
+
* { type: 'text', name: 'name', label: 'Name' },
|
|
461
|
+
* { type: 'email', name: 'email', label: 'Email' },
|
|
462
|
+
* ];
|
|
463
|
+
*
|
|
464
|
+
* <FormRenderer elements={elements} />
|
|
465
|
+
* ```
|
|
466
|
+
*/
|
|
467
|
+
const FormRenderer = ({ elements }) => {
|
|
468
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_jsx_runtime.Fragment, { children: elements.map((element, index) => {
|
|
469
|
+
const key = "name" in element && element.name ? element.name : `element-${index}`;
|
|
470
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ElementRenderer, { element }, key);
|
|
471
|
+
}) });
|
|
472
|
+
};
|
|
473
|
+
FormRenderer.displayName = "FormRenderer";
|
|
474
|
+
|
|
475
|
+
//#endregion
|
|
476
|
+
//#region src/parser/configValidator.ts
|
|
477
|
+
/**
|
|
478
|
+
* JSON Logic rule schema - accepts any object structure.
|
|
479
|
+
* Actual JSON Logic validation happens at runtime.
|
|
480
|
+
*/
|
|
481
|
+
const jsonLogicRuleSchema = zod.z.record(zod.z.string(), zod.z.unknown());
|
|
482
|
+
/**
|
|
483
|
+
* Validation configuration schema.
|
|
484
|
+
*/
|
|
485
|
+
const validationConfigSchema = zod.z.object({
|
|
486
|
+
required: zod.z.boolean().optional(),
|
|
487
|
+
type: zod.z.enum([
|
|
488
|
+
"number",
|
|
489
|
+
"email",
|
|
490
|
+
"date"
|
|
491
|
+
]).optional(),
|
|
492
|
+
minLength: zod.z.number().int().min(0).optional(),
|
|
493
|
+
maxLength: zod.z.number().int().min(0).optional(),
|
|
494
|
+
pattern: zod.z.string().optional(),
|
|
495
|
+
message: zod.z.string().optional(),
|
|
496
|
+
condition: jsonLogicRuleSchema.optional()
|
|
497
|
+
}).strict().optional();
|
|
498
|
+
/**
|
|
499
|
+
* Base field element schema (common properties).
|
|
500
|
+
*/
|
|
501
|
+
const baseFieldSchema = zod.z.object({
|
|
502
|
+
name: zod.z.string().min(1, "Field name is required"),
|
|
503
|
+
label: zod.z.string().optional(),
|
|
504
|
+
placeholder: zod.z.string().optional(),
|
|
505
|
+
defaultValue: zod.z.union([
|
|
506
|
+
zod.z.string(),
|
|
507
|
+
zod.z.number(),
|
|
508
|
+
zod.z.boolean(),
|
|
509
|
+
zod.z.null(),
|
|
510
|
+
zod.z.array(zod.z.unknown()),
|
|
511
|
+
zod.z.record(zod.z.string(), zod.z.unknown())
|
|
512
|
+
]).optional(),
|
|
513
|
+
validation: validationConfigSchema,
|
|
514
|
+
visible: jsonLogicRuleSchema.optional(),
|
|
515
|
+
dependsOn: zod.z.string().optional(),
|
|
516
|
+
resetOnParentChange: zod.z.boolean().optional()
|
|
517
|
+
});
|
|
518
|
+
/**
|
|
519
|
+
* Text field element schema.
|
|
520
|
+
*/
|
|
521
|
+
const textFieldSchema = baseFieldSchema.extend({ type: zod.z.literal("text") });
|
|
522
|
+
/**
|
|
523
|
+
* Email field element schema.
|
|
524
|
+
*/
|
|
525
|
+
const emailFieldSchema = baseFieldSchema.extend({ type: zod.z.literal("email") });
|
|
526
|
+
/**
|
|
527
|
+
* Boolean field element schema.
|
|
528
|
+
*/
|
|
529
|
+
const booleanFieldSchema = baseFieldSchema.extend({ type: zod.z.literal("boolean") });
|
|
530
|
+
/**
|
|
531
|
+
* Phone field element schema.
|
|
532
|
+
*/
|
|
533
|
+
const phoneFieldSchema = baseFieldSchema.extend({ type: zod.z.literal("phone") });
|
|
534
|
+
/**
|
|
535
|
+
* Date field element schema.
|
|
536
|
+
*/
|
|
537
|
+
const dateFieldSchema = baseFieldSchema.extend({ type: zod.z.literal("date") });
|
|
538
|
+
/**
|
|
539
|
+
* Select option schema.
|
|
540
|
+
*/
|
|
541
|
+
const selectOptionSchema = zod.z.object({
|
|
542
|
+
value: zod.z.union([zod.z.string(), zod.z.number()]),
|
|
543
|
+
label: zod.z.string(),
|
|
544
|
+
disabled: zod.z.boolean().optional()
|
|
545
|
+
});
|
|
546
|
+
/**
|
|
547
|
+
* Options source schema - describes how to resolve options.
|
|
548
|
+
*/
|
|
549
|
+
const optionsSourceSchema = zod.z.discriminatedUnion("type", [
|
|
550
|
+
zod.z.object({ type: zod.z.literal("static") }),
|
|
551
|
+
zod.z.object({
|
|
552
|
+
type: zod.z.literal("map"),
|
|
553
|
+
key: zod.z.string()
|
|
554
|
+
}),
|
|
555
|
+
zod.z.object({
|
|
556
|
+
type: zod.z.literal("api"),
|
|
557
|
+
endpoint: zod.z.string()
|
|
558
|
+
}),
|
|
559
|
+
zod.z.object({
|
|
560
|
+
type: zod.z.literal("search"),
|
|
561
|
+
endpoint: zod.z.string(),
|
|
562
|
+
minChars: zod.z.number().optional()
|
|
563
|
+
}),
|
|
564
|
+
zod.z.object({
|
|
565
|
+
type: zod.z.literal("resolver"),
|
|
566
|
+
name: zod.z.string()
|
|
567
|
+
})
|
|
568
|
+
]);
|
|
569
|
+
/**
|
|
570
|
+
* Select field element schema.
|
|
571
|
+
* Options are required when optionsSource is not provided.
|
|
572
|
+
*/
|
|
573
|
+
const selectFieldSchema = baseFieldSchema.extend({
|
|
574
|
+
type: zod.z.literal("select"),
|
|
575
|
+
options: zod.z.array(selectOptionSchema).optional(),
|
|
576
|
+
optionsSource: optionsSourceSchema.optional(),
|
|
577
|
+
multiple: zod.z.boolean().optional(),
|
|
578
|
+
clearable: zod.z.boolean().optional(),
|
|
579
|
+
searchable: zod.z.boolean().optional(),
|
|
580
|
+
creatable: zod.z.boolean().optional()
|
|
581
|
+
}).refine((data) => {
|
|
582
|
+
if (!data.optionsSource || data.optionsSource.type === "static") return data.options !== void 0 && data.options.length >= 0;
|
|
583
|
+
return true;
|
|
584
|
+
}, { message: "Options are required when optionsSource is not provided" });
|
|
585
|
+
/**
|
|
586
|
+
* Custom field element schema.
|
|
587
|
+
*/
|
|
588
|
+
const customFieldSchema = baseFieldSchema.extend({
|
|
589
|
+
type: zod.z.literal("custom"),
|
|
590
|
+
component: zod.z.string().min(1, "Custom component name is required"),
|
|
591
|
+
componentProps: zod.z.record(zod.z.string(), zod.z.unknown()).optional()
|
|
592
|
+
});
|
|
593
|
+
/**
|
|
594
|
+
* Array field element schema.
|
|
595
|
+
* Contains repeatable group of fields.
|
|
596
|
+
*/
|
|
597
|
+
const arrayFieldSchema = baseFieldSchema.extend({
|
|
598
|
+
type: zod.z.literal("array"),
|
|
599
|
+
itemFields: zod.z.lazy(() => zod.z.array(fieldElementSchema)),
|
|
600
|
+
minItems: zod.z.number().int().min(0).optional(),
|
|
601
|
+
maxItems: zod.z.number().int().min(0).optional(),
|
|
602
|
+
addButtonLabel: zod.z.string().optional(),
|
|
603
|
+
sortable: zod.z.boolean().optional()
|
|
604
|
+
}).refine((data) => {
|
|
605
|
+
if (data.minItems !== void 0 && data.maxItems !== void 0) return data.minItems <= data.maxItems;
|
|
606
|
+
return true;
|
|
607
|
+
}, { message: "minItems must be less than or equal to maxItems" });
|
|
608
|
+
/**
|
|
609
|
+
* Field element schema - union of all field types.
|
|
610
|
+
*/
|
|
611
|
+
const fieldElementSchema = zod.z.discriminatedUnion("type", [
|
|
612
|
+
textFieldSchema,
|
|
613
|
+
emailFieldSchema,
|
|
614
|
+
booleanFieldSchema,
|
|
615
|
+
phoneFieldSchema,
|
|
616
|
+
dateFieldSchema,
|
|
617
|
+
selectFieldSchema,
|
|
618
|
+
customFieldSchema,
|
|
619
|
+
arrayFieldSchema
|
|
620
|
+
]);
|
|
621
|
+
/**
|
|
622
|
+
* Form element schema - for Phase 1, only field elements are supported.
|
|
623
|
+
* Phase 2 will add container and column schemas.
|
|
624
|
+
*
|
|
625
|
+
* We use a lazy schema to allow for future recursive definitions
|
|
626
|
+
* (containers containing columns containing elements).
|
|
627
|
+
*/
|
|
628
|
+
const formElementSchema = zod.z.lazy(() => zod.z.union([
|
|
629
|
+
fieldElementSchema,
|
|
630
|
+
containerElementSchema,
|
|
631
|
+
columnElementSchema
|
|
632
|
+
]));
|
|
633
|
+
/**
|
|
634
|
+
* Column element schema (for Phase 2, but defined here for type completeness).
|
|
635
|
+
*/
|
|
636
|
+
const columnElementSchema = zod.z.object({
|
|
637
|
+
type: zod.z.literal("column"),
|
|
638
|
+
width: zod.z.string().min(1, "Column width is required"),
|
|
639
|
+
elements: zod.z.array(zod.z.lazy(() => formElementSchema)),
|
|
640
|
+
visible: jsonLogicRuleSchema.optional()
|
|
641
|
+
});
|
|
642
|
+
/**
|
|
643
|
+
* Container element schema (for Phase 2).
|
|
644
|
+
*/
|
|
645
|
+
const containerElementSchema = zod.z.object({
|
|
646
|
+
type: zod.z.literal("container"),
|
|
647
|
+
columns: zod.z.array(columnElementSchema),
|
|
648
|
+
visible: jsonLogicRuleSchema.optional()
|
|
649
|
+
});
|
|
650
|
+
/**
|
|
651
|
+
* Custom component definition schema.
|
|
652
|
+
*/
|
|
653
|
+
const customComponentDefinitionSchema = zod.z.object({ defaultProps: zod.z.record(zod.z.string(), zod.z.unknown()).optional() });
|
|
654
|
+
/**
|
|
655
|
+
* Root form configuration schema.
|
|
656
|
+
*/
|
|
657
|
+
const formConfigurationSchema = zod.z.object({
|
|
658
|
+
name: zod.z.string().optional(),
|
|
659
|
+
elements: zod.z.array(formElementSchema).min(1, "At least one element is required"),
|
|
660
|
+
customComponents: zod.z.record(zod.z.string(), customComponentDefinitionSchema).optional()
|
|
661
|
+
});
|
|
662
|
+
/**
|
|
663
|
+
* Validates a form configuration object.
|
|
664
|
+
*
|
|
665
|
+
* @param config - Configuration object to validate
|
|
666
|
+
* @returns Validated and typed configuration
|
|
667
|
+
* @throws ZodError if validation fails
|
|
668
|
+
*/
|
|
669
|
+
const validateConfiguration = (config) => {
|
|
670
|
+
return formConfigurationSchema.parse(config);
|
|
671
|
+
};
|
|
672
|
+
/**
|
|
673
|
+
* Safely validates a form configuration object without throwing.
|
|
674
|
+
*
|
|
675
|
+
* @param config - Configuration object to validate
|
|
676
|
+
* @returns Result object with success status and data or error
|
|
677
|
+
*/
|
|
678
|
+
const safeValidateConfiguration = (config) => {
|
|
679
|
+
return formConfigurationSchema.safeParse(config);
|
|
680
|
+
};
|
|
681
|
+
|
|
682
|
+
//#endregion
|
|
683
|
+
//#region src/parser/configParser.ts
|
|
684
|
+
/**
|
|
685
|
+
* Error thrown when configuration parsing fails.
|
|
686
|
+
*/
|
|
687
|
+
var ConfigurationError = class extends Error {
|
|
688
|
+
/** The original validation errors */
|
|
689
|
+
errors;
|
|
690
|
+
constructor(message, errors) {
|
|
691
|
+
super(message);
|
|
692
|
+
this.name = "ConfigurationError";
|
|
693
|
+
this.errors = errors;
|
|
694
|
+
}
|
|
695
|
+
};
|
|
696
|
+
/**
|
|
697
|
+
* Parses and validates a form configuration.
|
|
698
|
+
*
|
|
699
|
+
* @param config - Raw configuration object (typically from JSON)
|
|
700
|
+
* @returns Validated FormConfiguration
|
|
701
|
+
* @throws ConfigurationError if validation fails
|
|
702
|
+
*
|
|
703
|
+
* @example
|
|
704
|
+
* ```typescript
|
|
705
|
+
* try {
|
|
706
|
+
* const config = parseConfiguration({
|
|
707
|
+
* elements: [
|
|
708
|
+
* { type: 'text', name: 'name', label: 'Name' }
|
|
709
|
+
* ]
|
|
710
|
+
* });
|
|
711
|
+
* // config is now typed as FormConfiguration
|
|
712
|
+
* } catch (error) {
|
|
713
|
+
* if (error instanceof ConfigurationError) {
|
|
714
|
+
* console.error('Invalid configuration:', error.errors);
|
|
715
|
+
* }
|
|
716
|
+
* }
|
|
717
|
+
* ```
|
|
718
|
+
*/
|
|
719
|
+
const parseConfiguration = (config) => {
|
|
720
|
+
try {
|
|
721
|
+
return validateConfiguration(config);
|
|
722
|
+
} catch (error) {
|
|
723
|
+
if (error && typeof error === "object" && "issues" in error) throw new ConfigurationError("Invalid form configuration", error.issues);
|
|
724
|
+
throw error;
|
|
725
|
+
}
|
|
726
|
+
};
|
|
727
|
+
/**
|
|
728
|
+
* Safely parses and validates a form configuration without throwing.
|
|
729
|
+
*
|
|
730
|
+
* @param config - Raw configuration object
|
|
731
|
+
* @returns ParseResult with success status and config or errors
|
|
732
|
+
*
|
|
733
|
+
* @example
|
|
734
|
+
* ```typescript
|
|
735
|
+
* const result = safeParseConfiguration(rawConfig);
|
|
736
|
+
* if (result.success) {
|
|
737
|
+
* // result.config is available
|
|
738
|
+
* renderForm(result.config);
|
|
739
|
+
* } else {
|
|
740
|
+
* // result.errors contains validation messages
|
|
741
|
+
* showErrors(result.errors);
|
|
742
|
+
* }
|
|
743
|
+
* ```
|
|
744
|
+
*/
|
|
745
|
+
const safeParseConfiguration = (config) => {
|
|
746
|
+
const result = safeValidateConfiguration(config);
|
|
747
|
+
if (result.success) return {
|
|
748
|
+
success: true,
|
|
749
|
+
config: result.data
|
|
750
|
+
};
|
|
751
|
+
return {
|
|
752
|
+
success: false,
|
|
753
|
+
errors: result.error.issues.map((issue) => {
|
|
754
|
+
const path = issue.path.join(".");
|
|
755
|
+
return path ? `${path}: ${issue.message}` : issue.message;
|
|
756
|
+
})
|
|
757
|
+
};
|
|
758
|
+
};
|
|
759
|
+
|
|
760
|
+
//#endregion
|
|
761
|
+
//#region src/resolver/visibilityAwareResolver.ts
|
|
762
|
+
/**
|
|
763
|
+
* Check if a value is a leaf error node.
|
|
764
|
+
* React-hook-form leaf errors have both 'type' and 'message' properties.
|
|
765
|
+
*/
|
|
766
|
+
const isLeafError = (value) => value !== null && typeof value === "object" && "type" in value && "message" in value;
|
|
767
|
+
/**
|
|
768
|
+
* Create a warning error from an existing error object.
|
|
769
|
+
*/
|
|
770
|
+
const createWarningError = (error) => ({
|
|
771
|
+
...error,
|
|
772
|
+
type: "warning"
|
|
773
|
+
});
|
|
774
|
+
/**
|
|
775
|
+
* Process a single error entry based on visibility rules.
|
|
776
|
+
* Returns the error to include or undefined to skip.
|
|
777
|
+
*/
|
|
778
|
+
const processLeafError = (value, path, visibility, warnMode) => {
|
|
779
|
+
if (visibility[path] !== false) return value;
|
|
780
|
+
if (warnMode) return createWarningError(value);
|
|
781
|
+
};
|
|
782
|
+
/**
|
|
783
|
+
* Recursively filter errors based on field visibility.
|
|
784
|
+
* Handles nested error structures for dot-notation paths.
|
|
785
|
+
*/
|
|
786
|
+
const filterErrorsByVisibility = (errors, visibility, warnMode, parentPath = "") => {
|
|
787
|
+
const acc = {};
|
|
788
|
+
for (const [key, value] of Object.entries(errors)) {
|
|
789
|
+
const path = parentPath ? `${parentPath}.${key}` : key;
|
|
790
|
+
if (!isLeafError(value) && value && typeof value === "object") {
|
|
791
|
+
const nested = filterErrorsByVisibility(value, visibility, warnMode, path);
|
|
792
|
+
if (Object.keys(nested).length > 0) acc[key] = nested;
|
|
793
|
+
continue;
|
|
794
|
+
}
|
|
795
|
+
const processed = processLeafError(value, path, visibility, warnMode);
|
|
796
|
+
if (processed !== void 0) acc[key] = processed;
|
|
797
|
+
}
|
|
798
|
+
return acc;
|
|
799
|
+
};
|
|
800
|
+
/**
|
|
801
|
+
* Creates a resolver that respects field visibility.
|
|
802
|
+
*
|
|
803
|
+
* This resolver wraps the standard zodResolver and filters validation
|
|
804
|
+
* errors based on field visibility. This is useful when fields are
|
|
805
|
+
* conditionally shown/hidden and you want to skip validation for
|
|
806
|
+
* hidden fields.
|
|
807
|
+
*
|
|
808
|
+
* @param options - Configuration for the resolver
|
|
809
|
+
* @returns A react-hook-form resolver
|
|
810
|
+
*
|
|
811
|
+
* @example
|
|
812
|
+
* ```typescript
|
|
813
|
+
* const resolver = createVisibilityAwareResolver({
|
|
814
|
+
* schema: myZodSchema,
|
|
815
|
+
* getVisibility: () => ({ name: true, phone: false }),
|
|
816
|
+
* invisibleFieldValidation: "skip",
|
|
817
|
+
* });
|
|
818
|
+
*
|
|
819
|
+
* const form = useForm({ resolver });
|
|
820
|
+
* ```
|
|
821
|
+
*/
|
|
822
|
+
const createVisibilityAwareResolver = (options) => {
|
|
823
|
+
const baseResolver = (0, _hookform_resolvers_zod.zodResolver)(options.schema);
|
|
824
|
+
return async (values, context, resolverOptions) => {
|
|
825
|
+
const result = await baseResolver(values, context, resolverOptions);
|
|
826
|
+
if (!result.errors || options.invisibleFieldValidation === "validate") return result;
|
|
827
|
+
const visibility = options.getVisibility();
|
|
828
|
+
const warnMode = options.invisibleFieldValidation === "warn";
|
|
829
|
+
const filteredErrors = filterErrorsByVisibility(result.errors, visibility, warnMode);
|
|
830
|
+
return {
|
|
831
|
+
values: result.values,
|
|
832
|
+
errors: filteredErrors
|
|
833
|
+
};
|
|
834
|
+
};
|
|
835
|
+
};
|
|
836
|
+
|
|
837
|
+
//#endregion
|
|
838
|
+
//#region src/schema/fieldSchemas.ts
|
|
839
|
+
/**
|
|
840
|
+
* Build the base Zod schema for a select field.
|
|
841
|
+
*
|
|
842
|
+
* @param field - Select field configuration
|
|
843
|
+
* @returns Base Zod schema for the select field
|
|
844
|
+
*/
|
|
845
|
+
const buildSelectSchema = (field) => {
|
|
846
|
+
if (field.multiple) return zod.z.array(zod.z.union([zod.z.string(), zod.z.number()]));
|
|
847
|
+
return zod.z.union([
|
|
848
|
+
zod.z.string(),
|
|
849
|
+
zod.z.number(),
|
|
850
|
+
zod.z.null()
|
|
851
|
+
]);
|
|
852
|
+
};
|
|
853
|
+
/**
|
|
854
|
+
* Build the base Zod schema for an array field.
|
|
855
|
+
* Recursively generates schema from itemFields.
|
|
856
|
+
*
|
|
857
|
+
* @param field - Array field configuration
|
|
858
|
+
* @returns Base Zod schema for the array field
|
|
859
|
+
*/
|
|
860
|
+
const buildArraySchema = (field) => {
|
|
861
|
+
const itemShape = {};
|
|
862
|
+
for (const itemField of field.itemFields) itemShape[itemField.name] = buildFieldSchema(itemField);
|
|
863
|
+
let arraySchema = zod.z.array(zod.z.object(itemShape));
|
|
864
|
+
if (field.minItems !== void 0) arraySchema = arraySchema.min(field.minItems, `At least ${field.minItems} item(s) required`);
|
|
865
|
+
if (field.maxItems !== void 0) arraySchema = arraySchema.max(field.maxItems, `Maximum ${field.maxItems} item(s) allowed`);
|
|
866
|
+
return arraySchema;
|
|
867
|
+
};
|
|
868
|
+
/**
|
|
869
|
+
* Build the base Zod schema for a field based on its type.
|
|
870
|
+
*
|
|
871
|
+
* @param field - The field element configuration
|
|
872
|
+
* @returns Base Zod schema for the field type
|
|
873
|
+
*/
|
|
874
|
+
const buildBaseSchema = (field) => {
|
|
875
|
+
switch (field.type) {
|
|
876
|
+
case "text":
|
|
877
|
+
case "phone": return zod.z.string();
|
|
878
|
+
case "email": return zod.z.string().email("Invalid email address");
|
|
879
|
+
case "boolean": return zod.z.boolean();
|
|
880
|
+
case "date": return zod.z.string();
|
|
881
|
+
case "select": return buildSelectSchema(field);
|
|
882
|
+
case "array": return buildArraySchema(field);
|
|
883
|
+
case "custom": return zod.z.unknown();
|
|
884
|
+
default: return zod.z.unknown();
|
|
885
|
+
}
|
|
886
|
+
};
|
|
887
|
+
/**
|
|
888
|
+
* Apply validation rules to a string schema.
|
|
889
|
+
*
|
|
890
|
+
* @param schema - Base string schema
|
|
891
|
+
* @param validation - Validation configuration
|
|
892
|
+
* @returns Schema with validation rules applied
|
|
893
|
+
*/
|
|
894
|
+
const applyStringValidation = (schema, validation) => {
|
|
895
|
+
let result = schema;
|
|
896
|
+
if (validation.required) result = result.min(1, "This field is required");
|
|
897
|
+
if (validation.minLength !== void 0) result = result.min(validation.minLength, `Must be at least ${validation.minLength} characters`);
|
|
898
|
+
if (validation.maxLength !== void 0) result = result.max(validation.maxLength, `Must be no more than ${validation.maxLength} characters`);
|
|
899
|
+
if (validation.pattern) try {
|
|
900
|
+
const regex = new RegExp(validation.pattern);
|
|
901
|
+
result = result.regex(regex, validation.message || "Invalid format");
|
|
902
|
+
} catch {
|
|
903
|
+
console.warn(`Invalid regex pattern: ${validation.pattern}`);
|
|
904
|
+
}
|
|
905
|
+
return result;
|
|
906
|
+
};
|
|
907
|
+
/**
|
|
908
|
+
* Apply validation rules to a boolean schema.
|
|
909
|
+
*
|
|
910
|
+
* @param schema - Base boolean schema
|
|
911
|
+
* @param validation - Validation configuration
|
|
912
|
+
* @returns Schema with validation rules applied
|
|
913
|
+
*/
|
|
914
|
+
const applyBooleanValidation = (schema, validation) => {
|
|
915
|
+
if (validation.required) return schema.refine((val) => val === true, { message: "This field is required" });
|
|
916
|
+
return schema;
|
|
917
|
+
};
|
|
918
|
+
/**
|
|
919
|
+
* Apply validation rules to a select schema.
|
|
920
|
+
*
|
|
921
|
+
* @param schema - Base select schema
|
|
922
|
+
* @param validation - Validation configuration
|
|
923
|
+
* @param isMultiple - Whether this is a multi-select
|
|
924
|
+
* @returns Schema with validation rules applied
|
|
925
|
+
*/
|
|
926
|
+
const applySelectValidation = (schema, validation, isMultiple) => {
|
|
927
|
+
if (validation.required && isMultiple) return schema.min(1, "At least one selection is required");
|
|
928
|
+
if (validation.required && !isMultiple) return schema.refine((val) => val !== null && val !== void 0, { message: "This field is required" });
|
|
929
|
+
return schema;
|
|
930
|
+
};
|
|
931
|
+
/**
|
|
932
|
+
* Apply validation configuration to a Zod schema based on field type.
|
|
933
|
+
*
|
|
934
|
+
* @param schema - Base Zod schema
|
|
935
|
+
* @param validation - Validation configuration
|
|
936
|
+
* @param field - Field element configuration
|
|
937
|
+
* @returns Schema with validation rules applied
|
|
938
|
+
*/
|
|
939
|
+
const applyValidationRules = (schema, validation, field) => {
|
|
940
|
+
const fieldType = field.type;
|
|
941
|
+
if (fieldType === "text" || fieldType === "phone" || fieldType === "email" || fieldType === "date") return applyStringValidation(schema, validation);
|
|
942
|
+
if (fieldType === "boolean") return applyBooleanValidation(schema, validation);
|
|
943
|
+
if (fieldType === "select") return applySelectValidation(schema, validation, field.multiple ?? false);
|
|
944
|
+
return schema;
|
|
945
|
+
};
|
|
946
|
+
/**
|
|
947
|
+
* Build a complete Zod schema for a single field.
|
|
948
|
+
*
|
|
949
|
+
* @param field - Field element configuration
|
|
950
|
+
* @returns Zod schema for the field
|
|
951
|
+
*
|
|
952
|
+
* @example
|
|
953
|
+
* ```typescript
|
|
954
|
+
* const textField = {
|
|
955
|
+
* type: 'text',
|
|
956
|
+
* name: 'name',
|
|
957
|
+
* validation: { required: true, minLength: 3 }
|
|
958
|
+
* };
|
|
959
|
+
*
|
|
960
|
+
* const schema = buildFieldSchema(textField);
|
|
961
|
+
* // schema is z.string().min(1, 'required').min(3, '...')
|
|
962
|
+
* ```
|
|
963
|
+
*/
|
|
964
|
+
const buildFieldSchema = (field) => {
|
|
965
|
+
let schema = buildBaseSchema(field);
|
|
966
|
+
if (field.validation) schema = applyValidationRules(schema, field.validation, field);
|
|
967
|
+
return schema;
|
|
968
|
+
};
|
|
969
|
+
|
|
970
|
+
//#endregion
|
|
971
|
+
//#region src/validation/jsonLogic.ts
|
|
972
|
+
/**
|
|
973
|
+
* Register custom JSON Logic operations.
|
|
974
|
+
* Called once at module initialization.
|
|
975
|
+
*/
|
|
976
|
+
const registerCustomOperations = () => {
|
|
977
|
+
/**
|
|
978
|
+
* regex_match: Tests if a value matches a regex pattern.
|
|
979
|
+
*
|
|
980
|
+
* @example
|
|
981
|
+
* ```json
|
|
982
|
+
* { "regex_match": ["^[0-9]{10}$", { "var": "phone" }] }
|
|
983
|
+
* ```
|
|
984
|
+
*/
|
|
985
|
+
json_logic_js.default.add_operation("regex_match", (pattern, value) => {
|
|
986
|
+
if (typeof value !== "string" || typeof pattern !== "string") return false;
|
|
987
|
+
try {
|
|
988
|
+
return new RegExp(pattern).test(value);
|
|
989
|
+
} catch {
|
|
990
|
+
return false;
|
|
991
|
+
}
|
|
992
|
+
});
|
|
993
|
+
};
|
|
994
|
+
registerCustomOperations();
|
|
995
|
+
/**
|
|
996
|
+
* Evaluate a JSON Logic rule against form data.
|
|
997
|
+
*
|
|
998
|
+
* @param rule - JSON Logic rule to evaluate
|
|
999
|
+
* @param data - Form data to evaluate against
|
|
1000
|
+
* @returns Result of the evaluation
|
|
1001
|
+
*
|
|
1002
|
+
* @example
|
|
1003
|
+
* ```typescript
|
|
1004
|
+
* const rule = { "==": [{ var: "status" }, "active"] };
|
|
1005
|
+
* const data = { status: "active" };
|
|
1006
|
+
* applyJsonLogic(rule, data); // true
|
|
1007
|
+
* ```
|
|
1008
|
+
*/
|
|
1009
|
+
const applyJsonLogic = (rule, data) => {
|
|
1010
|
+
return json_logic_js.default.apply(rule, data);
|
|
1011
|
+
};
|
|
1012
|
+
/**
|
|
1013
|
+
* Evaluate a JSON Logic rule and return boolean result.
|
|
1014
|
+
* Returns true if rule evaluates to a truthy value.
|
|
1015
|
+
*
|
|
1016
|
+
* @param rule - JSON Logic condition
|
|
1017
|
+
* @param data - Form data
|
|
1018
|
+
* @returns true if condition passes, false otherwise
|
|
1019
|
+
*
|
|
1020
|
+
* @example
|
|
1021
|
+
* ```typescript
|
|
1022
|
+
* const rule = { "and": [{ var: "active" }, { var: "confirmed" }] };
|
|
1023
|
+
* evaluateCondition(rule, { active: true, confirmed: true }); // true
|
|
1024
|
+
* evaluateCondition(rule, { active: true, confirmed: false }); // false
|
|
1025
|
+
* ```
|
|
1026
|
+
*/
|
|
1027
|
+
const evaluateCondition = (rule, data) => {
|
|
1028
|
+
const result = applyJsonLogic(rule, data);
|
|
1029
|
+
return Boolean(result);
|
|
1030
|
+
};
|
|
1031
|
+
|
|
1032
|
+
//#endregion
|
|
1033
|
+
//#region src/utils/calculateVisibility.ts
|
|
1034
|
+
/**
|
|
1035
|
+
* Compare two visibility states and return the new state only if changed.
|
|
1036
|
+
* Returns prev if no change (preserves reference equality for React).
|
|
1037
|
+
*/
|
|
1038
|
+
const getUpdatedVisibility = (prev, next) => {
|
|
1039
|
+
const keys = new Set([...Object.keys(prev), ...Object.keys(next)]);
|
|
1040
|
+
for (const key of keys) if (prev[key] !== next[key]) return next;
|
|
1041
|
+
return prev;
|
|
1042
|
+
};
|
|
1043
|
+
/**
|
|
1044
|
+
* Calculate visibility state for all fields based on their visibility rules.
|
|
1045
|
+
* Evaluates JSON Logic rules against current form data.
|
|
1046
|
+
*
|
|
1047
|
+
* @param elements - Array of form elements
|
|
1048
|
+
* @param formData - Current form values
|
|
1049
|
+
* @returns Visibility state for all fields
|
|
1050
|
+
*
|
|
1051
|
+
* @example
|
|
1052
|
+
* ```typescript
|
|
1053
|
+
* const elements = [
|
|
1054
|
+
* { type: 'text', name: 'reason', visible: { "==": [{ "var": "type" }, "other"] } },
|
|
1055
|
+
* { type: 'text', name: 'name' }
|
|
1056
|
+
* ];
|
|
1057
|
+
*
|
|
1058
|
+
* const visibility = calculateVisibility(elements, { type: 'other' });
|
|
1059
|
+
* // Returns: { reason: true, name: true }
|
|
1060
|
+
*
|
|
1061
|
+
* const visibility2 = calculateVisibility(elements, { type: 'standard' });
|
|
1062
|
+
* // Returns: { reason: false, name: true }
|
|
1063
|
+
* ```
|
|
1064
|
+
*/
|
|
1065
|
+
const calculateVisibility = (elements, formData) => {
|
|
1066
|
+
const visibility = {};
|
|
1067
|
+
const processElement = (element, parentVisible) => {
|
|
1068
|
+
if (isFieldElement(element)) processField(element, parentVisible);
|
|
1069
|
+
else if (isContainerElement(element)) processContainer(element, parentVisible);
|
|
1070
|
+
else if (isColumnElement(element)) processColumn(element, parentVisible);
|
|
1071
|
+
};
|
|
1072
|
+
const processField = (field, parentVisible) => {
|
|
1073
|
+
if (!parentVisible) {
|
|
1074
|
+
visibility[field.name] = false;
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
if (!field.visible) {
|
|
1078
|
+
visibility[field.name] = true;
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
visibility[field.name] = evaluateCondition(field.visible, formData);
|
|
1082
|
+
};
|
|
1083
|
+
const processContainer = (container, parentVisible) => {
|
|
1084
|
+
let containerVisible = parentVisible;
|
|
1085
|
+
if (container.visible && parentVisible) containerVisible = evaluateCondition(container.visible, formData);
|
|
1086
|
+
for (const column of container.columns) processColumn(column, containerVisible);
|
|
1087
|
+
};
|
|
1088
|
+
const processColumn = (column, parentVisible) => {
|
|
1089
|
+
let columnVisible = parentVisible;
|
|
1090
|
+
if (column.visible && parentVisible) columnVisible = evaluateCondition(column.visible, formData);
|
|
1091
|
+
for (const element of column.elements) processElement(element, columnVisible);
|
|
1092
|
+
};
|
|
1093
|
+
for (const element of elements) processElement(element, true);
|
|
1094
|
+
return visibility;
|
|
1095
|
+
};
|
|
1096
|
+
|
|
1097
|
+
//#endregion
|
|
1098
|
+
//#region src/utils/flattenFields.ts
|
|
1099
|
+
/**
|
|
1100
|
+
* Recursively extracts all field elements from a form configuration.
|
|
1101
|
+
* Traverses containers and columns to find nested fields.
|
|
1102
|
+
*
|
|
1103
|
+
* @param elements - Array of form elements (may include containers/columns)
|
|
1104
|
+
* @returns Flat array of all field elements
|
|
1105
|
+
*
|
|
1106
|
+
* @example
|
|
1107
|
+
* ```typescript
|
|
1108
|
+
* const config = {
|
|
1109
|
+
* elements: [
|
|
1110
|
+
* { type: 'text', name: 'name' },
|
|
1111
|
+
* {
|
|
1112
|
+
* type: 'container',
|
|
1113
|
+
* columns: [{
|
|
1114
|
+
* type: 'column',
|
|
1115
|
+
* width: '50%',
|
|
1116
|
+
* elements: [{ type: 'email', name: 'email' }]
|
|
1117
|
+
* }]
|
|
1118
|
+
* }
|
|
1119
|
+
* ]
|
|
1120
|
+
* };
|
|
1121
|
+
*
|
|
1122
|
+
* const fields = flattenFields(config.elements);
|
|
1123
|
+
* // Returns: [{ type: 'text', name: 'name' }, { type: 'email', name: 'email' }]
|
|
1124
|
+
* ```
|
|
1125
|
+
*/
|
|
1126
|
+
const flattenFields = (elements) => {
|
|
1127
|
+
const fields = [];
|
|
1128
|
+
const processElement = (element) => {
|
|
1129
|
+
if (isFieldElement(element)) fields.push(element);
|
|
1130
|
+
else if (isContainerElement(element)) processContainer(element);
|
|
1131
|
+
else if (isColumnElement(element)) processColumn(element);
|
|
1132
|
+
};
|
|
1133
|
+
const processContainer = (container) => {
|
|
1134
|
+
for (const column of container.columns) processColumn(column);
|
|
1135
|
+
};
|
|
1136
|
+
const processColumn = (column) => {
|
|
1137
|
+
for (const element of column.elements) processElement(element);
|
|
1138
|
+
};
|
|
1139
|
+
for (const element of elements) processElement(element);
|
|
1140
|
+
return fields;
|
|
1141
|
+
};
|
|
1142
|
+
/**
|
|
1143
|
+
* Gets all field names from a form configuration.
|
|
1144
|
+
* Useful for initializing form state or visibility tracking.
|
|
1145
|
+
*
|
|
1146
|
+
* @param elements - Array of form elements
|
|
1147
|
+
* @returns Array of field names (including nested paths like 'source.name')
|
|
1148
|
+
*/
|
|
1149
|
+
const getFieldNames = (elements) => {
|
|
1150
|
+
return flattenFields(elements).map((field) => field.name);
|
|
1151
|
+
};
|
|
1152
|
+
|
|
1153
|
+
//#endregion
|
|
1154
|
+
//#region src/utils/dependencies.ts
|
|
1155
|
+
/**
|
|
1156
|
+
* Builds a dependency map from form elements.
|
|
1157
|
+
* Maps parent field names to their dependent children.
|
|
1158
|
+
*
|
|
1159
|
+
* @param elements - Array of form elements
|
|
1160
|
+
* @returns Map of parent field names to dependent field names
|
|
1161
|
+
*
|
|
1162
|
+
* @example
|
|
1163
|
+
* ```typescript
|
|
1164
|
+
* const elements = [
|
|
1165
|
+
* { type: 'select', name: 'country', options: [...] },
|
|
1166
|
+
* { type: 'select', name: 'city', dependsOn: 'country', options: [...] }
|
|
1167
|
+
* ];
|
|
1168
|
+
*
|
|
1169
|
+
* const map = buildDependencyMap(elements);
|
|
1170
|
+
* // Returns: { country: ['city'] }
|
|
1171
|
+
* ```
|
|
1172
|
+
*/
|
|
1173
|
+
const buildDependencyMap = (elements) => {
|
|
1174
|
+
const map = {};
|
|
1175
|
+
const fields = flattenFields(elements);
|
|
1176
|
+
for (const field of fields) if (field.dependsOn) {
|
|
1177
|
+
const parent = field.dependsOn;
|
|
1178
|
+
if (!map[parent]) map[parent] = [];
|
|
1179
|
+
map[parent].push(field.name);
|
|
1180
|
+
}
|
|
1181
|
+
return map;
|
|
1182
|
+
};
|
|
1183
|
+
/**
|
|
1184
|
+
* Finds a field by name in the form elements.
|
|
1185
|
+
*
|
|
1186
|
+
* @param elements - Array of form elements
|
|
1187
|
+
* @param name - Field name to find
|
|
1188
|
+
* @returns Field element if found, undefined otherwise
|
|
1189
|
+
*/
|
|
1190
|
+
const findFieldByName = (elements, name) => {
|
|
1191
|
+
return flattenFields(elements).find((field) => field.name === name);
|
|
1192
|
+
};
|
|
1193
|
+
/**
|
|
1194
|
+
* Gets the default value for a field based on its type.
|
|
1195
|
+
* Used when resetting dependent fields.
|
|
1196
|
+
*
|
|
1197
|
+
* @param field - Field element
|
|
1198
|
+
* @returns Default value appropriate for the field type
|
|
1199
|
+
*/
|
|
1200
|
+
const getFieldTypeDefault = (field) => {
|
|
1201
|
+
switch (field.type) {
|
|
1202
|
+
case "boolean": return false;
|
|
1203
|
+
case "select": return field.multiple ? [] : null;
|
|
1204
|
+
case "array": return [];
|
|
1205
|
+
default: return "";
|
|
1206
|
+
}
|
|
1207
|
+
};
|
|
1208
|
+
/**
|
|
1209
|
+
* Gets the effective default value for a field.
|
|
1210
|
+
* Priority: config.defaultValue > type default
|
|
1211
|
+
*
|
|
1212
|
+
* @param field - Field element
|
|
1213
|
+
* @returns Default value to use when resetting
|
|
1214
|
+
*/
|
|
1215
|
+
const getFieldDefault = (field) => {
|
|
1216
|
+
if (field.defaultValue !== void 0) return field.defaultValue;
|
|
1217
|
+
return getFieldTypeDefault(field);
|
|
1218
|
+
};
|
|
1219
|
+
|
|
1220
|
+
//#endregion
|
|
1221
|
+
//#region src/utils/mergeDefaults.ts
|
|
1222
|
+
/**
|
|
1223
|
+
* Sets a value in a nested object using dot notation path.
|
|
1224
|
+
*
|
|
1225
|
+
* @param obj - Object to modify
|
|
1226
|
+
* @param path - Dot-notation path (e.g., 'source.name')
|
|
1227
|
+
* @param value - Value to set
|
|
1228
|
+
*
|
|
1229
|
+
* @example
|
|
1230
|
+
* ```typescript
|
|
1231
|
+
* const obj = {};
|
|
1232
|
+
* setNestedValue(obj, 'source.name', 'John');
|
|
1233
|
+
* // obj is now { source: { name: 'John' } }
|
|
1234
|
+
* ```
|
|
1235
|
+
*/
|
|
1236
|
+
const setNestedValue = (obj, path, value) => {
|
|
1237
|
+
const parts = path.split(".");
|
|
1238
|
+
let current = obj;
|
|
1239
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
1240
|
+
const part = parts[i];
|
|
1241
|
+
if (!(part in current) || typeof current[part] !== "object" || current[part] === null) current[part] = {};
|
|
1242
|
+
current = current[part];
|
|
1243
|
+
}
|
|
1244
|
+
const lastPart = parts.at(-1);
|
|
1245
|
+
if (lastPart !== void 0) current[lastPart] = value;
|
|
1246
|
+
};
|
|
1247
|
+
/**
|
|
1248
|
+
* Gets a value from a nested object using dot notation path.
|
|
1249
|
+
*
|
|
1250
|
+
* @param obj - Object to read from
|
|
1251
|
+
* @param path - Dot-notation path (e.g., 'source.name')
|
|
1252
|
+
* @returns The value at the path, or undefined if not found
|
|
1253
|
+
*
|
|
1254
|
+
* @example
|
|
1255
|
+
* ```typescript
|
|
1256
|
+
* const obj = { source: { name: 'John' } };
|
|
1257
|
+
* getNestedValue(obj, 'source.name'); // 'John'
|
|
1258
|
+
* getNestedValue(obj, 'source.email'); // undefined
|
|
1259
|
+
* ```
|
|
1260
|
+
*/
|
|
1261
|
+
const getNestedValue = (obj, path) => {
|
|
1262
|
+
const parts = path.split(".");
|
|
1263
|
+
let current = obj;
|
|
1264
|
+
for (const part of parts) {
|
|
1265
|
+
if (current === null || current === void 0 || typeof current !== "object") return;
|
|
1266
|
+
current = current[part];
|
|
1267
|
+
}
|
|
1268
|
+
return current;
|
|
1269
|
+
};
|
|
1270
|
+
/**
|
|
1271
|
+
* Deep merge two objects. Source values override target values.
|
|
1272
|
+
*
|
|
1273
|
+
* @param target - Base object
|
|
1274
|
+
* @param source - Object to merge in (takes precedence)
|
|
1275
|
+
* @returns New merged object
|
|
1276
|
+
*/
|
|
1277
|
+
const deepMerge = (target, source) => {
|
|
1278
|
+
const result = { ...target };
|
|
1279
|
+
for (const key in source) if (Object.hasOwn(source, key)) {
|
|
1280
|
+
const sourceValue = source[key];
|
|
1281
|
+
const targetValue = result[key];
|
|
1282
|
+
if (sourceValue !== null && typeof sourceValue === "object" && !Array.isArray(sourceValue) && targetValue !== null && typeof targetValue === "object" && !Array.isArray(targetValue)) result[key] = deepMerge(targetValue, sourceValue);
|
|
1283
|
+
else result[key] = sourceValue;
|
|
1284
|
+
}
|
|
1285
|
+
return result;
|
|
1286
|
+
};
|
|
1287
|
+
/**
|
|
1288
|
+
* Gets the default value for a field based on its type.
|
|
1289
|
+
*
|
|
1290
|
+
* @param field - Field element
|
|
1291
|
+
* @returns Appropriate default value for the field type
|
|
1292
|
+
*/
|
|
1293
|
+
const getTypeDefault = (field) => {
|
|
1294
|
+
switch (field.type) {
|
|
1295
|
+
case "boolean": return false;
|
|
1296
|
+
case "select": return field.multiple ? [] : null;
|
|
1297
|
+
case "array": return [];
|
|
1298
|
+
case "text":
|
|
1299
|
+
case "email":
|
|
1300
|
+
case "phone":
|
|
1301
|
+
case "date": return "";
|
|
1302
|
+
default: return "";
|
|
1303
|
+
}
|
|
1304
|
+
};
|
|
1305
|
+
/**
|
|
1306
|
+
* Merges configuration default values with initial data.
|
|
1307
|
+
* Priority: initialData > config.defaultValue > type default
|
|
1308
|
+
*
|
|
1309
|
+
* @param config - Form configuration containing field definitions
|
|
1310
|
+
* @param initialData - Initial data provided by the user
|
|
1311
|
+
* @returns Merged default values for react-hook-form
|
|
1312
|
+
*
|
|
1313
|
+
* @example
|
|
1314
|
+
* ```typescript
|
|
1315
|
+
* const config = {
|
|
1316
|
+
* elements: [
|
|
1317
|
+
* { type: 'text', name: 'source.name', defaultValue: 'Default Name' },
|
|
1318
|
+
* { type: 'boolean', name: 'source.active' }
|
|
1319
|
+
* ]
|
|
1320
|
+
* };
|
|
1321
|
+
*
|
|
1322
|
+
* const initialData = { source: { name: 'Provided Name' } };
|
|
1323
|
+
* const defaults = mergeDefaults(config, initialData);
|
|
1324
|
+
* // Result: { source: { name: 'Provided Name', active: false } }
|
|
1325
|
+
* ```
|
|
1326
|
+
*/
|
|
1327
|
+
const mergeDefaults = (config, initialData) => {
|
|
1328
|
+
const defaults = {};
|
|
1329
|
+
const fields = flattenFields(config.elements);
|
|
1330
|
+
for (const field of fields) {
|
|
1331
|
+
const typeDefault = getTypeDefault(field);
|
|
1332
|
+
const configDefault = field.defaultValue ?? typeDefault;
|
|
1333
|
+
setNestedValue(defaults, field.name, configDefault);
|
|
1334
|
+
}
|
|
1335
|
+
if (initialData) return deepMerge(defaults, initialData);
|
|
1336
|
+
return defaults;
|
|
1337
|
+
};
|
|
1338
|
+
|
|
1339
|
+
//#endregion
|
|
1340
|
+
//#region src/schema/nestedPaths.ts
|
|
1341
|
+
/**
|
|
1342
|
+
* Sets a nested schema value using dot notation path.
|
|
1343
|
+
* Builds nested z.object structures as needed.
|
|
1344
|
+
*
|
|
1345
|
+
* @param shape - The shape object to modify
|
|
1346
|
+
* @param path - Dot-notation path (e.g., 'source.name')
|
|
1347
|
+
* @param schema - Zod schema to set at the path
|
|
1348
|
+
*
|
|
1349
|
+
* @example
|
|
1350
|
+
* ```typescript
|
|
1351
|
+
* const shape = {};
|
|
1352
|
+
* setNestedSchema(shape, 'source.name', z.string());
|
|
1353
|
+
* // shape is now: { source: ZodObject({ name: ZodString }) }
|
|
1354
|
+
*
|
|
1355
|
+
* setNestedSchema(shape, 'source.email', z.string().email());
|
|
1356
|
+
* // shape is now: { source: ZodObject({ name: ZodString, email: ZodString }) }
|
|
1357
|
+
* ```
|
|
1358
|
+
*/
|
|
1359
|
+
const setNestedSchema = (shape, path, schema) => {
|
|
1360
|
+
const parts = path.split(".");
|
|
1361
|
+
if (parts.length === 1) {
|
|
1362
|
+
shape[path] = schema;
|
|
1363
|
+
return;
|
|
1364
|
+
}
|
|
1365
|
+
const [first, ...rest] = parts;
|
|
1366
|
+
const remainingPath = rest.join(".");
|
|
1367
|
+
if (!shape[first]) shape[first] = zod.z.object({});
|
|
1368
|
+
const existingSchema = shape[first];
|
|
1369
|
+
if (existingSchema instanceof zod.ZodObject) {
|
|
1370
|
+
const innerShape = { ...existingSchema.shape };
|
|
1371
|
+
setNestedSchema(innerShape, remainingPath, schema);
|
|
1372
|
+
shape[first] = zod.z.object(innerShape);
|
|
1373
|
+
} else {
|
|
1374
|
+
const innerShape = {};
|
|
1375
|
+
setNestedSchema(innerShape, remainingPath, schema);
|
|
1376
|
+
shape[first] = zod.z.object(innerShape);
|
|
1377
|
+
}
|
|
1378
|
+
};
|
|
1379
|
+
|
|
1380
|
+
//#endregion
|
|
1381
|
+
//#region src/schema/generateSchema.ts
|
|
1382
|
+
/**
|
|
1383
|
+
* Extract all JSON Logic validation conditions from fields.
|
|
1384
|
+
*
|
|
1385
|
+
* @param fields - Array of field elements
|
|
1386
|
+
* @returns Array of field conditions to evaluate
|
|
1387
|
+
*/
|
|
1388
|
+
const collectConditions = (fields) => {
|
|
1389
|
+
const conditions = [];
|
|
1390
|
+
for (const field of fields) if (field.validation?.condition) conditions.push({
|
|
1391
|
+
fieldPath: field.name,
|
|
1392
|
+
condition: field.validation.condition,
|
|
1393
|
+
message: field.validation.message || "Validation failed"
|
|
1394
|
+
});
|
|
1395
|
+
return conditions;
|
|
1396
|
+
};
|
|
1397
|
+
/**
|
|
1398
|
+
* Generate a Zod schema from form configuration.
|
|
1399
|
+
* Supports nested field paths via dot notation.
|
|
1400
|
+
*
|
|
1401
|
+
* This function is called once when the form initializes and the schema
|
|
1402
|
+
* is memoized. Visibility changes are handled at validation time, not
|
|
1403
|
+
* by regenerating the schema.
|
|
1404
|
+
*
|
|
1405
|
+
* @param config - Form configuration object
|
|
1406
|
+
* @returns Zod object schema for validating form data
|
|
1407
|
+
*
|
|
1408
|
+
* @example
|
|
1409
|
+
* ```typescript
|
|
1410
|
+
* const config = {
|
|
1411
|
+
* elements: [
|
|
1412
|
+
* { type: 'text', name: 'source.name', validation: { required: true } },
|
|
1413
|
+
* { type: 'email', name: 'source.email' },
|
|
1414
|
+
* { type: 'boolean', name: 'active' }
|
|
1415
|
+
* ]
|
|
1416
|
+
* };
|
|
1417
|
+
*
|
|
1418
|
+
* const schema = generateZodSchema(config);
|
|
1419
|
+
*
|
|
1420
|
+
* // The generated schema is equivalent to:
|
|
1421
|
+
* // z.object({
|
|
1422
|
+
* // source: z.object({
|
|
1423
|
+
* // name: z.string().min(1, 'required'),
|
|
1424
|
+
* // email: z.string().email()
|
|
1425
|
+
* // }),
|
|
1426
|
+
* // active: z.boolean()
|
|
1427
|
+
* // })
|
|
1428
|
+
*
|
|
1429
|
+
* schema.parse({
|
|
1430
|
+
* source: { name: 'John', email: 'john@example.com' },
|
|
1431
|
+
* active: true
|
|
1432
|
+
* }); // Valid
|
|
1433
|
+
* ```
|
|
1434
|
+
*/
|
|
1435
|
+
const generateZodSchema = (config) => {
|
|
1436
|
+
const fields = flattenFields(config.elements);
|
|
1437
|
+
const schemaShape = {};
|
|
1438
|
+
for (const field of fields) {
|
|
1439
|
+
const fieldSchema = buildFieldSchema(field);
|
|
1440
|
+
setNestedSchema(schemaShape, field.name, fieldSchema);
|
|
1441
|
+
}
|
|
1442
|
+
let schema = zod.z.object(schemaShape);
|
|
1443
|
+
const conditions = collectConditions(fields);
|
|
1444
|
+
if (conditions.length > 0) schema = schema.superRefine((data, ctx) => {
|
|
1445
|
+
for (const { fieldPath, condition, message } of conditions) if (!evaluateCondition(condition, data)) ctx.addIssue({
|
|
1446
|
+
code: zod.z.ZodIssueCode.custom,
|
|
1447
|
+
message,
|
|
1448
|
+
path: fieldPath.split(".")
|
|
1449
|
+
});
|
|
1450
|
+
});
|
|
1451
|
+
return schema;
|
|
1452
|
+
};
|
|
1453
|
+
/**
|
|
1454
|
+
* Extract field paths from a generated schema.
|
|
1455
|
+
* Returns all top-level and nested paths.
|
|
1456
|
+
*
|
|
1457
|
+
* @param schema - Generated Zod schema
|
|
1458
|
+
* @param prefix - Current path prefix (used in recursion)
|
|
1459
|
+
* @returns Array of all field paths
|
|
1460
|
+
*/
|
|
1461
|
+
const getSchemaFieldPaths = (schema, prefix = "") => {
|
|
1462
|
+
const paths = [];
|
|
1463
|
+
const shape = schema.shape;
|
|
1464
|
+
for (const key in shape) if (Object.hasOwn(shape, key)) {
|
|
1465
|
+
const fullPath = prefix ? `${prefix}.${key}` : key;
|
|
1466
|
+
const fieldSchema = shape[key];
|
|
1467
|
+
if (fieldSchema instanceof zod.ZodObject) paths.push(...getSchemaFieldPaths(fieldSchema, fullPath));
|
|
1468
|
+
else paths.push(fullPath);
|
|
1469
|
+
}
|
|
1470
|
+
return paths;
|
|
1471
|
+
};
|
|
1472
|
+
|
|
1473
|
+
//#endregion
|
|
1474
|
+
//#region src/DynamicForm.tsx
|
|
1475
|
+
const DynamicForm = ({ config, initialData, fieldComponents, customComponents = {}, customContainers = {}, onSubmit, onChange, onValidationChange, onReset, onError, mode = "onChange", invisibleFieldValidation = "skip", className, style, id, children, fieldWrapper, ref }) => {
|
|
1476
|
+
const parsedConfig = (0, react.useMemo)(() => {
|
|
1477
|
+
return validateCustomComponents(parseConfiguration(config), customComponents);
|
|
1478
|
+
}, [config, customComponents]);
|
|
1479
|
+
const zodSchema = (0, react.useMemo)(() => generateZodSchema(parsedConfig), [parsedConfig]);
|
|
1480
|
+
const defaultValues = (0, react.useMemo)(() => mergeDefaults(parsedConfig, initialData), [parsedConfig, initialData]);
|
|
1481
|
+
const [visibility, setVisibility] = (0, react.useState)(() => calculateVisibility(parsedConfig.elements, defaultValues));
|
|
1482
|
+
const visibilityRef = (0, react.useRef)(visibility);
|
|
1483
|
+
visibilityRef.current = visibility;
|
|
1484
|
+
const onChangeRef = (0, react.useRef)(onChange);
|
|
1485
|
+
onChangeRef.current = onChange;
|
|
1486
|
+
const form = (0, react_hook_form.useForm)({
|
|
1487
|
+
defaultValues,
|
|
1488
|
+
resolver: (0, react.useMemo)(() => createVisibilityAwareResolver({
|
|
1489
|
+
schema: zodSchema,
|
|
1490
|
+
getVisibility: () => visibilityRef.current,
|
|
1491
|
+
invisibleFieldValidation
|
|
1492
|
+
}), [zodSchema, invisibleFieldValidation]),
|
|
1493
|
+
mode
|
|
1494
|
+
});
|
|
1495
|
+
(0, react.useImperativeHandle)(ref, () => ({
|
|
1496
|
+
getValues: () => form.getValues(),
|
|
1497
|
+
setValue: (name, value) => form.setValue(name, value),
|
|
1498
|
+
watchAll: () => form.watch(),
|
|
1499
|
+
watchField: (name) => form.watch(name),
|
|
1500
|
+
reset: (values) => form.reset(values ?? defaultValues),
|
|
1501
|
+
trigger: (name) => form.trigger(name)
|
|
1502
|
+
}), [form, defaultValues]);
|
|
1503
|
+
const dependencyMap = (0, react.useMemo)(() => buildDependencyMap(parsedConfig.elements), [parsedConfig]);
|
|
1504
|
+
const previousValuesRef = (0, react.useRef)({});
|
|
1505
|
+
(0, react.useEffect)(() => {
|
|
1506
|
+
const handleDependencyReset = (fieldName, formValues) => {
|
|
1507
|
+
const dependents = dependencyMap[fieldName];
|
|
1508
|
+
if (!dependents) return;
|
|
1509
|
+
const currentValue = getNestedValue(formValues, fieldName);
|
|
1510
|
+
if (currentValue === getNestedValue(previousValuesRef.current, fieldName)) return;
|
|
1511
|
+
setNestedValue(previousValuesRef.current, fieldName, currentValue);
|
|
1512
|
+
for (const dep of dependents) {
|
|
1513
|
+
const field = findFieldByName(parsedConfig.elements, dep);
|
|
1514
|
+
if (field && field.resetOnParentChange !== false) form.setValue(dep, getFieldDefault(field));
|
|
1515
|
+
}
|
|
1516
|
+
};
|
|
1517
|
+
const subscription = form.watch((values, { name }) => {
|
|
1518
|
+
const formValues = values;
|
|
1519
|
+
const newVisibility = calculateVisibility(parsedConfig.elements, formValues);
|
|
1520
|
+
setVisibility((prev) => getUpdatedVisibility(prev, newVisibility));
|
|
1521
|
+
if (!name) return;
|
|
1522
|
+
handleDependencyReset(name, formValues);
|
|
1523
|
+
onChangeRef.current?.(values, name);
|
|
1524
|
+
});
|
|
1525
|
+
return () => subscription.unsubscribe();
|
|
1526
|
+
}, [
|
|
1527
|
+
form,
|
|
1528
|
+
parsedConfig,
|
|
1529
|
+
dependencyMap
|
|
1530
|
+
]);
|
|
1531
|
+
const { errors: formErrors, isValid: formIsValid } = (0, react_hook_form.useFormState)({ control: form.control });
|
|
1532
|
+
(0, react.useEffect)(() => {
|
|
1533
|
+
if (!onValidationChange) return;
|
|
1534
|
+
onValidationChange(formErrors, formIsValid);
|
|
1535
|
+
}, [
|
|
1536
|
+
formErrors,
|
|
1537
|
+
formIsValid,
|
|
1538
|
+
onValidationChange
|
|
1539
|
+
]);
|
|
1540
|
+
const contextValue = (0, react.useMemo)(() => ({
|
|
1541
|
+
form,
|
|
1542
|
+
config: parsedConfig,
|
|
1543
|
+
fieldComponents,
|
|
1544
|
+
customComponents,
|
|
1545
|
+
customContainers,
|
|
1546
|
+
visibility,
|
|
1547
|
+
fieldWrapper
|
|
1548
|
+
}), [
|
|
1549
|
+
form,
|
|
1550
|
+
parsedConfig,
|
|
1551
|
+
fieldComponents,
|
|
1552
|
+
customComponents,
|
|
1553
|
+
customContainers,
|
|
1554
|
+
visibility,
|
|
1555
|
+
fieldWrapper
|
|
1556
|
+
]);
|
|
1557
|
+
const handleSubmit = form.handleSubmit(onSubmit, (errors) => onError?.(errors));
|
|
1558
|
+
const handleReset = (0, react.useCallback)(() => {
|
|
1559
|
+
form.reset(defaultValues);
|
|
1560
|
+
onReset?.();
|
|
1561
|
+
}, [
|
|
1562
|
+
defaultValues,
|
|
1563
|
+
onReset,
|
|
1564
|
+
form
|
|
1565
|
+
]);
|
|
1566
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_hook_form.FormProvider, {
|
|
1567
|
+
...form,
|
|
1568
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DynamicFormContext.Provider, {
|
|
1569
|
+
value: contextValue,
|
|
1570
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("form", {
|
|
1571
|
+
className,
|
|
1572
|
+
id,
|
|
1573
|
+
noValidate: true,
|
|
1574
|
+
onReset: handleReset,
|
|
1575
|
+
onSubmit: handleSubmit,
|
|
1576
|
+
style,
|
|
1577
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(FormRenderer, { elements: parsedConfig.elements }), children]
|
|
1578
|
+
})
|
|
1579
|
+
})
|
|
1580
|
+
});
|
|
1581
|
+
};
|
|
1582
|
+
DynamicForm.displayName = "DynamicForm";
|
|
1583
|
+
var DynamicForm_default = DynamicForm;
|
|
1584
|
+
|
|
1585
|
+
//#endregion
|
|
1586
|
+
exports.ConfigurationError = ConfigurationError;
|
|
1587
|
+
exports.DynamicForm = DynamicForm;
|
|
1588
|
+
exports.DynamicFormContext = DynamicFormContext;
|
|
1589
|
+
exports.applyJsonLogic = applyJsonLogic;
|
|
1590
|
+
exports.buildFieldSchema = buildFieldSchema;
|
|
1591
|
+
exports.calculateVisibility = calculateVisibility;
|
|
1592
|
+
exports.createVisibilityAwareResolver = createVisibilityAwareResolver;
|
|
1593
|
+
exports.default = DynamicForm_default;
|
|
1594
|
+
exports.defineCustomComponent = defineCustomComponent;
|
|
1595
|
+
exports.evaluateCondition = evaluateCondition;
|
|
1596
|
+
exports.flattenFields = flattenFields;
|
|
1597
|
+
exports.generateZodSchema = generateZodSchema;
|
|
1598
|
+
exports.getFieldNames = getFieldNames;
|
|
1599
|
+
exports.getNestedValue = getNestedValue;
|
|
1600
|
+
exports.getSchemaFieldPaths = getSchemaFieldPaths;
|
|
1601
|
+
exports.isArrayFieldElement = isArrayFieldElement;
|
|
1602
|
+
exports.isColumnElement = isColumnElement;
|
|
1603
|
+
exports.isContainerElement = isContainerElement;
|
|
1604
|
+
exports.isCustomFieldElement = isCustomFieldElement;
|
|
1605
|
+
exports.isFieldElement = isFieldElement;
|
|
1606
|
+
exports.mergeDefaults = mergeDefaults;
|
|
1607
|
+
exports.parseConfiguration = parseConfiguration;
|
|
1608
|
+
exports.safeParseConfiguration = safeParseConfiguration;
|
|
1609
|
+
exports.setNestedValue = setNestedValue;
|
|
1610
|
+
exports.useDynamicFormContext = useDynamicFormContext;
|
|
1611
|
+
exports.useDynamicFormContextSafe = useDynamicFormContextSafe;
|