Fix: Navigation Executor + liste scrollable + sauvegarde variables

- Ajout onglets Standard/VWB dans Executor pour permettre la navigation
- Liste d'exécution scrollable (max 300px)
- Synchronisation bidirectionnelle des variables avec le workflow

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Dom
2026-01-14 22:02:49 +01:00
parent c636f7f163
commit 1d2b643aa6
2 changed files with 1645 additions and 0 deletions

View File

@@ -0,0 +1,630 @@
/**
* Application principale Visual Workflow Builder V2
* Auteur : Dom, Alice, Kiro - 09 janvier 2026
*
* Interface moderne pour la création de workflows d'automatisation RPA
* avec sélection visuelle basée sur la vision et terminologie française.
*/
import { useState, useCallback, useMemo, useEffect, Component, ErrorInfo, ReactNode } from 'react';
import {
Box,
CssBaseline,
ThemeProvider,
createTheme,
AppBar,
Toolbar,
Typography,
Tabs,
Tab,
Drawer,
Alert,
AlertTitle,
Button,
} from '@mui/material';
import { Provider } from 'react-redux';
import { ReactFlowProvider } from '@xyflow/react';
import { store } from './store';
// Composants principaux
import Canvas from './components/Canvas';
import Palette from './components/Palette';
import PropertiesPanel from './components/PropertiesPanel';
import VariableManager from './components/VariableManager';
import DocumentationTab from './components/DocumentationTab';
import Validator from './components/Validator';
import Executor from './components/Executor';
import WorkflowManager from './components/WorkflowManager';
import ContextualHelp from './components/ContextualHelp';
import KeyboardShortcuts from './components/KeyboardShortcuts';
import AccessibilityProvider from './components/AccessibilityProvider';
import ConnectionIndicator from './components/ConnectionIndicator';
import TestCatalogLoader from './components/TestCatalogLoader';
import VWBIntegrationTest from './components/VWBIntegrationTest';
// Hooks personnalisés
import { useKeyboardNavigation } from './hooks/useKeyboardNavigation';
import { useResponsiveLayout } from './hooks/useResponsiveLayout';
// Import des types partagés
import {
Step,
Variable,
StepTemplate,
StepCategory,
Position,
Workflow,
WorkflowConnection,
StepExecutionState,
} from './types';
// Error Boundary pour gérer les erreurs de l'application
interface ErrorBoundaryState {
hasError: boolean;
error?: Error;
errorInfo?: ErrorInfo;
}
class AppErrorBoundary extends Component<
{ children: ReactNode },
ErrorBoundaryState
> {
constructor(props: { children: ReactNode }) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
// Met à jour l'état pour afficher l'interface d'erreur
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
// Log l'erreur pour le debugging
console.error('Erreur capturée par Error Boundary:', error, errorInfo);
this.setState({
error,
errorInfo,
});
}
render() {
if (this.state.hasError) {
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
p: 3,
}}
>
<Alert severity="error" sx={{ maxWidth: 600, mb: 2 }}>
<AlertTitle>Une erreur inattendue s'est produite</AlertTitle>
<Typography variant="body2" gutterBottom>
L'application a rencontré une erreur et ne peut pas continuer.
</Typography>
{this.state.error && (
<Typography variant="caption" component="pre" sx={{ mt: 1 }}>
{this.state.error.message}
</Typography>
)}
</Alert>
<Button
variant="contained"
onClick={() => window.location.reload()}
sx={{ mt: 2 }}
>
Recharger l'application
</Button>
</Box>
);
}
return this.props.children;
}
}
// Thème Material-UI personnalisé avec couleurs françaises - mémorisé pour éviter les re-créations
const theme = createTheme({
palette: {
primary: {
main: '#1976d2', // Bleu français
},
secondary: {
main: '#dc004e', // Rouge français
},
background: {
default: '#fafafa',
paper: '#ffffff',
},
},
typography: {
fontFamily: '"Segoe UI", "Roboto", "Helvetica", "Arial", sans-serif',
},
components: {
MuiAppBar: {
styleOverrides: {
root: {
backgroundColor: '#ffffff',
color: '#1976d2',
boxShadow: '0 1px 3px rgba(0,0,0,0.12)',
},
},
},
},
});
/**
* Composant principal de l'application
*/
function App() {
// États principaux
const [selectedStep, setSelectedStep] = useState<Step | null>(null);
const [variables, setVariables] = useState<Variable[]>([]);
const [searchTerm, setSearchTerm] = useState('');
const [activeDocTab, setActiveDocTab] = useState(0);
const [isDocDrawerOpen, setIsDocDrawerOpen] = useState(false);
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false);
// Hooks d'accessibilité et responsivité
const responsiveLayout = useResponsiveLayout();
// État du workflow
const [workflow, setWorkflow] = useState<Workflow>({
id: 'workflow_1',
name: 'Nouveau Workflow',
description: 'Workflow créé avec le Visual Workflow Builder V2',
steps: [],
connections: [],
variables: [],
createdAt: new Date(),
updatedAt: new Date(),
});
// Configuration de la navigation au clavier
const keyboardNavigation = useKeyboardNavigation({
onStepSelect: (stepId: string) => {
const step = workflow.steps.find(s => s.id === stepId);
if (step) handleStepSelect(step);
},
onStepMove: (stepId: string, direction: 'up' | 'down' | 'left' | 'right') => {
const step = workflow.steps.find(s => s.id === stepId);
if (step) {
const moveDistance = 20;
const newPosition = { ...step.position };
switch (direction) {
case 'up': newPosition.y -= moveDistance; break;
case 'down': newPosition.y += moveDistance; break;
case 'left': newPosition.x -= moveDistance; break;
case 'right': newPosition.x += moveDistance; break;
}
handleStepMove(stepId, newPosition);
}
},
onStepDelete: (stepId: string) => handleStepDelete(stepId),
onSave: () => console.log('Sauvegarde via clavier'),
onHelp: () => setShowKeyboardShortcuts(true),
selectedStepId: selectedStep?.id,
availableStepIds: workflow.steps.map(s => s.id),
isEnabled: true,
});
// Gestionnaires d'événements optimisés avec useCallback
const handleStepSelect = useCallback((step: Step | null) => {
setSelectedStep(step);
}, []);
// Synchroniser selectedStep avec le workflow quand celui-ci change
// Ceci corrige le bug où les paramètres (visual_anchor) ne sont pas persistés dans l'UI
useEffect(() => {
if (selectedStep) {
const updatedStep = workflow.steps.find(s => s.id === selectedStep.id);
if (updatedStep) {
// Vérifier si les données ont changé avant de mettre à jour
const currentDataStr = JSON.stringify(selectedStep.data);
const updatedDataStr = JSON.stringify(updatedStep.data);
if (currentDataStr !== updatedDataStr) {
setSelectedStep(updatedStep);
}
}
}
}, [workflow.steps, selectedStep?.id]);
const handleStepMove = useCallback((stepId: string, position: Position) => {
console.log('Déplacement étape:', stepId, position);
setWorkflow(prev => ({
...prev,
steps: prev.steps.map(step =>
step.id === stepId ? { ...step, position } : step
),
updatedAt: new Date(),
}));
}, []);
const handleConnection = (source: string, target: string) => {
console.log('Connexion:', source, '->', target);
const newConnection: WorkflowConnection = {
id: `conn_${Date.now()}`,
source,
target,
};
setWorkflow(prev => ({
...prev,
connections: [...prev.connections, newConnection],
updatedAt: new Date(),
}));
};
const handleStepAdd = (stepData: Omit<Step, 'id'>) => {
console.log('Ajout étape:', stepData);
const newStep: Step = {
...stepData,
id: `step_${Date.now()}`,
executionState: StepExecutionState.IDLE,
validationErrors: [],
};
setWorkflow(prev => ({
...prev,
steps: [...prev.steps, newStep],
updatedAt: new Date(),
}));
};
const handleStepDelete = (stepId: string) => {
console.log('Suppression étape:', stepId);
setWorkflow(prev => ({
...prev,
steps: prev.steps.filter(step => step.id !== stepId),
connections: prev.connections.filter(conn =>
conn.source !== stepId && conn.target !== stepId
),
updatedAt: new Date(),
}));
// Désélectionner l'étape si elle était sélectionnée
if (selectedStep?.id === stepId) {
setSelectedStep(null);
}
};
const handleConnectionDelete = (connectionId: string) => {
console.log('Suppression connexion:', connectionId);
setWorkflow(prev => ({
...prev,
connections: prev.connections.filter(conn => conn.id !== connectionId),
updatedAt: new Date(),
}));
};
const handleParameterChange = (stepId: string, param: string, value: any) => {
console.log('Changement paramètre:', stepId, param, value);
setWorkflow(prev => ({
...prev,
steps: prev.steps.map(step =>
step.id === stepId
? {
...step,
data: {
...step.data,
parameters: {
...step.data.parameters,
[param]: value
}
}
}
: step
),
updatedAt: new Date(),
}));
};
const handleVisualSelection = (stepId: string) => {
console.log('Sélection visuelle pour étape:', stepId);
// La sélection visuelle est maintenant gérée par le VisualSelector dans PropertiesPanel
};
const handleStepHighlight = (stepId: string, highlight: boolean) => {
// Mise en évidence des étapes pour la validation
console.log('Mise en évidence étape:', stepId, highlight ? 'activée' : 'désactivée');
};
const handleStepStateChange = (stepId: string, state: StepExecutionState) => {
setWorkflow(prev => ({
...prev,
steps: prev.steps.map(step =>
step.id === stepId ? { ...step, executionState: state } : step
),
updatedAt: new Date(),
}));
};
const handleExecutionComplete = (success: boolean, summary: any) => {
console.log('Exécution terminée:', success ? 'Succès' : 'Échec', summary);
};
const handleWorkflowLoad = (loadedWorkflow: Workflow) => {
console.log('📥 [App] Chargement workflow:', {
name: loadedWorkflow.name,
stepsCount: loadedWorkflow.steps?.length || 0,
connectionsCount: loadedWorkflow.connections?.length || 0,
steps: loadedWorkflow.steps,
});
// S'assurer que les étapes ont des positions valides
const workflowWithPositions: Workflow = {
...loadedWorkflow,
steps: (loadedWorkflow.steps || []).map((step, index) => ({
...step,
position: step.position || { x: 100 + (index % 3) * 200, y: 100 + Math.floor(index / 3) * 150 },
})),
};
setWorkflow(workflowWithPositions);
// Charger les variables du workflow
setVariables(workflowWithPositions.variables || []);
setSelectedStep(null);
console.log('✅ [App] Workflow appliqué avec', workflowWithPositions.steps.length, 'étapes et', (workflowWithPositions.variables || []).length, 'variables');
};
const handleWorkflowSave = (savedWorkflow: Workflow) => {
setWorkflow(savedWorkflow);
console.log('Workflow sauvegardé:', savedWorkflow.name);
};
const handleStepDrag = (stepTemplate: StepTemplate) => {
console.log('Début drag étape:', stepTemplate);
// Cette fonction est appelée quand le drag commence depuis la palette
// La logique de drop est gérée dans le Canvas
};
// Gestion des variables
const handleVariableCreate = (variable: Omit<Variable, 'id'>) => {
const newVariable: Variable = {
...variable,
id: `var_${Date.now()}`,
};
const updatedVariables = [...variables, newVariable];
setVariables(updatedVariables);
// Synchroniser avec le workflow pour la sauvegarde
setWorkflow(prev => ({ ...prev, variables: updatedVariables, updatedAt: new Date() }));
};
const handleVariableUpdate = (id: string, updates: Partial<Variable>) => {
const updatedVariables = variables.map(v => v.id === id ? { ...v, ...updates } : v);
setVariables(updatedVariables);
// Synchroniser avec le workflow pour la sauvegarde
setWorkflow(prev => ({ ...prev, variables: updatedVariables, updatedAt: new Date() }));
};
const handleVariableDelete = (id: string) => {
const updatedVariables = variables.filter(v => v.id !== id);
setVariables(updatedVariables);
// Synchroniser avec le workflow pour la sauvegarde
setWorkflow(prev => ({ ...prev, variables: updatedVariables, updatedAt: new Date() }));
};
// Onglets de documentation
const docTabs = [
{ id: 'canvas', label: 'Canvas', toolName: 'canvas' },
{ id: 'palette', label: 'Palette', toolName: 'palette' },
{ id: 'properties', label: 'Propriétés', toolName: 'properties' },
{ id: 'test', label: 'Test Catalogue', toolName: 'test' },
{ id: 'vwb-test', label: 'Test VWB', toolName: 'vwb-test' },
];
// Mode 100% Visuel - Pas de catégories par défaut
// Seules les actions VisionOnly du catalogue sont utilisées
const defaultCategories: StepCategory[] = [];
return (
<AppErrorBoundary>
<AccessibilityProvider>
<Provider store={store}>
<ThemeProvider theme={theme}>
<ReactFlowProvider>
<CssBaseline />
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
{/* Barre d'application */}
<AppBar position="static" elevation={1}>
<Toolbar>
<Typography variant="h6" component="h1" sx={{ flexGrow: 1 }}>
Visual Workflow Builder V2
</Typography>
{/* Gestionnaire de workflows */}
<Box sx={{ mr: 2 }}>
<WorkflowManager
currentWorkflow={workflow}
onWorkflowLoad={handleWorkflowLoad}
onWorkflowSave={handleWorkflowSave}
/>
</Box>
{/* Indicateur de connexion API */}
<Box sx={{ mr: 2 }}>
<ConnectionIndicator compact showRefreshButton />
</Box>
<Tabs
value={activeDocTab}
onChange={(_, newValue) => {
setActiveDocTab(newValue);
setIsDocDrawerOpen(true);
}}
textColor="inherit"
indicatorColor="secondary"
>
<Tab label="Aide Canvas" />
<Tab label="Aide Palette" />
<Tab label="Aide Propriétés" />
<Tab label="Test Catalogue" />
<Tab label="Test VWB" />
</Tabs>
</Toolbar>
</AppBar>
{/* Interface principale */}
<Box sx={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
{/* Palette d'étapes - Responsive */}
<Box
sx={responsiveLayout.getResponsiveStyles('palette')}
role="complementary"
aria-label="Palette d'étapes disponibles"
>
<Palette
categories={defaultCategories}
searchTerm={searchTerm}
onSearch={setSearchTerm}
onStepDrag={handleStepDrag}
/>
</Box>
{/* Canvas principal */}
<Box
sx={{ flex: 1, display: 'flex', flexDirection: 'column' }}
role="main"
aria-label="Zone principale de création de workflow"
>
{/* Validateur et Exécuteur en haut */}
<Box sx={{
display: 'flex',
borderBottom: '1px solid #e0e0e0',
backgroundColor: '#ffffff',
flexDirection: responsiveLayout.isMobile ? 'column' : 'row'
}}
role="toolbar"
aria-label="Outils de validation et d'exécution"
>
{/* Validateur */}
<Box sx={{
flex: 1,
p: responsiveLayout.isMobile ? 1 : 2,
borderRight: responsiveLayout.isMobile ? 'none' : '1px solid #e0e0e0',
borderBottom: responsiveLayout.isMobile ? '1px solid #e0e0e0' : 'none'
}}
role="region"
aria-label="Validation du workflow"
>
<Validator
workflow={workflow}
variables={variables}
onStepHighlight={handleStepHighlight}
/>
</Box>
{/* Exécuteur */}
<Box
sx={{ flex: 1, p: responsiveLayout.isMobile ? 1 : 2 }}
role="region"
aria-label="Exécution du workflow"
>
<Executor
workflow={workflow}
canExecute={workflow.steps.length > 0}
onStepStateChange={handleStepStateChange}
onExecutionComplete={handleExecutionComplete}
/>
</Box>
</Box>
<Canvas
workflow={workflow}
selectedStep={selectedStep}
onStepSelect={handleStepSelect}
onStepMove={handleStepMove}
onConnection={handleConnection}
onConnectionDelete={handleConnectionDelete}
onStepAdd={handleStepAdd}
onStepDelete={handleStepDelete}
/>
{/* Gestionnaire de variables - Responsive */}
<Box sx={{
...responsiveLayout.getResponsiveStyles('variables'),
borderTop: '1px solid #e0e0e0',
backgroundColor: '#ffffff',
p: responsiveLayout.isMobile ? 1 : 2,
overflow: 'auto',
}}
role="region"
aria-label="Gestionnaire de variables"
>
<VariableManager
variables={variables}
onVariableCreate={handleVariableCreate}
onVariableUpdate={handleVariableUpdate}
onVariableDelete={handleVariableDelete}
/>
</Box>
</Box>
{/* Panneau de propriétés - Responsive */}
<Box
sx={responsiveLayout.getResponsiveStyles('properties')}
role="complementary"
aria-label="Panneau de propriétés de l'étape sélectionnée"
>
<PropertiesPanel
selectedStep={selectedStep}
variables={variables}
onParameterChange={handleParameterChange}
onVisualSelection={handleVisualSelection}
/>
</Box>
</Box>
{/* Tiroir de documentation */}
<Drawer
anchor="right"
open={isDocDrawerOpen}
onClose={() => setIsDocDrawerOpen(false)}
sx={{
'& .MuiDrawer-paper': {
width: responsiveLayout.isMobile ? '100%' : 400,
boxSizing: 'border-box',
},
}}
>
{activeDocTab === 3 ? (
<TestCatalogLoader />
) : activeDocTab === 4 ? (
<VWBIntegrationTest />
) : (
<DocumentationTab
toolName={docTabs[activeDocTab]?.toolName || 'canvas'}
isActive={isDocDrawerOpen}
onActivate={() => setIsDocDrawerOpen(true)}
/>
)}
</Drawer>
{/* Aide contextuelle */}
<ContextualHelp
context={selectedStep ? 'properties' : 'canvas'}
selectedStepType={selectedStep?.type}
currentErrors={[]} // TODO: Intégrer avec le validateur
isVisible={!responsiveLayout.isMobile} // Masquer sur mobile pour éviter l'encombrement
/>
{/* Dialogue des raccourcis clavier */}
<KeyboardShortcuts
open={showKeyboardShortcuts}
onClose={() => setShowKeyboardShortcuts(false)}
shortcuts={keyboardNavigation.shortcuts}
/>
</Box>
</ReactFlowProvider>
</ThemeProvider>
</Provider>
</AccessibilityProvider>
</AppErrorBoundary>
);
}
export default App;