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:
@@ -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
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user