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

25 KiB

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

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

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

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

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

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

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

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

interface EntitySliceState<T> {
  [key: string]: T[] | boolean | number | NotificationState | unknown[];
  loading: boolean;
  count: number;
  refetch: boolean;
  notify: NotificationState;
}

Configuration Interface

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

interface TableComponentProps {
  filterItems: FilterItem[];
  setFilterItems: (items: FilterItem[]) => void;
  filters: Filter[];
  showGrid?: boolean;
}

Usage Example

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

const DEFAULT_COLUMN_PROPS = {
  flex: 1,
  minWidth: 120,
  filterable: false,
  headerClassName: 'datagrid--header',
  cellClassName: 'datagrid--cell',
};

Column Metadata Interface

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

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

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

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

import type {
  EntitySliceConfig,
  NotificationState,
  EntitySliceState,
} from '../types/redux';
import type { PaginatedResponse, FetchParams, ApiError } from '../types/api';
import type { BaseEntity } from '../types/entities';

Configuration Interface

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

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:

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

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

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

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

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

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

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

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

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

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

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

// Permissions follow pattern: ACTION_ENTITY
readPermission: 'READ_WIDGETS',
createPermission: 'CREATE_WIDGETS',
updatePermission: 'UPDATE_WIDGETS',  // Used by configBuilderFactory
deletePermission: 'DELETE_WIDGETS',

3. Type Definitions

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

// 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) => { ... }
);


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