diff --git a/.vite/deps/_metadata.json b/.vite/deps/_metadata.json new file mode 100644 index 00000000..da1d4ca8 --- /dev/null +++ b/.vite/deps/_metadata.json @@ -0,0 +1,8 @@ +{ + "hash": "056e6577", + "configHash": "746c5f54", + "lockfileHash": "e3b0c442", + "browserHash": "4f0a34a2", + "optimized": {}, + "chunks": {} +} \ No newline at end of file diff --git a/.vite/deps/package.json b/.vite/deps/package.json new file mode 100644 index 00000000..3dbc1ca5 --- /dev/null +++ b/.vite/deps/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/web-editor/.gitignore b/web-editor/.gitignore index a547bf36..61cb0c2e 100644 --- a/web-editor/.gitignore +++ b/web-editor/.gitignore @@ -11,6 +11,7 @@ node_modules dist dist-ssr *.local +.vite # Editor directories and files .vscode/* diff --git a/web-editor/src/components/ScenarioEditor/CustomNode.tsx b/web-editor/src/components/ScenarioEditor/CustomNode.tsx index 54182fff..bd60f502 100644 --- a/web-editor/src/components/ScenarioEditor/CustomNode.tsx +++ b/web-editor/src/components/ScenarioEditor/CustomNode.tsx @@ -1,6 +1,8 @@ import { Handle, Position, type NodeProps, type Node } from '@xyflow/react'; -import { Box, CheckCircle, XCircle, AlertTriangle, Loader2, MinusCircle, RotateCw, GitBranch, Timer, Hourglass, Plane, ShieldAlert, CircleDot, Infinity as InfinityIcon } from 'lucide-react'; + +import { Box, CheckCircle, XCircle, AlertTriangle, Loader2, MinusCircle, RotateCw, GitBranch, Timer, Hourglass, Plane, Monitor, ShieldAlert, CircleDot, Infinity as InfinityIcon } from 'lucide-react'; + import styles from '../../styles/Node.module.css'; import { getPhaseColor, PHASE_LABELS } from '../../utils/phaseColors'; import type { NodeData } from '../../types/scenario'; @@ -154,6 +156,12 @@ export const CustomNode = ({ data, selected }: NodeProps>) => { )} + {data.category && ( +
+ + {data.category} +
+ )} {data.ifCondition && data.ifCondition.trim() !== '' && data.ifCondition.trim() !== 'success()' && (
diff --git a/web-editor/src/components/ScenarioEditor/PropertiesPanel.tsx b/web-editor/src/components/ScenarioEditor/PropertiesPanel.tsx index 4a898f65..63274f58 100644 --- a/web-editor/src/components/ScenarioEditor/PropertiesPanel.tsx +++ b/web-editor/src/components/ScenarioEditor/PropertiesPanel.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo, useEffect } from 'react'; +import React, { useState, useMemo } from 'react'; import { X, Link as LinkIcon, Unlink, Plane } from 'lucide-react'; import type { Node } from '@xyflow/react'; import layoutStyles from '../../styles/EditorLayout.module.css'; @@ -96,11 +96,15 @@ export const PropertiesPanel = ({ selectedNode, connectedNodes, allNodes, onClos // Local state to track when "Custom expression…" is explicitly selected const [isCustomCondition, setIsCustomCondition] = useState(false); - // Sync local state when node selection or loop items change - useEffect(() => { + // Reset transient UI state when the selected node or its loop items change. + // Using render-time state update (React docs pattern) avoids setState-in-effect lint error. + const nodeResetKey = `${selectedNode.id}::${JSON.stringify(selectedNode.data.loop?.items)}`; + const [prevResetKey, setPrevResetKey] = useState(nodeResetKey); + if (prevResetKey !== nodeResetKey) { + setPrevResetKey(nodeResetKey); setJsonItemsText(null); setIsCustomCondition(false); - }, [selectedNode.id, selectedNode.data.loop?.items]); + } const getItemsText = () => { if (jsonItemsText !== null) return jsonItemsText; @@ -164,6 +168,9 @@ export const PropertiesPanel = ({ selectedNode, connectedNodes, allNodes, onClos

{selectedNode.data.label}

Node ID: {selectedNode.id}
+ {selectedNode.data.category && ( +
Client: {selectedNode.data.category as string}
+ )} {selectedNode.data.phase && (
{ // just check if the class name includes the selected class. expect(nodeElement.classList.toString()).toContain(styles.selected || 'selected'); }); + + it('renders client badge when category is provided', () => { + const propsWithCategory = { + ...defaultProps, + data: { ...mockData, category: 'FlightBlenderClient' } + }; + render(, { wrapper }); + expect(screen.getByText('FlightBlenderClient')).toBeInTheDocument(); + expect(screen.getByTitle('Client: FlightBlenderClient')).toBeInTheDocument(); + }); + + it('does not render client badge when category is absent', () => { + render(, { wrapper }); + expect(screen.queryByTitle(/^Client:/)).not.toBeInTheDocument(); + }); }); diff --git a/web-editor/src/hooks/useScenarioGraph.ts b/web-editor/src/hooks/useScenarioGraph.ts index 6646a269..d2b4b0f5 100644 --- a/web-editor/src/hooks/useScenarioGraph.ts +++ b/web-editor/src/hooks/useScenarioGraph.ts @@ -149,6 +149,7 @@ export const useScenarioGraph = (initialNodesParams: Node[] = [], init data: { label: type, operationId: opId, + category: operation?.category, description: operation?.description, phase: operation?.phase, parameters: operation?.parameters ? JSON.parse(JSON.stringify(operation.parameters)) : [], // Deep copy parameters diff --git a/web-editor/src/styles/Node.module.css b/web-editor/src/styles/Node.module.css index 1857ee4b..fe04f06a 100644 --- a/web-editor/src/styles/Node.module.css +++ b/web-editor/src/styles/Node.module.css @@ -118,6 +118,23 @@ white-space: nowrap; } +/* Client / category badge */ +.clientBadge { + display: flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 4px; + font-size: 10px; + font-weight: 500; + color: var(--text-secondary); + background-color: rgba(107, 114, 128, 0.1); + border: 1px solid rgba(107, 114, 128, 0.2); + white-space: nowrap; + margin-top: -4px; + margin-bottom: 0; +} + .loopBadge, .conditionBadge, .backgroundBadge, diff --git a/web-editor/src/types/scenario.ts b/web-editor/src/types/scenario.ts index 24691d3a..2fe19443 100644 --- a/web-editor/src/types/scenario.ts +++ b/web-editor/src/types/scenario.ts @@ -19,6 +19,7 @@ export interface NodeData extends Record { label: string; stepId?: string; operationId?: string; + category?: string; description?: string; parameters?: OperationParam[]; phase?: string; diff --git a/web-editor/src/utils/__tests__/scenarioConversion.test.ts b/web-editor/src/utils/__tests__/scenarioConversion.test.ts index 22a23acc..8dbcfbc6 100644 --- a/web-editor/src/utils/__tests__/scenarioConversion.test.ts +++ b/web-editor/src/utils/__tests__/scenarioConversion.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; -import { convertGraphToYaml } from '../scenarioConversion'; +import { convertGraphToYaml, convertYamlToGraph } from '../scenarioConversion'; import type { Node, Edge } from '@xyflow/react'; -import type { NodeData } from '../../types/scenario'; +import type { NodeData, Operation } from '../../types/scenario'; describe('YAML Conversion - Reference Normalization', () => { it('normalizes group references to steps for top-level nodes', () => { @@ -146,6 +146,73 @@ describe('YAML Conversion - Reference Normalization', () => { }); }); +describe('YAML Conversion - Category propagation', () => { + it('convertYamlToGraph passes operation category into node data', () => { + const operations: Operation[] = [ + { id: 'FlightBlenderClient.submit', name: 'Submit Flight', description: '', parameters: [], category: 'FlightBlenderClient', phase: 'CRUISE' }, + ]; + const scenario = { + name: 'test', + steps: [{ step: 'Submit Flight', arguments: {} }], + }; + const { nodes } = convertYamlToGraph(scenario, operations); + expect(nodes).toHaveLength(1); + expect(nodes[0].data.category).toBe('FlightBlenderClient'); + }); + + it('convertYamlToGraph sets category for group child steps', () => { + const operations: Operation[] = [ + { id: 'BlueSkyClient.generate', name: 'Generate Traffic', description: '', parameters: [], category: 'BlueSkyClient' }, + ]; + const scenario = { + name: 'test', + groups: { + my_group: { + description: 'g', + steps: [{ step: 'Generate Traffic', arguments: {} }], + }, + }, + steps: [{ step: 'my_group', arguments: {} }], + }; + const { nodes } = convertYamlToGraph(scenario, operations); + const childNode = nodes.find(n => n.data.label === 'Generate Traffic'); + expect(childNode).toBeDefined(); + expect(childNode!.data.category).toBe('BlueSkyClient'); + }); + + it('convertYamlToGraph leaves category undefined when operation has none', () => { + const operations: Operation[] = [ + { id: 'Common.wait', name: 'Wait', description: '', parameters: [] }, + ]; + const scenario = { + name: 'test', + steps: [{ step: 'Wait', arguments: {} }], + }; + const { nodes } = convertYamlToGraph(scenario, operations); + expect(nodes[0].data.category).toBeUndefined(); + }); + + it('convertYamlToGraph skips group child steps with missing operations', () => { + const operations: Operation[] = []; + const scenario = { + name: 'test', + groups: { + my_group: { + description: 'g', + steps: [{ step: 'Unknown Step', arguments: {} }], + }, + }, + steps: [{ step: 'my_group', arguments: {} }], + }; + const { nodes } = convertYamlToGraph(scenario, operations); + // Group container should still be created, but no child node for the unknown step + const containerNode = nodes.find(n => n.data.isGroupContainer); + expect(containerNode).toBeDefined(); + const childNode = nodes.find(n => n.data.label === 'Unknown Step'); + expect(childNode).toBeUndefined(); + }); +}); + describe('YAML Conversion - continue-on-error', () => { it('includes continue-on-error=true in YAML output', () => { const node: Node = { diff --git a/web-editor/src/utils/scenarioConversion.ts b/web-editor/src/utils/scenarioConversion.ts index bfb3d86f..7924c5a0 100644 --- a/web-editor/src/utils/scenarioConversion.ts +++ b/web-editor/src/utils/scenarioConversion.ts @@ -108,7 +108,12 @@ export const convertYamlToGraph = ( const groupOperation = operations.find(op => op.name === groupStep.step); - const groupParameters = (groupOperation?.parameters || []).map(param => ({ + if (!groupOperation) { + console.warn(`Operation ${groupStep.step} not found in group ${step.step}`); + return; + } + + const groupParameters = (groupOperation.parameters || []).map(param => ({ ...param, default: groupStep.arguments?.[param.name] ?? param.default })); @@ -122,9 +127,10 @@ export const convertYamlToGraph = ( data: { label: groupStep.step, stepId: groupStep.id || groupStep.step, - operationId: groupOperation?.id, - description: groupStep.description || groupOperation?.description || '', - phase: groupOperation?.phase, + operationId: groupOperation.id, + category: groupOperation.category, + description: groupStep.description || groupOperation.description || '', + phase: groupOperation.phase, parameters: groupParameters, continueOnError: groupStep['continue-on-error'], } @@ -182,6 +188,7 @@ export const convertYamlToGraph = ( label: step.step, stepId: step.id, operationId: operation?.id, + category: operation?.category, description: step.description || operation?.description || '', phase: operation?.phase, parameters: parameters,