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) => { ... }
);
Related Documentation
- stores-module.md - Redux state management
- components-module.md - Component organization
- pages-module.md - Page structure
- types-module.md - TypeScript types
- 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 |