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

632 lines
15 KiB
Markdown

# 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](https://zod.dev/) - 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:
```typescript
/**
* 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
```typescript
// 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
```typescript
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:
```typescript
// 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
```typescript
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:
```typescript
.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
```typescript
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
```typescript
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
```typescript
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
```typescript
export const roleInitialValues: RoleFormData = {
name: '',
permissions: [],
};
```
---
### 6. Index File (`index.ts`)
**Purpose:** Central re-export for all schemas.
**Lines:** 9 LOC
```typescript
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:
```typescript
// Accepts: undefined, '', or valid string
fieldName: z.string().optional().or(z.literal(''))
```
### 2. Required String
Field must have non-empty value:
```typescript
// Fails on empty string
name: z.string().min(1, 'Name is required')
```
### 3. Email Validation
Built-in email format validation:
```typescript
email: z.string().email('Invalid email address').min(1, 'Email is required')
```
### 4. Enum Validation
Restrict to specific values:
```typescript
asset_type: z.enum(['image', 'video', 'audio', 'file'])
```
### 5. Number Coercion
Convert string input to number:
```typescript
// Input: "123" → number 123
// Input: "" → literal ''
size_mb: z.coerce.number().min(0).optional().or(z.literal(''))
```
### 6. Regex Pattern
Validate string format:
```typescript
slug: z.string().regex(/^[a-z0-9_-]*$/i, 'Error message')
```
### 7. Nullable Relation
Optional reference to another entity:
```typescript
project: z.unknown().optional().nullable()
```
### 8. Boolean with Default
Boolean field with default value:
```typescript
disabled: z.boolean().default(false)
```
---
## Type Inference
Zod schemas generate TypeScript types automatically:
```typescript
// 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
```typescript
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
```typescript
import { toFormikValidationSchema } from 'zod-formik-adapter';
import { projectSchema, projectInitialValues } from '@/schemas';
<Formik
initialValues={projectInitialValues}
validationSchema={toFormikValidationSchema(projectSchema)}
onSubmit={handleSubmit}
>
{/* form fields */}
</Formik>
```
---
## Related: IndexedDB Schema
The `lib/offlineDb/schema.ts` file defines the **Dexie.js** IndexedDB schema for offline storage (separate from form validation):
```typescript
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:
```typescript
// 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:
```typescript
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:
```typescript
// 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:
```typescript
// 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:
```typescript
// Converts "123" to 123, validates as number
amount: z.coerce.number().min(0)
```
---
## Related Documentation
- [types-module.md](./types-module.md) - Entity type definitions
- [factories-module.md](./factories-module.md) - Form page factory (uses schemas)
- [lib-module.md](./lib-module.md) - Offline storage schema
- [hooks-module.md](./hooks-module.md) - Form hooks
---
## Adding New Schemas
To add validation for a new entity:
### Step 1: Create Schema File
```typescript
// 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
```typescript
// schemas/index.ts
export * from './widgetSchema';
```
### Step 3: Use in Form
```typescript
import { widgetSchema, widgetInitialValues, type WidgetFormData } from '@/schemas';
// With validation
const validate = (values: WidgetFormData) => {
const result = widgetSchema.safeParse(values);
// ... handle errors
};
```