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

848 lines
25 KiB
Markdown

# Frontend Factories Module
## Overview
The Factories module provides **code generation patterns** that eliminate massive boilerplate across the frontend application. These factories generate complete components, pages, Redux slices, and table configurations from declarative configurations.
**Location:** Multiple directories (factory pattern is distributed)
**Total Lines:** ~1,291 LOC across 6 factory files
---
## Architecture
```
frontend/src/
├── factories/ # Page factories
│ ├── createListPage.tsx (193 LOC) # List page generator
│ ├── createFormPage.tsx (331 LOC) # Form page generator
│ └── index.ts (6 LOC) # Exports
├── components/
│ ├── Factory/
│ │ └── createTableComponent.tsx (118 LOC) # Table component generator
│ └── DataGrid/
│ └── configBuilderFactory.tsx (301 LOC) # Column config generator
└── stores/
└── createEntitySlice.ts (342 LOC) # Redux slice generator
```
---
## Factory Relationship Diagram
```
┌────────────────────────────────────┐
│ createListPage │
│ (generates list pages) │
└────────────────┬───────────────────┘
│ uses
┌────────────────────────────────────┐
│ createTableComponent │
│ (generates table wrappers) │
└────────────────┬───────────────────┘
│ wraps
┌────────────────────────────────────┐
│ GenericTable │
│ (reusable data grid) │
└────────────────┬───────────────────┘
│ uses
┌────────────────┴───────────────────┐
▼ ▼
┌───────────────────────────────┐ ┌────────────────────────────────┐
│ createColumnLoader │ │ createEntitySlice │
│ (column config generator) │ │ (Redux slice generator) │
└───────────────────────────────┘ └────────────────────────────────┘
```
---
## Factory Files
### 1. createListPage (`factories/createListPage.tsx`)
**Purpose:** Generate complete list pages with filtering, CSV import/export, and permissions.
**Lines:** 193 LOC
#### Configuration Interface
```typescript
interface ListPageConfig {
entityName: string; // URL path segment (e.g., 'roles')
entityTitle: string; // Page title (e.g., 'Roles')
TableComponent: React.ComponentType<{
filterItems: FilterItem[];
setFilterItems: (items: FilterItem[]) => void;
filters: Filter[];
showGrid?: boolean;
}>;
filters: Filter[]; // Available filter fields
readPermission: string; // Permission to view (e.g., 'READ_ROLES')
createPermission: string; // Permission to create (e.g., 'CREATE_ROLES')
uploadCsvAction: AsyncThunk<unknown, File, object>; // Redux thunk for CSV upload
setRefetchAction: (value: boolean) => { type: string; payload: boolean }; // Action to trigger data refresh
newItemLabel?: string; // Custom "New Item" button text
cardBoxId?: string; // Optional card container ID
}
```
#### Generated Features
| Feature | Description |
|---------|-------------|
| Page Title | HTML `<title>` via Next.js Head |
| Permission Guard | `LayoutAuthenticated` wrapper with `readPermission` |
| New Item Button | Links to `-new` page (if `createPermission`) |
| Filter Button | Adds dynamic filter rows |
| Download CSV | Exports entity data as CSV |
| Upload CSV | Modal with drag-drop file picker |
| Table Display | Renders configured `TableComponent` |
#### Usage Example
```typescript
// pages/roles/roles-list.tsx
import { createListPage } from '../../factories/createListPage';
import TableRoles from '../../components/Roles/TableRoles';
import { uploadCsv, setRefetch } from '../../stores/roles/rolesSlice';
const filters = [
{ label: 'Name', title: 'name' },
{ label: 'Permissions', title: 'permissions' },
];
export default createListPage({
entityName: 'roles',
entityTitle: 'Roles',
TableComponent: TableRoles,
filters,
readPermission: 'READ_ROLES',
createPermission: 'CREATE_ROLES',
uploadCsvAction: uploadCsv,
setRefetchAction: setRefetch,
});
```
**Result:** 24 lines of code generates a complete list page (~200 lines equivalent).
---
### 2. createFormPage (`factories/createFormPage.tsx`)
**Purpose:** Generate form pages for creating and editing entities with Formik integration.
**Lines:** 331 LOC
#### Field Types
```typescript
type FormFieldType =
| 'text' // Text input
| 'email' // Email input with validation
| 'number' // Numeric input
| 'textarea' // Multi-line text
| 'select' // Single-select relation
| 'selectMany' // Multi-select relation
| 'enumSelect' // Select from predefined options
| 'switch' // Boolean toggle
| 'image' // Image picker
| 'date' // Date picker
| 'datetime' // DateTime picker
| 'password' // Password input
| 'custom'; // Custom component
```
#### Field Configuration
```typescript
interface FormFieldConfig {
name: string; // Form field name
label: string; // Display label
type: FormFieldType; // Field type
placeholder?: string; // Input placeholder
itemRef?: string; // Entity reference for select fields
showField?: string; // Display field for relations
options?: Array<{ // Options for enumSelect
value: string;
label: string;
}>;
path?: string; // Upload path for images
schema?: { // Image constraints
size?: number;
formats?: string[];
};
component?: React.ComponentType; // Custom component
props?: Record<string, unknown>; // Custom component props
}
```
#### Page Configuration
```typescript
interface FormPageConfig<T> {
entityName: string; // Entity URL segment
entityTitle: string; // Page title
singularTitle: string; // Singular form (e.g., 'Role')
mode: 'create' | 'edit'; // Form mode
sliceSelector: Selector; // Redux state selector
fetchAction?: AsyncThunk; // Fetch for edit mode
createAction?: AsyncThunk; // Create thunk
updateAction?: AsyncThunk; // Update thunk
permission: string; // Required permission
initialValues: T; // Default form values
fields: FormFieldConfig[]; // Field definitions
validate?: Validator; // Formik validation
}
```
#### Usage Example
```typescript
// pages/users/users-new.tsx
import { createFormPage } from '../../factories/createFormPage';
import { create } from '../../stores/users/usersSlice';
export default createFormPage({
entityName: 'users',
entityTitle: 'Users',
singularTitle: 'User',
mode: 'create',
sliceSelector: (state) => state.users,
createAction: create,
permission: 'CREATE_USERS',
initialValues: {
firstName: '',
lastName: '',
email: '',
role: null,
},
fields: [
{ name: 'firstName', label: 'First Name', type: 'text' },
{ name: 'lastName', label: 'Last Name', type: 'text' },
{ name: 'email', label: 'Email', type: 'email' },
{ name: 'role', label: 'Role', type: 'select', itemRef: 'roles', showField: 'name' },
],
});
```
#### Field Rendering Logic
The `renderField` helper function handles all field types:
```typescript
function renderField(field: FormFieldConfig, formValues: T): React.ReactNode {
switch (field.type) {
case 'text':
case 'email':
case 'password':
return <Field name={name} type={type} placeholder={placeholder} />;
case 'select':
return <Field name={name} component={SelectField} options={...} />;
case 'switch':
return <Field name={name} component={SwitchField} />;
case 'image':
return <Field name={name} component={FormImagePicker} path={path} />;
case 'custom':
return <Field name={name} component={CustomComponent} {...props} />;
// ... other types
}
}
```
---
### 3. createTableComponent (`components/Factory/createTableComponent.tsx`)
**Purpose:** Generate table wrapper components that connect GenericTable with entity-specific configuration.
**Lines:** 118 LOC
#### Entity Slice State Interface
```typescript
interface EntitySliceState<T> {
[key: string]: T[] | boolean | number | NotificationState | unknown[];
loading: boolean;
count: number;
refetch: boolean;
notify: NotificationState;
}
```
#### Configuration Interface
```typescript
interface TableComponentConfig<T extends BaseEntity> {
entityName: string; // Entity identifier
sliceSelector: (state: RootState) => EntitySliceState<T>; // Redux slice selector
fetchAction: AsyncThunk<
T | { rows: T[]; count: number },
{ id?: string; query?: string },
object
>;
updateAction: AsyncThunk<T, { id: string; data: Partial<T> }, object>;
deleteAction: AsyncThunk<void, string, object>;
deleteByIdsAction: AsyncThunk<void, string[], object>;
setRefetchAction: (refetch: boolean) => { type: string; payload: boolean };
loadColumnsFunction: (
onDelete: (id: string) => void,
entityName: string,
user: unknown,
) => Promise<GridColDef[]>;
}
```
#### Generated Props Interface
```typescript
interface TableComponentProps {
filterItems: FilterItem[];
setFilterItems: (items: FilterItem[]) => void;
filters: Filter[];
showGrid?: boolean;
}
```
#### Usage Example
```typescript
// components/Roles/TableRoles.tsx
import { createTableComponent } from '../Factory/createTableComponent';
import { fetch, update, deleteItem, setRefetch, deleteItemsByIds } from '../../stores/roles/rolesSlice';
import { loadColumns } from './configureRolesCols';
import type { Role } from '../../types/entities';
const TableRoles = createTableComponent<Role>({
entityName: 'roles',
sliceSelector: (state) => state.roles,
fetchAction: fetch,
updateAction: update,
deleteAction: deleteItem,
deleteByIdsAction: deleteItemsByIds,
setRefetchAction: setRefetch,
loadColumnsFunction: loadColumns,
});
export default TableRoles;
```
**Result:** 13 lines replaces ~100 lines of boilerplate.
---
### 4. configBuilderFactory (`components/DataGrid/configBuilderFactory.tsx`)
**Purpose:** Generate MUI X DataGrid column configurations from declarative metadata.
**Lines:** 301 LOC
#### Default Column Properties
```typescript
const DEFAULT_COLUMN_PROPS = {
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
};
```
#### Column Metadata Interface
```typescript
interface ColumnMetadata {
field: string; // Data field name
headerName: string; // Column header
type?: // Column type
| 'text'
| 'boolean'
| 'date'
| 'datetime'
| 'number'
| 'relation'
| 'relationMany'
| 'singleSelectRelation'
| 'image'
| 'actions';
editable?: boolean; // Allow inline editing
sortable?: boolean; // Allow sorting
width?: number; // Fixed width
flex?: number; // Flex grow
minWidth?: number; // Minimum width
entityRef?: string; // Related entity for relations
displayField?: string; // Field to display for relations
renderCell?: (params: GridRenderCellParams) => React.ReactElement; // Custom cell renderer
valueFormatter?: (value: unknown) => string; // Value transformation
}
```
#### Column Builder Configuration
```typescript
interface ColumnBuilderConfig {
entityName: string; // Entity identifier
entityPath?: string; // URL path (defaults to entityName)
columns: ColumnMetadata[]; // Column definitions
updatePermission?: string; // Override update permission check
}
```
#### Key Functions
| Function | Purpose |
|----------|---------|
| `buildColumns()` | Async function that builds complete column array |
| `createColumnLoader()` | Factory that returns column loader function |
| `fetchRelationOptions()` | Fetches options for singleSelectRelation columns |
| `buildColumn()` | Builds individual column definition |
| `buildActionsColumn()` | Builds actions column with edit/view/delete |
| `getFormatter()` | Returns appropriate value formatter |
#### Column Type Handling
```typescript
// Type-specific column configuration
switch (col.type) {
case 'boolean':
baseColumn.type = 'boolean';
baseColumn.valueFormatter = ({ value }) => dataFormatter.booleanFormatter(value);
break;
case 'datetime':
baseColumn.type = 'dateTime';
baseColumn.valueGetter = (_value, row) => new Date(row[col.field]);
break;
case 'relation':
case 'relationMany':
baseColumn.type = 'singleSelect';
baseColumn.renderEditCell = (params) => (
<DataGridMultiSelect {...params} entityName={col.entityRef} />
);
break;
case 'image':
baseColumn.renderCell = (params) => (
<span style={{ backgroundImage: `url(${params.value[0]?.publicUrl})` }} />
);
break;
}
```
#### Usage Example
```typescript
// components/Roles/configureRolesCols.tsx
import { createColumnLoader, ColumnMetadata } from '../DataGrid/configBuilderFactory';
const ROLES_COLUMNS: ColumnMetadata[] = [
{ field: 'name', headerName: 'Name', type: 'text', editable: true },
{ field: 'permissions', headerName: 'Permissions', type: 'relationMany', entityRef: 'permissions' },
{ field: 'actions', headerName: '', type: 'actions' },
];
export const loadColumns = createColumnLoader({
entityName: 'roles',
columns: ROLES_COLUMNS,
});
```
**Result:** 12 lines replaces ~150 lines of column configuration.
---
### 5. createEntitySlice (`stores/createEntitySlice.ts`)
**Purpose:** Generate complete Redux Toolkit slices with CRUD async thunks.
**Lines:** 342 LOC
#### Imported Types
The factory imports types from dedicated type files:
```typescript
import type {
EntitySliceConfig,
NotificationState,
EntitySliceState,
} from '../types/redux';
import type { PaginatedResponse, FetchParams, ApiError } from '../types/api';
import type { BaseEntity } from '../types/entities';
```
#### Configuration Interface
```typescript
interface EntitySliceConfig {
name: string; // Slice name (e.g., 'roles')
endpoint: string; // API endpoint (e.g., 'roles')
singularName?: string; // Singular form for notifications (e.g., 'Role')
}
```
#### Generated Actions
| Action | Type | Description |
|--------|------|-------------|
| `fetch` | AsyncThunk | Fetch single item by ID or paginated list |
| `create` | AsyncThunk | Create new entity |
| `update` | AsyncThunk | Update existing entity |
| `deleteItem` | AsyncThunk | Delete single item |
| `deleteItemsByIds` | AsyncThunk | Bulk delete items |
| `uploadCsv` | AsyncThunk | Import entities from CSV |
| `setRefetch` | Action | Trigger data refresh |
#### Generated State
```typescript
interface InternalSliceState<T> {
[entityName]: T[]; // Entity array
loading: boolean; // Loading state
count: number; // Total count for pagination
refetch: boolean; // Refetch trigger flag
rolesWidgets: unknown[]; // Legacy widgets support
notify: NotificationState; // Notification state
}
```
#### Notification Handling
Each async action automatically handles notifications:
```typescript
// Fulfilled state
builder.addCase(create.fulfilled, (state) => {
state.loading = false;
fulfilledNotify(state, `${displayName} has been created`);
});
// Rejected state
builder.addCase(create.rejected, (state, action) => {
state.loading = false;
rejectNotify(state, action);
});
```
#### Usage Example
```typescript
// stores/roles/rolesSlice.ts
import { createEntitySlice } from '../createEntitySlice';
import type { Role } from '../../types/entities';
const { slice, actions, reducer: baseReducer } = createEntitySlice<Role>({
name: 'roles',
endpoint: 'roles',
singularName: 'Role',
});
// Export standard CRUD actions
export const { fetch, create, update, deleteItem, deleteItemsByIds, uploadCsv, setRefetch } = actions;
export const rolesSlice = slice;
export default baseReducer;
```
**Result:** 10 lines generates complete Redux slice (~300 lines equivalent).
#### Exported Type
The factory exports a utility type for accessing actions:
```typescript
export type EntityActions<T extends BaseEntity> = ReturnType<
typeof createEntitySlice<T>
>['actions'];
```
#### Helper Functions
The factory includes internal helper functions:
| Function | Purpose |
|----------|---------|
| `isPaginatedResponse<T>()` | Type guard to check if response has `rows` and `count` |
| `isAxiosError()` | Type guard for Axios errors |
| `getSingularName()` | Get singular form for notifications |
| `capitalize()` | Capitalize first letter of string |
#### Custom Thunks Extension
Slices can add custom thunks alongside generated ones when an existing legacy
entity flow still requires Redux-side server calls. New server-state flows
should use TanStack Query instead of adding new entity CRUD thunks.
---
## GenericTable Component
The `GenericTable` component (`components/Generic/GenericTable.tsx`, 429 LOC) is the foundation that table factories wrap.
### Features
| Feature | Implementation |
|---------|----------------|
| Server-side pagination | `paginationMode='server'` |
| Server-side sorting | `sortingMode='server'` |
| Row selection | Checkbox selection with bulk actions |
| Inline editing | Row edit mode with validation |
| Dynamic filters | Formik-based filter panel |
| Notifications | Toast notifications via react-toastify |
| Permissions | Column editability based on user permissions |
### Props Interface
```typescript
interface GenericTableProps<T extends BaseEntity> {
entityName: string;
sliceSelector: StateSelector<T>;
fetchAction: AsyncThunk;
updateAction: AsyncThunk;
deleteAction: AsyncThunk;
deleteByIdsAction?: AsyncThunk;
setRefetchAction: ActionCreator;
loadColumnsFunction: ColumnLoader;
filters: Filter[];
filterItems: FilterItem[];
setFilterItems: Setter;
extraQuery?: string;
}
```
---
## Entity Implementation Pattern
Complete pattern for adding a new entity using all factories:
### Step 1: Create Entity Slice
```typescript
// stores/widgets/widgetsSlice.ts
import { createEntitySlice } from '../createEntitySlice';
import type { Widget } from '../../types/entities';
const { slice, actions, reducer } = createEntitySlice<Widget>({
name: 'widgets',
endpoint: 'widgets',
singularName: 'Widget',
});
export const { fetch, create, update, deleteItem, deleteItemsByIds, uploadCsv, setRefetch } = actions;
export default reducer;
```
### Step 2: Create Column Configuration
```typescript
// components/Widgets/configureWidgetsCols.tsx
import { createColumnLoader, ColumnMetadata } from '../DataGrid/configBuilderFactory';
const WIDGETS_COLUMNS: ColumnMetadata[] = [
{ field: 'name', headerName: 'Name', type: 'text', editable: true },
{ field: 'status', headerName: 'Status', type: 'text' },
{ field: 'createdAt', headerName: 'Created', type: 'datetime' },
{ field: 'actions', headerName: '', type: 'actions' },
];
export const loadColumns = createColumnLoader({
entityName: 'widgets',
columns: WIDGETS_COLUMNS,
});
```
### Step 3: Create Table Component
```typescript
// components/Widgets/TableWidgets.tsx
import { createTableComponent } from '../Factory/createTableComponent';
import { fetch, update, deleteItem, setRefetch, deleteItemsByIds } from '../../stores/widgets/widgetsSlice';
import { loadColumns } from './configureWidgetsCols';
import type { Widget } from '../../types/entities';
const TableWidgets = createTableComponent<Widget>({
entityName: 'widgets',
sliceSelector: (state) => state.widgets,
fetchAction: fetch,
updateAction: update,
deleteAction: deleteItem,
deleteByIdsAction: deleteItemsByIds,
setRefetchAction: setRefetch,
loadColumnsFunction: loadColumns,
});
export default TableWidgets;
```
### Step 4: Create List Page
```typescript
// pages/widgets/widgets-list.tsx
import { createListPage } from '../../factories/createListPage';
import TableWidgets from '../../components/Widgets/TableWidgets';
import { uploadCsv, setRefetch } from '../../stores/widgets/widgetsSlice';
const filters = [
{ label: 'Name', title: 'name' },
{ label: 'Status', title: 'status' },
];
export default createListPage({
entityName: 'widgets',
entityTitle: 'Widgets',
TableComponent: TableWidgets,
filters,
readPermission: 'READ_WIDGETS',
createPermission: 'CREATE_WIDGETS',
uploadCsvAction: uploadCsv,
setRefetchAction: setRefetch,
});
```
### Step 5: Create Form Pages
```typescript
// pages/widgets/widgets-new.tsx
import { createFormPage } from '../../factories/createFormPage';
import { create } from '../../stores/widgets/widgetsSlice';
export default createFormPage({
entityName: 'widgets',
entityTitle: 'Widgets',
singularTitle: 'Widget',
mode: 'create',
sliceSelector: (state) => state.widgets,
createAction: create,
permission: 'CREATE_WIDGETS',
initialValues: { name: '', status: 'draft' },
fields: [
{ name: 'name', label: 'Name', type: 'text' },
{ name: 'status', label: 'Status', type: 'enumSelect', options: [
{ value: 'draft', label: 'Draft' },
{ value: 'active', label: 'Active' },
]},
],
});
```
**Total:** ~80 lines for complete CRUD implementation (~1,500 lines equivalent without factories).
---
## Boilerplate Reduction
| Component | Without Factory | With Factory | Reduction |
|-----------|-----------------|--------------|-----------|
| Redux Slice | ~300 LOC | ~10 LOC | **97%** |
| List Page | ~200 LOC | ~24 LOC | **88%** |
| Form Page | ~250 LOC | ~30 LOC | **88%** |
| Table Component | ~100 LOC | ~13 LOC | **87%** |
| Column Config | ~150 LOC | ~12 LOC | **92%** |
| **Total per Entity** | **~1,000 LOC** | **~89 LOC** | **91%** |
With 13 entities in the application, factories eliminate approximately **11,843 lines** of boilerplate code.
---
## Type Safety
All factories are fully typed with TypeScript generics:
```typescript
// createEntitySlice with type parameter
const { actions } = createEntitySlice<Role>({ ... });
// actions.fetch: AsyncThunk<Role | PaginatedResponse<Role>, FetchParams, ...>
// createTableComponent with type parameter
const TableRoles = createTableComponent<Role>({ ... });
// TableRoles: React.FC<TableComponentProps>
// createFormPage with type parameter
const RolesNew = createFormPage<RoleFormValues>({ ... });
// Type-safe form values throughout
```
---
## Best Practices
### 1. Use Consistent Naming
```typescript
// Entity name should match across all layers
const entityName = 'widgets'; // Used in:
// - Slice: stores/widgets/widgetsSlice.ts
// - Table: components/Widgets/TableWidgets.tsx
// - Pages: pages/widgets/widgets-list.tsx
```
### 2. Permission Naming Convention
```typescript
// Permissions follow pattern: ACTION_ENTITY
readPermission: 'READ_WIDGETS',
createPermission: 'CREATE_WIDGETS',
updatePermission: 'UPDATE_WIDGETS', // Used by configBuilderFactory
deletePermission: 'DELETE_WIDGETS',
```
### 3. Type Definitions
```typescript
// Always define entity type in types/entities.ts
export interface Widget extends BaseEntity {
name: string;
status: 'draft' | 'active';
}
// Use type parameter in all factories
createEntitySlice<Widget>({ ... });
createTableComponent<Widget>({ ... });
```
### 4. Custom Extensions
```typescript
// Add custom thunks after factory generation
const { slice, actions, reducer } = createEntitySlice<Widget>({ ... });
// Custom widget-specific thunk
export const archiveWidget = createAsyncThunk(
'widgets/archive',
async (id: string) => { ... }
);
```
---
## Related Documentation
- [stores-module.md](./stores-module.md) - Redux state management
- [components-module.md](./components-module.md) - Component organization
- [pages-module.md](./pages-module.md) - Page structure
- [types-module.md](./types-module.md) - TypeScript types
- [hooks-module.md](./hooks-module.md) - Custom hooks
---
## File Inventory
| File | Location | LOC | Purpose |
|------|----------|-----|---------|
| `createListPage.tsx` | `factories/` | 193 | List page generator |
| `createFormPage.tsx` | `factories/` | 331 | Form page generator |
| `index.ts` | `factories/` | 6 | Exports |
| `createTableComponent.tsx` | `components/Factory/` | 118 | Table wrapper generator |
| `configBuilderFactory.tsx` | `components/DataGrid/` | 301 | Column config generator |
| `createEntitySlice.ts` | `stores/` | 342 | Redux slice generator |
| **Total** | | **1,291** | |