Assist_Design/docs/validation/FRONTEND_VALIDATION_PATTERNS.md

459 lines
14 KiB
Markdown

# Frontend Form Validation Patterns
## Overview
This guide documents the correct patterns for implementing forms in the customer portal frontend. Following these patterns ensures validation logic is not duplicated between the frontend and domain layers, maintaining a **single source of truth** for all validation rules.
## Core Principle
**Domain schemas are the single source of truth for validation rules.**
Frontend forms should:
1. Import and use domain validation schemas as the base
2. Only extend schemas for frontend-specific concerns (e.g., `confirmPassword`)
3. Never duplicate validation logic that exists in the domain
## Architecture
```
┌──────────────────────────────────────────────────────────┐
│ Domain Layer (packages/domain/*) │
│ │
│ ✓ Define validation schemas (Zod) │
│ ✓ Define atomic field schemas (emailSchema, etc.) │
│ ✓ Define business validation functions │
│ ✓ Define data transformations (API format conversions) │
└──────────────────────────────────────────────────────────┘
↓ imports
┌──────────────────────────────────────────────────────────┐
│ Frontend Layer (apps/portal/*) │
│ │
│ ✓ Import domain schemas │
│ ✓ Extend with UI-only fields (confirmPassword, etc.) │
│ ✓ Use useZodForm hook for form state management │
│ ✗ Do NOT duplicate validation rules │
└──────────────────────────────────────────────────────────┘
```
## Pattern: Domain Schema + Frontend Extension
### Step 1: Domain Layer Provides Base Schema
The domain layer should provide a base input schema that can be extended by frontends:
```typescript
// packages/domain/auth/schema.ts
/**
* Base signup input schema (before API transformation)
* Used by frontend forms - can be extended with UI-specific fields
*/
export const signupInputSchema = z.object({
email: emailSchema,
password: passwordSchema,
firstName: nameSchema,
lastName: nameSchema,
// ... other fields
acceptTerms: z.boolean(),
});
/**
* Signup request schema with API transformation
* Transforms camelCase fields to match API expectations
*/
export const signupRequestSchema = signupInputSchema.transform(data => ({
...data,
firstname: data.firstName, // API format
lastname: data.lastName,
// ...
}));
```
### Step 2: Frontend Extends for UI Concerns
The frontend form extends the domain schema only for UI-specific fields:
```typescript
// apps/portal/src/features/auth/components/SignupForm/SignupForm.tsx
import { signupInputSchema } from "@customer-portal/domain/auth";
/**
* Frontend form schema - extends domain signupInputSchema with UI-specific fields
*
* Single source of truth: Domain layer (signupInputSchema) defines all validation rules
* Frontend only adds: confirmPassword field and password matching logic
*/
export const signupFormSchema = signupInputSchema
.extend({
confirmPassword: z.string().min(1, "Please confirm your password"),
})
.refine((data) => data.acceptTerms === true, {
message: "You must accept the terms and conditions",
path: ["acceptTerms"],
})
.superRefine((data, ctx) => {
if (data.password !== data.confirmPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["confirmPassword"],
message: "Passwords do not match",
});
}
});
```
### Step 3: Use Schema in Form
```typescript
const { values, errors, handleSubmit } = useZodForm<SignupFormValues>({
schema: signupFormSchema,
initialValues: {
email: "",
password: "",
confirmPassword: "", // UI-only field
// ... other fields
},
onSubmit: handleSignup,
});
```
## Common Use Cases
### Use Case 1: Simple Form (No Extension Needed)
When the form exactly matches the domain schema with no extra fields:
```typescript
// Domain schema is sufficient
import { linkWhmcsRequestSchema } from "@customer-portal/domain/auth";
const form = useZodForm({
schema: linkWhmcsRequestSchema, // Use directly
initialValues: { email: "", password: "" },
onSubmit: handleSubmit,
});
```
### Use Case 2: Form with Confirmation Field
When you need password confirmation or similar UI-only fields:
```typescript
import { setPasswordRequestSchema } from "@customer-portal/domain/auth";
const formSchema = setPasswordRequestSchema
.extend({
confirmPassword: z.string().min(1, "Please confirm your password"),
})
.superRefine((data, ctx) => {
if (data.password !== data.confirmPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["confirmPassword"],
message: "Passwords do not match",
});
}
});
```
### Use Case 3: Form with UI-Only Fields
When you need UI state that doesn't go to the API:
```typescript
import { loginRequestSchema } from "@customer-portal/domain/auth";
// Add rememberMe checkbox (UI state only, not sent to API)
const loginFormSchema = loginRequestSchema.extend({
rememberMe: z.boolean().optional(),
});
// In submit handler, remove UI-only fields:
const handleLogin = async ({ rememberMe, ...credentials }) => {
// Use rememberMe for UI logic (e.g., localStorage)
// Send only credentials to API
await login(credentials);
};
```
## Complete Examples
### Example 1: SignupForm (Complex Multi-Step Form)
```typescript
import { signupInputSchema, type SignupRequest } from "@customer-portal/domain/auth";
import { useZodForm } from "@customer-portal/validation";
// Extend domain schema with UI field
export const signupFormSchema = signupInputSchema
.extend({
confirmPassword: z.string().min(1, "Please confirm your password"),
})
.superRefine((data, ctx) => {
if (data.password !== data.confirmPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["confirmPassword"],
message: "Passwords do not match",
});
}
});
export function SignupForm() {
const { signup } = useSignup();
const handleSignup = async ({ confirmPassword, ...formData }) => {
// Remove confirmPassword, create API request
const request: SignupRequest = {
...formData,
// Add API-specific fields if needed
firstname: formData.firstName,
lastname: formData.lastName,
};
await signup(request);
};
const form = useZodForm({
schema: signupFormSchema,
initialValues: { /* ... */ },
onSubmit: handleSignup,
});
return (
<form onSubmit={form.handleSubmit}>
{/* Form fields */}
</form>
);
}
```
### Example 2: LoginForm (Simple Form with UI State)
```typescript
import { loginRequestSchema } from "@customer-portal/domain/auth";
import { useZodForm } from "@customer-portal/validation";
const loginFormSchema = loginRequestSchema.extend({
rememberMe: z.boolean().optional(),
});
export function LoginForm() {
const { login } = useLogin();
const handleLogin = async ({ rememberMe, ...formData }) => {
// formData already matches LoginRequest (email, password)
await login(formData);
// Handle rememberMe separately if needed
};
const form = useZodForm({
schema: loginFormSchema,
initialValues: {
email: "",
password: "",
rememberMe: false,
},
onSubmit: handleLogin,
});
return (
<form onSubmit={form.handleSubmit}>
{/* Form fields */}
</form>
);
}
```
### Example 3: PasswordResetForm (Two Modes, One Schema)
```typescript
import {
passwordResetRequestSchema,
passwordResetSchema
} from "@customer-portal/domain/auth";
import { useZodForm } from "@customer-portal/validation";
export function PasswordResetForm({ mode }: { mode: "request" | "reset" }) {
// Request mode: uses domain schema directly
const requestForm = useZodForm({
schema: passwordResetRequestSchema,
initialValues: { email: "" },
onSubmit: async (data) => {
await requestPasswordReset(data.email);
},
});
// Reset mode: extends domain schema with confirmPassword
const resetFormSchema = passwordResetSchema
.extend({
confirmPassword: z.string().min(1, "Please confirm your new password"),
})
.superRefine((data, ctx) => {
if (data.password !== data.confirmPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["confirmPassword"],
message: "Passwords do not match",
});
}
});
const resetForm = useZodForm({
schema: resetFormSchema,
initialValues: { token: "", password: "", confirmPassword: "" },
onSubmit: async ({ confirmPassword, ...data }) => {
await resetPassword(data.token, data.password);
},
});
return mode === "request" ? (
<form onSubmit={requestForm.handleSubmit}>
{/* Request form */}
</form>
) : (
<form onSubmit={resetForm.handleSubmit}>
{/* Reset form */}
</form>
);
}
```
## Anti-Patterns
### ❌ DON'T: Duplicate Validation in Frontend
```typescript
// BAD - duplicates domain validation
const signupSchema = z.object({
email: z.string().email(), // Duplicates emailSchema from domain
password: z.string().min(8), // Duplicates passwordSchema from domain
firstName: z.string().min(2), // Duplicates nameSchema from domain
lastName: z.string().min(2), // Duplicates nameSchema from domain
// ... all fields duplicated
});
```
### ✅ DO: Extend Domain Schema
```typescript
// GOOD - uses domain schema as base
import { signupInputSchema } from "@customer-portal/domain/auth";
const signupFormSchema = signupInputSchema.extend({
confirmPassword: z.string().min(1), // Only add UI-specific field
});
```
### ❌ DON'T: Manual Data Transformation
```typescript
// BAD - manually transforms data (duplicates domain logic)
const handleSubmit = async (formData) => {
const request = {
...formData,
firstname: formData.firstName, // Manual transform
lastname: formData.lastName, // Manual transform
companyname: formData.company, // Manual transform
phonenumber: formData.phone, // Manual transform
};
await api.signup(request);
};
```
### ✅ DO: Use Domain Types
```typescript
// GOOD - let domain handle transformation or use types
import { type SignupRequest } from "@customer-portal/domain/auth";
const handleSubmit = async (formData) => {
const request: SignupRequest = {
...formData,
firstname: formData.firstName, // Type-safe with domain types
// ... use domain-defined types to ensure correctness
};
await api.signup(request);
};
```
### ❌ DON'T: Import from Wrong Domain
```typescript
// BAD - importing auth schemas from billing domain
import { loginFormSchema } from "@customer-portal/domain/billing"; // Wrong!
```
### ✅ DO: Import from Correct Domain
```typescript
// GOOD - import from correct domain
import { loginRequestSchema } from "@customer-portal/domain/auth"; // Correct!
```
## Domain Schema Checklist
When creating schemas in the domain layer, ensure:
- [ ] Base input schemas are exported (without transforms) for form reuse
- [ ] Request schemas include necessary transforms for API format
- [ ] Atomic field schemas are reusable (emailSchema, passwordSchema, etc.)
- [ ] Schema names are clear and indicate their purpose (e.g., `signupInputSchema` vs `signupRequestSchema`)
- [ ] Types are inferred from schemas (`type SignupRequest = z.infer<typeof signupRequestSchema>`)
## Frontend Form Checklist
When creating forms in the frontend, ensure:
- [ ] Import domain schema as base (don't duplicate)
- [ ] Only extend for UI-specific fields (confirmPassword, rememberMe, etc.)
- [ ] Use `useZodForm` hook for form state management
- [ ] Remove UI-only fields before submitting to API
- [ ] Use domain types for type safety
- [ ] Add documentation comments explaining what's extended and why
## Benefits of This Pattern
1. **Single Source of Truth**: Validation rules defined once in domain layer
2. **Type Safety**: Frontend gets correct types from domain schemas
3. **Consistency**: Forms automatically stay in sync with API schemas
4. **Maintainability**: Changes to validation only need to be made once
5. **Less Code**: Remove ~30-50 lines of duplicate validation per form
6. **Clear Separation**: Domain handles business rules, frontend handles UI concerns
## Known Issues
### SIM Configuration Forms
The SIM configuration forms (useSimConfigure hook) attempt to import schemas that don't yet exist in the domain:
```typescript
// These imports are currently broken:
import {
simConfigureFormSchema, // Does not exist
simConfigureFormToRequest, // Does not exist
type SimConfigureFormData, // Does not exist
} from "@customer-portal/domain/billing"; // Wrong domain anyway
```
**Resolution needed:**
1. Create these schemas in `packages/domain/sim/` (not billing)
2. Update imports in `apps/portal/src/features/catalog/hooks/useSimConfigure.ts`
3. Follow the pattern established in this document
## Related Documentation
- [Validation Patterns Guide](./VALIDATION_PATTERNS.md) - Backend validation patterns
- [Validation Audit Summary](../../VALIDATION_AUDIT_SUMMARY.md) - Recent validation cleanup
- [Domain Architecture](../architecture/DOMAIN_LAYER.md) - Domain layer structure
## Questions?
If you're unsure whether a validation should be in the domain or frontend:
- **Domain**: Business rules, format validation, field requirements, data constraints
- **Frontend**: UI state (rememberMe), confirmation fields (confirmPassword), form-specific logic
When in doubt, put validation in the domain layer. Frontend can always extend it.