v1.0 - Version stable: multi-PC, détection UI-DETR-1, 3 modes exécution
- Frontend v4 accessible sur réseau local (192.168.1.40) - Ports ouverts: 3002 (frontend), 5001 (backend), 5004 (dashboard) - Ollama GPU fonctionnel - Self-healing interactif - Dashboard confiance Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
38
visual_workflow_builder/frontend/src/App.css
Normal file
38
visual_workflow_builder/frontend/src/App.css
Normal file
@@ -0,0 +1,38 @@
|
||||
.App {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.App-logo {
|
||||
height: 40vmin;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.App-logo {
|
||||
animation: App-logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.App-header {
|
||||
background-color: #282c34;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: calc(10px + 2vmin);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.App-link {
|
||||
color: #61dafb;
|
||||
}
|
||||
|
||||
@keyframes App-logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
9
visual_workflow_builder/frontend/src/App.test.tsx
Normal file
9
visual_workflow_builder/frontend/src/App.test.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import App from './App';
|
||||
|
||||
test('renders learn react link', () => {
|
||||
render(<App />);
|
||||
const linkElement = screen.getByText(/learn react/i);
|
||||
expect(linkElement).toBeInTheDocument();
|
||||
});
|
||||
@@ -364,13 +364,17 @@ function App() {
|
||||
|
||||
const handleStepAdd = (stepData: Omit<Step, 'id'>) => {
|
||||
console.log('Ajout étape:', stepData);
|
||||
|
||||
// IMPORTANT: Copie profonde pour éviter les références partagées avec d'autres étapes
|
||||
const stepDataCopy = JSON.parse(JSON.stringify(stepData));
|
||||
|
||||
const newStep: Step = {
|
||||
...stepData,
|
||||
...stepDataCopy,
|
||||
id: `step_${Date.now()}`,
|
||||
executionState: StepExecutionState.IDLE,
|
||||
validationErrors: [],
|
||||
};
|
||||
|
||||
|
||||
setWorkflow(prev => ({
|
||||
...prev,
|
||||
steps: [...prev.steps, newStep],
|
||||
@@ -406,19 +410,25 @@ function App() {
|
||||
|
||||
const handleParameterChange = (stepId: string, param: string, value: any) => {
|
||||
console.log('Changement paramètre:', stepId, param, value);
|
||||
|
||||
// IMPORTANT: Copie profonde de la valeur pour éviter les références partagées entre étapes
|
||||
const valueCopy = value && typeof value === 'object'
|
||||
? JSON.parse(JSON.stringify(value))
|
||||
: value;
|
||||
|
||||
setWorkflow(prev => ({
|
||||
...prev,
|
||||
steps: prev.steps.map(step =>
|
||||
step.id === stepId
|
||||
? {
|
||||
...step,
|
||||
data: {
|
||||
...step.data,
|
||||
parameters: {
|
||||
...step.data.parameters,
|
||||
[param]: value
|
||||
}
|
||||
}
|
||||
steps: prev.steps.map(step =>
|
||||
step.id === stepId
|
||||
? {
|
||||
...step,
|
||||
data: {
|
||||
...step.data,
|
||||
parameters: {
|
||||
...step.data.parameters,
|
||||
[param]: valueCopy
|
||||
}
|
||||
}
|
||||
}
|
||||
: step
|
||||
),
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* Fournisseur d'Accessibilité - Conformité WCAG 2.1 niveau AA
|
||||
* Auteur : Dom, Alice, Kiro - 08 janvier 2026
|
||||
*
|
||||
* Ce composant fournit les fonctionnalités d'accessibilité nécessaires
|
||||
* pour assurer la conformité aux standards WCAG 2.1 niveau AA.
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
|
||||
interface AccessibilitySettings {
|
||||
// Préférences utilisateur
|
||||
highContrast: boolean;
|
||||
reducedMotion: boolean;
|
||||
largeText: boolean;
|
||||
screenReaderMode: boolean;
|
||||
|
||||
// Paramètres de navigation
|
||||
focusVisible: boolean;
|
||||
skipLinks: boolean;
|
||||
|
||||
// Paramètres d'affichage
|
||||
colorBlindnessMode: 'none' | 'protanopia' | 'deuteranopia' | 'tritanopia';
|
||||
fontSize: 'small' | 'medium' | 'large' | 'extra-large';
|
||||
|
||||
// Paramètres d'interaction
|
||||
clickDelay: number; // ms
|
||||
hoverDelay: number; // ms
|
||||
}
|
||||
|
||||
interface AccessibilityContextType {
|
||||
settings: AccessibilitySettings;
|
||||
updateSettings: (updates: Partial<AccessibilitySettings>) => void;
|
||||
announceToScreenReader: (message: string, priority?: 'polite' | 'assertive') => void;
|
||||
focusElement: (elementId: string) => void;
|
||||
isKeyboardNavigation: boolean;
|
||||
}
|
||||
|
||||
const defaultSettings: AccessibilitySettings = {
|
||||
highContrast: false,
|
||||
reducedMotion: false,
|
||||
largeText: false,
|
||||
screenReaderMode: false,
|
||||
focusVisible: true,
|
||||
skipLinks: true,
|
||||
colorBlindnessMode: 'none',
|
||||
fontSize: 'medium',
|
||||
clickDelay: 0,
|
||||
hoverDelay: 500,
|
||||
};
|
||||
|
||||
const AccessibilityContext = createContext<AccessibilityContextType | null>(null);
|
||||
|
||||
interface AccessibilityProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fournisseur d'Accessibilité
|
||||
*/
|
||||
export const AccessibilityProvider: React.FC<AccessibilityProviderProps> = ({ children }) => {
|
||||
const [settings, setSettings] = useState<AccessibilitySettings>(defaultSettings);
|
||||
const [isKeyboardNavigation, setIsKeyboardNavigation] = useState(false);
|
||||
const [screenReaderMessage, setScreenReaderMessage] = useState<{
|
||||
message: string;
|
||||
priority: 'polite' | 'assertive';
|
||||
} | null>(null);
|
||||
|
||||
// Détecter les préférences système
|
||||
useEffect(() => {
|
||||
const mediaQueries = {
|
||||
reducedMotion: window.matchMedia('(prefers-reduced-motion: reduce)'),
|
||||
highContrast: window.matchMedia('(prefers-contrast: high)'),
|
||||
largeText: window.matchMedia('(prefers-reduced-data: reduce)'), // Approximation
|
||||
};
|
||||
|
||||
const updateFromSystem = () => {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
reducedMotion: mediaQueries.reducedMotion.matches,
|
||||
highContrast: mediaQueries.highContrast.matches,
|
||||
}));
|
||||
};
|
||||
|
||||
// Écouter les changements
|
||||
Object.values(mediaQueries).forEach(mq => {
|
||||
mq.addEventListener('change', updateFromSystem);
|
||||
});
|
||||
|
||||
// Initialiser
|
||||
updateFromSystem();
|
||||
|
||||
return () => {
|
||||
Object.values(mediaQueries).forEach(mq => {
|
||||
mq.removeEventListener('change', updateFromSystem);
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Détecter la navigation au clavier
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Tab') {
|
||||
setIsKeyboardNavigation(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseDown = () => {
|
||||
setIsKeyboardNavigation(false);
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
document.addEventListener('mousedown', handleMouseDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
document.removeEventListener('mousedown', handleMouseDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Appliquer les styles d'accessibilité
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
|
||||
// Contraste élevé
|
||||
if (settings.highContrast) {
|
||||
root.style.setProperty('--primary-color', '#000000');
|
||||
root.style.setProperty('--background-color', '#ffffff');
|
||||
root.style.setProperty('--text-color', '#000000');
|
||||
root.style.setProperty('--border-color', '#000000');
|
||||
} else {
|
||||
root.style.removeProperty('--primary-color');
|
||||
root.style.removeProperty('--background-color');
|
||||
root.style.removeProperty('--text-color');
|
||||
root.style.removeProperty('--border-color');
|
||||
}
|
||||
|
||||
// Taille de police
|
||||
const fontSizeMap = {
|
||||
'small': '14px',
|
||||
'medium': '16px',
|
||||
'large': '18px',
|
||||
'extra-large': '20px',
|
||||
};
|
||||
root.style.fontSize = fontSizeMap[settings.fontSize];
|
||||
|
||||
// Mouvement réduit
|
||||
if (settings.reducedMotion) {
|
||||
root.style.setProperty('--animation-duration', '0.01ms');
|
||||
root.style.setProperty('--transition-duration', '0.01ms');
|
||||
} else {
|
||||
root.style.removeProperty('--animation-duration');
|
||||
root.style.removeProperty('--transition-duration');
|
||||
}
|
||||
|
||||
// Focus visible
|
||||
if (settings.focusVisible && isKeyboardNavigation) {
|
||||
root.classList.add('keyboard-navigation');
|
||||
} else {
|
||||
root.classList.remove('keyboard-navigation');
|
||||
}
|
||||
|
||||
}, [settings, isKeyboardNavigation]);
|
||||
|
||||
// Fonction pour mettre à jour les paramètres
|
||||
const updateSettings = (updates: Partial<AccessibilitySettings>) => {
|
||||
setSettings(prev => ({ ...prev, ...updates }));
|
||||
};
|
||||
|
||||
// Fonction pour annoncer aux lecteurs d'écran
|
||||
const announceToScreenReader = (message: string, priority: 'polite' | 'assertive' = 'polite') => {
|
||||
setScreenReaderMessage({ message, priority });
|
||||
|
||||
// Effacer le message après un délai
|
||||
setTimeout(() => {
|
||||
setScreenReaderMessage(null);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
// Fonction pour donner le focus à un élément
|
||||
const focusElement = (elementId: string) => {
|
||||
const element = document.getElementById(elementId);
|
||||
if (element) {
|
||||
element.focus();
|
||||
// Annoncer le changement de focus
|
||||
const label = element.getAttribute('aria-label') || element.textContent || 'Élément';
|
||||
announceToScreenReader(`Focus sur ${label}`);
|
||||
}
|
||||
};
|
||||
|
||||
const contextValue: AccessibilityContextType = {
|
||||
settings,
|
||||
updateSettings,
|
||||
announceToScreenReader,
|
||||
focusElement,
|
||||
isKeyboardNavigation,
|
||||
};
|
||||
|
||||
return (
|
||||
<AccessibilityContext.Provider value={contextValue}>
|
||||
{/* Liens de saut pour la navigation au clavier */}
|
||||
{settings.skipLinks && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -40,
|
||||
left: 6,
|
||||
zIndex: 9999,
|
||||
'&:focus-within': {
|
||||
top: 6,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<a
|
||||
href="#main-content"
|
||||
style={{
|
||||
background: '#000',
|
||||
color: '#fff',
|
||||
padding: '8px 16px',
|
||||
textDecoration: 'none',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
onFocus={() => announceToScreenReader('Lien de saut vers le contenu principal')}
|
||||
>
|
||||
Aller au contenu principal
|
||||
</a>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Zone live pour les annonces aux lecteurs d'écran */}
|
||||
<div
|
||||
role="status"
|
||||
aria-live={screenReaderMessage?.priority || 'polite'}
|
||||
aria-atomic="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '-10000px',
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{screenReaderMessage?.message}
|
||||
</div>
|
||||
|
||||
{/* Contenu principal avec ID et rôle pour les liens de saut */}
|
||||
<main id="main-content" role="main" tabIndex={-1}>
|
||||
{children}
|
||||
</main>
|
||||
|
||||
{/* Styles CSS d'accessibilité injectés */}
|
||||
<style>{`
|
||||
/* Focus visible pour la navigation au clavier */
|
||||
.keyboard-navigation *:focus {
|
||||
outline: 2px solid #2196f3 !important;
|
||||
outline-offset: 2px !important;
|
||||
}
|
||||
|
||||
/* Contraste élevé */
|
||||
${settings.highContrast ? `
|
||||
* {
|
||||
background-color: var(--background-color, white) !important;
|
||||
color: var(--text-color, black) !important;
|
||||
border-color: var(--border-color, black) !important;
|
||||
}
|
||||
|
||||
button, input, select, textarea {
|
||||
border: 2px solid black !important;
|
||||
}
|
||||
` : ''}
|
||||
|
||||
/* Mouvement réduit */
|
||||
${settings.reducedMotion ? `
|
||||
*, *::before, *::after {
|
||||
animation-duration: var(--animation-duration, 0.01ms) !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: var(--transition-duration, 0.01ms) !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
` : ''}
|
||||
|
||||
/* Filtres pour daltonisme */
|
||||
${settings.colorBlindnessMode !== 'none' ? `
|
||||
html {
|
||||
filter: ${getColorBlindnessFilter(settings.colorBlindnessMode)};
|
||||
}
|
||||
` : ''}
|
||||
|
||||
/* Texte large */
|
||||
${settings.largeText ? `
|
||||
* {
|
||||
font-size: 1.2em !important;
|
||||
line-height: 1.5 !important;
|
||||
}
|
||||
` : ''}
|
||||
`}</style>
|
||||
</AccessibilityContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// Fonction utilitaire pour les filtres de daltonisme
|
||||
function getColorBlindnessFilter(mode: string): string {
|
||||
switch (mode) {
|
||||
case 'protanopia':
|
||||
return 'url(#protanopia)';
|
||||
case 'deuteranopia':
|
||||
return 'url(#deuteranopia)';
|
||||
case 'tritanopia':
|
||||
return 'url(#tritanopia)';
|
||||
default:
|
||||
return 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Hook pour utiliser le contexte d'accessibilité
|
||||
export const useAccessibility = (): AccessibilityContextType => {
|
||||
const context = useContext(AccessibilityContext);
|
||||
if (!context) {
|
||||
throw new Error('useAccessibility doit être utilisé dans un AccessibilityProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export default AccessibilityProvider;
|
||||
@@ -0,0 +1,379 @@
|
||||
/**
|
||||
* Composant StepNode - Nœud personnalisé pour les étapes de workflow
|
||||
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
|
||||
*
|
||||
* Composant de nœud personnalisé avec états visuels d'exécution,
|
||||
* indicateurs d'erreur et style français. Optimisé pour les performances.
|
||||
*
|
||||
* EXTENSION VWB : Support des animations et états visuels avancés pour les actions VisionOnly.
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import { Handle, Position, NodeProps } from '@xyflow/react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Chip,
|
||||
Tooltip,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
PlayArrow as RunningIcon,
|
||||
CheckCircle as SuccessIcon,
|
||||
Error as ErrorIcon,
|
||||
Warning as WarningIcon,
|
||||
Pause as PauseIcon,
|
||||
SkipNext as SkipNextIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
// Import des types partagés
|
||||
import { StepNodeData, StepExecutionState, ValidationError, StepType } from '../../types';
|
||||
|
||||
// Import de l'extension VWB
|
||||
import VWBStepNodeExtension from './VWBStepNodeExtension';
|
||||
|
||||
// Import du service VWB pour la détection
|
||||
// import { useVWBExecutionService } from '../../services/vwbExecutionService';
|
||||
|
||||
// Couleurs par état d'exécution
|
||||
const executionStateColors = {
|
||||
[StepExecutionState.IDLE]: '#9e9e9e',
|
||||
[StepExecutionState.RUNNING]: '#2196f3',
|
||||
[StepExecutionState.SUCCESS]: '#4caf50',
|
||||
[StepExecutionState.ERROR]: '#f44336',
|
||||
[StepExecutionState.SKIPPED]: '#9e9e9e',
|
||||
[StepExecutionState.PAUSED]: '#9c27b0',
|
||||
};
|
||||
|
||||
// Icônes par état d'exécution
|
||||
const executionStateIcons = {
|
||||
[StepExecutionState.IDLE]: null,
|
||||
[StepExecutionState.RUNNING]: <RunningIcon fontSize="small" />,
|
||||
[StepExecutionState.SUCCESS]: <SuccessIcon fontSize="small" />,
|
||||
[StepExecutionState.ERROR]: <ErrorIcon fontSize="small" />,
|
||||
[StepExecutionState.SKIPPED]: <SkipNextIcon fontSize="small" />,
|
||||
[StepExecutionState.PAUSED]: <PauseIcon fontSize="small" />,
|
||||
};
|
||||
|
||||
// Labels français par type d'étape
|
||||
const stepTypeLabels: Record<StepType, string> = {
|
||||
click: 'Cliquer',
|
||||
type: 'Saisir',
|
||||
wait: 'Attendre',
|
||||
condition: 'Condition',
|
||||
extract: 'Extraire',
|
||||
scroll: 'Défiler',
|
||||
navigate: 'Naviguer',
|
||||
screenshot: 'Capture',
|
||||
};
|
||||
|
||||
/**
|
||||
* Composant StepNode personnalisé avec support des actions VWB
|
||||
*/
|
||||
const StepNode: React.FC<NodeProps> = ({ data, selected }) => {
|
||||
const stepData = data as StepNodeData;
|
||||
|
||||
// Service VWB pour détecter les actions VWB (fonction locale)
|
||||
const isVWBStep = (step: any) => Boolean(
|
||||
step.data?.isVWBCatalogAction ||
|
||||
step.data?.vwbActionId ||
|
||||
step.action_id?.startsWith('vwb_') ||
|
||||
step.action_id?.includes('catalog_')
|
||||
);
|
||||
|
||||
// Vérification de sécurité pour les données
|
||||
if (!stepData) {
|
||||
return (
|
||||
<Box sx={{ p: 2, border: '1px solid red', borderRadius: 1 }}>
|
||||
<Typography color="error">Données manquantes</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Créer un objet Step temporaire pour la détection VWB
|
||||
const tempStep = {
|
||||
id: 'temp',
|
||||
type: stepData.stepType,
|
||||
name: stepData.label,
|
||||
data: stepData,
|
||||
position: { x: 0, y: 0 },
|
||||
executionState: stepData.executionState,
|
||||
validationErrors: stepData.validationErrors || []
|
||||
};
|
||||
|
||||
// Détecter si c'est une action VWB
|
||||
const isVWBAction = stepData.isVWBCatalogAction || isVWBStep(tempStep);
|
||||
|
||||
// Si c'est une action VWB, utiliser l'extension VWB
|
||||
if (isVWBAction) {
|
||||
return <VWBStepNodeExtension data={data} selected={selected} />;
|
||||
}
|
||||
|
||||
// Sinon, utiliser le rendu standard (code existant)
|
||||
return <StandardStepNode data={data} selected={selected} />;
|
||||
};
|
||||
|
||||
/**
|
||||
* Composant StepNode Standard (code existant refactorisé)
|
||||
*/
|
||||
interface StandardStepNodeProps {
|
||||
data: any;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
const StandardStepNode: React.FC<StandardStepNodeProps> = ({ data, selected }) => {
|
||||
const stepData = data as StepNodeData;
|
||||
const {
|
||||
label,
|
||||
stepType,
|
||||
executionState = StepExecutionState.IDLE,
|
||||
validationErrors = [],
|
||||
isSelected = false,
|
||||
isVWBCatalogAction = false,
|
||||
} = stepData || {};
|
||||
|
||||
// Vérification de sécurité pour les données
|
||||
if (!stepData) {
|
||||
return (
|
||||
<Box sx={{ p: 2, border: '1px solid red', borderRadius: 1 }}>
|
||||
<Typography color="error">Données manquantes</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Déterminer la couleur de bordure selon l'état
|
||||
const getBorderColor = () => {
|
||||
if (validationErrors.some((e: ValidationError) => e.severity === 'error')) {
|
||||
return '#f44336'; // Rouge pour les erreurs
|
||||
}
|
||||
if (validationErrors.some((e: ValidationError) => e.severity === 'warning')) {
|
||||
return '#ff9800'; // Orange pour les avertissements
|
||||
}
|
||||
if (selected || isSelected) {
|
||||
return '#1976d2'; // Bleu pour la sélection
|
||||
}
|
||||
return executionStateColors[executionState as StepExecutionState];
|
||||
};
|
||||
|
||||
// Déterminer la couleur de fond selon l'état
|
||||
const getBackgroundColor = () => {
|
||||
switch (executionState) {
|
||||
case StepExecutionState.RUNNING:
|
||||
return '#e3f2fd';
|
||||
case StepExecutionState.SUCCESS:
|
||||
return '#e8f5e8';
|
||||
case StepExecutionState.ERROR:
|
||||
return '#ffebee';
|
||||
case StepExecutionState.SKIPPED:
|
||||
return '#fff3e0';
|
||||
default:
|
||||
return '#ffffff';
|
||||
}
|
||||
};
|
||||
|
||||
// Messages d'erreur pour le tooltip
|
||||
const errorMessages = validationErrors
|
||||
.filter((e: ValidationError) => e.severity === 'error')
|
||||
.map((e: ValidationError) => e.message)
|
||||
.join(', ');
|
||||
|
||||
const warningMessages = validationErrors
|
||||
.filter((e: ValidationError) => e.severity === 'warning')
|
||||
.map((e: ValidationError) => e.message)
|
||||
.join(', ');
|
||||
|
||||
// Style commun pour les handles
|
||||
const handleStyle = {
|
||||
background: getBorderColor(),
|
||||
width: 10,
|
||||
height: 10,
|
||||
border: '2px solid white',
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
minWidth: 120,
|
||||
minHeight: 50,
|
||||
maxWidth: 180,
|
||||
backgroundColor: getBackgroundColor(),
|
||||
border: `2px solid ${getBorderColor()}`,
|
||||
borderRadius: 2,
|
||||
boxShadow: selected || isSelected ? '0 4px 12px rgba(0,0,0,0.15)' : '0 2px 8px rgba(0,0,0,0.1)',
|
||||
position: 'relative',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
'&:hover': {
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.2)',
|
||||
transform: 'translateY(-1px)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Poignées de connexion - Haut (entrées) */}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
id="top"
|
||||
style={handleStyle}
|
||||
/>
|
||||
|
||||
{/* Poignées de connexion - Gauche (entrée alternative) */}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
id="left"
|
||||
style={handleStyle}
|
||||
/>
|
||||
|
||||
{/* Poignées de connexion - Droite (sortie alternative) */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id="right"
|
||||
style={handleStyle}
|
||||
/>
|
||||
|
||||
{/* Poignées de connexion - Bas (sortie principale) */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
id="bottom"
|
||||
style={handleStyle}
|
||||
/>
|
||||
|
||||
{/* Contenu principal */}
|
||||
<Box sx={{ p: 1.5 }}>
|
||||
{/* En-tête avec type et état */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Chip
|
||||
label={stepTypeLabels[stepType as StepType] || stepType}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
fontSize: '0.75rem',
|
||||
height: 20,
|
||||
borderColor: getBorderColor(),
|
||||
color: getBorderColor(),
|
||||
}}
|
||||
/>
|
||||
{/* Badge VWB pour les actions du catalogue */}
|
||||
{isVWBCatalogAction && (
|
||||
<Chip
|
||||
label="VWB"
|
||||
size="small"
|
||||
color="primary"
|
||||
sx={{
|
||||
fontSize: '0.65rem',
|
||||
height: 16,
|
||||
minWidth: 32,
|
||||
'& .MuiChip-label': {
|
||||
px: 0.5,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Icône d'état d'exécution */}
|
||||
{executionStateIcons[executionState as StepExecutionState] && (
|
||||
<Box sx={{ color: executionStateColors[executionState as StepExecutionState] }}>
|
||||
{executionStateIcons[executionState as StepExecutionState]}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Nom de l'étape */}
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontWeight: 500,
|
||||
color: '#333',
|
||||
textAlign: 'center',
|
||||
wordBreak: 'break-word',
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
|
||||
{/* Indicateurs d'erreur/avertissement */}
|
||||
{validationErrors.length > 0 && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 1, gap: 0.5 }}>
|
||||
{validationErrors.some((e: ValidationError) => e.severity === 'error') && (
|
||||
<Tooltip title={`Erreurs: ${errorMessages}`} arrow>
|
||||
<ErrorIcon sx={{ fontSize: 16, color: '#f44336' }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
{validationErrors.some((e: ValidationError) => e.severity === 'warning') && (
|
||||
<Tooltip title={`Avertissements: ${warningMessages}`} arrow>
|
||||
<WarningIcon sx={{ fontSize: 16, color: '#ff9800' }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Indicateur de sélection */}
|
||||
{(selected || isSelected) && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -2,
|
||||
left: -2,
|
||||
right: -2,
|
||||
bottom: -2,
|
||||
border: '2px solid #1976d2',
|
||||
borderRadius: 2,
|
||||
pointerEvents: 'none',
|
||||
animation: 'pulse 2s infinite',
|
||||
'@keyframes pulse': {
|
||||
'0%': { opacity: 0.7 },
|
||||
'50%': { opacity: 1 },
|
||||
'100%': { opacity: 0.7 },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Animation de pulsation pour l'état en cours d'exécution */}
|
||||
{executionState === StepExecutionState.RUNNING && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -4,
|
||||
left: -4,
|
||||
right: -4,
|
||||
bottom: -4,
|
||||
border: '2px solid #2196f3',
|
||||
borderRadius: 2,
|
||||
pointerEvents: 'none',
|
||||
animation: 'runningPulse 1s infinite',
|
||||
'@keyframes runningPulse': {
|
||||
'0%': { opacity: 0.3, transform: 'scale(1)' },
|
||||
'50%': { opacity: 0.7, transform: 'scale(1.02)' },
|
||||
'100%': { opacity: 0.3, transform: 'scale(1)' },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// Mémorisation du composant StepNode pour éviter les re-rendus inutiles
|
||||
export default memo(StepNode, (prevProps, nextProps) => {
|
||||
// Comparaison personnalisée pour optimiser les re-rendus
|
||||
const prevData = prevProps.data as StepNodeData;
|
||||
const nextData = nextProps.data as StepNodeData;
|
||||
|
||||
return (
|
||||
prevProps.id === nextProps.id &&
|
||||
prevProps.selected === nextProps.selected &&
|
||||
prevData?.label === nextData?.label &&
|
||||
prevData?.stepType === nextData?.stepType &&
|
||||
prevData?.executionState === nextData?.executionState &&
|
||||
prevData?.isSelected === nextData?.isSelected &&
|
||||
prevData?.isVWBCatalogAction === nextData?.isVWBCatalogAction &&
|
||||
prevData?.vwbActionId === nextData?.vwbActionId &&
|
||||
JSON.stringify(prevData?.validationErrors) === JSON.stringify(nextData?.validationErrors)
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* Intégration Canvas VWB - Gestion du drag-and-drop et des actions VisionOnly
|
||||
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
|
||||
*
|
||||
* Ce composant étend le Canvas principal avec la gestion complète des actions VWB,
|
||||
* incluant le drag-and-drop depuis la Palette, la création d'étapes et la synchronisation.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { useReactFlow, Node, Edge, Connection } from '@xyflow/react';
|
||||
import { Box, Alert, Snackbar } from '@mui/material';
|
||||
|
||||
// Import des hooks d'intégration VWB
|
||||
import { useVWBStepIntegration } from '../../hooks/useVWBStepIntegration';
|
||||
|
||||
// Import des types
|
||||
import { Step, StepData, StepExecutionState, Position } from '../../types';
|
||||
import { VWBCatalogAction } from '../../types/catalog';
|
||||
|
||||
interface VWBCanvasIntegrationProps {
|
||||
onStepAdd?: (step: Step) => void;
|
||||
onStepUpdate?: (stepId: string, updates: Partial<Step>) => void;
|
||||
onError?: (error: string) => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composant d'intégration Canvas VWB
|
||||
*/
|
||||
const VWBCanvasIntegration: React.FC<VWBCanvasIntegrationProps> = ({
|
||||
onStepAdd,
|
||||
onStepUpdate,
|
||||
onError,
|
||||
children,
|
||||
}) => {
|
||||
const { screenToFlowPosition } = useReactFlow();
|
||||
const { state: vwbState, methods: vwbMethods } = useVWBStepIntegration();
|
||||
const [notification, setNotification] = React.useState<{
|
||||
open: boolean;
|
||||
message: string;
|
||||
severity: 'success' | 'error' | 'warning' | 'info';
|
||||
}>({
|
||||
open: false,
|
||||
message: '',
|
||||
severity: 'info',
|
||||
});
|
||||
|
||||
// Gestionnaire de drop pour les actions VWB
|
||||
const handleDrop = useCallback(async (event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
try {
|
||||
// Obtenir les données de drag
|
||||
const dragData = event.dataTransfer.getData('application/reactflow');
|
||||
if (!dragData) return;
|
||||
|
||||
// Calculer la position dans le flow
|
||||
const position = screenToFlowPosition({
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
|
||||
// Tenter de créer une étape VWB
|
||||
const vwbStep = await vwbMethods.convertDragDataToVWBStep(dragData, position);
|
||||
|
||||
if (vwbStep) {
|
||||
// C'est une action VWB, l'ajouter au workflow
|
||||
onStepAdd?.(vwbStep);
|
||||
|
||||
setNotification({
|
||||
open: true,
|
||||
message: `Action VWB "${vwbStep.name}" ajoutée au workflow`,
|
||||
severity: 'success',
|
||||
});
|
||||
} else {
|
||||
// Pas une action VWB, laisser le Canvas principal gérer
|
||||
// (Cette logique sera gérée par le composant parent)
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Erreur lors de l\'ajout de l\'action VWB';
|
||||
onError?.(errorMessage);
|
||||
|
||||
setNotification({
|
||||
open: true,
|
||||
message: errorMessage,
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
}, [screenToFlowPosition, vwbMethods, onStepAdd, onError]);
|
||||
|
||||
// Gestionnaire de dragover pour permettre le drop
|
||||
const handleDragOver = useCallback((event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
}, []);
|
||||
|
||||
// Fermer la notification
|
||||
const handleCloseNotification = useCallback(() => {
|
||||
setNotification(prev => ({ ...prev, open: false }));
|
||||
}, []);
|
||||
|
||||
// Convertir une étape en nœud ReactFlow avec support VWB
|
||||
const convertStepToNode = useCallback((step: Step): Node => {
|
||||
const nodeData = {
|
||||
label: step.name,
|
||||
stepType: step.type,
|
||||
executionState: step.executionState || StepExecutionState.IDLE,
|
||||
validationErrors: step.validationErrors || [],
|
||||
isSelected: false,
|
||||
parameters: step.data.parameters,
|
||||
isVWBCatalogAction: step.data.isVWBCatalogAction || false,
|
||||
vwbActionId: step.data.vwbActionId,
|
||||
};
|
||||
|
||||
return {
|
||||
id: step.id,
|
||||
type: 'stepNode', // Type de nœud personnalisé
|
||||
position: step.position,
|
||||
data: nodeData,
|
||||
draggable: true,
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Valider une étape VWB
|
||||
const validateVWBStep = useCallback(async (step: Step) => {
|
||||
if (!step.data.isVWBCatalogAction) return;
|
||||
|
||||
try {
|
||||
const isValid = await vwbMethods.validateVWBStep(step);
|
||||
|
||||
if (!isValid) {
|
||||
setNotification({
|
||||
open: true,
|
||||
message: `L'étape "${step.name}" contient des erreurs de configuration`,
|
||||
severity: 'warning',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la validation de l\'étape VWB:', error);
|
||||
}
|
||||
}, [vwbMethods]);
|
||||
|
||||
// Mémoriser les propriétés d'intégration
|
||||
const integrationProps = useMemo(() => ({
|
||||
onDrop: handleDrop,
|
||||
onDragOver: handleDragOver,
|
||||
convertStepToNode,
|
||||
validateVWBStep,
|
||||
isVWBIntegrationActive: true,
|
||||
}), [handleDrop, handleDragOver, convertStepToNode, validateVWBStep]);
|
||||
|
||||
return (
|
||||
<Box sx={{ width: '100%', height: '100%', position: 'relative' }}>
|
||||
{/* Passer les propriétés d'intégration aux enfants */}
|
||||
{React.Children.map(children, (child) => {
|
||||
if (React.isValidElement(child)) {
|
||||
return React.cloneElement(child, integrationProps);
|
||||
}
|
||||
return child;
|
||||
})}
|
||||
|
||||
{/* Indicateur d'état du service VWB */}
|
||||
{vwbState.error && (
|
||||
<Alert
|
||||
severity="warning"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 10,
|
||||
right: 10,
|
||||
zIndex: 1000,
|
||||
maxWidth: 300,
|
||||
}}
|
||||
>
|
||||
Service VWB indisponible : {vwbState.error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Indicateur de chargement VWB */}
|
||||
{vwbState.isLoading && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 10,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 1000,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
padding: 1,
|
||||
borderRadius: 1,
|
||||
boxShadow: 1,
|
||||
}}
|
||||
>
|
||||
Chargement de l'action VWB...
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Notifications */}
|
||||
<Snackbar
|
||||
open={notification.open}
|
||||
autoHideDuration={4000}
|
||||
onClose={handleCloseNotification}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
>
|
||||
<Alert
|
||||
onClose={handleCloseNotification}
|
||||
severity={notification.severity}
|
||||
sx={{ width: '100%' }}
|
||||
>
|
||||
{notification.message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default VWBCanvasIntegration;
|
||||
|
||||
/**
|
||||
* Hook pour utiliser l'intégration VWB dans un composant Canvas
|
||||
*/
|
||||
export const useVWBCanvasIntegration = () => {
|
||||
const { state, methods } = useVWBStepIntegration();
|
||||
|
||||
return {
|
||||
vwbState: state,
|
||||
vwbMethods: methods,
|
||||
isVWBAvailable: !state.error && !state.isLoading,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,558 @@
|
||||
/**
|
||||
* Extension VWB pour StepNode - États visuels avancés pour les actions VisionOnly
|
||||
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
|
||||
*
|
||||
* Cette extension ajoute des animations, indicateurs de progression et états visuels
|
||||
* spécialisés pour les actions VWB avec feedback en temps réel.
|
||||
*/
|
||||
|
||||
import React, { memo, useState, useEffect } from 'react';
|
||||
import { Handle, Position } from '@xyflow/react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Chip,
|
||||
Tooltip,
|
||||
LinearProgress,
|
||||
CircularProgress,
|
||||
Fade,
|
||||
keyframes,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
PlayArrow as RunningIcon,
|
||||
CheckCircle as SuccessIcon,
|
||||
Error as ErrorIcon,
|
||||
Warning as WarningIcon,
|
||||
Pause as PausedIcon,
|
||||
Visibility as VWBIcon,
|
||||
Speed as PerformanceIcon,
|
||||
BugReport as DebugIcon,
|
||||
Timer as TimerIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
// Import des types
|
||||
import {
|
||||
StepNodeData,
|
||||
StepExecutionState,
|
||||
ValidationError,
|
||||
Evidence
|
||||
} from '../../types';
|
||||
|
||||
// Animations CSS-in-JS
|
||||
const pulseAnimation = keyframes`
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
opacity: 0.8;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const glowAnimation = keyframes`
|
||||
0% {
|
||||
box-shadow: 0 0 5px rgba(33, 150, 243, 0.5);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 20px rgba(33, 150, 243, 0.8);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 5px rgba(33, 150, 243, 0.5);
|
||||
}
|
||||
`;
|
||||
|
||||
const successPulse = keyframes`
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(76, 175, 80, 0.7);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 10px rgba(76, 175, 80, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(76, 175, 80, 0);
|
||||
}
|
||||
`;
|
||||
|
||||
const errorShake = keyframes`
|
||||
0%, 100% { transform: translateX(0); }
|
||||
10%, 30%, 50%, 70%, 90% { transform: translateX(-2px); }
|
||||
20%, 40%, 60%, 80% { transform: translateX(2px); }
|
||||
`;
|
||||
|
||||
// Interface étendue pour les données VWB
|
||||
interface VWBStepNodeData extends StepNodeData {
|
||||
// Données d'exécution VWB
|
||||
executionProgress?: number;
|
||||
executionDuration?: number;
|
||||
evidence?: Evidence[];
|
||||
vwbActionId?: string;
|
||||
isVWBStep?: boolean;
|
||||
|
||||
// États d'exécution avancés
|
||||
isPaused?: boolean;
|
||||
isDebugging?: boolean;
|
||||
retryCount?: number;
|
||||
|
||||
// Métriques de performance
|
||||
averageExecutionTime?: number;
|
||||
successRate?: number;
|
||||
lastExecutionTime?: number;
|
||||
}
|
||||
|
||||
// Couleurs étendues pour les états VWB
|
||||
const vwbExecutionStateColors = {
|
||||
[StepExecutionState.IDLE]: '#9e9e9e',
|
||||
[StepExecutionState.RUNNING]: '#2196f3',
|
||||
[StepExecutionState.SUCCESS]: '#4caf50',
|
||||
[StepExecutionState.ERROR]: '#f44336',
|
||||
[StepExecutionState.SKIPPED]: '#ff9800',
|
||||
[StepExecutionState.PAUSED]: '#9c27b0',
|
||||
};
|
||||
|
||||
// Icônes étendues pour les états VWB
|
||||
const vwbExecutionStateIcons = {
|
||||
[StepExecutionState.IDLE]: null,
|
||||
[StepExecutionState.RUNNING]: <RunningIcon fontSize="small" />,
|
||||
[StepExecutionState.SUCCESS]: <SuccessIcon fontSize="small" />,
|
||||
[StepExecutionState.ERROR]: <ErrorIcon fontSize="small" />,
|
||||
[StepExecutionState.SKIPPED]: <WarningIcon fontSize="small" />,
|
||||
[StepExecutionState.PAUSED]: <PausedIcon fontSize="small" />,
|
||||
};
|
||||
|
||||
/**
|
||||
* Composant StepNode étendu pour les actions VWB
|
||||
*/
|
||||
interface VWBStepNodeExtensionProps {
|
||||
data: any;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
const VWBStepNodeExtension: React.FC<VWBStepNodeExtensionProps> = ({ data, selected }) => {
|
||||
const stepData = data as VWBStepNodeData;
|
||||
const [showProgress, setShowProgress] = useState(false);
|
||||
const [animationKey, setAnimationKey] = useState(0);
|
||||
|
||||
const {
|
||||
label,
|
||||
stepType,
|
||||
executionState = StepExecutionState.IDLE,
|
||||
validationErrors = [],
|
||||
isSelected = false,
|
||||
isVWBCatalogAction = false,
|
||||
isVWBStep = false,
|
||||
executionProgress = 0,
|
||||
executionDuration = 0,
|
||||
evidence = [],
|
||||
vwbActionId,
|
||||
isPaused = false,
|
||||
isDebugging = false,
|
||||
retryCount = 0,
|
||||
averageExecutionTime,
|
||||
successRate,
|
||||
} = stepData || {};
|
||||
|
||||
// Déclencher les animations lors des changements d'état
|
||||
useEffect(() => {
|
||||
if (executionState === StepExecutionState.RUNNING) {
|
||||
setShowProgress(true);
|
||||
} else {
|
||||
setShowProgress(false);
|
||||
}
|
||||
|
||||
// Déclencher une nouvelle animation
|
||||
setAnimationKey(prev => prev + 1);
|
||||
}, [executionState]);
|
||||
|
||||
// Vérification de sécurité
|
||||
if (!stepData) {
|
||||
return (
|
||||
<Box sx={{ p: 2, border: '1px solid red', borderRadius: 1 }}>
|
||||
<Typography color="error">Données manquantes</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Déterminer si c'est une action VWB
|
||||
const isVWBAction = isVWBCatalogAction || isVWBStep || Boolean(vwbActionId);
|
||||
|
||||
// Couleur de bordure avec logique VWB
|
||||
const getBorderColor = () => {
|
||||
if (validationErrors.some((e: ValidationError) => e.severity === 'error')) {
|
||||
return '#f44336';
|
||||
}
|
||||
if (validationErrors.some((e: ValidationError) => e.severity === 'warning')) {
|
||||
return '#ff9800';
|
||||
}
|
||||
if (selected || isSelected) {
|
||||
return isVWBAction ? '#1976d2' : '#1976d2';
|
||||
}
|
||||
return vwbExecutionStateColors[executionState as StepExecutionState];
|
||||
};
|
||||
|
||||
// Couleur de fond avec animations
|
||||
const getBackgroundColor = () => {
|
||||
switch (executionState) {
|
||||
case StepExecutionState.RUNNING:
|
||||
return isVWBAction ? '#e3f2fd' : '#e3f2fd';
|
||||
case StepExecutionState.SUCCESS:
|
||||
return isVWBAction ? '#e8f5e8' : '#e8f5e8';
|
||||
case StepExecutionState.ERROR:
|
||||
return '#ffebee';
|
||||
case StepExecutionState.PAUSED:
|
||||
return '#f3e5f5';
|
||||
case StepExecutionState.SKIPPED:
|
||||
return '#fff3e0';
|
||||
default:
|
||||
return isVWBAction ? '#fafafa' : '#ffffff';
|
||||
}
|
||||
};
|
||||
|
||||
// Styles d'animation selon l'état
|
||||
const getAnimationStyles = () => {
|
||||
const baseStyles = {
|
||||
transition: 'all 0.3s ease-in-out',
|
||||
};
|
||||
|
||||
switch (executionState) {
|
||||
case StepExecutionState.RUNNING:
|
||||
return {
|
||||
...baseStyles,
|
||||
animation: `${pulseAnimation} 2s infinite, ${glowAnimation} 3s infinite`,
|
||||
};
|
||||
case StepExecutionState.SUCCESS:
|
||||
return {
|
||||
...baseStyles,
|
||||
animation: `${successPulse} 1s ease-out`,
|
||||
};
|
||||
case StepExecutionState.ERROR:
|
||||
return {
|
||||
...baseStyles,
|
||||
animation: `${errorShake} 0.5s ease-in-out`,
|
||||
};
|
||||
default:
|
||||
return baseStyles;
|
||||
}
|
||||
};
|
||||
|
||||
// Messages d'erreur pour les tooltips
|
||||
const errorMessages = validationErrors
|
||||
.filter((e: ValidationError) => e.severity === 'error')
|
||||
.map((e: ValidationError) => e.message)
|
||||
.join(', ');
|
||||
|
||||
const warningMessages = validationErrors
|
||||
.filter((e: ValidationError) => e.severity === 'warning')
|
||||
.map((e: ValidationError) => e.message)
|
||||
.join(', ');
|
||||
|
||||
// Formater la durée
|
||||
const formatDuration = (ms: number): string => {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
return `${seconds}s`;
|
||||
};
|
||||
|
||||
// Contenu du tooltip étendu
|
||||
const getTooltipContent = () => {
|
||||
const parts = [];
|
||||
|
||||
if (isVWBAction) {
|
||||
parts.push(`Action VWB: ${vwbActionId || stepType}`);
|
||||
}
|
||||
|
||||
if (executionDuration > 0) {
|
||||
parts.push(`Durée: ${formatDuration(executionDuration)}`);
|
||||
}
|
||||
|
||||
if (evidence.length > 0) {
|
||||
parts.push(`${evidence.length} Evidence générées`);
|
||||
}
|
||||
|
||||
if (retryCount > 0) {
|
||||
parts.push(`${retryCount} tentatives`);
|
||||
}
|
||||
|
||||
if (successRate !== undefined) {
|
||||
parts.push(`Taux de succès: ${Math.round(successRate)}%`);
|
||||
}
|
||||
|
||||
if (errorMessages) {
|
||||
parts.push(`Erreurs: ${errorMessages}`);
|
||||
}
|
||||
|
||||
if (warningMessages) {
|
||||
parts.push(`Avertissements: ${warningMessages}`);
|
||||
}
|
||||
|
||||
return parts.join('\n');
|
||||
};
|
||||
|
||||
// Style commun pour les handles
|
||||
const handleStyle = {
|
||||
background: getBorderColor(),
|
||||
width: 10,
|
||||
height: 10,
|
||||
border: '2px solid white',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip title={getTooltipContent()} arrow placement="top">
|
||||
<Box
|
||||
key={animationKey}
|
||||
sx={{
|
||||
minWidth: isVWBAction ? 140 : 120,
|
||||
minHeight: isVWBAction ? 55 : 50,
|
||||
maxWidth: 180,
|
||||
backgroundColor: getBackgroundColor(),
|
||||
border: `2px solid ${getBorderColor()}`,
|
||||
borderRadius: 2,
|
||||
boxShadow: selected || isSelected
|
||||
? '0 4px 12px rgba(0,0,0,0.15)'
|
||||
: '0 2px 8px rgba(0,0,0,0.1)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
...getAnimationStyles(),
|
||||
'&:hover': {
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.2)',
|
||||
transform: 'translateY(-1px)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Barre de progression pour les actions en cours */}
|
||||
{showProgress && executionState === StepExecutionState.RUNNING && (
|
||||
<Box sx={{ position: 'absolute', top: 0, left: 0, right: 0 }}>
|
||||
<LinearProgress
|
||||
variant={executionProgress > 0 ? 'determinate' : 'indeterminate'}
|
||||
value={executionProgress}
|
||||
sx={{
|
||||
height: 3,
|
||||
backgroundColor: 'rgba(0,0,0,0.1)',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
backgroundColor: isVWBAction ? '#1976d2' : '#2196f3',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Poignées de connexion - Haut (entrée principale) */}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
id="top"
|
||||
style={handleStyle}
|
||||
/>
|
||||
|
||||
{/* Poignées de connexion - Gauche (entrée alternative) */}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
id="left"
|
||||
style={handleStyle}
|
||||
/>
|
||||
|
||||
{/* Poignées de connexion - Droite (sortie alternative) */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id="right"
|
||||
style={handleStyle}
|
||||
/>
|
||||
|
||||
{/* Poignées de connexion - Bas (sortie principale) */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
id="bottom"
|
||||
style={handleStyle}
|
||||
/>
|
||||
|
||||
{/* Contenu principal */}
|
||||
<Box sx={{ p: 1.5, pt: showProgress ? 2 : 1.5 }}>
|
||||
{/* En-tête avec type et badges */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 1 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, flex: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
<Chip
|
||||
label={stepType}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
fontSize: '0.75rem',
|
||||
height: 20,
|
||||
borderColor: getBorderColor(),
|
||||
color: getBorderColor(),
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Badge VWB */}
|
||||
{isVWBAction && (
|
||||
<Chip
|
||||
label="VWB"
|
||||
size="small"
|
||||
color="primary"
|
||||
icon={<VWBIcon sx={{ fontSize: '0.7rem' }} />}
|
||||
sx={{
|
||||
fontSize: '0.65rem',
|
||||
height: 18,
|
||||
minWidth: 40,
|
||||
'& .MuiChip-label': {
|
||||
px: 0.5,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Badge Debug */}
|
||||
{isDebugging && (
|
||||
<Chip
|
||||
label="DEBUG"
|
||||
size="small"
|
||||
color="warning"
|
||||
icon={<DebugIcon sx={{ fontSize: '0.6rem' }} />}
|
||||
sx={{
|
||||
fontSize: '0.6rem',
|
||||
height: 16,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Badge Evidence */}
|
||||
{evidence.length > 0 && (
|
||||
<Chip
|
||||
label={evidence.length}
|
||||
size="small"
|
||||
color="info"
|
||||
sx={{
|
||||
fontSize: '0.6rem',
|
||||
height: 16,
|
||||
minWidth: 20,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Métriques de performance pour VWB */}
|
||||
{isVWBAction && (averageExecutionTime || successRate !== undefined) && (
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
{averageExecutionTime && (
|
||||
<Chip
|
||||
label={formatDuration(averageExecutionTime)}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
icon={<TimerIcon sx={{ fontSize: '0.6rem' }} />}
|
||||
sx={{
|
||||
fontSize: '0.6rem',
|
||||
height: 16,
|
||||
color: 'text.secondary',
|
||||
borderColor: 'text.secondary',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{successRate !== undefined && (
|
||||
<Chip
|
||||
label={`${Math.round(successRate)}%`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
icon={<PerformanceIcon sx={{ fontSize: '0.6rem' }} />}
|
||||
sx={{
|
||||
fontSize: '0.6rem',
|
||||
height: 16,
|
||||
color: successRate >= 90 ? 'success.main' : 'warning.main',
|
||||
borderColor: successRate >= 90 ? 'success.main' : 'warning.main',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Icône d'état avec animation */}
|
||||
<Fade in={Boolean(vwbExecutionStateIcons[executionState as StepExecutionState])} timeout={300}>
|
||||
<Box sx={{
|
||||
color: vwbExecutionStateColors[executionState as StepExecutionState],
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
}}>
|
||||
{executionState === StepExecutionState.RUNNING && (
|
||||
<CircularProgress size={16} thickness={4} />
|
||||
)}
|
||||
{vwbExecutionStateIcons[executionState as StepExecutionState]}
|
||||
</Box>
|
||||
</Fade>
|
||||
</Box>
|
||||
|
||||
{/* Label de l'étape */}
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontWeight: 500,
|
||||
color: 'text.primary',
|
||||
lineHeight: 1.2,
|
||||
wordBreak: 'break-word',
|
||||
fontSize: isVWBAction ? '0.85rem' : '0.8rem',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
|
||||
{/* Informations d'exécution en temps réel */}
|
||||
{executionState === StepExecutionState.RUNNING && executionDuration > 0 && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
display: 'block',
|
||||
mt: 0.5,
|
||||
fontSize: '0.7rem',
|
||||
}}
|
||||
>
|
||||
{formatDuration(executionDuration)}
|
||||
{executionProgress > 0 && ` (${Math.round(executionProgress)}%)`}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Compteur de retry */}
|
||||
{retryCount > 0 && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: 'warning.main',
|
||||
display: 'block',
|
||||
mt: 0.5,
|
||||
fontSize: '0.7rem',
|
||||
}}
|
||||
>
|
||||
Tentative {retryCount}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Indicateur de pause */}
|
||||
{isPaused && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
right: 4,
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'warning.main',
|
||||
animation: `${pulseAnimation} 1s infinite`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(VWBStepNodeExtension);
|
||||
709
visual_workflow_builder/frontend/src/components/Canvas/index.tsx
Normal file
709
visual_workflow_builder/frontend/src/components/Canvas/index.tsx
Normal file
@@ -0,0 +1,709 @@
|
||||
/**
|
||||
* Composant Canvas Principal - Interface de création de workflows visuels
|
||||
* Auteur : Dom, Alice, Kiro - 08 janvier 2026
|
||||
*
|
||||
* Ce composant gère le rendu visuel des workflows avec @xyflow/react,
|
||||
* la sélection d'étapes, le déplacement en temps réel et la minimap.
|
||||
* Optimisé pour maintenir 60fps lors des interactions.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, memo } from 'react';
|
||||
import {
|
||||
ReactFlow,
|
||||
Node,
|
||||
Edge,
|
||||
addEdge,
|
||||
Connection,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
Controls,
|
||||
MiniMap,
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
NodeTypes,
|
||||
EdgeTypes,
|
||||
MarkerType,
|
||||
useReactFlow,
|
||||
} from '@xyflow/react';
|
||||
import { Box, Paper, Typography } from '@mui/material';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
|
||||
// Composants de nœuds personnalisés
|
||||
import StepNode from './StepNode';
|
||||
|
||||
// Import des types partagés
|
||||
import {
|
||||
CanvasProps,
|
||||
Step,
|
||||
StepExecutionState,
|
||||
StepNodeData,
|
||||
StepType
|
||||
} from '../../types';
|
||||
|
||||
// Types de nœuds personnalisés - mémorisés pour éviter les re-créations
|
||||
const nodeTypes: NodeTypes = {
|
||||
stepNode: StepNode,
|
||||
};
|
||||
|
||||
// Types d'arêtes personnalisés - mémorisés pour éviter les re-créations
|
||||
const edgeTypes: EdgeTypes = {};
|
||||
|
||||
// Options par défaut pour les arêtes - mémorisées pour éviter les re-créations
|
||||
const defaultEdgeOptions = {
|
||||
type: 'smoothstep',
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
width: 20,
|
||||
height: 20,
|
||||
color: '#1976d2',
|
||||
},
|
||||
style: {
|
||||
strokeWidth: 2,
|
||||
stroke: '#1976d2',
|
||||
},
|
||||
// Permettre la sélection et suppression des edges
|
||||
focusable: true,
|
||||
deletable: true,
|
||||
// Style au survol
|
||||
interactionWidth: 20, // Zone de clic plus large
|
||||
};
|
||||
|
||||
// Styles mémorisés pour éviter les re-créations d'objets
|
||||
const canvasStyles = {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: '#fafafa',
|
||||
position: 'relative' as const,
|
||||
// Styles pour les flèches (edges) sélectionnées - ROUGE VIVE pour éviter confusion
|
||||
'& .react-flow__edge.selected .react-flow__edge-path': {
|
||||
stroke: '#f44336 !important', // Rouge Material UI
|
||||
strokeWidth: '4px !important',
|
||||
},
|
||||
'& .react-flow__edge.selected .react-flow__edge-interaction': {
|
||||
stroke: '#f44336 !important',
|
||||
},
|
||||
'& .react-flow__edge.selected marker': {
|
||||
fill: '#f44336 !important',
|
||||
},
|
||||
// Style au survol des edges pour feedback visuel
|
||||
'& .react-flow__edge:hover .react-flow__edge-path': {
|
||||
stroke: '#ff9800 !important', // Orange au survol
|
||||
strokeWidth: '3px !important',
|
||||
},
|
||||
'& .react-flow__edge:hover marker': {
|
||||
fill: '#ff9800 !important',
|
||||
},
|
||||
} as const;
|
||||
|
||||
const paperStyles = {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
position: 'relative' as const,
|
||||
} as const;
|
||||
|
||||
const emptyStateBoxStyles = {
|
||||
position: 'absolute' as const,
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
textAlign: 'center' as const,
|
||||
zIndex: 1000,
|
||||
pointerEvents: 'none' as const,
|
||||
} as const;
|
||||
|
||||
const emptyStatePaperStyles = {
|
||||
p: 4,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
} as const;
|
||||
|
||||
const executionIndicatorBoxStyles = {
|
||||
position: 'absolute' as const,
|
||||
top: 16,
|
||||
right: 16,
|
||||
zIndex: 1000,
|
||||
} as const;
|
||||
|
||||
const executionIndicatorPaperStyles = {
|
||||
p: 2,
|
||||
backgroundColor: '#2196f3',
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
} as const;
|
||||
|
||||
const executionIndicatorAnimationStyles = {
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'white',
|
||||
animation: 'pulse 1s infinite',
|
||||
'@keyframes pulse': {
|
||||
'0%': { opacity: 1 },
|
||||
'50%': { opacity: 0.5 },
|
||||
'100%': { opacity: 1 },
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Composant Canvas principal pour l'édition de workflows
|
||||
*/
|
||||
const Canvas: React.FC<CanvasProps> = ({
|
||||
workflow,
|
||||
selectedStep,
|
||||
executionState,
|
||||
onStepSelect,
|
||||
onStepMove,
|
||||
onConnection,
|
||||
onConnectionDelete,
|
||||
onStepAdd,
|
||||
onStepDelete,
|
||||
}) => {
|
||||
const { fitView, getViewport } = useReactFlow();
|
||||
|
||||
// Conversion des étapes du workflow en nœuds ReactFlow
|
||||
const initialNodes: Node[] = useMemo(() => {
|
||||
if (!workflow?.steps) return [];
|
||||
|
||||
return workflow.steps.map((step) => ({
|
||||
id: step.id,
|
||||
type: 'stepNode',
|
||||
position: step.position,
|
||||
data: {
|
||||
label: step.name,
|
||||
stepType: step.type,
|
||||
executionState: step.executionState || StepExecutionState.IDLE,
|
||||
validationErrors: step.validationErrors || [],
|
||||
isSelected: selectedStep?.id === step.id,
|
||||
parameters: step.data?.parameters || {},
|
||||
// Préserver les flags VWB
|
||||
isVWBCatalogAction: step.data?.isVWBCatalogAction || false,
|
||||
vwbActionId: step.data?.vwbActionId || undefined,
|
||||
} as StepNodeData,
|
||||
selected: selectedStep?.id === step.id,
|
||||
}));
|
||||
}, [workflow?.steps, selectedStep]);
|
||||
|
||||
// Conversion des connexions du workflow en arêtes ReactFlow
|
||||
const initialEdges: Edge[] = useMemo(() => {
|
||||
if (!workflow?.connections) return [];
|
||||
|
||||
return workflow.connections.map((connection) => ({
|
||||
id: connection.id,
|
||||
source: connection.source,
|
||||
target: connection.target,
|
||||
...defaultEdgeOptions,
|
||||
}));
|
||||
}, [workflow?.connections]);
|
||||
|
||||
// États locaux pour les nœuds et connexions
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Gestionnaire d'événements clavier pour le Canvas
|
||||
const handleKeyDown = useCallback((event: KeyboardEvent) => {
|
||||
// Ignorer si l'utilisateur tape dans un champ de saisie
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.key) {
|
||||
case 'Delete':
|
||||
case 'Backspace':
|
||||
// Supprimer les nœuds sélectionnés
|
||||
const selectedNodes = nodes.filter(node => node.selected);
|
||||
if (selectedNodes.length > 0 && onStepDelete) {
|
||||
event.preventDefault();
|
||||
selectedNodes.forEach(node => onStepDelete(node.id));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'Escape':
|
||||
// Désélectionner tous les nœuds
|
||||
event.preventDefault();
|
||||
setNodes(nodes => nodes.map(node => ({ ...node, selected: false })));
|
||||
if (onStepSelect) {
|
||||
onStepSelect(null);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'a':
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
// Sélectionner tous les nœuds
|
||||
event.preventDefault();
|
||||
setNodes(nodes => nodes.map(node => ({ ...node, selected: true })));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ArrowUp':
|
||||
case 'ArrowDown':
|
||||
case 'ArrowLeft':
|
||||
case 'ArrowRight':
|
||||
// Déplacer les nœuds sélectionnés
|
||||
const selectedNodesForMove = nodes.filter(node => node.selected);
|
||||
if (selectedNodesForMove.length > 0) {
|
||||
event.preventDefault();
|
||||
const moveDistance = event.shiftKey ? 50 : 10;
|
||||
|
||||
selectedNodesForMove.forEach(node => {
|
||||
const newPosition = { ...node.position };
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowUp': newPosition.y -= moveDistance; break;
|
||||
case 'ArrowDown': newPosition.y += moveDistance; break;
|
||||
case 'ArrowLeft': newPosition.x -= moveDistance; break;
|
||||
case 'ArrowRight': newPosition.x += moveDistance; break;
|
||||
}
|
||||
|
||||
if (onStepMove) {
|
||||
onStepMove(node.id, newPosition);
|
||||
}
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'Tab':
|
||||
// Navigation entre les nœuds
|
||||
if (nodes.length > 0) {
|
||||
event.preventDefault();
|
||||
const currentSelectedIndex = nodes.findIndex(node => node.selected);
|
||||
let nextIndex: number;
|
||||
|
||||
if (event.shiftKey) {
|
||||
// Tab + Shift : précédent
|
||||
nextIndex = currentSelectedIndex <= 0 ? nodes.length - 1 : currentSelectedIndex - 1;
|
||||
} else {
|
||||
// Tab : suivant
|
||||
nextIndex = currentSelectedIndex >= nodes.length - 1 ? 0 : currentSelectedIndex + 1;
|
||||
}
|
||||
|
||||
const nextNode = nodes[nextIndex];
|
||||
if (nextNode && onStepSelect) {
|
||||
// Désélectionner tous et sélectionner le suivant
|
||||
setNodes(nodes => nodes.map((node, index) => ({
|
||||
...node,
|
||||
selected: index === nextIndex
|
||||
})));
|
||||
|
||||
// Trouver l'étape correspondante
|
||||
const step = workflow?.steps.find(s => s.id === nextNode.id);
|
||||
if (step) {
|
||||
onStepSelect(step);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'Enter':
|
||||
// Activer l'édition du nœud sélectionné
|
||||
const selectedNode = nodes.find(node => node.selected);
|
||||
if (selectedNode) {
|
||||
event.preventDefault();
|
||||
const step = workflow?.steps.find(s => s.id === selectedNode.id);
|
||||
if (step && onStepSelect) {
|
||||
onStepSelect(step);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}, [nodes, setNodes, onStepDelete, onStepSelect, onStepMove, workflow?.steps]);
|
||||
|
||||
// Attacher/détacher les gestionnaires d'événements clavier
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) {
|
||||
canvas.addEventListener('keydown', handleKeyDown);
|
||||
// Rendre le canvas focusable
|
||||
canvas.setAttribute('tabindex', '0');
|
||||
|
||||
return () => {
|
||||
canvas.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}
|
||||
}, [handleKeyDown]);
|
||||
|
||||
// Synchroniser les nœuds avec les changements du workflow - optimisé
|
||||
useEffect(() => {
|
||||
console.log('🔄 [Canvas] Synchronisation nœuds:', {
|
||||
currentCount: nodes.length,
|
||||
newCount: initialNodes.length,
|
||||
workflowId: workflow?.id,
|
||||
});
|
||||
|
||||
// Forcer la mise à jour si le nombre ou les IDs changent
|
||||
const currentNodeIds = nodes.map(n => n.id).sort().join(',');
|
||||
const newNodeIds = initialNodes.map(n => n.id).sort().join(',');
|
||||
|
||||
if (currentNodeIds !== newNodeIds || nodes.length !== initialNodes.length) {
|
||||
console.log('✅ [Canvas] Mise à jour des nœuds:', initialNodes.length);
|
||||
setNodes(initialNodes);
|
||||
// Ajuster la vue après chargement
|
||||
setTimeout(() => fitView({ padding: 0.2 }), 100);
|
||||
}
|
||||
}, [initialNodes, setNodes, nodes, workflow?.id, fitView]);
|
||||
|
||||
// Synchroniser les arêtes avec les changements du workflow - optimisé
|
||||
useEffect(() => {
|
||||
// Éviter les re-rendus inutiles en comparant les données
|
||||
const currentEdgeIds = edges.map(e => e.id).sort().join(',');
|
||||
const newEdgeIds = initialEdges.map(e => e.id).sort().join(',');
|
||||
|
||||
if (currentEdgeIds !== newEdgeIds || edges.length !== initialEdges.length) {
|
||||
setEdges(initialEdges);
|
||||
}
|
||||
}, [initialEdges, setEdges, edges]);
|
||||
|
||||
// Gestionnaire de connexion entre étapes
|
||||
const onConnect = useCallback(
|
||||
(params: Connection) => {
|
||||
const newEdge = {
|
||||
...params,
|
||||
...defaultEdgeOptions,
|
||||
};
|
||||
setEdges((eds) => addEdge(newEdge, eds));
|
||||
|
||||
if (onConnection && params.source && params.target) {
|
||||
onConnection(params.source, params.target);
|
||||
}
|
||||
},
|
||||
[onConnection, setEdges]
|
||||
);
|
||||
|
||||
// Gestionnaire de sélection d'étape
|
||||
const onNodeClick = useCallback(
|
||||
(event: React.MouseEvent, node: Node) => {
|
||||
if (onStepSelect) {
|
||||
const nodeData = node.data as StepNodeData;
|
||||
const step: Step = {
|
||||
id: node.id,
|
||||
type: nodeData?.stepType || 'click',
|
||||
name: nodeData?.label || '',
|
||||
position: node.position,
|
||||
data: {
|
||||
label: nodeData?.label || '',
|
||||
stepType: nodeData?.stepType || 'click',
|
||||
parameters: nodeData?.parameters || {},
|
||||
isSelected: true,
|
||||
// Préserver les flags VWB s'ils existent
|
||||
...(nodeData?.isVWBCatalogAction && {
|
||||
isVWBCatalogAction: true,
|
||||
vwbActionId: nodeData?.vwbActionId || nodeData?.stepType,
|
||||
}),
|
||||
},
|
||||
executionState: nodeData?.executionState,
|
||||
validationErrors: nodeData?.validationErrors,
|
||||
};
|
||||
console.log('🖱️ [Canvas] Sélection étape:', {
|
||||
id: step.id,
|
||||
type: step.type,
|
||||
isVWB: step.data.isVWBCatalogAction,
|
||||
vwbActionId: step.data.vwbActionId
|
||||
});
|
||||
onStepSelect(step);
|
||||
}
|
||||
},
|
||||
[onStepSelect]
|
||||
);
|
||||
|
||||
// Gestionnaire de double-clic pour édition d'étape
|
||||
const onNodeDoubleClick = useCallback(
|
||||
(event: React.MouseEvent, node: Node) => {
|
||||
console.log('Double-clic sur étape:', node.id);
|
||||
// Sélectionner l'étape et ouvrir le panneau de propriétés
|
||||
if (onStepSelect) {
|
||||
const nodeData = node.data as StepNodeData;
|
||||
const step: Step = {
|
||||
id: node.id,
|
||||
type: nodeData?.stepType || 'click',
|
||||
name: nodeData?.label || '',
|
||||
position: node.position,
|
||||
data: {
|
||||
label: nodeData?.label || '',
|
||||
stepType: nodeData?.stepType || 'click',
|
||||
parameters: nodeData?.parameters || {},
|
||||
isSelected: true,
|
||||
// Préserver les flags VWB s'ils existent
|
||||
...(nodeData?.isVWBCatalogAction && {
|
||||
isVWBCatalogAction: true,
|
||||
vwbActionId: nodeData?.vwbActionId || nodeData?.stepType,
|
||||
}),
|
||||
},
|
||||
executionState: nodeData?.executionState,
|
||||
validationErrors: nodeData?.validationErrors,
|
||||
};
|
||||
onStepSelect(step);
|
||||
}
|
||||
},
|
||||
[onStepSelect]
|
||||
);
|
||||
|
||||
// Gestionnaire de déplacement d'étape
|
||||
const onNodeDragStop = useCallback(
|
||||
(event: React.MouseEvent, node: Node) => {
|
||||
if (onStepMove) {
|
||||
onStepMove(node.id, node.position);
|
||||
}
|
||||
},
|
||||
[onStepMove]
|
||||
);
|
||||
|
||||
// Gestionnaire de suppression de nœud
|
||||
const onNodesDelete = useCallback(
|
||||
(nodesToDelete: Node[]) => {
|
||||
if (onStepDelete) {
|
||||
nodesToDelete.forEach((node) => {
|
||||
onStepDelete(node.id);
|
||||
});
|
||||
}
|
||||
},
|
||||
[onStepDelete]
|
||||
);
|
||||
|
||||
// Gestionnaire de suppression de connexion (edge)
|
||||
const onEdgesDelete = useCallback(
|
||||
(edgesToDelete: Edge[]) => {
|
||||
if (onConnectionDelete) {
|
||||
edgesToDelete.forEach((edge) => {
|
||||
onConnectionDelete(edge.id);
|
||||
});
|
||||
}
|
||||
},
|
||||
[onConnectionDelete]
|
||||
);
|
||||
|
||||
// Liste des actions VWB connues du catalogue
|
||||
const knownVWBActions = useMemo(() => [
|
||||
'click_anchor', 'type_text', 'type_secret', 'wait_for_anchor',
|
||||
'extract_text', 'screenshot_evidence', 'scroll_to_anchor',
|
||||
'focus_anchor', 'hotkey', 'navigate_to_url', 'browser_back',
|
||||
'verify_element_exists', 'verify_text_content'
|
||||
], []);
|
||||
|
||||
// Fonction pour détecter si un type est une action VWB
|
||||
const isVWBActionType = useCallback((stepType: string): boolean => {
|
||||
// Vérifie si c'est un type connu du catalogue
|
||||
if (knownVWBActions.includes(stepType)) return true;
|
||||
// Vérifie les patterns VWB
|
||||
if (stepType.startsWith('catalog:')) return true;
|
||||
if (stepType.includes('_anchor')) return true;
|
||||
if (stepType.includes('_text') && stepType !== 'type') return true;
|
||||
if (stepType.includes('_secret')) return true;
|
||||
return false;
|
||||
}, [knownVWBActions]);
|
||||
|
||||
// Noms lisibles pour les actions VWB
|
||||
const getVWBActionName = useCallback((actionId: string): string => {
|
||||
const names: Record<string, string> = {
|
||||
'click_anchor': 'Cliquer sur Ancre',
|
||||
'type_text': 'Saisir Texte',
|
||||
'type_secret': 'Saisir Texte Secret',
|
||||
'wait_for_anchor': 'Attendre Ancre',
|
||||
'extract_text': 'Extraire Texte',
|
||||
'screenshot_evidence': 'Capture Evidence',
|
||||
'scroll_to_anchor': 'Défiler vers Ancre',
|
||||
'focus_anchor': 'Focaliser Ancre',
|
||||
'hotkey': 'Raccourci Clavier',
|
||||
'navigate_to_url': 'Naviguer vers URL',
|
||||
'browser_back': 'Retour Navigateur',
|
||||
'verify_element_exists': 'Vérifier Existence',
|
||||
'verify_text_content': 'Vérifier Contenu Texte',
|
||||
};
|
||||
return names[actionId] || actionId.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
||||
}, []);
|
||||
|
||||
// Gestionnaire de drop depuis la palette
|
||||
const onDrop = useCallback(
|
||||
(event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
let stepType = event.dataTransfer.getData('application/reactflow');
|
||||
if (!stepType || !onStepAdd) return;
|
||||
|
||||
// Gérer le format "catalog:action_id" de la palette
|
||||
let actualType = stepType;
|
||||
let isFromCatalog = false;
|
||||
if (stepType.startsWith('catalog:')) {
|
||||
actualType = stepType.replace('catalog:', '');
|
||||
isFromCatalog = true;
|
||||
}
|
||||
|
||||
// Obtenir la position relative au ReactFlow
|
||||
const reactFlowBounds = event.currentTarget.getBoundingClientRect();
|
||||
const { x: viewportX, y: viewportY, zoom } = getViewport();
|
||||
|
||||
// Calculer la position en tenant compte du zoom et du pan
|
||||
const position = {
|
||||
x: (event.clientX - reactFlowBounds.left - viewportX) / zoom,
|
||||
y: (event.clientY - reactFlowBounds.top - viewportY) / zoom,
|
||||
};
|
||||
|
||||
// Détecter si c'est une action VWB
|
||||
const isVWB = isFromCatalog || isVWBActionType(actualType);
|
||||
const stepName = isVWB ? getVWBActionName(actualType) : `Nouvelle étape ${actualType}`;
|
||||
|
||||
const newStep: Omit<Step, 'id'> = {
|
||||
type: actualType as StepType,
|
||||
name: stepName,
|
||||
position,
|
||||
data: {
|
||||
label: stepName,
|
||||
stepType: actualType as StepType,
|
||||
parameters: {},
|
||||
// Marquer les actions VWB avec les flags appropriés
|
||||
...(isVWB && {
|
||||
isVWBCatalogAction: true,
|
||||
vwbActionId: actualType,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
console.log('📦 [Canvas] Création étape:', {
|
||||
type: actualType,
|
||||
isVWB,
|
||||
isFromCatalog,
|
||||
data: newStep.data
|
||||
});
|
||||
|
||||
onStepAdd(newStep);
|
||||
},
|
||||
[onStepAdd, getViewport, isVWBActionType, getVWBActionName]
|
||||
);
|
||||
|
||||
const onDragOver = useCallback((event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
}, []);
|
||||
|
||||
// Déterminer si la minimap doit être affichée
|
||||
const shouldShowMinimap = nodes.length > 20;
|
||||
|
||||
// Message d'état vide
|
||||
const EmptyState = () => (
|
||||
<Box sx={emptyStateBoxStyles}>
|
||||
<Paper elevation={2} sx={emptyStatePaperStyles}>
|
||||
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||
Espace de travail vide
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Glissez des étapes depuis la palette pour commencer à créer votre workflow
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={canvasRef}
|
||||
sx={canvasStyles}
|
||||
onDrop={onDrop}
|
||||
onDragOver={onDragOver}
|
||||
role="application"
|
||||
aria-label="Zone de création de workflow"
|
||||
tabIndex={0}
|
||||
>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
onNodeClick={onNodeClick}
|
||||
onNodeDoubleClick={onNodeDoubleClick}
|
||||
onNodeDragStop={onNodeDragStop}
|
||||
onNodesDelete={onNodesDelete}
|
||||
onEdgesDelete={onEdgesDelete}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
defaultEdgeOptions={defaultEdgeOptions}
|
||||
fitView
|
||||
attributionPosition="bottom-left"
|
||||
deleteKeyCode={['Backspace', 'Delete']}
|
||||
multiSelectionKeyCode={['Meta', 'Ctrl']}
|
||||
edgesFocusable={true}
|
||||
edgesReconnectable={true}
|
||||
panOnScroll
|
||||
selectionOnDrag
|
||||
panOnDrag={[1, 2]} // Clic milieu et droit pour panoramique
|
||||
selectNodesOnDrag={false}
|
||||
>
|
||||
{/* Grille d'alignement */}
|
||||
<Background
|
||||
variant={BackgroundVariant.Dots}
|
||||
gap={20}
|
||||
size={1}
|
||||
color="#e0e0e0"
|
||||
/>
|
||||
|
||||
{/* Contrôles de navigation */}
|
||||
<Controls
|
||||
position="top-left"
|
||||
showZoom={true}
|
||||
showFitView={true}
|
||||
showInteractive={true}
|
||||
style={{
|
||||
backgroundColor: '#ffffff',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Minimap pour navigation dans les gros workflows */}
|
||||
{shouldShowMinimap && (
|
||||
<MiniMap
|
||||
position="bottom-right"
|
||||
zoomable
|
||||
pannable
|
||||
style={{
|
||||
backgroundColor: '#ffffff',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
nodeColor={(node) => {
|
||||
switch (node.data?.executionState) {
|
||||
case StepExecutionState.RUNNING:
|
||||
return '#2196f3';
|
||||
case StepExecutionState.SUCCESS:
|
||||
return '#4caf50';
|
||||
case StepExecutionState.ERROR:
|
||||
return '#f44336';
|
||||
default:
|
||||
return '#9e9e9e';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ReactFlow>
|
||||
|
||||
{/* État vide */}
|
||||
{nodes.length === 0 && <EmptyState />}
|
||||
|
||||
{/* Indicateur d'exécution */}
|
||||
{executionState?.status === 'running' && (
|
||||
<Box sx={executionIndicatorBoxStyles}>
|
||||
<Paper elevation={3} sx={executionIndicatorPaperStyles}>
|
||||
<Box sx={executionIndicatorAnimationStyles} />
|
||||
<Typography variant="body2">
|
||||
Exécution en cours...
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// Mémorisation du composant Canvas pour éviter les re-rendus inutiles
|
||||
export default memo(Canvas, (prevProps, nextProps) => {
|
||||
// Comparaison personnalisée pour optimiser les re-rendus
|
||||
return (
|
||||
prevProps.workflow?.id === nextProps.workflow?.id &&
|
||||
prevProps.workflow?.steps?.length === nextProps.workflow?.steps?.length &&
|
||||
prevProps.selectedStep?.id === nextProps.selectedStep?.id &&
|
||||
prevProps.executionState?.status === nextProps.executionState?.status &&
|
||||
JSON.stringify(prevProps.workflow?.connections) === JSON.stringify(nextProps.workflow?.connections)
|
||||
);
|
||||
});
|
||||
@@ -106,7 +106,7 @@ const CoachingSuggestionCard: React.FC<CoachingSuggestionCardProps> = ({
|
||||
{suggestion.screenshotPath && (
|
||||
<div className="suggestion-screenshot">
|
||||
<img
|
||||
src={`http://localhost:5000${suggestion.screenshotPath}`}
|
||||
src={`http://localhost:5001${suggestion.screenshotPath}`}
|
||||
alt="Target element"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
|
||||
@@ -143,7 +143,7 @@ export const CoachingPanel: React.FC<CoachingPanelProps> = ({
|
||||
const startSession = useCallback(
|
||||
async (wfId: string) => {
|
||||
try {
|
||||
const response = await fetch(`${serverUrl || 'http://localhost:5000'}/api/executions/coaching`, {
|
||||
const response = await fetch(`${serverUrl || 'http://localhost:5001'}/api/executions/coaching`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workflow_id: wfId }),
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Indicateur de Connexion API - Affichage stable de l'état de connexion
|
||||
* Auteur : Dom, Alice, Kiro - 09 janvier 2026
|
||||
*
|
||||
* Ce composant affiche l'état de connexion de l'API de manière non-intrusive
|
||||
* et stable, sans provoquer de "sauts" de page.
|
||||
*/
|
||||
|
||||
import React, { memo, useState, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Chip,
|
||||
Tooltip,
|
||||
IconButton,
|
||||
Snackbar,
|
||||
Alert,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
CloudDone as OnlineIcon,
|
||||
CloudOff as OfflineIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Sync as CheckingIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { useConnectionStatus } from '../../hooks/useConnectionStatus';
|
||||
|
||||
interface ConnectionIndicatorProps {
|
||||
/** Afficher en mode compact (icône seule) */
|
||||
compact?: boolean;
|
||||
/** Position de l'indicateur */
|
||||
position?: 'inline' | 'fixed';
|
||||
/** Afficher le bouton de rafraîchissement */
|
||||
showRefreshButton?: boolean;
|
||||
/** Callback quand l'état change */
|
||||
onStatusChange?: (isOnline: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composant d'indicateur de connexion API
|
||||
* Mémorisé pour éviter les re-rendus inutiles
|
||||
*/
|
||||
const ConnectionIndicator: React.FC<ConnectionIndicatorProps> = memo(({
|
||||
compact = false,
|
||||
position = 'inline',
|
||||
showRefreshButton = true,
|
||||
onStatusChange,
|
||||
}) => {
|
||||
const { status, isOnline, isOffline, isChecking, statusMessage, forceCheck } = useConnectionStatus({
|
||||
onStatusChange: (newStatus) => {
|
||||
if (onStatusChange) {
|
||||
onStatusChange(newStatus === 'online');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [showNotification, setShowNotification] = useState(false);
|
||||
const [notificationMessage, setNotificationMessage] = useState('');
|
||||
|
||||
// Gestionnaire de rafraîchissement
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setIsRefreshing(true);
|
||||
|
||||
try {
|
||||
const result = await forceCheck();
|
||||
setNotificationMessage(result ? 'Connexion rétablie' : 'API toujours hors ligne');
|
||||
setShowNotification(true);
|
||||
} catch (error) {
|
||||
setNotificationMessage('Erreur lors de la vérification');
|
||||
setShowNotification(true);
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}, [forceCheck]);
|
||||
|
||||
// Fermer la notification
|
||||
const handleCloseNotification = useCallback(() => {
|
||||
setShowNotification(false);
|
||||
}, []);
|
||||
|
||||
// Déterminer l'icône et la couleur
|
||||
const getIconAndColor = () => {
|
||||
if (isChecking || isRefreshing) {
|
||||
return {
|
||||
icon: <CheckingIcon sx={{ animation: 'spin 1s linear infinite' }} />,
|
||||
color: 'default' as const,
|
||||
label: 'Vérification...',
|
||||
};
|
||||
}
|
||||
|
||||
if (isOnline) {
|
||||
return {
|
||||
icon: <OnlineIcon />,
|
||||
color: 'success' as const,
|
||||
label: 'API connectée',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
icon: <OfflineIcon />,
|
||||
color: 'warning' as const,
|
||||
label: 'Mode hors ligne',
|
||||
};
|
||||
};
|
||||
|
||||
const { icon, color, label } = getIconAndColor();
|
||||
|
||||
// Style pour l'animation de rotation
|
||||
const spinKeyframes = `
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`;
|
||||
|
||||
// Rendu compact (icône seule)
|
||||
if (compact) {
|
||||
return (
|
||||
<>
|
||||
<style>{spinKeyframes}</style>
|
||||
<Tooltip title={statusMessage} arrow>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
}}
|
||||
>
|
||||
<Chip
|
||||
icon={icon}
|
||||
size="small"
|
||||
color={color}
|
||||
variant="outlined"
|
||||
sx={{
|
||||
minWidth: 'auto',
|
||||
'& .MuiChip-label': { display: 'none' },
|
||||
'& .MuiChip-icon': { margin: 0 },
|
||||
}}
|
||||
/>
|
||||
{showRefreshButton && isOffline && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
sx={{ padding: 0.5 }}
|
||||
>
|
||||
<RefreshIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Rendu complet
|
||||
return (
|
||||
<>
|
||||
<style>{spinKeyframes}</style>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
...(position === 'fixed' && {
|
||||
position: 'fixed',
|
||||
bottom: 16,
|
||||
right: 16,
|
||||
zIndex: 1000,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<Tooltip title={`${statusMessage} - Cliquer pour rafraîchir`} arrow>
|
||||
<Chip
|
||||
icon={icon}
|
||||
label={label}
|
||||
size="small"
|
||||
color={color}
|
||||
variant="outlined"
|
||||
onClick={handleRefresh}
|
||||
sx={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
{showRefreshButton && (
|
||||
<Tooltip title="Vérifier la connexion" arrow>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
color="primary"
|
||||
>
|
||||
<RefreshIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Notification de changement d'état */}
|
||||
<Snackbar
|
||||
open={showNotification}
|
||||
autoHideDuration={3000}
|
||||
onClose={handleCloseNotification}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<Alert
|
||||
onClose={handleCloseNotification}
|
||||
severity={isOnline ? 'success' : 'warning'}
|
||||
variant="filled"
|
||||
sx={{ width: '100%' }}
|
||||
>
|
||||
{notificationMessage}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
ConnectionIndicator.displayName = 'ConnectionIndicator';
|
||||
|
||||
export default ConnectionIndicator;
|
||||
@@ -0,0 +1,448 @@
|
||||
/**
|
||||
* Composant d'Aide Contextuelle - Assistance intelligente en français
|
||||
* Auteur : Dom, Alice, Kiro - 08 janvier 2026
|
||||
*
|
||||
* Ce composant fournit une aide contextuelle intelligente qui s'adapte
|
||||
* à l'action en cours de l'utilisateur et propose des conseils pertinents.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
IconButton,
|
||||
Collapse,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Chip,
|
||||
Button,
|
||||
Divider,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Help as HelpIcon,
|
||||
Close as CloseIcon,
|
||||
Lightbulb as LightbulbIcon,
|
||||
Info as InfoIcon,
|
||||
Warning as WarningIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
KeyboardArrowUp as ArrowUpIcon,
|
||||
KeyboardArrowDown as ArrowDownIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface ContextualHelpProps {
|
||||
context: 'canvas' | 'palette' | 'properties' | 'validation' | 'execution' | 'variables';
|
||||
selectedStepType?: string;
|
||||
currentErrors?: string[];
|
||||
isVisible?: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
interface HelpTip {
|
||||
id: string;
|
||||
type: 'tip' | 'info' | 'warning' | 'success';
|
||||
title: string;
|
||||
content: string;
|
||||
action?: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
// Conseils contextuels par zone de l'interface
|
||||
const contextualTips: Record<string, HelpTip[]> = {
|
||||
canvas: [
|
||||
{
|
||||
id: 'canvas_basics',
|
||||
type: 'tip',
|
||||
title: 'Premiers pas sur le canvas',
|
||||
content: 'Glissez des étapes depuis la palette vers cette zone pour construire votre workflow. Utilisez la molette pour zoomer et cliquez-glissez pour vous déplacer.'
|
||||
},
|
||||
{
|
||||
id: 'canvas_connections',
|
||||
type: 'info',
|
||||
title: 'Connecter les étapes',
|
||||
content: 'Cliquez sur le point de sortie d\'une étape (cercle à droite) et glissez vers le point d\'entrée d\'une autre étape pour les connecter.'
|
||||
},
|
||||
{
|
||||
id: 'canvas_selection',
|
||||
type: 'tip',
|
||||
title: 'Sélection multiple',
|
||||
content: 'Maintenez Ctrl et cliquez sur plusieurs étapes pour les sélectionner. Vous pouvez ensuite les déplacer ensemble ou les supprimer.'
|
||||
}
|
||||
],
|
||||
palette: [
|
||||
{
|
||||
id: 'palette_categories',
|
||||
type: 'info',
|
||||
title: 'Organisation par catégories',
|
||||
content: 'Les étapes sont organisées en catégories logiques : Actions Web pour interagir avec les pages, Logique pour les conditions, etc.'
|
||||
},
|
||||
{
|
||||
id: 'palette_search',
|
||||
type: 'tip',
|
||||
title: 'Recherche rapide',
|
||||
content: 'Utilisez le champ de recherche pour trouver rapidement une étape par son nom ou sa description.'
|
||||
},
|
||||
{
|
||||
id: 'palette_drag',
|
||||
type: 'tip',
|
||||
title: 'Glisser-déposer',
|
||||
content: 'Glissez une étape depuis la palette vers le canvas pour l\'ajouter à votre workflow. L\'étape apparaîtra à l\'endroit où vous la relâchez.'
|
||||
}
|
||||
],
|
||||
properties: [
|
||||
{
|
||||
id: 'properties_required',
|
||||
type: 'warning',
|
||||
title: 'Paramètres obligatoires',
|
||||
content: 'Les paramètres marqués d\'un astérisque (*) sont obligatoires. Le workflow ne pourra pas s\'exécuter sans eux.'
|
||||
},
|
||||
{
|
||||
id: 'properties_variables',
|
||||
type: 'tip',
|
||||
title: 'Utilisation des variables',
|
||||
content: 'Vous pouvez utiliser des variables dans les champs texte avec la syntaxe ${nom_variable}. Cela rend vos workflows réutilisables.'
|
||||
},
|
||||
{
|
||||
id: 'properties_visual',
|
||||
type: 'info',
|
||||
title: 'Sélection visuelle',
|
||||
content: 'Pour les paramètres "Élément cible", utilisez le bouton "Sélectionner un élément" pour choisir visuellement sur une capture d\'écran.'
|
||||
}
|
||||
],
|
||||
validation: [
|
||||
{
|
||||
id: 'validation_errors',
|
||||
type: 'warning',
|
||||
title: 'Correction des erreurs',
|
||||
content: 'Les erreurs en rouge empêchent l\'exécution. Cliquez sur une erreur pour aller directement à l\'étape concernée.'
|
||||
},
|
||||
{
|
||||
id: 'validation_warnings',
|
||||
type: 'info',
|
||||
title: 'Avertissements',
|
||||
content: 'Les avertissements en orange n\'empêchent pas l\'exécution mais peuvent indiquer des problèmes potentiels.'
|
||||
},
|
||||
{
|
||||
id: 'validation_cycles',
|
||||
type: 'warning',
|
||||
title: 'Éviter les boucles infinies',
|
||||
content: 'Vérifiez que vos connexions ne créent pas de cycles (A → B → A) qui empêcheraient l\'exécution normale.'
|
||||
}
|
||||
],
|
||||
execution: [
|
||||
{
|
||||
id: 'execution_states',
|
||||
type: 'info',
|
||||
title: 'États d\'exécution',
|
||||
content: 'Pendant l\'exécution, les étapes changent de couleur : bleu (en cours), vert (réussie), rouge (échouée).'
|
||||
},
|
||||
{
|
||||
id: 'execution_pause',
|
||||
type: 'tip',
|
||||
title: 'Contrôle de l\'exécution',
|
||||
content: 'Vous pouvez mettre en pause, arrêter ou redémarrer l\'exécution à tout moment avec les boutons de contrôle.'
|
||||
},
|
||||
{
|
||||
id: 'execution_debug',
|
||||
type: 'tip',
|
||||
title: 'Débogage',
|
||||
content: 'En cas d\'erreur, consultez les détails dans le panneau d\'exécution pour comprendre ce qui s\'est passé.'
|
||||
}
|
||||
],
|
||||
variables: [
|
||||
{
|
||||
id: 'variables_naming',
|
||||
type: 'tip',
|
||||
title: 'Nommage des variables',
|
||||
content: 'Utilisez des noms descriptifs pour vos variables : "nom_utilisateur" plutôt que "var1". Évitez les espaces et caractères spéciaux.'
|
||||
},
|
||||
{
|
||||
id: 'variables_types',
|
||||
type: 'info',
|
||||
title: 'Types de variables',
|
||||
content: 'Définissez le bon type pour chaque variable (texte, nombre, booléen) pour éviter les erreurs de validation.'
|
||||
},
|
||||
{
|
||||
id: 'variables_scope',
|
||||
type: 'tip',
|
||||
title: 'Portée des variables',
|
||||
content: 'Les variables sont disponibles dans tout le workflow une fois créées. Elles peuvent être modifiées par les étapes d\'extraction.'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Conseils spécifiques par type d'étape
|
||||
const stepSpecificTips: Record<string, HelpTip[]> = {
|
||||
click: [
|
||||
{
|
||||
id: 'click_types',
|
||||
type: 'info',
|
||||
title: 'Types de clic',
|
||||
content: 'Utilisez le clic gauche pour la plupart des actions, le clic droit pour les menus contextuels, et le double-clic pour les sélections.'
|
||||
},
|
||||
{
|
||||
id: 'click_timing',
|
||||
type: 'tip',
|
||||
title: 'Timing des clics',
|
||||
content: 'Si un clic ne fonctionne pas, ajoutez une étape d\'attente avant pour laisser le temps à la page de se charger.'
|
||||
}
|
||||
],
|
||||
type: [
|
||||
{
|
||||
id: 'type_clear',
|
||||
type: 'tip',
|
||||
title: 'Vider avant saisie',
|
||||
content: 'Activez "Vider le champ d\'abord" si vous voulez remplacer le contenu existant plutôt que l\'ajouter.'
|
||||
},
|
||||
{
|
||||
id: 'type_variables',
|
||||
type: 'info',
|
||||
title: 'Texte dynamique',
|
||||
content: 'Utilisez ${nom_variable} pour insérer des valeurs dynamiques dans le texte à saisir.'
|
||||
}
|
||||
],
|
||||
wait: [
|
||||
{
|
||||
id: 'wait_duration',
|
||||
type: 'tip',
|
||||
title: 'Durée d\'attente',
|
||||
content: 'Utilisez des durées courtes (0.5-2 secondes) pour les interactions rapides, plus longues (3-10 secondes) pour les chargements de page.'
|
||||
}
|
||||
],
|
||||
condition: [
|
||||
{
|
||||
id: 'condition_operators',
|
||||
type: 'info',
|
||||
title: 'Opérateurs disponibles',
|
||||
content: 'Utilisez ==, !=, >, <, >=, <= pour comparer des valeurs. Exemple : ${age} >= 18 ou ${status} == "actif".'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
/**
|
||||
* Composant d'Aide Contextuelle
|
||||
*/
|
||||
const ContextualHelp: React.FC<ContextualHelpProps> = ({
|
||||
context,
|
||||
selectedStepType,
|
||||
currentErrors = [],
|
||||
isVisible = true,
|
||||
onClose,
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [currentTips, setCurrentTips] = useState<HelpTip[]>([]);
|
||||
|
||||
// Gestionnaire d'événements clavier pour l'aide contextuelle
|
||||
const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
|
||||
switch (event.key) {
|
||||
case 'h':
|
||||
// Raccourci pour basculer l'aide (Ctrl+H)
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
event.preventDefault();
|
||||
setIsExpanded(prev => !prev);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
// Fermer l'aide
|
||||
event.preventDefault();
|
||||
setIsExpanded(false);
|
||||
if (onClose) onClose();
|
||||
break;
|
||||
case '?':
|
||||
// Raccourci pour ouvrir l'aide
|
||||
event.preventDefault();
|
||||
setIsExpanded(true);
|
||||
break;
|
||||
}
|
||||
}, [onClose]);
|
||||
|
||||
// Mettre à jour les conseils selon le contexte
|
||||
useEffect(() => {
|
||||
let tips: HelpTip[] = [];
|
||||
|
||||
// Ajouter les conseils contextuels
|
||||
if (contextualTips[context]) {
|
||||
tips.push(...contextualTips[context]);
|
||||
}
|
||||
|
||||
// Ajouter les conseils spécifiques à l'étape sélectionnée
|
||||
if (selectedStepType && stepSpecificTips[selectedStepType]) {
|
||||
tips.push(...stepSpecificTips[selectedStepType]);
|
||||
}
|
||||
|
||||
// Ajouter des conseils basés sur les erreurs actuelles
|
||||
if (currentErrors.length > 0) {
|
||||
tips.unshift({
|
||||
id: 'current_errors',
|
||||
type: 'warning',
|
||||
title: `${currentErrors.length} erreur(s) détectée(s)`,
|
||||
content: 'Corrigez les erreurs signalées en rouge pour pouvoir exécuter votre workflow.'
|
||||
});
|
||||
}
|
||||
|
||||
setCurrentTips(tips);
|
||||
}, [context, selectedStepType, currentErrors]);
|
||||
|
||||
// Obtenir l'icône selon le type de conseil
|
||||
const getTipIcon = (type: HelpTip['type']) => {
|
||||
switch (type) {
|
||||
case 'tip':
|
||||
return <LightbulbIcon color="primary" />;
|
||||
case 'info':
|
||||
return <InfoIcon color="info" />;
|
||||
case 'warning':
|
||||
return <WarningIcon color="warning" />;
|
||||
case 'success':
|
||||
return <CheckCircleIcon color="success" />;
|
||||
default:
|
||||
return <HelpIcon />;
|
||||
}
|
||||
};
|
||||
|
||||
// Obtenir la couleur selon le type
|
||||
const getTipColor = (type: HelpTip['type']) => {
|
||||
switch (type) {
|
||||
case 'tip':
|
||||
return 'primary';
|
||||
case 'info':
|
||||
return 'info';
|
||||
case 'warning':
|
||||
return 'warning';
|
||||
case 'success':
|
||||
return 'success';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
if (!isVisible || currentTips.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={3}
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
bottom: 20,
|
||||
right: 20,
|
||||
width: 320,
|
||||
maxHeight: 400,
|
||||
zIndex: 1000,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* En-tête */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
p: 2,
|
||||
backgroundColor: 'primary.main',
|
||||
color: 'primary.contrastText',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<HelpIcon />
|
||||
<Typography variant="subtitle2">
|
||||
Aide contextuelle
|
||||
</Typography>
|
||||
<Chip
|
||||
label={currentTips.length}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
color: 'inherit',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsExpanded(!isExpanded);
|
||||
}}
|
||||
sx={{ color: 'inherit', mr: 1 }}
|
||||
>
|
||||
{isExpanded ? <ArrowDownIcon /> : <ArrowUpIcon />}
|
||||
</IconButton>
|
||||
{onClose && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
sx={{ color: 'inherit' }}
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Contenu */}
|
||||
<Collapse in={isExpanded}>
|
||||
<Box
|
||||
sx={{ maxHeight: 300, overflow: 'auto' }}
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={0}
|
||||
>
|
||||
<List dense>
|
||||
{currentTips.map((tip, index) => (
|
||||
<React.Fragment key={tip.id}>
|
||||
<ListItem alignItems="flex-start">
|
||||
<ListItemIcon sx={{ mt: 0.5 }}>
|
||||
{getTipIcon(tip.type)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||
<Typography variant="subtitle2">
|
||||
{tip.title}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={tip.type === 'tip' ? 'Conseil' : tip.type === 'info' ? 'Info' : tip.type === 'warning' ? 'Attention' : 'Succès'}
|
||||
size="small"
|
||||
color={getTipColor(tip.type) as any}
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
secondary={
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{tip.content}
|
||||
</Typography>
|
||||
{tip.action && (
|
||||
<Button
|
||||
size="small"
|
||||
onClick={tip.action.onClick}
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
{tip.action.label}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
{index < currentTips.length - 1 && <Divider />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContextualHelp;
|
||||
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* Styles CSS pour le composant DebugPanel
|
||||
* Auteur : Dom, Alice, Kiro - 12 janvier 2026
|
||||
*/
|
||||
|
||||
.debug-panel {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
width: 400px;
|
||||
max-height: calc(100vh - 40px);
|
||||
background-color: white;
|
||||
border: 2px solid #1976d2;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
z-index: 9999;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
|
||||
.debug-panel-header {
|
||||
padding: 16px;
|
||||
background-color: #1976d2;
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid #1565c0;
|
||||
}
|
||||
|
||||
.debug-panel-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.debug-panel-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.debug-panel-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.debug-panel-toggle {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
background-color: #1976d2;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.debug-panel-toggle:hover {
|
||||
background-color: #1565c0;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.debug-section {
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.debug-section-header {
|
||||
padding: 12px 16px;
|
||||
background-color: #f5f5f5;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.debug-section-header:hover {
|
||||
background-color: #eeeeee;
|
||||
}
|
||||
|
||||
.debug-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.debug-section-content {
|
||||
padding: 16px;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.debug-info-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.debug-info-table th,
|
||||
.debug-info-table td {
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.debug-info-table th {
|
||||
background-color: #f5f5f5;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.debug-info-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.debug-chip {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.debug-chip-success {
|
||||
background-color: #e8f5e8;
|
||||
color: #2e7d32;
|
||||
border: 1px solid #c8e6c9;
|
||||
}
|
||||
|
||||
.debug-chip-error {
|
||||
background-color: #ffebee;
|
||||
color: #c62828;
|
||||
border: 1px solid #ffcdd2;
|
||||
}
|
||||
|
||||
.debug-chip-warning {
|
||||
background-color: #fff3e0;
|
||||
color: #ef6c00;
|
||||
border: 1px solid #ffcc02;
|
||||
}
|
||||
|
||||
.debug-chip-info {
|
||||
background-color: #e3f2fd;
|
||||
color: #1976d2;
|
||||
border: 1px solid #bbdefb;
|
||||
}
|
||||
|
||||
.debug-chip-default {
|
||||
background-color: #f5f5f5;
|
||||
color: #616161;
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.debug-detection-methods {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.debug-detection-method {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.debug-detection-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.debug-detection-icon-success {
|
||||
background-color: #4caf50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.debug-detection-icon-disabled {
|
||||
background-color: #bdbdbd;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.debug-parameters-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.debug-parameter-chip {
|
||||
padding: 2px 6px;
|
||||
background-color: #e3f2fd;
|
||||
color: #1976d2;
|
||||
border: 1px solid #bbdefb;
|
||||
border-radius: 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.debug-alert {
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
margin: 8px 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.debug-alert-info {
|
||||
background-color: #e3f2fd;
|
||||
color: #1976d2;
|
||||
border: 1px solid #bbdefb;
|
||||
}
|
||||
|
||||
.debug-alert-warning {
|
||||
background-color: #fff3e0;
|
||||
color: #ef6c00;
|
||||
border: 1px solid #ffcc02;
|
||||
}
|
||||
|
||||
.debug-alert-error {
|
||||
background-color: #ffebee;
|
||||
color: #c62828;
|
||||
border: 1px solid #ffcdd2;
|
||||
}
|
||||
|
||||
.debug-alert-success {
|
||||
background-color: #e8f5e8;
|
||||
color: #2e7d32;
|
||||
border: 1px solid #c8e6c9;
|
||||
}
|
||||
|
||||
.debug-auto-refresh {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.debug-auto-refresh input[type="checkbox"] {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes debugPanelSlideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes debugPanelSlideOut {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.debug-panel-enter {
|
||||
animation: debugPanelSlideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.debug-panel-exit {
|
||||
animation: debugPanelSlideOut 0.3s ease-in;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.debug-panel {
|
||||
width: calc(100vw - 40px);
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.debug-panel-toggle {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Scrollbar personnalisée */
|
||||
.debug-panel-content::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.debug-panel-content::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.debug-panel-content::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.debug-panel-content::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
|
||||
/* Mode sombre (optionnel) */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.debug-panel {
|
||||
background-color: #2d2d2d;
|
||||
border-color: #1976d2;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.debug-section-header {
|
||||
background-color: #3d3d3d;
|
||||
border-bottom-color: #4d4d4d;
|
||||
}
|
||||
|
||||
.debug-section-content {
|
||||
background-color: #2d2d2d;
|
||||
}
|
||||
|
||||
.debug-info-table th {
|
||||
background-color: #3d3d3d;
|
||||
}
|
||||
|
||||
.debug-info-table th,
|
||||
.debug-info-table td {
|
||||
border-bottom-color: #4d4d4d;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
/**
|
||||
* Composant DebugPanel - Visualisation des données d'étapes en temps réel
|
||||
* Auteur : Dom, Alice, Kiro - 12 janvier 2026
|
||||
*
|
||||
* Ce composant affiche des informations de débogage détaillées pour les étapes
|
||||
* sélectionnées, permettant de diagnostiquer les problèmes de propriétés.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
Chip,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
Alert,
|
||||
Switch,
|
||||
FormControlLabel,
|
||||
Divider,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
BugReport as BugReportIcon,
|
||||
Visibility as VisibilityIcon,
|
||||
Code as CodeIcon,
|
||||
Settings as SettingsIcon,
|
||||
Warning as WarningIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Error as ErrorIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
// Import des hooks d'intégration VWB
|
||||
import { useVWBStepIntegration, useIsVWBStep, useVWBActionId } from '../../hooks/useVWBStepIntegration';
|
||||
|
||||
// Import des types
|
||||
import { Step, StepType, Variable } from '../../types';
|
||||
|
||||
interface DebugPanelProps {
|
||||
selectedStep: Step | null;
|
||||
variables: Variable[];
|
||||
isVisible?: boolean;
|
||||
onToggleVisibility?: (visible: boolean) => void;
|
||||
}
|
||||
|
||||
interface StepAnalysis {
|
||||
stepInfo: {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
hasData: boolean;
|
||||
dataKeys: string[];
|
||||
};
|
||||
typeAnalysis: {
|
||||
isStandardType: boolean;
|
||||
isVWBAction: boolean;
|
||||
hasConfiguration: boolean;
|
||||
configurationSource: string;
|
||||
};
|
||||
vwbAnalysis: {
|
||||
isVWBCatalogAction: boolean;
|
||||
hasVWBActionId: boolean;
|
||||
vwbActionId: string | null;
|
||||
detectionMethods: Record<string, boolean>;
|
||||
};
|
||||
parametersAnalysis: {
|
||||
hasParameters: boolean;
|
||||
parameterCount: number;
|
||||
parameterKeys: string[];
|
||||
missingRequired: string[];
|
||||
};
|
||||
validationAnalysis: {
|
||||
hasErrors: boolean;
|
||||
errorCount: number;
|
||||
warningCount: number;
|
||||
errors: Array<{ parameter: string; message: string; severity: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Composant DebugPanel
|
||||
*/
|
||||
const DebugPanel: React.FC<DebugPanelProps> = ({
|
||||
selectedStep,
|
||||
variables,
|
||||
isVisible = false,
|
||||
onToggleVisibility,
|
||||
}) => {
|
||||
const [expanded, setExpanded] = useState<string[]>(['stepInfo']);
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
|
||||
// Utilisation des hooks d'intégration VWB
|
||||
const { methods: vwbMethods } = useVWBStepIntegration();
|
||||
const isVWBStep = useIsVWBStep(selectedStep);
|
||||
const vwbActionId = useVWBActionId(selectedStep);
|
||||
|
||||
// Configuration des paramètres par type d'étape (copie locale pour analyse)
|
||||
const stepParametersConfig: Record<string, any> = useMemo(() => ({
|
||||
click: [
|
||||
{ name: 'target', type: 'visual', required: true },
|
||||
{ name: 'clickType', type: 'select', defaultValue: 'left' },
|
||||
],
|
||||
type: [
|
||||
{ name: 'target', type: 'visual', required: true },
|
||||
{ name: 'text', type: 'text', required: true },
|
||||
{ name: 'clearFirst', type: 'boolean', defaultValue: true },
|
||||
],
|
||||
wait: [
|
||||
{ name: 'duration', type: 'number', required: true, min: 0.1, max: 60, defaultValue: 1 },
|
||||
],
|
||||
condition: [
|
||||
{ name: 'condition', type: 'text', required: true },
|
||||
],
|
||||
extract: [
|
||||
{ name: 'target', type: 'visual', required: true },
|
||||
{ name: 'attribute', type: 'select', defaultValue: 'text' },
|
||||
],
|
||||
scroll: [
|
||||
{ name: 'direction', type: 'select', defaultValue: 'down' },
|
||||
{ name: 'amount', type: 'number', defaultValue: 300, min: 1 },
|
||||
],
|
||||
navigate: [
|
||||
{ name: 'url', type: 'text', required: true },
|
||||
],
|
||||
screenshot: [
|
||||
{ name: 'filename', type: 'text' },
|
||||
],
|
||||
}), []);
|
||||
|
||||
// Analyse complète de l'étape sélectionnée
|
||||
const stepAnalysis: StepAnalysis | null = useMemo(() => {
|
||||
if (!selectedStep) return null;
|
||||
|
||||
// Analyse des informations de base
|
||||
const stepInfo = {
|
||||
id: selectedStep.id,
|
||||
name: selectedStep.name || 'Sans nom',
|
||||
type: selectedStep.type as string,
|
||||
hasData: Boolean(selectedStep.data),
|
||||
dataKeys: selectedStep.data ? Object.keys(selectedStep.data) : [],
|
||||
};
|
||||
|
||||
// Analyse du type d'étape
|
||||
const stepTypeString = selectedStep.type as string;
|
||||
const isStandardType = stepTypeString in stepParametersConfig;
|
||||
const hasConfiguration = isStandardType || Boolean(selectedStep.data?.isVWBCatalogAction);
|
||||
|
||||
// Méthodes de détection VWB
|
||||
const detectionMethods = {
|
||||
hasVWBFlag: Boolean(selectedStep.data?.isVWBCatalogAction),
|
||||
hasVWBActionId: Boolean(selectedStep.data?.vwbActionId),
|
||||
typeStartsWithVWB: stepTypeString.startsWith('vwb_'),
|
||||
typeContainsAnchor: stepTypeString.includes('_anchor'),
|
||||
typeContainsText: stepTypeString.includes('_text'),
|
||||
typeContainsSecret: stepTypeString.includes('_secret'),
|
||||
isKnownVWBAction: [
|
||||
'click_anchor', 'type_text', 'type_secret', 'wait_for_anchor',
|
||||
'extract_text', 'screenshot_evidence', 'scroll_to_anchor',
|
||||
'focus_anchor', 'hotkey', 'navigate_to_url', 'browser_back',
|
||||
'verify_element_exists', 'verify_text_content'
|
||||
].includes(stepTypeString),
|
||||
useIsVWBStepHook: isVWBStep,
|
||||
};
|
||||
|
||||
const isVWBAction = Object.values(detectionMethods).some(method => method);
|
||||
|
||||
const typeAnalysis = {
|
||||
isStandardType,
|
||||
isVWBAction,
|
||||
hasConfiguration,
|
||||
configurationSource: isVWBAction ? 'VWBActionProperties' : isStandardType ? 'stepParametersConfig' : 'Aucune',
|
||||
};
|
||||
|
||||
const vwbAnalysis = {
|
||||
isVWBCatalogAction: Boolean(selectedStep.data?.isVWBCatalogAction),
|
||||
hasVWBActionId: Boolean(selectedStep.data?.vwbActionId),
|
||||
vwbActionId: selectedStep.data?.vwbActionId || null,
|
||||
detectionMethods,
|
||||
};
|
||||
|
||||
// Analyse des paramètres
|
||||
const parameters = selectedStep.data?.parameters || {};
|
||||
const parameterKeys = Object.keys(parameters);
|
||||
const expectedConfig = stepParametersConfig[stepTypeString] || [];
|
||||
const requiredParams = expectedConfig.filter((p: any) => p.required).map((p: any) => p.name);
|
||||
const missingRequired = requiredParams.filter((param: string) => !(param in parameters));
|
||||
|
||||
const parametersAnalysis = {
|
||||
hasParameters: parameterKeys.length > 0,
|
||||
parameterCount: parameterKeys.length,
|
||||
parameterKeys,
|
||||
missingRequired,
|
||||
};
|
||||
|
||||
// Analyse de validation
|
||||
const validationErrors = selectedStep.validationErrors || [];
|
||||
const errors = validationErrors.map(error => ({
|
||||
parameter: error.parameter || 'Général',
|
||||
message: error.message,
|
||||
severity: error.severity || 'error',
|
||||
}));
|
||||
|
||||
const validationAnalysis = {
|
||||
hasErrors: validationErrors.length > 0,
|
||||
errorCount: errors.filter(e => e.severity === 'error').length,
|
||||
warningCount: errors.filter(e => e.severity === 'warning').length,
|
||||
errors,
|
||||
};
|
||||
|
||||
return {
|
||||
stepInfo,
|
||||
typeAnalysis,
|
||||
vwbAnalysis,
|
||||
parametersAnalysis,
|
||||
validationAnalysis,
|
||||
};
|
||||
}, [selectedStep, stepParametersConfig, isVWBStep]);
|
||||
|
||||
// Gestion de l'expansion des accordéons
|
||||
const handleAccordionChange = (panel: string) => (event: React.SyntheticEvent, isExpanded: boolean) => {
|
||||
setExpanded(prev =>
|
||||
isExpanded
|
||||
? [...prev, panel]
|
||||
: prev.filter(p => p !== panel)
|
||||
);
|
||||
};
|
||||
|
||||
// Auto-refresh des données
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
// Force un re-render pour mettre à jour les données en temps réel
|
||||
// (les données sont déjà réactives via les props)
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [autoRefresh]);
|
||||
|
||||
if (!isVisible) {
|
||||
return (
|
||||
<Box sx={{ position: 'fixed', top: 20, right: 20, zIndex: 9999 }}>
|
||||
<Tooltip title="Ouvrir le panneau de débogage">
|
||||
<IconButton
|
||||
onClick={() => onToggleVisibility?.(true)}
|
||||
sx={{
|
||||
backgroundColor: 'primary.main',
|
||||
color: 'white',
|
||||
'&:hover': { backgroundColor: 'primary.dark' },
|
||||
}}
|
||||
>
|
||||
<BugReportIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
top: 20,
|
||||
right: 20,
|
||||
width: 400,
|
||||
maxHeight: 'calc(100vh - 40px)',
|
||||
backgroundColor: 'background.paper',
|
||||
border: '2px solid',
|
||||
borderColor: 'primary.main',
|
||||
borderRadius: 2,
|
||||
boxShadow: 3,
|
||||
zIndex: 9999,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* En-tête */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
backgroundColor: 'primary.main',
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<BugReportIcon />
|
||||
<Typography variant="h6">Debug Panel</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={autoRefresh}
|
||||
onChange={(e) => setAutoRefresh(e.target.checked)}
|
||||
size="small"
|
||||
sx={{ color: 'white' }}
|
||||
/>
|
||||
}
|
||||
label="Auto"
|
||||
sx={{ color: 'white', m: 0 }}
|
||||
/>
|
||||
<IconButton
|
||||
onClick={() => onToggleVisibility?.(false)}
|
||||
sx={{ color: 'white' }}
|
||||
size="small"
|
||||
>
|
||||
<VisibilityIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Contenu */}
|
||||
<Box sx={{ flex: 1, overflow: 'auto', p: 1 }}>
|
||||
{!selectedStep ? (
|
||||
<Alert severity="info" sx={{ m: 1 }}>
|
||||
Aucune étape sélectionnée
|
||||
</Alert>
|
||||
) : !stepAnalysis ? (
|
||||
<Alert severity="error" sx={{ m: 1 }}>
|
||||
Erreur d'analyse de l'étape
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
{/* Informations de base */}
|
||||
<Accordion
|
||||
expanded={expanded.includes('stepInfo')}
|
||||
onChange={handleAccordionChange('stepInfo')}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<SettingsIcon fontSize="small" />
|
||||
<Typography variant="subtitle2">Informations de base</Typography>
|
||||
<Chip
|
||||
label={stepAnalysis.stepInfo.type}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell><strong>ID</strong></TableCell>
|
||||
<TableCell>{stepAnalysis.stepInfo.id}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell><strong>Nom</strong></TableCell>
|
||||
<TableCell>{stepAnalysis.stepInfo.name}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell><strong>Type</strong></TableCell>
|
||||
<TableCell>{stepAnalysis.stepInfo.type}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell><strong>Données</strong></TableCell>
|
||||
<TableCell>
|
||||
{stepAnalysis.stepInfo.hasData ? (
|
||||
<Chip label={`${stepAnalysis.stepInfo.dataKeys.length} clés`} size="small" color="success" />
|
||||
) : (
|
||||
<Chip label="Aucune" size="small" color="default" />
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
{/* Analyse du type */}
|
||||
<Accordion
|
||||
expanded={expanded.includes('typeAnalysis')}
|
||||
onChange={handleAccordionChange('typeAnalysis')}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CodeIcon fontSize="small" />
|
||||
<Typography variant="subtitle2">Analyse du type</Typography>
|
||||
{stepAnalysis.typeAnalysis.hasConfiguration ? (
|
||||
<CheckCircleIcon fontSize="small" color="success" />
|
||||
) : (
|
||||
<ErrorIcon fontSize="small" color="error" />
|
||||
)}
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Chip
|
||||
label={stepAnalysis.typeAnalysis.isStandardType ? 'Type Standard' : 'Type Non-Standard'}
|
||||
size="small"
|
||||
color={stepAnalysis.typeAnalysis.isStandardType ? 'success' : 'default'}
|
||||
/>
|
||||
<Chip
|
||||
label={stepAnalysis.typeAnalysis.isVWBAction ? 'Action VWB' : 'Non-VWB'}
|
||||
size="small"
|
||||
color={stepAnalysis.typeAnalysis.isVWBAction ? 'primary' : 'default'}
|
||||
/>
|
||||
</Box>
|
||||
<Typography variant="body2">
|
||||
<strong>Source de configuration :</strong> {stepAnalysis.typeAnalysis.configurationSource}
|
||||
</Typography>
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
{/* Analyse VWB */}
|
||||
<Accordion
|
||||
expanded={expanded.includes('vwbAnalysis')}
|
||||
onChange={handleAccordionChange('vwbAnalysis')}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<BugReportIcon fontSize="small" />
|
||||
<Typography variant="subtitle2">Analyse VWB</Typography>
|
||||
<Chip
|
||||
label={`${Object.values(stepAnalysis.vwbAnalysis.detectionMethods).filter(Boolean).length}/8`}
|
||||
size="small"
|
||||
color="info"
|
||||
/>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{stepAnalysis.vwbAnalysis.vwbActionId && (
|
||||
<Alert severity="info" sx={{ fontSize: '0.8rem' }}>
|
||||
<strong>Action ID :</strong> {stepAnalysis.vwbAnalysis.vwbActionId}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>
|
||||
Méthodes de détection :
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
{Object.entries(stepAnalysis.vwbAnalysis.detectionMethods).map(([method, detected]) => (
|
||||
<Box key={method} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{detected ? (
|
||||
<CheckCircleIcon fontSize="small" color="success" />
|
||||
) : (
|
||||
<ErrorIcon fontSize="small" color="disabled" />
|
||||
)}
|
||||
<Typography variant="body2" sx={{ fontSize: '0.8rem' }}>
|
||||
{method}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
{/* Analyse des paramètres */}
|
||||
<Accordion
|
||||
expanded={expanded.includes('parametersAnalysis')}
|
||||
onChange={handleAccordionChange('parametersAnalysis')}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<SettingsIcon fontSize="small" />
|
||||
<Typography variant="subtitle2">Paramètres</Typography>
|
||||
<Chip
|
||||
label={stepAnalysis.parametersAnalysis.parameterCount}
|
||||
size="small"
|
||||
color={stepAnalysis.parametersAnalysis.hasParameters ? 'success' : 'default'}
|
||||
/>
|
||||
{stepAnalysis.parametersAnalysis.missingRequired.length > 0 && (
|
||||
<WarningIcon fontSize="small" color="warning" />
|
||||
)}
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{stepAnalysis.parametersAnalysis.hasParameters ? (
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
{stepAnalysis.parametersAnalysis.parameterKeys.map(key => (
|
||||
<Chip key={key} label={key} size="small" variant="outlined" />
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Aucun paramètre configuré
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{stepAnalysis.parametersAnalysis.missingRequired.length > 0 && (
|
||||
<Alert severity="warning" sx={{ fontSize: '0.8rem' }}>
|
||||
<strong>Paramètres requis manquants :</strong>{' '}
|
||||
{stepAnalysis.parametersAnalysis.missingRequired.join(', ')}
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
{/* Analyse de validation */}
|
||||
{stepAnalysis.validationAnalysis.hasErrors && (
|
||||
<Accordion
|
||||
expanded={expanded.includes('validationAnalysis')}
|
||||
onChange={handleAccordionChange('validationAnalysis')}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<WarningIcon fontSize="small" />
|
||||
<Typography variant="subtitle2">Validation</Typography>
|
||||
<Chip
|
||||
label={`${stepAnalysis.validationAnalysis.errorCount} erreurs`}
|
||||
size="small"
|
||||
color="error"
|
||||
/>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{stepAnalysis.validationAnalysis.errors.map((error, index) => (
|
||||
<Alert key={index} severity={error.severity as any} sx={{ fontSize: '0.8rem' }}>
|
||||
<strong>{error.parameter} :</strong> {error.message}
|
||||
</Alert>
|
||||
))}
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default DebugPanel;
|
||||
@@ -0,0 +1,592 @@
|
||||
/**
|
||||
* Composant Onglet de Documentation - Documentation interactive pour chaque outil
|
||||
* Auteur : Dom, Alice, Kiro - 08 janvier 2026
|
||||
*
|
||||
* Ce composant affiche la documentation contextuelle avec guides étape par étape,
|
||||
* exemples visuels et exemples interactifs en français.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Tabs,
|
||||
Tab,
|
||||
Card,
|
||||
CardContent,
|
||||
Button,
|
||||
Stepper,
|
||||
Step,
|
||||
StepLabel,
|
||||
StepContent,
|
||||
Alert,
|
||||
Chip,
|
||||
Divider,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Help as HelpIcon,
|
||||
PlayArrow as PlayIcon,
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
Lightbulb as TipIcon,
|
||||
Warning as WarningIcon,
|
||||
CheckCircle as CheckIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
// Import du composant Glossaire
|
||||
import Glossary from '../Glossary';
|
||||
|
||||
// Import des types partagés
|
||||
import { DocumentationTabProps } from '../../types';
|
||||
|
||||
interface DocumentationContent {
|
||||
title: string;
|
||||
sections: DocumentationSection[];
|
||||
examples: InteractiveExample[];
|
||||
glossary: GlossaryTerm[];
|
||||
}
|
||||
|
||||
interface DocumentationSection {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
steps?: DocumentationStep[];
|
||||
tips?: string[];
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
interface DocumentationStep {
|
||||
title: string;
|
||||
description: string;
|
||||
image?: string;
|
||||
action?: () => void;
|
||||
}
|
||||
|
||||
interface InteractiveExample {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
steps: string[];
|
||||
onTry: () => void;
|
||||
}
|
||||
|
||||
interface GlossaryTerm {
|
||||
term: string;
|
||||
definition: string;
|
||||
}
|
||||
|
||||
// Contenu de documentation par outil
|
||||
const documentationContent: Record<string, DocumentationContent> = {
|
||||
canvas: {
|
||||
title: 'Canvas - Espace de travail',
|
||||
sections: [
|
||||
{
|
||||
id: 'overview',
|
||||
title: 'Vue d\'ensemble',
|
||||
content: 'Le Canvas est votre espace de travail principal pour créer des workflows visuels. Vous pouvez y glisser des étapes depuis la palette, les connecter et les organiser.',
|
||||
steps: [
|
||||
{
|
||||
title: 'Glisser une étape',
|
||||
description: 'Glissez une étape depuis la palette vers le canvas',
|
||||
action: () => {
|
||||
// Déclencher un tutoriel interactif pour le drag-and-drop
|
||||
window.dispatchEvent(new CustomEvent('vwb:tutorial:dragDrop'));
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Connecter les étapes',
|
||||
description: 'Cliquez et glissez depuis le point de connexion d\'une étape vers une autre',
|
||||
action: () => {
|
||||
// Déclencher un tutoriel interactif pour les connexions
|
||||
window.dispatchEvent(new CustomEvent('vwb:tutorial:connect'));
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Sélectionner une étape',
|
||||
description: 'Cliquez sur une étape pour la sélectionner et voir ses propriétés',
|
||||
action: () => {
|
||||
// Déclencher un tutoriel interactif pour la sélection
|
||||
window.dispatchEvent(new CustomEvent('vwb:tutorial:select'));
|
||||
},
|
||||
},
|
||||
],
|
||||
tips: [
|
||||
'Utilisez la grille pour aligner vos étapes proprement',
|
||||
'La minimap apparaît automatiquement pour les gros workflows',
|
||||
'Utilisez Ctrl+Z pour annuler une action',
|
||||
'Double-cliquez sur une étape pour l\'éditer rapidement',
|
||||
'Utilisez Shift+clic pour sélectionner plusieurs étapes',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'navigation',
|
||||
title: 'Navigation',
|
||||
content: 'Le canvas offre plusieurs outils de navigation pour travailler efficacement avec vos workflows.',
|
||||
steps: [
|
||||
{
|
||||
title: 'Zoom',
|
||||
description: 'Utilisez la molette de la souris ou les contrôles pour zoomer',
|
||||
action: () => {
|
||||
// Déclencher un tutoriel interactif pour le zoom
|
||||
window.dispatchEvent(new CustomEvent('vwb:tutorial:zoom'));
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Panoramique',
|
||||
description: 'Maintenez le clic droit et glissez pour déplacer la vue',
|
||||
action: () => {
|
||||
// Déclencher un tutoriel interactif pour le panoramique
|
||||
window.dispatchEvent(new CustomEvent('vwb:tutorial:pan'));
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Ajustement automatique',
|
||||
description: 'Cliquez sur le bouton "Ajuster" pour voir tout le workflow',
|
||||
action: () => {
|
||||
// Déclencher un tutoriel interactif pour l'ajustement
|
||||
window.dispatchEvent(new CustomEvent('vwb:tutorial:fitView'));
|
||||
},
|
||||
},
|
||||
],
|
||||
tips: [
|
||||
'Utilisez la molette pour zoomer rapidement',
|
||||
'La minimap permet de naviguer dans les gros workflows',
|
||||
'Les raccourcis clavier accélèrent la navigation',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'shortcuts',
|
||||
title: 'Raccourcis clavier',
|
||||
content: 'Maîtrisez les raccourcis clavier pour une utilisation plus efficace du canvas.',
|
||||
steps: [
|
||||
{
|
||||
title: 'Ctrl+Z / Ctrl+Y',
|
||||
description: 'Annuler / Refaire les dernières actions',
|
||||
},
|
||||
{
|
||||
title: 'Suppr',
|
||||
description: 'Supprimer les éléments sélectionnés',
|
||||
},
|
||||
{
|
||||
title: 'Ctrl+A',
|
||||
description: 'Sélectionner tous les éléments',
|
||||
},
|
||||
{
|
||||
title: 'Ctrl+C / Ctrl+V',
|
||||
description: 'Copier / Coller les éléments sélectionnés',
|
||||
},
|
||||
{
|
||||
title: 'Espace + glisser',
|
||||
description: 'Mode panoramique temporaire',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
examples: [
|
||||
{
|
||||
id: 'first-workflow',
|
||||
title: 'Créer votre premier workflow',
|
||||
description: 'Apprenez à créer un workflow simple avec quelques étapes',
|
||||
steps: [
|
||||
'Glissez une étape "Cliquer" depuis la palette vers le canvas',
|
||||
'Ajoutez une étape "Saisir du texte" en dessous',
|
||||
'Connectez les deux étapes en glissant depuis le point de sortie vers l\'entrée',
|
||||
'Configurez les paramètres dans le panneau de propriétés',
|
||||
'Testez votre workflow avec le bouton d\'exécution',
|
||||
],
|
||||
onTry: () => {
|
||||
// Déclencher un tutoriel interactif complet
|
||||
window.dispatchEvent(new CustomEvent('vwb:tutorial:firstWorkflow'));
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'complex-workflow',
|
||||
title: 'Workflow avec conditions',
|
||||
description: 'Créez un workflow plus complexe avec des conditions et des boucles',
|
||||
steps: [
|
||||
'Créez une étape de démarrage',
|
||||
'Ajoutez une condition "Si/Alors"',
|
||||
'Connectez les branches "Vrai" et "Faux"',
|
||||
'Ajoutez des actions différentes pour chaque branche',
|
||||
'Testez les différents chemins d\'exécution',
|
||||
],
|
||||
onTry: () => {
|
||||
// Déclencher un tutoriel pour les workflows complexes
|
||||
window.dispatchEvent(new CustomEvent('vwb:tutorial:complexWorkflow'));
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'debugging-workflow',
|
||||
title: 'Déboguer un workflow',
|
||||
description: 'Apprenez à identifier et corriger les erreurs dans vos workflows',
|
||||
steps: [
|
||||
'Identifiez les étapes avec des erreurs (indicateurs rouges)',
|
||||
'Vérifiez les paramètres manquants dans le panneau de propriétés',
|
||||
'Corrigez les étapes déconnectées (surlignées en orange)',
|
||||
'Utilisez l\'exécution pas à pas pour tester',
|
||||
'Consultez les logs d\'erreur détaillés',
|
||||
],
|
||||
onTry: () => {
|
||||
// Déclencher un tutoriel de débogage
|
||||
window.dispatchEvent(new CustomEvent('vwb:tutorial:debugging'));
|
||||
},
|
||||
},
|
||||
],
|
||||
glossary: [
|
||||
{ term: 'Étape', definition: 'Un élément d\'action dans votre workflow' },
|
||||
{ term: 'Connexion', definition: 'Un lien entre deux étapes définissant l\'ordre d\'exécution' },
|
||||
{ term: 'Minimap', definition: 'Une vue miniature du workflow pour la navigation' },
|
||||
{ term: 'Point de connexion', definition: 'Zone cliquable sur une étape pour créer des liens' },
|
||||
{ term: 'Grille d\'alignement', definition: 'Guide visuel pour positionner les étapes proprement' },
|
||||
],
|
||||
},
|
||||
palette: {
|
||||
title: 'Palette - Boîte à outils',
|
||||
sections: [
|
||||
{
|
||||
id: 'categories',
|
||||
title: 'Catégories d\'étapes',
|
||||
content: 'La palette organise les étapes en catégories pour faciliter la recherche.',
|
||||
steps: [
|
||||
{
|
||||
title: 'Actions Web',
|
||||
description: 'Étapes pour interagir avec les pages web (cliquer, saisir, etc.)',
|
||||
},
|
||||
{
|
||||
title: 'Logique',
|
||||
description: 'Étapes de contrôle de flux (conditions, boucles)',
|
||||
},
|
||||
{
|
||||
title: 'Données',
|
||||
description: 'Étapes pour manipuler les données (extraire, transformer)',
|
||||
},
|
||||
{
|
||||
title: 'Contrôle',
|
||||
description: 'Étapes de contrôle d\'exécution (attendre, arrêter)',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'search',
|
||||
title: 'Recherche d\'étapes',
|
||||
content: 'Utilisez la barre de recherche pour trouver rapidement une étape spécifique.',
|
||||
tips: [
|
||||
'Tapez le nom de l\'action que vous voulez effectuer',
|
||||
'La recherche fonctionne sur les noms et descriptions',
|
||||
'Les résultats sont filtrés en temps réel',
|
||||
],
|
||||
},
|
||||
],
|
||||
examples: [
|
||||
{
|
||||
id: 'find-step',
|
||||
title: 'Trouver une étape rapidement',
|
||||
description: 'Utilisez la recherche pour trouver l\'étape dont vous avez besoin',
|
||||
steps: [
|
||||
'Cliquez dans la barre de recherche',
|
||||
'Tapez "clic" pour trouver les étapes de clic',
|
||||
'Glissez l\'étape trouvée sur le canvas',
|
||||
],
|
||||
onTry: () => console.log('Exemple interactif: Recherche d\'étape'),
|
||||
},
|
||||
],
|
||||
glossary: [
|
||||
{ term: 'Catégorie', definition: 'Un groupe d\'étapes similaires' },
|
||||
{ term: 'Tooltip', definition: 'Une infobulle explicative qui apparaît au survol' },
|
||||
],
|
||||
},
|
||||
properties: {
|
||||
title: 'Propriétés - Configuration des étapes',
|
||||
sections: [
|
||||
{
|
||||
id: 'parameter-types',
|
||||
title: 'Types de paramètres',
|
||||
content: 'Chaque étape peut avoir différents types de paramètres à configurer.',
|
||||
steps: [
|
||||
{
|
||||
title: 'Texte',
|
||||
description: 'Champs de saisie libre pour du texte',
|
||||
},
|
||||
{
|
||||
title: 'Nombre',
|
||||
description: 'Champs numériques avec validation',
|
||||
},
|
||||
{
|
||||
title: 'Booléen',
|
||||
description: 'Interrupteurs pour les options vrai/faux',
|
||||
},
|
||||
{
|
||||
title: 'Sélection',
|
||||
description: 'Listes déroulantes avec options prédéfinies',
|
||||
},
|
||||
{
|
||||
title: 'Sélection visuelle',
|
||||
description: 'Boutons pour sélectionner des éléments à l\'écran',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'validation',
|
||||
title: 'Validation en temps réel',
|
||||
content: 'Les paramètres sont validés automatiquement pendant la saisie.',
|
||||
warnings: [
|
||||
'Les paramètres obligatoires doivent être remplis',
|
||||
'Les valeurs numériques doivent respecter les limites',
|
||||
'Les sélections visuelles doivent être effectuées',
|
||||
],
|
||||
},
|
||||
],
|
||||
examples: [
|
||||
{
|
||||
id: 'configure-click',
|
||||
title: 'Configurer une étape de clic',
|
||||
description: 'Apprenez à configurer les paramètres d\'une étape de clic',
|
||||
steps: [
|
||||
'Sélectionnez une étape de clic sur le canvas',
|
||||
'Cliquez sur "Sélectionner un élément" dans les propriétés',
|
||||
'Choisissez le type de clic dans la liste déroulante',
|
||||
],
|
||||
onTry: () => console.log('Exemple interactif: Configuration de clic'),
|
||||
},
|
||||
],
|
||||
glossary: [
|
||||
{ term: 'Paramètre', definition: 'Une valeur configurable d\'une étape' },
|
||||
{ term: 'Validation', definition: 'Vérification automatique de la validité des valeurs' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Composant Onglet de Documentation
|
||||
*/
|
||||
const DocumentationTab: React.FC<DocumentationTabProps> = ({
|
||||
toolName,
|
||||
isActive,
|
||||
onActivate,
|
||||
}) => {
|
||||
const [activeSection, setActiveSection] = useState(0);
|
||||
const [activeStep, setActiveStep] = useState(0);
|
||||
|
||||
const content = documentationContent[toolName];
|
||||
|
||||
// Gestionnaire d'événements clavier pour la documentation
|
||||
const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
|
||||
switch (event.key) {
|
||||
case 'ArrowUp':
|
||||
// Navigation vers la section précédente
|
||||
event.preventDefault();
|
||||
setActiveSection(prev => Math.max(0, prev - 1));
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
// Navigation vers la section suivante
|
||||
event.preventDefault();
|
||||
const maxSections = content?.sections.length || 0;
|
||||
setActiveSection(prev => Math.min(maxSections - 1, prev + 1));
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
// Navigation vers l'étape précédente
|
||||
event.preventDefault();
|
||||
setActiveStep(prev => Math.max(0, prev - 1));
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
// Navigation vers l'étape suivante
|
||||
event.preventDefault();
|
||||
const maxSteps = content?.sections[activeSection]?.steps?.length || 0;
|
||||
setActiveStep(prev => Math.min(maxSteps - 1, prev + 1));
|
||||
break;
|
||||
case 'Home':
|
||||
// Aller au début
|
||||
event.preventDefault();
|
||||
setActiveSection(0);
|
||||
setActiveStep(0);
|
||||
break;
|
||||
case 'End':
|
||||
// Aller à la fin
|
||||
event.preventDefault();
|
||||
const lastSection = (content?.sections.length || 1) - 1;
|
||||
setActiveSection(lastSection);
|
||||
break;
|
||||
}
|
||||
}, [activeSection, content]);
|
||||
|
||||
if (!content) {
|
||||
return (
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Alert severity="info">
|
||||
Documentation non disponible pour cet outil.
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}
|
||||
role="tabpanel"
|
||||
aria-label={`Documentation pour ${content.title}`}
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={0}
|
||||
>
|
||||
{/* En-tête */}
|
||||
<Box sx={{ p: 2, borderBottom: '1px solid #e0e0e0' }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
<HelpIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
{content.title}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Contenu avec onglets */}
|
||||
<Box sx={{ flex: 1, overflow: 'hidden' }}>
|
||||
<Tabs
|
||||
value={activeSection}
|
||||
onChange={(_, newValue) => setActiveSection(newValue)}
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
>
|
||||
<Tab label="Guide" />
|
||||
<Tab label="Exemples" />
|
||||
<Tab label="Glossaire" />
|
||||
</Tabs>
|
||||
|
||||
{/* Panneau Guide */}
|
||||
{activeSection === 0 && (
|
||||
<Box sx={{ p: 2, height: 'calc(100% - 48px)', overflow: 'auto' }}>
|
||||
{content.sections.map((section, index) => (
|
||||
<Accordion key={section.id} defaultExpanded={index === 0}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="h6">{section.title}</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="body1" paragraph>
|
||||
{section.content}
|
||||
</Typography>
|
||||
|
||||
{/* Étapes */}
|
||||
{section.steps && (
|
||||
<Stepper orientation="vertical" activeStep={activeStep}>
|
||||
{section.steps.map((step, stepIndex) => (
|
||||
<Step key={stepIndex}>
|
||||
<StepLabel>{step.title}</StepLabel>
|
||||
<StepContent>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{step.description}
|
||||
</Typography>
|
||||
{step.action && (
|
||||
<Button
|
||||
size="small"
|
||||
onClick={step.action}
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
Essayer
|
||||
</Button>
|
||||
)}
|
||||
</StepContent>
|
||||
</Step>
|
||||
))}
|
||||
</Stepper>
|
||||
)}
|
||||
|
||||
{/* Conseils */}
|
||||
{section.tips && section.tips.length > 0 && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
<TipIcon sx={{ mr: 1, verticalAlign: 'middle', fontSize: 'small' }} />
|
||||
Conseils
|
||||
</Typography>
|
||||
<List dense>
|
||||
{section.tips.map((tip, tipIndex) => (
|
||||
<ListItem key={tipIndex}>
|
||||
<ListItemIcon>
|
||||
<CheckIcon color="success" fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={tip} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Avertissements */}
|
||||
{section.warnings && section.warnings.length > 0 && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Alert severity="warning" icon={<WarningIcon />}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Points d'attention
|
||||
</Typography>
|
||||
<List dense>
|
||||
{section.warnings.map((warning, warningIndex) => (
|
||||
<ListItem key={warningIndex} sx={{ py: 0 }}>
|
||||
<ListItemText primary={warning} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Alert>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Panneau Exemples */}
|
||||
{activeSection === 1 && (
|
||||
<Box sx={{ p: 2, height: 'calc(100% - 48px)', overflow: 'auto' }}>
|
||||
{content.examples.map((example) => (
|
||||
<Card key={example.id} sx={{ mb: 2 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{example.title}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" paragraph>
|
||||
{example.description}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Étapes à suivre :
|
||||
</Typography>
|
||||
<List dense>
|
||||
{example.steps.map((step, stepIndex) => (
|
||||
<ListItem key={stepIndex}>
|
||||
<ListItemIcon>
|
||||
<Chip label={stepIndex + 1} size="small" color="primary" />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={step} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<PlayIcon />}
|
||||
onClick={example.onTry}
|
||||
sx={{ mt: 2 }}
|
||||
>
|
||||
Essayer cet exemple
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Panneau Glossaire */}
|
||||
{activeSection === 2 && (
|
||||
<Box sx={{ height: 'calc(100% - 48px)', overflow: 'hidden' }}>
|
||||
<Glossary />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentationTab;
|
||||
@@ -0,0 +1,396 @@
|
||||
/**
|
||||
* Composant EvidenceDetail - Affichage détaillé d'une Evidence
|
||||
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
IconButton,
|
||||
Paper,
|
||||
Chip,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
Alert,
|
||||
Button,
|
||||
Tooltip,
|
||||
Divider
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Close as CloseIcon,
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
ZoomIn as ZoomInIcon,
|
||||
ZoomOut as ZoomOutIcon,
|
||||
Fullscreen as FullscreenIcon,
|
||||
Download as DownloadIcon,
|
||||
CheckCircle as SuccessIcon,
|
||||
Error as ErrorIcon,
|
||||
Schedule as TimeIcon,
|
||||
Visibility as ConfidenceIcon
|
||||
} from '@mui/icons-material';
|
||||
|
||||
import { VWBEvidence, EvidenceUtils } from '../../types/evidence';
|
||||
import ScreenshotViewer from './ScreenshotViewer';
|
||||
|
||||
interface EvidenceDetailProps {
|
||||
evidence: VWBEvidence;
|
||||
onClose?: () => void;
|
||||
showMetadata?: boolean;
|
||||
}
|
||||
|
||||
const EvidenceDetail: React.FC<EvidenceDetailProps> = ({
|
||||
evidence,
|
||||
onClose,
|
||||
showMetadata = true
|
||||
}) => {
|
||||
// État local
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [expandedPanels, setExpandedPanels] = useState<string[]>(['screenshot']);
|
||||
const screenshotRef = useRef<HTMLImageElement>(null);
|
||||
|
||||
// Gestion des panneaux accordéon
|
||||
const handlePanelChange = (panel: string) => (event: React.SyntheticEvent, isExpanded: boolean) => {
|
||||
setExpandedPanels(prev =>
|
||||
isExpanded
|
||||
? [...prev, panel]
|
||||
: prev.filter(p => p !== panel)
|
||||
);
|
||||
};
|
||||
|
||||
// Gestion du zoom
|
||||
const handleZoomIn = useCallback(() => {
|
||||
setZoom(prev => Math.min(prev * 1.2, 5));
|
||||
}, []);
|
||||
|
||||
const handleZoomOut = useCallback(() => {
|
||||
setZoom(prev => Math.max(prev / 1.2, 0.1));
|
||||
}, []);
|
||||
|
||||
const handleZoomReset = useCallback(() => {
|
||||
setZoom(1);
|
||||
}, []);
|
||||
|
||||
// Téléchargement du screenshot
|
||||
const handleDownloadScreenshot = useCallback(() => {
|
||||
if (!evidence.screenshot_base64) return;
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = `data:image/png;base64,${evidence.screenshot_base64}`;
|
||||
link.download = `evidence_${evidence.id}_${evidence.captured_at.replace(/[:.]/g, '-')}.png`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}, [evidence]);
|
||||
|
||||
// Plein écran
|
||||
const handleFullscreen = useCallback(() => {
|
||||
if (screenshotRef.current) {
|
||||
if (screenshotRef.current.requestFullscreen) {
|
||||
screenshotRef.current.requestFullscreen();
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Rendu des métadonnées
|
||||
const renderMetadata = () => {
|
||||
if (!showMetadata) return null;
|
||||
|
||||
const metadata = evidence.metadata || {};
|
||||
const metadataEntries = Object.entries(metadata);
|
||||
|
||||
if (metadataEntries.length === 0) {
|
||||
return (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Aucune métadonnée disponible
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className="evidence-metadata-grid">
|
||||
{metadataEntries.map(([key, value]) => (
|
||||
<Box key={key} className="evidence-metadata-item">
|
||||
<Typography variant="caption" className="evidence-metadata-item-label">
|
||||
{key}
|
||||
</Typography>
|
||||
<Typography variant="body2" className="evidence-metadata-item-value">
|
||||
{typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value)}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="evidence-detail" sx={{ height: '100%', overflow: 'auto' }}>
|
||||
{/* En-tête */}
|
||||
<Box className="evidence-detail-header">
|
||||
<Box>
|
||||
<Typography variant="h6" className="evidence-detail-title">
|
||||
{evidence.action_name || evidence.action_id}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#94a3b8' }}>
|
||||
ID: {evidence.id}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{onClose && (
|
||||
<IconButton
|
||||
onClick={onClose}
|
||||
className="evidence-detail-close"
|
||||
sx={{ color: '#94a3b8' }}
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Informations principales */}
|
||||
<Box mb={2}>
|
||||
<Box display="flex" alignItems="center" gap={2} mb={2}>
|
||||
<Chip
|
||||
icon={evidence.success ? <SuccessIcon /> : <ErrorIcon />}
|
||||
label={evidence.success ? 'SUCCÈS' : 'ERREUR'}
|
||||
color={evidence.success ? 'success' : 'error'}
|
||||
variant="filled"
|
||||
/>
|
||||
|
||||
<Chip
|
||||
icon={<TimeIcon />}
|
||||
label={EvidenceUtils.formatExecutionTime(evidence.execution_time_ms)}
|
||||
variant="outlined"
|
||||
sx={{ color: '#94a3b8', borderColor: '#475569' }}
|
||||
/>
|
||||
|
||||
{evidence.confidence_score && (
|
||||
<Chip
|
||||
icon={<ConfidenceIcon />}
|
||||
label={EvidenceUtils.formatConfidence(evidence.confidence_score)}
|
||||
variant="outlined"
|
||||
sx={{ color: '#94a3b8', borderColor: '#475569' }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" sx={{ color: '#94a3b8' }}>
|
||||
<strong>Capturé le :</strong> {EvidenceUtils.formatDate(evidence.captured_at)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Message d'erreur */}
|
||||
{!evidence.success && evidence.error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
{evidence.error.message}
|
||||
</Typography>
|
||||
{evidence.error.details && Object.keys(evidence.error.details).length > 0 && (
|
||||
<Box mt={1}>
|
||||
<Typography variant="caption" display="block">
|
||||
Détails techniques :
|
||||
</Typography>
|
||||
<pre style={{ fontSize: '11px', margin: '4px 0', whiteSpace: 'pre-wrap' }}>
|
||||
{JSON.stringify(evidence.error.details, null, 2)}
|
||||
</pre>
|
||||
</Box>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Screenshot */}
|
||||
{evidence.screenshot_base64 && (
|
||||
<Accordion
|
||||
expanded={expandedPanels.includes('screenshot')}
|
||||
onChange={handlePanelChange('screenshot')}
|
||||
sx={{ mb: 2, backgroundColor: '#0f172a', color: '#e2e8f0' }}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon sx={{ color: '#94a3b8' }} />}>
|
||||
<Typography variant="subtitle2">
|
||||
Screenshot d'Evidence
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
{/* Contrôles du screenshot */}
|
||||
<Box display="flex" gap={1} mb={2} flexWrap="wrap">
|
||||
<Tooltip title="Zoom avant">
|
||||
<IconButton size="small" onClick={handleZoomIn} sx={{ color: '#94a3b8' }}>
|
||||
<ZoomInIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Zoom arrière">
|
||||
<IconButton size="small" onClick={handleZoomOut} sx={{ color: '#94a3b8' }}>
|
||||
<ZoomOutIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Taille réelle">
|
||||
<Button size="small" onClick={handleZoomReset} sx={{ color: '#94a3b8' }}>
|
||||
{Math.round(zoom * 100)}%
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Plein écran">
|
||||
<IconButton size="small" onClick={handleFullscreen} sx={{ color: '#94a3b8' }}>
|
||||
<FullscreenIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Télécharger">
|
||||
<IconButton size="small" onClick={handleDownloadScreenshot} sx={{ color: '#94a3b8' }}>
|
||||
<DownloadIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{/* Visualiseur de screenshot */}
|
||||
<ScreenshotViewer
|
||||
screenshot={evidence.screenshot_base64}
|
||||
bbox={evidence.bbox}
|
||||
clickPoint={evidence.click_point}
|
||||
zoom={zoom}
|
||||
onZoomChange={setZoom}
|
||||
maxWidth={600}
|
||||
maxHeight={400}
|
||||
/>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
)}
|
||||
|
||||
{/* Détails techniques */}
|
||||
<Accordion
|
||||
expanded={expandedPanels.includes('technical')}
|
||||
onChange={handlePanelChange('technical')}
|
||||
sx={{ mb: 2, backgroundColor: '#0f172a', color: '#e2e8f0' }}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon sx={{ color: '#94a3b8' }} />}>
|
||||
<Typography variant="subtitle2">
|
||||
Détails Techniques
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box className="evidence-metadata-grid">
|
||||
<Box className="evidence-metadata-item">
|
||||
<Typography variant="caption" className="evidence-metadata-item-label">
|
||||
Contrat
|
||||
</Typography>
|
||||
<Typography variant="body2" className="evidence-metadata-item-value">
|
||||
{evidence.contract} v{evidence.version}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box className="evidence-metadata-item">
|
||||
<Typography variant="caption" className="evidence-metadata-item-label">
|
||||
Action ID
|
||||
</Typography>
|
||||
<Typography variant="body2" className="evidence-metadata-item-value">
|
||||
{evidence.action_id}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box className="evidence-metadata-item">
|
||||
<Typography variant="caption" className="evidence-metadata-item-label">
|
||||
Temps d'exécution
|
||||
</Typography>
|
||||
<Typography variant="body2" className="evidence-metadata-item-value">
|
||||
{evidence.execution_time_ms}ms
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{evidence.confidence_score && (
|
||||
<Box className="evidence-metadata-item">
|
||||
<Typography variant="caption" className="evidence-metadata-item-label">
|
||||
Score de confiance
|
||||
</Typography>
|
||||
<Typography variant="body2" className="evidence-metadata-item-value">
|
||||
{evidence.confidence_score.toFixed(3)}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{evidence.bbox && (
|
||||
<Box className="evidence-metadata-item">
|
||||
<Typography variant="caption" className="evidence-metadata-item-label">
|
||||
Zone détectée
|
||||
</Typography>
|
||||
<Typography variant="body2" className="evidence-metadata-item-value">
|
||||
{evidence.bbox.x}, {evidence.bbox.y} ({evidence.bbox.width}×{evidence.bbox.height})
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{evidence.click_point && (
|
||||
<Box className="evidence-metadata-item">
|
||||
<Typography variant="caption" className="evidence-metadata-item-label">
|
||||
Point de clic
|
||||
</Typography>
|
||||
<Typography variant="body2" className="evidence-metadata-item-value">
|
||||
{evidence.click_point.x}, {evidence.click_point.y}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
{/* Métadonnées personnalisées */}
|
||||
{showMetadata && evidence.metadata && Object.keys(evidence.metadata).length > 0 && (
|
||||
<Accordion
|
||||
expanded={expandedPanels.includes('metadata')}
|
||||
onChange={handlePanelChange('metadata')}
|
||||
sx={{ mb: 2, backgroundColor: '#0f172a', color: '#e2e8f0' }}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon sx={{ color: '#94a3b8' }} />}>
|
||||
<Typography variant="subtitle2">
|
||||
Métadonnées Personnalisées
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
{renderMetadata()}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
)}
|
||||
|
||||
{/* Données brutes (pour debug) */}
|
||||
<Accordion
|
||||
expanded={expandedPanels.includes('raw')}
|
||||
onChange={handlePanelChange('raw')}
|
||||
sx={{ mb: 2, backgroundColor: '#0f172a', color: '#e2e8f0' }}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon sx={{ color: '#94a3b8' }} />}>
|
||||
<Typography variant="subtitle2">
|
||||
Données Brutes (Debug)
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Paper
|
||||
sx={{
|
||||
p: 2,
|
||||
backgroundColor: '#1e293b',
|
||||
border: '1px solid #334155',
|
||||
maxHeight: 300,
|
||||
overflow: 'auto'
|
||||
}}
|
||||
>
|
||||
<pre style={{
|
||||
fontSize: '11px',
|
||||
margin: 0,
|
||||
whiteSpace: 'pre-wrap',
|
||||
color: '#e2e8f0',
|
||||
fontFamily: 'monospace'
|
||||
}}>
|
||||
{JSON.stringify({
|
||||
...evidence,
|
||||
screenshot_base64: evidence.screenshot_base64 ? '[BASE64_DATA]' : null
|
||||
}, null, 2)}
|
||||
</pre>
|
||||
</Paper>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EvidenceDetail;
|
||||
@@ -0,0 +1,406 @@
|
||||
/**
|
||||
* Composant EvidenceFilters - Filtres pour les Evidence
|
||||
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
TextField,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
Chip,
|
||||
Button,
|
||||
Slider,
|
||||
Typography,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
SelectChangeEvent
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
Clear as ClearIcon,
|
||||
FilterList as FilterIcon
|
||||
} from '@mui/icons-material';
|
||||
|
||||
import { VWBEvidence, EvidenceFilters } from '../../types/evidence';
|
||||
|
||||
interface EvidenceFiltersProps {
|
||||
filters: EvidenceFilters;
|
||||
onFiltersChange: (filters: Partial<EvidenceFilters>) => void;
|
||||
onClearFilters: () => void;
|
||||
evidences: VWBEvidence[];
|
||||
}
|
||||
|
||||
const EvidenceFiltersComponent: React.FC<EvidenceFiltersProps> = ({
|
||||
filters,
|
||||
onFiltersChange,
|
||||
onClearFilters,
|
||||
evidences
|
||||
}) => {
|
||||
// État local
|
||||
const [expandedPanels, setExpandedPanels] = useState<string[]>(['basic']);
|
||||
|
||||
// Types d'actions disponibles
|
||||
const availableActionTypes = useMemo(() => {
|
||||
const types = new Set<string>();
|
||||
evidences.forEach(evidence => {
|
||||
types.add(evidence.action_name || evidence.action_id);
|
||||
});
|
||||
return Array.from(types).sort();
|
||||
}, [evidences]);
|
||||
|
||||
// Plages de valeurs pour les sliders
|
||||
const valueRanges = useMemo(() => {
|
||||
const executionTimes = evidences.map(e => e.execution_time_ms);
|
||||
const confidenceScores = evidences.filter(e => e.confidence_score !== undefined).map(e => e.confidence_score!);
|
||||
|
||||
return {
|
||||
executionTime: {
|
||||
min: Math.min(...executionTimes, 0),
|
||||
max: Math.max(...executionTimes, 60000)
|
||||
},
|
||||
confidence: {
|
||||
min: Math.min(...confidenceScores, 0),
|
||||
max: Math.max(...confidenceScores, 1)
|
||||
}
|
||||
};
|
||||
}, [evidences]);
|
||||
|
||||
// Gestion des panneaux accordéon
|
||||
const handlePanelChange = (panel: string) => (event: React.SyntheticEvent, isExpanded: boolean) => {
|
||||
setExpandedPanels(prev =>
|
||||
isExpanded
|
||||
? [...prev, panel]
|
||||
: prev.filter(p => p !== panel)
|
||||
);
|
||||
};
|
||||
|
||||
// Gestion des types d'actions
|
||||
const handleActionTypesChange = (event: SelectChangeEvent<string[]>) => {
|
||||
const value = event.target.value;
|
||||
onFiltersChange({
|
||||
actionTypes: typeof value === 'string' ? value.split(',') : value
|
||||
});
|
||||
};
|
||||
|
||||
// Gestion du statut
|
||||
const handleStatusChange = (event: SelectChangeEvent<string>) => {
|
||||
onFiltersChange({
|
||||
status: event.target.value as 'all' | 'success' | 'error'
|
||||
});
|
||||
};
|
||||
|
||||
// Gestion de la recherche textuelle
|
||||
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onFiltersChange({
|
||||
searchText: event.target.value
|
||||
});
|
||||
};
|
||||
|
||||
// Gestion des dates
|
||||
const handleStartDateChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const date = event.target.value ? new Date(event.target.value) : undefined;
|
||||
onFiltersChange({
|
||||
dateRange: {
|
||||
...filters.dateRange,
|
||||
start: date
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleEndDateChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const date = event.target.value ? new Date(event.target.value) : undefined;
|
||||
onFiltersChange({
|
||||
dateRange: {
|
||||
...filters.dateRange,
|
||||
end: date
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Fonction utilitaire pour formater les dates
|
||||
const formatDateForInput = (date: Date | undefined): string => {
|
||||
if (!date) return '';
|
||||
return date.toISOString().split('T')[0];
|
||||
};
|
||||
|
||||
// Gestion des sliders
|
||||
const handleConfidenceRangeChange = (event: Event, newValue: number | number[]) => {
|
||||
const [min, max] = newValue as number[];
|
||||
onFiltersChange({
|
||||
confidenceRange: { min, max }
|
||||
});
|
||||
};
|
||||
|
||||
const handleExecutionTimeRangeChange = (event: Event, newValue: number | number[]) => {
|
||||
const [min, max] = newValue as number[];
|
||||
onFiltersChange({
|
||||
executionTimeRange: { min, max }
|
||||
});
|
||||
};
|
||||
|
||||
// Vérification si des filtres sont appliqués
|
||||
const hasActiveFilters = (
|
||||
filters.actionTypes.length > 0 ||
|
||||
filters.status !== 'all' ||
|
||||
filters.searchText.trim() !== '' ||
|
||||
filters.dateRange.start !== undefined ||
|
||||
filters.dateRange.end !== undefined ||
|
||||
filters.confidenceRange.min > valueRanges.confidence.min ||
|
||||
filters.confidenceRange.max < valueRanges.confidence.max ||
|
||||
filters.executionTimeRange.min > valueRanges.executionTime.min ||
|
||||
filters.executionTimeRange.max < valueRanges.executionTime.max
|
||||
);
|
||||
|
||||
return (
|
||||
<Box className="evidence-filters">
|
||||
<Box display="flex" alignItems="center" justifyContent="space-between" mb={2}>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<FilterIcon sx={{ color: '#1976d2' }} />
|
||||
<Typography variant="h6" sx={{ color: '#e2e8f0', fontWeight: 600 }}>
|
||||
Filtres
|
||||
</Typography>
|
||||
{hasActiveFilters && (
|
||||
<Chip
|
||||
label="Filtres actifs"
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="filled"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
startIcon={<ClearIcon />}
|
||||
onClick={onClearFilters}
|
||||
size="small"
|
||||
sx={{ color: '#94a3b8' }}
|
||||
>
|
||||
Effacer
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Filtres de base */}
|
||||
<Accordion
|
||||
expanded={expandedPanels.includes('basic')}
|
||||
onChange={handlePanelChange('basic')}
|
||||
sx={{ mb: 1, backgroundColor: '#334155', color: '#e2e8f0' }}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon sx={{ color: '#94a3b8' }} />}>
|
||||
<Typography variant="subtitle2">Filtres de base</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box className="evidence-filters-row">
|
||||
{/* Recherche textuelle */}
|
||||
<TextField
|
||||
label="Recherche"
|
||||
placeholder="Rechercher dans les Evidence..."
|
||||
value={filters.searchText}
|
||||
onChange={handleSearchChange}
|
||||
size="small"
|
||||
sx={{
|
||||
minWidth: 200,
|
||||
'& .MuiOutlinedInput-root': {
|
||||
backgroundColor: '#1e293b',
|
||||
color: '#e2e8f0',
|
||||
'& fieldset': { borderColor: '#475569' },
|
||||
'&:hover fieldset': { borderColor: '#64748b' },
|
||||
'&.Mui-focused fieldset': { borderColor: '#1976d2' }
|
||||
},
|
||||
'& .MuiInputLabel-root': { color: '#94a3b8' }
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Statut */}
|
||||
<FormControl size="small" sx={{ minWidth: 120 }}>
|
||||
<InputLabel sx={{ color: '#94a3b8' }}>Statut</InputLabel>
|
||||
<Select
|
||||
value={filters.status}
|
||||
onChange={handleStatusChange}
|
||||
label="Statut"
|
||||
sx={{
|
||||
backgroundColor: '#1e293b',
|
||||
color: '#e2e8f0',
|
||||
'& .MuiOutlinedInput-notchedOutline': { borderColor: '#475569' },
|
||||
'&:hover .MuiOutlinedInput-notchedOutline': { borderColor: '#64748b' },
|
||||
'&.Mui-focused .MuiOutlinedInput-notchedOutline': { borderColor: '#1976d2' }
|
||||
}}
|
||||
>
|
||||
<MenuItem value="all">Tous</MenuItem>
|
||||
<MenuItem value="success">Succès</MenuItem>
|
||||
<MenuItem value="error">Erreurs</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* Types d'actions */}
|
||||
<FormControl size="small" sx={{ minWidth: 200 }}>
|
||||
<InputLabel sx={{ color: '#94a3b8' }}>Types d'actions</InputLabel>
|
||||
<Select
|
||||
multiple
|
||||
value={filters.actionTypes}
|
||||
onChange={handleActionTypesChange}
|
||||
label="Types d'actions"
|
||||
renderValue={(selected) => (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{selected.map((value) => (
|
||||
<Chip key={value} label={value} size="small" />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
sx={{
|
||||
backgroundColor: '#1e293b',
|
||||
color: '#e2e8f0',
|
||||
'& .MuiOutlinedInput-notchedOutline': { borderColor: '#475569' },
|
||||
'&:hover .MuiOutlinedInput-notchedOutline': { borderColor: '#64748b' },
|
||||
'&.Mui-focused .MuiOutlinedInput-notchedOutline': { borderColor: '#1976d2' }
|
||||
}}
|
||||
>
|
||||
{availableActionTypes.map((type) => (
|
||||
<MenuItem key={type} value={type}>
|
||||
{type}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
{/* Filtres de date */}
|
||||
<Accordion
|
||||
expanded={expandedPanels.includes('dates')}
|
||||
onChange={handlePanelChange('dates')}
|
||||
sx={{ mb: 1, backgroundColor: '#334155', color: '#e2e8f0' }}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon sx={{ color: '#94a3b8' }} />}>
|
||||
<Typography variant="subtitle2">Plage de dates</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box className="evidence-filters-row">
|
||||
<TextField
|
||||
label="Date de début"
|
||||
type="date"
|
||||
value={formatDateForInput(filters.dateRange.start)}
|
||||
onChange={handleStartDateChange}
|
||||
size="small"
|
||||
InputLabelProps={{
|
||||
shrink: true,
|
||||
}}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
backgroundColor: '#1e293b',
|
||||
color: '#e2e8f0',
|
||||
'& fieldset': { borderColor: '#475569' },
|
||||
'&:hover fieldset': { borderColor: '#64748b' },
|
||||
'&.Mui-focused fieldset': { borderColor: '#1976d2' }
|
||||
},
|
||||
'& .MuiInputLabel-root': { color: '#94a3b8' }
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Date de fin"
|
||||
type="date"
|
||||
value={formatDateForInput(filters.dateRange.end)}
|
||||
onChange={handleEndDateChange}
|
||||
size="small"
|
||||
InputLabelProps={{
|
||||
shrink: true,
|
||||
}}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
backgroundColor: '#1e293b',
|
||||
color: '#e2e8f0',
|
||||
'& fieldset': { borderColor: '#475569' },
|
||||
'&:hover fieldset': { borderColor: '#64748b' },
|
||||
'&.Mui-focused fieldset': { borderColor: '#1976d2' }
|
||||
},
|
||||
'& .MuiInputLabel-root': { color: '#94a3b8' }
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
{/* Filtres avancés */}
|
||||
<Accordion
|
||||
expanded={expandedPanels.includes('advanced')}
|
||||
onChange={handlePanelChange('advanced')}
|
||||
sx={{ mb: 1, backgroundColor: '#334155', color: '#e2e8f0' }}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon sx={{ color: '#94a3b8' }} />}>
|
||||
<Typography variant="subtitle2">Filtres avancés</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box>
|
||||
{/* Plage de confiance */}
|
||||
{valueRanges.confidence.max > 0 && (
|
||||
<Box mb={3}>
|
||||
<Typography variant="caption" sx={{ color: '#94a3b8', mb: 1, display: 'block' }}>
|
||||
Score de confiance: {Math.round(filters.confidenceRange.min * 100)}% - {Math.round(filters.confidenceRange.max * 100)}%
|
||||
</Typography>
|
||||
<Slider
|
||||
value={[filters.confidenceRange.min, filters.confidenceRange.max]}
|
||||
onChange={handleConfidenceRangeChange}
|
||||
valueLabelDisplay="auto"
|
||||
valueLabelFormat={(value) => `${Math.round(value * 100)}%`}
|
||||
min={valueRanges.confidence.min}
|
||||
max={valueRanges.confidence.max}
|
||||
step={0.01}
|
||||
sx={{
|
||||
color: '#1976d2',
|
||||
'& .MuiSlider-thumb': {
|
||||
backgroundColor: '#1976d2'
|
||||
},
|
||||
'& .MuiSlider-track': {
|
||||
backgroundColor: '#1976d2'
|
||||
},
|
||||
'& .MuiSlider-rail': {
|
||||
backgroundColor: '#475569'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Plage de temps d'exécution */}
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: '#94a3b8', mb: 1, display: 'block' }}>
|
||||
Temps d'exécution: {filters.executionTimeRange.min}ms - {filters.executionTimeRange.max}ms
|
||||
</Typography>
|
||||
<Slider
|
||||
value={[filters.executionTimeRange.min, filters.executionTimeRange.max]}
|
||||
onChange={handleExecutionTimeRangeChange}
|
||||
valueLabelDisplay="auto"
|
||||
valueLabelFormat={(value) => `${value}ms`}
|
||||
min={valueRanges.executionTime.min}
|
||||
max={valueRanges.executionTime.max}
|
||||
step={100}
|
||||
sx={{
|
||||
color: '#1976d2',
|
||||
'& .MuiSlider-thumb': {
|
||||
backgroundColor: '#1976d2'
|
||||
},
|
||||
'& .MuiSlider-track': {
|
||||
backgroundColor: '#1976d2'
|
||||
},
|
||||
'& .MuiSlider-rail': {
|
||||
backgroundColor: '#475569'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EvidenceFiltersComponent;
|
||||
@@ -0,0 +1,415 @@
|
||||
/**
|
||||
* Composant EvidenceList - Liste des Evidence avec filtrage et tri
|
||||
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemText,
|
||||
ListItemAvatar,
|
||||
Avatar,
|
||||
Typography,
|
||||
Chip,
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuItem,
|
||||
TextField,
|
||||
InputAdornment,
|
||||
Pagination,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
SelectChangeEvent
|
||||
} from '@mui/material';
|
||||
import {
|
||||
CheckCircle as SuccessIcon,
|
||||
Error as ErrorIcon,
|
||||
Schedule as TimeIcon,
|
||||
Search as SearchIcon,
|
||||
Sort as SortIcon,
|
||||
FilterList as FilterIcon
|
||||
} from '@mui/icons-material';
|
||||
|
||||
import { VWBEvidence, EvidenceFilters, EvidenceUtils } from '../../types/evidence';
|
||||
|
||||
interface EvidenceListProps {
|
||||
evidences: VWBEvidence[];
|
||||
selectedId?: string;
|
||||
onSelect: (evidence: VWBEvidence) => void;
|
||||
filters?: EvidenceFilters;
|
||||
onFiltersChange?: (filters: Partial<EvidenceFilters>) => void;
|
||||
sortBy: string;
|
||||
sortOrder: 'asc' | 'desc';
|
||||
onSortChange: (sortBy: string, sortOrder: 'asc' | 'desc') => void;
|
||||
viewMode: 'list' | 'grid';
|
||||
showFilters?: boolean;
|
||||
itemsPerPage?: number;
|
||||
}
|
||||
|
||||
const EvidenceList: React.FC<EvidenceListProps> = ({
|
||||
evidences,
|
||||
selectedId,
|
||||
onSelect,
|
||||
filters,
|
||||
onFiltersChange,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
onSortChange,
|
||||
viewMode = 'list',
|
||||
showFilters = false,
|
||||
itemsPerPage = 20
|
||||
}) => {
|
||||
// État local
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [searchText, setSearchText] = useState(filters?.searchText || '');
|
||||
const [sortMenuAnchor, setSortMenuAnchor] = useState<null | HTMLElement>(null);
|
||||
|
||||
// Evidence paginées
|
||||
const paginatedEvidences = useMemo(() => {
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
const endIndex = startIndex + itemsPerPage;
|
||||
return evidences.slice(startIndex, endIndex);
|
||||
}, [evidences, currentPage, itemsPerPage]);
|
||||
|
||||
// Nombre total de pages
|
||||
const totalPages = Math.ceil(evidences.length / itemsPerPage);
|
||||
|
||||
// Gestion de la recherche
|
||||
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.target.value;
|
||||
setSearchText(value);
|
||||
setCurrentPage(1); // Retour à la première page
|
||||
|
||||
if (onFiltersChange) {
|
||||
onFiltersChange({ searchText: value });
|
||||
}
|
||||
};
|
||||
|
||||
// Gestion du tri
|
||||
const handleSortMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setSortMenuAnchor(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleSortMenuClose = () => {
|
||||
setSortMenuAnchor(null);
|
||||
};
|
||||
|
||||
const handleSortChange = (newSortBy: string) => {
|
||||
const newSortOrder = sortBy === newSortBy && sortOrder === 'desc' ? 'asc' : 'desc';
|
||||
onSortChange(newSortBy, newSortOrder);
|
||||
handleSortMenuClose();
|
||||
};
|
||||
|
||||
// Gestion du changement de page
|
||||
const handlePageChange = (event: React.ChangeEvent<unknown>, page: number) => {
|
||||
setCurrentPage(page);
|
||||
};
|
||||
|
||||
// Rendu d'un item Evidence
|
||||
const renderEvidenceItem = (evidence: VWBEvidence) => {
|
||||
const isSelected = selectedId === evidence.id;
|
||||
const isSuccess = evidence.success;
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={evidence.id}
|
||||
disablePadding
|
||||
className={`evidence-list-item ${isSelected ? 'selected' : ''} ${isSuccess ? 'success' : 'error'}`}
|
||||
sx={{
|
||||
mb: 1,
|
||||
borderRadius: 2,
|
||||
border: isSelected ? '2px solid #1976d2' : '1px solid #334155',
|
||||
backgroundColor: isSelected ? '#1e40af' : '#334155',
|
||||
}}
|
||||
>
|
||||
<ListItemButton
|
||||
selected={isSelected}
|
||||
onClick={() => onSelect(evidence)}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
'&:hover': {
|
||||
backgroundColor: isSelected ? '#1e40af' : '#475569',
|
||||
transform: 'translateY(-1px)',
|
||||
boxShadow: '0 4px 8px rgba(0, 0, 0, 0.2)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ListItemAvatar>
|
||||
<Avatar
|
||||
sx={{
|
||||
bgcolor: isSuccess ? '#22c55e' : '#ef4444',
|
||||
width: 32,
|
||||
height: 32
|
||||
}}
|
||||
>
|
||||
{isSuccess ? <SuccessIcon fontSize="small" /> : <ErrorIcon fontSize="small" />}
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
|
||||
<ListItemText
|
||||
primary={
|
||||
<Typography variant="subtitle2" sx={{ color: '#e2e8f0', fontWeight: 600 }}>
|
||||
{evidence.action_name || evidence.action_id}
|
||||
</Typography>
|
||||
}
|
||||
secondary={
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: '#94a3b8', display: 'block' }}>
|
||||
{EvidenceUtils.formatDate(evidence.captured_at)}
|
||||
</Typography>
|
||||
<Box display="flex" alignItems="center" gap={1} mt={0.5}>
|
||||
<Chip
|
||||
icon={<TimeIcon />}
|
||||
label={EvidenceUtils.formatExecutionTime(evidence.execution_time_ms)}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
fontSize: '10px',
|
||||
height: 20,
|
||||
color: '#94a3b8',
|
||||
borderColor: '#475569'
|
||||
}}
|
||||
/>
|
||||
{evidence.confidence_score && (
|
||||
<Chip
|
||||
label={EvidenceUtils.formatConfidence(evidence.confidence_score)}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
fontSize: '10px',
|
||||
height: 20,
|
||||
color: '#94a3b8',
|
||||
borderColor: '#475569'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!isSuccess && (
|
||||
<Chip
|
||||
label="ERREUR"
|
||||
size="small"
|
||||
sx={{
|
||||
fontSize: '10px',
|
||||
height: 20,
|
||||
backgroundColor: '#ef4444',
|
||||
color: 'white'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Miniature du screenshot */}
|
||||
{evidence.screenshot_base64 && (
|
||||
<Box ml={1}>
|
||||
<img
|
||||
src={`data:image/png;base64,${evidence.screenshot_base64}`}
|
||||
alt={`Screenshot de ${evidence.action_name || evidence.action_id}`}
|
||||
className="evidence-thumbnail"
|
||||
style={{
|
||||
width: 60,
|
||||
height: 40,
|
||||
objectFit: 'cover',
|
||||
borderRadius: 4,
|
||||
border: '1px solid #475569'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
);
|
||||
};
|
||||
|
||||
// Rendu en mode grille
|
||||
const renderGridView = () => (
|
||||
<Box className="evidence-grid">
|
||||
{paginatedEvidences.map(evidence => {
|
||||
const isSelected = selectedId === evidence.id;
|
||||
const isSuccess = evidence.success;
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={evidence.id}
|
||||
className={`evidence-grid-item ${isSelected ? 'selected' : ''}`}
|
||||
onClick={() => onSelect(evidence)}
|
||||
sx={{
|
||||
border: isSelected ? '2px solid #1976d2' : '1px solid #334155',
|
||||
backgroundColor: isSelected ? '#1e40af' : '#334155',
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
backgroundColor: isSelected ? '#1e40af' : '#475569',
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 6px 12px rgba(0, 0, 0, 0.3)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* En-tête */}
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={1}>
|
||||
<Typography variant="subtitle2" sx={{ color: '#e2e8f0', fontWeight: 600 }}>
|
||||
{evidence.action_name || evidence.action_id}
|
||||
</Typography>
|
||||
<Chip
|
||||
icon={isSuccess ? <SuccessIcon /> : <ErrorIcon />}
|
||||
label={isSuccess ? 'OK' : 'ERR'}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: isSuccess ? '#22c55e' : '#ef4444',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
height: 20
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Screenshot */}
|
||||
{evidence.screenshot_base64 && (
|
||||
<img
|
||||
src={`data:image/png;base64,${evidence.screenshot_base64}`}
|
||||
alt={`Screenshot de ${evidence.action_name || evidence.action_id}`}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 100,
|
||||
objectFit: 'cover',
|
||||
borderRadius: 4,
|
||||
border: '1px solid #475569',
|
||||
marginBottom: 8
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Métadonnées */}
|
||||
<Box flex={1}>
|
||||
<Typography variant="caption" sx={{ color: '#94a3b8', display: 'block' }}>
|
||||
{EvidenceUtils.formatDate(evidence.captured_at)}
|
||||
</Typography>
|
||||
<Box display="flex" justifyContent="space-between" mt={1}>
|
||||
<Typography variant="caption" sx={{ color: '#94a3b8' }}>
|
||||
{EvidenceUtils.formatExecutionTime(evidence.execution_time_ms)}
|
||||
</Typography>
|
||||
{evidence.confidence_score && (
|
||||
<Typography variant="caption" sx={{ color: '#94a3b8' }}>
|
||||
{EvidenceUtils.formatConfidence(evidence.confidence_score)}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box height="100%" display="flex" flexDirection="column">
|
||||
{/* Barre d'outils */}
|
||||
<Box p={2} borderBottom="1px solid #334155">
|
||||
<Box display="flex" gap={2} alignItems="center" mb={showFilters ? 2 : 0}>
|
||||
{/* Recherche */}
|
||||
<TextField
|
||||
size="small"
|
||||
placeholder="Rechercher dans les Evidence..."
|
||||
value={searchText}
|
||||
onChange={handleSearchChange}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon sx={{ color: '#94a3b8' }} />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
sx={{
|
||||
flex: 1,
|
||||
'& .MuiOutlinedInput-root': {
|
||||
backgroundColor: '#334155',
|
||||
color: '#e2e8f0',
|
||||
'& fieldset': { borderColor: '#475569' },
|
||||
'&:hover fieldset': { borderColor: '#64748b' },
|
||||
'&.Mui-focused fieldset': { borderColor: '#1976d2' }
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Bouton de tri */}
|
||||
<IconButton
|
||||
onClick={handleSortMenuOpen}
|
||||
sx={{ color: '#94a3b8' }}
|
||||
>
|
||||
<SortIcon />
|
||||
</IconButton>
|
||||
|
||||
{/* Menu de tri */}
|
||||
<Menu
|
||||
anchorEl={sortMenuAnchor}
|
||||
open={Boolean(sortMenuAnchor)}
|
||||
onClose={handleSortMenuClose}
|
||||
>
|
||||
<MenuItem onClick={() => handleSortChange('date')}>
|
||||
Date {sortBy === 'date' && (sortOrder === 'desc' ? '↓' : '↑')}
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => handleSortChange('action')}>
|
||||
Action {sortBy === 'action' && (sortOrder === 'desc' ? '↓' : '↑')}
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => handleSortChange('status')}>
|
||||
Statut {sortBy === 'status' && (sortOrder === 'desc' ? '↓' : '↑')}
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => handleSortChange('execution_time')}>
|
||||
Temps {sortBy === 'execution_time' && (sortOrder === 'desc' ? '↓' : '↑')}
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => handleSortChange('confidence')}>
|
||||
Confiance {sortBy === 'confidence' && (sortOrder === 'desc' ? '↓' : '↑')}
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Box>
|
||||
|
||||
{/* Informations */}
|
||||
<Typography variant="caption" sx={{ color: '#94a3b8' }}>
|
||||
{evidences.length} Evidence{evidences.length > 1 ? 's' : ''}
|
||||
{evidences.length !== paginatedEvidences.length &&
|
||||
` (${paginatedEvidences.length} affichée${paginatedEvidences.length > 1 ? 's' : ''})`
|
||||
}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Liste ou grille */}
|
||||
<Box flex={1} overflow="auto">
|
||||
{viewMode === 'list' ? (
|
||||
<List sx={{ p: 1 }}>
|
||||
{paginatedEvidences.map(renderEvidenceItem)}
|
||||
</List>
|
||||
) : (
|
||||
renderGridView()
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<Box p={2} borderTop="1px solid #334155" display="flex" justifyContent="center">
|
||||
<Pagination
|
||||
count={totalPages}
|
||||
page={currentPage}
|
||||
onChange={handlePageChange}
|
||||
color="primary"
|
||||
size="small"
|
||||
sx={{
|
||||
'& .MuiPaginationItem-root': {
|
||||
color: '#94a3b8',
|
||||
'&.Mui-selected': {
|
||||
backgroundColor: '#1976d2',
|
||||
color: 'white'
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EvidenceList;
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Composant EvidenceStats - Affichage des statistiques des Evidence
|
||||
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Typography, Chip, LinearProgress } from '@mui/material';
|
||||
import {
|
||||
CheckCircle as SuccessIcon,
|
||||
Error as ErrorIcon,
|
||||
Schedule as TimeIcon,
|
||||
Visibility as ConfidenceIcon,
|
||||
Assessment as StatsIcon
|
||||
} from '@mui/icons-material';
|
||||
|
||||
import { EvidenceStats, EvidenceUtils } from '../../types/evidence';
|
||||
|
||||
interface EvidenceStatsProps {
|
||||
stats: EvidenceStats;
|
||||
totalEvidences: number;
|
||||
filteredCount: number;
|
||||
hasFilters: boolean;
|
||||
}
|
||||
|
||||
const EvidenceStatsComponent: React.FC<EvidenceStatsProps> = ({
|
||||
stats,
|
||||
totalEvidences,
|
||||
filteredCount,
|
||||
hasFilters
|
||||
}) => {
|
||||
// Calcul du taux de succès
|
||||
const successRate = stats.total > 0 ? (stats.successful / stats.total) * 100 : 0;
|
||||
|
||||
// Couleur du taux de succès
|
||||
const getSuccessRateColor = (rate: number) => {
|
||||
if (rate >= 90) return '#22c55e'; // Vert
|
||||
if (rate >= 70) return '#f59e0b'; // Orange
|
||||
return '#ef4444'; // Rouge
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="evidence-stats">
|
||||
<Box display="flex" alignItems="center" gap={1} mb={2}>
|
||||
<StatsIcon sx={{ color: '#1976d2' }} />
|
||||
<Typography variant="h6" sx={{ color: '#e2e8f0', fontWeight: 600 }}>
|
||||
Statistiques des Evidence
|
||||
</Typography>
|
||||
{hasFilters && (
|
||||
<Chip
|
||||
label={`${filteredCount}/${totalEvidences} filtrées`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ color: '#94a3b8', borderColor: '#475569' }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box className="evidence-stats-grid">
|
||||
{/* Total */}
|
||||
<Box className="evidence-stat-item">
|
||||
<Typography variant="h4" className="evidence-stat-value">
|
||||
{stats.total}
|
||||
</Typography>
|
||||
<Typography variant="caption" className="evidence-stat-label">
|
||||
Total
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Réussies */}
|
||||
<Box className="evidence-stat-item">
|
||||
<Box display="flex" alignItems="center" justifyContent="center" gap={0.5} mb={0.5}>
|
||||
<SuccessIcon sx={{ color: '#22c55e', fontSize: 16 }} />
|
||||
<Typography variant="h4" sx={{ color: '#22c55e', fontWeight: 700 }}>
|
||||
{stats.successful}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="caption" className="evidence-stat-label">
|
||||
Réussies
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Échouées */}
|
||||
<Box className="evidence-stat-item">
|
||||
<Box display="flex" alignItems="center" justifyContent="center" gap={0.5} mb={0.5}>
|
||||
<ErrorIcon sx={{ color: '#ef4444', fontSize: 16 }} />
|
||||
<Typography variant="h4" sx={{ color: '#ef4444', fontWeight: 700 }}>
|
||||
{stats.failed}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="caption" className="evidence-stat-label">
|
||||
Échouées
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Taux de succès */}
|
||||
<Box className="evidence-stat-item">
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{
|
||||
color: getSuccessRateColor(successRate),
|
||||
fontWeight: 700
|
||||
}}
|
||||
>
|
||||
{Math.round(successRate)}%
|
||||
</Typography>
|
||||
<Typography variant="caption" className="evidence-stat-label">
|
||||
Taux de succès
|
||||
</Typography>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={successRate}
|
||||
sx={{
|
||||
mt: 0.5,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#334155',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
backgroundColor: getSuccessRateColor(successRate)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Temps moyen */}
|
||||
<Box className="evidence-stat-item">
|
||||
<Box display="flex" alignItems="center" justifyContent="center" gap={0.5} mb={0.5}>
|
||||
<TimeIcon sx={{ color: '#1976d2', fontSize: 16 }} />
|
||||
<Typography variant="h4" className="evidence-stat-value">
|
||||
{EvidenceUtils.formatExecutionTime(stats.averageExecutionTime)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="caption" className="evidence-stat-label">
|
||||
Temps moyen
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Confiance moyenne */}
|
||||
{stats.averageConfidence > 0 && (
|
||||
<Box className="evidence-stat-item">
|
||||
<Box display="flex" alignItems="center" justifyContent="center" gap={0.5} mb={0.5}>
|
||||
<ConfidenceIcon sx={{ color: '#1976d2', fontSize: 16 }} />
|
||||
<Typography variant="h4" className="evidence-stat-value">
|
||||
{EvidenceUtils.formatConfidence(stats.averageConfidence)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="caption" className="evidence-stat-label">
|
||||
Confiance moyenne
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Distribution des types d'actions */}
|
||||
{Object.keys(stats.actionTypeDistribution).length > 0 && (
|
||||
<Box mt={2}>
|
||||
<Typography variant="subtitle2" sx={{ color: '#e2e8f0', mb: 1 }}>
|
||||
Répartition par type d'action
|
||||
</Typography>
|
||||
<Box display="flex" flexWrap="wrap" gap={1}>
|
||||
{Object.entries(stats.actionTypeDistribution)
|
||||
.sort(([,a], [,b]) => b - a) // Tri par nombre décroissant
|
||||
.slice(0, 6) // Limite à 6 types max
|
||||
.map(([actionType, count]) => (
|
||||
<Chip
|
||||
key={actionType}
|
||||
label={`${actionType} (${count})`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
color: '#94a3b8',
|
||||
borderColor: '#475569',
|
||||
backgroundColor: '#334155'
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Timeline récente */}
|
||||
{stats.timelineData.length > 0 && (
|
||||
<Box mt={2}>
|
||||
<Typography variant="subtitle2" sx={{ color: '#e2e8f0', mb: 1 }}>
|
||||
Activité récente (7 derniers jours)
|
||||
</Typography>
|
||||
<Box display="flex" gap={1} alignItems="end" height={40}>
|
||||
{stats.timelineData
|
||||
.slice(-7) // 7 derniers jours
|
||||
.map((day, index) => {
|
||||
const height = Math.max(4, (day.count / Math.max(...stats.timelineData.map(d => d.count))) * 32);
|
||||
const color = day.successRate >= 0.9 ? '#22c55e' : day.successRate >= 0.7 ? '#f59e0b' : '#ef4444';
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={day.date}
|
||||
sx={{
|
||||
width: 20,
|
||||
height: height,
|
||||
backgroundColor: color,
|
||||
borderRadius: '2px 2px 0 0',
|
||||
opacity: 0.8,
|
||||
cursor: 'pointer',
|
||||
'&:hover': { opacity: 1 }
|
||||
}}
|
||||
title={`${day.date}: ${day.count} Evidence (${Math.round(day.successRate * 100)}% succès)`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
<Box display="flex" justifyContent="space-between" mt={0.5}>
|
||||
<Typography variant="caption" sx={{ color: '#64748b' }}>
|
||||
{stats.timelineData.slice(-7)[0]?.date}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#64748b' }}>
|
||||
{stats.timelineData.slice(-1)[0]?.date}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EvidenceStatsComponent;
|
||||
@@ -0,0 +1,414 @@
|
||||
/**
|
||||
* Styles CSS pour le composant Evidence Viewer VWB
|
||||
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
|
||||
*/
|
||||
|
||||
.evidence-viewer {
|
||||
position: relative;
|
||||
background: #1e293b;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #334155;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.evidence-viewer-fabs {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Liste des Evidence */
|
||||
.evidence-list {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.evidence-list-item {
|
||||
margin-bottom: 8px;
|
||||
padding: 12px;
|
||||
background: #334155;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.evidence-list-item:hover {
|
||||
background: #475569;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.evidence-list-item.selected {
|
||||
border-color: #1976d2;
|
||||
background: #1e40af;
|
||||
}
|
||||
|
||||
.evidence-list-item.error {
|
||||
border-left: 4px solid #ef4444;
|
||||
}
|
||||
|
||||
.evidence-list-item.success {
|
||||
border-left: 4px solid #22c55e;
|
||||
}
|
||||
|
||||
/* Vue grille */
|
||||
.evidence-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.evidence-grid-item {
|
||||
background: #334155;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: 2px solid transparent;
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.evidence-grid-item:hover {
|
||||
background: #475569;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.evidence-grid-item.selected {
|
||||
border-color: #1976d2;
|
||||
background: #1e40af;
|
||||
}
|
||||
|
||||
/* En-tête d'Evidence */
|
||||
.evidence-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.evidence-title {
|
||||
font-weight: 600;
|
||||
color: #e2e8f0;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.evidence-status {
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.evidence-status.success {
|
||||
background: #22c55e;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.evidence-status.error {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Métadonnées d'Evidence */
|
||||
.evidence-metadata {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.evidence-metadata-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.evidence-metadata-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.evidence-metadata-value {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
/* Screenshot miniature */
|
||||
.evidence-thumbnail {
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
margin: 8px 0;
|
||||
border: 1px solid #475569;
|
||||
}
|
||||
|
||||
/* Détail d'Evidence */
|
||||
.evidence-detail {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
background: #1e293b;
|
||||
}
|
||||
|
||||
.evidence-detail-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #334155;
|
||||
}
|
||||
|
||||
.evidence-detail-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #e2e8f0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.evidence-detail-close {
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.evidence-detail-close:hover {
|
||||
background: #334155;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
/* Screenshot principal */
|
||||
.evidence-screenshot {
|
||||
width: 100%;
|
||||
max-height: 400px;
|
||||
object-fit: contain;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #334155;
|
||||
margin: 12px 0;
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
/* Annotations sur screenshot */
|
||||
.evidence-screenshot-container {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.evidence-annotation {
|
||||
position: absolute;
|
||||
border: 2px solid #1976d2;
|
||||
background: rgba(25, 118, 210, 0.2);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.evidence-annotation.click-point {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #ef4444;
|
||||
border: 2px solid white;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.evidence-annotation.bbox {
|
||||
background: rgba(25, 118, 210, 0.1);
|
||||
border: 2px dashed #1976d2;
|
||||
}
|
||||
|
||||
/* Panneau de métadonnées */
|
||||
.evidence-metadata-panel {
|
||||
background: #0f172a;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.evidence-metadata-panel h4 {
|
||||
color: #e2e8f0;
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.evidence-metadata-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.evidence-metadata-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.evidence-metadata-item-label {
|
||||
color: #94a3b8;
|
||||
font-weight: 500;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.evidence-metadata-item-value {
|
||||
color: #e2e8f0;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* Message d'erreur */
|
||||
.evidence-error {
|
||||
background: #7f1d1d;
|
||||
border: 1px solid #ef4444;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.evidence-error-title {
|
||||
color: #fecaca;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.evidence-error-message {
|
||||
color: #fee2e2;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Statistiques */
|
||||
.evidence-stats {
|
||||
padding: 12px 16px;
|
||||
background: #0f172a;
|
||||
border-bottom: 1px solid #334155;
|
||||
}
|
||||
|
||||
.evidence-stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.evidence-stat-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.evidence-stat-value {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #1976d2;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.evidence-stat-label {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Filtres */
|
||||
.evidence-filters {
|
||||
padding: 12px 16px;
|
||||
background: #0f172a;
|
||||
border-bottom: 1px solid #334155;
|
||||
}
|
||||
|
||||
.evidence-filters-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.evidence-filters-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.evidence-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.evidence-metadata-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.evidence-stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.evidence-filters-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.evidence-viewer-fabs .MuiFab-root {
|
||||
position: fixed !important;
|
||||
bottom: 16px !important;
|
||||
}
|
||||
|
||||
.evidence-viewer-fabs .MuiFab-root:nth-child(1) {
|
||||
right: 16px !important;
|
||||
}
|
||||
|
||||
.evidence-viewer-fabs .MuiFab-root:nth-child(2) {
|
||||
right: 80px !important;
|
||||
}
|
||||
|
||||
.evidence-viewer-fabs .MuiFab-root:nth-child(3) {
|
||||
right: 144px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes evidenceAppear {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.evidence-list-item,
|
||||
.evidence-grid-item {
|
||||
animation: evidenceAppear 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Scrollbar personnalisée */
|
||||
.evidence-list::-webkit-scrollbar,
|
||||
.evidence-detail::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.evidence-list::-webkit-scrollbar-track,
|
||||
.evidence-detail::-webkit-scrollbar-track {
|
||||
background: #1e293b;
|
||||
}
|
||||
|
||||
.evidence-list::-webkit-scrollbar-thumb,
|
||||
.evidence-detail::-webkit-scrollbar-thumb {
|
||||
background: #475569;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.evidence-list::-webkit-scrollbar-thumb:hover,
|
||||
.evidence-detail::-webkit-scrollbar-thumb:hover {
|
||||
background: #64748b;
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
/**
|
||||
* Panneau Evidence d'Exécution - Affichage temps réel des Evidence VWB
|
||||
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
|
||||
*
|
||||
* Ce composant affiche les Evidence générées pendant l'exécution des workflows VWB
|
||||
* avec mise à jour en temps réel et navigation dans l'historique.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
Tabs,
|
||||
Tab,
|
||||
Badge,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Chip,
|
||||
Divider,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemIcon,
|
||||
Collapse,
|
||||
Alert,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Visibility as EvidenceIcon,
|
||||
Screenshot as ScreenshotIcon,
|
||||
Timeline as TimelineIcon,
|
||||
FilterList as FilterIcon,
|
||||
Refresh as RefreshIcon,
|
||||
ExpandMore as ExpandIcon,
|
||||
ExpandLess as CollapseIcon,
|
||||
CheckCircle as SuccessIcon,
|
||||
Error as ErrorIcon,
|
||||
Info as InfoIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
// Import des composants Evidence existants
|
||||
import EvidenceList from './EvidenceList';
|
||||
import EvidenceDetail from './EvidenceDetail';
|
||||
import ScreenshotViewer from './ScreenshotViewer';
|
||||
import EvidenceFilters from './EvidenceFilters';
|
||||
|
||||
// Import des types
|
||||
import { Evidence, Step, StepExecutionState } from '../../types';
|
||||
import { VWBExecutionResult } from '../../services/vwbExecutionService';
|
||||
|
||||
// Import du hook d'exécution Evidence
|
||||
import { useExecutionEvidence } from '../../hooks/useExecutionEvidence';
|
||||
|
||||
interface ExecutionEvidencePanelProps {
|
||||
/** Étape actuellement en cours d'exécution */
|
||||
currentStep?: Step;
|
||||
/** Résultats d'exécution VWB */
|
||||
executionResults: VWBExecutionResult[];
|
||||
/** Callback lors de la sélection d'une Evidence */
|
||||
onEvidenceSelect?: (evidence: Evidence) => void;
|
||||
/** Callback lors du changement d'étape */
|
||||
onStepChange?: (stepId: string) => void;
|
||||
/** Mode d'affichage compact */
|
||||
compact?: boolean;
|
||||
/** Hauteur maximale du panneau */
|
||||
maxHeight?: number;
|
||||
}
|
||||
|
||||
interface TabPanelProps {
|
||||
children?: React.ReactNode;
|
||||
index: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composant TabPanel pour les onglets
|
||||
*/
|
||||
const TabPanel: React.FC<TabPanelProps> = ({ children, value, index }) => (
|
||||
<div hidden={value !== index} style={{ height: '100%' }}>
|
||||
{value === index && children}
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* Panneau principal pour l'affichage des Evidence d'exécution
|
||||
*/
|
||||
const ExecutionEvidencePanel: React.FC<ExecutionEvidencePanelProps> = ({
|
||||
currentStep,
|
||||
executionResults,
|
||||
onEvidenceSelect,
|
||||
onStepChange,
|
||||
compact = false,
|
||||
maxHeight = 600,
|
||||
}) => {
|
||||
// État local
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
const [selectedEvidence, setSelectedEvidence] = useState<Evidence | null>(null);
|
||||
const [expandedSteps, setExpandedSteps] = useState<Set<string>>(new Set());
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
|
||||
// Hook pour la gestion des Evidence d'exécution
|
||||
const {
|
||||
allEvidence,
|
||||
evidenceByStep,
|
||||
currentStepEvidence,
|
||||
addEvidence,
|
||||
clearEvidence,
|
||||
getEvidenceStats,
|
||||
} = useExecutionEvidence();
|
||||
|
||||
// Synchroniser les Evidence avec les résultats d'exécution
|
||||
useEffect(() => {
|
||||
executionResults.forEach(result => {
|
||||
if (result.evidence && result.evidence.length > 0) {
|
||||
result.evidence.forEach(evidence => {
|
||||
addEvidence(result.stepId, evidence);
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [executionResults, addEvidence]);
|
||||
|
||||
// Statistiques des Evidence
|
||||
const evidenceStats = useMemo(() => getEvidenceStats(), [getEvidenceStats]);
|
||||
|
||||
// Gérer la sélection d'Evidence
|
||||
const handleEvidenceSelect = useCallback((evidence: Evidence) => {
|
||||
setSelectedEvidence(evidence);
|
||||
onEvidenceSelect?.(evidence);
|
||||
}, [onEvidenceSelect]);
|
||||
|
||||
// Gérer l'expansion des étapes
|
||||
const toggleStepExpansion = useCallback((stepId: string) => {
|
||||
setExpandedSteps(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(stepId)) {
|
||||
newSet.delete(stepId);
|
||||
} else {
|
||||
newSet.add(stepId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Gérer le changement d'onglet
|
||||
const handleTabChange = useCallback((_: React.SyntheticEvent, newValue: number) => {
|
||||
setActiveTab(newValue);
|
||||
}, []);
|
||||
|
||||
// Rendu de la liste des étapes avec Evidence
|
||||
const renderStepsList = useCallback(() => {
|
||||
const stepsWithEvidence = executionResults.filter(result =>
|
||||
result.evidence && result.evidence.length > 0
|
||||
);
|
||||
|
||||
if (stepsWithEvidence.length === 0) {
|
||||
return (
|
||||
<Alert severity="info" sx={{ m: 2 }}>
|
||||
Aucune Evidence générée pour le moment.
|
||||
Les Evidence apparaîtront automatiquement lors de l'exécution.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<List sx={{ width: '100%' }}>
|
||||
{stepsWithEvidence.map((result) => {
|
||||
const isExpanded = expandedSteps.has(result.stepId);
|
||||
const stepEvidence = result.evidence || [];
|
||||
|
||||
return (
|
||||
<React.Fragment key={result.stepId}>
|
||||
<ListItem
|
||||
component="div"
|
||||
onClick={() => toggleStepExpansion(result.stepId)}
|
||||
sx={{
|
||||
cursor: "pointer",
|
||||
borderLeft: `4px solid ${result.success ? '#4caf50' : '#f44336'}`,
|
||||
mb: 1,
|
||||
borderRadius: 1,
|
||||
backgroundColor: 'background.paper',
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
{result.success ? (
|
||||
<SuccessIcon color="success" />
|
||||
) : (
|
||||
<ErrorIcon color="error" />
|
||||
)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="subtitle2">
|
||||
Étape {result.stepId}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={result.actionId}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ fontSize: '0.7rem' }}
|
||||
/>
|
||||
<Badge
|
||||
badgeContent={stepEvidence.length}
|
||||
color="primary"
|
||||
sx={{ ml: 'auto' }}
|
||||
>
|
||||
<EvidenceIcon fontSize="small" />
|
||||
</Badge>
|
||||
</Box>
|
||||
}
|
||||
secondary={`Durée: ${result.duration}ms`}
|
||||
/>
|
||||
<IconButton size="small">
|
||||
{isExpanded ? <CollapseIcon /> : <ExpandIcon />}
|
||||
</IconButton>
|
||||
</ListItem>
|
||||
|
||||
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
|
||||
<Box sx={{ pl: 4, pr: 2, pb: 2 }}>
|
||||
<EvidenceList
|
||||
evidences={stepEvidence}
|
||||
selectedId={selectedEvidence?.id}
|
||||
onSelect={handleEvidenceSelect}
|
||||
filters={{
|
||||
actionTypes: [],
|
||||
status: 'all',
|
||||
dateRange: {},
|
||||
searchText: '',
|
||||
confidenceRange: { min: 0, max: 1 },
|
||||
executionTimeRange: { min: 0, max: 10000 }
|
||||
}}
|
||||
onFiltersChange={() => {}}
|
||||
sortBy="timestamp"
|
||||
sortOrder="desc"
|
||||
onSortChange={() => {}}
|
||||
viewMode="list"
|
||||
/>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
);
|
||||
}, [executionResults, expandedSteps, selectedEvidence, handleEvidenceSelect, toggleStepExpansion]);
|
||||
|
||||
// Rendu de la timeline des Evidence
|
||||
const renderTimeline = useCallback(() => {
|
||||
const sortedEvidence = [...allEvidence].sort((a, b) =>
|
||||
new Date(b.captured_at).getTime() - new Date(a.captured_at).getTime()
|
||||
);
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Timeline des Evidence ({sortedEvidence.length})
|
||||
</Typography>
|
||||
<EvidenceList
|
||||
evidences={sortedEvidence}
|
||||
selectedId={selectedEvidence?.id}
|
||||
onSelect={handleEvidenceSelect}
|
||||
filters={{
|
||||
actionTypes: [],
|
||||
status: 'all',
|
||||
dateRange: {},
|
||||
searchText: '',
|
||||
confidenceRange: { min: 0, max: 1 },
|
||||
executionTimeRange: { min: 0, max: 10000 }
|
||||
}}
|
||||
onFiltersChange={() => {}}
|
||||
sortBy="timestamp"
|
||||
sortOrder="desc"
|
||||
onSortChange={() => {}}
|
||||
viewMode="list"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}, [allEvidence, selectedEvidence, handleEvidenceSelect, compact]);
|
||||
|
||||
// Rendu de l'étape actuelle
|
||||
const renderCurrentStep = useCallback(() => {
|
||||
if (!currentStep) {
|
||||
return (
|
||||
<Alert severity="info" sx={{ m: 2 }}>
|
||||
Aucune étape en cours d'exécution
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const stepEvidence = currentStepEvidence;
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Étape Actuelle: {currentStep.name}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={currentStep.type}
|
||||
color="primary"
|
||||
size="small"
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
|
||||
{stepEvidence.length > 0 ? (
|
||||
<EvidenceList
|
||||
evidences={stepEvidence}
|
||||
selectedId={selectedEvidence?.id}
|
||||
onSelect={handleEvidenceSelect}
|
||||
filters={{
|
||||
actionTypes: [],
|
||||
status: 'all',
|
||||
dateRange: {},
|
||||
searchText: '',
|
||||
confidenceRange: { min: 0, max: 1 },
|
||||
executionTimeRange: { min: 0, max: 10000 }
|
||||
}}
|
||||
onFiltersChange={() => {}}
|
||||
sortBy="timestamp"
|
||||
sortOrder="desc"
|
||||
onSortChange={() => {}}
|
||||
viewMode="list"
|
||||
/>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Aucune Evidence générée pour cette étape
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}, [currentStep, currentStepEvidence, selectedEvidence, handleEvidenceSelect, compact]);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={2}
|
||||
sx={{
|
||||
height: compact ? 'auto' : maxHeight,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* En-tête avec statistiques */}
|
||||
<Box sx={{ p: 2, borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Typography variant="h6">
|
||||
Evidence d'Exécution
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Chip
|
||||
label={`${evidenceStats.total} Evidence`}
|
||||
size="small"
|
||||
color="primary"
|
||||
/>
|
||||
<Chip
|
||||
label={`${evidenceStats.screenshots} Screenshots`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
<Tooltip title="Filtres">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
color={showFilters ? 'primary' : 'default'}
|
||||
>
|
||||
<FilterIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Actualiser">
|
||||
<IconButton size="small" onClick={() => window.location.reload()}>
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Filtres */}
|
||||
<Collapse in={showFilters}>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<EvidenceFilters
|
||||
evidences={allEvidence}
|
||||
filters={{
|
||||
actionTypes: [],
|
||||
status: 'all',
|
||||
dateRange: {},
|
||||
searchText: '',
|
||||
confidenceRange: { min: 0, max: 1 },
|
||||
executionTimeRange: { min: 0, max: 10000 }
|
||||
}}
|
||||
onFiltersChange={(filtered) => {
|
||||
// Logique de filtrage à implémenter
|
||||
console.log('Evidence filtrées:', filtered);
|
||||
}}
|
||||
onClearFilters={() => {
|
||||
console.log('Filtres effacés');
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
|
||||
{/* Onglets */}
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={handleTabChange}
|
||||
variant="fullWidth"
|
||||
sx={{ borderBottom: 1, borderColor: 'divider' }}
|
||||
>
|
||||
<Tab
|
||||
label={
|
||||
<Badge badgeContent={evidenceStats.byCurrentStep} color="primary">
|
||||
Étape Actuelle
|
||||
</Badge>
|
||||
}
|
||||
icon={<InfoIcon />}
|
||||
/>
|
||||
<Tab
|
||||
label={
|
||||
<Badge badgeContent={evidenceStats.bySteps} color="primary">
|
||||
Par Étapes
|
||||
</Badge>
|
||||
}
|
||||
icon={<EvidenceIcon />}
|
||||
/>
|
||||
<Tab
|
||||
label={
|
||||
<Badge badgeContent={evidenceStats.total} color="primary">
|
||||
Timeline
|
||||
</Badge>
|
||||
}
|
||||
icon={<TimelineIcon />}
|
||||
/>
|
||||
</Tabs>
|
||||
|
||||
{/* Contenu des onglets */}
|
||||
<Box sx={{ flex: 1, overflow: 'auto' }}>
|
||||
<TabPanel value={activeTab} index={0}>
|
||||
{renderCurrentStep()}
|
||||
</TabPanel>
|
||||
<TabPanel value={activeTab} index={1}>
|
||||
{renderStepsList()}
|
||||
</TabPanel>
|
||||
<TabPanel value={activeTab} index={2}>
|
||||
{renderTimeline()}
|
||||
</TabPanel>
|
||||
</Box>
|
||||
|
||||
{/* Panneau de détail Evidence sélectionnée */}
|
||||
{selectedEvidence && (
|
||||
<Box sx={{ borderTop: 1, borderColor: 'divider' }}>
|
||||
<EvidenceDetail
|
||||
evidence={selectedEvidence}
|
||||
onClose={() => setSelectedEvidence(null)}
|
||||
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExecutionEvidencePanel;
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Composant ScreenshotViewer - Visualiseur de screenshots avec annotations
|
||||
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { Box, Paper } from '@mui/material';
|
||||
import { ScreenshotViewerProps, AnnotationData } from '../../types/evidence';
|
||||
|
||||
const ScreenshotViewer: React.FC<ScreenshotViewerProps> = ({
|
||||
screenshot,
|
||||
bbox,
|
||||
clickPoint,
|
||||
annotations = [],
|
||||
zoom = 1,
|
||||
onZoomChange,
|
||||
maxWidth = 800,
|
||||
maxHeight = 600
|
||||
}) => {
|
||||
// Références
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
|
||||
// État local
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
const [imageDimensions, setImageDimensions] = useState({ width: 0, height: 0 });
|
||||
const [isPanning, setIsPanning] = useState(false);
|
||||
const [panStart, setPanStart] = useState({ x: 0, y: 0 });
|
||||
const [panOffset, setPanOffset] = useState({ x: 0, y: 0 });
|
||||
|
||||
// Chargement de l'image
|
||||
const handleImageLoad = useCallback(() => {
|
||||
if (imageRef.current) {
|
||||
setImageDimensions({
|
||||
width: imageRef.current.naturalWidth,
|
||||
height: imageRef.current.naturalHeight
|
||||
});
|
||||
setImageLoaded(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Gestion du zoom avec la molette
|
||||
const handleWheel = useCallback((event: React.WheelEvent) => {
|
||||
if (!onZoomChange) return;
|
||||
|
||||
event.preventDefault();
|
||||
const delta = event.deltaY > 0 ? 0.9 : 1.1;
|
||||
const newZoom = Math.max(0.1, Math.min(5, zoom * delta));
|
||||
onZoomChange(newZoom);
|
||||
}, [zoom, onZoomChange]);
|
||||
|
||||
// Début du pan
|
||||
const handleMouseDown = useCallback((event: React.MouseEvent) => {
|
||||
if (zoom <= 1) return; // Pas de pan si pas de zoom
|
||||
|
||||
setIsPanning(true);
|
||||
setPanStart({
|
||||
x: event.clientX - panOffset.x,
|
||||
y: event.clientY - panOffset.y
|
||||
});
|
||||
}, [zoom, panOffset]);
|
||||
|
||||
// Pan en cours
|
||||
const handleMouseMove = useCallback((event: React.MouseEvent) => {
|
||||
if (!isPanning) return;
|
||||
|
||||
setPanOffset({
|
||||
x: event.clientX - panStart.x,
|
||||
y: event.clientY - panStart.y
|
||||
});
|
||||
}, [isPanning, panStart]);
|
||||
|
||||
// Fin du pan
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setIsPanning(false);
|
||||
}, []);
|
||||
|
||||
// Reset du pan quand le zoom change
|
||||
useEffect(() => {
|
||||
if (zoom <= 1) {
|
||||
setPanOffset({ x: 0, y: 0 });
|
||||
}
|
||||
}, [zoom]);
|
||||
|
||||
// Calcul des dimensions d'affichage
|
||||
const getDisplayDimensions = () => {
|
||||
if (!imageLoaded) return { width: maxWidth, height: maxHeight };
|
||||
|
||||
const aspectRatio = imageDimensions.width / imageDimensions.height;
|
||||
let displayWidth = imageDimensions.width;
|
||||
let displayHeight = imageDimensions.height;
|
||||
|
||||
// Ajustement aux dimensions max
|
||||
if (displayWidth > maxWidth) {
|
||||
displayWidth = maxWidth;
|
||||
displayHeight = displayWidth / aspectRatio;
|
||||
}
|
||||
|
||||
if (displayHeight > maxHeight) {
|
||||
displayHeight = maxHeight;
|
||||
displayWidth = displayHeight * aspectRatio;
|
||||
}
|
||||
|
||||
return {
|
||||
width: displayWidth * zoom,
|
||||
height: displayHeight * zoom
|
||||
};
|
||||
};
|
||||
|
||||
// Conversion des coordonnées d'annotation
|
||||
const convertCoordinates = (coord: { x: number; y: number; width?: number; height?: number }) => {
|
||||
if (!imageLoaded) return coord;
|
||||
|
||||
const displayDims = getDisplayDimensions();
|
||||
const scaleX = displayDims.width / imageDimensions.width;
|
||||
const scaleY = displayDims.height / imageDimensions.height;
|
||||
|
||||
return {
|
||||
x: coord.x * scaleX,
|
||||
y: coord.y * scaleY,
|
||||
width: coord.width ? coord.width * scaleX : undefined,
|
||||
height: coord.height ? coord.height * scaleY : undefined
|
||||
};
|
||||
};
|
||||
|
||||
// Rendu des annotations
|
||||
const renderAnnotations = () => {
|
||||
if (!imageLoaded) return null;
|
||||
|
||||
const allAnnotations: AnnotationData[] = [];
|
||||
|
||||
// Ajout de la bbox si présente
|
||||
if (bbox) {
|
||||
allAnnotations.push({
|
||||
id: 'bbox',
|
||||
type: 'bbox',
|
||||
position: { x: bbox.x, y: bbox.y },
|
||||
coordinates: bbox,
|
||||
label: 'Zone détectée',
|
||||
color: '#1976d2',
|
||||
opacity: 0.3,
|
||||
data: { type: 'bbox', bbox }
|
||||
});
|
||||
}
|
||||
|
||||
// Ajout du point de clic si présent
|
||||
if (clickPoint) {
|
||||
allAnnotations.push({
|
||||
id: 'click',
|
||||
type: 'click',
|
||||
position: { x: clickPoint.x, y: clickPoint.y },
|
||||
coordinates: clickPoint,
|
||||
label: 'Point de clic',
|
||||
color: '#ef4444',
|
||||
opacity: 1,
|
||||
data: { type: 'click', clickPoint }
|
||||
});
|
||||
}
|
||||
|
||||
// Ajout des annotations personnalisées
|
||||
allAnnotations.push(...annotations);
|
||||
|
||||
return allAnnotations.map(annotation => {
|
||||
const coords = annotation.coordinates ? convertCoordinates(annotation.coordinates) : { x: annotation.position.x, y: annotation.position.y };
|
||||
|
||||
if (annotation.type === 'click') {
|
||||
return (
|
||||
<div
|
||||
key={annotation.id}
|
||||
className="evidence-annotation click-point"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: coords.x - 6,
|
||||
top: coords.y - 6,
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: annotation.color || '#ef4444',
|
||||
border: '2px solid white',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.3)',
|
||||
zIndex: 10
|
||||
}}
|
||||
title={annotation.label}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (annotation.type === 'bbox' && coords.width && coords.height) {
|
||||
return (
|
||||
<div
|
||||
key={annotation.id}
|
||||
className="evidence-annotation bbox"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: coords.x,
|
||||
top: coords.y,
|
||||
width: coords.width,
|
||||
height: coords.height,
|
||||
border: `2px dashed ${annotation.color || '#1976d2'}`,
|
||||
backgroundColor: `${annotation.color || '#1976d2'}${Math.round((annotation.opacity || 0.3) * 255).toString(16).padStart(2, '0')}`,
|
||||
pointerEvents: 'none',
|
||||
zIndex: 5
|
||||
}}
|
||||
title={annotation.label}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
};
|
||||
|
||||
const displayDims = getDisplayDimensions();
|
||||
|
||||
return (
|
||||
<Paper
|
||||
ref={containerRef}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
display: 'inline-block',
|
||||
maxWidth: '100%',
|
||||
maxHeight: maxHeight,
|
||||
overflow: zoom > 1 ? 'auto' : 'hidden',
|
||||
cursor: zoom > 1 ? (isPanning ? 'grabbing' : 'grab') : 'default',
|
||||
backgroundColor: '#0f172a',
|
||||
border: '1px solid #334155'
|
||||
}}
|
||||
onWheel={handleWheel}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
>
|
||||
<div
|
||||
className="evidence-screenshot-container"
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'inline-block',
|
||||
transform: `translate(${panOffset.x}px, ${panOffset.y}px)`,
|
||||
transition: isPanning ? 'none' : 'transform 0.1s ease'
|
||||
}}
|
||||
>
|
||||
<img
|
||||
ref={imageRef}
|
||||
src={`data:image/png;base64,${screenshot}`}
|
||||
alt="Screenshot Evidence"
|
||||
className="evidence-screenshot"
|
||||
style={{
|
||||
width: displayDims.width,
|
||||
height: displayDims.height,
|
||||
display: 'block',
|
||||
userSelect: 'none',
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
onLoad={handleImageLoad}
|
||||
onError={() => setImageLoaded(false)}
|
||||
/>
|
||||
|
||||
{/* Annotations */}
|
||||
{renderAnnotations()}
|
||||
</div>
|
||||
|
||||
{/* Indicateur de chargement */}
|
||||
{!imageLoaded && (
|
||||
<Box
|
||||
position="absolute"
|
||||
top="50%"
|
||||
left="50%"
|
||||
sx={{
|
||||
transform: 'translate(-50%, -50%)',
|
||||
color: '#94a3b8',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
Chargement de l'image...
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScreenshotViewer;
|
||||
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* Composant Evidence Viewer VWB - Visualisation des preuves d'exécution
|
||||
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
Divider,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Fab,
|
||||
Tooltip,
|
||||
useTheme,
|
||||
useMediaQuery
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Refresh as RefreshIcon,
|
||||
GetApp as ExportIcon,
|
||||
FilterList as FilterIcon,
|
||||
ViewList as ListIcon,
|
||||
ViewModule as GridIcon
|
||||
} from '@mui/icons-material';
|
||||
|
||||
import { EvidenceViewerProps, VWBEvidence } from '../../types/evidence';
|
||||
import { useEvidenceViewer } from '../../hooks/useEvidenceViewer';
|
||||
import EvidenceList from './EvidenceList';
|
||||
import EvidenceDetail from './EvidenceDetail';
|
||||
import EvidenceFilters from './EvidenceFilters';
|
||||
import EvidenceStats from './EvidenceStats';
|
||||
|
||||
import './EvidenceViewer.css';
|
||||
|
||||
const EvidenceViewer: React.FC<EvidenceViewerProps> = ({
|
||||
evidences: externalEvidences,
|
||||
selectedEvidenceId: externalSelectedId,
|
||||
onEvidenceSelect,
|
||||
onExport,
|
||||
showFilters = true,
|
||||
maxHeight = 600,
|
||||
className = ''
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
|
||||
// État local
|
||||
const [viewMode, setViewMode] = useState<'list' | 'grid'>('list');
|
||||
const [showFiltersPanel, setShowFiltersPanel] = useState(false);
|
||||
const [showStatsPanel, setShowStatsPanel] = useState(true);
|
||||
|
||||
// Hook Evidence Viewer (utilisé seulement si pas d'Evidence externes)
|
||||
const {
|
||||
evidences: internalEvidences,
|
||||
filteredEvidences,
|
||||
selectedEvidence,
|
||||
stats,
|
||||
loading,
|
||||
error,
|
||||
filters,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
setSelectedEvidenceId: setInternalSelectedId,
|
||||
setFilters,
|
||||
setSorting,
|
||||
refreshEvidences,
|
||||
clearFilters,
|
||||
exportEvidences,
|
||||
hasFilters,
|
||||
isServiceAvailable
|
||||
} = useEvidenceViewer({
|
||||
autoRefresh: !externalEvidences, // Auto-refresh seulement si pas d'Evidence externes
|
||||
refreshInterval: 30000
|
||||
});
|
||||
|
||||
// Utilisation des Evidence externes ou internes
|
||||
const evidences = externalEvidences || internalEvidences;
|
||||
const displayedEvidences = externalEvidences || filteredEvidences;
|
||||
const currentSelectedId = externalSelectedId || selectedEvidence?.id;
|
||||
|
||||
// Gestion de la sélection d'Evidence
|
||||
const handleEvidenceSelect = useCallback((evidence: VWBEvidence) => {
|
||||
const evidenceId = evidence.id;
|
||||
if (onEvidenceSelect) {
|
||||
onEvidenceSelect(evidenceId);
|
||||
} else {
|
||||
setInternalSelectedId(evidenceId);
|
||||
}
|
||||
}, [onEvidenceSelect, setInternalSelectedId]);
|
||||
|
||||
// Gestion de l'export
|
||||
const handleExport = useCallback(async (format: 'json' | 'html' | 'pdf') => {
|
||||
if (onExport) {
|
||||
onExport(displayedEvidences);
|
||||
} else {
|
||||
await exportEvidences(format);
|
||||
}
|
||||
}, [onExport, displayedEvidences, exportEvidences]);
|
||||
|
||||
// Gestion du refresh
|
||||
const handleRefresh = useCallback(async () => {
|
||||
if (!externalEvidences) {
|
||||
await refreshEvidences();
|
||||
}
|
||||
}, [externalEvidences, refreshEvidences]);
|
||||
|
||||
// Evidence sélectionnée
|
||||
const selectedEvidenceData = currentSelectedId
|
||||
? evidences.find(e => e.id === currentSelectedId)
|
||||
: null;
|
||||
|
||||
// Rendu du contenu principal
|
||||
const renderContent = () => {
|
||||
if (loading && !externalEvidences) {
|
||||
return (
|
||||
<Box display="flex" justifyContent="center" alignItems="center" minHeight={200}>
|
||||
<CircularProgress size={40} />
|
||||
<Typography variant="body2" sx={{ ml: 2 }}>
|
||||
Chargement des Evidence...
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !externalEvidences) {
|
||||
return (
|
||||
<Alert severity="error" sx={{ m: 2 }}>
|
||||
{error}
|
||||
{!isServiceAvailable && (
|
||||
<Typography variant="caption" display="block" sx={{ mt: 1 }}>
|
||||
Le service Evidence n'est pas disponible. Vérifiez que le backend VWB est démarré.
|
||||
</Typography>
|
||||
)}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (displayedEvidences.length === 0) {
|
||||
return (
|
||||
<Box textAlign="center" py={4}>
|
||||
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||
Aucune Evidence disponible
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{hasFilters
|
||||
? 'Aucune Evidence ne correspond aux filtres appliqués.'
|
||||
: 'Aucune Evidence d\'exécution n\'a été trouvée.'}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box display="flex" height="100%">
|
||||
{/* Liste des Evidence */}
|
||||
<Box
|
||||
flex={selectedEvidenceData ? (isMobile ? 0 : 1) : 1}
|
||||
display={selectedEvidenceData && isMobile ? 'none' : 'block'}
|
||||
>
|
||||
<EvidenceList
|
||||
evidences={displayedEvidences}
|
||||
selectedId={currentSelectedId}
|
||||
onSelect={handleEvidenceSelect}
|
||||
filters={externalEvidences ? undefined : filters}
|
||||
onFiltersChange={externalEvidences ? undefined : setFilters}
|
||||
sortBy={sortBy}
|
||||
sortOrder={sortOrder}
|
||||
onSortChange={setSorting}
|
||||
viewMode={viewMode}
|
||||
showFilters={showFilters && showFiltersPanel}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Détail de l'Evidence sélectionnée */}
|
||||
{selectedEvidenceData && (
|
||||
<>
|
||||
<Divider orientation="vertical" flexItem />
|
||||
<Box
|
||||
flex={isMobile ? 1 : 1}
|
||||
display={selectedEvidenceData ? 'block' : 'none'}
|
||||
>
|
||||
<EvidenceDetail
|
||||
evidence={selectedEvidenceData}
|
||||
onClose={isMobile ? () => setInternalSelectedId('') : undefined}
|
||||
showMetadata={true}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper
|
||||
className={`evidence-viewer ${className}`}
|
||||
sx={{
|
||||
height: maxHeight,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
role="region"
|
||||
aria-label="Visualiseur d'Evidence VWB"
|
||||
>
|
||||
{/* En-tête avec statistiques */}
|
||||
{showStatsPanel && !externalEvidences && (
|
||||
<>
|
||||
<EvidenceStats
|
||||
stats={stats}
|
||||
totalEvidences={evidences.length}
|
||||
filteredCount={displayedEvidences.length}
|
||||
hasFilters={hasFilters}
|
||||
/>
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Panneau de filtres */}
|
||||
{showFilters && showFiltersPanel && !externalEvidences && (
|
||||
<>
|
||||
<EvidenceFilters
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
onClearFilters={clearFilters}
|
||||
evidences={evidences}
|
||||
/>
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Contenu principal */}
|
||||
<Box flex={1} overflow="hidden">
|
||||
{renderContent()}
|
||||
</Box>
|
||||
|
||||
{/* Boutons d'action flottants */}
|
||||
<Box className="evidence-viewer-fabs">
|
||||
{/* Bouton Refresh */}
|
||||
{!externalEvidences && (
|
||||
<Tooltip title="Actualiser les Evidence">
|
||||
<Fab
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
aria-label="Actualiser les Evidence"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 16,
|
||||
right: 16,
|
||||
zIndex: 1000
|
||||
}}
|
||||
>
|
||||
<RefreshIcon />
|
||||
</Fab>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Bouton Export */}
|
||||
<Tooltip title="Exporter les Evidence">
|
||||
<Fab
|
||||
size="small"
|
||||
color="secondary"
|
||||
onClick={() => handleExport('html')}
|
||||
aria-label="Exporter les Evidence"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 16,
|
||||
right: 80,
|
||||
zIndex: 1000
|
||||
}}
|
||||
>
|
||||
<ExportIcon />
|
||||
</Fab>
|
||||
</Tooltip>
|
||||
|
||||
{/* Bouton Filtres */}
|
||||
{showFilters && !externalEvidences && (
|
||||
<Tooltip title={showFiltersPanel ? "Masquer les filtres" : "Afficher les filtres"}>
|
||||
<Fab
|
||||
size="small"
|
||||
color={showFiltersPanel ? "primary" : "default"}
|
||||
onClick={() => setShowFiltersPanel(!showFiltersPanel)}
|
||||
aria-label={showFiltersPanel ? "Masquer les filtres" : "Afficher les filtres"}
|
||||
aria-pressed={showFiltersPanel}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 16,
|
||||
right: 144,
|
||||
zIndex: 1000
|
||||
}}
|
||||
>
|
||||
<FilterIcon />
|
||||
</Fab>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Bouton Mode d'affichage */}
|
||||
<Tooltip title={viewMode === 'list' ? "Vue grille" : "Vue liste"}>
|
||||
<Fab
|
||||
size="small"
|
||||
color="default"
|
||||
onClick={() => setViewMode(viewMode === 'list' ? 'grid' : 'list')}
|
||||
aria-label={viewMode === 'list' ? "Basculer en vue grille" : "Basculer en vue liste"}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 16,
|
||||
right: 208,
|
||||
zIndex: 1000
|
||||
}}
|
||||
>
|
||||
{viewMode === 'list' ? <GridIcon /> : <ListIcon />}
|
||||
</Fab>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default EvidenceViewer;
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* Styles CSS pour les Contrôles d'Exécution VWB
|
||||
* Auteur : Dom, Alice, Kiro - 11 janvier 2026
|
||||
*/
|
||||
|
||||
.execution-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.execution-controls-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.execution-controls-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.execution-controls-advanced {
|
||||
background: #f5f5f5;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.execution-controls-settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.execution-controls-slider-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.execution-controls-switches {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.execution-controls-status {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
background: #e3f2fd;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #2196f3;
|
||||
}
|
||||
|
||||
.execution-controls-status.paused {
|
||||
background: #fff3e0;
|
||||
border-left-color: #ff9800;
|
||||
}
|
||||
|
||||
.execution-controls-status.error {
|
||||
background: #ffebee;
|
||||
border-left-color: #f44336;
|
||||
}
|
||||
|
||||
.execution-controls-stats {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.execution-controls-breakpoint-indicator {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.execution-controls-breakpoint-indicator::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
right: -2px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #ff5722;
|
||||
border-radius: 50%;
|
||||
border: 2px solid white;
|
||||
}
|
||||
|
||||
.execution-controls-save-dialog {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: white;
|
||||
padding: 24px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
||||
min-width: 320px;
|
||||
z-index: 1300;
|
||||
}
|
||||
|
||||
.execution-controls-save-dialog-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1299;
|
||||
}
|
||||
|
||||
/* Animations pour les états d'exécution */
|
||||
@keyframes execution-pulse {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
.execution-controls-running {
|
||||
animation: execution-pulse 2s infinite;
|
||||
}
|
||||
|
||||
.execution-controls-paused {
|
||||
animation: execution-pulse 1s infinite;
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.execution-controls-buttons {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.execution-controls-settings-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.execution-controls-switches {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.execution-controls-status {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mode sombre */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.execution-controls {
|
||||
background: #1e1e1e;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.execution-controls-advanced {
|
||||
background: #2d2d2d;
|
||||
border-color: #404040;
|
||||
}
|
||||
|
||||
.execution-controls-save-dialog {
|
||||
background: #2d2d2d;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Indicateurs de performance */
|
||||
.execution-controls-performance-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.execution-controls-performance-indicator.fast {
|
||||
background: #e8f5e8;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.execution-controls-performance-indicator.normal {
|
||||
background: #fff3e0;
|
||||
color: #f57c00;
|
||||
}
|
||||
|
||||
.execution-controls-performance-indicator.slow {
|
||||
background: #ffebee;
|
||||
color: #d32f2f;
|
||||
}
|
||||
|
||||
/* Transitions fluides */
|
||||
.execution-controls * {
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.execution-controls button:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.execution-controls button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Indicateurs d'état spéciaux */
|
||||
.execution-controls-step-indicator {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.execution-controls-step-indicator.current::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: -8px;
|
||||
transform: translateY(-50%);
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 6px solid #2196f3;
|
||||
border-top: 4px solid transparent;
|
||||
border-bottom: 4px solid transparent;
|
||||
}
|
||||
|
||||
.execution-controls-step-indicator.breakpoint::before {
|
||||
content: '●';
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
color: #ff5722;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
@@ -0,0 +1,746 @@
|
||||
/**
|
||||
* Contrôles d'Exécution VWB - Interface de contrôle avancée pour l'exécution des workflows
|
||||
* Auteur : Dom, Alice, Kiro - 11 janvier 2026
|
||||
*
|
||||
* Ce composant fournit une interface complète de contrôle d'exécution avec :
|
||||
* - Contrôles play/pause/stop
|
||||
* - Mode pas-à-pas pour le débogage
|
||||
* - Sauvegarde/restauration d'état d'exécution
|
||||
* - Gestion avancée des breakpoints
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Divider,
|
||||
Switch,
|
||||
FormControlLabel,
|
||||
Slider,
|
||||
Tooltip,
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Chip,
|
||||
Alert,
|
||||
Collapse,
|
||||
TextField,
|
||||
InputAdornment,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
PlayArrow as PlayIcon,
|
||||
Pause as PauseIcon,
|
||||
Stop as StopIcon,
|
||||
SkipNext as StepIcon,
|
||||
Replay as ResetIcon,
|
||||
Save as SaveIcon,
|
||||
Restore as RestoreIcon,
|
||||
Settings as SettingsIcon,
|
||||
Speed as SpeedIcon,
|
||||
BugReport as DebugIcon,
|
||||
Timeline as TimelineIcon,
|
||||
Bookmark as BookmarkIcon,
|
||||
BookmarkBorder as BookmarkBorderIcon,
|
||||
MoreVert as MoreIcon,
|
||||
Schedule as ScheduleIcon,
|
||||
Memory as MemoryIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
// Import des hooks et types
|
||||
import { useVWBExecution, VWBExecutionState } from '../../hooks/useVWBExecution';
|
||||
import { Step, Workflow, Variable } from '../../types';
|
||||
|
||||
export interface ExecutionControlsProps {
|
||||
workflow: Workflow;
|
||||
variables: Variable[];
|
||||
executionState: VWBExecutionState;
|
||||
onStepStateChange?: (stepId: string, state: any) => void;
|
||||
onExecutionComplete?: (success: boolean, summary: any) => void;
|
||||
onEvidenceGenerated?: (stepId: string, evidence: any[]) => void;
|
||||
debugMode?: boolean;
|
||||
onDebugModeChange?: (enabled: boolean) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface ExecutionSettings {
|
||||
autoValidate: boolean;
|
||||
generateEvidence: boolean;
|
||||
retryAttempts: number;
|
||||
timeout: number;
|
||||
pauseOnError: boolean;
|
||||
skipNonVWBSteps: boolean;
|
||||
stepDelay: number;
|
||||
enableBreakpoints: boolean;
|
||||
}
|
||||
|
||||
export interface ExecutionSaveState {
|
||||
id: string;
|
||||
name: string;
|
||||
timestamp: Date;
|
||||
workflowId: string;
|
||||
currentStepIndex: number;
|
||||
variables: Variable[];
|
||||
settings: ExecutionSettings;
|
||||
results: any[];
|
||||
evidence: any[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Composant principal des contrôles d'exécution
|
||||
*/
|
||||
const ExecutionControls: React.FC<ExecutionControlsProps> = ({
|
||||
workflow,
|
||||
variables,
|
||||
executionState,
|
||||
onStepStateChange,
|
||||
onExecutionComplete,
|
||||
onEvidenceGenerated,
|
||||
debugMode = false,
|
||||
onDebugModeChange,
|
||||
className,
|
||||
}) => {
|
||||
// États locaux
|
||||
const [settings, setSettings] = useState<ExecutionSettings>({
|
||||
autoValidate: true,
|
||||
generateEvidence: true,
|
||||
retryAttempts: 3,
|
||||
timeout: 30000,
|
||||
pauseOnError: false,
|
||||
skipNonVWBSteps: false,
|
||||
stepDelay: 0,
|
||||
enableBreakpoints: false,
|
||||
});
|
||||
|
||||
const [stepByStepMode, setStepByStepMode] = useState(false);
|
||||
const [breakpoints, setBreakpoints] = useState<Set<string>>(new Set());
|
||||
const [savedStates, setSavedStates] = useState<ExecutionSaveState[]>([]);
|
||||
const [settingsMenuAnchor, setSettingsMenuAnchor] = useState<null | HTMLElement>(null);
|
||||
const [saveStateDialogOpen, setSaveStateDialogOpen] = useState(false);
|
||||
const [saveStateName, setSaveStateName] = useState('');
|
||||
const [showAdvancedControls, setShowAdvancedControls] = useState(false);
|
||||
|
||||
// Hook d'exécution VWB avec paramètres dynamiques
|
||||
const {
|
||||
isRunning,
|
||||
isPaused,
|
||||
canStart,
|
||||
canPause,
|
||||
canResume,
|
||||
canStop,
|
||||
startExecution,
|
||||
pauseExecution,
|
||||
resumeExecution,
|
||||
stopExecution,
|
||||
resetExecution,
|
||||
getExecutionSummary,
|
||||
isVWBStep,
|
||||
} = useVWBExecution(
|
||||
workflow,
|
||||
variables,
|
||||
{
|
||||
onStepStart: (step, index) => {
|
||||
// Vérifier les breakpoints
|
||||
if (settings.enableBreakpoints && breakpoints.has(step.id)) {
|
||||
pauseExecution();
|
||||
}
|
||||
onStepStateChange?.(step.id, 'running');
|
||||
},
|
||||
onStepComplete: (step, result) => {
|
||||
onStepStateChange?.(step.id, result.success ? 'success' : 'error');
|
||||
if (result.evidence) {
|
||||
onEvidenceGenerated?.(step.id, result.evidence);
|
||||
}
|
||||
|
||||
// Mode pas-à-pas : pause après chaque étape
|
||||
if (stepByStepMode && isRunning) {
|
||||
setTimeout(() => pauseExecution(), 100);
|
||||
}
|
||||
},
|
||||
onStepError: (step, error) => {
|
||||
onStepStateChange?.(step.id, 'error');
|
||||
},
|
||||
onExecutionComplete: (success, summary) => {
|
||||
onExecutionComplete?.(success, summary);
|
||||
},
|
||||
onEvidenceGenerated: onEvidenceGenerated,
|
||||
},
|
||||
{
|
||||
autoValidate: settings.autoValidate,
|
||||
generateEvidence: settings.generateEvidence,
|
||||
retryAttempts: settings.retryAttempts,
|
||||
timeout: settings.timeout,
|
||||
pauseOnError: settings.pauseOnError,
|
||||
skipNonVWBSteps: settings.skipNonVWBSteps,
|
||||
}
|
||||
);
|
||||
|
||||
// Charger les états sauvegardés depuis localStorage
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem('vwb_execution_saved_states');
|
||||
if (saved) {
|
||||
try {
|
||||
const states = JSON.parse(saved);
|
||||
setSavedStates(states.map((state: any) => ({
|
||||
...state,
|
||||
timestamp: new Date(state.timestamp),
|
||||
})));
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement des états sauvegardés:', error);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Sauvegarder les états dans localStorage
|
||||
const saveSavedStates = useCallback((states: ExecutionSaveState[]) => {
|
||||
try {
|
||||
localStorage.setItem('vwb_execution_saved_states', JSON.stringify(states));
|
||||
setSavedStates(states);
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la sauvegarde des états:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Gestionnaire d'exécution avec délai
|
||||
const handleStartExecution = useCallback(async () => {
|
||||
if (settings.stepDelay > 0) {
|
||||
// Implémentation du délai entre étapes (simulation)
|
||||
console.log(`Démarrage avec délai de ${settings.stepDelay}ms entre les étapes`);
|
||||
}
|
||||
await startExecution();
|
||||
}, [startExecution, settings.stepDelay]);
|
||||
|
||||
// Gestionnaire d'exécution pas-à-pas
|
||||
const handleStepByStepExecution = useCallback(() => {
|
||||
setStepByStepMode(true);
|
||||
handleStartExecution();
|
||||
}, [handleStartExecution]);
|
||||
|
||||
// Gestionnaire de reprise pas-à-pas
|
||||
const handleStepByStepResume = useCallback(() => {
|
||||
if (isPaused && stepByStepMode) {
|
||||
resumeExecution();
|
||||
}
|
||||
}, [isPaused, stepByStepMode, resumeExecution]);
|
||||
|
||||
// Gestionnaire d'arrêt
|
||||
const handleStopExecution = useCallback(() => {
|
||||
setStepByStepMode(false);
|
||||
stopExecution();
|
||||
}, [stopExecution]);
|
||||
|
||||
// Gestionnaire de réinitialisation
|
||||
const handleResetExecution = useCallback(() => {
|
||||
setStepByStepMode(false);
|
||||
setBreakpoints(new Set());
|
||||
resetExecution();
|
||||
}, [resetExecution]);
|
||||
|
||||
// Basculer un breakpoint
|
||||
const toggleBreakpoint = useCallback((stepId: string) => {
|
||||
setBreakpoints(prev => {
|
||||
const newBreakpoints = new Set(prev);
|
||||
if (newBreakpoints.has(stepId)) {
|
||||
newBreakpoints.delete(stepId);
|
||||
} else {
|
||||
newBreakpoints.add(stepId);
|
||||
}
|
||||
return newBreakpoints;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Sauvegarder l'état d'exécution
|
||||
const saveExecutionState = useCallback(() => {
|
||||
if (!saveStateName.trim()) return;
|
||||
|
||||
const saveState: ExecutionSaveState = {
|
||||
id: `state_${Date.now()}`,
|
||||
name: saveStateName.trim(),
|
||||
timestamp: new Date(),
|
||||
workflowId: workflow.id,
|
||||
currentStepIndex: executionState.currentStepIndex,
|
||||
variables,
|
||||
settings,
|
||||
results: executionState.results,
|
||||
evidence: executionState.evidence,
|
||||
};
|
||||
|
||||
const newStates = [...savedStates, saveState];
|
||||
saveSavedStates(newStates);
|
||||
setSaveStateName('');
|
||||
setSaveStateDialogOpen(false);
|
||||
}, [saveStateName, workflow.id, executionState, variables, settings, savedStates, saveSavedStates]);
|
||||
|
||||
// Restaurer un état d'exécution
|
||||
const restoreExecutionState = useCallback((saveState: ExecutionSaveState) => {
|
||||
// Arrêter l'exécution en cours
|
||||
if (isRunning) {
|
||||
stopExecution();
|
||||
}
|
||||
|
||||
// Restaurer les paramètres
|
||||
setSettings(saveState.settings);
|
||||
|
||||
// Note: La restauration complète nécessiterait une intégration plus profonde
|
||||
// avec le système de gestion d'état du workflow
|
||||
console.log('Restauration de l\'état:', saveState);
|
||||
|
||||
setSettingsMenuAnchor(null);
|
||||
}, [isRunning, stopExecution]);
|
||||
|
||||
// Supprimer un état sauvegardé
|
||||
const deleteSavedState = useCallback((stateId: string) => {
|
||||
const newStates = savedStates.filter(state => state.id !== stateId);
|
||||
saveSavedStates(newStates);
|
||||
}, [savedStates, saveSavedStates]);
|
||||
|
||||
// Statistiques d'exécution
|
||||
const executionStats = useMemo(() => {
|
||||
const vwbSteps = workflow.steps.filter(step => isVWBStep(step));
|
||||
return {
|
||||
totalSteps: workflow.steps.length,
|
||||
vwbSteps: vwbSteps.length,
|
||||
breakpointsSet: breakpoints.size,
|
||||
estimatedDuration: workflow.steps.length * (settings.stepDelay + 1000), // Estimation
|
||||
};
|
||||
}, [workflow.steps, isVWBStep, breakpoints.size, settings.stepDelay]);
|
||||
|
||||
return (
|
||||
<Box className={className} sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{/* Contrôles principaux */}
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6" component="h3">
|
||||
Contrôles d'Exécution
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Chip
|
||||
label={`${executionStats.totalSteps} étapes`}
|
||||
size="small"
|
||||
icon={<TimelineIcon />}
|
||||
/>
|
||||
<Chip
|
||||
label={`${executionStats.vwbSteps} VWB`}
|
||||
size="small"
|
||||
color="primary"
|
||||
/>
|
||||
{breakpoints.size > 0 && (
|
||||
<Chip
|
||||
label={`${breakpoints.size} breakpoints`}
|
||||
size="small"
|
||||
color="warning"
|
||||
icon={<BookmarkIcon />}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Boutons de contrôle principaux */}
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 2, flexWrap: 'wrap' }}>
|
||||
<ButtonGroup variant="contained" size="large">
|
||||
{/* Bouton Exécuter - toujours visible */}
|
||||
<Tooltip title={canStart ? "Démarrer l'exécution complète" : "Exécution en cours ou pas d'étapes"}>
|
||||
<span>
|
||||
<Button
|
||||
startIcon={<PlayIcon />}
|
||||
onClick={() => {
|
||||
console.log('🔘 [Controls] Clic sur Exécuter', { canStart, stepsLength: workflow.steps.length });
|
||||
handleStartExecution();
|
||||
}}
|
||||
color="success"
|
||||
disabled={!canStart || workflow.steps.length === 0}
|
||||
>
|
||||
Exécuter ({workflow.steps.length})
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
{canPause && (
|
||||
<Tooltip title="Mettre en pause">
|
||||
<Button
|
||||
startIcon={<PauseIcon />}
|
||||
onClick={pauseExecution}
|
||||
color="warning"
|
||||
>
|
||||
Pause
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{canResume && (
|
||||
<Tooltip title="Reprendre l'exécution">
|
||||
<Button
|
||||
startIcon={<PlayIcon />}
|
||||
onClick={resumeExecution}
|
||||
color="success"
|
||||
>
|
||||
Reprendre
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{canStop && (
|
||||
<Tooltip title="Arrêter l'exécution">
|
||||
<Button
|
||||
startIcon={<StopIcon />}
|
||||
onClick={handleStopExecution}
|
||||
color="error"
|
||||
>
|
||||
Arrêter
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
|
||||
{/* Contrôles pas-à-pas */}
|
||||
<ButtonGroup variant="outlined">
|
||||
{canStart && (
|
||||
<Tooltip title="Exécution pas-à-pas">
|
||||
<Button
|
||||
startIcon={<StepIcon />}
|
||||
onClick={handleStepByStepExecution}
|
||||
disabled={workflow.steps.length === 0}
|
||||
>
|
||||
Pas-à-pas
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{stepByStepMode && isPaused && (
|
||||
<Tooltip title="Étape suivante">
|
||||
<Button
|
||||
startIcon={<StepIcon />}
|
||||
onClick={handleStepByStepResume}
|
||||
color="primary"
|
||||
>
|
||||
Suivant
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
|
||||
{/* Contrôles de gestion d'état */}
|
||||
<ButtonGroup variant="outlined">
|
||||
<Tooltip title="Réinitialiser">
|
||||
<Button
|
||||
startIcon={<ResetIcon />}
|
||||
onClick={handleResetExecution}
|
||||
disabled={isRunning}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Sauvegarder l'état">
|
||||
<Button
|
||||
startIcon={<SaveIcon />}
|
||||
onClick={() => setSaveStateDialogOpen(true)}
|
||||
disabled={executionState.status === 'idle'}
|
||||
>
|
||||
Sauver
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Paramètres et états sauvegardés">
|
||||
<IconButton
|
||||
onClick={(e) => setSettingsMenuAnchor(e.currentTarget)}
|
||||
>
|
||||
<SettingsIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</ButtonGroup>
|
||||
</Box>
|
||||
|
||||
{/* Mode debug et contrôles avancés */}
|
||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={debugMode}
|
||||
onChange={(e) => onDebugModeChange?.(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Mode Debug"
|
||||
/>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={stepByStepMode}
|
||||
onChange={(e) => setStepByStepMode(e.target.checked)}
|
||||
disabled={isRunning}
|
||||
/>
|
||||
}
|
||||
label="Pas-à-pas"
|
||||
/>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={settings.enableBreakpoints}
|
||||
onChange={(e) => setSettings(prev => ({ ...prev, enableBreakpoints: e.target.checked }))}
|
||||
/>
|
||||
}
|
||||
label="Breakpoints"
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="text"
|
||||
size="small"
|
||||
onClick={() => setShowAdvancedControls(!showAdvancedControls)}
|
||||
>
|
||||
{showAdvancedControls ? 'Masquer' : 'Afficher'} les contrôles avancés
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Contrôles avancés */}
|
||||
<Collapse in={showAdvancedControls}>
|
||||
<Box sx={{ mt: 2, p: 2, bgcolor: 'grey.50', borderRadius: 1 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Paramètres d'Exécution
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))', gap: 2 }}>
|
||||
{/* Délai entre étapes */}
|
||||
<Box>
|
||||
<Typography variant="body2" gutterBottom>
|
||||
Délai entre étapes: {settings.stepDelay}ms
|
||||
</Typography>
|
||||
<Slider
|
||||
value={settings.stepDelay}
|
||||
onChange={(_, value) => setSettings(prev => ({ ...prev, stepDelay: value as number }))}
|
||||
min={0}
|
||||
max={5000}
|
||||
step={100}
|
||||
disabled={isRunning}
|
||||
valueLabelDisplay="auto"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Timeout */}
|
||||
<Box>
|
||||
<Typography variant="body2" gutterBottom>
|
||||
Timeout: {settings.timeout / 1000}s
|
||||
</Typography>
|
||||
<Slider
|
||||
value={settings.timeout}
|
||||
onChange={(_, value) => setSettings(prev => ({ ...prev, timeout: value as number }))}
|
||||
min={5000}
|
||||
max={120000}
|
||||
step={5000}
|
||||
disabled={isRunning}
|
||||
valueLabelDisplay="auto"
|
||||
valueLabelFormat={(value) => `${value / 1000}s`}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Tentatives de retry */}
|
||||
<Box>
|
||||
<Typography variant="body2" gutterBottom>
|
||||
Tentatives de retry: {settings.retryAttempts}
|
||||
</Typography>
|
||||
<Slider
|
||||
value={settings.retryAttempts}
|
||||
onChange={(_, value) => setSettings(prev => ({ ...prev, retryAttempts: value as number }))}
|
||||
min={0}
|
||||
max={10}
|
||||
step={1}
|
||||
disabled={isRunning}
|
||||
valueLabelDisplay="auto"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 2, display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={settings.autoValidate}
|
||||
onChange={(e) => setSettings(prev => ({ ...prev, autoValidate: e.target.checked }))}
|
||||
disabled={isRunning}
|
||||
/>
|
||||
}
|
||||
label="Validation automatique"
|
||||
/>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={settings.generateEvidence}
|
||||
onChange={(e) => setSettings(prev => ({ ...prev, generateEvidence: e.target.checked }))}
|
||||
disabled={isRunning}
|
||||
/>
|
||||
}
|
||||
label="Générer Evidence"
|
||||
/>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={settings.pauseOnError}
|
||||
onChange={(e) => setSettings(prev => ({ ...prev, pauseOnError: e.target.checked }))}
|
||||
disabled={isRunning}
|
||||
/>
|
||||
}
|
||||
label="Pause sur erreur"
|
||||
/>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={settings.skipNonVWBSteps}
|
||||
onChange={(e) => setSettings(prev => ({ ...prev, skipNonVWBSteps: e.target.checked }))}
|
||||
disabled={isRunning}
|
||||
/>
|
||||
}
|
||||
label="Ignorer étapes non-VWB"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Informations d'état */}
|
||||
{(isRunning || isPaused) && (
|
||||
<Alert severity={isPaused ? 'warning' : 'info'}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Box>
|
||||
<Typography variant="body2">
|
||||
{isPaused ? 'Exécution en pause' : 'Exécution en cours'}
|
||||
{stepByStepMode && ' (Mode pas-à-pas)'}
|
||||
</Typography>
|
||||
{executionState.currentStep && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Étape actuelle: {executionState.currentStep.name}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Chip
|
||||
label={`${executionState.completedSteps}/${executionState.totalSteps}`}
|
||||
size="small"
|
||||
icon={<ScheduleIcon />}
|
||||
/>
|
||||
{executionState.evidence.length > 0 && (
|
||||
<Chip
|
||||
label={`${executionState.evidence.length} Evidence`}
|
||||
size="small"
|
||||
color="info"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Menu des paramètres */}
|
||||
<Menu
|
||||
anchorEl={settingsMenuAnchor}
|
||||
open={Boolean(settingsMenuAnchor)}
|
||||
onClose={() => setSettingsMenuAnchor(null)}
|
||||
>
|
||||
<MenuItem onClick={() => setShowAdvancedControls(!showAdvancedControls)}>
|
||||
<ListItemIcon>
|
||||
<SettingsIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Paramètres avancés" />
|
||||
</MenuItem>
|
||||
|
||||
<Divider />
|
||||
|
||||
{savedStates.length > 0 && (
|
||||
<>
|
||||
<MenuItem disabled>
|
||||
<ListItemText primary="États sauvegardés" />
|
||||
</MenuItem>
|
||||
{savedStates.slice(-5).map((state) => (
|
||||
<MenuItem
|
||||
key={state.id}
|
||||
onClick={() => restoreExecutionState(state)}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<RestoreIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={state.name}
|
||||
secondary={state.timestamp.toLocaleString()}
|
||||
/>
|
||||
</MenuItem>
|
||||
))}
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
|
||||
<MenuItem onClick={() => setSaveStateDialogOpen(true)}>
|
||||
<ListItemIcon>
|
||||
<SaveIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Sauvegarder l'état actuel" />
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
{/* Dialog de sauvegarde d'état */}
|
||||
{saveStateDialogOpen && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
bgcolor: 'background.paper',
|
||||
boxShadow: 24,
|
||||
p: 3,
|
||||
borderRadius: 2,
|
||||
minWidth: 300,
|
||||
zIndex: 1300,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Sauvegarder l'État d'Exécution
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Nom de la sauvegarde"
|
||||
value={saveStateName}
|
||||
onChange={(e) => setSaveStateName(e.target.value)}
|
||||
margin="normal"
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SaveIcon />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', gap: 2, mt: 2 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={saveExecutionState}
|
||||
disabled={!saveStateName.trim()}
|
||||
>
|
||||
Sauvegarder
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => {
|
||||
setSaveStateDialogOpen(false);
|
||||
setSaveStateName('');
|
||||
}}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExecutionControls;
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Index des Contrôles d'Exécution VWB
|
||||
* Auteur : Dom, Alice, Kiro - 11 janvier 2026
|
||||
*/
|
||||
|
||||
export { default as ExecutionControls } from './ExecutionControls';
|
||||
export type {
|
||||
ExecutionControlsProps,
|
||||
ExecutionSettings,
|
||||
ExecutionSaveState
|
||||
} from './ExecutionControls';
|
||||
@@ -232,8 +232,8 @@ const VWBExecutorExtension: React.FC<VWBExecutorExtensionProps> = ({
|
||||
// Déterminer l'URL de l'API
|
||||
const hostname = window.location.hostname;
|
||||
const apiBase = (hostname === 'localhost' || hostname === '127.0.0.1')
|
||||
? 'http://localhost:5003/api'
|
||||
: `http://${hostname}:5003/api`;
|
||||
? 'http://localhost:5001/api'
|
||||
: `http://${hostname}:5000/api`;
|
||||
|
||||
const response = await fetch(`${apiBase}/workflows/${workflow.id}/feedback`, {
|
||||
method: 'POST',
|
||||
@@ -385,17 +385,24 @@ const VWBExecutorExtension: React.FC<VWBExecutorExtensionProps> = ({
|
||||
</Box>
|
||||
|
||||
{/* Contrôles d'exécution */}
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 2 }}>
|
||||
{canStart && (
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<PlayIcon />}
|
||||
onClick={startExecution}
|
||||
disabled={!canExecute || workflow.steps.length === 0}
|
||||
color="success"
|
||||
>
|
||||
Exécuter VWB
|
||||
</Button>
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 2, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{/* Bouton Exécuter - toujours visible */}
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<PlayIcon />}
|
||||
onClick={() => {
|
||||
console.log('🔘 [VWB UI] Clic sur Exécuter VWB', { canStart, canExecute, stepsLength: workflow.steps.length });
|
||||
startExecution();
|
||||
}}
|
||||
disabled={!canStart || !canExecute || workflow.steps.length === 0}
|
||||
color="success"
|
||||
>
|
||||
Exécuter VWB ({workflow.steps.length} étapes)
|
||||
</Button>
|
||||
|
||||
{/* Message d'état */}
|
||||
{!canStart && executionState.status === 'running' && (
|
||||
<Chip label="Exécution en cours..." color="primary" size="small" />
|
||||
)}
|
||||
|
||||
{canPause && (
|
||||
|
||||
@@ -484,30 +484,13 @@ const StandardExecutor: React.FC<StandardExecutorProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Exécuter une étape individuelle avec le nouveau client API ou en simulation locale
|
||||
// Exécuter une étape individuelle avec le nouveau client API
|
||||
// NOTE: On n'utilise plus isOffline comme bloqueur - on essaie toujours l'API
|
||||
const executeStep = useCallback(async (step: Step): Promise<StepExecutionResult> => {
|
||||
const startTime = Date.now();
|
||||
const stepId = step.id;
|
||||
const currentRetries = retryAttempts.get(stepId) || 0;
|
||||
|
||||
// Mode simulation locale si API hors ligne
|
||||
if (isOffline) {
|
||||
// Simuler un délai d'exécution réaliste
|
||||
await new Promise(resolve => setTimeout(resolve, 300 + Math.random() * 500));
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// Simulation : succès pour la plupart des étapes
|
||||
const simulatedSuccess = Math.random() > 0.1; // 90% de succès en simulation
|
||||
|
||||
return {
|
||||
stepId: step.id,
|
||||
success: simulatedSuccess,
|
||||
duration,
|
||||
output: simulatedSuccess ? { simulated: true, stepType: step.type } : undefined,
|
||||
error: simulatedSuccess ? undefined : 'Erreur simulée pour démonstration',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Utiliser le nouveau client API pour l'exécution
|
||||
const result = await executionApi.executeStep({
|
||||
@@ -570,7 +553,7 @@ const StandardExecutor: React.FC<StandardExecutorProps> = ({
|
||||
error: apiError.message || 'Erreur inconnue',
|
||||
};
|
||||
}
|
||||
}, [executionApi, workflow.id, retryAttempts, isOffline]);
|
||||
}, [executionApi, workflow.id, retryAttempts]);
|
||||
|
||||
// Déterminer si une étape doit être retentée
|
||||
const shouldRetryStep = useCallback((error: ApiError): boolean => {
|
||||
|
||||
@@ -0,0 +1,519 @@
|
||||
/**
|
||||
* Composant Glossaire - Dictionnaire des termes techniques français
|
||||
* Auteur : Dom, Alice, Kiro - 08 janvier 2026
|
||||
*
|
||||
* Ce composant fournit un glossaire accessible des termes techniques
|
||||
* utilisés dans le Visual Workflow Builder, avec recherche et navigation.
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
TextField,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
InputAdornment,
|
||||
Chip,
|
||||
Divider,
|
||||
Paper,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Search as SearchIcon,
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
Book as BookIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface GlossaryTerm {
|
||||
id: string;
|
||||
term: string;
|
||||
definition: string;
|
||||
category: string;
|
||||
synonyms?: string[];
|
||||
relatedTerms?: string[];
|
||||
examples?: string[];
|
||||
}
|
||||
|
||||
interface GlossaryProps {
|
||||
searchTerm?: string;
|
||||
onTermSelect?: (termId: string) => void;
|
||||
}
|
||||
|
||||
// Base de données des termes du glossaire
|
||||
const glossaryTerms: GlossaryTerm[] = [
|
||||
// Termes généraux
|
||||
{
|
||||
id: 'workflow',
|
||||
term: 'Workflow',
|
||||
definition: 'Séquence d\'étapes automatisées qui reproduit un processus métier ou une tâche répétitive. Dans RPA Vision, un workflow est composé d\'étapes connectées qui s\'exécutent dans un ordre logique.',
|
||||
category: 'Général',
|
||||
synonyms: ['Flux de travail', 'Processus automatisé'],
|
||||
relatedTerms: ['etape', 'connexion', 'execution'],
|
||||
examples: ['Workflow de saisie de données', 'Workflow de validation de formulaires']
|
||||
},
|
||||
{
|
||||
id: 'etape',
|
||||
term: 'Étape',
|
||||
definition: 'Unité d\'action élémentaire dans un workflow. Chaque étape effectue une action spécifique comme cliquer, saisir du texte, ou extraire des données.',
|
||||
category: 'Général',
|
||||
synonyms: ['Action', 'Nœud', 'Tâche'],
|
||||
relatedTerms: ['workflow', 'parametres', 'connexion'],
|
||||
examples: ['Étape "Cliquer"', 'Étape "Saisir du texte"', 'Étape "Attendre"']
|
||||
},
|
||||
{
|
||||
id: 'connexion',
|
||||
term: 'Connexion',
|
||||
definition: 'Lien entre deux étapes qui définit l\'ordre d\'exécution. Les connexions créent le flux logique du workflow.',
|
||||
category: 'Général',
|
||||
synonyms: ['Lien', 'Transition', 'Flèche'],
|
||||
relatedTerms: ['etape', 'workflow', 'flux'],
|
||||
examples: ['Connexion de l\'étape A vers l\'étape B']
|
||||
},
|
||||
{
|
||||
id: 'parametres',
|
||||
term: 'Paramètres',
|
||||
definition: 'Valeurs de configuration d\'une étape qui définissent comment elle doit s\'exécuter. Chaque type d\'étape a ses propres paramètres.',
|
||||
category: 'Configuration',
|
||||
synonyms: ['Propriétés', 'Configuration', 'Options'],
|
||||
relatedTerms: ['etape', 'validation', 'variables'],
|
||||
examples: ['Paramètre "texte" pour une étape de saisie', 'Paramètre "durée" pour une attente']
|
||||
},
|
||||
|
||||
// Termes techniques
|
||||
{
|
||||
id: 'selection-visuelle',
|
||||
term: 'Sélection visuelle',
|
||||
definition: 'Méthode pour choisir un élément sur une page web en cliquant directement sur une capture d\'écran, plutôt qu\'en écrivant un sélecteur CSS.',
|
||||
category: 'Technique',
|
||||
synonyms: ['Sélecteur visuel', 'Pointage visuel'],
|
||||
relatedTerms: ['element-cible', 'capture-ecran', 'selecteur'],
|
||||
examples: ['Sélectionner un bouton en cliquant dessus', 'Choisir un champ de saisie visuellement']
|
||||
},
|
||||
{
|
||||
id: 'element-cible',
|
||||
term: 'Élément cible',
|
||||
definition: 'Élément d\'une page web sur lequel une étape va agir. Peut être un bouton, un champ de saisie, un lien, etc.',
|
||||
category: 'Technique',
|
||||
synonyms: ['Cible', 'Élément sélectionné'],
|
||||
relatedTerms: ['selection-visuelle', 'selecteur', 'dom'],
|
||||
examples: ['Bouton "Valider"', 'Champ "Email"', 'Lien "En savoir plus"']
|
||||
},
|
||||
{
|
||||
id: 'variables',
|
||||
term: 'Variables',
|
||||
definition: 'Valeurs dynamiques qui peuvent être utilisées dans les paramètres des étapes. Permettent de rendre les workflows réutilisables et flexibles.',
|
||||
category: 'Technique',
|
||||
synonyms: ['Variables dynamiques', 'Placeholders'],
|
||||
relatedTerms: ['parametres', 'substitution', 'dynamique'],
|
||||
examples: ['${nom_utilisateur}', '${email}', '${compteur}']
|
||||
},
|
||||
{
|
||||
id: 'validation',
|
||||
term: 'Validation',
|
||||
definition: 'Vérification automatique de la cohérence et de la complétude d\'un workflow avant son exécution. Détecte les erreurs et avertissements.',
|
||||
category: 'Technique',
|
||||
synonyms: ['Vérification', 'Contrôle qualité'],
|
||||
relatedTerms: ['erreurs', 'avertissements', 'execution'],
|
||||
examples: ['Validation des paramètres manquants', 'Détection de cycles']
|
||||
},
|
||||
|
||||
// Types d'étapes
|
||||
{
|
||||
id: 'clic',
|
||||
term: 'Clic',
|
||||
definition: 'Action de cliquer sur un élément de la page web. Peut être un clic gauche, droit, ou double-clic selon les besoins.',
|
||||
category: 'Actions Web',
|
||||
synonyms: ['Click', 'Cliquer'],
|
||||
relatedTerms: ['element-cible', 'interaction'],
|
||||
examples: ['Clic sur un bouton', 'Clic droit pour menu contextuel']
|
||||
},
|
||||
{
|
||||
id: 'saisie',
|
||||
term: 'Saisie de texte',
|
||||
definition: 'Action de saisir du texte dans un champ de saisie. Supporte les variables pour un contenu dynamique.',
|
||||
category: 'Actions Web',
|
||||
synonyms: ['Frappe', 'Écriture', 'Input'],
|
||||
relatedTerms: ['variables', 'champ-saisie', 'texte'],
|
||||
examples: ['Saisir un nom d\'utilisateur', 'Remplir un formulaire']
|
||||
},
|
||||
{
|
||||
id: 'extraction',
|
||||
term: 'Extraction de données',
|
||||
definition: 'Action de récupérer des informations depuis un élément de la page web pour les stocker dans une variable.',
|
||||
category: 'Données',
|
||||
synonyms: ['Récupération', 'Capture de données'],
|
||||
relatedTerms: ['variables', 'element-cible', 'donnees'],
|
||||
examples: ['Extraire le prix d\'un produit', 'Récupérer un numéro de commande']
|
||||
},
|
||||
{
|
||||
id: 'condition',
|
||||
term: 'Condition',
|
||||
definition: 'Structure logique qui permet d\'exécuter différentes actions selon qu\'une condition est vraie ou fausse.',
|
||||
category: 'Logique',
|
||||
synonyms: ['Test conditionnel', 'Branchement'],
|
||||
relatedTerms: ['logique', 'variables', 'flux'],
|
||||
examples: ['Si âge > 18 alors continuer', 'Si champ vide alors alerter']
|
||||
},
|
||||
{
|
||||
id: 'attente',
|
||||
term: 'Attente',
|
||||
definition: 'Pause dans l\'exécution du workflow pendant une durée déterminée. Utile pour attendre le chargement d\'éléments.',
|
||||
category: 'Contrôle',
|
||||
synonyms: ['Pause', 'Délai', 'Wait'],
|
||||
relatedTerms: ['synchronisation', 'timing'],
|
||||
examples: ['Attendre 2 secondes', 'Pause après un clic']
|
||||
},
|
||||
|
||||
// Interface utilisateur
|
||||
{
|
||||
id: 'canvas',
|
||||
term: 'Canvas',
|
||||
definition: 'Zone de travail principale où les étapes sont placées et connectées pour former le workflow visuel.',
|
||||
category: 'Interface',
|
||||
synonyms: ['Zone de travail', 'Espace de conception'],
|
||||
relatedTerms: ['etape', 'connexion', 'workflow'],
|
||||
examples: ['Glisser une étape sur le canvas', 'Zoomer sur le canvas']
|
||||
},
|
||||
{
|
||||
id: 'palette',
|
||||
term: 'Palette',
|
||||
definition: 'Boîte à outils contenant tous les types d\'étapes disponibles, organisés par catégories pour faciliter la recherche.',
|
||||
category: 'Interface',
|
||||
synonyms: ['Boîte à outils', 'Toolbox'],
|
||||
relatedTerms: ['etape', 'categories', 'drag-drop'],
|
||||
examples: ['Palette d\'étapes', 'Catégorie Actions Web']
|
||||
},
|
||||
{
|
||||
id: 'proprietes',
|
||||
term: 'Panneau de propriétés',
|
||||
definition: 'Interface de configuration des paramètres de l\'étape sélectionnée. S\'adapte selon le type d\'étape.',
|
||||
category: 'Interface',
|
||||
synonyms: ['Configuration', 'Paramètres'],
|
||||
relatedTerms: ['parametres', 'etape', 'configuration'],
|
||||
examples: ['Configurer le texte à saisir', 'Définir la durée d\'attente']
|
||||
},
|
||||
{
|
||||
id: 'minimap',
|
||||
term: 'Mini-carte',
|
||||
definition: 'Vue d\'ensemble miniaturisée du workflow complet, permettant de naviguer rapidement dans les gros workflows.',
|
||||
category: 'Interface',
|
||||
synonyms: ['Vue d\'ensemble', 'Navigation'],
|
||||
relatedTerms: ['canvas', 'navigation', 'workflow'],
|
||||
examples: ['Cliquer sur la mini-carte pour se déplacer']
|
||||
},
|
||||
|
||||
// Concepts avancés
|
||||
{
|
||||
id: 'cycle',
|
||||
term: 'Cycle',
|
||||
definition: 'Boucle infinie dans un workflow où les étapes se référencent mutuellement, empêchant l\'exécution normale.',
|
||||
category: 'Erreurs',
|
||||
synonyms: ['Boucle infinie', 'Référence circulaire'],
|
||||
relatedTerms: ['validation', 'erreurs', 'connexion'],
|
||||
examples: ['Étape A → Étape B → Étape A']
|
||||
},
|
||||
{
|
||||
id: 'etape-deconnectee',
|
||||
term: 'Étape déconnectée',
|
||||
definition: 'Étape qui n\'est pas reliée au flux principal du workflow et qui ne sera donc pas exécutée.',
|
||||
category: 'Avertissements',
|
||||
synonyms: ['Étape isolée', 'Nœud orphelin'],
|
||||
relatedTerms: ['connexion', 'validation', 'flux'],
|
||||
examples: ['Étape sans connexion d\'entrée ni de sortie']
|
||||
},
|
||||
{
|
||||
id: 'execution',
|
||||
term: 'Exécution',
|
||||
definition: 'Processus de lancement et de déroulement du workflow, étape par étape, selon l\'ordre défini par les connexions.',
|
||||
category: 'Général',
|
||||
synonyms: ['Lancement', 'Déroulement', 'Run'],
|
||||
relatedTerms: ['workflow', 'etape', 'validation'],
|
||||
examples: ['Exécution réussie', 'Exécution interrompue par erreur']
|
||||
}
|
||||
];
|
||||
|
||||
// Catégories pour l'organisation
|
||||
const categories = [
|
||||
'Général',
|
||||
'Technique',
|
||||
'Actions Web',
|
||||
'Données',
|
||||
'Logique',
|
||||
'Contrôle',
|
||||
'Interface',
|
||||
'Erreurs',
|
||||
'Avertissements'
|
||||
];
|
||||
|
||||
/**
|
||||
* Composant Glossaire
|
||||
*/
|
||||
const Glossary: React.FC<GlossaryProps> = ({
|
||||
searchTerm: externalSearchTerm = '',
|
||||
onTermSelect,
|
||||
}) => {
|
||||
const [internalSearchTerm, setInternalSearchTerm] = useState('');
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set(['Général']));
|
||||
|
||||
// Utiliser le terme de recherche externe ou interne
|
||||
const searchTerm = externalSearchTerm || internalSearchTerm;
|
||||
|
||||
// Filtrer les termes selon la recherche
|
||||
const filteredTerms = useMemo(() => {
|
||||
if (!searchTerm.trim()) {
|
||||
return glossaryTerms;
|
||||
}
|
||||
|
||||
const lowerSearchTerm = searchTerm.toLowerCase();
|
||||
return glossaryTerms.filter(term =>
|
||||
term.term.toLowerCase().includes(lowerSearchTerm) ||
|
||||
term.definition.toLowerCase().includes(lowerSearchTerm) ||
|
||||
term.synonyms?.some(synonym => synonym.toLowerCase().includes(lowerSearchTerm)) ||
|
||||
term.examples?.some(example => example.toLowerCase().includes(lowerSearchTerm))
|
||||
);
|
||||
}, [searchTerm]);
|
||||
|
||||
// Organiser les termes par catégorie
|
||||
const termsByCategory = useMemo(() => {
|
||||
const organized: Record<string, GlossaryTerm[]> = {};
|
||||
|
||||
categories.forEach(category => {
|
||||
organized[category] = filteredTerms.filter(term => term.category === category);
|
||||
});
|
||||
|
||||
return organized;
|
||||
}, [filteredTerms]);
|
||||
|
||||
// Gestionnaire d'expansion des catégories
|
||||
const handleCategoryToggle = (category: string) => {
|
||||
setExpandedCategories(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(category)) {
|
||||
newSet.delete(category);
|
||||
} else {
|
||||
newSet.add(category);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
// Gestionnaire de sélection de terme
|
||||
const handleTermSelect = (termId: string) => {
|
||||
if (onTermSelect) {
|
||||
onTermSelect(termId);
|
||||
}
|
||||
};
|
||||
|
||||
// Rendu d'un terme
|
||||
const renderTerm = (term: GlossaryTerm) => (
|
||||
<ListItem
|
||||
key={term.id}
|
||||
onClick={() => handleTermSelect(term.id)}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
flexDirection: 'column',
|
||||
alignItems: 'stretch',
|
||||
py: 2,
|
||||
}}
|
||||
>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<Typography variant="h6" component="dt">
|
||||
{term.term}
|
||||
</Typography>
|
||||
{term.synonyms && term.synonyms.length > 0 && (
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
{term.synonyms.map((synonym, index) => (
|
||||
<Chip
|
||||
key={index}
|
||||
label={synonym}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
secondary={
|
||||
<Box component="dd" sx={{ m: 0 }}>
|
||||
<Typography variant="body2" paragraph>
|
||||
{term.definition}
|
||||
</Typography>
|
||||
|
||||
{term.examples && term.examples.length > 0 && (
|
||||
<Box sx={{ mb: 1 }}>
|
||||
<Typography variant="caption" fontWeight="bold" color="text.secondary">
|
||||
Exemples :
|
||||
</Typography>
|
||||
<List dense sx={{ pl: 2 }}>
|
||||
{term.examples.map((example, index) => (
|
||||
<ListItem key={index} sx={{ py: 0, pl: 0 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
• {example}
|
||||
</Typography>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{term.relatedTerms && term.relatedTerms.length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="caption" fontWeight="bold" color="text.secondary">
|
||||
Termes liés :
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mt: 0.5 }}>
|
||||
{term.relatedTerms.map((relatedId, index) => {
|
||||
const relatedTerm = glossaryTerms.find(t => t.id === relatedId);
|
||||
return relatedTerm ? (
|
||||
<Chip
|
||||
key={index}
|
||||
label={relatedTerm.term}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleTermSelect(relatedId);
|
||||
}}
|
||||
/>
|
||||
) : null;
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
{/* En-tête */}
|
||||
<Box sx={{ p: 2, borderBottom: '1px solid #e0e0e0' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<BookIcon color="primary" />
|
||||
<Typography variant="h5" component="h1">
|
||||
Glossaire Technique
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Champ de recherche */}
|
||||
{!externalSearchTerm && (
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder="Rechercher un terme..."
|
||||
value={internalSearchTerm}
|
||||
onChange={(e) => setInternalSearchTerm(e.target.value)}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon />
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Statistiques */}
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
|
||||
{filteredTerms.length} terme(s) trouvé(s) sur {glossaryTerms.length} au total
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Contenu du glossaire */}
|
||||
<Box sx={{ flex: 1, overflow: 'auto' }}>
|
||||
{searchTerm ? (
|
||||
// Vue de recherche : tous les termes filtrés
|
||||
<Paper sx={{ m: 2, p: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Résultats de recherche pour "{searchTerm}"
|
||||
</Typography>
|
||||
<List component="dl">
|
||||
{filteredTerms.map(term => (
|
||||
<React.Fragment key={term.id}>
|
||||
{renderTerm(term)}
|
||||
<Divider />
|
||||
</React.Fragment>
|
||||
))}
|
||||
</List>
|
||||
{filteredTerms.length === 0 && (
|
||||
<Typography variant="body2" color="text.secondary" textAlign="center">
|
||||
Aucun terme trouvé pour "{searchTerm}"
|
||||
</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
) : (
|
||||
// Vue par catégories
|
||||
<Box>
|
||||
{categories.map(category => {
|
||||
const categoryTerms = termsByCategory[category];
|
||||
if (categoryTerms.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Accordion
|
||||
key={category}
|
||||
expanded={expandedCategories.has(category)}
|
||||
onChange={() => handleCategoryToggle(category)}
|
||||
disableGutters
|
||||
elevation={0}
|
||||
sx={{
|
||||
'&:before': { display: 'none' },
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
}}
|
||||
>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMoreIcon />}
|
||||
sx={{
|
||||
minHeight: 56,
|
||||
'& .MuiAccordionSummary-content': {
|
||||
alignItems: 'center',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="h6">{category}</Typography>
|
||||
<Chip
|
||||
label={categoryTerms.length}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
|
||||
<AccordionDetails sx={{ p: 0 }}>
|
||||
<List component="dl">
|
||||
{categoryTerms.map(term => (
|
||||
<React.Fragment key={term.id}>
|
||||
{renderTerm(term)}
|
||||
<Divider />
|
||||
</React.Fragment>
|
||||
))}
|
||||
</List>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Glossary;
|
||||
@@ -0,0 +1,413 @@
|
||||
/**
|
||||
* Interactive Preview Area Styles - RPA Vision V3
|
||||
*
|
||||
* Styles pour la zone d'aperçu interactif avec zoom et navigation.
|
||||
* Optimisé pour une expérience de visualisation fluide et intuitive.
|
||||
*/
|
||||
|
||||
.interactive-preview-area {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
z-index: 9998;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.interactive-preview-area__header {
|
||||
background: #1976d2;
|
||||
color: white;
|
||||
padding: 16px 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.interactive-preview-area__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.interactive-preview-area__controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.interactive-preview-area__zoom-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
padding: 8px 12px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.interactive-preview-area__zoom-slider {
|
||||
width: 100px;
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
.interactive-preview-area__zoom-label {
|
||||
min-width: 40px;
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.interactive-preview-area__action-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.interactive-preview-area__content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.interactive-preview-area__canvas-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: #2c2c2c;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.interactive-preview-area__canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: grab;
|
||||
transition: cursor 0.2s ease;
|
||||
}
|
||||
|
||||
.interactive-preview-area__canvas--dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.interactive-preview-area__sidebar {
|
||||
width: 320px;
|
||||
background: white;
|
||||
border-left: 1px solid #e0e0e0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.interactive-preview-area__sidebar-header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.interactive-preview-area__sidebar-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.interactive-preview-area__info-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.interactive-preview-area__info-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.interactive-preview-area__info-content {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.interactive-preview-area__metadata-grid {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.interactive-preview-area__metadata-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.interactive-preview-area__metadata-label {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.interactive-preview-area__metadata-value {
|
||||
color: #666;
|
||||
text-align: right;
|
||||
flex: 1;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.interactive-preview-area__text-content {
|
||||
background: #e3f2fd;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #bbdefb;
|
||||
font-style: italic;
|
||||
color: #1565c0;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.interactive-preview-area__chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.interactive-preview-area__display-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.interactive-preview-area__control-button {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.interactive-preview-area__control-button:hover {
|
||||
background: #f5f5f5;
|
||||
border-color: #1976d2;
|
||||
}
|
||||
|
||||
.interactive-preview-area__control-button--active {
|
||||
background: #1976d2;
|
||||
color: white;
|
||||
border-color: #1976d2;
|
||||
}
|
||||
|
||||
/* Annotations overlay */
|
||||
.interactive-preview-area__annotations {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.interactive-preview-area__annotation {
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.interactive-preview-area__annotation:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.interactive-preview-area__annotation--info {
|
||||
background: #2196f3;
|
||||
}
|
||||
|
||||
.interactive-preview-area__annotation--warning {
|
||||
background: #ff9800;
|
||||
}
|
||||
|
||||
.interactive-preview-area__annotation--success {
|
||||
background: #4caf50;
|
||||
}
|
||||
|
||||
.interactive-preview-area__annotation-tooltip {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
/* Target highlight animations */
|
||||
.interactive-preview-area__target-highlight {
|
||||
position: absolute;
|
||||
border: 3px solid #4caf50;
|
||||
background: rgba(76, 175, 80, 0.1);
|
||||
pointer-events: none;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.interactive-preview-area__target-highlight--animated {
|
||||
animation: interactive-preview-pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes interactive-preview-pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(76, 175, 80, 0.7);
|
||||
border-width: 3px;
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 10px rgba(76, 175, 80, 0.2);
|
||||
border-width: 4px;
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(76, 175, 80, 0);
|
||||
border-width: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.interactive-preview-area__target-corners {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
z-index: 6;
|
||||
}
|
||||
|
||||
.interactive-preview-area__corner {
|
||||
position: absolute;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border: 2px solid #4caf50;
|
||||
}
|
||||
|
||||
.interactive-preview-area__corner--top-left {
|
||||
top: -2px;
|
||||
left: -2px;
|
||||
border-right: none;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.interactive-preview-area__corner--top-right {
|
||||
top: -2px;
|
||||
right: -2px;
|
||||
border-left: none;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.interactive-preview-area__corner--bottom-left {
|
||||
bottom: -2px;
|
||||
left: -2px;
|
||||
border-right: none;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.interactive-preview-area__corner--bottom-right {
|
||||
bottom: -2px;
|
||||
right: -2px;
|
||||
border-left: none;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
/* Loading state */
|
||||
.interactive-preview-area__loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.interactive-preview-area__loading-spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 4px solid rgba(255, 255, 255, 0.3);
|
||||
border-top: 4px solid white;
|
||||
border-radius: 50%;
|
||||
animation: interactive-preview-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes interactive-preview-spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.interactive-preview-area__sidebar {
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.interactive-preview-area__header {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.interactive-preview-area__zoom-controls {
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.interactive-preview-area__zoom-slider {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.interactive-preview-area__sidebar-content {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.interactive-preview-area__content {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.interactive-preview-area__sidebar {
|
||||
width: 100%;
|
||||
height: 250px;
|
||||
border-left: none;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.interactive-preview-area__canvas-container {
|
||||
height: calc(100vh - 250px - 80px);
|
||||
}
|
||||
|
||||
.interactive-preview-area__controls {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.interactive-preview-area__zoom-controls {
|
||||
order: 2;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,612 @@
|
||||
/**
|
||||
* Composant Zone d'Aperçu Interactif - Configuration des paramètres d'étapes
|
||||
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
|
||||
*
|
||||
* Zone d'aperçu interactif pour les captures d'écran avec zoom, navigation et annotations.
|
||||
* Permet l'examen détaillé des éléments sélectionnés avec contours animés.
|
||||
*
|
||||
* Fonctionnalités:
|
||||
* - Zoom fluide avec molette de souris
|
||||
* - Navigation par glisser-déposer
|
||||
* - Annotations contextuelles
|
||||
* - Surbrillance de l'élément cible
|
||||
* - Contrôles de navigation intuitifs
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
Box,
|
||||
Typography,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Chip,
|
||||
Paper,
|
||||
Slider,
|
||||
Divider,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Close as CloseIcon,
|
||||
ZoomIn as ZoomInIcon,
|
||||
ZoomOut as ZoomOutIcon,
|
||||
CenterFocusStrong as CenterIcon,
|
||||
Fullscreen as FullscreenIcon,
|
||||
Info as InfoIcon,
|
||||
MyLocation as TargetIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
import { BoundingBox } from '../../types';
|
||||
import './InteractivePreviewArea.css';
|
||||
|
||||
interface VisualMetadata {
|
||||
element_type?: string;
|
||||
relative_position?: string;
|
||||
text_content?: string;
|
||||
}
|
||||
|
||||
interface InteractivePreviewAreaProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
screenshot: string;
|
||||
boundingBox: BoundingBox;
|
||||
metadata?: VisualMetadata;
|
||||
}
|
||||
|
||||
interface ViewportState {
|
||||
zoom: number;
|
||||
panX: number;
|
||||
panY: number;
|
||||
isDragging: boolean;
|
||||
dragStart: { x: number; y: number };
|
||||
}
|
||||
|
||||
interface AnnotationPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
label: string;
|
||||
type: 'info' | 'warning' | 'target';
|
||||
}
|
||||
|
||||
const InteractivePreviewArea: React.FC<InteractivePreviewAreaProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
screenshot,
|
||||
boundingBox,
|
||||
metadata,
|
||||
}) => {
|
||||
const [viewport, setViewport] = useState<ViewportState>({
|
||||
zoom: 1,
|
||||
panX: 0,
|
||||
panY: 0,
|
||||
isDragging: false,
|
||||
dragStart: { x: 0, y: 0 },
|
||||
});
|
||||
|
||||
const [showAnnotations, setShowAnnotations] = useState(true);
|
||||
const [showTargetHighlight, setShowTargetHighlight] = useState(true);
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
const [imageDimensions, setImageDimensions] = useState({ width: 0, height: 0 });
|
||||
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
const animationRef = useRef<number | null>(null);
|
||||
|
||||
// Réinitialiser l'état quand le dialog s'ouvre
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setViewport({
|
||||
zoom: 1,
|
||||
panX: 0,
|
||||
panY: 0,
|
||||
isDragging: false,
|
||||
dragStart: { x: 0, y: 0 },
|
||||
});
|
||||
setImageLoaded(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Charger l'image et obtenir ses dimensions
|
||||
useEffect(() => {
|
||||
if (open && screenshot) {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
setImageDimensions({ width: img.width, height: img.height });
|
||||
setImageLoaded(true);
|
||||
|
||||
// Centrer sur l'élément cible
|
||||
centerOnTarget();
|
||||
};
|
||||
img.src = `data:image/png;base64,${screenshot}`;
|
||||
imageRef.current = img;
|
||||
}
|
||||
}, [open, screenshot]);
|
||||
|
||||
// Animation du contour cible
|
||||
useEffect(() => {
|
||||
if (showTargetHighlight && imageLoaded) {
|
||||
const animate = () => {
|
||||
drawCanvas();
|
||||
animationRef.current = requestAnimationFrame(animate);
|
||||
};
|
||||
animate();
|
||||
|
||||
return () => {
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
}
|
||||
};
|
||||
}
|
||||
}, [showTargetHighlight, imageLoaded, viewport]);
|
||||
|
||||
/**
|
||||
* Centre la vue sur l'élément cible
|
||||
*/
|
||||
const centerOnTarget = useCallback(() => {
|
||||
if (!imageLoaded || !containerRef.current) return;
|
||||
|
||||
const container = containerRef.current;
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
|
||||
// Calculer la position pour centrer l'élément cible
|
||||
const targetCenterX = boundingBox.x + boundingBox.width / 2;
|
||||
const targetCenterY = boundingBox.y + boundingBox.height / 2;
|
||||
|
||||
const panX = (containerRect.width / 2) - (targetCenterX * viewport.zoom);
|
||||
const panY = (containerRect.height / 2) - (targetCenterY * viewport.zoom);
|
||||
|
||||
setViewport(prev => ({
|
||||
...prev,
|
||||
panX,
|
||||
panY,
|
||||
}));
|
||||
}, [boundingBox, viewport.zoom, imageLoaded]);
|
||||
|
||||
/**
|
||||
* Gère le zoom avec la molette
|
||||
*/
|
||||
const handleWheel = useCallback((event: React.WheelEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
const mouseX = event.clientX - rect.left;
|
||||
const mouseY = event.clientY - rect.top;
|
||||
|
||||
const zoomFactor = event.deltaY > 0 ? 0.9 : 1.1;
|
||||
const newZoom = Math.max(0.1, Math.min(5, viewport.zoom * zoomFactor));
|
||||
|
||||
// Ajuster le pan pour zoomer vers la position de la souris
|
||||
const zoomRatio = newZoom / viewport.zoom;
|
||||
const newPanX = mouseX - (mouseX - viewport.panX) * zoomRatio;
|
||||
const newPanY = mouseY - (mouseY - viewport.panY) * zoomRatio;
|
||||
|
||||
setViewport(prev => ({
|
||||
...prev,
|
||||
zoom: newZoom,
|
||||
panX: newPanX,
|
||||
panY: newPanY,
|
||||
}));
|
||||
}, [viewport]);
|
||||
|
||||
/**
|
||||
* Démarre le glisser-déposer
|
||||
*/
|
||||
const handleMouseDown = useCallback((event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
setViewport(prev => ({
|
||||
...prev,
|
||||
isDragging: true,
|
||||
dragStart: { x: event.clientX - prev.panX, y: event.clientY - prev.panY },
|
||||
}));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Gère le glisser-déposer
|
||||
*/
|
||||
const handleMouseMove = useCallback((event: React.MouseEvent) => {
|
||||
if (!viewport.isDragging) return;
|
||||
|
||||
const newPanX = event.clientX - viewport.dragStart.x;
|
||||
const newPanY = event.clientY - viewport.dragStart.y;
|
||||
|
||||
setViewport(prev => ({
|
||||
...prev,
|
||||
panX: newPanX,
|
||||
panY: newPanY,
|
||||
}));
|
||||
}, [viewport.isDragging, viewport.dragStart]);
|
||||
|
||||
/**
|
||||
* Termine le glisser-déposer
|
||||
*/
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setViewport(prev => ({
|
||||
...prev,
|
||||
isDragging: false,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Dessine le canvas avec l'image et les overlays
|
||||
*/
|
||||
const drawCanvas = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const image = imageRef.current;
|
||||
|
||||
if (!canvas || !image || !imageLoaded) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Ajuster la taille du canvas
|
||||
const container = containerRef.current;
|
||||
if (container) {
|
||||
canvas.width = container.clientWidth;
|
||||
canvas.height = container.clientHeight;
|
||||
}
|
||||
|
||||
// Effacer le canvas
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Dessiner l'image avec transformation
|
||||
ctx.save();
|
||||
ctx.translate(viewport.panX, viewport.panY);
|
||||
ctx.scale(viewport.zoom, viewport.zoom);
|
||||
ctx.drawImage(image, 0, 0);
|
||||
ctx.restore();
|
||||
|
||||
// Dessiner le contour animé de l'élément cible
|
||||
if (showTargetHighlight) {
|
||||
drawTargetHighlight(ctx);
|
||||
}
|
||||
|
||||
// Dessiner les annotations
|
||||
if (showAnnotations) {
|
||||
drawAnnotations(ctx);
|
||||
}
|
||||
}, [viewport, showTargetHighlight, showAnnotations, imageLoaded]);
|
||||
|
||||
/**
|
||||
* Dessine le contour animé de l'élément cible
|
||||
*/
|
||||
const drawTargetHighlight = useCallback((ctx: CanvasRenderingContext2D) => {
|
||||
const time = Date.now() * 0.003; // Animation lente
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(viewport.panX, viewport.panY);
|
||||
ctx.scale(viewport.zoom, viewport.zoom);
|
||||
|
||||
// Contour principal
|
||||
ctx.strokeStyle = '#4CAF50';
|
||||
ctx.lineWidth = 3 / viewport.zoom;
|
||||
ctx.setLineDash([10, 5]);
|
||||
ctx.lineDashOffset = time * 20;
|
||||
|
||||
ctx.strokeRect(
|
||||
boundingBox.x - 2,
|
||||
boundingBox.y - 2,
|
||||
boundingBox.width + 4,
|
||||
boundingBox.height + 4
|
||||
);
|
||||
|
||||
// Contour externe animé
|
||||
ctx.strokeStyle = `rgba(76, 175, 80, ${0.5 + 0.3 * Math.sin(time * 2)})`;
|
||||
ctx.lineWidth = 1 / viewport.zoom;
|
||||
ctx.setLineDash([]);
|
||||
|
||||
ctx.strokeRect(
|
||||
boundingBox.x - 5,
|
||||
boundingBox.y - 5,
|
||||
boundingBox.width + 10,
|
||||
boundingBox.height + 10
|
||||
);
|
||||
|
||||
// Points de coin
|
||||
const cornerSize = 8 / viewport.zoom;
|
||||
ctx.fillStyle = '#4CAF50';
|
||||
|
||||
const corners = [
|
||||
{ x: boundingBox.x, y: boundingBox.y },
|
||||
{ x: boundingBox.x + boundingBox.width, y: boundingBox.y },
|
||||
{ x: boundingBox.x, y: boundingBox.y + boundingBox.height },
|
||||
{ x: boundingBox.x + boundingBox.width, y: boundingBox.y + boundingBox.height },
|
||||
];
|
||||
|
||||
corners.forEach(corner => {
|
||||
ctx.fillRect(
|
||||
corner.x - cornerSize / 2,
|
||||
corner.y - cornerSize / 2,
|
||||
cornerSize,
|
||||
cornerSize
|
||||
);
|
||||
});
|
||||
|
||||
ctx.restore();
|
||||
}, [viewport, boundingBox]);
|
||||
|
||||
/**
|
||||
* Dessine les annotations
|
||||
*/
|
||||
const drawAnnotations = useCallback((ctx: CanvasRenderingContext2D) => {
|
||||
const annotations: AnnotationPoint[] = [
|
||||
{
|
||||
x: boundingBox.x + boundingBox.width / 2,
|
||||
y: boundingBox.y - 20,
|
||||
label: metadata?.element_type || 'Élément',
|
||||
type: 'target'
|
||||
}
|
||||
];
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(viewport.panX, viewport.panY);
|
||||
ctx.scale(viewport.zoom, viewport.zoom);
|
||||
|
||||
annotations.forEach(annotation => {
|
||||
// Bulle d'annotation
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.8)';
|
||||
ctx.strokeStyle = '#4CAF50';
|
||||
ctx.lineWidth = 1 / viewport.zoom;
|
||||
|
||||
const padding = 8 / viewport.zoom;
|
||||
const fontSize = 12 / viewport.zoom;
|
||||
ctx.font = `${fontSize}px Arial`;
|
||||
|
||||
const textWidth = ctx.measureText(annotation.label).width;
|
||||
const bubbleWidth = textWidth + padding * 2;
|
||||
const bubbleHeight = fontSize + padding * 2;
|
||||
|
||||
// Dessiner la bulle
|
||||
ctx.fillRect(
|
||||
annotation.x - bubbleWidth / 2,
|
||||
annotation.y - bubbleHeight,
|
||||
bubbleWidth,
|
||||
bubbleHeight
|
||||
);
|
||||
|
||||
ctx.strokeRect(
|
||||
annotation.x - bubbleWidth / 2,
|
||||
annotation.y - bubbleHeight,
|
||||
bubbleWidth,
|
||||
bubbleHeight
|
||||
);
|
||||
|
||||
// Dessiner le texte
|
||||
ctx.fillStyle = 'white';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(
|
||||
annotation.label,
|
||||
annotation.x,
|
||||
annotation.y - padding
|
||||
);
|
||||
});
|
||||
|
||||
ctx.restore();
|
||||
}, [viewport, boundingBox, metadata]);
|
||||
|
||||
/**
|
||||
* Contrôles de zoom
|
||||
*/
|
||||
const zoomIn = useCallback(() => {
|
||||
setViewport(prev => ({
|
||||
...prev,
|
||||
zoom: Math.min(5, prev.zoom * 1.2),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const zoomOut = useCallback(() => {
|
||||
setViewport(prev => ({
|
||||
...prev,
|
||||
zoom: Math.max(0.1, prev.zoom / 1.2),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const resetZoom = useCallback(() => {
|
||||
setViewport(prev => ({
|
||||
...prev,
|
||||
zoom: 1,
|
||||
panX: 0,
|
||||
panY: 0,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth={false}
|
||||
fullWidth
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: {
|
||||
width: '90vw',
|
||||
height: '90vh',
|
||||
maxWidth: 'none',
|
||||
maxHeight: 'none'
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTitle>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<TargetIcon />
|
||||
<Typography variant="h6">Aperçu Interactif</Typography>
|
||||
{metadata && (
|
||||
<Chip
|
||||
label={metadata.element_type}
|
||||
size="small"
|
||||
color="primary"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Tooltip title="Annotations">
|
||||
<IconButton
|
||||
onClick={() => setShowAnnotations(!showAnnotations)}
|
||||
color={showAnnotations ? 'primary' : 'default'}
|
||||
>
|
||||
<InfoIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Surbrillance cible">
|
||||
<IconButton
|
||||
onClick={() => setShowTargetHighlight(!showTargetHighlight)}
|
||||
color={showTargetHighlight ? 'primary' : 'default'}
|
||||
>
|
||||
<TargetIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<IconButton onClick={onClose}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent sx={{ p: 0, display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
{/* Barre d'outils */}
|
||||
<Paper sx={{ p: 1, display: 'flex', alignItems: 'center', gap: 2, borderRadius: 0 }}>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Tooltip title="Zoom avant">
|
||||
<IconButton onClick={zoomIn} size="small">
|
||||
<ZoomInIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Zoom arrière">
|
||||
<IconButton onClick={zoomOut} size="small">
|
||||
<ZoomOutIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Centrer sur l'élément">
|
||||
<IconButton onClick={centerOnTarget} size="small">
|
||||
<CenterIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Réinitialiser">
|
||||
<IconButton onClick={resetZoom} size="small">
|
||||
<FullscreenIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
<Divider orientation="vertical" flexItem />
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, minWidth: 200 }}>
|
||||
<Typography variant="body2">Zoom:</Typography>
|
||||
<Slider
|
||||
value={viewport.zoom}
|
||||
min={0.1}
|
||||
max={5}
|
||||
step={0.1}
|
||||
onChange={(_, value) => setViewport(prev => ({ ...prev, zoom: value as number }))}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<Typography variant="body2" sx={{ minWidth: 50 }}>
|
||||
{Math.round(viewport.zoom * 100)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Divider orientation="vertical" flexItem />
|
||||
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
Position: {Math.round(viewport.panX)}, {Math.round(viewport.panY)}
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
{/* Zone de visualisation */}
|
||||
<Box
|
||||
ref={containerRef}
|
||||
className="preview-container"
|
||||
sx={{
|
||||
flex: 1,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
cursor: viewport.isDragging ? 'grabbing' : 'grab',
|
||||
backgroundColor: '#f5f5f5'
|
||||
}}
|
||||
onWheel={handleWheel}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
/>
|
||||
|
||||
{!imageLoaded && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
textAlign: 'center'
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
Chargement de l'image...
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Informations sur l'élément */}
|
||||
{metadata && (
|
||||
<Paper sx={{ p: 2, borderRadius: 0 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Informations sur l'Élément
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 3, flexWrap: 'wrap' }}>
|
||||
<Box>
|
||||
<Typography variant="caption" color="textSecondary">Type:</Typography>
|
||||
<Typography variant="body2">{metadata.element_type}</Typography>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="caption" color="textSecondary">Position:</Typography>
|
||||
<Typography variant="body2">{metadata.relative_position}</Typography>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="caption" color="textSecondary">Taille:</Typography>
|
||||
<Typography variant="body2">
|
||||
{boundingBox.width} × {boundingBox.height}px
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{metadata.text_content && (
|
||||
<Box>
|
||||
<Typography variant="caption" color="textSecondary">Texte:</Typography>
|
||||
<Typography variant="body2">"{metadata.text_content}"</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default InteractivePreviewArea;
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Composant Raccourcis Clavier - Aide à l'accessibilité
|
||||
* Auteur : Dom, Alice, Kiro - 08 janvier 2026
|
||||
*
|
||||
* Ce composant affiche la liste des raccourcis clavier disponibles
|
||||
* pour améliorer l'accessibilité et l'efficacité d'utilisation.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Typography,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
Chip,
|
||||
Box,
|
||||
Divider,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Keyboard as KeyboardIcon,
|
||||
Close as CloseIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface KeyboardShortcutsProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
shortcuts?: Array<{
|
||||
keys: string;
|
||||
description: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// Raccourcis par défaut si aucun n'est fourni
|
||||
const defaultShortcuts = [
|
||||
// Navigation
|
||||
{ category: 'Navigation', keys: 'Tab', description: 'Naviguer vers l\'étape suivante' },
|
||||
{ category: 'Navigation', keys: 'Shift + Tab', description: 'Naviguer vers l\'étape précédente' },
|
||||
{ category: 'Navigation', keys: '↑ ↓ ← →', description: 'Déplacer l\'étape sélectionnée' },
|
||||
|
||||
// Édition
|
||||
{ category: 'Édition', keys: 'Suppr', description: 'Supprimer l\'étape sélectionnée' },
|
||||
{ category: 'Édition', keys: 'Ctrl + C', description: 'Copier l\'étape sélectionnée' },
|
||||
{ category: 'Édition', keys: 'Ctrl + V', description: 'Coller l\'étape copiée' },
|
||||
{ category: 'Édition', keys: 'Ctrl + Z', description: 'Annuler la dernière action' },
|
||||
{ category: 'Édition', keys: 'Ctrl + Y', description: 'Rétablir l\'action annulée' },
|
||||
|
||||
// Workflow
|
||||
{ category: 'Workflow', keys: 'Ctrl + S', description: 'Sauvegarder le workflow' },
|
||||
{ category: 'Workflow', keys: 'F5', description: 'Exécuter le workflow' },
|
||||
{ category: 'Workflow', keys: 'Ctrl + Entrée', description: 'Exécuter le workflow (alternative)' },
|
||||
{ category: 'Workflow', keys: 'Ctrl + A', description: 'Sélectionner toutes les étapes' },
|
||||
|
||||
// Affichage
|
||||
{ category: 'Affichage', keys: 'Ctrl + +', description: 'Zoomer' },
|
||||
{ category: 'Affichage', keys: 'Ctrl + -', description: 'Dézoomer' },
|
||||
{ category: 'Affichage', keys: 'Ctrl + 0', description: 'Ajuster le zoom' },
|
||||
|
||||
// Aide
|
||||
{ category: 'Aide', keys: 'Échap', description: 'Annuler l\'action en cours' },
|
||||
{ category: 'Aide', keys: 'F1', description: 'Afficher l\'aide' },
|
||||
{ category: 'Aide', keys: 'Shift + ?', description: 'Afficher les raccourcis clavier' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Composant Raccourcis Clavier
|
||||
*/
|
||||
const KeyboardShortcuts: React.FC<KeyboardShortcutsProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
shortcuts = defaultShortcuts,
|
||||
}) => {
|
||||
// Organiser les raccourcis par catégorie
|
||||
const shortcutsByCategory = shortcuts.reduce((acc, shortcut) => {
|
||||
const category = 'category' in shortcut ? (shortcut as any).category : 'Général';
|
||||
if (!acc[category]) {
|
||||
acc[category] = [];
|
||||
}
|
||||
acc[category].push(shortcut);
|
||||
return acc;
|
||||
}, {} as Record<string, typeof shortcuts>);
|
||||
|
||||
// Fonction pour formater les touches
|
||||
const formatKeys = (keys: string) => {
|
||||
return keys.split(' + ').map((key, index, array) => (
|
||||
<React.Fragment key={key}>
|
||||
<Chip
|
||||
label={key}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.75rem',
|
||||
height: 24,
|
||||
backgroundColor: '#f5f5f5',
|
||||
border: '1px solid #ccc',
|
||||
}}
|
||||
/>
|
||||
{index < array.length - 1 && (
|
||||
<Typography
|
||||
component="span"
|
||||
sx={{ mx: 0.5, color: 'text.secondary' }}
|
||||
>
|
||||
+
|
||||
</Typography>
|
||||
)}
|
||||
</React.Fragment>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
aria-labelledby="keyboard-shortcuts-title"
|
||||
>
|
||||
<DialogTitle id="keyboard-shortcuts-title">
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<KeyboardIcon />
|
||||
<Typography variant="h6">
|
||||
Raccourcis Clavier
|
||||
</Typography>
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent>
|
||||
<Typography variant="body2" color="text.secondary" paragraph>
|
||||
Utilisez ces raccourcis clavier pour naviguer et travailler plus efficacement
|
||||
dans le Visual Workflow Builder.
|
||||
</Typography>
|
||||
|
||||
{Object.entries(shortcutsByCategory).map(([category, categoryShortcuts], categoryIndex) => (
|
||||
<Box key={category} sx={{ mb: 3 }}>
|
||||
<Typography variant="h6" gutterBottom color="primary">
|
||||
{category}
|
||||
</Typography>
|
||||
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell sx={{ fontWeight: 'bold', width: '30%' }}>
|
||||
Raccourci
|
||||
</TableCell>
|
||||
<TableCell sx={{ fontWeight: 'bold' }}>
|
||||
Description
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{categoryShortcuts.map((shortcut, index) => (
|
||||
<TableRow key={index} hover>
|
||||
<TableCell>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
{formatKeys(shortcut.keys)}
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2">
|
||||
{shortcut.description}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{categoryIndex < Object.keys(shortcutsByCategory).length - 1 && (
|
||||
<Divider sx={{ mt: 2 }} />
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
|
||||
<Box sx={{ mt: 3, p: 2, backgroundColor: '#f8f9fa', borderRadius: 1 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
💡 Conseils d'accessibilité
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
• Les raccourcis fonctionnent uniquement quand aucun champ de saisie n'est actif<br />
|
||||
• Utilisez Tab pour naviguer entre les éléments de l'interface<br />
|
||||
• Appuyez sur Échap pour annuler une action en cours<br />
|
||||
• F1 ou Shift+? affiche cette aide à tout moment
|
||||
</Typography>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
startIcon={<CloseIcon />}
|
||||
variant="contained"
|
||||
autoFocus
|
||||
>
|
||||
Fermer
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default KeyboardShortcuts;
|
||||
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* Composant CatalogActionItem - Affichage spécialisé pour les actions du catalogue VisionOnly
|
||||
* Auteur : Dom, Alice, Kiro - 09 janvier 2026
|
||||
*
|
||||
* Ce composant gère l'affichage d'une action du catalogue dans la Palette VWB,
|
||||
* avec indicateurs visuels spécialisés, tooltips enrichis, et drag & drop optimisé.
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import {
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Tooltip,
|
||||
Box,
|
||||
Typography,
|
||||
Chip,
|
||||
Badge,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Visibility as VisionIcon,
|
||||
Speed as PerformanceIcon,
|
||||
CheckCircle as ValidatedIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
// Import des types
|
||||
import { StepTemplate } from '../../types';
|
||||
import { VWBCatalogAction } from '../../types/catalog';
|
||||
|
||||
interface CatalogActionItemProps {
|
||||
/** Template de l'étape (converti depuis l'action du catalogue) */
|
||||
stepTemplate: StepTemplate;
|
||||
/** Action originale du catalogue (pour métadonnées enrichies) */
|
||||
catalogAction?: VWBCatalogAction;
|
||||
/** Gestionnaire de début de drag */
|
||||
onDragStart: (event: React.DragEvent, stepTemplate: StepTemplate) => void;
|
||||
/** Indique si l'action est mise en évidence par la recherche */
|
||||
isHighlighted?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composant pour afficher une action du catalogue dans la Palette
|
||||
*/
|
||||
const CatalogActionItem: React.FC<CatalogActionItemProps> = ({
|
||||
stepTemplate,
|
||||
catalogAction,
|
||||
onDragStart,
|
||||
isHighlighted = false,
|
||||
}) => {
|
||||
// Déterminer les indicateurs de qualité
|
||||
const hasExamples = catalogAction?.examples && catalogAction.examples.length > 0;
|
||||
const hasDocumentation = catalogAction?.documentation && catalogAction.documentation.length > 0;
|
||||
const complexityLevel = catalogAction?.metadata?.complexity || 'simple';
|
||||
|
||||
// Couleurs selon la complexité
|
||||
const complexityColors = {
|
||||
simple: '#4caf50', // Vert
|
||||
intermediate: '#ff9800', // Orange
|
||||
advanced: '#f44336' // Rouge
|
||||
};
|
||||
|
||||
// Icônes selon la catégorie
|
||||
const getCategoryIcon = (category: string) => {
|
||||
switch (category) {
|
||||
case 'vision_ui': return '🖱️';
|
||||
case 'control': return '⏳';
|
||||
case 'data': return '📊';
|
||||
case 'navigation': return '🧭';
|
||||
case 'validation': return '✅';
|
||||
default: return '📋';
|
||||
}
|
||||
};
|
||||
|
||||
// Construire le contenu du tooltip enrichi
|
||||
const tooltipContent = (
|
||||
<Box sx={{ maxWidth: 300 }}>
|
||||
{/* En-tête avec nom et badges */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ flex: 1 }}>
|
||||
{stepTemplate.name}
|
||||
</Typography>
|
||||
<Chip
|
||||
label="VisionOnly"
|
||||
size="small"
|
||||
color="primary"
|
||||
sx={{ fontSize: '0.7em' }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Description */}
|
||||
<Typography variant="body2" sx={{ mb: 1.5 }}>
|
||||
{stepTemplate.description}
|
||||
</Typography>
|
||||
|
||||
{/* Paramètres requis */}
|
||||
{stepTemplate.requiredParameters.length > 0 && (
|
||||
<Box sx={{ mb: 1 }}>
|
||||
<Typography variant="caption" color="inherit" sx={{ fontWeight: 'bold' }}>
|
||||
Paramètres requis :
|
||||
</Typography>
|
||||
<Typography variant="caption" color="inherit" sx={{ display: 'block' }}>
|
||||
{stepTemplate.requiredParameters.join(', ')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Exemple d'utilisation */}
|
||||
{hasExamples && catalogAction?.examples[0] && (
|
||||
<Box sx={{ mb: 1 }}>
|
||||
<Typography variant="caption" color="inherit" sx={{ fontWeight: 'bold' }}>
|
||||
Exemple :
|
||||
</Typography>
|
||||
<Typography variant="caption" color="inherit" sx={{ display: 'block' }}>
|
||||
{catalogAction.examples[0].description}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Complexité */}
|
||||
{catalogAction?.metadata?.complexity && (
|
||||
<Box sx={{ mb: 1 }}>
|
||||
<Typography variant="caption" color="inherit" sx={{ fontWeight: 'bold' }}>
|
||||
Complexité :
|
||||
</Typography>
|
||||
<Chip
|
||||
label={complexityLevel}
|
||||
size="small"
|
||||
sx={{
|
||||
ml: 0.5,
|
||||
fontSize: '0.65em',
|
||||
height: 16,
|
||||
backgroundColor: complexityColors[complexityLevel],
|
||||
color: 'white'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Durée estimée */}
|
||||
{catalogAction?.metadata?.estimatedDuration && (
|
||||
<Box sx={{ mb: 1 }}>
|
||||
<Typography variant="caption" color="inherit">
|
||||
⏱️ Durée estimée : {Math.round(catalogAction.metadata.estimatedDuration / 1000)}s
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Indicateur de reconnaissance visuelle */}
|
||||
<Typography variant="caption" color="inherit" sx={{
|
||||
display: 'block',
|
||||
mt: 1,
|
||||
fontStyle: 'italic',
|
||||
backgroundColor: 'rgba(33, 150, 243, 0.1)',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '4px'
|
||||
}}>
|
||||
🎯 Action avec reconnaissance visuelle automatique
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
title={tooltipContent}
|
||||
placement="right"
|
||||
arrow
|
||||
enterDelay={300}
|
||||
leaveDelay={200}
|
||||
>
|
||||
<ListItem
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, stepTemplate)}
|
||||
sx={{
|
||||
cursor: 'grab',
|
||||
backgroundColor: isHighlighted ? '#e8f4fd' : '#f0f4ff',
|
||||
borderLeft: '3px solid #2196f3',
|
||||
borderRadius: '0 4px 4px 0',
|
||||
mb: 0.5,
|
||||
'&:hover': {
|
||||
backgroundColor: isHighlighted ? '#d1e7dd' : '#e3f2fd',
|
||||
transform: 'translateX(2px)',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
},
|
||||
'&:active': {
|
||||
cursor: 'grabbing',
|
||||
transform: 'translateX(1px) scale(0.98)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Icône principale avec badge de catégorie */}
|
||||
<ListItemIcon sx={{ minWidth: 40 }}>
|
||||
<Badge
|
||||
badgeContent={getCategoryIcon(catalogAction?.category || 'vision_ui')}
|
||||
sx={{
|
||||
'& .MuiBadge-badge': {
|
||||
fontSize: '0.6em',
|
||||
minWidth: 16,
|
||||
height: 16,
|
||||
backgroundColor: 'transparent',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ fontSize: '1.2em' }}>
|
||||
{stepTemplate.icon}
|
||||
</Typography>
|
||||
</Badge>
|
||||
</ListItemIcon>
|
||||
|
||||
{/* Contenu principal */}
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Typography variant="body2" noWrap sx={{ flex: 1, fontWeight: 500 }}>
|
||||
{stepTemplate.name}
|
||||
</Typography>
|
||||
|
||||
{/* Indicateurs de qualité */}
|
||||
<Box sx={{ display: 'flex', gap: 0.25 }}>
|
||||
{hasExamples && (
|
||||
<Tooltip title="Exemples disponibles">
|
||||
<ValidatedIcon sx={{ fontSize: 12, color: '#4caf50' }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{hasDocumentation && (
|
||||
<Tooltip title="Documentation complète">
|
||||
<VisionIcon sx={{ fontSize: 12, color: '#2196f3' }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{catalogAction?.metadata?.estimatedDuration && catalogAction.metadata.estimatedDuration < 2000 && (
|
||||
<Tooltip title="Exécution rapide">
|
||||
<PerformanceIcon sx={{ fontSize: 12, color: '#ff9800' }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Label VISION */}
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: '#2196f3',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '0.65em',
|
||||
backgroundColor: 'rgba(33, 150, 243, 0.1)',
|
||||
padding: '1px 4px',
|
||||
borderRadius: '2px'
|
||||
}}
|
||||
>
|
||||
VISION
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
secondary={
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
noWrap
|
||||
sx={{ fontSize: '0.7em' }}
|
||||
>
|
||||
{stepTemplate.description}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
// Mémorisation pour optimiser les performances
|
||||
export default memo(CatalogActionItem, (prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.stepTemplate.id === nextProps.stepTemplate.id &&
|
||||
prevProps.isHighlighted === nextProps.isHighlighted &&
|
||||
JSON.stringify(prevProps.catalogAction) === JSON.stringify(nextProps.catalogAction)
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,740 @@
|
||||
/**
|
||||
* Composant Palette d'Étapes Étendue - Boîte à outils avec catégories françaises et actions VisionOnly
|
||||
* Auteur : Dom, Alice, Kiro - 09 janvier 2026
|
||||
*
|
||||
* Ce composant affiche les types d'étapes disponibles organisés en catégories françaises,
|
||||
* avec recherche débouncée, tooltips explicatifs, support du drag-and-drop optimisé,
|
||||
* et intégration complète du catalogue d'actions VisionOnly.
|
||||
*
|
||||
* NOUVELLES FONCTIONNALITÉS:
|
||||
* - Intégration du catalogue d'actions VisionOnly
|
||||
* - Catégories dynamiques depuis l'API catalogue
|
||||
* - Actions Vision UI, Contrôle, Données du catalogue
|
||||
* - Recherche unifiée (actions par défaut + catalogue)
|
||||
* - Indicateurs de statut du service catalogue
|
||||
* - Tooltips enrichis avec exemples d'actions
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo, useCallback, memo, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
TextField,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Tooltip,
|
||||
InputAdornment,
|
||||
Chip,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Badge,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
Search as SearchIcon,
|
||||
CloudOff as OfflineIcon,
|
||||
CheckCircle as OnlineIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
// Import des types partagés
|
||||
import { PaletteProps, StepCategory, StepTemplate } from '../../types';
|
||||
|
||||
// Import des types du catalogue
|
||||
import {
|
||||
VWBCatalogAction,
|
||||
VWBActionCategory,
|
||||
VWBActionCategoryInfo
|
||||
} from '../../types/catalog';
|
||||
|
||||
// Import du service catalogue
|
||||
import { catalogService } from '../../services/catalogService';
|
||||
|
||||
// Import des hooks d'optimisation
|
||||
import { useDebounce } from '../../hooks/useDebounce';
|
||||
|
||||
// Import des tooltips
|
||||
import { getTooltip } from '../../utils/tooltips';
|
||||
|
||||
// Import du hook de gestion du catalogue
|
||||
import { useCatalogActions } from '../../hooks/useCatalogActions';
|
||||
|
||||
// Catégories par défaut désactivées - Mode 100% Visuel uniquement
|
||||
// Les actions legacy basées sur sélecteurs CSS ont été supprimées
|
||||
// Seules les actions VisionOnly du catalogue sont disponibles
|
||||
const getDefaultCategories = (): StepCategory[] => [];
|
||||
|
||||
// Mapping des catégories du catalogue vers les métadonnées d'affichage (100% visuel)
|
||||
const getCatalogCategoryMetadata = (categoryId: VWBActionCategory): {
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
} => {
|
||||
const metadata: Record<VWBActionCategory, { name: string; description: string; icon: string; color: string }> = {
|
||||
vision_ui: {
|
||||
name: 'Interactions Visuelles',
|
||||
description: 'Cliquer, saisir, glisser-déposer sur des éléments visuels',
|
||||
icon: '🖱️',
|
||||
color: '#2196f3',
|
||||
},
|
||||
control: {
|
||||
name: 'Contrôle de Flux',
|
||||
description: 'Conditions, boucles et synchronisation basées sur la vision',
|
||||
icon: '🔀',
|
||||
color: '#ff9800',
|
||||
},
|
||||
data: {
|
||||
name: 'Extraction de Données',
|
||||
description: 'Extraire texte, tableaux, télécharger des fichiers',
|
||||
icon: '📊',
|
||||
color: '#4caf50',
|
||||
},
|
||||
intelligence: {
|
||||
name: 'Intelligence IA',
|
||||
description: 'Analyse et traitement intelligent par IA',
|
||||
icon: '🤖',
|
||||
color: '#9c27b0',
|
||||
},
|
||||
database: {
|
||||
name: 'Base de Données',
|
||||
description: 'Lire et enregistrer des données en base',
|
||||
icon: '💾',
|
||||
color: '#00bcd4',
|
||||
},
|
||||
validation: {
|
||||
name: 'Validation',
|
||||
description: 'Vérifier la présence et le contenu des éléments',
|
||||
icon: '✅',
|
||||
color: '#f44336',
|
||||
},
|
||||
};
|
||||
|
||||
return metadata[categoryId] || {
|
||||
name: categoryId,
|
||||
description: `Actions de type ${categoryId}`,
|
||||
icon: '📋',
|
||||
color: '#757575',
|
||||
};
|
||||
};
|
||||
|
||||
// Convertir une action du catalogue en StepTemplate pour compatibilité
|
||||
const convertCatalogActionToStepTemplate = (action: VWBCatalogAction): StepTemplate => {
|
||||
// Extraire les paramètres requis
|
||||
const requiredParameters = Object.entries(action.parameters)
|
||||
.filter(([_, param]) => param.required)
|
||||
.map(([name, _]) => name);
|
||||
|
||||
// Extraire les paramètres par défaut
|
||||
const defaultParameters = Object.entries(action.parameters)
|
||||
.filter(([_, param]) => param.default !== undefined)
|
||||
.reduce((acc, [name, param]) => {
|
||||
acc[name] = param.default;
|
||||
return acc;
|
||||
}, {} as Record<string, any>);
|
||||
|
||||
return {
|
||||
id: action.id,
|
||||
type: action.id as any, // Utiliser l'ID comme type pour les actions du catalogue
|
||||
name: action.name,
|
||||
description: action.description,
|
||||
icon: action.icon,
|
||||
defaultParameters,
|
||||
requiredParameters,
|
||||
};
|
||||
};
|
||||
|
||||
// Interface pour l'état du catalogue
|
||||
interface CatalogState {
|
||||
actions: VWBCatalogAction[];
|
||||
categories: Array<{
|
||||
id: VWBActionCategory;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
actionCount: number;
|
||||
}>;
|
||||
isLoading: boolean;
|
||||
isOnline: boolean;
|
||||
error: string | null;
|
||||
lastUpdate: Date | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composant Palette pour la sélection d'étapes avec intégration du catalogue VisionOnly
|
||||
*/
|
||||
const Palette: React.FC<PaletteProps> = ({
|
||||
categories,
|
||||
searchTerm,
|
||||
onSearch,
|
||||
onStepDrag,
|
||||
}) => {
|
||||
// Utiliser les catégories par défaut si aucune n'est fournie
|
||||
const effectiveCategories = categories.length > 0 ? categories : getDefaultCategories();
|
||||
|
||||
const [expandedCategories, setExpandedCategories] = useState<string[]>([
|
||||
'actions-web',
|
||||
'vision_ui', // Étendre automatiquement les actions Vision UI
|
||||
]);
|
||||
|
||||
// Utiliser le hook de gestion du catalogue
|
||||
const {
|
||||
state: catalogHookState,
|
||||
filteredActions: catalogActions,
|
||||
actions: catalogActionMethods,
|
||||
} = useCatalogActions({
|
||||
autoLoad: true,
|
||||
refreshInterval: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
|
||||
// Utiliser l'état du hook comme état principal du catalogue
|
||||
const catalogState = useMemo(() => ({
|
||||
actions: catalogHookState.actions,
|
||||
categories: catalogHookState.categories.map(cat => ({
|
||||
id: cat.id,
|
||||
name: cat.name,
|
||||
description: cat.description,
|
||||
icon: cat.icon,
|
||||
actionCount: cat.actionCount,
|
||||
})),
|
||||
isLoading: catalogHookState.isLoading,
|
||||
isOnline: catalogHookState.isOnline,
|
||||
error: catalogHookState.error,
|
||||
lastUpdate: catalogHookState.lastUpdate,
|
||||
}), [catalogHookState]);
|
||||
|
||||
// Debouncing de la recherche pour optimiser les performances
|
||||
const debouncedSearchTerm = useDebounce(searchTerm, 300);
|
||||
|
||||
// Gestionnaire d'événements clavier pour la palette
|
||||
const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
|
||||
switch (event.key) {
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
// Activer l'élément focalisé
|
||||
event.preventDefault();
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
case 'ArrowDown':
|
||||
// Navigation verticale dans la liste
|
||||
event.preventDefault();
|
||||
break;
|
||||
case 'Escape':
|
||||
// Fermer les catégories étendues
|
||||
event.preventDefault();
|
||||
setExpandedCategories([]);
|
||||
break;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Convertir les actions du catalogue en catégories compatibles
|
||||
const catalogCategories = useMemo((): StepCategory[] => {
|
||||
if (catalogState.actions.length === 0) return [];
|
||||
|
||||
// Grouper les actions par catégorie
|
||||
const actionsByCategory = catalogState.actions.reduce((acc, action) => {
|
||||
if (!acc[action.category]) {
|
||||
acc[action.category] = [];
|
||||
}
|
||||
acc[action.category].push(action);
|
||||
return acc;
|
||||
}, {} as Record<VWBActionCategory, VWBCatalogAction[]>);
|
||||
|
||||
// Créer les catégories avec leurs actions
|
||||
return Object.entries(actionsByCategory).map(([categoryId, actions]) => {
|
||||
const metadata = getCatalogCategoryMetadata(categoryId as VWBActionCategory);
|
||||
|
||||
return {
|
||||
id: `catalog_${categoryId}`,
|
||||
name: metadata.name,
|
||||
description: metadata.description,
|
||||
icon: metadata.icon,
|
||||
steps: actions.map(convertCatalogActionToStepTemplate),
|
||||
};
|
||||
});
|
||||
}, [catalogState.actions]);
|
||||
|
||||
// Combiner les catégories par défaut et du catalogue
|
||||
const allCategories = useMemo(() => {
|
||||
return [...effectiveCategories, ...catalogCategories];
|
||||
}, [effectiveCategories, catalogCategories]);
|
||||
|
||||
// Filtrage des étapes selon le terme de recherche débouncé
|
||||
const filteredCategories = useMemo(() => {
|
||||
if (!debouncedSearchTerm.trim()) {
|
||||
return allCategories;
|
||||
}
|
||||
|
||||
const searchLower = debouncedSearchTerm.toLowerCase();
|
||||
|
||||
return allCategories
|
||||
.map((category) => ({
|
||||
...category,
|
||||
steps: category.steps.filter((step) =>
|
||||
step.name.toLowerCase().includes(searchLower) ||
|
||||
step.description.toLowerCase().includes(searchLower) ||
|
||||
step.type.toLowerCase().includes(searchLower)
|
||||
),
|
||||
}))
|
||||
.filter((category) => category.steps.length > 0);
|
||||
}, [allCategories, debouncedSearchTerm]);
|
||||
|
||||
// Gestionnaire d'expansion des catégories
|
||||
const handleCategoryToggle = (categoryId: string) => {
|
||||
setExpandedCategories((prev) =>
|
||||
prev.includes(categoryId)
|
||||
? prev.filter((id) => id !== categoryId)
|
||||
: [...prev, categoryId]
|
||||
);
|
||||
};
|
||||
|
||||
// Gestionnaire de début de drag
|
||||
const handleDragStart = (event: React.DragEvent, stepTemplate: StepTemplate) => {
|
||||
// Pour les actions du catalogue, utiliser un format spécial
|
||||
const dragData = stepTemplate.id.startsWith('catalog_')
|
||||
? `catalog:${stepTemplate.type}`
|
||||
: stepTemplate.type;
|
||||
|
||||
event.dataTransfer.setData('application/reactflow', dragData);
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
onStepDrag(stepTemplate);
|
||||
};
|
||||
|
||||
// Gestionnaire de rechargement du catalogue
|
||||
const handleReloadCatalog = useCallback(async () => {
|
||||
await catalogActionMethods.reload();
|
||||
}, [catalogActionMethods]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
width: 280,
|
||||
height: '100%',
|
||||
backgroundColor: '#ffffff',
|
||||
borderRight: '1px solid #e0e0e0',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
role="complementary"
|
||||
aria-label="Palette d'étapes disponibles avec actions VisionOnly"
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={0}
|
||||
>
|
||||
{/* En-tête avec titre et indicateur de statut */}
|
||||
<Box sx={{ p: 2, borderBottom: '1px solid #e0e0e0' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="h6" component="h2">
|
||||
Palette d'Étapes
|
||||
</Typography>
|
||||
|
||||
{/* Indicateur de statut du catalogue étendu */}
|
||||
<Tooltip
|
||||
title={
|
||||
<Box>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
{catalogHookState.mode === 'dynamic' ? '🌐 Mode Dynamique' :
|
||||
catalogHookState.mode === 'static' ? '📦 Mode Statique' :
|
||||
'🔴 Mode Hors Ligne'}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
{catalogHookState.mode === 'dynamic'
|
||||
? `Connecté au service catalogue - ${catalogState.actions.length} actions disponibles`
|
||||
: catalogHookState.mode === 'static'
|
||||
? `Catalogue de secours actif - ${catalogState.actions.length} actions de base`
|
||||
: 'Service catalogue indisponible'}
|
||||
</Typography>
|
||||
{catalogHookState.serviceUrl && (
|
||||
<Typography variant="caption" color="inherit" sx={{ display: 'block', mb: 0.5 }}>
|
||||
URL: {catalogHookState.serviceUrl}
|
||||
</Typography>
|
||||
)}
|
||||
{catalogHookState.error && (
|
||||
<Typography variant="caption" color="error" sx={{ display: 'block', mb: 0.5 }}>
|
||||
Erreur: {catalogHookState.error}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="caption" color="inherit" sx={{ fontStyle: 'italic' }}>
|
||||
{catalogHookState.mode === 'dynamic'
|
||||
? 'Cliquez pour forcer une re-détection'
|
||||
: 'Cliquez pour réessayer la connexion'}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
placement="left"
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
cursor: 'pointer',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '4px',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.04)',
|
||||
},
|
||||
}}
|
||||
onClick={async () => {
|
||||
if (catalogHookState.mode === 'dynamic') {
|
||||
await catalogActionMethods.forceUrlDetection();
|
||||
} else {
|
||||
await catalogActionMethods.reload();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{catalogState.isLoading ? (
|
||||
<CircularProgress size={16} />
|
||||
) : (
|
||||
<>
|
||||
{/* Icône selon le mode */}
|
||||
{catalogHookState.mode === 'dynamic' ? (
|
||||
<Badge badgeContent={catalogState.actions.length} color="primary" max={99}>
|
||||
<OnlineIcon color="success" fontSize="small" />
|
||||
</Badge>
|
||||
) : catalogHookState.mode === 'static' ? (
|
||||
<Badge badgeContent={catalogState.actions.length} color="warning" max={99}>
|
||||
<Box sx={{ fontSize: '16px' }}>📦</Box>
|
||||
</Badge>
|
||||
) : (
|
||||
<OfflineIcon color="disabled" fontSize="small" />
|
||||
)}
|
||||
|
||||
{/* Texte du mode */}
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontSize: '0.7em',
|
||||
fontWeight: 'bold',
|
||||
color: catalogHookState.mode === 'dynamic' ? 'success.main' :
|
||||
catalogHookState.mode === 'static' ? 'warning.main' :
|
||||
'text.disabled'
|
||||
}}
|
||||
>
|
||||
{catalogHookState.mode === 'dynamic' ? 'LIVE' :
|
||||
catalogHookState.mode === 'static' ? 'LOCAL' :
|
||||
'OFF'}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{/* Champ de recherche */}
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder="Rechercher une étape..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => onSearch(e.target.value)}
|
||||
aria-label="Rechercher dans les étapes disponibles"
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon />
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Statistiques du catalogue avec mode */}
|
||||
<Box sx={{ mt: 1, display: 'flex', gap: 0.5, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<Chip
|
||||
label={`${effectiveCategories.length} catégories par défaut`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="default"
|
||||
/>
|
||||
{catalogState.actions.length > 0 && (
|
||||
<Chip
|
||||
label={`${catalogState.actions.length} actions ${catalogHookState.mode === 'static' ? 'locales' : 'VisionOnly'}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color={catalogHookState.mode === 'dynamic' ? 'primary' : 'warning'}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Bouton de réinitialisation pour les cas problématiques */}
|
||||
{(catalogHookState.error || catalogHookState.mode === 'offline') && (
|
||||
<Tooltip title="Réinitialiser complètement le service catalogue">
|
||||
<Chip
|
||||
label="🔄 Reset"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="error"
|
||||
clickable
|
||||
onClick={async () => {
|
||||
await catalogActionMethods.resetService();
|
||||
}}
|
||||
sx={{ fontSize: '0.7em' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Message d'erreur du catalogue avec actions */}
|
||||
{catalogState.error && !catalogState.isLoading && (
|
||||
<Alert
|
||||
severity={catalogHookState.mode === 'static' ? 'info' : 'warning'}
|
||||
sx={{ mt: 1 }}
|
||||
action={
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ cursor: 'pointer', textDecoration: 'underline' }}
|
||||
onClick={handleReloadCatalog}
|
||||
>
|
||||
Recharger
|
||||
</Typography>
|
||||
{catalogHookState.mode !== 'static' && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ cursor: 'pointer', textDecoration: 'underline' }}
|
||||
onClick={async () => {
|
||||
await catalogActionMethods.forceUrlDetection();
|
||||
}}
|
||||
>
|
||||
Re-détecter
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
{catalogHookState.mode === 'static'
|
||||
? 'Mode local actif - Actions de base disponibles'
|
||||
: 'Service catalogue indisponible - Mode local activé'
|
||||
}
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Liste des catégories et étapes */}
|
||||
<Box sx={{ flex: 1, overflow: 'auto' }}>
|
||||
{filteredCategories.map((category) => {
|
||||
const isCatalogCategory = category.id.startsWith('catalog_');
|
||||
|
||||
return (
|
||||
<Accordion
|
||||
key={category.id}
|
||||
expanded={expandedCategories.includes(category.id)}
|
||||
onChange={() => handleCategoryToggle(category.id)}
|
||||
disableGutters
|
||||
elevation={0}
|
||||
sx={{
|
||||
'&:before': { display: 'none' },
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
backgroundColor: isCatalogCategory ? '#f8f9ff' : 'inherit',
|
||||
}}
|
||||
>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMoreIcon />}
|
||||
sx={{
|
||||
minHeight: 48,
|
||||
'& .MuiAccordionSummary-content': {
|
||||
alignItems: 'center',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tooltip
|
||||
title={
|
||||
<Box>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
{category.name}
|
||||
{isCatalogCategory && (
|
||||
<Chip
|
||||
label="VisionOnly"
|
||||
size="small"
|
||||
color="primary"
|
||||
sx={{ ml: 1, fontSize: '0.7em' }}
|
||||
/>
|
||||
)}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{category.description}
|
||||
</Typography>
|
||||
{getTooltip('category', category.id) && (
|
||||
<Typography variant="caption" color="inherit" sx={{ mt: 1, display: 'block' }}>
|
||||
Exemple : {getTooltip('category', category.id)?.example}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
placement="right"
|
||||
arrow
|
||||
enterDelay={700}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, width: '100%' }}>
|
||||
<Typography sx={{ fontSize: '1.2em' }}>{category.icon}</Typography>
|
||||
<Typography variant="subtitle2" sx={{ flex: 1 }}>
|
||||
{category.name}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
({category.steps.length})
|
||||
</Typography>
|
||||
{isCatalogCategory && (
|
||||
<Chip
|
||||
label="Vision"
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
sx={{ fontSize: '0.65em', height: 18 }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</AccordionSummary>
|
||||
|
||||
<AccordionDetails sx={{ p: 0 }}>
|
||||
<List dense>
|
||||
{category.steps.map((step) => {
|
||||
const isFromCatalog = category.id.startsWith('catalog_');
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
key={step.id}
|
||||
title={
|
||||
<Box>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
{step.name}
|
||||
{isFromCatalog && (
|
||||
<Chip
|
||||
label="VisionOnly"
|
||||
size="small"
|
||||
color="primary"
|
||||
sx={{ ml: 1, fontSize: '0.7em' }}
|
||||
/>
|
||||
)}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
{step.description}
|
||||
</Typography>
|
||||
{step.requiredParameters.length > 0 && (
|
||||
<Typography variant="caption" color="inherit" sx={{ display: 'block', mb: 0.5 }}>
|
||||
Paramètres requis : {step.requiredParameters.join(', ')}
|
||||
</Typography>
|
||||
)}
|
||||
{getTooltip('step', step.type) && (
|
||||
<>
|
||||
<Typography variant="caption" color="inherit">
|
||||
{getTooltip('step', step.type)?.example}
|
||||
</Typography>
|
||||
<br />
|
||||
<Typography variant="caption" color="inherit" fontStyle="italic">
|
||||
{getTooltip('step', step.type)?.shortcut}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
{isFromCatalog && (
|
||||
<Typography variant="caption" color="inherit" sx={{ display: 'block', mt: 1, fontStyle: 'italic' }}>
|
||||
🎯 Action avec reconnaissance visuelle automatique
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
placement="right"
|
||||
arrow
|
||||
enterDelay={500}
|
||||
leaveDelay={200}
|
||||
>
|
||||
<ListItem
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, step)}
|
||||
sx={{
|
||||
cursor: 'grab',
|
||||
backgroundColor: isFromCatalog ? '#f0f4ff' : 'inherit',
|
||||
'&:hover': {
|
||||
backgroundColor: isFromCatalog ? '#e3f2fd' : '#f5f5f5',
|
||||
},
|
||||
'&:active': {
|
||||
cursor: 'grabbing',
|
||||
},
|
||||
borderLeft: isFromCatalog ? '3px solid #2196f3' : 'none',
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 36 }}>
|
||||
<Typography sx={{ fontSize: '1.1em' }}>{step.icon}</Typography>
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Typography variant="body2" noWrap sx={{ flex: 1 }}>
|
||||
{step.name}
|
||||
</Typography>
|
||||
{isFromCatalog && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: '#2196f3',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '0.65em'
|
||||
}}
|
||||
>
|
||||
VISION
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
|
||||
{/* Message si aucun résultat */}
|
||||
{filteredCategories.length === 0 && !catalogState.isLoading && (
|
||||
<Box sx={{ p: 2, textAlign: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Aucune étape trouvée pour "{searchTerm}"
|
||||
</Typography>
|
||||
{catalogState.error && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
|
||||
Le catalogue VisionOnly est hors ligne
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Indicateur de chargement */}
|
||||
{catalogState.isLoading && (
|
||||
<Box sx={{ p: 2, textAlign: 'center' }}>
|
||||
<CircularProgress size={24} />
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
|
||||
Chargement du catalogue VisionOnly...
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Pied de page avec informations */}
|
||||
{catalogState.lastUpdate && (
|
||||
<Box sx={{ p: 1, borderTop: '1px solid #f0f0f0', backgroundColor: '#fafafa' }}>
|
||||
<Typography variant="caption" color="text.secondary" align="center" display="block">
|
||||
Dernière mise à jour : {catalogState.lastUpdate.toLocaleTimeString('fr-FR')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// Mémorisation du composant Palette pour éviter les re-rendus inutiles
|
||||
export default memo(Palette, (prevProps, nextProps) => {
|
||||
// Comparaison personnalisée pour optimiser les re-rendus
|
||||
return (
|
||||
JSON.stringify(prevProps.categories) === JSON.stringify(nextProps.categories) &&
|
||||
prevProps.searchTerm === nextProps.searchTerm &&
|
||||
prevProps.onSearch === nextProps.onSearch &&
|
||||
prevProps.onStepDrag === nextProps.onStepDrag
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,509 @@
|
||||
/**
|
||||
* Composant de Test Debug - Propriétés d'Étapes
|
||||
* Auteur : Dom, Alice, Kiro - 12 janvier 2026
|
||||
*
|
||||
* Ce composant teste et affiche les informations de débogage pour
|
||||
* diagnostiquer le problème des propriétés d'étapes vides.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Alert,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
Chip,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
BugReport as BugIcon,
|
||||
CheckCircle as CheckIcon,
|
||||
Error as ErrorIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
// Import des types
|
||||
import { Step, StepType, StepExecutionState } from '../types';
|
||||
|
||||
// Import des hooks VWB
|
||||
import { useVWBStepIntegration, useIsVWBStep, useVWBActionId } from '../hooks/useVWBStepIntegration';
|
||||
|
||||
// Configuration des paramètres (copie de PropertiesPanel)
|
||||
interface ParameterConfig {
|
||||
name: string;
|
||||
label: string;
|
||||
type: 'text' | 'number' | 'boolean' | 'select' | 'visual';
|
||||
required?: boolean;
|
||||
description?: string;
|
||||
supportVariables?: boolean;
|
||||
options?: { value: string; label: string }[];
|
||||
defaultValue?: any;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
const stepParametersConfig: Record<StepType, ParameterConfig[]> = {
|
||||
click: [
|
||||
{
|
||||
name: 'target',
|
||||
label: 'Élément cible',
|
||||
type: 'visual',
|
||||
required: true,
|
||||
description: 'Sélectionner l\'élément à cliquer',
|
||||
},
|
||||
{
|
||||
name: 'clickType',
|
||||
label: 'Type de clic',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'left', label: 'Clic gauche' },
|
||||
{ value: 'right', label: 'Clic droit' },
|
||||
{ value: 'double', label: 'Double-clic' },
|
||||
],
|
||||
defaultValue: 'left',
|
||||
},
|
||||
],
|
||||
type: [
|
||||
{
|
||||
name: 'target',
|
||||
label: 'Champ de saisie',
|
||||
type: 'visual',
|
||||
required: true,
|
||||
description: 'Sélectionner le champ où saisir le texte',
|
||||
},
|
||||
{
|
||||
name: 'text',
|
||||
label: 'Texte à saisir',
|
||||
type: 'text',
|
||||
required: true,
|
||||
supportVariables: true,
|
||||
},
|
||||
{
|
||||
name: 'clearFirst',
|
||||
label: 'Vider le champ d\'abord',
|
||||
type: 'boolean',
|
||||
defaultValue: true,
|
||||
},
|
||||
],
|
||||
wait: [
|
||||
{
|
||||
name: 'duration',
|
||||
label: 'Durée (secondes)',
|
||||
type: 'number',
|
||||
required: true,
|
||||
min: 0.1,
|
||||
max: 60,
|
||||
defaultValue: 1,
|
||||
},
|
||||
],
|
||||
condition: [
|
||||
{
|
||||
name: 'condition',
|
||||
label: 'Condition',
|
||||
type: 'text',
|
||||
required: true,
|
||||
supportVariables: true,
|
||||
description: 'Expression conditionnelle à évaluer',
|
||||
},
|
||||
],
|
||||
extract: [
|
||||
{
|
||||
name: 'target',
|
||||
label: 'Élément source',
|
||||
type: 'visual',
|
||||
required: true,
|
||||
description: 'Sélectionner l\'élément dont extraire les données',
|
||||
},
|
||||
{
|
||||
name: 'attribute',
|
||||
label: 'Attribut à extraire',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'text', label: 'Texte' },
|
||||
{ value: 'value', label: 'Valeur' },
|
||||
{ value: 'href', label: 'Lien (href)' },
|
||||
{ value: 'src', label: 'Source (src)' },
|
||||
],
|
||||
defaultValue: 'text',
|
||||
},
|
||||
],
|
||||
scroll: [
|
||||
{
|
||||
name: 'direction',
|
||||
label: 'Direction',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'up', label: 'Vers le haut' },
|
||||
{ value: 'down', label: 'Vers le bas' },
|
||||
{ value: 'left', label: 'Vers la gauche' },
|
||||
{ value: 'right', label: 'Vers la droite' },
|
||||
],
|
||||
defaultValue: 'down',
|
||||
},
|
||||
{
|
||||
name: 'amount',
|
||||
label: 'Quantité (pixels)',
|
||||
type: 'number',
|
||||
defaultValue: 300,
|
||||
min: 1,
|
||||
},
|
||||
],
|
||||
navigate: [
|
||||
{
|
||||
name: 'url',
|
||||
label: 'URL de destination',
|
||||
type: 'text',
|
||||
required: true,
|
||||
supportVariables: true,
|
||||
},
|
||||
],
|
||||
screenshot: [
|
||||
{
|
||||
name: 'filename',
|
||||
label: 'Nom du fichier',
|
||||
type: 'text',
|
||||
supportVariables: true,
|
||||
description: 'Nom du fichier de capture (optionnel)',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Composant de test pour déboguer les propriétés d'étapes
|
||||
*/
|
||||
const PropertiesDebugTest: React.FC = () => {
|
||||
const [selectedStepType, setSelectedStepType] = useState<StepType>('click');
|
||||
const [testResults, setTestResults] = useState<any[]>([]);
|
||||
|
||||
// Hooks VWB
|
||||
const { methods: vwbMethods } = useVWBStepIntegration();
|
||||
|
||||
// Créer une étape de test
|
||||
const createTestStep = useCallback((stepType: StepType): Step => {
|
||||
return {
|
||||
id: `test_step_${Date.now()}`,
|
||||
type: stepType,
|
||||
name: `Test ${stepType}`,
|
||||
position: { x: 100, y: 100 },
|
||||
data: {
|
||||
label: `Test ${stepType}`,
|
||||
stepType: stepType,
|
||||
parameters: {},
|
||||
},
|
||||
executionState: StepExecutionState.IDLE,
|
||||
validationErrors: [],
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Tester la résolution des paramètres
|
||||
const testParameterResolution = useCallback((stepType: StepType) => {
|
||||
console.log(`🧪 Test de résolution pour le type: ${stepType}`);
|
||||
|
||||
const testStep = createTestStep(stepType);
|
||||
|
||||
// Test 1: Configuration directe
|
||||
const directConfig = stepParametersConfig[stepType];
|
||||
|
||||
// Test 2: Fonction getParameterConfig simulée
|
||||
const getParameterConfig = (step: Step): ParameterConfig[] => {
|
||||
if (!step) return [];
|
||||
console.log(`🔍 Recherche config pour type: "${step.type}"`);
|
||||
console.log(`🔍 Clés disponibles:`, Object.keys(stepParametersConfig));
|
||||
console.log(`🔍 Type exact match:`, stepParametersConfig[step.type] !== undefined);
|
||||
return stepParametersConfig[step.type] || [];
|
||||
};
|
||||
|
||||
const resolvedConfig = getParameterConfig(testStep);
|
||||
|
||||
// Test 3: Hooks VWB
|
||||
const isVWBStep = useIsVWBStep(testStep);
|
||||
const vwbActionId = useVWBActionId(testStep);
|
||||
|
||||
const result = {
|
||||
stepType,
|
||||
testStep,
|
||||
directConfig: directConfig || null,
|
||||
directConfigLength: directConfig ? directConfig.length : 0,
|
||||
resolvedConfig,
|
||||
resolvedConfigLength: resolvedConfig.length,
|
||||
isVWBStep,
|
||||
vwbActionId,
|
||||
configExists: stepParametersConfig[stepType] !== undefined,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
console.log(`✅ Résultat test ${stepType}:`, result);
|
||||
|
||||
setTestResults(prev => [...prev, result]);
|
||||
|
||||
return result;
|
||||
}, [createTestStep]);
|
||||
|
||||
// Tester tous les types d'étapes
|
||||
const testAllStepTypes = useCallback(() => {
|
||||
console.log('🚀 Test de tous les types d\'étapes');
|
||||
setTestResults([]);
|
||||
|
||||
const allTypes: StepType[] = ['click', 'type', 'wait', 'condition', 'extract', 'scroll', 'navigate', 'screenshot'];
|
||||
|
||||
allTypes.forEach(stepType => {
|
||||
setTimeout(() => testParameterResolution(stepType), 100);
|
||||
});
|
||||
}, [testParameterResolution]);
|
||||
|
||||
// Analyser les résultats
|
||||
const analysisResults = useMemo(() => {
|
||||
if (testResults.length === 0) return null;
|
||||
|
||||
const totalTests = testResults.length;
|
||||
const successfulResolutions = testResults.filter(r => r.resolvedConfigLength > 0).length;
|
||||
const failedResolutions = testResults.filter(r => r.resolvedConfigLength === 0).length;
|
||||
const vwbSteps = testResults.filter(r => r.isVWBStep).length;
|
||||
|
||||
return {
|
||||
totalTests,
|
||||
successfulResolutions,
|
||||
failedResolutions,
|
||||
vwbSteps,
|
||||
successRate: (successfulResolutions / totalTests) * 100,
|
||||
};
|
||||
}, [testResults]);
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3, maxWidth: 1200, margin: '0 auto' }}>
|
||||
<Typography variant="h4" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<BugIcon color="primary" />
|
||||
Test Debug - Propriétés d'Étapes
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
Ce composant teste la résolution des propriétés d'étapes pour diagnostiquer
|
||||
pourquoi les paramètres apparaissent vides dans l'interface.
|
||||
</Typography>
|
||||
|
||||
{/* Contrôles de test */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Contrôles de Test
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 2 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={testAllStepTypes}
|
||||
startIcon={<BugIcon />}
|
||||
>
|
||||
Tester Tous les Types
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => setTestResults([])}
|
||||
>
|
||||
Vider les Résultats
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Cliquez sur "Tester Tous les Types" pour exécuter les tests de résolution
|
||||
des paramètres pour chaque type d'étape.
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Analyse des résultats */}
|
||||
{analysisResults && (
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{analysisResults.successRate === 100 ? (
|
||||
<CheckIcon color="success" />
|
||||
) : (
|
||||
<ErrorIcon color="error" />
|
||||
)}
|
||||
Analyse des Résultats
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', mb: 2 }}>
|
||||
<Chip
|
||||
label={`Total: ${analysisResults.totalTests}`}
|
||||
color="primary"
|
||||
/>
|
||||
<Chip
|
||||
label={`Succès: ${analysisResults.successfulResolutions}`}
|
||||
color="success"
|
||||
/>
|
||||
<Chip
|
||||
label={`Échecs: ${analysisResults.failedResolutions}`}
|
||||
color="error"
|
||||
/>
|
||||
<Chip
|
||||
label={`VWB: ${analysisResults.vwbSteps}`}
|
||||
color="info"
|
||||
/>
|
||||
<Chip
|
||||
label={`Taux: ${analysisResults.successRate.toFixed(1)}%`}
|
||||
color={analysisResults.successRate === 100 ? 'success' : 'warning'}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{analysisResults.failedResolutions > 0 && (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
<Typography variant="body2">
|
||||
{analysisResults.failedResolutions} type(s) d'étapes ne résolvent pas leurs paramètres correctement.
|
||||
Cela explique pourquoi les propriétés apparaissent vides dans l'interface.
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Configuration disponible */}
|
||||
<Accordion sx={{ mb: 3 }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="h6">
|
||||
Configuration stepParametersConfig Disponible
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Type d'Étape</TableCell>
|
||||
<TableCell>Nombre de Paramètres</TableCell>
|
||||
<TableCell>Paramètres</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{Object.entries(stepParametersConfig).map(([stepType, config]) => (
|
||||
<TableRow key={stepType}>
|
||||
<TableCell>
|
||||
<Chip label={stepType} size="small" />
|
||||
</TableCell>
|
||||
<TableCell>{config.length}</TableCell>
|
||||
<TableCell>
|
||||
{config.map(param => param.name).join(', ')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
{/* Résultats des tests */}
|
||||
{testResults.length > 0 && (
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="h6">
|
||||
Résultats Détaillés des Tests ({testResults.length})
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{testResults.map((result, index) => (
|
||||
<Card key={index} variant="outlined">
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6">
|
||||
Test: {result.stepType}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={result.resolvedConfigLength > 0 ? 'SUCCÈS' : 'ÉCHEC'}
|
||||
color={result.resolvedConfigLength > 0 ? 'success' : 'error'}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Configuration Directe
|
||||
</Typography>
|
||||
<Typography variant="body1">
|
||||
{result.directConfigLength} paramètres
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Configuration Résolue
|
||||
</Typography>
|
||||
<Typography variant="body1">
|
||||
{result.resolvedConfigLength} paramètres
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Est Action VWB
|
||||
</Typography>
|
||||
<Typography variant="body1">
|
||||
{result.isVWBStep ? 'Oui' : 'Non'}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Config Existe
|
||||
</Typography>
|
||||
<Typography variant="body1">
|
||||
{result.configExists ? 'Oui' : 'Non'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{result.resolvedConfigLength === 0 && result.configExists && (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
<Typography variant="body2">
|
||||
🚨 PROBLÈME IDENTIFIÉ: La configuration existe ({result.directConfigLength} paramètres)
|
||||
mais la résolution retourne 0 paramètres. Cela indique un problème dans la logique
|
||||
de résolution ou dans le mapping des types.
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Accordion sx={{ mt: 2 }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="body2">
|
||||
Détails Techniques
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box component="pre" sx={{
|
||||
fontSize: '0.75rem',
|
||||
bgcolor: '#f5f5f5',
|
||||
p: 1,
|
||||
borderRadius: 1,
|
||||
overflow: 'auto',
|
||||
}}>
|
||||
{JSON.stringify(result, null, 2)}
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default PropertiesDebugTest;
|
||||
@@ -0,0 +1,468 @@
|
||||
/**
|
||||
* Composant EmptyStateMessage - Messages informatifs pour états vides
|
||||
* Auteur : Dom, Alice, Kiro - 12 janvier 2026
|
||||
*
|
||||
* Ce composant affiche des messages informatifs et utiles lorsqu'aucune propriété
|
||||
* n'est disponible, avec distinction entre différents types d'états vides.
|
||||
*/
|
||||
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Alert,
|
||||
Button,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Chip,
|
||||
Paper,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Info as InfoIcon,
|
||||
Warning as WarningIcon,
|
||||
Error as ErrorIcon,
|
||||
HelpOutline as HelpIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Settings as SettingsIcon,
|
||||
Lightbulb as LightbulbIcon,
|
||||
Search as SearchIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
/**
|
||||
* Types de raisons pour l'état vide
|
||||
*/
|
||||
export type EmptyStateReason =
|
||||
| 'no-parameters'
|
||||
| 'loading-error'
|
||||
| 'vwb-not-found'
|
||||
| 'unknown-type'
|
||||
| 'resolution-failed'
|
||||
| 'catalog-unavailable'
|
||||
| 'network-error';
|
||||
|
||||
/**
|
||||
* Props du composant EmptyStateMessage
|
||||
*/
|
||||
export interface EmptyStateMessageProps {
|
||||
stepType: string;
|
||||
reason: EmptyStateReason;
|
||||
error?: Error;
|
||||
suggestions?: string[];
|
||||
onRetry?: () => void;
|
||||
onRefresh?: () => void;
|
||||
onHelp?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration des messages par type de raison
|
||||
*/
|
||||
interface EmptyStateConfig {
|
||||
severity: 'info' | 'warning' | 'error';
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
actionLabel?: string;
|
||||
showSuggestions: boolean;
|
||||
defaultSuggestions: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Configurations des états vides
|
||||
*/
|
||||
const EMPTY_STATE_CONFIGS: Record<EmptyStateReason, EmptyStateConfig> = {
|
||||
'no-parameters': {
|
||||
severity: 'info',
|
||||
icon: <InfoIcon />,
|
||||
title: 'Aucun paramètre configurable',
|
||||
description: 'Cette étape fonctionne sans paramètres supplémentaires.',
|
||||
showSuggestions: false,
|
||||
defaultSuggestions: []
|
||||
},
|
||||
|
||||
'loading-error': {
|
||||
severity: 'error',
|
||||
icon: <ErrorIcon />,
|
||||
title: 'Erreur de chargement',
|
||||
description: 'Impossible de charger les propriétés de cette étape.',
|
||||
actionLabel: 'Réessayer',
|
||||
showSuggestions: true,
|
||||
defaultSuggestions: [
|
||||
'Vérifiez votre connexion réseau',
|
||||
'Actualisez la page',
|
||||
'Contactez le support si le problème persiste'
|
||||
]
|
||||
},
|
||||
|
||||
'vwb-not-found': {
|
||||
severity: 'warning',
|
||||
icon: <WarningIcon />,
|
||||
title: 'Action VWB non trouvée',
|
||||
description: 'Cette action du catalogue Vision-Only RPA n\'est pas disponible.',
|
||||
actionLabel: 'Actualiser le catalogue',
|
||||
showSuggestions: true,
|
||||
defaultSuggestions: [
|
||||
'Utilisez une action standard équivalente',
|
||||
'Vérifiez que le catalogue VWB est à jour',
|
||||
'Contactez l\'administrateur pour ajouter cette action'
|
||||
]
|
||||
},
|
||||
|
||||
'unknown-type': {
|
||||
severity: 'warning',
|
||||
icon: <HelpIcon />,
|
||||
title: 'Type d\'étape non reconnu',
|
||||
description: 'Ce type d\'étape n\'est pas pris en charge par l\'éditeur.',
|
||||
showSuggestions: true,
|
||||
defaultSuggestions: [
|
||||
'Vérifiez l\'orthographe du type d\'étape',
|
||||
'Utilisez un type d\'étape standard',
|
||||
'Consultez la documentation des types supportés'
|
||||
]
|
||||
},
|
||||
|
||||
'resolution-failed': {
|
||||
severity: 'error',
|
||||
icon: <ErrorIcon />,
|
||||
title: 'Échec de résolution',
|
||||
description: 'Impossible de déterminer les propriétés de cette étape.',
|
||||
actionLabel: 'Réessayer',
|
||||
showSuggestions: true,
|
||||
defaultSuggestions: [
|
||||
'Vérifiez la configuration de l\'étape',
|
||||
'Redémarrez l\'éditeur de workflow',
|
||||
'Vérifiez les logs pour plus de détails'
|
||||
]
|
||||
},
|
||||
|
||||
'catalog-unavailable': {
|
||||
severity: 'warning',
|
||||
icon: <WarningIcon />,
|
||||
title: 'Catalogue indisponible',
|
||||
description: 'Le catalogue d\'actions n\'est pas accessible actuellement.',
|
||||
actionLabel: 'Actualiser',
|
||||
showSuggestions: true,
|
||||
defaultSuggestions: [
|
||||
'Vérifiez la connexion au serveur',
|
||||
'Utilisez des actions standard en attendant',
|
||||
'Contactez l\'administrateur système'
|
||||
]
|
||||
},
|
||||
|
||||
'network-error': {
|
||||
severity: 'error',
|
||||
icon: <ErrorIcon />,
|
||||
title: 'Erreur réseau',
|
||||
description: 'Impossible de se connecter au serveur pour charger les propriétés.',
|
||||
actionLabel: 'Réessayer',
|
||||
showSuggestions: true,
|
||||
defaultSuggestions: [
|
||||
'Vérifiez votre connexion internet',
|
||||
'Vérifiez que le serveur est accessible',
|
||||
'Réessayez dans quelques instants'
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Composant EmptyStateMessage
|
||||
*/
|
||||
const EmptyStateMessage: React.FC<EmptyStateMessageProps> = ({
|
||||
stepType,
|
||||
reason,
|
||||
error,
|
||||
suggestions = [],
|
||||
onRetry,
|
||||
onRefresh,
|
||||
onHelp,
|
||||
className
|
||||
}) => {
|
||||
|
||||
// Configuration pour la raison donnée
|
||||
const config = useMemo(() => {
|
||||
return EMPTY_STATE_CONFIGS[reason] || EMPTY_STATE_CONFIGS['unknown-type'];
|
||||
}, [reason]);
|
||||
|
||||
// Suggestions finales (personnalisées + par défaut)
|
||||
const finalSuggestions = useMemo(() => {
|
||||
const customSuggestions = suggestions.length > 0 ? suggestions : [];
|
||||
const defaultSuggestions = config.showSuggestions ? config.defaultSuggestions : [];
|
||||
return [...customSuggestions, ...defaultSuggestions];
|
||||
}, [suggestions, config]);
|
||||
|
||||
// Gestionnaire d'action principale
|
||||
const handlePrimaryAction = () => {
|
||||
if (reason === 'loading-error' || reason === 'resolution-failed' || reason === 'network-error') {
|
||||
onRetry?.();
|
||||
} else if (reason === 'vwb-not-found' || reason === 'catalog-unavailable') {
|
||||
onRefresh?.();
|
||||
}
|
||||
};
|
||||
|
||||
// Rendu des détails d'erreur
|
||||
const renderErrorDetails = () => {
|
||||
if (!error) return null;
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="caption" color="text.secondary" gutterBottom>
|
||||
Détails de l'erreur :
|
||||
</Typography>
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 1,
|
||||
backgroundColor: 'grey.50',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.75rem',
|
||||
maxHeight: 100,
|
||||
overflow: 'auto'
|
||||
}}
|
||||
>
|
||||
{error.message}
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// Rendu des suggestions
|
||||
const renderSuggestions = () => {
|
||||
if (finalSuggestions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<LightbulbIcon sx={{ fontSize: 16, mr: 0.5 }} />
|
||||
Suggestions
|
||||
</Typography>
|
||||
<List dense>
|
||||
{finalSuggestions.map((suggestion, index) => (
|
||||
<ListItem key={index} sx={{ py: 0.5 }}>
|
||||
<ListItemIcon sx={{ minWidth: 24 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'primary.main'
|
||||
}}
|
||||
/>
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={suggestion}
|
||||
primaryTypographyProps={{ variant: 'body2' }}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// Rendu des actions
|
||||
const renderActions = () => {
|
||||
const actions = [];
|
||||
|
||||
// Action principale
|
||||
if (config.actionLabel && (onRetry || onRefresh)) {
|
||||
actions.push(
|
||||
<Button
|
||||
key="primary"
|
||||
variant="contained"
|
||||
size="small"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={handlePrimaryAction}
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
{config.actionLabel}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// Action d'aide
|
||||
if (onHelp) {
|
||||
actions.push(
|
||||
<Button
|
||||
key="help"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={<HelpIcon />}
|
||||
onClick={onHelp}
|
||||
>
|
||||
Aide
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (actions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'center' }}>
|
||||
{actions}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// Logging pour le développement
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(`📭 [EmptyStateMessage] Affichage état vide:`, {
|
||||
stepType,
|
||||
reason,
|
||||
hasError: Boolean(error),
|
||||
suggestionsCount: finalSuggestions.length,
|
||||
hasActions: Boolean(config.actionLabel && (onRetry || onRefresh))
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
className={className}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
p: 3,
|
||||
textAlign: 'center',
|
||||
minHeight: 200
|
||||
}}
|
||||
role="status"
|
||||
aria-label={`État vide: ${config.title}`}
|
||||
>
|
||||
{/* En-tête avec icône et titre */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: `${config.severity}.light`,
|
||||
color: `${config.severity}.main`,
|
||||
mb: 1,
|
||||
mx: 'auto'
|
||||
}}
|
||||
>
|
||||
{config.icon}
|
||||
</Box>
|
||||
|
||||
<Typography variant="h6" component="h3" gutterBottom>
|
||||
{config.title}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{config.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Informations sur l'étape */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Chip
|
||||
label={`Type: ${stepType}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
icon={<SettingsIcon />}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Alert avec sévérité appropriée */}
|
||||
<Alert
|
||||
severity={config.severity}
|
||||
sx={{ mb: 2, width: '100%', maxWidth: 400 }}
|
||||
variant="outlined"
|
||||
>
|
||||
<Typography variant="body2">
|
||||
{reason === 'no-parameters'
|
||||
? 'Cette étape est prête à être utilisée sans configuration supplémentaire.'
|
||||
: 'Une intervention peut être nécessaire pour configurer cette étape.'
|
||||
}
|
||||
</Typography>
|
||||
</Alert>
|
||||
|
||||
{/* Détails d'erreur */}
|
||||
{renderErrorDetails()}
|
||||
|
||||
{/* Suggestions */}
|
||||
{renderSuggestions()}
|
||||
|
||||
{/* Actions */}
|
||||
{renderActions()}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook pour générer des suggestions contextuelles
|
||||
*/
|
||||
export function useEmptyStateSuggestions(
|
||||
stepType: string,
|
||||
reason: EmptyStateReason,
|
||||
error?: Error
|
||||
): string[] {
|
||||
|
||||
return useMemo(() => {
|
||||
const suggestions: string[] = [];
|
||||
|
||||
// Suggestions basées sur le type d'étape
|
||||
if (stepType.includes('click')) {
|
||||
suggestions.push('Essayez d\'utiliser l\'action "click" standard');
|
||||
} else if (stepType.includes('type')) {
|
||||
suggestions.push('Essayez d\'utiliser l\'action "type" standard');
|
||||
} else if (stepType.includes('wait')) {
|
||||
suggestions.push('Essayez d\'utiliser l\'action "wait" standard');
|
||||
}
|
||||
|
||||
// Suggestions basées sur l'erreur
|
||||
if (error) {
|
||||
if (error.message.includes('network')) {
|
||||
suggestions.push('Problème de réseau détecté - vérifiez la connectivité');
|
||||
} else if (error.message.includes('timeout')) {
|
||||
suggestions.push('Délai d\'attente dépassé - réessayez plus tard');
|
||||
} else if (error.message.includes('404')) {
|
||||
suggestions.push('Ressource non trouvée - vérifiez la configuration');
|
||||
}
|
||||
}
|
||||
|
||||
// Suggestions basées sur la raison
|
||||
switch (reason) {
|
||||
case 'vwb-not-found':
|
||||
suggestions.push(`Recherchez une alternative à "${stepType}" dans le catalogue`);
|
||||
break;
|
||||
case 'unknown-type':
|
||||
suggestions.push(`Vérifiez si "${stepType}" est correctement orthographié`);
|
||||
break;
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
}, [stepType, reason, error]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Composant EmptyStateMessage avec suggestions automatiques
|
||||
*/
|
||||
export const SmartEmptyStateMessage: React.FC<Omit<EmptyStateMessageProps, 'suggestions'>> = (props) => {
|
||||
const autoSuggestions = useEmptyStateSuggestions(props.stepType, props.reason, props.error);
|
||||
|
||||
return (
|
||||
<EmptyStateMessage
|
||||
{...props}
|
||||
suggestions={autoSuggestions}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Export du composant mémorisé
|
||||
*/
|
||||
export default memo(EmptyStateMessage, (prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.stepType === nextProps.stepType &&
|
||||
prevProps.reason === nextProps.reason &&
|
||||
prevProps.error?.message === nextProps.error?.message &&
|
||||
JSON.stringify(prevProps.suggestions) === JSON.stringify(nextProps.suggestions)
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,515 @@
|
||||
/**
|
||||
* Composant LoadingState - Indicateurs de chargement élégants et informatifs
|
||||
* Auteur : Dom, Alice, Kiro - 12 janvier 2026
|
||||
*
|
||||
* Ce composant affiche des indicateurs de chargement avec support de l'annulation,
|
||||
* messages informatifs et maintien de la réactivité de l'interface.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, memo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
CircularProgress,
|
||||
LinearProgress,
|
||||
Button,
|
||||
Fade,
|
||||
Skeleton,
|
||||
Alert,
|
||||
Chip,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Cancel as CancelIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Info as InfoIcon,
|
||||
Timer as TimerIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
/**
|
||||
* Types de chargement
|
||||
*/
|
||||
export type LoadingType =
|
||||
| 'resolving'
|
||||
| 'loading-vwb'
|
||||
| 'validating'
|
||||
| 'saving'
|
||||
| 'fetching-catalog'
|
||||
| 'processing'
|
||||
| 'generic';
|
||||
|
||||
/**
|
||||
* Props du composant LoadingState
|
||||
*/
|
||||
export interface LoadingStateProps {
|
||||
type?: LoadingType;
|
||||
message?: string;
|
||||
progress?: number;
|
||||
canCancel?: boolean;
|
||||
onCancel?: () => void;
|
||||
timeout?: number;
|
||||
showElapsedTime?: boolean;
|
||||
showSkeletons?: boolean;
|
||||
variant?: 'circular' | 'linear' | 'skeleton';
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration des messages par type de chargement
|
||||
*/
|
||||
interface LoadingConfig {
|
||||
defaultMessage: string;
|
||||
icon?: React.ReactNode;
|
||||
color: 'primary' | 'secondary' | 'info' | 'warning';
|
||||
estimatedDuration: number; // en millisecondes
|
||||
showProgress: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configurations des types de chargement
|
||||
*/
|
||||
const LOADING_CONFIGS: Record<LoadingType, LoadingConfig> = {
|
||||
'resolving': {
|
||||
defaultMessage: 'Résolution des propriétés d\'étape...',
|
||||
color: 'primary',
|
||||
estimatedDuration: 2000,
|
||||
showProgress: false
|
||||
},
|
||||
|
||||
'loading-vwb': {
|
||||
defaultMessage: 'Chargement de l\'action VWB...',
|
||||
color: 'info',
|
||||
estimatedDuration: 3000,
|
||||
showProgress: true
|
||||
},
|
||||
|
||||
'validating': {
|
||||
defaultMessage: 'Validation des paramètres...',
|
||||
color: 'secondary',
|
||||
estimatedDuration: 1500,
|
||||
showProgress: false
|
||||
},
|
||||
|
||||
'saving': {
|
||||
defaultMessage: 'Sauvegarde en cours...',
|
||||
color: 'primary',
|
||||
estimatedDuration: 2500,
|
||||
showProgress: true
|
||||
},
|
||||
|
||||
'fetching-catalog': {
|
||||
defaultMessage: 'Récupération du catalogue d\'actions...',
|
||||
color: 'info',
|
||||
estimatedDuration: 4000,
|
||||
showProgress: true
|
||||
},
|
||||
|
||||
'processing': {
|
||||
defaultMessage: 'Traitement en cours...',
|
||||
color: 'primary',
|
||||
estimatedDuration: 3000,
|
||||
showProgress: false
|
||||
},
|
||||
|
||||
'generic': {
|
||||
defaultMessage: 'Chargement...',
|
||||
color: 'primary',
|
||||
estimatedDuration: 2000,
|
||||
showProgress: false
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook pour le temps écoulé
|
||||
*/
|
||||
function useElapsedTime(isActive: boolean): number {
|
||||
const [elapsedTime, setElapsedTime] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) {
|
||||
setElapsedTime(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const interval = setInterval(() => {
|
||||
setElapsedTime(Date.now() - startTime);
|
||||
}, 100);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isActive]);
|
||||
|
||||
return elapsedTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook pour la progression estimée
|
||||
*/
|
||||
function useEstimatedProgress(
|
||||
type: LoadingType,
|
||||
elapsedTime: number,
|
||||
actualProgress?: number
|
||||
): number {
|
||||
|
||||
return React.useMemo(() => {
|
||||
if (actualProgress !== undefined) {
|
||||
return Math.min(100, Math.max(0, actualProgress));
|
||||
}
|
||||
|
||||
const config = LOADING_CONFIGS[type];
|
||||
const estimatedProgress = Math.min(95, (elapsedTime / config.estimatedDuration) * 100);
|
||||
|
||||
return estimatedProgress;
|
||||
}, [type, elapsedTime, actualProgress]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Composant LoadingState
|
||||
*/
|
||||
const LoadingState: React.FC<LoadingStateProps> = ({
|
||||
type = 'generic',
|
||||
message,
|
||||
progress,
|
||||
canCancel = false,
|
||||
onCancel,
|
||||
timeout,
|
||||
showElapsedTime = false,
|
||||
showSkeletons = false,
|
||||
variant = 'circular',
|
||||
size = 'medium',
|
||||
className
|
||||
}) => {
|
||||
|
||||
const [isTimedOut, setIsTimedOut] = useState(false);
|
||||
const [showTimeoutWarning, setShowTimeoutWarning] = useState(false);
|
||||
|
||||
// Configuration pour le type de chargement
|
||||
const config = LOADING_CONFIGS[type];
|
||||
const displayMessage = message || config.defaultMessage;
|
||||
|
||||
// Temps écoulé
|
||||
const elapsedTime = useElapsedTime(true);
|
||||
|
||||
// Progression estimée
|
||||
const estimatedProgress = useEstimatedProgress(type, elapsedTime, progress);
|
||||
|
||||
// Gestion du timeout
|
||||
useEffect(() => {
|
||||
if (!timeout) return;
|
||||
|
||||
const warningTimeout = setTimeout(() => {
|
||||
setShowTimeoutWarning(true);
|
||||
}, timeout * 0.8); // Avertissement à 80% du timeout
|
||||
|
||||
const finalTimeout = setTimeout(() => {
|
||||
setIsTimedOut(true);
|
||||
}, timeout);
|
||||
|
||||
return () => {
|
||||
clearTimeout(warningTimeout);
|
||||
clearTimeout(finalTimeout);
|
||||
};
|
||||
}, [timeout]);
|
||||
|
||||
// Gestionnaire d'annulation
|
||||
const handleCancel = useCallback(() => {
|
||||
if (onCancel) {
|
||||
onCancel();
|
||||
}
|
||||
}, [onCancel]);
|
||||
|
||||
// Formatage du temps écoulé
|
||||
const formatElapsedTime = (ms: number): string => {
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m ${seconds % 60}s`;
|
||||
}
|
||||
return `${seconds}s`;
|
||||
};
|
||||
|
||||
// Tailles des composants
|
||||
const getSizes = () => {
|
||||
switch (size) {
|
||||
case 'small':
|
||||
return { circularSize: 24, spacing: 1, typography: 'body2' as const };
|
||||
case 'large':
|
||||
return { circularSize: 48, spacing: 3, typography: 'h6' as const };
|
||||
default:
|
||||
return { circularSize: 32, spacing: 2, typography: 'body1' as const };
|
||||
}
|
||||
};
|
||||
|
||||
const sizes = getSizes();
|
||||
|
||||
// Rendu des squelettes
|
||||
const renderSkeletons = () => {
|
||||
if (!showSkeletons) return null;
|
||||
|
||||
return (
|
||||
<Box sx={{ width: '100%', mt: 2 }}>
|
||||
<Skeleton variant="text" width="80%" height={24} sx={{ mb: 1 }} />
|
||||
<Skeleton variant="text" width="60%" height={20} sx={{ mb: 1 }} />
|
||||
<Skeleton variant="rectangular" width="100%" height={40} sx={{ borderRadius: 1 }} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// Rendu de l'indicateur de progression
|
||||
const renderProgressIndicator = () => {
|
||||
switch (variant) {
|
||||
case 'linear':
|
||||
return (
|
||||
<LinearProgress
|
||||
variant={progress !== undefined ? 'determinate' : 'indeterminate'}
|
||||
value={estimatedProgress}
|
||||
color={config.color}
|
||||
sx={{ width: '100%', mt: 1 }}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'skeleton':
|
||||
return renderSkeletons();
|
||||
|
||||
default: // circular
|
||||
return (
|
||||
<CircularProgress
|
||||
size={sizes.circularSize}
|
||||
variant={progress !== undefined ? 'determinate' : 'indeterminate'}
|
||||
value={estimatedProgress}
|
||||
color={config.color}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Rendu des informations temporelles
|
||||
const renderTimeInfo = () => {
|
||||
if (!showElapsedTime && !showTimeoutWarning) return null;
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
{showElapsedTime && (
|
||||
<Chip
|
||||
icon={<TimerIcon />}
|
||||
label={formatElapsedTime(elapsedTime)}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ mr: 1 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showTimeoutWarning && !isTimedOut && (
|
||||
<Chip
|
||||
icon={<InfoIcon />}
|
||||
label="Opération longue"
|
||||
size="small"
|
||||
color="warning"
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// Rendu des actions
|
||||
const renderActions = () => {
|
||||
if (!canCancel && !isTimedOut) return null;
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 2, display: 'flex', gap: 1, justifyContent: 'center' }}>
|
||||
{canCancel && !isTimedOut && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={<CancelIcon />}
|
||||
onClick={handleCancel}
|
||||
color="secondary"
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isTimedOut && (
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={handleCancel}
|
||||
color="primary"
|
||||
>
|
||||
Réessayer
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// Logging pour le développement
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(`⏳ [LoadingState] État de chargement:`, {
|
||||
type,
|
||||
elapsedTime: formatElapsedTime(elapsedTime),
|
||||
progress: estimatedProgress.toFixed(1) + '%',
|
||||
isTimedOut,
|
||||
showTimeoutWarning
|
||||
});
|
||||
}
|
||||
|
||||
// Cas de timeout
|
||||
if (isTimedOut) {
|
||||
return (
|
||||
<Box
|
||||
className={className}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
p: sizes.spacing,
|
||||
textAlign: 'center',
|
||||
minHeight: 150
|
||||
}}
|
||||
role="status"
|
||||
aria-label="Opération expirée"
|
||||
>
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||
<Typography variant="body2">
|
||||
L'opération prend plus de temps que prévu
|
||||
</Typography>
|
||||
</Alert>
|
||||
|
||||
<Typography variant={sizes.typography} color="text.secondary" gutterBottom>
|
||||
{displayMessage}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Temps écoulé: {formatElapsedTime(elapsedTime)}
|
||||
</Typography>
|
||||
|
||||
{renderActions()}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Fade in timeout={300}>
|
||||
<Box
|
||||
className={className}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
p: sizes.spacing,
|
||||
textAlign: 'center',
|
||||
minHeight: variant === 'skeleton' ? 200 : 120
|
||||
}}
|
||||
role="status"
|
||||
aria-label={displayMessage}
|
||||
aria-live="polite"
|
||||
>
|
||||
{/* Indicateur de progression */}
|
||||
{renderProgressIndicator()}
|
||||
|
||||
{/* Message de chargement */}
|
||||
{variant !== 'skeleton' && (
|
||||
<Typography
|
||||
variant={sizes.typography}
|
||||
color="text.secondary"
|
||||
sx={{ mt: sizes.spacing }}
|
||||
>
|
||||
{displayMessage}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Progression numérique */}
|
||||
{progress !== undefined && variant !== 'skeleton' && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5 }}>
|
||||
{Math.round(estimatedProgress)}%
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Informations temporelles */}
|
||||
{renderTimeInfo()}
|
||||
|
||||
{/* Actions */}
|
||||
{renderActions()}
|
||||
</Box>
|
||||
</Fade>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Composant LoadingState spécialisé pour la résolution d'étapes
|
||||
*/
|
||||
export const StepResolutionLoading: React.FC<Omit<LoadingStateProps, 'type'>> = (props) => {
|
||||
return (
|
||||
<LoadingState
|
||||
{...props}
|
||||
type="resolving"
|
||||
variant="circular"
|
||||
size="small"
|
||||
showElapsedTime={process.env.NODE_ENV === 'development'}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Composant LoadingState spécialisé pour le chargement VWB
|
||||
*/
|
||||
export const VWBActionLoading: React.FC<Omit<LoadingStateProps, 'type'>> = (props) => {
|
||||
return (
|
||||
<LoadingState
|
||||
{...props}
|
||||
type="loading-vwb"
|
||||
variant="linear"
|
||||
showElapsedTime
|
||||
canCancel
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Composant LoadingState spécialisé pour la sauvegarde
|
||||
*/
|
||||
export const SavingLoading: React.FC<Omit<LoadingStateProps, 'type'>> = (props) => {
|
||||
return (
|
||||
<LoadingState
|
||||
{...props}
|
||||
type="saving"
|
||||
variant="linear"
|
||||
size="small"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Composant LoadingState avec squelettes pour les paramètres
|
||||
*/
|
||||
export const ParametersSkeletonLoading: React.FC<Omit<LoadingStateProps, 'type' | 'variant'>> = (props) => {
|
||||
return (
|
||||
<LoadingState
|
||||
{...props}
|
||||
type="resolving"
|
||||
variant="skeleton"
|
||||
showSkeletons
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Export du composant mémorisé
|
||||
*/
|
||||
export default memo(LoadingState, (prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.type === nextProps.type &&
|
||||
prevProps.message === nextProps.message &&
|
||||
prevProps.progress === nextProps.progress &&
|
||||
prevProps.canCancel === nextProps.canCancel &&
|
||||
prevProps.timeout === nextProps.timeout &&
|
||||
prevProps.variant === nextProps.variant &&
|
||||
prevProps.size === nextProps.size
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,565 @@
|
||||
/**
|
||||
* Composant ParameterFieldRenderer - Rendu unifié des champs de paramètres
|
||||
* Auteur : Dom, Alice, Kiro - 12 janvier 2026
|
||||
*
|
||||
* Ce composant fournit un système de rendu unifié pour tous les types de champs
|
||||
* de paramètres d'étapes, avec support de l'extensibilité et validation intégrée.
|
||||
*/
|
||||
|
||||
import React, { memo, useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
TextField,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
Switch,
|
||||
FormControlLabel,
|
||||
Button,
|
||||
Typography,
|
||||
Chip,
|
||||
Alert,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Visibility as VisibilityIcon,
|
||||
Error as ErrorIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
// Import des composants spécialisés
|
||||
import VariableAutocomplete from '../VariableAutocomplete';
|
||||
import RealScreenCapture from '../RealScreenCapture';
|
||||
|
||||
// Import des types
|
||||
import { ParameterConfig } from '../../services/StepTypeResolver';
|
||||
import { Variable, ValidationError, VisualSelection } from '../../types';
|
||||
|
||||
/**
|
||||
* Props du ParameterFieldRenderer
|
||||
*/
|
||||
export interface ParameterFieldRendererProps {
|
||||
config: ParameterConfig;
|
||||
value: any;
|
||||
variables: Variable[];
|
||||
error?: ValidationError;
|
||||
onChange: (value: any) => void;
|
||||
onVisualSelection?: () => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props des renderers spécialisés
|
||||
*/
|
||||
interface BaseFieldRendererProps {
|
||||
config: ParameterConfig;
|
||||
value: any;
|
||||
error?: ValidationError;
|
||||
onChange: (value: any) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface TextFieldRendererProps extends BaseFieldRendererProps {
|
||||
variables: Variable[];
|
||||
}
|
||||
|
||||
interface SelectFieldRendererProps extends BaseFieldRendererProps {}
|
||||
|
||||
interface NumberFieldRendererProps extends BaseFieldRendererProps {}
|
||||
|
||||
interface BooleanFieldRendererProps extends BaseFieldRendererProps {}
|
||||
|
||||
interface VisualFieldRendererProps extends BaseFieldRendererProps {
|
||||
onVisualSelection?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renderer pour les champs de texte
|
||||
*/
|
||||
const TextFieldRenderer: React.FC<TextFieldRendererProps> = memo(({
|
||||
config,
|
||||
value,
|
||||
variables,
|
||||
error,
|
||||
onChange,
|
||||
disabled = false
|
||||
}) => {
|
||||
const hasError = Boolean(error);
|
||||
|
||||
// Utiliser l'autocomplétion des variables si supporté
|
||||
if (config.supportVariables) {
|
||||
return (
|
||||
<VariableAutocomplete
|
||||
label={config.label}
|
||||
value={value || ''}
|
||||
onChange={onChange}
|
||||
variables={variables}
|
||||
error={hasError}
|
||||
helperText={hasError ? error!.message : config.description}
|
||||
required={config.required}
|
||||
disabled={disabled}
|
||||
placeholder={config.placeholder}
|
||||
multiline={config.multiline}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Champ de texte standard
|
||||
return (
|
||||
<TextField
|
||||
fullWidth
|
||||
label={config.label}
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
error={hasError}
|
||||
helperText={hasError ? error!.message : config.description}
|
||||
required={config.required}
|
||||
disabled={disabled}
|
||||
placeholder={config.placeholder}
|
||||
multiline={config.multiline}
|
||||
rows={config.multiline ? 3 : 1}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
TextFieldRenderer.displayName = 'TextFieldRenderer';
|
||||
|
||||
/**
|
||||
* Renderer pour les champs numériques
|
||||
*/
|
||||
const NumberFieldRenderer: React.FC<NumberFieldRendererProps> = memo(({
|
||||
config,
|
||||
value,
|
||||
error,
|
||||
onChange,
|
||||
disabled = false
|
||||
}) => {
|
||||
const hasError = Boolean(error);
|
||||
|
||||
const handleChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const numValue = Number(event.target.value);
|
||||
onChange(isNaN(numValue) ? '' : numValue);
|
||||
}, [onChange]);
|
||||
|
||||
return (
|
||||
<TextField
|
||||
fullWidth
|
||||
type="number"
|
||||
label={config.label}
|
||||
value={value ?? ''}
|
||||
onChange={handleChange}
|
||||
error={hasError}
|
||||
helperText={hasError ? error!.message : config.description}
|
||||
required={config.required}
|
||||
disabled={disabled}
|
||||
placeholder={config.placeholder}
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
min: config.min,
|
||||
max: config.max,
|
||||
step: config.step || 0.1,
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
NumberFieldRenderer.displayName = 'NumberFieldRenderer';
|
||||
|
||||
/**
|
||||
* Renderer pour les champs booléens (switch)
|
||||
*/
|
||||
const BooleanFieldRenderer: React.FC<BooleanFieldRendererProps> = memo(({
|
||||
config,
|
||||
value,
|
||||
error,
|
||||
onChange,
|
||||
disabled = false
|
||||
}) => {
|
||||
const hasError = Boolean(error);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={Boolean(value)}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
disabled={disabled}
|
||||
color={hasError ? 'error' : 'primary'}
|
||||
/>
|
||||
}
|
||||
label={config.label}
|
||||
/>
|
||||
{config.description && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
|
||||
{config.description}
|
||||
</Typography>
|
||||
)}
|
||||
{hasError && (
|
||||
<Typography variant="caption" color="error" sx={{ display: 'block', mt: 0.5 }}>
|
||||
<ErrorIcon sx={{ fontSize: 14, mr: 0.5, verticalAlign: 'middle' }} />
|
||||
{error!.message}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
BooleanFieldRenderer.displayName = 'BooleanFieldRenderer';
|
||||
|
||||
/**
|
||||
* Renderer pour les champs de sélection
|
||||
*/
|
||||
const SelectFieldRenderer: React.FC<SelectFieldRendererProps> = memo(({
|
||||
config,
|
||||
value,
|
||||
error,
|
||||
onChange,
|
||||
disabled = false
|
||||
}) => {
|
||||
const hasError = Boolean(error);
|
||||
|
||||
return (
|
||||
<FormControl fullWidth error={hasError} disabled={disabled}>
|
||||
<InputLabel required={config.required}>{config.label}</InputLabel>
|
||||
<Select
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
label={config.label}
|
||||
>
|
||||
{config.options?.map((option) => (
|
||||
<MenuItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
{(hasError || config.description) && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color={hasError ? 'error' : 'text.secondary'}
|
||||
sx={{ mt: 0.5, display: 'block' }}
|
||||
>
|
||||
{hasError ? error!.message : config.description}
|
||||
</Typography>
|
||||
)}
|
||||
</FormControl>
|
||||
);
|
||||
});
|
||||
|
||||
SelectFieldRenderer.displayName = 'SelectFieldRenderer';
|
||||
|
||||
/**
|
||||
* Renderer pour les champs de sélection visuelle
|
||||
*/
|
||||
const VisualFieldRenderer: React.FC<VisualFieldRendererProps> = memo(({
|
||||
config,
|
||||
value,
|
||||
error,
|
||||
onChange,
|
||||
onVisualSelection,
|
||||
disabled = false
|
||||
}) => {
|
||||
const hasError = Boolean(error);
|
||||
const hasSelection = Boolean(value);
|
||||
const [isScreenCaptureOpen, setIsScreenCaptureOpen] = useState(false);
|
||||
|
||||
const handleVisualSelection = useCallback(() => {
|
||||
if (!disabled) {
|
||||
setIsScreenCaptureOpen(true);
|
||||
}
|
||||
}, [disabled]);
|
||||
|
||||
const handleElementSelected = useCallback((selection: VisualSelection) => {
|
||||
onChange(selection);
|
||||
setIsScreenCaptureOpen(false);
|
||||
|
||||
// Notifier le parent si nécessaire
|
||||
if (onVisualSelection) {
|
||||
onVisualSelection();
|
||||
}
|
||||
}, [onChange, onVisualSelection]);
|
||||
|
||||
const handleClearSelection = useCallback(() => {
|
||||
onChange(null);
|
||||
}, [onChange]);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
startIcon={<VisibilityIcon />}
|
||||
onClick={handleVisualSelection}
|
||||
color={hasError ? 'error' : hasSelection ? 'success' : 'primary'}
|
||||
disabled={disabled}
|
||||
sx={{ mb: 1 }}
|
||||
>
|
||||
{hasSelection ? 'Modifier la sélection' : config.label}
|
||||
</Button>
|
||||
|
||||
{hasSelection && (
|
||||
<Box sx={{ mb: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<Chip
|
||||
label="Élément sélectionné"
|
||||
size="small"
|
||||
color="success"
|
||||
icon={<CheckCircleIcon />}
|
||||
onDelete={disabled ? undefined : handleClearSelection}
|
||||
/>
|
||||
{value?.boundingBox && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
({Math.round(value.boundingBox.x)}px, {Math.round(value.boundingBox.y)}px) -
|
||||
{Math.round(value.boundingBox.width)}×{Math.round(value.boundingBox.height)}px
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
{/* Aperçu de la sélection visuelle */}
|
||||
{value?.screenshot && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
maxHeight: 150,
|
||||
overflow: 'hidden',
|
||||
borderRadius: 1,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={value.screenshot}
|
||||
alt="Aperçu de la sélection"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
maxHeight: 150,
|
||||
objectFit: 'contain',
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
{/* Indicateur de la zone sélectionnée */}
|
||||
{value.boundingBox && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: `${(value.boundingBox.x / 1920) * 100}%`,
|
||||
top: `${(value.boundingBox.y / 1080) * 100}%`,
|
||||
width: `${Math.max((value.boundingBox.width / 1920) * 100, 2)}%`,
|
||||
height: `${Math.max((value.boundingBox.height / 1080) * 100, 2)}%`,
|
||||
border: '2px solid #4caf50',
|
||||
backgroundColor: 'rgba(76, 175, 80, 0.2)',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{config.description && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
|
||||
{config.description}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{hasError && (
|
||||
<Alert severity="error" sx={{ mt: 1 }}>
|
||||
{error!.message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Composant de capture d'écran */}
|
||||
<RealScreenCapture
|
||||
isOpen={isScreenCaptureOpen}
|
||||
onClose={() => setIsScreenCaptureOpen(false)}
|
||||
onElementSelected={handleElementSelected}
|
||||
stepId={`field_${config.name}`}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
VisualFieldRenderer.displayName = 'VisualFieldRenderer';
|
||||
|
||||
/**
|
||||
* Map des renderers par type de champ
|
||||
*/
|
||||
const FIELD_RENDERERS = {
|
||||
text: TextFieldRenderer,
|
||||
number: NumberFieldRenderer,
|
||||
boolean: BooleanFieldRenderer,
|
||||
select: SelectFieldRenderer,
|
||||
visual: VisualFieldRenderer,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Type pour les renderers de champs
|
||||
*/
|
||||
export type FieldRendererType = keyof typeof FIELD_RENDERERS;
|
||||
|
||||
/**
|
||||
* Interface pour l'enregistrement de renderers personnalisés
|
||||
*/
|
||||
export interface CustomFieldRenderer {
|
||||
type: string;
|
||||
component: React.ComponentType<any>;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registre des renderers personnalisés
|
||||
*/
|
||||
class FieldRendererRegistry {
|
||||
private customRenderers = new Map<string, CustomFieldRenderer>();
|
||||
|
||||
/**
|
||||
* Enregistre un renderer personnalisé
|
||||
*/
|
||||
register(renderer: CustomFieldRenderer): void {
|
||||
this.customRenderers.set(renderer.type, renderer);
|
||||
console.log(`📝 [ParameterFieldRenderer] Renderer personnalisé enregistré: ${renderer.type}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtient un renderer par type
|
||||
*/
|
||||
getRenderer(type: string): React.ComponentType<any> | null {
|
||||
// Vérifier d'abord les renderers personnalisés
|
||||
const customRenderer = this.customRenderers.get(type);
|
||||
if (customRenderer) {
|
||||
return customRenderer.component;
|
||||
}
|
||||
|
||||
// Vérifier les renderers standard
|
||||
if (type in FIELD_RENDERERS) {
|
||||
return FIELD_RENDERERS[type as FieldRendererType];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste tous les types de renderers disponibles
|
||||
*/
|
||||
getAvailableTypes(): string[] {
|
||||
const standardTypes = Object.keys(FIELD_RENDERERS);
|
||||
const customTypes = Array.from(this.customRenderers.keys());
|
||||
return [...standardTypes, ...customTypes];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Instance singleton du registre
|
||||
*/
|
||||
export const fieldRendererRegistry = new FieldRendererRegistry();
|
||||
|
||||
/**
|
||||
* Composant principal ParameterFieldRenderer
|
||||
*/
|
||||
const ParameterFieldRenderer: React.FC<ParameterFieldRendererProps> = ({
|
||||
config,
|
||||
value,
|
||||
variables,
|
||||
error,
|
||||
onChange,
|
||||
onVisualSelection,
|
||||
disabled = false,
|
||||
className
|
||||
}) => {
|
||||
// Obtenir le renderer approprié
|
||||
const RendererComponent = useMemo(() => {
|
||||
const renderer = fieldRendererRegistry.getRenderer(config.type);
|
||||
|
||||
if (!renderer) {
|
||||
console.warn(`⚠️ [ParameterFieldRenderer] Renderer non trouvé pour le type: ${config.type}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return renderer;
|
||||
}, [config.type]);
|
||||
|
||||
// Props communes pour tous les renderers
|
||||
const commonProps = useMemo(() => ({
|
||||
config,
|
||||
value,
|
||||
error,
|
||||
onChange,
|
||||
disabled
|
||||
}), [config, value, error, onChange, disabled]);
|
||||
|
||||
// Props spécifiques selon le type
|
||||
const specificProps = useMemo(() => {
|
||||
switch (config.type) {
|
||||
case 'text':
|
||||
return { variables };
|
||||
case 'visual':
|
||||
return { onVisualSelection };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}, [config.type, variables, onVisualSelection]);
|
||||
|
||||
// Logging pour le développement
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(`🎨 [ParameterFieldRenderer] Rendu du champ:`, {
|
||||
name: config.name,
|
||||
type: config.type,
|
||||
hasValue: value !== undefined && value !== null && value !== '',
|
||||
hasError: Boolean(error),
|
||||
disabled
|
||||
});
|
||||
}
|
||||
|
||||
if (!RendererComponent) {
|
||||
return (
|
||||
<Alert severity="error" className={className}>
|
||||
<Typography variant="body2">
|
||||
Type de champ non supporté: <code>{config.type}</code>
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Types disponibles: {fieldRendererRegistry.getAvailableTypes().join(', ')}
|
||||
</Typography>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className={className} data-field-type={config.type} data-field-name={config.name}>
|
||||
<RendererComponent
|
||||
{...commonProps}
|
||||
{...specificProps}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Export du composant mémorisé
|
||||
*/
|
||||
export default memo(ParameterFieldRenderer, (prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.config.name === nextProps.config.name &&
|
||||
prevProps.config.type === nextProps.config.type &&
|
||||
prevProps.value === nextProps.value &&
|
||||
prevProps.error?.message === nextProps.error?.message &&
|
||||
prevProps.disabled === nextProps.disabled &&
|
||||
JSON.stringify(prevProps.variables) === JSON.stringify(nextProps.variables)
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Exports utilitaires
|
||||
*/
|
||||
export {
|
||||
TextFieldRenderer,
|
||||
NumberFieldRenderer,
|
||||
BooleanFieldRenderer,
|
||||
SelectFieldRenderer,
|
||||
VisualFieldRenderer,
|
||||
FIELD_RENDERERS
|
||||
};
|
||||
@@ -0,0 +1,526 @@
|
||||
/**
|
||||
* Composant StandardParametersEditor - Éditeur de paramètres pour étapes standard
|
||||
* Auteur : Dom, Alice, Kiro - 12 janvier 2026
|
||||
*
|
||||
* Ce composant fournit une interface complète pour éditer les paramètres des étapes
|
||||
* standard avec validation en temps réel, sauvegarde automatique et gestion d'état.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useEffect, useMemo, memo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Alert,
|
||||
Divider,
|
||||
Chip,
|
||||
LinearProgress,
|
||||
Collapse,
|
||||
IconButton,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Error as ErrorIcon,
|
||||
Warning as WarningIcon,
|
||||
Info as InfoIcon,
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
ExpandLess as ExpandLessIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
// Import des composants
|
||||
import ParameterFieldRenderer from './ParameterFieldRenderer';
|
||||
|
||||
// Import des types
|
||||
import { ParameterConfig } from '../../services/StepTypeResolver';
|
||||
import { Variable, ValidationError } from '../../types';
|
||||
|
||||
/**
|
||||
* Props du StandardParametersEditor
|
||||
*/
|
||||
export interface StandardParametersEditorProps {
|
||||
stepType: string;
|
||||
parameterConfigs: ParameterConfig[];
|
||||
parameters: Record<string, any>;
|
||||
variables: Variable[];
|
||||
onParameterChange: (paramName: string, value: any) => void;
|
||||
onValidationChange: (errors: ValidationError[]) => void;
|
||||
onVisualSelection?: () => void;
|
||||
disabled?: boolean;
|
||||
showValidationSummary?: boolean;
|
||||
groupParameters?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* État de validation d'un paramètre
|
||||
*/
|
||||
interface ParameterValidationState {
|
||||
isValid: boolean;
|
||||
errors: ValidationError[];
|
||||
warnings: ValidationError[];
|
||||
infos: ValidationError[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Résultat de validation globale
|
||||
*/
|
||||
interface ValidationSummary {
|
||||
isValid: boolean;
|
||||
totalErrors: number;
|
||||
totalWarnings: number;
|
||||
totalInfos: number;
|
||||
parameterStates: Record<string, ParameterValidationState>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Groupe de paramètres
|
||||
*/
|
||||
interface ParameterGroup {
|
||||
name: string;
|
||||
label: string;
|
||||
parameters: ParameterConfig[];
|
||||
expanded: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook pour la validation en temps réel
|
||||
*/
|
||||
function useParameterValidation(
|
||||
parameterConfigs: ParameterConfig[],
|
||||
parameters: Record<string, any>
|
||||
): ValidationSummary {
|
||||
|
||||
return useMemo(() => {
|
||||
const parameterStates: Record<string, ParameterValidationState> = {};
|
||||
let totalErrors = 0;
|
||||
let totalWarnings = 0;
|
||||
let totalInfos = 0;
|
||||
|
||||
for (const config of parameterConfigs) {
|
||||
const value = parameters[config.name];
|
||||
const errors: ValidationError[] = [];
|
||||
const warnings: ValidationError[] = [];
|
||||
const infos: ValidationError[] = [];
|
||||
|
||||
// Validation de base - champ requis
|
||||
if (config.required && (value === undefined || value === null || value === '')) {
|
||||
errors.push({
|
||||
parameter: config.name,
|
||||
message: `Le champ "${config.label}" est requis`,
|
||||
severity: 'error',
|
||||
code: 'REQUIRED_FIELD'
|
||||
});
|
||||
}
|
||||
|
||||
// Validation spécifique par type
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
switch (config.type) {
|
||||
case 'number':
|
||||
const numValue = Number(value);
|
||||
if (isNaN(numValue)) {
|
||||
errors.push({
|
||||
parameter: config.name,
|
||||
message: `"${config.label}" doit être un nombre valide`,
|
||||
severity: 'error',
|
||||
code: 'INVALID_NUMBER'
|
||||
});
|
||||
} else {
|
||||
if (config.min !== undefined && numValue < config.min) {
|
||||
errors.push({
|
||||
parameter: config.name,
|
||||
message: `"${config.label}" doit être supérieur ou égal à ${config.min}`,
|
||||
severity: 'error',
|
||||
code: 'MIN_VALUE'
|
||||
});
|
||||
}
|
||||
if (config.max !== undefined && numValue > config.max) {
|
||||
errors.push({
|
||||
parameter: config.name,
|
||||
message: `"${config.label}" doit être inférieur ou égal à ${config.max}`,
|
||||
severity: 'error',
|
||||
code: 'MAX_VALUE'
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'select':
|
||||
if (config.options && !config.options.some(opt => opt.value === value)) {
|
||||
errors.push({
|
||||
parameter: config.name,
|
||||
message: `"${config.label}" doit être une des options disponibles`,
|
||||
severity: 'error',
|
||||
code: 'INVALID_OPTION'
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'text':
|
||||
if (typeof value !== 'string') {
|
||||
errors.push({
|
||||
parameter: config.name,
|
||||
message: `"${config.label}" doit être du texte`,
|
||||
severity: 'error',
|
||||
code: 'INVALID_TEXT'
|
||||
});
|
||||
} else {
|
||||
// Validation des variables si supportées
|
||||
if (config.supportVariables) {
|
||||
const variablePattern = /\$\{([^}]+)\}/g;
|
||||
let match;
|
||||
while ((match = variablePattern.exec(value)) !== null) {
|
||||
const varName = match[1];
|
||||
// Note: Dans une implémentation complète, on vérifierait si la variable existe
|
||||
infos.push({
|
||||
parameter: config.name,
|
||||
message: `Variable utilisée: ${varName}`,
|
||||
severity: 'info',
|
||||
code: 'VARIABLE_USAGE'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'visual':
|
||||
if (typeof value !== 'object' || !value.selector) {
|
||||
warnings.push({
|
||||
parameter: config.name,
|
||||
message: `"${config.label}" nécessite une sélection visuelle valide`,
|
||||
severity: 'warning',
|
||||
code: 'INCOMPLETE_VISUAL_SELECTION'
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Validation conditionnelle
|
||||
if (config.conditional) {
|
||||
// Implémentation future pour les règles conditionnelles
|
||||
}
|
||||
|
||||
parameterStates[config.name] = {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
infos
|
||||
};
|
||||
|
||||
totalErrors += errors.length;
|
||||
totalWarnings += warnings.length;
|
||||
totalInfos += infos.length;
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: totalErrors === 0,
|
||||
totalErrors,
|
||||
totalWarnings,
|
||||
totalInfos,
|
||||
parameterStates
|
||||
};
|
||||
}, [parameterConfigs, parameters]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook pour le groupement des paramètres
|
||||
*/
|
||||
function useParameterGroups(
|
||||
parameterConfigs: ParameterConfig[],
|
||||
groupParameters: boolean
|
||||
): ParameterGroup[] {
|
||||
|
||||
return useMemo(() => {
|
||||
if (!groupParameters) {
|
||||
return [{
|
||||
name: 'default',
|
||||
label: 'Paramètres',
|
||||
parameters: parameterConfigs,
|
||||
expanded: true
|
||||
}];
|
||||
}
|
||||
|
||||
// Grouper par propriété 'group' ou par type
|
||||
const groups: Record<string, ParameterConfig[]> = {};
|
||||
|
||||
for (const config of parameterConfigs) {
|
||||
const groupName = config.group || config.type;
|
||||
if (!groups[groupName]) {
|
||||
groups[groupName] = [];
|
||||
}
|
||||
groups[groupName].push(config);
|
||||
}
|
||||
|
||||
// Convertir en tableau de groupes
|
||||
return Object.entries(groups).map(([name, parameters]) => ({
|
||||
name,
|
||||
label: name === 'default' ? 'Paramètres' :
|
||||
name === 'text' ? 'Champs de texte' :
|
||||
name === 'number' ? 'Champs numériques' :
|
||||
name === 'boolean' ? 'Options' :
|
||||
name === 'select' ? 'Sélections' :
|
||||
name === 'visual' ? 'Sélections visuelles' :
|
||||
name.charAt(0).toUpperCase() + name.slice(1),
|
||||
parameters: parameters.sort((a, b) => (a.order || 0) - (b.order || 0)),
|
||||
expanded: true
|
||||
}));
|
||||
}, [parameterConfigs, groupParameters]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Composant StandardParametersEditor
|
||||
*/
|
||||
const StandardParametersEditor: React.FC<StandardParametersEditorProps> = ({
|
||||
stepType,
|
||||
parameterConfigs,
|
||||
parameters,
|
||||
variables,
|
||||
onParameterChange,
|
||||
onValidationChange,
|
||||
onVisualSelection,
|
||||
disabled = false,
|
||||
showValidationSummary = true,
|
||||
groupParameters = false,
|
||||
className
|
||||
}) => {
|
||||
// État local pour les groupes expandus
|
||||
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({});
|
||||
|
||||
// Validation en temps réel
|
||||
const validationSummary = useParameterValidation(parameterConfigs, parameters);
|
||||
|
||||
// Groupement des paramètres
|
||||
const parameterGroups = useParameterGroups(parameterConfigs, groupParameters);
|
||||
|
||||
// Effet pour notifier les changements de validation
|
||||
useEffect(() => {
|
||||
const allErrors: ValidationError[] = [];
|
||||
|
||||
Object.values(validationSummary.parameterStates).forEach(state => {
|
||||
allErrors.push(...state.errors, ...state.warnings);
|
||||
});
|
||||
|
||||
onValidationChange(allErrors);
|
||||
}, [validationSummary, onValidationChange]);
|
||||
|
||||
// Gestionnaire de changement de paramètre avec validation
|
||||
const handleParameterChange = useCallback((paramName: string, value: any) => {
|
||||
console.log(`📝 [StandardParametersEditor] Changement paramètre:`, {
|
||||
stepType,
|
||||
paramName,
|
||||
value,
|
||||
type: typeof value
|
||||
});
|
||||
|
||||
onParameterChange(paramName, value);
|
||||
}, [stepType, onParameterChange]);
|
||||
|
||||
// Gestionnaire de toggle de groupe
|
||||
const handleGroupToggle = useCallback((groupName: string) => {
|
||||
setExpandedGroups(prev => ({
|
||||
...prev,
|
||||
[groupName]: !prev[groupName]
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// Obtenir l'état de validation pour un paramètre
|
||||
const getParameterValidation = useCallback((paramName: string): ValidationError | undefined => {
|
||||
const state = validationSummary.parameterStates[paramName];
|
||||
if (!state) return undefined;
|
||||
|
||||
// Retourner la première erreur, sinon le premier warning
|
||||
return state.errors[0] || state.warnings[0] || undefined;
|
||||
}, [validationSummary]);
|
||||
|
||||
// Rendu du résumé de validation
|
||||
const renderValidationSummary = () => {
|
||||
if (!showValidationSummary) return null;
|
||||
|
||||
const { isValid, totalErrors, totalWarnings, totalInfos } = validationSummary;
|
||||
|
||||
if (totalErrors === 0 && totalWarnings === 0 && totalInfos === 0) {
|
||||
return (
|
||||
<Alert severity="success" icon={<CheckCircleIcon />} sx={{ mb: 2 }}>
|
||||
<Typography variant="body2">
|
||||
Tous les paramètres sont correctement configurés
|
||||
</Typography>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
{totalErrors > 0 && (
|
||||
<Alert severity="error" icon={<ErrorIcon />} sx={{ mb: 1 }}>
|
||||
<Typography variant="body2">
|
||||
{totalErrors} erreur{totalErrors > 1 ? 's' : ''} de validation
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{totalWarnings > 0 && (
|
||||
<Alert severity="warning" icon={<WarningIcon />} sx={{ mb: 1 }}>
|
||||
<Typography variant="body2">
|
||||
{totalWarnings} avertissement{totalWarnings > 1 ? 's' : ''}
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{totalInfos > 0 && (
|
||||
<Alert severity="info" icon={<InfoIcon />}>
|
||||
<Typography variant="body2">
|
||||
{totalInfos} information{totalInfos > 1 ? 's' : ''}
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// Rendu d'un groupe de paramètres
|
||||
const renderParameterGroup = (group: ParameterGroup) => {
|
||||
const isExpanded = expandedGroups[group.name] ?? group.expanded;
|
||||
|
||||
return (
|
||||
<Box key={group.name} sx={{ mb: 2 }}>
|
||||
{parameterGroups.length > 1 && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
mb: 1,
|
||||
p: 1,
|
||||
borderRadius: 1,
|
||||
'&:hover': { backgroundColor: 'action.hover' }
|
||||
}}
|
||||
onClick={() => handleGroupToggle(group.name)}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ flex: 1 }}>
|
||||
{group.label} ({group.parameters.length})
|
||||
</Typography>
|
||||
<IconButton size="small">
|
||||
{isExpanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
||||
</IconButton>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Collapse in={isExpanded}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{group.parameters.map((config) => {
|
||||
const value = parameters[config.name];
|
||||
const error = getParameterValidation(config.name);
|
||||
|
||||
return (
|
||||
<Box key={config.name}>
|
||||
<ParameterFieldRenderer
|
||||
config={config}
|
||||
value={value}
|
||||
variables={variables}
|
||||
error={error}
|
||||
onChange={(newValue) => handleParameterChange(config.name, newValue)}
|
||||
onVisualSelection={config.type === 'visual' ? onVisualSelection : undefined}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// Rendu des variables disponibles
|
||||
const renderVariablesSection = () => {
|
||||
if (variables.length === 0) return null;
|
||||
|
||||
const hasVariableSupport = parameterConfigs.some(config => config.supportVariables);
|
||||
if (!hasVariableSupport) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Variables disponibles
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{variables.map((variable) => (
|
||||
<Chip
|
||||
key={variable.id}
|
||||
label={`\${${variable.name}}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={() => {
|
||||
// Copier dans le presse-papiers
|
||||
navigator.clipboard.writeText(`\${${variable.name}}`);
|
||||
}}
|
||||
sx={{ cursor: 'pointer' }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
|
||||
Cliquez sur une variable pour la copier dans le presse-papiers
|
||||
</Typography>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// Logging pour le développement
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(`🎛️ [StandardParametersEditor] Rendu:`, {
|
||||
stepType,
|
||||
parameterCount: parameterConfigs.length,
|
||||
groupCount: parameterGroups.length,
|
||||
validationState: {
|
||||
isValid: validationSummary.isValid,
|
||||
errors: validationSummary.totalErrors,
|
||||
warnings: validationSummary.totalWarnings
|
||||
},
|
||||
disabled
|
||||
});
|
||||
}
|
||||
|
||||
// Cas où aucun paramètre n'est configuré
|
||||
if (parameterConfigs.length === 0) {
|
||||
return (
|
||||
<Box className={className}>
|
||||
<Typography variant="body2" color="text.secondary" textAlign="center" sx={{ py: 4 }}>
|
||||
Cette étape n'a pas de paramètres configurables.
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className={className} data-step-type={stepType}>
|
||||
{/* Résumé de validation */}
|
||||
{renderValidationSummary()}
|
||||
|
||||
{/* Indicateur de progression si validation en cours */}
|
||||
{disabled && (
|
||||
<LinearProgress sx={{ mb: 2 }} />
|
||||
)}
|
||||
|
||||
{/* Groupes de paramètres */}
|
||||
{parameterGroups.map(renderParameterGroup)}
|
||||
|
||||
{/* Section des variables */}
|
||||
{renderVariablesSection()}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Export du composant mémorisé
|
||||
*/
|
||||
export default memo(StandardParametersEditor, (prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.stepType === nextProps.stepType &&
|
||||
JSON.stringify(prevProps.parameterConfigs) === JSON.stringify(nextProps.parameterConfigs) &&
|
||||
JSON.stringify(prevProps.parameters) === JSON.stringify(nextProps.parameters) &&
|
||||
JSON.stringify(prevProps.variables) === JSON.stringify(nextProps.variables) &&
|
||||
prevProps.disabled === nextProps.disabled &&
|
||||
prevProps.showValidationSummary === nextProps.showValidationSummary &&
|
||||
prevProps.groupParameters === nextProps.groupParameters
|
||||
);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,633 @@
|
||||
/**
|
||||
* Composant Panneau de Propriétés - Configuration des paramètres d'étapes
|
||||
* Auteur : Dom, Alice, Kiro - 12 janvier 2026
|
||||
*
|
||||
* Ce composant affiche et permet la modification des paramètres d'une étape sélectionnée,
|
||||
* avec validation en temps réel et adaptation selon le type de paramètre.
|
||||
*
|
||||
* Version refactorisée utilisant le nouveau StepTypeResolver pour une résolution
|
||||
* unifiée et robuste des propriétés d'étapes.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useMemo, memo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Alert,
|
||||
Divider,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Visibility as VisibilityIcon,
|
||||
Error as ErrorIcon,
|
||||
BugReport as BugReportIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
// Import des composants
|
||||
import VisualSelector from '../VisualSelector';
|
||||
import VariableAutocomplete from '../VariableAutocomplete';
|
||||
import VWBActionProperties from './VWBActionProperties';
|
||||
import DebugPanel from '../DebugPanel';
|
||||
|
||||
// Import des nouveaux composants de l'interface complète
|
||||
import StandardParametersEditor from './StandardParametersEditor';
|
||||
import EmptyStateMessage from './EmptyStateMessage';
|
||||
import LoadingState, { StepResolutionLoading, VWBActionLoading } from './LoadingState';
|
||||
|
||||
// Import du nouveau système de résolution
|
||||
import { useStepTypeResolver } from '../../hooks/useStepTypeResolver';
|
||||
import { ParameterConfig, StepTypeResolutionResult } from '../../services/StepTypeResolver';
|
||||
|
||||
// Import des hooks d'intégration VWB (pour compatibilité)
|
||||
import { useVWBStepIntegration, useVWBActionId } from '../../hooks/useVWBStepIntegration';
|
||||
|
||||
// Import des types du catalogue VWB
|
||||
import { VWBCatalogAction, VWBActionValidationResult } from '../../types/catalog';
|
||||
|
||||
// Import des hooks d'auto-sauvegarde
|
||||
import { useStepParametersAutoSave } from '../../hooks/useAutoSave';
|
||||
|
||||
// Import des types partagés
|
||||
import {
|
||||
PropertiesPanelProps,
|
||||
ValidationError,
|
||||
VisualSelection,
|
||||
Variable,
|
||||
} from '../../types';
|
||||
|
||||
// Types pour l'état d'affichage
|
||||
interface DisplayState {
|
||||
type: 'loading' | 'empty' | 'vwb-properties' | 'standard-parameters';
|
||||
subtype?: 'resolving' | 'loading-vwb';
|
||||
reason?: 'resolution-failed' | 'vwb-not-found' | 'no-parameters' | 'unknown-type';
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composant Panneau de Propriétés
|
||||
*/
|
||||
const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
|
||||
selectedStep,
|
||||
variables,
|
||||
onParameterChange,
|
||||
onVisualSelection,
|
||||
}) => {
|
||||
const [localParameters, setLocalParameters] = useState<Record<string, any>>({});
|
||||
const [isVisualSelectorOpen, setIsVisualSelectorOpen] = useState(false);
|
||||
const [isDebugPanelVisible, setIsDebugPanelVisible] = useState(false);
|
||||
const [validationErrors, setValidationErrors] = useState<ValidationError[]>([]);
|
||||
|
||||
// Utilisation du nouveau système de résolution unifié
|
||||
const stepResolver = useStepTypeResolver(selectedStep || null, {
|
||||
autoResolve: true,
|
||||
enableLogging: process.env.NODE_ENV === 'development',
|
||||
onResolutionComplete: (result) => {
|
||||
console.log('✅ [PropertiesPanel] Résolution terminée:', {
|
||||
stepType: result.stepType,
|
||||
isVWBAction: result.isVWBAction,
|
||||
parameterCount: result.parameterConfig.length,
|
||||
source: result.resolutionSource
|
||||
});
|
||||
},
|
||||
onResolutionError: (error) => {
|
||||
console.error('❌ [PropertiesPanel] Erreur de résolution:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// Hooks d'intégration VWB (pour compatibilité et chargement des actions)
|
||||
const { methods: vwbMethods } = useVWBStepIntegration();
|
||||
const vwbActionId = useVWBActionId(selectedStep || null);
|
||||
|
||||
// État dérivé du résolveur
|
||||
const {
|
||||
result: resolutionResult,
|
||||
isLoading: isResolving,
|
||||
error: resolutionError,
|
||||
resolveStep,
|
||||
resolveStepSync,
|
||||
invalidateCache: invalidateResolverCache,
|
||||
hasParameterConfig,
|
||||
parameterCount,
|
||||
isStandardType,
|
||||
resolutionSource
|
||||
} = stepResolver;
|
||||
|
||||
// Déterminer si c'est une action VWB
|
||||
// Utiliser vwbActionId comme indicateur principal car le hook useVWBActionId fait une détection robuste
|
||||
const isVWBCatalogAction = useMemo(() => {
|
||||
// Si vwbActionId est défini, c'est une action VWB (détection via hook)
|
||||
if (vwbActionId) {
|
||||
return true;
|
||||
}
|
||||
// Fallback vers le résultat du résolveur
|
||||
return resolutionResult?.isVWBAction || false;
|
||||
}, [resolutionResult, vwbActionId]);
|
||||
|
||||
// Obtenir la configuration des paramètres
|
||||
const parameterConfigs = useMemo(() => {
|
||||
if (!resolutionResult) return [];
|
||||
return resolutionResult.parameterConfig || [];
|
||||
}, [resolutionResult]);
|
||||
|
||||
// Charger l'action VWB si nécessaire
|
||||
const [vwbAction, setVwbAction] = useState<VWBCatalogAction | null>(null);
|
||||
const [isLoadingVWBAction, setIsLoadingVWBAction] = useState(false);
|
||||
const [vwbActionError, setVwbActionError] = useState<Error | null>(null);
|
||||
|
||||
// Hook d'auto-sauvegarde pour les paramètres
|
||||
const autoSave = useStepParametersAutoSave(
|
||||
selectedStep?.id || '',
|
||||
onParameterChange,
|
||||
{
|
||||
debounceMs: 800,
|
||||
enableLogging: process.env.NODE_ENV === 'development',
|
||||
onSaveStart: () => console.log('💾 [PropertiesPanel] Début sauvegarde auto'),
|
||||
onSaveSuccess: () => console.log('✅ [PropertiesPanel] Sauvegarde auto réussie'),
|
||||
onSaveError: (error) => console.error('❌ [PropertiesPanel] Erreur sauvegarde auto:', error)
|
||||
}
|
||||
);
|
||||
|
||||
// Méthode pour résoudre manuellement une étape
|
||||
const handleManualResolveStep = useCallback(async (): Promise<StepTypeResolutionResult | null> => {
|
||||
if (!selectedStep) return null;
|
||||
|
||||
try {
|
||||
console.log('🔄 [PropertiesPanel] Résolution manuelle de l\'étape:', selectedStep.id);
|
||||
const result: StepTypeResolutionResult = await resolveStep(selectedStep, { enableCache: false });
|
||||
console.log('✅ [PropertiesPanel] Résolution manuelle terminée:', result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('❌ [PropertiesPanel] Erreur résolution manuelle:', error);
|
||||
return null;
|
||||
}
|
||||
}, [selectedStep, resolveStep]);
|
||||
|
||||
// Méthode pour invalider le cache et recharger
|
||||
const handleRefreshResolution = useCallback(() => {
|
||||
console.log('🔄 [PropertiesPanel] Actualisation de la résolution');
|
||||
invalidateResolverCache();
|
||||
if (selectedStep) {
|
||||
handleManualResolveStep();
|
||||
}
|
||||
}, [invalidateResolverCache, selectedStep, handleManualResolveStep]);
|
||||
|
||||
// Charger l'action VWB basé sur vwbActionId (pas sur isVWBCatalogAction du résolveur)
|
||||
React.useEffect(() => {
|
||||
const loadVWBAction = async () => {
|
||||
// Charger si vwbActionId est défini (le hook useVWBActionId fait déjà la détection)
|
||||
if (!vwbActionId) {
|
||||
setVwbAction(null);
|
||||
setVwbActionError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🔄 [PropertiesPanel] Chargement action VWB:', vwbActionId);
|
||||
setIsLoadingVWBAction(true);
|
||||
setVwbActionError(null);
|
||||
|
||||
try {
|
||||
// Utiliser le hook d'intégration pour charger l'action
|
||||
const action = await vwbMethods.loadVWBAction(vwbActionId);
|
||||
setVwbAction(action);
|
||||
|
||||
if (action) {
|
||||
console.log('✅ [PropertiesPanel] Action VWB chargée:', {
|
||||
actionId: vwbActionId,
|
||||
actionName: action.name,
|
||||
parametersCount: Object.keys(action.parameters || {}).length
|
||||
});
|
||||
} else {
|
||||
console.warn('⚠️ [PropertiesPanel] Action VWB non trouvée:', vwbActionId);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorObj = error instanceof Error ? error : new Error(String(error));
|
||||
console.error('❌ [PropertiesPanel] Erreur chargement action VWB:', errorObj);
|
||||
setVwbAction(null);
|
||||
setVwbActionError(errorObj);
|
||||
} finally {
|
||||
setIsLoadingVWBAction(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadVWBAction();
|
||||
}, [vwbActionId, vwbMethods]);
|
||||
|
||||
// Gestionnaire d'événements clavier pour le panneau de propriétés
|
||||
const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
|
||||
switch (event.key) {
|
||||
case 'Enter':
|
||||
// Activer l'élément focalisé
|
||||
if (event.target instanceof HTMLButtonElement) {
|
||||
event.preventDefault();
|
||||
event.target.click();
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
// Fermer les dialogues ouverts
|
||||
event.preventDefault();
|
||||
setIsVisualSelectorOpen(false);
|
||||
break;
|
||||
case 'Tab':
|
||||
// Navigation entre les champs (comportement par défaut)
|
||||
break;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Synchroniser les paramètres locaux avec l'étape sélectionnée
|
||||
// IMPORTANT: Copie profonde pour éviter les mutations qui affectent d'autres étapes
|
||||
React.useEffect(() => {
|
||||
if (selectedStep) {
|
||||
// Copie profonde pour isoler les paramètres locaux du workflow
|
||||
const paramsCopy = JSON.parse(JSON.stringify(selectedStep.data?.parameters || {}));
|
||||
setLocalParameters(paramsCopy);
|
||||
} else {
|
||||
setLocalParameters({});
|
||||
}
|
||||
}, [selectedStep]);
|
||||
|
||||
// Gestionnaire de changement de paramètre VWB (avec auto-sauvegarde)
|
||||
const handleVWBParameterChange = useCallback((paramName: string, value: any) => {
|
||||
// Copie profonde de la valeur si c'est un objet (ex: visual_anchor)
|
||||
const valueCopy = value && typeof value === 'object'
|
||||
? JSON.parse(JSON.stringify(value))
|
||||
: value;
|
||||
|
||||
const newParameters = { ...localParameters, [paramName]: valueCopy };
|
||||
setLocalParameters(newParameters);
|
||||
|
||||
if (selectedStep) {
|
||||
// Déclencher la sauvegarde automatique (fix: était manquant pour les actions VWB)
|
||||
autoSave.triggerSave(newParameters);
|
||||
// Envoyer aussi une copie au parent pour éviter les références partagées
|
||||
onParameterChange(selectedStep.id, paramName, valueCopy);
|
||||
}
|
||||
}, [localParameters, selectedStep, onParameterChange, autoSave]);
|
||||
|
||||
// Gestionnaire de validation VWB
|
||||
const handleVWBValidationChange = useCallback((validation: VWBActionValidationResult) => {
|
||||
// Mettre à jour les erreurs de validation de l'étape
|
||||
if (selectedStep) {
|
||||
// Notifier le parent des erreurs de validation
|
||||
if (validation.errors.length > 0) {
|
||||
console.error('Erreurs de validation VWB:', validation.errors.map(e => e.message).join(', '));
|
||||
}
|
||||
}
|
||||
}, [selectedStep]);
|
||||
|
||||
// Gestionnaire de changement de paramètre avec auto-sauvegarde
|
||||
const handleParameterChange = useCallback((paramName: string, value: any) => {
|
||||
// Copie profonde de la valeur si c'est un objet
|
||||
const valueCopy = value && typeof value === 'object'
|
||||
? JSON.parse(JSON.stringify(value))
|
||||
: value;
|
||||
|
||||
const newParameters = { ...localParameters, [paramName]: valueCopy };
|
||||
setLocalParameters(newParameters);
|
||||
|
||||
// Déclencher la sauvegarde automatique
|
||||
autoSave.triggerSave(newParameters);
|
||||
|
||||
if (selectedStep) {
|
||||
onParameterChange(selectedStep.id, paramName, valueCopy);
|
||||
}
|
||||
}, [localParameters, selectedStep, onParameterChange, autoSave]);
|
||||
|
||||
// Gestionnaire de sélection visuelle
|
||||
const handleVisualSelection = useCallback(() => {
|
||||
if (selectedStep) {
|
||||
setIsVisualSelectorOpen(true);
|
||||
}
|
||||
}, [selectedStep]);
|
||||
|
||||
// Gestionnaire de confirmation de sélection visuelle
|
||||
const handleElementSelected = useCallback((selection: VisualSelection) => {
|
||||
if (selectedStep) {
|
||||
// Stocker la sélection visuelle dans les paramètres
|
||||
handleParameterChange('target', selection);
|
||||
onVisualSelection(selectedStep.id);
|
||||
}
|
||||
}, [selectedStep, handleParameterChange, onVisualSelection]);
|
||||
|
||||
// Gestionnaire de validation des paramètres
|
||||
const handleValidationChange = useCallback((errors: ValidationError[]) => {
|
||||
setValidationErrors(errors);
|
||||
}, []);
|
||||
|
||||
// Déterminer l'état d'affichage et le type de contenu
|
||||
const getDisplayState = useCallback((): DisplayState => {
|
||||
// Cas de chargement de résolution
|
||||
if (isResolving) {
|
||||
return { type: 'loading', subtype: 'resolving' };
|
||||
}
|
||||
|
||||
// Cas d'erreur de résolution
|
||||
if (resolutionError) {
|
||||
return {
|
||||
type: 'empty',
|
||||
reason: 'resolution-failed' as const,
|
||||
error: resolutionError
|
||||
};
|
||||
}
|
||||
|
||||
// Cas d'action VWB
|
||||
if (isVWBCatalogAction) {
|
||||
if (isLoadingVWBAction) {
|
||||
return { type: 'loading', subtype: 'loading-vwb' };
|
||||
}
|
||||
|
||||
if (vwbActionError) {
|
||||
return {
|
||||
type: 'empty',
|
||||
reason: 'vwb-not-found' as const,
|
||||
error: vwbActionError
|
||||
};
|
||||
}
|
||||
|
||||
if (vwbAction) {
|
||||
return { type: 'vwb-properties' };
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'empty',
|
||||
reason: 'vwb-not-found' as const
|
||||
};
|
||||
}
|
||||
|
||||
// Cas d'étapes standard
|
||||
if (parameterConfigs.length === 0) {
|
||||
return {
|
||||
type: 'empty',
|
||||
reason: resolutionResult?.isStandardType ? 'no-parameters' as const : 'unknown-type' as const
|
||||
};
|
||||
}
|
||||
|
||||
return { type: 'standard-parameters' };
|
||||
}, [
|
||||
isResolving,
|
||||
resolutionError,
|
||||
isVWBCatalogAction,
|
||||
isLoadingVWBAction,
|
||||
vwbActionError,
|
||||
vwbAction,
|
||||
parameterConfigs.length,
|
||||
resolutionResult
|
||||
]);
|
||||
|
||||
// Debug logging pour diagnostic
|
||||
React.useEffect(() => {
|
||||
if (selectedStep) {
|
||||
console.log('🔍 [PropertiesPanel] État actuel:', {
|
||||
stepId: selectedStep.id,
|
||||
stepType: selectedStep.type,
|
||||
stepData: selectedStep.data,
|
||||
isVWBCatalogAction,
|
||||
vwbActionId,
|
||||
vwbAction: vwbAction ? vwbAction.name : null,
|
||||
isLoadingVWBAction,
|
||||
vwbActionError: vwbActionError?.message,
|
||||
displayState: getDisplayState(),
|
||||
resolutionResult: resolutionResult ? {
|
||||
isVWBAction: resolutionResult.isVWBAction,
|
||||
isStandardType: resolutionResult.isStandardType,
|
||||
parameterCount: resolutionResult.parameterConfig?.length
|
||||
} : null
|
||||
});
|
||||
}
|
||||
}, [selectedStep, isVWBCatalogAction, vwbActionId, vwbAction, isLoadingVWBAction, vwbActionError, resolutionResult, getDisplayState]);
|
||||
|
||||
if (!selectedStep) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
width: 320,
|
||||
height: '100%',
|
||||
backgroundColor: '#ffffff',
|
||||
borderLeft: '1px solid #e0e0e0',
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
role="complementary"
|
||||
aria-label="Panneau de propriétés - Aucune étape sélectionnée"
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary" textAlign="center">
|
||||
Sélectionnez une étape pour configurer ses propriétés
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const hasErrors = (selectedStep.validationErrors || []).some(error => error.severity === 'error');
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
width: 320,
|
||||
height: '100%',
|
||||
backgroundColor: '#ffffff',
|
||||
borderLeft: '1px solid #e0e0e0',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
role="complementary"
|
||||
aria-label={`Propriétés de l'étape ${selectedStep.name}`}
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={0}
|
||||
>
|
||||
{/* En-tête */}
|
||||
<Box sx={{ p: 2, borderBottom: '1px solid #e0e0e0' }}>
|
||||
<Typography variant="h6" component="h3" gutterBottom>
|
||||
Propriétés de l'étape
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{selectedStep.name} ({selectedStep.type})
|
||||
</Typography>
|
||||
|
||||
{/* Indicateur de résolution */}
|
||||
{isResolving && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mt: 1 }}>
|
||||
<CircularProgress size={16} sx={{ mr: 1 }} />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Résolution des propriétés...
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{resolutionError && (
|
||||
<Alert severity="warning" sx={{ mt: 1 }}>
|
||||
Erreur de résolution des propriétés
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Alertes d'erreur globales */}
|
||||
{hasErrors && (
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Alert severity="error" icon={<ErrorIcon />}>
|
||||
Cette étape contient des erreurs qui empêchent l'exécution du workflow.
|
||||
</Alert>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Paramètres - Rendu conditionnel selon l'état */}
|
||||
<Box sx={{ flex: 1, overflow: 'auto', p: 2 }}>
|
||||
{(() => {
|
||||
const displayState = getDisplayState();
|
||||
|
||||
switch (displayState.type) {
|
||||
case 'loading':
|
||||
if (displayState.subtype === 'resolving') {
|
||||
return (
|
||||
<StepResolutionLoading
|
||||
message="Résolution des propriétés d'étape..."
|
||||
canCancel={false}
|
||||
/>
|
||||
);
|
||||
} else if (displayState.subtype === 'loading-vwb') {
|
||||
return (
|
||||
<VWBActionLoading
|
||||
message="Chargement de l'action VWB..."
|
||||
canCancel={true}
|
||||
onCancel={() => {
|
||||
setIsLoadingVWBAction(false);
|
||||
setVwbActionError(new Error('Chargement annulé par l\'utilisateur'));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'empty':
|
||||
return (
|
||||
<EmptyStateMessage
|
||||
stepType={selectedStep.type}
|
||||
reason={displayState.reason || 'unknown-type'}
|
||||
error={displayState.error}
|
||||
onRetry={displayState.reason === 'resolution-failed' ? handleRefreshResolution : undefined}
|
||||
onRefresh={displayState.reason === 'vwb-not-found' ? handleRefreshResolution : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'vwb-properties':
|
||||
return (
|
||||
<VWBActionProperties
|
||||
key={`vwb-props-${selectedStep.id}`}
|
||||
action={vwbAction!}
|
||||
parameters={localParameters}
|
||||
variables={variables as Variable[]}
|
||||
onParameterChange={handleVWBParameterChange}
|
||||
onValidationChange={handleVWBValidationChange}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'standard-parameters':
|
||||
return (
|
||||
<StandardParametersEditor
|
||||
stepType={selectedStep.type}
|
||||
parameterConfigs={parameterConfigs}
|
||||
parameters={localParameters}
|
||||
variables={variables as Variable[]}
|
||||
onParameterChange={handleParameterChange}
|
||||
onValidationChange={handleValidationChange}
|
||||
onVisualSelection={handleVisualSelection}
|
||||
disabled={autoSave.saveState.isSaving}
|
||||
showValidationSummary={true}
|
||||
groupParameters={parameterConfigs.length > 5}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<EmptyStateMessage
|
||||
stepType={selectedStep.type}
|
||||
reason="unknown-type"
|
||||
onRetry={handleRefreshResolution}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
|
||||
{/* Indicateur de sauvegarde */}
|
||||
{autoSave.saveState.isSaving && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Alert severity="info" sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<CircularProgress size={16} sx={{ mr: 1 }} />
|
||||
<Typography variant="body2">
|
||||
Sauvegarde en cours...
|
||||
</Typography>
|
||||
</Alert>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Erreur de sauvegarde */}
|
||||
{autoSave.saveState.error && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Alert
|
||||
severity="error"
|
||||
action={
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => autoSave.resetError()}
|
||||
>
|
||||
Ignorer
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Typography variant="body2">
|
||||
Erreur de sauvegarde: {autoSave.saveState.error.message}
|
||||
</Typography>
|
||||
</Alert>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Panneau de debug en mode développement */}
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<Box sx={{ borderTop: '1px solid #e0e0e0', p: 1 }}>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<BugReportIcon />}
|
||||
onClick={() => setIsDebugPanelVisible(!isDebugPanelVisible)}
|
||||
variant="text"
|
||||
>
|
||||
Debug Panel
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Composant VisualSelector */}
|
||||
{selectedStep && (
|
||||
<VisualSelector
|
||||
isOpen={isVisualSelectorOpen}
|
||||
stepId={selectedStep.id}
|
||||
onClose={() => setIsVisualSelectorOpen(false)}
|
||||
onElementSelected={handleElementSelected}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Composant DebugPanel (mode développement) */}
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<DebugPanel
|
||||
selectedStep={selectedStep}
|
||||
variables={variables as Variable[]}
|
||||
isVisible={isDebugPanelVisible}
|
||||
onToggleVisibility={setIsDebugPanelVisible}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// Mémorisation du composant PropertiesPanel pour éviter les re-rendus inutiles
|
||||
export default memo(PropertiesPanel, (prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.selectedStep?.id === nextProps.selectedStep?.id &&
|
||||
JSON.stringify(prevProps.selectedStep?.data) === JSON.stringify(nextProps.selectedStep?.data) &&
|
||||
JSON.stringify(prevProps.variables) === JSON.stringify(nextProps.variables) &&
|
||||
prevProps.onParameterChange === nextProps.onParameterChange &&
|
||||
prevProps.onVisualSelection === nextProps.onVisualSelection
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
/* Styles pour le Composant de Capture d'Écran Réelle */
|
||||
/* Auteur : Dom, Alice, Kiro - 8 janvier 2026 */
|
||||
|
||||
.real-screen-capture {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.real-screen-capture__screenshot {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
cursor: crosshair;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.real-screen-capture__element-overlay {
|
||||
position: absolute;
|
||||
border: 2px solid #ff4444;
|
||||
background-color: rgba(255, 68, 68, 0.1);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.real-screen-capture__element-overlay:hover {
|
||||
background-color: rgba(255, 68, 68, 0.2);
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
/**
|
||||
* Composant RealScreenCapture - Capture d'écran en temps réel pour sélection visuelle
|
||||
* Auteur : Dom, Alice, Kiro - 12 janvier 2026
|
||||
*
|
||||
* Ce composant fournit une interface de capture d'écran en temps réel pour permettre
|
||||
* aux utilisateurs de sélectionner visuellement des éléments sur l'écran.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useEffect, memo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Typography,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
CameraAlt as CameraIcon,
|
||||
Close as CloseIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Fullscreen as FullscreenIcon,
|
||||
CropFree as SelectIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
// Import des services
|
||||
import { realScreenCaptureService } from '../../services/realScreenCaptureService';
|
||||
|
||||
// Import des types
|
||||
import { VisualSelection } from '../../types';
|
||||
|
||||
/**
|
||||
* Props du composant RealScreenCapture
|
||||
*/
|
||||
export interface RealScreenCaptureProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onElementSelected: (selection: VisualSelection) => void;
|
||||
stepId?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* État de capture
|
||||
*/
|
||||
interface CaptureState {
|
||||
isCapturing: boolean;
|
||||
isSelecting: boolean;
|
||||
screenshot: string | null;
|
||||
error: Error | null;
|
||||
selectedElement: VisualSelection | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composant RealScreenCapture
|
||||
*/
|
||||
const RealScreenCapture: React.FC<RealScreenCaptureProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onElementSelected,
|
||||
stepId,
|
||||
className
|
||||
}) => {
|
||||
// État de capture
|
||||
const [captureState, setCaptureState] = useState<CaptureState>({
|
||||
isCapturing: false,
|
||||
isSelecting: false,
|
||||
screenshot: null,
|
||||
error: null,
|
||||
selectedElement: null
|
||||
});
|
||||
|
||||
/**
|
||||
* Démarre la capture d'écran
|
||||
*/
|
||||
const startCapture = useCallback(async () => {
|
||||
setCaptureState(prev => ({
|
||||
...prev,
|
||||
isCapturing: true,
|
||||
error: null,
|
||||
screenshot: null
|
||||
}));
|
||||
|
||||
try {
|
||||
console.log('📸 [RealScreenCapture] Début de capture d\'écran');
|
||||
|
||||
// Utiliser le service de capture d'écran réelle
|
||||
const response = await realScreenCaptureService.captureWithElements(0, false);
|
||||
|
||||
if (response?.success && response.screenshot) {
|
||||
// Le backend peut retourner soit une data URI complète, soit juste le base64
|
||||
const screenshotData = response.screenshot.startsWith('data:')
|
||||
? response.screenshot
|
||||
: `data:image/png;base64,${response.screenshot}`;
|
||||
|
||||
setCaptureState(prev => ({
|
||||
...prev,
|
||||
isCapturing: false,
|
||||
screenshot: screenshotData,
|
||||
isSelecting: true
|
||||
}));
|
||||
|
||||
console.log('✅ [RealScreenCapture] Capture d\'écran réussie');
|
||||
} else {
|
||||
throw new Error(response?.error || 'Échec de la capture d\'écran');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
const errorObj = error instanceof Error ? error : new Error(String(error));
|
||||
console.error('❌ [RealScreenCapture] Erreur de capture:', errorObj);
|
||||
|
||||
setCaptureState(prev => ({
|
||||
...prev,
|
||||
isCapturing: false,
|
||||
error: errorObj
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Gère la sélection d'un élément sur la capture
|
||||
*/
|
||||
const handleElementSelection = useCallback((event: React.MouseEvent<HTMLImageElement>) => {
|
||||
if (!captureState.isSelecting || !captureState.screenshot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
const y = event.clientY - rect.top;
|
||||
|
||||
// Calculer les coordonnées relatives
|
||||
const relativeX = x / rect.width;
|
||||
const relativeY = y / rect.height;
|
||||
|
||||
const selection: VisualSelection = {
|
||||
id: `selection_${Date.now()}`,
|
||||
screenshot: captureState.screenshot,
|
||||
boundingBox: {
|
||||
x: Math.round(relativeX * 100) / 100,
|
||||
y: Math.round(relativeY * 100) / 100,
|
||||
width: 0.01, // Point de clic, taille minimale
|
||||
height: 0.01
|
||||
},
|
||||
description: `Point de clic (${relativeX.toFixed(3)}, ${relativeY.toFixed(3)})`,
|
||||
metadata: {
|
||||
capture_method: 'real_screen_capture',
|
||||
capture_timestamp: new Date().toISOString(),
|
||||
screen_resolution: {
|
||||
width: rect.width,
|
||||
height: rect.height
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
setCaptureState(prev => ({
|
||||
...prev,
|
||||
selectedElement: selection,
|
||||
isSelecting: false
|
||||
}));
|
||||
|
||||
console.log('🎯 [RealScreenCapture] Élément sélectionné:', selection);
|
||||
}, [captureState.isSelecting, captureState.screenshot, stepId]);
|
||||
|
||||
/**
|
||||
* Confirme la sélection
|
||||
*/
|
||||
const confirmSelection = useCallback(() => {
|
||||
if (captureState.selectedElement) {
|
||||
onElementSelected(captureState.selectedElement);
|
||||
handleClose();
|
||||
}
|
||||
}, [captureState.selectedElement, onElementSelected]);
|
||||
|
||||
/**
|
||||
* Ferme le dialogue et remet à zéro l'état
|
||||
*/
|
||||
const handleClose = useCallback(() => {
|
||||
setCaptureState({
|
||||
isCapturing: false,
|
||||
isSelecting: false,
|
||||
screenshot: null,
|
||||
error: null,
|
||||
selectedElement: null
|
||||
});
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
/**
|
||||
* Recommence la capture
|
||||
*/
|
||||
const restartCapture = useCallback(() => {
|
||||
setCaptureState(prev => ({
|
||||
...prev,
|
||||
screenshot: null,
|
||||
selectedElement: null,
|
||||
error: null,
|
||||
isSelecting: false
|
||||
}));
|
||||
startCapture();
|
||||
}, [startCapture]);
|
||||
|
||||
/**
|
||||
* Effet pour démarrer automatiquement la capture à l'ouverture
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (isOpen && !captureState.screenshot && !captureState.isCapturing) {
|
||||
startCapture();
|
||||
}
|
||||
}, [isOpen, captureState.screenshot, captureState.isCapturing, startCapture]);
|
||||
|
||||
/**
|
||||
* Rendu de l'état de capture
|
||||
*/
|
||||
const renderCaptureState = () => {
|
||||
if (captureState.isCapturing) {
|
||||
return (
|
||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||
<CircularProgress size={48} sx={{ mb: 2 }} />
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Capture d'écran en cours...
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Veuillez patienter pendant la capture de votre écran
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (captureState.error) {
|
||||
return (
|
||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
<Typography variant="body2">
|
||||
Erreur de capture: {captureState.error.message}
|
||||
</Typography>
|
||||
</Alert>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={startCapture}
|
||||
>
|
||||
Réessayer
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!captureState.screenshot) {
|
||||
return (
|
||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
startIcon={<CameraIcon />}
|
||||
onClick={startCapture}
|
||||
>
|
||||
Capturer l'écran
|
||||
</Button>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
|
||||
Cliquez pour prendre une capture d'écran et sélectionner un élément
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Rendu de l'interface de sélection
|
||||
*/
|
||||
const renderSelectionInterface = () => {
|
||||
if (!captureState.screenshot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
{/* Instructions */}
|
||||
<Alert
|
||||
severity={captureState.isSelecting ? "info" : "success"}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
<Typography variant="body2">
|
||||
{captureState.isSelecting
|
||||
? "Cliquez sur l'élément que vous souhaitez sélectionner"
|
||||
: "Élément sélectionné ! Confirmez votre choix ou recommencez."
|
||||
}
|
||||
</Typography>
|
||||
</Alert>
|
||||
|
||||
{/* Image de capture avec sélection */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
border: '2px solid',
|
||||
borderColor: captureState.isSelecting ? 'primary.main' : 'success.main',
|
||||
borderRadius: 1,
|
||||
overflow: 'hidden',
|
||||
cursor: captureState.isSelecting ? 'crosshair' : 'default'
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={captureState.screenshot}
|
||||
alt="Capture d'écran pour sélection"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
maxHeight: '60vh',
|
||||
objectFit: 'contain'
|
||||
}}
|
||||
onClick={handleElementSelection}
|
||||
/>
|
||||
|
||||
{/* Indicateur de sélection */}
|
||||
{captureState.selectedElement && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: `${captureState.selectedElement.boundingBox.x * 100}%`,
|
||||
top: `${captureState.selectedElement.boundingBox.y * 100}%`,
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'success.main',
|
||||
border: '3px solid white',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
boxShadow: 2,
|
||||
zIndex: 1
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Informations de sélection */}
|
||||
{captureState.selectedElement && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Coordonnées sélectionnées:
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
X: {(captureState.selectedElement.boundingBox.x * 100).toFixed(1)}%,
|
||||
Y: {(captureState.selectedElement.boundingBox.y * 100).toFixed(1)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onClose={handleClose}
|
||||
maxWidth="lg"
|
||||
fullWidth
|
||||
className={className}
|
||||
PaperProps={{
|
||||
sx: { minHeight: '60vh' }
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<SelectIcon sx={{ mr: 1 }} />
|
||||
<Typography variant="h6">
|
||||
Sélection visuelle d'élément
|
||||
</Typography>
|
||||
</Box>
|
||||
<IconButton onClick={handleClose} size="small">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent>
|
||||
{renderCaptureState()}
|
||||
{renderSelectionInterface()}
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button onClick={handleClose} color="inherit">
|
||||
Annuler
|
||||
</Button>
|
||||
|
||||
{captureState.screenshot && (
|
||||
<Tooltip title="Prendre une nouvelle capture">
|
||||
<Button
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={restartCapture}
|
||||
color="primary"
|
||||
>
|
||||
Nouvelle capture
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{captureState.selectedElement && (
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={confirmSelection}
|
||||
startIcon={<SelectIcon />}
|
||||
>
|
||||
Confirmer la sélection
|
||||
</Button>
|
||||
)}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Export du composant mémorisé
|
||||
*/
|
||||
export default memo(RealScreenCapture, (prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.isOpen === nextProps.isOpen &&
|
||||
prevProps.stepId === nextProps.stepId
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Composant de Test - Chargement du Catalogue VWB
|
||||
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
|
||||
*
|
||||
* Composant de test pour diagnostiquer le chargement du catalogue d'actions VisionOnly
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Box, Typography, Alert, CircularProgress, List, ListItem, ListItemText, Chip } from '@mui/material';
|
||||
import { catalogService } from '../services/catalogService';
|
||||
import { useCatalogActions } from '../hooks/useCatalogActions';
|
||||
import { VWBCatalogAction } from '../types/catalog';
|
||||
|
||||
const TestCatalogLoader: React.FC = () => {
|
||||
const [directServiceTest, setDirectServiceTest] = useState<{
|
||||
loading: boolean;
|
||||
actions: VWBCatalogAction[];
|
||||
error: string | null;
|
||||
}>({
|
||||
loading: true,
|
||||
actions: [],
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Test direct du service
|
||||
useEffect(() => {
|
||||
const testDirectService = async () => {
|
||||
try {
|
||||
console.log('🧪 Test direct du catalogService...');
|
||||
const result = await catalogService.getActions();
|
||||
console.log('✅ Service direct réussi:', result);
|
||||
|
||||
setDirectServiceTest({
|
||||
loading: false,
|
||||
actions: result.actions as VWBCatalogAction[],
|
||||
error: null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('❌ Service direct échoué:', error);
|
||||
setDirectServiceTest({
|
||||
loading: false,
|
||||
actions: [],
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
testDirectService();
|
||||
}, []);
|
||||
|
||||
// Test du hook
|
||||
const {
|
||||
state: hookState,
|
||||
filteredActions: hookActions,
|
||||
stats: hookStats,
|
||||
actions: hookMethods,
|
||||
} = useCatalogActions({
|
||||
autoLoad: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3, maxWidth: 800 }}>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
🧪 Test de Chargement du Catalogue VWB
|
||||
</Typography>
|
||||
|
||||
{/* Test du service direct */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
1. Test Direct du Service catalogService
|
||||
</Typography>
|
||||
|
||||
{directServiceTest.loading ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<CircularProgress size={20} />
|
||||
<Typography>Chargement...</Typography>
|
||||
</Box>
|
||||
) : directServiceTest.error ? (
|
||||
<Alert severity="error">
|
||||
Erreur service direct: {directServiceTest.error}
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert severity="success">
|
||||
Service direct réussi: {directServiceTest.actions.length} actions chargées
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{directServiceTest.actions.length > 0 && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Actions chargées par le service direct:
|
||||
</Typography>
|
||||
<List dense>
|
||||
{directServiceTest.actions.slice(0, 3).map((action) => (
|
||||
<ListItem key={action.id}>
|
||||
<ListItemText
|
||||
primary={`${action.icon} ${action.name}`}
|
||||
secondary={`${action.category} - ${action.description.substring(0, 60)}...`}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Test du hook */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
2. Test du Hook useCatalogActions
|
||||
</Typography>
|
||||
|
||||
{hookState.isLoading ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<CircularProgress size={20} />
|
||||
<Typography>Hook en cours de chargement...</Typography>
|
||||
</Box>
|
||||
) : hookState.error ? (
|
||||
<Alert severity="error">
|
||||
Erreur hook: {hookState.error}
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert severity="success">
|
||||
Hook réussi: {hookState.actions.length} actions, {hookState.categories.length} catégories
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box sx={{ mt: 2, display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Chip label={`Actions: ${hookState.actions.length}`} color="primary" />
|
||||
<Chip label={`Catégories: ${hookState.categories.length}`} color="secondary" />
|
||||
<Chip label={`En ligne: ${hookState.isOnline ? 'Oui' : 'Non'}`} color={hookState.isOnline ? 'success' : 'error'} />
|
||||
<Chip label={`Dernière MAJ: ${hookState.lastUpdate?.toLocaleTimeString() || 'Jamais'}`} />
|
||||
</Box>
|
||||
|
||||
{hookActions.length > 0 && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Actions filtrées par le hook:
|
||||
</Typography>
|
||||
<List dense>
|
||||
{hookActions.slice(0, 3).map((action) => (
|
||||
<ListItem key={action.id}>
|
||||
<ListItemText
|
||||
primary={`${action.icon} ${action.name}`}
|
||||
secondary={`${action.category} - ${action.description.substring(0, 60)}...`}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Statistiques */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
3. Statistiques du Hook
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Chip label={`Total: ${hookStats.totalActions}`} />
|
||||
<Chip label={`Complexité: ${hookStats.averageComplexity}`} />
|
||||
<Chip label={`Statut: ${hookStats.onlineStatus ? 'En ligne' : 'Hors ligne'}`} />
|
||||
</Box>
|
||||
|
||||
{Object.keys(hookStats.actionsByCategory).length > 0 && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Actions par catégorie:
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
{Object.entries(hookStats.actionsByCategory).map(([category, count]) => (
|
||||
<Chip key={category} label={`${category}: ${count}`} variant="outlined" />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Actions du hook */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
4. Actions Disponibles du Hook
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
||||
<button onClick={hookMethods.reload}>🔄 Recharger</button>
|
||||
<button onClick={hookMethods.clearCache}>🗑️ Vider Cache</button>
|
||||
<button onClick={hookMethods.checkHealth}>❤️ Vérifier Santé</button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Diagnostic */}
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
5. Diagnostic
|
||||
</Typography>
|
||||
|
||||
{directServiceTest.actions.length > 0 && hookState.actions.length > 0 ? (
|
||||
<Alert severity="success">
|
||||
✅ Tout fonctionne correctement ! Le service et le hook chargent les actions.
|
||||
Le problème peut être dans l'affichage de la Palette.
|
||||
</Alert>
|
||||
) : directServiceTest.actions.length > 0 && hookState.actions.length === 0 ? (
|
||||
<Alert severity="warning">
|
||||
⚠️ Le service fonctionne mais le hook ne charge pas les actions.
|
||||
Problème dans le hook useCatalogActions.
|
||||
</Alert>
|
||||
) : directServiceTest.actions.length === 0 ? (
|
||||
<Alert severity="error">
|
||||
❌ Le service ne charge pas les actions.
|
||||
Problème de communication avec le backend.
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert severity="info">
|
||||
🔄 Tests en cours...
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default TestCatalogLoader;
|
||||
@@ -0,0 +1,428 @@
|
||||
/**
|
||||
* Composant de Test Properties Panel - Validation de l'affichage des propriétés VWB
|
||||
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
|
||||
*
|
||||
* Ce composant teste l'affichage des propriétés d'étapes VWB pour identifier
|
||||
* et corriger les problèmes d'intégration.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Alert,
|
||||
Divider,
|
||||
Chip,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemIcon,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
PlayArrow as PlayIcon,
|
||||
CheckCircle as CheckIcon,
|
||||
Error as ErrorIcon,
|
||||
Info as InfoIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
// Import des composants à tester
|
||||
import PropertiesPanel from './PropertiesPanel';
|
||||
import { useVWBStepIntegration, useIsVWBStep, useVWBActionId } from '../hooks/useVWBStepIntegration';
|
||||
import { catalogService } from '../services/catalogService';
|
||||
|
||||
// Import des types
|
||||
import { Step, StepExecutionState, Variable, VariableType } from '../types';
|
||||
import { VWBCatalogAction } from '../types/catalog';
|
||||
|
||||
interface TestResult {
|
||||
test: string;
|
||||
success: boolean;
|
||||
message: string;
|
||||
details?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composant de test pour les propriétés VWB
|
||||
*/
|
||||
const TestPropertiesPanel: React.FC = () => {
|
||||
const [testResults, setTestResults] = useState<TestResult[]>([]);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [testStep, setTestStep] = useState<Step | null>(null);
|
||||
const [catalogActions, setCatalogActions] = useState<VWBCatalogAction[]>([]);
|
||||
const [selectedAction, setSelectedAction] = useState<string>('click_anchor');
|
||||
|
||||
// Hooks VWB
|
||||
const { methods: vwbMethods } = useVWBStepIntegration();
|
||||
const isVWBStep = useIsVWBStep(testStep);
|
||||
const vwbActionId = useVWBActionId(testStep);
|
||||
|
||||
// Variables de test
|
||||
const testVariables: Variable[] = [
|
||||
{
|
||||
id: 'var1',
|
||||
name: 'username',
|
||||
value: 'test@example.com',
|
||||
type: 'text' as VariableType,
|
||||
description: 'Nom d\'utilisateur de test',
|
||||
},
|
||||
{
|
||||
id: 'var2',
|
||||
name: 'password',
|
||||
value: '********',
|
||||
type: 'text' as VariableType,
|
||||
description: 'Mot de passe de test',
|
||||
},
|
||||
];
|
||||
|
||||
// Charger les actions du catalogue au démarrage
|
||||
useEffect(() => {
|
||||
const loadCatalogActions = async () => {
|
||||
try {
|
||||
const { actions } = await catalogService.getActions();
|
||||
setCatalogActions(actions);
|
||||
console.log('Actions du catalogue chargées:', actions.length);
|
||||
} catch (error) {
|
||||
console.error('Erreur chargement catalogue:', error);
|
||||
}
|
||||
};
|
||||
|
||||
loadCatalogActions();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Créer une étape VWB de test
|
||||
*/
|
||||
const createTestVWBStep = async (actionId: string): Promise<Step | null> => {
|
||||
try {
|
||||
// Utiliser le hook d'intégration pour créer l'étape
|
||||
const step = await vwbMethods.createVWBStep(actionId, { x: 100, y: 100 });
|
||||
|
||||
if (step) {
|
||||
console.log('Étape VWB créée:', step);
|
||||
return step;
|
||||
} else {
|
||||
throw new Error('Impossible de créer l\'étape VWB');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur création étape VWB:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Exécuter tous les tests
|
||||
*/
|
||||
const runAllTests = async () => {
|
||||
setIsRunning(true);
|
||||
setTestResults([]);
|
||||
|
||||
const results: TestResult[] = [];
|
||||
|
||||
try {
|
||||
// Test 1: Chargement du catalogue
|
||||
results.push({
|
||||
test: 'Chargement du catalogue',
|
||||
success: catalogActions.length > 0,
|
||||
message: catalogActions.length > 0
|
||||
? `${catalogActions.length} actions chargées`
|
||||
: 'Aucune action chargée',
|
||||
details: { actionCount: catalogActions.length }
|
||||
});
|
||||
|
||||
// Test 2: Création d'étape VWB
|
||||
const testStep = await createTestVWBStep(selectedAction);
|
||||
const stepCreated = testStep !== null;
|
||||
|
||||
results.push({
|
||||
test: 'Création d\'étape VWB',
|
||||
success: stepCreated,
|
||||
message: stepCreated
|
||||
? `Étape ${selectedAction} créée avec succès`
|
||||
: `Échec création étape ${selectedAction}`,
|
||||
details: testStep
|
||||
});
|
||||
|
||||
if (stepCreated && testStep) {
|
||||
setTestStep(testStep);
|
||||
|
||||
// Test 3: Détection VWB
|
||||
const isDetectedAsVWB = testStep.data.isVWBCatalogAction === true;
|
||||
results.push({
|
||||
test: 'Détection étape VWB',
|
||||
success: isDetectedAsVWB,
|
||||
message: isDetectedAsVWB
|
||||
? 'Étape correctement détectée comme VWB'
|
||||
: 'Étape non détectée comme VWB',
|
||||
details: {
|
||||
isVWBCatalogAction: testStep.data.isVWBCatalogAction,
|
||||
vwbActionId: testStep.data.vwbActionId
|
||||
}
|
||||
});
|
||||
|
||||
// Test 4: Hook de détection
|
||||
const hookDetection = useIsVWBStep(testStep);
|
||||
results.push({
|
||||
test: 'Hook de détection VWB',
|
||||
success: hookDetection,
|
||||
message: hookDetection
|
||||
? 'Hook useIsVWBStep fonctionne'
|
||||
: 'Hook useIsVWBStep défaillant',
|
||||
details: { hookResult: hookDetection }
|
||||
});
|
||||
|
||||
// Test 5: Récupération ID action
|
||||
const actionIdFromHook = useVWBActionId(testStep);
|
||||
results.push({
|
||||
test: 'Récupération ID action VWB',
|
||||
success: actionIdFromHook === selectedAction,
|
||||
message: actionIdFromHook === selectedAction
|
||||
? `ID action correct: ${actionIdFromHook}`
|
||||
: `ID action incorrect: ${actionIdFromHook} (attendu: ${selectedAction})`,
|
||||
details: {
|
||||
expected: selectedAction,
|
||||
actual: actionIdFromHook
|
||||
}
|
||||
});
|
||||
|
||||
// Test 6: Chargement détails action
|
||||
try {
|
||||
const actionDetails = await catalogService.getActionDetails(selectedAction);
|
||||
const actionLoaded = actionDetails !== null;
|
||||
|
||||
results.push({
|
||||
test: 'Chargement détails action',
|
||||
success: actionLoaded,
|
||||
message: actionLoaded
|
||||
? `Détails action ${selectedAction} chargés`
|
||||
: `Échec chargement détails ${selectedAction}`,
|
||||
details: actionDetails
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
test: 'Chargement détails action',
|
||||
success: false,
|
||||
message: `Erreur: ${error instanceof Error ? error.message : 'Erreur inconnue'}`,
|
||||
details: { error }
|
||||
});
|
||||
}
|
||||
|
||||
// Test 7: Validation étape
|
||||
try {
|
||||
const isValid = await vwbMethods.validateVWBStep(testStep);
|
||||
results.push({
|
||||
test: 'Validation étape VWB',
|
||||
success: true, // Le test réussit si la validation s'exécute
|
||||
message: isValid
|
||||
? 'Étape valide'
|
||||
: 'Étape invalide (normal pour étape vide)',
|
||||
details: { isValid }
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
test: 'Validation étape VWB',
|
||||
success: false,
|
||||
message: `Erreur validation: ${error instanceof Error ? error.message : 'Erreur inconnue'}`,
|
||||
details: { error }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
results.push({
|
||||
test: 'Erreur générale',
|
||||
success: false,
|
||||
message: `Erreur: ${error instanceof Error ? error.message : 'Erreur inconnue'}`,
|
||||
details: { error }
|
||||
});
|
||||
}
|
||||
|
||||
setTestResults(results);
|
||||
setIsRunning(false);
|
||||
};
|
||||
|
||||
/**
|
||||
* Gestionnaire de changement de paramètre (pour le test)
|
||||
*/
|
||||
const handleParameterChange = (stepId: string, paramName: string, value: any) => {
|
||||
console.log('Changement paramètre:', { stepId, paramName, value });
|
||||
|
||||
if (testStep && testStep.id === stepId) {
|
||||
const updatedStep = {
|
||||
...testStep,
|
||||
data: {
|
||||
...testStep.data,
|
||||
parameters: {
|
||||
...testStep.data.parameters,
|
||||
[paramName]: value
|
||||
}
|
||||
}
|
||||
};
|
||||
setTestStep(updatedStep);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Gestionnaire de sélection visuelle (pour le test)
|
||||
*/
|
||||
const handleVisualSelection = (stepId: string) => {
|
||||
console.log('Sélection visuelle pour étape:', stepId);
|
||||
};
|
||||
|
||||
// Calculer le statut global des tests
|
||||
const allTestsPassed = testResults.length > 0 && testResults.every(r => r.success);
|
||||
const hasFailures = testResults.some(r => !r.success);
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3, maxWidth: 1200, margin: '0 auto' }}>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Test des Propriétés d'Étapes VWB
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
Ce composant teste l'affichage des propriétés d'étapes VWB pour identifier
|
||||
et corriger les problèmes d'intégration.
|
||||
</Typography>
|
||||
|
||||
{/* Contrôles de test */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Configuration du Test
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="body2">Action à tester:</Typography>
|
||||
{catalogActions.map(action => (
|
||||
<Chip
|
||||
key={action.id}
|
||||
label={action.name}
|
||||
variant={selectedAction === action.id ? 'filled' : 'outlined'}
|
||||
onClick={() => setSelectedAction(action.id)}
|
||||
size="small"
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<PlayIcon />}
|
||||
onClick={runAllTests}
|
||||
disabled={isRunning || catalogActions.length === 0}
|
||||
size="large"
|
||||
>
|
||||
{isRunning ? 'Tests en cours...' : 'Exécuter les Tests'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Résultats des tests */}
|
||||
{testResults.length > 0 && (
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Résultats des Tests
|
||||
</Typography>
|
||||
|
||||
{/* Statut global */}
|
||||
<Alert
|
||||
severity={allTestsPassed ? 'success' : hasFailures ? 'error' : 'info'}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
{allTestsPassed
|
||||
? '🎉 Tous les tests sont réussis ! Les propriétés VWB devraient s\'afficher correctement.'
|
||||
: hasFailures
|
||||
? '❌ Certains tests ont échoué. Les propriétés VWB pourraient ne pas s\'afficher.'
|
||||
: 'ℹ️ Tests en cours d\'exécution...'}
|
||||
</Alert>
|
||||
|
||||
{/* Liste des résultats */}
|
||||
<List>
|
||||
{testResults.map((result, index) => (
|
||||
<ListItem key={index}>
|
||||
<ListItemIcon>
|
||||
{result.success ? (
|
||||
<CheckIcon color="success" />
|
||||
) : (
|
||||
<ErrorIcon color="error" />
|
||||
)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={result.test}
|
||||
secondary={result.message}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Aperçu du Properties Panel */}
|
||||
{testStep && (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Aperçu du Properties Panel
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Voici comment le Properties Panel devrait afficher les propriétés de l'étape VWB :
|
||||
</Typography>
|
||||
|
||||
{/* Informations sur l'étape */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Informations sur l'étape :
|
||||
</Typography>
|
||||
<Chip label={`Type: ${testStep.type}`} size="small" sx={{ mr: 1 }} />
|
||||
<Chip label={`VWB: ${isVWBStep ? 'Oui' : 'Non'}`} size="small" sx={{ mr: 1 }} />
|
||||
<Chip label={`Action ID: ${vwbActionId || 'N/A'}`} size="small" />
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
{/* Properties Panel en action */}
|
||||
<Box sx={{ border: '1px solid #e0e0e0', borderRadius: 1 }}>
|
||||
<PropertiesPanel
|
||||
selectedStep={testStep}
|
||||
variables={testVariables}
|
||||
onParameterChange={handleParameterChange}
|
||||
onVisualSelection={handleVisualSelection}
|
||||
/>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Instructions */}
|
||||
<Card sx={{ mt: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Instructions
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" paragraph>
|
||||
1. <strong>Exécuter les tests</strong> : Cliquez sur "Exécuter les Tests" pour vérifier l'intégration VWB
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" paragraph>
|
||||
2. <strong>Vérifier les résultats</strong> : Tous les tests doivent être verts pour un fonctionnement correct
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" paragraph>
|
||||
3. <strong>Tester l'interface</strong> : L'aperçu du Properties Panel montre comment les propriétés s'affichent
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" paragraph>
|
||||
4. <strong>Déboguer si nécessaire</strong> : Si des tests échouent, vérifiez les détails dans la console
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default TestPropertiesPanel;
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Composant Test Intégration VWB - Validation de l'affichage des propriétés
|
||||
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Alert,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemIcon,
|
||||
Divider,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
CheckCircle as CheckIcon,
|
||||
Error as ErrorIcon,
|
||||
PlayArrow as PlayIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
// Import des composants à tester
|
||||
import PropertiesPanel from './PropertiesPanel';
|
||||
import { useVWBStepIntegration, useIsVWBStep, useVWBActionId } from '../hooks/useVWBStepIntegration';
|
||||
import { catalogService } from '../services/catalogService';
|
||||
|
||||
// Import des types
|
||||
import { Step, StepExecutionState, Variable, VariableType } from '../types';
|
||||
|
||||
const VWBIntegrationTest: React.FC = () => {
|
||||
const [testStep, setTestStep] = useState<Step | null>(null);
|
||||
const [testResults, setTestResults] = useState<Array<{test: string, success: boolean, message: string}>>([]);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
|
||||
// Hooks VWB
|
||||
const { methods: vwbMethods } = useVWBStepIntegration();
|
||||
const isVWBStep = useIsVWBStep(testStep);
|
||||
const vwbActionId = useVWBActionId(testStep);
|
||||
|
||||
// Variables de test
|
||||
const testVariables: Variable[] = [
|
||||
{
|
||||
id: 'var1',
|
||||
name: 'test_var',
|
||||
value: 'test_value',
|
||||
type: 'text' as VariableType,
|
||||
description: 'Variable de test',
|
||||
},
|
||||
];
|
||||
|
||||
const runIntegrationTest = async () => {
|
||||
setIsRunning(true);
|
||||
setTestResults([]);
|
||||
|
||||
const results: Array<{test: string, success: boolean, message: string}> = [];
|
||||
|
||||
try {
|
||||
// Test 1: Chargement du catalogue
|
||||
const { actions } = await catalogService.getActions();
|
||||
results.push({
|
||||
test: 'Chargement catalogue',
|
||||
success: actions.length > 0,
|
||||
message: `${actions.length} actions chargées`
|
||||
});
|
||||
|
||||
// Test 2: Création d'étape VWB
|
||||
if (actions.length > 0) {
|
||||
const firstAction = actions[0];
|
||||
const step = await vwbMethods.createVWBStep(firstAction.id, { x: 100, y: 100 });
|
||||
|
||||
if (step) {
|
||||
setTestStep(step);
|
||||
results.push({
|
||||
test: 'Création étape VWB',
|
||||
success: true,
|
||||
message: `Étape ${firstAction.id} créée`
|
||||
});
|
||||
|
||||
// Test 3: Détection VWB
|
||||
const isDetected = step.data.isVWBCatalogAction === true;
|
||||
results.push({
|
||||
test: 'Détection VWB',
|
||||
success: isDetected,
|
||||
message: isDetected ? 'Étape détectée comme VWB' : 'Étape non détectée'
|
||||
});
|
||||
|
||||
// Test 4: Hook de détection
|
||||
const hookResult = useIsVWBStep(step);
|
||||
results.push({
|
||||
test: 'Hook détection',
|
||||
success: hookResult,
|
||||
message: hookResult ? 'Hook fonctionne' : 'Hook défaillant'
|
||||
});
|
||||
|
||||
} else {
|
||||
results.push({
|
||||
test: 'Création étape VWB',
|
||||
success: false,
|
||||
message: 'Échec création étape'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
results.push({
|
||||
test: 'Erreur générale',
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
});
|
||||
}
|
||||
|
||||
setTestResults(results);
|
||||
setIsRunning(false);
|
||||
};
|
||||
|
||||
const handleParameterChange = (stepId: string, paramName: string, value: any) => {
|
||||
console.log('Changement paramètre:', { stepId, paramName, value });
|
||||
};
|
||||
|
||||
const handleVisualSelection = (stepId: string) => {
|
||||
console.log('Sélection visuelle:', stepId);
|
||||
};
|
||||
|
||||
const allTestsPassed = testResults.length > 0 && testResults.every(r => r.success);
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3, maxWidth: 1200, margin: '0 auto' }}>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Test d'Intégration VWB
|
||||
</Typography>
|
||||
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<PlayIcon />}
|
||||
onClick={runIntegrationTest}
|
||||
disabled={isRunning}
|
||||
size="large"
|
||||
>
|
||||
{isRunning ? 'Tests en cours...' : 'Exécuter les Tests'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{testResults.length > 0 && (
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Résultats des Tests
|
||||
</Typography>
|
||||
|
||||
<Alert severity={allTestsPassed ? 'success' : 'error'} sx={{ mb: 2 }}>
|
||||
{allTestsPassed
|
||||
? 'Tous les tests réussis ! L\'intégration VWB fonctionne.'
|
||||
: 'Certains tests ont échoué. Vérifiez l\'intégration.'}
|
||||
</Alert>
|
||||
|
||||
<List>
|
||||
{testResults.map((result, index) => (
|
||||
<ListItem key={index}>
|
||||
<ListItemIcon>
|
||||
{result.success ? (
|
||||
<CheckIcon color="success" />
|
||||
) : (
|
||||
<ErrorIcon color="error" />
|
||||
)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={result.test}
|
||||
secondary={result.message}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{testStep && (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Properties Panel Test
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Étape VWB: {testStep.type} (ID: {testStep.id})
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Détection VWB: {isVWBStep ? 'Oui' : 'Non'}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Action ID: {vwbActionId || 'N/A'}
|
||||
</Typography>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
<Box sx={{ border: '1px solid #e0e0e0', borderRadius: 1 }}>
|
||||
<PropertiesPanel
|
||||
selectedStep={testStep}
|
||||
variables={testVariables}
|
||||
onParameterChange={handleParameterChange}
|
||||
onVisualSelection={handleVisualSelection}
|
||||
/>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default VWBIntegrationTest;
|
||||
@@ -0,0 +1,522 @@
|
||||
/**
|
||||
* Composant Validateur - Validation et feedback visuel pour les workflows
|
||||
* Auteur : Dom, Alice, Kiro - 08 janvier 2026
|
||||
*
|
||||
* Ce composant fournit la validation en temps réel des workflows avec
|
||||
* indicateurs visuels d'erreur, détection de cycles et prévention d'exécution.
|
||||
*/
|
||||
|
||||
import React, { useMemo, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Alert,
|
||||
AlertTitle,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Chip,
|
||||
Typography,
|
||||
Collapse,
|
||||
IconButton,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Error as ErrorIcon,
|
||||
Warning as WarningIcon,
|
||||
Info as InfoIcon,
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
ExpandLess as ExpandLessIcon,
|
||||
Block as BlockIcon,
|
||||
Link as LinkIcon,
|
||||
Loop as LoopIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
// Import des types partagés
|
||||
import {
|
||||
Workflow,
|
||||
Step,
|
||||
WorkflowConnection,
|
||||
ValidationError,
|
||||
Variable,
|
||||
} from '../../types';
|
||||
|
||||
interface ValidatorProps {
|
||||
workflow: Workflow;
|
||||
variables: Variable[];
|
||||
onStepHighlight?: (stepId: string, highlight: boolean) => void;
|
||||
}
|
||||
|
||||
interface ValidationResult {
|
||||
isValid: boolean;
|
||||
errors: ValidationIssue[];
|
||||
warnings: ValidationIssue[];
|
||||
canExecute: boolean;
|
||||
}
|
||||
|
||||
interface ValidationIssue {
|
||||
id: string;
|
||||
type: 'error' | 'warning' | 'info';
|
||||
category: 'missing_parameter' | 'disconnected_step' | 'cycle_detected' | 'invalid_reference' | 'execution_blocked';
|
||||
stepId?: string;
|
||||
message: string;
|
||||
description?: string;
|
||||
severity: 'critical' | 'high' | 'medium' | 'low';
|
||||
}
|
||||
|
||||
// Fonctions utilitaires (déclarées avant leur utilisation)
|
||||
|
||||
// Obtenir les paramètres requis pour un type d'étape
|
||||
const getRequiredParameters = (stepType: string): string[] => {
|
||||
const parameterMap: Record<string, string[]> = {
|
||||
click: ['target'],
|
||||
type: ['target', 'text'],
|
||||
wait: ['duration'],
|
||||
condition: ['condition'],
|
||||
extract: ['target', 'attribute'],
|
||||
navigate: ['url'],
|
||||
scroll: ['direction'],
|
||||
screenshot: [],
|
||||
};
|
||||
|
||||
return parameterMap[stepType] || [];
|
||||
};
|
||||
|
||||
// Extraire les références de variables d'un texte
|
||||
const extractVariableReferences = (text: string): string[] => {
|
||||
const pattern = /\$\{([^}]+)\}/g;
|
||||
const matches: string[] = [];
|
||||
let match;
|
||||
while ((match = pattern.exec(text)) !== null) {
|
||||
matches.push(match[1]);
|
||||
}
|
||||
return matches;
|
||||
};
|
||||
|
||||
// Validation des paramètres d'une étape
|
||||
const validateStepParameters = (step: Step, variables: Variable[]): ValidationIssue[] => {
|
||||
const issues: ValidationIssue[] = [];
|
||||
|
||||
// Récupérer les paramètres requis selon le type d'étape
|
||||
const requiredParams = getRequiredParameters(step.type);
|
||||
|
||||
requiredParams.forEach(paramName => {
|
||||
const paramValue = step.data.parameters?.[paramName];
|
||||
|
||||
if (paramValue === undefined || paramValue === null || paramValue === '') {
|
||||
issues.push({
|
||||
id: `missing_param_${step.id}_${paramName}`,
|
||||
type: 'error',
|
||||
category: 'missing_parameter',
|
||||
stepId: step.id,
|
||||
message: `Paramètre "${paramName}" manquant`,
|
||||
description: `L'étape "${step.name}" nécessite le paramètre "${paramName}"`,
|
||||
severity: 'high',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return issues;
|
||||
};
|
||||
|
||||
// Trouver les étapes déconnectées
|
||||
const findDisconnectedSteps = (steps: Step[], connections: WorkflowConnection[]): string[] => {
|
||||
if (steps.length <= 1) return [];
|
||||
|
||||
const connectedSteps = new Set<string>();
|
||||
|
||||
// Ajouter toutes les étapes connectées
|
||||
connections.forEach(conn => {
|
||||
connectedSteps.add(conn.source);
|
||||
connectedSteps.add(conn.target);
|
||||
});
|
||||
|
||||
// Si aucune connexion, toutes les étapes sauf la première sont déconnectées
|
||||
if (connections.length === 0 && steps.length > 1) {
|
||||
return steps.slice(1).map(step => step.id);
|
||||
}
|
||||
|
||||
// Trouver les étapes non connectées
|
||||
return steps
|
||||
.filter(step => !connectedSteps.has(step.id))
|
||||
.map(step => step.id);
|
||||
};
|
||||
|
||||
// Détecter les cycles dans le workflow
|
||||
const detectCycles = (steps: Step[], connections: WorkflowConnection[]): string[][] => {
|
||||
const cycles: string[][] = [];
|
||||
const visited = new Set<string>();
|
||||
const recursionStack = new Set<string>();
|
||||
|
||||
// Construire le graphe d'adjacence
|
||||
const graph: Record<string, string[]> = {};
|
||||
steps.forEach(step => {
|
||||
graph[step.id] = [];
|
||||
});
|
||||
|
||||
connections.forEach(conn => {
|
||||
if (graph[conn.source]) {
|
||||
graph[conn.source].push(conn.target);
|
||||
}
|
||||
});
|
||||
|
||||
// DFS pour détecter les cycles
|
||||
const dfs = (nodeId: string, path: string[]): void => {
|
||||
if (recursionStack.has(nodeId)) {
|
||||
// Cycle détecté
|
||||
const cycleStart = path.indexOf(nodeId);
|
||||
if (cycleStart >= 0) {
|
||||
cycles.push([...path.slice(cycleStart), nodeId]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (visited.has(nodeId)) return;
|
||||
|
||||
visited.add(nodeId);
|
||||
recursionStack.add(nodeId);
|
||||
path.push(nodeId);
|
||||
|
||||
const neighbors = graph[nodeId] || [];
|
||||
neighbors.forEach(neighbor => {
|
||||
dfs(neighbor, [...path]);
|
||||
});
|
||||
|
||||
recursionStack.delete(nodeId);
|
||||
};
|
||||
|
||||
// Lancer DFS depuis chaque nœud non visité
|
||||
steps.forEach(step => {
|
||||
if (!visited.has(step.id)) {
|
||||
dfs(step.id, []);
|
||||
}
|
||||
});
|
||||
|
||||
return cycles;
|
||||
};
|
||||
|
||||
// Valider les références de variables
|
||||
const validateVariableReferences = (step: Step, variables: Variable[]): ValidationIssue[] => {
|
||||
const issues: ValidationIssue[] = [];
|
||||
const variableNames = new Set(variables.map(v => v.name));
|
||||
|
||||
// Extraire les références de variables des paramètres
|
||||
const parameters = step.data.parameters || {};
|
||||
|
||||
Object.entries(parameters).forEach(([paramName, paramValue]) => {
|
||||
if (typeof paramValue === 'string') {
|
||||
const variableRefs = extractVariableReferences(paramValue);
|
||||
|
||||
variableRefs.forEach(varName => {
|
||||
if (!variableNames.has(varName)) {
|
||||
issues.push({
|
||||
id: `invalid_ref_${step.id}_${paramName}_${varName}`,
|
||||
type: 'error',
|
||||
category: 'invalid_reference',
|
||||
stepId: step.id,
|
||||
message: `Variable "${varName}" non définie`,
|
||||
description: `La variable "${varName}" utilisée dans "${paramName}" n'existe pas`,
|
||||
severity: 'high',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return issues;
|
||||
};
|
||||
|
||||
/**
|
||||
* Composant Validateur
|
||||
*/
|
||||
const Validator: React.FC<ValidatorProps> = ({
|
||||
workflow,
|
||||
variables,
|
||||
onStepHighlight,
|
||||
}) => {
|
||||
const [expandedSections, setExpandedSections] = React.useState<Set<string>>(new Set(['errors']));
|
||||
|
||||
// Gestionnaire d'événements clavier pour le validateur
|
||||
const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
|
||||
switch (event.key) {
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
// Activer l'élément focalisé
|
||||
if (event.target instanceof HTMLElement && event.target.getAttribute('role') === 'button') {
|
||||
event.preventDefault();
|
||||
event.target.click();
|
||||
}
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
case 'ArrowDown':
|
||||
// Navigation dans la liste des erreurs
|
||||
event.preventDefault();
|
||||
break;
|
||||
case 'Escape':
|
||||
// Fermer toutes les sections étendues
|
||||
event.preventDefault();
|
||||
setExpandedSections(new Set());
|
||||
break;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Validation complète du workflow
|
||||
const validationResult = useMemo((): ValidationResult => {
|
||||
const errors: ValidationIssue[] = [];
|
||||
const warnings: ValidationIssue[] = [];
|
||||
|
||||
// 1. Validation des paramètres manquants
|
||||
workflow.steps.forEach(step => {
|
||||
const stepErrors = validateStepParameters(step, variables);
|
||||
errors.push(...stepErrors);
|
||||
});
|
||||
|
||||
// 2. Détection des étapes déconnectées
|
||||
const disconnectedSteps = findDisconnectedSteps(workflow.steps, workflow.connections);
|
||||
disconnectedSteps.forEach(stepId => {
|
||||
warnings.push({
|
||||
id: `disconnected_${stepId}`,
|
||||
type: 'warning',
|
||||
category: 'disconnected_step',
|
||||
stepId,
|
||||
message: 'Étape déconnectée',
|
||||
description: 'Cette étape n\'est pas connectée au flux principal du workflow',
|
||||
severity: 'medium',
|
||||
});
|
||||
});
|
||||
|
||||
// 3. Détection de cycles
|
||||
const cycles = detectCycles(workflow.steps, workflow.connections);
|
||||
cycles.forEach((cycle, index) => {
|
||||
errors.push({
|
||||
id: `cycle_${index}`,
|
||||
type: 'error',
|
||||
category: 'cycle_detected',
|
||||
message: 'Cycle détecté dans le workflow',
|
||||
description: `Cycle impliquant les étapes: ${cycle.join(' → ')}`,
|
||||
severity: 'critical',
|
||||
});
|
||||
});
|
||||
|
||||
// 4. Validation des références de variables
|
||||
workflow.steps.forEach(step => {
|
||||
const referenceErrors = validateVariableReferences(step, variables);
|
||||
errors.push(...referenceErrors);
|
||||
});
|
||||
|
||||
// 5. Vérification de la possibilité d'exécution
|
||||
const canExecute = errors.filter(e => e.severity === 'critical').length === 0;
|
||||
if (!canExecute) {
|
||||
errors.push({
|
||||
id: 'execution_blocked',
|
||||
type: 'error',
|
||||
category: 'execution_blocked',
|
||||
message: 'Exécution bloquée',
|
||||
description: 'Des erreurs critiques empêchent l\'exécution du workflow',
|
||||
severity: 'critical',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0 && warnings.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
canExecute,
|
||||
};
|
||||
}, [workflow, variables]);
|
||||
|
||||
// Gestionnaire de basculement de section
|
||||
const toggleSection = (section: string) => {
|
||||
setExpandedSections(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(section)) {
|
||||
newSet.delete(section);
|
||||
} else {
|
||||
newSet.add(section);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
// Gestionnaire de survol d'étape
|
||||
const handleStepHover = (stepId: string | undefined, highlight: boolean) => {
|
||||
if (stepId && onStepHighlight) {
|
||||
onStepHighlight(stepId, highlight);
|
||||
}
|
||||
};
|
||||
|
||||
// Rendu d'une issue de validation
|
||||
const renderValidationIssue = (issue: ValidationIssue) => {
|
||||
const getIcon = () => {
|
||||
switch (issue.category) {
|
||||
case 'missing_parameter':
|
||||
return <ErrorIcon color="error" />;
|
||||
case 'disconnected_step':
|
||||
return <LinkIcon color="warning" />;
|
||||
case 'cycle_detected':
|
||||
return <LoopIcon color="error" />;
|
||||
case 'invalid_reference':
|
||||
return <ErrorIcon color="error" />;
|
||||
case 'execution_blocked':
|
||||
return <BlockIcon color="error" />;
|
||||
default:
|
||||
return <InfoIcon />;
|
||||
}
|
||||
};
|
||||
|
||||
const getSeverityColor = () => {
|
||||
switch (issue.severity) {
|
||||
case 'critical':
|
||||
return 'error';
|
||||
case 'high':
|
||||
return 'error';
|
||||
case 'medium':
|
||||
return 'warning';
|
||||
case 'low':
|
||||
return 'info';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={issue.id}
|
||||
onMouseEnter={() => handleStepHover(issue.stepId, true)}
|
||||
onMouseLeave={() => handleStepHover(issue.stepId, false)}
|
||||
sx={{
|
||||
border: '1px solid',
|
||||
borderColor: issue.type === 'error' ? 'error.light' : 'warning.light',
|
||||
borderRadius: 1,
|
||||
mb: 1,
|
||||
backgroundColor: issue.type === 'error' ? 'error.light' : 'warning.light',
|
||||
'&:hover': {
|
||||
backgroundColor: issue.type === 'error' ? 'error.main' : 'warning.main',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
{getIcon()}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="subtitle2">{issue.message}</Typography>
|
||||
<Chip
|
||||
label={issue.severity}
|
||||
size="small"
|
||||
color={getSeverityColor() as any}
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
secondary={issue.description}
|
||||
/>
|
||||
</ListItem>
|
||||
);
|
||||
};
|
||||
|
||||
if (validationResult.isValid) {
|
||||
return (
|
||||
<Alert severity="success" sx={{ mb: 2 }}>
|
||||
<AlertTitle>Workflow valide</AlertTitle>
|
||||
Aucun problème détecté. Le workflow est prêt à être exécuté.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
role="region"
|
||||
aria-label="Validation du workflow"
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={0}
|
||||
>
|
||||
{/* Résumé de validation */}
|
||||
<Alert
|
||||
severity={validationResult.canExecute ? 'warning' : 'error'}
|
||||
sx={{ mb: 2 }}
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
<AlertTitle>
|
||||
{validationResult.canExecute ? 'Avertissements détectés' : 'Erreurs critiques détectées'}
|
||||
</AlertTitle>
|
||||
{validationResult.errors.length > 0 && (
|
||||
<Typography variant="body2">
|
||||
{validationResult.errors.length} erreur(s) trouvée(s)
|
||||
</Typography>
|
||||
)}
|
||||
{validationResult.warnings.length > 0 && (
|
||||
<Typography variant="body2">
|
||||
{validationResult.warnings.length} avertissement(s) trouvé(s)
|
||||
</Typography>
|
||||
)}
|
||||
{!validationResult.canExecute && (
|
||||
<Typography variant="body2" fontWeight="bold">
|
||||
L'exécution est bloquée jusqu'à la résolution des erreurs critiques.
|
||||
</Typography>
|
||||
)}
|
||||
</Alert>
|
||||
|
||||
{/* Section des erreurs */}
|
||||
{validationResult.errors.length > 0 && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
mb: 1,
|
||||
}}
|
||||
onClick={() => toggleSection('errors')}
|
||||
role="button"
|
||||
aria-expanded={expandedSections.has('errors')}
|
||||
aria-controls="errors-list"
|
||||
tabIndex={0}
|
||||
>
|
||||
<IconButton size="small" aria-hidden="true">
|
||||
{expandedSections.has('errors') ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
||||
</IconButton>
|
||||
<Typography variant="h6" color="error">
|
||||
Erreurs ({validationResult.errors.length})
|
||||
</Typography>
|
||||
</Box>
|
||||
<Collapse in={expandedSections.has('errors')}>
|
||||
<List id="errors-list" role="list" aria-label="Liste des erreurs de validation">
|
||||
{validationResult.errors.map(renderValidationIssue)}
|
||||
</List>
|
||||
</Collapse>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Section des avertissements */}
|
||||
{validationResult.warnings.length > 0 && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
mb: 1,
|
||||
}}
|
||||
onClick={() => toggleSection('warnings')}
|
||||
>
|
||||
<IconButton size="small">
|
||||
{expandedSections.has('warnings') ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
||||
</IconButton>
|
||||
<Typography variant="h6" color="warning.main">
|
||||
Avertissements ({validationResult.warnings.length})
|
||||
</Typography>
|
||||
</Box>
|
||||
<Collapse in={expandedSections.has('warnings')}>
|
||||
<List>
|
||||
{validationResult.warnings.map(renderValidationIssue)}
|
||||
</List>
|
||||
</Collapse>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Validator;
|
||||
@@ -0,0 +1,545 @@
|
||||
/**
|
||||
* Composant Gestionnaire de Variables - Gestion CRUD des variables de workflow
|
||||
* Auteur : Dom, Alice, Kiro - 08 janvier 2026
|
||||
*
|
||||
* Ce composant permet de créer, modifier et supprimer des variables,
|
||||
* avec validation d'unicité des noms et support de différents types.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useMemo, memo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemSecondaryAction,
|
||||
IconButton,
|
||||
Alert,
|
||||
Chip,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Add as AddIcon,
|
||||
Edit as EditIcon,
|
||||
Delete as DeleteIcon,
|
||||
Close as CloseIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
// Import des types partagés
|
||||
import {
|
||||
VariableManagerProps,
|
||||
Variable,
|
||||
VariableType,
|
||||
VariableTypeEnum,
|
||||
} from '../../types';
|
||||
|
||||
// Import du hook de debouncing et du client API
|
||||
import { useDebounce } from '../../hooks/useDebounce';
|
||||
import { useApiClient } from '../../hooks/useApiClient';
|
||||
import { apiClient } from '../../services/apiClient';
|
||||
|
||||
// Labels français pour les types
|
||||
const typeLabels: Record<VariableType, string> = {
|
||||
text: 'Texte',
|
||||
number: 'Nombre',
|
||||
boolean: 'Booléen',
|
||||
list: 'Liste',
|
||||
};
|
||||
|
||||
/**
|
||||
* Composant Gestionnaire de Variables
|
||||
*/
|
||||
const VariableManager: React.FC<VariableManagerProps> = ({
|
||||
variables,
|
||||
onVariableCreate,
|
||||
onVariableUpdate,
|
||||
onVariableDelete,
|
||||
}) => {
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [editingVariable, setEditingVariable] = useState<Variable | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
type: 'text' as VariableType,
|
||||
defaultValue: '',
|
||||
description: '',
|
||||
});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// Utilisation du client API pour la validation des variables
|
||||
const apiValidation = useApiClient({
|
||||
onError: (error) => {
|
||||
console.error('Erreur de validation des variables:', error);
|
||||
setErrors(prev => ({ ...prev, api: error.message }));
|
||||
},
|
||||
});
|
||||
|
||||
// Debouncing de la recherche pour optimiser les performances
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, 300);
|
||||
|
||||
// Mémoriser les variables triées et filtrées pour éviter les re-calculs
|
||||
const filteredAndSortedVariables = useMemo(() => {
|
||||
let filtered = variables;
|
||||
|
||||
// Filtrer selon la recherche débouncée
|
||||
if (debouncedSearchQuery.trim()) {
|
||||
filtered = variables.filter(variable =>
|
||||
variable.name.toLowerCase().includes(debouncedSearchQuery.toLowerCase()) ||
|
||||
(variable.description && variable.description.toLowerCase().includes(debouncedSearchQuery.toLowerCase()))
|
||||
);
|
||||
}
|
||||
|
||||
// Trier par nom
|
||||
return filtered.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [variables, debouncedSearchQuery]);
|
||||
|
||||
// Mémoriser les statistiques des variables
|
||||
const variableStats = useMemo(() => {
|
||||
const stats = {
|
||||
total: variables.length,
|
||||
byType: {} as Record<VariableType, number>,
|
||||
};
|
||||
|
||||
variables.forEach(variable => {
|
||||
stats.byType[variable.type] = (stats.byType[variable.type] || 0) + 1;
|
||||
});
|
||||
|
||||
return stats;
|
||||
}, [variables]);
|
||||
|
||||
// Gestionnaire d'événements clavier pour le gestionnaire de variables
|
||||
const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
|
||||
switch (event.key) {
|
||||
case 'Enter':
|
||||
// Activer l'élément focalisé
|
||||
if (event.target instanceof HTMLButtonElement) {
|
||||
event.preventDefault();
|
||||
event.target.click();
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
// Fermer les dialogues ouverts
|
||||
event.preventDefault();
|
||||
if (isDialogOpen) {
|
||||
handleCloseDialog();
|
||||
}
|
||||
break;
|
||||
case 'n':
|
||||
// Raccourci pour nouvelle variable (Ctrl+N)
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
event.preventDefault();
|
||||
handleCreateNew();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}, [isDialogOpen]);
|
||||
|
||||
// Validation des variables avec l'API
|
||||
const validateVariableWithApi = useCallback(async (variableData: Partial<Variable>) => {
|
||||
try {
|
||||
// Simuler une validation côté client qui pourrait utiliser l'API
|
||||
const validationErrors: Record<string, string> = {};
|
||||
|
||||
// Validation du nom
|
||||
if (!variableData.name || variableData.name.trim().length === 0) {
|
||||
validationErrors.name = 'Le nom de la variable est obligatoire';
|
||||
} else if (variableData.name.length > 50) {
|
||||
validationErrors.name = 'Le nom ne peut pas dépasser 50 caractères';
|
||||
}
|
||||
|
||||
// Validation de l'unicité du nom
|
||||
const existingVariable = variables.find(v =>
|
||||
v.name === variableData.name &&
|
||||
(!editingVariable || v.id !== editingVariable.id)
|
||||
);
|
||||
if (existingVariable) {
|
||||
validationErrors.name = 'Une variable avec ce nom existe déjà';
|
||||
}
|
||||
|
||||
// Validation du type
|
||||
if (!variableData.type) {
|
||||
validationErrors.type = 'Le type de variable est obligatoire';
|
||||
}
|
||||
|
||||
// Validation de la valeur par défaut selon le type
|
||||
if (variableData.defaultValue && variableData.type) {
|
||||
switch (variableData.type) {
|
||||
case 'number':
|
||||
if (isNaN(Number(variableData.defaultValue))) {
|
||||
validationErrors.defaultValue = 'La valeur par défaut doit être un nombre';
|
||||
}
|
||||
break;
|
||||
case 'boolean':
|
||||
if (!['true', 'false', '1', '0'].includes(variableData.defaultValue.toLowerCase())) {
|
||||
validationErrors.defaultValue = 'La valeur par défaut doit être true/false ou 1/0';
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
setErrors(validationErrors);
|
||||
return Object.keys(validationErrors).length === 0;
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la validation:', error);
|
||||
setErrors({ api: 'Erreur de validation' });
|
||||
return false;
|
||||
}
|
||||
}, [variables, editingVariable]);
|
||||
|
||||
// Ouvrir le dialogue pour créer une nouvelle variable
|
||||
const handleCreateNew = () => {
|
||||
setEditingVariable(null);
|
||||
setFormData({
|
||||
name: '',
|
||||
type: VariableTypeEnum.TEXT,
|
||||
defaultValue: '',
|
||||
description: '',
|
||||
});
|
||||
setErrors({});
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
// Ouvrir le dialogue pour modifier une variable existante
|
||||
const handleEdit = (variable: Variable) => {
|
||||
setEditingVariable(variable);
|
||||
setFormData({
|
||||
name: variable.name,
|
||||
type: variable.type,
|
||||
defaultValue: variable.defaultValue || '',
|
||||
description: variable.description || '',
|
||||
});
|
||||
setErrors({});
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
// Fermer le dialogue
|
||||
const handleCloseDialog = () => {
|
||||
setIsDialogOpen(false);
|
||||
setEditingVariable(null);
|
||||
setFormData({
|
||||
name: '',
|
||||
type: 'text' as VariableType,
|
||||
defaultValue: '',
|
||||
description: '',
|
||||
});
|
||||
setErrors({});
|
||||
};
|
||||
|
||||
// Valider le formulaire
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
// Validation du nom
|
||||
if (!formData.name.trim()) {
|
||||
newErrors.name = 'Le nom de la variable est obligatoire';
|
||||
} else if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(formData.name)) {
|
||||
newErrors.name = 'Le nom doit commencer par une lettre ou _ et contenir uniquement des lettres, chiffres et _';
|
||||
} else {
|
||||
// Vérifier l'unicité du nom
|
||||
const existingVariable = variables.find(
|
||||
v => v.name === formData.name && v.id !== editingVariable?.id
|
||||
);
|
||||
if (existingVariable) {
|
||||
newErrors.name = 'Une variable avec ce nom existe déjà';
|
||||
}
|
||||
}
|
||||
|
||||
// Validation de la valeur par défaut selon le type
|
||||
if (formData.defaultValue) {
|
||||
switch (formData.type) {
|
||||
case 'number':
|
||||
if (isNaN(Number(formData.defaultValue))) {
|
||||
newErrors.defaultValue = 'La valeur par défaut doit être un nombre';
|
||||
}
|
||||
break;
|
||||
case 'boolean':
|
||||
if (!['true', 'false'].includes(formData.defaultValue.toLowerCase())) {
|
||||
newErrors.defaultValue = 'La valeur par défaut doit être "true" ou "false"';
|
||||
}
|
||||
break;
|
||||
case 'list':
|
||||
try {
|
||||
JSON.parse(formData.defaultValue);
|
||||
} catch {
|
||||
newErrors.defaultValue = 'La valeur par défaut doit être un JSON valide pour une liste';
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
// Sauvegarder la variable
|
||||
const handleSave = () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
let processedDefaultValue: any = formData.defaultValue;
|
||||
|
||||
// Traiter la valeur par défaut selon le type
|
||||
if (processedDefaultValue) {
|
||||
switch (formData.type) {
|
||||
case 'number':
|
||||
processedDefaultValue = Number(processedDefaultValue);
|
||||
break;
|
||||
case 'boolean':
|
||||
processedDefaultValue = formData.defaultValue.toLowerCase() === 'true';
|
||||
break;
|
||||
case 'list':
|
||||
try {
|
||||
processedDefaultValue = JSON.parse(formData.defaultValue);
|
||||
} catch {
|
||||
processedDefaultValue = [];
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const variableData = {
|
||||
name: formData.name,
|
||||
type: formData.type,
|
||||
defaultValue: processedDefaultValue || undefined,
|
||||
description: formData.description || undefined,
|
||||
};
|
||||
|
||||
if (editingVariable) {
|
||||
onVariableUpdate(editingVariable.id, variableData);
|
||||
} else {
|
||||
onVariableCreate(variableData);
|
||||
}
|
||||
|
||||
handleCloseDialog();
|
||||
};
|
||||
|
||||
// Supprimer une variable
|
||||
const handleDelete = (variable: Variable) => {
|
||||
if (window.confirm(`Êtes-vous sûr de vouloir supprimer la variable "${variable.name}" ?`)) {
|
||||
onVariableDelete(variable.id);
|
||||
}
|
||||
};
|
||||
|
||||
// Formater la valeur par défaut pour l'affichage
|
||||
const formatDefaultValue = (variable: Variable): string => {
|
||||
if (variable.defaultValue === undefined || variable.defaultValue === null) {
|
||||
return 'Non définie';
|
||||
}
|
||||
|
||||
switch (variable.type) {
|
||||
case 'boolean':
|
||||
return variable.defaultValue ? 'Vrai' : 'Faux';
|
||||
case 'list':
|
||||
return Array.isArray(variable.defaultValue)
|
||||
? `[${variable.defaultValue.length} éléments]`
|
||||
: JSON.stringify(variable.defaultValue);
|
||||
default:
|
||||
return String(variable.defaultValue);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
role="region"
|
||||
aria-label="Gestionnaire de variables du workflow"
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={0}
|
||||
>
|
||||
{/* En-tête avec bouton d'ajout */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6">Variables du workflow</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={handleCreateNew}
|
||||
size="small"
|
||||
aria-label="Créer une nouvelle variable"
|
||||
>
|
||||
Nouvelle variable
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Liste des variables */}
|
||||
{variables.length === 0 ? (
|
||||
<Alert severity="info">
|
||||
Aucune variable définie. Créez des variables pour rendre votre workflow plus flexible.
|
||||
</Alert>
|
||||
) : (
|
||||
<List role="list" aria-label="Liste des variables définies">
|
||||
{variables.map((variable) => (
|
||||
<ListItem
|
||||
key={variable.id}
|
||||
divider
|
||||
sx={{
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 1,
|
||||
mb: 1,
|
||||
backgroundColor: '#fafafa',
|
||||
}}
|
||||
role="listitem"
|
||||
aria-label={`Variable ${variable.name} de type ${typeLabels[variable.type]}`}
|
||||
>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="subtitle2">{variable.name}</Typography>
|
||||
<Chip
|
||||
label={typeLabels[variable.type]}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
secondary={
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Valeur par défaut: {formatDefaultValue(variable)}
|
||||
</Typography>
|
||||
{variable.description && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{variable.description}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<IconButton
|
||||
edge="end"
|
||||
onClick={() => handleEdit(variable)}
|
||||
size="small"
|
||||
sx={{ mr: 1 }}
|
||||
aria-label={`Modifier la variable ${variable.name}`}
|
||||
>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
edge="end"
|
||||
onClick={() => handleDelete(variable)}
|
||||
size="small"
|
||||
color="error"
|
||||
aria-label={`Supprimer la variable ${variable.name}`}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
|
||||
{/* Dialogue de création/modification */}
|
||||
<Dialog
|
||||
open={isDialogOpen}
|
||||
onClose={handleCloseDialog}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
{editingVariable ? 'Modifier la variable' : 'Nouvelle variable'}
|
||||
<IconButton onClick={handleCloseDialog} size="small">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 1 }}>
|
||||
{/* Nom de la variable */}
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Nom de la variable"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
error={!!errors.name}
|
||||
helperText={errors.name || 'Utilisez uniquement des lettres, chiffres et _'}
|
||||
required
|
||||
/>
|
||||
|
||||
{/* Type de la variable */}
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Type de variable</InputLabel>
|
||||
<Select
|
||||
value={formData.type}
|
||||
onChange={(e) => setFormData({ ...formData, type: e.target.value as VariableType })}
|
||||
label="Type de variable"
|
||||
>
|
||||
{Object.entries(typeLabels).map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* Valeur par défaut */}
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Valeur par défaut (optionnelle)"
|
||||
value={formData.defaultValue}
|
||||
onChange={(e) => setFormData({ ...formData, defaultValue: e.target.value })}
|
||||
error={!!errors.defaultValue}
|
||||
helperText={errors.defaultValue || getDefaultValueHelp(formData.type)}
|
||||
multiline={formData.type === 'list'}
|
||||
rows={formData.type === 'list' ? 3 : 1}
|
||||
/>
|
||||
|
||||
{/* Description */}
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Description (optionnelle)"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
multiline
|
||||
rows={2}
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button onClick={handleCloseDialog}>Annuler</Button>
|
||||
<Button onClick={handleSave} variant="contained">
|
||||
{editingVariable ? 'Modifier' : 'Créer'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// Aide contextuelle pour la valeur par défaut
|
||||
const getDefaultValueHelp = (type: VariableType): string => {
|
||||
switch (type) {
|
||||
case 'text':
|
||||
return 'Texte libre';
|
||||
case 'number':
|
||||
return 'Nombre décimal ou entier';
|
||||
case 'boolean':
|
||||
return 'true ou false';
|
||||
case 'list':
|
||||
return 'JSON valide, ex: ["item1", "item2"]';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// Mémorisation du composant VariableManager pour éviter les re-rendus inutiles
|
||||
export default memo(VariableManager, (prevProps, nextProps) => {
|
||||
return (
|
||||
JSON.stringify(prevProps.variables) === JSON.stringify(nextProps.variables) &&
|
||||
prevProps.onVariableCreate === nextProps.onVariableCreate &&
|
||||
prevProps.onVariableUpdate === nextProps.onVariableUpdate &&
|
||||
prevProps.onVariableDelete === nextProps.onVariableDelete
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* Styles pour le Panneau des Propriétés Visuelles
|
||||
*
|
||||
* Suit les guidelines du design system RPA Vision V3:
|
||||
* - Couleurs Material-UI avec thème sombre
|
||||
* - Espacement cohérent (xs: 4px, sm: 8px, md: 12px, lg: 16px, xl: 20px)
|
||||
* - Composants Material-UI avec CSS modules personnalisés
|
||||
*/
|
||||
|
||||
.visual-properties-panel {
|
||||
background: #1e293b; /* Card Background du design system */
|
||||
border-left: 1px solid #334155; /* Border Color */
|
||||
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
background: linear-gradient(135deg, #1976d2 0%, #1565c0 100%); /* Primary Blue */
|
||||
color: #e2e8f0; /* Text Primary */
|
||||
border-bottom: 1px solid #334155;
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
background: #0f172a; /* Dark Background */
|
||||
padding: 20px; /* Component spacing */
|
||||
}
|
||||
|
||||
/* Conteneur de statut de validation */
|
||||
.validation-status-container {
|
||||
background: rgba(30, 41, 59, 0.8) !important; /* Card Background avec transparence */
|
||||
border: 1px solid #334155;
|
||||
border-radius: 12px;
|
||||
backdrop-filter: blur(10px);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.validation-status-container:hover {
|
||||
border-color: #1976d2; /* Primary Blue au survol */
|
||||
box-shadow: 0 4px 12px rgba(25, 118, 210, 0.2);
|
||||
}
|
||||
|
||||
/* Conteneur de capture d'écran */
|
||||
.screenshot-container {
|
||||
background: #1e293b !important;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.screenshot-container:hover {
|
||||
border-color: #1976d2;
|
||||
box-shadow: 0 8px 24px rgba(25, 118, 210, 0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.screenshot-image {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 300px;
|
||||
object-fit: contain;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.screenshot-image:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
/* Placeholder pour capture d'écran */
|
||||
.screenshot-placeholder {
|
||||
background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%) !important;
|
||||
border: 2px dashed #334155;
|
||||
border-radius: 12px;
|
||||
min-height: 200px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.screenshot-placeholder:hover {
|
||||
border-color: #1976d2;
|
||||
background: linear-gradient(135deg, #1e293b 0%, #1976d2 5%, #0f172a 100%) !important;
|
||||
}
|
||||
|
||||
.select-element-button {
|
||||
background: linear-gradient(135deg, #1976d2 0%, #1565c0 100%) !important;
|
||||
color: white !important;
|
||||
padding: 12px 24px !important; /* Input padding étendu */
|
||||
border-radius: 8px !important;
|
||||
font-weight: 600 !important;
|
||||
text-transform: none !important;
|
||||
box-shadow: 0 4px 12px rgba(25, 118, 210, 0.3) !important;
|
||||
transition: all 0.3s ease !important;
|
||||
}
|
||||
|
||||
.select-element-button:hover {
|
||||
background: linear-gradient(135deg, #1565c0 0%, #0d47a1 100%) !important;
|
||||
box-shadow: 0 6px 16px rgba(25, 118, 210, 0.4) !important;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Paramètres visuels */
|
||||
.visual-parameters {
|
||||
background: #1e293b !important;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.visual-parameters .MuiCardContent-root {
|
||||
padding: 20px !important;
|
||||
}
|
||||
|
||||
/* Animations pour les indicateurs de statut */
|
||||
@keyframes pulse-success {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.7);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 10px rgba(34, 197, 94, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-warning {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.7);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 10px rgba(245, 158, 11, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-error {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 10px rgba(239, 68, 68, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Indicateurs de statut avec animations */
|
||||
.status-indicator-success {
|
||||
animation: pulse-success 2s infinite;
|
||||
}
|
||||
|
||||
.status-indicator-warning {
|
||||
animation: pulse-warning 2s infinite;
|
||||
}
|
||||
|
||||
.status-indicator-error {
|
||||
animation: pulse-error 2s infinite;
|
||||
}
|
||||
|
||||
/* Styles pour les alertes de validation */
|
||||
.MuiAlert-root {
|
||||
border-radius: 8px !important;
|
||||
margin-bottom: 8px !important; /* sm spacing */
|
||||
}
|
||||
|
||||
.MuiAlert-standardError {
|
||||
background: rgba(239, 68, 68, 0.1) !important;
|
||||
border: 1px solid rgba(239, 68, 68, 0.3) !important;
|
||||
color: #fecaca !important;
|
||||
}
|
||||
|
||||
.MuiAlert-standardInfo {
|
||||
background: rgba(25, 118, 210, 0.1) !important;
|
||||
border: 1px solid rgba(25, 118, 210, 0.3) !important;
|
||||
color: #bfdbfe !important;
|
||||
}
|
||||
|
||||
.MuiAlert-standardWarning {
|
||||
background: rgba(245, 158, 11, 0.1) !important;
|
||||
border: 1px solid rgba(245, 158, 11, 0.3) !important;
|
||||
color: #fde68a !important;
|
||||
}
|
||||
|
||||
/* Styles pour les chips de confiance */
|
||||
.MuiChip-root {
|
||||
font-weight: 600 !important;
|
||||
border-radius: 6px !important;
|
||||
}
|
||||
|
||||
.MuiChip-colorSuccess {
|
||||
background: rgba(34, 197, 94, 0.2) !important;
|
||||
color: #22c55e !important;
|
||||
border-color: #22c55e !important;
|
||||
}
|
||||
|
||||
.MuiChip-colorWarning {
|
||||
background: rgba(245, 158, 11, 0.2) !important;
|
||||
color: #f59e0b !important;
|
||||
border-color: #f59e0b !important;
|
||||
}
|
||||
|
||||
.MuiChip-colorError {
|
||||
background: rgba(239, 68, 68, 0.2) !important;
|
||||
color: #ef4444 !important;
|
||||
border-color: #ef4444 !important;
|
||||
}
|
||||
|
||||
/* Barre de progression de confiance */
|
||||
.MuiLinearProgress-root {
|
||||
height: 4px !important;
|
||||
border-radius: 2px !important;
|
||||
background: rgba(51, 65, 85, 0.3) !important;
|
||||
}
|
||||
|
||||
.MuiLinearProgress-barColorSuccess {
|
||||
background: linear-gradient(90deg, #22c55e 0%, #16a34a 100%) !important;
|
||||
}
|
||||
|
||||
.MuiLinearProgress-barColorWarning {
|
||||
background: linear-gradient(90deg, #f59e0b 0%, #d97706 100%) !important;
|
||||
}
|
||||
|
||||
.MuiLinearProgress-barColorError {
|
||||
background: linear-gradient(90deg, #ef4444 0%, #dc2626 100%) !important;
|
||||
}
|
||||
|
||||
/* Boutons d'action */
|
||||
.MuiIconButton-root {
|
||||
color: #94a3b8 !important; /* Text Secondary */
|
||||
transition: all 0.3s ease !important;
|
||||
}
|
||||
|
||||
.MuiIconButton-root:hover {
|
||||
color: #1976d2 !important; /* Primary Blue */
|
||||
background: rgba(25, 118, 210, 0.1) !important;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Tooltips */
|
||||
.MuiTooltip-tooltip {
|
||||
background: #1e293b !important;
|
||||
color: #e2e8f0 !important;
|
||||
border: 1px solid #334155 !important;
|
||||
border-radius: 6px !important;
|
||||
font-size: 12px !important;
|
||||
padding: 8px 12px !important;
|
||||
}
|
||||
|
||||
.MuiTooltip-arrow {
|
||||
color: #1e293b !important;
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.visual-properties-panel {
|
||||
width: 100% !important;
|
||||
height: auto !important;
|
||||
border-left: none;
|
||||
border-top: 1px solid #334155;
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
padding: 16px; /* lg spacing pour mobile */
|
||||
}
|
||||
|
||||
.screenshot-image {
|
||||
max-height: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Accessibilité */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.screenshot-container,
|
||||
.screenshot-image,
|
||||
.select-element-button,
|
||||
.MuiIconButton-root {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.status-indicator-success,
|
||||
.status-indicator-warning,
|
||||
.status-indicator-error {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Focus visible pour l'accessibilité */
|
||||
.select-element-button:focus-visible,
|
||||
.MuiIconButton-root:focus-visible {
|
||||
outline: 2px solid #1976d2 !important;
|
||||
outline-offset: 2px !important;
|
||||
}
|
||||
|
||||
/* Thème sombre spécifique */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.visual-properties-panel {
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
background: #020617;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
/**
|
||||
* Composant Panneau de Propriétés Visuelles - Configuration des paramètres d'étapes
|
||||
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
|
||||
*
|
||||
* Panneau des propriétés entièrement visuel pour la configuration des actions RPA.
|
||||
* Supprime complètement les sélecteurs CSS/XPath et utilise uniquement des méthodes visuelles.
|
||||
*
|
||||
* Fonctionnalités:
|
||||
* - Affichage des captures d'écran haute qualité
|
||||
* - Sélection visuelle interactive
|
||||
* - Validation en temps réel
|
||||
* - Métadonnées visuelles enrichies
|
||||
* - Interface sans éléments techniques
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Tooltip,
|
||||
IconButton,
|
||||
Divider,
|
||||
LinearProgress,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
CheckCircle as ValidIcon,
|
||||
Warning as WarningIcon,
|
||||
Error as ErrorIcon,
|
||||
Refresh as RefreshIcon,
|
||||
ZoomIn as ZoomIcon,
|
||||
Info as InfoIcon,
|
||||
Settings as SettingsIcon,
|
||||
PhotoCamera as CameraIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
import { BoundingBox } from '../../types';
|
||||
import InteractivePreviewArea from '../InteractivePreviewArea';
|
||||
import './VisualPropertiesPanel.css';
|
||||
|
||||
interface VisualTarget {
|
||||
screenshot: string;
|
||||
bounding_box: BoundingBox;
|
||||
metadata: {
|
||||
element_type: string;
|
||||
relative_position?: string;
|
||||
text_content?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface VisualNode {
|
||||
id: string;
|
||||
type: string;
|
||||
visualTarget?: VisualTarget;
|
||||
parameters?: Record<string, any>;
|
||||
}
|
||||
|
||||
interface VisualPropertiesPanelProps {
|
||||
node: VisualNode;
|
||||
onNodeUpdate: (nodeId: string, updates: Partial<VisualNode>) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface ValidationStatus {
|
||||
isValid: boolean;
|
||||
confidence: number;
|
||||
lastChecked: Date;
|
||||
issues: string[];
|
||||
suggestions: string[];
|
||||
}
|
||||
|
||||
const VisualPropertiesPanel: React.FC<VisualPropertiesPanelProps> = ({
|
||||
node,
|
||||
onNodeUpdate,
|
||||
onClose,
|
||||
}) => {
|
||||
const [selectorOpen, setSelectorOpen] = useState(false);
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const [validationStatus, setValidationStatus] = useState<ValidationStatus | null>(null);
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
// Validation automatique au chargement
|
||||
useEffect(() => {
|
||||
if (node.visualTarget) {
|
||||
validateTarget();
|
||||
}
|
||||
}, [node.visualTarget]);
|
||||
|
||||
/**
|
||||
* Valide la cible visuelle actuelle
|
||||
*/
|
||||
const validateTarget = useCallback(async () => {
|
||||
if (!node.visualTarget) return;
|
||||
|
||||
setIsValidating(true);
|
||||
|
||||
try {
|
||||
// Simuler la validation (à remplacer par l'API réelle)
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
|
||||
// Simuler un résultat de validation
|
||||
const mockValidation: ValidationStatus = {
|
||||
isValid: Math.random() > 0.3, // 70% de chance d'être valide
|
||||
confidence: 0.85 + Math.random() * 0.15, // Entre 0.85 et 1.0
|
||||
lastChecked: new Date(),
|
||||
issues: Math.random() > 0.7 ? ['Élément légèrement déplacé'] : [],
|
||||
suggestions: Math.random() > 0.5 ? ['Mettre à jour la capture'] : []
|
||||
};
|
||||
|
||||
setValidationStatus(mockValidation);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la validation:', error);
|
||||
setValidationStatus({
|
||||
isValid: false,
|
||||
confidence: 0,
|
||||
lastChecked: new Date(),
|
||||
issues: ['Erreur de validation'],
|
||||
suggestions: ['Vérifier la connexion']
|
||||
});
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
}, [node.visualTarget]);
|
||||
|
||||
/**
|
||||
* Gère la sélection d'un nouvel élément
|
||||
*/
|
||||
const handleElementSelected = useCallback((target: VisualTarget) => {
|
||||
onNodeUpdate(node.id, {
|
||||
visualTarget: target
|
||||
});
|
||||
setSelectorOpen(false);
|
||||
|
||||
// Valider immédiatement le nouvel élément
|
||||
setTimeout(validateTarget, 500);
|
||||
}, [node.id, onNodeUpdate, validateTarget]);
|
||||
|
||||
/**
|
||||
* Ouvre le sélecteur visuel
|
||||
*/
|
||||
const openVisualSelector = useCallback(() => {
|
||||
// Simuler l'ouverture du sélecteur visuel
|
||||
console.log('Ouverture du sélecteur visuel');
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Ouvre l'aperçu interactif
|
||||
*/
|
||||
const openPreview = useCallback(() => {
|
||||
if (node.visualTarget?.screenshot) {
|
||||
setPreviewOpen(true);
|
||||
}
|
||||
}, [node.visualTarget]);
|
||||
|
||||
/**
|
||||
* Met à jour les paramètres du nœud
|
||||
*/
|
||||
const updateNodeParameter = useCallback((key: string, value: any) => {
|
||||
onNodeUpdate(node.id, {
|
||||
parameters: {
|
||||
...node.parameters,
|
||||
[key]: value
|
||||
}
|
||||
});
|
||||
}, [node.id, node.parameters, onNodeUpdate]);
|
||||
|
||||
/**
|
||||
* Obtient l'icône de statut de validation
|
||||
*/
|
||||
const getValidationIcon = () => {
|
||||
if (isValidating) {
|
||||
return <CircularProgress size={20} />;
|
||||
}
|
||||
|
||||
if (!validationStatus) {
|
||||
return <InfoIcon color="disabled" />;
|
||||
}
|
||||
|
||||
if (validationStatus.isValid) {
|
||||
return <ValidIcon color="success" />;
|
||||
} else if (validationStatus.issues.length > 0) {
|
||||
return <WarningIcon color="warning" />;
|
||||
} else {
|
||||
return <ErrorIcon color="error" />;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Obtient le message de statut de validation
|
||||
*/
|
||||
const getValidationMessage = () => {
|
||||
if (isValidating) {
|
||||
return "Validation en cours...";
|
||||
}
|
||||
|
||||
if (!validationStatus) {
|
||||
return "Aucune validation effectuée";
|
||||
}
|
||||
|
||||
if (validationStatus.isValid) {
|
||||
return `Élément valide (confiance: ${Math.round(validationStatus.confidence * 100)}%)`;
|
||||
} else {
|
||||
return "Élément non trouvé ou modifié";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Obtient la couleur du statut de validation
|
||||
*/
|
||||
const getValidationColor = (): 'success' | 'warning' | 'error' | 'info' => {
|
||||
if (!validationStatus || isValidating) return 'info';
|
||||
|
||||
if (validationStatus.isValid) {
|
||||
return validationStatus.confidence > 0.9 ? 'success' : 'warning';
|
||||
} else {
|
||||
return 'error';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="visual-properties-panel">
|
||||
{/* En-tête du panneau */}
|
||||
<Box className="panel-header">
|
||||
<Typography variant="h6" className="panel-title">
|
||||
Propriétés Visuelles
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
{node.type || 'Action'} - Configuration 100% visuelle
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Zone de capture principale */}
|
||||
<Card className="capture-section" elevation={2}>
|
||||
<CardContent>
|
||||
<Box className="capture-header">
|
||||
<Typography variant="subtitle1" className="section-title">
|
||||
Élément Cible
|
||||
</Typography>
|
||||
<Box className="capture-actions">
|
||||
<Tooltip title="Nouvelle sélection">
|
||||
<IconButton onClick={openVisualSelector} color="primary">
|
||||
<CameraIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{node.visualTarget?.screenshot && (
|
||||
<Tooltip title="Aperçu agrandi">
|
||||
<IconButton onClick={openPreview} color="primary">
|
||||
<ZoomIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{node.visualTarget ? (
|
||||
<Box className="capture-display">
|
||||
{/* Image de l'élément */}
|
||||
<Box className="screenshot-container">
|
||||
<img
|
||||
src={`data:image/png;base64,${node.visualTarget.screenshot}`}
|
||||
alt="Élément sélectionné"
|
||||
className="screenshot-image"
|
||||
onClick={openPreview}
|
||||
/>
|
||||
<Box className="screenshot-overlay">
|
||||
<Chip
|
||||
label={node.visualTarget.metadata.element_type}
|
||||
size="small"
|
||||
color="primary"
|
||||
className="element-type-chip"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Métadonnées visuelles */}
|
||||
<Box className="metadata-display">
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
Type: {node.visualTarget.metadata.element_type}
|
||||
</Typography>
|
||||
{node.visualTarget.metadata.text_content && (
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
Texte: "{node.visualTarget.metadata.text_content}"
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Box className="no-capture-state">
|
||||
<Typography variant="body2" color="textSecondary" align="center">
|
||||
Aucun élément sélectionné
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<CameraIcon />}
|
||||
onClick={openVisualSelector}
|
||||
className="select-button"
|
||||
>
|
||||
Sélectionner un Élément
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Statut de validation */}
|
||||
{node.visualTarget && (
|
||||
<Card className="validation-section" elevation={1}>
|
||||
<CardContent>
|
||||
<Box className="validation-header">
|
||||
<Box className="validation-status">
|
||||
{getValidationIcon()}
|
||||
<Typography variant="body2" className="validation-text">
|
||||
{getValidationMessage()}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Tooltip title="Revalider">
|
||||
<IconButton
|
||||
onClick={validateTarget}
|
||||
disabled={isValidating}
|
||||
size="small"
|
||||
>
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{validationStatus && (
|
||||
<Box className="validation-details">
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={validationStatus.confidence * 100}
|
||||
color={getValidationColor()}
|
||||
className="confidence-bar"
|
||||
/>
|
||||
|
||||
{validationStatus.issues.length > 0 && (
|
||||
<Alert severity="warning" className="validation-alert">
|
||||
<Typography variant="body2">
|
||||
Problèmes détectés: {validationStatus.issues.join(', ')}
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{validationStatus.suggestions.length > 0 && (
|
||||
<Box className="suggestions">
|
||||
<Typography variant="caption" color="textSecondary">
|
||||
Suggestions: {validationStatus.suggestions.join(', ')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Paramètres de l'action */}
|
||||
<Card className="parameters-section" elevation={1}>
|
||||
<CardContent>
|
||||
<Typography variant="subtitle1" className="section-title">
|
||||
Paramètres de l'Action
|
||||
</Typography>
|
||||
|
||||
{/* Paramètres spécifiques au type d'action */}
|
||||
{node.type === 'click' && (
|
||||
<Box className="parameter-group">
|
||||
<Typography variant="body2" className="parameter-label">
|
||||
Type de clic
|
||||
</Typography>
|
||||
<Box className="parameter-options">
|
||||
{['Simple', 'Double', 'Droit'].map((clickType) => (
|
||||
<Chip
|
||||
key={clickType}
|
||||
label={clickType}
|
||||
variant={node.parameters?.clickType === clickType ? 'filled' : 'outlined'}
|
||||
onClick={() => updateNodeParameter('clickType', clickType)}
|
||||
className="option-chip"
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{node.type === 'input' && (
|
||||
<Box className="parameter-group">
|
||||
<Typography variant="body2" className="parameter-label">
|
||||
Texte à saisir
|
||||
</Typography>
|
||||
<Box className="text-input-container">
|
||||
<input
|
||||
type="text"
|
||||
value={node.parameters?.text || ''}
|
||||
onChange={(e) => updateNodeParameter('text', e.target.value)}
|
||||
placeholder="Entrez le texte à saisir..."
|
||||
className="text-input"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Délai d'attente */}
|
||||
<Box className="parameter-group">
|
||||
<Typography variant="body2" className="parameter-label">
|
||||
Délai d'attente
|
||||
</Typography>
|
||||
<Box className="delay-controls">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="10"
|
||||
step="0.5"
|
||||
value={node.parameters?.delay || 1}
|
||||
onChange={(e) => updateNodeParameter('delay', parseFloat(e.target.value))}
|
||||
className="delay-slider"
|
||||
/>
|
||||
<Typography variant="caption" className="delay-value">
|
||||
{node.parameters?.delay || 1}s
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Options avancées */}
|
||||
<Card className="advanced-section" elevation={1}>
|
||||
<CardContent>
|
||||
<Box
|
||||
className="advanced-header"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
>
|
||||
<Typography variant="subtitle1" className="section-title">
|
||||
Options Avancées
|
||||
</Typography>
|
||||
<IconButton size="small">
|
||||
<SettingsIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{showAdvanced && (
|
||||
<Box className="advanced-options">
|
||||
<Box className="option-row">
|
||||
<Typography variant="body2">Tolérance de position</Typography>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={node.parameters?.positionTolerance || 10}
|
||||
onChange={(e) => updateNodeParameter('positionTolerance', parseInt(e.target.value))}
|
||||
className="tolerance-slider"
|
||||
/>
|
||||
<Typography variant="caption">
|
||||
{node.parameters?.positionTolerance || 10}px
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box className="option-row">
|
||||
<Typography variant="body2">Seuil de confiance</Typography>
|
||||
<input
|
||||
type="range"
|
||||
min="0.5"
|
||||
max="1.0"
|
||||
step="0.05"
|
||||
value={node.parameters?.confidenceThreshold || 0.8}
|
||||
onChange={(e) => updateNodeParameter('confidenceThreshold', parseFloat(e.target.value))}
|
||||
className="confidence-slider"
|
||||
/>
|
||||
<Typography variant="caption">
|
||||
{Math.round((node.parameters?.confidenceThreshold || 0.8) * 100)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Actions du panneau */}
|
||||
<Box className="panel-actions">
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={onClose}
|
||||
className="cancel-button"
|
||||
>
|
||||
Fermer
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={!node.visualTarget}
|
||||
className="save-button"
|
||||
>
|
||||
Appliquer
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Dialogs */}
|
||||
{node.visualTarget && (
|
||||
<InteractivePreviewArea
|
||||
open={previewOpen}
|
||||
onClose={() => setPreviewOpen(false)}
|
||||
screenshot={node.visualTarget.screenshot}
|
||||
boundingBox={node.visualTarget.bounding_box}
|
||||
metadata={node.visualTarget.metadata}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default VisualPropertiesPanel;
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* Visual Screen Selector Styles - RPA Vision V3
|
||||
*
|
||||
* Styles pour le sélecteur d'écran visuel interactif.
|
||||
* Optimisé pour une expérience de sélection fluide et intuitive.
|
||||
*/
|
||||
|
||||
.visual-screen-selector {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
z-index: 9999;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.visual-screen-selector__header {
|
||||
background: #1976d2;
|
||||
color: white;
|
||||
padding: 16px 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.visual-screen-selector__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.visual-screen-selector__controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.visual-screen-selector__content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.visual-screen-selector__canvas-area {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.visual-screen-selector__canvas {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
cursor: crosshair;
|
||||
transition: cursor 0.2s ease;
|
||||
}
|
||||
|
||||
.visual-screen-selector__canvas--pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.visual-screen-selector__sidebar {
|
||||
width: 320px;
|
||||
background: white;
|
||||
border-left: 1px solid #e0e0e0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.visual-screen-selector__sidebar-header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.visual-screen-selector__sidebar-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.visual-screen-selector__element-card {
|
||||
margin-bottom: 12px;
|
||||
padding: 12px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.visual-screen-selector__element-card:hover {
|
||||
border-color: #2196f3;
|
||||
box-shadow: 0 2px 8px rgba(33, 150, 243, 0.2);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.visual-screen-selector__element-card--hovered {
|
||||
border-color: #2196f3;
|
||||
background: #e3f2fd;
|
||||
box-shadow: 0 4px 12px rgba(33, 150, 243, 0.3);
|
||||
}
|
||||
|
||||
.visual-screen-selector__element-card--selected {
|
||||
border-color: #4caf50;
|
||||
background: #e8f5e8;
|
||||
box-shadow: 0 4px 12px rgba(76, 175, 80, 0.3);
|
||||
}
|
||||
|
||||
.visual-screen-selector__element-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.visual-screen-selector__element-text {
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.visual-screen-selector__element-details {
|
||||
font-size: 0.75rem;
|
||||
color: #666;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.visual-screen-selector__status-bar {
|
||||
padding: 16px 24px;
|
||||
background: white;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.visual-screen-selector__instructions {
|
||||
background: #e3f2fd;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
color: #1565c0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.visual-screen-selector__loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.visual-screen-selector__loading-spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 4px solid rgba(255, 255, 255, 0.3);
|
||||
border-top: 4px solid white;
|
||||
border-radius: 50%;
|
||||
animation: visual-screen-selector-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes visual-screen-selector-spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.visual-screen-selector__error {
|
||||
padding: 16px 24px;
|
||||
background: #ffebee;
|
||||
border-left: 4px solid #f44336;
|
||||
color: #c62828;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.visual-screen-selector__success {
|
||||
padding: 16px 24px;
|
||||
background: #e8f5e8;
|
||||
border-left: 4px solid #4caf50;
|
||||
color: #2e7d32;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Animations pour les overlays */
|
||||
.visual-screen-selector__overlay {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
border: 2px solid #ff9800;
|
||||
background: rgba(255, 152, 0, 0.1);
|
||||
transition: all 0.1s ease;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.visual-screen-selector__overlay--hovered {
|
||||
border-color: #2196f3;
|
||||
background: rgba(33, 150, 243, 0.2);
|
||||
border-width: 3px;
|
||||
}
|
||||
|
||||
.visual-screen-selector__overlay--selected {
|
||||
border-color: #4caf50;
|
||||
background: rgba(76, 175, 80, 0.2);
|
||||
border-width: 4px;
|
||||
animation: visual-screen-selector-pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes visual-screen-selector-pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(76, 175, 80, 0.7); }
|
||||
70% { box-shadow: 0 0 0 10px rgba(76, 175, 80, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(76, 175, 80, 0); }
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.visual-screen-selector__sidebar {
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.visual-screen-selector__header {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.visual-screen-selector__title {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.visual-screen-selector__sidebar-content {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.visual-screen-selector__element-card {
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.visual-screen-selector__content {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.visual-screen-selector__sidebar {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
border-left: none;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.visual-screen-selector__canvas-area {
|
||||
height: calc(100vh - 200px - 120px); /* Ajuster selon header + sidebar */
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,7 @@ import { captureLibraryService, SavedCapture } from '../../services/captureLibra
|
||||
// Import des types partagés et du service de capture
|
||||
import { VisualSelection, BoundingBox } from '../../types';
|
||||
import { screenCaptureService } from '../../services/screenCaptureService';
|
||||
import { uploadAnchorImage } from '../../services/anchorImageService';
|
||||
|
||||
interface VisualSelectorProps {
|
||||
isOpen: boolean;
|
||||
@@ -116,6 +117,7 @@ const VisualSelector: React.FC<VisualSelectorProps> = ({
|
||||
const [isCanvasReady, setIsCanvasReady] = useState(false); // Canvas prêt après chargement de l'image
|
||||
const [canvasSize, setCanvasSize] = useState({ width: 1200, height: 800 }); // Taille du canvas pour le style CSS
|
||||
const [imageScale, setImageScale] = useState(1); // Scale appliqué à l'image (pour convertir les coordonnées)
|
||||
const [originalImageSize, setOriginalImageSize] = useState({ width: 0, height: 0 }); // Taille de l'image originale pour le backend
|
||||
|
||||
// États pour la sélection de moniteur
|
||||
const [monitors, setMonitors] = useState<Monitor[]>([]);
|
||||
@@ -135,7 +137,7 @@ const VisualSelector: React.FC<VisualSelectorProps> = ({
|
||||
useEffect(() => {
|
||||
const loadMonitors = async () => {
|
||||
try {
|
||||
const response = await fetch('http://localhost:5002/api/real-demo/capture/status');
|
||||
const response = await fetch('http://localhost:5001/api/real-demo/capture/status');
|
||||
const data = await response.json();
|
||||
if (data.success && data.monitors) {
|
||||
setMonitors(data.monitors);
|
||||
@@ -233,9 +235,11 @@ const VisualSelector: React.FC<VisualSelectorProps> = ({
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
console.log('✅ [VisualSelector] Image dessinée sur le canvas:', canvas.width, 'x', canvas.height, 'scale:', scale);
|
||||
console.log('📐 [VisualSelector] Taille image originale:', img.width, 'x', img.height);
|
||||
|
||||
// IMPORTANT: Stocker le scale pour la conversion des coordonnées
|
||||
// IMPORTANT: Stocker le scale et la taille originale pour la conversion des coordonnées
|
||||
setImageScale(scale);
|
||||
setOriginalImageSize({ width: img.width, height: img.height });
|
||||
|
||||
// IMPORTANT: Mettre à jour le state canvasSize pour que le CSS corresponde
|
||||
// Ceci évite le bug de coordonnées quand scaleX != 1
|
||||
@@ -297,7 +301,7 @@ const VisualSelector: React.FC<VisualSelectorProps> = ({
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
|
||||
// Utiliser l'API de capture réelle avec le moniteur sélectionné
|
||||
const response = await fetch('http://localhost:5002/api/real-demo/capture', {
|
||||
const response = await fetch('http://localhost:5001/api/real-demo/capture', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -463,6 +467,13 @@ const VisualSelector: React.FC<VisualSelectorProps> = ({
|
||||
height: canvasHeight / imageScale,
|
||||
};
|
||||
|
||||
// DIAGNOSTIC: Afficher toutes les informations de debug
|
||||
console.log(`📐 [VisualSelector] DIAGNOSTIC COORDONNÉES:`);
|
||||
console.log(` Canvas dimensions: ${canvas.width}x${canvas.height} (interne)`);
|
||||
console.log(` Canvas CSS rect: ${rect.width.toFixed(0)}x${rect.height.toFixed(0)} (affiché)`);
|
||||
console.log(` CSS→Canvas scale: scaleX=${scaleX.toFixed(3)}, scaleY=${scaleY.toFixed(3)}`);
|
||||
console.log(` Image scale (canvas→original): ${imageScale.toFixed(3)}`);
|
||||
console.log(` Original image size: ${originalImageSize.width}x${originalImageSize.height}`);
|
||||
console.log(`🎯 Sélection finale: canvas=(${canvasX.toFixed(0)}, ${canvasY.toFixed(0)}) ${canvasWidth.toFixed(0)}×${canvasHeight.toFixed(0)}px -> original=(${selectedArea.x.toFixed(0)}, ${selectedArea.y.toFixed(0)}) ${selectedArea.width.toFixed(0)}×${selectedArea.height.toFixed(0)}px [scale: ${imageScale}]`);
|
||||
|
||||
// Valider que la zone sélectionnée a une taille minimale
|
||||
@@ -496,9 +507,6 @@ const VisualSelector: React.FC<VisualSelectorProps> = ({
|
||||
try {
|
||||
console.log('🎯 Création de la sélection visuelle...');
|
||||
|
||||
// Extraire la région sélectionnée de l'image
|
||||
let referenceImage = captureState.screenshot;
|
||||
|
||||
// Essayer de créer un embedding via le service (optionnel)
|
||||
let embeddingData: number[] | undefined;
|
||||
let embeddingId: string | undefined;
|
||||
@@ -513,7 +521,6 @@ const VisualSelector: React.FC<VisualSelectorProps> = ({
|
||||
if (result?.success && result.embedding) {
|
||||
embeddingData = result.embedding;
|
||||
embeddingId = result.embedding_id;
|
||||
referenceImage = result.reference_image || captureState.screenshot;
|
||||
console.log(`✅ Embedding créé - ID: ${embeddingId}`);
|
||||
}
|
||||
} catch (embeddingError) {
|
||||
@@ -521,26 +528,63 @@ const VisualSelector: React.FC<VisualSelectorProps> = ({
|
||||
console.warn('⚠️ Service d\'embedding non disponible, sélection sans embedding');
|
||||
}
|
||||
|
||||
// Créer l'objet VisualSelection (fonctionne avec ou sans embedding)
|
||||
// Upload de l'image sur le serveur pour obtenir des URLs
|
||||
let thumbnailUrl: string | undefined;
|
||||
let originalUrl: string | undefined;
|
||||
let anchorId: string = `visual_${stepId}_${Date.now()}`;
|
||||
|
||||
try {
|
||||
const uploadResult = await uploadAnchorImage(
|
||||
captureState.screenshot,
|
||||
captureState.selectedArea
|
||||
);
|
||||
|
||||
if (uploadResult.success) {
|
||||
anchorId = uploadResult.anchor_id;
|
||||
thumbnailUrl = uploadResult.thumbnail_url;
|
||||
originalUrl = uploadResult.original_url;
|
||||
console.log('✅ Image uploadée sur le serveur:', {
|
||||
anchor_id: anchorId,
|
||||
thumbnail_url: thumbnailUrl,
|
||||
});
|
||||
}
|
||||
} catch (uploadError) {
|
||||
// Si l'upload échoue, on continue avec le base64 en fallback
|
||||
console.warn('⚠️ Upload serveur échoué, utilisation du base64 en fallback:', uploadError);
|
||||
}
|
||||
|
||||
// Créer l'objet VisualSelection
|
||||
const visualSelection: VisualSelection = {
|
||||
id: `visual_${stepId}_${Date.now()}`,
|
||||
screenshot: captureState.screenshot,
|
||||
id: anchorId,
|
||||
// Ne stocker le screenshot en base64 que si l'upload a échoué
|
||||
screenshot: thumbnailUrl ? '' : captureState.screenshot,
|
||||
boundingBox: captureState.selectedArea,
|
||||
embedding: embeddingData,
|
||||
description: `Élément sélectionné pour l'étape ${stepId}`,
|
||||
metadata: {
|
||||
embedding_id: embeddingId,
|
||||
reference_image: referenceImage,
|
||||
// URLs serveur (prioritaires)
|
||||
thumbnail_url: thumbnailUrl,
|
||||
reference_image_url: originalUrl,
|
||||
// Fallback base64 seulement si pas d'URLs
|
||||
reference_image: thumbnailUrl ? undefined : captureState.screenshot,
|
||||
capture_method: 'screen_capture',
|
||||
capture_timestamp: new Date().toISOString(),
|
||||
has_embedding: !!embeddingData,
|
||||
uses_server_storage: !!thumbnailUrl,
|
||||
// IMPORTANT: Résolution de l'image originale pour le calcul des coordonnées côté backend
|
||||
screen_resolution: originalImageSize.width > 0 ? {
|
||||
width: originalImageSize.width,
|
||||
height: originalImageSize.height,
|
||||
} : undefined,
|
||||
},
|
||||
};
|
||||
|
||||
console.log('✅ Sélection visuelle créée:', {
|
||||
id: visualSelection.id,
|
||||
boundingBox: visualSelection.boundingBox,
|
||||
hasEmbedding: !!embeddingData
|
||||
hasEmbedding: !!embeddingData,
|
||||
usesServerStorage: !!thumbnailUrl,
|
||||
});
|
||||
|
||||
onElementSelected(visualSelection);
|
||||
|
||||
@@ -0,0 +1,963 @@
|
||||
/**
|
||||
* Composant Gestionnaire de Workflows - Sauvegarde et chargement des workflows
|
||||
* Auteur : Dom, Alice, Kiro - 08 janvier 2026
|
||||
*
|
||||
* Ce composant gère la sauvegarde, le chargement, la liste et la gestion
|
||||
* des workflows avec intégration Backend_VWB améliorée et gestion robuste des erreurs.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo, memo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Typography,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemSecondaryAction,
|
||||
IconButton,
|
||||
Alert,
|
||||
Chip,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Divider,
|
||||
CircularProgress,
|
||||
Snackbar,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Save as SaveIcon,
|
||||
FolderOpen as LoadIcon,
|
||||
Delete as DeleteIcon,
|
||||
Edit as EditIcon,
|
||||
MoreVert as MoreIcon,
|
||||
Close as CloseIcon,
|
||||
Warning as WarningIcon,
|
||||
History as HistoryIcon,
|
||||
Refresh as RefreshIcon,
|
||||
CloudOff as OfflineIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
// Import des types partagés et du nouveau client API
|
||||
import { Workflow, WorkflowApiData } from '../../types';
|
||||
import { useWorkflowApi } from '../../hooks/useApiClient';
|
||||
import { ApiError } from '../../services/apiClient';
|
||||
// NOTE: Stockage local supprimé - utilisation API uniquement comme source de vérité
|
||||
|
||||
interface WorkflowManagerProps {
|
||||
currentWorkflow: Workflow;
|
||||
onWorkflowLoad: (workflow: Workflow) => void;
|
||||
onWorkflowSave: (workflow: Workflow) => void;
|
||||
}
|
||||
|
||||
interface SavedWorkflow {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
lastModified: Date;
|
||||
version: number;
|
||||
stepCount: number;
|
||||
hasConflicts?: boolean;
|
||||
}
|
||||
|
||||
interface SaveDialogState {
|
||||
isOpen: boolean;
|
||||
workflowName: string;
|
||||
workflowDescription: string;
|
||||
isOverwrite: boolean;
|
||||
existingWorkflowId?: string;
|
||||
}
|
||||
|
||||
interface ConflictResolution {
|
||||
workflowId: string;
|
||||
action: 'overwrite' | 'create_new' | 'merge';
|
||||
newName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composant Gestionnaire de Workflows
|
||||
*/
|
||||
const WorkflowManager: React.FC<WorkflowManagerProps> = ({
|
||||
currentWorkflow,
|
||||
onWorkflowLoad,
|
||||
onWorkflowSave,
|
||||
}) => {
|
||||
// Utilisation du nouveau client API avec gestion d'erreurs
|
||||
// NOTE: Mode hors ligne supprimé - API uniquement comme source de vérité
|
||||
const workflowApi = useWorkflowApi({
|
||||
onError: (error: ApiError) => {
|
||||
console.error('Erreur API Workflow:', error);
|
||||
setSnackbarMessage(`Erreur: ${error.message}`);
|
||||
setSnackbarOpen(true);
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
console.log('Succès API Workflow:', data);
|
||||
},
|
||||
});
|
||||
|
||||
const [savedWorkflows, setSavedWorkflows] = useState<SavedWorkflow[]>([]);
|
||||
const [isLoadDialogOpen, setIsLoadDialogOpen] = useState(false);
|
||||
const [saveDialogState, setSaveDialogState] = useState<SaveDialogState>({
|
||||
isOpen: false,
|
||||
workflowName: '',
|
||||
workflowDescription: '',
|
||||
isOverwrite: false,
|
||||
});
|
||||
const [menuAnchor, setMenuAnchor] = useState<{ element: HTMLElement; workflowId: string } | null>(null);
|
||||
const [conflictDialog, setConflictDialog] = useState<{
|
||||
isOpen: boolean;
|
||||
conflictingWorkflow?: SavedWorkflow;
|
||||
resolution?: ConflictResolution;
|
||||
}>({ isOpen: false });
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
const [pageSize] = useState(50); // Pagination pour les gros workflows
|
||||
const [snackbarOpen, setSnackbarOpen] = useState(false);
|
||||
const [snackbarMessage, setSnackbarMessage] = useState('');
|
||||
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
||||
|
||||
// Optimisations de chargement avec useMemo
|
||||
const sortedWorkflows = useMemo(() => {
|
||||
return [...savedWorkflows].sort((a, b) =>
|
||||
new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime()
|
||||
);
|
||||
}, [savedWorkflows]);
|
||||
|
||||
// Pagination des workflows pour optimiser l'affichage
|
||||
const paginatedWorkflows = useMemo(() => {
|
||||
const start = currentPage * pageSize;
|
||||
const end = start + pageSize;
|
||||
return sortedWorkflows.slice(start, end);
|
||||
}, [sortedWorkflows, currentPage, pageSize]);
|
||||
|
||||
const totalPages = useMemo(() => {
|
||||
return Math.ceil(savedWorkflows.length / pageSize);
|
||||
}, [savedWorkflows.length, pageSize]);
|
||||
|
||||
const workflowStats = useMemo(() => {
|
||||
return {
|
||||
total: savedWorkflows.length,
|
||||
totalSteps: savedWorkflows.reduce((sum, w) => sum + w.stepCount, 0),
|
||||
hasConflicts: savedWorkflows.some(w => w.hasConflicts),
|
||||
recentCount: savedWorkflows.filter(w =>
|
||||
new Date().getTime() - new Date(w.lastModified).getTime() < 24 * 60 * 60 * 1000
|
||||
).length,
|
||||
};
|
||||
}, [savedWorkflows]);
|
||||
|
||||
// États de chargement pour optimiser les performances
|
||||
const loadingStates = useMemo(() => ({
|
||||
hasLoadingStates: true,
|
||||
hasPagination: true, // Pagination activée
|
||||
hasErrorBoundary: true,
|
||||
hasLazyLoading: true,
|
||||
hasInfiniteScroll: false,
|
||||
hasSuspense: false,
|
||||
}), []);
|
||||
|
||||
// Gestionnaire d'événements clavier pour le gestionnaire de workflows
|
||||
const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
|
||||
switch (event.key) {
|
||||
case 'Enter':
|
||||
// Activer l'élément focalisé
|
||||
if (event.target instanceof HTMLButtonElement) {
|
||||
event.preventDefault();
|
||||
event.target.click();
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
// Fermer les dialogues ouverts
|
||||
event.preventDefault();
|
||||
setIsLoadDialogOpen(false);
|
||||
setSaveDialogState(prev => ({ ...prev, isOpen: false }));
|
||||
setConflictDialog({ isOpen: false });
|
||||
setMenuAnchor(null);
|
||||
break;
|
||||
case 's':
|
||||
// Raccourci pour sauvegarder (Ctrl+S)
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
event.preventDefault();
|
||||
handleSaveClick();
|
||||
}
|
||||
break;
|
||||
case 'o':
|
||||
// Raccourci pour ouvrir (Ctrl+O)
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
event.preventDefault();
|
||||
setIsLoadDialogOpen(true);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Charger la liste des workflows depuis le backend (API uniquement)
|
||||
const loadWorkflowList = useCallback(async () => {
|
||||
try {
|
||||
console.log('📡 [WorkflowManager] Chargement des workflows depuis l\'API...');
|
||||
const workflows = await workflowApi.loadWorkflows();
|
||||
|
||||
if (workflows && workflows.length > 0) {
|
||||
const formattedWorkflows: SavedWorkflow[] = workflows.map((wf: any) => ({
|
||||
id: wf.id,
|
||||
name: wf.name,
|
||||
description: wf.description,
|
||||
lastModified: new Date(wf.lastModified || wf.updatedAt || wf.updated_at),
|
||||
version: wf.version || 1,
|
||||
stepCount: wf.stepCount || wf.nodes?.length || wf.steps?.length || 0,
|
||||
hasConflicts: false,
|
||||
}));
|
||||
|
||||
console.log('✅ [WorkflowManager] Workflows chargés:', formattedWorkflows.length);
|
||||
setSavedWorkflows(formattedWorkflows);
|
||||
} else {
|
||||
console.log('ℹ️ [WorkflowManager] Aucun workflow trouvé');
|
||||
setSavedWorkflows([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ [WorkflowManager] Erreur chargement workflows:', error);
|
||||
setSnackbarMessage('Erreur de connexion à l\'API');
|
||||
setSnackbarOpen(true);
|
||||
setSavedWorkflows([]);
|
||||
}
|
||||
}, []); // Pas de dépendances - fonction stable
|
||||
|
||||
// Charger la liste des workflows au montage (une seule fois)
|
||||
useEffect(() => {
|
||||
loadWorkflowList();
|
||||
}, [loadWorkflowList]);
|
||||
|
||||
// Ouvrir le dialogue de sauvegarde
|
||||
const handleSaveClick = () => {
|
||||
setSaveDialogState({
|
||||
isOpen: true,
|
||||
workflowName: currentWorkflow.name,
|
||||
workflowDescription: currentWorkflow.description || '',
|
||||
isOverwrite: false,
|
||||
});
|
||||
};
|
||||
|
||||
// Sauvegarder le workflow avec validation côté client
|
||||
const handleSaveWorkflow = useCallback(async () => {
|
||||
if (!saveDialogState.workflowName.trim()) {
|
||||
setValidationErrors(['Le nom du workflow est obligatoire']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Convertir les steps en format nodes pour le backend
|
||||
const nodes = currentWorkflow.steps.map((step) => ({
|
||||
id: step.id,
|
||||
type: step.type,
|
||||
name: step.name,
|
||||
label: step.name,
|
||||
position: step.position,
|
||||
data: step.data,
|
||||
stepType: step.type,
|
||||
parameters: step.data?.parameters || {},
|
||||
isVWBCatalogAction: step.data?.isVWBCatalogAction || false,
|
||||
vwbActionId: step.data?.vwbActionId,
|
||||
}));
|
||||
|
||||
// Convertir les connections en format edges pour le backend
|
||||
const edges = currentWorkflow.connections.map((conn) => ({
|
||||
id: conn.id,
|
||||
source: conn.source,
|
||||
target: conn.target,
|
||||
}));
|
||||
|
||||
console.log('💾 [WorkflowManager] Sauvegarde workflow:', {
|
||||
name: saveDialogState.workflowName,
|
||||
stepsCount: currentWorkflow.steps.length,
|
||||
nodesCount: nodes.length,
|
||||
});
|
||||
|
||||
// Diagnostic des données visuelles pour chaque étape
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
currentWorkflow.steps.forEach((step, index) => {
|
||||
const params = step.data?.parameters || {};
|
||||
const hasVisualAnchor = !!params.visual_anchor;
|
||||
const hasRefImage = !!params.visual_anchor?.reference_image_base64;
|
||||
console.log(`💾 [Save] Étape ${index + 1} (${step.id}):`, {
|
||||
hasVisualAnchor,
|
||||
hasRefImage,
|
||||
refImageLength: params.visual_anchor?.reference_image_base64?.length || 0,
|
||||
hasBoundingBox: !!params.visual_anchor?.bounding_box,
|
||||
dataKeys: Object.keys(step.data || {}),
|
||||
paramKeys: Object.keys(params),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Validation côté client avant envoi - Envoyer les deux formats
|
||||
const workflowData: WorkflowApiData = {
|
||||
id: saveDialogState.isOverwrite ? saveDialogState.existingWorkflowId : undefined,
|
||||
name: saveDialogState.workflowName.trim(),
|
||||
description: saveDialogState.workflowDescription.trim() || undefined,
|
||||
steps: currentWorkflow.steps,
|
||||
connections: currentWorkflow.connections,
|
||||
variables: currentWorkflow.variables,
|
||||
// Ajouter aussi le format nodes/edges pour le backend
|
||||
nodes: nodes,
|
||||
edges: edges,
|
||||
// Champ requis par le backend
|
||||
created_by: 'vwb_user',
|
||||
};
|
||||
|
||||
// Vérifier s'il y a un conflit de nom
|
||||
const existingWorkflow = savedWorkflows.find(
|
||||
wf => wf.name === saveDialogState.workflowName.trim() &&
|
||||
wf.id !== currentWorkflow.id
|
||||
);
|
||||
|
||||
if (existingWorkflow && !saveDialogState.isOverwrite) {
|
||||
// Conflit détecté, demander résolution
|
||||
setConflictDialog({
|
||||
isOpen: true,
|
||||
conflictingWorkflow: existingWorkflow,
|
||||
resolution: {
|
||||
workflowId: existingWorkflow.id,
|
||||
action: 'overwrite',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Valider le workflow avant sauvegarde
|
||||
try {
|
||||
const validationResult = await workflowApi.validateWorkflow(workflowData);
|
||||
|
||||
if (!validationResult?.isValid) {
|
||||
setValidationErrors(validationResult?.errors || ['Erreur de validation']);
|
||||
return;
|
||||
}
|
||||
|
||||
if (validationResult.warnings && validationResult.warnings.length > 0) {
|
||||
setSnackbarMessage(`Avertissements: ${validationResult.warnings.join(', ')}`);
|
||||
setSnackbarOpen(true);
|
||||
}
|
||||
} catch (validationError) {
|
||||
// Validation optionnelle - continuer avec la sauvegarde
|
||||
console.warn('⚠️ [WorkflowManager] Validation non disponible, sauvegarde directe');
|
||||
}
|
||||
|
||||
// Sauvegarder via API uniquement
|
||||
try {
|
||||
console.log('💾 [WorkflowManager] Sauvegarde via API...');
|
||||
const workflowId = await workflowApi.saveWorkflow(workflowData);
|
||||
|
||||
if (workflowId) {
|
||||
const updatedWorkflow: Workflow = {
|
||||
...currentWorkflow,
|
||||
id: workflowId,
|
||||
name: saveDialogState.workflowName.trim(),
|
||||
description: saveDialogState.workflowDescription.trim() || undefined,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
onWorkflowSave(updatedWorkflow);
|
||||
|
||||
// Fermer le dialogue et recharger la liste
|
||||
setSaveDialogState({ isOpen: false, workflowName: '', workflowDescription: '', isOverwrite: false });
|
||||
setValidationErrors([]);
|
||||
await loadWorkflowList();
|
||||
|
||||
setSnackbarMessage('✅ Workflow sauvegardé avec succès');
|
||||
setSnackbarOpen(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ [WorkflowManager] Erreur sauvegarde API:', error);
|
||||
setSnackbarMessage('Erreur: impossible de sauvegarder le workflow');
|
||||
setSnackbarOpen(true);
|
||||
}
|
||||
}, [saveDialogState, currentWorkflow, savedWorkflows, workflowApi, onWorkflowSave, loadWorkflowList]);
|
||||
|
||||
// Charger un workflow depuis l'API uniquement
|
||||
const handleLoadWorkflow = useCallback(async (workflowId: string) => {
|
||||
// Fonction pour mapper les données du backend vers le format frontend
|
||||
const mapWorkflowData = (workflowData: any): Workflow => {
|
||||
const steps = workflowData.steps || workflowData.nodes || [];
|
||||
const connections = workflowData.connections || workflowData.edges || [];
|
||||
|
||||
// Liste des types d'actions VWB connus
|
||||
const VWB_ACTION_TYPES = new Set([
|
||||
'click_anchor', 'double_click_anchor', 'right_click_anchor', 'hover_anchor',
|
||||
'type_text', 'type_secret', 'focus_anchor', 'drag_drop_anchor', 'scroll_to_anchor',
|
||||
'keyboard_shortcut', 'wait_for_anchor', 'visual_condition', 'loop_visual',
|
||||
'extract_text', 'extract_table', 'screenshot_evidence', 'download_to_folder',
|
||||
'ai_analyze_text', 'db_save_data', 'db_read_data', 'verify_element_exists', 'verify_text_content'
|
||||
]);
|
||||
|
||||
const mappedSteps = steps.map((node: any, index: number) => {
|
||||
// NORMALISATION CRITIQUE: Résoudre les incohérences de type
|
||||
// Prioriser le type VWB valide, peu importe où il se trouve
|
||||
const typeFromNode = node.type;
|
||||
const typeFromStepType = node.stepType;
|
||||
const typeFromData = node.data?.stepType;
|
||||
const typeFromVwbAction = node.data?.vwbActionId || node.vwbActionId;
|
||||
|
||||
// Chercher le type VWB valide en premier
|
||||
let normalizedType = '';
|
||||
const candidates = [typeFromNode, typeFromStepType, typeFromData, typeFromVwbAction];
|
||||
for (const candidate of candidates) {
|
||||
if (candidate && VWB_ACTION_TYPES.has(candidate)) {
|
||||
normalizedType = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Fallback: prendre le premier disponible
|
||||
if (!normalizedType) {
|
||||
normalizedType = typeFromNode || typeFromStepType || typeFromData || 'click';
|
||||
}
|
||||
|
||||
// Log si incohérence détectée
|
||||
if (typeFromNode && typeFromData && typeFromNode !== typeFromData) {
|
||||
console.warn(
|
||||
`⚠️ [mapWorkflowData] Incohérence type détectée pour étape ${node.id}:`,
|
||||
`node.type="${typeFromNode}", node.data.stepType="${typeFromData}"`,
|
||||
`→ Normalisé vers "${normalizedType}"`
|
||||
);
|
||||
}
|
||||
|
||||
const nodeType = normalizedType;
|
||||
|
||||
// Déterminer si c'est une action VWB (vérifier plusieurs sources)
|
||||
const isVWBAction = Boolean(
|
||||
node.data?.isVWBCatalogAction ||
|
||||
node.isVWBCatalogAction ||
|
||||
VWB_ACTION_TYPES.has(nodeType) ||
|
||||
normalizedType?.includes('anchor') ||
|
||||
normalizedType?.includes('vwb_') ||
|
||||
node.data?.parameters?.visual_anchor
|
||||
);
|
||||
|
||||
// Récupérer les paramètres depuis plusieurs sources possibles
|
||||
// IMPORTANT: Copie profonde pour éviter les références partagées entre étapes
|
||||
const rawParametersSource = node.data?.parameters || node.parameters || {};
|
||||
const rawParameters = JSON.parse(JSON.stringify(rawParametersSource));
|
||||
|
||||
// Copie profonde des données brutes pour éviter les mutations croisées
|
||||
const baseData = node.data ? JSON.parse(JSON.stringify(node.data)) : {};
|
||||
|
||||
// Fusionner les données existantes avec les valeurs par défaut
|
||||
// IMPORTANT: Utiliser normalizedType pour garantir la cohérence
|
||||
const nodeData = {
|
||||
...baseData, // Spread d'abord les données brutes (copiées)
|
||||
label: node.name || node.label || node.data?.label || `Étape ${index + 1}`,
|
||||
stepType: normalizedType, // UTILISER LE TYPE NORMALISÉ
|
||||
parameters: rawParameters, // Puis définir parameters explicitement (copié)
|
||||
isVWBCatalogAction: isVWBAction,
|
||||
vwbActionId: node.data?.vwbActionId || node.vwbActionId || node.action_id || normalizedType,
|
||||
};
|
||||
|
||||
// Log de diagnostic pour les miniatures
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
const hasVisualAnchor = !!rawParameters.visual_anchor;
|
||||
const hasRefImage = !!rawParameters.visual_anchor?.reference_image_base64;
|
||||
console.log(`📷 [mapWorkflowData] Étape ${index + 1} (${node.id}):`, {
|
||||
hasVisualAnchor,
|
||||
hasRefImage,
|
||||
refImageLength: rawParameters.visual_anchor?.reference_image_base64?.length || 0,
|
||||
hasBoundingBox: !!rawParameters.visual_anchor?.bounding_box,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: node.id || `step_${index}`,
|
||||
type: normalizedType, // UTILISER LE TYPE NORMALISÉ
|
||||
name: node.name || node.label || node.data?.label || `Étape ${index + 1}`,
|
||||
position: node.position || { x: 100 + (index % 3) * 200, y: 100 + Math.floor(index / 3) * 150 },
|
||||
data: nodeData,
|
||||
executionState: node.executionState || 'IDLE',
|
||||
validationErrors: node.validationErrors || [],
|
||||
};
|
||||
});
|
||||
|
||||
const mappedConnections = connections.map((edge: any, index: number) => ({
|
||||
id: edge.id || `conn_${index}`,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
}));
|
||||
|
||||
return {
|
||||
id: workflowData.id,
|
||||
name: workflowData.name,
|
||||
description: workflowData.description,
|
||||
steps: mappedSteps,
|
||||
connections: mappedConnections,
|
||||
variables: workflowData.variables || [],
|
||||
createdAt: new Date(workflowData.createdAt || workflowData.created_at || Date.now()),
|
||||
updatedAt: new Date(workflowData.updatedAt || workflowData.updated_at || Date.now()),
|
||||
};
|
||||
};
|
||||
|
||||
// Chargement via API uniquement
|
||||
try {
|
||||
console.log('📡 [WorkflowManager] Chargement workflow depuis API:', workflowId);
|
||||
const workflowData = await workflowApi.loadWorkflow(workflowId);
|
||||
|
||||
if (workflowData) {
|
||||
console.log('📦 [WorkflowManager] Données brutes du backend:', workflowData);
|
||||
|
||||
const loadedWorkflow = mapWorkflowData(workflowData);
|
||||
|
||||
console.log('✅ [WorkflowManager] Workflow mappé:', {
|
||||
name: loadedWorkflow.name,
|
||||
stepsCount: loadedWorkflow.steps.length,
|
||||
connectionsCount: loadedWorkflow.connections.length,
|
||||
});
|
||||
|
||||
onWorkflowLoad(loadedWorkflow);
|
||||
setIsLoadDialogOpen(false);
|
||||
|
||||
setSnackbarMessage(`✅ Workflow "${loadedWorkflow.name}" chargé avec ${loadedWorkflow.steps.length} étapes`);
|
||||
setSnackbarOpen(true);
|
||||
} else {
|
||||
setSnackbarMessage('Workflow non trouvé');
|
||||
setSnackbarOpen(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ [WorkflowManager] Erreur chargement workflow:', error);
|
||||
setSnackbarMessage('Erreur: impossible de charger le workflow');
|
||||
setSnackbarOpen(true);
|
||||
}
|
||||
}, [workflowApi, onWorkflowLoad]);
|
||||
|
||||
// Supprimer un workflow (API uniquement)
|
||||
const handleDeleteWorkflow = useCallback(async (workflowId: string) => {
|
||||
if (!window.confirm('Êtes-vous sûr de vouloir supprimer ce workflow ?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('🗑️ [WorkflowManager] Suppression workflow via API:', workflowId);
|
||||
await workflowApi.deleteWorkflow(workflowId);
|
||||
|
||||
// Recharger la liste
|
||||
await loadWorkflowList();
|
||||
setMenuAnchor(null);
|
||||
|
||||
setSnackbarMessage('✅ Workflow supprimé avec succès');
|
||||
setSnackbarOpen(true);
|
||||
} catch (error) {
|
||||
console.error('❌ [WorkflowManager] Erreur suppression workflow:', error);
|
||||
setSnackbarMessage('Erreur: impossible de supprimer le workflow');
|
||||
setSnackbarOpen(true);
|
||||
}
|
||||
}, [workflowApi, loadWorkflowList]);
|
||||
|
||||
// Renommer un workflow
|
||||
const handleRenameWorkflow = (workflow: SavedWorkflow) => {
|
||||
const newName = window.prompt('Nouveau nom du workflow:', workflow.name);
|
||||
if (newName && newName.trim() !== workflow.name) {
|
||||
// TODO: Implémenter la logique de renommage
|
||||
console.log('Renommer workflow:', workflow.id, 'vers', newName.trim());
|
||||
}
|
||||
setMenuAnchor(null);
|
||||
};
|
||||
|
||||
// Résoudre un conflit de sauvegarde
|
||||
const handleConflictResolution = async (resolution: ConflictResolution) => {
|
||||
setConflictDialog({ isOpen: false });
|
||||
|
||||
if (resolution.action === 'overwrite') {
|
||||
setSaveDialogState(prev => ({
|
||||
...prev,
|
||||
isOverwrite: true,
|
||||
existingWorkflowId: resolution.workflowId,
|
||||
}));
|
||||
await handleSaveWorkflow();
|
||||
} else if (resolution.action === 'create_new') {
|
||||
setSaveDialogState(prev => ({
|
||||
...prev,
|
||||
workflowName: resolution.newName || `${prev.workflowName}_copie`,
|
||||
isOverwrite: false,
|
||||
}));
|
||||
}
|
||||
// TODO: Implémenter la logique de merge pour resolution.action === 'merge'
|
||||
};
|
||||
|
||||
// Formater la date de modification
|
||||
const formatLastModified = (date: Date): string => {
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) {
|
||||
return 'Aujourd\'hui';
|
||||
} else if (diffDays === 1) {
|
||||
return 'Hier';
|
||||
} else if (diffDays < 7) {
|
||||
return `Il y a ${diffDays} jours`;
|
||||
} else {
|
||||
return date.toLocaleDateString('fr-FR');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box onKeyDown={handleKeyDown} tabIndex={0}>
|
||||
{/* Boutons principaux avec indicateur de santé API */}
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 2, alignItems: 'center' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<SaveIcon />}
|
||||
onClick={handleSaveClick}
|
||||
disabled={workflowApi.loading}
|
||||
>
|
||||
Sauvegarder
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<LoadIcon />}
|
||||
onClick={() => setIsLoadDialogOpen(true)}
|
||||
disabled={workflowApi.loading}
|
||||
>
|
||||
Charger
|
||||
</Button>
|
||||
<Button
|
||||
variant="text"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={() => loadWorkflowList()}
|
||||
disabled={workflowApi.loading}
|
||||
size="small"
|
||||
>
|
||||
Actualiser
|
||||
</Button>
|
||||
|
||||
{/* Indicateur de chargement et retry */}
|
||||
{workflowApi.loading && <CircularProgress size={24} />}
|
||||
{workflowApi.isRetrying && (
|
||||
<Chip
|
||||
icon={<RefreshIcon />}
|
||||
label={`Retry ${workflowApi.retryCount}/${3}`}
|
||||
size="small"
|
||||
color="warning"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Indicateur d'erreur API */}
|
||||
{workflowApi.error && (
|
||||
<Chip
|
||||
icon={<OfflineIcon />}
|
||||
label="Erreur API"
|
||||
size="small"
|
||||
color="error"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Affichage des erreurs de validation */}
|
||||
{validationErrors.length > 0 && (
|
||||
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setValidationErrors([])}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Erreurs de validation:
|
||||
</Typography>
|
||||
<List dense>
|
||||
{validationErrors.map((error, index) => (
|
||||
<ListItem key={index} sx={{ py: 0 }}>
|
||||
<Typography variant="body2">• {error}</Typography>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Affichage des erreurs API */}
|
||||
{workflowApi.error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }} onClose={() => workflowApi.reset()}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Erreur API:
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{workflowApi.error.message}
|
||||
{workflowApi.error.code && ` (Code: ${workflowApi.error.code})`}
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Dialogue de sauvegarde */}
|
||||
<Dialog
|
||||
open={saveDialogState.isOpen}
|
||||
onClose={() => setSaveDialogState({ isOpen: false, workflowName: '', workflowDescription: '', isOverwrite: false })}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
Sauvegarder le workflow
|
||||
<IconButton
|
||||
onClick={() => setSaveDialogState({ isOpen: false, workflowName: '', workflowDescription: '', isOverwrite: false })}
|
||||
size="small"
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 1 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Nom du workflow"
|
||||
value={saveDialogState.workflowName}
|
||||
onChange={(e) => setSaveDialogState(prev => ({ ...prev, workflowName: e.target.value }))}
|
||||
required
|
||||
error={!saveDialogState.workflowName.trim()}
|
||||
helperText={!saveDialogState.workflowName.trim() ? 'Le nom est obligatoire' : ''}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Description (optionnelle)"
|
||||
value={saveDialogState.workflowDescription}
|
||||
onChange={(e) => setSaveDialogState(prev => ({ ...prev, workflowDescription: e.target.value }))}
|
||||
multiline
|
||||
rows={3}
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={() => setSaveDialogState({ isOpen: false, workflowName: '', workflowDescription: '', isOverwrite: false })}
|
||||
disabled={workflowApi.loading}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSaveWorkflow}
|
||||
variant="contained"
|
||||
disabled={workflowApi.loading || !saveDialogState.workflowName.trim()}
|
||||
>
|
||||
Sauvegarder
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Dialogue de chargement */}
|
||||
<Dialog
|
||||
open={isLoadDialogOpen}
|
||||
onClose={() => setIsLoadDialogOpen(false)}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
Charger un workflow
|
||||
<IconButton onClick={() => setIsLoadDialogOpen(false)} size="small">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent>
|
||||
{savedWorkflows.length === 0 ? (
|
||||
<Alert severity="info">
|
||||
Aucun workflow sauvegardé trouvé.
|
||||
</Alert>
|
||||
) : (
|
||||
<List>
|
||||
{paginatedWorkflows.map((workflow) => (
|
||||
<ListItem
|
||||
key={workflow.id}
|
||||
component="div"
|
||||
onClick={() => handleLoadWorkflow(workflow.id)}
|
||||
sx={{
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 1,
|
||||
mb: 1,
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="subtitle1">{workflow.name}</Typography>
|
||||
{workflow.hasConflicts && (
|
||||
<Chip
|
||||
icon={<WarningIcon />}
|
||||
label="Conflits"
|
||||
size="small"
|
||||
color="warning"
|
||||
/>
|
||||
)}
|
||||
<Chip
|
||||
label={`v${workflow.version}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
secondary={
|
||||
<Box>
|
||||
{workflow.description && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{workflow.description}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{workflow.stepCount} étapes • Modifié {formatLastModified(workflow.lastModified)}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<IconButton
|
||||
edge="end"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMenuAnchor({ element: e.currentTarget, workflowId: workflow.id });
|
||||
}}
|
||||
>
|
||||
<MoreIcon />
|
||||
</IconButton>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 2 }}>
|
||||
<Button
|
||||
onClick={() => setCurrentPage(prev => Math.max(0, prev - 1))}
|
||||
disabled={currentPage === 0}
|
||||
>
|
||||
Précédent
|
||||
</Button>
|
||||
<Typography sx={{ mx: 2, alignSelf: 'center' }}>
|
||||
Page {currentPage + 1} sur {totalPages}
|
||||
</Typography>
|
||||
<Button
|
||||
onClick={() => setCurrentPage(prev => Math.min(totalPages - 1, prev + 1))}
|
||||
disabled={currentPage === totalPages - 1}
|
||||
>
|
||||
Suivant
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button onClick={() => setIsLoadDialogOpen(false)}>
|
||||
Fermer
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Snackbar pour les notifications */}
|
||||
<Snackbar
|
||||
open={snackbarOpen}
|
||||
autoHideDuration={6000}
|
||||
onClose={() => setSnackbarOpen(false)}
|
||||
message={snackbarMessage}
|
||||
action={
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="close"
|
||||
color="inherit"
|
||||
onClick={() => setSnackbarOpen(false)}
|
||||
>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Menu contextuel */}
|
||||
<Menu
|
||||
anchorEl={menuAnchor?.element}
|
||||
open={Boolean(menuAnchor)}
|
||||
onClose={() => setMenuAnchor(null)}
|
||||
>
|
||||
<MenuItem onClick={() => {
|
||||
const workflow = savedWorkflows.find(wf => wf.id === menuAnchor?.workflowId);
|
||||
if (workflow) handleRenameWorkflow(workflow);
|
||||
}}>
|
||||
<EditIcon sx={{ mr: 1 }} />
|
||||
Renommer
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => {
|
||||
// TODO: Implémenter l'historique des versions
|
||||
console.log('Voir historique:', menuAnchor?.workflowId);
|
||||
setMenuAnchor(null);
|
||||
}}>
|
||||
<HistoryIcon sx={{ mr: 1 }} />
|
||||
Historique
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
<MenuItem
|
||||
onClick={() => menuAnchor && handleDeleteWorkflow(menuAnchor.workflowId)}
|
||||
sx={{ color: 'error.main' }}
|
||||
>
|
||||
<DeleteIcon sx={{ mr: 1 }} />
|
||||
Supprimer
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
{/* Dialogue de résolution de conflit */}
|
||||
<Dialog
|
||||
open={conflictDialog.isOpen}
|
||||
onClose={() => setConflictDialog({ isOpen: false })}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<WarningIcon color="warning" />
|
||||
Conflit de nom détecté
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent>
|
||||
<Typography variant="body1" gutterBottom>
|
||||
Un workflow avec le nom "{saveDialogState.workflowName}" existe déjà.
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Que souhaitez-vous faire ?
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions sx={{ flexDirection: 'column', gap: 1, alignItems: 'stretch' }}>
|
||||
<Button
|
||||
onClick={() => handleConflictResolution({
|
||||
workflowId: conflictDialog.conflictingWorkflow?.id || '',
|
||||
action: 'overwrite',
|
||||
})}
|
||||
color="warning"
|
||||
>
|
||||
Écraser le workflow existant
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleConflictResolution({
|
||||
workflowId: conflictDialog.conflictingWorkflow?.id || '',
|
||||
action: 'create_new',
|
||||
newName: `${saveDialogState.workflowName}_copie`,
|
||||
})}
|
||||
variant="contained"
|
||||
>
|
||||
Créer une nouvelle version
|
||||
</Button>
|
||||
<Button onClick={() => setConflictDialog({ isOpen: false })}>
|
||||
Annuler
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// Mémorisation du composant WorkflowManager pour éviter les re-rendus inutiles
|
||||
export default memo(WorkflowManager, (prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.currentWorkflow?.id === nextProps.currentWorkflow?.id &&
|
||||
prevProps.currentWorkflow?.name === nextProps.currentWorkflow?.name &&
|
||||
prevProps.currentWorkflow?.steps?.length === nextProps.currentWorkflow?.steps?.length &&
|
||||
prevProps.onWorkflowLoad === nextProps.onWorkflowLoad &&
|
||||
prevProps.onWorkflowSave === nextProps.onWorkflowSave
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,382 @@
|
||||
/**
|
||||
* Contrats Stricts des Actions VWB - Frontend
|
||||
*
|
||||
* Auteur : Dom, Alice, Kiro - 23 janvier 2026
|
||||
*
|
||||
* Ce module définit les contrats stricts pour chaque action VWB côté frontend.
|
||||
* Validation AVANT envoi au backend pour éviter les erreurs.
|
||||
*
|
||||
* PRINCIPE CLÉ: Si le contrat n'est pas respecté → BLOQUER l'exécution
|
||||
*/
|
||||
|
||||
export enum ContractViolationType {
|
||||
MISSING_REQUIRED = 'missing_required',
|
||||
INVALID_TYPE = 'invalid_type',
|
||||
INVALID_VALUE = 'invalid_value',
|
||||
INCOMPATIBLE_ACTION = 'incompatible_action'
|
||||
}
|
||||
|
||||
export interface ContractViolation {
|
||||
violationType: ContractViolationType;
|
||||
parameter: string;
|
||||
message: string;
|
||||
expected?: string;
|
||||
received?: string;
|
||||
}
|
||||
|
||||
export interface ActionContract {
|
||||
actionType: string;
|
||||
description: string;
|
||||
requiredParams: string[];
|
||||
optionalParams: string[];
|
||||
validators?: Record<string, (value: any) => boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si visual_anchor est présent et valide
|
||||
*/
|
||||
function hasVisualAnchor(params: Record<string, any>): boolean {
|
||||
const anchor = params.visual_anchor || params.target || params.visualSelection;
|
||||
if (!anchor) return false;
|
||||
if (typeof anchor !== 'object') return false;
|
||||
|
||||
// Doit avoir soit une image, soit des coordonnées, soit un ID
|
||||
const hasImage = !!(
|
||||
anchor.screenshot ||
|
||||
anchor.image ||
|
||||
anchor.reference_image_base64 ||
|
||||
anchor.id ||
|
||||
anchor.anchor_id
|
||||
);
|
||||
const hasCoords = !!(
|
||||
anchor.bounding_box ||
|
||||
anchor.boundingBox
|
||||
);
|
||||
const hasServerStorage = !!(
|
||||
anchor.metadata?.uses_server_storage ||
|
||||
anchor.metadata?.thumbnail_url
|
||||
);
|
||||
|
||||
return hasImage || hasCoords || hasServerStorage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si text est présent et non vide
|
||||
*/
|
||||
function hasText(params: Record<string, any>): boolean {
|
||||
const text = params.text || params.text_to_type || params.texte;
|
||||
return !!(text && typeof text === 'string' && text.trim().length > 0);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// DÉFINITION DES CONTRATS POUR CHAQUE ACTION VWB
|
||||
// =============================================================================
|
||||
|
||||
export const VWB_ACTION_CONTRACTS: Record<string, ActionContract> = {
|
||||
// --- ACTIONS DE CLIC ---
|
||||
click_anchor: {
|
||||
actionType: 'click_anchor',
|
||||
description: 'Clic sur un élément identifié par ancre visuelle',
|
||||
requiredParams: ['visual_anchor'],
|
||||
optionalParams: ['click_type', 'click_offset_x', 'click_offset_y', 'confidence_threshold'],
|
||||
validators: {
|
||||
visual_anchor: (v) => hasVisualAnchor({ visual_anchor: v })
|
||||
}
|
||||
},
|
||||
|
||||
double_click_anchor: {
|
||||
actionType: 'double_click_anchor',
|
||||
description: 'Double-clic sur un élément identifié par ancre visuelle',
|
||||
requiredParams: ['visual_anchor'],
|
||||
optionalParams: ['click_offset_x', 'click_offset_y', 'confidence_threshold'],
|
||||
validators: {
|
||||
visual_anchor: (v) => hasVisualAnchor({ visual_anchor: v })
|
||||
}
|
||||
},
|
||||
|
||||
right_click_anchor: {
|
||||
actionType: 'right_click_anchor',
|
||||
description: 'Clic droit sur un élément identifié par ancre visuelle',
|
||||
requiredParams: ['visual_anchor'],
|
||||
optionalParams: ['click_offset_x', 'click_offset_y', 'confidence_threshold'],
|
||||
validators: {
|
||||
visual_anchor: (v) => hasVisualAnchor({ visual_anchor: v })
|
||||
}
|
||||
},
|
||||
|
||||
hover_anchor: {
|
||||
actionType: 'hover_anchor',
|
||||
description: 'Survol d\'un élément identifié par ancre visuelle',
|
||||
requiredParams: ['visual_anchor'],
|
||||
optionalParams: ['hover_duration_ms', 'confidence_threshold'],
|
||||
validators: {
|
||||
visual_anchor: (v) => hasVisualAnchor({ visual_anchor: v })
|
||||
}
|
||||
},
|
||||
|
||||
// --- ACTIONS DE SAISIE ---
|
||||
type_text: {
|
||||
actionType: 'type_text',
|
||||
description: 'Saisie de texte (le focus doit être déjà fait)',
|
||||
requiredParams: ['text'],
|
||||
optionalParams: ['typing_speed_ms', 'clear_field_first', 'press_enter_after'],
|
||||
validators: {
|
||||
text: (v) => !!(v && typeof v === 'string')
|
||||
}
|
||||
},
|
||||
|
||||
type_secret: {
|
||||
actionType: 'type_secret',
|
||||
description: 'Saisie sécurisée de texte sensible',
|
||||
requiredParams: ['secret_text'],
|
||||
optionalParams: ['typing_speed_ms', 'clear_field_first', 'mask_in_evidence'],
|
||||
validators: {
|
||||
secret_text: (v) => !!(v && typeof v === 'string')
|
||||
}
|
||||
},
|
||||
|
||||
// --- ACTIONS DE FOCUS ---
|
||||
focus_anchor: {
|
||||
actionType: 'focus_anchor',
|
||||
description: 'Donne le focus à un élément',
|
||||
requiredParams: ['visual_anchor'],
|
||||
optionalParams: ['focus_method', 'verify_focus', 'confidence_threshold'],
|
||||
validators: {
|
||||
visual_anchor: (v) => hasVisualAnchor({ visual_anchor: v })
|
||||
}
|
||||
},
|
||||
|
||||
// --- ACTIONS D'ATTENTE ---
|
||||
wait_for_anchor: {
|
||||
actionType: 'wait_for_anchor',
|
||||
description: 'Attendre qu\'un élément apparaisse ou disparaisse',
|
||||
requiredParams: ['visual_anchor'],
|
||||
optionalParams: ['wait_mode', 'max_wait_time_ms', 'check_interval_ms'],
|
||||
validators: {
|
||||
visual_anchor: (v) => hasVisualAnchor({ visual_anchor: v })
|
||||
}
|
||||
},
|
||||
|
||||
// --- ACTIONS DE SCROLL ---
|
||||
scroll_to_anchor: {
|
||||
actionType: 'scroll_to_anchor',
|
||||
description: 'Défiler jusqu\'à ce qu\'un élément soit visible',
|
||||
requiredParams: ['visual_anchor'],
|
||||
optionalParams: ['scroll_direction', 'scroll_speed', 'max_scroll_attempts'],
|
||||
validators: {
|
||||
visual_anchor: (v) => hasVisualAnchor({ visual_anchor: v })
|
||||
}
|
||||
},
|
||||
|
||||
drag_drop_anchor: {
|
||||
actionType: 'drag_drop_anchor',
|
||||
description: 'Glisser-déposer d\'un élément vers un autre',
|
||||
requiredParams: ['source_anchor', 'target_anchor'],
|
||||
optionalParams: ['drag_speed', 'hold_duration_ms'],
|
||||
validators: {
|
||||
source_anchor: (v) => hasVisualAnchor({ visual_anchor: v }),
|
||||
target_anchor: (v) => hasVisualAnchor({ visual_anchor: v })
|
||||
}
|
||||
},
|
||||
|
||||
// --- ACTIONS CLAVIER ---
|
||||
keyboard_shortcut: {
|
||||
actionType: 'keyboard_shortcut',
|
||||
description: 'Exécuter un raccourci clavier',
|
||||
requiredParams: ['keys'],
|
||||
optionalParams: ['hold_duration_ms'],
|
||||
validators: {
|
||||
keys: (v) => Array.isArray(v) && v.length > 0
|
||||
}
|
||||
},
|
||||
|
||||
// --- ACTIONS D'EXTRACTION ---
|
||||
extract_text: {
|
||||
actionType: 'extract_text',
|
||||
description: 'Extraire du texte d\'une zone',
|
||||
requiredParams: ['visual_anchor'],
|
||||
optionalParams: ['extraction_mode', 'text_filters', 'output_format'],
|
||||
validators: {
|
||||
visual_anchor: (v) => hasVisualAnchor({ visual_anchor: v })
|
||||
}
|
||||
},
|
||||
|
||||
extract_table: {
|
||||
actionType: 'extract_table',
|
||||
description: 'Extraire un tableau d\'une zone',
|
||||
requiredParams: ['visual_anchor'],
|
||||
optionalParams: ['table_format', 'output_variable'],
|
||||
validators: {
|
||||
visual_anchor: (v) => hasVisualAnchor({ visual_anchor: v })
|
||||
}
|
||||
},
|
||||
|
||||
screenshot_evidence: {
|
||||
actionType: 'screenshot_evidence',
|
||||
description: 'Capturer une preuve visuelle',
|
||||
requiredParams: [],
|
||||
optionalParams: ['region', 'label', 'include_timestamp']
|
||||
},
|
||||
|
||||
// --- ACTIONS CONDITIONNELLES ---
|
||||
visual_condition: {
|
||||
actionType: 'visual_condition',
|
||||
description: 'Condition basée sur présence d\'élément visuel',
|
||||
requiredParams: ['visual_anchor'],
|
||||
optionalParams: ['condition_type', 'timeout_ms'],
|
||||
validators: {
|
||||
visual_anchor: (v) => hasVisualAnchor({ visual_anchor: v })
|
||||
}
|
||||
},
|
||||
|
||||
loop_visual: {
|
||||
actionType: 'loop_visual',
|
||||
description: 'Boucle tant qu\'un élément est visible',
|
||||
requiredParams: ['visual_anchor'],
|
||||
optionalParams: ['max_iterations', 'timeout_ms'],
|
||||
validators: {
|
||||
visual_anchor: (v) => hasVisualAnchor({ visual_anchor: v })
|
||||
}
|
||||
},
|
||||
|
||||
// --- ACTIONS VÉRIFICATION ---
|
||||
verify_element_exists: {
|
||||
actionType: 'verify_element_exists',
|
||||
description: 'Vérifier qu\'un élément existe',
|
||||
requiredParams: ['visual_anchor'],
|
||||
optionalParams: ['timeout_ms', 'should_exist'],
|
||||
validators: {
|
||||
visual_anchor: (v) => hasVisualAnchor({ visual_anchor: v })
|
||||
}
|
||||
},
|
||||
|
||||
verify_text_content: {
|
||||
actionType: 'verify_text_content',
|
||||
description: 'Vérifier le contenu textuel',
|
||||
requiredParams: ['visual_anchor', 'expected_text'],
|
||||
optionalParams: ['match_mode', 'case_sensitive'],
|
||||
validators: {
|
||||
visual_anchor: (v) => hasVisualAnchor({ visual_anchor: v })
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Classe d'erreur pour les violations de contrat
|
||||
*/
|
||||
export class ContractValidationError extends Error {
|
||||
public violations: ContractViolation[];
|
||||
public actionType: string;
|
||||
|
||||
constructor(violations: ContractViolation[], actionType: string) {
|
||||
const messages = violations.map(v => v.message).join('; ');
|
||||
super(`Contrat violé pour '${actionType}': ${messages}`);
|
||||
this.name = 'ContractValidationError';
|
||||
this.violations = violations;
|
||||
this.actionType = actionType;
|
||||
}
|
||||
|
||||
toDict(): Record<string, any> {
|
||||
return {
|
||||
error: 'contract_violation',
|
||||
actionType: this.actionType,
|
||||
violations: this.violations,
|
||||
message: this.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide les paramètres d'une action contre son contrat
|
||||
*/
|
||||
export function validateActionContract(
|
||||
actionType: string,
|
||||
parameters: Record<string, any>
|
||||
): ContractViolation[] {
|
||||
const normalizedType = actionType.toLowerCase().trim();
|
||||
const contract = VWB_ACTION_CONTRACTS[normalizedType];
|
||||
|
||||
if (!contract) {
|
||||
console.warn(`⚠️ [Contract] Action '${actionType}' non reconnue dans les contrats`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const violations: ContractViolation[] = [];
|
||||
|
||||
// Vérifier les paramètres obligatoires
|
||||
for (const param of contract.requiredParams) {
|
||||
const value = parameters[param];
|
||||
|
||||
if (value === undefined || value === null) {
|
||||
violations.push({
|
||||
violationType: ContractViolationType.MISSING_REQUIRED,
|
||||
parameter: param,
|
||||
message: `Paramètre obligatoire '${param}' manquant pour l'action '${actionType}'`,
|
||||
expected: `'${param}' doit être fourni`,
|
||||
received: 'absent ou null'
|
||||
});
|
||||
} else if (contract.validators && contract.validators[param]) {
|
||||
// Valider le contenu
|
||||
if (!contract.validators[param](value)) {
|
||||
violations.push({
|
||||
violationType: ContractViolationType.INVALID_VALUE,
|
||||
parameter: param,
|
||||
message: `Valeur invalide pour '${param}' dans l'action '${actionType}'`,
|
||||
expected: 'valeur valide selon les règles du contrat',
|
||||
received: typeof value
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide et BLOQUE si le contrat n'est pas respecté
|
||||
*/
|
||||
export function enforceActionContract(
|
||||
actionType: string,
|
||||
parameters: Record<string, any>
|
||||
): void {
|
||||
const violations = validateActionContract(actionType, parameters);
|
||||
|
||||
if (violations.length > 0) {
|
||||
console.error(`🚫 [Contract] VIOLATION DÉTECTÉE pour '${actionType}':`);
|
||||
violations.forEach(v => {
|
||||
console.error(` - ${v.parameter}: ${v.message}`);
|
||||
});
|
||||
throw new ContractValidationError(violations, actionType);
|
||||
}
|
||||
|
||||
console.log(`✅ [Contract] Contrat respecté pour '${actionType}'`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne le contrat d'une action
|
||||
*/
|
||||
export function getActionContract(actionType: string): ActionContract | undefined {
|
||||
return VWB_ACTION_CONTRACTS[actionType.toLowerCase().trim()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne les paramètres obligatoires pour une action
|
||||
*/
|
||||
export function getRequiredParams(actionType: string): string[] {
|
||||
const contract = getActionContract(actionType);
|
||||
return contract?.requiredParams || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si un type d'action est reconnu
|
||||
*/
|
||||
export function isKnownActionType(actionType: string): boolean {
|
||||
return actionType.toLowerCase().trim() in VWB_ACTION_CONTRACTS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste tous les types d'actions avec contrat
|
||||
*/
|
||||
export function listAllActionTypes(): string[] {
|
||||
return Object.keys(VWB_ACTION_CONTRACTS);
|
||||
}
|
||||
20
visual_workflow_builder/frontend/src/contracts/index.ts
Normal file
20
visual_workflow_builder/frontend/src/contracts/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Module de Contrats VWB - Exports
|
||||
*/
|
||||
|
||||
export {
|
||||
ContractViolationType,
|
||||
ContractValidationError,
|
||||
VWB_ACTION_CONTRACTS,
|
||||
validateActionContract,
|
||||
enforceActionContract,
|
||||
getActionContract,
|
||||
getRequiredParams,
|
||||
isKnownActionType,
|
||||
listAllActionTypes
|
||||
} from './actionContracts';
|
||||
|
||||
export type {
|
||||
ContractViolation,
|
||||
ActionContract
|
||||
} from './actionContracts';
|
||||
428
visual_workflow_builder/frontend/src/hooks/useApiClient.ts
Normal file
428
visual_workflow_builder/frontend/src/hooks/useApiClient.ts
Normal file
@@ -0,0 +1,428 @@
|
||||
/**
|
||||
* Hook API Client - Interface React pour le client API
|
||||
* Auteur : Dom, Alice, Kiro - 09 janvier 2026
|
||||
*
|
||||
* Ce hook fournit une interface React pour utiliser le client API
|
||||
* avec gestion d'état, loading, erreurs et mode hors ligne gracieux.
|
||||
* Optimisé pour éviter les re-renders excessifs et les sauts de page.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||
import { apiClient, ApiError, ConnectionState } from '../services/apiClient';
|
||||
import { WorkflowApiData } from '../types';
|
||||
|
||||
// Types pour les états de requête
|
||||
interface RequestState<T = any> {
|
||||
data: T | null;
|
||||
loading: boolean;
|
||||
error: ApiError | null;
|
||||
lastUpdated: Date | null;
|
||||
isOffline: boolean;
|
||||
}
|
||||
|
||||
interface UseApiClientOptions {
|
||||
enableAutoRetry?: boolean;
|
||||
retryDelay?: number;
|
||||
maxRetries?: number;
|
||||
onError?: (error: ApiError) => void;
|
||||
onSuccess?: (data: any) => void;
|
||||
silentOffline?: boolean; // Ne pas afficher d'erreur en mode hors ligne
|
||||
}
|
||||
|
||||
// État initial stable (évite les re-créations)
|
||||
const INITIAL_STATE: RequestState = {
|
||||
data: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: null,
|
||||
isOffline: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook pour utiliser le client API avec gestion d'état React
|
||||
* Optimisé pour éviter les re-renders inutiles
|
||||
*/
|
||||
export function useApiClient<T = any>(options: UseApiClientOptions = {}) {
|
||||
const {
|
||||
enableAutoRetry = false, // Désactivé par défaut pour éviter les sauts
|
||||
retryDelay = 1000,
|
||||
maxRetries = 2,
|
||||
onError,
|
||||
onSuccess,
|
||||
silentOffline = true, // Par défaut, ne pas afficher d'erreur en mode hors ligne
|
||||
} = options;
|
||||
|
||||
const [state, setState] = useState<RequestState<T>>(INITIAL_STATE);
|
||||
const retryCountRef = useRef(0);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
// Nettoyer les timeouts et marquer comme démonté
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Fonction pour mettre à jour l'état de manière sécurisée
|
||||
const safeSetState = useCallback((updater: (prev: RequestState<T>) => RequestState<T>) => {
|
||||
if (mountedRef.current) {
|
||||
setState(updater);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fonction générique pour exécuter une requête API
|
||||
const executeRequest = useCallback(async <R = T>(
|
||||
requestFn: () => Promise<R>,
|
||||
requestOptions: { skipLoading?: boolean; skipErrorHandling?: boolean } = {}
|
||||
): Promise<R | null> => {
|
||||
const { skipLoading = false, skipErrorHandling = false } = requestOptions;
|
||||
|
||||
try {
|
||||
if (!skipLoading) {
|
||||
safeSetState(prev => ({
|
||||
...prev,
|
||||
loading: true,
|
||||
error: null,
|
||||
}));
|
||||
}
|
||||
|
||||
const result = await requestFn();
|
||||
|
||||
// Vérifier si le résultat indique un mode hors ligne
|
||||
const isOfflineResult = result && typeof result === 'object' && 'offline' in result && (result as any).offline;
|
||||
|
||||
safeSetState(prev => ({
|
||||
...prev,
|
||||
data: isOfflineResult ? prev.data : (result as unknown as T), // Garder les anciennes données si hors ligne
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: isOfflineResult ? prev.lastUpdated : new Date(),
|
||||
isOffline: isOfflineResult,
|
||||
}));
|
||||
|
||||
retryCountRef.current = 0;
|
||||
|
||||
if (onSuccess && !isOfflineResult) {
|
||||
onSuccess(result);
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
const apiError = error as ApiError;
|
||||
const isOffline = apiError.code === 'OFFLINE' || apiError.code === 'NETWORK_ERROR';
|
||||
|
||||
safeSetState(prev => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
error: (silentOffline && isOffline) ? null : apiError,
|
||||
isOffline,
|
||||
}));
|
||||
|
||||
// Gestion du retry automatique (seulement si pas hors ligne)
|
||||
if (enableAutoRetry && !isOffline && retryCountRef.current < maxRetries && shouldRetryError(apiError)) {
|
||||
retryCountRef.current++;
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
executeRequest(requestFn, requestOptions);
|
||||
}, retryDelay * Math.pow(2, retryCountRef.current - 1));
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
retryCountRef.current = 0;
|
||||
|
||||
if (!skipErrorHandling && onError && !(silentOffline && isOffline)) {
|
||||
onError(apiError);
|
||||
}
|
||||
|
||||
// Ne pas relancer l'erreur en mode hors ligne silencieux
|
||||
if (silentOffline && isOffline) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw apiError;
|
||||
}
|
||||
}, [enableAutoRetry, maxRetries, retryDelay, onError, onSuccess, silentOffline, safeSetState]);
|
||||
|
||||
// Déterminer si une erreur justifie un retry
|
||||
const shouldRetryError = useCallback((error: ApiError): boolean => {
|
||||
// Ne pas retry pour les erreurs hors ligne
|
||||
if (error.code === 'OFFLINE' || error.code === 'NETWORK_ERROR') {
|
||||
return false;
|
||||
}
|
||||
// Retry pour les erreurs serveur
|
||||
return (
|
||||
(error.status !== undefined && error.status >= 500) ||
|
||||
error.status === 408 ||
|
||||
error.status === 429
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Réinitialiser l'état
|
||||
const reset = useCallback(() => {
|
||||
safeSetState(() => INITIAL_STATE);
|
||||
retryCountRef.current = 0;
|
||||
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
}, [safeSetState]);
|
||||
|
||||
// Annuler la requête en cours
|
||||
const cancel = useCallback(() => {
|
||||
apiClient.cancelRequest();
|
||||
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
|
||||
safeSetState(prev => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
}));
|
||||
}, [safeSetState]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
executeRequest,
|
||||
reset,
|
||||
cancel,
|
||||
isRetrying: retryCountRef.current > 0,
|
||||
retryCount: retryCountRef.current,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook pour surveiller l'état de connexion de l'API
|
||||
* Utilise un abonnement pour éviter les re-renders excessifs
|
||||
* L'état initial est 'offline' pour éviter les tentatives de connexion au montage
|
||||
*/
|
||||
export function useConnectionState() {
|
||||
// État initial 'online' - on suppose que l'API est disponible
|
||||
const [connectionState, setConnectionState] = useState<ConnectionState>('online');
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
// Vérification DIRECTE au montage (SANS passer par apiClient singleton)
|
||||
const checkOnMount = async () => {
|
||||
try {
|
||||
const response = await fetch('http://localhost:5001/api/health', {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
if (isMounted) {
|
||||
setConnectionState('online');
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignorer l'erreur - on garde l'état 'online' par défaut
|
||||
}
|
||||
|
||||
// Seulement si vraiment impossible de contacter l'API
|
||||
if (isMounted) {
|
||||
setConnectionState('offline');
|
||||
}
|
||||
};
|
||||
checkOnMount();
|
||||
|
||||
// NE PAS s'abonner au singleton - cela cause des conflits d'état
|
||||
// L'état est géré localement par ce hook uniquement
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Mémoiser les valeurs dérivées
|
||||
const derivedState = useMemo(() => ({
|
||||
isOnline: connectionState === 'online',
|
||||
isOffline: connectionState === 'offline',
|
||||
isChecking: connectionState === 'checking',
|
||||
connectionState,
|
||||
}), [connectionState]);
|
||||
|
||||
// Fonction pour forcer une vérification
|
||||
const forceCheck = useCallback(async () => {
|
||||
return apiClient.forceConnectionCheck();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...derivedState,
|
||||
forceCheck,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook spécialisé pour les opérations sur les workflows
|
||||
* Gère gracieusement le mode hors ligne
|
||||
*/
|
||||
export function useWorkflowApi(options: UseApiClientOptions = {}) {
|
||||
const api = useApiClient<any>({ ...options, silentOffline: true });
|
||||
const { isOffline } = useConnectionState();
|
||||
|
||||
// Charger la liste des workflows
|
||||
// NOTE: On essaie toujours l'API - les erreurs sont gérées par makeRequest
|
||||
const loadWorkflows = useCallback(async () => {
|
||||
return api.executeRequest(() => apiClient.getWorkflows());
|
||||
}, [api]);
|
||||
|
||||
// Charger un workflow spécifique
|
||||
const loadWorkflow = useCallback(async (workflowId: string) => {
|
||||
return api.executeRequest(() => apiClient.getWorkflow(workflowId));
|
||||
}, [api]);
|
||||
|
||||
// Sauvegarder un workflow
|
||||
const saveWorkflow = useCallback(async (workflowData: WorkflowApiData) => {
|
||||
return api.executeRequest(() => apiClient.saveWorkflow(workflowData));
|
||||
}, [api]);
|
||||
|
||||
// Supprimer un workflow
|
||||
const deleteWorkflow = useCallback(async (workflowId: string) => {
|
||||
return api.executeRequest(() => apiClient.deleteWorkflow(workflowId));
|
||||
}, [api]);
|
||||
|
||||
// Valider un workflow
|
||||
const validateWorkflow = useCallback(async (workflowData: WorkflowApiData) => {
|
||||
return api.executeRequest(() => apiClient.validateWorkflow(workflowData));
|
||||
}, [api]);
|
||||
|
||||
return {
|
||||
...api,
|
||||
isOffline,
|
||||
loadWorkflows,
|
||||
loadWorkflow,
|
||||
saveWorkflow,
|
||||
deleteWorkflow,
|
||||
validateWorkflow,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook spécialisé pour l'exécution de workflows
|
||||
*/
|
||||
export function useWorkflowExecution(options: UseApiClientOptions = {}) {
|
||||
const api = useApiClient<any>({ ...options, silentOffline: true });
|
||||
const { isOffline } = useConnectionState();
|
||||
|
||||
// Exécuter une étape
|
||||
// NOTE: On n'utilise plus isOffline comme bloqueur car l'état peut être obsolète
|
||||
// On essaie toujours d'exécuter et on gère l'erreur si elle survient
|
||||
const executeStep = useCallback(async (stepData: {
|
||||
stepId: string;
|
||||
stepType: string;
|
||||
parameters: any;
|
||||
workflowId?: string;
|
||||
}) => {
|
||||
// Toujours essayer l'exécution - l'erreur sera gérée par makeRequest si l'API est vraiment hors ligne
|
||||
return api.executeRequest(() => apiClient.executeStep(stepData));
|
||||
}, [api]);
|
||||
|
||||
// Exécuter un workflow complet
|
||||
const executeWorkflow = useCallback(async (workflowId: string, parameters?: any) => {
|
||||
// Toujours essayer l'exécution
|
||||
return api.executeRequest(() => apiClient.executeWorkflow(workflowId, parameters));
|
||||
}, [api]);
|
||||
|
||||
return {
|
||||
...api,
|
||||
isOffline,
|
||||
executeStep,
|
||||
executeWorkflow,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook pour surveiller la santé de l'API
|
||||
* Optimisé pour éviter les re-renders excessifs
|
||||
*/
|
||||
export function useApiHealth(options: UseApiClientOptions & {
|
||||
pollInterval?: number;
|
||||
enablePolling?: boolean;
|
||||
} = {}) {
|
||||
const { pollInterval = 30000, enablePolling = false } = options;
|
||||
const api = useApiClient<{ status: string; timestamp: string }>({ ...options, silentOffline: true });
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const { connectionState, isOnline, forceCheck } = useConnectionState();
|
||||
|
||||
// Vérifier la santé de l'API
|
||||
const checkHealth = useCallback(async () => {
|
||||
return api.executeRequest(() => apiClient.healthCheck(), { skipLoading: true });
|
||||
}, [api]);
|
||||
|
||||
// Démarrer le polling
|
||||
const startPolling = useCallback(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
|
||||
intervalRef.current = setInterval(() => {
|
||||
checkHealth();
|
||||
}, pollInterval);
|
||||
|
||||
// Vérification initiale
|
||||
checkHealth();
|
||||
}, [checkHealth, pollInterval]);
|
||||
|
||||
// Arrêter le polling
|
||||
const stopPolling = useCallback(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Démarrer le polling automatiquement si activé
|
||||
useEffect(() => {
|
||||
if (enablePolling) {
|
||||
startPolling();
|
||||
}
|
||||
|
||||
return () => {
|
||||
stopPolling();
|
||||
};
|
||||
}, [enablePolling, startPolling, stopPolling]);
|
||||
|
||||
return {
|
||||
...api,
|
||||
checkHealth,
|
||||
startPolling,
|
||||
stopPolling,
|
||||
forceCheck,
|
||||
isHealthy: isOnline,
|
||||
connectionState,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook pour les statistiques de l'API
|
||||
*/
|
||||
export function useApiStats(options: UseApiClientOptions = {}) {
|
||||
const api = useApiClient<any>({ ...options, silentOffline: true });
|
||||
|
||||
// Charger les statistiques
|
||||
const loadStats = useCallback(async () => {
|
||||
return api.executeRequest(() => apiClient.getApiStats());
|
||||
}, [api]);
|
||||
|
||||
return {
|
||||
...api,
|
||||
loadStats,
|
||||
};
|
||||
}
|
||||
|
||||
// Export des types
|
||||
export type { RequestState, UseApiClientOptions };
|
||||
546
visual_workflow_builder/frontend/src/hooks/useAutoSave.ts
Normal file
546
visual_workflow_builder/frontend/src/hooks/useAutoSave.ts
Normal file
@@ -0,0 +1,546 @@
|
||||
/**
|
||||
* Hook useAutoSave - Sauvegarde automatique avec debouncing
|
||||
* Auteur : Dom, Alice, Kiro - 12 janvier 2026
|
||||
*
|
||||
* Ce hook fournit un système de sauvegarde automatique avec debouncing,
|
||||
* gestion d'erreurs et états de sauvegarde pour les paramètres d'étapes.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* Options de configuration pour l'auto-sauvegarde
|
||||
*/
|
||||
export interface AutoSaveOptions {
|
||||
debounceMs?: number;
|
||||
maxRetries?: number;
|
||||
retryDelayMs?: number;
|
||||
enableLogging?: boolean;
|
||||
onSaveStart?: () => void;
|
||||
onSaveSuccess?: (data: any) => void;
|
||||
onSaveError?: (error: Error) => void;
|
||||
onSaveComplete?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* État de la sauvegarde
|
||||
*/
|
||||
export interface SaveState {
|
||||
isSaving: boolean;
|
||||
isDirty: boolean;
|
||||
lastSaved: number | null;
|
||||
error: Error | null;
|
||||
retryCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Résultat du hook useAutoSave
|
||||
*/
|
||||
export interface UseAutoSaveResult {
|
||||
saveState: SaveState;
|
||||
triggerSave: (data: any) => void;
|
||||
forceSave: (data: any) => Promise<void>;
|
||||
clearDirty: () => void;
|
||||
resetError: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook useAutoSave
|
||||
*/
|
||||
export function useAutoSave(
|
||||
saveFunction: (data: any) => Promise<void>,
|
||||
options: AutoSaveOptions = {}
|
||||
): UseAutoSaveResult {
|
||||
|
||||
// Options par défaut
|
||||
const config = {
|
||||
debounceMs: 1000,
|
||||
maxRetries: 3,
|
||||
retryDelayMs: 2000,
|
||||
enableLogging: process.env.NODE_ENV === 'development',
|
||||
...options
|
||||
};
|
||||
|
||||
// État de sauvegarde
|
||||
const [saveState, setSaveState] = useState<SaveState>({
|
||||
isSaving: false,
|
||||
isDirty: false,
|
||||
lastSaved: null,
|
||||
error: null,
|
||||
retryCount: 0
|
||||
});
|
||||
|
||||
// Références pour éviter les re-rendus
|
||||
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const pendingDataRef = useRef<any>(null);
|
||||
const isUnmountedRef = useRef(false);
|
||||
const retryCountRef = useRef(0);
|
||||
const configRef = useRef(config);
|
||||
|
||||
// Mettre à jour la ref config quand elle change
|
||||
useEffect(() => {
|
||||
configRef.current = config;
|
||||
}, [config]);
|
||||
|
||||
/**
|
||||
* Fonction de sauvegarde avec gestion d'erreurs et retry
|
||||
* Note: Utilise des refs pour éviter les dépendances instables
|
||||
*/
|
||||
const performSave = useCallback(async (data: any, isRetry = false): Promise<void> => {
|
||||
if (isUnmountedRef.current) return;
|
||||
|
||||
const cfg = configRef.current;
|
||||
|
||||
try {
|
||||
// Marquer le début de la sauvegarde
|
||||
setSaveState(prev => ({
|
||||
...prev,
|
||||
isSaving: true,
|
||||
error: null
|
||||
}));
|
||||
|
||||
if (cfg.onSaveStart) {
|
||||
cfg.onSaveStart();
|
||||
}
|
||||
|
||||
if (cfg.enableLogging) {
|
||||
console.log('💾 [useAutoSave] Début de sauvegarde:', {
|
||||
dataSize: JSON.stringify(data).length,
|
||||
isRetry,
|
||||
retryCount: retryCountRef.current
|
||||
});
|
||||
}
|
||||
|
||||
// Exécuter la fonction de sauvegarde
|
||||
await saveFunction(data);
|
||||
|
||||
// Sauvegarde réussie
|
||||
if (!isUnmountedRef.current) {
|
||||
retryCountRef.current = 0;
|
||||
setSaveState(prev => ({
|
||||
...prev,
|
||||
isSaving: false,
|
||||
isDirty: false,
|
||||
lastSaved: Date.now(),
|
||||
error: null,
|
||||
retryCount: 0
|
||||
}));
|
||||
|
||||
if (cfg.onSaveSuccess) {
|
||||
cfg.onSaveSuccess(data);
|
||||
}
|
||||
|
||||
if (cfg.enableLogging) {
|
||||
console.log('✅ [useAutoSave] Sauvegarde réussie');
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
const errorObj = error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
if (cfg.enableLogging) {
|
||||
console.error('❌ [useAutoSave] Erreur de sauvegarde:', errorObj.message);
|
||||
}
|
||||
|
||||
if (!isUnmountedRef.current) {
|
||||
retryCountRef.current++;
|
||||
const newRetryCount = retryCountRef.current;
|
||||
|
||||
// Tentative de retry si pas encore atteint le maximum
|
||||
if (newRetryCount <= cfg.maxRetries && !isRetry) {
|
||||
if (cfg.enableLogging) {
|
||||
console.log(`🔄 [useAutoSave] Retry ${newRetryCount}/${cfg.maxRetries} dans ${cfg.retryDelayMs}ms`);
|
||||
}
|
||||
|
||||
setSaveState(prev => ({
|
||||
...prev,
|
||||
retryCount: newRetryCount,
|
||||
error: errorObj
|
||||
}));
|
||||
|
||||
// Programmer le retry
|
||||
setTimeout(() => {
|
||||
if (!isUnmountedRef.current) {
|
||||
performSave(data, true);
|
||||
}
|
||||
}, cfg.retryDelayMs);
|
||||
} else {
|
||||
// Échec définitif
|
||||
setSaveState(prev => ({
|
||||
...prev,
|
||||
isSaving: false,
|
||||
error: errorObj,
|
||||
retryCount: newRetryCount
|
||||
}));
|
||||
}
|
||||
|
||||
if (cfg.onSaveError) {
|
||||
cfg.onSaveError(errorObj);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (!isUnmountedRef.current && cfg.onSaveComplete) {
|
||||
cfg.onSaveComplete();
|
||||
}
|
||||
}
|
||||
}, [saveFunction]); // Dépendance minimale - utilise des refs pour le reste
|
||||
|
||||
/**
|
||||
* Déclenche une sauvegarde avec debouncing
|
||||
*/
|
||||
const triggerSave = useCallback((data: any) => {
|
||||
// Stocker les données en attente
|
||||
pendingDataRef.current = data;
|
||||
const cfg = configRef.current;
|
||||
|
||||
// Marquer comme dirty
|
||||
setSaveState(prev => ({
|
||||
...prev,
|
||||
isDirty: true,
|
||||
error: null
|
||||
}));
|
||||
|
||||
// Annuler le timeout précédent
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Programmer la nouvelle sauvegarde
|
||||
debounceTimeoutRef.current = setTimeout(() => {
|
||||
if (!isUnmountedRef.current && pendingDataRef.current !== null) {
|
||||
performSave(pendingDataRef.current);
|
||||
pendingDataRef.current = null;
|
||||
}
|
||||
}, cfg.debounceMs);
|
||||
|
||||
if (cfg.enableLogging) {
|
||||
console.log(`⏱️ [useAutoSave] Sauvegarde programmée dans ${cfg.debounceMs}ms`);
|
||||
}
|
||||
}, [performSave]); // Dépendance minimale
|
||||
|
||||
/**
|
||||
* Force une sauvegarde immédiate (sans debouncing)
|
||||
*/
|
||||
const forceSave = useCallback(async (data: any): Promise<void> => {
|
||||
// Annuler le debouncing en cours
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
debounceTimeoutRef.current = undefined;
|
||||
}
|
||||
|
||||
pendingDataRef.current = null;
|
||||
|
||||
if (configRef.current.enableLogging) {
|
||||
console.log('⚡ [useAutoSave] Sauvegarde forcée');
|
||||
}
|
||||
|
||||
await performSave(data);
|
||||
}, [performSave]); // Dépendance minimale
|
||||
|
||||
/**
|
||||
* Marque les données comme propres (non modifiées)
|
||||
*/
|
||||
const clearDirty = useCallback(() => {
|
||||
setSaveState(prev => ({
|
||||
...prev,
|
||||
isDirty: false
|
||||
}));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Remet à zéro l'erreur de sauvegarde
|
||||
*/
|
||||
const resetError = useCallback(() => {
|
||||
setSaveState(prev => ({
|
||||
...prev,
|
||||
error: null,
|
||||
retryCount: 0
|
||||
}));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Cleanup à la destruction du composant
|
||||
*/
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isUnmountedRef.current = true;
|
||||
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Sauvegarder les données en attente si nécessaire
|
||||
if (pendingDataRef.current !== null && !saveState.isSaving) {
|
||||
if (config.enableLogging) {
|
||||
console.log('🧹 [useAutoSave] Sauvegarde finale au cleanup');
|
||||
}
|
||||
|
||||
// Sauvegarde synchrone finale (best effort)
|
||||
try {
|
||||
saveFunction(pendingDataRef.current);
|
||||
} catch (error) {
|
||||
console.error('❌ [useAutoSave] Erreur sauvegarde finale:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [saveFunction, saveState.isSaving, config.enableLogging]);
|
||||
|
||||
return {
|
||||
saveState,
|
||||
triggerSave,
|
||||
forceSave,
|
||||
clearDirty,
|
||||
resetError
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook spécialisé pour la sauvegarde des paramètres d'étapes
|
||||
*
|
||||
* IMPORTANT: Ce hook gère correctement les changements de stepId pour éviter
|
||||
* que les paramètres d'une ancienne étape soient sauvegardés vers une nouvelle étape.
|
||||
*/
|
||||
export function useStepParametersAutoSave(
|
||||
stepId: string,
|
||||
onParameterChange: (stepId: string, paramName: string, value: any) => void,
|
||||
options: AutoSaveOptions = {}
|
||||
): UseAutoSaveResult {
|
||||
|
||||
// Référence pour tracker le stepId actuel et annuler les sauvegardes obsolètes
|
||||
const currentStepIdRef = useRef<string>(stepId);
|
||||
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const pendingDataRef = useRef<Record<string, any> | null>(null);
|
||||
|
||||
// État de sauvegarde local
|
||||
const [saveState, setSaveState] = useState<SaveState>({
|
||||
isSaving: false,
|
||||
isDirty: false,
|
||||
lastSaved: null,
|
||||
error: null,
|
||||
retryCount: 0
|
||||
});
|
||||
|
||||
// Configuration
|
||||
const config = {
|
||||
debounceMs: 800,
|
||||
maxRetries: 2,
|
||||
retryDelayMs: 1500,
|
||||
enableLogging: process.env.NODE_ENV === 'development',
|
||||
...options
|
||||
};
|
||||
|
||||
// CRITIQUE: Quand le stepId change, annuler toute sauvegarde en attente
|
||||
// pour éviter que les paramètres de l'ancienne étape soient sauvegardés vers la nouvelle
|
||||
useEffect(() => {
|
||||
if (currentStepIdRef.current !== stepId) {
|
||||
if (config.enableLogging) {
|
||||
console.log('🔄 [useStepParametersAutoSave] StepId changé:', {
|
||||
ancien: currentStepIdRef.current,
|
||||
nouveau: stepId
|
||||
});
|
||||
|
||||
if (pendingDataRef.current !== null) {
|
||||
console.log('⚠️ [useStepParametersAutoSave] Annulation sauvegarde en attente pour éviter contamination');
|
||||
}
|
||||
}
|
||||
|
||||
// Annuler le timeout de debounce
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
debounceTimeoutRef.current = undefined;
|
||||
}
|
||||
|
||||
// Effacer les données en attente (CRITIQUE pour éviter le bug)
|
||||
pendingDataRef.current = null;
|
||||
|
||||
// Mettre à jour la référence
|
||||
currentStepIdRef.current = stepId;
|
||||
|
||||
// Réinitialiser l'état
|
||||
setSaveState({
|
||||
isSaving: false,
|
||||
isDirty: false,
|
||||
lastSaved: null,
|
||||
error: null,
|
||||
retryCount: 0
|
||||
});
|
||||
}
|
||||
}, [stepId, config.enableLogging]);
|
||||
|
||||
// Fonction de sauvegarde
|
||||
const performSave = useCallback(async (parametersData: Record<string, any>, targetStepId: string) => {
|
||||
// CRITIQUE: Vérifier que le stepId cible correspond toujours au stepId actuel
|
||||
if (targetStepId !== currentStepIdRef.current) {
|
||||
if (config.enableLogging) {
|
||||
console.log('⚠️ [useStepParametersAutoSave] Sauvegarde annulée - stepId obsolète:', {
|
||||
target: targetStepId,
|
||||
current: currentStepIdRef.current
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!targetStepId) {
|
||||
throw new Error('Step ID is required for parameter save');
|
||||
}
|
||||
|
||||
try {
|
||||
setSaveState(prev => ({ ...prev, isSaving: true, error: null }));
|
||||
|
||||
if (config.onSaveStart) {
|
||||
config.onSaveStart();
|
||||
}
|
||||
|
||||
// Sauvegarder chaque paramètre individuellement
|
||||
const savePromises = Object.entries(parametersData).map(([paramName, value]) => {
|
||||
return new Promise<void>((resolve) => {
|
||||
onParameterChange(targetStepId, paramName, value);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
await Promise.all(savePromises);
|
||||
|
||||
setSaveState(prev => ({
|
||||
...prev,
|
||||
isSaving: false,
|
||||
isDirty: false,
|
||||
lastSaved: Date.now(),
|
||||
error: null,
|
||||
retryCount: 0
|
||||
}));
|
||||
|
||||
if (config.onSaveSuccess) {
|
||||
config.onSaveSuccess(parametersData);
|
||||
}
|
||||
|
||||
if (config.enableLogging) {
|
||||
console.log('✅ [useStepParametersAutoSave] Sauvegarde réussie pour stepId:', targetStepId);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
const errorObj = error instanceof Error ? error : new Error(String(error));
|
||||
setSaveState(prev => ({ ...prev, isSaving: false, error: errorObj }));
|
||||
|
||||
if (config.onSaveError) {
|
||||
config.onSaveError(errorObj);
|
||||
}
|
||||
}
|
||||
}, [onParameterChange, config]);
|
||||
|
||||
// Déclencher une sauvegarde avec debouncing
|
||||
const triggerSave = useCallback((data: Record<string, any>) => {
|
||||
// Capturer le stepId actuel au moment du déclenchement
|
||||
const targetStepId = currentStepIdRef.current;
|
||||
|
||||
if (!targetStepId) {
|
||||
if (config.enableLogging) {
|
||||
console.log('⚠️ [useStepParametersAutoSave] Pas de stepId, sauvegarde ignorée');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Stocker les données en attente
|
||||
pendingDataRef.current = data;
|
||||
|
||||
setSaveState(prev => ({ ...prev, isDirty: true, error: null }));
|
||||
|
||||
// Annuler le timeout précédent
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Programmer la nouvelle sauvegarde avec le stepId capturé
|
||||
debounceTimeoutRef.current = setTimeout(() => {
|
||||
if (pendingDataRef.current !== null) {
|
||||
// CRITIQUE: Utiliser le targetStepId capturé, pas currentStepIdRef.current
|
||||
// ET vérifier que c'est toujours valide
|
||||
if (targetStepId === currentStepIdRef.current) {
|
||||
performSave(pendingDataRef.current, targetStepId);
|
||||
} else if (config.enableLogging) {
|
||||
console.log('⚠️ [useStepParametersAutoSave] Sauvegarde debounced annulée - stepId changé');
|
||||
}
|
||||
pendingDataRef.current = null;
|
||||
}
|
||||
}, config.debounceMs);
|
||||
|
||||
if (config.enableLogging) {
|
||||
console.log(`⏱️ [useStepParametersAutoSave] Sauvegarde programmée pour ${targetStepId} dans ${config.debounceMs}ms`);
|
||||
}
|
||||
}, [performSave, config]);
|
||||
|
||||
// Force une sauvegarde immédiate
|
||||
const forceSave = useCallback(async (data: Record<string, any>): Promise<void> => {
|
||||
const targetStepId = currentStepIdRef.current;
|
||||
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
debounceTimeoutRef.current = undefined;
|
||||
}
|
||||
|
||||
pendingDataRef.current = null;
|
||||
|
||||
if (targetStepId) {
|
||||
await performSave(data, targetStepId);
|
||||
}
|
||||
}, [performSave]);
|
||||
|
||||
const clearDirty = useCallback(() => {
|
||||
setSaveState(prev => ({ ...prev, isDirty: false }));
|
||||
}, []);
|
||||
|
||||
const resetError = useCallback(() => {
|
||||
setSaveState(prev => ({ ...prev, error: null, retryCount: 0 }));
|
||||
}, []);
|
||||
|
||||
// Cleanup
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
saveState,
|
||||
triggerSave,
|
||||
forceSave,
|
||||
clearDirty,
|
||||
resetError
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook pour la sauvegarde avec état de synchronisation
|
||||
*/
|
||||
export function useSyncedAutoSave(
|
||||
saveFunction: (data: any) => Promise<void>,
|
||||
options: AutoSaveOptions = {}
|
||||
) {
|
||||
const autoSave = useAutoSave(saveFunction, options);
|
||||
const [syncState, setSyncState] = useState<'synced' | 'pending' | 'error'>('synced');
|
||||
|
||||
// Mettre à jour l'état de synchronisation
|
||||
useEffect(() => {
|
||||
if (autoSave.saveState.isSaving) {
|
||||
setSyncState('pending');
|
||||
} else if (autoSave.saveState.error) {
|
||||
setSyncState('error');
|
||||
} else if (!autoSave.saveState.isDirty) {
|
||||
setSyncState('synced');
|
||||
} else {
|
||||
setSyncState('pending');
|
||||
}
|
||||
}, [autoSave.saveState]);
|
||||
|
||||
return {
|
||||
...autoSave,
|
||||
syncState
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Export par défaut
|
||||
*/
|
||||
export default useAutoSave;
|
||||
421
visual_workflow_builder/frontend/src/hooks/useCatalogActions.ts
Normal file
421
visual_workflow_builder/frontend/src/hooks/useCatalogActions.ts
Normal file
@@ -0,0 +1,421 @@
|
||||
/**
|
||||
* Hook useCatalogActions - Gestion de l'état du catalogue d'actions VisionOnly
|
||||
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
|
||||
*
|
||||
* Ce hook gère le chargement, la mise en cache, et la synchronisation
|
||||
* des actions du catalogue VisionOnly avec l'API backend.
|
||||
*
|
||||
* NOUVEAUTÉS v2.0:
|
||||
* - Support du mode statique automatique (fallback hors ligne)
|
||||
* - Détection automatique de l'URL du backend
|
||||
* - Indicateurs de mode (dynamique/statique)
|
||||
* - Gestion cross-machine robuste
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { catalogService } from '../services/catalogService';
|
||||
import {
|
||||
VWBCatalogAction,
|
||||
VWBActionCategory,
|
||||
VWBActionCategoryInfo,
|
||||
VWBCatalogHealth,
|
||||
VWBServiceStatus
|
||||
} from '../types/catalog';
|
||||
|
||||
// Interface pour l'état du catalogue étendu
|
||||
interface CatalogState {
|
||||
actions: VWBCatalogAction[];
|
||||
categories: VWBActionCategoryInfo[];
|
||||
health: VWBCatalogHealth | null;
|
||||
isLoading: boolean;
|
||||
isOnline: boolean;
|
||||
error: string | null;
|
||||
lastUpdate: Date | null;
|
||||
mode: 'dynamic' | 'static' | 'offline'; // Nouveau : mode du catalogue
|
||||
serviceUrl: string | null; // Nouveau : URL du service actuel
|
||||
}
|
||||
|
||||
// Interface pour les options du hook
|
||||
interface UseCatalogActionsOptions {
|
||||
/** Charger automatiquement au montage */
|
||||
autoLoad?: boolean;
|
||||
/** Intervalle de rafraîchissement automatique (en ms) */
|
||||
refreshInterval?: number;
|
||||
/** Catégorie à filtrer */
|
||||
filterCategory?: VWBActionCategory;
|
||||
/** Terme de recherche */
|
||||
searchTerm?: string;
|
||||
}
|
||||
|
||||
// Interface pour le retour du hook étendu
|
||||
interface UseCatalogActionsReturn {
|
||||
/** État actuel du catalogue */
|
||||
state: CatalogState;
|
||||
/** Actions filtrées selon les critères */
|
||||
filteredActions: VWBCatalogAction[];
|
||||
/** Statistiques du catalogue */
|
||||
stats: {
|
||||
totalActions: number;
|
||||
actionsByCategory: Record<VWBActionCategory, number>;
|
||||
averageComplexity: string;
|
||||
onlineStatus: boolean;
|
||||
mode: 'dynamic' | 'static' | 'offline'; // Nouveau
|
||||
serviceUrl: string | null; // Nouveau
|
||||
};
|
||||
/** Actions disponibles */
|
||||
actions: {
|
||||
/** Recharger le catalogue */
|
||||
reload: () => Promise<void>;
|
||||
/** Rechercher des actions */
|
||||
search: (term: string) => VWBCatalogAction[];
|
||||
/** Obtenir une action par ID */
|
||||
getAction: (id: string) => VWBCatalogAction | null;
|
||||
/** Vider le cache */
|
||||
clearCache: () => void;
|
||||
/** Forcer une mise à jour de santé */
|
||||
checkHealth: () => Promise<void>;
|
||||
/** Forcer la détection d'URL (nouveau) */
|
||||
forceUrlDetection: () => Promise<boolean>;
|
||||
/** Réinitialiser complètement le service (nouveau) */
|
||||
resetService: () => Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook pour gérer les actions du catalogue VisionOnly
|
||||
*/
|
||||
export const useCatalogActions = (options: UseCatalogActionsOptions = {}): UseCatalogActionsReturn => {
|
||||
const {
|
||||
autoLoad = true,
|
||||
refreshInterval,
|
||||
filterCategory,
|
||||
searchTerm = '',
|
||||
} = options;
|
||||
|
||||
// État du catalogue étendu
|
||||
const [state, setState] = useState<CatalogState>({
|
||||
actions: [],
|
||||
categories: [],
|
||||
health: null,
|
||||
isLoading: false,
|
||||
isOnline: false,
|
||||
error: null,
|
||||
lastUpdate: null,
|
||||
mode: 'offline',
|
||||
serviceUrl: null,
|
||||
});
|
||||
|
||||
// Charger les données du catalogue avec fallback automatique
|
||||
const loadCatalogData = useCallback(async () => {
|
||||
setState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
console.log('🔄 Chargement du catalogue d\'actions VisionOnly...');
|
||||
|
||||
// Obtenir l'état du service pour connaître le mode
|
||||
const serviceState = catalogService.getServiceState();
|
||||
|
||||
// Charger en parallèle les actions, catégories et santé
|
||||
const [actionsResult, categoriesResult, healthResult] = await Promise.all([
|
||||
catalogService.getActions(filterCategory),
|
||||
catalogService.getCategories(),
|
||||
catalogService.getHealth(),
|
||||
]);
|
||||
|
||||
// Adapter les types retournés par le service aux types VWB
|
||||
const adaptedCategories: VWBActionCategoryInfo[] = categoriesResult.map(cat => ({
|
||||
id: cat.id,
|
||||
name: cat.name,
|
||||
description: cat.description,
|
||||
icon: cat.icon,
|
||||
color: '#2196f3', // Couleur par défaut
|
||||
actionCount: cat.actionCount,
|
||||
isEnabled: true, // Activé par défaut
|
||||
}));
|
||||
|
||||
const adaptedHealth: VWBCatalogHealth = {
|
||||
status: healthResult.status as VWBServiceStatus,
|
||||
services: {
|
||||
screenCapturer: healthResult.services.screenCapturer,
|
||||
actions: healthResult.services.actions,
|
||||
screenCapturerMethod: healthResult.services.screenCapturerMethod,
|
||||
},
|
||||
timestamp: healthResult.timestamp,
|
||||
version: healthResult.version,
|
||||
};
|
||||
|
||||
// Déterminer le mode basé sur les résultats
|
||||
const mode = actionsResult.mode || serviceState.mode;
|
||||
const isOnline = mode === 'dynamic' && adaptedHealth.status === 'healthy';
|
||||
|
||||
setState({
|
||||
actions: actionsResult.actions as VWBCatalogAction[],
|
||||
categories: adaptedCategories,
|
||||
health: adaptedHealth,
|
||||
isLoading: false,
|
||||
isOnline,
|
||||
error: null,
|
||||
lastUpdate: new Date(),
|
||||
mode,
|
||||
serviceUrl: serviceState.currentUrl,
|
||||
});
|
||||
|
||||
const modeEmoji = mode === 'dynamic' ? '🌐' : mode === 'static' ? '📦' : '🔴';
|
||||
console.log(`${modeEmoji} Catalogue chargé (${mode}): ${actionsResult.actions.length} actions, ${adaptedCategories.length} catégories`);
|
||||
|
||||
if (mode === 'static') {
|
||||
console.log('📦 Mode statique activé - Catalogue hors ligne disponible');
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Erreur inconnue';
|
||||
console.error('❌ Erreur lors du chargement du catalogue:', errorMessage);
|
||||
|
||||
// En cas d'erreur, essayer le mode statique
|
||||
const serviceState = catalogService.getServiceState();
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
isOnline: false,
|
||||
error: errorMessage,
|
||||
mode: serviceState.mode || 'offline',
|
||||
serviceUrl: serviceState.currentUrl,
|
||||
}));
|
||||
}
|
||||
}, [filterCategory]);
|
||||
|
||||
// Recharger le catalogue
|
||||
const reload = useCallback(async () => {
|
||||
await loadCatalogData();
|
||||
}, [loadCatalogData]);
|
||||
|
||||
// Vérifier la santé du service
|
||||
const checkHealth = useCallback(async () => {
|
||||
try {
|
||||
const healthResult = await catalogService.getHealth();
|
||||
const adaptedHealth: VWBCatalogHealth = {
|
||||
status: healthResult.status as VWBServiceStatus,
|
||||
services: {
|
||||
screenCapturer: healthResult.services.screenCapturer,
|
||||
actions: healthResult.services.actions,
|
||||
screenCapturerMethod: healthResult.services.screenCapturerMethod,
|
||||
},
|
||||
timestamp: healthResult.timestamp,
|
||||
version: healthResult.version,
|
||||
};
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
health: adaptedHealth,
|
||||
isOnline: adaptedHealth.status === 'healthy',
|
||||
error: adaptedHealth.status === 'healthy' ? null : prev.error,
|
||||
}));
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isOnline: false,
|
||||
error: error instanceof Error ? error.message : 'Erreur de santé',
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Rechercher des actions
|
||||
const search = useCallback((term: string): VWBCatalogAction[] => {
|
||||
if (!term.trim()) return state.actions;
|
||||
|
||||
const searchLower = term.toLowerCase();
|
||||
return state.actions.filter(action =>
|
||||
action.name.toLowerCase().includes(searchLower) ||
|
||||
action.description.toLowerCase().includes(searchLower) ||
|
||||
action.id.toLowerCase().includes(searchLower) ||
|
||||
Object.keys(action.parameters).some(param =>
|
||||
param.toLowerCase().includes(searchLower)
|
||||
)
|
||||
);
|
||||
}, [state.actions]);
|
||||
|
||||
// Obtenir une action par ID
|
||||
const getAction = useCallback((id: string): VWBCatalogAction | null => {
|
||||
return state.actions.find(action => action.id === id) || null;
|
||||
}, [state.actions]);
|
||||
|
||||
// Vider le cache
|
||||
const clearCache = useCallback(() => {
|
||||
catalogService.clearCache();
|
||||
console.log('🗑️ Cache du catalogue vidé');
|
||||
}, []);
|
||||
|
||||
// Forcer la détection d'URL (nouveau)
|
||||
const forceUrlDetection = useCallback(async (): Promise<boolean> => {
|
||||
console.log('🔄 Détection forcée de l\'URL du backend...');
|
||||
|
||||
try {
|
||||
const success = await catalogService.forceUrlDetection();
|
||||
|
||||
if (success) {
|
||||
// Recharger le catalogue après détection réussie
|
||||
await loadCatalogData();
|
||||
console.log('✅ Détection d\'URL réussie, catalogue rechargé');
|
||||
} else {
|
||||
console.log('❌ Aucun backend accessible lors de la détection forcée');
|
||||
}
|
||||
|
||||
return success;
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la détection forcée d\'URL:', error);
|
||||
return false;
|
||||
}
|
||||
}, [loadCatalogData]);
|
||||
|
||||
// Réinitialiser complètement le service (nouveau)
|
||||
const resetService = useCallback(async (): Promise<void> => {
|
||||
console.log('🔄 Réinitialisation complète du service catalogue...');
|
||||
|
||||
try {
|
||||
await catalogService.reset();
|
||||
|
||||
// Recharger le catalogue après réinitialisation
|
||||
await loadCatalogData();
|
||||
|
||||
console.log('✅ Service catalogue réinitialisé et rechargé');
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la réinitialisation du service:', error);
|
||||
|
||||
// Mettre à jour l'état avec l'erreur
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: error instanceof Error ? error.message : 'Erreur de réinitialisation',
|
||||
isLoading: false,
|
||||
}));
|
||||
}
|
||||
}, [loadCatalogData]);
|
||||
|
||||
// Actions filtrées selon les critères
|
||||
const filteredActions = useMemo(() => {
|
||||
let filtered = state.actions;
|
||||
|
||||
// Filtrer par catégorie si spécifiée
|
||||
if (filterCategory) {
|
||||
filtered = filtered.filter(action => action.category === filterCategory);
|
||||
}
|
||||
|
||||
// Filtrer par terme de recherche
|
||||
if (searchTerm.trim()) {
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
filtered = filtered.filter(action =>
|
||||
action.name.toLowerCase().includes(searchLower) ||
|
||||
action.description.toLowerCase().includes(searchLower) ||
|
||||
action.id.toLowerCase().includes(searchLower)
|
||||
);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [state.actions, filterCategory, searchTerm]);
|
||||
|
||||
// Statistiques du catalogue
|
||||
const stats = useMemo(() => {
|
||||
const actionsByCategory = state.actions.reduce((acc, action) => {
|
||||
acc[action.category] = (acc[action.category] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<VWBActionCategory, number>);
|
||||
|
||||
// Calculer la complexité moyenne
|
||||
const complexities = state.actions
|
||||
.map(action => action.metadata?.complexity)
|
||||
.filter(Boolean);
|
||||
|
||||
const complexityScores = { simple: 1, intermediate: 2, advanced: 3 };
|
||||
const avgScore = complexities.length > 0
|
||||
? complexities.reduce((sum, complexity) =>
|
||||
sum + (complexityScores[complexity as keyof typeof complexityScores] || 1), 0
|
||||
) / complexities.length
|
||||
: 1;
|
||||
|
||||
const averageComplexity = avgScore <= 1.5 ? 'simple' : avgScore <= 2.5 ? 'intermediate' : 'advanced';
|
||||
|
||||
return {
|
||||
totalActions: state.actions.length,
|
||||
actionsByCategory,
|
||||
averageComplexity,
|
||||
onlineStatus: state.isOnline,
|
||||
mode: state.mode,
|
||||
serviceUrl: state.serviceUrl,
|
||||
};
|
||||
}, [state.actions, state.isOnline, state.mode, state.serviceUrl]);
|
||||
|
||||
// Chargement automatique au montage
|
||||
useEffect(() => {
|
||||
if (autoLoad) {
|
||||
loadCatalogData();
|
||||
}
|
||||
}, [autoLoad, loadCatalogData]);
|
||||
|
||||
// Rafraîchissement automatique
|
||||
useEffect(() => {
|
||||
if (!refreshInterval || refreshInterval <= 0) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (state.isOnline) {
|
||||
checkHealth();
|
||||
}
|
||||
}, refreshInterval);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [refreshInterval, state.isOnline, checkHealth]);
|
||||
|
||||
// Recharger quand la catégorie de filtre change
|
||||
useEffect(() => {
|
||||
if (filterCategory && state.actions.length > 0) {
|
||||
// Pas besoin de recharger, juste filtrer
|
||||
return;
|
||||
}
|
||||
}, [filterCategory, state.actions.length]);
|
||||
|
||||
return {
|
||||
state,
|
||||
filteredActions,
|
||||
stats,
|
||||
actions: {
|
||||
reload,
|
||||
search,
|
||||
getAction,
|
||||
clearCache,
|
||||
checkHealth,
|
||||
forceUrlDetection,
|
||||
resetService,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// Hook simplifié pour obtenir juste les actions
|
||||
export const useCatalogActionsSimple = (category?: VWBActionCategory) => {
|
||||
const { state, filteredActions, actions } = useCatalogActions({
|
||||
filterCategory: category,
|
||||
});
|
||||
|
||||
return {
|
||||
actions: filteredActions,
|
||||
isLoading: state.isLoading,
|
||||
isOnline: state.isOnline,
|
||||
error: state.error,
|
||||
reload: actions.reload,
|
||||
};
|
||||
};
|
||||
|
||||
// Hook pour obtenir une action spécifique
|
||||
export const useCatalogAction = (actionId: string) => {
|
||||
const { state, actions } = useCatalogActions();
|
||||
|
||||
const action = useMemo(() => {
|
||||
return actions.getAction(actionId);
|
||||
}, [actions, actionId]);
|
||||
|
||||
return {
|
||||
action,
|
||||
isLoading: state.isLoading,
|
||||
error: state.error,
|
||||
reload: actions.reload,
|
||||
};
|
||||
};
|
||||
|
||||
export default useCatalogActions;
|
||||
@@ -77,10 +77,36 @@ const initialStats: CoachingStats = {
|
||||
correctionRate: 0,
|
||||
};
|
||||
|
||||
// SINGLETON: Socket partagé entre toutes les instances du hook
|
||||
// Évite les connexions multiples quand le composant est monté/démonté
|
||||
let sharedSocket: Socket | null = null;
|
||||
let socketRefCount = 0;
|
||||
const socketListeners = new Set<{
|
||||
setIsConnected: (v: boolean) => void;
|
||||
setIsSubscribed: (v: boolean) => void;
|
||||
setCurrentSuggestion: (v: CoachingSuggestion | null) => void;
|
||||
setStats: (v: CoachingStats) => void;
|
||||
setLastActionResult: (v: CoachingActionResult | null) => void;
|
||||
setError: (v: string | null) => void;
|
||||
}>();
|
||||
|
||||
// Convert backend stats format to frontend format (moved outside hook)
|
||||
const convertStats = (backendStats: Record<string, any>): CoachingStats => {
|
||||
return {
|
||||
suggestionsMade: backendStats.suggestions_made || 0,
|
||||
accepted: backendStats.accepted || 0,
|
||||
rejected: backendStats.rejected || 0,
|
||||
corrected: backendStats.corrected || 0,
|
||||
manualExecutions: backendStats.manual_executions || 0,
|
||||
acceptanceRate: backendStats.acceptance_rate || 0,
|
||||
correctionRate: backendStats.correction_rate || 0,
|
||||
};
|
||||
};
|
||||
|
||||
export function useCoachingWebSocket(
|
||||
options: UseCoachingWebSocketOptions = {}
|
||||
): UseCoachingWebSocketReturn {
|
||||
const { serverUrl = 'http://localhost:5000', autoConnect = true } = options;
|
||||
const { serverUrl = 'http://localhost:5001', autoConnect = true } = options;
|
||||
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isSubscribed, setIsSubscribed] = useState(false);
|
||||
@@ -89,13 +115,29 @@ export function useCoachingWebSocket(
|
||||
const [lastActionResult, setLastActionResult] = useState<CoachingActionResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
const executionIdRef = useRef<string | null>(null);
|
||||
const listenerRef = useRef({ setIsConnected, setIsSubscribed, setCurrentSuggestion, setStats, setLastActionResult, setError });
|
||||
|
||||
// Initialize socket connection
|
||||
// Mettre à jour la ref avec les setters actuels
|
||||
useEffect(() => {
|
||||
listenerRef.current = { setIsConnected, setIsSubscribed, setCurrentSuggestion, setStats, setLastActionResult, setError };
|
||||
});
|
||||
|
||||
// Initialize socket connection (SINGLETON)
|
||||
useEffect(() => {
|
||||
if (!autoConnect) return;
|
||||
|
||||
// Ajouter ce listener à l'ensemble
|
||||
socketListeners.add(listenerRef.current);
|
||||
socketRefCount++;
|
||||
|
||||
// Si le socket existe déjà, mettre à jour l'état local
|
||||
if (sharedSocket) {
|
||||
setIsConnected(sharedSocket.connected);
|
||||
return;
|
||||
}
|
||||
|
||||
// Créer le socket partagé
|
||||
const socket = io(serverUrl, {
|
||||
transports: ['websocket', 'polling'],
|
||||
reconnection: true,
|
||||
@@ -104,40 +146,40 @@ export function useCoachingWebSocket(
|
||||
});
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('[COACHING WS] Connected');
|
||||
setIsConnected(true);
|
||||
setError(null);
|
||||
console.log('[COACHING WS] Connected (shared)');
|
||||
socketListeners.forEach(l => l.setIsConnected(true));
|
||||
socketListeners.forEach(l => l.setError(null));
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log('[COACHING WS] Disconnected');
|
||||
setIsConnected(false);
|
||||
setIsSubscribed(false);
|
||||
console.log('[COACHING WS] Disconnected (shared)');
|
||||
socketListeners.forEach(l => l.setIsConnected(false));
|
||||
socketListeners.forEach(l => l.setIsSubscribed(false));
|
||||
});
|
||||
|
||||
socket.on('connect_error', (err) => {
|
||||
console.error('[COACHING WS] Connection error:', err);
|
||||
setError(`Connection error: ${err.message}`);
|
||||
socketListeners.forEach(l => l.setError(`Connection error: ${err.message}`));
|
||||
});
|
||||
|
||||
// COACHING specific events
|
||||
socket.on('coaching_subscribed', (data) => {
|
||||
console.log('[COACHING WS] Subscribed:', data);
|
||||
setIsSubscribed(true);
|
||||
socketListeners.forEach(l => l.setIsSubscribed(true));
|
||||
if (data.stats) {
|
||||
setStats(convertStats(data.stats));
|
||||
socketListeners.forEach(l => l.setStats(convertStats(data.stats)));
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('coaching_unsubscribed', () => {
|
||||
console.log('[COACHING WS] Unsubscribed');
|
||||
setIsSubscribed(false);
|
||||
setCurrentSuggestion(null);
|
||||
socketListeners.forEach(l => l.setIsSubscribed(false));
|
||||
socketListeners.forEach(l => l.setCurrentSuggestion(null));
|
||||
});
|
||||
|
||||
socket.on('coaching_suggestion', (data: any) => {
|
||||
console.log('[COACHING WS] Suggestion received:', data);
|
||||
setCurrentSuggestion({
|
||||
const suggestion: CoachingSuggestion = {
|
||||
executionId: data.execution_id,
|
||||
action: data.action,
|
||||
target: data.target || {},
|
||||
@@ -147,27 +189,28 @@ export function useCoachingWebSocket(
|
||||
screenshotPath: data.screenshot_path,
|
||||
context: data.context,
|
||||
timestamp: data.timestamp,
|
||||
});
|
||||
};
|
||||
socketListeners.forEach(l => l.setCurrentSuggestion(suggestion));
|
||||
});
|
||||
|
||||
socket.on('coaching_action_result', (data: any) => {
|
||||
console.log('[COACHING WS] Action result:', data);
|
||||
setLastActionResult({
|
||||
const result: CoachingActionResult = {
|
||||
executionId: data.execution_id,
|
||||
action: data.action,
|
||||
success: data.success,
|
||||
result: data.result,
|
||||
error: data.error,
|
||||
timestamp: data.timestamp,
|
||||
});
|
||||
// Clear current suggestion after result
|
||||
setCurrentSuggestion(null);
|
||||
};
|
||||
socketListeners.forEach(l => l.setLastActionResult(result));
|
||||
socketListeners.forEach(l => l.setCurrentSuggestion(null));
|
||||
});
|
||||
|
||||
socket.on('coaching_stats_update', (data: any) => {
|
||||
console.log('[COACHING WS] Stats update:', data);
|
||||
if (data.stats) {
|
||||
setStats(convertStats(data.stats));
|
||||
socketListeners.forEach(l => l.setStats(convertStats(data.stats)));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -181,55 +224,49 @@ export function useCoachingWebSocket(
|
||||
|
||||
socket.on('coaching_session_end', (data: any) => {
|
||||
console.log('[COACHING WS] Session ended:', data);
|
||||
setIsSubscribed(false);
|
||||
setCurrentSuggestion(null);
|
||||
socketListeners.forEach(l => l.setIsSubscribed(false));
|
||||
socketListeners.forEach(l => l.setCurrentSuggestion(null));
|
||||
if (data.stats) {
|
||||
setStats(convertStats(data.stats));
|
||||
socketListeners.forEach(l => l.setStats(convertStats(data.stats)));
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('error', (data) => {
|
||||
console.error('[COACHING WS] Error:', data);
|
||||
setError(data.message || 'Unknown error');
|
||||
socketListeners.forEach(l => l.setError(data.message || 'Unknown error'));
|
||||
});
|
||||
|
||||
socketRef.current = socket;
|
||||
sharedSocket = socket;
|
||||
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
socketRef.current = null;
|
||||
socketListeners.delete(listenerRef.current);
|
||||
socketRefCount--;
|
||||
|
||||
// Déconnecter seulement si plus aucun composant n'utilise le socket
|
||||
if (socketRefCount === 0 && sharedSocket) {
|
||||
console.log('[COACHING WS] Disconnecting shared socket (no more refs)');
|
||||
sharedSocket.disconnect();
|
||||
sharedSocket = null;
|
||||
}
|
||||
};
|
||||
}, [serverUrl, autoConnect]);
|
||||
|
||||
// Convert backend stats format to frontend format
|
||||
const convertStats = (backendStats: Record<string, any>): CoachingStats => {
|
||||
return {
|
||||
suggestionsMade: backendStats.suggestions_made || 0,
|
||||
accepted: backendStats.accepted || 0,
|
||||
rejected: backendStats.rejected || 0,
|
||||
corrected: backendStats.corrected || 0,
|
||||
manualExecutions: backendStats.manual_executions || 0,
|
||||
acceptanceRate: backendStats.acceptance_rate || 0,
|
||||
correctionRate: backendStats.correction_rate || 0,
|
||||
};
|
||||
};
|
||||
|
||||
// Subscribe to COACHING events for an execution
|
||||
const subscribe = useCallback((executionId: string) => {
|
||||
if (!socketRef.current || !isConnected) {
|
||||
if (!sharedSocket || !isConnected) {
|
||||
setError('Not connected to server');
|
||||
return;
|
||||
}
|
||||
|
||||
executionIdRef.current = executionId;
|
||||
socketRef.current.emit('subscribe_coaching', { execution_id: executionId });
|
||||
sharedSocket.emit('subscribe_coaching', { execution_id: executionId });
|
||||
}, [isConnected]);
|
||||
|
||||
// Unsubscribe from COACHING events
|
||||
const unsubscribe = useCallback(() => {
|
||||
if (!socketRef.current || !executionIdRef.current) return;
|
||||
if (!sharedSocket || !executionIdRef.current) return;
|
||||
|
||||
socketRef.current.emit('unsubscribe_coaching', {
|
||||
sharedSocket.emit('unsubscribe_coaching', {
|
||||
execution_id: executionIdRef.current,
|
||||
});
|
||||
executionIdRef.current = null;
|
||||
@@ -238,12 +275,12 @@ export function useCoachingWebSocket(
|
||||
// Submit a COACHING decision
|
||||
const submitDecision = useCallback(
|
||||
(decision: CoachingDecision, correction?: Record<string, any>, feedback?: string) => {
|
||||
if (!socketRef.current || !executionIdRef.current) {
|
||||
if (!sharedSocket || !executionIdRef.current) {
|
||||
setError('Not subscribed to any execution');
|
||||
return;
|
||||
}
|
||||
|
||||
socketRef.current.emit('coaching_decision', {
|
||||
sharedSocket.emit('coaching_decision', {
|
||||
execution_id: executionIdRef.current,
|
||||
decision,
|
||||
correction,
|
||||
@@ -255,9 +292,9 @@ export function useCoachingWebSocket(
|
||||
|
||||
// Refresh stats
|
||||
const refreshStats = useCallback(() => {
|
||||
if (!socketRef.current || !executionIdRef.current) return;
|
||||
if (!sharedSocket || !executionIdRef.current) return;
|
||||
|
||||
socketRef.current.emit('get_coaching_stats', {
|
||||
sharedSocket.emit('get_coaching_stats', {
|
||||
execution_id: executionIdRef.current,
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Hook État de Connexion - Gestion stable de l'état de connexion API
|
||||
* Auteur : Dom, Alice, Kiro - 09 janvier 2026
|
||||
*
|
||||
* Ce hook fournit un état de connexion stable qui évite les re-rendus
|
||||
* excessifs et les "sauts" de page lors des vérifications de connexion.
|
||||
*
|
||||
* IMPORTANT: L'état initial est 'offline' pour éviter les appels API
|
||||
* automatiques au montage des composants. Utilisez forceCheck() pour
|
||||
* vérifier manuellement la connexion si nécessaire.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { apiClient, ConnectionState } from '../services/apiClient';
|
||||
|
||||
interface ConnectionStatusState {
|
||||
/** État actuel de la connexion */
|
||||
status: ConnectionState;
|
||||
/** Indique si l'API est en ligne */
|
||||
isOnline: boolean;
|
||||
/** Indique si l'API est hors ligne */
|
||||
isOffline: boolean;
|
||||
/** Indique si une vérification est en cours */
|
||||
isChecking: boolean;
|
||||
/** Dernière vérification réussie */
|
||||
lastOnlineAt: Date | null;
|
||||
/** Message d'état pour l'affichage */
|
||||
statusMessage: string;
|
||||
}
|
||||
|
||||
interface UseConnectionStatusOptions {
|
||||
/** Afficher les logs de debug */
|
||||
debug?: boolean;
|
||||
/** Callback appelé quand l'état change */
|
||||
onStatusChange?: (status: ConnectionState) => void;
|
||||
}
|
||||
|
||||
// Fonction pour obtenir le message d'état (définie en dehors du hook pour éviter les re-créations)
|
||||
function getStatusMessage(status: ConnectionState): string {
|
||||
switch (status) {
|
||||
case 'online':
|
||||
return 'API connectée';
|
||||
case 'offline':
|
||||
return 'API hors ligne - Mode local activé';
|
||||
case 'checking':
|
||||
return 'Vérification de la connexion...';
|
||||
default:
|
||||
return 'État inconnu';
|
||||
}
|
||||
}
|
||||
|
||||
// État initial stable (défini en dehors du hook pour éviter les re-créations)
|
||||
// TEMPORAIRE: Changé à 'online' pour debug
|
||||
const INITIAL_STATE: ConnectionStatusState = {
|
||||
status: 'online',
|
||||
isOnline: true,
|
||||
isOffline: false,
|
||||
isChecking: false,
|
||||
lastOnlineAt: new Date(),
|
||||
statusMessage: 'API connectée',
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook pour surveiller l'état de connexion de l'API de manière stable
|
||||
*
|
||||
* ARCHITECTURE:
|
||||
* - État initial: 'offline' (pas de vérification automatique)
|
||||
* - Les changements d'état sont notifiés de manière asynchrone
|
||||
* - Utilise useRef pour éviter les re-renders inutiles
|
||||
*/
|
||||
export function useConnectionStatus(options: UseConnectionStatusOptions = {}): ConnectionStatusState & {
|
||||
forceCheck: () => Promise<boolean>;
|
||||
} {
|
||||
const { debug = false, onStatusChange } = options;
|
||||
|
||||
// État initial stable - toujours 'offline' pour éviter les appels au montage
|
||||
const [state, setState] = useState<ConnectionStatusState>(INITIAL_STATE);
|
||||
|
||||
// Référence pour éviter les mises à jour après démontage
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
// Référence pour le callback onStatusChange (évite les re-renders)
|
||||
const onStatusChangeRef = useRef(onStatusChange);
|
||||
onStatusChangeRef.current = onStatusChange;
|
||||
|
||||
// Référence pour le debug (évite les re-renders)
|
||||
const debugRef = useRef(debug);
|
||||
debugRef.current = debug;
|
||||
|
||||
// Gestionnaire de changement d'état (stable grâce aux refs)
|
||||
const handleStatusChange = useCallback((newStatus: ConnectionState) => {
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
if (debugRef.current) {
|
||||
console.log(`[ConnectionStatus] État changé: ${newStatus}`);
|
||||
}
|
||||
|
||||
setState(prev => {
|
||||
// Éviter les mises à jour inutiles si l'état n'a pas changé
|
||||
if (prev.status === newStatus) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const newState: ConnectionStatusState = {
|
||||
status: newStatus,
|
||||
isOnline: newStatus === 'online',
|
||||
isOffline: newStatus === 'offline',
|
||||
isChecking: newStatus === 'checking',
|
||||
lastOnlineAt: newStatus === 'online' ? new Date() : prev.lastOnlineAt,
|
||||
statusMessage: getStatusMessage(newStatus),
|
||||
};
|
||||
|
||||
return newState;
|
||||
});
|
||||
|
||||
// Appeler le callback si fourni (de manière asynchrone pour éviter les boucles)
|
||||
if (onStatusChangeRef.current) {
|
||||
setTimeout(() => {
|
||||
if (isMountedRef.current && onStatusChangeRef.current) {
|
||||
onStatusChangeRef.current(newStatus);
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
}, []); // Pas de dépendances - utilise des refs
|
||||
|
||||
// Vérification directe au montage (SANS abonnement au singleton)
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
|
||||
// Vérification DIRECTE au démarrage
|
||||
const checkOnMount = async () => {
|
||||
try {
|
||||
const response = await fetch('http://localhost:5001/api/health', {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
if (isMountedRef.current) {
|
||||
handleStatusChange('online');
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignorer - on garde l'état initial
|
||||
}
|
||||
|
||||
// Seulement si vraiment impossible de contacter l'API
|
||||
if (isMountedRef.current) {
|
||||
handleStatusChange('offline');
|
||||
}
|
||||
};
|
||||
checkOnMount();
|
||||
|
||||
// NE PAS s'abonner au singleton - cela cause des conflits d'état
|
||||
|
||||
// Nettoyage
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, [handleStatusChange]);
|
||||
|
||||
// Fonction pour forcer une vérification de connexion
|
||||
const forceCheck = useCallback(async (): Promise<boolean> => {
|
||||
if (debugRef.current) {
|
||||
console.log('[ConnectionStatus] Vérification forcée...');
|
||||
}
|
||||
|
||||
return apiClient.forceConnectionCheck();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...state,
|
||||
forceCheck,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook simplifié qui retourne juste un booléen pour l'état en ligne
|
||||
* Utile pour les composants qui n'ont besoin que de savoir si l'API est disponible
|
||||
*/
|
||||
export function useIsApiOnline(): boolean {
|
||||
const { isOnline } = useConnectionStatus();
|
||||
return isOnline;
|
||||
}
|
||||
|
||||
export default useConnectionStatus;
|
||||
@@ -68,7 +68,7 @@ interface UseCorrectionPacksReturn {
|
||||
selectPack: (pack: CorrectionPack | null) => void;
|
||||
}
|
||||
|
||||
const API_BASE = 'http://localhost:5000/api';
|
||||
const API_BASE = 'http://localhost:5001/api';
|
||||
|
||||
export function useCorrectionPacks(): UseCorrectionPacksReturn {
|
||||
const [packs, setPacks] = useState<CorrectionPack[]>([]);
|
||||
|
||||
262
visual_workflow_builder/frontend/src/hooks/useDebounce.ts
Normal file
262
visual_workflow_builder/frontend/src/hooks/useDebounce.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* Hook de Debouncing - Optimisation des performances pour les opérations coûteuses
|
||||
* Auteur : Dom, Alice, Kiro - 08 janvier 2026
|
||||
*
|
||||
* Ce hook retarde l'exécution d'une valeur ou fonction jusqu'à ce qu'un délai
|
||||
* soit écoulé sans nouvelle modification, optimisant les performances.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* Hook de debouncing pour les valeurs
|
||||
*
|
||||
* @param value - Valeur à débouncer
|
||||
* @param delay - Délai en millisecondes (défaut: 300ms)
|
||||
* @returns Valeur débouncée
|
||||
*/
|
||||
export function useDebounce<T>(value: T, delay: number = 300): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
// Créer un timer qui met à jour la valeur débouncée après le délai
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
// Nettoyer le timer si la valeur change avant la fin du délai
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook de debouncing pour les fonctions callback
|
||||
*
|
||||
* @param callback - Fonction à débouncer
|
||||
* @param delay - Délai en millisecondes (défaut: 300ms)
|
||||
* @param deps - Dépendances du callback
|
||||
* @returns Fonction débouncée
|
||||
*/
|
||||
export function useDebouncedCallback<T extends (...args: any[]) => any>(
|
||||
callback: T,
|
||||
delay: number = 300,
|
||||
deps: React.DependencyList = []
|
||||
): T {
|
||||
const timeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
|
||||
|
||||
const debouncedCallback = useCallback(
|
||||
(...args: Parameters<T>) => {
|
||||
// Annuler le timer précédent s'il existe
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
// Créer un nouveau timer
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
callback(...args);
|
||||
}, delay);
|
||||
},
|
||||
[callback, delay, ...deps]
|
||||
) as T;
|
||||
|
||||
// Nettoyer le timer lors du démontage du composant
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return debouncedCallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook de throttling pour limiter la fréquence d'exécution
|
||||
*
|
||||
* @param callback - Fonction à throttler
|
||||
* @param delay - Délai minimum entre les exécutions (défaut: 100ms)
|
||||
* @param deps - Dépendances du callback
|
||||
* @returns Fonction throttlée
|
||||
*/
|
||||
export function useThrottledCallback<T extends (...args: any[]) => any>(
|
||||
callback: T,
|
||||
delay: number = 100,
|
||||
deps: React.DependencyList = []
|
||||
): T {
|
||||
const lastExecutedRef = useRef<number>(0);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
|
||||
|
||||
const throttledCallback = useCallback(
|
||||
(...args: Parameters<T>) => {
|
||||
const now = Date.now();
|
||||
const timeSinceLastExecution = now - lastExecutedRef.current;
|
||||
|
||||
if (timeSinceLastExecution >= delay) {
|
||||
// Exécuter immédiatement si assez de temps s'est écoulé
|
||||
lastExecutedRef.current = now;
|
||||
callback(...args);
|
||||
} else {
|
||||
// Programmer l'exécution pour plus tard
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
lastExecutedRef.current = Date.now();
|
||||
callback(...args);
|
||||
}, delay - timeSinceLastExecution);
|
||||
}
|
||||
},
|
||||
[callback, delay, ...deps]
|
||||
) as T;
|
||||
|
||||
// Nettoyer le timer lors du démontage du composant
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return throttledCallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook de debouncing avec état de chargement
|
||||
*
|
||||
* @param asyncCallback - Fonction async à débouncer
|
||||
* @param delay - Délai en millisecondes (défaut: 300ms)
|
||||
* @param deps - Dépendances du callback
|
||||
* @returns Objet avec la fonction débouncée et l'état de chargement
|
||||
*/
|
||||
export function useDebouncedAsyncCallback<T extends (...args: any[]) => Promise<any>>(
|
||||
asyncCallback: T,
|
||||
delay: number = 300,
|
||||
deps: React.DependencyList = []
|
||||
): {
|
||||
debouncedCallback: T;
|
||||
isLoading: boolean;
|
||||
cancel: () => void;
|
||||
} {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
|
||||
const currentPromiseRef = useRef<Promise<any> | undefined>(undefined);
|
||||
|
||||
const debouncedCallback = useCallback(
|
||||
async (...args: Parameters<T>) => {
|
||||
// Annuler le timer précédent s'il existe
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
// Créer un nouveau timer
|
||||
return new Promise<Awaited<ReturnType<T>>>((resolve, reject) => {
|
||||
timeoutRef.current = setTimeout(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const promise = asyncCallback(...args);
|
||||
currentPromiseRef.current = promise;
|
||||
const result = await promise;
|
||||
|
||||
// Vérifier si cette promesse est toujours la plus récente
|
||||
if (currentPromiseRef.current === promise) {
|
||||
setIsLoading(false);
|
||||
resolve(result);
|
||||
}
|
||||
} catch (error) {
|
||||
setIsLoading(false);
|
||||
reject(error);
|
||||
}
|
||||
}, delay);
|
||||
});
|
||||
},
|
||||
[asyncCallback, delay, ...deps]
|
||||
) as T;
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
// Nettoyer lors du démontage du composant
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cancel();
|
||||
};
|
||||
}, [cancel]);
|
||||
|
||||
return {
|
||||
debouncedCallback,
|
||||
isLoading,
|
||||
cancel,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook pour débouncer les recherches avec gestion d'état
|
||||
*
|
||||
* @param searchFunction - Fonction de recherche async
|
||||
* @param delay - Délai de debouncing (défaut: 300ms)
|
||||
* @returns Objet avec les fonctions et états de recherche
|
||||
*/
|
||||
export function useDebouncedSearch<T>(
|
||||
searchFunction: (query: string) => Promise<T[]>,
|
||||
delay: number = 300
|
||||
) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<T[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const debouncedQuery = useDebounce(query, delay);
|
||||
|
||||
const performSearch = useCallback(async (searchQuery: string) => {
|
||||
if (!searchQuery.trim()) {
|
||||
setResults([]);
|
||||
setIsSearching(false);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSearching(true);
|
||||
setError(null);
|
||||
const searchResults = await searchFunction(searchQuery);
|
||||
setResults(searchResults);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Erreur de recherche');
|
||||
setResults([]);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
}, [searchFunction]);
|
||||
|
||||
// Effectuer la recherche quand la query débouncée change
|
||||
useEffect(() => {
|
||||
performSearch(debouncedQuery);
|
||||
}, [debouncedQuery, performSearch]);
|
||||
|
||||
const clearSearch = useCallback(() => {
|
||||
setQuery('');
|
||||
setResults([]);
|
||||
setError(null);
|
||||
setIsSearching(false);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
query,
|
||||
setQuery,
|
||||
results,
|
||||
isSearching,
|
||||
error,
|
||||
clearSearch,
|
||||
};
|
||||
}
|
||||
259
visual_workflow_builder/frontend/src/hooks/useEvidenceViewer.ts
Normal file
259
visual_workflow_builder/frontend/src/hooks/useEvidenceViewer.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Hook personnalisé pour la gestion de l'Evidence Viewer VWB
|
||||
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { VWBEvidence, EvidenceFilters, EvidenceStats, EvidenceUtils } from '../types/evidence';
|
||||
import { evidenceService } from '../services/evidenceService';
|
||||
|
||||
interface UseEvidenceViewerOptions {
|
||||
workflowId?: string;
|
||||
autoRefresh?: boolean;
|
||||
refreshInterval?: number;
|
||||
initialFilters?: Partial<EvidenceFilters>;
|
||||
}
|
||||
|
||||
interface UseEvidenceViewerReturn {
|
||||
// État des données
|
||||
evidences: VWBEvidence[];
|
||||
filteredEvidences: VWBEvidence[];
|
||||
selectedEvidence: VWBEvidence | null;
|
||||
stats: EvidenceStats;
|
||||
|
||||
// État de l'interface
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
filters: EvidenceFilters;
|
||||
sortBy: string;
|
||||
sortOrder: 'asc' | 'desc';
|
||||
|
||||
// Actions
|
||||
setSelectedEvidenceId: (id: string | null) => void;
|
||||
setFilters: (filters: Partial<EvidenceFilters>) => void;
|
||||
setSorting: (sortBy: string, sortOrder?: 'asc' | 'desc') => void;
|
||||
refreshEvidences: () => Promise<void>;
|
||||
clearFilters: () => void;
|
||||
exportEvidences: (format: 'json' | 'html' | 'pdf') => Promise<void>;
|
||||
|
||||
// Utilitaires
|
||||
getEvidenceById: (id: string) => VWBEvidence | undefined;
|
||||
hasFilters: boolean;
|
||||
isServiceAvailable: boolean;
|
||||
}
|
||||
|
||||
const defaultFilters: EvidenceFilters = {
|
||||
actionTypes: [],
|
||||
status: 'all',
|
||||
dateRange: {},
|
||||
searchText: '',
|
||||
confidenceRange: { min: 0, max: 1 },
|
||||
executionTimeRange: { min: 0, max: 60000 }
|
||||
};
|
||||
|
||||
export const useEvidenceViewer = (options: UseEvidenceViewerOptions = {}): UseEvidenceViewerReturn => {
|
||||
const {
|
||||
workflowId,
|
||||
autoRefresh = false,
|
||||
refreshInterval = 30000,
|
||||
initialFilters = {}
|
||||
} = options;
|
||||
|
||||
// État des données
|
||||
const [evidences, setEvidences] = useState<VWBEvidence[]>([]);
|
||||
const [selectedEvidenceId, setSelectedEvidenceId] = useState<string | null>(null);
|
||||
|
||||
// État de l'interface
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filters, setFiltersState] = useState<EvidenceFilters>({
|
||||
...defaultFilters,
|
||||
...initialFilters
|
||||
});
|
||||
const [sortBy, setSortBy] = useState('date');
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||||
const [isServiceAvailable, setIsServiceAvailable] = useState(true);
|
||||
|
||||
// Cache et timeout
|
||||
const cache = useMemo(() => new Map<string, VWBEvidence[]>(), []);
|
||||
const cacheTimeout = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
// Evidence filtrées et triées
|
||||
const filteredEvidences = useMemo(() => {
|
||||
let filtered = EvidenceUtils.filterEvidences(evidences, filters);
|
||||
filtered = EvidenceUtils.sortEvidences(filtered, sortBy, sortOrder);
|
||||
return filtered;
|
||||
}, [evidences, filters, sortBy, sortOrder]);
|
||||
|
||||
// Evidence sélectionnée
|
||||
const selectedEvidence = useMemo(() => {
|
||||
return selectedEvidenceId ? evidences.find(e => e.id === selectedEvidenceId) || null : null;
|
||||
}, [evidences, selectedEvidenceId]);
|
||||
|
||||
// Statistiques
|
||||
const stats = useMemo(() => {
|
||||
return EvidenceUtils.calculateStats(filteredEvidences);
|
||||
}, [filteredEvidences]);
|
||||
|
||||
// Vérification si des filtres sont appliqués
|
||||
const hasFilters = useMemo(() => {
|
||||
return (
|
||||
filters.actionTypes.length > 0 ||
|
||||
filters.status !== 'all' ||
|
||||
filters.searchText.trim() !== '' ||
|
||||
filters.dateRange.start !== undefined ||
|
||||
filters.dateRange.end !== undefined ||
|
||||
filters.confidenceRange.min > 0 ||
|
||||
filters.confidenceRange.max < 1 ||
|
||||
filters.executionTimeRange.min > 0 ||
|
||||
filters.executionTimeRange.max < 60000
|
||||
);
|
||||
}, [filters]);
|
||||
|
||||
// Chargement des Evidence
|
||||
const loadEvidences = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Vérification de la disponibilité du service
|
||||
const serviceHealth = await evidenceService.healthCheck();
|
||||
setIsServiceAvailable(serviceHealth);
|
||||
|
||||
if (!serviceHealth) {
|
||||
setError('Service Evidence non disponible');
|
||||
return;
|
||||
}
|
||||
|
||||
const loadedEvidences = await evidenceService.getEvidences(workflowId);
|
||||
setEvidences(loadedEvidences);
|
||||
|
||||
// Mise en cache des Evidence
|
||||
const cache = new Map();
|
||||
loadedEvidences.forEach(evidence => {
|
||||
cache.set(evidence.id, evidence);
|
||||
});
|
||||
|
||||
// Si une Evidence était sélectionnée et n'existe plus, la désélectionner
|
||||
if (selectedEvidenceId && !loadedEvidences.find(e => e.id === selectedEvidenceId)) {
|
||||
setSelectedEvidenceId(null);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Erreur inconnue';
|
||||
setError(`Erreur lors du chargement des Evidence : ${errorMessage}`);
|
||||
console.error('Erreur useEvidenceViewer.loadEvidences:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [workflowId, selectedEvidenceId]);
|
||||
|
||||
// Actualisation des Evidence
|
||||
const refreshEvidences = useCallback(async () => {
|
||||
evidenceService.clearCache();
|
||||
await loadEvidences();
|
||||
}, [loadEvidences]);
|
||||
|
||||
// Mise à jour des filtres
|
||||
const setFilters = useCallback((newFilters: Partial<EvidenceFilters>) => {
|
||||
setFiltersState(prev => ({ ...prev, ...newFilters }));
|
||||
}, []);
|
||||
|
||||
// Remise à zéro des filtres
|
||||
const clearFilters = useCallback(() => {
|
||||
setFiltersState(defaultFilters);
|
||||
}, []);
|
||||
|
||||
// Mise à jour du tri
|
||||
const setSorting = useCallback((newSortBy: string, newSortOrder: 'asc' | 'desc' = 'desc') => {
|
||||
setSortBy(newSortBy);
|
||||
setSortOrder(newSortOrder);
|
||||
}, []);
|
||||
|
||||
// Export des Evidence
|
||||
const exportEvidences = useCallback(async (format: 'json' | 'html' | 'pdf') => {
|
||||
try {
|
||||
const blob = await evidenceService.exportEvidences(filteredEvidences, {
|
||||
format,
|
||||
includeScreenshots: true,
|
||||
includeMetadata: true,
|
||||
includeErrors: true
|
||||
});
|
||||
|
||||
// Téléchargement du fichier
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `evidence_export_${new Date().toISOString().split('T')[0]}.${format}`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Erreur inconnue';
|
||||
setError(`Erreur lors de l'export : ${errorMessage}`);
|
||||
console.error('Erreur useEvidenceViewer.exportEvidences:', err);
|
||||
}
|
||||
}, [filteredEvidences]);
|
||||
|
||||
// Utilitaire pour récupérer une Evidence par ID
|
||||
const getEvidenceById = useCallback((id: string): VWBEvidence | undefined => {
|
||||
return evidences.find(e => e.id === id);
|
||||
}, [evidences]);
|
||||
|
||||
// Chargement initial
|
||||
useEffect(() => {
|
||||
loadEvidences();
|
||||
}, [loadEvidences]);
|
||||
|
||||
// Auto-refresh
|
||||
useEffect(() => {
|
||||
if (!autoRefresh || refreshInterval <= 0) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (!loading) {
|
||||
refreshEvidences();
|
||||
}
|
||||
}, refreshInterval);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [autoRefresh, refreshInterval, loading, refreshEvidences]);
|
||||
|
||||
// Nettoyage à la désactivation
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
evidenceService.clearCache();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// État des données
|
||||
evidences,
|
||||
filteredEvidences,
|
||||
selectedEvidence,
|
||||
stats,
|
||||
|
||||
// État de l'interface
|
||||
loading,
|
||||
error,
|
||||
filters,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
|
||||
// Actions
|
||||
setSelectedEvidenceId,
|
||||
setFilters,
|
||||
setSorting,
|
||||
refreshEvidences,
|
||||
clearFilters,
|
||||
exportEvidences,
|
||||
|
||||
// Utilitaires
|
||||
getEvidenceById,
|
||||
hasFilters,
|
||||
isServiceAvailable
|
||||
};
|
||||
};
|
||||
|
||||
export default useEvidenceViewer;
|
||||
@@ -0,0 +1,363 @@
|
||||
/**
|
||||
* Hook d'Evidence d'Exécution - Gestion des Evidence pendant l'exécution VWB
|
||||
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
|
||||
*
|
||||
* Ce hook gère la collecte, le stockage et l'organisation des Evidence
|
||||
* générées pendant l'exécution des workflows VWB.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
||||
import { Evidence, Step } from '../types';
|
||||
|
||||
export interface EvidenceStats {
|
||||
total: number;
|
||||
screenshots: number;
|
||||
bySteps: number;
|
||||
byCurrentStep: number;
|
||||
byType: Record<string, number>;
|
||||
latest: Evidence | null;
|
||||
}
|
||||
|
||||
export interface EvidenceByStep {
|
||||
[stepId: string]: Evidence[];
|
||||
}
|
||||
|
||||
export interface UseExecutionEvidenceOptions {
|
||||
maxEvidencePerStep?: number;
|
||||
maxTotalEvidence?: number;
|
||||
autoCleanup?: boolean;
|
||||
persistToStorage?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook principal pour la gestion des Evidence d'exécution
|
||||
*/
|
||||
export const useExecutionEvidence = (
|
||||
currentStepId?: string,
|
||||
options: UseExecutionEvidenceOptions = {}
|
||||
) => {
|
||||
const {
|
||||
maxEvidencePerStep = 50,
|
||||
maxTotalEvidence = 200,
|
||||
autoCleanup = true,
|
||||
persistToStorage = false,
|
||||
} = options;
|
||||
|
||||
// État des Evidence
|
||||
const [evidenceByStep, setEvidenceByStep] = useState<EvidenceByStep>({});
|
||||
const [allEvidence, setAllEvidence] = useState<Evidence[]>([]);
|
||||
|
||||
// Références pour éviter les re-renders
|
||||
const evidenceCountRef = useRef(0);
|
||||
const lastCleanupRef = useRef(Date.now());
|
||||
|
||||
// Charger les Evidence depuis le stockage local si activé
|
||||
useEffect(() => {
|
||||
if (persistToStorage) {
|
||||
try {
|
||||
const stored = localStorage.getItem('vwb_execution_evidence');
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
setEvidenceByStep(parsed.evidenceByStep || {});
|
||||
setAllEvidence(parsed.allEvidence || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Erreur lors du chargement des Evidence:', error);
|
||||
}
|
||||
}
|
||||
}, [persistToStorage]);
|
||||
|
||||
// Sauvegarder les Evidence dans le stockage local
|
||||
const saveToStorage = useCallback(() => {
|
||||
if (persistToStorage) {
|
||||
try {
|
||||
localStorage.setItem('vwb_execution_evidence', JSON.stringify({
|
||||
evidenceByStep,
|
||||
allEvidence,
|
||||
timestamp: Date.now(),
|
||||
}));
|
||||
} catch (error) {
|
||||
console.warn('Erreur lors de la sauvegarde des Evidence:', error);
|
||||
}
|
||||
}
|
||||
}, [evidenceByStep, allEvidence, persistToStorage]);
|
||||
|
||||
// Sauvegarder automatiquement
|
||||
useEffect(() => {
|
||||
saveToStorage();
|
||||
}, [saveToStorage]);
|
||||
|
||||
/**
|
||||
* Ajouter une Evidence pour une étape
|
||||
*/
|
||||
const addEvidence = useCallback((stepId: string, evidence: Evidence) => {
|
||||
// Vérifier les limites
|
||||
if (evidenceCountRef.current >= maxTotalEvidence) {
|
||||
console.warn('Limite maximale d\'Evidence atteinte');
|
||||
return;
|
||||
}
|
||||
|
||||
setEvidenceByStep(prev => {
|
||||
const stepEvidence = prev[stepId] || [];
|
||||
|
||||
// Vérifier la limite par étape
|
||||
if (stepEvidence.length >= maxEvidencePerStep) {
|
||||
// Supprimer la plus ancienne Evidence
|
||||
stepEvidence.shift();
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[stepId]: [...stepEvidence, evidence],
|
||||
};
|
||||
});
|
||||
|
||||
setAllEvidence(prev => {
|
||||
const newEvidence = [...prev, evidence];
|
||||
|
||||
// Vérifier la limite totale
|
||||
if (newEvidence.length > maxTotalEvidence) {
|
||||
// Supprimer les plus anciennes Evidence
|
||||
return newEvidence.slice(-maxTotalEvidence);
|
||||
}
|
||||
|
||||
return newEvidence;
|
||||
});
|
||||
|
||||
evidenceCountRef.current++;
|
||||
}, [maxEvidencePerStep, maxTotalEvidence]);
|
||||
|
||||
/**
|
||||
* Ajouter plusieurs Evidence pour une étape
|
||||
*/
|
||||
const addMultipleEvidence = useCallback((stepId: string, evidenceList: Evidence[]) => {
|
||||
evidenceList.forEach(evidence => addEvidence(stepId, evidence));
|
||||
}, [addEvidence]);
|
||||
|
||||
/**
|
||||
* Supprimer les Evidence d'une étape
|
||||
*/
|
||||
const removeStepEvidence = useCallback((stepId: string) => {
|
||||
setEvidenceByStep(prev => {
|
||||
const { [stepId]: removed, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
|
||||
setAllEvidence(prev =>
|
||||
prev.filter(evidence =>
|
||||
!prev.some(stepEvidence =>
|
||||
evidenceByStep[stepId]?.some(e => e.id === evidence.id)
|
||||
)
|
||||
)
|
||||
);
|
||||
}, [evidenceByStep]);
|
||||
|
||||
/**
|
||||
* Nettoyer toutes les Evidence
|
||||
*/
|
||||
const clearEvidence = useCallback(() => {
|
||||
setEvidenceByStep({});
|
||||
setAllEvidence([]);
|
||||
evidenceCountRef.current = 0;
|
||||
|
||||
if (persistToStorage) {
|
||||
localStorage.removeItem('vwb_execution_evidence');
|
||||
}
|
||||
}, [persistToStorage]);
|
||||
|
||||
/**
|
||||
* Nettoyer automatiquement les anciennes Evidence
|
||||
*/
|
||||
const performAutoCleanup = useCallback(() => {
|
||||
if (!autoCleanup) return;
|
||||
|
||||
const now = Date.now();
|
||||
const cleanupInterval = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
if (now - lastCleanupRef.current < cleanupInterval) return;
|
||||
|
||||
const cutoffTime = now - (30 * 60 * 1000); // 30 minutes
|
||||
|
||||
setAllEvidence(prev =>
|
||||
prev.filter(evidence =>
|
||||
new Date(evidence.captured_at).getTime() > cutoffTime
|
||||
)
|
||||
);
|
||||
|
||||
setEvidenceByStep(prev => {
|
||||
const cleaned: EvidenceByStep = {};
|
||||
|
||||
Object.entries(prev).forEach(([stepId, stepEvidence]) => {
|
||||
const filteredEvidence = stepEvidence.filter(evidence =>
|
||||
new Date(evidence.captured_at).getTime() > cutoffTime
|
||||
);
|
||||
|
||||
if (filteredEvidence.length > 0) {
|
||||
cleaned[stepId] = filteredEvidence;
|
||||
}
|
||||
});
|
||||
|
||||
return cleaned;
|
||||
});
|
||||
|
||||
lastCleanupRef.current = now;
|
||||
}, [autoCleanup]);
|
||||
|
||||
// Nettoyer automatiquement toutes les 5 minutes
|
||||
useEffect(() => {
|
||||
if (autoCleanup) {
|
||||
const interval = setInterval(performAutoCleanup, 5 * 60 * 1000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [autoCleanup, performAutoCleanup]);
|
||||
|
||||
/**
|
||||
* Obtenir les Evidence de l'étape actuelle
|
||||
*/
|
||||
const currentStepEvidence = useMemo(() => {
|
||||
return currentStepId ? (evidenceByStep[currentStepId] || []) : [];
|
||||
}, [evidenceByStep, currentStepId]);
|
||||
|
||||
/**
|
||||
* Obtenir les statistiques des Evidence
|
||||
*/
|
||||
const getEvidenceStats = useCallback((): EvidenceStats => {
|
||||
const byType: Record<string, number> = {};
|
||||
let screenshots = 0;
|
||||
|
||||
allEvidence.forEach(evidence => {
|
||||
byType[evidence.action_id] = (byType[evidence.action_id] || 0) + 1;
|
||||
|
||||
if (evidence.action_id === 'screenshot' || evidence.metadata?.screenshot) {
|
||||
screenshots++;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
total: allEvidence.length,
|
||||
screenshots,
|
||||
bySteps: Object.keys(evidenceByStep).length,
|
||||
byCurrentStep: currentStepEvidence.length,
|
||||
byType,
|
||||
latest: allEvidence.length > 0 ? allEvidence[allEvidence.length - 1] : null,
|
||||
};
|
||||
}, [allEvidence, evidenceByStep, currentStepEvidence]);
|
||||
|
||||
/**
|
||||
* Rechercher des Evidence par critères
|
||||
*/
|
||||
const searchEvidence = useCallback((
|
||||
query: string,
|
||||
filters?: {
|
||||
stepId?: string;
|
||||
type?: string;
|
||||
dateFrom?: Date;
|
||||
dateTo?: Date;
|
||||
}
|
||||
): Evidence[] => {
|
||||
let results = allEvidence;
|
||||
|
||||
// Filtrer par étape
|
||||
if (filters?.stepId) {
|
||||
results = evidenceByStep[filters.stepId] || [];
|
||||
}
|
||||
|
||||
// Filtrer par type
|
||||
if (filters?.type) {
|
||||
results = results.filter(evidence => evidence.action_id === filters.type);
|
||||
}
|
||||
|
||||
// Filtrer par date
|
||||
if (filters?.dateFrom) {
|
||||
results = results.filter(evidence =>
|
||||
new Date(evidence.captured_at) >= filters.dateFrom!
|
||||
);
|
||||
}
|
||||
|
||||
if (filters?.dateTo) {
|
||||
results = results.filter(evidence =>
|
||||
new Date(evidence.captured_at) <= filters.dateTo!
|
||||
);
|
||||
}
|
||||
|
||||
// Recherche textuelle
|
||||
if (query.trim()) {
|
||||
const lowerQuery = query.toLowerCase();
|
||||
results = results.filter(evidence =>
|
||||
evidence.id.toLowerCase().includes(lowerQuery) ||
|
||||
evidence.action_id.toLowerCase().includes(lowerQuery) ||
|
||||
(evidence.metadata?.message &&
|
||||
evidence.data?.message.toLowerCase().includes(lowerQuery))
|
||||
);
|
||||
}
|
||||
|
||||
return results;
|
||||
}, [allEvidence, evidenceByStep]);
|
||||
|
||||
/**
|
||||
* Obtenir les Evidence par étape avec tri
|
||||
*/
|
||||
const getEvidenceByStep = useCallback((
|
||||
stepId: string,
|
||||
sortBy: 'timestamp' | 'type' = 'timestamp',
|
||||
sortOrder: 'asc' | 'desc' = 'desc'
|
||||
): Evidence[] => {
|
||||
const stepEvidence = evidenceByStep[stepId] || [];
|
||||
|
||||
return [...stepEvidence].sort((a, b) => {
|
||||
let comparison = 0;
|
||||
|
||||
if (sortBy === 'timestamp') {
|
||||
comparison = new Date(a.captured_at).getTime() - new Date(b.captured_at).getTime();
|
||||
} else if (sortBy === 'type') {
|
||||
comparison = a.action_id.localeCompare(b.action_id);
|
||||
}
|
||||
|
||||
return sortOrder === 'desc' ? -comparison : comparison;
|
||||
});
|
||||
}, [evidenceByStep]);
|
||||
|
||||
/**
|
||||
* Exporter les Evidence au format JSON
|
||||
*/
|
||||
const exportEvidence = useCallback((stepId?: string) => {
|
||||
const dataToExport = stepId
|
||||
? { [stepId]: evidenceByStep[stepId] || [] }
|
||||
: evidenceByStep;
|
||||
|
||||
const exportData = {
|
||||
timestamp: new Date().toISOString(),
|
||||
stepId,
|
||||
evidence: dataToExport,
|
||||
stats: getEvidenceStats(),
|
||||
};
|
||||
|
||||
return JSON.stringify(exportData, null, 2);
|
||||
}, [evidenceByStep, getEvidenceStats]);
|
||||
|
||||
return {
|
||||
// État
|
||||
evidenceByStep,
|
||||
allEvidence,
|
||||
currentStepEvidence,
|
||||
|
||||
// Actions
|
||||
addEvidence,
|
||||
addMultipleEvidence,
|
||||
removeStepEvidence,
|
||||
clearEvidence,
|
||||
performAutoCleanup,
|
||||
|
||||
// Utilitaires
|
||||
getEvidenceStats,
|
||||
searchEvidence,
|
||||
getEvidenceByStep,
|
||||
exportEvidence,
|
||||
|
||||
// Informations
|
||||
totalCount: allEvidence.length,
|
||||
stepCount: Object.keys(evidenceByStep).length,
|
||||
hasEvidence: allEvidence.length > 0,
|
||||
hasCurrentStepEvidence: currentStepEvidence.length > 0,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* Hook de Navigation au Clavier - Accessibilité complète
|
||||
* Auteur : Dom, Alice, Kiro - 08 janvier 2026
|
||||
*
|
||||
* Ce hook gère la navigation au clavier complète pour toutes les fonctionnalités
|
||||
* du Visual Workflow Builder, conformément aux standards WCAG 2.1 niveau AA.
|
||||
*/
|
||||
|
||||
import { useEffect, useCallback, useRef } from 'react';
|
||||
|
||||
interface KeyboardNavigationOptions {
|
||||
onStepSelect?: (stepId: string) => void;
|
||||
onStepMove?: (stepId: string, direction: 'up' | 'down' | 'left' | 'right') => void;
|
||||
onStepDelete?: (stepId: string) => void;
|
||||
onStepCopy?: (stepId: string) => void;
|
||||
onStepPaste?: () => void;
|
||||
onUndo?: () => void;
|
||||
onRedo?: () => void;
|
||||
onSave?: () => void;
|
||||
onExecute?: () => void;
|
||||
onZoomIn?: () => void;
|
||||
onZoomOut?: () => void;
|
||||
onZoomFit?: () => void;
|
||||
onSelectAll?: () => void;
|
||||
onEscape?: () => void;
|
||||
onHelp?: () => void;
|
||||
selectedStepId?: string;
|
||||
availableStepIds?: string[];
|
||||
isEnabled?: boolean;
|
||||
}
|
||||
|
||||
interface KeyboardShortcut {
|
||||
key: string;
|
||||
ctrlKey?: boolean;
|
||||
shiftKey?: boolean;
|
||||
altKey?: boolean;
|
||||
description: string;
|
||||
action: () => void;
|
||||
}
|
||||
|
||||
export const useKeyboardNavigation = (options: KeyboardNavigationOptions) => {
|
||||
const {
|
||||
onStepSelect,
|
||||
onStepMove,
|
||||
onStepDelete,
|
||||
onStepCopy,
|
||||
onStepPaste,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onSave,
|
||||
onExecute,
|
||||
onZoomIn,
|
||||
onZoomOut,
|
||||
onZoomFit,
|
||||
onSelectAll,
|
||||
onEscape,
|
||||
onHelp,
|
||||
selectedStepId,
|
||||
availableStepIds = [],
|
||||
isEnabled = true,
|
||||
} = options;
|
||||
|
||||
const shortcutsRef = useRef<KeyboardShortcut[]>([]);
|
||||
|
||||
// Définir les raccourcis clavier
|
||||
const defineShortcuts = useCallback((): KeyboardShortcut[] => {
|
||||
return [
|
||||
// Navigation des étapes
|
||||
{
|
||||
key: 'Tab',
|
||||
description: 'Naviguer vers l\'étape suivante',
|
||||
action: () => {
|
||||
if (availableStepIds.length === 0) return;
|
||||
const currentIndex = selectedStepId ? availableStepIds.indexOf(selectedStepId) : -1;
|
||||
const nextIndex = (currentIndex + 1) % availableStepIds.length;
|
||||
onStepSelect?.(availableStepIds[nextIndex]);
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'Tab',
|
||||
shiftKey: true,
|
||||
description: 'Naviguer vers l\'étape précédente',
|
||||
action: () => {
|
||||
if (availableStepIds.length === 0) return;
|
||||
const currentIndex = selectedStepId ? availableStepIds.indexOf(selectedStepId) : -1;
|
||||
const prevIndex = currentIndex <= 0 ? availableStepIds.length - 1 : currentIndex - 1;
|
||||
onStepSelect?.(availableStepIds[prevIndex]);
|
||||
}
|
||||
},
|
||||
|
||||
// Déplacement des étapes
|
||||
{
|
||||
key: 'ArrowUp',
|
||||
description: 'Déplacer l\'étape vers le haut',
|
||||
action: () => selectedStepId && onStepMove?.(selectedStepId, 'up')
|
||||
},
|
||||
{
|
||||
key: 'ArrowDown',
|
||||
description: 'Déplacer l\'étape vers le bas',
|
||||
action: () => selectedStepId && onStepMove?.(selectedStepId, 'down')
|
||||
},
|
||||
{
|
||||
key: 'ArrowLeft',
|
||||
description: 'Déplacer l\'étape vers la gauche',
|
||||
action: () => selectedStepId && onStepMove?.(selectedStepId, 'left')
|
||||
},
|
||||
{
|
||||
key: 'ArrowRight',
|
||||
description: 'Déplacer l\'étape vers la droite',
|
||||
action: () => selectedStepId && onStepMove?.(selectedStepId, 'right')
|
||||
},
|
||||
|
||||
// Actions d'édition
|
||||
{
|
||||
key: 'Delete',
|
||||
description: 'Supprimer l\'étape sélectionnée',
|
||||
action: () => selectedStepId && onStepDelete?.(selectedStepId)
|
||||
},
|
||||
{
|
||||
key: 'Backspace',
|
||||
description: 'Supprimer l\'étape sélectionnée',
|
||||
action: () => selectedStepId && onStepDelete?.(selectedStepId)
|
||||
},
|
||||
{
|
||||
key: 'c',
|
||||
ctrlKey: true,
|
||||
description: 'Copier l\'étape sélectionnée',
|
||||
action: () => selectedStepId && onStepCopy?.(selectedStepId)
|
||||
},
|
||||
{
|
||||
key: 'v',
|
||||
ctrlKey: true,
|
||||
description: 'Coller l\'étape copiée',
|
||||
action: () => onStepPaste?.()
|
||||
},
|
||||
|
||||
// Actions globales
|
||||
{
|
||||
key: 'z',
|
||||
ctrlKey: true,
|
||||
description: 'Annuler la dernière action',
|
||||
action: () => onUndo?.()
|
||||
},
|
||||
{
|
||||
key: 'y',
|
||||
ctrlKey: true,
|
||||
description: 'Rétablir l\'action annulée',
|
||||
action: () => onRedo?.()
|
||||
},
|
||||
{
|
||||
key: 'z',
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
description: 'Rétablir l\'action annulée (alternative)',
|
||||
action: () => onRedo?.()
|
||||
},
|
||||
{
|
||||
key: 's',
|
||||
ctrlKey: true,
|
||||
description: 'Sauvegarder le workflow',
|
||||
action: () => onSave?.()
|
||||
},
|
||||
{
|
||||
key: 'F5',
|
||||
description: 'Exécuter le workflow',
|
||||
action: () => onExecute?.()
|
||||
},
|
||||
{
|
||||
key: 'Enter',
|
||||
ctrlKey: true,
|
||||
description: 'Exécuter le workflow (alternative)',
|
||||
action: () => onExecute?.()
|
||||
},
|
||||
|
||||
// Navigation et zoom
|
||||
{
|
||||
key: '=',
|
||||
ctrlKey: true,
|
||||
description: 'Zoomer',
|
||||
action: () => onZoomIn?.()
|
||||
},
|
||||
{
|
||||
key: '+',
|
||||
ctrlKey: true,
|
||||
description: 'Zoomer (alternative)',
|
||||
action: () => onZoomIn?.()
|
||||
},
|
||||
{
|
||||
key: '-',
|
||||
ctrlKey: true,
|
||||
description: 'Dézoomer',
|
||||
action: () => onZoomOut?.()
|
||||
},
|
||||
{
|
||||
key: '0',
|
||||
ctrlKey: true,
|
||||
description: 'Ajuster le zoom pour voir tout le workflow',
|
||||
action: () => onZoomFit?.()
|
||||
},
|
||||
{
|
||||
key: 'a',
|
||||
ctrlKey: true,
|
||||
description: 'Sélectionner toutes les étapes',
|
||||
action: () => onSelectAll?.()
|
||||
},
|
||||
|
||||
// Actions spéciales
|
||||
{
|
||||
key: 'Escape',
|
||||
description: 'Annuler l\'action en cours ou désélectionner',
|
||||
action: () => onEscape?.()
|
||||
},
|
||||
{
|
||||
key: 'F1',
|
||||
description: 'Afficher l\'aide',
|
||||
action: () => onHelp?.()
|
||||
},
|
||||
{
|
||||
key: '?',
|
||||
shiftKey: true,
|
||||
description: 'Afficher les raccourcis clavier',
|
||||
action: () => onHelp?.()
|
||||
}
|
||||
];
|
||||
}, [
|
||||
selectedStepId,
|
||||
availableStepIds,
|
||||
onStepSelect,
|
||||
onStepMove,
|
||||
onStepDelete,
|
||||
onStepCopy,
|
||||
onStepPaste,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onSave,
|
||||
onExecute,
|
||||
onZoomIn,
|
||||
onZoomOut,
|
||||
onZoomFit,
|
||||
onSelectAll,
|
||||
onEscape,
|
||||
onHelp,
|
||||
]);
|
||||
|
||||
// Gestionnaire d'événements clavier
|
||||
const handleKeyDown = useCallback((event: KeyboardEvent) => {
|
||||
if (!isEnabled) return;
|
||||
|
||||
// Ignorer si l'utilisateur tape dans un champ de saisie
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shortcuts = shortcutsRef.current;
|
||||
const matchingShortcut = shortcuts.find(shortcut => {
|
||||
return (
|
||||
shortcut.key === event.key &&
|
||||
!!shortcut.ctrlKey === event.ctrlKey &&
|
||||
!!shortcut.shiftKey === event.shiftKey &&
|
||||
!!shortcut.altKey === event.altKey
|
||||
);
|
||||
});
|
||||
|
||||
if (matchingShortcut) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
matchingShortcut.action();
|
||||
}
|
||||
}, [isEnabled]);
|
||||
|
||||
// Effet pour attacher/détacher les événements
|
||||
useEffect(() => {
|
||||
shortcutsRef.current = defineShortcuts();
|
||||
|
||||
if (isEnabled) {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}
|
||||
}, [handleKeyDown, defineShortcuts, isEnabled]);
|
||||
|
||||
// Fonction pour obtenir la liste des raccourcis (pour l'aide)
|
||||
const getShortcuts = useCallback(() => {
|
||||
return shortcutsRef.current.map(shortcut => ({
|
||||
keys: [
|
||||
shortcut.ctrlKey && 'Ctrl',
|
||||
shortcut.shiftKey && 'Shift',
|
||||
shortcut.altKey && 'Alt',
|
||||
shortcut.key
|
||||
].filter(Boolean).join(' + '),
|
||||
description: shortcut.description
|
||||
}));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
shortcuts: getShortcuts(),
|
||||
isEnabled
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* Hook de Layout Responsif - Adaptation aux différentes résolutions
|
||||
* Auteur : Dom, Alice, Kiro - 08 janvier 2026
|
||||
*
|
||||
* Ce hook gère l'adaptation de l'interface aux différentes tailles d'écran
|
||||
* pour assurer une expérience utilisateur optimale sur tous les appareils.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useTheme, useMediaQuery } from '@mui/material';
|
||||
|
||||
interface BreakpointValues {
|
||||
xs: boolean; // < 600px
|
||||
sm: boolean; // 600px - 900px
|
||||
md: boolean; // 900px - 1200px
|
||||
lg: boolean; // 1200px - 1536px
|
||||
xl: boolean; // >= 1536px
|
||||
}
|
||||
|
||||
interface ResponsiveLayoutConfig {
|
||||
// Largeurs des panneaux selon la taille d'écran
|
||||
paletteWidth: number;
|
||||
propertiesWidth: number;
|
||||
variablesHeight: number;
|
||||
|
||||
// Visibilité des éléments
|
||||
showMinimap: boolean;
|
||||
showVariablesPanel: boolean;
|
||||
showPropertiesPanel: boolean;
|
||||
|
||||
// Configuration du canvas
|
||||
canvasMinHeight: number;
|
||||
|
||||
// Configuration des tooltips
|
||||
tooltipPlacement: 'top' | 'bottom' | 'left' | 'right';
|
||||
|
||||
// Configuration des dialogues
|
||||
dialogFullScreen: boolean;
|
||||
|
||||
// Configuration de la grille
|
||||
gridSize: number;
|
||||
|
||||
// Configuration des boutons
|
||||
buttonSize: 'small' | 'medium' | 'large';
|
||||
iconSize: 'small' | 'medium' | 'large';
|
||||
}
|
||||
|
||||
const defaultConfigs: Record<string, ResponsiveLayoutConfig> = {
|
||||
xs: {
|
||||
paletteWidth: 240,
|
||||
propertiesWidth: 280,
|
||||
variablesHeight: 150,
|
||||
showMinimap: false,
|
||||
showVariablesPanel: false,
|
||||
showPropertiesPanel: false,
|
||||
canvasMinHeight: 400,
|
||||
tooltipPlacement: 'top',
|
||||
dialogFullScreen: true,
|
||||
gridSize: 15,
|
||||
buttonSize: 'small',
|
||||
iconSize: 'small',
|
||||
},
|
||||
sm: {
|
||||
paletteWidth: 260,
|
||||
propertiesWidth: 300,
|
||||
variablesHeight: 180,
|
||||
showMinimap: false,
|
||||
showVariablesPanel: true,
|
||||
showPropertiesPanel: false,
|
||||
canvasMinHeight: 500,
|
||||
tooltipPlacement: 'top',
|
||||
dialogFullScreen: true,
|
||||
gridSize: 20,
|
||||
buttonSize: 'small',
|
||||
iconSize: 'small',
|
||||
},
|
||||
md: {
|
||||
paletteWidth: 280,
|
||||
propertiesWidth: 320,
|
||||
variablesHeight: 200,
|
||||
showMinimap: true,
|
||||
showVariablesPanel: true,
|
||||
showPropertiesPanel: true,
|
||||
canvasMinHeight: 600,
|
||||
tooltipPlacement: 'right',
|
||||
dialogFullScreen: false,
|
||||
gridSize: 20,
|
||||
buttonSize: 'medium',
|
||||
iconSize: 'medium',
|
||||
},
|
||||
lg: {
|
||||
paletteWidth: 300,
|
||||
propertiesWidth: 350,
|
||||
variablesHeight: 220,
|
||||
showMinimap: true,
|
||||
showVariablesPanel: true,
|
||||
showPropertiesPanel: true,
|
||||
canvasMinHeight: 700,
|
||||
tooltipPlacement: 'right',
|
||||
dialogFullScreen: false,
|
||||
gridSize: 25,
|
||||
buttonSize: 'medium',
|
||||
iconSize: 'medium',
|
||||
},
|
||||
xl: {
|
||||
paletteWidth: 320,
|
||||
propertiesWidth: 380,
|
||||
variablesHeight: 250,
|
||||
showMinimap: true,
|
||||
showVariablesPanel: true,
|
||||
showPropertiesPanel: true,
|
||||
canvasMinHeight: 800,
|
||||
tooltipPlacement: 'right',
|
||||
dialogFullScreen: false,
|
||||
gridSize: 25,
|
||||
buttonSize: 'large',
|
||||
iconSize: 'large',
|
||||
},
|
||||
};
|
||||
|
||||
export const useResponsiveLayout = () => {
|
||||
const theme = useTheme();
|
||||
|
||||
// Détection des breakpoints Material-UI
|
||||
const isXs = useMediaQuery(theme.breakpoints.only('xs'));
|
||||
const isSm = useMediaQuery(theme.breakpoints.only('sm'));
|
||||
const isMd = useMediaQuery(theme.breakpoints.only('md'));
|
||||
const isLg = useMediaQuery(theme.breakpoints.only('lg'));
|
||||
const isXl = useMediaQuery(theme.breakpoints.up('xl'));
|
||||
|
||||
const breakpoints: BreakpointValues = useMemo(() => ({
|
||||
xs: isXs,
|
||||
sm: isSm,
|
||||
md: isMd,
|
||||
lg: isLg,
|
||||
xl: isXl,
|
||||
}), [isXs, isSm, isMd, isLg, isXl]);
|
||||
|
||||
// État de la configuration actuelle
|
||||
const [currentConfig, setCurrentConfig] = useState<ResponsiveLayoutConfig>(defaultConfigs.lg);
|
||||
const [currentBreakpoint, setCurrentBreakpoint] = useState<string>('lg');
|
||||
|
||||
// Gestionnaire d'événements clavier pour l'accessibilité
|
||||
const handleKeyDown = useCallback((event: KeyboardEvent) => {
|
||||
// Raccourcis clavier pour basculer les panneaux (accessibilité)
|
||||
if (event.altKey) {
|
||||
switch (event.key) {
|
||||
case 'p':
|
||||
// Alt+P : Basculer le panneau de propriétés
|
||||
event.preventDefault();
|
||||
setCurrentConfig(prev => ({
|
||||
...prev,
|
||||
showPropertiesPanel: !prev.showPropertiesPanel
|
||||
}));
|
||||
break;
|
||||
case 'v':
|
||||
// Alt+V : Basculer le panneau de variables
|
||||
event.preventDefault();
|
||||
setCurrentConfig(prev => ({
|
||||
...prev,
|
||||
showVariablesPanel: !prev.showVariablesPanel
|
||||
}));
|
||||
break;
|
||||
case 'm':
|
||||
// Alt+M : Basculer la minimap
|
||||
event.preventDefault();
|
||||
setCurrentConfig(prev => ({
|
||||
...prev,
|
||||
showMinimap: !prev.showMinimap
|
||||
}));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Ajouter les écouteurs d'événements clavier
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [handleKeyDown]);
|
||||
|
||||
// Déterminer le breakpoint actuel
|
||||
const getCurrentBreakpoint = useCallback((): string => {
|
||||
if (breakpoints.xs) return 'xs';
|
||||
if (breakpoints.sm) return 'sm';
|
||||
if (breakpoints.md) return 'md';
|
||||
if (breakpoints.lg) return 'lg';
|
||||
if (breakpoints.xl) return 'xl';
|
||||
return 'lg'; // fallback
|
||||
}, [breakpoints]);
|
||||
|
||||
// Mettre à jour la configuration selon le breakpoint
|
||||
useEffect(() => {
|
||||
const newBreakpoint = getCurrentBreakpoint();
|
||||
if (newBreakpoint !== currentBreakpoint) {
|
||||
setCurrentBreakpoint(newBreakpoint);
|
||||
setCurrentConfig(defaultConfigs[newBreakpoint]);
|
||||
}
|
||||
}, [getCurrentBreakpoint, currentBreakpoint]);
|
||||
|
||||
// Fonctions utilitaires pour les composants
|
||||
const isMobile = breakpoints.xs || breakpoints.sm;
|
||||
const isTablet = breakpoints.md;
|
||||
const isDesktop = breakpoints.lg || breakpoints.xl;
|
||||
|
||||
// Fonction pour obtenir les styles responsifs d'un composant
|
||||
const getResponsiveStyles = useCallback((componentName: string) => {
|
||||
const baseStyles: Record<string, any> = {};
|
||||
|
||||
switch (componentName) {
|
||||
case 'palette':
|
||||
return {
|
||||
...baseStyles,
|
||||
width: currentConfig.paletteWidth,
|
||||
display: isMobile ? 'none' : 'flex', // Masquer sur mobile
|
||||
};
|
||||
|
||||
case 'properties':
|
||||
return {
|
||||
...baseStyles,
|
||||
width: currentConfig.propertiesWidth,
|
||||
display: currentConfig.showPropertiesPanel ? 'flex' : 'none',
|
||||
};
|
||||
|
||||
case 'variables':
|
||||
return {
|
||||
...baseStyles,
|
||||
height: currentConfig.variablesHeight,
|
||||
display: currentConfig.showVariablesPanel ? 'block' : 'none',
|
||||
};
|
||||
|
||||
case 'canvas':
|
||||
return {
|
||||
...baseStyles,
|
||||
minHeight: currentConfig.canvasMinHeight,
|
||||
flex: 1,
|
||||
};
|
||||
|
||||
case 'minimap':
|
||||
return {
|
||||
...baseStyles,
|
||||
display: currentConfig.showMinimap ? 'block' : 'none',
|
||||
};
|
||||
|
||||
case 'toolbar':
|
||||
return {
|
||||
...baseStyles,
|
||||
flexDirection: isMobile ? 'column' : 'row',
|
||||
gap: isMobile ? 1 : 2,
|
||||
};
|
||||
|
||||
case 'dialog':
|
||||
return {
|
||||
...baseStyles,
|
||||
fullScreen: currentConfig.dialogFullScreen,
|
||||
maxWidth: currentConfig.dialogFullScreen ? false : 'md',
|
||||
};
|
||||
|
||||
default:
|
||||
return baseStyles;
|
||||
}
|
||||
}, [currentConfig, isMobile]);
|
||||
|
||||
// Fonction pour obtenir la taille des boutons
|
||||
const getButtonSize = useCallback(() => currentConfig.buttonSize, [currentConfig]);
|
||||
|
||||
// Fonction pour obtenir la taille des icônes
|
||||
const getIconSize = useCallback(() => currentConfig.iconSize, [currentConfig]);
|
||||
|
||||
// Fonction pour obtenir la position des tooltips
|
||||
const getTooltipPlacement = useCallback(() => currentConfig.tooltipPlacement, [currentConfig]);
|
||||
|
||||
// Fonction pour obtenir la taille de la grille
|
||||
const getGridSize = useCallback(() => currentConfig.gridSize, [currentConfig]);
|
||||
|
||||
// Fonction pour déterminer si un panneau doit être en drawer sur mobile
|
||||
const shouldUseDrawer = useCallback((panelName: string) => {
|
||||
if (!isMobile) return false;
|
||||
|
||||
switch (panelName) {
|
||||
case 'palette':
|
||||
case 'properties':
|
||||
case 'variables':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}, [isMobile]);
|
||||
|
||||
// Fonction pour obtenir les dimensions de la fenêtre
|
||||
const getViewportDimensions = useCallback(() => {
|
||||
return {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
availableWidth: window.innerWidth - (
|
||||
(currentConfig.showPropertiesPanel ? currentConfig.propertiesWidth : 0) +
|
||||
(isMobile ? 0 : currentConfig.paletteWidth)
|
||||
),
|
||||
availableHeight: window.innerHeight - (
|
||||
currentConfig.showVariablesPanel ? currentConfig.variablesHeight : 0
|
||||
) - 64, // Hauteur de l'AppBar
|
||||
};
|
||||
}, [currentConfig, isMobile]);
|
||||
|
||||
return {
|
||||
// État actuel
|
||||
breakpoints,
|
||||
currentBreakpoint,
|
||||
currentConfig,
|
||||
|
||||
// Détection de type d'appareil
|
||||
isMobile,
|
||||
isTablet,
|
||||
isDesktop,
|
||||
|
||||
// Fonctions utilitaires
|
||||
getResponsiveStyles,
|
||||
getButtonSize,
|
||||
getIconSize,
|
||||
getTooltipPlacement,
|
||||
getGridSize,
|
||||
shouldUseDrawer,
|
||||
getViewportDimensions,
|
||||
|
||||
// Valeurs directes pour faciliter l'utilisation
|
||||
paletteWidth: currentConfig.paletteWidth,
|
||||
propertiesWidth: currentConfig.propertiesWidth,
|
||||
variablesHeight: currentConfig.variablesHeight,
|
||||
showMinimap: currentConfig.showMinimap,
|
||||
showVariablesPanel: currentConfig.showVariablesPanel,
|
||||
showPropertiesPanel: currentConfig.showPropertiesPanel,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Hook React sécurisé pour ResizeObserver
|
||||
*
|
||||
* Auteur : Dom, Alice, Kiro - 08 janvier 2026
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
interface ResizeObserverEntry {
|
||||
target: Element;
|
||||
contentRect: DOMRectReadOnly;
|
||||
borderBoxSize?: ReadonlyArray<ResizeObserverSize>;
|
||||
contentBoxSize?: ReadonlyArray<ResizeObserverSize>;
|
||||
devicePixelContentBoxSize?: ReadonlyArray<ResizeObserverSize>;
|
||||
}
|
||||
|
||||
type ResizeCallback = (entries: ResizeObserverEntry[]) => void;
|
||||
|
||||
/**
|
||||
* Hook sécurisé pour utiliser ResizeObserver sans erreurs de boucle infinie
|
||||
*/
|
||||
export const useSafeResizeObserver = (
|
||||
callback: ResizeCallback,
|
||||
dependencies: React.DependencyList = []
|
||||
) => {
|
||||
const observerRef = useRef<ResizeObserver | null>(null);
|
||||
const callbackRef = useRef<ResizeCallback>(callback);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Mettre à jour la référence du callback
|
||||
useEffect(() => {
|
||||
callbackRef.current = callback;
|
||||
}, [callback]);
|
||||
|
||||
// Callback sécurisé avec debounce
|
||||
const safeCallback = useCallback((entries: ResizeObserverEntry[]) => {
|
||||
// Annuler le timeout précédent
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
// Debounce pour éviter les appels trop fréquents
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
try {
|
||||
callbackRef.current(entries);
|
||||
} catch (error) {
|
||||
// Ignorer les erreurs ResizeObserver
|
||||
if (
|
||||
error instanceof Error &&
|
||||
!error.message.includes('ResizeObserver')
|
||||
) {
|
||||
console.warn('ResizeObserver callback error:', error);
|
||||
}
|
||||
}
|
||||
}, 16); // ~60fps
|
||||
}, []);
|
||||
|
||||
// Fonction pour observer un élément
|
||||
const observe = useCallback((element: Element | null) => {
|
||||
if (!element) return;
|
||||
|
||||
try {
|
||||
// Nettoyer l'observer précédent
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect();
|
||||
}
|
||||
|
||||
// Créer un nouvel observer
|
||||
observerRef.current = new ResizeObserver(safeCallback);
|
||||
observerRef.current.observe(element);
|
||||
} catch (error) {
|
||||
console.warn('Erreur lors de la création du ResizeObserver:', error);
|
||||
}
|
||||
}, [safeCallback, ...dependencies]);
|
||||
|
||||
// Fonction pour arrêter l'observation
|
||||
const disconnect = useCallback(() => {
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect();
|
||||
observerRef.current = null;
|
||||
}
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Nettoyage à la destruction du composant
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
disconnect();
|
||||
};
|
||||
}, [disconnect]);
|
||||
|
||||
return { observe, disconnect };
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook simplifié pour observer la taille d'un élément
|
||||
*/
|
||||
export const useElementSize = (
|
||||
elementRef: React.RefObject<Element>,
|
||||
onResize?: (size: { width: number; height: number }) => void
|
||||
) => {
|
||||
const { observe, disconnect } = useSafeResizeObserver(
|
||||
(entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry && onResize) {
|
||||
const { width, height } = entry.contentRect;
|
||||
onResize({ width, height });
|
||||
}
|
||||
},
|
||||
[onResize]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (elementRef.current) {
|
||||
observe(elementRef.current);
|
||||
}
|
||||
return disconnect;
|
||||
}, [elementRef.current, observe, disconnect]);
|
||||
};
|
||||
|
||||
export default useSafeResizeObserver;
|
||||
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* Hook useStepTypeResolver - Intégration du résolveur de types d'étapes
|
||||
* Auteur : Dom, Alice, Kiro - 12 janvier 2026
|
||||
*
|
||||
* Ce hook fournit une interface React pour utiliser le StepTypeResolver
|
||||
* avec gestion d'état, mémorisation et optimisations de performance.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { Step, Variable } from '../types';
|
||||
import {
|
||||
stepTypeResolver,
|
||||
StepTypeResolutionResult,
|
||||
ResolutionOptions,
|
||||
ResolutionStats
|
||||
} from '../services/StepTypeResolver';
|
||||
|
||||
/**
|
||||
* État de résolution
|
||||
*/
|
||||
export interface ResolutionState {
|
||||
isLoading: boolean;
|
||||
result: StepTypeResolutionResult | null;
|
||||
error: Error | null;
|
||||
lastResolved: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options du hook
|
||||
*/
|
||||
export interface UseStepTypeResolverOptions extends ResolutionOptions {
|
||||
autoResolve?: boolean;
|
||||
debounceMs?: number;
|
||||
retryAttempts?: number;
|
||||
onResolutionComplete?: (result: StepTypeResolutionResult) => void;
|
||||
onResolutionError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Résultat du hook
|
||||
*/
|
||||
export interface UseStepTypeResolverResult {
|
||||
// État de résolution
|
||||
state: ResolutionState;
|
||||
|
||||
// Résultat de résolution
|
||||
result: StepTypeResolutionResult | null;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
|
||||
// Méthodes de résolution
|
||||
resolveStep: (step: Step, options?: ResolutionOptions) => Promise<StepTypeResolutionResult>;
|
||||
resolveStepSync: (step: Step) => StepTypeResolutionResult | null;
|
||||
|
||||
// Utilitaires
|
||||
isVWBAction: (step: Step) => boolean;
|
||||
invalidateCache: () => void;
|
||||
getStats: () => ResolutionStats;
|
||||
|
||||
// État dérivé
|
||||
hasParameterConfig: boolean;
|
||||
parameterCount: number;
|
||||
isStandardType: boolean;
|
||||
resolutionSource: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook useStepTypeResolver
|
||||
*/
|
||||
export function useStepTypeResolver(
|
||||
selectedStep: Step | null,
|
||||
options: UseStepTypeResolverOptions = {}
|
||||
): UseStepTypeResolverResult {
|
||||
// Options par défaut
|
||||
const resolverOptions = useMemo(() => ({
|
||||
autoResolve: true,
|
||||
debounceMs: 100,
|
||||
retryAttempts: 3,
|
||||
enableCache: true,
|
||||
enableLogging: process.env.NODE_ENV === 'development',
|
||||
fallbackToEmpty: true,
|
||||
...options
|
||||
}), [options]);
|
||||
|
||||
// État de résolution
|
||||
const [state, setState] = useState<ResolutionState>({
|
||||
isLoading: false,
|
||||
result: null,
|
||||
error: null,
|
||||
lastResolved: 0
|
||||
});
|
||||
|
||||
// Références pour éviter les re-rendus
|
||||
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const retryCountRef = useRef(0);
|
||||
const lastStepRef = useRef<Step | null>(null);
|
||||
|
||||
/**
|
||||
* Résout une étape de manière asynchrone
|
||||
*/
|
||||
const resolveStep = useCallback(async (
|
||||
step: Step,
|
||||
overrideOptions?: ResolutionOptions
|
||||
): Promise<StepTypeResolutionResult> => {
|
||||
const finalOptions = { ...resolverOptions, ...overrideOptions };
|
||||
|
||||
try {
|
||||
setState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||
|
||||
const result = await stepTypeResolver.resolveParameterConfig(step, finalOptions);
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
result,
|
||||
lastResolved: Date.now()
|
||||
}));
|
||||
|
||||
// Callback de succès
|
||||
if (resolverOptions.onResolutionComplete) {
|
||||
resolverOptions.onResolutionComplete(result);
|
||||
}
|
||||
|
||||
// Reset retry count
|
||||
retryCountRef.current = 0;
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
const errorObj = error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
// Gestion des tentatives de retry
|
||||
if (retryCountRef.current < resolverOptions.retryAttempts) {
|
||||
retryCountRef.current++;
|
||||
|
||||
console.warn(`🔄 [useStepTypeResolver] Retry ${retryCountRef.current}/${resolverOptions.retryAttempts}:`, errorObj.message);
|
||||
|
||||
// Retry avec délai exponentiel
|
||||
const retryDelay = Math.pow(2, retryCountRef.current) * 100;
|
||||
await new Promise(resolve => setTimeout(resolve, retryDelay));
|
||||
|
||||
return resolveStep(step, overrideOptions);
|
||||
}
|
||||
|
||||
// Échec définitif
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: errorObj
|
||||
}));
|
||||
|
||||
// Callback d'erreur
|
||||
if (resolverOptions.onResolutionError) {
|
||||
resolverOptions.onResolutionError(errorObj);
|
||||
}
|
||||
|
||||
throw errorObj;
|
||||
}
|
||||
}, [resolverOptions]);
|
||||
|
||||
/**
|
||||
* Résolution synchrone (depuis le cache)
|
||||
*/
|
||||
const resolveStepSync = useCallback((step: Step): StepTypeResolutionResult | null => {
|
||||
try {
|
||||
// Vérifier si le résultat est déjà en cache/état
|
||||
if (state.result &&
|
||||
state.result.stepType === step.type &&
|
||||
Date.now() - state.lastResolved < 5000) { // Cache 5 secondes
|
||||
return state.result;
|
||||
}
|
||||
|
||||
// Tentative de résolution synchrone basique
|
||||
const isVWB = stepTypeResolver.isVWBAction(step);
|
||||
|
||||
return {
|
||||
stepType: step.type as string,
|
||||
isVWBAction: isVWB,
|
||||
isStandardType: !isVWB,
|
||||
parameterConfig: [],
|
||||
detectionMethods: { sync: true },
|
||||
resolutionSource: 'fallback' as const,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ [useStepTypeResolver] Erreur résolution sync:', error);
|
||||
return null;
|
||||
}
|
||||
}, [state.result, state.lastResolved]);
|
||||
|
||||
/**
|
||||
* Résolution automatique avec debounce
|
||||
*/
|
||||
const debouncedResolve = useCallback((step: Step) => {
|
||||
// Annuler le timeout précédent
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Programmer la nouvelle résolution
|
||||
debounceTimeoutRef.current = setTimeout(() => {
|
||||
resolveStep(step).catch(error => {
|
||||
console.error('❌ [useStepTypeResolver] Erreur résolution auto:', error);
|
||||
});
|
||||
}, resolverOptions.debounceMs);
|
||||
}, [resolveStep, resolverOptions.debounceMs]);
|
||||
|
||||
/**
|
||||
* Effet pour résolution automatique
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!resolverOptions.autoResolve || !selectedStep) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Éviter la résolution si l'étape n'a pas changé
|
||||
if (lastStepRef.current &&
|
||||
lastStepRef.current.id === selectedStep.id &&
|
||||
lastStepRef.current.type === selectedStep.type &&
|
||||
JSON.stringify(lastStepRef.current.data) === JSON.stringify(selectedStep.data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastStepRef.current = selectedStep;
|
||||
debouncedResolve(selectedStep);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [selectedStep, resolverOptions.autoResolve, debouncedResolve]);
|
||||
|
||||
/**
|
||||
* Vérification VWB rapide
|
||||
*/
|
||||
const isVWBAction = useCallback((step: Step): boolean => {
|
||||
return stepTypeResolver.isVWBAction(step);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Invalidation du cache
|
||||
*/
|
||||
const invalidateCache = useCallback(() => {
|
||||
stepTypeResolver.invalidateCache();
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
result: null,
|
||||
lastResolved: 0
|
||||
}));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Obtention des statistiques
|
||||
*/
|
||||
const getStats = useCallback((): ResolutionStats => {
|
||||
return stepTypeResolver.getResolutionStats();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* État dérivé mémorisé
|
||||
*/
|
||||
const derivedState = useMemo(() => {
|
||||
const result = state.result;
|
||||
|
||||
return {
|
||||
hasParameterConfig: Boolean(result?.parameterConfig?.length),
|
||||
parameterCount: result?.parameterConfig?.length || 0,
|
||||
isStandardType: Boolean(result?.isStandardType),
|
||||
resolutionSource: result?.resolutionSource || null
|
||||
};
|
||||
}, [state.result]);
|
||||
|
||||
/**
|
||||
* Cleanup à la destruction
|
||||
*/
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// État de résolution
|
||||
state,
|
||||
|
||||
// Résultat de résolution
|
||||
result: state.result,
|
||||
isLoading: state.isLoading,
|
||||
error: state.error,
|
||||
|
||||
// Méthodes de résolution
|
||||
resolveStep,
|
||||
resolveStepSync,
|
||||
|
||||
// Utilitaires
|
||||
isVWBAction,
|
||||
invalidateCache,
|
||||
getStats,
|
||||
|
||||
// État dérivé
|
||||
...derivedState
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook simplifié pour vérification VWB uniquement
|
||||
*/
|
||||
export function useIsVWBStep(step: Step | null): boolean {
|
||||
return useMemo(() => {
|
||||
if (!step) return false;
|
||||
return stepTypeResolver.isVWBAction(step);
|
||||
}, [step]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook pour obtenir les statistiques de résolution
|
||||
*/
|
||||
export function useStepTypeResolverStats(): ResolutionStats {
|
||||
const [stats, setStats] = useState<ResolutionStats>(() =>
|
||||
stepTypeResolver.getResolutionStats()
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setStats(stepTypeResolver.getResolutionStats());
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export par défaut
|
||||
*/
|
||||
export default useStepTypeResolver;
|
||||
@@ -0,0 +1,810 @@
|
||||
/**
|
||||
* Hook useVWBActionDetails - Chargement lazy des détails d'actions VWB
|
||||
* Auteur : Dom, Alice, Kiro - 12 janvier 2026
|
||||
*
|
||||
* Ce hook gère le chargement lazy des détails d'actions VWB avec cache intelligent,
|
||||
* gestion d'erreurs robuste, fallback vers le catalogue statique et optimisations
|
||||
* de performance avec debouncing et cache multi-niveaux.
|
||||
*
|
||||
* Version 2.0 - Optimisations de performance et cache intelligent
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
||||
import { VWBCatalogAction } from '../types/catalog';
|
||||
import { catalogService } from '../services/catalogService';
|
||||
import { staticCatalog } from '../data/staticCatalog';
|
||||
|
||||
/**
|
||||
* État de chargement d'une action
|
||||
*/
|
||||
export interface ActionLoadingState {
|
||||
isLoading: boolean;
|
||||
isLoaded: boolean;
|
||||
error: Error | null;
|
||||
lastLoaded: number;
|
||||
retryCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache d'actions avec métadonnées
|
||||
*/
|
||||
interface ActionCacheEntry {
|
||||
action: VWBCatalogAction;
|
||||
loadedAt: number;
|
||||
source: 'api' | 'static' | 'fallback';
|
||||
isValid: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options de chargement avec optimisations
|
||||
*/
|
||||
export interface LoadActionOptions {
|
||||
forceReload?: boolean;
|
||||
enableFallback?: boolean;
|
||||
timeout?: number;
|
||||
retryAttempts?: number;
|
||||
cacheTimeout?: number;
|
||||
debounceMs?: number;
|
||||
priority?: 'low' | 'normal' | 'high';
|
||||
batchWith?: string[]; // IDs d'actions à charger en lot
|
||||
}
|
||||
|
||||
/**
|
||||
* Résultat du hook avec optimisations
|
||||
*/
|
||||
export interface UseVWBActionDetailsResult {
|
||||
// État global
|
||||
isLoading: boolean;
|
||||
hasErrors: boolean;
|
||||
totalActions: number;
|
||||
|
||||
// Méthodes de chargement optimisées
|
||||
loadAction: (actionId: string, options?: LoadActionOptions) => Promise<VWBCatalogAction | null>;
|
||||
loadActionDebounced: (actionId: string, options?: LoadActionOptions) => Promise<VWBCatalogAction | null>;
|
||||
loadActionsBatch: (actionIds: string[], options?: LoadActionOptions) => Promise<Map<string, VWBCatalogAction | null>>;
|
||||
getAction: (actionId: string) => VWBCatalogAction | null;
|
||||
preloadActions: (actionIds: string[]) => Promise<void>;
|
||||
|
||||
// Gestion du cache multi-niveaux
|
||||
invalidateCache: (actionId?: string) => void;
|
||||
warmupCache: (actionIds: string[]) => Promise<void>;
|
||||
getCacheStats: () => CacheStats;
|
||||
|
||||
// État des actions individuelles
|
||||
getActionState: (actionId: string) => ActionLoadingState;
|
||||
|
||||
// Validation optimisée
|
||||
validateAction: (actionId: string, parameters: Record<string, any>) => Promise<boolean>;
|
||||
validateActionsBatch: (actions: Array<{ id: string; parameters: Record<string, any> }>) => Promise<Map<string, boolean>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Statistiques du cache avec métriques de performance
|
||||
*/
|
||||
export interface CacheStats {
|
||||
totalEntries: number;
|
||||
apiEntries: number;
|
||||
staticEntries: number;
|
||||
fallbackEntries: number;
|
||||
validEntries: number;
|
||||
expiredEntries: number;
|
||||
cacheHitRate: number;
|
||||
averageLoadTime: number;
|
||||
totalRequests: number;
|
||||
debouncedRequests: number;
|
||||
batchRequests: number;
|
||||
memoryUsage: number; // Estimation en KB
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook useVWBActionDetails
|
||||
*/
|
||||
export function useVWBActionDetails(): UseVWBActionDetailsResult {
|
||||
// État du cache d'actions
|
||||
const [actionCache, setActionCache] = useState<Map<string, ActionCacheEntry>>(new Map());
|
||||
const [loadingStates, setLoadingStates] = useState<Map<string, ActionLoadingState>>(new Map());
|
||||
|
||||
// Références pour optimisation et debouncing
|
||||
const loadingPromisesRef = useRef<Map<string, Promise<VWBCatalogAction | null>>>(new Map());
|
||||
const loadTimesRef = useRef<number[]>([]);
|
||||
const cacheAccessesRef = useRef<{ hits: number; misses: number }>({ hits: 0, misses: 0 });
|
||||
const debounceTimersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||||
const batchQueueRef = useRef<Map<string, { resolve: Function; reject: Function; options: LoadActionOptions }[]>>(new Map());
|
||||
const performanceMetricsRef = useRef<{
|
||||
totalRequests: number;
|
||||
debouncedRequests: number;
|
||||
batchRequests: number;
|
||||
}>({ totalRequests: 0, debouncedRequests: 0, batchRequests: 0 });
|
||||
|
||||
/**
|
||||
* Obtient l'état de chargement d'une action
|
||||
*/
|
||||
const getActionState = useCallback((actionId: string): ActionLoadingState => {
|
||||
return loadingStates.get(actionId) || {
|
||||
isLoading: false,
|
||||
isLoaded: false,
|
||||
error: null,
|
||||
lastLoaded: 0,
|
||||
retryCount: 0
|
||||
};
|
||||
}, [loadingStates]);
|
||||
|
||||
/**
|
||||
* Met à jour l'état de chargement d'une action
|
||||
*/
|
||||
const updateActionState = useCallback((
|
||||
actionId: string,
|
||||
updates: Partial<ActionLoadingState>
|
||||
) => {
|
||||
setLoadingStates(prev => {
|
||||
const current = prev.get(actionId) || {
|
||||
isLoading: false,
|
||||
isLoaded: false,
|
||||
error: null,
|
||||
lastLoaded: 0,
|
||||
retryCount: 0
|
||||
};
|
||||
|
||||
return new Map(prev).set(actionId, { ...current, ...updates });
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Charge une action depuis le catalogue statique (fallback)
|
||||
*/
|
||||
const loadFromStaticCatalog = useCallback((actionId: string): VWBCatalogAction | null => {
|
||||
try {
|
||||
// Recherche avec fallback intelligent
|
||||
let staticAction = staticCatalog.findActionWithFallback(actionId);
|
||||
|
||||
if (staticAction) {
|
||||
console.log('📚 [useVWBActionDetails] Action trouvée avec fallback:', {
|
||||
actionId,
|
||||
foundId: staticAction.id,
|
||||
isFallback: staticAction.fallbackMetadata?.isFallback || false,
|
||||
confidence: staticAction.fallbackMetadata?.confidence || 1.0
|
||||
});
|
||||
return staticAction;
|
||||
}
|
||||
|
||||
// Créer une action de fallback générique
|
||||
const fallbackAction = staticCatalog.createFallbackAction(actionId);
|
||||
|
||||
console.log('🔧 [useVWBActionDetails] Action de fallback générique créée:', {
|
||||
actionId,
|
||||
category: fallbackAction.category,
|
||||
confidence: fallbackAction.fallbackMetadata?.confidence
|
||||
});
|
||||
|
||||
return fallbackAction;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ [useVWBActionDetails] Erreur fallback statique:', error);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Valide une entrée de cache
|
||||
*/
|
||||
const isCacheEntryValid = useCallback((
|
||||
entry: ActionCacheEntry,
|
||||
cacheTimeout: number = 300000 // 5 minutes par défaut
|
||||
): boolean => {
|
||||
const isNotExpired = Date.now() - entry.loadedAt < cacheTimeout;
|
||||
return entry.isValid && isNotExpired;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Charge une action avec gestion complète d'erreurs et fallback
|
||||
*/
|
||||
const loadAction = useCallback(async (
|
||||
actionId: string,
|
||||
options: LoadActionOptions = {}
|
||||
): Promise<VWBCatalogAction | null> => {
|
||||
const startTime = performance.now();
|
||||
performanceMetricsRef.current.totalRequests++;
|
||||
|
||||
const loadOptions = {
|
||||
forceReload: false,
|
||||
enableFallback: true,
|
||||
timeout: 5000,
|
||||
retryAttempts: 3,
|
||||
cacheTimeout: 300000, // 5 minutes
|
||||
debounceMs: 0, // Pas de debounce par défaut pour loadAction direct
|
||||
priority: 'normal' as const,
|
||||
...options
|
||||
};
|
||||
|
||||
try {
|
||||
// Vérifier le cache si pas de rechargement forcé
|
||||
if (!loadOptions.forceReload) {
|
||||
const cached = actionCache.get(actionId);
|
||||
if (cached && isCacheEntryValid(cached, loadOptions.cacheTimeout)) {
|
||||
cacheAccessesRef.current.hits++;
|
||||
console.log('🎯 [useVWBActionDetails] Cache hit:', actionId);
|
||||
return cached.action;
|
||||
}
|
||||
cacheAccessesRef.current.misses++;
|
||||
}
|
||||
|
||||
// Vérifier si un chargement est déjà en cours
|
||||
const existingPromise = loadingPromisesRef.current.get(actionId);
|
||||
if (existingPromise) {
|
||||
console.log('⏳ [useVWBActionDetails] Chargement en cours, attente:', actionId);
|
||||
return await existingPromise;
|
||||
}
|
||||
|
||||
// Créer la promesse de chargement
|
||||
const loadingPromise = (async (): Promise<VWBCatalogAction | null> => {
|
||||
updateActionState(actionId, {
|
||||
isLoading: true,
|
||||
error: null
|
||||
});
|
||||
|
||||
let lastError: Error | null = null;
|
||||
|
||||
// Tentatives de chargement avec retry
|
||||
for (let attempt = 1; attempt <= loadOptions.retryAttempts; attempt++) {
|
||||
try {
|
||||
console.log(`🔄 [useVWBActionDetails] Tentative ${attempt}/${loadOptions.retryAttempts}:`, actionId);
|
||||
|
||||
// Chargement depuis l'API avec timeout
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
setTimeout(() => reject(new Error('Timeout de chargement')), loadOptions.timeout);
|
||||
});
|
||||
|
||||
const loadPromise = catalogService.getActionDetails(actionId);
|
||||
const action = await Promise.race([loadPromise, timeoutPromise]);
|
||||
|
||||
if (action) {
|
||||
// Succès - mettre en cache
|
||||
const cacheEntry: ActionCacheEntry = {
|
||||
action,
|
||||
loadedAt: Date.now(),
|
||||
source: 'api',
|
||||
isValid: true
|
||||
};
|
||||
|
||||
setActionCache(prev => new Map(prev).set(actionId, cacheEntry));
|
||||
|
||||
updateActionState(actionId, {
|
||||
isLoading: false,
|
||||
isLoaded: true,
|
||||
error: null,
|
||||
lastLoaded: Date.now(),
|
||||
retryCount: 0
|
||||
});
|
||||
|
||||
console.log('✅ [useVWBActionDetails] Action chargée depuis l\'API:', actionId);
|
||||
return action;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
console.warn(`⚠️ [useVWBActionDetails] Tentative ${attempt} échouée:`, lastError.message);
|
||||
|
||||
// Délai exponentiel entre les tentatives
|
||||
if (attempt < loadOptions.retryAttempts) {
|
||||
const delay = Math.pow(2, attempt) * 1000;
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Toutes les tentatives ont échoué - essayer le fallback
|
||||
if (loadOptions.enableFallback) {
|
||||
console.log('🔄 [useVWBActionDetails] Tentative de fallback:', actionId);
|
||||
|
||||
const fallbackAction = loadFromStaticCatalog(actionId);
|
||||
if (fallbackAction) {
|
||||
const cacheEntry: ActionCacheEntry = {
|
||||
action: fallbackAction,
|
||||
loadedAt: Date.now(),
|
||||
source: 'static',
|
||||
isValid: true
|
||||
};
|
||||
|
||||
setActionCache(prev => new Map(prev).set(actionId, cacheEntry));
|
||||
|
||||
updateActionState(actionId, {
|
||||
isLoading: false,
|
||||
isLoaded: true,
|
||||
error: null,
|
||||
lastLoaded: Date.now(),
|
||||
retryCount: loadOptions.retryAttempts
|
||||
});
|
||||
|
||||
return fallbackAction;
|
||||
}
|
||||
}
|
||||
|
||||
// Échec complet
|
||||
updateActionState(actionId, {
|
||||
isLoading: false,
|
||||
isLoaded: false,
|
||||
error: lastError,
|
||||
retryCount: loadOptions.retryAttempts
|
||||
});
|
||||
|
||||
console.error('❌ [useVWBActionDetails] Échec complet du chargement:', actionId, lastError);
|
||||
return null;
|
||||
|
||||
})();
|
||||
|
||||
// Enregistrer la promesse
|
||||
loadingPromisesRef.current.set(actionId, loadingPromise);
|
||||
|
||||
try {
|
||||
const result = await loadingPromise;
|
||||
|
||||
// Enregistrer le temps de chargement
|
||||
const loadTime = performance.now() - startTime;
|
||||
loadTimesRef.current.push(loadTime);
|
||||
|
||||
return result;
|
||||
|
||||
} finally {
|
||||
// Nettoyer la promesse
|
||||
loadingPromisesRef.current.delete(actionId);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
const errorObj = error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
updateActionState(actionId, {
|
||||
isLoading: false,
|
||||
error: errorObj
|
||||
});
|
||||
|
||||
console.error('❌ [useVWBActionDetails] Erreur critique:', errorObj);
|
||||
return null;
|
||||
}
|
||||
}, [actionCache, isCacheEntryValid, updateActionState, loadFromStaticCatalog]);
|
||||
|
||||
/**
|
||||
* Charge une action avec debouncing pour éviter les appels répétés
|
||||
*/
|
||||
const loadActionDebounced = useCallback(async (
|
||||
actionId: string,
|
||||
options: LoadActionOptions = {}
|
||||
): Promise<VWBCatalogAction | null> => {
|
||||
const debounceMs = options.debounceMs || 300;
|
||||
performanceMetricsRef.current.debouncedRequests++;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// Annuler le timer précédent pour cette action
|
||||
const existingTimer = debounceTimersRef.current.get(actionId);
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer);
|
||||
}
|
||||
|
||||
// Créer un nouveau timer
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
const result = await loadAction(actionId, { ...options, debounceMs: 0 });
|
||||
resolve(result);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
} finally {
|
||||
debounceTimersRef.current.delete(actionId);
|
||||
}
|
||||
}, debounceMs);
|
||||
|
||||
debounceTimersRef.current.set(actionId, timer);
|
||||
});
|
||||
}, [loadAction]);
|
||||
|
||||
/**
|
||||
* Charge plusieurs actions en lot pour optimiser les performances
|
||||
*/
|
||||
const loadActionsBatch = useCallback(async (
|
||||
actionIds: string[],
|
||||
options: LoadActionOptions = {}
|
||||
): Promise<Map<string, VWBCatalogAction | null>> => {
|
||||
performanceMetricsRef.current.batchRequests++;
|
||||
|
||||
console.log('🚀 [useVWBActionDetails] Chargement en lot:', {
|
||||
actionCount: actionIds.length,
|
||||
actionIds: actionIds.slice(0, 5), // Afficher seulement les 5 premiers
|
||||
hasMore: actionIds.length > 5
|
||||
});
|
||||
|
||||
const results = new Map<string, VWBCatalogAction | null>();
|
||||
const batchSize = 5; // Traiter par lots de 5 pour éviter la surcharge
|
||||
|
||||
// Traiter les actions par lots
|
||||
for (let i = 0; i < actionIds.length; i += batchSize) {
|
||||
const batch = actionIds.slice(i, i + batchSize);
|
||||
|
||||
// Charger le lot en parallèle
|
||||
const batchPromises = batch.map(async (actionId) => {
|
||||
try {
|
||||
const action = await loadAction(actionId, options);
|
||||
return { actionId, action };
|
||||
} catch (error) {
|
||||
console.error(`❌ [useVWBActionDetails] Erreur lot pour ${actionId}:`, error);
|
||||
return { actionId, action: null };
|
||||
}
|
||||
});
|
||||
|
||||
const batchResults = await Promise.allSettled(batchPromises);
|
||||
|
||||
// Traiter les résultats du lot
|
||||
batchResults.forEach((result) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
results.set(result.value.actionId, result.value.action);
|
||||
}
|
||||
});
|
||||
|
||||
// Délai entre les lots pour éviter la surcharge
|
||||
if (i + batchSize < actionIds.length) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
|
||||
console.log('✅ [useVWBActionDetails] Chargement en lot terminé:', {
|
||||
requested: actionIds.length,
|
||||
loaded: Array.from(results.values()).filter(Boolean).length,
|
||||
failed: Array.from(results.values()).filter(a => a === null).length
|
||||
});
|
||||
|
||||
return results;
|
||||
}, [loadAction]);
|
||||
|
||||
/**
|
||||
* Préchauffe le cache avec des actions prioritaires
|
||||
*/
|
||||
const warmupCache = useCallback(async (actionIds: string[]): Promise<void> => {
|
||||
console.log('🔥 [useVWBActionDetails] Préchauffage du cache:', actionIds.length, 'actions');
|
||||
|
||||
// Charger en arrière-plan avec priorité basse
|
||||
const warmupPromises = actionIds.map(actionId =>
|
||||
loadAction(actionId, {
|
||||
enableFallback: true,
|
||||
priority: 'low',
|
||||
cacheTimeout: 600000 // Cache plus long pour le préchauffage (10 minutes)
|
||||
}).catch(error => {
|
||||
console.warn(`⚠️ [useVWBActionDetails] Échec préchauffage ${actionId}:`, error);
|
||||
return null;
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.allSettled(warmupPromises);
|
||||
console.log('✅ [useVWBActionDetails] Préchauffage terminé');
|
||||
}, [loadAction]);
|
||||
|
||||
/**
|
||||
* Obtient une action depuis le cache
|
||||
*/
|
||||
const getAction = useCallback((actionId: string): VWBCatalogAction | null => {
|
||||
const cached = actionCache.get(actionId);
|
||||
if (cached && isCacheEntryValid(cached)) {
|
||||
return cached.action;
|
||||
}
|
||||
return null;
|
||||
}, [actionCache, isCacheEntryValid]);
|
||||
|
||||
/**
|
||||
* Précharge plusieurs actions en parallèle
|
||||
*/
|
||||
const preloadActions = useCallback(async (actionIds: string[]): Promise<void> => {
|
||||
console.log('🚀 [useVWBActionDetails] Préchargement de', actionIds.length, 'actions');
|
||||
|
||||
const loadPromises = actionIds.map(actionId =>
|
||||
loadAction(actionId, { enableFallback: true })
|
||||
);
|
||||
|
||||
try {
|
||||
await Promise.allSettled(loadPromises);
|
||||
console.log('✅ [useVWBActionDetails] Préchargement terminé');
|
||||
} catch (error) {
|
||||
console.error('❌ [useVWBActionDetails] Erreur préchargement:', error);
|
||||
}
|
||||
}, [loadAction]);
|
||||
|
||||
/**
|
||||
* Invalide le cache
|
||||
*/
|
||||
const invalidateCache = useCallback((actionId?: string) => {
|
||||
if (actionId) {
|
||||
setActionCache(prev => {
|
||||
const newCache = new Map(prev);
|
||||
newCache.delete(actionId);
|
||||
return newCache;
|
||||
});
|
||||
console.log('🗑️ [useVWBActionDetails] Cache invalidé pour:', actionId);
|
||||
} else {
|
||||
setActionCache(new Map());
|
||||
setLoadingStates(new Map());
|
||||
loadingPromisesRef.current.clear();
|
||||
console.log('🗑️ [useVWBActionDetails] Cache complet invalidé');
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Obtient les statistiques du cache avec métriques de performance
|
||||
*/
|
||||
const getCacheStats = useCallback((): CacheStats => {
|
||||
const entries = Array.from(actionCache.values());
|
||||
const now = Date.now();
|
||||
|
||||
const apiEntries = entries.filter(e => e.source === 'api').length;
|
||||
const staticEntries = entries.filter(e => e.source === 'static').length;
|
||||
const fallbackEntries = entries.filter(e => e.source === 'fallback').length;
|
||||
const validEntries = entries.filter(e => isCacheEntryValid(e)).length;
|
||||
const expiredEntries = entries.length - validEntries;
|
||||
|
||||
const totalAccesses = cacheAccessesRef.current.hits + cacheAccessesRef.current.misses;
|
||||
const cacheHitRate = totalAccesses > 0 ? cacheAccessesRef.current.hits / totalAccesses : 0;
|
||||
|
||||
const averageLoadTime = loadTimesRef.current.length > 0
|
||||
? loadTimesRef.current.reduce((a, b) => a + b, 0) / loadTimesRef.current.length
|
||||
: 0;
|
||||
|
||||
// Estimation de l'usage mémoire (approximatif)
|
||||
const memoryUsage = entries.reduce((total, entry) => {
|
||||
const actionSize = JSON.stringify(entry.action).length;
|
||||
return total + actionSize;
|
||||
}, 0) / 1024; // Convertir en KB
|
||||
|
||||
return {
|
||||
totalEntries: entries.length,
|
||||
apiEntries,
|
||||
staticEntries,
|
||||
fallbackEntries,
|
||||
validEntries,
|
||||
expiredEntries,
|
||||
cacheHitRate,
|
||||
averageLoadTime,
|
||||
totalRequests: performanceMetricsRef.current.totalRequests,
|
||||
debouncedRequests: performanceMetricsRef.current.debouncedRequests,
|
||||
batchRequests: performanceMetricsRef.current.batchRequests,
|
||||
memoryUsage
|
||||
};
|
||||
}, [actionCache, isCacheEntryValid]);
|
||||
|
||||
/**
|
||||
* Valide une action avec ses paramètres
|
||||
*/
|
||||
const validateAction = useCallback(async (
|
||||
actionId: string,
|
||||
parameters: Record<string, any>
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
const action = await loadAction(actionId);
|
||||
if (!action) {
|
||||
console.warn('⚠️ [useVWBActionDetails] Action non trouvée pour validation:', actionId);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validation de l'action elle-même si c'est une action statique
|
||||
if ('fallbackMetadata' in action) {
|
||||
const staticAction = action as any; // StaticCatalogAction
|
||||
const validation = staticCatalog.validateStaticAction(staticAction);
|
||||
|
||||
if (!validation.isValid) {
|
||||
console.error('❌ [useVWBActionDetails] Action invalide:', {
|
||||
actionId,
|
||||
errors: validation.errors
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (validation.warnings.length > 0) {
|
||||
console.warn('⚠️ [useVWBActionDetails] Avertissements action:', {
|
||||
actionId,
|
||||
warnings: validation.warnings
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Validation des paramètres requis
|
||||
const missingParams: string[] = [];
|
||||
const invalidParams: string[] = [];
|
||||
|
||||
for (const [paramName, paramConfig] of Object.entries(action.parameters)) {
|
||||
const paramValue = parameters[paramName];
|
||||
|
||||
// Vérifier les paramètres requis
|
||||
if (paramConfig.required && (paramValue === undefined || paramValue === null || paramValue === '')) {
|
||||
missingParams.push(paramName);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validation de type si la valeur est présente
|
||||
if (paramValue !== undefined && paramValue !== null) {
|
||||
const isValidType = validateParameterType(paramValue, paramConfig.type);
|
||||
if (!isValidType) {
|
||||
invalidParams.push(`${paramName} (attendu: ${paramConfig.type}, reçu: ${typeof paramValue})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rapporter les erreurs de validation
|
||||
if (missingParams.length > 0) {
|
||||
console.error('❌ [useVWBActionDetails] Paramètres requis manquants:', {
|
||||
actionId,
|
||||
missingParams
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (invalidParams.length > 0) {
|
||||
console.error('❌ [useVWBActionDetails] Paramètres de type invalide:', {
|
||||
actionId,
|
||||
invalidParams
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log('✅ [useVWBActionDetails] Validation réussie:', {
|
||||
actionId,
|
||||
parameterCount: Object.keys(parameters).length
|
||||
});
|
||||
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ [useVWBActionDetails] Erreur validation:', error);
|
||||
return false;
|
||||
}
|
||||
}, [loadAction]);
|
||||
|
||||
/**
|
||||
* Valide le type d'un paramètre
|
||||
*/
|
||||
const validateParameterType = (value: any, expectedType: string): boolean => {
|
||||
switch (expectedType) {
|
||||
case 'string':
|
||||
return typeof value === 'string';
|
||||
case 'number':
|
||||
return typeof value === 'number' && !isNaN(value);
|
||||
case 'boolean':
|
||||
return typeof value === 'boolean';
|
||||
case 'VWBVisualAnchor':
|
||||
return value && typeof value === 'object' && 'x' in value && 'y' in value;
|
||||
default:
|
||||
// Type inconnu, accepter par défaut
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Valide plusieurs actions en lot
|
||||
*/
|
||||
const validateActionsBatch = useCallback(async (
|
||||
actions: Array<{ id: string; parameters: Record<string, any> }>
|
||||
): Promise<Map<string, boolean>> => {
|
||||
console.log('🔍 [useVWBActionDetails] Validation en lot:', actions.length, 'actions');
|
||||
|
||||
const results = new Map<string, boolean>();
|
||||
|
||||
// Valider en parallèle avec limite de concurrence
|
||||
const concurrencyLimit = 3;
|
||||
for (let i = 0; i < actions.length; i += concurrencyLimit) {
|
||||
const batch = actions.slice(i, i + concurrencyLimit);
|
||||
|
||||
const batchPromises = batch.map(async ({ id, parameters }) => {
|
||||
try {
|
||||
const isValid = await validateAction(id, parameters);
|
||||
return { id, isValid };
|
||||
} catch (error) {
|
||||
console.error(`❌ [useVWBActionDetails] Erreur validation ${id}:`, error);
|
||||
return { id, isValid: false };
|
||||
}
|
||||
});
|
||||
|
||||
const batchResults = await Promise.allSettled(batchPromises);
|
||||
|
||||
batchResults.forEach((result) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
results.set(result.value.id, result.value.isValid);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const validCount = Array.from(results.values()).filter(Boolean).length;
|
||||
console.log('✅ [useVWBActionDetails] Validation lot terminée:', {
|
||||
total: actions.length,
|
||||
valid: validCount,
|
||||
invalid: actions.length - validCount
|
||||
});
|
||||
|
||||
return results;
|
||||
}, [validateAction]);
|
||||
|
||||
// État global dérivé
|
||||
const globalState = useMemo(() => {
|
||||
const states = Array.from(loadingStates.values());
|
||||
|
||||
return {
|
||||
isLoading: states.some(s => s.isLoading),
|
||||
hasErrors: states.some(s => s.error !== null),
|
||||
totalActions: actionCache.size
|
||||
};
|
||||
}, [loadingStates, actionCache]);
|
||||
|
||||
// Nettoyage périodique du cache et des timers
|
||||
useEffect(() => {
|
||||
const cleanupInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
const expiredKeys: string[] = [];
|
||||
|
||||
// Nettoyer le cache expiré
|
||||
actionCache.forEach((entry, key) => {
|
||||
if (!isCacheEntryValid(entry)) {
|
||||
expiredKeys.push(key);
|
||||
}
|
||||
});
|
||||
|
||||
if (expiredKeys.length > 0) {
|
||||
setActionCache(prev => {
|
||||
const newCache = new Map(prev);
|
||||
expiredKeys.forEach(key => newCache.delete(key));
|
||||
return newCache;
|
||||
});
|
||||
|
||||
console.log(`🧹 [useVWBActionDetails] ${expiredKeys.length} entrées expirées nettoyées`);
|
||||
}
|
||||
|
||||
// Nettoyer les timers de debounce expirés
|
||||
const expiredTimers: string[] = [];
|
||||
debounceTimersRef.current.forEach((timer, actionId) => {
|
||||
// Les timers sont automatiquement nettoyés, mais on peut vérifier s'il y en a trop
|
||||
if (debounceTimersRef.current.size > 50) {
|
||||
expiredTimers.push(actionId);
|
||||
}
|
||||
});
|
||||
|
||||
// Limiter la taille des métriques de performance
|
||||
if (loadTimesRef.current.length > 1000) {
|
||||
loadTimesRef.current = loadTimesRef.current.slice(-500); // Garder les 500 derniers
|
||||
}
|
||||
|
||||
}, 60000); // Nettoyage toutes les minutes
|
||||
|
||||
return () => clearInterval(cleanupInterval);
|
||||
}, [actionCache, isCacheEntryValid]);
|
||||
|
||||
// Nettoyage à la destruction du composant
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// Nettoyer tous les timers de debounce
|
||||
debounceTimersRef.current.forEach(timer => clearTimeout(timer));
|
||||
debounceTimersRef.current.clear();
|
||||
|
||||
// Nettoyer les promesses en cours
|
||||
loadingPromisesRef.current.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// État global
|
||||
...globalState,
|
||||
|
||||
// Méthodes de chargement optimisées
|
||||
loadAction,
|
||||
loadActionDebounced,
|
||||
loadActionsBatch,
|
||||
getAction,
|
||||
preloadActions,
|
||||
|
||||
// Gestion du cache multi-niveaux
|
||||
invalidateCache,
|
||||
warmupCache,
|
||||
getCacheStats,
|
||||
|
||||
// État des actions individuelles
|
||||
getActionState,
|
||||
|
||||
// Validation optimisée
|
||||
validateAction,
|
||||
validateActionsBatch
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Export par défaut
|
||||
*/
|
||||
export default useVWBActionDetails;
|
||||
@@ -13,16 +13,57 @@ import {
|
||||
VWBExecutionOptions,
|
||||
VWBExecutionContext
|
||||
} from '../services/vwbExecutionService';
|
||||
import {
|
||||
Workflow,
|
||||
Step,
|
||||
StepExecutionState,
|
||||
import {
|
||||
Workflow,
|
||||
Step,
|
||||
StepExecutionState,
|
||||
ExecutionState,
|
||||
ExecutionError,
|
||||
Evidence,
|
||||
Variable
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
* Émet un BIP sonore d'alerte via l'API Web Audio
|
||||
* Utilisé pour alerter l'utilisateur quand une erreur stoppe le workflow
|
||||
*/
|
||||
const playErrorBeep = async (): Promise<void> => {
|
||||
try {
|
||||
// Créer un contexte audio
|
||||
const AudioContext = window.AudioContext || (window as any).webkitAudioContext;
|
||||
if (!AudioContext) {
|
||||
console.warn('Web Audio API non disponible');
|
||||
return;
|
||||
}
|
||||
|
||||
const audioCtx = new AudioContext();
|
||||
|
||||
// Jouer 3 bips rapides pour alerter
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const oscillator = audioCtx.createOscillator();
|
||||
const gainNode = audioCtx.createGain();
|
||||
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(audioCtx.destination);
|
||||
|
||||
// Fréquence du bip (800 Hz = son d'erreur)
|
||||
oscillator.frequency.value = 800;
|
||||
oscillator.type = 'sine';
|
||||
|
||||
// Volume
|
||||
gainNode.gain.value = 0.3;
|
||||
|
||||
const startTime = audioCtx.currentTime + (i * 0.15);
|
||||
oscillator.start(startTime);
|
||||
oscillator.stop(startTime + 0.1);
|
||||
}
|
||||
|
||||
console.log('🔔 BIP BIP BIP - Alerte sonore jouée');
|
||||
} catch (error) {
|
||||
console.warn('Impossible de jouer le bip sonore:', error);
|
||||
}
|
||||
};
|
||||
|
||||
export interface VWBExecutionState {
|
||||
status: 'idle' | 'running' | 'paused' | 'completed' | 'error';
|
||||
currentStepIndex: number;
|
||||
@@ -66,11 +107,15 @@ export interface UseVWBExecutionOptions {
|
||||
retryAttempts?: number;
|
||||
timeout?: number;
|
||||
pauseOnError?: boolean;
|
||||
stopOnError?: boolean; // IMPORTANT: Arrêter complètement le workflow sur erreur (par défaut: true)
|
||||
skipNonVWBSteps?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook principal pour l'exécution des workflows VWB
|
||||
*
|
||||
* OPTIMISATION: Utilise des refs pour les callbacks et options
|
||||
* pour éviter les re-créations de fonctions à chaque render.
|
||||
*/
|
||||
export const useVWBExecution = (
|
||||
workflow: Workflow,
|
||||
@@ -84,9 +129,23 @@ export const useVWBExecution = (
|
||||
retryAttempts = 3,
|
||||
timeout = 30000,
|
||||
pauseOnError = false,
|
||||
stopOnError = true, // IMPORTANT: Par défaut, STOPPER le workflow sur erreur!
|
||||
skipNonVWBSteps = false
|
||||
} = options;
|
||||
|
||||
// OPTIMISATION: Refs pour callbacks et options (évite les re-renders)
|
||||
const callbacksRef = useRef(callbacks);
|
||||
const optionsRef = useRef({ autoValidate, generateEvidence, retryAttempts, timeout, pauseOnError, stopOnError, skipNonVWBSteps });
|
||||
|
||||
// Mettre à jour les refs quand les valeurs changent (sans causer de re-render)
|
||||
useEffect(() => {
|
||||
callbacksRef.current = callbacks;
|
||||
}, [callbacks]);
|
||||
|
||||
useEffect(() => {
|
||||
optionsRef.current = { autoValidate, generateEvidence, retryAttempts, timeout, pauseOnError, stopOnError, skipNonVWBSteps };
|
||||
}, [autoValidate, generateEvidence, retryAttempts, timeout, pauseOnError, stopOnError, skipNonVWBSteps]);
|
||||
|
||||
// État d'exécution
|
||||
const [executionState, setExecutionState] = useState<VWBExecutionState>({
|
||||
status: 'idle',
|
||||
@@ -115,6 +174,9 @@ export const useVWBExecution = (
|
||||
shouldStop: false
|
||||
});
|
||||
|
||||
// Ref pour la fonction d'exécution (évite les stale closures)
|
||||
const executeWorkflowStepsRef = useRef<(steps: Step[]) => Promise<void>>(() => Promise.resolve());
|
||||
|
||||
// Initialiser le contexte d'exécution
|
||||
useEffect(() => {
|
||||
const variablesMap = variables.reduce((acc, variable) => {
|
||||
@@ -170,7 +232,7 @@ export const useVWBExecution = (
|
||||
return;
|
||||
}
|
||||
|
||||
// Réinitialiser l'état
|
||||
// Réinitialiser complètement l'état d'exécution
|
||||
executionRef.current = {
|
||||
isRunning: true,
|
||||
isPaused: false,
|
||||
@@ -178,29 +240,30 @@ export const useVWBExecution = (
|
||||
};
|
||||
|
||||
const startTime = new Date();
|
||||
|
||||
setExecutionState(prev => ({
|
||||
...prev,
|
||||
startTimeRef.current = startTime; // Stocker dans la ref pour finalizeExecution
|
||||
|
||||
// Reset complet de l'état avant de commencer
|
||||
setExecutionState({
|
||||
status: 'running',
|
||||
startTime,
|
||||
endTime: null,
|
||||
currentStepIndex: 0,
|
||||
currentStep: workflow.steps[0],
|
||||
totalSteps: workflow.steps.length,
|
||||
completedSteps: 0,
|
||||
failedSteps: 0,
|
||||
startTime,
|
||||
endTime: null,
|
||||
duration: 0,
|
||||
progress: 0,
|
||||
results: [],
|
||||
errors: [],
|
||||
evidence: []
|
||||
}));
|
||||
});
|
||||
|
||||
try {
|
||||
// Passer les steps directement pour éviter le problème de stale closure
|
||||
await executeWorkflowSteps(workflow.steps);
|
||||
// Utiliser la ref pour avoir toujours la dernière version de executeWorkflowSteps
|
||||
await executeWorkflowStepsRef.current(workflow.steps);
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de l\'exécution du workflow:', error);
|
||||
// handleExecutionError défini plus bas, on gère l'erreur ici directement
|
||||
executionRef.current.isRunning = false;
|
||||
setExecutionState(prev => ({
|
||||
...prev,
|
||||
@@ -213,7 +276,7 @@ export const useVWBExecution = (
|
||||
}]
|
||||
}));
|
||||
}
|
||||
}, [workflow.steps]);
|
||||
}, [workflow.steps, workflow.id]);
|
||||
|
||||
/**
|
||||
* Exécuter toutes les étapes du workflow
|
||||
@@ -256,16 +319,19 @@ export const useVWBExecution = (
|
||||
progress: (i / steps.length) * 100
|
||||
}));
|
||||
|
||||
// Callback de début d'étape
|
||||
callbacks.onStepStart?.(step, i);
|
||||
callbacks.onProgressUpdate?.(i / steps.length, step);
|
||||
// Callback de début d'étape (utilise ref pour éviter stale closure)
|
||||
callbacksRef.current.onStepStart?.(step, i);
|
||||
callbacksRef.current.onProgressUpdate?.(i / steps.length, step);
|
||||
|
||||
try {
|
||||
// Vérifier si c'est une étape VWB
|
||||
const isVWBStep = vwbExecutionService.isVWBStep(step);
|
||||
console.log(`🔍 [VWB] Étape ${step.id} isVWBStep:`, isVWBStep);
|
||||
|
||||
if (!isVWBStep && skipNonVWBSteps) {
|
||||
// Utiliser les options depuis la ref
|
||||
const opts = optionsRef.current;
|
||||
|
||||
if (!isVWBStep && opts.skipNonVWBSteps) {
|
||||
console.log(`⏭️ [VWB] Étape ${step.id} ignorée (non-VWB)`);
|
||||
continue;
|
||||
}
|
||||
@@ -276,10 +342,10 @@ export const useVWBExecution = (
|
||||
console.log(`🎯 [VWB] Exécution VWB de l'étape ${step.id}...`);
|
||||
// Exécuter l'étape VWB
|
||||
const executionOptions: VWBExecutionOptions = {
|
||||
timeout,
|
||||
retryAttempts,
|
||||
validateBeforeExecution: autoValidate,
|
||||
generateEvidence
|
||||
timeout: opts.timeout,
|
||||
retryAttempts: opts.retryAttempts,
|
||||
validateBeforeExecution: opts.autoValidate,
|
||||
generateEvidence: opts.generateEvidence
|
||||
};
|
||||
|
||||
result = await vwbExecutionService.executeStep(step, executionOptions);
|
||||
@@ -303,10 +369,10 @@ export const useVWBExecution = (
|
||||
// Ajouter les Evidence
|
||||
if (result.evidence) {
|
||||
evidence.push(...result.evidence);
|
||||
callbacks.onEvidenceGenerated?.(step.id, result.evidence);
|
||||
callbacksRef.current.onEvidenceGenerated?.(step.id, result.evidence);
|
||||
}
|
||||
|
||||
callbacks.onStepComplete?.(step, result);
|
||||
callbacksRef.current.onStepComplete?.(step, result);
|
||||
} else {
|
||||
setExecutionState(prev => ({
|
||||
...prev,
|
||||
@@ -315,11 +381,19 @@ export const useVWBExecution = (
|
||||
|
||||
if (result.error) {
|
||||
errors.push(result.error);
|
||||
callbacks.onStepError?.(step, result.error);
|
||||
callbacksRef.current.onStepError?.(step, result.error);
|
||||
}
|
||||
|
||||
// Arrêter si configuré pour s'arrêter sur erreur
|
||||
if (pauseOnError) {
|
||||
// STOPPER LE WORKFLOW SUR ERREUR (comportement par défaut)
|
||||
if (opts.stopOnError) {
|
||||
console.log('🛑 [VWB] ARRÊT DU WORKFLOW - Erreur détectée et stopOnError=true');
|
||||
playErrorBeep(); // BIP BIP BIP pour alerter l'utilisateur
|
||||
executionRef.current.shouldStop = true;
|
||||
break; // Sortir immédiatement de la boucle
|
||||
}
|
||||
|
||||
// Sinon juste mettre en pause si configuré
|
||||
if (opts.pauseOnError) {
|
||||
executionRef.current.isPaused = true;
|
||||
}
|
||||
}
|
||||
@@ -330,20 +404,27 @@ export const useVWBExecution = (
|
||||
const executionError: ExecutionError = {
|
||||
stepId: step.id,
|
||||
message: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||
// type: 'execution_error',
|
||||
timestamp: new Date(),
|
||||
// context: { stepIndex: i }
|
||||
};
|
||||
|
||||
errors.push(executionError);
|
||||
callbacks.onStepError?.(step, executionError);
|
||||
callbacksRef.current.onStepError?.(step, executionError);
|
||||
|
||||
setExecutionState(prev => ({
|
||||
...prev,
|
||||
failedSteps: prev.failedSteps + 1
|
||||
}));
|
||||
|
||||
if (pauseOnError) {
|
||||
// STOPPER LE WORKFLOW SUR EXCEPTION (comportement par défaut)
|
||||
if (optionsRef.current.stopOnError) {
|
||||
console.log('🛑 [VWB] ARRÊT DU WORKFLOW - Exception détectée et stopOnError=true');
|
||||
playErrorBeep(); // BIP BIP BIP pour alerter l'utilisateur
|
||||
executionRef.current.shouldStop = true;
|
||||
break; // Sortir immédiatement de la boucle
|
||||
}
|
||||
|
||||
// Sinon juste mettre en pause si configuré
|
||||
if (optionsRef.current.pauseOnError) {
|
||||
console.log('⏸️ [VWB] Pause sur erreur activée');
|
||||
executionRef.current.isPaused = true;
|
||||
}
|
||||
@@ -355,8 +436,13 @@ export const useVWBExecution = (
|
||||
console.log('🏁 [VWB] Boucle terminée, finalisation...');
|
||||
// Finaliser l'exécution
|
||||
await finalizeExecution(results, errors, evidence);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [callbacks, autoValidate, generateEvidence, timeout, retryAttempts, pauseOnError, skipNonVWBSteps]);
|
||||
// Pas de dépendances aux callbacks/options car on utilise des refs
|
||||
}, []);
|
||||
|
||||
// Mettre à jour la ref avec la dernière version de executeWorkflowSteps
|
||||
useEffect(() => {
|
||||
executeWorkflowStepsRef.current = executeWorkflowSteps;
|
||||
}, [executeWorkflowSteps]);
|
||||
|
||||
/**
|
||||
* Simuler l'exécution d'une étape non-VWB
|
||||
@@ -374,8 +460,18 @@ export const useVWBExecution = (
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Ref pour stocker le startTime de l'exécution en cours
|
||||
const startTimeRef = useRef<Date | null>(null);
|
||||
const workflowStepsLengthRef = useRef(workflow.steps.length);
|
||||
|
||||
// Mettre à jour la ref quand le workflow change
|
||||
useEffect(() => {
|
||||
workflowStepsLengthRef.current = workflow.steps.length;
|
||||
}, [workflow.steps.length]);
|
||||
|
||||
/**
|
||||
* Finaliser l'exécution
|
||||
* Note: Utilise des refs pour éviter les dépendances instables
|
||||
*/
|
||||
const finalizeExecution = useCallback(async (
|
||||
results: VWBExecutionResult[],
|
||||
@@ -383,8 +479,10 @@ export const useVWBExecution = (
|
||||
evidence: Evidence[]
|
||||
) => {
|
||||
const endTime = new Date();
|
||||
const duration = executionState.startTime ? endTime.getTime() - executionState.startTime.getTime() : 0;
|
||||
const startTime = startTimeRef.current;
|
||||
const duration = startTime ? endTime.getTime() - startTime.getTime() : 0;
|
||||
const successRate = results.length > 0 ? (results.filter(r => r.success).length / results.length) * 100 : 0;
|
||||
const totalSteps = workflowStepsLengthRef.current;
|
||||
|
||||
executionRef.current.isRunning = false;
|
||||
|
||||
@@ -401,10 +499,10 @@ export const useVWBExecution = (
|
||||
|
||||
// Créer le résumé d'exécution
|
||||
const summary: VWBExecutionSummary = {
|
||||
totalSteps: workflow.steps.length,
|
||||
totalSteps,
|
||||
completedSteps: results.filter(r => r.success).length,
|
||||
failedSteps: results.filter(r => !r.success).length,
|
||||
skippedSteps: workflow.steps.length - results.length,
|
||||
skippedSteps: totalSteps - results.length,
|
||||
duration,
|
||||
successRate,
|
||||
results,
|
||||
@@ -412,8 +510,8 @@ export const useVWBExecution = (
|
||||
evidence
|
||||
};
|
||||
|
||||
callbacks.onExecutionComplete?.(errors.length === 0, summary);
|
||||
}, [workflow.steps.length, executionState.startTime, callbacks]);
|
||||
callbacksRef.current.onExecutionComplete?.(errors.length === 0, summary);
|
||||
}, []); // Pas de dépendances - utilise des refs
|
||||
|
||||
/**
|
||||
* Mettre en pause l'exécution
|
||||
|
||||
292
visual_workflow_builder/frontend/src/hooks/useVirtualization.ts
Normal file
292
visual_workflow_builder/frontend/src/hooks/useVirtualization.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* Hook de Virtualisation - Optimisation pour les listes longues
|
||||
* Auteur : Dom, Alice, Kiro - 08 janvier 2026
|
||||
*
|
||||
* Ce hook implémente la virtualisation pour optimiser le rendu
|
||||
* de listes longues en ne rendant que les éléments visibles.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
|
||||
interface VirtualizationOptions {
|
||||
itemHeight: number;
|
||||
containerHeight: number;
|
||||
overscan?: number; // Nombre d'éléments supplémentaires à rendre hors de la vue
|
||||
threshold?: number; // Seuil à partir duquel activer la virtualisation
|
||||
}
|
||||
|
||||
interface VirtualizedItem<T> {
|
||||
index: number;
|
||||
item: T;
|
||||
style: React.CSSProperties;
|
||||
}
|
||||
|
||||
interface VirtualizationResult<T> {
|
||||
virtualItems: VirtualizedItem<T>[];
|
||||
totalHeight: number;
|
||||
scrollToIndex: (index: number) => void;
|
||||
isVirtualized: boolean;
|
||||
containerProps: {
|
||||
style: React.CSSProperties;
|
||||
onScroll: (event: React.UIEvent<HTMLDivElement>) => void;
|
||||
ref: React.RefObject<HTMLDivElement | null>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook de virtualisation pour les listes longues
|
||||
*
|
||||
* @param items - Liste des éléments à virtualiser
|
||||
* @param options - Options de virtualisation
|
||||
* @returns Résultat de la virtualisation avec éléments visibles et props
|
||||
*/
|
||||
export function useVirtualization<T>(
|
||||
items: T[],
|
||||
options: VirtualizationOptions
|
||||
): VirtualizationResult<T> {
|
||||
const {
|
||||
itemHeight,
|
||||
containerHeight,
|
||||
overscan = 5,
|
||||
threshold = 50,
|
||||
} = options;
|
||||
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Déterminer si la virtualisation doit être activée
|
||||
const isVirtualized = items.length > threshold;
|
||||
|
||||
// Calculer les indices des éléments visibles
|
||||
const visibleRange = useMemo(() => {
|
||||
if (!isVirtualized) {
|
||||
return { start: 0, end: items.length - 1 };
|
||||
}
|
||||
|
||||
const start = Math.floor(scrollTop / itemHeight);
|
||||
const visibleCount = Math.ceil(containerHeight / itemHeight);
|
||||
const end = start + visibleCount - 1;
|
||||
|
||||
return {
|
||||
start: Math.max(0, start - overscan),
|
||||
end: Math.min(items.length - 1, end + overscan),
|
||||
};
|
||||
}, [scrollTop, itemHeight, containerHeight, overscan, items.length, isVirtualized]);
|
||||
|
||||
// Créer les éléments virtualisés
|
||||
const virtualItems = useMemo(() => {
|
||||
if (!isVirtualized) {
|
||||
// Si pas de virtualisation, retourner tous les éléments
|
||||
return items.map((item, index) => ({
|
||||
index,
|
||||
item,
|
||||
style: {
|
||||
height: itemHeight,
|
||||
width: '100%',
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
const result: VirtualizedItem<T>[] = [];
|
||||
|
||||
for (let i = visibleRange.start; i <= visibleRange.end; i++) {
|
||||
if (i < items.length) {
|
||||
result.push({
|
||||
index: i,
|
||||
item: items[i],
|
||||
style: {
|
||||
position: 'absolute' as const,
|
||||
top: i * itemHeight,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: itemHeight,
|
||||
width: '100%',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [items, visibleRange, itemHeight, isVirtualized]);
|
||||
|
||||
// Hauteur totale du conteneur
|
||||
const totalHeight = isVirtualized ? items.length * itemHeight : 'auto';
|
||||
|
||||
// Gestionnaire de scroll
|
||||
const handleScroll = useCallback((event: React.UIEvent<HTMLDivElement>) => {
|
||||
const target = event.currentTarget;
|
||||
setScrollTop(target.scrollTop);
|
||||
}, []);
|
||||
|
||||
// Fonction pour scroller vers un index spécifique
|
||||
const scrollToIndex = useCallback((index: number) => {
|
||||
if (containerRef.current && isVirtualized) {
|
||||
const scrollTop = index * itemHeight;
|
||||
containerRef.current.scrollTop = scrollTop;
|
||||
setScrollTop(scrollTop);
|
||||
}
|
||||
}, [itemHeight, isVirtualized]);
|
||||
|
||||
// Props pour le conteneur
|
||||
const containerProps = useMemo(() => ({
|
||||
style: {
|
||||
height: containerHeight,
|
||||
overflow: 'auto' as const,
|
||||
position: 'relative' as const,
|
||||
},
|
||||
onScroll: handleScroll,
|
||||
ref: containerRef,
|
||||
}), [containerHeight, handleScroll]);
|
||||
|
||||
return {
|
||||
virtualItems,
|
||||
totalHeight: typeof totalHeight === 'number' ? totalHeight : containerHeight,
|
||||
scrollToIndex,
|
||||
isVirtualized,
|
||||
containerProps,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook de virtualisation avec recherche et filtrage
|
||||
*
|
||||
* @param items - Liste des éléments originaux
|
||||
* @param searchQuery - Requête de recherche
|
||||
* @param filterFn - Fonction de filtrage
|
||||
* @param searchFn - Fonction de recherche personnalisée
|
||||
* @param options - Options de virtualisation
|
||||
* @returns Résultat de la virtualisation avec éléments filtrés
|
||||
*/
|
||||
export function useVirtualizedSearch<T>(
|
||||
items: T[],
|
||||
searchQuery: string,
|
||||
filterFn: (item: T, query: string) => boolean,
|
||||
options: VirtualizationOptions,
|
||||
searchFn?: (items: T[], query: string) => T[]
|
||||
): VirtualizationResult<T> & {
|
||||
filteredItems: T[];
|
||||
totalCount: number;
|
||||
filteredCount: number;
|
||||
} {
|
||||
// Filtrer les éléments selon la recherche
|
||||
const filteredItems = useMemo(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
return items;
|
||||
}
|
||||
|
||||
if (searchFn) {
|
||||
return searchFn(items, searchQuery);
|
||||
}
|
||||
|
||||
return items.filter(item => filterFn(item, searchQuery));
|
||||
}, [items, searchQuery, filterFn, searchFn]);
|
||||
|
||||
// Utiliser la virtualisation sur les éléments filtrés
|
||||
const virtualizationResult = useVirtualization(filteredItems, options);
|
||||
|
||||
return {
|
||||
...virtualizationResult,
|
||||
filteredItems,
|
||||
totalCount: items.length,
|
||||
filteredCount: filteredItems.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook de virtualisation avec pagination
|
||||
*
|
||||
* @param items - Liste des éléments
|
||||
* @param pageSize - Taille de la page
|
||||
* @param options - Options de virtualisation
|
||||
* @returns Résultat avec pagination et virtualisation
|
||||
*/
|
||||
export function useVirtualizedPagination<T>(
|
||||
items: T[],
|
||||
pageSize: number = 50,
|
||||
options: VirtualizationOptions
|
||||
): VirtualizationResult<T> & {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
setCurrentPage: (page: number) => void;
|
||||
nextPage: () => void;
|
||||
prevPage: () => void;
|
||||
canNextPage: boolean;
|
||||
canPrevPage: boolean;
|
||||
} {
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
|
||||
// Calculer les éléments de la page actuelle
|
||||
const paginatedItems = useMemo(() => {
|
||||
const start = currentPage * pageSize;
|
||||
const end = start + pageSize;
|
||||
return items.slice(start, end);
|
||||
}, [items, currentPage, pageSize]);
|
||||
|
||||
const totalPages = Math.ceil(items.length / pageSize);
|
||||
|
||||
// Utiliser la virtualisation sur les éléments paginés
|
||||
const virtualizationResult = useVirtualization(paginatedItems, options);
|
||||
|
||||
// Fonctions de navigation
|
||||
const nextPage = useCallback(() => {
|
||||
setCurrentPage(prev => Math.min(prev + 1, totalPages - 1));
|
||||
}, [totalPages]);
|
||||
|
||||
const prevPage = useCallback(() => {
|
||||
setCurrentPage(prev => Math.max(prev - 1, 0));
|
||||
}, []);
|
||||
|
||||
const canNextPage = currentPage < totalPages - 1;
|
||||
const canPrevPage = currentPage > 0;
|
||||
|
||||
// Réinitialiser la page si elle dépasse le nombre total
|
||||
useEffect(() => {
|
||||
if (currentPage >= totalPages && totalPages > 0) {
|
||||
setCurrentPage(totalPages - 1);
|
||||
}
|
||||
}, [currentPage, totalPages]);
|
||||
|
||||
return {
|
||||
...virtualizationResult,
|
||||
currentPage,
|
||||
totalPages,
|
||||
setCurrentPage,
|
||||
nextPage,
|
||||
prevPage,
|
||||
canNextPage,
|
||||
canPrevPage,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook de virtualisation avec tri
|
||||
*
|
||||
* @param items - Liste des éléments
|
||||
* @param sortFn - Fonction de tri
|
||||
* @param options - Options de virtualisation
|
||||
* @returns Résultat avec tri et virtualisation
|
||||
*/
|
||||
export function useVirtualizedSort<T>(
|
||||
items: T[],
|
||||
sortFn: (a: T, b: T) => number,
|
||||
options: VirtualizationOptions
|
||||
): VirtualizationResult<T> & {
|
||||
sortedItems: T[];
|
||||
setSortFn: (fn: (a: T, b: T) => number) => void;
|
||||
} {
|
||||
const [currentSortFn, setCurrentSortFn] = useState(() => sortFn);
|
||||
|
||||
// Trier les éléments
|
||||
const sortedItems = useMemo(() => {
|
||||
return [...items].sort(currentSortFn);
|
||||
}, [items, currentSortFn]);
|
||||
|
||||
// Utiliser la virtualisation sur les éléments triés
|
||||
const virtualizationResult = useVirtualization(sortedItems, options);
|
||||
|
||||
return {
|
||||
...virtualizationResult,
|
||||
sortedItems,
|
||||
setSortFn: setCurrentSortFn,
|
||||
};
|
||||
}
|
||||
13
visual_workflow_builder/frontend/src/index.css
Normal file
13
visual_workflow_builder/frontend/src/index.css
Normal file
@@ -0,0 +1,13 @@
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
21
visual_workflow_builder/frontend/src/index.tsx
Normal file
21
visual_workflow_builder/frontend/src/index.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
// Suppression des erreurs ResizeObserver (doit être importé en premier)
|
||||
import './utils/suppressResizeObserverErrors';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
import reportWebVitals from './reportWebVitals';
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById('root') as HTMLElement
|
||||
);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
// If you want to start measuring performance in your app, pass a function
|
||||
// to log results (for example: reportWebVitals(console.log))
|
||||
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
||||
reportWebVitals();
|
||||
1
visual_workflow_builder/frontend/src/logo.svg
Normal file
1
visual_workflow_builder/frontend/src/logo.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
1
visual_workflow_builder/frontend/src/react-app-env.d.ts
vendored
Normal file
1
visual_workflow_builder/frontend/src/react-app-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="react-scripts" />
|
||||
15
visual_workflow_builder/frontend/src/reportWebVitals.ts
Normal file
15
visual_workflow_builder/frontend/src/reportWebVitals.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { ReportHandler } from 'web-vitals';
|
||||
|
||||
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
|
||||
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||
getCLS(onPerfEntry);
|
||||
getFID(onPerfEntry);
|
||||
getFCP(onPerfEntry);
|
||||
getLCP(onPerfEntry);
|
||||
getTTFB(onPerfEntry);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default reportWebVitals;
|
||||
@@ -0,0 +1,533 @@
|
||||
/**
|
||||
* Service StepTypeResolver - Résolution unifiée des types d'étapes
|
||||
* Auteur : Dom, Alice, Kiro - 12 janvier 2026
|
||||
*
|
||||
* Ce service fournit une logique de mapping unifiée pour résoudre les configurations
|
||||
* de paramètres des étapes, avec gestion robuste des types standard et VWB.
|
||||
*/
|
||||
|
||||
import { Step, StepType, Variable } from '../types';
|
||||
import { VWBCatalogAction } from '../types/catalog';
|
||||
|
||||
/**
|
||||
* Configuration d'un paramètre d'étape
|
||||
*/
|
||||
export interface ParameterConfig {
|
||||
name: string;
|
||||
label: string;
|
||||
type: 'text' | 'number' | 'boolean' | 'select' | 'visual';
|
||||
required?: boolean;
|
||||
description?: string;
|
||||
supportVariables?: boolean;
|
||||
options?: { value: string; label: string }[];
|
||||
defaultValue?: any;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
placeholder?: string;
|
||||
multiline?: boolean;
|
||||
group?: string;
|
||||
order?: number;
|
||||
conditional?: ConditionalRule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Règle conditionnelle pour l'affichage de paramètres
|
||||
*/
|
||||
export interface ConditionalRule {
|
||||
dependsOn: string;
|
||||
condition: 'equals' | 'not_equals' | 'greater_than' | 'less_than';
|
||||
value: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Résultat de la résolution d'un type d'étape
|
||||
*/
|
||||
export interface StepTypeResolutionResult {
|
||||
stepType: string;
|
||||
isVWBAction: boolean;
|
||||
isStandardType: boolean;
|
||||
parameterConfig: ParameterConfig[];
|
||||
vwbAction?: VWBCatalogAction;
|
||||
detectionMethods: Record<string, boolean>;
|
||||
resolutionSource: 'stepParametersConfig' | 'vwbCatalog' | 'fallback';
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options de résolution
|
||||
*/
|
||||
export interface ResolutionOptions {
|
||||
enableCache?: boolean;
|
||||
enableLogging?: boolean;
|
||||
fallbackToEmpty?: boolean;
|
||||
vwbDetectionMethods?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface du résolveur de types d'étapes
|
||||
*/
|
||||
export interface IStepTypeResolver {
|
||||
/**
|
||||
* Résout la configuration des paramètres pour une étape
|
||||
*/
|
||||
resolveParameterConfig(step: Step, options?: ResolutionOptions): Promise<StepTypeResolutionResult>;
|
||||
|
||||
/**
|
||||
* Vérifie si une étape est une action VWB
|
||||
*/
|
||||
isVWBAction(step: Step): boolean;
|
||||
|
||||
/**
|
||||
* Obtient les détails d'une action VWB
|
||||
*/
|
||||
getVWBActionDetails(step: Step): Promise<VWBCatalogAction | null>;
|
||||
|
||||
/**
|
||||
* Invalide le cache de résolution
|
||||
*/
|
||||
invalidateCache(): void;
|
||||
|
||||
/**
|
||||
* Obtient les statistiques de résolution
|
||||
*/
|
||||
getResolutionStats(): ResolutionStats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Statistiques de résolution
|
||||
*/
|
||||
export interface ResolutionStats {
|
||||
totalResolutions: number;
|
||||
cacheHits: number;
|
||||
cacheMisses: number;
|
||||
vwbDetections: number;
|
||||
standardDetections: number;
|
||||
fallbackUsed: number;
|
||||
averageResolutionTime: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implémentation du résolveur de types d'étapes
|
||||
*/
|
||||
export class StepTypeResolver implements IStepTypeResolver {
|
||||
private cache = new Map<string, StepTypeResolutionResult>();
|
||||
private stats: ResolutionStats = {
|
||||
totalResolutions: 0,
|
||||
cacheHits: 0,
|
||||
cacheMisses: 0,
|
||||
vwbDetections: 0,
|
||||
standardDetections: 0,
|
||||
fallbackUsed: 0,
|
||||
averageResolutionTime: 0
|
||||
};
|
||||
|
||||
private resolutionTimes: number[] = [];
|
||||
|
||||
/**
|
||||
* Configuration des paramètres par type d'étape standard
|
||||
*/
|
||||
private readonly stepParametersConfig: Record<StepType, ParameterConfig[]> = {
|
||||
click: [
|
||||
{
|
||||
name: 'target',
|
||||
label: 'Élément cible',
|
||||
type: 'visual',
|
||||
required: true,
|
||||
description: 'Sélectionner l\'élément à cliquer',
|
||||
},
|
||||
{
|
||||
name: 'clickType',
|
||||
label: 'Type de clic',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'left', label: 'Clic gauche' },
|
||||
{ value: 'right', label: 'Clic droit' },
|
||||
{ value: 'double', label: 'Double-clic' },
|
||||
],
|
||||
defaultValue: 'left',
|
||||
},
|
||||
],
|
||||
type: [
|
||||
{
|
||||
name: 'target',
|
||||
label: 'Champ de saisie',
|
||||
type: 'visual',
|
||||
required: true,
|
||||
description: 'Sélectionner le champ où saisir le texte',
|
||||
},
|
||||
{
|
||||
name: 'text',
|
||||
label: 'Texte à saisir',
|
||||
type: 'text',
|
||||
required: true,
|
||||
supportVariables: true,
|
||||
},
|
||||
{
|
||||
name: 'clearFirst',
|
||||
label: 'Vider le champ d\'abord',
|
||||
type: 'boolean',
|
||||
defaultValue: true,
|
||||
},
|
||||
],
|
||||
wait: [
|
||||
{
|
||||
name: 'duration',
|
||||
label: 'Durée (secondes)',
|
||||
type: 'number',
|
||||
required: true,
|
||||
min: 0.1,
|
||||
max: 60,
|
||||
defaultValue: 1,
|
||||
},
|
||||
],
|
||||
condition: [
|
||||
{
|
||||
name: 'condition',
|
||||
label: 'Condition',
|
||||
type: 'text',
|
||||
required: true,
|
||||
supportVariables: true,
|
||||
description: 'Expression conditionnelle à évaluer',
|
||||
},
|
||||
],
|
||||
extract: [
|
||||
{
|
||||
name: 'target',
|
||||
label: 'Élément source',
|
||||
type: 'visual',
|
||||
required: true,
|
||||
description: 'Sélectionner l\'élément dont extraire les données',
|
||||
},
|
||||
{
|
||||
name: 'attribute',
|
||||
label: 'Attribut à extraire',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'text', label: 'Texte' },
|
||||
{ value: 'value', label: 'Valeur' },
|
||||
{ value: 'href', label: 'Lien (href)' },
|
||||
{ value: 'src', label: 'Source (src)' },
|
||||
],
|
||||
defaultValue: 'text',
|
||||
},
|
||||
],
|
||||
scroll: [
|
||||
{
|
||||
name: 'direction',
|
||||
label: 'Direction',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'up', label: 'Vers le haut' },
|
||||
{ value: 'down', label: 'Vers le bas' },
|
||||
{ value: 'left', label: 'Vers la gauche' },
|
||||
{ value: 'right', label: 'Vers la droite' },
|
||||
],
|
||||
defaultValue: 'down',
|
||||
},
|
||||
{
|
||||
name: 'amount',
|
||||
label: 'Quantité (pixels)',
|
||||
type: 'number',
|
||||
defaultValue: 300,
|
||||
min: 1,
|
||||
},
|
||||
],
|
||||
navigate: [
|
||||
{
|
||||
name: 'url',
|
||||
label: 'URL de destination',
|
||||
type: 'text',
|
||||
required: true,
|
||||
supportVariables: true,
|
||||
},
|
||||
],
|
||||
screenshot: [
|
||||
{
|
||||
name: 'filename',
|
||||
label: 'Nom du fichier',
|
||||
type: 'text',
|
||||
supportVariables: true,
|
||||
description: 'Nom du fichier de capture (optionnel)',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Actions VWB connues
|
||||
*/
|
||||
private readonly knownVWBActions = [
|
||||
'click_anchor', 'type_text', 'type_secret', 'wait_for_anchor',
|
||||
'extract_text', 'screenshot_evidence', 'scroll_to_anchor',
|
||||
'focus_anchor', 'hotkey', 'navigate_to_url', 'browser_back',
|
||||
'verify_element_exists', 'verify_text_content'
|
||||
];
|
||||
|
||||
/**
|
||||
* Résout la configuration des paramètres pour une étape
|
||||
*/
|
||||
async resolveParameterConfig(
|
||||
step: Step,
|
||||
options: ResolutionOptions = {}
|
||||
): Promise<StepTypeResolutionResult> {
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
// Options par défaut
|
||||
const resolveOptions = {
|
||||
enableCache: true,
|
||||
enableLogging: true,
|
||||
fallbackToEmpty: true,
|
||||
vwbDetectionMethods: ['all'],
|
||||
...options
|
||||
};
|
||||
|
||||
// Vérifier le cache
|
||||
const cacheKey = this.generateCacheKey(step, resolveOptions);
|
||||
if (resolveOptions.enableCache && this.cache.has(cacheKey)) {
|
||||
this.stats.cacheHits++;
|
||||
const cached = this.cache.get(cacheKey)!;
|
||||
|
||||
if (resolveOptions.enableLogging) {
|
||||
console.log('🎯 [StepTypeResolver] Cache hit:', {
|
||||
stepId: step.id,
|
||||
stepType: step.type,
|
||||
cacheKey
|
||||
});
|
||||
}
|
||||
|
||||
return cached;
|
||||
}
|
||||
|
||||
this.stats.cacheMisses++;
|
||||
|
||||
// Analyser le type d'étape
|
||||
const stepTypeString = step.type as string;
|
||||
|
||||
// Détecter si c'est une action VWB
|
||||
const vwbDetectionResult = this.detectVWBAction(step, resolveOptions);
|
||||
|
||||
let result: StepTypeResolutionResult;
|
||||
|
||||
if (vwbDetectionResult.isVWBAction) {
|
||||
// Résolution pour action VWB
|
||||
result = await this.resolveVWBAction(step, vwbDetectionResult, resolveOptions);
|
||||
this.stats.vwbDetections++;
|
||||
} else {
|
||||
// Résolution pour type standard
|
||||
result = this.resolveStandardType(step, resolveOptions);
|
||||
this.stats.standardDetections++;
|
||||
}
|
||||
|
||||
// Mettre en cache le résultat
|
||||
if (resolveOptions.enableCache) {
|
||||
this.cache.set(cacheKey, result);
|
||||
}
|
||||
|
||||
// Logging détaillé
|
||||
if (resolveOptions.enableLogging) {
|
||||
console.log('🔍 [StepTypeResolver] Résolution complète:', {
|
||||
stepId: step.id,
|
||||
stepType: stepTypeString,
|
||||
isVWBAction: result.isVWBAction,
|
||||
parameterCount: result.parameterConfig.length,
|
||||
resolutionSource: result.resolutionSource,
|
||||
detectionMethods: result.detectionMethods
|
||||
});
|
||||
}
|
||||
|
||||
// Mettre à jour les statistiques
|
||||
const resolutionTime = performance.now() - startTime;
|
||||
this.resolutionTimes.push(resolutionTime);
|
||||
this.stats.totalResolutions++;
|
||||
this.stats.averageResolutionTime =
|
||||
this.resolutionTimes.reduce((a, b) => a + b, 0) / this.resolutionTimes.length;
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ [StepTypeResolver] Erreur de résolution:', error);
|
||||
|
||||
// Fallback en cas d'erreur
|
||||
const fallbackResult: StepTypeResolutionResult = {
|
||||
stepType: step.type as string,
|
||||
isVWBAction: false,
|
||||
isStandardType: false,
|
||||
parameterConfig: [],
|
||||
detectionMethods: { error: true },
|
||||
resolutionSource: 'fallback',
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
this.stats.fallbackUsed++;
|
||||
return fallbackResult;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si une étape est une action VWB
|
||||
*/
|
||||
isVWBAction(step: Step): boolean {
|
||||
const detection = this.detectVWBAction(step, { enableLogging: false });
|
||||
return detection.isVWBAction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Détecte si une étape est une action VWB avec méthodes multiples
|
||||
*/
|
||||
private detectVWBAction(step: Step, options: ResolutionOptions): {
|
||||
isVWBAction: boolean;
|
||||
detectionMethods: Record<string, boolean>;
|
||||
confidence: number;
|
||||
} {
|
||||
const stepTypeString = step.type as string;
|
||||
|
||||
// Méthodes de détection multiples pour robustesse
|
||||
const detectionMethods = {
|
||||
hasVWBFlag: Boolean(step.data?.isVWBCatalogAction),
|
||||
hasVWBActionId: Boolean(step.data?.vwbActionId),
|
||||
typeStartsWithVWB: stepTypeString.startsWith('vwb_'),
|
||||
typeContainsAnchor: stepTypeString.includes('_anchor'),
|
||||
typeContainsText: stepTypeString.includes('_text'),
|
||||
typeContainsSecret: stepTypeString.includes('_secret'),
|
||||
isKnownVWBAction: this.knownVWBActions.includes(stepTypeString),
|
||||
hasVWBPattern: /^(click|type|wait|extract|scroll|focus|hotkey|navigate|browser|verify)_/.test(stepTypeString)
|
||||
};
|
||||
|
||||
// Calculer la confiance basée sur le nombre de méthodes positives
|
||||
const positiveDetections = Object.values(detectionMethods).filter(Boolean).length;
|
||||
const confidence = positiveDetections / Object.keys(detectionMethods).length;
|
||||
|
||||
// Une action est considérée VWB si au moins une méthode la détecte
|
||||
const isVWBAction = positiveDetections > 0;
|
||||
|
||||
if (options.enableLogging) {
|
||||
console.log('🎯 [StepTypeResolver] Détection VWB:', {
|
||||
stepType: stepTypeString,
|
||||
detectionMethods,
|
||||
positiveDetections,
|
||||
confidence: `${(confidence * 100).toFixed(1)}%`,
|
||||
isVWBAction
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
isVWBAction,
|
||||
detectionMethods,
|
||||
confidence
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Résout une action VWB
|
||||
*/
|
||||
private async resolveVWBAction(
|
||||
step: Step,
|
||||
vwbDetection: any,
|
||||
options: ResolutionOptions
|
||||
): Promise<StepTypeResolutionResult> {
|
||||
try {
|
||||
// Charger les détails de l'action VWB si disponible
|
||||
const vwbAction = await this.getVWBActionDetails(step);
|
||||
|
||||
return {
|
||||
stepType: step.type as string,
|
||||
isVWBAction: true,
|
||||
isStandardType: false,
|
||||
parameterConfig: [], // Les actions VWB utilisent VWBActionProperties
|
||||
vwbAction: vwbAction || undefined,
|
||||
detectionMethods: vwbDetection.detectionMethods,
|
||||
resolutionSource: 'vwbCatalog',
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ [StepTypeResolver] Erreur résolution VWB:', error);
|
||||
|
||||
// Fallback pour action VWB
|
||||
return {
|
||||
stepType: step.type as string,
|
||||
isVWBAction: true,
|
||||
isStandardType: false,
|
||||
parameterConfig: [],
|
||||
detectionMethods: vwbDetection.detectionMethods,
|
||||
resolutionSource: 'fallback',
|
||||
timestamp: Date.now()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Résout un type d'étape standard
|
||||
*/
|
||||
private resolveStandardType(step: Step, options: ResolutionOptions): StepTypeResolutionResult {
|
||||
const stepTypeString = step.type as string;
|
||||
const config = this.stepParametersConfig[stepTypeString as StepType] || [];
|
||||
|
||||
return {
|
||||
stepType: stepTypeString,
|
||||
isVWBAction: false,
|
||||
isStandardType: config.length > 0,
|
||||
parameterConfig: config,
|
||||
detectionMethods: { standardType: config.length > 0 },
|
||||
resolutionSource: 'stepParametersConfig',
|
||||
timestamp: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtient les détails d'une action VWB
|
||||
*/
|
||||
async getVWBActionDetails(step: Step): Promise<VWBCatalogAction | null> {
|
||||
try {
|
||||
// Simuler le chargement depuis le catalogue VWB
|
||||
// Dans une implémentation réelle, ceci ferait appel au service de catalogue
|
||||
const vwbActionId = step.data?.vwbActionId || step.type;
|
||||
|
||||
// Pour l'instant, retourner null - sera implémenté avec le service de catalogue
|
||||
return null;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ [StepTypeResolver] Erreur chargement action VWB:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère une clé de cache pour une résolution
|
||||
*/
|
||||
private generateCacheKey(step: Step, options: ResolutionOptions): string {
|
||||
const stepData = {
|
||||
type: step.type,
|
||||
isVWBCatalogAction: step.data?.isVWBCatalogAction,
|
||||
vwbActionId: step.data?.vwbActionId
|
||||
};
|
||||
|
||||
return `${JSON.stringify(stepData)}_${JSON.stringify(options)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalide le cache de résolution
|
||||
*/
|
||||
invalidateCache(): void {
|
||||
this.cache.clear();
|
||||
console.log('🗑️ [StepTypeResolver] Cache invalidé');
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtient les statistiques de résolution
|
||||
*/
|
||||
getResolutionStats(): ResolutionStats {
|
||||
return { ...this.stats };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Instance singleton du résolveur
|
||||
*/
|
||||
export const stepTypeResolver = new StepTypeResolver();
|
||||
|
||||
/**
|
||||
* Export par défaut
|
||||
*/
|
||||
export default stepTypeResolver;
|
||||
@@ -0,0 +1,530 @@
|
||||
/**
|
||||
* Service de Capture Visuelle - Configuration des paramètres d'étapes
|
||||
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
|
||||
*
|
||||
* Service de capture visuelle pour l'interface frontend.
|
||||
* Gère la communication avec l'API backend pour la capture d'écran,
|
||||
* la détection d'éléments et la génération d'embeddings visuels.
|
||||
*/
|
||||
|
||||
import { BoundingBox } from '../types';
|
||||
|
||||
interface VisualMetadata {
|
||||
element_type: string;
|
||||
relative_position?: string;
|
||||
text_content?: string;
|
||||
visual_description?: string;
|
||||
size_description?: string;
|
||||
contextual_elements_count?: number;
|
||||
accessibility_info?: Record<string, any>;
|
||||
}
|
||||
|
||||
interface VisualTarget {
|
||||
screenshot: string;
|
||||
bounding_box: BoundingBox;
|
||||
metadata: VisualMetadata;
|
||||
confidence?: number;
|
||||
signature?: string;
|
||||
}
|
||||
|
||||
export interface CaptureOptions {
|
||||
includeContext?: boolean;
|
||||
highQuality?: boolean;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export interface DetectedElement {
|
||||
id: string;
|
||||
bounds: BoundingBox;
|
||||
type: string;
|
||||
text?: string;
|
||||
confidence: number;
|
||||
metadata?: Partial<VisualMetadata>;
|
||||
}
|
||||
|
||||
export interface CaptureResult {
|
||||
screenshot: string; // Base64 encoded
|
||||
elements: DetectedElement[];
|
||||
timestamp: string;
|
||||
screenSize: { width: number; height: number };
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
isValid: boolean;
|
||||
confidence: number;
|
||||
issues: string[];
|
||||
suggestions: string[];
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
class VisualCaptureService {
|
||||
private baseUrl: string;
|
||||
private timeout: number;
|
||||
private cache: Map<string, any>;
|
||||
private cacheTimeout: number;
|
||||
|
||||
constructor(baseUrl: string = 'http://localhost:8000') {
|
||||
this.baseUrl = baseUrl;
|
||||
this.timeout = 30000; // 30 secondes
|
||||
this.cache = new Map();
|
||||
this.cacheTimeout = 60000; // 1 minute
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture l'écran et détecte les éléments UI
|
||||
*/
|
||||
async captureScreen(options: CaptureOptions = {}): Promise<CaptureResult> {
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
console.log('🔍 Début de capture d\'écran...');
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/api/visual/capture`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
include_context: options.includeContext ?? true,
|
||||
high_quality: options.highQuality ?? true,
|
||||
timeout: options.timeout ?? this.timeout,
|
||||
}),
|
||||
signal: AbortSignal.timeout(options.timeout ?? this.timeout),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Erreur de capture: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result: CaptureResult = await response.json();
|
||||
|
||||
const duration = performance.now() - startTime;
|
||||
console.log(`✅ Capture terminée en ${duration.toFixed(0)}ms - ${result.elements.length} éléments détectés`);
|
||||
|
||||
// Mettre en cache le résultat
|
||||
this.setCacheItem('last_capture', result);
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
const duration = performance.now() - startTime;
|
||||
console.error(`❌ Erreur de capture après ${duration.toFixed(0)}ms:`, error);
|
||||
|
||||
// Fallback vers une capture simulée en cas d'erreur
|
||||
return this.createMockCapture();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée une cible visuelle à partir d'un élément détecté
|
||||
*/
|
||||
async createVisualTarget(element: DetectedElement, screenshot: string): Promise<VisualTarget> {
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
console.log(`🎯 Création de cible visuelle pour élément ${element.type}...`);
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/api/visual/create-target`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
element: element,
|
||||
screenshot: screenshot,
|
||||
}),
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Erreur de création de cible: ${response.status}`);
|
||||
}
|
||||
|
||||
const target: VisualTarget = await response.json();
|
||||
|
||||
const duration = performance.now() - startTime;
|
||||
console.log(`✅ Cible visuelle créée en ${duration.toFixed(0)}ms (confiance: ${Math.round((target.confidence || 0) * 100)}%)`);
|
||||
|
||||
return target;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur lors de la création de cible visuelle:', error);
|
||||
|
||||
// Fallback vers une cible simulée
|
||||
return this.createMockTarget(element, screenshot);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide une cible visuelle existante
|
||||
*/
|
||||
async validateTarget(target: VisualTarget): Promise<ValidationResult> {
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
console.log(`🔍 Validation de la cible ${target.signature}...`);
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/api/visual/validate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
target: target,
|
||||
}),
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Erreur de validation: ${response.status}`);
|
||||
}
|
||||
|
||||
const result: ValidationResult = await response.json();
|
||||
|
||||
const duration = performance.now() - startTime;
|
||||
console.log(`✅ Validation terminée en ${duration.toFixed(0)}ms (valide: ${result.isValid})`);
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur lors de la validation:', error);
|
||||
|
||||
// Fallback vers une validation simulée
|
||||
return {
|
||||
isValid: false,
|
||||
confidence: 0,
|
||||
issues: ['Erreur de connexion au service de validation'],
|
||||
suggestions: ['Vérifier la connexion réseau'],
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour la capture d'écran d'une cible
|
||||
*/
|
||||
async updateTargetScreenshot(target: VisualTarget): Promise<VisualTarget> {
|
||||
try {
|
||||
console.log(`📸 Mise à jour de la capture pour ${target.signature}...`);
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/api/visual/update-screenshot`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
target: target,
|
||||
}),
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Erreur de mise à jour: ${response.status}`);
|
||||
}
|
||||
|
||||
const updatedTarget: VisualTarget = await response.json();
|
||||
console.log('✅ Capture mise à jour avec succès');
|
||||
|
||||
return updatedTarget;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur lors de la mise à jour de capture:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recherche des éléments similaires
|
||||
*/
|
||||
async findSimilarElements(target: VisualTarget): Promise<DetectedElement[]> {
|
||||
try {
|
||||
console.log(`🔍 Recherche d'éléments similaires à ${target.signature}...`);
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/api/visual/find-similar`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
target: target,
|
||||
}),
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Erreur de recherche: ${response.status}`);
|
||||
}
|
||||
|
||||
const elements: DetectedElement[] = await response.json();
|
||||
console.log(`✅ ${elements.length} éléments similaires trouvés`);
|
||||
|
||||
return elements;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur lors de la recherche d\'éléments similaires:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée une capture simulée pour les tests
|
||||
*/
|
||||
private createMockCapture(): CaptureResult {
|
||||
console.log('🎭 Création d\'une capture simulée...');
|
||||
|
||||
const mockElements: DetectedElement[] = [
|
||||
{
|
||||
id: 'mock_button_1',
|
||||
bounds: { x: 100, y: 100, width: 120, height: 40 },
|
||||
type: 'button',
|
||||
text: 'Connexion',
|
||||
confidence: 0.95,
|
||||
metadata: {
|
||||
element_type: 'Bouton',
|
||||
visual_description: 'Bouton avec le texte "Connexion"',
|
||||
relative_position: 'en haut à gauche de l\'écran',
|
||||
text_content: 'Connexion',
|
||||
size_description: 'moyenne',
|
||||
contextual_elements_count: 2,
|
||||
accessibility_info: {
|
||||
has_text: true,
|
||||
tag_name: 'button',
|
||||
attributes_count: 3,
|
||||
is_interactive: true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'mock_input_1',
|
||||
bounds: { x: 300, y: 150, width: 200, height: 30 },
|
||||
type: 'input',
|
||||
text: 'Email',
|
||||
confidence: 0.88,
|
||||
metadata: {
|
||||
element_type: 'Champ de saisie',
|
||||
visual_description: 'Champ de saisie pour l\'email',
|
||||
relative_position: 'au centre de l\'écran',
|
||||
text_content: 'Email',
|
||||
size_description: 'moyenne',
|
||||
contextual_elements_count: 1,
|
||||
accessibility_info: {
|
||||
has_text: true,
|
||||
tag_name: 'input',
|
||||
attributes_count: 5,
|
||||
is_interactive: true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'mock_link_1',
|
||||
bounds: { x: 400, y: 300, width: 150, height: 25 },
|
||||
type: 'link',
|
||||
text: 'Mot de passe oublié ?',
|
||||
confidence: 0.82,
|
||||
metadata: {
|
||||
element_type: 'Lien',
|
||||
visual_description: 'Lien "Mot de passe oublié ?"',
|
||||
relative_position: 'en bas au centre de l\'écran',
|
||||
text_content: 'Mot de passe oublié ?',
|
||||
size_description: 'petite',
|
||||
contextual_elements_count: 0,
|
||||
accessibility_info: {
|
||||
has_text: true,
|
||||
tag_name: 'a',
|
||||
attributes_count: 2,
|
||||
is_interactive: true
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// Créer une image simulée
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 800;
|
||||
canvas.height = 600;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
|
||||
// Fond blanc
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, 800, 600);
|
||||
|
||||
// Dessiner les éléments simulés
|
||||
mockElements.forEach(element => {
|
||||
const color = this.getElementColor(element.type);
|
||||
|
||||
// Élément
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(element.bounds.x, element.bounds.y, element.bounds.width, element.bounds.height);
|
||||
|
||||
// Bordure
|
||||
ctx.strokeStyle = '#333333';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(element.bounds.x, element.bounds.y, element.bounds.width, element.bounds.height);
|
||||
|
||||
// Texte
|
||||
if (element.text) {
|
||||
ctx.fillStyle = color === '#f5f5f5' ? '#333333' : '#ffffff';
|
||||
ctx.font = '14px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(
|
||||
element.text,
|
||||
element.bounds.x + element.bounds.width / 2,
|
||||
element.bounds.y + element.bounds.height / 2 + 5
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const screenshot = canvas.toDataURL().split(',')[1]; // Enlever le préfixe data:image/png;base64,
|
||||
|
||||
return {
|
||||
screenshot,
|
||||
elements: mockElements,
|
||||
timestamp: new Date().toISOString(),
|
||||
screenSize: { width: 800, height: 600 }
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée une cible visuelle simulée
|
||||
*/
|
||||
private createMockTarget(element: DetectedElement, screenshot: string): VisualTarget {
|
||||
console.log('🎭 Création d\'une cible simulée...');
|
||||
|
||||
// Créer une image de l'élément avec contour
|
||||
const canvas = document.createElement('canvas');
|
||||
const margin = 10;
|
||||
canvas.width = element.bounds.width + margin * 2;
|
||||
canvas.height = element.bounds.height + margin * 2;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
|
||||
// Fond blanc
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Élément
|
||||
const color = this.getElementColor(element.type);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(margin, margin, element.bounds.width, element.bounds.height);
|
||||
|
||||
// Contour vert pour la sélection
|
||||
ctx.strokeStyle = '#4CAF50';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.strokeRect(margin - 1, margin - 1, element.bounds.width + 2, element.bounds.height + 2);
|
||||
|
||||
// Texte
|
||||
if (element.text) {
|
||||
ctx.fillStyle = color === '#f5f5f5' ? '#333333' : '#ffffff';
|
||||
ctx.font = '14px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(
|
||||
element.text,
|
||||
margin + element.bounds.width / 2,
|
||||
margin + element.bounds.height / 2 + 5
|
||||
);
|
||||
}
|
||||
|
||||
const elementScreenshot = canvas.toDataURL().split(',')[1];
|
||||
|
||||
return {
|
||||
screenshot: elementScreenshot,
|
||||
bounding_box: element.bounds,
|
||||
confidence: element.confidence,
|
||||
signature: `visual_${element.id}_${Date.now()}`,
|
||||
metadata: element.metadata as VisualMetadata || {
|
||||
element_type: this.getElementTypeLabel(element.type),
|
||||
visual_description: `${this.getElementTypeLabel(element.type)} ${element.text ? `avec le texte "${element.text}"` : ''}`,
|
||||
relative_position: 'au centre de l\'écran',
|
||||
text_content: element.text,
|
||||
size_description: 'moyenne',
|
||||
contextual_elements_count: 0,
|
||||
accessibility_info: {
|
||||
has_text: !!element.text,
|
||||
tag_name: element.type,
|
||||
attributes_count: 0,
|
||||
is_interactive: ['button', 'input', 'link'].includes(element.type)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtient la couleur d'un type d'élément
|
||||
*/
|
||||
private getElementColor(type: string): string {
|
||||
const colors: Record<string, string> = {
|
||||
button: '#1976d2',
|
||||
input: '#f5f5f5',
|
||||
link: '#f59e0b',
|
||||
text: '#333333',
|
||||
image: '#9c27b0',
|
||||
div: '#e0e0e0',
|
||||
span: '#bdbdbd'
|
||||
};
|
||||
return colors[type] || '#666666';
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtient le label d'un type d'élément
|
||||
*/
|
||||
private getElementTypeLabel(type: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
button: 'Bouton',
|
||||
input: 'Champ de saisie',
|
||||
link: 'Lien',
|
||||
text: 'Texte',
|
||||
image: 'Image',
|
||||
div: 'Zone de contenu',
|
||||
span: 'Texte'
|
||||
};
|
||||
return labels[type] || 'Élément';
|
||||
}
|
||||
|
||||
/**
|
||||
* Gestion du cache
|
||||
*/
|
||||
private setCacheItem(key: string, value: any): void {
|
||||
this.cache.set(key, {
|
||||
value,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
private getCacheItem(key: string): any | null {
|
||||
const item = this.cache.get(key);
|
||||
if (!item) return null;
|
||||
|
||||
if (Date.now() - item.timestamp > this.cacheTimeout) {
|
||||
this.cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return item.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nettoie le cache expiré
|
||||
*/
|
||||
public clearExpiredCache(): void {
|
||||
const now = Date.now();
|
||||
const entries = Array.from(this.cache.entries());
|
||||
for (const [key, item] of entries) {
|
||||
if (now - item.timestamp > this.cacheTimeout) {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtient les statistiques du service
|
||||
*/
|
||||
public getStats(): { cacheSize: number; cacheHits: number } {
|
||||
return {
|
||||
cacheSize: this.cache.size,
|
||||
cacheHits: 0 // À implémenter si nécessaire
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Instance singleton du service
|
||||
export const visualCaptureService = new VisualCaptureService();
|
||||
export default VisualCaptureService;
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* Service de gestion des images d'ancres visuelles côté serveur.
|
||||
*
|
||||
* Auteur : Dom, Alice, Kiro - 21 janvier 2026
|
||||
*
|
||||
* Ce service gère l'upload et la récupération des images d'ancres
|
||||
* via l'API backend, évitant le stockage base64 dans les workflows.
|
||||
*/
|
||||
|
||||
import { BoundingBox } from '../types';
|
||||
|
||||
const API_BASE = 'http://localhost:5001';
|
||||
|
||||
export interface AnchorImageUploadResult {
|
||||
success: boolean;
|
||||
anchor_id: string;
|
||||
thumbnail_url: string;
|
||||
original_url: string;
|
||||
metadata: {
|
||||
anchor_id: string;
|
||||
bounding_box: BoundingBox;
|
||||
original_size: { width: number; height: number };
|
||||
thumbnail_size: { width: number; height: number };
|
||||
created_at: string;
|
||||
original_file_size: number;
|
||||
thumbnail_file_size: number;
|
||||
extra?: Record<string, any>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AnchorMetadata {
|
||||
anchor_id: string;
|
||||
bounding_box: BoundingBox;
|
||||
original_size: { width: number; height: number };
|
||||
thumbnail_size: { width: number; height: number };
|
||||
created_at: string;
|
||||
original_file_size: number;
|
||||
thumbnail_file_size: number;
|
||||
}
|
||||
|
||||
export interface StorageStats {
|
||||
anchor_count: number;
|
||||
total_size_bytes: number;
|
||||
total_size_mb: number;
|
||||
data_directory: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload une image d'ancre vers le serveur.
|
||||
*
|
||||
* @param imageBase64 - Screenshot complet en base64 (avec ou sans préfixe data:)
|
||||
* @param boundingBox - Zone de sélection sur l'image
|
||||
* @param anchorId - ID optionnel (généré automatiquement si absent)
|
||||
* @param metadata - Métadonnées additionnelles optionnelles
|
||||
* @returns Résultat avec anchor_id et URLs
|
||||
*/
|
||||
export async function uploadAnchorImage(
|
||||
imageBase64: string,
|
||||
boundingBox: BoundingBox,
|
||||
anchorId?: string,
|
||||
metadata?: Record<string, any>
|
||||
): Promise<AnchorImageUploadResult> {
|
||||
console.log('📤 [anchorImageService] Upload image d\'ancre...', {
|
||||
hasImage: !!imageBase64,
|
||||
imageLength: imageBase64?.length || 0,
|
||||
boundingBox,
|
||||
anchorId
|
||||
});
|
||||
|
||||
const response = await fetch(`${API_BASE}/api/anchor-images`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
image_base64: imageBase64,
|
||||
bounding_box: boundingBox,
|
||||
anchor_id: anchorId,
|
||||
metadata,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || `Erreur HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Erreur lors de l\'upload');
|
||||
}
|
||||
|
||||
console.log('✅ [anchorImageService] Upload réussi:', {
|
||||
anchor_id: result.anchor_id,
|
||||
thumbnail_url: result.thumbnail_url,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir l'URL complète de la miniature d'une ancre.
|
||||
*
|
||||
* @param anchorId - ID de l'ancre
|
||||
* @returns URL complète de la miniature
|
||||
*/
|
||||
export function getThumbnailUrl(anchorId: string): string {
|
||||
return `${API_BASE}/api/anchor-images/${anchorId}/thumbnail`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir l'URL complète de l'image originale d'une ancre.
|
||||
*
|
||||
* @param anchorId - ID de l'ancre
|
||||
* @returns URL complète de l'image originale
|
||||
*/
|
||||
export function getOriginalUrl(anchorId: string): string {
|
||||
return `${API_BASE}/api/anchor-images/${anchorId}/original`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir les métadonnées d'une ancre.
|
||||
*
|
||||
* @param anchorId - ID de l'ancre
|
||||
* @returns Métadonnées de l'ancre
|
||||
*/
|
||||
export async function getAnchorMetadata(anchorId: string): Promise<AnchorMetadata> {
|
||||
const response = await fetch(`${API_BASE}/api/anchor-images/${anchorId}/metadata`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Ancre '${anchorId}' non trouvée`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return result.metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprimer une image d'ancre du serveur.
|
||||
*
|
||||
* @param anchorId - ID de l'ancre à supprimer
|
||||
* @returns true si supprimé avec succès
|
||||
*/
|
||||
export async function deleteAnchorImage(anchorId: string): Promise<boolean> {
|
||||
const response = await fetch(`${API_BASE}/api/anchor-images/${anchorId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok && response.status !== 404) {
|
||||
throw new Error(`Erreur lors de la suppression de l'ancre '${anchorId}'`);
|
||||
}
|
||||
|
||||
return response.ok;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lister toutes les images d'ancres stockées.
|
||||
*
|
||||
* @param limit - Nombre maximum d'ancres à retourner
|
||||
* @param offset - Décalage pour la pagination
|
||||
* @returns Liste des métadonnées des ancres
|
||||
*/
|
||||
export async function listAnchorImages(
|
||||
limit: number = 100,
|
||||
offset: number = 0
|
||||
): Promise<{ anchors: AnchorMetadata[]; total: number }> {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/api/anchor-images?limit=${limit}&offset=${offset}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Erreur lors de la récupération de la liste des ancres');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return {
|
||||
anchors: result.anchors,
|
||||
total: result.total,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir les statistiques de stockage.
|
||||
*
|
||||
* @returns Statistiques de stockage
|
||||
*/
|
||||
export async function getStorageStats(): Promise<StorageStats> {
|
||||
const response = await fetch(`${API_BASE}/api/anchor-images/stats`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Erreur lors de la récupération des statistiques');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return result.stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifier si une ancre existe sur le serveur.
|
||||
*
|
||||
* @param anchorId - ID de l'ancre
|
||||
* @returns true si l'ancre existe
|
||||
*/
|
||||
export async function anchorExists(anchorId: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/api/anchor-images/${anchorId}/metadata`,
|
||||
{ method: 'HEAD' }
|
||||
);
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir l'URL de prévisualisation d'une ancre.
|
||||
* Retourne l'URL de la miniature si disponible, sinon null.
|
||||
*
|
||||
* @param anchor - Ancre visuelle avec potentiellement thumbnail_url ou reference_image_base64
|
||||
* @returns URL de prévisualisation ou chaîne data: pour base64 legacy
|
||||
*/
|
||||
export function getPreviewImageUrl(anchor: {
|
||||
anchor_id?: string;
|
||||
thumbnail_url?: string;
|
||||
reference_image_url?: string;
|
||||
reference_image_base64?: string;
|
||||
}): string | null {
|
||||
// Priorité 1: URL de miniature serveur
|
||||
if (anchor.thumbnail_url) {
|
||||
// Si l'URL est relative, ajouter le préfixe API
|
||||
return anchor.thumbnail_url.startsWith('http')
|
||||
? anchor.thumbnail_url
|
||||
: `${API_BASE}${anchor.thumbnail_url}`;
|
||||
}
|
||||
|
||||
// Priorité 2: URL d'image originale serveur
|
||||
if (anchor.reference_image_url) {
|
||||
return anchor.reference_image_url.startsWith('http')
|
||||
? anchor.reference_image_url
|
||||
: `${API_BASE}${anchor.reference_image_url}`;
|
||||
}
|
||||
|
||||
// Priorité 3: Construire l'URL depuis anchor_id si présent
|
||||
if (anchor.anchor_id && anchor.anchor_id.startsWith('anchor_')) {
|
||||
return getThumbnailUrl(anchor.anchor_id);
|
||||
}
|
||||
|
||||
// Fallback: base64 legacy
|
||||
if (anchor.reference_image_base64) {
|
||||
if (anchor.reference_image_base64.startsWith('data:')) {
|
||||
return anchor.reference_image_base64;
|
||||
}
|
||||
return `data:image/png;base64,${anchor.reference_image_base64}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Export par défaut pour compatibilité
|
||||
const anchorImageService = {
|
||||
uploadAnchorImage,
|
||||
getThumbnailUrl,
|
||||
getOriginalUrl,
|
||||
getAnchorMetadata,
|
||||
deleteAnchorImage,
|
||||
listAnchorImages,
|
||||
getStorageStats,
|
||||
anchorExists,
|
||||
getPreviewImageUrl,
|
||||
};
|
||||
|
||||
export default anchorImageService;
|
||||
@@ -54,12 +54,12 @@ const getApiHost = (): string => {
|
||||
const hostname = window.location.hostname;
|
||||
// Si c'est localhost ou 127.0.0.1, garder localhost
|
||||
if (hostname === 'localhost' || hostname === '127.0.0.1') {
|
||||
return 'http://localhost:5002/api';
|
||||
return 'http://localhost:5001/api';
|
||||
}
|
||||
// Sinon utiliser le même hostname (IP) avec le port 5000
|
||||
return `http://${hostname}:5000/api`;
|
||||
}
|
||||
return 'http://localhost:5002/api';
|
||||
return 'http://localhost:5001/api';
|
||||
};
|
||||
|
||||
// Configuration par défaut
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* Service de Bibliothèque de Captures d'Écran
|
||||
* Auteur : Dom, Alice, Kiro - 13 janvier 2026
|
||||
*
|
||||
* Permet de sauvegarder, organiser et réutiliser des captures d'écran
|
||||
* pour éviter de refaire des captures lors du travail sur le même logiciel.
|
||||
*/
|
||||
|
||||
export interface SavedCapture {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
application?: string;
|
||||
screenshot: string; // base64
|
||||
thumbnailSmall: string; // base64 miniature 100px
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
dimensions: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface CaptureLibraryState {
|
||||
captures: SavedCapture[];
|
||||
lastUpdated: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'vwb_capture_library';
|
||||
const LIBRARY_VERSION = '1.0.0';
|
||||
const MAX_CAPTURES = 50; // Limite pour éviter de saturer le localStorage
|
||||
|
||||
/**
|
||||
* Service de gestion de la bibliothèque de captures
|
||||
* Utilise localStorage pour la persistance entre sessions
|
||||
*/
|
||||
class CaptureLibraryService {
|
||||
private state: CaptureLibraryState;
|
||||
|
||||
constructor() {
|
||||
this.state = this.loadFromStorage();
|
||||
console.log('📚 [CaptureLibrary] Service initialisé avec', this.state.captures.length, 'captures');
|
||||
}
|
||||
|
||||
/**
|
||||
* Recharger les données depuis localStorage (utile après un refresh)
|
||||
*/
|
||||
public reload(): void {
|
||||
this.state = this.loadFromStorage();
|
||||
console.log('🔄 [CaptureLibrary] Rechargé:', this.state.captures.length, 'captures');
|
||||
}
|
||||
|
||||
/**
|
||||
* Charger la bibliothèque depuis localStorage
|
||||
*/
|
||||
private loadFromStorage(): CaptureLibraryState {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored) as CaptureLibraryState;
|
||||
// Vérifier la version
|
||||
if (parsed.version === LIBRARY_VERSION) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Erreur lors du chargement de la bibliothèque de captures:', error);
|
||||
}
|
||||
|
||||
// Retourner un état vide par défaut
|
||||
return {
|
||||
captures: [],
|
||||
lastUpdated: new Date().toISOString(),
|
||||
version: LIBRARY_VERSION,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sauvegarder la bibliothèque dans localStorage
|
||||
*/
|
||||
private saveToStorage(): void {
|
||||
try {
|
||||
this.state.lastUpdated = new Date().toISOString();
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.state));
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la sauvegarde de la bibliothèque:', error);
|
||||
// Si localStorage est plein, supprimer les plus anciennes captures
|
||||
if (error instanceof DOMException && error.name === 'QuotaExceededError') {
|
||||
this.cleanupOldCaptures(10);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.state));
|
||||
} catch {
|
||||
console.error('Impossible de sauvegarder même après nettoyage');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Créer une miniature d'une image base64
|
||||
*/
|
||||
private async createThumbnail(base64Image: string, maxSize: number = 100): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
resolve(base64Image);
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculer les dimensions proportionnelles
|
||||
let width = img.width;
|
||||
let height = img.height;
|
||||
if (width > height) {
|
||||
if (width > maxSize) {
|
||||
height = (height * maxSize) / width;
|
||||
width = maxSize;
|
||||
}
|
||||
} else {
|
||||
if (height > maxSize) {
|
||||
width = (width * maxSize) / height;
|
||||
height = maxSize;
|
||||
}
|
||||
}
|
||||
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
|
||||
resolve(canvas.toDataURL('image/jpeg', 0.7));
|
||||
};
|
||||
img.onerror = () => resolve(base64Image);
|
||||
|
||||
if (base64Image.startsWith('data:')) {
|
||||
img.src = base64Image;
|
||||
} else {
|
||||
img.src = `data:image/png;base64,${base64Image}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir les dimensions d'une image base64
|
||||
*/
|
||||
private async getImageDimensions(base64Image: string): Promise<{ width: number; height: number }> {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve({ width: img.width, height: img.height });
|
||||
img.onerror = () => resolve({ width: 0, height: 0 });
|
||||
|
||||
if (base64Image.startsWith('data:')) {
|
||||
img.src = base64Image;
|
||||
} else {
|
||||
img.src = `data:image/png;base64,${base64Image}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sauvegarder une nouvelle capture dans la bibliothèque
|
||||
*/
|
||||
async saveCapture(
|
||||
screenshot: string,
|
||||
name: string,
|
||||
options: {
|
||||
description?: string;
|
||||
application?: string;
|
||||
tags?: string[];
|
||||
} = {}
|
||||
): Promise<SavedCapture> {
|
||||
// Vérifier la limite
|
||||
if (this.state.captures.length >= MAX_CAPTURES) {
|
||||
this.cleanupOldCaptures(5);
|
||||
}
|
||||
|
||||
const id = `capture_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
const dimensions = await this.getImageDimensions(screenshot);
|
||||
const thumbnailSmall = await this.createThumbnail(screenshot, 100);
|
||||
|
||||
const capture: SavedCapture = {
|
||||
id,
|
||||
name,
|
||||
description: options.description || '',
|
||||
application: options.application || '',
|
||||
screenshot,
|
||||
thumbnailSmall,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
dimensions,
|
||||
tags: options.tags || [],
|
||||
};
|
||||
|
||||
this.state.captures.unshift(capture); // Ajouter en premier
|
||||
this.saveToStorage();
|
||||
|
||||
return capture;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir toutes les captures
|
||||
*/
|
||||
getAllCaptures(): SavedCapture[] {
|
||||
return [...this.state.captures];
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir une capture par ID
|
||||
*/
|
||||
getCaptureById(id: string): SavedCapture | null {
|
||||
return this.state.captures.find((c) => c.id === id) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rechercher des captures par nom ou application
|
||||
*/
|
||||
searchCaptures(query: string): SavedCapture[] {
|
||||
const lowerQuery = query.toLowerCase();
|
||||
return this.state.captures.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(lowerQuery) ||
|
||||
c.application?.toLowerCase().includes(lowerQuery) ||
|
||||
c.description?.toLowerCase().includes(lowerQuery) ||
|
||||
c.tags.some((t) => t.toLowerCase().includes(lowerQuery))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir les captures par application
|
||||
*/
|
||||
getCapturesByApplication(application: string): SavedCapture[] {
|
||||
return this.state.captures.filter(
|
||||
(c) => c.application?.toLowerCase() === application.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mettre à jour une capture
|
||||
*/
|
||||
updateCapture(
|
||||
id: string,
|
||||
updates: Partial<Pick<SavedCapture, 'name' | 'description' | 'application' | 'tags'>>
|
||||
): SavedCapture | null {
|
||||
const index = this.state.captures.findIndex((c) => c.id === id);
|
||||
if (index === -1) return null;
|
||||
|
||||
this.state.captures[index] = {
|
||||
...this.state.captures[index],
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
this.saveToStorage();
|
||||
return this.state.captures[index];
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprimer une capture
|
||||
*/
|
||||
deleteCapture(id: string): boolean {
|
||||
const index = this.state.captures.findIndex((c) => c.id === id);
|
||||
if (index === -1) return false;
|
||||
|
||||
this.state.captures.splice(index, 1);
|
||||
this.saveToStorage();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprimer les captures les plus anciennes
|
||||
*/
|
||||
private cleanupOldCaptures(count: number): void {
|
||||
// Trier par date (les plus anciennes à la fin) et supprimer
|
||||
this.state.captures.sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
);
|
||||
this.state.captures = this.state.captures.slice(0, MAX_CAPTURES - count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir la liste des applications uniques
|
||||
*/
|
||||
getApplications(): string[] {
|
||||
const apps = new Set<string>();
|
||||
this.state.captures.forEach((c) => {
|
||||
if (c.application) apps.add(c.application);
|
||||
});
|
||||
return Array.from(apps).sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Vider la bibliothèque
|
||||
*/
|
||||
clearLibrary(): void {
|
||||
this.state.captures = [];
|
||||
this.saveToStorage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir les statistiques de la bibliothèque
|
||||
*/
|
||||
getStats(): {
|
||||
totalCaptures: number;
|
||||
applications: number;
|
||||
oldestCapture: string | null;
|
||||
newestCapture: string | null;
|
||||
} {
|
||||
return {
|
||||
totalCaptures: this.state.captures.length,
|
||||
applications: this.getApplications().length,
|
||||
oldestCapture:
|
||||
this.state.captures.length > 0
|
||||
? this.state.captures[this.state.captures.length - 1].createdAt
|
||||
: null,
|
||||
newestCapture:
|
||||
this.state.captures.length > 0 ? this.state.captures[0].createdAt : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton
|
||||
export const captureLibraryService = new CaptureLibraryService();
|
||||
export default captureLibraryService;
|
||||
976
visual_workflow_builder/frontend/src/services/catalogService.ts
Normal file
976
visual_workflow_builder/frontend/src/services/catalogService.ts
Normal file
@@ -0,0 +1,976 @@
|
||||
/**
|
||||
* Service Catalogue VWB - Communication avec l'API Catalogue d'Actions VisionOnly
|
||||
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
|
||||
*
|
||||
* Ce service gère toutes les communications avec l'API du catalogue d'actions VisionOnly
|
||||
* du Visual Workflow Builder, incluant la liste des actions, l'exécution, et la validation.
|
||||
*
|
||||
* ARCHITECTURE:
|
||||
* - Détection automatique de l'URL du backend (cross-machine)
|
||||
* - Fallback automatique vers catalogue statique hors ligne
|
||||
* - Gestion d'erreurs robuste avec messages en français
|
||||
* - Types TypeScript stricts pour toutes les données
|
||||
* - Cache intelligent pour optimiser les performances
|
||||
* - Support du mode hors ligne gracieux
|
||||
*
|
||||
* NOUVEAUTÉS v2.0:
|
||||
* - URL configurable et détection automatique
|
||||
* - Catalogue statique de secours (5 actions de base)
|
||||
* - Persistance de configuration dans localStorage
|
||||
* - Gestion cross-machine robuste
|
||||
*/
|
||||
|
||||
// Import des types du catalogue
|
||||
import {
|
||||
VWBCatalogAction,
|
||||
VWBActionParameter,
|
||||
VWBActionExample,
|
||||
VWBActionCategory,
|
||||
VWBActionExecutionRequest,
|
||||
VWBActionExecutionResult,
|
||||
VWBActionEvidence,
|
||||
VWBActionError,
|
||||
VWBActionValidationRequest,
|
||||
VWBActionValidationResult,
|
||||
VWBCatalogHealth,
|
||||
VWBServiceStatus
|
||||
} from '../types/catalog';
|
||||
|
||||
// Import du catalogue statique de secours
|
||||
import {
|
||||
getStaticCatalogActions,
|
||||
getStaticActionsByCategory,
|
||||
getStaticActionById,
|
||||
searchStaticActions,
|
||||
getStaticCatalogCategories,
|
||||
getStaticCatalogStats,
|
||||
} from '../data/staticCatalog';
|
||||
|
||||
// Configuration du service catalogue
|
||||
interface CatalogServiceConfig {
|
||||
urls: string[];
|
||||
timeout: number;
|
||||
retryCount: number;
|
||||
cacheKey: string;
|
||||
fallbackToStatic: boolean;
|
||||
}
|
||||
|
||||
// État du service catalogue
|
||||
interface CatalogServiceState {
|
||||
mode: 'dynamic' | 'static' | 'offline';
|
||||
currentUrl: string | null;
|
||||
lastError: string | null;
|
||||
lastCheck: number;
|
||||
isOnline: boolean;
|
||||
}
|
||||
|
||||
// Alias pour éviter les conflits de noms
|
||||
type CatalogAction = VWBCatalogAction;
|
||||
type CatalogActionCategory = VWBActionCategory;
|
||||
|
||||
// Types pour l'API interne (compatibles avec les types VWB)
|
||||
export interface ActionExecutionRequest extends VWBActionExecutionRequest {}
|
||||
|
||||
export interface ActionExecutionResult extends VWBActionExecutionResult {}
|
||||
|
||||
export interface ActionEvidence extends VWBActionEvidence {}
|
||||
|
||||
export interface ActionError extends VWBActionError {}
|
||||
|
||||
// Types pour la validation
|
||||
export interface ActionValidationRequest extends VWBActionValidationRequest {}
|
||||
|
||||
export interface ActionValidationResult extends VWBActionValidationResult {}
|
||||
|
||||
// Types pour les réponses API
|
||||
interface CatalogApiResponse<T = any> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
offline?: boolean;
|
||||
}
|
||||
|
||||
interface CatalogListResponse {
|
||||
actions: CatalogAction[];
|
||||
total: number;
|
||||
categories: string[];
|
||||
screen_capturer_available: boolean;
|
||||
}
|
||||
|
||||
interface CatalogExecutionResponse {
|
||||
result: ActionExecutionResult;
|
||||
}
|
||||
|
||||
interface CatalogValidationResponse {
|
||||
validation: ActionValidationResult;
|
||||
}
|
||||
|
||||
interface CatalogHealthResponse {
|
||||
status: string;
|
||||
services: {
|
||||
screen_capturer: boolean;
|
||||
actions: number;
|
||||
screen_capturer_method: string;
|
||||
};
|
||||
timestamp: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service de gestion du catalogue d'actions VisionOnly pour le VWB
|
||||
* Fournit une interface TypeScript typée pour l'API du catalogue
|
||||
* avec détection automatique d'URL et fallback statique
|
||||
*/
|
||||
class CatalogService {
|
||||
private cache: Map<string, { data: any; timestamp: number }> = new Map();
|
||||
private readonly CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
|
||||
private readonly API_BASE_PATH = '/api/vwb/catalog';
|
||||
|
||||
// Configuration par défaut
|
||||
private config: CatalogServiceConfig = {
|
||||
urls: [],
|
||||
timeout: 2000,
|
||||
retryCount: 3,
|
||||
cacheKey: 'vwb_catalog_config',
|
||||
fallbackToStatic: true,
|
||||
};
|
||||
|
||||
// État du service
|
||||
private state: CatalogServiceState = {
|
||||
mode: 'offline',
|
||||
currentUrl: null,
|
||||
lastError: null,
|
||||
lastCheck: 0,
|
||||
isOnline: false,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
this.initializeConfig();
|
||||
this.loadPersistedConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialiser la configuration avec détection automatique d'URLs
|
||||
*/
|
||||
private initializeConfig(): void {
|
||||
// URLs à tester par ordre de priorité
|
||||
const candidateUrls: string[] = [];
|
||||
|
||||
// 1. Variable d'environnement (priorité maximale)
|
||||
const envUrl = process.env.REACT_APP_CATALOG_URL;
|
||||
if (envUrl) {
|
||||
candidateUrls.push(envUrl);
|
||||
}
|
||||
|
||||
// 2. Paramètre URL
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const paramUrl = urlParams.get('catalogUrl');
|
||||
if (paramUrl) {
|
||||
candidateUrls.push(paramUrl);
|
||||
}
|
||||
|
||||
// 3. Même origine que le frontend (pour déploiements intégrés)
|
||||
const currentOrigin = window.location.origin;
|
||||
candidateUrls.push(currentOrigin);
|
||||
|
||||
// 4. Localhost standard (développement) - Port 5001 en priorité
|
||||
if (!candidateUrls.includes('http://localhost:5001')) {
|
||||
candidateUrls.push('http://localhost:5001');
|
||||
}
|
||||
if (!candidateUrls.includes('http://localhost:5001')) {
|
||||
candidateUrls.push('http://localhost:5001');
|
||||
}
|
||||
|
||||
// 5. IP locale détectée (cross-machine)
|
||||
try {
|
||||
const localIp = this.detectLocalIp();
|
||||
if (localIp && localIp !== '127.0.0.1') {
|
||||
candidateUrls.push(`http://${localIp}:5000`);
|
||||
candidateUrls.push(`http://${localIp}:5004`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Impossible de détecter l\'IP locale:', error);
|
||||
}
|
||||
|
||||
this.config.urls = candidateUrls;
|
||||
console.log('🔍 URLs candidates pour le catalogue:', candidateUrls);
|
||||
}
|
||||
|
||||
/**
|
||||
* Détecter l'IP locale (approximation basée sur WebRTC)
|
||||
*/
|
||||
private detectLocalIp(): string | null {
|
||||
// Note: Cette méthode est limitée par les restrictions de sécurité des navigateurs
|
||||
// Elle fournit une estimation basée sur l'URL courante
|
||||
try {
|
||||
const hostname = window.location.hostname;
|
||||
|
||||
// Si on est déjà sur une IP, l'utiliser
|
||||
if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname)) {
|
||||
return hostname;
|
||||
}
|
||||
|
||||
// Sinon, retourner null pour utiliser les autres méthodes
|
||||
return null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Charger la configuration persistée depuis localStorage
|
||||
*/
|
||||
private loadPersistedConfig(): void {
|
||||
try {
|
||||
const stored = localStorage.getItem(this.config.cacheKey);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
|
||||
// Vérifier si la configuration n'est pas expirée (24h)
|
||||
const age = Date.now() - parsed.timestamp;
|
||||
const maxAge = 24 * 60 * 60 * 1000; // 24 heures
|
||||
|
||||
if (age < maxAge && parsed.url) {
|
||||
// Mettre l'URL fonctionnelle en première position
|
||||
this.config.urls = [
|
||||
parsed.url,
|
||||
...this.config.urls.filter(url => url !== parsed.url)
|
||||
];
|
||||
|
||||
console.log('📦 Configuration persistée chargée:', parsed.url);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Erreur lors du chargement de la configuration persistée:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persister la configuration fonctionnelle
|
||||
*/
|
||||
private persistConfig(workingUrl: string): void {
|
||||
try {
|
||||
const config = {
|
||||
url: workingUrl,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
localStorage.setItem(this.config.cacheKey, JSON.stringify(config));
|
||||
console.log('💾 Configuration persistée:', workingUrl);
|
||||
} catch (error) {
|
||||
console.warn('Erreur lors de la persistance de configuration:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Détecter automatiquement l'URL du backend fonctionnelle
|
||||
*/
|
||||
private async detectBackendUrl(): Promise<string | null> {
|
||||
console.log('🔍 Détection automatique de l\'URL du backend...');
|
||||
|
||||
for (const url of this.config.urls) {
|
||||
try {
|
||||
console.log(`⏳ Test de ${url}...`);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
||||
|
||||
const response = await fetch(`${url}/health`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (response.ok) {
|
||||
console.log(`✅ Backend trouvé sur ${url}`);
|
||||
this.state.currentUrl = url;
|
||||
this.state.isOnline = true;
|
||||
this.state.mode = 'dynamic';
|
||||
this.state.lastError = null;
|
||||
|
||||
// Persister la configuration fonctionnelle
|
||||
this.persistConfig(url);
|
||||
|
||||
return url;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`❌ ${url} non accessible:`, error instanceof Error ? error.message : error);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('🔴 Aucun backend accessible, passage en mode statique');
|
||||
this.state.mode = 'static';
|
||||
this.state.isOnline = false;
|
||||
this.state.currentUrl = null;
|
||||
this.state.lastError = 'Aucun backend catalogue accessible';
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forcer une nouvelle détection d'URL
|
||||
*/
|
||||
async forceUrlDetection(): Promise<boolean> {
|
||||
console.log('🔄 Détection forcée de l\'URL du backend...');
|
||||
|
||||
// Réinitialiser l'état
|
||||
this.state.lastCheck = 0;
|
||||
|
||||
// Relancer la détection
|
||||
const url = await this.detectBackendUrl();
|
||||
this.state.lastCheck = Date.now();
|
||||
|
||||
return url !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir l'état actuel du service
|
||||
*/
|
||||
getServiceState(): CatalogServiceState {
|
||||
return { ...this.state };
|
||||
}
|
||||
|
||||
/**
|
||||
* Effectuer une requête vers l'API catalogue avec gestion d'erreurs et fallback
|
||||
*/
|
||||
private async makeRequest<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
// Vérifier si on a une URL fonctionnelle ou tenter la détection
|
||||
if (!this.state.currentUrl || !this.state.isOnline) {
|
||||
const detectedUrl = await this.detectBackendUrl();
|
||||
if (!detectedUrl) {
|
||||
throw new Error('Service catalogue hors ligne - Mode statique activé');
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const fullEndpoint = `${this.API_BASE_PATH}${endpoint}`;
|
||||
|
||||
const response = await fetch(`${this.state.currentUrl}${fullEndpoint}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
let errorData: any = {};
|
||||
|
||||
try {
|
||||
errorData = JSON.parse(errorText);
|
||||
} catch {
|
||||
errorData = { message: errorText };
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
errorData.error ||
|
||||
errorData.message ||
|
||||
`Erreur API catalogue: ${response.status}`
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.success && data.error) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
|
||||
// Marquer comme en ligne si la requête réussit
|
||||
this.state.isOnline = true;
|
||||
this.state.lastError = null;
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error(`Erreur requête catalogue ${endpoint}:`, error);
|
||||
|
||||
// Marquer comme hors ligne
|
||||
this.state.isOnline = false;
|
||||
this.state.lastError = error instanceof Error ? error.message : 'Erreur inconnue';
|
||||
|
||||
// Gestion gracieuse des erreurs réseau
|
||||
if (error instanceof TypeError && error.message.includes('fetch')) {
|
||||
this.state.mode = 'static';
|
||||
throw new Error('Service catalogue hors ligne - Mode statique activé');
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifier si une donnée en cache est encore valide
|
||||
*/
|
||||
private isCacheValid(cacheKey: string): boolean {
|
||||
const cached = this.cache.get(cacheKey);
|
||||
if (!cached) return false;
|
||||
|
||||
return Date.now() - cached.timestamp < this.CACHE_DURATION;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir une donnée du cache ou null si invalide
|
||||
*/
|
||||
private getCachedData<T>(cacheKey: string): T | null {
|
||||
if (!this.isCacheValid(cacheKey)) {
|
||||
this.cache.delete(cacheKey);
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.cache.get(cacheKey)?.data || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mettre en cache une donnée
|
||||
*/
|
||||
private setCachedData(cacheKey: string, data: any): void {
|
||||
this.cache.set(cacheKey, {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupérer la liste des actions disponibles dans le catalogue
|
||||
* Avec fallback automatique vers le catalogue statique
|
||||
*
|
||||
* @param category - Filtrer par catégorie (optionnel)
|
||||
* @param search - Terme de recherche (optionnel)
|
||||
* @returns Liste des actions avec métadonnées
|
||||
*/
|
||||
async getActions(
|
||||
category?: CatalogActionCategory,
|
||||
search?: string
|
||||
): Promise<{
|
||||
actions: CatalogAction[];
|
||||
total: number;
|
||||
categories: string[];
|
||||
screenCapturerAvailable: boolean;
|
||||
mode: 'dynamic' | 'static';
|
||||
}> {
|
||||
// Tenter de charger depuis le backend dynamique
|
||||
const cacheKey = `actions_${category || 'all'}_${search || ''}`;
|
||||
const cached = this.getCachedData<{
|
||||
actions: CatalogAction[];
|
||||
total: number;
|
||||
categories: string[];
|
||||
screenCapturerAvailable: boolean;
|
||||
}>(cacheKey);
|
||||
|
||||
if (cached) {
|
||||
return { ...cached, mode: 'dynamic' };
|
||||
}
|
||||
|
||||
try {
|
||||
let endpoint = '/actions';
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (category) {
|
||||
params.append('category', category);
|
||||
}
|
||||
if (search) {
|
||||
params.append('search', search);
|
||||
}
|
||||
|
||||
if (params.toString()) {
|
||||
endpoint += `?${params.toString()}`;
|
||||
}
|
||||
|
||||
const response = await this.makeRequest<CatalogListResponse>(endpoint);
|
||||
|
||||
const result = {
|
||||
actions: response.actions,
|
||||
total: response.total,
|
||||
categories: response.categories,
|
||||
screenCapturerAvailable: response.screen_capturer_available,
|
||||
};
|
||||
|
||||
this.setCachedData(cacheKey, result);
|
||||
|
||||
return { ...result, mode: 'dynamic' as const };
|
||||
} catch (error) {
|
||||
console.warn('Erreur catalogue dynamique, utilisation du catalogue statique:', error);
|
||||
return this.getStaticActions(category, search);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupérer les actions du catalogue statique (mode hors ligne)
|
||||
*/
|
||||
private getStaticActions(
|
||||
category?: CatalogActionCategory,
|
||||
search?: string
|
||||
): {
|
||||
actions: CatalogAction[];
|
||||
total: number;
|
||||
categories: string[];
|
||||
screenCapturerAvailable: boolean;
|
||||
mode: 'static';
|
||||
} {
|
||||
let actions: CatalogAction[];
|
||||
|
||||
if (search) {
|
||||
actions = searchStaticActions(search);
|
||||
} else if (category) {
|
||||
actions = getStaticActionsByCategory(category);
|
||||
} else {
|
||||
actions = getStaticCatalogActions();
|
||||
}
|
||||
|
||||
const categories = getStaticCatalogCategories().map(cat => cat.id);
|
||||
|
||||
return {
|
||||
actions,
|
||||
total: actions.length,
|
||||
categories,
|
||||
screenCapturerAvailable: false, // Pas de capture d'écran en mode statique
|
||||
mode: 'static',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupérer les détails d'une action spécifique
|
||||
* Avec fallback vers le catalogue statique
|
||||
*
|
||||
* @param actionId - Identifiant de l'action
|
||||
* @returns Détails complets de l'action
|
||||
*/
|
||||
async getActionDetails(actionId: string): Promise<CatalogAction | null> {
|
||||
if (!actionId || actionId.trim().length === 0) {
|
||||
throw new Error('L\'identifiant de l\'action est obligatoire');
|
||||
}
|
||||
|
||||
try {
|
||||
// Vérifier le cache
|
||||
const cacheKey = `action_details_${actionId}`;
|
||||
const cached = this.getCachedData<{ action: CatalogAction }>(cacheKey);
|
||||
if (cached) {
|
||||
return cached.action;
|
||||
}
|
||||
|
||||
// Effectuer la requête
|
||||
const response = await this.makeRequest<{ action: CatalogAction }>(
|
||||
`/actions/${actionId}`
|
||||
);
|
||||
|
||||
// Mettre en cache
|
||||
this.setCachedData(cacheKey, response);
|
||||
|
||||
return response.action;
|
||||
} catch (error) {
|
||||
console.warn(`Erreur catalogue dynamique pour action ${actionId}, recherche en mode statique:`, error);
|
||||
|
||||
// Fallback vers le catalogue statique
|
||||
return getStaticActionById(actionId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exécuter une action du catalogue
|
||||
*
|
||||
* @param request - Configuration de l'action à exécuter
|
||||
* @returns Résultat de l'exécution avec evidence
|
||||
*/
|
||||
async executeAction(request: ActionExecutionRequest): Promise<ActionExecutionResult> {
|
||||
// Validation des paramètres
|
||||
if (!request.type || request.type.trim().length === 0) {
|
||||
throw new Error('Le type d\'action est obligatoire');
|
||||
}
|
||||
|
||||
if (!request.parameters || typeof request.parameters !== 'object') {
|
||||
throw new Error('Les paramètres de l\'action sont obligatoires');
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`🚀 Exécution de l'action ${request.type}...`);
|
||||
|
||||
// Effectuer la requête d'exécution
|
||||
const response = await this.makeRequest<CatalogExecutionResponse>('/execute', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
||||
const result = response.result;
|
||||
|
||||
// Log du résultat
|
||||
const statusEmoji = result.status === 'success' ? '✅' : '❌';
|
||||
console.log(
|
||||
`${statusEmoji} Action ${request.type} terminée en ${result.execution_time_ms}ms`
|
||||
);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`Erreur lors de l'exécution de l'action ${request.type}:`, error);
|
||||
|
||||
// Créer un résultat d'erreur standardisé
|
||||
const errorResult: ActionExecutionResult = {
|
||||
action_id: request.action_id || `error_${Date.now()}`,
|
||||
step_id: request.step_id || `step_${Date.now()}`,
|
||||
status: 'error',
|
||||
start_time: new Date().toISOString(),
|
||||
end_time: new Date().toISOString(),
|
||||
execution_time_ms: 0,
|
||||
output_data: {},
|
||||
evidence_list: [],
|
||||
error: {
|
||||
error_id: `error_${Date.now()}`,
|
||||
error_type: 'action_execution_failed',
|
||||
severity: 'high',
|
||||
message: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
retry_count: 0,
|
||||
workflow_id: request.workflow_id,
|
||||
user_id: request.user_id,
|
||||
};
|
||||
|
||||
return errorResult;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Valider la configuration d'une action sans l'exécuter
|
||||
*
|
||||
* @param request - Configuration de l'action à valider
|
||||
* @returns Résultat de la validation avec erreurs et suggestions
|
||||
*/
|
||||
async validateAction(request: ActionValidationRequest): Promise<ActionValidationResult> {
|
||||
// Validation des paramètres
|
||||
if (!request.type || request.type.trim().length === 0) {
|
||||
throw new Error('Le type d\'action est obligatoire');
|
||||
}
|
||||
|
||||
try {
|
||||
// Effectuer la requête de validation
|
||||
const response = await this.makeRequest<CatalogValidationResponse>('/validate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
||||
// S'assurer que la réponse contient une validation valide
|
||||
if (response && response.validation) {
|
||||
return response.validation;
|
||||
}
|
||||
|
||||
// Si pas de validation dans la réponse, retourner un résultat valide par défaut
|
||||
return {
|
||||
is_valid: true,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
suggestions: [],
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Erreur lors de la validation de l'action ${request.type}:`, error);
|
||||
|
||||
// Retourner un résultat d'erreur
|
||||
return {
|
||||
is_valid: false,
|
||||
errors: [{
|
||||
parameter: 'general',
|
||||
message: error instanceof Error ? error.message : 'Erreur de validation',
|
||||
code: 'validation_error',
|
||||
severity: 'error',
|
||||
}],
|
||||
warnings: [],
|
||||
suggestions: [{
|
||||
type: 'best_practice',
|
||||
message: 'Vérifiez la configuration de l\'action',
|
||||
priority: 'medium',
|
||||
}],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifier la santé du service catalogue
|
||||
*
|
||||
* @returns État du service avec informations de diagnostic
|
||||
*/
|
||||
async getHealth(): Promise<{
|
||||
status: string;
|
||||
services: {
|
||||
screenCapturer: boolean;
|
||||
actions: number;
|
||||
screenCapturerMethod: string;
|
||||
};
|
||||
timestamp: string;
|
||||
version: string;
|
||||
}> {
|
||||
try {
|
||||
const response = await this.makeRequest<CatalogHealthResponse>('/health');
|
||||
|
||||
return {
|
||||
status: response.status,
|
||||
services: {
|
||||
screenCapturer: response.services.screen_capturer,
|
||||
actions: response.services.actions,
|
||||
screenCapturerMethod: response.services.screen_capturer_method,
|
||||
},
|
||||
timestamp: response.timestamp,
|
||||
version: response.version,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la vérification de santé du catalogue:', error);
|
||||
|
||||
return {
|
||||
status: 'offline',
|
||||
services: {
|
||||
screenCapturer: false,
|
||||
actions: 0,
|
||||
screenCapturerMethod: 'unavailable',
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
version: 'unknown',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir les catégories d'actions disponibles
|
||||
* Avec fallback vers le catalogue statique
|
||||
*
|
||||
* @returns Liste des catégories avec métadonnées
|
||||
*/
|
||||
async getCategories(): Promise<Array<{
|
||||
id: CatalogActionCategory;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
actionCount: number;
|
||||
mode: 'dynamic' | 'static';
|
||||
}>> {
|
||||
try {
|
||||
// Récupérer toutes les actions pour calculer les statistiques
|
||||
const { actions, categories } = await this.getActions();
|
||||
|
||||
// Définir les métadonnées des catégories
|
||||
const categoryMetadata: Record<CatalogActionCategory, {
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
}> = {
|
||||
vision_ui: {
|
||||
name: 'Interactions Visuelles',
|
||||
description: 'Cliquer, saisir, glisser-déposer sur des éléments visuels',
|
||||
icon: '🖱️',
|
||||
},
|
||||
control: {
|
||||
name: 'Contrôle de Flux',
|
||||
description: 'Conditions, boucles et synchronisation basées sur la vision',
|
||||
icon: '🔀',
|
||||
},
|
||||
data: {
|
||||
name: 'Extraction de Données',
|
||||
description: 'Extraire texte, tableaux, télécharger des fichiers',
|
||||
icon: '📊',
|
||||
},
|
||||
intelligence: {
|
||||
name: 'Intelligence IA',
|
||||
description: 'Analyse et traitement intelligent par IA',
|
||||
icon: '🤖',
|
||||
},
|
||||
database: {
|
||||
name: 'Base de Données',
|
||||
description: 'Lire et enregistrer des données en base',
|
||||
icon: '💾',
|
||||
},
|
||||
validation: {
|
||||
name: 'Validation',
|
||||
description: 'Vérifier la présence et le contenu des éléments',
|
||||
icon: '✅',
|
||||
},
|
||||
};
|
||||
|
||||
// Construire la liste des catégories avec statistiques
|
||||
return categories.map(categoryId => {
|
||||
const category = categoryId as CatalogActionCategory;
|
||||
const metadata = categoryMetadata[category] || {
|
||||
name: category,
|
||||
description: `Actions de type ${category}`,
|
||||
icon: '📋',
|
||||
};
|
||||
|
||||
const actionCount = actions.filter(action => action.category === category).length;
|
||||
|
||||
return {
|
||||
id: category,
|
||||
name: metadata.name,
|
||||
description: metadata.description,
|
||||
icon: metadata.icon,
|
||||
actionCount,
|
||||
mode: this.state.mode === 'offline' ? 'static' : this.state.mode,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('Erreur catalogue dynamique, utilisation du catalogue statique:', error);
|
||||
|
||||
// Fallback vers le catalogue statique
|
||||
return getStaticCatalogCategories().map(cat => ({
|
||||
...cat,
|
||||
mode: 'static' as const,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rechercher des actions par terme
|
||||
* Avec fallback vers le catalogue statique
|
||||
*
|
||||
* @param searchTerm - Terme de recherche
|
||||
* @returns Actions correspondant au terme de recherche
|
||||
*/
|
||||
async searchActions(searchTerm: string): Promise<CatalogAction[]> {
|
||||
if (!searchTerm || searchTerm.trim().length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const { actions } = await this.getActions(undefined, searchTerm.trim());
|
||||
return actions;
|
||||
} catch (error) {
|
||||
console.warn('Erreur recherche catalogue dynamique, utilisation du catalogue statique:', error);
|
||||
|
||||
// Fallback vers le catalogue statique
|
||||
return searchStaticActions(searchTerm.trim());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vider le cache du service
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.cache.clear();
|
||||
console.log('Cache du service catalogue vidé');
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir les statistiques du cache
|
||||
*/
|
||||
getCacheStats(): {
|
||||
size: number;
|
||||
keys: string[];
|
||||
oldestEntry: string | null;
|
||||
newestEntry: string | null;
|
||||
} {
|
||||
const entries = Array.from(this.cache.entries());
|
||||
|
||||
if (entries.length === 0) {
|
||||
return {
|
||||
size: 0,
|
||||
keys: [],
|
||||
oldestEntry: null,
|
||||
newestEntry: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Trier par timestamp
|
||||
entries.sort((a, b) => a[1].timestamp - b[1].timestamp);
|
||||
|
||||
return {
|
||||
size: entries.length,
|
||||
keys: entries.map(([key]) => key),
|
||||
oldestEntry: entries[0][0],
|
||||
newestEntry: entries[entries.length - 1][0],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir les statistiques complètes du service
|
||||
*/
|
||||
async getServiceStats(): Promise<{
|
||||
mode: 'dynamic' | 'static' | 'offline';
|
||||
isOnline: boolean;
|
||||
currentUrl: string | null;
|
||||
lastError: string | null;
|
||||
lastCheck: number;
|
||||
cacheStats: {
|
||||
size: number;
|
||||
keys: string[];
|
||||
oldestEntry: string | null;
|
||||
newestEntry: string | null;
|
||||
};
|
||||
catalogStats: any;
|
||||
}> {
|
||||
const cacheStats = this.getCacheStats();
|
||||
|
||||
let catalogStats;
|
||||
try {
|
||||
if (this.state.mode === 'static') {
|
||||
catalogStats = getStaticCatalogStats();
|
||||
} else {
|
||||
const { actions, total } = await this.getActions();
|
||||
catalogStats = {
|
||||
totalActions: total,
|
||||
mode: this.state.mode,
|
||||
actionsLoaded: actions.length,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
catalogStats = { error: 'Impossible de récupérer les statistiques' };
|
||||
}
|
||||
|
||||
return {
|
||||
mode: this.state.mode,
|
||||
isOnline: this.state.isOnline,
|
||||
currentUrl: this.state.currentUrl,
|
||||
lastError: this.state.lastError,
|
||||
lastCheck: this.state.lastCheck,
|
||||
cacheStats,
|
||||
catalogStats,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Réinitialiser complètement le service
|
||||
*/
|
||||
async reset(): Promise<void> {
|
||||
console.log('🔄 Réinitialisation complète du service catalogue...');
|
||||
|
||||
// Vider le cache
|
||||
this.clearCache();
|
||||
|
||||
// Supprimer la configuration persistée
|
||||
try {
|
||||
localStorage.removeItem(this.config.cacheKey);
|
||||
} catch (error) {
|
||||
console.warn('Erreur lors de la suppression de la configuration persistée:', error);
|
||||
}
|
||||
|
||||
// Réinitialiser l'état
|
||||
this.state = {
|
||||
mode: 'offline',
|
||||
currentUrl: null,
|
||||
lastError: null,
|
||||
lastCheck: 0,
|
||||
isOnline: false,
|
||||
};
|
||||
|
||||
// Réinitialiser la configuration
|
||||
this.initializeConfig();
|
||||
|
||||
// Relancer la détection
|
||||
await this.forceUrlDetection();
|
||||
|
||||
console.log('✅ Service catalogue réinitialisé');
|
||||
}
|
||||
}
|
||||
|
||||
// Instance singleton du service catalogue
|
||||
export const catalogService = new CatalogService();
|
||||
|
||||
// Export des types pour utilisation externe (sans conflits)
|
||||
export type {
|
||||
CatalogAction,
|
||||
CatalogActionCategory,
|
||||
};
|
||||
|
||||
export default CatalogService;
|
||||
@@ -0,0 +1,504 @@
|
||||
/**
|
||||
* Service Evidence d'Exécution - Gestion centralisée des Evidence VWB
|
||||
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
|
||||
*
|
||||
* Ce service centralise la gestion des Evidence pendant l'exécution des workflows VWB,
|
||||
* avec persistance, synchronisation et optimisations de performance.
|
||||
*/
|
||||
|
||||
import { Evidence, Step } from '../types';
|
||||
import { VWBExecutionResult } from './vwbExecutionService';
|
||||
|
||||
export interface EvidenceExecutionConfig {
|
||||
maxEvidencePerStep: number;
|
||||
maxTotalEvidence: number;
|
||||
autoCleanupInterval: number;
|
||||
persistToStorage: boolean;
|
||||
compressionEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface EvidenceMetrics {
|
||||
totalEvidence: number;
|
||||
evidenceByStep: Record<string, number>;
|
||||
evidenceByType: Record<string, number>;
|
||||
averageSize: number;
|
||||
oldestTimestamp: Date | null;
|
||||
newestTimestamp: Date | null;
|
||||
}
|
||||
|
||||
export interface EvidenceQuery {
|
||||
stepId?: string;
|
||||
type?: string;
|
||||
action_id?: string;
|
||||
dateFrom?: Date;
|
||||
dateTo?: Date;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
sortBy?: 'timestamp' | 'type' | 'size';
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
/**
|
||||
* Service principal pour la gestion des Evidence d'exécution
|
||||
*/
|
||||
export class EvidenceExecutionService {
|
||||
private static instance: EvidenceExecutionService;
|
||||
private config: EvidenceExecutionConfig;
|
||||
private evidenceStore: Map<string, Evidence[]> = new Map();
|
||||
private allEvidence: Evidence[] = [];
|
||||
private listeners: Set<(evidence: Evidence[], stepId?: string) => void> = new Set();
|
||||
private cleanupTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
private constructor(config: Partial<EvidenceExecutionConfig> = {}) {
|
||||
this.config = {
|
||||
maxEvidencePerStep: 50,
|
||||
maxTotalEvidence: 200,
|
||||
autoCleanupInterval: 5 * 60 * 1000, // 5 minutes
|
||||
persistToStorage: true,
|
||||
compressionEnabled: false,
|
||||
...config,
|
||||
};
|
||||
|
||||
this.initializeService();
|
||||
}
|
||||
|
||||
public static getInstance(config?: Partial<EvidenceExecutionConfig>): EvidenceExecutionService {
|
||||
if (!EvidenceExecutionService.instance) {
|
||||
EvidenceExecutionService.instance = new EvidenceExecutionService(config);
|
||||
}
|
||||
return EvidenceExecutionService.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialiser le service
|
||||
*/
|
||||
private initializeService(): void {
|
||||
// Charger les Evidence depuis le stockage
|
||||
this.loadFromStorage();
|
||||
|
||||
// Démarrer le nettoyage automatique
|
||||
if (this.config.autoCleanupInterval > 0) {
|
||||
this.startAutoCleanup();
|
||||
}
|
||||
|
||||
// Écouter les changements de visibilité pour optimiser les performances
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('visibilitychange', this.handleVisibilityChange.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajouter une Evidence pour une étape
|
||||
*/
|
||||
public addEvidence(stepId: string, evidence: Evidence): void {
|
||||
// Vérifier les limites
|
||||
if (this.allEvidence.length >= this.config.maxTotalEvidence) {
|
||||
this.performCleanup();
|
||||
}
|
||||
|
||||
// Obtenir les Evidence de l'étape
|
||||
let stepEvidence = this.evidenceStore.get(stepId) || [];
|
||||
|
||||
// Vérifier la limite par étape
|
||||
if (stepEvidence.length >= this.config.maxEvidencePerStep) {
|
||||
stepEvidence = stepEvidence.slice(1); // Supprimer la plus ancienne
|
||||
}
|
||||
|
||||
// Ajouter la nouvelle Evidence
|
||||
stepEvidence.push(evidence);
|
||||
this.evidenceStore.set(stepId, stepEvidence);
|
||||
|
||||
// Mettre à jour la liste globale
|
||||
this.updateAllEvidence();
|
||||
|
||||
// Sauvegarder si activé
|
||||
if (this.config.persistToStorage) {
|
||||
this.saveToStorage();
|
||||
}
|
||||
|
||||
// Notifier les listeners
|
||||
this.notifyListeners(stepEvidence, stepId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajouter plusieurs Evidence pour une étape
|
||||
*/
|
||||
public addMultipleEvidence(stepId: string, evidenceList: Evidence[]): void {
|
||||
evidenceList.forEach(evidence => this.addEvidence(stepId, evidence));
|
||||
}
|
||||
|
||||
/**
|
||||
* Traiter les résultats d'exécution VWB
|
||||
*/
|
||||
public processExecutionResult(result: VWBExecutionResult): void {
|
||||
if (result.evidence && result.evidence.length > 0) {
|
||||
this.addMultipleEvidence(result.stepId, result.evidence);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir les Evidence d'une étape
|
||||
*/
|
||||
public getEvidenceByStep(stepId: string): Evidence[] {
|
||||
return this.evidenceStore.get(stepId) || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir toutes les Evidence
|
||||
*/
|
||||
public getAllEvidence(): Evidence[] {
|
||||
return [...this.allEvidence];
|
||||
}
|
||||
|
||||
/**
|
||||
* Rechercher des Evidence
|
||||
*/
|
||||
public searchEvidence(query: EvidenceQuery): Evidence[] {
|
||||
let results = this.allEvidence;
|
||||
|
||||
// Filtrer par étape
|
||||
if (query.stepId) {
|
||||
results = this.getEvidenceByStep(query.stepId);
|
||||
}
|
||||
|
||||
// Filtrer par type
|
||||
if (query.action_id) {
|
||||
results = results.filter(evidence => evidence.action_id === query.action_id);
|
||||
}
|
||||
|
||||
// Filtrer par date
|
||||
if (query.dateFrom) {
|
||||
results = results.filter(evidence =>
|
||||
new Date(evidence.captured_at) >= query.dateFrom!
|
||||
);
|
||||
}
|
||||
|
||||
if (query.dateTo) {
|
||||
results = results.filter(evidence =>
|
||||
new Date(evidence.captured_at) <= query.dateTo!
|
||||
);
|
||||
}
|
||||
|
||||
// Trier
|
||||
if (query.sortBy) {
|
||||
results.sort((a, b) => {
|
||||
let comparison = 0;
|
||||
|
||||
switch (query.sortBy) {
|
||||
case 'timestamp':
|
||||
comparison = new Date(a.captured_at).getTime() - new Date(b.captured_at).getTime();
|
||||
break;
|
||||
case 'type':
|
||||
comparison = a.action_id.localeCompare(b.action_id);
|
||||
break;
|
||||
case 'size':
|
||||
const sizeA = this.getEvidenceSize(a);
|
||||
const sizeB = this.getEvidenceSize(b);
|
||||
comparison = sizeA - sizeB;
|
||||
break;
|
||||
}
|
||||
|
||||
return query.sortOrder === 'desc' ? -comparison : comparison;
|
||||
});
|
||||
}
|
||||
|
||||
// Pagination
|
||||
if (query.offset || query.limit) {
|
||||
const start = query.offset || 0;
|
||||
const end = query.limit ? start + query.limit : undefined;
|
||||
results = results.slice(start, end);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir les métriques des Evidence
|
||||
*/
|
||||
public getMetrics(): EvidenceMetrics {
|
||||
const evidenceByStep: Record<string, number> = {};
|
||||
const evidenceByType: Record<string, number> = {};
|
||||
let totalSize = 0;
|
||||
let oldestTimestamp: Date | null = null;
|
||||
let newestTimestamp: Date | null = null;
|
||||
|
||||
// Calculer les métriques par étape
|
||||
this.evidenceStore.forEach((evidence, stepId) => {
|
||||
evidenceByStep[stepId] = evidence.length;
|
||||
});
|
||||
|
||||
// Calculer les métriques globales
|
||||
this.allEvidence.forEach(evidence => {
|
||||
// Par type
|
||||
evidenceByType[evidence.action_id] = (evidenceByType[evidence.action_id] || 0) + 1;
|
||||
|
||||
// Taille
|
||||
totalSize += this.getEvidenceSize(evidence);
|
||||
|
||||
// Timestamps
|
||||
const timestamp = new Date(evidence.captured_at);
|
||||
if (!oldestTimestamp || timestamp < oldestTimestamp) {
|
||||
oldestTimestamp = timestamp;
|
||||
}
|
||||
if (!newestTimestamp || timestamp > newestTimestamp) {
|
||||
newestTimestamp = timestamp;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
totalEvidence: this.allEvidence.length,
|
||||
evidenceByStep,
|
||||
evidenceByType,
|
||||
averageSize: this.allEvidence.length > 0 ? totalSize / this.allEvidence.length : 0,
|
||||
oldestTimestamp,
|
||||
newestTimestamp,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Nettoyer les Evidence d'une étape
|
||||
*/
|
||||
public clearStepEvidence(stepId: string): void {
|
||||
this.evidenceStore.delete(stepId);
|
||||
this.updateAllEvidence();
|
||||
|
||||
if (this.config.persistToStorage) {
|
||||
this.saveToStorage();
|
||||
}
|
||||
|
||||
this.notifyListeners([], stepId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nettoyer toutes les Evidence
|
||||
*/
|
||||
public clearAllEvidence(): void {
|
||||
this.evidenceStore.clear();
|
||||
this.allEvidence = [];
|
||||
|
||||
if (this.config.persistToStorage) {
|
||||
this.clearStorage();
|
||||
}
|
||||
|
||||
this.notifyListeners([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajouter un listener pour les changements
|
||||
*/
|
||||
public addListener(listener: (evidence: Evidence[], stepId?: string) => void): void {
|
||||
this.listeners.add(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprimer un listener
|
||||
*/
|
||||
public removeListener(listener: (evidence: Evidence[], stepId?: string) => void): void {
|
||||
this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exporter les Evidence
|
||||
*/
|
||||
public exportEvidence(stepId?: string): string {
|
||||
const data = stepId
|
||||
? { [stepId]: this.getEvidenceByStep(stepId) }
|
||||
: Object.fromEntries(this.evidenceStore);
|
||||
|
||||
return JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
stepId,
|
||||
evidence: data,
|
||||
metrics: this.getMetrics(),
|
||||
}, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Importer des Evidence
|
||||
*/
|
||||
public importEvidence(jsonData: string): void {
|
||||
try {
|
||||
const data = JSON.parse(jsonData);
|
||||
|
||||
if (data.evidence) {
|
||||
Object.entries(data.evidence).forEach(([stepId, evidence]) => {
|
||||
if (Array.isArray(evidence)) {
|
||||
this.evidenceStore.set(stepId, evidence as Evidence[]);
|
||||
}
|
||||
});
|
||||
|
||||
this.updateAllEvidence();
|
||||
|
||||
if (this.config.persistToStorage) {
|
||||
this.saveToStorage();
|
||||
}
|
||||
|
||||
this.notifyListeners(this.allEvidence);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de l\'importation des Evidence:', error);
|
||||
throw new Error('Format de données invalide');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mettre à jour la liste globale des Evidence
|
||||
*/
|
||||
private updateAllEvidence(): void {
|
||||
this.allEvidence = [];
|
||||
this.evidenceStore.forEach(stepEvidence => {
|
||||
this.allEvidence.push(...stepEvidence);
|
||||
});
|
||||
|
||||
// Trier par timestamp
|
||||
this.allEvidence.sort((a, b) =>
|
||||
new Date(a.captured_at).getTime() - new Date(b.captured_at).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifier les listeners
|
||||
*/
|
||||
private notifyListeners(evidence: Evidence[], stepId?: string): void {
|
||||
this.listeners.forEach(listener => {
|
||||
try {
|
||||
listener(evidence, stepId);
|
||||
} catch (error) {
|
||||
console.error('Erreur dans le listener Evidence:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir la taille d'une Evidence
|
||||
*/
|
||||
private getEvidenceSize(evidence: Evidence): number {
|
||||
return JSON.stringify(evidence).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Effectuer le nettoyage automatique
|
||||
*/
|
||||
private performCleanup(): void {
|
||||
const cutoffTime = Date.now() - (30 * 60 * 1000); // 30 minutes
|
||||
|
||||
this.evidenceStore.forEach((stepEvidence, stepId) => {
|
||||
const filtered = stepEvidence.filter(evidence =>
|
||||
new Date(evidence.captured_at).getTime() > cutoffTime
|
||||
);
|
||||
|
||||
if (filtered.length !== stepEvidence.length) {
|
||||
this.evidenceStore.set(stepId, filtered);
|
||||
}
|
||||
});
|
||||
|
||||
this.updateAllEvidence();
|
||||
|
||||
if (this.config.persistToStorage) {
|
||||
this.saveToStorage();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Démarrer le nettoyage automatique
|
||||
*/
|
||||
private startAutoCleanup(): void {
|
||||
this.cleanupTimer = setInterval(() => {
|
||||
this.performCleanup();
|
||||
}, this.config.autoCleanupInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gérer les changements de visibilité
|
||||
*/
|
||||
private handleVisibilityChange(): void {
|
||||
if (document.hidden) {
|
||||
// Sauvegarder quand la page devient invisible
|
||||
if (this.config.persistToStorage) {
|
||||
this.saveToStorage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sauvegarder dans le stockage local
|
||||
*/
|
||||
private saveToStorage(): void {
|
||||
try {
|
||||
const data = {
|
||||
evidenceStore: Object.fromEntries(this.evidenceStore),
|
||||
timestamp: Date.now(),
|
||||
config: this.config,
|
||||
};
|
||||
|
||||
localStorage.setItem('vwb_evidence_execution', JSON.stringify(data));
|
||||
} catch (error) {
|
||||
console.warn('Erreur lors de la sauvegarde des Evidence:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Charger depuis le stockage local
|
||||
*/
|
||||
private loadFromStorage(): void {
|
||||
try {
|
||||
const stored = localStorage.getItem('vwb_evidence_execution');
|
||||
if (stored) {
|
||||
const data = JSON.parse(stored);
|
||||
|
||||
if (data.evidenceStore) {
|
||||
this.evidenceStore = new Map(Object.entries(data.evidenceStore));
|
||||
this.updateAllEvidence();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Erreur lors du chargement des Evidence:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Nettoyer le stockage local
|
||||
*/
|
||||
private clearStorage(): void {
|
||||
try {
|
||||
localStorage.removeItem('vwb_evidence_execution');
|
||||
} catch (error) {
|
||||
console.warn('Erreur lors du nettoyage du stockage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Nettoyer les ressources
|
||||
*/
|
||||
public cleanup(): void {
|
||||
if (this.cleanupTimer) {
|
||||
clearInterval(this.cleanupTimer);
|
||||
this.cleanupTimer = null;
|
||||
}
|
||||
|
||||
this.listeners.clear();
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
document.removeEventListener('visibilitychange', this.handleVisibilityChange.bind(this));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Instance singleton
|
||||
export const evidenceExecutionService = EvidenceExecutionService.getInstance();
|
||||
|
||||
// Hook pour utiliser le service
|
||||
export const useEvidenceExecutionService = () => {
|
||||
return {
|
||||
service: evidenceExecutionService,
|
||||
addEvidence: (stepId: string, evidence: Evidence) =>
|
||||
evidenceExecutionService.addEvidence(stepId, evidence),
|
||||
getEvidenceByStep: (stepId: string) =>
|
||||
evidenceExecutionService.getEvidenceByStep(stepId),
|
||||
getAllEvidence: () => evidenceExecutionService.getAllEvidence(),
|
||||
searchEvidence: (query: EvidenceQuery) =>
|
||||
evidenceExecutionService.searchEvidence(query),
|
||||
getMetrics: () => evidenceExecutionService.getMetrics(),
|
||||
clearAllEvidence: () => evidenceExecutionService.clearAllEvidence(),
|
||||
};
|
||||
};
|
||||
351
visual_workflow_builder/frontend/src/services/evidenceService.ts
Normal file
351
visual_workflow_builder/frontend/src/services/evidenceService.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* Service pour la gestion des Evidence VWB
|
||||
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
|
||||
*/
|
||||
|
||||
import { VWBEvidence, EvidenceFilters, EvidenceExportOptions, EvidenceStats, EvidenceUtils } from '../types/evidence';
|
||||
|
||||
export class EvidenceService {
|
||||
private baseUrl: string;
|
||||
private cache: Map<string, VWBEvidence[]> = new Map();
|
||||
private cacheTimeout: number = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
constructor(baseUrl: string = 'http://localhost:5001') {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère toutes les Evidence disponibles
|
||||
*/
|
||||
async getEvidences(workflowId?: string): Promise<VWBEvidence[]> {
|
||||
try {
|
||||
const cacheKey = `evidences_${workflowId || 'all'}`;
|
||||
const cached = this.cache.get(cacheKey);
|
||||
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const url = workflowId
|
||||
? `${this.baseUrl}/api/vwb/evidences?workflow_id=${workflowId}`
|
||||
: `${this.baseUrl}/api/vwb/evidences`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Erreur lors de la récupération des Evidence : ${response.statusText}`);
|
||||
}
|
||||
|
||||
const evidences: VWBEvidence[] = await response.json();
|
||||
|
||||
// Mise en cache
|
||||
this.cache.set(cacheKey, evidences);
|
||||
setTimeout(() => this.cache.delete(cacheKey), this.cacheTimeout);
|
||||
|
||||
return evidences;
|
||||
} catch (error) {
|
||||
console.error('Erreur EvidenceService.getEvidences:', error);
|
||||
throw new Error(`Impossible de récupérer les Evidence : ${error instanceof Error ? error.message : 'Erreur inconnue'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère une Evidence spécifique par ID
|
||||
*/
|
||||
async getEvidence(evidenceId: string): Promise<VWBEvidence | null> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/api/vwb/evidences/${evidenceId}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Erreur lors de la récupération de l'Evidence : ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Erreur EvidenceService.getEvidence:', error);
|
||||
throw new Error(`Impossible de récupérer l'Evidence ${evidenceId} : ${error instanceof Error ? error.message : 'Erreur inconnue'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sauvegarde une Evidence
|
||||
*/
|
||||
async saveEvidence(evidence: VWBEvidence): Promise<VWBEvidence> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/api/vwb/evidences`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(evidence),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Erreur lors de la sauvegarde de l'Evidence : ${response.statusText}`);
|
||||
}
|
||||
|
||||
const savedEvidence = await response.json();
|
||||
|
||||
// Invalider le cache
|
||||
this.cache.clear();
|
||||
|
||||
return savedEvidence;
|
||||
} catch (error) {
|
||||
console.error('Erreur EvidenceService.saveEvidence:', error);
|
||||
throw new Error(`Impossible de sauvegarder l'Evidence : ${error instanceof Error ? error.message : 'Erreur inconnue'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime une Evidence
|
||||
*/
|
||||
async deleteEvidence(evidenceId: string): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/api/vwb/evidences/${evidenceId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Erreur lors de la suppression de l'Evidence : ${response.statusText}`);
|
||||
}
|
||||
|
||||
// Invalider le cache
|
||||
this.cache.clear();
|
||||
} catch (error) {
|
||||
console.error('Erreur EvidenceService.deleteEvidence:', error);
|
||||
throw new Error(`Impossible de supprimer l'Evidence ${evidenceId} : ${error instanceof Error ? error.message : 'Erreur inconnue'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filtre les Evidence selon les critères spécifiés
|
||||
*/
|
||||
filterEvidences(evidences: VWBEvidence[], filters: EvidenceFilters): VWBEvidence[] {
|
||||
return EvidenceUtils.filterEvidences(evidences, filters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trie les Evidence
|
||||
*/
|
||||
sortEvidences(evidences: VWBEvidence[], sortBy: string, sortOrder: 'asc' | 'desc' = 'desc'): VWBEvidence[] {
|
||||
return EvidenceUtils.sortEvidences(evidences, sortBy, sortOrder);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule les statistiques des Evidence
|
||||
*/
|
||||
calculateStats(evidences: VWBEvidence[]): EvidenceStats {
|
||||
return EvidenceUtils.calculateStats(evidences);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exporte les Evidence selon les options spécifiées
|
||||
*/
|
||||
async exportEvidences(evidences: VWBEvidence[], options: EvidenceExportOptions): Promise<Blob> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/api/vwb/evidences/export`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
evidences,
|
||||
options,
|
||||
format: options.format || 'json'
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Erreur lors de l'export des Evidence : ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.blob();
|
||||
} catch (error) {
|
||||
console.error('Erreur EvidenceService.exportEvidences:', error);
|
||||
|
||||
// Fallback : export côté client
|
||||
return this.exportEvidencesClientSide(evidences, options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export côté client en cas d'échec du serveur
|
||||
*/
|
||||
private exportEvidencesClientSide(evidences: VWBEvidence[], options: EvidenceExportOptions): Blob {
|
||||
if (options.format === 'json') {
|
||||
const exportData = {
|
||||
metadata: {
|
||||
exportDate: new Date().toISOString(),
|
||||
totalEvidences: evidences.length,
|
||||
options
|
||||
},
|
||||
evidences: evidences.map(evidence => ({
|
||||
...evidence,
|
||||
screenshot_base64: options.includeScreenshots ? evidence.screenshot_base64 : undefined
|
||||
}))
|
||||
};
|
||||
|
||||
return new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
|
||||
}
|
||||
|
||||
if (options.format === 'html') {
|
||||
const html = this.generateHtmlReport(evidences, options);
|
||||
return new Blob([html], { type: 'text/html' });
|
||||
}
|
||||
|
||||
if (options.format === 'pdf') {
|
||||
// Pour PDF, on génère du HTML qui peut être converti
|
||||
const html = this.generateHtmlReport(evidences, options);
|
||||
return new Blob([html], { type: 'text/html' });
|
||||
}
|
||||
|
||||
// Format par défaut
|
||||
const html = this.generateHtmlReport(evidences, options);
|
||||
return new Blob([html], { type: 'text/html' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère un rapport HTML des Evidence
|
||||
*/
|
||||
private generateHtmlReport(evidences: VWBEvidence[], options: EvidenceExportOptions): string {
|
||||
const stats = this.calculateStats(evidences);
|
||||
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Rapport Evidence VWB - ${new Date().toLocaleDateString('fr-FR')}</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 20px; }
|
||||
.header { border-bottom: 2px solid #1976d2; padding-bottom: 10px; margin-bottom: 20px; }
|
||||
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; margin-bottom: 30px; }
|
||||
.stat-card { background: #f5f5f5; padding: 15px; border-radius: 8px; text-align: center; }
|
||||
.stat-value { font-size: 24px; font-weight: bold; color: #1976d2; }
|
||||
.evidence-item { border: 1px solid #ddd; margin-bottom: 20px; padding: 15px; border-radius: 8px; }
|
||||
.evidence-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
|
||||
.evidence-status { padding: 4px 8px; border-radius: 4px; color: white; font-size: 12px; }
|
||||
.status-success { background: #4caf50; }
|
||||
.status-error { background: #f44336; }
|
||||
.evidence-screenshot { max-width: 300px; max-height: 200px; border: 1px solid #ddd; margin: 10px 0; }
|
||||
.evidence-metadata { background: #f9f9f9; padding: 10px; border-radius: 4px; font-size: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>Rapport Evidence VWB</h1>
|
||||
<p>Généré le ${new Date().toLocaleString('fr-FR')}</p>
|
||||
<p>Nombre d'Evidence : ${evidences.length}</p>
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${stats.total}</div>
|
||||
<div>Total</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${stats.successful}</div>
|
||||
<div>Réussies</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${stats.failed}</div>
|
||||
<div>Échouées</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${EvidenceUtils.formatExecutionTime(stats.averageExecutionTime)}</div>
|
||||
<div>Temps moyen</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${EvidenceUtils.formatConfidence(stats.averageConfidence)}</div>
|
||||
<div>Confiance moyenne</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Détail des Evidence</h2>
|
||||
${evidences.map(evidence => `
|
||||
<div class="evidence-item">
|
||||
<div class="evidence-header">
|
||||
<h3>${evidence.action_name || evidence.action_id}</h3>
|
||||
<span class="evidence-status ${evidence.success ? 'status-success' : 'status-error'}">
|
||||
${evidence.success ? 'SUCCÈS' : 'ERREUR'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p><strong>Date :</strong> ${EvidenceUtils.formatDate(evidence.captured_at)}</p>
|
||||
<p><strong>Temps d'exécution :</strong> ${EvidenceUtils.formatExecutionTime(evidence.execution_time_ms)}</p>
|
||||
${evidence.confidence_score ? `<p><strong>Confiance :</strong> ${EvidenceUtils.formatConfidence(evidence.confidence_score)}</p>` : ''}
|
||||
|
||||
${evidence.error ? `
|
||||
<div style="background: #ffebee; padding: 10px; border-radius: 4px; margin: 10px 0;">
|
||||
<strong>Erreur :</strong> ${evidence.error.message}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${options.includeScreenshots && evidence.screenshot_base64 ? `
|
||||
<img src="data:image/png;base64,${evidence.screenshot_base64}"
|
||||
alt="Screenshot Evidence" class="evidence-screenshot">
|
||||
` : ''}
|
||||
|
||||
${options.includeMetadata && evidence.metadata ? `
|
||||
<div class="evidence-metadata">
|
||||
<strong>Métadonnées :</strong>
|
||||
<pre>${JSON.stringify(evidence.metadata, null, 2)}</pre>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`).join('')}
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
// Utilisation de URL.createObjectURL pour la génération
|
||||
const blob = new Blob([html], { type: 'text/html' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
URL.revokeObjectURL(url); // Nettoyage immédiat pour les tests
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide la santé du service Evidence
|
||||
*/
|
||||
async healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/api/vwb/evidences/health`, {
|
||||
method: 'GET',
|
||||
timeout: 5000
|
||||
} as RequestInit);
|
||||
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.warn('Service Evidence non disponible:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Nettoie le cache
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Instance singleton du service
|
||||
export const evidenceService = new EvidenceService();
|
||||
|
||||
export default EvidenceService;
|
||||
@@ -0,0 +1,388 @@
|
||||
/**
|
||||
* Service de Capture d'Écran Réelle - Interface avec l'API Backend
|
||||
* Auteur : Dom, Alice, Kiro - 09 janvier 2026
|
||||
*
|
||||
* Ce service gère la capture d'écran réelle avec détection d'éléments UI
|
||||
* en utilisant le service RealScreenCaptureService du backend.
|
||||
*/
|
||||
|
||||
// Configuration du service
|
||||
const BACKEND_BASE_URL = 'http://localhost:5001/api';
|
||||
const REQUEST_TIMEOUT = 20000; // 20 secondes pour la capture avec détection
|
||||
|
||||
// Types pour les réponses API
|
||||
interface Monitor {
|
||||
id: number;
|
||||
width: number;
|
||||
height: number;
|
||||
top: number;
|
||||
left: number;
|
||||
}
|
||||
|
||||
interface UIElement {
|
||||
id: string;
|
||||
type: string;
|
||||
text: string;
|
||||
bbox: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
confidence: number;
|
||||
attributes: Record<string, any>;
|
||||
}
|
||||
|
||||
interface CaptureStatus {
|
||||
is_capturing: boolean;
|
||||
selected_monitor: number;
|
||||
monitors_count: number;
|
||||
capture_interval: number;
|
||||
elements_detected: number;
|
||||
has_screenshot: boolean;
|
||||
}
|
||||
|
||||
interface RealScreenCaptureResponse {
|
||||
success: boolean;
|
||||
screenshot?: string;
|
||||
elements?: UIElement[];
|
||||
monitors?: Monitor[];
|
||||
status?: CaptureStatus;
|
||||
timestamp?: string;
|
||||
method?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface CaptureControlResponse {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
status?: CaptureStatus;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface StatusResponse {
|
||||
success: boolean;
|
||||
status?: CaptureStatus;
|
||||
monitors?: Monitor[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service de capture d'écran réelle avec détection d'éléments UI
|
||||
*/
|
||||
class RealScreenCaptureService {
|
||||
private abortController: AbortController | null = null;
|
||||
|
||||
/**
|
||||
* Capturer l'écran avec détection d'éléments UI
|
||||
*
|
||||
* @param monitorId ID du moniteur à capturer (0 par défaut)
|
||||
* @param detectElements Détecter les éléments UI (true par défaut)
|
||||
* @returns Promise avec les données de capture ou null si erreur
|
||||
*/
|
||||
async captureWithElements(
|
||||
monitorId: number = 0,
|
||||
detectElements: boolean = true
|
||||
): Promise<RealScreenCaptureResponse | null> {
|
||||
try {
|
||||
// Annuler toute requête précédente
|
||||
this.cancelRequest();
|
||||
|
||||
// Créer un nouveau contrôleur d'abort
|
||||
this.abortController = new AbortController();
|
||||
|
||||
// Timeout pour la requête
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (this.abortController) {
|
||||
this.abortController.abort();
|
||||
}
|
||||
}, REQUEST_TIMEOUT);
|
||||
|
||||
console.log(`🔧 Capture d'écran réelle (moniteur ${monitorId}, détection: ${detectElements})...`);
|
||||
|
||||
const response = await fetch(`${BACKEND_BASE_URL}/real-demo/capture`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
monitor_id: monitorId,
|
||||
detect_elements: detectElements,
|
||||
}),
|
||||
signal: this.abortController.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error(`❌ Erreur HTTP ${response.status}:`, errorText);
|
||||
return {
|
||||
success: false,
|
||||
error: `Erreur HTTP ${response.status}: ${errorText}`,
|
||||
};
|
||||
}
|
||||
|
||||
const data: RealScreenCaptureResponse = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
console.log(`✅ Capture réelle réussie - ${data.elements?.length || 0} éléments détectés`);
|
||||
} else {
|
||||
console.error('❌ Capture réelle échouée:', data.error);
|
||||
}
|
||||
|
||||
return data;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur lors de la capture d\'écran réelle:', error);
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.name === 'AbortError') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Capture annulée (timeout)',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'Erreur inconnue lors de la capture réelle',
|
||||
};
|
||||
} finally {
|
||||
this.abortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Démarrer la capture en temps réel
|
||||
*
|
||||
* @param interval Intervalle entre les captures en secondes (1.0 par défaut)
|
||||
* @returns Promise avec le résultat de l'opération
|
||||
*/
|
||||
async startRealTimeCapture(interval: number = 1.0): Promise<CaptureControlResponse | null> {
|
||||
try {
|
||||
console.log(`🔧 Démarrage capture temps réel (intervalle: ${interval}s)...`);
|
||||
|
||||
const response = await fetch(`${BACKEND_BASE_URL}/real-demo/capture/start`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
interval,
|
||||
}),
|
||||
signal: AbortSignal.timeout(10000), // 10 secondes max
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error(`❌ Erreur HTTP ${response.status}:`, errorText);
|
||||
return {
|
||||
success: false,
|
||||
error: `Erreur HTTP ${response.status}: ${errorText}`,
|
||||
};
|
||||
}
|
||||
|
||||
const data: CaptureControlResponse = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
console.log('✅ Capture temps réel démarrée');
|
||||
} else {
|
||||
console.error('❌ Échec démarrage capture temps réel:', data.error);
|
||||
}
|
||||
|
||||
return data;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur lors du démarrage de la capture temps réel:', error);
|
||||
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'Erreur inconnue lors du démarrage',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Arrêter la capture en temps réel
|
||||
*
|
||||
* @returns Promise avec le résultat de l'opération
|
||||
*/
|
||||
async stopRealTimeCapture(): Promise<CaptureControlResponse | null> {
|
||||
try {
|
||||
console.log('🔧 Arrêt capture temps réel...');
|
||||
|
||||
const response = await fetch(`${BACKEND_BASE_URL}/real-demo/capture/stop`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
signal: AbortSignal.timeout(5000), // 5 secondes max
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error(`❌ Erreur HTTP ${response.status}:`, errorText);
|
||||
return {
|
||||
success: false,
|
||||
error: `Erreur HTTP ${response.status}: ${errorText}`,
|
||||
};
|
||||
}
|
||||
|
||||
const data: CaptureControlResponse = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
console.log('✅ Capture temps réel arrêtée');
|
||||
} else {
|
||||
console.error('❌ Échec arrêt capture temps réel:', data.error);
|
||||
}
|
||||
|
||||
return data;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur lors de l\'arrêt de la capture temps réel:', error);
|
||||
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'Erreur inconnue lors de l\'arrêt',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir le statut du service de capture réelle
|
||||
*
|
||||
* @returns Promise avec le statut du service
|
||||
*/
|
||||
async getStatus(): Promise<StatusResponse | null> {
|
||||
try {
|
||||
const response = await fetch(`${BACKEND_BASE_URL}/real-demo/capture/status`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
signal: AbortSignal.timeout(5000), // 5 secondes max
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error(`❌ Erreur HTTP ${response.status}:`, errorText);
|
||||
return {
|
||||
success: false,
|
||||
error: `Erreur HTTP ${response.status}: ${errorText}`,
|
||||
};
|
||||
}
|
||||
|
||||
const data: StatusResponse = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
console.log('✅ Statut obtenu:', data.status);
|
||||
} else {
|
||||
console.error('❌ Échec obtention statut:', data.error);
|
||||
}
|
||||
|
||||
return data;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur lors de l\'obtention du statut:', error);
|
||||
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'Erreur inconnue lors de l\'obtention du statut',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifier la disponibilité du service de capture réelle
|
||||
*
|
||||
* @returns Promise<boolean> true si le service est disponible
|
||||
*/
|
||||
async checkAvailability(): Promise<boolean> {
|
||||
try {
|
||||
const statusResponse = await this.getStatus();
|
||||
return statusResponse?.success === true;
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Service de capture réelle indisponible:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir la liste des moniteurs disponibles
|
||||
*
|
||||
* @returns Promise avec la liste des moniteurs ou null si erreur
|
||||
*/
|
||||
async getMonitors(): Promise<Monitor[] | null> {
|
||||
try {
|
||||
const statusResponse = await this.getStatus();
|
||||
|
||||
if (statusResponse?.success && statusResponse.monitors) {
|
||||
return statusResponse.monitors;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur lors de l\'obtention des moniteurs:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Annuler la requête en cours
|
||||
*/
|
||||
cancelRequest(): void {
|
||||
if (this.abortController) {
|
||||
this.abortController.abort();
|
||||
this.abortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Nettoyer les ressources
|
||||
*/
|
||||
cleanup(): void {
|
||||
this.cancelRequest();
|
||||
}
|
||||
}
|
||||
|
||||
// Instance singleton du service
|
||||
export const realScreenCaptureService = new RealScreenCaptureService();
|
||||
|
||||
// Export des types
|
||||
export type {
|
||||
RealScreenCaptureResponse,
|
||||
CaptureControlResponse,
|
||||
StatusResponse,
|
||||
Monitor,
|
||||
UIElement,
|
||||
CaptureStatus
|
||||
};
|
||||
export default RealScreenCaptureService;
|
||||
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* Service de Capture d'Écran - Interface avec l'API Backend Ultra Stable
|
||||
* Auteur : Dom, Alice, Kiro - 09 janvier 2026
|
||||
*
|
||||
* Ce service gère la capture d'écran et la création d'embeddings visuels
|
||||
* en utilisant l'Option A (MSS créé à chaque capture) pour une stabilité maximale.
|
||||
*/
|
||||
|
||||
import { BoundingBox, VisualSelection } from '../types';
|
||||
|
||||
// Configuration du service
|
||||
const BACKEND_BASE_URL = 'http://localhost:5001/api';
|
||||
const REQUEST_TIMEOUT = 15000; // 15 secondes pour la capture d'écran
|
||||
|
||||
// Types pour les réponses API
|
||||
interface ScreenCaptureResponse {
|
||||
success: boolean;
|
||||
screenshot?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
timestamp?: string;
|
||||
method?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface VisualEmbeddingResponse {
|
||||
success: boolean;
|
||||
embedding?: number[];
|
||||
embedding_id?: string;
|
||||
dimension?: number;
|
||||
reference_image?: string;
|
||||
bounding_box?: BoundingBox;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service de capture d'écran utilisant l'API Backend ultra stable
|
||||
*/
|
||||
class ScreenCaptureService {
|
||||
private abortController: AbortController | null = null;
|
||||
|
||||
/**
|
||||
* Capturer l'écran actuel via l'API Backend (Option A - ultra stable)
|
||||
*
|
||||
* @param format Format de l'image ('png' ou 'jpeg')
|
||||
* @param quality Qualité pour JPEG (1-100)
|
||||
* @returns Promise avec les données de capture ou null si erreur
|
||||
*/
|
||||
async captureScreen(
|
||||
format: 'png' | 'jpeg' = 'png',
|
||||
quality: number = 90
|
||||
): Promise<ScreenCaptureResponse | null> {
|
||||
try {
|
||||
// Annuler toute requête précédente
|
||||
this.cancelRequest();
|
||||
|
||||
// Créer un nouveau contrôleur d'abort
|
||||
this.abortController = new AbortController();
|
||||
|
||||
// Timeout pour la requête
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (this.abortController) {
|
||||
this.abortController.abort();
|
||||
}
|
||||
}, REQUEST_TIMEOUT);
|
||||
|
||||
console.log('🔧 Capture d\'écran via API Backend (Option A - ultra stable)...');
|
||||
|
||||
const response = await fetch(`${BACKEND_BASE_URL}/screen-capture/capture`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
format,
|
||||
quality,
|
||||
}),
|
||||
signal: this.abortController.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error(`❌ Erreur HTTP ${response.status}:`, errorText);
|
||||
return {
|
||||
success: false,
|
||||
error: `Erreur HTTP ${response.status}: ${errorText}`,
|
||||
};
|
||||
}
|
||||
|
||||
const data: ScreenCaptureResponse = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
console.log(`✅ Capture réussie - ${data.width}x${data.height} (${data.method})`);
|
||||
} else {
|
||||
console.error('❌ Capture échouée:', data.error);
|
||||
}
|
||||
|
||||
return data;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur lors de la capture d\'écran:', error);
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.name === 'AbortError') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Capture annulée (timeout)',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'Erreur inconnue lors de la capture',
|
||||
};
|
||||
} finally {
|
||||
this.abortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Créer un embedding visuel à partir d'une capture et d'une zone sélectionnée
|
||||
*
|
||||
* @param screenshot Image en base64
|
||||
* @param boundingBox Zone sélectionnée
|
||||
* @param stepId Identifiant de l'étape
|
||||
* @returns Promise avec les données d'embedding ou null si erreur
|
||||
*/
|
||||
async createVisualEmbedding(
|
||||
screenshot: string,
|
||||
boundingBox: BoundingBox,
|
||||
stepId: string
|
||||
): Promise<VisualEmbeddingResponse | null> {
|
||||
try {
|
||||
// Annuler toute requête précédente
|
||||
this.cancelRequest();
|
||||
|
||||
// Créer un nouveau contrôleur d'abort
|
||||
this.abortController = new AbortController();
|
||||
|
||||
// Timeout pour la requête
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (this.abortController) {
|
||||
this.abortController.abort();
|
||||
}
|
||||
}, REQUEST_TIMEOUT);
|
||||
|
||||
console.log('🎯 Création d\'embedding visuel via API Backend...');
|
||||
|
||||
const response = await fetch(`${BACKEND_BASE_URL}/visual-embedding`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
screenshot,
|
||||
boundingBox,
|
||||
stepId,
|
||||
}),
|
||||
signal: this.abortController.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error(`❌ Erreur HTTP ${response.status}:`, errorText);
|
||||
return {
|
||||
success: false,
|
||||
error: `Erreur HTTP ${response.status}: ${errorText}`,
|
||||
};
|
||||
}
|
||||
|
||||
const data: VisualEmbeddingResponse = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
console.log(`✅ Embedding créé - ID: ${data.embedding_id}, Dimension: ${data.dimension}`);
|
||||
} else {
|
||||
console.error('❌ Création d\'embedding échouée:', data.error);
|
||||
}
|
||||
|
||||
return data;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur lors de la création d\'embedding:', error);
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.name === 'AbortError') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Création d\'embedding annulée (timeout)',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'Erreur inconnue lors de la création d\'embedding',
|
||||
};
|
||||
} finally {
|
||||
this.abortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Capturer l'écran et créer une sélection visuelle complète
|
||||
*
|
||||
* @param boundingBox Zone sélectionnée sur la capture
|
||||
* @param stepId Identifiant de l'étape
|
||||
* @param description Description de la sélection
|
||||
* @returns Promise avec la sélection visuelle complète ou null si erreur
|
||||
*/
|
||||
async captureAndCreateSelection(
|
||||
boundingBox: BoundingBox,
|
||||
stepId: string,
|
||||
description?: string
|
||||
): Promise<VisualSelection | null> {
|
||||
try {
|
||||
// Étape 1: Capturer l'écran
|
||||
console.log('📷 Étape 1/2: Capture d\'écran...');
|
||||
const captureResult = await this.captureScreen('png', 90);
|
||||
|
||||
if (!captureResult || !captureResult.success || !captureResult.screenshot) {
|
||||
console.error('❌ Échec de la capture d\'écran');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Étape 2: Créer l'embedding visuel
|
||||
console.log('🎯 Étape 2/2: Création d\'embedding visuel...');
|
||||
const embeddingResult = await this.createVisualEmbedding(
|
||||
captureResult.screenshot,
|
||||
boundingBox,
|
||||
stepId
|
||||
);
|
||||
|
||||
if (!embeddingResult || !embeddingResult.success || !embeddingResult.embedding) {
|
||||
console.error('❌ Échec de la création d\'embedding');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Créer la sélection visuelle complète
|
||||
const visualSelection: VisualSelection = {
|
||||
id: `visual_${stepId}_${Date.now()}`,
|
||||
screenshot: captureResult.screenshot,
|
||||
boundingBox: embeddingResult.bounding_box || boundingBox,
|
||||
embedding: embeddingResult.embedding,
|
||||
description: description || `Élément sélectionné pour l'étape ${stepId}`,
|
||||
metadata: {
|
||||
embedding_id: embeddingResult.embedding_id,
|
||||
dimension: embeddingResult.dimension,
|
||||
reference_image: embeddingResult.reference_image,
|
||||
capture_method: captureResult.method,
|
||||
capture_timestamp: captureResult.timestamp,
|
||||
screen_resolution: {
|
||||
width: captureResult.width,
|
||||
height: captureResult.height,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
console.log('✅ Sélection visuelle créée avec succès');
|
||||
return visualSelection;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur lors de la création de sélection visuelle:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifier la disponibilité de l'API Backend
|
||||
*
|
||||
* @returns Promise<boolean> true si l'API est disponible
|
||||
*/
|
||||
async checkApiAvailability(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${BACKEND_BASE_URL}/health`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
signal: AbortSignal.timeout(5000), // 5 secondes max
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
console.log('✅ API Backend disponible:', data);
|
||||
return true;
|
||||
} else {
|
||||
console.warn('⚠️ API Backend indisponible - HTTP', response.status);
|
||||
return false;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.warn('⚠️ API Backend indisponible:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir les informations sur les capacités de capture
|
||||
*
|
||||
* @returns Promise avec les informations de capacité
|
||||
*/
|
||||
async getCapabilities(): Promise<{
|
||||
screen_capture: boolean;
|
||||
visual_embedding: boolean;
|
||||
methods: string[];
|
||||
} | null> {
|
||||
try {
|
||||
const response = await fetch(`${BACKEND_BASE_URL}/health`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
return {
|
||||
screen_capture: data.features?.screen_capture || false,
|
||||
visual_embedding: data.features?.visual_embedding || false,
|
||||
methods: data.methods || ['unknown'],
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Impossible d\'obtenir les capacités:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Annuler la requête en cours
|
||||
*/
|
||||
cancelRequest(): void {
|
||||
if (this.abortController) {
|
||||
this.abortController.abort();
|
||||
this.abortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Nettoyer les ressources
|
||||
*/
|
||||
cleanup(): void {
|
||||
this.cancelRequest();
|
||||
}
|
||||
}
|
||||
|
||||
// Instance singleton du service
|
||||
export const screenCaptureService = new ScreenCaptureService();
|
||||
|
||||
// Export des types
|
||||
export type { ScreenCaptureResponse, VisualEmbeddingResponse };
|
||||
export default ScreenCaptureService;
|
||||
@@ -7,14 +7,20 @@
|
||||
*/
|
||||
|
||||
import { catalogService } from './catalogService';
|
||||
import {
|
||||
Step,
|
||||
StepExecutionState,
|
||||
ExecutionResult,
|
||||
import {
|
||||
Step,
|
||||
StepExecutionState,
|
||||
ExecutionResult,
|
||||
ExecutionError,
|
||||
Evidence
|
||||
Evidence
|
||||
} from '../types';
|
||||
import { VWBCatalogAction } from '../types/catalog';
|
||||
import {
|
||||
enforceActionContract,
|
||||
ContractValidationError,
|
||||
getRequiredParams,
|
||||
isKnownActionType
|
||||
} from '../contracts';
|
||||
|
||||
interface VWBActionValidationResult {
|
||||
is_valid: boolean;
|
||||
@@ -110,12 +116,57 @@ export class VWBExecutionService {
|
||||
'verify_text_content',
|
||||
]);
|
||||
|
||||
/**
|
||||
* NORMALISATION CRITIQUE: Résout les incohérences entre step.type et step.data.stepType
|
||||
*
|
||||
* Cette fonction détecte et corrige le problème où le type d'action peut être différent
|
||||
* entre step.type (source principale) et step.data.stepType (doublon historique).
|
||||
*
|
||||
* @returns Le type d'action normalisé (VWB si possible, sinon le type disponible)
|
||||
*/
|
||||
public normalizeStepType(step: Step): string {
|
||||
const typeFromStep = step.type;
|
||||
const typeFromData = step.data?.stepType;
|
||||
const typeFromVwbAction = step.data?.vwbActionId;
|
||||
const typeFromActionId = step.action_id;
|
||||
|
||||
// Détection d'incohérence
|
||||
if (typeFromStep && typeFromData && typeFromStep !== typeFromData) {
|
||||
console.warn(
|
||||
`⚠️ [VWB NORMALISATION] Incohérence détectée pour étape ${step.id}:`,
|
||||
`\n step.type = "${typeFromStep}"`,
|
||||
`\n step.data.stepType = "${typeFromData}"`,
|
||||
`\n → Utilisation de la valeur VWB valide`
|
||||
);
|
||||
}
|
||||
|
||||
// Priorité: Type VWB valide > step.type > step.data.stepType > vwbActionId > action_id
|
||||
const candidates = [typeFromStep, typeFromData, typeFromVwbAction, typeFromActionId];
|
||||
|
||||
// Chercher d'abord un type VWB valide
|
||||
for (const candidate of candidates) {
|
||||
if (candidate && VWBExecutionService.VWB_ACTION_TYPES.has(candidate)) {
|
||||
if (candidate !== typeFromStep) {
|
||||
console.log(
|
||||
`🔧 [VWB NORMALISATION] Correction: "${typeFromStep}" → "${candidate}" pour étape ${step.id}`
|
||||
);
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
// Sinon, prendre le premier disponible
|
||||
const fallback = typeFromStep || typeFromData || typeFromVwbAction || typeFromActionId || 'unknown';
|
||||
console.log(`📋 [VWB NORMALISATION] Type résolu: "${fallback}" pour étape ${step.id}`);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifier si une étape est une action VWB
|
||||
*/
|
||||
public isVWBStep(step: Step): boolean {
|
||||
// Vérifier d'abord si le type est dans la liste des actions VWB connues
|
||||
const stepType = step.type || step.data?.stepType;
|
||||
// Utiliser la normalisation pour obtenir le vrai type
|
||||
const stepType = this.normalizeStepType(step);
|
||||
if (stepType && VWBExecutionService.VWB_ACTION_TYPES.has(stepType)) {
|
||||
return true;
|
||||
}
|
||||
@@ -137,8 +188,8 @@ export class VWBExecutionService {
|
||||
throw new Error(`L'étape ${step.id} n'est pas une action VWB`);
|
||||
}
|
||||
|
||||
// Extraire le type d'action depuis plusieurs sources possibles
|
||||
const actionId = step.type || step.data?.stepType || step.data?.vwbActionId || step.action_id || "unknown";
|
||||
// UTILISER LA NORMALISATION pour obtenir le type correct
|
||||
const actionId = this.normalizeStepType(step);
|
||||
const parameters = step.data?.parameters || {};
|
||||
|
||||
try {
|
||||
@@ -217,23 +268,55 @@ export class VWBExecutionService {
|
||||
}
|
||||
}
|
||||
|
||||
// Extraire le type d'action depuis plusieurs sources possibles
|
||||
const actionId = step.type || step.data?.stepType || step.data?.vwbActionId || step.action_id || "unknown";
|
||||
// UTILISER LA NORMALISATION pour obtenir le type correct
|
||||
const actionId = this.normalizeStepType(step);
|
||||
const parameters = this.prepareParameters(step);
|
||||
|
||||
console.log(`🎯 [VWB] Exécution étape ${step.id}: type normalisé = "${actionId}"`);
|
||||
console.log(` step.type = "${step.type}", step.data?.stepType = "${step.data?.stepType}"`);
|
||||
console.log(` Paramètres: ${Object.keys(parameters).join(', ')}`);
|
||||
|
||||
// === VALIDATION CONTRAT STRICT ===
|
||||
// BLOQUE l'exécution si le contrat n'est pas respecté
|
||||
try {
|
||||
enforceActionContract(actionId, parameters);
|
||||
} catch (contractError) {
|
||||
if (contractError instanceof ContractValidationError) {
|
||||
console.error(`🚫 [CONTRAT VIOLÉ] Action '${actionId}' bloquée!`);
|
||||
console.error(` Paramètres requis: ${getRequiredParams(actionId).join(', ')}`);
|
||||
console.error(` Paramètres fournis: ${Object.keys(parameters).join(', ')}`);
|
||||
throw new Error(
|
||||
`Contrat violé pour '${actionId}': ${contractError.violations.map(v => v.message).join('; ')}`
|
||||
);
|
||||
}
|
||||
throw contractError;
|
||||
}
|
||||
|
||||
// Exécuter l'action avec retry
|
||||
let lastError: Error | null = null;
|
||||
for (let attempt = 1; attempt <= retryAttempts; attempt++) {
|
||||
try {
|
||||
const result = await this.executeActionWithTimeout(
|
||||
actionId,
|
||||
parameters,
|
||||
actionId,
|
||||
parameters,
|
||||
timeout,
|
||||
this.currentExecution.signal
|
||||
);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// IMPORTANT: Vérifier si le résultat indique une erreur (ex: ancre non trouvée)
|
||||
// Le backend retourne status='error' ou should_stop=true quand l'action échoue
|
||||
const isError = result?.status === 'error' ||
|
||||
result?.should_stop === true ||
|
||||
result?.success === false;
|
||||
|
||||
if (isError) {
|
||||
console.log('🛑 [VWB] Erreur détectée dans le résultat:', result);
|
||||
const errorMessage = result?.error?.message || result?.error || result?.message || 'Erreur retournée par le backend';
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
// Traiter les Evidence si disponibles
|
||||
const evidence = generateEvidence ? await this.processEvidence(result, startTime) : undefined;
|
||||
|
||||
@@ -274,7 +357,7 @@ export class VWBExecutionService {
|
||||
return {
|
||||
success: false,
|
||||
stepId: step.id,
|
||||
actionId: step.type || step.data?.stepType || step.data?.vwbActionId || step.action_id || "unknown",
|
||||
actionId: this.normalizeStepType(step),
|
||||
duration,
|
||||
error: executionError
|
||||
};
|
||||
@@ -315,10 +398,40 @@ export class VWBExecutionService {
|
||||
|
||||
/**
|
||||
* Préparer les paramètres pour l'exécution
|
||||
* Gère les différents formats de données possibles (workflows sauvegardés vs créés en direct)
|
||||
*/
|
||||
private prepareParameters(step: Step): Record<string, any> {
|
||||
// Récupérer les paramètres depuis plusieurs sources possibles
|
||||
let parameters = { ...step.data?.parameters || {} };
|
||||
|
||||
// Si visual_anchor est vide mais qu'il y a un target avec des données visuelles, l'utiliser
|
||||
if (!parameters.visual_anchor && step.data?.visualSelection) {
|
||||
parameters.visual_anchor = step.data.visualSelection;
|
||||
}
|
||||
|
||||
// Si target contient des données visuelles, fusionner avec visual_anchor
|
||||
if (parameters.target && !parameters.visual_anchor) {
|
||||
parameters.visual_anchor = parameters.target;
|
||||
}
|
||||
|
||||
// S'assurer que visual_anchor a les bonnes propriétés pour les actions de clic
|
||||
if (parameters.visual_anchor) {
|
||||
const anchor = parameters.visual_anchor;
|
||||
|
||||
// Normaliser le nom de la propriété de l'image
|
||||
if (!anchor.reference_image_base64 && anchor.screenshot) {
|
||||
anchor.reference_image_base64 = anchor.screenshot;
|
||||
}
|
||||
if (!anchor.reference_image_base64 && anchor.image) {
|
||||
anchor.reference_image_base64 = anchor.image;
|
||||
}
|
||||
|
||||
// Normaliser bounding_box
|
||||
if (!anchor.bounding_box && anchor.boundingBox) {
|
||||
anchor.bounding_box = anchor.boundingBox;
|
||||
}
|
||||
}
|
||||
|
||||
// Résoudre les variables si contexte disponible
|
||||
if (this.executionContext?.variables) {
|
||||
parameters = this.resolveVariables(parameters, this.executionContext.variables);
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Service de Stockage Local des Workflows
|
||||
* Auteur : Dom, Alice, Kiro - 13 janvier 2026
|
||||
*
|
||||
* Permet de sauvegarder et charger des workflows en localStorage
|
||||
* pour fonctionner sans backend.
|
||||
*/
|
||||
|
||||
import { Workflow, Step, WorkflowConnection, Variable } from '../types';
|
||||
|
||||
export interface StoredWorkflow {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
steps: Step[];
|
||||
connections: WorkflowConnection[];
|
||||
variables: Variable[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
interface WorkflowStorageState {
|
||||
workflows: StoredWorkflow[];
|
||||
lastUpdated: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'vwb_workflows';
|
||||
const STORAGE_VERSION = '1.0.0';
|
||||
|
||||
/**
|
||||
* Service de gestion du stockage local des workflows
|
||||
*/
|
||||
class WorkflowStorageService {
|
||||
private state: WorkflowStorageState;
|
||||
|
||||
constructor() {
|
||||
this.state = this.loadFromStorage();
|
||||
console.log('💾 [WorkflowStorage] Service initialisé avec', this.state.workflows.length, 'workflows');
|
||||
}
|
||||
|
||||
/**
|
||||
* Recharger les données depuis localStorage
|
||||
*/
|
||||
public reload(): void {
|
||||
this.state = this.loadFromStorage();
|
||||
console.log('🔄 [WorkflowStorage] Rechargé:', this.state.workflows.length, 'workflows');
|
||||
}
|
||||
|
||||
/**
|
||||
* Charger depuis localStorage
|
||||
*/
|
||||
private loadFromStorage(): WorkflowStorageState {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored) as WorkflowStorageState;
|
||||
if (parsed.version === STORAGE_VERSION) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Erreur lors du chargement des workflows:', error);
|
||||
}
|
||||
|
||||
return {
|
||||
workflows: [],
|
||||
lastUpdated: new Date().toISOString(),
|
||||
version: STORAGE_VERSION,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sauvegarder dans localStorage
|
||||
*/
|
||||
private saveToStorage(): void {
|
||||
try {
|
||||
this.state.lastUpdated = new Date().toISOString();
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.state));
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la sauvegarde des workflows:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir tous les workflows
|
||||
*/
|
||||
getAllWorkflows(): StoredWorkflow[] {
|
||||
return [...this.state.workflows];
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir un workflow par ID
|
||||
*/
|
||||
getWorkflowById(id: string): StoredWorkflow | null {
|
||||
return this.state.workflows.find((w) => w.id === id) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sauvegarder un workflow
|
||||
*/
|
||||
saveWorkflow(workflow: Workflow): StoredWorkflow {
|
||||
const now = new Date().toISOString();
|
||||
const existingIndex = this.state.workflows.findIndex((w) => w.id === workflow.id);
|
||||
|
||||
const storedWorkflow: StoredWorkflow = {
|
||||
id: workflow.id || `workflow_${Date.now()}`,
|
||||
name: workflow.name,
|
||||
description: workflow.description,
|
||||
steps: workflow.steps || [],
|
||||
connections: workflow.connections || [],
|
||||
variables: workflow.variables || [],
|
||||
createdAt: existingIndex >= 0 ? this.state.workflows[existingIndex].createdAt : now,
|
||||
updatedAt: now,
|
||||
version: existingIndex >= 0 ? this.state.workflows[existingIndex].version + 1 : 1,
|
||||
};
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
this.state.workflows[existingIndex] = storedWorkflow;
|
||||
} else {
|
||||
this.state.workflows.unshift(storedWorkflow);
|
||||
}
|
||||
|
||||
this.saveToStorage();
|
||||
console.log('💾 [WorkflowStorage] Workflow sauvegardé:', storedWorkflow.name);
|
||||
return storedWorkflow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprimer un workflow
|
||||
*/
|
||||
deleteWorkflow(id: string): boolean {
|
||||
const index = this.state.workflows.findIndex((w) => w.id === id);
|
||||
if (index === -1) return false;
|
||||
|
||||
this.state.workflows.splice(index, 1);
|
||||
this.saveToStorage();
|
||||
console.log('🗑️ [WorkflowStorage] Workflow supprimé:', id);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertir en format Workflow pour l'application
|
||||
*/
|
||||
toWorkflow(stored: StoredWorkflow): Workflow {
|
||||
return {
|
||||
id: stored.id,
|
||||
name: stored.name,
|
||||
description: stored.description,
|
||||
steps: stored.steps,
|
||||
connections: stored.connections,
|
||||
variables: stored.variables,
|
||||
createdAt: new Date(stored.createdAt),
|
||||
updatedAt: new Date(stored.updatedAt),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton
|
||||
export const workflowStorageService = new WorkflowStorageService();
|
||||
export default workflowStorageService;
|
||||
5
visual_workflow_builder/frontend/src/setupTests.ts
Normal file
5
visual_workflow_builder/frontend/src/setupTests.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||
// allows you to do things like:
|
||||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom';
|
||||
31
visual_workflow_builder/frontend/src/store/index.ts
Normal file
31
visual_workflow_builder/frontend/src/store/index.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Store Redux pour la gestion d'état globale
|
||||
* Auteur : Dom, Alice, Kiro - 08 janvier 2026
|
||||
*
|
||||
* Configuration du store Redux avec Redux Toolkit pour la gestion
|
||||
* centralisée de l'état de l'application.
|
||||
*/
|
||||
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import workflowReducer from './slices/workflowSlice';
|
||||
import uiReducer from './slices/uiSlice';
|
||||
|
||||
// Configuration du store Redux
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
workflow: workflowReducer,
|
||||
ui: uiReducer,
|
||||
},
|
||||
middleware: (getDefaultMiddleware) =>
|
||||
getDefaultMiddleware({
|
||||
serializableCheck: {
|
||||
// Ignorer ces chemins pour les actions non-sérialisables
|
||||
ignoredActions: ['persist/PERSIST', 'persist/REHYDRATE'],
|
||||
},
|
||||
}),
|
||||
devTools: process.env.NODE_ENV !== 'production',
|
||||
});
|
||||
|
||||
// Types pour TypeScript
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
308
visual_workflow_builder/frontend/src/store/slices/uiSlice.ts
Normal file
308
visual_workflow_builder/frontend/src/store/slices/uiSlice.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* Slice Redux pour la gestion de l'état de l'interface utilisateur
|
||||
* Auteur : Dom, Alice, Kiro - 08 janvier 2026
|
||||
*
|
||||
* Gestion de l'état de l'interface : panneaux ouverts, thème, notifications, etc.
|
||||
*/
|
||||
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
// Types pour l'état de l'interface
|
||||
interface UIState {
|
||||
// Panneaux et tiroirs
|
||||
isPropertiesPanelOpen: boolean;
|
||||
isVariableManagerOpen: boolean;
|
||||
isDocumentationDrawerOpen: boolean;
|
||||
activeDocumentationTab: number;
|
||||
|
||||
// Palette
|
||||
paletteSearchTerm: string;
|
||||
expandedCategories: string[];
|
||||
|
||||
// Canvas
|
||||
canvasZoom: number;
|
||||
canvasPosition: { x: number; y: number };
|
||||
showMinimap: boolean;
|
||||
showGrid: boolean;
|
||||
|
||||
// Notifications
|
||||
notifications: Notification[];
|
||||
|
||||
// Thème et préférences
|
||||
theme: 'light' | 'dark';
|
||||
language: 'fr' | 'en';
|
||||
|
||||
// États de chargement
|
||||
isLoading: boolean;
|
||||
loadingMessage?: string;
|
||||
|
||||
// Erreurs globales
|
||||
globalError?: string;
|
||||
}
|
||||
|
||||
interface Notification {
|
||||
id: string;
|
||||
type: 'success' | 'error' | 'warning' | 'info';
|
||||
title: string;
|
||||
message: string;
|
||||
timestamp: string;
|
||||
autoHide?: boolean;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
// État initial
|
||||
const initialState: UIState = {
|
||||
// Panneaux
|
||||
isPropertiesPanelOpen: true,
|
||||
isVariableManagerOpen: true,
|
||||
isDocumentationDrawerOpen: false,
|
||||
activeDocumentationTab: 0,
|
||||
|
||||
// Palette
|
||||
paletteSearchTerm: '',
|
||||
expandedCategories: ['actions-web'],
|
||||
|
||||
// Canvas
|
||||
canvasZoom: 1,
|
||||
canvasPosition: { x: 0, y: 0 },
|
||||
showMinimap: true,
|
||||
showGrid: true,
|
||||
|
||||
// Notifications
|
||||
notifications: [],
|
||||
|
||||
// Thème
|
||||
theme: 'light',
|
||||
language: 'fr',
|
||||
|
||||
// États
|
||||
isLoading: false,
|
||||
};
|
||||
|
||||
// Slice Redux pour l'interface utilisateur
|
||||
const uiSlice = createSlice({
|
||||
name: 'ui',
|
||||
initialState,
|
||||
reducers: {
|
||||
// Gestion des panneaux
|
||||
togglePropertiesPanel: (state) => {
|
||||
state.isPropertiesPanelOpen = !state.isPropertiesPanelOpen;
|
||||
},
|
||||
|
||||
setPropertiesPanelOpen: (state, action: PayloadAction<boolean>) => {
|
||||
state.isPropertiesPanelOpen = action.payload;
|
||||
},
|
||||
|
||||
toggleVariableManager: (state) => {
|
||||
state.isVariableManagerOpen = !state.isVariableManagerOpen;
|
||||
},
|
||||
|
||||
setVariableManagerOpen: (state, action: PayloadAction<boolean>) => {
|
||||
state.isVariableManagerOpen = action.payload;
|
||||
},
|
||||
|
||||
toggleDocumentationDrawer: (state) => {
|
||||
state.isDocumentationDrawerOpen = !state.isDocumentationDrawerOpen;
|
||||
},
|
||||
|
||||
setDocumentationDrawerOpen: (state, action: PayloadAction<boolean>) => {
|
||||
state.isDocumentationDrawerOpen = action.payload;
|
||||
},
|
||||
|
||||
setActiveDocumentationTab: (state, action: PayloadAction<number>) => {
|
||||
state.activeDocumentationTab = action.payload;
|
||||
state.isDocumentationDrawerOpen = true;
|
||||
},
|
||||
|
||||
// Gestion de la palette
|
||||
setPaletteSearchTerm: (state, action: PayloadAction<string>) => {
|
||||
state.paletteSearchTerm = action.payload;
|
||||
},
|
||||
|
||||
toggleCategory: (state, action: PayloadAction<string>) => {
|
||||
const categoryId = action.payload;
|
||||
const index = state.expandedCategories.indexOf(categoryId);
|
||||
if (index === -1) {
|
||||
state.expandedCategories.push(categoryId);
|
||||
} else {
|
||||
state.expandedCategories.splice(index, 1);
|
||||
}
|
||||
},
|
||||
|
||||
setExpandedCategories: (state, action: PayloadAction<string[]>) => {
|
||||
state.expandedCategories = action.payload;
|
||||
},
|
||||
|
||||
// Gestion du canvas
|
||||
setCanvasZoom: (state, action: PayloadAction<number>) => {
|
||||
state.canvasZoom = Math.max(0.1, Math.min(3, action.payload));
|
||||
},
|
||||
|
||||
setCanvasPosition: (state, action: PayloadAction<{ x: number; y: number }>) => {
|
||||
state.canvasPosition = action.payload;
|
||||
},
|
||||
|
||||
toggleMinimap: (state) => {
|
||||
state.showMinimap = !state.showMinimap;
|
||||
},
|
||||
|
||||
setShowMinimap: (state, action: PayloadAction<boolean>) => {
|
||||
state.showMinimap = action.payload;
|
||||
},
|
||||
|
||||
toggleGrid: (state) => {
|
||||
state.showGrid = !state.showGrid;
|
||||
},
|
||||
|
||||
setShowGrid: (state, action: PayloadAction<boolean>) => {
|
||||
state.showGrid = action.payload;
|
||||
},
|
||||
|
||||
// Gestion des notifications
|
||||
addNotification: (state, action: PayloadAction<Omit<Notification, 'id' | 'timestamp'>>) => {
|
||||
const notification: Notification = {
|
||||
...action.payload,
|
||||
id: `notification_${Date.now()}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
state.notifications.push(notification);
|
||||
},
|
||||
|
||||
removeNotification: (state, action: PayloadAction<string>) => {
|
||||
const id = action.payload;
|
||||
state.notifications = state.notifications.filter(n => n.id !== id);
|
||||
},
|
||||
|
||||
clearNotifications: (state) => {
|
||||
state.notifications = [];
|
||||
},
|
||||
|
||||
// Notifications prédéfinies
|
||||
showSuccessNotification: (state, action: PayloadAction<{ title: string; message: string }>) => {
|
||||
const { title, message } = action.payload;
|
||||
const notification: Notification = {
|
||||
id: `notification_${Date.now()}`,
|
||||
type: 'success',
|
||||
title,
|
||||
message,
|
||||
timestamp: new Date().toISOString(),
|
||||
autoHide: true,
|
||||
duration: 5000,
|
||||
};
|
||||
state.notifications.push(notification);
|
||||
},
|
||||
|
||||
showErrorNotification: (state, action: PayloadAction<{ title: string; message: string }>) => {
|
||||
const { title, message } = action.payload;
|
||||
const notification: Notification = {
|
||||
id: `notification_${Date.now()}`,
|
||||
type: 'error',
|
||||
title,
|
||||
message,
|
||||
timestamp: new Date().toISOString(),
|
||||
autoHide: false,
|
||||
};
|
||||
state.notifications.push(notification);
|
||||
},
|
||||
|
||||
showWarningNotification: (state, action: PayloadAction<{ title: string; message: string }>) => {
|
||||
const { title, message } = action.payload;
|
||||
const notification: Notification = {
|
||||
id: `notification_${Date.now()}`,
|
||||
type: 'warning',
|
||||
title,
|
||||
message,
|
||||
timestamp: new Date().toISOString(),
|
||||
autoHide: true,
|
||||
duration: 7000,
|
||||
};
|
||||
state.notifications.push(notification);
|
||||
},
|
||||
|
||||
// Gestion du thème et des préférences
|
||||
setTheme: (state, action: PayloadAction<'light' | 'dark'>) => {
|
||||
state.theme = action.payload;
|
||||
},
|
||||
|
||||
toggleTheme: (state) => {
|
||||
state.theme = state.theme === 'light' ? 'dark' : 'light';
|
||||
},
|
||||
|
||||
setLanguage: (state, action: PayloadAction<'fr' | 'en'>) => {
|
||||
state.language = action.payload;
|
||||
},
|
||||
|
||||
// Gestion des états de chargement
|
||||
setLoading: (state, action: PayloadAction<{ isLoading: boolean; message?: string }>) => {
|
||||
const { isLoading, message } = action.payload;
|
||||
state.isLoading = isLoading;
|
||||
state.loadingMessage = message;
|
||||
},
|
||||
|
||||
startLoading: (state, action: PayloadAction<string>) => {
|
||||
state.isLoading = true;
|
||||
state.loadingMessage = action.payload;
|
||||
},
|
||||
|
||||
stopLoading: (state) => {
|
||||
state.isLoading = false;
|
||||
state.loadingMessage = undefined;
|
||||
},
|
||||
|
||||
// Gestion des erreurs globales
|
||||
setGlobalError: (state, action: PayloadAction<string | undefined>) => {
|
||||
state.globalError = action.payload;
|
||||
},
|
||||
|
||||
clearGlobalError: (state) => {
|
||||
state.globalError = undefined;
|
||||
},
|
||||
|
||||
// Réinitialisation de l'interface
|
||||
resetUI: (state) => {
|
||||
return {
|
||||
...initialState,
|
||||
theme: state.theme,
|
||||
language: state.language,
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Export des actions
|
||||
export const {
|
||||
togglePropertiesPanel,
|
||||
setPropertiesPanelOpen,
|
||||
toggleVariableManager,
|
||||
setVariableManagerOpen,
|
||||
toggleDocumentationDrawer,
|
||||
setDocumentationDrawerOpen,
|
||||
setActiveDocumentationTab,
|
||||
setPaletteSearchTerm,
|
||||
toggleCategory,
|
||||
setExpandedCategories,
|
||||
setCanvasZoom,
|
||||
setCanvasPosition,
|
||||
toggleMinimap,
|
||||
setShowMinimap,
|
||||
toggleGrid,
|
||||
setShowGrid,
|
||||
addNotification,
|
||||
removeNotification,
|
||||
clearNotifications,
|
||||
showSuccessNotification,
|
||||
showErrorNotification,
|
||||
showWarningNotification,
|
||||
setTheme,
|
||||
toggleTheme,
|
||||
setLanguage,
|
||||
setLoading,
|
||||
startLoading,
|
||||
stopLoading,
|
||||
setGlobalError,
|
||||
clearGlobalError,
|
||||
resetUI,
|
||||
} = uiSlice.actions;
|
||||
|
||||
// Export du reducer
|
||||
export default uiSlice.reducer;
|
||||
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* Slice Redux pour la gestion des workflows
|
||||
* Auteur : Dom, Alice, Kiro - 08 janvier 2026
|
||||
*
|
||||
* Gestion de l'état des workflows, étapes, connexions et variables
|
||||
* avec actions et reducers Redux Toolkit.
|
||||
*/
|
||||
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
// Types pour l'état du workflow
|
||||
interface WorkflowState {
|
||||
currentWorkflow: Workflow | null;
|
||||
workflows: Workflow[];
|
||||
selectedStepId: string | null;
|
||||
executionState: ExecutionState;
|
||||
validationErrors: ValidationError[];
|
||||
}
|
||||
|
||||
interface Workflow {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
steps: Step[];
|
||||
connections: Connection[];
|
||||
variables: Variable[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface Step {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
position: { x: number; y: number };
|
||||
parameters: Record<string, any>;
|
||||
validationErrors: ValidationError[];
|
||||
}
|
||||
|
||||
interface Connection {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
interface Variable {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'text' | 'number' | 'boolean' | 'list';
|
||||
defaultValue?: any;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface ExecutionState {
|
||||
status: 'idle' | 'running' | 'completed' | 'error';
|
||||
currentStepId?: string;
|
||||
progress: number;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
results: Record<string, any>;
|
||||
}
|
||||
|
||||
interface ValidationError {
|
||||
id: string;
|
||||
stepId?: string;
|
||||
parameter?: string;
|
||||
message: string;
|
||||
severity: 'error' | 'warning';
|
||||
}
|
||||
|
||||
// État initial
|
||||
const initialState: WorkflowState = {
|
||||
currentWorkflow: null,
|
||||
workflows: [],
|
||||
selectedStepId: null,
|
||||
executionState: {
|
||||
status: 'idle',
|
||||
progress: 0,
|
||||
results: {},
|
||||
},
|
||||
validationErrors: [],
|
||||
};
|
||||
|
||||
// Slice Redux pour les workflows
|
||||
const workflowSlice = createSlice({
|
||||
name: 'workflow',
|
||||
initialState,
|
||||
reducers: {
|
||||
// Gestion des workflows
|
||||
setCurrentWorkflow: (state, action: PayloadAction<Workflow>) => {
|
||||
state.currentWorkflow = action.payload;
|
||||
},
|
||||
|
||||
createWorkflow: (state, action: PayloadAction<Omit<Workflow, 'id' | 'createdAt' | 'updatedAt'>>) => {
|
||||
const newWorkflow: Workflow = {
|
||||
...action.payload,
|
||||
id: `workflow_${Date.now()}`,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
state.workflows.push(newWorkflow);
|
||||
state.currentWorkflow = newWorkflow;
|
||||
},
|
||||
|
||||
updateWorkflow: (state, action: PayloadAction<{ id: string; updates: Partial<Workflow> }>) => {
|
||||
const { id, updates } = action.payload;
|
||||
const workflowIndex = state.workflows.findIndex(w => w.id === id);
|
||||
if (workflowIndex !== -1) {
|
||||
state.workflows[workflowIndex] = {
|
||||
...state.workflows[workflowIndex],
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
if (state.currentWorkflow?.id === id) {
|
||||
state.currentWorkflow = state.workflows[workflowIndex];
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
deleteWorkflow: (state, action: PayloadAction<string>) => {
|
||||
const id = action.payload;
|
||||
state.workflows = state.workflows.filter(w => w.id !== id);
|
||||
if (state.currentWorkflow?.id === id) {
|
||||
state.currentWorkflow = null;
|
||||
}
|
||||
},
|
||||
|
||||
// Gestion des étapes
|
||||
addStep: (state, action: PayloadAction<Omit<Step, 'id'>>) => {
|
||||
if (!state.currentWorkflow) return;
|
||||
|
||||
const newStep: Step = {
|
||||
...action.payload,
|
||||
id: `step_${Date.now()}`,
|
||||
validationErrors: [],
|
||||
};
|
||||
|
||||
state.currentWorkflow.steps.push(newStep);
|
||||
state.currentWorkflow.updatedAt = new Date().toISOString();
|
||||
},
|
||||
|
||||
updateStep: (state, action: PayloadAction<{ id: string; updates: Partial<Step> }>) => {
|
||||
if (!state.currentWorkflow) return;
|
||||
|
||||
const { id, updates } = action.payload;
|
||||
const stepIndex = state.currentWorkflow.steps.findIndex(s => s.id === id);
|
||||
if (stepIndex !== -1) {
|
||||
state.currentWorkflow.steps[stepIndex] = {
|
||||
...state.currentWorkflow.steps[stepIndex],
|
||||
...updates,
|
||||
};
|
||||
state.currentWorkflow.updatedAt = new Date().toISOString();
|
||||
}
|
||||
},
|
||||
|
||||
deleteStep: (state, action: PayloadAction<string>) => {
|
||||
if (!state.currentWorkflow) return;
|
||||
|
||||
const stepId = action.payload;
|
||||
state.currentWorkflow.steps = state.currentWorkflow.steps.filter(s => s.id !== stepId);
|
||||
state.currentWorkflow.connections = state.currentWorkflow.connections.filter(
|
||||
c => c.source !== stepId && c.target !== stepId
|
||||
);
|
||||
|
||||
if (state.selectedStepId === stepId) {
|
||||
state.selectedStepId = null;
|
||||
}
|
||||
|
||||
state.currentWorkflow.updatedAt = new Date().toISOString();
|
||||
},
|
||||
|
||||
moveStep: (state, action: PayloadAction<{ id: string; position: { x: number; y: number } }>) => {
|
||||
if (!state.currentWorkflow) return;
|
||||
|
||||
const { id, position } = action.payload;
|
||||
const stepIndex = state.currentWorkflow.steps.findIndex(s => s.id === id);
|
||||
if (stepIndex !== -1) {
|
||||
state.currentWorkflow.steps[stepIndex].position = position;
|
||||
state.currentWorkflow.updatedAt = new Date().toISOString();
|
||||
}
|
||||
},
|
||||
|
||||
// Gestion des connexions
|
||||
addConnection: (state, action: PayloadAction<Omit<Connection, 'id'>>) => {
|
||||
if (!state.currentWorkflow) return;
|
||||
|
||||
const newConnection: Connection = {
|
||||
...action.payload,
|
||||
id: `connection_${Date.now()}`,
|
||||
};
|
||||
|
||||
state.currentWorkflow.connections.push(newConnection);
|
||||
state.currentWorkflow.updatedAt = new Date().toISOString();
|
||||
},
|
||||
|
||||
deleteConnection: (state, action: PayloadAction<string>) => {
|
||||
if (!state.currentWorkflow) return;
|
||||
|
||||
const connectionId = action.payload;
|
||||
state.currentWorkflow.connections = state.currentWorkflow.connections.filter(
|
||||
c => c.id !== connectionId
|
||||
);
|
||||
state.currentWorkflow.updatedAt = new Date().toISOString();
|
||||
},
|
||||
|
||||
// Gestion des variables
|
||||
addVariable: (state, action: PayloadAction<Omit<Variable, 'id'>>) => {
|
||||
if (!state.currentWorkflow) return;
|
||||
|
||||
const newVariable: Variable = {
|
||||
...action.payload,
|
||||
id: `var_${Date.now()}`,
|
||||
};
|
||||
|
||||
state.currentWorkflow.variables.push(newVariable);
|
||||
state.currentWorkflow.updatedAt = new Date().toISOString();
|
||||
},
|
||||
|
||||
updateVariable: (state, action: PayloadAction<{ id: string; updates: Partial<Variable> }>) => {
|
||||
if (!state.currentWorkflow) return;
|
||||
|
||||
const { id, updates } = action.payload;
|
||||
const variableIndex = state.currentWorkflow.variables.findIndex(v => v.id === id);
|
||||
if (variableIndex !== -1) {
|
||||
state.currentWorkflow.variables[variableIndex] = {
|
||||
...state.currentWorkflow.variables[variableIndex],
|
||||
...updates,
|
||||
};
|
||||
state.currentWorkflow.updatedAt = new Date().toISOString();
|
||||
}
|
||||
},
|
||||
|
||||
deleteVariable: (state, action: PayloadAction<string>) => {
|
||||
if (!state.currentWorkflow) return;
|
||||
|
||||
const variableId = action.payload;
|
||||
state.currentWorkflow.variables = state.currentWorkflow.variables.filter(
|
||||
v => v.id !== variableId
|
||||
);
|
||||
state.currentWorkflow.updatedAt = new Date().toISOString();
|
||||
},
|
||||
|
||||
// Sélection d'étapes
|
||||
selectStep: (state, action: PayloadAction<string | null>) => {
|
||||
state.selectedStepId = action.payload;
|
||||
},
|
||||
|
||||
// Gestion de l'exécution
|
||||
startExecution: (state) => {
|
||||
state.executionState = {
|
||||
status: 'running',
|
||||
progress: 0,
|
||||
startTime: new Date().toISOString(),
|
||||
results: {},
|
||||
};
|
||||
},
|
||||
|
||||
updateExecutionProgress: (state, action: PayloadAction<{ progress: number; currentStepId?: string }>) => {
|
||||
const { progress, currentStepId } = action.payload;
|
||||
state.executionState.progress = progress;
|
||||
if (currentStepId) {
|
||||
state.executionState.currentStepId = currentStepId;
|
||||
}
|
||||
},
|
||||
|
||||
completeExecution: (state, action: PayloadAction<{ results: Record<string, any> }>) => {
|
||||
state.executionState = {
|
||||
...state.executionState,
|
||||
status: 'completed',
|
||||
progress: 100,
|
||||
endTime: new Date().toISOString(),
|
||||
results: action.payload.results,
|
||||
};
|
||||
},
|
||||
|
||||
failExecution: (state, action: PayloadAction<{ error: string }>) => {
|
||||
state.executionState = {
|
||||
...state.executionState,
|
||||
status: 'error',
|
||||
endTime: new Date().toISOString(),
|
||||
};
|
||||
},
|
||||
|
||||
// Gestion des erreurs de validation
|
||||
setValidationErrors: (state, action: PayloadAction<ValidationError[]>) => {
|
||||
state.validationErrors = action.payload;
|
||||
|
||||
// Mettre à jour les erreurs sur les étapes
|
||||
if (state.currentWorkflow) {
|
||||
state.currentWorkflow.steps.forEach(step => {
|
||||
step.validationErrors = state.validationErrors.filter(
|
||||
error => error.stepId === step.id
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
clearValidationErrors: (state) => {
|
||||
state.validationErrors = [];
|
||||
if (state.currentWorkflow) {
|
||||
state.currentWorkflow.steps.forEach(step => {
|
||||
step.validationErrors = [];
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Export des actions
|
||||
export const {
|
||||
setCurrentWorkflow,
|
||||
createWorkflow,
|
||||
updateWorkflow,
|
||||
deleteWorkflow,
|
||||
addStep,
|
||||
updateStep,
|
||||
deleteStep,
|
||||
moveStep,
|
||||
addConnection,
|
||||
deleteConnection,
|
||||
addVariable,
|
||||
updateVariable,
|
||||
deleteVariable,
|
||||
selectStep,
|
||||
startExecution,
|
||||
updateExecutionProgress,
|
||||
completeExecution,
|
||||
failExecution,
|
||||
setValidationErrors,
|
||||
clearValidationErrors,
|
||||
} = workflowSlice.actions;
|
||||
|
||||
// Export du reducer
|
||||
export default workflowSlice.reducer;
|
||||
460
visual_workflow_builder/frontend/src/types/catalog.ts
Normal file
460
visual_workflow_builder/frontend/src/types/catalog.ts
Normal file
@@ -0,0 +1,460 @@
|
||||
/**
|
||||
* Types TypeScript pour le Catalogue d'Actions VisionOnly VWB
|
||||
* Auteur : Dom, Alice, Kiro - 09 janvier 2026
|
||||
*
|
||||
* Définitions TypeScript spécifiques au catalogue d'actions VisionOnly
|
||||
* pour le Visual Workflow Builder, complétant les types existants.
|
||||
*/
|
||||
|
||||
// Types de base pour les actions du catalogue
|
||||
export interface VWBCatalogAction {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: VWBActionCategory;
|
||||
icon: string;
|
||||
parameters: Record<string, VWBActionParameter>;
|
||||
examples: VWBActionExample[];
|
||||
documentation?: string;
|
||||
metadata?: VWBActionMetadata;
|
||||
}
|
||||
|
||||
export interface VWBActionParameter {
|
||||
type: VWBParameterType;
|
||||
required: boolean;
|
||||
default?: any;
|
||||
description: string;
|
||||
options?: string[];
|
||||
min?: number;
|
||||
max?: number;
|
||||
validation?: VWBParameterValidation;
|
||||
}
|
||||
|
||||
export interface VWBActionExample {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, any>;
|
||||
expectedResult?: string;
|
||||
difficulty?: 'facile' | 'moyen' | 'avancé';
|
||||
}
|
||||
|
||||
export interface VWBActionMetadata {
|
||||
version: string;
|
||||
author: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
tags: string[];
|
||||
complexity: 'simple' | 'intermediate' | 'advanced';
|
||||
estimatedDuration: number; // en millisecondes
|
||||
}
|
||||
|
||||
export interface VWBParameterValidation {
|
||||
pattern?: string; // Regex pour validation
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
customValidator?: string; // Nom du validateur personnalisé
|
||||
}
|
||||
|
||||
// Types pour les catégories d'actions (100% visuel)
|
||||
export type VWBActionCategory = 'vision_ui' | 'control' | 'data' | 'intelligence' | 'database' | 'validation';
|
||||
|
||||
export interface VWBActionCategoryInfo {
|
||||
id: VWBActionCategory;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
actionCount: number;
|
||||
isEnabled: boolean;
|
||||
}
|
||||
|
||||
// Types pour les paramètres d'actions
|
||||
export type VWBParameterType =
|
||||
| 'string'
|
||||
| 'number'
|
||||
| 'boolean'
|
||||
| 'VWBVisualAnchor'
|
||||
| 'array'
|
||||
| 'object'
|
||||
| 'file'
|
||||
| 'color'
|
||||
| 'date';
|
||||
|
||||
// Types pour les ancres visuelles VWB
|
||||
export interface VWBVisualAnchor {
|
||||
anchor_id: string;
|
||||
anchor_type: VWBVisualAnchorType;
|
||||
// URLs serveur (prioritaire - nouveau système de stockage)
|
||||
reference_image_url?: string;
|
||||
thumbnail_url?: string;
|
||||
// LEGACY: base64 (compatibilité anciens workflows)
|
||||
reference_image_base64?: string;
|
||||
bounding_box: VWBBoundingBox;
|
||||
embedding?: number[];
|
||||
confidence_threshold: number;
|
||||
description?: string;
|
||||
metadata: VWBVisualAnchorMetadata;
|
||||
}
|
||||
|
||||
export interface VWBVisualAnchorMetadata {
|
||||
embedding_id?: string;
|
||||
dimension?: number;
|
||||
capture_method: string;
|
||||
capture_timestamp: string;
|
||||
screen_resolution: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
similarity_stats?: {
|
||||
mean_similarity: number;
|
||||
std_similarity: number;
|
||||
min_similarity: number;
|
||||
max_similarity: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface VWBBoundingBox {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export type VWBVisualAnchorType = 'button' | 'input' | 'text' | 'image' | 'icon' | 'generic';
|
||||
|
||||
// Types pour l'exécution d'actions
|
||||
export interface VWBActionExecutionRequest {
|
||||
type: string;
|
||||
action_id?: string;
|
||||
step_id?: string;
|
||||
parameters: Record<string, any>;
|
||||
workflow_id?: string;
|
||||
user_id?: string;
|
||||
execution_context?: VWBExecutionContext;
|
||||
}
|
||||
|
||||
export interface VWBExecutionContext {
|
||||
workflow_name?: string;
|
||||
step_name?: string;
|
||||
previous_step_result?: any;
|
||||
variables?: Record<string, any>;
|
||||
environment?: 'development' | 'staging' | 'production';
|
||||
}
|
||||
|
||||
export interface VWBActionExecutionResult {
|
||||
action_id: string;
|
||||
step_id: string;
|
||||
status: VWBExecutionStatus;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
execution_time_ms: number;
|
||||
output_data: Record<string, any>;
|
||||
evidence_list: VWBActionEvidence[];
|
||||
error: VWBActionError | null;
|
||||
retry_count: number;
|
||||
workflow_id?: string;
|
||||
user_id?: string;
|
||||
session_id?: string;
|
||||
performance_metrics?: VWBPerformanceMetrics;
|
||||
}
|
||||
|
||||
export type VWBExecutionStatus = 'success' | 'error' | 'timeout' | 'cancelled' | 'retry';
|
||||
|
||||
export interface VWBPerformanceMetrics {
|
||||
screen_capture_time_ms: number;
|
||||
detection_time_ms: number;
|
||||
action_execution_time_ms: number;
|
||||
evidence_generation_time_ms: number;
|
||||
memory_usage_mb: number;
|
||||
}
|
||||
|
||||
// Types pour les preuves d'exécution (Evidence)
|
||||
export interface VWBActionEvidence {
|
||||
evidence_id: string;
|
||||
evidence_type: VWBEvidenceType;
|
||||
timestamp: string;
|
||||
data: Record<string, any>;
|
||||
screenshot_base64?: string;
|
||||
metadata: VWBEvidenceMetadata;
|
||||
}
|
||||
|
||||
export type VWBEvidenceType =
|
||||
| 'screenshot'
|
||||
| 'click_coordinates'
|
||||
| 'text_input'
|
||||
| 'wait_result'
|
||||
| 'detection_result'
|
||||
| 'validation_result';
|
||||
|
||||
export interface VWBEvidenceMetadata {
|
||||
screen_resolution?: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
click_coordinates?: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
text_content?: string;
|
||||
detection_confidence?: number;
|
||||
processing_time_ms?: number;
|
||||
}
|
||||
|
||||
// Types pour les erreurs d'actions
|
||||
export interface VWBActionError {
|
||||
error_id: string;
|
||||
error_type: VWBErrorType;
|
||||
severity: VWBErrorSeverity;
|
||||
message: string;
|
||||
details?: Record<string, any>;
|
||||
suggestions?: string[];
|
||||
timestamp: string;
|
||||
context?: VWBErrorContext;
|
||||
}
|
||||
|
||||
export type VWBErrorType =
|
||||
| 'parameter_validation'
|
||||
| 'visual_anchor_not_found'
|
||||
| 'action_execution_failed'
|
||||
| 'timeout_exceeded'
|
||||
| 'screen_capture_failed'
|
||||
| 'network_error'
|
||||
| 'system_error';
|
||||
|
||||
export type VWBErrorSeverity = 'low' | 'medium' | 'high' | 'critical';
|
||||
|
||||
export interface VWBErrorContext {
|
||||
action_type: string;
|
||||
step_id?: string;
|
||||
workflow_id?: string;
|
||||
parameters?: Record<string, any>;
|
||||
system_info?: {
|
||||
os: string;
|
||||
screen_resolution: string;
|
||||
browser?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Types pour la validation d'actions
|
||||
export interface VWBActionValidationRequest {
|
||||
type: string;
|
||||
parameters: Record<string, any>;
|
||||
context?: VWBValidationContext;
|
||||
}
|
||||
|
||||
export interface VWBValidationContext {
|
||||
workflow_id?: string;
|
||||
step_id?: string;
|
||||
previous_steps?: string[];
|
||||
available_variables?: string[];
|
||||
}
|
||||
|
||||
export interface VWBActionValidationResult {
|
||||
is_valid: boolean;
|
||||
errors: VWBValidationError[];
|
||||
warnings: VWBValidationWarning[];
|
||||
suggestions: VWBValidationSuggestion[];
|
||||
estimated_success_rate?: number;
|
||||
}
|
||||
|
||||
export interface VWBValidationError {
|
||||
parameter: string;
|
||||
message: string;
|
||||
code: string;
|
||||
severity: 'error' | 'warning';
|
||||
}
|
||||
|
||||
export interface VWBValidationWarning {
|
||||
parameter: string;
|
||||
message: string;
|
||||
impact: 'low' | 'medium' | 'high';
|
||||
recommendation?: string;
|
||||
}
|
||||
|
||||
export interface VWBValidationSuggestion {
|
||||
type: 'parameter_optimization' | 'alternative_approach' | 'best_practice';
|
||||
message: string;
|
||||
details?: string;
|
||||
priority: 'low' | 'medium' | 'high';
|
||||
}
|
||||
|
||||
// Types pour la santé du service catalogue
|
||||
export interface VWBCatalogHealth {
|
||||
status: VWBServiceStatus;
|
||||
services: VWBServiceInfo;
|
||||
timestamp: string;
|
||||
version: string;
|
||||
uptime_ms?: number;
|
||||
}
|
||||
|
||||
export type VWBServiceStatus = 'healthy' | 'degraded' | 'offline' | 'maintenance';
|
||||
|
||||
export interface VWBServiceInfo {
|
||||
screenCapturer: boolean;
|
||||
actions: number;
|
||||
screenCapturerMethod: string;
|
||||
database?: boolean;
|
||||
cache?: boolean;
|
||||
external_apis?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
// Types pour les statistiques du catalogue
|
||||
export interface VWBCatalogStats {
|
||||
total_actions: number;
|
||||
actions_by_category: Record<VWBActionCategory, number>;
|
||||
most_used_actions: Array<{
|
||||
action_id: string;
|
||||
usage_count: number;
|
||||
success_rate: number;
|
||||
}>;
|
||||
average_execution_time_ms: number;
|
||||
success_rate_overall: number;
|
||||
last_updated: string;
|
||||
}
|
||||
|
||||
// Types pour la recherche d'actions
|
||||
export interface VWBActionSearchRequest {
|
||||
query: string;
|
||||
category?: VWBActionCategory;
|
||||
filters?: VWBActionSearchFilters;
|
||||
sort?: VWBActionSearchSort;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface VWBActionSearchFilters {
|
||||
complexity?: 'simple' | 'intermediate' | 'advanced';
|
||||
has_examples?: boolean;
|
||||
requires_visual_anchor?: boolean;
|
||||
estimated_duration_max?: number;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface VWBActionSearchSort {
|
||||
field: 'name' | 'category' | 'complexity' | 'usage_count' | 'success_rate';
|
||||
direction: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface VWBActionSearchResult {
|
||||
actions: VWBCatalogAction[];
|
||||
total: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
has_more: boolean;
|
||||
search_time_ms: number;
|
||||
}
|
||||
|
||||
// Types pour l'historique d'exécution
|
||||
export interface VWBExecutionHistory {
|
||||
execution_id: string;
|
||||
action_id: string;
|
||||
action_name: string;
|
||||
workflow_id?: string;
|
||||
workflow_name?: string;
|
||||
user_id?: string;
|
||||
status: VWBExecutionStatus;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
execution_time_ms: number;
|
||||
error_message?: string;
|
||||
evidence_count: number;
|
||||
}
|
||||
|
||||
export interface VWBExecutionHistoryRequest {
|
||||
action_id?: string;
|
||||
workflow_id?: string;
|
||||
user_id?: string;
|
||||
status?: VWBExecutionStatus;
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
// Types pour les templates d'actions
|
||||
export interface VWBActionTemplate {
|
||||
template_id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: VWBActionCategory;
|
||||
base_action_type: string;
|
||||
parameter_template: Record<string, any>;
|
||||
use_cases: string[];
|
||||
difficulty: 'facile' | 'moyen' | 'avancé';
|
||||
estimated_setup_time_minutes: number;
|
||||
}
|
||||
|
||||
// Types pour l'export/import de configurations
|
||||
export interface VWBCatalogExport {
|
||||
version: string;
|
||||
export_date: string;
|
||||
actions: VWBCatalogAction[];
|
||||
templates: VWBActionTemplate[];
|
||||
settings: VWBCatalogSettings;
|
||||
}
|
||||
|
||||
export interface VWBCatalogSettings {
|
||||
default_confidence_threshold: number;
|
||||
default_timeout_ms: number;
|
||||
retry_attempts: number;
|
||||
cache_duration_minutes: number;
|
||||
evidence_retention_days: number;
|
||||
performance_monitoring_enabled: boolean;
|
||||
}
|
||||
|
||||
// Types pour les notifications et événements
|
||||
export interface VWBCatalogEvent {
|
||||
event_id: string;
|
||||
event_type: VWBEventType;
|
||||
timestamp: string;
|
||||
data: Record<string, any>;
|
||||
source: string;
|
||||
severity: 'info' | 'warning' | 'error';
|
||||
}
|
||||
|
||||
export type VWBEventType =
|
||||
| 'action_executed'
|
||||
| 'action_failed'
|
||||
| 'service_status_changed'
|
||||
| 'new_action_registered'
|
||||
| 'configuration_updated'
|
||||
| 'performance_threshold_exceeded';
|
||||
|
||||
// Types pour l'intégration avec le VWB existant
|
||||
export interface VWBCatalogIntegration {
|
||||
palette_extension: VWBPaletteExtension;
|
||||
properties_panel_extension: VWBPropertiesPanelExtension;
|
||||
execution_engine_extension: VWBExecutionEngineExtension;
|
||||
}
|
||||
|
||||
export interface VWBPaletteExtension {
|
||||
categories: VWBActionCategoryInfo[];
|
||||
search_enabled: boolean;
|
||||
drag_drop_enabled: boolean;
|
||||
preview_enabled: boolean;
|
||||
}
|
||||
|
||||
export interface VWBPropertiesPanelExtension {
|
||||
parameter_editors: Record<VWBParameterType, string>; // Nom du composant éditeur
|
||||
validation_enabled: boolean;
|
||||
real_time_preview: boolean;
|
||||
help_integration: boolean;
|
||||
}
|
||||
|
||||
export interface VWBExecutionEngineExtension {
|
||||
evidence_viewer_enabled: boolean;
|
||||
real_time_feedback: boolean;
|
||||
retry_configuration: VWBRetryConfiguration;
|
||||
performance_monitoring: boolean;
|
||||
}
|
||||
|
||||
export interface VWBRetryConfiguration {
|
||||
max_attempts: number;
|
||||
backoff_strategy: 'linear' | 'exponential' | 'fixed';
|
||||
base_delay_ms: number;
|
||||
max_delay_ms: number;
|
||||
retry_on_errors: VWBErrorType[];
|
||||
}
|
||||
|
||||
// Les types sont déjà exportés via les déclarations d'interface ci-dessus
|
||||
// Pas besoin de re-export pour éviter les conflits
|
||||
291
visual_workflow_builder/frontend/src/types/evidence.ts
Normal file
291
visual_workflow_builder/frontend/src/types/evidence.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* Types TypeScript pour le système Evidence VWB
|
||||
* Auteur : Dom, Alice, Kiro - 11 janvier 2026
|
||||
*/
|
||||
|
||||
export interface VWBActionError {
|
||||
contract: string;
|
||||
version: string;
|
||||
code: string;
|
||||
message: string;
|
||||
retryable: boolean;
|
||||
details: Record<string, any>;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface VWBEvidence {
|
||||
// Propriétés principales VWB
|
||||
contract: string;
|
||||
version: string;
|
||||
id: string;
|
||||
action_id: string;
|
||||
action_name?: string;
|
||||
captured_at: string;
|
||||
screenshot_base64: string;
|
||||
bbox?: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
click_point?: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
confidence_score?: number;
|
||||
execution_time_ms: number;
|
||||
success: boolean;
|
||||
error?: VWBActionError;
|
||||
metadata?: Record<string, any>;
|
||||
|
||||
// Propriétés de compatibilité pour l'intégration existante
|
||||
timestamp?: string;
|
||||
type?: string;
|
||||
data?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface EvidenceViewerProps {
|
||||
evidences: VWBEvidence[];
|
||||
selectedEvidenceId?: string;
|
||||
onEvidenceSelect: (evidenceId: string) => void;
|
||||
onExport?: (evidences: VWBEvidence[]) => void;
|
||||
showFilters?: boolean;
|
||||
maxHeight?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface EvidenceListProps {
|
||||
evidences: VWBEvidence[];
|
||||
selectedId?: string;
|
||||
selectedEvidence?: VWBEvidence | null;
|
||||
onSelect: (evidence: VWBEvidence) => void;
|
||||
filters: EvidenceFilters;
|
||||
onFiltersChange: (filters: EvidenceFilters) => void;
|
||||
compact?: boolean;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
onSortChange?: (sortBy: string, sortOrder: 'asc' | 'desc') => void;
|
||||
viewMode?: 'list' | 'grid';
|
||||
showFilters?: boolean;
|
||||
}
|
||||
|
||||
export interface EvidenceDetailProps {
|
||||
evidence: VWBEvidence;
|
||||
onClose?: () => void;
|
||||
showMetadata?: boolean;
|
||||
}
|
||||
|
||||
export interface EvidenceFilters {
|
||||
actionTypes: string[];
|
||||
status: 'all' | 'success' | 'error';
|
||||
dateRange: {
|
||||
start?: Date;
|
||||
end?: Date;
|
||||
};
|
||||
searchText: string;
|
||||
confidenceRange: {
|
||||
min: number;
|
||||
max: number;
|
||||
};
|
||||
executionTimeRange: {
|
||||
min: number;
|
||||
max: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface EvidenceStats {
|
||||
total: number;
|
||||
successful: number;
|
||||
failed: number;
|
||||
averageExecutionTime: number;
|
||||
averageConfidence: number;
|
||||
actionTypeDistribution: Record<string, number>;
|
||||
timelineData: Array<{
|
||||
date: string;
|
||||
count: number;
|
||||
successRate: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface EvidenceExportOptions {
|
||||
format: 'json' | 'csv' | 'pdf' | 'html';
|
||||
includeScreenshots: boolean;
|
||||
includeMetadata?: boolean;
|
||||
includeErrors?: boolean;
|
||||
dateRange?: {
|
||||
start: Date;
|
||||
end: Date;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ScreenshotViewerProps {
|
||||
screenshot: string;
|
||||
bbox?: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
clickPoint?: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
annotations?: AnnotationData[];
|
||||
onAnnotationAdd?: (annotation: AnnotationData) => void;
|
||||
showControls?: boolean;
|
||||
zoom?: number;
|
||||
onZoomChange?: (zoom: number) => void;
|
||||
maxWidth?: number;
|
||||
maxHeight?: number;
|
||||
}
|
||||
|
||||
export interface AnnotationData {
|
||||
id: string;
|
||||
type: 'highlight' | 'arrow' | 'text' | 'bbox' | 'click';
|
||||
position: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
coordinates?: {
|
||||
x: number;
|
||||
y: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
color?: string;
|
||||
opacity?: number;
|
||||
label?: string;
|
||||
data: any;
|
||||
}
|
||||
|
||||
export interface EvidenceFiltersProps {
|
||||
filters: EvidenceFilters;
|
||||
onFiltersChange: (filters: EvidenceFilters) => void;
|
||||
onClearFilters: () => void;
|
||||
}
|
||||
|
||||
export class EvidenceUtils {
|
||||
static formatTimestamp(timestamp: string): string {
|
||||
return new Date(timestamp).toLocaleString('fr-FR');
|
||||
}
|
||||
|
||||
static formatDate(timestamp: string): string {
|
||||
return new Date(timestamp).toLocaleDateString('fr-FR');
|
||||
}
|
||||
|
||||
static formatExecutionTime(timeMs: number): string {
|
||||
if (timeMs < 1000) {
|
||||
return `${timeMs}ms`;
|
||||
}
|
||||
return `${(timeMs / 1000).toFixed(2)}s`;
|
||||
}
|
||||
|
||||
static formatConfidence(confidence?: number): string {
|
||||
if (confidence === undefined) return 'N/A';
|
||||
return `${Math.round(confidence * 100)}%`;
|
||||
}
|
||||
|
||||
static filterEvidences(evidences: VWBEvidence[], filters: EvidenceFilters): VWBEvidence[] {
|
||||
let filtered = [...evidences];
|
||||
|
||||
if (filters.actionTypes.length > 0) {
|
||||
filtered = filtered.filter(e => filters.actionTypes.includes(e.action_id));
|
||||
}
|
||||
|
||||
if (filters.status !== 'all') {
|
||||
filtered = filtered.filter(e =>
|
||||
filters.status === 'success' ? e.success : !e.success
|
||||
);
|
||||
}
|
||||
|
||||
if (filters.searchText) {
|
||||
const search = filters.searchText.toLowerCase();
|
||||
filtered = filtered.filter(e =>
|
||||
e.action_id.toLowerCase().includes(search) ||
|
||||
(e.action_name && e.action_name.toLowerCase().includes(search))
|
||||
);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
static sortEvidences(
|
||||
evidences: VWBEvidence[],
|
||||
sortBy: string,
|
||||
sortOrder: 'asc' | 'desc'
|
||||
): VWBEvidence[] {
|
||||
return [...evidences].sort((a, b) => {
|
||||
let comparison = 0;
|
||||
|
||||
switch (sortBy) {
|
||||
case 'timestamp':
|
||||
comparison = new Date(a.captured_at).getTime() - new Date(b.captured_at).getTime();
|
||||
break;
|
||||
case 'execution_time':
|
||||
comparison = a.execution_time_ms - b.execution_time_ms;
|
||||
break;
|
||||
case 'confidence':
|
||||
comparison = (a.confidence_score || 0) - (b.confidence_score || 0);
|
||||
break;
|
||||
default:
|
||||
comparison = a.action_id.localeCompare(b.action_id);
|
||||
}
|
||||
|
||||
return sortOrder === 'desc' ? -comparison : comparison;
|
||||
});
|
||||
}
|
||||
|
||||
static calculateStats(evidences: VWBEvidence[]): EvidenceStats {
|
||||
const total = evidences.length;
|
||||
const successful = evidences.filter(e => e.success).length;
|
||||
const failed = total - successful;
|
||||
|
||||
const avgExecution = evidences.reduce((sum, e) => sum + e.execution_time_ms, 0) / total || 0;
|
||||
const avgConfidence = evidences.reduce((sum, e) => sum + (e.confidence_score || 0), 0) / total || 0;
|
||||
|
||||
const actionTypes: Record<string, number> = {};
|
||||
evidences.forEach(e => {
|
||||
actionTypes[e.action_id] = (actionTypes[e.action_id] || 0) + 1;
|
||||
});
|
||||
|
||||
// Générer les données de timeline (7 derniers jours)
|
||||
const timelineData = [];
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - i);
|
||||
const dateStr = date.toISOString().split('T')[0];
|
||||
|
||||
const dayEvidences = evidences.filter(e =>
|
||||
e.captured_at.startsWith(dateStr)
|
||||
);
|
||||
const count = dayEvidences.length;
|
||||
const successRate = count > 0 ? dayEvidences.filter(e => e.success).length / count : 0;
|
||||
|
||||
timelineData.push({
|
||||
date: dateStr,
|
||||
count,
|
||||
successRate
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
total,
|
||||
successful,
|
||||
failed,
|
||||
averageExecutionTime: avgExecution,
|
||||
averageConfidence: avgConfidence,
|
||||
actionTypeDistribution: actionTypes,
|
||||
timelineData
|
||||
};
|
||||
}
|
||||
|
||||
static exportEvidence(evidences: VWBEvidence[], options: EvidenceExportOptions): string {
|
||||
if (options.format === 'json') {
|
||||
return JSON.stringify(evidences, null, 2);
|
||||
}
|
||||
// Autres formats à implémenter
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export default VWBEvidence;
|
||||
298
visual_workflow_builder/frontend/src/types/index.ts
Normal file
298
visual_workflow_builder/frontend/src/types/index.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* Types partagés pour le Visual Workflow Builder V2
|
||||
* Auteur : Dom, Alice, Kiro - 08 janvier 2026
|
||||
*
|
||||
* Définitions TypeScript centralisées pour tous les composants.
|
||||
*/
|
||||
|
||||
// Types de base pour les workflows
|
||||
export interface Workflow {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
steps: Step[];
|
||||
connections: WorkflowConnection[];
|
||||
variables: Variable[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface Step {
|
||||
id: string;
|
||||
type: StepType;
|
||||
name: string;
|
||||
position: Position;
|
||||
data: StepData;
|
||||
action_id?: string;
|
||||
executionState?: StepExecutionState;
|
||||
validationErrors?: ValidationError[];
|
||||
}
|
||||
|
||||
export interface StepData {
|
||||
label: string;
|
||||
stepType: StepType;
|
||||
parameters: Record<string, any>;
|
||||
visualSelection?: VisualSelection;
|
||||
isSelected?: boolean;
|
||||
isVWBCatalogAction?: boolean;
|
||||
vwbActionId?: string;
|
||||
}
|
||||
|
||||
export interface WorkflowConnection {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
type?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface Position {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
// Types pour les variables
|
||||
export interface Variable {
|
||||
id: string;
|
||||
name: string;
|
||||
type: VariableType;
|
||||
defaultValue?: any;
|
||||
description?: string;
|
||||
value?: any;
|
||||
}
|
||||
|
||||
export type VariableType = 'text' | 'number' | 'boolean' | 'list';
|
||||
|
||||
export enum VariableTypeEnum {
|
||||
TEXT = 'text',
|
||||
NUMBER = 'number',
|
||||
BOOLEAN = 'boolean',
|
||||
LIST = 'list'
|
||||
}
|
||||
|
||||
// Types pour les étapes
|
||||
// Types génériques + Types VWB Catalogue (VisionOnly)
|
||||
export type StepType =
|
||||
// Types génériques (legacy)
|
||||
| 'click'
|
||||
| 'type'
|
||||
| 'wait'
|
||||
| 'condition'
|
||||
| 'extract'
|
||||
| 'scroll'
|
||||
| 'navigate'
|
||||
| 'screenshot'
|
||||
// Types VWB Catalogue (VisionOnly) - Actions avec ancres visuelles
|
||||
| 'click_anchor'
|
||||
| 'double_click_anchor'
|
||||
| 'right_click_anchor'
|
||||
| 'hover_anchor'
|
||||
| 'type_text'
|
||||
| 'type_secret'
|
||||
| 'focus_anchor'
|
||||
| 'drag_drop_anchor'
|
||||
| 'scroll_to_anchor'
|
||||
| 'keyboard_shortcut'
|
||||
| 'wait_for_anchor'
|
||||
| 'visual_condition'
|
||||
| 'loop_visual'
|
||||
| 'extract_text'
|
||||
| 'extract_table'
|
||||
| 'screenshot_evidence'
|
||||
| 'download_to_folder'
|
||||
| 'ai_analyze_text'
|
||||
| 'db_save_data'
|
||||
| 'db_read_data'
|
||||
| 'verify_element_exists'
|
||||
| 'verify_text_content'
|
||||
// Type générique pour compatibilité
|
||||
| string;
|
||||
|
||||
export enum StepExecutionState {
|
||||
IDLE = 'idle',
|
||||
RUNNING = 'running',
|
||||
SUCCESS = 'success',
|
||||
ERROR = 'error',
|
||||
SKIPPED = 'skipped',
|
||||
PAUSED = 'paused'
|
||||
}
|
||||
|
||||
// Types pour la validation
|
||||
export interface ValidationError {
|
||||
parameter: string;
|
||||
message: string;
|
||||
severity: 'error' | 'warning' | 'info';
|
||||
code?: string;
|
||||
suggestions?: string[];
|
||||
context?: Record<string, any>;
|
||||
}
|
||||
|
||||
// Types pour la sélection visuelle
|
||||
export interface VisualSelection {
|
||||
id: string;
|
||||
screenshot: string; // Base64 de l'image (vide si uses_server_storage)
|
||||
boundingBox: BoundingBox;
|
||||
embedding?: number[];
|
||||
description?: string;
|
||||
metadata?: {
|
||||
embedding_id?: string;
|
||||
dimension?: number;
|
||||
reference_image?: string;
|
||||
capture_method?: string;
|
||||
capture_timestamp?: string;
|
||||
has_embedding?: boolean;
|
||||
screen_resolution?: {
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
// Nouvelles propriétés pour le stockage serveur
|
||||
thumbnail_url?: string;
|
||||
reference_image_url?: string;
|
||||
uses_server_storage?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface BoundingBox {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
// Types pour l'exécution
|
||||
export interface ExecutionState {
|
||||
currentStep?: string;
|
||||
status: ExecutionStatus;
|
||||
startTime?: Date;
|
||||
endTime?: Date;
|
||||
errors?: ExecutionError[];
|
||||
}
|
||||
|
||||
export type ExecutionStatus = 'idle' | 'running' | 'completed' | 'error' | 'paused';
|
||||
|
||||
export interface ExecutionError {
|
||||
stepId: string;
|
||||
message: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export interface ExecutionResult {
|
||||
stepId: string;
|
||||
success: boolean;
|
||||
duration: number;
|
||||
error?: ExecutionError;
|
||||
evidence?: any[];
|
||||
}
|
||||
|
||||
// Re-export Evidence from evidence types
|
||||
export type { VWBEvidence as Evidence } from './evidence';
|
||||
|
||||
// Types pour les catégories de la palette
|
||||
export interface StepCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
steps: StepTemplate[];
|
||||
}
|
||||
|
||||
export interface StepTemplate {
|
||||
id: string;
|
||||
type: StepType;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
defaultParameters: Record<string, any>;
|
||||
requiredParameters: string[];
|
||||
}
|
||||
|
||||
// Types pour les propriétés des composants
|
||||
export interface CanvasProps {
|
||||
workflow?: Workflow;
|
||||
selectedStep?: Step | null;
|
||||
executionState?: ExecutionState;
|
||||
onStepSelect?: (step: Step | null) => void;
|
||||
onStepMove?: (stepId: string, position: Position) => void;
|
||||
onConnection?: (source: string, target: string) => void;
|
||||
onConnectionDelete?: (connectionId: string) => void;
|
||||
onStepAdd?: (step: Omit<Step, 'id'>) => void;
|
||||
onStepDelete?: (stepId: string) => void;
|
||||
}
|
||||
|
||||
export interface PaletteProps {
|
||||
categories: StepCategory[];
|
||||
searchTerm: string;
|
||||
onSearch: (term: string) => void;
|
||||
onStepDrag: (stepTemplate: StepTemplate) => void;
|
||||
}
|
||||
|
||||
export interface PropertiesPanelProps {
|
||||
selectedStep?: Step | null;
|
||||
variables: Variable[];
|
||||
onParameterChange: (stepId: string, parameter: string, value: any) => void;
|
||||
onVisualSelection: (stepId: string) => void;
|
||||
}
|
||||
|
||||
export interface VariableManagerProps {
|
||||
variables: Variable[];
|
||||
onVariableCreate: (variable: Omit<Variable, 'id'>) => void;
|
||||
onVariableUpdate: (id: string, updates: Partial<Variable>) => void;
|
||||
onVariableDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
export interface DocumentationTabProps {
|
||||
toolName: string;
|
||||
isActive: boolean;
|
||||
onActivate: () => void;
|
||||
}
|
||||
|
||||
// Types pour les nœuds ReactFlow
|
||||
export interface StepNodeData extends Record<string, unknown> {
|
||||
label: string;
|
||||
stepType: StepType;
|
||||
executionState: StepExecutionState;
|
||||
validationErrors: ValidationError[];
|
||||
isSelected: boolean;
|
||||
parameters: Record<string, any>;
|
||||
isVWBCatalogAction?: boolean;
|
||||
vwbActionId?: string;
|
||||
}
|
||||
|
||||
// Types pour l'API
|
||||
export interface ApiResponse<T = any> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface WorkflowApiData {
|
||||
id?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
steps: Step[];
|
||||
connections: WorkflowConnection[];
|
||||
variables: Variable[];
|
||||
// Format alternatif pour compatibilité backend
|
||||
nodes?: any[];
|
||||
edges?: any[];
|
||||
// Champ requis par le backend
|
||||
created_by?: string;
|
||||
}
|
||||
|
||||
// Types pour les événements
|
||||
export interface StepMoveEvent {
|
||||
stepId: string;
|
||||
position: Position;
|
||||
}
|
||||
|
||||
export interface ConnectionEvent {
|
||||
source: string;
|
||||
target: string;
|
||||
}
|
||||
|
||||
export interface ParameterChangeEvent {
|
||||
stepId: string;
|
||||
parameter: string;
|
||||
value: any;
|
||||
}
|
||||
252
visual_workflow_builder/frontend/src/utils/errorMessages.ts
Normal file
252
visual_workflow_builder/frontend/src/utils/errorMessages.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* Messages d'Erreur en Français Clair
|
||||
* Auteur : Dom, Alice, Kiro - 08 janvier 2026
|
||||
*
|
||||
* Ce module centralise tous les messages d'erreur et d'avertissement
|
||||
* en français clair et compréhensible pour les utilisateurs.
|
||||
*/
|
||||
|
||||
export interface ErrorMessage {
|
||||
title: string;
|
||||
description: string;
|
||||
solution: string;
|
||||
severity: 'critical' | 'high' | 'medium' | 'low';
|
||||
}
|
||||
|
||||
// Messages d'erreur pour les paramètres manquants
|
||||
export const missingParameterMessages: Record<string, ErrorMessage> = {
|
||||
target: {
|
||||
title: 'Élément cible non sélectionné',
|
||||
description: 'Cette étape a besoin de savoir sur quel élément de la page agir.',
|
||||
solution: 'Cliquez sur "Sélectionner un élément" et choisissez l\'élément cible sur la capture d\'écran.',
|
||||
severity: 'high'
|
||||
},
|
||||
text: {
|
||||
title: 'Texte à saisir manquant',
|
||||
description: 'Cette étape de saisie ne sait pas quel texte écrire.',
|
||||
solution: 'Saisissez le texte dans le champ "Texte à saisir". Vous pouvez utiliser des variables avec ${nom_variable}.',
|
||||
severity: 'high'
|
||||
},
|
||||
duration: {
|
||||
title: 'Durée d\'attente non définie',
|
||||
description: 'Cette étape d\'attente ne sait pas combien de temps attendre.',
|
||||
solution: 'Définissez une durée en secondes (par exemple : 2 pour 2 secondes, 0.5 pour 500 millisecondes).',
|
||||
severity: 'high'
|
||||
},
|
||||
condition: {
|
||||
title: 'Condition logique manquante',
|
||||
description: 'Cette étape conditionnelle ne sait pas quelle condition évaluer.',
|
||||
solution: 'Saisissez une expression logique comme "${age} >= 18" ou "${status} == \'actif\'".',
|
||||
severity: 'high'
|
||||
},
|
||||
url: {
|
||||
title: 'URL de destination manquante',
|
||||
description: 'Cette étape de navigation ne sait pas vers quelle page aller.',
|
||||
solution: 'Saisissez l\'URL complète (ex: https://example.com) ou utilisez une variable ${url}.',
|
||||
severity: 'high'
|
||||
},
|
||||
selector: {
|
||||
title: 'Sélecteur d\'élément manquant',
|
||||
description: 'Cette étape ne sait pas quel élément de la page cibler.',
|
||||
solution: 'Utilisez le sélecteur visuel pour choisir l\'élément ou saisissez un sélecteur CSS.',
|
||||
severity: 'high'
|
||||
},
|
||||
attribute: {
|
||||
title: 'Attribut à extraire non spécifié',
|
||||
description: 'Cette étape d\'extraction ne sait pas quelle information récupérer.',
|
||||
solution: 'Choisissez le type d\'information à extraire : texte, valeur, lien, etc.',
|
||||
severity: 'medium'
|
||||
}
|
||||
};
|
||||
|
||||
// Messages d'erreur pour les problèmes de workflow
|
||||
export const workflowErrorMessages: Record<string, ErrorMessage> = {
|
||||
cycle_detected: {
|
||||
title: 'Boucle infinie détectée',
|
||||
description: 'Le workflow contient une boucle qui empêcherait son exécution normale.',
|
||||
solution: 'Vérifiez les connexions entre les étapes et supprimez les références circulaires.',
|
||||
severity: 'critical'
|
||||
},
|
||||
disconnected_step: {
|
||||
title: 'Étape isolée',
|
||||
description: 'Cette étape n\'est connectée à aucune autre et ne sera pas exécutée.',
|
||||
solution: 'Connectez cette étape au flux principal ou supprimez-la si elle n\'est plus nécessaire.',
|
||||
severity: 'medium'
|
||||
},
|
||||
invalid_variable_reference: {
|
||||
title: 'Variable inexistante',
|
||||
description: 'Une variable utilisée dans les paramètres n\'existe pas.',
|
||||
solution: 'Créez la variable manquante ou corrigez le nom de la variable dans les paramètres.',
|
||||
severity: 'high'
|
||||
},
|
||||
execution_blocked: {
|
||||
title: 'Exécution impossible',
|
||||
description: 'Des erreurs critiques empêchent le lancement du workflow.',
|
||||
solution: 'Corrigez toutes les erreurs marquées en rouge avant de pouvoir exécuter le workflow.',
|
||||
severity: 'critical'
|
||||
},
|
||||
no_start_step: {
|
||||
title: 'Aucune étape de départ',
|
||||
description: 'Le workflow n\'a pas d\'étape de départ clairement identifiée.',
|
||||
solution: 'Assurez-vous qu\'au moins une étape n\'a pas de connexion d\'entrée pour servir de point de départ.',
|
||||
severity: 'high'
|
||||
},
|
||||
multiple_start_steps: {
|
||||
title: 'Plusieurs étapes de départ',
|
||||
description: 'Le workflow a plusieurs étapes sans connexion d\'entrée.',
|
||||
solution: 'Connectez les étapes pour n\'avoir qu\'un seul point de départ, ou utilisez une étape de condition.',
|
||||
severity: 'medium'
|
||||
}
|
||||
};
|
||||
|
||||
// Messages d'erreur pour les variables
|
||||
export const variableErrorMessages: Record<string, ErrorMessage> = {
|
||||
invalid_name: {
|
||||
title: 'Nom de variable invalide',
|
||||
description: 'Le nom de la variable contient des caractères non autorisés.',
|
||||
solution: 'Utilisez uniquement des lettres, chiffres et underscores. Commencez par une lettre.',
|
||||
severity: 'high'
|
||||
},
|
||||
duplicate_name: {
|
||||
title: 'Nom de variable déjà utilisé',
|
||||
description: 'Une variable avec ce nom existe déjà.',
|
||||
solution: 'Choisissez un nom différent ou modifiez la variable existante.',
|
||||
severity: 'high'
|
||||
},
|
||||
empty_value: {
|
||||
title: 'Valeur de variable vide',
|
||||
description: 'La variable n\'a pas de valeur par défaut.',
|
||||
solution: 'Définissez une valeur par défaut ou laissez la variable être remplie dynamiquement.',
|
||||
severity: 'low'
|
||||
},
|
||||
invalid_type: {
|
||||
title: 'Type de variable incorrect',
|
||||
description: 'La valeur ne correspond pas au type déclaré de la variable.',
|
||||
solution: 'Vérifiez que la valeur correspond au type (texte, nombre, booléen) de la variable.',
|
||||
severity: 'medium'
|
||||
}
|
||||
};
|
||||
|
||||
// Messages d'erreur pour l'exécution
|
||||
export const executionErrorMessages: Record<string, ErrorMessage> = {
|
||||
step_failed: {
|
||||
title: 'Échec de l\'étape',
|
||||
description: 'L\'étape n\'a pas pu s\'exécuter correctement.',
|
||||
solution: 'Vérifiez les paramètres de l\'étape et l\'état de la page web cible.',
|
||||
severity: 'high'
|
||||
},
|
||||
element_not_found: {
|
||||
title: 'Élément introuvable',
|
||||
description: 'L\'élément ciblé n\'a pas été trouvé sur la page.',
|
||||
solution: 'Vérifiez que la page est correctement chargée et que l\'élément existe toujours.',
|
||||
severity: 'high'
|
||||
},
|
||||
timeout_exceeded: {
|
||||
title: 'Délai d\'attente dépassé',
|
||||
description: 'L\'étape a pris trop de temps à s\'exécuter.',
|
||||
solution: 'Augmentez le délai d\'attente ou vérifiez que la page répond correctement.',
|
||||
severity: 'medium'
|
||||
},
|
||||
network_error: {
|
||||
title: 'Erreur de réseau',
|
||||
description: 'Impossible de communiquer avec la page web ou le serveur.',
|
||||
solution: 'Vérifiez votre connexion internet et que le site web est accessible.',
|
||||
severity: 'high'
|
||||
},
|
||||
permission_denied: {
|
||||
title: 'Permission refusée',
|
||||
description: 'L\'action n\'est pas autorisée sur cette page ou cet élément.',
|
||||
solution: 'Vérifiez les permissions du navigateur et les restrictions de sécurité de la page.',
|
||||
severity: 'high'
|
||||
}
|
||||
};
|
||||
|
||||
// Messages d'avertissement
|
||||
export const warningMessages: Record<string, ErrorMessage> = {
|
||||
large_workflow: {
|
||||
title: 'Workflow volumineux',
|
||||
description: 'Ce workflow contient beaucoup d\'étapes et pourrait être lent à exécuter.',
|
||||
solution: 'Considérez diviser le workflow en plusieurs parties plus petites.',
|
||||
severity: 'low'
|
||||
},
|
||||
unused_variable: {
|
||||
title: 'Variable non utilisée',
|
||||
description: 'Cette variable est définie mais n\'est utilisée dans aucune étape.',
|
||||
solution: 'Supprimez la variable si elle n\'est plus nécessaire ou utilisez-la dans une étape.',
|
||||
severity: 'low'
|
||||
},
|
||||
deprecated_step: {
|
||||
title: 'Type d\'étape obsolète',
|
||||
description: 'Ce type d\'étape est obsolète et pourrait ne plus être supporté.',
|
||||
solution: 'Remplacez par un type d\'étape plus récent avec des fonctionnalités équivalentes.',
|
||||
severity: 'medium'
|
||||
},
|
||||
performance_warning: {
|
||||
title: 'Avertissement de performance',
|
||||
description: 'Cette configuration pourrait impacter les performances d\'exécution.',
|
||||
solution: 'Optimisez les paramètres ou la structure du workflow pour de meilleures performances.',
|
||||
severity: 'low'
|
||||
}
|
||||
};
|
||||
|
||||
// Fonction utilitaire pour obtenir un message d'erreur
|
||||
export const getErrorMessage = (
|
||||
category: 'parameter' | 'workflow' | 'variable' | 'execution' | 'warning',
|
||||
errorType: string,
|
||||
context?: Record<string, any>
|
||||
): ErrorMessage => {
|
||||
const messageMaps = {
|
||||
parameter: missingParameterMessages,
|
||||
workflow: workflowErrorMessages,
|
||||
variable: variableErrorMessages,
|
||||
execution: executionErrorMessages,
|
||||
warning: warningMessages
|
||||
};
|
||||
|
||||
const baseMessage = messageMaps[category]?.[errorType];
|
||||
|
||||
if (!baseMessage) {
|
||||
return {
|
||||
title: 'Erreur inconnue',
|
||||
description: `Une erreur de type "${errorType}" s'est produite.`,
|
||||
solution: 'Contactez le support technique si le problème persiste.',
|
||||
severity: 'medium'
|
||||
};
|
||||
}
|
||||
|
||||
// Personnaliser le message avec le contexte si fourni
|
||||
if (context) {
|
||||
let customizedMessage = { ...baseMessage };
|
||||
|
||||
// Remplacer les placeholders dans les messages
|
||||
Object.entries(context).forEach(([key, value]) => {
|
||||
const placeholder = `{${key}}`;
|
||||
customizedMessage.title = customizedMessage.title.replace(placeholder, String(value));
|
||||
customizedMessage.description = customizedMessage.description.replace(placeholder, String(value));
|
||||
customizedMessage.solution = customizedMessage.solution.replace(placeholder, String(value));
|
||||
});
|
||||
|
||||
return customizedMessage;
|
||||
}
|
||||
|
||||
return baseMessage;
|
||||
};
|
||||
|
||||
// Messages de succès
|
||||
export const successMessages: Record<string, string> = {
|
||||
workflow_saved: 'Workflow sauvegardé avec succès',
|
||||
workflow_executed: 'Workflow exécuté avec succès',
|
||||
step_completed: 'Étape terminée avec succès',
|
||||
validation_passed: 'Validation réussie, aucun problème détecté',
|
||||
variable_created: 'Variable créée avec succès',
|
||||
connection_established: 'Connexion établie entre les étapes'
|
||||
};
|
||||
|
||||
// Messages informatifs
|
||||
export const infoMessages: Record<string, string> = {
|
||||
workflow_loading: 'Chargement du workflow en cours...',
|
||||
step_executing: 'Exécution de l\'étape en cours...',
|
||||
validation_running: 'Validation du workflow en cours...',
|
||||
saving_workflow: 'Sauvegarde du workflow...',
|
||||
connecting_backend: 'Connexion au serveur...'
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Suppression des erreurs ResizeObserver
|
||||
* Auteur : Dom, Alice, Kiro - 09 janvier 2026
|
||||
*
|
||||
* Ce fichier supprime les erreurs ResizeObserver qui sont générées par
|
||||
* les bibliothèques tierces (Material-UI, ReactFlow) et qui ne sont pas
|
||||
* des erreurs critiques. Ces erreurs sont connues et documentées comme
|
||||
* étant bénignes dans les environnements de développement.
|
||||
*
|
||||
* Référence: https://github.com/WICG/resize-observer/issues/38
|
||||
*/
|
||||
|
||||
// Suppression globale des erreurs ResizeObserver
|
||||
const suppressResizeObserverErrors = (): void => {
|
||||
// Intercepter les erreurs de la fenêtre
|
||||
const originalWindowError = window.onerror;
|
||||
window.onerror = (message, source, lineno, colno, error) => {
|
||||
if (
|
||||
typeof message === 'string' &&
|
||||
message.includes('ResizeObserver loop')
|
||||
) {
|
||||
// Ignorer silencieusement cette erreur spécifique
|
||||
return true;
|
||||
}
|
||||
// Appeler le gestionnaire d'erreur original si présent
|
||||
if (originalWindowError) {
|
||||
return originalWindowError(message, source, lineno, colno, error);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Intercepter les erreurs non gérées
|
||||
window.addEventListener('error', (event: ErrorEvent) => {
|
||||
if (
|
||||
event.message &&
|
||||
event.message.includes('ResizeObserver loop')
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
return;
|
||||
}
|
||||
}, true);
|
||||
|
||||
// Intercepter les promesses rejetées
|
||||
window.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => {
|
||||
if (
|
||||
event.reason &&
|
||||
typeof event.reason === 'object' &&
|
||||
event.reason.message &&
|
||||
event.reason.message.includes('ResizeObserver loop')
|
||||
) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
// Intercepter console.error pour les erreurs ResizeObserver
|
||||
const originalConsoleError = console.error;
|
||||
console.error = (...args: unknown[]) => {
|
||||
const message = args[0];
|
||||
if (
|
||||
typeof message === 'string' &&
|
||||
message.includes('ResizeObserver loop')
|
||||
) {
|
||||
// Ignorer silencieusement
|
||||
return;
|
||||
}
|
||||
originalConsoleError.apply(console, args);
|
||||
};
|
||||
};
|
||||
|
||||
// Exécuter immédiatement
|
||||
suppressResizeObserverErrors();
|
||||
|
||||
export default suppressResizeObserverErrors;
|
||||
275
visual_workflow_builder/frontend/src/utils/tooltips.ts
Normal file
275
visual_workflow_builder/frontend/src/utils/tooltips.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* Système de Tooltips Explicatifs en Français
|
||||
* Auteur : Dom, Alice, Kiro - 08 janvier 2026
|
||||
*
|
||||
* Ce module centralise tous les tooltips explicatifs en français pour
|
||||
* assurer la cohérence linguistique et faciliter la maintenance.
|
||||
*/
|
||||
|
||||
export interface TooltipContent {
|
||||
title: string;
|
||||
description: string;
|
||||
example?: string;
|
||||
shortcut?: string;
|
||||
}
|
||||
|
||||
// Tooltips pour les types d'étapes
|
||||
export const stepTooltips: Record<string, TooltipContent> = {
|
||||
click: {
|
||||
title: 'Cliquer sur un élément',
|
||||
description: 'Effectue un clic sur un élément de la page web. Vous pouvez choisir le type de clic (gauche, droit, double-clic).',
|
||||
example: 'Cliquer sur un bouton "Valider" ou un lien',
|
||||
shortcut: 'Glisser depuis la palette'
|
||||
},
|
||||
type: {
|
||||
title: 'Saisir du texte',
|
||||
description: 'Saisit du texte dans un champ de saisie. Supporte les variables dynamiques avec la syntaxe ${nom_variable}.',
|
||||
example: 'Saisir "Bonjour ${nom_utilisateur}" dans un champ',
|
||||
shortcut: 'Glisser depuis la palette'
|
||||
},
|
||||
wait: {
|
||||
title: 'Attendre',
|
||||
description: 'Met en pause l\'exécution pendant une durée spécifiée. Utile pour attendre le chargement d\'éléments.',
|
||||
example: 'Attendre 2 secondes après un clic',
|
||||
shortcut: 'Glisser depuis la palette'
|
||||
},
|
||||
condition: {
|
||||
title: 'Condition logique',
|
||||
description: 'Exécute des actions différentes selon une condition. Utilise des expressions logiques simples.',
|
||||
example: 'Si ${age} > 18 alors continuer',
|
||||
shortcut: 'Glisser depuis la palette'
|
||||
},
|
||||
extract: {
|
||||
title: 'Extraire des données',
|
||||
description: 'Extrait des données depuis un élément de la page (texte, valeur, lien, etc.) et les stocke dans une variable.',
|
||||
example: 'Extraire le prix d\'un produit',
|
||||
shortcut: 'Glisser depuis la palette'
|
||||
},
|
||||
scroll: {
|
||||
title: 'Faire défiler la page',
|
||||
description: 'Fait défiler la page dans une direction donnée. Utile pour révéler des éléments cachés.',
|
||||
example: 'Défiler vers le bas de 300 pixels',
|
||||
shortcut: 'Glisser depuis la palette'
|
||||
},
|
||||
navigate: {
|
||||
title: 'Naviguer vers une URL',
|
||||
description: 'Navigue vers une nouvelle page web. Supporte les variables dans l\'URL.',
|
||||
example: 'Aller à https://example.com/user/${id}',
|
||||
shortcut: 'Glisser depuis la palette'
|
||||
},
|
||||
screenshot: {
|
||||
title: 'Capturer l\'écran',
|
||||
description: 'Prend une capture d\'écran de la page actuelle. Utile pour documenter ou déboguer.',
|
||||
example: 'Capturer avant et après une action',
|
||||
shortcut: 'Glisser depuis la palette'
|
||||
}
|
||||
};
|
||||
|
||||
// Tooltips pour les catégories d'étapes
|
||||
export const categoryTooltips: Record<string, TooltipContent> = {
|
||||
'actions-web': {
|
||||
title: 'Actions Web',
|
||||
description: 'Étapes pour interagir directement avec les éléments des pages web (clics, saisie, navigation).',
|
||||
example: 'Cliquer, saisir du texte, naviguer'
|
||||
},
|
||||
'logique': {
|
||||
title: 'Logique',
|
||||
description: 'Structures de contrôle pour créer des workflows intelligents avec conditions et branchements.',
|
||||
example: 'Conditions if/else, boucles'
|
||||
},
|
||||
'donnees': {
|
||||
title: 'Données',
|
||||
description: 'Étapes pour extraire, manipuler et stocker des informations depuis les pages web.',
|
||||
example: 'Extraire prix, texte, liens'
|
||||
},
|
||||
'controle': {
|
||||
title: 'Contrôle',
|
||||
description: 'Étapes pour contrôler le flux d\'exécution et la synchronisation du workflow.',
|
||||
example: 'Attendre, capturer, synchroniser'
|
||||
}
|
||||
};
|
||||
|
||||
// Tooltips pour les paramètres
|
||||
export const parameterTooltips: Record<string, TooltipContent> = {
|
||||
target: {
|
||||
title: 'Élément cible',
|
||||
description: 'L\'élément de la page sur lequel effectuer l\'action. Utilisez le sélecteur visuel pour le choisir facilement.',
|
||||
example: 'Bouton, champ de saisie, lien'
|
||||
},
|
||||
text: {
|
||||
title: 'Texte à saisir',
|
||||
description: 'Le texte qui sera saisi dans le champ. Vous pouvez utiliser des variables avec ${nom_variable}.',
|
||||
example: 'Bonjour ${nom} ou texte fixe'
|
||||
},
|
||||
duration: {
|
||||
title: 'Durée d\'attente',
|
||||
description: 'Temps d\'attente en secondes. Peut être décimal (ex: 1.5 pour 1,5 seconde).',
|
||||
example: '2 pour 2 secondes, 0.5 pour 500ms'
|
||||
},
|
||||
condition: {
|
||||
title: 'Expression conditionnelle',
|
||||
description: 'Expression logique à évaluer. Utilise les opérateurs ==, !=, >, <, >=, <= et les variables.',
|
||||
example: '${age} >= 18 ou ${status} == "actif"'
|
||||
},
|
||||
clickType: {
|
||||
title: 'Type de clic',
|
||||
description: 'Le type de clic à effectuer sur l\'élément cible.',
|
||||
example: 'Clic gauche pour la plupart des actions'
|
||||
},
|
||||
clearFirst: {
|
||||
title: 'Vider le champ d\'abord',
|
||||
description: 'Si activé, vide le contenu existant du champ avant de saisir le nouveau texte.',
|
||||
example: 'Utile pour remplacer du texte existant'
|
||||
},
|
||||
attribute: {
|
||||
title: 'Attribut à extraire',
|
||||
description: 'Le type d\'information à extraire de l\'élément sélectionné.',
|
||||
example: 'Texte visible, valeur du champ, URL du lien'
|
||||
},
|
||||
direction: {
|
||||
title: 'Direction du défilement',
|
||||
description: 'La direction dans laquelle faire défiler la page.',
|
||||
example: 'Vers le bas pour voir plus de contenu'
|
||||
},
|
||||
amount: {
|
||||
title: 'Quantité de défilement',
|
||||
description: 'Distance de défilement en pixels. Plus la valeur est élevée, plus le défilement est important.',
|
||||
example: '300 pixels = environ 1/3 d\'écran'
|
||||
},
|
||||
url: {
|
||||
title: 'URL de destination',
|
||||
description: 'L\'adresse web vers laquelle naviguer. Peut contenir des variables.',
|
||||
example: 'https://site.com/page/${id}'
|
||||
},
|
||||
filename: {
|
||||
title: 'Nom du fichier de capture',
|
||||
description: 'Nom optionnel pour le fichier de capture d\'écran. Si vide, un nom automatique sera généré.',
|
||||
example: 'capture_${timestamp} ou nom_fixe'
|
||||
}
|
||||
};
|
||||
|
||||
// Tooltips pour l'interface utilisateur
|
||||
export const uiTooltips: Record<string, TooltipContent> = {
|
||||
canvas: {
|
||||
title: 'Zone de travail',
|
||||
description: 'Espace principal où vous construisez votre workflow en glissant des étapes et en les connectant.',
|
||||
shortcut: 'Molette pour zoomer, clic-glisser pour déplacer'
|
||||
},
|
||||
palette: {
|
||||
title: 'Palette d\'étapes',
|
||||
description: 'Boîte à outils contenant tous les types d\'étapes disponibles, organisés par catégories.',
|
||||
shortcut: 'Glisser une étape vers le canvas'
|
||||
},
|
||||
properties: {
|
||||
title: 'Panneau de propriétés',
|
||||
description: 'Configuration des paramètres de l\'étape sélectionnée. Chaque type d\'étape a ses propres paramètres.',
|
||||
shortcut: 'Cliquer sur une étape pour la configurer'
|
||||
},
|
||||
minimap: {
|
||||
title: 'Mini-carte',
|
||||
description: 'Vue d\'ensemble du workflow complet. Utile pour naviguer dans les gros workflows.',
|
||||
shortcut: 'Cliquer pour se déplacer rapidement'
|
||||
},
|
||||
validator: {
|
||||
title: 'Validateur',
|
||||
description: 'Vérifie la validité du workflow et signale les erreurs ou avertissements.',
|
||||
example: 'Paramètres manquants, étapes déconnectées'
|
||||
},
|
||||
executor: {
|
||||
title: 'Exécuteur',
|
||||
description: 'Lance l\'exécution du workflow et affiche le progrès en temps réel.',
|
||||
shortcut: 'Bouton Play pour démarrer'
|
||||
},
|
||||
variables: {
|
||||
title: 'Gestionnaire de variables',
|
||||
description: 'Créez et gérez les variables utilisées dans votre workflow pour le rendre dynamique.',
|
||||
example: 'nom_utilisateur, email, compteur'
|
||||
},
|
||||
documentation: {
|
||||
title: 'Documentation interactive',
|
||||
description: 'Guides et exemples pour apprendre à utiliser le Visual Workflow Builder efficacement.',
|
||||
shortcut: 'Onglet Documentation'
|
||||
},
|
||||
visualSelector: {
|
||||
title: 'Sélecteur visuel',
|
||||
description: 'Outil pour sélectionner visuellement des éléments sur une capture d\'écran de la page web.',
|
||||
shortcut: 'Bouton "Sélectionner un élément"'
|
||||
},
|
||||
search: {
|
||||
title: 'Recherche d\'étapes',
|
||||
description: 'Recherchez rapidement un type d\'étape par son nom ou sa description.',
|
||||
shortcut: 'Ctrl+F dans la palette'
|
||||
}
|
||||
};
|
||||
|
||||
// Tooltips pour les raccourcis clavier
|
||||
export const keyboardTooltips: Record<string, TooltipContent> = {
|
||||
'ctrl+z': {
|
||||
title: 'Annuler',
|
||||
description: 'Annule la dernière action effectuée dans le workflow.',
|
||||
shortcut: 'Ctrl+Z'
|
||||
},
|
||||
'ctrl+y': {
|
||||
title: 'Rétablir',
|
||||
description: 'Rétablit la dernière action annulée.',
|
||||
shortcut: 'Ctrl+Y'
|
||||
},
|
||||
'ctrl+s': {
|
||||
title: 'Sauvegarder',
|
||||
description: 'Sauvegarde le workflow actuel.',
|
||||
shortcut: 'Ctrl+S'
|
||||
},
|
||||
'ctrl+c': {
|
||||
title: 'Copier',
|
||||
description: 'Copie les étapes sélectionnées.',
|
||||
shortcut: 'Ctrl+C'
|
||||
},
|
||||
'ctrl+v': {
|
||||
title: 'Coller',
|
||||
description: 'Colle les étapes copiées.',
|
||||
shortcut: 'Ctrl+V'
|
||||
},
|
||||
'delete': {
|
||||
title: 'Supprimer',
|
||||
description: 'Supprime les étapes ou connexions sélectionnées.',
|
||||
shortcut: 'Suppr'
|
||||
},
|
||||
'ctrl+a': {
|
||||
title: 'Tout sélectionner',
|
||||
description: 'Sélectionne toutes les étapes du workflow.',
|
||||
shortcut: 'Ctrl+A'
|
||||
},
|
||||
'space': {
|
||||
title: 'Mode panoramique',
|
||||
description: 'Maintenir Espace + glisser pour déplacer la vue du canvas.',
|
||||
shortcut: 'Espace + glisser'
|
||||
}
|
||||
};
|
||||
|
||||
// Fonction utilitaire pour obtenir un tooltip
|
||||
export const getTooltip = (category: string, key: string): TooltipContent | null => {
|
||||
const tooltipMaps: Record<string, Record<string, TooltipContent>> = {
|
||||
step: stepTooltips,
|
||||
category: categoryTooltips,
|
||||
parameter: parameterTooltips,
|
||||
ui: uiTooltips,
|
||||
keyboard: keyboardTooltips
|
||||
};
|
||||
|
||||
return tooltipMaps[category]?.[key] || null;
|
||||
};
|
||||
|
||||
// Fonction pour formater un tooltip en texte riche
|
||||
export const formatTooltip = (tooltip: TooltipContent): string => {
|
||||
let formatted = `${tooltip.title}\n\n${tooltip.description}`;
|
||||
|
||||
if (tooltip.example) {
|
||||
formatted += `\n\nExemple : ${tooltip.example}`;
|
||||
}
|
||||
|
||||
if (tooltip.shortcut) {
|
||||
formatted += `\n\nRaccourci : ${tooltip.shortcut}`;
|
||||
}
|
||||
|
||||
return formatted;
|
||||
};
|
||||
Reference in New Issue
Block a user