zod-form-kit
Version:
UI-agnostic form generation library based on Zod schemas with extensible adapter pattern
409 lines (408 loc) • 15.4 kB
JavaScript
import { z } from 'zod';
// Default field renderers (will be imported from existing components)
import { StringField } from '../components/fields/StringField';
import { NumberField } from '../components/fields/NumberField';
import { BooleanField } from '../components/fields/BooleanField';
import { DateField } from '../components/fields/DateField';
import { EnumField } from '../components/fields/EnumField';
import { ArrayField } from '../components/fields/ArrayField';
import { ObjectField } from '../components/fields/ObjectField';
import { DiscriminatedUnionField } from '../components/fields/DiscriminatedUnionField';
class PluginRegistryManager {
constructor(config = {}) {
Object.defineProperty(this, "registry", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "config", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
this.config = {
fallbackToDefaults: true,
...config
};
this.registry = {
fieldRenderers: new Map(),
patternRenderers: [], // Initialize empty pattern renderers array
uiAdapters: new Map(),
defaultAdapter: this.config.defaultAdapter
};
// Register default field renderers
this.registerDefaultRenderers();
}
registerDefaultRenderers() {
this.registry.fieldRenderers.set('string', StringField);
this.registry.fieldRenderers.set('number', NumberField);
this.registry.fieldRenderers.set('boolean', BooleanField);
this.registry.fieldRenderers.set('date', DateField);
this.registry.fieldRenderers.set('enum', EnumField);
this.registry.fieldRenderers.set('array', ArrayField);
this.registry.fieldRenderers.set('object', ObjectField);
this.registry.fieldRenderers.set('discriminatedUnion', DiscriminatedUnionField);
}
/**
* Register a custom field renderer for a specific field type
*/
registerFieldRenderer(fieldType, component) {
this.registry.fieldRenderers.set(fieldType, component);
}
/**
* Register a schema pattern renderer
* @param id - Unique identifier for this pattern
* @param matcher - Function or Zod schema to match against
* @param component - React component to render when pattern matches
* @param priority - Priority level (higher numbers = higher priority)
*/
registerSchemaPatternRenderer(id, matcher, component, priority = 0) {
// Convert Zod schema to matcher function if needed
const matcherFunction = typeof matcher === 'function'
? matcher
: (zodSchema, _parsedField, _formValue) => {
try {
// For literal schemas, check if the structure matches
if (zodSchema instanceof z.ZodLiteral && matcher instanceof z.ZodLiteral) {
return zodSchema._def.value === matcher._def.value;
}
// For object schemas, perform deep structural comparison
if (zodSchema instanceof z.ZodObject && matcher instanceof z.ZodObject) {
return this.compareZodObjectSchemas(zodSchema, matcher);
}
// For number schemas, compare constraints
if (zodSchema instanceof z.ZodNumber && matcher instanceof z.ZodNumber) {
return this.compareZodNumberSchemas(zodSchema, matcher);
}
// For string schemas, compare constraints
if (zodSchema instanceof z.ZodString && matcher instanceof z.ZodString) {
return this.compareZodStringSchemas(zodSchema, matcher);
}
// For other schema types, use type comparison
return zodSchema.constructor === matcher.constructor;
}
catch {
return false;
}
};
// Remove existing pattern with same ID
this.registry.patternRenderers = this.registry.patternRenderers.filter(entry => entry.id !== id);
// Add new pattern and sort by priority (highest first)
this.registry.patternRenderers.push({
id,
matcher: matcherFunction,
component,
priority
});
this.registry.patternRenderers.sort((a, b) => b.priority - a.priority);
}
/**
* Helper method to compare Zod object schemas structurally
*/
compareZodObjectSchemas(schema1, schema2) {
const shape1 = schema1.shape;
const shape2 = schema2.shape;
// Check if all keys in schema2 exist in schema1 with matching types
for (const [key, value2] of Object.entries(shape2)) {
const value1 = shape1[key];
if (!value1)
return false;
// Cast value2 to z.ZodTypeAny since it comes from a Zod object shape
const zodValue2 = value2;
const zodValue1 = value1;
// For literal values, check exact match
if (zodValue2 instanceof z.ZodLiteral) {
if (!(zodValue1 instanceof z.ZodLiteral) || zodValue1._def.value !== zodValue2._def.value) {
return false;
}
}
// For other types, check constructor match
else if (zodValue1.constructor !== zodValue2.constructor) {
return false;
}
}
return true;
}
/**
* Helper method to compare Zod number schemas with constraints
*/
compareZodNumberSchemas(schema1, schema2) {
// Get checks for both schemas
const checks1 = schema1._def.checks || [];
const checks2 = schema2._def.checks || [];
// If pattern has no constraints, it matches any number
if (checks2.length === 0) {
return true;
}
// Extract constraints from both schemas
const getConstraints = (checks) => {
const constraints = {};
checks.forEach((check) => {
if (check.kind === 'min')
constraints.min = check.value;
if (check.kind === 'max')
constraints.max = check.value;
if (check.kind === 'int')
constraints.int = true;
if (check.kind === 'multipleOf')
constraints.multipleOf = check.value;
});
return constraints;
};
const constraints1 = getConstraints(checks1);
const constraints2 = getConstraints(checks2);
// Check if all constraints in schema2 are present in schema1 with same values
for (const [key, value] of Object.entries(constraints2)) {
if (constraints1[key] !== value) {
return false;
}
}
return true;
}
/**
* Helper method to compare Zod string schemas with constraints
*/
compareZodStringSchemas(schema1, schema2) {
// Get checks for both schemas
const checks1 = schema1._def.checks || [];
const checks2 = schema2._def.checks || [];
// If pattern has no constraints, it matches any string
if (checks2.length === 0) {
return true;
}
// Extract constraints from both schemas
const getConstraints = (checks) => {
const constraints = {};
checks.forEach((check) => {
if (check.kind === 'min')
constraints.min = check.value;
if (check.kind === 'max')
constraints.max = check.value;
if (check.kind === 'email')
constraints.email = true;
if (check.kind === 'url')
constraints.url = true;
if (check.kind === 'uuid')
constraints.uuid = true;
if (check.kind === 'regex')
constraints.regex = check.regex.toString();
if (check.kind === 'length')
constraints.length = check.value;
});
return constraints;
};
const constraints1 = getConstraints(checks1);
const constraints2 = getConstraints(checks2);
// Check if all constraints in schema2 are present in schema1 with same values
for (const [key, value] of Object.entries(constraints2)) {
if (constraints1[key] !== value) {
return false;
}
}
return true;
}
/**
* Register a complete UI adapter
*/
registerUIAdapter(adapter) {
this.registry.uiAdapters.set(adapter.name, adapter);
// Register all components from the adapter as field renderers
Object.entries(adapter.components).forEach(([fieldType, component]) => {
this.registry.fieldRenderers.set(fieldType, component);
});
// If this is the first adapter and no default is set, make it the default
if (!this.registry.defaultAdapter) {
this.registry.defaultAdapter = adapter.name;
}
}
/**
* Get a registered field renderer for a specific field type
*/
getRegisteredRenderer(fieldType) {
return this.registry.fieldRenderers.get(fieldType);
}
/**
* Get a registered UI adapter by name
*/
getUIAdapter(adapterName) {
return this.registry.uiAdapters.get(adapterName);
}
/**
* Get the default UI adapter
*/
getDefaultAdapter() {
if (!this.registry.defaultAdapter) {
return undefined;
}
return this.registry.uiAdapters.get(this.registry.defaultAdapter);
}
/**
* Set the default UI adapter
*/
setDefaultAdapter(adapterName) {
if (this.registry.uiAdapters.has(adapterName)) {
this.registry.defaultAdapter = adapterName;
// Update field renderers with components from the new default adapter
const adapter = this.registry.uiAdapters.get(adapterName);
if (adapter) {
Object.entries(adapter.components).forEach(([fieldType, component]) => {
this.registry.fieldRenderers.set(fieldType, component);
});
}
}
else {
throw new Error(`UI adapter "${adapterName}" not found`);
}
}
/**
* Get pattern renderer that matches the given schema
*/
getMatchingPatternRenderer(zodSchema, parsedField, formValue) {
for (const entry of this.registry.patternRenderers) {
if (entry.matcher(zodSchema, parsedField, formValue)) {
return entry.component;
}
}
return undefined;
}
/**
* Remove a pattern renderer by ID
*/
removeSchemaPatternRenderer(id) {
const originalLength = this.registry.patternRenderers.length;
this.registry.patternRenderers = this.registry.patternRenderers.filter(entry => entry.id !== id);
return this.registry.patternRenderers.length < originalLength;
}
/**
* Get all registered field renderers
*/
getAllFieldRenderers() {
return new Map(this.registry.fieldRenderers);
}
/**
* Get all registered UI adapters
*/
getAllUIAdapters() {
return new Map(this.registry.uiAdapters);
}
/**
* Get all registered pattern renderers
*/
getAllPatternRenderers() {
return [...this.registry.patternRenderers];
}
/**
* Clear all custom field renderers (keeps defaults)
*/
clearCustomFieldRenderers() {
this.registry.fieldRenderers.clear();
this.registerDefaultRenderers();
}
/**
* Clear all UI adapters
*/
clearUIAdapters() {
this.registry.uiAdapters.clear();
this.registry.defaultAdapter = undefined;
}
/**
* Clear all pattern renderers
*/
clearPatternRenderers() {
this.registry.patternRenderers = [];
}
/**
* Reset the entire registry to defaults
*/
reset() {
this.registry = {
fieldRenderers: new Map(),
patternRenderers: [], // Initialize empty pattern renderers array
uiAdapters: new Map(),
defaultAdapter: this.config.defaultAdapter
};
this.registerDefaultRenderers();
}
/**
* Check if a field renderer is registered for a specific type
*/
hasFieldRenderer(fieldType) {
return this.registry.fieldRenderers.has(fieldType);
}
/**
* Check if a UI adapter is registered
*/
hasUIAdapter(adapterName) {
return this.registry.uiAdapters.has(adapterName);
}
/**
* Check if a pattern renderer is registered
*/
hasPatternRenderer(id) {
return this.registry.patternRenderers.some(entry => entry.id === id);
}
/**
* Get the current registry state
*/
getRegistry() {
return {
fieldRenderers: new Map(this.registry.fieldRenderers),
patternRenderers: [...this.registry.patternRenderers], // Deep copy pattern renderers
uiAdapters: new Map(this.registry.uiAdapters),
defaultAdapter: this.registry.defaultAdapter
};
}
/**
* Get the current configuration
*/
getConfig() {
return { ...this.config };
}
/**
* Update the configuration
*/
updateConfig(newConfig) {
this.config = { ...this.config, ...newConfig };
}
}
// Create a singleton instance
const pluginRegistry = new PluginRegistryManager();
// Export the singleton instance and the class for testing
export { pluginRegistry, PluginRegistryManager };
// Export convenience functions that use the singleton
export const registerFieldRenderer = (fieldType, component) => {
pluginRegistry.registerFieldRenderer(fieldType, component);
};
export const registerUIAdapter = (adapter) => {
pluginRegistry.registerUIAdapter(adapter);
};
export const getRegisteredRenderer = (fieldType) => {
return pluginRegistry.getRegisteredRenderer(fieldType);
};
export const getUIAdapter = (adapterName) => {
return pluginRegistry.getUIAdapter(adapterName);
};
export const getDefaultAdapter = () => {
return pluginRegistry.getDefaultAdapter();
};
export const setDefaultAdapter = (adapterName) => {
pluginRegistry.setDefaultAdapter(adapterName);
};
// Export convenience functions for pattern registration
export const registerSchemaPatternRenderer = (id, matcher, component, priority) => {
pluginRegistry.registerSchemaPatternRenderer(id, matcher, component, priority);
};
export const getMatchingPatternRenderer = (zodSchema, parsedField, formValue) => {
return pluginRegistry.getMatchingPatternRenderer(zodSchema, parsedField, formValue);
};
export const removeSchemaPatternRenderer = (id) => {
return pluginRegistry.removeSchemaPatternRenderer(id);
};
export const getAllPatternRenderers = () => {
return pluginRegistry.getAllPatternRenderers();
};
export const clearPatternRenderers = () => {
pluginRegistry.clearPatternRenderers();
};