Main: revise specs to 1.5.0 (completed)
This commit is contained in:
497
specs/05-decisions/ADR-013-form-handling-validation.md
Normal file
497
specs/05-decisions/ADR-013-form-handling-validation.md
Normal file
@@ -0,0 +1,497 @@
|
||||
# ADR-013: Form Handling & Validation Strategy
|
||||
|
||||
**Status:** ✅ Accepted
|
||||
**Date:** 2025-12-01
|
||||
**Decision Makers:** Frontend Team
|
||||
**Related Documents:** [Frontend Guidelines](../03-implementation/frontend-guidelines.md)
|
||||
|
||||
---
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
ระบบ LCBP3-DMS มี Forms จำนวนมาก (Create/Edit Correspondence, RFA, Drawings) ต้องการวิธีจัดการ Forms ที่มี Performance ดี Validation ชัดเจน และ Developer Experience สูง
|
||||
|
||||
### ปัญหาที่ต้องแก้:
|
||||
|
||||
1. **Form State Management:** จัดการ Form state อย่างไร
|
||||
2. **Validation:** Validate client-side และ server-side อย่างไร
|
||||
3. **Error Handling:** แสดง Error messages อย่างไร
|
||||
4. **Performance:** Forms ขนาดใหญ่ไม่ช้า
|
||||
5. **Type Safety:** Type-safe forms with TypeScript
|
||||
|
||||
---
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- ✅ **Type Safety:** TypeScript support เต็มรูปแบบ
|
||||
- ⚡ **Performance:** Re-render minimal
|
||||
- 🎯 **DX:** Developer Experience ดี
|
||||
- 📝 **Validation:** Schema-based validation
|
||||
- 🔄 **Reusability:** Reuse validation schema
|
||||
- 🎨 **Flexibility:** ปรับแต่งได้ง่าย
|
||||
|
||||
---
|
||||
|
||||
## Considered Options
|
||||
|
||||
### Option 1: Formik
|
||||
|
||||
**Pros:**
|
||||
|
||||
- ✅ Popular และ Mature
|
||||
- ✅ Documentation ดี
|
||||
- ✅ Yup validation
|
||||
|
||||
**Cons:**
|
||||
|
||||
- ❌ Performance issues (re-renders)
|
||||
- ❌ Bundle size ใหญ่
|
||||
- ❌ TypeScript support ไม่ดีมาก
|
||||
- ❌ Not actively maintained
|
||||
|
||||
### Option 2: Plain React State
|
||||
|
||||
```typescript
|
||||
const [formData, setFormData] = useState({});
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
|
||||
- ✅ Simple
|
||||
- ✅ No dependencies
|
||||
|
||||
**Cons:**
|
||||
|
||||
- ❌ Boilerplate code มาก
|
||||
- ❌ ต้องจัดการ Validation เอง
|
||||
- ❌ Error handling ซับซ้อน
|
||||
- ❌ Performance issues
|
||||
|
||||
### Option 3: React Hook Form + Zod
|
||||
|
||||
**Pros:**
|
||||
|
||||
- ✅ **Performance:** Uncontrolled components (minimal re-renders)
|
||||
- ✅ **TypeScript First:** Full type safety
|
||||
- ✅ **Small Bundle:** ~8.5kb
|
||||
- ✅ **Schema Validation:** Zod integration
|
||||
- ✅ **DX:** Clean API
|
||||
- ✅ **Actively Maintained**
|
||||
|
||||
**Cons:**
|
||||
|
||||
- ❌ Learning curve (uncontrolled approach)
|
||||
- ❌ Complex forms ต้องใช้ Controller
|
||||
|
||||
---
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
**Chosen Option:** **Option 3 - React Hook Form + Zod**
|
||||
|
||||
### Rationale
|
||||
|
||||
1. **Performance:** Uncontrolled components = minimal re-renders
|
||||
2. **Type Safety:** Zod schemas → TypeScript types → Runtime validation
|
||||
3. **Bundle Size:** เล็กมาก (8.5kb)
|
||||
4. **Developer Experience:** API สะอาด ใช้งานง่าย
|
||||
5. **Validation Reuse:** Validation schema ใช้ร่วมกับ Backend ได้
|
||||
|
||||
---
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### 1. Install Dependencies
|
||||
|
||||
```bash
|
||||
npm install react-hook-form zod @hookform/resolvers
|
||||
```
|
||||
|
||||
### 2. Define Zod Schema
|
||||
|
||||
```typescript
|
||||
// File: lib/validations/correspondence.ts
|
||||
import { z } from 'zod';
|
||||
|
||||
export const correspondenceSchema = z.object({
|
||||
subject: z
|
||||
.string()
|
||||
.min(5, 'Subject must be at least 5 characters')
|
||||
.max(255, 'Subject must not exceed 255 characters'),
|
||||
|
||||
description: z
|
||||
.string()
|
||||
.min(10, 'Description must be at least 10 characters')
|
||||
.optional(),
|
||||
|
||||
document_type_id: z.number({
|
||||
required_error: 'Document type is required',
|
||||
}),
|
||||
|
||||
from_organization_id: z.number({
|
||||
required_error: 'From organization is required',
|
||||
}),
|
||||
|
||||
to_organization_id: z.number({
|
||||
required_error: 'To organization is required',
|
||||
}),
|
||||
|
||||
importance: z.enum(['NORMAL', 'HIGH', 'URGENT']).default('NORMAL'),
|
||||
|
||||
attachments: z.array(z.instanceof(File)).optional(),
|
||||
});
|
||||
|
||||
// Export TypeScript type
|
||||
export type CorrespondenceFormData = z.infer<typeof correspondenceSchema>;
|
||||
```
|
||||
|
||||
### 3. Create Form Component
|
||||
|
||||
```typescript
|
||||
// File: components/correspondences/create-form.tsx
|
||||
'use client';
|
||||
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import {
|
||||
correspondenceSchema,
|
||||
type CorrespondenceFormData,
|
||||
} from '@/lib/validations/correspondence';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
export function CreateCorrespondenceForm() {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
setValue,
|
||||
} = useForm<CorrespondenceFormData>({
|
||||
resolver: zodResolver(correspondenceSchema),
|
||||
defaultValues: {
|
||||
importance: 'NORMAL',
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: CorrespondenceFormData) => {
|
||||
try {
|
||||
const response = await fetch('/api/correspondences', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to create');
|
||||
|
||||
// Success - redirect
|
||||
window.location.href = '/correspondences';
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
{/* Subject */}
|
||||
<div>
|
||||
<Label htmlFor="subject">Subject *</Label>
|
||||
<Input
|
||||
id="subject"
|
||||
{...register('subject')}
|
||||
placeholder="Enter subject"
|
||||
/>
|
||||
{errors.subject && (
|
||||
<p className="text-sm text-red-600 mt-1">{errors.subject.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
{...register('description')}
|
||||
placeholder="Enter description"
|
||||
rows={4}
|
||||
/>
|
||||
{errors.description && (
|
||||
<p className="text-sm text-red-600 mt-1">
|
||||
{errors.description.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Document Type (Select) */}
|
||||
<div>
|
||||
<Label>Document Type *</Label>
|
||||
<Select
|
||||
onValueChange={(value) =>
|
||||
setValue('document_type_id', parseInt(value))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">Internal Letter</SelectItem>
|
||||
<SelectItem value="2">External Letter</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.document_type_id && (
|
||||
<p className="text-sm text-red-600 mt-1">
|
||||
{errors.document_type_id.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Importance (Radio) */}
|
||||
<div>
|
||||
<Label>Importance</Label>
|
||||
<div className="flex gap-4 mt-2">
|
||||
<label className="flex items-center">
|
||||
<input type="radio" value="NORMAL" {...register('importance')} />
|
||||
<span className="ml-2">Normal</span>
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input type="radio" value="HIGH" {...register('importance')} />
|
||||
<span className="ml-2">High</span>
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input type="radio" value="URGENT" {...register('importance')} />
|
||||
<span className="ml-2">Urgent</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Creating...' : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Reusable Form Field Component
|
||||
|
||||
```typescript
|
||||
// File: components/ui/form-field.tsx
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { UseFormRegister, FieldError } from 'react-hook-form';
|
||||
|
||||
interface FormFieldProps {
|
||||
label: string;
|
||||
name: string;
|
||||
type?: string;
|
||||
register: UseFormRegister<any>;
|
||||
error?: FieldError;
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function FormField({
|
||||
label,
|
||||
name,
|
||||
type = 'text',
|
||||
register,
|
||||
error,
|
||||
required = false,
|
||||
placeholder,
|
||||
}: FormFieldProps) {
|
||||
return (
|
||||
<div>
|
||||
<Label htmlFor={name}>
|
||||
{label} {required && <span className="text-red-600">*</span>}
|
||||
</Label>
|
||||
<Input
|
||||
id={name}
|
||||
type={type}
|
||||
{...register(name)}
|
||||
placeholder={placeholder}
|
||||
className={error ? 'border-red-600' : ''}
|
||||
/>
|
||||
{error && <p className="text-sm text-red-600 mt-1">{error.message}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 5. File Upload Handling
|
||||
|
||||
```typescript
|
||||
// File: components/correspondences/file-upload.tsx
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { UseFormSetValue } from 'react-hook-form';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface FileUploadProps {
|
||||
setValue: UseFormSetValue<any>;
|
||||
fieldName: string;
|
||||
}
|
||||
|
||||
export function FileUpload({ setValue, fieldName }: FileUploadProps) {
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFiles = Array.from(e.target.files || []);
|
||||
setFiles(selectedFiles);
|
||||
setValue(fieldName, selectedFiles);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
id="file-upload"
|
||||
/>
|
||||
<label htmlFor="file-upload">
|
||||
<Button type="button" variant="outline" asChild>
|
||||
<span>Choose Files</span>
|
||||
</Button>
|
||||
</label>
|
||||
|
||||
{files.length > 0 && (
|
||||
<div className="mt-2 text-sm text-gray-600">
|
||||
{files.map((file, i) => (
|
||||
<div key={i}>{file.name}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Server-Side Validation
|
||||
|
||||
```typescript
|
||||
// File: app/api/correspondences/route.ts
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { correspondenceSchema } from '@/lib/validations/correspondence';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
// Validate with same Zod schema
|
||||
const validated = correspondenceSchema.parse(body);
|
||||
|
||||
// Create correspondence
|
||||
// ...
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', issues: error.errors },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Form Patterns
|
||||
|
||||
### Dynamic Fields
|
||||
|
||||
```typescript
|
||||
import { useFieldArray } from 'react-hook-form';
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: 'items', // RFA items
|
||||
});
|
||||
|
||||
// Add item
|
||||
append({ description: '', quantity: 0 });
|
||||
|
||||
// Remove item
|
||||
remove(index);
|
||||
```
|
||||
|
||||
### Controlled Components
|
||||
|
||||
```typescript
|
||||
import { Controller } from 'react-hook-form';
|
||||
|
||||
<Controller
|
||||
name="discipline_id"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
{/* Options */}
|
||||
</Select>
|
||||
)}
|
||||
/>;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive Consequences
|
||||
|
||||
1. ✅ **Performance:** Minimal re-renders (uncontrolled)
|
||||
2. ✅ **Type Safety:** Full TypeScript support
|
||||
3. ✅ **Validation Reuse:** Same schema for client & server
|
||||
4. ✅ **Small Bundle:** ~8.5kb only
|
||||
5. ✅ **Clean Code:** Less boilerplate
|
||||
6. ✅ **Error Handling:** Built-in error states
|
||||
|
||||
### Negative Consequences
|
||||
|
||||
1. ❌ **Learning Curve:** Uncontrolled approach ต่างจาก Formik
|
||||
2. ❌ **Complex Forms:** ต้องใช้ Controller บางครั้ง
|
||||
|
||||
### Mitigation Strategies
|
||||
|
||||
- **Documentation:** เขียน Form patterns และ Examples
|
||||
- **Reusable Components:** สร้าง FormField wrapper
|
||||
- **Code Review:** Review forms ให้ใช้ best practices
|
||||
|
||||
---
|
||||
|
||||
## Related ADRs
|
||||
|
||||
- [ADR-007: API Design & Error Handling](./ADR-007-api-design-error-handling.md)
|
||||
- [ADR-012: UI Component Library](./ADR-012-ui-component-library.md)
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [React Hook Form Documentation](https://react-hook-form.com/)
|
||||
- [Zod Documentation](https://zod.dev/)
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2025-12-01
|
||||
**Next Review:** 2026-06-01
|
||||
Reference in New Issue
Block a user