zod-form-kit
Version:
UI-agnostic form generation library based on Zod schemas with extensible adapter pattern
301 lines (300 loc) • 15.9 kB
JavaScript
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { z } from 'zod';
import { ZodForm } from '../components/ZodForm';
// Mock the FieldRenderer component to isolate ZodForm logic and test TanStack Form integration
vi.mock('../components/FieldRenderer', () => ({
FieldRenderer: ({ schema, form, path, zodSchema }) => {
// Create a simple mock field renderer that works with TanStack Form
const renderField = (fieldName, fieldSchema) => {
return (_jsx(form.Field, { name: fieldName, validators: {
onChange: ({ value }) => {
// Validate using the actual zodSchema for this field
try {
if (zodSchema && zodSchema instanceof z.ZodObject) {
const fieldZodSchema = zodSchema.shape[fieldName];
if (fieldZodSchema) {
fieldZodSchema.parse(value);
}
}
return undefined;
}
catch (error) {
if (error instanceof z.ZodError) {
return error.errors[0]?.message || 'Validation error';
}
return 'Validation error';
}
}
}, children: (field) => {
const fieldType = fieldSchema.type;
const hasError = field.state.meta.errors.length > 0;
if (fieldType === 'boolean') {
return (_jsxs("div", { "data-testid": `field-${fieldName}`, children: [_jsxs("label", { children: [_jsx("input", { type: "checkbox", "aria-label": fieldName, checked: field.state.value || false, onChange: (e) => field.handleChange(e.target.checked), onBlur: field.handleBlur }), fieldName] }), hasError && (_jsx("span", { "data-testid": `error-${fieldName}`, role: "alert", children: field.state.meta.errors[0] }))] }));
}
if (fieldType === 'number') {
return (_jsxs("div", { "data-testid": `field-${fieldName}`, children: [_jsx("label", { htmlFor: fieldName, children: fieldName }), _jsx("input", { id: fieldName, type: "number", "aria-label": fieldName, value: field.state.value || '', onChange: (e) => field.handleChange(Number(e.target.value) || 0), onBlur: field.handleBlur }), hasError && (_jsx("span", { "data-testid": `error-${fieldName}`, role: "alert", children: field.state.meta.errors[0] }))] }));
}
if (fieldType === 'enum') {
return (_jsxs("div", { "data-testid": `field-${fieldName}`, children: [_jsx("label", { htmlFor: fieldName, children: fieldName }), _jsxs("select", { id: fieldName, "aria-label": fieldName, value: field.state.value || '', onChange: (e) => field.handleChange(e.target.value), onBlur: field.handleBlur, children: [_jsx("option", { value: "", children: "Select..." }), fieldSchema.values?.map((value) => (_jsx("option", { value: value, children: value }, value)))] }), hasError && (_jsx("span", { "data-testid": `error-${fieldName}`, role: "alert", children: field.state.meta.errors[0] }))] }));
}
// Default to string/text input
return (_jsxs("div", { "data-testid": `field-${fieldName}`, children: [_jsx("label", { htmlFor: fieldName, children: fieldName }), _jsx("input", { id: fieldName, type: "text", "aria-label": fieldName, value: field.state.value || '', onChange: (e) => field.handleChange(e.target.value), onBlur: field.handleBlur }), hasError && (_jsx("span", { "data-testid": `error-${fieldName}`, role: "alert", children: field.state.meta.errors[0] }))] }));
} }, fieldName));
};
// Handle object schemas
if (schema.type === 'object' && schema.properties) {
return (_jsx("div", { "data-testid": "form-fields", children: Object.entries(schema.properties).map(([fieldName, fieldSchema]) => renderField(fieldName, fieldSchema)) }));
}
// Handle primitive schemas (when form has single field)
return renderField(path || 'field', schema);
},
}));
describe('ZodForm', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('Form Initialization', () => {
it('should initialize form with TanStack Form', () => {
const schema = z.object({
name: z.string(),
});
const onSubmit = vi.fn();
render(_jsx(ZodForm, { schema: schema, onSubmit: onSubmit }));
expect(screen.getByRole('form')).toBeInTheDocument();
expect(screen.getByLabelText('name')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /submit/i })).toBeInTheDocument();
});
it('should initialize with default values from schema', () => {
const schema = z.object({
name: z.string().default('John Doe'),
age: z.number().default(25),
active: z.boolean().default(true),
});
const onSubmit = vi.fn();
render(_jsx(ZodForm, { schema: schema, onSubmit: onSubmit }));
expect(screen.getByLabelText('name')).toHaveValue('John Doe');
expect(screen.getByLabelText('age')).toHaveValue(25);
expect(screen.getByLabelText('active')).toBeChecked();
});
it('should override schema defaults with provided defaultValues', () => {
const schema = z.object({
name: z.string().default('Schema Default'),
email: z.string(),
});
const defaultValues = {
name: 'Props Default',
email: 'test@example.com',
};
const onSubmit = vi.fn();
render(_jsx(ZodForm, { schema: schema, onSubmit: onSubmit, defaultValues: defaultValues }));
expect(screen.getByLabelText('name')).toHaveValue('Props Default');
expect(screen.getByLabelText('email')).toHaveValue('test@example.com');
});
});
describe('Field Rendering', () => {
it('should render different field types correctly', () => {
const schema = z.object({
name: z.string(),
age: z.number(),
active: z.boolean(),
role: z.enum(['admin', 'user', 'guest']),
});
const onSubmit = vi.fn();
render(_jsx(ZodForm, { schema: schema, onSubmit: onSubmit }));
expect(screen.getByLabelText('name')).toHaveAttribute('type', 'text');
expect(screen.getByLabelText('age')).toHaveAttribute('type', 'number');
expect(screen.getByLabelText('active')).toHaveAttribute('type', 'checkbox');
expect(screen.getByLabelText('role')).toBeInstanceOf(HTMLSelectElement);
});
it('should render enum options correctly', () => {
const schema = z.object({
role: z.enum(['admin', 'user', 'guest']),
});
const onSubmit = vi.fn();
render(_jsx(ZodForm, { schema: schema, onSubmit: onSubmit }));
const select = screen.getByLabelText('role');
const options = select.querySelectorAll('option');
expect(options).toHaveLength(4); // Including "Select..." option
expect(options[1]).toHaveTextContent('admin');
expect(options[2]).toHaveTextContent('user');
expect(options[3]).toHaveTextContent('guest');
});
});
describe('Form Validation', () => {
it('should validate fields using Zod schema on change', async () => {
const user = userEvent.setup();
const schema = z.object({
email: z.string().email('Invalid email format'),
age: z.number().min(18, 'Must be at least 18'),
});
const onSubmit = vi.fn();
render(_jsx(ZodForm, { schema: schema, onSubmit: onSubmit }));
const emailField = screen.getByLabelText('email');
const ageField = screen.getByLabelText('age');
// Test invalid email
await user.type(emailField, 'invalid-email');
await user.tab();
await waitFor(() => {
expect(screen.getByTestId('error-email')).toHaveTextContent('Invalid email format');
});
// Test invalid age
await user.clear(ageField);
await user.type(ageField, '15');
await user.tab();
await waitFor(() => {
expect(screen.getByTestId('error-age')).toHaveTextContent('Must be at least 18');
});
});
it('should clear validation errors when field becomes valid', async () => {
const user = userEvent.setup();
const schema = z.object({
email: z.string().email('Invalid email format'),
});
const onSubmit = vi.fn();
render(_jsx(ZodForm, { schema: schema, onSubmit: onSubmit }));
const emailField = screen.getByLabelText('email');
// First, create an error
await user.type(emailField, 'invalid');
await user.tab();
await waitFor(() => {
expect(screen.getByTestId('error-email')).toBeInTheDocument();
});
// Then fix it
await user.clear(emailField);
await user.type(emailField, 'valid@example.com');
await user.tab();
await waitFor(() => {
expect(screen.queryByTestId('error-email')).not.toBeInTheDocument();
});
});
});
describe('Form Submission', () => {
it('should handle successful form submission', async () => {
const user = userEvent.setup();
const schema = z.object({
name: z.string().min(1),
email: z.string().email(),
});
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(_jsx(ZodForm, { schema: schema, onSubmit: onSubmit }));
await user.type(screen.getByLabelText('name'), 'John Doe');
await user.type(screen.getByLabelText('email'), 'john@example.com');
const submitButton = screen.getByRole('button', { name: /submit/i });
await user.click(submitButton);
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({
name: 'John Doe',
email: 'john@example.com',
});
});
});
it('should prevent submission with invalid data', async () => {
const user = userEvent.setup();
const schema = z.object({
email: z.string().email('Invalid email'),
});
const onSubmit = vi.fn();
render(_jsx(ZodForm, { schema: schema, onSubmit: onSubmit }));
await user.type(screen.getByLabelText('email'), 'invalid-email');
const submitButton = screen.getByRole('button', { name: /submit/i });
await user.click(submitButton);
// Should not call onSubmit due to validation errors
expect(onSubmit).not.toHaveBeenCalled();
});
});
describe('Form State Management', () => {
it('should disable submit button while submitting', async () => {
const user = userEvent.setup();
const schema = z.object({
name: z.string(),
});
let resolveSubmit;
const submitPromise = new Promise((resolve) => {
resolveSubmit = resolve;
});
const onSubmit = vi.fn().mockReturnValue(submitPromise);
render(_jsx(ZodForm, { schema: schema, onSubmit: onSubmit }));
await user.type(screen.getByLabelText('name'), 'John Doe');
const submitButton = screen.getByRole('button', { name: /submit/i });
await user.click(submitButton);
// Button should show submitting state
await waitFor(() => {
expect(submitButton).toHaveTextContent('Submitting...');
expect(submitButton).toBeDisabled();
});
// Resolve the submission
resolveSubmit();
await waitFor(() => {
expect(submitButton).toHaveTextContent('Submit');
expect(submitButton).not.toBeDisabled();
});
});
it('should manage form state properly with complex schemas', async () => {
const user = userEvent.setup();
const schema = z.object({
name: z.string().min(1, 'Name required'),
email: z.string().email('Invalid email'),
age: z.number().min(18, 'Must be 18+'),
terms: z.boolean(),
});
const onSubmit = vi.fn();
render(_jsx(ZodForm, { schema: schema, onSubmit: onSubmit }));
// Fill valid data
await user.type(screen.getByLabelText('name'), 'John Doe');
await user.type(screen.getByLabelText('email'), 'john@example.com');
await user.type(screen.getByLabelText('age'), '25');
await user.click(screen.getByLabelText('terms'));
const submitButton = screen.getByRole('button', { name: /submit/i });
await user.click(submitButton);
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({
name: 'John Doe',
email: 'john@example.com',
age: 25,
terms: true,
});
});
});
});
describe('Custom Props', () => {
it('should apply custom className to form', () => {
const schema = z.object({
name: z.string(),
});
const onSubmit = vi.fn();
render(_jsx(ZodForm, { schema: schema, onSubmit: onSubmit, className: "custom-form" }));
const form = screen.getByRole('form');
expect(form).toHaveClass('form-generator', 'custom-form');
});
});
describe('Edge Cases', () => {
it('should handle empty object schema', () => {
const schema = z.object({});
const onSubmit = vi.fn();
render(_jsx(ZodForm, { schema: schema, onSubmit: onSubmit }));
expect(screen.getByRole('form')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /submit/i })).toBeInTheDocument();
});
it('should handle optional fields correctly', async () => {
const user = userEvent.setup();
const schema = z.object({
required: z.string().min(1, 'Required field'),
optional: z.string().optional(),
});
const onSubmit = vi.fn();
render(_jsx(ZodForm, { schema: schema, onSubmit: onSubmit }));
// Only fill required field
await user.type(screen.getByLabelText('required'), 'Required value');
const submitButton = screen.getByRole('button', { name: /submit/i });
await user.click(submitButton);
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({
required: 'Required value',
});
});
});
});
});