resumed backend integration, RBAC focus

This commit is contained in:
Ida Dittrich 2025-12-15 07:32:06 +01:00
parent aa34508be4
commit aaf64b869f
136 changed files with 11391 additions and 9393 deletions

View file

@ -1,623 +0,0 @@
# API Routes Documentation
## Overview
This documentation covers the Chat Playground and Workflow management API endpoints. These routes provide functionality for managing AI workflows, chat interactions, messages, logs, and related data.
## Authentication
All endpoints require authentication via the `getCurrentUser` dependency, which validates the user's session/token. Rate limiting is applied at 120 requests per minute for all endpoints.
---
## Chat Playground Routes
Base path: `/api/chat/playground`
### 1. Start Workflow
**Endpoint:** `POST /api/chat/playground/start`
**Description:** Starts a new workflow or continues an existing one based on the provided input.
**Query Parameters:**
- `workflowId` (optional, string): ID of an existing workflow to continue
- `workflowMode` (string, default: "Actionplan"): Workflow execution mode
- `"Actionplan"`: Traditional task planning workflow
- `"React"`: Iterative react-style processing
**Request Body:** `UserInputRequest` object containing:
```json
{
"input": "user's input text",
"files": [...], // optional file attachments
"metadata": {...} // optional metadata
}
```
**Response:** `ChatWorkflow` object representing the created or continued workflow
**Example Request:**
```bash
POST /api/chat/playground/start?workflowMode=Actionplan
Content-Type: application/json
{
"input": "Analyze the quarterly sales data",
"files": [...],
"metadata": {}
}
```
**Example Response:**
```json
{
"id": "wf_123456",
"userId": "user_abc",
"status": "running",
"mode": "Actionplan",
"messageIds": [...],
"logIds": [...],
"createdAt": 1234567890.0,
...
}
```
**Use Cases:**
- **User:** Initiating a new AI conversation or task
- **AI:** Starting automated workflow processing for scheduled tasks
- **Integration:** Continuing an interrupted workflow from a previous session
---
### 2. Stop Workflow
**Endpoint:** `POST /api/chat/playground/{workflowId}/stop`
**Description:** Stops a running workflow, allowing graceful termination of ongoing processes.
**Path Parameters:**
- `workflowId` (required, string): ID of the workflow to stop
**Response:** `ChatWorkflow` object with updated status
**Example Request:**
```bash
POST /api/chat/playground/wf_123456/stop
```
**Example Response:**
```json
{
"id": "wf_123456",
"status": "stopped",
"stoppedAt": 1234567890.0,
...
}
```
**Use Cases:**
- **User:** Manually stopping a long-running task
- **AI:** Implementing timeout mechanisms
- **System:** Cleanup of abandoned workflows
---
### 3. Get Unified Chat Data
**Endpoint:** `GET /api/chat/playground/{workflowId}/chatData`
**Description:** Retrieves unified chat data including messages, logs, and statistics in chronological order. Supports incremental data fetching.
**Path Parameters:**
- `workflowId` (required, string): ID of the workflow
**Query Parameters:**
- `afterTimestamp` (optional, float): Unix timestamp to fetch only data created after this time
**Response:** Dictionary containing:
- `messages`: Array of chat messages
- `logs`: Array of log entries
- `stats`: Array of statistics
- `documents`: Array of attached documents
- All sorted by `_createdAt` timestamp
**Example Request:**
```bash
GET /api/chat/playground/wf_123456/chatData?afterTimestamp=1234567800.0
```
**Example Response:**
```json
{
"messages": [
{
"id": "msg_001",
"content": "Hello",
"_createdAt": 1234567890.0,
...
}
],
"logs": [
{
"id": "log_001",
"level": "info",
"message": "Processing started",
"_createdAt": 1234567891.0,
...
}
],
"stats": [...],
"documents": [...]
}
```
**Use Cases:**
- **User:** Real-time chat UI updates and polling for new data
- **AI:** Monitoring workflow progress and synchronizing state
- **Mobile App:** Efficient incremental data synchronization
---
## Workflow Routes
Base path: `/api/workflows`
### 1. List All Workflows
**Endpoint:** `GET /api/workflows/`
**Description:** Retrieves all workflows for the currently authenticated user.
**Response:** Array of `ChatWorkflow` objects
**Example Request:**
```bash
GET /api/workflows/
```
**Example Response:**
```json
[
{
"id": "wf_001",
"userId": "user_abc",
"status": "completed",
"createdAt": 1234567890.0,
...
},
{
"id": "wf_002",
"userId": "user_abc",
"status": "running",
"createdAt": 1234567990.0,
...
}
]
```
**Use Cases:**
- **User:** Dashboard showing all their workflows
- **AI:** Analyzing user's workflow history and patterns
- **Admin:** User activity monitoring
---
### 2. Get Workflow by ID
**Endpoint:** `GET /api/workflows/{workflowId}`
**Description:** Retrieves detailed information about a specific workflow.
**Path Parameters:**
- `workflowId` (required, string): ID of the workflow
**Response:** `ChatWorkflow` object
**Example Request:**
```bash
GET /api/workflows/wf_123456
```
**Example Response:**
```json
{
"id": "wf_123456",
"userId": "user_abc",
"status": "running",
"mode": "Actionplan",
"messageIds": ["msg_001", "msg_002"],
"logIds": ["log_001", "log_002"],
"createdAt": 1234567890.0,
"updatedAt": 1234567990.0,
...
}
```
**Use Cases:**
- **User:** Viewing specific workflow details
- **AI:** Loading workflow state for processing
- **Debugging:** Investigating workflow behavior
---
### 3. Update Workflow
**Endpoint:** `PUT /api/workflows/{workflowId}`
**Description:** Updates workflow properties. Requires ownership or modification permissions.
**Path Parameters:**
- `workflowId` (required, string): ID of the workflow to update
**Request Body:** Dictionary with fields to update:
```json
{
"name": "Updated workflow name",
"description": "Updated description",
"tags": ["tag1", "tag2"],
...
}
```
**Response:** Updated `ChatWorkflow` object
**Example Request:**
```bash
PUT /api/workflows/wf_123456
Content-Type: application/json
{
"name": "Sales Analysis Q4",
"tags": ["sales", "analysis"]
}
```
**Example Response:**
```json
{
"id": "wf_123456",
"name": "Sales Analysis Q4",
"tags": ["sales", "analysis"],
...
}
```
**Use Cases:**
- **User:** Renaming workflows for better organization
- **User:** Adding tags and metadata
- **AI:** Programmatically updating workflow metadata
---
### 4. Get Workflow Status
**Endpoint:** `GET /api/workflows/{workflowId}/status`
**Description:** Gets the current status of a workflow without loading all associated data.
**Path Parameters:**
- `workflowId` (required, string): ID of the workflow
**Response:** `ChatWorkflow` object (lightweight status check)
**Example Request:**
```bash
GET /api/workflows/wf_123456/status
```
**Use Response:**
```json
{
"id": "wf_123456",
"status": "running",
"currentStep": "analyzing",
"progress": 45,
"updatedAt": 1234567990.0
}
```
**Use Cases:**
- **User:** Quick status checks without loading full data
- **AI:** Monitoring workflow progress
- **Mobile:** Efficient polling for status updates
---
### 5. Get Workflow Logs
**Endpoint:** `GET /api/workflows/{workflowId}/logs`
**Description:** Retrieves logs for a workflow with support for selective data transfer.
**Path Parameters:**
- `workflowId` (required, string): ID of the workflow
**Query Parameters:**
- `logId` (optional, string): Log ID to fetch only newer logs (selective data transfer)
**Response:** Array of `ChatLog` objects
**Example Request (All Logs):**
```bash
GET /api/workflows/wf_123456/logs
```
**Example Request (Incremental):**
```bash
GET /api/workflows/wf_123456/logs?logId=log_050
```
**Example Response:**
```json
[
{
"id": "log_001",
"workflowId": "wf_123456",
"level": "info",
"message": "Workflow started",
"timestamp": 1234567890.0,
...
},
{
"id": "log_002",
"workflowId": "wf_123456",
"level": "warning",
"message": "Slow response detected",
"timestamp": 1234567900.0,
...
}
]
```
**Use Cases:**
- **User:** Viewing detailed execution logs for debugging
- **AI:** Error analysis and troubleshooting
- **System:** Audit trail and compliance
---
### 6. Get Workflow Messages
**Endpoint:** `GET /api/workflows/{workflowId}/messages`
**Description:** Retrieves messages for a workflow with support for selective data transfer.
**Path Parameters:**
- `workflowId` (required, string): ID of the workflow
**Query Parameters:**
- `messageId` (optional, string): Message ID to fetch only newer messages
**Response:** Array of `ChatMessage` objects
**Example Request (All Messages):**
```bash
GET /api/workflows/wf_123456/messages
```
**Example Request (Incremental):**
```bash
GET /api/workflows/wf_123456/messages?messageId=msg_100
```
**Example Response:**
```json
[
{
"id": "msg_001",
"workflowId": "wf_123456",
"role": "user",
"content": "Analyze the data",
"timestamp": 1234567890.0,
"files": [...],
...
},
{
"id": "msg_002",
"workflowId": "wf_123456",
"role": "assistant",
"content": "Processing your request...",
"timestamp": 1234567900.0,
...
}
]
```
**Use Cases:**
- **User:** Viewing conversation history
- **AI:** Context building for responses
- **UI:** Chat interface message display
---
### 7. Delete Workflow
**Endpoint:** `DELETE /api/workflows/{workflowId}`
**Description:** Deletes a workflow and all its associated data (messages, logs, etc.). Requires ownership or deletion permissions.
**Path Parameters:**
- `workflowId` (required, string): ID of the workflow to delete
**Response:** Dictionary with deletion confirmation
**Example Request:**
```bash
DELETE /api/workflows/wf_123456
```
**Example Response:**
```json
{
"id": "wf_123456",
"message": "Workflow and associated data deleted successfully"
}
```
**Use Cases:**
- **User:** Cleanup of old or unnecessary workflows
- **AI:** Automated cleanup of expired workflows
- **Admin:** Data management and compliance
---
### 8. Delete Workflow Message
**Endpoint:** `DELETE /api/workflows/{workflowId}/messages/{messageId}`
**Description:** Deletes a specific message from a workflow.
**Path Parameters:**
- `workflowId` (required, string): ID of the workflow
- `messageId` (required, string): ID of the message to delete
**Response:** Dictionary with deletion confirmation
**Example Request:**
```bash
DELETE /api/workflows/wf_123456/messages/msg_050
```
**Example Response:**
```json
{
"workflowId": "wf_123456",
"messageId": "msg_050",
"message": "Message deleted successfully"
}
```
**Use Cases:**
- **User:** Removing sensitive or incorrect messages
- **AI:** Content moderation
- **User:** Editing conversation history
---
### 9. Delete File from Message
**Endpoint:** `DELETE /api/workflows/{workflowId}/messages/{messageId}/files/{fileId}`
**Description:** Deletes a file reference from a message within a workflow.
**Path Parameters:**
- `workflowId` (required, string): ID of the workflow
- `messageId` (required, string): ID of the message
- `fileId` (required, string): ID of the file to delete
**Response:** Dictionary with deletion confirmation
**Example Request:**
```bash
DELETE /api/workflows/wf_123456/messages/msg_050/files/file_123
```
**Example Response:**
```json
{
"workflowId": "wf_123456",
"messageId": "msg_050",
"fileId": "file_123",
"message": "File reference deleted successfully"
}
```
**Use Cases:**
- **User:** Removing attached files for privacy
- **User:** Fixing incorrect file uploads
- **System:** Managing storage quotas
---
## Error Handling
All endpoints may return the following HTTP status codes:
- `200 OK`: Successful request
- `400 Bad Request`: Invalid request parameters
- `401 Unauthorized`: Authentication required
- `403 Forbidden`: Insufficient permissions
- `404 Not Found`: Resource not found
- `500 Internal Server Error`: Server error
**Example Error Response:**
```json
{
"detail": "Error message describing what went wrong"
}
```
---
## Best Practices
### For Users:
1. **Polling Strategy**: Use the `afterTimestamp` or `logId`/`messageId` parameters to implement efficient polling without redundant data transfer
2. **Error Handling**: Always implement proper error handling and user feedback
3. **Rate Limiting**: Be aware of the 120 requests/minute limit and implement exponential backoff
4. **Cleanup**: Regularly delete old workflows to manage storage
### For AI/Automation:
1. **Incremental Updates**: Always use selective data transfer parameters to minimize bandwidth
2. **Status Checks**: Use lightweight endpoints like `/status` for monitoring
3. **Timeout Handling**: Implement proper stop logic for long-running workflows
4. **Logging**: Monitor logs for debugging and performance analysis
### For Developers:
1. **Caching**: Cache workflow status to reduce API calls
2. **Webhooks**: Consider implementing webhooks for real-time updates instead of polling
3. **Batch Operations**: Group related operations to reduce network overhead
4. **Error Recovery**: Implement retry logic with exponential backoff
---
## Integration Examples
### Starting a Workflow with File Upload
```bash
# Start workflow
POST /api/chat/playground/start?workflowMode=Actionplan
{
"input": "Analyze this report",
"files": [{"id": "file_001", "name": "report.pdf"}]
}
# Poll for updates
GET /api/chat/playground/wf_123456/chatData
# Continue polling with incremental updates
GET /api/chat/playground/wf_123456/chatData?afterTimestamp=1234567890.0
```
### Monitoring Workflow Progress
```bash
# Start workflow
POST /api/chat/playground/start
# Check status periodically
GET /api/workflows/wf_123456/status
# Get detailed logs if status indicates issues
GET /api/workflows/wf_123456/logs
# Stop if needed
POST /api/chat/playground/wf_123456/stop
```
### Cleanup Old Workflows
```bash
# List all workflows
GET /api/workflows/
# Filter for old/completed workflows and delete
DELETE /api/workflows/wf_old_123
DELETE /api/workflows/wf_old_124
```

View file

@ -1,362 +0,0 @@
# Language Architecture - Single Source of Truth
## ✅ Correct Architecture (Current)
### Single Source of Truth
```
User Profile in Database → localStorage('currentUser').language → UI
```
**There is NO separate `localStorage.language` storage!**
---
## 📊 Data Flow
### 1. On Login
```
User logs in
Backend authenticates
GET /api/*/me returns User object
{
username: "user@example.com",
privilege: "admin",
language: "de", ← Language is part of user data
...
}
Store ONCE in localStorage:
localStorage.setItem('currentUser', JSON.stringify(userData))
LanguageContext reads: currentUser.language
UI displays in correct language ✅
```
### 2. When User Changes Language
```
User selects new language in settings
Settings component updates backend:
PUT /api/users/{id} with { language: "fr" }
Backend returns updated user object
Update localStorage('currentUser') with new data ✅
localStorage.setItem('currentUser', JSON.stringify(updatedUser))
Call setLanguage(newLanguage)
LanguageContext loads new translations
Trigger 'userInfoUpdated' event
All components sync with new language ✅
```
### 3. On Page Load/Refresh
```
App initializes
LanguageContext checks:
1. localStorage('currentUser').language ← Primary source
2. Browser language (navigator.language) ← Fallback if no user data
Load translations for selected language
UI displays in correct language ✅
```
---
## 🎯 Priority System
### Language Resolution Order:
```typescript
Priority 1: currentUser.language ← From database (logged-in users)
Priority 2: Browser language ← Fallback (before login or no user data)
Priority 3: Default 'de' ← Ultimate fallback
```
### Why No `localStorage.language`?
**Before (Wrong):**
```typescript
// ❌ Multiple sources of truth - can get out of sync!
localStorage.setItem('language', 'fr'); // UI preference
localStorage.setItem('currentUser', { language: 'de' }); // Backend data
// ^ Which one is correct? 🤔
```
**After (Correct):**
```typescript
// ✅ Single source of truth - always in sync!
localStorage.setItem('currentUser', { language: 'fr' }); // ONLY source
// ^ Always matches backend! 🎯
```
---
## 💻 Code Implementation
### LanguageContext.tsx
```typescript
// On mount: Read from currentUser.language
useEffect(() => {
const currentUserData = localStorage.getItem('currentUser');
if (currentUserData) {
const userData = JSON.parse(currentUserData);
if (userData.language) {
initialLanguage = userData.language; // ✅ From user profile
}
} else {
// Fallback to browser language if no user data
initialLanguage = navigator.language;
}
loadAndSetLanguage(initialLanguage);
}, []);
// When user updates language
const setLanguage = async (language: Language) => {
await loadAndSetLanguage(language);
// Note: This should ONLY be called AFTER:
// 1. Backend is updated
// 2. localStorage('currentUser') is updated
// The settings component handles this flow
};
```
### settingsUser.tsx
```typescript
const handleSaveUserInfo = async () => {
// 1. Update backend
const updatedUser = await updateUser(user.id, {
...userData,
language: newLanguage
});
// 2. Update localStorage (single source of truth!)
localStorage.setItem('currentUser', JSON.stringify(updatedUser));
// 3. Update UI language
if (newLanguage !== currentLanguage) {
await setLanguage(newLanguage);
}
// 4. Notify other components
window.dispatchEvent(new CustomEvent('userInfoUpdated'));
};
```
### useAuthentication.ts
```typescript
// On login: Fetch and cache user data
const userResponse = await api.get('/api/local/me');
if (userResponse.data) {
// Store user data ONCE (includes language)
localStorage.setItem('currentUser', JSON.stringify(userResponse.data));
// ✅ No separate language storage!
}
```
---
## 🔄 Complete Flow Diagram
```
┌──────────────────────────────────────────────────────────┐
│ USER LOGS IN │
└────────────────────┬─────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ GET /api/*/me returns: │
│ { username, privilege, language: "de", ... } │
└────────────────────┬─────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ localStorage('currentUser') = userData │
│ ✅ Language is part of user data │
└────────────────────┬─────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ LanguageContext reads: currentUser.language │
│ Loads translations for 'de' │
└────────────────────┬─────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ UI displays in German ✅ │
└──────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ USER CHANGES LANGUAGE TO FRENCH │
└────────────────────┬─────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ PUT /api/users/{id} │
│ { language: "fr" } │
└────────────────────┬─────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ Backend returns: │
│ { username, privilege, language: "fr", ... } │
└────────────────────┬─────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ localStorage('currentUser') = updatedUserData │
│ ✅ Language updated in user data │
└────────────────────┬─────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ setLanguage('fr') called │
│ Loads French translations │
└────────────────────┬─────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ UI displays in French ✅ │
└──────────────────────────────────────────────────────────┘
```
---
## 🧪 Testing
### Test 1: Login with Different Languages
```bash
# User with language='de'
1. Log in
2. Check console: "🌍 Using language from user profile: de"
3. Check localStorage: currentUser.language === 'de'
4. Verify UI is in German ✅
# User with language='fr'
1. Log in
2. Check console: "🌍 Using language from user profile: fr"
3. Check localStorage: currentUser.language === 'fr'
4. Verify UI is in French ✅
```
### Test 2: Change Language in Settings
```bash
1. Log in with language='de'
2. Go to settings
3. Change language to 'fr'
4. Click Save
5. Check console:
- "✅ User update successful"
- "💾 Updated user data cached in localStorage"
- "🌍 Frontend language updated to: fr"
6. Check localStorage: currentUser.language === 'fr'
7. Verify UI immediately changes to French ✅
8. Refresh page
9. Verify UI is still in French ✅
```
### Test 3: Multiple Browser Tabs
```bash
1. Open app in two tabs
2. In Tab 1: Change language to 'fr'
3. In Tab 2: Reload page
4. Both tabs should display in French ✅
(Because both read from currentUser.language)
```
### Test 4: Before Login (No User Data)
```bash
1. Clear localStorage
2. Open app
3. Should use browser language as fallback
4. After login, should switch to user's profile language ✅
```
---
## 🎯 Benefits of Single Source of Truth
| Aspect | Before (Multiple Sources) | After (Single Source) |
|--------|--------------------------|----------------------|
| **Consistency** | ❌ Can get out of sync | ✅ Always in sync |
| **Simplicity** | ❌ Check multiple places | ✅ One place to check |
| **Reliability** | ❌ Which source is correct? | ✅ Always correct |
| **Maintenance** | ❌ Update multiple places | ✅ Update one place |
| **Debugging** | ❌ Hard to trace issues | ✅ Easy to trace |
---
## 📋 Key Points
1. **User language is part of user data** - stored in `localStorage('currentUser').language`
2. **No separate language storage** - eliminates redundancy and sync issues
3. **Backend is the source of truth** - frontend always syncs with backend
4. **Settings update flow:**
- Update backend → Receive updated user → Cache in localStorage → Update UI
5. **Language changes persist** - because they're stored in the user profile in the database
---
## 🚫 Anti-Patterns to Avoid
### ❌ Don't do this:
```typescript
// Don't store language separately
localStorage.setItem('language', 'fr');
// Don't read from separate storage
const lang = localStorage.getItem('language');
// Don't update UI before backend
setLanguage('fr'); // Then update backend
```
### ✅ Do this instead:
```typescript
// Update backend first
const updatedUser = await updateUser(id, { language: 'fr' });
// Cache the complete user data
localStorage.setItem('currentUser', JSON.stringify(updatedUser));
// Then update UI
setLanguage(updatedUser.language);
```
---
## 📁 Related Files
- `src/contexts/LanguageContext.tsx` - Language context implementation
- `src/components/settings/settingsUser.tsx` - User settings with language update
- `src/hooks/useAuthentication.ts` - Login flow with user data fetch
- `src/hooks/useUsers.ts` - User data management
---
## 🔄 Migration Notes
If you had existing code that used `localStorage.language`:
### Before:
```typescript
const lang = localStorage.getItem('language') || 'de';
```
### After:
```typescript
const currentUser = JSON.parse(localStorage.getItem('currentUser') || '{}');
const lang = currentUser.language || navigator.language || 'de';
```
All existing references should now use `currentUser.language` exclusively.

View file

@ -1,319 +0,0 @@
# Login and Privilege Flow Documentation
## Overview
This document describes the complete login flow, including user data fetching, privilege checking, and language synchronization.
## Updated Login Flow (Post-Fix)
### 1. Login Process
#### Local Authentication (`useAuth` in `useAuthentication.ts`)
```
User enters credentials → POST /api/local/login → Success
✅ Tokens stored in httpOnly cookies
✅ authenticationAuthority saved to localStorage
🔄 IMMEDIATE user data fetch: GET /api/local/me
✅ User data cached in localStorage ('currentUser')
- Includes: username, privilege, language, etc.
- Language is part of user data (NO separate storage!)
Navigate to Home page
```
#### Microsoft Authentication (`useMsalAuth` in `useAuthentication.ts`)
```
User clicks Microsoft login → Popup opens → Microsoft OAuth flow
✅ Tokens stored in httpOnly cookies
✅ authenticationAuthority saved to localStorage
⏳ Wait 500ms for cookie propagation
🔄 IMMEDIATE user data fetch: GET /api/msft/me
✅ User data cached in localStorage ('currentUser')
✅ Language setting synced to localStorage ('language')
Navigate to Home page
```
#### Google Authentication (`useGoogleAuth` in `useAuthentication.ts`)
```
User clicks Google login → Popup opens → Google OAuth flow
✅ Tokens stored in httpOnly cookies
✅ authenticationAuthority saved to localStorage
⏳ Wait 500ms for cookie propagation
🔄 IMMEDIATE user data fetch: GET /api/google/me
✅ User data cached in localStorage ('currentUser')
✅ Language setting synced to localStorage ('language')
Navigate to Home page
```
### 2. Home Page Load (`Home.tsx`)
```
Home page mounts
useCurrentUser() hook called
Checks localStorage for cached user data
If cached: Uses cached data (instant)
If not cached: Fetches from API (with loading state)
User data available
PageManager receives user data context
```
### 3. Language Synchronization (`LanguageContext.tsx`)
The language context now follows a priority system:
**Priority Order:**
1. **User profile language** (from `localStorage('currentUser').language` - synced from backend)
2. **Browser language** (from `navigator.language` - fallback if no user data)
**Language Loading:**
```
LanguageProvider mounts
Check currentUser in localStorage
If user.language exists: Use user.language ✅
Else: Use browser language (fallback)
Load translations for selected language
```
**Language Updates (Settings Flow):**
```
User changes language in settings
1. Update backend user profile (PUT /api/users/{id})
2. Backend returns updated user data
3. Update localStorage('currentUser') with new data ✅
4. Call setLanguage() to load new translations
5. Trigger 'userInfoUpdated' event
LanguageContext syncs and UI updates
```
### 4. Privilege Checking System
#### Where Privileges Are Checked:
**A. Page Level (`PageManager.tsx`)**
```typescript
// Line 29-40 in PageManager.tsx
const checkPageAccess = async (pageData: GenericPageData): Promise<boolean> => {
if (!pageData.privilegeChecker) {
return true; // No checker = accessible to all
}
try {
return await pageData.privilegeChecker();
} catch (error) {
console.error(`Error checking page access for ${pageData.path}:`, error);
return false;
}
};
```
**B. Privilege Checkers (`privilegeCheckers.ts`)**
All privilege checkers read from `localStorage.getItem('currentUser')`:
```typescript
const getCurrentUserPrivilege = (): string | null => {
try {
const userData = localStorage.getItem('currentUser');
if (userData) {
const user = JSON.parse(userData);
return user.privilege || null;
}
return null;
} catch (error) {
console.error('Error getting user privilege:', error);
return null;
}
};
```
**Available Privilege Checkers:**
- `privilegeCheckers.adminRole` - For admin and sysadmin users
- `privilegeCheckers.sysadminRole` - For sysadmin only
- `privilegeCheckers.userRole` - For user, admin, and sysadmin
- `privilegeCheckers.viewerRole` - For all authenticated users
- `privilegeCheckers.speechSignup` - For speech feature access
- `privilegeCheckers.alwaysAllow` - For public pages
- `privilegeCheckers.neverAllow` - For disabled features
#### Privilege Check Flow:
```
PageManager renders page
checkPageAccess(pageData)
pageData.privilegeChecker() called
Reads from localStorage('currentUser')
Checks user.privilege against required privileges
Returns true/false
If true: Page renders
If false: Error component shows
```
### 5. Complete Flow Diagram
```
┌─────────────────────────────────────────────────────────────┐
│ LOGIN │
│ (Local/Microsoft/Google) │
└──────────────────┬──────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ ✅ Set httpOnly cookies (backend) │
│ ✅ Save auth_authority to localStorage │
│ 🔄 IMMEDIATELY fetch user data: GET /api/*/me │
│ ✅ Cache user data in localStorage('currentUser') │
│ ✅ Sync language to localStorage('language') │
└──────────────────┬──────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Navigate to Home │
└──────────────────┬──────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Home.tsx Mounts │
│ - useCurrentUser() → Reads from localStorage (instant!) │
│ - LanguageProvider → Reads user.language (instant!) │
└──────────────────┬──────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ PageManager Renders │
│ - Gets currentLanguage from LanguageContext │
│ - Checks page privileges (reads from localStorage) │
│ - Passes language to PageRenderer │
└──────────────────┬──────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ PageRenderer Displays Page │
│ - Uses user's language for all text │
│ - All privilege checks use cached user data │
└─────────────────────────────────────────────────────────────┘
```
## Key Changes Made
### ✅ Fixed Issues:
1. **User data is now fetched IMMEDIATELY after login**
- Previously: Fetched only when Home.tsx mounted
- Now: Fetched right after successful authentication
- Location: `src/hooks/useAuthentication.ts` (lines 65-88 for local, 258-297 for Microsoft, 727-753 for Google)
2. **Language is synced from user profile**
- Previously: Loaded from localStorage or browser only
- Now: Prioritizes user.language from API response
- Location: `src/contexts/LanguageContext.tsx` (lines 42-108)
3. **Language is passed to PageRenderer**
- Previously: Default 'de' was used
- Now: Current language from context is passed
- Location: `src/core/PageManager/PageManager.tsx` (line 104)
4. **Privilege checks use cached user data**
- User data is available immediately in localStorage
- No race conditions between page load and user data fetch
- Location: `src/utils/privilegeCheckers.ts` (lines 4-21)
### 📝 Important Notes:
1. **OAuth Cookie Delay**: Microsoft and Google auth have a 500ms delay before fetching user data to ensure cookies are properly set by the browser.
2. **Error Handling**: If user data fetch fails after login, the user is still navigated to the home page, but will see a loading/error state there.
3. **Cache Strategy**: User data is cached in localStorage for instant access, but is also refreshed on each page load via `useCurrentUser()` hook.
4. **Language Updates**: When a user updates their language in settings, the system:
- Updates backend user profile
- Triggers 'userInfoUpdated' event
- LanguageContext listens and syncs the new language
- All components using `useLanguage()` automatically update
## API Endpoints Used
| Endpoint | Purpose | When Called |
|----------|---------|-------------|
| `POST /api/local/login` | Local authentication | User submits login form |
| `GET /api/local/me` | Get current user (local) | Immediately after local login + on Home.tsx mount |
| `GET /api/msft/me` | Get current user (Microsoft) | Immediately after Microsoft login + on Home.tsx mount |
| `GET /api/google/me` | Get current user (Google) | Immediately after Google login + on Home.tsx mount |
## Testing the Flow
To verify the flow is working correctly:
1. **Login Test:**
```
- Clear localStorage
- Log in with any method
- Check console for: "🔄 Fetching user data immediately after login..."
- Check console for: "✅ User data fetched and cached"
- Verify localStorage has 'currentUser' and 'language' keys
```
2. **Language Test:**
```
- Log in
- Check console for: "🌍 Using language from user data: [language]"
- Change language in settings
- Verify UI updates immediately
```
3. **Privilege Test:**
```
- Log in as user with different privilege levels
- Navigate to admin pages
- Verify access based on privilege
- Check console for: "🔍 Checking role privilege" logs
```
## Troubleshooting
### Issue: Pages show "Access denied" after login
**Solution:** Check if user data is properly cached in localStorage. Look for console errors in user data fetch.
### Issue: Wrong language is displayed
**Solution:** Verify that user.language exists in the API response. Check browser console for language loading logs.
### Issue: OAuth login doesn't fetch user data
**Solution:** Check if the 500ms delay is sufficient for your environment. Increase delay if needed in `useAuthentication.ts`.
## Related Files
- `src/hooks/useAuthentication.ts` - Login logic and immediate user fetch
- `src/hooks/useUsers.ts` - User data management
- `src/contexts/LanguageContext.tsx` - Language management
- `src/core/PageManager/PageManager.tsx` - Page routing and privilege checking
- `src/core/PageManager/PageRenderer.tsx` - Page rendering with language
- `src/utils/privilegeCheckers.ts` - Privilege checking utilities
- `src/pages/Home/Home.tsx` - Main application entry after login

View file

@ -1,321 +0,0 @@
# Login Flow: Before vs After Comparison
## ❌ BEFORE (Issues)
```
1. User logs in
2. Login successful
✅ Tokens set in httpOnly cookies
✅ auth_authority saved
❌ No user data fetched
3. Navigate to Home.tsx
4. Home.tsx mounts
5. useCurrentUser() starts fetching ⏰ (Race condition!)
6. PageManager tries to render
❌ Privilege checks fail (no user data yet!)
❌ Language defaults to 'de' (not from user profile)
7. Eventually user data arrives
✅ Pages render with correct privileges
❌ But language is still wrong!
```
### Problems:
- ⚠️ **Race Condition**: Pages try to render before user data is available
- ⚠️ **Wrong Language**: Language comes from localStorage, not user profile
- ⚠️ **Delayed Privilege Checks**: Initial page load might show wrong content
- ⚠️ **Poor UX**: User sees loading state or errors on first page load
---
## ✅ AFTER (Fixed)
```
1. User logs in
2. Login successful
✅ Tokens set in httpOnly cookies
✅ auth_authority saved
3. 🔄 IMMEDIATELY fetch user data
→ GET /api/local/me (or /api/msft/me or /api/google/me)
4. User data received
✅ Cache in localStorage('currentUser')
✅ Language is part of user data (NO separate storage)
5. Navigate to Home.tsx
6. Home.tsx mounts
7. useCurrentUser() reads from cache
✅ Instant user data (no loading!)
8. LanguageContext initializes
✅ Uses user.language from cached data
9. PageManager renders
✅ Privilege checks work (data available!)
✅ Correct language passed to PageRenderer
10. Pages render perfectly
✅ Correct language
✅ Correct privileges
✅ No loading delays
```
### Benefits:
- ✅ **No Race Condition**: User data available before page render
- ✅ **Correct Language**: Language comes from user profile
- ✅ **Instant Privilege Checks**: All checks work immediately
- ✅ **Better UX**: Smooth transition from login to app
---
## Code Changes Summary
### 1. `useAuthentication.ts` - Immediate User Fetch
**Local Login:**
```typescript
// BEFORE: Just returned after setting auth_authority
if (response.data.type === 'local_auth_success') {
localStorage.setItem('auth_authority', response.data.authenticationAuthority);
return response.data;
}
// AFTER: Fetch user data immediately
if (response.data.type === 'local_auth_success') {
localStorage.setItem('auth_authority', response.data.authenticationAuthority);
// CRITICAL: Immediately fetch user data
try {
const userResponse = await api.get('/api/local/me');
if (userResponse.data) {
localStorage.setItem('currentUser', JSON.stringify(userResponse.data));
if (userResponse.data.language) {
localStorage.setItem('language', userResponse.data.language);
}
}
} catch (userError) {
console.error('Failed to fetch user data:', userError);
}
return response.data;
}
```
**Microsoft & Google Login:**
```typescript
// BEFORE: Just closed popup after auth
localStorage.setItem('auth_authority', event.data.authenticationAuthority);
window.removeEventListener('message', messageListener);
popup.close();
// AFTER: Fetch user data before closing
localStorage.setItem('auth_authority', event.data.authenticationAuthority);
// Wait for cookies to be set, then fetch user data
setTimeout(async () => {
try {
const userResponse = await api.get('/api/msft/me');
if (userResponse.data) {
localStorage.setItem('currentUser', JSON.stringify(userResponse.data));
if (userResponse.data.language) {
localStorage.setItem('language', userResponse.data.language);
}
}
} catch (userError) {
console.error('Failed to fetch user data:', userError);
}
}, 500);
window.removeEventListener('message', messageListener);
popup.close();
```
### 2. `LanguageContext.tsx` - Priority System
**BEFORE:**
```typescript
// Only checked localStorage or browser language
const savedLanguage = localStorage.getItem('language') as Language;
if (savedLanguage) {
initialLanguage = savedLanguage;
} else {
const browserLang = navigator.language.split('-')[0];
initialLanguage = browserLang;
}
```
**AFTER:**
```typescript
// 1st priority: User profile language
const currentUserData = localStorage.getItem('currentUser');
if (currentUserData) {
const userData = JSON.parse(currentUserData);
if (userData.language) {
initialLanguage = userData.language; // ✅ Use user's language!
return;
}
}
// 2nd priority: localStorage
const savedLanguage = localStorage.getItem('language');
if (savedLanguage) {
initialLanguage = savedLanguage;
}
// 3rd priority: Browser language
else {
const browserLang = navigator.language.split('-')[0];
initialLanguage = browserLang;
}
```
### 3. `PageManager.tsx` - Pass Language to Renderer
**BEFORE:**
```typescript
<PageRenderer
pageData={pageData}
// No language prop - defaulted to 'de'
/>
```
**AFTER:**
```typescript
const { currentLanguage } = useLanguage();
<PageRenderer
pageData={pageData}
language={currentLanguage} // ✅ Use actual user language!
/>
```
---
## Timing Comparison
### BEFORE:
```
T=0ms: User clicks login
T=100ms: Login response received
T=101ms: Navigate to home
T=150ms: Home.tsx renders
T=151ms: useCurrentUser() starts API call
T=200ms: PageManager tries to check privileges ❌ (no data!)
T=300ms: User data arrives ✅
T=301ms: Pages re-render with correct data
```
**Total time to correct render: ~300ms**
**Issues: Race condition, wrong language initially**
### AFTER:
```
T=0ms: User clicks login
T=100ms: Login response received
T=101ms: Start user data fetch
T=200ms: User data cached in localStorage
T=201ms: Navigate to home
T=250ms: Home.tsx renders
T=251ms: useCurrentUser() reads from cache (instant!)
T=252ms: LanguageContext uses user.language
T=253ms: PageManager checks privileges ✅ (data available!)
T=254ms: Pages render correctly
```
**Total time to correct render: ~54ms after navigation**
**Issues: None! Everything works perfectly**
---
## Visual Flow Comparison
### BEFORE:
```
Login → Navigate → [Loading...] → [Error?] → Eventually Works
(100ms delay between login and user data fetch)
```
### AFTER:
```
Login → [Fetch User Data] → Navigate → Works Immediately ✅
(User data ready before navigation)
```
---
## Testing Checklist
### ✅ Verify These After Changes:
1. **Login Flow:**
- [ ] Open DevTools Console
- [ ] Clear localStorage
- [ ] Log in
- [ ] See: "🔄 Fetching user data immediately after login..."
- [ ] See: "✅ User data fetched and cached: {...}"
- [ ] Verify localStorage has 'currentUser' with correct data
- [ ] Verify localStorage has 'language' matching user profile
2. **Language Display:**
- [ ] Log in with user who has language 'fr'
- [ ] UI should display in French immediately
- [ ] No flash of German content
- [ ] Console shows: "🌍 Using language from user data: fr"
3. **Privilege Checking:**
- [ ] Log in as regular user
- [ ] Try accessing admin page
- [ ] Should see error/access denied (correct!)
- [ ] Log in as admin
- [ ] Should see admin page immediately
- [ ] Console shows: "🔍 Checking role privilege" with correct role
4. **Page Rendering:**
- [ ] No loading spinner on pages after login
- [ ] Correct language displayed on all pages
- [ ] All privilege-based features work correctly
- [ ] No console errors about missing user data
---
## Files Changed
| File | Changes | Lines |
|------|---------|-------|
| `src/hooks/useAuthentication.ts` | Added immediate user data fetch after login | 65-88, 258-297, 727-753 |
| `src/contexts/LanguageContext.tsx` | Priority system for language selection | 42-108 |
| `src/core/PageManager/PageManager.tsx` | Pass current language to PageRenderer | 7, 20, 104 |
---
## Migration Notes
### For Existing Users:
When existing users log in after this update:
1. Their user data will be fetched and cached on login
2. Their language setting from the backend will override any local preference
3. All privilege checks will work correctly from the first page load
### For New Users:
New users will experience:
1. Instant page rendering after login (no loading delays)
2. Correct language display based on their profile
3. Immediate access to features based on their privilege level
### For Developers:
If you're adding new features:
1. Always read user data from `localStorage.getItem('currentUser')`
2. Use `useLanguage()` hook for language-aware text
3. Use `privilegeCheckers` from `utils/privilegeCheckers.ts` for access control
4. User data is guaranteed to be available after login

View file

@ -1,312 +0,0 @@
# PageManager System Documentation
> **✅ Status**: Production Ready - All critical issues resolved
> **📖 New to PageManager?** See [USAGE_GUIDE.md](./USAGE_GUIDE.md) for step-by-step instructions on creating new pages
## Overview
The PageManager is a declarative, data-driven page rendering system that manages routing, navigation, and page lifecycle through configuration objects instead of hardcoded components.
**Architecture**: Page Definition → PageManager (instances) → PageRenderer (hooks) → FormGenerator (table) → Action Buttons
---
## Core Concepts
### Hook Factory Pattern
Pages define data hooks using a factory pattern to ensure React rules compliance:
```typescript
const createFilesHook = () => {
return () => {
// Call hooks at component level
const { data, loading, error, refetch, removeFileOptimistically } = useUserFiles();
const { handleFileDownload, handleFileDelete, handleFilePreview, handleFileUpdate,
downloadingFiles, deletingFiles, previewingFiles, editingFiles } = useFileOperations();
// Return unified interface (hookData)
return { data, loading, error, refetch, removeFileOptimistically,
handleDownload, handleDelete, handlePreview, handleUpload, handleFileUpdate,
downloadingFiles, deletingFiles, previewingFiles, editingFiles };
};
};
```
**Why?**
- Allows PageRenderer to call hooks at component level
- Creates stable hook instance via `useMemo`
- Single source of truth for all operations
### Page Configuration
Pages are defined as data objects in `src/core/PageManager/data/pages/`:
```typescript
export const dateienPageData: GenericPageData = {
id: 'verwaltung-dateien',
path: 'verwaltung/dateien',
title: 'Dateien',
icon: FaRegFileAlt,
headerButtons: [
{ id: 'upload-file', label: 'Upload File', icon: FaUpload, variant: 'primary' }
],
content: [{
type: 'table',
tableConfig: {
hookFactory: createFilesHook,
columns: filesColumns,
actionButtons: [
{ type: 'view', operationName: 'handlePreview', loadingStateName: 'previewingFiles' },
{ type: 'edit', operationName: 'handleFileUpdate', loadingStateName: 'editingFiles' },
{ type: 'download', operationName: 'handleDownload', loadingStateName: 'downloadingFiles' },
{ type: 'delete', operationName: 'handleDelete', loadingStateName: 'deletingFiles' }
]
}
}],
privilegeChecker: privilegeCheckers.viewerRole,
preserveState: false
};
```
---
## Data Flow
### State Management
```
PageRenderer (calls hookFactory)
hookData = { data, operations, loadingStates, refetch }
FormGenerator (receives hookData)
Action Buttons (use hookData operations)
API Calls (via operations)
refetch() updates data
FormGenerator re-renders
```
**Key Point**: Single source of truth - all components use the same hook instance via `hookData`.
### Component Responsibilities
| Component | Responsibility | State |
|-----------|---------------|-------|
| **PageManager** | Instance lifecycle, routing | Page instances map |
| **PageRenderer** | Execute hooks, render structure | None (passes hookData down) |
| **FormGenerator** | Table UI (search, sort, filter, pagination) | Local UI state only |
| **Action Buttons** | Trigger operations from hookData | Internal loading flags |
| **Popup/EditForm** | Presentational UI | Local form state only |
---
## Action Buttons Deep Dive
All action buttons follow the same pattern:
1. Receive `hookData` as required prop (no fallback hooks)
2. Extract operation: `const handleOp = hookData[operationName]`
3. Extract loading state: `const loading = hookData[loadingStateName]`
4. Validate operations exist (throw error if missing)
5. Call operation, show loading indicator, handle result
### Upload Button
**Trigger**: User selects file
**Flow**: Upload → refetch() → table updates
**Memoized**: ✅ Uses `useCallback([refetch])`
### View Button
**Trigger**: User clicks eye icon
**Flow**: Opens FilePreview → fetches preview data → displays
**Refetch**: ❌ Not needed (read-only)
### Edit Button
**Trigger**: User clicks edit icon
**Flow**: Opens Popup → EditForm → Save → handleFileUpdate() → refetch() → table updates
**Components**: EditActionButton → Popup (presentational) → EditForm (presentational)
**State**: Local form state in EditForm, operations via hookData
### Download Button
**Trigger**: User clicks download icon
**Flow**: Fetch blob → trigger browser download
**Refetch**: ❌ Not needed (read-only)
### Delete Button
**Trigger**: User confirms delete
**Flow**: removeFileOptimistically() → handleFileDelete() → refetch() (on success/failure)
**Optimistic Update**: ✅ Instant UI feedback, rollback on error
---
## Request Management
### Caching (useApi.ts)
- GET requests cached for 5 seconds
- Cache key: `${method}:${url}:${params}`
- Prevents duplicate simultaneous requests
- Cleared on error or timeout
### CSRF & Auth
- CSRF token: Auto-added via `addCSRFTokenToHeaders()`
- JWT token: Auto-added by axios interceptor
- Handled transparently by `api` instance
---
## Critical Issues Fixed ✅
### 1. Hook Duplication in Action Buttons
**Problem**: DeleteActionButton and EditActionButton called `useFileOperations()` and `useUserFiles()` unconditionally as fallbacks, creating duplicate hook instances with separate state.
**Fix**:
- Made `hookData` required (not optional)
- Removed all fallback hook imports and calls
- Added validation: throw error if operations missing
- All buttons now use single shared state from hookData
### 2. Missing Edit Operations
**Problem**: `handleFileUpdate` and `editingFiles` not included in hookData
**Fix**:
- Added to hook factory destructuring and return statement
- Added `operationName` and `loadingStateName` to button config
### 3. Upload Function Not Memoized
**Problem**: `handleFileUpload` recreated every render
**Fix**: Wrapped with `useCallback([refetch])`
### Result
✅ No duplicate hooks
✅ Single source of truth
✅ Consistent state across all components
✅ Better performance
---
## Page Lifecycle
### Navigation Flow
```
1. User navigates to /verwaltung/dateien
2. PageManager.useEffect triggered
3. getPageDataByPath('verwaltung/dateien')
4. Check privilegeChecker
5. Create PageInstance (or reuse if preserveState: true)
6. PageRenderer calls hookFactory() → useTableData
7. Hooks execute: useUserFiles(), useFileOperations()
8. API call: /api/files/list
9. setFiles(data) updates state
10. FormGenerator renders table
11. Action buttons render per row
```
### Cleanup
**preserveState: false** (default):
- Component unmounted after 500ms
- All state lost
- Next visit: Full reload
**preserveState: true**:
- Component stays mounted (hidden)
- State preserved
- Next visit: Instant
---
## Best Practices
### ✅ Do
- Use hook factory pattern for data fetching
- Pass `hookData` to all action buttons
- Make `hookData` required (not optional)
- Use `useCallback` for functions inside hooks
- Implement optimistic updates for better UX
- Use per-item loading states (Set<string>)
- Keep presentational components stateless (Popup, EditForm)
### ❌ Don't
- Call hooks conditionally or in loops
- Create fallback hooks in action buttons
- Duplicate state across components
- Call operations directly without hookData
- Mutate hookData (it's a shared reference)
---
## Troubleshooting
### "hookData.X is not defined"
**Cause**: Operation not included in hook factory return statement
**Fix**: Add operation to hook factory's return object
### Hook duplication / inconsistent state
**Cause**: Action button calling hooks directly instead of using hookData
**Fix**: Remove fallback hooks, make hookData required, use hookData operations
### Backend 500 errors
**Cause**: Backend issue (e.g., "'str' object has no attribute '__name__'")
**Fix**: Check backend logs for stack trace - not a frontend issue
---
## Summary
### Architecture Quality: A- (Excellent)
**Strengths**:
- ✅ Declarative page configuration
- ✅ Separation of concerns (data/logic/UI)
- ✅ Reusable components (FormGenerator, ActionButtons)
- ✅ Optimistic updates for better UX
- ✅ Single source of truth for state
- ✅ Hook factory pattern follows React rules
- ✅ All critical issues resolved
### Remaining Improvements
1. **Global error handling** (Priority: High) - Add toast notification system
2. **TypeScript strict mode** (Priority: Medium) - Remove `any` types, proper hookData interface
3. **Unit tests** (Priority: Medium) - Test hook factory, optimistic updates, error recovery
4. **Performance** (Priority: Low) - Virtual scrolling, pagination caching, React.memo
### Status: 🟢 Production Ready
Critical issues have been resolved. The system is fully functional with clean architecture. Remaining improvements are nice-to-haves that would enhance UX and maintainability.
---
## Next Steps
📖 **Ready to create a new page?** Check out the [USAGE_GUIDE.md](./USAGE_GUIDE.md) for:
- Step-by-step instructions
- Complete code examples
- Advanced features
- Best practices
- Troubleshooting tips

View file

@ -1,581 +0,0 @@
# Privilege and Language Flow - Complete Trace (dateien.ts Example)
## 📋 Overview
This document traces the **complete flow** of privilege checking and language resolution from PageManager through to rendered content, using `dateien.ts` as a concrete example.
---
## 🔄 Complete Flow Diagram
```
┌─────────────────────────────────────────────────────────────┐
│ 1. USER NAVIGATES TO /verwaltung/dateien │
└────────────────────┬────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 2. PageManager.tsx - useEffect triggered │
│ Line 67: const pageData = getPageDataByPath(currentPath)│
└────────────────────┬────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 3. data/pages/index.ts - getPageDataByPath() │
│ Line 27-29: Find page by path │
│ Returns: dateienPageData object │
└────────────────────┬────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 4. PageManager.tsx - Check if module enabled │
│ Line 70: if (!pageData.moduleEnabled) return │
│ dateien.ts Line 248: moduleEnabled: true ✅ │
└────────────────────┬────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 5. PageManager.tsx - Check Page Privilege │
│ Line 75: checkPageAccess(pageData) │
│ ↓ │
│ Line 29-40: async checkPageAccess() │
│ if (!pageData.privilegeChecker) return true │
│ else return await pageData.privilegeChecker() │
│ ↓ │
│ dateien.ts Line 243: privilegeChecker: privilegeCheckers.viewerRole │
│ ↓ │
│ privilegeCheckers.ts Line 199-208: │
│ createRolePrivilegeChecker(['viewer', 'user', 'admin', 'sysadmin']) │
│ Reads from localStorage('currentUser').privilege │
│ Returns true if user privilege matches ✅ │
└────────────────────┬────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 6. PageManager.tsx - Get Current Language │
│ Line 20: const { currentLanguage } = useLanguage() │
│ ↓ │
│ LanguageContext reads from: │
│ localStorage('currentUser').language │
│ Current language: 'de' | 'en' | 'fr' │
└────────────────────┬────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 7. PageManager.tsx - Create Page Instance │
│ Line 93-116: Create PageInstance │
│ Line 101-108: Render PageRenderer with: │
│ - pageData (full dateienPageData object) │
│ - language={currentLanguage} ✅ │
└────────────────────┬────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 8. PageRenderer.tsx - Receive Props │
│ Line 13-17: PageRendererProps │
│ - pageData: GenericPageData │
│ - language: 'de' | 'en' | 'fr' ✅ │
└────────────────────┬────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 9. PageRenderer.tsx - Initialize Hook Factory │
│ Line 20-34: Execute hook factory │
│ ↓ │
│ dateien.ts Line 8-62: createFilesHook() │
│ Returns hook function that calls: │
│ - useUserFiles() → fetches files data │
│ - useFileOperations() → handles file operations │
│ Returns: hookData with data, operations, states │
└────────────────────┬────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 10. PageRenderer.tsx - Render Page Header │
│ Line 190-191: Render title │
│ resolveLanguageText(pageData.title, language) │
│ ↓ │
│ dateien.ts Line 141-145: title object │
│ { de: 'Dateien', en: 'Files', fr: 'Fichiers' } │
│ ↓ │
│ pageInterface.ts Line 87-91: resolveLanguageText() │
│ Returns: text[language] → 'Dateien' ✅ │
│ ↓ │
│ Line 192-193: Render subtitle (same process) │
│ Result: 'Verwalten Sie Ihre Dateien...' ✅ │
└────────────────────┬────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 11. PageRenderer.tsx - Render Header Buttons │
│ Line 198-234: Loop through headerButtons │
│ ↓ │
│ dateien.ts Line 153-165: Upload button config │
│ label: { de: 'Datei hochladen', ... } │
│ ↓ │
│ Line 230: resolveLanguageText(button.label, language) │
│ Result: 'Datei hochladen' ✅ │
└────────────────────┬────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 12. PageRenderer.tsx - Render Table Content │
│ Line 115-177: Render table type content │
│ ↓ │
│ dateien.ts Line 169-239: Table configuration │
│ - hookFactory: createFilesHook │
│ - columns: filesColumns (Line 65-124) │
│ Each column has: │
│ label: { de: '...', en: '...', fr: '...' } │
│ - actionButtons: [view, edit, download, delete] │
│ Each button has: │
│ title: { de: '...', en: '...', fr: '...' } │
│ ↓ │
│ Line 140: const columns = hookData.columns || configColumns │
│ columns = filesColumns (LanguageText objects!) │
│ ↓ │
│ Line 142-146: Resolve column labels ✅ │
│ resolvedColumns with label: string │
│ ↓ │
│ Line 150-165: Map action buttons │
│ title: resolveLanguageText(action.title, language) ✅│
│ ↓ │
│ Line 174-181: Pass to FormGenerator │
│ columns={resolvedColumns} ← RESOLVED strings! ✅ │
│ actionButtons={formGeneratorActions} ← RESOLVED! ✅ │
└────────────────────┬────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 13. FormGenerator.tsx - Receive Props │
│ Line 81-104: FormGeneratorProps │
│ columns: ColumnConfig[] with label: string │
│ NOW receiving: label: string (resolved!) ✅ │
│ ↓ │
│ Line 105: const { t } = useLanguage() │
│ Has access to t() and currentLanguage ✅ │
│ ↓ │
│ Line 627, 642: Uses column.label directly │
│ Displays: 'Dateiname' (correct text!) ✅ │
└────────────────────┬────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 14. Action Buttons Rendering │
│ Line 766-785: Map through actionButtons │
│ ↓ │
│ Line 767-769: Get title │
│ actionTitle = actionButton.title (string!) ✅ │
│ ↓ │
│ Passed to EditActionButton, DeleteActionButton, etc. │
│ ↓ │
│ EditActionButton.tsx Line 39: title prop (string) │
│ Receives correct string! ✅ │
└─────────────────────────────────────────────────────────────┘
```
---
## 🎯 Privilege Checking - Detailed
### ✅ Where Privilege Checks Happen
#### 1. **Page Level Check** (`PageManager.tsx` Line 75)
```typescript
// PageManager.tsx
const checkPageAccess = async (pageData: GenericPageData): Promise<boolean> => {
if (!pageData.privilegeChecker) {
return true; // No checker = accessible to all
}
try {
return await pageData.privilegeChecker();
} catch (error) {
console.error(`Error checking page access for ${pageData.path}:`, error);
return false;
}
};
```
**For dateien.ts:**
```typescript
// Line 243
privilegeChecker: privilegeCheckers.viewerRole
```
**Privilege Checker Implementation:**
```typescript
// privilegeCheckers.ts Lines 199-208
viewerRole: createRolePrivilegeChecker(
['viewer', 'user', 'admin', 'sysadmin'],
() => {
const userPrivilege = getCurrentUserPrivilege(); // Reads from localStorage
return Promise.resolve(userPrivilege ? [userPrivilege] : []);
}
)
```
**Process:**
1. Read `localStorage.getItem('currentUser')`
2. Parse JSON and extract `user.privilege`
3. Check if privilege is in allowed list: `['viewer', 'user', 'admin', 'sysadmin']`
4. Return `true` if match, `false` otherwise
#### 2. **Button Level Check** (`PageRenderer.tsx` Line 40)
```typescript
const handleButtonClick = async (button: PageButton) => {
try {
// Check privilege if required
if (button.privilegeChecker) {
const hasPrivilege = await button.privilegeChecker();
if (!hasPrivilege) {
console.warn(`Access denied for button: ${button.id}`);
return;
}
}
// Execute onClick...
}
};
```
**Example from example-page.ts:**
```typescript
{
id: 'delete-all',
label: 'Delete All',
onClick: () => { /* ... */ },
privilegeChecker: privilegeCheckers.adminRole // Only admins
}
```
#### 3. **Content Level Check** (`PageRenderer.tsx` Line 245)
```typescript
{pageData.content?.map((content) => {
// Check privilege for content
if (content.privilegeChecker) {
// Content is rendered only if privilege check passes
return renderContent(content);
}
return renderContent(content);
})}
```
### ✅ Timing of Privilege Checks
```
User navigates → PageManager useEffect triggers
getPageDataByPath(currentPath) - fetches page config
checkPageAccess(pageData) - ASYNC check
If hasAccess = false → Return early (no render)
If hasAccess = true → Create PageInstance → Render PageRenderer
Button clicks → Check button.privilegeChecker before executing
```
**Key Point:** Privilege checks are **asynchronous** and happen **before** page rendering.
---
## 🌍 Language Resolution - Detailed
### ✅ Where Language IS Resolved Correctly
#### 1. **Page Title and Subtitle** (`PageRenderer.tsx` Lines 191-193)
```typescript
// PageRenderer receives: language = 'de' (from LanguageContext)
<h1>{resolveLanguageText(pageData.title, language)}</h1>
<p>{resolveLanguageText(pageData.subtitle, language)}</p>
```
**Input (dateien.ts):**
```typescript
title: {
de: 'Dateien',
en: 'Files',
fr: 'Fichiers'
}
```
**Process:**
```typescript
// pageInterface.ts Line 87-91
export const resolveLanguageText = (text: string | LanguageText, language: 'de') => {
if (typeof text === 'string') return text;
return text[language] || text.de || '';
};
```
**Result:** `'Dateien'`
#### 2. **Header Button Labels** (`PageRenderer.tsx` Line 230)
```typescript
{button.icon && <button.icon />}
{resolveLanguageText(button.label, language)}
```
**Input (dateien.ts):**
```typescript
label: {
de: 'Datei hochladen',
en: 'Upload File',
fr: 'Télécharger un fichier'
}
```
**Result:** `'Datei hochladen'`
#### 3. **Simple Content Types** (heading, paragraph, list)
All simple content types properly use `resolveLanguageText(content.content, language)`
### ~~❌ Where Language WAS NOT Resolved~~ NOW FIXED ✅
#### ~~1. **Table Column Labels**~~ FIXED ✅
**Problem:**
```typescript
// PageRenderer.tsx Line 140
const columns = hookData.columns || configColumns;
// Line 169 - Passed directly to FormGenerator
<FormGenerator
columns={columns} // ← LanguageText objects NOT resolved! ❌
...
/>
```
**Input (dateien.ts Lines 68-72):**
```typescript
{
key: 'file_name',
label: {
de: 'Dateiname',
en: 'Filename',
fr: 'Nom de fichier'
},
// ...
}
```
**What happens in FormGenerator:**
```typescript
// FormGenerator.tsx Line 627
<label>{column.label}</label>
// Displays: [object Object] ❌
```
**Expected:**
```typescript
// Should be resolved BEFORE passing to FormGenerator
const resolvedColumns = columns.map(col => ({
...col,
label: resolveLanguageText(col.label, language)
}));
```
#### ~~2. **Action Button Titles**~~ FIXED ✅
**Problem:**
```typescript
// PageRenderer.tsx Line 144-158
const formGeneratorActions = actionButtons?.map(action => {
return {
type: action.type,
title: action.title, // ← LanguageText object NOT resolved! ❌
// ...
};
});
```
**Input (dateien.ts Lines 179-183):**
```typescript
{
type: 'view',
title: {
de: 'Datei vorschauen',
en: 'Preview file',
fr: 'Aperçu du fichier'
},
// ...
}
```
**What happens:**
- FormGenerator passes raw `title` to action button components
- Action buttons expect `title?: string` but receive `LanguageText` object
- Tooltip/aria-label shows `[object Object]`
**Expected:**
```typescript
const formGeneratorActions = actionButtons?.map(action => {
return {
type: action.type,
title: resolveLanguageText(action.title, language), // ✅ Resolve here!
// ...
};
});
```
#### 3. **Filter Placeholders** (`FormGenerator.tsx` Line 642)
```typescript
<label>
{t('formgen.filter.placeholder').replace('{column}', column.label)}
</label>
```
If `column.label` is a LanguageText object, this breaks! ❌
---
## ✅ Issues Fixed
### ~~Issue #1: Column Labels Not Resolved~~ FIXED ✅
**Location:** `PageRenderer.tsx` Line 142-146
**Fixed Code:**
```typescript
const columns = hookData.columns || configColumns;
// CRITICAL: Resolve LanguageText objects in column labels
const resolvedColumns = columns.map(col => ({
...col,
label: resolveLanguageText(col.label, language)
}));
<FormGenerator
columns={resolvedColumns} // ✅ Resolved strings
...
/>
```
### ~~Issue #2: Action Button Titles Not Resolved~~ FIXED ✅
**Location:** `PageRenderer.tsx` Line 150-165
**Fixed Code:**
```typescript
const formGeneratorActions = actionButtons?.map(action => {
return {
type: action.type,
// CRITICAL: Resolve LanguageText objects in action titles
title: resolveLanguageText(action.title, language), // ✅ Resolved string
isProcessing: action.loading || (() => false),
disabled: action.disabled || (() => false),
// ...
};
});
```
**Result:** All LanguageText objects are now properly resolved to strings before being passed to FormGenerator! 🎉
---
## 📊 Data Flow Summary
```
┌────────────────────────────────────────────────────────────┐
│ dateien.ts Configuration │
│ - Page metadata (title, subtitle) → LanguageText │
│ - Header buttons (labels) → LanguageText │
│ - Table columns (labels) → LanguageText ⚠️ │
│ - Action buttons (titles) → LanguageText ⚠️ │
│ - Privilege checker → viewerRole │
└────────────────────┬───────────────────────────────────────┘
┌────────────────────────────────────────────────────────────┐
│ PageManager.tsx │
│ - Fetches page config │
│ - Checks privilege (async) ✅ │
│ - Gets current language from context ✅ │
│ - Passes both to PageRenderer │
└────────────────────┬───────────────────────────────────────┘
┌────────────────────────────────────────────────────────────┐
│ PageRenderer.tsx │
│ - Resolves: title, subtitle, button labels ✅ │
│ - Does NOT resolve: column labels, action titles ❌ │
│ - Passes unresolved objects to FormGenerator │
└────────────────────┬───────────────────────────────────────┘
┌────────────────────────────────────────────────────────────┐
│ FormGenerator.tsx │
│ - Receives columns with LanguageText objects ❌ │
│ - Displays [object Object] for labels │
│ - Has access to useLanguage() but doesn't use it │
└────────────────────────────────────────────────────────────┘
```
---
## ✅ Best Practices
### 1. **Privilege Checks**
- ✅ Always check at page level (`pageData.privilegeChecker`)
- ✅ Check at button level for sensitive actions
- ✅ Checks are async - handled properly
- ✅ Reads from `localStorage('currentUser').privilege`
### 2. **Language Resolution**
- ✅ Get language from `useLanguage()` context
- ✅ Resolve ALL LanguageText objects before passing to child components
- ✅ Use `resolveLanguageText()` utility function
- ❌ DON'T pass raw LanguageText objects to generic components
### 3. **Type Safety**
```typescript
// ❌ Bad - allows LanguageText to leak through
interface ActionButton {
title?: string | LanguageText; // Ambiguous!
}
// ✅ Good - clearly separate config from resolved
interface ActionButtonConfig {
title: string | LanguageText; // Input config
}
interface ActionButtonProps {
title?: string; // Resolved output
}
```
---
## ✅ Completed
1. ~~**Fix PageRenderer** to resolve column labels and action titles~~ ✅ DONE
2. **Add type checks** to ensure LanguageText resolution (optional enhancement)
3. **Update FormGenerator types** to strictly expect `string` for labels (optional enhancement)
4. **Add console warnings** when LanguageText objects are not resolved (optional enhancement)
5. **Test with all three languages** (de, en, fr) - Ready for testing!
---
## 📁 Key Files
| File | Role | Line References |
|------|------|-----------------|
| `src/core/PageManager/data/pages/dateien.ts` | Page configuration | 65-124 (columns), 176-230 (actions), 243 (privilege) |
| `src/core/PageManager/PageManager.tsx` | Page routing & privilege check | 67-78 (fetch & check), 20 (language), 103 (pass to renderer) |
| `src/core/PageManager/PageRenderer.tsx` | Page rendering | 140 (columns), 144-158 (actions), 191-230 (header) |
| `src/components/FormGenerator/FormGenerator.tsx` | Table rendering | 105 (useLanguage), 627, 642 (display labels) |
| `src/utils/privilegeCheckers.ts` | Privilege checking | 4-21 (getCurrentUserPrivilege), 199-208 (viewerRole) |
| `src/contexts/LanguageContext.tsx` | Language state | 46-57 (get from currentUser) |
---
## 🎯 Conclusion
**Privilege checking works perfectly:** ✅
- Checks happen at the right time (before rendering)
- Uses cached user data from localStorage
- Async handling is correct
- Multiple levels of checks (page, button, content)
**Language resolution now works completely:** ✅
- ✅ Page headers, buttons, simple content
- ✅ Table columns labels (FIXED!)
- ✅ Action button titles (FIXED!)
- All LanguageText objects are resolved before passing to FormGenerator

View file

@ -1,869 +0,0 @@
# 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! 🚀

232
src/api/authApi.ts Normal file
View file

@ -0,0 +1,232 @@
import { ApiRequestOptions } from '../hooks/useApi';
import api from '../api';
import { addCSRFTokenToHeaders } from '../utils/csrfUtils';
// ============================================================================
// TYPES & INTERFACES
// ============================================================================
export interface LoginRequest {
username: string;
password: string;
}
export interface LoginResponse {
type: 'local_auth_success';
accessToken?: string;
tokenType?: string;
authenticationAuthority?: string;
label?: any;
fieldLabels?: any;
}
export interface RegisterData {
username: string;
password: string;
email: string;
fullName: string;
language?: string;
enabled?: boolean;
privilege?: string;
}
export interface RegisterRequest {
userData: {
username: string;
email: string;
fullName: string;
language: string;
enabled: boolean;
privilege: string;
};
password: string;
}
export interface RegisterResponse {
success: boolean;
message?: string;
user?: {
id: string;
username: string;
email: string;
fullName: string;
language: string;
enabled: boolean;
privilege: string;
};
}
export interface MsalRegisterData {
username: string;
email: string;
fullName: string;
language?: string;
}
export interface UsernameAvailabilityRequest {
username: string;
authenticationAuthority?: string;
}
export interface UsernameAvailabilityResponse {
username: string;
authenticationAuthority: string;
available: boolean;
message: string;
}
export interface User {
id: string;
username: string;
email: string;
fullName: string;
language: string;
enabled: boolean;
privilege: string;
mandateId: string;
[key: string]: any;
}
// Type for the request function passed to API functions
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
// ============================================================================
// API REQUEST FUNCTIONS
// ============================================================================
/**
* Login with username and password
* Endpoint: POST /api/local/login
*/
export async function loginApi(loginData: LoginRequest): Promise<LoginResponse> {
// Create the form data in the exact format FastAPI OAuth2 expects
const params = new URLSearchParams();
params.append('username', loginData.username);
params.append('password', loginData.password);
params.append('grant_type', 'password');
params.append('scope', '');
params.append('client_id', '');
params.append('client_secret', '');
// Prepare headers with CSRF token if available
const headers: Record<string, string> = {
'Content-Type': 'application/x-www-form-urlencoded'
};
// Add CSRF token if available (for new security implementation)
addCSRFTokenToHeaders(headers);
// Use the existing api instance with custom headers for this request
const response = await api.post<LoginResponse>('/api/local/login', params, {
headers
});
return response.data;
}
/**
* Fetch current user data
* Endpoint: GET /api/local/me | /api/msft/me | /api/google/me
*/
export async function fetchCurrentUserApi(authAuthority?: string): Promise<User> {
let endpoint = '/api/local/me';
if (authAuthority === 'msft') {
endpoint = '/api/msft/me';
} else if (authAuthority === 'google') {
endpoint = '/api/google/me';
}
const response = await api.get<User>(endpoint);
return response.data;
}
/**
* Register a new user
* Endpoint: POST /api/local/register
*/
export async function registerApi(registerData: RegisterData): Promise<RegisterResponse> {
// Prepare data to match backend expectations
const dataToSend: RegisterRequest = {
userData: {
username: registerData.username,
email: registerData.email,
fullName: registerData.fullName,
language: registerData.language || 'de',
enabled: registerData.enabled !== undefined ? registerData.enabled : true,
privilege: registerData.privilege || 'user'
},
password: registerData.password
};
// Prepare headers with CSRF token if available
const headers: Record<string, string> = {
'Content-Type': 'application/json'
};
// Add CSRF token if available (for new security implementation)
addCSRFTokenToHeaders(headers);
const response = await api.post<RegisterResponse>('/api/local/register', dataToSend, {
headers
});
return {
success: true,
message: 'Registration successful',
user: response.data
};
}
/**
* Register with Microsoft account
* Endpoint: POST /api/msft/register
*/
export async function registerWithMsalApi(
request: ApiRequestFunction,
userData: MsalRegisterData
): Promise<RegisterResponse> {
const response = await request<RegisterResponse>({
url: '/api/msft/register',
method: 'post',
data: userData,
additionalConfig: {
headers: {
'Content-Type': 'application/json'
}
}
});
return {
success: true,
message: 'Registration successful',
user: response
};
}
/**
* Check username availability
* Endpoint: GET /api/local/available
*/
export async function checkUsernameAvailabilityApi(
username: string,
authenticationAuthority: string = 'local'
): Promise<UsernameAvailabilityResponse> {
const response = await api.get<UsernameAvailabilityResponse>('/api/local/available', {
params: {
username,
authenticationAuthority
}
});
return response.data;
}
/**
* Logout current user
* Endpoint: POST /api/local/logout
*/
export async function logoutApi(): Promise<void> {
await api.post('/api/local/logout');
}

221
src/api/connectionApi.ts Normal file
View file

@ -0,0 +1,221 @@
import { ApiRequestOptions } from '../hooks/useApi';
// ============================================================================
// TYPES & INTERFACES
// ============================================================================
export interface Connection {
id: string;
userId: string;
authority: 'local' | 'google' | 'msft';
externalId: string;
externalUsername: string;
externalEmail?: string;
status: 'active' | 'expired' | 'revoked' | 'pending';
connectedAt: number; // Backend uses float for UTC timestamp in seconds
lastChecked: number; // Backend uses float for UTC timestamp in seconds
expiresAt?: number; // Backend uses Optional[float] for UTC timestamp in seconds
[key: string]: any; // Allow additional properties
}
export interface AttributeDefinition {
name: string;
label: string;
type: 'string' | 'number' | 'date' | 'boolean' | 'enum';
sortable?: boolean;
filterable?: boolean;
searchable?: boolean;
width?: number;
minWidth?: number;
maxWidth?: number;
filterOptions?: string[];
}
export interface PaginationParams {
page?: number;
pageSize?: number;
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
filters?: Record<string, any>;
search?: string;
}
export interface PaginatedResponse<T> {
items: T[];
pagination?: {
currentPage: number;
pageSize: number;
totalItems: number;
totalPages: number;
};
}
export interface CreateConnectionData {
id?: string;
userId?: string;
authority?: 'msft' | 'google';
type?: 'msft' | 'google'; // Backend expects this field
externalId?: string;
externalUsername?: string;
externalEmail?: string;
status?: 'active' | 'expired' | 'revoked' | 'pending';
connectedAt?: number;
lastChecked?: number;
expiresAt?: number;
}
export interface ConnectResponse {
authUrl: string;
}
// Type for the request function passed to API functions
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
// ============================================================================
// API REQUEST FUNCTIONS
// ============================================================================
/**
* Fetch connection attributes from backend
* Endpoint: GET /api/attributes/UserConnection
*/
export async function fetchConnectionAttributes(request: ApiRequestFunction): Promise<AttributeDefinition[]> {
// Note: This uses api.get directly due to response format handling
// For now, we'll use api.get directly in the hook as well
throw new Error('fetchConnectionAttributes should use api instance directly for response format handling');
}
/**
* Fetch list of connections with optional pagination
* Endpoint: GET /api/connections/
*/
export async function fetchConnections(
request: ApiRequestFunction,
params?: PaginationParams
): Promise<PaginatedResponse<Connection> | Connection[]> {
const requestParams: any = {};
// Build pagination object if provided
if (params) {
const paginationObj: any = {};
if (params.page !== undefined) paginationObj.page = params.page;
if (params.pageSize !== undefined) paginationObj.pageSize = params.pageSize;
if (params.sort) paginationObj.sort = params.sort;
if (params.filters) paginationObj.filters = params.filters;
if (params.search) paginationObj.search = params.search;
if (Object.keys(paginationObj).length > 0) {
requestParams.pagination = JSON.stringify(paginationObj);
}
}
const data = await request<PaginatedResponse<Connection> | Connection[]>({
url: '/api/connections/',
method: 'get',
params: requestParams
});
return data;
}
/**
* Create a new connection
* Endpoint: POST /api/connections/
*/
export async function createConnection(
request: ApiRequestFunction,
connectionData: CreateConnectionData
): Promise<Connection> {
return await request<Connection>({
url: '/api/connections/',
method: 'post',
data: connectionData
});
}
/**
* Connect to a service (initiate OAuth)
* Endpoint: POST /api/connections/{connectionId}/connect
*/
export async function connectService(
request: ApiRequestFunction,
connectionId: string
): Promise<ConnectResponse> {
return await request<ConnectResponse>({
url: `/api/connections/${connectionId}/connect`,
method: 'post'
});
}
/**
* Disconnect from a service
* Endpoint: POST /api/connections/{connectionId}/disconnect
*/
export async function disconnectService(
request: ApiRequestFunction,
connectionId: string
): Promise<{ message: string }> {
return await request<{ message: string }>({
url: `/api/connections/${connectionId}/disconnect`,
method: 'post'
});
}
/**
* Delete a connection
* Endpoint: DELETE /api/connections/{connectionId}
*/
export async function deleteConnection(
request: ApiRequestFunction,
connectionId: string
): Promise<{ message: string }> {
return await request<{ message: string }>({
url: `/api/connections/${connectionId}`,
method: 'delete'
});
}
/**
* Update a connection
* Endpoint: PUT /api/connections/{connectionId}
*/
export async function updateConnection(
request: ApiRequestFunction,
connectionId: string,
updateData: Partial<Connection>
): Promise<Connection> {
return await request<Connection>({
url: `/api/connections/${connectionId}`,
method: 'put',
data: updateData
});
}
/**
* Refresh Microsoft token
* Endpoint: POST /api/connections/{connectionId}/refresh-microsoft-token
*/
export async function refreshMicrosoftToken(
request: ApiRequestFunction,
connectionId: string
): Promise<Connection> {
return await request<Connection>({
url: `/api/connections/${connectionId}/refresh-microsoft-token`,
method: 'post'
});
}
/**
* Refresh Google token
* Endpoint: POST /api/connections/{connectionId}/refresh-google-token
*/
export async function refreshGoogleToken(
request: ApiRequestFunction,
connectionId: string
): Promise<Connection> {
return await request<Connection>({
url: `/api/connections/${connectionId}/refresh-google-token`,
method: 'post'
});
}

203
src/api/fileApi.ts Normal file
View file

@ -0,0 +1,203 @@
import { ApiRequestOptions } from '../hooks/useApi';
// ============================================================================
// TYPES & INTERFACES
// ============================================================================
export interface FileInfo {
id: string;
mandateId: string;
fileName: string;
mimeType: string;
fileHash: string;
fileSize: number;
creationDate: number;
[key: string]: any; // Allow additional properties
}
export interface AttributeDefinition {
name: string;
label: string;
type: 'string' | 'number' | 'date' | 'boolean' | 'enum';
sortable?: boolean;
filterable?: boolean;
searchable?: boolean;
width?: number;
minWidth?: number;
maxWidth?: number;
filterOptions?: string[];
}
export interface PaginationParams {
page?: number;
pageSize?: number;
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
filters?: Record<string, any>;
search?: string;
}
export interface PaginatedResponse<T> {
items: T[];
pagination?: {
currentPage: number;
pageSize: number;
totalItems: number;
totalPages: number;
};
}
// Type for the request function passed to API functions
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
// ============================================================================
// API REQUEST FUNCTIONS
// ============================================================================
/**
* Fetch file attributes from backend
* Endpoint: GET /api/attributes/FileItem
*/
export async function fetchFileAttributes(request: ApiRequestFunction): Promise<AttributeDefinition[]> {
const data = await request<AttributeDefinition[] | { attributes: AttributeDefinition[] }>({
url: '/api/attributes/FileItem',
method: 'get'
});
// Handle different response formats
if (Array.isArray(data)) {
return data;
}
if (data && typeof data === 'object' && 'attributes' in data && Array.isArray(data.attributes)) {
return data.attributes;
}
// Try to find any array property in the response
if (data && typeof data === 'object') {
const keys = Object.keys(data);
for (const key of keys) {
if (Array.isArray((data as any)[key])) {
return (data as any)[key];
}
}
}
return [];
}
/**
* Fetch list of files with optional pagination
* Endpoint: GET /api/files/list
*/
export async function fetchFiles(
request: ApiRequestFunction,
params?: PaginationParams
): Promise<PaginatedResponse<FileInfo> | FileInfo[]> {
const requestParams: any = {};
// Build pagination object if provided
if (params) {
const paginationObj: any = {};
if (params.page !== undefined) paginationObj.page = params.page;
if (params.pageSize !== undefined) paginationObj.pageSize = params.pageSize;
if (params.sort) paginationObj.sort = params.sort;
if (params.filters) paginationObj.filters = params.filters;
if (params.search) paginationObj.search = params.search;
if (Object.keys(paginationObj).length > 0) {
requestParams.pagination = JSON.stringify(paginationObj);
}
}
const data = await request<PaginatedResponse<FileInfo> | FileInfo[]>({
url: '/api/files/list',
method: 'get',
params: requestParams
});
return data;
}
/**
* Fetch a single file by ID
* Endpoint: GET /api/files/{fileId}
*/
export async function fetchFileById(
request: ApiRequestFunction,
fileId: string
): Promise<FileInfo | null> {
try {
const data = await request<FileInfo>({
url: `/api/files/${fileId}`,
method: 'get'
});
return data || null;
} catch (error: any) {
console.error('Error fetching file by ID:', error);
return null;
}
}
/**
* Update a file
* Endpoint: PUT /api/files/{fileId}
*/
export async function updateFile(
request: ApiRequestFunction,
fileId: string,
fileData: Partial<FileInfo>
): Promise<FileInfo> {
return await request<FileInfo>({
url: `/api/files/${fileId}`,
method: 'put',
data: fileData
});
}
/**
* Delete a file
* Endpoint: DELETE /api/files/{fileId}
*/
export async function deleteFile(
request: ApiRequestFunction,
fileId: string
): Promise<void> {
await request({
url: `/api/files/${fileId}`,
method: 'delete'
});
}
/**
* Delete multiple files
* Endpoint: DELETE /api/files/{fileId} (called multiple times)
*/
export async function deleteFiles(
request: ApiRequestFunction,
fileIds: string[]
): Promise<Array<{ success: boolean; fileId: string; error?: any }>> {
const results = await Promise.allSettled(
fileIds.map(fileId =>
request({
url: `/api/files/${fileId}`,
method: 'delete'
}).then(() => ({ success: true, fileId }))
.catch((error) => ({ success: false, fileId, error }))
)
);
return results.map((result, index) => {
if (result.status === 'fulfilled') {
return result.value;
}
return { success: false, fileId: fileIds[index], error: result.reason };
});
}
// Note: The following operations require special handling (FormData, blob responses)
// and should use the api instance directly from '../api' rather than the request function:
// - uploadFile: Requires FormData with multipart/form-data
// - downloadFile: Requires blob responseType
// - previewFile: Requires flexible responseType (json or blob)
// These are kept in the hooks for now due to their special requirements

49
src/api/permissionApi.ts Normal file
View file

@ -0,0 +1,49 @@
import { ApiRequestOptions } from '../hooks/useApi';
// ============================================================================
// TYPES & INTERFACES
// ============================================================================
export type PermissionLevel = 'n' | 'o' | 'a';
export interface UserPermissions {
view: boolean;
read: PermissionLevel;
create: PermissionLevel;
update: PermissionLevel;
delete: PermissionLevel;
}
export type PermissionContext = 'DATA' | 'UI' | 'RESOURCE';
// Type for the request function passed to API functions
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
// ============================================================================
// API REQUEST FUNCTIONS
// ============================================================================
/**
* Fetch permissions for a given context and item
* Endpoint: GET /api/rbac/permissions
* Query params: context (required), item (optional)
*/
export async function fetchPermissions(
request: ApiRequestFunction,
context: PermissionContext,
item?: string
): Promise<UserPermissions> {
const params: Record<string, string> = { context };
if (item) {
params.item = item;
}
const data = await request<UserPermissions>({
url: '/api/rbac/permissions',
method: 'get',
params
});
return data;
}

191
src/api/promptApi.ts Normal file
View file

@ -0,0 +1,191 @@
import { ApiRequestOptions } from '../hooks/useApi';
// ============================================================================
// TYPES & INTERFACES
// ============================================================================
export interface Prompt {
id: string;
mandateId: string;
content: string;
name: string;
_createdBy?: string;
_hideDelete?: boolean;
[key: string]: any; // Allow additional properties
}
export interface AttributeOption {
value: string | number;
label: string | { [key: string]: string };
}
export interface AttributeDefinition {
name: string;
type: 'text' | 'email' | 'date' | 'checkbox' | 'select' | 'multiselect' | 'number' | 'textarea';
label: string;
description?: string;
required?: boolean;
default?: any;
options?: AttributeOption[] | string;
validation?: any;
ui?: any;
readonly?: boolean;
editable?: boolean;
visible?: boolean;
order?: number;
placeholder?: string;
sortable?: boolean;
filterable?: boolean;
searchable?: boolean;
width?: number;
minWidth?: number;
maxWidth?: number;
filterOptions?: string[];
}
export interface PaginationParams {
page?: number;
pageSize?: number;
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
filters?: Record<string, any>;
search?: string;
}
export interface PaginatedResponse<T> {
items: T[];
pagination?: {
currentPage: number;
pageSize: number;
totalItems: number;
totalPages: number;
};
}
export interface CreatePromptData {
mandateId: string;
name: string;
content: string;
}
export interface UpdatePromptData {
mandateId: string;
name: string;
content: string;
}
// Type for the request function passed to API functions
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
// ============================================================================
// API REQUEST FUNCTIONS
// ============================================================================
/**
* Fetch prompt attributes from backend
* Endpoint: GET /api/attributes/Prompt
*/
export async function fetchPromptAttributes(request: ApiRequestFunction): Promise<AttributeDefinition[]> {
// Note: This uses api.get directly due to response format handling
// For now, we'll use api.get directly in the hook as well
throw new Error('fetchPromptAttributes should use api instance directly for response format handling');
}
/**
* Fetch list of prompts with optional pagination
* Endpoint: GET /api/prompts
*/
export async function fetchPrompts(
request: ApiRequestFunction,
params?: PaginationParams
): Promise<PaginatedResponse<Prompt> | Prompt[]> {
const requestParams: any = {};
// Build pagination object if provided
if (params) {
const paginationObj: any = {};
if (params.page !== undefined) paginationObj.page = params.page;
if (params.pageSize !== undefined) paginationObj.pageSize = params.pageSize;
if (params.sort) paginationObj.sort = params.sort;
if (params.filters) paginationObj.filters = params.filters;
if (params.search) paginationObj.search = params.search;
if (Object.keys(paginationObj).length > 0) {
requestParams.pagination = JSON.stringify(paginationObj);
}
}
const data = await request<PaginatedResponse<Prompt> | Prompt[]>({
url: '/api/prompts',
method: 'get',
params: requestParams
});
return data;
}
/**
* Fetch a single prompt by ID
* Endpoint: GET /api/prompts/{promptId}
*/
export async function fetchPromptById(
request: ApiRequestFunction,
promptId: string
): Promise<Prompt | null> {
try {
const data = await request<Prompt>({
url: `/api/prompts/${promptId}`,
method: 'get'
});
return data || null;
} catch (error: any) {
console.error('Error fetching prompt by ID:', error);
return null;
}
}
/**
* Create a new prompt
* Endpoint: POST /api/prompts
*/
export async function createPrompt(
request: ApiRequestFunction,
promptData: CreatePromptData
): Promise<Prompt> {
return await request<Prompt>({
url: '/api/prompts',
method: 'post',
data: promptData
});
}
/**
* Update a prompt
* Endpoint: PUT /api/prompts/{promptId}
*/
export async function updatePrompt(
request: ApiRequestFunction,
promptId: string,
promptData: UpdatePromptData
): Promise<Prompt> {
return await request<Prompt>({
url: `/api/prompts/${promptId}`,
method: 'put',
data: promptData
});
}
/**
* Delete a prompt
* Endpoint: DELETE /api/prompts/{promptId}
*/
export async function deletePrompt(
request: ApiRequestFunction,
promptId: string
): Promise<void> {
await request({
url: `/api/prompts/${promptId}`,
method: 'delete'
});
}

215
src/api/userApi.ts Normal file
View file

@ -0,0 +1,215 @@
import { ApiRequestOptions } from '../hooks/useApi';
// ============================================================================
// TYPES & INTERFACES
// ============================================================================
export interface User {
id: string;
username: string;
email: string;
fullName: string;
language: string;
enabled: boolean;
privilege: string;
authenticationAuthority: string;
mandateId: string;
[key: string]: any; // Allow additional properties
}
export type UserUpdateData = Partial<Omit<User, 'id' | 'mandateId'>>;
export interface AttributeDefinition {
name: string;
type: 'text' | 'email' | 'date' | 'checkbox' | 'select' | 'multiselect' | 'number' | 'textarea';
label: string;
description?: string;
required?: boolean;
default?: any;
options?: Array<{ value: string | number; label: string | { [key: string]: string } }> | string;
validation?: any;
sortable?: boolean;
filterable?: boolean;
searchable?: boolean;
width?: number;
minWidth?: number;
maxWidth?: number;
filterOptions?: string[];
}
export interface PaginationParams {
page?: number;
pageSize?: number;
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
filters?: Record<string, any>;
search?: string;
}
export interface PaginatedResponse<T> {
items: T[];
pagination?: {
currentPage: number;
pageSize: number;
totalItems: number;
totalPages: number;
};
}
// Type for the request function passed to API functions
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
// ============================================================================
// API REQUEST FUNCTIONS
// ============================================================================
/**
* Fetch current user data
* Endpoint: GET /api/local/me | /api/msft/me | /api/google/me
*/
export async function fetchCurrentUser(
request: ApiRequestFunction,
authAuthority?: string
): Promise<User> {
let endpoint = '/api/local/me';
if (authAuthority === 'msft') {
endpoint = '/api/msft/me';
} else if (authAuthority === 'google') {
endpoint = '/api/google/me';
}
return await request<User>({
url: endpoint,
method: 'get'
});
}
/**
* Logout current user
* Endpoint: POST /api/local/logout | /api/msft/logout
*/
export async function logoutUser(
request: ApiRequestFunction,
authAuthority: string = 'local'
): Promise<void> {
let endpoint = '/api/local/logout';
if (authAuthority === 'msft') {
endpoint = '/api/msft/logout';
}
await request({
url: endpoint,
method: 'post'
});
}
/**
* Fetch user attributes from backend
* Endpoint: GET /api/attributes/User
*/
export async function fetchUserAttributes(request: ApiRequestFunction): Promise<AttributeDefinition[]> {
// Note: This uses api.get directly in the hook due to response format handling
// Keeping the function signature here for consistency, but implementation may need api instance
throw new Error('fetchUserAttributes should use api instance directly for response format handling');
}
/**
* Fetch list of users with optional pagination
* Endpoint: GET /api/users/
*/
export async function fetchUsers(
request: ApiRequestFunction,
params?: PaginationParams
): Promise<PaginatedResponse<User> | User[]> {
const requestParams: any = {};
// Build pagination object if provided
if (params) {
const paginationObj: any = {};
if (params.page !== undefined) paginationObj.page = params.page;
if (params.pageSize !== undefined) paginationObj.pageSize = params.pageSize;
if (params.sort) paginationObj.sort = params.sort;
if (params.filters) paginationObj.filters = params.filters;
if (params.search) paginationObj.search = params.search;
if (Object.keys(paginationObj).length > 0) {
requestParams.pagination = JSON.stringify(paginationObj);
}
}
const data = await request<PaginatedResponse<User> | User[]>({
url: '/api/users/',
method: 'get',
params: requestParams
});
return data;
}
/**
* Fetch a single user by ID
* Endpoint: GET /api/users/{userId}
*/
export async function fetchUserById(
request: ApiRequestFunction,
userId: string
): Promise<User | null> {
try {
const data = await request<User>({
url: `/api/users/${userId}`,
method: 'get'
});
return data || null;
} catch (error: any) {
console.error('Error fetching user by ID:', error);
return null;
}
}
/**
* Create a new user
* Endpoint: POST /api/users
*/
export async function createUser(
request: ApiRequestFunction,
userData: Partial<User>
): Promise<User> {
return await request<User>({
url: '/api/users',
method: 'post',
data: userData
});
}
/**
* Update a user
* Endpoint: PUT /api/users/{userId}
*/
export async function updateUser(
request: ApiRequestFunction,
userId: string,
userData: UserUpdateData
): Promise<User> {
return await request<User>({
url: `/api/users/${userId}`,
method: 'put',
data: userData
});
}
/**
* Delete a user
* Endpoint: DELETE /api/users/{userId}
*/
export async function deleteUser(
request: ApiRequestFunction,
userId: string
): Promise<void> {
await request({
url: `/api/users/${userId}`,
method: 'delete'
});
}

View file

@ -86,8 +86,8 @@ export async function fetchWorkflows(request: ApiRequestFunction): Promise<Workf
export async function fetchWorkflow(
request: ApiRequestFunction,
workflowId: string
): Promise<Workflow> {
return await request<Workflow>({
): Promise<Workflow & { messages?: WorkflowMessage[]; logs?: WorkflowLog[] }> {
return await request<any>({
url: `/api/workflows/${workflowId}`,
method: 'get'
});
@ -100,11 +100,20 @@ export async function fetchWorkflow(
export async function fetchWorkflowStatus(
request: ApiRequestFunction,
workflowId: string
): Promise<Workflow> {
return await request<Workflow>({
): Promise<Workflow | { status: string } | null> {
const data = await request<any>({
url: `/api/workflows/${workflowId}/status`,
method: 'get'
});
if (data && typeof data === 'object') {
if (data.status) {
return { status: data.status };
}
return data;
}
return null;
}
/**
@ -118,12 +127,26 @@ export async function fetchWorkflowMessages(
messageId?: string
): Promise<WorkflowMessage[]> {
const params = messageId ? { messageId } : undefined;
const data = await request<WorkflowMessage[]>({
const data = await request<any>({
url: `/api/workflows/${workflowId}/messages`,
method: 'get',
params
});
return Array.isArray(data) ? data : [];
if (Array.isArray(data)) {
return data;
}
if (data && typeof data === 'object') {
if (Array.isArray(data.messages)) {
return data.messages;
}
if (Array.isArray(data.data)) {
return data.data;
}
}
return [];
}
/**
@ -137,12 +160,26 @@ export async function fetchWorkflowLogs(
logId?: string
): Promise<WorkflowLog[]> {
const params = logId ? { logId } : undefined;
const data = await request<WorkflowLog[]>({
const data = await request<any>({
url: `/api/workflows/${workflowId}/logs`,
method: 'get',
params
});
return Array.isArray(data) ? data : [];
if (Array.isArray(data)) {
return data;
}
if (data && typeof data === 'object') {
if (Array.isArray(data.logs)) {
return data.logs;
}
if (Array.isArray(data.data)) {
return data.data;
}
}
return [];
}
/**
@ -185,24 +222,44 @@ export async function fetchChatData(
export async function startWorkflowApi(
request: ApiRequestFunction,
workflowData: StartWorkflowRequest,
options?: { workflowId?: string; workflowMode?: 'Actionplan' | 'React' }
options?: { workflowId?: string; workflowMode?: 'Dynamic' | 'Automation' }
): Promise<StartWorkflowResponse> {
const params: Record<string, string> = {};
// workflowMode is REQUIRED according to API spec
if (options?.workflowMode) {
params.workflowMode = options.workflowMode;
} else {
// Default to 'Dynamic' if not provided (though it should always be provided)
params.workflowMode = 'Dynamic';
}
if (options?.workflowId) {
params.workflowId = options.workflowId;
}
if (options?.workflowMode) {
params.workflowMode = options.workflowMode;
}
// Request body uses 'prompt' field (not 'input') according to API spec
const requestBody: any = {
prompt: workflowData.prompt,
...(workflowData.listFileId && workflowData.listFileId.length > 0 && { listFileId: workflowData.listFileId }),
...(workflowData.userLanguage && { userLanguage: workflowData.userLanguage }),
...(workflowData.metadata && { metadata: workflowData.metadata })
};
const requestConfig = {
url: '/api/chat/playground/start',
method: 'post' as const,
data: workflowData,
params: Object.keys(params).length > 0 ? params : undefined
data: requestBody,
params: params // Always include workflowMode
};
console.log('📤 startWorkflow request:', requestConfig);
// Log full request details
console.log('📤 Full startWorkflow request details:');
console.log(' URL:', requestConfig.url);
console.log(' Method:', requestConfig.method);
console.log(' Query Parameters:', params);
console.log(' Request Body:', JSON.stringify(requestBody, null, 2));
console.log(' Full Request Config:', JSON.stringify(requestConfig, null, 2));
const response = await request<StartWorkflowResponse>(requestConfig);
@ -307,3 +364,60 @@ export async function deleteFileFromMessageApi(
});
}
/**
* Fetch attributes for a workflow type
* Endpoint: GET /api/attributes/{entityType}
*/
export interface AttributeDefinition {
name: string;
type: 'text' | 'email' | 'date' | 'checkbox' | 'select' | 'multiselect' | 'number' | 'textarea';
label: string;
description?: string;
required?: boolean;
default?: any;
options?: Array<{ value: string | number; label: string | { [key: string]: string } }> | string;
validation?: any;
ui?: any;
readonly?: boolean;
editable?: boolean;
visible?: boolean;
order?: number;
placeholder?: string;
sortable?: boolean;
filterable?: boolean;
searchable?: boolean;
width?: number;
minWidth?: number;
maxWidth?: number;
filterOptions?: string[];
}
export async function fetchAttributes(
request: ApiRequestFunction,
entityType: string = 'ChatWorkflow'
): Promise<AttributeDefinition[]> {
const data = await request<any>({
url: `/api/attributes/${entityType}`,
method: 'get'
});
// Extract attributes from response - check if response.data.attributes exists, otherwise check if response.data is an array
let attrs: AttributeDefinition[] = [];
if (data?.attributes && Array.isArray(data.attributes)) {
attrs = data.attributes;
} else if (Array.isArray(data)) {
attrs = data;
} else if (data && typeof data === 'object') {
// Try to find any array property in the response
const keys = Object.keys(data);
for (const key of keys) {
if (Array.isArray(data[key])) {
attrs = data[key];
break;
}
}
}
return attrs;
}

View file

@ -841,3 +841,7 @@
word-wrap: inherit;
}
.contentPreviewPopup {
/* Popup-specific styles if needed */
}

View file

@ -16,9 +16,9 @@ import {
LoadingRenderer,
ErrorRenderer
} from './renderers';
import styles from './FilePreview.module.css';
import styles from './ContentPreview.module.css';
export interface FilePreviewProps {
export interface ContentPreviewProps {
isOpen: boolean;
onClose: () => void;
fileId: string;
@ -26,20 +26,20 @@ export interface FilePreviewProps {
mimeType?: string;
}
export function FilePreview({
export function ContentPreview({
isOpen,
onClose,
fileId,
fileName,
mimeType
}: FilePreviewProps) {
}: ContentPreviewProps) {
const { t } = useLanguage();
const { handleFilePreview, handleFileDownload, previewingFiles, previewError, downloadingFiles } = useFileOperations();
// Debug logging to see what data we're receiving
useEffect(() => {
if (isOpen && import.meta.env.DEV) {
console.log('FilePreview received:', { fileId, fileName, mimeType });
console.log('ContentPreview received:', { fileId, fileName, mimeType });
}
}, [isOpen, fileId, fileName, mimeType]);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
@ -162,7 +162,7 @@ export function FilePreview({
const renderPreview = () => {
// Handle text content in PDF files (corrupted files) - check this first
if (previewContent && !previewUrl && mimeType === 'application/pdf') {
console.log('🔍 FilePreview: Rendering corrupted PDF with text content');
console.log('🔍 ContentPreview: Rendering corrupted PDF with text content');
return (
<PdfRenderer
previewUrl={undefined}
@ -246,7 +246,7 @@ export function FilePreview({
case 'application':
if (mimeType === 'application/pdf') {
console.log('🔍 FilePreview passing normal PDF to PdfRenderer:', {
console.log('🔍 ContentPreview passing normal PDF to PdfRenderer:', {
previewUrl,
previewContent: previewContent ? `${previewContent.substring(0, 50)}...` : null,
fileName,
@ -292,9 +292,9 @@ export function FilePreview({
<Popup
isOpen={isOpen}
onClose={onClose}
title={`${t('files.preview.title', 'File Preview')}: ${fileName}`}
title={`${t('files.preview.title', 'Content Preview')}: ${fileName}`}
size="fullscreen"
className={styles.filePreviewPopup}
className={styles.contentPreviewPopup}
actions={actions}
>
<div className={styles.previewContainer}>
@ -304,6 +304,5 @@ export function FilePreview({
);
}
export default FilePreview;
export default ContentPreview;

View file

@ -0,0 +1,3 @@
export { ContentPreview } from './ContentPreview';
export type { ContentPreviewProps } from './ContentPreview';

View file

@ -1,4 +1,4 @@
import styles from '../FilePreview.module.css';
import styles from '../ContentPreview.module.css';
interface ApplicationRendererProps {
previewUrl: string;
@ -19,3 +19,4 @@ export function ApplicationRenderer({ previewUrl, fileName, mimeType, onError }:
/>
);
}

View file

@ -1,5 +1,5 @@
import { useLanguage } from '../../../providers/language/LanguageContext';
import styles from '../FilePreview.module.css';
import styles from '../ContentPreview.module.css';
interface ErrorRendererProps {
error: string;
@ -22,3 +22,4 @@ export function ErrorRenderer({ error, onRetry }: ErrorRendererProps) {
</div>
);
}

View file

@ -1,4 +1,4 @@
import styles from '../FilePreview.module.css';
import styles from '../ContentPreview.module.css';
interface HtmlRendererProps {
previewUrl: string;
@ -39,3 +39,4 @@ export function HtmlRenderer({ previewUrl, fileName, onError }: HtmlRendererProp
/>
);
}

View file

@ -1,4 +1,4 @@
import styles from '../FilePreview.module.css';
import styles from '../ContentPreview.module.css';
interface ImageRendererProps {
previewUrl: string;
@ -32,3 +32,4 @@ export function ImageRenderer({ previewUrl, fileName, onError }: ImageRendererPr
/>
);
}

View file

@ -1,6 +1,6 @@
import { useState } from 'react';
import { useLanguage } from '../../../providers/language/LanguageContext';
import styles from '../FilePreview.module.css';
import styles from '../ContentPreview.module.css';
interface JsonRendererProps {
previewContent: string;
@ -504,3 +504,4 @@ export function JsonRenderer({ previewContent, fileName }: JsonRendererProps) {
);
}
}

View file

@ -1,5 +1,5 @@
import { useLanguage } from '../../../providers/language/LanguageContext';
import styles from '../FilePreview.module.css';
import styles from '../ContentPreview.module.css';
export function LoadingRenderer() {
const { t } = useLanguage();
@ -11,3 +11,4 @@ export function LoadingRenderer() {
</div>
);
}

View file

@ -1,6 +1,6 @@
import { IoIosWarning } from 'react-icons/io';
import { useLanguage } from '../../../providers/language/LanguageContext';
import styles from '../FilePreview.module.css';
import styles from '../ContentPreview.module.css';
interface PdfRendererProps {
previewUrl?: string;
@ -51,3 +51,4 @@ export function PdfRenderer({ previewUrl, previewContent, fileName, onError }: P
/>
);
}

View file

@ -1,4 +1,4 @@
import styles from '../FilePreview.module.css';
import styles from '../ContentPreview.module.css';
// Updated to handle both previewUrl and previewContent
@ -39,3 +39,4 @@ export function TextRenderer({ previewUrl, previewContent, fileName, mimeType, o
/>
);
}

View file

@ -1,5 +1,5 @@
import { useLanguage } from '../../../providers/language/LanguageContext';
import styles from '../FilePreview.module.css';
import styles from '../ContentPreview.module.css';
interface UnsupportedRendererProps {
previewUrl: string;
@ -24,3 +24,4 @@ export function UnsupportedRenderer({ previewUrl, fileName }: UnsupportedRendere
</div>
);
}

View file

@ -7,3 +7,4 @@ export { ApplicationRenderer } from './ApplicationRenderer';
export { UnsupportedRenderer } from './UnsupportedRenderer';
export { LoadingRenderer } from './LoadingRenderer';
export { ErrorRenderer } from './ErrorRenderer';

View file

@ -1,2 +0,0 @@
export { FilePreview } from './FilePreview';
export type { FilePreviewProps } from './FilePreview';

View file

@ -14,6 +14,7 @@ export interface DownloadActionButtonProps<T = any> {
hookData?: any; // Contains all hook data including operations
// Field mappings
idField?: string; // Field name for the unique identifier
nameField?: string; // Field name for file name (with extension)
loadingStateName?: string; // Name of the loading state in hookData
operationName?: string; // Name of the operation function in hookData
}
@ -28,6 +29,7 @@ export function DownloadActionButton<T = any>({
isDownloading = false,
hookData,
idField = 'id',
nameField,
loadingStateName = 'downloadingFiles',
operationName
}: DownloadActionButtonProps<T>) {
@ -38,6 +40,32 @@ export function DownloadActionButton<T = any>({
const isDisabled = typeof disabled === 'boolean' ? disabled : disabled?.disabled || false;
const disabledMessage = typeof disabled === 'object' ? disabled?.message : undefined;
// Extract file name from row using nameField or fallback to common field names
const getFileName = (): string => {
const rowAny = row as any;
// If nameField is explicitly provided, use it
if (nameField && rowAny[nameField]) {
return rowAny[nameField];
}
// Try common field names in order of preference
if (rowAny.fileName) return rowAny.fileName;
if (rowAny.file_name) return rowAny.file_name;
if (rowAny.name) return rowAny.name;
// Fallback: try to find any field that might contain the file name
const possibleFields = ['fileName', 'file_name', 'name', 'filename'];
for (const field of possibleFields) {
if (rowAny[field]) {
return rowAny[field];
}
}
// Last resort: use id or a default
return rowAny[idField] || 'download';
};
const handleClick = async (e: React.MouseEvent) => {
e.stopPropagation();
if (!isDisabled && !loading && !isDownloading && !internalLoading) {
@ -45,7 +73,9 @@ export function DownloadActionButton<T = any>({
try {
// If operationName is provided and hookData is available, use the hook function
if (operationName && hookData && hookData[operationName]) {
await hookData[operationName]((row as any)[idField], (row as any).file_name);
const fileId = (row as any)[idField];
const fileName = getFileName();
await hookData[operationName](fileId, fileName);
} else if (onDownload) {
// Fallback to the provided onDownload function
await onDownload(row);

View file

@ -0,0 +1,25 @@
/* Loading state */
.loadingContainer {
padding: 20px;
text-align: center;
}
.loadingSpinner {
margin-bottom: 10px;
font-size: 24px;
}
.loadingText {
margin: 0;
}
/* Error message */
.errorMessage {
padding: 10px;
margin-bottom: 15px;
background-color: #fee;
color: #c33;
border-radius: 4px;
font-size: 14px;
}

View file

@ -1,7 +1,8 @@
import React, { useState } from 'react';
import { MdModeEdit } from 'react-icons/md';
import { useLanguage } from '../../../../providers/language/LanguageContext';
import { Popup, EditForm } from '../../../UiComponents/Popup';
import { Popup } from '../../../UiComponents/Popup';
import { FormGeneratorForm } from '../../FormGeneratorForm';
import styles from '../ActionButton.module.css';
export interface EditActionButtonProps<T = any> {
@ -19,15 +20,12 @@ export interface EditActionButtonProps<T = any> {
typeField?: string; // Field name for type/mime type
operationName?: string; // Name of the operation function in hookData
loadingStateName?: string; // Name of the loading state in hookData
// Edit configuration
editFields?: Array<{
key: string;
label: string;
type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'readonly';
editable?: boolean;
required?: boolean;
validator?: (value: any) => string | null;
}>;
// Function name in hookData to fetch a single item (e.g., 'fetchPromptById', 'fetchItem')
fetchItemFunctionName?: string;
// Entity type for FormGeneratorForm (e.g., "Prompt", "User", "FileItem")
entityType?: string;
// Optional: Pre-fetched attributes (if available in hookData)
attributes?: any[];
}
export function EditActionButton<T = any>({
@ -42,29 +40,15 @@ export function EditActionButton<T = any>({
idField = 'id',
operationName = 'handleFileUpdate',
loadingStateName = 'editingFiles',
editFields = [
{
key: 'file_name',
label: 'Filename',
type: 'string',
editable: true,
required: true,
validator: (value: string) => {
if (!value || value.trim() === '') {
return 'Filename cannot be empty';
}
if (value.includes('/') || value.includes('\\')) {
return 'Filename cannot contain / or \\ characters';
}
return null;
}
}
]
fetchItemFunctionName = 'fetchPromptById',
entityType,
attributes: providedAttributes
}: EditActionButtonProps<T>) {
const { t } = useLanguage();
const [internalLoading, setInternalLoading] = useState(false);
const [isPopupOpen, setIsPopupOpen] = useState(false);
const [editData, setEditData] = useState<T | null>(null);
const [fetchingData, setFetchingData] = useState(false);
// Extract disabled state and tooltip message
const isDisabled = typeof disabled === 'boolean' ? disabled : disabled?.disabled || false;
@ -77,21 +61,83 @@ export function EditActionButton<T = any>({
throw new Error('EditActionButton requires hookData to be provided');
}
// Get entity type from hookData or props
const getEntityType = (): string | undefined => {
if (entityType) return entityType;
if (hookData.entityType) return hookData.entityType;
if (hookData.entityName) return hookData.entityName;
// Try to infer from hookData attributes if available
if (hookData.attributes && Array.isArray(hookData.attributes) && hookData.attributes.length > 0) {
// Could potentially infer from attribute structure, but safer to require explicit entityType
return undefined;
}
return undefined;
};
// Get attributes from hookData or props
const getAttributes = () => {
if (providedAttributes) return providedAttributes;
if (hookData.attributes && Array.isArray(hookData.attributes)) return hookData.attributes;
return undefined;
};
const handleClick = async (e: React.MouseEvent) => {
e.stopPropagation();
if (!isDisabled && !loading && !isEditing && !internalLoading) {
if (!isDisabled && !loading && !isEditing && !internalLoading && !fetchingData && !isPopupOpen) {
setInternalLoading(true);
setFetchingData(true);
try {
// Debug logging to see what data we're working with
// Call the onEdit callback if provided
if (onEdit) {
await onEdit(row);
}
// Set up edit data and open popup
setEditData(row);
const itemId = (row as any)[idField];
// Fetch current item data - use generic fetch function from hookData
let freshData: T | null = null;
if (itemId) {
const possibleFunctionNames = [
fetchItemFunctionName,
'fetchItemById',
'fetchItem',
'getItemById',
'getItem'
].filter(Boolean);
let fetchFunction: ((id: string) => Promise<T | null>) | null = null;
for (const funcName of possibleFunctionNames) {
if (hookData[funcName] && typeof hookData[funcName] === 'function') {
fetchFunction = hookData[funcName];
break;
}
}
if (fetchFunction) {
try {
freshData = await fetchFunction(itemId);
} catch (error: any) {
console.error('Failed to fetch fresh data:', error);
}
}
}
// Ensure attributes are loaded - use generic function from hookData if available
if (hookData.ensureAttributesLoaded && typeof hookData.ensureAttributesLoaded === 'function') {
await hookData.ensureAttributesLoaded();
}
// Use fresh data if available, otherwise use row data
setEditData(freshData || row);
// Set fetchingData to false first
setFetchingData(false);
// Wait for React to update state
await new Promise(resolve => setTimeout(resolve, 0));
// Open popup AFTER data is ready - like CreateButton (no loading state shown)
setIsPopupOpen(true);
} finally {
setInternalLoading(false);
@ -108,21 +154,22 @@ export function EditActionButton<T = any>({
// Get the item ID from the row
const itemId = (editData as any)[idField];
// Get edit fields configuration
const fields = getEditFields();
// Extract the fields to update from the edit data
const updateData: any = {};
editFields.forEach(field => {
fields.forEach(field => {
if (field.editable !== false) {
// Map frontend field names to API field names
if (field.key === 'file_name') {
updateData.fileName = (updatedData as any)[field.key];
} else {
updateData[field.key] = (updatedData as any)[field.key];
const value = (updatedData as any)[field.key];
if (value !== undefined) {
updateData[field.key] = value;
}
}
});
// Check if optimistic update is available
const updateOptimistically = hookData.updateOptimistically || hookData.updateFileOptimistically;
const updateOptimistically = hookData.updateOptimistically;
// Validate required operation exists
if (!hookData[operationName]) {
@ -134,39 +181,40 @@ export function EditActionButton<T = any>({
updateOptimistically(itemId, updateData);
}
// Close popup and reset state immediately for better UX
setIsPopupOpen(false);
setEditData(null);
// Use hookData operation to update in the background
const result = await hookData[operationName](itemId, updateData, editData);
const success = result?.success || result === true;
if (success) {
// If we used optimistic update, don't refetch to avoid overwriting our changes
if (updateOptimistically) {
// Trust the optimistic update worked
} else {
// No optimistic update, refetch to sync with backend
if (hookData.refetch) {
await hookData.refetch();
}
}
} else {
// If update failed, refetch to restore original state
// Close popup and reset state on success
setIsPopupOpen(false);
setEditData(null);
// If we used optimistic update, refetch to get fresh data from backend
// This ensures we have the latest data including any server-side transformations
if (hookData.refetch) {
await hookData.refetch();
}
console.error('Failed to update item:', itemId);
// TODO: Show error message to user
} else {
// If update failed, revert optimistic update
if (updateOptimistically && hookData.refetch) {
// Revert by refetching original data
await hookData.refetch();
}
// Close popup on error
setIsPopupOpen(false);
setEditData(null);
}
} catch (error) {
// If update failed, refetch to restore original state
if (hookData.refetch) {
} catch (error: any) {
// If update failed, revert optimistic update
if (hookData.updateOptimistically && hookData.refetch) {
await hookData.refetch();
}
console.error('Failed to update item:', error);
// TODO: Show error message to user
setIsPopupOpen(false);
setEditData(null);
} finally {
setInternalLoading(false);
}
@ -181,13 +229,11 @@ export function EditActionButton<T = any>({
// Use hookData editing state if available, otherwise use passed isEditing
const loadingState = hookData?.[loadingStateName];
const actualIsEditing = loadingState?.has((row as any)[idField]) || isEditing;
const isLoading = loading || actualIsEditing || internalLoading;
const isLoading = loading || actualIsEditing || internalLoading || fetchingData;
// Determine the final button title (tooltip)
const finalTitle = isDisabled && disabledMessage ? disabledMessage : buttonTitle;
return (
<>
<button
@ -201,31 +247,40 @@ export function EditActionButton<T = any>({
</span>
</button>
{/* Edit Popup */}
{/* Edit Popup - Identical structure to CreateButton */}
<Popup
isOpen={isPopupOpen}
title={t('files.edit.title', 'Edit Item')}
onClose={handleCancel}
size="small"
closable={true}
size="medium"
closable={!internalLoading}
>
{editData && (
<EditForm
data={editData}
fields={editFields.map(field => ({
key: field.key,
label: field.label,
type: field.type,
editable: field.editable ?? true,
required: field.required ?? false,
validator: field.validator
}))}
onSave={handleSave}
onCancel={handleCancel}
saveButtonText={t('common.save', 'Save')}
cancelButtonText={t('common.cancel', 'Cancel')}
/>
)}
{editData && (() => {
const entityTypeValue = getEntityType();
const attributesValue = getAttributes();
if (!entityTypeValue && !attributesValue) {
console.warn('EditActionButton: entityType or attributes must be provided for FormGeneratorForm');
return (
<div style={{ padding: '20px', textAlign: 'center' }}>
{t('common.error', 'Error: Entity type or attributes must be provided')}
</div>
);
}
return (
<FormGeneratorForm
entityType={entityTypeValue}
attributes={attributesValue}
data={editData}
mode="edit"
onSubmit={handleSave}
onCancel={handleCancel}
submitButtonText={t('common.save', 'Save')}
cancelButtonText={t('common.cancel', 'Cancel')}
/>
);
})()}
</Popup>
</>
);

View file

@ -16,8 +16,11 @@ export interface PlayActionButtonProps<T = any> {
// Field mappings
idField?: string; // Field name for the unique identifier
nameField?: string; // Field name for display name
contentField?: string; // Field name for content (e.g., 'content' for prompts, 'prompt' for workflows)
// Navigation
navigateTo?: string; // Path to navigate to after selection (default: 'start/dashboard')
// Behavior
mode?: 'workflow' | 'prompt'; // 'workflow' selects workflow, 'prompt' sets input value
}
export function PlayActionButton<T = any>({
@ -30,7 +33,9 @@ export function PlayActionButton<T = any>({
hookData,
idField = 'id',
nameField = 'name',
navigateTo = 'start/dashboard'
contentField = 'content',
navigateTo = 'start/dashboard',
mode = 'prompt'
}: PlayActionButtonProps<T>) {
const { t } = useLanguage();
const navigate = useNavigate();
@ -44,30 +49,41 @@ export function PlayActionButton<T = any>({
e.stopPropagation();
if (!isDisabled && !loading) {
try {
// Get workflow ID from row
const workflowId = (row as any)[idField];
if (!workflowId) {
console.error('Workflow ID not found in row');
return;
}
// Call the onPlay callback if provided
if (onPlay) {
await onPlay(row);
}
// Select the workflow in context
selectWorkflow(workflowId);
if (mode === 'workflow') {
// Workflow mode: select workflow and navigate
const workflowId = (row as any)[idField];
if (!workflowId) {
console.error('Workflow ID not found in row');
return;
}
selectWorkflow(workflowId);
} else {
// Prompt mode: set input value in dashboard
const content = (row as any)[contentField];
if (content && typeof content === 'string') {
// Dispatch event to set dashboard input value
window.dispatchEvent(new CustomEvent('dashboardSetInput', {
detail: { value: content }
}));
}
}
// Navigate to dashboard (or specified path)
navigate(`/${navigateTo}`);
} catch (error: any) {
console.error('Error playing workflow:', error);
console.error('Error in PlayActionButton:', error);
}
}
};
const buttonTitle = title || t('workflows.action.play', 'Play');
const buttonTitle = title || (mode === 'workflow'
? t('workflows.action.play', 'Play')
: t('prompts.action.start', 'Start Prompt'));
const isLoading = loading;
const finalTitle = isDisabled && disabledMessage ? disabledMessage : buttonTitle;

View file

@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { IoIosEye } from 'react-icons/io';
import { useLanguage } from '../../../../providers/language/LanguageContext';
import { FilePreview } from '../../../FilePreview/FilePreview';
import { ContentPreview } from '../../../ContentPreview';
import styles from '../ActionButton.module.css';
export interface ViewActionButtonProps<T = any> {
@ -82,8 +82,8 @@ export function ViewActionButton<T = any>({
</span>
</button>
{/* File Preview Component */}
<FilePreview
{/* Content Preview Component */}
<ContentPreview
isOpen={isPopupOpen}
onClose={() => setIsPopupOpen(false)}
fileId={(row as any)[idField]}

View file

@ -0,0 +1,273 @@
/* Integrated Delete Controls - appears inside the controls container */
.deleteControlsIntegrated {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
/* Controls Section */
.controls {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 15px;
background: var(--color-bg);
border: 1px solid var(--color-primary);
border-radius: 25px;
}
.searchContainer {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.refreshButton {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border: 1px solid var(--color-primary);
border-radius: 50%;
background: var(--color-bg);
color: var(--color-text);
cursor: pointer;
transition: all 0.2s ease;
font-size: 16px;
font-family: var(--font-family);
flex-shrink: 0;
}
.refreshButton:hover:not(:disabled) {
background: var(--color-secondary);
color: white;
border-color: var(--color-secondary);
}
.refreshButton:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.refreshIcon {
font-size: 18px;
font-weight: bold;
transition: transform 0.2s ease;
}
.floatingLabelInput {
position: relative;
width: 250px;
}
.label {
position: absolute;
left: 16px;
top: 50%;
transform: translateY(-50%);
color: var(--color-text);
opacity: 0.6;
font-size: 14px;
pointer-events: none;
transition: all 0.3s ease;
background-color: transparent;
font-family: var(--font-family);
}
.focusedLabel {
position: absolute;
left: 12px;
top: -8px;
transform: translateY(0);
color: var(--color-secondary);
font-size: 12px;
pointer-events: none;
transition: all 0.3s ease;
background-color: var(--color-bg);
padding: 0 4px;
font-family: var(--font-family);
font-weight: 500;
}
.searchInput {
width: 100%;
height: 40px;
padding: 8px 12px;
border: 1px solid var(--color-primary);
border-radius: 25px;
font-size: 14px;
font-family: var(--font-family);
background: var(--color-bg);
color: var(--color-text);
transition: all 0.2s ease;
box-sizing: border-box;
}
.searchInput:focus {
border-color: var(--color-secondary);
box-shadow: 0 0 0 2px rgba(var(--color-secondary), 0.1);
}
.searchInput::placeholder {
color: transparent;
}
.filtersContainer {
display: flex;
flex-wrap: wrap;
gap: 15px;
align-items: center;
}
.filterGroup {
display: flex;
align-items: center;
gap: 8px;
}
.filterGroup .floatingLabelInput {
width: 160px;
}
.customSelectContainer {
position: relative;
display: inline-block;
min-width: 120px;
}
.filterInput {
width: 100%;
height: 40px;
padding: 6px 10px;
border: 1px solid var(--color-primary);
border-radius: 25px;
font-size: 14px;
font-family: var(--font-family);
background: var(--color-bg);
color: var(--color-text);
opacity: 0.6;
min-width: 120px;
transition: all 0.2s ease;
box-sizing: border-box;
}
.filterInput:focus {
outline: none;
border-color: var(--color-secondary);
opacity: 1;
box-shadow: 0 0 0 2px rgba(var(--color-secondary), 0.1);
}
.filterInput::placeholder {
color: transparent;
}
.filterSelect {
height: 40px;
padding: 6px 10px;
border: 1px solid var(--color-primary);
border-radius: 25px;
font-size: 14px;
font-family: var(--font-family);
background: var(--color-bg);
color: var(--color-text);
opacity: 0.6;
min-width: 120px;
box-sizing: border-box;
appearance: none;
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6,9 12,15 18,9'%3e%3c/polyline%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 10px center;
background-size: 16px;
padding-right: 35px;
}
/* Hide dropdown arrow when filter has a value */
.filterSelect.hasValue {
background-image: none;
color: var(--color-secondary);
border-color: var(--color-secondary);
opacity: 1;
}
.filterSelect:focus {
outline: none;
border-color: var(--color-secondary);
opacity: 1;
}
.clearFilterButton {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: var(--color-primary);
cursor: pointer;
font-size: 16px;
padding: 2px;
border-radius: 50%;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.clearFilterButton:hover {
background: none;
color: var(--color-secondary);
}
/* Responsive Design */
@media (max-width: 768px) {
.deleteControlsIntegrated {
flex-direction: column;
align-items: stretch;
gap: 10px;
width: 100%;
}
.controls {
flex-direction: column;
align-items: stretch;
gap: 15px;
padding: 10px;
}
.filtersContainer {
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.filterGroup {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.filterInput,
.filterSelect {
width: 100%;
min-width: auto;
}
.floatingLabelInput {
max-width: none;
width: 100%;
}
.filterGroup .floatingLabelInput {
width: 100%;
}
}

View file

@ -0,0 +1,304 @@
import React from 'react';
import { useLanguage } from '../../../providers/language/LanguageContext';
import styles from './FormGeneratorControls.module.css';
import { Button } from '../../UiComponents/Button';
import { IoIosRefresh } from "react-icons/io";
import { FaTrash } from "react-icons/fa";
// Generic field/column config interface
export interface FilterableField {
key: string;
label: string;
type?: 'string' | 'number' | 'date' | 'boolean' | 'enum' | 'readonly';
filterable?: boolean;
filterOptions?: string[];
}
export interface FormGeneratorControlsProps {
// Field/column configuration
fields: FilterableField[];
// Search state
searchTerm: string;
onSearchChange: (value: string) => void;
searchFocused: boolean;
onSearchFocus: (focused: boolean) => void;
// Filter state
filters: Record<string, any>;
onFilterChange: (key: string, value: any) => void;
filterFocused: Record<string, boolean>;
onFilterFocus: (key: string, focused: boolean) => void;
// Selection state
selectedCount: number;
displayData: any[];
// Delete handlers
onDeleteSingle?: () => void;
onDeleteMultiple?: () => void;
// Refresh handler
onRefresh?: () => void;
// Flags
searchable?: boolean;
filterable?: boolean;
selectable?: boolean;
loading?: boolean;
// Special date filter handler (for FormGenerator date formatting)
onDateFilterChange?: (key: string, value: string) => void;
}
export function FormGeneratorControls({
fields,
searchTerm,
onSearchChange,
searchFocused,
onSearchFocus,
filters,
onFilterChange,
filterFocused,
onFilterFocus,
selectedCount,
displayData,
onDeleteSingle,
onDeleteMultiple,
onRefresh,
searchable = true,
filterable = true,
selectable = true,
loading = false,
onDateFilterChange
}: FormGeneratorControlsProps) {
const { t } = useLanguage();
// Filter fields that are filterable
const filterableFields = fields.filter(field => {
if (field.type === 'readonly') return false;
return field.filterable !== false;
});
// Handle date filter with special formatting (for FormGenerator)
const handleDateFilterChange = (key: string, value: string) => {
if (onDateFilterChange) {
onDateFilterChange(key, value);
return;
}
// Default behavior for FormGeneratorList
onFilterChange(key, value);
};
// Date filter formatting logic (for FormGenerator)
const handleDateFilterInput = (key: string, e: React.ChangeEvent<HTMLInputElement>) => {
let value = e.target.value;
const currentValue = filters[key] || '';
// Check if user is deleting (new value is shorter)
const isDeleting = value.length < currentValue.length;
if (isDeleting) {
// When deleting, preserve the exact input without auto-formatting
handleDateFilterChange(key, value);
return;
}
// Auto-pad single digits followed by dot (e.g., "4." -> "04.")
value = value.replace(/^(\d)\./, '0$1.');
value = value.replace(/\.(\d)\./, '.0$1.');
// Allow typing and format as DD.MM.YYYY
const digitsOnly = value.replace(/\D/g, ''); // Remove non-digits
let formatted = '';
if (digitsOnly.length >= 8) {
// Full format: DDMMYYYY -> DD.MM.YYYY
const day = digitsOnly.slice(0, 2);
const month = digitsOnly.slice(2, 4);
const year = digitsOnly.slice(4, 8);
// Validate day (01-31) and month (01-12)
if (parseInt(day) > 31 || parseInt(month) > 12 || parseInt(day) === 0 || parseInt(month) === 0) {
return; // Don't update if invalid
}
formatted = `${day}.${month}.${year}`;
} else if (digitsOnly.length >= 4) {
// Partial format: DDMM -> DD.MM.
const day = digitsOnly.slice(0, 2);
const month = digitsOnly.slice(2, 4);
const remaining = digitsOnly.slice(4);
// Validate day (01-31) and month (01-12)
if (parseInt(day) > 31 || parseInt(month) > 12 || parseInt(day) === 0 || parseInt(month) === 0) {
return; // Don't update if invalid
}
formatted = `${day}.${month}.${remaining}`;
} else if (digitsOnly.length >= 2) {
// Start format: DD -> DD.
const day = digitsOnly.slice(0, 2);
const remaining = digitsOnly.slice(2);
// Validate day (01-31)
if (parseInt(day) > 31 || parseInt(day) === 0) {
return; // Don't update if invalid
}
formatted = `${day}.${remaining}`;
} else {
// Just digits
formatted = digitsOnly;
}
handleDateFilterChange(key, formatted);
};
return (
<div className={styles.controls}>
{/* Delete Controls - Show when items are selected */}
{selectable && selectedCount > 0 && (
<div className={styles.deleteControlsIntegrated}>
{selectedCount === 1 && onDeleteSingle && (
<Button
onClick={onDeleteSingle}
variant="primary"
size="sm"
icon={FaTrash}
>
{t('formgen.delete.single', 'Delete')}
</Button>
)}
{selectedCount > 1 && onDeleteMultiple && (
<Button
onClick={onDeleteMultiple}
variant="primary"
size="sm"
icon={FaTrash}
>
{t('formgen.delete.multiple', `Delete ${selectedCount} selected items`).replace('{count}', selectedCount.toString())}
</Button>
)}
</div>
)}
{/* Search Controls - Hide when items are selected */}
{searchable && selectedCount === 0 && (
<div className={styles.searchContainer}>
<div className={styles.floatingLabelInput}>
<input
type="text"
placeholder=" "
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
onFocus={() => onSearchFocus(true)}
onBlur={() => onSearchFocus(false)}
className={`${styles.searchInput} ${searchFocused || searchTerm ? styles.focused : ''}`}
/>
<label className={searchFocused || searchTerm ? styles.focusedLabel : styles.label}>
{t('formgen.search.placeholder')}
</label>
</div>
{onRefresh && (
<button
onClick={onRefresh}
className={styles.refreshButton}
title={t('formgen.refresh.tooltip', 'Refresh data')}
disabled={loading}
>
<span className={styles.refreshIcon}><IoIosRefresh /></span>
</button>
)}
</div>
)}
{/* Filters */}
{filterable && (
<div className={styles.filtersContainer}>
{filterableFields.map(field => (
<div key={field.key} className={styles.filterGroup}>
{field.type === 'boolean' ? (
<div className={styles.customSelectContainer}>
<select
value={filters[field.key] || ''}
onChange={(e) => onFilterChange(field.key, e.target.value === '' ? undefined : e.target.value === 'true')}
className={`${styles.filterSelect} ${filters[field.key] ? styles.hasValue : ''}`}
>
<option value="" disabled hidden>{field.label}</option>
<option value="true">{t('formgen.filter.yes')}</option>
<option value="false">{t('formgen.filter.no')}</option>
</select>
{filters[field.key] && (
<button
type="button"
onClick={() => onFilterChange(field.key, '')}
className={styles.clearFilterButton}
title={t('formgen.filter.clear')}
>
</button>
)}
</div>
) : field.filterOptions ? (
<div className={styles.customSelectContainer}>
<select
value={filters[field.key] || ''}
onChange={(e) => onFilterChange(field.key, e.target.value)}
className={`${styles.filterSelect} ${filters[field.key] ? styles.hasValue : ''}`}
>
<option value="" disabled hidden>{field.label}</option>
{field.filterOptions.map(option => (
<option key={option} value={option}>{option}</option>
))}
</select>
{filters[field.key] && (
<button
type="button"
onClick={() => onFilterChange(field.key, '')}
className={styles.clearFilterButton}
title={t('formgen.filter.clear')}
>
</button>
)}
</div>
) : field.type === 'date' ? (
<div className={styles.floatingLabelInput}>
<input
type="text"
placeholder=" "
value={filters[field.key] || ''}
onChange={(e) => handleDateFilterInput(field.key, e)}
onFocus={() => onFilterFocus(field.key, true)}
onBlur={() => onFilterFocus(field.key, false)}
className={`${styles.filterInput} ${filterFocused[field.key] || filters[field.key] ? styles.focused : ''}`}
maxLength={10}
/>
<label className={filterFocused[field.key] || filters[field.key] ? styles.focusedLabel : styles.label}>
{field.label}
</label>
</div>
) : (
<div className={styles.floatingLabelInput}>
<input
type="text"
placeholder=" "
value={filters[field.key] || ''}
onChange={(e) => onFilterChange(field.key, e.target.value)}
onFocus={() => onFilterFocus(field.key, true)}
onBlur={() => onFilterFocus(field.key, false)}
className={`${styles.filterInput} ${filterFocused[field.key] || filters[field.key] ? styles.focused : ''}`}
/>
<label className={filterFocused[field.key] || filters[field.key] ? styles.focusedLabel : styles.label}>
{t('formgen.filter.placeholder').replace('{column}', field.label)}
</label>
</div>
)}
</div>
))}
</div>
)}
</div>
);
}
export default FormGeneratorControls;

View file

@ -0,0 +1,3 @@
export { FormGeneratorControls, default } from './FormGeneratorControls';
export type { FilterableField, FormGeneratorControlsProps } from './FormGeneratorControls';

View file

@ -1,8 +1,33 @@
/* EditForm container */
.editForm {
/* FormGeneratorForm container */
.formGeneratorForm {
width: 100%;
}
/* Loading state */
.loadingState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px;
gap: 16px;
}
.loadingSpinner {
width: 40px;
height: 40px;
border: 3px solid var(--color-primary);
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Field styling */
.fieldGroup {
margin-bottom: 20px;
@ -14,6 +39,7 @@
color: var(--color-text);
margin-bottom: 6px;
font-size: 14px;
text-align: left;
}
/* Floating label container */
@ -39,11 +65,85 @@
border-color: var(--color-secondary);
}
.fieldInput:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.fieldInput.fieldError {
border-color: #ef4444;
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
}
/* Multiselect styling */
.multiselectContainer {
width: 100%;
padding: 12px;
border: 1px solid var(--color-primary);
border-radius: 25px;
background-color: var(--color-bg);
min-height: 60px;
max-height: 200px;
overflow-y: auto;
box-sizing: border-box;
text-align: left;
}
.multiselectContainer.fieldError {
border-color: #ef4444;
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
}
.multiselectLoading,
.multiselectEmpty {
padding: 8px;
text-align: center;
color: var(--color-text);
opacity: 0.7;
font-size: 14px;
}
.multiselectOptions {
display: flex;
flex-direction: column;
gap: 8px;
align-items: flex-start;
}
.multiselectOption {
display: flex;
align-items: center;
cursor: pointer;
padding: 4px 0;
user-select: none;
width: 100%;
}
.multiselectOption:hover {
opacity: 0.8;
}
.multiselectCheckbox {
margin-right: 8px;
width: 16px;
height: 16px;
cursor: pointer;
accent-color: var(--color-primary);
}
.multiselectLabel {
font-size: 14px;
color: var(--color-text);
flex: 1;
}
.multiselectCount {
font-size: 0.75rem;
color: var(--color-primary);
margin-left: 4px;
font-weight: normal;
}
/* Textarea styling */
.fieldTextarea {
width: 100%;
@ -60,7 +160,12 @@
overflow-y: auto;
resize: vertical;
min-height: 4em;
max-height: 8em;
}
/* Content textarea - larger default size */
.contentTextarea {
min-height: 18em !important;
height: auto;
}
.fieldTextarea:focus {
@ -147,6 +252,7 @@
width: 16px;
height: 16px;
cursor: pointer;
accent-color: var(--color-primary);
}
/* Required field indicator */
@ -185,13 +291,18 @@
transition: all 0.2s ease;
}
.cancelButton:hover {
.cancelButton:hover:not(:disabled) {
background-color: var(--color-primary-hover);
border-color: var(--color-primary);
color: #181818;
}
.saveButton {
.cancelButton:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.submitButton {
padding: 8px 16px;
border: none;
background-color: var(--color-secondary);
@ -203,11 +314,11 @@
transition: all 0.2s ease;
}
.saveButton:hover {
.submitButton:hover:not(:disabled) {
background-color: var(--color-secondary-hover);
}
.saveButton:disabled {
.submitButton:disabled {
background-color: #9ca3af;
cursor: not-allowed;
}
@ -219,8 +330,9 @@
}
.cancelButton,
.saveButton {
.submitButton {
width: 100%;
padding: 12px;
}
}
}

View file

@ -0,0 +1,744 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useLanguage } from '../../../providers/language/LanguageContext';
import api from '../../../api';
import styles from './FormGeneratorForm.module.css';
// Attribute definition interface (matches backend structure)
export interface AttributeDefinition {
name: string;
type: 'text' | 'email' | 'date' | 'checkbox' | 'select' | 'multiselect' | 'number' | 'textarea' |
'timestamp' | 'time' | 'url' | 'password' | 'file' | 'integer' | 'float' | 'string' |
'boolean' | 'enum' | 'readonly';
label: string;
description?: string;
required?: boolean;
default?: any;
options?: AttributeOption[] | string | string[]; // Array of options or reference like "user.role"
validation?: any;
ui?: any;
readonly?: boolean;
editable?: boolean;
visible?: boolean;
order?: number;
placeholder?: string;
minRows?: number; // For textarea types
maxRows?: number; // For textarea types
}
export interface AttributeOption {
value: string | number;
label: string | { [language: string]: string };
}
// FormGeneratorForm props
export interface FormGeneratorFormProps<T = any> {
// Entity type for fetching attributes (e.g., "Prompt", "User", "FileItem")
// Required if attributes are not provided
entityType?: string;
// Initial form data (for edit mode)
data?: T;
// Form mode: 'create' | 'edit' | 'display'
mode?: 'create' | 'edit' | 'display';
// Callback when form is submitted
onSubmit: (formData: T) => void | Promise<void>;
// Optional cancel callback
onCancel?: () => void;
// Button text customization
submitButtonText?: string;
cancelButtonText?: string;
// Show/hide buttons
showButtons?: boolean;
// Custom className
className?: string;
// Optional: Pre-fetched attributes (if already available)
attributes?: AttributeDefinition[];
// Optional: Custom field filtering function
filterFields?: (attributes: AttributeDefinition[]) => AttributeDefinition[];
// Optional: Custom field transformation function
transformField?: (attribute: AttributeDefinition) => AttributeDefinition;
// Optional: Custom validation function
customValidator?: (formData: T, attributes: AttributeDefinition[]) => Record<string, string>;
}
// FormGeneratorForm component - Backend-driven form generation
export function FormGeneratorForm<T extends Record<string, any>>({
entityType,
data,
mode = 'edit',
onSubmit,
onCancel,
submitButtonText,
cancelButtonText,
showButtons = true,
className = '',
attributes: providedAttributes,
filterFields,
transformField,
customValidator
}: FormGeneratorFormProps<T>) {
const { t } = useLanguage();
const [formData, setFormData] = useState<T>(data || {} as T);
const [errors, setErrors] = useState<Record<string, string>>({});
const [fieldFocused, setFieldFocused] = useState<Record<string, boolean>>({});
const [attributes, setAttributes] = useState<AttributeDefinition[]>(providedAttributes || []);
const [loadingAttributes, setLoadingAttributes] = useState(!providedAttributes);
const [optionsCache, setOptionsCache] = useState<Record<string, Array<{ value: string | number; label: string }>>>({});
const [loadingOptions, setLoadingOptions] = useState<Record<string, boolean>>({});
const [submitting, setSubmitting] = useState(false);
// Fetch attributes from backend
useEffect(() => {
const fetchAttributes = async () => {
if (providedAttributes) {
setAttributes(providedAttributes);
setLoadingAttributes(false);
return;
}
if (!entityType) {
console.warn('FormGeneratorForm: entityType is required when attributes are not provided');
setAttributes([]);
setLoadingAttributes(false);
return;
}
try {
setLoadingAttributes(true);
const response = await api.get(`/api/attributes/${entityType}`);
// Extract attributes from response
let attrs: AttributeDefinition[] = [];
if (response.data?.attributes && Array.isArray(response.data.attributes)) {
attrs = response.data.attributes;
} else if (Array.isArray(response.data)) {
attrs = response.data;
} else if (response.data && typeof response.data === 'object') {
const keys = Object.keys(response.data);
for (const key of keys) {
if (Array.isArray(response.data[key])) {
attrs = response.data[key];
break;
}
}
}
setAttributes(attrs);
} catch (error: any) {
console.error(`Error fetching attributes for ${entityType}:`, error);
setAttributes([]);
} finally {
setLoadingAttributes(false);
}
};
fetchAttributes();
}, [entityType, providedAttributes]);
// Filter attributes based on mode
const getFilteredAttributes = useCallback((): AttributeDefinition[] => {
let filtered = [...attributes];
// Apply custom filter if provided
if (filterFields) {
filtered = filterFields(filtered);
} else {
// Default filtering based on mode
if (mode === 'edit') {
filtered = filtered.filter(attr => attr.visible !== false && attr.editable !== false);
} else if (mode === 'create') {
filtered = filtered.filter(attr => attr.visible !== false && attr.editable !== false);
} else if (mode === 'display') {
filtered = filtered.filter(attr => attr.visible !== false);
}
}
// Apply custom transformation if provided
if (transformField) {
filtered = filtered.map(transformField);
}
// Sort by order if available
filtered.sort((a, b) => {
const orderA = a.order ?? 999;
const orderB = b.order ?? 999;
return orderA - orderB;
});
return filtered;
}, [attributes, mode, filterFields, transformField]);
// Initialize form data with defaults
useEffect(() => {
if (data) {
setFormData({ ...data });
} else {
const filteredAttrs = getFilteredAttributes();
const initialData: any = {};
filteredAttrs.forEach(attr => {
if (attr.default !== undefined) {
initialData[attr.name] = attr.default;
} else if (attr.type === 'checkbox' || attr.type === 'boolean') {
initialData[attr.name] = false;
} else if (attr.type === 'multiselect') {
initialData[attr.name] = [];
} else {
initialData[attr.name] = '';
}
});
setFormData(initialData as T);
}
setErrors({});
setFieldFocused({});
}, [data, getFilteredAttributes]);
// Fetch options for fields with optionsReference
useEffect(() => {
const fetchOptions = async () => {
const filteredAttrs = getFilteredAttributes();
const fieldsToFetch = filteredAttrs.filter(attr => {
if (typeof attr.options === 'string' && !optionsCache[attr.options]) {
return true;
}
return false;
});
if (fieldsToFetch.length === 0) return;
for (const field of fieldsToFetch) {
if (typeof field.options !== 'string') continue;
setLoadingOptions(prev => ({ ...prev, [field.name]: true }));
try {
const response = await api.get(`/api/options/${field.options}`);
let fetchedOptions: Array<{ value: string | number; label: string }> = [];
if (Array.isArray(response.data)) {
fetchedOptions = response.data.map((opt: any) => {
if (typeof opt === 'string' || typeof opt === 'number') {
return { value: opt, label: String(opt) };
}
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label || {})[0]] || String(opt.value);
return {
value: opt.value,
label: labelValue
};
});
} else if (response.data?.options && Array.isArray(response.data.options)) {
fetchedOptions = response.data.options.map((opt: any) => {
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label || {})[0]] || String(opt.value);
return {
value: opt.value,
label: labelValue
};
});
}
setOptionsCache(prev => ({ ...prev, [field.options as string]: fetchedOptions }));
} catch (error: any) {
console.error(`Failed to fetch options for ${field.options}:`, error);
setOptionsCache(prev => ({ ...prev, [field.options as string]: [] }));
} finally {
setLoadingOptions(prev => ({ ...prev, [field.name]: false }));
}
}
};
fetchOptions();
}, [getFilteredAttributes, optionsCache]);
// Handle field focus
const handleFieldFocus = (fieldName: string, focused: boolean) => {
setFieldFocused(prev => ({
...prev,
[fieldName]: focused
}));
};
// Handle field value changes
const handleFieldChange = (fieldName: string, value: any) => {
setFormData(prev => ({
...prev,
[fieldName]: value
}));
// Clear error for this field when user starts typing
if (errors[fieldName]) {
setErrors(prev => {
const newErrors = { ...prev };
delete newErrors[fieldName];
return newErrors;
});
}
};
// Normalize options for a field
const normalizeOptions = (attr: AttributeDefinition): Array<{ value: string | number; label: string }> => {
// Check if optionsReference is provided and cached
if (typeof attr.options === 'string' && optionsCache[attr.options]) {
return optionsCache[attr.options];
}
// Handle direct options array
if (Array.isArray(attr.options)) {
return attr.options.map(opt => {
if (typeof opt === 'string') {
return { value: opt, label: opt };
}
if (typeof opt === 'object' && 'value' in opt) {
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label || {})[0]] || String(opt.value);
return {
value: opt.value,
label: labelValue
};
}
return { value: String(opt), label: String(opt) };
});
}
return [];
};
// Validate all fields
const validateFields = (): boolean => {
const newErrors: Record<string, string> = {};
const filteredAttrs = getFilteredAttributes();
filteredAttrs.forEach(attr => {
const value = formData[attr.name];
// Check required fields
if (attr.required && (value === undefined || value === null || value === '' ||
(Array.isArray(value) && value.length === 0))) {
newErrors[attr.name] = t('formgen.form.required', `${attr.label} is required`);
return;
}
// Type-specific validation
if (value !== undefined && value !== null && value !== '') {
// Integer validation
if (attr.type === 'integer') {
if (!Number.isInteger(Number(value)) || isNaN(Number(value))) {
newErrors[attr.name] = t('formgen.form.invalidInteger', `${attr.label} must be a valid integer`);
return;
}
}
// Number/Float validation
if (attr.type === 'number' || attr.type === 'float') {
if (isNaN(Number(value))) {
newErrors[attr.name] = t('formgen.form.invalidNumber', `${attr.label} must be a valid number`);
return;
}
}
// Email validation
if (attr.type === 'email') {
if (!/\S+@\S+\.\S+/.test(String(value))) {
newErrors[attr.name] = t('formgen.form.invalidEmail', 'Invalid email format');
return;
}
}
// URL validation
if (attr.type === 'url') {
try {
new URL(String(value));
} catch {
newErrors[attr.name] = t('formgen.form.invalidUrl', 'Invalid URL format');
return;
}
}
// Select/Multiselect option validation
if (attr.type === 'select' || attr.type === 'enum') {
const options = normalizeOptions(attr);
if (options.length > 0 && !options.some(opt => String(opt.value) === String(value))) {
newErrors[attr.name] = t('formgen.form.invalidOption', 'Invalid option selected');
return;
}
}
// Timestamp/Date validation
if (attr.type === 'timestamp' || attr.type === 'date' || attr.type === 'time') {
const dateValue = new Date(String(value));
if (isNaN(dateValue.getTime())) {
newErrors[attr.name] = t('formgen.form.invalidDate', 'Invalid date format');
return;
}
}
// Custom validation from attribute
if (attr.validation && typeof attr.validation === 'function') {
const error = attr.validation(value);
if (error) {
newErrors[attr.name] = error;
return;
}
}
}
});
// Apply custom validator if provided
if (customValidator) {
const customErrors = customValidator(formData, filteredAttrs);
Object.assign(newErrors, customErrors);
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// Handle form submission
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateFields()) {
return;
}
try {
setSubmitting(true);
await onSubmit(formData);
} catch (error: any) {
console.error('Form submission error:', error);
// Handle backend validation errors
if (error.response?.data?.errors) {
setErrors(error.response.data.errors);
}
} finally {
setSubmitting(false);
}
};
// Handle cancel
const handleCancel = () => {
if (data) {
setFormData({ ...data });
}
setErrors({});
onCancel?.();
};
// Helper function to get label class
const getLabelClass = (fieldName: string, value: any) => {
const isFocused = fieldFocused[fieldName];
const hasValue = value !== undefined && value !== null && value !== '' &&
!(Array.isArray(value) && value.length === 0);
if (isFocused) {
return styles.activeFocusedLabel;
} else if (hasValue) {
return styles.focusedLabel;
} else {
return styles.label;
}
};
// Render field based on attribute type
const renderField = (attr: AttributeDefinition) => {
const value = formData[attr.name];
const hasError = errors[attr.name];
const isReadonly = mode === 'display' || attr.readonly || attr.editable === false;
// Readonly/Display field
if (isReadonly) {
let displayValue = value;
if (attr.type === 'checkbox' || attr.type === 'boolean') {
displayValue = value ? t('common.yes', 'Yes') : t('common.no', 'No');
} else if (attr.type === 'select' || attr.type === 'enum') {
const options = normalizeOptions(attr);
const selectedOption = options.find(opt => String(opt.value) === String(value));
displayValue = selectedOption ? selectedOption.label : value;
} else if (attr.type === 'multiselect') {
const options = normalizeOptions(attr);
const selectedValues = Array.isArray(value) ? value : (value ? [value] : []);
displayValue = selectedValues.map(v => {
const option = options.find(opt => String(opt.value) === String(v));
return option ? option.label : v;
}).join(', ') || t('common.none', 'None');
}
return (
<div className={styles.floatingLabelInput} key={attr.name}>
<div className={styles.readonlyField}>
{displayValue || t('common.na', 'N/A')}
</div>
<label className={styles.focusedLabel}>
{attr.label}
</label>
</div>
);
}
// Select/Enum field
if (attr.type === 'select' || attr.type === 'enum') {
const options = normalizeOptions(attr);
const isLoading = typeof attr.options === 'string' && loadingOptions[attr.name];
return (
<div className={styles.floatingLabelInput} key={attr.name}>
<select
value={value || ''}
onChange={(e) => handleFieldChange(attr.name, e.target.value)}
onFocus={() => handleFieldFocus(attr.name, true)}
onBlur={() => handleFieldFocus(attr.name, false)}
className={`${styles.fieldInput} ${hasError ? styles.fieldError : ''}`}
disabled={isLoading}
>
<option value="" disabled hidden></option>
{options.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
<label className={getLabelClass(attr.name, value)}>
{attr.label}
{attr.required && <span className={styles.required}>*</span>}
</label>
{hasError && <span className={styles.errorText}>{hasError}</span>}
</div>
);
}
// Multiselect field
if (attr.type === 'multiselect') {
const options = normalizeOptions(attr);
const currentValues = Array.isArray(value) ? value : (value ? [value] : []);
const isLoading = typeof attr.options === 'string' && loadingOptions[attr.name];
return (
<div className={styles.fieldGroup} key={attr.name}>
<label className={styles.fieldLabel}>
{attr.label}
{attr.required && <span className={styles.required}>*</span>}
{currentValues.length > 0 && (
<span className={styles.multiselectCount}> ({currentValues.length} {t('common.selected', 'selected')})</span>
)}
</label>
<div className={`${styles.multiselectContainer} ${hasError ? styles.fieldError : ''}`}>
{isLoading ? (
<div className={styles.multiselectLoading}>{t('common.loading', 'Loading options...')}</div>
) : options.length === 0 ? (
<div className={styles.multiselectEmpty}>{t('common.noOptions', 'No options available')}</div>
) : (
<div className={styles.multiselectOptions}>
{options.map(option => {
const isSelected = currentValues.some(v => String(v) === String(option.value));
return (
<label key={option.value} className={styles.multiselectOption}>
<input
type="checkbox"
checked={isSelected}
onChange={(e) => {
let newValues: any[];
if (e.target.checked) {
newValues = [...currentValues, option.value];
} else {
newValues = currentValues.filter(v => String(v) !== String(option.value));
}
handleFieldChange(attr.name, newValues);
}}
onFocus={() => handleFieldFocus(attr.name, true)}
onBlur={() => handleFieldFocus(attr.name, false)}
className={styles.multiselectCheckbox}
/>
<span className={styles.multiselectLabel}>{option.label}</span>
</label>
);
})}
</div>
)}
</div>
{hasError && <span className={styles.errorText}>{hasError}</span>}
</div>
);
}
// Checkbox/Boolean field
if (attr.type === 'checkbox' || attr.type === 'boolean') {
return (
<div className={styles.fieldGroup} key={attr.name}>
<label className={styles.checkboxLabel}>
<input
type="checkbox"
checked={!!value}
onChange={(e) => handleFieldChange(attr.name, e.target.checked)}
onFocus={() => handleFieldFocus(attr.name, true)}
onBlur={() => handleFieldFocus(attr.name, false)}
className={styles.checkboxInput}
/>
{attr.label}
{attr.required && <span className={styles.required}>*</span>}
</label>
{hasError && <span className={styles.errorText}>{hasError}</span>}
</div>
);
}
// Textarea field
if (attr.type === 'textarea') {
const minRows = attr.minRows || 4;
const maxRows = attr.maxRows || 8;
const minHeight = minRows * 1.5 * 16;
const maxHeight = maxRows * 1.5 * 16;
const currentValue = value || '';
const isContentField = attr.name === 'content' || attr.name.toLowerCase().includes('content');
const textareaClassName = isContentField
? `${styles.fieldTextarea} ${styles.contentTextarea} ${hasError ? styles.fieldError : ''}`
: `${styles.fieldTextarea} ${hasError ? styles.fieldError : ''}`;
return (
<div className={styles.floatingLabelInput} key={attr.name}>
<textarea
name={attr.name}
value={currentValue}
onChange={(e) => {
handleFieldChange(attr.name, e.target.value);
const textarea = e.target;
textarea.style.height = 'auto';
const scrollHeight = textarea.scrollHeight;
const newHeight = Math.max(
minHeight,
Math.min(scrollHeight || minHeight, maxHeight)
);
textarea.style.height = `${newHeight}px`;
}}
onFocus={() => handleFieldFocus(attr.name, true)}
onBlur={() => handleFieldFocus(attr.name, false)}
className={textareaClassName}
rows={minRows}
placeholder={attr.placeholder}
ref={(textarea) => {
if (textarea) {
textarea.style.setProperty('min-height', `${minHeight}px`, 'important');
textarea.style.setProperty('height', `${minHeight}px`, 'important');
textarea.style.setProperty('max-height', `${maxHeight}px`, 'important');
textarea.setAttribute('rows', minRows.toString());
}
}}
/>
<label className={getLabelClass(attr.name, value)}>
{attr.label}
{attr.required && <span className={styles.required}>*</span>}
</label>
{hasError && <span className={styles.errorText}>{hasError}</span>}
</div>
);
}
// File field
if (attr.type === 'file') {
return (
<div className={styles.floatingLabelInput} key={attr.name}>
<input
type="file"
onChange={(e) => {
const file = e.target.files?.[0];
handleFieldChange(attr.name, file);
}}
onFocus={() => handleFieldFocus(attr.name, true)}
onBlur={() => handleFieldFocus(attr.name, false)}
className={`${styles.fieldInput} ${hasError ? styles.fieldError : ''}`}
/>
<label className={getLabelClass(attr.name, value)}>
{attr.label}
{attr.required && <span className={styles.required}>*</span>}
</label>
{hasError && <span className={styles.errorText}>{hasError}</span>}
</div>
);
}
// Default input field (text, email, date, time, url, password, number, integer, float)
const inputType = attr.type === 'email' ? 'email' :
attr.type === 'date' ? 'date' :
attr.type === 'time' ? 'time' :
attr.type === 'timestamp' ? 'datetime-local' :
attr.type === 'url' ? 'url' :
attr.type === 'password' ? 'password' :
(attr.type === 'number' || attr.type === 'integer' || attr.type === 'float') ? 'number' :
'text';
return (
<div className={styles.floatingLabelInput} key={attr.name}>
<input
type={inputType}
value={value || ''}
onChange={(e) => {
let newValue: any = e.target.value;
if (attr.type === 'number' || attr.type === 'integer' || attr.type === 'float') {
newValue = e.target.value === '' ? '' : Number(e.target.value);
}
handleFieldChange(attr.name, newValue);
}}
onFocus={() => handleFieldFocus(attr.name, true)}
onBlur={() => handleFieldFocus(attr.name, false)}
className={`${styles.fieldInput} ${hasError ? styles.fieldError : ''}`}
placeholder={attr.placeholder}
/>
<label className={getLabelClass(attr.name, value)}>
{attr.label}
{attr.required && <span className={styles.required}>*</span>}
</label>
{hasError && <span className={styles.errorText}>{hasError}</span>}
</div>
);
};
const filteredAttributes = getFilteredAttributes();
if (loadingAttributes) {
return (
<div className={`${styles.formGeneratorForm} ${className}`}>
<div className={styles.loadingState}>
<div className={styles.loadingSpinner}></div>
<p>{t('common.loading', 'Loading...')}</p>
</div>
</div>
);
}
return (
<div className={`${styles.formGeneratorForm} ${className}`}>
<form onSubmit={handleSubmit}>
{filteredAttributes.map(attr => renderField(attr))}
</form>
{showButtons && (
<div className={styles.buttonGroup}>
{onCancel && (
<button
type="button"
className={styles.cancelButton}
onClick={handleCancel}
disabled={submitting}
>
{cancelButtonText || t('common.cancel', 'Cancel')}
</button>
)}
{mode !== 'display' && (
<button
type="button"
className={styles.submitButton}
onClick={handleSubmit}
disabled={submitting}
>
{submitting ? t('common.saving', 'Saving...') : (submitButtonText || t('common.save', 'Save'))}
</button>
)}
</div>
)}
</div>
);
}
export default FormGeneratorForm;

View file

@ -0,0 +1,3 @@
export { default as FormGeneratorForm } from './FormGeneratorForm';
export type { FormGeneratorFormProps, AttributeDefinition, AttributeOption } from './FormGeneratorForm';

View file

@ -0,0 +1,421 @@
.formGeneratorList {
display: flex;
flex-direction: column;
gap: 20px;
width: 100%;
font-family: var(--font-family);
min-height: 0;
max-height: 100%;
}
/* List Container */
.listContainer {
position: relative;
overflow: auto;
border: 1px solid var(--color-primary);
border-radius: 25px;
background: var(--color-bg);
max-height: calc(100vh - 400px);
}
.emptyList {
min-height: auto;
height: auto;
max-height: none;
}
.emptyMessage {
text-align: center;
padding: 40px 20px;
color: var(--color-text);
opacity: 0.6;
}
.loadingState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem 2rem;
text-align: center;
color: var(--color-text-secondary, #666);
}
.loadingSpinner {
width: 40px;
height: 40px;
border: 3px solid var(--color-bg-secondary, #e9ecef);
border-top: 3px solid var(--color-primary, #007bff);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 1rem;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* List Header */
.listHeader {
display: flex;
align-items: center;
gap: 15px;
padding: 12px 16px;
border-bottom: 1px solid var(--color-primary);
background: var(--color-bg);
position: sticky;
top: 0;
z-index: 10;
}
.selectAllCheckbox {
cursor: pointer;
transform: scale(1.3);
width: 16px;
height: 16px;
accent-color: var(--color-secondary);
margin: 0;
padding: 0;
border: 2px solid var(--color-primary);
border-radius: 3px;
background: var(--color-bg);
}
.selectAllCheckbox:checked {
background-color: var(--color-secondary);
border-color: var(--color-secondary);
}
.sortControls {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.sortButton {
padding: 6px 12px;
border: 1px solid var(--color-primary);
border-radius: 20px;
background: var(--color-bg);
color: var(--color-text);
font-size: 13px;
font-family: var(--font-family);
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 6px;
}
.sortButton:hover {
background: var(--color-gray-disabled);
border-color: var(--color-secondary);
}
.sortButton.active {
background: var(--color-secondary);
color: white;
border-color: var(--color-secondary);
}
.sortIcon {
font-size: 12px;
color: inherit;
}
/* Items List */
.itemsList {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
}
.listItem {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 16px;
border: 1px solid var(--color-primary);
border-radius: 20px;
background: var(--color-bg);
transition: all 0.2s ease;
}
.listItem:hover {
border-color: var(--color-secondary);
box-shadow: 0 2px 8px rgba(var(--color-secondary-rgb), 0.1);
}
.listItem.selected {
background: rgba(var(--color-secondary-rgb), 0.1);
border-color: var(--color-secondary);
}
.listItem.clickable {
cursor: pointer;
}
.itemSelect {
flex-shrink: 0;
padding-top: 4px;
}
.itemCheckbox {
cursor: pointer;
transform: scale(1.3);
width: 16px;
height: 16px;
accent-color: var(--color-secondary);
margin: 0;
padding: 0;
border: 2px solid var(--color-primary);
border-radius: 3px;
background: var(--color-bg);
}
.itemCheckbox:checked {
background-color: var(--color-secondary);
border-color: var(--color-secondary);
}
.itemCheckbox:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.itemActions {
display: flex;
gap: 4px;
flex-shrink: 0;
padding-top: 4px;
}
.itemFields {
display: flex;
flex-direction: column;
gap: 12px;
flex: 1;
min-width: 0;
}
.itemField {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.fieldLabel {
font-size: 12px;
font-weight: 500;
color: var(--color-text);
opacity: 0.7;
font-family: var(--font-family);
}
.fieldValue {
font-size: 14px;
color: var(--color-text);
font-family: var(--font-family);
padding: 8px 12px;
background: var(--color-bg-disabled, rgba(0, 0, 0, 0.02));
border-radius: 12px;
border: 1px solid transparent;
}
.fieldInput {
width: 100%;
}
.fieldSelect {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--color-primary);
border-radius: 20px;
font-size: 14px;
font-family: var(--font-family);
background: var(--color-bg);
color: var(--color-text);
transition: all 0.2s ease;
box-sizing: border-box;
}
.fieldSelect:focus {
outline: none;
border-color: var(--color-secondary);
box-shadow: 0 0 0 2px rgba(var(--color-secondary), 0.1);
}
.fieldCheckbox {
cursor: pointer;
transform: scale(1.2);
width: 18px;
height: 18px;
accent-color: var(--color-secondary);
margin: 0;
padding: 0;
}
/* Pagination */
.pagination {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
padding: 15px;
border-top: 1px solid var(--color-primary);
flex-shrink: 0;
background: var(--color-bg);
border-radius: 0 0 8px 8px;
}
.pageSizeSelector {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: var(--color-text);
}
.pageSizeSelector label {
white-space: nowrap;
font-family: var(--font-family);
}
.pageSizeSelect {
height: 32px;
padding: 4px 8px;
border: 1px solid var(--color-primary);
border-radius: 4px;
font-size: 14px;
font-family: var(--font-family);
background: var(--color-bg);
color: var(--color-text);
cursor: pointer;
min-width: 60px;
}
.pageSizeSelect:focus {
outline: none;
border-color: var(--color-secondary);
}
.paginationButton {
padding: 8px 12px;
border: 1px solid var(--color-gray-disabled);
background: var(--color-bg);
color: var(--color-text);
border-radius: 4px;
cursor: pointer;
font-family: var(--font-family);
transition: all 0.2s ease;
}
.paginationButton:hover:not(:disabled) {
background: var(--color-gray-disabled);
border-color: var(--color-secondary);
}
.paginationButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.paginationInfo {
font-size: 14px;
color: var(--color-text);
margin: 0 15px;
white-space: nowrap;
}
/* Responsive Design */
@media (max-width: 768px) {
.listContainer {
max-height: calc(100vh - 350px);
}
.emptyList {
min-height: auto;
height: auto;
}
.listHeader {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.sortControls {
width: 100%;
flex-direction: column;
}
.sortButton {
width: 100%;
justify-content: space-between;
}
.listItem {
flex-direction: column;
gap: 10px;
}
.itemFields {
width: 100%;
}
.pagination {
flex-direction: column;
gap: 10px;
padding: 10px;
}
.pageSizeSelector {
order: -1;
justify-content: center;
}
.paginationInfo {
text-align: center;
margin: 0;
font-size: 13px;
}
}
/* Dark theme support */
@media (prefers-color-scheme: dark) {
.listItem:hover {
background: rgba(255, 255, 255, 0.05);
}
.listItem.selected {
background: rgba(var(--color-secondary-rgb), 0.2);
}
.fieldValue {
background: rgba(255, 255, 255, 0.05);
}
}
/* Custom scrollbar for list container */
.listContainer::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.listContainer::-webkit-scrollbar-track {
background: var(--color-gray-disabled);
border-radius: 4px;
}
.listContainer::-webkit-scrollbar-thumb {
background: var(--color-gray);
border-radius: 4px;
}
.listContainer::-webkit-scrollbar-thumb:hover {
background: var(--color-secondary);
}

View file

@ -0,0 +1,787 @@
import React, { useState, useMemo, useRef, useEffect } from 'react';
import { useLanguage } from '../../../providers/language/LanguageContext';
import styles from './FormGeneratorList.module.css';
import {
EditActionButton,
DeleteActionButton,
DownloadActionButton,
ViewActionButton,
CopyActionButton,
ConnectActionButton,
PlayActionButton
} from '../ActionButtons';
import { formatUnixTimestamp } from '../../../utils/time';
import TextField from '../../UiComponents/TextField/TextField';
import { FormGeneratorControls } from '../FormGeneratorControls';
// Types for the FormGeneratorList
export interface FieldConfig {
key: string;
label: string;
type?: 'string' | 'number' | 'date' | 'boolean' | 'enum' | 'readonly';
editable?: boolean;
required?: boolean;
formatter?: (value: any, row: any) => React.ReactNode;
filterOptions?: string[]; // For enum/select filters
cellClassName?: (value: any, row: any) => string; // For custom cell styling
options?: Array<{ value: string | number; label: string }>; // For enum fields
}
export interface FormGeneratorListProps<T = any> {
data: T[];
fields?: FieldConfig[];
title?: string;
searchable?: boolean;
filterable?: boolean;
sortable?: boolean;
pagination?: boolean;
pageSize?: number;
pageSizeOptions?: number[];
showPageSizeSelector?: boolean;
onItemClick?: (row: T, index: number) => void;
onItemSelect?: (selectedRows: T[]) => void;
selectable?: boolean;
isItemSelectable?: (row: T) => boolean;
loading?: boolean;
actionButtons?: {
type: 'edit' | 'delete' | 'download' | 'view' | 'copy' | 'connect' | 'play';
onAction?: (row: T) => Promise<void> | void;
disabled?: (row: T) => boolean | { disabled: boolean; message?: string };
loading?: (row: T) => boolean;
title?: string | ((row: T) => string);
className?: string;
isProcessing?: (row: T) => boolean;
idField?: string;
nameField?: string;
typeField?: string;
contentField?: string;
statusField?: string;
authorityField?: string;
operationName?: string;
refreshOperationName?: string;
loadingStateName?: string;
navigateTo?: string;
}[];
onDelete?: (row: T) => void;
onDeleteMultiple?: (rows: T[]) => void;
onRefresh?: () => void;
className?: string;
getItemDataAttributes?: (row: T, index: number) => Record<string, string>;
hookData?: any;
onFieldChange?: (row: T, fieldKey: string, value: any) => void; // For editable fields
}
export function FormGeneratorList<T extends Record<string, any>>({
data,
fields: providedFields,
searchable = true,
filterable = true,
sortable = true,
pagination = true,
pageSize = 10,
pageSizeOptions = [10, 25, 50, 100],
showPageSizeSelector = true,
onItemClick,
onItemSelect,
selectable = true,
isItemSelectable,
loading = false,
actionButtons = [],
onDelete,
onDeleteMultiple,
onRefresh,
className = '',
getItemDataAttributes,
hookData,
onFieldChange
}: FormGeneratorListProps<T>) {
const { t } = useLanguage();
// Cache fields so they persist even when data is empty
const fieldsRef = useRef<FieldConfig[]>([]);
const detectedFields = useMemo((): FieldConfig[] => {
// Always use providedFields if available
if (providedFields && providedFields.length > 0) {
fieldsRef.current = providedFields;
return providedFields;
}
// If we have cached fields and no new fields provided, use cached fields
if (fieldsRef.current.length > 0 && data.length === 0) {
return fieldsRef.current;
}
// Only auto-detect if no fields provided AND we have data
if (data.length === 0) {
return fieldsRef.current;
}
const sampleRow = data[0];
const autoDetected = Object.keys(sampleRow).map(key => {
const value = sampleRow[key];
let type: FieldConfig['type'] = 'string';
// Check if field name suggests it's a timestamp/date field
const isTimestampField = /(at|date|time|timestamp|created|updated|expires|checked)$/i.test(key);
// Auto-detect type based on value
if (typeof value === 'number') {
if (isTimestampField || (value > 0 && value < 4102444800000)) {
if (value < 10000000000) {
type = 'date';
} else if (value < 4102444800000) {
type = 'date';
} else {
type = 'number';
}
} else {
type = 'number';
}
} else if (typeof value === 'boolean') {
type = 'boolean';
} else if (value instanceof Date || (typeof value === 'string' && !isNaN(Date.parse(value)) && value.includes('-'))) {
type = 'date';
} else if (isTimestampField && typeof value === 'string') {
const numValue = parseFloat(value);
if (!isNaN(numValue) && numValue > 0 && numValue < 4102444800000) {
type = 'date';
}
}
return {
key,
label: key.charAt(0).toUpperCase() + key.slice(1).replace(/([A-Z])/g, ' $1'),
type,
editable: false
};
});
// Cache auto-detected fields
if (autoDetected.length > 0) {
fieldsRef.current = autoDetected;
}
return autoDetected;
}, [providedFields, data]);
// State management
const [searchTerm, setSearchTerm] = useState('');
const [searchFocused, setSearchFocused] = useState(false);
const [filterFocused, setFilterFocused] = useState<Record<string, boolean>>({});
const [sortConfig, setSortConfig] = useState<{ key: string; direction: 'asc' | 'desc' } | null>(null);
const [filters, setFilters] = useState<Record<string, any>>({});
const [selectedItems, setSelectedItems] = useState<Set<number>>(new Set());
const [currentPage, setCurrentPage] = useState(1);
const [currentPageSize, setCurrentPageSize] = useState(pageSize);
// Check if backend pagination is supported
const supportsBackendPagination = hookData?.refetch && typeof hookData.refetch === 'function';
// Debounce search term for backend calls
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(searchTerm);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearchTerm(searchTerm);
}, 300);
return () => clearTimeout(timer);
}, [searchTerm]);
// Call backend when filters/search/sort/pagination change
useEffect(() => {
if (!supportsBackendPagination || !hookData?.refetch) return;
const paginationParams: any = {
page: currentPage,
pageSize: currentPageSize,
};
if (debouncedSearchTerm && debouncedSearchTerm.trim()) {
paginationParams.search = debouncedSearchTerm.trim();
}
const activeFilters: Record<string, any> = {};
Object.entries(filters).forEach(([key, value]) => {
if (value !== undefined && value !== '') {
activeFilters[key] = value;
}
});
if (Object.keys(activeFilters).length > 0) {
paginationParams.filters = activeFilters;
}
if (sortConfig) {
paginationParams.sort = [{
field: sortConfig.key,
direction: sortConfig.direction
}];
}
hookData.refetch(paginationParams).then(() => {
console.log('✅ FormGeneratorList: Backend refetch completed');
}).catch((error: any) => {
console.error('❌ FormGeneratorList: Backend refetch failed:', error);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSearchTerm, filters, sortConfig, currentPage, currentPageSize, supportsBackendPagination]);
// Refs for action buttons containers
const actionButtonsRefs = useRef<Map<number, HTMLDivElement>>(new Map());
// Data is already filtered, sorted, and paginated by the backend
const displayData = data;
// Get pagination info from backend
const totalPages = useMemo(() => {
if (!supportsBackendPagination || !hookData?.pagination) {
return 1;
}
return hookData.pagination.totalPages || 1;
}, [supportsBackendPagination, hookData?.pagination]);
// Handle sorting
const handleSort = (key: string) => {
if (!sortable) return;
setSortConfig(current => {
if (current?.key === key) {
return current.direction === 'asc'
? { key, direction: 'desc' }
: null;
}
return { key, direction: 'asc' };
});
};
// Handle filtering
const handleFilter = (key: string, value: any) => {
setFilters(prev => ({
...prev,
[key]: value
}));
setCurrentPage(1);
};
// Handle filter input focus
const handleFilterFocus = (key: string, focused: boolean) => {
setFilterFocused(prev => ({
...prev,
[key]: focused
}));
};
// Handle item selection
const handleItemSelect = (index: number) => {
if (!selectable) return;
const row = displayData[index];
if (isItemSelectable && !isItemSelectable(row)) return;
const newSelected = new Set(selectedItems);
if (newSelected.has(index)) {
newSelected.delete(index);
} else {
newSelected.add(index);
}
setSelectedItems(newSelected);
if (onItemSelect) {
const selectedData = Array.from(newSelected).map(i => displayData[i]);
onItemSelect(selectedData);
}
};
// Handle select all
const handleSelectAll = () => {
if (!selectable) return;
const selectableIndices = displayData
.map((row, index) => ({ row, index }))
.filter(({ row }) => !isItemSelectable || isItemSelectable(row))
.map(({ index }) => index);
if (selectedItems.size === selectableIndices.length) {
setSelectedItems(new Set());
onItemSelect?.([]);
} else {
const allSelectableIndices = new Set(selectableIndices);
setSelectedItems(allSelectableIndices);
const selectableData = selectableIndices.map(i => displayData[i]);
onItemSelect?.(selectableData);
}
};
// Handle delete single item
const handleDeleteSingle = (row: T, index: number) => {
if (onDelete) {
onDelete(row);
if (selectedItems.has(index)) {
const newSelected = new Set(selectedItems);
newSelected.delete(index);
setSelectedItems(newSelected);
if (onItemSelect) {
const selectedData = Array.from(newSelected).map(i => displayData[i]);
onItemSelect(selectedData);
}
}
}
};
// Handle delete multiple items
const handleDeleteMultiple = () => {
if (onDeleteMultiple && selectedItems.size > 0) {
const selectedData = Array.from(selectedItems).map(i => displayData[i]);
onDeleteMultiple(selectedData);
setSelectedItems(new Set());
onItemSelect?.([]);
}
};
// Handle page size change
const handlePageSizeChange = (newPageSize: number) => {
setCurrentPageSize(newPageSize);
setCurrentPage(1);
};
// Format field value
const formatFieldValue = (value: any, field: FieldConfig, row: T) => {
if (field.formatter) {
return field.formatter(value, row);
}
if (value === null || value === undefined) {
return '-';
}
const isTimestampField = /(at|date|time|timestamp|created|updated|expires|checked)$/i.test(field.key);
const isLikelyTimestamp = typeof value === 'number' && value > 0 && value < 4102444800000;
if ((isTimestampField || isLikelyTimestamp) && typeof value === 'number') {
try {
let timestamp: number;
if (value < 10000000000) {
timestamp = value;
} else {
timestamp = value / 1000;
}
const formatted = formatUnixTimestamp(timestamp, undefined, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
});
return `${formatted.time} ${formatted.timezone}`;
} catch (error) {
console.error('Error formatting timestamp:', error, value);
}
}
switch (field.type) {
case 'date':
try {
let timestamp: number;
if (typeof value === 'number') {
if (value < 10000000000) {
timestamp = value;
} else {
timestamp = value / 1000;
}
} else if (typeof value === 'string') {
if (value.includes('T') || value.includes('-') || value.includes(':')) {
const date = new Date(value);
if (isNaN(date.getTime())) return '-';
timestamp = Math.floor(date.getTime() / 1000);
} else {
const numValue = parseFloat(value);
if (!isNaN(numValue)) {
if (numValue < 10000000000) {
timestamp = numValue;
} else {
timestamp = numValue / 1000;
}
} else {
const date = new Date(value);
if (isNaN(date.getTime())) return '-';
timestamp = Math.floor(date.getTime() / 1000);
}
}
} else if (value instanceof Date) {
if (isNaN(value.getTime())) return '-';
timestamp = Math.floor(value.getTime() / 1000);
} else {
const date = new Date(value);
if (isNaN(date.getTime())) return '-';
timestamp = Math.floor(date.getTime() / 1000);
}
const formatted = formatUnixTimestamp(timestamp, undefined, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
});
return `${formatted.time} ${formatted.timezone}`;
} catch (error) {
console.error('Error formatting date:', error, value);
return '-';
}
case 'boolean':
return value ? '✓' : '✗';
case 'number':
return typeof value === 'number' ? value.toLocaleString() : value;
default:
return String(value);
}
};
// Render field input
const renderFieldInput = (field: FieldConfig, value: any, row: T, index: number) => {
if (field.type === 'readonly' || !field.editable) {
return (
<div className={styles.fieldValue} key={field.key}>
{formatFieldValue(value, field, row)}
</div>
);
}
if (field.type === 'enum' && field.options) {
return (
<select
key={field.key}
value={value || ''}
onChange={(e) => onFieldChange?.(row, field.key, e.target.value)}
className={styles.fieldSelect}
>
<option value="" disabled hidden></option>
{field.options.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
);
}
if (field.type === 'boolean') {
return (
<input
key={field.key}
type="checkbox"
checked={!!value}
onChange={(e) => onFieldChange?.(row, field.key, e.target.checked)}
className={styles.fieldCheckbox}
/>
);
}
// Default to text input
return (
<TextField
key={field.key}
value={value || ''}
onChange={(newValue) => onFieldChange?.(row, field.key, newValue)}
type={field.type === 'date' ? 'date' : field.type === 'number' ? 'number' : 'text'}
required={field.required}
readonly={!field.editable}
className={styles.fieldInput}
/>
);
};
return (
<div className={`${styles.formGeneratorList} ${className}`}>
{(searchable || filterable || (selectable && selectedItems.size > 0)) && (
<FormGeneratorControls
fields={detectedFields}
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
searchFocused={searchFocused}
onSearchFocus={setSearchFocused}
filters={filters}
onFilterChange={handleFilter}
filterFocused={filterFocused}
onFilterFocus={handleFilterFocus}
selectedCount={selectedItems.size}
displayData={displayData}
onDeleteSingle={selectedItems.size === 1 && onDelete ? () => {
const selectedIndex = Array.from(selectedItems)[0];
const selectedRow = displayData[selectedIndex];
handleDeleteSingle(selectedRow, selectedIndex);
} : undefined}
onDeleteMultiple={selectedItems.size > 1 && onDeleteMultiple ? handleDeleteMultiple : undefined}
onRefresh={onRefresh}
searchable={searchable}
filterable={filterable}
selectable={selectable}
loading={loading}
/>
)}
{/* List Container */}
<div className={`${styles.listContainer} ${displayData.length === 0 ? styles.emptyList : ''}`}>
{loading ? (
<div className={styles.loadingState}>
<div className={styles.loadingSpinner}></div>
<p>{t('common.loading', 'Loading...')}</p>
</div>
) : (
<>
{/* Select All Header */}
{selectable && displayData.length > 0 && (
<div className={styles.listHeader}>
<input
type="checkbox"
checked={(() => {
const selectableIndices = displayData
.map((row, index) => ({ row, index }))
.filter(({ row }) => !isItemSelectable || isItemSelectable(row))
.map(({ index }) => index);
return selectedItems.size === selectableIndices.length && selectableIndices.length > 0;
})()}
onChange={handleSelectAll}
title={t('formgen.select.all', 'Select all items')}
className={styles.selectAllCheckbox}
/>
{sortable && (
<div className={styles.sortControls}>
{detectedFields.map(field => (
<button
key={field.key}
onClick={() => handleSort(field.key)}
className={`${styles.sortButton} ${sortConfig?.key === field.key ? styles.active : ''}`}
>
{field.label}
{sortConfig?.key === field.key && (
<span className={styles.sortIcon}>
{sortConfig.direction === 'asc' ? '↑' : '↓'}
</span>
)}
</button>
))}
</div>
)}
</div>
)}
{/* List Items */}
{displayData.length === 0 ? (
<div className={styles.emptyMessage}>
{t('formgen.empty', 'No data available')}
</div>
) : (
<div className={styles.itemsList}>
{displayData.map((row, index) => {
const dataAttributes = getItemDataAttributes ? getItemDataAttributes(row, index) : {};
const customClassName = selectedItems.has(index) ? styles.selected : '';
return (
<div
key={index}
className={`${styles.listItem} ${customClassName} ${onItemClick ? styles.clickable : ''}`}
onClick={() => onItemClick?.(row, index)}
{...Object.fromEntries(
Object.entries(dataAttributes).map(([key, value]) => [`data-${key}`, value])
)}
>
{/* Selection Checkbox */}
{selectable && (
<div className={styles.itemSelect}>
<input
type="checkbox"
checked={selectedItems.has(index)}
onChange={() => handleItemSelect(index)}
onClick={(e) => e.stopPropagation()}
disabled={isItemSelectable && !isItemSelectable(row)}
title={
isItemSelectable && !isItemSelectable(row)
? t('formgen.select.disabled', 'This item cannot be selected')
: t('formgen.select.item', 'Select this item')
}
className={styles.itemCheckbox}
/>
</div>
)}
{/* Action Buttons */}
{actionButtons.length > 0 && (
<div
ref={(el) => {
if (el) {
actionButtonsRefs.current.set(index, el);
} else {
actionButtonsRefs.current.delete(index);
}
}}
className={styles.itemActions}
onClick={(e) => e.stopPropagation()}
>
{actionButtons.map((actionButton, actionIndex) => {
const actionTitle = typeof actionButton.title === 'function'
? actionButton.title(row)
: actionButton.title;
const disabledResult = actionButton.disabled ? actionButton.disabled(row) : false;
const isLoading = actionButton.loading ? actionButton.loading(row) : false;
const isProcessing = actionButton.isProcessing ? actionButton.isProcessing(row) : false;
const baseProps = {
row,
disabled: disabledResult,
loading: isLoading,
className: actionButton.className,
title: actionTitle,
idField: actionButton.idField ?? 'id',
nameField: actionButton.nameField ?? 'name',
typeField: actionButton.typeField ?? 'type',
contentField: actionButton.contentField ?? 'content',
statusField: actionButton.statusField ?? 'status',
authorityField: actionButton.authorityField ?? 'authority',
operationName: actionButton.operationName,
refreshOperationName: actionButton.refreshOperationName,
loadingStateName: actionButton.loadingStateName
};
switch (actionButton.type) {
case 'edit':
return <EditActionButton
key={actionIndex}
{...baseProps}
onEdit={actionButton.onAction}
hookData={hookData}
/>;
case 'delete':
return <DeleteActionButton key={actionIndex} {...baseProps} containerRef={{ current: actionButtonsRefs.current.get(index) || null }} hookData={hookData} />;
case 'download':
return <DownloadActionButton key={actionIndex} {...baseProps} onDownload={actionButton.onAction || (() => {})} isDownloading={isProcessing} hookData={hookData} operationName={actionButton.operationName} />;
case 'view':
return <ViewActionButton key={actionIndex} {...baseProps} onView={actionButton.onAction || (() => {})} isViewing={isProcessing} hookData={hookData} />;
case 'copy':
return <CopyActionButton key={actionIndex} {...baseProps} onCopy={actionButton.onAction} isCopying={isProcessing} contentField={actionButton.contentField} />;
case 'connect':
return <ConnectActionButton key={actionIndex} {...baseProps} hookData={hookData} />;
case 'play':
return <PlayActionButton
key={actionIndex}
{...baseProps}
onPlay={actionButton.onAction}
hookData={hookData}
navigateTo={actionButton.navigateTo}
contentField={actionButton.contentField}
mode={(actionButton as any).mode || 'prompt'}
/>;
default:
return null;
}
})}
</div>
)}
{/* Fields */}
<div className={styles.itemFields}>
{detectedFields.map(field => {
const cellValue = row[field.key];
const customClassName = field.cellClassName ? field.cellClassName(cellValue, row) : '';
return (
<div
key={field.key}
className={`${styles.itemField} ${customClassName}`}
>
<label className={styles.fieldLabel}>{field.label}</label>
{renderFieldInput(field, cellValue, row, index)}
</div>
);
})}
</div>
</div>
);
})}
</div>
)}
</>
)}
</div>
{/* Pagination */}
{pagination && (
<div className={styles.pagination}>
{showPageSizeSelector && (
<div className={styles.pageSizeSelector}>
<label htmlFor="pageSize">{t('formgen.pagination.pageSize', 'Items per page:')}</label>
<select
id="pageSize"
value={currentPageSize}
onChange={(e) => handlePageSizeChange(Number(e.target.value))}
className={styles.pageSizeSelect}
>
{pageSizeOptions.map(size => (
<option key={size} value={size}>{size}</option>
))}
</select>
</div>
)}
{totalPages > 1 && (
<>
<button
onClick={() => setCurrentPage(1)}
disabled={currentPage === 1}
className={styles.paginationButton}
title={t('formgen.pagination.first')}
>
««
</button>
<button
onClick={() => setCurrentPage(currentPage - 1)}
disabled={currentPage === 1}
className={styles.paginationButton}
title={t('formgen.pagination.prev')}
>
«
</button>
<span className={styles.paginationInfo}>
{t('formgen.pagination.info')
.replace('{page}', currentPage.toString())
.replace('{total}', totalPages.toString())
.replace('{count}', supportsBackendPagination && hookData?.pagination
? hookData.pagination.totalItems.toString()
: displayData.length.toString())}
</span>
<button
onClick={() => setCurrentPage(currentPage + 1)}
disabled={currentPage === totalPages}
className={styles.paginationButton}
title={t('formgen.pagination.next')}
>
»
</button>
<button
onClick={() => setCurrentPage(totalPages)}
disabled={currentPage === totalPages}
className={styles.paginationButton}
title={t('formgen.pagination.last')}
>
»»
</button>
</>
)}
</div>
)}
</div>
);
}
export default FormGeneratorList;

View file

@ -0,0 +1,3 @@
export { default as FormGeneratorList, FormGeneratorList as FormGeneratorListComponent } from './FormGeneratorList';
export type { FieldConfig, FormGeneratorListProps } from './FormGeneratorList';

View file

@ -1,9 +1,12 @@
.formGenerator {
.formGeneratorTable {
display: flex;
flex-direction: column;
gap: 20px;
width: 100%;
font-family: var(--font-family);
/* Ensure proper height constraints for scrolling */
min-height: 0;
max-height: 100%;
}
.title {
@ -14,298 +17,6 @@
margin-bottom: 10px;
}
/* Integrated Delete Controls - appears inside the controls container */
.deleteControlsIntegrated {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.deleteButton,
.deleteAllButton {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border: none;
border-radius: 20px;
font-size: 14px;
font-family: var(--font-family);
cursor: pointer;
transition: all 0.2s ease;
font-weight: 500;
color: white;
background: var(--color-secondary);
}
.deleteButton {
background: var(--color-secondary);
color: white;
}
.deleteButton:hover {
background: var(--color-secondary-hover);
transform: translateY(-1px);
}
.deleteAllButton {
background: var(--color-secondary);
color: white;
}
.deleteAllButton:hover {
background: var(--color-secondary-hover);
transform: translateY(-1px);
}
.deleteIcon {
font-size: 16px;
font-weight: bold;
}
.selectionInfo {
color: var(--color-text);
font-size: 14px;
font-family: var(--font-family);
margin-left: auto;
opacity: 0.8;
}
/* Controls Section */
.controls {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 15px;
background: var(--color-bg);
border: 1px solid var(--color-primary);
border-radius: 25px;
}
.searchContainer {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.refreshButton {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border: 1px solid var(--color-primary);
border-radius: 50%;
background: var(--color-bg);
color: var(--color-text);
cursor: pointer;
transition: all 0.2s ease;
font-size: 16px;
font-family: var(--font-family);
flex-shrink: 0;
}
.refreshButton:hover:not(:disabled) {
background: var(--color-secondary);
color: white;
border-color: var(--color-secondary);
}
.refreshButton:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.refreshIcon {
font-size: 18px;
font-weight: bold;
transition: transform 0.2s ease;
}
.floatingLabelInput {
position: relative;
width: 250px;
}
.label {
position: absolute;
left: 16px;
top: 50%;
transform: translateY(-50%);
color: var(--color-text);
opacity: 0.6;
font-size: 14px;
pointer-events: none;
transition: all 0.3s ease;
background-color: transparent;
font-family: var(--font-family);
}
.focusedLabel {
position: absolute;
left: 12px;
top: -8px;
transform: translateY(0);
color: var(--color-secondary);
font-size: 12px;
pointer-events: none;
transition: all 0.3s ease;
background-color: var(--color-bg);
padding: 0 4px;
font-family: var(--font-family);
font-weight: 500;
}
.searchInput {
width: 100%;
height: 40px;
padding: 8px 12px;
border: 1px solid var(--color-primary);
border-radius: 25px;
font-size: 14px;
font-family: var(--font-family);
background: var(--color-bg);
color: var(--color-text);
transition: all 0.2s ease;
box-sizing: border-box;
}
.searchInput:focus {
border-color: var(--color-secondary);
box-shadow: 0 0 0 2px rgba(var(--color-secondary), 0.1);
}
.searchInput::placeholder {
color: transparent;
}
.filtersContainer {
display: flex;
flex-wrap: wrap;
gap: 15px;
align-items: center;
}
.filterGroup {
display: flex;
align-items: center;
gap: 8px;
}
.filterGroup .floatingLabelInput {
width: 160px;
}
.customSelectContainer {
position: relative;
display: inline-block;
min-width: 120px;
}
.filterInput {
width: 100%;
height: 40px;
padding: 6px 10px;
border: 1px solid var(--color-primary);
border-radius: 25px;
font-size: 14px;
font-family: var(--font-family);
background: var(--color-bg);
color: var(--color-text);
opacity: 0.6;
min-width: 120px;
transition: all 0.2s ease;
box-sizing: border-box;
}
.filterInput:focus {
outline: none;
border-color: var(--color-secondary);
opacity: 1;
box-shadow: 0 0 0 2px rgba(var(--color-secondary), 0.1);
}
.filterInput::placeholder {
color: transparent;
}
.filterSelect {
height: 40px;
padding: 6px 10px;
border: 1px solid var(--color-primary);
border-radius: 25px;
font-size: 14px;
font-family: var(--font-family);
background: var(--color-bg);
color: var(--color-text);
opacity: 0.6;
min-width: 120px;
box-sizing: border-box;
}
.filterSelect {
appearance: none;
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6,9 12,15 18,9'%3e%3c/polyline%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 10px center;
background-size: 16px;
padding-right: 35px;
}
/* Hide dropdown arrow when filter has a value */
.filterSelect.hasValue {
background-image: none;
color: var(--color-secondary);
border-color: var(--color-secondary);
opacity: 1;
}
.filterSelect:focus {
outline: none;
border-color: var(--color-secondary);
opacity: 1;
}
.clearFilterButton {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: var(--color-primary);
cursor: pointer;
font-size: 16px;
padding: 2px;
border-radius: 50%;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.clearFilterButton:hover {
background: none;
color: var(--color-secondary);
}
/* Table Container */
.tableContainer {
position: relative;
@ -313,7 +24,25 @@
border: 1px solid var(--color-primary);
border-radius: 25px;
background: var(--color-bg);
max-height: 70%;
/* Use calc to account for controls, pagination, and spacing */
max-height: calc(100vh - 400px);
/* No min-height - let it shrink to fit content */
/* When empty, it will only show header */
}
/* Empty table styling - no extra space, just header */
.emptyTable {
min-height: auto;
height: auto;
max-height: none;
}
/* Empty message styling */
.emptyMessage {
text-align: center;
padding: 20px;
color: var(--color-text);
opacity: 0.6;
}
.loading {
@ -577,6 +306,8 @@ tbody .actionsColumn {
gap: 10px;
padding: 15px;
border-top: 1px solid var(--color-primary);
/* Ensure pagination stays visible and doesn't get cut off */
flex-shrink: 0;
background: var(--color-bg);
border-radius: 0 0 8px 8px;
}
@ -642,60 +373,15 @@ tbody .actionsColumn {
/* Responsive Design */
@media (max-width: 768px) {
.deleteControlsIntegrated {
flex-direction: column;
align-items: stretch;
gap: 10px;
width: 100%;
}
.deleteButton,
.deleteAllButton {
justify-content: center;
padding: 10px 16px;
}
.selectionInfo {
text-align: center;
margin-left: 0;
margin-top: 5px;
}
.controls {
flex-direction: column;
align-items: stretch;
gap: 15px;
padding: 10px;
}
.filtersContainer {
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.filterGroup {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.filterInput,
.filterSelect {
width: 100%;
min-width: auto;
}
.floatingLabelInput {
max-width: none;
}
.filterGroup .floatingLabelInput {
width: 100%;
}
.tableContainer {
max-height: 90%px;
max-height: calc(100vh - 350px);
/* No min-height on mobile - let it shrink to fit content */
}
/* Empty table styling - no extra space */
.emptyTable {
min-height: auto;
height: auto;
}
.th,
@ -749,20 +435,10 @@ tbody .actionsColumn {
/* Accessibility */
.actionButton:focus,
.paginationButton:focus,
.searchInput:focus,
.filterInput:focus,
.filterSelect:focus,
.deleteButton:focus,
.deleteAllButton:focus {
.paginationButton:focus {
outline: none;
}
.deleteButton:focus,
.deleteAllButton:focus {
box-shadow: 0 0 0 2px rgba(220, 53, 69, 0.5);
}
/* Custom scrollbar for table container */
.tableContainer::-webkit-scrollbar {
width: 8px;
@ -807,4 +483,5 @@ tbody .actionsColumn {
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
}

View file

@ -1,6 +1,6 @@
import React, { useState, useMemo, useRef, useEffect } from 'react';
import { useLanguage } from '../../providers/language/LanguageContext';
import styles from './FormGenerator.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
import styles from './FormGeneratorTable.module.css';
import {
EditActionButton,
DeleteActionButton,
@ -9,13 +9,12 @@ import {
CopyActionButton,
ConnectActionButton,
PlayActionButton
} from './ActionButtons';
import { Button } from '../UiComponents/Button';
} from '../ActionButtons';
import { formatUnixTimestamp } from '../../../utils/time';
import { FormGeneratorControls } from '../FormGeneratorControls';
import { CopyableTruncatedValue } from '../../UiComponents/CopyableTruncatedValue';
import { IoIosRefresh } from "react-icons/io";
import { FaTrash } from "react-icons/fa";
// Types for the FormGenerator
// Types for the FormGeneratorTable
export interface ColumnConfig {
key: string;
label: string;
@ -31,7 +30,7 @@ export interface ColumnConfig {
cellClassName?: (value: any, row: any) => string; // For custom cell styling
}
export interface FormGeneratorProps<T = any> {
export interface FormGeneratorTableProps<T = any> {
data: T[];
columns?: ColumnConfig[];
title?: string;
@ -68,15 +67,8 @@ export interface FormGeneratorProps<T = any> {
operationName?: string; // Name of the operation function in hookData
refreshOperationName?: string; // Name of the refresh operation function in hookData (for connect button)
loadingStateName?: string; // Name of the loading state in hookData
// Edit configuration (for edit buttons)
editFields?: Array<{
key: string;
label: string;
type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'readonly';
editable?: boolean;
required?: boolean;
validator?: (value: any) => string | null;
}>;
// Navigation (for play button)
navigateTo?: string; // Route to navigate to when play button is clicked
}[];
onDelete?: (row: T) => void;
onDeleteMultiple?: (rows: T[]) => void;
@ -87,7 +79,7 @@ export interface FormGeneratorProps<T = any> {
hookData?: any; // Contains all hook data: refetch, operations, loading states, etc.
}
export function FormGenerator<T extends Record<string, any>>({
export function FormGeneratorTable<T extends Record<string, any>>({
data,
columns: providedColumns,
searchable = true,
@ -110,26 +102,71 @@ export function FormGenerator<T extends Record<string, any>>({
className = '',
getRowDataAttributes,
hookData
}: FormGeneratorProps<T>) {
}: FormGeneratorTableProps<T>) {
const { t } = useLanguage();
// Auto-detect columns if not provided
// Use provided columns (from attributes) if available, otherwise auto-detect from data
// Columns should persist even when data is empty (e.g., after filtering)
// Use a ref to cache columns so they persist across data changes
const columnsRef = useRef<ColumnConfig[]>([]);
const detectedColumns = useMemo((): ColumnConfig[] => {
if (providedColumns) return providedColumns;
// Always use providedColumns if available (from attributes/hookData.columns)
// This ensures columns persist even when data is empty
if (providedColumns && providedColumns.length > 0) {
columnsRef.current = providedColumns;
return providedColumns;
}
if (data.length === 0) return [];
// If we have cached columns and no new columns provided, use cached columns
// This prevents columns from disappearing when data becomes empty
if (columnsRef.current.length > 0 && data.length === 0) {
return columnsRef.current;
}
// Only auto-detect if no columns provided AND we have data
if (data.length === 0) {
// Return cached columns if available, otherwise empty array
return columnsRef.current;
}
const sampleRow = data[0];
return Object.keys(sampleRow).map(key => {
const autoDetected = Object.keys(sampleRow).map(key => {
const value = sampleRow[key];
let type: ColumnConfig['type'] = 'string';
// Check if field name suggests it's a timestamp/date field
const isTimestampField = /(at|date|time|timestamp|created|updated|expires|checked)$/i.test(key);
// Auto-detect type based on value
if (typeof value === 'number') {
type = 'number';
// Check if it's a Unix timestamp (in seconds or milliseconds)
// Unix timestamps are typically between 1970-01-01 (0) and year 2100 (4102444800 in seconds, 4102444800000 in ms)
if (isTimestampField || (value > 0 && value < 4102444800000)) {
// If it's a reasonable timestamp range, treat as date
// Timestamps in seconds are < 4102444800, timestamps in ms are < 4102444800000
if (value < 10000000000) {
// Likely Unix timestamp in seconds (e.g., 1704067200)
type = 'date';
} else if (value < 4102444800000) {
// Could be Unix timestamp in milliseconds (e.g., 1704067200000)
type = 'date';
} else {
// Too large to be a timestamp, treat as number
type = 'number';
}
} else {
type = 'number';
}
} else if (typeof value === 'boolean') {
type = 'boolean';
} else if (value instanceof Date || (typeof value === 'string' && !isNaN(Date.parse(value)) && value.includes('-'))) {
type = 'date';
} else if (isTimestampField && typeof value === 'string') {
// Field name suggests timestamp but value is string - try to parse
const numValue = parseFloat(value);
if (!isNaN(numValue) && numValue > 0 && numValue < 4102444800000) {
type = 'date';
}
}
return {
@ -144,7 +181,14 @@ export function FormGenerator<T extends Record<string, any>>({
maxWidth: 400
};
});
}, [data, providedColumns]);
// Cache auto-detected columns
if (autoDetected.length > 0) {
columnsRef.current = autoDetected;
}
return autoDetected;
}, [providedColumns, data]);
// State management
const [searchTerm, setSearchTerm] = useState('');
@ -157,6 +201,75 @@ export function FormGenerator<T extends Record<string, any>>({
const [currentPage, setCurrentPage] = useState(1);
const [currentPageSize, setCurrentPageSize] = useState(pageSize);
// Check if backend pagination is supported (hookData has refetch that accepts params)
const supportsBackendPagination = hookData?.refetch && typeof hookData.refetch === 'function';
// Debounce search term for backend calls
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(searchTerm);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearchTerm(searchTerm);
}, 300); // 300ms debounce
return () => clearTimeout(timer);
}, [searchTerm]);
// Call backend when filters/search/sort/pagination change
useEffect(() => {
if (!supportsBackendPagination || !hookData?.refetch) return;
// Build pagination parameters
const paginationParams: any = {
page: currentPage,
pageSize: currentPageSize,
};
// Add search if provided
if (debouncedSearchTerm && debouncedSearchTerm.trim()) {
paginationParams.search = debouncedSearchTerm.trim();
}
// Add filters if provided
// Note: Date/timestamp filters are disabled in column config, so they won't appear here
const activeFilters: Record<string, any> = {};
Object.entries(filters).forEach(([key, value]) => {
if (value !== undefined && value !== '') {
activeFilters[key] = value;
}
});
if (Object.keys(activeFilters).length > 0) {
paginationParams.filters = activeFilters;
}
// Add sort if provided
if (sortConfig) {
paginationParams.sort = [{
field: sortConfig.key,
direction: sortConfig.direction
}];
}
// Log search parameters being sent to backend
console.log('🔍 FormGeneratorTable: Calling backend with pagination params:', {
searchTerm: debouncedSearchTerm,
searchInParams: paginationParams.search,
filters: paginationParams.filters,
sort: paginationParams.sort,
page: paginationParams.page,
pageSize: paginationParams.pageSize,
fullParams: paginationParams
});
// Call backend refetch with parameters
hookData.refetch(paginationParams).then(() => {
console.log('✅ FormGeneratorTable: Backend refetch completed');
}).catch((error: any) => {
console.error('❌ FormGeneratorTable: Backend refetch failed:', error);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSearchTerm, filters, sortConfig, currentPage, currentPageSize, supportsBackendPagination]);
// Refs for action buttons containers to detect clicks outside
const actionButtonsRefs = useRef<Map<number, HTMLDivElement>>(new Map());
@ -166,116 +279,32 @@ export function FormGenerator<T extends Record<string, any>>({
const startX = useRef<number>(0);
const startWidth = useRef<number>(0);
// Initialize column widths
// Initialize column widths - preserve widths even when columns don't change
useEffect(() => {
if (detectedColumns.length === 0) return; // Don't clear widths if no columns
const initialWidths: Record<string, number> = {};
detectedColumns.forEach(col => {
// Set a default width if none specified to ensure all columns have explicit widths
initialWidths[col.key] = col.width || 150;
// Preserve existing width if column already exists
initialWidths[col.key] = col.width || columnWidths[col.key] || 150;
});
setColumnWidths(initialWidths);
setColumnWidths(prev => ({ ...prev, ...initialWidths }));
}, [detectedColumns]);
// Filter and search data
const filteredData = useMemo(() => {
let result = [...data];
// Data is already filtered, sorted, and paginated by the backend
// No client-side processing needed
const displayData = data;
// Apply search filter
if (searchTerm && searchable) {
const searchLower = searchTerm.toLowerCase();
result = result.filter(row => {
return detectedColumns.some(col => {
if (!col.searchable) return false;
const value = row[col.key];
return String(value).toLowerCase().includes(searchLower);
});
});
// Get pagination info from backend
const totalPages = useMemo(() => {
if (!supportsBackendPagination || !hookData?.pagination) {
return 1; // No pagination if backend doesn't support it
}
// Apply column filters
Object.entries(filters).forEach(([key, filterValue]) => {
if (filterValue !== undefined && filterValue !== '') {
result = result.filter(row => {
const value = row[key];
const column = detectedColumns.find(col => col.key === key);
if (column?.type === 'boolean') {
return Boolean(value) === Boolean(filterValue);
} else if (column?.type === 'number') {
return Number(value) === Number(filterValue);
} else if (column?.type === 'date') {
// Handle Unix timestamps in seconds (backend format) by converting to milliseconds
let rowDate: Date;
if (typeof value === 'number') {
// If it's a number, check if it's in seconds (typical Unix timestamp range)
if (value < 10000000000) { // Less than year 2286 in seconds
rowDate = new Date(value * 1000); // Convert seconds to milliseconds
} else {
rowDate = new Date(value); // Already in milliseconds
}
} else if (typeof value === 'string') {
// Check if it's an ISO date string or date string (contains 'T', '-', or ':')
if (value.includes('T') || value.includes('-') || value.includes(':')) {
rowDate = new Date(value); // Parse as date string (ISO or other formats)
} else {
// Try to parse as number (Unix timestamp as string)
const numValue = parseFloat(value);
if (!isNaN(numValue)) {
if (numValue < 10000000000) { // Less than year 2286 in seconds
rowDate = new Date(numValue * 1000); // Convert seconds to milliseconds
} else {
rowDate = new Date(numValue); // Already in milliseconds
}
} else {
rowDate = new Date(value); // Fallback: try parsing as date string
}
}
} else {
rowDate = new Date(value);
}
const rowFormatted = `${rowDate.getDate().toString().padStart(2, '0')}.${(rowDate.getMonth() + 1).toString().padStart(2, '0')}.${rowDate.getFullYear()}`;
// Check if filter value is complete (DD.MM.YYYY)
if (filterValue.length === 10 && filterValue.match(/^\d{2}\.\d{2}\.\d{4}$/)) {
return rowFormatted === filterValue;
} else {
// Partial matching for incomplete dates
return rowFormatted.startsWith(filterValue);
}
} else {
return String(value).toLowerCase().includes(String(filterValue).toLowerCase());
}
});
}
});
// Apply sorting
if (sortConfig) {
result.sort((a, b) => {
const aVal = a[sortConfig.key];
const bVal = b[sortConfig.key];
if (aVal < bVal) return sortConfig.direction === 'asc' ? -1 : 1;
if (aVal > bVal) return sortConfig.direction === 'asc' ? 1 : -1;
return 0;
});
}
return result;
}, [data, searchTerm, filters, sortConfig, detectedColumns, searchable]);
// Pagination
const paginatedData = useMemo(() => {
if (!pagination) return filteredData;
const startIndex = (currentPage - 1) * currentPageSize;
return filteredData.slice(startIndex, startIndex + currentPageSize);
}, [filteredData, currentPage, currentPageSize, pagination]);
const totalPages = Math.ceil(filteredData.length / currentPageSize);
return hookData.pagination.totalPages || 1;
}, [supportsBackendPagination, hookData?.pagination]);
// Handle sorting
const handleSort = (key: string) => {
@ -312,7 +341,7 @@ export function FormGenerator<T extends Record<string, any>>({
const handleRowSelect = (index: number) => {
if (!selectable) return;
const row = paginatedData[index];
const row = displayData[index];
if (isRowSelectable && !isRowSelectable(row)) return;
const newSelected = new Set(selectedRows);
@ -324,7 +353,7 @@ export function FormGenerator<T extends Record<string, any>>({
setSelectedRows(newSelected);
if (onRowSelect) {
const selectedData = Array.from(newSelected).map(i => paginatedData[i]);
const selectedData = Array.from(newSelected).map(i => displayData[i]);
onRowSelect(selectedData);
}
};
@ -334,7 +363,7 @@ export function FormGenerator<T extends Record<string, any>>({
if (!selectable) return;
// Get only selectable rows
const selectableIndices = paginatedData
const selectableIndices = displayData
.map((row, index) => ({ row, index }))
.filter(({ row }) => !isRowSelectable || isRowSelectable(row))
.map(({ index }) => index);
@ -345,7 +374,7 @@ export function FormGenerator<T extends Record<string, any>>({
} else {
const allSelectableIndices = new Set(selectableIndices);
setSelectedRows(allSelectableIndices);
const selectableData = selectableIndices.map(i => paginatedData[i]);
const selectableData = selectableIndices.map(i => displayData[i]);
onRowSelect?.(selectableData);
}
};
@ -360,7 +389,7 @@ export function FormGenerator<T extends Record<string, any>>({
newSelected.delete(index);
setSelectedRows(newSelected);
if (onRowSelect) {
const selectedData = Array.from(newSelected).map(i => paginatedData[i]);
const selectedData = Array.from(newSelected).map(i => displayData[i]);
onRowSelect(selectedData);
}
}
@ -370,7 +399,7 @@ export function FormGenerator<T extends Record<string, any>>({
// Handle delete multiple items
const handleDeleteMultiple = () => {
if (onDeleteMultiple && selectedRows.size > 0) {
const selectedData = Array.from(selectedRows).map(i => paginatedData[i]);
const selectedData = Array.from(selectedRows).map(i => displayData[i]);
onDeleteMultiple(selectedData);
// Clear selection
setSelectedRows(new Set());
@ -436,64 +465,158 @@ export function FormGenerator<T extends Record<string, any>>({
document.removeEventListener('mouseup', handleMouseUp);
};
// Check if a column is an ID field
const isIdField = (columnKey: string): boolean => {
const lowerKey = columnKey.toLowerCase();
// Match exact "id" or fields ending with "Id" or "ID" (camelCase/PascalCase)
// Also match fields like "mandateId", "userId", "workflowId", "fileId", etc.
return /^(id|_id)$/i.test(columnKey) ||
/Id$/i.test(columnKey) ||
/ID$/i.test(columnKey) ||
(lowerKey.includes('id') && (
lowerKey.includes('mandate') ||
lowerKey.includes('user') ||
lowerKey.includes('workflow') ||
lowerKey.includes('file') ||
lowerKey.includes('prompt') ||
lowerKey.includes('connection')
));
};
// Check if a column is a hash field
const isHashField = (columnKey: string, value: any): boolean => {
const hashPatterns = /hash|Hash|HASH/i;
if (hashPatterns.test(columnKey)) {
return true;
}
// Also check if value looks like a hash (long alphanumeric string)
if (typeof value === 'string' && value.length > 20) {
const hashLikePattern = /^[a-f0-9]{20,}$/i;
return hashLikePattern.test(value);
}
return false;
};
// Format cell value
const formatCellValue = (value: any, column: ColumnConfig, row: T) => {
if (value === null || value === undefined) {
return '-';
}
// Check if this is an ID or hash field that should be truncated and copyable
// Do this BEFORE checking for custom formatters to ensure IDs/hashes are always copyable
const isId = isIdField(column.key);
const isHash = isHashField(column.key, value);
if ((isId || isHash)) {
// Convert to string if needed
const stringValue = String(value);
if (stringValue.length > 0) {
return <CopyableTruncatedValue value={stringValue} maxLength={isHash ? 12 : 20} />;
}
}
// Use custom formatter if provided (but only if not an ID/hash field)
if (column.formatter) {
return column.formatter(value, row);
}
if (value === null || value === undefined) {
return '-';
// Check if this is a timestamp field even if column type isn't 'date'
const isTimestampField = /(at|date|time|timestamp|created|updated|expires|checked)$/i.test(column.key);
const isLikelyTimestamp = typeof value === 'number' && value > 0 && value < 4102444800000;
// If it's a timestamp field or looks like a timestamp, format as date
if ((isTimestampField || isLikelyTimestamp) && typeof value === 'number') {
try {
// Handle Unix timestamps in seconds (backend format)
let timestamp: number;
if (value < 10000000000) {
// Likely Unix timestamp in seconds
timestamp = value;
} else {
// Likely Unix timestamp in milliseconds
timestamp = value / 1000;
}
const formatted = formatUnixTimestamp(timestamp, undefined, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
});
return `${formatted.time} ${formatted.timezone}`;
} catch (error) {
console.error('Error formatting timestamp:', error, value);
// Fall through to default number formatting
}
}
switch (column.type) {
case 'date':
try {
// Handle Unix timestamps in seconds (backend format) by converting to milliseconds
let date: Date;
// Handle Unix timestamps in seconds (backend format)
let timestamp: number;
if (typeof value === 'number') {
// If it's a number, check if it's in seconds (typical Unix timestamp range)
// Unix timestamps in seconds are typically much smaller than milliseconds
if (value < 10000000000) { // Less than year 2286 in seconds
date = new Date(value * 1000); // Convert seconds to milliseconds
timestamp = value; // Already in seconds
} else {
date = new Date(value); // Already in milliseconds
timestamp = value / 1000; // Convert milliseconds to seconds
}
} else if (typeof value === 'string') {
// Check if it's an ISO date string or date string (contains 'T', '-', or ':')
if (value.includes('T') || value.includes('-') || value.includes(':')) {
date = new Date(value); // Parse as date string (ISO or other formats)
// Parse as date string and convert to Unix timestamp in seconds
const date = new Date(value);
if (isNaN(date.getTime())) return '-';
timestamp = Math.floor(date.getTime() / 1000);
} else {
// Try to parse as number (Unix timestamp as string)
const numValue = parseFloat(value);
if (!isNaN(numValue)) {
if (numValue < 10000000000) { // Less than year 2286 in seconds
date = new Date(numValue * 1000); // Convert seconds to milliseconds
timestamp = numValue; // Already in seconds
} else {
date = new Date(numValue); // Already in milliseconds
timestamp = numValue / 1000; // Convert milliseconds to seconds
}
} else {
date = new Date(value); // Fallback: try parsing as date string
// Fallback: try parsing as date string
const date = new Date(value);
if (isNaN(date.getTime())) return '-';
timestamp = Math.floor(date.getTime() / 1000);
}
}
} else if (value instanceof Date) {
// Already a Date object
if (isNaN(value.getTime())) return '-';
timestamp = Math.floor(value.getTime() / 1000);
} else {
date = new Date(value);
// Try to convert to Date and then to timestamp
const date = new Date(value);
if (isNaN(date.getTime())) return '-';
timestamp = Math.floor(date.getTime() / 1000);
}
if (isNaN(date.getTime())) return '-';
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
const timezoneOffset = date.getTimezoneOffset();
const offsetHours = Math.floor(Math.abs(timezoneOffset) / 60);
const offsetMinutes = Math.abs(timezoneOffset) % 60;
const offsetSign = timezoneOffset <= 0 ? '+' : '-';
const timezone = `GMT${offsetSign}${offsetHours}${offsetMinutes > 0 ? ':' + offsetMinutes.toString().padStart(2, '0') : ''}`;
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds} ${timezone}`;
} catch {
// Use formatUnixTimestamp utility function
const formatted = formatUnixTimestamp(timestamp, undefined, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
});
return `${formatted.time} ${formatted.timezone}`;
} catch (error) {
console.error('Error formatting date:', error, value);
return '-';
}
case 'boolean':
@ -506,219 +629,38 @@ export function FormGenerator<T extends Record<string, any>>({
};
return (
<div className={`${styles.formGenerator} ${className}`}>
<div className={`${styles.formGeneratorTable} ${className}`}>
{(searchable || filterable || (selectable && selectedRows.size > 0)) && (
<div className={styles.controls}>
{/* Delete Controls - Show when items are selected */}
{selectable && selectedRows.size > 0 && (
<div className={styles.deleteControlsIntegrated}>
{selectedRows.size === 1 && onDelete && (
<Button
onClick={() => {
const selectedIndex = Array.from(selectedRows)[0];
const selectedRow = paginatedData[selectedIndex];
handleDeleteSingle(selectedRow, selectedIndex);
}}
variant="primary"
size="sm"
icon={FaTrash}
>
{t('formgen.delete.single', 'Delete')}
</Button>
)}
{selectedRows.size > 1 && onDeleteMultiple && (
<Button
onClick={handleDeleteMultiple}
variant="primary"
size="sm"
icon={FaTrash}
>
{t('formgen.delete.multiple', `Delete ${selectedRows.size} selected items`).replace('{count}', selectedRows.size.toString())}
</Button>
)}
</div>
)}
{/* Search Controls - Hide when items are selected */}
{searchable && selectedRows.size === 0 && (
<div className={styles.searchContainer}>
<div className={styles.floatingLabelInput}>
<input
type="text"
placeholder=" "
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onFocus={() => setSearchFocused(true)}
onBlur={() => setSearchFocused(false)}
className={`${styles.searchInput} ${searchFocused || searchTerm ? styles.focused : ''}`}
/>
<label className={searchFocused || searchTerm ? styles.focusedLabel : styles.label}>{t('formgen.search.placeholder')}</label>
</div>
{onRefresh && (
<button
onClick={onRefresh}
className={styles.refreshButton}
title={t('formgen.refresh.tooltip', 'Refresh data')}
disabled={loading}
>
<span className={styles.refreshIcon}><IoIosRefresh /></span>
</button>
)}
</div>
)}
{filterable && (
<div className={styles.filtersContainer}>
{detectedColumns.filter(col => col.filterable).map(column => (
<div key={column.key} className={styles.filterGroup}>
{column.type === 'boolean' ? (
<div className={styles.customSelectContainer}>
<select
value={filters[column.key] || ''}
onChange={(e) => handleFilter(column.key, e.target.value === '' ? undefined : e.target.value === 'true')}
className={`${styles.filterSelect} ${filters[column.key] ? styles.hasValue : ''}`}
>
<option value="" disabled hidden>{column.label}</option>
<option value="true">{t('formgen.filter.yes')}</option>
<option value="false">{t('formgen.filter.no')}</option>
</select>
{filters[column.key] && (
<button
type="button"
onClick={() => handleFilter(column.key, '')}
className={styles.clearFilterButton}
title={t('formgen.filter.clear')}
>
</button>
)}
</div>
) : column.filterOptions ? (
<div className={styles.customSelectContainer}>
<select
value={filters[column.key] || ''}
onChange={(e) => handleFilter(column.key, e.target.value)}
className={`${styles.filterSelect} ${filters[column.key] ? styles.hasValue : ''}`}
>
<option value="" disabled hidden>{column.label}</option>
{column.filterOptions.map(option => (
<option key={option} value={option}>{option}</option>
))}
</select>
{filters[column.key] && (
<button
type="button"
onClick={() => handleFilter(column.key, '')}
className={styles.clearFilterButton}
title={t('formgen.filter.clear')}
>
</button>
)}
</div>
) : column.type === 'date' ? (
<div className={styles.floatingLabelInput}>
<input
type="text"
placeholder=" "
value={filters[column.key] || ''}
onChange={(e) => {
let value = e.target.value;
const currentValue = filters[column.key] || '';
// Check if user is deleting (new value is shorter)
const isDeleting = value.length < currentValue.length;
if (isDeleting) {
// When deleting, preserve the exact input without auto-formatting
handleFilter(column.key, value);
return;
}
// Auto-pad single digits followed by dot (e.g., "4." -> "04.")
value = value.replace(/^(\d)\./, '0$1.');
value = value.replace(/\.(\d)\./, '.0$1.');
// Allow typing and format as DD.MM.YYYY
const digitsOnly = value.replace(/\D/g, ''); // Remove non-digits
let formatted = '';
if (digitsOnly.length >= 8) {
// Full format: DDMMYYYY -> DD.MM.YYYY
const day = digitsOnly.slice(0, 2);
const month = digitsOnly.slice(2, 4);
const year = digitsOnly.slice(4, 8);
// Validate day (01-31) and month (01-12)
if (parseInt(day) > 31 || parseInt(month) > 12 || parseInt(day) === 0 || parseInt(month) === 0) {
return; // Don't update if invalid
}
formatted = `${day}.${month}.${year}`;
} else if (digitsOnly.length >= 4) {
// Partial format: DDMM -> DD.MM.
const day = digitsOnly.slice(0, 2);
const month = digitsOnly.slice(2, 4);
const remaining = digitsOnly.slice(4);
// Validate day (01-31) and month (01-12)
if (parseInt(day) > 31 || parseInt(month) > 12 || parseInt(day) === 0 || parseInt(month) === 0) {
return; // Don't update if invalid
}
formatted = `${day}.${month}.${remaining}`;
} else if (digitsOnly.length >= 2) {
// Start format: DD -> DD.
const day = digitsOnly.slice(0, 2);
const remaining = digitsOnly.slice(2);
// Validate day (01-31)
if (parseInt(day) > 31 || parseInt(day) === 0) {
return; // Don't update if invalid
}
formatted = `${day}.${remaining}`;
} else {
// Just digits
formatted = digitsOnly;
}
handleFilter(column.key, formatted);
}}
onFocus={() => handleFilterFocus(column.key, true)}
onBlur={() => handleFilterFocus(column.key, false)}
className={`${styles.filterInput} ${filterFocused[column.key] || filters[column.key] ? styles.focused : ''}`}
maxLength={10}
/>
<label className={filterFocused[column.key] || filters[column.key] ? styles.focusedLabel : styles.label}>
{column.label}
</label>
</div>
) : (
<div className={styles.floatingLabelInput}>
<input
type="text"
placeholder=" "
value={filters[column.key] || ''}
onChange={(e) => handleFilter(column.key, e.target.value)}
onFocus={() => handleFilterFocus(column.key, true)}
onBlur={() => handleFilterFocus(column.key, false)}
className={`${styles.filterInput} ${filterFocused[column.key] || filters[column.key] ? styles.focused : ''}`}
/>
<label className={filterFocused[column.key] || filters[column.key] ? styles.focusedLabel : styles.label}>
{t('formgen.filter.placeholder').replace('{column}', column.label)}
</label>
</div>
)}
</div>
))}
</div>
)}
</div>
<FormGeneratorControls
fields={detectedColumns}
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
searchFocused={searchFocused}
onSearchFocus={setSearchFocused}
filters={filters}
onFilterChange={handleFilter}
filterFocused={filterFocused}
onFilterFocus={handleFilterFocus}
selectedCount={selectedRows.size}
displayData={displayData}
onDeleteSingle={selectedRows.size === 1 && onDelete ? () => {
const selectedIndex = Array.from(selectedRows)[0];
const selectedRow = displayData[selectedIndex];
handleDeleteSingle(selectedRow, selectedIndex);
} : undefined}
onDeleteMultiple={selectedRows.size > 1 && onDeleteMultiple ? handleDeleteMultiple : undefined}
onRefresh={onRefresh}
searchable={searchable}
filterable={filterable}
selectable={selectable}
loading={loading}
onDateFilterChange={(key, value) => handleFilter(key, value)}
/>
)}
{/* Table */}
<div className={styles.tableContainer}>
<div className={`${styles.tableContainer} ${displayData.length === 0 ? styles.emptyTable : ''}`}>
{loading ? (
<div className={styles.loadingState}>
<div className={styles.loadingSpinner}></div>
@ -733,7 +675,7 @@ export function FormGenerator<T extends Record<string, any>>({
<input
type="checkbox"
checked={(() => {
const selectableIndices = paginatedData
const selectableIndices = displayData
.map((row, index) => ({ row, index }))
.filter(({ row }) => !isRowSelectable || isRowSelectable(row))
.map(({ index }) => index);
@ -784,7 +726,14 @@ export function FormGenerator<T extends Record<string, any>>({
</tr>
</thead>
<tbody>
{paginatedData.map((row, index) => {
{displayData.length === 0 ? (
<tr>
<td colSpan={detectedColumns.length + (selectable ? 1 : 0) + (actionButtons.length > 0 ? 1 : 0)} className={styles.emptyMessage}>
{t('formgen.empty', 'No data available')}
</td>
</tr>
) : (
displayData.map((row, index) => {
const dataAttributes = getRowDataAttributes ? getRowDataAttributes(row, index) : {};
return (
<tr
@ -864,7 +813,6 @@ export function FormGenerator<T extends Record<string, any>>({
{...baseProps}
onEdit={actionButton.onAction}
hookData={hookData}
editFields={actionButton.editFields}
/>;
case 'delete':
return <DeleteActionButton key={actionIndex} {...baseProps} containerRef={{ current: actionButtonsRefs.current.get(index) || null }} hookData={hookData} />;
@ -877,7 +825,15 @@ export function FormGenerator<T extends Record<string, any>>({
case 'connect':
return <ConnectActionButton key={actionIndex} {...baseProps} hookData={hookData} />;
case 'play':
return <PlayActionButton key={actionIndex} {...baseProps} onPlay={actionButton.onAction} hookData={hookData} navigateTo={actionButton.navigateTo} />;
return <PlayActionButton
key={actionIndex}
{...baseProps}
onPlay={actionButton.onAction}
hookData={hookData}
navigateTo={actionButton.navigateTo}
contentField={actionButton.contentField}
mode={(actionButton as any).mode || 'prompt'}
/>;
default:
return null;
}
@ -907,7 +863,8 @@ export function FormGenerator<T extends Record<string, any>>({
</tr>
);
})}
})
)}
</tbody>
</table>
)}
@ -955,7 +912,9 @@ export function FormGenerator<T extends Record<string, any>>({
{t('formgen.pagination.info')
.replace('{page}', currentPage.toString())
.replace('{total}', totalPages.toString())
.replace('{count}', filteredData.length.toString())}
.replace('{count}', supportsBackendPagination && hookData?.pagination
? hookData.pagination.totalItems.toString()
: displayData.length.toString())}
</span>
<button
@ -982,4 +941,5 @@ export function FormGenerator<T extends Record<string, any>>({
);
}
export default FormGenerator;
export default FormGeneratorTable;

View file

@ -0,0 +1,3 @@
export { default as FormGeneratorTable, FormGeneratorTable as FormGeneratorTableComponent } from './FormGeneratorTable';
export type { ColumnConfig, FormGeneratorTableProps } from './FormGeneratorTable';

View file

@ -1,5 +1,18 @@
export { default as FormGenerator } from './FormGenerator';
export type { ColumnConfig, FormGeneratorProps } from './FormGenerator';
// Legacy export - FormGenerator is now FormGeneratorTable (for backward compatibility)
export { FormGeneratorTable as FormGenerator } from './FormGeneratorTable';
export type { ColumnConfig, FormGeneratorTableProps as FormGeneratorProps } from './FormGeneratorTable';
export { FormGeneratorTable } from './FormGeneratorTable';
export type { ColumnConfig, FormGeneratorTableProps } from './FormGeneratorTable';
export { FormGeneratorList } from './FormGeneratorList';
export type { FieldConfig, FormGeneratorListProps } from './FormGeneratorList';
export { FormGeneratorControls } from './FormGeneratorControls';
export type { FilterableField, FormGeneratorControlsProps } from './FormGeneratorControls';
export { FormGeneratorForm } from './FormGeneratorForm';
export type { FormGeneratorFormProps, AttributeDefinition, AttributeOption } from './FormGeneratorForm';
// Re-export action button components and types
export * from './ActionButtons';

View file

@ -1,163 +0,0 @@
/* Main table container */
.mitgliederTable {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
gap: 20px;
}
/* Header container with title and add button */
.headerContainer {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.tableTitle {
font-size: 1.5rem;
font-weight: 400;
color: var(--color-text);
margin: 0;
font-family: var(--font-family);
}
.addUserButton {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
background: var(--color-secondary);
color: white;
border: none;
border-radius: 25px;
font-size: 14px;
font-family: var(--font-family);
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.addUserButton:hover {
background: var(--color-secondary-hover);
transform: translateY(-1px);
}
.addUserButton:active {
transform: translateY(0);
}
/* FormGenerator container */
.mitgliederFormGenerator {
flex: 1;
min-height: 0;
}
/* Error state styling */
.errorState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px;
gap: 20px;
color: var(--color-text);
font-family: var(--font-family);
text-align: center;
}
.retryButton {
padding: 10px 20px;
border: 1px solid var(--color-primary);
border-radius: 20px;
background: var(--color-bg);
color: var(--color-text);
cursor: pointer;
transition: all 0.2s ease;
font-family: var(--font-family);
}
.retryButton:hover {
background: var(--color-primary);
color: var(--color-bg);
}
/* User-specific formatting */
.userName {
font-weight: 500;
color: var(--color-text);
}
.userFullName {
color: var(--color-text);
}
.userEmail {
color: var(--color-gray);
font-size: 0.9em;
}
.userLanguage {
color: var(--color-text);
font-size: 0.85em;
}
.userPrivilege {
color: var(--color-text);
font-size: 0.85em;
}
.userEnabled {
font-size: 0.85em;
font-weight: 500;
}
.userEnabled.enabled {
color: var(--color-success, #28a745);
}
.userEnabled.disabled {
color: var(--color-danger, #dc3545);
}
.userAuthAuthority {
color: var(--color-gray);
font-size: 0.85em;
}
/* Delete confirmation dialog styles */
.deleteConfirmation {
padding: 20px;
text-align: center;
color: var(--color-text);
font-family: var(--font-family);
}
.deleteConfirmation p {
margin: 0 0 20px 0;
font-size: 16px;
line-height: 1.5;
}
.userInfo {
margin: 10px 0;
padding: 10px;
background: var(--color-gray-disabled);
border-radius: 8px;
text-align: left;
}
.userInfo strong {
color: var(--color-secondary);
margin-right: 8px;
}
.warning {
margin-top: 20px !important;
color: var(--color-danger, #dc3545);
font-weight: 500;
font-size: 14px;
}

View file

@ -1,264 +0,0 @@
import { FormGenerator } from '../FormGenerator/FormGenerator';
import { Popup } from '../UiComponents/Popup/Popup';
import { EditForm, EditFieldConfig } from '../UiComponents/Popup/EditForm';
import { useMitgliederLogic } from './mitgliederLogic';
import { MitgliederTableProps } from './mitgliederTypes';
import { useLanguage } from '../../providers/language/LanguageContext';
import styles from './MitgliederTable.module.css';
function MitgliederTable({ className = '', showAddUser = false, onAddUserClose }: MitgliederTableProps) {
const { t } = useLanguage();
const {
users,
loading,
error,
columns,
actions,
refetch,
editingUser,
handleSaveUser,
handleCancelEdit,
deletingUser,
handleConfirmDelete,
handleCancelDelete,
handleSaveNewUser
} = useMitgliederLogic();
// Override handleCancelAddUser to use the parent's onAddUserClose
const handleCancelAddUserOverride = () => {
onAddUserClose?.();
};
// Configure edit form fields - moved inside component to access editingUser
const editFields: EditFieldConfig[] = [
{
key: 'username',
label: t('users.column.username', 'Username'),
type: 'string',
editable: false, // Username should not be editable
required: true
},
{
key: 'fullName',
label: t('users.column.name', 'Name'),
type: 'string',
editable: true,
required: true
},
{
key: 'email',
label: t('users.column.email', 'Email'),
type: 'email',
editable: editingUser?.authenticationAuthority === 'local',
required: true
},
{
key: 'language',
label: t('users.column.language', 'Language'),
type: 'enum',
editable: true,
required: true,
options: ['en', 'de', 'fr']
},
{
key: 'privilege',
label: t('users.column.privilege', 'Privilege'),
type: 'enum',
editable: true,
required: true,
options: ['viewer', 'user', 'admin', 'sysadmin']
},
{
key: 'enabled',
label: t('users.column.enabled', 'Enabled'),
type: 'boolean',
editable: true,
required: false
},
{
key: 'authenticationAuthority',
label: t('users.column.authAuthority', 'Auth Authority'),
type: 'readonly',
editable: false,
required: false
}
];
// Configure add user form fields
const addUserFields: EditFieldConfig[] = [
{
key: 'username',
label: t('users.column.username', 'Username'),
type: 'string',
editable: true,
required: true
},
{
key: 'email',
label: t('users.column.email', 'Email'),
type: 'email',
editable: true,
required: true
},
{
key: 'password',
label: t('users.column.password', 'Password'),
type: 'string',
editable: true,
required: true,
placeholder: t('users.password.placeholder', 'Enter password')
},
{
key: 'fullName',
label: t('users.column.name', 'Name'),
type: 'string',
editable: true,
required: true
},
{
key: 'language',
label: t('users.column.language', 'Language'),
type: 'enum',
editable: true,
required: true,
options: ['en', 'de', 'fr']
},
{
key: 'privilege',
label: t('users.column.privilege', 'Privilege'),
type: 'enum',
editable: true,
required: true,
options: ['viewer', 'user', 'admin', 'sysadmin']
},
{
key: 'enabled',
label: t('users.column.enabled', 'Enabled'),
type: 'boolean',
editable: true,
required: false
}
];
if (error) {
return (
<div className={styles.errorState}>
<p>{t('users.error.loading', 'Error loading users:')} {error}</p>
<button onClick={refetch} className={styles.retryButton}>
{t('common.retry', 'Retry')}
</button>
</div>
);
}
return (
<div className={`${styles.mitgliederTable} ${className}`}>
<FormGenerator
data={users}
columns={columns}
title={t('users.title', 'Users')}
searchable={true}
filterable={true}
sortable={true}
resizable={true}
pagination={true}
pageSize={10}
selectable={false}
loading={loading}
actionButtons={actions}
onRefresh={refetch}
className={styles.mitgliederFormGenerator}
getRowDataAttributes={(row) => {
const enabled = row.enabled?.toString() || 'false';
console.log('Row data attributes:', { enabled, row: row.username });
return {
'user-enabled': enabled
};
}}
/>
{/* Edit User Popup */}
{editingUser && (
<Popup
isOpen={!!editingUser}
title={t('users.edit.title', 'Edit User')}
onClose={handleCancelEdit}
size="medium"
>
<EditForm
data={editingUser}
fields={editFields}
onSave={handleSaveUser}
onCancel={handleCancelEdit}
saveButtonText={t('common.save', 'Save')}
cancelButtonText={t('common.cancel', 'Cancel')}
/>
</Popup>
)}
{/* Delete Confirmation Popup */}
{deletingUser && (
<Popup
isOpen={!!deletingUser}
title={t('users.delete.title', 'Delete User')}
onClose={handleCancelDelete}
size="small"
actions={[
{
label: t('common.cancel', 'Cancel'),
onClick: handleCancelDelete,
variant: 'secondary'
},
{
label: t('users.delete.confirm', 'Delete'),
onClick: handleConfirmDelete,
variant: 'danger'
}
]}
>
<div className={styles.deleteConfirmation}>
<p>{t('users.delete.message', 'Are you sure you want to delete this user?')}</p>
<div className={styles.userInfo}>
<strong>{t('users.column.name', 'Name')}:</strong> {deletingUser.fullName || deletingUser.username}
</div>
<div className={styles.userInfo}>
<strong>{t('users.column.email', 'Email')}:</strong> {deletingUser.email}
</div>
<p className={styles.warning}>
{t('users.delete.warning', 'This action cannot be undone.')}
</p>
</div>
</Popup>
)}
{/* Add User Popup */}
{showAddUser && (
<Popup
isOpen={showAddUser}
title={t('users.add.title', 'Add User')}
onClose={handleCancelAddUserOverride}
size="medium"
>
<EditForm
data={{
username: '',
email: '',
password: '',
fullName: '',
language: 'en',
privilege: 'user',
enabled: true
}}
fields={addUserFields}
onSave={handleSaveNewUser}
onCancel={handleCancelAddUserOverride}
saveButtonText={t('users.add.create', 'Create User')}
cancelButtonText={t('common.cancel', 'Cancel')}
/>
</Popup>
)}
</div>
);
}
export default MitgliederTable;

View file

@ -1,3 +0,0 @@
export { default as MitgliederTable } from './MitgliederTable';
export { useMitgliederLogic } from './mitgliederLogic';
export type * from './mitgliederTypes';

View file

@ -1,271 +0,0 @@
import { useMemo, useState } from 'react';
import { useOrgUsers } from '../../hooks/useUsers';
import { useLanguage } from '../../providers/language/LanguageContext';
import type {
MitgliederLogicReturn,
UserActionConfig,
UserColumnConfig
} from './mitgliederTypes';
export function useMitgliederLogic(): MitgliederLogicReturn {
const { users, loading, error, refetch, updateUser, deleteUser, createUser } = useOrgUsers();
const { t } = useLanguage();
const [editingUser, setEditingUser] = useState<any>(null);
const [deletingUser, setDeletingUser] = useState<any>(null);
// Configure columns for the users table
const columns: UserColumnConfig[] = useMemo(() => [
{
key: 'username',
label: t('users.column.username', 'Username'),
type: 'string',
width: 150,
minWidth: 120,
maxWidth: 200,
sortable: true,
filterable: true,
searchable: true,
formatter: (value: string | undefined) => (
<span className="userName">
{value || t('users.noUsername', 'No Username')}
</span>
)
},
{
key: 'fullName',
label: t('users.column.name', 'Name'),
type: 'string',
width: 200,
minWidth: 150,
maxWidth: 300,
sortable: true,
filterable: true,
searchable: true,
formatter: (value: string | undefined) => (
<span className="userFullName">
{value || t('users.noName', 'No Name')}
</span>
)
},
{
key: 'email',
label: t('users.column.email', 'Email'),
type: 'string',
width: 250,
minWidth: 200,
maxWidth: 350,
sortable: true,
filterable: true,
searchable: true,
formatter: (value: string | undefined) => (
<span className="userEmail">
{value || t('users.noEmail', 'No Email')}
</span>
)
},
{
key: 'language',
label: t('users.column.language', 'Language'),
type: 'enum',
width: 120,
minWidth: 100,
maxWidth: 150,
sortable: true,
filterable: true,
filterOptions: ['en', 'de', 'fr'],
formatter: (value: string | undefined) => {
const languageMap: Record<string, string> = {
'en': t('language.english', 'English'),
'de': t('language.german', 'Deutsch'),
'fr': t('language.french', 'Français')
};
return (
<span className="userLanguage">
{value ? languageMap[value] || value : t('users.noLanguage', 'No Language')}
</span>
);
}
},
{
key: 'privilege',
label: t('users.column.privilege', 'Privilege'),
type: 'enum',
width: 120,
minWidth: 100,
maxWidth: 150,
sortable: true,
filterable: true,
filterOptions: ['viewer', 'user', 'admin', 'sysadmin'],
formatter: (value: string | undefined) => {
const privilegeMap: Record<string, string> = {
'viewer': t('users.privilege.viewer', 'Viewer'),
'user': t('users.privilege.user', 'User'),
'admin': t('users.privilege.admin', 'Admin'),
'sysadmin': t('users.privilege.sysadmin', 'Sysadmin')
};
return (
<span className="userPrivilege">
{value ? privilegeMap[value] || value : t('users.noPrivilege', 'No Privilege')}
</span>
);
}
},
{
key: 'enabled',
label: t('users.column.enabled', 'Enabled'),
type: 'boolean',
width: 100,
minWidth: 80,
maxWidth: 120,
sortable: true,
filterable: true,
formatter: (value: boolean | undefined) => (
<span className={`userEnabled ${value ? 'enabled' : 'disabled'}`}>
{value ? t('users.enabled.yes', 'Yes') : t('users.enabled.no', 'No')}
</span>
)
},
{
key: 'authenticationAuthority',
label: t('users.column.authAuthority', 'Auth Authority'),
type: 'enum',
width: 150,
minWidth: 120,
maxWidth: 200,
sortable: true,
filterable: true,
filterOptions: ['local', 'msft'],
formatter: (value: string | undefined) => {
const authMap: Record<string, string> = {
'local': t('users.auth.local', 'Local'),
'msft': t('users.auth.msft', 'Microsoft')
};
return (
<span className="userAuthAuthority">
{value ? authMap[value] || value : t('users.noAuthAuthority', 'No Auth Authority')}
</span>
);
}
}
], [t]);
// Handle edit user
const handleEditUser = (user: any) => {
setEditingUser(user);
};
// Handle save user
const handleSaveUser = async (updatedUser: any) => {
try {
await updateUser(updatedUser.id, updatedUser);
setEditingUser(null);
} catch (error) {
console.error('Failed to update user:', error);
}
};
// Handle cancel edit
const handleCancelEdit = () => {
setEditingUser(null);
};
// Handle delete user
const handleDeleteUser = (user: any) => {
setDeletingUser(user);
};
// Handle confirm delete
const handleConfirmDelete = async () => {
if (deletingUser) {
try {
await deleteUser(deletingUser.id);
setDeletingUser(null);
} catch (error) {
console.error('Failed to delete user:', error);
}
}
};
// Handle cancel delete
const handleCancelDelete = () => {
setDeletingUser(null);
};
// Handle save new user
const handleSaveNewUser = async (userData: any) => {
try {
// Create user data with required fields
const newUserData = {
username: userData.username,
email: userData.email,
password: userData.password,
fullName: userData.fullName,
language: userData.language,
enabled: userData.enabled || false,
privilege: userData.privilege,
authenticationAuthority: 'local' // New users are always local
};
// Debug logging
console.log('Creating user with data:', newUserData);
console.log('Password field:', userData.password);
console.log('Password type:', typeof userData.password);
console.log('Password length:', userData.password?.length);
await createUser(newUserData);
} catch (error) {
console.error('Failed to create user:', error);
}
};
// Handle cancel add user
const handleCancelAddUser = () => {
// This will be handled by the parent component
};
// Configure action buttons
const actions: UserActionConfig[] = useMemo(() => [
{
type: 'edit',
title: t('users.actions.edit', 'Edit'),
onAction: (row: any) => handleEditUser(row)
},
{
type: 'delete',
title: t('users.actions.delete', 'Delete'),
onAction: (row: any) => handleDeleteUser(row)
}
] as any, [t]);
return {
// Data
users,
loading,
error,
// Refetch function
refetch,
// Additional data for rendering
columns,
actions,
// Edit functionality
editingUser,
setEditingUser,
handleSaveUser,
handleCancelEdit,
// Delete functionality
deletingUser,
setDeletingUser,
handleConfirmDelete,
handleCancelDelete,
// Add user functionality
handleSaveNewUser,
handleCancelAddUser
};
}

View file

@ -1,64 +0,0 @@
import React from 'react';
import { User } from '../../hooks/useUsers';
// Props for the MitgliederTable component
export interface MitgliederTableProps {
className?: string;
showAddUser?: boolean;
onAddUserClose?: () => void;
}
// Action configuration for user actions
export interface UserActionConfig {
type: 'edit' | 'delete' | 'download' | 'view' | 'copy';
title?: string | ((row: User) => string);
onAction?: (row: User) => Promise<void> | void;
disabled?: (row: User) => boolean | { disabled: boolean; message?: string };
loading?: (row: User) => boolean;
}
// Column configuration for the users table
export interface UserColumnConfig {
key: string;
label: string;
type: 'string' | 'number' | 'date' | 'boolean' | 'enum';
width: number;
minWidth: number;
maxWidth: number;
sortable: boolean;
filterable: boolean;
searchable?: boolean;
filterOptions?: string[];
formatter: (value: any, row?: any) => React.ReactElement | string;
}
// Return type for the mitglieder logic hook
export interface MitgliederLogicReturn {
// Data
users: User[];
loading: boolean;
error: string | null;
// Refetch function
refetch: () => Promise<void>;
// Additional data for rendering
columns: UserColumnConfig[];
actions: UserActionConfig[];
// Edit functionality
editingUser: User | null;
setEditingUser: (user: User | null) => void;
handleSaveUser: (updatedUser: User) => Promise<void>;
handleCancelEdit: () => void;
// Delete functionality
deletingUser: User | null;
setDeletingUser: (user: User | null) => void;
handleConfirmDelete: () => Promise<void>;
handleCancelDelete: () => void;
// Add user functionality
handleSaveNewUser: (userData: any) => Promise<void>;
handleCancelAddUser: () => void;
}

View file

@ -33,7 +33,14 @@ const SidebarItem: React.FC<SidebarItemProps> = React.memo(({
e.preventDefault();
return;
}
// Allow normal navigation for the main link
// If item has submenu, prevent navigation and only toggle submenu
if (hasSubItems) {
e.preventDefault();
e.stopPropagation();
onToggle();
return;
}
// Allow normal navigation for items without submenu
};
return (
@ -45,34 +52,40 @@ const SidebarItem: React.FC<SidebarItemProps> = React.memo(({
{/* Text and arrow - hidden when minimized */}
{!isMinimized && (
<>
<Link
to={isDisabled ? "#" : (item.link || "#")}
className={`${styles.menuTextLink} ${isDisabled ? styles.disabledLink : ''}`}
onClick={handleLinkClick}
aria-disabled={isDisabled}
title={isDisabled ? `${item.name} (Module disabled)` : item.name}
>
<span className={`${styles.menuText} ${isDisabled ? styles.disabledText : ''}`}>
{item.name}
</span>
</Link>
{/* Arrow button separate from link */}
{hasSubItems && (
{hasSubItems ? (
// For items with submenu, make the entire area clickable to toggle
<button
onClick={toggleSubmenu}
className={`${styles.arrowButton} ${isDisabled ? styles.disabledArrow : ''}`}
className={`${styles.menuTextButton} ${isDisabled ? styles.disabledLink : ''}`}
aria-disabled={isDisabled}
title={isDisabled ? `${item.name} (Module disabled)` : `Toggle ${item.name} submenu`}
>
<span className={`${styles.menuText} ${isDisabled ? styles.disabledText : ''}`}>
{item.name}
</span>
<IoIosArrowDown className={`${styles.hassubmenu} ${isOpen ? styles.rotated : ''} ${isDisabled ? styles.disabledArrow : ''}`} />
</button>
) : (
// For items without submenu, use normal link
<>
<Link
to={isDisabled ? "#" : (item.link || "#")}
className={`${styles.menuTextLink} ${isDisabled ? styles.disabledLink : ''}`}
onClick={handleLinkClick}
aria-disabled={isDisabled}
title={isDisabled ? `${item.name} (Module disabled)` : item.name}
>
<span className={`${styles.menuText} ${isDisabled ? styles.disabledText : ''}`}>
{item.name}
</span>
</Link>
</>
)}
</>
)}
{isMinimized && !isDisabled && (
{isMinimized && !isDisabled && !hasSubItems && (
<Link
to={item.link || "#"}
className={styles.minimizedOverlay}

View file

@ -8,14 +8,18 @@
background-repeat: no-repeat;
box-shadow: 0px 2px 6px 0px rgba(194, 194, 194, 0.10);*/
width: 240px;
padding-bottom: 1px;
padding: 0;
display: flex;
justify-content: flex-start;
align-items: stretch;
flex-direction: column;
height: 100vh;
max-height: 100vh;
overflow: hidden;
transition: width 0.3s ease-in-out;
position: relative;
z-index: 1;
box-sizing: border-box;
border-right: 1px solid var(--color-primary);
}
@ -25,6 +29,7 @@
flex-direction: column;
align-items: flex-start;
flex: 1;
min-height: 0;
font-family: var(--font-family);
overflow-y: auto;
overflow-x: hidden; /* Disable horizontal scrolling */

View file

@ -56,6 +56,35 @@
line-height: normal;
}
.menuTextButton {
flex: 1;
text-decoration: none;
color: inherit;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 8px 0 0;
margin: 0;
background: none;
border: none;
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
gap: 5px;
font-family: var(--font-family);
font-size: 0.9rem;
font-style: normal;
font-weight: 500;
line-height: normal;
width: 100%;
}
.menu li:hover .menuTextButton {
color: white;
}
.arrowButton {
background: none;
border: none;

View file

@ -1,10 +1,8 @@
.submenu {
position: relative;
background: var(--color-primary);
border: none;
border-top-right-radius: 25px;
border-bottom-right-radius: 25px;
z-index: 1000;
margin: 0;
overflow: hidden;
width: 220px;

View file

@ -1,14 +1,20 @@
.user_section {
display: flex;
width: 240px;
width: 100%;
flex-direction: column;
align-items: left;
font-family: var(--font-family);
box-sizing: border-box;
position: relative;
margin-top: auto; /* Push to bottom */
margin-bottom: 7px;
margin-bottom: 0;
padding-left: 5px;
padding-bottom: 7px;
padding-right: 5px;
flex-shrink: 0;
max-height: fit-content;
overflow: visible;
contain: layout;
}
.user_info {
@ -105,7 +111,7 @@
border: none;
border-top-right-radius: 25px;
border-bottom-right-radius: 25px;
z-index: 1000;
z-index: 10;
margin-bottom: 8px;
overflow: hidden;
}
@ -278,7 +284,7 @@
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease;
z-index: 1001;
z-index: 11;
}
.logout_popup_minimized .logout_menu_button:hover::after {

View file

@ -0,0 +1,91 @@
.autoScrollContainer {
position: relative;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-height: 0;
}
.scrollableContent {
flex: 1;
overflow-y: auto;
min-height: 0;
width: 100%;
}
.scrollToBottomButton {
position: absolute;
top: 16px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
padding: 0;
background-color: var(--color-secondary);
color: white;
border: none;
border-radius: 50%;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
transition: all 0.2s ease;
animation: slideDown 0.3s ease;
}
.scrollToBottomButton:hover {
background-color: var(--color-secondary);
opacity: 0.9;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
transform: translateX(-50%) translateY(-2px);
}
.scrollToBottomButton:active {
transform: translateX(-50%) translateY(0);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
}
.scrollArrow {
font-size: 20px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateX(-50%) translateY(-10px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
/* Dark theme support */
[data-theme="dark"] .scrollToBottomButton {
background-color: var(--color-secondary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
[data-theme="dark"] .scrollToBottomButton:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
/* Responsive design */
@media (max-width: 640px) {
.scrollToBottomButton {
width: 36px;
height: 36px;
}
.scrollArrow {
font-size: 18px;
}
}

View file

@ -0,0 +1,168 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import styles from './AutoScroll.module.css';
export interface AutoScrollProps {
/**
* Children to render inside the scrollable container
*/
children: React.ReactNode;
/**
* Optional className for the container
*/
className?: string;
/**
* Dependency array to watch for changes that should trigger auto-scroll
* Typically the length of the items array or a unique identifier
*/
scrollDependency?: any;
/**
* Threshold in pixels from bottom to consider user "at bottom"
* @default 100
*/
threshold?: number;
}
/**
* AutoScroll component that automatically scrolls to the bottom when new content is added,
* unless the user has scrolled up. Shows a button when user has scrolled up.
*/
const AutoScroll: React.FC<AutoScrollProps> = ({
children,
className = '',
scrollDependency,
threshold = 100
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const [showNewMessageButton, setShowNewMessageButton] = useState(false);
const [isUserScrolling, setIsUserScrolling] = useState(false);
const scrollTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastScrollDependencyRef = useRef<any>(scrollDependency);
const isScrollingProgrammaticallyRef = useRef(false);
// Check if user is near the bottom of the scroll container
const isNearBottom = useCallback((): boolean => {
const container = containerRef.current;
if (!container) return true;
const { scrollTop, scrollHeight, clientHeight } = container;
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
return distanceFromBottom <= threshold;
}, [threshold]);
// Scroll to bottom
const scrollToBottom = useCallback((smooth: boolean = true) => {
const container = containerRef.current;
if (!container) return;
isScrollingProgrammaticallyRef.current = true;
container.scrollTo({
top: container.scrollHeight,
behavior: smooth ? 'smooth' : 'auto'
});
// Reset flag and update button visibility after scroll completes
setTimeout(() => {
isScrollingProgrammaticallyRef.current = false;
setShowNewMessageButton(!isNearBottom());
}, smooth ? 500 : 100);
}, [isNearBottom]);
// Handle scroll events
const handleScroll = useCallback(() => {
// Ignore programmatic scrolling
if (isScrollingProgrammaticallyRef.current) {
return;
}
// Clear existing timeout
if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current);
}
// Mark that user is scrolling
setIsUserScrolling(true);
// Check if user is near bottom
const nearBottom = isNearBottom();
// Always show button when scrolled up
setShowNewMessageButton(!nearBottom);
// Reset user scrolling flag after a delay
scrollTimeoutRef.current = setTimeout(() => {
setIsUserScrolling(false);
}, 150);
}, [isNearBottom]);
// Auto-scroll when content changes
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const hasNewContent = scrollDependency !== lastScrollDependencyRef.current;
lastScrollDependencyRef.current = scrollDependency;
// Only auto-scroll if:
// 1. There's new content
// 2. User is not currently scrolling
// 3. User is near the bottom (or was near bottom before new content)
if (hasNewContent && !isUserScrolling) {
if (isNearBottom()) {
// User is at bottom, scroll to show new content
scrollToBottom(true);
setShowNewMessageButton(false);
} else {
// User has scrolled up, show button
setShowNewMessageButton(true);
}
} else if (!isUserScrolling) {
// Check scroll position even if no new content (to update button visibility)
setShowNewMessageButton(!isNearBottom());
}
}, [scrollDependency, isUserScrolling, isNearBottom, scrollToBottom]);
// Handle new message button click
const handleNewMessageClick = useCallback(() => {
scrollToBottom(true);
setShowNewMessageButton(false);
}, [scrollToBottom]);
// Initial scroll to bottom on mount and check scroll position
useEffect(() => {
if (containerRef.current) {
scrollToBottom(false);
// Check scroll position after a brief delay to ensure DOM is ready
setTimeout(() => {
setShowNewMessageButton(!isNearBottom());
}, 100);
}
}, [scrollToBottom, isNearBottom]);
return (
<div className={`${styles.autoScrollContainer} ${className}`}>
{showNewMessageButton && (
<button
className={styles.scrollToBottomButton}
onClick={handleNewMessageClick}
aria-label="Scroll to bottom"
>
<span className={styles.scrollArrow}></span>
</button>
)}
<div
ref={containerRef}
className={styles.scrollableContent}
onScroll={handleScroll}
>
{children}
</div>
</div>
);
};
export default AutoScroll;

View file

@ -0,0 +1,3 @@
export { default as AutoScroll } from './AutoScroll';
export type { AutoScrollProps } from './AutoScroll';

View file

@ -30,13 +30,17 @@ export interface UploadButtonProps extends BaseButtonProps {
export interface CreateButtonFieldConfig {
key: string;
label: string;
type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'readonly';
type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly';
required?: boolean;
placeholder?: string;
minRows?: number;
maxRows?: number;
validator?: (value: any) => string | null;
defaultValue?: any;
// Options for enum/select/multiselect fields - can be array of strings or array of {value, label} objects
options?: string[] | Array<{ value: string | number; label: string }>;
// Options reference for fetching from API (e.g., "user.role")
optionsReference?: string;
}
export interface CreateButtonProps extends BaseButtonProps {

View file

@ -1,7 +1,8 @@
import React, { useState } from 'react';
import React, { useState, useMemo } from 'react';
import { CreateButtonProps } from '../ButtonTypes';
import Button from '../Button';
import { Popup, EditForm } from '../../Popup';
import { Popup } from '../../Popup';
import { FormGeneratorForm, AttributeDefinition } from '../../../FormGenerator/FormGeneratorForm';
import { useLanguage } from '../../../../providers/language/LanguageContext';
const CreateButton: React.FC<CreateButtonProps> = ({
@ -24,25 +25,87 @@ const CreateButton: React.FC<CreateButtonProps> = ({
const { t } = useLanguage();
const [isCreating, setIsCreating] = useState(false);
const [isPopupOpen, setIsPopupOpen] = useState(false);
const [formData, setFormData] = useState<any>({});
// Convert CreateButtonFieldConfig to AttributeDefinition format
const attributes: AttributeDefinition[] = useMemo(() => {
return fields.map(field => {
// Convert options to AttributeOption[] format
let options: AttributeDefinition['options'] = undefined;
if (field.options) {
// If options is an array of strings, convert to AttributeOption format
if (Array.isArray(field.options)) {
options = field.options.map(opt => {
if (typeof opt === 'string') {
return { value: opt, label: opt };
}
// Already in {value, label} format
return opt;
});
}
} else if (field.optionsReference) {
// Use optionsReference as string (will be fetched from API)
options = field.optionsReference;
}
// Map field types to FormGeneratorForm attribute types
let attributeType: AttributeDefinition['type'] = 'text';
if (field.type === 'boolean') {
attributeType = 'checkbox';
} else if (field.type === 'enum') {
attributeType = 'select';
} else if (field.type === 'multiselect') {
attributeType = 'multiselect';
} else if (field.type === 'email') {
attributeType = 'email';
} else if (field.type === 'date') {
attributeType = 'date';
} else if (field.type === 'textarea') {
attributeType = 'textarea';
} else if (field.type === 'readonly') {
attributeType = 'readonly';
} else if (field.type === 'string') {
// Check if it's a password field by key name
attributeType = field.key.toLowerCase().includes('password') ? 'password' : 'text';
}
return {
name: field.key,
label: typeof field.label === 'string' ? field.label : String(field.label),
type: attributeType,
required: field.required || false,
placeholder: field.placeholder,
default: field.defaultValue,
editable: true,
visible: true,
minRows: field.minRows,
maxRows: field.maxRows,
validation: field.validator,
options: options
};
});
}, [fields]);
// Initialize form data with default values
React.useEffect(() => {
const initialData: any = {};
const initialFormData = useMemo(() => {
const data: any = {};
fields.forEach(field => {
initialData[field.key] = field.defaultValue || '';
if (field.type === 'multiselect') {
// Multiselect fields should default to empty array
data[field.key] = field.defaultValue || [];
} else if (field.type === 'boolean') {
// Boolean fields should default to false
data[field.key] = field.defaultValue !== undefined ? field.defaultValue : false;
} else {
// Other fields default to empty string or provided default
data[field.key] = field.defaultValue !== undefined ? field.defaultValue : '';
}
});
setFormData(initialData);
return data;
}, [fields]);
const handleButtonClick = () => {
if (!disabled && !loading && !isCreating) {
// Reset form data
const initialData: any = {};
fields.forEach(field => {
initialData[field.key] = field.defaultValue || '';
});
setFormData(initialData);
setIsPopupOpen(true);
}
};
@ -86,15 +149,16 @@ const CreateButton: React.FC<CreateButtonProps> = ({
? t(popupTitle, popupTitle)
: popupTitle;
// Resolve language text for fields
const resolvedFields = fields.map(field => ({
...field,
label: typeof field.label === 'string' ? t(field.label, field.label) : field.label,
placeholder: field.placeholder
? (typeof field.placeholder === 'string' ? t(field.placeholder, field.placeholder) : field.placeholder)
: undefined,
editable: true
}));
// Resolve language text for attributes
const resolvedAttributes: AttributeDefinition[] = useMemo(() => {
return attributes.map(attr => ({
...attr,
label: typeof attr.label === 'string' ? t(attr.label, attr.label) : attr.label,
placeholder: attr.placeholder
? (typeof attr.placeholder === 'string' ? t(attr.placeholder, attr.placeholder) : attr.placeholder)
: undefined
}));
}, [attributes, t]);
return (
<>
@ -123,12 +187,13 @@ const CreateButton: React.FC<CreateButtonProps> = ({
size={popupSize}
closable={!isCreating}
>
<EditForm
data={formData}
fields={resolvedFields}
onSave={handleSave}
<FormGeneratorForm
attributes={resolvedAttributes}
data={initialFormData}
mode="create"
onSubmit={handleSave}
onCancel={handleCancel}
saveButtonText={t('common.create', 'Create')}
submitButtonText={t('common.create', 'Create')}
cancelButtonText={t('common.cancel', 'Cancel')}
/>
</Popup>

View file

@ -1,14 +1,52 @@
import React, { useMemo } from 'react';
import { ViewActionButton, DeleteActionButton, RemoveActionButton } from '../../FormGenerator/ActionButtons';
import {
ViewActionButton,
DeleteActionButton,
RemoveActionButton,
EditActionButton,
DownloadActionButton,
CopyActionButton,
ConnectActionButton,
PlayActionButton
} from '../../FormGenerator/ActionButtons';
import { WorkflowFile } from '../../../hooks/usePlayground';
import styles from './ConnectedFilesList.module.css';
export interface ConnectedFilesListActionButton {
type: 'edit' | 'delete' | 'download' | 'view' | 'copy' | 'connect' | 'play' | 'remove';
onAction?: (file: WorkflowFile) => Promise<void> | void;
disabled?: (file: WorkflowFile) => boolean | { disabled: boolean; message?: string };
loading?: (file: WorkflowFile) => boolean;
title?: string | ((file: WorkflowFile) => string);
className?: string;
// For download and view buttons
isProcessing?: (file: WorkflowFile) => boolean;
// Field mappings for flexible data access
idField?: string; // Field name for the unique identifier (default: 'fileId')
nameField?: string; // Field name for display name (default: 'fileName')
typeField?: string; // Field name for type/mime type (default: 'mimeType')
contentField?: string; // Field name for content (used by copy button)
statusField?: string; // Field name for status (used by connect button)
authorityField?: string; // Field name for authority (msft/google) (used by connect button)
// Operation and loading state names
operationName?: string; // Name of the operation function in hookData
refreshOperationName?: string; // Name of the refresh operation function in hookData (for connect button)
loadingStateName?: string; // Name of the loading state in hookData
// Navigation (for play button)
navigateTo?: string; // Route to navigate to when play button is clicked
// Special handling for remove button
showOnlyForPending?: boolean; // Show remove button only for pending files (default: true for 'remove' type)
}
export interface ConnectedFilesListProps {
files: WorkflowFile[];
pendingFiles?: WorkflowFile[];
onDelete: (file: WorkflowFile) => Promise<void>;
// New: Configurable action buttons (takes precedence over legacy props)
actionButtons?: ConnectedFilesListActionButton[];
// Legacy props (kept for backward compatibility, used as defaults if actionButtons not provided)
onDelete?: (file: WorkflowFile) => Promise<void>;
onRemove?: (file: WorkflowFile) => Promise<void>;
onAttach?: (fileId: string) => Promise<void>; // New: attach file for next message
onAttach?: (fileId: string) => Promise<void>; // Attach file for next message
deletingFiles?: Set<string>;
previewingFiles?: Set<string>;
removingFiles?: Set<string>;
@ -19,6 +57,7 @@ export interface ConnectedFilesListProps {
export function ConnectedFilesList({
files,
pendingFiles = [],
actionButtons,
onDelete,
onRemove,
onAttach,
@ -53,7 +92,7 @@ export function ConnectedFilesList({
const hookData = useMemo(() => ({
handleDelete: async (fileId: string) => {
const file = allFiles.find(f => f.fileId === fileId);
if (file) {
if (file && onDelete) {
await onDelete(file);
return true;
}
@ -66,8 +105,56 @@ export function ConnectedFilesList({
// Refetch handled by parent
},
deletingItems: deletingFiles,
previewingFiles: previewingFiles
}), [allFiles, onDelete, deletingFiles, previewingFiles]);
previewingFiles: previewingFiles,
removingItems: removingFiles
}), [allFiles, onDelete, deletingFiles, previewingFiles, removingFiles]);
// Generate default action buttons from legacy props if actionButtons not provided
const defaultActionButtons = useMemo<ConnectedFilesListActionButton[]>(() => {
if (actionButtons) {
return actionButtons;
}
// Legacy behavior: create default buttons from old props
const buttons: ConnectedFilesListActionButton[] = [];
// View button (always shown)
buttons.push({
type: 'view',
onAction: async (file: WorkflowFile) => {
// View is handled by ViewActionButton's FilePreview component
return Promise.resolve();
},
idField: 'fileId',
nameField: 'fileName',
typeField: 'mimeType'
});
// Remove button (only for pending files, if onRemove provided)
if (onRemove) {
buttons.push({
type: 'remove',
onAction: async (file: WorkflowFile) => {
await onRemove(file);
},
showOnlyForPending: true,
idField: 'fileId',
loadingStateName: 'removingItems'
});
}
// Delete button (always shown, if onDelete provided)
if (onDelete) {
buttons.push({
type: 'delete',
operationName: 'handleDelete',
loadingStateName: 'deletingItems',
idField: 'fileId'
});
}
return buttons;
}, [actionButtons, onDelete, onRemove]);
const handleView = async (file: WorkflowFile) => {
// View is handled by ViewActionButton's FilePreview component
@ -169,34 +256,99 @@ export function ConnectedFilesList({
</div>
</div>
<div className={styles.fileActions} onClick={(e) => e.stopPropagation()}>
<ViewActionButton
row={file}
onView={handleView}
disabled={isDeleting || isRemoving}
loading={isPreviewing}
hookData={hookData}
idField="fileId"
nameField="fileName"
typeField="mimeType"
/>
{isPendingFile && onRemove && (
<RemoveActionButton
row={file}
onRemove={handleRemove}
disabled={isDeleting}
loading={isRemoving}
hookData={hookData}
idField="fileId"
loadingStateName="removingItems"
/>
)}
<DeleteActionButton
row={file}
hookData={hookData}
idField="fileId"
operationName="handleDelete"
loadingStateName="deletingItems"
/>
{defaultActionButtons.map((actionButton, actionIndex) => {
// Check if button should be shown for this file
if (actionButton.showOnlyForPending !== undefined) {
const shouldShow = actionButton.showOnlyForPending ? isPendingFile : !isPendingFile;
if (!shouldShow) {
return null;
}
}
const actionTitle = typeof actionButton.title === 'function'
? actionButton.title(file)
: actionButton.title;
const disabledResult = actionButton.disabled ? actionButton.disabled(file) : false;
const isLoading = actionButton.loading ? actionButton.loading(file) : false;
const isProcessing = actionButton.isProcessing ? actionButton.isProcessing(file) : false;
const baseProps = {
row: file,
disabled: disabledResult,
loading: isLoading,
className: actionButton.className,
title: actionTitle,
idField: actionButton.idField ?? 'fileId',
nameField: actionButton.nameField ?? 'fileName',
typeField: actionButton.typeField ?? 'mimeType',
contentField: actionButton.contentField ?? 'content',
statusField: actionButton.statusField ?? 'status',
authorityField: actionButton.authorityField ?? 'authority',
operationName: actionButton.operationName,
refreshOperationName: actionButton.refreshOperationName,
loadingStateName: actionButton.loadingStateName,
hookData: hookData
};
switch (actionButton.type) {
case 'edit':
return <EditActionButton
key={actionIndex}
{...baseProps}
onEdit={actionButton.onAction}
/>;
case 'delete':
return <DeleteActionButton
key={actionIndex}
{...baseProps}
/>;
case 'download':
return <DownloadActionButton
key={actionIndex}
{...baseProps}
onDownload={actionButton.onAction || (() => {})}
isDownloading={isProcessing}
operationName={actionButton.operationName}
/>;
case 'view':
return <ViewActionButton
key={actionIndex}
{...baseProps}
onView={actionButton.onAction || handleView}
isViewing={isProcessing}
/>;
case 'copy':
return <CopyActionButton
key={actionIndex}
{...baseProps}
onCopy={actionButton.onAction}
isCopying={isProcessing}
contentField={actionButton.contentField}
/>;
case 'connect':
return <ConnectActionButton
key={actionIndex}
{...baseProps}
/>;
case 'play':
return <PlayActionButton
key={actionIndex}
{...baseProps}
onPlay={actionButton.onAction}
navigateTo={actionButton.navigateTo}
contentField={actionButton.contentField}
mode={(actionButton as any).mode || 'prompt'}
/>;
case 'remove':
return <RemoveActionButton
key={actionIndex}
{...baseProps}
onRemove={actionButton.onAction || handleRemove}
/>;
default:
return null;
}
})}
</div>
</div>
);

View file

@ -1,5 +1,5 @@
export { default as ConnectedFilesList } from './ConnectedFilesList';
export type { ConnectedFilesListProps } from './ConnectedFilesList';
export type { ConnectedFilesListProps, ConnectedFilesListActionButton } from './ConnectedFilesList';

View file

@ -0,0 +1,34 @@
/* Copyable truncated value styles */
.copyableValue {
display: inline-flex;
align-items: center;
gap: 4px;
cursor: pointer;
user-select: none;
padding: 2px 4px;
border-radius: 4px;
transition: background-color 0.2s ease, color 0.2s ease;
font-family: 'Courier New', monospace;
font-size: 0.9em;
}
.copyableValue:hover {
color: var(--color-secondary);
}
.truncatedText {
display: inline-block;
}
.copiedIndicator {
color: var(--color-secondary);
font-weight: bold;
font-size: 0.9em;
animation: fadeInOut 0.3s ease;
}
@keyframes fadeInOut {
0%, 100% { opacity: 0; }
50% { opacity: 1; }
}

View file

@ -0,0 +1,70 @@
import React, { useState } from 'react';
import { useLanguage } from '../../../providers/language/LanguageContext';
import styles from './CopyableTruncatedValue.module.css';
export interface CopyableTruncatedValueProps {
value: string;
maxLength?: number;
className?: string;
onClick?: (e: React.MouseEvent) => void;
}
export function CopyableTruncatedValue({
value,
maxLength = 20,
className = '',
onClick
}: CopyableTruncatedValueProps) {
const { t } = useLanguage();
const [copied, setCopied] = useState(false);
const [showFull, setShowFull] = useState(false);
// Determine if value should be truncated
const shouldTruncate = value.length > maxLength;
const displayValue = shouldTruncate && !showFull
? `${value.substring(0, maxLength)}...`
: value;
const handleClick = async (e: React.MouseEvent) => {
e.stopPropagation(); // Prevent row click or other parent handlers
// Call custom onClick handler if provided
if (onClick) {
onClick(e);
}
try {
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (error) {
console.error('Failed to copy to clipboard:', error);
}
};
const handleDoubleClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (shouldTruncate) {
setShowFull(!showFull);
}
};
const tooltipText = copied
? t('common.copied', 'Copied!')
: `${t('common.copy', 'copy')}: ${value}`;
return (
<span
className={`${styles.copyableValue} ${className}`}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
title={tooltipText}
>
<span className={styles.truncatedText}>{displayValue}</span>
{copied && <span className={styles.copiedIndicator}></span>}
</span>
);
}
export default CopyableTruncatedValue;

View file

@ -0,0 +1,4 @@
export { default as CopyableTruncatedValue } from './CopyableTruncatedValue';
export { default } from './CopyableTruncatedValue';
export type { CopyableTruncatedValueProps } from './CopyableTruncatedValue';

View file

@ -1,95 +0,0 @@
import React from 'react';
import { DropdownSelect, DropdownSelectItem } from '../../DropdownSelect';
import { ButtonSize } from '../../Button/ButtonTypes';
export interface SelectFieldOption {
id: string | number;
label: string;
value: any;
}
export interface SelectFieldProps {
value?: string | number | null;
onChange?: (value: any) => void;
label?: string;
options: SelectFieldOption[];
placeholder?: string;
required?: boolean;
disabled?: boolean;
description?: string;
className?: string;
size?: ButtonSize;
}
const SelectField: React.FC<SelectFieldProps> = ({
value,
onChange,
label,
options,
placeholder = 'Select an option',
required = false,
disabled = false,
description,
className = '',
size = 'md'
}) => {
// Convert options to DropdownSelectItem format
const items: DropdownSelectItem[] = options.map(opt => ({
id: opt.id,
label: opt.label,
value: opt.value
}));
// Find selected item ID from value
const selectedItemId = value !== null && value !== undefined
? options.find(opt => opt.value === value || opt.id === value)?.id ?? null
: null;
const handleSelect = (item: DropdownSelectItem | null) => {
if (onChange) {
onChange(item ? item.value : null);
}
};
return (
<div className={className}>
{label && (
<div style={{ marginBottom: '8px' }}>
<label style={{
fontSize: '0.875rem',
fontWeight: 500,
color: 'var(--color-text)',
fontFamily: 'var(--font-family)'
}}>
{label}
{required && <span style={{ color: 'var(--color-error)', marginLeft: '4px' }}>*</span>}
</label>
{description && (
<div style={{
fontSize: '0.75rem',
color: 'var(--color-primary)',
marginTop: '4px',
fontStyle: 'italic'
}}>
{description}
</div>
)}
</div>
)}
<DropdownSelect
items={items}
selectedItemId={selectedItemId}
onSelect={handleSelect}
placeholder={placeholder}
disabled={disabled}
size={size}
variant="primary"
showClearButton={false}
minWidth="100%"
/>
</div>
);
};
export default SelectField;

View file

@ -1,3 +0,0 @@
export { default } from './SelectField';
export type { SelectFieldProps, SelectFieldOption } from './SelectField';

View file

@ -1,76 +0,0 @@
import React from 'react';
import TextField from '../../TextField';
import { TextFieldSize } from '../../TextField/TextFieldTypes';
export interface TextInputFieldProps {
value?: string;
onChange?: (value: string) => void;
label?: string;
placeholder?: string;
required?: boolean;
disabled?: boolean;
readonly?: boolean;
type?: 'text' | 'email' | 'tel';
description?: string;
error?: string;
className?: string;
size?: TextFieldSize;
}
const TextInputField: React.FC<TextInputFieldProps> = ({
value = '',
onChange,
label,
placeholder,
required = false,
disabled = false,
readonly = false,
type = 'text',
description,
error,
className = '',
size = 'md'
}) => {
return (
<div className={className}>
{label && (
<div style={{ marginBottom: '8px' }}>
<label style={{
fontSize: '0.875rem',
fontWeight: 500,
color: 'var(--color-text)',
fontFamily: 'var(--font-family)'
}}>
{label}
{required && <span style={{ color: 'var(--color-error)', marginLeft: '4px' }}>*</span>}
</label>
{description && (
<div style={{
fontSize: '0.75rem',
color: 'var(--color-primary)',
marginTop: '4px',
fontStyle: 'italic'
}}>
{description}
</div>
)}
</div>
)}
<TextField
value={value}
onChange={onChange}
placeholder={placeholder}
required={required}
disabled={disabled}
readonly={readonly}
type={type}
error={error}
label={undefined} // Don't use TextField's label since we render it above
size={size}
/>
</div>
);
};
export default TextInputField;

View file

@ -1,3 +0,0 @@
export { default } from './TextInputField';
export type { TextInputFieldProps } from './TextInputField';

View file

@ -1,111 +0,0 @@
import React from 'react';
export interface ToggleFieldProps {
value?: boolean;
onChange?: (value: boolean) => void;
label?: string;
description?: string;
disabled?: boolean;
className?: string;
}
const ToggleField: React.FC<ToggleFieldProps> = ({
value = false,
onChange,
label,
description,
disabled = false,
className = ''
}) => {
const handleToggle = () => {
if (!disabled && onChange) {
onChange(!value);
}
};
return (
<div className={className}>
{label && (
<div style={{ marginBottom: '8px' }}>
<label style={{
fontSize: '0.875rem',
fontWeight: 500,
color: 'var(--color-text)',
fontFamily: 'var(--font-family)'
}}>
{label}
</label>
{description && (
<div style={{
fontSize: '0.75rem',
color: 'var(--color-primary)',
marginTop: '4px',
fontStyle: 'italic'
}}>
{description}
</div>
)}
</div>
)}
<button
type="button"
onClick={handleToggle}
disabled={disabled}
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '12px 16px',
borderRadius: '25px',
border: `1px solid ${value ? 'var(--color-secondary)' : 'var(--color-primary)'}`,
background: value ? 'var(--color-secondary)' : 'var(--color-bg)',
color: value ? 'white' : 'var(--color-text)',
cursor: disabled ? 'not-allowed' : 'pointer',
transition: 'all 0.3s ease',
fontFamily: 'var(--font-family)',
fontSize: '0.875rem',
fontWeight: 500,
opacity: disabled ? 0.6 : 1,
outline: 'none'
}}
onMouseEnter={(e) => {
if (!disabled) {
e.currentTarget.style.boxShadow = '0 4px 12px rgba(63, 81, 181, 0.2)';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.boxShadow = 'none';
}}
>
<div
style={{
width: '44px',
height: '24px',
borderRadius: '12px',
background: value ? 'rgba(255, 255, 255, 0.3)' : 'var(--color-gray-light)',
position: 'relative',
transition: 'all 0.3s ease'
}}
>
<div
style={{
width: '20px',
height: '20px',
borderRadius: '50%',
background: 'white',
position: 'absolute',
top: '2px',
left: value ? '22px' : '2px',
transition: 'left 0.3s ease',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.2)'
}}
/>
</div>
<span>{value ? 'Enabled' : 'Disabled'}</span>
</button>
</div>
);
};
export default ToggleField;

View file

@ -1,3 +0,0 @@
export { default } from './ToggleField';
export type { ToggleFieldProps } from './ToggleField';

View file

@ -1,7 +0,0 @@
export { default as TextInputField } from './TextInputField';
export { default as SelectField } from './SelectField';
export { default as ToggleField } from './ToggleField';
export type { TextInputFieldProps } from './TextInputField';
export type { SelectFieldProps, SelectFieldOption } from './SelectField';
export type { ToggleFieldProps } from './ToggleField';

View file

@ -0,0 +1,102 @@
.logContainer {
display: flex;
flex-direction: column;
width: 100%;
font-family: var(--font-family);
flex: 1;
min-height: 0;
overflow: hidden;
}
.scrollableContent {
padding: 16px 20px;
display: flex;
flex-direction: column;
gap: 16px;
}
.emptyState {
padding: 40px 20px;
text-align: center;
color: var(--color-gray);
font-size: 14px;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
/* Round Group */
.roundGroup {
display: flex;
flex-direction: column;
gap: 12px;
}
.roundHeader {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 16px;
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--object-radius-small);
transition: background-color 0.2s ease, border-color 0.2s ease;
}
.roundHeader.clickable {
cursor: pointer;
user-select: none;
}
.roundHeader.clickable:hover {
background-color: var(--color-highlight-gray);
border-color: var(--color-primary);
}
.roundHeaderLabel {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 13px;
font-weight: 600;
color: var(--color-text);
}
.collapseIcon {
display: inline-block;
font-size: 10px;
color: var(--color-gray);
transition: transform 0.2s ease;
}
.collapseIcon.collapsed {
transform: rotate(-90deg);
}
.roundLogs {
display: flex;
flex-direction: column;
gap: 8px;
padding-left: 16px;
}
/* Dark theme support */
[data-theme="dark"] .roundHeader {
background-color: var(--color-surface-dark);
border-color: var(--color-border-dark);
}
[data-theme="dark"] .roundHeader.clickable:hover {
background-color: rgba(255, 255, 255, 0.05);
border-color: var(--color-primary);
}
[data-theme="dark"] .roundHeaderLabel {
color: var(--color-text-dark);
}
[data-theme="dark"] .collapseIcon {
color: var(--color-gray-dark);
}

View file

@ -0,0 +1,188 @@
import React, { useMemo, useState, useEffect } from 'react';
import { LogProps, RoundGroup } from './LogTypes';
import { formatUnixTimestamp } from '../../../utils/time';
import { AutoScroll } from '../AutoScroll';
import { LogMessage } from './LogMessage/LogMessage';
import styles from './Log.module.css';
// Helper function to group logs by round
const groupLogsByRound = (logs: any[]): RoundGroup[] => {
const roundMap = new Map<number, RoundGroup>();
let currentRound = 1; // Track current round
// Sort logs chronologically first
const sortedLogs = [...logs].sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
sortedLogs.forEach((log) => {
const message = (log.message || '').toLowerCase();
// Check if this is a workflow status message that indicates a round change
if (message.includes('workflow started') || message.includes('workflow resumed')) {
const roundMatch = message.match(/\(?round\s+(\d+)\)?/i);
if (roundMatch) {
currentRound = parseInt(roundMatch[1], 10);
} else if (message.includes('workflow started')) {
// If started without round number, assume round 1
currentRound = 1;
}
// If resumed without round number, keep current round
}
// Assign log to current round
const roundNumber = currentRound;
if (!roundMap.has(roundNumber)) {
roundMap.set(roundNumber, {
round: roundNumber,
logs: [],
latestProgress: undefined,
latestTimestamp: 0
});
}
const roundGroup = roundMap.get(roundNumber)!;
roundGroup.logs.push(log);
// Update latest progress and timestamp
if (log.progress !== undefined && log.progress !== null) {
if (roundGroup.latestProgress === undefined || log.progress > roundGroup.latestProgress) {
roundGroup.latestProgress = log.progress;
}
}
if ((log.timestamp || 0) > roundGroup.latestTimestamp) {
roundGroup.latestTimestamp = log.timestamp || 0;
}
});
// Sort rounds and logs within each round
return Array.from(roundMap.values())
.sort((a, b) => a.round - b.round)
.map(roundGroup => ({
...roundGroup,
logs: roundGroup.logs.sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0))
}));
};
const Log: React.FC<LogProps> = ({
className = '',
emptyMessage = 'No log information available',
logs = []
}) => {
// Group logs by round
const roundGroups = useMemo(() => groupLogsByRound(logs), [logs]);
// Get the latest round number
const latestRound = roundGroups.length > 0 ? roundGroups[roundGroups.length - 1].round : null;
// State to track collapsed rounds (round number -> isCollapsed)
const [collapsedRounds, setCollapsedRounds] = useState<Set<number>>(new Set());
// Initialize collapsed state: collapse all rounds except the latest one
useEffect(() => {
if (roundGroups.length > 0 && latestRound !== null) {
setCollapsedRounds(prev => {
const newSet = new Set(prev);
// Ensure latest round is not collapsed
newSet.delete(latestRound);
// Collapse all other rounds that aren't already in the set
roundGroups.forEach(rg => {
if (rg.round !== latestRound && !newSet.has(rg.round)) {
newSet.add(rg.round);
}
});
return newSet;
});
}
}, [roundGroups.length, latestRound]); // Only update when rounds change, not on every log update
// Toggle collapse state for a round
const toggleRoundCollapse = (round: number) => {
setCollapsedRounds(prev => {
const newSet = new Set(prev);
if (newSet.has(round)) {
newSet.delete(round);
} else {
newSet.add(round);
}
return newSet;
});
};
if (logs.length === 0) {
return (
<div className={`${styles.logContainer} ${className}`}>
<div className={styles.emptyState}>{emptyMessage}</div>
</div>
);
}
return (
<div className={`${styles.logContainer} ${className}`}>
{/* Scrollable Content Section - All Rounds in Chronological Order */}
<AutoScroll
scrollDependency={logs.length}
>
<div className={styles.scrollableContent}>
{/* All Round Groups - In Chronological Order (Oldest First, Latest Last) */}
{roundGroups.map((roundGroup) => {
const isCollapsed = collapsedRounds.has(roundGroup.round);
return (
<div key={`round-${roundGroup.round}`} className={styles.roundGroup}>
{/* Round Header - Clickable */}
{roundGroup.logs.length > 0 && (
<div
className={`${styles.roundHeader} ${styles.clickable}`}
onClick={() => toggleRoundCollapse(roundGroup.round)}
>
<div className={styles.roundHeaderLabel}>
<span>Round {roundGroup.round} Logs</span>
<span className={`${styles.collapseIcon} ${isCollapsed ? styles.collapsed : ''}`}>
</span>
</div>
</div>
)}
{/* Log Messages for this Round - Collapsible */}
{!isCollapsed && (
<div className={styles.roundLogs}>
{roundGroup.logs.map((log, index) => {
// Convert log to Message format for LogMessage component
const message = {
id: log.id || `log-${index}`,
workflowId: log.workflowId || '',
message: log.message || '',
status: log.status,
timestamp: log.timestamp,
publishedAt: log.timestamp,
sequenceNr: index,
role: 'system',
documents: undefined,
summary: undefined
};
return (
<LogMessage
key={message.id}
message={message}
showDocuments={false}
showMetadata={true}
showProgress={false}
/>
);
})}
</div>
)}
</div>
);
})}
</div>
</AutoScroll>
</div>
);
};
export default Log;

View file

@ -1,8 +1,8 @@
import React from 'react';
import { Message } from '../MessagesTypes';
import { DocumentItem, MessageMetadata, ActionInfo } from '../MessageParts';
import { Message } from '../../Messages/MessagesTypes';
import { DocumentItem, MessageMetadata, ActionInfo } from '../../Messages/MessageParts';
import { WorkflowFile } from '../../../../hooks/usePlayground';
import styles from '../Messages.module.css';
import styles from '../../Messages/Messages.module.css';
import logStyles from './LogMessage.module.css';
export interface LogMessageProps {

View file

@ -0,0 +1,3 @@
export { LogMessage } from './LogMessage';
export type { LogMessageProps } from './LogMessage';

View file

@ -0,0 +1,49 @@
import type React from 'react';
/**
* Log entry from workflow
*/
export interface WorkflowLog {
id: string;
workflowId: string;
message: string;
type?: string;
timestamp: number;
status?: string;
progress?: number;
performance?: any;
parentId?: string | null;
operationId?: string | null;
}
/**
* Round group containing logs and progress
*/
export interface RoundGroup {
round: number;
logs: WorkflowLog[];
latestProgress: number | undefined;
latestTimestamp: number;
}
/**
* Props for the Log component
*/
export interface LogProps {
/**
* Optional className for custom styling
*/
className?: string;
/**
* Empty state message when no logs are available
* @default "No log information available"
*/
emptyMessage?: string;
/**
* Array of log entries to display
*/
logs?: WorkflowLog[];
}

View file

@ -0,0 +1,5 @@
export { default as Log } from './Log';
export { default } from './Log';
export * from './LogTypes';
export * from './LogMessage';

View file

@ -4,11 +4,13 @@
gap: 12px;
width: 100%;
font-family: var(--font-family);
padding: 16px 20px;
}
.emptyContainer {
flex: 1;
overflow-y: auto;
min-height: 0;
padding: 16px 20px;
background-color: var(--color-surface);
}
.emptyState {
@ -44,7 +46,7 @@
display: flex;
flex-direction: column;
gap: 8px;
max-width: 75%;
max-width: 65%;
padding: 12px 16px;
border-radius: 18px;
word-wrap: break-word;
@ -60,7 +62,6 @@
background-color: var(--color-surface);
color: var(--color-text);
border-bottom-left-radius: 4px;
border: 1px solid var(--color-primary);
}
/* Message Metadata */

View file

@ -1,32 +1,11 @@
import React from 'react';
import { MessagesProps } from './MessagesTypes';
import { ChatMessage } from './ChatMessages/ChatMessage';
import { LogMessage } from './LogMessages/LogMessage';
import { LogMessage } from '../Log/LogMessage/LogMessage';
import { AutoScroll } from '../AutoScroll';
import styles from './Messages.module.css';
/**
* Generic Messages component for displaying workflow messages
* Supports two variants: 'chat' (bubble UI) and 'log' (list/table UI)
*
* @example
* ```tsx
* // Chat style (default)
* <Messages
* messages={workflowMessages}
* variant="chat"
* showDocuments={true}
* showMetadata={true}
* />
*
* // Log style
* <Messages
* messages={workflowMessages}
* variant="log"
* showDocuments={true}
* showMetadata={true}
* />
* ```
*/
const Messages: React.FC<MessagesProps> = ({
messages,
className = '',
@ -45,18 +24,21 @@ const Messages: React.FC<MessagesProps> = ({
removingFiles,
workflowId
}) => {
// Handle empty state
if (!messages || messages.length === 0) {
return (
<div className={`${styles.messagesContainer} ${className}`}>
<div className={`${styles.messagesContainer} ${styles.emptyContainer} ${className}`}>
<div className={styles.emptyState}>{emptyMessage}</div>
</div>
);
}
return (
<div className={`${styles.messagesContainer} ${className}`}>
{messages.map((message, index) => {
<AutoScroll
className={className}
scrollDependency={messages.length}
>
<div className={styles.messagesContainer}>
{messages.map((message, index) => {
// Use custom render function if provided
if (renderMessage) {
return (
@ -104,7 +86,8 @@ const Messages: React.FC<MessagesProps> = ({
/>
);
})}
</div>
</div>
</AutoScroll>
);
};

View file

@ -1,9 +1,7 @@
export { default as Messages } from './Messages';
export { ChatMessage } from './ChatMessages/ChatMessage';
export { LogMessage } from './LogMessages/LogMessage';
export { DocumentItem, MessageMetadata, ActionInfo } from './MessageParts';
export * from './MessageUtils';
export type { MessagesProps, Message, MessageDocument, MessageVariant } from './MessagesTypes';
export type { ChatMessageProps } from './ChatMessages/ChatMessage';
export type { LogMessageProps } from './LogMessages/LogMessage';

View file

@ -1,312 +0,0 @@
import { useState, useEffect } from 'react';
import styles from './EditForm.module.css';
// Field configuration interface (moved from EditPopup)
export interface EditFieldConfig {
key: string;
label: string;
type: 'string' | 'email' | 'date' | 'enum' | 'boolean' | 'readonly' | 'textarea';
editable: boolean;
required?: boolean;
options?: string[]; // For enum types
formatter?: (value: any) => string; // For display formatting
validator?: (value: any) => string | null; // Returns error message or null
placeholder?: string;
minRows?: number; // For textarea types
maxRows?: number; // For textarea types
}
// EditForm props
export interface EditFormProps<T = any> {
data: T;
fields: EditFieldConfig[];
onSave: (updatedData: T) => void;
onCancel?: () => void;
saveButtonText?: string;
cancelButtonText?: string;
showButtons?: boolean;
className?: string;
}
// EditForm component - handles form logic
export function EditForm<T extends Record<string, any>>({
data,
fields,
onSave,
onCancel,
saveButtonText = 'Save',
cancelButtonText = 'Cancel',
showButtons = true,
className = ''
}: EditFormProps<T>) {
const [editedData, setEditedData] = useState<T>(data);
const [errors, setErrors] = useState<Record<string, string>>({});
const [fieldFocused, setFieldFocused] = useState<Record<string, boolean>>({});
// Reset data when data changes
useEffect(() => {
setEditedData({ ...data });
setErrors({});
setFieldFocused({});
// Initialize textarea heights for textarea fields
setTimeout(() => {
fields.forEach(field => {
if (field.type === 'textarea') {
const textarea = document.querySelector(`textarea[name="${field.key}"]`) as HTMLTextAreaElement;
if (textarea) {
const minRows = field.minRows || 4;
const maxRows = field.maxRows || 8;
textarea.style.height = 'auto';
const newHeight = Math.max(
minRows * 1.5 * 16,
Math.min(
textarea.scrollHeight,
maxRows * 1.5 * 16
)
);
textarea.style.height = `${newHeight}px`;
}
}
});
}, 0);
}, [data, fields]);
// Handle field focus
const handleFieldFocus = (fieldKey: string, focused: boolean) => {
setFieldFocused(prev => ({
...prev,
[fieldKey]: focused
}));
};
// Handle field value changes
const handleFieldChange = (fieldKey: string, value: any) => {
setEditedData(prev => ({
...prev,
[fieldKey]: value
}));
// Clear error for this field when user starts typing
if (errors[fieldKey]) {
setErrors(prev => {
const newErrors = { ...prev };
delete newErrors[fieldKey];
return newErrors;
});
}
};
// Validate all fields
const validateFields = (): boolean => {
const newErrors: Record<string, string> = {};
fields.forEach(field => {
const value = editedData[field.key];
// Check required fields
if (field.required && (!value || value.toString().trim() === '')) {
newErrors[field.key] = `${field.label} is required`;
return;
}
// Run custom validator
if (field.validator && value) {
const error = field.validator(value);
if (error) {
newErrors[field.key] = error;
}
}
// Basic email validation
if (field.type === 'email' && value && !/\S+@\S+\.\S+/.test(value)) {
newErrors[field.key] = 'Invalid email format';
}
});
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// Handle save
const handleSave = () => {
if (validateFields()) {
onSave(editedData);
}
};
// Handle cancel
const handleCancel = () => {
setEditedData({ ...data });
setErrors({});
onCancel?.();
};
// Helper function to get label class
const getLabelClass = (fieldKey: string, value: any) => {
const isFocused = fieldFocused[fieldKey];
const hasValue = value && value.toString().trim() !== '';
if (isFocused) {
return styles.activeFocusedLabel; // Secondary color when actively focused
} else if (hasValue) {
return styles.focusedLabel; // Primary color when has value but not focused
} else {
return styles.label; // Regular position when empty and not focused
}
};
// Render field based on its type
const renderField = (field: EditFieldConfig) => {
const value = editedData[field.key];
const hasError = errors[field.key];
if (field.type === 'readonly' || !field.editable) {
return (
<div className={styles.floatingLabelInput} key={field.key}>
<div className={styles.readonlyField}>
{field.formatter ? field.formatter(value) : (value || 'N/A')}
</div>
<label className={styles.focusedLabel}>
{field.label}
</label>
</div>
);
}
if (field.type === 'enum') {
return (
<div className={styles.floatingLabelInput} key={field.key}>
<select
value={value || ''}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
onFocus={() => handleFieldFocus(field.key, true)}
onBlur={() => handleFieldFocus(field.key, false)}
className={`${styles.fieldInput} ${hasError ? styles.fieldError : ''}`}
>
<option value="" disabled hidden></option>
{field.options?.map(option => (
<option key={option} value={option}>{option}</option>
))}
</select>
<label className={getLabelClass(field.key, value)}>
{field.label}
{field.required && <span className={styles.required}>*</span>}
</label>
{hasError && <span className={styles.errorText}>{hasError}</span>}
</div>
);
}
if (field.type === 'boolean') {
return (
<div className={styles.fieldGroup} key={field.key}>
<label className={styles.checkboxLabel}>
<input
type="checkbox"
checked={!!value}
onChange={(e) => handleFieldChange(field.key, e.target.checked)}
onFocus={() => handleFieldFocus(field.key, true)}
onBlur={() => handleFieldFocus(field.key, false)}
className={styles.checkboxInput}
/>
{field.label}
{field.required && <span className={styles.required}>*</span>}
</label>
{hasError && <span className={styles.errorText}>{hasError}</span>}
</div>
);
}
// Handle textarea type
if (field.type === 'textarea') {
const minRows = field.minRows || 4;
const maxRows = field.maxRows || 8;
return (
<div className={styles.floatingLabelInput} key={field.key}>
<textarea
name={field.key}
value={value || ''}
onChange={(e) => {
handleFieldChange(field.key, e.target.value);
// Auto-resize textarea
const textarea = e.target;
textarea.style.height = 'auto';
const newHeight = Math.max(
minRows * 1.5 * 16, // minRows * line-height * font-size
Math.min(
textarea.scrollHeight,
maxRows * 1.5 * 16 // maxRows * line-height * font-size
)
);
textarea.style.height = `${newHeight}px`;
}}
onFocus={() => handleFieldFocus(field.key, true)}
onBlur={() => handleFieldFocus(field.key, false)}
className={`${styles.fieldTextarea} ${hasError ? styles.fieldError : ''}`}
rows={minRows}
/>
<label className={getLabelClass(field.key, value)}>
{field.label}
{field.required && <span className={styles.required}>*</span>}
</label>
{hasError && <span className={styles.errorText}>{hasError}</span>}
</div>
);
}
// Default to text input for string, email, date types
const inputType = field.type === 'email' ? 'email' :
field.type === 'date' ? 'date' : 'text';
return (
<div className={styles.floatingLabelInput} key={field.key}>
<input
type={inputType}
value={value || ''}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
onFocus={() => handleFieldFocus(field.key, true)}
onBlur={() => handleFieldFocus(field.key, false)}
className={`${styles.fieldInput} ${hasError ? styles.fieldError : ''}`}
/>
<label className={getLabelClass(field.key, value)}>
{field.label}
{field.required && <span className={styles.required}>*</span>}
</label>
{hasError && <span className={styles.errorText}>{hasError}</span>}
</div>
);
};
return (
<div className={`${styles.editForm} ${className}`}>
<form onSubmit={(e) => { e.preventDefault(); handleSave(); }}>
{fields.map(field => renderField(field))}
</form>
{showButtons && (
<div className={styles.buttonGroup}>
{onCancel && (
<button
type="button"
className={styles.cancelButton}
onClick={handleCancel}
>
{cancelButtonText}
</button>
)}
<button
type="button"
className={styles.saveButton}
onClick={handleSave}
>
{saveButtonText}
</button>
</div>
)}
</div>
);
}
export default EditForm;

View file

@ -9,7 +9,7 @@
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
z-index: 9999;
padding: 20px;
}

View file

@ -1,11 +1,17 @@
import styles from './ViewForm.module.css';
import { EditFieldConfig } from './EditForm';
// Field configuration interface for ViewForm
export interface ViewFieldConfig {
key: string;
label: string;
formatter?: (value: any) => string;
}
// ViewForm props - for display-only purposes
export interface ViewFormProps<T = any> {
data: T;
fields: EditFieldConfig[];
fields: ViewFieldConfig[];
className?: string;
}
@ -17,7 +23,7 @@ export function ViewForm<T extends Record<string, any>>({
}: ViewFormProps<T>) {
// Render field in view-only mode
const renderField = (field: EditFieldConfig) => {
const renderField = (field: ViewFieldConfig) => {
const value = data[field.key];
return (

View file

@ -2,9 +2,9 @@
export { Popup, default as DefaultPopup } from './Popup';
export type { PopupProps, PopupAction } from './Popup';
// EditForm component
export { EditForm } from './EditForm';
export type { EditFormProps, EditFieldConfig } from './EditForm';
// FormGeneratorForm component (recommended for backend-driven forms)
export { FormGeneratorForm } from '../../FormGenerator/FormGeneratorForm';
export type { FormGeneratorFormProps, AttributeDefinition, AttributeOption } from '../../FormGenerator/FormGeneratorForm';
// ViewForm component
export { ViewForm } from './ViewForm';

View file

@ -0,0 +1,153 @@
.workflowStatusContainer {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 16px;
border: 1px solid var(--color-border);
border-radius: var(--object-radius-small);
}
.workflowStatus {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid transparent;
border-top-color: var(--color-primary);
border-right-color: var(--color-primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
flex-shrink: 0;
display: inline-block;
opacity: 0.8;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.statusBadge {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 13px;
font-weight: 600;
text-transform: capitalize;
}
.statusBadge[data-status="started"] {
background-color: #e3f2fd;
color: #1976d2;
}
.statusBadge[data-status="resumed"] {
background-color: #e8f5e9;
color: #388e3c;
}
.statusBadge[data-status="stopped"] {
background-color: #fff3e0;
color: #f57c00;
}
.statusBadge[data-status="failed"] {
background-color: #ffebee;
color: #d32f2f;
}
.statusBadge[data-status="completed"] {
background-color: #e8f5e9;
color: #2e7d32;
}
.roundBadge {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 13px;
font-weight: 600;
background-color: var(--color-highlight-gray);
color: var(--color-text);
}
.progressBarContainer {
display: flex;
align-items: center;
gap: 8px;
min-width: 150px;
}
.progressBar {
flex: 1;
height: 8px;
background-color: var(--color-highlight-gray);
border-radius: 4px;
overflow: hidden;
min-width: 100px;
}
.progressBarFill {
height: 100%;
background-color: var(--color-primary);
transition: width 0.3s ease;
}
.progressBarLabel {
font-size: 12px;
font-weight: 600;
color: var(--color-text);
min-width: 35px;
text-align: right;
}
/* Dark theme support */
[data-theme="dark"] .workflowStatusContainer {
background-color: var(--color-surface-dark);
border-color: var(--color-border-dark);
}
[data-theme="dark"] .statusBadge[data-status="started"] {
background-color: rgba(33, 150, 243, 0.2);
color: #64b5f6;
}
[data-theme="dark"] .statusBadge[data-status="resumed"] {
background-color: rgba(76, 175, 80, 0.2);
color: #81c784;
}
[data-theme="dark"] .statusBadge[data-status="stopped"] {
background-color: rgba(255, 152, 0, 0.2);
color: #ffb74d;
}
[data-theme="dark"] .statusBadge[data-status="failed"] {
background-color: rgba(244, 67, 54, 0.2);
color: #e57373;
}
[data-theme="dark"] .statusBadge[data-status="completed"] {
background-color: rgba(76, 175, 80, 0.2);
color: #81c784;
}
[data-theme="dark"] .roundBadge {
background-color: var(--color-highlight-gray-dark);
color: var(--color-text-dark);
}
[data-theme="dark"] .progressBar {
background-color: var(--color-highlight-gray-dark);
}
[data-theme="dark"] .progressBarLabel {
color: var(--color-text-dark);
}

View file

@ -0,0 +1,228 @@
import React, { useMemo } from 'react';
import { WorkflowStatusProps, WorkflowStatusType } from './WorkflowStatusTypes';
import styles from './WorkflowStatus.module.css';
// Helper function to extract workflow status and round from log message
const extractWorkflowStatus = (logs: any[]): { status: WorkflowStatusType; round: number | null; timestamp: number } => {
// First, check for completion messages with success status (these take priority)
const completionMessages = logs.filter(log => {
const message = (log.message || '').toLowerCase();
const logStatus = (log.status || '').toLowerCase();
return (message.includes('fast path completed') ||
message.includes('completed successfully')) &&
logStatus === 'success';
});
// If we have completion messages, use the latest one
if (completionMessages.length > 0) {
const latestCompletion = completionMessages.sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0))[0];
// Try to extract round from completion message
let round: number | null = null;
const message = (latestCompletion.message || '').toLowerCase();
const roundMatch = message.match(/\(?round\s+(\d+)\)?/i);
if (roundMatch) {
round = parseInt(roundMatch[1], 10);
} else {
// If no round in completion message, get round from latest workflow status message
const statusMessages = logs.filter(log => {
const msg = (log.message || '').toLowerCase();
return msg.includes('workflow started') || msg.includes('workflow resumed');
});
if (statusMessages.length > 0) {
const latestWorkflowStatus = statusMessages.sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0))[0];
const workflowMessage = (latestWorkflowStatus.message || '').toLowerCase();
const workflowRoundMatch = workflowMessage.match(/\(?round\s+(\d+)\)?/i);
if (workflowRoundMatch) {
round = parseInt(workflowRoundMatch[1], 10);
}
}
}
return {
status: 'completed',
round,
timestamp: latestCompletion.timestamp || 0
};
}
// If no completion messages, look for workflow started/resumed/stopped messages
const statusMessages = logs.filter(log => {
const message = (log.message || '').toLowerCase();
return message.includes('workflow started') ||
message.includes('workflow resumed') ||
message.includes('workflow stopped') ||
message.includes('workflow failed') ||
message.includes('workflow completed');
});
if (statusMessages.length === 0) {
return { status: null, round: null, timestamp: 0 };
}
// Get the latest status message
const latestStatus = statusMessages.sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0))[0];
const message = (latestStatus.message || '').toLowerCase();
let status: WorkflowStatusType = null;
if (message.includes('started')) {
status = 'started';
} else if (message.includes('resumed')) {
status = 'resumed';
} else if (message.includes('stopped')) {
status = 'stopped';
} else if (message.includes('failed')) {
status = 'failed';
} else if (message.includes('completed')) {
status = 'completed';
}
// Extract round number from message (e.g., "round 4", "round 2", or "(round 4)")
const roundMatch = message.match(/\(?round\s+(\d+)\)?/i);
const round = roundMatch ? parseInt(roundMatch[1], 10) : null;
return {
status,
round,
timestamp: latestStatus.timestamp || 0
};
};
// Helper function to group logs by round and get latest progress
const getLatestRoundProgress = (logs: any[]): { round: number | null; progress: number | undefined } => {
if (!logs || logs.length === 0) {
return { round: null, progress: undefined };
}
// Find the latest round
let currentRound = 1;
let latestProgress: number | undefined = undefined;
let latestRound = 1;
const sortedLogs = [...logs].sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
sortedLogs.forEach((log) => {
const message = (log.message || '').toLowerCase();
// Check if this is a workflow status message that indicates a round change
if (message.includes('workflow started') || message.includes('workflow resumed')) {
const roundMatch = message.match(/\(?round\s+(\d+)\)?/i);
if (roundMatch) {
currentRound = parseInt(roundMatch[1], 10);
latestRound = currentRound;
} else if (message.includes('workflow started')) {
currentRound = 1;
latestRound = 1;
}
}
// Update progress for current round
if (log.progress !== undefined && log.progress !== null) {
if (currentRound === latestRound) {
latestProgress = log.progress;
}
}
});
return { round: latestRound, progress: latestProgress };
};
const WorkflowStatus: React.FC<WorkflowStatusProps> = ({
className = '',
logs = [],
workflowStatus: workflowStatusFromApi,
currentRound: currentRoundFromApi,
isRunning
}) => {
// Use workflow status and round from API response, fallback to extracting from logs
const workflowStatus = useMemo(() => {
// If we have status from API, use it
if (workflowStatusFromApi) {
let status: WorkflowStatusType = null;
const statusLower = workflowStatusFromApi.toLowerCase();
if (statusLower === 'completed') {
status = 'completed';
} else if (statusLower === 'running') {
// Check if it's started or resumed from logs
const startedResumedLogs = logs.filter(log => {
const message = (log.message || '').toLowerCase();
return message.includes('workflow started') || message.includes('workflow resumed');
});
if (startedResumedLogs.length > 0) {
const latest = startedResumedLogs.sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0))[0];
const message = (latest.message || '').toLowerCase();
status = message.includes('resumed') ? 'resumed' : 'started';
} else {
status = 'started';
}
} else if (statusLower === 'stopped') {
status = 'stopped';
} else if (statusLower === 'failed') {
status = 'failed';
}
return {
status,
round: currentRoundFromApi || null,
timestamp: Date.now() / 1000 // Use current time since we don't have timestamp from API
};
}
// Fallback to extracting from logs
return extractWorkflowStatus(logs);
}, [workflowStatusFromApi, currentRoundFromApi, logs]);
// Get latest round progress
const latestProgress = useMemo(() => getLatestRoundProgress(logs), [logs]);
// Determine if workflow is running (show spinner)
// Show spinner if explicitly running OR if status indicates running state
const showSpinner = isRunning === true || workflowStatus.status === 'started' || workflowStatus.status === 'resumed';
// Calculate progress percentage
const progressValue = latestProgress.progress !== undefined
? Math.min(Math.max(latestProgress.progress, 0), 1)
: undefined;
const progressPercent = progressValue !== undefined ? Math.round(progressValue * 100) : undefined;
// Don't render if no status information (but always show if spinner should be visible)
if (!showSpinner && !workflowStatus.status && workflowStatus.round === null && progressValue === undefined) {
return null;
}
return (
<div className={`${styles.workflowStatusContainer} ${className}`}>
{/* Status and Round Badges */}
<div className={styles.workflowStatus}>
{showSpinner && (
<div className={styles.spinner} aria-label="Workflow running" />
)}
{workflowStatus.status && (
<span className={styles.statusBadge} data-status={workflowStatus.status}>
{workflowStatus.status.charAt(0).toUpperCase() + workflowStatus.status.slice(1)}
</span>
)}
{workflowStatus.round !== null && (
<span className={styles.roundBadge}>Round {workflowStatus.round}</span>
)}
</div>
{/* Progress Bar */}
{progressValue !== undefined && (
<div className={styles.progressBarContainer}>
<div className={styles.progressBar}>
<div
className={styles.progressBarFill}
style={{ width: `${progressPercent}%` }}
/>
</div>
<div className={styles.progressBarLabel}>{progressPercent}%</div>
</div>
)}
</div>
);
};
export default WorkflowStatus;

View file

@ -0,0 +1,50 @@
import type React from 'react';
/**
* Log entry from workflow
*/
export interface WorkflowLog {
id: string;
workflowId: string;
message: string;
type?: string;
timestamp: number;
status?: string;
progress?: number;
performance?: any;
parentId?: string | null;
operationId?: string | null;
}
/**
* Props for the WorkflowStatus component
*/
export interface WorkflowStatusProps {
/**
* Optional className for custom styling
*/
className?: string;
/**
* Array of log entries to extract status from
*/
logs?: WorkflowLog[];
/**
* Workflow status from API response (e.g., "completed", "running", "stopped")
*/
workflowStatus?: string;
/**
* Current round number from API response
*/
currentRound?: number;
/**
* Whether the workflow is currently running (shows spinner)
*/
isRunning?: boolean;
}
export type WorkflowStatusType = 'started' | 'resumed' | 'stopped' | 'failed' | 'completed' | null;

View file

@ -0,0 +1,4 @@
export { default as WorkflowStatus } from './WorkflowStatus';
export { default } from './WorkflowStatus';
export * from './WorkflowStatusTypes';

View file

@ -6,7 +6,12 @@ export * from './DragDropOverlay';
export * from './TextField';
export * from './Messages';
export * from './DropdownSelect';
export * from './EditFields';
export * from './LocationInput';
export * from './MapView';
export * from './ParcelInfoPanel';
export * from './CopyableTruncatedValue';
export { Log } from './Log';
export * from './Log';
export { WorkflowStatus } from './WorkflowStatus';
export * from './WorkflowStatus';
export * from './AutoScroll';

View file

@ -1,5 +1,5 @@
import React, { createContext, useContext, ReactNode } from 'react';
import { usePek } from './usePek';
import { usePek } from '../hooks/usePek';
interface PekContextType {
// Location input - separate fields

View file

@ -1,5 +1,5 @@
import React, { createContext, useContext, ReactNode } from 'react';
import { usePekTables } from './usePekTables';
import { usePekTables } from '../hooks/usePekTables';
interface PekTablesContextType {
// Tables list

View file

@ -3,6 +3,7 @@ import { useLocation } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { getPageDataByPath, GenericPageData, PageInstance } from './data';
import PageRenderer from './PageRenderer';
import { usePermissions } from '../../hooks/usePermissions';
interface PageManagerProps {
loadingComponent: React.ComponentType;
@ -15,64 +16,36 @@ const PageManager: React.FC<PageManagerProps> = ({
}) => {
const location = useLocation();
const [pageInstances, setPageInstances] = useState<Map<string, PageInstance>>(new Map());
const { canView } = usePermissions();
// Get current path
const getCurrentPath = () => {
const path = location.pathname === '/' ? '/dashboard' : location.pathname;
const path = location.pathname === '/' ? '' : location.pathname;
return path.startsWith('/') ? path.slice(1) : path;
};
const currentPath = getCurrentPath();
// Check if user has access to a page
// Check if user has access to a page using RBAC
const checkPageAccess = async (pageData: GenericPageData): Promise<boolean> => {
if (!pageData.privilegeChecker) {
return true; // No privilege checker means accessible to all
}
try {
return await pageData.privilegeChecker();
return await canView('UI', pageData.path);
} catch (error) {
console.error(`Error checking page access for ${pageData.path}:`, error);
console.error(`Error checking RBAC access for ${pageData.path}:`, error);
return false;
}
};
// Check if user has access to speech-related pages (legacy support)
const checkSpeechAccess = (path: string) => {
if (path.startsWith('speech/transcripts')) {
try {
const savedData = localStorage.getItem('speechSignUpData');
const timestamp = localStorage.getItem('speechSignUpTimestamp');
if (savedData && timestamp) {
const savedTime = parseInt(timestamp);
const now = Date.now();
const twentyFourHours = 24 * 60 * 60 * 1000;
// Check if data is still valid (within 24 hours)
return (now - savedTime) < twentyFourHours;
}
return false;
} catch (error) {
console.error('Error checking speech access:', error);
return false;
}
}
return true; // Allow access to non-speech pages
};
useEffect(() => {
const pageData = getPageDataByPath(currentPath);
if (!pageData || pageData.hide || !pageData.moduleEnabled || !checkSpeechAccess(currentPath)) {
if (!pageData || pageData.hide || !pageData.moduleEnabled) {
return;
}
// Check page access
checkPageAccess(pageData).then(hasAccess => {
if (!hasAccess) {
console.warn(`Access denied for page: ${currentPath}`);
return;
}
@ -150,7 +123,7 @@ const PageManager: React.FC<PageManagerProps> = ({
const pageData = getPageDataByPath(currentPath);
if (!pageData || pageData.hide || !pageData.moduleEnabled || !checkSpeechAccess(currentPath)) {
if (!pageData || pageData.hide || !pageData.moduleEnabled) {
return <ErrorComponent />;
}

File diff suppressed because it is too large Load diff

View file

@ -2,6 +2,24 @@ import React, { createContext, useContext, useState, useEffect } from 'react';
import { allPageData, SidebarItem } from './data';
import { useLanguage } from '../../providers/language/LanguageContext';
import { resolveLanguageText } from './pageInterface';
import { usePermissions } from '../../hooks/usePermissions';
import { FaHome, FaCogs } from 'react-icons/fa';
// Configuration for parent groups that don't have a page definition
// Maps parentPath to icon and default order
const parentGroupConfig: Record<string, {
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
defaultOrder?: number;
}> = {
'start': {
icon: FaHome,
defaultOrder: 1
},
'administration': {
icon: FaCogs,
defaultOrder: 3
}
};
interface SidebarContextType {
sidebarItems: SidebarItem[];
@ -31,11 +49,111 @@ export const SidebarProvider: React.FC<SidebarProviderProps> = ({ children }) =>
// Get translation function from language context
const { t } = useLanguage();
const { canView } = usePermissions();
// Get sidebar items from page data
const getSidebarItems = async (): Promise<SidebarItem[]> => {
const items: SidebarItem[] = [];
// Get all unique parent paths from pages that have subpages
const parentPaths = new Set<string>();
allPageData.forEach(page => {
if (page.parentPath && !page.hide && page.showInSidebar !== false) {
parentPaths.add(page.parentPath);
}
});
// Create parent groups for each parentPath (even if no page exists for that path)
const parentGroups = new Map<string, {
id: string;
name: string;
icon?: React.ComponentType<React.SVGProps<SVGSVGElement>>;
order: number;
subpages: typeof allPageData;
}>();
for (const parentPath of parentPaths) {
// Check if a page exists for this parent path
const parentPage = allPageData.find(p => p.path === parentPath && !p.hide);
// Get all subpages for this parent
const subpages = allPageData.filter(p =>
p.parentPath === parentPath &&
!p.hide &&
p.showInSidebar !== false
);
if (subpages.length > 0) {
// Use parent page data if it exists, otherwise create a virtual parent
// Try to resolve name from translation key (e.g., "start.title") or use capitalized path
let parentName: string;
if (parentPage) {
parentName = resolveLanguageText(parentPage.name, t);
} else {
// Try to resolve as translation key first (e.g., "start.title")
const translationKey = `${parentPath}.title`;
const translated = t(translationKey);
parentName = translated !== translationKey ? translated : parentPath.charAt(0).toUpperCase() + parentPath.slice(1);
}
// Get icon: use parent page icon if exists, otherwise use config, or undefined
const parentIcon = parentPage?.icon || parentGroupConfig[parentPath]?.icon;
// Determine order: use parent page order if exists, otherwise use config default,
// then minimum order of subpages, or default to 0
let parentOrder = parentPage?.order;
if (parentOrder === undefined) {
parentOrder = parentGroupConfig[parentPath]?.defaultOrder;
if (parentOrder === undefined) {
const subpageOrders = subpages.map(s => s.order ?? 0);
parentOrder = subpageOrders.length > 0 ? Math.min(...subpageOrders) : 0;
}
}
parentGroups.set(parentPath, {
id: parentPage?.id || parentPath,
name: parentName,
icon: parentIcon,
order: parentOrder,
subpages: subpages
});
}
}
// Process parent groups
for (const [parentPath, parentGroup] of parentGroups.entries()) {
// Filter subpages by RBAC access
const accessibleSubpages = [];
for (const subpage of parentGroup.subpages) {
try {
const hasSubpageRBACAccess = await canView('UI', subpage.path);
if (hasSubpageRBACAccess) {
accessibleSubpages.push(subpage);
}
} catch (error) {
console.error(`Error checking RBAC access for subpage ${subpage.path}:`, error);
}
}
if (accessibleSubpages.length > 0) {
// Create parent item with submenu (no link since it's not a real page)
items.push({
id: parentGroup.id,
name: parentGroup.name,
link: undefined, // No link - parent is not a clickable page
icon: parentGroup.icon,
moduleEnabled: true,
order: parentGroup.order,
submenu: accessibleSubpages.map(subpage => ({
id: subpage.id,
name: resolveLanguageText(subpage.name, t),
link: `/${subpage.path}`,
icon: subpage.icon
}))
});
}
}
// Get main pages (no parent path)
const mainPages = allPageData
.filter(page => !page.parentPath && !page.hide && page.showInSidebar !== false)
@ -43,78 +161,57 @@ export const SidebarProvider: React.FC<SidebarProviderProps> = ({ children }) =>
// Process each main page
for (const pageData of mainPages) {
// Check if user has privilege to access this page
let hasPagePrivilege = true;
if (pageData.privilegeChecker) {
try {
hasPagePrivilege = await pageData.privilegeChecker();
console.log(`🔍 Page privilege check for ${pageData.path}:`, { hasPagePrivilege });
} catch (error) {
console.error(`Error checking page privilege for ${pageData.path}:`, error);
hasPagePrivilege = false;
// Check RBAC permissions
try {
const hasRBACAccess = await canView('UI', pageData.path);
if (!hasRBACAccess) {
continue;
}
}
// Skip this page if user doesn't have privilege
if (!hasPagePrivilege) {
console.log(`❌ Skipping ${pageData.path} - no privilege`);
} catch (error) {
console.error(`Error checking RBAC access for ${pageData.path}:`, error);
continue;
}
// Check if this page has subpages and should show them
if (pageData.hasSubpages && pageData.subpagePrivilegeChecker) {
try {
const hasSubpagePrivilege = await pageData.subpagePrivilegeChecker();
if (hasSubpagePrivilege) {
// Find all subpages for this parent
const subpages = allPageData.filter(p =>
p.parentPath === pageData.path &&
!p.hide &&
p.showInSidebar !== false // Include subpages that should show in sidebar
);
// Check if this page has subpages
if (pageData.hasSubpages) {
// Find all subpages for this parent
const allSubpages = allPageData.filter(p =>
p.parentPath === pageData.path &&
!p.hide &&
p.showInSidebar !== false
);
if (subpages.length > 0) {
// Create expandable item with submenu
items.push({
id: pageData.id,
name: resolveLanguageText(pageData.name, t),
link: `/${pageData.path}`,
icon: pageData.icon,
moduleEnabled: pageData.moduleEnabled ?? true,
order: pageData.order || 0,
submenu: subpages.map(subpage => ({
id: subpage.id,
name: resolveLanguageText(subpage.name, t),
link: `/${subpage.path}`,
icon: subpage.icon
}))
});
} else {
// No subpages found, show as regular item
items.push({
id: pageData.id,
name: resolveLanguageText(pageData.name, t),
link: `/${pageData.path}`,
icon: pageData.icon,
moduleEnabled: pageData.moduleEnabled ?? true,
order: pageData.order || 0
});
// Filter subpages by RBAC access
const accessibleSubpages = [];
for (const subpage of allSubpages) {
try {
const hasSubpageRBACAccess = await canView('UI', subpage.path);
if (hasSubpageRBACAccess) {
accessibleSubpages.push(subpage);
}
} else {
// No subpage privilege, show as regular non-expandable item
items.push({
id: pageData.id,
name: resolveLanguageText(pageData.name, t),
link: `/${pageData.path}`,
icon: pageData.icon,
moduleEnabled: pageData.moduleEnabled ?? true,
order: pageData.order || 0
});
} catch (error) {
console.error(`Error checking RBAC access for subpage ${subpage.path}:`, error);
}
} catch (error) {
console.error(`Error checking subpage privilege for ${pageData.path}:`, error);
// Fallback to regular item on error
}
if (accessibleSubpages.length > 0) {
// Create expandable item with submenu
items.push({
id: pageData.id,
name: resolveLanguageText(pageData.name, t),
link: `/${pageData.path}`,
icon: pageData.icon,
moduleEnabled: pageData.moduleEnabled ?? true,
order: pageData.order || 0,
submenu: accessibleSubpages.map(subpage => ({
id: subpage.id,
name: resolveLanguageText(subpage.name, t),
link: `/${subpage.path}`,
icon: subpage.icon
}))
});
} else {
// No accessible subpages, show as regular item
items.push({
id: pageData.id,
name: resolveLanguageText(pageData.name, t),
@ -137,7 +234,8 @@ export const SidebarProvider: React.FC<SidebarProviderProps> = ({ children }) =>
}
}
return items;
// Sort all items by order
return items.sort((a, b) => (a.order || 0) - (b.order || 0));
};
// Refresh sidebar items

View file

@ -1,68 +0,0 @@
import { GenericPageData } from '../../pageInterface';
import { FaCogs } from 'react-icons/fa';
import { privilegeCheckers } from '../../../../utils/privilegeCheckers';
export const administrationPageData: GenericPageData = {
id: 'administration',
path: 'administration',
name: 'administration.title',
description: 'administration.description',
// Visual
icon: FaCogs,
title: 'administration.title',
subtitle: 'administration.subtitle',
// Content sections
content: [
{
id: 'intro',
type: 'heading',
content: 'administration.title',
level: 2
},
{
id: 'description',
type: 'paragraph',
content: 'administration.intro.description'
},
{
id: 'features',
type: 'heading',
content: 'administration.features.title',
level: 3
},
{
id: 'features-list',
type: 'list',
content: 'administration.features.description',
items: [
'administration.features.file_management',
'administration.features.user_management',
'administration.features.system_settings',
'administration.features.data_management'
]
}
],
// Privilege system
privilegeChecker: privilegeCheckers.viewerRole,
// Subpage support
hasSubpages: true,
subpagePrivilegeChecker: privilegeCheckers.viewerRole,
// Page behavior
persistent: false,
preload: true,
moduleEnabled: true,
// Sidebar
order: 3,
showInSidebar: true,
// Lifecycle hooks
onActivate: async () => {
if (import.meta.env.DEV) console.log('Administration activated');
}
};

View file

@ -4,6 +4,22 @@ import { FaGoogle, FaMicrosoft, FaLink } from 'react-icons/fa';
import { privilegeCheckers } from '../../../../utils/privilegeCheckers';
import { useConnections } from '../../../../hooks/useConnections';
// Helper function to convert attribute definitions to column config
const attributesToColumns = (attributes: any[]) => {
return attributes.map(attr => ({
key: attr.name,
label: attr.label || attr.name,
type: attr.type || 'string',
width: attr.width || 200,
minWidth: attr.minWidth || 100,
maxWidth: attr.maxWidth || 400,
sortable: attr.sortable !== false,
filterable: attr.filterable !== false,
searchable: attr.searchable !== false,
filterOptions: attr.filterOptions
}));
};
// Hook factory function for connections data
const createConnectionsHook = () => {
return () => {
@ -16,117 +32,79 @@ const createConnectionsHook = () => {
connectWithPopup,
isConnecting,
isLoading,
error
error,
attributes,
permissions,
pagination
} = useConnections();
const generatedColumns = attributes && attributes.length > 0
? attributesToColumns(attributes)
: undefined;
// Refetch function for pagination-aware refresh
const refetch = useCallback(async (params?: any) => {
await fetchConnections(params);
}, [fetchConnections]);
// Handle connection deletion
const handleDelete = useCallback(async (connectionId: string) => {
try {
await deleteConnection(connectionId);
// Refresh connections after deletion
await fetchConnections();
// Refresh connections after deletion - FormGenerator will handle pagination
// by calling refetch with current pagination params via its useEffect
return true;
} catch (error) {
console.error('Failed to delete connection:', error);
return false;
}
}, [deleteConnection, fetchConnections]);
}, [deleteConnection]);
// Handle single connection deletion for FormGenerator
const handleDeleteSingle = useCallback(async (connection: any) => {
const success = await handleDelete(connection.id);
if (success) {
refetch();
}
}, [handleDelete, refetch]);
// Handle multiple connection deletion for FormGenerator
const handleDeleteMultiple = useCallback(async (selectedConnections: any[]) => {
const connectionIds = selectedConnections.map(conn => conn.id);
const results = await Promise.all(
connectionIds.map(id => handleDelete(id))
);
const allSuccessful = results.every(result => result);
if (allSuccessful) {
refetch();
}
}, [handleDelete, refetch]);
return {
data: connections,
loading: isLoading,
error: error,
refetch: async () => { await fetchConnections(); },
refetch,
// Operations
handleDelete,
// FormGenerator specific handlers
onDelete: handleDeleteSingle,
onDeleteMultiple: handleDeleteMultiple,
connectWithPopup,
createGoogleConnectionAndAuth,
createMicrosoftConnectionAndAuth,
// Loading states
isConnecting,
deletingConnections: new Set() // Placeholder for consistency with other pages
deletingConnections: new Set(), // Placeholder for consistency with other pages
// Attributes and permissions for dynamic column/button generation
attributes,
permissions,
columns: generatedColumns, // Return generated columns
pagination
};
};
};
// Static columns configuration for connections table
const connectionsColumns = [
{
key: 'externalUsername',
label: 'connections.column.username',
type: 'string',
width: 200,
minWidth: 150,
maxWidth: 300,
sortable: true,
filterable: true,
searchable: true
},
{
key: 'externalEmail',
label: 'connections.column.email',
type: 'string',
width: 250,
minWidth: 200,
maxWidth: 350,
sortable: true,
filterable: true,
searchable: true
},
{
key: 'authority',
label: 'connections.column.authority',
type: 'string',
width: 150,
minWidth: 100,
maxWidth: 200,
sortable: true,
filterable: true,
searchable: true
},
{
key: 'status',
label: 'connections.column.status',
type: 'string',
width: 120,
minWidth: 100,
maxWidth: 150,
sortable: true,
filterable: true,
searchable: true
},
{
key: 'connectedAt',
label: 'connections.column.connectedat',
type: 'date',
width: 180,
minWidth: 150,
maxWidth: 220,
sortable: true,
filterable: true
},
{
key: 'lastChecked',
label: 'connections.column.lastchecked',
type: 'date',
width: 180,
minWidth: 150,
maxWidth: 220,
sortable: true,
filterable: true
},
{
key: 'expiresAt',
label: 'connections.column.expiresat',
type: 'date',
width: 180,
minWidth: 150,
maxWidth: 220,
sortable: true,
filterable: true
}
];
export const connectionsPageData: GenericPageData = {
id: 'administration-connections',
path: 'administration/connections',
@ -149,13 +127,29 @@ export const connectionsPageData: GenericPageData = {
icon: FaGoogle,
variant: 'primary',
onClick: async (hookData: any) => {
if (hookData?.createGoogleConnectionAndAuth) {
try {
await hookData.createGoogleConnectionAndAuth();
} catch (error) {
console.error('Failed to create Google connection:', error);
}
if (!hookData) {
console.error('No hookData available for Google connection creation');
return;
}
if (!hookData.createGoogleConnectionAndAuth) {
console.error('createGoogleConnectionAndAuth function not found in hookData', hookData);
return;
}
try {
await hookData.createGoogleConnectionAndAuth();
// Refresh connections after creation
if (hookData?.refetch) {
await hookData.refetch();
}
} catch (error) {
console.error('Failed to create Google connection:', error);
}
},
// Only show if user has create permission
disabled: (hookData: any) => {
if (!hookData?.permissions) return { disabled: false };
const hasCreate = hookData.permissions.create !== 'n' && hookData.permissions.view;
return { disabled: !hasCreate, message: 'No permission to create connections' };
}
},
{
@ -164,13 +158,29 @@ export const connectionsPageData: GenericPageData = {
icon: FaMicrosoft,
variant: 'primary',
onClick: async (hookData: any) => {
if (hookData?.createMicrosoftConnectionAndAuth) {
try {
await hookData.createMicrosoftConnectionAndAuth();
} catch (error) {
console.error('Failed to create Microsoft connection:', error);
}
if (!hookData) {
console.error('No hookData available for Microsoft connection creation');
return;
}
if (!hookData.createMicrosoftConnectionAndAuth) {
console.error('createMicrosoftConnectionAndAuth function not found in hookData', hookData);
return;
}
try {
await hookData.createMicrosoftConnectionAndAuth();
// Refresh connections after creation
if (hookData?.refetch) {
await hookData.refetch();
}
} catch (error) {
console.error('Failed to create Microsoft connection:', error);
}
},
// Only show if user has create permission
disabled: (hookData: any) => {
if (!hookData?.permissions) return { disabled: false };
const hasCreate = hookData.permissions.create !== 'n' && hookData.permissions.view;
return { disabled: !hasCreate, message: 'No permission to create connections' };
}
}
],
@ -182,7 +192,7 @@ export const connectionsPageData: GenericPageData = {
type: 'table',
tableConfig: {
hookFactory: createConnectionsHook,
columns: connectionsColumns,
// Columns are generated dynamically from attributes via hookData.columns
actionButtons: [
{
type: 'connect',
@ -190,14 +200,26 @@ export const connectionsPageData: GenericPageData = {
idField: 'id',
statusField: 'status',
operationName: 'connectWithPopup',
loadingStateName: 'isConnecting'
loadingStateName: 'isConnecting',
// Only show if user has update permission (connect = update operation)
disabled: (hookData: any) => {
if (!hookData?.permissions) return { disabled: false };
const hasUpdate = hookData.permissions.update !== 'n' && hookData.permissions.view;
return { disabled: !hasUpdate, message: 'No permission to connect' };
}
},
{
type: 'delete',
title: 'connections.action.delete',
idField: 'id',
operationName: 'handleDelete',
loadingStateName: 'deletingConnections'
loadingStateName: 'deletingConnections',
// Only show if user has delete permission
disabled: (hookData: any) => {
if (!hookData?.permissions) return { disabled: false };
const hasDelete = hookData.permissions.delete !== 'n' && hookData.permissions.view;
return { disabled: !hasDelete, message: 'No permission to delete connections' };
}
}
],
searchable: true,

View file

@ -14,7 +14,7 @@ export const dashboardPageData: GenericPageData = {
// Parent page
parentPath: 'start',
showInSidebar: false,
showInSidebar: true,
// Visual
icon: LuTicket,
@ -56,6 +56,13 @@ export const dashboardPageData: GenericPageData = {
emptyMessage: 'No messages yet. Start a workflow to see messages here.'
}
},
{
id: 'workflow-log',
type: 'log',
logConfig: {
emptyMessage: 'No log information available'
}
},
{
id: 'workflow-input',
type: 'inputForm',
@ -83,8 +90,6 @@ export const dashboardPageData: GenericPageData = {
preload: true,
moduleEnabled: true,
// Sidebar
showInSidebar: false,
// Lifecycle hooks
onActivate: async () => {

Some files were not shown because too many files have changed in this diff Show more