23 KiB
PageManager Usage Guide
A step-by-step guide to creating new pages using the PageManager system.
Quick Start: Adding a New Page
Step 1: Create Page Definition File
Create a new file in src/core/PageManager/data/pages/ (e.g., mypage.ts):
import { useCallback } from 'react';
import { GenericPageData } from '../../pageInterface';
import { FaIcon } from 'react-icons/fa';
import { privilegeCheckers } from '../../../../hooks/privilegeCheckers';
// 1. Import your custom hooks
import { useMyData } from '../../../../hooks/useMyData';
import { useMyOperations } from '../../../../hooks/useMyOperations';
// 2. Create Hook Factory
const createMyPageHook = () => {
return () => {
// Call your data hooks
const { data, loading, error, refetch } = useMyData();
const { handleCreate, handleUpdate, handleDelete,
creatingItems, updatingItems, deletingItems } = useMyOperations();
// Return unified interface
return {
data,
loading,
error,
refetch,
// Operations
handleCreate,
handleUpdate,
handleDelete,
// Loading states
creatingItems,
updatingItems,
deletingItems
};
};
};
// 3. Define Columns
const myPageColumns = [
{
key: 'name',
label: 'Name',
type: 'string',
width: 250,
sortable: true,
filterable: true,
searchable: true
},
{
key: 'status',
label: 'Status',
type: 'enum',
width: 150,
sortable: true,
filterable: true,
filterOptions: ['Active', 'Inactive']
},
{
key: 'created_at',
label: 'Created',
type: 'date',
width: 200,
sortable: true,
filterable: true
}
];
// 4. Export Page Configuration
export const myPageData: GenericPageData = {
// Identification
id: 'my-page',
path: 'my-page',
name: 'My Page',
description: 'Description of my page',
// Visual
icon: FaIcon,
title: 'My Page Title',
subtitle: 'Subtitle text',
// Header buttons (optional)
headerButtons: [
{
id: 'create-item',
label: 'Create New',
icon: FaIcon,
variant: 'primary',
onClick: () => {} // Will be handled by PageRenderer
}
],
// Content
content: [
{
id: 'my-table',
type: 'table',
tableConfig: {
hookFactory: createMyPageHook,
columns: myPageColumns,
actionButtons: [
{
type: 'view',
title: 'View details',
idField: 'id',
nameField: 'name',
operationName: 'handleView',
loadingStateName: 'viewingItems'
},
{
type: 'edit',
title: 'Edit item',
idField: 'id',
nameField: 'name',
operationName: 'handleUpdate',
loadingStateName: 'updatingItems'
},
{
type: 'delete',
title: 'Delete item',
idField: 'id',
operationName: 'handleDelete',
loadingStateName: 'deletingItems'
}
],
searchable: true,
filterable: true,
sortable: true,
resizable: true,
pagination: true,
pageSize: 10
}
}
],
// Privilege check
privilegeChecker: privilegeCheckers.viewerRole,
// Page behavior
persistent: false, // false = unmount when navigating away
preload: false,
moduleEnabled: true,
showInSidebar: true,
order: 10
};
Step 2: Register the Page
Add your page to src/core/PageManager/data/index.ts:
import { myPageData } from './pages/mypage';
export const allPageData: GenericPageData[] = [
// ... existing pages
myPageData, // Add your page
];
// Export for direct access
export { myPageData } from './pages/mypage';
Step 3: Create Your Custom Hooks
Create src/hooks/useMyData.ts:
import { useState, useEffect, useCallback } from 'react';
import { useApiRequest } from './useApi';
export interface MyDataItem {
id: string;
name: string;
status: string;
created_at: string;
}
export function useMyData() {
const [data, setData] = useState<MyDataItem[]>([]);
const [isRefetching, setIsRefetching] = useState(false);
const { request, isLoading: loading, error, clearCache } = useApiRequest<null, MyDataItem[]>();
const fetchData = useCallback(async () => {
try {
const result = await request({
url: '/api/mydata',
method: 'get'
});
setData(result || []);
} catch (error: any) {
console.error('Failed to fetch data:', error);
setData([]);
}
}, [request]);
const refetch = useCallback(async () => {
setIsRefetching(true);
try {
clearCache('/api/mydata', 'get');
await fetchData();
} finally {
setIsRefetching(false);
}
}, [clearCache, fetchData]);
useEffect(() => {
fetchData();
}, [fetchData]);
return { data, loading, isRefetching, error, refetch };
}
export function useMyOperations() {
const [creatingItems, setCreatingItems] = useState<Set<string>>(new Set());
const [updatingItems, setUpdatingItems] = useState<Set<string>>(new Set());
const [deletingItems, setDeletingItems] = useState<Set<string>>(new Set());
const { request } = useApiRequest();
const handleCreate = async (itemData: Partial<MyDataItem>) => {
setCreatingItems(prev => new Set(prev).add('new'));
try {
await request({
url: '/api/mydata',
method: 'post',
data: itemData
});
return true;
} catch (error) {
console.error('Create failed:', error);
return false;
} finally {
setCreatingItems(prev => {
const newSet = new Set(prev);
newSet.delete('new');
return newSet;
});
}
};
const handleUpdate = async (itemId: string, updateData: Partial<MyDataItem>) => {
setUpdatingItems(prev => new Set(prev).add(itemId));
try {
await request({
url: `/api/mydata/${itemId}`,
method: 'put',
data: updateData
});
return { success: true };
} catch (error) {
console.error('Update failed:', error);
return { success: false };
} finally {
setUpdatingItems(prev => {
const newSet = new Set(prev);
newSet.delete(itemId);
return newSet;
});
}
};
const handleDelete = async (itemId: string) => {
setDeletingItems(prev => new Set(prev).add(itemId));
try {
await request({
url: `/api/mydata/${itemId}`,
method: 'delete'
});
return true;
} catch (error) {
console.error('Delete failed:', error);
return false;
} finally {
setDeletingItems(prev => {
const newSet = new Set(prev);
newSet.delete(itemId);
return newSet;
});
}
};
return {
handleCreate,
handleUpdate,
handleDelete,
creatingItems,
updatingItems,
deletingItems
};
}
Step 4: Navigate to Your Page
The page is now available at /my-page and will appear in the sidebar if showInSidebar: true.
Advanced Features
Adding Subpages
export const parentPageData: GenericPageData = {
id: 'parent',
path: 'parent',
name: 'Parent',
hasSubpages: true,
subpagePrivilegeChecker: privilegeCheckers.adminRole,
showInSidebar: true
};
export const subpageData: GenericPageData = {
id: 'parent-subpage',
path: 'parent/subpage',
name: 'Subpage',
parentPath: 'parent', // Links to parent
showInSidebar: false // Shown under parent in sidebar
};
Custom Upload Handler
If your page needs file upload:
const createMyPageHook = () => {
return () => {
const { data, refetch } = useMyData();
// Memoized upload function
const handleUpload = useCallback(async (file: File) => {
try {
const formData = new FormData();
formData.append('file', file);
const headers = addCSRFTokenToHeaders();
const response = await api.post('/api/mydata/upload', formData, {
headers: { ...headers }
});
refetch(); // Refresh data
return { success: true, data: response.data };
} catch (error: any) {
throw new Error(error.message);
}
}, [refetch]);
return {
data,
handleUpload, // Add to return object
// ... other operations
};
};
};
// In page config
headerButtons: [
{
id: 'upload-file',
label: 'Upload File',
icon: FaUpload,
variant: 'primary',
onClick: () => {} // PageRenderer will detect and render UploadComponent
}
]
Custom Action Buttons
Add custom actions beyond the standard view/edit/delete:
actionButtons: [
{
type: 'download', // Standard type
title: 'Download',
idField: 'id',
nameField: 'name',
operationName: 'handleDownload',
loadingStateName: 'downloadingItems'
}
]
// In your operations hook
const handleDownload = async (itemId: string, itemName: string) => {
setDownloadingItems(prev => new Set(prev).add(itemId));
try {
const blob = await request({
url: `/api/mydata/${itemId}/download`,
method: 'get',
additionalConfig: { responseType: 'blob' }
});
// Trigger download
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = itemName;
link.click();
window.URL.revokeObjectURL(url);
return true;
} catch (error) {
console.error('Download failed:', error);
return false;
} finally {
setDownloadingItems(prev => {
const newSet = new Set(prev);
newSet.delete(itemId);
return newSet;
});
}
};
Optimistic Updates
Implement instant UI feedback:
export function useMyData() {
const [data, setData] = useState<MyDataItem[]>([]);
// Optimistic removal
const removeOptimistically = (itemId: string) => {
setData(prevData => prevData.filter(item => item.id !== itemId));
};
// Optimistic addition
const addOptimistically = (newItem: MyDataItem) => {
setData(prevData => [newItem, ...prevData]);
};
return {
data,
removeOptimistically,
addOptimistically,
// ... other properties
};
}
// In hook factory
return {
data,
removeOptimistically,
addOptimistically,
// ... other properties
};
// In delete operation
const handleDelete = async (itemId: string, onOptimisticDelete?: () => void) => {
// Call optimistic removal immediately
if (onOptimisticDelete) {
onOptimisticDelete();
}
try {
await request({ url: `/api/mydata/${itemId}`, method: 'delete' });
return true;
} catch (error) {
// On failure, refetch to restore data
return false;
}
};
Custom Page Component
For complex pages that need custom UI beyond tables:
import React from 'react';
export const MyCustomPage: React.FC = () => {
return (
<div>
<h1>Custom Page Content</h1>
{/* Your custom UI here */}
</div>
);
};
// In page config
export const myPageData: GenericPageData = {
// ... other config
customComponent: MyCustomPage, // PageRenderer will render this instead
};
Edit Field Configuration
Customize edit form fields:
actionButtons: [
{
type: 'edit',
title: 'Edit item',
idField: 'id',
operationName: 'handleUpdate',
loadingStateName: 'updatingItems',
editFields: [
{
key: 'name',
label: 'Name',
type: 'string',
editable: true,
required: true,
validator: (value: string) => {
if (value.length < 3) return 'Name must be at least 3 characters';
return null;
}
},
{
key: 'status',
label: 'Status',
type: 'enum',
editable: true,
required: true,
options: ['Active', 'Inactive']
},
{
key: 'description',
label: 'Description',
type: 'textarea',
editable: true,
minRows: 4,
maxRows: 8
},
{
key: 'created_at',
label: 'Created',
type: 'readonly',
editable: false,
formatter: (value) => new Date(value).toLocaleDateString()
}
]
}
]
Column Types & Configuration
Available Column Types
type: 'string' | 'number' | 'date' | 'boolean' | 'enum'
Column Properties
{
key: string; // Data field name
label: string; // Column header label
type?: string; // Data type (affects formatting & filtering)
width?: number; // Default width in pixels
minWidth?: number; // Minimum width when resizing
maxWidth?: number; // Maximum width when resizing
sortable?: boolean; // Enable sorting
filterable?: boolean; // Enable filtering
searchable?: boolean; // Include in global search
filterOptions?: string[]; // Options for enum filter dropdown
formatter?: (value: any, row: any) => React.ReactNode; // Custom display
cellClassName?: (value: any, row: any) => string; // Custom cell CSS
}
Custom Formatters
{
key: 'price',
label: 'Price',
type: 'number',
formatter: (value) => `$${value.toFixed(2)}`
},
{
key: 'status',
label: 'Status',
type: 'string',
formatter: (value) => (
<span className={`badge badge-${value.toLowerCase()}`}>
{value}
</span>
)
},
{
key: 'date',
label: 'Date',
type: 'date',
formatter: (value) => new Date(value).toLocaleDateString('de-DE')
}
Action Button Types
Built-in Action Types
| Type | Purpose | Required Props | Optional Props |
|---|---|---|---|
view |
Preview/view item | idField, operationName |
nameField, typeField, loadingStateName |
edit |
Edit item | idField, operationName |
editFields, loadingStateName |
download |
Download item | idField, operationName |
nameField, loadingStateName |
delete |
Delete item | idField, operationName |
loadingStateName |
Action Button Configuration
{
type: 'view' | 'edit' | 'download' | 'delete';
title?: string; // Tooltip text
idField?: string; // Row field for ID (default: 'id')
nameField?: string; // Row field for name (default: 'name')
typeField?: string; // Row field for type (default: 'type')
operationName?: string; // hookData operation name
loadingStateName?: string; // hookData loading state name
onAction?: (row: any) => void; // Optional callback
disabled?: (row: any) => boolean | { disabled: boolean; message?: string }; // Conditional disable with tooltip
editFields?: EditFieldConfig[]; // For edit button
}
Best Practices
✅ Do
- Memoize functions in hooks using
useCallback([dependencies]) - Use per-item loading states with
Set<string>for better UX - Implement optimistic updates for delete operations
- Validate hookData operations in action buttons (throw if missing)
- Keep hook factory simple - just call hooks and return data
- Use clear naming -
handleXyzfor operations,xyzingItemsfor loading states - Add proper TypeScript types for your data interfaces
- Clear API cache when refetching:
clearCache(url, method) - Use disabled buttons with tooltips - provide helpful messages explaining why buttons are disabled
- Test disabled states - ensure buttons are properly disabled and tooltips show correctly
❌ Don't
- Don't call hooks conditionally or in loops
- Don't create fallback hooks in action buttons (use hookData)
- Don't forget to add operations to hook factory return statement
- Don't mutate hookData - it's a shared reference
- Don't forget refetch after create/update/delete operations
- Don't skip operationName/loadingStateName in button config
- Don't make hookData optional in action buttons (require it)
Common Patterns
Pattern: Create New Item
// Header button
headerButtons: [
{
id: 'create-new',
label: 'Create New',
icon: FaPlus,
variant: 'primary',
onClick: (hookData) => {
// Open create dialog
// Call hookData.handleCreate()
// Call hookData.refetch()
}
}
]
Pattern: Bulk Operations
// In FormGenerator props
onDeleteMultiple: (rows: MyDataItem[]) => {
// Delete multiple selected items
Promise.all(rows.map(row => hookData.handleDelete(row.id)))
.then(() => hookData.refetch());
}
Pattern: Conditional Action Buttons
actionButtons: [
{
type: 'delete',
disabled: (row) => row.status === 'Protected',
title: (row) => row.status === 'Protected'
? 'Cannot delete protected item'
: 'Delete item'
}
]
Pattern: Disabled Buttons with Tooltips
actionButtons: [
{
type: 'edit',
title: 'Edit file',
operationName: 'handleUpdate',
loadingStateName: 'updatingItems',
// Disable with custom tooltip message
disabled: (file) => {
if (file.file_name.startsWith('.')) {
return {
disabled: true,
message: 'Cannot edit system files'
};
}
return false;
}
},
{
type: 'download',
title: 'Download file',
operationName: 'handleDownload',
loadingStateName: 'downloadingItems',
// Disable for large files with size info
disabled: (file) => {
if (file.file_size > 100 * 1024 * 1024) { // 100MB
return {
disabled: true,
message: `File too large to download (${Math.round(file.file_size / 1024 / 1024)}MB)`
};
}
return false;
}
},
{
type: 'delete',
title: 'Delete file',
operationName: 'handleDelete',
loadingStateName: 'deletingItems',
// Simple boolean disable (no custom message)
disabled: (file) => file.is_protected
}
]
Pattern: Custom Loading Indicator
// In page content
{
type: 'custom',
customComponent: () => {
const hookData = useTableData(); // Access hook data
return (
<div>
{hookData.loading && <div>Loading...</div>}
{hookData.error && <div>Error: {hookData.error}</div>}
</div>
);
}
}
Troubleshooting
Issue: "hookData.X is not defined"
Solution: Add the operation to your hook factory's return statement.
Issue: Duplicate hook calls
Solution: Remove any fallback hooks in action buttons. Make hookData required.
Issue: Table not updating after operation
Solution: Call refetch() after create/update/delete operations.
Issue: Loading state not working
Solution:
- Ensure loading state is returned from hook factory
- Add
loadingStateNameto button config - Use
Set<string>for per-item tracking
Issue: Edit form not opening
Solution:
- Add
handleFileUpdate(or your operation) to hook factory - Add
operationName: 'handleFileUpdate'to button config - Optionally add
editFieldsfor custom form fields
Example: Complete Minimal Page
// src/core/PageManager/data/pages/simple.ts
import { GenericPageData } from '../../pageInterface';
import { FaList } from 'react-icons/fa';
import { useState, useEffect, useCallback } from 'react';
import { useApiRequest } from '../../../../hooks/useApi';
const createSimpleHook = () => {
return () => {
const [data, setData] = useState([]);
const { request, isLoading: loading, error } = useApiRequest();
const fetchData = useCallback(async () => {
const result = await request({ url: '/api/items', method: 'get' });
setData(result || []);
}, [request]);
useEffect(() => { fetchData(); }, [fetchData]);
return { data, loading, error, refetch: fetchData };
};
};
export const simplePageData: GenericPageData = {
id: 'simple',
path: 'simple',
name: 'Simple Page',
icon: FaList,
title: 'Simple Page',
content: [{
type: 'table',
tableConfig: {
hookFactory: createSimpleHook,
columns: [
{ key: 'name', label: 'Name', type: 'string', sortable: true }
],
actionButtons: []
}
}],
moduleEnabled: true
};
Summary
Creating a new page requires:
- ✅ Create page definition file with hook factory
- ✅ Register page in
data/index.ts - ✅ Create data hooks (useMyData, useMyOperations)
- ✅ Define columns and action buttons
- ✅ Navigate to
/your-page-path
The system handles routing, rendering, state management, and action buttons automatically. Focus on your data hooks and page configuration! 🚀