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
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ const workflowActivityVNextMessages = {
'workflowActivityVNext.editor.backAria': 'Back to workflows',
'workflowActivityVNext.editor.canvas': 'Canvas',
'workflowActivityVNext.editor.canvasAria': 'Workflow canvas',
'workflowActivityVNext.editor.canvasUpdateFailed': "Couldn't update workflow",
'workflowActivityVNext.editor.description':
'Build, test, and refine this workflow.',
'workflowActivityVNext.editor.discardLeave': 'Discard and leave',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ const workflowActivityVNextMessages: Record<keyof typeof enUSMessages, string> =
'workflowActivityVNext.editor.backAria': '返回工作流列表',
'workflowActivityVNext.editor.canvas': '画布',
'workflowActivityVNext.editor.canvasAria': '工作流画布',
'workflowActivityVNext.editor.canvasUpdateFailed': '无法更新工作流',
'workflowActivityVNext.editor.description': '构建、测试并完善这个工作流。',
'workflowActivityVNext.editor.discardLeave': '放弃并离开',
'workflowActivityVNext.editor.emptyCanvas':
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import React from 'react';
import WorkflowStudioCanvasRegion from './WorkflowStudioCanvasRegion';
import WorkflowStudioNodeLibrary from './WorkflowStudioNodeLibrary';

type WorkflowStudioCanvasRegionProps = React.ComponentProps<
typeof WorkflowStudioCanvasRegion
>;

type WorkflowStudioEditingCallbacks = Required<
Pick<
WorkflowStudioCanvasRegionProps,
| 'onCanvasSelect'
| 'onConnectNodes'
| 'onDeleteEdges'
| 'onDeleteNodes'
| 'onEdgeSelect'
| 'onNodeLayoutChange'
| 'onNodeSelect'
>
>;

type WorkflowStudioEditorSurfaceProps = Omit<
WorkflowStudioCanvasRegionProps,
keyof WorkflowStudioEditingCallbacks | 'children'
> &
WorkflowStudioEditingCallbacks & {
readonly children?: React.ReactNode;
readonly nodeLibraryOpen: boolean;
readonly onCloseNodeLibrary: () => void;
readonly onInsertNode: (stepType: string) => void;
};

const WorkflowStudioEditorSurface: React.FC<
WorkflowStudioEditorSurfaceProps
> = ({
children,
nodeLibraryOpen,
onCloseNodeLibrary,
onInsertNode,
...canvasProps
}) => (
<WorkflowStudioCanvasRegion {...canvasProps}>
<WorkflowStudioNodeLibrary
onClose={onCloseNodeLibrary}
onInsertNode={onInsertNode}
open={nodeLibraryOpen}
/>
{children}
</WorkflowStudioCanvasRegion>
);

export default WorkflowStudioEditorSurface;
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
removeStep,
removeStepConnection,
type StudioStepInspectorDraft,
suggestBranchLabelForStep,
} from '@/shared/studio/document';
import {
buildExecutionTrace,
Expand Down Expand Up @@ -233,7 +234,7 @@ type TeamMemberWorkflowStudioState = {
readonly closeNodeLibrary: () => void;
readonly closeYamlPanel: () => void;
readonly connectNodes: (sourceNodeId: string, targetNodeId: string) => void;
readonly deleteSelectedConnection: () => void;
readonly deleteSelectedConnection: (edgeId?: string) => void;
readonly deleteSelectedNode: () => void;
readonly dirty: boolean;
readonly emptyDescription: string;
Expand Down Expand Up @@ -3202,10 +3203,18 @@ export function useTeamMemberWorkflowStudio(): TeamMemberWorkflowStudioState {
return;
}

const sourceStep = editableDocument.steps?.find(
(step) => trimOptional(step.id) === sourceStepId,
);
const branchLabel = suggestBranchLabelForStep(
trimOptional(sourceStep?.type),
sourceStep?.branches ?? {},
);
const result = connectStepToTarget(
editableDocument,
sourceStepId,
targetStepId,
branchLabel,
);
setEditableDocument(result.document);
setSelectedEdgeId('');
Expand Down Expand Up @@ -3242,27 +3251,30 @@ export function useTeamMemberWorkflowStudio(): TeamMemberWorkflowStudioState {
setSelectedNodeId(result.nodeId);
markDraftDirty();
}, [editableDocument, markDraftDirty, selectedNodeId]);
const deleteSelectedConnection = React.useCallback(() => {
if (!editableDocument || !selectedEdgeId) {
return;
}
const deleteSelectedConnection = React.useCallback(
(edgeId: string = selectedEdgeId) => {
if (!editableDocument || !edgeId) {
return;
}

const connection = readConnectionFromGraphEdgeId(selectedEdgeId);
if (!connection) {
return;
}
const connection = readConnectionFromGraphEdgeId(edgeId);
if (!connection) {
return;
}

const result = removeStepConnection(
editableDocument,
connection.sourceStepId,
connection.targetStepId,
connection.branchLabel,
);
setEditableDocument(result.document);
setSelectedEdgeId('');
setSelectedNodeId('');
markDraftDirty();
}, [editableDocument, markDraftDirty, selectedEdgeId]);
const result = removeStepConnection(
editableDocument,
connection.sourceStepId,
connection.targetStepId,
connection.branchLabel,
);
setEditableDocument(result.document);
setSelectedEdgeId('');
setSelectedNodeId('');
markDraftDirty();
},
[editableDocument, markDraftDirty, selectedEdgeId],
);
const updateSelectedStepConfiguration = React.useCallback(
(parametersText: string) => {
if (!editableDocument || !selectedStepDraft) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ jest.mock('@/shared/graphs/GraphCanvas', () => ({
edges?: Array<{ id?: string }>;
onCanvasSelect?: () => void;
onConnectNodes?: (sourceNodeId: string, targetNodeId: string) => void;
onDeleteEdges?: (edgeIds: string[]) => Promise<void> | void;
onEdgeSelect?: (edgeId: string) => void;
onNodeLayoutChange?: (
nodes: Array<{ id?: string; position?: { x: number; y: number } }>,
Expand Down Expand Up @@ -91,13 +92,24 @@ jest.mock('@/shared/graphs/GraphCanvas', () => ({
),
props.edges?.map((edge) =>
React.createElement(
'button',
{
key: edge.id,
onClick: () => props.onEdgeSelect?.(String(edge.id ?? '')),
type: 'button',
},
`edge:${edge.id}`,
React.Fragment,
{ key: edge.id },
React.createElement(
'button',
{
onClick: () => props.onEdgeSelect?.(String(edge.id ?? '')),
type: 'button',
},
`edge:${edge.id}`,
),
React.createElement(
'button',
{
onClick: () => props.onDeleteEdges?.([String(edge.id ?? '')]),
type: 'button',
},
`delete edge:${edge.id}`,
),
),
),
React.createElement(
Expand Down Expand Up @@ -3102,7 +3114,94 @@ describe('TeamMemberWorkflowStudioPage', () => {
});
});

it('deletes a selected connection without deleting either node', async () => {
it('connects conditional nodes through the shared branch-aware Studio editor', async () => {
window.history.replaceState(
{},
'',
'/scopes/scope-1/teams/t-alpha/members/member-alpha/workflow?workflowId=workflow-alpha',
);
(studioApi.getMember as jest.Mock).mockResolvedValue({
implementationRef: {
implementationKind: 'workflow',
workflowId: 'workflow-alpha',
},
summary: {
createdAt: '2026-06-08T00:00:00Z',
description: '',
displayName: 'Workflow Alpha',
implementationKind: 'workflow',
lastBoundRevisionId: null,
lifecycleStage: 'created',
memberId: 'member-alpha',
publishedServiceId: '',
scopeId: 'scope-1',
teamId: 't-alpha',
updatedAt: '2026-06-08T00:00:00Z',
},
});
(studioApi.getWorkflow as jest.Mock).mockResolvedValue({
directoryId: 'scope:scope-1',
directoryLabel: 'scope-1',
draftExists: true,
fileName: 'workflow-alpha.yaml',
filePath: 'scope://scope-1/workflow-alpha.yaml',
findings: [],
layout: null,
name: 'Workflow Alpha',
workflowId: 'workflow-alpha',
yaml: 'name: Workflow Alpha\nsteps: []\n',
document: {
...mockWorkflowDocument,
steps: [
{
id: 'condition',
type: 'conditional',
targetRole: '',
parameters: {},
next: null,
branches: {},
},
{
id: 'transform',
type: 'transform',
targetRole: '',
parameters: {},
next: null,
branches: {},
},
],
},
updatedAtUtc: '2026-06-08T00:00:00Z',
});

renderWithQueryClient(React.createElement(TeamMemberWorkflowStudioPage));

await waitFor(() => {
expect(screen.getByTestId('graph-canvas')).toHaveTextContent('nodes:2');
});
fireEvent.click(
screen.getByRole('button', { name: 'connect first two nodes' }),
);
fireEvent.click(screen.getByRole('button', { name: 'Save' }));

await waitFor(() => {
expect(studioApi.serializeYaml).toHaveBeenCalledWith(
expect.objectContaining({
document: expect.objectContaining({
steps: expect.arrayContaining([
expect.objectContaining({
id: 'condition',
branches: { true: 'transform' },
next: null,
}),
]),
}),
}),
);
});
});

it('deletes the connection requested by the canvas without deleting either node', async () => {
window.history.replaceState(
{},
'',
Expand Down Expand Up @@ -3171,17 +3270,10 @@ describe('TeamMemberWorkflowStudioPage', () => {
expect(screen.getByText('nodes:2')).toBeTruthy();
});
fireEvent.click(
screen.getByRole('button', { name: 'edge:edge:triage:publish:linear' }),
screen.getByRole('button', {
name: 'delete edge:edge:triage:publish:linear',
}),
);
openMoreActionsMenu();
expect(
screen.getByRole('menuitem', { name: 'Delete selected connection' }),
).toBeTruthy();
closeOpenMenu();
const confirmSpy = jest
.spyOn(window, 'confirm')
.mockImplementation(() => true);
clickMoreAction('Delete selected connection');
expect(
screen.queryByRole('button', {
name: 'edge:edge:triage:publish:linear',
Expand All @@ -3192,10 +3284,6 @@ describe('TeamMemberWorkflowStudioPage', () => {
expect(
screen.queryByRole('button', { name: 'More workflow actions' }),
).toBeNull();
expect(confirmSpy).toHaveBeenCalledWith(
'Delete the selected connection? This cannot be undone.',
);
confirmSpy.mockRestore();
fireEvent.click(screen.getByRole('button', { name: 'Save' }));

await waitFor(() => {
Expand Down
Loading
Loading