From 8860f497144ba93bf9e8df980b7414bccdb056dd Mon Sep 17 00:00:00 2001 From: Ida Date: Wed, 13 May 2026 14:25:20 +0200 Subject: [PATCH] fix: removed duplicate keepAlive Files for pages and views and instead implemented single source of truth for mounted pages, removed unused legacy core page manager --- src/App.tsx | 4 +- src/config/keepAliveRoutes.tsx | 45 ++ src/config/pageRegistry.tsx | 1 - src/core/PageManager/SidebarProvider.tsx | 473 ------------------ src/core/PageManager/data/index.ts | 27 - src/core/PageManager/data/pages/index.ts | 15 - .../data/pages/realestate/index.ts | 3 - .../data/pages/trustee/position-documents.ts | 241 --------- src/core/PageManager/pageInterface.ts | 44 -- src/layouts/MainLayout.tsx | 139 +++-- src/pages/FeatureView.tsx | 21 +- src/pages/admin/AdminLanguagesKeepAlive.tsx | 35 -- .../views/commcoach/CommcoachKeepAlive.tsx | 55 -- .../GraphicalEditorKeepAlive.tsx | 70 --- .../GraphicalEditorWorkflowsPage.tsx | 441 ---------------- .../GraphicalEditorWorkflowsTasksPage.tsx | 2 +- .../views/workspace/WorkspaceKeepAlive.tsx | 57 --- src/test/setup.ts | 45 -- src/test/smoke.test.ts | 23 - src/types/keepAlive.types.ts | 34 ++ src/types/mandate.ts | 1 - 21 files changed, 182 insertions(+), 1594 deletions(-) create mode 100644 src/config/keepAliveRoutes.tsx delete mode 100644 src/core/PageManager/SidebarProvider.tsx delete mode 100644 src/core/PageManager/data/index.ts delete mode 100644 src/core/PageManager/data/pages/index.ts delete mode 100644 src/core/PageManager/data/pages/realestate/index.ts delete mode 100644 src/core/PageManager/data/pages/trustee/position-documents.ts delete mode 100644 src/core/PageManager/pageInterface.ts delete mode 100644 src/pages/admin/AdminLanguagesKeepAlive.tsx delete mode 100644 src/pages/views/commcoach/CommcoachKeepAlive.tsx delete mode 100644 src/pages/views/graphicalEditor/GraphicalEditorKeepAlive.tsx delete mode 100644 src/pages/views/graphicalEditor/GraphicalEditorWorkflowsPage.tsx delete mode 100644 src/pages/views/workspace/WorkspaceKeepAlive.tsx delete mode 100644 src/test/setup.ts delete mode 100644 src/test/smoke.test.ts create mode 100644 src/types/keepAlive.types.ts diff --git a/src/App.tsx b/src/App.tsx index 499bb37..64560d4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -169,8 +169,8 @@ function App() { } /> } /> - {/* Automation2 Workflows & Tasks */} - } /> + {/* Automation2: legacy workflows URL → editor */} + } /> } /> {/* Teams Bot Feature Views */} diff --git a/src/config/keepAliveRoutes.tsx b/src/config/keepAliveRoutes.tsx new file mode 100644 index 0000000..38a1281 --- /dev/null +++ b/src/config/keepAliveRoutes.tsx @@ -0,0 +1,45 @@ +import type { KeepAliveEntry } from '../types/keepAlive.types'; +import { AdminLanguagesPage } from '../pages/admin/AdminLanguagesPage'; +import { CommcoachSessionView } from '../pages/views/commcoach'; +import { GraphicalEditorPage } from '../pages/views/graphicalEditor/GraphicalEditorPage'; +import { WorkspacePage } from '../pages/views/workspace/WorkspacePage'; + +export const KEEP_ALIVE_ROUTES: KeepAliveEntry[] = [ + { + id: 'workspace-dashboard', + pathRegex: /\/mandates\/[^/]+\/workspace\/[^/]+\/dashboard/, + scopeRegex: /\/mandates\/([^/]+)\/workspace\/([^/]+)/, + requireMandateForMount: false, + render: ({ instanceId, scopeKey }) => ( + + ), + }, + { + id: 'commcoach-session', + pathRegex: /\/mandates\/[^/]+\/commcoach\/[^/]+\/session/, + scopeRegex: /\/mandates\/([^/]+)\/commcoach\/([^/]+)\/session/, + shellOverflowHidden: false, + render: ({ scopeKey }) => , + }, + { + id: 'graphical-editor', + pathRegex: /\/mandates\/[^/]+\/graphicalEditor\/[^/]+\/editor/, + scopeRegex: /\/mandates\/([^/]+)\/graphicalEditor\/([^/]+)\/editor/, + render: ({ mandateId, instanceId, scopeKey }) => ( + + ), + }, + { + id: 'admin-languages', + pathRegex: /\/admin\/languages(?:$|\/)/, + render: () => , + }, +]; + +export function hideFeatureOutlet(pathname: string): boolean { + return KEEP_ALIVE_ROUTES.some((e) => e.pathRegex.test(pathname)); +} diff --git a/src/config/pageRegistry.tsx b/src/config/pageRegistry.tsx index d8b84c1..f4eea42 100644 --- a/src/config/pageRegistry.tsx +++ b/src/config/pageRegistry.tsx @@ -131,7 +131,6 @@ export const PAGE_ICONS: Record = { 'feature.chatworkflow': , 'feature.graphicalEditor': , 'page.feature.graphicalEditor.editor': , - 'page.feature.graphicalEditor.workflows': , 'page.feature.graphicalEditor.workflows-tasks': , 'page.feature.chatbot.conversations': , 'feature.chatbot': , diff --git a/src/core/PageManager/SidebarProvider.tsx b/src/core/PageManager/SidebarProvider.tsx deleted file mode 100644 index 08558e2..0000000 --- a/src/core/PageManager/SidebarProvider.tsx +++ /dev/null @@ -1,473 +0,0 @@ -import React, { createContext, useContext, useState, useEffect } from 'react'; -import { allPageData, SidebarItem, SidebarSubmenuItemData } from './data'; -import { useLanguage } from '../../providers/language/LanguageContext'; -import { resolveLanguageText, GenericPageData } from './pageInterface'; -import { usePermissions } from '../../hooks/usePermissions'; -import { FaHome, FaHatWizard, FaBriefcase, FaBuilding, FaProjectDiagram } from 'react-icons/fa'; -import { RiFolderSettingsFill } from 'react-icons/ri'; - -// Configuration for parent groups that don't have a page definition -// Maps parentPath (can be nested like "start.real-estate") to icon and default order -const parentGroupConfig: Record>; - defaultOrder?: number; -}> = { - 'start': { - icon: FaHome, - defaultOrder: 1 - }, - 'workflows': { - icon: FaProjectDiagram, - defaultOrder: 2 - }, - 'trustee': { - icon: FaBriefcase, - defaultOrder: 3 - }, - 'basedata': { - icon: RiFolderSettingsFill, - defaultOrder: 4 - }, - 'admin': { - icon: FaHatWizard, - defaultOrder: 5 - }, - 'start.realestate': { - icon: FaBuilding, - defaultOrder: 2 - } -}; - -interface SidebarContextType { - sidebarItems: SidebarItem[]; - loading: boolean; - error: string | null; - refreshSidebar: () => Promise; -} - -const SidebarContext = createContext(undefined); - -export const useSidebar = () => { - const context = useContext(SidebarContext); - if (!context) { - throw new Error('useSidebar must be used within a SidebarProvider'); - } - return context; -}; - -interface SidebarProviderProps { - children: React.ReactNode; -} - -export const SidebarProvider: React.FC = ({ children }) => { - const [sidebarItems, setSidebarItems] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - // Get translation function from language context - const { t } = useLanguage(); - const { canView, preloadUiPermissions } = usePermissions(); - - // Helper type for navigation tree nodes - interface NavigationNode { - id: string; - pathSegment: string; - fullPath: string; // Full dot-notation path (e.g., "start.real-estate") - name: string; - icon?: React.ComponentType>; - order: number; - page?: GenericPageData; // If this node represents an actual page - children: Map; // Keyed by path segment - pages: GenericPageData[]; // Direct child pages - } - - // Helper function to resolve node name - const resolveNodeName = (pathSegment: string, _fullPath: string, page?: GenericPageData): string => { - if (page) { - return resolveLanguageText(page.name, t); - } - return pathSegment.split('-').map(s => s.charAt(0).toUpperCase() + s.slice(1)).join(' '); - }; - - // Helper function to resolve node icon - const resolveNodeIcon = (pathSegment: string, fullPath: string, page?: GenericPageData): React.ComponentType> | undefined => { - if (page?.icon) { - return page.icon; - } - // Check parentGroupConfig for nested paths first (e.g., "start.real-estate") - if (parentGroupConfig[fullPath]?.icon) { - return parentGroupConfig[fullPath].icon; - } - // Check parentGroupConfig for top-level segments (e.g., "start") - if (fullPath === pathSegment && parentGroupConfig[pathSegment]?.icon) { - return parentGroupConfig[pathSegment].icon; - } - return undefined; - }; - - // Helper function to resolve node order - const resolveNodeOrder = (pathSegment: string, fullPath: string, page?: GenericPageData, childPages: GenericPageData[] = []): number => { - if (page?.order !== undefined) { - return page.order; - } - // Check parentGroupConfig for top-level segments - if (fullPath === pathSegment && parentGroupConfig[pathSegment]?.defaultOrder !== undefined) { - return parentGroupConfig[pathSegment].defaultOrder!; - } - // Use minimum order of child pages - if (childPages.length > 0) { - const childOrders = childPages.map(p => p.order ?? 0); - return Math.min(...childOrders); - } - return 0; - }; - - // Build navigation tree from page data - const buildNavigationTree = (): Map => { - const rootNodes = new Map(); - - // Process all pages with parent paths - const pagesWithParents = allPageData.filter( - page => page.parentPath && !page.hide && page.showInSidebar !== false - ); - - for (const page of pagesWithParents) { - if (!page.parentPath) continue; - - // Parse parent path segments (e.g., "start.real-estate" -> ["start", "real-estate"]) - const pathSegments = page.parentPath.split('.'); - - // Build path to root, creating nodes as needed - let currentMap = rootNodes; - let currentFullPath = ''; - - for (let i = 0; i < pathSegments.length; i++) { - const segment = pathSegments[i]; - currentFullPath = currentFullPath ? `${currentFullPath}.${segment}` : segment; - - // Get or create node for this segment - if (!currentMap.has(segment)) { - // Check if there's a page for this path segment - const segmentPage = allPageData.find( - p => p.path === currentFullPath && !p.hide - ); - - const node: NavigationNode = { - id: segmentPage?.id || currentFullPath, - pathSegment: segment, - fullPath: currentFullPath, - name: '', // Will be resolved later - icon: undefined, // Will be resolved later - order: 0, // Will be resolved later - page: segmentPage, - children: new Map(), - pages: [] - }; - - currentMap.set(segment, node); - } - - const node = currentMap.get(segment)!; - - // If this is the last segment, add the page as a child page - if (i === pathSegments.length - 1) { - node.pages.push(page); - } - - // Move to next level - currentMap = node.children; - } - } - - // Resolve names, icons, and orders for all nodes - const resolveNode = (node: NavigationNode): void => { - // Resolve children first (bottom-up) - for (const childNode of node.children.values()) { - resolveNode(childNode); - } - - // Resolve this node - node.name = resolveNodeName(node.pathSegment, node.fullPath, node.page); - node.icon = resolveNodeIcon(node.pathSegment, node.fullPath, node.page); - - // Collect all child pages (from direct pages and nested children) - const allChildPages = [...node.pages]; - for (const childNode of node.children.values()) { - if (childNode.page) { - allChildPages.push(childNode.page); - } - allChildPages.push(...childNode.pages); - } - - node.order = resolveNodeOrder(node.pathSegment, node.fullPath, node.page, allChildPages); - }; - - // Resolve all root nodes - for (const node of rootNodes.values()) { - resolveNode(node); - } - - return rootNodes; - }; - - // Convert navigation tree node to sidebar submenu item (recursive) - const nodeToSubmenuItem = async (node: NavigationNode, depth: number = 0): Promise => { - // Filter child pages by RBAC and privilegeChecker - const accessiblePages: GenericPageData[] = []; - for (const page of node.pages) { - try { - const hasRBACAccess = await canView('UI', page.path); - if (!hasRBACAccess) continue; - - if (page.privilegeChecker) { - try { - const hasPrivilege = await page.privilegeChecker(); - if (!hasPrivilege) continue; - } catch (error) { - console.error(`Error checking privilegeChecker for page ${page.path}:`, error); - continue; - } - } - - accessiblePages.push(page); - } catch (error) { - console.error(`Error checking RBAC access for page ${page.path}:`, error); - } - } - - // Process child nodes recursively (increment depth) - const accessibleChildren: SidebarSubmenuItemData[] = []; - for (const childNode of node.children.values()) { - const childItem = await nodeToSubmenuItem(childNode, depth + 1); - if (childItem) { - accessibleChildren.push(childItem); - } - } - - // Combine pages and child nodes, assigning depth - const allChildren: SidebarSubmenuItemData[] = [ - ...accessiblePages.map(page => ({ - id: page.id, - name: resolveLanguageText(page.name, t), - link: `/${page.path}`, - icon: page.icon, - depth: depth + 1 // Child pages are one level deeper - })), - ...accessibleChildren - ]; - - // If no accessible children, don't create this node - if (allChildren.length === 0) { - return null; - } - - // If this node has a page itself, it shouldn't be a navigation node - // But according to requirements: if it has subpages, it is NOT a page itself - // So we create a navigation node without a link - return { - id: node.id, - name: node.name, - link: undefined, // Navigation node - not a clickable page - icon: node.icon, - submenu: allChildren.length > 0 ? allChildren : undefined, - depth: depth // Current depth level - }; - }; - - // Convert navigation tree to sidebar items - const treeToSidebarItems = async (tree: Map): Promise => { - const items: SidebarItem[] = []; - - // Process each root node (depth 0 for top-level items) - for (const node of tree.values()) { - const submenuItem = await nodeToSubmenuItem(node, 0); - if (submenuItem && submenuItem.submenu && submenuItem.submenu.length > 0) { - items.push({ - id: node.id, - name: node.name, - link: undefined, // Navigation node - not a clickable page - icon: node.icon, - moduleEnabled: true, - order: node.order, - submenu: submenuItem.submenu, - depth: 0 // Top-level items have depth 0 - }); - } - } - - return items; - }; - - // Get sidebar items from page data - const getSidebarItems = async (): Promise => { - const items: SidebarItem[] = []; - - // Build navigation tree - const navigationTree = buildNavigationTree(); - - // Convert tree to sidebar items - const treeItems = await treeToSidebarItems(navigationTree); - items.push(...treeItems); - - // Get main pages (no parent path) - const mainPages = allPageData - .filter(page => !page.parentPath && !page.hide && page.showInSidebar !== false) - .sort((a, b) => (a.order || 0) - (b.order || 0)); - - // Process each main page - for (const pageData of mainPages) { - // Check RBAC permissions - try { - const hasRBACAccess = await canView('UI', pageData.path); - if (!hasRBACAccess) { - continue; - } - - // Check client-side privilegeChecker if provided - if (pageData.privilegeChecker) { - try { - const hasPrivilege = await pageData.privilegeChecker(); - if (!hasPrivilege) { - continue; - } - } catch (error) { - console.error(`Error checking privilegeChecker for ${pageData.path}:`, error); - continue; - } - } - } catch (error) { - console.error(`Error checking RBAC access for ${pageData.path}:`, error); - continue; - } - - // Check if this page has subpages (legacy support) - if (pageData.hasSubpages) { - // Find all subpages for this parent - const allSubpages = allPageData.filter(p => - p.parentPath === pageData.path && - !p.hide && - p.showInSidebar !== false - ); - - // Filter subpages by RBAC access - const accessibleSubpages: GenericPageData[] = []; - for (const subpage of allSubpages) { - try { - const hasSubpageRBACAccess = await canView('UI', subpage.path); - if (!hasSubpageRBACAccess) { - continue; - } - - if (subpage.privilegeChecker) { - try { - const hasPrivilege = await subpage.privilegeChecker(); - if (!hasPrivilege) { - continue; - } - } catch (error) { - console.error(`Error checking privilegeChecker for subpage ${subpage.path}:`, error); - continue; - } - } - - accessibleSubpages.push(subpage); - } catch (error) { - console.error(`Error checking RBAC access for subpage ${subpage.path}:`, error); - } - } - - if (accessibleSubpages.length > 0) { - // Create item with submenu (no link since it has subpages) - items.push({ - id: pageData.id, - name: resolveLanguageText(pageData.name, t), - link: undefined, // No link - has subpages, so it's a navigation node - icon: pageData.icon, - moduleEnabled: pageData.moduleEnabled ?? true, - order: pageData.order || 0, - depth: 0, // Top-level items have depth 0 - submenu: accessibleSubpages.map(subpage => ({ - id: subpage.id, - name: resolveLanguageText(subpage.name, t), - link: `/${subpage.path}`, - icon: subpage.icon, - depth: 1 // First level of submenu - })) - }); - } else { - // No accessible subpages, show as regular item - items.push({ - id: pageData.id, - name: resolveLanguageText(pageData.name, t), - link: `/${pageData.path}`, - icon: pageData.icon, - moduleEnabled: pageData.moduleEnabled ?? true, - order: pageData.order || 0, - depth: 0 // Top-level items have depth 0 - }); - } - } else { - // Regular items without subpages - items.push({ - id: pageData.id, - name: resolveLanguageText(pageData.name, t), - link: `/${pageData.path}`, - icon: pageData.icon, - moduleEnabled: pageData.moduleEnabled ?? true, - order: pageData.order || 0, - depth: 0 // Top-level items have depth 0 - }); - } - } - - // Sort all items by order - const sortedItems = items.sort((a, b) => (a.order || 0) - (b.order || 0)); - - return sortedItems; - }; - - // Refresh sidebar items - const refreshSidebar = async () => { - console.log('🔄 SidebarProvider: Refreshing sidebar items...'); - setLoading(true); - setError(null); - - try { - // Preload all UI permissions in a single API call - // This caches all permissions before iterating through pages - await preloadUiPermissions(); - - const items = await getSidebarItems(); - console.log('✅ SidebarProvider: Setting sidebar items:', { - count: items.length, - items: items.map(item => ({ id: item.id, link: item.link, name: item.name })) - }); - setSidebarItems(items); - } catch (err) { - console.error('❌ SidebarProvider: Error refreshing sidebar:', err); - setError(err instanceof Error ? err.message : t('Seitenleiste konnte nicht geladen werden')); - } finally { - setLoading(false); - } - }; - - // Load sidebar items on mount and when language changes - useEffect(() => { - refreshSidebar(); - }, [t]); - - const contextValue: SidebarContextType = { - sidebarItems, - loading, - error, - refreshSidebar - }; - - return ( - - {children} - - ); -}; - -export default SidebarProvider; diff --git a/src/core/PageManager/data/index.ts b/src/core/PageManager/data/index.ts deleted file mode 100644 index 33ee74d..0000000 --- a/src/core/PageManager/data/index.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * PageManager data: allPageData and SidebarItem type. - */ - -import type React from 'react'; - -export { allPageData } from './pages'; - -export interface SidebarItem { - id: string; - name: string; - link?: string; - icon?: React.ComponentType>; - moduleEnabled?: boolean; - order?: number; - submenu?: SidebarSubmenuItemData[]; - depth?: number; -} - -export interface SidebarSubmenuItemData { - id: string; - name: string; - link?: string; - icon?: React.ComponentType>; - depth?: number; - submenu?: SidebarSubmenuItemData[]; -} diff --git a/src/core/PageManager/data/pages/index.ts b/src/core/PageManager/data/pages/index.ts deleted file mode 100644 index e524954..0000000 --- a/src/core/PageManager/data/pages/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Central page registry: all PageData for PageManager/Sidebar. - */ - -import type { GenericPageData } from '../../pageInterface'; -import { trusteePositionDocumentsPageData } from './trustee/position-documents'; -import { realEstatePages } from './realestate'; - -export { realEstatePages } from './realestate'; -export { trusteePositionDocumentsPageData } from './trustee/position-documents'; - -export const allPageData: GenericPageData[] = [ - trusteePositionDocumentsPageData, - ...realEstatePages, -]; diff --git a/src/core/PageManager/data/pages/realestate/index.ts b/src/core/PageManager/data/pages/realestate/index.ts deleted file mode 100644 index 70608a6..0000000 --- a/src/core/PageManager/data/pages/realestate/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { GenericPageData } from '../../../pageInterface'; - -export const realEstatePages: GenericPageData[] = []; diff --git a/src/core/PageManager/data/pages/trustee/position-documents.ts b/src/core/PageManager/data/pages/trustee/position-documents.ts deleted file mode 100644 index 31577a3..0000000 --- a/src/core/PageManager/data/pages/trustee/position-documents.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { useCallback } from 'react'; -import { GenericPageData } from '../../../pageInterface'; -import { FaLink, FaPlus } from 'react-icons/fa'; -import { useTrusteePositionDocuments, useTrusteePositionDocumentOperations } from '../../../../../hooks/useTrusteePositionDocuments'; - -// Helper function to convert attribute definitions to column config -const attributesToColumns = (attributes: any[]) => { - return attributes.map(attr => { - const isDateField = attr.type === 'date' || - /(date|Date|timestamp|Timestamp|created|Created|updated|Updated|at|At)$/i.test(attr.name); - - return { - key: attr.name, - label: attr.label || attr.name, - type: attr.type || 'string', - width: attr.width || 200, - minWidth: attr.minWidth || 100, - maxWidth: attr.maxWidth || 400, - sortable: attr.sortable !== false, - filterable: isDateField ? false : (attr.filterable !== false), - searchable: attr.searchable !== false, - filterOptions: attr.filterOptions - }; - }); -}; - -// Hook factory function for position-documents data -const createPositionDocumentsHook = () => { - return () => { - const { - positionDocuments, - loading, - error, - refetch, - removeOptimistically, - attributes, - permissions, - pagination, - fetchPositionDocumentById, - generateEditFieldsFromAttributes, - ensureAttributesLoaded - } = useTrusteePositionDocuments(); - const { - handlePositionDocumentDelete, - handlePositionDocumentCreate, - deletingPositionDocuments, - creatingPositionDocument, - deleteError, - createError - } = useTrusteePositionDocumentOperations(); - - const generatedColumns = attributes && attributes.length > 0 - ? attributesToColumns(attributes) - : undefined; - - const wrappedHandlePositionDocumentCreate = useCallback(async (formData: any) => { - return await handlePositionDocumentCreate(formData); - }, [handlePositionDocumentCreate]); - - const handleDeleteSingle = useCallback(async (positionDocument: any) => { - const success = await handlePositionDocumentDelete(positionDocument.id); - if (success) { - refetch(); - } - }, [handlePositionDocumentDelete, refetch]); - - const handleDeleteMultiple = useCallback(async (selectedPositionDocuments: any[]) => { - const positionDocumentIds = selectedPositionDocuments.map(pd => pd.id); - const results = await Promise.all( - positionDocumentIds.map(id => handlePositionDocumentDelete(id)) - ); - - const allSuccessful = results.every(result => result); - if (allSuccessful) { - refetch(); - } - }, [handlePositionDocumentDelete, refetch]); - - return { - data: positionDocuments, - loading, - error, - refetch, - removeOptimistically, - handleDelete: handlePositionDocumentDelete, - handleDeleteMultiple, - handlePositionDocumentCreate: wrappedHandlePositionDocumentCreate, - onDelete: handleDeleteSingle, - onDeleteMultiple: handleDeleteMultiple, - deletingPositionDocuments, - creatingPositionDocument, - deleteError, - createError, - attributes, - permissions, - columns: generatedColumns, - pagination, - fetchPositionDocumentById, - generateEditFieldsFromAttributes, - ensureAttributesLoaded - }; - }; -}; - -export const trusteePositionDocumentsPageData: GenericPageData = { - id: 'administration-trustee-position-documents', - path: 'administration/trustee/position-documents', - name: 'trustee.positionDocuments.title', - description: 'trustee.positionDocuments.description', - - // Parent page - parentPath: 'administration/trustee', - - // Visual - icon: FaLink, - title: 'trustee.positionDocuments.title', - subtitle: 'trustee.positionDocuments.subtitle', - - // Header buttons - headerButtons: [ - { - id: 'new-position-document', - label: 'trustee.positionDocuments.new', - icon: FaPlus, - variant: 'primary', - formConfig: { - fields: [ - { - key: 'organisationId', - label: 'trustee.positionDocuments.field.organisationId', - type: 'enum', - required: true, - optionsReference: 'trustee.organisation', - validator: (value: any) => { - if (!value || (typeof value === 'string' && value.trim() === '')) { - return 'Organisation is required'; - } - return null; - } - }, - { - key: 'contractId', - label: 'trustee.positionDocuments.field.contractId', - type: 'enum', - required: true, - optionsReference: 'trustee.contract', - dependsOn: 'organisationId', - validator: (value: any) => { - if (!value || (typeof value === 'string' && value.trim() === '')) { - return 'Contract is required'; - } - return null; - } - }, - { - key: 'positionId', - label: 'trustee.positionDocuments.field.positionId', - type: 'enum', - required: true, - optionsReference: 'trustee.position', - dependsOn: 'contractId', - validator: (value: any) => { - if (!value || (typeof value === 'string' && value.trim() === '')) { - return 'Position is required'; - } - return null; - } - }, - { - key: 'documentId', - label: 'trustee.positionDocuments.field.documentId', - type: 'enum', - required: true, - optionsReference: 'trustee.document', - dependsOn: 'contractId', - validator: (value: any) => { - if (!value || (typeof value === 'string' && value.trim() === '')) { - return 'Document is required'; - } - return null; - } - } - ], - popupTitle: 'trustee.positionDocuments.modal.create.title', - popupSize: 'medium', - createOperationName: 'handlePositionDocumentCreate', - successMessage: 'trustee.positionDocuments.create.success', - errorMessage: 'trustee.positionDocuments.create.error' - } - } - ], - - // Content sections - content: [ - { - id: 'position-documents-table', - type: 'table', - tableConfig: { - hookFactory: createPositionDocumentsHook, - actionButtons: [ - { - type: 'delete', - title: 'trustee.positionDocuments.action.delete', - idField: 'id', - operationName: 'handleDelete', - loadingStateName: 'deletingPositionDocuments', - disabled: (hookData: any) => { - if (!hookData?.permissions) return { disabled: false }; - const hasDelete = hookData.permissions.delete !== 'n' && hookData.permissions.view; - return { disabled: !hasDelete, message: 'No permission to delete links' }; - } - } - ], - searchable: true, - filterable: true, - sortable: true, - resizable: true, - pagination: true, - pageSize: 10, - className: 'position-documents-table' - } - } - ], - - // Page behavior - persistent: false, - preload: false, - preserveState: true, - moduleEnabled: true, - - // Lifecycle hooks - onActivate: async () => { - if (import.meta.env.DEV) console.log('Position-Documents activated'); - }, - onLoad: async () => { - if (import.meta.env.DEV) console.log('Position-Documents loaded'); - }, - onUnload: async () => { - if (import.meta.env.DEV) console.log('Position-Documents unloaded'); - } -}; diff --git a/src/core/PageManager/pageInterface.ts b/src/core/PageManager/pageInterface.ts deleted file mode 100644 index e805aae..0000000 --- a/src/core/PageManager/pageInterface.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * PageManager page interface and helpers. - * Used by PageData definitions and SidebarProvider. - */ - -import type React from 'react'; - -export interface GenericPageData { - id: string; - path: string; - name: string; - description?: string; - parentPath?: string; - icon?: React.ComponentType>; - title?: string; - subtitle?: string; - headerButtons?: Array>; - content?: Array>; - moduleEnabled?: boolean; - order?: number; - hide?: boolean; - showInSidebar?: boolean; - showInSidebarIf?: boolean; - hasSubpages?: boolean; - privilegeChecker?: () => Promise | boolean; - persistent?: boolean; - preload?: boolean; - preserveState?: boolean; - onActivate?: () => void | Promise; - onLoad?: () => void | Promise; - onUnload?: () => void | Promise; -} - -type TranslationFunction = (key: string, params?: Record) => string; - -/** - * Resolve display text from a page name (i18n key) via the translation function. - */ -export function resolveLanguageText( - name: string, - t: TranslationFunction -): string { - return t(name); -} diff --git a/src/layouts/MainLayout.tsx b/src/layouts/MainLayout.tsx index 880c702..42a9045 100644 --- a/src/layouts/MainLayout.tsx +++ b/src/layouts/MainLayout.tsx @@ -1,44 +1,108 @@ /** * MainLayout - * + * * Hauptlayout der Anwendung mit Sidebar und Content-Bereich. * Enthält den FeatureProvider für das Multi-Tenant-System. */ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { Outlet, useLocation } from 'react-router-dom'; import { FeatureProvider, useFeatureStore } from '../stores/featureStore'; import { MandateNavigation } from '../components/Navigation/MandateNavigation'; import { UserSection } from '../components/Navigation/UserSection'; -import { WorkspaceKeepAlive } from '../pages/views/workspace/WorkspaceKeepAlive'; -import { CommcoachKeepAlive } from '../pages/views/commcoach/CommcoachKeepAlive'; -import { GraphicalEditorKeepAlive } from '../pages/views/graphicalEditor/GraphicalEditorKeepAlive'; -import { AdminLanguagesKeepAlive } from '../pages/admin/AdminLanguagesKeepAlive'; +import { KEEP_ALIVE_ROUTES, hideFeatureOutlet } from '../config/keepAliveRoutes'; +import type { KeepAliveEntry, KeepAliveScopedEntry, KeepAliveUnscopedEntry } from '../types/keepAlive.types'; +import { isKeepAliveScoped } from '../types/keepAlive.types'; import styles from './MainLayout.module.css'; import { useLanguage } from '../providers/language/LanguageContext'; -const _WORKSPACE_ROUTE_RE = /\/mandates\/[^/]+\/workspace\/[^/]+\/dashboard/; -const _COMMCOACH_ROUTE_RE = /\/mandates\/[^/]+\/commcoach\/[^/]+\/session/; -const _GE_EDITOR_ROUTE_RE = /\/mandates\/[^/]+\/graphicalEditor\/[^/]+\/editor/; -const _ADMIN_LANGUAGES_RE = /\/admin\/languages(?:$|\/)/; +const keepAliveShellStyle = (isVisible: boolean, shellOverflowHidden: boolean): React.CSSProperties => ({ + display: isVisible ? 'flex' : 'none', + flexDirection: 'column', + position: 'absolute', + top: 'var(--mobile-topbar-height, 0px)', + left: 0, + right: 0, + bottom: 0, + ...(shellOverflowHidden ? { overflow: 'hidden' as const } : {}), +}); + +const RoutedKeepAliveUnscoped: React.FC<{ entry: KeepAliveUnscopedEntry; pathname: string }> = ({ + entry, + pathname, +}) => { + const isVisible = entry.pathRegex.test(pathname); + return ( +
+ {entry.render()} +
+ ); +}; + +const RoutedKeepAliveScoped: React.FC<{ entry: KeepAliveScopedEntry; pathname: string }> = ({ + entry, + pathname, +}) => { + const isVisible = entry.pathRegex.test(pathname); + const { + scopeRegex, + requireMandateForMount = true, + shellOverflowHidden = true, + render, + } = entry; + + const cachedMandateIdRef = useRef(''); + const cachedInstanceIdRef = useRef(''); + + const match = pathname.match(scopeRegex); + if (match?.[1] && match?.[2]) { + cachedMandateIdRef.current = match[1]; + cachedInstanceIdRef.current = match[2]; + } + + const mandateId = cachedMandateIdRef.current; + const instanceId = cachedInstanceIdRef.current; + + const scopeReady = requireMandateForMount + ? !!(mandateId && instanceId) + : !!instanceId; + + if (!scopeReady) { + return null; + } + + const scopeKey = `${mandateId}:${instanceId}`; + + return ( +
+ {render({ mandateId, instanceId, scopeKey })} +
+ ); +}; + +const RoutedKeepAliveSlot: React.FC<{ entry: KeepAliveEntry; pathname: string }> = ({ + entry, + pathname, +}) => { + if (!isKeepAliveScoped(entry)) { + return ; + } + return ; +}; // ============================================================================= // INNER LAYOUT (mit Zugriff auf Store) // ============================================================================= const MainLayoutInner: React.FC = () => { - const { t } = useLanguage(); + const { t } = useLanguage(); const { loadFeatures, initialized, loading, error } = useFeatureStore(); const location = useLocation(); const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false); - const isWorkspaceKeepAliveVisible = _WORKSPACE_ROUTE_RE.test(location.pathname); - const isCommcoachKeepAliveVisible = _COMMCOACH_ROUTE_RE.test(location.pathname); - const isGEEditorKeepAliveVisible = _GE_EDITOR_ROUTE_RE.test(location.pathname); - const isLanguagesKeepAliveVisible = _ADMIN_LANGUAGES_RE.test(location.pathname); - const hideOutletShell = isWorkspaceKeepAliveVisible || isCommcoachKeepAliveVisible || isGEEditorKeepAliveVisible || isLanguagesKeepAliveVisible; - + const hideOutletShell = hideFeatureOutlet(location.pathname); + // Features laden beim Mount useEffect(() => { if (!initialized && !loading) { @@ -60,7 +124,7 @@ const MainLayoutInner: React.FC = () => { window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, []); - + return (
{isMobileSidebarOpen && ( @@ -74,35 +138,25 @@ const MainLayoutInner: React.FC = () => { {/* Sidebar */} - + {/* Content */}
@@ -113,17 +167,12 @@ const MainLayoutInner: React.FC = () => { > ☰ - PowerOn + PowerOn
- - - - + {KEEP_ALIVE_ROUTES.map((routeEntry) => ( + + ))}
> = { }, graphicalEditor: { editor: GraphicalEditorPage, - workflows: GraphicalEditorWorkflowsPage, 'workflows-tasks': GraphicalEditorWorkflowsTasksPage, templates: GraphicalEditorTemplatesPage, }, @@ -192,6 +192,7 @@ interface FeatureViewPageProps { } export const FeatureViewPage: React.FC = ({ view }) => { + const location = useLocation(); const { instance, featureCode, isValid } = useCurrentInstance(); // Berechtigungs-Check @@ -226,20 +227,10 @@ export const FeatureViewPage: React.FC = ({ view }) => { if (!canView && view !== 'not-found') { return ; } - - // Workspace dashboard is rendered persistently by WorkspaceKeepAlive at MainLayout level; - // other workspace views (e.g. settings, editor) use the standard FeatureViewPage rendering. - if (featureCode === 'workspace' && view !== 'settings' && view !== 'editor' && view !== 'rag-insights') { - return null; - } - // CommCoach session is rendered persistently by CommcoachKeepAlive at MainLayout level. - if (featureCode === 'commcoach' && view === 'session') { - return null; - } - - // GraphicalEditor editor is rendered persistently by GraphicalEditorKeepAlive at MainLayout level. - if (featureCode === 'graphicalEditor' && view === 'editor') { + // Feature outlet is hidden for paths configured in KEEP_ALIVE_ROUTES (rendered in MainLayout). + // Add new persistent workspace URLs there if needed. + if (hideFeatureOutlet(location.pathname)) { return null; } diff --git a/src/pages/admin/AdminLanguagesKeepAlive.tsx b/src/pages/admin/AdminLanguagesKeepAlive.tsx deleted file mode 100644 index 07a02b6..0000000 --- a/src/pages/admin/AdminLanguagesKeepAlive.tsx +++ /dev/null @@ -1,35 +0,0 @@ -/** - * AdminLanguagesKeepAlive - * - * Keeps the AdminLanguagesPage mounted across route changes so that - * long-running AI translation progress, table state, and selections - * survive when the user navigates away and returns. - */ - -import React from 'react'; -import { AdminLanguagesPage } from './AdminLanguagesPage'; - -interface AdminLanguagesKeepAliveProps { - isVisible: boolean; -} - -export const AdminLanguagesKeepAlive: React.FC = ({ isVisible }) => { - return ( -
- -
- ); -}; - -export default AdminLanguagesKeepAlive; diff --git a/src/pages/views/commcoach/CommcoachKeepAlive.tsx b/src/pages/views/commcoach/CommcoachKeepAlive.tsx deleted file mode 100644 index 9149594..0000000 --- a/src/pages/views/commcoach/CommcoachKeepAlive.tsx +++ /dev/null @@ -1,55 +0,0 @@ -/** - * CommcoachKeepAlive - * - * Keeps the CommCoach session page mounted across route changes. - * The voice session must persist when the user navigates to other tabs. - * Only the "session" tab is kept alive; modules/dashboard can unmount freely. - * - * Persistence is scoped per `(mandateId, instanceId)` — switching to a - * different mandate or instance unmounts the previous view. - */ - -import React, { useRef } from 'react'; -import { useLocation } from 'react-router-dom'; -import { CommcoachSessionView } from './CommcoachSessionView'; - -const _COMMCOACH_SESSION_ROUTE_RE = /\/mandates\/([^/]+)\/commcoach\/([^/]+)\/session/; - -interface CommcoachKeepAliveProps { - isVisible: boolean; -} - -export const CommcoachKeepAlive: React.FC = ({ isVisible }) => { - const location = useLocation(); - const cachedMandateIdRef = useRef(''); - const cachedInstanceIdRef = useRef(''); - - const match = location.pathname.match(_COMMCOACH_SESSION_ROUTE_RE); - if (match?.[1] && match?.[2]) { - cachedMandateIdRef.current = match[1]; - cachedInstanceIdRef.current = match[2]; - } - - const mandateId = cachedMandateIdRef.current; - const instanceId = cachedInstanceIdRef.current; - if (!mandateId || !instanceId) return null; - const scopeKey = `${mandateId}:${instanceId}`; - - return ( -
- -
- ); -}; - -export default CommcoachKeepAlive; diff --git a/src/pages/views/graphicalEditor/GraphicalEditorKeepAlive.tsx b/src/pages/views/graphicalEditor/GraphicalEditorKeepAlive.tsx deleted file mode 100644 index fdf5b78..0000000 --- a/src/pages/views/graphicalEditor/GraphicalEditorKeepAlive.tsx +++ /dev/null @@ -1,70 +0,0 @@ -/** - * GraphicalEditorKeepAlive - * - * Keeps the GraphicalEditorPage mounted across route changes so the canvas - * state, SSE connections, and editor context survive navigation to ANY page - * (other features, admin, settings, etc.). - * - * Persistence is scoped per `(mandateId, instanceId)`: when the user switches - * to a DIFFERENT mandate or instance via the navigator, the previous editor - * mount is discarded and a fresh page is mounted. Otherwise stale state from - * mandate A leaks into mandate B and saves end up hitting the wrong tenant - * (HTTP 404 / "not found"). - * - * Implementation: feeds the cached `(mandate, instance)` tuple into both - * `props` and `key`. React reuses the mount as long as the tuple stays - * identical and unmounts/remounts on change. - */ - -import React, { useRef } from 'react'; -import { useLocation } from 'react-router-dom'; -import { GraphicalEditorPage } from './GraphicalEditorPage'; - -const _GE_EDITOR_ROUTE_RE = /\/mandates\/([^/]+)\/graphicalEditor\/([^/]+)\/editor/; - -interface GraphicalEditorKeepAliveProps { - isVisible: boolean; -} - -export const GraphicalEditorKeepAlive: React.FC = ({ isVisible }) => { - const location = useLocation(); - const cachedMandateIdRef = useRef(''); - const cachedInstanceIdRef = useRef(''); - const hasEverMountedRef = useRef(false); - - const match = location.pathname.match(_GE_EDITOR_ROUTE_RE); - if (match?.[1] && match?.[2]) { - cachedMandateIdRef.current = match[1]; - cachedInstanceIdRef.current = match[2]; - hasEverMountedRef.current = true; - } - - if (!hasEverMountedRef.current) return null; - - const mandateId = cachedMandateIdRef.current; - const instanceId = cachedInstanceIdRef.current; - const scopeKey = `${mandateId}:${instanceId}`; - - return ( -
- -
- ); -}; - -export default GraphicalEditorKeepAlive; diff --git a/src/pages/views/graphicalEditor/GraphicalEditorWorkflowsPage.tsx b/src/pages/views/graphicalEditor/GraphicalEditorWorkflowsPage.tsx deleted file mode 100644 index 0f12750..0000000 --- a/src/pages/views/graphicalEditor/GraphicalEditorWorkflowsPage.tsx +++ /dev/null @@ -1,441 +0,0 @@ -/** - * GraphicalEditorWorkflowsPage - * List of saved workflows with FormGeneratorTable. - * Shows: label, active, isRunning, stuckAt, createdAt, lastStartedAt, runCount. - * Filter: Alle | Aktiv | Inaktiv. - * Actions: Edit, Delete, Aktivieren/Deaktivieren, Ausführen (nur bei manuellem Trigger). - */ - -import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react'; -import { useNavigate, useParams } from 'react-router-dom'; -import { FaPlay, FaSync, FaCheck, FaBan, FaPen, FaFileImport, FaFileExport } from 'react-icons/fa'; -import { usePrompt } from '../../../hooks/usePrompt'; -import { FormGeneratorTable, type ColumnConfig } from '../../../components/FormGenerator'; -import { useInstanceId } from '../../../hooks/useCurrentInstance'; -import { useApiRequest } from '../../../hooks/useApi'; -import { - fetchWorkflows, - deleteWorkflow, - executeGraph, - updateWorkflow, - importWorkflowFromFile, - exportWorkflowToFile, - isWorkflowFileContent, - workflowFileNameFor, - WORKFLOW_FILE_EXTENSION, - type Automation2Workflow, - type WorkflowFileEnvelope, -} from '../../../api/workflowApi'; -import { fetchAttributes } from '../../../api/attributesApi'; -import type { AttributeDefinition } from '../../../api/attributesApi'; -import { resolveColumnTypes } from '../../../utils/columnTypeResolver'; -import { useToast } from '../../../contexts/ToastContext'; -import { formatUnixTimestamp } from '../../../utils/time'; -import styles from '../../../pages/admin/Admin.module.css'; - -import { useLanguage } from '../../../providers/language/LanguageContext'; - -function formatTs(ts?: number): string { - if (ts == null || ts <= 0) return '—'; - const sec = ts < 1e12 ? ts : ts / 1000; - const { time } = formatUnixTimestamp(sec, undefined, { - day: '2-digit', - month: '2-digit', - year: 'numeric', - hour: '2-digit', - minute: '2-digit', - }); - return time; -} - -export const GraphicalEditorWorkflowsPage: React.FC = () => { - const { t } = useLanguage(); - - const instanceId = useInstanceId(); - const { mandateId } = useParams<{ mandateId: string }>(); - const { request } = useApiRequest(); - const navigate = useNavigate(); - const { showSuccess, showError } = useToast(); - const { prompt: promptInput, PromptDialog } = usePrompt(); - - const [workflows, setWorkflows] = useState([]); - const [loading, setLoading] = useState(true); - const [executingId, setExecutingId] = useState(null); - const [togglingId, setTogglingId] = useState(null); - const [activeFilter, setActiveFilter] = useState<'all' | 'active' | 'inactive'>('all'); - - const [paginationMeta, setPaginationMeta] = useState(null); - const [importing, setImporting] = useState(false); - const importFileInputRef = useRef(null); - const [backendAttributes, setBackendAttributes] = useState([]); - - useEffect(() => { - fetchAttributes(request, 'Automation2WorkflowView') - .then(setBackendAttributes) - .catch((err) => { - console.error('[graphicalEditor] fetchAttributes Automation2WorkflowView failed', err); - }); - }, [request]); - - const load = useCallback(async (paginationParams?: any) => { - if (!instanceId) return; - setLoading(true); - try { - const active = activeFilter === 'active' ? true : activeFilter === 'inactive' ? false : undefined; - const result = await fetchWorkflows(request, instanceId, { active, pagination: paginationParams }); - if (result && typeof result === 'object' && 'items' in result && !Array.isArray(result)) { - setWorkflows((result as any).items); - setPaginationMeta((result as any).pagination); - } else { - setWorkflows(result as Automation2Workflow[]); - setPaginationMeta(null); - } - } catch (e) { - console.error('[graphicalEditor] load workflows failed', e); - showError(t('Fehler beim Laden der Workflows')); - } finally { - setLoading(false); - } - }, [instanceId, request, showError, activeFilter, t]); - - useEffect(() => { - load(); - }, [load]); - - const handleDelete = useCallback( - async (workflowId: string): Promise => { - if (!instanceId) return false; - try { - await deleteWorkflow(request, instanceId, workflowId); - showSuccess(t('Workflow gelöscht')); - await load(); - return true; - } catch (e: any) { - showError(t('Fehler: {msg}', { msg: e?.message || t('Löschen fehlgeschlagen') })); - return false; - } - }, - [instanceId, request, showSuccess, showError, load, t] - ); - - const handleEdit = useCallback( - (row: Automation2Workflow) => { - if (!mandateId || !instanceId) return; - navigate(`/mandates/${mandateId}/graphicalEditor/${instanceId}/editor?workflowId=${row.id}`); - }, - [mandateId, instanceId, navigate] - ); - - const hasManualTrigger = useCallback((row: Automation2Workflow): boolean => { - const invs = row.invocations || []; - return invs.some((i) => i.enabled && (i.kind === 'manual' || i.kind === 'api')); - }, []); - - const handleToggleActive = useCallback( - async (row: Automation2Workflow) => { - if (!instanceId) return; - const next = !(row.active !== false); - setTogglingId(row.id); - try { - await updateWorkflow(request, instanceId, row.id, { active: next }); - showSuccess(next ? t('Workflow aktiviert') : t('Workflow deaktiviert')); - await load(); - } catch (e: any) { - showError(t('Fehler: {msg}', { msg: e?.message || t('Status-Update fehlgeschlagen') })); - } finally { - setTogglingId(null); - } - }, - [instanceId, request, showSuccess, showError, load, t] - ); - - const handleRename = useCallback( - async (row: Automation2Workflow) => { - if (!instanceId) return; - const newLabel = await promptInput(t('Neuer Name:'), { - title: t('Workflow umbenennen'), - defaultValue: row.label, - placeholder: t('Workflow-Name'), - }); - if (!newLabel || newLabel.trim() === row.label) return; - try { - await updateWorkflow(request, instanceId, row.id, { label: newLabel.trim() }); - showSuccess(t('Workflow umbenannt')); - await load(); - } catch (e: any) { - showError(t('Fehler: {msg}', { msg: e?.message || t('Umbenennen fehlgeschlagen') })); - } - }, - [instanceId, request, promptInput, showSuccess, showError, load, t] - ); - - const handleExecute = useCallback( - async (row: Automation2Workflow) => { - if (!instanceId) return; - setExecutingId(row.id); - try { - const invs = row.invocations || []; - const primary = - invs.find((i) => i.enabled && i.kind === 'manual') || - invs.find((i) => i.enabled && (i.kind === 'form' || i.kind === 'api')); - const result = await executeGraph(request, instanceId, row.graph!, row.id, { - ...(primary ? { entryPointId: primary.id } : {}), - }); - if (result?.success) { - if (result?.paused) { - showSuccess(t('Workflow gestartet und bei Human Task pausiert. Öffne Workflows & Tasks.')); - } else { - showSuccess(t('Workflow ausgeführt')); - } - await load(); - } else { - showError(result?.error || t('Ausführung fehlgeschlagen')); - } - } catch (e: any) { - showError(t('Fehler: {msg}', { msg: e?.message || t('Ausführung fehlgeschlagen') })); - } finally { - setExecutingId(null); - } - }, - [instanceId, request, showSuccess, showError, load, t] - ); - - const handleExport = useCallback( - async (row: Automation2Workflow) => { - if (!instanceId) return; - try { - const result = await exportWorkflowToFile(request, instanceId, row.id, false); - const fileName = result.fileName || workflowFileNameFor(row.label); - const blob = new Blob([JSON.stringify(result.envelope, null, 2)], { - type: 'application/json;charset=utf-8', - }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = fileName; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - showSuccess(t('Workflow als Datei exportiert')); - } catch (e: any) { - showError(t('Fehler: {msg}', { msg: e?.message || t('Export fehlgeschlagen') })); - } - }, - [instanceId, request, showSuccess, showError, t], - ); - - const handleImportFileSelected = useCallback( - async (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - e.target.value = ''; - if (!file || !instanceId) return; - setImporting(true); - try { - const text = await file.text(); - let envelope: WorkflowFileEnvelope; - try { - envelope = JSON.parse(text) as WorkflowFileEnvelope; - } catch { - showError(t('Datei ist kein gültiges JSON')); - return; - } - if (!isWorkflowFileContent(envelope)) { - showError(t('Datei ist kein PowerOn-Workflow ({ext})', { ext: WORKFLOW_FILE_EXTENSION })); - return; - } - const result = await importWorkflowFromFile(request, instanceId, { envelope }); - const warnings = result?.warnings ?? []; - if (warnings.length > 0) { - showSuccess( - t('Workflow importiert ({n} Warnungen). Aktivierung manuell.', { n: warnings.length }), - ); - } else { - showSuccess(t('Workflow importiert (deaktiviert). Bitte vor Aktivierung prüfen.')); - } - await load(); - } catch (e: any) { - showError(t('Fehler: {msg}', { msg: e?.message || t('Import fehlgeschlagen') })); - } finally { - setImporting(false); - } - }, - [instanceId, request, showSuccess, showError, load, t], - ); - - const _rawColumns: ColumnConfig[] = useMemo(() => [ - { key: 'label', label: t('Workflow'), width: 200, sortable: true, filterable: true }, - { key: 'active', width: 80, sortable: true, filterable: true }, - { key: 'isRunning', width: 80, sortable: true, filterable: true }, - { - key: 'stuckAtNodeLabel', - label: t('steht bei'), - width: 160, - sortable: false, - filterable: false, - formatter: (value: string, row: Automation2Workflow) => - row.isRunning && (value || row.stuckAtNodeId) - ? value || row.stuckAtNodeId || '—' - : '—', - }, - { - key: 'sysCreatedAt', - label: t('Erstellt'), - width: 140, - sortable: true, - filterable: true, - formatter: (v: number) => formatTs(v), - }, - { - key: 'lastStartedAt', - label: t('zuletzt gestartet'), - width: 160, - sortable: true, - filterable: true, - formatter: (v: number) => formatTs(v), - }, - { - key: 'runCount', - label: t('Läufe'), - width: 80, - sortable: true, - filterable: true, - formatter: (v: number) => (v != null ? String(v) : '0'), - }, - ], [t]); - - const columns = useMemo( - () => resolveColumnTypes(_rawColumns, backendAttributes), - [_rawColumns, backendAttributes], - ); - - const hookData = { - refetch: load, - handleDelete: (id: string) => handleDelete(id), - pagination: paginationMeta, - }; - - if (!instanceId) { - return ( -
-

{t('Keine Feature-Instanz gefunden')}

-
- ); - } - - return ( -
-
-
-

- {t('Workflows verwalten, ausführen und bearbeiten')} -

-
-
-
- {(['all', 'active', 'inactive'] as const).map((f) => ( - - ))} -
- - - -
-
- -
- - data={workflows} - columns={columns} - loading={loading} - pagination={true} - pageSize={25} - searchable={true} - filterable={true} - sortable={true} - selectable={true} - apiEndpoint={`/api/workflows/${instanceId}/workflows`} - actionButtons={[ - { - type: 'edit', - title: t('bearbeiten'), - onAction: handleEdit, - }, - { - type: 'delete', - title: t('löschen'), - }, - ]} - customActions={[ - { - id: 'rename', - icon: , - title: t('umbenennen'), - onClick: (row) => handleRename(row), - }, - { - id: 'activate', - icon: , - title: t('aktivieren'), - onClick: (row) => handleToggleActive(row), - loading: (row) => togglingId === row.id, - visible: (row) => row.active === false, - }, - { - id: 'deactivate', - icon: , - title: t('deaktivieren'), - onClick: (row) => handleToggleActive(row), - loading: (row) => togglingId === row.id, - visible: (row) => row.active !== false, - }, - { - id: 'execute', - icon: , - title: t('ausführen'), - onClick: (row) => handleExecute(row), - loading: (row) => executingId === row.id, - visible: (row) => hasManualTrigger(row), - }, - { - id: 'export', - icon: , - title: t('Als Datei exportieren'), - onClick: (row) => handleExport(row), - }, - ]} - onDelete={(row) => handleDelete(row.id)} - hookData={hookData} - emptyMessage={t('Keine Workflows gefunden. Erstelle einen.')} - /> -
- -
- ); -}; diff --git a/src/pages/views/graphicalEditor/GraphicalEditorWorkflowsTasksPage.tsx b/src/pages/views/graphicalEditor/GraphicalEditorWorkflowsTasksPage.tsx index 9299209..1788959 100644 --- a/src/pages/views/graphicalEditor/GraphicalEditorWorkflowsTasksPage.tsx +++ b/src/pages/views/graphicalEditor/GraphicalEditorWorkflowsTasksPage.tsx @@ -76,7 +76,7 @@ function hasManualOrFormInvocation(wf: Automation2Workflow): boolean { } /** - * Primary entry for execute — matches GraphicalEditorWorkflowsPage.handleExecute + * Primary entry for execute — POST /api/workflows/{instanceId}/execute with collected inputs. * (manual first, then form or api). */ function getPrimaryEntryPoint(wf: Automation2Workflow) { diff --git a/src/pages/views/workspace/WorkspaceKeepAlive.tsx b/src/pages/views/workspace/WorkspaceKeepAlive.tsx deleted file mode 100644 index 7cb7575..0000000 --- a/src/pages/views/workspace/WorkspaceKeepAlive.tsx +++ /dev/null @@ -1,57 +0,0 @@ -/** - * WorkspaceKeepAlive - * - * Renders the WorkspacePage permanently at the MainLayout level so it - * survives route changes. Visibility is toggled via CSS `display` - * instead of mount / unmount, preserving messages, SSE connections, - * files, and all other workspace state. - * - * Persistence is scoped per `(mandateId, instanceId)` — switching to a - * different mandate or instance via the navigator unmounts the previous - * page and mounts a fresh one (otherwise stale state from tenant A - * leaks into tenant B). - */ - -import React, { useRef } from 'react'; -import { useLocation } from 'react-router-dom'; -import { WorkspacePage } from './WorkspacePage'; - -const _WORKSPACE_ROUTE_RE = /\/mandates\/([^/]+)\/workspace\/([^/]+)/; - -interface WorkspaceKeepAliveProps { - isVisible: boolean; -} - -export const WorkspaceKeepAlive: React.FC = ({ isVisible }) => { - const location = useLocation(); - const cachedMandateIdRef = useRef(''); - const cachedInstanceIdRef = useRef(''); - - const match = location.pathname.match(_WORKSPACE_ROUTE_RE); - if (match?.[1] && match?.[2]) { - cachedMandateIdRef.current = match[1]; - cachedInstanceIdRef.current = match[2]; - } - - const mandateId = cachedMandateIdRef.current; - const instanceId = cachedInstanceIdRef.current; - if (!instanceId) return null; - const scopeKey = `${mandateId}:${instanceId}`; - - return ( -
- -
- ); -}; diff --git a/src/test/setup.ts b/src/test/setup.ts deleted file mode 100644 index 4177b43..0000000 --- a/src/test/setup.ts +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright (c) 2025 Patrick Motsch -// All rights reserved. -// -// Vitest global setup: jest-dom matchers + jsdom polyfills required by some -// of our components (ResizeObserver, matchMedia, scrollIntoView). - -import '@testing-library/jest-dom/vitest'; -import { afterEach } from 'vitest'; -import { cleanup } from '@testing-library/react'; - -afterEach(() => { - cleanup(); -}); - -class _ResizeObserverPolyfill { - observe(): void {} - unobserve(): void {} - disconnect(): void {} -} - -if (!('ResizeObserver' in globalThis)) { - (globalThis as unknown as { ResizeObserver: typeof _ResizeObserverPolyfill }).ResizeObserver = - _ResizeObserverPolyfill; -} - -if (!('matchMedia' in window)) { - Object.defineProperty(window, 'matchMedia', { - writable: true, - value: (query: string) => ({ - matches: false, - media: query, - onchange: null, - addListener: () => {}, - removeListener: () => {}, - addEventListener: () => {}, - removeEventListener: () => {}, - dispatchEvent: () => false, - }), - }); -} - -if (!('scrollIntoView' in HTMLElement.prototype)) { - (HTMLElement.prototype as unknown as { scrollIntoView: () => void }).scrollIntoView = - function (): void {}; -} diff --git a/src/test/smoke.test.ts b/src/test/smoke.test.ts deleted file mode 100644 index 256daf9..0000000 --- a/src/test/smoke.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright (c) 2025 Patrick Motsch -// All rights reserved. -// -// Smoke test that validates the Vitest + jsdom setup is wired correctly. -// If this fails the rest of the suite is meaningless. - -import { describe, expect, it } from 'vitest'; - -describe('vitest smoke', () => { - it('runs in jsdom and has window/document', () => { - expect(typeof window).toBe('object'); - expect(typeof document).toBe('object'); - }); - - it('has jest-dom matchers via globals setup', () => { - const div = document.createElement('div'); - div.textContent = 'hello'; - document.body.appendChild(div); - expect(div).toBeInTheDocument(); - expect(div).toHaveTextContent('hello'); - document.body.removeChild(div); - }); -}); diff --git a/src/types/keepAlive.types.ts b/src/types/keepAlive.types.ts new file mode 100644 index 0000000..14bdbd5 --- /dev/null +++ b/src/types/keepAlive.types.ts @@ -0,0 +1,34 @@ +import type { ReactNode } from 'react'; + +export interface KeepAliveRenderContext { + mandateId: string; + instanceId: string; + scopeKey: string; +} + +/** Mandate-scoped persistent routes: cache (mandateId, instanceId) from the URL while hidden. */ +export interface KeepAliveScopedEntry { + id: string; + pathRegex: RegExp; + scopeRegex: RegExp; + /** + * If false, mount once instanceId is known (Workspace). If true, both ids required (Commcoach, Graphical Editor). + */ + requireMandateForMount?: boolean; + /** Commcoach shell omits overflow:hidden; other routes use hidden. */ + shellOverflowHidden?: boolean; + render: (ctx: KeepAliveRenderContext) => ReactNode; +} + +/** Routes kept alive without mandate/instance scope (e.g. admin). */ +export interface KeepAliveUnscopedEntry { + id: string; + pathRegex: RegExp; + render: () => ReactNode; +} + +export type KeepAliveEntry = KeepAliveScopedEntry | KeepAliveUnscopedEntry; + +export function isKeepAliveScoped(entry: KeepAliveEntry): entry is KeepAliveScopedEntry { + return 'scopeRegex' in entry; +} diff --git a/src/types/mandate.ts b/src/types/mandate.ts index 1824530..5e36e27 100644 --- a/src/types/mandate.ts +++ b/src/types/mandate.ts @@ -259,7 +259,6 @@ export const FEATURE_REGISTRY: Record = { icon: 'sitemap', views: [ { code: 'editor', label: 'Editor', path: 'editor' }, - { code: 'workflows', label: 'Workflows', path: 'workflows' }, { code: 'templates', label: 'Vorlagen', path: 'templates' }, { code: 'workflows-tasks', label: 'Tasks', path: 'workflows-tasks' }, { code: 'dashboard', label: 'Dashboard', path: 'dashboard' },