@docvhcmc/react-form-schema
Version:
A flexible React form schema manager with pluggable validation adapters (Zod, Yup, etc.)
716 lines (710 loc) • 23.7 kB
JavaScript
import {
FormErrorManager,
ValidationCode,
ValidationError
} from "../../chunk-OXZAPTIP.mjs";
// src/adapters/zod3/FormSchema.ts
import { z as z3 } from "zod";
// src/adapters/zod3/buildValidationError.ts
import { z } from "zod";
function buildValidationError(e, field, debug) {
if (debug) {
console.log("buildValidationError called for:", e, "field:", field);
}
if (e instanceof z.ZodError) {
return new ValidationError(
e.issues.map((issue) => {
const { path, code, message, ...params } = issue;
const origin = "origin" in issue && typeof issue.origin === "string" ? issue.origin : void 0;
const combinedCode = origin ? [origin, code || ""].filter((a) => !!a).join(".") : code || "";
return {
path: (path || []).join("."),
// Ensure path is a string for FieldError
code: combinedCode,
message,
origin,
params
};
}),
e
);
}
const errorMessage = e instanceof Error ? e.message : String(e);
if (field) {
return new ValidationError(
[
{
path: field,
code: "UNEXPECTED" /* UNEXPECTED */,
// Use a generic code for unexpected errors
message: errorMessage || "An unexpected error occurred."
}
],
e
);
}
return new ValidationError(
[
{
path: "",
// General form-level error
code: "UNEXPECTED" /* UNEXPECTED */,
message: errorMessage || "An unexpected error occurred during validation."
}
],
e
);
}
// src/adapters/zod3/normalizeZodSchemaOptions.ts
import { z as z2 } from "zod";
function normalizeZodSchemaOptions(obj) {
const result = {};
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
const value = obj[key];
result[key] = typeof value === "function" ? value(z2) : value;
}
}
return result;
}
// src/adapters/zod3/FormSchema.ts
var FormSchema = class {
/**
* Constructs a new FormSchema instance.
* @param schemaOptions The raw schema definitions for each form field, optionally followed by custom validation rules.
* @param initialValues Optional initial values to pre-populate the form's raw input.
* @param debug Optional. If true, enables debug logging for the form schema instance.
*/
constructor(schemaOptions, initialValues, debug) {
this.schemaOptions = schemaOptions;
this._fieldSchemas = Array.isArray(schemaOptions) ? normalizeZodSchemaOptions(schemaOptions[0]) : normalizeZodSchemaOptions(schemaOptions);
this._schema = z3.object(this._fieldSchemas);
if (initialValues) {
this._rawInput = { ...initialValues };
}
if (Array.isArray(schemaOptions)) {
const [, ...rules] = schemaOptions;
rules.forEach((rule) => {
this.applyRule(rule);
});
}
this._debug = process.env.NODE_ENV === "production" ? false : debug || false;
this._errorsManager = new FormErrorManager();
if (this._debug) {
console.log("FormSchema initialized with debug mode.", {
schemaOptions,
initialValues
});
}
}
// Stores the raw, untransformed input values from the form.
_rawInput = {};
// The main Zod object schema for the entire form.
_schema;
// Tracks fields that have custom `refine` rules applied,
// indicating that parsing them requires validating the whole schema.
_refinedFields = [];
// Stores the normalized Zod schemas for each individual field.
_fieldSchemas;
// Cache for field-specific setter functions to ensure stable references,
// which is crucial for React performance (e.g., with `React.memo`).
_setterCache = /* @__PURE__ */ new Map();
// Type-level property for direct inference of the output type from the class instance.
__outputType;
// Internal store for current validation errors, managed by FormErrorManager.
_errorsManager;
// Set of callback functions to notify React components about state changes.
_subscribers = /* @__PURE__ */ new Set();
// Debug flag to enable/disable console logging for development purposes.
_debug;
/**
* Subscribes a callback function to listen for changes in the form's state (input or errors).
* @param callback The function to call when the form state changes.
*/
subscribe(callback) {
this._subscribers.add(callback);
if (this._debug) {
console.log("Subscriber added.", this._subscribers.size);
}
}
/**
* Unsubscribes a callback function.
* @param callback The function to remove from the subscriber list.
*/
unsubscribe(callback) {
this._subscribers.delete(callback);
if (this._debug) {
console.log("Subscriber removed.", this._subscribers.size);
}
}
/**
* Provides access to the FormErrorManager instance for detailed error handling.
*/
get errors() {
return this._errorsManager;
}
/**
* Indicates whether the form is currently valid (has no errors).
*/
get isValid() {
return !this._errorsManager.hasError();
}
getRawValue(field) {
if (field === void 0) {
return { ...this._rawInput };
}
return this._rawInput[field];
}
async getValidatedValue(field) {
if (this._debug) {
console.log(`getValidatedValue called for field: ${String(field)}`);
}
this._errorsManager.clearErrors();
if (field === void 0) {
const result = await this._schema.safeParseAsync(this._rawInput);
if (result.success) {
this._errorsManager.clearErrors();
this._notifySubscribers();
if (this._debug) {
console.log("getValidatedValue (full form) success:", result.data);
}
return result.data;
}
const validationError = buildValidationError(
result.error,
void 0,
this._debug
);
this._updateErrors(validationError);
this._notifySubscribers();
if (this._debug) {
console.error("getValidatedValue (full form) failed:", validationError);
}
throw validationError;
} else {
if (!(field in this._fieldSchemas)) {
const error = new ValidationError(
[
{
path: String(field),
code: "FIELD_UNKNOWN" /* FIELD_UNKNOWN */,
message: `Unknown Field: ${String(field)}`
}
],
`Unknown Field: ${String(field)}`
);
this._updateErrors(error);
this._notifySubscribers();
if (this._debug) {
console.error("getValidatedValue (unknown field) failed:", error);
}
throw error;
}
}
if (this._refinedFields.includes(field)) {
const result = await this._schema.safeParseAsync(this._rawInput);
if (result.success) {
const _parsedValue = result.data[field];
this._errorsManager.clearErrors();
this._notifySubscribers();
if (this._debug) {
console.log(
`getValidatedValue (refined field ${String(field)}) success:`,
_parsedValue
);
}
return _parsedValue;
}
const validationError = buildValidationError(
result.error,
String(field),
this._debug
);
this._updateErrors(validationError);
this._notifySubscribers();
if (this._debug) {
console.error(
`getValidatedValue (refined field ${String(field)}) failed:`,
validationError
);
}
throw validationError;
} else {
const fieldSchema = this._fieldSchemas[field];
const result = await fieldSchema.safeParseAsync(this._rawInput[field]);
if (result.success) {
this._errorsManager.clearErrors();
this._notifySubscribers();
if (this._debug) {
console.log(
`getValidatedValue (single field ${String(field)}) success:`,
result.data
);
}
return result.data;
}
const validationError = buildValidationError(
result.error,
String(field),
this._debug
);
this._updateErrors(validationError);
this._notifySubscribers();
if (this._debug)
console.error(
`getValidatedValue (single field ${String(field)}) failed:`,
validationError
);
throw validationError;
}
}
/**
* Parses and validates an external data object or FormData against the entire schema.
* This method performs asynchronous validation and transformation.
* It throws a ValidationError if validation fails.
* @param data The external data object or FormData to parse.
* @returns A promise that resolves to the parsed and validated data.
* @throws ValidationError if validation fails.
*/
async parse(data) {
if (this._debug) {
console.log("parse called with external data:", data);
}
this._errorsManager.clearErrors();
const formData = data instanceof FormData && "entries" in data && typeof data["entries"] === "function" ? Object.fromEntries(data.entries()) : data;
const result = await this._schema.safeParseAsync(formData);
if (result.success) {
this._errorsManager.clearErrors();
this._notifySubscribers();
if (this._debug) {
console.log("parse success:", result.data);
}
return result.data;
}
const validationError = buildValidationError(
result.error,
void 0,
this._debug
);
this._updateErrors(validationError);
this._notifySubscribers();
if (this._debug) {
console.error("parse failed:", validationError);
}
throw validationError;
}
/**
* Applies a custom refinement rule to the entire schema.
* This allows for cross-field validation or complex asynchronous checks.
* The rule definition includes the validation logic (`check`) and error details.
* @param rule The `ValidationRule` object containing the validation logic, path, code, and optional message/params.
* @returns The current FormSchema instance for chaining.
*/
applyRule(rule) {
const _path = (Array.isArray(rule.path) ? rule.path : [rule.path]).map(
String
);
this._refinedFields.push(..._path);
this._schema = this._schema.refine(
(data) => {
return rule.check(data);
},
{
message: rule.code || "",
params: rule.params,
path: _path
}
);
if (this._debug) {
console.log("Refinement rule applied to paths:", _path);
}
return this;
}
/**
* Overwrites the entire raw input state of the form with the new values.
* This is useful for completely replacing the form's data, e.g., when loading
* a new record from an API or clearing the form.
* It also triggers a full form validation and updates error state.
* @param newValues The new raw input values to set.
*/
setRawValue(newValues) {
this._rawInput = { ...newValues };
if (this._debug) {
console.log("setRawValue called with:", newValues);
}
this.validate().then(() => {
if (this._debug) {
console.log("Full form validation after setRawValue completed.");
}
}).catch((e) => {
console.error("Unexpected error during setRawValue validation:", e);
});
}
/**
* Merges the provided partial values into the existing raw input state.
* Existing fields not present in `partialValues` will be retained.
* This is useful for applying incremental updates to the form's data.
* It also triggers a full form validation and updates error state.
* @param partialValues The partial raw input values to merge.
*/
mergeRawValue(partialValues) {
this._rawInput = { ...this._rawInput, ...partialValues };
if (this._debug) {
console.log("mergeRawValue called with:", partialValues);
}
this.validate().then(() => {
if (this._debug) {
console.log("Full form validation after mergeRawValue completed.");
}
}).catch((e) => {
console.error("Unexpected error during mergeRawValue validation:", e);
});
}
/**
* Returns a memoized setter function for a specific form field.
* This setter updates the form's internal raw input state (`_rawInput`).
* It also triggers field-specific validation and updates error state.
* @param field The name of the field for which to create a setter.
* @returns A function that takes a value (of SchemaInput type) and updates the corresponding field.
*/
setValueFor(field) {
if (this._setterCache.has(field)) {
return this._setterCache.get(field);
}
const setter = (value) => {
this._rawInput[field] = value;
if (this._debug) {
console.log(`setValueFor '${String(field)}' called with:`, value);
}
this.validate(field).then(() => {
if (this._debug) {
console.log(`Validation for '${String(field)}' completed.`);
}
}).catch((e) => {
console.error(
`Unexpected error during setValueFor validation for '${String(
field
)}':`,
e
);
});
};
this._setterCache.set(
field,
setter
);
return setter;
}
/**
* Resets the form's internal raw input state (`_rawInput`) to its initial state or an empty state.
* This effectively overwrites the current state and clears all errors.
* @param initialValues Optional. New initial values to reset the form to. If not provided, resets to empty.
*/
reset(initialValues) {
if (this._debug) {
console.log("reset called with initialValues:", initialValues);
}
this.setRawValue(initialValues || {});
}
async validate(arg1, arg2) {
if (this._debug) {
console.log("validate called with args:", { arg1, arg2 });
}
let inputToValidate;
let fieldToValidate;
let valueToValidate;
let shouldClearAllErrors = false;
if (arg1 === void 0) {
inputToValidate = this._rawInput;
shouldClearAllErrors = true;
} else if (typeof arg1 === "string" && arg2 === void 0) {
fieldToValidate = arg1;
inputToValidate = this._rawInput;
this._errorsManager.clearErrors();
} else if (typeof arg1 === "object" && arg1 !== null) {
inputToValidate = arg1;
} else if (typeof arg1 === "string" && arg2 !== void 0) {
fieldToValidate = arg1;
valueToValidate = arg2;
inputToValidate = {
...this._rawInput,
[fieldToValidate]: valueToValidate
};
} else {
const error = buildValidationError(
new Error("Invalid arguments provided to validate method."),
void 0,
this._debug
);
this._updateErrors(error);
this._notifySubscribers();
if (this._debug) {
console.error("validate (invalid args) failed:", error);
}
return error;
}
try {
if (shouldClearAllErrors) {
this._errorsManager.clearErrors();
}
if (fieldToValidate === void 0) {
const result = await this._schema.safeParseAsync(inputToValidate);
if (!result.success) {
const validationError = buildValidationError(
result.error,
void 0,
this._debug
);
this._updateErrors(validationError);
this._notifySubscribers();
if (this._debug) {
console.error("validate (full form) failed:", validationError);
}
return validationError;
}
this._errorsManager.clearErrors();
} else {
if (!(fieldToValidate in this._fieldSchemas)) {
const error = new ValidationError(
[
{
path: String(fieldToValidate),
code: "FIELD_UNKNOWN" /* FIELD_UNKNOWN */,
message: `Unknown Field: ${String(fieldToValidate)}`
}
],
`Unknown Field: ${String(fieldToValidate)}`
);
this._updateErrors(error);
this._notifySubscribers();
if (this._debug) {
console.error(
`validate (unknown field ${String(fieldToValidate)}) failed:`,
error
);
}
return error;
}
if (this._refinedFields.includes(fieldToValidate)) {
const result = await this._schema.safeParseAsync(inputToValidate);
if (!result.success) {
const fieldErrors = result.error.issues.filter((issue) => {
const issuePath = (issue.path || []).join(".");
const targetPath = String(fieldToValidate);
return issuePath === targetPath || issuePath.startsWith(`${targetPath}.`);
});
if (fieldErrors.length > 0) {
const zodError = new z3.ZodError(fieldErrors);
const validationError = buildValidationError(
zodError,
String(fieldToValidate),
this._debug
);
this._updateErrors(validationError);
this._notifySubscribers();
if (this._debug) {
console.error(
`validate (refined field ${String(fieldToValidate)}) failed:`,
validationError
);
}
return validationError;
}
}
this._errorsManager.clearErrors();
} else {
const fieldSchema = this._fieldSchemas[fieldToValidate];
const value = valueToValidate !== void 0 ? valueToValidate : inputToValidate[fieldToValidate];
const result = await fieldSchema.safeParseAsync(value);
if (!result.success) {
const validationError = buildValidationError(
result.error,
String(fieldToValidate),
this._debug
);
this._updateErrors(validationError);
this._notifySubscribers();
if (this._debug) {
console.error(
`validate (single field ${String(fieldToValidate)}) failed:`,
validationError
);
}
return validationError;
}
this._errorsManager.clearErrors();
}
}
this._notifySubscribers();
if (this._debug) {
console.log("Validation successful.");
}
return void 0;
} catch (e) {
const error = buildValidationError(
e,
fieldToValidate ? String(fieldToValidate) : void 0,
this._debug
);
this._updateErrors(error);
this._notifySubscribers();
if (this._debug) {
console.error("validate (unexpected error) failed:", error);
}
return error;
}
}
// Private Methods
/**
* Provides direct access to the underlying Zod schema object.
* @returns The ZodObject instance used for validation.
*/
get schema() {
return this._schema;
}
/**
* Notifies all subscribed listeners about a state change, triggering React re-renders.
*/
_notifySubscribers() {
this._subscribers.forEach((callback) => callback());
if (this._debug) {
console.log("Notifying subscribers.");
}
}
/**
* Updates the internal error state based on a ValidationError instance.
* It clears existing errors and populates them with the new validation error issues.
* @param validationError The ValidationError instance containing new errors, or undefined if no errors.
*/
_updateErrors(validationError) {
this._errorsManager.clearErrors();
if (validationError && validationError.fields.length > 0) {
this._errorsManager.setErrors(validationError.fields);
}
}
};
// src/adapters/zod3/useFormSchema.ts
import { useCallback, useEffect, useMemo, useState } from "react";
function useFormSchema(fieldSchemas, initialValues, debug) {
const formSchema = useMemo(
() => {
if (Array.isArray(fieldSchemas)) {
return new FormSchema(fieldSchemas[0], initialValues, debug);
}
return new FormSchema(fieldSchemas, initialValues, debug);
},
[fieldSchemas, initialValues, debug]
// Include debug in dependencies
);
const [currentValues, setCurrentValues] = useState(
() => formSchema.getRawValue()
);
const [_, forceUpdate] = useState(0);
useEffect(() => {
const subscriber = () => {
setCurrentValues(formSchema.getRawValue());
forceUpdate((prev) => prev + 1);
};
formSchema.subscribe(subscriber);
if (debug) {
console.log("useFormSchema: Subscribed to FormSchema instance.");
}
return () => {
formSchema.unsubscribe(subscriber);
if (debug) {
console.log("useFormSchema: Unsubscribed from FormSchema instance.");
}
};
}, [formSchema, debug]);
const errors = useMemo(() => {
return {
hasError: formSchema.errors.hasError.bind(formSchema.errors),
getFirstFieldError: formSchema.errors.getFirstFieldError.bind(
formSchema.errors
),
getErrorsForField: formSchema.errors.getErrorsForField.bind(
formSchema.errors
),
getAllErrors: formSchema.errors.getAllErrors.bind(formSchema.errors),
clearErrors: formSchema.errors.clearErrors.bind(formSchema.errors)
// Additionally, you can add a direct property for the raw errors array if needed,
// but methods are generally preferred for encapsulation.
// raw: formSchema.errors.getAllErrors(), // Be careful with this, as it's a snapshot
};
}, [formSchema]);
const isValid = useMemo(() => formSchema.isValid, [formSchema, errors]);
const handleSubmit = useCallback(
(onSubmitValid, onSubmitInvalid) => async (event) => {
event.preventDefault();
if (debug) {
console.log("handleSubmit called.");
}
try {
const data = await formSchema.getValidatedValue();
onSubmitValid(data);
} catch (e) {
if (e instanceof ValidationError) {
if (debug) {
console.warn("Form validation failed on submit:", e.fields);
}
onSubmitInvalid?.(e.fields);
} else {
console.error(
"An unexpected error occurred during form submission:",
e
);
onSubmitInvalid?.([
{
path: "",
code: "UNEXPECTED" /* UNEXPECTED */,
message: "An unexpected error occurred during submission."
}
]);
}
}
},
[formSchema, debug]
);
return {
rawInput: currentValues,
isValid,
// Direct access to the validation status
errors,
// The memoized object with error utility methods
handleSubmit,
// onSubmitError is part of handleSubmit's signature, no need to expose separately
reset: useCallback(() => formSchema.reset(), [formSchema]),
setValueFor: useCallback(
(field) => {
return formSchema.setValueFor(field);
},
[formSchema]
),
setRawValue: useCallback(
(newValues) => {
formSchema.setRawValue(newValues);
},
[formSchema]
),
mergeRawValue: useCallback(
(partialValues) => {
formSchema.mergeRawValue(partialValues);
},
[formSchema]
),
validate: useCallback(
async (field, value) => {
return await formSchema.validate(field, value);
},
[formSchema]
)
// Optionally expose FormSchema instance itself for advanced use cases,
// though the hook aims to abstract most direct interactions.
// formSchemaInstance: formSchema,
};
}
export {
FormSchema,
ValidationCode,
useFormSchema
};