From aaf64b869ff6564774c65f8c9ad40049ba2b0993 Mon Sep 17 00:00:00 2001 From: Ida Dittrich Date: Mon, 15 Dec 2025 07:32:06 +0100 Subject: [PATCH] resumed backend integration, RBAC focus --- docs/API_ROUTES_DOCUMENTATION.md | 623 -------- docs/LANGUAGE_ARCHITECTURE.md | 362 ----- docs/LOGIN_AND_PRIVILEGE_FLOW.md | 319 ---- docs/LOGIN_FLOW_COMPARISON.md | 321 ---- docs/PAGEMANAGER_SYSTEM_DOCUMENTATION.md | 312 ---- docs/PRIVILEGE_AND_LANGUAGE_FLOW_DETAILED.md | 581 ------- docs/USAGE_GUIDE_PAGES.md | 869 ---------- src/api/authApi.ts | 232 +++ src/api/connectionApi.ts | 221 +++ src/api/fileApi.ts | 203 +++ src/api/permissionApi.ts | 49 + src/api/promptApi.ts | 191 +++ src/api/userApi.ts | 215 +++ src/api/workflowApi.ts | 144 +- .../ContentPreview.module.css} | 4 + .../ContentPreview.tsx} | 21 +- src/components/ContentPreview/index.ts | 3 + .../renderers/ApplicationRenderer.tsx | 3 +- .../renderers/ErrorRenderer.tsx | 3 +- .../renderers/HtmlRenderer.tsx | 3 +- .../renderers/ImageRenderer.tsx | 3 +- .../renderers/JsonRenderer.tsx | 3 +- .../renderers/LoadingRenderer.tsx | 3 +- .../renderers/PdfRenderer.tsx | 3 +- .../renderers/TextRenderer.tsx | 3 +- .../renderers/UnsupportedRenderer.tsx | 3 +- .../renderers/index.ts | 1 + src/components/FilePreview/index.ts | 2 - .../DownloadActionButton.tsx | 32 +- .../EditActionButton.module.css | 25 + .../EditActionButton/EditActionButton.tsx | 225 ++- .../PlayActionButton/PlayActionButton.tsx | 40 +- .../ViewActionButton/ViewActionButton.tsx | 6 +- .../FormGeneratorControls.module.css | 273 ++++ .../FormGeneratorControls.tsx | 304 ++++ .../FormGeneratorControls/index.ts | 3 + .../FormGeneratorForm.module.css} | 130 +- .../FormGeneratorForm/FormGeneratorForm.tsx | 744 +++++++++ .../FormGenerator/FormGeneratorForm/index.ts | 3 + .../FormGeneratorList.module.css | 421 +++++ .../FormGeneratorList/FormGeneratorList.tsx | 787 ++++++++++ .../FormGenerator/FormGeneratorList/index.ts | 3 + .../FormGeneratorTable.module.css} | 395 +---- .../FormGeneratorTable.tsx} | 702 ++++----- .../FormGenerator/FormGeneratorTable/index.ts | 3 + src/components/FormGenerator/index.ts | 17 +- .../Mitglieder/MitgliederTable.module.css | 163 -- src/components/Mitglieder/MitgliederTable.tsx | 264 ---- src/components/Mitglieder/index.ts | 3 - src/components/Mitglieder/mitgliederLogic.tsx | 271 ---- src/components/Mitglieder/mitgliederTypes.ts | 64 - src/components/Sidebar/SidebarItem.tsx | 47 +- .../Sidebar/SidebarStyles/Sidebar.module.css | 7 +- .../SidebarStyles/SidebarItem.module.css | 29 + .../SidebarStyles/SidebarSubmenu.module.css | 2 - .../SidebarStyles/SidebarUser.module.css | 14 +- .../AutoScroll/AutoScroll.module.css | 91 ++ .../UiComponents/AutoScroll/AutoScroll.tsx | 168 ++ .../UiComponents/AutoScroll/index.ts | 3 + .../UiComponents/Button/ButtonTypes.ts | 6 +- .../Button/CreateButton/CreateButton.tsx | 119 +- .../ConnectedFilesList/ConnectedFilesList.tsx | 220 ++- .../UiComponents/ConnectedFilesList/index.ts | 2 +- .../CopyableTruncatedValue.module.css | 34 + .../CopyableTruncatedValue.tsx | 70 + .../CopyableTruncatedValue/index.ts | 4 + .../EditFields/SelectField/SelectField.tsx | 95 -- .../EditFields/SelectField/index.ts | 3 - .../TextInputField/TextInputField.tsx | 76 - .../EditFields/TextInputField/index.ts | 3 - .../EditFields/ToggleField/ToggleField.tsx | 111 -- .../EditFields/ToggleField/index.ts | 3 - .../UiComponents/EditFields/index.ts | 7 - .../UiComponents/Log/Log.module.css | 102 ++ src/components/UiComponents/Log/Log.tsx | 188 +++ .../LogMessage}/LogMessage.module.css | 0 .../LogMessage}/LogMessage.tsx | 6 +- .../UiComponents/Log/LogMessage/index.ts | 3 + src/components/UiComponents/Log/LogTypes.ts | 49 + src/components/UiComponents/Log/index.ts | 5 + .../UiComponents/Messages/Messages.module.css | 9 +- .../UiComponents/Messages/Messages.tsx | 41 +- src/components/UiComponents/Messages/index.ts | 2 - .../UiComponents/Popup/EditForm.tsx | 312 ---- .../UiComponents/Popup/Popup.module.css | 2 +- .../UiComponents/Popup/ViewForm.tsx | 12 +- src/components/UiComponents/Popup/index.ts | 6 +- .../WorkflowStatus/WorkflowStatus.module.css | 153 ++ .../WorkflowStatus/WorkflowStatus.tsx | 228 +++ .../WorkflowStatus/WorkflowStatusTypes.ts | 50 + .../UiComponents/WorkflowStatus/index.ts | 4 + src/components/UiComponents/index.ts | 7 +- src/{hooks => contexts}/PekContext.tsx | 2 +- src/{hooks => contexts}/PekTablesContext.tsx | 2 +- src/core/PageManager/PageManager.tsx | 43 +- src/core/PageManager/PageRenderer.tsx | 1328 +++++++++++----- src/core/PageManager/SidebarProvider.tsx | 230 ++- .../PageManager/data/pages/administration.ts | 68 - .../PageManager/data/pages/connections.ts | 220 +-- src/core/PageManager/data/pages/dashboard.ts | 11 +- src/core/PageManager/data/pages/files.ts | 319 ++-- src/core/PageManager/data/pages/index.ts | 12 +- src/core/PageManager/data/pages/pek-tables.ts | 4 +- .../pages/pek-tables/PekTablesDropdown.tsx | 2 +- .../pages/pek-tables/PekTablesPageWrapper.tsx | 2 +- .../data/pages/pek-tables/PekTablesTable.tsx | 2 +- src/core/PageManager/data/pages/pek.ts | 2 +- .../data/pages/pek/PekLocationInput.tsx | 2 +- .../PageManager/data/pages/pek/PekMapView.tsx | 2 +- .../data/pages/pek/PekPageWrapper.tsx | 2 +- src/core/PageManager/data/pages/prompts.ts | 146 +- src/core/PageManager/data/pages/start.ts | 68 - .../PageManager/data/pages/team-bereich.ts | 126 -- .../PageManager/data/pages/team-members.ts | 293 ++++ src/core/PageManager/data/pages/workflows.ts | 225 +-- src/core/PageManager/pageInterface.ts | 27 +- src/hooks/playground/playgroundUtils.ts | 62 + src/hooks/playground/useDashboardInputForm.ts | 642 ++++++++ src/hooks/playground/useWorkflowLifecycle.ts | 231 +++ src/hooks/playground/useWorkflowOperations.ts | 2 + src/hooks/playground/useWorkflows.ts | 65 + src/hooks/privilegeTestUtils.ts | 106 -- src/hooks/useAuthentication.ts | 189 +-- src/hooks/useConnections.ts | 281 ++-- src/hooks/useFiles.ts | 432 +++-- src/hooks/usePermissions.ts | 195 +++ src/hooks/usePlayground.ts | 1394 +---------------- src/hooks/usePrompts.ts | 403 ++++- src/hooks/useSharePointTest.ts | 296 ---- src/hooks/useUsers.ts | 530 +++++-- src/hooks/useWorkflows.ts | 602 +++++-- src/locales/de.ts | 16 + src/locales/en.ts | 16 + src/locales/fr.ts | 16 + src/styles/pages.module.css | 221 ++- src/utils/time.ts | 76 + 136 files changed, 11391 insertions(+), 9393 deletions(-) delete mode 100644 docs/API_ROUTES_DOCUMENTATION.md delete mode 100644 docs/LANGUAGE_ARCHITECTURE.md delete mode 100644 docs/LOGIN_AND_PRIVILEGE_FLOW.md delete mode 100644 docs/LOGIN_FLOW_COMPARISON.md delete mode 100644 docs/PAGEMANAGER_SYSTEM_DOCUMENTATION.md delete mode 100644 docs/PRIVILEGE_AND_LANGUAGE_FLOW_DETAILED.md delete mode 100644 docs/USAGE_GUIDE_PAGES.md create mode 100644 src/api/authApi.ts create mode 100644 src/api/connectionApi.ts create mode 100644 src/api/fileApi.ts create mode 100644 src/api/permissionApi.ts create mode 100644 src/api/promptApi.ts create mode 100644 src/api/userApi.ts rename src/components/{FilePreview/FilePreview.module.css => ContentPreview/ContentPreview.module.css} (99%) rename src/components/{FilePreview/FilePreview.tsx => ContentPreview/ContentPreview.tsx} (94%) create mode 100644 src/components/ContentPreview/index.ts rename src/components/{FilePreview => ContentPreview}/renderers/ApplicationRenderer.tsx (90%) rename src/components/{FilePreview => ContentPreview}/renderers/ErrorRenderer.tsx (91%) rename src/components/{FilePreview => ContentPreview}/renderers/HtmlRenderer.tsx (94%) rename src/components/{FilePreview => ContentPreview}/renderers/ImageRenderer.tsx (93%) rename src/components/{FilePreview => ContentPreview}/renderers/JsonRenderer.tsx (99%) rename src/components/{FilePreview => ContentPreview}/renderers/LoadingRenderer.tsx (86%) rename src/components/{FilePreview => ContentPreview}/renderers/PdfRenderer.tsx (96%) rename src/components/{FilePreview => ContentPreview}/renderers/TextRenderer.tsx (95%) rename src/components/{FilePreview => ContentPreview}/renderers/UnsupportedRenderer.tsx (93%) rename src/components/{FilePreview => ContentPreview}/renderers/index.ts (99%) delete mode 100644 src/components/FilePreview/index.ts create mode 100644 src/components/FormGenerator/ActionButtons/EditActionButton/EditActionButton.module.css create mode 100644 src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.module.css create mode 100644 src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.tsx create mode 100644 src/components/FormGenerator/FormGeneratorControls/index.ts rename src/components/{UiComponents/Popup/EditForm.module.css => FormGenerator/FormGeneratorForm/FormGeneratorForm.module.css} (65%) create mode 100644 src/components/FormGenerator/FormGeneratorForm/FormGeneratorForm.tsx create mode 100644 src/components/FormGenerator/FormGeneratorForm/index.ts create mode 100644 src/components/FormGenerator/FormGeneratorList/FormGeneratorList.module.css create mode 100644 src/components/FormGenerator/FormGeneratorList/FormGeneratorList.tsx create mode 100644 src/components/FormGenerator/FormGeneratorList/index.ts rename src/components/FormGenerator/{FormGenerator.module.css => FormGeneratorTable/FormGeneratorTable.module.css} (56%) rename src/components/FormGenerator/{FormGenerator.tsx => FormGeneratorTable/FormGeneratorTable.tsx} (56%) create mode 100644 src/components/FormGenerator/FormGeneratorTable/index.ts delete mode 100644 src/components/Mitglieder/MitgliederTable.module.css delete mode 100644 src/components/Mitglieder/MitgliederTable.tsx delete mode 100644 src/components/Mitglieder/index.ts delete mode 100644 src/components/Mitglieder/mitgliederLogic.tsx delete mode 100644 src/components/Mitglieder/mitgliederTypes.ts create mode 100644 src/components/UiComponents/AutoScroll/AutoScroll.module.css create mode 100644 src/components/UiComponents/AutoScroll/AutoScroll.tsx create mode 100644 src/components/UiComponents/AutoScroll/index.ts create mode 100644 src/components/UiComponents/CopyableTruncatedValue/CopyableTruncatedValue.module.css create mode 100644 src/components/UiComponents/CopyableTruncatedValue/CopyableTruncatedValue.tsx create mode 100644 src/components/UiComponents/CopyableTruncatedValue/index.ts delete mode 100644 src/components/UiComponents/EditFields/SelectField/SelectField.tsx delete mode 100644 src/components/UiComponents/EditFields/SelectField/index.ts delete mode 100644 src/components/UiComponents/EditFields/TextInputField/TextInputField.tsx delete mode 100644 src/components/UiComponents/EditFields/TextInputField/index.ts delete mode 100644 src/components/UiComponents/EditFields/ToggleField/ToggleField.tsx delete mode 100644 src/components/UiComponents/EditFields/ToggleField/index.ts delete mode 100644 src/components/UiComponents/EditFields/index.ts create mode 100644 src/components/UiComponents/Log/Log.module.css create mode 100644 src/components/UiComponents/Log/Log.tsx rename src/components/UiComponents/{Messages/LogMessages => Log/LogMessage}/LogMessage.module.css (100%) rename src/components/UiComponents/{Messages/LogMessages => Log/LogMessage}/LogMessage.tsx (93%) create mode 100644 src/components/UiComponents/Log/LogMessage/index.ts create mode 100644 src/components/UiComponents/Log/LogTypes.ts create mode 100644 src/components/UiComponents/Log/index.ts delete mode 100644 src/components/UiComponents/Popup/EditForm.tsx create mode 100644 src/components/UiComponents/WorkflowStatus/WorkflowStatus.module.css create mode 100644 src/components/UiComponents/WorkflowStatus/WorkflowStatus.tsx create mode 100644 src/components/UiComponents/WorkflowStatus/WorkflowStatusTypes.ts create mode 100644 src/components/UiComponents/WorkflowStatus/index.ts rename src/{hooks => contexts}/PekContext.tsx (97%) rename src/{hooks => contexts}/PekTablesContext.tsx (96%) delete mode 100644 src/core/PageManager/data/pages/administration.ts delete mode 100644 src/core/PageManager/data/pages/start.ts delete mode 100644 src/core/PageManager/data/pages/team-bereich.ts create mode 100644 src/core/PageManager/data/pages/team-members.ts create mode 100644 src/hooks/playground/playgroundUtils.ts create mode 100644 src/hooks/playground/useDashboardInputForm.ts create mode 100644 src/hooks/playground/useWorkflowLifecycle.ts create mode 100644 src/hooks/playground/useWorkflowOperations.ts create mode 100644 src/hooks/playground/useWorkflows.ts delete mode 100644 src/hooks/privilegeTestUtils.ts create mode 100644 src/hooks/usePermissions.ts delete mode 100644 src/hooks/useSharePointTest.ts create mode 100644 src/utils/time.ts diff --git a/docs/API_ROUTES_DOCUMENTATION.md b/docs/API_ROUTES_DOCUMENTATION.md deleted file mode 100644 index d790c1a..0000000 --- a/docs/API_ROUTES_DOCUMENTATION.md +++ /dev/null @@ -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 -``` - diff --git a/docs/LANGUAGE_ARCHITECTURE.md b/docs/LANGUAGE_ARCHITECTURE.md deleted file mode 100644 index 4a31cd3..0000000 --- a/docs/LANGUAGE_ARCHITECTURE.md +++ /dev/null @@ -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. - diff --git a/docs/LOGIN_AND_PRIVILEGE_FLOW.md b/docs/LOGIN_AND_PRIVILEGE_FLOW.md deleted file mode 100644 index f185030..0000000 --- a/docs/LOGIN_AND_PRIVILEGE_FLOW.md +++ /dev/null @@ -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 => { - 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 - diff --git a/docs/LOGIN_FLOW_COMPARISON.md b/docs/LOGIN_FLOW_COMPARISON.md deleted file mode 100644 index 496bad3..0000000 --- a/docs/LOGIN_FLOW_COMPARISON.md +++ /dev/null @@ -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 - -``` - -**AFTER:** -```typescript -const { currentLanguage } = useLanguage(); - - -``` - ---- - -## 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 - diff --git a/docs/PAGEMANAGER_SYSTEM_DOCUMENTATION.md b/docs/PAGEMANAGER_SYSTEM_DOCUMENTATION.md deleted file mode 100644 index 513b834..0000000 --- a/docs/PAGEMANAGER_SYSTEM_DOCUMENTATION.md +++ /dev/null @@ -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) -- 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 diff --git a/docs/PRIVILEGE_AND_LANGUAGE_FLOW_DETAILED.md b/docs/PRIVILEGE_AND_LANGUAGE_FLOW_DETAILED.md deleted file mode 100644 index 6aadb87..0000000 --- a/docs/PRIVILEGE_AND_LANGUAGE_FLOW_DETAILED.md +++ /dev/null @@ -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 => { - 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) - -

