632 lines
15 KiB
Markdown
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
|
|
};
|
|
```
|