459 lines
14 KiB
Markdown
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.
|
|
|