{resolveLanguageText(pageData.title, language)}

-

{resolveLanguageText(pageData.subtitle, language)}

-``` - -**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 && } -{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 - -``` - -**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 - -// 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 - -``` - -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) -})); - - -``` - -### ~~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 - diff --git a/docs/USAGE_GUIDE_PAGES.md b/docs/USAGE_GUIDE_PAGES.md deleted file mode 100644 index 114ae18..0000000 --- a/docs/USAGE_GUIDE_PAGES.md +++ /dev/null @@ -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([]); - const [isRefetching, setIsRefetching] = useState(false); - const { request, isLoading: loading, error, clearCache } = useApiRequest(); - - 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>(new Set()); - const [updatingItems, setUpdatingItems] = useState>(new Set()); - const [deletingItems, setDeletingItems] = useState>(new Set()); - const { request } = useApiRequest(); - - const handleCreate = async (itemData: Partial) => { - 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) => { - 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([]); - - // 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 ( -
-

Custom Page Content

- {/* Your custom UI here */} -
- ); -}; - -// 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) => ( - - {value} - - ) -}, -{ - 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` 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 ( -
- {hookData.loading &&
Loading...
} - {hookData.error &&
Error: {hookData.error}
} -
- ); - } -} -``` - ---- - -## 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` 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! πŸš€ - diff --git a/src/api/authApi.ts b/src/api/authApi.ts new file mode 100644 index 0000000..e4cc292 --- /dev/null +++ b/src/api/authApi.ts @@ -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) => Promise; + +// ============================================================================ +// API REQUEST FUNCTIONS +// ============================================================================ + +/** + * Login with username and password + * Endpoint: POST /api/local/login + */ +export async function loginApi(loginData: LoginRequest): Promise { + // 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 = { + '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('/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 { + 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(endpoint); + return response.data; +} + +/** + * Register a new user + * Endpoint: POST /api/local/register + */ +export async function registerApi(registerData: RegisterData): Promise { + // 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 = { + 'Content-Type': 'application/json' + }; + + // Add CSRF token if available (for new security implementation) + addCSRFTokenToHeaders(headers); + + const response = await api.post('/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 { + const response = await request({ + 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 { + const response = await api.get('/api/local/available', { + params: { + username, + authenticationAuthority + } + }); + + return response.data; +} + +/** + * Logout current user + * Endpoint: POST /api/local/logout + */ +export async function logoutApi(): Promise { + await api.post('/api/local/logout'); +} + diff --git a/src/api/connectionApi.ts b/src/api/connectionApi.ts new file mode 100644 index 0000000..606a20b --- /dev/null +++ b/src/api/connectionApi.ts @@ -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; + search?: string; +} + +export interface PaginatedResponse { + 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) => Promise; + +// ============================================================================ +// API REQUEST FUNCTIONS +// ============================================================================ + +/** + * Fetch connection attributes from backend + * Endpoint: GET /api/attributes/UserConnection + */ +export async function fetchConnectionAttributes(request: ApiRequestFunction): Promise { + // 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 | 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 | 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 { + return await request({ + 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 { + return await request({ + 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 +): Promise { + return await request({ + 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 { + return await request({ + 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 { + return await request({ + url: `/api/connections/${connectionId}/refresh-google-token`, + method: 'post' + }); +} + diff --git a/src/api/fileApi.ts b/src/api/fileApi.ts new file mode 100644 index 0000000..037a0cf --- /dev/null +++ b/src/api/fileApi.ts @@ -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; + search?: string; +} + +export interface PaginatedResponse { + 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) => Promise; + +// ============================================================================ +// API REQUEST FUNCTIONS +// ============================================================================ + +/** + * Fetch file attributes from backend + * Endpoint: GET /api/attributes/FileItem + */ +export async function fetchFileAttributes(request: ApiRequestFunction): Promise { + const data = await request({ + 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 | 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 | 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 { + try { + const data = await request({ + 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 +): Promise { + return await request({ + 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 { + 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> { + 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 + diff --git a/src/api/permissionApi.ts b/src/api/permissionApi.ts new file mode 100644 index 0000000..a367f6f --- /dev/null +++ b/src/api/permissionApi.ts @@ -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) => Promise; + +// ============================================================================ +// 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 { + const params: Record = { context }; + if (item) { + params.item = item; + } + + const data = await request({ + url: '/api/rbac/permissions', + method: 'get', + params + }); + + return data; +} + diff --git a/src/api/promptApi.ts b/src/api/promptApi.ts new file mode 100644 index 0000000..8743153 --- /dev/null +++ b/src/api/promptApi.ts @@ -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; + search?: string; +} + +export interface PaginatedResponse { + 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) => Promise; + +// ============================================================================ +// API REQUEST FUNCTIONS +// ============================================================================ + +/** + * Fetch prompt attributes from backend + * Endpoint: GET /api/attributes/Prompt + */ +export async function fetchPromptAttributes(request: ApiRequestFunction): Promise { + // 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 | 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 | 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 { + try { + const data = await request({ + 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 { + return await request({ + 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 { + return await request({ + 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 { + await request({ + url: `/api/prompts/${promptId}`, + method: 'delete' + }); +} + diff --git a/src/api/userApi.ts b/src/api/userApi.ts new file mode 100644 index 0000000..68dc887 --- /dev/null +++ b/src/api/userApi.ts @@ -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>; + +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; + search?: string; +} + +export interface PaginatedResponse { + 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) => Promise; + +// ============================================================================ +// 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 { + let endpoint = '/api/local/me'; + + if (authAuthority === 'msft') { + endpoint = '/api/msft/me'; + } else if (authAuthority === 'google') { + endpoint = '/api/google/me'; + } + + return await request({ + 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 { + 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 { + // 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 | 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 | 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 { + try { + const data = await request({ + 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 +): Promise { + return await request({ + 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 { + return await request({ + 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 { + await request({ + url: `/api/users/${userId}`, + method: 'delete' + }); +} + diff --git a/src/api/workflowApi.ts b/src/api/workflowApi.ts index ad3f0d0..b57f673 100644 --- a/src/api/workflowApi.ts +++ b/src/api/workflowApi.ts @@ -86,8 +86,8 @@ export async function fetchWorkflows(request: ApiRequestFunction): Promise { - return await request({ +): Promise { + return await request({ url: `/api/workflows/${workflowId}`, method: 'get' }); @@ -100,11 +100,20 @@ export async function fetchWorkflow( export async function fetchWorkflowStatus( request: ApiRequestFunction, workflowId: string -): Promise { - return await request({ +): Promise { + const data = await request({ url: `/api/workflows/${workflowId}/status`, method: 'get' }); + + if (data && typeof data === 'object') { + if (data.status) { + return { status: data.status }; + } + return data; + } + + return null; } /** @@ -118,12 +127,26 @@ export async function fetchWorkflowMessages( messageId?: string ): Promise { const params = messageId ? { messageId } : undefined; - const data = await request({ + const data = await request({ url: `/api/workflows/${workflowId}/messages`, method: 'get', params }); - return Array.isArray(data) ? data : []; + + if (Array.isArray(data)) { + return data; + } + + if (data && typeof data === 'object') { + if (Array.isArray(data.messages)) { + return data.messages; + } + if (Array.isArray(data.data)) { + return data.data; + } + } + + return []; } /** @@ -137,12 +160,26 @@ export async function fetchWorkflowLogs( logId?: string ): Promise { const params = logId ? { logId } : undefined; - const data = await request({ + const data = await request({ url: `/api/workflows/${workflowId}/logs`, method: 'get', params }); - return Array.isArray(data) ? data : []; + + if (Array.isArray(data)) { + return data; + } + + if (data && typeof data === 'object') { + if (Array.isArray(data.logs)) { + return data.logs; + } + if (Array.isArray(data.data)) { + return data.data; + } + } + + return []; } /** @@ -185,24 +222,44 @@ export async function fetchChatData( export async function startWorkflowApi( request: ApiRequestFunction, workflowData: StartWorkflowRequest, - options?: { workflowId?: string; workflowMode?: 'Actionplan' | 'React' } + options?: { workflowId?: string; workflowMode?: 'Dynamic' | 'Automation' } ): Promise { const params: Record = {}; + + // workflowMode is REQUIRED according to API spec + if (options?.workflowMode) { + params.workflowMode = options.workflowMode; + } else { + // Default to 'Dynamic' if not provided (though it should always be provided) + params.workflowMode = 'Dynamic'; + } + if (options?.workflowId) { params.workflowId = options.workflowId; } - if (options?.workflowMode) { - params.workflowMode = options.workflowMode; - } + + // Request body uses 'prompt' field (not 'input') according to API spec + const requestBody: any = { + prompt: workflowData.prompt, + ...(workflowData.listFileId && workflowData.listFileId.length > 0 && { listFileId: workflowData.listFileId }), + ...(workflowData.userLanguage && { userLanguage: workflowData.userLanguage }), + ...(workflowData.metadata && { metadata: workflowData.metadata }) + }; const requestConfig = { url: '/api/chat/playground/start', method: 'post' as const, - data: workflowData, - params: Object.keys(params).length > 0 ? params : undefined + data: requestBody, + params: params // Always include workflowMode }; - console.log('πŸ“€ startWorkflow request:', requestConfig); + // Log full request details + console.log('πŸ“€ Full startWorkflow request details:'); + console.log(' URL:', requestConfig.url); + console.log(' Method:', requestConfig.method); + console.log(' Query Parameters:', params); + console.log(' Request Body:', JSON.stringify(requestBody, null, 2)); + console.log(' Full Request Config:', JSON.stringify(requestConfig, null, 2)); const response = await request(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 { + const data = await request({ + 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; +} + diff --git a/src/components/FilePreview/FilePreview.module.css b/src/components/ContentPreview/ContentPreview.module.css similarity index 99% rename from src/components/FilePreview/FilePreview.module.css rename to src/components/ContentPreview/ContentPreview.module.css index e6bb776..fd8cf40 100644 --- a/src/components/FilePreview/FilePreview.module.css +++ b/src/components/ContentPreview/ContentPreview.module.css @@ -841,3 +841,7 @@ word-wrap: inherit; } +.contentPreviewPopup { + /* Popup-specific styles if needed */ +} + diff --git a/src/components/FilePreview/FilePreview.tsx b/src/components/ContentPreview/ContentPreview.tsx similarity index 94% rename from src/components/FilePreview/FilePreview.tsx rename to src/components/ContentPreview/ContentPreview.tsx index 76e8c57..7e9e101 100644 --- a/src/components/FilePreview/FilePreview.tsx +++ b/src/components/ContentPreview/ContentPreview.tsx @@ -16,9 +16,9 @@ import { LoadingRenderer, ErrorRenderer } from './renderers'; -import styles from './FilePreview.module.css'; +import styles from './ContentPreview.module.css'; -export interface FilePreviewProps { +export interface ContentPreviewProps { isOpen: boolean; onClose: () => void; fileId: string; @@ -26,20 +26,20 @@ export interface FilePreviewProps { mimeType?: string; } -export function FilePreview({ +export function ContentPreview({ isOpen, onClose, fileId, fileName, mimeType -}: FilePreviewProps) { +}: ContentPreviewProps) { const { t } = useLanguage(); const { handleFilePreview, handleFileDownload, previewingFiles, previewError, downloadingFiles } = useFileOperations(); // Debug logging to see what data we're receiving useEffect(() => { if (isOpen && import.meta.env.DEV) { - console.log('FilePreview received:', { fileId, fileName, mimeType }); + console.log('ContentPreview received:', { fileId, fileName, mimeType }); } }, [isOpen, fileId, fileName, mimeType]); const [previewUrl, setPreviewUrl] = useState(null); @@ -162,7 +162,7 @@ export function FilePreview({ const renderPreview = () => { // Handle text content in PDF files (corrupted files) - check this first if (previewContent && !previewUrl && mimeType === 'application/pdf') { - console.log('πŸ” FilePreview: Rendering corrupted PDF with text content'); + console.log('πŸ” ContentPreview: Rendering corrupted PDF with text content'); return (
@@ -304,6 +304,5 @@ export function FilePreview({ ); } -export default FilePreview; - +export default ContentPreview; diff --git a/src/components/ContentPreview/index.ts b/src/components/ContentPreview/index.ts new file mode 100644 index 0000000..88f1fae --- /dev/null +++ b/src/components/ContentPreview/index.ts @@ -0,0 +1,3 @@ +export { ContentPreview } from './ContentPreview'; +export type { ContentPreviewProps } from './ContentPreview'; + diff --git a/src/components/FilePreview/renderers/ApplicationRenderer.tsx b/src/components/ContentPreview/renderers/ApplicationRenderer.tsx similarity index 90% rename from src/components/FilePreview/renderers/ApplicationRenderer.tsx rename to src/components/ContentPreview/renderers/ApplicationRenderer.tsx index f3e3c60..2495ec3 100644 --- a/src/components/FilePreview/renderers/ApplicationRenderer.tsx +++ b/src/components/ContentPreview/renderers/ApplicationRenderer.tsx @@ -1,4 +1,4 @@ -import styles from '../FilePreview.module.css'; +import styles from '../ContentPreview.module.css'; interface ApplicationRendererProps { previewUrl: string; @@ -19,3 +19,4 @@ export function ApplicationRenderer({ previewUrl, fileName, mimeType, onError }: /> ); } + diff --git a/src/components/FilePreview/renderers/ErrorRenderer.tsx b/src/components/ContentPreview/renderers/ErrorRenderer.tsx similarity index 91% rename from src/components/FilePreview/renderers/ErrorRenderer.tsx rename to src/components/ContentPreview/renderers/ErrorRenderer.tsx index 4f7be3c..c7a3748 100644 --- a/src/components/FilePreview/renderers/ErrorRenderer.tsx +++ b/src/components/ContentPreview/renderers/ErrorRenderer.tsx @@ -1,5 +1,5 @@ import { useLanguage } from '../../../providers/language/LanguageContext'; -import styles from '../FilePreview.module.css'; +import styles from '../ContentPreview.module.css'; interface ErrorRendererProps { error: string; @@ -22,3 +22,4 @@ export function ErrorRenderer({ error, onRetry }: ErrorRendererProps) {
); } + diff --git a/src/components/FilePreview/renderers/HtmlRenderer.tsx b/src/components/ContentPreview/renderers/HtmlRenderer.tsx similarity index 94% rename from src/components/FilePreview/renderers/HtmlRenderer.tsx rename to src/components/ContentPreview/renderers/HtmlRenderer.tsx index f22a52b..9627884 100644 --- a/src/components/FilePreview/renderers/HtmlRenderer.tsx +++ b/src/components/ContentPreview/renderers/HtmlRenderer.tsx @@ -1,4 +1,4 @@ -import styles from '../FilePreview.module.css'; +import styles from '../ContentPreview.module.css'; interface HtmlRendererProps { previewUrl: string; @@ -39,3 +39,4 @@ export function HtmlRenderer({ previewUrl, fileName, onError }: HtmlRendererProp /> ); } + diff --git a/src/components/FilePreview/renderers/ImageRenderer.tsx b/src/components/ContentPreview/renderers/ImageRenderer.tsx similarity index 93% rename from src/components/FilePreview/renderers/ImageRenderer.tsx rename to src/components/ContentPreview/renderers/ImageRenderer.tsx index 290ebf3..9940ecd 100644 --- a/src/components/FilePreview/renderers/ImageRenderer.tsx +++ b/src/components/ContentPreview/renderers/ImageRenderer.tsx @@ -1,4 +1,4 @@ -import styles from '../FilePreview.module.css'; +import styles from '../ContentPreview.module.css'; interface ImageRendererProps { previewUrl: string; @@ -32,3 +32,4 @@ export function ImageRenderer({ previewUrl, fileName, onError }: ImageRendererPr /> ); } + diff --git a/src/components/FilePreview/renderers/JsonRenderer.tsx b/src/components/ContentPreview/renderers/JsonRenderer.tsx similarity index 99% rename from src/components/FilePreview/renderers/JsonRenderer.tsx rename to src/components/ContentPreview/renderers/JsonRenderer.tsx index 893ec55..b458f46 100644 --- a/src/components/FilePreview/renderers/JsonRenderer.tsx +++ b/src/components/ContentPreview/renderers/JsonRenderer.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { useLanguage } from '../../../providers/language/LanguageContext'; -import styles from '../FilePreview.module.css'; +import styles from '../ContentPreview.module.css'; interface JsonRendererProps { previewContent: string; @@ -504,3 +504,4 @@ export function JsonRenderer({ previewContent, fileName }: JsonRendererProps) { ); } } + diff --git a/src/components/FilePreview/renderers/LoadingRenderer.tsx b/src/components/ContentPreview/renderers/LoadingRenderer.tsx similarity index 86% rename from src/components/FilePreview/renderers/LoadingRenderer.tsx rename to src/components/ContentPreview/renderers/LoadingRenderer.tsx index 2cf4f7c..bf4c40b 100644 --- a/src/components/FilePreview/renderers/LoadingRenderer.tsx +++ b/src/components/ContentPreview/renderers/LoadingRenderer.tsx @@ -1,5 +1,5 @@ import { useLanguage } from '../../../providers/language/LanguageContext'; -import styles from '../FilePreview.module.css'; +import styles from '../ContentPreview.module.css'; export function LoadingRenderer() { const { t } = useLanguage(); @@ -11,3 +11,4 @@ export function LoadingRenderer() { ); } + diff --git a/src/components/FilePreview/renderers/PdfRenderer.tsx b/src/components/ContentPreview/renderers/PdfRenderer.tsx similarity index 96% rename from src/components/FilePreview/renderers/PdfRenderer.tsx rename to src/components/ContentPreview/renderers/PdfRenderer.tsx index edd8655..9a1d8ee 100644 --- a/src/components/FilePreview/renderers/PdfRenderer.tsx +++ b/src/components/ContentPreview/renderers/PdfRenderer.tsx @@ -1,6 +1,6 @@ import { IoIosWarning } from 'react-icons/io'; import { useLanguage } from '../../../providers/language/LanguageContext'; -import styles from '../FilePreview.module.css'; +import styles from '../ContentPreview.module.css'; interface PdfRendererProps { previewUrl?: string; @@ -51,3 +51,4 @@ export function PdfRenderer({ previewUrl, previewContent, fileName, onError }: P /> ); } + diff --git a/src/components/FilePreview/renderers/TextRenderer.tsx b/src/components/ContentPreview/renderers/TextRenderer.tsx similarity index 95% rename from src/components/FilePreview/renderers/TextRenderer.tsx rename to src/components/ContentPreview/renderers/TextRenderer.tsx index 043f780..90fa02e 100644 --- a/src/components/FilePreview/renderers/TextRenderer.tsx +++ b/src/components/ContentPreview/renderers/TextRenderer.tsx @@ -1,4 +1,4 @@ -import styles from '../FilePreview.module.css'; +import styles from '../ContentPreview.module.css'; // Updated to handle both previewUrl and previewContent @@ -39,3 +39,4 @@ export function TextRenderer({ previewUrl, previewContent, fileName, mimeType, o /> ); } + diff --git a/src/components/FilePreview/renderers/UnsupportedRenderer.tsx b/src/components/ContentPreview/renderers/UnsupportedRenderer.tsx similarity index 93% rename from src/components/FilePreview/renderers/UnsupportedRenderer.tsx rename to src/components/ContentPreview/renderers/UnsupportedRenderer.tsx index fdb668d..631d62c 100644 --- a/src/components/FilePreview/renderers/UnsupportedRenderer.tsx +++ b/src/components/ContentPreview/renderers/UnsupportedRenderer.tsx @@ -1,5 +1,5 @@ import { useLanguage } from '../../../providers/language/LanguageContext'; -import styles from '../FilePreview.module.css'; +import styles from '../ContentPreview.module.css'; interface UnsupportedRendererProps { previewUrl: string; @@ -24,3 +24,4 @@ export function UnsupportedRenderer({ previewUrl, fileName }: UnsupportedRendere ); } + diff --git a/src/components/FilePreview/renderers/index.ts b/src/components/ContentPreview/renderers/index.ts similarity index 99% rename from src/components/FilePreview/renderers/index.ts rename to src/components/ContentPreview/renderers/index.ts index 7acab78..0aa8e32 100644 --- a/src/components/FilePreview/renderers/index.ts +++ b/src/components/ContentPreview/renderers/index.ts @@ -7,3 +7,4 @@ export { ApplicationRenderer } from './ApplicationRenderer'; export { UnsupportedRenderer } from './UnsupportedRenderer'; export { LoadingRenderer } from './LoadingRenderer'; export { ErrorRenderer } from './ErrorRenderer'; + diff --git a/src/components/FilePreview/index.ts b/src/components/FilePreview/index.ts deleted file mode 100644 index aa286d3..0000000 --- a/src/components/FilePreview/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { FilePreview } from './FilePreview'; -export type { FilePreviewProps } from './FilePreview'; diff --git a/src/components/FormGenerator/ActionButtons/DownloadActionButton/DownloadActionButton.tsx b/src/components/FormGenerator/ActionButtons/DownloadActionButton/DownloadActionButton.tsx index 7ad9687..76a503e 100644 --- a/src/components/FormGenerator/ActionButtons/DownloadActionButton/DownloadActionButton.tsx +++ b/src/components/FormGenerator/ActionButtons/DownloadActionButton/DownloadActionButton.tsx @@ -14,6 +14,7 @@ export interface DownloadActionButtonProps { hookData?: any; // Contains all hook data including operations // Field mappings idField?: string; // Field name for the unique identifier + nameField?: string; // Field name for file name (with extension) loadingStateName?: string; // Name of the loading state in hookData operationName?: string; // Name of the operation function in hookData } @@ -28,6 +29,7 @@ export function DownloadActionButton({ isDownloading = false, hookData, idField = 'id', + nameField, loadingStateName = 'downloadingFiles', operationName }: DownloadActionButtonProps) { @@ -38,6 +40,32 @@ export function DownloadActionButton({ const isDisabled = typeof disabled === 'boolean' ? disabled : disabled?.disabled || false; const disabledMessage = typeof disabled === 'object' ? disabled?.message : undefined; + // Extract file name from row using nameField or fallback to common field names + const getFileName = (): string => { + const rowAny = row as any; + + // If nameField is explicitly provided, use it + if (nameField && rowAny[nameField]) { + return rowAny[nameField]; + } + + // Try common field names in order of preference + if (rowAny.fileName) return rowAny.fileName; + if (rowAny.file_name) return rowAny.file_name; + if (rowAny.name) return rowAny.name; + + // Fallback: try to find any field that might contain the file name + const possibleFields = ['fileName', 'file_name', 'name', 'filename']; + for (const field of possibleFields) { + if (rowAny[field]) { + return rowAny[field]; + } + } + + // Last resort: use id or a default + return rowAny[idField] || 'download'; + }; + const handleClick = async (e: React.MouseEvent) => { e.stopPropagation(); if (!isDisabled && !loading && !isDownloading && !internalLoading) { @@ -45,7 +73,9 @@ export function DownloadActionButton({ try { // If operationName is provided and hookData is available, use the hook function if (operationName && hookData && hookData[operationName]) { - await hookData[operationName]((row as any)[idField], (row as any).file_name); + const fileId = (row as any)[idField]; + const fileName = getFileName(); + await hookData[operationName](fileId, fileName); } else if (onDownload) { // Fallback to the provided onDownload function await onDownload(row); diff --git a/src/components/FormGenerator/ActionButtons/EditActionButton/EditActionButton.module.css b/src/components/FormGenerator/ActionButtons/EditActionButton/EditActionButton.module.css new file mode 100644 index 0000000..a2aa8bc --- /dev/null +++ b/src/components/FormGenerator/ActionButtons/EditActionButton/EditActionButton.module.css @@ -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; +} + diff --git a/src/components/FormGenerator/ActionButtons/EditActionButton/EditActionButton.tsx b/src/components/FormGenerator/ActionButtons/EditActionButton/EditActionButton.tsx index c6f9fb7..dc288b4 100644 --- a/src/components/FormGenerator/ActionButtons/EditActionButton/EditActionButton.tsx +++ b/src/components/FormGenerator/ActionButtons/EditActionButton/EditActionButton.tsx @@ -1,7 +1,8 @@ import React, { useState } from 'react'; import { MdModeEdit } from 'react-icons/md'; import { useLanguage } from '../../../../providers/language/LanguageContext'; -import { Popup, EditForm } from '../../../UiComponents/Popup'; +import { Popup } from '../../../UiComponents/Popup'; +import { FormGeneratorForm } from '../../FormGeneratorForm'; import styles from '../ActionButton.module.css'; export interface EditActionButtonProps { @@ -19,15 +20,12 @@ export interface EditActionButtonProps { typeField?: string; // Field name for type/mime type operationName?: string; // Name of the operation function in hookData loadingStateName?: string; // Name of the loading state in hookData - // Edit configuration - editFields?: Array<{ - key: string; - label: string; - type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'readonly'; - editable?: boolean; - required?: boolean; - validator?: (value: any) => string | null; - }>; + // Function name in hookData to fetch a single item (e.g., 'fetchPromptById', 'fetchItem') + fetchItemFunctionName?: string; + // Entity type for FormGeneratorForm (e.g., "Prompt", "User", "FileItem") + entityType?: string; + // Optional: Pre-fetched attributes (if available in hookData) + attributes?: any[]; } export function EditActionButton({ @@ -42,29 +40,15 @@ export function EditActionButton({ idField = 'id', operationName = 'handleFileUpdate', loadingStateName = 'editingFiles', - editFields = [ - { - key: 'file_name', - label: 'Filename', - type: 'string', - editable: true, - required: true, - validator: (value: string) => { - if (!value || value.trim() === '') { - return 'Filename cannot be empty'; - } - if (value.includes('/') || value.includes('\\')) { - return 'Filename cannot contain / or \\ characters'; - } - return null; - } - } - ] + fetchItemFunctionName = 'fetchPromptById', + entityType, + attributes: providedAttributes }: EditActionButtonProps) { const { t } = useLanguage(); const [internalLoading, setInternalLoading] = useState(false); const [isPopupOpen, setIsPopupOpen] = useState(false); const [editData, setEditData] = useState(null); + const [fetchingData, setFetchingData] = useState(false); // Extract disabled state and tooltip message const isDisabled = typeof disabled === 'boolean' ? disabled : disabled?.disabled || false; @@ -77,21 +61,83 @@ export function EditActionButton({ throw new Error('EditActionButton requires hookData to be provided'); } + // Get entity type from hookData or props + const getEntityType = (): string | undefined => { + if (entityType) return entityType; + if (hookData.entityType) return hookData.entityType; + if (hookData.entityName) return hookData.entityName; + // Try to infer from hookData attributes if available + if (hookData.attributes && Array.isArray(hookData.attributes) && hookData.attributes.length > 0) { + // Could potentially infer from attribute structure, but safer to require explicit entityType + return undefined; + } + return undefined; + }; + + // Get attributes from hookData or props + const getAttributes = () => { + if (providedAttributes) return providedAttributes; + if (hookData.attributes && Array.isArray(hookData.attributes)) return hookData.attributes; + return undefined; + }; + const handleClick = async (e: React.MouseEvent) => { e.stopPropagation(); - if (!isDisabled && !loading && !isEditing && !internalLoading) { + if (!isDisabled && !loading && !isEditing && !internalLoading && !fetchingData && !isPopupOpen) { setInternalLoading(true); + setFetchingData(true); + try { - // Debug logging to see what data we're working with - - // Call the onEdit callback if provided if (onEdit) { await onEdit(row); } - // Set up edit data and open popup - setEditData(row); + const itemId = (row as any)[idField]; + + // Fetch current item data - use generic fetch function from hookData + let freshData: T | null = null; + if (itemId) { + const possibleFunctionNames = [ + fetchItemFunctionName, + 'fetchItemById', + 'fetchItem', + 'getItemById', + 'getItem' + ].filter(Boolean); + + let fetchFunction: ((id: string) => Promise) | null = null; + for (const funcName of possibleFunctionNames) { + if (hookData[funcName] && typeof hookData[funcName] === 'function') { + fetchFunction = hookData[funcName]; + break; + } + } + + if (fetchFunction) { + try { + freshData = await fetchFunction(itemId); + } catch (error: any) { + console.error('Failed to fetch fresh data:', error); + } + } + } + + // Ensure attributes are loaded - use generic function from hookData if available + if (hookData.ensureAttributesLoaded && typeof hookData.ensureAttributesLoaded === 'function') { + await hookData.ensureAttributesLoaded(); + } + + // Use fresh data if available, otherwise use row data + setEditData(freshData || row); + + // Set fetchingData to false first + setFetchingData(false); + + // Wait for React to update state + await new Promise(resolve => setTimeout(resolve, 0)); + + // Open popup AFTER data is ready - like CreateButton (no loading state shown) setIsPopupOpen(true); } finally { setInternalLoading(false); @@ -108,21 +154,22 @@ export function EditActionButton({ // Get the item ID from the row const itemId = (editData as any)[idField]; + // Get edit fields configuration + const fields = getEditFields(); + // Extract the fields to update from the edit data const updateData: any = {}; - editFields.forEach(field => { + fields.forEach(field => { if (field.editable !== false) { - // Map frontend field names to API field names - if (field.key === 'file_name') { - updateData.fileName = (updatedData as any)[field.key]; - } else { - updateData[field.key] = (updatedData as any)[field.key]; + const value = (updatedData as any)[field.key]; + if (value !== undefined) { + updateData[field.key] = value; } } }); // Check if optimistic update is available - const updateOptimistically = hookData.updateOptimistically || hookData.updateFileOptimistically; + const updateOptimistically = hookData.updateOptimistically; // Validate required operation exists if (!hookData[operationName]) { @@ -134,39 +181,40 @@ export function EditActionButton({ updateOptimistically(itemId, updateData); } - // Close popup and reset state immediately for better UX - setIsPopupOpen(false); - setEditData(null); - // Use hookData operation to update in the background const result = await hookData[operationName](itemId, updateData, editData); const success = result?.success || result === true; if (success) { - // If we used optimistic update, don't refetch to avoid overwriting our changes - if (updateOptimistically) { - // Trust the optimistic update worked - } else { - // No optimistic update, refetch to sync with backend - if (hookData.refetch) { - await hookData.refetch(); - } - } - } else { - // If update failed, refetch to restore original state + // Close popup and reset state on success + setIsPopupOpen(false); + setEditData(null); + + // If we used optimistic update, refetch to get fresh data from backend + // This ensures we have the latest data including any server-side transformations if (hookData.refetch) { await hookData.refetch(); } - console.error('Failed to update item:', itemId); - // TODO: Show error message to user + } else { + // If update failed, revert optimistic update + if (updateOptimistically && hookData.refetch) { + // Revert by refetching original data + await hookData.refetch(); + } + + // Close popup on error + setIsPopupOpen(false); + setEditData(null); } - } catch (error) { - // If update failed, refetch to restore original state - if (hookData.refetch) { + } catch (error: any) { + // If update failed, revert optimistic update + if (hookData.updateOptimistically && hookData.refetch) { await hookData.refetch(); } + console.error('Failed to update item:', error); - // TODO: Show error message to user + setIsPopupOpen(false); + setEditData(null); } finally { setInternalLoading(false); } @@ -181,13 +229,11 @@ export function EditActionButton({ // Use hookData editing state if available, otherwise use passed isEditing const loadingState = hookData?.[loadingStateName]; const actualIsEditing = loadingState?.has((row as any)[idField]) || isEditing; - const isLoading = loading || actualIsEditing || internalLoading; + const isLoading = loading || actualIsEditing || internalLoading || fetchingData; // Determine the final button title (tooltip) const finalTitle = isDisabled && disabledMessage ? disabledMessage : buttonTitle; - - return ( <> - {/* Edit Popup */} + {/* Edit Popup - Identical structure to CreateButton */} - {editData && ( - ({ - key: field.key, - label: field.label, - type: field.type, - editable: field.editable ?? true, - required: field.required ?? false, - validator: field.validator - }))} - onSave={handleSave} - onCancel={handleCancel} - saveButtonText={t('common.save', 'Save')} - cancelButtonText={t('common.cancel', 'Cancel')} - /> - )} + {editData && (() => { + const entityTypeValue = getEntityType(); + const attributesValue = getAttributes(); + + if (!entityTypeValue && !attributesValue) { + console.warn('EditActionButton: entityType or attributes must be provided for FormGeneratorForm'); + return ( +
+ {t('common.error', 'Error: Entity type or attributes must be provided')} +
+ ); + } + + return ( + + ); + })()}
); diff --git a/src/components/FormGenerator/ActionButtons/PlayActionButton/PlayActionButton.tsx b/src/components/FormGenerator/ActionButtons/PlayActionButton/PlayActionButton.tsx index a1514fa..d3ae4ea 100644 --- a/src/components/FormGenerator/ActionButtons/PlayActionButton/PlayActionButton.tsx +++ b/src/components/FormGenerator/ActionButtons/PlayActionButton/PlayActionButton.tsx @@ -16,8 +16,11 @@ export interface PlayActionButtonProps { // Field mappings idField?: string; // Field name for the unique identifier nameField?: string; // Field name for display name + contentField?: string; // Field name for content (e.g., 'content' for prompts, 'prompt' for workflows) // Navigation navigateTo?: string; // Path to navigate to after selection (default: 'start/dashboard') + // Behavior + mode?: 'workflow' | 'prompt'; // 'workflow' selects workflow, 'prompt' sets input value } export function PlayActionButton({ @@ -30,7 +33,9 @@ export function PlayActionButton({ hookData, idField = 'id', nameField = 'name', - navigateTo = 'start/dashboard' + contentField = 'content', + navigateTo = 'start/dashboard', + mode = 'prompt' }: PlayActionButtonProps) { const { t } = useLanguage(); const navigate = useNavigate(); @@ -44,30 +49,41 @@ export function PlayActionButton({ e.stopPropagation(); if (!isDisabled && !loading) { try { - // Get workflow ID from row - const workflowId = (row as any)[idField]; - if (!workflowId) { - console.error('Workflow ID not found in row'); - return; - } - // Call the onPlay callback if provided if (onPlay) { await onPlay(row); } - // Select the workflow in context - selectWorkflow(workflowId); + if (mode === 'workflow') { + // Workflow mode: select workflow and navigate + const workflowId = (row as any)[idField]; + if (!workflowId) { + console.error('Workflow ID not found in row'); + return; + } + selectWorkflow(workflowId); + } else { + // Prompt mode: set input value in dashboard + const content = (row as any)[contentField]; + if (content && typeof content === 'string') { + // Dispatch event to set dashboard input value + window.dispatchEvent(new CustomEvent('dashboardSetInput', { + detail: { value: content } + })); + } + } // Navigate to dashboard (or specified path) navigate(`/${navigateTo}`); } catch (error: any) { - console.error('Error playing workflow:', error); + console.error('Error in PlayActionButton:', error); } } }; - const buttonTitle = title || t('workflows.action.play', 'Play'); + const buttonTitle = title || (mode === 'workflow' + ? t('workflows.action.play', 'Play') + : t('prompts.action.start', 'Start Prompt')); const isLoading = loading; const finalTitle = isDisabled && disabledMessage ? disabledMessage : buttonTitle; diff --git a/src/components/FormGenerator/ActionButtons/ViewActionButton/ViewActionButton.tsx b/src/components/FormGenerator/ActionButtons/ViewActionButton/ViewActionButton.tsx index 039d6b0..bfc8b2f 100644 --- a/src/components/FormGenerator/ActionButtons/ViewActionButton/ViewActionButton.tsx +++ b/src/components/FormGenerator/ActionButtons/ViewActionButton/ViewActionButton.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import { IoIosEye } from 'react-icons/io'; import { useLanguage } from '../../../../providers/language/LanguageContext'; -import { FilePreview } from '../../../FilePreview/FilePreview'; +import { ContentPreview } from '../../../ContentPreview'; import styles from '../ActionButton.module.css'; export interface ViewActionButtonProps { @@ -82,8 +82,8 @@ export function ViewActionButton({ - {/* File Preview Component */} - setIsPopupOpen(false)} fileId={(row as any)[idField]} diff --git a/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.module.css b/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.module.css new file mode 100644 index 0000000..34bcb66 --- /dev/null +++ b/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.module.css @@ -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%; + } +} + diff --git a/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.tsx b/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.tsx new file mode 100644 index 0000000..36c8bcf --- /dev/null +++ b/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.tsx @@ -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; + onFilterChange: (key: string, value: any) => void; + filterFocused: Record; + 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) => { + 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 ( +
+ {/* Delete Controls - Show when items are selected */} + {selectable && selectedCount > 0 && ( +
+ {selectedCount === 1 && onDeleteSingle && ( + + )} + {selectedCount > 1 && onDeleteMultiple && ( + + )} +
+ )} + + {/* Search Controls - Hide when items are selected */} + {searchable && selectedCount === 0 && ( +
+
+ onSearchChange(e.target.value)} + onFocus={() => onSearchFocus(true)} + onBlur={() => onSearchFocus(false)} + className={`${styles.searchInput} ${searchFocused || searchTerm ? styles.focused : ''}`} + /> + +
+ {onRefresh && ( + + )} +
+ )} + + {/* Filters */} + {filterable && ( +
+ {filterableFields.map(field => ( +
+ {field.type === 'boolean' ? ( +
+ + {filters[field.key] && ( + + )} +
+ ) : field.filterOptions ? ( +
+ + {filters[field.key] && ( + + )} +
+ ) : field.type === 'date' ? ( +
+ 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} + /> + +
+ ) : ( +
+ 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 : ''}`} + /> + +
+ )} +
+ ))} +
+ )} +
+ ); +} + +export default FormGeneratorControls; + diff --git a/src/components/FormGenerator/FormGeneratorControls/index.ts b/src/components/FormGenerator/FormGeneratorControls/index.ts new file mode 100644 index 0000000..8eaa3d1 --- /dev/null +++ b/src/components/FormGenerator/FormGeneratorControls/index.ts @@ -0,0 +1,3 @@ +export { FormGeneratorControls, default } from './FormGeneratorControls'; +export type { FilterableField, FormGeneratorControlsProps } from './FormGeneratorControls'; + diff --git a/src/components/UiComponents/Popup/EditForm.module.css b/src/components/FormGenerator/FormGeneratorForm/FormGeneratorForm.module.css similarity index 65% rename from src/components/UiComponents/Popup/EditForm.module.css rename to src/components/FormGenerator/FormGeneratorForm/FormGeneratorForm.module.css index 1f3f3c4..edb7750 100644 --- a/src/components/UiComponents/Popup/EditForm.module.css +++ b/src/components/FormGenerator/FormGeneratorForm/FormGeneratorForm.module.css @@ -1,8 +1,33 @@ -/* EditForm container */ -.editForm { +/* FormGeneratorForm container */ +.formGeneratorForm { width: 100%; } +/* Loading state */ +.loadingState { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px; + gap: 16px; +} + +.loadingSpinner { + width: 40px; + height: 40px; + border: 3px solid var(--color-primary); + border-top-color: transparent; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + /* Field styling */ .fieldGroup { margin-bottom: 20px; @@ -14,6 +39,7 @@ color: var(--color-text); margin-bottom: 6px; font-size: 14px; + text-align: left; } /* Floating label container */ @@ -39,11 +65,85 @@ border-color: var(--color-secondary); } +.fieldInput:disabled { + opacity: 0.6; + cursor: not-allowed; +} + .fieldInput.fieldError { border-color: #ef4444; box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1); } +/* Multiselect styling */ +.multiselectContainer { + width: 100%; + padding: 12px; + border: 1px solid var(--color-primary); + border-radius: 25px; + background-color: var(--color-bg); + min-height: 60px; + max-height: 200px; + overflow-y: auto; + box-sizing: border-box; + text-align: left; +} + +.multiselectContainer.fieldError { + border-color: #ef4444; + box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1); +} + +.multiselectLoading, +.multiselectEmpty { + padding: 8px; + text-align: center; + color: var(--color-text); + opacity: 0.7; + font-size: 14px; +} + +.multiselectOptions { + display: flex; + flex-direction: column; + gap: 8px; + align-items: flex-start; +} + +.multiselectOption { + display: flex; + align-items: center; + cursor: pointer; + padding: 4px 0; + user-select: none; + width: 100%; +} + +.multiselectOption:hover { + opacity: 0.8; +} + +.multiselectCheckbox { + margin-right: 8px; + width: 16px; + height: 16px; + cursor: pointer; + accent-color: var(--color-primary); +} + +.multiselectLabel { + font-size: 14px; + color: var(--color-text); + flex: 1; +} + +.multiselectCount { + font-size: 0.75rem; + color: var(--color-primary); + margin-left: 4px; + font-weight: normal; +} + /* Textarea styling */ .fieldTextarea { width: 100%; @@ -60,7 +160,12 @@ overflow-y: auto; resize: vertical; min-height: 4em; - max-height: 8em; +} + +/* Content textarea - larger default size */ +.contentTextarea { + min-height: 18em !important; + height: auto; } .fieldTextarea:focus { @@ -147,6 +252,7 @@ width: 16px; height: 16px; cursor: pointer; + accent-color: var(--color-primary); } /* Required field indicator */ @@ -185,13 +291,18 @@ transition: all 0.2s ease; } -.cancelButton:hover { +.cancelButton:hover:not(:disabled) { background-color: var(--color-primary-hover); border-color: var(--color-primary); color: #181818; } -.saveButton { +.cancelButton:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.submitButton { padding: 8px 16px; border: none; background-color: var(--color-secondary); @@ -203,11 +314,11 @@ transition: all 0.2s ease; } -.saveButton:hover { +.submitButton:hover:not(:disabled) { background-color: var(--color-secondary-hover); } -.saveButton:disabled { +.submitButton:disabled { background-color: #9ca3af; cursor: not-allowed; } @@ -219,8 +330,9 @@ } .cancelButton, - .saveButton { + .submitButton { width: 100%; padding: 12px; } -} \ No newline at end of file +} + diff --git a/src/components/FormGenerator/FormGeneratorForm/FormGeneratorForm.tsx b/src/components/FormGenerator/FormGeneratorForm/FormGeneratorForm.tsx new file mode 100644 index 0000000..a420d09 --- /dev/null +++ b/src/components/FormGenerator/FormGeneratorForm/FormGeneratorForm.tsx @@ -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 { + // 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; + // 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; +} + +// FormGeneratorForm component - Backend-driven form generation +export function FormGeneratorForm>({ + entityType, + data, + mode = 'edit', + onSubmit, + onCancel, + submitButtonText, + cancelButtonText, + showButtons = true, + className = '', + attributes: providedAttributes, + filterFields, + transformField, + customValidator +}: FormGeneratorFormProps) { + const { t } = useLanguage(); + const [formData, setFormData] = useState(data || {} as T); + const [errors, setErrors] = useState>({}); + const [fieldFocused, setFieldFocused] = useState>({}); + const [attributes, setAttributes] = useState(providedAttributes || []); + const [loadingAttributes, setLoadingAttributes] = useState(!providedAttributes); + const [optionsCache, setOptionsCache] = useState>>({}); + const [loadingOptions, setLoadingOptions] = useState>({}); + 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 = {}; + 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 ( +
+
+ {displayValue || t('common.na', 'N/A')} +
+ +
+ ); + } + + // Select/Enum field + if (attr.type === 'select' || attr.type === 'enum') { + const options = normalizeOptions(attr); + const isLoading = typeof attr.options === 'string' && loadingOptions[attr.name]; + + return ( +
+ + + {hasError && {hasError}} +
+ ); + } + + // 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 ( +
+ +
+ {isLoading ? ( +
{t('common.loading', 'Loading options...')}
+ ) : options.length === 0 ? ( +
{t('common.noOptions', 'No options available')}
+ ) : ( +
+ {options.map(option => { + const isSelected = currentValues.some(v => String(v) === String(option.value)); + return ( + + ); + })} +
+ )} +
+ {hasError && {hasError}} +
+ ); + } + + // Checkbox/Boolean field + if (attr.type === 'checkbox' || attr.type === 'boolean') { + return ( +
+ + {hasError && {hasError}} +
+ ); + } + + // 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 ( +
+