frontend_nyla/docs/USAGE_GUIDE_PAGES.md

869 lines
23 KiB
Markdown

# 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`):
```typescript
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`:
```typescript
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`:
```typescript
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
```typescript
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:
```typescript
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:
```typescript
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:
```typescript
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:
```typescript
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:
```typescript
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
```typescript
type: 'string' | 'number' | 'date' | 'boolean' | 'enum'
```
### Column Properties
```typescript
{
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
```typescript
{
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
```typescript
{
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
1. **Memoize functions in hooks** using `useCallback([dependencies])`
2. **Use per-item loading states** with `Set<string>` for better UX
3. **Implement optimistic updates** for delete operations
4. **Validate hookData operations** in action buttons (throw if missing)
5. **Keep hook factory simple** - just call hooks and return data
6. **Use clear naming** - `handleXyz` for operations, `xyzingItems` for loading states
7. **Add proper TypeScript types** for your data interfaces
8. **Clear API cache** when refetching: `clearCache(url, method)`
9. **Use disabled buttons with tooltips** - provide helpful messages explaining why buttons are disabled
10. **Test disabled states** - ensure buttons are properly disabled and tooltips show correctly
### ❌ Don't
1. **Don't call hooks conditionally** or in loops
2. **Don't create fallback hooks** in action buttons (use hookData)
3. **Don't forget to add operations** to hook factory return statement
4. **Don't mutate hookData** - it's a shared reference
5. **Don't forget refetch** after create/update/delete operations
6. **Don't skip operationName/loadingStateName** in button config
7. **Don't make hookData optional** in action buttons (require it)
---
## Common Patterns
### Pattern: Create New Item
```typescript
// 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
```typescript
// 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
```typescript
actionButtons: [
{
type: 'delete',
disabled: (row) => row.status === 'Protected',
title: (row) => row.status === 'Protected'
? 'Cannot delete protected item'
: 'Delete item'
}
]
```
### Pattern: Disabled Buttons with Tooltips
```typescript
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
```typescript
// 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**:
1. Ensure loading state is returned from hook factory
2. Add `loadingStateName` to button config
3. Use `Set<string>` for per-item tracking
### Issue: Edit form not opening
**Solution**:
1. Add `handleFileUpdate` (or your operation) to hook factory
2. Add `operationName: 'handleFileUpdate'` to button config
3. Optionally add `editFields` for custom form fields
---
## Example: Complete Minimal Page
```typescript
// 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:
1. ✅ Create page definition file with hook factory
2. ✅ Register page in `data/index.ts`
3. ✅ Create data hooks (useMyData, useMyOperations)
4. ✅ Define columns and action buttons
5. ✅ Navigate to `/your-page-path`
The system handles routing, rendering, state management, and action buttons automatically. Focus on your data hooks and page configuration! 🚀