UNPKG

@docvhcmc/react-form-schema

Version:

A flexible React form schema manager with pluggable validation adapters (Zod, Yup, etc.)

830 lines (820 loc) 27.8 kB
"use strict"; 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/zod4/index.ts var zod4_exports = {}; __export(zod4_exports, { FormSchema: () => FormSchema, ValidationCode: () => ValidationCode, useFormSchema: () => useFormSchema }); module.exports = __toCommonJS(zod4_exports); // src/constants/errorCodes.ts var ValidationCode = /* @__PURE__ */ ((ValidationCode2) => { ValidationCode2["FIELD_UNKNOWN"] = "FIELD_UNKNOWN"; ValidationCode2["UNEXPECTED"] = "UNEXPECTED"; return ValidationCode2; })(ValidationCode || {}); // src/adapters/zod4/FormSchema.ts var import_v43 = require("zod/v4"); // 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/zod4/buildValidationError.ts var import_v4 = require("zod/v4"); function buildValidationError(e, field, debug) { if (debug) { console.log("buildValidationError called for:", e, "field:", field); } if (e instanceof import_v4.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/zod4/normalizeZodSchemaOptions.ts var import_v42 = require("zod/v4"); 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_v42.z) : value; } } return result; } // src/adapters/zod4/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_v43.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_v43.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/zod4/useFormSchema.ts var import_react = require("react"); function useFormSchema(schemaOptions, initialValues, debug) { const formSchema = (0, import_react.useMemo)( () => { return new FormSchema(schemaOptions, initialValues, debug); }, [schemaOptions, 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 });