Initial commit
This commit is contained in:
12
.claude-plugin/plugin.json
Normal file
12
.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "form-generator-rhf-zod",
|
||||||
|
"description": "This skill should be used when generating React forms with React Hook Form, Zod validation, and shadcn/ui components. Applies when creating entity forms, character editors, location forms, data entry forms, or any form requiring client and server validation. Trigger terms include create form, generate form, build form, React Hook Form, RHF, Zod validation, form component, entity form, character form, data entry, form schema.",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": {
|
||||||
|
"name": "Hope Overture",
|
||||||
|
"email": "support@worldbuilding-app-skills.dev"
|
||||||
|
},
|
||||||
|
"skills": [
|
||||||
|
"./skills"
|
||||||
|
]
|
||||||
|
}
|
||||||
3
README.md
Normal file
3
README.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# form-generator-rhf-zod
|
||||||
|
|
||||||
|
This skill should be used when generating React forms with React Hook Form, Zod validation, and shadcn/ui components. Applies when creating entity forms, character editors, location forms, data entry forms, or any form requiring client and server validation. Trigger terms include create form, generate form, build form, React Hook Form, RHF, Zod validation, form component, entity form, character form, data entry, form schema.
|
||||||
57
plugin.lock.json
Normal file
57
plugin.lock.json
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"$schema": "internal://schemas/plugin.lock.v1.json",
|
||||||
|
"pluginId": "gh:hopeoverture/worldbuilding-app-skills:plugins/form-generator-rhf-zod",
|
||||||
|
"normalized": {
|
||||||
|
"repo": null,
|
||||||
|
"ref": "refs/tags/v20251128.0",
|
||||||
|
"commit": "c9988d6bb678b144b0e0e534d36838d1139ece09",
|
||||||
|
"treeHash": "f33f786e867652f41a3c3d16853471957d745ac4e1c1d8be78da8495601e4a2f",
|
||||||
|
"generatedAt": "2025-11-28T10:17:34.363667Z",
|
||||||
|
"toolVersion": "publish_plugins.py@0.2.0"
|
||||||
|
},
|
||||||
|
"origin": {
|
||||||
|
"remote": "git@github.com:zhongweili/42plugin-data.git",
|
||||||
|
"branch": "master",
|
||||||
|
"commit": "aa1497ed0949fd50e99e70d6324a29c5b34f9390",
|
||||||
|
"repoRoot": "/Users/zhongweili/projects/openmind/42plugin-data"
|
||||||
|
},
|
||||||
|
"manifest": {
|
||||||
|
"name": "form-generator-rhf-zod",
|
||||||
|
"description": "This skill should be used when generating React forms with React Hook Form, Zod validation, and shadcn/ui components. Applies when creating entity forms, character editors, location forms, data entry forms, or any form requiring client and server validation. Trigger terms include create form, generate form, build form, React Hook Form, RHF, Zod validation, form component, entity form, character form, data entry, form schema.",
|
||||||
|
"version": "1.0.0"
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "README.md",
|
||||||
|
"sha256": "08e0c00cbece3fd1d82abe5498920799bd9c36bc5ee924ce26f7de262fc2124a"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ".claude-plugin/plugin.json",
|
||||||
|
"sha256": "68aabb5983f34d016ef38ba4ec1b75a3d22fcbbf74f4c97a704e6cdddf45bc16"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/form-generator-rhf-zod/SKILL.md",
|
||||||
|
"sha256": "be065196c64c134d341eefeb334a02652435b5fa33014b43f4949ef47b50a8e4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/form-generator-rhf-zod/references/rhf-patterns.md",
|
||||||
|
"sha256": "3ea838379039c678a586b79a27ab6ddef0c801d8ecc8a169d6d4489de6544614"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/form-generator-rhf-zod/references/zod-validation.md",
|
||||||
|
"sha256": "5ec6e757eb6ad71775821c696e7b1798b9369568d6b2759f78e7f6b72e2591dc"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/form-generator-rhf-zod/assets/form-template.tsx",
|
||||||
|
"sha256": "f52b8ff27aedfcf8145285272ace7d36f669a3cb841e3b235cd965fe54c1eccf"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dirSha256": "f33f786e867652f41a3c3d16853471957d745ac4e1c1d8be78da8495601e4a2f"
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"scannedAt": null,
|
||||||
|
"scannerVersion": null,
|
||||||
|
"flags": []
|
||||||
|
}
|
||||||
|
}
|
||||||
657
skills/form-generator-rhf-zod/SKILL.md
Normal file
657
skills/form-generator-rhf-zod/SKILL.md
Normal file
@@ -0,0 +1,657 @@
|
|||||||
|
---
|
||||||
|
name: form-generator-rhf-zod
|
||||||
|
description: This skill should be used when generating React forms with React Hook Form, Zod validation, and shadcn/ui components. Applies when creating entity forms, character editors, location forms, data entry forms, or any form requiring client and server validation. Trigger terms include create form, generate form, build form, React Hook Form, RHF, Zod validation, form component, entity form, character form, data entry, form schema.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Form Generator with React Hook Form & Zod
|
||||||
|
|
||||||
|
Generate production-ready React forms using React Hook Form, Zod validation schemas, and accessible shadcn/ui form controls. This skill creates forms with client-side and server-side validation, proper TypeScript types, and consistent error handling.
|
||||||
|
|
||||||
|
## When to Use This Skill
|
||||||
|
|
||||||
|
Apply this skill when:
|
||||||
|
- Creating forms for entities (characters, locations, items, factions)
|
||||||
|
- Building data entry interfaces with validation requirements
|
||||||
|
- Generating forms with complex field types and conditional logic
|
||||||
|
- Setting up forms that need both client and server validation
|
||||||
|
- Creating accessible forms with proper ARIA attributes
|
||||||
|
- Building forms with multi-step or wizard patterns
|
||||||
|
|
||||||
|
## Resources Available
|
||||||
|
|
||||||
|
### Scripts
|
||||||
|
|
||||||
|
**scripts/generate_form.py** - Generates form component, Zod schema, and server action from field specifications.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
```bash
|
||||||
|
python scripts/generate_form.py --name CharacterForm --fields fields.json --output components/forms
|
||||||
|
```
|
||||||
|
|
||||||
|
**scripts/generate_zod_schema.py** - Converts field specifications to Zod schema with validation rules.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
```bash
|
||||||
|
python scripts/generate_zod_schema.py --fields fields.json --output lib/schemas
|
||||||
|
```
|
||||||
|
|
||||||
|
### References
|
||||||
|
|
||||||
|
**references/rhf-patterns.md** - React Hook Form patterns, hooks, and best practices
|
||||||
|
**references/zod-validation.md** - Zod schema patterns, refinements, and custom validators
|
||||||
|
**references/shadcn-form-controls.md** - shadcn/ui form component usage and examples
|
||||||
|
**references/server-actions.md** - Server action patterns for form submission
|
||||||
|
|
||||||
|
### Assets
|
||||||
|
|
||||||
|
**assets/form-template.tsx** - Base form component template with RHF setup
|
||||||
|
**assets/field-templates/** - Individual field component templates (Input, Textarea, Select, Checkbox, etc.)
|
||||||
|
**assets/validation-schemas.ts** - Common Zod validation patterns
|
||||||
|
**assets/form-utils.ts** - Form utility functions (formatters, transformers, validators)
|
||||||
|
|
||||||
|
## Form Generation Process
|
||||||
|
|
||||||
|
### Step 1: Define Field Specifications
|
||||||
|
|
||||||
|
Create a field specification file describing form fields, types, validation rules, and UI properties.
|
||||||
|
|
||||||
|
Field specification format:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"name": "characterName",
|
||||||
|
"label": "Character Name",
|
||||||
|
"type": "text",
|
||||||
|
"required": true,
|
||||||
|
"validation": {
|
||||||
|
"minLength": 2,
|
||||||
|
"maxLength": 100,
|
||||||
|
"pattern": "^[a-zA-Z\\s'-]+$"
|
||||||
|
},
|
||||||
|
"placeholder": "Enter character name",
|
||||||
|
"helpText": "The character's full name as it appears in your world"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "age",
|
||||||
|
"label": "Age",
|
||||||
|
"type": "number",
|
||||||
|
"required": false,
|
||||||
|
"validation": {
|
||||||
|
"min": 0,
|
||||||
|
"max": 10000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "faction",
|
||||||
|
"label": "Faction",
|
||||||
|
"type": "select",
|
||||||
|
"required": true,
|
||||||
|
"options": "dynamic",
|
||||||
|
"optionsSource": "api.getFactions()"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "biography",
|
||||||
|
"label": "Biography",
|
||||||
|
"type": "textarea",
|
||||||
|
"required": false,
|
||||||
|
"validation": {
|
||||||
|
"maxLength": 5000
|
||||||
|
},
|
||||||
|
"rows": 8
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"formOptions": {
|
||||||
|
"submitLabel": "Create Character",
|
||||||
|
"resetLabel": "Clear Form",
|
||||||
|
"showReset": true,
|
||||||
|
"successMessage": "Character created successfully",
|
||||||
|
"errorMessage": "Failed to create character"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Generate Zod Schema
|
||||||
|
|
||||||
|
Use scripts/generate_zod_schema.py to create type-safe validation schema:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/generate_zod_schema.py --fields character-fields.json --output lib/schemas/character.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Generated schema includes:
|
||||||
|
- Field-level validation rules
|
||||||
|
- Custom refinements and transformations
|
||||||
|
- Type inference for TypeScript
|
||||||
|
- Error message customization
|
||||||
|
- Server-side validation support
|
||||||
|
|
||||||
|
### Step 3: Generate Form Component
|
||||||
|
|
||||||
|
Use scripts/generate_form.py to create React Hook Form component:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/generate_form.py --name CharacterForm --fields character-fields.json --output components/forms
|
||||||
|
```
|
||||||
|
|
||||||
|
Generated component includes:
|
||||||
|
- React Hook Form setup with useForm hook
|
||||||
|
- Zod schema resolver integration
|
||||||
|
- shadcn/ui FormField components
|
||||||
|
- Proper TypeScript types inferred from schema
|
||||||
|
- Accessible form controls with ARIA labels
|
||||||
|
- Error display with FormMessage components
|
||||||
|
- Form submission handler with loading states
|
||||||
|
- Success/error toast notifications
|
||||||
|
|
||||||
|
### Step 4: Create Server Action
|
||||||
|
|
||||||
|
Generate server action for form submission with server-side validation:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
'use server'
|
||||||
|
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { characterSchema } from '@/lib/schemas/character'
|
||||||
|
import { createCharacter } from '@/lib/db/characters'
|
||||||
|
|
||||||
|
export async function createCharacterAction(data: z.infer<typeof characterSchema>) {
|
||||||
|
// Server-side validation
|
||||||
|
const validated = characterSchema.safeParse(data)
|
||||||
|
|
||||||
|
if (!validated.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errors: validated.error.flatten().fieldErrors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Database operation
|
||||||
|
const character = await createCharacter(validated.data)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: character
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 5: Integrate Form into Page
|
||||||
|
|
||||||
|
Import and use generated form component in page or parent component:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { CharacterForm } from '@/components/forms/CharacterForm'
|
||||||
|
|
||||||
|
export default function CreateCharacterPage() {
|
||||||
|
return (
|
||||||
|
<div className="container max-w-2xl py-8">
|
||||||
|
<h1 className="text-3xl font-bold mb-6">Create New Character</h1>
|
||||||
|
<CharacterForm />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Field Type Support
|
||||||
|
|
||||||
|
Supported field types and their shadcn/ui mappings:
|
||||||
|
|
||||||
|
- **text** → Input (type="text")
|
||||||
|
- **email** → Input (type="email")
|
||||||
|
- **password** → Input (type="password")
|
||||||
|
- **number** → Input (type="number")
|
||||||
|
- **tel** → Input (type="tel")
|
||||||
|
- **url** → Input (type="url")
|
||||||
|
- **textarea** → Textarea
|
||||||
|
- **select** → Select with SelectTrigger/SelectContent
|
||||||
|
- **multiselect** → MultiSelect custom component
|
||||||
|
- **checkbox** → Checkbox
|
||||||
|
- **radio** → RadioGroup with RadioGroupItem
|
||||||
|
- **switch** → Switch
|
||||||
|
- **date** → DatePicker (Popover + Calendar)
|
||||||
|
- **datetime** → DateTimePicker custom component
|
||||||
|
- **file** → Input (type="file")
|
||||||
|
- **combobox** → Combobox (Command + Popover)
|
||||||
|
- **tags** → TagInput custom component
|
||||||
|
- **slider** → Slider
|
||||||
|
- **color** → ColorPicker custom component
|
||||||
|
|
||||||
|
## Validation Patterns
|
||||||
|
|
||||||
|
Common validation patterns using Zod:
|
||||||
|
|
||||||
|
### String Validation
|
||||||
|
```typescript
|
||||||
|
// Required with length constraints
|
||||||
|
z.string().min(2, "Too short").max(100, "Too long")
|
||||||
|
|
||||||
|
// Email
|
||||||
|
z.string().email("Invalid email")
|
||||||
|
|
||||||
|
// URL
|
||||||
|
z.string().url("Invalid URL")
|
||||||
|
|
||||||
|
// Pattern matching
|
||||||
|
z.string().regex(/^[a-zA-Z]+$/, "Letters only")
|
||||||
|
|
||||||
|
// Trimmed strings
|
||||||
|
z.string().trim().min(1)
|
||||||
|
|
||||||
|
// Custom transformation
|
||||||
|
z.string().transform(val => val.toLowerCase())
|
||||||
|
```
|
||||||
|
|
||||||
|
### Number Validation
|
||||||
|
```typescript
|
||||||
|
// Range validation
|
||||||
|
z.number().min(0).max(100)
|
||||||
|
|
||||||
|
// Integer only
|
||||||
|
z.number().int("Must be whole number")
|
||||||
|
|
||||||
|
// Positive numbers
|
||||||
|
z.number().positive("Must be positive")
|
||||||
|
|
||||||
|
// Custom refinement
|
||||||
|
z.number().refine(val => val % 5 === 0, "Must be multiple of 5")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Array Validation
|
||||||
|
```typescript
|
||||||
|
// Array with min/max items
|
||||||
|
z.array(z.string()).min(1, "Select at least one").max(5, "Too many")
|
||||||
|
|
||||||
|
// Non-empty array
|
||||||
|
z.array(z.string()).nonempty("Required")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Object Validation
|
||||||
|
```typescript
|
||||||
|
// Nested objects
|
||||||
|
z.object({
|
||||||
|
address: z.object({
|
||||||
|
street: z.string(),
|
||||||
|
city: z.string(),
|
||||||
|
zipCode: z.string().regex(/^\d{5}$/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Conditional Validation
|
||||||
|
```typescript
|
||||||
|
// Refine with cross-field validation
|
||||||
|
z.object({
|
||||||
|
password: z.string().min(8),
|
||||||
|
confirmPassword: z.string()
|
||||||
|
}).refine(data => data.password === data.confirmPassword, {
|
||||||
|
message: "Passwords must match",
|
||||||
|
path: ["confirmPassword"]
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Optional and Nullable Fields
|
||||||
|
```typescript
|
||||||
|
// Optional (can be undefined)
|
||||||
|
z.string().optional()
|
||||||
|
|
||||||
|
// Nullable (can be null)
|
||||||
|
z.string().nullable()
|
||||||
|
|
||||||
|
// Optional with default
|
||||||
|
z.string().default("default value")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Form Patterns
|
||||||
|
|
||||||
|
### Basic Form Structure
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useForm } from 'react-hook-form'
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@/components/ui/form'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
name: z.string().min(2).max(100),
|
||||||
|
email: z.string().email()
|
||||||
|
})
|
||||||
|
|
||||||
|
type FormValues = z.infer<typeof formSchema>
|
||||||
|
|
||||||
|
export function ExampleForm() {
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: '',
|
||||||
|
email: ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function onSubmit(values: FormValues) {
|
||||||
|
try {
|
||||||
|
const result = await submitAction(values)
|
||||||
|
if (result.success) {
|
||||||
|
toast.success('Submitted successfully')
|
||||||
|
form.reset()
|
||||||
|
} else {
|
||||||
|
toast.error(result.message)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('An error occurred')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Enter name" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Your display name</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Email</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="email" placeholder="you@example.com" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||||
|
{form.formState.isSubmitting ? 'Submitting...' : 'Submit'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Array Fields with useFieldArray
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useFieldArray } from 'react-hook-form'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
// In schema
|
||||||
|
const formSchema = z.object({
|
||||||
|
tags: z.array(z.object({
|
||||||
|
value: z.string().min(1)
|
||||||
|
})).min(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
// In component
|
||||||
|
const { fields, append, remove } = useFieldArray({
|
||||||
|
control: form.control,
|
||||||
|
name: 'tags'
|
||||||
|
})
|
||||||
|
|
||||||
|
// In JSX
|
||||||
|
{fields.map((field, index) => (
|
||||||
|
<div key={field.id} className="flex gap-2">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`tags.${index}.value`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex-1">
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button type="button" variant="destructive" size="icon" onClick={() => remove(index)}>
|
||||||
|
X
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button type="button" onClick={() => append({ value: '' })}>
|
||||||
|
Add Tag
|
||||||
|
</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### File Upload with Preview
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const [preview, setPreview] = useState<string | null>(null)
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="avatar"
|
||||||
|
render={({ field: { value, onChange, ...field } }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Avatar</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
{...field}
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (file) {
|
||||||
|
onChange(file)
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onloadend = () => setPreview(reader.result as string)
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
{preview && (
|
||||||
|
<img src={preview} alt="Preview" className="mt-2 h-32 w-32 object-cover rounded" />
|
||||||
|
)}
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Conditional Fields
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const showAdvanced = form.watch('showAdvanced')
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="showAdvanced"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex items-center gap-2">
|
||||||
|
<FormControl>
|
||||||
|
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||||
|
</FormControl>
|
||||||
|
<FormLabel>Show Advanced Options</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{showAdvanced && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="advancedOption"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Advanced Option</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Accessibility Considerations
|
||||||
|
|
||||||
|
Ensure forms are accessible by:
|
||||||
|
|
||||||
|
1. **Proper Labels**: Every form control must have an associated FormLabel
|
||||||
|
2. **Error Messages**: Use FormMessage to announce validation errors
|
||||||
|
3. **Descriptions**: Use FormDescription for helpful context
|
||||||
|
4. **Required Fields**: Mark required fields visually and in ARIA attributes
|
||||||
|
5. **Focus Management**: Ensure logical tab order and focus indicators
|
||||||
|
6. **Keyboard Navigation**: All controls operable via keyboard
|
||||||
|
7. **ARIA Attributes**: FormField automatically sets aria-describedby and aria-invalid
|
||||||
|
8. **Error Summary**: Consider adding error summary at top of form for screen readers
|
||||||
|
|
||||||
|
## Testing Generated Forms
|
||||||
|
|
||||||
|
Test forms using React Testing Library and Vitest:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { CharacterForm } from './CharacterForm'
|
||||||
|
|
||||||
|
describe('CharacterForm', () => {
|
||||||
|
it('validates required fields', async () => {
|
||||||
|
render(<CharacterForm />)
|
||||||
|
|
||||||
|
const submitButton = screen.getByRole('button', { name: /submit/i })
|
||||||
|
await userEvent.click(submitButton)
|
||||||
|
|
||||||
|
expect(await screen.findByText(/name is required/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('submits valid data', async () => {
|
||||||
|
const mockSubmit = vi.fn()
|
||||||
|
render(<CharacterForm onSubmit={mockSubmit} />)
|
||||||
|
|
||||||
|
await userEvent.type(screen.getByLabelText(/name/i), 'Aragorn')
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /submit/i }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockSubmit).toHaveBeenCalledWith({
|
||||||
|
name: 'Aragorn'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Use Cases for Worldbuilding
|
||||||
|
|
||||||
|
### Character Creation Form
|
||||||
|
Fields: name, race, faction, class, age, appearance, biography, relationships, attributes, inventory
|
||||||
|
|
||||||
|
### Location Form
|
||||||
|
Fields: name, type, region, coordinates, climate, population, government, description, points of interest
|
||||||
|
|
||||||
|
### Item/Artifact Form
|
||||||
|
Fields: name, type, rarity, owner, location, properties, history, magical effects, value
|
||||||
|
|
||||||
|
### Event/Timeline Form
|
||||||
|
Fields: title, date, location, participants, description, consequences, related events
|
||||||
|
|
||||||
|
### Faction/Organization Form
|
||||||
|
Fields: name, type, leader, headquarters, goals, allies, enemies, members, history
|
||||||
|
|
||||||
|
## Implementation Checklist
|
||||||
|
|
||||||
|
When generating forms, ensure:
|
||||||
|
|
||||||
|
- [ ] Zod schema created with all validation rules
|
||||||
|
- [ ] Form component uses zodResolver
|
||||||
|
- [ ] All field types mapped to appropriate shadcn/ui components
|
||||||
|
- [ ] FormField used for each field with proper render prop
|
||||||
|
- [ ] FormLabel, FormControl, FormMessage included for each field
|
||||||
|
- [ ] Form submission handler with error handling
|
||||||
|
- [ ] Loading states during submission
|
||||||
|
- [ ] Success/error feedback (toasts or messages)
|
||||||
|
- [ ] Server action created with server-side validation
|
||||||
|
- [ ] TypeScript types inferred from Zod schema
|
||||||
|
- [ ] Accessibility attributes present
|
||||||
|
- [ ] Form reset after successful submission
|
||||||
|
- [ ] Proper default values set
|
||||||
|
|
||||||
|
## Dependencies Required
|
||||||
|
|
||||||
|
Ensure these packages are installed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install react-hook-form @hookform/resolvers zod
|
||||||
|
npm install sonner # for toast notifications
|
||||||
|
```
|
||||||
|
|
||||||
|
shadcn/ui components needed:
|
||||||
|
```bash
|
||||||
|
npx shadcn-ui@latest add form button input textarea select checkbox radio-group switch slider
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Co-locate validation**: Keep Zod schemas close to form components
|
||||||
|
2. **Reuse schemas**: Share schemas between client and server validation
|
||||||
|
3. **Type inference**: Use `z.infer<typeof schema>` for TypeScript types
|
||||||
|
4. **Granular validation**: Validate on blur for better UX
|
||||||
|
5. **Optimistic updates**: Show success state before server confirmation when appropriate
|
||||||
|
6. **Error recovery**: Allow users to easily fix validation errors
|
||||||
|
7. **Progress indication**: Show loading states during async operations
|
||||||
|
8. **Data persistence**: Consider auto-saving drafts for long forms
|
||||||
|
9. **Field dependencies**: Use form.watch() for conditional fields
|
||||||
|
10. **Performance**: Use mode: 'onBlur' or 'onChange' based on form complexity
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**Issue**: Form not submitting
|
||||||
|
- Check handleSubmit is wrapping onSubmit
|
||||||
|
- Verify zodResolver is configured
|
||||||
|
- Check for validation errors in form state
|
||||||
|
|
||||||
|
**Issue**: Validation not working
|
||||||
|
- Ensure schema matches field names exactly
|
||||||
|
- Check resolver is zodResolver(schema)
|
||||||
|
- Verify field is registered with FormField
|
||||||
|
|
||||||
|
**Issue**: TypeScript errors
|
||||||
|
- Use z.infer<typeof schema> for type inference
|
||||||
|
- Ensure form values type matches schema type
|
||||||
|
- Check FormField generic type matches field value type
|
||||||
|
|
||||||
|
**Issue**: Field not updating
|
||||||
|
- Verify field spread {...field} is applied
|
||||||
|
- Check value/onChange are not overridden incorrectly
|
||||||
|
- Use field.value and field.onChange for controlled components
|
||||||
|
|
||||||
|
## Additional Resources
|
||||||
|
|
||||||
|
Consult references/ directory for detailed patterns:
|
||||||
|
- references/rhf-patterns.md - Advanced React Hook Form patterns
|
||||||
|
- references/zod-validation.md - Complex validation scenarios
|
||||||
|
- references/shadcn-form-controls.md - All form component variants
|
||||||
|
- references/server-actions.md - Server-side form handling
|
||||||
|
|
||||||
|
Use assets/ directory for starting templates:
|
||||||
|
- assets/form-template.tsx - Copy and customize
|
||||||
|
- assets/field-templates/ - Individual field implementations
|
||||||
|
- assets/validation-schemas.ts - Common validation patterns
|
||||||
159
skills/form-generator-rhf-zod/assets/form-template.tsx
Normal file
159
skills/form-generator-rhf-zod/assets/form-template.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useForm } from 'react-hook-form'
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@/components/ui/form'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
// Define your schema
|
||||||
|
const formSchema = z.object({
|
||||||
|
name: z.string().min(2, 'Name must be at least 2 characters').max(100),
|
||||||
|
email: z.string().email('Invalid email address'),
|
||||||
|
// Add more fields as needed
|
||||||
|
})
|
||||||
|
|
||||||
|
// Infer TypeScript type from schema
|
||||||
|
type FormValues = z.infer<typeof formSchema>
|
||||||
|
|
||||||
|
interface ExampleFormProps {
|
||||||
|
defaultValues?: Partial<FormValues>
|
||||||
|
onSuccess?: (data: FormValues) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExampleForm({ defaultValues, onSuccess }: ExampleFormProps) {
|
||||||
|
// Initialize form with React Hook Form
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
...defaultValues,
|
||||||
|
},
|
||||||
|
mode: 'onBlur', // Validate on blur for better UX
|
||||||
|
})
|
||||||
|
|
||||||
|
// Form submission handler
|
||||||
|
async function onSubmit(values: FormValues) {
|
||||||
|
try {
|
||||||
|
// Call your server action or API endpoint
|
||||||
|
const result = await submitFormAction(values)
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
toast.success('Form submitted successfully')
|
||||||
|
form.reset()
|
||||||
|
onSuccess?.(values)
|
||||||
|
} else {
|
||||||
|
// Handle server-side validation errors
|
||||||
|
if (result.errors) {
|
||||||
|
Object.entries(result.errors).forEach(([field, message]) => {
|
||||||
|
form.setError(field as keyof FormValues, {
|
||||||
|
type: 'server',
|
||||||
|
message: message as string,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
toast.error(result.message || 'Failed to submit form')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Form submission error:', error)
|
||||||
|
toast.error('An unexpected error occurred')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
{/* Name Field */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Enter your name" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
This is your display name.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Email Field */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Email</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="email" placeholder="you@example.com" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Add more FormField components as needed */}
|
||||||
|
|
||||||
|
{/* Root Error Display */}
|
||||||
|
{form.formState.errors.root && (
|
||||||
|
<div className="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
|
||||||
|
{form.formState.errors.root.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Submit Button */}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||||
|
{form.formState.isSubmitting ? 'Submitting...' : 'Submit'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => form.reset()}
|
||||||
|
disabled={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Placeholder server action - replace with actual implementation
|
||||||
|
async function submitFormAction(data: FormValues) {
|
||||||
|
// Simulate API call
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||||
|
|
||||||
|
// Example server-side validation
|
||||||
|
const validated = formSchema.safeParse(data)
|
||||||
|
if (!validated.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errors: validated.error.flatten().fieldErrors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform database operation
|
||||||
|
// const result = await db.insert(...)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: validated.data,
|
||||||
|
}
|
||||||
|
}
|
||||||
631
skills/form-generator-rhf-zod/references/rhf-patterns.md
Normal file
631
skills/form-generator-rhf-zod/references/rhf-patterns.md
Normal file
@@ -0,0 +1,631 @@
|
|||||||
|
# React Hook Form Patterns Reference
|
||||||
|
|
||||||
|
## Core Hooks
|
||||||
|
|
||||||
|
### useForm
|
||||||
|
|
||||||
|
Main hook for form management:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
defaultValues: { name: '', email: '' },
|
||||||
|
mode: 'onBlur', // 'onChange' | 'onBlur' | 'onSubmit' | 'onTouched' | 'all'
|
||||||
|
reValidateMode: 'onChange', // When to re-validate after first error
|
||||||
|
criteriaMode: 'firstError', // 'firstError' | 'all'
|
||||||
|
shouldFocusError: true,
|
||||||
|
shouldUnregister: false,
|
||||||
|
shouldUseNativeValidation: false,
|
||||||
|
delayError: 500 // Delay error display (ms)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### useFieldArray
|
||||||
|
|
||||||
|
Manage dynamic field arrays:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const { fields, append, prepend, remove, swap, move, insert } = useFieldArray({
|
||||||
|
control: form.control,
|
||||||
|
name: 'items',
|
||||||
|
keyName: 'id' // Custom key name (default: 'id')
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### useWatch
|
||||||
|
|
||||||
|
Watch field values without re-rendering entire form:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const watchedValue = useWatch({
|
||||||
|
control: form.control,
|
||||||
|
name: 'fieldName',
|
||||||
|
defaultValue: 'default'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Watch multiple fields
|
||||||
|
const [field1, field2] = useWatch({
|
||||||
|
control: form.control,
|
||||||
|
name: ['field1', 'field2']
|
||||||
|
})
|
||||||
|
|
||||||
|
// Watch all fields
|
||||||
|
const allValues = useWatch({ control: form.control })
|
||||||
|
```
|
||||||
|
|
||||||
|
### useFormState
|
||||||
|
|
||||||
|
Access form state without subscribing to all changes:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const { isDirty, isValid, errors, isSubmitting } = useFormState({
|
||||||
|
control: form.control
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### useController
|
||||||
|
|
||||||
|
Lower-level field control:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const { field, fieldState, formState } = useController({
|
||||||
|
name: 'fieldName',
|
||||||
|
control: form.control,
|
||||||
|
rules: { required: true },
|
||||||
|
defaultValue: ''
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Form Validation Modes
|
||||||
|
|
||||||
|
### onSubmit (Default)
|
||||||
|
- Validates on form submission
|
||||||
|
- Best for simple forms
|
||||||
|
- Minimal re-renders
|
||||||
|
- Delayed feedback
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const form = useForm({ mode: 'onSubmit' })
|
||||||
|
```
|
||||||
|
|
||||||
|
### onBlur
|
||||||
|
- Validates when field loses focus
|
||||||
|
- Good balance of UX and performance
|
||||||
|
- Recommended for most forms
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const form = useForm({ mode: 'onBlur' })
|
||||||
|
```
|
||||||
|
|
||||||
|
### onChange
|
||||||
|
- Validates on every keystroke
|
||||||
|
- Real-time feedback
|
||||||
|
- More re-renders
|
||||||
|
- Good for complex validation
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const form = useForm({ mode: 'onChange' })
|
||||||
|
```
|
||||||
|
|
||||||
|
### onTouched
|
||||||
|
- Validates after field is touched then on every change
|
||||||
|
- Progressive enhancement approach
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const form = useForm({ mode: 'onTouched' })
|
||||||
|
```
|
||||||
|
|
||||||
|
### all
|
||||||
|
- Validates on all events
|
||||||
|
- Most feedback but most re-renders
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const form = useForm({ mode: 'all' })
|
||||||
|
```
|
||||||
|
|
||||||
|
## Form Methods
|
||||||
|
|
||||||
|
### Form State Access
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Check if form is dirty
|
||||||
|
form.formState.isDirty
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
form.formState.isValid
|
||||||
|
|
||||||
|
// Check if submitting
|
||||||
|
form.formState.isSubmitting
|
||||||
|
|
||||||
|
// Get all errors
|
||||||
|
form.formState.errors
|
||||||
|
|
||||||
|
// Check if field is touched
|
||||||
|
form.formState.touchedFields.fieldName
|
||||||
|
|
||||||
|
// Get dirty fields
|
||||||
|
form.formState.dirtyFields
|
||||||
|
|
||||||
|
// Submit count
|
||||||
|
form.formState.submitCount
|
||||||
|
```
|
||||||
|
|
||||||
|
### Form Actions
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Submit form
|
||||||
|
form.handleSubmit(onSubmit, onError)
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
form.reset() // to default values
|
||||||
|
form.reset({ name: 'New Name' }) // to specific values
|
||||||
|
|
||||||
|
// Set value
|
||||||
|
form.setValue('fieldName', 'value', {
|
||||||
|
shouldValidate: true,
|
||||||
|
shouldDirty: true,
|
||||||
|
shouldTouch: true
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get value
|
||||||
|
const value = form.getValues('fieldName')
|
||||||
|
const allValues = form.getValues()
|
||||||
|
|
||||||
|
// Clear errors
|
||||||
|
form.clearErrors() // all errors
|
||||||
|
form.clearErrors('fieldName') // specific field
|
||||||
|
|
||||||
|
// Set error
|
||||||
|
form.setError('fieldName', {
|
||||||
|
type: 'manual',
|
||||||
|
message: 'Error message'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Trigger validation
|
||||||
|
form.trigger() // all fields
|
||||||
|
form.trigger('fieldName') // specific field
|
||||||
|
form.trigger(['field1', 'field2']) // multiple fields
|
||||||
|
|
||||||
|
// Set focus
|
||||||
|
form.setFocus('fieldName')
|
||||||
|
```
|
||||||
|
|
||||||
|
## Advanced Patterns
|
||||||
|
|
||||||
|
### Dependent Fields
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const faction = form.watch('faction')
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="rank"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Rank</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select rank" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{getRanksForFaction(faction).map(rank => (
|
||||||
|
<SelectItem key={rank.id} value={rank.id}>
|
||||||
|
{rank.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Async Validation
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const schema = z.object({
|
||||||
|
username: z.string()
|
||||||
|
.min(3)
|
||||||
|
.refine(async (username) => {
|
||||||
|
const available = await checkUsernameAvailability(username)
|
||||||
|
return available
|
||||||
|
}, {
|
||||||
|
message: 'Username already taken'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Use mode: 'onBlur' for better UX with async validation
|
||||||
|
const form = useForm({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
mode: 'onBlur'
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Form Context
|
||||||
|
|
||||||
|
Share form between multiple components:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Parent component
|
||||||
|
import { FormProvider } from 'react-hook-form'
|
||||||
|
|
||||||
|
const form = useForm()
|
||||||
|
|
||||||
|
<FormProvider {...form}>
|
||||||
|
<ChildComponent1 />
|
||||||
|
<ChildComponent2 />
|
||||||
|
</FormProvider>
|
||||||
|
|
||||||
|
// Child component
|
||||||
|
import { useFormContext } from 'react-hook-form'
|
||||||
|
|
||||||
|
function ChildComponent() {
|
||||||
|
const form = useFormContext()
|
||||||
|
// Use form methods
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Controlled Components
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Controller } from 'react-hook-form'
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="customField"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field, fieldState, formState }) => (
|
||||||
|
<CustomComponent
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
error={fieldState.error?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Transform Values on Submit
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const onSubmit = form.handleSubmit((data) => {
|
||||||
|
const transformed = {
|
||||||
|
...data,
|
||||||
|
age: parseInt(data.age),
|
||||||
|
tags: data.tags.map(t => t.toLowerCase()),
|
||||||
|
createdAt: new Date()
|
||||||
|
}
|
||||||
|
// Submit transformed data
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dirty Field Tracking
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const dirtyFields = form.formState.dirtyFields
|
||||||
|
|
||||||
|
function onSubmit(data: FormValues) {
|
||||||
|
// Only submit changed fields
|
||||||
|
const changedData = Object.keys(dirtyFields).reduce((acc, key) => {
|
||||||
|
acc[key] = data[key]
|
||||||
|
return acc
|
||||||
|
}, {} as Partial<FormValues>)
|
||||||
|
|
||||||
|
await updateEntity(id, changedData)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multi-Step Form
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const [step, setStep] = useState(1)
|
||||||
|
|
||||||
|
function onSubmit(data: FormValues) {
|
||||||
|
if (step < 3) {
|
||||||
|
setStep(step + 1)
|
||||||
|
} else {
|
||||||
|
// Final submission
|
||||||
|
submitData(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
|
{step === 1 && <Step1Fields control={form.control} />}
|
||||||
|
{step === 2 && <Step2Fields control={form.control} />}
|
||||||
|
{step === 3 && <Step3Fields control={form.control} />}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{step > 1 && (
|
||||||
|
<Button type="button" onClick={() => setStep(step - 1)}>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button type="submit">
|
||||||
|
{step < 3 ? 'Next' : 'Submit'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reset with Default Values
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Reset to specific values after successful submission
|
||||||
|
async function onSubmit(data: FormValues) {
|
||||||
|
const result = await createEntity(data)
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
form.reset({
|
||||||
|
name: '',
|
||||||
|
type: 'default',
|
||||||
|
// Keep some values
|
||||||
|
category: data.category
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
async function onSubmit(data: FormValues) {
|
||||||
|
try {
|
||||||
|
const result = await submitAction(data)
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
// Set server errors
|
||||||
|
if (result.errors) {
|
||||||
|
Object.entries(result.errors).forEach(([field, message]) => {
|
||||||
|
form.setError(field as any, {
|
||||||
|
type: 'server',
|
||||||
|
message: message as string
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set root error for general issues
|
||||||
|
if (result.message) {
|
||||||
|
form.setError('root', {
|
||||||
|
type: 'server',
|
||||||
|
message: result.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.success('Saved successfully')
|
||||||
|
form.reset()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
form.setError('root', {
|
||||||
|
type: 'server',
|
||||||
|
message: 'An unexpected error occurred'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display root error
|
||||||
|
{form.formState.errors.root && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>
|
||||||
|
{form.formState.errors.root.message}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Optimistic Updates
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const [optimisticData, setOptimisticData] = useState<Entity | null>(null)
|
||||||
|
|
||||||
|
async function onSubmit(data: FormValues) {
|
||||||
|
// Show optimistic state
|
||||||
|
setOptimisticData(data as Entity)
|
||||||
|
toast.success('Saving...')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await saveEntity(data)
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
// Confirm success
|
||||||
|
setOptimisticData(result.data)
|
||||||
|
toast.success('Saved successfully')
|
||||||
|
} else {
|
||||||
|
// Revert optimistic state
|
||||||
|
setOptimisticData(null)
|
||||||
|
toast.error('Save failed')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setOptimisticData(null)
|
||||||
|
toast.error('An error occurred')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Auto-Save Draft
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useDebouncedCallback } from 'use-debounce'
|
||||||
|
|
||||||
|
const saveDraft = useDebouncedCallback(async (data: FormValues) => {
|
||||||
|
await saveDraftToStorage(data)
|
||||||
|
}, 1000)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const subscription = form.watch((data) => {
|
||||||
|
if (form.formState.isDirty) {
|
||||||
|
saveDraft(data as FormValues)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return () => subscription.unsubscribe()
|
||||||
|
}, [form.watch, form.formState.isDirty])
|
||||||
|
|
||||||
|
// Load draft on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const draft = loadDraftFromStorage()
|
||||||
|
if (draft) {
|
||||||
|
form.reset(draft)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
```
|
||||||
|
|
||||||
|
### Conditional Required Fields
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const schema = z.object({
|
||||||
|
type: z.enum(['character', 'location']),
|
||||||
|
characterRace: z.string().optional(),
|
||||||
|
locationType: z.string().optional()
|
||||||
|
}).refine(
|
||||||
|
(data) => {
|
||||||
|
if (data.type === 'character') {
|
||||||
|
return data.characterRace !== undefined && data.characterRace.length > 0
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: 'Race is required for characters',
|
||||||
|
path: ['characterRace']
|
||||||
|
}
|
||||||
|
).refine(
|
||||||
|
(data) => {
|
||||||
|
if (data.type === 'location') {
|
||||||
|
return data.locationType !== undefined && data.locationType.length > 0
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: 'Location type is required',
|
||||||
|
path: ['locationType']
|
||||||
|
}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Field Masking/Formatting
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="phone"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Phone</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
onChange={(e) => {
|
||||||
|
const formatted = formatPhoneNumber(e.target.value)
|
||||||
|
field.onChange(formatted)
|
||||||
|
}}
|
||||||
|
placeholder="(123) 456-7890"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
function formatPhoneNumber(value: string): string {
|
||||||
|
const numbers = value.replace(/\D/g, '')
|
||||||
|
const match = numbers.match(/^(\d{0,3})(\d{0,3})(\d{0,4})$/)
|
||||||
|
if (!match) return value
|
||||||
|
|
||||||
|
const formatted = [match[1], match[2], match[3]]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('-')
|
||||||
|
|
||||||
|
return formatted
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Optimization
|
||||||
|
|
||||||
|
### Isolate Re-renders
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Bad: Entire form re-renders on field change
|
||||||
|
const value = form.watch('field')
|
||||||
|
|
||||||
|
// Good: Only component re-renders
|
||||||
|
function WatchedField() {
|
||||||
|
const value = useWatch({ control: form.control, name: 'field' })
|
||||||
|
return <div>{value}</div>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Use FormField for Each Field
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// FormField isolates re-renders to individual fields
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="field"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Memoize Expensive Computations
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
|
const options = useMemo(() => {
|
||||||
|
return computeExpensiveOptions()
|
||||||
|
}, [dependencies])
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Patterns
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { MyForm } from './MyForm'
|
||||||
|
|
||||||
|
describe('MyForm', () => {
|
||||||
|
it('submits form with valid data', async () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
render(<MyForm onSubmit={onSubmit} />)
|
||||||
|
|
||||||
|
await userEvent.type(screen.getByLabelText(/name/i), 'Test Name')
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /submit/i }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onSubmit).toHaveBeenCalledWith({
|
||||||
|
name: 'Test Name'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows validation errors', async () => {
|
||||||
|
render(<MyForm />)
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /submit/i }))
|
||||||
|
|
||||||
|
expect(await screen.findByText(/name is required/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears errors on valid input', async () => {
|
||||||
|
render(<MyForm />)
|
||||||
|
|
||||||
|
const input = screen.getByLabelText(/name/i)
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /submit/i }))
|
||||||
|
expect(await screen.findByText(/name is required/i)).toBeInTheDocument()
|
||||||
|
|
||||||
|
await userEvent.type(input, 'Valid Name')
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText(/name is required/i)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
784
skills/form-generator-rhf-zod/references/zod-validation.md
Normal file
784
skills/form-generator-rhf-zod/references/zod-validation.md
Normal file
@@ -0,0 +1,784 @@
|
|||||||
|
# Zod Validation Reference
|
||||||
|
|
||||||
|
## Basic Types
|
||||||
|
|
||||||
|
### Strings
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Basic string
|
||||||
|
z.string()
|
||||||
|
|
||||||
|
// With constraints
|
||||||
|
z.string().min(3, "Minimum 3 characters")
|
||||||
|
z.string().max(100, "Maximum 100 characters")
|
||||||
|
z.string().length(10, "Must be exactly 10 characters")
|
||||||
|
|
||||||
|
// Email
|
||||||
|
z.string().email("Invalid email address")
|
||||||
|
|
||||||
|
// URL
|
||||||
|
z.string().url("Invalid URL")
|
||||||
|
|
||||||
|
// UUID
|
||||||
|
z.string().uuid("Invalid UUID")
|
||||||
|
|
||||||
|
// CUID
|
||||||
|
z.string().cuid("Invalid CUID")
|
||||||
|
|
||||||
|
// Regex pattern
|
||||||
|
z.string().regex(/^[a-zA-Z]+$/, "Letters only")
|
||||||
|
|
||||||
|
// DateTime ISO
|
||||||
|
z.string().datetime("Invalid datetime")
|
||||||
|
|
||||||
|
// IP Address
|
||||||
|
z.string().ip("Invalid IP address")
|
||||||
|
z.string().ip({ version: 'v4' }) // IPv4 only
|
||||||
|
z.string().ip({ version: 'v6' }) // IPv6 only
|
||||||
|
|
||||||
|
// Trim whitespace
|
||||||
|
z.string().trim()
|
||||||
|
|
||||||
|
// Transform to lowercase
|
||||||
|
z.string().toLowerCase()
|
||||||
|
|
||||||
|
// Transform to uppercase
|
||||||
|
z.string().toUpperCase()
|
||||||
|
|
||||||
|
// Empty strings as null
|
||||||
|
z.string().nullable()
|
||||||
|
|
||||||
|
// Starts with
|
||||||
|
z.string().startsWith("prefix", "Must start with 'prefix'")
|
||||||
|
|
||||||
|
// Ends with
|
||||||
|
z.string().endsWith("suffix", "Must end with 'suffix'")
|
||||||
|
|
||||||
|
// Includes
|
||||||
|
z.string().includes("substring", "Must contain 'substring'")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Numbers
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Basic number
|
||||||
|
z.number()
|
||||||
|
|
||||||
|
// Integer only
|
||||||
|
z.number().int("Must be an integer")
|
||||||
|
|
||||||
|
// Positive numbers
|
||||||
|
z.number().positive("Must be positive")
|
||||||
|
|
||||||
|
// Non-negative (includes 0)
|
||||||
|
z.number().nonnegative("Must be 0 or greater")
|
||||||
|
|
||||||
|
// Negative numbers
|
||||||
|
z.number().negative("Must be negative")
|
||||||
|
|
||||||
|
// Non-positive (includes 0)
|
||||||
|
z.number().nonpositive("Must be 0 or less")
|
||||||
|
|
||||||
|
// Range
|
||||||
|
z.number().min(0, "Minimum is 0")
|
||||||
|
z.number().max(100, "Maximum is 100")
|
||||||
|
z.number().gte(0).lte(100) // Greater/less than or equal
|
||||||
|
|
||||||
|
// Multiple of
|
||||||
|
z.number().multipleOf(5, "Must be multiple of 5")
|
||||||
|
|
||||||
|
// Finite (not Infinity or NaN)
|
||||||
|
z.number().finite("Must be finite")
|
||||||
|
|
||||||
|
// Safe integer
|
||||||
|
z.number().safe("Must be safe integer")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Booleans
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Basic boolean
|
||||||
|
z.boolean()
|
||||||
|
|
||||||
|
// Coerce from string
|
||||||
|
z.coerce.boolean() // "true", "false", "1", "0" → boolean
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dates
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Date object
|
||||||
|
z.date()
|
||||||
|
|
||||||
|
// Min/max dates
|
||||||
|
z.date().min(new Date("2020-01-01"), "Too early")
|
||||||
|
z.date().max(new Date("2030-12-31"), "Too late")
|
||||||
|
|
||||||
|
// Coerce from string or number
|
||||||
|
z.coerce.date()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Enums
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// String enum
|
||||||
|
z.enum(['character', 'location', 'item'])
|
||||||
|
|
||||||
|
// With error message
|
||||||
|
z.enum(['character', 'location', 'item'], {
|
||||||
|
errorMap: () => ({ message: "Invalid entity type" })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Native enum
|
||||||
|
enum Role {
|
||||||
|
Admin = 'ADMIN',
|
||||||
|
User = 'USER'
|
||||||
|
}
|
||||||
|
z.nativeEnum(Role)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Literals
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Single literal value
|
||||||
|
z.literal('admin')
|
||||||
|
z.literal(42)
|
||||||
|
z.literal(true)
|
||||||
|
|
||||||
|
// Union of literals
|
||||||
|
z.union([
|
||||||
|
z.literal('small'),
|
||||||
|
z.literal('medium'),
|
||||||
|
z.literal('large')
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complex Types
|
||||||
|
|
||||||
|
### Objects
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Basic object
|
||||||
|
z.object({
|
||||||
|
name: z.string(),
|
||||||
|
age: z.number()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Nested objects
|
||||||
|
z.object({
|
||||||
|
user: z.object({
|
||||||
|
name: z.string(),
|
||||||
|
email: z.string().email()
|
||||||
|
}),
|
||||||
|
address: z.object({
|
||||||
|
street: z.string(),
|
||||||
|
city: z.string()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Optional properties
|
||||||
|
z.object({
|
||||||
|
name: z.string(),
|
||||||
|
nickname: z.string().optional()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Partial (all properties optional)
|
||||||
|
const schema = z.object({ name: z.string(), age: z.number() })
|
||||||
|
const partialSchema = schema.partial()
|
||||||
|
|
||||||
|
// Pick specific keys
|
||||||
|
schema.pick({ name: true })
|
||||||
|
|
||||||
|
// Omit specific keys
|
||||||
|
schema.omit({ age: true })
|
||||||
|
|
||||||
|
// Extend object
|
||||||
|
const baseSchema = z.object({ name: z.string() })
|
||||||
|
const extendedSchema = baseSchema.extend({
|
||||||
|
age: z.number()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Merge objects
|
||||||
|
const merged = schema1.merge(schema2)
|
||||||
|
|
||||||
|
// Passthrough (allow unknown keys)
|
||||||
|
z.object({ name: z.string() }).passthrough()
|
||||||
|
|
||||||
|
// Strict (reject unknown keys)
|
||||||
|
z.object({ name: z.string() }).strict()
|
||||||
|
|
||||||
|
// Catchall (type for unknown keys)
|
||||||
|
z.object({ name: z.string() }).catchall(z.string())
|
||||||
|
```
|
||||||
|
|
||||||
|
### Arrays
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Basic array
|
||||||
|
z.array(z.string())
|
||||||
|
|
||||||
|
// Array with min/max length
|
||||||
|
z.array(z.string()).min(1, "At least one item required")
|
||||||
|
z.array(z.string()).max(10, "Maximum 10 items")
|
||||||
|
z.array(z.string()).length(5, "Must have exactly 5 items")
|
||||||
|
|
||||||
|
// Non-empty array
|
||||||
|
z.array(z.string()).nonempty("Cannot be empty")
|
||||||
|
|
||||||
|
// Array of objects
|
||||||
|
z.array(z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string()
|
||||||
|
}))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tuples
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Fixed-length array with specific types
|
||||||
|
z.tuple([z.string(), z.number(), z.boolean()])
|
||||||
|
|
||||||
|
// With rest parameter
|
||||||
|
z.tuple([z.string(), z.number()]).rest(z.string())
|
||||||
|
```
|
||||||
|
|
||||||
|
### Records
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Object with string keys and specific value type
|
||||||
|
z.record(z.string()) // Record<string, string>
|
||||||
|
z.record(z.number()) // Record<string, number>
|
||||||
|
|
||||||
|
// With specific key type
|
||||||
|
z.record(z.enum(['a', 'b', 'c']), z.number())
|
||||||
|
```
|
||||||
|
|
||||||
|
### Maps & Sets
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Map
|
||||||
|
z.map(z.string(), z.number()) // Map<string, number>
|
||||||
|
|
||||||
|
// Set
|
||||||
|
z.set(z.string()) // Set<string>
|
||||||
|
z.set(z.number()).min(3, "At least 3 items")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Unions
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Union of types
|
||||||
|
z.union([z.string(), z.number()])
|
||||||
|
|
||||||
|
// Discriminated union
|
||||||
|
z.discriminatedUnion('type', [
|
||||||
|
z.object({ type: z.literal('character'), race: z.string() }),
|
||||||
|
z.object({ type: z.literal('location'), region: z.string() })
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
### Intersections
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Combine multiple schemas
|
||||||
|
const Name = z.object({ name: z.string() })
|
||||||
|
const Age = z.object({ age: z.number() })
|
||||||
|
const Person = z.intersection(Name, Age)
|
||||||
|
// or
|
||||||
|
const Person = Name.and(Age)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Optional and Nullable
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Optional (can be undefined)
|
||||||
|
z.string().optional() // string | undefined
|
||||||
|
|
||||||
|
// Nullable (can be null)
|
||||||
|
z.string().nullable() // string | null
|
||||||
|
|
||||||
|
// Both
|
||||||
|
z.string().nullish() // string | null | undefined
|
||||||
|
|
||||||
|
// With default value
|
||||||
|
z.string().default("default value")
|
||||||
|
z.number().default(0)
|
||||||
|
|
||||||
|
// Default from function
|
||||||
|
z.date().default(() => new Date())
|
||||||
|
```
|
||||||
|
|
||||||
|
## Refinements
|
||||||
|
|
||||||
|
### Basic Refinement
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Custom validation
|
||||||
|
z.string().refine(
|
||||||
|
(val) => val.length > 5,
|
||||||
|
{ message: "Must be more than 5 characters" }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Multiple refinements
|
||||||
|
z.string()
|
||||||
|
.refine((val) => val.includes('@'), "Must include @")
|
||||||
|
.refine((val) => val.endsWith('.com'), "Must end with .com")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Async Refinement
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
z.string().refine(
|
||||||
|
async (username) => {
|
||||||
|
const exists = await checkUsername(username)
|
||||||
|
return !exists
|
||||||
|
},
|
||||||
|
{ message: "Username already taken" }
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cross-Field Validation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
z.object({
|
||||||
|
password: z.string().min(8),
|
||||||
|
confirmPassword: z.string()
|
||||||
|
}).refine(
|
||||||
|
(data) => data.password === data.confirmPassword,
|
||||||
|
{
|
||||||
|
message: "Passwords must match",
|
||||||
|
path: ["confirmPassword"] // Error shown on this field
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Multiple cross-field validations
|
||||||
|
z.object({
|
||||||
|
startDate: z.date(),
|
||||||
|
endDate: z.date(),
|
||||||
|
type: z.enum(['event', 'period'])
|
||||||
|
}).refine(
|
||||||
|
(data) => data.startDate < data.endDate,
|
||||||
|
{
|
||||||
|
message: "End date must be after start date",
|
||||||
|
path: ["endDate"]
|
||||||
|
}
|
||||||
|
).refine(
|
||||||
|
(data) => {
|
||||||
|
if (data.type === 'event') {
|
||||||
|
return data.startDate.getTime() === data.endDate.getTime()
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "Events must have same start and end date",
|
||||||
|
path: ["endDate"]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Superrefine (Advanced)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
z.object({
|
||||||
|
age: z.number(),
|
||||||
|
faction: z.string()
|
||||||
|
}).superrefine((data, ctx) => {
|
||||||
|
if (data.age < 18 && data.faction === 'military') {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Must be 18+ to join military",
|
||||||
|
path: ["age"]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.age > 1000 && !data.faction.startsWith('ancient')) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Ancient beings must join ancient factions",
|
||||||
|
path: ["faction"]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Transformations
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Transform string to number
|
||||||
|
z.string().transform((val) => parseInt(val))
|
||||||
|
|
||||||
|
// Transform and validate
|
||||||
|
z.string()
|
||||||
|
.transform((val) => val.trim())
|
||||||
|
.pipe(z.string().min(1))
|
||||||
|
|
||||||
|
// Transform dates
|
||||||
|
z.string().transform((val) => new Date(val))
|
||||||
|
|
||||||
|
// Coerce types
|
||||||
|
z.coerce.number() // "123" → 123
|
||||||
|
z.coerce.boolean() // "true" → true
|
||||||
|
z.coerce.date() // "2023-01-01" → Date
|
||||||
|
|
||||||
|
// Chain transformations
|
||||||
|
z.string()
|
||||||
|
.transform((val) => val.trim())
|
||||||
|
.transform((val) => val.toLowerCase())
|
||||||
|
.transform((val) => val.split(','))
|
||||||
|
```
|
||||||
|
|
||||||
|
## Preprocess
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Transform before validation
|
||||||
|
z.preprocess(
|
||||||
|
(val) => {
|
||||||
|
if (typeof val === 'string') {
|
||||||
|
return val.trim()
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
},
|
||||||
|
z.string().min(1)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Normalize dates
|
||||||
|
z.preprocess(
|
||||||
|
(val) => {
|
||||||
|
if (val instanceof Date) return val
|
||||||
|
if (typeof val === 'string') return new Date(val)
|
||||||
|
return val
|
||||||
|
},
|
||||||
|
z.date()
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Custom Error Messages
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Field-level messages
|
||||||
|
z.string({
|
||||||
|
required_error: "Name is required",
|
||||||
|
invalid_type_error: "Name must be a string"
|
||||||
|
})
|
||||||
|
|
||||||
|
// Constraint messages
|
||||||
|
z.string()
|
||||||
|
.min(3, { message: "Minimum 3 characters" })
|
||||||
|
.max(100, { message: "Maximum 100 characters" })
|
||||||
|
|
||||||
|
// Error map for entire schema
|
||||||
|
const schema = z.object({
|
||||||
|
name: z.string(),
|
||||||
|
age: z.number()
|
||||||
|
}, {
|
||||||
|
errorMap: (issue, ctx) => {
|
||||||
|
if (issue.code === z.ZodIssueCode.invalid_type) {
|
||||||
|
return { message: "Please provide a valid value" }
|
||||||
|
}
|
||||||
|
return { message: ctx.defaultError }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parsing and Validation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Parse (throws on error)
|
||||||
|
try {
|
||||||
|
const result = schema.parse(data)
|
||||||
|
// result is typed
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
console.log(error.errors)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safe parse (returns result object)
|
||||||
|
const result = schema.safeParse(data)
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
console.log(result.data) // typed data
|
||||||
|
} else {
|
||||||
|
console.log(result.error) // ZodError
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse async
|
||||||
|
const result = await schema.parseAsync(data)
|
||||||
|
|
||||||
|
// Safe parse async
|
||||||
|
const result = await schema.safeParseAsync(data)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Get all errors
|
||||||
|
const result = schema.safeParse(data)
|
||||||
|
if (!result.success) {
|
||||||
|
result.error.issues.forEach(issue => {
|
||||||
|
console.log(issue.path) // ['field', 'name']
|
||||||
|
console.log(issue.message) // "Invalid value"
|
||||||
|
console.log(issue.code) // ZodIssueCode
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flatten errors for forms
|
||||||
|
const result = schema.safeParse(data)
|
||||||
|
if (!result.success) {
|
||||||
|
const flattened = result.error.flatten()
|
||||||
|
console.log(flattened.fieldErrors) // { name: ["error"], age: ["error"] }
|
||||||
|
console.log(flattened.formErrors) // ["general error"]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format errors for React Hook Form
|
||||||
|
const result = schema.safeParse(data)
|
||||||
|
if (!result.success) {
|
||||||
|
const errors = result.error.flatten().fieldErrors
|
||||||
|
Object.entries(errors).forEach(([field, messages]) => {
|
||||||
|
form.setError(field as any, {
|
||||||
|
type: 'validation',
|
||||||
|
message: messages?.[0] || 'Invalid value'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Patterns for Worldbuilding
|
||||||
|
|
||||||
|
### Character Schema
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const characterSchema = z.object({
|
||||||
|
name: z.string()
|
||||||
|
.min(2, "Name too short")
|
||||||
|
.max(100, "Name too long")
|
||||||
|
.regex(/^[a-zA-Z\s'-]+$/, "Invalid characters in name"),
|
||||||
|
|
||||||
|
race: z.string().min(1, "Race required"),
|
||||||
|
|
||||||
|
age: z.number()
|
||||||
|
.int("Age must be whole number")
|
||||||
|
.min(0, "Age cannot be negative")
|
||||||
|
.max(10000, "Age unrealistic"),
|
||||||
|
|
||||||
|
faction: z.string().optional(),
|
||||||
|
|
||||||
|
attributes: z.object({
|
||||||
|
strength: z.number().min(0).max(100),
|
||||||
|
intelligence: z.number().min(0).max(100),
|
||||||
|
charisma: z.number().min(0).max(100)
|
||||||
|
}).optional(),
|
||||||
|
|
||||||
|
biography: z.string()
|
||||||
|
.max(5000, "Biography too long")
|
||||||
|
.optional(),
|
||||||
|
|
||||||
|
relationships: z.array(z.object({
|
||||||
|
characterId: z.string().uuid(),
|
||||||
|
type: z.enum(['ally', 'enemy', 'neutral', 'family']),
|
||||||
|
description: z.string().optional()
|
||||||
|
})).optional()
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Location Schema
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const locationSchema = z.object({
|
||||||
|
name: z.string().min(2).max(200),
|
||||||
|
|
||||||
|
type: z.enum([
|
||||||
|
'city', 'town', 'village', 'dungeon', 'forest',
|
||||||
|
'mountain', 'ocean', 'desert', 'ruins', 'landmark'
|
||||||
|
]),
|
||||||
|
|
||||||
|
region: z.string().optional(),
|
||||||
|
|
||||||
|
coordinates: z.object({
|
||||||
|
x: z.number(),
|
||||||
|
y: z.number(),
|
||||||
|
z: z.number().optional()
|
||||||
|
}).optional(),
|
||||||
|
|
||||||
|
climate: z.enum([
|
||||||
|
'tropical', 'temperate', 'arctic', 'desert', 'varied'
|
||||||
|
]).optional(),
|
||||||
|
|
||||||
|
population: z.number()
|
||||||
|
.int()
|
||||||
|
.nonnegative()
|
||||||
|
.optional(),
|
||||||
|
|
||||||
|
government: z.string().optional(),
|
||||||
|
|
||||||
|
description: z.string().max(10000).optional(),
|
||||||
|
|
||||||
|
pointsOfInterest: z.array(z.object({
|
||||||
|
name: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
type: z.string()
|
||||||
|
})).optional()
|
||||||
|
}).refine(
|
||||||
|
(data) => {
|
||||||
|
// Cities should have population
|
||||||
|
if (data.type === 'city' && !data.population) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "Cities must have population defined",
|
||||||
|
path: ["population"]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Timeline Event Schema
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const eventSchema = z.object({
|
||||||
|
title: z.string().min(1).max(200),
|
||||||
|
|
||||||
|
date: z.union([
|
||||||
|
z.date(),
|
||||||
|
z.object({
|
||||||
|
year: z.number(),
|
||||||
|
month: z.number().min(1).max(12).optional(),
|
||||||
|
day: z.number().min(1).max(31).optional()
|
||||||
|
})
|
||||||
|
]),
|
||||||
|
|
||||||
|
endDate: z.union([
|
||||||
|
z.date(),
|
||||||
|
z.object({
|
||||||
|
year: z.number(),
|
||||||
|
month: z.number().min(1).max(12).optional(),
|
||||||
|
day: z.number().min(1).max(31).optional()
|
||||||
|
})
|
||||||
|
]).optional(),
|
||||||
|
|
||||||
|
location: z.string().optional(),
|
||||||
|
|
||||||
|
participants: z.array(z.string()).optional(),
|
||||||
|
|
||||||
|
description: z.string().max(5000).optional(),
|
||||||
|
|
||||||
|
consequences: z.string().max(5000).optional(),
|
||||||
|
|
||||||
|
relatedEvents: z.array(z.string()).optional(),
|
||||||
|
|
||||||
|
tags: z.array(z.string()).optional()
|
||||||
|
}).refine(
|
||||||
|
(data) => {
|
||||||
|
// If endDate exists, validate it's after startDate
|
||||||
|
if (data.endDate) {
|
||||||
|
// Compare dates/objects appropriately
|
||||||
|
return true // Implement comparison logic
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "End date must be after start date",
|
||||||
|
path: ["endDate"]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Item/Artifact Schema
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const itemSchema = z.object({
|
||||||
|
name: z.string().min(1).max(200),
|
||||||
|
|
||||||
|
type: z.enum([
|
||||||
|
'weapon', 'armor', 'artifact', 'consumable',
|
||||||
|
'tool', 'treasure', 'document', 'other'
|
||||||
|
]),
|
||||||
|
|
||||||
|
rarity: z.enum([
|
||||||
|
'common', 'uncommon', 'rare', 'epic', 'legendary', 'unique'
|
||||||
|
]),
|
||||||
|
|
||||||
|
owner: z.string().uuid().optional(),
|
||||||
|
|
||||||
|
location: z.string().uuid().optional(),
|
||||||
|
|
||||||
|
properties: z.record(z.string(), z.any()).optional(),
|
||||||
|
|
||||||
|
magicalEffects: z.array(z.object({
|
||||||
|
name: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
potency: z.number().min(1).max(10).optional()
|
||||||
|
})).optional(),
|
||||||
|
|
||||||
|
value: z.number().nonnegative().optional(),
|
||||||
|
|
||||||
|
weight: z.number().nonnegative().optional(),
|
||||||
|
|
||||||
|
history: z.string().max(5000).optional(),
|
||||||
|
|
||||||
|
requiresAttunement: z.boolean().default(false)
|
||||||
|
}).refine(
|
||||||
|
(data) => {
|
||||||
|
// Magical items should have magical effects
|
||||||
|
if (data.requiresAttunement && (!data.magicalEffects || data.magicalEffects.length === 0)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "Items requiring attunement must have magical effects",
|
||||||
|
path: ["magicalEffects"]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Type Inference
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Infer TypeScript type from schema
|
||||||
|
type Character = z.infer<typeof characterSchema>
|
||||||
|
|
||||||
|
// Infer input type (before transformations)
|
||||||
|
type CharacterInput = z.input<typeof characterSchema>
|
||||||
|
|
||||||
|
// Infer output type (after transformations)
|
||||||
|
type CharacterOutput = z.output<typeof characterSchema>
|
||||||
|
|
||||||
|
// Use in React Hook Form
|
||||||
|
const form = useForm<Character>({
|
||||||
|
resolver: zodResolver(characterSchema)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Schema Composition
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Base entity schema
|
||||||
|
const baseEntitySchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
name: z.string().min(1).max(200),
|
||||||
|
description: z.string().max(5000).optional(),
|
||||||
|
tags: z.array(z.string()).optional(),
|
||||||
|
createdAt: z.date(),
|
||||||
|
updatedAt: z.date()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Extend for specific entities
|
||||||
|
const characterSchema = baseEntitySchema.extend({
|
||||||
|
type: z.literal('character'),
|
||||||
|
race: z.string(),
|
||||||
|
age: z.number().optional()
|
||||||
|
})
|
||||||
|
|
||||||
|
const locationSchema = baseEntitySchema.extend({
|
||||||
|
type: z.literal('location'),
|
||||||
|
coordinates: z.object({ x: z.number(), y: z.number() }).optional()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Union of all entity types
|
||||||
|
const entitySchema = z.discriminatedUnion('type', [
|
||||||
|
characterSchema,
|
||||||
|
locationSchema
|
||||||
|
])
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user