@docvhcmc/react-form-schema
Version:
A flexible React form schema manager with pluggable validation adapters (Zod, Yup, etc.)
833 lines (823 loc) • 27.9 kB
JavaScript
;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// src/adapters/zod3/index.ts
var zod3_exports = {};
__export(zod3_exports, {
FormSchema: () => FormSchema,
ValidationCode: () => ValidationCode,
useFormSchema: () => useFormSchema
});
module.exports = __toCommonJS(zod3_exports);
// src/constants/errorCodes.ts
var ValidationCode = /* @__PURE__ */ ((ValidationCode2) => {
ValidationCode2["FIELD_UNKNOWN"] = "FIELD_UNKNOWN";
ValidationCode2["UNEXPECTED"] = "UNEXPECTED";
return ValidationCode2;
})(ValidationCode || {});
// src/adapters/zod3/FormSchema.ts
var import_zod3 = require("zod");
// src/core/FormErrorManager.ts
var FormErrorManager = class {
_errors = [];
constructor(initialErrors = []) {
this._errors = initialErrors;
}
/**
* Sets all errors at once, replacing any existing errors.
* @param errors An array of FieldError objects.
*/
setErrors(errors) {
this._errors = errors;
}
/**
* Adds a single error to the current list of errors.
* @param error The FieldError object to add.
*/
addError(error) {
this._errors.push(error);
}
/**
* Checks if any error exists for a specific field path, or if any error exists in general.
* @param path Optional. The dot-notation path of the field (e.g., 'name', 'address.street').
* @returns `true` if an error exists for the specified path or if any error exists, `false` otherwise.
*/
hasError(path) {
if (!path) {
return this._errors.length > 0;
}
return this._errors.some((error) => error.path === path);
}
/**
* Retrieves the first error message for a specific field.
* @param path The dot-notation path of the field.
* @returns The error message string, or `undefined` if the field has no error.
*/
getFirstFieldError(path) {
return this._errors.find((error) => error.path === path)?.message;
}
/**
* Retrieves all error objects for a specific field.
* @param path The dot-notation path of the field.
* @returns An array of `FieldError` objects for the specified field.
*/
getErrorsForField(path) {
return this._errors.filter((error) => error.path === path);
}
/**
* Retrieves all current error objects.
* @returns An array of all `FieldError` objects.
*/
getAllErrors() {
return [...this._errors];
}
/**
* Clears all existing errors.
*/
clearErrors() {
this._errors = [];
}
};
// src/errors/ValidationError.ts
var ValidationError = class _ValidationError extends Error {
fields;
/**
* Optionally store the original error (e.g., ZodError) for debugging
*/
originalError;
/**
* Creates an instance of ValidationError.
* @param fields An array of FieldError objects.
* @param originalError The original error object from the validator (optional).
* @param message A custom error message (optional). If not provided, a default message is generated.
*/
constructor(fields, originalError, message) {
const defaultMessage = fields.map((f) => `${f.path}: ${f.message}`).join(", ") || "Validation failed.";
super(message || defaultMessage);
this.name = "ValidationError";
this.fields = fields;
this.originalError = originalError;
if (Error.captureStackTrace) {
Error.captureStackTrace(this, _ValidationError);
}
}
};
// src/adapters/zod3/buildValidationError.ts
var import_zod = require("zod");
function buildValidationError(e, field, debug) {
if (debug) {
console.log("buildValidationError called for:", e, "field:", field);
}
if (e instanceof import_zod.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
var import_zod2 = require("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(import_zod2.z) : 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 = import_zod3.z.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 import_zod3.z.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
var import_react = require("react");
function useFormSchema(fieldSchemas, initialValues, debug) {
const formSchema = (0, import_react.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] = (0, import_react.useState)(
() => formSchema.getRawValue()
);
const [_, forceUpdate] = (0, import_react.useState)(0);
(0, import_react.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 = (0, import_react.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 = (0, import_react.useMemo)(() => formSchema.isValid, [formSchema, errors]);
const handleSubmit = (0, import_react.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: (0, import_react.useCallback)(() => formSchema.reset(), [formSchema]),
setValueFor: (0, import_react.useCallback)(
(field) => {
return formSchema.setValueFor(field);
},
[formSchema]
),
setRawValue: (0, import_react.useCallback)(
(newValues) => {
formSchema.setRawValue(newValues);
},
[formSchema]
),
mergeRawValue: (0, import_react.useCallback)(
(partialValues) => {
formSchema.mergeRawValue(partialValues);
},
[formSchema]
),
validate: (0, import_react.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,
};
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
FormSchema,
ValidationCode,
useFormSchema
});