Skip to content
Open
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
Binary file modified diagram-editor/dist.tar.gz
Binary file not shown.
142 changes: 142 additions & 0 deletions diagram-editor/frontend/add-operation.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { fireEvent, render, screen } from '@testing-library/react';
import AddOperation from './add-operation';

const mockCreateTransformChanges = jest.fn(() => [
{
type: 'add' as const,
item: { id: 'new-transform-node' },
},
]);

const mockOperations = [
{
key: 'transform',
label: 'Transform',
createChanges: mockCreateTransformChanges,
},
{
key: 'fork_clone',
label: 'Fork Clone',
createChanges: () => [
{
type: 'add' as const,
item: { id: 'new-fork-clone-node' },
},
],
},
];

let visibleOperations = mockOperations;
const mockEditorMode = [{ mode: 0 }];
const mockNodeManager = {
tryGetNode: () => null,
};

jest.mock('./editor-mode', () => ({
EditorMode: { Normal: 0, Template: 1 },
useEditorMode: () => mockEditorMode,
}));

jest.mock('./node-manager', () => ({
useNodeManager: () => mockNodeManager,
}));

jest.mock('./utils/add-operation-catalog', () => ({
getVisibleAddOperations: () => visibleOperations,
}));

describe('AddOperation', () => {
beforeEach(() => {
visibleOperations = mockOperations;
jest.clearAllMocks();
});

test('renders operation suggestions with corresponding icons', () => {
render(<AddOperation newNodePosition={{ x: 10, y: 20 }} />);

const transformButton = screen.getByRole('button', { name: /Transform/ });
const forkCloneButton = screen.getByRole('button', { name: /Fork Clone/ });

expect(transformButton).toBeInTheDocument();
expect(forkCloneButton).toBeInTheDocument();

const transformIcon = transformButton.querySelector(
'.material-symbols-outlined',
);
const forkCloneIcon = forkCloneButton.querySelector(
'.material-symbols-outlined',
);

expect(transformIcon).toBeInTheDocument();
expect(forkCloneIcon).toBeInTheDocument();
expect(transformIcon?.textContent).toBe('change_circle');
expect(forkCloneIcon?.textContent).toBe('content_copy');
});

test('calls onAdd with changes and primaryNodeId when an operation is clicked', () => {
const onAdd = jest.fn();

render(
<AddOperation
parentId="parent-1"
newNodePosition={{ x: 10, y: 20 }}
onAdd={onAdd}
/>,
);

fireEvent.click(screen.getByRole('button', { name: /Transform/ }));

expect(mockCreateTransformChanges).toHaveBeenCalledWith({
namespace: '',
parentId: 'parent-1',
newNodePosition: { x: 10, y: 20 },
nodeManager: mockNodeManager,
});
expect(onAdd).toHaveBeenCalledWith({
primaryNodeId: 'new-transform-node',
changes: [
{
type: 'add',
item: { id: 'new-transform-node' },
},
],
});
});

test('does not throw when clicked and onAdd is omitted', () => {
render(<AddOperation newNodePosition={{ x: 10, y: 20 }} />);
expect(() => {
fireEvent.click(screen.getByRole('button', { name: /Transform/ }));
}).not.toThrow();
});

test('filters operations by search text and shows no match message', () => {
render(<AddOperation newNodePosition={{ x: 10, y: 20 }} />);

const searchInput = screen.getByPlaceholderText('Filter operations');
fireEvent.change(searchInput, { target: { value: 'fork' } });

expect(
screen.queryByRole('button', { name: /Transform/ }),
).not.toBeInTheDocument();
expect(
screen.getByRole('button', { name: /Fork Clone/ }),
).toBeInTheDocument();

fireEvent.change(searchInput, { target: { value: 'non-existent' } });
expect(screen.queryByRole('button')).not.toBeInTheDocument();
expect(
screen.getByText('No operations match this filter.'),
).toBeInTheDocument();
});

test('shows empty state message when no operations are available', () => {
visibleOperations = [];
render(<AddOperation newNodePosition={{ x: 10, y: 20 }} />);

expect(screen.queryByRole('button')).not.toBeInTheDocument();
expect(
screen.getByText('No operations are available here yet.'),
).toBeInTheDocument();
});
});
47 changes: 4 additions & 43 deletions diagram-editor/frontend/add-operation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,57 +10,18 @@ import type { NodeAddChange, XYPosition } from '@xyflow/react';
import React from 'react';
import { EditorMode, useEditorMode } from './editor-mode';
import { useNodeManager } from './node-manager';
import type { DiagramEditorNode } from './nodes';
import {
BufferAccessIcon,
BufferIcon,
ForkCloneIcon,
ForkResultIcon,
type DiagramEditorNode,
getAddOperationIcon,
isOperationNode,
JoinIcon,
ListenIcon,
NodeIcon,
ScopeIcon,
ScriptIcon,
SectionBufferIcon,
SectionIcon,
SectionInputIcon,
SectionOutputIcon,
SplitIcon,
StreamOutIcon,
TransformIcon,
UnzipIcon,
} from './nodes';
import {
type AddOperationKey,
getVisibleAddOperations,
} from './utils/add-operation-catalog';
import { getVisibleAddOperations } from './utils/add-operation-catalog';
import { joinNamespaces, ROOT_NAMESPACE } from './utils/namespace';

const StyledOperationButton = styled(Button)({
justifyContent: 'flex-start',
});

