# 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({ 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 fields */}
); } ``` ### 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 fields */}
); } ``` ### 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" ? (
{/* Request form */}
) : (
{/* Reset 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`) ## 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.