492 lines
14 KiB
TypeScript
492 lines
14 KiB
TypeScript
|
|
import type { Meta, StoryObj } from '@storybook/nextjs'
|
|||
|
|
import { z } from 'zod'
|
|||
|
|
import withValidation from '.'
|
|||
|
|
|
|||
|
|
// Sample components to wrap with validation
|
|||
|
|
type UserCardProps = {
|
|||
|
|
name: string
|
|||
|
|
email: string
|
|||
|
|
age: number
|
|||
|
|
role?: string
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const UserCard = ({ name, email, age, role }: UserCardProps) => {
|
|||
|
|
return (
|
|||
|
|
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
|||
|
|
<h3 className="mb-2 text-lg font-semibold">{name}</h3>
|
|||
|
|
<div className="space-y-1 text-sm text-gray-600">
|
|||
|
|
<div>Email: {email}</div>
|
|||
|
|
<div>Age: {age}</div>
|
|||
|
|
{role && <div>Role: {role}</div>}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type ProductCardProps = {
|
|||
|
|
name: string
|
|||
|
|
price: number
|
|||
|
|
category: string
|
|||
|
|
inStock: boolean
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const ProductCard = ({ name, price, category, inStock }: ProductCardProps) => {
|
|||
|
|
return (
|
|||
|
|
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
|||
|
|
<h3 className="mb-2 text-lg font-semibold">{name}</h3>
|
|||
|
|
<div className="space-y-1 text-sm">
|
|||
|
|
<div className="text-xl font-bold text-green-600">${price}</div>
|
|||
|
|
<div className="text-gray-600">Category: {category}</div>
|
|||
|
|
<div className={inStock ? 'text-green-600' : 'text-red-600'}>
|
|||
|
|
{inStock ? '✓ In Stock' : '✗ Out of Stock'}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Create validated versions
|
|||
|
|
const userSchema = z.object({
|
|||
|
|
name: z.string().min(1, 'Name is required'),
|
|||
|
|
email: z.string().email('Invalid email'),
|
|||
|
|
age: z.number().min(0).max(150),
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const productSchema = z.object({
|
|||
|
|
name: z.string().min(1, 'Product name required'),
|
|||
|
|
price: z.number().positive('Price must be positive'),
|
|||
|
|
category: z.string().min(1, 'Category required'),
|
|||
|
|
inStock: z.boolean(),
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const ValidatedUserCard = withValidation(UserCard, userSchema)
|
|||
|
|
const ValidatedProductCard = withValidation(ProductCard, productSchema)
|
|||
|
|
|
|||
|
|
const meta = {
|
|||
|
|
title: 'Base/Data Entry/WithInputValidation',
|
|||
|
|
parameters: {
|
|||
|
|
layout: 'centered',
|
|||
|
|
docs: {
|
|||
|
|
description: {
|
|||
|
|
component: 'Higher-order component (HOC) for wrapping components with Zod schema validation. Validates props before rendering and returns null if validation fails, logging errors to console.',
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
tags: ['autodocs'],
|
|||
|
|
} satisfies Meta
|
|||
|
|
|
|||
|
|
export default meta
|
|||
|
|
type Story = StoryObj<typeof meta>
|
|||
|
|
|
|||
|
|
// Valid data example
|
|||
|
|
export const ValidData: Story = {
|
|||
|
|
render: () => (
|
|||
|
|
<div style={{ width: '400px' }}>
|
|||
|
|
<h3 className="mb-4 text-lg font-semibold">Valid Props (Renders Successfully)</h3>
|
|||
|
|
<ValidatedUserCard
|
|||
|
|
name="John Doe"
|
|||
|
|
email="john@example.com"
|
|||
|
|
age={30}
|
|||
|
|
role="Developer"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Invalid email
|
|||
|
|
export const InvalidEmail: Story = {
|
|||
|
|
render: () => (
|
|||
|
|
<div style={{ width: '400px' }}>
|
|||
|
|
<h3 className="mb-4 text-lg font-semibold">Invalid Email (Returns null)</h3>
|
|||
|
|
<p className="mb-4 text-sm text-gray-600">
|
|||
|
|
Check console for validation error. Component won't render.
|
|||
|
|
</p>
|
|||
|
|
<ValidatedUserCard
|
|||
|
|
name="John Doe"
|
|||
|
|
email="invalid-email"
|
|||
|
|
age={30}
|
|||
|
|
role="Developer"
|
|||
|
|
/>
|
|||
|
|
<div className="mt-4 rounded-lg bg-red-50 p-3 text-sm text-red-800">
|
|||
|
|
⚠️ Validation failed: Invalid email format
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Invalid age
|
|||
|
|
export const InvalidAge: Story = {
|
|||
|
|
render: () => (
|
|||
|
|
<div style={{ width: '400px' }}>
|
|||
|
|
<h3 className="mb-4 text-lg font-semibold">Invalid Age (Returns null)</h3>
|
|||
|
|
<p className="mb-4 text-sm text-gray-600">
|
|||
|
|
Age must be between 0 and 150. Check console.
|
|||
|
|
</p>
|
|||
|
|
<ValidatedUserCard
|
|||
|
|
name="John Doe"
|
|||
|
|
email="john@example.com"
|
|||
|
|
age={200}
|
|||
|
|
role="Developer"
|
|||
|
|
/>
|
|||
|
|
<div className="mt-4 rounded-lg bg-red-50 p-3 text-sm text-red-800">
|
|||
|
|
⚠️ Validation failed: Age must be ≤ 150
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Product validation - valid
|
|||
|
|
export const ValidProduct: Story = {
|
|||
|
|
render: () => (
|
|||
|
|
<div style={{ width: '400px' }}>
|
|||
|
|
<h3 className="mb-4 text-lg font-semibold">Valid Product</h3>
|
|||
|
|
<ValidatedProductCard
|
|||
|
|
name="Laptop Pro"
|
|||
|
|
price={1299}
|
|||
|
|
category="Electronics"
|
|||
|
|
inStock={true}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Product validation - invalid price
|
|||
|
|
export const InvalidPrice: Story = {
|
|||
|
|
render: () => (
|
|||
|
|
<div style={{ width: '400px' }}>
|
|||
|
|
<h3 className="mb-4 text-lg font-semibold">Invalid Price (Returns null)</h3>
|
|||
|
|
<p className="mb-4 text-sm text-gray-600">
|
|||
|
|
Price must be positive. Check console.
|
|||
|
|
</p>
|
|||
|
|
<ValidatedProductCard
|
|||
|
|
name="Laptop Pro"
|
|||
|
|
price={-100}
|
|||
|
|
category="Electronics"
|
|||
|
|
inStock={true}
|
|||
|
|
/>
|
|||
|
|
<div className="mt-4 rounded-lg bg-red-50 p-3 text-sm text-red-800">
|
|||
|
|
⚠️ Validation failed: Price must be positive
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Comparison: validated vs unvalidated
|
|||
|
|
export const ValidationComparison: Story = {
|
|||
|
|
render: () => (
|
|||
|
|
<div style={{ width: '700px' }} className="space-y-6">
|
|||
|
|
<div>
|
|||
|
|
<h3 className="mb-4 text-lg font-semibold">Without Validation</h3>
|
|||
|
|
<div className="space-y-3">
|
|||
|
|
<UserCard
|
|||
|
|
name="John Doe"
|
|||
|
|
email="invalid-email"
|
|||
|
|
age={200}
|
|||
|
|
role="Developer"
|
|||
|
|
/>
|
|||
|
|
<div className="text-xs text-gray-500">
|
|||
|
|
⚠️ Renders with invalid data (no validation)
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="border-t border-gray-200 pt-6">
|
|||
|
|
<h3 className="mb-4 text-lg font-semibold">With Validation (HOC)</h3>
|
|||
|
|
<div className="space-y-3">
|
|||
|
|
<ValidatedUserCard
|
|||
|
|
name="John Doe"
|
|||
|
|
email="invalid-email"
|
|||
|
|
age={200}
|
|||
|
|
role="Developer"
|
|||
|
|
/>
|
|||
|
|
<div className="text-xs text-gray-500">
|
|||
|
|
✓ Returns null when validation fails (check console)
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Real-world example - Form submission
|
|||
|
|
export const FormSubmission: Story = {
|
|||
|
|
render: () => {
|
|||
|
|
const handleSubmit = (data: UserCardProps) => {
|
|||
|
|
console.log('Submitting:', data)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const validData: UserCardProps = {
|
|||
|
|
name: 'Jane Smith',
|
|||
|
|
email: 'jane@example.com',
|
|||
|
|
age: 28,
|
|||
|
|
role: 'Designer',
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const invalidData: UserCardProps = {
|
|||
|
|
name: '',
|
|||
|
|
email: 'not-an-email',
|
|||
|
|
age: -5,
|
|||
|
|
role: 'Designer',
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
|||
|
|
<h3 className="mb-4 text-lg font-semibold">Form Submission with Validation</h3>
|
|||
|
|
|
|||
|
|
<div className="space-y-6">
|
|||
|
|
<div>
|
|||
|
|
<h4 className="mb-2 text-sm font-medium text-gray-700">Valid Data</h4>
|
|||
|
|
<ValidatedUserCard {...validData} />
|
|||
|
|
<button
|
|||
|
|
className="mt-3 w-full rounded-lg bg-green-600 px-4 py-2 text-white hover:bg-green-700"
|
|||
|
|
onClick={() => handleSubmit(validData)}
|
|||
|
|
>
|
|||
|
|
Submit Valid Data
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="border-t border-gray-200 pt-6">
|
|||
|
|
<h4 className="mb-2 text-sm font-medium text-gray-700">Invalid Data</h4>
|
|||
|
|
<ValidatedUserCard {...invalidData} />
|
|||
|
|
<button
|
|||
|
|
className="mt-3 w-full rounded-lg bg-red-600 px-4 py-2 text-white hover:bg-red-700"
|
|||
|
|
onClick={() => handleSubmit(invalidData)}
|
|||
|
|
>
|
|||
|
|
Try to Submit Invalid Data
|
|||
|
|
</button>
|
|||
|
|
<div className="mt-2 text-xs text-red-600">
|
|||
|
|
Component returns null, preventing invalid data rendering
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Real-world example - API response validation
|
|||
|
|
export const APIResponseValidation: Story = {
|
|||
|
|
render: () => {
|
|||
|
|
const mockAPIResponses = [
|
|||
|
|
{
|
|||
|
|
name: 'Laptop',
|
|||
|
|
price: 999,
|
|||
|
|
category: 'Electronics',
|
|||
|
|
inStock: true,
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
name: 'Invalid Product',
|
|||
|
|
price: -50, // Invalid: negative price
|
|||
|
|
category: 'Electronics',
|
|||
|
|
inStock: true,
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
name: '', // Invalid: empty name
|
|||
|
|
price: 100,
|
|||
|
|
category: 'Electronics',
|
|||
|
|
inStock: false,
|
|||
|
|
},
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div style={{ width: '700px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
|||
|
|
<h3 className="mb-4 text-lg font-semibold">API Response Validation</h3>
|
|||
|
|
<p className="mb-4 text-sm text-gray-600">
|
|||
|
|
Only valid products render. Invalid ones return null (check console).
|
|||
|
|
</p>
|
|||
|
|
|
|||
|
|
<div className="grid grid-cols-2 gap-4">
|
|||
|
|
{mockAPIResponses.map((product, index) => (
|
|||
|
|
<div key={index}>
|
|||
|
|
<ValidatedProductCard {...product} />
|
|||
|
|
{!product.name || product.price <= 0 ? (
|
|||
|
|
<div className="mt-2 text-xs text-red-600">
|
|||
|
|
⚠️ Validation failed for product {index + 1}
|
|||
|
|
</div>
|
|||
|
|
) : null}
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Real-world example - Configuration validation
|
|||
|
|
export const ConfigurationValidation: Story = {
|
|||
|
|
render: () => {
|
|||
|
|
type ConfigPanelProps = {
|
|||
|
|
apiUrl: string
|
|||
|
|
timeout: number
|
|||
|
|
retries: number
|
|||
|
|
debug: boolean
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const ConfigPanel = ({ apiUrl, timeout, retries, debug }: ConfigPanelProps) => (
|
|||
|
|
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
|||
|
|
<h3 className="mb-3 text-base font-semibold">Configuration</h3>
|
|||
|
|
<div className="space-y-2 text-sm">
|
|||
|
|
<div className="flex justify-between">
|
|||
|
|
<span className="text-gray-600">API URL:</span>
|
|||
|
|
<span className="font-mono">{apiUrl}</span>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex justify-between">
|
|||
|
|
<span className="text-gray-600">Timeout:</span>
|
|||
|
|
<span>{timeout}ms</span>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex justify-between">
|
|||
|
|
<span className="text-gray-600">Retries:</span>
|
|||
|
|
<span>{retries}</span>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex justify-between">
|
|||
|
|
<span className="text-gray-600">Debug Mode:</span>
|
|||
|
|
<span>{debug ? '✓ Enabled' : '✗ Disabled'}</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
const configSchema = z.object({
|
|||
|
|
apiUrl: z.string().url('Must be valid URL'),
|
|||
|
|
timeout: z.number().min(0).max(30000),
|
|||
|
|
retries: z.number().min(0).max(5),
|
|||
|
|
debug: z.boolean(),
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const ValidatedConfigPanel = withValidation(ConfigPanel, configSchema)
|
|||
|
|
|
|||
|
|
const validConfig = {
|
|||
|
|
apiUrl: 'https://api.example.com',
|
|||
|
|
timeout: 5000,
|
|||
|
|
retries: 3,
|
|||
|
|
debug: true,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const invalidConfig = {
|
|||
|
|
apiUrl: 'not-a-url',
|
|||
|
|
timeout: 50000, // Too high
|
|||
|
|
retries: 10, // Too many
|
|||
|
|
debug: true,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div style={{ width: '600px' }} className="space-y-6">
|
|||
|
|
<div>
|
|||
|
|
<h4 className="mb-2 text-sm font-medium text-gray-700">Valid Configuration</h4>
|
|||
|
|
<ValidatedConfigPanel {...validConfig} />
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div>
|
|||
|
|
<h4 className="mb-2 text-sm font-medium text-gray-700">Invalid Configuration</h4>
|
|||
|
|
<ValidatedConfigPanel {...invalidConfig} />
|
|||
|
|
<div className="mt-2 text-xs text-red-600">
|
|||
|
|
⚠️ Validation errors: Invalid URL, timeout too high, too many retries
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Usage documentation
|
|||
|
|
export const UsageDocumentation: Story = {
|
|||
|
|
render: () => (
|
|||
|
|
<div style={{ width: '700px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
|||
|
|
<h3 className="mb-4 text-xl font-bold">withValidation HOC</h3>
|
|||
|
|
|
|||
|
|
<div className="space-y-6">
|
|||
|
|
<div>
|
|||
|
|
<h4 className="mb-2 text-sm font-semibold text-gray-900">Purpose</h4>
|
|||
|
|
<p className="text-sm text-gray-600">
|
|||
|
|
Wraps React components with Zod schema validation for their props.
|
|||
|
|
Returns null and logs errors if validation fails.
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div>
|
|||
|
|
<h4 className="mb-2 text-sm font-semibold text-gray-900">Usage Example</h4>
|
|||
|
|
<pre className="overflow-x-auto rounded-lg bg-gray-900 p-4 text-xs text-gray-100">
|
|||
|
|
{`import { z } from 'zod'
|
|||
|
|
import withValidation from './withValidation'
|
|||
|
|
|
|||
|
|
// Define your component
|
|||
|
|
const UserCard = ({ name, email, age }) => (
|
|||
|
|
<div>{name} - {email} - {age}</div>
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// Define validation schema
|
|||
|
|
const schema = z.object({
|
|||
|
|
name: z.string().min(1),
|
|||
|
|
email: z.string().email(),
|
|||
|
|
age: z.number().min(0).max(150),
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// Wrap with validation
|
|||
|
|
const ValidatedUserCard = withValidation(UserCard, schema)
|
|||
|
|
|
|||
|
|
// Use validated component
|
|||
|
|
<ValidatedUserCard
|
|||
|
|
name="John"
|
|||
|
|
email="john@example.com"
|
|||
|
|
age={30}
|
|||
|
|
/>`}
|
|||
|
|
</pre>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div>
|
|||
|
|
<h4 className="mb-2 text-sm font-semibold text-gray-900">Key Features</h4>
|
|||
|
|
<ul className="list-inside list-disc space-y-1 text-sm text-gray-600">
|
|||
|
|
<li>Type-safe validation using Zod schemas</li>
|
|||
|
|
<li>Returns null on validation failure</li>
|
|||
|
|
<li>Logs validation errors to console</li>
|
|||
|
|
<li>Only validates props defined in schema</li>
|
|||
|
|
<li>Preserves all original props</li>
|
|||
|
|
</ul>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div>
|
|||
|
|
<h4 className="mb-2 text-sm font-semibold text-gray-900">Use Cases</h4>
|
|||
|
|
<ul className="list-inside list-disc space-y-1 text-sm text-gray-600">
|
|||
|
|
<li>API response validation before rendering</li>
|
|||
|
|
<li>Form data validation</li>
|
|||
|
|
<li>Configuration panel validation</li>
|
|||
|
|
<li>Preventing invalid data from reaching components</li>
|
|||
|
|
</ul>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Interactive playground
|
|||
|
|
export const Playground: Story = {
|
|||
|
|
render: () => {
|
|||
|
|
return (
|
|||
|
|
<div style={{ width: '600px' }} className="space-y-6">
|
|||
|
|
<div>
|
|||
|
|
<h4 className="mb-2 text-sm font-medium text-gray-700">Try Valid Data</h4>
|
|||
|
|
<ValidatedUserCard
|
|||
|
|
name="Alice Johnson"
|
|||
|
|
email="alice@example.com"
|
|||
|
|
age={25}
|
|||
|
|
role="Engineer"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div>
|
|||
|
|
<h4 className="mb-2 text-sm font-medium text-gray-700">Try Invalid Data</h4>
|
|||
|
|
<ValidatedUserCard
|
|||
|
|
name="Bob"
|
|||
|
|
email="invalid-email"
|
|||
|
|
age={-10}
|
|||
|
|
role="Manager"
|
|||
|
|
/>
|
|||
|
|
<p className="mt-2 text-xs text-gray-500">
|
|||
|
|
Open browser console to see validation errors
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
},
|
|||
|
|
}
|