const OPERATION_ICONS: Record<AddOperationKey, React.ReactNode> = {
sectionInput: <SectionInputIcon />,
sectionOutput: <SectionOutputIcon />,
sectionBuffer: <SectionBufferIcon />,
node: <NodeIcon />,
fork_clone: <ForkCloneIcon />,
unzip: <UnzipIcon />,
fork_result: <ForkResultIcon />,
split: <SplitIcon />,
join: <JoinIcon />,
transform: <TransformIcon />,
buffer: <BufferIcon />,
buffer_access: <BufferAccessIcon />,
listen: <ListenIcon />,
stream_out: <StreamOutIcon />,
scope: <ScopeIcon />,
section: <SectionIcon />,
script: <ScriptIcon />,
};

export interface AddOperationSelection {
primaryNodeId: string;
changes: NodeAddChange<DiagramEditorNode>[];
Expand Down Expand Up @@ -131,7 +92,7 @@ function AddOperation({ parentId, newNodePosition, onAdd }: AddOperationProps) {
{operations.map((operation) => (
<StyledOperationButton
key={operation.key}
startIcon={OPERATION_ICONS[operation.key]}
startIcon={getAddOperationIcon(operation.key)}
onClick={() => {
const changes = operation.createChanges({
namespace,
Expand Down
104 changes: 88 additions & 16 deletions diagram-editor/frontend/compatible-add-operation.test.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,53 @@
import { render, screen, waitFor } from '@testing-library/react';
import { CompatibleAddOperation } from './compatible-add-operation';

const mockCandidate = {
key: 'candidate',
label: 'Candidate operation',
createChanges: () => [
{
type: 'add',
item: { id: 'candidate-node' },
},
],
};
const mockCandidates = [
{
key: 'transform',
label: 'Transform',
createChanges: () => [
{
type: 'add',
item: { id: 'transform-node' },
},
],
},
{
key: 'fork_clone',
label: 'Fork Clone',
createChanges: () => [
{
type: 'add',
item: { id: 'fork-clone-node' },
},
],
},
{
key: 'node:calculator',
label: 'Calculator',
createChanges: () => [
{
type: 'add',
item: { id: 'calc-node' },
},
],
},
];

const mockCheckConnections = jest.fn(
async () =>
new Map([
[
'candidate',
{ id: 'candidate', status: 'compatible' as const, reason: '' },
'transform',
{ id: 'transform', status: 'compatible' as const, reason: '' },
],
[
'fork_clone',
{ id: 'fork_clone', status: 'compatible' as const, reason: '' },
],
[
'node:calculator',
{ id: 'node:calculator', status: 'compatible' as const, reason: '' },
],
]),
);
Expand Down Expand Up @@ -46,14 +77,14 @@ jest.mock('./registry-provider', () => ({

jest.mock('./utils/add-operation-catalog', () => ({
filterCompatibleAddOperations: (candidates: unknown[]) => candidates,
getAddOperationCandidates: () => [mockCandidate],
getAddOperationCandidates: () => mockCandidates,
getVisibleAddOperations: () => [],
}));

jest.mock('./utils/connection', () => ({
createConnectionFromHandles: () => ({
createConnectionFromHandles: (_source: unknown, targetId: string) => ({
source: 'source-node',
target: 'candidate-node',
target: targetId,
}),
}));

Expand All @@ -77,10 +108,51 @@ describe('CompatibleAddOperation', () => {
screen.getByText('Checking compatible operations...'),
).toBeInTheDocument();
expect(
await screen.findByRole('button', { name: /Candidate operation/ }),
await screen.findByRole('button', { name: /Transform/ }),
).toBeInTheDocument();
await waitFor(() => {
expect(onContentChange).toHaveBeenCalled();
});
});

test('renders specific icons for compatible operations and registry builders', async () => {
render(
<CompatibleAddOperation
newNodePosition={{ x: 0, y: 0 }}
sourceConnection={{
sourceNodeId: 'source-node',
sourceHandle: null,
sourceHandleType: 'source',
}}
/>,
);

const transformButton = await screen.findByRole('button', {
name: /Transform/,
});
const forkCloneButton = await screen.findByRole('button', {
name: /Fork Clone/,
});
const calculatorButton = await screen.findByRole('button', {
name: /Calculator/,
});

const transformIcon = transformButton.querySelector(
'.material-symbols-outlined',
);
const forkCloneIcon = forkCloneButton.querySelector(
'.material-symbols-outlined',
);
const calculatorIcon = calculatorButton.querySelector(
'.material-symbols-outlined',
);

expect(transformIcon).toBeInTheDocument();
expect(forkCloneIcon).toBeInTheDocument();
expect(calculatorIcon).toBeInTheDocument();

expect(transformIcon?.textContent).toBe('change_circle');
expect(forkCloneIcon?.textContent).toBe('content_copy');
expect(calculatorIcon?.textContent).toBe('line_start_circle');
});
});
4 changes: 2 additions & 2 deletions diagram-editor/frontend/compatible-add-operation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import type { AddOperationSelection } from './add-operation';
import { useCompatibilityChecker } from './connection-compatibility-provider';
import { EditorMode, useEditorMode } from './editor-mode';
import { useNodeManager } from './node-manager';
import { isOperationNode, NodeIcon } from './nodes';
import { getAddOperationIcon, isOperationNode } from './nodes';
import { useRegistry } from './registry-provider';
import {
type AddOperationCandidate,
Expand Down Expand Up @@ -221,7 +221,7 @@ export function CompatibleAddOperation({
{operations.map((operation) => (
<StyledOperationButton
key={operation.key}
startIcon={<NodeIcon />}
startIcon={getAddOperationIcon(operation.key)}
onClick={() => {
const changes = operation.createChanges({
namespace,
Expand Down
Loading