Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .vite/deps/_metadata.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"hash": "056e6577",
"configHash": "746c5f54",
"lockfileHash": "e3b0c442",
"browserHash": "4f0a34a2",
"optimized": {},
"chunks": {}
}
Comment thread
atti92 marked this conversation as resolved.
3 changes: 3 additions & 0 deletions .vite/deps/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"type": "module"
}
Comment thread
atti92 marked this conversation as resolved.
1 change: 1 addition & 0 deletions web-editor/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ node_modules
dist
dist-ssr
*.local
.vite

# Editor directories and files
.vscode/*
Expand Down
10 changes: 9 additions & 1 deletion web-editor/src/components/ScenarioEditor/CustomNode.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -154,6 +156,12 @@ export const CustomNode = ({ data, selected }: NodeProps<Node<NodeData>>) => {
</div>
)}
</div>
{data.category && (
<div className={styles.clientBadge} title={`Client: ${data.category}`}>
<Monitor size={11} />
<span>{data.category}</span>
</div>
)}
{data.ifCondition && data.ifCondition.trim() !== '' && data.ifCondition.trim() !== 'success()' && (
<div className={styles.conditionText} title={data.ifCondition}>
<ConditionIcon condition={data.ifCondition} />
Expand Down
15 changes: 11 additions & 4 deletions web-editor/src/components/ScenarioEditor/PropertiesPanel.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -164,6 +168,9 @@ export const PropertiesPanel = ({ selectedNode, connectedNodes, allNodes, onClos
<h3>{selectedNode.data.label}</h3>
<div style={{ fontSize: '11px', color: 'var(--text-secondary)', marginBottom: '12px' }}>
<div style={{ fontFamily: 'monospace' }}>Node ID: {selectedNode.id}</div>
{selectedNode.data.category && (
<div style={{ marginTop: '4px', fontFamily: 'monospace' }}>Client: {selectedNode.data.category as string}</div>
)}
{selectedNode.data.phase && (
<div style={{ marginTop: '4px' }}>
<span style={{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,19 @@ describe('CustomNode', () => {
// 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(<CustomNode {...propsWithCategory} />, { wrapper });
expect(screen.getByText('FlightBlenderClient')).toBeInTheDocument();
expect(screen.getByTitle('Client: FlightBlenderClient')).toBeInTheDocument();
});

it('does not render client badge when category is absent', () => {
render(<CustomNode {...defaultProps} />, { wrapper });
expect(screen.queryByTitle(/^Client:/)).not.toBeInTheDocument();
});
});
1 change: 1 addition & 0 deletions web-editor/src/hooks/useScenarioGraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ export const useScenarioGraph = (initialNodesParams: Node<NodeData>[] = [], 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
Expand Down
17 changes: 17 additions & 0 deletions web-editor/src/styles/Node.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions web-editor/src/types/scenario.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export interface NodeData extends Record<string, unknown> {
label: string;
stepId?: string;
operationId?: string;
category?: string;
description?: string;
parameters?: OperationParam[];
phase?: string;
Expand Down
71 changes: 69 additions & 2 deletions web-editor/src/utils/__tests__/scenarioConversion.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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<NodeData> = {
Expand Down
15 changes: 11 additions & 4 deletions web-editor/src/utils/scenarioConversion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}));
Expand All @@ -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'],
}
Expand Down Expand Up @@ -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,
Expand Down
Loading