39948-vm/frontend/docs/schemas-module.md
2026-07-03 16:11:24 +02:00

15 KiB

Frontend Schemas Module

Overview

The Schemas module provides Zod validation schemas for form validation across the frontend application. These schemas define data validation rules, generate TypeScript types, and provide initial form values for entity forms.

Location: frontend/src/schemas/

Total Files: 6 files (~257 LOC)

Library: Zod - TypeScript-first schema validation


Architecture

frontend/src/schemas/
├── index.ts           (9 LOC)    # Central exports
├── userSchema.ts      (80 LOC)   # User validation (create/update)
├── assetSchema.ts     (85 LOC)   # Asset validation
├── projectSchema.ts   (31 LOC)   # Project validation
├── tourPageSchema.ts  (35 LOC)   # Tour page validation
└── roleSchema.ts      (17 LOC)   # Role validation

Related Schema: frontend/src/lib/offlineDb/schema.ts - Dexie IndexedDB schema (separate concern)


Schema Pattern

Each schema file follows a consistent pattern:

/**
 * 1. Zod schema definition
 */
export const entitySchema = z.object({
  fieldName: z.string().min(1, 'Error message'),
  // ...
});

/**
 * 2. TypeScript type inference
 */
export type EntityFormData = z.infer<typeof entitySchema>;

/**
 * 3. Initial form values
 */
export const entityInitialValues: EntityFormData = {
  fieldName: '',
  // ...
};

Schema Files

1. User Schema (userSchema.ts)

Purpose: Validation for user creation and update forms.

Lines: 80 LOC

Schemas

// User creation - requires email, optional password
export const userCreateSchema = z.object({
  firstName: z.string().max(255, 'First name too long').optional().or(z.literal('')),
  lastName: z.string().max(255, 'Last name too long').optional().or(z.literal('')),
  phoneNumber: z.string().max(50, 'Phone number too long').optional().or(z.literal('')),
  email: z.string().email('Invalid email address').min(1, 'Email is required'),
  password: z.string().min(6, 'Password must be at least 6 characters').optional().or(z.literal('')),
  disabled: z.boolean().default(false),
  avatar: z.array(z.unknown()).optional(),
  app_role: z.unknown().optional().nullable(),
  custom_permissions: z.array(z.unknown()).optional(),
});

// User update - same fields, password optional
export const userUpdateSchema = z.object({
  // Same structure as userCreateSchema
});

Exports

Export Type Description
userCreateSchema ZodObject Create user validation
userUpdateSchema ZodObject Update user validation
UserCreateFormData Type Inferred create form type
UserUpdateFormData Type Inferred update form type
userInitialValues Object Default form values

Field Validations

Field Validation Error Message
firstName max(255), optional "First name too long"
lastName max(255), optional "Last name too long"
phoneNumber max(50), optional "Phone number too long"
email email(), min(1) "Invalid email address", "Email is required"
password min(6), optional "Password must be at least 6 characters"
disabled boolean, default(false) -
avatar array, optional -
app_role unknown, optional, nullable -
custom_permissions array, optional -

2. Asset Schema (assetSchema.ts)

Purpose: Validation for asset upload and edit forms.

Lines: 85 LOC

Schema

export const assetSchema = z.object({
  project: z.unknown().optional().nullable(),
  name: z.string().min(1, 'Name is required').max(255, 'Name too long'),
  asset_type: z.enum(['image', 'video', 'audio', 'file']),
  type: z.enum([
    'general', 'icon', 'background_image', 'audio',
    'video', 'transition', 'logo', 'favicon', 'document',
  ]).default('general'),
  cdn_url: z.string().url('Invalid URL').optional().or(z.literal('')),
  storage_key: z.string().max(500, 'Storage key too long').optional().or(z.literal('')),
  mime_type: z.string().max(100, 'MIME type too long').optional().or(z.literal('')),
  size_mb: z.coerce.number().min(0, 'Size cannot be negative').optional().or(z.literal('')),
  width_px: z.coerce.number().int().min(0, 'Width cannot be negative').optional().or(z.literal('')),
  height_px: z.coerce.number().int().min(0, 'Height cannot be negative').optional().or(z.literal('')),
  duration_sec: z.coerce.number().min(0, 'Duration cannot be negative').optional().or(z.literal('')),
  checksum: z.string().max(255, 'Checksum too long').optional().or(z.literal('')),
  is_public: z.boolean().default(false),
  is_deleted: z.boolean().default(false),
  deleted_at_time: z.date().optional().nullable(),
});

Exports

Export Type Description
assetSchema ZodObject Asset validation schema
AssetFormData Type Inferred form type
assetInitialValues Object Default form values

Enum Values

