Assist_Design/docs/validation/FRONTEND_VALIDATION_PATTERNS.md

14 KiB

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:

// 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:

// 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

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:

// 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:

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:

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)

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)

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)

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

// 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

// 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

// 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

// 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

// BAD - importing auth schemas from billing domain
import { loginFormSchema } from "@customer-portal/domain/billing";  // Wrong!

DO: Import from Correct Domain

// 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:

// 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

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.