dify
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import { z } from 'zod'
|
||||
import withValidation from '.'
|
||||
import { noop } from 'lodash-es'
|
||||
|
||||
describe('withValidation HOC', () => {
|
||||
// schema for validation
|
||||
const schema = z.object({ name: z.string() })
|
||||
type Props = z.infer<typeof schema> & {
|
||||
age: number
|
||||
}
|
||||
|
||||
const TestComponent = ({ name, age }: Props) => (
|
||||
<div>{name} - {age}</div>
|
||||
)
|
||||
const WrappedComponent = withValidation(TestComponent, schema)
|
||||
|
||||
beforeAll(() => {
|
||||
jest.spyOn(console, 'error').mockImplementation(noop)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('renders the component when validation passes', () => {
|
||||
render(<WrappedComponent name='Valid Name' age={30} />)
|
||||
expect(screen.getByText('Valid Name - 30')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the component when props is invalid but not in schema ', () => {
|
||||
render(<WrappedComponent name='Valid Name' age={'aaa' as any} />)
|
||||
expect(screen.getByText('Valid Name - aaa')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render the component when validation fails', () => {
|
||||
render(<WrappedComponent name={123 as any} age={30} />)
|
||||
expect(screen.queryByText('123 - 30')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,491 @@
|
||||
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>
|
||||
)
|
||||
},
|
||||
}
|
||||
24
dify/web/app/components/base/with-input-validation/index.tsx
Normal file
24
dify/web/app/components/base/with-input-validation/index.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
'use client'
|
||||
import React from 'react'
|
||||
import type { ZodSchema } from 'zod'
|
||||
|
||||
function withValidation<T extends Record<string, unknown>, K extends keyof T>(
|
||||
WrappedComponent: React.ComponentType<T>,
|
||||
schema: ZodSchema<Pick<T, K>>,
|
||||
) {
|
||||
return function EnsuredComponent(props: T) {
|
||||
const partialProps = Object.fromEntries(
|
||||
Object.entries(props).filter(([key]) => key in (schema._def as any).shape),
|
||||
) as Pick<T, K>
|
||||
|
||||
const checkRes = schema.safeParse(partialProps)
|
||||
if (!checkRes.success) {
|
||||
console.error(checkRes.error)
|
||||
// Maybe there is a better way to handle this, like error logic placeholder
|
||||
return null
|
||||
}
|
||||
return <WrappedComponent {...props} />
|
||||
}
|
||||
}
|
||||
|
||||
export default withValidation
|
||||
Reference in New Issue
Block a user