asset_type:

  • image - Image files (PNG, JPG, WebP, etc.)
  • video - Video files (MP4, WebM, etc.)
  • audio - Audio files (MP3, WAV, etc.)
  • file - Generic files (documents, etc.)

type (usage category):

  • general - Default category
  • icon - UI icons
  • background_image - Page backgrounds
  • audio - Background audio
  • video - Video content
  • transition - Transition videos
  • logo - Brand logos
  • favicon - Site favicons
  • document - Documents/PDFs

Coercion Pattern

Number fields use z.coerce for form input handling:

// Converts string input to number, validates, allows empty string
size_mb: z.coerce
  .number()
  .min(0, 'Size cannot be negative')
  .optional()
  .or(z.literal(''))

3. Project Schema (projectSchema.ts)

Purpose: Validation for project creation and edit forms.

Lines: 31 LOC

Schema

export const projectSchema = z.object({
  name: z.string().min(1, 'Name is required').max(255, 'Name too long'),
  slug: z.string()
    .max(255, 'Slug too long')
    .regex(
      /^[a-z0-9_-]*$/i,
      'Slug can only contain letters, numbers, dashes, underscores',
    )
    .optional()
    .or(z.literal('')),
  description: z.string().max(5000, 'Description too long').optional().or(z.literal('')),
});

Exports

Export Type Description
projectSchema ZodObject Project validation schema
ProjectFormData Type Inferred form type
projectInitialValues Object Default form values

Slug Validation

The slug field uses regex validation for URL-safe strings:

.regex(/^[a-z0-9_-]*$/i, 'Slug can only contain letters, numbers, dashes, underscores')

Valid: my-project, project_123, MyProject Invalid: my project, project@123, project/name


4. Tour Page Schema (tourPageSchema.ts)

Purpose: Validation for tour page creation and edit forms.

Lines: 35 LOC

Schema

export const tourPageSchema = z.object({
  project: z.unknown().optional().nullable(),
  title: z.string().max(255, 'Title too long').optional().or(z.literal('')),
  slug: z.string()
    .max(255, 'Slug too long')
    .regex(
      /^[a-z0-9_-]*$/i,
      'Slug can only contain letters, numbers, dashes, underscores',
    )
    .optional()
    .or(z.literal('')),
  background_asset: z.unknown().optional().nullable(),
  audio_asset: z.unknown().optional().nullable(),
  is_start_page: z.boolean().default(false),
  sort_order: z.coerce.number().int().min(0).optional().or(z.literal('')),
});

Exports

Export Type Description
tourPageSchema ZodObject Tour page validation schema
TourPageFormData Type Inferred form type
tourPageInitialValues Object Default form values

Initial Values

export const tourPageInitialValues: TourPageFormData = {
  project: null,
  title: '',
  slug: '',
  background_asset: null,
  audio_asset: null,
  is_start_page: false,
  sort_order: '',
};

5. Role Schema (roleSchema.ts)

Purpose: Validation for role creation and edit forms.

Lines: 17 LOC

Schema

export const roleSchema = z.object({
  name: z.string().min(1, 'Name is required').max(255, 'Name too long'),
  permissions: z.array(z.unknown()).optional(),
});

Exports

Export Type Description
roleSchema ZodObject Role validation schema
RoleFormData Type Inferred form type
roleInitialValues Object Default form values

Initial Values

export const roleInitialValues: RoleFormData = {
  name: '',
  permissions: [],
};

6. Index File (index.ts)

Purpose: Central re-export for all schemas.

Lines: 9 LOC

export * from './userSchema';
export * from './projectSchema';
export * from './assetSchema';
export * from './roleSchema';
export * from './tourPageSchema';

Zod Validation Patterns

1. Optional with Empty String

Allow field to be empty or have valid value:

// Accepts: undefined, '', or valid string
fieldName: z.string().optional().or(z.literal(''))

2. Required String

Field must have non-empty value:

// Fails on empty string
name: z.string().min(1, 'Name is required')

3. Email Validation

Built-in email format validation:

email: z.string().email('Invalid email address').min(1, 'Email is required')

4. Enum Validation

Restrict to specific values:

asset_type: z.enum(['image', 'video', 'audio', 'file'])

5. Number Coercion

Convert string input to number:

// Input: "123" → number 123
// Input: "" → literal ''
size_mb: z.coerce.number().min(0).optional().or(z.literal(''))

6. Regex Pattern

Validate string format:

slug: z.string().regex(/^[a-z0-9_-]*$/i, 'Error message')

7. Nullable Relation

Optional reference to another entity:

project: z.unknown().optional().nullable()

8. Boolean with Default

Boolean field with default value:

disabled: z.boolean().default(false)

Type Inference

