resumed backend integration, RBAC focus
This commit is contained in:
parent
aa34508be4
commit
aaf64b869f
136 changed files with 11391 additions and 9393 deletions
|
|
@ -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
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
||||||
|
|
@ -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
232
src/api/authApi.ts
Normal 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
221
src/api/connectionApi.ts
Normal 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
203
src/api/fileApi.ts
Normal 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
49
src/api/permissionApi.ts
Normal 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
191
src/api/promptApi.ts
Normal 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
215
src/api/userApi.ts
Normal 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'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -86,8 +86,8 @@ export async function fetchWorkflows(request: ApiRequestFunction): Promise<Workf
|
||||||
export async function fetchWorkflow(
|
export async function fetchWorkflow(
|
||||||
request: ApiRequestFunction,
|
request: ApiRequestFunction,
|
||||||
workflowId: string
|
workflowId: string
|
||||||
): Promise<Workflow> {
|
): Promise<Workflow & { messages?: WorkflowMessage[]; logs?: WorkflowLog[] }> {
|
||||||
return await request<Workflow>({
|
return await request<any>({
|
||||||
url: `/api/workflows/${workflowId}`,
|
url: `/api/workflows/${workflowId}`,
|
||||||
method: 'get'
|
method: 'get'
|
||||||
});
|
});
|
||||||
|
|
@ -100,11 +100,20 @@ export async function fetchWorkflow(
|
||||||
export async function fetchWorkflowStatus(
|
export async function fetchWorkflowStatus(
|
||||||
request: ApiRequestFunction,
|
request: ApiRequestFunction,
|
||||||
workflowId: string
|
workflowId: string
|
||||||
): Promise<Workflow> {
|
): Promise<Workflow | { status: string } | null> {
|
||||||
return await request<Workflow>({
|
const data = await request<any>({
|
||||||
url: `/api/workflows/${workflowId}/status`,
|
url: `/api/workflows/${workflowId}/status`,
|
||||||
method: 'get'
|
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
|
messageId?: string
|
||||||
): Promise<WorkflowMessage[]> {
|
): Promise<WorkflowMessage[]> {
|
||||||
const params = messageId ? { messageId } : undefined;
|
const params = messageId ? { messageId } : undefined;
|
||||||
const data = await request<WorkflowMessage[]>({
|
const data = await request<any>({
|
||||||
url: `/api/workflows/${workflowId}/messages`,
|
url: `/api/workflows/${workflowId}/messages`,
|
||||||
method: 'get',
|
method: 'get',
|
||||||
params
|
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
|
logId?: string
|
||||||
): Promise<WorkflowLog[]> {
|
): Promise<WorkflowLog[]> {
|
||||||
const params = logId ? { logId } : undefined;
|
const params = logId ? { logId } : undefined;
|
||||||
const data = await request<WorkflowLog[]>({
|
const data = await request<any>({
|
||||||
url: `/api/workflows/${workflowId}/logs`,
|
url: `/api/workflows/${workflowId}/logs`,
|
||||||
method: 'get',
|
method: 'get',
|
||||||
params
|
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(
|
export async function startWorkflowApi(
|
||||||
request: ApiRequestFunction,
|
request: ApiRequestFunction,
|
||||||
workflowData: StartWorkflowRequest,
|
workflowData: StartWorkflowRequest,
|
||||||
options?: { workflowId?: string; workflowMode?: 'Actionplan' | 'React' }
|
options?: { workflowId?: string; workflowMode?: 'Dynamic' | 'Automation' }
|
||||||
): Promise<StartWorkflowResponse> {
|
): Promise<StartWorkflowResponse> {
|
||||||
const params: Record<string, string> = {};
|
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) {
|
if (options?.workflowId) {
|
||||||
params.workflowId = 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 = {
|
const requestConfig = {
|
||||||
url: '/api/chat/playground/start',
|
url: '/api/chat/playground/start',
|
||||||
method: 'post' as const,
|
method: 'post' as const,
|
||||||
data: workflowData,
|
data: requestBody,
|
||||||
params: Object.keys(params).length > 0 ? params : undefined
|
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);
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -841,3 +841,7 @@
|
||||||
word-wrap: inherit;
|
word-wrap: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.contentPreviewPopup {
|
||||||
|
/* Popup-specific styles if needed */
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -16,9 +16,9 @@ import {
|
||||||
LoadingRenderer,
|
LoadingRenderer,
|
||||||
ErrorRenderer
|
ErrorRenderer
|
||||||
} from './renderers';
|
} from './renderers';
|
||||||
import styles from './FilePreview.module.css';
|
import styles from './ContentPreview.module.css';
|
||||||
|
|
||||||
export interface FilePreviewProps {
|
export interface ContentPreviewProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
fileId: string;
|
fileId: string;
|
||||||
|
|
@ -26,20 +26,20 @@ export interface FilePreviewProps {
|
||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FilePreview({
|
export function ContentPreview({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
fileId,
|
fileId,
|
||||||
fileName,
|
fileName,
|
||||||
mimeType
|
mimeType
|
||||||
}: FilePreviewProps) {
|
}: ContentPreviewProps) {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const { handleFilePreview, handleFileDownload, previewingFiles, previewError, downloadingFiles } = useFileOperations();
|
const { handleFilePreview, handleFileDownload, previewingFiles, previewError, downloadingFiles } = useFileOperations();
|
||||||
|
|
||||||
// Debug logging to see what data we're receiving
|
// Debug logging to see what data we're receiving
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen && import.meta.env.DEV) {
|
if (isOpen && import.meta.env.DEV) {
|
||||||
console.log('FilePreview received:', { fileId, fileName, mimeType });
|
console.log('ContentPreview received:', { fileId, fileName, mimeType });
|
||||||
}
|
}
|
||||||
}, [isOpen, fileId, fileName, mimeType]);
|
}, [isOpen, fileId, fileName, mimeType]);
|
||||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
|
|
@ -162,7 +162,7 @@ export function FilePreview({
|
||||||
const renderPreview = () => {
|
const renderPreview = () => {
|
||||||
// Handle text content in PDF files (corrupted files) - check this first
|
// Handle text content in PDF files (corrupted files) - check this first
|
||||||
if (previewContent && !previewUrl && mimeType === 'application/pdf') {
|
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 (
|
return (
|
||||||
<PdfRenderer
|
<PdfRenderer
|
||||||
previewUrl={undefined}
|
previewUrl={undefined}
|
||||||
|
|
@ -246,7 +246,7 @@ export function FilePreview({
|
||||||
|
|
||||||
case 'application':
|
case 'application':
|
||||||
if (mimeType === 'application/pdf') {
|
if (mimeType === 'application/pdf') {
|
||||||
console.log('🔍 FilePreview passing normal PDF to PdfRenderer:', {
|
console.log('🔍 ContentPreview passing normal PDF to PdfRenderer:', {
|
||||||
previewUrl,
|
previewUrl,
|
||||||
previewContent: previewContent ? `${previewContent.substring(0, 50)}...` : null,
|
previewContent: previewContent ? `${previewContent.substring(0, 50)}...` : null,
|
||||||
fileName,
|
fileName,
|
||||||
|
|
@ -292,9 +292,9 @@ export function FilePreview({
|
||||||
<Popup
|
<Popup
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title={`${t('files.preview.title', 'File Preview')}: ${fileName}`}
|
title={`${t('files.preview.title', 'Content Preview')}: ${fileName}`}
|
||||||
size="fullscreen"
|
size="fullscreen"
|
||||||
className={styles.filePreviewPopup}
|
className={styles.contentPreviewPopup}
|
||||||
actions={actions}
|
actions={actions}
|
||||||
>
|
>
|
||||||
<div className={styles.previewContainer}>
|
<div className={styles.previewContainer}>
|
||||||
|
|
@ -304,6 +304,5 @@ export function FilePreview({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default FilePreview;
|
export default ContentPreview;
|
||||||
|
|
||||||
|
|
||||||
3
src/components/ContentPreview/index.ts
Normal file
3
src/components/ContentPreview/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { ContentPreview } from './ContentPreview';
|
||||||
|
export type { ContentPreviewProps } from './ContentPreview';
|
||||||
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import styles from '../FilePreview.module.css';
|
import styles from '../ContentPreview.module.css';
|
||||||
|
|
||||||
interface ApplicationRendererProps {
|
interface ApplicationRendererProps {
|
||||||
previewUrl: string;
|
previewUrl: string;
|
||||||
|
|
@ -19,3 +19,4 @@ export function ApplicationRenderer({ previewUrl, fileName, mimeType, onError }:
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
import styles from '../FilePreview.module.css';
|
import styles from '../ContentPreview.module.css';
|
||||||
|
|
||||||
interface ErrorRendererProps {
|
interface ErrorRendererProps {
|
||||||
error: string;
|
error: string;
|
||||||
|
|
@ -22,3 +22,4 @@ export function ErrorRenderer({ error, onRetry }: ErrorRendererProps) {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import styles from '../FilePreview.module.css';
|
import styles from '../ContentPreview.module.css';
|
||||||
|
|
||||||
interface HtmlRendererProps {
|
interface HtmlRendererProps {
|
||||||
previewUrl: string;
|
previewUrl: string;
|
||||||
|
|
@ -39,3 +39,4 @@ export function HtmlRenderer({ previewUrl, fileName, onError }: HtmlRendererProp
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import styles from '../FilePreview.module.css';
|
import styles from '../ContentPreview.module.css';
|
||||||
|
|
||||||
interface ImageRendererProps {
|
interface ImageRendererProps {
|
||||||
previewUrl: string;
|
previewUrl: string;
|
||||||
|
|
@ -32,3 +32,4 @@ export function ImageRenderer({ previewUrl, fileName, onError }: ImageRendererPr
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
import styles from '../FilePreview.module.css';
|
import styles from '../ContentPreview.module.css';
|
||||||
|
|
||||||
interface JsonRendererProps {
|
interface JsonRendererProps {
|
||||||
previewContent: string;
|
previewContent: string;
|
||||||
|
|
@ -504,3 +504,4 @@ export function JsonRenderer({ previewContent, fileName }: JsonRendererProps) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
import styles from '../FilePreview.module.css';
|
import styles from '../ContentPreview.module.css';
|
||||||
|
|
||||||
export function LoadingRenderer() {
|
export function LoadingRenderer() {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
|
@ -11,3 +11,4 @@ export function LoadingRenderer() {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { IoIosWarning } from 'react-icons/io';
|
import { IoIosWarning } from 'react-icons/io';
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
import styles from '../FilePreview.module.css';
|
import styles from '../ContentPreview.module.css';
|
||||||
|
|
||||||
interface PdfRendererProps {
|
interface PdfRendererProps {
|
||||||
previewUrl?: string;
|
previewUrl?: string;
|
||||||
|
|
@ -51,3 +51,4 @@ export function PdfRenderer({ previewUrl, previewContent, fileName, onError }: P
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import styles from '../FilePreview.module.css';
|
import styles from '../ContentPreview.module.css';
|
||||||
|
|
||||||
// Updated to handle both previewUrl and previewContent
|
// Updated to handle both previewUrl and previewContent
|
||||||
|
|
||||||
|
|
@ -39,3 +39,4 @@ export function TextRenderer({ previewUrl, previewContent, fileName, mimeType, o
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
import styles from '../FilePreview.module.css';
|
import styles from '../ContentPreview.module.css';
|
||||||
|
|
||||||
interface UnsupportedRendererProps {
|
interface UnsupportedRendererProps {
|
||||||
previewUrl: string;
|
previewUrl: string;
|
||||||
|
|
@ -24,3 +24,4 @@ export function UnsupportedRenderer({ previewUrl, fileName }: UnsupportedRendere
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -7,3 +7,4 @@ export { ApplicationRenderer } from './ApplicationRenderer';
|
||||||
export { UnsupportedRenderer } from './UnsupportedRenderer';
|
export { UnsupportedRenderer } from './UnsupportedRenderer';
|
||||||
export { LoadingRenderer } from './LoadingRenderer';
|
export { LoadingRenderer } from './LoadingRenderer';
|
||||||
export { ErrorRenderer } from './ErrorRenderer';
|
export { ErrorRenderer } from './ErrorRenderer';
|
||||||
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
export { FilePreview } from './FilePreview';
|
|
||||||
export type { FilePreviewProps } from './FilePreview';
|
|
||||||
|
|
@ -14,6 +14,7 @@ export interface DownloadActionButtonProps<T = any> {
|
||||||
hookData?: any; // Contains all hook data including operations
|
hookData?: any; // Contains all hook data including operations
|
||||||
// Field mappings
|
// Field mappings
|
||||||
idField?: string; // Field name for the unique identifier
|
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
|
loadingStateName?: string; // Name of the loading state in hookData
|
||||||
operationName?: string; // Name of the operation function in hookData
|
operationName?: string; // Name of the operation function in hookData
|
||||||
}
|
}
|
||||||
|
|
@ -28,6 +29,7 @@ export function DownloadActionButton<T = any>({
|
||||||
isDownloading = false,
|
isDownloading = false,
|
||||||
hookData,
|
hookData,
|
||||||
idField = 'id',
|
idField = 'id',
|
||||||
|
nameField,
|
||||||
loadingStateName = 'downloadingFiles',
|
loadingStateName = 'downloadingFiles',
|
||||||
operationName
|
operationName
|
||||||
}: DownloadActionButtonProps<T>) {
|
}: DownloadActionButtonProps<T>) {
|
||||||
|
|
@ -38,6 +40,32 @@ export function DownloadActionButton<T = any>({
|
||||||
const isDisabled = typeof disabled === 'boolean' ? disabled : disabled?.disabled || false;
|
const isDisabled = typeof disabled === 'boolean' ? disabled : disabled?.disabled || false;
|
||||||
const disabledMessage = typeof disabled === 'object' ? disabled?.message : undefined;
|
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) => {
|
const handleClick = async (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (!isDisabled && !loading && !isDownloading && !internalLoading) {
|
if (!isDisabled && !loading && !isDownloading && !internalLoading) {
|
||||||
|
|
@ -45,7 +73,9 @@ export function DownloadActionButton<T = any>({
|
||||||
try {
|
try {
|
||||||
// If operationName is provided and hookData is available, use the hook function
|
// If operationName is provided and hookData is available, use the hook function
|
||||||
if (operationName && hookData && hookData[operationName]) {
|
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) {
|
} else if (onDownload) {
|
||||||
// Fallback to the provided onDownload function
|
// Fallback to the provided onDownload function
|
||||||
await onDownload(row);
|
await onDownload(row);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { MdModeEdit } from 'react-icons/md';
|
import { MdModeEdit } from 'react-icons/md';
|
||||||
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
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';
|
import styles from '../ActionButton.module.css';
|
||||||
|
|
||||||
export interface EditActionButtonProps<T = any> {
|
export interface EditActionButtonProps<T = any> {
|
||||||
|
|
@ -19,15 +20,12 @@ export interface EditActionButtonProps<T = any> {
|
||||||
typeField?: string; // Field name for type/mime type
|
typeField?: string; // Field name for type/mime type
|
||||||
operationName?: string; // Name of the operation function in hookData
|
operationName?: string; // Name of the operation function in hookData
|
||||||
loadingStateName?: string; // Name of the loading state in hookData
|
loadingStateName?: string; // Name of the loading state in hookData
|
||||||
// Edit configuration
|
// Function name in hookData to fetch a single item (e.g., 'fetchPromptById', 'fetchItem')
|
||||||
editFields?: Array<{
|
fetchItemFunctionName?: string;
|
||||||
key: string;
|
// Entity type for FormGeneratorForm (e.g., "Prompt", "User", "FileItem")
|
||||||
label: string;
|
entityType?: string;
|
||||||
type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'readonly';
|
// Optional: Pre-fetched attributes (if available in hookData)
|
||||||
editable?: boolean;
|
attributes?: any[];
|
||||||
required?: boolean;
|
|
||||||
validator?: (value: any) => string | null;
|
|
||||||
}>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EditActionButton<T = any>({
|
export function EditActionButton<T = any>({
|
||||||
|
|
@ -42,29 +40,15 @@ export function EditActionButton<T = any>({
|
||||||
idField = 'id',
|
idField = 'id',
|
||||||
operationName = 'handleFileUpdate',
|
operationName = 'handleFileUpdate',
|
||||||
loadingStateName = 'editingFiles',
|
loadingStateName = 'editingFiles',
|
||||||
editFields = [
|
fetchItemFunctionName = 'fetchPromptById',
|
||||||
{
|
entityType,
|
||||||
key: 'file_name',
|
attributes: providedAttributes
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}: EditActionButtonProps<T>) {
|
}: EditActionButtonProps<T>) {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const [internalLoading, setInternalLoading] = useState(false);
|
const [internalLoading, setInternalLoading] = useState(false);
|
||||||
const [isPopupOpen, setIsPopupOpen] = useState(false);
|
const [isPopupOpen, setIsPopupOpen] = useState(false);
|
||||||
const [editData, setEditData] = useState<T | null>(null);
|
const [editData, setEditData] = useState<T | null>(null);
|
||||||
|
const [fetchingData, setFetchingData] = useState(false);
|
||||||
|
|
||||||
// Extract disabled state and tooltip message
|
// Extract disabled state and tooltip message
|
||||||
const isDisabled = typeof disabled === 'boolean' ? disabled : disabled?.disabled || false;
|
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');
|
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) => {
|
const handleClick = async (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (!isDisabled && !loading && !isEditing && !internalLoading) {
|
if (!isDisabled && !loading && !isEditing && !internalLoading && !fetchingData && !isPopupOpen) {
|
||||||
setInternalLoading(true);
|
setInternalLoading(true);
|
||||||
|
setFetchingData(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Debug logging to see what data we're working with
|
|
||||||
|
|
||||||
|
|
||||||
// Call the onEdit callback if provided
|
// Call the onEdit callback if provided
|
||||||
if (onEdit) {
|
if (onEdit) {
|
||||||
await onEdit(row);
|
await onEdit(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up edit data and open popup
|
const itemId = (row as any)[idField];
|
||||||
setEditData(row);
|
|
||||||
|
// 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);
|
setIsPopupOpen(true);
|
||||||
} finally {
|
} finally {
|
||||||
setInternalLoading(false);
|
setInternalLoading(false);
|
||||||
|
|
@ -108,21 +154,22 @@ export function EditActionButton<T = any>({
|
||||||
// Get the item ID from the row
|
// Get the item ID from the row
|
||||||
const itemId = (editData as any)[idField];
|
const itemId = (editData as any)[idField];
|
||||||
|
|
||||||
|
// Get edit fields configuration
|
||||||
|
const fields = getEditFields();
|
||||||
|
|
||||||
// Extract the fields to update from the edit data
|
// Extract the fields to update from the edit data
|
||||||
const updateData: any = {};
|
const updateData: any = {};
|
||||||
editFields.forEach(field => {
|
fields.forEach(field => {
|
||||||
if (field.editable !== false) {
|
if (field.editable !== false) {
|
||||||
// Map frontend field names to API field names
|
const value = (updatedData as any)[field.key];
|
||||||
if (field.key === 'file_name') {
|
if (value !== undefined) {
|
||||||
updateData.fileName = (updatedData as any)[field.key];
|
updateData[field.key] = value;
|
||||||
} else {
|
|
||||||
updateData[field.key] = (updatedData as any)[field.key];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check if optimistic update is available
|
// Check if optimistic update is available
|
||||||
const updateOptimistically = hookData.updateOptimistically || hookData.updateFileOptimistically;
|
const updateOptimistically = hookData.updateOptimistically;
|
||||||
|
|
||||||
// Validate required operation exists
|
// Validate required operation exists
|
||||||
if (!hookData[operationName]) {
|
if (!hookData[operationName]) {
|
||||||
|
|
@ -134,39 +181,40 @@ export function EditActionButton<T = any>({
|
||||||
updateOptimistically(itemId, updateData);
|
updateOptimistically(itemId, updateData);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close popup and reset state immediately for better UX
|
|
||||||
setIsPopupOpen(false);
|
|
||||||
setEditData(null);
|
|
||||||
|
|
||||||
// Use hookData operation to update in the background
|
// Use hookData operation to update in the background
|
||||||
const result = await hookData[operationName](itemId, updateData, editData);
|
const result = await hookData[operationName](itemId, updateData, editData);
|
||||||
const success = result?.success || result === true;
|
const success = result?.success || result === true;
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
// If we used optimistic update, don't refetch to avoid overwriting our changes
|
// Close popup and reset state on success
|
||||||
if (updateOptimistically) {
|
setIsPopupOpen(false);
|
||||||
// Trust the optimistic update worked
|
setEditData(null);
|
||||||
} else {
|
|
||||||
// No optimistic update, refetch to sync with backend
|
// If we used optimistic update, refetch to get fresh data from backend
|
||||||
if (hookData.refetch) {
|
// This ensures we have the latest data including any server-side transformations
|
||||||
await hookData.refetch();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// If update failed, refetch to restore original state
|
|
||||||
if (hookData.refetch) {
|
if (hookData.refetch) {
|
||||||
await hookData.refetch();
|
await hookData.refetch();
|
||||||
}
|
}
|
||||||
console.error('Failed to update item:', itemId);
|
} else {
|
||||||
// TODO: Show error message to user
|
// 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) {
|
} catch (error: any) {
|
||||||
// If update failed, refetch to restore original state
|
// If update failed, revert optimistic update
|
||||||
if (hookData.refetch) {
|
if (hookData.updateOptimistically && hookData.refetch) {
|
||||||
await hookData.refetch();
|
await hookData.refetch();
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error('Failed to update item:', error);
|
console.error('Failed to update item:', error);
|
||||||
// TODO: Show error message to user
|
setIsPopupOpen(false);
|
||||||
|
setEditData(null);
|
||||||
} finally {
|
} finally {
|
||||||
setInternalLoading(false);
|
setInternalLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -181,13 +229,11 @@ export function EditActionButton<T = any>({
|
||||||
// Use hookData editing state if available, otherwise use passed isEditing
|
// Use hookData editing state if available, otherwise use passed isEditing
|
||||||
const loadingState = hookData?.[loadingStateName];
|
const loadingState = hookData?.[loadingStateName];
|
||||||
const actualIsEditing = loadingState?.has((row as any)[idField]) || isEditing;
|
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)
|
// Determine the final button title (tooltip)
|
||||||
const finalTitle = isDisabled && disabledMessage ? disabledMessage : buttonTitle;
|
const finalTitle = isDisabled && disabledMessage ? disabledMessage : buttonTitle;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
|
|
@ -201,31 +247,40 @@ export function EditActionButton<T = any>({
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Edit Popup */}
|
{/* Edit Popup - Identical structure to CreateButton */}
|
||||||
<Popup
|
<Popup
|
||||||
isOpen={isPopupOpen}
|
isOpen={isPopupOpen}
|
||||||
title={t('files.edit.title', 'Edit Item')}
|
title={t('files.edit.title', 'Edit Item')}
|
||||||
onClose={handleCancel}
|
onClose={handleCancel}
|
||||||
size="small"
|
size="medium"
|
||||||
closable={true}
|
closable={!internalLoading}
|
||||||
>
|
>
|
||||||
{editData && (
|
{editData && (() => {
|
||||||
<EditForm
|
const entityTypeValue = getEntityType();
|
||||||
data={editData}
|
const attributesValue = getAttributes();
|
||||||
fields={editFields.map(field => ({
|
|
||||||
key: field.key,
|
if (!entityTypeValue && !attributesValue) {
|
||||||
label: field.label,
|
console.warn('EditActionButton: entityType or attributes must be provided for FormGeneratorForm');
|
||||||
type: field.type,
|
return (
|
||||||
editable: field.editable ?? true,
|
<div style={{ padding: '20px', textAlign: 'center' }}>
|
||||||
required: field.required ?? false,
|
{t('common.error', 'Error: Entity type or attributes must be provided')}
|
||||||
validator: field.validator
|
</div>
|
||||||
}))}
|
);
|
||||||
onSave={handleSave}
|
}
|
||||||
onCancel={handleCancel}
|
|
||||||
saveButtonText={t('common.save', 'Save')}
|
return (
|
||||||
cancelButtonText={t('common.cancel', 'Cancel')}
|
<FormGeneratorForm
|
||||||
/>
|
entityType={entityTypeValue}
|
||||||
)}
|
attributes={attributesValue}
|
||||||
|
data={editData}
|
||||||
|
mode="edit"
|
||||||
|
onSubmit={handleSave}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
submitButtonText={t('common.save', 'Save')}
|
||||||
|
cancelButtonText={t('common.cancel', 'Cancel')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</Popup>
|
</Popup>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,11 @@ export interface PlayActionButtonProps<T = any> {
|
||||||
// Field mappings
|
// Field mappings
|
||||||
idField?: string; // Field name for the unique identifier
|
idField?: string; // Field name for the unique identifier
|
||||||
nameField?: string; // Field name for display name
|
nameField?: string; // Field name for display name
|
||||||
|
contentField?: string; // Field name for content (e.g., 'content' for prompts, 'prompt' for workflows)
|
||||||
// Navigation
|
// Navigation
|
||||||
navigateTo?: string; // Path to navigate to after selection (default: 'start/dashboard')
|
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>({
|
export function PlayActionButton<T = any>({
|
||||||
|
|
@ -30,7 +33,9 @@ export function PlayActionButton<T = any>({
|
||||||
hookData,
|
hookData,
|
||||||
idField = 'id',
|
idField = 'id',
|
||||||
nameField = 'name',
|
nameField = 'name',
|
||||||
navigateTo = 'start/dashboard'
|
contentField = 'content',
|
||||||
|
navigateTo = 'start/dashboard',
|
||||||
|
mode = 'prompt'
|
||||||
}: PlayActionButtonProps<T>) {
|
}: PlayActionButtonProps<T>) {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
@ -44,30 +49,41 @@ export function PlayActionButton<T = any>({
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (!isDisabled && !loading) {
|
if (!isDisabled && !loading) {
|
||||||
try {
|
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
|
// Call the onPlay callback if provided
|
||||||
if (onPlay) {
|
if (onPlay) {
|
||||||
await onPlay(row);
|
await onPlay(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Select the workflow in context
|
if (mode === 'workflow') {
|
||||||
selectWorkflow(workflowId);
|
// 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 to dashboard (or specified path)
|
||||||
navigate(`/${navigateTo}`);
|
navigate(`/${navigateTo}`);
|
||||||
} catch (error: any) {
|
} 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 isLoading = loading;
|
||||||
const finalTitle = isDisabled && disabledMessage ? disabledMessage : buttonTitle;
|
const finalTitle = isDisabled && disabledMessage ? disabledMessage : buttonTitle;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { IoIosEye } from 'react-icons/io';
|
import { IoIosEye } from 'react-icons/io';
|
||||||
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||||
import { FilePreview } from '../../../FilePreview/FilePreview';
|
import { ContentPreview } from '../../../ContentPreview';
|
||||||
import styles from '../ActionButton.module.css';
|
import styles from '../ActionButton.module.css';
|
||||||
|
|
||||||
export interface ViewActionButtonProps<T = any> {
|
export interface ViewActionButtonProps<T = any> {
|
||||||
|
|
@ -82,8 +82,8 @@ export function ViewActionButton<T = any>({
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* File Preview Component */}
|
{/* Content Preview Component */}
|
||||||
<FilePreview
|
<ContentPreview
|
||||||
isOpen={isPopupOpen}
|
isOpen={isPopupOpen}
|
||||||
onClose={() => setIsPopupOpen(false)}
|
onClose={() => setIsPopupOpen(false)}
|
||||||
fileId={(row as any)[idField]}
|
fileId={(row as any)[idField]}
|
||||||
|
|
|
||||||
|
|
@ -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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { FormGeneratorControls, default } from './FormGeneratorControls';
|
||||||
|
export type { FilterableField, FormGeneratorControlsProps } from './FormGeneratorControls';
|
||||||
|
|
||||||
|
|
@ -1,8 +1,33 @@
|
||||||
/* EditForm container */
|
/* FormGeneratorForm container */
|
||||||
.editForm {
|
.formGeneratorForm {
|
||||||
width: 100%;
|
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 */
|
/* Field styling */
|
||||||
.fieldGroup {
|
.fieldGroup {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
|
|
@ -14,6 +39,7 @@
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Floating label container */
|
/* Floating label container */
|
||||||
|
|
@ -39,11 +65,85 @@
|
||||||
border-color: var(--color-secondary);
|
border-color: var(--color-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fieldInput:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.fieldInput.fieldError {
|
.fieldInput.fieldError {
|
||||||
border-color: #ef4444;
|
border-color: #ef4444;
|
||||||
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
|
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 */
|
/* Textarea styling */
|
||||||
.fieldTextarea {
|
.fieldTextarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
@ -60,7 +160,12 @@
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
min-height: 4em;
|
min-height: 4em;
|
||||||
max-height: 8em;
|
}
|
||||||
|
|
||||||
|
/* Content textarea - larger default size */
|
||||||
|
.contentTextarea {
|
||||||
|
min-height: 18em !important;
|
||||||
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fieldTextarea:focus {
|
.fieldTextarea:focus {
|
||||||
|
|
@ -147,6 +252,7 @@
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
accent-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Required field indicator */
|
/* Required field indicator */
|
||||||
|
|
@ -185,13 +291,18 @@
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cancelButton:hover {
|
.cancelButton:hover:not(:disabled) {
|
||||||
background-color: var(--color-primary-hover);
|
background-color: var(--color-primary-hover);
|
||||||
border-color: var(--color-primary);
|
border-color: var(--color-primary);
|
||||||
color: #181818;
|
color: #181818;
|
||||||
}
|
}
|
||||||
|
|
||||||
.saveButton {
|
.cancelButton:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submitButton {
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
border: none;
|
border: none;
|
||||||
background-color: var(--color-secondary);
|
background-color: var(--color-secondary);
|
||||||
|
|
@ -203,11 +314,11 @@
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.saveButton:hover {
|
.submitButton:hover:not(:disabled) {
|
||||||
background-color: var(--color-secondary-hover);
|
background-color: var(--color-secondary-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.saveButton:disabled {
|
.submitButton:disabled {
|
||||||
background-color: #9ca3af;
|
background-color: #9ca3af;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
@ -219,8 +330,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.cancelButton,
|
.cancelButton,
|
||||||
.saveButton {
|
.submitButton {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
3
src/components/FormGenerator/FormGeneratorForm/index.ts
Normal file
3
src/components/FormGenerator/FormGeneratorForm/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { default as FormGeneratorForm } from './FormGeneratorForm';
|
||||||
|
export type { FormGeneratorFormProps, AttributeDefinition, AttributeOption } from './FormGeneratorForm';
|
||||||
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
3
src/components/FormGenerator/FormGeneratorList/index.ts
Normal file
3
src/components/FormGenerator/FormGeneratorList/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { default as FormGeneratorList, FormGeneratorList as FormGeneratorListComponent } from './FormGeneratorList';
|
||||||
|
export type { FieldConfig, FormGeneratorListProps } from './FormGeneratorList';
|
||||||
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
.formGenerator {
|
.formGeneratorTable {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
font-family: var(--font-family);
|
font-family: var(--font-family);
|
||||||
|
/* Ensure proper height constraints for scrolling */
|
||||||
|
min-height: 0;
|
||||||
|
max-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
|
|
@ -14,298 +17,6 @@
|
||||||
margin-bottom: 10px;
|
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 */
|
/* Table Container */
|
||||||
.tableContainer {
|
.tableContainer {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
@ -313,7 +24,25 @@
|
||||||
border: 1px solid var(--color-primary);
|
border: 1px solid var(--color-primary);
|
||||||
border-radius: 25px;
|
border-radius: 25px;
|
||||||
background: var(--color-bg);
|
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 {
|
.loading {
|
||||||
|
|
@ -577,6 +306,8 @@ tbody .actionsColumn {
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
border-top: 1px solid var(--color-primary);
|
border-top: 1px solid var(--color-primary);
|
||||||
|
/* Ensure pagination stays visible and doesn't get cut off */
|
||||||
|
flex-shrink: 0;
|
||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
border-radius: 0 0 8px 8px;
|
border-radius: 0 0 8px 8px;
|
||||||
}
|
}
|
||||||
|
|
@ -642,60 +373,15 @@ tbody .actionsColumn {
|
||||||
|
|
||||||
/* Responsive Design */
|
/* Responsive Design */
|
||||||
@media (max-width: 768px) {
|
@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 {
|
.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,
|
.th,
|
||||||
|
|
@ -749,20 +435,10 @@ tbody .actionsColumn {
|
||||||
|
|
||||||
/* Accessibility */
|
/* Accessibility */
|
||||||
.actionButton:focus,
|
.actionButton:focus,
|
||||||
.paginationButton:focus,
|
.paginationButton:focus {
|
||||||
.searchInput:focus,
|
|
||||||
.filterInput:focus,
|
|
||||||
.filterSelect:focus,
|
|
||||||
.deleteButton:focus,
|
|
||||||
.deleteAllButton:focus {
|
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.deleteButton:focus,
|
|
||||||
.deleteAllButton:focus {
|
|
||||||
box-shadow: 0 0 0 2px rgba(220, 53, 69, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Custom scrollbar for table container */
|
/* Custom scrollbar for table container */
|
||||||
.tableContainer::-webkit-scrollbar {
|
.tableContainer::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
|
|
@ -807,4 +483,5 @@ tbody .actionsColumn {
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
0% { transform: rotate(0deg); }
|
0% { transform: rotate(0deg); }
|
||||||
100% { transform: rotate(360deg); }
|
100% { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import React, { useState, useMemo, useRef, useEffect } from 'react';
|
import React, { useState, useMemo, useRef, useEffect } from 'react';
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
import styles from './FormGenerator.module.css';
|
import styles from './FormGeneratorTable.module.css';
|
||||||
import {
|
import {
|
||||||
EditActionButton,
|
EditActionButton,
|
||||||
DeleteActionButton,
|
DeleteActionButton,
|
||||||
|
|
@ -9,13 +9,12 @@ import {
|
||||||
CopyActionButton,
|
CopyActionButton,
|
||||||
ConnectActionButton,
|
ConnectActionButton,
|
||||||
PlayActionButton
|
PlayActionButton
|
||||||
} from './ActionButtons';
|
} from '../ActionButtons';
|
||||||
import { Button } from '../UiComponents/Button';
|
import { formatUnixTimestamp } from '../../../utils/time';
|
||||||
|
import { FormGeneratorControls } from '../FormGeneratorControls';
|
||||||
|
import { CopyableTruncatedValue } from '../../UiComponents/CopyableTruncatedValue';
|
||||||
|
|
||||||
import { IoIosRefresh } from "react-icons/io";
|
// Types for the FormGeneratorTable
|
||||||
import { FaTrash } from "react-icons/fa";
|
|
||||||
|
|
||||||
// Types for the FormGenerator
|
|
||||||
export interface ColumnConfig {
|
export interface ColumnConfig {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
|
@ -31,7 +30,7 @@ export interface ColumnConfig {
|
||||||
cellClassName?: (value: any, row: any) => string; // For custom cell styling
|
cellClassName?: (value: any, row: any) => string; // For custom cell styling
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FormGeneratorProps<T = any> {
|
export interface FormGeneratorTableProps<T = any> {
|
||||||
data: T[];
|
data: T[];
|
||||||
columns?: ColumnConfig[];
|
columns?: ColumnConfig[];
|
||||||
title?: string;
|
title?: string;
|
||||||
|
|
@ -68,15 +67,8 @@ export interface FormGeneratorProps<T = any> {
|
||||||
operationName?: string; // Name of the operation function in hookData
|
operationName?: string; // Name of the operation function in hookData
|
||||||
refreshOperationName?: string; // Name of the refresh operation function in hookData (for connect button)
|
refreshOperationName?: string; // Name of the refresh operation function in hookData (for connect button)
|
||||||
loadingStateName?: string; // Name of the loading state in hookData
|
loadingStateName?: string; // Name of the loading state in hookData
|
||||||
// Edit configuration (for edit buttons)
|
// Navigation (for play button)
|
||||||
editFields?: Array<{
|
navigateTo?: string; // Route to navigate to when play button is clicked
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'readonly';
|
|
||||||
editable?: boolean;
|
|
||||||
required?: boolean;
|
|
||||||
validator?: (value: any) => string | null;
|
|
||||||
}>;
|
|
||||||
}[];
|
}[];
|
||||||
onDelete?: (row: T) => void;
|
onDelete?: (row: T) => void;
|
||||||
onDeleteMultiple?: (rows: 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.
|
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,
|
data,
|
||||||
columns: providedColumns,
|
columns: providedColumns,
|
||||||
searchable = true,
|
searchable = true,
|
||||||
|
|
@ -110,26 +102,71 @@ export function FormGenerator<T extends Record<string, any>>({
|
||||||
className = '',
|
className = '',
|
||||||
getRowDataAttributes,
|
getRowDataAttributes,
|
||||||
hookData
|
hookData
|
||||||
}: FormGeneratorProps<T>) {
|
}: FormGeneratorTableProps<T>) {
|
||||||
const { t } = useLanguage();
|
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[] => {
|
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];
|
const sampleRow = data[0];
|
||||||
return Object.keys(sampleRow).map(key => {
|
const autoDetected = Object.keys(sampleRow).map(key => {
|
||||||
const value = sampleRow[key];
|
const value = sampleRow[key];
|
||||||
let type: ColumnConfig['type'] = 'string';
|
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
|
// Auto-detect type based on value
|
||||||
if (typeof value === 'number') {
|
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') {
|
} else if (typeof value === 'boolean') {
|
||||||
type = 'boolean';
|
type = 'boolean';
|
||||||
} else if (value instanceof Date || (typeof value === 'string' && !isNaN(Date.parse(value)) && value.includes('-'))) {
|
} else if (value instanceof Date || (typeof value === 'string' && !isNaN(Date.parse(value)) && value.includes('-'))) {
|
||||||
type = 'date';
|
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 {
|
return {
|
||||||
|
|
@ -144,7 +181,14 @@ export function FormGenerator<T extends Record<string, any>>({
|
||||||
maxWidth: 400
|
maxWidth: 400
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, [data, providedColumns]);
|
|
||||||
|
// Cache auto-detected columns
|
||||||
|
if (autoDetected.length > 0) {
|
||||||
|
columnsRef.current = autoDetected;
|
||||||
|
}
|
||||||
|
|
||||||
|
return autoDetected;
|
||||||
|
}, [providedColumns, data]);
|
||||||
|
|
||||||
// State management
|
// State management
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
@ -157,6 +201,75 @@ export function FormGenerator<T extends Record<string, any>>({
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [currentPageSize, setCurrentPageSize] = useState(pageSize);
|
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
|
// Refs for action buttons containers to detect clicks outside
|
||||||
const actionButtonsRefs = useRef<Map<number, HTMLDivElement>>(new Map());
|
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 startX = useRef<number>(0);
|
||||||
const startWidth = useRef<number>(0);
|
const startWidth = useRef<number>(0);
|
||||||
|
|
||||||
// Initialize column widths
|
// Initialize column widths - preserve widths even when columns don't change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (detectedColumns.length === 0) return; // Don't clear widths if no columns
|
||||||
|
|
||||||
const initialWidths: Record<string, number> = {};
|
const initialWidths: Record<string, number> = {};
|
||||||
|
|
||||||
detectedColumns.forEach(col => {
|
detectedColumns.forEach(col => {
|
||||||
// Set a default width if none specified to ensure all columns have explicit widths
|
// 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]);
|
}, [detectedColumns]);
|
||||||
|
|
||||||
|
|
||||||
// Filter and search data
|
// Data is already filtered, sorted, and paginated by the backend
|
||||||
const filteredData = useMemo(() => {
|
// No client-side processing needed
|
||||||
let result = [...data];
|
const displayData = data;
|
||||||
|
|
||||||
// Apply search filter
|
// Get pagination info from backend
|
||||||
if (searchTerm && searchable) {
|
const totalPages = useMemo(() => {
|
||||||
const searchLower = searchTerm.toLowerCase();
|
if (!supportsBackendPagination || !hookData?.pagination) {
|
||||||
result = result.filter(row => {
|
return 1; // No pagination if backend doesn't support it
|
||||||
return detectedColumns.some(col => {
|
|
||||||
if (!col.searchable) return false;
|
|
||||||
const value = row[col.key];
|
|
||||||
return String(value).toLowerCase().includes(searchLower);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
return hookData.pagination.totalPages || 1;
|
||||||
// Apply column filters
|
}, [supportsBackendPagination, hookData?.pagination]);
|
||||||
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);
|
|
||||||
|
|
||||||
// Handle sorting
|
// Handle sorting
|
||||||
const handleSort = (key: string) => {
|
const handleSort = (key: string) => {
|
||||||
|
|
@ -312,7 +341,7 @@ export function FormGenerator<T extends Record<string, any>>({
|
||||||
const handleRowSelect = (index: number) => {
|
const handleRowSelect = (index: number) => {
|
||||||
if (!selectable) return;
|
if (!selectable) return;
|
||||||
|
|
||||||
const row = paginatedData[index];
|
const row = displayData[index];
|
||||||
if (isRowSelectable && !isRowSelectable(row)) return;
|
if (isRowSelectable && !isRowSelectable(row)) return;
|
||||||
|
|
||||||
const newSelected = new Set(selectedRows);
|
const newSelected = new Set(selectedRows);
|
||||||
|
|
@ -324,7 +353,7 @@ export function FormGenerator<T extends Record<string, any>>({
|
||||||
setSelectedRows(newSelected);
|
setSelectedRows(newSelected);
|
||||||
|
|
||||||
if (onRowSelect) {
|
if (onRowSelect) {
|
||||||
const selectedData = Array.from(newSelected).map(i => paginatedData[i]);
|
const selectedData = Array.from(newSelected).map(i => displayData[i]);
|
||||||
onRowSelect(selectedData);
|
onRowSelect(selectedData);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -334,7 +363,7 @@ export function FormGenerator<T extends Record<string, any>>({
|
||||||
if (!selectable) return;
|
if (!selectable) return;
|
||||||
|
|
||||||
// Get only selectable rows
|
// Get only selectable rows
|
||||||
const selectableIndices = paginatedData
|
const selectableIndices = displayData
|
||||||
.map((row, index) => ({ row, index }))
|
.map((row, index) => ({ row, index }))
|
||||||
.filter(({ row }) => !isRowSelectable || isRowSelectable(row))
|
.filter(({ row }) => !isRowSelectable || isRowSelectable(row))
|
||||||
.map(({ index }) => index);
|
.map(({ index }) => index);
|
||||||
|
|
@ -345,7 +374,7 @@ export function FormGenerator<T extends Record<string, any>>({
|
||||||
} else {
|
} else {
|
||||||
const allSelectableIndices = new Set(selectableIndices);
|
const allSelectableIndices = new Set(selectableIndices);
|
||||||
setSelectedRows(allSelectableIndices);
|
setSelectedRows(allSelectableIndices);
|
||||||
const selectableData = selectableIndices.map(i => paginatedData[i]);
|
const selectableData = selectableIndices.map(i => displayData[i]);
|
||||||
onRowSelect?.(selectableData);
|
onRowSelect?.(selectableData);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -360,7 +389,7 @@ export function FormGenerator<T extends Record<string, any>>({
|
||||||
newSelected.delete(index);
|
newSelected.delete(index);
|
||||||
setSelectedRows(newSelected);
|
setSelectedRows(newSelected);
|
||||||
if (onRowSelect) {
|
if (onRowSelect) {
|
||||||
const selectedData = Array.from(newSelected).map(i => paginatedData[i]);
|
const selectedData = Array.from(newSelected).map(i => displayData[i]);
|
||||||
onRowSelect(selectedData);
|
onRowSelect(selectedData);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -370,7 +399,7 @@ export function FormGenerator<T extends Record<string, any>>({
|
||||||
// Handle delete multiple items
|
// Handle delete multiple items
|
||||||
const handleDeleteMultiple = () => {
|
const handleDeleteMultiple = () => {
|
||||||
if (onDeleteMultiple && selectedRows.size > 0) {
|
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);
|
onDeleteMultiple(selectedData);
|
||||||
// Clear selection
|
// Clear selection
|
||||||
setSelectedRows(new Set());
|
setSelectedRows(new Set());
|
||||||
|
|
@ -436,64 +465,158 @@ export function FormGenerator<T extends Record<string, any>>({
|
||||||
document.removeEventListener('mouseup', handleMouseUp);
|
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
|
// Format cell value
|
||||||
const formatCellValue = (value: any, column: ColumnConfig, row: T) => {
|
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) {
|
if (column.formatter) {
|
||||||
return column.formatter(value, row);
|
return column.formatter(value, row);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value === null || value === undefined) {
|
// Check if this is a timestamp field even if column type isn't 'date'
|
||||||
return '-';
|
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) {
|
switch (column.type) {
|
||||||
case 'date':
|
case 'date':
|
||||||
try {
|
try {
|
||||||
// Handle Unix timestamps in seconds (backend format) by converting to milliseconds
|
// Handle Unix timestamps in seconds (backend format)
|
||||||
let date: Date;
|
let timestamp: number;
|
||||||
|
|
||||||
if (typeof value === 'number') {
|
if (typeof value === 'number') {
|
||||||
// If it's a number, check if it's in seconds (typical Unix timestamp range)
|
// 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
|
// Unix timestamps in seconds are typically much smaller than milliseconds
|
||||||
if (value < 10000000000) { // Less than year 2286 in seconds
|
if (value < 10000000000) { // Less than year 2286 in seconds
|
||||||
date = new Date(value * 1000); // Convert seconds to milliseconds
|
timestamp = value; // Already in seconds
|
||||||
} else {
|
} else {
|
||||||
date = new Date(value); // Already in milliseconds
|
timestamp = value / 1000; // Convert milliseconds to seconds
|
||||||
}
|
}
|
||||||
} else if (typeof value === 'string') {
|
} else if (typeof value === 'string') {
|
||||||
// Check if it's an ISO date string or date string (contains 'T', '-', or ':')
|
// Check if it's an ISO date string or date string (contains 'T', '-', or ':')
|
||||||
if (value.includes('T') || value.includes('-') || value.includes(':')) {
|
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 {
|
} else {
|
||||||
// Try to parse as number (Unix timestamp as string)
|
// Try to parse as number (Unix timestamp as string)
|
||||||
const numValue = parseFloat(value);
|
const numValue = parseFloat(value);
|
||||||
if (!isNaN(numValue)) {
|
if (!isNaN(numValue)) {
|
||||||
if (numValue < 10000000000) { // Less than year 2286 in seconds
|
if (numValue < 10000000000) { // Less than year 2286 in seconds
|
||||||
date = new Date(numValue * 1000); // Convert seconds to milliseconds
|
timestamp = numValue; // Already in seconds
|
||||||
} else {
|
} else {
|
||||||
date = new Date(numValue); // Already in milliseconds
|
timestamp = numValue / 1000; // Convert milliseconds to seconds
|
||||||
}
|
}
|
||||||
} else {
|
} 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 {
|
} 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 '-';
|
// Use formatUnixTimestamp utility function
|
||||||
const year = date.getFullYear();
|
const formatted = formatUnixTimestamp(timestamp, undefined, {
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
year: 'numeric',
|
||||||
const day = String(date.getDate()).padStart(2, '0');
|
month: '2-digit',
|
||||||
const hours = String(date.getHours()).padStart(2, '0');
|
day: '2-digit',
|
||||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
hour: '2-digit',
|
||||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
minute: '2-digit',
|
||||||
const timezoneOffset = date.getTimezoneOffset();
|
second: '2-digit',
|
||||||
const offsetHours = Math.floor(Math.abs(timezoneOffset) / 60);
|
hour12: false
|
||||||
const offsetMinutes = Math.abs(timezoneOffset) % 60;
|
});
|
||||||
const offsetSign = timezoneOffset <= 0 ? '+' : '-';
|
|
||||||
const timezone = `GMT${offsetSign}${offsetHours}${offsetMinutes > 0 ? ':' + offsetMinutes.toString().padStart(2, '0') : ''}`;
|
return `${formatted.time} ${formatted.timezone}`;
|
||||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds} ${timezone}`;
|
} catch (error) {
|
||||||
} catch {
|
console.error('Error formatting date:', error, value);
|
||||||
return '-';
|
return '-';
|
||||||
}
|
}
|
||||||
case 'boolean':
|
case 'boolean':
|
||||||
|
|
@ -506,219 +629,38 @@ export function FormGenerator<T extends Record<string, any>>({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.formGenerator} ${className}`}>
|
<div className={`${styles.formGeneratorTable} ${className}`}>
|
||||||
|
|
||||||
{(searchable || filterable || (selectable && selectedRows.size > 0)) && (
|
{(searchable || filterable || (selectable && selectedRows.size > 0)) && (
|
||||||
<div className={styles.controls}>
|
<FormGeneratorControls
|
||||||
{/* Delete Controls - Show when items are selected */}
|
fields={detectedColumns}
|
||||||
{selectable && selectedRows.size > 0 && (
|
searchTerm={searchTerm}
|
||||||
<div className={styles.deleteControlsIntegrated}>
|
onSearchChange={setSearchTerm}
|
||||||
{selectedRows.size === 1 && onDelete && (
|
searchFocused={searchFocused}
|
||||||
<Button
|
onSearchFocus={setSearchFocused}
|
||||||
onClick={() => {
|
filters={filters}
|
||||||
const selectedIndex = Array.from(selectedRows)[0];
|
onFilterChange={handleFilter}
|
||||||
const selectedRow = paginatedData[selectedIndex];
|
filterFocused={filterFocused}
|
||||||
handleDeleteSingle(selectedRow, selectedIndex);
|
onFilterFocus={handleFilterFocus}
|
||||||
}}
|
selectedCount={selectedRows.size}
|
||||||
variant="primary"
|
displayData={displayData}
|
||||||
size="sm"
|
onDeleteSingle={selectedRows.size === 1 && onDelete ? () => {
|
||||||
icon={FaTrash}
|
const selectedIndex = Array.from(selectedRows)[0];
|
||||||
>
|
const selectedRow = displayData[selectedIndex];
|
||||||
{t('formgen.delete.single', 'Delete')}
|
handleDeleteSingle(selectedRow, selectedIndex);
|
||||||
</Button>
|
} : undefined}
|
||||||
)}
|
onDeleteMultiple={selectedRows.size > 1 && onDeleteMultiple ? handleDeleteMultiple : undefined}
|
||||||
{selectedRows.size > 1 && onDeleteMultiple && (
|
onRefresh={onRefresh}
|
||||||
<Button
|
searchable={searchable}
|
||||||
onClick={handleDeleteMultiple}
|
filterable={filterable}
|
||||||
variant="primary"
|
selectable={selectable}
|
||||||
size="sm"
|
loading={loading}
|
||||||
icon={FaTrash}
|
onDateFilterChange={(key, value) => handleFilter(key, value)}
|
||||||
>
|
/>
|
||||||
{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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<div className={styles.tableContainer}>
|
<div className={`${styles.tableContainer} ${displayData.length === 0 ? styles.emptyTable : ''}`}>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className={styles.loadingState}>
|
<div className={styles.loadingState}>
|
||||||
<div className={styles.loadingSpinner}></div>
|
<div className={styles.loadingSpinner}></div>
|
||||||
|
|
@ -733,7 +675,7 @@ export function FormGenerator<T extends Record<string, any>>({
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={(() => {
|
checked={(() => {
|
||||||
const selectableIndices = paginatedData
|
const selectableIndices = displayData
|
||||||
.map((row, index) => ({ row, index }))
|
.map((row, index) => ({ row, index }))
|
||||||
.filter(({ row }) => !isRowSelectable || isRowSelectable(row))
|
.filter(({ row }) => !isRowSelectable || isRowSelectable(row))
|
||||||
.map(({ index }) => index);
|
.map(({ index }) => index);
|
||||||
|
|
@ -784,7 +726,14 @@ export function FormGenerator<T extends Record<string, any>>({
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<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) : {};
|
const dataAttributes = getRowDataAttributes ? getRowDataAttributes(row, index) : {};
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
|
|
@ -864,7 +813,6 @@ export function FormGenerator<T extends Record<string, any>>({
|
||||||
{...baseProps}
|
{...baseProps}
|
||||||
onEdit={actionButton.onAction}
|
onEdit={actionButton.onAction}
|
||||||
hookData={hookData}
|
hookData={hookData}
|
||||||
editFields={actionButton.editFields}
|
|
||||||
/>;
|
/>;
|
||||||
case 'delete':
|
case 'delete':
|
||||||
return <DeleteActionButton key={actionIndex} {...baseProps} containerRef={{ current: actionButtonsRefs.current.get(index) || null }} hookData={hookData} />;
|
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':
|
case 'connect':
|
||||||
return <ConnectActionButton key={actionIndex} {...baseProps} hookData={hookData} />;
|
return <ConnectActionButton key={actionIndex} {...baseProps} hookData={hookData} />;
|
||||||
case 'play':
|
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:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -907,7 +863,8 @@ export function FormGenerator<T extends Record<string, any>>({
|
||||||
|
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})
|
||||||
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
|
|
@ -955,7 +912,9 @@ export function FormGenerator<T extends Record<string, any>>({
|
||||||
{t('formgen.pagination.info')
|
{t('formgen.pagination.info')
|
||||||
.replace('{page}', currentPage.toString())
|
.replace('{page}', currentPage.toString())
|
||||||
.replace('{total}', totalPages.toString())
|
.replace('{total}', totalPages.toString())
|
||||||
.replace('{count}', filteredData.length.toString())}
|
.replace('{count}', supportsBackendPagination && hookData?.pagination
|
||||||
|
? hookData.pagination.totalItems.toString()
|
||||||
|
: displayData.length.toString())}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|
@ -982,4 +941,5 @@ export function FormGenerator<T extends Record<string, any>>({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default FormGenerator;
|
export default FormGeneratorTable;
|
||||||
|
|
||||||
3
src/components/FormGenerator/FormGeneratorTable/index.ts
Normal file
3
src/components/FormGenerator/FormGeneratorTable/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { default as FormGeneratorTable, FormGeneratorTable as FormGeneratorTableComponent } from './FormGeneratorTable';
|
||||||
|
export type { ColumnConfig, FormGeneratorTableProps } from './FormGeneratorTable';
|
||||||
|
|
||||||
|
|
@ -1,5 +1,18 @@
|
||||||
export { default as FormGenerator } from './FormGenerator';
|
// Legacy export - FormGenerator is now FormGeneratorTable (for backward compatibility)
|
||||||
export type { ColumnConfig, FormGeneratorProps } from './FormGenerator';
|
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
|
// Re-export action button components and types
|
||||||
export * from './ActionButtons';
|
export * from './ActionButtons';
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
export { default as MitgliederTable } from './MitgliederTable';
|
|
||||||
export { useMitgliederLogic } from './mitgliederLogic';
|
|
||||||
export type * from './mitgliederTypes';
|
|
||||||
|
|
@ -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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -33,7 +33,14 @@ const SidebarItem: React.FC<SidebarItemProps> = React.memo(({
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return;
|
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 (
|
return (
|
||||||
|
|
@ -45,34 +52,40 @@ const SidebarItem: React.FC<SidebarItemProps> = React.memo(({
|
||||||
{/* Text and arrow - hidden when minimized */}
|
{/* Text and arrow - hidden when minimized */}
|
||||||
{!isMinimized && (
|
{!isMinimized && (
|
||||||
<>
|
<>
|
||||||
<Link
|
{hasSubItems ? (
|
||||||
to={isDisabled ? "#" : (item.link || "#")}
|
// For items with submenu, make the entire area clickable to toggle
|
||||||
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 && (
|
|
||||||
<button
|
<button
|
||||||
onClick={toggleSubmenu}
|
onClick={toggleSubmenu}
|
||||||
className={`${styles.arrowButton} ${isDisabled ? styles.disabledArrow : ''}`}
|
className={`${styles.menuTextButton} ${isDisabled ? styles.disabledLink : ''}`}
|
||||||
aria-disabled={isDisabled}
|
aria-disabled={isDisabled}
|
||||||
title={isDisabled ? `${item.name} (Module disabled)` : `Toggle ${item.name} submenu`}
|
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 : ''}`} />
|
<IoIosArrowDown className={`${styles.hassubmenu} ${isOpen ? styles.rotated : ''} ${isDisabled ? styles.disabledArrow : ''}`} />
|
||||||
</button>
|
</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
|
<Link
|
||||||
to={item.link || "#"}
|
to={item.link || "#"}
|
||||||
className={styles.minimizedOverlay}
|
className={styles.minimizedOverlay}
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,18 @@
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
box-shadow: 0px 2px 6px 0px rgba(194, 194, 194, 0.10);*/
|
box-shadow: 0px 2px 6px 0px rgba(194, 194, 194, 0.10);*/
|
||||||
width: 240px;
|
width: 240px;
|
||||||
padding-bottom: 1px;
|
padding: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
max-height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
transition: width 0.3s ease-in-out;
|
transition: width 0.3s ease-in-out;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
border-right: 1px solid var(--color-primary);
|
border-right: 1px solid var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
@ -25,6 +29,7 @@
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
font-family: var(--font-family);
|
font-family: var(--font-family);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden; /* Disable horizontal scrolling */
|
overflow-x: hidden; /* Disable horizontal scrolling */
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,35 @@
|
||||||
line-height: normal;
|
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 {
|
.arrowButton {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
.submenu {
|
.submenu {
|
||||||
position: relative;
|
position: relative;
|
||||||
background: var(--color-primary);
|
|
||||||
border: none;
|
border: none;
|
||||||
border-top-right-radius: 25px;
|
border-top-right-radius: 25px;
|
||||||
border-bottom-right-radius: 25px;
|
border-bottom-right-radius: 25px;
|
||||||
z-index: 1000;
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
width: 220px;
|
width: 220px;
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,20 @@
|
||||||
.user_section {
|
.user_section {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 240px;
|
width: 100%;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: left;
|
align-items: left;
|
||||||
font-family: var(--font-family);
|
font-family: var(--font-family);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-top: auto; /* Push to bottom */
|
margin-top: auto; /* Push to bottom */
|
||||||
margin-bottom: 7px;
|
margin-bottom: 0;
|
||||||
padding-left: 5px;
|
padding-left: 5px;
|
||||||
|
padding-bottom: 7px;
|
||||||
|
padding-right: 5px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
max-height: fit-content;
|
||||||
|
overflow: visible;
|
||||||
|
contain: layout;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user_info {
|
.user_info {
|
||||||
|
|
@ -105,7 +111,7 @@
|
||||||
border: none;
|
border: none;
|
||||||
border-top-right-radius: 25px;
|
border-top-right-radius: 25px;
|
||||||
border-bottom-right-radius: 25px;
|
border-bottom-right-radius: 25px;
|
||||||
z-index: 1000;
|
z-index: 10;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
@ -278,7 +284,7 @@
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transition: opacity 0.3s ease;
|
transition: opacity 0.3s ease;
|
||||||
z-index: 1001;
|
z-index: 11;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logout_popup_minimized .logout_menu_button:hover::after {
|
.logout_popup_minimized .logout_menu_button:hover::after {
|
||||||
|
|
|
||||||
91
src/components/UiComponents/AutoScroll/AutoScroll.module.css
Normal file
91
src/components/UiComponents/AutoScroll/AutoScroll.module.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
168
src/components/UiComponents/AutoScroll/AutoScroll.tsx
Normal file
168
src/components/UiComponents/AutoScroll/AutoScroll.tsx
Normal 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;
|
||||||
|
|
||||||
3
src/components/UiComponents/AutoScroll/index.ts
Normal file
3
src/components/UiComponents/AutoScroll/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { default as AutoScroll } from './AutoScroll';
|
||||||
|
export type { AutoScrollProps } from './AutoScroll';
|
||||||
|
|
||||||
|
|
@ -30,13 +30,17 @@ export interface UploadButtonProps extends BaseButtonProps {
|
||||||
export interface CreateButtonFieldConfig {
|
export interface CreateButtonFieldConfig {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'readonly';
|
type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly';
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
minRows?: number;
|
minRows?: number;
|
||||||
maxRows?: number;
|
maxRows?: number;
|
||||||
validator?: (value: any) => string | null;
|
validator?: (value: any) => string | null;
|
||||||
defaultValue?: any;
|
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 {
|
export interface CreateButtonProps extends BaseButtonProps {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { CreateButtonProps } from '../ButtonTypes';
|
import { CreateButtonProps } from '../ButtonTypes';
|
||||||
import Button from '../Button';
|
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';
|
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||||
|
|
||||||
const CreateButton: React.FC<CreateButtonProps> = ({
|
const CreateButton: React.FC<CreateButtonProps> = ({
|
||||||
|
|
@ -24,25 +25,87 @@ const CreateButton: React.FC<CreateButtonProps> = ({
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const [isPopupOpen, setIsPopupOpen] = 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
|
// Initialize form data with default values
|
||||||
React.useEffect(() => {
|
const initialFormData = useMemo(() => {
|
||||||
const initialData: any = {};
|
const data: any = {};
|
||||||
fields.forEach(field => {
|
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]);
|
}, [fields]);
|
||||||
|
|
||||||
const handleButtonClick = () => {
|
const handleButtonClick = () => {
|
||||||
if (!disabled && !loading && !isCreating) {
|
if (!disabled && !loading && !isCreating) {
|
||||||
// Reset form data
|
|
||||||
const initialData: any = {};
|
|
||||||
fields.forEach(field => {
|
|
||||||
initialData[field.key] = field.defaultValue || '';
|
|
||||||
});
|
|
||||||
setFormData(initialData);
|
|
||||||
setIsPopupOpen(true);
|
setIsPopupOpen(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -86,15 +149,16 @@ const CreateButton: React.FC<CreateButtonProps> = ({
|
||||||
? t(popupTitle, popupTitle)
|
? t(popupTitle, popupTitle)
|
||||||
: popupTitle;
|
: popupTitle;
|
||||||
|
|
||||||
// Resolve language text for fields
|
// Resolve language text for attributes
|
||||||
const resolvedFields = fields.map(field => ({
|
const resolvedAttributes: AttributeDefinition[] = useMemo(() => {
|
||||||
...field,
|
return attributes.map(attr => ({
|
||||||
label: typeof field.label === 'string' ? t(field.label, field.label) : field.label,
|
...attr,
|
||||||
placeholder: field.placeholder
|
label: typeof attr.label === 'string' ? t(attr.label, attr.label) : attr.label,
|
||||||
? (typeof field.placeholder === 'string' ? t(field.placeholder, field.placeholder) : field.placeholder)
|
placeholder: attr.placeholder
|
||||||
: undefined,
|
? (typeof attr.placeholder === 'string' ? t(attr.placeholder, attr.placeholder) : attr.placeholder)
|
||||||
editable: true
|
: undefined
|
||||||
}));
|
}));
|
||||||
|
}, [attributes, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -123,12 +187,13 @@ const CreateButton: React.FC<CreateButtonProps> = ({
|
||||||
size={popupSize}
|
size={popupSize}
|
||||||
closable={!isCreating}
|
closable={!isCreating}
|
||||||
>
|
>
|
||||||
<EditForm
|
<FormGeneratorForm
|
||||||
data={formData}
|
attributes={resolvedAttributes}
|
||||||
fields={resolvedFields}
|
data={initialFormData}
|
||||||
onSave={handleSave}
|
mode="create"
|
||||||
|
onSubmit={handleSave}
|
||||||
onCancel={handleCancel}
|
onCancel={handleCancel}
|
||||||
saveButtonText={t('common.create', 'Create')}
|
submitButtonText={t('common.create', 'Create')}
|
||||||
cancelButtonText={t('common.cancel', 'Cancel')}
|
cancelButtonText={t('common.cancel', 'Cancel')}
|
||||||
/>
|
/>
|
||||||
</Popup>
|
</Popup>
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,52 @@
|
||||||
import React, { useMemo } from 'react';
|
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 { WorkflowFile } from '../../../hooks/usePlayground';
|
||||||
import styles from './ConnectedFilesList.module.css';
|
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 {
|
export interface ConnectedFilesListProps {
|
||||||
files: WorkflowFile[];
|
files: WorkflowFile[];
|
||||||
pendingFiles?: 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>;
|
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>;
|
deletingFiles?: Set<string>;
|
||||||
previewingFiles?: Set<string>;
|
previewingFiles?: Set<string>;
|
||||||
removingFiles?: Set<string>;
|
removingFiles?: Set<string>;
|
||||||
|
|
@ -19,6 +57,7 @@ export interface ConnectedFilesListProps {
|
||||||
export function ConnectedFilesList({
|
export function ConnectedFilesList({
|
||||||
files,
|
files,
|
||||||
pendingFiles = [],
|
pendingFiles = [],
|
||||||
|
actionButtons,
|
||||||
onDelete,
|
onDelete,
|
||||||
onRemove,
|
onRemove,
|
||||||
onAttach,
|
onAttach,
|
||||||
|
|
@ -53,7 +92,7 @@ export function ConnectedFilesList({
|
||||||
const hookData = useMemo(() => ({
|
const hookData = useMemo(() => ({
|
||||||
handleDelete: async (fileId: string) => {
|
handleDelete: async (fileId: string) => {
|
||||||
const file = allFiles.find(f => f.fileId === fileId);
|
const file = allFiles.find(f => f.fileId === fileId);
|
||||||
if (file) {
|
if (file && onDelete) {
|
||||||
await onDelete(file);
|
await onDelete(file);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -66,8 +105,56 @@ export function ConnectedFilesList({
|
||||||
// Refetch handled by parent
|
// Refetch handled by parent
|
||||||
},
|
},
|
||||||
deletingItems: deletingFiles,
|
deletingItems: deletingFiles,
|
||||||
previewingFiles: previewingFiles
|
previewingFiles: previewingFiles,
|
||||||
}), [allFiles, onDelete, deletingFiles, 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) => {
|
const handleView = async (file: WorkflowFile) => {
|
||||||
// View is handled by ViewActionButton's FilePreview component
|
// View is handled by ViewActionButton's FilePreview component
|
||||||
|
|
@ -169,34 +256,99 @@ export function ConnectedFilesList({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.fileActions} onClick={(e) => e.stopPropagation()}>
|
<div className={styles.fileActions} onClick={(e) => e.stopPropagation()}>
|
||||||
<ViewActionButton
|
{defaultActionButtons.map((actionButton, actionIndex) => {
|
||||||
row={file}
|
// Check if button should be shown for this file
|
||||||
onView={handleView}
|
if (actionButton.showOnlyForPending !== undefined) {
|
||||||
disabled={isDeleting || isRemoving}
|
const shouldShow = actionButton.showOnlyForPending ? isPendingFile : !isPendingFile;
|
||||||
loading={isPreviewing}
|
if (!shouldShow) {
|
||||||
hookData={hookData}
|
return null;
|
||||||
idField="fileId"
|
}
|
||||||
nameField="fileName"
|
}
|
||||||
typeField="mimeType"
|
|
||||||
/>
|
const actionTitle = typeof actionButton.title === 'function'
|
||||||
{isPendingFile && onRemove && (
|
? actionButton.title(file)
|
||||||
<RemoveActionButton
|
: actionButton.title;
|
||||||
row={file}
|
const disabledResult = actionButton.disabled ? actionButton.disabled(file) : false;
|
||||||
onRemove={handleRemove}
|
const isLoading = actionButton.loading ? actionButton.loading(file) : false;
|
||||||
disabled={isDeleting}
|
const isProcessing = actionButton.isProcessing ? actionButton.isProcessing(file) : false;
|
||||||
loading={isRemoving}
|
|
||||||
hookData={hookData}
|
const baseProps = {
|
||||||
idField="fileId"
|
row: file,
|
||||||
loadingStateName="removingItems"
|
disabled: disabledResult,
|
||||||
/>
|
loading: isLoading,
|
||||||
)}
|
className: actionButton.className,
|
||||||
<DeleteActionButton
|
title: actionTitle,
|
||||||
row={file}
|
idField: actionButton.idField ?? 'fileId',
|
||||||
hookData={hookData}
|
nameField: actionButton.nameField ?? 'fileName',
|
||||||
idField="fileId"
|
typeField: actionButton.typeField ?? 'mimeType',
|
||||||
operationName="handleDelete"
|
contentField: actionButton.contentField ?? 'content',
|
||||||
loadingStateName="deletingItems"
|
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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
export { default as ConnectedFilesList } from './ConnectedFilesList';
|
export { default as ConnectedFilesList } from './ConnectedFilesList';
|
||||||
export type { ConnectedFilesListProps } from './ConnectedFilesList';
|
export type { ConnectedFilesListProps, ConnectedFilesListActionButton } from './ConnectedFilesList';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
export { default as CopyableTruncatedValue } from './CopyableTruncatedValue';
|
||||||
|
export { default } from './CopyableTruncatedValue';
|
||||||
|
export type { CopyableTruncatedValueProps } from './CopyableTruncatedValue';
|
||||||
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
export { default } from './SelectField';
|
|
||||||
export type { SelectFieldProps, SelectFieldOption } from './SelectField';
|
|
||||||
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
export { default } from './TextInputField';
|
|
||||||
export type { TextInputFieldProps } from './TextInputField';
|
|
||||||
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
export { default } from './ToggleField';
|
|
||||||
export type { ToggleFieldProps } from './ToggleField';
|
|
||||||
|
|
||||||
|
|
@ -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';
|
|
||||||
|
|
||||||
102
src/components/UiComponents/Log/Log.module.css
Normal file
102
src/components/UiComponents/Log/Log.module.css
Normal 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);
|
||||||
|
}
|
||||||
|
|
||||||
188
src/components/UiComponents/Log/Log.tsx
Normal file
188
src/components/UiComponents/Log/Log.tsx
Normal 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;
|
||||||
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Message } from '../MessagesTypes';
|
import { Message } from '../../Messages/MessagesTypes';
|
||||||
import { DocumentItem, MessageMetadata, ActionInfo } from '../MessageParts';
|
import { DocumentItem, MessageMetadata, ActionInfo } from '../../Messages/MessageParts';
|
||||||
import { WorkflowFile } from '../../../../hooks/usePlayground';
|
import { WorkflowFile } from '../../../../hooks/usePlayground';
|
||||||
import styles from '../Messages.module.css';
|
import styles from '../../Messages/Messages.module.css';
|
||||||
import logStyles from './LogMessage.module.css';
|
import logStyles from './LogMessage.module.css';
|
||||||
|
|
||||||
export interface LogMessageProps {
|
export interface LogMessageProps {
|
||||||
3
src/components/UiComponents/Log/LogMessage/index.ts
Normal file
3
src/components/UiComponents/Log/LogMessage/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { LogMessage } from './LogMessage';
|
||||||
|
export type { LogMessageProps } from './LogMessage';
|
||||||
|
|
||||||
49
src/components/UiComponents/Log/LogTypes.ts
Normal file
49
src/components/UiComponents/Log/LogTypes.ts
Normal 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[];
|
||||||
|
}
|
||||||
|
|
||||||
5
src/components/UiComponents/Log/index.ts
Normal file
5
src/components/UiComponents/Log/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
export { default as Log } from './Log';
|
||||||
|
export { default } from './Log';
|
||||||
|
export * from './LogTypes';
|
||||||
|
export * from './LogMessage';
|
||||||
|
|
||||||
|
|
@ -4,11 +4,13 @@
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
font-family: var(--font-family);
|
font-family: var(--font-family);
|
||||||
|
padding: 16px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyContainer {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 16px 20px;
|
|
||||||
background-color: var(--color-surface);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.emptyState {
|
.emptyState {
|
||||||
|
|
@ -44,7 +46,7 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
max-width: 75%;
|
max-width: 65%;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
|
|
@ -60,7 +62,6 @@
|
||||||
background-color: var(--color-surface);
|
background-color: var(--color-surface);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
border-bottom-left-radius: 4px;
|
border-bottom-left-radius: 4px;
|
||||||
border: 1px solid var(--color-primary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Message Metadata */
|
/* Message Metadata */
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,11 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { MessagesProps } from './MessagesTypes';
|
import { MessagesProps } from './MessagesTypes';
|
||||||
import { ChatMessage } from './ChatMessages/ChatMessage';
|
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';
|
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> = ({
|
const Messages: React.FC<MessagesProps> = ({
|
||||||
messages,
|
messages,
|
||||||
className = '',
|
className = '',
|
||||||
|
|
@ -45,18 +24,21 @@ const Messages: React.FC<MessagesProps> = ({
|
||||||
removingFiles,
|
removingFiles,
|
||||||
workflowId
|
workflowId
|
||||||
}) => {
|
}) => {
|
||||||
// Handle empty state
|
|
||||||
if (!messages || messages.length === 0) {
|
if (!messages || messages.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.messagesContainer} ${className}`}>
|
<div className={`${styles.messagesContainer} ${styles.emptyContainer} ${className}`}>
|
||||||
<div className={styles.emptyState}>{emptyMessage}</div>
|
<div className={styles.emptyState}>{emptyMessage}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.messagesContainer} ${className}`}>
|
<AutoScroll
|
||||||
{messages.map((message, index) => {
|
className={className}
|
||||||
|
scrollDependency={messages.length}
|
||||||
|
>
|
||||||
|
<div className={styles.messagesContainer}>
|
||||||
|
{messages.map((message, index) => {
|
||||||
// Use custom render function if provided
|
// Use custom render function if provided
|
||||||
if (renderMessage) {
|
if (renderMessage) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -104,7 +86,8 @@ const Messages: React.FC<MessagesProps> = ({
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</AutoScroll>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
export { default as Messages } from './Messages';
|
export { default as Messages } from './Messages';
|
||||||
export { ChatMessage } from './ChatMessages/ChatMessage';
|
export { ChatMessage } from './ChatMessages/ChatMessage';
|
||||||
export { LogMessage } from './LogMessages/LogMessage';
|
|
||||||
export { DocumentItem, MessageMetadata, ActionInfo } from './MessageParts';
|
export { DocumentItem, MessageMetadata, ActionInfo } from './MessageParts';
|
||||||
export * from './MessageUtils';
|
export * from './MessageUtils';
|
||||||
export type { MessagesProps, Message, MessageDocument, MessageVariant } from './MessagesTypes';
|
export type { MessagesProps, Message, MessageDocument, MessageVariant } from './MessagesTypes';
|
||||||
export type { ChatMessageProps } from './ChatMessages/ChatMessage';
|
export type { ChatMessageProps } from './ChatMessages/ChatMessage';
|
||||||
export type { LogMessageProps } from './LogMessages/LogMessage';
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
z-index: 1000;
|
z-index: 9999;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,17 @@
|
||||||
|
|
||||||
import styles from './ViewForm.module.css';
|
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
|
// ViewForm props - for display-only purposes
|
||||||
export interface ViewFormProps<T = any> {
|
export interface ViewFormProps<T = any> {
|
||||||
data: T;
|
data: T;
|
||||||
fields: EditFieldConfig[];
|
fields: ViewFieldConfig[];
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -17,7 +23,7 @@ export function ViewForm<T extends Record<string, any>>({
|
||||||
}: ViewFormProps<T>) {
|
}: ViewFormProps<T>) {
|
||||||
|
|
||||||
// Render field in view-only mode
|
// Render field in view-only mode
|
||||||
const renderField = (field: EditFieldConfig) => {
|
const renderField = (field: ViewFieldConfig) => {
|
||||||
const value = data[field.key];
|
const value = data[field.key];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,9 @@
|
||||||
export { Popup, default as DefaultPopup } from './Popup';
|
export { Popup, default as DefaultPopup } from './Popup';
|
||||||
export type { PopupProps, PopupAction } from './Popup';
|
export type { PopupProps, PopupAction } from './Popup';
|
||||||
|
|
||||||
// EditForm component
|
// FormGeneratorForm component (recommended for backend-driven forms)
|
||||||
export { EditForm } from './EditForm';
|
export { FormGeneratorForm } from '../../FormGenerator/FormGeneratorForm';
|
||||||
export type { EditFormProps, EditFieldConfig } from './EditForm';
|
export type { FormGeneratorFormProps, AttributeDefinition, AttributeOption } from '../../FormGenerator/FormGeneratorForm';
|
||||||
|
|
||||||
// ViewForm component
|
// ViewForm component
|
||||||
export { ViewForm } from './ViewForm';
|
export { ViewForm } from './ViewForm';
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
228
src/components/UiComponents/WorkflowStatus/WorkflowStatus.tsx
Normal file
228
src/components/UiComponents/WorkflowStatus/WorkflowStatus.tsx
Normal 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;
|
||||||
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
4
src/components/UiComponents/WorkflowStatus/index.ts
Normal file
4
src/components/UiComponents/WorkflowStatus/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
export { default as WorkflowStatus } from './WorkflowStatus';
|
||||||
|
export { default } from './WorkflowStatus';
|
||||||
|
export * from './WorkflowStatusTypes';
|
||||||
|
|
||||||
|
|
@ -6,7 +6,12 @@ export * from './DragDropOverlay';
|
||||||
export * from './TextField';
|
export * from './TextField';
|
||||||
export * from './Messages';
|
export * from './Messages';
|
||||||
export * from './DropdownSelect';
|
export * from './DropdownSelect';
|
||||||
export * from './EditFields';
|
|
||||||
export * from './LocationInput';
|
export * from './LocationInput';
|
||||||
export * from './MapView';
|
export * from './MapView';
|
||||||
export * from './ParcelInfoPanel';
|
export * from './ParcelInfoPanel';
|
||||||
|
export * from './CopyableTruncatedValue';
|
||||||
|
export { Log } from './Log';
|
||||||
|
export * from './Log';
|
||||||
|
export { WorkflowStatus } from './WorkflowStatus';
|
||||||
|
export * from './WorkflowStatus';
|
||||||
|
export * from './AutoScroll';
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { createContext, useContext, ReactNode } from 'react';
|
import React, { createContext, useContext, ReactNode } from 'react';
|
||||||
import { usePek } from './usePek';
|
import { usePek } from '../hooks/usePek';
|
||||||
|
|
||||||
interface PekContextType {
|
interface PekContextType {
|
||||||
// Location input - separate fields
|
// Location input - separate fields
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { createContext, useContext, ReactNode } from 'react';
|
import React, { createContext, useContext, ReactNode } from 'react';
|
||||||
import { usePekTables } from './usePekTables';
|
import { usePekTables } from '../hooks/usePekTables';
|
||||||
|
|
||||||
interface PekTablesContextType {
|
interface PekTablesContextType {
|
||||||
// Tables list
|
// Tables list
|
||||||
|
|
@ -3,6 +3,7 @@ import { useLocation } from 'react-router-dom';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { getPageDataByPath, GenericPageData, PageInstance } from './data';
|
import { getPageDataByPath, GenericPageData, PageInstance } from './data';
|
||||||
import PageRenderer from './PageRenderer';
|
import PageRenderer from './PageRenderer';
|
||||||
|
import { usePermissions } from '../../hooks/usePermissions';
|
||||||
|
|
||||||
interface PageManagerProps {
|
interface PageManagerProps {
|
||||||
loadingComponent: React.ComponentType;
|
loadingComponent: React.ComponentType;
|
||||||
|
|
@ -15,64 +16,36 @@ const PageManager: React.FC<PageManagerProps> = ({
|
||||||
}) => {
|
}) => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [pageInstances, setPageInstances] = useState<Map<string, PageInstance>>(new Map());
|
const [pageInstances, setPageInstances] = useState<Map<string, PageInstance>>(new Map());
|
||||||
|
const { canView } = usePermissions();
|
||||||
|
|
||||||
// Get current path
|
// Get current path
|
||||||
const getCurrentPath = () => {
|
const getCurrentPath = () => {
|
||||||
const path = location.pathname === '/' ? '/dashboard' : location.pathname;
|
const path = location.pathname === '/' ? '' : location.pathname;
|
||||||
return path.startsWith('/') ? path.slice(1) : path;
|
return path.startsWith('/') ? path.slice(1) : path;
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentPath = getCurrentPath();
|
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> => {
|
const checkPageAccess = async (pageData: GenericPageData): Promise<boolean> => {
|
||||||
if (!pageData.privilegeChecker) {
|
|
||||||
return true; // No privilege checker means accessible to all
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await pageData.privilegeChecker();
|
return await canView('UI', pageData.path);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error checking page access for ${pageData.path}:`, error);
|
console.error(`Error checking RBAC access for ${pageData.path}:`, error);
|
||||||
return false;
|
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(() => {
|
useEffect(() => {
|
||||||
const pageData = getPageDataByPath(currentPath);
|
const pageData = getPageDataByPath(currentPath);
|
||||||
|
|
||||||
if (!pageData || pageData.hide || !pageData.moduleEnabled || !checkSpeechAccess(currentPath)) {
|
if (!pageData || pageData.hide || !pageData.moduleEnabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check page access
|
// Check page access
|
||||||
checkPageAccess(pageData).then(hasAccess => {
|
checkPageAccess(pageData).then(hasAccess => {
|
||||||
if (!hasAccess) {
|
if (!hasAccess) {
|
||||||
console.warn(`Access denied for page: ${currentPath}`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -150,7 +123,7 @@ const PageManager: React.FC<PageManagerProps> = ({
|
||||||
|
|
||||||
const pageData = getPageDataByPath(currentPath);
|
const pageData = getPageDataByPath(currentPath);
|
||||||
|
|
||||||
if (!pageData || pageData.hide || !pageData.moduleEnabled || !checkSpeechAccess(currentPath)) {
|
if (!pageData || pageData.hide || !pageData.moduleEnabled) {
|
||||||
return <ErrorComponent />;
|
return <ErrorComponent />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -2,6 +2,24 @@ import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||||
import { allPageData, SidebarItem } from './data';
|
import { allPageData, SidebarItem } from './data';
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
import { resolveLanguageText } from './pageInterface';
|
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 {
|
interface SidebarContextType {
|
||||||
sidebarItems: SidebarItem[];
|
sidebarItems: SidebarItem[];
|
||||||
|
|
@ -31,11 +49,111 @@ export const SidebarProvider: React.FC<SidebarProviderProps> = ({ children }) =>
|
||||||
|
|
||||||
// Get translation function from language context
|
// Get translation function from language context
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
const { canView } = usePermissions();
|
||||||
|
|
||||||
// Get sidebar items from page data
|
// Get sidebar items from page data
|
||||||
const getSidebarItems = async (): Promise<SidebarItem[]> => {
|
const getSidebarItems = async (): Promise<SidebarItem[]> => {
|
||||||
const items: 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)
|
// Get main pages (no parent path)
|
||||||
const mainPages = allPageData
|
const mainPages = allPageData
|
||||||
.filter(page => !page.parentPath && !page.hide && page.showInSidebar !== false)
|
.filter(page => !page.parentPath && !page.hide && page.showInSidebar !== false)
|
||||||
|
|
@ -43,78 +161,57 @@ export const SidebarProvider: React.FC<SidebarProviderProps> = ({ children }) =>
|
||||||
|
|
||||||
// Process each main page
|
// Process each main page
|
||||||
for (const pageData of mainPages) {
|
for (const pageData of mainPages) {
|
||||||
// Check if user has privilege to access this page
|
// Check RBAC permissions
|
||||||
let hasPagePrivilege = true;
|
try {
|
||||||
if (pageData.privilegeChecker) {
|
const hasRBACAccess = await canView('UI', pageData.path);
|
||||||
try {
|
if (!hasRBACAccess) {
|
||||||
hasPagePrivilege = await pageData.privilegeChecker();
|
continue;
|
||||||
console.log(`🔍 Page privilege check for ${pageData.path}:`, { hasPagePrivilege });
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error checking page privilege for ${pageData.path}:`, error);
|
|
||||||
hasPagePrivilege = false;
|
|
||||||
}
|
}
|
||||||
}
|
} catch (error) {
|
||||||
|
console.error(`Error checking RBAC access for ${pageData.path}:`, error);
|
||||||
// Skip this page if user doesn't have privilege
|
|
||||||
if (!hasPagePrivilege) {
|
|
||||||
console.log(`❌ Skipping ${pageData.path} - no privilege`);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this page has subpages and should show them
|
// Check if this page has subpages
|
||||||
if (pageData.hasSubpages && pageData.subpagePrivilegeChecker) {
|
if (pageData.hasSubpages) {
|
||||||
try {
|
// Find all subpages for this parent
|
||||||
const hasSubpagePrivilege = await pageData.subpagePrivilegeChecker();
|
const allSubpages = allPageData.filter(p =>
|
||||||
|
p.parentPath === pageData.path &&
|
||||||
if (hasSubpagePrivilege) {
|
!p.hide &&
|
||||||
// Find all subpages for this parent
|
p.showInSidebar !== false
|
||||||
const subpages = allPageData.filter(p =>
|
);
|
||||||
p.parentPath === pageData.path &&
|
|
||||||
!p.hide &&
|
|
||||||
p.showInSidebar !== false // Include subpages that should show in sidebar
|
|
||||||
);
|
|
||||||
|
|
||||||
if (subpages.length > 0) {
|
// Filter subpages by RBAC access
|
||||||
// Create expandable item with submenu
|
const accessibleSubpages = [];
|
||||||
items.push({
|
for (const subpage of allSubpages) {
|
||||||
id: pageData.id,
|
try {
|
||||||
name: resolveLanguageText(pageData.name, t),
|
const hasSubpageRBACAccess = await canView('UI', subpage.path);
|
||||||
link: `/${pageData.path}`,
|
if (hasSubpageRBACAccess) {
|
||||||
icon: pageData.icon,
|
accessibleSubpages.push(subpage);
|
||||||
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
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} else {
|
} catch (error) {
|
||||||
// No subpage privilege, show as regular non-expandable item
|
console.error(`Error checking RBAC access for subpage ${subpage.path}:`, error);
|
||||||
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 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({
|
items.push({
|
||||||
id: pageData.id,
|
id: pageData.id,
|
||||||
name: resolveLanguageText(pageData.name, t),
|
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
|
// Refresh sidebar items
|
||||||
|
|
|
||||||
|
|
@ -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');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -4,6 +4,22 @@ import { FaGoogle, FaMicrosoft, FaLink } from 'react-icons/fa';
|
||||||
import { privilegeCheckers } from '../../../../utils/privilegeCheckers';
|
import { privilegeCheckers } from '../../../../utils/privilegeCheckers';
|
||||||
import { useConnections } from '../../../../hooks/useConnections';
|
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
|
// Hook factory function for connections data
|
||||||
const createConnectionsHook = () => {
|
const createConnectionsHook = () => {
|
||||||
return () => {
|
return () => {
|
||||||
|
|
@ -16,117 +32,79 @@ const createConnectionsHook = () => {
|
||||||
connectWithPopup,
|
connectWithPopup,
|
||||||
isConnecting,
|
isConnecting,
|
||||||
isLoading,
|
isLoading,
|
||||||
error
|
error,
|
||||||
|
attributes,
|
||||||
|
permissions,
|
||||||
|
pagination
|
||||||
} = useConnections();
|
} = 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
|
// Handle connection deletion
|
||||||
const handleDelete = useCallback(async (connectionId: string) => {
|
const handleDelete = useCallback(async (connectionId: string) => {
|
||||||
try {
|
try {
|
||||||
await deleteConnection(connectionId);
|
await deleteConnection(connectionId);
|
||||||
// Refresh connections after deletion
|
// Refresh connections after deletion - FormGenerator will handle pagination
|
||||||
await fetchConnections();
|
// by calling refetch with current pagination params via its useEffect
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete connection:', error);
|
console.error('Failed to delete connection:', error);
|
||||||
return false;
|
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 {
|
return {
|
||||||
data: connections,
|
data: connections,
|
||||||
loading: isLoading,
|
loading: isLoading,
|
||||||
error: error,
|
error: error,
|
||||||
refetch: async () => { await fetchConnections(); },
|
refetch,
|
||||||
// Operations
|
// Operations
|
||||||
handleDelete,
|
handleDelete,
|
||||||
|
// FormGenerator specific handlers
|
||||||
|
onDelete: handleDeleteSingle,
|
||||||
|
onDeleteMultiple: handleDeleteMultiple,
|
||||||
connectWithPopup,
|
connectWithPopup,
|
||||||
createGoogleConnectionAndAuth,
|
createGoogleConnectionAndAuth,
|
||||||
createMicrosoftConnectionAndAuth,
|
createMicrosoftConnectionAndAuth,
|
||||||
// Loading states
|
// Loading states
|
||||||
isConnecting,
|
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 = {
|
export const connectionsPageData: GenericPageData = {
|
||||||
id: 'administration-connections',
|
id: 'administration-connections',
|
||||||
path: 'administration/connections',
|
path: 'administration/connections',
|
||||||
|
|
@ -149,13 +127,29 @@ export const connectionsPageData: GenericPageData = {
|
||||||
icon: FaGoogle,
|
icon: FaGoogle,
|
||||||
variant: 'primary',
|
variant: 'primary',
|
||||||
onClick: async (hookData: any) => {
|
onClick: async (hookData: any) => {
|
||||||
if (hookData?.createGoogleConnectionAndAuth) {
|
if (!hookData) {
|
||||||
try {
|
console.error('No hookData available for Google connection creation');
|
||||||
await hookData.createGoogleConnectionAndAuth();
|
return;
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to create Google connection:', error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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,
|
icon: FaMicrosoft,
|
||||||
variant: 'primary',
|
variant: 'primary',
|
||||||
onClick: async (hookData: any) => {
|
onClick: async (hookData: any) => {
|
||||||
if (hookData?.createMicrosoftConnectionAndAuth) {
|
if (!hookData) {
|
||||||
try {
|
console.error('No hookData available for Microsoft connection creation');
|
||||||
await hookData.createMicrosoftConnectionAndAuth();
|
return;
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to create Microsoft connection:', error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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',
|
type: 'table',
|
||||||
tableConfig: {
|
tableConfig: {
|
||||||
hookFactory: createConnectionsHook,
|
hookFactory: createConnectionsHook,
|
||||||
columns: connectionsColumns,
|
// Columns are generated dynamically from attributes via hookData.columns
|
||||||
actionButtons: [
|
actionButtons: [
|
||||||
{
|
{
|
||||||
type: 'connect',
|
type: 'connect',
|
||||||
|
|
@ -190,14 +200,26 @@ export const connectionsPageData: GenericPageData = {
|
||||||
idField: 'id',
|
idField: 'id',
|
||||||
statusField: 'status',
|
statusField: 'status',
|
||||||
operationName: 'connectWithPopup',
|
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',
|
type: 'delete',
|
||||||
title: 'connections.action.delete',
|
title: 'connections.action.delete',
|
||||||
idField: 'id',
|
idField: 'id',
|
||||||
operationName: 'handleDelete',
|
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,
|
searchable: true,
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ export const dashboardPageData: GenericPageData = {
|
||||||
|
|
||||||
// Parent page
|
// Parent page
|
||||||
parentPath: 'start',
|
parentPath: 'start',
|
||||||
showInSidebar: false,
|
showInSidebar: true,
|
||||||
|
|
||||||
// Visual
|
// Visual
|
||||||
icon: LuTicket,
|
icon: LuTicket,
|
||||||
|
|
@ -56,6 +56,13 @@ export const dashboardPageData: GenericPageData = {
|
||||||
emptyMessage: 'No messages yet. Start a workflow to see messages here.'
|
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',
|
id: 'workflow-input',
|
||||||
type: 'inputForm',
|
type: 'inputForm',
|
||||||
|
|
@ -83,8 +90,6 @@ export const dashboardPageData: GenericPageData = {
|
||||||
preload: true,
|
preload: true,
|
||||||
moduleEnabled: true,
|
moduleEnabled: true,
|
||||||
|
|
||||||
// Sidebar
|
|
||||||
showInSidebar: false,
|
|
||||||
|
|
||||||
// Lifecycle hooks
|
// Lifecycle hooks
|
||||||
onActivate: async () => {
|
onActivate: async () => {
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue