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:
23
visual_workflow_builder/frontend/.gitignore
vendored
Normal file
23
visual_workflow_builder/frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
46
visual_workflow_builder/frontend/README.md
Normal file
46
visual_workflow_builder/frontend/README.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Getting Started with Create React App
|
||||
|
||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||
|
||||
## Available Scripts
|
||||
|
||||
In the project directory, you can run:
|
||||
|
||||
### `npm start`
|
||||
|
||||
Runs the app in the development mode.\
|
||||
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
|
||||
|
||||
The page will reload if you make edits.\
|
||||
You will also see any lint errors in the console.
|
||||
|
||||
### `npm test`
|
||||
|
||||
Launches the test runner in the interactive watch mode.\
|
||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||
|
||||
### `npm run build`
|
||||
|
||||
Builds the app for production to the `build` folder.\
|
||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||
|
||||
The build is minified and the filenames include the hashes.\
|
||||
Your app is ready to be deployed!
|
||||
|
||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||
|
||||
### `npm run eject`
|
||||
|
||||
**Note: this is a one-way operation. Once you `eject`, you can’t go back!**
|
||||
|
||||
If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||
|
||||
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
|
||||
|
||||
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
|
||||
|
||||
## Learn More
|
||||
|
||||
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||
|
||||
To learn React, check out the [React documentation](https://reactjs.org/).
|
||||
16895
visual_workflow_builder/frontend/package-lock.json
generated
Normal file
16895
visual_workflow_builder/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
58
visual_workflow_builder/frontend/package.json
Normal file
58
visual_workflow_builder/frontend/package.json
Normal file
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"proxy": "http://localhost:5001",
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/icons-material": "^7.3.7",
|
||||
"@mui/material": "^7.3.7",
|
||||
"@reduxjs/toolkit": "^2.11.2",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.1",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"@types/jest": "^27.5.2",
|
||||
"@types/node": "^16.18.126",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@xyflow/react": "^12.10.0",
|
||||
"axios": "^1.13.2",
|
||||
"lodash": "^4.17.21",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"typescript": "^4.9.5",
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "DISABLE_ESLINT_PLUGIN=true react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject",
|
||||
"start:safe": "DISABLE_ESLINT_PLUGIN=true GENERATE_SOURCEMAP=false react-scripts start",
|
||||
"build:safe": "GENERATE_SOURCEMAP=false react-scripts build",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
}
|
||||
}
|
||||
BIN
visual_workflow_builder/frontend/public/favicon.ico
Normal file
BIN
visual_workflow_builder/frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
87
visual_workflow_builder/frontend/public/index.html
Normal file
87
visual_workflow_builder/frontend/public/index.html
Normal file
@@ -0,0 +1,87 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#1976d2" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Visual Workflow Builder V2 - Interface de création de workflows RPA"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<title>Visual Workflow Builder V2</title>
|
||||
|
||||
<!--
|
||||
Suppression des erreurs ResizeObserver
|
||||
Auteur : Dom, Alice, Kiro - 09 janvier 2026
|
||||
Ces erreurs sont bénignes et générées par Material-UI/ReactFlow
|
||||
-->
|
||||
<script>
|
||||
(function() {
|
||||
// Fonction de vérification des erreurs ResizeObserver
|
||||
function isResizeObserverError(message) {
|
||||
return message && (
|
||||
message.includes('ResizeObserver loop') ||
|
||||
message.includes('ResizeObserver loop completed with undelivered notifications')
|
||||
);
|
||||
}
|
||||
|
||||
// Intercepter window.onerror
|
||||
var originalOnError = window.onerror;
|
||||
window.onerror = function(message, source, lineno, colno, error) {
|
||||
if (isResizeObserverError(message)) {
|
||||
return true; // Empêcher la propagation
|
||||
}
|
||||
if (originalOnError) {
|
||||
return originalOnError(message, source, lineno, colno, error);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Intercepter les événements d'erreur (phase de capture)
|
||||
window.addEventListener('error', function(event) {
|
||||
if (event.message && isResizeObserverError(event.message)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
return false;
|
||||
}
|
||||
}, true);
|
||||
|
||||
// Intercepter les promesses rejetées
|
||||
window.addEventListener('unhandledrejection', function(event) {
|
||||
if (event.reason && event.reason.message && isResizeObserverError(event.reason.message)) {
|
||||
event.preventDefault();
|
||||
return false;
|
||||
}
|
||||
}, true);
|
||||
|
||||
// Intercepter console.error
|
||||
var originalConsoleError = console.error;
|
||||
console.error = function() {
|
||||
var args = Array.prototype.slice.call(arguments);
|
||||
if (args.length > 0 && typeof args[0] === 'string' && isResizeObserverError(args[0])) {
|
||||
return; // Ignorer silencieusement
|
||||
}
|
||||
originalConsoleError.apply(console, args);
|
||||
};
|
||||
|
||||
// Intercepter console.warn aussi
|
||||
var originalConsoleWarn = console.warn;
|
||||
console.warn = function() {
|
||||
var args = Array.prototype.slice.call(arguments);
|
||||
if (args.length > 0 && typeof args[0] === 'string' && isResizeObserverError(args[0])) {
|
||||
return; // Ignorer silencieusement
|
||||
}
|
||||
originalConsoleWarn.apply(console, args);
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>Vous devez activer JavaScript pour utiliser cette application.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
BIN
visual_workflow_builder/frontend/public/logo192.png
Normal file
BIN
visual_workflow_builder/frontend/public/logo192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
BIN
visual_workflow_builder/frontend/public/logo512.png
Normal file
BIN
visual_workflow_builder/frontend/public/logo512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
25
visual_workflow_builder/frontend/public/manifest.json
Normal file
25
visual_workflow_builder/frontend/public/manifest.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"short_name": "React App",
|
||||
"name": "Create React App Sample",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
3
visual_workflow_builder/frontend/public/robots.txt
Normal file
3
visual_workflow_builder/frontend/public/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
38
visual_workflow_builder/frontend/src/App.css
Normal file
38
visual_workflow_builder/frontend/src/App.css
Normal file
@@ -0,0 +1,38 @@
|
||||
.App {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.App-logo {
|
||||
height: 40vmin;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.App-logo {
|
||||
animation: App-logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.App-header {
|
||||
background-color: #282c34;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: calc(10px + 2vmin);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.App-link {
|
||||
color: #61dafb;
|
||||
}
|
||||
|
||||
@keyframes App-logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
9
visual_workflow_builder/frontend/src/App.test.tsx
Normal file
9
visual_workflow_builder/frontend/src/App.test.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import App from './App';
|
||||
|
||||
test('renders learn react link', () => {
|
||||
render(<App />);
|
||||
const linkElement = screen.getByText(/learn react/i);
|
||||
expect(linkElement).toBeInTheDocument();
|
||||
});
|
||||
@@ -364,13 +364,17 @@ function App() {
|
||||
|
||||
const handleStepAdd = (stepData: Omit<Step, 'id'>) => {
|
||||
console.log('Ajout étape:', stepData);
|
||||
|
||||
// IMPORTANT: Copie profonde pour éviter les références partagées avec d'autres étapes
|
||||
const stepDataCopy = JSON.parse(JSON.stringify(stepData));
|
||||
|
||||
const newStep: Step = {
|
||||
...stepData,
|
||||
...stepDataCopy,
|
||||
id: `step_${Date.now()}`,
|
||||
executionState: StepExecutionState.IDLE,
|
||||
validationErrors: [],
|
||||
};
|
||||
|
||||
|
||||
setWorkflow(prev => ({
|
||||
...prev,
|
||||
steps: [...prev.steps, newStep],
|
||||
@@ -406,19 +410,25 @@ function App() {
|
||||
|
||||
const handleParameterChange = (stepId: string, param: string, value: any) => {
|
||||
console.log('Changement paramètre:', stepId, param, value);
|
||||
|
||||
// IMPORTANT: Copie profonde de la valeur pour éviter les références partagées entre étapes
|
||||
const valueCopy = value && typeof value === 'object'
|
||||
? JSON.parse(JSON.stringify(value))
|
||||
: value;
|
||||
|
||||
setWorkflow(prev => ({
|
||||
...prev,
|
||||
steps: prev.steps.map(step =>
|
||||
step.id === stepId
|
||||
? {
|
||||
...step,
|
||||
data: {
|
||||
...step.data,
|
||||
parameters: {
|
||||
...step.data.parameters,
|
||||
[param]: value
|
||||
}
|
||||
}
|
||||
steps: prev.steps.map(step =>
|
||||
step.id === stepId
|
||||
? {
|
||||
...step,
|
||||
data: {
|
||||
...step.data,
|
||||
parameters: {
|
||||
...step.data.parameters,
|
||||
[param]: valueCopy
|
||||
}
|
||||
}
|
||||
}
|
||||
: step
|
||||
),
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* Fournisseur d'Accessibilité - Conformité WCAG 2.1 niveau AA
|
||||
* Auteur : Dom, Alice, Kiro - 08 janvier 2026
|
||||
*
|
||||
* Ce composant fournit les fonctionnalités d'accessibilité nécessaires
|
||||
* pour assurer la conformité aux standards WCAG 2.1 niveau AA.
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
|
||||
interface AccessibilitySettings {
|
||||
// Préférences utilisateur
|
||||
highContrast: boolean;
|
||||
reducedMotion: boolean;
|
||||
largeText: boolean;
|
||||
screenReaderMode: boolean;
|
||||
|
||||
// Paramètres de navigation
|
||||
focusVisible: boolean;
|
||||
skipLinks: boolean;
|
||||
|
||||
// Paramètres d'affichage
|
||||
colorBlindnessMode: 'none' | 'protanopia' | 'deuteranopia' | 'tritanopia';
|
||||
fontSize: 'small' | 'medium' | 'large' | 'extra-large';
|
||||
|
||||
// Paramètres d'interaction
|
||||
clickDelay: number; // ms
|
||||
hoverDelay: number; // ms
|
||||
}
|
||||
|
||||
interface AccessibilityContextType {
|
||||
settings: AccessibilitySettings;
|
||||
updateSettings: (updates: Partial<AccessibilitySettings>) => void;
|
||||
announceToScreenReader: (message: string, priority?: 'polite' | 'assertive') => void;
|
||||
focusElement: (elementId: string) => void;
|
||||
isKeyboardNavigation: boolean;
|
||||
}
|
||||
|
||||
const defaultSettings: AccessibilitySettings = {
|
||||
highContrast: false,
|
||||
reducedMotion: false,
|
||||
largeText: false,
|
||||
screenReaderMode: false,
|
||||
focusVisible: true,
|
||||
skipLinks: true,
|
||||
colorBlindnessMode: 'none',
|
||||
fontSize: 'medium',
|
||||
clickDelay: 0,
|
||||
hoverDelay: 500,
|
||||
};
|
||||
|
||||
const AccessibilityContext = createContext<AccessibilityContextType | null>(null);
|
||||
|
||||
interface AccessibilityProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fournisseur d'Accessibilité
|
||||
*/
|
||||
export const AccessibilityProvider: React.FC<AccessibilityProviderProps> = ({ children }) => {
|
||||
const [settings, setSettings] = useState<AccessibilitySettings>(defaultSettings);
|
||||
const [isKeyboardNavigation, setIsKeyboardNavigation] = useState(false);
|
||||
const [screenReaderMessage, setScreenReaderMessage] = useState<{
|
||||
message: string;
|
||||
priority: 'polite' | 'assertive';
|
||||
} | null>(null);
|
||||
|
||||
// Détecter les préférences système
|
||||
useEffect(() => {
|
||||
const mediaQueries = {
|
||||
reducedMotion: window.matchMedia('(prefers-reduced-motion: reduce)'),
|
||||
highContrast: window.matchMedia('(prefers-contrast: high)'),
|
||||
largeText: window.matchMedia('(prefers-reduced-data: reduce)'), // Approximation
|
||||
};
|
||||
|
||||
const updateFromSystem = () => {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
reducedMotion: mediaQueries.reducedMotion.matches,
|
||||
highContrast: mediaQueries.highContrast.matches,
|
||||
}));
|
||||
};
|
||||
|
||||
// Écouter les changements
|
||||
Object.values(mediaQueries).forEach(mq => {
|
||||
mq.addEventListener('change', updateFromSystem);
|
||||
});
|
||||
|
||||
// Initialiser
|
||||
updateFromSystem();
|
||||
|
||||
return () => {
|
||||
Object.values(mediaQueries).forEach(mq => {
|
||||
mq.removeEventListener('change', updateFromSystem);
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Détecter la navigation au clavier
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Tab') {
|
||||
setIsKeyboardNavigation(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseDown = () => {
|
||||
setIsKeyboardNavigation(false);
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
document.addEventListener('mousedown', handleMouseDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
document.removeEventListener('mousedown', handleMouseDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Appliquer les styles d'accessibilité
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
|
||||
// Contraste élevé
|
||||
if (settings.highContrast) {
|
||||
root.style.setProperty('--primary-color', '#000000');
|
||||
root.style.setProperty('--background-color', '#ffffff');
|
||||
root.style.setProperty('--text-color', '#000000');
|
||||
root.style.setProperty('--border-color', '#000000');
|
||||
} else {
|
||||
root.style.removeProperty('--primary-color');
|
||||
root.style.removeProperty('--background-color');
|
||||
root.style.removeProperty('--text-color');
|
||||
root.style.removeProperty('--border-color');
|
||||
}
|
||||
|
||||
// Taille de police
|
||||
const fontSizeMap = {
|
||||
'small': '14px',
|
||||
'medium': '16px',
|
||||
'large': '18px',
|
||||
'extra-large': '20px',
|
||||
};
|
||||
root.style.fontSize = fontSizeMap[settings.fontSize];
|
||||
|
||||
// Mouvement réduit
|
||||
if (settings.reducedMotion) {
|
||||
root.style.setProperty('--animation-duration', '0.01ms');
|
||||
root.style.setProperty('--transition-duration', '0.01ms');
|
||||
} else {
|
||||
root.style.removeProperty('--animation-duration');
|
||||
root.style.removeProperty('--transition-duration');
|
||||
}
|
||||
|
||||
// Focus visible
|
||||
if (settings.focusVisible && isKeyboardNavigation) {
|
||||
root.classList.add('keyboard-navigation');
|
||||
} else {
|
||||
root.classList.remove('keyboard-navigation');
|
||||
}
|
||||
|
||||
}, [settings, isKeyboardNavigation]);
|
||||
|
||||
// Fonction pour mettre à jour les paramètres
|
||||
const updateSettings = (updates: Partial<AccessibilitySettings>) => {
|
||||
setSettings(prev => ({ ...prev, ...updates }));
|
||||
};
|
||||
|
||||
// Fonction pour annoncer aux lecteurs d'écran
|
||||
const announceToScreenReader = (message: string, priority: 'polite' | 'assertive' = 'polite') => {
|
||||
setScreenReaderMessage({ message, priority });
|
||||
|
||||
// Effacer le message après un délai
|
||||
setTimeout(() => {
|
||||
setScreenReaderMessage(null);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
// Fonction pour donner le focus à un élément
|
||||
const focusElement = (elementId: string) => {
|
||||
const element = document.getElementById(elementId);
|
||||
if (element) {
|
||||
element.focus();
|
||||
// Annoncer le changement de focus
|
||||
const label = element.getAttribute('aria-label') || element.textContent || 'Élément';
|
||||
announceToScreenReader(`Focus sur ${label}`);
|
||||
}
|
||||
};
|
||||
|
||||
const contextValue: AccessibilityContextType = {
|
||||
settings,
|
||||
updateSettings,
|
||||
announceToScreenReader,
|
||||
focusElement,
|
||||
isKeyboardNavigation,
|
||||
};
|
||||
|
||||
return (
|
||||
<AccessibilityContext.Provider value={contextValue}>
|
||||
{/* Liens de saut pour la navigation au clavier */}
|
||||
{settings.skipLinks && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -40,
|
||||
left: 6,
|
||||
zIndex: 9999,
|
||||
'&:focus-within': {
|
||||
top: 6,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<a
|
||||
href="#main-content"
|
||||
style={{
|
||||
background: '#000',
|
||||
color: '#fff',
|
||||
padding: '8px 16px',
|
||||
textDecoration: 'none',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
onFocus={() => announceToScreenReader('Lien de saut vers le contenu principal')}
|
||||
>
|
||||
Aller au contenu principal
|
||||
</a>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Zone live pour les annonces aux lecteurs d'écran */}
|
||||
<div
|
||||
role="status"
|
||||
aria-live={screenReaderMessage?.priority || 'polite'}
|
||||
aria-atomic="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '-10000px',
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{screenReaderMessage?.message}
|
||||
</div>
|
||||
|
||||
{/* Contenu principal avec ID et rôle pour les liens de saut */}
|
||||
<main id="main-content" role="main" tabIndex={-1}>
|
||||
{children}
|
||||
</main>
|
||||
|
||||
{/* Styles CSS d'accessibilité injectés */}
|
||||
<style>{`
|
||||
/* Focus visible pour la navigation au clavier */
|
||||
.keyboard-navigation *:focus {
|
||||
outline: 2px solid #2196f3 !important;
|
||||
outline-offset: 2px !important;
|
||||
}
|
||||
|
||||
/* Contraste élevé */
|
||||
${settings.highContrast ? `
|
||||
* {
|
||||
background-color: var(--background-color, white) !important;
|
||||
color: var(--text-color, black) !important;
|
||||
border-color: var(--border-color, black) !important;
|
||||
}
|
||||
|
||||
button, input, select, textarea {
|
||||
border: 2px solid black !important;
|
||||
}
|
||||
` : ''}
|
||||
|
||||
/* Mouvement réduit */
|
||||
${settings.reducedMotion ? `
|
||||
*, *::before, *::after {
|
||||
animation-duration: var(--animation-duration, 0.01ms) !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: var(--transition-duration, 0.01ms) !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
` : ''}
|
||||
|
||||
/* Filtres pour daltonisme */
|
||||
${settings.colorBlindnessMode !== 'none' ? `
|
||||
html {
|
||||
filter: ${getColorBlindnessFilter(settings.colorBlindnessMode)};
|
||||
}
|
||||
` : ''}
|
||||
|
||||
/* Texte large */
|
||||
${settings.largeText ? `
|
||||
* {
|
||||
font-size: 1.2em !important;
|
||||
line-height: 1.5 !important;
|
||||
}
|
||||
` : ''}
|
||||
`}</style>
|
||||
</AccessibilityContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// Fonction utilitaire pour les filtres de daltonisme
|
||||
function getColorBlindnessFilter(mode: string): string {
|
||||
switch (mode) {
|
||||
case 'protanopia':
|
||||
return 'url(#protanopia)';
|
||||
case 'deuteranopia':
|
||||
return 'url(#deuteranopia)';
|
||||
case 'tritanopia':
|
||||
return 'url(#tritanopia)';
|
||||
default:
|
||||
return 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Hook pour utiliser le contexte d'accessibilité
|
||||
export const useAccessibility = (): AccessibilityContextType => {
|
||||
const context = useContext(AccessibilityContext);
|
||||
if (!context) {
|
||||
throw new Error('useAccessibility doit être utilisé dans un AccessibilityProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export default AccessibilityProvider;
|
||||
@@ -0,0 +1,379 @@
|
||||
/**
|
||||
* Composant StepNode - Nœud personnalisé pour les étapes de workflow
|
||||
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
|
||||
*
|
||||
* Composant de nœud personnalisé avec états visuels d'exécution,
|
||||
* indicateurs d'erreur et style français. Optimisé pour les performances.
|
||||
*
|
||||
* EXTENSION VWB : Support des animations et états visuels avancés pour les actions VisionOnly.
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import { Handle, Position, NodeProps } from '@xyflow/react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Chip,
|
||||
Tooltip,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
PlayArrow as RunningIcon,
|
||||
CheckCircle as SuccessIcon,
|
||||
Error as ErrorIcon,
|
||||
Warning as WarningIcon,
|
||||
Pause as PauseIcon,
|
||||
SkipNext as SkipNextIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
// Import des types partagés
|
||||
import { StepNodeData, StepExecutionState, ValidationError, StepType } from '../../types';
|
||||
|
||||
// Import de l'extension VWB
|
||||
import VWBStepNodeExtension from './VWBStepNodeExtension';
|
||||
|
||||
// Import du service VWB pour la détection
|
||||
// import { useVWBExecutionService } from '../../services/vwbExecutionService';
|
||||
|
||||
// Couleurs par état d'exécution
|
||||
const executionStateColors = {
|
||||
[StepExecutionState.IDLE]: '#9e9e9e',
|
||||
[StepExecutionState.RUNNING]: '#2196f3',
|
||||
[StepExecutionState.SUCCESS]: '#4caf50',
|
||||
[StepExecutionState.ERROR]: '#f44336',
|
||||
[StepExecutionState.SKIPPED]: '#9e9e9e',
|
||||
[StepExecutionState.PAUSED]: '#9c27b0',
|
||||
};
|
||||
|
||||
// Icônes par état d'exécution
|
||||
const executionStateIcons = {
|
||||
[StepExecutionState.IDLE]: null,
|
||||
[StepExecutionState.RUNNING]: <RunningIcon fontSize="small" />,
|
||||
[StepExecutionState.SUCCESS]: <SuccessIcon fontSize="small" />,
|
||||
[StepExecutionState.ERROR]: <ErrorIcon fontSize="small" />,
|
||||
[StepExecutionState.SKIPPED]: <SkipNextIcon fontSize="small" />,
|
||||
[StepExecutionState.PAUSED]: <PauseIcon fontSize="small" />,
|
||||
};
|
||||
|
||||
// Labels français par type d'étape
|
||||
const stepTypeLabels: Record<StepType, string> = {
|
||||
click: 'Cliquer',
|
||||
type: 'Saisir',
|
||||
wait: 'Attendre',
|
||||
condition: 'Condition',
|
||||
extract: 'Extraire',
|
||||
scroll: 'Défiler',
|
||||
navigate: 'Naviguer',
|
||||
screenshot: 'Capture',
|
||||
};
|
||||
|
||||
/**
|
||||
* Composant StepNode personnalisé avec support des actions VWB
|
||||
*/
|
||||
const StepNode: React.FC<NodeProps> = ({ data, selected }) => {
|
||||
const stepData = data as StepNodeData;
|
||||
|
||||
// Service VWB pour détecter les actions VWB (fonction locale)
|
||||
const isVWBStep = (step: any) => Boolean(
|
||||
step.data?.isVWBCatalogAction ||
|
||||
step.data?.vwbActionId ||
|
||||
step.action_id?.startsWith('vwb_') ||
|
||||
step.action_id?.includes('catalog_')
|
||||
);
|
||||
|
||||
// Vérification de sécurité pour les données
|
||||
if (!stepData) {
|
||||
return (
|
||||
<Box sx={{ p: 2, border: '1px solid red', borderRadius: 1 }}>
|
||||
<Typography color="error">Données manquantes</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Créer un objet Step temporaire pour la détection VWB
|
||||
const tempStep = {
|
||||
id: 'temp',
|
||||
type: stepData.stepType,
|
||||
name: stepData.label,
|
||||
data: stepData,
|
||||
position: { x: 0, y: 0 },
|
||||
executionState: stepData.executionState,
|
||||
validationErrors: stepData.validationErrors || []
|
||||
};
|
||||
|
||||
// Détecter si c'est une action VWB
|
||||
const isVWBAction = stepData.isVWBCatalogAction || isVWBStep(tempStep);
|
||||
|
||||
// Si c'est une action VWB, utiliser l'extension VWB
|
||||
if (isVWBAction) {
|
||||
return <VWBStepNodeExtension data={data} selected={selected} />;
|
||||
}
|
||||
|
||||
// Sinon, utiliser le rendu standard (code existant)
|
||||
return <StandardStepNode data={data} selected={selected} />;
|
||||
};
|
||||
|
||||
/**
|
||||
* Composant StepNode Standard (code existant refactorisé)
|
||||
*/
|
||||
interface StandardStepNodeProps {
|
||||
data: any;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
const StandardStepNode: React.FC<StandardStepNodeProps> = ({ data, selected }) => {
|
||||
const stepData = data as StepNodeData;
|
||||
const {
|
||||
label,
|
||||
stepType,
|
||||
executionState = StepExecutionState.IDLE,
|
||||
validationErrors = [],
|
||||
isSelected = false,
|
||||
isVWBCatalogAction = false,
|
||||
} = stepData || {};
|
||||
|
||||
// Vérification de sécurité pour les données
|
||||
if (!stepData) {
|
||||
return (
|
||||
<Box sx={{ p: 2, border: '1px solid red', borderRadius: 1 }}>
|
||||
<Typography color="error">Données manquantes</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Déterminer la couleur de bordure selon l'état
|
||||
const getBorderColor = () => {
|
||||
if (validationErrors.some((e: ValidationError) => e.severity === 'error')) {
|
||||
return '#f44336'; // Rouge pour les erreurs
|
||||
}
|
||||
if (validationErrors.some((e: ValidationError) => e.severity === 'warning')) {
|
||||
return '#ff9800'; // Orange pour les avertissements
|
||||
}
|
||||
if (selected || isSelected) {
|
||||
return '#1976d2'; // Bleu pour la sélection
|
||||
}
|
||||
return executionStateColors[executionState as StepExecutionState];
|
||||
};
|
||||
|
||||
// Déterminer la couleur de fond selon l'état
|
||||
const getBackgroundColor = () => {
|
||||
switch (executionState) {
|
||||
case StepExecutionState.RUNNING:
|
||||
return '#e3f2fd';
|
||||
case StepExecutionState.SUCCESS:
|
||||
return '#e8f5e8';
|
||||
case StepExecutionState.ERROR:
|
||||
return '#ffebee';
|
||||
case StepExecutionState.SKIPPED:
|
||||
return '#fff3e0';
|
||||
default:
|
||||
return '#ffffff';
|
||||
}
|
||||
};
|
||||
|
||||
// Messages d'erreur pour le tooltip
|
||||
const errorMessages = validationErrors
|
||||
.filter((e: ValidationError) => e.severity === 'error')
|
||||
.map((e: ValidationError) => e.message)
|
||||
.join(', ');
|
||||
|
||||
const warningMessages = validationErrors
|
||||
.filter((e: ValidationError) => e.severity === 'warning')
|
||||
.map((e: ValidationError) => e.message)
|
||||
.join(', ');
|
||||
|
||||
// Style commun pour les handles
|
||||
const handleStyle = {
|
||||
background: getBorderColor(),
|
||||
width: 10,
|
||||
height: 10,
|
||||
border: '2px solid white',
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
minWidth: 120,
|
||||
minHeight: 50,
|
||||
maxWidth: 180,
|
||||
backgroundColor: getBackgroundColor(),
|
||||
border: `2px solid ${getBorderColor()}`,
|
||||
borderRadius: 2,
|
||||
boxShadow: selected || isSelected ? '0 4px 12px rgba(0,0,0,0.15)' : '0 2px 8px rgba(0,0,0,0.1)',
|
||||
position: 'relative',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
'&:hover': {
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.2)',
|
||||
transform: 'translateY(-1px)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Poignées de connexion - Haut (entrées) */}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
id="top"
|
||||
style={handleStyle}
|
||||
/>
|
||||
|
||||
{/* Poignées de connexion - Gauche (entrée alternative) */}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
id="left"
|
||||
style={handleStyle}
|
||||
/>
|
||||
|
||||
{/* Poignées de connexion - Droite (sortie alternative) */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id="right"
|
||||
style={handleStyle}
|
||||
/>
|
||||
|
||||
{/* Poignées de connexion - Bas (sortie principale) */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
id="bottom"
|
||||
style={handleStyle}
|
||||
/>
|
||||
|
||||
{/* Contenu principal */}
|
||||
<Box sx={{ p: 1.5 }}>
|
||||
{/* En-tête avec type et état */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Chip
|
||||
label={stepTypeLabels[stepType as StepType] || stepType}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
fontSize: '0.75rem',
|
||||
height: 20,
|
||||
borderColor: getBorderColor(),
|
||||
color: getBorderColor(),
|
||||
}}
|
||||
/>
|
||||
{/* Badge VWB pour les actions du catalogue */}
|
||||
{isVWBCatalogAction && (
|
||||
<Chip
|
||||
label="VWB"
|
||||
size="small"
|
||||
color="primary"
|
||||
sx={{
|
||||
fontSize: '0.65rem',
|
||||
height: 16,
|
||||
minWidth: 32,
|
||||
'& .MuiChip-label': {
|
||||
px: 0.5,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Icône d'état d'exécution */}
|
||||
{executionStateIcons[executionState as StepExecutionState] && (
|
||||
<Box sx={{ color: executionStateColors[executionState as StepExecutionState] }}>
|
||||
{executionStateIcons[executionState as StepExecutionState]}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Nom de l'étape */}
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontWeight: 500,
|
||||
color: '#333',
|
||||
textAlign: 'center',
|
||||
wordBreak: 'break-word',
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
|
||||
{/* Indicateurs d'erreur/avertissement */}
|
||||
{validationErrors.length > 0 && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 1, gap: 0.5 }}>
|
||||
{validationErrors.some((e: ValidationError) => e.severity === 'error') && (
|
||||
<Tooltip title={`Erreurs: ${errorMessages}`} arrow>
|
||||
<ErrorIcon sx={{ fontSize: 16, color: '#f44336' }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
{validationErrors.some((e: ValidationError) => e.severity === 'warning') && (
|
||||
<Tooltip title={`Avertissements: ${warningMessages}`} arrow>
|
||||
<WarningIcon sx={{ fontSize: 16, color: '#ff9800' }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Indicateur de sélection */}
|
||||
{(selected || isSelected) && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -2,
|
||||
left: -2,
|
||||
right: -2,
|
||||
bottom: -2,
|
||||
border: '2px solid #1976d2',
|
||||
borderRadius: 2,
|
||||
pointerEvents: 'none',
|
||||
animation: 'pulse 2s infinite',
|
||||
'@keyframes pulse': {
|
||||
'0%': { opacity: 0.7 },
|
||||
'50%': { opacity: 1 },
|
||||
'100%': { opacity: 0.7 },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Animation de pulsation pour l'état en cours d'exécution */}
|
||||
{executionState === StepExecutionState.RUNNING && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -4,
|
||||
left: -4,
|
||||
right: -4,
|
||||
bottom: -4,
|
||||
border: '2px solid #2196f3',
|
||||
borderRadius: 2,
|
||||
pointerEvents: 'none',
|
||||
animation: 'runningPulse 1s infinite',
|
||||
'@keyframes runningPulse': {
|
||||
'0%': { opacity: 0.3, transform: 'scale(1)' },
|
||||
'50%': { opacity: 0.7, transform: 'scale(1.02)' },
|
||||
'100%': { opacity: 0.3, transform: 'scale(1)' },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// Mémorisation du composant StepNode pour éviter les re-rendus inutiles
|
||||
export default memo(StepNode, (prevProps, nextProps) => {
|
||||
// Comparaison personnalisée pour optimiser les re-rendus
|
||||
const prevData = prevProps.data as StepNodeData;
|
||||
const nextData = nextProps.data as StepNodeData;
|
||||
|
||||
return (
|
||||
prevProps.id === nextProps.id &&
|
||||
prevProps.selected === nextProps.selected &&
|
||||
prevData?.label === nextData?.label &&
|
||||
prevData?.stepType === nextData?.stepType &&
|
||||
prevData?.executionState === nextData?.executionState &&
|
||||
prevData?.isSelected === nextData?.isSelected &&
|
||||
prevData?.isVWBCatalogAction === nextData?.isVWBCatalogAction &&
|
||||
prevData?.vwbActionId === nextData?.vwbActionId &&
|
||||
JSON.stringify(prevData?.validationErrors) === JSON.stringify(nextData?.validationErrors)
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* Intégration Canvas VWB - Gestion du drag-and-drop et des actions VisionOnly
|
||||
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
|
||||
*
|
||||
* Ce composant étend le Canvas principal avec la gestion complète des actions VWB,
|
||||
* incluant le drag-and-drop depuis la Palette, la création d'étapes et la synchronisation.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { useReactFlow, Node, Edge, Connection } from '@xyflow/react';
|
||||
import { Box, Alert, Snackbar } from '@mui/material';
|
||||
|
||||
// Import des hooks d'intégration VWB
|
||||
import { useVWBStepIntegration } from '../../hooks/useVWBStepIntegration';
|
||||
|
||||
// Import des types
|
||||
import { Step, StepData, StepExecutionState, Position } from '../../types';
|
||||
import { VWBCatalogAction } from '../../types/catalog';
|
||||
|
||||
interface VWBCanvasIntegrationProps {
|
||||
onStepAdd?: (step: Step) => void;
|
||||
onStepUpdate?: (stepId: string, updates: Partial<Step>) => void;
|
||||
onError?: (error: string) => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composant d'intégration Canvas VWB
|
||||
*/
|
||||
const VWBCanvasIntegration: React.FC<VWBCanvasIntegrationProps> = ({
|
||||
onStepAdd,
|
||||
onStepUpdate,
|
||||
onError,
|
||||
children,
|
||||
}) => {
|
||||
const { screenToFlowPosition } = useReactFlow();
|
||||
const { state: vwbState, methods: vwbMethods } = useVWBStepIntegration();
|
||||
const [notification, setNotification] = React.useState<{
|
||||
open: boolean;
|
||||
message: string;
|
||||
severity: 'success' | 'error' | 'warning' | 'info';
|
||||
}>({
|
||||
open: false,
|
||||
message: '',
|
||||
severity: 'info',
|
||||
});
|
||||
|
||||
// Gestionnaire de drop pour les actions VWB
|
||||
const handleDrop = useCallback(async (event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
try {
|
||||
// Obtenir les données de drag
|
||||
const dragData = event.dataTransfer.getData('application/reactflow');
|
||||
if (!dragData) return;
|
||||
|
||||
// Calculer la position dans le flow
|
||||
const position = screenToFlowPosition({
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
|
||||
// Tenter de créer une étape VWB
|
||||
const vwbStep = await vwbMethods.convertDragDataToVWBStep(dragData, position);
|
||||
|
||||
if (vwbStep) {
|
||||
// C'est une action VWB, l'ajouter au workflow
|
||||
onStepAdd?.(vwbStep);
|
||||
|
||||
setNotification({
|
||||
open: true,
|
||||
message: `Action VWB "${vwbStep.name}" ajoutée au workflow`,
|
||||
severity: 'success',
|
||||
});
|
||||
} else {
|
||||
// Pas une action VWB, laisser le Canvas principal gérer
|
||||
// (Cette logique sera gérée par le composant parent)
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Erreur lors de l\'ajout de l\'action VWB';
|
||||
onError?.(errorMessage);
|
||||
|
||||
setNotification({
|
||||
open: true,
|
||||
message: errorMessage,
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
}, [screenToFlowPosition, vwbMethods, onStepAdd, onError]);
|
||||
|
||||
// Gestionnaire de dragover pour permettre le drop
|
||||
const handleDragOver = useCallback((event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
}, []);
|
||||
|
||||
// Fermer la notification
|
||||
const handleCloseNotification = useCallback(() => {
|
||||
setNotification(prev => ({ ...prev, open: false }));
|
||||
}, []);
|
||||
|
||||
// Convertir une étape en nœud ReactFlow avec support VWB
|
||||
const convertStepToNode = useCallback((step: Step): Node => {
|
||||
const nodeData = {
|
||||
label: step.name,
|
||||
stepType: step.type,
|
||||
executionState: step.executionState || StepExecutionState.IDLE,
|
||||
validationErrors: step.validationErrors || [],
|
||||
isSelected: false,
|
||||
parameters: step.data.parameters,
|
||||
isVWBCatalogAction: step.data.isVWBCatalogAction || false,
|
||||
vwbActionId: step.data.vwbActionId,
|
||||
};
|
||||
|
||||
return {
|
||||
id: step.id,
|
||||
type: 'stepNode', // Type de nœud personnalisé
|
||||
position: step.position,
|
||||
data: nodeData,
|
||||
draggable: true,
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Valider une étape VWB
|
||||
const validateVWBStep = useCallback(async (step: Step) => {
|
||||
if (!step.data.isVWBCatalogAction) return;
|
||||
|
||||
try {
|
||||
const isValid = await vwbMethods.validateVWBStep(step);
|
||||
|
||||
if (!isValid) {
|
||||
setNotification({
|
||||
open: true,
|
||||
message: `L'étape "${step.name}" contient des erreurs de configuration`,
|
||||
severity: 'warning',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la validation de l\'étape VWB:', error);
|
||||
}
|
||||
}, [vwbMethods]);
|
||||
|
||||
// Mémoriser les propriétés d'intégration
|
||||
const integrationProps = useMemo(() => ({
|
||||
onDrop: handleDrop,
|
||||
onDragOver: handleDragOver,
|
||||
convertStepToNode,
|
||||
validateVWBStep,
|
||||
isVWBIntegrationActive: true,
|
||||
}), [handleDrop, handleDragOver, convertStepToNode, validateVWBStep]);
|
||||
|
||||
return (
|
||||
<Box sx={{ width: '100%', height: '100%', position: 'relative' }}>
|
||||
{/* Passer les propriétés d'intégration aux enfants */}
|
||||
{React.Children.map(children, (child) => {
|
||||
if (React.isValidElement(child)) {
|
||||
return React.cloneElement(child, integrationProps);
|
||||
}
|
||||
return child;
|
||||
})}
|
||||
|
||||
{/* Indicateur d'état du service VWB */}
|
||||
{vwbState.error && (
|
||||
<Alert
|
||||
severity="warning"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 10,
|
||||
right: 10,
|
||||
zIndex: 1000,
|
||||
maxWidth: 300,
|
||||
}}
|
||||
>
|
||||
Service VWB indisponible : {vwbState.error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Indicateur de chargement VWB */}
|
||||
{vwbState.isLoading && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 10,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 1000,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
padding: 1,
|
||||
borderRadius: 1,
|
||||
boxShadow: 1,
|
||||
}}
|
||||
>
|
||||
Chargement de l'action VWB...
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Notifications */}
|
||||
<Snackbar
|
||||
open={notification.open}
|
||||
autoHideDuration={4000}
|
||||
onClose={handleCloseNotification}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
>
|
||||
<Alert
|
||||
onClose={handleCloseNotification}
|
||||
severity={notification.severity}
|
||||
sx={{ width: '100%' }}
|
||||
>
|
||||
{notification.message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default VWBCanvasIntegration;
|
||||
|
||||
/**
|
||||
* Hook pour utiliser l'intégration VWB dans un composant Canvas
|
||||
*/
|
||||
export const useVWBCanvasIntegration = () => {
|
||||
const { state, methods } = useVWBStepIntegration();
|
||||
|
||||
return {
|
||||
vwbState: state,
|
||||
vwbMethods: methods,
|
||||
isVWBAvailable: !state.error && !state.isLoading,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,558 @@
|
||||
/**
|
||||
* Extension VWB pour StepNode - États visuels avancés pour les actions VisionOnly
|
||||
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
|
||||
*
|
||||
* Cette extension ajoute des animations, indicateurs de progression et états visuels
|
||||
* spécialisés pour les actions VWB avec feedback en temps réel.
|
||||
*/
|
||||
|
||||
import React, { memo, useState, useEffect } from 'react';
|
||||
import { Handle, Position } from '@xyflow/react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Chip,
|
||||
Tooltip,
|
||||
LinearProgress,
|
||||
CircularProgress,
|
||||
Fade,
|
||||
keyframes,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
PlayArrow as RunningIcon,
|
||||
CheckCircle as SuccessIcon,
|
||||
Error as ErrorIcon,
|
||||
Warning as WarningIcon,
|
||||
Pause as PausedIcon,
|
||||
Visibility as VWBIcon,
|
||||
Speed as PerformanceIcon,
|
||||
BugReport as DebugIcon,
|
||||
Timer as TimerIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
// Import des types
|
||||
import {
|
||||
StepNodeData,
|
||||
StepExecutionState,
|
||||
ValidationError,
|
||||
Evidence
|
||||
} from '../../types';
|
||||
|
||||
// Animations CSS-in-JS
|
||||
const pulseAnimation = keyframes`
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
opacity: 0.8;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const glowAnimation = keyframes`
|
||||
0% {
|
||||
box-shadow: 0 0 5px rgba(33, 150, 243, 0.5);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 20px rgba(33, 150, 243, 0.8);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 5px rgba(33, 150, 243, 0.5);
|
||||
}
|
||||
`;
|
||||
|
||||
const successPulse = keyframes`
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(76, 175, 80, 0.7);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 10px rgba(76, 175, 80, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(76, 175, 80, 0);
|
||||
}
|
||||
`;
|
||||
|
||||
const errorShake = keyframes`
|
||||
0%, 100% { transform: translateX(0); }
|
||||
10%, 30%, 50%, 70%, 90% { transform: translateX(-2px); }
|
||||
20%, 40%, 60%, 80% { transform: translateX(2px); }
|
||||
`;
|
||||
|
||||
// Interface étendue pour les données VWB
|
||||
interface VWBStepNodeData extends StepNodeData {
|
||||
// Données d'exécution VWB
|
||||
executionProgress?: number;
|
||||
executionDuration?: number;
|
||||
evidence?: Evidence[];
|
||||
vwbActionId?: string;
|
||||
isVWBStep?: boolean;
|
||||
|
||||
// États d'exécution avancés
|
||||
isPaused?: boolean;
|
||||
isDebugging?: boolean;
|
||||
retryCount?: number;
|
||||
|
||||
// Métriques de performance
|
||||
averageExecutionTime?: number;
|
||||
successRate?: number;
|
||||
lastExecutionTime?: number;
|
||||
}
|
||||
|
||||
// Couleurs étendues pour les états VWB
|
||||
const vwbExecutionStateColors = {
|
||||
[StepExecutionState.IDLE]: '#9e9e9e',
|
||||
[StepExecutionState.RUNNING]: '#2196f3',
|
||||
[StepExecutionState.SUCCESS]: '#4caf50',
|
||||
[StepExecutionState.ERROR]: '#f44336',
|
||||
[StepExecutionState.SKIPPED]: '#ff9800',
|
||||
[StepExecutionState.PAUSED]: '#9c27b0',
|
||||
};
|
||||
|
||||
// Icônes étendues pour les états VWB
|
||||
const vwbExecutionStateIcons = {
|
||||
[StepExecutionState.IDLE]: null,
|
||||
[StepExecutionState.RUNNING]: <RunningIcon fontSize="small" />,
|
||||
[StepExecutionState.SUCCESS]: <SuccessIcon fontSize="small" />,
|
||||
[StepExecutionState.ERROR]: <ErrorIcon fontSize="small" />,
|
||||
[StepExecutionState.SKIPPED]: <WarningIcon fontSize="small" />,
|
||||
[StepExecutionState.PAUSED]: <PausedIcon fontSize="small" />,
|
||||
};
|
||||
|
||||
/**
|
||||
* Composant StepNode étendu pour les actions VWB
|
||||
*/
|
||||
interface VWBStepNodeExtensionProps {
|
||||
data: any;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
const VWBStepNodeExtension: React.FC<VWBStepNodeExtensionProps> = ({ data, selected }) => {
|
||||
const stepData = data as VWBStepNodeData;
|
||||
const [showProgress, setShowProgress] = useState(false);
|
||||
const [animationKey, setAnimationKey] = useState(0);
|
||||
|
||||
const {
|
||||
label,
|
||||
stepType,
|
||||
executionState = StepExecutionState.IDLE,
|
||||
validationErrors = [],
|
||||
isSelected = false,
|
||||
isVWBCatalogAction = false,
|
||||
isVWBStep = false,
|
||||
executionProgress = 0,
|
||||
executionDuration = 0,
|
||||
evidence = [],
|
||||
vwbActionId,
|
||||
isPaused = false,
|
||||
isDebugging = false,
|
||||
retryCount = 0,
|
||||
averageExecutionTime,
|
||||
successRate,
|
||||
} = stepData || {};
|
||||
|
||||
// Déclencher les animations lors des changements d'état
|
||||
useEffect(() => {
|
||||
if (executionState === StepExecutionState.RUNNING) {
|
||||
setShowProgress(true);
|
||||
} else {
|
||||
setShowProgress(false);
|
||||
}
|
||||
|
||||
// Déclencher une nouvelle animation
|
||||
setAnimationKey(prev => prev + 1);
|
||||
}, [executionState]);
|
||||
|
||||
// Vérification de sécurité
|
||||
if (!stepData) {
|
||||
return (
|
||||
<Box sx={{ p: 2, border: '1px solid red', borderRadius: 1 }}>
|
||||
<Typography color="error">Données manquantes</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Déterminer si c'est une action VWB
|
||||
const isVWBAction = isVWBCatalogAction || isVWBStep || Boolean(vwbActionId);
|
||||
|
||||
// Couleur de bordure avec logique VWB
|
||||
const getBorderColor = () => {
|
||||
if (validationErrors.some((e: ValidationError) => e.severity === 'error')) {
|
||||
return '#f44336';
|
||||
}
|
||||
if (validationErrors.some((e: ValidationError) => e.severity === 'warning')) {
|
||||
return '#ff9800';
|
||||
}
|
||||
if (selected || isSelected) {
|
||||
return isVWBAction ? '#1976d2' : '#1976d2';
|
||||
}
|
||||
return vwbExecutionStateColors[executionState as StepExecutionState];
|
||||
};
|
||||
|
||||
// Couleur de fond avec animations
|
||||
const getBackgroundColor = () => {
|
||||
switch (executionState) {
|
||||
case StepExecutionState.RUNNING:
|
||||
return isVWBAction ? '#e3f2fd' : '#e3f2fd';
|
||||
case StepExecutionState.SUCCESS:
|
||||
return isVWBAction ? '#e8f5e8' : '#e8f5e8';
|
||||
case StepExecutionState.ERROR:
|
||||
return '#ffebee';
|
||||
case StepExecutionState.PAUSED:
|
||||
return '#f3e5f5';
|
||||
case StepExecutionState.SKIPPED:
|
||||
return '#fff3e0';
|
||||
default:
|
||||
return isVWBAction ? '#fafafa' : '#ffffff';
|
||||
}
|
||||
};
|
||||
|
||||
// Styles d'animation selon l'état
|
||||
const getAnimationStyles = () => {
|
||||
const baseStyles = {
|
||||
transition: 'all 0.3s ease-in-out',
|
||||
};
|
||||
|
||||
switch (executionState) {
|
||||
case StepExecutionState.RUNNING:
|
||||
return {
|
||||
...baseStyles,
|
||||
animation: `${pulseAnimation} 2s infinite, ${glowAnimation} 3s infinite`,
|
||||
};
|
||||
case StepExecutionState.SUCCESS:
|
||||
return {
|
||||
...baseStyles,
|
||||
animation: `${successPulse} 1s ease-out`,
|
||||
};
|
||||
case StepExecutionState.ERROR:
|
||||
return {
|
||||
...baseStyles,
|
||||
animation: `${errorShake} 0.5s ease-in-out`,
|
||||
};
|
||||
default:
|
||||
return baseStyles;
|
||||
}
|
||||
};
|
||||
|
||||
// Messages d'erreur pour les tooltips
|
||||
const errorMessages = validationErrors
|
||||
.filter((e: ValidationError) => e.severity === 'error')
|
||||
.map((e: ValidationError) => e.message)
|
||||
.join(', ');
|
||||
|
||||
const warningMessages = validationErrors
|
||||
.filter((e: ValidationError) => e.severity === 'warning')
|
||||
.map((e: ValidationError) => e.message)
|
||||
.join(', ');
|
||||
|
||||
// Formater la durée
|
||||
const formatDuration = (ms: number): string => {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
return `${seconds}s`;
|
||||
};
|
||||
|
||||
// Contenu du tooltip étendu
|
||||
const getTooltipContent = () => {
|
||||
const parts = [];
|
||||
|
||||
if (isVWBAction) {
|
||||
parts.push(`Action VWB: ${vwbActionId || stepType}`);
|
||||
}
|
||||
|
||||
if (executionDuration > 0) {
|
||||
parts.push(`Durée: ${formatDuration(executionDuration)}`);
|
||||
}
|
||||
|
||||
if (evidence.length > 0) {
|
||||
parts.push(`${evidence.length} Evidence générées`);
|
||||
}
|
||||
|
||||
if (retryCount > 0) {
|
||||
parts.push(`${retryCount} tentatives`);
|
||||
}
|
||||
|
||||
if (successRate !== undefined) {
|
||||
parts.push(`Taux de succès: ${Math.round(successRate)}%`);
|
||||
}
|
||||
|
||||
if (errorMessages) {
|
||||
parts.push(`Erreurs: ${errorMessages}`);
|
||||
}
|
||||
|
||||
if (warningMessages) {
|
||||
parts.push(`Avertissements: ${warningMessages}`);
|
||||
}
|
||||
|
||||
return parts.join('\n');
|
||||
};
|
||||
|
||||
// Style commun pour les handles
|
||||
const handleStyle = {
|
||||
background: getBorderColor(),
|
||||
width: 10,
|
||||
height: 10,
|
||||
border: '2px solid white',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip title={getTooltipContent()} arrow placement="top">
|
||||
<Box
|
||||
key={animationKey}
|
||||
sx={{
|
||||
minWidth: isVWBAction ? 140 : 120,
|
||||
minHeight: isVWBAction ? 55 : 50,
|
||||
maxWidth: 180,
|
||||
backgroundColor: getBackgroundColor(),
|
||||
border: `2px solid ${getBorderColor()}`,
|
||||
borderRadius: 2,
|
||||
boxShadow: selected || isSelected
|
||||
? '0 4px 12px rgba(0,0,0,0.15)'
|
||||
: '0 2px 8px rgba(0,0,0,0.1)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
...getAnimationStyles(),
|
||||
'&:hover': {
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.2)',
|
||||
transform: 'translateY(-1px)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Barre de progression pour les actions en cours */}
|
||||
{showProgress && executionState === StepExecutionState.RUNNING && (
|
||||
<Box sx={{ position: 'absolute', top: 0, left: 0, right: 0 }}>
|
||||
<LinearProgress
|
||||
variant={executionProgress > 0 ? 'determinate' : 'indeterminate'}
|
||||
value={executionProgress}
|
||||
sx={{
|
||||
height: 3,
|
||||
backgroundColor: 'rgba(0,0,0,0.1)',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
backgroundColor: isVWBAction ? '#1976d2' : '#2196f3',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Poignées de connexion - Haut (entrée principale) */}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
id="top"
|
||||
style={handleStyle}
|
||||
/>
|
||||
|
||||
{/* Poignées de connexion - Gauche (entrée alternative) */}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
id="left"
|
||||
style={handleStyle}
|
||||
/>
|
||||
|
||||
{/* Poignées de connexion - Droite (sortie alternative) */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id="right"
|
||||
style={handleStyle}
|
||||
/>
|
||||
|
||||
{/* Poignées de connexion - Bas (sortie principale) */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
id="bottom"
|
||||
style={handleStyle}
|
||||
/>
|
||||
|
||||
{/* Contenu principal */}
|
||||
<Box sx={{ p: 1.5, pt: showProgress ? 2 : 1.5 }}>
|
||||
{/* En-tête avec type et badges */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 1 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, flex: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
<Chip
|
||||
label={stepType}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
fontSize: '0.75rem',
|
||||
height: 20,
|
||||
borderColor: getBorderColor(),
|
||||
color: getBorderColor(),
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Badge VWB */}
|
||||
{isVWBAction && (
|
||||
<Chip
|
||||
label="VWB"
|
||||
size="small"
|
||||
color="primary"
|
||||
icon={<VWBIcon sx={{ fontSize: '0.7rem' }} />}
|
||||
sx={{
|
||||
fontSize: '0.65rem',
|
||||
height: 18,
|
||||
minWidth: 40,
|
||||
'& .MuiChip-label': {
|
||||
px: 0.5,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Badge Debug */}
|
||||
{isDebugging && (
|
||||
<Chip
|
||||
label="DEBUG"
|
||||
size="small"
|
||||
color="warning"
|
||||
icon={<DebugIcon sx={{ fontSize: '0.6rem' }} />}
|
||||
sx={{
|
||||
fontSize: '0.6rem',
|
||||
height: 16,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Badge Evidence */}
|
||||
{evidence.length > 0 && (
|
||||
<Chip
|
||||
label={evidence.length}
|
||||
size="small"
|
||||
color="info"
|
||||
sx={{
|
||||
fontSize: '0.6rem',
|
||||
height: 16,
|
||||
minWidth: 20,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Métriques de performance pour VWB */}
|
||||
{isVWBAction && (averageExecutionTime || successRate !== undefined) && (
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
{averageExecutionTime && (
|
||||
<Chip
|
||||
label={formatDuration(averageExecutionTime)}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
icon={<TimerIcon sx={{ fontSize: '0.6rem' }} />}
|
||||
sx={{
|
||||
fontSize: '0.6rem',
|
||||
height: 16,
|
||||
color: 'text.secondary',
|
||||
borderColor: 'text.secondary',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{successRate !== undefined && (
|
||||
<Chip
|
||||
label={`${Math.round(successRate)}%`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
icon={<PerformanceIcon sx={{ fontSize: '0.6rem' }} />}
|
||||
sx={{
|
||||
fontSize: '0.6rem',
|
||||
height: 16,
|
||||
color: successRate >= 90 ? 'success.main' : 'warning.main',
|
||||
borderColor: successRate >= 90 ? 'success.main' : 'warning.main',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Icône d'état avec animation */}
|
||||
<Fade in={Boolean(vwbExecutionStateIcons[executionState as StepExecutionState])} timeout={300}>
|
||||
<Box sx={{
|
||||
color: vwbExecutionStateColors[executionState as StepExecutionState],
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
}}>
|
||||
{executionState === StepExecutionState.RUNNING && (
|
||||
<CircularProgress size={16} thickness={4} />
|
||||
)}
|
||||
{vwbExecutionStateIcons[executionState as StepExecutionState]}
|
||||
</Box>
|
||||
</Fade>
|
||||
</Box>
|
||||
|
||||
{/* Label de l'étape */}
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontWeight: 500,
|
||||
color: 'text.primary',
|
||||
lineHeight: 1.2,
|
||||
wordBreak: 'break-word',
|
||||
fontSize: isVWBAction ? '0.85rem' : '0.8rem',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
|
||||
{/* Informations d'exécution en temps réel */}
|
||||
{executionState === StepExecutionState.RUNNING && executionDuration > 0 && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
display: 'block',
|
||||
mt: 0.5,
|
||||
fontSize: '0.7rem',
|
||||
}}
|
||||
>
|
||||
{formatDuration(executionDuration)}
|
||||
{executionProgress > 0 && ` (${Math.round(executionProgress)}%)`}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Compteur de retry */}
|
||||
{retryCount > 0 && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: 'warning.main',
|
||||
display: 'block',
|
||||
mt: 0.5,
|
||||
fontSize: '0.7rem',
|
||||
}}
|
||||
>
|
||||
Tentative {retryCount}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Indicateur de pause */}
|
||||
{isPaused && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
right: 4,
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'warning.main',
|
||||
animation: `${pulseAnimation} 1s infinite`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(VWBStepNodeExtension);
|
||||
709
visual_workflow_builder/frontend/src/components/Canvas/index.tsx
Normal file
709
visual_workflow_builder/frontend/src/components/Canvas/index.tsx
Normal file
@@ -0,0 +1,709 @@
|
||||
/**
|
||||
* Composant Canvas Principal - Interface de création de workflows visuels
|
||||
* Auteur : Dom, Alice, Kiro - 08 janvier 2026
|
||||
*
|
||||
* Ce composant gère le rendu visuel des workflows avec @xyflow/react,
|
||||
* la sélection d'étapes, le déplacement en temps réel et la minimap.
|
||||
* Optimisé pour maintenir 60fps lors des interactions.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, memo } from 'react';
|
||||
import {
|
||||
ReactFlow,
|
||||
Node,
|
||||
Edge,
|
||||
addEdge,
|
||||
Connection,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
Controls,
|
||||
MiniMap,
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
NodeTypes,
|
||||
EdgeTypes,
|
||||
MarkerType,
|
||||
useReactFlow,
|
||||
} from '@xyflow/react';
|
||||
import { Box, Paper, Typography } from '@mui/material';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
|
||||
// Composants de nœuds personnalisés
|
||||
import StepNode from './StepNode';
|
||||
|
||||
// Import des types partagés
|
||||
import {
|
||||
CanvasProps,
|
||||
Step,
|
||||
StepExecutionState,
|
||||
StepNodeData,
|
||||
StepType
|
||||
} from '../../types';
|
||||
|
||||
// Types de nœuds personnalisés - mémorisés pour éviter les re-créations
|
||||
const nodeTypes: NodeTypes = {
|
||||
stepNode: StepNode,
|
||||
};
|
||||
|
||||
// Types d'arêtes personnalisés - mémorisés pour éviter les re-créations
|
||||
const edgeTypes: EdgeTypes = {};
|
||||
|
||||
// Options par défaut pour les arêtes - mémorisées pour éviter les re-créations
|
||||
const defaultEdgeOptions = {
|
||||
type: 'smoothstep',
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
width: 20,
|
||||
height: 20,
|
||||
color: '#1976d2',
|
||||
},
|
||||
style: {
|
||||
strokeWidth: 2,
|
||||
stroke: '#1976d2',
|
||||
},
|
||||
// Permettre la sélection et suppression des edges
|
||||
focusable: true,
|
||||
deletable: true,
|
||||
// Style au survol
|
||||
interactionWidth: 20, // Zone de clic plus large
|
||||
};
|
||||
|
||||
// Styles mémorisés pour éviter les re-créations d'objets
|
||||
const canvasStyles = {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: '#fafafa',
|
||||
position: 'relative' as const,
|
||||
// Styles pour les flèches (edges) sélectionnées - ROUGE VIVE pour éviter confusion
|
||||
'& .react-flow__edge.selected .react-flow__edge-path': {
|
||||
stroke: '#f44336 !important', // Rouge Material UI
|
||||
strokeWidth: '4px !important',
|
||||
},
|
||||
'& .react-flow__edge.selected .react-flow__edge-interaction': {
|
||||
stroke: '#f44336 !important',
|
||||
},
|
||||
'& .react-flow__edge.selected marker': {
|
||||
fill: '#f44336 !important',
|
||||
},
|
||||
// Style au survol des edges pour feedback visuel
|
||||
'& .react-flow__edge:hover .react-flow__edge-path': {
|
||||
stroke: '#ff9800 !important', // Orange au survol
|
||||
strokeWidth: '3px !important',
|
||||
},
|
||||
'& .react-flow__edge:hover marker': {
|
||||
fill: '#ff9800 !important',
|
||||
},
|
||||
} as const;
|
||||
|
||||
const paperStyles = {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
position: 'relative' as const,
|
||||
} as const;
|
||||
|
||||
const emptyStateBoxStyles = {
|
||||
position: 'absolute' as const,
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
textAlign: 'center' as const,
|
||||
zIndex: 1000,
|
||||
pointerEvents: 'none' as const,
|
||||
} as const;
|
||||
|
||||
const emptyStatePaperStyles = {
|
||||
p: 4,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
} as const;
|
||||
|
||||
const executionIndicatorBoxStyles = {
|
||||
position: 'absolute' as const,
|
||||
top: 16,
|
||||
right: 16,
|
||||
zIndex: 1000,
|
||||
} as const;
|
||||
|
||||
const executionIndicatorPaperStyles = {
|
||||
p: 2,
|
||||
backgroundColor: '#2196f3',
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
} as const;
|
||||
|
||||
const executionIndicatorAnimationStyles = {
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'white',
|
||||
animation: 'pulse 1s infinite',
|
||||
'@keyframes pulse': {
|
||||
'0%': { opacity: 1 },
|
||||
'50%': { opacity: 0.5 },
|
||||
'100%': { opacity: 1 },
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Composant Canvas principal pour l'édition de workflows
|
||||
*/
|
||||
const Canvas: React.FC<CanvasProps> = ({
|
||||
workflow,
|
||||
selectedStep,
|
||||
executionState,
|
||||
onStepSelect,
|
||||
onStepMove,
|
||||
onConnection,
|
||||
onConnectionDelete,
|
||||
onStepAdd,
|
||||
onStepDelete,
|
||||
}) => {
|
||||
const { fitView, getViewport } = useReactFlow();
|
||||
|
||||
// Conversion des étapes du workflow en nœuds ReactFlow
|
||||
const initialNodes: Node[] = useMemo(() => {
|
||||
if (!workflow?.steps) return [];
|
||||
|
||||
return workflow.steps.map((step) => ({
|
||||
id: step.id,
|
||||
type: 'stepNode',
|
||||
position: step.position,
|
||||
data: {
|
||||
label: step.name,
|
||||
stepType: step.type,
|
||||
executionState: step.executionState || StepExecutionState.IDLE,
|
||||
validationErrors: step.validationErrors || [],
|
||||
isSelected: selectedStep?.id === step.id,
|
||||
parameters: step.data?.parameters || {},
|
||||
// Préserver les flags VWB
|
||||
isVWBCatalogAction: step.data?.isVWBCatalogAction || false,
|
||||
vwbActionId: step.data?.vwbActionId || undefined,
|
||||
} as StepNodeData,
|
||||
selected: selectedStep?.id === step.id,
|
||||
}));
|
||||
}, [workflow?.steps, selectedStep]);
|
||||
|
||||
// Conversion des connexions du workflow en arêtes ReactFlow
|
||||
const initialEdges: Edge[] = useMemo(() => {
|
||||
if (!workflow?.connections) return [];
|
||||
|
||||
return workflow.connections.map((connection) => ({
|
||||
id: connection.id,
|
||||
source: connection.source,
|
||||
target: connection.target,
|
||||
...defaultEdgeOptions,
|
||||
}));
|
||||
}, [workflow?.connections]);
|
||||
|
||||
// États locaux pour les nœuds et connexions
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Gestionnaire d'événements clavier pour le Canvas
|
||||
const handleKeyDown = useCallback((event: KeyboardEvent) => {
|
||||
// Ignorer si l'utilisateur tape dans un champ de saisie
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.key) {
|
||||
case 'Delete':
|
||||
case 'Backspace':
|
||||
// Supprimer les nœuds sélectionnés
|
||||
const selectedNodes = nodes.filter(node => node.selected);
|
||||
if (selectedNodes.length > 0 && onStepDelete) {
|
||||
event.preventDefault();
|
||||
selectedNodes.forEach(node => onStepDelete(node.id));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'Escape':
|
||||
// Désélectionner tous les nœuds
|
||||
event.preventDefault();
|
||||
setNodes(nodes => nodes.map(node => ({ ...node, selected: false })));
|
||||
if (onStepSelect) {
|
||||
onStepSelect(null);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'a':
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
// Sélectionner tous les nœuds
|
||||
event.preventDefault();
|
||||
setNodes(nodes => nodes.map(node => ({ ...node, selected: true })));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ArrowUp':
|
||||
case 'ArrowDown':
|
||||
case 'ArrowLeft':
|
||||
case 'ArrowRight':
|
||||
// Déplacer les nœuds sélectionnés
|
||||
const selectedNodesForMove = nodes.filter(node => node.selected);
|
||||
if (selectedNodesForMove.length > 0) {
|
||||
event.preventDefault();
|
||||
const moveDistance = event.shiftKey ? 50 : 10;
|
||||
|
||||
selectedNodesForMove.forEach(node => {
|
||||
const newPosition = { ...node.position };
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowUp': newPosition.y -= moveDistance; break;
|
||||
case 'ArrowDown': newPosition.y += moveDistance; break;
|
||||
case 'ArrowLeft': newPosition.x -= moveDistance; break;
|
||||
case 'ArrowRight': newPosition.x += moveDistance; break;
|
||||
}
|
||||
|
||||
if (onStepMove) {
|
||||
onStepMove(node.id, newPosition);
|
||||
}
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'Tab':
|
||||
// Navigation entre les nœuds
|
||||
if (nodes.length > 0) {
|
||||
event.preventDefault();
|
||||
const currentSelectedIndex = nodes.findIndex(node => node.selected);
|
||||
let nextIndex: number;
|
||||
|
||||
if (event.shiftKey) {
|
||||
// Tab + Shift : précédent
|
||||
nextIndex = currentSelectedIndex <= 0 ? nodes.length - 1 : currentSelectedIndex - 1;
|
||||
} else {
|
||||
// Tab : suivant
|
||||
nextIndex = currentSelectedIndex >= nodes.length - 1 ? 0 : currentSelectedIndex + 1;
|
||||
}
|
||||
|
||||
const nextNode = nodes[nextIndex];
|
||||
if (nextNode && onStepSelect) {
|
||||
// Désélectionner tous et sélectionner le suivant
|
||||
setNodes(nodes => nodes.map((node, index) => ({
|
||||
...node,
|
||||
selected: index === nextIndex
|
||||
})));
|
||||
|
||||
// Trouver l'étape correspondante
|
||||
const step = workflow?.steps.find(s => s.id === nextNode.id);
|
||||
if (step) {
|
||||
onStepSelect(step);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'Enter':
|
||||
// Activer l'édition du nœud sélectionné
|
||||
const selectedNode = nodes.find(node => node.selected);
|
||||
if (selectedNode) {
|
||||
event.preventDefault();
|
||||
const step = workflow?.steps.find(s => s.id === selectedNode.id);
|
||||
if (step && onStepSelect) {
|
||||
onStepSelect(step);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}, [nodes, setNodes, onStepDelete, onStepSelect, onStepMove, workflow?.steps]);
|
||||
|
||||
// Attacher/détacher les gestionnaires d'événements clavier
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) {
|
||||
canvas.addEventListener('keydown', handleKeyDown);
|
||||
// Rendre le canvas focusable
|
||||
canvas.setAttribute('tabindex', '0');
|
||||
|
||||
return () => {
|
||||
canvas.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}
|
||||
}, [handleKeyDown]);
|
||||
|
||||
// Synchroniser les nœuds avec les changements du workflow - optimisé
|
||||
useEffect(() => {
|
||||
console.log('🔄 [Canvas] Synchronisation nœuds:', {
|
||||
currentCount: nodes.length,
|
||||
newCount: initialNodes.length,
|
||||
workflowId: workflow?.id,
|
||||
});
|
||||
|
||||
// Forcer la mise à jour si le nombre ou les IDs changent
|
||||
const currentNodeIds = nodes.map(n => n.id).sort().join(',');
|
||||
const newNodeIds = initialNodes.map(n => n.id).sort().join(',');
|
||||
|
||||
if (currentNodeIds !== newNodeIds || nodes.length !== initialNodes.length) {
|
||||
console.log('✅ [Canvas] Mise à jour des nœuds:', initialNodes.length);
|
||||
setNodes(initialNodes);
|
||||
// Ajuster la vue après chargement
|
||||
setTimeout(() => fitView({ padding: 0.2 }), 100);
|
||||
}
|
||||
}, [initialNodes, setNodes, nodes, workflow?.id, fitView]);
|
||||
|
||||
// Synchroniser les arêtes avec les changements du workflow - optimisé
|
||||
useEffect(() => {
|
||||
// Éviter les re-rendus inutiles en comparant les données
|
||||
const currentEdgeIds = edges.map(e => e.id).sort().join(',');
|
||||
const newEdgeIds = initialEdges.map(e => e.id).sort().join(',');
|
||||
|
||||
if (currentEdgeIds !== newEdgeIds || edges.length !== initialEdges.length) {
|
||||
setEdges(initialEdges);
|
||||
}
|
||||
}, [initialEdges, setEdges, edges]);
|
||||
|
||||
// Gestionnaire de connexion entre étapes
|
||||
const onConnect = useCallback(
|
||||
(params: Connection) => {
|
||||
const newEdge = {
|
||||
...params,
|
||||
...defaultEdgeOptions,
|
||||
};
|
||||
setEdges((eds) => addEdge(newEdge, eds));
|
||||
|
||||
if (onConnection && params.source && params.target) {
|
||||
onConnection(params.source, params.target);
|
||||
}
|
||||
},
|
||||
[onConnection, setEdges]
|
||||
);
|
||||
|
||||
// Gestionnaire de sélection d'étape
|
||||
const onNodeClick = useCallback(
|
||||
(event: React.MouseEvent, node: Node) => {
|
||||
if (onStepSelect) {
|
||||
const nodeData = node.data as StepNodeData;
|
||||
const step: Step = {
|
||||
id: node.id,
|
||||
type: nodeData?.stepType || 'click',
|
||||
name: nodeData?.label || '',
|
||||
position: node.position,
|
||||
data: {
|
||||
label: nodeData?.label || '',
|
||||
stepType: nodeData?.stepType || 'click',
|
||||
parameters: nodeData?.parameters || {},
|
||||
isSelected: true,
|
||||
// Préserver les flags VWB s'ils existent
|
||||
...(nodeData?.isVWBCatalogAction && {
|
||||
isVWBCatalogAction: true,
|
||||
vwbActionId: nodeData?.vwbActionId || nodeData?.stepType,
|
||||
}),
|
||||
},
|
||||
executionState: nodeData?.executionState,
|
||||
validationErrors: nodeData?.validationErrors,
|
||||
};
|
||||
console.log('🖱️ [Canvas] Sélection étape:', {
|
||||
id: step.id,
|
||||
type: step.type,
|
||||
isVWB: step.data.isVWBCatalogAction,
|
||||
vwbActionId: step.data.vwbActionId
|
||||
});
|
||||
onStepSelect(step);
|
||||
}
|
||||
},
|
||||
[onStepSelect]
|
||||
);
|
||||
|
||||
// Gestionnaire de double-clic pour édition d'étape
|
||||
const onNodeDoubleClick = useCallback(
|
||||
(event: React.MouseEvent, node: Node) => {
|
||||
console.log('Double-clic sur étape:', node.id);
|
||||
// Sélectionner l'étape et ouvrir le panneau de propriétés
|
||||
if (onStepSelect) {
|
||||
const nodeData = node.data as StepNodeData;
|
||||
const step: Step = {
|
||||
id: node.id,
|
||||
type: nodeData?.stepType || 'click',
|
||||
name: nodeData?.label || '',
|
||||
position: node.position,
|
||||
data: {
|
||||
label: nodeData?.label || '',
|
||||
stepType: nodeData?.stepType || 'click',
|
||||
parameters: nodeData?.parameters || {},
|
||||
isSelected: true,
|
||||
// Préserver les flags VWB s'ils existent
|
||||
...(nodeData?.isVWBCatalogAction && {
|
||||
isVWBCatalogAction: true,
|
||||
vwbActionId: nodeData?.vwbActionId || nodeData?.stepType,
|
||||
}),
|
||||
},
|
||||
executionState: nodeData?.executionState,
|
||||
validationErrors: nodeData?.validationErrors,
|
||||
};
|
||||
onStepSelect(step);
|
||||
}
|
||||
},
|
||||
[onStepSelect]
|
||||
);
|
||||
|
||||
// Gestionnaire de déplacement d'étape
|
||||
const onNodeDragStop = useCallback(
|
||||
(event: React.MouseEvent, node: Node) => {
|
||||
if (onStepMove) {
|
||||
onStepMove(node.id, node.position);
|
||||
}
|
||||
},
|
||||
[onStepMove]
|
||||
);
|
||||
|
||||
// Gestionnaire de suppression de nœud
|
||||
const onNodesDelete = useCallback(
|
||||
(nodesToDelete: Node[]) => {
|
||||
if (onStepDelete) {
|
||||
nodesToDelete.forEach((node) => {
|
||||
onStepDelete(node.id);
|
||||
});
|
||||
}
|
||||
},
|
||||
[onStepDelete]
|
||||
);
|
||||
|
||||
// Gestionnaire de suppression de connexion (edge)
|
||||
const onEdgesDelete = useCallback(
|
||||
(edgesToDelete: Edge[]) => {
|
||||
if (onConnectionDelete) {
|
||||
edgesToDelete.forEach((edge) => {
|
||||
onConnectionDelete(edge.id);
|
||||
});
|
||||
}
|
||||
},
|
||||
[onConnectionDelete]
|
||||
);
|
||||
|
||||
// Liste des actions VWB connues du catalogue
|
||||
const knownVWBActions = useMemo(() => [
|
||||
'click_anchor', 'type_text', 'type_secret', 'wait_for_anchor',
|
||||
'extract_text', 'screenshot_evidence', 'scroll_to_anchor',
|
||||
'focus_anchor', 'hotkey', 'navigate_to_url', 'browser_back',
|
||||
'verify_element_exists', 'verify_text_content'
|
||||
], []);
|
||||
|
||||
// Fonction pour détecter si un type est une action VWB
|
||||
const isVWBActionType = useCallback((stepType: string): boolean => {
|
||||
// Vérifie si c'est un type connu du catalogue
|
||||
if (knownVWBActions.includes(stepType)) return true;
|
||||
// Vérifie les patterns VWB
|
||||
if (stepType.startsWith('catalog:')) return true;
|
||||
if (stepType.includes('_anchor')) return true;
|
||||
if (stepType.includes('_text') && stepType !== 'type') return true;
|
||||
if (stepType.includes('_secret')) return true;
|
||||
return false;
|
||||
}, [knownVWBActions]);
|
||||
|
||||
// Noms lisibles pour les actions VWB
|
||||
const getVWBActionName = useCallback((actionId: string): string => {
|
||||
const names: Record<string, string> = {
|
||||
'click_anchor': 'Cliquer sur Ancre',
|
||||
'type_text': 'Saisir Texte',
|
||||
'type_secret': 'Saisir Texte Secret',
|
||||
'wait_for_anchor': 'Attendre Ancre',
|
||||
'extract_text': 'Extraire Texte',
|
||||
'screenshot_evidence': 'Capture Evidence',
|
||||
'scroll_to_anchor': 'Défiler vers Ancre',
|
||||
'focus_anchor': 'Focaliser Ancre',
|
||||
'hotkey': 'Raccourci Clavier',
|
||||
'navigate_to_url': 'Naviguer vers URL',
|
||||
'browser_back': 'Retour Navigateur',
|
||||
'verify_element_exists': 'Vérifier Existence',
|
||||
'verify_text_content': 'Vérifier Contenu Texte',
|
||||
};
|
||||
return names[actionId] || actionId.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
||||
}, []);
|
||||
|
||||
// Gestionnaire de drop depuis la palette
|
||||
const onDrop = useCallback(
|
||||
(event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
let stepType = event.dataTransfer.getData('application/reactflow');
|
||||
if (!stepType || !onStepAdd) return;
|
||||
|
||||
// Gérer le format "catalog:action_id" de la palette
|
||||
let actualType = stepType;
|
||||
let isFromCatalog = false;
|
||||
if (stepType.startsWith('catalog:')) {
|
||||
actualType = stepType.replace('catalog:', '');
|
||||
isFromCatalog = true;
|
||||
}
|
||||
|
||||
// Obtenir la position relative au ReactFlow
|
||||
const reactFlowBounds = event.currentTarget.getBoundingClientRect();
|
||||
const { x: viewportX, y: viewportY, zoom } = getViewport();
|
||||
|
||||
// Calculer la position en tenant compte du zoom et du pan
|
||||
const position = {
|
||||
x: (event.clientX - reactFlowBounds.left - viewportX) / zoom,
|
||||
y: (event.clientY - reactFlowBounds.top - viewportY) / zoom,
|
||||
};
|
||||
|
||||
// Détecter si c'est une action VWB
|
||||
const isVWB = isFromCatalog || isVWBActionType(actualType);
|
||||
const stepName = isVWB ? getVWBActionName(actualType) : `Nouvelle étape ${actualType}`;
|
||||
|
||||
const newStep: Omit<Step, 'id'> = {
|
||||
type: actualType as StepType,
|
||||
name: stepName,
|
||||
position,
|
||||
data: {
|
||||
label: stepName,
|
||||
stepType: actualType as StepType,
|
||||
parameters: {},
|
||||
// Marquer les actions VWB avec les flags appropriés
|
||||
...(isVWB && {
|
||||
isVWBCatalogAction: true,
|
||||
vwbActionId: actualType,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
console.log('📦 [Canvas] Création étape:', {
|
||||
type: actualType,
|
||||
isVWB,
|
||||
isFromCatalog,
|
||||
data: newStep.data
|
||||
});
|
||||
|
||||
onStepAdd(newStep);
|
||||
},
|
||||
[onStepAdd, getViewport, isVWBActionType, getVWBActionName]
|
||||
);
|
||||
|
||||
const onDragOver = useCallback((event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
}, []);
|
||||
|
||||
// Déterminer si la minimap doit être affichée
|
||||
const shouldShowMinimap = nodes.length > 20;
|
||||
|
||||
// Message d'état vide
|
||||
const EmptyState = () => (
|
||||
<Box sx={emptyStateBoxStyles}>
|
||||
<Paper elevation={2} sx={emptyStatePaperStyles}>
|
||||
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||
Espace de travail vide
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Glissez des étapes depuis la palette pour commencer à créer votre workflow
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={canvasRef}
|
||||
sx={canvasStyles}
|
||||
onDrop={onDrop}
|
||||
onDragOver={onDragOver}
|
||||
role="application"
|
||||
aria-label="Zone de création de workflow"
|
||||
tabIndex={0}
|
||||
>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
onNodeClick={onNodeClick}
|
||||
onNodeDoubleClick={onNodeDoubleClick}
|
||||
onNodeDragStop={onNodeDragStop}
|
||||
onNodesDelete={onNodesDelete}
|
||||
onEdgesDelete={onEdgesDelete}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
defaultEdgeOptions={defaultEdgeOptions}
|
||||
fitView
|
||||
attributionPosition="bottom-left"
|
||||
deleteKeyCode={['Backspace', 'Delete']}
|
||||
multiSelectionKeyCode={['Meta', 'Ctrl']}
|
||||
edgesFocusable={true}
|
||||
edgesReconnectable={true}
|
||||
panOnScroll
|
||||
selectionOnDrag
|
||||
panOnDrag={[1, 2]} // Clic milieu et droit pour panoramique
|
||||
selectNodesOnDrag={false}
|
||||
>
|
||||
{/* Grille d'alignement */}
|
||||
<Background
|
||||
variant={BackgroundVariant.Dots}
|
||||
gap={20}
|
||||
size={1}
|
||||
color="#e0e0e0"
|
||||
/>
|
||||
|
||||
{/* Contrôles de navigation */}
|
||||
<Controls
|
||||
position="top-left"
|
||||
showZoom={true}
|
||||
showFitView={true}
|
||||
showInteractive={true}
|
||||
style={{
|
||||
backgroundColor: '#ffffff',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Minimap pour navigation dans les gros workflows */}
|
||||
{shouldShowMinimap && (
|
||||
<MiniMap
|
||||
position="bottom-right"
|
||||
zoomable
|
||||
pannable
|
||||
style={{
|
||||
backgroundColor: '#ffffff',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
nodeColor={(node) => {
|
||||
switch (node.data?.executionState) {
|
||||
case StepExecutionState.RUNNING:
|
||||
return '#2196f3';
|
||||
case StepExecutionState.SUCCESS:
|
||||
return '#4caf50';
|
||||
case StepExecutionState.ERROR:
|
||||
return '#f44336';
|
||||
default:
|
||||
return '#9e9e9e';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ReactFlow>
|
||||
|
||||
{/* État vide */}
|
||||
{nodes.length === 0 && <EmptyState />}
|
||||
|
||||
{/* Indicateur d'exécution */}
|
||||
{executionState?.status === 'running' && (
|
||||
<Box sx={executionIndicatorBoxStyles}>
|
||||
<Paper elevation={3} sx={executionIndicatorPaperStyles}>
|
||||
<Box sx={executionIndicatorAnimationStyles} />
|
||||
<Typography variant="body2">
|
||||
Exécution en cours...
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// Mémorisation du composant Canvas pour éviter les re-rendus inutiles
|
||||
export default memo(Canvas, (prevProps, nextProps) => {
|
||||
// Comparaison personnalisée pour optimiser les re-rendus
|
||||
return (
|
||||
prevProps.workflow?.id === nextProps.workflow?.id &&
|
||||
prevProps.workflow?.steps?.length === nextProps.workflow?.steps?.length &&
|
||||
prevProps.selectedStep?.id === nextProps.selectedStep?.id &&
|
||||
prevProps.executionState?.status === nextProps.executionState?.status &&
|
||||
JSON.stringify(prevProps.workflow?.connections) === JSON.stringify(nextProps.workflow?.connections)
|
||||
);
|
||||
});
|
||||
@@ -106,7 +106,7 @@ const CoachingSuggestionCard: React.FC<CoachingSuggestionCardProps> = ({
|
||||
{suggestion.screenshotPath && (
|
||||
<div className="suggestion-screenshot">
|
||||
<img
|
||||
src={`http://localhost:5000${suggestion.screenshotPath}`}
|
||||
src={`http://localhost:5001${suggestion.screenshotPath}`}
|
||||
alt="Target element"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
|
||||
@@ -143,7 +143,7 @@ export const CoachingPanel: React.FC<CoachingPanelProps> = ({
|
||||
const startSession = useCallback(
|
||||
async (wfId: string) => {
|
||||
try {
|
||||
const response = await fetch(`${serverUrl || 'http://localhost:5000'}/api/executions/coaching`, {
|
||||
const response = await fetch(`${serverUrl || 'http://localhost:5001'}/api/executions/coaching`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workflow_id: wfId }),
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Indicateur de Connexion API - Affichage stable de l'état de connexion
|
||||
* Auteur : Dom, Alice, Kiro - 09 janvier 2026
|
||||
*
|
||||
* Ce composant affiche l'état de connexion de l'API de manière non-intrusive
|
||||
* et stable, sans provoquer de "sauts" de page.
|
||||
*/
|
||||
|
||||
import React, { memo, useState, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Chip,
|
||||
Tooltip,
|
||||
IconButton,
|
||||
Snackbar,
|
||||
Alert,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
CloudDone as OnlineIcon,
|
||||
CloudOff as OfflineIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Sync as CheckingIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { useConnectionStatus } from '../../hooks/useConnectionStatus';
|
||||
|
||||
interface ConnectionIndicatorProps {
|
||||
/** Afficher en mode compact (icône seule) */
|
||||
compact?: boolean;
|
||||
/** Position de l'indicateur */
|
||||
position?: 'inline' | 'fixed';
|
||||
/** Afficher le bouton de rafraîchissement */
|
||||
showRefreshButton?: boolean;
|
||||
/** Callback quand l'état change */
|
||||
onStatusChange?: (isOnline: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composant d'indicateur de connexion API
|
||||
* Mémorisé pour éviter les re-rendus inutiles
|
||||
*/
|
||||
const ConnectionIndicator: React.FC<ConnectionIndicatorProps> = memo(({
|
||||
compact = false,
|
||||
position = 'inline',
|
||||
showRefreshButton = true,
|
||||
onStatusChange,
|
||||
}) => {
|
||||
const { status, isOnline, isOffline, isChecking, statusMessage, forceCheck } = useConnectionStatus({
|
||||
onStatusChange: (newStatus) => {
|
||||
if (onStatusChange) {
|
||||
onStatusChange(newStatus === 'online');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [showNotification, setShowNotification] = useState(false);
|
||||
const [notificationMessage, setNotificationMessage] = useState('');
|
||||
|
||||
// Gestionnaire de rafraîchissement
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setIsRefreshing(true);
|
||||
|
||||
try {
|
||||
const result = await forceCheck();
|
||||
setNotificationMessage(result ? 'Connexion rétablie' : 'API toujours hors ligne');
|
||||
setShowNotification(true);
|
||||
} catch (error) {
|
||||
setNotificationMessage('Erreur lors de la vérification');
|
||||
setShowNotification(true);
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}, [forceCheck]);
|
||||
|
||||
// Fermer la notification
|
||||
const handleCloseNotification = useCallback(() => {
|
||||
setShowNotification(false);
|
||||
}, []);
|
||||
|
||||
// Déterminer l'icône et la couleur
|
||||
const getIconAndColor = () => {
|
||||
if (isChecking || isRefreshing) {
|
||||
return {
|
||||
icon: <CheckingIcon sx={{ animation: 'spin 1s linear infinite' }} />,
|
||||
color: 'default' as const,
|
||||
label: 'Vérification...',
|
||||
};
|
||||
}
|
||||
|
||||
if (isOnline) {
|
||||
return {
|
||||
icon: <OnlineIcon />,
|
||||
color: 'success' as const,
|
||||
label: 'API connectée',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
icon: <OfflineIcon />,
|
||||
color: 'warning' as const,
|
||||
label: 'Mode hors ligne',
|
||||
};
|
||||
};
|
||||
|
||||
const { icon, color, label } = getIconAndColor();
|
||||
|
||||
// Style pour l'animation de rotation
|
||||
const spinKeyframes = `
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`;
|
||||
|
||||
// Rendu compact (icône seule)
|
||||
if (compact) {
|
||||
return (
|
||||
<>
|
||||
<style>{spinKeyframes}</style>
|
||||
<Tooltip title={statusMessage} arrow>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
}}
|
||||
>
|
||||
<Chip
|
||||
icon={icon}
|
||||
size="small"
|
||||
color={color}
|
||||
variant="outlined"
|
||||
sx={{
|
||||
minWidth: 'auto',
|
||||
'& .MuiChip-label': { display: 'none' },
|
||||
'& .MuiChip-icon': { margin: 0 },
|
||||
}}
|
||||
/>
|
||||
{showRefreshButton && isOffline && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
sx={{ padding: 0.5 }}
|
||||
>
|
||||
<RefreshIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Rendu complet
|
||||
return (
|
||||
<>
|
||||
<style>{spinKeyframes}</style>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
...(position === 'fixed' && {
|
||||
position: 'fixed',
|
||||
bottom: 16,
|
||||
right: 16,
|
||||
zIndex: 1000,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<Tooltip title={`${statusMessage} - Cliquer pour rafraîchir`} arrow>
|
||||
<Chip
|
||||
icon={icon}
|
||||
label={label}
|
||||
size="small"
|
||||
color={color}
|
||||
variant="outlined"
|
||||
onClick={handleRefresh}
|
||||
sx={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
{showRefreshButton && (
|
||||
<Tooltip title="Vérifier la connexion" arrow>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
color="primary"
|
||||
>
|
||||
<RefreshIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Notification de changement d'état */}
|
||||
<Snackbar
|
||||
open={showNotification}
|
||||
autoHideDuration={3000}
|
||||
onClose={handleCloseNotification}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<Alert
|
||||
onClose={handleCloseNotification}
|
||||
severity={isOnline ? 'success' : 'warning'}
|
||||
variant="filled"
|
||||
sx={{ width: '100%' }}
|
||||
>
|
||||
{notificationMessage}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
ConnectionIndicator.displayName = 'ConnectionIndicator';
|
||||
|
||||
export default ConnectionIndicator;
|
||||
@@ -0,0 +1,448 @@
|
||||
/**
|
||||
* Composant d'Aide Contextuelle - Assistance intelligente en français
|
||||
* Auteur : Dom, Alice, Kiro - 08 janvier 2026
|
||||
*
|
||||
* Ce composant fournit une aide contextuelle intelligente qui s'adapte
|
||||
* à l'action en cours de l'utilisateur et propose des conseils pertinents.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
IconButton,
|
||||
Collapse,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Chip,
|
||||
Button,
|
||||
Divider,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Help as HelpIcon,
|
||||
Close as CloseIcon,
|
||||
Lightbulb as LightbulbIcon,
|
||||
Info as InfoIcon,
|
||||
Warning as WarningIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
KeyboardArrowUp as ArrowUpIcon,
|
||||
KeyboardArrowDown as ArrowDownIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface ContextualHelpProps {
|
||||
context: 'canvas' | 'palette' | 'properties' | 'validation' | 'execution' | 'variables';
|
||||
selectedStepType?: string;
|
||||
currentErrors?: string[];
|
||||
isVisible?: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
interface HelpTip {
|
||||
id: string;
|
||||
type: 'tip' | 'info' | 'warning' | 'success';
|
||||
title: string;
|
||||
content: string;
|
||||
action?: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
// Conseils contextuels par zone de l'interface
|
||||
const contextualTips: Record<string, HelpTip[]> = {
|
||||
canvas: [
|
||||
{
|
||||
id: 'canvas_basics',
|
||||
type: 'tip',
|
||||
title: 'Premiers pas sur le canvas',
|
||||
content: 'Glissez des étapes depuis la palette vers cette zone pour construire votre workflow. Utilisez la molette pour zoomer et cliquez-glissez pour vous déplacer.'
|
||||
},
|
||||
{
|
||||
id: 'canvas_connections',
|
||||
type: 'info',
|
||||
title: 'Connecter les étapes',
|
||||
content: 'Cliquez sur le point de sortie d\'une étape (cercle à droite) et glissez vers le point d\'entrée d\'une autre étape pour les connecter.'
|
||||
},
|
||||
{
|
||||
id: 'canvas_selection',
|
||||
type: 'tip',
|
||||
title: 'Sélection multiple',
|
||||
content: 'Maintenez Ctrl et cliquez sur plusieurs étapes pour les sélectionner. Vous pouvez ensuite les déplacer ensemble ou les supprimer.'
|
||||
}
|
||||
],
|
||||
palette: [
|
||||
{
|
||||
id: 'palette_categories',
|
||||
type: 'info',
|
||||
title: 'Organisation par catégories',
|
||||
content: 'Les étapes sont organisées en catégories logiques : Actions Web pour interagir avec les pages, Logique pour les conditions, etc.'
|
||||
},
|
||||
{
|
||||
id: 'palette_search',
|
||||
type: 'tip',
|
||||
title: 'Recherche rapide',
|
||||
content: 'Utilisez le champ de recherche pour trouver rapidement une étape par son nom ou sa description.'
|
||||
},
|
||||
{
|
||||
id: 'palette_drag',
|
||||
type: 'tip',
|
||||
title: 'Glisser-déposer',
|
||||
content: 'Glissez une étape depuis la palette vers le canvas pour l\'ajouter à votre workflow. L\'étape apparaîtra à l\'endroit où vous la relâchez.'
|
||||
}
|
||||
],
|
||||
properties: [
|
||||
{
|
||||
id: 'properties_required',
|
||||
type: 'warning',
|
||||
title: 'Paramètres obligatoires',
|
||||
content: 'Les paramètres marqués d\'un astérisque (*) sont obligatoires. Le workflow ne pourra pas s\'exécuter sans eux.'
|
||||
},
|
||||
{
|
||||
id: 'properties_variables',
|
||||
type: 'tip',
|
||||
title: 'Utilisation des variables',
|
||||
content: 'Vous pouvez utiliser des variables dans les champs texte avec la syntaxe ${nom_variable}. Cela rend vos workflows réutilisables.'
|
||||
},
|
||||
{
|
||||
id: 'properties_visual',
|
||||
type: 'info',
|
||||
title: 'Sélection visuelle',
|
||||
content: 'Pour les paramètres "Élément cible", utilisez le bouton "Sélectionner un élément" pour choisir visuellement sur une capture d\'écran.'
|
||||
}
|
||||
],
|
||||
validation: [
|
||||
{
|
||||
id: 'validation_errors',
|
||||
type: 'warning',
|
||||
title: 'Correction des erreurs',
|
||||
content: 'Les erreurs en rouge empêchent l\'exécution. Cliquez sur une erreur pour aller directement à l\'étape concernée.'
|
||||
},
|
||||
{
|
||||
id: 'validation_warnings',
|
||||
type: 'info',
|
||||
title: 'Avertissements',
|
||||
content: 'Les avertissements en orange n\'empêchent pas l\'exécution mais peuvent indiquer des problèmes potentiels.'
|
||||
},
|
||||
{
|
||||
id: 'validation_cycles',
|
||||
type: 'warning',
|
||||
title: 'Éviter les boucles infinies',
|
||||
content: 'Vérifiez que vos connexions ne créent pas de cycles (A → B → A) qui empêcheraient l\'exécution normale.'
|
||||
}
|
||||
],
|
||||
execution: [
|
||||
{
|
||||
id: 'execution_states',
|
||||
type: 'info',
|
||||
title: 'États d\'exécution',
|
||||
content: 'Pendant l\'exécution, les étapes changent de couleur : bleu (en cours), vert (réussie), rouge (échouée).'
|
||||
},
|
||||
{
|
||||
id: 'execution_pause',
|
||||
type: 'tip',
|
||||
title: 'Contrôle de l\'exécution',
|
||||
content: 'Vous pouvez mettre en pause, arrêter ou redémarrer l\'exécution à tout moment avec les boutons de contrôle.'
|
||||
},
|
||||
{
|
||||
id: 'execution_debug',
|
||||
type: 'tip',
|
||||
title: 'Débogage',
|
||||
content: 'En cas d\'erreur, consultez les détails dans le panneau d\'exécution pour comprendre ce qui s\'est passé.'
|
||||
}
|
||||
],
|
||||
variables: [
|
||||
{
|
||||
id: 'variables_naming',
|
||||
type: 'tip',
|
||||
title: 'Nommage des variables',
|
||||
content: 'Utilisez des noms descriptifs pour vos variables : "nom_utilisateur" plutôt que "var1". Évitez les espaces et caractères spéciaux.'
|
||||
},
|
||||
{
|
||||
id: 'variables_types',
|
||||
type: 'info',
|
||||
title: 'Types de variables',
|
||||
content: 'Définissez le bon type pour chaque variable (texte, nombre, booléen) pour éviter les erreurs de validation.'
|
||||
},
|
||||
{
|
||||
id: 'variables_scope',
|
||||
type: 'tip',
|
||||
title: 'Portée des variables',
|
||||
content: 'Les variables sont disponibles dans tout le workflow une fois créées. Elles peuvent être modifiées par les étapes d\'extraction.'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Conseils spécifiques par type d'étape
|
||||
const stepSpecificTips: Record<string, HelpTip[]> = {
|
||||
click: [
|
||||
{
|
||||
id: 'click_types',
|
||||
type: 'info',
|
||||
title: 'Types de clic',
|
||||
content: 'Utilisez le clic gauche pour la plupart des actions, le clic droit pour les menus contextuels, et le double-clic pour les sélections.'
|
||||
},
|
||||
{
|
||||
id: 'click_timing',
|
||||
type: 'tip',
|
||||
title: 'Timing des clics',
|
||||
content: 'Si un clic ne fonctionne pas, ajoutez une étape d\'attente avant pour laisser le temps à la page de se charger.'
|
||||
}
|
||||
],
|
||||
type: [
|
||||
{
|
||||
id: 'type_clear',
|
||||
type: 'tip',
|
||||
title: 'Vider avant saisie',
|
||||
content: 'Activez "Vider le champ d\'abord" si vous voulez remplacer le contenu existant plutôt que l\'ajouter.'
|
||||
},
|
||||
{
|
||||
id: 'type_variables',
|
||||
type: 'info',
|
||||
title: 'Texte dynamique',
|
||||
content: 'Utilisez ${nom_variable} pour insérer des valeurs dynamiques dans le texte à saisir.'
|
||||
}
|
||||
],
|
||||
wait: [
|
||||
{
|
||||
id: 'wait_duration',
|
||||
type: 'tip',
|
||||
title: 'Durée d\'attente',
|
||||
content: 'Utilisez des durées courtes (0.5-2 secondes) pour les interactions rapides, plus longues (3-10 secondes) pour les chargements de page.'
|
||||
}
|
||||
],
|
||||
condition: [
|
||||
{
|
||||
id: 'condition_operators',
|
||||
type: 'info',
|
||||
title: 'Opérateurs disponibles',
|
||||
content: 'Utilisez ==, !=, >, <, >=, <= pour comparer des valeurs. Exemple : ${age} >= 18 ou ${status} == "actif".'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
/**
|
||||
* Composant d'Aide Contextuelle
|
||||
*/
|
||||
const ContextualHelp: React.FC<ContextualHelpProps> = ({
|
||||
context,
|
||||
selectedStepType,
|
||||
currentErrors = [],
|
||||
isVisible = true,
|
||||
onClose,
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [currentTips, setCurrentTips] = useState<HelpTip[]>([]);
|
||||
|
||||
// Gestionnaire d'événements clavier pour l'aide contextuelle
|
||||
const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
|
||||
switch (event.key) {
|
||||
case 'h':
|
||||
// Raccourci pour basculer l'aide (Ctrl+H)
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
event.preventDefault();
|
||||
setIsExpanded(prev => !prev);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
// Fermer l'aide
|
||||
event.preventDefault();
|
||||
setIsExpanded(false);
|
||||
if (onClose) onClose();
|
||||
break;
|
||||
case '?':
|
||||
// Raccourci pour ouvrir l'aide
|
||||
event.preventDefault();
|
||||
setIsExpanded(true);
|
||||
break;
|
||||
}
|
||||
}, [onClose]);
|
||||
|
||||
// Mettre à jour les conseils selon le contexte
|
||||
useEffect(() => {
|
||||
let tips: HelpTip[] = [];
|
||||
|
||||
// Ajouter les conseils contextuels
|
||||
if (contextualTips[context]) {
|
||||
tips.push(...contextualTips[context]);
|
||||
}
|
||||
|
||||
// Ajouter les conseils spécifiques à l'étape sélectionnée
|
||||
if (selectedStepType && stepSpecificTips[selectedStepType]) {
|
||||
tips.push(...stepSpecificTips[selectedStepType]);
|
||||
}
|
||||
|
||||
// Ajouter des conseils basés sur les erreurs actuelles
|
||||
if (currentErrors.length > 0) {
|
||||
tips.unshift({
|
||||
id: 'current_errors',
|
||||
type: 'warning',
|
||||
title: `${currentErrors.length} erreur(s) détectée(s)`,
|
||||
content: 'Corrigez les erreurs signalées en rouge pour pouvoir exécuter votre workflow.'
|
||||
});
|
||||
}
|
||||
|
||||
setCurrentTips(tips);
|
||||
}, [context, selectedStepType, currentErrors]);
|
||||
|
||||
// Obtenir l'icône selon le type de conseil
|
||||
const getTipIcon = (type: HelpTip['type']) => {
|
||||
switch (type) {
|
||||
case 'tip':
|
||||
return <LightbulbIcon color="primary" />;
|
||||
case 'info':
|
||||
return <InfoIcon color="info" />;
|
||||
case 'warning':
|
||||
return <WarningIcon color="warning" />;
|
||||
case 'success':
|
||||
return <CheckCircleIcon color="success" />;
|
||||
default:
|
||||
return <HelpIcon />;
|
||||
}
|
||||
};
|
||||
|
||||
// Obtenir la couleur selon le type
|
||||
const getTipColor = (type: HelpTip['type']) => {
|
||||
switch (type) {
|
||||
case 'tip':
|
||||
return 'primary';
|
||||
case 'info':
|
||||
return 'info';
|
||||
case 'warning':
|
||||
return 'warning';
|
||||
case 'success':
|
||||
return 'success';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
if (!isVisible || currentTips.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={3}
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
bottom: 20,
|
||||
right: 20,
|
||||
width: 320,
|
||||
maxHeight: 400,
|
||||
zIndex: 1000,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* En-tête */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
p: 2,
|
||||
backgroundColor: 'primary.main',
|
||||
color: 'primary.contrastText',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<HelpIcon />
|
||||
<Typography variant="subtitle2">
|
||||
Aide contextuelle
|
||||
</Typography>
|
||||
<Chip
|
||||
label={currentTips.length}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
color: 'inherit',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsExpanded(!isExpanded);
|
||||
}}
|
||||
sx={{ color: 'inherit', mr: 1 }}
|
||||
>
|
||||
{isExpanded ? <ArrowDownIcon /> : <ArrowUpIcon />}
|
||||
</IconButton>
|
||||
{onClose && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
sx={{ color: 'inherit' }}
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Contenu */}
|
||||
<Collapse in={isExpanded}>
|
||||
<Box
|
||||
sx={{ maxHeight: 300, overflow: 'auto' }}
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={0}
|
||||
>
|
||||
<List dense>
|
||||
{currentTips.map((tip, index) => (
|
||||
<React.Fragment key={tip.id}>
|
||||
<ListItem alignItems="flex-start">
|
||||
<ListItemIcon sx={{ mt: 0.5 }}>
|
||||
{getTipIcon(tip.type)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||
<Typography variant="subtitle2">
|
||||
{tip.title}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={tip.type === 'tip' ? 'Conseil' : tip.type === 'info' ? 'Info' : tip.type === 'warning' ? 'Attention' : 'Succès'}
|
||||
size="small"
|
||||
color={getTipColor(tip.type) as any}
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
secondary={
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{tip.content}
|
||||
</Typography>
|
||||
{tip.action && (
|
||||
<Button
|
||||
size="small"
|
||||
onClick={tip.action.onClick}
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
{tip.action.label}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
{index < currentTips.length - 1 && <Divider />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContextualHelp;
|
||||
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* Styles CSS pour le composant DebugPanel
|
||||
* Auteur : Dom, Alice, Kiro - 12 janvier 2026
|
||||
*/
|
||||
|
||||
.debug-panel {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
width: 400px;
|
||||
max-height: calc(100vh - 40px);
|
||||
background-color: white;
|
||||
border: 2px solid #1976d2;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
z-index: 9999;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
|
||||
.debug-panel-header {
|
||||
padding: 16px;
|
||||
background-color: #1976d2;
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid #1565c0;
|
||||
}
|
||||
|
||||
.debug-panel-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.debug-panel-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.debug-panel-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.debug-panel-toggle {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
background-color: #1976d2;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.debug-panel-toggle:hover {
|
||||
background-color: #1565c0;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.debug-section {
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.debug-section-header {
|
||||
padding: 12px 16px;
|
||||
background-color: #f5f5f5;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.debug-section-header:hover {
|
||||
background-color: #eeeeee;
|
||||
}
|
||||
|
||||
.debug-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.debug-section-content {
|
||||
padding: 16px;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.debug-info-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.debug-info-table th,
|
||||
.debug-info-table td {
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.debug-info-table th {
|
||||
background-color: #f5f5f5;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.debug-info-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.debug-chip {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.debug-chip-success {
|
||||
background-color: #e8f5e8;
|
||||
color: #2e7d32;
|
||||
border: 1px solid #c8e6c9;
|
||||
}
|
||||
|
||||
.debug-chip-error {
|
||||
background-color: #ffebee;
|
||||
color: #c62828;
|
||||
border: 1px solid #ffcdd2;
|
||||
}
|
||||
|
||||
.debug-chip-warning {
|
||||
background-color: #fff3e0;
|
||||
color: #ef6c00;
|
||||
border: 1px solid #ffcc02;
|
||||
}
|
||||
|
||||
.debug-chip-info {
|
||||
background-color: #e3f2fd;
|
||||
color: #1976d2;
|
||||
border: 1px solid #bbdefb;
|
||||
}
|
||||
|
||||
.debug-chip-default {
|
||||
background-color: #f5f5f5;
|
||||
color: #616161;
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.debug-detection-methods {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.debug-detection-method {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.debug-detection-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.debug-detection-icon-success {
|
||||
background-color: #4caf50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.debug-detection-icon-disabled {
|
||||
background-color: #bdbdbd;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.debug-parameters-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.debug-parameter-chip {
|
||||
padding: 2px 6px;
|
||||
background-color: #e3f2fd;
|
||||
color: #1976d2;
|
||||
border: 1px solid #bbdefb;
|
||||
border-radius: 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.debug-alert {
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
margin: 8px 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.debug-alert-info {
|
||||
background-color: #e3f2fd;
|
||||
color: #1976d2;
|
||||
border: 1px solid #bbdefb;
|
||||
}
|
||||
|
||||
.debug-alert-warning {
|
||||
background-color: #fff3e0;
|
||||
color: #ef6c00;
|
||||
border: 1px solid #ffcc02;
|
||||
}
|
||||
|
||||
.debug-alert-error {
|
||||
background-color: #ffebee;
|
||||
color: #c62828;
|
||||
border: 1px solid #ffcdd2;
|
||||
}
|
||||
|
||||
.debug-alert-success {
|
||||
background-color: #e8f5e8;
|
||||
color: #2e7d32;
|
||||
border: 1px solid #c8e6c9;
|
||||
}
|
||||
|
||||
.debug-auto-refresh {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.debug-auto-refresh input[type="checkbox"] {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes debugPanelSlideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes debugPanelSlideOut {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.debug-panel-enter {
|
||||
animation: debugPanelSlideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.debug-panel-exit {
|
||||
animation: debugPanelSlideOut 0.3s ease-in;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.debug-panel {
|
||||
width: calc(100vw - 40px);
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.debug-panel-toggle {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Scrollbar personnalisée */
|
||||
.debug-panel-content::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.debug-panel-content::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.debug-panel-content::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.debug-panel-content::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
|
||||
/* Mode sombre (optionnel) */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.debug-panel {
|
||||
background-color: #2d2d2d;
|
||||
border-color: #1976d2;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.debug-section-header {
|
||||
background-color: #3d3d3d;
|
||||
border-bottom-color: #4d4d4d;
|
||||
}
|
||||
|
||||
.debug-section-content {
|
||||
background-color: #2d2d2d;
|
||||
}
|
||||
|
||||
.debug-info-table th {
|
||||
background-color: #3d3d3d;
|
||||
}
|
||||
|
||||
.debug-info-table th,
|
||||
.debug-info-table td {
|
||||
border-bottom-color: #4d4d4d;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
/**
|
||||
* Composant DebugPanel - Visualisation des données d'étapes en temps réel
|
||||
* Auteur : Dom, Alice, Kiro - 12 janvier 2026
|
||||
*
|
||||
* Ce composant affiche des informations de débogage détaillées pour les étapes
|
||||
* sélectionnées, permettant de diagnostiquer les problèmes de propriétés.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
Chip,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
Alert,
|
||||
Switch,
|
||||
FormControlLabel,
|
||||
Divider,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
BugReport as BugReportIcon,
|
||||
Visibility as VisibilityIcon,
|
||||
Code as CodeIcon,
|
||||
Settings as SettingsIcon,
|
||||
Warning as WarningIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Error as ErrorIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
// Import des hooks d'intégration VWB
|
||||
import { useVWBStepIntegration, useIsVWBStep, useVWBActionId } from '../../hooks/useVWBStepIntegration';
|
||||
|
||||
// Import des types
|
||||
import { Step, StepType, Variable } from '../../types';
|
||||
|
||||
interface DebugPanelProps {
|
||||
selectedStep: Step | null;
|
||||
variables: Variable[];
|
||||
isVisible?: boolean;
|
||||
onToggleVisibility?: (visible: boolean) => void;
|
||||
}
|
||||
|
||||
interface StepAnalysis {
|
||||
stepInfo: {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
hasData: boolean;
|
||||
dataKeys: string[];
|
||||
};
|
||||
typeAnalysis: {
|
||||
isStandardType: boolean;
|
||||
isVWBAction: boolean;
|
||||
hasConfiguration: boolean;
|
||||
configurationSource: string;
|
||||
};
|
||||
vwbAnalysis: {
|
||||
isVWBCatalogAction: boolean;
|
||||
hasVWBActionId: boolean;
|
||||
vwbActionId: string | null;
|
||||
detectionMethods: Record<string, boolean>;
|
||||
};
|
||||
parametersAnalysis: {
|
||||
hasParameters: boolean;
|
||||
parameterCount: number;
|
||||
parameterKeys: string[];
|
||||
missingRequired: string[];
|
||||
};
|
||||
validationAnalysis: {
|
||||
hasErrors: boolean;
|
||||
errorCount: number;
|
||||
warningCount: number;
|
||||
errors: Array<{ parameter: string; message: string; severity: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Composant DebugPanel
|
||||
*/
|
||||
const DebugPanel: React.FC<DebugPanelProps> = ({
|
||||
selectedStep,
|
||||
variables,
|
||||
isVisible = false,
|
||||
onToggleVisibility,
|
||||
}) => {
|
||||
const [expanded, setExpanded] = useState<string[]>(['stepInfo']);
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
|
||||
// Utilisation des hooks d'intégration VWB
|
||||
const { methods: vwbMethods } = useVWBStepIntegration();
|
||||
const isVWBStep = useIsVWBStep(selectedStep);
|
||||
const vwbActionId = useVWBActionId(selectedStep);
|
||||
|
||||
// Configuration des paramètres par type d'étape (copie locale pour analyse)
|
||||
const stepParametersConfig: Record<string, any> = useMemo(() => ({
|
||||
click: [
|
||||
{ name: 'target', type: 'visual', required: true },
|
||||
{ name: 'clickType', type: 'select', defaultValue: 'left' },
|
||||
],
|
||||
type: [
|
||||
{ name: 'target', type: 'visual', required: true },
|
||||
{ name: 'text', type: 'text', required: true },
|
||||
{ name: 'clearFirst', type: 'boolean', defaultValue: true },
|
||||
],
|
||||
wait: [
|
||||
{ name: 'duration', type: 'number', required: true, min: 0.1, max: 60, defaultValue: 1 },
|
||||
],
|
||||
condition: [
|
||||
{ name: 'condition', type: 'text', required: true },
|
||||
],
|
||||
extract: [
|
||||
{ name: 'target', type: 'visual', required: true },
|
||||
{ name: 'attribute', type: 'select', defaultValue: 'text' },
|
||||
],
|
||||
scroll: [
|
||||
{ name: 'direction', type: 'select', defaultValue: 'down' },
|
||||
{ name: 'amount', type: 'number', defaultValue: 300, min: 1 },
|
||||
],
|
||||
navigate: [
|
||||
{ name: 'url', type: 'text', required: true },
|
||||
],
|
||||
screenshot: [
|
||||
{ name: 'filename', type: 'text' },
|
||||
],
|
||||
}), []);
|
||||
|
||||
// Analyse complète de l'étape sélectionnée
|
||||
const stepAnalysis: StepAnalysis | null = useMemo(() => {
|
||||
if (!selectedStep) return null;
|
||||
|
||||
// Analyse des informations de base
|
||||
const stepInfo = {
|
||||
id: selectedStep.id,
|
||||
name: selectedStep.name || 'Sans nom',
|
||||
type: selectedStep.type as string,
|
||||
hasData: Boolean(selectedStep.data),
|
||||
dataKeys: selectedStep.data ? Object.keys(selectedStep.data) : [],
|
||||
};
|
||||
|
||||
// Analyse du type d'étape
|
||||
const stepTypeString = selectedStep.type as string;
|
||||
const isStandardType = stepTypeString in stepParametersConfig;
|
||||
const hasConfiguration = isStandardType || Boolean(selectedStep.data?.isVWBCatalogAction);
|
||||
|
||||
// Méthodes de détection VWB
|
||||
const detectionMethods = {
|
||||
hasVWBFlag: Boolean(selectedStep.data?.isVWBCatalogAction),
|
||||
hasVWBActionId: Boolean(selectedStep.data?.vwbActionId),
|
||||
typeStartsWithVWB: stepTypeString.startsWith('vwb_'),
|
||||
typeContainsAnchor: stepTypeString.includes('_anchor'),
|
||||
typeContainsText: stepTypeString.includes('_text'),
|
||||
typeContainsSecret: stepTypeString.includes('_secret'),
|
||||
isKnownVWBAction: [
|
||||
'click_anchor', 'type_text', 'type_secret', 'wait_for_anchor',
|
||||
'extract_text', 'screenshot_evidence', 'scroll_to_anchor',
|
||||
'focus_anchor', 'hotkey', 'navigate_to_url', 'browser_back',
|
||||
'verify_element_exists', 'verify_text_content'
|
||||
].includes(stepTypeString),
|
||||
useIsVWBStepHook: isVWBStep,
|
||||
};
|
||||
|
||||
const isVWBAction = Object.values(detectionMethods).some(method => method);
|
||||
|
||||
const typeAnalysis = {
|
||||
isStandardType,
|
||||
isVWBAction,
|
||||
hasConfiguration,
|
||||
configurationSource: isVWBAction ? 'VWBActionProperties' : isStandardType ? 'stepParametersConfig' : 'Aucune',
|
||||
};
|
||||
|
||||
const vwbAnalysis = {
|
||||
isVWBCatalogAction: Boolean(selectedStep.data?.isVWBCatalogAction),
|
||||
hasVWBActionId: Boolean(selectedStep.data?.vwbActionId),
|
||||
vwbActionId: selectedStep.data?.vwbActionId || null,
|
||||
detectionMethods,
|
||||
};
|
||||
|
||||
// Analyse des paramètres
|
||||
const parameters = selectedStep.data?.parameters || {};
|
||||
const parameterKeys = Object.keys(parameters);
|
||||
const expectedConfig = stepParametersConfig[stepTypeString] || [];
|
||||
const requiredParams = expectedConfig.filter((p: any) => p.required).map((p: any) => p.name);
|
||||
const missingRequired = requiredParams.filter((param: string) => !(param in parameters));
|
||||
|
||||
const parametersAnalysis = {
|
||||
hasParameters: parameterKeys.length > 0,
|
||||
parameterCount: parameterKeys.length,
|
||||
parameterKeys,
|
||||
missingRequired,
|
||||
};
|
||||
|
||||
// Analyse de validation
|
||||
const validationErrors = selectedStep.validationErrors || [];
|
||||
const errors = validationErrors.map(error => ({
|
||||
parameter: error.parameter || 'Général',
|
||||
message: error.message,
|
||||
severity: error.severity || 'error',
|
||||
}));
|
||||
|
||||
const validationAnalysis = {
|
||||
hasErrors: validationErrors.length > 0,
|
||||
errorCount: errors.filter(e => e.severity === 'error').length,
|
||||
warningCount: errors.filter(e => e.severity === 'warning').length,
|
||||
errors,
|
||||
};
|
||||
|
||||
return {
|
||||
stepInfo,
|
||||
typeAnalysis,
|
||||
vwbAnalysis,
|
||||
parametersAnalysis,
|
||||
validationAnalysis,
|
||||
};
|
||||
}, [selectedStep, stepParametersConfig, isVWBStep]);
|
||||
|
||||
// Gestion de l'expansion des accordéons
|
||||
const handleAccordionChange = (panel: string) => (event: React.SyntheticEvent, isExpanded: boolean) => {
|
||||
setExpanded(prev =>
|
||||
isExpanded
|
||||
? [...prev, panel]
|
||||
: prev.filter(p => p !== panel)
|
||||
);
|
||||
};
|
||||
|
||||
// Auto-refresh des données
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
// Force un re-render pour mettre à jour les données en temps réel
|
||||
// (les données sont déjà réactives via les props)
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [autoRefresh]);
|
||||
|
||||
if (!isVisible) {
|
||||
return (
|
||||
<Box sx={{ position: 'fixed', top: 20, right: 20, zIndex: 9999 }}>
|
||||
<Tooltip title="Ouvrir le panneau de débogage">
|
||||
<IconButton
|
||||
onClick={() => onToggleVisibility?.(true)}
|
||||
sx={{
|
||||
backgroundColor: 'primary.main',
|
||||
color: 'white',
|
||||
'&:hover': { backgroundColor: 'primary.dark' },
|
||||
}}
|
||||
>
|
||||
<BugReportIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
top: 20,
|
||||
right: 20,
|
||||
width: 400,
|
||||
maxHeight: 'calc(100vh - 40px)',
|
||||
backgroundColor: 'background.paper',
|
||||
border: '2px solid',
|
||||
borderColor: 'primary.main',
|
||||
borderRadius: 2,
|
||||
boxShadow: 3,
|
||||
zIndex: 9999,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* En-tête */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
backgroundColor: 'primary.main',
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<BugReportIcon />
|
||||
<Typography variant="h6">Debug Panel</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={autoRefresh}
|
||||
onChange={(e) => setAutoRefresh(e.target.checked)}
|
||||
size="small"
|
||||
sx={{ color: 'white' }}
|
||||
/>
|
||||
}
|
||||
label="Auto"
|
||||
sx={{ color: 'white', m: 0 }}
|
||||
/>
|
||||
<IconButton
|
||||
onClick={() => onToggleVisibility?.(false)}
|
||||
sx={{ color: 'white' }}
|
||||
size="small"
|
||||
>
|
||||
<VisibilityIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Contenu */}
|
||||
<Box sx={{ flex: 1, overflow: 'auto', p: 1 }}>
|
||||
{!selectedStep ? (
|
||||
<Alert severity="info" sx={{ m: 1 }}>
|
||||
Aucune étape sélectionnée
|
||||
</Alert>
|
||||
) : !stepAnalysis ? (
|
||||
<Alert severity="error" sx={{ m: 1 }}>
|
||||
Erreur d'analyse de l'étape
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
{/* Informations de base */}
|
||||
<Accordion
|
||||
expanded={expanded.includes('stepInfo')}
|
||||
onChange={handleAccordionChange('stepInfo')}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<SettingsIcon fontSize="small" />
|
||||
<Typography variant="subtitle2">Informations de base</Typography>
|
||||
<Chip
|
||||
label={stepAnalysis.stepInfo.type}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell><strong>ID</strong></TableCell>
|
||||
<TableCell>{stepAnalysis.stepInfo.id}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell><strong>Nom</strong></TableCell>
|
||||
<TableCell>{stepAnalysis.stepInfo.name}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell><strong>Type</strong></TableCell>
|
||||
<TableCell>{stepAnalysis.stepInfo.type}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell><strong>Données</strong></TableCell>
|
||||
<TableCell>
|
||||
{stepAnalysis.stepInfo.hasData ? (
|
||||
<Chip label={`${stepAnalysis.stepInfo.dataKeys.length} clés`} size="small" color="success" />
|
||||
) : (
|
||||
<Chip label="Aucune" size="small" color="default" />
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
{/* Analyse du type */}
|
||||
<Accordion
|
||||
expanded={expanded.includes('typeAnalysis')}
|
||||
onChange={handleAccordionChange('typeAnalysis')}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CodeIcon fontSize="small" />
|
||||
<Typography variant="subtitle2">Analyse du type</Typography>
|
||||
{stepAnalysis.typeAnalysis.hasConfiguration ? (
|
||||
<CheckCircleIcon fontSize="small" color="success" />
|
||||
) : (
|
||||
<ErrorIcon fontSize="small" color="error" />
|
||||
)}
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Chip
|
||||
label={stepAnalysis.typeAnalysis.isStandardType ? 'Type Standard' : 'Type Non-Standard'}
|
||||
size="small"
|
||||
color={stepAnalysis.typeAnalysis.isStandardType ? 'success' : 'default'}
|
||||
/>
|
||||
<Chip
|
||||
label={stepAnalysis.typeAnalysis.isVWBAction ? 'Action VWB' : 'Non-VWB'}
|
||||
size="small"
|
||||
color={stepAnalysis.typeAnalysis.isVWBAction ? 'primary' : 'default'}
|
||||
/>
|
||||
</Box>
|
||||
<Typography variant="body2">
|
||||
<strong>Source de configuration :</strong> {stepAnalysis.typeAnalysis.configurationSource}
|
||||
</Typography>
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
{/* Analyse VWB */}
|
||||
<Accordion
|
||||
expanded={expanded.includes('vwbAnalysis')}
|
||||
onChange={handleAccordionChange('vwbAnalysis')}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<BugReportIcon fontSize="small" />
|
||||
<Typography variant="subtitle2">Analyse VWB</Typography>
|
||||
<Chip
|
||||
label={`${Object.values(stepAnalysis.vwbAnalysis.detectionMethods).filter(Boolean).length}/8`}
|
||||
size="small"
|
||||
color="info"
|
||||
/>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{stepAnalysis.vwbAnalysis.vwbActionId && (
|
||||
<Alert severity="info" sx={{ fontSize: '0.8rem' }}>
|
||||
<strong>Action ID :</strong> {stepAnalysis.vwbAnalysis.vwbActionId}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>
|
||||
Méthodes de détection :
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
{Object.entries(stepAnalysis.vwbAnalysis.detectionMethods).map(([method, detected]) => (
|
||||
<Box key={method} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{detected ? (
|
||||
<CheckCircleIcon fontSize="small" color="success" />
|
||||
) : (
|
||||
<ErrorIcon fontSize="small" color="disabled" />
|
||||
)}
|
||||
<Typography variant="body2" sx={{ fontSize: '0.8rem' }}>
|
||||
{method}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
{/* Analyse des paramètres */}
|
||||
<Accordion
|
||||
expanded={expanded.includes('parametersAnalysis')}
|
||||
onChange={handleAccordionChange('parametersAnalysis')}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<SettingsIcon fontSize="small" />
|
||||
<Typography variant="subtitle2">Paramètres</Typography>
|
||||
<Chip
|
||||
label={stepAnalysis.parametersAnalysis.parameterCount}
|
||||
size="small"
|
||||
color={stepAnalysis.parametersAnalysis.hasParameters ? 'success' : 'default'}
|
||||
/>
|
||||
{stepAnalysis.parametersAnalysis.missingRequired.length > 0 && (
|
||||
<WarningIcon fontSize="small" color="warning" />
|
||||
)}
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{stepAnalysis.parametersAnalysis.hasParameters ? (
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
{stepAnalysis.parametersAnalysis.parameterKeys.map(key => (
|
||||
<Chip key={key} label={key} size="small" variant="outlined" />
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Aucun paramètre configuré
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{stepAnalysis.parametersAnalysis.missingRequired.length > 0 && (
|
||||
<Alert severity="warning" sx={{ fontSize: '0.8rem' }}>
|
||||
<strong>Paramètres requis manquants :</strong>{' '}
|
||||
{stepAnalysis.parametersAnalysis.missingRequired.join(', ')}
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
{/* Analyse de validation */}
|
||||
{stepAnalysis.validationAnalysis.hasErrors && (
|
||||
<Accordion
|
||||
expanded={expanded.includes('validationAnalysis')}
|
||||
onChange={handleAccordionChange('validationAnalysis')}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<WarningIcon fontSize="small" />
|
||||
<Typography variant="subtitle2">Validation</Typography>
|
||||
<Chip
|
||||
label={`${stepAnalysis.validationAnalysis.errorCount} erreurs`}
|
||||
size="small"
|
||||
color="error"
|
||||
/>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{stepAnalysis.validationAnalysis.errors.map((error, index) => (
|
||||
<Alert key={index} severity={error.severity as any} sx={{ fontSize: '0.8rem' }}>
|
||||
<strong>{error.parameter} :</strong> {error.message}
|
||||
</Alert>
|
||||
))}
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default DebugPanel;
|
||||
@@ -0,0 +1,592 @@
|
||||
/**
|
||||
* Composant Onglet de Documentation - Documentation interactive pour chaque outil
|
||||
* Auteur : Dom, Alice, Kiro - 08 janvier 2026
|
||||
*
|
||||
* Ce composant affiche la documentation contextuelle avec guides étape par étape,
|
||||
* exemples visuels et exemples interactifs en français.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Tabs,
|
||||
Tab,
|
||||
Card,
|
||||
CardContent,
|
||||
Button,
|
||||
Stepper,
|
||||
Step,
|
||||
StepLabel,
|
||||
StepContent,
|
||||
Alert,
|
||||
Chip,
|
||||
Divider,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Help as HelpIcon,
|
||||
PlayArrow as PlayIcon,
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
Lightbulb as TipIcon,
|
||||
Warning as WarningIcon,
|
||||
CheckCircle as CheckIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
// Import du composant Glossaire
|
||||
import Glossary from '../Glossary';
|
||||
|
||||
// Import des types partagés
|
||||
import { DocumentationTabProps } from '../../types';
|
||||
|
||||
interface DocumentationContent {
|
||||
title: string;
|
||||
sections: DocumentationSection[];
|
||||
examples: InteractiveExample[];
|
||||
glossary: GlossaryTerm[];
|
||||
}
|
||||
|
||||
interface DocumentationSection {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
steps?: DocumentationStep[];
|
||||
tips?: string[];
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
interface DocumentationStep {
|
||||
title: string;
|
||||
description: string;
|
||||
image?: string;
|
||||
action?: () => void;
|
||||
}
|
||||
|
||||
interface InteractiveExample {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
steps: string[];
|
||||
onTry: () => void;
|
||||
}
|
||||
|
||||
interface GlossaryTerm {
|
||||
term: string;
|
||||
definition: string;
|
||||
}
|
||||
|
||||
// Contenu de documentation par outil
|
||||
const documentationContent: Record<string, DocumentationContent> = {
|
||||
canvas: {
|
||||
title: 'Canvas - Espace de travail',
|
||||
sections: [
|
||||
{
|
||||
id: 'overview',
|
||||
title: 'Vue d\'ensemble',
|
||||
content: 'Le Canvas est votre espace de travail principal pour créer des workflows visuels. Vous pouvez y glisser des étapes depuis la palette, les connecter et les organiser.',
|
||||
steps: [
|
||||
{
|
||||
title: 'Glisser une étape',
|
||||
description: 'Glissez une étape depuis la palette vers le canvas',
|
||||
action: () => {
|
||||
// Déclencher un tutoriel interactif pour le drag-and-drop
|
||||
window.dispatchEvent(new CustomEvent('vwb:tutorial:dragDrop'));
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Connecter les étapes',
|
||||
description: 'Cliquez et glissez depuis le point de connexion d\'une étape vers une autre',
|
||||
action: () => {
|
||||
// Déclencher un tutoriel interactif pour les connexions
|
||||
window.dispatchEvent(new CustomEvent('vwb:tutorial:connect'));
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Sélectionner une étape',
|
||||
description: 'Cliquez sur une étape pour la sélectionner et voir ses propriétés',
|
||||
action: () => {
|
||||
// Déclencher un tutoriel interactif pour la sélection
|
||||
window.dispatchEvent(new CustomEvent('vwb:tutorial:select'));
|
||||
},
|
||||
},
|
||||
],
|
||||
tips: [
|
||||
'Utilisez la grille pour aligner vos étapes proprement',
|
||||
'La minimap apparaît automatiquement pour les gros workflows',
|
||||
'Utilisez Ctrl+Z pour annuler une action',
|
||||
'Double-cliquez sur une étape pour l\'éditer rapidement',
|
||||
'Utilisez Shift+clic pour sélectionner plusieurs étapes',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'navigation',
|
||||
title: 'Navigation',
|
||||
content: 'Le canvas offre plusieurs outils de navigation pour travailler efficacement avec vos workflows.',
|
||||
steps: [
|
||||
{
|
||||
title: 'Zoom',
|
||||
description: 'Utilisez la molette de la souris ou les contrôles pour zoomer',
|
||||
action: () => {
|
||||
// Déclencher un tutoriel interactif pour le zoom
|
||||
window.dispatchEvent(new CustomEvent('vwb:tutorial:zoom'));
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Panoramique',
|
||||
description: 'Maintenez le clic droit et glissez pour déplacer la vue',
|
||||
action: () => {
|
||||
// Déclencher un tutoriel interactif pour le panoramique
|
||||
window.dispatchEvent(new CustomEvent('vwb:tutorial:pan'));
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Ajustement automatique',
|
||||
description: 'Cliquez sur le bouton "Ajuster" pour voir tout le workflow',
|
||||
action: () => {
|
||||
// Déclencher un tutoriel interactif pour l'ajustement
|
||||
window.dispatchEvent(new CustomEvent('vwb:tutorial:fitView'));
|
||||
},
|
||||
},
|
||||
],
|
||||
tips: [
|
||||
'Utilisez la molette pour zoomer rapidement',
|
||||
'La minimap permet de naviguer dans les gros workflows',
|
||||
'Les raccourcis clavier accélèrent la navigation',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'shortcuts',
|
||||
title: 'Raccourcis clavier',
|
||||
content: 'Maîtrisez les raccourcis clavier pour une utilisation plus efficace du canvas.',
|
||||
steps: [
|
||||
{
|
||||
title: 'Ctrl+Z / Ctrl+Y',
|
||||
description: 'Annuler / Refaire les dernières actions',
|
||||
},
|
||||
{
|
||||
title: 'Suppr',
|
||||
description: 'Supprimer les éléments sélectionnés',
|
||||
},
|
||||
{
|
||||
title: 'Ctrl+A',
|
||||
description: 'Sélectionner tous les éléments',
|
||||
},
|
||||
{
|
||||
title: 'Ctrl+C / Ctrl+V',
|
||||
description: 'Copier / Coller les éléments sélectionnés',
|
||||
},
|
||||
{
|
||||
title: 'Espace + glisser',
|
||||
description: 'Mode panoramique temporaire',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
examples: [
|
||||
{
|
||||
id: 'first-workflow',
|
||||
title: 'Créer votre premier workflow',
|
||||
description: 'Apprenez à créer un workflow simple avec quelques étapes',
|
||||
steps: [
|
||||
'Glissez une étape "Cliquer" depuis la palette vers le canvas',
|
||||
'Ajoutez une étape "Saisir du texte" en dessous',
|
||||
'Connectez les deux étapes en glissant depuis le point de sortie vers l\'entrée',
|
||||
'Configurez les paramètres dans le panneau de propriétés',
|
||||
'Testez votre workflow avec le bouton d\'exécution',
|
||||
],
|
||||
onTry: () => {
|
||||
// Déclencher un tutoriel interactif complet
|
||||
window.dispatchEvent(new CustomEvent('vwb:tutorial:firstWorkflow'));
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'complex-workflow',
|
||||
title: 'Workflow avec conditions',
|
||||
description: 'Créez un workflow plus complexe avec des conditions et des boucles',
|
||||
steps: [
|
||||
'Créez une étape de démarrage',
|
||||
'Ajoutez une condition "Si/Alors"',
|
||||
'Connectez les branches "Vrai" et "Faux"',
|
||||
'Ajoutez des actions différentes pour chaque branche',
|
||||
'Testez les différents chemins d\'exécution',
|
||||
],
|
||||
onTry: () => {
|
||||
// Déclencher un tutoriel pour les workflows complexes
|
||||
window.dispatchEvent(new CustomEvent('vwb:tutorial:complexWorkflow'));
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'debugging-workflow',
|
||||
title: 'Déboguer un workflow',
|
||||
description: 'Apprenez à identifier et corriger les erreurs dans vos workflows',
|
||||
steps: [
|
||||
'Identifiez les étapes avec des erreurs (indicateurs rouges)',
|
||||
'Vérifiez les paramètres manquants dans le panneau de propriétés',
|
||||
'Corrigez les étapes déconnectées (surlignées en orange)',
|
||||
'Utilisez l\'exécution pas à pas pour tester',
|
||||
'Consultez les logs d\'erreur détaillés',
|
||||
],
|
||||
onTry: () => {
|
||||
// Déclencher un tutoriel de débogage
|
||||
window.dispatchEvent(new CustomEvent('vwb:tutorial:debugging'));
|
||||
},
|
||||
},
|
||||
],
|
||||
glossary: [
|
||||
{ term: 'Étape', definition: 'Un élément d\'action dans votre workflow' },
|
||||
{ term: 'Connexion', definition: 'Un lien entre deux étapes définissant l\'ordre d\'exécution' },
|
||||
{ term: 'Minimap', definition: 'Une vue miniature du workflow pour la navigation' },
|
||||
{ term: 'Point de connexion', definition: 'Zone cliquable sur une étape pour créer des liens' },
|
||||
{ term: 'Grille d\'alignement', definition: 'Guide visuel pour positionner les étapes proprement' },
|
||||
],
|
||||
},
|
||||
palette: {
|
||||
title: 'Palette - Boîte à outils',
|
||||
sections: [
|
||||
{
|
||||
id: 'categories',
|
||||
title: 'Catégories d\'étapes',
|
||||
content: 'La palette organise les étapes en catégories pour faciliter la recherche.',
|
||||
steps: [
|
||||
{
|
||||
title: 'Actions Web',
|
||||
description: 'Étapes pour interagir avec les pages web (cliquer, saisir, etc.)',
|
||||
},
|
||||
{
|
||||
title: 'Logique',
|
||||
description: 'Étapes de contrôle de flux (conditions, boucles)',
|
||||
},
|
||||
{
|
||||
title: 'Données',
|
||||
description: 'Étapes pour manipuler les données (extraire, transformer)',
|
||||
},
|
||||
{
|
||||
title: 'Contrôle',
|
||||
description: 'Étapes de contrôle d\'exécution (attendre, arrêter)',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'search',
|
||||
title: 'Recherche d\'étapes',
|
||||
content: 'Utilisez la barre de recherche pour trouver rapidement une étape spécifique.',
|
||||
tips: [
|
||||
'Tapez le nom de l\'action que vous voulez effectuer',
|
||||
'La recherche fonctionne sur les noms et descriptions',
|
||||
'Les résultats sont filtrés en temps réel',
|
||||
],
|
||||
},
|
||||
],
|
||||
examples: [
|
||||
{
|
||||
id: 'find-step',
|
||||
title: 'Trouver une étape rapidement',
|
||||
description: 'Utilisez la recherche pour trouver l\'étape dont vous avez besoin',
|
||||
steps: [
|
||||
'Cliquez dans la barre de recherche',
|
||||
'Tapez "clic" pour trouver les étapes de clic',
|
||||
'Glissez l\'étape trouvée sur le canvas',
|
||||
],
|
||||
onTry: () => console.log('Exemple interactif: Recherche d\'étape'),
|
||||
},
|
||||
],
|
||||
glossary: [
|
||||
{ term: 'Catégorie', definition: 'Un groupe d\'étapes similaires' },
|
||||
{ term: 'Tooltip', definition: 'Une infobulle explicative qui apparaît au survol' },
|
||||
],
|
||||
},
|
||||
properties: {
|
||||
title: 'Propriétés - Configuration des étapes',
|
||||
sections: [
|
||||
{
|
||||
id: 'parameter-types',
|
||||
title: 'Types de paramètres',
|
||||
content: 'Chaque étape peut avoir différents types de paramètres à configurer.',
|
||||
steps: [
|
||||
{
|
||||
title: 'Texte',
|
||||
description: 'Champs de saisie libre pour du texte',
|
||||
},
|
||||
{
|
||||
title: 'Nombre',
|
||||
description: 'Champs numériques avec validation',
|
||||
},
|
||||
{
|
||||
title: 'Booléen',
|
||||
description: 'Interrupteurs pour les options vrai/faux',
|
||||
},
|
||||
{
|
||||
title: 'Sélection',
|
||||
description: 'Listes déroulantes avec options prédéfinies',
|
||||
},
|
||||
{
|
||||
title: 'Sélection visuelle',
|
||||
description: 'Boutons pour sélectionner des éléments à l\'écran',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'validation',
|
||||
title: 'Validation en temps réel',
|
||||
content: 'Les paramètres sont validés automatiquement pendant la saisie.',
|
||||
warnings: [
|
||||
'Les paramètres obligatoires doivent être remplis',
|
||||
'Les valeurs numériques doivent respecter les limites',
|
||||
'Les sélections visuelles doivent être effectuées',
|
||||
],
|
||||
},
|
||||
],
|
||||
examples: [
|
||||
{
|
||||
id: 'configure-click',
|
||||
title: 'Configurer une étape de clic',
|
||||
description: 'Apprenez à configurer les paramètres d\'une étape de clic',
|
||||
steps: [
|
||||
'Sélectionnez une étape de clic sur le canvas',
|
||||
'Cliquez sur "Sélectionner un élément" dans les propriétés',
|
||||
'Choisissez le type de clic dans la liste déroulante',
|
||||
],
|
||||
onTry: () => console.log('Exemple interactif: Configuration de clic'),
|
||||
},
|
||||
],
|
||||
glossary: [
|
||||
{ term: 'Paramètre', definition: 'Une valeur configurable d\'une étape' },
|
||||
{ term: 'Validation', definition: 'Vérification automatique de la validité des valeurs' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Composant Onglet de Documentation
|
||||
*/
|
||||
const DocumentationTab: React.FC<DocumentationTabProps> = ({
|
||||
toolName,
|
||||
isActive,
|
||||
onActivate,
|
||||
}) => {
|
||||
const [activeSection, setActiveSection] = useState(0);
|
||||
const [activeStep, setActiveStep] = useState(0);
|
||||
|
||||
const content = documentationContent[toolName];
|
||||
|
||||
// Gestionnaire d'événements clavier pour la documentation
|
||||
const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
|
||||
switch (event.key) {
|
||||
case 'ArrowUp':
|
||||
// Navigation vers la section précédente
|
||||
event.preventDefault();
|
||||
setActiveSection(prev => Math.max(0, prev - 1));
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
// Navigation vers la section suivante
|
||||
event.preventDefault();
|
||||
const maxSections = content?.sections.length || 0;
|
||||
setActiveSection(prev => Math.min(maxSections - 1, prev + 1));
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
// Navigation vers l'étape précédente
|
||||
event.preventDefault();
|
||||
setActiveStep(prev => Math.max(0, prev - 1));
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
// Navigation vers l'étape suivante
|
||||
event.preventDefault();
|
||||
const maxSteps = content?.sections[activeSection]?.steps?.length || 0;
|
||||
setActiveStep(prev => Math.min(maxSteps - 1, prev + 1));
|
||||
break;
|
||||
case 'Home':
|
||||
// Aller au début
|
||||
event.preventDefault();
|
||||
setActiveSection(0);
|
||||
setActiveStep(0);
|
||||
break;
|
||||
case 'End':
|
||||
// Aller à la fin
|
||||
event.preventDefault();
|
||||
const lastSection = (content?.sections.length || 1) - 1;
|
||||
setActiveSection(lastSection);
|
||||
break;
|
||||
}
|
||||
}, [activeSection, content]);
|
||||
|
||||
if (!content) {
|
||||
return (
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Alert severity="info">
|
||||
Documentation non disponible pour cet outil.
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}
|
||||
role="tabpanel"
|
||||
aria-label={`Documentation pour ${content.title}`}
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={0}
|
||||
>
|
||||
{/* En-tête */}
|
||||
<Box sx={{ p: 2, borderBottom: '1px solid #e0e0e0' }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
<HelpIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
{content.title}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Contenu avec onglets */}
|
||||
<Box sx={{ flex: 1, overflow: 'hidden' }}>
|
||||
<Tabs
|
||||
value={activeSection}
|
||||
onChange={(_, newValue) => setActiveSection(newValue)}
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
>
|
||||
<Tab label="Guide" />
|
||||
<Tab label="Exemples" />
|
||||
<Tab label="Glossaire" />
|
||||
</Tabs>
|
||||
|
||||
{/* Panneau Guide */}
|
||||
{activeSection === 0 && (
|
||||
<Box sx={{ p: 2, height: 'calc(100% - 48px)', overflow: 'auto' }}>
|
||||
{content.sections.map((section, index) => (
|
||||
<Accordion key={section.id} defaultExpanded={index === 0}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="h6">{section.title}</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="body1" paragraph>
|
||||
{section.content}
|
||||
</Typography>
|
||||
|
||||
{/* Étapes */}
|
||||
{section.steps && (
|
||||
<Stepper orientation="vertical" activeStep={activeStep}>
|
||||
{section.steps.map((step, stepIndex) => (
|
||||
<Step key={stepIndex}>
|
||||
<StepLabel>{step.title}</StepLabel>
|
||||
<StepContent>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{step.description}
|
||||
</Typography>
|
||||
{step.action && (
|
||||
<Button
|
||||
size="small"
|
||||
onClick={step.action}
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
Essayer
|
||||
</Button>
|
||||
)}
|
||||
</StepContent>
|
||||
</Step>
|
||||
))}
|
||||
</Stepper>
|
||||
)}
|
||||
|
||||
{/* Conseils */}
|
||||
{section.tips && section.tips.length > 0 && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
<TipIcon sx={{ mr: 1, verticalAlign: 'middle', fontSize: 'small' }} />
|
||||
Conseils
|
||||
</Typography>
|
||||
<List dense>
|
||||
{section.tips.map((tip, tipIndex) => (
|
||||
<ListItem key={tipIndex}>
|
||||
<ListItemIcon>
|
||||
<CheckIcon color="success" fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={tip} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Avertissements */}
|
||||
{section.warnings && section.warnings.length > 0 && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Alert severity="warning" icon={<WarningIcon />}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Points d'attention
|
||||
</Typography>
|
||||
<List dense>
|
||||
{section.warnings.map((warning, warningIndex) => (
|
||||
<ListItem key={warningIndex} sx={{ py: 0 }}>
|
||||
<ListItemText primary={warning} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Alert>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Panneau Exemples */}
|
||||
{activeSection === 1 && (
|
||||
<Box sx={{ p: 2, height: 'calc(100% - 48px)', overflow: 'auto' }}>
|
||||
{content.examples.map((example) => (
|
||||
<Card key={example.id} sx={{ mb: 2 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{example.title}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" paragraph>
|
||||
{example.description}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Étapes à suivre :
|
||||
</Typography>
|
||||
<List dense>
|
||||
{example.steps.map((step, stepIndex) => (
|
||||
<ListItem key={stepIndex}>
|
||||
<ListItemIcon>
|
||||
<Chip label={stepIndex + 1} size="small" color="primary" />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={step} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<PlayIcon />}
|
||||
onClick={example.onTry}
|
||||
sx={{ mt: 2 }}
|
||||
>
|
||||
Essayer cet exemple
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Panneau Glossaire */}
|
||||
{activeSection === 2 && (
|
||||
<Box sx={{ height: 'calc(100% - 48px)', overflow: 'hidden' }}>
|
||||
<Glossary />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentationTab;
|
||||
@@ -0,0 +1,396 @@
|
||||
/**
|
||||
* Composant EvidenceDetail - Affichage détaillé d'une Evidence
|
||||
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
IconButton,
|
||||
Paper,
|
||||
Chip,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
Alert,
|
||||
Button,
|
||||
Tooltip,
|
||||
Divider
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Close as CloseIcon,
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
ZoomIn as ZoomInIcon,
|
||||
ZoomOut as ZoomOutIcon,
|
||||
Fullscreen as FullscreenIcon,
|
||||
Download as DownloadIcon,
|
||||
CheckCircle as SuccessIcon,
|
||||
Error as ErrorIcon,
|
||||
Schedule as TimeIcon,
|
||||
Visibility as ConfidenceIcon
|
||||
} from '@mui/icons-material';
|
||||
|
||||
import { VWBEvidence, EvidenceUtils } from '../../types/evidence';
|
||||
import ScreenshotViewer from './ScreenshotViewer';
|
||||
|
||||
interface EvidenceDetailProps {
|
||||
evidence: VWBEvidence;
|
||||
onClose?: () => void;
|
||||
showMetadata?: boolean;
|
||||
}
|
||||
|
||||
const EvidenceDetail: React.FC<EvidenceDetailProps> = ({
|
||||
evidence,
|
||||
onClose,
|
||||
showMetadata = true
|
||||
}) => {
|
||||
// État local
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [expandedPanels, setExpandedPanels] = useState<string[]>(['screenshot']);
|
||||
const screenshotRef = useRef<HTMLImageElement>(null);
|
||||
|
||||
// Gestion des panneaux accordéon
|
||||
const handlePanelChange = (panel: string) => (event: React.SyntheticEvent, isExpanded: boolean) => {
|
||||
setExpandedPanels(prev =>
|
||||
isExpanded
|
||||
? [...prev, panel]
|
||||
: prev.filter(p => p !== panel)
|
||||
);
|
||||
};
|
||||
|
||||
// Gestion du zoom
|
||||
const handleZoomIn = useCallback(() => {
|
||||
setZoom(prev => Math.min(prev * 1.2, 5));
|
||||
}, []);
|
||||
|
||||
const handleZoomOut = useCallback(() => {
|
||||
setZoom(prev => Math.max(prev / 1.2, 0.1));
|
||||
}, []);
|
||||
|
||||
const handleZoomReset = useCallback(() => {
|
||||
setZoom(1);
|
||||
}, []);
|
||||
|
||||
// Téléchargement du screenshot
|
||||
const handleDownloadScreenshot = useCallback(() => {
|
||||
if (!evidence.screenshot_base64) return;
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = `data:image/png;base64,${evidence.screenshot_base64}`;
|
||||
link.download = `evidence_${evidence.id}_${evidence.captured_at.replace(/[:.]/g, '-')}.png`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}, [evidence]);
|
||||
|
||||
// Plein écran
|
||||
const handleFullscreen = useCallback(() => {
|
||||
if (screenshotRef.current) {
|
||||
if (screenshotRef.current.requestFullscreen) {
|
||||
screenshotRef.current.requestFullscreen();
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Rendu des métadonnées
|
||||
const renderMetadata = () => {
|
||||
if (!showMetadata) return null;
|
||||
|
||||
const metadata = evidence.metadata || {};
|
||||
const metadataEntries = Object.entries(metadata);
|
||||
|
||||
if (metadataEntries.length === 0) {
|
||||
return (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Aucune métadonnée disponible
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className="evidence-metadata-grid">
|
||||
{metadataEntries.map(([key, value]) => (
|
||||
<Box key={key} className="evidence-metadata-item">
|
||||
<Typography variant="caption" className="evidence-metadata-item-label">
|
||||
{key}
|
||||
</Typography>
|
||||
<Typography variant="body2" className="evidence-metadata-item-value">
|
||||
{typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value)}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="evidence-detail" sx={{ height: '100%', overflow: 'auto' }}>
|
||||
{/* En-tête */}
|
||||
<Box className="evidence-detail-header">
|
||||
<Box>
|
||||
<Typography variant="h6" className="evidence-detail-title">
|
||||
{evidence.action_name || evidence.action_id}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#94a3b8' }}>
|
||||
ID: {evidence.id}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{onClose && (
|
||||
<IconButton
|
||||
onClick={onClose}
|
||||
className="evidence-detail-close"
|
||||
sx={{ color: '#94a3b8' }}
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Informations principales */}
|
||||
<Box mb={2}>
|
||||
<Box display="flex" alignItems="center" gap={2} mb={2}>
|
||||
<Chip
|
||||
icon={evidence.success ? <SuccessIcon /> : <ErrorIcon />}
|
||||
label={evidence.success ? 'SUCCÈS' : 'ERREUR'}
|
||||
color={evidence.success ? 'success' : 'error'}
|
||||
variant="filled"
|
||||
/>
|
||||
|
||||
<Chip
|
||||
icon={<TimeIcon />}
|
||||
label={EvidenceUtils.formatExecutionTime(evidence.execution_time_ms)}
|
||||
variant="outlined"
|
||||
sx={{ color: '#94a3b8', borderColor: '#475569' }}
|
||||
/>
|
||||
|
||||
{evidence.confidence_score && (
|
||||
<Chip
|
||||
icon={<ConfidenceIcon />}
|
||||
label={EvidenceUtils.formatConfidence(evidence.confidence_score)}
|
||||
variant="outlined"
|
||||
sx={{ color: '#94a3b8', borderColor: '#475569' }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" sx={{ color: '#94a3b8' }}>
|
||||
<strong>Capturé le :</strong> {EvidenceUtils.formatDate(evidence.captured_at)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Message d'erreur */}
|
||||
{!evidence.success && evidence.error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
{evidence.error.message}
|
||||
</Typography>
|
||||
{evidence.error.details && Object.keys(evidence.error.details).length > 0 && (
|
||||
<Box mt={1}>
|
||||
<Typography variant="caption" display="block">
|
||||
Détails techniques :
|
||||
</Typography>
|
||||
<pre style={{ fontSize: '11px', margin: '4px 0', whiteSpace: 'pre-wrap' }}>
|
||||
{JSON.stringify(evidence.error.details, null, 2)}
|
||||
</pre>
|
||||
</Box>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Screenshot */}
|
||||
{evidence.screenshot_base64 && (
|
||||
<Accordion
|
||||
expanded={expandedPanels.includes('screenshot')}
|
||||
onChange={handlePanelChange('screenshot')}
|
||||
sx={{ mb: 2, backgroundColor: '#0f172a', color: '#e2e8f0' }}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon sx={{ color: '#94a3b8' }} />}>
|
||||
<Typography variant="subtitle2">
|
||||
Screenshot d'Evidence
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
{/* Contrôles du screenshot */}
|
||||
<Box display="flex" gap={1} mb={2} flexWrap="wrap">
|
||||
<Tooltip title="Zoom avant">
|
||||
<IconButton size="small" onClick={handleZoomIn} sx={{ color: '#94a3b8' }}>
|
||||
<ZoomInIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Zoom arrière">
|
||||
<IconButton size="small" onClick={handleZoomOut} sx={{ color: '#94a3b8' }}>
|
||||
<ZoomOutIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Taille réelle">
|
||||
<Button size="small" onClick={handleZoomReset} sx={{ color: '#94a3b8' }}>
|
||||
{Math.round(zoom * 100)}%
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Plein écran">
|
||||
<IconButton size="small" onClick={handleFullscreen} sx={{ color: '#94a3b8' }}>
|
||||
<FullscreenIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Télécharger">
|
||||
<IconButton size="small" onClick={handleDownloadScreenshot} sx={{ color: '#94a3b8' }}>
|
||||
<DownloadIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{/* Visualiseur de screenshot */}
|
||||
<ScreenshotViewer
|
||||
screenshot={evidence.screenshot_base64}
|
||||
bbox={evidence.bbox}
|
||||
clickPoint={evidence.click_point}
|
||||
zoom={zoom}
|
||||
onZoomChange={setZoom}
|
||||
maxWidth={600}
|
||||
maxHeight={400}
|
||||
/>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
)}
|
||||
|
||||
{/* Détails techniques */}
|
||||
<Accordion
|
||||
expanded={expandedPanels.includes('technical')}
|
||||
onChange={handlePanelChange('technical')}
|
||||
sx={{ mb: 2, backgroundColor: '#0f172a', color: '#e2e8f0' }}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon sx={{ color: '#94a3b8' }} />}>
|
||||
<Typography variant="subtitle2">
|
||||
Détails Techniques
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box className="evidence-metadata-grid">
|
||||
<Box className="evidence-metadata-item">
|
||||
<Typography variant="caption" className="evidence-metadata-item-label">
|
||||
Contrat
|
||||
</Typography>
|
||||
<Typography variant="body2" className="evidence-metadata-item-value">
|
||||
{evidence.contract} v{evidence.version}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box className="evidence-metadata-item">
|
||||
<Typography variant="caption" className="evidence-metadata-item-label">
|
||||
Action ID
|
||||
</Typography>
|
||||
<Typography variant="body2" className="evidence-metadata-item-value">
|
||||
{evidence.action_id}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box className="evidence-metadata-item">
|
||||
<Typography variant="caption" className="evidence-metadata-item-label">
|
||||
Temps d'exécution
|
||||
</Typography>
|
||||
<Typography variant="body2" className="evidence-metadata-item-value">
|
||||
{evidence.execution_time_ms}ms
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{evidence.confidence_score && (
|
||||
<Box className="evidence-metadata-item">
|
||||
<Typography variant="caption" className="evidence-metadata-item-label">
|
||||
Score de confiance
|
||||
</Typography>
|
||||
<Typography variant="body2" className="evidence-metadata-item-value">
|
||||
{evidence.confidence_score.toFixed(3)}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{evidence.bbox && (
|
||||
<Box className="evidence-metadata-item">
|
||||
<Typography variant="caption" className="evidence-metadata-item-label">
|
||||
Zone détectée
|
||||
</Typography>
|
||||
<Typography variant="body2" className="evidence-metadata-item-value">
|
||||
{evidence.bbox.x}, {evidence.bbox.y} ({evidence.bbox.width}×{evidence.bbox.height})
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{evidence.click_point && (
|
||||
<Box className="evidence-metadata-item">
|
||||
<Typography variant="caption" className="evidence-metadata-item-label">
|
||||
Point de clic
|
||||
</Typography>
|
||||
<Typography variant="body2" className="evidence-metadata-item-value">
|
||||
{evidence.click_point.x}, {evidence.click_point.y}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
{/* Métadonnées personnalisées */}
|
||||
{showMetadata && evidence.metadata && Object.keys(evidence.metadata).length > 0 && (
|
||||
<Accordion
|
||||
expanded={expandedPanels.includes('metadata')}
|
||||
onChange={handlePanelChange('metadata')}
|
||||
sx={{ mb: 2, backgroundColor: '#0f172a', color: '#e2e8f0' }}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon sx={{ color: '#94a3b8' }} />}>
|
||||
<Typography variant="subtitle2">
|
||||
Métadonnées Personnalisées
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
{renderMetadata()}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
)}
|
||||
|
||||
{/* Données brutes (pour debug) */}
|
||||
<Accordion
|
||||
expanded={expandedPanels.includes('raw')}
|
||||
onChange={handlePanelChange('raw')}
|
||||
sx={{ mb: 2, backgroundColor: '#0f172a', color: '#e2e8f0' }}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon sx={{ color: '#94a3b8' }} />}>
|
||||
<Typography variant="subtitle2">
|
||||
Données Brutes (Debug)
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Paper
|
||||
sx={{
|
||||
p: 2,
|
||||
backgroundColor: '#1e293b',
|
||||
border: '1px solid #334155',
|
||||
maxHeight: 300,
|
||||
overflow: 'auto'
|
||||
}}
|
||||
>
|
||||
<pre style={{
|
||||
fontSize: '11px',
|
||||
margin: 0,
|
||||
whiteSpace: 'pre-wrap',
|
||||
color: '#e2e8f0',
|
||||
fontFamily: 'monospace'
|
||||
}}>
|
||||
{JSON.stringify({
|
||||
...evidence,
|
||||
screenshot_base64: evidence.screenshot_base64 ? '[BASE64_DATA]' : null
|
||||
}, null, 2)}
|
||||
</pre>
|
||||
</Paper>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EvidenceDetail;
|
||||
@@ -0,0 +1,406 @@
|
||||
/**
|
||||
* Composant EvidenceFilters - Filtres pour les Evidence
|
||||
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
TextField,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
Chip,
|
||||
Button,
|
||||
Slider,
|
||||
Typography,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
SelectChangeEvent
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
Clear as ClearIcon,
|
||||
FilterList as FilterIcon
|
||||
} from '@mui/icons-material';
|
||||
|
||||
import { VWBEvidence, EvidenceFilters } from '../../types/evidence';
|
||||
|
||||
interface EvidenceFiltersProps {
|
||||
filters: EvidenceFilters;
|
||||
onFiltersChange: (filters: Partial<EvidenceFilters>) => void;
|
||||
onClearFilters: () => void;
|
||||
evidences: VWBEvidence[];
|
||||
}
|
||||
|
||||
const EvidenceFiltersComponent: React.FC<EvidenceFiltersProps> = ({
|
||||
filters,
|
||||
onFiltersChange,
|
||||
onClearFilters,
|
||||
evidences
|
||||
}) => {
|
||||
// État local
|
||||
const [expandedPanels, setExpandedPanels] = useState<string[]>(['basic']);
|
||||
|
||||
// Types d'actions disponibles
|
||||
const availableActionTypes = useMemo(() => {
|
||||
const types = new Set<string>();
|
||||
evidences.forEach(evidence => {
|
||||
types.add(evidence.action_name || evidence.action_id);
|
||||
});
|
||||
return Array.from(types).sort();
|
||||
}, [evidences]);
|
||||
|
||||
// Plages de valeurs pour les sliders
|
||||
const valueRanges = useMemo(() => {
|
||||
const executionTimes = evidences.map(e => e.execution_time_ms);
|
||||
const confidenceScores = evidences.filter(e => e.confidence_score !== undefined).map(e => e.confidence_score!);
|
||||
|
||||
return {
|
||||
executionTime: {
|
||||
min: Math.min(...executionTimes, 0),
|
||||
max: Math.max(...executionTimes, 60000)
|
||||
},
|
||||
confidence: {
|
||||
min: Math.min(...confidenceScores, 0),
|
||||
max: Math.max(...confidenceScores, 1)
|
||||
}
|
||||
};
|
||||
}, [evidences]);
|
||||
|
||||
// Gestion des panneaux accordéon
|
||||
const handlePanelChange = (panel: string) => (event: React.SyntheticEvent, isExpanded: boolean) => {
|
||||
setExpandedPanels(prev =>
|
||||
isExpanded
|
||||
? [...prev, panel]
|
||||
: prev.filter(p => p !== panel)
|
||||
);
|
||||
};
|
||||
|
||||
// Gestion des types d'actions
|
||||
const handleActionTypesChange = (event: SelectChangeEvent<string[]>) => {
|
||||
const value = event.target.value;
|
||||
onFiltersChange({
|
||||
actionTypes: typeof value === 'string' ? value.split(',') : value
|
||||
});
|
||||
};
|
||||
|
||||
// Gestion du statut
|
||||
const handleStatusChange = (event: SelectChangeEvent<string>) => {
|
||||
onFiltersChange({
|
||||
status: event.target.value as 'all' | 'success' | 'error'
|
||||
});
|
||||
};
|
||||
|
||||
// Gestion de la recherche textuelle
|
||||
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onFiltersChange({
|
||||
searchText: event.target.value
|
||||
});
|
||||
};
|
||||
|
||||
// Gestion des dates
|
||||
const handleStartDateChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const date = event.target.value ? new Date(event.target.value) : undefined;
|
||||
onFiltersChange({
|
||||
dateRange: {
|
||||
...filters.dateRange,
|
||||
start: date
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleEndDateChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const date = event.target.value ? new Date(event.target.value) : undefined;
|
||||
onFiltersChange({
|
||||
dateRange: {
|
||||
...filters.dateRange,
|
||||
end: date
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Fonction utilitaire pour formater les dates
|
||||
const formatDateForInput = (date: Date | undefined): string => {
|
||||
if (!date) return '';
|
||||
return date.toISOString().split('T')[0];
|
||||
};
|
||||
|
||||
// Gestion des sliders
|
||||
const handleConfidenceRangeChange = (event: Event, newValue: number | number[]) => {
|
||||
const [min, max] = newValue as number[];
|
||||
onFiltersChange({
|
||||
confidenceRange: { min, max }
|
||||
});
|
||||
};
|
||||
|
||||
const handleExecutionTimeRangeChange = (event: Event, newValue: number | number[]) => {
|
||||
const [min, max] = newValue as number[];
|
||||
onFiltersChange({
|
||||
executionTimeRange: { min, max }
|
||||
});
|
||||
};
|
||||
|
||||
// Vérification si des filtres sont appliqués
|
||||
const hasActiveFilters = (
|
||||
filters.actionTypes.length > 0 ||
|
||||
filters.status !== 'all' ||
|
||||
filters.searchText.trim() !== '' ||
|
||||
filters.dateRange.start !== undefined ||
|
||||
filters.dateRange.end !== undefined ||
|
||||
filters.confidenceRange.min > valueRanges.confidence.min ||
|
||||
filters.confidenceRange.max < valueRanges.confidence.max ||
|
||||
filters.executionTimeRange.min > valueRanges.executionTime.min ||
|
||||
filters.executionTimeRange.max < valueRanges.executionTime.max
|
||||
);
|
||||
|
||||
return (
|
||||
<Box className="evidence-filters">
|
||||
<Box display="flex" alignItems="center" justifyContent="space-between" mb={2}>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<FilterIcon sx={{ color: '#1976d2' }} />
|
||||
<Typography variant="h6" sx={{ color: '#e2e8f0', fontWeight: 600 }}>
|
||||
Filtres
|
||||
</Typography>
|
||||
{hasActiveFilters && (
|
||||
<Chip
|
||||
label="Filtres actifs"
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="filled"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
startIcon={<ClearIcon />}
|
||||
onClick={onClearFilters}
|
||||
size="small"
|
||||
sx={{ color: '#94a3b8' }}
|
||||
>
|
||||
Effacer
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Filtres de base */}
|
||||
<Accordion
|
||||
expanded={expandedPanels.includes('basic')}
|
||||
onChange={handlePanelChange('basic')}
|
||||
sx={{ mb: 1, backgroundColor: '#334155', color: '#e2e8f0' }}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon sx={{ color: '#94a3b8' }} />}>
|
||||
<Typography variant="subtitle2">Filtres de base</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box className="evidence-filters-row">
|
||||
{/* Recherche textuelle */}
|
||||
<TextField
|
||||
label="Recherche"
|
||||
placeholder="Rechercher dans les Evidence..."
|
||||
value={filters.searchText}
|
||||
onChange={handleSearchChange}
|
||||
size="small"
|
||||
sx={{
|
||||
minWidth: 200,
|
||||
'& .MuiOutlinedInput-root': {
|
||||
backgroundColor: '#1e293b',
|
||||
color: '#e2e8f0',
|
||||
'& fieldset': { borderColor: '#475569' },
|
||||
'&:hover fieldset': { borderColor: '#64748b' },
|
||||
'&.Mui-focused fieldset': { borderColor: '#1976d2' }
|
||||
},
|
||||
'& .MuiInputLabel-root': { color: '#94a3b8' }
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Statut */}
|
||||
<FormControl size="small" sx={{ minWidth: 120 }}>
|
||||
<InputLabel sx={{ color: '#94a3b8' }}>Statut</InputLabel>
|
||||
<Select
|
||||
value={filters.status}
|
||||
onChange={handleStatusChange}
|
||||
label="Statut"
|
||||
sx={{
|
||||
backgroundColor: '#1e293b',
|
||||
color: '#e2e8f0',
|
||||
'& .MuiOutlinedInput-notchedOutline': { borderColor: '#475569' },
|
||||
'&:hover .MuiOutlinedInput-notchedOutline': { borderColor: '#64748b' },
|
||||
'&.Mui-focused .MuiOutlinedInput-notchedOutline': { borderColor: '#1976d2' }
|
||||
}}
|
||||
>
|
||||
<MenuItem value="all">Tous</MenuItem>
|
||||
<MenuItem value="success">Succès</MenuItem>
|
||||
<MenuItem value="error">Erreurs</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* Types d'actions */}
|
||||
<FormControl size="small" sx={{ minWidth: 200 }}>
|
||||
<InputLabel sx={{ color: '#94a3b8' }}>Types d'actions</InputLabel>
|
||||
<Select
|
||||
multiple
|
||||
value={filters.actionTypes}
|
||||
onChange={handleActionTypesChange}
|
||||
label="Types d'actions"
|
||||
renderValue={(selected) => (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{selected.map((value) => (
|
||||
<Chip key={value} label={value} size="small" />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
sx={{
|
||||
backgroundColor: '#1e293b',
|
||||
color: '#e2e8f0',
|
||||
'& .MuiOutlinedInput-notchedOutline': { borderColor: '#475569' },
|
||||
'&:hover .MuiOutlinedInput-notchedOutline': { borderColor: '#64748b' },
|
||||
'&.Mui-focused .MuiOutlinedInput-notchedOutline': { borderColor: '#1976d2' }
|
||||
}}
|
||||
>
|
||||
{availableActionTypes.map((type) => (
|
||||
<MenuItem key={type} value={type}>
|
||||
{type}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
{/* Filtres de date */}
|
||||
<Accordion
|
||||
expanded={expandedPanels.includes('dates')}
|
||||
onChange={handlePanelChange('dates')}
|
||||
sx={{ mb: 1, backgroundColor: '#334155', color: '#e2e8f0' }}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon sx={{ color: '#94a3b8' }} />}>
|
||||
<Typography variant="subtitle2">Plage de dates</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box className="evidence-filters-row">
|
||||
<TextField
|
||||
label="Date de début"
|
||||
type="date"
|
||||
value={formatDateForInput(filters.dateRange.start)}
|
||||
onChange={handleStartDateChange}
|
||||
size="small"
|
||||
InputLabelProps={{
|
||||
shrink: true,
|
||||
}}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
backgroundColor: '#1e293b',
|
||||
color: '#e2e8f0',
|
||||
'& fieldset': { borderColor: '#475569' },
|
||||
'&:hover fieldset': { borderColor: '#64748b' },
|
||||
'&.Mui-focused fieldset': { borderColor: '#1976d2' }
|
||||
},
|
||||
'& .MuiInputLabel-root': { color: '#94a3b8' }
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Date de fin"
|
||||
type="date"
|
||||
value={formatDateForInput(filters.dateRange.end)}
|
||||
onChange={handleEndDateChange}
|
||||
size="small"
|
||||
InputLabelProps={{
|
||||
shrink: true,
|
||||
}}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
backgroundColor: '#1e293b',
|
||||
color: '#e2e8f0',
|
||||
'& fieldset': { borderColor: '#475569' },
|
||||
'&:hover fieldset': { borderColor: '#64748b' },
|
||||
'&.Mui-focused fieldset': { borderColor: '#1976d2' }
|
||||
},
|
||||
'& .MuiInputLabel-root': { color: '#94a3b8' }
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
{/* Filtres avancés */}
|
||||
<Accordion
|
||||
expanded={expandedPanels.includes('advanced')}
|
||||
onChange={handlePanelChange('advanced')}
|
||||
sx={{ mb: 1, backgroundColor: '#334155', color: '#e2e8f0' }}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon sx={{ color: '#94a3b8' }} />}>
|
||||
<Typography variant="subtitle2">Filtres avancés</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box>
|
||||
{/* Plage de confiance */}
|
||||
{valueRanges.confidence.max > 0 && (
|
||||
<Box mb={3}>
|
||||
<Typography variant="caption" sx={{ color: '#94a3b8', mb: 1, display: 'block' }}>
|
||||
Score de confiance: {Math.round(filters.confidenceRange.min * 100)}% - {Math.round(filters.confidenceRange.max * 100)}%
|
||||
</Typography>
|
||||
<Slider
|
||||
value={[filters.confidenceRange.min, filters.confidenceRange.max]}
|
||||
onChange={handleConfidenceRangeChange}
|
||||
valueLabelDisplay="auto"
|
||||
valueLabelFormat={(value) => `${Math.round(value * 100)}%`}
|
||||
min={valueRanges.confidence.min}
|
||||
max={valueRanges.confidence.max}
|
||||
step={0.01}
|
||||
sx={{
|
||||
color: '#1976d2',
|
||||
'& .MuiSlider-thumb': {
|
||||
backgroundColor: '#1976d2'
|
||||
},
|
||||
'& .MuiSlider-track': {
|
||||
backgroundColor: '#1976d2'
|
||||
},
|
||||
'& .MuiSlider-rail': {
|
||||
backgroundColor: '#475569'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Plage de temps d'exécution */}
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: '#94a3b8', mb: 1, display: 'block' }}>
|
||||
Temps d'exécution: {filters.executionTimeRange.min}ms - {filters.executionTimeRange.max}ms
|
||||
</Typography>
|
||||
<Slider
|
||||
value={[filters.executionTimeRange.min, filters.executionTimeRange.max]}
|
||||
onChange={handleExecutionTimeRangeChange}
|
||||
valueLabelDisplay="auto"
|
||||
valueLabelFormat={(value) => `${value}ms`}
|
||||
min={valueRanges.executionTime.min}
|
||||
max={valueRanges.executionTime.max}
|
||||
step={100}
|
||||
sx={{
|
||||
color: '#1976d2',
|
||||
'& .MuiSlider-thumb': {
|
||||
backgroundColor: '#1976d2'
|
||||
},
|
||||
'& .MuiSlider-track': {
|
||||
backgroundColor: '#1976d2'
|
||||
},
|
||||
'& .MuiSlider-rail': {
|
||||
backgroundColor: '#475569'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EvidenceFiltersComponent;
|
||||
@@ -0,0 +1,415 @@
|
||||
/**
|
||||
* Composant EvidenceList - Liste des Evidence avec filtrage et tri
|
||||
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemText,
|
||||
ListItemAvatar,
|
||||
Avatar,
|
||||
Typography,
|
||||
Chip,
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuItem,
|
||||
TextField,
|
||||
InputAdornment,
|
||||
Pagination,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
SelectChangeEvent
|
||||
} from '@mui/material';
|
||||
import {
|
||||
CheckCircle as SuccessIcon,
|
||||
Error as ErrorIcon,
|
||||
Schedule as TimeIcon,
|
||||
Search as SearchIcon,
|
||||
Sort as SortIcon,
|
||||
FilterList as FilterIcon
|
||||
} from '@mui/icons-material';
|
||||
|
||||
import { VWBEvidence, EvidenceFilters, EvidenceUtils } from '../../types/evidence';
|
||||
|
||||
interface EvidenceListProps {
|
||||
evidences: VWBEvidence[];
|
||||
selectedId?: string;
|
||||
onSelect: (evidence: VWBEvidence) => void;
|
||||
filters?: EvidenceFilters;
|
||||
onFiltersChange?: (filters: Partial<EvidenceFilters>) => void;
|
||||
sortBy: string;
|
||||
sortOrder: 'asc' | 'desc';
|
||||
onSortChange: (sortBy: string, sortOrder: 'asc' | 'desc') => void;
|
||||
viewMode: 'list' | 'grid';
|
||||
showFilters?: boolean;
|
||||
itemsPerPage?: number;
|
||||
}
|
||||
|
||||
const EvidenceList: React.FC<EvidenceListProps> = ({
|
||||
evidences,
|
||||
selectedId,
|
||||
onSelect,
|
||||
filters,
|
||||
onFiltersChange,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
onSortChange,
|
||||
viewMode = 'list',
|
||||
showFilters = false,
|
||||
itemsPerPage = 20
|
||||
}) => {
|
||||
// État local
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [searchText, setSearchText] = useState(filters?.searchText || '');
|
||||
const [sortMenuAnchor, setSortMenuAnchor] = useState<null | HTMLElement>(null);
|
||||
|
||||
// Evidence paginées
|
||||
const paginatedEvidences = useMemo(() => {
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
const endIndex = startIndex + itemsPerPage;
|
||||
return evidences.slice(startIndex, endIndex);
|
||||
}, [evidences, currentPage, itemsPerPage]);
|
||||
|
||||
// Nombre total de pages
|
||||
const totalPages = Math.ceil(evidences.length / itemsPerPage);
|
||||
|
||||
// Gestion de la recherche
|
||||
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.target.value;
|
||||
setSearchText(value);
|
||||
setCurrentPage(1); // Retour à la première page
|
||||
|
||||
if (onFiltersChange) {
|
||||
onFiltersChange({ searchText: value });
|
||||
}
|
||||
};
|
||||
|
||||
// Gestion du tri
|
||||
const handleSortMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setSortMenuAnchor(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleSortMenuClose = () => {
|
||||
setSortMenuAnchor(null);
|
||||
};
|
||||
|
||||
const handleSortChange = (newSortBy: string) => {
|
||||
const newSortOrder = sortBy === newSortBy && sortOrder === 'desc' ? 'asc' : 'desc';
|
||||
onSortChange(newSortBy, newSortOrder);
|
||||
handleSortMenuClose();
|
||||
};
|
||||
|
||||
// Gestion du changement de page
|
||||
const handlePageChange = (event: React.ChangeEvent<unknown>, page: number) => {
|
||||
setCurrentPage(page);
|
||||
};
|
||||
|
||||
// Rendu d'un item Evidence
|
||||
const renderEvidenceItem = (evidence: VWBEvidence) => {
|
||||
const isSelected = selectedId === evidence.id;
|
||||
const isSuccess = evidence.success;
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={evidence.id}
|
||||
disablePadding
|
||||
className={`evidence-list-item ${isSelected ? 'selected' : ''} ${isSuccess ? 'success' : 'error'}`}
|
||||
sx={{
|
||||
mb: 1,
|
||||
borderRadius: 2,
|
||||
border: isSelected ? '2px solid #1976d2' : '1px solid #334155',
|
||||
backgroundColor: isSelected ? '#1e40af' : '#334155',
|
||||
}}
|
||||
>
|
||||
<ListItemButton
|
||||
selected={isSelected}
|
||||
onClick={() => onSelect(evidence)}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
'&:hover': {
|
||||
backgroundColor: isSelected ? '#1e40af' : '#475569',
|
||||
transform: 'translateY(-1px)',
|
||||
boxShadow: '0 4px 8px rgba(0, 0, 0, 0.2)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ListItemAvatar>
|
||||
<Avatar
|
||||
sx={{
|
||||
bgcolor: isSuccess ? '#22c55e' : '#ef4444',
|
||||
width: 32,
|
||||
height: 32
|
||||
}}
|
||||
>
|
||||
{isSuccess ? <SuccessIcon fontSize="small" /> : <ErrorIcon fontSize="small" />}
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
|
||||
<ListItemText
|
||||
primary={
|
||||
<Typography variant="subtitle2" sx={{ color: '#e2e8f0', fontWeight: 600 }}>
|
||||
{evidence.action_name || evidence.action_id}
|
||||
</Typography>
|
||||
}
|
||||
secondary={
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: '#94a3b8', display: 'block' }}>
|
||||
{EvidenceUtils.formatDate(evidence.captured_at)}
|
||||
</Typography>
|
||||
<Box display="flex" alignItems="center" gap={1} mt={0.5}>
|
||||
<Chip
|
||||
icon={<TimeIcon />}
|
||||
label={EvidenceUtils.formatExecutionTime(evidence.execution_time_ms)}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
fontSize: '10px',
|
||||
height: 20,
|
||||
color: '#94a3b8',
|
||||
borderColor: '#475569'
|
||||
}}
|
||||
/>
|
||||
{evidence.confidence_score && (
|
||||
<Chip
|
||||
label={EvidenceUtils.formatConfidence(evidence.confidence_score)}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
fontSize: '10px',
|
||||
height: 20,
|
||||
color: '#94a3b8',
|
||||
borderColor: '#475569'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!isSuccess && (
|
||||
<Chip
|
||||
label="ERREUR"
|
||||
size="small"
|
||||
sx={{
|
||||
fontSize: '10px',
|
||||
height: 20,
|
||||
backgroundColor: '#ef4444',
|
||||
color: 'white'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Miniature du screenshot */}
|
||||
{evidence.screenshot_base64 && (
|
||||
<Box ml={1}>
|
||||
<img
|
||||
src={`data:image/png;base64,${evidence.screenshot_base64}`}
|
||||
alt={`Screenshot de ${evidence.action_name || evidence.action_id}`}
|
||||
className="evidence-thumbnail"
|
||||
style={{
|
||||
width: 60,
|
||||
height: 40,
|
||||
objectFit: 'cover',
|
||||
borderRadius: 4,
|
||||
border: '1px solid #475569'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
);
|
||||
};
|
||||
|
||||
// Rendu en mode grille
|
||||
const renderGridView = () => (
|
||||
<Box className="evidence-grid">
|
||||
{paginatedEvidences.map(evidence => {
|
||||
const isSelected = selectedId === evidence.id;
|
||||
const isSuccess = evidence.success;
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={evidence.id}
|
||||
className={`evidence-grid-item ${isSelected ? 'selected' : ''}`}
|
||||
onClick={() => onSelect(evidence)}
|
||||
sx={{
|
||||
border: isSelected ? '2px solid #1976d2' : '1px solid #334155',
|
||||
backgroundColor: isSelected ? '#1e40af' : '#334155',
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
backgroundColor: isSelected ? '#1e40af' : '#475569',
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 6px 12px rgba(0, 0, 0, 0.3)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* En-tête */}
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={1}>
|
||||
<Typography variant="subtitle2" sx={{ color: '#e2e8f0', fontWeight: 600 }}>
|
||||
{evidence.action_name || evidence.action_id}
|
||||
</Typography>
|
||||
<Chip
|
||||
icon={isSuccess ? <SuccessIcon /> : <ErrorIcon />}
|
||||
label={isSuccess ? 'OK' : 'ERR'}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: isSuccess ? '#22c55e' : '#ef4444',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
height: 20
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Screenshot */}
|
||||
{evidence.screenshot_base64 && (
|
||||
<img
|
||||
src={`data:image/png;base64,${evidence.screenshot_base64}`}
|
||||
alt={`Screenshot de ${evidence.action_name || evidence.action_id}`}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 100,
|
||||
objectFit: 'cover',
|
||||
borderRadius: 4,
|
||||
border: '1px solid #475569',
|
||||
marginBottom: 8
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Métadonnées */}
|
||||
<Box flex={1}>
|
||||
<Typography variant="caption" sx={{ color: '#94a3b8', display: 'block' }}>
|
||||
{EvidenceUtils.formatDate(evidence.captured_at)}
|
||||
</Typography>
|
||||
<Box display="flex" justifyContent="space-between" mt={1}>
|
||||
<Typography variant="caption" sx={{ color: '#94a3b8' }}>
|
||||
{EvidenceUtils.formatExecutionTime(evidence.execution_time_ms)}
|
||||
</Typography>
|
||||
{evidence.confidence_score && (
|
||||
<Typography variant="caption" sx={{ color: '#94a3b8' }}>
|
||||
{EvidenceUtils.formatConfidence(evidence.confidence_score)}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box height="100%" display="flex" flexDirection="column">
|
||||
{/* Barre d'outils */}
|
||||
<Box p={2} borderBottom="1px solid #334155">
|
||||
<Box display="flex" gap={2} alignItems="center" mb={showFilters ? 2 : 0}>
|
||||
{/* Recherche */}
|
||||
<TextField
|
||||
size="small"
|
||||
placeholder="Rechercher dans les Evidence..."
|
||||
value={searchText}
|
||||
onChange={handleSearchChange}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon sx={{ color: '#94a3b8' }} />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
sx={{
|
||||
flex: 1,
|
||||
'& .MuiOutlinedInput-root': {
|
||||
backgroundColor: '#334155',
|
||||
color: '#e2e8f0',
|
||||
'& fieldset': { borderColor: '#475569' },
|
||||
'&:hover fieldset': { borderColor: '#64748b' },
|
||||
'&.Mui-focused fieldset': { borderColor: '#1976d2' }
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Bouton de tri */}
|
||||
<IconButton
|
||||
onClick={handleSortMenuOpen}
|
||||
sx={{ color: '#94a3b8' }}
|
||||
>
|
||||
<SortIcon />
|
||||
</IconButton>
|
||||
|
||||
{/* Menu de tri */}
|
||||
<Menu
|
||||
anchorEl={sortMenuAnchor}
|
||||
open={Boolean(sortMenuAnchor)}
|
||||
onClose={handleSortMenuClose}
|
||||
>
|
||||
<MenuItem onClick={() => handleSortChange('date')}>
|
||||
Date {sortBy === 'date' && (sortOrder === 'desc' ? '↓' : '↑')}
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => handleSortChange('action')}>
|
||||
Action {sortBy === 'action' && (sortOrder === 'desc' ? '↓' : '↑')}
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => handleSortChange('status')}>
|
||||
Statut {sortBy === 'status' && (sortOrder === 'desc' ? '↓' : '↑')}
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => handleSortChange('execution_time')}>
|
||||
Temps {sortBy === 'execution_time' && (sortOrder === 'desc' ? '↓' : '↑')}
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => handleSortChange('confidence')}>
|
||||
Confiance {sortBy === 'confidence' && (sortOrder === 'desc' ? '↓' : '↑')}
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Box>
|
||||
|
||||
{/* Informations */}
|
||||
<Typography variant="caption" sx={{ color: '#94a3b8' }}>
|
||||
{evidences.length} Evidence{evidences.length > 1 ? 's' : ''}
|
||||
{evidences.length !== paginatedEvidences.length &&
|
||||
` (${paginatedEvidences.length} affichée${paginatedEvidences.length > 1 ? 's' : ''})`
|
||||
}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Liste ou grille */}
|
||||
<Box flex={1} overflow="auto">
|
||||
{viewMode === 'list' ? (
|
||||
<List sx={{ p: 1 }}>
|
||||
{paginatedEvidences.map(renderEvidenceItem)}
|
||||
</List>
|
||||
) : (
|
||||
renderGridView()
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<Box p={2} borderTop="1px solid #334155" display="flex" justifyContent="center">
|
||||
<Pagination
|
||||
count={totalPages}
|
||||
page={currentPage}
|
||||
onChange={handlePageChange}
|
||||
color="primary"
|
||||
size="small"
|
||||
sx={{
|
||||
'& .MuiPaginationItem-root': {
|
||||
color: '#94a3b8',
|
||||
'&.Mui-selected': {
|
||||
backgroundColor: '#1976d2',
|
||||
color: 'white'
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EvidenceList;
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Composant EvidenceStats - Affichage des statistiques des Evidence
|
||||
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Typography, Chip, LinearProgress } from '@mui/material';
|
||||
import {
|
||||
CheckCircle as SuccessIcon,
|
||||
Error as ErrorIcon,
|
||||
Schedule as TimeIcon,
|
||||
Visibility as ConfidenceIcon,
|
||||
Assessment as StatsIcon
|
||||
} from '@mui/icons-material';
|
||||
|
||||
import { EvidenceStats, EvidenceUtils } from '../../types/evidence';
|
||||
|
||||
interface EvidenceStatsProps {
|
||||
stats: EvidenceStats;
|
||||
totalEvidences: number;
|
||||
filteredCount: number;
|
||||
hasFilters: boolean;
|
||||
}
|
||||
|
||||
const EvidenceStatsComponent: React.FC<EvidenceStatsProps> = ({
|
||||
stats,
|
||||
totalEvidences,
|
||||
filteredCount,
|
||||
hasFilters
|
||||
}) => {
|
||||
// Calcul du taux de succès
|
||||
const successRate = stats.total > 0 ? (stats.successful / stats.total) * 100 : 0;
|
||||
|
||||
// Couleur du taux de succès
|
||||
const getSuccessRateColor = (rate: number) => {
|
||||
if (rate >= 90) return '#22c55e'; // Vert
|
||||
if (rate >= 70) return '#f59e0b'; // Orange
|
||||
return '#ef4444'; // Rouge
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="evidence-stats">
|
||||
<Box display="flex" alignItems="center" gap={1} mb={2}>
|
||||
<StatsIcon sx={{ color: '#1976d2' }} />
|
||||
<Typography variant="h6" sx={{ color: '#e2e8f0', fontWeight: 600 }}>
|
||||
Statistiques des Evidence
|
||||
</Typography>
|
||||
{hasFilters && (
|
||||
<Chip
|
||||
label={`${filteredCount}/${totalEvidences} filtrées`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ color: '#94a3b8', borderColor: '#475569' }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box className="evidence-stats-grid">
|
||||
{/* Total */}
|
||||
<Box className="evidence-stat-item">
|
||||
<Typography variant="h4" className="evidence-stat-value">
|
||||
{stats.total}
|
||||
</Typography>
|
||||
<Typography variant="caption" className="evidence-stat-label">
|
||||
Total
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Réussies */}
|
||||
<Box className="evidence-stat-item">
|
||||
<Box display="flex" alignItems="center" justifyContent="center" gap={0.5} mb={0.5}>
|
||||
<SuccessIcon sx={{ color: '#22c55e', fontSize: 16 }} />
|
||||
<Typography variant="h4" sx={{ color: '#22c55e', fontWeight: 700 }}>
|
||||
{stats.successful}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="caption" className="evidence-stat-label">
|
||||
Réussies
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Échouées */}
|
||||
<Box className="evidence-stat-item">
|
||||
<Box display="flex" alignItems="center" justifyContent="center" gap={0.5} mb={0.5}>
|
||||
<ErrorIcon sx={{ color: '#ef4444', fontSize: 16 }} />
|
||||
<Typography variant="h4" sx={{ color: '#ef4444', fontWeight: 700 }}>
|
||||
{stats.failed}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="caption" className="evidence-stat-label">
|
||||
Échouées
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Taux de succès */}
|
||||
<Box className="evidence-stat-item">
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{
|
||||
color: getSuccessRateColor(successRate),
|
||||
fontWeight: 700
|
||||
}}
|
||||
>
|
||||
{Math.round(successRate)}%
|
||||
</Typography>
|
||||
<Typography variant="caption" className="evidence-stat-label">
|
||||
Taux de succès
|
||||
</Typography>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={successRate}
|
||||
sx={{
|
||||
mt: 0.5,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#334155',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
backgroundColor: getSuccessRateColor(successRate)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Temps moyen */}
|
||||
<Box className="evidence-stat-item">
|
||||
<Box display="flex" alignItems="center" justifyContent="center" gap={0.5} mb={0.5}>
|
||||
<TimeIcon sx={{ color: '#1976d2', fontSize: 16 }} />
|
||||
<Typography variant="h4" className="evidence-stat-value">
|
||||
{EvidenceUtils.formatExecutionTime(stats.averageExecutionTime)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="caption" className="evidence-stat-label">
|
||||
Temps moyen
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Confiance moyenne */}
|
||||
{stats.averageConfidence > 0 && (
|
||||
<Box className="evidence-stat-item">
|
||||
<Box display="flex" alignItems="center" justifyContent="center" gap={0.5} mb={0.5}>
|
||||
<ConfidenceIcon sx={{ color: '#1976d2', fontSize: 16 }} />
|
||||
<Typography variant="h4" className="evidence-stat-value">
|
||||
{EvidenceUtils.formatConfidence(stats.averageConfidence)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="caption" className="evidence-stat-label">
|
||||
Confiance moyenne
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Distribution des types d'actions */}
|
||||
{Object.keys(stats.actionTypeDistribution).length > 0 && (
|
||||
<Box mt={2}>
|
||||
<Typography variant="subtitle2" sx={{ color: '#e2e8f0', mb: 1 }}>
|
||||
Répartition par type d'action
|
||||
</Typography>
|
||||
<Box display="flex" flexWrap="wrap" gap={1}>
|
||||
{Object.entries(stats.actionTypeDistribution)
|
||||
.sort(([,a], [,b]) => b - a) // Tri par nombre décroissant
|
||||
.slice(0, 6) // Limite à 6 types max
|
||||
.map(([actionType, count]) => (
|
||||
<Chip
|
||||
key={actionType}
|
||||
label={`${actionType} (${count})`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
color: '#94a3b8',
|
||||
borderColor: '#475569',
|
||||
backgroundColor: '#334155'
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Timeline récente */}
|
||||
{stats.timelineData.length > 0 && (
|
||||
<Box mt={2}>
|
||||
<Typography variant="subtitle2" sx={{ color: '#e2e8f0', mb: 1 }}>
|
||||
Activité récente (7 derniers jours)
|
||||
</Typography>
|
||||
<Box display="flex" gap={1} alignItems="end" height={40}>
|
||||
{stats.timelineData
|
||||
.slice(-7) // 7 derniers jours
|
||||
.map((day, index) => {
|
||||
const height = Math.max(4, (day.count / Math.max(...stats.timelineData.map(d => d.count))) * 32);
|
||||
const color = day.successRate >= 0.9 ? '#22c55e' : day.successRate >= 0.7 ? '#f59e0b' : '#ef4444';
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={day.date}
|
||||
sx={{
|
||||
width: 20,
|
||||
height: height,
|
||||
backgroundColor: color,
|
||||
borderRadius: '2px 2px 0 0',
|
||||
opacity: 0.8,
|
||||
cursor: 'pointer',
|
||||
'&:hover': { opacity: 1 }
|
||||
}}
|
||||
title={`${day.date}: ${day.count} Evidence (${Math.round(day.successRate * 100)}% succès)`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
<Box display="flex" justifyContent="space-between" mt={0.5}>
|
||||
<Typography variant="caption" sx={{ color: '#64748b' }}>
|
||||
{stats.timelineData.slice(-7)[0]?.date}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#64748b' }}>
|
||||
{stats.timelineData.slice(-1)[0]?.date}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EvidenceStatsComponent;
|
||||
@@ -0,0 +1,414 @@
|
||||
/**
|
||||
* Styles CSS pour le composant Evidence Viewer VWB
|
||||
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
|
||||
*/
|
||||
|
||||
.evidence-viewer {
|
||||
position: relative;
|
||||
background: #1e293b;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #334155;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.evidence-viewer-fabs {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Liste des Evidence */
|
||||
.evidence-list {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.evidence-list-item {
|
||||
margin-bottom: 8px;
|
||||
padding: 12px;
|
||||
background: #334155;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.evidence-list-item:hover {
|
||||
background: #475569;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.evidence-list-item.selected {
|
||||
border-color: #1976d2;
|
||||
background: #1e40af;
|
||||
}
|
||||
|
||||
.evidence-list-item.error {
|
||||
border-left: 4px solid #ef4444;
|
||||
}
|
||||
|
||||
.evidence-list-item.success {
|
||||
border-left: 4px solid #22c55e;
|
||||
}
|
||||
|
||||
/* Vue grille */
|
||||
.evidence-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.evidence-grid-item {
|
||||
background: #334155;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: 2px solid transparent;
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.evidence-grid-item:hover {
|
||||
background: #475569;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.evidence-grid-item.selected {
|
||||
border-color: #1976d2;
|
||||
background: #1e40af;
|
||||
}
|
||||
|
||||
/* En-tête d'Evidence */
|
||||
.evidence-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.evidence-title {
|
||||
font-weight: 600;
|
||||
color: #e2e8f0;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.evidence-status {
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.evidence-status.success {
|
||||
background: #22c55e;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.evidence-status.error {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Métadonnées d'Evidence */
|
||||
.evidence-metadata {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.evidence-metadata-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.evidence-metadata-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.evidence-metadata-value {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
/* Screenshot miniature */
|
||||
.evidence-thumbnail {
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
margin: 8px 0;
|
||||
border: 1px solid #475569;
|
||||
}
|
||||
|
||||
/* Détail d'Evidence */
|
||||
.evidence-detail {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
background: #1e293b;
|
||||
}
|
||||
|
||||
.evidence-detail-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #334155;
|
||||
}
|
||||
|
||||
.evidence-detail-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #e2e8f0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.evidence-detail-close {
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.evidence-detail-close:hover {
|
||||
background: #334155;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
/* Screenshot principal */
|
||||
.evidence-screenshot {
|
||||
width: 100%;
|
||||
max-height: 400px;
|
||||
object-fit: contain;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #334155;
|
||||
margin: 12px 0;
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
/* Annotations sur screenshot */
|
||||
.evidence-screenshot-container {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.evidence-annotation {
|
||||
position: absolute;
|
||||
border: 2px solid #1976d2;
|
||||
background: rgba(25, 118, 210, 0.2);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.evidence-annotation.click-point {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #ef4444;
|
||||
border: 2px solid white;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.evidence-annotation.bbox {
|
||||
background: rgba(25, 118, 210, 0.1);
|
||||
border: 2px dashed #1976d2;
|
||||
}
|
||||
|
||||
/* Panneau de métadonnées */
|
||||
.evidence-metadata-panel {
|
||||
background: #0f172a;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.evidence-metadata-panel h4 {
|
||||
color: #e2e8f0;
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.evidence-metadata-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.evidence-metadata-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.evidence-metadata-item-label {
|
||||
color: #94a3b8;
|
||||
font-weight: 500;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.evidence-metadata-item-value {
|
||||
color: #e2e8f0;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* Message d'erreur */
|
||||
.evidence-error {
|
||||
background: #7f1d1d;
|
||||
border: 1px solid #ef4444;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.evidence-error-title {
|
||||
color: #fecaca;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.evidence-error-message {
|
||||
color: #fee2e2;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Statistiques */
|
||||
.evidence-stats {
|
||||
padding: 12px 16px;
|
||||
background: #0f172a;
|
||||
border-bottom: 1px solid #334155;
|
||||
}
|
||||
|
||||
.evidence-stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.evidence-stat-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.evidence-stat-value {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #1976d2;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.evidence-stat-label {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Filtres */
|
||||
.evidence-filters {
|
||||
padding: 12px 16px;
|
||||
background: #0f172a;
|
||||
border-bottom: 1px solid #334155;
|
||||
}
|
||||
|
||||
.evidence-filters-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.evidence-filters-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.evidence-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.evidence-metadata-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.evidence-stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.evidence-filters-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.evidence-viewer-fabs .MuiFab-root {
|
||||
position: fixed !important;
|
||||
bottom: 16px !important;
|
||||
}
|
||||
|
||||
.evidence-viewer-fabs .MuiFab-root:nth-child(1) {
|
||||
right: 16px !important;
|
||||
}
|
||||
|
||||
.evidence-viewer-fabs .MuiFab-root:nth-child(2) {
|
||||
right: 80px !important;
|
||||
}
|
||||
|
||||
.evidence-viewer-fabs .MuiFab-root:nth-child(3) {
|
||||
right: 144px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes evidenceAppear {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.evidence-list-item,
|
||||
.evidence-grid-item {
|
||||
animation: evidenceAppear 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Scrollbar personnalisée */
|
||||
.evidence-list::-webkit-scrollbar,
|
||||
.evidence-detail::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.evidence-list::-webkit-scrollbar-track,
|
||||
.evidence-detail::-webkit-scrollbar-track {
|
||||
background: #1e293b;
|
||||
}
|
||||
|
||||
.evidence-list::-webkit-scrollbar-thumb,
|
||||
.evidence-detail::-webkit-scrollbar-thumb {
|
||||
background: #475569;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.evidence-list::-webkit-scrollbar-thumb:hover,
|
||||
.evidence-detail::-webkit-scrollbar-thumb:hover {
|
||||
background: #64748b;
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
/**
|
||||
* Panneau Evidence d'Exécution - Affichage temps réel des Evidence VWB
|
||||
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
|
||||
*
|
||||
* Ce composant affiche les Evidence générées pendant l'exécution des workflows VWB
|
||||
* avec mise à jour en temps réel et navigation dans l'historique.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
Tabs,
|
||||
Tab,
|
||||
Badge,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Chip,
|
||||
Divider,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemIcon,
|
||||
Collapse,
|
||||
Alert,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Visibility as EvidenceIcon,
|
||||
Screenshot as ScreenshotIcon,
|
||||
Timeline as TimelineIcon,
|
||||
FilterList as FilterIcon,
|
||||
Refresh as RefreshIcon,
|
||||
ExpandMore as ExpandIcon,
|
||||
ExpandLess as CollapseIcon,
|
||||
CheckCircle as SuccessIcon,
|
||||
Error as ErrorIcon,
|
||||
Info as InfoIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
// Import des composants Evidence existants
|
||||
import EvidenceList from './EvidenceList';
|
||||
import EvidenceDetail from './EvidenceDetail';
|
||||
import ScreenshotViewer from './ScreenshotViewer';
|
||||
import EvidenceFilters from './EvidenceFilters';
|
||||
|
||||
// Import des types
|
||||
import { Evidence, Step, StepExecutionState } from '../../types';
|
||||
import { VWBExecutionResult } from '../../services/vwbExecutionService';
|
||||
|
||||
// Import du hook d'exécution Evidence
|
||||
import { useExecutionEvidence } from '../../hooks/useExecutionEvidence';
|
||||
|
||||
interface ExecutionEvidencePanelProps {
|
||||
/** Étape actuellement en cours d'exécution */
|
||||
currentStep?: Step;
|
||||
/** Résultats d'exécution VWB */
|
||||
executionResults: VWBExecutionResult[];
|
||||
/** Callback lors de la sélection d'une Evidence */
|
||||
onEvidenceSelect?: (evidence: Evidence) => void;
|
||||
/** Callback lors du changement d'étape */
|
||||
onStepChange?: (stepId: string) => void;
|
||||
/** Mode d'affichage compact */
|
||||
compact?: boolean;
|
||||
/** Hauteur maximale du panneau */
|
||||
maxHeight?: number;
|
||||
}
|
||||
|
||||
interface TabPanelProps {
|
||||
children?: React.ReactNode;
|
||||
index: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composant TabPanel pour les onglets
|
||||
*/
|
||||
const TabPanel: React.FC<TabPanelProps> = ({ children, value, index }) => (
|
||||
<div hidden={value !== index} style={{ height: '100%' }}>
|
||||
{value === index && children}
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* Panneau principal pour l'affichage des Evidence d'exécution
|
||||
*/
|
||||
const ExecutionEvidencePanel: React.FC<ExecutionEvidencePanelProps> = ({
|
||||
currentStep,
|
||||
executionResults,
|
||||
onEvidenceSelect,
|
||||
onStepChange,
|
||||
compact = false,
|
||||
maxHeight = 600,
|
||||
}) => {
|
||||
// État local
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
const [selectedEvidence, setSelectedEvidence] = useState<Evidence | null>(null);
|
||||
const [expandedSteps, setExpandedSteps] = useState<Set<string>>(new Set());
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
|
||||
// Hook pour la gestion des Evidence d'exécution
|
||||
const {
|
||||
allEvidence,
|
||||
evidenceByStep,
|
||||
currentStepEvidence,
|
||||
addEvidence,
|
||||
clearEvidence,
|
||||
getEvidenceStats,
|
||||
} = useExecutionEvidence();
|
||||
|
||||
// Synchroniser les Evidence avec les résultats d'exécution
|
||||
useEffect(() => {
|
||||
executionResults.forEach(result => {
|
||||
if (result.evidence && result.evidence.length > 0) {
|
||||
result.evidence.forEach(evidence => {
|
||||
addEvidence(result.stepId, evidence);
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [executionResults, addEvidence]);
|
||||
|
||||
// Statistiques des Evidence
|
||||
const evidenceStats = useMemo(() => getEvidenceStats(), [getEvidenceStats]);
|
||||
|
||||
// Gérer la sélection d'Evidence
|
||||
const handleEvidenceSelect = useCallback((evidence: Evidence) => {
|
||||
setSelectedEvidence(evidence);
|
||||
onEvidenceSelect?.(evidence);
|
||||
}, [onEvidenceSelect]);
|
||||
|
||||
// Gérer l'expansion des étapes
|
||||
const toggleStepExpansion = useCallback((stepId: string) => {
|
||||
setExpandedSteps(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(stepId)) {
|
||||
newSet.delete(stepId);
|
||||
} else {
|
||||
newSet.add(stepId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Gérer le changement d'onglet
|
||||
const handleTabChange = useCallback((_: React.SyntheticEvent, newValue: number) => {
|
||||
setActiveTab(newValue);
|
||||
}, []);
|
||||
|
||||
// Rendu de la liste des étapes avec Evidence
|
||||
const renderStepsList = useCallback(() => {
|
||||
const stepsWithEvidence = executionResults.filter(result =>
|
||||
result.evidence && result.evidence.length > 0
|
||||
);
|
||||
|
||||
if (stepsWithEvidence.length === 0) {
|
||||
return (
|
||||
<Alert severity="info" sx={{ m: 2 }}>
|
||||
Aucune Evidence générée pour le moment.
|
||||
Les Evidence apparaîtront automatiquement lors de l'exécution.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<List sx={{ width: '100%' }}>
|
||||
{stepsWithEvidence.map((result) => {
|
||||
const isExpanded = expandedSteps.has(result.stepId);
|
||||
const stepEvidence = result.evidence || [];
|
||||
|
||||
return (
|
||||
<React.Fragment key={result.stepId}>
|
||||
<ListItem
|
||||
component="div"
|
||||
onClick={() => toggleStepExpansion(result.stepId)}
|
||||
sx={{
|
||||
cursor: "pointer",
|
||||
borderLeft: `4px solid ${result.success ? '#4caf50' : '#f44336'}`,
|
||||
mb: 1,
|
||||
borderRadius: 1,
|
||||
backgroundColor: 'background.paper',
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
{result.success ? (
|
||||
<SuccessIcon color="success" />
|
||||
) : (
|
||||
<ErrorIcon color="error" />
|
||||
)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="subtitle2">
|
||||
Étape {result.stepId}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={result.actionId}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ fontSize: '0.7rem' }}
|
||||
/>
|
||||
<Badge
|
||||
badgeContent={stepEvidence.length}
|
||||
color="primary"
|
||||
sx={{ ml: 'auto' }}
|
||||
>
|
||||
<EvidenceIcon fontSize="small" />
|
||||
</Badge>
|
||||
</Box>
|
||||
}
|
||||
secondary={`Durée: ${result.duration}ms`}
|
||||
/>
|
||||
<IconButton size="small">
|
||||
{isExpanded ? <CollapseIcon /> : <ExpandIcon />}
|
||||
</IconButton>
|
||||
</ListItem>
|
||||
|
||||
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
|
||||
<Box sx={{ pl: 4, pr: 2, pb: 2 }}>
|
||||
<EvidenceList
|
||||
evidences={stepEvidence}
|
||||
selectedId={selectedEvidence?.id}
|
||||
onSelect={handleEvidenceSelect}
|
||||
filters={{
|
||||
actionTypes: [],
|
||||
status: 'all',
|
||||
dateRange: {},
|
||||
searchText: '',
|
||||
confidenceRange: { min: 0, max: 1 },
|
||||
executionTimeRange: { min: 0, max: 10000 }
|
||||
}}
|
||||
onFiltersChange={() => {}}
|
||||
sortBy="timestamp"
|
||||
sortOrder="desc"
|
||||
onSortChange={() => {}}
|
||||
viewMode="list"
|
||||
/>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
);
|
||||
}, [executionResults, expandedSteps, selectedEvidence, handleEvidenceSelect, toggleStepExpansion]);
|
||||
|
||||
// Rendu de la timeline des Evidence
|
||||
const renderTimeline = useCallback(() => {
|
||||
const sortedEvidence = [...allEvidence].sort((a, b) =>
|
||||
new Date(b.captured_at).getTime() - new Date(a.captured_at).getTime()
|
||||
);
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Timeline des Evidence ({sortedEvidence.length})
|
||||
</Typography>
|
||||
<EvidenceList
|
||||
evidences={sortedEvidence}
|
||||
selectedId={selectedEvidence?.id}
|
||||
onSelect={handleEvidenceSelect}
|
||||
filters={{
|
||||
actionTypes: [],
|
||||
status: 'all',
|
||||
dateRange: {},
|
||||
searchText: '',
|
||||
confidenceRange: { min: 0, max: 1 },
|
||||
executionTimeRange: { min: 0, max: 10000 }
|
||||
}}
|
||||
onFiltersChange={() => {}}
|
||||
sortBy="timestamp"
|
||||
sortOrder="desc"
|
||||
onSortChange={() => {}}
|
||||
viewMode="list"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}, [allEvidence, selectedEvidence, handleEvidenceSelect, compact]);
|
||||
|
||||
// Rendu de l'étape actuelle
|
||||
const renderCurrentStep = useCallback(() => {
|
||||
if (!currentStep) {
|
||||
return (
|
||||
<Alert severity="info" sx={{ m: 2 }}>
|
||||
Aucune étape en cours d'exécution
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const stepEvidence = currentStepEvidence;
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Étape Actuelle: {currentStep.name}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={currentStep.type}
|
||||
color="primary"
|
||||
size="small"
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
|
||||
{stepEvidence.length > 0 ? (
|
||||
<EvidenceList
|
||||
evidences={stepEvidence}
|
||||
selectedId={selectedEvidence?.id}
|
||||
onSelect={handleEvidenceSelect}
|
||||
filters={{
|
||||
actionTypes: [],
|
||||
status: 'all',
|
||||
dateRange: {},
|
||||
searchText: '',
|
||||
confidenceRange: { min: 0, max: 1 },
|
||||
executionTimeRange: { min: 0, max: 10000 }
|
||||
}}
|
||||
onFiltersChange={() => {}}
|
||||
sortBy="timestamp"
|
||||
sortOrder="desc"
|
||||
onSortChange={() => {}}
|
||||
viewMode="list"
|
||||
/>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Aucune Evidence générée pour cette étape
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}, [currentStep, currentStepEvidence, selectedEvidence, handleEvidenceSelect, compact]);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={2}
|
||||
sx={{
|
||||
height: compact ? 'auto' : maxHeight,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* En-tête avec statistiques */}
|
||||
<Box sx={{ p: 2, borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Typography variant="h6">
|
||||
Evidence d'Exécution
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Chip
|
||||
label={`${evidenceStats.total} Evidence`}
|
||||
size="small"
|
||||
color="primary"
|
||||
/>
|
||||
<Chip
|
||||
label={`${evidenceStats.screenshots} Screenshots`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
<Tooltip title="Filtres">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
color={showFilters ? 'primary' : 'default'}
|
||||
>
|
||||
<FilterIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Actualiser">
|
||||
<IconButton size="small" onClick={() => window.location.reload()}>
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Filtres */}
|
||||
<Collapse in={showFilters}>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<EvidenceFilters
|
||||
evidences={allEvidence}
|
||||
filters={{
|
||||
actionTypes: [],
|
||||
status: 'all',
|
||||
dateRange: {},
|
||||
searchText: '',
|
||||
confidenceRange: { min: 0, max: 1 },
|
||||
executionTimeRange: { min: 0, max: 10000 }
|
||||
}}
|
||||
onFiltersChange={(filtered) => {
|
||||
// Logique de filtrage à implémenter
|
||||
console.log('Evidence filtrées:', filtered);
|
||||
}}
|
||||
onClearFilters={() => {
|
||||
console.log('Filtres effacés');
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
|
||||
{/* Onglets */}
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={handleTabChange}
|
||||
variant="fullWidth"
|
||||
sx={{ borderBottom: 1, borderColor: 'divider' }}
|
||||
>
|
||||
<Tab
|
||||
label={
|
||||
<Badge badgeContent={evidenceStats.byCurrentStep} color="primary">
|
||||
Étape Actuelle
|
||||
</Badge>
|
||||
}
|
||||
icon={<InfoIcon />}
|
||||
/>
|
||||
<Tab
|
||||
label={
|
||||
<Badge badgeContent={evidenceStats.bySteps} color="primary">
|
||||
Par Étapes
|
||||
</Badge>
|
||||
}
|
||||
icon={<EvidenceIcon />}
|
||||
/>
|
||||
<Tab
|
||||
label={
|
||||
<Badge badgeContent={evidenceStats.total} color="primary">
|
||||
Timeline
|
||||
</Badge>
|
||||
}
|
||||
icon={<TimelineIcon />}
|
||||
/>
|
||||
</Tabs>
|
||||
|
||||
{/* Contenu des onglets */}
|
||||
<Box sx={{ flex: 1, overflow: 'auto' }}>
|
||||
<TabPanel value={activeTab} index={0}>
|
||||
{renderCurrentStep()}
|
||||
</TabPanel>
|
||||
<TabPanel value={activeTab} index={1}>
|
||||
{renderStepsList()}
|
||||
</TabPanel>
|
||||
<TabPanel value={activeTab} index={2}>
|
||||
{renderTimeline()}
|
||||
</TabPanel>
|
||||
</Box>
|
||||
|
||||
{/* Panneau de détail Evidence sélectionnée */}
|
||||
{selectedEvidence && (
|
||||
<Box sx={{ borderTop: 1, borderColor: 'divider' }}>
|
||||
<EvidenceDetail
|
||||
evidence={selectedEvidence}
|
||||
onClose={() => setSelectedEvidence(null)}
|
||||
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExecutionEvidencePanel;
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Composant ScreenshotViewer - Visualiseur de screenshots avec annotations
|
||||
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { Box, Paper } from '@mui/material';
|
||||
import { ScreenshotViewerProps, AnnotationData } from '../../types/evidence';
|
||||
|
||||
const ScreenshotViewer: React.FC<ScreenshotViewerProps> = ({
|
||||
screenshot,
|
||||
bbox,
|
||||
clickPoint,
|
||||
annotations = [],
|
||||
zoom = 1,
|
||||
onZoomChange,
|
||||
maxWidth = 800,
|
||||
maxHeight = 600
|
||||
}) => {
|
||||
// Références
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
|
||||
// État local
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
const [imageDimensions, setImageDimensions] = useState({ width: 0, height: 0 });
|
||||
const [isPanning, setIsPanning] = useState(false);
|
||||
const [panStart, setPanStart] = useState({ x: 0, y: 0 });
|
||||
const [panOffset, setPanOffset] = useState({ x: 0, y: 0 });
|
||||
|
||||
// Chargement de l'image
|
||||
const handleImageLoad = useCallback(() => {
|
||||
if (imageRef.current) {
|
||||
setImageDimensions({
|
||||
width: imageRef.current.naturalWidth,
|
||||
height: imageRef.current.naturalHeight
|
||||
});
|
||||
setImageLoaded(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Gestion du zoom avec la molette
|
||||
const handleWheel = useCallback((event: React.WheelEvent) => {
|
||||
if (!onZoomChange) return;
|
||||
|
||||
event.preventDefault();
|
||||
const delta = event.deltaY > 0 ? 0.9 : 1.1;
|
||||
const newZoom = Math.max(0.1, Math.min(5, zoom * delta));
|
||||
onZoomChange(newZoom);
|
||||
}, [zoom, onZoomChange]);
|
||||
|
||||
// Début du pan
|
||||
const handleMouseDown = useCallback((event: React.MouseEvent) => {
|
||||
if (zoom <= 1) return; // Pas de pan si pas de zoom
|
||||
|
||||
setIsPanning(true);
|
||||
setPanStart({
|
||||
x: event.clientX - panOffset.x,
|
||||
y: event.clientY - panOffset.y
|
||||
});
|
||||
}, [zoom, panOffset]);
|
||||
|
||||
// Pan en cours
|
||||
const handleMouseMove = useCallback((event: React.MouseEvent) => {
|
||||
if (!isPanning) return;
|
||||
|
||||
setPanOffset({
|
||||
x: event.clientX - panStart.x,
|
||||
y: event.clientY - panStart.y
|
||||
});
|
||||
}, [isPanning, panStart]);
|
||||
|
||||
// Fin du pan
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setIsPanning(false);
|
||||
}, []);
|
||||
|
||||
// Reset du pan quand le zoom change
|
||||
useEffect(() => {
|
||||
if (zoom <= 1) {
|
||||
setPanOffset({ x: 0, y: 0 });
|
||||
}
|
||||
}, [zoom]);
|
||||
|
||||
// Calcul des dimensions d'affichage
|
||||
const getDisplayDimensions = () => {
|
||||
if (!imageLoaded) return { width: maxWidth, height: maxHeight };
|
||||
|
||||
const aspectRatio = imageDimensions.width / imageDimensions.height;
|
||||
let displayWidth = imageDimensions.width;
|
||||
let displayHeight = imageDimensions.height;
|
||||
|
||||
// Ajustement aux dimensions max
|
||||
if (displayWidth > maxWidth) {
|
||||
displayWidth = maxWidth;
|
||||
displayHeight = displayWidth / aspectRatio;
|
||||
}
|
||||
|
||||
if (displayHeight > maxHeight) {
|
||||
displayHeight = maxHeight;
|
||||
displayWidth = displayHeight * aspectRatio;
|
||||
}
|
||||
|
||||
return {
|
||||
width: displayWidth * zoom,
|
||||
height: displayHeight * zoom
|
||||
};
|
||||
};
|
||||
|
||||
// Conversion des coordonnées d'annotation
|
||||
const convertCoordinates = (coord: { x: number; y: number; width?: number; height?: number }) => {
|
||||
if (!imageLoaded) return coord;
|
||||
|
||||
const displayDims = getDisplayDimensions();
|
||||
const scaleX = displayDims.width / imageDimensions.width;
|
||||
const scaleY = displayDims.height / imageDimensions.height;
|
||||
|
||||
return {
|
||||
x: coord.x * scaleX,
|
||||
y: coord.y * scaleY,
|
||||
width: coord.width ? coord.width * scaleX : undefined,
|
||||
height: coord.height ? coord.height * scaleY : undefined
|
||||
};
|
||||
};
|
||||
|
||||
// Rendu des annotations
|
||||
const renderAnnotations = () => {
|
||||
if (!imageLoaded) return null;
|
||||
|
||||
const allAnnotations: AnnotationData[] = [];
|
||||
|
||||
// Ajout de la bbox si présente
|
||||
if (bbox) {
|
||||
allAnnotations.push({
|
||||
id: 'bbox',
|
||||
type: 'bbox',
|
||||
position: { x: bbox.x, y: bbox.y },
|
||||
coordinates: bbox,
|
||||
label: 'Zone détectée',
|
||||
color: '#1976d2',
|
||||
opacity: 0.3,
|
||||
data: { type: 'bbox', bbox }
|
||||
});
|
||||
}
|
||||
|
||||
// Ajout du point de clic si présent
|
||||
if (clickPoint) {
|
||||
allAnnotations.push({
|
||||
id: 'click',
|
||||
type: 'click',
|
||||
position: { x: clickPoint.x, y: clickPoint.y },
|
||||
coordinates: clickPoint,
|
||||
label: 'Point de clic',
|
||||
color: '#ef4444',
|
||||
opacity: 1,
|
||||
data: { type: 'click', clickPoint }
|
||||
});
|
||||
}
|
||||
|
||||
// Ajout des annotations personnalisées
|
||||
allAnnotations.push(...annotations);
|
||||
|
||||
return allAnnotations.map(annotation => {
|
||||
const coords = annotation.coordinates ? convertCoordinates(annotation.coordinates) : { x: annotation.position.x, y: annotation.position.y };
|
||||
|
||||
if (annotation.type === 'click') {
|
||||
return (
|
||||
<div
|
||||
key={annotation.id}
|
||||
className="evidence-annotation click-point"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: coords.x - 6,
|
||||
top: coords.y - 6,
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: annotation.color || '#ef4444',
|
||||
border: '2px solid white',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.3)',
|
||||
zIndex: 10
|
||||
}}
|
||||
title={annotation.label}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (annotation.type === 'bbox' && coords.width && coords.height) {
|
||||
return (
|
||||
<div
|
||||
key={annotation.id}
|
||||
className="evidence-annotation bbox"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: coords.x,
|
||||
top: coords.y,
|
||||
width: coords.width,
|
||||
height: coords.height,
|
||||
border: `2px dashed ${annotation.color || '#1976d2'}`,
|
||||
backgroundColor: `${annotation.color || '#1976d2'}${Math.round((annotation.opacity || 0.3) * 255).toString(16).padStart(2, '0')}`,
|
||||
pointerEvents: 'none',
|
||||
zIndex: 5
|
||||
}}
|
||||
title={annotation.label}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
};
|
||||
|
||||
const displayDims = getDisplayDimensions();
|
||||
|
||||
return (
|
||||
<Paper
|
||||
ref={containerRef}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
display: 'inline-block',
|
||||
maxWidth: '100%',
|
||||
maxHeight: maxHeight,
|
||||
overflow: zoom > 1 ? 'auto' : 'hidden',
|
||||
cursor: zoom > 1 ? (isPanning ? 'grabbing' : 'grab') : 'default',
|
||||
backgroundColor: '#0f172a',
|
||||
border: '1px solid #334155'
|
||||
}}
|
||||
onWheel={handleWheel}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
>
|
||||
<div
|
||||
className="evidence-screenshot-container"
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'inline-block',
|
||||
transform: `translate(${panOffset.x}px, ${panOffset.y}px)`,
|
||||
transition: isPanning ? 'none' : 'transform 0.1s ease'
|
||||
}}
|
||||
>
|
||||
<img
|
||||
ref={imageRef}
|
||||
src={`data:image/png;base64,${screenshot}`}
|
||||
alt="Screenshot Evidence"
|
||||
className="evidence-screenshot"
|
||||
style={{
|
||||
width: displayDims.width,
|
||||
height: displayDims.height,
|
||||
display: 'block',
|
||||
userSelect: 'none',
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
onLoad={handleImageLoad}
|
||||
onError={() => setImageLoaded(false)}
|
||||
/>
|
||||
|
||||
{/* Annotations */}
|
||||
{renderAnnotations()}
|
||||
</div>
|
||||
|
||||
{/* Indicateur de chargement */}
|
||||
{!imageLoaded && (
|
||||
<Box
|
||||
position="absolute"
|
||||
top="50%"
|
||||
left="50%"
|
||||
sx={{
|
||||
transform: 'translate(-50%, -50%)',
|
||||
color: '#94a3b8',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
Chargement de l'image...
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScreenshotViewer;
|
||||
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* Composant Evidence Viewer VWB - Visualisation des preuves d'exécution
|
||||
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
Divider,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Fab,
|
||||
Tooltip,
|
||||
useTheme,
|
||||
useMediaQuery
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Refresh as RefreshIcon,
|
||||
GetApp as ExportIcon,
|
||||
FilterList as FilterIcon,
|
||||
ViewList as ListIcon,
|
||||
ViewModule as GridIcon
|
||||
} from '@mui/icons-material';
|
||||
|
||||
import { EvidenceViewerProps, VWBEvidence } from '../../types/evidence';
|
||||
import { useEvidenceViewer } from '../../hooks/useEvidenceViewer';
|
||||
import EvidenceList from './EvidenceList';
|
||||
import EvidenceDetail from './EvidenceDetail';
|
||||
import EvidenceFilters from './EvidenceFilters';
|
||||
import EvidenceStats from './EvidenceStats';
|
||||
|
||||
import './EvidenceViewer.css';
|
||||
|
||||
const EvidenceViewer: React.FC<EvidenceViewerProps> = ({
|
||||
evidences: externalEvidences,
|
||||
selectedEvidenceId: externalSelectedId,
|
||||
onEvidenceSelect,
|
||||
onExport,
|
||||
showFilters = true,
|
||||
maxHeight = 600,
|
||||
className = ''
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
|
||||
// État local
|
||||
const [viewMode, setViewMode] = useState<'list' | 'grid'>('list');
|
||||
const [showFiltersPanel, setShowFiltersPanel] = useState(false);
|
||||
const [showStatsPanel, setShowStatsPanel] = useState(true);
|
||||
|
||||
// Hook Evidence Viewer (utilisé seulement si pas d'Evidence externes)
|
||||
const {
|
||||
evidences: internalEvidences,
|
||||
filteredEvidences,
|
||||
selectedEvidence,
|
||||
stats,
|
||||
loading,
|
||||
error,
|
||||
filters,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
setSelectedEvidenceId: setInternalSelectedId,
|
||||
setFilters,
|
||||
setSorting,
|
||||
refreshEvidences,
|
||||
clearFilters,
|
||||
exportEvidences,
|
||||
hasFilters,
|
||||
isServiceAvailable
|
||||
} = useEvidenceViewer({
|
||||
autoRefresh: !externalEvidences, // Auto-refresh seulement si pas d'Evidence externes
|
||||
refreshInterval: 30000
|
||||
});
|
||||
|
||||
// Utilisation des Evidence externes ou internes
|
||||
const evidences = externalEvidences || internalEvidences;
|
||||
const displayedEvidences = externalEvidences || filteredEvidences;
|
||||
const currentSelectedId = externalSelectedId || selectedEvidence?.id;
|
||||
|
||||
// Gestion de la sélection d'Evidence
|
||||
const handleEvidenceSelect = useCallback((evidence: VWBEvidence) => {
|
||||
const evidenceId = evidence.id;
|
||||
if (onEvidenceSelect) {
|
||||
onEvidenceSelect(evidenceId);
|
||||
} else {
|
||||
setInternalSelectedId(evidenceId);
|
||||
}
|
||||
}, [onEvidenceSelect, setInternalSelectedId]);
|
||||
|
||||
// Gestion de l'export
|
||||
const handleExport = useCallback(async (format: 'json' | 'html' | 'pdf') => {
|
||||
if (onExport) {
|
||||
onExport(displayedEvidences);
|
||||
} else {
|
||||
await exportEvidences(format);
|
||||
}
|
||||
}, [onExport, displayedEvidences, exportEvidences]);
|
||||
|
||||
// Gestion du refresh
|
||||
const handleRefresh = useCallback(async () => {
|
||||
if (!externalEvidences) {
|
||||
await refreshEvidences();
|
||||
}
|
||||
}, [externalEvidences, refreshEvidences]);
|
||||
|
||||
// Evidence sélectionnée
|
||||
const selectedEvidenceData = currentSelectedId
|
||||
? evidences.find(e => e.id === currentSelectedId)
|
||||
: null;
|
||||
|
||||
// Rendu du contenu principal
|
||||
const renderContent = () => {
|
||||
if (loading && !externalEvidences) {
|
||||
return (
|
||||
<Box display="flex" justifyContent="center" alignItems="center" minHeight={200}>
|
||||
<CircularProgress size={40} />
|
||||
<Typography variant="body2" sx={{ ml: 2 }}>
|
||||
Chargement des Evidence...
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !externalEvidences) {
|
||||
return (
|
||||
<Alert severity="error" sx={{ m: 2 }}>
|
||||
{error}
|
||||
{!isServiceAvailable && (
|
||||
<Typography variant="caption" display="block" sx={{ mt: 1 }}>
|
||||
Le service Evidence n'est pas disponible. Vérifiez que le backend VWB est démarré.
|
||||
</Typography>
|
||||
)}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (displayedEvidences.length === 0) {
|
||||
return (
|
||||
<Box textAlign="center" py={4}>
|
||||
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||
Aucune Evidence disponible
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{hasFilters
|
||||
? 'Aucune Evidence ne correspond aux filtres appliqués.'
|
||||
: 'Aucune Evidence d\'exécution n\'a été trouvée.'}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box display="flex" height="100%">
|
||||
{/* Liste des Evidence */}
|
||||
<Box
|
||||
flex={selectedEvidenceData ? (isMobile ? 0 : 1) : 1}
|
||||
display={selectedEvidenceData && isMobile ? 'none' : 'block'}
|
||||
>
|
||||
<EvidenceList
|
||||
evidences={displayedEvidences}
|
||||
selectedId={currentSelectedId}
|
||||
onSelect={handleEvidenceSelect}
|
||||
filters={externalEvidences ? undefined : filters}
|
||||
onFiltersChange={externalEvidences ? undefined : setFilters}
|
||||
sortBy={sortBy}
|
||||
sortOrder={sortOrder}
|
||||
onSortChange={setSorting}
|
||||
viewMode={viewMode}
|
||||
showFilters={showFilters && showFiltersPanel}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Détail de l'Evidence sélectionnée */}
|
||||
{selectedEvidenceData && (
|
||||
<>
|
||||
<Divider orientation="vertical" flexItem />
|
||||
<Box
|
||||
flex={isMobile ? 1 : 1}
|
||||
display={selectedEvidenceData ? 'block' : 'none'}
|
||||
>
|
||||
<EvidenceDetail
|
||||
evidence={selectedEvidenceData}
|
||||
onClose={isMobile ? () => setInternalSelectedId('') : undefined}
|
||||
showMetadata={true}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper
|
||||
className={`evidence-viewer ${className}`}
|
||||
sx={{
|
||||
height: maxHeight,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
role="region"
|
||||
aria-label="Visualiseur d'Evidence VWB"
|
||||
>
|
||||
{/* En-tête avec statistiques */}
|
||||
{showStatsPanel && !externalEvidences && (
|
||||
<>
|
||||
<EvidenceStats
|
||||
stats={stats}
|
||||
totalEvidences={evidences.length}
|
||||
filteredCount={displayedEvidences.length}
|
||||
hasFilters={hasFilters}
|
||||
/>
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Panneau de filtres */}
|
||||
{showFilters && showFiltersPanel && !externalEvidences && (
|
||||
<>
|
||||
<EvidenceFilters
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
onClearFilters={clearFilters}
|
||||
evidences={evidences}
|
||||
/>
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Contenu principal */}
|
||||
<Box flex={1} overflow="hidden">
|
||||
{renderContent()}
|
||||
</Box>
|
||||
|
||||
{/* Boutons d'action flottants */}
|
||||
<Box className="evidence-viewer-fabs">
|
||||
{/* Bouton Refresh */}
|
||||
{!externalEvidences && (
|
||||
<Tooltip title="Actualiser les Evidence">
|
||||
<Fab
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
aria-label="Actualiser les Evidence"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 16,
|
||||
right: 16,
|
||||
zIndex: 1000
|
||||
}}
|
||||
>
|
||||
<RefreshIcon />
|
||||
</Fab>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Bouton Export */}
|
||||
<Tooltip title="Exporter les Evidence">
|
||||
<Fab
|
||||
size="small"
|
||||
color="secondary"
|
||||
onClick={() => handleExport('html')}
|
||||
aria-label="Exporter les Evidence"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 16,
|
||||
right: 80,
|
||||
zIndex: 1000
|
||||
}}
|
||||
>
|
||||
<ExportIcon />
|
||||
</Fab>
|
||||
</Tooltip>
|
||||
|
||||
{/* Bouton Filtres */}
|
||||
{showFilters && !externalEvidences && (
|
||||
<Tooltip title={showFiltersPanel ? "Masquer les filtres" : "Afficher les filtres"}>
|
||||
<Fab
|
||||
size="small"
|
||||
color={showFiltersPanel ? "primary" : "default"}
|
||||
onClick={() => setShowFiltersPanel(!showFiltersPanel)}
|
||||
aria-label={showFiltersPanel ? "Masquer les filtres" : "Afficher les filtres"}
|
||||
aria-pressed={showFiltersPanel}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 16,
|
||||
right: 144,
|
||||
zIndex: 1000
|
||||
}}
|
||||
>
|
||||
<FilterIcon />
|
||||
</Fab>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Bouton Mode d'affichage */}
|
||||
<Tooltip title={viewMode === 'list' ? "Vue grille" : "Vue liste"}>
|
||||
<Fab
|
||||
size="small"
|
||||
color="default"
|
||||
onClick={() => setViewMode(viewMode === 'list' ? 'grid' : 'list')}
|
||||
aria-label={viewMode === 'list' ? "Basculer en vue grille" : "Basculer en vue liste"}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 16,
|
||||
right: 208,
|
||||
zIndex: 1000
|
||||
}}
|
||||
>
|
||||
{viewMode === 'list' ? <GridIcon /> : <ListIcon />}
|
||||
</Fab>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default EvidenceViewer;
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* Styles CSS pour les Contrôles d'Exécution VWB
|
||||
* Auteur : Dom, Alice, Kiro - 11 janvier 2026
|
||||
*/
|
||||
|
||||
.execution-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.execution-controls-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.execution-controls-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.execution-controls-advanced {
|
||||
background: #f5f5f5;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.execution-controls-settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.execution-controls-slider-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.execution-controls-switches {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.execution-controls-status {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
background: #e3f2fd;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #2196f3;
|
||||
}
|
||||
|
||||
.execution-controls-status.paused {
|
||||
background: #fff3e0;
|
||||
border-left-color: #ff9800;
|
||||
}
|
||||
|
||||
.execution-controls-status.error {
|
||||
background: #ffebee;
|
||||
border-left-color: #f44336;
|
||||
}
|
||||
|
||||
.execution-controls-stats {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.execution-controls-breakpoint-indicator {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.execution-controls-breakpoint-indicator::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
right: -2px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #ff5722;
|
||||
border-radius: 50%;
|
||||
border: 2px solid white;
|
||||
}
|
||||
|
||||
.execution-controls-save-dialog {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: white;
|
||||
padding: 24px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
||||
min-width: 320px;
|
||||
z-index: 1300;
|
||||
}
|
||||
|
||||
.execution-controls-save-dialog-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1299;
|
||||
}
|
||||
|
||||
/* Animations pour les états d'exécution */
|
||||
@keyframes execution-pulse {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
.execution-controls-running {
|
||||
animation: execution-pulse 2s infinite;
|
||||
}
|
||||
|
||||
.execution-controls-paused {
|
||||
animation: execution-pulse 1s infinite;
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.execution-controls-buttons {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.execution-controls-settings-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.execution-controls-switches {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.execution-controls-status {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mode sombre */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.execution-controls {
|
||||
background: #1e1e1e;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.execution-controls-advanced {
|
||||
background: #2d2d2d;
|
||||
border-color: #404040;
|
||||
}
|
||||
|
||||
.execution-controls-save-dialog {
|
||||
background: #2d2d2d;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Indicateurs de performance */
|
||||
.execution-controls-performance-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.execution-controls-performance-indicator.fast {
|
||||
background: #e8f5e8;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.execution-controls-performance-indicator.normal {
|
||||
background: #fff3e0;
|
||||
color: #f57c00;
|
||||
}
|
||||
|
||||
.execution-controls-performance-indicator.slow {
|
||||
background: #ffebee;
|
||||
color: #d32f2f;
|
||||
}
|
||||
|
||||
/* Transitions fluides */
|
||||
.execution-controls * {
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.execution-controls button:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.execution-controls button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Indicateurs d'état spéciaux */
|
||||
.execution-controls-step-indicator {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.execution-controls-step-indicator.current::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: -8px;
|
||||
transform: translateY(-50%);
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 6px solid #2196f3;
|
||||
border-top: 4px solid transparent;
|
||||
border-bottom: 4px solid transparent;
|
||||
}
|
||||
|
||||
.execution-controls-step-indicator.breakpoint::before {
|
||||
content: '●';
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
color: #ff5722;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
@@ -0,0 +1,746 @@
|
||||
/**
|
||||
* Contrôles d'Exécution VWB - Interface de contrôle avancée pour l'exécution des workflows
|
||||
* Auteur : Dom, Alice, Kiro - 11 janvier 2026
|
||||
*
|
||||
* Ce composant fournit une interface complète de contrôle d'exécution avec :
|
||||
* - Contrôles play/pause/stop
|
||||
* - Mode pas-à-pas pour le débogage
|
||||
* - Sauvegarde/restauration d'état d'exécution
|
||||
* - Gestion avancée des breakpoints
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Divider,
|
||||
Switch,
|
||||
FormControlLabel,
|
||||
Slider,
|
||||
Tooltip,
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Chip,
|
||||
Alert,
|
||||
Collapse,
|
||||
TextField,
|
||||
InputAdornment,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
PlayArrow as PlayIcon,
|
||||
Pause as PauseIcon,
|
||||
Stop as StopIcon,
|
||||
SkipNext as StepIcon,
|
||||
Replay as ResetIcon,
|
||||
Save as SaveIcon,
|
||||
Restore as RestoreIcon,
|
||||
Settings as SettingsIcon,
|
||||
Speed as SpeedIcon,
|
||||
BugReport as DebugIcon,
|
||||
Timeline as TimelineIcon,
|
||||
Bookmark as BookmarkIcon,
|
||||
BookmarkBorder as BookmarkBorderIcon,
|
||||
MoreVert as MoreIcon,
|
||||
Schedule as ScheduleIcon,
|
||||
Memory as MemoryIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
// Import des hooks et types
|
||||
import { useVWBExecution, VWBExecutionState } from '../../hooks/useVWBExecution';
|
||||
import { Step, Workflow, Variable } from '../../types';
|
||||
|
||||
export interface ExecutionControlsProps {
|
||||
workflow: Workflow;
|
||||
variables: Variable[];
|
||||
executionState: VWBExecutionState;
|
||||
onStepStateChange?: (stepId: string, state: any) => void;
|
||||
onExecutionComplete?: (success: boolean, summary: any) => void;
|
||||
onEvidenceGenerated?: (stepId: string, evidence: any[]) => void;
|
||||
debugMode?: boolean;
|
||||
onDebugModeChange?: (enabled: boolean) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface ExecutionSettings {
|
||||
autoValidate: boolean;
|
||||
generateEvidence: boolean;
|
||||
retryAttempts: number;
|
||||
timeout: number;
|
||||
pauseOnError: boolean;
|
||||
skipNonVWBSteps: boolean;
|
||||
stepDelay: number;
|
||||
enableBreakpoints: boolean;
|
||||
}
|
||||
|
||||
export interface ExecutionSaveState {
|
||||
id: string;
|
||||
name: string;
|
||||
timestamp: Date;
|
||||
workflowId: string;
|
||||
currentStepIndex: number;
|
||||
variables: Variable[];
|
||||
settings: ExecutionSettings;
|
||||
results: any[];
|
||||
evidence: any[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Composant principal des contrôles d'exécution
|
||||
*/
|
||||
const ExecutionControls: React.FC<ExecutionControlsProps> = ({
|
||||
workflow,
|
||||
variables,
|
||||
executionState,
|
||||
onStepStateChange,
|
||||
onExecutionComplete,
|
||||
onEvidenceGenerated,
|
||||
debugMode = false,
|
||||
onDebugModeChange,
|
||||
className,
|
||||
}) => {
|
||||
// États locaux
|
||||
const [settings, setSettings] = useState<ExecutionSettings>({
|
||||
autoValidate: true,
|
||||
generateEvidence: true,
|
||||
retryAttempts: 3,
|
||||
timeout: 30000,
|
||||
pauseOnError: false,
|
||||
skipNonVWBSteps: false,
|
||||
stepDelay: 0,
|
||||
enableBreakpoints: false,
|
||||
});
|
||||
|
||||
const [stepByStepMode, setStepByStepMode] = useState(false);
|
||||
const [breakpoints, setBreakpoints] = useState<Set<string>>(new Set());
|
||||
const [savedStates, setSavedStates] = useState<ExecutionSaveState[]>([]);
|
||||
const [settingsMenuAnchor, setSettingsMenuAnchor] = useState<null | HTMLElement>(null);
|
||||
const [saveStateDialogOpen, setSaveStateDialogOpen] = useState(false);
|
||||
const [saveStateName, setSaveStateName] = useState('');
|
||||
const [showAdvancedControls, setShowAdvancedControls] = useState(false);
|
||||
|
||||
// Hook d'exécution VWB avec paramètres dynamiques
|
||||
const {
|
||||
isRunning,
|
||||
isPaused,
|
||||
canStart,
|
||||
canPause,
|
||||
canResume,
|
||||
canStop,
|
||||
startExecution,
|
||||
pauseExecution,
|
||||
resumeExecution,
|
||||
stopExecution,
|
||||
resetExecution,
|
||||
getExecutionSummary,
|
||||
isVWBStep,
|
||||
} = useVWBExecution(
|
||||
workflow,
|
||||
variables,
|
||||
{
|
||||
onStepStart: (step, index) => {
|
||||
// Vérifier les breakpoints
|
||||
if (settings.enableBreakpoints && breakpoints.has(step.id)) {
|
||||
pauseExecution();
|
||||
}
|
||||
onStepStateChange?.(step.id, 'running');
|
||||
},
|
||||
onStepComplete: (step, result) => {
|
||||
onStepStateChange?.(step.id, result.success ? 'success' : 'error');
|
||||
if (result.evidence) {
|
||||
onEvidenceGenerated?.(step.id, result.evidence);
|
||||
}
|
||||
|
||||
// Mode pas-à-pas : pause après chaque étape
|
||||
if (stepByStepMode && isRunning) {
|
||||
setTimeout(() => pauseExecution(), 100);
|
||||
}
|
||||
},
|
||||
onStepError: (step, error) => {
|
||||
onStepStateChange?.(step.id, 'error');
|
||||
},
|
||||
onExecutionComplete: (success, summary) => {
|
||||
onExecutionComplete?.(success, summary);
|
||||
},
|
||||
onEvidenceGenerated: onEvidenceGenerated,
|
||||
},
|
||||
{
|
||||
autoValidate: settings.autoValidate,
|
||||
generateEvidence: settings.generateEvidence,
|
||||
retryAttempts: settings.retryAttempts,
|
||||
timeout: settings.timeout,
|
||||
pauseOnError: settings.pauseOnError,
|
||||
skipNonVWBSteps: settings.skipNonVWBSteps,
|
||||
}
|
||||
);
|
||||
|
||||
// Charger les états sauvegardés depuis localStorage
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem('vwb_execution_saved_states');
|
||||
if (saved) {
|
||||
try {
|
||||
const states = JSON.parse(saved);
|
||||
setSavedStates(states.map((state: any) => ({
|
||||
...state,
|
||||
timestamp: new Date(state.timestamp),
|
||||
})));
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement des états sauvegardés:', error);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Sauvegarder les états dans localStorage
|
||||
const saveSavedStates = useCallback((states: ExecutionSaveState[]) => {
|
||||
try {
|
||||
localStorage.setItem('vwb_execution_saved_states', JSON.stringify(states));
|
||||
setSavedStates(states);
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la sauvegarde des états:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Gestionnaire d'exécution avec délai
|
||||
const handleStartExecution = useCallback(async () => {
|
||||
if (settings.stepDelay > 0) {
|
||||
// Implémentation du délai entre étapes (simulation)
|
||||
console.log(`Démarrage avec délai de ${settings.stepDelay}ms entre les étapes`);
|
||||
}
|
||||
await startExecution();
|
||||
}, [startExecution, settings.stepDelay]);
|
||||
|
||||
// Gestionnaire d'exécution pas-à-pas
|
||||
const handleStepByStepExecution = useCallback(() => {
|
||||
setStepByStepMode(true);
|
||||
handleStartExecution();
|
||||
}, [handleStartExecution]);
|
||||
|
||||
// Gestionnaire de reprise pas-à-pas
|
||||
const handleStepByStepResume = useCallback(() => {
|
||||
if (isPaused && stepByStepMode) {
|
||||
resumeExecution();
|
||||
}
|
||||
}, [isPaused, stepByStepMode, resumeExecution]);
|
||||
|
||||
// Gestionnaire d'arrêt
|
||||
const handleStopExecution = useCallback(() => {
|
||||
setStepByStepMode(false);
|
||||
stopExecution();
|
||||
}, [stopExecution]);
|
||||
|
||||
// Gestionnaire de réinitialisation
|
||||
const handleResetExecution = useCallback(() => {
|
||||
setStepByStepMode(false);
|
||||
setBreakpoints(new Set());
|
||||
resetExecution();
|
||||
}, [resetExecution]);
|
||||
|
||||
// Basculer un breakpoint
|
||||
const toggleBreakpoint = useCallback((stepId: string) => {
|
||||
setBreakpoints(prev => {
|
||||
const newBreakpoints = new Set(prev);
|
||||
if (newBreakpoints.has(stepId)) {
|
||||
newBreakpoints.delete(stepId);
|
||||
} else {
|
||||
newBreakpoints.add(stepId);
|
||||
}
|
||||
return newBreakpoints;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Sauvegarder l'état d'exécution
|
||||
const saveExecutionState = useCallback(() => {
|
||||
if (!saveStateName.trim()) return;
|
||||
|
||||
const saveState: ExecutionSaveState = {
|
||||
id: `state_${Date.now()}`,
|
||||
name: saveStateName.trim(),
|
||||
timestamp: new Date(),
|
||||
workflowId: workflow.id,
|
||||
currentStepIndex: executionState.currentStepIndex,
|
||||
variables,
|
||||
settings,
|
||||
results: executionState.results,
|
||||
evidence: executionState.evidence,
|
||||
};
|
||||
|
||||
const newStates = [...savedStates, saveState];
|
||||
saveSavedStates(newStates);
|
||||
setSaveStateName('');
|
||||
setSaveStateDialogOpen(false);
|
||||
}, [saveStateName, workflow.id, executionState, variables, settings, savedStates, saveSavedStates]);
|
||||
|
||||
// Restaurer un état d'exécution
|
||||
const restoreExecutionState = useCallback((saveState: ExecutionSaveState) => {
|
||||
// Arrêter l'exécution en cours
|
||||
if (isRunning) {
|
||||
stopExecution();
|
||||
}
|
||||
|
||||
// Restaurer les paramètres
|
||||
setSettings(saveState.settings);
|
||||
|
||||
// Note: La restauration complète nécessiterait une intégration plus profonde
|
||||
// avec le système de gestion d'état du workflow
|
||||
console.log('Restauration de l\'état:', saveState);
|
||||
|
||||
setSettingsMenuAnchor(null);
|
||||
}, [isRunning, stopExecution]);
|
||||
|
||||
// Supprimer un état sauvegardé
|
||||
const deleteSavedState = useCallback((stateId: string) => {
|
||||
const newStates = savedStates.filter(state => state.id !== stateId);
|
||||
saveSavedStates(newStates);
|
||||
}, [savedStates, saveSavedStates]);
|
||||
|
||||
// Statistiques d'exécution
|
||||
const executionStats = useMemo(() => {
|
||||
const vwbSteps = workflow.steps.filter(step => isVWBStep(step));
|
||||
return {
|
||||
totalSteps: workflow.steps.length,
|
||||
vwbSteps: vwbSteps.length,
|
||||
breakpointsSet: breakpoints.size,
|
||||
estimatedDuration: workflow.steps.length * (settings.stepDelay + 1000), // Estimation
|
||||
};
|
||||
}, [workflow.steps, isVWBStep, breakpoints.size, settings.stepDelay]);
|
||||
|
||||
return (
|
||||
<Box className={className} sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{/* Contrôles principaux */}
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6" component="h3">
|
||||
Contrôles d'Exécution
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Chip
|
||||
label={`${executionStats.totalSteps} étapes`}
|
||||
size="small"
|
||||
icon={<TimelineIcon />}
|
||||
/>
|
||||
<Chip
|
||||
label={`${executionStats.vwbSteps} VWB`}
|
||||
size="small"
|
||||
color="primary"
|
||||
/>
|
||||
{breakpoints.size > 0 && (
|
||||
<Chip
|
||||
label={`${breakpoints.size} breakpoints`}
|
||||
size="small"
|
||||
color="warning"
|
||||
icon={<BookmarkIcon />}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Boutons de contrôle principaux */}
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 2, flexWrap: 'wrap' }}>
|
||||
<ButtonGroup variant="contained" size="large">
|
||||
{/* Bouton Exécuter - toujours visible */}
|
||||
<Tooltip title={canStart ? "Démarrer l'exécution complète" : "Exécution en cours ou pas d'étapes"}>
|
||||
<span>
|
||||
<Button
|
||||
startIcon={<PlayIcon />}
|
||||
onClick={() => {
|
||||
console.log('🔘 [Controls] Clic sur Exécuter', { canStart, stepsLength: workflow.steps.length });
|
||||
handleStartExecution();
|
||||
}}
|
||||
color="success"
|
||||
disabled={!canStart || workflow.steps.length === 0}
|
||||
>
|
||||
Exécuter ({workflow.steps.length})
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
{canPause && (
|
||||
<Tooltip title="Mettre en pause">
|
||||
<Button
|
||||
startIcon={<PauseIcon />}
|
||||
onClick={pauseExecution}
|
||||
color="warning"
|
||||
>
|
||||
Pause
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{canResume && (
|
||||
<Tooltip title="Reprendre l'exécution">
|
||||
<Button
|
||||
startIcon={<PlayIcon />}
|
||||
onClick={resumeExecution}
|
||||
color="success"
|
||||
>
|
||||
Reprendre
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{canStop && (
|
||||
<Tooltip title="Arrêter l'exécution">
|
||||
<Button
|
||||
startIcon={<StopIcon />}
|
||||
onClick={handleStopExecution}
|
||||
color="error"
|
||||
>
|
||||
Arrêter
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
|
||||
{/* Contrôles pas-à-pas */}
|
||||
<ButtonGroup variant="outlined">
|
||||
{canStart && (
|
||||
<Tooltip title="Exécution pas-à-pas">
|
||||
<Button
|
||||
startIcon={<StepIcon />}
|
||||
onClick={handleStepByStepExecution}
|
||||
disabled={workflow.steps.length === 0}
|
||||
>
|
||||
Pas-à-pas
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{stepByStepMode && isPaused && (
|
||||
<Tooltip title="Étape suivante">
|
||||
<Button
|
||||
startIcon={<StepIcon />}
|
||||
onClick={handleStepByStepResume}
|
||||
color="primary"
|
||||
>
|
||||
Suivant
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
|
||||
{/* Contrôles de gestion d'état */}
|
||||
<ButtonGroup variant="outlined">
|
||||
<Tooltip title="Réinitialiser">
|
||||
<Button
|
||||
startIcon={<ResetIcon />}
|
||||
onClick={handleResetExecution}
|
||||
disabled={isRunning}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Sauvegarder l'état">
|
||||
<Button
|
||||
startIcon={<SaveIcon />}
|
||||
onClick={() => setSaveStateDialogOpen(true)}
|
||||
disabled={executionState.status === 'idle'}
|
||||
>
|
||||
Sauver
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Paramètres et états sauvegardés">
|
||||
<IconButton
|
||||
onClick={(e) => setSettingsMenuAnchor(e.currentTarget)}
|
||||
>
|
||||
<SettingsIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</ButtonGroup>
|
||||
</Box>
|
||||
|
||||
{/* Mode debug et contrôles avancés */}
|
||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={debugMode}
|
||||
onChange={(e) => onDebugModeChange?.(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Mode Debug"
|
||||
/>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={stepByStepMode}
|
||||
onChange={(e) => setStepByStepMode(e.target.checked)}
|
||||
disabled={isRunning}
|
||||
/>
|
||||
}
|
||||
label="Pas-à-pas"
|
||||
/>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={settings.enableBreakpoints}
|
||||
onChange={(e) => setSettings(prev => ({ ...prev, enableBreakpoints: e.target.checked }))}
|
||||
/>
|
||||
}
|
||||
label="Breakpoints"
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="text"
|
||||
size="small"
|
||||
onClick={() => setShowAdvancedControls(!showAdvancedControls)}
|
||||
>
|
||||
{showAdvancedControls ? 'Masquer' : 'Afficher'} les contrôles avancés
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Contrôles avancés */}
|
||||
<Collapse in={showAdvancedControls}>
|
||||
<Box sx={{ mt: 2, p: 2, bgcolor: 'grey.50', borderRadius: 1 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Paramètres d'Exécution
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))', gap: 2 }}>
|
||||
{/* Délai entre étapes */}
|
||||
<Box>
|
||||
<Typography variant="body2" gutterBottom>
|
||||
Délai entre étapes: {settings.stepDelay}ms
|
||||
</Typography>
|
||||
<Slider
|
||||
value={settings.stepDelay}
|
||||
onChange={(_, value) => setSettings(prev => ({ ...prev, stepDelay: value as number }))}
|
||||
min={0}
|
||||
max={5000}
|
||||
step={100}
|
||||
disabled={isRunning}
|
||||
valueLabelDisplay="auto"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Timeout */}
|
||||
<Box>
|
||||
<Typography variant="body2" gutterBottom>
|
||||
Timeout: {settings.timeout / 1000}s
|
||||
</Typography>
|
||||
<Slider
|
||||
value={settings.timeout}
|
||||
onChange={(_, value) => setSettings(prev => ({ ...prev, timeout: value as number }))}
|
||||
min={5000}
|
||||
max={120000}
|
||||
step={5000}
|
||||
disabled={isRunning}
|
||||
valueLabelDisplay="auto"
|
||||
valueLabelFormat={(value) => `${value / 1000}s`}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Tentatives de retry */}
|
||||
<Box>
|
||||
<Typography variant="body2" gutterBottom>
|
||||
Tentatives de retry: {settings.retryAttempts}
|
||||
</Typography>
|
||||
<Slider
|
||||
value={settings.retryAttempts}
|
||||
onChange={(_, value) => setSettings(prev => ({ ...prev, retryAttempts: value as number }))}
|
||||
min={0}
|
||||
max={10}
|
||||
step={1}
|
||||
disabled={isRunning}
|
||||
valueLabelDisplay="auto"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 2, display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={settings.autoValidate}
|
||||
onChange={(e) => setSettings(prev => ({ ...prev, autoValidate: e.target.checked }))}
|
||||
disabled={isRunning}
|
||||
/>
|
||||
}
|
||||
label="Validation automatique"
|
||||
/>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={settings.generateEvidence}
|
||||
onChange={(e) => setSettings(prev => ({ ...prev, generateEvidence: e.target.checked }))}
|
||||
disabled={isRunning}
|
||||
/>
|
||||
}
|
||||
label="Générer Evidence"
|
||||
/>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={settings.pauseOnError}
|
||||
onChange={(e) => setSettings(prev => ({ ...prev, pauseOnError: e.target.checked }))}
|
||||
disabled={isRunning}
|
||||
/>
|
||||
}
|
||||
label="Pause sur erreur"
|
||||
/>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={settings.skipNonVWBSteps}
|
||||
onChange={(e) => setSettings(prev => ({ ...prev, skipNonVWBSteps: e.target.checked }))}
|
||||
disabled={isRunning}
|
||||
/>
|
||||
}
|
||||
label="Ignorer étapes non-VWB"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Informations d'état */}
|
||||
{(isRunning || isPaused) && (
|
||||
<Alert severity={isPaused ? 'warning' : 'info'}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Box>
|
||||
<Typography variant="body2">
|
||||
{isPaused ? 'Exécution en pause' : 'Exécution en cours'}
|
||||
{stepByStepMode && ' (Mode pas-à-pas)'}
|
||||
</Typography>
|
||||
{executionState.currentStep && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Étape actuelle: {executionState.currentStep.name}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Chip
|
||||
label={`${executionState.completedSteps}/${executionState.totalSteps}`}
|
||||
size="small"
|
||||
icon={<ScheduleIcon />}
|
||||
/>
|
||||
{executionState.evidence.length > 0 && (
|
||||
<Chip
|
||||
label={`${executionState.evidence.length} Evidence`}
|
||||
size="small"
|
||||
color="info"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Menu des paramètres */}
|
||||
<Menu
|
||||
anchorEl={settingsMenuAnchor}
|
||||
open={Boolean(settingsMenuAnchor)}
|
||||
onClose={() => setSettingsMenuAnchor(null)}
|
||||
>
|
||||
<MenuItem onClick={() => setShowAdvancedControls(!showAdvancedControls)}>
|
||||
<ListItemIcon>
|
||||
<SettingsIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Paramètres avancés" />
|
||||
</MenuItem>
|
||||
|
||||
<Divider />
|
||||
|
||||
{savedStates.length > 0 && (
|
||||
<>
|
||||
<MenuItem disabled>
|
||||
<ListItemText primary="États sauvegardés" />
|
||||
</MenuItem>
|
||||
{savedStates.slice(-5).map((state) => (
|
||||
<MenuItem
|
||||
key={state.id}
|
||||
onClick={() => restoreExecutionState(state)}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<RestoreIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={state.name}
|
||||
secondary={state.timestamp.toLocaleString()}
|
||||
/>
|
||||
</MenuItem>
|
||||
))}
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
|
||||
<MenuItem onClick={() => setSaveStateDialogOpen(true)}>
|
||||
<ListItemIcon>
|
||||
<SaveIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Sauvegarder l'état actuel" />
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
{/* Dialog de sauvegarde d'état */}
|
||||
{saveStateDialogOpen && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
bgcolor: 'background.paper',
|
||||
boxShadow: 24,
|
||||
p: 3,
|
||||
borderRadius: 2,
|
||||
minWidth: 300,
|
||||
zIndex: 1300,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Sauvegarder l'État d'Exécution
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Nom de la sauvegarde"
|
||||
value={saveStateName}
|
||||
onChange={(e) => setSaveStateName(e.target.value)}
|
||||
margin="normal"
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SaveIcon />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', gap: 2, mt: 2 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={saveExecutionState}
|
||||
disabled={!saveStateName.trim()}
|
||||
>
|
||||
Sauvegarder
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => {
|
||||
setSaveStateDialogOpen(false);
|
||||
setSaveStateName('');
|
||||
}}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExecutionControls;
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Index des Contrôles d'Exécution VWB
|
||||
* Auteur : Dom, Alice, Kiro - 11 janvier 2026
|
||||
*/
|
||||
|
||||
export { default as ExecutionControls } from './ExecutionControls';
|
||||
export type {
|
||||
ExecutionControlsProps,
|
||||
ExecutionSettings,
|
||||
ExecutionSaveState
|
||||
} from './ExecutionControls';
|
||||
@@ -232,8 +232,8 @@ const VWBExecutorExtension: React.FC<VWBExecutorExtensionProps> = ({
|
||||
// Déterminer l'URL de l'API
|
||||
const hostname = window.location.hostname;
|
||||
const apiBase = (hostname === 'localhost' || hostname === '127.0.0.1')
|
||||
? 'http://localhost:5003/api'
|
||||
: `http://${hostname}:5003/api`;
|
||||
? 'http://localhost:5001/api'
|
||||
: `http://${hostname}:5000/api`;
|
||||
|
||||
const response = await fetch(`${apiBase}/workflows/${workflow.id}/feedback`, {
|
||||
method: 'POST',
|
||||
@@ -385,17 +385,24 @@ const VWBExecutorExtension: React.FC<VWBExecutorExtensionProps> = ({
|
||||
</Box>
|
||||
|
||||
{/* Contrôles d'exécution */}
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 2 }}>
|
||||
{canStart && (
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<PlayIcon />}
|
||||
onClick={startExecution}
|
||||
disabled={!canExecute || workflow.steps.length === 0}
|
||||
color="success"
|
||||
>
|
||||
Exécuter VWB
|
||||
</Button>
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 2, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{/* Bouton Exécuter - toujours visible */}
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<PlayIcon />}
|
||||
onClick={() => {
|
||||
console.log('🔘 [VWB UI] Clic sur Exécuter VWB', { canStart, canExecute, stepsLength: workflow.steps.length });
|
||||
startExecution();
|
||||
}}
|
||||
disabled={!canStart || !canExecute || workflow.steps.length === 0}
|
||||
color="success"
|
||||
>
|
||||
Exécuter VWB ({workflow.steps.length} étapes)
|
||||
</Button>
|
||||
|
||||
{/* Message d'état */}
|
||||
{!canStart && executionState.status === 'running' && (
|
||||
<Chip label="Exécution en cours..." color="primary" size="small" />
|
||||
)}
|
||||
|
||||
{canPause && (
|
||||
|
||||
@@ -484,30 +484,13 @@ const StandardExecutor: React.FC<StandardExecutorProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Exécuter une étape individuelle avec le nouveau client API ou en simulation locale
|
||||
// Exécuter une étape individuelle avec le nouveau client API
|
||||
// NOTE: On n'utilise plus isOffline comme bloqueur - on essaie toujours l'API
|
||||
const executeStep = useCallback(async (step: Step): Promise<StepExecutionResult> => {
|
||||
const startTime = Date.now();
|
||||
const stepId = step.id;
|
||||
const currentRetries = retryAttempts.get(stepId) || 0;
|
||||
|
||||
// Mode simulation locale si API hors ligne
|
||||
if (isOffline) {
|
||||
// Simuler un délai d'exécution réaliste
|
||||
await new Promise(resolve => setTimeout(resolve, 300 + Math.random() * 500));
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// Simulation : succès pour la plupart des étapes
|
||||
const simulatedSuccess = Math.random() > 0.1; // 90% de succès en simulation
|
||||
|
||||
return {
|
||||
stepId: step.id,
|
||||
success: simulatedSuccess,
|
||||
duration,
|
||||
output: simulatedSuccess ? { simulated: true, stepType: step.type } : undefined,
|
||||
error: simulatedSuccess ? undefined : 'Erreur simulée pour démonstration',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Utiliser le nouveau client API pour l'exécution
|
||||
const result = await executionApi.executeStep({
|
||||
@@ -570,7 +553,7 @@ const StandardExecutor: React.FC<StandardExecutorProps> = ({
|
||||
error: apiError.message || 'Erreur inconnue',
|
||||
};
|
||||
}
|
||||
}, [executionApi, workflow.id, retryAttempts, isOffline]);
|
||||
}, [executionApi, workflow.id, retryAttempts]);
|
||||
|
||||
// Déterminer si une étape doit être retentée
|
||||
const shouldRetryStep = useCallback((error: ApiError): boolean => {
|
||||
|
||||
@@ -0,0 +1,519 @@
|
||||
/**
|
||||
* Composant Glossaire - Dictionnaire des termes techniques français
|
||||
* Auteur : Dom, Alice, Kiro - 08 janvier 2026
|
||||
*
|
||||
* Ce composant fournit un glossaire accessible des termes techniques
|
||||
* utilisés dans le Visual Workflow Builder, avec recherche et navigation.
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
TextField,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
InputAdornment,
|
||||
Chip,
|
||||
Divider,
|
||||
Paper,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Search as SearchIcon,
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
Book as BookIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface GlossaryTerm {
|
||||
id: string;
|
||||
term: string;
|
||||
definition: string;
|
||||
category: string;
|
||||
synonyms?: string[];
|
||||
relatedTerms?: string[];
|
||||
examples?: string[];
|
||||
}
|
||||
|
||||
interface GlossaryProps {
|
||||
searchTerm?: string;
|
||||
onTermSelect?: (termId: string) => void;
|
||||
}
|
||||
|
||||
// Base de données des termes du glossaire
|
||||
const glossaryTerms: GlossaryTerm[] = [
|
||||
// Termes généraux
|
||||
{
|
||||
id: 'workflow',
|
||||
term: 'Workflow',
|
||||
definition: 'Séquence d\'étapes automatisées qui reproduit un processus métier ou une tâche répétitive. Dans RPA Vision, un workflow est composé d\'étapes connectées qui s\'exécutent dans un ordre logique.',
|
||||
category: 'Général',
|
||||
synonyms: ['Flux de travail', 'Processus automatisé'],
|
||||
relatedTerms: ['etape', 'connexion', 'execution'],
|
||||
examples: ['Workflow de saisie de données', 'Workflow de validation de formulaires']
|
||||
},
|
||||
{
|
||||
id: 'etape',
|
||||
term: 'Étape',
|
||||
definition: 'Unité d\'action élémentaire dans un workflow. Chaque étape effectue une action spécifique comme cliquer, saisir du texte, ou extraire des données.',
|
||||
category: 'Général',
|
||||
synonyms: ['Action', 'Nœud', 'Tâche'],
|
||||
relatedTerms: ['workflow', 'parametres', 'connexion'],
|
||||
examples: ['Étape "Cliquer"', 'Étape "Saisir du texte"', 'Étape "Attendre"']
|
||||
},
|
||||
{
|
||||
id: 'connexion',
|
||||
term: 'Connexion',
|
||||
definition: 'Lien entre deux étapes qui définit l\'ordre d\'exécution. Les connexions créent le flux logique du workflow.',
|
||||
category: 'Général',
|
||||
synonyms: ['Lien', 'Transition', 'Flèche'],
|
||||
relatedTerms: ['etape', 'workflow', 'flux'],
|
||||
examples: ['Connexion de l\'étape A vers l\'étape B']
|
||||
},
|
||||
{
|
||||
id: 'parametres',
|
||||
term: 'Paramètres',
|
||||
definition: 'Valeurs de configuration d\'une étape qui définissent comment elle doit s\'exécuter. Chaque type d\'étape a ses propres paramètres.',
|
||||
category: 'Configuration',
|
||||
synonyms: ['Propriétés', 'Configuration', 'Options'],
|
||||
relatedTerms: ['etape', 'validation', 'variables'],
|
||||
examples: ['Paramètre "texte" pour une étape de saisie', 'Paramètre "durée" pour une attente']
|
||||
},
|
||||
|
||||
// Termes techniques
|
||||
{
|
||||
id: 'selection-visuelle',
|
||||
term: 'Sélection visuelle',
|
||||
definition: 'Méthode pour choisir un élément sur une page web en cliquant directement sur une capture d\'écran, plutôt qu\'en écrivant un sélecteur CSS.',
|
||||
category: 'Technique',
|
||||
synonyms: ['Sélecteur visuel', 'Pointage visuel'],
|
||||
relatedTerms: ['element-cible', 'capture-ecran', 'selecteur'],
|
||||
examples: ['Sélectionner un bouton en cliquant dessus', 'Choisir un champ de saisie visuellement']
|
||||
},
|
||||
{
|
||||
id: 'element-cible',
|
||||
term: 'Élément cible',
|
||||
definition: 'Élément d\'une page web sur lequel une étape va agir. Peut être un bouton, un champ de saisie, un lien, etc.',
|
||||
category: 'Technique',
|
||||
synonyms: ['Cible', 'Élément sélectionné'],
|
||||
relatedTerms: ['selection-visuelle', 'selecteur', 'dom'],
|
||||
examples: ['Bouton "Valider"', 'Champ "Email"', 'Lien "En savoir plus"']
|
||||
},
|
||||
{
|
||||
id: 'variables',
|
||||
term: 'Variables',
|
||||
definition: 'Valeurs dynamiques qui peuvent être utilisées dans les paramètres des étapes. Permettent de rendre les workflows réutilisables et flexibles.',
|
||||
category: 'Technique',
|
||||
synonyms: ['Variables dynamiques', 'Placeholders'],
|
||||
relatedTerms: ['parametres', 'substitution', 'dynamique'],
|
||||
examples: ['${nom_utilisateur}', '${email}', '${compteur}']
|
||||
},
|
||||
{
|
||||
id: 'validation',
|
||||
term: 'Validation',
|
||||
definition: 'Vérification automatique de la cohérence et de la complétude d\'un workflow avant son exécution. Détecte les erreurs et avertissements.',
|
||||
category: 'Technique',
|
||||
synonyms: ['Vérification', 'Contrôle qualité'],
|
||||
relatedTerms: ['erreurs', 'avertissements', 'execution'],
|
||||
examples: ['Validation des paramètres manquants', 'Détection de cycles']
|
||||
},
|
||||
|
||||
// Types d'étapes
|
||||
{
|
||||
id: 'clic',
|
||||
term: 'Clic',
|
||||
definition: 'Action de cliquer sur un élément de la page web. Peut être un clic gauche, droit, ou double-clic selon les besoins.',
|
||||
category: 'Actions Web',
|
||||
synonyms: ['Click', 'Cliquer'],
|
||||
relatedTerms: ['element-cible', 'interaction'],
|
||||
examples: ['Clic sur un bouton', 'Clic droit pour menu contextuel']
|
||||
},
|
||||
{
|
||||
id: 'saisie',
|
||||
term: 'Saisie de texte',
|
||||
definition: 'Action de saisir du texte dans un champ de saisie. Supporte les variables pour un contenu dynamique.',
|
||||
category: 'Actions Web',
|
||||
synonyms: ['Frappe', 'Écriture', 'Input'],
|
||||
relatedTerms: ['variables', 'champ-saisie', 'texte'],
|
||||
examples: ['Saisir un nom d\'utilisateur', 'Remplir un formulaire']
|
||||
},
|
||||
{
|
||||
id: 'extraction',
|
||||
term: 'Extraction de données',
|
||||
definition: 'Action de récupérer des informations depuis un élément de la page web pour les stocker dans une variable.',
|
||||
category: 'Données',
|
||||
synonyms: ['Récupération', 'Capture de données'],
|
||||
relatedTerms: ['variables', 'element-cible', 'donnees'],
|
||||
examples: ['Extraire le prix d\'un produit', 'Récupérer un numéro de commande']
|
||||
},
|
||||
{
|
||||
id: 'condition',
|
||||
term: 'Condition',
|
||||
definition: 'Structure logique qui permet d\'exécuter différentes actions selon qu\'une condition est vraie ou fausse.',
|
||||
category: 'Logique',
|
||||
synonyms: ['Test conditionnel', 'Branchement'],
|
||||
relatedTerms: ['logique', 'variables', 'flux'],
|
||||
examples: ['Si âge > 18 alors continuer', 'Si champ vide alors alerter']
|
||||
},
|
||||
{
|
||||
id: 'attente',
|
||||
term: 'Attente',
|
||||
definition: 'Pause dans l\'exécution du workflow pendant une durée déterminée. Utile pour attendre le chargement d\'éléments.',
|
||||
category: 'Contrôle',
|
||||
synonyms: ['Pause', 'Délai', 'Wait'],
|
||||
relatedTerms: ['synchronisation', 'timing'],
|
||||
examples: ['Attendre 2 secondes', 'Pause après un clic']
|
||||
},
|
||||
|
||||
// Interface utilisateur
|
||||
{
|
||||
id: 'canvas',
|
||||
term: 'Canvas',
|
||||
definition: 'Zone de travail principale où les étapes sont placées et connectées pour former le workflow visuel.',
|
||||
category: 'Interface',
|
||||
synonyms: ['Zone de travail', 'Espace de conception'],
|
||||
relatedTerms: ['etape', 'connexion', 'workflow'],
|
||||
examples: ['Glisser une étape sur le canvas', 'Zoomer sur le canvas']
|
||||
},
|
||||
{
|
||||
id: 'palette',
|
||||
term: 'Palette',
|
||||
definition: 'Boîte à outils contenant tous les types d\'étapes disponibles, organisés par catégories pour faciliter la recherche.',
|
||||
category: 'Interface',
|
||||
synonyms: ['Boîte à outils', 'Toolbox'],
|
||||
relatedTerms: ['etape', 'categories', 'drag-drop'],
|
||||
examples: ['Palette d\'étapes', 'Catégorie Actions Web']
|
||||
},
|
||||
{
|
||||
id: 'proprietes',
|
||||
term: 'Panneau de propriétés',
|
||||
definition: 'Interface de configuration des paramètres de l\'étape sélectionnée. S\'adapte selon le type d\'étape.',
|
||||
category: 'Interface',
|
||||
synonyms: ['Configuration', 'Paramètres'],
|
||||
relatedTerms: ['parametres', 'etape', 'configuration'],
|
||||
examples: ['Configurer le texte à saisir', 'Définir la durée d\'attente']
|
||||
},
|
||||
{
|
||||
id: 'minimap',
|
||||
term: 'Mini-carte',
|
||||
definition: 'Vue d\'ensemble miniaturisée du workflow complet, permettant de naviguer rapidement dans les gros workflows.',
|
||||
category: 'Interface',
|
||||
synonyms: ['Vue d\'ensemble', 'Navigation'],
|
||||
relatedTerms: ['canvas', 'navigation', 'workflow'],
|
||||
examples: ['Cliquer sur la mini-carte pour se déplacer']
|
||||
},
|
||||
|
||||
// Concepts avancés
|
||||
{
|
||||
id: 'cycle',
|
||||
term: 'Cycle',
|
||||
definition: 'Boucle infinie dans un workflow où les étapes se référencent mutuellement, empêchant l\'exécution normale.',
|
||||
category: 'Erreurs',
|
||||
synonyms: ['Boucle infinie', 'Référence circulaire'],
|
||||
relatedTerms: ['validation', 'erreurs', 'connexion'],
|
||||
examples: ['Étape A → Étape B → Étape A']
|
||||
},
|
||||
{
|
||||
id: 'etape-deconnectee',
|
||||
term: 'Étape déconnectée',
|
||||
definition: 'Étape qui n\'est pas reliée au flux principal du workflow et qui ne sera donc pas exécutée.',
|
||||
category: 'Avertissements',
|
||||
synonyms: ['Étape isolée', 'Nœud orphelin'],
|
||||
relatedTerms: ['connexion', 'validation', 'flux'],
|
||||
examples: ['Étape sans connexion d\'entrée ni de sortie']
|
||||
},
|
||||
{
|
||||
id: 'execution',
|
||||
term: 'Exécution',
|
||||
definition: 'Processus de lancement et de déroulement du workflow, étape par étape, selon l\'ordre défini par les connexions.',
|
||||
category: 'Général',
|
||||
synonyms: ['Lancement', 'Déroulement', 'Run'],
|
||||
relatedTerms: ['workflow', 'etape', 'validation'],
|
||||
examples: ['Exécution réussie', 'Exécution interrompue par erreur']
|
||||
}
|
||||
];
|
||||
|
||||
// Catégories pour l'organisation
|
||||
const categories = [
|
||||
'Général',
|
||||
'Technique',
|
||||
'Actions Web',
|
||||
'Données',
|
||||
'Logique',
|
||||
'Contrôle',
|
||||
'Interface',
|
||||
'Erreurs',
|
||||
'Avertissements'
|
||||
];
|
||||
|
||||
/**
|
||||
* Composant Glossaire
|
||||
*/
|
||||
const Glossary: React.FC<GlossaryProps> = ({
|
||||
searchTerm: externalSearchTerm = '',
|
||||
onTermSelect,
|
||||
}) => {
|
||||
const [internalSearchTerm, setInternalSearchTerm] = useState('');
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set(['Général']));
|
||||
|
||||
// Utiliser le terme de recherche externe ou interne
|
||||
const searchTerm = externalSearchTerm || internalSearchTerm;
|
||||
|
||||
// Filtrer les termes selon la recherche
|
||||
const filteredTerms = useMemo(() => {
|
||||
if (!searchTerm.trim()) {
|
||||
return glossaryTerms;
|
||||
}
|
||||
|
||||
const lowerSearchTerm = searchTerm.toLowerCase();
|
||||
return glossaryTerms.filter(term =>
|
||||
term.term.toLowerCase().includes(lowerSearchTerm) ||
|
||||
term.definition.toLowerCase().includes(lowerSearchTerm) ||
|
||||
term.synonyms?.some(synonym => synonym.toLowerCase().includes(lowerSearchTerm)) ||
|
||||
term.examples?.some(example => example.toLowerCase().includes(lowerSearchTerm))
|
||||
);
|
||||
}, [searchTerm]);
|
||||
|
||||
// Organiser les termes par catégorie
|
||||
const termsByCategory = useMemo(() => {
|
||||
const organized: Record<string, GlossaryTerm[]> = {};
|
||||
|
||||
categories.forEach(category => {
|
||||
organized[category] = filteredTerms.filter(term => term.category === category);
|
||||
});
|
||||
|
||||
return organized;
|
||||
}, [filteredTerms]);
|
||||
|
||||
// Gestionnaire d'expansion des catégories
|
||||
const handleCategoryToggle = (category: string) => {
|
||||
setExpandedCategories(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(category)) {
|
||||
newSet.delete(category);
|
||||
} else {
|
||||
newSet.add(category);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
// Gestionnaire de sélection de terme
|
||||
const handleTermSelect = (termId: string) => {
|
||||
if (onTermSelect) {
|
||||
onTermSelect(termId);
|
||||
}
|
||||
};
|
||||
|
||||
// Rendu d'un terme
|
||||
const renderTerm = (term: GlossaryTerm) => (
|
||||
<ListItem
|
||||
key={term.id}
|
||||
onClick={() => handleTermSelect(term.id)}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
flexDirection: 'column',
|
||||
alignItems: 'stretch',
|
||||
py: 2,
|
||||
}}
|
||||
>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<Typography variant="h6" component="dt">
|
||||
{term.term}
|
||||
</Typography>
|
||||
{term.synonyms && term.synonyms.length > 0 && (
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
{term.synonyms.map((synonym, index) => (
|
||||
<Chip
|
||||
key={index}
|
||||
label={synonym}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
secondary={
|
||||
<Box component="dd" sx={{ m: 0 }}>
|
||||
<Typography variant="body2" paragraph>
|
||||
{term.definition}
|
||||
</Typography>
|
||||
|
||||
{term.examples && term.examples.length > 0 && (
|
||||
<Box sx={{ mb: 1 }}>
|
||||
<Typography variant="caption" fontWeight="bold" color="text.secondary">
|
||||
Exemples :
|
||||
</Typography>
|
||||
<List dense sx={{ pl: 2 }}>
|
||||
{term.examples.map((example, index) => (
|
||||
<ListItem key={index} sx={{ py: 0, pl: 0 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
• {example}
|
||||
</Typography>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{term.relatedTerms && term.relatedTerms.length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="caption" fontWeight="bold" color="text.secondary">
|
||||
Termes liés :
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mt: 0.5 }}>
|
||||
{term.relatedTerms.map((relatedId, index) => {
|
||||
const relatedTerm = glossaryTerms.find(t => t.id === relatedId);
|
||||
return relatedTerm ? (
|
||||
<Chip
|
||||
key={index}
|
||||
label={relatedTerm.term}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleTermSelect(relatedId);
|
||||
}}
|
||||
/>
|
||||
) : null;
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
{/* En-tête */}
|
||||
<Box sx={{ p: 2, borderBottom: '1px solid #e0e0e0' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<BookIcon color="primary" />
|
||||
<Typography variant="h5" component="h1">
|
||||
Glossaire Technique
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Champ de recherche */}
|
||||
{!externalSearchTerm && (
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder="Rechercher un terme..."
|
||||
value={internalSearchTerm}
|
||||
onChange={(e) => setInternalSearchTerm(e.target.value)}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon />
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Statistiques */}
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
|
||||
{filteredTerms.length} terme(s) trouvé(s) sur {glossaryTerms.length} au total
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Contenu du glossaire */}
|
||||
<Box sx={{ flex: 1, overflow: 'auto' }}>
|
||||
{searchTerm ? (
|
||||
// Vue de recherche : tous les termes filtrés
|
||||
<Paper sx={{ m: 2, p: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Résultats de recherche pour "{searchTerm}"
|
||||
</Typography>
|
||||
<List component="dl">
|
||||
{filteredTerms.map(term => (
|
||||
<React.Fragment key={term.id}>
|
||||
{renderTerm(term)}
|
||||
<Divider />
|
||||
</React.Fragment>
|
||||
))}
|
||||
</List>
|
||||
{filteredTerms.length === 0 && (
|
||||
<Typography variant="body2" color="text.secondary" textAlign="center">
|
||||
Aucun terme trouvé pour "{searchTerm}"
|
||||
</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
) : (
|
||||
// Vue par catégories
|
||||
<Box>
|
||||
{categories.map(category => {
|
||||
const categoryTerms = termsByCategory[category];
|
||||
if (categoryTerms.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Accordion
|
||||
key={category}
|
||||
expanded={expandedCategories.has(category)}
|
||||
onChange={() => handleCategoryToggle(category)}
|
||||
disableGutters
|
||||
elevation={0}
|
||||
sx={{
|
||||
'&:before': { display: 'none' },
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
}}
|
||||
>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMoreIcon />}
|
||||
sx={{
|
||||
minHeight: 56,
|
||||
'& .MuiAccordionSummary-content': {
|
||||
alignItems: 'center',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="h6">{category}</Typography>
|
||||
<Chip
|
||||
label={categoryTerms.length}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
|
||||
<AccordionDetails sx={{ p: 0 }}>
|
||||
<List component="dl">
|
||||
{categoryTerms.map(term => (
|
||||
<React.Fragment key={term.id}>
|
||||
{renderTerm(term)}
|
||||
<Divider />
|
||||
</React.Fragment>
|
||||
))}
|
||||
</List>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Glossary;
|
||||
@@ -0,0 +1,413 @@
|
||||
/**
|
||||
* Interactive Preview Area Styles - RPA Vision V3
|
||||
*
|
||||
* Styles pour la zone d'aperçu interactif avec zoom et navigation.
|
||||
* Optimisé pour une expérience de visualisation fluide et intuitive.
|
||||
*/
|
||||
|
||||
.interactive-preview-area {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
z-index: 9998;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.interactive-preview-area__header {
|
||||
background: #1976d2;
|
||||
color: white;
|
||||
padding: 16px 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.interactive-preview-area__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.interactive-preview-area__controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.interactive-preview-area__zoom-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
padding: 8px 12px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.interactive-preview-area__zoom-slider {
|
||||
width: 100px;
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
.interactive-preview-area__zoom-label {
|
||||
min-width: 40px;
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.interactive-preview-area__action-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.interactive-preview-area__content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.interactive-preview-area__canvas-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: #2c2c2c;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.interactive-preview-area__canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: grab;
|
||||
transition: cursor 0.2s ease;
|
||||
}
|
||||
|
||||
.interactive-preview-area__canvas--dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.interactive-preview-area__sidebar {
|
||||
width: 320px;
|
||||
background: white;
|
||||
border-left: 1px solid #e0e0e0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.interactive-preview-area__sidebar-header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.interactive-preview-area__sidebar-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.interactive-preview-area__info-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.interactive-preview-area__info-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.interactive-preview-area__info-content {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.interactive-preview-area__metadata-grid {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.interactive-preview-area__metadata-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.interactive-preview-area__metadata-label {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.interactive-preview-area__metadata-value {
|
||||
color: #666;
|
||||
text-align: right;
|
||||
flex: 1;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.interactive-preview-area__text-content {
|
||||
background: #e3f2fd;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #bbdefb;
|
||||
font-style: italic;
|
||||
color: #1565c0;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.interactive-preview-area__chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.interactive-preview-area__display-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.interactive-preview-area__control-button {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.interactive-preview-area__control-button:hover {
|
||||
background: #f5f5f5;
|
||||
border-color: #1976d2;
|
||||
}
|
||||
|
||||
.interactive-preview-area__control-button--active {
|
||||
background: #1976d2;
|
||||
color: white;
|
||||
border-color: #1976d2;
|
||||
}
|
||||
|
||||
/* Annotations overlay */
|
||||
.interactive-preview-area__annotations {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.interactive-preview-area__annotation {
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.interactive-preview-area__annotation:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.interactive-preview-area__annotation--info {
|
||||
background: #2196f3;
|
||||
}
|
||||
|
||||
.interactive-preview-area__annotation--warning {
|
||||
background: #ff9800;
|
||||
}
|
||||
|
||||
.interactive-preview-area__annotation--success {
|
||||
background: #4caf50;
|
||||
}
|
||||
|
||||
.interactive-preview-area__annotation-tooltip {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
/* Target highlight animations */
|
||||
.interactive-preview-area__target-highlight {
|
||||
position: absolute;
|
||||
border: 3px solid #4caf50;
|
||||
background: rgba(76, 175, 80, 0.1);
|
||||
pointer-events: none;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.interactive-preview-area__target-highlight--animated {
|
||||
animation: interactive-preview-pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes interactive-preview-pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(76, 175, 80, 0.7);
|
||||
border-width: 3px;
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 10px rgba(76, 175, 80, 0.2);
|
||||
border-width: 4px;
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(76, 175, 80, 0);
|
||||
border-width: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.interactive-preview-area__target-corners {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
z-index: 6;
|
||||
}
|
||||
|
||||
.interactive-preview-area__corner {
|
||||
position: absolute;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border: 2px solid #4caf50;
|
||||
}
|
||||
|
||||
.interactive-preview-area__corner--top-left {
|
||||
top: -2px;
|
||||
left: -2px;
|
||||
border-right: none;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.interactive-preview-area__corner--top-right {
|
||||
top: -2px;
|
||||
right: -2px;
|
||||
border-left: none;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.interactive-preview-area__corner--bottom-left {
|
||||
bottom: -2px;
|
||||
left: -2px;
|
||||
border-right: none;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.interactive-preview-area__corner--bottom-right {
|
||||
bottom: -2px;
|
||||
right: -2px;
|
||||
border-left: none;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
/* Loading state */
|
||||
.interactive-preview-area__loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.interactive-preview-area__loading-spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 4px solid rgba(255, 255, 255, 0.3);
|
||||
border-top: 4px solid white;
|
||||
border-radius: 50%;
|
||||
animation: interactive-preview-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes interactive-preview-spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.interactive-preview-area__sidebar {
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.interactive-preview-area__header {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.interactive-preview-area__zoom-controls {
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.interactive-preview-area__zoom-slider {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.interactive-preview-area__sidebar-content {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.interactive-preview-area__content {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.interactive-preview-area__sidebar {
|
||||
width: 100%;
|
||||
height: 250px;
|
||||
border-left: none;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.interactive-preview-area__canvas-container {
|
||||
height: calc(100vh - 250px - 80px);
|
||||
}
|
||||
|
||||
.interactive-preview-area__controls {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.interactive-preview-area__zoom-controls {
|
||||
order: 2;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,612 @@
|
||||
/**
|
||||
* Composant Zone d'Aperçu Interactif - Configuration des paramètres d'étapes
|
||||
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
|
||||
*
|
||||
* Zone d'aperçu interactif pour les captures d'écran avec zoom, navigation et annotations.
|
||||
* Permet l'examen détaillé des éléments sélectionnés avec contours animés.
|
||||
*
|
||||
* Fonctionnalités:
|
||||
* - Zoom fluide avec molette de souris
|
||||
* - Navigation par glisser-déposer
|
||||
* - Annotations contextuelles
|
||||
* - Surbrillance de l'élément cible
|
||||
* - Contrôles de navigation intuitifs
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
Box,
|
||||
Typography,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Chip,
|
||||
Paper,
|
||||
Slider,
|
||||
Divider,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Close as CloseIcon,
|
||||
ZoomIn as ZoomInIcon,
|
||||
ZoomOut as ZoomOutIcon,
|
||||
CenterFocusStrong as CenterIcon,
|
||||
Fullscreen as FullscreenIcon,
|
||||
Info as InfoIcon,
|
||||
MyLocation as TargetIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
import { BoundingBox } from '../../types';
|
||||
import './InteractivePreviewArea.css';
|
||||
|
||||
interface VisualMetadata {
|
||||
element_type?: string;
|
||||
relative_position?: string;
|
||||
text_content?: string;
|
||||
}
|
||||
|
||||
interface InteractivePreviewAreaProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
screenshot: string;
|
||||
boundingBox: BoundingBox;
|
||||
metadata?: VisualMetadata;
|
||||
}
|
||||
|
||||
interface ViewportState {
|
||||
zoom: number;
|
||||
panX: number;
|
||||
panY: number;
|
||||
isDragging: boolean;
|
||||
dragStart: { x: number; y: number };
|
||||
}
|
||||
|
||||
interface AnnotationPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
label: string;
|
||||
type: 'info' | 'warning' | 'target';
|
||||
}
|
||||
|
||||
const InteractivePreviewArea: React.FC<InteractivePreviewAreaProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
screenshot,
|
||||
boundingBox,
|
||||
metadata,
|
||||
}) => {
|
||||
const [viewport, setViewport] = useState<ViewportState>({
|
||||
zoom: 1,
|
||||
panX: 0,
|
||||
panY: 0,
|
||||
isDragging: false,
|
||||
dragStart: { x: 0, y: 0 },
|
||||
});
|
||||
|
||||
const [showAnnotations, setShowAnnotations] = useState(true);
|
||||
const [showTargetHighlight, setShowTargetHighlight] = useState(true);
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
const [imageDimensions, setImageDimensions] = useState({ width: 0, height: 0 });
|
||||
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
const animationRef = useRef<number | null>(null);
|
||||
|
||||
// Réinitialiser l'état quand le dialog s'ouvre
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setViewport({
|
||||
zoom: 1,
|
||||
panX: 0,
|
||||
panY: 0,
|
||||
isDragging: false,
|
||||
dragStart: { x: 0, y: 0 },
|
||||
});
|
||||
setImageLoaded(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Charger l'image et obtenir ses dimensions
|
||||
useEffect(() => {
|
||||
if (open && screenshot) {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
setImageDimensions({ width: img.width, height: img.height });
|
||||
setImageLoaded(true);
|
||||
|
||||
// Centrer sur l'élément cible
|
||||
centerOnTarget();
|
||||
};
|
||||
img.src = `data:image/png;base64,${screenshot}`;
|
||||
imageRef.current = img;
|
||||
}
|
||||
}, [open, screenshot]);
|
||||
|
||||
// Animation du contour cible
|
||||
useEffect(() => {
|
||||
if (showTargetHighlight && imageLoaded) {
|
||||
const animate = () => {
|
||||
drawCanvas();
|
||||
animationRef.current = requestAnimationFrame(animate);
|
||||
};
|
||||
animate();
|
||||
|
||||
return () => {
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
}
|
||||
};
|
||||
}
|
||||
}, [showTargetHighlight, imageLoaded, viewport]);
|
||||
|
||||
/**
|
||||
* Centre la vue sur l'élément cible
|
||||
*/
|
||||
const centerOnTarget = useCallback(() => {
|
||||
if (!imageLoaded || !containerRef.current) return;
|
||||
|
||||
const container = containerRef.current;
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
|
||||
// Calculer la position pour centrer l'élément cible
|
||||
const targetCenterX = boundingBox.x + boundingBox.width / 2;
|
||||
const targetCenterY = boundingBox.y + boundingBox.height / 2;
|
||||
|
||||
const panX = (containerRect.width / 2) - (targetCenterX * viewport.zoom);
|
||||
const panY = (containerRect.height / 2) - (targetCenterY * viewport.zoom);
|
||||
|
||||
setViewport(prev => ({
|
||||
...prev,
|
||||
panX,
|
||||
panY,
|
||||
}));
|
||||
}, [boundingBox, viewport.zoom, imageLoaded]);
|
||||
|
||||
/**
|
||||
* Gère le zoom avec la molette
|
||||
*/
|
||||
const handleWheel = useCallback((event: React.WheelEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
const mouseX = event.clientX - rect.left;
|
||||
const mouseY = event.clientY - rect.top;
|
||||
|
||||
const zoomFactor = event.deltaY > 0 ? 0.9 : 1.1;
|
||||
const newZoom = Math.max(0.1, Math.min(5, viewport.zoom * zoomFactor));
|
||||
|
||||
// Ajuster le pan pour zoomer vers la position de la souris
|
||||
const zoomRatio = newZoom / viewport.zoom;
|
||||
const newPanX = mouseX - (mouseX - viewport.panX) * zoomRatio;
|
||||
const newPanY = mouseY - (mouseY - viewport.panY) * zoomRatio;
|
||||
|
||||
setViewport(prev => ({
|
||||
...prev,
|
||||
zoom: newZoom,
|
||||
panX: newPanX,
|
||||
panY: newPanY,
|
||||
}));
|
||||
}, [viewport]);
|
||||
|
||||
/**
|
||||
* Démarre le glisser-déposer
|
||||
*/
|
||||
const handleMouseDown = useCallback((event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
setViewport(prev => ({
|
||||
...prev,
|
||||
isDragging: true,
|
||||
dragStart: { x: event.clientX - prev.panX, y: event.clientY - prev.panY },
|
||||
}));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Gère le glisser-déposer
|
||||
*/
|
||||
const handleMouseMove = useCallback((event: React.MouseEvent) => {
|
||||
if (!viewport.isDragging) return;
|
||||
|
||||
const newPanX = event.clientX - viewport.dragStart.x;
|
||||
const newPanY = event.clientY - viewport.dragStart.y;
|
||||
|
||||
setViewport(prev => ({
|
||||
...prev,
|
||||
panX: newPanX,
|
||||
panY: newPanY,
|
||||
}));
|
||||
}, [viewport.isDragging, viewport.dragStart]);
|
||||
|
||||
/**
|
||||
* Termine le glisser-déposer
|
||||
*/
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setViewport(prev => ({
|
||||
...prev,
|
||||
isDragging: false,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Dessine le canvas avec l'image et les overlays
|
||||
*/
|
||||
const drawCanvas = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const image = imageRef.current;
|
||||
|
||||
if (!canvas || !image || !imageLoaded) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Ajuster la taille du canvas
|
||||
const container = containerRef.current;
|
||||
if (container) {
|
||||
canvas.width = container.clientWidth;
|
||||
canvas.height = container.clientHeight;
|
||||
}
|
||||
|
||||
// Effacer le canvas
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Dessiner l'image avec transformation
|
||||
ctx.save();
|
||||
ctx.translate(viewport.panX, viewport.panY);
|
||||
ctx.scale(viewport.zoom, viewport.zoom);
|
||||
ctx.drawImage(image, 0, 0);
|
||||
ctx.restore();
|
||||
|
||||
// Dessiner le contour animé de l'élément cible
|
||||
if (showTargetHighlight) {
|
||||
drawTargetHighlight(ctx);
|
||||
}
|
||||
|
||||
// Dessiner les annotations
|
||||
if (showAnnotations) {
|
||||
drawAnnotations(ctx);
|
||||
}
|
||||
}, [viewport, showTargetHighlight, showAnnotations, imageLoaded]);
|
||||
|
||||
/**
|
||||
* Dessine le contour animé de l'élément cible
|
||||
*/
|
||||
const drawTargetHighlight = useCallback((ctx: CanvasRenderingContext2D) => {
|
||||
const time = Date.now() * 0.003; // Animation lente
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(viewport.panX, viewport.panY);
|
||||
ctx.scale(viewport.zoom, viewport.zoom);
|
||||
|
||||
// Contour principal
|
||||
ctx.strokeStyle = '#4CAF50';
|
||||
ctx.lineWidth = 3 / viewport.zoom;
|
||||
ctx.setLineDash([10, 5]);
|
||||
ctx.lineDashOffset = time * 20;
|
||||
|
||||
ctx.strokeRect(
|
||||
boundingBox.x - 2,
|
||||
boundingBox.y - 2,
|
||||
boundingBox.width + 4,
|
||||
boundingBox.height + 4
|
||||
);
|
||||
|
||||
// Contour externe animé
|
||||
ctx.strokeStyle = `rgba(76, 175, 80, ${0.5 + 0.3 * Math.sin(time * 2)})`;
|
||||
ctx.lineWidth = 1 / viewport.zoom;
|
||||
ctx.setLineDash([]);
|
||||
|
||||
ctx.strokeRect(
|
||||
boundingBox.x - 5,
|
||||
boundingBox.y - 5,
|
||||
boundingBox.width + 10,
|
||||
boundingBox.height + 10
|
||||
);
|
||||
|
||||
// Points de coin
|
||||
const cornerSize = 8 / viewport.zoom;
|
||||
ctx.fillStyle = '#4CAF50';
|
||||
|
||||
const corners = [
|
||||
{ x: boundingBox.x, y: boundingBox.y },
|
||||
{ x: boundingBox.x + boundingBox.width, y: boundingBox.y },
|
||||
{ x: boundingBox.x, y: boundingBox.y + boundingBox.height },
|
||||
{ x: boundingBox.x + boundingBox.width, y: boundingBox.y + boundingBox.height },
|
||||
];
|
||||
|
||||
corners.forEach(corner => {
|
||||
ctx.fillRect(
|
||||
corner.x - cornerSize / 2,
|
||||
corner.y - cornerSize / 2,
|
||||
cornerSize,
|
||||
cornerSize
|
||||
);
|
||||
});
|
||||
|
||||
ctx.restore();
|
||||
}, [viewport, boundingBox]);
|
||||
|
||||
/**
|
||||
* Dessine les annotations
|
||||
*/
|
||||
const drawAnnotations = useCallback((ctx: CanvasRenderingContext2D) => {
|
||||
const annotations: AnnotationPoint[] = [
|
||||
{
|
||||
x: boundingBox.x + boundingBox.width / 2,
|
||||
y: boundingBox.y - 20,
|
||||
label: metadata?.element_type || 'Élément',
|
||||
type: 'target'
|
||||
}
|
||||
];
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(viewport.panX, viewport.panY);
|
||||
ctx.scale(viewport.zoom, viewport.zoom);
|
||||
|
||||
annotations.forEach(annotation => {
|
||||
// Bulle d'annotation
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.8)';
|
||||
ctx.strokeStyle = '#4CAF50';
|
||||
ctx.lineWidth = 1 / viewport.zoom;
|
||||
|
||||
const padding = 8 / viewport.zoom;
|
||||
const fontSize = 12 / viewport.zoom;
|
||||
ctx.font = `${fontSize}px Arial`;
|
||||
|
||||
const textWidth = ctx.measureText(annotation.label).width;
|
||||
const bubbleWidth = textWidth + padding * 2;
|
||||
const bubbleHeight = fontSize + padding * 2;
|
||||
|
||||
// Dessiner la bulle
|
||||
ctx.fillRect(
|
||||
annotation.x - bubbleWidth / 2,
|
||||
annotation.y - bubbleHeight,
|
||||
bubbleWidth,
|
||||
bubbleHeight
|
||||
);
|
||||
|
||||
ctx.strokeRect(
|
||||
annotation.x - bubbleWidth / 2,
|
||||
annotation.y - bubbleHeight,
|
||||
bubbleWidth,
|
||||
bubbleHeight
|
||||
);
|
||||
|
||||
// Dessiner le texte
|
||||
ctx.fillStyle = 'white';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(
|
||||
annotation.label,
|
||||
annotation.x,
|
||||
annotation.y - padding
|
||||
);
|
||||
});
|
||||
|
||||
ctx.restore();
|
||||
}, [viewport, boundingBox, metadata]);
|
||||
|
||||
/**
|
||||
* Contrôles de zoom
|
||||
*/
|
||||
const zoomIn = useCallback(() => {
|
||||
setViewport(prev => ({
|
||||
...prev,
|
||||
zoom: Math.min(5, prev.zoom * 1.2),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const zoomOut = useCallback(() => {
|
||||
setViewport(prev => ({
|
||||
...prev,
|
||||
zoom: Math.max(0.1, prev.zoom / 1.2),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const resetZoom = useCallback(() => {
|
||||
setViewport(prev => ({
|
||||
...prev,
|
||||
zoom: 1,
|
||||
panX: 0,
|
||||
panY: 0,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth={false}
|
||||
fullWidth
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: {
|
||||
width: '90vw',
|
||||
height: '90vh',
|
||||
maxWidth: 'none',
|
||||
maxHeight: 'none'
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTitle>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<TargetIcon />
|
||||
<Typography variant="h6">Aperçu Interactif</Typography>
|
||||
{metadata && (
|
||||
<Chip
|
||||
label={metadata.element_type}
|
||||
size="small"
|
||||
color="primary"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Tooltip title="Annotations">
|
||||
<IconButton
|
||||
onClick={() => setShowAnnotations(!showAnnotations)}
|
||||
color={showAnnotations ? 'primary' : 'default'}
|
||||
>
|
||||
<InfoIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Surbrillance cible">
|
||||
<IconButton
|
||||
onClick={() => setShowTargetHighlight(!showTargetHighlight)}
|
||||
color={showTargetHighlight ? 'primary' : 'default'}
|
||||
>
|
||||
<TargetIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<IconButton onClick={onClose}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent sx={{ p: 0, display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
{/* Barre d'outils */}
|
||||
<Paper sx={{ p: 1, display: 'flex', alignItems: 'center', gap: 2, borderRadius: 0 }}>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Tooltip title="Zoom avant">
|
||||
<IconButton onClick={zoomIn} size="small">
|
||||
<ZoomInIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Zoom arrière">
|
||||
<IconButton onClick={zoomOut} size="small">
|
||||
<ZoomOutIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Centrer sur l'élément">
|
||||
<IconButton onClick={centerOnTarget} size="small">
|
||||
<CenterIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Réinitialiser">
|
||||
<IconButton onClick={resetZoom} size="small">
|
||||
<FullscreenIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
<Divider orientation="vertical" flexItem />
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, minWidth: 200 }}>
|
||||
<Typography variant="body2">Zoom:</Typography>
|
||||
<Slider
|
||||
value={viewport.zoom}
|
||||
min={0.1}
|
||||
max={5}
|
||||
step={0.1}
|
||||
onChange={(_, value) => setViewport(prev => ({ ...prev, zoom: value as number }))}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<Typography variant="body2" sx={{ minWidth: 50 }}>
|
||||
{Math.round(viewport.zoom * 100)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Divider orientation="vertical" flexItem />
|
||||
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
Position: {Math.round(viewport.panX)}, {Math.round(viewport.panY)}
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
{/* Zone de visualisation */}
|
||||
<Box
|
||||
ref={containerRef}
|
||||
className="preview-container"
|
||||
sx={{
|
||||
flex: 1,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
cursor: viewport.isDragging ? 'grabbing' : 'grab',
|
||||
backgroundColor: '#f5f5f5'
|
||||
}}
|
||||
onWheel={handleWheel}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
/>
|
||||
|
||||
{!imageLoaded && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
textAlign: 'center'
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
Chargement de l'image...
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Informations sur l'élément */}
|
||||
{metadata && (
|
||||
<Paper sx={{ p: 2, borderRadius: 0 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Informations sur l'Élément
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 3, flexWrap: 'wrap' }}>
|
||||
<Box>
|
||||
<Typography variant="caption" color="textSecondary">Type:</Typography>
|
||||
<Typography variant="body2">{metadata.element_type}</Typography>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="caption" color="textSecondary">Position:</Typography>
|
||||
<Typography variant="body2">{metadata.relative_position}</Typography>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="caption" color="textSecondary">Taille:</Typography>
|
||||
<Typography variant="body2">
|
||||
{boundingBox.width} × {boundingBox.height}px
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{metadata.text_content && (
|
||||
<Box>
|
||||
<Typography variant="caption" color="textSecondary">Texte:</Typography>
|
||||
<Typography variant="body2">"{metadata.text_content}"</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default InteractivePreviewArea;
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Composant Raccourcis Clavier - Aide à l'accessibilité
|
||||
* Auteur : Dom, Alice, Kiro - 08 janvier 2026
|
||||
*
|
||||
* Ce composant affiche la liste des raccourcis clavier disponibles
|
||||
* pour améliorer l'accessibilité et l'efficacité d'utilisation.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Typography,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
Chip,
|
||||
Box,
|
||||
Divider,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Keyboard as KeyboardIcon,
|
||||
Close as CloseIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface KeyboardShortcutsProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
shortcuts?: Array<{
|
||||
keys: string;
|
||||
description: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// Raccourcis par défaut si aucun n'est fourni
|
||||
const defaultShortcuts = [
|
||||
// Navigation
|
||||
{ category: 'Navigation', keys: 'Tab', description: 'Naviguer vers l\'étape suivante' },
|
||||
{ category: 'Navigation', keys: 'Shift + Tab', description: 'Naviguer vers l\'étape précédente' },
|
||||
{ category: 'Navigation', keys: '↑ ↓ ← →', description: 'Déplacer l\'étape sélectionnée' },
|
||||
|
||||
// Édition
|
||||
{ category: 'Édition', keys: 'Suppr', description: 'Supprimer l\'étape sélectionnée' },
|
||||
{ category: 'Édition', keys: 'Ctrl + C', description: 'Copier l\'étape sélectionnée' },
|
||||
{ category: 'Édition', keys: 'Ctrl + V', description: 'Coller l\'étape copiée' },
|
||||
{ category: 'Édition', keys: 'Ctrl + Z', description: 'Annuler la dernière action' },
|
||||
{ category: 'Édition', keys: 'Ctrl + Y', description: 'Rétablir l\'action annulée' },
|
||||
|
||||
// Workflow
|
||||
{ category: 'Workflow', keys: 'Ctrl + S', description: 'Sauvegarder le workflow' },
|
||||
{ category: 'Workflow', keys: 'F5', description: 'Exécuter le workflow' },
|
||||
{ category: 'Workflow', keys: 'Ctrl + Entrée', description: 'Exécuter le workflow (alternative)' },
|
||||
{ category: 'Workflow', keys: 'Ctrl + A', description: 'Sélectionner toutes les étapes' },
|
||||
|
||||
// Affichage
|
||||
{ category: 'Affichage', keys: 'Ctrl + +', description: 'Zoomer' },
|
||||
{ category: 'Affichage', keys: 'Ctrl + -', description: 'Dézoomer' },
|
||||
{ category: 'Affichage', keys: 'Ctrl + 0', description: 'Ajuster le zoom' },
|
||||
|
||||
// Aide
|
||||
{ category: 'Aide', keys: 'Échap', description: 'Annuler l\'action en cours' },
|
||||
{ category: 'Aide', keys: 'F1', description: 'Afficher l\'aide' },
|
||||
{ category: 'Aide', keys: 'Shift + ?', description: 'Afficher les raccourcis clavier' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Composant Raccourcis Clavier
|
||||
*/
|
||||
const KeyboardShortcuts: React.FC<KeyboardShortcutsProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
shortcuts = defaultShortcuts,
|
||||
}) => {
|
||||
// Organiser les raccourcis par catégorie
|
||||
const shortcutsByCategory = shortcuts.reduce((acc, shortcut) => {
|
||||
const category = 'category' in shortcut ? (shortcut as any).category : 'Général';
|
||||
if (!acc[category]) {
|
||||
acc[category] = [];
|
||||
}
|
||||
acc[category].push(shortcut);
|
||||
return acc;
|
||||
}, {} as Record<string, typeof shortcuts>);
|
||||
|
||||
// Fonction pour formater les touches
|
||||
const formatKeys = (keys: string) => {
|
||||
return keys.split(' + ').map((key, index, array) => (
|
||||
<React.Fragment key={key}>
|
||||
<Chip
|
||||
label={key}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.75rem',
|
||||
height: 24,
|
||||
backgroundColor: '#f5f5f5',
|
||||
border: '1px solid #ccc',
|
||||
}}
|
||||
/>
|
||||
{index < array.length - 1 && (
|
||||
<Typography
|
||||
component="span"
|
||||
sx={{ mx: 0.5, color: 'text.secondary' }}
|
||||
>
|
||||
+
|
||||
</Typography>
|
||||
)}
|
||||
</React.Fragment>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
aria-labelledby="keyboard-shortcuts-title"
|
||||
>
|
||||
<DialogTitle id="keyboard-shortcuts-title">
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<KeyboardIcon />
|
||||
<Typography variant="h6">
|
||||
Raccourcis Clavier
|
||||
</Typography>
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent>
|
||||
<Typography variant="body2" color="text.secondary" paragraph>
|
||||
Utilisez ces raccourcis clavier pour naviguer et travailler plus efficacement
|
||||
dans le Visual Workflow Builder.
|
||||
</Typography>
|
||||
|
||||
{Object.entries(shortcutsByCategory).map(([category, categoryShortcuts], categoryIndex) => (
|
||||
<Box key={category} sx={{ mb: 3 }}>
|
||||
<Typography variant="h6" gutterBottom color="primary">
|
||||
{category}
|
||||
</Typography>
|
||||
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell sx={{ fontWeight: 'bold', width: '30%' }}>
|
||||
Raccourci
|
||||
</TableCell>
|
||||
<TableCell sx={{ fontWeight: 'bold' }}>
|
||||
Description
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{categoryShortcuts.map((shortcut, index) => (
|
||||
<TableRow key={index} hover>
|
||||
<TableCell>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
{formatKeys(shortcut.keys)}
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2">
|
||||
{shortcut.description}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{categoryIndex < Object.keys(shortcutsByCategory).length - 1 && (
|
||||
<Divider sx={{ mt: 2 }} />
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
|
||||
<Box sx={{ mt: 3, p: 2, backgroundColor: '#f8f9fa', borderRadius: 1 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
💡 Conseils d'accessibilité
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
• Les raccourcis fonctionnent uniquement quand aucun champ de saisie n'est actif<br />
|
||||
• Utilisez Tab pour naviguer entre les éléments de l'interface<br />
|
||||
• Appuyez sur Échap pour annuler une action en cours<br />
|
||||
• F1 ou Shift+? affiche cette aide à tout moment
|
||||
</Typography>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
startIcon={<CloseIcon />}
|
||||
variant="contained"
|
||||
autoFocus
|
||||
>
|
||||
Fermer
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default KeyboardShortcuts;
|
||||
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* Composant CatalogActionItem - Affichage spécialisé pour les actions du catalogue VisionOnly
|
||||
* Auteur : Dom, Alice, Kiro - 09 janvier 2026
|
||||
*
|
||||
* Ce composant gère l'affichage d'une action du catalogue dans la Palette VWB,
|
||||
* avec indicateurs visuels spécialisés, tooltips enrichis, et drag & drop optimisé.
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import {
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Tooltip,
|
||||
Box,
|
||||
Typography,
|
||||
Chip,
|
||||
Badge,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Visibility as VisionIcon,
|
||||
Speed as PerformanceIcon,
|
||||
CheckCircle as ValidatedIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
// Import des types
|
||||
import { StepTemplate } from '../../types';
|
||||
import { VWBCatalogAction } from '../../types/catalog';
|
||||
|
||||
interface CatalogActionItemProps {
|
||||
/** Template de l'étape (converti depuis l'action du catalogue) */
|
||||
stepTemplate: StepTemplate;
|
||||
/** Action originale du catalogue (pour métadonnées enrichies) */
|
||||
catalogAction?: VWBCatalogAction;
|
||||
/** Gestionnaire de début de drag */
|
||||
onDragStart: (event: React.DragEvent, stepTemplate: StepTemplate) => void;
|
||||
/** Indique si l'action est mise en évidence par la recherche */
|
||||
isHighlighted?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composant pour afficher une action du catalogue dans la Palette
|
||||
*/
|
||||
const CatalogActionItem: React.FC<CatalogActionItemProps> = ({
|
||||
stepTemplate,
|
||||
catalogAction,
|
||||
onDragStart,
|
||||
isHighlighted = false,
|
||||
}) => {
|
||||
// Déterminer les indicateurs de qualité
|
||||
const hasExamples = catalogAction?.examples && catalogAction.examples.length > 0;
|
||||
const hasDocumentation = catalogAction?.documentation && catalogAction.documentation.length > 0;
|
||||
const complexityLevel = catalogAction?.metadata?.complexity || 'simple';
|
||||
|
||||
// Couleurs selon la complexité
|
||||
const complexityColors = {
|
||||
simple: '#4caf50', // Vert
|
||||
intermediate: '#ff9800', // Orange
|
||||
advanced: '#f44336' // Rouge
|
||||
};
|
||||
|
||||
// Icônes selon la catégorie
|
||||
const getCategoryIcon = (category: string) => {
|
||||
switch (category) {
|
||||
case 'vision_ui': return '🖱️';
|
||||
case 'control': return '⏳';
|
||||
case 'data': return '📊';
|
||||
case 'navigation': return '🧭';
|
||||
case 'validation': return '✅';
|
||||
default: return '📋';
|
||||
}
|
||||
};
|
||||
|
||||
// Construire le contenu du tooltip enrichi
|
||||
const tooltipContent = (
|
||||
<Box sx={{ maxWidth: 300 }}>
|
||||
{/* En-tête avec nom et badges */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ flex: 1 }}>
|
||||
{stepTemplate.name}
|
||||
</Typography>
|
||||
<Chip
|
||||
label="VisionOnly"
|
||||
size="small"
|
||||
color="primary"
|
||||
sx={{ fontSize: '0.7em' }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Description */}
|
||||
<Typography variant="body2" sx={{ mb: 1.5 }}>
|
||||
{stepTemplate.description}
|
||||
</Typography>
|
||||
|
||||
{/* Paramètres requis */}
|
||||
{stepTemplate.requiredParameters.length > 0 && (
|
||||
<Box sx={{ mb: 1 }}>
|
||||
<Typography variant="caption" color="inherit" sx={{ fontWeight: 'bold' }}>
|
||||
Paramètres requis :
|
||||
</Typography>
|
||||
<Typography variant="caption" color="inherit" sx={{ display: 'block' }}>
|
||||
{stepTemplate.requiredParameters.join(', ')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Exemple d'utilisation */}
|
||||
{hasExamples && catalogAction?.examples[0] && (
|
||||
<Box sx={{ mb: 1 }}>
|
||||
<Typography variant="caption" color="inherit" sx={{ fontWeight: 'bold' }}>
|
||||
Exemple :
|
||||
</Typography>
|
||||
<Typography variant="caption" color="inherit" sx={{ display: 'block' }}>
|
||||
{catalogAction.examples[0].description}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Complexité */}
|
||||
{catalogAction?.metadata?.complexity && (
|
||||
<Box sx={{ mb: 1 }}>
|
||||
<Typography variant="caption" color="inherit" sx={{ fontWeight: 'bold' }}>
|
||||
Complexité :
|
||||
</Typography>
|
||||
<Chip
|
||||
label={complexityLevel}
|
||||
size="small"
|
||||
sx={{
|
||||
ml: 0.5,
|
||||
fontSize: '0.65em',
|
||||
height: 16,
|
||||
backgroundColor: complexityColors[complexityLevel],
|
||||
color: 'white'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Durée estimée */}
|
||||
{catalogAction?.metadata?.estimatedDuration && (
|
||||
<Box sx={{ mb: 1 }}>
|
||||
<Typography variant="caption" color="inherit">
|
||||
⏱️ Durée estimée : {Math.round(catalogAction.metadata.estimatedDuration / 1000)}s
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Indicateur de reconnaissance visuelle */}
|
||||
<Typography variant="caption" color="inherit" sx={{
|
||||
display: 'block',
|
||||
mt: 1,
|
||||
fontStyle: 'italic',
|
||||
backgroundColor: 'rgba(33, 150, 243, 0.1)',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '4px'
|
||||
}}>
|
||||
🎯 Action avec reconnaissance visuelle automatique
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
title={tooltipContent}
|
||||
placement="right"
|
||||
arrow
|
||||
enterDelay={300}
|
||||
leaveDelay={200}
|
||||
>
|
||||
<ListItem
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, stepTemplate)}
|
||||
sx={{
|
||||
cursor: 'grab',
|
||||
backgroundColor: isHighlighted ? '#e8f4fd' : '#f0f4ff',
|
||||
borderLeft: '3px solid #2196f3',
|
||||
borderRadius: '0 4px 4px 0',
|
||||
mb: 0.5,
|
||||
'&:hover': {
|
||||
backgroundColor: isHighlighted ? '#d1e7dd' : '#e3f2fd',
|
||||
transform: 'translateX(2px)',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
},
|
||||
'&:active': {
|
||||
cursor: 'grabbing',
|
||||
transform: 'translateX(1px) scale(0.98)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Icône principale avec badge de catégorie */}
|
||||
<ListItemIcon sx={{ minWidth: 40 }}>
|
||||
<Badge
|
||||
badgeContent={getCategoryIcon(catalogAction?.category || 'vision_ui')}
|
||||
sx={{
|
||||
'& .MuiBadge-badge': {
|
||||
fontSize: '0.6em',
|
||||
minWidth: 16,
|
||||
height: 16,
|
||||
backgroundColor: 'transparent',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ fontSize: '1.2em' }}>
|
||||
{stepTemplate.icon}
|
||||
</Typography>
|
||||
</Badge>
|
||||
</ListItemIcon>
|
||||
|
||||
{/* Contenu principal */}
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Typography variant="body2" noWrap sx={{ flex: 1, fontWeight: 500 }}>
|
||||
{stepTemplate.name}
|
||||
</Typography>
|
||||
|
||||
{/* Indicateurs de qualité */}
|
||||
<Box sx={{ display: 'flex', gap: 0.25 }}>
|
||||
{hasExamples && (
|
||||
<Tooltip title="Exemples disponibles">
|
||||
<ValidatedIcon sx={{ fontSize: 12, color: '#4caf50' }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{hasDocumentation && (
|
||||
<Tooltip title="Documentation complète">
|
||||
<VisionIcon sx={{ fontSize: 12, color: '#2196f3' }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{catalogAction?.metadata?.estimatedDuration && catalogAction.metadata.estimatedDuration < 2000 && (
|
||||
<Tooltip title="Exécution rapide">
|
||||
<PerformanceIcon sx={{ fontSize: 12, color: '#ff9800' }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Label VISION */}
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: '#2196f3',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '0.65em',
|
||||
backgroundColor: 'rgba(33, 150, 243, 0.1)',
|
||||
padding: '1px 4px',
|
||||
borderRadius: '2px'
|
||||
}}
|
||||
>
|
||||
VISION
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
secondary={
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
noWrap
|
||||
sx={{ fontSize: '0.7em' }}
|
||||
>
|
||||
{stepTemplate.description}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
// Mémorisation pour optimiser les performances
|
||||
export default memo(CatalogActionItem, (prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.stepTemplate.id === nextProps.stepTemplate.id &&
|
||||
prevProps.isHighlighted === nextProps.isHighlighted &&
|
||||
JSON.stringify(prevProps.catalogAction) === JSON.stringify(nextProps.catalogAction)
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,740 @@
|
||||
/**
|
||||
* Composant Palette d'Étapes Étendue - Boîte à outils avec catégories françaises et actions VisionOnly
|
||||
* Auteur : Dom, Alice, Kiro - 09 janvier 2026
|
||||
*
|
||||
* Ce composant affiche les types d'étapes disponibles organisés en catégories françaises,
|
||||
* avec recherche débouncée, tooltips explicatifs, support du drag-and-drop optimisé,
|
||||
* et intégration complète du catalogue d'actions VisionOnly.
|
||||
*
|
||||
* NOUVELLES FONCTIONNALITÉS:
|
||||
* - Intégration du catalogue d'actions VisionOnly
|
||||
* - Catégories dynamiques depuis l'API catalogue
|
||||
* - Actions Vision UI, Contrôle, Données du catalogue
|
||||
* - Recherche unifiée (actions par défaut + catalogue)
|
||||
* - Indicateurs de statut du service catalogue
|
||||
* - Tooltips enrichis avec exemples d'actions
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo, useCallback, memo, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
TextField,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Tooltip,
|
||||
InputAdornment,
|
||||
Chip,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Badge,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
Search as SearchIcon,
|
||||
CloudOff as OfflineIcon,
|
||||
CheckCircle as OnlineIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
// Import des types partagés
|
||||
import { PaletteProps, StepCategory, StepTemplate } from '../../types';
|
||||
|
||||
// Import des types du catalogue
|
||||
import {
|
||||
VWBCatalogAction,
|
||||
VWBActionCategory,
|
||||
VWBActionCategoryInfo
|
||||
} from '../../types/catalog';
|
||||
|
||||
// Import du service catalogue
|
||||
import { catalogService } from '../../services/catalogService';
|
||||
|
||||
// Import des hooks d'optimisation
|
||||
import { useDebounce } from '../../hooks/useDebounce';
|
||||
|
||||
// Import des tooltips
|
||||
import { getTooltip } from '../../utils/tooltips';
|
||||
|
||||
// Import du hook de gestion du catalogue
|
||||
import { useCatalogActions } from '../../hooks/useCatalogActions';
|
||||
|
||||
// Catégories par défaut désactivées - Mode 100% Visuel uniquement
|
||||
// Les actions legacy basées sur sélecteurs CSS ont été supprimées
|
||||
// Seules les actions VisionOnly du catalogue sont disponibles
|
||||
const getDefaultCategories = (): StepCategory[] => [];
|
||||
|
||||
// Mapping des catégories du catalogue vers les métadonnées d'affichage (100% visuel)
|
||||
const getCatalogCategoryMetadata = (categoryId: VWBActionCategory): {
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
} => {
|
||||
const metadata: Record<VWBActionCategory, { name: string; description: string; icon: string; color: string }> = {
|
||||
vision_ui: {
|
||||
name: 'Interactions Visuelles',
|
||||
description: 'Cliquer, saisir, glisser-déposer sur des éléments visuels',
|
||||
icon: '🖱️',
|
||||
color: '#2196f3',
|
||||
},
|
||||
control: {
|
||||
name: 'Contrôle de Flux',
|
||||
description: 'Conditions, boucles et synchronisation basées sur la vision',
|
||||
icon: '🔀',
|
||||
color: '#ff9800',
|
||||
},
|
||||
data: {
|
||||
name: 'Extraction de Données',
|
||||
description: 'Extraire texte, tableaux, télécharger des fichiers',
|
||||
icon: '📊',
|
||||
color: '#4caf50',
|
||||
},
|
||||
intelligence: {
|
||||
name: 'Intelligence IA',
|
||||
description: 'Analyse et traitement intelligent par IA',
|
||||
icon: '🤖',
|
||||
color: '#9c27b0',
|
||||
},
|
||||
database: {
|
||||
name: 'Base de Données',
|
||||
description: 'Lire et enregistrer des données en base',
|
||||
icon: '💾',
|
||||
color: '#00bcd4',
|
||||
},
|
||||
validation: {
|
||||
name: 'Validation',
|
||||
description: 'Vérifier la présence et le contenu des éléments',
|
||||
icon: '✅',
|
||||
color: '#f44336',
|
||||
},
|
||||
};
|
||||
|
||||
return metadata[categoryId] || {
|
||||
name: categoryId,
|
||||
description: `Actions de type ${categoryId}`,
|
||||
icon: '📋',
|
||||
color: '#757575',
|
||||
};
|
||||
};
|
||||
|
||||
// Convertir une action du catalogue en StepTemplate pour compatibilité
|
||||
const convertCatalogActionToStepTemplate = (action: VWBCatalogAction): StepTemplate => {
|
||||
// Extraire les paramètres requis
|
||||
const requiredParameters = Object.entries(action.parameters)
|
||||
.filter(([_, param]) => param.required)
|
||||
.map(([name, _]) => name);
|
||||
|
||||
// Extraire les paramètres par défaut
|
||||
const defaultParameters = Object.entries(action.parameters)
|
||||
.filter(([_, param]) => param.default !== undefined)
|
||||
.reduce((acc, [name, param]) => {
|
||||
acc[name] = param.default;
|
||||
return acc;
|
||||
}, {} as Record<string, any>);
|
||||
|
||||
return {
|
||||
id: action.id,
|
||||
type: action.id as any, // Utiliser l'ID comme type pour les actions du catalogue
|
||||
name: action.name,
|
||||
description: action.description,
|
||||
icon: action.icon,
|
||||
defaultParameters,
|
||||
requiredParameters,
|
||||
};
|
||||
};
|
||||
|
||||
// Interface pour l'état du catalogue
|
||||
interface CatalogState {
|
||||
actions: VWBCatalogAction[];
|
||||
categories: Array<{
|
||||
id: VWBActionCategory;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
actionCount: number;
|
||||
}>;
|
||||
isLoading: boolean;
|
||||
isOnline: boolean;
|
||||
error: string | null;
|
||||
lastUpdate: Date | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composant Palette pour la sélection d'étapes avec intégration du catalogue VisionOnly
|
||||
*/
|
||||
const Palette: React.FC<PaletteProps> = ({
|
||||
categories,
|
||||
searchTerm,
|
||||
onSearch,
|
||||
onStepDrag,
|
||||
}) => {
|
||||
// Utiliser les catégories par défaut si aucune n'est fournie
|
||||
const effectiveCategories = categories.length > 0 ? categories : getDefaultCategories();
|
||||
|
||||
const [expandedCategories, setExpandedCategories] = useState<string[]>([
|
||||
'actions-web',
|
||||
'vision_ui', // Étendre automatiquement les actions Vision UI
|
||||
]);
|
||||
|
||||
// Utiliser le hook de gestion du catalogue
|
||||
const {
|
||||
state: catalogHookState,
|
||||
filteredActions: catalogActions,
|
||||
actions: catalogActionMethods,
|
||||
} = useCatalogActions({
|
||||
autoLoad: true,
|
||||
refreshInterval: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
|
||||
// Utiliser l'état du hook comme état principal du catalogue
|
||||
const catalogState = useMemo(() => ({
|
||||
actions: catalogHookState.actions,
|
||||
categories: catalogHookState.categories.map(cat => ({
|
||||
id: cat.id,
|
||||
name: cat.name,
|
||||
description: cat.description,
|
||||
icon: cat.icon,
|
||||
actionCount: cat.actionCount,
|
||||
})),
|
||||
isLoading: catalogHookState.isLoading,
|
||||
isOnline: catalogHookState.isOnline,
|
||||
error: catalogHookState.error,
|
||||
lastUpdate: catalogHookState.lastUpdate,
|
||||
}), [catalogHookState]);
|
||||
|
||||
// Debouncing de la recherche pour optimiser les performances
|
||||
const debouncedSearchTerm = useDebounce(searchTerm, 300);
|
||||
|
||||
// Gestionnaire d'événements clavier pour la palette
|
||||
const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
|
||||
switch (event.key) {
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
// Activer l'élément focalisé
|
||||
event.preventDefault();
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
case 'ArrowDown':
|
||||
// Navigation verticale dans la liste
|
||||
event.preventDefault();
|
||||
break;
|
||||
case 'Escape':
|
||||
// Fermer les catégories étendues
|
||||
event.preventDefault();
|
||||
setExpandedCategories([]);
|
||||
break;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Convertir les actions du catalogue en catégories compatibles
|
||||
const catalogCategories = useMemo((): StepCategory[] => {
|
||||
if (catalogState.actions.length === 0) return [];
|
||||
|
||||
// Grouper les actions par catégorie
|
||||
const actionsByCategory = catalogState.actions.reduce((acc, action) => {
|
||||
if (!acc[action.category]) {
|
||||
acc[action.category] = [];
|
||||
}
|
||||
acc[action.category].push(action);
|
||||
return acc;
|
||||
}, {} as Record<VWBActionCategory, VWBCatalogAction[]>);
|
||||
|
||||
// Créer les catégories avec leurs actions
|
||||
return Object.entries(actionsByCategory).map(([categoryId, actions]) => {
|
||||
const metadata = getCatalogCategoryMetadata(categoryId as VWBActionCategory);
|
||||
|
||||
return {
|
||||
id: `catalog_${categoryId}`,
|
||||
name: metadata.name,
|
||||
description: metadata.description,
|
||||
icon: metadata.icon,
|
||||
steps: actions.map(convertCatalogActionToStepTemplate),
|
||||
};
|
||||
});
|
||||
}, [catalogState.actions]);
|
||||
|
||||
// Combiner les catégories par défaut et du catalogue
|
||||
const allCategories = useMemo(() => {
|
||||
return [...effectiveCategories, ...catalogCategories];
|
||||
}, [effectiveCategories, catalogCategories]);
|
||||
|
||||
// Filtrage des étapes selon le terme de recherche débouncé
|
||||
const filteredCategories = useMemo(() => {
|
||||
if (!debouncedSearchTerm.trim()) {
|
||||
return allCategories;
|
||||
}
|
||||
|
||||
const searchLower = debouncedSearchTerm.toLowerCase();
|
||||
|
||||
return allCategories
|
||||
.map((category) => ({
|
||||
...category,
|
||||
steps: category.steps.filter((step) =>
|
||||
step.name.toLowerCase().includes(searchLower) ||
|
||||
step.description.toLowerCase().includes(searchLower) ||
|
||||
step.type.toLowerCase().includes(searchLower)
|
||||
),
|
||||
}))
|
||||
.filter((category) => category.steps.length > 0);
|
||||
}, [allCategories, debouncedSearchTerm]);
|
||||
|
||||
// Gestionnaire d'expansion des catégories
|
||||
const handleCategoryToggle = (categoryId: string) => {
|
||||
setExpandedCategories((prev) =>
|
||||
prev.includes(categoryId)
|
||||
? prev.filter((id) => id !== categoryId)
|
||||
: [...prev, categoryId]
|
||||
);
|
||||
};
|
||||
|
||||
// Gestionnaire de début de drag
|
||||
const handleDragStart = (event: React.DragEvent, stepTemplate: StepTemplate) => {
|
||||
// Pour les actions du catalogue, utiliser un format spécial
|
||||
const dragData = stepTemplate.id.startsWith('catalog_')
|
||||
? `catalog:${stepTemplate.type}`
|
||||
: stepTemplate.type;
|
||||
|
||||
event.dataTransfer.setData('application/reactflow', dragData);
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
onStepDrag(stepTemplate);
|
||||
};
|
||||
|
||||
// Gestionnaire de rechargement du catalogue
|
||||
const handleReloadCatalog = useCallback(async () => {
|
||||
await catalogActionMethods.reload();
|
||||
}, [catalogActionMethods]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
width: 280,
|
||||
height: '100%',
|
||||
backgroundColor: '#ffffff',
|
||||
borderRight: '1px solid #e0e0e0',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
role="complementary"
|
||||
aria-label="Palette d'étapes disponibles avec actions VisionOnly"
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={0}
|
||||
>
|
||||
{/* En-tête avec titre et indicateur de statut */}
|
||||
<Box sx={{ p: 2, borderBottom: '1px solid #e0e0e0' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="h6" component="h2">
|
||||
Palette d'Étapes
|
||||
</Typography>
|
||||
|
||||
{/* Indicateur de statut du catalogue étendu */}
|
||||
<Tooltip
|
||||
title={
|
||||
<Box>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
{catalogHookState.mode === 'dynamic' ? '🌐 Mode Dynamique' :
|
||||
catalogHookState.mode === 'static' ? '📦 Mode Statique' :
|
||||
'🔴 Mode Hors Ligne'}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
{catalogHookState.mode === 'dynamic'
|
||||
? `Connecté au service catalogue - ${catalogState.actions.length} actions disponibles`
|
||||
: catalogHookState.mode === 'static'
|
||||
? `Catalogue de secours actif - ${catalogState.actions.length} actions de base`
|
||||
: 'Service catalogue indisponible'}
|
||||
</Typography>
|
||||
{catalogHookState.serviceUrl && (
|
||||
<Typography variant="caption" color="inherit" sx={{ display: 'block', mb: 0.5 }}>
|
||||
URL: {catalogHookState.serviceUrl}
|
||||
</Typography>
|
||||
)}
|
||||
{catalogHookState.error && (
|
||||
<Typography variant="caption" color="error" sx={{ display: 'block', mb: 0.5 }}>
|
||||
Erreur: {catalogHookState.error}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="caption" color="inherit" sx={{ fontStyle: 'italic' }}>
|
||||
{catalogHookState.mode === 'dynamic'
|
||||
? 'Cliquez pour forcer une re-détection'
|
||||
: 'Cliquez pour réessayer la connexion'}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
placement="left"
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
cursor: 'pointer',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '4px',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.04)',
|
||||
},
|
||||
}}
|
||||
onClick={async () => {
|
||||
if (catalogHookState.mode === 'dynamic') {
|
||||
await catalogActionMethods.forceUrlDetection();
|
||||
} else {
|
||||
await catalogActionMethods.reload();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{catalogState.isLoading ? (
|
||||
<CircularProgress size={16} />
|
||||
) : (
|
||||
<>
|
||||
{/* Icône selon le mode */}
|
||||
{catalogHookState.mode === 'dynamic' ? (
|
||||
<Badge badgeContent={catalogState.actions.length} color="primary" max={99}>
|
||||
<OnlineIcon color="success" fontSize="small" />
|
||||
</Badge>
|
||||
) : catalogHookState.mode === 'static' ? (
|
||||
<Badge badgeContent={catalogState.actions.length} color="warning" max={99}>
|
||||
<Box sx={{ fontSize: '16px' }}>📦</Box>
|
||||
</Badge>
|
||||
) : (
|
||||
<OfflineIcon color="disabled" fontSize="small" />
|
||||
)}
|
||||
|
||||
{/* Texte du mode */}
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontSize: '0.7em',
|
||||
fontWeight: 'bold',
|
||||
color: catalogHookState.mode === 'dynamic' ? 'success.main' :
|
||||
catalogHookState.mode === 'static' ? 'warning.main' :
|
||||
'text.disabled'
|
||||
}}
|
||||
>
|
||||
{catalogHookState.mode === 'dynamic' ? 'LIVE' :
|
||||
catalogHookState.mode === 'static' ? 'LOCAL' :
|
||||
'OFF'}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{/* Champ de recherche */}
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder="Rechercher une étape..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => onSearch(e.target.value)}
|
||||
aria-label="Rechercher dans les étapes disponibles"
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon />
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Statistiques du catalogue avec mode */}
|
||||
<Box sx={{ mt: 1, display: 'flex', gap: 0.5, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<Chip
|
||||
label={`${effectiveCategories.length} catégories par défaut`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="default"
|
||||
/>
|
||||
{catalogState.actions.length > 0 && (
|
||||
<Chip
|
||||
label={`${catalogState.actions.length} actions ${catalogHookState.mode === 'static' ? 'locales' : 'VisionOnly'}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color={catalogHookState.mode === 'dynamic' ? 'primary' : 'warning'}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Bouton de réinitialisation pour les cas problématiques */}
|
||||
{(catalogHookState.error || catalogHookState.mode === 'offline') && (
|
||||
<Tooltip title="Réinitialiser complètement le service catalogue">
|
||||
<Chip
|
||||
label="🔄 Reset"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="error"
|
||||
clickable
|
||||
onClick={async () => {
|
||||
await catalogActionMethods.resetService();
|
||||
}}
|
||||
sx={{ fontSize: '0.7em' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Message d'erreur du catalogue avec actions */}
|
||||
{catalogState.error && !catalogState.isLoading && (
|
||||
<Alert
|
||||
severity={catalogHookState.mode === 'static' ? 'info' : 'warning'}
|
||||
sx={{ mt: 1 }}
|
||||
action={
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ cursor: 'pointer', textDecoration: 'underline' }}
|
||||
onClick={handleReloadCatalog}
|
||||
>
|
||||
Recharger
|
||||
</Typography>
|
||||
{catalogHookState.mode !== 'static' && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ cursor: 'pointer', textDecoration: 'underline' }}
|
||||
onClick={async () => {
|
||||
await catalogActionMethods.forceUrlDetection();
|
||||
}}
|
||||
>
|
||||
Re-détecter
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
{catalogHookState.mode === 'static'
|
||||
? 'Mode local actif - Actions de base disponibles'
|
||||
: 'Service catalogue indisponible - Mode local activé'
|
||||
}
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Liste des catégories et étapes */}
|
||||
<Box sx={{ flex: 1, overflow: 'auto' }}>
|
||||
{filteredCategories.map((category) => {
|
||||
const isCatalogCategory = category.id.startsWith('catalog_');
|
||||
|
||||
return (
|
||||
<Accordion
|
||||
key={category.id}
|
||||
expanded={expandedCategories.includes(category.id)}
|
||||
onChange={() => handleCategoryToggle(category.id)}
|
||||
disableGutters
|
||||
elevation={0}
|
||||
sx={{
|
||||
'&:before': { display: 'none' },
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
backgroundColor: isCatalogCategory ? '#f8f9ff' : 'inherit',
|
||||
}}
|
||||
>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMoreIcon />}
|
||||
sx={{
|
||||
minHeight: 48,
|
||||
'& .MuiAccordionSummary-content': {
|
||||
alignItems: 'center',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tooltip
|
||||
title={
|
||||
<Box>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
{category.name}
|
||||
{isCatalogCategory && (
|
||||
<Chip
|
||||
label="VisionOnly"
|
||||
size="small"
|
||||
color="primary"
|
||||
sx={{ ml: 1, fontSize: '0.7em' }}
|
||||
/>
|
||||
)}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{category.description}
|
||||
</Typography>
|
||||
{getTooltip('category', category.id) && (
|
||||
<Typography variant="caption" color="inherit" sx={{ mt: 1, display: 'block' }}>
|
||||
Exemple : {getTooltip('category', category.id)?.example}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
placement="right"
|
||||
arrow
|
||||
enterDelay={700}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, width: '100%' }}>
|
||||
<Typography sx={{ fontSize: '1.2em' }}>{category.icon}</Typography>
|
||||
<Typography variant="subtitle2" sx={{ flex: 1 }}>
|
||||
{category.name}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
({category.steps.length})
|
||||
</Typography>
|
||||
{isCatalogCategory && (
|
||||
<Chip
|
||||
label="Vision"
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
sx={{ fontSize: '0.65em', height: 18 }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</AccordionSummary>
|
||||
|
||||
<AccordionDetails sx={{ p: 0 }}>
|
||||
<List dense>
|
||||
{category.steps.map((step) => {
|
||||
const isFromCatalog = category.id.startsWith('catalog_');
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
key={step.id}
|
||||
title={
|
||||
<Box>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
{step.name}
|
||||
{isFromCatalog && (
|
||||
<Chip
|
||||
label="VisionOnly"
|
||||
size="small"
|
||||
color="primary"
|
||||
sx={{ ml: 1, fontSize: '0.7em' }}
|
||||
/>
|
||||
)}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
{step.description}
|
||||
</Typography>
|
||||
{step.requiredParameters.length > 0 && (
|
||||
<Typography variant="caption" color="inherit" sx={{ display: 'block', mb: 0.5 }}>
|
||||
Paramètres requis : {step.requiredParameters.join(', ')}
|
||||
</Typography>
|
||||
)}
|
||||
{getTooltip('step', step.type) && (
|
||||
<>
|
||||
<Typography variant="caption" color="inherit">
|
||||
{getTooltip('step', step.type)?.example}
|
||||
</Typography>
|
||||
<br />
|
||||
<Typography variant="caption" color="inherit" fontStyle="italic">
|
||||
{getTooltip('step', step.type)?.shortcut}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
{isFromCatalog && (
|
||||
<Typography variant="caption" color="inherit" sx={{ display: 'block', mt: 1, fontStyle: 'italic' }}>
|
||||
🎯 Action avec reconnaissance visuelle automatique
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
placement="right"
|
||||
arrow
|
||||
enterDelay={500}
|
||||
leaveDelay={200}
|
||||
>
|
||||
<ListItem
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, step)}
|
||||
sx={{
|
||||
cursor: 'grab',
|
||||
backgroundColor: isFromCatalog ? '#f0f4ff' : 'inherit',
|
||||
'&:hover': {
|
||||
backgroundColor: isFromCatalog ? '#e3f2fd' : '#f5f5f5',
|
||||
},
|
||||
'&:active': {
|
||||
cursor: 'grabbing',
|
||||
},
|
||||
borderLeft: isFromCatalog ? '3px solid #2196f3' : 'none',
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 36 }}>
|
||||
<Typography sx={{ fontSize: '1.1em' }}>{step.icon}</Typography>
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Typography variant="body2" noWrap sx={{ flex: 1 }}>
|
||||
{step.name}
|
||||
</Typography>
|
||||
{isFromCatalog && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: '#2196f3',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '0.65em'
|
||||
}}
|
||||
>
|
||||
VISION
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
|
||||
{/* Message si aucun résultat */}
|
||||
{filteredCategories.length === 0 && !catalogState.isLoading && (
|
||||
<Box sx={{ p: 2, textAlign: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Aucune étape trouvée pour "{searchTerm}"
|
||||
</Typography>
|
||||
{catalogState.error && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
|
||||
Le catalogue VisionOnly est hors ligne
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Indicateur de chargement */}
|
||||
{catalogState.isLoading && (
|
||||
<Box sx={{ p: 2, textAlign: 'center' }}>
|
||||
<CircularProgress size={24} />
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
|
||||
Chargement du catalogue VisionOnly...
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Pied de page avec informations */}
|
||||
{catalogState.lastUpdate && (
|
||||
<Box sx={{ p: 1, borderTop: '1px solid #f0f0f0', backgroundColor: '#fafafa' }}>
|
||||
<Typography variant="caption" color="text.secondary" align="center" display="block">
|
||||
Dernière mise à jour : {catalogState.lastUpdate.toLocaleTimeString('fr-FR')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// Mémorisation du composant Palette pour éviter les re-rendus inutiles
|
||||
export default memo(Palette, (prevProps, nextProps) => {
|
||||
// Comparaison personnalisée pour optimiser les re-rendus
|
||||
return (
|
||||
JSON.stringify(prevProps.categories) === JSON.stringify(nextProps.categories) &&
|
||||
prevProps.searchTerm === nextProps.searchTerm &&
|
||||
prevProps.onSearch === nextProps.onSearch &&
|
||||
prevProps.onStepDrag === nextProps.onStepDrag
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,509 @@
|
||||
/**
|
||||
* Composant de Test Debug - Propriétés d'Étapes
|
||||
* Auteur : Dom, Alice, Kiro - 12 janvier 2026
|
||||
*
|
||||
* Ce composant teste et affiche les informations de débogage pour
|
||||
* diagnostiquer le problème des propriétés d'étapes vides.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Alert,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
Chip,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
BugReport as BugIcon,
|
||||
CheckCircle as CheckIcon,
|
||||
Error as ErrorIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
// Import des types
|
||||
import { Step, StepType, StepExecutionState } from '../types';
|
||||
|
||||
// Import des hooks VWB
|
||||
import { useVWBStepIntegration, useIsVWBStep, useVWBActionId } from '../hooks/useVWBStepIntegration';
|
||||
|
||||
// Configuration des paramètres (copie de PropertiesPanel)
|
||||
interface ParameterConfig {
|
||||
name: string;
|
||||
label: string;
|
||||
type: 'text' | 'number' | 'boolean' | 'select' | 'visual';
|
||||
required?: boolean;
|
||||
description?: string;
|
||||
supportVariables?: boolean;
|
||||
options?: { value: string; label: string }[];
|
||||
defaultValue?: any;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
const stepParametersConfig: Record<StepType, ParameterConfig[]> = {
|
||||
click: [
|
||||
{
|
||||
name: 'target',
|
||||
label: 'Élément cible',
|
||||
type: 'visual',
|
||||
required: true,
|
||||
description: 'Sélectionner l\'élément à cliquer',
|
||||
},
|
||||
{
|
||||
name: 'clickType',
|
||||
label: 'Type de clic',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'left', label: 'Clic gauche' },
|
||||
{ value: 'right', label: 'Clic droit' },
|
||||
{ value: 'double', label: 'Double-clic' },
|
||||
],
|
||||
defaultValue: 'left',
|
||||
},
|
||||
],
|
||||
type: [
|
||||
{
|
||||
name: 'target',
|
||||
label: 'Champ de saisie',
|
||||
type: 'visual',
|
||||
required: true,
|
||||
description: 'Sélectionner le champ où saisir le texte',
|
||||
},
|
||||
{
|
||||
name: 'text',
|
||||
label: 'Texte à saisir',
|
||||
type: 'text',
|
||||
required: true,
|
||||
supportVariables: true,
|
||||
},
|
||||
{
|
||||
name: 'clearFirst',
|
||||
label: 'Vider le champ d\'abord',
|
||||
type: 'boolean',
|
||||
defaultValue: true,
|
||||
},
|
||||
],
|
||||
wait: [
|
||||
{
|
||||
name: 'duration',
|
||||
label: 'Durée (secondes)',
|
||||
type: 'number',
|
||||
required: true,
|
||||
min: 0.1,
|
||||
max: 60,
|
||||
defaultValue: 1,
|
||||
},
|
||||
],
|
||||
condition: [
|
||||
{
|
||||
name: 'condition',
|
||||
label: 'Condition',
|
||||
type: 'text',
|
||||
required: true,
|
||||
supportVariables: true,
|
||||
description: 'Expression conditionnelle à évaluer',
|
||||
},
|
||||
],
|
||||
extract: [
|
||||
{
|
||||
name: 'target',
|
||||
label: 'Élément source',
|
||||
type: 'visual',
|
||||
required: true,
|
||||
description: 'Sélectionner l\'élément dont extraire les données',
|
||||
},
|
||||
{
|
||||
name: 'attribute',
|
||||
label: 'Attribut à extraire',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'text', label: 'Texte' },
|
||||
{ value: 'value', label: 'Valeur' },
|
||||
{ value: 'href', label: 'Lien (href)' },
|
||||
{ value: 'src', label: 'Source (src)' },
|
||||
],
|
||||
defaultValue: 'text',
|
||||
},
|
||||
],
|
||||
scroll: [
|
||||
{
|
||||
name: 'direction',
|
||||
label: 'Direction',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'up', label: 'Vers le haut' },
|
||||
{ value: 'down', label: 'Vers le bas' },
|
||||
{ value: 'left', label: 'Vers la gauche' },
|
||||
{ value: 'right', label: 'Vers la droite' },
|
||||
],
|
||||
defaultValue: 'down',
|
||||
},
|
||||
{
|
||||
name: 'amount',
|
||||
label: 'Quantité (pixels)',
|
||||
type: 'number',
|
||||
defaultValue: 300,
|
||||
min: 1,
|
||||
},
|
||||
],
|
||||
navigate: [
|
||||
{
|
||||
name: 'url',
|
||||
label: 'URL de destination',
|
||||
type: 'text',
|
||||
required: true,
|
||||
supportVariables: true,
|
||||
},
|
||||
],
|
||||
screenshot: [
|
||||
{
|
||||
name: 'filename',
|
||||
label: 'Nom du fichier',
|
||||
type: 'text',
|
||||
supportVariables: true,
|
||||
description: 'Nom du fichier de capture (optionnel)',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Composant de test pour déboguer les propriétés d'étapes
|
||||
*/
|
||||
const PropertiesDebugTest: React.FC = () => {
|
||||
const [selectedStepType, setSelectedStepType] = useState<StepType>('click');
|
||||
const [testResults, setTestResults] = useState<any[]>([]);
|
||||
|
||||
// Hooks VWB
|
||||
const { methods: vwbMethods } = useVWBStepIntegration();
|
||||
|
||||
// Créer une étape de test
|
||||
const createTestStep = useCallback((stepType: StepType): Step => {
|
||||
return {
|
||||
id: `test_step_${Date.now()}`,
|
||||
type: stepType,
|
||||
name: `Test ${stepType}`,
|
||||
position: { x: 100, y: 100 },
|
||||
data: {
|
||||
label: `Test ${stepType}`,
|
||||
stepType: stepType,
|
||||
parameters: {},
|
||||
},
|
||||
executionState: StepExecutionState.IDLE,
|
||||
validationErrors: [],
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Tester la résolution des paramètres
|
||||
const testParameterResolution = useCallback((stepType: StepType) => {
|
||||
console.log(`🧪 Test de résolution pour le type: ${stepType}`);
|
||||
|
||||
const testStep = createTestStep(stepType);
|
||||
|
||||
// Test 1: Configuration directe
|
||||
const directConfig = stepParametersConfig[stepType];
|
||||
|
||||
// Test 2: Fonction getParameterConfig simulée
|
||||
const getParameterConfig = (step: Step): ParameterConfig[] => {
|
||||
if (!step) return [];
|
||||
console.log(`🔍 Recherche config pour type: "${step.type}"`);
|
||||
console.log(`🔍 Clés disponibles:`, Object.keys(stepParametersConfig));
|
||||
console.log(`🔍 Type exact match:`, stepParametersConfig[step.type] !== undefined);
|
||||
return stepParametersConfig[step.type] || [];
|
||||
};
|
||||
|
||||
const resolvedConfig = getParameterConfig(testStep);
|
||||
|
||||
// Test 3: Hooks VWB
|
||||
const isVWBStep = useIsVWBStep(testStep);
|
||||
const vwbActionId = useVWBActionId(testStep);
|
||||
|
||||
const result = {
|
||||
stepType,
|
||||
testStep,
|
||||
directConfig: directConfig || null,
|
||||
directConfigLength: directConfig ? directConfig.length : 0,
|
||||
resolvedConfig,
|
||||
resolvedConfigLength: resolvedConfig.length,
|
||||
isVWBStep,
|
||||
vwbActionId,
|
||||
configExists: stepParametersConfig[stepType] !== undefined,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
console.log(`✅ Résultat test ${stepType}:`, result);
|
||||
|
||||
setTestResults(prev => [...prev, result]);
|
||||
|
||||
return result;
|
||||
}, [createTestStep]);
|
||||
|
||||
// Tester tous les types d'étapes
|
||||
const testAllStepTypes = useCallback(() => {
|
||||
console.log('🚀 Test de tous les types d\'étapes');
|
||||
setTestResults([]);
|
||||
|
||||
const allTypes: StepType[] = ['click', 'type', 'wait', 'condition', 'extract', 'scroll', 'navigate', 'screenshot'];
|
||||
|
||||
allTypes.forEach(stepType => {
|
||||
setTimeout(() => testParameterResolution(stepType), 100);
|
||||
});
|
||||
}, [testParameterResolution]);
|
||||
|
||||
// Analyser les résultats
|
||||
const analysisResults = useMemo(() => {
|
||||
if (testResults.length === 0) return null;
|
||||
|
||||
const totalTests = testResults.length;
|
||||
const successfulResolutions = testResults.filter(r => r.resolvedConfigLength > 0).length;
|
||||
const failedResolutions = testResults.filter(r => r.resolvedConfigLength === 0).length;
|
||||
const vwbSteps = testResults.filter(r => r.isVWBStep).length;
|
||||
|
||||
return {
|
||||
totalTests,
|
||||
successfulResolutions,
|
||||
failedResolutions,
|
||||
vwbSteps,
|
||||
successRate: (successfulResolutions / totalTests) * 100,
|
||||
};
|
||||
}, [testResults]);
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3, maxWidth: 1200, margin: '0 auto' }}>
|
||||
<Typography variant="h4" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<BugIcon color="primary" />
|
||||
Test Debug - Propriétés d'Étapes
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
Ce composant teste la résolution des propriétés d'étapes pour diagnostiquer
|
||||
pourquoi les paramètres apparaissent vides dans l'interface.
|
||||
</Typography>
|
||||
|
||||
{/* Contrôles de test */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Contrôles de Test
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 2 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={testAllStepTypes}
|
||||
startIcon={<BugIcon />}
|
||||
>
|
||||
Tester Tous les Types
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => setTestResults([])}
|
||||
>
|
||||
Vider les Résultats
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Cliquez sur "Tester Tous les Types" pour exécuter les tests de résolution
|
||||
des paramètres pour chaque type d'étape.
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Analyse des résultats */}
|
||||
{analysisResults && (
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{analysisResults.successRate === 100 ? (
|
||||
<CheckIcon color="success" />
|
||||
) : (
|
||||
<ErrorIcon color="error" />
|
||||
)}
|
||||
Analyse des Résultats
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', mb: 2 }}>
|
||||
<Chip
|
||||
label={`Total: ${analysisResults.totalTests}`}
|
||||
color="primary"
|
||||
/>
|
||||
<Chip
|
||||
label={`Succès: ${analysisResults.successfulResolutions}`}
|
||||
color="success"
|
||||
/>
|
||||
<Chip
|
||||
label={`Échecs: ${analysisResults.failedResolutions}`}
|
||||
color="error"
|
||||
/>
|
||||
<Chip
|
||||
label={`VWB: ${analysisResults.vwbSteps}`}
|
||||
color="info"
|
||||
/>
|
||||
<Chip
|
||||
label={`Taux: ${analysisResults.successRate.toFixed(1)}%`}
|
||||
color={analysisResults.successRate === 100 ? 'success' : 'warning'}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{analysisResults.failedResolutions > 0 && (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
<Typography variant="body2">
|
||||
{analysisResults.failedResolutions} type(s) d'étapes ne résolvent pas leurs paramètres correctement.
|
||||
Cela explique pourquoi les propriétés apparaissent vides dans l'interface.
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Configuration disponible */}
|
||||
<Accordion sx={{ mb: 3 }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="h6">
|
||||
Configuration stepParametersConfig Disponible
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Type d'Étape</TableCell>
|
||||
<TableCell>Nombre de Paramètres</TableCell>
|
||||
<TableCell>Paramètres</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{Object.entries(stepParametersConfig).map(([stepType, config]) => (
|
||||
<TableRow key={stepType}>
|
||||
<TableCell>
|
||||
<Chip label={stepType} size="small" />
|
||||
</TableCell>
|
||||
<TableCell>{config.length}</TableCell>
|
||||
<TableCell>
|
||||
{config.map(param => param.name).join(', ')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
{/* Résultats des tests */}
|
||||
{testResults.length > 0 && (
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="h6">
|
||||
Résultats Détaillés des Tests ({testResults.length})
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{testResults.map((result, index) => (
|
||||
<Card key={index} variant="outlined">
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6">
|
||||
Test: {result.stepType}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={result.resolvedConfigLength > 0 ? 'SUCCÈS' : 'ÉCHEC'}
|
||||
color={result.resolvedConfigLength > 0 ? 'success' : 'error'}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Configuration Directe
|
||||
</Typography>
|
||||
<Typography variant="body1">
|
||||
{result.directConfigLength} paramètres
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Configuration Résolue
|
||||
</Typography>
|
||||
<Typography variant="body1">
|
||||
{result.resolvedConfigLength} paramètres
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Est Action VWB
|
||||
</Typography>
|
||||
<Typography variant="body1">
|
||||
{result.isVWBStep ? 'Oui' : 'Non'}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Config Existe
|
||||
</Typography>
|
||||
<Typography variant="body1">
|
||||
{result.configExists ? 'Oui' : 'Non'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{result.resolvedConfigLength === 0 && result.configExists && (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
<Typography variant="body2">
|
||||
🚨 PROBLÈME IDENTIFIÉ: La configuration existe ({result.directConfigLength} paramètres)
|
||||
mais la résolution retourne 0 paramètres. Cela indique un problème dans la logique
|
||||
de résolution ou dans le mapping des types.
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Accordion sx={{ mt: 2 }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="body2">
|
||||
Détails Techniques
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box component="pre" sx={{
|
||||
fontSize: '0.75rem',
|
||||
bgcolor: '#f5f5f5',
|
||||
p: 1,
|
||||
borderRadius: 1,
|
||||
overflow: 'auto',
|
||||
}}>
|
||||
{JSON.stringify(result, null, 2)}
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default PropertiesDebugTest;
|
||||
@@ -0,0 +1,468 @@
|
||||
/**
|
||||
* Composant EmptyStateMessage - Messages informatifs pour états vides
|
||||
* Auteur : Dom, Alice, Kiro - 12 janvier 2026
|
||||
*
|
||||
* Ce composant affiche des messages informatifs et utiles lorsqu'aucune propriété
|
||||
* n'est disponible, avec distinction entre différents types d'états vides.
|
||||
*/
|
||||
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Alert,
|
||||
Button,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Chip,
|
||||
Paper,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Info as InfoIcon,
|
||||
Warning as WarningIcon,
|
||||
Error as ErrorIcon,
|
||||
HelpOutline as HelpIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Settings as SettingsIcon,
|
||||
Lightbulb as LightbulbIcon,
|
||||
Search as SearchIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
/**
|
||||
* Types de raisons pour l'état vide
|
||||
*/
|
||||
export type EmptyStateReason =
|
||||
| 'no-parameters'
|
||||
| 'loading-error'
|
||||
| 'vwb-not-found'
|
||||
| 'unknown-type'
|
||||
| 'resolution-failed'
|
||||
| 'catalog-unavailable'
|
||||
| 'network-error';
|
||||
|
||||
/**
|
||||
* Props du composant EmptyStateMessage
|
||||
*/
|
||||
export interface EmptyStateMessageProps {
|
||||
stepType: string;
|
||||
reason: EmptyStateReason;
|
||||
error?: Error;
|
||||
suggestions?: string[];
|
||||
onRetry?: () => void;
|
||||
onRefresh?: () => void;
|
||||
onHelp?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration des messages par type de raison
|
||||
*/
|
||||
interface EmptyStateConfig {
|
||||
severity: 'info' | 'warning' | 'error';
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
actionLabel?: string;
|
||||
showSuggestions: boolean;
|
||||
defaultSuggestions: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Configurations des états vides
|
||||
*/
|
||||
const EMPTY_STATE_CONFIGS: Record<EmptyStateReason, EmptyStateConfig> = {
|
||||
'no-parameters': {
|
||||
severity: 'info',
|
||||
icon: <InfoIcon />,
|
||||
title: 'Aucun paramètre configurable',
|
||||
description: 'Cette étape fonctionne sans paramètres supplémentaires.',
|
||||
showSuggestions: false,
|
||||
defaultSuggestions: []
|
||||
},
|
||||
|
||||
'loading-error': {
|
||||
severity: 'error',
|
||||
icon: <ErrorIcon />,
|
||||
title: 'Erreur de chargement',
|
||||
description: 'Impossible de charger les propriétés de cette étape.',
|
||||
actionLabel: 'Réessayer',
|
||||
showSuggestions: true,
|
||||
defaultSuggestions: [
|
||||
'Vérifiez votre connexion réseau',
|
||||
'Actualisez la page',
|
||||
'Contactez le support si le problème persiste'
|
||||
]
|
||||
},
|
||||
|
||||
'vwb-not-found': {
|
||||
severity: 'warning',
|
||||
icon: <WarningIcon />,
|
||||
title: 'Action VWB non trouvée',
|
||||
description: 'Cette action du catalogue Vision-Only RPA n\'est pas disponible.',
|
||||
actionLabel: 'Actualiser le catalogue',
|
||||
showSuggestions: true,
|
||||
defaultSuggestions: [
|
||||
'Utilisez une action standard équivalente',
|
||||
'Vérifiez que le catalogue VWB est à jour',
|
||||
'Contactez l\'administrateur pour ajouter cette action'
|
||||
]
|
||||
},
|
||||
|
||||
'unknown-type': {
|
||||
severity: 'warning',
|
||||
icon: <HelpIcon />,
|
||||
title: 'Type d\'étape non reconnu',
|
||||
description: 'Ce type d\'étape n\'est pas pris en charge par l\'éditeur.',
|
||||
showSuggestions: true,
|
||||
defaultSuggestions: [
|
||||
'Vérifiez l\'orthographe du type d\'étape',
|
||||
'Utilisez un type d\'étape standard',
|
||||
'Consultez la documentation des types supportés'
|
||||
]
|
||||
},
|
||||
|
||||
'resolution-failed': {
|
||||
severity: 'error',
|
||||
icon: <ErrorIcon />,
|
||||
title: 'Échec de résolution',
|
||||
description: 'Impossible de déterminer les propriétés de cette étape.',
|
||||
actionLabel: 'Réessayer',
|
||||
showSuggestions: true,
|
||||
defaultSuggestions: [
|
||||
'Vérifiez la configuration de l\'étape',
|
||||
'Redémarrez l\'éditeur de workflow',
|
||||
'Vérifiez les logs pour plus de détails'
|
||||
]
|
||||
},
|
||||
|
||||
'catalog-unavailable': {
|
||||
severity: 'warning',
|
||||
icon: <WarningIcon />,
|
||||
title: 'Catalogue indisponible',
|
||||
description: 'Le catalogue d\'actions n\'est pas accessible actuellement.',
|
||||
actionLabel: 'Actualiser',
|
||||
showSuggestions: true,
|
||||
defaultSuggestions: [
|
||||
'Vérifiez la connexion au serveur',
|
||||
'Utilisez des actions standard en attendant',
|
||||
'Contactez l\'administrateur système'
|
||||
]
|
||||
},
|
||||
|
||||
'network-error': {
|
||||
severity: 'error',
|
||||
icon: <ErrorIcon />,
|
||||
title: 'Erreur réseau',
|
||||
description: 'Impossible de se connecter au serveur pour charger les propriétés.',
|
||||
actionLabel: 'Réessayer',
|
||||
showSuggestions: true,
|
||||
defaultSuggestions: [
|
||||
'Vérifiez votre connexion internet',
|
||||
'Vérifiez que le serveur est accessible',
|
||||
'Réessayez dans quelques instants'
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Composant EmptyStateMessage
|
||||
*/
|
||||
const EmptyStateMessage: React.FC<EmptyStateMessageProps> = ({
|
||||
stepType,
|
||||
reason,
|
||||
error,
|
||||
suggestions = [],
|
||||
onRetry,
|
||||
onRefresh,
|
||||
onHelp,
|
||||
className
|
||||
}) => {
|
||||
|
||||
// Configuration pour la raison donnée
|
||||
const config = useMemo(() => {
|
||||
return EMPTY_STATE_CONFIGS[reason] || EMPTY_STATE_CONFIGS['unknown-type'];
|
||||
}, [reason]);
|
||||
|
||||
// Suggestions finales (personnalisées + par défaut)
|
||||
const finalSuggestions = useMemo(() => {
|
||||
const customSuggestions = suggestions.length > 0 ? suggestions : [];
|
||||
const defaultSuggestions = config.showSuggestions ? config.defaultSuggestions : [];
|
||||
return [...customSuggestions, ...defaultSuggestions];
|
||||
}, [suggestions, config]);
|
||||
|
||||
// Gestionnaire d'action principale
|
||||
const handlePrimaryAction = () => {
|
||||
if (reason === 'loading-error' || reason === 'resolution-failed' || reason === 'network-error') {
|
||||
onRetry?.();
|
||||
} else if (reason === 'vwb-not-found' || reason === 'catalog-unavailable') {
|
||||
onRefresh?.();
|
||||
}
|
||||
};
|
||||
|
||||
// Rendu des détails d'erreur
|
||||
const renderErrorDetails = () => {
|
||||
if (!error) return null;
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="caption" color="text.secondary" gutterBottom>
|
||||
Détails de l'erreur :
|
||||
</Typography>
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 1,
|
||||
backgroundColor: 'grey.50',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.75rem',
|
||||
maxHeight: 100,
|
||||
overflow: 'auto'
|
||||
}}
|
||||
>
|
||||
{error.message}
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// Rendu des suggestions
|
||||
const renderSuggestions = () => {
|
||||
if (finalSuggestions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<LightbulbIcon sx={{ fontSize: 16, mr: 0.5 }} />
|
||||
Suggestions
|
||||
</Typography>
|
||||
<List dense>
|
||||
{finalSuggestions.map((suggestion, index) => (
|
||||
<ListItem key={index} sx={{ py: 0.5 }}>
|
||||
<ListItemIcon sx={{ minWidth: 24 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'primary.main'
|
||||
}}
|
||||
/>
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={suggestion}
|
||||
primaryTypographyProps={{ variant: 'body2' }}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// Rendu des actions
|
||||
const renderActions = () => {
|
||||
const actions = [];
|
||||
|
||||
// Action principale
|
||||
if (config.actionLabel && (onRetry || onRefresh)) {
|
||||
actions.push(
|
||||
<Button
|
||||
key="primary"
|
||||
variant="contained"
|
||||
size="small"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={handlePrimaryAction}
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
{config.actionLabel}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// Action d'aide
|
||||
if (onHelp) {
|
||||
actions.push(
|
||||
<Button
|
||||
key="help"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={<HelpIcon />}
|
||||
onClick={onHelp}
|
||||
>
|
||||
Aide
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (actions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'center' }}>
|
||||
{actions}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// Logging pour le développement
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(`📭 [EmptyStateMessage] Affichage état vide:`, {
|
||||
stepType,
|
||||
reason,
|
||||
hasError: Boolean(error),
|
||||
suggestionsCount: finalSuggestions.length,
|
||||
hasActions: Boolean(config.actionLabel && (onRetry || onRefresh))
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
className={className}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
p: 3,
|
||||
textAlign: 'center',
|
||||
minHeight: 200
|
||||
}}
|
||||
role="status"
|
||||
aria-label={`État vide: ${config.title}`}
|
||||
>
|
||||
{/* En-tête avec icône et titre */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: `${config.severity}.light`,
|
||||
color: `${config.severity}.main`,
|
||||
mb: 1,
|
||||
mx: 'auto'
|
||||
}}
|
||||
>
|
||||
{config.icon}
|
||||
</Box>
|
||||
|
||||
<Typography variant="h6" component="h3" gutterBottom>
|
||||
{config.title}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{config.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Informations sur l'étape */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Chip
|
||||
label={`Type: ${stepType}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
icon={<SettingsIcon />}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Alert avec sévérité appropriée */}
|
||||
<Alert
|
||||
severity={config.severity}
|
||||
sx={{ mb: 2, width: '100%', maxWidth: 400 }}
|
||||
variant="outlined"
|
||||
>
|
||||
<Typography variant="body2">
|
||||
{reason === 'no-parameters'
|
||||
? 'Cette étape est prête à être utilisée sans configuration supplémentaire.'
|
||||
: 'Une intervention peut être nécessaire pour configurer cette étape.'
|
||||
}
|
||||
</Typography>
|
||||
</Alert>
|
||||
|
||||
{/* Détails d'erreur */}
|
||||
{renderErrorDetails()}
|
||||
|
||||
{/* Suggestions */}
|
||||
{renderSuggestions()}
|
||||
|
||||
{/* Actions */}
|
||||
{renderActions()}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook pour générer des suggestions contextuelles
|
||||
*/
|
||||
export function useEmptyStateSuggestions(
|
||||
stepType: string,
|
||||
reason: EmptyStateReason,
|
||||
error?: Error
|
||||
): string[] {
|
||||
|
||||
return useMemo(() => {
|
||||
const suggestions: string[] = [];
|
||||
|
||||
// Suggestions basées sur le type d'étape
|
||||
if (stepType.includes('click')) {
|
||||
suggestions.push('Essayez d\'utiliser l\'action "click" standard');
|
||||
} else if (stepType.includes('type')) {
|
||||
suggestions.push('Essayez d\'utiliser l\'action "type" standard');
|
||||
} else if (stepType.includes('wait')) {
|
||||
suggestions.push('Essayez d\'utiliser l\'action "wait" standard');
|
||||
}
|
||||
|
||||
// Suggestions basées sur l'erreur
|
||||
if (error) {
|
||||
if (error.message.includes('network')) {
|
||||
suggestions.push('Problème de réseau détecté - vérifiez la connectivité');
|
||||
} else if (error.message.includes('timeout')) {
|
||||
suggestions.push('Délai d\'attente dépassé - réessayez plus tard');
|
||||
} else if (error.message.includes('404')) {
|
||||
suggestions.push('Ressource non trouvée - vérifiez la configuration');
|
||||
}
|
||||
}
|
||||
|
||||
// Suggestions basées sur la raison
|
||||
switch (reason) {
|
||||
case 'vwb-not-found':
|
||||
suggestions.push(`Recherchez une alternative à "${stepType}" dans le catalogue`);
|
||||
break;
|
||||
case 'unknown-type':
|
||||
suggestions.push(`Vérifiez si "${stepType}" est correctement orthographié`);
|
||||
break;
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
}, [stepType, reason, error]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Composant EmptyStateMessage avec suggestions automatiques
|
||||
*/
|
||||
export const SmartEmptyStateMessage: React.FC<Omit<EmptyStateMessageProps, 'suggestions'>> = (props) => {
|
||||
const autoSuggestions = useEmptyStateSuggestions(props.stepType, props.reason, props.error);
|
||||
|
||||
return (
|
||||
<EmptyStateMessage
|
||||
{...props}
|
||||
suggestions={autoSuggestions}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Export du composant mémorisé
|
||||
*/
|
||||
export default memo(EmptyStateMessage, (prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.stepType === nextProps.stepType &&
|
||||
prevProps.reason === nextProps.reason &&
|
||||
prevProps.error?.message === nextProps.error?.message &&
|
||||
JSON.stringify(prevProps.suggestions) === JSON.stringify(nextProps.suggestions)
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,515 @@
|
||||
/**
|
||||
* Composant LoadingState - Indicateurs de chargement élégants et informatifs
|
||||
* Auteur : Dom, Alice, Kiro - 12 janvier 2026
|
||||
*
|
||||
* Ce composant affiche des indicateurs de chargement avec support de l'annulation,
|
||||
* messages informatifs et maintien de la réactivité de l'interface.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, memo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
CircularProgress,
|
||||
LinearProgress,
|
||||
Button,
|
||||
Fade,
|
||||
Skeleton,
|
||||
Alert,
|
||||
Chip,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Cancel as CancelIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Info as InfoIcon,
|
||||
Timer as TimerIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
/**
|
||||
* Types de chargement
|
||||
*/
|
||||
export type LoadingType =
|
||||
| 'resolving'
|
||||
| 'loading-vwb'
|
||||
| 'validating'
|
||||
| 'saving'
|
||||
| 'fetching-catalog'
|
||||
| 'processing'
|
||||
| 'generic';
|
||||
|
||||
/**
|
||||
* Props du composant LoadingState
|
||||
*/
|
||||
export interface LoadingStateProps {
|
||||
type?: LoadingType;
|
||||
message?: string;
|
||||
progress?: number;
|
||||
canCancel?: boolean;
|
||||
onCancel?: () => void;
|
||||
timeout?: number;
|
||||
showElapsedTime?: boolean;
|
||||
showSkeletons?: boolean;
|
||||
variant?: 'circular' | 'linear' | 'skeleton';
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration des messages par type de chargement
|
||||
*/
|
||||
interface LoadingConfig {
|
||||
defaultMessage: string;
|
||||
icon?: React.ReactNode;
|
||||
color: 'primary' | 'secondary' | 'info' | 'warning';
|
||||
estimatedDuration: number; // en millisecondes
|
||||
showProgress: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configurations des types de chargement
|
||||
*/
|
||||
const LOADING_CONFIGS: Record<LoadingType, LoadingConfig> = {
|
||||
'resolving': {
|
||||
defaultMessage: 'Résolution des propriétés d\'étape...',
|
||||
color: 'primary',
|
||||
estimatedDuration: 2000,
|
||||
showProgress: false
|
||||
},
|
||||
|
||||
'loading-vwb': {
|
||||
defaultMessage: 'Chargement de l\'action VWB...',
|
||||
color: 'info',
|
||||
estimatedDuration: 3000,
|
||||
showProgress: true
|
||||
},
|
||||
|
||||
'validating': {
|
||||
defaultMessage: 'Validation des paramètres...',
|
||||
color: 'secondary',
|
||||
estimatedDuration: 1500,
|
||||
showProgress: false
|
||||
},
|
||||
|
||||
'saving': {
|
||||
defaultMessage: 'Sauvegarde en cours...',
|
||||
color: 'primary',
|
||||
estimatedDuration: 2500,
|
||||
showProgress: true
|
||||
},
|
||||
|
||||
'fetching-catalog': {
|
||||
defaultMessage: 'Récupération du catalogue d\'actions...',
|
||||
color: 'info',
|
||||
estimatedDuration: 4000,
|
||||
showProgress: true
|
||||
},
|
||||
|
||||
'processing': {
|
||||
defaultMessage: 'Traitement en cours...',
|
||||
color: 'primary',
|
||||
estimatedDuration: 3000,
|
||||
showProgress: false
|
||||
},
|
||||
|
||||
'generic': {
|
||||
defaultMessage: 'Chargement...',
|
||||
color: 'primary',
|
||||
estimatedDuration: 2000,
|
||||
showProgress: false
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook pour le temps écoulé
|
||||
*/
|
||||
function useElapsedTime(isActive: boolean): number {
|
||||
const [elapsedTime, setElapsedTime] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) {
|
||||
setElapsedTime(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const interval = setInterval(() => {
|
||||
setElapsedTime(Date.now() - startTime);
|
||||
}, 100);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isActive]);
|
||||
|
||||
return elapsedTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook pour la progression estimée
|
||||
*/
|
||||
function useEstimatedProgress(
|
||||
type: LoadingType,
|
||||
elapsedTime: number,
|
||||
actualProgress?: number
|
||||
): number {
|
||||
|
||||
return React.useMemo(() => {
|
||||
if (actualProgress !== undefined) {
|
||||
return Math.min(100, Math.max(0, actualProgress));
|
||||
}
|
||||
|
||||
const config = LOADING_CONFIGS[type];
|
||||
const estimatedProgress = Math.min(95, (elapsedTime / config.estimatedDuration) * 100);
|
||||
|
||||
return estimatedProgress;
|
||||
}, [type, elapsedTime, actualProgress]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Composant LoadingState
|
||||
*/
|
||||
const LoadingState: React.FC<LoadingStateProps> = ({
|
||||
type = 'generic',
|
||||
message,
|
||||
progress,
|
||||
canCancel = false,
|
||||
onCancel,
|
||||
timeout,
|
||||
showElapsedTime = false,
|
||||
showSkeletons = false,
|
||||
variant = 'circular',
|
||||
size = 'medium',
|
||||
className
|
||||
}) => {
|
||||
|
||||
const [isTimedOut, setIsTimedOut] = useState(false);
|
||||
const [showTimeoutWarning, setShowTimeoutWarning] = useState(false);
|
||||
|
||||
// Configuration pour le type de chargement
|
||||
const config = LOADING_CONFIGS[type];
|
||||
const displayMessage = message || config.defaultMessage;
|
||||
|
||||
// Temps écoulé
|
||||
const elapsedTime = useElapsedTime(true);
|
||||
|
||||
// Progression estimée
|
||||
const estimatedProgress = useEstimatedProgress(type, elapsedTime, progress);
|
||||
|
||||
// Gestion du timeout
|
||||
useEffect(() => {
|
||||
if (!timeout) return;
|
||||
|
||||
const warningTimeout = setTimeout(() => {
|
||||
setShowTimeoutWarning(true);
|
||||
}, timeout * 0.8); // Avertissement à 80% du timeout
|
||||
|
||||
const finalTimeout = setTimeout(() => {
|
||||
setIsTimedOut(true);
|
||||
}, timeout);
|
||||
|
||||
return () => {
|
||||
clearTimeout(warningTimeout);
|
||||
clearTimeout(finalTimeout);
|
||||
};
|
||||
}, [timeout]);
|
||||
|
||||
// Gestionnaire d'annulation
|
||||
const handleCancel = useCallback(() => {
|
||||
if (onCancel) {
|
||||
onCancel();
|
||||
}
|
||||
}, [onCancel]);
|
||||
|
||||
// Formatage du temps écoulé
|
||||
const formatElapsedTime = (ms: number): string => {
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m ${seconds % 60}s`;
|
||||
}
|
||||
return `${seconds}s`;
|
||||
};
|
||||
|
||||
// Tailles des composants
|
||||
const getSizes = () => {
|
||||
switch (size) {
|
||||
case 'small':
|
||||
return { circularSize: 24, spacing: 1, typography: 'body2' as const };
|
||||
case 'large':
|
||||
return { circularSize: 48, spacing: 3, typography: 'h6' as const };
|
||||
default:
|
||||
return { circularSize: 32, spacing: 2, typography: 'body1' as const };
|
||||
}
|
||||
};
|
||||
|
||||
const sizes = getSizes();
|
||||
|
||||
// Rendu des squelettes
|
||||
const renderSkeletons = () => {
|
||||
if (!showSkeletons) return null;
|
||||
|
||||
return (
|
||||
<Box sx={{ width: '100%', mt: 2 }}>
|
||||
<Skeleton variant="text" width="80%" height={24} sx={{ mb: 1 }} />
|
||||
<Skeleton variant="text" width="60%" height={20} sx={{ mb: 1 }} />
|
||||
<Skeleton variant="rectangular" width="100%" height={40} sx={{ borderRadius: 1 }} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// Rendu de l'indicateur de progression
|
||||
const renderProgressIndicator = () => {
|
||||
switch (variant) {
|
||||
case 'linear':
|
||||
return (
|
||||
<LinearProgress
|
||||
variant={progress !== undefined ? 'determinate' : 'indeterminate'}
|
||||
value={estimatedProgress}
|
||||
color={config.color}
|
||||
sx={{ width: '100%', mt: 1 }}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'skeleton':
|
||||
return renderSkeletons();
|
||||
|
||||
default: // circular
|
||||
return (
|
||||
<CircularProgress
|
||||
size={sizes.circularSize}
|
||||
variant={progress !== undefined ? 'determinate' : 'indeterminate'}
|
||||
value={estimatedProgress}
|
||||
color={config.color}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Rendu des informations temporelles
|
||||
const renderTimeInfo = () => {
|
||||
if (!showElapsedTime && !showTimeoutWarning) return null;
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
{showElapsedTime && (
|
||||
<Chip
|
||||
icon={<TimerIcon />}
|
||||
label={formatElapsedTime(elapsedTime)}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ mr: 1 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showTimeoutWarning && !isTimedOut && (
|
||||
<Chip
|
||||
icon={<InfoIcon />}
|
||||
label="Opération longue"
|
||||
size="small"
|
||||
color="warning"
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// Rendu des actions
|
||||
const renderActions = () => {
|
||||
if (!canCancel && !isTimedOut) return null;
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 2, display: 'flex', gap: 1, justifyContent: 'center' }}>
|
||||
{canCancel && !isTimedOut && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={<CancelIcon />}
|
||||
onClick={handleCancel}
|
||||
color="secondary"
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isTimedOut && (
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={handleCancel}
|
||||
color="primary"
|
||||
>
|
||||
Réessayer
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// Logging pour le développement
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(`⏳ [LoadingState] État de chargement:`, {
|
||||
type,
|
||||
elapsedTime: formatElapsedTime(elapsedTime),
|
||||
progress: estimatedProgress.toFixed(1) + '%',
|
||||
isTimedOut,
|
||||
showTimeoutWarning
|
||||
});
|
||||
}
|
||||
|
||||
// Cas de timeout
|
||||
if (isTimedOut) {
|
||||
return (
|
||||
<Box
|
||||
className={className}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
p: sizes.spacing,
|
||||
textAlign: 'center',
|
||||
minHeight: 150
|
||||
}}
|
||||
role="status"
|
||||
aria-label="Opération expirée"
|
||||
>
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||
<Typography variant="body2">
|
||||
L'opération prend plus de temps que prévu
|
||||
</Typography>
|
||||
</Alert>
|
||||
|
||||
<Typography variant={sizes.typography} color="text.secondary" gutterBottom>
|
||||
{displayMessage}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Temps écoulé: {formatElapsedTime(elapsedTime)}
|
||||
</Typography>
|
||||
|
||||
{renderActions()}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Fade in timeout={300}>
|
||||
<Box
|
||||
className={className}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
p: sizes.spacing,
|
||||
textAlign: 'center',
|
||||
minHeight: variant === 'skeleton' ? 200 : 120
|
||||
}}
|
||||
role="status"
|
||||
aria-label={displayMessage}
|
||||
aria-live="polite"
|
||||
>
|
||||
{/* Indicateur de progression */}
|
||||
{renderProgressIndicator()}
|
||||
|
||||
{/* Message de chargement */}
|
||||
{variant !== 'skeleton' && (
|
||||
<Typography
|
||||
variant={sizes.typography}
|
||||
color="text.secondary"
|
||||
sx={{ mt: sizes.spacing }}
|
||||
>
|
||||
{displayMessage}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Progression numérique */}
|
||||
{progress !== undefined && variant !== 'skeleton' && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5 }}>
|
||||
{Math.round(estimatedProgress)}%
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Informations temporelles */}
|
||||
{renderTimeInfo()}
|
||||
|
||||
{/* Actions */}
|
||||
{renderActions()}
|
||||
</Box>
|
||||
</Fade>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Composant LoadingState spécialisé pour la résolution d'étapes
|
||||
*/
|
||||
export const StepResolutionLoading: React.FC<Omit<LoadingStateProps, 'type'>> = (props) => {
|
||||
return (
|
||||
<LoadingState
|
||||
{...props}
|
||||
type="resolving"
|
||||
variant="circular"
|
||||
size="small"
|
||||
showElapsedTime={process.env.NODE_ENV === 'development'}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Composant LoadingState spécialisé pour le chargement VWB
|
||||
*/
|
||||
export const VWBActionLoading: React.FC<Omit<LoadingStateProps, 'type'>> = (props) => {
|
||||
return (
|
||||
<LoadingState
|
||||
{...props}
|
||||
type="loading-vwb"
|
||||
variant="linear"
|
||||
showElapsedTime
|
||||
canCancel
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Composant LoadingState spécialisé pour la sauvegarde
|
||||
*/
|
||||
export const SavingLoading: React.FC<Omit<LoadingStateProps, 'type'>> = (props) => {
|
||||
return (
|
||||
<LoadingState
|
||||
{...props}
|
||||
type="saving"
|
||||
variant="linear"
|
||||
size="small"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Composant LoadingState avec squelettes pour les paramètres
|
||||
*/
|
||||
export const ParametersSkeletonLoading: React.FC<Omit<LoadingStateProps, 'type' | 'variant'>> = (props) => {
|
||||
return (
|
||||
<LoadingState
|
||||
{...props}
|
||||
type="resolving"
|
||||
variant="skeleton"
|
||||
showSkeletons
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Export du composant mémorisé
|
||||
*/
|
||||
export default memo(LoadingState, (prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.type === nextProps.type &&
|
||||
prevProps.message === nextProps.message &&
|
||||
prevProps.progress === nextProps.progress &&
|
||||
prevProps.canCancel === nextProps.canCancel &&
|
||||
prevProps.timeout === nextProps.timeout &&
|
||||
prevProps.variant === nextProps.variant &&
|
||||
prevProps.size === nextProps.size
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,565 @@
|
||||
/**
|
||||
* Composant ParameterFieldRenderer - Rendu unifié des champs de paramètres
|
||||
* Auteur : Dom, Alice, Kiro - 12 janvier 2026
|
||||
*
|
||||
* Ce composant fournit un système de rendu unifié pour tous les types de champs
|
||||
* de paramètres d'étapes, avec support de l'extensibilité et validation intégrée.
|
||||
*/
|
||||
|
||||
import React, { memo, useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
TextField,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
Switch,
|
||||
FormControlLabel,
|
||||
Button,
|
||||
Typography,
|
||||
Chip,
|
||||
Alert,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Visibility as VisibilityIcon,
|
||||
Error as ErrorIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
// Import des composants spécialisés
|
||||
import VariableAutocomplete from '../VariableAutocomplete';
|
||||
import RealScreenCapture from '../RealScreenCapture';
|
||||
|
||||
// Import des types
|
||||
import { ParameterConfig } from '../../services/StepTypeResolver';
|
||||
import { Variable, ValidationError, VisualSelection } from '../../types';
|
||||
|
||||
/**
|
||||
* Props du ParameterFieldRenderer
|
||||
*/
|
||||
export interface ParameterFieldRendererProps {
|
||||
config: ParameterConfig;
|
||||
value: any;
|
||||
variables: Variable[];
|
||||
error?: ValidationError;
|
||||
onChange: (value: any) => void;
|
||||
onVisualSelection?: () => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props des renderers spécialisés
|
||||
*/
|
||||
interface BaseFieldRendererProps {
|
||||
config: ParameterConfig;
|
||||
value: any;
|
||||
error?: ValidationError;
|
||||
onChange: (value: any) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface TextFieldRendererProps extends BaseFieldRendererProps {
|
||||
variables: Variable[];
|
||||
}
|
||||
|
||||
interface SelectFieldRendererProps extends BaseFieldRendererProps {}
|
||||
|
||||
interface NumberFieldRendererProps extends BaseFieldRendererProps {}
|
||||
|
||||
interface BooleanFieldRendererProps extends BaseFieldRendererProps {}
|
||||
|
||||
interface VisualFieldRendererProps extends BaseFieldRendererProps {
|
||||
onVisualSelection?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renderer pour les champs de texte
|
||||
*/
|
||||
const TextFieldRenderer: React.FC<TextFieldRendererProps> = memo(({
|
||||
config,
|
||||
value,
|
||||
variables,
|
||||
error,
|
||||
onChange,
|
||||
disabled = false
|
||||
}) => {
|
||||
const hasError = Boolean(error);
|
||||
|
||||
// Utiliser l'autocomplétion des variables si supporté
|
||||
if (config.supportVariables) {
|
||||
return (
|
||||
<VariableAutocomplete
|
||||
label={config.label}
|
||||
value={value || ''}
|
||||
onChange={onChange}
|
||||
variables={variables}
|
||||
error={hasError}
|
||||
helperText={hasError ? error!.message : config.description}
|
||||
required={config.required}
|
||||
disabled={disabled}
|
||||
placeholder={config.placeholder}
|
||||
multiline={config.multiline}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Champ de texte standard
|
||||
return (
|
||||
<TextField
|
||||
fullWidth
|
||||
label={config.label}
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
error={hasError}
|
||||
helperText={hasError ? error!.message : config.description}
|
||||
required={config.required}
|
||||
disabled={disabled}
|
||||
placeholder={config.placeholder}
|
||||
multiline={config.multiline}
|
||||
rows={config.multiline ? 3 : 1}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
TextFieldRenderer.displayName = 'TextFieldRenderer';
|
||||
|
||||
/**
|
||||
* Renderer pour les champs numériques
|
||||
*/
|
||||
const NumberFieldRenderer: React.FC<NumberFieldRendererProps> = memo(({
|
||||
config,
|
||||
value,
|
||||
error,
|
||||
onChange,
|
||||
disabled = false
|
||||
}) => {
|
||||
const hasError = Boolean(error);
|
||||
|
||||
const handleChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const numValue = Number(event.target.value);
|
||||
onChange(isNaN(numValue) ? '' : numValue);
|
||||
}, [onChange]);
|
||||
|
||||
return (
|
||||
<TextField
|
||||
fullWidth
|
||||
type="number"
|
||||
label={config.label}
|
||||
value={value ?? ''}
|
||||
onChange={handleChange}
|
||||
error={hasError}
|
||||
helperText={hasError ? error!.message : config.description}
|
||||
required={config.required}
|
||||
disabled={disabled}
|
||||
placeholder={config.placeholder}
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
min: config.min,
|
||||
max: config.max,
|
||||
step: config.step || 0.1,
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
NumberFieldRenderer.displayName = 'NumberFieldRenderer';
|
||||
|
||||
/**
|
||||
* Renderer pour les champs booléens (switch)
|
||||
*/
|
||||
const BooleanFieldRenderer: React.FC<BooleanFieldRendererProps> = memo(({
|
||||
config,
|
||||
value,
|
||||
error,
|
||||
onChange,
|
||||
disabled = false
|
||||
}) => {
|
||||
const hasError = Boolean(error);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={Boolean(value)}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
disabled={disabled}
|
||||
color={hasError ? 'error' : 'primary'}
|
||||
/>
|
||||
}
|
||||
label={config.label}
|
||||
/>
|
||||
{config.description && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
|
||||
{config.description}
|
||||
</Typography>
|
||||
)}
|
||||
{hasError && (
|
||||
<Typography variant="caption" color="error" sx={{ display: 'block', mt: 0.5 }}>
|
||||
<ErrorIcon sx={{ fontSize: 14, mr: 0.5, verticalAlign: 'middle' }} />
|
||||
{error!.message}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
BooleanFieldRenderer.displayName = 'BooleanFieldRenderer';
|
||||
|
||||
/**
|
||||
* Renderer pour les champs de sélection
|
||||
*/
|
||||
const SelectFieldRenderer: React.FC<SelectFieldRendererProps> = memo(({
|
||||
config,
|
||||
value,
|
||||
error,
|
||||
onChange,
|
||||
disabled = false
|
||||
}) => {
|
||||
const hasError = Boolean(error);
|
||||
|
||||
return (
|
||||
<FormControl fullWidth error={hasError} disabled={disabled}>
|
||||
<InputLabel required={config.required}>{config.label}</InputLabel>
|
||||
<Select
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
label={config.label}
|
||||
>
|
||||
{config.options?.map((option) => (
|
||||
<MenuItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
{(hasError || config.description) && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color={hasError ? 'error' : 'text.secondary'}
|
||||
sx={{ mt: 0.5, display: 'block' }}
|
||||
>
|
||||
{hasError ? error!.message : config.description}
|
||||
</Typography>
|
||||
)}
|
||||
</FormControl>
|
||||
);
|
||||
});
|
||||
|
||||
SelectFieldRenderer.displayName = 'SelectFieldRenderer';
|
||||
|
||||
/**
|
||||
* Renderer pour les champs de sélection visuelle
|
||||
*/
|
||||
const VisualFieldRenderer: React.FC<VisualFieldRendererProps> = memo(({
|
||||
config,
|
||||
value,
|
||||
error,
|
||||
onChange,
|
||||
onVisualSelection,
|
||||
disabled = false
|
||||
}) => {
|
||||
const hasError = Boolean(error);
|
||||
const hasSelection = Boolean(value);
|
||||
const [isScreenCaptureOpen, setIsScreenCaptureOpen] = useState(false);
|
||||
|
||||
const handleVisualSelection = useCallback(() => {
|
||||
if (!disabled) {
|
||||
setIsScreenCaptureOpen(true);
|
||||
}
|
||||
}, [disabled]);
|
||||
|
||||
const handleElementSelected = useCallback((selection: VisualSelection) => {
|
||||
onChange(selection);
|
||||
setIsScreenCaptureOpen(false);
|
||||
|
||||
// Notifier le parent si nécessaire
|
||||
if (onVisualSelection) {
|
||||
onVisualSelection();
|
||||
}
|
||||
}, [onChange, onVisualSelection]);
|
||||
|
||||
const handleClearSelection = useCallback(() => {
|
||||
onChange(null);
|
||||
}, [onChange]);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
startIcon={<VisibilityIcon />}
|
||||
onClick={handleVisualSelection}
|
||||
color={hasError ? 'error' : hasSelection ? 'success' : 'primary'}
|
||||
disabled={disabled}
|
||||
sx={{ mb: 1 }}
|
||||
>
|
||||
{hasSelection ? 'Modifier la sélection' : config.label}
|
||||
</Button>
|
||||
|
||||
{hasSelection && (
|
||||
<Box sx={{ mb: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<Chip
|
||||
label="Élément sélectionné"
|
||||
size="small"
|
||||
color="success"
|
||||
icon={<CheckCircleIcon />}
|
||||
onDelete={disabled ? undefined : handleClearSelection}
|
||||
/>
|
||||
{value?.boundingBox && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
({Math.round(value.boundingBox.x)}px, {Math.round(value.boundingBox.y)}px) -
|
||||
{Math.round(value.boundingBox.width)}×{Math.round(value.boundingBox.height)}px
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
{/* Aperçu de la sélection visuelle */}
|
||||
{value?.screenshot && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
maxHeight: 150,
|
||||
overflow: 'hidden',
|
||||
borderRadius: 1,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={value.screenshot}
|
||||
alt="Aperçu de la sélection"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
maxHeight: 150,
|
||||
objectFit: 'contain',
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
{/* Indicateur de la zone sélectionnée */}
|
||||
{value.boundingBox && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: `${(value.boundingBox.x / 1920) * 100}%`,
|
||||
top: `${(value.boundingBox.y / 1080) * 100}%`,
|
||||
width: `${Math.max((value.boundingBox.width / 1920) * 100, 2)}%`,
|
||||
height: `${Math.max((value.boundingBox.height / 1080) * 100, 2)}%`,
|
||||
border: '2px solid #4caf50',
|
||||
backgroundColor: 'rgba(76, 175, 80, 0.2)',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{config.description && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
|
||||
{config.description}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{hasError && (
|
||||
<Alert severity="error" sx={{ mt: 1 }}>
|
||||
{error!.message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Composant de capture d'écran */}
|
||||
<RealScreenCapture
|
||||
isOpen={isScreenCaptureOpen}
|
||||
onClose={() => setIsScreenCaptureOpen(false)}
|
||||
onElementSelected={handleElementSelected}
|
||||
stepId={`field_${config.name}`}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
VisualFieldRenderer.displayName = 'VisualFieldRenderer';
|
||||
|
||||
/**
|
||||
* Map des renderers par type de champ
|
||||
*/
|
||||
const FIELD_RENDERERS = {
|
||||
text: TextFieldRenderer,
|
||||
number: NumberFieldRenderer,
|
||||
boolean: BooleanFieldRenderer,
|
||||
select: SelectFieldRenderer,
|
||||
visual: VisualFieldRenderer,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Type pour les renderers de champs
|
||||
*/
|
||||
export type FieldRendererType = keyof typeof FIELD_RENDERERS;
|
||||
|
||||
/**
|
||||
* Interface pour l'enregistrement de renderers personnalisés
|
||||
*/
|
||||
export interface CustomFieldRenderer {
|
||||
type: string;
|
||||
component: React.ComponentType<any>;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registre des renderers personnalisés
|
||||
*/
|
||||
class FieldRendererRegistry {
|
||||
private customRenderers = new Map<string, CustomFieldRenderer>();
|
||||
|
||||
/**
|
||||
* Enregistre un renderer personnalisé
|
||||
*/
|
||||
register(renderer: CustomFieldRenderer): void {
|
||||
this.customRenderers.set(renderer.type, renderer);
|
||||
console.log(`📝 [ParameterFieldRenderer] Renderer personnalisé enregistré: ${renderer.type}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtient un renderer par type
|
||||
*/
|
||||
getRenderer(type: string): React.ComponentType<any> | null {
|
||||
// Vérifier d'abord les renderers personnalisés
|
||||
const customRenderer = this.customRenderers.get(type);
|
||||
if (customRenderer) {
|
||||
return customRenderer.component;
|
||||
}
|
||||
|
||||
// Vérifier les renderers standard
|
||||
if (type in FIELD_RENDERERS) {
|
||||
return FIELD_RENDERERS[type as FieldRendererType];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste tous les types de renderers disponibles
|
||||
*/
|
||||
getAvailableTypes(): string[] {
|
||||
const standardTypes = Object.keys(FIELD_RENDERERS);
|
||||
const customTypes = Array.from(this.customRenderers.keys());
|
||||
return [...standardTypes, ...customTypes];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Instance singleton du registre
|
||||
*/
|
||||
export const fieldRendererRegistry = new FieldRendererRegistry();
|
||||
|
||||
/**
|
||||
* Composant principal ParameterFieldRenderer
|
||||
*/
|
||||
const ParameterFieldRenderer: React.FC<ParameterFieldRendererProps> = ({
|
||||
config,
|
||||
value,
|
||||
variables,
|
||||
error,
|
||||
onChange,
|
||||
onVisualSelection,
|
||||
disabled = false,
|
||||
className
|
||||
}) => {
|
||||
// Obtenir le renderer approprié
|
||||
const RendererComponent = useMemo(() => {
|
||||
const renderer = fieldRendererRegistry.getRenderer(config.type);
|
||||
|
||||
if (!renderer) {
|
||||
console.warn(`⚠️ [ParameterFieldRenderer] Renderer non trouvé pour le type: ${config.type}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return renderer;
|
||||
}, [config.type]);
|
||||
|
||||
// Props communes pour tous les renderers
|
||||
const commonProps = useMemo(() => ({
|
||||
config,
|
||||
value,
|
||||
error,
|
||||
onChange,
|
||||
disabled
|
||||
}), [config, value, error, onChange, disabled]);
|
||||
|
||||
// Props spécifiques selon le type
|
||||
const specificProps = useMemo(() => {
|
||||
switch (config.type) {
|
||||
case 'text':
|
||||
return { variables };
|
||||
case 'visual':
|
||||
return { onVisualSelection };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}, [config.type, variables, onVisualSelection]);
|
||||
|
||||
// Logging pour le développement
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(`🎨 [ParameterFieldRenderer] Rendu du champ:`, {
|
||||
name: config.name,
|
||||
type: config.type,
|
||||
hasValue: value !== undefined && value !== null && value !== '',
|
||||
hasError: Boolean(error),
|
||||
disabled
|
||||
});
|
||||
}
|
||||
|
||||
if (!RendererComponent) {
|
||||
return (
|
||||
<Alert severity="error" className={className}>
|
||||
<Typography variant="body2">
|
||||
Type de champ non supporté: <code>{config.type}</code>
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Types disponibles: {fieldRendererRegistry.getAvailableTypes().join(', ')}
|
||||
</Typography>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className={className} data-field-type={config.type} data-field-name={config.name}>
|
||||
<RendererComponent
|
||||
{...commonProps}
|
||||
{...specificProps}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Export du composant mémorisé
|
||||
*/
|
||||
export default memo(ParameterFieldRenderer, (prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.config.name === nextProps.config.name &&
|
||||
prevProps.config.type === nextProps.config.type &&
|
||||
prevProps.value === nextProps.value &&
|
||||
prevProps.error?.message === nextProps.error?.message &&
|
||||
prevProps.disabled === nextProps.disabled &&
|
||||
JSON.stringify(prevProps.variables) === JSON.stringify(nextProps.variables)
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Exports utilitaires
|
||||
*/
|
||||
export {
|
||||
TextFieldRenderer,
|
||||
NumberFieldRenderer,
|
||||
BooleanFieldRenderer,
|
||||
SelectFieldRenderer,
|
||||
VisualFieldRenderer,
|
||||
FIELD_RENDERERS
|
||||
};
|
||||
@@ -0,0 +1,526 @@
|
||||
/**
|
||||
* Composant StandardParametersEditor - Éditeur de paramètres pour étapes standard
|
||||
* Auteur : Dom, Alice, Kiro - 12 janvier 2026
|
||||
*
|
||||
* Ce composant fournit une interface complète pour éditer les paramètres des étapes
|
||||
* standard avec validation en temps réel, sauvegarde automatique et gestion d'état.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useEffect, useMemo, memo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Alert,
|
||||
Divider,
|
||||
Chip,
|
||||
LinearProgress,
|
||||
Collapse,
|
||||
IconButton,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Error as ErrorIcon,
|
||||
Warning as WarningIcon,
|
||||
Info as InfoIcon,
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
ExpandLess as ExpandLessIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
// Import des composants
|
||||
import ParameterFieldRenderer from './ParameterFieldRenderer';
|
||||
|
||||
// Import des types
|
||||
import { ParameterConfig } from '../../services/StepTypeResolver';
|
||||
import { Variable, ValidationError } from '../../types';
|
||||
|
||||
/**
|
||||
* Props du StandardParametersEditor
|
||||
*/
|
||||
export interface StandardParametersEditorProps {
|
||||
stepType: string;
|
||||
parameterConfigs: ParameterConfig[];
|
||||
parameters: Record<string, any>;
|
||||
variables: Variable[];
|
||||
onParameterChange: (paramName: string, value: any) => void;
|
||||
onValidationChange: (errors: ValidationError[]) => void;
|
||||
onVisualSelection?: () => void;
|
||||
disabled?: boolean;
|
||||
showValidationSummary?: boolean;
|
||||
groupParameters?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* État de validation d'un paramètre
|
||||
*/
|
||||
interface ParameterValidationState {
|
||||
isValid: boolean;
|
||||
errors: ValidationError[];
|
||||
warnings: ValidationError[];
|
||||
infos: ValidationError[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Résultat de validation globale
|
||||
*/
|
||||
interface ValidationSummary {
|
||||
isValid: boolean;
|
||||
totalErrors: number;
|
||||
totalWarnings: number;
|
||||
totalInfos: number;
|
||||
parameterStates: Record<string, ParameterValidationState>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Groupe de paramètres
|
||||
*/
|
||||
interface ParameterGroup {
|
||||
name: string;
|
||||
label: string;
|
||||
parameters: ParameterConfig[];
|
||||
expanded: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook pour la validation en temps réel
|
||||
*/
|
||||
function useParameterValidation(
|
||||
parameterConfigs: ParameterConfig[],
|
||||
parameters: Record<string, any>
|
||||
): ValidationSummary {
|
||||
|
||||
return useMemo(() => {
|
||||
const parameterStates: Record<string, ParameterValidationState> = {};
|
||||
let totalErrors = 0;
|
||||
let totalWarnings = 0;
|
||||
let totalInfos = 0;
|
||||
|
||||
for (const config of parameterConfigs) {
|
||||
const value = parameters[config.name];
|
||||
const errors: ValidationError[] = [];
|
||||
const warnings: ValidationError[] = [];
|
||||
const infos: ValidationError[] = [];
|
||||
|
||||
// Validation de base - champ requis
|
||||
if (config.required && (value === undefined || value === null || value === '')) {
|
||||
errors.push({
|
||||
parameter: config.name,
|
||||
message: `Le champ "${config.label}" est requis`,
|
||||
severity: 'error',
|
||||
code: 'REQUIRED_FIELD'
|
||||
});
|
||||
}
|
||||
|
||||
// Validation spécifique par type
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
switch (config.type) {
|
||||
case 'number':
|
||||
const numValue = Number(value);
|
||||
if (isNaN(numValue)) {
|
||||
errors.push({
|
||||
parameter: config.name,
|
||||
message: `"${config.label}" doit être un nombre valide`,
|
||||
severity: 'error',
|
||||
code: 'INVALID_NUMBER'
|
||||
});
|
||||
} else {
|
||||
if (config.min !== undefined && numValue < config.min) {
|
||||
errors.push({
|
||||
parameter: config.name,
|
||||
message: `"${config.label}" doit être supérieur ou égal à ${config.min}`,
|
||||
severity: 'error',
|
||||
code: 'MIN_VALUE'
|
||||
});
|
||||
}
|
||||
if (config.max !== undefined && numValue > config.max) {
|
||||
errors.push({
|
||||
parameter: config.name,
|
||||
message: `"${config.label}" doit être inférieur ou égal à ${config.max}`,
|
||||
severity: 'error',
|
||||
code: 'MAX_VALUE'
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'select':
|
||||
if (config.options && !config.options.some(opt => opt.value === value)) {
|
||||
errors.push({
|
||||
parameter: config.name,
|
||||
message: `"${config.label}" doit être une des options disponibles`,
|
||||
severity: 'error',
|
||||
code: 'INVALID_OPTION'
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'text':
|
||||
if (typeof value !== 'string') {
|
||||
errors.push({
|
||||
parameter: config.name,
|
||||
message: `"${config.label}" doit être du texte`,
|
||||
severity: 'error',
|
||||
code: 'INVALID_TEXT'
|
||||
});
|
||||
} else {
|
||||
// Validation des variables si supportées
|
||||
if (config.supportVariables) {
|
||||
const variablePattern = /\$\{([^}]+)\}/g;
|
||||
let match;
|
||||
while ((match = variablePattern.exec(value)) !== null) {
|
||||
const varName = match[1];
|
||||
// Note: Dans une implémentation complète, on vérifierait si la variable existe
|
||||
infos.push({
|
||||
parameter: config.name,
|
||||
message: `Variable utilisée: ${varName}`,
|
||||
severity: 'info',
|
||||
code: 'VARIABLE_USAGE'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'visual':
|
||||
if (typeof value !== 'object' || !value.selector) {
|
||||
warnings.push({
|
||||
parameter: config.name,
|
||||
message: `"${config.label}" nécessite une sélection visuelle valide`,
|
||||
severity: 'warning',
|
||||
code: 'INCOMPLETE_VISUAL_SELECTION'
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Validation conditionnelle
|
||||
if (config.conditional) {
|
||||
// Implémentation future pour les règles conditionnelles
|
||||
}
|
||||
|
||||
parameterStates[config.name] = {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
infos
|
||||
};
|
||||
|
||||
totalErrors += errors.length;
|
||||
totalWarnings += warnings.length;
|
||||
totalInfos += infos.length;
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: totalErrors === 0,
|
||||
totalErrors,
|
||||
totalWarnings,
|
||||
totalInfos,
|
||||
parameterStates
|
||||
};
|
||||
}, [parameterConfigs, parameters]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook pour le groupement des paramètres
|
||||
*/
|
||||
function useParameterGroups(
|
||||
parameterConfigs: ParameterConfig[],
|
||||
groupParameters: boolean
|
||||
): ParameterGroup[] {
|
||||
|
||||
return useMemo(() => {
|
||||
if (!groupParameters) {
|
||||
return [{
|
||||
name: 'default',
|
||||
label: 'Paramètres',
|
||||
parameters: parameterConfigs,
|
||||
expanded: true
|
||||
}];
|
||||
}
|
||||
|
||||
// Grouper par propriété 'group' ou par type
|
||||
const groups: Record<string, ParameterConfig[]> = {};
|
||||
|
||||
for (const config of parameterConfigs) {
|
||||
const groupName = config.group || config.type;
|
||||
if (!groups[groupName]) {
|
||||
groups[groupName] = [];
|
||||
}
|
||||
groups[groupName].push(config);
|
||||
}
|
||||
|
||||
// Convertir en tableau de groupes
|
||||
return Object.entries(groups).map(([name, parameters]) => ({
|
||||
name,
|
||||
label: name === 'default' ? 'Paramètres' :
|
||||
name === 'text' ? 'Champs de texte' :
|
||||
name === 'number' ? 'Champs numériques' :
|
||||
name === 'boolean' ? 'Options' :
|
||||
name === 'select' ? 'Sélections' :
|
||||
name === 'visual' ? 'Sélections visuelles' :
|
||||
name.charAt(0).toUpperCase() + name.slice(1),
|
||||
parameters: parameters.sort((a, b) => (a.order || 0) - (b.order || 0)),
|
||||
expanded: true
|
||||
}));
|
||||
}, [parameterConfigs, groupParameters]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Composant StandardParametersEditor
|
||||
*/
|
||||
const StandardParametersEditor: React.FC<StandardParametersEditorProps> = ({
|
||||
stepType,
|
||||
parameterConfigs,
|
||||
parameters,
|
||||
variables,
|
||||
onParameterChange,
|
||||
onValidationChange,
|
||||
onVisualSelection,
|
||||
disabled = false,
|
||||
showValidationSummary = true,
|
||||
groupParameters = false,
|
||||
className
|
||||
}) => {
|
||||
// État local pour les groupes expandus
|
||||
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({});
|
||||
|
||||
// Validation en temps réel
|
||||
const validationSummary = useParameterValidation(parameterConfigs, parameters);
|
||||
|
||||
// Groupement des paramètres
|
||||
const parameterGroups = useParameterGroups(parameterConfigs, groupParameters);
|
||||
|
||||
// Effet pour notifier les changements de validation
|
||||
useEffect(() => {
|
||||
const allErrors: ValidationError[] = [];
|
||||
|
||||
Object.values(validationSummary.parameterStates).forEach(state => {
|
||||
allErrors.push(...state.errors, ...state.warnings);
|
||||
});
|
||||
|
||||
onValidationChange(allErrors);
|
||||
}, [validationSummary, onValidationChange]);
|
||||
|
||||
// Gestionnaire de changement de paramètre avec validation
|
||||
const handleParameterChange = useCallback((paramName: string, value: any) => {
|
||||
console.log(`📝 [StandardParametersEditor] Changement paramètre:`, {
|
||||
stepType,
|
||||
paramName,
|
||||
value,
|
||||
type: typeof value
|
||||
});
|
||||
|
||||
onParameterChange(paramName, value);
|
||||
}, [stepType, onParameterChange]);
|
||||
|
||||
// Gestionnaire de toggle de groupe
|
||||
const handleGroupToggle = useCallback((groupName: string) => {
|
||||
setExpandedGroups(prev => ({
|
||||
...prev,
|
||||
[groupName]: !prev[groupName]
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// Obtenir l'état de validation pour un paramètre
|
||||
const getParameterValidation = useCallback((paramName: string): ValidationError | undefined => {
|
||||
const state = validationSummary.parameterStates[paramName];
|
||||
if (!state) return undefined;
|
||||
|
||||
// Retourner la première erreur, sinon le premier warning
|
||||
return state.errors[0] || state.warnings[0] || undefined;
|
||||
}, [validationSummary]);
|
||||
|
||||
// Rendu du résumé de validation
|
||||
const renderValidationSummary = () => {
|
||||
if (!showValidationSummary) return null;
|
||||
|
||||
const { isValid, totalErrors, totalWarnings, totalInfos } = validationSummary;
|
||||
|
||||
if (totalErrors === 0 && totalWarnings === 0 && totalInfos === 0) {
|
||||
return (
|
||||
<Alert severity="success" icon={<CheckCircleIcon />} sx={{ mb: 2 }}>
|
||||
<Typography variant="body2">
|
||||
Tous les paramètres sont correctement configurés
|
||||
</Typography>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
{totalErrors > 0 && (
|
||||
<Alert severity="error" icon={<ErrorIcon />} sx={{ mb: 1 }}>
|
||||
<Typography variant="body2">
|
||||
{totalErrors} erreur{totalErrors > 1 ? 's' : ''} de validation
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{totalWarnings > 0 && (
|
||||
<Alert severity="warning" icon={<WarningIcon />} sx={{ mb: 1 }}>
|
||||
<Typography variant="body2">
|
||||
{totalWarnings} avertissement{totalWarnings > 1 ? 's' : ''}
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{totalInfos > 0 && (
|
||||
<Alert severity="info" icon={<InfoIcon />}>
|
||||
<Typography variant="body2">
|
||||
{totalInfos} information{totalInfos > 1 ? 's' : ''}
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// Rendu d'un groupe de paramètres
|
||||
const renderParameterGroup = (group: ParameterGroup) => {
|
||||
const isExpanded = expandedGroups[group.name] ?? group.expanded;
|
||||
|
||||
return (
|
||||
<Box key={group.name} sx={{ mb: 2 }}>
|
||||
{parameterGroups.length > 1 && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
mb: 1,
|
||||
p: 1,
|
||||
borderRadius: 1,
|
||||
'&:hover': { backgroundColor: 'action.hover' }
|
||||
}}
|
||||
onClick={() => handleGroupToggle(group.name)}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ flex: 1 }}>
|
||||
{group.label} ({group.parameters.length})
|
||||
</Typography>
|
||||
<IconButton size="small">
|
||||
{isExpanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
||||
</IconButton>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Collapse in={isExpanded}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{group.parameters.map((config) => {
|
||||
const value = parameters[config.name];
|
||||
const error = getParameterValidation(config.name);
|
||||
|
||||
return (
|
||||
<Box key={config.name}>
|
||||
<ParameterFieldRenderer
|
||||
config={config}
|
||||
value={value}
|
||||
variables={variables}
|
||||
error={error}
|
||||
onChange={(newValue) => handleParameterChange(config.name, newValue)}
|
||||
onVisualSelection={config.type === 'visual' ? onVisualSelection : undefined}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// Rendu des variables disponibles
|
||||
const renderVariablesSection = () => {
|
||||
if (variables.length === 0) return null;
|
||||
|
||||
const hasVariableSupport = parameterConfigs.some(config => config.supportVariables);
|
||||
if (!hasVariableSupport) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Variables disponibles
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{variables.map((variable) => (
|
||||
<Chip
|
||||
key={variable.id}
|
||||
label={`\${${variable.name}}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={() => {
|
||||
// Copier dans le presse-papiers
|
||||
navigator.clipboard.writeText(`\${${variable.name}}`);
|
||||
}}
|
||||
sx={{ cursor: 'pointer' }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
|
||||
Cliquez sur une variable pour la copier dans le presse-papiers
|
||||
</Typography>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// Logging pour le développement
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(`🎛️ [StandardParametersEditor] Rendu:`, {
|
||||
stepType,
|
||||
parameterCount: parameterConfigs.length,
|
||||
groupCount: parameterGroups.length,
|
||||
validationState: {
|
||||
isValid: validationSummary.isValid,
|
||||
errors: validationSummary.totalErrors,
|
||||
warnings: validationSummary.totalWarnings
|
||||
},
|
||||
disabled
|
||||
});
|
||||
}
|
||||
|
||||
// Cas où aucun paramètre n'est configuré
|
||||
if (parameterConfigs.length === 0) {
|
||||
return (
|
||||
<Box className={className}>
|
||||
<Typography variant="body2" color="text.secondary" textAlign="center" sx={{ py: 4 }}>
|
||||
Cette étape n'a pas de paramètres configurables.
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className={className} data-step-type={stepType}>
|
||||
{/* Résumé de validation */}
|
||||
{renderValidationSummary()}
|
||||
|
||||
{/* Indicateur de progression si validation en cours */}
|
||||
{disabled && (
|
||||
<LinearProgress sx={{ mb: 2 }} />
|
||||
)}
|
||||
|
||||
{/* Groupes de paramètres */}
|
||||
{parameterGroups.map(renderParameterGroup)}
|
||||
|
||||
{/* Section des variables */}
|
||||
{renderVariablesSection()}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Export du composant mémorisé
|
||||
*/
|
||||
export default memo(StandardParametersEditor, (prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.stepType === nextProps.stepType &&
|
||||
JSON.stringify(prevProps.parameterConfigs) === JSON.stringify(nextProps.parameterConfigs) &&
|
||||
JSON.stringify(prevProps.parameters) === JSON.stringify(nextProps.parameters) &&
|
||||
JSON.stringify(prevProps.variables) === JSON.stringify(nextProps.variables) &&
|
||||
prevProps.disabled === nextProps.disabled &&
|
||||
prevProps.showValidationSummary === nextProps.showValidationSummary &&
|
||||
prevProps.groupParameters === nextProps.groupParameters
|
||||
);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,633 @@
|
||||
/**
|
||||
* Composant Panneau de Propriétés - Configuration des paramètres d'étapes
|
||||
* Auteur : Dom, Alice, Kiro - 12 janvier 2026
|
||||
*
|
||||
* Ce composant affiche et permet la modification des paramètres d'une étape sélectionnée,
|
||||
* avec validation en temps réel et adaptation selon le type de paramètre.
|
||||
*
|
||||
* Version refactorisée utilisant le nouveau StepTypeResolver pour une résolution
|
||||
* unifiée et robuste des propriétés d'étapes.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useMemo, memo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Alert,
|
||||
Divider,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Visibility as VisibilityIcon,
|
||||
Error as ErrorIcon,
|
||||
BugReport as BugReportIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
// Import des composants
|
||||
import VisualSelector from '../VisualSelector';
|
||||
import VariableAutocomplete from '../VariableAutocomplete';
|
||||
import VWBActionProperties from './VWBActionProperties';
|
||||
import DebugPanel from '../DebugPanel';
|
||||
|
||||
// Import des nouveaux composants de l'interface complète
|
||||
import StandardParametersEditor from './StandardParametersEditor';
|
||||
import EmptyStateMessage from './EmptyStateMessage';
|
||||
import LoadingState, { StepResolutionLoading, VWBActionLoading } from './LoadingState';
|
||||
|
||||
// Import du nouveau système de résolution
|
||||
import { useStepTypeResolver } from '../../hooks/useStepTypeResolver';
|
||||
import { ParameterConfig, StepTypeResolutionResult } from '../../services/StepTypeResolver';
|
||||
|
||||
// Import des hooks d'intégration VWB (pour compatibilité)
|
||||
import { useVWBStepIntegration, useVWBActionId } from '../../hooks/useVWBStepIntegration';
|
||||
|
||||
// Import des types du catalogue VWB
|
||||
import { VWBCatalogAction, VWBActionValidationResult } from '../../types/catalog';
|
||||
|
||||
// Import des hooks d'auto-sauvegarde
|
||||
import { useStepParametersAutoSave } from '../../hooks/useAutoSave';
|
||||
|
||||
// Import des types partagés
|
||||
import {
|
||||
PropertiesPanelProps,
|
||||
ValidationError,
|
||||
VisualSelection,
|
||||
Variable,
|
||||
} from '../../types';
|
||||
|
||||
// Types pour l'état d'affichage
|
||||
interface DisplayState {
|
||||
type: 'loading' | 'empty' | 'vwb-properties' | 'standard-parameters';
|
||||
subtype?: 'resolving' | 'loading-vwb';
|
||||
reason?: 'resolution-failed' | 'vwb-not-found' | 'no-parameters' | 'unknown-type';
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composant Panneau de Propriétés
|
||||
*/
|
||||
const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
|
||||
selectedStep,
|
||||
variables,
|
||||
onParameterChange,
|
||||
onVisualSelection,
|
||||
}) => {
|
||||
const [localParameters, setLocalParameters] = useState<Record<string, any>>({});
|
||||
const [isVisualSelectorOpen, setIsVisualSelectorOpen] = useState(false);
|
||||
const [isDebugPanelVisible, setIsDebugPanelVisible] = useState(false);
|
||||
const [validationErrors, setValidationErrors] = useState<ValidationError[]>([]);
|
||||
|
||||
// Utilisation du nouveau système de résolution unifié
|
||||
const stepResolver = useStepTypeResolver(selectedStep || null, {
|
||||
autoResolve: true,
|
||||
enableLogging: process.env.NODE_ENV === 'development',
|
||||
onResolutionComplete: (result) => {
|
||||
console.log('✅ [PropertiesPanel] Résolution terminée:', {
|
||||
stepType: result.stepType,
|
||||
isVWBAction: result.isVWBAction,
|
||||
parameterCount: result.parameterConfig.length,
|
||||
source: result.resolutionSource
|
||||
});
|
||||
},
|
||||
onResolutionError: (error) => {
|
||||
console.error('❌ [PropertiesPanel] Erreur de résolution:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// Hooks d'intégration VWB (pour compatibilité et chargement des actions)
|
||||
const { methods: vwbMethods } = useVWBStepIntegration();
|
||||
const vwbActionId = useVWBActionId(selectedStep || null);
|
||||
|
||||
// État dérivé du résolveur
|
||||
const {
|
||||
result: resolutionResult,
|
||||
isLoading: isResolving,
|
||||
error: resolutionError,
|
||||
resolveStep,
|
||||
resolveStepSync,
|
||||
invalidateCache: invalidateResolverCache,
|
||||
hasParameterConfig,
|
||||
parameterCount,
|
||||
isStandardType,
|
||||
resolutionSource
|
||||
} = stepResolver;
|
||||
|
||||
// Déterminer si c'est une action VWB
|
||||
// Utiliser vwbActionId comme indicateur principal car le hook useVWBActionId fait une détection robuste
|
||||
const isVWBCatalogAction = useMemo(() => {
|
||||
// Si vwbActionId est défini, c'est une action VWB (détection via hook)
|
||||
if (vwbActionId) {
|
||||
return true;
|
||||
}
|
||||
// Fallback vers le résultat du résolveur
|
||||
return resolutionResult?.isVWBAction || false;
|
||||
}, [resolutionResult, vwbActionId]);
|
||||
|
||||
// Obtenir la configuration des paramètres
|
||||
const parameterConfigs = useMemo(() => {
|
||||
if (!resolutionResult) return [];
|
||||
return resolutionResult.parameterConfig || [];
|
||||
}, [resolutionResult]);
|
||||
|
||||
// Charger l'action VWB si nécessaire
|
||||
const [vwbAction, setVwbAction] = useState<VWBCatalogAction | null>(null);
|
||||
const [isLoadingVWBAction, setIsLoadingVWBAction] = useState(false);
|
||||
const [vwbActionError, setVwbActionError] = useState<Error | null>(null);
|
||||
|
||||
// Hook d'auto-sauvegarde pour les paramètres
|
||||
const autoSave = useStepParametersAutoSave(
|
||||
selectedStep?.id || '',
|
||||
onParameterChange,
|
||||
{
|
||||
debounceMs: 800,
|
||||
enableLogging: process.env.NODE_ENV === 'development',
|
||||
onSaveStart: () => console.log('💾 [PropertiesPanel] Début sauvegarde auto'),
|
||||
onSaveSuccess: () => console.log('✅ [PropertiesPanel] Sauvegarde auto réussie'),
|
||||
onSaveError: (error) => console.error('❌ [PropertiesPanel] Erreur sauvegarde auto:', error)
|
||||
}
|
||||
);
|
||||
|
||||
// Méthode pour résoudre manuellement une étape
|
||||
const handleManualResolveStep = useCallback(async (): Promise<StepTypeResolutionResult | null> => {
|
||||
if (!selectedStep) return null;
|
||||
|
||||
try {
|
||||
console.log('🔄 [PropertiesPanel] Résolution manuelle de l\'étape:', selectedStep.id);
|
||||
const result: StepTypeResolutionResult = await resolveStep(selectedStep, { enableCache: false });
|
||||
console.log('✅ [PropertiesPanel] Résolution manuelle terminée:', result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('❌ [PropertiesPanel] Erreur résolution manuelle:', error);
|
||||
return null;
|
||||
}
|
||||
}, [selectedStep, resolveStep]);
|
||||
|
||||
// Méthode pour invalider le cache et recharger
|
||||
const handleRefreshResolution = useCallback(() => {
|
||||
console.log('🔄 [PropertiesPanel] Actualisation de la résolution');
|
||||
invalidateResolverCache();
|
||||
if (selectedStep) {
|
||||
handleManualResolveStep();
|
||||
}
|
||||
}, [invalidateResolverCache, selectedStep, handleManualResolveStep]);
|
||||
|
||||
// Charger l'action VWB basé sur vwbActionId (pas sur isVWBCatalogAction du résolveur)
|
||||
React.useEffect(() => {
|
||||
const loadVWBAction = async () => {
|
||||
// Charger si vwbActionId est défini (le hook useVWBActionId fait déjà la détection)
|
||||
if (!vwbActionId) {
|
||||
setVwbAction(null);
|
||||
setVwbActionError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🔄 [PropertiesPanel] Chargement action VWB:', vwbActionId);
|
||||
setIsLoadingVWBAction(true);
|
||||
setVwbActionError(null);
|
||||
|
||||
try {
|
||||
// Utiliser le hook d'intégration pour charger l'action
|
||||
const action = await vwbMethods.loadVWBAction(vwbActionId);
|
||||
setVwbAction(action);
|
||||
|
||||
if (action) {
|
||||
console.log('✅ [PropertiesPanel] Action VWB chargée:', {
|
||||
actionId: vwbActionId,
|
||||
actionName: action.name,
|
||||
parametersCount: Object.keys(action.parameters || {}).length
|
||||
});
|
||||
} else {
|
||||
console.warn('⚠️ [PropertiesPanel] Action VWB non trouvée:', vwbActionId);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorObj = error instanceof Error ? error : new Error(String(error));
|
||||
console.error('❌ [PropertiesPanel] Erreur chargement action VWB:', errorObj);
|
||||
setVwbAction(null);
|
||||
setVwbActionError(errorObj);
|
||||
} finally {
|
||||
setIsLoadingVWBAction(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadVWBAction();
|
||||
}, [vwbActionId, vwbMethods]);
|
||||
|
||||
// Gestionnaire d'événements clavier pour le panneau de propriétés
|
||||
const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
|
||||
switch (event.key) {
|
||||
case 'Enter':
|
||||
// Activer l'élément focalisé
|
||||
if (event.target instanceof HTMLButtonElement) {
|
||||
event.preventDefault();
|
||||
event.target.click();
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
// Fermer les dialogues ouverts
|
||||
event.preventDefault();
|
||||
setIsVisualSelectorOpen(false);
|
||||
break;
|
||||
case 'Tab':
|
||||
// Navigation entre les champs (comportement par défaut)
|
||||
break;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Synchroniser les paramètres locaux avec l'étape sélectionnée
|
||||
// IMPORTANT: Copie profonde pour éviter les mutations qui affectent d'autres étapes
|
||||
React.useEffect(() => {
|
||||
if (selectedStep) {
|
||||
// Copie profonde pour isoler les paramètres locaux du workflow
|
||||
const paramsCopy = JSON.parse(JSON.stringify(selectedStep.data?.parameters || {}));
|
||||
setLocalParameters(paramsCopy);
|
||||
} else {
|
||||
setLocalParameters({});
|
||||
}
|
||||
}, [selectedStep]);
|
||||
|
||||
// Gestionnaire de changement de paramètre VWB (avec auto-sauvegarde)
|
||||
const handleVWBParameterChange = useCallback((paramName: string, value: any) => {
|
||||
// Copie profonde de la valeur si c'est un objet (ex: visual_anchor)
|
||||
const valueCopy = value && typeof value === 'object'
|
||||
? JSON.parse(JSON.stringify(value))
|
||||
: value;
|
||||
|
||||
const newParameters = { ...localParameters, [paramName]: valueCopy };
|
||||
setLocalParameters(newParameters);
|
||||
|
||||
if (selectedStep) {
|
||||
// Déclencher la sauvegarde automatique (fix: était manquant pour les actions VWB)
|
||||
autoSave.triggerSave(newParameters);
|
||||
// Envoyer aussi une copie au parent pour éviter les références partagées
|
||||
onParameterChange(selectedStep.id, paramName, valueCopy);
|
||||
}
|
||||
}, [localParameters, selectedStep, onParameterChange, autoSave]);
|
||||
|
||||
// Gestionnaire de validation VWB
|
||||
const handleVWBValidationChange = useCallback((validation: VWBActionValidationResult) => {
|
||||
// Mettre à jour les erreurs de validation de l'étape
|
||||
if (selectedStep) {
|
||||
// Notifier le parent des erreurs de validation
|
||||
if (validation.errors.length > 0) {
|
||||
console.error('Erreurs de validation VWB:', validation.errors.map(e => e.message).join(', '));
|
||||
}
|
||||
}
|
||||
}, [selectedStep]);
|
||||
|
||||
// Gestionnaire de changement de paramètre avec auto-sauvegarde
|
||||
const handleParameterChange = useCallback((paramName: string, value: any) => {
|
||||
// Copie profonde de la valeur si c'est un objet
|
||||
const valueCopy = value && typeof value === 'object'
|
||||
? JSON.parse(JSON.stringify(value))
|
||||
: value;
|
||||
|
||||
const newParameters = { ...localParameters, [paramName]: valueCopy };
|
||||
setLocalParameters(newParameters);
|
||||
|
||||
// Déclencher la sauvegarde automatique
|
||||
autoSave.triggerSave(newParameters);
|
||||
|
||||
if (selectedStep) {
|
||||
onParameterChange(selectedStep.id, paramName, valueCopy);
|
||||
}
|
||||
}, [localParameters, selectedStep, onParameterChange, autoSave]);
|
||||
|
||||
// Gestionnaire de sélection visuelle
|
||||
const handleVisualSelection = useCallback(() => {
|
||||
if (selectedStep) {
|
||||
setIsVisualSelectorOpen(true);
|
||||
}
|
||||
}, [selectedStep]);
|
||||
|
||||
// Gestionnaire de confirmation de sélection visuelle
|
||||
const handleElementSelected = useCallback((selection: VisualSelection) => {
|
||||
if (selectedStep) {
|
||||
// Stocker la sélection visuelle dans les paramètres
|
||||
handleParameterChange('target', selection);
|
||||
onVisualSelection(selectedStep.id);
|
||||
}
|
||||
}, [selectedStep, handleParameterChange, onVisualSelection]);
|
||||
|
||||
// Gestionnaire de validation des paramètres
|
||||
const handleValidationChange = useCallback((errors: ValidationError[]) => {
|
||||
setValidationErrors(errors);
|
||||
}, []);
|
||||
|
||||
// Déterminer l'état d'affichage et le type de contenu
|
||||
const getDisplayState = useCallback((): DisplayState => {
|
||||
// Cas de chargement de résolution
|
||||
if (isResolving) {
|
||||
return { type: 'loading', subtype: 'resolving' };
|
||||
}
|
||||
|
||||
// Cas d'erreur de résolution
|
||||
if (resolutionError) {
|
||||
return {
|
||||
type: 'empty',
|
||||
reason: 'resolution-failed' as const,
|
||||
error: resolutionError
|
||||
};
|
||||
}
|
||||
|
||||
// Cas d'action VWB
|
||||
if (isVWBCatalogAction) {
|
||||
if (isLoadingVWBAction) {
|
||||
return { type: 'loading', subtype: 'loading-vwb' };
|
||||
}
|
||||
|
||||
if (vwbActionError) {
|
||||
return {
|
||||
type: 'empty',
|
||||
reason: 'vwb-not-found' as const,
|
||||
error: vwbActionError
|
||||
};
|
||||
}
|
||||
|
||||
if (vwbAction) {
|
||||
return { type: 'vwb-properties' };
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'empty',
|
||||
reason: 'vwb-not-found' as const
|
||||
};
|
||||
}
|
||||
|
||||
// Cas d'étapes standard
|
||||
if (parameterConfigs.length === 0) {
|
||||
return {
|
||||
type: 'empty',
|
||||
reason: resolutionResult?.isStandardType ? 'no-parameters' as const : 'unknown-type' as const
|
||||
};
|
||||
}
|
||||
|
||||
return { type: 'standard-parameters' };
|
||||
}, [
|
||||
isResolving,
|
||||
resolutionError,
|
||||
isVWBCatalogAction,
|
||||
isLoadingVWBAction,
|
||||
vwbActionError,
|
||||
vwbAction,
|
||||
parameterConfigs.length,
|
||||
resolutionResult
|
||||
]);
|
||||
|
||||
// Debug logging pour diagnostic
|
||||
React.useEffect(() => {
|
||||
if (selectedStep) {
|
||||
console.log('🔍 [PropertiesPanel] État actuel:', {
|
||||
stepId: selectedStep.id,
|
||||
stepType: selectedStep.type,
|
||||
stepData: selectedStep.data,
|
||||
isVWBCatalogAction,
|
||||
vwbActionId,
|
||||
vwbAction: vwbAction ? vwbAction.name : null,
|
||||
isLoadingVWBAction,
|
||||
vwbActionError: vwbActionError?.message,
|
||||
displayState: getDisplayState(),
|
||||
resolutionResult: resolutionResult ? {
|
||||
isVWBAction: resolutionResult.isVWBAction,
|
||||
isStandardType: resolutionResult.isStandardType,
|
||||
parameterCount: resolutionResult.parameterConfig?.length
|
||||
} : null
|
||||
});
|
||||
}
|
||||
}, [selectedStep, isVWBCatalogAction, vwbActionId, vwbAction, isLoadingVWBAction, vwbActionError, resolutionResult, getDisplayState]);
|
||||
|
||||
if (!selectedStep) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
width: 320,
|
||||
height: '100%',
|
||||
backgroundColor: '#ffffff',
|
||||
borderLeft: '1px solid #e0e0e0',
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
role="complementary"
|
||||
aria-label="Panneau de propriétés - Aucune étape sélectionnée"
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary" textAlign="center">
|
||||
Sélectionnez une étape pour configurer ses propriétés
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const hasErrors = (selectedStep.validationErrors || []).some(error => error.severity === 'error');
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
width: 320,
|
||||
height: '100%',
|
||||
backgroundColor: '#ffffff',
|
||||
borderLeft: '1px solid #e0e0e0',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
role="complementary"
|
||||
aria-label={`Propriétés de l'étape ${selectedStep.name}`}
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={0}
|
||||
>
|
||||
{/* En-tête */}
|
||||
<Box sx={{ p: 2, borderBottom: '1px solid #e0e0e0' }}>
|
||||
<Typography variant="h6" component="h3" gutterBottom>
|
||||
Propriétés de l'étape
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{selectedStep.name} ({selectedStep.type})
|
||||
</Typography>
|
||||
|
||||
{/* Indicateur de résolution */}
|
||||
{isResolving && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mt: 1 }}>
|
||||
<CircularProgress size={16} sx={{ mr: 1 }} />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Résolution des propriétés...
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{resolutionError && (
|
||||
<Alert severity="warning" sx={{ mt: 1 }}>
|
||||
Erreur de résolution des propriétés
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Alertes d'erreur globales */}
|
||||
{hasErrors && (
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Alert severity="error" icon={<ErrorIcon />}>
|
||||
Cette étape contient des erreurs qui empêchent l'exécution du workflow.
|
||||
</Alert>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Paramètres - Rendu conditionnel selon l'état */}
|
||||
<Box sx={{ flex: 1, overflow: 'auto', p: 2 }}>
|
||||
{(() => {
|
||||
const displayState = getDisplayState();
|
||||
|
||||
switch (displayState.type) {
|
||||
case 'loading':
|
||||
if (displayState.subtype === 'resolving') {
|
||||
return (
|
||||
<StepResolutionLoading
|
||||
message="Résolution des propriétés d'étape..."
|
||||
canCancel={false}
|
||||
/>
|
||||
);
|
||||
} else if (displayState.subtype === 'loading-vwb') {
|
||||
return (
|
||||
<VWBActionLoading
|
||||
message="Chargement de l'action VWB..."
|
||||
canCancel={true}
|
||||
onCancel={() => {
|
||||
setIsLoadingVWBAction(false);
|
||||
setVwbActionError(new Error('Chargement annulé par l\'utilisateur'));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'empty':
|
||||
return (
|
||||
<EmptyStateMessage
|
||||
stepType={selectedStep.type}
|
||||
reason={displayState.reason || 'unknown-type'}
|
||||
error={displayState.error}
|
||||
onRetry={displayState.reason === 'resolution-failed' ? handleRefreshResolution : undefined}
|
||||
onRefresh={displayState.reason === 'vwb-not-found' ? handleRefreshResolution : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'vwb-properties':
|
||||
return (
|
||||
<VWBActionProperties
|
||||
key={`vwb-props-${selectedStep.id}`}
|
||||
action={vwbAction!}
|
||||
parameters={localParameters}
|
||||
variables={variables as Variable[]}
|
||||
onParameterChange={handleVWBParameterChange}
|
||||
onValidationChange={handleVWBValidationChange}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'standard-parameters':
|
||||
return (
|
||||
<StandardParametersEditor
|
||||
stepType={selectedStep.type}
|
||||
parameterConfigs={parameterConfigs}
|
||||
parameters={localParameters}
|
||||
variables={variables as Variable[]}
|
||||
onParameterChange={handleParameterChange}
|
||||
onValidationChange={handleValidationChange}
|
||||
onVisualSelection={handleVisualSelection}
|
||||
disabled={autoSave.saveState.isSaving}
|
||||
showValidationSummary={true}
|
||||
groupParameters={parameterConfigs.length > 5}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<EmptyStateMessage
|
||||
stepType={selectedStep.type}
|
||||
reason="unknown-type"
|
||||
onRetry={handleRefreshResolution}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
|
||||
{/* Indicateur de sauvegarde */}
|
||||
{autoSave.saveState.isSaving && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Alert severity="info" sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<CircularProgress size={16} sx={{ mr: 1 }} />
|
||||
<Typography variant="body2">
|
||||
Sauvegarde en cours...
|
||||
</Typography>
|
||||
</Alert>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Erreur de sauvegarde */}
|
||||
{autoSave.saveState.error && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Alert
|
||||
severity="error"
|
||||
action={
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => autoSave.resetError()}
|
||||
>
|
||||
Ignorer
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Typography variant="body2">
|
||||
Erreur de sauvegarde: {autoSave.saveState.error.message}
|
||||
</Typography>
|
||||
</Alert>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Panneau de debug en mode développement */}
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<Box sx={{ borderTop: '1px solid #e0e0e0', p: 1 }}>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<BugReportIcon />}
|
||||
onClick={() => setIsDebugPanelVisible(!isDebugPanelVisible)}
|
||||
variant="text"
|
||||
>
|
||||
Debug Panel
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Composant VisualSelector */}
|
||||
{selectedStep && (
|
||||
<VisualSelector
|
||||
isOpen={isVisualSelectorOpen}
|
||||
stepId={selectedStep.id}
|
||||
onClose={() => setIsVisualSelectorOpen(false)}
|
||||
onElementSelected={handleElementSelected}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Composant DebugPanel (mode développement) */}
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<DebugPanel
|
||||
selectedStep={selectedStep}
|
||||
variables={variables as Variable[]}
|
||||
isVisible={isDebugPanelVisible}
|
||||
onToggleVisibility={setIsDebugPanelVisible}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// Mémorisation du composant PropertiesPanel pour éviter les re-rendus inutiles
|
||||
export default memo(PropertiesPanel, (prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.selectedStep?.id === nextProps.selectedStep?.id &&
|
||||
JSON.stringify(prevProps.selectedStep?.data) === JSON.stringify(nextProps.selectedStep?.data) &&
|
||||
JSON.stringify(prevProps.variables) === JSON.stringify(nextProps.variables) &&
|
||||
prevProps.onParameterChange === nextProps.onParameterChange &&
|
||||
prevProps.onVisualSelection === nextProps.onVisualSelection
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
/* Styles pour le Composant de Capture d'Écran Réelle */
|
||||
/* Auteur : Dom, Alice, Kiro - 8 janvier 2026 */
|
||||
|
||||
.real-screen-capture {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.real-screen-capture__screenshot {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
cursor: crosshair;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.real-screen-capture__element-overlay {
|
||||
position: absolute;
|
||||
border: 2px solid #ff4444;
|
||||
background-color: rgba(255, 68, 68, 0.1);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.real-screen-capture__element-overlay:hover {
|
||||
background-color: rgba(255, 68, 68, 0.2);
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
/**
|
||||
* Composant RealScreenCapture - Capture d'écran en temps réel pour sélection visuelle
|
||||
* Auteur : Dom, Alice, Kiro - 12 janvier 2026
|
||||
*
|
||||
* Ce composant fournit une interface de capture d'écran en temps réel pour permettre
|
||||
* aux utilisateurs de sélectionner visuellement des éléments sur l'écran.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useEffect, memo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Typography,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
CameraAlt as CameraIcon,
|
||||
Close as CloseIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Fullscreen as FullscreenIcon,
|
||||
CropFree as SelectIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
// Import des services
|
||||
import { realScreenCaptureService } from '../../services/realScreenCaptureService';
|
||||
|
||||
// Import des types
|
||||
import { VisualSelection } from '../../types';
|
||||
|
||||
/**
|
||||
* Props du composant RealScreenCapture
|
||||
*/
|
||||
export interface RealScreenCaptureProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onElementSelected: (selection: VisualSelection) => void;
|
||||
stepId?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* État de capture
|
||||
*/
|
||||
interface CaptureState {
|
||||
isCapturing: boolean;
|
||||
isSelecting: boolean;
|
||||
screenshot: string | null;
|
||||
error: Error | null;
|
||||
selectedElement: VisualSelection | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composant RealScreenCapture
|
||||
*/
|
||||
const RealScreenCapture: React.FC<RealScreenCaptureProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onElementSelected,
|
||||
stepId,
|
||||
className
|
||||
}) => {
|
||||
// État de capture
|
||||
const [captureState, setCaptureState] = useState<CaptureState>({
|
||||
isCapturing: false,
|
||||
isSelecting: false,
|
||||
screenshot: null,
|
||||
error: null,
|
||||
selectedElement: null
|
||||
});
|
||||
|
||||
/**
|
||||
* Démarre la capture d'écran
|
||||
*/
|
||||
const startCapture = useCallback(async () => {
|
||||
setCaptureState(prev => ({
|
||||
...prev,
|
||||
isCapturing: true,
|
||||
error: null,
|
||||
screenshot: null
|
||||
}));
|
||||
|
||||
try {
|
||||
console.log('📸 [RealScreenCapture] Début de capture d\'écran');
|
||||
|
||||
// Utiliser le service de capture d'écran réelle
|
||||
const response = await realScreenCaptureService.captureWithElements(0, false);
|
||||
|
||||
if (response?.success && response.screenshot) {
|
||||
// Le backend peut retourner soit une data URI complète, soit juste le base64
|
||||
const screenshotData = response.screenshot.startsWith('data:')
|
||||
? response.screenshot
|
||||
: `data:image/png;base64,${response.screenshot}`;
|
||||
|
||||
setCaptureState(prev => ({
|
||||
...prev,
|
||||
isCapturing: false,
|
||||
screenshot: screenshotData,
|
||||
isSelecting: true
|
||||
}));
|
||||
|
||||
console.log('✅ [RealScreenCapture] Capture d\'écran réussie');
|
||||
} else {
|
||||
throw new Error(response?.error || 'Échec de la capture d\'écran');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
const errorObj = error instanceof Error ? error : new Error(String(error));
|
||||
console.error('❌ [RealScreenCapture] Erreur de capture:', errorObj);
|
||||
|
||||
setCaptureState(prev => ({
|
||||
...prev,
|
||||
isCapturing: false,
|
||||
error: errorObj
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Gère la sélection d'un élément sur la capture
|
||||
*/
|
||||
const handleElementSelection = useCallback((event: React.MouseEvent<HTMLImageElement>) => {
|
||||
if (!captureState.isSelecting || !captureState.screenshot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
const y = event.clientY - rect.top;
|
||||
|
||||
// Calculer les coordonnées relatives
|
||||
const relativeX = x / rect.width;
|
||||
const relativeY = y / rect.height;
|
||||
|
||||
const selection: VisualSelection = {
|
||||
id: `selection_${Date.now()}`,
|
||||
screenshot: captureState.screenshot,
|
||||
boundingBox: {
|
||||
x: Math.round(relativeX * 100) / 100,
|
||||
y: Math.round(relativeY * 100) / 100,
|
||||
width: 0.01, // Point de clic, taille minimale
|
||||
height: 0.01
|
||||
},
|
||||
description: `Point de clic (${relativeX.toFixed(3)}, ${relativeY.toFixed(3)})`,
|
||||
metadata: {
|
||||
capture_method: 'real_screen_capture',
|
||||
capture_timestamp: new Date().toISOString(),
|
||||
screen_resolution: {
|
||||
width: rect.width,
|
||||
height: rect.height
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
setCaptureState(prev => ({
|
||||
...prev,
|
||||
selectedElement: selection,
|
||||
isSelecting: false
|
||||
}));
|
||||
|
||||
console.log('🎯 [RealScreenCapture] Élément sélectionné:', selection);
|
||||
}, [captureState.isSelecting, captureState.screenshot, stepId]);
|
||||
|
||||
/**
|
||||
* Confirme la sélection
|
||||
*/
|
||||
const confirmSelection = useCallback(() => {
|
||||
if (captureState.selectedElement) {
|
||||
onElementSelected(captureState.selectedElement);
|
||||
handleClose();
|
||||
}
|
||||
}, [captureState.selectedElement, onElementSelected]);
|
||||
|
||||
/**
|
||||
* Ferme le dialogue et remet à zéro l'état
|
||||
*/
|
||||
const handleClose = useCallback(() => {
|
||||
setCaptureState({
|
||||
isCapturing: false,
|
||||
isSelecting: false,
|
||||
screenshot: null,
|
||||
error: null,
|
||||
selectedElement: null
|
||||
});
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
/**
|
||||
* Recommence la capture
|
||||
*/
|
||||
const restartCapture = useCallback(() => {
|
||||
setCaptureState(prev => ({
|
||||
...prev,
|
||||
screenshot: null,
|
||||
selectedElement: null,
|
||||
error: null,
|
||||
isSelecting: false
|
||||
}));
|
||||
startCapture();
|
||||
}, [startCapture]);
|
||||
|
||||
/**
|
||||
* Effet pour démarrer automatiquement la capture à l'ouverture
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (isOpen && !captureState.screenshot && !captureState.isCapturing) {
|
||||
startCapture();
|
||||
}
|
||||
}, [isOpen, captureState.screenshot, captureState.isCapturing, startCapture]);
|
||||
|
||||
/**
|
||||
* Rendu de l'état de capture
|
||||
*/
|
||||
const renderCaptureState = () => {
|
||||
if (captureState.isCapturing) {
|
||||
return (
|
||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||
<CircularProgress size={48} sx={{ mb: 2 }} />
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Capture d'écran en cours...
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Veuillez patienter pendant la capture de votre écran
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (captureState.error) {
|
||||
return (
|
||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
<Typography variant="body2">
|
||||
Erreur de capture: {captureState.error.message}
|
||||
</Typography>
|
||||
</Alert>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={startCapture}
|
||||
>
|
||||
Réessayer
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!captureState.screenshot) {
|
||||
return (
|
||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
startIcon={<CameraIcon />}
|
||||
onClick={startCapture}
|
||||
>
|
||||
Capturer l'écran
|
||||
</Button>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
|
||||
Cliquez pour prendre une capture d'écran et sélectionner un élément
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Rendu de l'interface de sélection
|
||||
*/
|
||||
const renderSelectionInterface = () => {
|
||||
if (!captureState.screenshot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
{/* Instructions */}
|
||||
<Alert
|
||||
severity={captureState.isSelecting ? "info" : "success"}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
<Typography variant="body2">
|
||||
{captureState.isSelecting
|
||||
? "Cliquez sur l'élément que vous souhaitez sélectionner"
|
||||
: "Élément sélectionné ! Confirmez votre choix ou recommencez."
|
||||
}
|
||||
</Typography>
|
||||
</Alert>
|
||||
|
||||
{/* Image de capture avec sélection */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
border: '2px solid',
|
||||
borderColor: captureState.isSelecting ? 'primary.main' : 'success.main',
|
||||
borderRadius: 1,
|
||||
overflow: 'hidden',
|
||||
cursor: captureState.isSelecting ? 'crosshair' : 'default'
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={captureState.screenshot}
|
||||
alt="Capture d'écran pour sélection"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
maxHeight: '60vh',
|
||||
objectFit: 'contain'
|
||||
}}
|
||||
onClick={handleElementSelection}
|
||||
/>
|
||||
|
||||
{/* Indicateur de sélection */}
|
||||
{captureState.selectedElement && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: `${captureState.selectedElement.boundingBox.x * 100}%`,
|
||||
top: `${captureState.selectedElement.boundingBox.y * 100}%`,
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'success.main',
|
||||
border: '3px solid white',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
boxShadow: 2,
|
||||
zIndex: 1
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Informations de sélection */}
|
||||
{captureState.selectedElement && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Coordonnées sélectionnées:
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
X: {(captureState.selectedElement.boundingBox.x * 100).toFixed(1)}%,
|
||||
Y: {(captureState.selectedElement.boundingBox.y * 100).toFixed(1)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onClose={handleClose}
|
||||
maxWidth="lg"
|
||||
fullWidth
|
||||
className={className}
|
||||
PaperProps={{
|
||||
sx: { minHeight: '60vh' }
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<SelectIcon sx={{ mr: 1 }} />
|
||||
<Typography variant="h6">
|
||||
Sélection visuelle d'élément
|
||||
</Typography>
|
||||
</Box>
|
||||
<IconButton onClick={handleClose} size="small">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent>
|
||||
{renderCaptureState()}
|
||||
{renderSelectionInterface()}
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button onClick={handleClose} color="inherit">
|
||||
Annuler
|
||||
</Button>
|
||||
|
||||
{captureState.screenshot && (
|
||||
<Tooltip title="Prendre une nouvelle capture">
|
||||
<Button
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={restartCapture}
|
||||
color="primary"
|
||||
>
|
||||
Nouvelle capture
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{captureState.selectedElement && (
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={confirmSelection}
|
||||
startIcon={<SelectIcon />}
|
||||
>
|
||||
Confirmer la sélection
|
||||
</Button>
|
||||
)}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Export du composant mémorisé
|
||||
*/
|
||||
export default memo(RealScreenCapture, (prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.isOpen === nextProps.isOpen &&
|
||||
prevProps.stepId === nextProps.stepId
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Composant de Test - Chargement du Catalogue VWB
|
||||
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
|
||||
*
|
||||
* Composant de test pour diagnostiquer le chargement du catalogue d'actions VisionOnly
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Box, Typography, Alert, CircularProgress, List, ListItem, ListItemText, Chip } from '@mui/material';
|
||||
import { catalogService } from '../services/catalogService';
|
||||
import { useCatalogActions } from '../hooks/useCatalogActions';
|
||||
import { VWBCatalogAction } from '../types/catalog';
|
||||
|
||||
const TestCatalogLoader: React.FC = () => {
|
||||
const [directServiceTest, setDirectServiceTest] = useState<{
|
||||
loading: boolean;
|
||||
actions: VWBCatalogAction[];
|
||||
error: string | null;
|
||||
}>({
|
||||
loading: true,
|
||||
actions: [],
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Test direct du service
|
||||
useEffect(() => {
|
||||
const testDirectService = async () => {
|
||||
try {
|
||||
console.log('🧪 Test direct du catalogService...');
|
||||
const result = await catalogService.getActions();
|
||||
console.log('✅ Service direct réussi:', result);
|
||||
|
||||
setDirectServiceTest({
|
||||
loading: false,
|
||||
actions: result.actions as VWBCatalogAction[],
|
||||
error: null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('❌ Service direct échoué:', error);
|
||||
setDirectServiceTest({
|
||||
loading: false,
|
||||
actions: [],
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
testDirectService();
|
||||
}, []);
|
||||
|
||||
// Test du hook
|
||||
const {
|
||||
state: hookState,
|
||||
filteredActions: hookActions,
|
||||
stats: hookStats,
|
||||
actions: hookMethods,
|
||||
} = useCatalogActions({
|
||||
autoLoad: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3, maxWidth: 800 }}>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
🧪 Test de Chargement du Catalogue VWB
|
||||
</Typography>
|
||||
|
||||
{/* Test du service direct */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
1. Test Direct du Service catalogService
|
||||
</Typography>
|
||||
|
||||
{directServiceTest.loading ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<CircularProgress size={20} />
|
||||
<Typography>Chargement...</Typography>
|
||||
</Box>
|
||||
) : directServiceTest.error ? (
|
||||
<Alert severity="error">
|
||||
Erreur service direct: {directServiceTest.error}
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert severity="success">
|
||||
Service direct réussi: {directServiceTest.actions.length} actions chargées
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{directServiceTest.actions.length > 0 && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Actions chargées par le service direct:
|
||||
</Typography>
|
||||
<List dense>
|
||||
{directServiceTest.actions.slice(0, 3).map((action) => (
|
||||
<ListItem key={action.id}>
|
||||
<ListItemText
|
||||
primary={`${action.icon} ${action.name}`}
|
||||
secondary={`${action.category} - ${action.description.substring(0, 60)}...`}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Test du hook */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
2. Test du Hook useCatalogActions
|
||||
</Typography>
|
||||
|
||||
{hookState.isLoading ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<CircularProgress size={20} />
|
||||
<Typography>Hook en cours de chargement...</Typography>
|
||||
</Box>
|
||||
) : hookState.error ? (
|
||||
<Alert severity="error">
|
||||
Erreur hook: {hookState.error}
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert severity="success">
|
||||
Hook réussi: {hookState.actions.length} actions, {hookState.categories.length} catégories
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box sx={{ mt: 2, display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Chip label={`Actions: ${hookState.actions.length}`} color="primary" />
|
||||
<Chip label={`Catégories: ${hookState.categories.length}`} color="secondary" />
|
||||
<Chip label={`En ligne: ${hookState.isOnline ? 'Oui' : 'Non'}`} color={hookState.isOnline ? 'success' : 'error'} />
|
||||
<Chip label={`Dernière MAJ: ${hookState.lastUpdate?.toLocaleTimeString() || 'Jamais'}`} />
|
||||
</Box>
|
||||
|
||||
{hookActions.length > 0 && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Actions filtrées par le hook:
|
||||
</Typography>
|
||||
<List dense>
|
||||
{hookActions.slice(0, 3).map((action) => (
|
||||
<ListItem key={action.id}>
|
||||
<ListItemText
|
||||
primary={`${action.icon} ${action.name}`}
|
||||
secondary={`${action.category} - ${action.description.substring(0, 60)}...`}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Statistiques */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
3. Statistiques du Hook
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Chip label={`Total: ${hookStats.totalActions}`} />
|
||||
<Chip label={`Complexité: ${hookStats.averageComplexity}`} />
|
||||
<Chip label={`Statut: ${hookStats.onlineStatus ? 'En ligne' : 'Hors ligne'}`} />
|
||||
</Box>
|
||||
|
||||
{Object.keys(hookStats.actionsByCategory).length > 0 && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Actions par catégorie:
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
{Object.entries(hookStats.actionsByCategory).map(([category, count]) => (
|
||||
<Chip key={category} label={`${category}: ${count}`} variant="outlined" />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Actions du hook */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
4. Actions Disponibles du Hook
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
||||
<button onClick={hookMethods.reload}>🔄 Recharger</button>
|
||||
<button onClick={hookMethods.clearCache}>🗑️ Vider Cache</button>
|
||||
<button onClick={hookMethods.checkHealth}>❤️ Vérifier Santé</button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Diagnostic */}
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
5. Diagnostic
|
||||
</Typography>
|
||||
|
||||
{directServiceTest.actions.length > 0 && hookState.actions.length > 0 ? (
|
||||
<Alert severity="success">
|
||||
✅ Tout fonctionne correctement ! Le service et le hook chargent les actions.
|
||||
Le problème peut être dans l'affichage de la Palette.
|
||||
</Alert>
|
||||
) : directServiceTest.actions.length > 0 && hookState.actions.length === 0 ? (
|
||||
<Alert severity="warning">
|
||||
⚠️ Le service fonctionne mais le hook ne charge pas les actions.
|
||||
Problème dans le hook useCatalogActions.
|
||||
</Alert>
|
||||
) : directServiceTest.actions.length === 0 ? (
|
||||
<Alert severity="error">
|
||||
❌ Le service ne charge pas les actions.
|
||||
Problème de communication avec le backend.
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert severity="info">
|
||||
🔄 Tests en cours...
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default TestCatalogLoader;
|
||||
@@ -0,0 +1,428 @@
|
||||
/**
|
||||
* Composant de Test Properties Panel - Validation de l'affichage des propriétés VWB
|
||||
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
|
||||
*
|
||||
* Ce composant teste l'affichage des propriétés d'étapes VWB pour identifier
|
||||
* et corriger les problèmes d'intégration.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Alert,
|
||||
Divider,
|
||||
Chip,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemIcon,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
PlayArrow as PlayIcon,
|
||||
CheckCircle as CheckIcon,
|
||||
Error as ErrorIcon,
|
||||
Info as InfoIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
// Import des composants à tester
|
||||
import PropertiesPanel from './PropertiesPanel';
|
||||
import { useVWBStepIntegration, useIsVWBStep, useVWBActionId } from '../hooks/useVWBStepIntegration';
|
||||
import { catalogService } from '../services/catalogService';
|
||||
|
||||
// Import des types
|
||||
import { Step, StepExecutionState, Variable, VariableType } from '../types';
|
||||
import { VWBCatalogAction } from '../types/catalog';
|
||||
|
||||
interface TestResult {
|
||||
test: string;
|
||||
success: boolean;
|
||||
message: string;
|
||||
details?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composant de test pour les propriétés VWB
|
||||
*/
|
||||
const TestPropertiesPanel: React.FC = () => {
|
||||
const [testResults, setTestResults] = useState<TestResult[]>([]);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [testStep, setTestStep] = useState<Step | null>(null);
|
||||
const [catalogActions, setCatalogActions] = useState<VWBCatalogAction[]>([]);
|
||||
const [selectedAction, setSelectedAction] = useState<string>('click_anchor');
|
||||
|
||||
// Hooks VWB
|
||||
const { methods: vwbMethods } = useVWBStepIntegration();
|
||||
const isVWBStep = useIsVWBStep(testStep);
|
||||
const vwbActionId = useVWBActionId(testStep);
|
||||
|
||||
// Variables de test
|
||||
const testVariables: Variable[] = [
|
||||
{
|
||||
id: 'var1',
|
||||
name: 'username',
|
||||
value: 'test@example.com',
|
||||
type: 'text' as VariableType,
|
||||
description: 'Nom d\'utilisateur de test',
|
||||
},
|
||||
{
|
||||
id: 'var2',
|
||||
name: 'password',
|
||||
value: '********',
|
||||
type: 'text' as VariableType,
|
||||
description: 'Mot de passe de test',
|
||||
},
|
||||
];
|
||||
|
||||
// Charger les actions du catalogue au démarrage
|
||||
useEffect(() => {
|
||||
const loadCatalogActions = async () => {
|
||||
try {
|
||||
const { actions } = await catalogService.getActions();
|
||||
setCatalogActions(actions);
|
||||
console.log('Actions du catalogue chargées:', actions.length);
|
||||
} catch (error) {
|
||||
console.error('Erreur chargement catalogue:', error);
|
||||
}
|
||||
};
|
||||
|
||||
loadCatalogActions();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Créer une étape VWB de test
|
||||
*/
|
||||
const createTestVWBStep = async (actionId: string): Promise<Step | null> => {
|
||||
try {
|
||||
// Utiliser le hook d'intégration pour créer l'étape
|
||||
const step = await vwbMethods.createVWBStep(actionId, { x: 100, y: 100 });
|
||||
|
||||
if (step) {
|
||||
console.log('Étape VWB créée:', step);
|
||||
return step;
|
||||
} else {
|
||||
throw new Error('Impossible de créer l\'étape VWB');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur création étape VWB:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Exécuter tous les tests
|
||||
*/
|
||||
const runAllTests = async () => {
|
||||
setIsRunning(true);
|
||||
setTestResults([]);
|
||||
|
||||
const results: TestResult[] = [];
|
||||
|
||||
try {
|
||||
// Test 1: Chargement du catalogue
|
||||
results.push({
|
||||
test: 'Chargement du catalogue',
|
||||
success: catalogActions.length > 0,
|
||||
message: catalogActions.length > 0
|
||||
? `${catalogActions.length} actions chargées`
|
||||
: 'Aucune action chargée',
|
||||
details: { actionCount: catalogActions.length }
|
||||
});
|
||||
|
||||
// Test 2: Création d'étape VWB
|
||||
const testStep = await createTestVWBStep(selectedAction);
|
||||
const stepCreated = testStep !== null;
|
||||
|
||||
results.push({
|
||||
test: 'Création d\'étape VWB',
|
||||
success: stepCreated,
|
||||
message: stepCreated
|
||||
? `Étape ${selectedAction} créée avec succès`
|
||||
: `Échec création étape ${selectedAction}`,
|
||||
details: testStep
|
||||
});
|
||||
|
||||
if (stepCreated && testStep) {
|
||||
setTestStep(testStep);
|
||||
|
||||
// Test 3: Détection VWB
|
||||
const isDetectedAsVWB = testStep.data.isVWBCatalogAction === true;
|
||||
results.push({
|
||||
test: 'Détection étape VWB',
|
||||
success: isDetectedAsVWB,
|
||||
message: isDetectedAsVWB
|
||||
? 'Étape correctement détectée comme VWB'
|
||||
: 'Étape non détectée comme VWB',
|
||||
details: {
|
||||
isVWBCatalogAction: testStep.data.isVWBCatalogAction,
|
||||
vwbActionId: testStep.data.vwbActionId
|
||||
}
|
||||
});
|
||||
|
||||
// Test 4: Hook de détection
|
||||
const hookDetection = useIsVWBStep(testStep);
|
||||
results.push({
|
||||
test: 'Hook de détection VWB',
|
||||
success: hookDetection,
|
||||
message: hookDetection
|
||||
? 'Hook useIsVWBStep fonctionne'
|
||||
: 'Hook useIsVWBStep défaillant',
|
||||
details: { hookResult: hookDetection }
|
||||
});
|
||||
|
||||
// Test 5: Récupération ID action
|
||||
const actionIdFromHook = useVWBActionId(testStep);
|
||||
results.push({
|
||||
test: 'Récupération ID action VWB',
|
||||
success: actionIdFromHook === selectedAction,
|
||||
message: actionIdFromHook === selectedAction
|
||||
? `ID action correct: ${actionIdFromHook}`
|
||||
: `ID action incorrect: ${actionIdFromHook} (attendu: ${selectedAction})`,
|
||||
details: {
|
||||
expected: selectedAction,
|
||||
actual: actionIdFromHook
|
||||
}
|
||||
});
|
||||
|
||||
// Test 6: Chargement détails action
|
||||
try {
|
||||
const actionDetails = await catalogService.getActionDetails(selectedAction);
|
||||
const actionLoaded = actionDetails !== null;
|
||||
|
||||
results.push({
|
||||
test: 'Chargement détails action',
|
||||
success: actionLoaded,
|
||||
message: actionLoaded
|
||||
? `Détails action ${selectedAction} chargés`
|
||||
: `Échec chargement détails ${selectedAction}`,
|
||||
details: actionDetails
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
test: 'Chargement détails action',
|
||||
success: false,
|
||||
message: `Erreur: ${error instanceof Error ? error.message : 'Erreur inconnue'}`,
|
||||
details: { error }
|
||||
});
|
||||
}
|
||||
|
||||
// Test 7: Validation étape
|
||||
try {
|
||||
const isValid = await vwbMethods.validateVWBStep(testStep);
|
||||
results.push({
|
||||
test: 'Validation étape VWB',
|
||||
success: true, // Le test réussit si la validation s'exécute
|
||||
message: isValid
|
||||
? 'Étape valide'
|
||||
: 'Étape invalide (normal pour étape vide)',
|
||||
details: { isValid }
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
test: 'Validation étape VWB',
|
||||
success: false,
|
||||
message: `Erreur validation: ${error instanceof Error ? error.message : 'Erreur inconnue'}`,
|
||||
details: { error }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
results.push({
|
||||
test: 'Erreur générale',
|
||||
success: false,
|
||||
message: `Erreur: ${error instanceof Error ? error.message : 'Erreur inconnue'}`,
|
||||
details: { error }
|
||||
});
|
||||
}
|
||||
|
||||
setTestResults(results);
|
||||
setIsRunning(false);
|
||||
};
|
||||
|
||||
/**
|
||||
* Gestionnaire de changement de paramètre (pour le test)
|
||||
*/
|
||||
const handleParameterChange = (stepId: string, paramName: string, value: any) => {
|
||||
console.log('Changement paramètre:', { stepId, paramName, value });
|
||||
|
||||
if (testStep && testStep.id === stepId) {
|
||||
const updatedStep = {
|
||||
...testStep,
|
||||
data: {
|
||||
...testStep.data,
|
||||
parameters: {
|
||||
...testStep.data.parameters,
|
||||
[paramName]: value
|
||||
}
|
||||
}
|
||||
};
|
||||
setTestStep(updatedStep);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Gestionnaire de sélection visuelle (pour le test)
|
||||
*/
|
||||
const handleVisualSelection = (stepId: string) => {
|
||||
console.log('Sélection visuelle pour étape:', stepId);
|
||||
};
|
||||
|
||||
// Calculer le statut global des tests
|
||||
const allTestsPassed = testResults.length > 0 && testResults.every(r => r.success);
|
||||
const hasFailures = testResults.some(r => !r.success);
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3, maxWidth: 1200, margin: '0 auto' }}>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Test des Propriétés d'Étapes VWB
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
Ce composant teste l'affichage des propriétés d'étapes VWB pour identifier
|
||||
et corriger les problèmes d'intégration.
|
||||
</Typography>
|
||||
|
||||
{/* Contrôles de test */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Configuration du Test
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="body2">Action à tester:</Typography>
|
||||
{catalogActions.map(action => (
|
||||
<Chip
|
||||
key={action.id}
|
||||
label={action.name}
|
||||
variant={selectedAction === action.id ? 'filled' : 'outlined'}
|
||||
onClick={() => setSelectedAction(action.id)}
|
||||
size="small"
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<PlayIcon />}
|
||||
onClick={runAllTests}
|
||||
disabled={isRunning || catalogActions.length === 0}
|
||||
size="large"
|
||||
>
|
||||
{isRunning ? 'Tests en cours...' : 'Exécuter les Tests'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Résultats des tests */}
|
||||
{testResults.length > 0 && (
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Résultats des Tests
|
||||
</Typography>
|
||||
|
||||
{/* Statut global */}
|
||||
<Alert
|
||||
severity={allTestsPassed ? 'success' : hasFailures ? 'error' : 'info'}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
{allTestsPassed
|
||||
? '🎉 Tous les tests sont réussis ! Les propriétés VWB devraient s\'afficher correctement.'
|
||||
: hasFailures
|
||||
? '❌ Certains tests ont échoué. Les propriétés VWB pourraient ne pas s\'afficher.'
|
||||
: 'ℹ️ Tests en cours d\'exécution...'}
|
||||
</Alert>
|
||||
|
||||
{/* Liste des résultats */}
|
||||
<List>
|
||||
{testResults.map((result, index) => (
|
||||
<ListItem key={index}>
|
||||
<ListItemIcon>
|
||||
{result.success ? (
|
||||
<CheckIcon color="success" />
|
||||
) : (
|
||||
<ErrorIcon color="error" />
|
||||
)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={result.test}
|
||||
secondary={result.message}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Aperçu du Properties Panel */}
|
||||
{testStep && (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Aperçu du Properties Panel
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Voici comment le Properties Panel devrait afficher les propriétés de l'étape VWB :
|
||||
</Typography>
|
||||
|
||||
{/* Informations sur l'étape */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Informations sur l'étape :
|
||||
</Typography>
|
||||
<Chip label={`Type: ${testStep.type}`} size="small" sx={{ mr: 1 }} />
|
||||
<Chip label={`VWB: ${isVWBStep ? 'Oui' : 'Non'}`} size="small" sx={{ mr: 1 }} />
|
||||
<Chip label={`Action ID: ${vwbActionId || 'N/A'}`} size="small" />
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
{/* Properties Panel en action */}
|
||||
<Box sx={{ border: '1px solid #e0e0e0', borderRadius: 1 }}>
|
||||
<PropertiesPanel
|
||||
selectedStep={testStep}
|
||||
variables={testVariables}
|
||||
onParameterChange={handleParameterChange}
|
||||
onVisualSelection={handleVisualSelection}
|
||||
/>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Instructions */}
|
||||
<Card sx={{ mt: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Instructions
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" paragraph>
|
||||
1. <strong>Exécuter les tests</strong> : Cliquez sur "Exécuter les Tests" pour vérifier l'intégration VWB
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" paragraph>
|
||||
2. <strong>Vérifier les résultats</strong> : Tous les tests doivent être verts pour un fonctionnement correct
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" paragraph>
|
||||
3. <strong>Tester l'interface</strong> : L'aperçu du Properties Panel montre comment les propriétés s'affichent
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" paragraph>
|
||||
4. <strong>Déboguer si nécessaire</strong> : Si des tests échouent, vérifiez les détails dans la console
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default TestPropertiesPanel;
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Composant Test Intégration VWB - Validation de l'affichage des propriétés
|
||||
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Alert,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemIcon,
|
||||
Divider,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
CheckCircle as CheckIcon,
|
||||
Error as ErrorIcon,
|
||||
PlayArrow as PlayIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
// Import des composants à tester
|
||||
import PropertiesPanel from './PropertiesPanel';
|
||||
import { useVWBStepIntegration, useIsVWBStep, useVWBActionId } from '../hooks/useVWBStepIntegration';
|
||||
import { catalogService } from '../services/catalogService';
|
||||
|
||||
// Import des types
|
||||
import { Step, StepExecutionState, Variable, VariableType } from '../types';
|
||||
|
||||
const VWBIntegrationTest: React.FC = () => {
|
||||
const [testStep, setTestStep] = useState<Step | null>(null);
|
||||
const [testResults, setTestResults] = useState<Array<{test: string, success: boolean, message: string}>>([]);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
|
||||
// Hooks VWB
|
||||
const { methods: vwbMethods } = useVWBStepIntegration();
|
||||
const isVWBStep = useIsVWBStep(testStep);
|
||||
const vwbActionId = useVWBActionId(testStep);
|
||||
|
||||
// Variables de test
|
||||
const testVariables: Variable[] = [
|
||||
{
|
||||
id: 'var1',
|
||||
name: 'test_var',
|
||||
value: 'test_value',
|
||||
type: 'text' as VariableType,
|
||||
description: 'Variable de test',
|
||||
},
|
||||
];
|
||||
|
||||
const runIntegrationTest = async () => {
|
||||
setIsRunning(true);
|
||||
setTestResults([]);
|
||||
|
||||
const results: Array<{test: string, success: boolean, message: string}> = [];
|
||||
|
||||
try {
|
||||
// Test 1: Chargement du catalogue
|
||||
const { actions } = await catalogService.getActions();
|
||||
results.push({
|
||||
test: 'Chargement catalogue',
|
||||
success: actions.length > 0,
|
||||
message: `${actions.length} actions chargées`
|
||||
});
|
||||
|
||||
// Test 2: Création d'étape VWB
|
||||
if (actions.length > 0) {
|
||||
const firstAction = actions[0];
|
||||
const step = await vwbMethods.createVWBStep(firstAction.id, { x: 100, y: 100 });
|
||||
|
||||
if (step) {
|
||||
setTestStep(step);
|
||||
results.push({
|
||||
test: 'Création étape VWB',
|
||||
success: true,
|
||||
message: `Étape ${firstAction.id} créée`
|
||||
});
|
||||
|
||||
// Test 3: Détection VWB
|
||||
const isDetected = step.data.isVWBCatalogAction === true;
|
||||
results.push({
|
||||
test: 'Détection VWB',
|
||||
success: isDetected,
|
||||
message: isDetected ? 'Étape détectée comme VWB' : 'Étape non détectée'
|
||||
});
|
||||
|
||||
// Test 4: Hook de détection
|
||||
const hookResult = useIsVWBStep(step);
|
||||
results.push({
|
||||
test: 'Hook détection',
|
||||
success: hookResult,
|
||||
message: hookResult ? 'Hook fonctionne' : 'Hook défaillant'
|
||||
});
|
||||
|
||||
} else {
|
||||
results.push({
|
||||
test: 'Création étape VWB',
|
||||
success: false,
|
||||
message: 'Échec création étape'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
results.push({
|
||||
test: 'Erreur générale',
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
});
|
||||
}
|
||||
|
||||
setTestResults(results);
|
||||
setIsRunning(false);
|
||||
};
|
||||
|
||||
const handleParameterChange = (stepId: string, paramName: string, value: any) => {
|
||||
console.log('Changement paramètre:', { stepId, paramName, value });
|
||||
};
|
||||
|
||||
const handleVisualSelection = (stepId: string) => {
|
||||
console.log('Sélection visuelle:', stepId);
|
||||
};
|
||||
|
||||
const allTestsPassed = testResults.length > 0 && testResults.every(r => r.success);
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3, maxWidth: 1200, margin: '0 auto' }}>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Test d'Intégration VWB
|
||||
</Typography>
|
||||
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<PlayIcon />}
|
||||
onClick={runIntegrationTest}
|
||||
disabled={isRunning}
|
||||
size="large"
|
||||
>
|
||||
{isRunning ? 'Tests en cours...' : 'Exécuter les Tests'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{testResults.length > 0 && (
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Résultats des Tests
|
||||
</Typography>
|
||||
|
||||
<Alert severity={allTestsPassed ? 'success' : 'error'} sx={{ mb: 2 }}>
|
||||
{allTestsPassed
|
||||
? 'Tous les tests réussis ! L\'intégration VWB fonctionne.'
|
||||
: 'Certains tests ont échoué. Vérifiez l\'intégration.'}
|
||||
</Alert>
|
||||
|
||||
<List>
|
||||
{testResults.map((result, index) => (
|
||||
<ListItem key={index}>
|
||||
<ListItemIcon>
|
||||
{result.success ? (
|
||||
<CheckIcon color="success" />
|
||||
) : (
|
||||
<ErrorIcon color="error" />
|
||||
)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={result.test}
|
||||
secondary={result.message}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{testStep && (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Properties Panel Test
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Étape VWB: {testStep.type} (ID: {testStep.id})
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Détection VWB: {isVWBStep ? 'Oui' : 'Non'}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Action ID: {vwbActionId || 'N/A'}
|
||||
</Typography>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
<Box sx={{ border: '1px solid #e0e0e0', borderRadius: 1 }}>
|
||||
<PropertiesPanel
|
||||
selectedStep={testStep}
|
||||
variables={testVariables}
|
||||
onParameterChange={handleParameterChange}
|
||||
onVisualSelection={handleVisualSelection}
|
||||
/>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default VWBIntegrationTest;
|
||||
@@ -0,0 +1,522 @@
|
||||
/**
|
||||
* Composant Validateur - Validation et feedback visuel pour les workflows
|
||||
* Auteur : Dom, Alice, Kiro - 08 janvier 2026
|
||||
*
|
||||
* Ce composant fournit la validation en temps réel des workflows avec
|
||||
* indicateurs visuels d'erreur, détection de cycles et prévention d'exécution.
|
||||
*/
|
||||
|
||||
import React, { useMemo, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Alert,
|
||||
AlertTitle,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Chip,
|
||||
Typography,
|
||||
Collapse,
|
||||
IconButton,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Error as ErrorIcon,
|
||||
Warning as WarningIcon,
|
||||
Info as InfoIcon,
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
ExpandLess as ExpandLessIcon,
|
||||
Block as BlockIcon,
|
||||
Link as LinkIcon,
|
||||
Loop as LoopIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
// Import des types partagés
|
||||
import {
|
||||
Workflow,
|
||||
Step,
|
||||
WorkflowConnection,
|
||||
ValidationError,
|
||||
Variable,
|
||||
} from '../../types';
|
||||
|
||||
interface ValidatorProps {
|
||||
workflow: Workflow;
|
||||
variables: Variable[];
|
||||
onStepHighlight?: (stepId: string, highlight: boolean) => void;
|
||||
}
|
||||
|
||||
interface ValidationResult {
|
||||
isValid: boolean;
|
||||
errors: ValidationIssue[];
|
||||
warnings: ValidationIssue[];
|
||||
canExecute: boolean;
|
||||
}
|
||||
|
||||
interface ValidationIssue {
|
||||
id: string;
|
||||
type: 'error' | 'warning' | 'info';
|
||||
category: 'missing_parameter' | 'disconnected_step' | 'cycle_detected' | 'invalid_reference' | 'execution_blocked';
|
||||
stepId?: string;
|
||||
message: string;
|
||||
description?: string;
|
||||
severity: 'critical' | 'high' | 'medium' | 'low';
|
||||
}
|
||||
|
||||
// Fonctions utilitaires (déclarées avant leur utilisation)
|
||||
|
||||
// Obtenir les paramètres requis pour un type d'étape
|
||||
const getRequiredParameters = (stepType: string): string[] => {
|
||||
const parameterMap: Record<string, string[]> = {
|
||||
click: ['target'],
|
||||
type: ['target', 'text'],
|
||||
wait: ['duration'],
|
||||
condition: ['condition'],
|
||||
extract: ['target', 'attribute'],
|
||||
navigate: ['url'],
|
||||
scroll: ['direction'],
|
||||
screenshot: [],
|
||||
};
|
||||
|
||||
return parameterMap[stepType] || [];
|
||||
};
|
||||
|
||||
// Extraire les références de variables d'un texte
|
||||
const extractVariableReferences = (text: string): string[] => {
|
||||
const pattern = /\$\{([^}]+)\}/g;
|
||||
const matches: string[] = [];
|
||||
let match;
|
||||
while ((match = pattern.exec(text)) !== null) {
|
||||
matches.push(match[1]);
|
||||
}
|
||||
return matches;
|
||||
};
|
||||
|
||||
// Validation des paramètres d'une étape
|
||||
const validateStepParameters = (step: Step, variables: Variable[]): ValidationIssue[] => {
|
||||
const issues: ValidationIssue[] = [];
|
||||
|
||||
// Récupérer les paramètres requis selon le type d'étape
|
||||
const requiredParams = getRequiredParameters(step.type);
|
||||
|
||||
requiredParams.forEach(paramName => {
|
||||
const paramValue = step.data.parameters?.[paramName];
|
||||
|
||||
if (paramValue === undefined || paramValue === null || paramValue === '') {
|
||||
issues.push({
|
||||
id: `missing_param_${step.id}_${paramName}`,
|
||||
type: 'error',
|
||||
category: 'missing_parameter',
|
||||
stepId: step.id,
|
||||
message: `Paramètre "${paramName}" manquant`,
|
||||
description: `L'étape "${step.name}" nécessite le paramètre "${paramName}"`,
|
||||
severity: 'high',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return issues;
|
||||
};
|
||||
|
||||
// Trouver les étapes déconnectées
|
||||
const findDisconnectedSteps = (steps: Step[], connections: WorkflowConnection[]): string[] => {
|
||||
if (steps.length <= 1) return [];
|
||||
|
||||
const connectedSteps = new Set<string>();
|
||||
|
||||
// Ajouter toutes les étapes connectées
|
||||
connections.forEach(conn => {
|
||||
connectedSteps.add(conn.source);
|
||||
connectedSteps.add(conn.target);
|
||||
});
|
||||
|
||||
// Si aucune connexion, toutes les étapes sauf la première sont déconnectées
|
||||
if (connections.length === 0 && steps.length > 1) {
|
||||
return steps.slice(1).map(step => step.id);
|
||||
}
|
||||
|
||||
// Trouver les étapes non connectées
|
||||
return steps
|
||||
.filter(step => !connectedSteps.has(step.id))
|
||||
.map(step => step.id);
|
||||
};
|
||||
|
||||
// Détecter les cycles dans le workflow
|
||||
const detectCycles = (steps: Step[], connections: WorkflowConnection[]): string[][] => {
|
||||
const cycles: string[][] = [];
|
||||
const visited = new Set<string>();
|
||||
const recursionStack = new Set<string>();
|
||||
|
||||
// Construire le graphe d'adjacence
|
||||
const graph: Record<string, string[]> = {};
|
||||
steps.forEach(step => {
|
||||
graph[step.id] = [];
|
||||
});
|
||||
|
||||
connections.forEach(conn => {
|
||||
if (graph[conn.source]) {
|
||||
graph[conn.source].push(conn.target);
|
||||
}
|
||||
});
|
||||
|
||||
// DFS pour détecter les cycles
|
||||
const dfs = (nodeId: string, path: string[]): void => {
|
||||
if (recursionStack.has(nodeId)) {
|
||||
// Cycle détecté
|
||||
const cycleStart = path.indexOf(nodeId);
|
||||
if (cycleStart >= 0) {
|
||||
cycles.push([...path.slice(cycleStart), nodeId]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (visited.has(nodeId)) return;
|
||||
|
||||
visited.add(nodeId);
|
||||
recursionStack.add(nodeId);
|
||||
path.push(nodeId);
|
||||
|
||||
const neighbors = graph[nodeId] || [];
|
||||
neighbors.forEach(neighbor => {
|
||||
dfs(neighbor, [...path]);
|
||||
});
|
||||
|
||||
recursionStack.delete(nodeId);
|
||||
};
|
||||
|
||||
// Lancer DFS depuis chaque nœud non visité
|
||||
steps.forEach(step => {
|
||||
if (!visited.has(step.id)) {
|
||||
dfs(step.id, []);
|
||||
}
|
||||
});
|
||||
|
||||
return cycles;
|
||||
};
|
||||
|
||||
// Valider les références de variables
|
||||
const validateVariableReferences = (step: Step, variables: Variable[]): ValidationIssue[] => {
|
||||
const issues: ValidationIssue[] = [];
|
||||
const variableNames = new Set(variables.map(v => v.name));
|
||||
|
||||
// Extraire les références de variables des paramètres
|
||||
const parameters = step.data.parameters || {};
|
||||
|
||||
Object.entries(parameters).forEach(([paramName, paramValue]) => {
|
||||
if (typeof paramValue === 'string') {
|
||||
const variableRefs = extractVariableReferences(paramValue);
|
||||
|
||||
variableRefs.forEach(varName => {
|
||||
if (!variableNames.has(varName)) {
|
||||
issues.push({
|
||||
id: `invalid_ref_${step.id}_${paramName}_${varName}`,
|
||||
type: 'error',
|
||||
category: 'invalid_reference',
|
||||
stepId: step.id,
|
||||
message: `Variable "${varName}" non définie`,
|
||||
description: `La variable "${varName}" utilisée dans "${paramName}" n'existe pas`,
|
||||
severity: 'high',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return issues;
|
||||
};
|
||||
|
||||
/**
|
||||
* Composant Validateur
|
||||
*/
|
||||
const Validator: React.FC<ValidatorProps> = ({
|
||||
workflow,
|
||||
variables,
|
||||
onStepHighlight,
|
||||
}) => {
|
||||
const [expandedSections, setExpandedSections] = React.useState<Set<string>>(new Set(['errors']));
|
||||
|
||||
// Gestionnaire d'événements clavier pour le validateur
|
||||
const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
|
||||
switch (event.key) {
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
// Activer l'élément focalisé
|
||||
if (event.target instanceof HTMLElement && event.target.getAttribute('role') === 'button') {
|
||||
event.preventDefault();
|
||||
event.target.click();
|
||||
}
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
case 'ArrowDown':
|
||||
// Navigation dans la liste des erreurs
|
||||
event.preventDefault();
|
||||
break;
|
||||
case 'Escape':
|
||||
// Fermer toutes les sections étendues
|
||||
event.preventDefault();
|
||||
setExpandedSections(new Set());
|
||||
break;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Validation complète du workflow
|
||||
const validationResult = useMemo((): ValidationResult => {
|
||||
const errors: ValidationIssue[] = [];
|
||||
const warnings: ValidationIssue[] = [];
|
||||
|
||||
// 1. Validation des paramètres manquants
|
||||
workflow.steps.forEach(step => {
|
||||
const stepErrors = validateStepParameters(step, variables);
|
||||
errors.push(...stepErrors);
|
||||
});
|
||||
|
||||
// 2. Détection des étapes déconnectées
|
||||
const disconnectedSteps = findDisconnectedSteps(workflow.steps, workflow.connections);
|
||||
disconnectedSteps.forEach(stepId => {
|
||||
warnings.push({
|
||||
id: `disconnected_${stepId}`,
|
||||
type: 'warning',
|
||||
category: 'disconnected_step',
|
||||
stepId,
|
||||
message: 'Étape déconnectée',
|
||||
description: 'Cette étape n\'est pas connectée au flux principal du workflow',
|
||||
severity: 'medium',
|
||||
});
|
||||
});
|
||||
|
||||
// 3. Détection de cycles
|
||||
const cycles = detectCycles(workflow.steps, workflow.connections);
|
||||
cycles.forEach((cycle, index) => {
|
||||
errors.push({
|
||||
id: `cycle_${index}`,
|
||||
type: 'error',
|
||||
category: 'cycle_detected',
|
||||
message: 'Cycle détecté dans le workflow',
|
||||
description: `Cycle impliquant les étapes: ${cycle.join(' → ')}`,
|
||||
severity: 'critical',
|
||||
});
|
||||
});
|
||||
|
||||
// 4. Validation des références de variables
|
||||
workflow.steps.forEach(step => {
|
||||
const referenceErrors = validateVariableReferences(step, variables);
|
||||
errors.push(...referenceErrors);
|
||||
});
|
||||
|
||||
// 5. Vérification de la possibilité d'exécution
|
||||
const canExecute = errors.filter(e => e.severity === 'critical').length === 0;
|
||||
if (!canExecute) {
|
||||
errors.push({
|
||||
id: 'execution_blocked',
|
||||
type: 'error',
|
||||
category: 'execution_blocked',
|
||||
message: 'Exécution bloquée',
|
||||
description: 'Des erreurs critiques empêchent l\'exécution du workflow',
|
||||
severity: 'critical',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0 && warnings.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
canExecute,
|
||||
};
|
||||
}, [workflow, variables]);
|
||||
|
||||
// Gestionnaire de basculement de section
|
||||
const toggleSection = (section: string) => {
|
||||
setExpandedSections(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(section)) {
|
||||
newSet.delete(section);
|
||||
} else {
|
||||
newSet.add(section);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
// Gestionnaire de survol d'étape
|
||||
const handleStepHover = (stepId: string | undefined, highlight: boolean) => {
|
||||
if (stepId && onStepHighlight) {
|
||||
onStepHighlight(stepId, highlight);
|
||||
}
|
||||
};
|
||||
|
||||
// Rendu d'une issue de validation
|
||||
const renderValidationIssue = (issue: ValidationIssue) => {
|
||||
const getIcon = () => {
|
||||
switch (issue.category) {
|
||||
case 'missing_parameter':
|
||||
return <ErrorIcon color="error" />;
|
||||
case 'disconnected_step':
|
||||
return <LinkIcon color="warning" />;
|
||||
case 'cycle_detected':
|
||||
return <LoopIcon color="error" />;
|
||||
case 'invalid_reference':
|
||||
return <ErrorIcon color="error" />;
|
||||
case 'execution_blocked':
|
||||
return <BlockIcon color="error" />;
|
||||
default:
|
||||
return <InfoIcon />;
|
||||
}
|
||||
};
|
||||
|
||||
const getSeverityColor = () => {
|
||||
switch (issue.severity) {
|
||||
case 'critical':
|
||||
return 'error';
|
||||
case 'high':
|
||||
return 'error';
|
||||
case 'medium':
|
||||
return 'warning';
|
||||
case 'low':
|
||||
return 'info';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={issue.id}
|
||||
onMouseEnter={() => handleStepHover(issue.stepId, true)}
|
||||
onMouseLeave={() => handleStepHover(issue.stepId, false)}
|
||||
sx={{
|
||||
border: '1px solid',
|
||||
borderColor: issue.type === 'error' ? 'error.light' : 'warning.light',
|
||||
borderRadius: 1,
|
||||
mb: 1,
|
||||
backgroundColor: issue.type === 'error' ? 'error.light' : 'warning.light',
|
||||
'&:hover': {
|
||||
backgroundColor: issue.type === 'error' ? 'error.main' : 'warning.main',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
{getIcon()}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="subtitle2">{issue.message}</Typography>
|
||||
<Chip
|
||||
label={issue.severity}
|
||||
size="small"
|
||||
color={getSeverityColor() as any}
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
secondary={issue.description}
|
||||
/>
|
||||
</ListItem>
|
||||
);
|
||||
};
|
||||
|
||||
if (validationResult.isValid) {
|
||||
return (
|
||||
<Alert severity="success" sx={{ mb: 2 }}>
|
||||
<AlertTitle>Workflow valide</AlertTitle>
|
||||
Aucun problème détecté. Le workflow est prêt à être exécuté.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
role="region"
|
||||
aria-label="Validation du workflow"
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={0}
|
||||
>
|
||||
{/* Résumé de validation */}
|
||||
<Alert
|
||||
severity={validationResult.canExecute ? 'warning' : 'error'}
|
||||
sx={{ mb: 2 }}
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
<AlertTitle>
|
||||
{validationResult.canExecute ? 'Avertissements détectés' : 'Erreurs critiques détectées'}
|
||||
</AlertTitle>
|
||||
{validationResult.errors.length > 0 && (
|
||||
<Typography variant="body2">
|
||||
{validationResult.errors.length} erreur(s) trouvée(s)
|
||||
</Typography>
|
||||
)}
|
||||
{validationResult.warnings.length > 0 && (
|
||||
<Typography variant="body2">
|
||||
{validationResult.warnings.length} avertissement(s) trouvé(s)
|
||||
</Typography>
|
||||
)}
|
||||
{!validationResult.canExecute && (
|
||||
<Typography variant="body2" fontWeight="bold">
|
||||
L'exécution est bloquée jusqu'à la résolution des erreurs critiques.
|
||||
</Typography>
|
||||
)}
|
||||
</Alert>
|
||||
|
||||
{/* Section des erreurs */}
|
||||
{validationResult.errors.length > 0 && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
mb: 1,
|
||||
}}
|
||||
onClick={() => toggleSection('errors')}
|
||||
role="button"
|
||||
aria-expanded={expandedSections.has('errors')}
|
||||
aria-controls="errors-list"
|
||||
tabIndex={0}
|
||||
>
|
||||
<IconButton size="small" aria-hidden="true">
|
||||
{expandedSections.has('errors') ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
||||
</IconButton>
|
||||
<Typography variant="h6" color="error">
|
||||
Erreurs ({validationResult.errors.length})
|
||||
</Typography>
|
||||
</Box>
|
||||
<Collapse in={expandedSections.has('errors')}>
|
||||
<List id="errors-list" role="list" aria-label="Liste des erreurs de validation">
|
||||
{validationResult.errors.map(renderValidationIssue)}
|
||||
</List>
|
||||
</Collapse>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Section des avertissements */}
|
||||
{validationResult.warnings.length > 0 && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
mb: 1,
|
||||
}}
|
||||
onClick={() => toggleSection('warnings')}
|
||||
>
|
||||
<IconButton size="small">
|
||||
{expandedSections.has('warnings') ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
||||
</IconButton>
|
||||
<Typography variant="h6" color="warning.main">
|
||||
Avertissements ({validationResult.warnings.length})
|
||||
</Typography>
|
||||
</Box>
|
||||
<Collapse in={expandedSections.has('warnings')}>
|
||||
<List>
|
||||
{validationResult.warnings.map(renderValidationIssue)}
|
||||
</List>
|
||||
</Collapse>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Validator;
|
||||
@@ -0,0 +1,545 @@
|
||||
/**
|
||||
* Composant Gestionnaire de Variables - Gestion CRUD des variables de workflow
|
||||
* Auteur : Dom, Alice, Kiro - 08 janvier 2026
|
||||
*
|
||||
* Ce composant permet de créer, modifier et supprimer des variables,
|
||||
* avec validation d'unicité des noms et support de différents types.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useMemo, memo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemSecondaryAction,
|
||||
IconButton,
|
||||
Alert,
|
||||
Chip,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Add as AddIcon,
|
||||
Edit as EditIcon,
|
||||
Delete as DeleteIcon,
|
||||
Close as CloseIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
// Import des types partagés
|
||||
import {
|
||||
VariableManagerProps,
|
||||
Variable,
|
||||
VariableType,
|
||||
VariableTypeEnum,
|
||||
} from '../../types';
|
||||
|
||||
// Import du hook de debouncing et du client API
|
||||
import { useDebounce } from '../../hooks/useDebounce';
|
||||
import { useApiClient } from '../../hooks/useApiClient';
|
||||
import { apiClient } from '../../services/apiClient';
|
||||
|
||||
// Labels français pour les types
|
||||
const typeLabels: Record<VariableType, string> = {
|
||||
text: 'Texte',
|
||||
number: 'Nombre',
|
||||
boolean: 'Booléen',
|
||||
list: 'Liste',
|
||||
};
|
||||
|
||||
/**
|
||||
* Composant Gestionnaire de Variables
|
||||
*/
|
||||
const VariableManager: React.FC<VariableManagerProps> = ({
|
||||
variables,
|
||||
onVariableCreate,
|
||||
onVariableUpdate,
|
||||
onVariableDelete,
|
||||
}) => {
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [editingVariable, setEditingVariable] = useState<Variable | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
type: 'text' as VariableType,
|
||||
defaultValue: '',
|
||||
description: '',
|
||||
});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// Utilisation du client API pour la validation des variables
|
||||
const apiValidation = useApiClient({
|
||||
onError: (error) => {
|
||||
console.error('Erreur de validation des variables:', error);
|
||||
setErrors(prev => ({ ...prev, api: error.message }));
|
||||
},
|
||||
});
|
||||
|
||||
// Debouncing de la recherche pour optimiser les performances
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, 300);
|
||||
|
||||
// Mémoriser les variables triées et filtrées pour éviter les re-calculs
|
||||
const filteredAndSortedVariables = useMemo(() => {
|
||||
let filtered = variables;
|
||||
|
||||
// Filtrer selon la recherche débouncée
|
||||
if (debouncedSearchQuery.trim()) {
|
||||
filtered = variables.filter(variable =>
|
||||
variable.name.toLowerCase().includes(debouncedSearchQuery.toLowerCase()) ||
|
||||
(variable.description && variable.description.toLowerCase().includes(debouncedSearchQuery.toLowerCase()))
|
||||
);
|
||||
}
|
||||
|
||||
// Trier par nom
|
||||
return filtered.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [variables, debouncedSearchQuery]);
|
||||
|
||||
// Mémoriser les statistiques des variables
|
||||
const variableStats = useMemo(() => {
|
||||
const stats = {
|
||||
total: variables.length,
|
||||
byType: {} as Record<VariableType, number>,
|
||||
};
|
||||
|
||||
variables.forEach(variable => {
|
||||
stats.byType[variable.type] = (stats.byType[variable.type] || 0) + 1;
|
||||
});
|
||||
|
||||
return stats;
|
||||
}, [variables]);
|
||||
|
||||
// Gestionnaire d'événements clavier pour le gestionnaire de variables
|
||||
const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
|
||||
switch (event.key) {
|
||||
case 'Enter':
|
||||
// Activer l'élément focalisé
|
||||
if (event.target instanceof HTMLButtonElement) {
|
||||
event.preventDefault();
|
||||
event.target.click();
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
// Fermer les dialogues ouverts
|
||||
event.preventDefault();
|
||||
if (isDialogOpen) {
|
||||
handleCloseDialog();
|
||||
}
|
||||
break;
|
||||
case 'n':
|
||||
// Raccourci pour nouvelle variable (Ctrl+N)
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
event.preventDefault();
|
||||
handleCreateNew();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}, [isDialogOpen]);
|
||||
|
||||
// Validation des variables avec l'API
|
||||
const validateVariableWithApi = useCallback(async (variableData: Partial<Variable>) => {
|
||||
try {
|
||||
// Simuler une validation côté client qui pourrait utiliser l'API
|
||||
const validationErrors: Record<string, string> = {};
|
||||
|
||||
// Validation du nom
|
||||
if (!variableData.name || variableData.name.trim().length === 0) {
|
||||
validationErrors.name = 'Le nom de la variable est obligatoire';
|
||||
} else if (variableData.name.length > 50) {
|
||||
validationErrors.name = 'Le nom ne peut pas dépasser 50 caractères';
|
||||
}
|
||||
|
||||
// Validation de l'unicité du nom
|
||||
const existingVariable = variables.find(v =>
|
||||
v.name === variableData.name &&
|
||||
(!editingVariable || v.id !== editingVariable.id)
|
||||
);
|
||||
if (existingVariable) {
|
||||
validationErrors.name = 'Une variable avec ce nom existe déjà';
|
||||
}
|
||||
|
||||
// Validation du type
|
||||
if (!variableData.type) {
|
||||
validationErrors.type = 'Le type de variable est obligatoire';
|
||||
}
|
||||
|
||||
// Validation de la valeur par défaut selon le type
|
||||
if (variableData.defaultValue && variableData.type) {
|
||||
switch (variableData.type) {
|
||||
case 'number':
|
||||
if (isNaN(Number(variableData.defaultValue))) {
|
||||
validationErrors.defaultValue = 'La valeur par défaut doit être un nombre';
|
||||
}
|
||||
break;
|
||||
case 'boolean':
|
||||
if (!['true', 'false', '1', '0'].includes(variableData.defaultValue.toLowerCase())) {
|
||||
validationErrors.defaultValue = 'La valeur par défaut doit être true/false ou 1/0';
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
setErrors(validationErrors);
|
||||
return Object.keys(validationErrors).length === 0;
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la validation:', error);
|
||||
setErrors({ api: 'Erreur de validation' });
|
||||
return false;
|
||||
}
|
||||
}, [variables, editingVariable]);
|
||||
|
||||
// Ouvrir le dialogue pour créer une nouvelle variable
|
||||
const handleCreateNew = () => {
|
||||
setEditingVariable(null);
|
||||
setFormData({
|
||||
name: '',
|
||||
type: VariableTypeEnum.TEXT,
|
||||
defaultValue: '',
|
||||
description: '',
|
||||
});
|
||||
setErrors({});
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
// Ouvrir le dialogue pour modifier une variable existante
|
||||
const handleEdit = (variable: Variable) => {
|
||||
setEditingVariable(variable);
|
||||
setFormData({
|
||||
name: variable.name,
|
||||
type: variable.type,
|
||||
defaultValue: variable.defaultValue || '',
|
||||
description: variable.description || '',
|
||||
});
|
||||
setErrors({});
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
// Fermer le dialogue
|
||||
const handleCloseDialog = () => {
|
||||
setIsDialogOpen(false);
|
||||
setEditingVariable(null);
|
||||
setFormData({
|
||||
name: '',
|
||||
type: 'text' as VariableType,
|
||||
defaultValue: '',
|
||||
description: '',
|
||||
});
|
||||
setErrors({});
|
||||
};
|
||||
|
||||
// Valider le formulaire
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
// Validation du nom
|
||||
if (!formData.name.trim()) {
|
||||
newErrors.name = 'Le nom de la variable est obligatoire';
|
||||
} else if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(formData.name)) {
|
||||
newErrors.name = 'Le nom doit commencer par une lettre ou _ et contenir uniquement des lettres, chiffres et _';
|
||||
} else {
|
||||
// Vérifier l'unicité du nom
|
||||
const existingVariable = variables.find(
|
||||
v => v.name === formData.name && v.id !== editingVariable?.id
|
||||
);
|
||||
if (existingVariable) {
|
||||
newErrors.name = 'Une variable avec ce nom existe déjà';
|
||||
}
|
||||
}
|
||||
|
||||
// Validation de la valeur par défaut selon le type
|
||||
if (formData.defaultValue) {
|
||||
switch (formData.type) {
|
||||
case 'number':
|
||||
if (isNaN(Number(formData.defaultValue))) {
|
||||
newErrors.defaultValue = 'La valeur par défaut doit être un nombre';
|
||||
}
|
||||
break;
|
||||
case 'boolean':
|
||||
if (!['true', 'false'].includes(formData.defaultValue.toLowerCase())) {
|
||||
newErrors.defaultValue = 'La valeur par défaut doit être "true" ou "false"';
|
||||
}
|
||||
break;
|
||||
case 'list':
|
||||
try {
|
||||
JSON.parse(formData.defaultValue);
|
||||
} catch {
|
||||
newErrors.defaultValue = 'La valeur par défaut doit être un JSON valide pour une liste';
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
// Sauvegarder la variable
|
||||
const handleSave = () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
let processedDefaultValue: any = formData.defaultValue;
|
||||
|
||||
// Traiter la valeur par défaut selon le type
|
||||
if (processedDefaultValue) {
|
||||
switch (formData.type) {
|
||||
case 'number':
|
||||
processedDefaultValue = Number(processedDefaultValue);
|
||||
break;
|
||||
case 'boolean':
|
||||
processedDefaultValue = formData.defaultValue.toLowerCase() === 'true';
|
||||
break;
|
||||
case 'list':
|
||||
try {
|
||||
processedDefaultValue = JSON.parse(formData.defaultValue);
|
||||
} catch {
|
||||
processedDefaultValue = [];
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const variableData = {
|
||||
name: formData.name,
|
||||
type: formData.type,
|
||||
defaultValue: processedDefaultValue || undefined,
|
||||
description: formData.description || undefined,
|
||||
};
|
||||
|
||||
if (editingVariable) {
|
||||
onVariableUpdate(editingVariable.id, variableData);
|
||||
} else {
|
||||
onVariableCreate(variableData);
|
||||
}
|
||||
|
||||
handleCloseDialog();
|
||||
};
|
||||
|
||||
// Supprimer une variable
|
||||
const handleDelete = (variable: Variable) => {
|
||||
if (window.confirm(`Êtes-vous sûr de vouloir supprimer la variable "${variable.name}" ?`)) {
|
||||
onVariableDelete(variable.id);
|
||||
}
|
||||
};
|
||||
|
||||
// Formater la valeur par défaut pour l'affichage
|
||||
const formatDefaultValue = (variable: Variable): string => {
|
||||
if (variable.defaultValue === undefined || variable.defaultValue === null) {
|
||||
return 'Non définie';
|
||||
}
|
||||
|
||||
switch (variable.type) {
|
||||
case 'boolean':
|
||||
return variable.defaultValue ? 'Vrai' : 'Faux';
|
||||
case 'list':
|
||||
return Array.isArray(variable.defaultValue)
|
||||
? `[${variable.defaultValue.length} éléments]`
|
||||
: JSON.stringify(variable.defaultValue);
|
||||
default:
|
||||
return String(variable.defaultValue);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
role="region"
|
||||
aria-label="Gestionnaire de variables du workflow"
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={0}
|
||||
>
|
||||
{/* En-tête avec bouton d'ajout */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6">Variables du workflow</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={handleCreateNew}
|
||||
size="small"
|
||||
aria-label="Créer une nouvelle variable"
|
||||
>
|
||||
Nouvelle variable
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Liste des variables */}
|
||||
{variables.length === 0 ? (
|
||||
<Alert severity="info">
|
||||
Aucune variable définie. Créez des variables pour rendre votre workflow plus flexible.
|
||||
</Alert>
|
||||
) : (
|
||||
<List role="list" aria-label="Liste des variables définies">
|
||||
{variables.map((variable) => (
|
||||
<ListItem
|
||||
key={variable.id}
|
||||
divider
|
||||
sx={{
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 1,
|
||||
mb: 1,
|
||||
backgroundColor: '#fafafa',
|
||||
}}
|
||||
role="listitem"
|
||||
aria-label={`Variable ${variable.name} de type ${typeLabels[variable.type]}`}
|
||||
>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="subtitle2">{variable.name}</Typography>
|
||||
<Chip
|
||||
label={typeLabels[variable.type]}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
secondary={
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Valeur par défaut: {formatDefaultValue(variable)}
|
||||
</Typography>
|
||||
{variable.description && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{variable.description}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<IconButton
|
||||
edge="end"
|
||||
onClick={() => handleEdit(variable)}
|
||||
size="small"
|
||||
sx={{ mr: 1 }}
|
||||
aria-label={`Modifier la variable ${variable.name}`}
|
||||
>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
edge="end"
|
||||
onClick={() => handleDelete(variable)}
|
||||
size="small"
|
||||
color="error"
|
||||
aria-label={`Supprimer la variable ${variable.name}`}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
|
||||
{/* Dialogue de création/modification */}
|
||||
<Dialog
|
||||
open={isDialogOpen}
|
||||
onClose={handleCloseDialog}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
{editingVariable ? 'Modifier la variable' : 'Nouvelle variable'}
|
||||
<IconButton onClick={handleCloseDialog} size="small">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 1 }}>
|
||||
{/* Nom de la variable */}
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Nom de la variable"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
error={!!errors.name}
|
||||
helperText={errors.name || 'Utilisez uniquement des lettres, chiffres et _'}
|
||||
required
|
||||
/>
|
||||
|
||||
{/* Type de la variable */}
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Type de variable</InputLabel>
|
||||
<Select
|
||||
value={formData.type}
|
||||
onChange={(e) => setFormData({ ...formData, type: e.target.value as VariableType })}
|
||||
label="Type de variable"
|
||||
>
|
||||
{Object.entries(typeLabels).map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* Valeur par défaut */}
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Valeur par défaut (optionnelle)"
|
||||
value={formData.defaultValue}
|
||||
onChange={(e) => setFormData({ ...formData, defaultValue: e.target.value })}
|
||||
error={!!errors.defaultValue}
|
||||
helperText={errors.defaultValue || getDefaultValueHelp(formData.type)}
|
||||
multiline={formData.type === 'list'}
|
||||
rows={formData.type === 'list' ? 3 : 1}
|
||||
/>
|
||||
|
||||
{/* Description */}
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Description (optionnelle)"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
multiline
|
||||
rows={2}
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button onClick={handleCloseDialog}>Annuler</Button>
|
||||
<Button onClick={handleSave} variant="contained">
|
||||
{editingVariable ? 'Modifier' : 'Créer'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// Aide contextuelle pour la valeur par défaut
|
||||
const getDefaultValueHelp = (type: VariableType): string => {
|
||||
switch (type) {
|
||||
case 'text':
|
||||
return 'Texte libre';
|
||||
case 'number':
|
||||
return 'Nombre décimal ou entier';
|
||||
case 'boolean':
|
||||
return 'true ou false';
|
||||
case 'list':
|
||||
return 'JSON valide, ex: ["item1", "item2"]';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// Mémorisation du composant VariableManager pour éviter les re-rendus inutiles
|
||||
export default memo(VariableManager, (prevProps, nextProps) => {
|
||||
return (
|
||||
JSON.stringify(prevProps.variables) === JSON.stringify(nextProps.variables) &&
|
||||
prevProps.onVariableCreate === nextProps.onVariableCreate &&
|
||||
prevProps.onVariableUpdate === nextProps.onVariableUpdate &&
|
||||
prevProps.onVariableDelete === nextProps.onVariableDelete
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* Styles pour le Panneau des Propriétés Visuelles
|
||||
*
|
||||
* Suit les guidelines du design system RPA Vision V3:
|
||||
* - Couleurs Material-UI avec thème sombre
|
||||
* - Espacement cohérent (xs: 4px, sm: 8px, md: 12px, lg: 16px, xl: 20px)
|
||||
* - Composants Material-UI avec CSS modules personnalisés
|
||||
*/
|
||||
|
||||
.visual-properties-panel {
|
||||
background: #1e293b; /* Card Background du design system */
|
||||
border-left: 1px solid #334155; /* Border Color */
|
||||
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
background: linear-gradient(135deg, #1976d2 0%, #1565c0 100%); /* Primary Blue */
|
||||
color: #e2e8f0; /* Text Primary */
|
||||
border-bottom: 1px solid #334155;
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
background: #0f172a; /* Dark Background */
|
||||
padding: 20px; /* Component spacing */
|
||||
}
|
||||
|
||||
/* Conteneur de statut de validation */
|
||||
.validation-status-container {
|
||||
background: rgba(30, 41, 59, 0.8) !important; /* Card Background avec transparence */
|
||||
border: 1px solid #334155;
|
||||
border-radius: 12px;
|
||||
backdrop-filter: blur(10px);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.validation-status-container:hover {
|
||||
border-color: #1976d2; /* Primary Blue au survol */
|
||||
box-shadow: 0 4px 12px rgba(25, 118, 210, 0.2);
|
||||
}
|
||||
|
||||
/* Conteneur de capture d'écran */
|
||||
.screenshot-container {
|
||||
background: #1e293b !important;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.screenshot-container:hover {
|
||||
border-color: #1976d2;
|
||||
box-shadow: 0 8px 24px rgba(25, 118, 210, 0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.screenshot-image {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 300px;
|
||||
object-fit: contain;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.screenshot-image:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
/* Placeholder pour capture d'écran */
|
||||
.screenshot-placeholder {
|
||||
background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%) !important;
|
||||
border: 2px dashed #334155;
|
||||
border-radius: 12px;
|
||||
min-height: 200px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.screenshot-placeholder:hover {
|
||||
border-color: #1976d2;
|
||||
background: linear-gradient(135deg, #1e293b 0%, #1976d2 5%, #0f172a 100%) !important;
|
||||
}
|
||||
|
||||
.select-element-button {
|
||||
background: linear-gradient(135deg, #1976d2 0%, #1565c0 100%) !important;
|
||||
color: white !important;
|
||||
padding: 12px 24px !important; /* Input padding étendu */
|
||||
border-radius: 8px !important;
|
||||
font-weight: 600 !important;
|
||||
text-transform: none !important;
|
||||
box-shadow: 0 4px 12px rgba(25, 118, 210, 0.3) !important;
|
||||
transition: all 0.3s ease !important;
|
||||
}
|
||||
|
||||
.select-element-button:hover {
|
||||
background: linear-gradient(135deg, #1565c0 0%, #0d47a1 100%) !important;
|
||||
box-shadow: 0 6px 16px rgba(25, 118, 210, 0.4) !important;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Paramètres visuels */
|
||||
.visual-parameters {
|
||||
background: #1e293b !important;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.visual-parameters .MuiCardContent-root {
|
||||
padding: 20px !important;
|
||||
}
|
||||
|
||||
/* Animations pour les indicateurs de statut */
|
||||
@keyframes pulse-success {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.7);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 10px rgba(34, 197, 94, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-warning {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.7);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 10px rgba(245, 158, 11, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-error {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 10px rgba(239, 68, 68, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Indicateurs de statut avec animations */
|
||||
.status-indicator-success {
|
||||
animation: pulse-success 2s infinite;
|
||||
}
|
||||
|
||||
.status-indicator-warning {
|
||||
animation: pulse-warning 2s infinite;
|
||||
}
|
||||
|
||||
.status-indicator-error {
|
||||
animation: pulse-error 2s infinite;
|
||||
}
|
||||
|
||||
/* Styles pour les alertes de validation */
|
||||
.MuiAlert-root {
|
||||
border-radius: 8px !important;
|
||||
margin-bottom: 8px !important; /* sm spacing */
|
||||
}
|
||||
|
||||
.MuiAlert-standardError {
|
||||
background: rgba(239, 68, 68, 0.1) !important;
|
||||
border: 1px solid rgba(239, 68, 68, 0.3) !important;
|
||||
color: #fecaca !important;
|
||||
}
|
||||
|
||||
.MuiAlert-standardInfo {
|
||||
background: rgba(25, 118, 210, 0.1) !important;
|
||||
border: 1px solid rgba(25, 118, 210, 0.3) !important;
|
||||
color: #bfdbfe !important;
|
||||
}
|
||||
|
||||
.MuiAlert-standardWarning {
|
||||
background: rgba(245, 158, 11, 0.1) !important;
|
||||
border: 1px solid rgba(245, 158, 11, 0.3) !important;
|
||||
color: #fde68a !important;
|
||||
}
|
||||
|
||||
/* Styles pour les chips de confiance */
|
||||
.MuiChip-root {
|
||||
font-weight: 600 !important;
|
||||
border-radius: 6px !important;
|
||||
}
|
||||
|
||||
.MuiChip-colorSuccess {
|
||||
background: rgba(34, 197, 94, 0.2) !important;
|
||||
color: #22c55e !important;
|
||||
border-color: #22c55e !important;
|
||||
}
|
||||
|
||||
.MuiChip-colorWarning {
|
||||
background: rgba(245, 158, 11, 0.2) !important;
|
||||
color: #f59e0b !important;
|
||||
border-color: #f59e0b !important;
|
||||
}
|
||||
|
||||
.MuiChip-colorError {
|
||||
background: rgba(239, 68, 68, 0.2) !important;
|
||||
color: #ef4444 !important;
|
||||
border-color: #ef4444 !important;
|
||||
}
|
||||
|
||||
/* Barre de progression de confiance */
|
||||
.MuiLinearProgress-root {
|
||||
height: 4px !important;
|
||||
border-radius: 2px !important;
|
||||
background: rgba(51, 65, 85, 0.3) !important;
|
||||
}
|
||||
|
||||
.MuiLinearProgress-barColorSuccess {
|
||||
background: linear-gradient(90deg, #22c55e 0%, #16a34a 100%) !important;
|
||||
}
|
||||
|
||||
.MuiLinearProgress-barColorWarning {
|
||||
background: linear-gradient(90deg, #f59e0b 0%, #d97706 100%) !important;
|
||||
}
|
||||
|
||||
.MuiLinearProgress-barColorError {
|
||||
background: linear-gradient(90deg, #ef4444 0%, #dc2626 100%) !important;
|
||||
}
|
||||
|
||||
/* Boutons d'action */
|
||||
.MuiIconButton-root {
|
||||
color: #94a3b8 !important; /* Text Secondary */
|
||||
transition: all 0.3s ease !important;
|
||||
}
|
||||
|
||||
.MuiIconButton-root:hover {
|
||||
color: #1976d2 !important; /* Primary Blue */
|
||||
background: rgba(25, 118, 210, 0.1) !important;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Tooltips */
|
||||
.MuiTooltip-tooltip {
|
||||
background: #1e293b !important;
|
||||
color: #e2e8f0 !important;
|
||||
border: 1px solid #334155 !important;
|
||||
border-radius: 6px !important;
|
||||
font-size: 12px !important;
|
||||
padding: 8px 12px !important;
|
||||
}
|
||||
|
||||
.MuiTooltip-arrow {
|
||||
color: #1e293b !important;
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.visual-properties-panel {
|
||||
width: 100% !important;
|
||||
height: auto !important;
|
||||
border-left: none;
|
||||
border-top: 1px solid #334155;
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
padding: 16px; /* lg spacing pour mobile */
|
||||
}
|
||||
|
||||
.screenshot-image {
|
||||
max-height: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Accessibilité */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.screenshot-container,
|
||||
.screenshot-image,
|
||||
.select-element-button,
|
||||
.MuiIconButton-root {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.status-indicator-success,
|
||||
.status-indicator-warning,
|
||||
.status-indicator-error {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Focus visible pour l'accessibilité */
|
||||
.select-element-button:focus-visible,
|
||||
.MuiIconButton-root:focus-visible {
|
||||
outline: 2px solid #1976d2 !important;
|
||||
outline-offset: 2px !important;
|
||||
}
|
||||
|
||||
/* Thème sombre spécifique */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.visual-properties-panel {
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
background: #020617;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
/**
|
||||
* Composant Panneau de Propriétés Visuelles - Configuration des paramètres d'étapes
|
||||
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
|
||||
*
|
||||
* Panneau des propriétés entièrement visuel pour la configuration des actions RPA.
|
||||
* Supprime complètement les sélecteurs CSS/XPath et utilise uniquement des méthodes visuelles.
|
||||
*
|
||||
* Fonctionnalités:
|
||||
* - Affichage des captures d'écran haute qualité
|
||||
* - Sélection visuelle interactive
|
||||
* - Validation en temps réel
|
||||
* - Métadonnées visuelles enrichies
|
||||
* - Interface sans éléments techniques
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Tooltip,
|
||||
IconButton,
|
||||
Divider,
|
||||
LinearProgress,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
CheckCircle as ValidIcon,
|
||||
Warning as WarningIcon,
|
||||
Error as ErrorIcon,
|
||||
Refresh as RefreshIcon,
|
||||
ZoomIn as ZoomIcon,
|
||||
Info as InfoIcon,
|
||||
Settings as SettingsIcon,
|
||||
PhotoCamera as CameraIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
import { BoundingBox } from '../../types';
|
||||
import InteractivePreviewArea from '../InteractivePreviewArea';
|
||||
import './VisualPropertiesPanel.css';
|
||||
|
||||
interface VisualTarget {
|
||||
screenshot: string;
|
||||
bounding_box: BoundingBox;
|
||||
metadata: {
|
||||
element_type: string;
|
||||
relative_position?: string;
|
||||
text_content?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface VisualNode {
|
||||
id: string;
|
||||
type: string;
|
||||
visualTarget?: VisualTarget;
|
||||
parameters?: Record<string, any>;
|
||||
}
|
||||
|
||||
interface VisualPropertiesPanelProps {
|
||||
node: VisualNode;
|
||||
onNodeUpdate: (nodeId: string, updates: Partial<VisualNode>) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface ValidationStatus {
|
||||
isValid: boolean;
|
||||
confidence: number;
|
||||
lastChecked: Date;
|
||||
issues: string[];
|
||||
suggestions: string[];
|
||||
}
|
||||
|
||||
const VisualPropertiesPanel: React.FC<VisualPropertiesPanelProps> = ({
|
||||
node,
|
||||
onNodeUpdate,
|
||||
onClose,
|
||||
}) => {
|
||||
const [selectorOpen, setSelectorOpen] = useState(false);
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const [validationStatus, setValidationStatus] = useState<ValidationStatus | null>(null);
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
// Validation automatique au chargement
|
||||
useEffect(() => {
|
||||
if (node.visualTarget) {
|
||||
validateTarget();
|
||||
}
|
||||
}, [node.visualTarget]);
|
||||
|
||||
/**
|
||||
* Valide la cible visuelle actuelle
|
||||
*/
|
||||
const validateTarget = useCallback(async () => {
|
||||
if (!node.visualTarget) return;
|
||||
|
||||
setIsValidating(true);
|
||||
|
||||
try {
|
||||
// Simuler la validation (à remplacer par l'API réelle)
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
|
||||
// Simuler un résultat de validation
|
||||
const mockValidation: ValidationStatus = {
|
||||
isValid: Math.random() > 0.3, // 70% de chance d'être valide
|
||||
confidence: 0.85 + Math.random() * 0.15, // Entre 0.85 et 1.0
|
||||
lastChecked: new Date(),
|
||||
issues: Math.random() > 0.7 ? ['Élément légèrement déplacé'] : [],
|
||||
suggestions: Math.random() > 0.5 ? ['Mettre à jour la capture'] : []
|
||||
};
|
||||
|
||||
setValidationStatus(mockValidation);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la validation:', error);
|
||||
setValidationStatus({
|
||||
isValid: false,
|
||||
confidence: 0,
|
||||
lastChecked: new Date(),
|
||||
issues: ['Erreur de validation'],
|
||||
suggestions: ['Vérifier la connexion']
|
||||
});
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
}, [node.visualTarget]);
|
||||
|
||||
/**
|
||||
* Gère la sélection d'un nouvel élément
|
||||
*/
|
||||
const handleElementSelected = useCallback((target: VisualTarget) => {
|
||||
onNodeUpdate(node.id, {
|
||||
visualTarget: target
|
||||
});
|
||||
setSelectorOpen(false);
|
||||
|
||||
// Valider immédiatement le nouvel élément
|
||||
setTimeout(validateTarget, 500);
|
||||
}, [node.id, onNodeUpdate, validateTarget]);
|
||||
|
||||
/**
|
||||
* Ouvre le sélecteur visuel
|
||||
*/
|
||||
const openVisualSelector = useCallback(() => {
|
||||
// Simuler l'ouverture du sélecteur visuel
|
||||
console.log('Ouverture du sélecteur visuel');
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Ouvre l'aperçu interactif
|
||||
*/
|
||||
const openPreview = useCallback(() => {
|
||||
if (node.visualTarget?.screenshot) {
|
||||
setPreviewOpen(true);
|
||||
}
|
||||
}, [node.visualTarget]);
|
||||
|
||||
/**
|
||||
* Met à jour les paramètres du nœud
|
||||
*/
|
||||
const updateNodeParameter = useCallback((key: string, value: any) => {
|
||||
onNodeUpdate(node.id, {
|
||||
parameters: {
|
||||
...node.parameters,
|
||||
[key]: value
|
||||
}
|
||||
});
|
||||
}, [node.id, node.parameters, onNodeUpdate]);
|
||||
|
||||
/**
|
||||
* Obtient l'icône de statut de validation
|
||||
*/
|
||||
const getValidationIcon = () => {
|
||||
if (isValidating) {
|
||||
return <CircularProgress size={20} />;
|
||||
}
|
||||
|
||||
if (!validationStatus) {
|
||||
return <InfoIcon color="disabled" />;
|
||||
}
|
||||
|
||||
if (validationStatus.isValid) {
|
||||
return <ValidIcon color="success" />;
|
||||
} else if (validationStatus.issues.length > 0) {
|
||||
return <WarningIcon color="warning" />;
|
||||
} else {
|
||||
return <ErrorIcon color="error" />;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Obtient le message de statut de validation
|
||||
*/
|
||||
const getValidationMessage = () => {
|
||||
if (isValidating) {
|
||||
return "Validation en cours...";
|
||||
}
|
||||
|
||||
if (!validationStatus) {
|
||||
return "Aucune validation effectuée";
|
||||
}
|
||||
|
||||
if (validationStatus.isValid) {
|
||||
return `Élément valide (confiance: ${Math.round(validationStatus.confidence * 100)}%)`;
|
||||
} else {
|
||||
return "Élément non trouvé ou modifié";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Obtient la couleur du statut de validation
|
||||
*/
|
||||
const getValidationColor = (): 'success' | 'warning' | 'error' | 'info' => {
|
||||
if (!validationStatus || isValidating) return 'info';
|
||||
|
||||
if (validationStatus.isValid) {
|
||||
return validationStatus.confidence > 0.9 ? 'success' : 'warning';
|
||||
} else {
|
||||
return 'error';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="visual-properties-panel">
|
||||
{/* En-tête du panneau */}
|
||||
<Box className="panel-header">
|
||||
<Typography variant="h6" className="panel-title">
|
||||
Propriétés Visuelles
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
{node.type || 'Action'} - Configuration 100% visuelle
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Zone de capture principale */}
|
||||
<Card className="capture-section" elevation={2}>
|
||||
<CardContent>
|
||||
<Box className="capture-header">
|
||||
<Typography variant="subtitle1" className="section-title">
|
||||
Élément Cible
|
||||
</Typography>
|
||||
<Box className="capture-actions">
|
||||
<Tooltip title="Nouvelle sélection">
|
||||
<IconButton onClick={openVisualSelector} color="primary">
|
||||
<CameraIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{node.visualTarget?.screenshot && (
|
||||
<Tooltip title="Aperçu agrandi">
|
||||
<IconButton onClick={openPreview} color="primary">
|
||||
<ZoomIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{node.visualTarget ? (
|
||||
<Box className="capture-display">
|
||||
{/* Image de l'élément */}
|
||||
<Box className="screenshot-container">
|
||||
<img
|
||||
src={`data:image/png;base64,${node.visualTarget.screenshot}`}
|
||||
alt="Élément sélectionné"
|
||||
className="screenshot-image"
|
||||
onClick={openPreview}
|
||||
/>
|
||||
<Box className="screenshot-overlay">
|
||||
<Chip
|
||||
label={node.visualTarget.metadata.element_type}
|
||||
size="small"
|
||||
color="primary"
|
||||
className="element-type-chip"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Métadonnées visuelles */}
|
||||
<Box className="metadata-display">
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
Type: {node.visualTarget.metadata.element_type}
|
||||
</Typography>
|
||||
{node.visualTarget.metadata.text_content && (
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
Texte: "{node.visualTarget.metadata.text_content}"
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Box className="no-capture-state">
|
||||
<Typography variant="body2" color="textSecondary" align="center">
|
||||
Aucun élément sélectionné
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<CameraIcon />}
|
||||
onClick={openVisualSelector}
|
||||
className="select-button"
|
||||
>
|
||||
Sélectionner un Élément
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Statut de validation */}
|
||||
{node.visualTarget && (
|
||||
<Card className="validation-section" elevation={1}>
|
||||
<CardContent>
|
||||
<Box className="validation-header">
|
||||
<Box className="validation-status">
|
||||
{getValidationIcon()}
|
||||
<Typography variant="body2" className="validation-text">
|
||||
{getValidationMessage()}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Tooltip title="Revalider">
|
||||
<IconButton
|
||||
onClick={validateTarget}
|
||||
disabled={isValidating}
|
||||
size="small"
|
||||
>
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{validationStatus && (
|
||||
<Box className="validation-details">
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={validationStatus.confidence * 100}
|
||||
color={getValidationColor()}
|
||||
className="confidence-bar"
|
||||
/>
|
||||
|
||||
{validationStatus.issues.length > 0 && (
|
||||
<Alert severity="warning" className="validation-alert">
|
||||
<Typography variant="body2">
|
||||
Problèmes détectés: {validationStatus.issues.join(', ')}
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{validationStatus.suggestions.length > 0 && (
|
||||
<Box className="suggestions">
|
||||
<Typography variant="caption" color="textSecondary">
|
||||
Suggestions: {validationStatus.suggestions.join(', ')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Paramètres de l'action */}
|
||||
<Card className="parameters-section" elevation={1}>
|
||||
<CardContent>
|
||||
<Typography variant="subtitle1" className="section-title">
|
||||
Paramètres de l'Action
|
||||
</Typography>
|
||||
|
||||
{/* Paramètres spécifiques au type d'action */}
|
||||
{node.type === 'click' && (
|
||||
<Box className="parameter-group">
|
||||
<Typography variant="body2" className="parameter-label">
|
||||
Type de clic
|
||||
</Typography>
|
||||
<Box className="parameter-options">
|
||||
{['Simple', 'Double', 'Droit'].map((clickType) => (
|
||||
<Chip
|
||||
key={clickType}
|
||||
label={clickType}
|
||||
variant={node.parameters?.clickType === clickType ? 'filled' : 'outlined'}
|
||||
onClick={() => updateNodeParameter('clickType', clickType)}
|
||||
className="option-chip"
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{node.type === 'input' && (
|
||||
<Box className="parameter-group">
|
||||
<Typography variant="body2" className="parameter-label">
|
||||
Texte à saisir
|
||||
</Typography>
|
||||
<Box className="text-input-container">
|
||||
<input
|
||||
type="text"
|
||||
value={node.parameters?.text || ''}
|
||||
onChange={(e) => updateNodeParameter('text', e.target.value)}
|
||||
placeholder="Entrez le texte à saisir..."
|
||||
className="text-input"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Délai d'attente */}
|
||||
<Box className="parameter-group">
|
||||
<Typography variant="body2" className="parameter-label">
|
||||
Délai d'attente
|
||||
</Typography>
|
||||
<Box className="delay-controls">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="10"
|
||||
step="0.5"
|
||||
value={node.parameters?.delay || 1}
|
||||
onChange={(e) => updateNodeParameter('delay', parseFloat(e.target.value))}
|
||||
className="delay-slider"
|
||||
/>
|
||||
<Typography variant="caption" className="delay-value">
|
||||
{node.parameters?.delay || 1}s
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Options avancées */}
|
||||
<Card className="advanced-section" elevation={1}>
|
||||
<CardContent>
|
||||
<Box
|
||||
className="advanced-header"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
>
|
||||
<Typography variant="subtitle1" className="section-title">
|
||||
Options Avancées
|
||||
</Typography>
|
||||
<IconButton size="small">
|
||||
<SettingsIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{showAdvanced && (
|
||||
<Box className="advanced-options">
|
||||
<Box className="option-row">
|
||||
<Typography variant="body2">Tolérance de position</Typography>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={node.parameters?.positionTolerance || 10}
|
||||
onChange={(e) => updateNodeParameter('positionTolerance', parseInt(e.target.value))}
|
||||
className="tolerance-slider"
|
||||
/>
|
||||
<Typography variant="caption">
|
||||
{node.parameters?.positionTolerance || 10}px
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box className="option-row">
|
||||
<Typography variant="body2">Seuil de confiance</Typography>
|
||||
<input
|
||||
type="range"
|
||||
min="0.5"
|
||||
max="1.0"
|
||||
step="0.05"
|
||||
value={node.parameters?.confidenceThreshold || 0.8}
|
||||
onChange={(e) => updateNodeParameter('confidenceThreshold', parseFloat(e.target.value))}
|
||||
className="confidence-slider"
|
||||
/>
|
||||
<Typography variant="caption">
|
||||
{Math.round((node.parameters?.confidenceThreshold || 0.8) * 100)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Actions du panneau */}
|
||||
<Box className="panel-actions">
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={onClose}
|
||||
className="cancel-button"
|
||||
>
|
||||
Fermer
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={!node.visualTarget}
|
||||
className="save-button"
|
||||
>
|
||||
Appliquer
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Dialogs */}
|
||||
{node.visualTarget && (
|
||||
<InteractivePreviewArea
|
||||
open={previewOpen}
|
||||
onClose={() => setPreviewOpen(false)}
|
||||
screenshot={node.visualTarget.screenshot}
|
||||
boundingBox={node.visualTarget.bounding_box}
|
||||
metadata={node.visualTarget.metadata}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default VisualPropertiesPanel;
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* Visual Screen Selector Styles - RPA Vision V3
|
||||
*
|
||||
* Styles pour le sélecteur d'écran visuel interactif.
|
||||
* Optimisé pour une expérience de sélection fluide et intuitive.
|
||||
*/
|
||||
|
||||
.visual-screen-selector {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
z-index: 9999;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.visual-screen-selector__header {
|
||||
background: #1976d2;
|
||||
color: white;
|
||||
padding: 16px 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.visual-screen-selector__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.visual-screen-selector__controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.visual-screen-selector__content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.visual-screen-selector__canvas-area {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.visual-screen-selector__canvas {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
cursor: crosshair;
|
||||
transition: cursor 0.2s ease;
|
||||
}
|
||||
|
||||
.visual-screen-selector__canvas--pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.visual-screen-selector__sidebar {
|
||||
width: 320px;
|
||||
background: white;
|
||||
border-left: 1px solid #e0e0e0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.visual-screen-selector__sidebar-header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.visual-screen-selector__sidebar-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.visual-screen-selector__element-card {
|
||||
margin-bottom: 12px;
|
||||
padding: 12px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.visual-screen-selector__element-card:hover {
|
||||
border-color: #2196f3;
|
||||
box-shadow: 0 2px 8px rgba(33, 150, 243, 0.2);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.visual-screen-selector__element-card--hovered {
|
||||
border-color: #2196f3;
|
||||
background: #e3f2fd;
|
||||
box-shadow: 0 4px 12px rgba(33, 150, 243, 0.3);
|
||||
}
|
||||
|
||||
.visual-screen-selector__element-card--selected {
|
||||
border-color: #4caf50;
|
||||
background: #e8f5e8;
|
||||
box-shadow: 0 4px 12px rgba(76, 175, 80, 0.3);
|
||||
}
|
||||
|
||||
.visual-screen-selector__element-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.visual-screen-selector__element-text {
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.visual-screen-selector__element-details {
|
||||
font-size: 0.75rem;
|
||||
color: #666;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.visual-screen-selector__status-bar {
|
||||
padding: 16px 24px;
|
||||
background: white;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.visual-screen-selector__instructions {
|
||||
background: #e3f2fd;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
color: #1565c0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.visual-screen-selector__loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.visual-screen-selector__loading-spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 4px solid rgba(255, 255, 255, 0.3);
|
||||
border-top: 4px solid white;
|
||||
border-radius: 50%;
|
||||
animation: visual-screen-selector-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes visual-screen-selector-spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.visual-screen-selector__error {
|
||||
padding: 16px 24px;
|
||||
background: #ffebee;
|
||||
border-left: 4px solid #f44336;
|
||||
color: #c62828;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.visual-screen-selector__success {
|
||||
padding: 16px 24px;
|
||||
background: #e8f5e8;
|
||||
border-left: 4px solid #4caf50;
|
||||
color: #2e7d32;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Animations pour les overlays */
|
||||
.visual-screen-selector__overlay {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
border: 2px solid #ff9800;
|
||||
background: rgba(255, 152, 0, 0.1);
|
||||
transition: all 0.1s ease;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.visual-screen-selector__overlay--hovered {
|
||||
border-color: #2196f3;
|
||||
background: rgba(33, 150, 243, 0.2);
|
||||
border-width: 3px;
|
||||
}
|
||||
|
||||
.visual-screen-selector__overlay--selected {
|
||||
border-color: #4caf50;
|
||||
background: rgba(76, 175, 80, 0.2);
|
||||
border-width: 4px;
|
||||
animation: visual-screen-selector-pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes visual-screen-selector-pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(76, 175, 80, 0.7); }
|
||||
70% { box-shadow: 0 0 0 10px rgba(76, 175, 80, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(76, 175, 80, 0); }
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.visual-screen-selector__sidebar {
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.visual-screen-selector__header {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.visual-screen-selector__title {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.visual-screen-selector__sidebar-content {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.visual-screen-selector__element-card {
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.visual-screen-selector__content {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.visual-screen-selector__sidebar {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
border-left: none;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.visual-screen-selector__canvas-area {
|
||||
height: calc(100vh - 200px - 120px); /* Ajuster selon header + sidebar */
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,7 @@ import { captureLibraryService, SavedCapture } from '../../services/captureLibra
|
||||
// Import des types partagés et du service de capture
|
||||
import { VisualSelection, BoundingBox } from '../../types';
|
||||
import { screenCaptureService } from '../../services/screenCaptureService';
|
||||
import { uploadAnchorImage } from '../../services/anchorImageService';
|
||||
|
||||
interface VisualSelectorProps {
|
||||
isOpen: boolean;
|
||||
@@ -116,6 +117,7 @@ const VisualSelector: React.FC<VisualSelectorProps> = ({
|
||||
const [isCanvasReady, setIsCanvasReady] = useState(false); // Canvas prêt après chargement de l'image
|
||||
const [canvasSize, setCanvasSize] = useState({ width: 1200, height: 800 }); // Taille du canvas pour le style CSS
|
||||
const [imageScale, setImageScale] = useState(1); // Scale appliqué à l'image (pour convertir les coordonnées)
|
||||
const [originalImageSize, setOriginalImageSize] = useState({ width: 0, height: 0 }); // Taille de l'image originale pour le backend
|
||||
|
||||
// États pour la sélection de moniteur
|
||||
const [monitors, setMonitors] = useState<Monitor[]>([]);
|
||||
@@ -135,7 +137,7 @@ const VisualSelector: React.FC<VisualSelectorProps> = ({
|
||||
useEffect(() => {
|
||||
const loadMonitors = async () => {
|
||||
try {
|
||||
const response = await fetch('http://localhost:5002/api/real-demo/capture/status');
|
||||
const response = await fetch('http://localhost:5001/api/real-demo/capture/status');
|
||||
const data = await response.json();
|
||||
if (data.success && data.monitors) {
|
||||
setMonitors(data.monitors);
|
||||
@@ -233,9 +235,11 @@ const VisualSelector: React.FC<VisualSelectorProps> = ({
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
console.log('✅ [VisualSelector] Image dessinée sur le canvas:', canvas.width, 'x', canvas.height, 'scale:', scale);
|
||||
console.log('📐 [VisualSelector] Taille image originale:', img.width, 'x', img.height);
|
||||
|
||||
// IMPORTANT: Stocker le scale pour la conversion des coordonnées
|
||||
// IMPORTANT: Stocker le scale et la taille originale pour la conversion des coordonnées
|
||||
setImageScale(scale);
|
||||
setOriginalImageSize({ width: img.width, height: img.height });
|
||||
|
||||
// IMPORTANT: Mettre à jour le state canvasSize pour que le CSS corresponde
|
||||
// Ceci évite le bug de coordonnées quand scaleX != 1
|
||||
@@ -297,7 +301,7 @@ const VisualSelector: React.FC<VisualSelectorProps> = ({
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
|
||||
// Utiliser l'API de capture réelle avec le moniteur sélectionné
|
||||
const response = await fetch('http://localhost:5002/api/real-demo/capture', {
|
||||
const response = await fetch('http://localhost:5001/api/real-demo/capture', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -463,6 +467,13 @@ const VisualSelector: React.FC<VisualSelectorProps> = ({
|
||||
height: canvasHeight / imageScale,
|
||||
};
|
||||
|
||||
// DIAGNOSTIC: Afficher toutes les informations de debug
|
||||
console.log(`📐 [VisualSelector] DIAGNOSTIC COORDONNÉES:`);
|
||||
console.log(` Canvas dimensions: ${canvas.width}x${canvas.height} (interne)`);
|
||||
console.log(` Canvas CSS rect: ${rect.width.toFixed(0)}x${rect.height.toFixed(0)} (affiché)`);
|
||||
console.log(` CSS→Canvas scale: scaleX=${scaleX.toFixed(3)}, scaleY=${scaleY.toFixed(3)}`);
|
||||
console.log(` Image scale (canvas→original): ${imageScale.toFixed(3)}`);
|
||||
console.log(` Original image size: ${originalImageSize.width}x${originalImageSize.height}`);
|
||||
console.log(`🎯 Sélection finale: canvas=(${canvasX.toFixed(0)}, ${canvasY.toFixed(0)}) ${canvasWidth.toFixed(0)}×${canvasHeight.toFixed(0)}px -> original=(${selectedArea.x.toFixed(0)}, ${selectedArea.y.toFixed(0)}) ${selectedArea.width.toFixed(0)}×${selectedArea.height.toFixed(0)}px [scale: ${imageScale}]`);
|
||||
|
||||
// Valider que la zone sélectionnée a une taille minimale
|
||||
@@ -496,9 +507,6 @@ const VisualSelector: React.FC<VisualSelectorProps> = ({
|
||||
try {
|
||||
console.log('🎯 Création de la sélection visuelle...');
|
||||
|
||||
// Extraire la région sélectionnée de l'image
|
||||
let referenceImage = captureState.screenshot;
|
||||
|
||||
// Essayer de créer un embedding via le service (optionnel)
|
||||
let embeddingData: number[] | undefined;
|
||||
let embeddingId: string | undefined;
|
||||
@@ -513,7 +521,6 @@ const VisualSelector: React.FC<VisualSelectorProps> = ({
|
||||
if (result?.success && result.embedding) {
|
||||
embeddingData = result.embedding;
|
||||
embeddingId = result.embedding_id;
|
||||
referenceImage = result.reference_image || captureState.screenshot;
|
||||
console.log(`✅ Embedding créé - ID: ${embeddingId}`);
|
||||
}
|
||||
} catch (embeddingError) {
|
||||
@@ -521,26 +528,63 @@ const VisualSelector: React.FC<VisualSelectorProps> = ({
|
||||
console.warn('⚠️ Service d\'embedding non disponible, sélection sans embedding');
|
||||
}
|
||||
|
||||
// Créer l'objet VisualSelection (fonctionne avec ou sans embedding)
|
||||
// Upload de l'image sur le serveur pour obtenir des URLs
|
||||
let thumbnailUrl: string | undefined;
|
||||
let originalUrl: string | undefined;
|
||||
let anchorId: string = `visual_${stepId}_${Date.now()}`;
|
||||
|
||||
try {
|
||||
const uploadResult = await uploadAnchorImage(
|
||||
captureState.screenshot,
|
||||
captureState.selectedArea
|
||||
);
|
||||
|
||||
if (uploadResult.success) {
|
||||
anchorId = uploadResult.anchor_id;
|
||||
thumbnailUrl = uploadResult.thumbnail_url;
|
||||
originalUrl = uploadResult.original_url;
|
||||
console.log('✅ Image uploadée sur le serveur:', {
|
||||
anchor_id: anchorId,
|
||||
thumbnail_url: thumbnailUrl,
|
||||
});
|
||||
}
|
||||
} catch (uploadError) {
|
||||
// Si l'upload échoue, on continue avec le base64 en fallback
|
||||
console.warn('⚠️ Upload serveur échoué, utilisation du base64 en fallback:', uploadError);
|
||||
}
|
||||
|
||||
// Créer l'objet VisualSelection
|
||||
const visualSelection: VisualSelection = {
|
||||
id: `visual_${stepId}_${Date.now()}`,
|
||||
screenshot: captureState.screenshot,
|
||||
id: anchorId,
|
||||
// Ne stocker le screenshot en base64 que si l'upload a échoué
|
||||
screenshot: thumbnailUrl ? '' : captureState.screenshot,
|
||||
boundingBox: captureState.selectedArea,
|
||||
embedding: embeddingData,
|
||||
description: `Élément sélectionné pour l'étape ${stepId}`,
|
||||
metadata: {
|
||||
embedding_id: embeddingId,
|
||||
reference_image: referenceImage,
|
||||
// URLs serveur (prioritaires)
|
||||
thumbnail_url: thumbnailUrl,
|
||||
reference_image_url: originalUrl,
|
||||
// Fallback base64 seulement si pas d'URLs
|
||||
reference_image: thumbnailUrl ? undefined : captureState.screenshot,
|
||||
capture_method: 'screen_capture',
|
||||
capture_timestamp: new Date().toISOString(),
|
||||
has_embedding: !!embeddingData,
|
||||
uses_server_storage: !!thumbnailUrl,
|
||||
// IMPORTANT: Résolution de l'image originale pour le calcul des coordonnées côté backend
|
||||
screen_resolution: originalImageSize.width > 0 ? {
|
||||
width: originalImageSize.width,
|
||||
height: originalImageSize.height,
|
||||
} : undefined,
|
||||
},
|
||||
};
|
||||
|
||||
console.log('✅ Sélection visuelle créée:', {
|
||||
id: visualSelection.id,
|
||||
boundingBox: visualSelection.boundingBox,
|
||||
hasEmbedding: !!embeddingData
|
||||
hasEmbedding: !!embeddingData,
|
||||
usesServerStorage: !!thumbnailUrl,
|
||||
});
|
||||
|
||||
onElementSelected(visualSelection);
|
||||
|
||||
@@ -0,0 +1,963 @@
|
||||
/**
|
||||
* Composant Gestionnaire de Workflows - Sauvegarde et chargement des workflows
|
||||
* Auteur : Dom, Alice, Kiro - 08 janvier 2026
|
||||
*
|
||||
* Ce composant gère la sauvegarde, le chargement, la liste et la gestion
|
||||
* des workflows avec intégration Backend_VWB améliorée et gestion robuste des erreurs.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo, memo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Typography,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemSecondaryAction,
|
||||
IconButton,
|
||||
Alert,
|
||||
Chip,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Divider,
|
||||
CircularProgress,
|
||||
Snackbar,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Save as SaveIcon,
|
||||
FolderOpen as LoadIcon,
|
||||
Delete as DeleteIcon,
|
||||
Edit as EditIcon,
|
||||
MoreVert as MoreIcon,
|
||||
Close as CloseIcon,
|
||||
Warning as WarningIcon,
|
||||
History as HistoryIcon,
|
||||
Refresh as RefreshIcon,
|
||||
CloudOff as OfflineIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
// Import des types partagés et du nouveau client API
|
||||
import { Workflow, WorkflowApiData } from '../../types';
|
||||
import { useWorkflowApi } from '../../hooks/useApiClient';
|
||||
import { ApiError } from '../../services/apiClient';
|
||||
// NOTE: Stockage local supprimé - utilisation API uniquement comme source de vérité
|
||||
|
||||
interface WorkflowManagerProps {
|
||||
currentWorkflow: Workflow;
|
||||
onWorkflowLoad: (workflow: Workflow) => void;
|
||||
onWorkflowSave: (workflow: Workflow) => void;
|
||||
}
|
||||
|
||||
interface SavedWorkflow {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
lastModified: Date;
|
||||
version: number;
|
||||
stepCount: number;
|
||||
hasConflicts?: boolean;
|
||||
}
|
||||
|
||||
interface SaveDialogState {
|
||||
isOpen: boolean;
|
||||
workflowName: string;
|
||||
workflowDescription: string;
|
||||
isOverwrite: boolean;
|
||||
existingWorkflowId?: string;
|
||||
}
|
||||
|
||||
interface ConflictResolution {
|
||||
workflowId: string;
|
||||
action: 'overwrite' | 'create_new' | 'merge';
|
||||
newName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composant Gestionnaire de Workflows
|
||||
*/
|
||||
const WorkflowManager: React.FC<WorkflowManagerProps> = ({
|
||||
currentWorkflow,
|
||||
onWorkflowLoad,
|
||||
onWorkflowSave,
|
||||
}) => {
|
||||
// Utilisation du nouveau client API avec gestion d'erreurs
|
||||
// NOTE: Mode hors ligne supprimé - API uniquement comme source de vérité
|
||||
const workflowApi = useWorkflowApi({
|
||||
onError: (error: ApiError) => {
|
||||
console.error('Erreur API Workflow:', error);
|
||||
setSnackbarMessage(`Erreur: ${error.message}`);
|
||||
setSnackbarOpen(true);
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
console.log('Succès API Workflow:', data);
|
||||
},
|
||||
});
|
||||
|
||||
const [savedWorkflows, setSavedWorkflows] = useState<SavedWorkflow[]>([]);
|
||||
const [isLoadDialogOpen, setIsLoadDialogOpen] = useState(false);
|
||||
const [saveDialogState, setSaveDialogState] = useState<SaveDialogState>({
|
||||
isOpen: false,
|
||||
workflowName: '',
|
||||
workflowDescription: '',
|
||||
isOverwrite: false,
|
||||
});
|
||||
const [menuAnchor, setMenuAnchor] = useState<{ element: HTMLElement; workflowId: string } | null>(null);
|
||||
const [conflictDialog, setConflictDialog] = useState<{
|
||||
isOpen: boolean;
|
||||
conflictingWorkflow?: SavedWorkflow;
|
||||
resolution?: ConflictResolution;
|
||||
}>({ isOpen: false });
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
const [pageSize] = useState(50); // Pagination pour les gros workflows
|
||||
const [snackbarOpen, setSnackbarOpen] = useState(false);
|
||||
const [snackbarMessage, setSnackbarMessage] = useState('');
|
||||
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
||||
|
||||
// Optimisations de chargement avec useMemo
|
||||
const sortedWorkflows = useMemo(() => {
|
||||
return [...savedWorkflows].sort((a, b) =>
|
||||
new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime()
|
||||
);
|
||||
}, [savedWorkflows]);
|
||||
|
||||
// Pagination des workflows pour optimiser l'affichage
|
||||
const paginatedWorkflows = useMemo(() => {
|
||||
const start = currentPage * pageSize;
|
||||
const end = start + pageSize;
|
||||
return sortedWorkflows.slice(start, end);
|
||||
}, [sortedWorkflows, currentPage, pageSize]);
|
||||
|
||||
const totalPages = useMemo(() => {
|
||||
return Math.ceil(savedWorkflows.length / pageSize);
|
||||
}, [savedWorkflows.length, pageSize]);
|
||||
|
||||
const workflowStats = useMemo(() => {
|
||||
return {
|
||||
total: savedWorkflows.length,
|
||||
totalSteps: savedWorkflows.reduce((sum, w) => sum + w.stepCount, 0),
|
||||
hasConflicts: savedWorkflows.some(w => w.hasConflicts),
|
||||
recentCount: savedWorkflows.filter(w =>
|
||||
new Date().getTime() - new Date(w.lastModified).getTime() < 24 * 60 * 60 * 1000
|
||||
).length,
|
||||
};
|
||||
}, [savedWorkflows]);
|
||||
|
||||
// États de chargement pour optimiser les performances
|
||||
const loadingStates = useMemo(() => ({
|
||||
hasLoadingStates: true,
|
||||
hasPagination: true, // Pagination activée
|
||||
hasErrorBoundary: true,
|
||||
hasLazyLoading: true,
|
||||
hasInfiniteScroll: false,
|
||||
hasSuspense: false,
|
||||
}), []);
|
||||
|
||||
// Gestionnaire d'événements clavier pour le gestionnaire de workflows
|
||||
const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
|
||||
switch (event.key) {
|
||||
case 'Enter':
|
||||
// Activer l'élément focalisé
|
||||
if (event.target instanceof HTMLButtonElement) {
|
||||
event.preventDefault();
|
||||
event.target.click();
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
// Fermer les dialogues ouverts
|
||||
event.preventDefault();
|
||||
setIsLoadDialogOpen(false);
|
||||
setSaveDialogState(prev => ({ ...prev, isOpen: false }));
|
||||
setConflictDialog({ isOpen: false });
|
||||
setMenuAnchor(null);
|
||||
break;
|
||||
case 's':
|
||||
// Raccourci pour sauvegarder (Ctrl+S)
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
event.preventDefault();
|
||||
handleSaveClick();
|
||||
}
|
||||
break;
|
||||
case 'o':
|
||||
// Raccourci pour ouvrir (Ctrl+O)
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
event.preventDefault();
|
||||
setIsLoadDialogOpen(true);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Charger la liste des workflows depuis le backend (API uniquement)
|
||||
const loadWorkflowList = useCallback(async () => {
|
||||
try {
|
||||
console.log('📡 [WorkflowManager] Chargement des workflows depuis l\'API...');
|
||||
const workflows = await workflowApi.loadWorkflows();
|
||||
|
||||
if (workflows && workflows.length > 0) {
|
||||
const formattedWorkflows: SavedWorkflow[] = workflows.map((wf: any) => ({
|
||||
id: wf.id,
|
||||
name: wf.name,
|
||||
description: wf.description,
|
||||
lastModified: new Date(wf.lastModified || wf.updatedAt || wf.updated_at),
|
||||
version: wf.version || 1,
|
||||
stepCount: wf.stepCount || wf.nodes?.length || wf.steps?.length || 0,
|
||||
hasConflicts: false,
|
||||
}));
|
||||
|
||||
console.log('✅ [WorkflowManager] Workflows chargés:', formattedWorkflows.length);
|
||||
setSavedWorkflows(formattedWorkflows);
|
||||
} else {
|
||||
console.log('ℹ️ [WorkflowManager] Aucun workflow trouvé');
|
||||
setSavedWorkflows([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ [WorkflowManager] Erreur chargement workflows:', error);
|
||||
setSnackbarMessage('Erreur de connexion à l\'API');
|
||||
setSnackbarOpen(true);
|
||||
setSavedWorkflows([]);
|
||||
}
|
||||
}, []); // Pas de dépendances - fonction stable
|
||||
|
||||
// Charger la liste des workflows au montage (une seule fois)
|
||||
useEffect(() => {
|
||||
loadWorkflowList();
|
||||
}, [loadWorkflowList]);
|
||||
|
||||
// Ouvrir le dialogue de sauvegarde
|
||||
const handleSaveClick = () => {
|
||||
setSaveDialogState({
|
||||
isOpen: true,
|
||||
workflowName: currentWorkflow.name,
|
||||
workflowDescription: currentWorkflow.description || '',
|
||||
isOverwrite: false,
|
||||
});
|
||||
};
|
||||
|
||||
// Sauvegarder le workflow avec validation côté client
|
||||
const handleSaveWorkflow = useCallback(async () => {
|
||||
if (!saveDialogState.workflowName.trim()) {
|
||||
setValidationErrors(['Le nom du workflow est obligatoire']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Convertir les steps en format nodes pour le backend
|
||||
const nodes = currentWorkflow.steps.map((step) => ({
|
||||
id: step.id,
|
||||
type: step.type,
|
||||
name: step.name,
|
||||
label: step.name,
|
||||
position: step.position,
|
||||
data: step.data,
|
||||
stepType: step.type,
|
||||
parameters: step.data?.parameters || {},
|
||||
isVWBCatalogAction: step.data?.isVWBCatalogAction || false,
|
||||
vwbActionId: step.data?.vwbActionId,
|
||||
}));
|
||||
|
||||
// Convertir les connections en format edges pour le backend
|
||||
const edges = currentWorkflow.connections.map((conn) => ({
|
||||
id: conn.id,
|
||||
source: conn.source,
|
||||
target: conn.target,
|
||||
}));
|
||||
|
||||
console.log('💾 [WorkflowManager] Sauvegarde workflow:', {
|
||||
name: saveDialogState.workflowName,
|
||||
stepsCount: currentWorkflow.steps.length,
|
||||
nodesCount: nodes.length,
|
||||
});
|
||||
|
||||
// Diagnostic des données visuelles pour chaque étape
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
currentWorkflow.steps.forEach((step, index) => {
|
||||
const params = step.data?.parameters || {};
|
||||
const hasVisualAnchor = !!params.visual_anchor;
|
||||
const hasRefImage = !!params.visual_anchor?.reference_image_base64;
|
||||
console.log(`💾 [Save] Étape ${index + 1} (${step.id}):`, {
|
||||
hasVisualAnchor,
|
||||
hasRefImage,
|
||||
refImageLength: params.visual_anchor?.reference_image_base64?.length || 0,
|
||||
hasBoundingBox: !!params.visual_anchor?.bounding_box,
|
||||
dataKeys: Object.keys(step.data || {}),
|
||||
paramKeys: Object.keys(params),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Validation côté client avant envoi - Envoyer les deux formats
|
||||
const workflowData: WorkflowApiData = {
|
||||
id: saveDialogState.isOverwrite ? saveDialogState.existingWorkflowId : undefined,
|
||||
name: saveDialogState.workflowName.trim(),
|
||||
description: saveDialogState.workflowDescription.trim() || undefined,
|
||||
steps: currentWorkflow.steps,
|
||||
connections: currentWorkflow.connections,
|
||||
variables: currentWorkflow.variables,
|
||||
// Ajouter aussi le format nodes/edges pour le backend
|
||||
nodes: nodes,
|
||||
edges: edges,
|
||||
// Champ requis par le backend
|
||||
created_by: 'vwb_user',
|
||||
};
|
||||
|
||||
// Vérifier s'il y a un conflit de nom
|
||||
const existingWorkflow = savedWorkflows.find(
|
||||
wf => wf.name === saveDialogState.workflowName.trim() &&
|
||||
wf.id !== currentWorkflow.id
|
||||
);
|
||||
|
||||
if (existingWorkflow && !saveDialogState.isOverwrite) {
|
||||
// Conflit détecté, demander résolution
|
||||
setConflictDialog({
|
||||
isOpen: true,
|
||||
conflictingWorkflow: existingWorkflow,
|
||||
resolution: {
|
||||
workflowId: existingWorkflow.id,
|
||||
action: 'overwrite',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Valider le workflow avant sauvegarde
|
||||
try {
|
||||
const validationResult = await workflowApi.validateWorkflow(workflowData);
|
||||
|
||||
if (!validationResult?.isValid) {
|
||||
setValidationErrors(validationResult?.errors || ['Erreur de validation']);
|
||||
return;
|
||||
}
|
||||
|
||||
if (validationResult.warnings && validationResult.warnings.length > 0) {
|
||||
setSnackbarMessage(`Avertissements: ${validationResult.warnings.join(', ')}`);
|
||||
setSnackbarOpen(true);
|
||||
}
|
||||
} catch (validationError) {
|
||||
// Validation optionnelle - continuer avec la sauvegarde
|
||||
console.warn('⚠️ [WorkflowManager] Validation non disponible, sauvegarde directe');
|
||||
}
|
||||
|
||||
// Sauvegarder via API uniquement
|
||||
try {
|
||||
console.log('💾 [WorkflowManager] Sauvegarde via API...');
|
||||
const workflowId = await workflowApi.saveWorkflow(workflowData);
|
||||
|
||||
if (workflowId) {
|
||||
const updatedWorkflow: Workflow = {
|
||||
...currentWorkflow,
|
||||
id: workflowId,
|
||||
name: saveDialogState.workflowName.trim(),
|
||||
description: saveDialogState.workflowDescription.trim() || undefined,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
onWorkflowSave(updatedWorkflow);
|
||||
|
||||
// Fermer le dialogue et recharger la liste
|
||||
setSaveDialogState({ isOpen: false, workflowName: '', workflowDescription: '', isOverwrite: false });
|
||||
setValidationErrors([]);
|
||||
await loadWorkflowList();
|
||||
|
||||
setSnackbarMessage('✅ Workflow sauvegardé avec succès');
|
||||
setSnackbarOpen(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ [WorkflowManager] Erreur sauvegarde API:', error);
|
||||
setSnackbarMessage('Erreur: impossible de sauvegarder le workflow');
|
||||
setSnackbarOpen(true);
|
||||
}
|
||||
}, [saveDialogState, currentWorkflow, savedWorkflows, workflowApi, onWorkflowSave, loadWorkflowList]);
|
||||
|
||||
// Charger un workflow depuis l'API uniquement
|
||||
const handleLoadWorkflow = useCallback(async (workflowId: string) => {
|
||||
// Fonction pour mapper les données du backend vers le format frontend
|
||||
const mapWorkflowData = (workflowData: any): Workflow => {
|
||||
const steps = workflowData.steps || workflowData.nodes || [];
|
||||
const connections = workflowData.connections || workflowData.edges || [];
|
||||
|
||||
// Liste des types d'actions VWB connus
|
||||
const VWB_ACTION_TYPES = new Set([
|
||||
'click_anchor', 'double_click_anchor', 'right_click_anchor', 'hover_anchor',
|
||||
'type_text', 'type_secret', 'focus_anchor', 'drag_drop_anchor', 'scroll_to_anchor',
|
||||
'keyboard_shortcut', 'wait_for_anchor', 'visual_condition', 'loop_visual',
|
||||
'extract_text', 'extract_table', 'screenshot_evidence', 'download_to_folder',
|
||||
'ai_analyze_text', 'db_save_data', 'db_read_data', 'verify_element_exists', 'verify_text_content'
|
||||
]);
|
||||
|
||||
const mappedSteps = steps.map((node: any, index: number) => {
|
||||
// NORMALISATION CRITIQUE: Résoudre les incohérences de type
|
||||
// Prioriser le type VWB valide, peu importe où il se trouve
|
||||
const typeFromNode = node.type;
|
||||
const typeFromStepType = node.stepType;
|
||||
const typeFromData = node.data?.stepType;
|
||||
const typeFromVwbAction = node.data?.vwbActionId || node.vwbActionId;
|
||||
|
||||
// Chercher le type VWB valide en premier
|
||||
let normalizedType = '';
|
||||
const candidates = [typeFromNode, typeFromStepType, typeFromData, typeFromVwbAction];
|
||||
for (const candidate of candidates) {
|
||||
if (candidate && VWB_ACTION_TYPES.has(candidate)) {
|
||||
normalizedType = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Fallback: prendre le premier disponible
|
||||
if (!normalizedType) {
|
||||
normalizedType = typeFromNode || typeFromStepType || typeFromData || 'click';
|
||||
}
|
||||
|
||||
// Log si incohérence détectée
|
||||
if (typeFromNode && typeFromData && typeFromNode !== typeFromData) {
|
||||
console.warn(
|
||||
`⚠️ [mapWorkflowData] Incohérence type détectée pour étape ${node.id}:`,
|
||||
`node.type="${typeFromNode}", node.data.stepType="${typeFromData}"`,
|
||||
`→ Normalisé vers "${normalizedType}"`
|
||||
);
|
||||
}
|
||||
|
||||
const nodeType = normalizedType;
|
||||
|
||||
// Déterminer si c'est une action VWB (vérifier plusieurs sources)
|
||||
const isVWBAction = Boolean(
|
||||
node.data?.isVWBCatalogAction ||
|
||||
node.isVWBCatalogAction ||
|
||||
VWB_ACTION_TYPES.has(nodeType) ||
|
||||
normalizedType?.includes('anchor') ||
|
||||
normalizedType?.includes('vwb_') ||
|
||||
node.data?.parameters?.visual_anchor
|
||||
);
|
||||
|
||||
// Récupérer les paramètres depuis plusieurs sources possibles
|
||||
// IMPORTANT: Copie profonde pour éviter les références partagées entre étapes
|
||||
const rawParametersSource = node.data?.parameters || node.parameters || {};
|
||||
const rawParameters = JSON.parse(JSON.stringify(rawParametersSource));
|
||||
|
||||
// Copie profonde des données brutes pour éviter les mutations croisées
|
||||
const baseData = node.data ? JSON.parse(JSON.stringify(node.data)) : {};
|
||||
|
||||
// Fusionner les données existantes avec les valeurs par défaut
|
||||
// IMPORTANT: Utiliser normalizedType pour garantir la cohérence
|
||||
const nodeData = {
|
||||
...baseData, // Spread d'abord les données brutes (copiées)
|
||||
label: node.name || node.label || node.data?.label || `Étape ${index + 1}`,
|
||||
stepType: normalizedType, // UTILISER LE TYPE NORMALISÉ
|
||||
parameters: rawParameters, // Puis définir parameters explicitement (copié)
|
||||
isVWBCatalogAction: isVWBAction,
|
||||
vwbActionId: node.data?.vwbActionId || node.vwbActionId || node.action_id || normalizedType,
|
||||
};
|
||||
|
||||
// Log de diagnostic pour les miniatures
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
const hasVisualAnchor = !!rawParameters.visual_anchor;
|
||||
const hasRefImage = !!rawParameters.visual_anchor?.reference_image_base64;
|
||||
console.log(`📷 [mapWorkflowData] Étape ${index + 1} (${node.id}):`, {
|
||||
hasVisualAnchor,
|
||||
hasRefImage,
|
||||
refImageLength: rawParameters.visual_anchor?.reference_image_base64?.length || 0,
|
||||
hasBoundingBox: !!rawParameters.visual_anchor?.bounding_box,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: node.id || `step_${index}`,
|
||||
type: normalizedType, // UTILISER LE TYPE NORMALISÉ
|
||||
name: node.name || node.label || node.data?.label || `Étape ${index + 1}`,
|
||||
position: node.position || { x: 100 + (index % 3) * 200, y: 100 + Math.floor(index / 3) * 150 },
|
||||
data: nodeData,
|
||||
executionState: node.executionState || 'IDLE',
|
||||
validationErrors: node.validationErrors || [],
|
||||
};
|
||||
});
|
||||
|
||||
const mappedConnections = connections.map((edge: any, index: number) => ({
|
||||
id: edge.id || `conn_${index}`,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
}));
|
||||
|
||||
return {
|
||||
id: workflowData.id,
|
||||
name: workflowData.name,
|
||||
description: workflowData.description,
|
||||
steps: mappedSteps,
|
||||
connections: mappedConnections,
|
||||
variables: workflowData.variables || [],
|
||||
createdAt: new Date(workflowData.createdAt || workflowData.created_at || Date.now()),
|
||||
updatedAt: new Date(workflowData.updatedAt || workflowData.updated_at || Date.now()),
|
||||
};
|
||||
};
|
||||
|
||||
// Chargement via API uniquement
|
||||
try {
|
||||
console.log('📡 [WorkflowManager] Chargement workflow depuis API:', workflowId);
|
||||
const workflowData = await workflowApi.loadWorkflow(workflowId);
|
||||
|
||||
if (workflowData) {
|
||||
console.log('📦 [WorkflowManager] Données brutes du backend:', workflowData);
|
||||
|
||||
const loadedWorkflow = mapWorkflowData(workflowData);
|
||||
|
||||
console.log('✅ [WorkflowManager] Workflow mappé:', {
|
||||
name: loadedWorkflow.name,
|
||||
stepsCount: loadedWorkflow.steps.length,
|
||||
connectionsCount: loadedWorkflow.connections.length,
|
||||
});
|
||||
|
||||
onWorkflowLoad(loadedWorkflow);
|
||||
setIsLoadDialogOpen(false);
|
||||
|
||||
setSnackbarMessage(`✅ Workflow "${loadedWorkflow.name}" chargé avec ${loadedWorkflow.steps.length} étapes`);
|
||||
setSnackbarOpen(true);
|
||||
} else {
|
||||
setSnackbarMessage('Workflow non trouvé');
|
||||
setSnackbarOpen(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ [WorkflowManager] Erreur chargement workflow:', error);
|
||||
setSnackbarMessage('Erreur: impossible de charger le workflow');
|
||||
setSnackbarOpen(true);
|
||||
}
|
||||
}, [workflowApi, onWorkflowLoad]);
|
||||
|
||||
// Supprimer un workflow (API uniquement)
|
||||
const handleDeleteWorkflow = useCallback(async (workflowId: string) => {
|
||||
if (!window.confirm('Êtes-vous sûr de vouloir supprimer ce workflow ?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('🗑️ [WorkflowManager] Suppression workflow via API:', workflowId);
|
||||
await workflowApi.deleteWorkflow(workflowId);
|
||||
|
||||
// Recharger la liste
|
||||
await loadWorkflowList();
|
||||
setMenuAnchor(null);
|
||||
|
||||
setSnackbarMessage('✅ Workflow supprimé avec succès');
|
||||
setSnackbarOpen(true);
|
||||
} catch (error) {
|
||||
console.error('❌ [WorkflowManager] Erreur suppression workflow:', error);
|
||||
setSnackbarMessage('Erreur: impossible de supprimer le workflow');
|
||||
setSnackbarOpen(true);
|
||||
}
|
||||
}, [workflowApi, loadWorkflowList]);
|
||||
|
||||
// Renommer un workflow
|
||||
const handleRenameWorkflow = (workflow: SavedWorkflow) => {
|
||||
const newName = window.prompt('Nouveau nom du workflow:', workflow.name);
|
||||
if (newName && newName.trim() !== workflow.name) {
|
||||
// TODO: Implémenter la logique de renommage
|
||||
console.log('Renommer workflow:', workflow.id, 'vers', newName.trim());
|
||||
}
|
||||
setMenuAnchor(null);
|
||||
};
|
||||
|
||||
// Résoudre un conflit de sauvegarde
|
||||
const handleConflictResolution = async (resolution: ConflictResolution) => {
|
||||
setConflictDialog({ isOpen: false });
|
||||
|
||||
if (resolution.action === 'overwrite') {
|
||||
setSaveDialogState(prev => ({
|
||||
...prev,
|
||||
isOverwrite: true,
|
||||
existingWorkflowId: resolution.workflowId,
|
||||
}));
|
||||
await handleSaveWorkflow();
|
||||
} else if (resolution.action === 'create_new') {
|
||||
setSaveDialogState(prev => ({
|
||||
...prev,
|
||||
workflowName: resolution.newName || `${prev.workflowName}_copie`,
|
||||
isOverwrite: false,
|
||||
}));
|
||||
}
|
||||
// TODO: Implémenter la logique de merge pour resolution.action === 'merge'
|
||||
};
|
||||
|
||||
// Formater la date de modification
|
||||
const formatLastModified = (date: Date): string => {
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) {
|
||||
return 'Aujourd\'hui';
|
||||
} else if (diffDays === 1) {
|
||||
return 'Hier';
|
||||
} else if (diffDays < 7) {
|
||||
return `Il y a ${diffDays} jours`;
|
||||
} else {
|
||||
return date.toLocaleDateString('fr-FR');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box onKeyDown={handleKeyDown} tabIndex={0}>
|
||||
{/* Boutons principaux avec indicateur de santé API */}
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 2, alignItems: 'center' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<SaveIcon />}
|
||||
onClick={handleSaveClick}
|
||||
disabled={workflowApi.loading}
|
||||
>
|
||||
Sauvegarder
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<LoadIcon />}
|
||||
onClick={() => setIsLoadDialogOpen(true)}
|
||||
disabled={workflowApi.loading}
|
||||
>
|
||||
Charger
|
||||
</Button>
|
||||
<Button
|
||||
variant="text"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={() => loadWorkflowList()}
|
||||
disabled={workflowApi.loading}
|
||||
size="small"
|
||||
>
|
||||
Actualiser
|
||||
</Button>
|
||||
|
||||
{/* Indicateur de chargement et retry */}
|
||||
{workflowApi.loading && <CircularProgress size={24} />}
|
||||
{workflowApi.isRetrying && (
|
||||
<Chip
|
||||
icon={<RefreshIcon />}
|
||||
label={`Retry ${workflowApi.retryCount}/${3}`}
|
||||
size="small"
|
||||
color="warning"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Indicateur d'erreur API */}
|
||||
{workflowApi.error && (
|
||||
<Chip
|
||||
icon={<OfflineIcon />}
|
||||
label="Erreur API"
|
||||
size="small"
|
||||
color="error"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Affichage des erreurs de validation */}
|
||||
{validationErrors.length > 0 && (
|
||||
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setValidationErrors([])}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Erreurs de validation:
|
||||
</Typography>
|
||||
<List dense>
|
||||
{validationErrors.map((error, index) => (
|
||||
<ListItem key={index} sx={{ py: 0 }}>
|
||||
<Typography variant="body2">• {error}</Typography>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Affichage des erreurs API */}
|
||||
{workflowApi.error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }} onClose={() => workflowApi.reset()}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Erreur API:
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{workflowApi.error.message}
|
||||
{workflowApi.error.code && ` (Code: ${workflowApi.error.code})`}
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Dialogue de sauvegarde */}
|
||||
<Dialog
|
||||
open={saveDialogState.isOpen}
|
||||
onClose={() => setSaveDialogState({ isOpen: false, workflowName: '', workflowDescription: '', isOverwrite: false })}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
Sauvegarder le workflow
|
||||
<IconButton
|
||||
onClick={() => setSaveDialogState({ isOpen: false, workflowName: '', workflowDescription: '', isOverwrite: false })}
|
||||
size="small"
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 1 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Nom du workflow"
|
||||
value={saveDialogState.workflowName}
|
||||
onChange={(e) => setSaveDialogState(prev => ({ ...prev, workflowName: e.target.value }))}
|
||||
required
|
||||
error={!saveDialogState.workflowName.trim()}
|
||||
helperText={!saveDialogState.workflowName.trim() ? 'Le nom est obligatoire' : ''}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Description (optionnelle)"
|
||||
value={saveDialogState.workflowDescription}
|
||||
onChange={(e) => setSaveDialogState(prev => ({ ...prev, workflowDescription: e.target.value }))}
|
||||
multiline
|
||||
rows={3}
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={() => setSaveDialogState({ isOpen: false, workflowName: '', workflowDescription: '', isOverwrite: false })}
|
||||
disabled={workflowApi.loading}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSaveWorkflow}
|
||||
variant="contained"
|
||||
disabled={workflowApi.loading || !saveDialogState.workflowName.trim()}
|
||||
>
|
||||
Sauvegarder
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Dialogue de chargement */}
|
||||
<Dialog
|
||||
open={isLoadDialogOpen}
|
||||
onClose={() => setIsLoadDialogOpen(false)}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
Charger un workflow
|
||||
<IconButton onClick={() => setIsLoadDialogOpen(false)} size="small">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent>
|
||||
{savedWorkflows.length === 0 ? (
|
||||
<Alert severity="info">
|
||||
Aucun workflow sauvegardé trouvé.
|
||||
</Alert>
|
||||
) : (
|
||||
<List>
|
||||
{paginatedWorkflows.map((workflow) => (
|
||||
<ListItem
|
||||
key={workflow.id}
|
||||
component="div"
|
||||
onClick={() => handleLoadWorkflow(workflow.id)}
|
||||
sx={{
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 1,
|
||||
mb: 1,
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="subtitle1">{workflow.name}</Typography>
|
||||
{workflow.hasConflicts && (
|
||||
<Chip
|
||||
icon={<WarningIcon />}
|
||||
label="Conflits"
|
||||
size="small"
|
||||
color="warning"
|
||||
/>
|
||||
)}
|
||||
<Chip
|
||||
label={`v${workflow.version}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
secondary={
|
||||
<Box>
|
||||
{workflow.description && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{workflow.description}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{workflow.stepCount} étapes • Modifié {formatLastModified(workflow.lastModified)}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<IconButton
|
||||
edge="end"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMenuAnchor({ element: e.currentTarget, workflowId: workflow.id });
|
||||
}}
|
||||
>
|
||||
<MoreIcon />
|
||||
</IconButton>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 2 }}>
|
||||
<Button
|
||||
onClick={() => setCurrentPage(prev => Math.max(0, prev - 1))}
|
||||
disabled={currentPage === 0}
|
||||
>
|
||||
Précédent
|
||||
</Button>
|
||||
<Typography sx={{ mx: 2, alignSelf: 'center' }}>
|
||||
Page {currentPage + 1} sur {totalPages}
|
||||
</Typography>
|
||||
<Button
|
||||
onClick={() => setCurrentPage(prev => Math.min(totalPages - 1, prev + 1))}
|
||||
disabled={currentPage === totalPages - 1}
|
||||
>
|
||||
Suivant
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button onClick={() => setIsLoadDialogOpen(false)}>
|
||||
Fermer
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Snackbar pour les notifications */}
|
||||
<Snackbar
|
||||
open={snackbarOpen}
|
||||
autoHideDuration={6000}
|
||||
onClose={() => setSnackbarOpen(false)}
|
||||
message={snackbarMessage}
|
||||
action={
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="close"
|
||||
color="inherit"
|
||||
onClick={() => setSnackbarOpen(false)}
|
||||
>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Menu contextuel */}
|
||||
<Menu
|
||||
anchorEl={menuAnchor?.element}
|
||||
open={Boolean(menuAnchor)}
|
||||
onClose={() => setMenuAnchor(null)}
|
||||
>
|
||||
<MenuItem onClick={() => {
|
||||
const workflow = savedWorkflows.find(wf => wf.id === menuAnchor?.workflowId);
|
||||
if (workflow) handleRenameWorkflow(workflow);
|
||||
}}>
|
||||
<EditIcon sx={{ mr: 1 }} />
|
||||
Renommer
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => {
|
||||
// TODO: Implémenter l'historique des versions
|
||||
console.log('Voir historique:', menuAnchor?.workflowId);
|
||||
setMenuAnchor(null);
|
||||
}}>
|
||||
<HistoryIcon sx={{ mr: 1 }} />
|
||||
Historique
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
<MenuItem
|
||||
onClick={() => menuAnchor && handleDeleteWorkflow(menuAnchor.workflowId)}
|
||||
sx={{ color: 'error.main' }}
|
||||
>
|
||||
<DeleteIcon sx={{ mr: 1 }} />
|
||||
Supprimer
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
{/* Dialogue de résolution de conflit */}
|
||||
<Dialog
|
||||
open={conflictDialog.isOpen}
|
||||
onClose={() => setConflictDialog({ isOpen: false })}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<WarningIcon color="warning" />
|
||||
Conflit de nom détecté
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent>
|
||||
<Typography variant="body1" gutterBottom>
|
||||
Un workflow avec le nom "{saveDialogState.workflowName}" existe déjà.
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Que souhaitez-vous faire ?
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions sx={{ flexDirection: 'column', gap: 1, alignItems: 'stretch' }}>
|
||||
<Button
|
||||
onClick={() => handleConflictResolution({
|
||||
workflowId: conflictDialog.conflictingWorkflow?.id || '',
|
||||
action: 'overwrite',
|
||||
})}
|
||||
color="warning"
|
||||
>
|
||||
Écraser le workflow existant
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleConflictResolution({
|
||||
workflowId: conflictDialog.conflictingWorkflow?.id || '',
|
||||
action: 'create_new',
|
||||
newName: `${saveDialogState.workflowName}_copie`,
|
||||
})}
|
||||
variant="contained"
|
||||
>
|
||||
Créer une nouvelle version
|
||||
</Button>
|
||||
<Button onClick={() => setConflictDialog({ isOpen: false })}>
|
||||
Annuler
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// Mémorisation du composant WorkflowManager pour éviter les re-rendus inutiles
|
||||
export default memo(WorkflowManager, (prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.currentWorkflow?.id === nextProps.currentWorkflow?.id &&
|
||||
prevProps.currentWorkflow?.name === nextProps.currentWorkflow?.name &&
|
||||
prevProps.currentWorkflow?.steps?.length === nextProps.currentWorkflow?.steps?.length &&
|
||||
prevProps.onWorkflowLoad === nextProps.onWorkflowLoad &&
|
||||
prevProps.onWorkflowSave === nextProps.onWorkflowSave
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,382 @@
|
||||
/**
|
||||
* Contrats Stricts des Actions VWB - Frontend
|
||||
*
|
||||
* Auteur : Dom, Alice, Kiro - 23 janvier 2026
|
||||
*
|
||||
* Ce module définit les contrats stricts pour chaque action VWB côté frontend.
|
||||
* Validation AVANT envoi au backend pour éviter les erreurs.
|
||||
*
|
||||
* PRINCIPE CLÉ: Si le contrat n'est pas respecté → BLOQUER l'exécution
|
||||
*/
|
||||
|
||||
export enum ContractViolationType {
|
||||
MISSING_REQUIRED = 'missing_required',
|
||||
INVALID_TYPE = 'invalid_type',
|
||||
INVALID_VALUE = 'invalid_value',
|
||||
INCOMPATIBLE_ACTION = 'incompatible_action'
|
||||
}
|
||||
|
||||
export interface ContractViolation {
|
||||
violationType: ContractViolationType;
|
||||
parameter: string;
|
||||
message: string;
|
||||
expected?: string;
|
||||
received?: string;
|
||||
}
|
||||
|
||||
export interface ActionContract {
|
||||
actionType: string;
|
||||
description: string;
|
||||
requiredParams: string[];
|
||||
optionalParams: string[];
|
||||
validators?: Record<string, (value: any) => boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si visual_anchor est présent et valide
|
||||
*/
|
||||
function hasVisualAnchor(params: Record<string, any>): boolean {
|
||||
const anchor = params.visual_anchor || params.target || params.visualSelection;
|
||||
if (!anchor) return false;
|
||||
if (typeof anchor !== 'object') return false;
|
||||
|
||||
// Doit avoir soit une image, soit des coordonnées, soit un ID
|
||||
const hasImage = !!(
|
||||
anchor.screenshot ||
|
||||
anchor.image ||
|
||||
anchor.reference_image_base64 ||
|
||||
anchor.id ||
|
||||
anchor.anchor_id
|
||||
);
|
||||
const hasCoords = !!(
|
||||
anchor.bounding_box ||
|
||||
anchor.boundingBox
|
||||
);
|
||||
const hasServerStorage = !!(
|
||||
anchor.metadata?.uses_server_storage ||
|
||||
anchor.metadata?.thumbnail_url
|
||||
);
|
||||
|
||||
return hasImage || hasCoords || hasServerStorage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si text est présent et non vide
|
||||
*/
|
||||
function hasText(params: Record<string, any>): boolean {
|
||||
const text = params.text || params.text_to_type || params.texte;
|
||||
return !!(text && typeof text === 'string' && text.trim().length > 0);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// DÉFINITION DES CONTRATS POUR CHAQUE ACTION VWB
|
||||
// =============================================================================
|
||||
|
||||
export const VWB_ACTION_CONTRACTS: Record<string, ActionContract> = {
|
||||
// --- ACTIONS DE CLIC ---
|
||||
click_anchor: {
|
||||
actionType: 'click_anchor',
|
||||
description: 'Clic sur un élément identifié par ancre visuelle',
|
||||
requiredParams: ['visual_anchor'],
|
||||
optionalParams: ['click_type', 'click_offset_x', 'click_offset_y', 'confidence_threshold'],
|
||||
validators: {
|
||||
visual_anchor: (v) => hasVisualAnchor({ visual_anchor: v })
|
||||
}
|
||||
},
|
||||
|
||||
double_click_anchor: {
|
||||
actionType: 'double_click_anchor',
|
||||
description: 'Double-clic sur un élément identifié par ancre visuelle',
|
||||
requiredParams: ['visual_anchor'],
|
||||
optionalParams: ['click_offset_x', 'click_offset_y', 'confidence_threshold'],
|
||||
validators: {
|
||||
visual_anchor: (v) => hasVisualAnchor({ visual_anchor: v })
|
||||
}
|
||||
},
|
||||
|
||||
right_click_anchor: {
|
||||
actionType: 'right_click_anchor',
|
||||
description: 'Clic droit sur un élément identifié par ancre visuelle',
|
||||
requiredParams: ['visual_anchor'],
|
||||
optionalParams: ['click_offset_x', 'click_offset_y', 'confidence_threshold'],
|
||||
validators: {
|
||||
visual_anchor: (v) => hasVisualAnchor({ visual_anchor: v })
|
||||
}
|
||||
},
|
||||
|
||||
hover_anchor: {
|
||||
actionType: 'hover_anchor',
|
||||
description: 'Survol d\'un élément identifié par ancre visuelle',
|
||||
requiredParams: ['visual_anchor'],
|
||||
optionalParams: ['hover_duration_ms', 'confidence_threshold'],
|
||||
validators: {
|
||||
visual_anchor: (v) => hasVisualAnchor({ visual_anchor: v })
|
||||
}
|
||||
},
|
||||
|
||||
// --- ACTIONS DE SAISIE ---
|
||||
type_text: {
|
||||
actionType: 'type_text',
|
||||
description: 'Saisie de texte (le focus doit être déjà fait)',
|
||||
requiredParams: ['text'],
|
||||
optionalParams: ['typing_speed_ms', 'clear_field_first', 'press_enter_after'],
|
||||
validators: {
|
||||
text: (v) => !!(v && typeof v === 'string')
|
||||
}
|
||||
},
|
||||
|
||||
type_secret: {
|
||||
actionType: 'type_secret',
|
||||
description: 'Saisie sécurisée de texte sensible',
|
||||
requiredParams: ['secret_text'],
|
||||
optionalParams: ['typing_speed_ms', 'clear_field_first', 'mask_in_evidence'],
|
||||
validators: {
|
||||
secret_text: (v) => !!(v && typeof v === 'string')
|
||||
}
|
||||
},
|
||||
|
||||
// --- ACTIONS DE FOCUS ---
|
||||
focus_anchor: {
|
||||
actionType: 'focus_anchor',
|
||||
description: 'Donne le focus à un élément',
|
||||
requiredParams: ['visual_anchor'],
|
||||
optionalParams: ['focus_method', 'verify_focus', 'confidence_threshold'],
|
||||
validators: {
|
||||
visual_anchor: (v) => hasVisualAnchor({ visual_anchor: v })
|
||||
}
|
||||
},
|
||||
|
||||
// --- ACTIONS D'ATTENTE ---
|
||||
wait_for_anchor: {
|
||||
actionType: 'wait_for_anchor',
|
||||
description: 'Attendre qu\'un élément apparaisse ou disparaisse',
|
||||
requiredParams: ['visual_anchor'],
|
||||
optionalParams: ['wait_mode', 'max_wait_time_ms', 'check_interval_ms'],
|
||||
validators: {
|
||||
visual_anchor: (v) => hasVisualAnchor({ visual_anchor: v })
|
||||
}
|
||||
},
|
||||
|
||||
// --- ACTIONS DE SCROLL ---
|
||||
scroll_to_anchor: {
|
||||
actionType: 'scroll_to_anchor',
|
||||
description: 'Défiler jusqu\'à ce qu\'un élément soit visible',
|
||||
requiredParams: ['visual_anchor'],
|
||||
optionalParams: ['scroll_direction', 'scroll_speed', 'max_scroll_attempts'],
|
||||
validators: {
|
||||
visual_anchor: (v) => hasVisualAnchor({ visual_anchor: v })
|
||||
}
|
||||
},
|
||||
|
||||
drag_drop_anchor: {
|
||||
actionType: 'drag_drop_anchor',
|
||||
description: 'Glisser-déposer d\'un élément vers un autre',
|
||||
requiredParams: ['source_anchor', 'target_anchor'],
|
||||
optionalParams: ['drag_speed', 'hold_duration_ms'],
|
||||
validators: {
|
||||
source_anchor: (v) => hasVisualAnchor({ visual_anchor: v }),
|
||||
target_anchor: (v) => hasVisualAnchor({ visual_anchor: v })
|
||||
}
|
||||
},
|
||||
|
||||
// --- ACTIONS CLAVIER ---
|
||||
keyboard_shortcut: {
|
||||
actionType: 'keyboard_shortcut',
|
||||
description: 'Exécuter un raccourci clavier',
|
||||
requiredParams: ['keys'],
|
||||
optionalParams: ['hold_duration_ms'],
|
||||
validators: {
|
||||
keys: (v) => Array.isArray(v) && v.length > 0
|
||||
}
|
||||
},
|
||||
|
||||
// --- ACTIONS D'EXTRACTION ---
|
||||
extract_text: {
|
||||
actionType: 'extract_text',
|
||||
description: 'Extraire du texte d\'une zone',
|
||||
requiredParams: ['visual_anchor'],
|
||||
optionalParams: ['extraction_mode', 'text_filters', 'output_format'],
|
||||
validators: {
|
||||
visual_anchor: (v) => hasVisualAnchor({ visual_anchor: v })
|
||||
}
|
||||
},
|
||||
|
||||
extract_table: {
|
||||
actionType: 'extract_table',
|
||||
description: 'Extraire un tableau d\'une zone',
|
||||
requiredParams: ['visual_anchor'],
|
||||
optionalParams: ['table_format', 'output_variable'],
|
||||
validators: {
|
||||
visual_anchor: (v) => hasVisualAnchor({ visual_anchor: v })
|
||||
}
|
||||
},
|
||||
|
||||
screenshot_evidence: {
|
||||
actionType: 'screenshot_evidence',
|
||||
description: 'Capturer une preuve visuelle',
|
||||
requiredParams: [],
|
||||
optionalParams: ['region', 'label', 'include_timestamp']
|
||||
},
|
||||
|
||||
// --- ACTIONS CONDITIONNELLES ---
|
||||
visual_condition: {
|
||||
actionType: 'visual_condition',
|
||||
description: 'Condition basée sur présence d\'élément visuel',
|
||||
requiredParams: ['visual_anchor'],
|
||||
optionalParams: ['condition_type', 'timeout_ms'],
|
||||
validators: {
|
||||
visual_anchor: (v) => hasVisualAnchor({ visual_anchor: v })
|
||||
}
|
||||
},
|
||||
|
||||
loop_visual: {
|
||||
actionType: 'loop_visual',
|
||||
description: 'Boucle tant qu\'un élément est visible',
|
||||
requiredParams: ['visual_anchor'],
|
||||
optionalParams: ['max_iterations', 'timeout_ms'],
|
||||
validators: {
|
||||
visual_anchor: (v) => hasVisualAnchor({ visual_anchor: v })
|
||||
}
|
||||
},
|
||||
|
||||
// --- ACTIONS VÉRIFICATION ---
|
||||
verify_element_exists: {
|
||||
actionType: 'verify_element_exists',
|
||||
description: 'Vérifier qu\'un élément existe',
|
||||
requiredParams: ['visual_anchor'],
|
||||
optionalParams: ['timeout_ms', 'should_exist'],
|
||||
validators: {
|
||||
visual_anchor: (v) => hasVisualAnchor({ visual_anchor: v })
|
||||
}
|
||||
},
|
||||
|
||||
verify_text_content: {
|
||||
actionType: 'verify_text_content',
|
||||
description: 'Vérifier le contenu textuel',
|
||||
requiredParams: ['visual_anchor', 'expected_text'],
|
||||
optionalParams: ['match_mode', 'case_sensitive'],
|
||||
validators: {
|
||||
visual_anchor: (v) => hasVisualAnchor({ visual_anchor: v })
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Classe d'erreur pour les violations de contrat
|
||||
*/
|
||||
export class ContractValidationError extends Error {
|
||||
public violations: ContractViolation[];
|
||||
public actionType: string;
|
||||
|
||||
constructor(violations: ContractViolation[], actionType: string) {
|
||||
const messages = violations.map(v => v.message).join('; ');
|
||||
super(`Contrat violé pour '${actionType}': ${messages}`);
|
||||
this.name = 'ContractValidationError';
|
||||
this.violations = violations;
|
||||
this.actionType = actionType;
|
||||
}
|
||||
|
||||
toDict(): Record<string, any> {
|
||||
return {
|
||||
error: 'contract_violation',
|
||||
actionType: this.actionType,
|
||||
violations: this.violations,
|
||||
message: this.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide les paramètres d'une action contre son contrat
|
||||
*/
|
||||
export function validateActionContract(
|
||||
actionType: string,
|
||||
parameters: Record<string, any>
|
||||
): ContractViolation[] {
|
||||
const normalizedType = actionType.toLowerCase().trim();
|
||||
const contract = VWB_ACTION_CONTRACTS[normalizedType];
|
||||
|
||||
if (!contract) {
|
||||
console.warn(`⚠️ [Contract] Action '${actionType}' non reconnue dans les contrats`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const violations: ContractViolation[] = [];
|
||||
|
||||
// Vérifier les paramètres obligatoires
|
||||
for (const param of contract.requiredParams) {
|
||||
const value = parameters[param];
|
||||
|
||||
if (value === undefined || value === null) {
|
||||
violations.push({
|
||||
violationType: ContractViolationType.MISSING_REQUIRED,
|
||||
parameter: param,
|
||||
message: `Paramètre obligatoire '${param}' manquant pour l'action '${actionType}'`,
|
||||
expected: `'${param}' doit être fourni`,
|
||||
received: 'absent ou null'
|
||||
});
|
||||
} else if (contract.validators && contract.validators[param]) {
|
||||
// Valider le contenu
|
||||
if (!contract.validators[param](value)) {
|
||||
violations.push({
|
||||
violationType: ContractViolationType.INVALID_VALUE,
|
||||
parameter: param,
|
||||
message: `Valeur invalide pour '${param}' dans l'action '${actionType}'`,
|
||||
expected: 'valeur valide selon les règles du contrat',
|
||||
received: typeof value
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide et BLOQUE si le contrat n'est pas respecté
|
||||
*/
|
||||
export function enforceActionContract(
|
||||
actionType: string,
|
||||
parameters: Record<string, any>
|
||||
): void {
|
||||
const violations = validateActionContract(actionType, parameters);
|
||||
|
||||
if (violations.length > 0) {
|
||||
console.error(`🚫 [Contract] VIOLATION DÉTECTÉE pour '${actionType}':`);
|
||||
violations.forEach(v => {
|
||||
console.error(` - ${v.parameter}: ${v.message}`);
|
||||
});
|
||||
throw new ContractValidationError(violations, actionType);
|
||||
}
|
||||
|
||||
console.log(`✅ [Contract] Contrat respecté pour '${actionType}'`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne le contrat d'une action
|
||||
*/
|
||||
export function getActionContract(actionType: string): ActionContract | undefined {
|
||||
return VWB_ACTION_CONTRACTS[actionType.toLowerCase().trim()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne les paramètres obligatoires pour une action
|
||||
*/
|
||||
export function getRequiredParams(actionType: string): string[] {
|
||||
const contract = getActionContract(actionType);
|
||||
return contract?.requiredParams || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si un type d'action est reconnu
|
||||
*/
|
||||
export function isKnownActionType(actionType: string): boolean {
|
||||
return actionType.toLowerCase().trim() in VWB_ACTION_CONTRACTS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste tous les types d'actions avec contrat
|
||||
*/
|
||||
export function listAllActionTypes(): string[] {
|
||||
return Object.keys(VWB_ACTION_CONTRACTS);
|
||||
}
|
||||
20
visual_workflow_builder/frontend/src/contracts/index.ts
Normal file
20
visual_workflow_builder/frontend/src/contracts/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Module de Contrats VWB - Exports
|
||||
*/
|
||||
|
||||
export {
|
||||
ContractViolationType,
|
||||
ContractValidationError,
|
||||
VWB_ACTION_CONTRACTS,
|
||||
validateActionContract,
|
||||
enforceActionContract,
|
||||
getActionContract,
|
||||
getRequiredParams,
|
||||
isKnownActionType,
|
||||
listAllActionTypes
|
||||
} from './actionContracts';
|
||||
|
||||
export type {
|
||||
ContractViolation,
|
||||
ActionContract
|
||||
} from './actionContracts';
|
||||
428
visual_workflow_builder/frontend/src/hooks/useApiClient.ts
Normal file
428
visual_workflow_builder/frontend/src/hooks/useApiClient.ts
Normal file
@@ -0,0 +1,428 @@
|
||||
/**
|
||||
* Hook API Client - Interface React pour le client API
|
||||
* Auteur : Dom, Alice, Kiro - 09 janvier 2026
|
||||
*
|
||||
* Ce hook fournit une interface React pour utiliser le client API
|
||||
* avec gestion d'état, loading, erreurs et mode hors ligne gracieux.
|
||||
* Optimisé pour éviter les re-renders excessifs et les sauts de page.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||
import { apiClient, ApiError, ConnectionState } from '../services/apiClient';
|
||||
import { WorkflowApiData } from '../types';
|
||||
|
||||
// Types pour les états de requête
|
||||
interface RequestState<T = any> {
|
||||
data: T | null;
|
||||
loading: boolean;
|
||||
error: ApiError | null;
|
||||
lastUpdated: Date | null;
|
||||
isOffline: boolean;
|
||||
}
|
||||
|
||||
interface UseApiClientOptions {
|
||||
enableAutoRetry?: boolean;
|
||||
retryDelay?: number;
|
||||
maxRetries?: number;
|
||||
onError?: (error: ApiError) => void;
|
||||
onSuccess?: (data: any) => void;
|
||||
silentOffline?: boolean; // Ne pas afficher d'erreur en mode hors ligne
|
||||
}
|
||||
|
||||
// État initial stable (évite les re-créations)
|
||||
const INITIAL_STATE: RequestState = {
|
||||
data: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: null,
|
||||
isOffline: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook pour utiliser le client API avec gestion d'état React
|
||||
* Optimisé pour éviter les re-renders inutiles
|
||||
*/
|
||||
export function useApiClient<T = any>(options: UseApiClientOptions = {}) {
|
||||
const {
|
||||
enableAutoRetry = false, // Désactivé par défaut pour éviter les sauts
|
||||
retryDelay = 1000,
|
||||
maxRetries = 2,
|
||||
onError,
|
||||
onSuccess,
|
||||
silentOffline = true, // Par défaut, ne pas afficher d'erreur en mode hors ligne
|
||||
} = options;
|
||||
|
||||
const [state, setState] = useState<RequestState<T>>(INITIAL_STATE);
|
||||
const retryCountRef = useRef(0);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
// Nettoyer les timeouts et marquer comme démonté
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Fonction pour mettre à jour l'état de manière sécurisée
|
||||
const safeSetState = useCallback((updater: (prev: RequestState<T>) => RequestState<T>) => {
|
||||
if (mountedRef.current) {
|
||||
setState(updater);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fonction générique pour exécuter une requête API
|
||||
const executeRequest = useCallback(async <R = T>(
|
||||
requestFn: () => Promise<R>,
|
||||
requestOptions: { skipLoading?: boolean; skipErrorHandling?: boolean } = {}
|
||||
): Promise<R | null> => {
|
||||
const { skipLoading = false, skipErrorHandling = false } = requestOptions;
|
||||
|
||||
try {
|
||||
if (!skipLoading) {
|
||||
safeSetState(prev => ({
|
||||
...prev,
|
||||
loading: true,
|
||||
error: null,
|
||||
}));
|
||||
}
|
||||
|
||||
const result = await requestFn();
|
||||
|
||||
// Vérifier si le résultat indique un mode hors ligne
|
||||
const isOfflineResult = result && typeof result === 'object' && 'offline' in result && (result as any).offline;
|
||||
|
||||
safeSetState(prev => ({
|
||||
...prev,
|
||||
data: isOfflineResult ? prev.data : (result as unknown as T), // Garder les anciennes données si hors ligne
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: isOfflineResult ? prev.lastUpdated : new Date(),
|
||||
isOffline: isOfflineResult,
|
||||
}));
|
||||
|
||||
retryCountRef.current = 0;
|
||||
|
||||
if (onSuccess && !isOfflineResult) {
|
||||
onSuccess(result);
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
const apiError = error as ApiError;
|
||||
const isOffline = apiError.code === 'OFFLINE' || apiError.code === 'NETWORK_ERROR';
|
||||
|
||||
safeSetState(prev => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
error: (silentOffline && isOffline) ? null : apiError,
|
||||
isOffline,
|
||||
}));
|
||||
|
||||
// Gestion du retry automatique (seulement si pas hors ligne)
|
||||
if (enableAutoRetry && !isOffline && retryCountRef.current < maxRetries && shouldRetryError(apiError)) {
|
||||
retryCountRef.current++;
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
executeRequest(requestFn, requestOptions);
|
||||
}, retryDelay * Math.pow(2, retryCountRef.current - 1));
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
retryCountRef.current = 0;
|
||||
|
||||
if (!skipErrorHandling && onError && !(silentOffline && isOffline)) {
|
||||
onError(apiError);
|
||||
}
|
||||
|
||||
// Ne pas relancer l'erreur en mode hors ligne silencieux
|
||||
if (silentOffline && isOffline) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw apiError;
|
||||
}
|
||||
}, [enableAutoRetry, maxRetries, retryDelay, onError, onSuccess, silentOffline, safeSetState]);
|
||||
|
||||
// Déterminer si une erreur justifie un retry
|
||||
const shouldRetryError = useCallback((error: ApiError): boolean => {
|
||||
// Ne pas retry pour les erreurs hors ligne
|
||||
if (error.code === 'OFFLINE' || error.code === 'NETWORK_ERROR') {
|
||||
return false;
|
||||
}
|
||||
// Retry pour les erreurs serveur
|
||||
return (
|
||||
(error.status !== undefined && error.status >= 500) ||
|
||||
error.status === 408 ||
|
||||
error.status === 429
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Réinitialiser l'état
|
||||
const reset = useCallback(() => {
|
||||
safeSetState(() => INITIAL_STATE);
|
||||
retryCountRef.current = 0;
|
||||
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
}, [safeSetState]);
|
||||
|
||||
// Annuler la requête en cours
|
||||
const cancel = useCallback(() => {
|
||||
apiClient.cancelRequest();
|
||||
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
|
||||
safeSetState(prev => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
}));
|
||||
}, [safeSetState]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
executeRequest,
|
||||
reset,
|
||||
cancel,
|
||||
isRetrying: retryCountRef.current > 0,
|
||||
retryCount: retryCountRef.current,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook pour surveiller l'état de connexion de l'API
|
||||
* Utilise un abonnement pour éviter les re-renders excessifs
|
||||
* L'état initial est 'offline' pour éviter les tentatives de connexion au montage
|
||||
*/
|
||||
export function useConnectionState() {
|
||||
// État initial 'online' - on suppose que l'API est disponible
|
||||
const [connectionState, setConnectionState] = useState<ConnectionState>('online');
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
// Vérification DIRECTE au montage (SANS passer par apiClient singleton)
|
||||
const checkOnMount = async () => {
|
||||
try {
|
||||
const response = await fetch('http://localhost:5001/api/health', {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
if (isMounted) {
|
||||
setConnectionState('online');
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignorer l'erreur - on garde l'état 'online' par défaut
|
||||
}
|
||||
|
||||
// Seulement si vraiment impossible de contacter l'API
|
||||
if (isMounted) {
|
||||
setConnectionState('offline');
|
||||
}
|
||||
};
|
||||
checkOnMount();
|
||||
|
||||
// NE PAS s'abonner au singleton - cela cause des conflits d'état
|
||||
// L'état est géré localement par ce hook uniquement
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Mémoiser les valeurs dérivées
|
||||
const derivedState = useMemo(() => ({
|
||||
isOnline: connectionState === 'online',
|
||||
isOffline: connectionState === 'offline',
|
||||
isChecking: connectionState === 'checking',
|
||||
connectionState,
|
||||
}), [connectionState]);
|
||||
|
||||
// Fonction pour forcer une vérification
|
||||
const forceCheck = useCallback(async () => {
|
||||
return apiClient.forceConnectionCheck();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...derivedState,
|
||||
forceCheck,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook spécialisé pour les opérations sur les workflows
|
||||
* Gère gracieusement le mode hors ligne
|
||||
*/
|
||||
export function useWorkflowApi(options: UseApiClientOptions = {}) {
|
||||
const api = useApiClient<any>({ ...options, silentOffline: true });
|
||||
const { isOffline } = useConnectionState();
|
||||
|
||||
// Charger la liste des workflows
|
||||
// NOTE: On essaie toujours l'API - les erreurs sont gérées par makeRequest
|
||||
const loadWorkflows = useCallback(async () => {
|
||||
return api.executeRequest(() => apiClient.getWorkflows());
|
||||
}, [api]);
|
||||
|
||||
// Charger un workflow spécifique
|
||||
const loadWorkflow = useCallback(async (workflowId: string) => {
|
||||
return api.executeRequest(() => apiClient.getWorkflow(workflowId));
|
||||
}, [api]);
|
||||
|
||||
// Sauvegarder un workflow
|
||||
const saveWorkflow = useCallback(async (workflowData: WorkflowApiData) => {
|
||||
return api.executeRequest(() => apiClient.saveWorkflow(workflowData));
|
||||
}, [api]);
|
||||
|
||||
// Supprimer un workflow
|
||||
const deleteWorkflow = useCallback(async (workflowId: string) => {
|
||||
return api.executeRequest(() => apiClient.deleteWorkflow(workflowId));
|
||||
}, [api]);
|
||||
|
||||
// Valider un workflow
|
||||
const validateWorkflow = useCallback(async (workflowData: WorkflowApiData) => {
|
||||
return api.executeRequest(() => apiClient.validateWorkflow(workflowData));
|
||||
}, [api]);
|
||||
|
||||
return {
|
||||
...api,
|
||||
isOffline,
|
||||
loadWorkflows,
|
||||
loadWorkflow,
|
||||
saveWorkflow,
|
||||
deleteWorkflow,
|
||||
validateWorkflow,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook spécialisé pour l'exécution de workflows
|
||||
*/
|
||||
export function useWorkflowExecution(options: UseApiClientOptions = {}) {
|
||||
const api = useApiClient<any>({ ...options, silentOffline: true });
|
||||
const { isOffline } = useConnectionState();
|
||||
|
||||
// Exécuter une étape
|
||||
// NOTE: On n'utilise plus isOffline comme bloqueur car l'état peut être obsolète
|
||||
// On essaie toujours d'exécuter et on gère l'erreur si elle survient
|
||||
const executeStep = useCallback(async (stepData: {
|
||||
stepId: string;
|
||||
stepType: string;
|
||||
parameters: any;
|
||||
workflowId?: string;
|
||||
}) => {
|
||||
// Toujours essayer l'exécution - l'erreur sera gérée par makeRequest si l'API est vraiment hors ligne
|
||||
return api.executeRequest(() => apiClient.executeStep(stepData));
|
||||
}, [api]);
|
||||
|
||||
// Exécuter un workflow complet
|
||||
const executeWorkflow = useCallback(async (workflowId: string, parameters?: any) => {
|
||||
// Toujours essayer l'exécution
|
||||
return api.executeRequest(() => apiClient.executeWorkflow(workflowId, parameters));
|
||||
}, [api]);
|
||||
|
||||
return {
|
||||
...api,
|
||||
isOffline,
|
||||
executeStep,
|
||||
executeWorkflow,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook pour surveiller la santé de l'API
|
||||
* Optimisé pour éviter les re-renders excessifs
|
||||
*/
|
||||
export function useApiHealth(options: UseApiClientOptions & {
|
||||
pollInterval?: number;
|
||||
enablePolling?: boolean;
|
||||
} = {}) {
|
||||
const { pollInterval = 30000, enablePolling = false } = options;
|
||||
const api = useApiClient<{ status: string; timestamp: string }>({ ...options, silentOffline: true });
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const { connectionState, isOnline, forceCheck } = useConnectionState();
|
||||
|
||||
// Vérifier la santé de l'API
|
||||
const checkHealth = useCallback(async () => {
|
||||
return api.executeRequest(() => apiClient.healthCheck(), { skipLoading: true });
|
||||
}, [api]);
|
||||
|
||||
// Démarrer le polling
|
||||
const startPolling = useCallback(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
|
||||
intervalRef.current = setInterval(() => {
|
||||
checkHealth();
|
||||
}, pollInterval);
|
||||
|
||||
// Vérification initiale
|
||||
checkHealth();
|
||||
}, [checkHealth, pollInterval]);
|
||||
|
||||
// Arrêter le polling
|
||||
const stopPolling = useCallback(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Démarrer le polling automatiquement si activé
|
||||
useEffect(() => {
|
||||
if (enablePolling) {
|
||||
startPolling();
|
||||
}
|
||||
|
||||
return () => {
|
||||
stopPolling();
|
||||
};
|
||||
}, [enablePolling, startPolling, stopPolling]);
|
||||
|
||||
return {
|
||||
...api,
|
||||
checkHealth,
|
||||
startPolling,
|
||||
stopPolling,
|
||||
forceCheck,
|
||||
isHealthy: isOnline,
|
||||
connectionState,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook pour les statistiques de l'API
|
||||
*/
|
||||
export function useApiStats(options: UseApiClientOptions = {}) {
|
||||
const api = useApiClient<any>({ ...options, silentOffline: true });
|
||||
|
||||
// Charger les statistiques
|
||||
const loadStats = useCallback(async () => {
|
||||
return api.executeRequest(() => apiClient.getApiStats());
|
||||
}, [api]);
|
||||
|
||||
return {
|
||||
...api,
|
||||
loadStats,
|
||||
};
|
||||
}
|
||||
|
||||
// Export des types
|
||||
export type { RequestState, UseApiClientOptions };
|
||||
546
visual_workflow_builder/frontend/src/hooks/useAutoSave.ts
Normal file
546
visual_workflow_builder/frontend/src/hooks/useAutoSave.ts
Normal file
@@ -0,0 +1,546 @@
|
||||
/**
|
||||
* Hook useAutoSave - Sauvegarde automatique avec debouncing
|
||||
* Auteur : Dom, Alice, Kiro - 12 janvier 2026
|
||||
*
|
||||
* Ce hook fournit un système de sauvegarde automatique avec debouncing,
|
||||
* gestion d'erreurs et états de sauvegarde pour les paramètres d'étapes.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* Options de configuration pour l'auto-sauvegarde
|
||||
*/
|
||||
export interface AutoSaveOptions {
|
||||
debounceMs?: number;
|
||||
maxRetries?: number;
|
||||
retryDelayMs?: number;
|
||||
enableLogging?: boolean;
|
||||
onSaveStart?: () => void;
|
||||
onSaveSuccess?: (data: any) => void;
|
||||
onSaveError?: (error: Error) => void;
|
||||
onSaveComplete?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* État de la sauvegarde
|
||||
*/
|
||||
export interface SaveState {
|
||||
isSaving: boolean;
|
||||
isDirty: boolean;
|
||||
lastSaved: number | null;
|
||||
error: Error | null;
|
||||
retryCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Résultat du hook useAutoSave
|
||||
*/
|
||||
export interface UseAutoSaveResult {
|
||||
saveState: SaveState;
|
||||
triggerSave: (data: any) => void;
|
||||
forceSave: (data: any) => Promise<void>;
|
||||
clearDirty: () => void;
|
||||
resetError: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook useAutoSave
|
||||
*/
|
||||
export function useAutoSave(
|
||||
saveFunction: (data: any) => Promise<void>,
|
||||
options: AutoSaveOptions = {}
|
||||
): UseAutoSaveResult {
|
||||
|
||||
// Options par défaut
|
||||
const config = {
|
||||
debounceMs: 1000,
|
||||
maxRetries: 3,
|
||||
retryDelayMs: 2000,
|
||||
enableLogging: process.env.NODE_ENV === 'development',
|
||||
...options
|
||||
};
|
||||
|
||||
// État de sauvegarde
|
||||
const [saveState, setSaveState] = useState<SaveState>({
|
||||
isSaving: false,
|
||||
isDirty: false,
|
||||
lastSaved: null,
|
||||
error: null,
|
||||
retryCount: 0
|
||||
});
|
||||
|
||||
// Références pour éviter les re-rendus
|
||||
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const pendingDataRef = useRef<any>(null);
|
||||
const isUnmountedRef = useRef(false);
|
||||
const retryCountRef = useRef(0);
|
||||
const configRef = useRef(config);
|
||||
|
||||
// Mettre à jour la ref config quand elle change
|
||||
useEffect(() => {
|
||||
configRef.current = config;
|
||||
}, [config]);
|
||||
|
||||
/**
|
||||
* Fonction de sauvegarde avec gestion d'erreurs et retry
|
||||
* Note: Utilise des refs pour éviter les dépendances instables
|
||||
*/
|
||||
const performSave = useCallback(async (data: any, isRetry = false): Promise<void> => {
|
||||
if (isUnmountedRef.current) return;
|
||||
|
||||
const cfg = configRef.current;
|
||||
|
||||
try {
|
||||
// Marquer le début de la sauvegarde
|
||||
setSaveState(prev => ({
|
||||
...prev,
|
||||
isSaving: true,
|
||||
error: null
|
||||
}));
|
||||
|
||||
if (cfg.onSaveStart) {
|
||||
cfg.onSaveStart();
|
||||
}
|
||||
|
||||
if (cfg.enableLogging) {
|
||||
console.log('💾 [useAutoSave] Début de sauvegarde:', {
|
||||
dataSize: JSON.stringify(data).length,
|
||||
isRetry,
|
||||
retryCount: retryCountRef.current
|
||||
});
|
||||
}
|
||||
|
||||
// Exécuter la fonction de sauvegarde
|
||||
await saveFunction(data);
|
||||
|
||||
// Sauvegarde réussie
|
||||
if (!isUnmountedRef.current) {
|
||||
retryCountRef.current = 0;
|
||||
setSaveState(prev => ({
|
||||
...prev,
|
||||
isSaving: false,
|
||||
isDirty: false,
|
||||
lastSaved: Date.now(),
|
||||
error: null,
|
||||
retryCount: 0
|
||||
}));
|
||||
|
||||
if (cfg.onSaveSuccess) {
|
||||
cfg.onSaveSuccess(data);
|
||||
}
|
||||
|
||||
if (cfg.enableLogging) {
|
||||
console.log('✅ [useAutoSave] Sauvegarde réussie');
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
const errorObj = error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
if (cfg.enableLogging) {
|
||||
console.error('❌ [useAutoSave] Erreur de sauvegarde:', errorObj.message);
|
||||
}
|
||||
|
||||
if (!isUnmountedRef.current) {
|
||||
retryCountRef.current++;
|
||||
const newRetryCount = retryCountRef.current;
|
||||
|
||||
// Tentative de retry si pas encore atteint le maximum
|
||||
if (newRetryCount <= cfg.maxRetries && !isRetry) {
|
||||
if (cfg.enableLogging) {
|
||||
console.log(`🔄 [useAutoSave] Retry ${newRetryCount}/${cfg.maxRetries} dans ${cfg.retryDelayMs}ms`);
|
||||
}
|
||||
|
||||
setSaveState(prev => ({
|
||||
...prev,
|
||||
retryCount: newRetryCount,
|
||||
error: errorObj
|
||||
}));
|
||||
|
||||
// Programmer le retry
|
||||
setTimeout(() => {
|
||||
if (!isUnmountedRef.current) {
|
||||
performSave(data, true);
|
||||
}
|
||||
}, cfg.retryDelayMs);
|
||||
} else {
|
||||
// Échec définitif
|
||||
setSaveState(prev => ({
|
||||
...prev,
|
||||
isSaving: false,
|
||||
error: errorObj,
|
||||
retryCount: newRetryCount
|
||||
}));
|
||||
}
|
||||
|
||||
if (cfg.onSaveError) {
|
||||
cfg.onSaveError(errorObj);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (!isUnmountedRef.current && cfg.onSaveComplete) {
|
||||
cfg.onSaveComplete();
|
||||
}
|
||||
}
|
||||
}, [saveFunction]); // Dépendance minimale - utilise des refs pour le reste
|
||||
|
||||
/**
|
||||
* Déclenche une sauvegarde avec debouncing
|
||||
*/
|
||||
const triggerSave = useCallback((data: any) => {
|
||||
// Stocker les données en attente
|
||||
pendingDataRef.current = data;
|
||||
const cfg = configRef.current;
|
||||
|
||||
// Marquer comme dirty
|
||||
setSaveState(prev => ({
|
||||
...prev,
|
||||
isDirty: true,
|
||||
error: null
|
||||
}));
|
||||
|
||||
// Annuler le timeout précédent
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Programmer la nouvelle sauvegarde
|
||||
debounceTimeoutRef.current = setTimeout(() => {
|
||||
if (!isUnmountedRef.current && pendingDataRef.current !== null) {
|
||||
performSave(pendingDataRef.current);
|
||||
pendingDataRef.current = null;
|
||||
}
|
||||
}, cfg.debounceMs);
|
||||
|
||||
if (cfg.enableLogging) {
|
||||
console.log(`⏱️ [useAutoSave] Sauvegarde programmée dans ${cfg.debounceMs}ms`);
|
||||
}
|
||||
}, [performSave]); // Dépendance minimale
|
||||
|
||||
/**
|
||||
* Force une sauvegarde immédiate (sans debouncing)
|
||||
*/
|
||||
const forceSave = useCallback(async (data: any): Promise<void> => {
|
||||
// Annuler le debouncing en cours
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
debounceTimeoutRef.current = undefined;
|
||||
}
|
||||
|
||||
pendingDataRef.current = null;
|
||||
|
||||
if (configRef.current.enableLogging) {
|
||||
console.log('⚡ [useAutoSave] Sauvegarde forcée');
|
||||
}
|
||||
|
||||
await performSave(data);
|
||||
}, [performSave]); // Dépendance minimale
|
||||
|
||||
/**
|
||||
* Marque les données comme propres (non modifiées)
|
||||
*/
|
||||
const clearDirty = useCallback(() => {
|
||||
setSaveState(prev => ({
|
||||
...prev,
|
||||
isDirty: false
|
||||
}));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Remet à zéro l'erreur de sauvegarde
|
||||
*/
|
||||
const resetError = useCallback(() => {
|
||||
setSaveState(prev => ({
|
||||
...prev,
|
||||
error: null,
|
||||
retryCount: 0
|
||||
}));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Cleanup à la destruction du composant
|
||||
*/
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isUnmountedRef.current = true;
|
||||
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Sauvegarder les données en attente si nécessaire
|
||||
if (pendingDataRef.current !== null && !saveState.isSaving) {
|
||||
if (config.enableLogging) {
|
||||
console.log('🧹 [useAutoSave] Sauvegarde finale au cleanup');
|
||||
}
|
||||
|
||||
// Sauvegarde synchrone finale (best effort)
|
||||
try {
|
||||
saveFunction(pendingDataRef.current);
|
||||
} catch (error) {
|
||||
console.error('❌ [useAutoSave] Erreur sauvegarde finale:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [saveFunction, saveState.isSaving, config.enableLogging]);
|
||||
|
||||
return {
|
||||
saveState,
|
||||
triggerSave,
|
||||
forceSave,
|
||||
clearDirty,
|
||||
resetError
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook spécialisé pour la sauvegarde des paramètres d'étapes
|
||||
*
|
||||
* IMPORTANT: Ce hook gère correctement les changements de stepId pour éviter
|
||||
* que les paramètres d'une ancienne étape soient sauvegardés vers une nouvelle étape.
|
||||
*/
|
||||
export function useStepParametersAutoSave(
|
||||
stepId: string,
|
||||
onParameterChange: (stepId: string, paramName: string, value: any) => void,
|
||||
options: AutoSaveOptions = {}
|
||||
): UseAutoSaveResult {
|
||||
|
||||
// Référence pour tracker le stepId actuel et annuler les sauvegardes obsolètes
|
||||
const currentStepIdRef = useRef<string>(stepId);
|
||||
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const pendingDataRef = useRef<Record<string, any> | null>(null);
|
||||
|
||||
// État de sauvegarde local
|
||||
const [saveState, setSaveState] = useState<SaveState>({
|
||||
isSaving: false,
|
||||
isDirty: false,
|
||||
lastSaved: null,
|
||||
error: null,
|
||||
retryCount: 0
|
||||
});
|
||||
|
||||
// Configuration
|
||||
const config = {
|
||||
debounceMs: 800,
|
||||
maxRetries: 2,
|
||||
retryDelayMs: 1500,
|
||||
enableLogging: process.env.NODE_ENV === 'development',
|
||||
...options
|
||||
};
|
||||
|
||||
// CRITIQUE: Quand le stepId change, annuler toute sauvegarde en attente
|
||||
// pour éviter que les paramètres de l'ancienne étape soient sauvegardés vers la nouvelle
|
||||
useEffect(() => {
|
||||
if (currentStepIdRef.current !== stepId) {
|
||||
if (config.enableLogging) {
|
||||
console.log('🔄 [useStepParametersAutoSave] StepId changé:', {
|
||||
ancien: currentStepIdRef.current,
|
||||
nouveau: stepId
|
||||
});
|
||||
|
||||
if (pendingDataRef.current !== null) {
|
||||
console.log('⚠️ [useStepParametersAutoSave] Annulation sauvegarde en attente pour éviter contamination');
|
||||
}
|
||||
}
|
||||
|
||||
// Annuler le timeout de debounce
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
debounceTimeoutRef.current = undefined;
|
||||
}
|
||||
|
||||
// Effacer les données en attente (CRITIQUE pour éviter le bug)
|
||||
pendingDataRef.current = null;
|
||||
|
||||
// Mettre à jour la référence
|
||||
currentStepIdRef.current = stepId;
|
||||
|
||||
// Réinitialiser l'état
|
||||
setSaveState({
|
||||
isSaving: false,
|
||||
isDirty: false,
|
||||
lastSaved: null,
|
||||
error: null,
|
||||
retryCount: 0
|
||||
});
|
||||
}
|
||||
}, [stepId, config.enableLogging]);
|
||||
|
||||
// Fonction de sauvegarde
|
||||
const performSave = useCallback(async (parametersData: Record<string, any>, targetStepId: string) => {
|
||||
// CRITIQUE: Vérifier que le stepId cible correspond toujours au stepId actuel
|
||||
if (targetStepId !== currentStepIdRef.current) {
|
||||
if (config.enableLogging) {
|
||||
console.log('⚠️ [useStepParametersAutoSave] Sauvegarde annulée - stepId obsolète:', {
|
||||
target: targetStepId,
|
||||
current: currentStepIdRef.current
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!targetStepId) {
|
||||
throw new Error('Step ID is required for parameter save');
|
||||
}
|
||||
|
||||
try {
|
||||
setSaveState(prev => ({ ...prev, isSaving: true, error: null }));
|
||||
|
||||
if (config.onSaveStart) {
|
||||
config.onSaveStart();
|
||||
}
|
||||
|
||||
// Sauvegarder chaque paramètre individuellement
|
||||
const savePromises = Object.entries(parametersData).map(([paramName, value]) => {
|
||||
return new Promise<void>((resolve) => {
|
||||
onParameterChange(targetStepId, paramName, value);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
await Promise.all(savePromises);
|
||||
|
||||
setSaveState(prev => ({
|
||||
...prev,
|
||||
isSaving: false,
|
||||
isDirty: false,
|
||||
lastSaved: Date.now(),
|
||||
error: null,
|
||||
retryCount: 0
|
||||
}));
|
||||
|
||||
if (config.onSaveSuccess) {
|
||||
config.onSaveSuccess(parametersData);
|
||||
}
|
||||
|
||||
if (config.enableLogging) {
|
||||
console.log('✅ [useStepParametersAutoSave] Sauvegarde réussie pour stepId:', targetStepId);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
const errorObj = error instanceof Error ? error : new Error(String(error));
|
||||
setSaveState(prev => ({ ...prev, isSaving: false, error: errorObj }));
|
||||
|
||||
if (config.onSaveError) {
|
||||
config.onSaveError(errorObj);
|
||||
}
|
||||
}
|
||||
}, [onParameterChange, config]);
|
||||
|
||||
// Déclencher une sauvegarde avec debouncing
|
||||
const triggerSave = useCallback((data: Record<string, any>) => {
|
||||
// Capturer le stepId actuel au moment du déclenchement
|
||||
const targetStepId = currentStepIdRef.current;
|
||||
|
||||
if (!targetStepId) {
|
||||
if (config.enableLogging) {
|
||||
console.log('⚠️ [useStepParametersAutoSave] Pas de stepId, sauvegarde ignorée');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Stocker les données en attente
|
||||
pendingDataRef.current = data;
|
||||
|
||||
setSaveState(prev => ({ ...prev, isDirty: true, error: null }));
|
||||
|
||||
// Annuler le timeout précédent
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Programmer la nouvelle sauvegarde avec le stepId capturé
|
||||
debounceTimeoutRef.current = setTimeout(() => {
|
||||
if (pendingDataRef.current !== null) {
|
||||
// CRITIQUE: Utiliser le targetStepId capturé, pas currentStepIdRef.current
|
||||
// ET vérifier que c'est toujours valide
|
||||
if (targetStepId === currentStepIdRef.current) {
|
||||
performSave(pendingDataRef.current, targetStepId);
|
||||
} else if (config.enableLogging) {
|
||||
console.log('⚠️ [useStepParametersAutoSave] Sauvegarde debounced annulée - stepId changé');
|
||||
}
|
||||
pendingDataRef.current = null;
|
||||
}
|
||||
}, config.debounceMs);
|
||||
|
||||
if (config.enableLogging) {
|
||||
console.log(`⏱️ [useStepParametersAutoSave] Sauvegarde programmée pour ${targetStepId} dans ${config.debounceMs}ms`);
|
||||
}
|
||||
}, [performSave, config]);
|
||||
|
||||
// Force une sauvegarde immédiate
|
||||
const forceSave = useCallback(async (data: Record<string, any>): Promise<void> => {
|
||||
const targetStepId = currentStepIdRef.current;
|
||||
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
debounceTimeoutRef.current = undefined;
|
||||
}
|
||||
|
||||
pendingDataRef.current = null;
|
||||
|
||||
if (targetStepId) {
|
||||
await performSave(data, targetStepId);
|
||||
}
|
||||
}, [performSave]);
|
||||
|
||||
const clearDirty = useCallback(() => {
|
||||
setSaveState(prev => ({ ...prev, isDirty: false }));
|
||||
}, []);
|
||||
|
||||
const resetError = useCallback(() => {
|
||||
setSaveState(prev => ({ ...prev, error: null, retryCount: 0 }));
|
||||
}, []);
|
||||
|
||||
// Cleanup
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
saveState,
|
||||
triggerSave,
|
||||
forceSave,
|
||||
clearDirty,
|
||||
resetError
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook pour la sauvegarde avec état de synchronisation
|
||||
*/
|
||||
export function useSyncedAutoSave(
|
||||
saveFunction: (data: any) => Promise<void>,
|
||||
options: AutoSaveOptions = {}
|
||||
) {
|
||||
const autoSave = useAutoSave(saveFunction, options);
|
||||
const [syncState, setSyncState] = useState<'synced' | 'pending' | 'error'>('synced');
|
||||
|
||||
// Mettre à jour l'état de synchronisation
|
||||
useEffect(() => {
|
||||
if (autoSave.saveState.isSaving) {
|
||||
setSyncState('pending');
|
||||
} else if (autoSave.saveState.error) {
|
||||
setSyncState('error');
|
||||
} else if (!autoSave.saveState.isDirty) {
|
||||
setSyncState('synced');
|
||||
} else {
|
||||
setSyncState('pending');
|
||||
}
|
||||
}, [autoSave.saveState]);
|
||||
|
||||
return {
|
||||
...autoSave,
|
||||
syncState
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Export par défaut
|
||||
*/
|
||||
export default useAutoSave;
|
||||
421
visual_workflow_builder/frontend/src/hooks/useCatalogActions.ts
Normal file
421
visual_workflow_builder/frontend/src/hooks/useCatalogActions.ts
Normal file
@@ -0,0 +1,421 @@
|
||||
/**
|
||||
* Hook useCatalogActions - Gestion de l'état du catalogue d'actions VisionOnly
|
||||
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
|
||||
*
|
||||
* Ce hook gère le chargement, la mise en cache, et la synchronisation
|
||||
* des actions du catalogue VisionOnly avec l'API backend.
|
||||
*
|
||||
* NOUVEAUTÉS v2.0:
|
||||
* - Support du mode statique automatique (fallback hors ligne)
|
||||
* - Détection automatique de l'URL du backend
|
||||
* - Indicateurs de mode (dynamique/statique)
|
||||
* - Gestion cross-machine robuste
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { catalogService } from '../services/catalogService';
|
||||
import {
|
||||
VWBCatalogAction,
|
||||
VWBActionCategory,
|
||||
VWBActionCategoryInfo,
|
||||
VWBCatalogHealth,
|
||||
VWBServiceStatus
|
||||
} from '../types/catalog';
|
||||
|
||||
// Interface pour l'état du catalogue étendu
|
||||
interface CatalogState {
|
||||
actions: VWBCatalogAction[];
|
||||
categories: VWBActionCategoryInfo[];
|
||||
health: VWBCatalogHealth | null;
|
||||
isLoading: boolean;
|
||||
isOnline: boolean;
|
||||
error: string | null;
|
||||
lastUpdate: Date | null;
|
||||
mode: 'dynamic' | 'static' | 'offline'; // Nouveau : mode du catalogue
|
||||
serviceUrl: string | null; // Nouveau : URL du service actuel
|
||||
}
|
||||
|
||||
// Interface pour les options du hook
|
||||
interface UseCatalogActionsOptions {
|
||||
/** Charger automatiquement au montage */
|
||||
autoLoad?: boolean;
|
||||
/** Intervalle de rafraîchissement automatique (en ms) */
|
||||
refreshInterval?: number;
|
||||
/** Catégorie à filtrer */
|
||||
filterCategory?: VWBActionCategory;
|
||||
/** Terme de recherche */
|
||||
searchTerm?: string;
|
||||
}
|
||||
|
||||
// Interface pour le retour du hook étendu
|
||||
interface UseCatalogActionsReturn {
|
||||
/** État actuel du catalogue */
|
||||
state: CatalogState;
|
||||
/** Actions filtrées selon les critères */
|
||||
filteredActions: VWBCatalogAction[];
|
||||
/** Statistiques du catalogue */
|
||||
stats: {
|
||||
totalActions: number;
|
||||
actionsByCategory: Record<VWBActionCategory, number>;
|
||||
averageComplexity: string;
|
||||
onlineStatus: boolean;
|
||||
mode: 'dynamic' | 'static' | 'offline'; // Nouveau
|
||||
serviceUrl: string | null; // Nouveau
|
||||
};
|
||||
/** Actions disponibles */
|
||||
actions: {
|
||||
/** Recharger le catalogue */
|
||||
reload: () => Promise<void>;
|
||||
/** Rechercher des actions */
|
||||
search: (term: string) => VWBCatalogAction[];
|
||||
/** Obtenir une action par ID */
|
||||
getAction: (id: string) => VWBCatalogAction | null;
|
||||
/** Vider le cache */
|
||||
clearCache: () => void;
|
||||
/** Forcer une mise à jour de santé */
|
||||
checkHealth: () => Promise<void>;
|
||||
/** Forcer la détection d'URL (nouveau) */
|
||||
forceUrlDetection: () => Promise<boolean>;
|
||||
/** Réinitialiser complètement le service (nouveau) */
|
||||
resetService: () => Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook pour gérer les actions du catalogue VisionOnly
|
||||
*/
|
||||
export const useCatalogActions = (options: UseCatalogActionsOptions = {}): UseCatalogActionsReturn => {
|
||||
const {
|
||||
autoLoad = true,
|
||||
refreshInterval,
|
||||
filterCategory,
|
||||
searchTerm = '',
|
||||
} = options;
|
||||
|
||||
// État du catalogue étendu
|
||||
const [state, setState] = useState<CatalogState>({
|
||||
actions: [],
|
||||
categories: [],
|
||||
health: null,
|
||||
isLoading: false,
|
||||
isOnline: false,
|
||||
error: null,
|
||||
lastUpdate: null,
|
||||
mode: 'offline',
|
||||
serviceUrl: null,
|
||||
});
|
||||
|
||||
// Charger les données du catalogue avec fallback automatique
|
||||
const loadCatalogData = useCallback(async () => {
|
||||
setState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
console.log('🔄 Chargement du catalogue d\'actions VisionOnly...');
|
||||
|
||||
// Obtenir l'état du service pour connaître le mode
|
||||
const serviceState = catalogService.getServiceState();
|
||||
|
||||
// Charger en parallèle les actions, catégories et santé
|
||||
const [actionsResult, categoriesResult, healthResult] = await Promise.all([
|
||||
catalogService.getActions(filterCategory),
|
||||
catalogService.getCategories(),
|
||||
catalogService.getHealth(),
|
||||
]);
|
||||
|
||||
// Adapter les types retournés par le service aux types VWB
|
||||
const adaptedCategories: VWBActionCategoryInfo[] = categoriesResult.map(cat => ({
|
||||
id: cat.id,
|
||||
name: cat.name,
|
||||
description: cat.description,
|
||||
icon: cat.icon,
|
||||
color: '#2196f3', // Couleur par défaut
|
||||
actionCount: cat.actionCount,
|
||||
isEnabled: true, // Activé par défaut
|
||||
}));
|
||||
|
||||
const adaptedHealth: VWBCatalogHealth = {
|
||||
status: healthResult.status as VWBServiceStatus,
|
||||
services: {
|
||||
screenCapturer: healthResult.services.screenCapturer,
|
||||
actions: healthResult.services.actions,
|
||||
screenCapturerMethod: healthResult.services.screenCapturerMethod,
|
||||
},
|
||||
timestamp: healthResult.timestamp,
|
||||
version: healthResult.version,
|
||||
};
|
||||
|
||||
// Déterminer le mode basé sur les résultats
|
||||
const mode = actionsResult.mode || serviceState.mode;
|
||||
const isOnline = mode === 'dynamic' && adaptedHealth.status === 'healthy';
|
||||
|
||||
setState({
|
||||
actions: actionsResult.actions as VWBCatalogAction[],
|
||||
categories: adaptedCategories,
|
||||
health: adaptedHealth,
|
||||
isLoading: false,
|
||||
isOnline,
|
||||
error: null,
|
||||
lastUpdate: new Date(),
|
||||
mode,
|
||||
serviceUrl: serviceState.currentUrl,
|
||||
});
|
||||
|
||||
const modeEmoji = mode === 'dynamic' ? '🌐' : mode === 'static' ? '📦' : '🔴';
|
||||
console.log(`${modeEmoji} Catalogue chargé (${mode}): ${actionsResult.actions.length} actions, ${adaptedCategories.length} catégories`);
|
||||
|
||||
if (mode === 'static') {
|
||||
console.log('📦 Mode statique activé - Catalogue hors ligne disponible');
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Erreur inconnue';
|
||||
console.error('❌ Erreur lors du chargement du catalogue:', errorMessage);
|
||||
|
||||
// En cas d'erreur, essayer le mode statique
|
||||
const serviceState = catalogService.getServiceState();
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
isOnline: false,
|
||||
error: errorMessage,
|
||||
mode: serviceState.mode || 'offline',
|
||||
serviceUrl: serviceState.currentUrl,
|
||||
}));
|
||||
}
|
||||
}, [filterCategory]);
|
||||
|
||||
// Recharger le catalogue
|
||||
const reload = useCallback(async () => {
|
||||
await loadCatalogData();
|
||||
}, [loadCatalogData]);
|
||||
|
||||
// Vérifier la santé du service
|
||||
const checkHealth = useCallback(async () => {
|
||||
try {
|
||||
const healthResult = await catalogService.getHealth();
|
||||
const adaptedHealth: VWBCatalogHealth = {
|
||||
status: healthResult.status as VWBServiceStatus,
|
||||
services: {
|
||||
screenCapturer: healthResult.services.screenCapturer,
|
||||
actions: healthResult.services.actions,
|
||||
screenCapturerMethod: healthResult.services.screenCapturerMethod,
|
||||
},
|
||||
timestamp: healthResult.timestamp,
|
||||
version: healthResult.version,
|
||||
};
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
health: adaptedHealth,
|
||||
isOnline: adaptedHealth.status === 'healthy',
|
||||
error: adaptedHealth.status === 'healthy' ? null : prev.error,
|
||||
}));
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isOnline: false,
|
||||
error: error instanceof Error ? error.message : 'Erreur de santé',
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Rechercher des actions
|
||||
const search = useCallback((term: string): VWBCatalogAction[] => {
|
||||
if (!term.trim()) return state.actions;
|
||||
|
||||
const searchLower = term.toLowerCase();
|
||||
return state.actions.filter(action =>
|
||||
action.name.toLowerCase().includes(searchLower) ||
|
||||
action.description.toLowerCase().includes(searchLower) ||
|
||||
action.id.toLowerCase().includes(searchLower) ||
|
||||
Object.keys(action.parameters).some(param =>
|
||||
param.toLowerCase().includes(searchLower)
|
||||
)
|
||||
);
|
||||
}, [state.actions]);
|
||||
|
||||
// Obtenir une action par ID
|
||||
const getAction = useCallback((id: string): VWBCatalogAction | null => {
|
||||
return state.actions.find(action => action.id === id) || null;
|
||||
}, [state.actions]);
|
||||
|
||||
// Vider le cache
|
||||
const clearCache = useCallback(() => {
|
||||
catalogService.clearCache();
|
||||
console.log('🗑️ Cache du catalogue vidé');
|
||||
}, []);
|
||||
|
||||
// Forcer la détection d'URL (nouveau)
|
||||
const forceUrlDetection = useCallback(async (): Promise<boolean> => {
|
||||
console.log('🔄 Détection forcée de l\'URL du backend...');
|
||||
|
||||
try {
|
||||
const success = await catalogService.forceUrlDetection();
|
||||
|
||||
if (success) {
|
||||
// Recharger le catalogue après détection réussie
|
||||
await loadCatalogData();
|
||||
console.log('✅ Détection d\'URL réussie, catalogue rechargé');
|
||||
} else {
|
||||
console.log('❌ Aucun backend accessible lors de la détection forcée');
|
||||
}
|
||||
|
||||
return success;
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la détection forcée d\'URL:', error);
|
||||
return false;
|
||||
}
|
||||
}, [loadCatalogData]);
|
||||
|
||||
// Réinitialiser complètement le service (nouveau)
|
||||
const resetService = useCallback(async (): Promise<void> => {
|
||||
console.log('🔄 Réinitialisation complète du service catalogue...');
|
||||
|
||||
try {
|
||||
await catalogService.reset();
|
||||
|
||||
// Recharger le catalogue après réinitialisation
|
||||
await loadCatalogData();
|
||||
|
||||
console.log('✅ Service catalogue réinitialisé et rechargé');
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la réinitialisation du service:', error);
|
||||
|
||||
// Mettre à jour l'état avec l'erreur
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: error instanceof Error ? error.message : 'Erreur de réinitialisation',
|
||||
isLoading: false,
|
||||
}));
|
||||
}
|
||||
}, [loadCatalogData]);
|
||||
|
||||
// Actions filtrées selon les critères
|
||||
const filteredActions = useMemo(() => {
|
||||
let filtered = state.actions;
|
||||
|
||||
// Filtrer par catégorie si spécifiée
|
||||
if (filterCategory) {
|
||||
filtered = filtered.filter(action => action.category === filterCategory);
|
||||
}
|
||||
|
||||
// Filtrer par terme de recherche
|
||||
if (searchTerm.trim()) {
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
filtered = filtered.filter(action =>
|
||||
action.name.toLowerCase().includes(searchLower) ||
|
||||
action.description.toLowerCase().includes(searchLower) ||
|
||||
action.id.toLowerCase().includes(searchLower)
|
||||
);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [state.actions, filterCategory, searchTerm]);
|
||||
|
||||
// Statistiques du catalogue
|
||||
const stats = useMemo(() => {
|
||||
const actionsByCategory = state.actions.reduce((acc, action) => {
|
||||
acc[action.category] = (acc[action.category] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<VWBActionCategory, number>);
|
||||
|
||||
// Calculer la complexité moyenne
|
||||
const complexities = state.actions
|
||||
.map(action => action.metadata?.complexity)
|
||||
.filter(Boolean);
|
||||
|
||||
const complexityScores = { simple: 1, intermediate: 2, advanced: 3 };
|
||||
const avgScore = complexities.length > 0
|
||||
? complexities.reduce((sum, complexity) =>
|
||||
sum + (complexityScores[complexity as keyof typeof complexityScores] || 1), 0
|
||||
) / complexities.length
|
||||
: 1;
|
||||
|
||||
const averageComplexity = avgScore <= 1.5 ? 'simple' : avgScore <= 2.5 ? 'intermediate' : 'advanced';
|
||||
|
||||
return {
|
||||
totalActions: state.actions.length,
|
||||
actionsByCategory,
|
||||
averageComplexity,
|
||||
onlineStatus: state.isOnline,
|
||||
mode: state.mode,
|
||||
serviceUrl: state.serviceUrl,
|
||||
};
|
||||
}, [state.actions, state.isOnline, state.mode, state.serviceUrl]);
|
||||
|
||||
// Chargement automatique au montage
|
||||
useEffect(() => {
|
||||
if (autoLoad) {
|
||||
loadCatalogData();
|
||||
}
|
||||
}, [autoLoad, loadCatalogData]);
|
||||
|
||||
// Rafraîchissement automatique
|
||||
useEffect(() => {
|
||||
if (!refreshInterval || refreshInterval <= 0) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (state.isOnline) {
|
||||
checkHealth();
|
||||
}
|
||||
}, refreshInterval);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [refreshInterval, state.isOnline, checkHealth]);
|
||||
|
||||
// Recharger quand la catégorie de filtre change
|
||||
useEffect(() => {
|
||||
if (filterCategory && state.actions.length > 0) {
|
||||
// Pas besoin de recharger, juste filtrer
|
||||
return;
|
||||
}
|
||||
}, [filterCategory, state.actions.length]);
|
||||
|
||||
return {
|
||||
state,
|
||||
filteredActions,
|
||||
stats,
|
||||
actions: {
|
||||
reload,
|
||||
search,
|
||||
getAction,
|
||||
clearCache,
|
||||
checkHealth,
|
||||
forceUrlDetection,
|
||||
resetService,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// Hook simplifié pour obtenir juste les actions
|
||||
export const useCatalogActionsSimple = (category?: VWBActionCategory) => {
|
||||
const { state, filteredActions, actions } = useCatalogActions({
|
||||
filterCategory: category,
|
||||
});
|
||||
|
||||
return {
|
||||
actions: filteredActions,
|
||||
isLoading: state.isLoading,
|
||||
isOnline: state.isOnline,
|
||||
error: state.error,
|
||||
reload: actions.reload,
|
||||
};
|
||||
};
|
||||
|
||||
// Hook pour obtenir une action spécifique
|
||||
export const useCatalogAction = (actionId: string) => {
|
||||
const { state, actions } = useCatalogActions();
|
||||
|
||||
const action = useMemo(() => {
|
||||
return actions.getAction(actionId);
|
||||
}, [actions, actionId]);
|
||||
|
||||
return {
|
||||
action,
|
||||
isLoading: state.isLoading,
|
||||
error: state.error,
|
||||
reload: actions.reload,
|
||||
};
|
||||
};
|
||||
|
||||
export default useCatalogActions;
|
||||
@@ -77,10 +77,36 @@ const initialStats: CoachingStats = {
|
||||
correctionRate: 0,
|
||||
};
|
||||
|
||||
// SINGLETON: Socket partagé entre toutes les instances du hook
|
||||
// Évite les connexions multiples quand le composant est monté/démonté
|
||||
let sharedSocket: Socket | null = null;
|
||||
let socketRefCount = 0;
|
||||
const socketListeners = new Set<{
|
||||
setIsConnected: (v: boolean) => void;
|
||||
setIsSubscribed: (v: boolean) => void;
|
||||
setCurrentSuggestion: (v: CoachingSuggestion | null) => void;
|
||||
setStats: (v: CoachingStats) => void;
|
||||
setLastActionResult: (v: CoachingActionResult | null) => void;
|
||||
setError: (v: string | null) => void;
|
||||
}>();
|
||||
|
||||
// Convert backend stats format to frontend format (moved outside hook)
|
||||
const convertStats = (backendStats: Record<string, any>): CoachingStats => {
|
||||
return {
|
||||
suggestionsMade: backendStats.suggestions_made || 0,
|
||||
accepted: backendStats.accepted || 0,
|
||||
rejected: backendStats.rejected || 0,
|
||||
corrected: backendStats.corrected || 0,
|
||||
manualExecutions: backendStats.manual_executions || 0,
|
||||
acceptanceRate: backendStats.acceptance_rate || 0,
|
||||
correctionRate: backendStats.correction_rate || 0,
|
||||
};
|
||||
};
|
||||
|
||||
export function useCoachingWebSocket(
|
||||
options: UseCoachingWebSocketOptions = {}
|
||||
): UseCoachingWebSocketReturn {
|
||||
const { serverUrl = 'http://localhost:5000', autoConnect = true } = options;
|
||||
const { serverUrl = 'http://localhost:5001', autoConnect = true } = options;
|
||||
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isSubscribed, setIsSubscribed] = useState(false);
|
||||
@@ -89,13 +115,29 @@ export function useCoachingWebSocket(
|
||||
const [lastActionResult, setLastActionResult] = useState<CoachingActionResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
const executionIdRef = useRef<string | null>(null);
|
||||
const listenerRef = useRef({ setIsConnected, setIsSubscribed, setCurrentSuggestion, setStats, setLastActionResult, setError });
|
||||
|
||||
// Initialize socket connection
|
||||
// Mettre à jour la ref avec les setters actuels
|
||||
useEffect(() => {
|
||||
listenerRef.current = { setIsConnected, setIsSubscribed, setCurrentSuggestion, setStats, setLastActionResult, setError };
|
||||
});
|
||||
|
||||
// Initialize socket connection (SINGLETON)
|
||||
useEffect(() => {
|
||||
if (!autoConnect) return;
|
||||
|
||||
// Ajouter ce listener à l'ensemble
|
||||
socketListeners.add(listenerRef.current);
|
||||
socketRefCount++;
|
||||
|
||||
// Si le socket existe déjà, mettre à jour l'état local
|
||||
if (sharedSocket) {
|
||||
setIsConnected(sharedSocket.connected);
|
||||
return;
|
||||
}
|
||||
|
||||
// Créer le socket partagé
|
||||
const socket = io(serverUrl, {
|
||||
transports: ['websocket', 'polling'],
|
||||
reconnection: true,
|
||||
@@ -104,40 +146,40 @@ export function useCoachingWebSocket(
|
||||
});
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('[COACHING WS] Connected');
|
||||
setIsConnected(true);
|
||||
setError(null);
|
||||
console.log('[COACHING WS] Connected (shared)');
|
||||
socketListeners.forEach(l => l.setIsConnected(true));
|
||||
socketListeners.forEach(l => l.setError(null));
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log('[COACHING WS] Disconnected');
|
||||
setIsConnected(false);
|
||||
setIsSubscribed(false);
|
||||
console.log('[COACHING WS] Disconnected (shared)');
|
||||
socketListeners.forEach(l => l.setIsConnected(false));
|
||||
socketListeners.forEach(l => l.setIsSubscribed(false));
|
||||
});
|
||||
|
||||
socket.on('connect_error', (err) => {
|
||||
console.error('[COACHING WS] Connection error:', err);
|
||||
setError(`Connection error: ${err.message}`);
|
||||
socketListeners.forEach(l => l.setError(`Connection error: ${err.message}`));
|
||||
});
|
||||
|
||||
// COACHING specific events
|
||||
socket.on('coaching_subscribed', (data) => {
|
||||
console.log('[COACHING WS] Subscribed:', data);
|
||||
setIsSubscribed(true);
|
||||
socketListeners.forEach(l => l.setIsSubscribed(true));
|
||||
if (data.stats) {
|
||||
setStats(convertStats(data.stats));
|
||||
socketListeners.forEach(l => l.setStats(convertStats(data.stats)));
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('coaching_unsubscribed', () => {
|
||||
console.log('[COACHING WS] Unsubscribed');
|
||||
setIsSubscribed(false);
|
||||
setCurrentSuggestion(null);
|
||||
socketListeners.forEach(l => l.setIsSubscribed(false));
|
||||
socketListeners.forEach(l => l.setCurrentSuggestion(null));
|
||||
});
|
||||
|
||||
socket.on('coaching_suggestion', (data: any) => {
|
||||
console.log('[COACHING WS] Suggestion received:', data);
|
||||
setCurrentSuggestion({
|
||||
const suggestion: CoachingSuggestion = {
|
||||
executionId: data.execution_id,
|
||||
action: data.action,
|
||||
target: data.target || {},
|
||||
@@ -147,27 +189,28 @@ export function useCoachingWebSocket(
|
||||
screenshotPath: data.screenshot_path,
|
||||
context: data.context,
|
||||
timestamp: data.timestamp,
|
||||
});
|
||||
};
|
||||
socketListeners.forEach(l => l.setCurrentSuggestion(suggestion));
|
||||
});
|
||||
|
||||
socket.on('coaching_action_result', (data: any) => {
|
||||
console.log('[COACHING WS] Action result:', data);
|
||||
setLastActionResult({
|
||||
const result: CoachingActionResult = {
|
||||
executionId: data.execution_id,
|
||||
action: data.action,
|
||||
success: data.success,
|
||||
result: data.result,
|
||||
error: data.error,
|
||||
timestamp: data.timestamp,
|
||||
});
|
||||
// Clear current suggestion after result
|
||||
setCurrentSuggestion(null);
|
||||
};
|
||||
socketListeners.forEach(l => l.setLastActionResult(result));
|
||||
socketListeners.forEach(l => l.setCurrentSuggestion(null));
|
||||
});
|
||||
|
||||
socket.on('coaching_stats_update', (data: any) => {
|
||||
console.log('[COACHING WS] Stats update:', data);
|
||||
if (data.stats) {
|
||||
setStats(convertStats(data.stats));
|
||||
socketListeners.forEach(l => l.setStats(convertStats(data.stats)));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -181,55 +224,49 @@ export function useCoachingWebSocket(
|
||||
|
||||
socket.on('coaching_session_end', (data: any) => {
|
||||
console.log('[COACHING WS] Session ended:', data);
|
||||
setIsSubscribed(false);
|
||||
setCurrentSuggestion(null);
|
||||
socketListeners.forEach(l => l.setIsSubscribed(false));
|
||||
socketListeners.forEach(l => l.setCurrentSuggestion(null));
|
||||
if (data.stats) {
|
||||
setStats(convertStats(data.stats));
|
||||
socketListeners.forEach(l => l.setStats(convertStats(data.stats)));
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('error', (data) => {
|
||||
console.error('[COACHING WS] Error:', data);
|
||||
setError(data.message || 'Unknown error');
|
||||
socketListeners.forEach(l => l.setError(data.message || 'Unknown error'));
|
||||
});
|
||||
|
||||
socketRef.current = socket;
|
||||
sharedSocket = socket;
|
||||
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
socketRef.current = null;
|
||||
socketListeners.delete(listenerRef.current);
|
||||
socketRefCount--;
|
||||
|
||||
// Déconnecter seulement si plus aucun composant n'utilise le socket
|
||||
if (socketRefCount === 0 && sharedSocket) {
|
||||
console.log('[COACHING WS] Disconnecting shared socket (no more refs)');
|
||||
sharedSocket.disconnect();
|
||||
sharedSocket = null;
|
||||
}
|
||||
};
|
||||
}, [serverUrl, autoConnect]);
|
||||
|
||||
// Convert backend stats format to frontend format
|
||||
const convertStats = (backendStats: Record<string, any>): CoachingStats => {
|
||||
return {
|
||||
suggestionsMade: backendStats.suggestions_made || 0,
|
||||
accepted: backendStats.accepted || 0,
|
||||
rejected: backendStats.rejected || 0,
|
||||
corrected: backendStats.corrected || 0,
|
||||
manualExecutions: backendStats.manual_executions || 0,
|
||||
acceptanceRate: backendStats.acceptance_rate || 0,
|
||||
correctionRate: backendStats.correction_rate || 0,
|
||||
};
|
||||
};
|
||||
|
||||
// Subscribe to COACHING events for an execution
|
||||
const subscribe = useCallback((executionId: string) => {
|
||||
if (!socketRef.current || !isConnected) {
|
||||
if (!sharedSocket || !isConnected) {
|
||||
setError('Not connected to server');
|
||||
return;
|
||||
}
|
||||
|
||||
executionIdRef.current = executionId;
|
||||
socketRef.current.emit('subscribe_coaching', { execution_id: executionId });
|
||||
sharedSocket.emit('subscribe_coaching', { execution_id: executionId });
|
||||
}, [isConnected]);
|
||||
|
||||
// Unsubscribe from COACHING events
|
||||
const unsubscribe = useCallback(() => {
|
||||
if (!socketRef.current || !executionIdRef.current) return;
|
||||
if (!sharedSocket || !executionIdRef.current) return;
|
||||
|
||||
socketRef.current.emit('unsubscribe_coaching', {
|
||||
sharedSocket.emit('unsubscribe_coaching', {
|
||||
execution_id: executionIdRef.current,
|
||||
});
|
||||
executionIdRef.current = null;
|
||||
@@ -238,12 +275,12 @@ export function useCoachingWebSocket(
|
||||
// Submit a COACHING decision
|
||||
const submitDecision = useCallback(
|
||||
(decision: CoachingDecision, correction?: Record<string, any>, feedback?: string) => {
|
||||
if (!socketRef.current || !executionIdRef.current) {
|
||||
if (!sharedSocket || !executionIdRef.current) {
|
||||
setError('Not subscribed to any execution');
|
||||
return;
|
||||
}
|
||||
|
||||
socketRef.current.emit('coaching_decision', {
|
||||
sharedSocket.emit('coaching_decision', {
|
||||
execution_id: executionIdRef.current,
|
||||
decision,
|
||||
correction,
|
||||
@@ -255,9 +292,9 @@ export function useCoachingWebSocket(
|
||||
|
||||
// Refresh stats
|
||||
const refreshStats = useCallback(() => {
|
||||
if (!socketRef.current || !executionIdRef.current) return;
|
||||
if (!sharedSocket || !executionIdRef.current) return;
|
||||
|
||||
socketRef.current.emit('get_coaching_stats', {
|
||||
sharedSocket.emit('get_coaching_stats', {
|
||||
execution_id: executionIdRef.current,
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Hook État de Connexion - Gestion stable de l'état de connexion API
|
||||
* Auteur : Dom, Alice, Kiro - 09 janvier 2026
|
||||
*
|
||||
* Ce hook fournit un état de connexion stable qui évite les re-rendus
|
||||
* excessifs et les "sauts" de page lors des vérifications de connexion.
|
||||
*
|
||||
* IMPORTANT: L'état initial est 'offline' pour éviter les appels API
|
||||
* automatiques au montage des composants. Utilisez forceCheck() pour
|
||||
* vérifier manuellement la connexion si nécessaire.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { apiClient, ConnectionState } from '../services/apiClient';
|
||||
|
||||
interface ConnectionStatusState {
|
||||
/** État actuel de la connexion */
|
||||
status: ConnectionState;
|
||||
/** Indique si l'API est en ligne */
|
||||
isOnline: boolean;
|
||||
/** Indique si l'API est hors ligne */
|
||||
isOffline: boolean;
|
||||
/** Indique si une vérification est en cours */
|
||||
isChecking: boolean;
|
||||
/** Dernière vérification réussie */
|
||||
lastOnlineAt: Date | null;
|
||||
/** Message d'état pour l'affichage */
|
||||
statusMessage: string;
|
||||
}
|
||||
|
||||
interface UseConnectionStatusOptions {
|
||||
/** Afficher les logs de debug */
|
||||
debug?: boolean;
|
||||
/** Callback appelé quand l'état change */
|
||||
onStatusChange?: (status: ConnectionState) => void;
|
||||
}
|
||||
|
||||
// Fonction pour obtenir le message d'état (définie en dehors du hook pour éviter les re-créations)
|
||||
function getStatusMessage(status: ConnectionState): string {
|
||||
switch (status) {
|
||||
case 'online':
|
||||
return 'API connectée';
|
||||
case 'offline':
|
||||
return 'API hors ligne - Mode local activé';
|
||||
case 'checking':
|
||||
return 'Vérification de la connexion...';
|
||||
default:
|
||||
return 'État inconnu';
|
||||
}
|
||||
}
|
||||
|
||||
// État initial stable (défini en dehors du hook pour éviter les re-créations)
|
||||
// TEMPORAIRE: Changé à 'online' pour debug
|
||||
const INITIAL_STATE: ConnectionStatusState = {
|
||||
status: 'online',
|
||||
isOnline: true,
|
||||
isOffline: false,
|
||||
isChecking: false,
|
||||
lastOnlineAt: new Date(),
|
||||
statusMessage: 'API connectée',
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook pour surveiller l'état de connexion de l'API de manière stable
|
||||
*
|
||||
* ARCHITECTURE:
|
||||
* - État initial: 'offline' (pas de vérification automatique)
|
||||
* - Les changements d'état sont notifiés de manière asynchrone
|
||||
* - Utilise useRef pour éviter les re-renders inutiles
|
||||
*/
|
||||
export function useConnectionStatus(options: UseConnectionStatusOptions = {}): ConnectionStatusState & {
|
||||
forceCheck: () => Promise<boolean>;
|
||||
} {
|
||||
const { debug = false, onStatusChange } = options;
|
||||
|
||||
// État initial stable - toujours 'offline' pour éviter les appels au montage
|
||||
const [state, setState] = useState<ConnectionStatusState>(INITIAL_STATE);
|
||||
|
||||
// Référence pour éviter les mises à jour après démontage
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
// Référence pour le callback onStatusChange (évite les re-renders)
|
||||
const onStatusChangeRef = useRef(onStatusChange);
|
||||
onStatusChangeRef.current = onStatusChange;
|
||||
|
||||
// Référence pour le debug (évite les re-renders)
|
||||
const debugRef = useRef(debug);
|
||||
debugRef.current = debug;
|
||||
|
||||
// Gestionnaire de changement d'état (stable grâce aux refs)
|
||||
const handleStatusChange = useCallback((newStatus: ConnectionState) => {
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
if (debugRef.current) {
|
||||
console.log(`[ConnectionStatus] État changé: ${newStatus}`);
|
||||
}
|
||||
|
||||
setState(prev => {
|
||||
// Éviter les mises à jour inutiles si l'état n'a pas changé
|
||||
if (prev.status === newStatus) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const newState: ConnectionStatusState = {
|
||||
status: newStatus,
|
||||
isOnline: newStatus === 'online',
|
||||
isOffline: newStatus === 'offline',
|
||||
isChecking: newStatus === 'checking',
|
||||
lastOnlineAt: newStatus === 'online' ? new Date() : prev.lastOnlineAt,
|
||||
statusMessage: getStatusMessage(newStatus),
|
||||
};
|
||||
|
||||
return newState;
|
||||
});
|
||||
|
||||
// Appeler le callback si fourni (de manière asynchrone pour éviter les boucles)
|
||||
if (onStatusChangeRef.current) {
|
||||
setTimeout(() => {
|
||||
if (isMountedRef.current && onStatusChangeRef.current) {
|
||||
onStatusChangeRef.current(newStatus);
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
}, []); // Pas de dépendances - utilise des refs
|
||||
|
||||
// Vérification directe au montage (SANS abonnement au singleton)
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
|
||||
// Vérification DIRECTE au démarrage
|
||||
const checkOnMount = async () => {
|
||||
try {
|
||||
const response = await fetch('http://localhost:5001/api/health', {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
if (isMountedRef.current) {
|
||||
handleStatusChange('online');
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignorer - on garde l'état initial
|
||||
}
|
||||
|
||||
// Seulement si vraiment impossible de contacter l'API
|
||||
if (isMountedRef.current) {
|
||||
handleStatusChange('offline');
|
||||
}
|
||||
};
|
||||
checkOnMount();
|
||||
|
||||
// NE PAS s'abonner au singleton - cela cause des conflits d'état
|
||||
|
||||
// Nettoyage
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, [handleStatusChange]);
|
||||
|
||||
// Fonction pour forcer une vérification de connexion
|
||||
const forceCheck = useCallback(async (): Promise<boolean> => {
|
||||
if (debugRef.current) {
|
||||
console.log('[ConnectionStatus] Vérification forcée...');
|
||||
}
|
||||
|
||||
return apiClient.forceConnectionCheck();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...state,
|
||||
forceCheck,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook simplifié qui retourne juste un booléen pour l'état en ligne
|
||||
* Utile pour les composants qui n'ont besoin que de savoir si l'API est disponible
|
||||
*/
|
||||
export function useIsApiOnline(): boolean {
|
||||
const { isOnline } = useConnectionStatus();
|
||||
return isOnline;
|
||||
}
|
||||
|
||||
export default useConnectionStatus;
|
||||
@@ -68,7 +68,7 @@ interface UseCorrectionPacksReturn {
|
||||
selectPack: (pack: CorrectionPack | null) => void;
|
||||
}
|
||||
|
||||
const API_BASE = 'http://localhost:5000/api';
|
||||
const API_BASE = 'http://localhost:5001/api';
|
||||
|
||||
export function useCorrectionPacks(): UseCorrectionPacksReturn {
|
||||
const [packs, setPacks] = useState<CorrectionPack[]>([]);
|
||||
|
||||
262
visual_workflow_builder/frontend/src/hooks/useDebounce.ts
Normal file
262
visual_workflow_builder/frontend/src/hooks/useDebounce.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* Hook de Debouncing - Optimisation des performances pour les opérations coûteuses
|
||||
* Auteur : Dom, Alice, Kiro - 08 janvier 2026
|
||||
*
|
||||
* Ce hook retarde l'exécution d'une valeur ou fonction jusqu'à ce qu'un délai
|
||||
* soit écoulé sans nouvelle modification, optimisant les performances.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* Hook de debouncing pour les valeurs
|
||||
*
|
||||
* @param value - Valeur à débouncer
|
||||
* @param delay - Délai en millisecondes (défaut: 300ms)
|
||||
* @returns Valeur débouncée
|
||||
*/
|
||||
export function useDebounce<T>(value: T, delay: number = 300): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
// Créer un timer qui met à jour la valeur débouncée après le délai
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
// Nettoyer le timer si la valeur change avant la fin du délai
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook de debouncing pour les fonctions callback
|
||||
*
|
||||
* @param callback - Fonction à débouncer
|
||||
* @param delay - Délai en millisecondes (défaut: 300ms)
|
||||
* @param deps - Dépendances du callback
|
||||
* @returns Fonction débouncée
|
||||
*/
|
||||
export function useDebouncedCallback<T extends (...args: any[]) => any>(
|
||||
callback: T,
|
||||
delay: number = 300,
|
||||
deps: React.DependencyList = []
|
||||
): T {
|
||||
const timeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
|
||||
|
||||
const debouncedCallback = useCallback(
|
||||
(...args: Parameters<T>) => {
|
||||
// Annuler le timer précédent s'il existe
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
// Créer un nouveau timer
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
callback(...args);
|
||||
}, delay);
|
||||
},
|
||||
[callback, delay, ...deps]
|
||||
) as T;
|
||||
|
||||
// Nettoyer le timer lors du démontage du composant
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return debouncedCallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook de throttling pour limiter la fréquence d'exécution
|
||||
*
|
||||
* @param callback - Fonction à throttler
|
||||
* @param delay - Délai minimum entre les exécutions (défaut: 100ms)
|
||||
* @param deps - Dépendances du callback
|
||||
* @returns Fonction throttlée
|
||||
*/
|
||||
export function useThrottledCallback<T extends (...args: any[]) => any>(
|
||||
callback: T,
|
||||
delay: number = 100,
|
||||
deps: React.DependencyList = []
|
||||
): T {
|
||||
const lastExecutedRef = useRef<number>(0);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
|
||||
|
||||
const throttledCallback = useCallback(
|
||||
(...args: Parameters<T>) => {
|
||||
const now = Date.now();
|
||||
const timeSinceLastExecution = now - lastExecutedRef.current;
|
||||
|
||||
if (timeSinceLastExecution >= delay) {
|
||||
// Exécuter immédiatement si assez de temps s'est écoulé
|
||||
lastExecutedRef.current = now;
|
||||
callback(...args);
|
||||
} else {
|
||||
// Programmer l'exécution pour plus tard
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
lastExecutedRef.current = Date.now();
|
||||
callback(...args);
|
||||
}, delay - timeSinceLastExecution);
|
||||
}
|
||||
},
|
||||
[callback, delay, ...deps]
|
||||
) as T;
|
||||
|
||||
// Nettoyer le timer lors du démontage du composant
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return throttledCallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook de debouncing avec état de chargement
|
||||
*
|
||||
* @param asyncCallback - Fonction async à débouncer
|
||||
* @param delay - Délai en millisecondes (défaut: 300ms)
|
||||
* @param deps - Dépendances du callback
|
||||
* @returns Objet avec la fonction débouncée et l'état de chargement
|
||||
*/
|
||||
export function useDebouncedAsyncCallback<T extends (...args: any[]) => Promise<any>>(
|
||||
asyncCallback: T,
|
||||
delay: number = 300,
|
||||
deps: React.DependencyList = []
|
||||
): {
|
||||
debouncedCallback: T;
|
||||
isLoading: boolean;
|
||||
cancel: () => void;
|
||||
} {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
|
||||
const currentPromiseRef = useRef<Promise<any> | undefined>(undefined);
|
||||
|
||||
const debouncedCallback = useCallback(
|
||||
async (...args: Parameters<T>) => {
|
||||
// Annuler le timer précédent s'il existe
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
// Créer un nouveau timer
|
||||
return new Promise<Awaited<ReturnType<T>>>((resolve, reject) => {
|
||||
timeoutRef.current = setTimeout(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const promise = asyncCallback(...args);
|
||||
currentPromiseRef.current = promise;
|
||||
const result = await promise;
|
||||
|
||||
// Vérifier si cette promesse est toujours la plus récente
|
||||
if (currentPromiseRef.current === promise) {
|
||||
setIsLoading(false);
|
||||
resolve(result);
|
||||
}
|
||||
} catch (error) {
|
||||
setIsLoading(false);
|
||||
reject(error);
|
||||
}
|
||||
}, delay);
|
||||
});
|
||||
},
|
||||
[asyncCallback, delay, ...deps]
|
||||
) as T;
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
// Nettoyer lors du démontage du composant
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cancel();
|
||||
};
|
||||
}, [cancel]);
|
||||
|
||||
return {
|
||||
debouncedCallback,
|
||||
isLoading,
|
||||
cancel,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook pour débouncer les recherches avec gestion d'état
|
||||
*
|
||||
* @param searchFunction - Fonction de recherche async
|
||||
* @param delay - Délai de debouncing (défaut: 300ms)
|
||||
* @returns Objet avec les fonctions et états de recherche
|
||||
*/
|
||||
export function useDebouncedSearch<T>(
|
||||
searchFunction: (query: string) => Promise<T[]>,
|
||||
delay: number = 300
|
||||
) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<T[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const debouncedQuery = useDebounce(query, delay);
|
||||
|
||||
const performSearch = useCallback(async (searchQuery: string) => {
|
||||
if (!searchQuery.trim()) {
|
||||
setResults([]);
|
||||
setIsSearching(false);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSearching(true);
|
||||
setError(null);
|
||||
const searchResults = await searchFunction(searchQuery);
|
||||
setResults(searchResults);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Erreur de recherche');
|
||||
setResults([]);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
}, [searchFunction]);
|
||||
|
||||
// Effectuer la recherche quand la query débouncée change
|
||||
useEffect(() => {
|
||||
performSearch(debouncedQuery);
|
||||
}, [debouncedQuery, performSearch]);
|
||||
|
||||
const clearSearch = useCallback(() => {
|
||||
setQuery('');
|
||||
setResults([]);
|
||||
setError(null);
|
||||
setIsSearching(false);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
query,
|
||||
setQuery,
|
||||
results,
|
||||
isSearching,
|
||||
error,
|
||||
clearSearch,
|
||||
};
|
||||
}
|
||||
259
visual_workflow_builder/frontend/src/hooks/useEvidenceViewer.ts
Normal file
259
visual_workflow_builder/frontend/src/hooks/useEvidenceViewer.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Hook personnalisé pour la gestion de l'Evidence Viewer VWB
|
||||
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { VWBEvidence, EvidenceFilters, EvidenceStats, EvidenceUtils } from '../types/evidence';
|
||||
import { evidenceService } from '../services/evidenceService';
|
||||
|
||||
interface UseEvidenceViewerOptions {
|
||||
workflowId?: string;
|
||||
autoRefresh?: boolean;
|
||||
refreshInterval?: number;
|
||||
initialFilters?: Partial<EvidenceFilters>;
|
||||
}
|
||||
|
||||
interface UseEvidenceViewerReturn {
|
||||
// État des données
|
||||
evidences: VWBEvidence[];
|
||||
filteredEvidences: VWBEvidence[];
|
||||
selectedEvidence: VWBEvidence | null;
|
||||
stats: EvidenceStats;
|
||||
|
||||
// État de l'interface
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
filters: EvidenceFilters;
|
||||
sortBy: string;
|
||||
sortOrder: 'asc' | 'desc';
|
||||
|
||||
// Actions
|
||||
setSelectedEvidenceId: (id: string | null) => void;
|
||||
setFilters: (filters: Partial<EvidenceFilters>) => void;
|
||||
setSorting: (sortBy: string, sortOrder?: 'asc' | 'desc') => void;
|
||||
refreshEvidences: () => Promise<void>;
|
||||
clearFilters: () => void;
|
||||
exportEvidences: (format: 'json' | 'html' | 'pdf') => Promise<void>;
|
||||
|
||||
// Utilitaires
|
||||
getEvidenceById: (id: string) => VWBEvidence | undefined;
|
||||
hasFilters: boolean;
|
||||
isServiceAvailable: boolean;
|
||||
}
|
||||
|
||||
const defaultFilters: EvidenceFilters = {
|
||||
actionTypes: [],
|
||||
status: 'all',
|
||||
dateRange: {},
|
||||
searchText: '',
|
||||
confidenceRange: { min: 0, max: 1 },
|
||||
executionTimeRange: { min: 0, max: 60000 }
|
||||
};
|
||||
|
||||
export const useEvidenceViewer = (options: UseEvidenceViewerOptions = {}): UseEvidenceViewerReturn => {
|
||||
const {
|
||||
workflowId,
|
||||
autoRefresh = false,
|
||||
refreshInterval = 30000,
|
||||
initialFilters = {}
|
||||
} = options;
|
||||
|
||||
// État des données
|
||||
const [evidences, setEvidences] = useState<VWBEvidence[]>([]);
|
||||
const [selectedEvidenceId, setSelectedEvidenceId] = useState<string | null>(null);
|
||||
|
||||
// État de l'interface
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filters, setFiltersState] = useState<EvidenceFilters>({
|
||||
...defaultFilters,
|
||||
...initialFilters
|
||||
});
|
||||
const [sortBy, setSortBy] = useState('date');
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||||
const [isServiceAvailable, setIsServiceAvailable] = useState(true);
|
||||
|
||||
// Cache et timeout
|
||||
const cache = useMemo(() => new Map<string, VWBEvidence[]>(), []);
|
||||
const cacheTimeout = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
// Evidence filtrées et triées
|
||||
const filteredEvidences = useMemo(() => {
|
||||
let filtered = EvidenceUtils.filterEvidences(evidences, filters);
|
||||
filtered = EvidenceUtils.sortEvidences(filtered, sortBy, sortOrder);
|
||||
return filtered;
|
||||
}, [evidences, filters, sortBy, sortOrder]);
|
||||
|
||||
// Evidence sélectionnée
|
||||
const selectedEvidence = useMemo(() => {
|
||||
return selectedEvidenceId ? evidences.find(e => e.id === selectedEvidenceId) || null : null;
|
||||
}, [evidences, selectedEvidenceId]);
|
||||
|
||||
// Statistiques
|
||||
const stats = useMemo(() => {
|
||||
return EvidenceUtils.calculateStats(filteredEvidences);
|
||||
}, [filteredEvidences]);
|
||||
|
||||
// Vérification si des filtres sont appliqués
|
||||
const hasFilters = useMemo(() => {
|
||||
return (
|
||||
filters.actionTypes.length > 0 ||
|
||||
filters.status !== 'all' ||
|
||||
filters.searchText.trim() !== '' ||
|
||||
filters.dateRange.start !== undefined ||
|
||||
filters.dateRange.end !== undefined ||
|
||||
filters.confidenceRange.min > 0 ||
|
||||
filters.confidenceRange.max < 1 ||
|
||||
filters.executionTimeRange.min > 0 ||
|
||||
filters.executionTimeRange.max < 60000
|
||||
);
|
||||
}, [filters]);
|
||||
|
||||
// Chargement des Evidence
|
||||
const loadEvidences = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Vérification de la disponibilité du service
|
||||
const serviceHealth = await evidenceService.healthCheck();
|
||||
setIsServiceAvailable(serviceHealth);
|
||||
|
||||
if (!serviceHealth) {
|
||||
setError('Service Evidence non disponible');
|
||||
return;
|
||||
}
|
||||
|
||||
const loadedEvidences = await evidenceService.getEvidences(workflowId);
|
||||
setEvidences(loadedEvidences);
|
||||
|
||||
// Mise en cache des Evidence
|
||||
const cache = new Map();
|
||||
loadedEvidences.forEach(evidence => {
|
||||
cache.set(evidence.id, evidence);
|
||||
});
|
||||
|
||||
// Si une Evidence était sélectionnée et n'existe plus, la désélectionner
|
||||
if (selectedEvidenceId && !loadedEvidences.find(e => e.id === selectedEvidenceId)) {
|
||||
setSelectedEvidenceId(null);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Erreur inconnue';
|
||||
setError(`Erreur lors du chargement des Evidence : ${errorMessage}`);
|
||||
console.error('Erreur useEvidenceViewer.loadEvidences:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [workflowId, selectedEvidenceId]);
|
||||
|
||||
// Actualisation des Evidence
|
||||
const refreshEvidences = useCallback(async () => {
|
||||
evidenceService.clearCache();
|
||||
await loadEvidences();
|
||||
}, [loadEvidences]);
|
||||
|
||||
// Mise à jour des filtres
|
||||
const setFilters = useCallback((newFilters: Partial<EvidenceFilters>) => {
|
||||
setFiltersState(prev => ({ ...prev, ...newFilters }));
|
||||
}, []);
|
||||
|
||||
// Remise à zéro des filtres
|
||||
const clearFilters = useCallback(() => {
|
||||
setFiltersState(defaultFilters);
|
||||
}, []);
|
||||
|
||||
// Mise à jour du tri
|
||||
const setSorting = useCallback((newSortBy: string, newSortOrder: 'asc' | 'desc' = 'desc') => {
|
||||
setSortBy(newSortBy);
|
||||
setSortOrder(newSortOrder);
|
||||
}, []);
|
||||
|
||||
// Export des Evidence
|
||||
const exportEvidences = useCallback(async (format: 'json' | 'html' | 'pdf') => {
|
||||
try {
|
||||
const blob = await evidenceService.exportEvidences(filteredEvidences, {
|
||||
format,
|
||||
includeScreenshots: true,
|
||||
includeMetadata: true,
|
||||
includeErrors: true
|
||||
});
|
||||
|
||||
// Téléchargement du fichier
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `evidence_export_${new Date().toISOString().split('T')[0]}.${format}`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Erreur inconnue';
|
||||
setError(`Erreur lors de l'export : ${errorMessage}`);
|
||||
console.error('Erreur useEvidenceViewer.exportEvidences:', err);
|
||||
}
|
||||
}, [filteredEvidences]);
|
||||
|
||||
// Utilitaire pour récupérer une Evidence par ID
|
||||
const getEvidenceById = useCallback((id: string): VWBEvidence | undefined => {
|
||||
return evidences.find(e => e.id === id);
|
||||
}, [evidences]);
|
||||
|
||||
// Chargement initial
|
||||
useEffect(() => {
|
||||
loadEvidences();
|
||||
}, [loadEvidences]);
|
||||
|
||||
// Auto-refresh
|
||||
useEffect(() => {
|
||||
if (!autoRefresh || refreshInterval <= 0) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (!loading) {
|
||||
refreshEvidences();
|
||||
}
|
||||
}, refreshInterval);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [autoRefresh, refreshInterval, loading, refreshEvidences]);
|
||||
|
||||
// Nettoyage à la désactivation
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
evidenceService.clearCache();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// État des données
|
||||
evidences,
|
||||
filteredEvidences,
|
||||
selectedEvidence,
|
||||
stats,
|
||||
|
||||
// État de l'interface
|
||||
loading,
|
||||
error,
|
||||
filters,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
|
||||
// Actions
|
||||
setSelectedEvidenceId,
|
||||
setFilters,
|
||||
setSorting,
|
||||
refreshEvidences,
|
||||
clearFilters,
|
||||
exportEvidences,
|
||||
|
||||
// Utilitaires
|
||||
getEvidenceById,
|
||||
hasFilters,
|
||||
isServiceAvailable
|
||||
};
|
||||
};
|
||||
|
||||
export default useEvidenceViewer;
|
||||
@@ -0,0 +1,363 @@
|
||||
/**
|
||||
* Hook d'Evidence d'Exécution - Gestion des Evidence pendant l'exécution VWB
|
||||
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
|
||||
*
|
||||
* Ce hook gère la collecte, le stockage et l'organisation des Evidence
|
||||
* générées pendant l'exécution des workflows VWB.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
||||
import { Evidence, Step } from '../types';
|
||||
|
||||
export interface EvidenceStats {
|
||||
total: number;
|
||||
screenshots: number;
|
||||
bySteps: number;
|
||||
byCurrentStep: number;
|
||||
byType: Record<string, number>;
|
||||
latest: Evidence | null;
|
||||
}
|
||||
|
||||
export interface EvidenceByStep {
|
||||
[stepId: string]: Evidence[];
|
||||
}
|
||||
|
||||
export interface UseExecutionEvidenceOptions {
|
||||
maxEvidencePerStep?: number;
|
||||
maxTotalEvidence?: number;
|
||||
autoCleanup?: boolean;
|
||||
persistToStorage?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook principal pour la gestion des Evidence d'exécution
|
||||
*/
|
||||
export const useExecutionEvidence = (
|
||||
currentStepId?: string,
|
||||
options: UseExecutionEvidenceOptions = {}
|
||||
) => {
|
||||
const {
|
||||
maxEvidencePerStep = 50,
|
||||
maxTotalEvidence = 200,
|
||||
autoCleanup = true,
|
||||
persistToStorage = false,
|
||||
} = options;
|
||||
|
||||
// État des Evidence
|
||||
const [evidenceByStep, setEvidenceByStep] = useState<EvidenceByStep>({});
|
||||
const [allEvidence, setAllEvidence] = useState<Evidence[]>([]);
|
||||
|
||||
// Références pour éviter les re-renders
|
||||
const evidenceCountRef = useRef(0);
|
||||
const lastCleanupRef = useRef(Date.now());
|
||||
|
||||
// Charger les Evidence depuis le stockage local si activé
|
||||
useEffect(() => {
|
||||
if (persistToStorage) {
|
||||
try {
|
||||
const stored = localStorage.getItem('vwb_execution_evidence');
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
setEvidenceByStep(parsed.evidenceByStep || {});
|
||||
setAllEvidence(parsed.allEvidence || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Erreur lors du chargement des Evidence:', error);
|
||||
}
|
||||
}
|
||||
}, [persistToStorage]);
|
||||
|
||||
// Sauvegarder les Evidence dans le stockage local
|
||||
const saveToStorage = useCallback(() => {
|
||||
if (persistToStorage) {
|
||||
try {
|
||||
localStorage.setItem('vwb_execution_evidence', JSON.stringify({
|
||||
evidenceByStep,
|
||||
allEvidence,
|
||||
timestamp: Date.now(),
|
||||
}));
|
||||
} catch (error) {
|
||||
console.warn('Erreur lors de la sauvegarde des Evidence:', error);
|
||||
}
|
||||
}
|
||||
}, [evidenceByStep, allEvidence, persistToStorage]);
|
||||
|
||||
// Sauvegarder automatiquement
|
||||
useEffect(() => {
|
||||
saveToStorage();
|
||||
}, [saveToStorage]);
|
||||
|
||||
/**
|
||||
* Ajouter une Evidence pour une étape
|
||||
*/
|
||||
const addEvidence = useCallback((stepId: string, evidence: Evidence) => {
|
||||
// Vérifier les limites
|
||||
if (evidenceCountRef.current >= maxTotalEvidence) {
|
||||
console.warn('Limite maximale d\'Evidence atteinte');
|
||||
return;
|
||||
}
|
||||
|
||||
setEvidenceByStep(prev => {
|
||||
const stepEvidence = prev[stepId] || [];
|
||||
|
||||
// Vérifier la limite par étape
|
||||
if (stepEvidence.length >= maxEvidencePerStep) {
|
||||
// Supprimer la plus ancienne Evidence
|
||||
stepEvidence.shift();
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[stepId]: [...stepEvidence, evidence],
|
||||
};
|
||||
});
|
||||
|
||||
setAllEvidence(prev => {
|
||||
const newEvidence = [...prev, evidence];
|
||||
|
||||
// Vérifier la limite totale
|
||||
if (newEvidence.length > maxTotalEvidence) {
|
||||
// Supprimer les plus anciennes Evidence
|
||||
return newEvidence.slice(-maxTotalEvidence);
|
||||
}
|
||||
|
||||
return newEvidence;
|
||||
});
|
||||
|
||||
evidenceCountRef.current++;
|
||||
}, [maxEvidencePerStep, maxTotalEvidence]);
|
||||
|
||||
/**
|
||||
* Ajouter plusieurs Evidence pour une étape
|
||||
*/
|
||||
const addMultipleEvidence = useCallback((stepId: string, evidenceList: Evidence[]) => {
|
||||
evidenceList.forEach(evidence => addEvidence(stepId, evidence));
|
||||
}, [addEvidence]);
|
||||
|
||||
/**
|
||||
* Supprimer les Evidence d'une étape
|
||||
*/
|
||||
const removeStepEvidence = useCallback((stepId: string) => {
|
||||
setEvidenceByStep(prev => {
|
||||
const { [stepId]: removed, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
|
||||
setAllEvidence(prev =>
|
||||
prev.filter(evidence =>
|
||||
!prev.some(stepEvidence =>
|
||||
evidenceByStep[stepId]?.some(e => e.id === evidence.id)
|
||||
)
|
||||
)
|
||||
);
|
||||
}, [evidenceByStep]);
|
||||
|
||||
/**
|
||||
* Nettoyer toutes les Evidence
|
||||
*/
|
||||
const clearEvidence = useCallback(() => {
|
||||
setEvidenceByStep({});
|
||||
setAllEvidence([]);
|
||||
evidenceCountRef.current = 0;
|
||||
|
||||
if (persistToStorage) {
|
||||
localStorage.removeItem('vwb_execution_evidence');
|
||||
}
|
||||
}, [persistToStorage]);
|
||||
|
||||
/**
|
||||
* Nettoyer automatiquement les anciennes Evidence
|
||||
*/
|
||||
const performAutoCleanup = useCallback(() => {
|
||||
if (!autoCleanup) return;
|
||||
|
||||
const now = Date.now();
|
||||
const cleanupInterval = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
if (now - lastCleanupRef.current < cleanupInterval) return;
|
||||
|
||||
const cutoffTime = now - (30 * 60 * 1000); // 30 minutes
|
||||
|
||||
setAllEvidence(prev =>
|
||||
prev.filter(evidence =>
|
||||
new Date(evidence.captured_at).getTime() > cutoffTime
|
||||
)
|
||||
);
|
||||
|
||||
setEvidenceByStep(prev => {
|
||||
const cleaned: EvidenceByStep = {};
|
||||
|
||||
Object.entries(prev).forEach(([stepId, stepEvidence]) => {
|
||||
const filteredEvidence = stepEvidence.filter(evidence =>
|
||||
new Date(evidence.captured_at).getTime() > cutoffTime
|
||||
);
|
||||
|
||||
if (filteredEvidence.length > 0) {
|
||||
cleaned[stepId] = filteredEvidence;
|
||||
}
|
||||
});
|
||||
|
||||
return cleaned;
|
||||
});
|
||||
|
||||
lastCleanupRef.current = now;
|
||||
}, [autoCleanup]);
|
||||
|
||||
// Nettoyer automatiquement toutes les 5 minutes
|
||||
useEffect(() => {
|
||||
if (autoCleanup) {
|
||||
const interval = setInterval(performAutoCleanup, 5 * 60 * 1000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [autoCleanup, performAutoCleanup]);
|
||||
|
||||
/**
|
||||
* Obtenir les Evidence de l'étape actuelle
|
||||
*/
|
||||
const currentStepEvidence = useMemo(() => {
|
||||
return currentStepId ? (evidenceByStep[currentStepId] || []) : [];
|
||||
}, [evidenceByStep, currentStepId]);
|
||||
|
||||
/**
|
||||
* Obtenir les statistiques des Evidence
|
||||
*/
|
||||
const getEvidenceStats = useCallback((): EvidenceStats => {
|
||||
const byType: Record<string, number> = {};
|
||||
let screenshots = 0;
|
||||
|
||||
allEvidence.forEach(evidence => {
|
||||
byType[evidence.action_id] = (byType[evidence.action_id] || 0) + 1;
|
||||
|
||||
if (evidence.action_id === 'screenshot' || evidence.metadata?.screenshot) {
|
||||
screenshots++;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
total: allEvidence.length,
|
||||
screenshots,
|
||||
bySteps: Object.keys(evidenceByStep).length,
|
||||
byCurrentStep: currentStepEvidence.length,
|
||||
byType,
|
||||
latest: allEvidence.length > 0 ? allEvidence[allEvidence.length - 1] : null,
|
||||
};
|
||||
}, [allEvidence, evidenceByStep, currentStepEvidence]);
|
||||
|
||||
/**
|
||||
* Rechercher des Evidence par critères
|
||||
*/
|
||||
const searchEvidence = useCallback((
|
||||
query: string,
|
||||
filters?: {
|
||||
stepId?: string;
|
||||
type?: string;
|
||||
dateFrom?: Date;
|
||||
dateTo?: Date;
|
||||
}
|
||||
): Evidence[] => {
|
||||
let results = allEvidence;
|
||||
|
||||
// Filtrer par étape
|
||||
if (filters?.stepId) {
|
||||
results = evidenceByStep[filters.stepId] || [];
|
||||
}
|
||||
|
||||
// Filtrer par type
|
||||
if (filters?.type) {
|
||||
results = results.filter(evidence => evidence.action_id === filters.type);
|
||||
}
|
||||
|
||||
// Filtrer par date
|
||||
if (filters?.dateFrom) {
|
||||
results = results.filter(evidence =>
|
||||
new Date(evidence.captured_at) >= filters.dateFrom!
|
||||
);
|
||||
}
|
||||
|
||||
if (filters?.dateTo) {
|
||||
results = results.filter(evidence =>
|
||||
new Date(evidence.captured_at) <= filters.dateTo!
|
||||
);
|
||||
}
|
||||
|
||||
// Recherche textuelle
|
||||
if (query.trim()) {
|
||||
const lowerQuery = query.toLowerCase();
|
||||
results = results.filter(evidence =>
|
||||
evidence.id.toLowerCase().includes(lowerQuery) ||
|
||||
evidence.action_id.toLowerCase().includes(lowerQuery) ||
|
||||
(evidence.metadata?.message &&
|
||||
evidence.data?.message.toLowerCase().includes(lowerQuery))
|
||||
);
|
||||
}
|
||||
|
||||
return results;
|
||||
}, [allEvidence, evidenceByStep]);
|
||||
|
||||
/**
|
||||
* Obtenir les Evidence par étape avec tri
|
||||
*/
|
||||
const getEvidenceByStep = useCallback((
|
||||
stepId: string,
|
||||
sortBy: 'timestamp' | 'type' = 'timestamp',
|
||||
sortOrder: 'asc' | 'desc' = 'desc'
|
||||
): Evidence[] => {
|
||||
const stepEvidence = evidenceByStep[stepId] || [];
|
||||
|
||||
return [...stepEvidence].sort((a, b) => {
|
||||
let comparison = 0;
|
||||
|
||||
if (sortBy === 'timestamp') {
|
||||
comparison = new Date(a.captured_at).getTime() - new Date(b.captured_at).getTime();
|
||||
} else if (sortBy === 'type') {
|
||||
comparison = a.action_id.localeCompare(b.action_id);
|
||||
}
|
||||
|
||||
return sortOrder === 'desc' ? -comparison : comparison;
|
||||
});
|
||||
}, [evidenceByStep]);
|
||||
|
||||
/**
|
||||
* Exporter les Evidence au format JSON
|
||||
*/
|
||||
const exportEvidence = useCallback((stepId?: string) => {
|
||||
const dataToExport = stepId
|
||||
? { [stepId]: evidenceByStep[stepId] || [] }
|
||||
: evidenceByStep;
|
||||
|
||||
const exportData = {
|
||||
timestamp: new Date().toISOString(),
|
||||
stepId,
|
||||
evidence: dataToExport,
|
||||
stats: getEvidenceStats(),
|
||||
};
|
||||
|
||||
return JSON.stringify(exportData, null, 2);
|
||||
}, [evidenceByStep, getEvidenceStats]);
|
||||
|
||||
return {
|
||||
// État
|
||||
evidenceByStep,
|
||||
allEvidence,
|
||||
currentStepEvidence,
|
||||
|
||||
// Actions
|
||||
addEvidence,
|
||||
addMultipleEvidence,
|
||||
removeStepEvidence,
|
||||
clearEvidence,
|
||||
performAutoCleanup,
|
||||
|
||||
// Utilitaires
|
||||
getEvidenceStats,
|
||||
searchEvidence,
|
||||
getEvidenceByStep,
|
||||
exportEvidence,
|
||||
|
||||
// Informations
|
||||
totalCount: allEvidence.length,
|
||||
stepCount: Object.keys(evidenceByStep).length,
|
||||
hasEvidence: allEvidence.length > 0,
|
||||
hasCurrentStepEvidence: currentStepEvidence.length > 0,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* Hook de Navigation au Clavier - Accessibilité complète
|
||||
* Auteur : Dom, Alice, Kiro - 08 janvier 2026
|
||||
*
|
||||
* Ce hook gère la navigation au clavier complète pour toutes les fonctionnalités
|
||||
* du Visual Workflow Builder, conformément aux standards WCAG 2.1 niveau AA.
|
||||
*/
|
||||
|
||||
import { useEffect, useCallback, useRef } from 'react';
|
||||
|
||||
interface KeyboardNavigationOptions {
|
||||
onStepSelect?: (stepId: string) => void;
|
||||
onStepMove?: (stepId: string, direction: 'up' | 'down' | 'left' | 'right') => void;
|
||||
onStepDelete?: (stepId: string) => void;
|
||||
onStepCopy?: (stepId: string) => void;
|
||||
onStepPaste?: () => void;
|
||||
onUndo?: () => void;
|
||||
onRedo?: () => void;
|
||||
onSave?: () => void;
|
||||
onExecute?: () => void;
|
||||
onZoomIn?: () => void;
|
||||
onZoomOut?: () => void;
|
||||
onZoomFit?: () => void;
|
||||
onSelectAll?: () => void;
|
||||
onEscape?: () => void;
|
||||
onHelp?: () => void;
|
||||
selectedStepId?: string;
|
||||
availableStepIds?: string[];
|
||||
isEnabled?: boolean;
|
||||
}
|
||||
|
||||
interface KeyboardShortcut {
|
||||
key: string;
|
||||
ctrlKey?: boolean;
|
||||
shiftKey?: boolean;
|
||||
altKey?: boolean;
|
||||
description: string;
|
||||
action: () => void;
|
||||
}
|
||||
|
||||
export const useKeyboardNavigation = (options: KeyboardNavigationOptions) => {
|
||||
const {
|
||||
onStepSelect,
|
||||
onStepMove,
|
||||
onStepDelete,
|
||||
onStepCopy,
|
||||
onStepPaste,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onSave,
|
||||
onExecute,
|
||||
onZoomIn,
|
||||
onZoomOut,
|
||||
onZoomFit,
|
||||
onSelectAll,
|
||||
onEscape,
|
||||
onHelp,
|
||||
selectedStepId,
|
||||
availableStepIds = [],
|
||||
isEnabled = true,
|
||||
} = options;
|
||||
|
||||
const shortcutsRef = useRef<KeyboardShortcut[]>([]);
|
||||
|
||||
// Définir les raccourcis clavier
|
||||
const defineShortcuts = useCallback((): KeyboardShortcut[] => {
|
||||
return [
|
||||
// Navigation des étapes
|
||||
{
|
||||
key: 'Tab',
|
||||
description: 'Naviguer vers l\'étape suivante',
|
||||
action: () => {
|
||||
if (availableStepIds.length === 0) return;
|
||||
const currentIndex = selectedStepId ? availableStepIds.indexOf(selectedStepId) : -1;
|
||||
const nextIndex = (currentIndex + 1) % availableStepIds.length;
|
||||
onStepSelect?.(availableStepIds[nextIndex]);
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'Tab',
|
||||
shiftKey: true,
|
||||
description: 'Naviguer vers l\'étape précédente',
|
||||
action: () => {
|
||||
if (availableStepIds.length === 0) return;
|
||||
const currentIndex = selectedStepId ? availableStepIds.indexOf(selectedStepId) : -1;
|
||||
const prevIndex = currentIndex <= 0 ? availableStepIds.length - 1 : currentIndex - 1;
|
||||
onStepSelect?.(availableStepIds[prevIndex]);
|
||||
}
|
||||
},
|
||||
|
||||
// Déplacement des étapes
|
||||
{
|
||||
key: 'ArrowUp',
|
||||
description: 'Déplacer l\'étape vers le haut',
|
||||
action: () => selectedStepId && onStepMove?.(selectedStepId, 'up')
|
||||
},
|
||||
{
|
||||
key: 'ArrowDown',
|
||||
description: 'Déplacer l\'étape vers le bas',
|
||||
action: () => selectedStepId && onStepMove?.(selectedStepId, 'down')
|
||||
},
|
||||
{
|
||||
key: 'ArrowLeft',
|
||||
description: 'Déplacer l\'étape vers la gauche',
|
||||
action: () => selectedStepId && onStepMove?.(selectedStepId, 'left')
|
||||
},
|
||||
{
|
||||
key: 'ArrowRight',
|
||||
description: 'Déplacer l\'étape vers la droite',
|
||||
action: () => selectedStepId && onStepMove?.(selectedStepId, 'right')
|
||||
},
|
||||
|
||||
// Actions d'édition
|
||||
{
|
||||
key: 'Delete',
|
||||
description: 'Supprimer l\'étape sélectionnée',
|
||||
action: () => selectedStepId && onStepDelete?.(selectedStepId)
|
||||
},
|
||||
{
|
||||
key: 'Backspace',
|
||||
description: 'Supprimer l\'étape sélectionnée',
|
||||
action: () => selectedStepId && onStepDelete?.(selectedStepId)
|
||||
},
|
||||
{
|
||||
key: 'c',
|
||||
ctrlKey: true,
|
||||
description: 'Copier l\'étape sélectionnée',
|
||||
action: () => selectedStepId && onStepCopy?.(selectedStepId)
|
||||
},
|
||||
{
|
||||
key: 'v',
|
||||
ctrlKey: true,
|
||||
description: 'Coller l\'étape copiée',
|
||||
action: () => onStepPaste?.()
|
||||
},
|
||||
|
||||
// Actions globales
|
||||
{
|
||||
key: 'z',
|
||||
ctrlKey: true,
|
||||
description: 'Annuler la dernière action',
|
||||
action: () => onUndo?.()
|
||||
},
|
||||
{
|
||||
key: 'y',
|
||||
ctrlKey: true,
|
||||
description: 'Rétablir l\'action annulée',
|
||||
action: () => onRedo?.()
|
||||
},
|
||||
{
|
||||
key: 'z',
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
description: 'Rétablir l\'action annulée (alternative)',
|
||||
action: () => onRedo?.()
|
||||
},
|
||||
{
|
||||
key: 's',
|
||||
ctrlKey: true,
|
||||
description: 'Sauvegarder le workflow',
|
||||
action: () => onSave?.()
|
||||
},
|
||||
{
|
||||
key: 'F5',
|
||||
description: 'Exécuter le workflow',
|
||||
action: () => onExecute?.()
|
||||
},
|
||||
{
|
||||
key: 'Enter',
|
||||
ctrlKey: true,
|
||||
description: 'Exécuter le workflow (alternative)',
|
||||
action: () => onExecute?.()
|
||||
},
|
||||
|
||||
// Navigation et zoom
|
||||
{
|
||||
key: '=',
|
||||
ctrlKey: true,
|
||||
description: 'Zoomer',
|
||||
action: () => onZoomIn?.()
|
||||
},
|
||||
{
|
||||
key: '+',
|
||||
ctrlKey: true,
|
||||
description: 'Zoomer (alternative)',
|
||||
action: () => onZoomIn?.()
|
||||
},
|
||||
{
|
||||
key: '-',
|
||||
ctrlKey: true,
|
||||
description: 'Dézoomer',
|
||||
action: () => onZoomOut?.()
|
||||
},
|
||||
{
|
||||
key: '0',
|
||||
ctrlKey: true,
|
||||
description: 'Ajuster le zoom pour voir tout le workflow',
|
||||
action: () => onZoomFit?.()
|
||||
},
|
||||
{
|
||||
key: 'a',
|
||||
ctrlKey: true,
|
||||
description: 'Sélectionner toutes les étapes',
|
||||
action: () => onSelectAll?.()
|
||||
},
|
||||
|
||||
// Actions spéciales
|
||||
{
|
||||
key: 'Escape',
|
||||
description: 'Annuler l\'action en cours ou désélectionner',
|
||||
action: () => onEscape?.()
|
||||
},
|
||||
{
|
||||
key: 'F1',
|
||||
description: 'Afficher l\'aide',
|
||||
action: () => onHelp?.()
|
||||
},
|
||||
{
|
||||
key: '?',
|
||||
shiftKey: true,
|
||||
description: 'Afficher les raccourcis clavier',
|
||||
action: () => onHelp?.()
|
||||
}
|
||||
];
|
||||
}, [
|
||||
selectedStepId,
|
||||
availableStepIds,
|
||||
onStepSelect,
|
||||
onStepMove,
|
||||
onStepDelete,
|
||||
onStepCopy,
|
||||
onStepPaste,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onSave,
|
||||
onExecute,
|
||||
onZoomIn,
|
||||
onZoomOut,
|
||||
onZoomFit,
|
||||
onSelectAll,
|
||||
onEscape,
|
||||
onHelp,
|
||||
]);
|
||||
|
||||
// Gestionnaire d'événements clavier
|
||||
const handleKeyDown = useCallback((event: KeyboardEvent) => {
|
||||
if (!isEnabled) return;
|
||||
|
||||
// Ignorer si l'utilisateur tape dans un champ de saisie
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shortcuts = shortcutsRef.current;
|
||||
const matchingShortcut = shortcuts.find(shortcut => {
|
||||
return (
|
||||
shortcut.key === event.key &&
|
||||
!!shortcut.ctrlKey === event.ctrlKey &&
|
||||
!!shortcut.shiftKey === event.shiftKey &&
|
||||
!!shortcut.altKey === event.altKey
|
||||
);
|
||||
});
|
||||
|
||||
if (matchingShortcut) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
matchingShortcut.action();
|
||||
}
|
||||
}, [isEnabled]);
|
||||
|
||||
// Effet pour attacher/détacher les événements
|
||||
useEffect(() => {
|
||||
shortcutsRef.current = defineShortcuts();
|
||||
|
||||
if (isEnabled) {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}
|
||||
}, [handleKeyDown, defineShortcuts, isEnabled]);
|
||||
|
||||
// Fonction pour obtenir la liste des raccourcis (pour l'aide)
|
||||
const getShortcuts = useCallback(() => {
|
||||
return shortcutsRef.current.map(shortcut => ({
|
||||
keys: [
|
||||
shortcut.ctrlKey && 'Ctrl',
|
||||
shortcut.shiftKey && 'Shift',
|
||||
shortcut.altKey && 'Alt',
|
||||
shortcut.key
|
||||
].filter(Boolean).join(' + '),
|
||||
description: shortcut.description
|
||||
}));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
shortcuts: getShortcuts(),
|
||||
isEnabled
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* Hook de Layout Responsif - Adaptation aux différentes résolutions
|
||||
* Auteur : Dom, Alice, Kiro - 08 janvier 2026
|
||||
*
|
||||
* Ce hook gère l'adaptation de l'interface aux différentes tailles d'écran
|
||||
* pour assurer une expérience utilisateur optimale sur tous les appareils.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useTheme, useMediaQuery } from '@mui/material';
|
||||
|
||||
interface BreakpointValues {
|
||||
xs: boolean; // < 600px
|
||||
sm: boolean; // 600px - 900px
|
||||
md: boolean; // 900px - 1200px
|
||||
lg: boolean; // 1200px - 1536px
|
||||
xl: boolean; // >= 1536px
|
||||
}
|
||||
|
||||
interface ResponsiveLayoutConfig {
|
||||
// Largeurs des panneaux selon la taille d'écran
|
||||
paletteWidth: number;
|
||||
propertiesWidth: number;
|
||||
variablesHeight: number;
|
||||
|
||||
// Visibilité des éléments
|
||||
showMinimap: boolean;
|
||||
showVariablesPanel: boolean;
|
||||
showPropertiesPanel: boolean;
|
||||
|
||||
// Configuration du canvas
|
||||
canvasMinHeight: number;
|
||||
|
||||
// Configuration des tooltips
|
||||
tooltipPlacement: 'top' | 'bottom' | 'left' | 'right';
|
||||
|
||||
// Configuration des dialogues
|
||||
dialogFullScreen: boolean;
|
||||
|
||||
// Configuration de la grille
|
||||
gridSize: number;
|
||||
|
||||
// Configuration des boutons
|
||||
buttonSize: 'small' | 'medium' | 'large';
|
||||
iconSize: 'small' | 'medium' | 'large';
|
||||
}
|
||||
|
||||
const defaultConfigs: Record<string, ResponsiveLayoutConfig> = {
|
||||
xs: {
|
||||
paletteWidth: 240,
|
||||
propertiesWidth: 280,
|
||||
variablesHeight: 150,
|
||||
showMinimap: false,
|
||||
showVariablesPanel: false,
|
||||
showPropertiesPanel: false,
|
||||
canvasMinHeight: 400,
|
||||
tooltipPlacement: 'top',
|
||||
dialogFullScreen: true,
|
||||
gridSize: 15,
|
||||
buttonSize: 'small',
|
||||
iconSize: 'small',
|
||||
},
|
||||
sm: {
|
||||
paletteWidth: 260,
|
||||
propertiesWidth: 300,
|
||||
variablesHeight: 180,
|
||||
showMinimap: false,
|
||||
showVariablesPanel: true,
|
||||
showPropertiesPanel: false,
|
||||
canvasMinHeight: 500,
|
||||
tooltipPlacement: 'top',
|
||||
dialogFullScreen: true,
|
||||
gridSize: 20,
|
||||
buttonSize: 'small',
|
||||
iconSize: 'small',
|
||||
},
|
||||
md: {
|
||||
paletteWidth: 280,
|
||||
propertiesWidth: 320,
|
||||
variablesHeight: 200,
|
||||
showMinimap: true,
|
||||
showVariablesPanel: true,
|
||||
showPropertiesPanel: true,
|
||||
canvasMinHeight: 600,
|
||||
tooltipPlacement: 'right',
|
||||
dialogFullScreen: false,
|
||||
gridSize: 20,
|
||||
buttonSize: 'medium',
|
||||
iconSize: 'medium',
|
||||
},
|
||||
lg: {
|
||||
paletteWidth: 300,
|
||||
propertiesWidth: 350,
|
||||
variablesHeight: 220,
|
||||
showMinimap: true,
|
||||
showVariablesPanel: true,
|
||||
showPropertiesPanel: true,
|
||||
canvasMinHeight: 700,
|
||||
tooltipPlacement: 'right',
|
||||
dialogFullScreen: false,
|
||||
gridSize: 25,
|
||||
buttonSize: 'medium',
|
||||
iconSize: 'medium',
|
||||
},
|
||||
xl: {
|
||||
paletteWidth: 320,
|
||||
propertiesWidth: 380,
|
||||
variablesHeight: 250,
|
||||
showMinimap: true,
|
||||
showVariablesPanel: true,
|
||||
showPropertiesPanel: true,
|
||||
canvasMinHeight: 800,
|
||||
tooltipPlacement: 'right',
|
||||
dialogFullScreen: false,
|
||||
gridSize: 25,
|
||||
buttonSize: 'large',
|
||||
iconSize: 'large',
|
||||
},
|
||||
};
|
||||
|
||||
export const useResponsiveLayout = () => {
|
||||
const theme = useTheme();
|
||||
|
||||
// Détection des breakpoints Material-UI
|
||||
const isXs = useMediaQuery(theme.breakpoints.only('xs'));
|
||||
const isSm = useMediaQuery(theme.breakpoints.only('sm'));
|
||||
const isMd = useMediaQuery(theme.breakpoints.only('md'));
|
||||
const isLg = useMediaQuery(theme.breakpoints.only('lg'));
|
||||
const isXl = useMediaQuery(theme.breakpoints.up('xl'));
|
||||
|
||||
const breakpoints: BreakpointValues = useMemo(() => ({
|
||||
xs: isXs,
|
||||
sm: isSm,
|
||||
md: isMd,
|
||||
lg: isLg,
|
||||
xl: isXl,
|
||||
}), [isXs, isSm, isMd, isLg, isXl]);
|
||||
|
||||
// État de la configuration actuelle
|
||||
const [currentConfig, setCurrentConfig] = useState<ResponsiveLayoutConfig>(defaultConfigs.lg);
|
||||
const [currentBreakpoint, setCurrentBreakpoint] = useState<string>('lg');
|
||||
|
||||
// Gestionnaire d'événements clavier pour l'accessibilité
|
||||
const handleKeyDown = useCallback((event: KeyboardEvent) => {
|
||||
// Raccourcis clavier pour basculer les panneaux (accessibilité)
|
||||
if (event.altKey) {
|
||||
switch (event.key) {
|
||||
case 'p':
|
||||
// Alt+P : Basculer le panneau de propriétés
|
||||
event.preventDefault();
|
||||
setCurrentConfig(prev => ({
|
||||
...prev,
|
||||
showPropertiesPanel: !prev.showPropertiesPanel
|
||||
}));
|
||||
break;
|
||||
case 'v':
|
||||
// Alt+V : Basculer le panneau de variables
|
||||
event.preventDefault();
|
||||
setCurrentConfig(prev => ({
|
||||
...prev,
|
||||
showVariablesPanel: !prev.showVariablesPanel
|
||||
}));
|
||||
break;
|
||||
case 'm':
|
||||
// Alt+M : Basculer la minimap
|
||||
event.preventDefault();
|
||||
setCurrentConfig(prev => ({
|
||||
...prev,
|
||||
showMinimap: !prev.showMinimap
|
||||
}));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Ajouter les écouteurs d'événements clavier
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [handleKeyDown]);
|
||||
|
||||
// Déterminer le breakpoint actuel
|
||||
const getCurrentBreakpoint = useCallback((): string => {
|
||||
if (breakpoints.xs) return 'xs';
|
||||
if (breakpoints.sm) return 'sm';
|
||||
if (breakpoints.md) return 'md';
|
||||
if (breakpoints.lg) return 'lg';
|
||||
if (breakpoints.xl) return 'xl';
|
||||
return 'lg'; // fallback
|
||||
}, [breakpoints]);
|
||||
|
||||
// Mettre à jour la configuration selon le breakpoint
|
||||
useEffect(() => {
|
||||
const newBreakpoint = getCurrentBreakpoint();
|
||||
if (newBreakpoint !== currentBreakpoint) {
|
||||
setCurrentBreakpoint(newBreakpoint);
|
||||
setCurrentConfig(defaultConfigs[newBreakpoint]);
|
||||
}
|
||||
}, [getCurrentBreakpoint, currentBreakpoint]);
|
||||
|
||||
// Fonctions utilitaires pour les composants
|
||||
const isMobile = breakpoints.xs || breakpoints.sm;
|
||||
const isTablet = breakpoints.md;
|
||||
const isDesktop = breakpoints.lg || breakpoints.xl;
|
||||
|
||||
// Fonction pour obtenir les styles responsifs d'un composant
|
||||
const getResponsiveStyles = useCallback((componentName: string) => {
|
||||
const baseStyles: Record<string, any> = {};
|
||||
|
||||
switch (componentName) {
|
||||
case 'palette':
|
||||
return {
|
||||
...baseStyles,
|
||||
width: currentConfig.paletteWidth,
|
||||
display: isMobile ? 'none' : 'flex', // Masquer sur mobile
|
||||
};
|
||||
|
||||
case 'properties':
|
||||
return {
|
||||
...baseStyles,
|
||||
width: currentConfig.propertiesWidth,
|
||||
display: currentConfig.showPropertiesPanel ? 'flex' : 'none',
|
||||
};
|
||||
|
||||
case 'variables':
|
||||
return {
|
||||
...baseStyles,
|
||||
height: currentConfig.variablesHeight,
|
||||
display: currentConfig.showVariablesPanel ? 'block' : 'none',
|
||||
};
|
||||
|
||||
case 'canvas':
|
||||
return {
|
||||
...baseStyles,
|
||||
minHeight: currentConfig.canvasMinHeight,
|
||||
flex: 1,
|
||||
};
|
||||
|
||||
case 'minimap':
|
||||
return {
|
||||
...baseStyles,
|
||||
display: currentConfig.showMinimap ? 'block' : 'none',
|
||||
};
|
||||
|
||||
case 'toolbar':
|
||||
return {
|
||||
...baseStyles,
|
||||
flexDirection: isMobile ? 'column' : 'row',
|
||||
gap: isMobile ? 1 : 2,
|
||||
};
|
||||
|
||||
case 'dialog':
|
||||
return {
|
||||
...baseStyles,
|
||||
fullScreen: currentConfig.dialogFullScreen,
|
||||
maxWidth: currentConfig.dialogFullScreen ? false : 'md',
|
||||
};
|
||||
|
||||
default:
|
||||
return baseStyles;
|
||||
}
|
||||
}, [currentConfig, isMobile]);
|
||||
|
||||
// Fonction pour obtenir la taille des boutons
|
||||
const getButtonSize = useCallback(() => currentConfig.buttonSize, [currentConfig]);
|
||||
|
||||
// Fonction pour obtenir la taille des icônes
|
||||
const getIconSize = useCallback(() => currentConfig.iconSize, [currentConfig]);
|
||||
|
||||
// Fonction pour obtenir la position des tooltips
|
||||
const getTooltipPlacement = useCallback(() => currentConfig.tooltipPlacement, [currentConfig]);
|
||||
|
||||
// Fonction pour obtenir la taille de la grille
|
||||
const getGridSize = useCallback(() => currentConfig.gridSize, [currentConfig]);
|
||||
|
||||
// Fonction pour déterminer si un panneau doit être en drawer sur mobile
|
||||
const shouldUseDrawer = useCallback((panelName: string) => {
|
||||
if (!isMobile) return false;
|
||||
|
||||
switch (panelName) {
|
||||
case 'palette':
|
||||
case 'properties':
|
||||
case 'variables':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}, [isMobile]);
|
||||
|
||||
// Fonction pour obtenir les dimensions de la fenêtre
|
||||
const getViewportDimensions = useCallback(() => {
|
||||
return {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
availableWidth: window.innerWidth - (
|
||||
(currentConfig.showPropertiesPanel ? currentConfig.propertiesWidth : 0) +
|
||||
(isMobile ? 0 : currentConfig.paletteWidth)
|
||||
),
|
||||
availableHeight: window.innerHeight - (
|
||||
currentConfig.showVariablesPanel ? currentConfig.variablesHeight : 0
|
||||
) - 64, // Hauteur de l'AppBar
|
||||
};
|
||||
}, [currentConfig, isMobile]);
|
||||
|
||||
return {
|
||||
// État actuel
|
||||
breakpoints,
|
||||
currentBreakpoint,
|
||||
currentConfig,
|
||||
|
||||
// Détection de type d'appareil
|
||||
isMobile,
|
||||
isTablet,
|
||||
isDesktop,
|
||||
|
||||
// Fonctions utilitaires
|
||||
getResponsiveStyles,
|
||||
getButtonSize,
|
||||
getIconSize,
|
||||
getTooltipPlacement,
|
||||
getGridSize,
|
||||
shouldUseDrawer,
|
||||
getViewportDimensions,
|
||||
|
||||
// Valeurs directes pour faciliter l'utilisation
|
||||
paletteWidth: currentConfig.paletteWidth,
|
||||
propertiesWidth: currentConfig.propertiesWidth,
|
||||
variablesHeight: currentConfig.variablesHeight,
|
||||
showMinimap: currentConfig.showMinimap,
|
||||
showVariablesPanel: currentConfig.showVariablesPanel,
|
||||
showPropertiesPanel: currentConfig.showPropertiesPanel,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Hook React sécurisé pour ResizeObserver
|
||||
*
|
||||
* Auteur : Dom, Alice, Kiro - 08 janvier 2026
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
interface ResizeObserverEntry {
|
||||
target: Element;
|
||||
contentRect: DOMRectReadOnly;
|
||||
borderBoxSize?: ReadonlyArray<ResizeObserverSize>;
|
||||
contentBoxSize?: ReadonlyArray<ResizeObserverSize>;
|
||||
devicePixelContentBoxSize?: ReadonlyArray<ResizeObserverSize>;
|
||||
}
|
||||
|
||||
type ResizeCallback = (entries: ResizeObserverEntry[]) => void;
|
||||
|
||||
/**
|
||||
* Hook sécurisé pour utiliser ResizeObserver sans erreurs de boucle infinie
|
||||
*/
|
||||
export const useSafeResizeObserver = (
|
||||
callback: ResizeCallback,
|
||||
dependencies: React.DependencyList = []
|
||||
) => {
|
||||
const observerRef = useRef<ResizeObserver | null>(null);
|
||||
const callbackRef = useRef<ResizeCallback>(callback);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Mettre à jour la référence du callback
|
||||
useEffect(() => {
|
||||
callbackRef.current = callback;
|
||||
}, [callback]);
|
||||
|
||||
// Callback sécurisé avec debounce
|
||||
const safeCallback = useCallback((entries: ResizeObserverEntry[]) => {
|
||||
// Annuler le timeout précédent
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
// Debounce pour éviter les appels trop fréquents
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
try {
|
||||
callbackRef.current(entries);
|
||||
} catch (error) {
|
||||
// Ignorer les erreurs ResizeObserver
|
||||
if (
|
||||
error instanceof Error &&
|
||||
!error.message.includes('ResizeObserver')
|
||||
) {
|
||||
console.warn('ResizeObserver callback error:', error);
|
||||
}
|
||||
}
|
||||
}, 16); // ~60fps
|
||||
}, []);
|
||||
|
||||
// Fonction pour observer un élément
|
||||
const observe = useCallback((element: Element | null) => {
|
||||
if (!element) return;
|
||||
|
||||
try {
|
||||
// Nettoyer l'observer précédent
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect();
|
||||
}
|
||||
|
||||
// Créer un nouvel observer
|
||||
observerRef.current = new ResizeObserver(safeCallback);
|
||||
observerRef.current.observe(element);
|
||||
} catch (error) {
|
||||
console.warn('Erreur lors de la création du ResizeObserver:', error);
|
||||
}
|
||||
}, [safeCallback, ...dependencies]);
|
||||
|
||||
// Fonction pour arrêter l'observation
|
||||
const disconnect = useCallback(() => {
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect();
|
||||
observerRef.current = null;
|
||||
}
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Nettoyage à la destruction du composant
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
disconnect();
|
||||
};
|
||||
}, [disconnect]);
|
||||
|
||||
return { observe, disconnect };
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook simplifié pour observer la taille d'un élément
|
||||
*/
|
||||
export const useElementSize = (
|
||||
elementRef: React.RefObject<Element>,
|
||||
onResize?: (size: { width: number; height: number }) => void
|
||||
) => {
|
||||
const { observe, disconnect } = useSafeResizeObserver(
|
||||
(entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry && onResize) {
|
||||
const { width, height } = entry.contentRect;
|
||||
onResize({ width, height });
|
||||
}
|
||||
},
|
||||
[onResize]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (elementRef.current) {
|
||||
observe(elementRef.current);
|
||||
}
|
||||
return disconnect;
|
||||
}, [elementRef.current, observe, disconnect]);
|
||||
};
|
||||
|
||||
export default useSafeResizeObserver;
|
||||
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* Hook useStepTypeResolver - Intégration du résolveur de types d'étapes
|
||||
* Auteur : Dom, Alice, Kiro - 12 janvier 2026
|
||||
*
|
||||
* Ce hook fournit une interface React pour utiliser le StepTypeResolver
|
||||
* avec gestion d'état, mémorisation et optimisations de performance.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { Step, Variable } from '../types';
|
||||
import {
|
||||
stepTypeResolver,
|
||||
StepTypeResolutionResult,
|
||||
ResolutionOptions,
|
||||
ResolutionStats
|
||||
} from '../services/StepTypeResolver';
|
||||
|
||||
/**
|
||||
* État de résolution
|
||||
*/
|
||||
export interface ResolutionState {
|
||||
isLoading: boolean;
|
||||
result: StepTypeResolutionResult | null;
|
||||
error: Error | null;
|
||||
lastResolved: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options du hook
|
||||
*/
|
||||
export interface UseStepTypeResolverOptions extends ResolutionOptions {
|
||||
autoResolve?: boolean;
|
||||
debounceMs?: number;
|
||||
retryAttempts?: number;
|
||||
onResolutionComplete?: (result: StepTypeResolutionResult) => void;
|
||||
onResolutionError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Résultat du hook
|
||||
*/
|
||||
export interface UseStepTypeResolverResult {
|
||||
// État de résolution
|
||||
state: ResolutionState;
|
||||
|
||||
// Résultat de résolution
|
||||
result: StepTypeResolutionResult | null;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
|
||||
// Méthodes de résolution
|
||||
resolveStep: (step: Step, options?: ResolutionOptions) => Promise<StepTypeResolutionResult>;
|
||||
resolveStepSync: (step: Step) => StepTypeResolutionResult | null;
|
||||
|
||||
// Utilitaires
|
||||
isVWBAction: (step: Step) => boolean;
|
||||
invalidateCache: () => void;
|
||||
getStats: () => ResolutionStats;
|
||||
|
||||
// État dérivé
|
||||
hasParameterConfig: boolean;
|
||||
parameterCount: number;
|
||||
isStandardType: boolean;
|
||||
resolutionSource: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook useStepTypeResolver
|
||||
*/
|
||||
export function useStepTypeResolver(
|
||||
selectedStep: Step | null,
|
||||
options: UseStepTypeResolverOptions = {}
|
||||
): UseStepTypeResolverResult {
|
||||
// Options par défaut
|
||||
const resolverOptions = useMemo(() => ({
|
||||
autoResolve: true,
|
||||
debounceMs: 100,
|
||||
retryAttempts: 3,
|
||||
enableCache: true,
|
||||
enableLogging: process.env.NODE_ENV === 'development',
|
||||
fallbackToEmpty: true,
|
||||
...options
|
||||
}), [options]);
|
||||
|
||||
// État de résolution
|
||||
const [state, setState] = useState<ResolutionState>({
|
||||
isLoading: false,
|
||||
result: null,
|
||||
error: null,
|
||||
lastResolved: 0
|
||||
});
|
||||
|
||||
// Références pour éviter les re-rendus
|
||||
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const retryCountRef = useRef(0);
|
||||
const lastStepRef = useRef<Step | null>(null);
|
||||
|
||||
/**
|
||||
* Résout une étape de manière asynchrone
|
||||
*/
|
||||
const resolveStep = useCallback(async (
|
||||
step: Step,
|
||||
overrideOptions?: ResolutionOptions
|
||||
): Promise<StepTypeResolutionResult> => {
|
||||
const finalOptions = { ...resolverOptions, ...overrideOptions };
|
||||
|
||||
try {
|
||||
setState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||
|
||||
const result = await stepTypeResolver.resolveParameterConfig(step, finalOptions);
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
result,
|
||||
lastResolved: Date.now()
|
||||
}));
|
||||
|
||||
// Callback de succès
|
||||
if (resolverOptions.onResolutionComplete) {
|
||||
resolverOptions.onResolutionComplete(result);
|
||||
}
|
||||
|
||||
// Reset retry count
|
||||
retryCountRef.current = 0;
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
const errorObj = error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
// Gestion des tentatives de retry
|
||||
if (retryCountRef.current < resolverOptions.retryAttempts) {
|
||||
retryCountRef.current++;
|
||||
|
||||
console.warn(`🔄 [useStepTypeResolver] Retry ${retryCountRef.current}/${resolverOptions.retryAttempts}:`, errorObj.message);
|
||||
|
||||
// Retry avec délai exponentiel
|
||||
const retryDelay = Math.pow(2, retryCountRef.current) * 100;
|
||||
await new Promise(resolve => setTimeout(resolve, retryDelay));
|
||||
|
||||
return resolveStep(step, overrideOptions);
|
||||
}
|
||||
|
||||
// Échec définitif
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: errorObj
|
||||
}));
|
||||
|
||||
// Callback d'erreur
|
||||
if (resolverOptions.onResolutionError) {
|
||||
resolverOptions.onResolutionError(errorObj);
|
||||
}
|
||||
|
||||
throw errorObj;
|
||||
}
|
||||
}, [resolverOptions]);
|
||||
|
||||
/**
|
||||
* Résolution synchrone (depuis le cache)
|
||||
*/
|
||||
const resolveStepSync = useCallback((step: Step): StepTypeResolutionResult | null => {
|
||||
try {
|
||||
// Vérifier si le résultat est déjà en cache/état
|
||||
if (state.result &&
|
||||
state.result.stepType === step.type &&
|
||||
Date.now() - state.lastResolved < 5000) { // Cache 5 secondes
|
||||
return state.result;
|
||||
}
|
||||
|
||||
// Tentative de résolution synchrone basique
|
||||
const isVWB = stepTypeResolver.isVWBAction(step);
|
||||
|
||||
return {
|
||||
stepType: step.type as string,
|
||||
isVWBAction: isVWB,
|
||||
isStandardType: !isVWB,
|
||||
parameterConfig: [],
|
||||
detectionMethods: { sync: true },
|
||||
resolutionSource: 'fallback' as const,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ [useStepTypeResolver] Erreur résolution sync:', error);
|
||||
return null;
|
||||
}
|
||||
}, [state.result, state.lastResolved]);
|
||||
|
||||
/**
|
||||
* Résolution automatique avec debounce
|
||||
*/
|
||||
const debouncedResolve = useCallback((step: Step) => {
|
||||
// Annuler le timeout précédent
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Programmer la nouvelle résolution
|
||||
debounceTimeoutRef.current = setTimeout(() => {
|
||||
resolveStep(step).catch(error => {
|
||||
console.error('❌ [useStepTypeResolver] Erreur résolution auto:', error);
|
||||
});
|
||||
}, resolverOptions.debounceMs);
|
||||
}, [resolveStep, resolverOptions.debounceMs]);
|
||||
|
||||
/**
|
||||
* Effet pour résolution automatique
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!resolverOptions.autoResolve || !selectedStep) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Éviter la résolution si l'étape n'a pas changé
|
||||
if (lastStepRef.current &&
|
||||
lastStepRef.current.id === selectedStep.id &&
|
||||
lastStepRef.current.type === selectedStep.type &&
|
||||
JSON.stringify(lastStepRef.current.data) === JSON.stringify(selectedStep.data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastStepRef.current = selectedStep;
|
||||
debouncedResolve(selectedStep);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [selectedStep, resolverOptions.autoResolve, debouncedResolve]);
|
||||
|
||||
/**
|
||||
* Vérification VWB rapide
|
||||
*/
|
||||
const isVWBAction = useCallback((step: Step): boolean => {
|
||||
return stepTypeResolver.isVWBAction(step);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Invalidation du cache
|
||||
*/
|
||||
const invalidateCache = useCallback(() => {
|
||||
stepTypeResolver.invalidateCache();
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
result: null,
|
||||
lastResolved: 0
|
||||
}));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Obtention des statistiques
|
||||
*/
|
||||
const getStats = useCallback((): ResolutionStats => {
|
||||
return stepTypeResolver.getResolutionStats();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* État dérivé mémorisé
|
||||
*/
|
||||
const derivedState = useMemo(() => {
|
||||
const result = state.result;
|
||||
|
||||
return {
|
||||
hasParameterConfig: Boolean(result?.parameterConfig?.length),
|
||||
parameterCount: result?.parameterConfig?.length || 0,
|
||||
isStandardType: Boolean(result?.isStandardType),
|
||||
resolutionSource: result?.resolutionSource || null
|
||||
};
|
||||
}, [state.result]);
|
||||
|
||||
/**
|
||||
* Cleanup à la destruction
|
||||
*/
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// État de résolution
|
||||
state,
|
||||
|
||||
// Résultat de résolution
|
||||
result: state.result,
|
||||
isLoading: state.isLoading,
|
||||
error: state.error,
|
||||
|
||||
// Méthodes de résolution
|
||||
resolveStep,
|
||||
resolveStepSync,
|
||||
|
||||
// Utilitaires
|
||||
isVWBAction,
|
||||
invalidateCache,
|
||||
getStats,
|
||||
|
||||
// État dérivé
|
||||
...derivedState
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook simplifié pour vérification VWB uniquement
|
||||
*/
|
||||
export function useIsVWBStep(step: Step | null): boolean {
|
||||
return useMemo(() => {
|
||||
if (!step) return false;
|
||||
return stepTypeResolver.isVWBAction(step);
|
||||
}, [step]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook pour obtenir les statistiques de résolution
|
||||
*/
|
||||
export function useStepTypeResolverStats(): ResolutionStats {
|
||||
const [stats, setStats] = useState<ResolutionStats>(() =>
|
||||
stepTypeResolver.getResolutionStats()
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setStats(stepTypeResolver.getResolutionStats());
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export par défaut
|
||||
*/
|
||||
export default useStepTypeResolver;
|
||||
@@ -0,0 +1,810 @@
|
||||
/**
|
||||
* Hook useVWBActionDetails - Chargement lazy des détails d'actions VWB
|
||||
* Auteur : Dom, Alice, Kiro - 12 janvier 2026
|
||||
*
|
||||
* Ce hook gère le chargement lazy des détails d'actions VWB avec cache intelligent,
|
||||
* gestion d'erreurs robuste, fallback vers le catalogue statique et optimisations
|
||||
* de performance avec debouncing et cache multi-niveaux.
|
||||
*
|
||||
* Version 2.0 - Optimisations de performance et cache intelligent
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
||||
import { VWBCatalogAction } from '../types/catalog';
|
||||
import { catalogService } from '../services/catalogService';
|
||||
import { staticCatalog } from '../data/staticCatalog';
|
||||
|
||||
/**
|
||||
* État de chargement d'une action
|
||||
*/
|
||||
export interface ActionLoadingState {
|
||||
isLoading: boolean;
|
||||
isLoaded: boolean;
|
||||
error: Error | null;
|
||||
lastLoaded: number;
|
||||
retryCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache d'actions avec métadonnées
|
||||
*/
|
||||
interface ActionCacheEntry {
|
||||
action: VWBCatalogAction;
|
||||
loadedAt: number;
|
||||
source: 'api' | 'static' | 'fallback';
|
||||
isValid: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options de chargement avec optimisations
|
||||
*/
|
||||
export interface LoadActionOptions {
|
||||
forceReload?: boolean;
|
||||
enableFallback?: boolean;
|
||||
timeout?: number;
|
||||
retryAttempts?: number;
|
||||
cacheTimeout?: number;
|
||||
debounceMs?: number;
|
||||
priority?: 'low' | 'normal' | 'high';
|
||||
batchWith?: string[]; // IDs d'actions à charger en lot
|
||||
}
|
||||
|
||||
/**
|
||||
* Résultat du hook avec optimisations
|
||||
*/
|
||||
export interface UseVWBActionDetailsResult {
|
||||
// État global
|
||||
isLoading: boolean;
|
||||
hasErrors: boolean;
|
||||
totalActions: number;
|
||||
|
||||
// Méthodes de chargement optimisées
|
||||
loadAction: (actionId: string, options?: LoadActionOptions) => Promise<VWBCatalogAction | null>;
|
||||
loadActionDebounced: (actionId: string, options?: LoadActionOptions) => Promise<VWBCatalogAction | null>;
|
||||
loadActionsBatch: (actionIds: string[], options?: LoadActionOptions) => Promise<Map<string, VWBCatalogAction | null>>;
|
||||
getAction: (actionId: string) => VWBCatalogAction | null;
|
||||
preloadActions: (actionIds: string[]) => Promise<void>;
|
||||
|
||||
// Gestion du cache multi-niveaux
|
||||
invalidateCache: (actionId?: string) => void;
|
||||
warmupCache: (actionIds: string[]) => Promise<void>;
|
||||
getCacheStats: () => CacheStats;
|
||||
|
||||
// État des actions individuelles
|
||||
getActionState: (actionId: string) => ActionLoadingState;
|
||||
|
||||
// Validation optimisée
|
||||
validateAction: (actionId: string, parameters: Record<string, any>) => Promise<boolean>;
|
||||
validateActionsBatch: (actions: Array<{ id: string; parameters: Record<string, any> }>) => Promise<Map<string, boolean>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Statistiques du cache avec métriques de performance
|
||||
*/
|
||||
export interface CacheStats {
|
||||
totalEntries: number;
|
||||
apiEntries: number;
|
||||
staticEntries: number;
|
||||
fallbackEntries: number;
|
||||
validEntries: number;
|
||||
expiredEntries: number;
|
||||
cacheHitRate: number;
|
||||
averageLoadTime: number;
|
||||
totalRequests: number;
|
||||
debouncedRequests: number;
|
||||
batchRequests: number;
|
||||
memoryUsage: number; // Estimation en KB
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook useVWBActionDetails
|
||||
*/
|
||||
export function useVWBActionDetails(): UseVWBActionDetailsResult {
|
||||
// État du cache d'actions
|
||||
const [actionCache, setActionCache] = useState<Map<string, ActionCacheEntry>>(new Map());
|
||||
const [loadingStates, setLoadingStates] = useState<Map<string, ActionLoadingState>>(new Map());
|
||||
|
||||
// Références pour optimisation et debouncing
|
||||
const loadingPromisesRef = useRef<Map<string, Promise<VWBCatalogAction | null>>>(new Map());
|
||||
const loadTimesRef = useRef<number[]>([]);
|
||||
const cacheAccessesRef = useRef<{ hits: number; misses: number }>({ hits: 0, misses: 0 });
|
||||
const debounceTimersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||||
const batchQueueRef = useRef<Map<string, { resolve: Function; reject: Function; options: LoadActionOptions }[]>>(new Map());
|
||||
const performanceMetricsRef = useRef<{
|
||||
totalRequests: number;
|
||||
debouncedRequests: number;
|
||||
batchRequests: number;
|
||||
}>({ totalRequests: 0, debouncedRequests: 0, batchRequests: 0 });
|
||||
|
||||
/**
|
||||
* Obtient l'état de chargement d'une action
|
||||
*/
|
||||
const getActionState = useCallback((actionId: string): ActionLoadingState => {
|
||||
return loadingStates.get(actionId) || {
|
||||
isLoading: false,
|
||||
isLoaded: false,
|
||||
error: null,
|
||||
lastLoaded: 0,
|
||||
retryCount: 0
|
||||
};
|
||||
}, [loadingStates]);
|
||||
|
||||
/**
|
||||
* Met à jour l'état de chargement d'une action
|
||||
*/
|
||||
const updateActionState = useCallback((
|
||||
actionId: string,
|
||||
updates: Partial<ActionLoadingState>
|
||||
) => {
|
||||
setLoadingStates(prev => {
|
||||
const current = prev.get(actionId) || {
|
||||
isLoading: false,
|
||||
isLoaded: false,
|
||||
error: null,
|
||||
lastLoaded: 0,
|
||||
retryCount: 0
|
||||
};
|
||||
|
||||
return new Map(prev).set(actionId, { ...current, ...updates });
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Charge une action depuis le catalogue statique (fallback)
|
||||
*/
|
||||
const loadFromStaticCatalog = useCallback((actionId: string): VWBCatalogAction | null => {
|
||||
try {
|
||||
// Recherche avec fallback intelligent
|
||||
let staticAction = staticCatalog.findActionWithFallback(actionId);
|
||||
|
||||
if (staticAction) {
|
||||
console.log('📚 [useVWBActionDetails] Action trouvée avec fallback:', {
|
||||
actionId,
|
||||
foundId: staticAction.id,
|
||||
isFallback: staticAction.fallbackMetadata?.isFallback || false,
|
||||
confidence: staticAction.fallbackMetadata?.confidence || 1.0
|
||||
});
|
||||
return staticAction;
|
||||
}
|
||||
|
||||
// Créer une action de fallback générique
|
||||
const fallbackAction = staticCatalog.createFallbackAction(actionId);
|
||||
|
||||
console.log('🔧 [useVWBActionDetails] Action de fallback générique créée:', {
|
||||
actionId,
|
||||
category: fallbackAction.category,
|
||||
confidence: fallbackAction.fallbackMetadata?.confidence
|
||||
});
|
||||
|
||||
return fallbackAction;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ [useVWBActionDetails] Erreur fallback statique:', error);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Valide une entrée de cache
|
||||
*/
|
||||
const isCacheEntryValid = useCallback((
|
||||
entry: ActionCacheEntry,
|
||||
cacheTimeout: number = 300000 // 5 minutes par défaut
|
||||
): boolean => {
|
||||
const isNotExpired = Date.now() - entry.loadedAt < cacheTimeout;
|
||||
return entry.isValid && isNotExpired;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Charge une action avec gestion complète d'erreurs et fallback
|
||||
*/
|
||||
const loadAction = useCallback(async (
|
||||
actionId: string,
|
||||
options: LoadActionOptions = {}
|
||||
): Promise<VWBCatalogAction | null> => {
|
||||
const startTime = performance.now();
|
||||
performanceMetricsRef.current.totalRequests++;
|
||||
|
||||
const loadOptions = {
|
||||
forceReload: false,
|
||||
enableFallback: true,
|
||||
timeout: 5000,
|
||||
retryAttempts: 3,
|
||||
cacheTimeout: 300000, // 5 minutes
|
||||
debounceMs: 0, // Pas de debounce par défaut pour loadAction direct
|
||||
priority: 'normal' as const,
|
||||
...options
|
||||
};
|
||||
|
||||
try {
|
||||
// Vérifier le cache si pas de rechargement forcé
|
||||
if (!loadOptions.forceReload) {
|
||||
const cached = actionCache.get(actionId);
|
||||
if (cached && isCacheEntryValid(cached, loadOptions.cacheTimeout)) {
|
||||
cacheAccessesRef.current.hits++;
|
||||
console.log('🎯 [useVWBActionDetails] Cache hit:', actionId);
|
||||
return cached.action;
|
||||
}
|
||||
cacheAccessesRef.current.misses++;
|
||||
}
|
||||
|
||||
// Vérifier si un chargement est déjà en cours
|
||||
const existingPromise = loadingPromisesRef.current.get(actionId);
|
||||
if (existingPromise) {
|
||||
console.log('⏳ [useVWBActionDetails] Chargement en cours, attente:', actionId);
|
||||
return await existingPromise;
|
||||
}
|
||||
|
||||
// Créer la promesse de chargement
|
||||
const loadingPromise = (async (): Promise<VWBCatalogAction | null> => {
|
||||
updateActionState(actionId, {
|
||||
isLoading: true,
|
||||
error: null
|
||||
});
|
||||
|
||||
let lastError: Error | null = null;
|
||||
|
||||
// Tentatives de chargement avec retry
|
||||
for (let attempt = 1; attempt <= loadOptions.retryAttempts; attempt++) {
|
||||
try {
|
||||
console.log(`🔄 [useVWBActionDetails] Tentative ${attempt}/${loadOptions.retryAttempts}:`, actionId);
|
||||
|
||||
// Chargement depuis l'API avec timeout
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
setTimeout(() => reject(new Error('Timeout de chargement')), loadOptions.timeout);
|
||||
});
|
||||
|
||||
const loadPromise = catalogService.getActionDetails(actionId);
|
||||
const action = await Promise.race([loadPromise, timeoutPromise]);
|
||||
|
||||
if (action) {
|
||||
// Succès - mettre en cache
|
||||
const cacheEntry: ActionCacheEntry = {
|
||||
action,
|
||||
loadedAt: Date.now(),
|
||||
source: 'api',
|
||||
isValid: true
|
||||
};
|
||||
|
||||
setActionCache(prev => new Map(prev).set(actionId, cacheEntry));
|
||||
|
||||
updateActionState(actionId, {
|
||||
isLoading: false,
|
||||
isLoaded: true,
|
||||
error: null,
|
||||
lastLoaded: Date.now(),
|
||||
retryCount: 0
|
||||
});
|
||||
|
||||
console.log('✅ [useVWBActionDetails] Action chargée depuis l\'API:', actionId);
|
||||
return action;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
console.warn(`⚠️ [useVWBActionDetails] Tentative ${attempt} échouée:`, lastError.message);
|
||||
|
||||
// Délai exponentiel entre les tentatives
|
||||
if (attempt < loadOptions.retryAttempts) {
|
||||
const delay = Math.pow(2, attempt) * 1000;
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Toutes les tentatives ont échoué - essayer le fallback
|
||||
if (loadOptions.enableFallback) {
|
||||
console.log('🔄 [useVWBActionDetails] Tentative de fallback:', actionId);
|
||||
|
||||
const fallbackAction = loadFromStaticCatalog(actionId);
|
||||
if (fallbackAction) {
|
||||
const cacheEntry: ActionCacheEntry = {
|
||||
action: fallbackAction,
|
||||
loadedAt: Date.now(),
|
||||
source: 'static',
|
||||
isValid: true
|
||||
};
|
||||
|
||||
setActionCache(prev => new Map(prev).set(actionId, cacheEntry));
|
||||
|
||||
updateActionState(actionId, {
|
||||
isLoading: false,
|
||||
isLoaded: true,
|
||||
error: null,
|
||||
lastLoaded: Date.now(),
|
||||
retryCount: loadOptions.retryAttempts
|
||||
});
|
||||
|
||||
return fallbackAction;
|
||||
}
|
||||
}
|
||||
|
||||
// Échec complet
|
||||
updateActionState(actionId, {
|
||||
isLoading: false,
|
||||
isLoaded: false,
|
||||
error: lastError,
|
||||
retryCount: loadOptions.retryAttempts
|
||||
});
|
||||
|
||||
console.error('❌ [useVWBActionDetails] Échec complet du chargement:', actionId, lastError);
|
||||
return null;
|
||||
|
||||
})();
|
||||
|
||||
// Enregistrer la promesse
|
||||
loadingPromisesRef.current.set(actionId, loadingPromise);
|
||||
|
||||
try {
|
||||
const result = await loadingPromise;
|
||||
|
||||
// Enregistrer le temps de chargement
|
||||
const loadTime = performance.now() - startTime;
|
||||
loadTimesRef.current.push(loadTime);
|
||||
|
||||
return result;
|
||||
|
||||
} finally {
|
||||
// Nettoyer la promesse
|
||||
loadingPromisesRef.current.delete(actionId);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
const errorObj = error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
updateActionState(actionId, {
|
||||
isLoading: false,
|
||||
error: errorObj
|
||||
});
|
||||
|
||||
console.error('❌ [useVWBActionDetails] Erreur critique:', errorObj);
|
||||
return null;
|
||||
}
|
||||
}, [actionCache, isCacheEntryValid, updateActionState, loadFromStaticCatalog]);
|
||||
|
||||
/**
|
||||
* Charge une action avec debouncing pour éviter les appels répétés
|
||||
*/
|
||||
const loadActionDebounced = useCallback(async (
|
||||
actionId: string,
|
||||
options: LoadActionOptions = {}
|
||||
): Promise<VWBCatalogAction | null> => {
|
||||
const debounceMs = options.debounceMs || 300;
|
||||
performanceMetricsRef.current.debouncedRequests++;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// Annuler le timer précédent pour cette action
|
||||
const existingTimer = debounceTimersRef.current.get(actionId);
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer);
|
||||
}
|
||||
|
||||
// Créer un nouveau timer
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
const result = await loadAction(actionId, { ...options, debounceMs: 0 });
|
||||
resolve(result);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
} finally {
|
||||
debounceTimersRef.current.delete(actionId);
|
||||
}
|
||||
}, debounceMs);
|
||||
|
||||
debounceTimersRef.current.set(actionId, timer);
|
||||
});
|
||||
}, [loadAction]);
|
||||
|
||||
/**
|
||||
* Charge plusieurs actions en lot pour optimiser les performances
|
||||
*/
|
||||
const loadActionsBatch = useCallback(async (
|
||||
actionIds: string[],
|
||||
options: LoadActionOptions = {}
|
||||
): Promise<Map<string, VWBCatalogAction | null>> => {
|
||||
performanceMetricsRef.current.batchRequests++;
|
||||
|
||||
console.log('🚀 [useVWBActionDetails] Chargement en lot:', {
|
||||
actionCount: actionIds.length,
|
||||
actionIds: actionIds.slice(0, 5), // Afficher seulement les 5 premiers
|
||||
hasMore: actionIds.length > 5
|
||||
});
|
||||
|
||||
const results = new Map<string, VWBCatalogAction | null>();
|
||||
const batchSize = 5; // Traiter par lots de 5 pour éviter la surcharge
|
||||
|
||||
// Traiter les actions par lots
|
||||
for (let i = 0; i < actionIds.length; i += batchSize) {
|
||||
const batch = actionIds.slice(i, i + batchSize);
|
||||
|
||||
// Charger le lot en parallèle
|
||||
const batchPromises = batch.map(async (actionId) => {
|
||||
try {
|
||||
const action = await loadAction(actionId, options);
|
||||
return { actionId, action };
|
||||
} catch (error) {
|
||||
console.error(`❌ [useVWBActionDetails] Erreur lot pour ${actionId}:`, error);
|
||||
return { actionId, action: null };
|
||||
}
|
||||
});
|
||||
|
||||
const batchResults = await Promise.allSettled(batchPromises);
|
||||
|
||||
// Traiter les résultats du lot
|
||||
batchResults.forEach((result) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
results.set(result.value.actionId, result.value.action);
|
||||
}
|
||||
});
|
||||
|
||||
// Délai entre les lots pour éviter la surcharge
|
||||
if (i + batchSize < actionIds.length) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
|
||||
console.log('✅ [useVWBActionDetails] Chargement en lot terminé:', {
|
||||
requested: actionIds.length,
|
||||
loaded: Array.from(results.values()).filter(Boolean).length,
|
||||
failed: Array.from(results.values()).filter(a => a === null).length
|
||||
});
|
||||
|
||||
return results;
|
||||
}, [loadAction]);
|
||||
|
||||
/**
|
||||
* Préchauffe le cache avec des actions prioritaires
|
||||
*/
|
||||
const warmupCache = useCallback(async (actionIds: string[]): Promise<void> => {
|
||||
console.log('🔥 [useVWBActionDetails] Préchauffage du cache:', actionIds.length, 'actions');
|
||||
|
||||
// Charger en arrière-plan avec priorité basse
|
||||
const warmupPromises = actionIds.map(actionId =>
|
||||
loadAction(actionId, {
|
||||
enableFallback: true,
|
||||
priority: 'low',
|
||||
cacheTimeout: 600000 // Cache plus long pour le préchauffage (10 minutes)
|
||||
}).catch(error => {
|
||||
console.warn(`⚠️ [useVWBActionDetails] Échec préchauffage ${actionId}:`, error);
|
||||
return null;
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.allSettled(warmupPromises);
|
||||
console.log('✅ [useVWBActionDetails] Préchauffage terminé');
|
||||
}, [loadAction]);
|
||||
|
||||
/**
|
||||
* Obtient une action depuis le cache
|
||||
*/
|
||||
const getAction = useCallback((actionId: string): VWBCatalogAction | null => {
|
||||
const cached = actionCache.get(actionId);
|
||||
if (cached && isCacheEntryValid(cached)) {
|
||||
return cached.action;
|
||||
}
|
||||
return null;
|
||||
}, [actionCache, isCacheEntryValid]);
|
||||
|
||||
/**
|
||||
* Précharge plusieurs actions en parallèle
|
||||
*/
|
||||
const preloadActions = useCallback(async (actionIds: string[]): Promise<void> => {
|
||||
console.log('🚀 [useVWBActionDetails] Préchargement de', actionIds.length, 'actions');
|
||||
|
||||
const loadPromises = actionIds.map(actionId =>
|
||||
loadAction(actionId, { enableFallback: true })
|
||||
);
|
||||
|
||||
try {
|
||||
await Promise.allSettled(loadPromises);
|
||||
console.log('✅ [useVWBActionDetails] Préchargement terminé');
|
||||
} catch (error) {
|
||||
console.error('❌ [useVWBActionDetails] Erreur préchargement:', error);
|
||||
}
|
||||
}, [loadAction]);
|
||||
|
||||
/**
|
||||
* Invalide le cache
|
||||
*/
|
||||
const invalidateCache = useCallback((actionId?: string) => {
|
||||
if (actionId) {
|
||||
setActionCache(prev => {
|
||||
const newCache = new Map(prev);
|
||||
newCache.delete(actionId);
|
||||
return newCache;
|
||||
});
|
||||
console.log('🗑️ [useVWBActionDetails] Cache invalidé pour:', actionId);
|
||||
} else {
|
||||
setActionCache(new Map());
|
||||
setLoadingStates(new Map());
|
||||
loadingPromisesRef.current.clear();
|
||||
console.log('🗑️ [useVWBActionDetails] Cache complet invalidé');
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Obtient les statistiques du cache avec métriques de performance
|
||||
*/
|
||||
const getCacheStats = useCallback((): CacheStats => {
|
||||
const entries = Array.from(actionCache.values());
|
||||
const now = Date.now();
|
||||
|
||||
const apiEntries = entries.filter(e => e.source === 'api').length;
|
||||
const staticEntries = entries.filter(e => e.source === 'static').length;
|
||||
const fallbackEntries = entries.filter(e => e.source === 'fallback').length;
|
||||
const validEntries = entries.filter(e => isCacheEntryValid(e)).length;
|
||||
const expiredEntries = entries.length - validEntries;
|
||||
|
||||
const totalAccesses = cacheAccessesRef.current.hits + cacheAccessesRef.current.misses;
|
||||
const cacheHitRate = totalAccesses > 0 ? cacheAccessesRef.current.hits / totalAccesses : 0;
|
||||
|
||||
const averageLoadTime = loadTimesRef.current.length > 0
|
||||
? loadTimesRef.current.reduce((a, b) => a + b, 0) / loadTimesRef.current.length
|
||||
: 0;
|
||||
|
||||
// Estimation de l'usage mémoire (approximatif)
|
||||
const memoryUsage = entries.reduce((total, entry) => {
|
||||
const actionSize = JSON.stringify(entry.action).length;
|
||||
return total + actionSize;
|
||||
}, 0) / 1024; // Convertir en KB
|
||||
|
||||
return {
|
||||
totalEntries: entries.length,
|
||||
apiEntries,
|
||||
staticEntries,
|
||||
fallbackEntries,
|
||||
validEntries,
|
||||
expiredEntries,
|
||||
cacheHitRate,
|
||||
averageLoadTime,
|
||||
totalRequests: performanceMetricsRef.current.totalRequests,
|
||||
debouncedRequests: performanceMetricsRef.current.debouncedRequests,
|
||||
batchRequests: performanceMetricsRef.current.batchRequests,
|
||||
memoryUsage
|
||||
};
|
||||
}, [actionCache, isCacheEntryValid]);
|
||||
|
||||
/**
|
||||
* Valide une action avec ses paramètres
|
||||
*/
|
||||
const validateAction = useCallback(async (
|
||||
actionId: string,
|
||||
parameters: Record<string, any>
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
const action = await loadAction(actionId);
|
||||
if (!action) {
|
||||
console.warn('⚠️ [useVWBActionDetails] Action non trouvée pour validation:', actionId);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validation de l'action elle-même si c'est une action statique
|
||||
if ('fallbackMetadata' in action) {
|
||||
const staticAction = action as any; // StaticCatalogAction
|
||||
const validation = staticCatalog.validateStaticAction(staticAction);
|
||||
|
||||
if (!validation.isValid) {
|
||||
console.error('❌ [useVWBActionDetails] Action invalide:', {
|
||||
actionId,
|
||||
errors: validation.errors
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (validation.warnings.length > 0) {
|
||||
console.warn('⚠️ [useVWBActionDetails] Avertissements action:', {
|
||||
actionId,
|
||||
warnings: validation.warnings
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Validation des paramètres requis
|
||||
const missingParams: string[] = [];
|
||||
const invalidParams: string[] = [];
|
||||
|
||||
for (const [paramName, paramConfig] of Object.entries(action.parameters)) {
|
||||
const paramValue = parameters[paramName];
|
||||
|
||||
// Vérifier les paramètres requis
|
||||
if (paramConfig.required && (paramValue === undefined || paramValue === null || paramValue === '')) {
|
||||
missingParams.push(paramName);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validation de type si la valeur est présente
|
||||
if (paramValue !== undefined && paramValue !== null) {
|
||||
const isValidType = validateParameterType(paramValue, paramConfig.type);
|
||||
if (!isValidType) {
|
||||
invalidParams.push(`${paramName} (attendu: ${paramConfig.type}, reçu: ${typeof paramValue})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rapporter les erreurs de validation
|
||||
if (missingParams.length > 0) {
|
||||
console.error('❌ [useVWBActionDetails] Paramètres requis manquants:', {
|
||||
actionId,
|
||||
missingParams
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (invalidParams.length > 0) {
|
||||
console.error('❌ [useVWBActionDetails] Paramètres de type invalide:', {
|
||||
actionId,
|
||||
invalidParams
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log('✅ [useVWBActionDetails] Validation réussie:', {
|
||||
actionId,
|
||||
parameterCount: Object.keys(parameters).length
|
||||
});
|
||||
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ [useVWBActionDetails] Erreur validation:', error);
|
||||
return false;
|
||||
}
|
||||
}, [loadAction]);
|
||||
|
||||
/**
|
||||
* Valide le type d'un paramètre
|
||||
*/
|
||||
const validateParameterType = (value: any, expectedType: string): boolean => {
|
||||
switch (expectedType) {
|
||||
case 'string':
|
||||
return typeof value === 'string';
|
||||
case 'number':
|
||||
return typeof value === 'number' && !isNaN(value);
|
||||
case 'boolean':
|
||||
return typeof value === 'boolean';
|
||||
case 'VWBVisualAnchor':
|
||||
return value && typeof value === 'object' && 'x' in value && 'y' in value;
|
||||
default:
|
||||
// Type inconnu, accepter par défaut
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Valide plusieurs actions en lot
|
||||
*/
|
||||
const validateActionsBatch = useCallback(async (
|
||||
actions: Array<{ id: string; parameters: Record<string, any> }>
|
||||
): Promise<Map<string, boolean>> => {
|
||||
console.log('🔍 [useVWBActionDetails] Validation en lot:', actions.length, 'actions');
|
||||
|
||||
const results = new Map<string, boolean>();
|
||||
|
||||
// Valider en parallèle avec limite de concurrence
|
||||
const concurrencyLimit = 3;
|
||||
for (let i = 0; i < actions.length; i += concurrencyLimit) {
|
||||
const batch = actions.slice(i, i + concurrencyLimit);
|
||||
|
||||
const batchPromises = batch.map(async ({ id, parameters }) => {
|
||||
try {
|
||||
const isValid = await validateAction(id, parameters);
|
||||
return { id, isValid };
|
||||
} catch (error) {
|
||||
console.error(`❌ [useVWBActionDetails] Erreur validation ${id}:`, error);
|
||||
return { id, isValid: false };
|
||||
}
|
||||
});
|
||||
|
||||
const batchResults = await Promise.allSettled(batchPromises);
|
||||
|
||||
batchResults.forEach((result) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
results.set(result.value.id, result.value.isValid);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const validCount = Array.from(results.values()).filter(Boolean).length;
|
||||
console.log('✅ [useVWBActionDetails] Validation lot terminée:', {
|
||||
total: actions.length,
|
||||
valid: validCount,
|
||||
invalid: actions.length - validCount
|
||||
});
|
||||
|
||||
return results;
|
||||
}, [validateAction]);
|
||||
|
||||
// État global dérivé
|
||||
const globalState = useMemo(() => {
|
||||
const states = Array.from(loadingStates.values());
|
||||
|
||||
return {
|
||||
isLoading: states.some(s => s.isLoading),
|
||||
hasErrors: states.some(s => s.error !== null),
|
||||
totalActions: actionCache.size
|
||||
};
|
||||
}, [loadingStates, actionCache]);
|
||||
|
||||
// Nettoyage périodique du cache et des timers
|
||||
useEffect(() => {
|
||||
const cleanupInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
const expiredKeys: string[] = [];
|
||||
|
||||
// Nettoyer le cache expiré
|
||||
actionCache.forEach((entry, key) => {
|
||||
if (!isCacheEntryValid(entry)) {
|
||||
expiredKeys.push(key);
|
||||
}
|
||||
});
|
||||
|
||||
if (expiredKeys.length > 0) {
|
||||
setActionCache(prev => {
|
||||
const newCache = new Map(prev);
|
||||
expiredKeys.forEach(key => newCache.delete(key));
|
||||
return newCache;
|
||||
});
|
||||
|
||||
console.log(`🧹 [useVWBActionDetails] ${expiredKeys.length} entrées expirées nettoyées`);
|
||||
}
|
||||
|
||||
// Nettoyer les timers de debounce expirés
|
||||
const expiredTimers: string[] = [];
|
||||
debounceTimersRef.current.forEach((timer, actionId) => {
|
||||
// Les timers sont automatiquement nettoyés, mais on peut vérifier s'il y en a trop
|
||||
if (debounceTimersRef.current.size > 50) {
|
||||
expiredTimers.push(actionId);
|
||||
}
|
||||
});
|
||||
|
||||
// Limiter la taille des métriques de performance
|
||||
if (loadTimesRef.current.length > 1000) {
|
||||
loadTimesRef.current = loadTimesRef.current.slice(-500); // Garder les 500 derniers
|
||||
}
|
||||
|
||||
}, 60000); // Nettoyage toutes les minutes
|
||||
|
||||
return () => clearInterval(cleanupInterval);
|
||||
}, [actionCache, isCacheEntryValid]);
|
||||
|
||||
// Nettoyage à la destruction du composant
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// Nettoyer tous les timers de debounce
|
||||
debounceTimersRef.current.forEach(timer => clearTimeout(timer));
|
||||
debounceTimersRef.current.clear();
|
||||
|
||||
// Nettoyer les promesses en cours
|
||||
loadingPromisesRef.current.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// État global
|
||||
...globalState,
|
||||
|
||||
// Méthodes de chargement optimisées
|
||||
loadAction,
|
||||
loadActionDebounced,
|
||||
loadActionsBatch,
|
||||
getAction,
|
||||
preloadActions,
|
||||
|
||||
// Gestion du cache multi-niveaux
|
||||
invalidateCache,
|
||||
warmupCache,
|
||||
getCacheStats,
|
||||
|
||||
// État des actions individuelles
|
||||
getActionState,
|
||||
|
||||
// Validation optimisée
|
||||
validateAction,
|
||||
validateActionsBatch
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Export par défaut
|
||||
*/
|
||||
export default useVWBActionDetails;
|
||||
@@ -13,16 +13,57 @@ import {
|
||||
VWBExecutionOptions,
|
||||
VWBExecutionContext
|
||||
} from '../services/vwbExecutionService';
|
||||
import {
|
||||
Workflow,
|
||||
Step,
|
||||
StepExecutionState,
|
||||
import {
|
||||
Workflow,
|
||||
Step,
|
||||
StepExecutionState,
|
||||
ExecutionState,
|
||||
ExecutionError,
|
||||
Evidence,
|
||||
Variable
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
* Émet un BIP sonore d'alerte via l'API Web Audio
|
||||
* Utilisé pour alerter l'utilisateur quand une erreur stoppe le workflow
|
||||
*/
|
||||
const playErrorBeep = async (): Promise<void> => {
|
||||
try {
|
||||
// Créer un contexte audio
|
||||
const AudioContext = window.AudioContext || (window as any).webkitAudioContext;
|
||||
if (!AudioContext) {
|
||||
console.warn('Web Audio API non disponible');
|
||||
return;
|
||||
}
|
||||
|
||||
const audioCtx = new AudioContext();
|
||||
|
||||
// Jouer 3 bips rapides pour alerter
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const oscillator = audioCtx.createOscillator();
|
||||
const gainNode = audioCtx.createGain();
|
||||
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(audioCtx.destination);
|
||||
|
||||
// Fréquence du bip (800 Hz = son d'erreur)
|
||||
oscillator.frequency.value = 800;
|
||||
oscillator.type = 'sine';
|
||||
|
||||
// Volume
|
||||
gainNode.gain.value = 0.3;
|
||||
|
||||
const startTime = audioCtx.currentTime + (i * 0.15);
|
||||
oscillator.start(startTime);
|
||||
oscillator.stop(startTime + 0.1);
|
||||
}
|
||||
|
||||
console.log('🔔 BIP BIP BIP - Alerte sonore jouée');
|
||||
} catch (error) {
|
||||
console.warn('Impossible de jouer le bip sonore:', error);
|
||||
}
|
||||
};
|
||||
|
||||
export interface VWBExecutionState {
|
||||
status: 'idle' | 'running' | 'paused' | 'completed' | 'error';
|
||||
currentStepIndex: number;
|
||||
@@ -66,11 +107,15 @@ export interface UseVWBExecutionOptions {
|
||||
retryAttempts?: number;
|
||||
timeout?: number;
|
||||
pauseOnError?: boolean;
|
||||
stopOnError?: boolean; // IMPORTANT: Arrêter complètement le workflow sur erreur (par défaut: true)
|
||||
skipNonVWBSteps?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook principal pour l'exécution des workflows VWB
|
||||
*
|
||||
* OPTIMISATION: Utilise des refs pour les callbacks et options
|
||||
* pour éviter les re-créations de fonctions à chaque render.
|
||||
*/
|
||||
export const useVWBExecution = (
|
||||
workflow: Workflow,
|
||||
@@ -84,9 +129,23 @@ export const useVWBExecution = (
|
||||
retryAttempts = 3,
|
||||
timeout = 30000,
|
||||
pauseOnError = false,
|
||||
stopOnError = true, // IMPORTANT: Par défaut, STOPPER le workflow sur erreur!
|
||||
skipNonVWBSteps = false
|
||||
} = options;
|
||||
|
||||
// OPTIMISATION: Refs pour callbacks et options (évite les re-renders)
|
||||
const callbacksRef = useRef(callbacks);
|
||||
const optionsRef = useRef({ autoValidate, generateEvidence, retryAttempts, timeout, pauseOnError, stopOnError, skipNonVWBSteps });
|
||||
|
||||
// Mettre à jour les refs quand les valeurs changent (sans causer de re-render)
|
||||
useEffect(() => {
|
||||
callbacksRef.current = callbacks;
|
||||
}, [callbacks]);
|
||||
|
||||
useEffect(() => {
|
||||
optionsRef.current = { autoValidate, generateEvidence, retryAttempts, timeout, pauseOnError, stopOnError, skipNonVWBSteps };
|
||||
}, [autoValidate, generateEvidence, retryAttempts, timeout, pauseOnError, stopOnError, skipNonVWBSteps]);
|
||||
|
||||
// État d'exécution
|
||||
const [executionState, setExecutionState] = useState<VWBExecutionState>({
|
||||
status: 'idle',
|
||||
@@ -115,6 +174,9 @@ export const useVWBExecution = (
|
||||
shouldStop: false
|
||||
});
|
||||
|
||||
// Ref pour la fonction d'exécution (évite les stale closures)
|
||||
const executeWorkflowStepsRef = useRef<(steps: Step[]) => Promise<void>>(() => Promise.resolve());
|
||||
|
||||
// Initialiser le contexte d'exécution
|
||||
useEffect(() => {
|
||||
const variablesMap = variables.reduce((acc, variable) => {
|
||||
@@ -170,7 +232,7 @@ export const useVWBExecution = (
|
||||
return;
|
||||
}
|
||||
|
||||
// Réinitialiser l'état
|
||||
// Réinitialiser complètement l'état d'exécution
|
||||
executionRef.current = {
|
||||
isRunning: true,
|
||||
isPaused: false,
|
||||
@@ -178,29 +240,30 @@ export const useVWBExecution = (
|
||||
};
|
||||
|
||||
const startTime = new Date();
|
||||
|
||||
setExecutionState(prev => ({
|
||||
...prev,
|
||||
startTimeRef.current = startTime; // Stocker dans la ref pour finalizeExecution
|
||||
|
||||
// Reset complet de l'état avant de commencer
|
||||
setExecutionState({
|
||||
status: 'running',
|
||||
startTime,
|
||||
endTime: null,
|
||||
currentStepIndex: 0,
|
||||
currentStep: workflow.steps[0],
|
||||
totalSteps: workflow.steps.length,
|
||||
completedSteps: 0,
|
||||
failedSteps: 0,
|
||||
startTime,
|
||||
endTime: null,
|
||||
duration: 0,
|
||||
progress: 0,
|
||||
results: [],
|
||||
errors: [],
|
||||
evidence: []
|
||||
}));
|
||||
});
|
||||
|
||||
try {
|
||||
// Passer les steps directement pour éviter le problème de stale closure
|
||||
await executeWorkflowSteps(workflow.steps);
|
||||
// Utiliser la ref pour avoir toujours la dernière version de executeWorkflowSteps
|
||||
await executeWorkflowStepsRef.current(workflow.steps);
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de l\'exécution du workflow:', error);
|
||||
// handleExecutionError défini plus bas, on gère l'erreur ici directement
|
||||
executionRef.current.isRunning = false;
|
||||
setExecutionState(prev => ({
|
||||
...prev,
|
||||
@@ -213,7 +276,7 @@ export const useVWBExecution = (
|
||||
}]
|
||||
}));
|
||||
}
|
||||
}, [workflow.steps]);
|
||||
}, [workflow.steps, workflow.id]);
|
||||
|
||||
/**
|
||||
* Exécuter toutes les étapes du workflow
|
||||
@@ -256,16 +319,19 @@ export const useVWBExecution = (
|
||||
progress: (i / steps.length) * 100
|
||||
}));
|
||||
|
||||
// Callback de début d'étape
|
||||
callbacks.onStepStart?.(step, i);
|
||||
callbacks.onProgressUpdate?.(i / steps.length, step);
|
||||
// Callback de début d'étape (utilise ref pour éviter stale closure)
|
||||
callbacksRef.current.onStepStart?.(step, i);
|
||||
callbacksRef.current.onProgressUpdate?.(i / steps.length, step);
|
||||
|
||||
try {
|
||||
// Vérifier si c'est une étape VWB
|
||||
const isVWBStep = vwbExecutionService.isVWBStep(step);
|
||||
console.log(`🔍 [VWB] Étape ${step.id} isVWBStep:`, isVWBStep);
|
||||
|
||||
if (!isVWBStep && skipNonVWBSteps) {
|
||||
// Utiliser les options depuis la ref
|
||||
const opts = optionsRef.current;
|
||||
|
||||
if (!isVWBStep && opts.skipNonVWBSteps) {
|
||||
console.log(`⏭️ [VWB] Étape ${step.id} ignorée (non-VWB)`);
|
||||
continue;
|
||||
}
|
||||
@@ -276,10 +342,10 @@ export const useVWBExecution = (
|
||||
console.log(`🎯 [VWB] Exécution VWB de l'étape ${step.id}...`);
|
||||
// Exécuter l'étape VWB
|
||||
const executionOptions: VWBExecutionOptions = {
|
||||
timeout,
|
||||
retryAttempts,
|
||||
validateBeforeExecution: autoValidate,
|
||||
generateEvidence
|
||||
timeout: opts.timeout,
|
||||
retryAttempts: opts.retryAttempts,
|
||||
validateBeforeExecution: opts.autoValidate,
|
||||
generateEvidence: opts.generateEvidence
|
||||
};
|
||||
|
||||
result = await vwbExecutionService.executeStep(step, executionOptions);
|
||||
@@ -303,10 +369,10 @@ export const useVWBExecution = (
|
||||
// Ajouter les Evidence
|
||||
if (result.evidence) {
|
||||
evidence.push(...result.evidence);
|
||||
callbacks.onEvidenceGenerated?.(step.id, result.evidence);
|
||||
callbacksRef.current.onEvidenceGenerated?.(step.id, result.evidence);
|
||||
}
|
||||
|
||||
callbacks.onStepComplete?.(step, result);
|
||||
callbacksRef.current.onStepComplete?.(step, result);
|
||||
} else {
|
||||
setExecutionState(prev => ({
|
||||
...prev,
|
||||
@@ -315,11 +381,19 @@ export const useVWBExecution = (
|
||||
|
||||
if (result.error) {
|
||||
errors.push(result.error);
|
||||
callbacks.onStepError?.(step, result.error);
|
||||
callbacksRef.current.onStepError?.(step, result.error);
|
||||
}
|
||||
|
||||
// Arrêter si configuré pour s'arrêter sur erreur
|
||||
if (pauseOnError) {
|
||||
// STOPPER LE WORKFLOW SUR ERREUR (comportement par défaut)
|
||||
if (opts.stopOnError) {
|
||||
console.log('🛑 [VWB] ARRÊT DU WORKFLOW - Erreur détectée et stopOnError=true');
|
||||
playErrorBeep(); // BIP BIP BIP pour alerter l'utilisateur
|
||||
executionRef.current.shouldStop = true;
|
||||
break; // Sortir immédiatement de la boucle
|
||||
}
|
||||
|
||||
// Sinon juste mettre en pause si configuré
|
||||
if (opts.pauseOnError) {
|
||||
executionRef.current.isPaused = true;
|
||||
}
|
||||
}
|
||||
@@ -330,20 +404,27 @@ export const useVWBExecution = (
|
||||
const executionError: ExecutionError = {
|
||||
stepId: step.id,
|
||||
message: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||
// type: 'execution_error',
|
||||
timestamp: new Date(),
|
||||
// context: { stepIndex: i }
|
||||
};
|
||||
|
||||
errors.push(executionError);
|
||||
callbacks.onStepError?.(step, executionError);
|
||||
callbacksRef.current.onStepError?.(step, executionError);
|
||||
|
||||
setExecutionState(prev => ({
|
||||
...prev,
|
||||
failedSteps: prev.failedSteps + 1
|
||||
}));
|
||||
|
||||
if (pauseOnError) {
|
||||
// STOPPER LE WORKFLOW SUR EXCEPTION (comportement par défaut)
|
||||
if (optionsRef.current.stopOnError) {
|
||||
console.log('🛑 [VWB] ARRÊT DU WORKFLOW - Exception détectée et stopOnError=true');
|
||||
playErrorBeep(); // BIP BIP BIP pour alerter l'utilisateur
|
||||
executionRef.current.shouldStop = true;
|
||||
break; // Sortir immédiatement de la boucle
|
||||
}
|
||||
|
||||
// Sinon juste mettre en pause si configuré
|
||||
if (optionsRef.current.pauseOnError) {
|
||||
console.log('⏸️ [VWB] Pause sur erreur activée');
|
||||
executionRef.current.isPaused = true;
|
||||
}
|
||||
@@ -355,8 +436,13 @@ export const useVWBExecution = (
|
||||
console.log('🏁 [VWB] Boucle terminée, finalisation...');
|
||||
// Finaliser l'exécution
|
||||
await finalizeExecution(results, errors, evidence);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [callbacks, autoValidate, generateEvidence, timeout, retryAttempts, pauseOnError, skipNonVWBSteps]);
|
||||
// Pas de dépendances aux callbacks/options car on utilise des refs
|
||||
}, []);
|
||||
|
||||
// Mettre à jour la ref avec la dernière version de executeWorkflowSteps
|
||||
useEffect(() => {
|
||||
executeWorkflowStepsRef.current = executeWorkflowSteps;
|
||||
}, [executeWorkflowSteps]);
|
||||
|
||||
/**
|
||||
* Simuler l'exécution d'une étape non-VWB
|
||||
@@ -374,8 +460,18 @@ export const useVWBExecution = (
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Ref pour stocker le startTime de l'exécution en cours
|
||||
const startTimeRef = useRef<Date | null>(null);
|
||||
const workflowStepsLengthRef = useRef(workflow.steps.length);
|
||||
|
||||
// Mettre à jour la ref quand le workflow change
|
||||
useEffect(() => {
|
||||
workflowStepsLengthRef.current = workflow.steps.length;
|
||||
}, [workflow.steps.length]);
|
||||
|
||||
/**
|
||||
* Finaliser l'exécution
|
||||
* Note: Utilise des refs pour éviter les dépendances instables
|
||||
*/
|
||||
const finalizeExecution = useCallback(async (
|
||||
results: VWBExecutionResult[],
|
||||
@@ -383,8 +479,10 @@ export const useVWBExecution = (
|
||||
evidence: Evidence[]
|
||||
) => {
|
||||
const endTime = new Date();
|
||||
const duration = executionState.startTime ? endTime.getTime() - executionState.startTime.getTime() : 0;
|
||||
const startTime = startTimeRef.current;
|
||||
const duration = startTime ? endTime.getTime() - startTime.getTime() : 0;
|
||||
const successRate = results.length > 0 ? (results.filter(r => r.success).length / results.length) * 100 : 0;
|
||||
const totalSteps = workflowStepsLengthRef.current;
|
||||
|
||||
executionRef.current.isRunning = false;
|
||||
|
||||
@@ -401,10 +499,10 @@ export const useVWBExecution = (
|
||||
|
||||
// Créer le résumé d'exécution
|
||||
const summary: VWBExecutionSummary = {
|
||||
totalSteps: workflow.steps.length,
|
||||
totalSteps,
|
||||
completedSteps: results.filter(r => r.success).length,
|
||||
failedSteps: results.filter(r => !r.success).length,
|
||||
skippedSteps: workflow.steps.length - results.length,
|
||||
skippedSteps: totalSteps - results.length,
|
||||
duration,
|
||||
successRate,
|
||||
results,
|
||||
@@ -412,8 +510,8 @@ export const useVWBExecution = (
|
||||
evidence
|
||||
};
|
||||
|
||||
callbacks.onExecutionComplete?.(errors.length === 0, summary);
|
||||
}, [workflow.steps.length, executionState.startTime, callbacks]);
|
||||
callbacksRef.current.onExecutionComplete?.(errors.length === 0, summary);
|
||||
}, []); // Pas de dépendances - utilise des refs
|
||||
|
||||
/**
|
||||
* Mettre en pause l'exécution
|
||||
|
||||
292
visual_workflow_builder/frontend/src/hooks/useVirtualization.ts
Normal file
292
visual_workflow_builder/frontend/src/hooks/useVirtualization.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* Hook de Virtualisation - Optimisation pour les listes longues
|
||||
* Auteur : Dom, Alice, Kiro - 08 janvier 2026
|
||||
*
|
||||
* Ce hook implémente la virtualisation pour optimiser le rendu
|
||||
* de listes longues en ne rendant que les éléments visibles.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
|
||||
interface VirtualizationOptions {
|
||||
itemHeight: number;
|
||||
containerHeight: number;
|
||||
overscan?: number; // Nombre d'éléments supplémentaires à rendre hors de la vue
|
||||
threshold?: number; // Seuil à partir duquel activer la virtualisation
|
||||
}
|
||||
|
||||
interface VirtualizedItem<T> {
|
||||
index: number;
|
||||
item: T;
|
||||
style: React.CSSProperties;
|
||||
}
|
||||
|
||||
interface VirtualizationResult<T> {
|
||||
virtualItems: VirtualizedItem<T>[];
|
||||
totalHeight: number;
|
||||
scrollToIndex: (index: number) => void;
|
||||
isVirtualized: boolean;
|
||||
containerProps: {
|
||||
style: React.CSSProperties;
|
||||
onScroll: (event: React.UIEvent<HTMLDivElement>) => void;
|
||||
ref: React.RefObject<HTMLDivElement | null>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook de virtualisation pour les listes longues
|
||||
*
|
||||
* @param items - Liste des éléments à virtualiser
|
||||
* @param options - Options de virtualisation
|
||||
* @returns Résultat de la virtualisation avec éléments visibles et props
|
||||
*/
|
||||
export function useVirtualization<T>(
|
||||
items: T[],
|
||||
options: VirtualizationOptions
|
||||
): VirtualizationResult<T> {
|
||||
const {
|
||||
itemHeight,
|
||||
containerHeight,
|
||||
overscan = 5,
|
||||
threshold = 50,
|
||||
} = options;
|
||||
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Déterminer si la virtualisation doit être activée
|
||||
const isVirtualized = items.length > threshold;
|
||||
|
||||
// Calculer les indices des éléments visibles
|
||||
const visibleRange = useMemo(() => {
|
||||
if (!isVirtualized) {
|
||||
return { start: 0, end: items.length - 1 };
|
||||
}
|
||||
|
||||
const start = Math.floor(scrollTop / itemHeight);
|
||||
const visibleCount = Math.ceil(containerHeight / itemHeight);
|
||||
const end = start + visibleCount - 1;
|
||||
|
||||
return {
|
||||
start: Math.max(0, start - overscan),
|
||||
end: Math.min(items.length - 1, end + overscan),
|
||||
};
|
||||
}, [scrollTop, itemHeight, containerHeight, overscan, items.length, isVirtualized]);
|
||||
|
||||
// Créer les éléments virtualisés
|
||||
const virtualItems = useMemo(() => {
|
||||
if (!isVirtualized) {
|
||||
// Si pas de virtualisation, retourner tous les éléments
|
||||
return items.map((item, index) => ({
|
||||
index,
|
||||
item,
|
||||
style: {
|
||||
height: itemHeight,
|
||||
width: '100%',
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
const result: VirtualizedItem<T>[] = [];
|
||||
|
||||
for (let i = visibleRange.start; i <= visibleRange.end; i++) {
|
||||
if (i < items.length) {
|
||||
result.push({
|
||||
index: i,
|
||||
item: items[i],
|
||||
style: {
|
||||
position: 'absolute' as const,
|
||||
top: i * itemHeight,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: itemHeight,
|
||||
width: '100%',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [items, visibleRange, itemHeight, isVirtualized]);
|
||||
|
||||
// Hauteur totale du conteneur
|
||||
const totalHeight = isVirtualized ? items.length * itemHeight : 'auto';
|
||||
|
||||
// Gestionnaire de scroll
|
||||
const handleScroll = useCallback((event: React.UIEvent<HTMLDivElement>) => {
|
||||
const target = event.currentTarget;
|
||||
setScrollTop(target.scrollTop);
|
||||
}, []);
|
||||
|
||||
// Fonction pour scroller vers un index spécifique
|
||||
const scrollToIndex = useCallback((index: number) => {
|
||||
if (containerRef.current && isVirtualized) {
|
||||
const scrollTop = index * itemHeight;
|
||||
containerRef.current.scrollTop = scrollTop;
|
||||
setScrollTop(scrollTop);
|
||||
}
|
||||
}, [itemHeight, isVirtualized]);
|
||||
|
||||
// Props pour le conteneur
|
||||
const containerProps = useMemo(() => ({
|
||||
style: {
|
||||
height: containerHeight,
|
||||
overflow: 'auto' as const,
|
||||
position: 'relative' as const,
|
||||
},
|
||||
onScroll: handleScroll,
|
||||
ref: containerRef,
|
||||
}), [containerHeight, handleScroll]);
|
||||
|
||||
return {
|
||||
virtualItems,
|
||||
totalHeight: typeof totalHeight === 'number' ? totalHeight : containerHeight,
|
||||
scrollToIndex,
|
||||
isVirtualized,
|
||||
containerProps,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook de virtualisation avec recherche et filtrage
|
||||
*
|
||||
* @param items - Liste des éléments originaux
|
||||
* @param searchQuery - Requête de recherche
|
||||
* @param filterFn - Fonction de filtrage
|
||||
* @param searchFn - Fonction de recherche personnalisée
|
||||
* @param options - Options de virtualisation
|
||||
* @returns Résultat de la virtualisation avec éléments filtrés
|
||||
*/
|
||||
export function useVirtualizedSearch<T>(
|
||||
items: T[],
|
||||
searchQuery: string,
|
||||
filterFn: (item: T, query: string) => boolean,
|
||||
options: VirtualizationOptions,
|
||||
searchFn?: (items: T[], query: string) => T[]
|
||||
): VirtualizationResult<T> & {
|
||||
filteredItems: T[];
|
||||
totalCount: number;
|
||||
filteredCount: number;
|
||||
} {
|
||||
// Filtrer les éléments selon la recherche
|
||||
const filteredItems = useMemo(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
return items;
|
||||
}
|
||||
|
||||
if (searchFn) {
|
||||
return searchFn(items, searchQuery);
|
||||
}
|
||||
|
||||
return items.filter(item => filterFn(item, searchQuery));
|
||||
}, [items, searchQuery, filterFn, searchFn]);
|
||||
|
||||
// Utiliser la virtualisation sur les éléments filtrés
|
||||
const virtualizationResult = useVirtualization(filteredItems, options);
|
||||
|
||||
return {
|
||||
...virtualizationResult,
|
||||
filteredItems,
|
||||
totalCount: items.length,
|
||||
filteredCount: filteredItems.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook de virtualisation avec pagination
|
||||
*
|
||||
* @param items - Liste des éléments
|
||||
* @param pageSize - Taille de la page
|
||||
* @param options - Options de virtualisation
|
||||
* @returns Résultat avec pagination et virtualisation
|
||||
*/
|
||||
export function useVirtualizedPagination<T>(
|
||||
items: T[],
|
||||
pageSize: number = 50,
|
||||
options: VirtualizationOptions
|
||||
): VirtualizationResult<T> & {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
setCurrentPage: (page: number) => void;
|
||||
nextPage: () => void;
|
||||
prevPage: () => void;
|
||||
canNextPage: boolean;
|
||||
canPrevPage: boolean;
|
||||
} {
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
|
||||
// Calculer les éléments de la page actuelle
|
||||
const paginatedItems = useMemo(() => {
|
||||
const start = currentPage * pageSize;
|
||||
const end = start + pageSize;
|
||||
return items.slice(start, end);
|
||||
}, [items, currentPage, pageSize]);
|
||||
|
||||
const totalPages = Math.ceil(items.length / pageSize);
|
||||
|
||||
// Utiliser la virtualisation sur les éléments paginés
|
||||
const virtualizationResult = useVirtualization(paginatedItems, options);
|
||||
|
||||
// Fonctions de navigation
|
||||
const nextPage = useCallback(() => {
|
||||
setCurrentPage(prev => Math.min(prev + 1, totalPages - 1));
|
||||
}, [totalPages]);
|
||||
|
||||
const prevPage = useCallback(() => {
|
||||
setCurrentPage(prev => Math.max(prev - 1, 0));
|
||||
}, []);
|
||||
|
||||
const canNextPage = currentPage < totalPages - 1;
|
||||
const canPrevPage = currentPage > 0;
|
||||
|
||||
// Réinitialiser la page si elle dépasse le nombre total
|
||||
useEffect(() => {
|
||||
if (currentPage >= totalPages && totalPages > 0) {
|
||||
setCurrentPage(totalPages - 1);
|
||||
}
|
||||
}, [currentPage, totalPages]);
|
||||
|
||||
return {
|
||||
...virtualizationResult,
|
||||
currentPage,
|
||||
totalPages,
|
||||
setCurrentPage,
|
||||
nextPage,
|
||||
prevPage,
|
||||
canNextPage,
|
||||
canPrevPage,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook de virtualisation avec tri
|
||||
*
|
||||
* @param items - Liste des éléments
|
||||
* @param sortFn - Fonction de tri
|
||||
* @param options - Options de virtualisation
|
||||
* @returns Résultat avec tri et virtualisation
|
||||
*/
|
||||
export function useVirtualizedSort<T>(
|
||||
items: T[],
|
||||
sortFn: (a: T, b: T) => number,
|
||||
options: VirtualizationOptions
|
||||
): VirtualizationResult<T> & {
|
||||
sortedItems: T[];
|
||||
setSortFn: (fn: (a: T, b: T) => number) => void;
|
||||
} {
|
||||
const [currentSortFn, setCurrentSortFn] = useState(() => sortFn);
|
||||
|
||||
// Trier les éléments
|
||||
const sortedItems = useMemo(() => {
|
||||
return [...items].sort(currentSortFn);
|
||||
}, [items, currentSortFn]);
|
||||
|
||||
// Utiliser la virtualisation sur les éléments triés
|
||||
const virtualizationResult = useVirtualization(sortedItems, options);
|
||||
|
||||
return {
|
||||
...virtualizationResult,
|
||||
sortedItems,
|
||||
setSortFn: setCurrentSortFn,
|
||||
};
|
||||
}
|
||||
13
visual_workflow_builder/frontend/src/index.css
Normal file
13
visual_workflow_builder/frontend/src/index.css
Normal file
@@ -0,0 +1,13 @@
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
21
visual_workflow_builder/frontend/src/index.tsx
Normal file
21
visual_workflow_builder/frontend/src/index.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
// Suppression des erreurs ResizeObserver (doit être importé en premier)
|
||||
import './utils/suppressResizeObserverErrors';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
import reportWebVitals from './reportWebVitals';
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById('root') as HTMLElement
|
||||
);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
// If you want to start measuring performance in your app, pass a function
|
||||
// to log results (for example: reportWebVitals(console.log))
|
||||
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
||||
reportWebVitals();
|
||||
1
visual_workflow_builder/frontend/src/logo.svg
Normal file
1
visual_workflow_builder/frontend/src/logo.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
1
visual_workflow_builder/frontend/src/react-app-env.d.ts
vendored
Normal file
1
visual_workflow_builder/frontend/src/react-app-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="react-scripts" />
|
||||
15
visual_workflow_builder/frontend/src/reportWebVitals.ts
Normal file
15
visual_workflow_builder/frontend/src/reportWebVitals.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { ReportHandler } from 'web-vitals';
|
||||
|
||||
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
|
||||
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||
getCLS(onPerfEntry);
|
||||
getFID(onPerfEntry);
|
||||
getFCP(onPerfEntry);
|
||||
getLCP(onPerfEntry);
|
||||
getTTFB(onPerfEntry);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default reportWebVitals;
|
||||
@@ -0,0 +1,533 @@
|
||||
/**
|
||||
* Service StepTypeResolver - Résolution unifiée des types d'étapes
|
||||
* Auteur : Dom, Alice, Kiro - 12 janvier 2026
|
||||
*
|
||||
* Ce service fournit une logique de mapping unifiée pour résoudre les configurations
|
||||
* de paramètres des étapes, avec gestion robuste des types standard et VWB.
|
||||
*/
|
||||
|
||||
import { Step, StepType, Variable } from '../types';
|
||||
import { VWBCatalogAction } from '../types/catalog';
|
||||
|
||||
/**
|
||||
* Configuration d'un paramètre d'étape
|
||||
*/
|
||||
export interface ParameterConfig {
|
||||
name: string;
|
||||
label: string;
|
||||
type: 'text' | 'number' | 'boolean' | 'select' | 'visual';
|
||||
required?: boolean;
|
||||
description?: string;
|
||||
supportVariables?: boolean;
|
||||
options?: { value: string; label: string }[];
|
||||
defaultValue?: any;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
placeholder?: string;
|
||||
multiline?: boolean;
|
||||
group?: string;
|
||||
order?: number;
|
||||
conditional?: ConditionalRule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Règle conditionnelle pour l'affichage de paramètres
|
||||
*/
|
||||
export interface ConditionalRule {
|
||||
dependsOn: string;
|
||||
condition: 'equals' | 'not_equals' | 'greater_than' | 'less_than';
|
||||
value: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Résultat de la résolution d'un type d'étape
|
||||
*/
|
||||
export interface StepTypeResolutionResult {
|
||||
stepType: string;
|
||||
isVWBAction: boolean;
|
||||
isStandardType: boolean;
|
||||
parameterConfig: ParameterConfig[];
|
||||
vwbAction?: VWBCatalogAction;
|
||||
detectionMethods: Record<string, boolean>;
|
||||
resolutionSource: 'stepParametersConfig' | 'vwbCatalog' | 'fallback';
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options de résolution
|
||||
*/
|
||||
export interface ResolutionOptions {
|
||||
enableCache?: boolean;
|
||||
enableLogging?: boolean;
|
||||
fallbackToEmpty?: boolean;
|
||||
vwbDetectionMethods?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface du résolveur de types d'étapes
|
||||
*/
|
||||
export interface IStepTypeResolver {
|
||||
/**
|
||||
* Résout la configuration des paramètres pour une étape
|
||||
*/
|
||||
resolveParameterConfig(step: Step, options?: ResolutionOptions): Promise<StepTypeResolutionResult>;
|
||||
|
||||
/**
|
||||
* Vérifie si une étape est une action VWB
|
||||
*/
|
||||
isVWBAction(step: Step): boolean;
|
||||
|
||||
/**
|
||||
* Obtient les détails d'une action VWB
|
||||
*/
|
||||
getVWBActionDetails(step: Step): Promise<VWBCatalogAction | null>;
|
||||
|
||||
/**
|
||||
* Invalide le cache de résolution
|
||||
*/
|
||||
invalidateCache(): void;
|
||||
|
||||
/**
|
||||
* Obtient les statistiques de résolution
|
||||
*/
|
||||
getResolutionStats(): ResolutionStats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Statistiques de résolution
|
||||
*/
|
||||
export interface ResolutionStats {
|
||||
totalResolutions: number;
|
||||
cacheHits: number;
|
||||
cacheMisses: number;
|
||||
vwbDetections: number;
|
||||
standardDetections: number;
|
||||
fallbackUsed: number;
|
||||
averageResolutionTime: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implémentation du résolveur de types d'étapes
|
||||
*/
|
||||
export class StepTypeResolver implements IStepTypeResolver {
|
||||
private cache = new Map<string, StepTypeResolutionResult>();
|
||||
private stats: ResolutionStats = {
|
||||
totalResolutions: 0,
|
||||
cacheHits: 0,
|
||||
cacheMisses: 0,
|
||||
vwbDetections: 0,
|
||||
standardDetections: 0,
|
||||
fallbackUsed: 0,
|
||||
averageResolutionTime: 0
|
||||
};
|
||||
|
||||
private resolutionTimes: number[] = [];
|
||||
|
||||
/**
|
||||
* Configuration des paramètres par type d'étape standard
|
||||
*/
|
||||
private readonly stepParametersConfig: Record<StepType, ParameterConfig[]> = {
|
||||
click: [
|
||||
{
|
||||
name: 'target',
|
||||
label: 'Élément cible',
|
||||
type: 'visual',
|
||||
required: true,
|
||||
description: 'Sélectionner l\'élément à cliquer',
|
||||
},
|
||||
{
|
||||
name: 'clickType',
|
||||
label: 'Type de clic',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'left', label: 'Clic gauche' },
|
||||
{ value: 'right', label: 'Clic droit' },
|
||||
{ value: 'double', label: 'Double-clic' },
|
||||
],
|
||||
defaultValue: 'left',
|
||||
},
|
||||
],
|
||||
type: [
|
||||
{
|
||||
name: 'target',
|
||||
label: 'Champ de saisie',
|
||||
type: 'visual',
|
||||
required: true,
|
||||
description: 'Sélectionner le champ où saisir le texte',
|
||||
},
|
||||
{
|
||||
name: 'text',
|
||||
label: 'Texte à saisir',
|
||||
type: 'text',
|
||||
required: true,
|
||||
supportVariables: true,
|
||||
},
|
||||
{
|
||||
name: 'clearFirst',
|
||||
label: 'Vider le champ d\'abord',
|
||||
type: 'boolean',
|
||||
defaultValue: true,
|
||||
},
|
||||
],
|
||||
wait: [
|
||||
{
|
||||
name: 'duration',
|
||||
label: 'Durée (secondes)',
|
||||
type: 'number',
|
||||
required: true,
|
||||
min: 0.1,
|
||||
max: 60,
|
||||
defaultValue: 1,
|
||||
},
|
||||
],
|
||||
condition: [
|
||||
{
|
||||
name: 'condition',
|
||||
label: 'Condition',
|
||||
type: 'text',
|
||||
required: true,
|
||||
supportVariables: true,
|
||||
description: 'Expression conditionnelle à évaluer',
|
||||
},
|
||||
],
|
||||
extract: [
|
||||
{
|
||||
name: 'target',
|
||||
label: 'Élément source',
|
||||
type: 'visual',
|
||||
required: true,
|
||||
description: 'Sélectionner l\'élément dont extraire les données',
|
||||
},
|
||||
{
|
||||
name: 'attribute',
|
||||
label: 'Attribut à extraire',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'text', label: 'Texte' },
|
||||
{ value: 'value', label: 'Valeur' },
|
||||
{ value: 'href', label: 'Lien (href)' },
|
||||
{ value: 'src', label: 'Source (src)' },
|
||||
],
|
||||
defaultValue: 'text',
|
||||
},
|
||||
],
|
||||
scroll: [
|
||||
{
|
||||
name: 'direction',
|
||||
label: 'Direction',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'up', label: 'Vers le haut' },
|
||||
{ value: 'down', label: 'Vers le bas' },
|
||||
{ value: 'left', label: 'Vers la gauche' },
|
||||
{ value: 'right', label: 'Vers la droite' },
|
||||
],
|
||||
defaultValue: 'down',
|
||||
},
|
||||
{
|
||||
name: 'amount',
|
||||
label: 'Quantité (pixels)',
|
||||
type: 'number',
|
||||
defaultValue: 300,
|
||||
min: 1,
|
||||
},
|
||||
],
|
||||
navigate: [
|
||||
{
|
||||
name: 'url',
|
||||
label: 'URL de destination',
|
||||
type: 'text',
|
||||
required: true,
|
||||
supportVariables: true,
|
||||
},
|
||||
],
|
||||
screenshot: [
|
||||
{
|
||||
name: 'filename',
|
||||
label: 'Nom du fichier',
|
||||
type: 'text',
|
||||
supportVariables: true,
|
||||
description: 'Nom du fichier de capture (optionnel)',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Actions VWB connues
|
||||
*/
|
||||
private readonly knownVWBActions = [
|
||||
'click_anchor', 'type_text', 'type_secret', 'wait_for_anchor',
|
||||
'extract_text', 'screenshot_evidence', 'scroll_to_anchor',
|
||||
'focus_anchor', 'hotkey', 'navigate_to_url', 'browser_back',
|
||||
'verify_element_exists', 'verify_text_content'
|
||||
];
|
||||
|
||||
/**
|
||||
* Résout la configuration des paramètres pour une étape
|
||||
*/
|
||||
async resolveParameterConfig(
|
||||
step: Step,
|
||||
options: ResolutionOptions = {}
|
||||
): Promise<StepTypeResolutionResult> {
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
// Options par défaut
|
||||
const resolveOptions = {
|
||||
enableCache: true,
|
||||
enableLogging: true,
|
||||
fallbackToEmpty: true,
|
||||
vwbDetectionMethods: ['all'],
|
||||
...options
|
||||
};
|
||||
|
||||
// Vérifier le cache
|
||||
const cacheKey = this.generateCacheKey(step, resolveOptions);
|
||||
if (resolveOptions.enableCache && this.cache.has(cacheKey)) {
|
||||
this.stats.cacheHits++;
|
||||
const cached = this.cache.get(cacheKey)!;
|
||||
|
||||
if (resolveOptions.enableLogging) {
|
||||
console.log('🎯 [StepTypeResolver] Cache hit:', {
|
||||
stepId: step.id,
|
||||
stepType: step.type,
|
||||
cacheKey
|
||||
});
|
||||
}
|
||||
|
||||
return cached;
|
||||
}
|
||||
|
||||
this.stats.cacheMisses++;
|
||||
|
||||
// Analyser le type d'étape
|
||||
const stepTypeString = step.type as string;
|
||||
|
||||
// Détecter si c'est une action VWB
|
||||
const vwbDetectionResult = this.detectVWBAction(step, resolveOptions);
|
||||
|
||||
let result: StepTypeResolutionResult;
|
||||
|
||||
if (vwbDetectionResult.isVWBAction) {
|
||||
// Résolution pour action VWB
|
||||
result = await this.resolveVWBAction(step, vwbDetectionResult, resolveOptions);
|
||||
this.stats.vwbDetections++;
|
||||
} else {
|
||||
// Résolution pour type standard
|
||||
result = this.resolveStandardType(step, resolveOptions);
|
||||
this.stats.standardDetections++;
|
||||
}
|
||||
|
||||
// Mettre en cache le résultat
|
||||
if (resolveOptions.enableCache) {
|
||||
this.cache.set(cacheKey, result);
|
||||
}
|
||||
|
||||
// Logging détaillé
|
||||
if (resolveOptions.enableLogging) {
|
||||
console.log('🔍 [StepTypeResolver] Résolution complète:', {
|
||||
stepId: step.id,
|
||||
stepType: stepTypeString,
|
||||
isVWBAction: result.isVWBAction,
|
||||
parameterCount: result.parameterConfig.length,
|
||||
resolutionSource: result.resolutionSource,
|
||||
detectionMethods: result.detectionMethods
|
||||
});
|
||||
}
|
||||
|
||||
// Mettre à jour les statistiques
|
||||
const resolutionTime = performance.now() - startTime;
|
||||
this.resolutionTimes.push(resolutionTime);
|
||||
this.stats.totalResolutions++;
|
||||
this.stats.averageResolutionTime =
|
||||
this.resolutionTimes.reduce((a, b) => a + b, 0) / this.resolutionTimes.length;
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ [StepTypeResolver] Erreur de résolution:', error);
|
||||
|
||||
// Fallback en cas d'erreur
|
||||
const fallbackResult: StepTypeResolutionResult = {
|
||||
stepType: step.type as string,
|
||||
isVWBAction: false,
|
||||
isStandardType: false,
|
||||
parameterConfig: [],
|
||||
detectionMethods: { error: true },
|
||||
resolutionSource: 'fallback',
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
this.stats.fallbackUsed++;
|
||||
return fallbackResult;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si une étape est une action VWB
|
||||
*/
|
||||
isVWBAction(step: Step): boolean {
|
||||
const detection = this.detectVWBAction(step, { enableLogging: false });
|
||||
return detection.isVWBAction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Détecte si une étape est une action VWB avec méthodes multiples
|
||||
*/
|
||||
private detectVWBAction(step: Step, options: ResolutionOptions): {
|
||||
isVWBAction: boolean;
|
||||
detectionMethods: Record<string, boolean>;
|
||||
confidence: number;
|
||||
} {
|
||||
const stepTypeString = step.type as string;
|
||||
|
||||
// Méthodes de détection multiples pour robustesse
|
||||
const detectionMethods = {
|
||||
hasVWBFlag: Boolean(step.data?.isVWBCatalogAction),
|
||||
hasVWBActionId: Boolean(step.data?.vwbActionId),
|
||||
typeStartsWithVWB: stepTypeString.startsWith('vwb_'),
|
||||
typeContainsAnchor: stepTypeString.includes('_anchor'),
|
||||
typeContainsText: stepTypeString.includes('_text'),
|
||||
typeContainsSecret: stepTypeString.includes('_secret'),
|
||||
isKnownVWBAction: this.knownVWBActions.includes(stepTypeString),
|
||||
hasVWBPattern: /^(click|type|wait|extract|scroll|focus|hotkey|navigate|browser|verify)_/.test(stepTypeString)
|
||||
};
|
||||
|
||||
// Calculer la confiance basée sur le nombre de méthodes positives
|
||||
const positiveDetections = Object.values(detectionMethods).filter(Boolean).length;
|
||||
const confidence = positiveDetections / Object.keys(detectionMethods).length;
|
||||
|
||||
// Une action est considérée VWB si au moins une méthode la détecte
|
||||
const isVWBAction = positiveDetections > 0;
|
||||
|
||||
if (options.enableLogging) {
|
||||
console.log('🎯 [StepTypeResolver] Détection VWB:', {
|
||||
stepType: stepTypeString,
|
||||
detectionMethods,
|
||||
positiveDetections,
|
||||
confidence: `${(confidence * 100).toFixed(1)}%`,
|
||||
isVWBAction
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
isVWBAction,
|
||||
detectionMethods,
|
||||
confidence
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Résout une action VWB
|
||||
*/
|
||||
private async resolveVWBAction(
|
||||
step: Step,
|
||||
vwbDetection: any,
|
||||
options: ResolutionOptions
|
||||
): Promise<StepTypeResolutionResult> {
|
||||
try {
|
||||
// Charger les détails de l'action VWB si disponible
|
||||
const vwbAction = await this.getVWBActionDetails(step);
|
||||
|
||||
return {
|
||||
stepType: step.type as string,
|
||||
isVWBAction: true,
|
||||
isStandardType: false,
|
||||
parameterConfig: [], // Les actions VWB utilisent VWBActionProperties
|
||||
vwbAction: vwbAction || undefined,
|
||||
detectionMethods: vwbDetection.detectionMethods,
|
||||
resolutionSource: 'vwbCatalog',
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ [StepTypeResolver] Erreur résolution VWB:', error);
|
||||
|
||||
// Fallback pour action VWB
|
||||
return {
|
||||
stepType: step.type as string,
|
||||
isVWBAction: true,
|
||||
isStandardType: false,
|
||||
parameterConfig: [],
|
||||
detectionMethods: vwbDetection.detectionMethods,
|
||||
resolutionSource: 'fallback',
|
||||
timestamp: Date.now()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Résout un type d'étape standard
|
||||
*/
|
||||
private resolveStandardType(step: Step, options: ResolutionOptions): StepTypeResolutionResult {
|
||||
const stepTypeString = step.type as string;
|
||||
const config = this.stepParametersConfig[stepTypeString as StepType] || [];
|
||||
|
||||
return {
|
||||
stepType: stepTypeString,
|
||||
isVWBAction: false,
|
||||
isStandardType: config.length > 0,
|
||||
parameterConfig: config,
|
||||
detectionMethods: { standardType: config.length > 0 },
|
||||
resolutionSource: 'stepParametersConfig',
|
||||
timestamp: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtient les détails d'une action VWB
|
||||
*/
|
||||
async getVWBActionDetails(step: Step): Promise<VWBCatalogAction | null> {
|
||||
try {
|
||||
// Simuler le chargement depuis le catalogue VWB
|
||||
// Dans une implémentation réelle, ceci ferait appel au service de catalogue
|
||||
const vwbActionId = step.data?.vwbActionId || step.type;
|
||||
|
||||
// Pour l'instant, retourner null - sera implémenté avec le service de catalogue
|
||||
return null;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ [StepTypeResolver] Erreur chargement action VWB:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère une clé de cache pour une résolution
|
||||
*/
|
||||
private generateCacheKey(step: Step, options: ResolutionOptions): string {
|
||||
const stepData = {
|
||||
type: step.type,
|
||||
isVWBCatalogAction: step.data?.isVWBCatalogAction,
|
||||
vwbActionId: step.data?.vwbActionId
|
||||
};
|
||||
|
||||
return `${JSON.stringify(stepData)}_${JSON.stringify(options)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalide le cache de résolution
|
||||
*/
|
||||
invalidateCache(): void {
|
||||
this.cache.clear();
|
||||
console.log('🗑️ [StepTypeResolver] Cache invalidé');
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtient les statistiques de résolution
|
||||
*/
|
||||
getResolutionStats(): ResolutionStats {
|
||||
return { ...this.stats };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Instance singleton du résolveur
|
||||
*/
|
||||
export const stepTypeResolver = new StepTypeResolver();
|
||||
|
||||
/**
|
||||
* Export par défaut
|
||||
*/
|
||||
export default stepTypeResolver;
|
||||
@@ -0,0 +1,530 @@
|
||||
/**
|
||||
* Service de Capture Visuelle - Configuration des paramètres d'étapes
|
||||
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
|
||||
*
|
||||
* Service de capture visuelle pour l'interface frontend.
|
||||
* Gère la communication avec l'API backend pour la capture d'écran,
|
||||
* la détection d'éléments et la génération d'embeddings visuels.
|
||||
*/
|
||||
|
||||
import { BoundingBox } from '../types';
|
||||
|
||||
interface VisualMetadata {
|
||||
element_type: string;
|
||||
relative_position?: string;
|
||||
text_content?: string;
|
||||
visual_description?: string;
|
||||
size_description?: string;
|
||||
contextual_elements_count?: number;
|
||||
accessibility_info?: Record<string, any>;
|
||||
}
|
||||
|
||||
interface VisualTarget {
|
||||
screenshot: string;
|
||||
bounding_box: BoundingBox;
|
||||
metadata: VisualMetadata;
|
||||
confidence?: number;
|
||||
signature?: string;
|
||||
}
|
||||
|
||||
export interface CaptureOptions {
|
||||
includeContext?: boolean;
|
||||
highQuality?: boolean;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export interface DetectedElement {
|
||||
id: string;
|
||||
bounds: BoundingBox;
|
||||
type: string;
|
||||
text?: string;
|
||||
confidence: number;
|
||||
metadata?: Partial<VisualMetadata>;
|
||||
}
|
||||
|
||||
export interface CaptureResult {
|
||||
screenshot: string; // Base64 encoded
|
||||
elements: DetectedElement[];
|
||||
timestamp: string;
|
||||
screenSize: { width: number; height: number };
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
isValid: boolean;
|
||||
confidence: number;
|
||||
issues: string[];
|
||||
suggestions: string[];
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
class VisualCaptureService {
|
||||
private baseUrl: string;
|
||||
private timeout: number;
|
||||
private cache: Map<string, any>;
|
||||
private cacheTimeout: number;
|
||||
|
||||
constructor(baseUrl: string = 'http://localhost:8000') {
|
||||
this.baseUrl = baseUrl;
|
||||
this.timeout = 30000; // 30 secondes
|
||||
this.cache = new Map();
|
||||
this.cacheTimeout = 60000; // 1 minute
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture l'écran et détecte les éléments UI
|
||||
*/
|
||||
async captureScreen(options: CaptureOptions = {}): Promise<CaptureResult> {
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
console.log('🔍 Début de capture d\'écran...');
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/api/visual/capture`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
include_context: options.includeContext ?? true,
|
||||
high_quality: options.highQuality ?? true,
|
||||
timeout: options.timeout ?? this.timeout,
|
||||
}),
|
||||
signal: AbortSignal.timeout(options.timeout ?? this.timeout),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Erreur de capture: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result: CaptureResult = await response.json();
|
||||
|
||||
const duration = performance.now() - startTime;
|
||||
console.log(`✅ Capture terminée en ${duration.toFixed(0)}ms - ${result.elements.length} éléments détectés`);
|
||||
|
||||
// Mettre en cache le résultat
|
||||
this.setCacheItem('last_capture', result);
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
const duration = performance.now() - startTime;
|
||||
console.error(`❌ Erreur de capture après ${duration.toFixed(0)}ms:`, error);
|
||||
|
||||
// Fallback vers une capture simulée en cas d'erreur
|
||||
return this.createMockCapture();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée une cible visuelle à partir d'un élément détecté
|
||||
*/
|
||||
async createVisualTarget(element: DetectedElement, screenshot: string): Promise<VisualTarget> {
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
console.log(`🎯 Création de cible visuelle pour élément ${element.type}...`);
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/api/visual/create-target`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
element: element,
|
||||
screenshot: screenshot,
|
||||
}),
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Erreur de création de cible: ${response.status}`);
|
||||
}
|
||||
|
||||
const target: VisualTarget = await response.json();
|
||||
|
||||
const duration = performance.now() - startTime;
|
||||
console.log(`✅ Cible visuelle créée en ${duration.toFixed(0)}ms (confiance: ${Math.round((target.confidence || 0) * 100)}%)`);
|
||||
|
||||
return target;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur lors de la création de cible visuelle:', error);
|
||||
|
||||
// Fallback vers une cible simulée
|
||||
return this.createMockTarget(element, screenshot);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide une cible visuelle existante
|
||||
*/
|
||||
async validateTarget(target: VisualTarget): Promise<ValidationResult> {
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
console.log(`🔍 Validation de la cible ${target.signature}...`);
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/api/visual/validate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
target: target,
|
||||
}),
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Erreur de validation: ${response.status}`);
|
||||
}
|
||||
|
||||
const result: ValidationResult = await response.json();
|
||||
|
||||
const duration = performance.now() - startTime;
|
||||
console.log(`✅ Validation terminée en ${duration.toFixed(0)}ms (valide: ${result.isValid})`);
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur lors de la validation:', error);
|
||||
|
||||
// Fallback vers une validation simulée
|
||||
return {
|
||||
isValid: false,
|
||||
confidence: 0,
|
||||
issues: ['Erreur de connexion au service de validation'],
|
||||
suggestions: ['Vérifier la connexion réseau'],
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour la capture d'écran d'une cible
|
||||
*/
|
||||
async updateTargetScreenshot(target: VisualTarget): Promise<VisualTarget> {
|
||||
try {
|
||||
console.log(`📸 Mise à jour de la capture pour ${target.signature}...`);
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/api/visual/update-screenshot`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
target: target,
|
||||
}),
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Erreur de mise à jour: ${response.status}`);
|
||||
}
|
||||
|
||||
const updatedTarget: VisualTarget = await response.json();
|
||||
console.log('✅ Capture mise à jour avec succès');
|
||||
|
||||
return updatedTarget;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur lors de la mise à jour de capture:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recherche des éléments similaires
|
||||
*/
|
||||
async findSimilarElements(target: VisualTarget): Promise<DetectedElement[]> {
|
||||
try {
|
||||
console.log(`🔍 Recherche d'éléments similaires à ${target.signature}...`);
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/api/visual/find-similar`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
target: target,
|
||||
}),
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Erreur de recherche: ${response.status}`);
|
||||
}
|
||||
|
||||
const elements: DetectedElement[] = await response.json();
|
||||
console.log(`✅ ${elements.length} éléments similaires trouvés`);
|
||||
|
||||
return elements;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur lors de la recherche d\'éléments similaires:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée une capture simulée pour les tests
|
||||
*/
|
||||
private createMockCapture(): CaptureResult {
|
||||
console.log('🎭 Création d\'une capture simulée...');
|
||||
|
||||
const mockElements: DetectedElement[] = [
|
||||
{
|
||||
id: 'mock_button_1',
|
||||
bounds: { x: 100, y: 100, width: 120, height: 40 },
|
||||
type: 'button',
|
||||
text: 'Connexion',
|
||||
confidence: 0.95,
|
||||
metadata: {
|
||||
element_type: 'Bouton',
|
||||
visual_description: 'Bouton avec le texte "Connexion"',
|
||||
relative_position: 'en haut à gauche de l\'écran',
|
||||
text_content: 'Connexion',
|
||||
size_description: 'moyenne',
|
||||
contextual_elements_count: 2,
|
||||
accessibility_info: {
|
||||
has_text: true,
|
||||
tag_name: 'button',
|
||||
attributes_count: 3,
|
||||
is_interactive: true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'mock_input_1',
|
||||
bounds: { x: 300, y: 150, width: 200, height: 30 },
|
||||
type: 'input',
|
||||
text: 'Email',
|
||||
confidence: 0.88,
|
||||
metadata: {
|
||||
element_type: 'Champ de saisie',
|
||||
visual_description: 'Champ de saisie pour l\'email',
|
||||
relative_position: 'au centre de l\'écran',
|
||||
text_content: 'Email',
|
||||
size_description: 'moyenne',
|
||||
contextual_elements_count: 1,
|
||||
accessibility_info: {
|
||||
has_text: true,
|
||||
tag_name: 'input',
|
||||
attributes_count: 5,
|
||||
is_interactive: true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'mock_link_1',
|
||||
bounds: { x: 400, y: 300, width: 150, height: 25 },
|
||||
type: 'link',
|
||||
text: 'Mot de passe oublié ?',
|
||||
confidence: 0.82,
|
||||
metadata: {
|
||||
element_type: 'Lien',
|
||||
visual_description: 'Lien "Mot de passe oublié ?"',
|
||||
relative_position: 'en bas au centre de l\'écran',
|
||||
text_content: 'Mot de passe oublié ?',
|
||||
size_description: 'petite',
|
||||
contextual_elements_count: 0,
|
||||
accessibility_info: {
|
||||
has_text: true,
|
||||
tag_name: 'a',
|
||||
attributes_count: 2,
|
||||
is_interactive: true
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// Créer une image simulée
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 800;
|
||||
canvas.height = 600;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
|
||||
// Fond blanc
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, 800, 600);
|
||||
|
||||
// Dessiner les éléments simulés
|
||||
mockElements.forEach(element => {
|
||||
const color = this.getElementColor(element.type);
|
||||
|
||||
// Élément
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(element.bounds.x, element.bounds.y, element.bounds.width, element.bounds.height);
|
||||
|
||||
// Bordure
|
||||
ctx.strokeStyle = '#333333';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(element.bounds.x, element.bounds.y, element.bounds.width, element.bounds.height);
|
||||
|
||||
// Texte
|
||||
if (element.text) {
|
||||
ctx.fillStyle = color === '#f5f5f5' ? '#333333' : '#ffffff';
|
||||
ctx.font = '14px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(
|
||||
element.text,
|
||||
element.bounds.x + element.bounds.width / 2,
|
||||
element.bounds.y + element.bounds.height / 2 + 5
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const screenshot = canvas.toDataURL().split(',')[1]; // Enlever le préfixe data:image/png;base64,
|
||||
|
||||
return {
|
||||
screenshot,
|
||||
elements: mockElements,
|
||||
timestamp: new Date().toISOString(),
|
||||
screenSize: { width: 800, height: 600 }
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée une cible visuelle simulée
|
||||
*/
|
||||
private createMockTarget(element: DetectedElement, screenshot: string): VisualTarget {
|
||||
console.log('🎭 Création d\'une cible simulée...');
|
||||
|
||||
// Créer une image de l'élément avec contour
|
||||
const canvas = document.createElement('canvas');
|
||||
const margin = 10;
|
||||
canvas.width = element.bounds.width + margin * 2;
|
||||
canvas.height = element.bounds.height + margin * 2;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
|
||||
// Fond blanc
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Élément
|
||||
const color = this.getElementColor(element.type);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(margin, margin, element.bounds.width, element.bounds.height);
|
||||
|
||||
// Contour vert pour la sélection
|
||||
ctx.strokeStyle = '#4CAF50';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.strokeRect(margin - 1, margin - 1, element.bounds.width + 2, element.bounds.height + 2);
|
||||
|
||||
// Texte
|
||||
if (element.text) {
|
||||
ctx.fillStyle = color === '#f5f5f5' ? '#333333' : '#ffffff';
|
||||
ctx.font = '14px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(
|
||||
element.text,
|
||||
margin + element.bounds.width / 2,
|
||||
margin + element.bounds.height / 2 + 5
|
||||
);
|
||||
}
|
||||
|
||||
const elementScreenshot = canvas.toDataURL().split(',')[1];
|
||||
|
||||
return {
|
||||
screenshot: elementScreenshot,
|
||||
bounding_box: element.bounds,
|
||||
confidence: element.confidence,
|
||||
signature: `visual_${element.id}_${Date.now()}`,
|
||||
metadata: element.metadata as VisualMetadata || {
|
||||
element_type: this.getElementTypeLabel(element.type),
|
||||
visual_description: `${this.getElementTypeLabel(element.type)} ${element.text ? `avec le texte "${element.text}"` : ''}`,
|
||||
relative_position: 'au centre de l\'écran',
|
||||
text_content: element.text,
|
||||
size_description: 'moyenne',
|
||||
contextual_elements_count: 0,
|
||||
accessibility_info: {
|
||||
has_text: !!element.text,
|
||||
tag_name: element.type,
|
||||
attributes_count: 0,
|
||||
is_interactive: ['button', 'input', 'link'].includes(element.type)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtient la couleur d'un type d'élément
|
||||
*/
|
||||
private getElementColor(type: string): string {
|
||||
const colors: Record<string, string> = {
|
||||
button: '#1976d2',
|
||||
input: '#f5f5f5',
|
||||
link: '#f59e0b',
|
||||
text: '#333333',
|
||||
image: '#9c27b0',
|
||||
div: '#e0e0e0',
|
||||
span: '#bdbdbd'
|
||||
};
|
||||
return colors[type] || '#666666';
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtient le label d'un type d'élément
|
||||
*/
|
||||
private getElementTypeLabel(type: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
button: 'Bouton',
|
||||
input: 'Champ de saisie',
|
||||
link: 'Lien',
|
||||
text: 'Texte',
|
||||
image: 'Image',
|
||||
div: 'Zone de contenu',
|
||||
span: 'Texte'
|
||||
};
|
||||
return labels[type] || 'Élément';
|
||||
}
|
||||
|
||||
/**
|
||||
* Gestion du cache
|
||||
*/
|
||||
private setCacheItem(key: string, value: any): void {
|
||||
this.cache.set(key, {
|
||||
value,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
private getCacheItem(key: string): any | null {
|
||||
const item = this.cache.get(key);
|
||||
if (!item) return null;
|
||||
|
||||
if (Date.now() - item.timestamp > this.cacheTimeout) {
|
||||
this.cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return item.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nettoie le cache expiré
|
||||
*/
|
||||
public clearExpiredCache(): void {
|
||||
const now = Date.now();
|
||||
const entries = Array.from(this.cache.entries());
|
||||
for (const [key, item] of entries) {
|
||||
if (now - item.timestamp > this.cacheTimeout) {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtient les statistiques du service
|
||||
*/
|
||||
public getStats(): { cacheSize: number; cacheHits: number } {
|
||||
return {
|
||||
cacheSize: this.cache.size,
|
||||
cacheHits: 0 // À implémenter si nécessaire
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Instance singleton du service
|
||||
export const visualCaptureService = new VisualCaptureService();
|
||||
export default VisualCaptureService;
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* Service de gestion des images d'ancres visuelles côté serveur.
|
||||
*
|
||||
* Auteur : Dom, Alice, Kiro - 21 janvier 2026
|
||||
*
|
||||
* Ce service gère l'upload et la récupération des images d'ancres
|
||||
* via l'API backend, évitant le stockage base64 dans les workflows.
|
||||
*/
|
||||
|
||||
import { BoundingBox } from '../types';
|
||||
|
||||
const API_BASE = 'http://localhost:5001';
|
||||
|
||||
export interface AnchorImageUploadResult {
|
||||
success: boolean;
|
||||
anchor_id: string;
|
||||
thumbnail_url: string;
|
||||
original_url: string;
|
||||
metadata: {
|
||||
anchor_id: string;
|
||||
bounding_box: BoundingBox;
|
||||
original_size: { width: number; height: number };
|
||||
thumbnail_size: { width: number; height: number };
|
||||
created_at: string;
|
||||
original_file_size: number;
|
||||
thumbnail_file_size: number;
|
||||
extra?: Record<string, any>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AnchorMetadata {
|
||||
anchor_id: string;
|
||||
bounding_box: BoundingBox;
|
||||
original_size: { width: number; height: number };
|
||||
thumbnail_size: { width: number; height: number };
|
||||
created_at: string;
|
||||
original_file_size: number;
|
||||
thumbnail_file_size: number;
|
||||
}
|
||||
|
||||
export interface StorageStats {
|
||||
anchor_count: number;
|
||||
total_size_bytes: number;
|
||||
total_size_mb: number;
|
||||
data_directory: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload une image d'ancre vers le serveur.
|
||||
*
|
||||
* @param imageBase64 - Screenshot complet en base64 (avec ou sans préfixe data:)
|
||||
* @param boundingBox - Zone de sélection sur l'image
|
||||
* @param anchorId - ID optionnel (généré automatiquement si absent)
|
||||
* @param metadata - Métadonnées additionnelles optionnelles
|
||||
* @returns Résultat avec anchor_id et URLs
|
||||
*/
|
||||
export async function uploadAnchorImage(
|
||||
imageBase64: string,
|
||||
boundingBox: BoundingBox,
|
||||
anchorId?: string,
|
||||
metadata?: Record<string, any>
|
||||
): Promise<AnchorImageUploadResult> {
|
||||
console.log('📤 [anchorImageService] Upload image d\'ancre...', {
|
||||
hasImage: !!imageBase64,
|
||||
imageLength: imageBase64?.length || 0,
|
||||
boundingBox,
|
||||
anchorId
|
||||
});
|
||||
|
||||
const response = await fetch(`${API_BASE}/api/anchor-images`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
image_base64: imageBase64,
|
||||
bounding_box: boundingBox,
|
||||
anchor_id: anchorId,
|
||||
metadata,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || `Erreur HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Erreur lors de l\'upload');
|
||||
}
|
||||
|
||||
console.log('✅ [anchorImageService] Upload réussi:', {
|
||||
anchor_id: result.anchor_id,
|
||||
thumbnail_url: result.thumbnail_url,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir l'URL complète de la miniature d'une ancre.
|
||||
*
|
||||
* @param anchorId - ID de l'ancre
|
||||
* @returns URL complète de la miniature
|
||||
*/
|
||||
export function getThumbnailUrl(anchorId: string): string {
|
||||
return `${API_BASE}/api/anchor-images/${anchorId}/thumbnail`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir l'URL complète de l'image originale d'une ancre.
|
||||
*
|
||||
* @param anchorId - ID de l'ancre
|
||||
* @returns URL complète de l'image originale
|
||||
*/
|
||||
export function getOriginalUrl(anchorId: string): string {
|
||||
return `${API_BASE}/api/anchor-images/${anchorId}/original`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir les métadonnées d'une ancre.
|
||||
*
|
||||
* @param anchorId - ID de l'ancre
|
||||
* @returns Métadonnées de l'ancre
|
||||
*/
|
||||
export async function getAnchorMetadata(anchorId: string): Promise<AnchorMetadata> {
|
||||
const response = await fetch(`${API_BASE}/api/anchor-images/${anchorId}/metadata`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Ancre '${anchorId}' non trouvée`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return result.metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprimer une image d'ancre du serveur.
|
||||
*
|
||||
* @param anchorId - ID de l'ancre à supprimer
|
||||
* @returns true si supprimé avec succès
|
||||
*/
|
||||
export async function deleteAnchorImage(anchorId: string): Promise<boolean> {
|
||||
const response = await fetch(`${API_BASE}/api/anchor-images/${anchorId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok && response.status !== 404) {
|
||||
throw new Error(`Erreur lors de la suppression de l'ancre '${anchorId}'`);
|
||||
}
|
||||
|
||||
return response.ok;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lister toutes les images d'ancres stockées.
|
||||
*
|
||||
* @param limit - Nombre maximum d'ancres à retourner
|
||||
* @param offset - Décalage pour la pagination
|
||||
* @returns Liste des métadonnées des ancres
|
||||
*/
|
||||
export async function listAnchorImages(
|
||||
limit: number = 100,
|
||||
offset: number = 0
|
||||
): Promise<{ anchors: AnchorMetadata[]; total: number }> {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/api/anchor-images?limit=${limit}&offset=${offset}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Erreur lors de la récupération de la liste des ancres');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return {
|
||||
anchors: result.anchors,
|
||||
total: result.total,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir les statistiques de stockage.
|
||||
*
|
||||
* @returns Statistiques de stockage
|
||||
*/
|
||||
export async function getStorageStats(): Promise<StorageStats> {
|
||||
const response = await fetch(`${API_BASE}/api/anchor-images/stats`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Erreur lors de la récupération des statistiques');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return result.stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifier si une ancre existe sur le serveur.
|
||||
*
|
||||
* @param anchorId - ID de l'ancre
|
||||
* @returns true si l'ancre existe
|
||||
*/
|
||||
export async function anchorExists(anchorId: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/api/anchor-images/${anchorId}/metadata`,
|
||||
{ method: 'HEAD' }
|
||||
);
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir l'URL de prévisualisation d'une ancre.
|
||||
* Retourne l'URL de la miniature si disponible, sinon null.
|
||||
*
|
||||
* @param anchor - Ancre visuelle avec potentiellement thumbnail_url ou reference_image_base64
|
||||
* @returns URL de prévisualisation ou chaîne data: pour base64 legacy
|
||||
*/
|
||||
export function getPreviewImageUrl(anchor: {
|
||||
anchor_id?: string;
|
||||
thumbnail_url?: string;
|
||||
reference_image_url?: string;
|
||||
reference_image_base64?: string;
|
||||
}): string | null {
|
||||
// Priorité 1: URL de miniature serveur
|
||||
if (anchor.thumbnail_url) {
|
||||
// Si l'URL est relative, ajouter le préfixe API
|
||||
return anchor.thumbnail_url.startsWith('http')
|
||||
? anchor.thumbnail_url
|
||||
: `${API_BASE}${anchor.thumbnail_url}`;
|
||||
}
|
||||
|
||||
// Priorité 2: URL d'image originale serveur
|
||||
if (anchor.reference_image_url) {
|
||||
return anchor.reference_image_url.startsWith('http')
|
||||
? anchor.reference_image_url
|
||||
: `${API_BASE}${anchor.reference_image_url}`;
|
||||
}
|
||||
|
||||
// Priorité 3: Construire l'URL depuis anchor_id si présent
|
||||
if (anchor.anchor_id && anchor.anchor_id.startsWith('anchor_')) {
|
||||
return getThumbnailUrl(anchor.anchor_id);
|
||||
}
|
||||
|
||||
// Fallback: base64 legacy
|
||||
if (anchor.reference_image_base64) {
|
||||
if (anchor.reference_image_base64.startsWith('data:')) {
|
||||
return anchor.reference_image_base64;
|
||||
}
|
||||
return `data:image/png;base64,${anchor.reference_image_base64}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Export par défaut pour compatibilité
|
||||
const anchorImageService = {
|
||||
uploadAnchorImage,
|
||||
getThumbnailUrl,
|
||||
getOriginalUrl,
|
||||
getAnchorMetadata,
|
||||
deleteAnchorImage,
|
||||
listAnchorImages,
|
||||
getStorageStats,
|
||||
anchorExists,
|
||||
getPreviewImageUrl,
|
||||
};
|
||||
|
||||
export default anchorImageService;
|
||||
@@ -54,12 +54,12 @@ const getApiHost = (): string => {
|
||||
const hostname = window.location.hostname;
|
||||
// Si c'est localhost ou 127.0.0.1, garder localhost
|
||||
if (hostname === 'localhost' || hostname === '127.0.0.1') {
|
||||
return 'http://localhost:5002/api';
|
||||
return 'http://localhost:5001/api';
|
||||
}
|
||||
// Sinon utiliser le même hostname (IP) avec le port 5000
|
||||
return `http://${hostname}:5000/api`;
|
||||
}
|
||||
return 'http://localhost:5002/api';
|
||||
return 'http://localhost:5001/api';
|
||||
};
|
||||
|
||||
// Configuration par défaut
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* Service de Bibliothèque de Captures d'Écran
|
||||
* Auteur : Dom, Alice, Kiro - 13 janvier 2026
|
||||
*
|
||||
* Permet de sauvegarder, organiser et réutiliser des captures d'écran
|
||||
* pour éviter de refaire des captures lors du travail sur le même logiciel.
|
||||
*/
|
||||
|
||||
export interface SavedCapture {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
application?: string;
|
||||
screenshot: string; // base64
|
||||
thumbnailSmall: string; // base64 miniature 100px
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
dimensions: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface CaptureLibraryState {
|
||||
captures: SavedCapture[];
|
||||
lastUpdated: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'vwb_capture_library';
|
||||
const LIBRARY_VERSION = '1.0.0';
|
||||
const MAX_CAPTURES = 50; // Limite pour éviter de saturer le localStorage
|
||||
|
||||
/**
|
||||
* Service de gestion de la bibliothèque de captures
|
||||
* Utilise localStorage pour la persistance entre sessions
|
||||
*/
|
||||
class CaptureLibraryService {
|
||||
private state: CaptureLibraryState;
|
||||
|
||||
constructor() {
|
||||
this.state = this.loadFromStorage();
|
||||
console.log('📚 [CaptureLibrary] Service initialisé avec', this.state.captures.length, 'captures');
|
||||
}
|
||||
|
||||
/**
|
||||
* Recharger les données depuis localStorage (utile après un refresh)
|
||||
*/
|
||||
public reload(): void {
|
||||
this.state = this.loadFromStorage();
|
||||
console.log('🔄 [CaptureLibrary] Rechargé:', this.state.captures.length, 'captures');
|
||||
}
|
||||
|
||||
/**
|
||||
* Charger la bibliothèque depuis localStorage
|
||||
*/
|
||||
private loadFromStorage(): CaptureLibraryState {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored) as CaptureLibraryState;
|
||||
// Vérifier la version
|
||||
if (parsed.version === LIBRARY_VERSION) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Erreur lors du chargement de la bibliothèque de captures:', error);
|
||||
}
|
||||
|
||||
// Retourner un état vide par défaut
|
||||
return {
|
||||
captures: [],
|
||||
lastUpdated: new Date().toISOString(),
|
||||
version: LIBRARY_VERSION,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sauvegarder la bibliothèque dans localStorage
|
||||
*/
|
||||
private saveToStorage(): void {
|
||||
try {
|
||||
this.state.lastUpdated = new Date().toISOString();
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.state));
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la sauvegarde de la bibliothèque:', error);
|
||||
// Si localStorage est plein, supprimer les plus anciennes captures
|
||||
if (error instanceof DOMException && error.name === 'QuotaExceededError') {
|
||||
this.cleanupOldCaptures(10);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.state));
|
||||
} catch {
|
||||
console.error('Impossible de sauvegarder même après nettoyage');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Créer une miniature d'une image base64
|
||||
*/
|
||||
private async createThumbnail(base64Image: string, maxSize: number = 100): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
resolve(base64Image);
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculer les dimensions proportionnelles
|
||||
let width = img.width;
|
||||
let height = img.height;
|
||||
if (width > height) {
|
||||
if (width > maxSize) {
|
||||
height = (height * maxSize) / width;
|
||||
width = maxSize;
|
||||
}
|
||||
} else {
|
||||
if (height > maxSize) {
|
||||
width = (width * maxSize) / height;
|
||||
height = maxSize;
|
||||
}
|
||||
}
|
||||
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
|
||||
resolve(canvas.toDataURL('image/jpeg', 0.7));
|
||||
};
|
||||
img.onerror = () => resolve(base64Image);
|
||||
|
||||
if (base64Image.startsWith('data:')) {
|
||||
img.src = base64Image;
|
||||
} else {
|
||||
img.src = `data:image/png;base64,${base64Image}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir les dimensions d'une image base64
|
||||
*/
|
||||
private async getImageDimensions(base64Image: string): Promise<{ width: number; height: number }> {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve({ width: img.width, height: img.height });
|
||||
img.onerror = () => resolve({ width: 0, height: 0 });
|
||||
|
||||
if (base64Image.startsWith('data:')) {
|
||||
img.src = base64Image;
|
||||
} else {
|
||||
img.src = `data:image/png;base64,${base64Image}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sauvegarder une nouvelle capture dans la bibliothèque
|
||||
*/
|
||||
async saveCapture(
|
||||
screenshot: string,
|
||||
name: string,
|
||||
options: {
|
||||
description?: string;
|
||||
application?: string;
|
||||
tags?: string[];
|
||||
} = {}
|
||||
): Promise<SavedCapture> {
|
||||
// Vérifier la limite
|
||||
if (this.state.captures.length >= MAX_CAPTURES) {
|
||||
this.cleanupOldCaptures(5);
|
||||
}
|
||||
|
||||
const id = `capture_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
const dimensions = await this.getImageDimensions(screenshot);
|
||||
const thumbnailSmall = await this.createThumbnail(screenshot, 100);
|
||||
|
||||
const capture: SavedCapture = {
|
||||
id,
|
||||
name,
|
||||
description: options.description || '',
|
||||
application: options.application || '',
|
||||
screenshot,
|
||||
thumbnailSmall,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
dimensions,
|
||||
tags: options.tags || [],
|
||||
};
|
||||
|
||||
this.state.captures.unshift(capture); // Ajouter en premier
|
||||
this.saveToStorage();
|
||||
|
||||
return capture;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir toutes les captures
|
||||
*/
|
||||
getAllCaptures(): SavedCapture[] {
|
||||
return [...this.state.captures];
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir une capture par ID
|
||||
*/
|
||||
getCaptureById(id: string): SavedCapture | null {
|
||||
return this.state.captures.find((c) => c.id === id) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rechercher des captures par nom ou application
|
||||
*/
|
||||
searchCaptures(query: string): SavedCapture[] {
|
||||
const lowerQuery = query.toLowerCase();
|
||||
return this.state.captures.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(lowerQuery) ||
|
||||
c.application?.toLowerCase().includes(lowerQuery) ||
|
||||
c.description?.toLowerCase().includes(lowerQuery) ||
|
||||
c.tags.some((t) => t.toLowerCase().includes(lowerQuery))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir les captures par application
|
||||
*/
|
||||
getCapturesByApplication(application: string): SavedCapture[] {
|
||||
return this.state.captures.filter(
|
||||
(c) => c.application?.toLowerCase() === application.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mettre à jour une capture
|
||||
*/
|
||||
updateCapture(
|
||||
id: string,
|
||||
updates: Partial<Pick<SavedCapture, 'name' | 'description' | 'application' | 'tags'>>
|
||||
): SavedCapture | null {
|
||||
const index = this.state.captures.findIndex((c) => c.id === id);
|
||||
if (index === -1) return null;
|
||||
|
||||
this.state.captures[index] = {
|
||||
...this.state.captures[index],
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
this.saveToStorage();
|
||||
return this.state.captures[index];
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprimer une capture
|
||||
*/
|
||||
deleteCapture(id: string): boolean {
|
||||
const index = this.state.captures.findIndex((c) => c.id === id);
|
||||
if (index === -1) return false;
|
||||
|
||||
this.state.captures.splice(index, 1);
|
||||
this.saveToStorage();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprimer les captures les plus anciennes
|
||||
*/
|
||||
private cleanupOldCaptures(count: number): void {
|
||||
// Trier par date (les plus anciennes à la fin) et supprimer
|
||||
this.state.captures.sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
);
|
||||
this.state.captures = this.state.captures.slice(0, MAX_CAPTURES - count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir la liste des applications uniques
|
||||
*/
|
||||
getApplications(): string[] {
|
||||
const apps = new Set<string>();
|
||||
this.state.captures.forEach((c) => {
|
||||
if (c.application) apps.add(c.application);
|
||||
});
|
||||
return Array.from(apps).sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Vider la bibliothèque
|
||||
*/
|
||||
clearLibrary(): void {
|
||||
this.state.captures = [];
|
||||
this.saveToStorage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir les statistiques de la bibliothèque
|
||||
*/
|
||||
getStats(): {
|
||||
totalCaptures: number;
|
||||
applications: number;
|
||||
oldestCapture: string | null;
|
||||
newestCapture: string | null;
|
||||
} {
|
||||
return {
|
||||
totalCaptures: this.state.captures.length,
|
||||
applications: this.getApplications().length,
|
||||
oldestCapture:
|
||||
this.state.captures.length > 0
|
||||
? this.state.captures[this.state.captures.length - 1].createdAt
|
||||
: null,
|
||||
newestCapture:
|
||||
this.state.captures.length > 0 ? this.state.captures[0].createdAt : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton
|
||||
export const captureLibraryService = new CaptureLibraryService();
|
||||
export default captureLibraryService;
|
||||
976
visual_workflow_builder/frontend/src/services/catalogService.ts
Normal file
976
visual_workflow_builder/frontend/src/services/catalogService.ts
Normal file
@@ -0,0 +1,976 @@
|
||||
/**
|
||||
* Service Catalogue VWB - Communication avec l'API Catalogue d'Actions VisionOnly
|
||||
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
|
||||
*
|
||||
* Ce service gère toutes les communications avec l'API du catalogue d'actions VisionOnly
|
||||
* du Visual Workflow Builder, incluant la liste des actions, l'exécution, et la validation.
|
||||
*
|
||||
* ARCHITECTURE:
|
||||
* - Détection automatique de l'URL du backend (cross-machine)
|
||||
* - Fallback automatique vers catalogue statique hors ligne
|
||||
* - Gestion d'erreurs robuste avec messages en français
|
||||
* - Types TypeScript stricts pour toutes les données
|
||||
* - Cache intelligent pour optimiser les performances
|
||||
* - Support du mode hors ligne gracieux
|
||||
*
|
||||
* NOUVEAUTÉS v2.0:
|
||||
* - URL configurable et détection automatique
|
||||
* - Catalogue statique de secours (5 actions de base)
|
||||
* - Persistance de configuration dans localStorage
|
||||
* - Gestion cross-machine robuste
|
||||
*/
|
||||
|
||||
// Import des types du catalogue
|
||||
import {
|
||||
VWBCatalogAction,
|
||||
VWBActionParameter,
|
||||
VWBActionExample,
|
||||
VWBActionCategory,
|
||||
VWBActionExecutionRequest,
|
||||
VWBActionExecutionResult,
|
||||
VWBActionEvidence,
|
||||
VWBActionError,
|
||||
VWBActionValidationRequest,
|
||||
VWBActionValidationResult,
|
||||
VWBCatalogHealth,
|
||||
VWBServiceStatus
|
||||
} from '../types/catalog';
|
||||
|
||||
// Import du catalogue statique de secours
|
||||
import {
|
||||
getStaticCatalogActions,
|
||||
getStaticActionsByCategory,
|
||||
getStaticActionById,
|
||||
searchStaticActions,
|
||||
getStaticCatalogCategories,
|
||||
getStaticCatalogStats,
|
||||
} from '../data/staticCatalog';
|
||||
|
||||
// Configuration du service catalogue
|
||||
interface CatalogServiceConfig {
|
||||
urls: string[];
|
||||
timeout: number;
|
||||
retryCount: number;
|
||||
cacheKey: string;
|
||||
fallbackToStatic: boolean;
|
||||
}
|
||||
|
||||
// État du service catalogue
|
||||
interface CatalogServiceState {
|
||||
mode: 'dynamic' | 'static' | 'offline';
|
||||
currentUrl: string | null;
|
||||
lastError: string | null;
|
||||
lastCheck: number;
|
||||
isOnline: boolean;
|
||||
}
|
||||
|
||||
// Alias pour éviter les conflits de noms
|
||||
type CatalogAction = VWBCatalogAction;
|
||||
type CatalogActionCategory = VWBActionCategory;
|
||||
|
||||
// Types pour l'API interne (compatibles avec les types VWB)
|
||||
export interface ActionExecutionRequest extends VWBActionExecutionRequest {}
|
||||
|
||||
export interface ActionExecutionResult extends VWBActionExecutionResult {}
|
||||
|
||||
export interface ActionEvidence extends VWBActionEvidence {}
|
||||
|
||||
export interface ActionError extends VWBActionError {}
|
||||
|
||||
// Types pour la validation
|
||||
export interface ActionValidationRequest extends VWBActionValidationRequest {}
|
||||
|
||||
export interface ActionValidationResult extends VWBActionValidationResult {}
|
||||
|
||||
// Types pour les réponses API
|
||||
interface CatalogApiResponse<T = any> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
offline?: boolean;
|
||||
}
|
||||
|
||||
interface CatalogListResponse {
|
||||
actions: CatalogAction[];
|
||||
total: number;
|
||||
categories: string[];
|
||||
screen_capturer_available: boolean;
|
||||
}
|
||||
|
||||
interface CatalogExecutionResponse {
|
||||
result: ActionExecutionResult;
|
||||
}
|
||||
|
||||
interface CatalogValidationResponse {
|
||||
validation: ActionValidationResult;
|
||||
}
|
||||
|
||||
interface CatalogHealthResponse {
|
||||
status: string;
|
||||
services: {
|
||||
screen_capturer: boolean;
|
||||
actions: number;
|
||||
screen_capturer_method: string;
|
||||
};
|
||||
timestamp: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service de gestion du catalogue d'actions VisionOnly pour le VWB
|
||||
* Fournit une interface TypeScript typée pour l'API du catalogue
|
||||
* avec détection automatique d'URL et fallback statique
|
||||
*/
|
||||
class CatalogService {
|
||||
private cache: Map<string, { data: any; timestamp: number }> = new Map();
|
||||
private readonly CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
|
||||
private readonly API_BASE_PATH = '/api/vwb/catalog';
|
||||
|
||||
// Configuration par défaut
|
||||
private config: CatalogServiceConfig = {
|
||||
urls: [],
|
||||
timeout: 2000,
|
||||
retryCount: 3,
|
||||
cacheKey: 'vwb_catalog_config',
|
||||
fallbackToStatic: true,
|
||||
};
|
||||
|
||||
// État du service
|
||||
private state: CatalogServiceState = {
|
||||
mode: 'offline',
|
||||
currentUrl: null,
|
||||
lastError: null,
|
||||
lastCheck: 0,
|
||||
isOnline: false,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
this.initializeConfig();
|
||||
this.loadPersistedConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialiser la configuration avec détection automatique d'URLs
|
||||
*/
|
||||
private initializeConfig(): void {
|
||||
// URLs à tester par ordre de priorité
|
||||
const candidateUrls: string[] = [];
|
||||
|
||||
// 1. Variable d'environnement (priorité maximale)
|
||||
const envUrl = process.env.REACT_APP_CATALOG_URL;
|
||||
if (envUrl) {
|
||||
candidateUrls.push(envUrl);
|
||||
}
|
||||
|
||||
// 2. Paramètre URL
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const paramUrl = urlParams.get('catalogUrl');
|
||||
if (paramUrl) {
|
||||
candidateUrls.push(paramUrl);
|
||||
}
|
||||
|
||||
// 3. Même origine que le frontend (pour déploiements intégrés)
|
||||
const currentOrigin = window.location.origin;
|
||||
candidateUrls.push(currentOrigin);
|
||||
|
||||
// 4. Localhost standard (développement) - Port 5001 en priorité
|
||||
if (!candidateUrls.includes('http://localhost:5001')) {
|
||||
candidateUrls.push('http://localhost:5001');
|
||||
}
|
||||
if (!candidateUrls.includes('http://localhost:5001')) {
|
||||
candidateUrls.push('http://localhost:5001');
|
||||
}
|
||||
|
||||
// 5. IP locale détectée (cross-machine)
|
||||
try {
|
||||
const localIp = this.detectLocalIp();
|
||||
if (localIp && localIp !== '127.0.0.1') {
|
||||
candidateUrls.push(`http://${localIp}:5000`);
|
||||
candidateUrls.push(`http://${localIp}:5004`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Impossible de détecter l\'IP locale:', error);
|
||||
}
|
||||
|
||||
this.config.urls = candidateUrls;
|
||||
console.log('🔍 URLs candidates pour le catalogue:', candidateUrls);
|
||||
}
|
||||
|
||||
/**
|
||||
* Détecter l'IP locale (approximation basée sur WebRTC)
|
||||
*/
|
||||
private detectLocalIp(): string | null {
|
||||
// Note: Cette méthode est limitée par les restrictions de sécurité des navigateurs
|
||||
// Elle fournit une estimation basée sur l'URL courante
|
||||
try {
|
||||
const hostname = window.location.hostname;
|
||||
|
||||
// Si on est déjà sur une IP, l'utiliser
|
||||
if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname)) {
|
||||
return hostname;
|
||||
}
|
||||
|
||||
// Sinon, retourner null pour utiliser les autres méthodes
|
||||
return null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Charger la configuration persistée depuis localStorage
|
||||
*/
|
||||
private loadPersistedConfig(): void {
|
||||
try {
|
||||
const stored = localStorage.getItem(this.config.cacheKey);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
|
||||
// Vérifier si la configuration n'est pas expirée (24h)
|
||||
const age = Date.now() - parsed.timestamp;
|
||||
const maxAge = 24 * 60 * 60 * 1000; // 24 heures
|
||||
|
||||
if (age < maxAge && parsed.url) {
|
||||
// Mettre l'URL fonctionnelle en première position
|
||||
this.config.urls = [
|
||||
parsed.url,
|
||||
...this.config.urls.filter(url => url !== parsed.url)
|
||||
];
|
||||
|
||||
console.log('📦 Configuration persistée chargée:', parsed.url);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Erreur lors du chargement de la configuration persistée:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persister la configuration fonctionnelle
|
||||
*/
|
||||
private persistConfig(workingUrl: string): void {
|
||||
try {
|
||||
const config = {
|
||||
url: workingUrl,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
localStorage.setItem(this.config.cacheKey, JSON.stringify(config));
|
||||
console.log('💾 Configuration persistée:', workingUrl);
|
||||
} catch (error) {
|
||||
console.warn('Erreur lors de la persistance de configuration:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Détecter automatiquement l'URL du backend fonctionnelle
|
||||
*/
|
||||
private async detectBackendUrl(): Promise<string | null> {
|
||||
console.log('🔍 Détection automatique de l\'URL du backend...');
|
||||
|
||||
for (const url of this.config.urls) {
|
||||
try {
|
||||
console.log(`⏳ Test de ${url}...`);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
||||
|
||||
const response = await fetch(`${url}/health`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (response.ok) {
|
||||
console.log(`✅ Backend trouvé sur ${url}`);
|
||||
this.state.currentUrl = url;
|
||||
this.state.isOnline = true;
|
||||
this.state.mode = 'dynamic';
|
||||
this.state.lastError = null;
|
||||
|
||||
// Persister la configuration fonctionnelle
|
||||
this.persistConfig(url);
|
||||
|
||||
return url;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`❌ ${url} non accessible:`, error instanceof Error ? error.message : error);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('🔴 Aucun backend accessible, passage en mode statique');
|
||||
this.state.mode = 'static';
|
||||
this.state.isOnline = false;
|
||||
this.state.currentUrl = null;
|
||||
this.state.lastError = 'Aucun backend catalogue accessible';
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forcer une nouvelle détection d'URL
|
||||
*/
|
||||
async forceUrlDetection(): Promise<boolean> {
|
||||
console.log('🔄 Détection forcée de l\'URL du backend...');
|
||||
|
||||
// Réinitialiser l'état
|
||||
this.state.lastCheck = 0;
|
||||
|
||||
// Relancer la détection
|
||||
const url = await this.detectBackendUrl();
|
||||
this.state.lastCheck = Date.now();
|
||||
|
||||
return url !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir l'état actuel du service
|
||||
*/
|
||||
getServiceState(): CatalogServiceState {
|
||||
return { ...this.state };
|
||||
}
|
||||
|
||||
/**
|
||||
* Effectuer une requête vers l'API catalogue avec gestion d'erreurs et fallback
|
||||
*/
|
||||
private async makeRequest<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
// Vérifier si on a une URL fonctionnelle ou tenter la détection
|
||||
if (!this.state.currentUrl || !this.state.isOnline) {
|
||||
const detectedUrl = await this.detectBackendUrl();
|
||||
if (!detectedUrl) {
|
||||
throw new Error('Service catalogue hors ligne - Mode statique activé');
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const fullEndpoint = `${this.API_BASE_PATH}${endpoint}`;
|
||||
|
||||
const response = await fetch(`${this.state.currentUrl}${fullEndpoint}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
let errorData: any = {};
|
||||
|
||||
try {
|
||||
errorData = JSON.parse(errorText);
|
||||
} catch {
|
||||
errorData = { message: errorText };
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
errorData.error ||
|
||||
errorData.message ||
|
||||
`Erreur API catalogue: ${response.status}`
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.success && data.error) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
|
||||
// Marquer comme en ligne si la requête réussit
|
||||
this.state.isOnline = true;
|
||||
this.state.lastError = null;
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error(`Erreur requête catalogue ${endpoint}:`, error);
|
||||
|
||||
// Marquer comme hors ligne
|
||||
this.state.isOnline = false;
|
||||
this.state.lastError = error instanceof Error ? error.message : 'Erreur inconnue';
|
||||
|
||||
// Gestion gracieuse des erreurs réseau
|
||||
if (error instanceof TypeError && error.message.includes('fetch')) {
|
||||
this.state.mode = 'static';
|
||||
throw new Error('Service catalogue hors ligne - Mode statique activé');
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifier si une donnée en cache est encore valide
|
||||
*/
|
||||
private isCacheValid(cacheKey: string): boolean {
|
||||
const cached = this.cache.get(cacheKey);
|
||||
if (!cached) return false;
|
||||
|
||||
return Date.now() - cached.timestamp < this.CACHE_DURATION;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir une donnée du cache ou null si invalide
|
||||
*/
|
||||
private getCachedData<T>(cacheKey: string): T | null {
|
||||
if (!this.isCacheValid(cacheKey)) {
|
||||
this.cache.delete(cacheKey);
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.cache.get(cacheKey)?.data || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mettre en cache une donnée
|
||||
*/
|
||||
private setCachedData(cacheKey: string, data: any): void {
|
||||
this.cache.set(cacheKey, {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupérer la liste des actions disponibles dans le catalogue
|
||||
* Avec fallback automatique vers le catalogue statique
|
||||
*
|
||||
* @param category - Filtrer par catégorie (optionnel)
|
||||
* @param search - Terme de recherche (optionnel)
|
||||
* @returns Liste des actions avec métadonnées
|
||||
*/
|
||||
async getActions(
|
||||
category?: CatalogActionCategory,
|
||||
search?: string
|
||||
): Promise<{
|
||||
actions: CatalogAction[];
|
||||
total: number;
|
||||
categories: string[];
|
||||
screenCapturerAvailable: boolean;
|
||||
mode: 'dynamic' | 'static';
|
||||
}> {
|
||||
// Tenter de charger depuis le backend dynamique
|
||||
const cacheKey = `actions_${category || 'all'}_${search || ''}`;
|
||||
const cached = this.getCachedData<{
|
||||
actions: CatalogAction[];
|
||||
total: number;
|
||||
categories: string[];
|
||||
screenCapturerAvailable: boolean;
|
||||
}>(cacheKey);
|
||||
|
||||
if (cached) {
|
||||
return { ...cached, mode: 'dynamic' };
|
||||
}
|
||||
|
||||
try {
|
||||
let endpoint = '/actions';
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (category) {
|
||||
params.append('category', category);
|
||||
}
|
||||
if (search) {
|
||||
params.append('search', search);
|
||||
}
|
||||
|
||||
if (params.toString()) {
|
||||
endpoint += `?${params.toString()}`;
|
||||
}
|
||||
|
||||
const response = await this.makeRequest<CatalogListResponse>(endpoint);
|
||||
|
||||
const result = {
|
||||
actions: response.actions,
|
||||
total: response.total,
|
||||
categories: response.categories,
|
||||
screenCapturerAvailable: response.screen_capturer_available,
|
||||
};
|
||||
|
||||
this.setCachedData(cacheKey, result);
|
||||
|
||||
return { ...result, mode: 'dynamic' as const };
|
||||
} catch (error) {
|
||||
console.warn('Erreur catalogue dynamique, utilisation du catalogue statique:', error);
|
||||
return this.getStaticActions(category, search);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupérer les actions du catalogue statique (mode hors ligne)
|
||||
*/
|
||||
private getStaticActions(
|
||||
category?: CatalogActionCategory,
|
||||
search?: string
|
||||
): {
|
||||
actions: CatalogAction[];
|
||||
total: number;
|
||||
categories: string[];
|
||||
screenCapturerAvailable: boolean;
|
||||
mode: 'static';
|
||||
} {
|
||||
let actions: CatalogAction[];
|
||||
|
||||
if (search) {
|
||||
actions = searchStaticActions(search);
|
||||
} else if (category) {
|
||||
actions = getStaticActionsByCategory(category);
|
||||
} else {
|
||||
actions = getStaticCatalogActions();
|
||||
}
|
||||
|
||||
const categories = getStaticCatalogCategories().map(cat => cat.id);
|
||||
|
||||
return {
|
||||
actions,
|
||||
total: actions.length,
|
||||
categories,
|
||||
screenCapturerAvailable: false, // Pas de capture d'écran en mode statique
|
||||
mode: 'static',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupérer les détails d'une action spécifique
|
||||
* Avec fallback vers le catalogue statique
|
||||
*
|
||||
* @param actionId - Identifiant de l'action
|
||||
* @returns Détails complets de l'action
|
||||
*/
|
||||
async getActionDetails(actionId: string): Promise<CatalogAction | null> {
|
||||
if (!actionId || actionId.trim().length === 0) {
|
||||
throw new Error('L\'identifiant de l\'action est obligatoire');
|
||||
}
|
||||
|
||||
try {
|
||||
// Vérifier le cache
|
||||
const cacheKey = `action_details_${actionId}`;
|
||||
const cached = this.getCachedData<{ action: CatalogAction }>(cacheKey);
|
||||
if (cached) {
|
||||
return cached.action;
|
||||
}
|
||||
|
||||
// Effectuer la requête
|
||||
const response = await this.makeRequest<{ action: CatalogAction }>(
|
||||
`/actions/${actionId}`
|
||||
);
|
||||
|
||||
// Mettre en cache
|
||||
this.setCachedData(cacheKey, response);
|
||||
|
||||
return response.action;
|
||||
} catch (error) {
|
||||
console.warn(`Erreur catalogue dynamique pour action ${actionId}, recherche en mode statique:`, error);
|
||||
|
||||
// Fallback vers le catalogue statique
|
||||
return getStaticActionById(actionId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exécuter une action du catalogue
|
||||
*
|
||||
* @param request - Configuration de l'action à exécuter
|
||||
* @returns Résultat de l'exécution avec evidence
|
||||
*/
|
||||
async executeAction(request: ActionExecutionRequest): Promise<ActionExecutionResult> {
|
||||
// Validation des paramètres
|
||||
if (!request.type || request.type.trim().length === 0) {
|
||||
throw new Error('Le type d\'action est obligatoire');
|
||||
}
|
||||
|
||||
if (!request.parameters || typeof request.parameters !== 'object') {
|
||||
throw new Error('Les paramètres de l\'action sont obligatoires');
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`🚀 Exécution de l'action ${request.type}...`);
|
||||
|
||||
// Effectuer la requête d'exécution
|
||||
const response = await this.makeRequest<CatalogExecutionResponse>('/execute', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
||||
const result = response.result;
|
||||
|
||||
// Log du résultat
|
||||
const statusEmoji = result.status === 'success' ? '✅' : '❌';
|
||||
console.log(
|
||||
`${statusEmoji} Action ${request.type} terminée en ${result.execution_time_ms}ms`
|
||||
);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`Erreur lors de l'exécution de l'action ${request.type}:`, error);
|
||||
|
||||
// Créer un résultat d'erreur standardisé
|
||||
const errorResult: ActionExecutionResult = {
|
||||
action_id: request.action_id || `error_${Date.now()}`,
|
||||
step_id: request.step_id || `step_${Date.now()}`,
|
||||
status: 'error',
|
||||
start_time: new Date().toISOString(),
|
||||
end_time: new Date().toISOString(),
|
||||
execution_time_ms: 0,
|
||||
output_data: {},
|
||||
evidence_list: [],
|
||||
error: {
|
||||
error_id: `error_${Date.now()}`,
|
||||
error_type: 'action_execution_failed',
|
||||
severity: 'high',
|
||||
message: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
retry_count: 0,
|
||||
workflow_id: request.workflow_id,
|
||||
user_id: request.user_id,
|
||||
};
|
||||
|
||||
return errorResult;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Valider la configuration d'une action sans l'exécuter
|
||||
*
|
||||
* @param request - Configuration de l'action à valider
|
||||
* @returns Résultat de la validation avec erreurs et suggestions
|
||||
*/
|
||||
async validateAction(request: ActionValidationRequest): Promise<ActionValidationResult> {
|
||||
// Validation des paramètres
|
||||
if (!request.type || request.type.trim().length === 0) {
|
||||
throw new Error('Le type d\'action est obligatoire');
|
||||
}
|
||||
|
||||
try {
|
||||
// Effectuer la requête de validation
|
||||
const response = await this.makeRequest<CatalogValidationResponse>('/validate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
||||
// S'assurer que la réponse contient une validation valide
|
||||
if (response && response.validation) {
|
||||
return response.validation;
|
||||
}
|
||||
|
||||
// Si pas de validation dans la réponse, retourner un résultat valide par défaut
|
||||
return {
|
||||
is_valid: true,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
suggestions: [],
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Erreur lors de la validation de l'action ${request.type}:`, error);
|
||||
|
||||
// Retourner un résultat d'erreur
|
||||
return {
|
||||
is_valid: false,
|
||||
errors: [{
|
||||
parameter: 'general',
|
||||
message: error instanceof Error ? error.message : 'Erreur de validation',
|
||||
code: 'validation_error',
|
||||
severity: 'error',
|
||||
}],
|
||||
warnings: [],
|
||||
suggestions: [{
|
||||
type: 'best_practice',
|
||||
message: 'Vérifiez la configuration de l\'action',
|
||||
priority: 'medium',
|
||||
}],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifier la santé du service catalogue
|
||||
*
|
||||
* @returns État du service avec informations de diagnostic
|
||||
*/
|
||||
async getHealth(): Promise<{
|
||||
status: string;
|
||||
services: {
|
||||
screenCapturer: boolean;
|
||||
actions: number;
|
||||
screenCapturerMethod: string;
|
||||
};
|
||||
timestamp: string;
|
||||
version: string;
|
||||
}> {
|
||||
try {
|
||||
const response = await this.makeRequest<CatalogHealthResponse>('/health');
|
||||
|
||||
return {
|
||||
status: response.status,
|
||||
services: {
|
||||
screenCapturer: response.services.screen_capturer,
|
||||
actions: response.services.actions,
|
||||
screenCapturerMethod: response.services.screen_capturer_method,
|
||||
},
|
||||
timestamp: response.timestamp,
|
||||
version: response.version,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la vérification de santé du catalogue:', error);
|
||||
|
||||
return {
|
||||
status: 'offline',
|
||||
services: {
|
||||
screenCapturer: false,
|
||||
actions: 0,
|
||||
screenCapturerMethod: 'unavailable',
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
version: 'unknown',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir les catégories d'actions disponibles
|
||||
* Avec fallback vers le catalogue statique
|
||||
*
|
||||
* @returns Liste des catégories avec métadonnées
|
||||
*/
|
||||
async getCategories(): Promise<Array<{
|
||||
id: CatalogActionCategory;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
actionCount: number;
|
||||
mode: 'dynamic' | 'static';
|
||||
}>> {
|
||||
try {
|
||||
// Récupérer toutes les actions pour calculer les statistiques
|
||||
const { actions, categories } = await this.getActions();
|
||||
|
||||
// Définir les métadonnées des catégories
|
||||
const categoryMetadata: Record<CatalogActionCategory, {
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
}> = {
|
||||
vision_ui: {
|
||||
name: 'Interactions Visuelles',
|
||||
description: 'Cliquer, saisir, glisser-déposer sur des éléments visuels',
|
||||
icon: '🖱️',
|
||||
},
|
||||
control: {
|
||||
name: 'Contrôle de Flux',
|
||||
description: 'Conditions, boucles et synchronisation basées sur la vision',
|
||||
icon: '🔀',
|
||||
},
|
||||
data: {
|
||||
name: 'Extraction de Données',
|
||||
description: 'Extraire texte, tableaux, télécharger des fichiers',
|
||||
icon: '📊',
|
||||
},
|
||||
intelligence: {
|
||||
name: 'Intelligence IA',
|
||||
description: 'Analyse et traitement intelligent par IA',
|
||||
icon: '🤖',
|
||||
},
|
||||
database: {
|
||||
name: 'Base de Données',
|
||||
description: 'Lire et enregistrer des données en base',
|
||||
icon: '💾',
|
||||
},
|
||||
validation: {
|
||||
name: 'Validation',
|
||||
description: 'Vérifier la présence et le contenu des éléments',
|
||||
icon: '✅',
|
||||
},
|
||||
};
|
||||
|
||||
// Construire la liste des catégories avec statistiques
|
||||
return categories.map(categoryId => {
|
||||
const category = categoryId as CatalogActionCategory;
|
||||
const metadata = categoryMetadata[category] || {
|
||||
name: category,
|
||||
description: `Actions de type ${category}`,
|
||||
icon: '📋',
|
||||
};
|
||||
|
||||
const actionCount = actions.filter(action => action.category === category).length;
|
||||
|
||||
return {
|
||||
id: category,
|
||||
name: metadata.name,
|
||||
description: metadata.description,
|
||||
icon: metadata.icon,
|
||||
actionCount,
|
||||
mode: this.state.mode === 'offline' ? 'static' : this.state.mode,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('Erreur catalogue dynamique, utilisation du catalogue statique:', error);
|
||||
|
||||
// Fallback vers le catalogue statique
|
||||
return getStaticCatalogCategories().map(cat => ({
|
||||
...cat,
|
||||
mode: 'static' as const,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rechercher des actions par terme
|
||||
* Avec fallback vers le catalogue statique
|
||||
*
|
||||
* @param searchTerm - Terme de recherche
|
||||
* @returns Actions correspondant au terme de recherche
|
||||
*/
|
||||
async searchActions(searchTerm: string): Promise<CatalogAction[]> {
|
||||
if (!searchTerm || searchTerm.trim().length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const { actions } = await this.getActions(undefined, searchTerm.trim());
|
||||
return actions;
|
||||
} catch (error) {
|
||||
console.warn('Erreur recherche catalogue dynamique, utilisation du catalogue statique:', error);
|
||||
|
||||
// Fallback vers le catalogue statique
|
||||
return searchStaticActions(searchTerm.trim());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vider le cache du service
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.cache.clear();
|
||||
console.log('Cache du service catalogue vidé');
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir les statistiques du cache
|
||||
*/
|
||||
getCacheStats(): {
|
||||
size: number;
|
||||
keys: string[];
|
||||
oldestEntry: string | null;
|
||||
newestEntry: string | null;
|
||||
} {
|
||||
const entries = Array.from(this.cache.entries());
|
||||
|
||||
if (entries.length === 0) {
|
||||
return {
|
||||
size: 0,
|
||||
keys: [],
|
||||
oldestEntry: null,
|
||||
newestEntry: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Trier par timestamp
|
||||
entries.sort((a, b) => a[1].timestamp - b[1].timestamp);
|
||||
|
||||
return {
|
||||
size: entries.length,
|
||||
keys: entries.map(([key]) => key),
|
||||
oldestEntry: entries[0][0],
|
||||
newestEntry: entries[entries.length - 1][0],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir les statistiques complètes du service
|
||||
*/
|
||||
async getServiceStats(): Promise<{
|
||||
mode: 'dynamic' | 'static' | 'offline';
|
||||
isOnline: boolean;
|
||||
currentUrl: string | null;
|
||||
lastError: string | null;
|
||||
lastCheck: number;
|
||||
cacheStats: {
|
||||
size: number;
|
||||
keys: string[];
|
||||
oldestEntry: string | null;
|
||||
newestEntry: string | null;
|
||||
};
|
||||
catalogStats: any;
|
||||
}> {
|
||||
const cacheStats = this.getCacheStats();
|
||||
|
||||
let catalogStats;
|
||||
try {
|
||||
if (this.state.mode === 'static') {
|
||||
catalogStats = getStaticCatalogStats();
|
||||
} else {
|
||||
const { actions, total } = await this.getActions();
|
||||
catalogStats = {
|
||||
totalActions: total,
|
||||
mode: this.state.mode,
|
||||
actionsLoaded: actions.length,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
catalogStats = { error: 'Impossible de récupérer les statistiques' };
|
||||
}
|
||||
|
||||
return {
|
||||
mode: this.state.mode,
|
||||
isOnline: this.state.isOnline,
|
||||
currentUrl: this.state.currentUrl,
|
||||
lastError: this.state.lastError,
|
||||
lastCheck: this.state.lastCheck,
|
||||
cacheStats,
|
||||
catalogStats,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Réinitialiser complètement le service
|
||||
*/
|
||||
async reset(): Promise<void> {
|
||||
console.log('🔄 Réinitialisation complète du service catalogue...');
|
||||
|
||||
// Vider le cache
|
||||
this.clearCache();
|
||||
|
||||
// Supprimer la configuration persistée
|
||||
try {
|
||||
localStorage.removeItem(this.config.cacheKey);
|
||||
} catch (error) {
|
||||
console.warn('Erreur lors de la suppression de la configuration persistée:', error);
|
||||
}
|
||||
|
||||
// Réinitialiser l'état
|
||||
this.state = {
|
||||
mode: 'offline',
|
||||
currentUrl: null,
|
||||
lastError: null,
|
||||
lastCheck: 0,
|
||||
isOnline: false,
|
||||
};
|
||||
|
||||
// Réinitialiser la configuration
|
||||
this.initializeConfig();
|
||||
|
||||
// Relancer la détection
|
||||
await this.forceUrlDetection();
|
||||
|
||||
console.log('✅ Service catalogue réinitialisé');
|
||||
}
|
||||
}
|
||||
|
||||
// Instance singleton du service catalogue
|
||||
export const catalogService = new CatalogService();
|
||||
|
||||
// Export des types pour utilisation externe (sans conflits)
|
||||
export type {
|
||||
CatalogAction,
|
||||
CatalogActionCategory,
|
||||
};
|
||||
|
||||
export default CatalogService;
|
||||
@@ -0,0 +1,504 @@
|
||||
/**
|
||||
* Service Evidence d'Exécution - Gestion centralisée des Evidence VWB
|
||||
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
|
||||
*
|
||||
* Ce service centralise la gestion des Evidence pendant l'exécution des workflows VWB,
|
||||
* avec persistance, synchronisation et optimisations de performance.
|
||||
*/
|
||||
|
||||
import { Evidence, Step } from '../types';
|
||||
import { VWBExecutionResult } from './vwbExecutionService';
|
||||
|
||||
export interface EvidenceExecutionConfig {
|
||||
maxEvidencePerStep: number;
|
||||
maxTotalEvidence: number;
|
||||
autoCleanupInterval: number;
|
||||
persistToStorage: boolean;
|
||||
compressionEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface EvidenceMetrics {
|
||||
totalEvidence: number;
|
||||
evidenceByStep: Record<string, number>;
|
||||
evidenceByType: Record<string, number>;
|
||||
averageSize: number;
|
||||
oldestTimestamp: Date | null;
|
||||
newestTimestamp: Date | null;
|
||||
}
|
||||
|
||||
export interface EvidenceQuery {
|
||||
stepId?: string;
|
||||
type?: string;
|
||||
action_id?: string;
|
||||
dateFrom?: Date;
|
||||
dateTo?: Date;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
sortBy?: 'timestamp' | 'type' | 'size';
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
/**
|
||||
* Service principal pour la gestion des Evidence d'exécution
|
||||
*/
|
||||
export class EvidenceExecutionService {
|
||||
private static instance: EvidenceExecutionService;
|
||||
private config: EvidenceExecutionConfig;
|
||||
private evidenceStore: Map<string, Evidence[]> = new Map();
|
||||
private allEvidence: Evidence[] = [];
|
||||
private listeners: Set<(evidence: Evidence[], stepId?: string) => void> = new Set();
|
||||
private cleanupTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
private constructor(config: Partial<EvidenceExecutionConfig> = {}) {
|
||||
this.config = {
|
||||
maxEvidencePerStep: 50,
|
||||
maxTotalEvidence: 200,
|
||||
autoCleanupInterval: 5 * 60 * 1000, // 5 minutes
|
||||
persistToStorage: true,
|
||||
compressionEnabled: false,
|
||||
...config,
|
||||
};
|
||||
|
||||
this.initializeService();
|
||||
}
|
||||
|
||||
public static getInstance(config?: Partial<EvidenceExecutionConfig>): EvidenceExecutionService {
|
||||
if (!EvidenceExecutionService.instance) {
|
||||
EvidenceExecutionService.instance = new EvidenceExecutionService(config);
|
||||
}
|
||||
return EvidenceExecutionService.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialiser le service
|
||||
*/
|
||||
private initializeService(): void {
|
||||
// Charger les Evidence depuis le stockage
|
||||
this.loadFromStorage();
|
||||
|
||||
// Démarrer le nettoyage automatique
|
||||
if (this.config.autoCleanupInterval > 0) {
|
||||
this.startAutoCleanup();
|
||||
}
|
||||
|
||||
// Écouter les changements de visibilité pour optimiser les performances
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('visibilitychange', this.handleVisibilityChange.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajouter une Evidence pour une étape
|
||||
*/
|
||||
public addEvidence(stepId: string, evidence: Evidence): void {
|
||||
// Vérifier les limites
|
||||
if (this.allEvidence.length >= this.config.maxTotalEvidence) {
|
||||
this.performCleanup();
|
||||
}
|
||||
|
||||
// Obtenir les Evidence de l'étape
|
||||
let stepEvidence = this.evidenceStore.get(stepId) || [];
|
||||
|
||||
// Vérifier la limite par étape
|
||||
if (stepEvidence.length >= this.config.maxEvidencePerStep) {
|
||||
stepEvidence = stepEvidence.slice(1); // Supprimer la plus ancienne
|
||||
}
|
||||
|
||||
// Ajouter la nouvelle Evidence
|
||||
stepEvidence.push(evidence);
|
||||
this.evidenceStore.set(stepId, stepEvidence);
|
||||
|
||||
// Mettre à jour la liste globale
|
||||
this.updateAllEvidence();
|
||||
|
||||
// Sauvegarder si activé
|
||||
if (this.config.persistToStorage) {
|
||||
this.saveToStorage();
|
||||
}
|
||||
|
||||
// Notifier les listeners
|
||||
this.notifyListeners(stepEvidence, stepId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajouter plusieurs Evidence pour une étape
|
||||
*/
|
||||
public addMultipleEvidence(stepId: string, evidenceList: Evidence[]): void {
|
||||
evidenceList.forEach(evidence => this.addEvidence(stepId, evidence));
|
||||
}
|
||||
|
||||
/**
|
||||
* Traiter les résultats d'exécution VWB
|
||||
*/
|
||||
public processExecutionResult(result: VWBExecutionResult): void {
|
||||
if (result.evidence && result.evidence.length > 0) {
|
||||
this.addMultipleEvidence(result.stepId, result.evidence);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir les Evidence d'une étape
|
||||
*/
|
||||
public getEvidenceByStep(stepId: string): Evidence[] {
|
||||
return this.evidenceStore.get(stepId) || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir toutes les Evidence
|
||||
*/
|
||||
public getAllEvidence(): Evidence[] {
|
||||
return [...this.allEvidence];
|
||||
}
|
||||
|
||||
/**
|
||||
* Rechercher des Evidence
|
||||
*/
|
||||
public searchEvidence(query: EvidenceQuery): Evidence[] {
|
||||
let results = this.allEvidence;
|
||||
|
||||
// Filtrer par étape
|
||||
if (query.stepId) {
|
||||
results = this.getEvidenceByStep(query.stepId);
|
||||
}
|
||||
|
||||
// Filtrer par type
|
||||
if (query.action_id) {
|
||||
results = results.filter(evidence => evidence.action_id === query.action_id);
|
||||
}
|
||||
|
||||
// Filtrer par date
|
||||
if (query.dateFrom) {
|
||||
results = results.filter(evidence =>
|
||||
new Date(evidence.captured_at) >= query.dateFrom!
|
||||
);
|
||||
}
|
||||
|
||||
if (query.dateTo) {
|
||||
results = results.filter(evidence =>
|
||||
new Date(evidence.captured_at) <= query.dateTo!
|
||||
);
|
||||
}
|
||||
|
||||
// Trier
|
||||
if (query.sortBy) {
|
||||
results.sort((a, b) => {
|
||||
let comparison = 0;
|
||||
|
||||
switch (query.sortBy) {
|
||||
case 'timestamp':
|
||||
comparison = new Date(a.captured_at).getTime() - new Date(b.captured_at).getTime();
|
||||
break;
|
||||
case 'type':
|
||||
comparison = a.action_id.localeCompare(b.action_id);
|
||||
break;
|
||||
case 'size':
|
||||
const sizeA = this.getEvidenceSize(a);
|
||||
const sizeB = this.getEvidenceSize(b);
|
||||
comparison = sizeA - sizeB;
|
||||
break;
|
||||
}
|
||||
|
||||
return query.sortOrder === 'desc' ? -comparison : comparison;
|
||||
});
|
||||
}
|
||||
|
||||
// Pagination
|
||||
if (query.offset || query.limit) {
|
||||
const start = query.offset || 0;
|
||||
const end = query.limit ? start + query.limit : undefined;
|
||||
results = results.slice(start, end);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir les métriques des Evidence
|
||||
*/
|
||||
public getMetrics(): EvidenceMetrics {
|
||||
const evidenceByStep: Record<string, number> = {};
|
||||
const evidenceByType: Record<string, number> = {};
|
||||
let totalSize = 0;
|
||||
let oldestTimestamp: Date | null = null;
|
||||
let newestTimestamp: Date | null = null;
|
||||
|
||||
// Calculer les métriques par étape
|
||||
this.evidenceStore.forEach((evidence, stepId) => {
|
||||
evidenceByStep[stepId] = evidence.length;
|
||||
});
|
||||
|
||||
// Calculer les métriques globales
|
||||
this.allEvidence.forEach(evidence => {
|
||||
// Par type
|
||||
evidenceByType[evidence.action_id] = (evidenceByType[evidence.action_id] || 0) + 1;
|
||||
|
||||
// Taille
|
||||
totalSize += this.getEvidenceSize(evidence);
|
||||
|
||||
// Timestamps
|
||||
const timestamp = new Date(evidence.captured_at);
|
||||
if (!oldestTimestamp || timestamp < oldestTimestamp) {
|
||||
oldestTimestamp = timestamp;
|
||||
}
|
||||
if (!newestTimestamp || timestamp > newestTimestamp) {
|
||||
newestTimestamp = timestamp;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
totalEvidence: this.allEvidence.length,
|
||||
evidenceByStep,
|
||||
evidenceByType,
|
||||
averageSize: this.allEvidence.length > 0 ? totalSize / this.allEvidence.length : 0,
|
||||
oldestTimestamp,
|
||||
newestTimestamp,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Nettoyer les Evidence d'une étape
|
||||
*/
|
||||
public clearStepEvidence(stepId: string): void {
|
||||
this.evidenceStore.delete(stepId);
|
||||
this.updateAllEvidence();
|
||||
|
||||
if (this.config.persistToStorage) {
|
||||
this.saveToStorage();
|
||||
}
|
||||
|
||||
this.notifyListeners([], stepId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nettoyer toutes les Evidence
|
||||
*/
|
||||
public clearAllEvidence(): void {
|
||||
this.evidenceStore.clear();
|
||||
this.allEvidence = [];
|
||||
|
||||
if (this.config.persistToStorage) {
|
||||
this.clearStorage();
|
||||
}
|
||||
|
||||
this.notifyListeners([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajouter un listener pour les changements
|
||||
*/
|
||||
public addListener(listener: (evidence: Evidence[], stepId?: string) => void): void {
|
||||
this.listeners.add(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprimer un listener
|
||||
*/
|
||||
public removeListener(listener: (evidence: Evidence[], stepId?: string) => void): void {
|
||||
this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exporter les Evidence
|
||||
*/
|
||||
public exportEvidence(stepId?: string): string {
|
||||
const data = stepId
|
||||
? { [stepId]: this.getEvidenceByStep(stepId) }
|
||||
: Object.fromEntries(this.evidenceStore);
|
||||
|
||||
return JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
stepId,
|
||||
evidence: data,
|
||||
metrics: this.getMetrics(),
|
||||
}, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Importer des Evidence
|
||||
*/
|
||||
public importEvidence(jsonData: string): void {
|
||||
try {
|
||||
const data = JSON.parse(jsonData);
|
||||
|
||||
if (data.evidence) {
|
||||
Object.entries(data.evidence).forEach(([stepId, evidence]) => {
|
||||
if (Array.isArray(evidence)) {
|
||||
this.evidenceStore.set(stepId, evidence as Evidence[]);
|
||||
}
|
||||
});
|
||||
|
||||
this.updateAllEvidence();
|
||||
|
||||
if (this.config.persistToStorage) {
|
||||
this.saveToStorage();
|
||||
}
|
||||
|
||||
this.notifyListeners(this.allEvidence);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de l\'importation des Evidence:', error);
|
||||
throw new Error('Format de données invalide');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mettre à jour la liste globale des Evidence
|
||||
*/
|
||||
private updateAllEvidence(): void {
|
||||
this.allEvidence = [];
|
||||
this.evidenceStore.forEach(stepEvidence => {
|
||||
this.allEvidence.push(...stepEvidence);
|
||||
});
|
||||
|
||||
// Trier par timestamp
|
||||
this.allEvidence.sort((a, b) =>
|
||||
new Date(a.captured_at).getTime() - new Date(b.captured_at).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifier les listeners
|
||||
*/
|
||||
private notifyListeners(evidence: Evidence[], stepId?: string): void {
|
||||
this.listeners.forEach(listener => {
|
||||
try {
|
||||
listener(evidence, stepId);
|
||||
} catch (error) {
|
||||
console.error('Erreur dans le listener Evidence:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir la taille d'une Evidence
|
||||
*/
|
||||
private getEvidenceSize(evidence: Evidence): number {
|
||||
return JSON.stringify(evidence).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Effectuer le nettoyage automatique
|
||||
*/
|
||||
private performCleanup(): void {
|
||||
const cutoffTime = Date.now() - (30 * 60 * 1000); // 30 minutes
|
||||
|
||||
this.evidenceStore.forEach((stepEvidence, stepId) => {
|
||||
const filtered = stepEvidence.filter(evidence =>
|
||||
new Date(evidence.captured_at).getTime() > cutoffTime
|
||||
);
|
||||
|
||||
if (filtered.length !== stepEvidence.length) {
|
||||
this.evidenceStore.set(stepId, filtered);
|
||||
}
|
||||
});
|
||||
|
||||
this.updateAllEvidence();
|
||||
|
||||
if (this.config.persistToStorage) {
|
||||
this.saveToStorage();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Démarrer le nettoyage automatique
|
||||
*/
|
||||
private startAutoCleanup(): void {
|
||||
this.cleanupTimer = setInterval(() => {
|
||||
this.performCleanup();
|
||||
}, this.config.autoCleanupInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gérer les changements de visibilité
|
||||
*/
|
||||
private handleVisibilityChange(): void {
|
||||
if (document.hidden) {
|
||||
// Sauvegarder quand la page devient invisible
|
||||
if (this.config.persistToStorage) {
|
||||
this.saveToStorage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sauvegarder dans le stockage local
|
||||
*/
|
||||
private saveToStorage(): void {
|
||||
try {
|
||||
const data = {
|
||||
evidenceStore: Object.fromEntries(this.evidenceStore),
|
||||
timestamp: Date.now(),
|
||||
config: this.config,
|
||||
};
|
||||
|
||||
localStorage.setItem('vwb_evidence_execution', JSON.stringify(data));
|
||||
} catch (error) {
|
||||
console.warn('Erreur lors de la sauvegarde des Evidence:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Charger depuis le stockage local
|
||||
*/
|
||||
private loadFromStorage(): void {
|
||||
try {
|
||||
const stored = localStorage.getItem('vwb_evidence_execution');
|
||||
if (stored) {
|
||||
const data = JSON.parse(stored);
|
||||
|
||||
if (data.evidenceStore) {
|
||||
this.evidenceStore = new Map(Object.entries(data.evidenceStore));
|
||||
this.updateAllEvidence();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Erreur lors du chargement des Evidence:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Nettoyer le stockage local
|
||||
*/
|
||||
private clearStorage(): void {
|
||||
try {
|
||||
localStorage.removeItem('vwb_evidence_execution');
|
||||
} catch (error) {
|
||||
console.warn('Erreur lors du nettoyage du stockage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Nettoyer les ressources
|
||||
*/
|
||||
public cleanup(): void {
|
||||
if (this.cleanupTimer) {
|
||||
clearInterval(this.cleanupTimer);
|
||||
this.cleanupTimer = null;
|
||||
}
|
||||
|
||||
this.listeners.clear();
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
document.removeEventListener('visibilitychange', this.handleVisibilityChange.bind(this));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Instance singleton
|
||||
export const evidenceExecutionService = EvidenceExecutionService.getInstance();
|
||||
|
||||
// Hook pour utiliser le service
|
||||
export const useEvidenceExecutionService = () => {
|
||||
return {
|
||||
service: evidenceExecutionService,
|
||||
addEvidence: (stepId: string, evidence: Evidence) =>
|
||||
evidenceExecutionService.addEvidence(stepId, evidence),
|
||||
getEvidenceByStep: (stepId: string) =>
|
||||
evidenceExecutionService.getEvidenceByStep(stepId),
|
||||
getAllEvidence: () => evidenceExecutionService.getAllEvidence(),
|
||||
searchEvidence: (query: EvidenceQuery) =>
|
||||
evidenceExecutionService.searchEvidence(query),
|
||||
getMetrics: () => evidenceExecutionService.getMetrics(),
|
||||
clearAllEvidence: () => evidenceExecutionService.clearAllEvidence(),
|
||||
};
|
||||
};
|
||||
351
visual_workflow_builder/frontend/src/services/evidenceService.ts
Normal file
351
visual_workflow_builder/frontend/src/services/evidenceService.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* Service pour la gestion des Evidence VWB
|
||||
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
|
||||
*/
|
||||
|
||||
import { VWBEvidence, EvidenceFilters, EvidenceExportOptions, EvidenceStats, EvidenceUtils } from '../types/evidence';
|
||||
|
||||
export class EvidenceService {
|
||||
private baseUrl: string;
|
||||
private cache: Map<string, VWBEvidence[]> = new Map();
|
||||
private cacheTimeout: number = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
constructor(baseUrl: string = 'http://localhost:5001') {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère toutes les Evidence disponibles
|
||||
*/
|
||||
async getEvidences(workflowId?: string): Promise<VWBEvidence[]> {
|
||||
try {
|
||||
const cacheKey = `evidences_${workflowId || 'all'}`;
|
||||
const cached = this.cache.get(cacheKey);
|
||||
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const url = workflowId
|
||||
? `${this.baseUrl}/api/vwb/evidences?workflow_id=${workflowId}`
|
||||
: `${this.baseUrl}/api/vwb/evidences`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Erreur lors de la récupération des Evidence : ${response.statusText}`);
|
||||
}
|
||||
|
||||
const evidences: VWBEvidence[] = await response.json();
|
||||
|
||||
// Mise en cache
|
||||
this.cache.set(cacheKey, evidences);
|
||||
setTimeout(() => this.cache.delete(cacheKey), this.cacheTimeout);
|
||||
|
||||
return evidences;
|
||||
} catch (error) {
|
||||
console.error('Erreur EvidenceService.getEvidences:', error);
|
||||
throw new Error(`Impossible de récupérer les Evidence : ${error instanceof Error ? error.message : 'Erreur inconnue'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère une Evidence spécifique par ID
|
||||
*/
|
||||
async getEvidence(evidenceId: string): Promise<VWBEvidence | null> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/api/vwb/evidences/${evidenceId}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Erreur lors de la récupération de l'Evidence : ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Erreur EvidenceService.getEvidence:', error);
|
||||
throw new Error(`Impossible de récupérer l'Evidence ${evidenceId} : ${error instanceof Error ? error.message : 'Erreur inconnue'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sauvegarde une Evidence
|
||||
*/
|
||||
async saveEvidence(evidence: VWBEvidence): Promise<VWBEvidence> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/api/vwb/evidences`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(evidence),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Erreur lors de la sauvegarde de l'Evidence : ${response.statusText}`);
|
||||
}
|
||||
|
||||
const savedEvidence = await response.json();
|
||||
|
||||
// Invalider le cache
|
||||
this.cache.clear();
|
||||
|
||||
return savedEvidence;
|
||||
} catch (error) {
|
||||
console.error('Erreur EvidenceService.saveEvidence:', error);
|
||||
throw new Error(`Impossible de sauvegarder l'Evidence : ${error instanceof Error ? error.message : 'Erreur inconnue'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime une Evidence
|
||||
*/
|
||||
async deleteEvidence(evidenceId: string): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/api/vwb/evidences/${evidenceId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Erreur lors de la suppression de l'Evidence : ${response.statusText}`);
|
||||
}
|
||||
|
||||
// Invalider le cache
|
||||
this.cache.clear();
|
||||
} catch (error) {
|
||||
console.error('Erreur EvidenceService.deleteEvidence:', error);
|
||||
throw new Error(`Impossible de supprimer l'Evidence ${evidenceId} : ${error instanceof Error ? error.message : 'Erreur inconnue'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filtre les Evidence selon les critères spécifiés
|
||||
*/
|
||||
filterEvidences(evidences: VWBEvidence[], filters: EvidenceFilters): VWBEvidence[] {
|
||||
return EvidenceUtils.filterEvidences(evidences, filters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trie les Evidence
|
||||
*/
|
||||
sortEvidences(evidences: VWBEvidence[], sortBy: string, sortOrder: 'asc' | 'desc' = 'desc'): VWBEvidence[] {
|
||||
return EvidenceUtils.sortEvidences(evidences, sortBy, sortOrder);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule les statistiques des Evidence
|
||||
*/
|
||||
calculateStats(evidences: VWBEvidence[]): EvidenceStats {
|
||||
return EvidenceUtils.calculateStats(evidences);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exporte les Evidence selon les options spécifiées
|
||||
*/
|
||||
async exportEvidences(evidences: VWBEvidence[], options: EvidenceExportOptions): Promise<Blob> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/api/vwb/evidences/export`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
evidences,
|
||||
options,
|
||||
format: options.format || 'json'
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Erreur lors de l'export des Evidence : ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.blob();
|
||||
} catch (error) {
|
||||
console.error('Erreur EvidenceService.exportEvidences:', error);
|
||||
|
||||
// Fallback : export côté client
|
||||
return this.exportEvidencesClientSide(evidences, options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export côté client en cas d'échec du serveur
|
||||
*/
|
||||
private exportEvidencesClientSide(evidences: VWBEvidence[], options: EvidenceExportOptions): Blob {
|
||||
if (options.format === 'json') {
|
||||
const exportData = {
|
||||
metadata: {
|
||||
exportDate: new Date().toISOString(),
|
||||
totalEvidences: evidences.length,
|
||||
options
|
||||
},
|
||||
evidences: evidences.map(evidence => ({
|
||||
...evidence,
|
||||
screenshot_base64: options.includeScreenshots ? evidence.screenshot_base64 : undefined
|
||||
}))
|
||||
};
|
||||
|
||||
return new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
|
||||
}
|
||||
|
||||
if (options.format === 'html') {
|
||||
const html = this.generateHtmlReport(evidences, options);
|
||||
return new Blob([html], { type: 'text/html' });
|
||||
}
|
||||
|
||||
if (options.format === 'pdf') {
|
||||
// Pour PDF, on génère du HTML qui peut être converti
|
||||
const html = this.generateHtmlReport(evidences, options);
|
||||
return new Blob([html], { type: 'text/html' });
|
||||
}
|
||||
|
||||
// Format par défaut
|
||||
const html = this.generateHtmlReport(evidences, options);
|
||||
return new Blob([html], { type: 'text/html' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère un rapport HTML des Evidence
|
||||
*/
|
||||
private generateHtmlReport(evidences: VWBEvidence[], options: EvidenceExportOptions): string {
|
||||
const stats = this.calculateStats(evidences);
|
||||
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Rapport Evidence VWB - ${new Date().toLocaleDateString('fr-FR')}</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 20px; }
|
||||
.header { border-bottom: 2px solid #1976d2; padding-bottom: 10px; margin-bottom: 20px; }
|
||||
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; margin-bottom: 30px; }
|
||||
.stat-card { background: #f5f5f5; padding: 15px; border-radius: 8px; text-align: center; }
|
||||
.stat-value { font-size: 24px; font-weight: bold; color: #1976d2; }
|
||||
.evidence-item { border: 1px solid #ddd; margin-bottom: 20px; padding: 15px; border-radius: 8px; }
|
||||
.evidence-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
|
||||
.evidence-status { padding: 4px 8px; border-radius: 4px; color: white; font-size: 12px; }
|
||||
.status-success { background: #4caf50; }
|
||||
.status-error { background: #f44336; }
|
||||
.evidence-screenshot { max-width: 300px; max-height: 200px; border: 1px solid #ddd; margin: 10px 0; }
|
||||
.evidence-metadata { background: #f9f9f9; padding: 10px; border-radius: 4px; font-size: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>Rapport Evidence VWB</h1>
|
||||
<p>Généré le ${new Date().toLocaleString('fr-FR')}</p>
|
||||
<p>Nombre d'Evidence : ${evidences.length}</p>
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${stats.total}</div>
|
||||
<div>Total</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${stats.successful}</div>
|
||||
<div>Réussies</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${stats.failed}</div>
|
||||
<div>Échouées</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${EvidenceUtils.formatExecutionTime(stats.averageExecutionTime)}</div>
|
||||
<div>Temps moyen</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${EvidenceUtils.formatConfidence(stats.averageConfidence)}</div>
|
||||
<div>Confiance moyenne</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Détail des Evidence</h2>
|
||||
${evidences.map(evidence => `
|
||||
<div class="evidence-item">
|
||||
<div class="evidence-header">
|
||||
<h3>${evidence.action_name || evidence.action_id}</h3>
|
||||
<span class="evidence-status ${evidence.success ? 'status-success' : 'status-error'}">
|
||||
${evidence.success ? 'SUCCÈS' : 'ERREUR'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p><strong>Date :</strong> ${EvidenceUtils.formatDate(evidence.captured_at)}</p>
|
||||
<p><strong>Temps d'exécution :</strong> ${EvidenceUtils.formatExecutionTime(evidence.execution_time_ms)}</p>
|
||||
${evidence.confidence_score ? `<p><strong>Confiance :</strong> ${EvidenceUtils.formatConfidence(evidence.confidence_score)}</p>` : ''}
|
||||
|
||||
${evidence.error ? `
|
||||
<div style="background: #ffebee; padding: 10px; border-radius: 4px; margin: 10px 0;">
|
||||
<strong>Erreur :</strong> ${evidence.error.message}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${options.includeScreenshots && evidence.screenshot_base64 ? `
|
||||
<img src="data:image/png;base64,${evidence.screenshot_base64}"
|
||||
alt="Screenshot Evidence" class="evidence-screenshot">
|
||||
` : ''}
|
||||
|
||||
${options.includeMetadata && evidence.metadata ? `
|
||||
<div class="evidence-metadata">
|
||||
<strong>Métadonnées :</strong>
|
||||
<pre>${JSON.stringify(evidence.metadata, null, 2)}</pre>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`).join('')}
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
// Utilisation de URL.createObjectURL pour la génération
|
||||
const blob = new Blob([html], { type: 'text/html' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
URL.revokeObjectURL(url); // Nettoyage immédiat pour les tests
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide la santé du service Evidence
|
||||
*/
|
||||
async healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/api/vwb/evidences/health`, {
|
||||
method: 'GET',
|
||||
timeout: 5000
|
||||
} as RequestInit);
|
||||
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.warn('Service Evidence non disponible:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Nettoie le cache
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Instance singleton du service
|
||||
export const evidenceService = new EvidenceService();
|
||||
|
||||
export default EvidenceService;
|
||||
@@ -0,0 +1,388 @@
|
||||
/**
|
||||
* Service de Capture d'Écran Réelle - Interface avec l'API Backend
|
||||
* Auteur : Dom, Alice, Kiro - 09 janvier 2026
|
||||
*
|
||||
* Ce service gère la capture d'écran réelle avec détection d'éléments UI
|
||||
* en utilisant le service RealScreenCaptureService du backend.
|
||||
*/
|
||||
|
||||
// Configuration du service
|
||||
const BACKEND_BASE_URL = 'http://localhost:5001/api';
|
||||
const REQUEST_TIMEOUT = 20000; // 20 secondes pour la capture avec détection
|
||||
|
||||
// Types pour les réponses API
|
||||
interface Monitor {
|
||||
id: number;
|
||||
width: number;
|
||||
height: number;
|
||||
top: number;
|
||||
left: number;
|
||||
}
|
||||
|
||||
interface UIElement {
|
||||
id: string;
|
||||
type: string;
|
||||
text: string;
|
||||
bbox: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
confidence: number;
|
||||
attributes: Record<string, any>;
|
||||
}
|
||||
|
||||
interface CaptureStatus {
|
||||
is_capturing: boolean;
|
||||
selected_monitor: number;
|
||||
monitors_count: number;
|
||||
capture_interval: number;
|
||||
elements_detected: number;
|
||||
has_screenshot: boolean;
|
||||
}
|
||||
|
||||
interface RealScreenCaptureResponse {
|
||||
success: boolean;
|
||||
screenshot?: string;
|
||||
elements?: UIElement[];
|
||||
monitors?: Monitor[];
|
||||
status?: CaptureStatus;
|
||||
timestamp?: string;
|
||||
method?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface CaptureControlResponse {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
status?: CaptureStatus;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface StatusResponse {
|
||||
success: boolean;
|
||||
status?: CaptureStatus;
|
||||
monitors?: Monitor[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service de capture d'écran réelle avec détection d'éléments UI
|
||||
*/
|
||||
class RealScreenCaptureService {
|
||||
private abortController: AbortController | null = null;
|
||||
|
||||
/**
|
||||
* Capturer l'écran avec détection d'éléments UI
|
||||
*
|
||||
* @param monitorId ID du moniteur à capturer (0 par défaut)
|
||||
* @param detectElements Détecter les éléments UI (true par défaut)
|
||||
* @returns Promise avec les données de capture ou null si erreur
|
||||
*/
|
||||
async captureWithElements(
|
||||
monitorId: number = 0,
|
||||
detectElements: boolean = true
|
||||
): Promise<RealScreenCaptureResponse | null> {
|
||||
try {
|
||||
// Annuler toute requête précédente
|
||||
this.cancelRequest();
|
||||
|
||||
// Créer un nouveau contrôleur d'abort
|
||||
this.abortController = new AbortController();
|
||||
|
||||
// Timeout pour la requête
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (this.abortController) {
|
||||
this.abortController.abort();
|
||||
}
|
||||
}, REQUEST_TIMEOUT);
|
||||
|
||||
console.log(`🔧 Capture d'écran réelle (moniteur ${monitorId}, détection: ${detectElements})...`);
|
||||
|
||||
const response = await fetch(`${BACKEND_BASE_URL}/real-demo/capture`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
monitor_id: monitorId,
|
||||
detect_elements: detectElements,
|
||||
}),
|
||||
signal: this.abortController.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error(`❌ Erreur HTTP ${response.status}:`, errorText);
|
||||
return {
|
||||
success: false,
|
||||
error: `Erreur HTTP ${response.status}: ${errorText}`,
|
||||
};
|
||||
}
|
||||
|
||||
const data: RealScreenCaptureResponse = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
console.log(`✅ Capture réelle réussie - ${data.elements?.length || 0} éléments détectés`);
|
||||
} else {
|
||||
console.error('❌ Capture réelle échouée:', data.error);
|
||||
}
|
||||
|
||||
return data;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur lors de la capture d\'écran réelle:', error);
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.name === 'AbortError') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Capture annulée (timeout)',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'Erreur inconnue lors de la capture réelle',
|
||||
};
|
||||
} finally {
|
||||
this.abortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Démarrer la capture en temps réel
|
||||
*
|
||||
* @param interval Intervalle entre les captures en secondes (1.0 par défaut)
|
||||
* @returns Promise avec le résultat de l'opération
|
||||
*/
|
||||
async startRealTimeCapture(interval: number = 1.0): Promise<CaptureControlResponse | null> {
|
||||
try {
|
||||
console.log(`🔧 Démarrage capture temps réel (intervalle: ${interval}s)...`);
|
||||
|
||||
const response = await fetch(`${BACKEND_BASE_URL}/real-demo/capture/start`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
interval,
|
||||
}),
|
||||
signal: AbortSignal.timeout(10000), // 10 secondes max
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error(`❌ Erreur HTTP ${response.status}:`, errorText);
|
||||
return {
|
||||
success: false,
|
||||
error: `Erreur HTTP ${response.status}: ${errorText}`,
|
||||
};
|
||||
}
|
||||
|
||||
const data: CaptureControlResponse = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
console.log('✅ Capture temps réel démarrée');
|
||||
} else {
|
||||
console.error('❌ Échec démarrage capture temps réel:', data.error);
|
||||
}
|
||||
|
||||
return data;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur lors du démarrage de la capture temps réel:', error);
|
||||
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'Erreur inconnue lors du démarrage',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Arrêter la capture en temps réel
|
||||
*
|
||||
* @returns Promise avec le résultat de l'opération
|
||||
*/
|
||||
async stopRealTimeCapture(): Promise<CaptureControlResponse | null> {
|
||||
try {
|
||||
console.log('🔧 Arrêt capture temps réel...');
|
||||
|
||||
const response = await fetch(`${BACKEND_BASE_URL}/real-demo/capture/stop`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
signal: AbortSignal.timeout(5000), // 5 secondes max
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error(`❌ Erreur HTTP ${response.status}:`, errorText);
|
||||
return {
|
||||
success: false,
|
||||
error: `Erreur HTTP ${response.status}: ${errorText}`,
|
||||
};
|
||||
}
|
||||
|
||||
const data: CaptureControlResponse = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
console.log('✅ Capture temps réel arrêtée');
|
||||
} else {
|
||||
console.error('❌ Échec arrêt capture temps réel:', data.error);
|
||||
}
|
||||
|
||||
return data;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur lors de l\'arrêt de la capture temps réel:', error);
|
||||
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'Erreur inconnue lors de l\'arrêt',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir le statut du service de capture réelle
|
||||
*
|
||||
* @returns Promise avec le statut du service
|
||||
*/
|
||||
async getStatus(): Promise<StatusResponse | null> {
|
||||
try {
|
||||
const response = await fetch(`${BACKEND_BASE_URL}/real-demo/capture/status`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
signal: AbortSignal.timeout(5000), // 5 secondes max
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error(`❌ Erreur HTTP ${response.status}:`, errorText);
|
||||
return {
|
||||
success: false,
|
||||
error: `Erreur HTTP ${response.status}: ${errorText}`,
|
||||
};
|
||||
}
|
||||
|
||||
const data: StatusResponse = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
console.log('✅ Statut obtenu:', data.status);
|
||||
} else {
|
||||
console.error('❌ Échec obtention statut:', data.error);
|
||||
}
|
||||
|
||||
return data;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur lors de l\'obtention du statut:', error);
|
||||
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'Erreur inconnue lors de l\'obtention du statut',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifier la disponibilité du service de capture réelle
|
||||
*
|
||||
* @returns Promise<boolean> true si le service est disponible
|
||||
*/
|
||||
async checkAvailability(): Promise<boolean> {
|
||||
try {
|
||||
const statusResponse = await this.getStatus();
|
||||
return statusResponse?.success === true;
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Service de capture réelle indisponible:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir la liste des moniteurs disponibles
|
||||
*
|
||||
* @returns Promise avec la liste des moniteurs ou null si erreur
|
||||
*/
|
||||
async getMonitors(): Promise<Monitor[] | null> {
|
||||
try {
|
||||
const statusResponse = await this.getStatus();
|
||||
|
||||
if (statusResponse?.success && statusResponse.monitors) {
|
||||
return statusResponse.monitors;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur lors de l\'obtention des moniteurs:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Annuler la requête en cours
|
||||
*/
|
||||
cancelRequest(): void {
|
||||
if (this.abortController) {
|
||||
this.abortController.abort();
|
||||
this.abortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Nettoyer les ressources
|
||||
*/
|
||||
cleanup(): void {
|
||||
this.cancelRequest();
|
||||
}
|
||||
}
|
||||
|
||||
// Instance singleton du service
|
||||
export const realScreenCaptureService = new RealScreenCaptureService();
|
||||
|
||||
// Export des types
|
||||
export type {
|
||||
RealScreenCaptureResponse,
|
||||
CaptureControlResponse,
|
||||
StatusResponse,
|
||||
Monitor,
|
||||
UIElement,
|
||||
CaptureStatus
|
||||
};
|
||||
export default RealScreenCaptureService;
|
||||
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* Service de Capture d'Écran - Interface avec l'API Backend Ultra Stable
|
||||
* Auteur : Dom, Alice, Kiro - 09 janvier 2026
|
||||
*
|
||||
* Ce service gère la capture d'écran et la création d'embeddings visuels
|
||||
* en utilisant l'Option A (MSS créé à chaque capture) pour une stabilité maximale.
|
||||
*/
|
||||
|
||||
import { BoundingBox, VisualSelection } from '../types';
|
||||
|
||||
// Configuration du service
|
||||
const BACKEND_BASE_URL = 'http://localhost:5001/api';
|
||||
const REQUEST_TIMEOUT = 15000; // 15 secondes pour la capture d'écran
|
||||
|
||||
// Types pour les réponses API
|
||||
interface ScreenCaptureResponse {
|
||||
success: boolean;
|
||||
screenshot?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
timestamp?: string;
|
||||
method?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface VisualEmbeddingResponse {
|
||||
success: boolean;
|
||||
embedding?: number[];
|
||||
embedding_id?: string;
|
||||
dimension?: number;
|
||||
reference_image?: string;
|
||||
bounding_box?: BoundingBox;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service de capture d'écran utilisant l'API Backend ultra stable
|
||||
*/
|
||||
class ScreenCaptureService {
|
||||
private abortController: AbortController | null = null;
|
||||
|
||||
/**
|
||||
* Capturer l'écran actuel via l'API Backend (Option A - ultra stable)
|
||||
*
|
||||
* @param format Format de l'image ('png' ou 'jpeg')
|
||||
* @param quality Qualité pour JPEG (1-100)
|
||||
* @returns Promise avec les données de capture ou null si erreur
|
||||
*/
|
||||
async captureScreen(
|
||||
format: 'png' | 'jpeg' = 'png',
|
||||
quality: number = 90
|
||||
): Promise<ScreenCaptureResponse | null> {
|
||||
try {
|
||||
// Annuler toute requête précédente
|
||||
this.cancelRequest();
|
||||
|
||||
// Créer un nouveau contrôleur d'abort
|
||||
this.abortController = new AbortController();
|
||||
|
||||
// Timeout pour la requête
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (this.abortController) {
|
||||
this.abortController.abort();
|
||||
}
|
||||
}, REQUEST_TIMEOUT);
|
||||
|
||||
console.log('🔧 Capture d\'écran via API Backend (Option A - ultra stable)...');
|
||||
|
||||
const response = await fetch(`${BACKEND_BASE_URL}/screen-capture/capture`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
format,
|
||||
quality,
|
||||
}),
|
||||
signal: this.abortController.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error(`❌ Erreur HTTP ${response.status}:`, errorText);
|
||||
return {
|
||||
success: false,
|
||||
error: `Erreur HTTP ${response.status}: ${errorText}`,
|
||||
};
|
||||
}
|
||||
|
||||
const data: ScreenCaptureResponse = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
console.log(`✅ Capture réussie - ${data.width}x${data.height} (${data.method})`);
|
||||
} else {
|
||||
console.error('❌ Capture échouée:', data.error);
|
||||
}
|
||||
|
||||
return data;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur lors de la capture d\'écran:', error);
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.name === 'AbortError') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Capture annulée (timeout)',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'Erreur inconnue lors de la capture',
|
||||
};
|
||||
} finally {
|
||||
this.abortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Créer un embedding visuel à partir d'une capture et d'une zone sélectionnée
|
||||
*
|
||||
* @param screenshot Image en base64
|
||||
* @param boundingBox Zone sélectionnée
|
||||
* @param stepId Identifiant de l'étape
|
||||
* @returns Promise avec les données d'embedding ou null si erreur
|
||||
*/
|
||||
async createVisualEmbedding(
|
||||
screenshot: string,
|
||||
boundingBox: BoundingBox,
|
||||
stepId: string
|
||||
): Promise<VisualEmbeddingResponse | null> {
|
||||
try {
|
||||
// Annuler toute requête précédente
|
||||
this.cancelRequest();
|
||||
|
||||
// Créer un nouveau contrôleur d'abort
|
||||
this.abortController = new AbortController();
|
||||
|
||||
// Timeout pour la requête
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (this.abortController) {
|
||||
this.abortController.abort();
|
||||
}
|
||||
}, REQUEST_TIMEOUT);
|
||||
|
||||
console.log('🎯 Création d\'embedding visuel via API Backend...');
|
||||
|
||||
const response = await fetch(`${BACKEND_BASE_URL}/visual-embedding`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
screenshot,
|
||||
boundingBox,
|
||||
stepId,
|
||||
}),
|
||||
signal: this.abortController.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error(`❌ Erreur HTTP ${response.status}:`, errorText);
|
||||
return {
|
||||
success: false,
|
||||
error: `Erreur HTTP ${response.status}: ${errorText}`,
|
||||
};
|
||||
}
|
||||
|
||||
const data: VisualEmbeddingResponse = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
console.log(`✅ Embedding créé - ID: ${data.embedding_id}, Dimension: ${data.dimension}`);
|
||||
} else {
|
||||
console.error('❌ Création d\'embedding échouée:', data.error);
|
||||
}
|
||||
|
||||
return data;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur lors de la création d\'embedding:', error);
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.name === 'AbortError') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Création d\'embedding annulée (timeout)',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'Erreur inconnue lors de la création d\'embedding',
|
||||
};
|
||||
} finally {
|
||||
this.abortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Capturer l'écran et créer une sélection visuelle complète
|
||||
*
|
||||
* @param boundingBox Zone sélectionnée sur la capture
|
||||
* @param stepId Identifiant de l'étape
|
||||
* @param description Description de la sélection
|
||||
* @returns Promise avec la sélection visuelle complète ou null si erreur
|
||||
*/
|
||||
async captureAndCreateSelection(
|
||||
boundingBox: BoundingBox,
|
||||
stepId: string,
|
||||
description?: string
|
||||
): Promise<VisualSelection | null> {
|
||||
try {
|
||||
// Étape 1: Capturer l'écran
|
||||
console.log('📷 Étape 1/2: Capture d\'écran...');
|
||||
const captureResult = await this.captureScreen('png', 90);
|
||||
|
||||
if (!captureResult || !captureResult.success || !captureResult.screenshot) {
|
||||
console.error('❌ Échec de la capture d\'écran');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Étape 2: Créer l'embedding visuel
|
||||
console.log('🎯 Étape 2/2: Création d\'embedding visuel...');
|
||||
const embeddingResult = await this.createVisualEmbedding(
|
||||
captureResult.screenshot,
|
||||
boundingBox,
|
||||
stepId
|
||||
);
|
||||
|
||||
if (!embeddingResult || !embeddingResult.success || !embeddingResult.embedding) {
|
||||
console.error('❌ Échec de la création d\'embedding');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Créer la sélection visuelle complète
|
||||
const visualSelection: VisualSelection = {
|
||||
id: `visual_${stepId}_${Date.now()}`,
|
||||
screenshot: captureResult.screenshot,
|
||||
boundingBox: embeddingResult.bounding_box || boundingBox,
|
||||
embedding: embeddingResult.embedding,
|
||||
description: description || `Élément sélectionné pour l'étape ${stepId}`,
|
||||
metadata: {
|
||||
embedding_id: embeddingResult.embedding_id,
|
||||
dimension: embeddingResult.dimension,
|
||||
reference_image: embeddingResult.reference_image,
|
||||
capture_method: captureResult.method,
|
||||
capture_timestamp: captureResult.timestamp,
|
||||
screen_resolution: {
|
||||
width: captureResult.width,
|
||||
height: captureResult.height,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
console.log('✅ Sélection visuelle créée avec succès');
|
||||
return visualSelection;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur lors de la création de sélection visuelle:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifier la disponibilité de l'API Backend
|
||||
*
|
||||
* @returns Promise<boolean> true si l'API est disponible
|
||||
*/
|
||||
async checkApiAvailability(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${BACKEND_BASE_URL}/health`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
signal: AbortSignal.timeout(5000), // 5 secondes max
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
console.log('✅ API Backend disponible:', data);
|
||||
return true;
|
||||
} else {
|
||||
console.warn('⚠️ API Backend indisponible - HTTP', response.status);
|
||||
return false;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.warn('⚠️ API Backend indisponible:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir les informations sur les capacités de capture
|
||||
*
|
||||
* @returns Promise avec les informations de capacité
|
||||
*/
|
||||
async getCapabilities(): Promise<{
|
||||
screen_capture: boolean;
|
||||
visual_embedding: boolean;
|
||||
methods: string[];
|
||||
} | null> {
|
||||
try {
|
||||
const response = await fetch(`${BACKEND_BASE_URL}/health`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
return {
|
||||
screen_capture: data.features?.screen_capture || false,
|
||||
visual_embedding: data.features?.visual_embedding || false,
|
||||
methods: data.methods || ['unknown'],
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Impossible d\'obtenir les capacités:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Annuler la requête en cours
|
||||
*/
|
||||
cancelRequest(): void {
|
||||
if (this.abortController) {
|
||||
this.abortController.abort();
|
||||
this.abortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Nettoyer les ressources
|
||||
*/
|
||||
cleanup(): void {
|
||||
this.cancelRequest();
|
||||
}
|
||||
}
|
||||
|
||||
// Instance singleton du service
|
||||
export const screenCaptureService = new ScreenCaptureService();
|
||||
|
||||
// Export des types
|
||||
export type { ScreenCaptureResponse, VisualEmbeddingResponse };
|
||||
export default ScreenCaptureService;
|
||||
@@ -7,14 +7,20 @@
|
||||
*/
|
||||
|
||||
import { catalogService } from './catalogService';
|
||||
import {
|
||||
Step,
|
||||
StepExecutionState,
|
||||
ExecutionResult,
|
||||
import {
|
||||
Step,
|
||||
StepExecutionState,
|
||||
ExecutionResult,
|
||||
ExecutionError,
|
||||
Evidence
|
||||
Evidence
|
||||
} from '../types';
|
||||
import { VWBCatalogAction } from '../types/catalog';
|
||||
import {
|
||||
enforceActionContract,
|
||||
ContractValidationError,
|
||||
getRequiredParams,
|
||||
isKnownActionType
|
||||
} from '../contracts';
|
||||
|
||||
interface VWBActionValidationResult {
|
||||
is_valid: boolean;
|
||||
@@ -110,12 +116,57 @@ export class VWBExecutionService {
|
||||
'verify_text_content',
|
||||
]);
|
||||
|
||||
/**
|
||||
* NORMALISATION CRITIQUE: Résout les incohérences entre step.type et step.data.stepType
|
||||
*
|
||||
* Cette fonction détecte et corrige le problème où le type d'action peut être différent
|
||||
* entre step.type (source principale) et step.data.stepType (doublon historique).
|
||||
*
|
||||
* @returns Le type d'action normalisé (VWB si possible, sinon le type disponible)
|
||||
*/
|
||||
public normalizeStepType(step: Step): string {
|
||||
const typeFromStep = step.type;
|
||||
const typeFromData = step.data?.stepType;
|
||||
const typeFromVwbAction = step.data?.vwbActionId;
|
||||
const typeFromActionId = step.action_id;
|
||||
|
||||
// Détection d'incohérence
|
||||
if (typeFromStep && typeFromData && typeFromStep !== typeFromData) {
|
||||
console.warn(
|
||||
`⚠️ [VWB NORMALISATION] Incohérence détectée pour étape ${step.id}:`,
|
||||
`\n step.type = "${typeFromStep}"`,
|
||||
`\n step.data.stepType = "${typeFromData}"`,
|
||||
`\n → Utilisation de la valeur VWB valide`
|
||||
);
|
||||
}
|
||||
|
||||
// Priorité: Type VWB valide > step.type > step.data.stepType > vwbActionId > action_id
|
||||
const candidates = [typeFromStep, typeFromData, typeFromVwbAction, typeFromActionId];
|
||||
|
||||
// Chercher d'abord un type VWB valide
|
||||
for (const candidate of candidates) {
|
||||
if (candidate && VWBExecutionService.VWB_ACTION_TYPES.has(candidate)) {
|
||||
if (candidate !== typeFromStep) {
|
||||
console.log(
|
||||
`🔧 [VWB NORMALISATION] Correction: "${typeFromStep}" → "${candidate}" pour étape ${step.id}`
|
||||
);
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
// Sinon, prendre le premier disponible
|
||||
const fallback = typeFromStep || typeFromData || typeFromVwbAction || typeFromActionId || 'unknown';
|
||||
console.log(`📋 [VWB NORMALISATION] Type résolu: "${fallback}" pour étape ${step.id}`);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifier si une étape est une action VWB
|
||||
*/
|
||||
public isVWBStep(step: Step): boolean {
|
||||
// Vérifier d'abord si le type est dans la liste des actions VWB connues
|
||||
const stepType = step.type || step.data?.stepType;
|
||||
// Utiliser la normalisation pour obtenir le vrai type
|
||||
const stepType = this.normalizeStepType(step);
|
||||
if (stepType && VWBExecutionService.VWB_ACTION_TYPES.has(stepType)) {
|
||||
return true;
|
||||
}
|
||||
@@ -137,8 +188,8 @@ export class VWBExecutionService {
|
||||
throw new Error(`L'étape ${step.id} n'est pas une action VWB`);
|
||||
}
|
||||
|
||||
// Extraire le type d'action depuis plusieurs sources possibles
|
||||
const actionId = step.type || step.data?.stepType || step.data?.vwbActionId || step.action_id || "unknown";
|
||||
// UTILISER LA NORMALISATION pour obtenir le type correct
|
||||
const actionId = this.normalizeStepType(step);
|
||||
const parameters = step.data?.parameters || {};
|
||||
|
||||
try {
|
||||
@@ -217,23 +268,55 @@ export class VWBExecutionService {
|
||||
}
|
||||
}
|
||||
|
||||
// Extraire le type d'action depuis plusieurs sources possibles
|
||||
const actionId = step.type || step.data?.stepType || step.data?.vwbActionId || step.action_id || "unknown";
|
||||
// UTILISER LA NORMALISATION pour obtenir le type correct
|
||||
const actionId = this.normalizeStepType(step);
|
||||
const parameters = this.prepareParameters(step);
|
||||
|
||||
console.log(`🎯 [VWB] Exécution étape ${step.id}: type normalisé = "${actionId}"`);
|
||||
console.log(` step.type = "${step.type}", step.data?.stepType = "${step.data?.stepType}"`);
|
||||
console.log(` Paramètres: ${Object.keys(parameters).join(', ')}`);
|
||||
|
||||
// === VALIDATION CONTRAT STRICT ===
|
||||
// BLOQUE l'exécution si le contrat n'est pas respecté
|
||||
try {
|
||||
enforceActionContract(actionId, parameters);
|
||||
} catch (contractError) {
|
||||
if (contractError instanceof ContractValidationError) {
|
||||
console.error(`🚫 [CONTRAT VIOLÉ] Action '${actionId}' bloquée!`);
|
||||
console.error(` Paramètres requis: ${getRequiredParams(actionId).join(', ')}`);
|
||||
console.error(` Paramètres fournis: ${Object.keys(parameters).join(', ')}`);
|
||||
throw new Error(
|
||||
`Contrat violé pour '${actionId}': ${contractError.violations.map(v => v.message).join('; ')}`
|
||||
);
|
||||
}
|
||||
throw contractError;
|
||||
}
|
||||
|
||||
// Exécuter l'action avec retry
|
||||
let lastError: Error | null = null;
|
||||
for (let attempt = 1; attempt <= retryAttempts; attempt++) {
|
||||
try {
|
||||
const result = await this.executeActionWithTimeout(
|
||||
actionId,
|
||||
parameters,
|
||||
actionId,
|
||||
parameters,
|
||||
timeout,
|
||||
this.currentExecution.signal
|
||||
);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// IMPORTANT: Vérifier si le résultat indique une erreur (ex: ancre non trouvée)
|
||||
// Le backend retourne status='error' ou should_stop=true quand l'action échoue
|
||||
const isError = result?.status === 'error' ||
|
||||
result?.should_stop === true ||
|
||||
result?.success === false;
|
||||
|
||||
if (isError) {
|
||||
console.log('🛑 [VWB] Erreur détectée dans le résultat:', result);
|
||||
const errorMessage = result?.error?.message || result?.error || result?.message || 'Erreur retournée par le backend';
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
// Traiter les Evidence si disponibles
|
||||
const evidence = generateEvidence ? await this.processEvidence(result, startTime) : undefined;
|
||||
|
||||
@@ -274,7 +357,7 @@ export class VWBExecutionService {
|
||||
return {
|
||||
success: false,
|
||||
stepId: step.id,
|
||||
actionId: step.type || step.data?.stepType || step.data?.vwbActionId || step.action_id || "unknown",
|
||||
actionId: this.normalizeStepType(step),
|
||||
duration,
|
||||
error: executionError
|
||||
};
|
||||
@@ -315,10 +398,40 @@ export class VWBExecutionService {
|
||||
|
||||
/**
|
||||
* Préparer les paramètres pour l'exécution
|
||||
* Gère les différents formats de données possibles (workflows sauvegardés vs créés en direct)
|
||||
*/
|
||||
private prepareParameters(step: Step): Record<string, any> {
|
||||
// Récupérer les paramètres depuis plusieurs sources possibles
|
||||
let parameters = { ...step.data?.parameters || {} };
|
||||
|
||||
// Si visual_anchor est vide mais qu'il y a un target avec des données visuelles, l'utiliser
|
||||
if (!parameters.visual_anchor && step.data?.visualSelection) {
|
||||
parameters.visual_anchor = step.data.visualSelection;
|
||||
}
|
||||
|
||||
// Si target contient des données visuelles, fusionner avec visual_anchor
|
||||
if (parameters.target && !parameters.visual_anchor) {
|
||||
parameters.visual_anchor = parameters.target;
|
||||
}
|
||||
|
||||
// S'assurer que visual_anchor a les bonnes propriétés pour les actions de clic
|
||||
if (parameters.visual_anchor) {
|
||||
const anchor = parameters.visual_anchor;
|
||||
|
||||
// Normaliser le nom de la propriété de l'image
|
||||
if (!anchor.reference_image_base64 && anchor.screenshot) {
|
||||
anchor.reference_image_base64 = anchor.screenshot;
|
||||
}
|
||||
if (!anchor.reference_image_base64 && anchor.image) {
|
||||
anchor.reference_image_base64 = anchor.image;
|
||||
}
|
||||
|
||||
// Normaliser bounding_box
|
||||
if (!anchor.bounding_box && anchor.boundingBox) {
|
||||
anchor.bounding_box = anchor.boundingBox;
|
||||
}
|
||||
}
|
||||
|
||||
// Résoudre les variables si contexte disponible
|
||||
if (this.executionContext?.variables) {
|
||||
parameters = this.resolveVariables(parameters, this.executionContext.variables);
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Service de Stockage Local des Workflows
|
||||
* Auteur : Dom, Alice, Kiro - 13 janvier 2026
|
||||
*
|
||||
* Permet de sauvegarder et charger des workflows en localStorage
|
||||
* pour fonctionner sans backend.
|
||||
*/
|
||||
|
||||
import { Workflow, Step, WorkflowConnection, Variable } from '../types';
|
||||
|
||||
export interface StoredWorkflow {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
steps: Step[];
|
||||
connections: WorkflowConnection[];
|
||||
variables: Variable[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
interface WorkflowStorageState {
|
||||
workflows: StoredWorkflow[];
|
||||
lastUpdated: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'vwb_workflows';
|
||||
const STORAGE_VERSION = '1.0.0';
|
||||
|
||||
/**
|
||||
* Service de gestion du stockage local des workflows
|
||||
*/
|
||||
class WorkflowStorageService {
|
||||
private state: WorkflowStorageState;
|
||||
|
||||
constructor() {
|
||||
this.state = this.loadFromStorage();
|
||||
console.log('💾 [WorkflowStorage] Service initialisé avec', this.state.workflows.length, 'workflows');
|
||||
}
|
||||
|
||||
/**
|
||||
* Recharger les données depuis localStorage
|
||||
*/
|
||||
public reload(): void {
|
||||
this.state = this.loadFromStorage();
|
||||
console.log('🔄 [WorkflowStorage] Rechargé:', this.state.workflows.length, 'workflows');
|
||||
}
|
||||
|
||||
/**
|
||||
* Charger depuis localStorage
|
||||
*/
|
||||
private loadFromStorage(): WorkflowStorageState {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored) as WorkflowStorageState;
|
||||
if (parsed.version === STORAGE_VERSION) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Erreur lors du chargement des workflows:', error);
|
||||
}
|
||||
|
||||
return {
|
||||
workflows: [],
|
||||
lastUpdated: new Date().toISOString(),
|
||||
version: STORAGE_VERSION,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sauvegarder dans localStorage
|
||||
*/
|
||||
private saveToStorage(): void {
|
||||
try {
|
||||
this.state.lastUpdated = new Date().toISOString();
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.state));
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la sauvegarde des workflows:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir tous les workflows
|
||||
*/
|
||||
getAllWorkflows(): StoredWorkflow[] {
|
||||
return [...this.state.workflows];
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir un workflow par ID
|
||||
*/
|
||||
getWorkflowById(id: string): StoredWorkflow | null {
|
||||
return this.state.workflows.find((w) => w.id === id) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sauvegarder un workflow
|
||||
*/
|
||||
saveWorkflow(workflow: Workflow): StoredWorkflow {
|
||||
const now = new Date().toISOString();
|
||||
const existingIndex = this.state.workflows.findIndex((w) => w.id === workflow.id);
|
||||
|
||||
const storedWorkflow: StoredWorkflow = {
|
||||
id: workflow.id || `workflow_${Date.now()}`,
|
||||
name: workflow.name,
|
||||
description: workflow.description,
|
||||
steps: workflow.steps || [],
|
||||
connections: workflow.connections || [],
|
||||
variables: workflow.variables || [],
|
||||
createdAt: existingIndex >= 0 ? this.state.workflows[existingIndex].createdAt : now,
|
||||
updatedAt: now,
|
||||
version: existingIndex >= 0 ? this.state.workflows[existingIndex].version + 1 : 1,
|
||||
};
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
this.state.workflows[existingIndex] = storedWorkflow;
|
||||
} else {
|
||||
this.state.workflows.unshift(storedWorkflow);
|
||||
}
|
||||
|
||||
this.saveToStorage();
|
||||
console.log('💾 [WorkflowStorage] Workflow sauvegardé:', storedWorkflow.name);
|
||||
return storedWorkflow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprimer un workflow
|
||||
*/
|
||||
deleteWorkflow(id: string): boolean {
|
||||
const index = this.state.workflows.findIndex((w) => w.id === id);
|
||||
if (index === -1) return false;
|
||||
|
||||
this.state.workflows.splice(index, 1);
|
||||
this.saveToStorage();
|
||||
console.log('🗑️ [WorkflowStorage] Workflow supprimé:', id);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertir en format Workflow pour l'application
|
||||
*/
|
||||
toWorkflow(stored: StoredWorkflow): Workflow {
|
||||
return {
|
||||
id: stored.id,
|
||||
name: stored.name,
|
||||
description: stored.description,
|
||||
steps: stored.steps,
|
||||
connections: stored.connections,
|
||||
variables: stored.variables,
|
||||
createdAt: new Date(stored.createdAt),
|
||||
updatedAt: new Date(stored.updatedAt),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton
|
||||
export const workflowStorageService = new WorkflowStorageService();
|
||||
export default workflowStorageService;
|
||||
5
visual_workflow_builder/frontend/src/setupTests.ts
Normal file
5
visual_workflow_builder/frontend/src/setupTests.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||
// allows you to do things like:
|
||||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom';
|
||||
31
visual_workflow_builder/frontend/src/store/index.ts
Normal file
31
visual_workflow_builder/frontend/src/store/index.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Store Redux pour la gestion d'état globale
|
||||
* Auteur : Dom, Alice, Kiro - 08 janvier 2026
|
||||
*
|
||||
* Configuration du store Redux avec Redux Toolkit pour la gestion
|
||||
* centralisée de l'état de l'application.
|
||||
*/
|
||||
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import workflowReducer from './slices/workflowSlice';
|
||||
import uiReducer from './slices/uiSlice';
|
||||
|
||||
// Configuration du store Redux
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
workflow: workflowReducer,
|
||||
ui: uiReducer,
|
||||
},
|
||||
middleware: (getDefaultMiddleware) =>
|
||||
getDefaultMiddleware({
|
||||
serializableCheck: {
|
||||
// Ignorer ces chemins pour les actions non-sérialisables
|
||||
ignoredActions: ['persist/PERSIST', 'persist/REHYDRATE'],
|
||||
},
|
||||
}),
|
||||
devTools: process.env.NODE_ENV !== 'production',
|
||||
});
|
||||
|
||||
// Types pour TypeScript
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user