Zod schemas generate TypeScript types automatically:

// Define schema
const projectSchema = z.object({
  name: z.string().min(1),
  slug: z.string().optional(),
});

// Infer type
type ProjectFormData = z.infer<typeof projectSchema>;

// Equivalent to:
// type ProjectFormData = {
//   name: string;
//   slug?: string;
// }

Usage with Formik

Schemas can be integrated with Formik using adapters:

Option 1: Manual Validation

import { projectSchema, projectInitialValues } from '@/schemas';

const validate = (values) => {
  const result = projectSchema.safeParse(values);
  if (!result.success) {
    const errors = {};
    result.error.issues.forEach((issue) => {
      errors[issue.path[0]] = issue.message;
    });
    return errors;
  }
  return {};
};

<Formik
  initialValues={projectInitialValues}
  validate={validate}
  onSubmit={handleSubmit}
>
  {/* form fields */}
</Formik>

Option 2: Zod-Formik Adapter

import { toFormikValidationSchema } from 'zod-formik-adapter';
import { projectSchema, projectInitialValues } from '@/schemas';

<Formik
  initialValues={projectInitialValues}
  validationSchema={toFormikValidationSchema(projectSchema)}
  onSubmit={handleSubmit}
>
  {/* form fields */}
</Formik>

The lib/offlineDb/schema.ts file defines the Dexie.js IndexedDB schema for offline storage (separate from form validation):

class OfflineDatabase extends Dexie {
  assets!: EntityTable<OfflineAsset, 'id'>;
  projects!: EntityTable<OfflineProject, 'id'>;
  downloadQueue!: EntityTable<DownloadQueueItem, 'id'>;

  constructor() {
    super(OFFLINE_CONFIG.dbName);

    this.version(OFFLINE_CONFIG.dbVersion).stores({
      assets: 'id, projectId, url, variantType, assetType, downloadedAt',
      projects: 'id, slug, status, lastSyncedAt',
      downloadQueue: 'id, projectId, status, priority, addedAt',
    });
  }
}

export const offlineDb = new OfflineDatabase();

Key Differences:

Aspect Zod Schemas Dexie Schema
Purpose Form validation Database structure
Library Zod Dexie.js
Location schemas/ lib/offlineDb/
Runtime Client-side validation IndexedDB storage

Schema Inventory

File LOC Entity Fields Exports
userSchema.ts 80 User 9 fields 5 (2 schemas, 2 types, 1 initial)
assetSchema.ts 85 Asset 14 fields 3 (1 schema, 1 type, 1 initial)
tourPageSchema.ts 35 TourPage 7 fields 3 (1 schema, 1 type, 1 initial)
projectSchema.ts 31 Project 3 fields 3 (1 schema, 1 type, 1 initial)
roleSchema.ts 17 Role 2 fields 3 (1 schema, 1 type, 1 initial)
index.ts 9 - - Re-exports all
Total 257 5 35 17

Best Practices

1. Consistent Error Messages

Use descriptive, user-friendly error messages:

// Good
name: z.string().min(1, 'Name is required').max(255, 'Name too long')

// Avoid
name: z.string().min(1).max(255)  // Generic messages

2. Type-Safe Initial Values

Use inferred type for initial values:

export const projectInitialValues: ProjectFormData = {
  name: '',      // TypeScript enforces this matches schema
  slug: '',
  description: '',
};

3. Empty String Handling

For optional fields that may receive empty strings from forms:

// Allows both undefined and empty string
fieldName: z.string().optional().or(z.literal(''))

4. Relation Fields

Use z.unknown() for relation fields that hold entity references:

// Accepts any value - actual validation happens on backend
project: z.unknown().optional().nullable()

5. Number Input Handling

Use z.coerce for number fields from text inputs:

// Converts "123" to 123, validates as number
amount: z.coerce.number().min(0)


Adding New Schemas

To add validation for a new entity:

Step 1: Create Schema File

// schemas/widgetSchema.ts
import { z } from 'zod';

export const widgetSchema = z.object({
  name: z.string().min(1, 'Name is required').max(255, 'Name too long'),
  type: z.enum(['basic', 'advanced']).default('basic'),
  config: z.record(z.unknown()).optional(),
});

export type WidgetFormData = z.infer<typeof widgetSchema>;

export const widgetInitialValues: WidgetFormData = {
  name: '',
  type: 'basic',
  config: {},
};

Step 2: Export from Index

// schemas/index.ts
export * from './widgetSchema';

Step 3: Use in Form

import { widgetSchema, widgetInitialValues, type WidgetFormData } from '@/schemas';

// With validation
const validate = (values: WidgetFormData) => {
  const result = widgetSchema.safeParse(values);
  // ... handle errors
};