Input
diff --git a/client/src/pages/platform/workflow-editor/components/tests/ErrorsBanner.test.tsx b/client/src/pages/platform/workflow-editor/components/tests/ErrorsBanner.test.tsx
deleted file mode 100644
index 31de21220be..00000000000
--- a/client/src/pages/platform/workflow-editor/components/tests/ErrorsBanner.test.tsx
+++ /dev/null
@@ -1,82 +0,0 @@
-import {WorkflowTask} from '@/shared/middleware/platform/configuration';
-import {fireEvent, render, screen} from '@/shared/util/test-utils';
-import {beforeEach, describe, expect, it, vi} from 'vitest';
-
-import ErrorsBanner from '../ErrorsBanner';
-
-const task = (name: string): WorkflowTask => ({name, type: 'example/v1/action'}) as WorkflowTask;
-
-const hoisted = vi.hoisted(() => ({
- setShowWorkflowCodeEditorSheet: vi.fn(),
- workflowState: {workflow: {tasks: [] as WorkflowTask[], triggers: []}},
-}));
-
-vi.mock('@/pages/platform/workflow-editor/stores/useWorkflowDataStore', () => ({
- default: (selector: (state: typeof hoisted.workflowState) => unknown) => selector(hoisted.workflowState),
-}));
-
-vi.mock('@/pages/platform/workflow-editor/stores/useWorkflowEditorStore', () => {
- const state = {setShowWorkflowCodeEditorSheet: hoisted.setShowWorkflowCodeEditorSheet};
-
- return {default: (selector: (currentState: typeof state) => unknown) => selector(state)};
-});
-
-const getDismissButton = () =>
- screen.getAllByRole('button').find((button) => !button.textContent?.includes('Open code editor'));
-
-describe('DuplicateNodeNamesBanner', () => {
- beforeEach(() => {
- hoisted.setShowWorkflowCodeEditorSheet.mockClear();
- hoisted.workflowState.workflow = {tasks: [], triggers: []};
- });
-
- it('renders nothing when all node names are unique', () => {
- hoisted.workflowState.workflow = {tasks: [task('a'), task('b')], triggers: []};
-
- const {container} = render(
);
-
- expect(container).toBeEmptyDOMElement();
- });
-
- it('renders a singular label for a single duplicate name', () => {
- hoisted.workflowState.workflow = {tasks: [task('dup'), task('dup')], triggers: []};
-
- render(
);
-
- expect(screen.getByText(/Duplicate node name:/)).toBeInTheDocument();
- expect(screen.getByText('dup')).toBeInTheDocument();
- });
-
- it('renders a plural label and joins multiple duplicate names', () => {
- hoisted.workflowState.workflow = {tasks: [task('a'), task('a'), task('b'), task('b')], triggers: []};
-
- render(
);
-
- expect(screen.getByText(/Duplicate node names:/)).toBeInTheDocument();
- expect(screen.getByText('a, b')).toBeInTheDocument();
- });
-
- it('opens the code editor sheet when the action button is clicked', () => {
- hoisted.workflowState.workflow = {tasks: [task('dup'), task('dup')], triggers: []};
-
- render(
);
-
- fireEvent.click(screen.getByText('Open code editor'));
-
- expect(hoisted.setShowWorkflowCodeEditorSheet).toHaveBeenCalledWith(true);
- });
-
- it('hides the banner once it is dismissed', () => {
- hoisted.workflowState.workflow = {tasks: [task('dup'), task('dup')], triggers: []};
-
- render(
);
-
- const dismissButton = getDismissButton();
-
- expect(dismissButton).toBeDefined();
-
- fireEvent.click(dismissButton!);
-
- expect(screen.queryByText(/Duplicate node name:/)).not.toBeInTheDocument();
- });
-});
diff --git a/client/src/pages/platform/workflow-editor/components/tests/WorkflowIssuesNote.test.tsx b/client/src/pages/platform/workflow-editor/components/tests/WorkflowIssuesNote.test.tsx
new file mode 100644
index 00000000000..59d2838e0c8
--- /dev/null
+++ b/client/src/pages/platform/workflow-editor/components/tests/WorkflowIssuesNote.test.tsx
@@ -0,0 +1,57 @@
+import {render, screen} from '@/shared/util/test-utils';
+import {ReactFlowProvider} from '@xyflow/react';
+import {ReactNode} from 'react';
+import {beforeEach, describe, expect, it} from 'vitest';
+
+import {WorkflowEditorReadOnlyContext} from '../../providers/workflowEditorReadOnlyContext';
+import useWorkflowIssuesStore from '../../stores/useWorkflowIssuesStore';
+import WorkflowIssuesNote from '../WorkflowIssuesNote';
+
+const renderNote = (readOnly = false, fallback: ReactNode =
hint
) =>
+ render(
+
+
+
+
+
+ );
+
+describe('WorkflowIssuesNote', () => {
+ beforeEach(() => {
+ useWorkflowIssuesStore.getState().reset();
+ });
+
+ it('renders the fallback when there are no issues', () => {
+ renderNote();
+
+ expect(screen.getByText('hint')).toBeInTheDocument();
+ });
+
+ it('replaces the fallback with a count and opens the sidebar on View', () => {
+ useWorkflowIssuesStore.getState().setValidatorIssues([
+ {kind: 'MISSING_REQUIRED', message: 'a', nodeName: 'n_1', severity: 'ERROR', source: 'VALIDATOR'},
+ {kind: 'MISSING_REQUIRED', message: 'b', nodeName: 'n_2', severity: 'WARNING', source: 'VALIDATOR'},
+ ]);
+
+ renderNote();
+
+ expect(screen.queryByText('hint')).not.toBeInTheDocument();
+ expect(screen.getByText('1 error, 1 warning in this workflow')).toBeInTheDocument();
+
+ screen.getByRole('button', {name: 'View'}).click();
+
+ expect(useWorkflowIssuesStore.getState().issuesSidebarOpen).toBe(true);
+ });
+
+ it('renders nothing at all in read-only mode, not even the fallback', () => {
+ useWorkflowIssuesStore
+ .getState()
+ .setValidatorIssues([
+ {kind: 'MISSING_REQUIRED', message: 'a', nodeName: 'n_1', severity: 'ERROR', source: 'VALIDATOR'},
+ ]);
+
+ const {container} = renderNote(true);
+
+ expect(container).toBeEmptyDOMElement();
+ });
+});
diff --git a/client/src/pages/platform/workflow-editor/components/tests/WorkflowIssuesSidebar.test.tsx b/client/src/pages/platform/workflow-editor/components/tests/WorkflowIssuesSidebar.test.tsx
new file mode 100644
index 00000000000..ca463e751a0
--- /dev/null
+++ b/client/src/pages/platform/workflow-editor/components/tests/WorkflowIssuesSidebar.test.tsx
@@ -0,0 +1,62 @@
+import {NodeDataType} from '@/shared/types';
+import {fireEvent, render, screen} from '@/shared/util/test-utils';
+import {beforeEach, describe, expect, it, vi} from 'vitest';
+
+import useWorkflowIssuesStore from '../../stores/useWorkflowIssuesStore';
+import WorkflowIssuesSidebar from '../WorkflowIssuesSidebar';
+
+const hoisted = vi.hoisted(() => ({
+ openNodeDetails: vi.fn(),
+}));
+
+vi.mock('../../utils/openNodeDetails', () => ({default: hoisted.openNodeDetails}));
+
+const nodeData = {componentName: 'dataTable', name: 'dataTable_2', workflowNodeName: 'dataTable_2'} as NodeDataType;
+
+vi.mock('../../stores/useWorkflowDataStore', () => ({
+ default: (selector: (state: {nodes: Array<{data: NodeDataType; id: string}>}) => unknown) =>
+ selector({nodes: [{data: nodeData, id: 'dataTable_2'}]}),
+}));
+
+describe('WorkflowIssuesSidebar', () => {
+ beforeEach(() => {
+ hoisted.openNodeDetails.mockClear();
+ useWorkflowIssuesStore.getState().reset();
+ });
+
+ it('shows an empty state when there are no issues', () => {
+ render(
);
+
+ expect(screen.getByText('No issues found')).toBeInTheDocument();
+ });
+
+ it('groups issues by node and opens the node when a row is clicked', () => {
+ useWorkflowIssuesStore.getState().setValidatorIssues([
+ {
+ kind: 'MISSING_RESOURCE',
+ message: "Table does not have primary key column 'id': dt_0_conversations",
+ nodeName: 'dataTable_2',
+ propertyPath: 'table',
+ severity: 'ERROR',
+ source: 'VALIDATOR',
+ },
+ {
+ kind: 'MISSING_REQUIRED',
+ message: 'Missing required property: id',
+ nodeName: 'dataTable_2',
+ propertyPath: 'id',
+ severity: 'ERROR',
+ source: 'VALIDATOR',
+ },
+ ]);
+
+ render(
);
+
+ expect(screen.getAllByRole('heading', {level: 3})).toHaveLength(1);
+ expect(screen.getByText('dataTable_2')).toBeInTheDocument();
+
+ fireEvent.click(screen.getByText('Missing required property: id'));
+
+ expect(hoisted.openNodeDetails).toHaveBeenCalledWith(nodeData, 'properties');
+ });
+});
diff --git a/client/src/pages/platform/workflow-editor/hooks/tests/useDelayedUnmount.test.ts b/client/src/pages/platform/workflow-editor/hooks/tests/useDelayedUnmount.test.ts
new file mode 100644
index 00000000000..551b87e57e4
--- /dev/null
+++ b/client/src/pages/platform/workflow-editor/hooks/tests/useDelayedUnmount.test.ts
@@ -0,0 +1,95 @@
+import useDelayedUnmount from '@/pages/platform/workflow-editor/hooks/useDelayedUnmount';
+import {act, renderHook} from '@testing-library/react';
+import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
+
+const flushAnimationFrames = async () => {
+ await act(async () => {
+ await Promise.resolve();
+ });
+};
+
+describe('useDelayedUnmount', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+
+ vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
+ callback(0);
+
+ return 1;
+ });
+ vi.stubGlobal('cancelAnimationFrame', vi.fn());
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+ });
+
+ it('mounts and shows an element that opens', async () => {
+ const {result} = renderHook(() => useDelayedUnmount(true));
+
+ await flushAnimationFrames();
+
+ expect(result.current.mounted).toBe(true);
+ expect(result.current.visible).toBe(true);
+ });
+
+ it('keeps a closed element unmounted', () => {
+ const {result} = renderHook(() => useDelayedUnmount(false));
+
+ expect(result.current.mounted).toBe(false);
+ expect(result.current.visible).toBe(false);
+ });
+
+ it('hides before unmounting so the transition can run', async () => {
+ const {rerender, result} = renderHook(({open}) => useDelayedUnmount(open, 300), {
+ initialProps: {open: true},
+ });
+
+ await flushAnimationFrames();
+
+ rerender({open: false});
+
+ expect(result.current.visible).toBe(false);
+ expect(result.current.mounted).toBe(true);
+
+ act(() => {
+ vi.advanceTimersByTime(300);
+ });
+
+ expect(result.current.mounted).toBe(false);
+ });
+
+ it('unmounts at once when the caller asks for no exit animation', async () => {
+ const {rerender, result} = renderHook(({open}) => useDelayedUnmount(open, 0), {
+ initialProps: {open: true},
+ });
+
+ await flushAnimationFrames();
+
+ rerender({open: false});
+
+ act(() => {
+ vi.advanceTimersByTime(0);
+ });
+
+ expect(result.current.mounted).toBe(false);
+ });
+
+ it('cancels the pending unmount when it is reopened', async () => {
+ const {rerender, result} = renderHook(({open}) => useDelayedUnmount(open, 300), {
+ initialProps: {open: true},
+ });
+
+ await flushAnimationFrames();
+
+ rerender({open: false});
+ rerender({open: true});
+
+ act(() => {
+ vi.advanceTimersByTime(300);
+ });
+
+ expect(result.current.mounted).toBe(true);
+ });
+});
diff --git a/client/src/pages/platform/workflow-editor/hooks/tests/useFlowCenterOffset.test.ts b/client/src/pages/platform/workflow-editor/hooks/tests/useFlowCenterOffset.test.ts
new file mode 100644
index 00000000000..148cf5f6af5
--- /dev/null
+++ b/client/src/pages/platform/workflow-editor/hooks/tests/useFlowCenterOffset.test.ts
@@ -0,0 +1,96 @@
+import useFlowCenterOffset from '@/pages/platform/workflow-editor/hooks/useFlowCenterOffset';
+import useLayoutDirectionStore from '@/pages/platform/workflow-editor/stores/useLayoutDirectionStore';
+import {renderHook} from '@testing-library/react';
+import {beforeEach, describe, expect, it, vi} from 'vitest';
+
+const {useStoreMock} = vi.hoisted(() => ({useStoreMock: vi.fn()}));
+
+vi.mock('@xyflow/react', () => ({
+ useStore: (selector: (state: unknown) => unknown) => useStoreMock(selector),
+}));
+
+interface BoxI {
+ left: number;
+ top: number;
+ width: number;
+}
+
+const createBox = ({left, top, width}: BoxI) => ({
+ getBoundingClientRect: () => ({left, right: left + width, top, width}),
+});
+
+const createDomNode = (boxes: Array
, containerLeft = 0, containerWidth = 1000) => ({
+ getBoundingClientRect: () => ({left: containerLeft, width: containerWidth}),
+ querySelectorAll: () =>
+ boxes.map((box) => ({
+ querySelector: (selector: string) => (selector === '[data-node-box]' ? createBox(box) : null),
+ })),
+});
+
+const createDomNodeWithoutBoxes = () => ({
+ getBoundingClientRect: () => ({left: 0, width: 1000}),
+ querySelectorAll: () => [{querySelector: () => null}],
+});
+
+const mockStore = (domNode: unknown) => {
+ useStoreMock.mockImplementation((selector: (state: never) => unknown) =>
+ selector({
+ domNode,
+ nodeLookup: new Map([['node_1', {position: {x: 10}}]]),
+ transform: [0, 0, 1],
+ width: 1000,
+ } as never)
+ );
+};
+
+describe('useFlowCenterOffset', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ useLayoutDirectionStore.setState({layoutDirection: 'TB'});
+ });
+
+ it('is zero before the flow has a container', () => {
+ mockStore(null);
+
+ const {result} = renderHook(() => useFlowCenterOffset());
+
+ expect(result.current).toBe(0);
+ });
+
+ it('is zero when no node renders a box', () => {
+ mockStore(createDomNodeWithoutBoxes());
+
+ const {result} = renderHook(() => useFlowCenterOffset());
+
+ expect(result.current).toBe(0);
+ });
+
+ it('measures the topmost box top to bottom', () => {
+ mockStore(
+ createDomNode([
+ {left: 600, top: 100, width: 100},
+ {left: 200, top: 20, width: 100},
+ ])
+ );
+
+ const {result} = renderHook(() => useFlowCenterOffset());
+
+ expect(result.current).toBe(-250);
+ });
+
+ it('measures the whole row left to right', () => {
+ useLayoutDirectionStore.setState({layoutDirection: 'LR'});
+
+ mockStore(
+ createDomNode([
+ {left: 600, top: 100, width: 100},
+ {left: 200, top: 20, width: 100},
+ ])
+ );
+
+ const {result} = renderHook(() => useFlowCenterOffset());
+
+ expect(result.current).toBe(-50);
+ });
+});
diff --git a/client/src/pages/platform/workflow-editor/hooks/tests/useNodeClick.test.ts b/client/src/pages/platform/workflow-editor/hooks/tests/useNodeClick.test.ts
index 1272cdb8790..fcdcbc95662 100644
--- a/client/src/pages/platform/workflow-editor/hooks/tests/useNodeClick.test.ts
+++ b/client/src/pages/platform/workflow-editor/hooks/tests/useNodeClick.test.ts
@@ -14,10 +14,20 @@ vi.mock('../../cluster-element-editor/stores/useClusterElementsDataStore', () =>
}));
vi.mock('../../stores/useWorkflowEditorStore', () => ({
- default: () => ({
- clusterElementsCanvasOpen: false,
- setClusterElementsCanvasOpen: vi.fn(),
- }),
+ default: Object.assign(
+ () => ({
+ clusterElementsCanvasOpen: false,
+ setClusterElementsCanvasOpen: vi.fn(),
+ }),
+ {
+ getState: () => ({
+ clusterElementsCanvasOpen: false,
+ setClusterElementsCanvasOpen: vi.fn(),
+ }),
+ setState: vi.fn(),
+ subscribe: vi.fn(),
+ }
+ ),
}));
vi.mock('@/pages/platform/workflow-editor/stores/useDataPillPanelStore', () => ({
diff --git a/client/src/pages/platform/workflow-editor/hooks/tests/useOverlayPanelsViewport.test.ts b/client/src/pages/platform/workflow-editor/hooks/tests/useOverlayPanelsViewport.test.ts
new file mode 100644
index 00000000000..bbf320debbe
--- /dev/null
+++ b/client/src/pages/platform/workflow-editor/hooks/tests/useOverlayPanelsViewport.test.ts
@@ -0,0 +1,233 @@
+import useOverlayPanelsViewport, {
+ OVERLAY_PAN_DURATION,
+} from '@/pages/platform/workflow-editor/hooks/useOverlayPanelsViewport';
+import useDataPillPanelStore from '@/pages/platform/workflow-editor/stores/useDataPillPanelStore';
+import useRightSidebarStore from '@/pages/platform/workflow-editor/stores/useRightSidebarStore';
+import useWorkflowEditorStore from '@/pages/platform/workflow-editor/stores/useWorkflowEditorStore';
+import useWorkflowIssuesStore from '@/pages/platform/workflow-editor/stores/useWorkflowIssuesStore';
+import useWorkflowNodeDetailsPanelStore from '@/pages/platform/workflow-editor/stores/useWorkflowNodeDetailsPanelStore';
+import useWorkflowTestChatStore from '@/pages/platform/workflow-editor/stores/useWorkflowTestChatStore';
+import {DATA_PILL_PANEL_WIDTH, NODE_DETAILS_PANEL_WIDTH, WORKFLOW_NODES_SIDEBAR_WIDTH} from '@/shared/constants';
+import {act, renderHook} from '@testing-library/react';
+import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
+
+interface ViewportI {
+ x: number;
+ y: number;
+ zoom: number;
+}
+
+const {setViewportMock, storeState} = vi.hoisted(() => ({
+ setViewportMock: vi.fn<(viewport: ViewportI, options?: {duration?: number}) => Promise>(() =>
+ Promise.resolve(true)
+ ),
+ storeState: {
+ transform: [0, 40, 1] as [number, number, number],
+ },
+}));
+
+vi.mock('@xyflow/react', () => ({
+ useReactFlow: () => ({setViewport: setViewportMock}),
+ useStoreApi: () => ({getState: () => storeState}),
+}));
+
+const SIDEBAR_OFFSET = -WORKFLOW_NODES_SIDEBAR_WIDTH / 2;
+const DETAILS_OFFSET = -NODE_DETAILS_PANEL_WIDTH / 2;
+const DATA_PILL_OFFSET = -DATA_PILL_PANEL_WIDTH / 2;
+
+function lastViewport() {
+ const calls = setViewportMock.mock.calls;
+
+ return calls[calls.length - 1];
+}
+
+function settleAt(x: number) {
+ act(() => {
+ vi.advanceTimersByTime(OVERLAY_PAN_DURATION);
+ });
+
+ storeState.transform = [x, 40, 1];
+}
+
+describe('useOverlayPanelsViewport', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ setViewportMock.mockClear();
+
+ storeState.transform = [0, 40, 1];
+
+ useRightSidebarStore.setState({rightSidebarOpen: false});
+ useWorkflowIssuesStore.setState({issuesSidebarOpen: false});
+ useWorkflowTestChatStore.setState({workflowTestChatPanelOpen: false});
+ useDataPillPanelStore.setState({dataPillPanelOpen: false});
+ useWorkflowEditorStore.setState({clusterElementsCanvasOpen: false});
+ useWorkflowNodeDetailsPanelStore.setState({workflowNodeDetailsPanelOpen: false});
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('leaves the viewport alone when nothing is open', () => {
+ renderHook(() => useOverlayPanelsViewport({enabled: true}));
+
+ expect(setViewportMock).not.toHaveBeenCalled();
+ });
+
+ it('applies an overlay that is already open on mount without animating', () => {
+ useRightSidebarStore.setState({rightSidebarOpen: true});
+
+ const {result} = renderHook(() => useOverlayPanelsViewport({enabled: true}));
+
+ expect(setViewportMock).toHaveBeenCalledTimes(1);
+ expect(lastViewport()).toEqual([{x: SIDEBAR_OFFSET, y: 40, zoom: 1}]);
+ expect(result.current.getViewportOffsetX()).toBe(SIDEBAR_OFFSET);
+ });
+
+ it('re-centres by half the sidebar width and pans back on close', () => {
+ renderHook(() => useOverlayPanelsViewport({enabled: true}));
+
+ act(() => {
+ useRightSidebarStore.setState({rightSidebarOpen: true});
+ });
+
+ const [viewport, options] = lastViewport();
+
+ expect(viewport).toEqual({x: SIDEBAR_OFFSET, y: 40, zoom: 1});
+ expect(options).toMatchObject({duration: OVERLAY_PAN_DURATION});
+
+ settleAt(SIDEBAR_OFFSET);
+
+ act(() => {
+ useRightSidebarStore.setState({rightSidebarOpen: false});
+ });
+
+ expect(setViewportMock).toHaveBeenCalledTimes(2);
+ expect(lastViewport()[0]).toEqual({x: 0, y: 40, zoom: 1});
+ });
+
+ it('moves the flow left by half the node details panel width and back on close', () => {
+ const {result} = renderHook(() => useOverlayPanelsViewport({enabled: true}));
+
+ act(() => {
+ useWorkflowNodeDetailsPanelStore.setState({workflowNodeDetailsPanelOpen: true});
+ });
+
+ expect(lastViewport()[0]).toEqual({x: DETAILS_OFFSET, y: 40, zoom: 1});
+ expect(result.current.getViewportOffsetX()).toBe(DETAILS_OFFSET);
+
+ settleAt(DETAILS_OFFSET);
+
+ act(() => {
+ useWorkflowNodeDetailsPanelStore.setState({workflowNodeDetailsPanelOpen: false});
+ });
+
+ expect(lastViewport()[0]).toEqual({x: 0, y: 40, zoom: 1});
+ expect(result.current.getViewportOffsetX()).toBe(0);
+ });
+
+ it('pushes further for the data pill panel and slides back when it closes', () => {
+ useWorkflowNodeDetailsPanelStore.setState({workflowNodeDetailsPanelOpen: true});
+
+ renderHook(() => useOverlayPanelsViewport({enabled: true}));
+
+ storeState.transform = [DETAILS_OFFSET, 40, 1];
+
+ act(() => {
+ useDataPillPanelStore.setState({dataPillPanelOpen: true});
+ });
+
+ expect(lastViewport()[0]).toEqual({x: DETAILS_OFFSET + DATA_PILL_OFFSET, y: 40, zoom: 1});
+
+ settleAt(DETAILS_OFFSET + DATA_PILL_OFFSET);
+
+ act(() => {
+ useDataPillPanelStore.setState({dataPillPanelOpen: false});
+ });
+
+ expect(lastViewport()[0]).toEqual({x: DETAILS_OFFSET, y: 40, zoom: 1});
+ });
+
+ it('keeps a user pan made between toggles', () => {
+ renderHook(() => useOverlayPanelsViewport({enabled: true}));
+
+ act(() => {
+ useWorkflowIssuesStore.setState({issuesSidebarOpen: true});
+ });
+
+ settleAt(SIDEBAR_OFFSET + 500);
+
+ act(() => {
+ useWorkflowIssuesStore.setState({issuesSidebarOpen: false});
+ });
+
+ expect(lastViewport()[0].x).toBe(500);
+ });
+
+ it('combines the sidebar release and the details pan on the issues-to-details handoff', () => {
+ useWorkflowIssuesStore.setState({issuesSidebarOpen: true});
+
+ renderHook(() => useOverlayPanelsViewport({enabled: true}));
+
+ storeState.transform = [SIDEBAR_OFFSET, 40, 1];
+ setViewportMock.mockClear();
+
+ act(() => {
+ useWorkflowIssuesStore.setState({issuesSidebarOpen: false});
+ useWorkflowNodeDetailsPanelStore.setState({workflowNodeDetailsPanelOpen: true});
+ });
+
+ expect(setViewportMock).toHaveBeenCalledTimes(1);
+ expect(lastViewport()[0]).toEqual({x: DETAILS_OFFSET, y: 40, zoom: 1});
+
+ settleAt(DETAILS_OFFSET);
+
+ act(() => {
+ useWorkflowNodeDetailsPanelStore.setState({workflowNodeDetailsPanelOpen: false});
+ });
+
+ expect(lastViewport()[0]).toEqual({x: 0, y: 40, zoom: 1});
+ });
+
+ it('continues from the pending target when a toggle interrupts an animation', () => {
+ renderHook(() => useOverlayPanelsViewport({enabled: true}));
+
+ act(() => {
+ useRightSidebarStore.setState({rightSidebarOpen: true});
+ });
+
+ storeState.transform = [-50, 40, 1];
+
+ act(() => {
+ useRightSidebarStore.setState({rightSidebarOpen: false});
+ });
+
+ expect(lastViewport()[0]).toEqual({x: 0, y: 40, zoom: 1});
+ });
+
+ it('does nothing while disabled', () => {
+ useRightSidebarStore.setState({rightSidebarOpen: true});
+
+ renderHook(() => useOverlayPanelsViewport({enabled: false}));
+
+ expect(setViewportMock).not.toHaveBeenCalled();
+ });
+
+ it('waits while the cluster elements canvas covers the graph and reconciles once it closes', () => {
+ useWorkflowEditorStore.setState({clusterElementsCanvasOpen: true});
+
+ renderHook(() => useOverlayPanelsViewport({enabled: true}));
+
+ act(() => {
+ useRightSidebarStore.setState({rightSidebarOpen: true});
+ });
+
+ expect(setViewportMock).not.toHaveBeenCalled();
+
+ act(() => {
+ useWorkflowEditorStore.setState({clusterElementsCanvasOpen: false});
+ });
+
+ expect(lastViewport()).toEqual([{x: SIDEBAR_OFFSET, y: 40, zoom: 1}]);
+ });
+});
diff --git a/client/src/pages/platform/workflow-editor/hooks/tests/useWorkflowIssuesSweep.test.ts b/client/src/pages/platform/workflow-editor/hooks/tests/useWorkflowIssuesSweep.test.ts
new file mode 100644
index 00000000000..f1ad002effb
--- /dev/null
+++ b/client/src/pages/platform/workflow-editor/hooks/tests/useWorkflowIssuesSweep.test.ts
@@ -0,0 +1,60 @@
+import useWorkflowIssuesSweep from '@/pages/platform/workflow-editor/hooks/useWorkflowIssuesSweep';
+import useWorkflowDataStore from '@/pages/platform/workflow-editor/stores/useWorkflowDataStore';
+import useWorkflowIssuesStore from '@/pages/platform/workflow-editor/stores/useWorkflowIssuesStore';
+import {renderHook} from '@testing-library/react';
+import {beforeEach, describe, expect, it} from 'vitest';
+
+describe('useWorkflowIssuesSweep', () => {
+ beforeEach(() => {
+ useWorkflowIssuesStore.setState({sweepIssues: []});
+ });
+
+ it('reports nothing for a workflow whose references all resolve', () => {
+ useWorkflowDataStore.setState({
+ workflow: {
+ inputs: [],
+ tasks: [{label: 'One', name: 'task_1', parameters: {}, type: 'acme/v1/one'}],
+ triggers: [],
+ },
+ } as never);
+
+ renderHook(() => useWorkflowIssuesSweep());
+
+ expect(useWorkflowIssuesStore.getState().sweepIssues).toEqual([]);
+ });
+
+ it('reports a reference to a node the workflow does not have', () => {
+ useWorkflowDataStore.setState({
+ workflow: {
+ inputs: [],
+ tasks: [{label: 'One', name: 'task_1', parameters: {value: '${missing_1.id}'}, type: 'acme/v1/one'}],
+ triggers: [],
+ },
+ } as never);
+
+ renderHook(() => useWorkflowIssuesSweep());
+
+ const sweepIssues = useWorkflowIssuesStore.getState().sweepIssues;
+
+ expect(sweepIssues).toHaveLength(1);
+ expect(sweepIssues[0].nodeName).toBe('task_1');
+ expect(sweepIssues[0].kind).toBe('BROKEN_REFERENCE');
+ });
+
+ it('reports a duplicate node name', () => {
+ useWorkflowDataStore.setState({
+ workflow: {
+ inputs: [],
+ tasks: [
+ {label: 'One', name: 'task_1', parameters: {}, type: 'acme/v1/one'},
+ {label: 'Two', name: 'task_1', parameters: {}, type: 'acme/v1/two'},
+ ],
+ triggers: [],
+ },
+ } as never);
+
+ renderHook(() => useWorkflowIssuesSweep());
+
+ expect(useWorkflowIssuesStore.getState().sweepIssues[0].kind).toBe('DUPLICATE_NODE_NAME');
+ });
+});
diff --git a/client/src/pages/platform/workflow-editor/hooks/tests/useWorkflowIssuesValidation.test.ts b/client/src/pages/platform/workflow-editor/hooks/tests/useWorkflowIssuesValidation.test.ts
new file mode 100644
index 00000000000..730ed5ce855
--- /dev/null
+++ b/client/src/pages/platform/workflow-editor/hooks/tests/useWorkflowIssuesValidation.test.ts
@@ -0,0 +1,109 @@
+import {renderHook} from '@testing-library/react';
+import {beforeEach, describe, expect, it, vi} from 'vitest';
+
+import useWorkflowIssuesStore from '../../stores/useWorkflowIssuesStore';
+import useWorkflowIssuesValidation from '../useWorkflowIssuesValidation';
+
+const hoisted = vi.hoisted(() => ({
+ queryResult: {data: undefined as unknown},
+ queryVariables: [] as Array,
+ workflowState: {workflow: {definition: '{"tasks":[]}', id: 'wf-1'}},
+}));
+
+vi.mock('@/shared/middleware/graphql', () => ({
+ useValidateWorkflowQuery: (variables: unknown) => {
+ hoisted.queryVariables.push(variables);
+
+ return hoisted.queryResult;
+ },
+}));
+
+vi.mock('@/shared/stores/useEnvironmentStore', () => ({
+ useEnvironmentStore: (selector: (state: {currentEnvironmentId: number}) => unknown) =>
+ selector({currentEnvironmentId: 2}),
+}));
+
+vi.mock('../../stores/useWorkflowDataStore', () => ({
+ default: (selector: (state: typeof hoisted.workflowState) => unknown) => selector(hoisted.workflowState),
+}));
+
+describe('useWorkflowIssuesValidation', () => {
+ beforeEach(() => {
+ hoisted.queryVariables = [];
+ hoisted.queryResult = {data: undefined};
+ hoisted.workflowState = {workflow: {definition: '{"tasks":[]}', id: 'wf-1'}};
+ useWorkflowIssuesStore.getState().reset();
+ });
+
+ it('queries with the definition and the selected environment', () => {
+ renderHook(() => useWorkflowIssuesValidation());
+
+ expect(hoisted.queryVariables[0]).toEqual({environmentId: 2, workflowDefinition: '{"tasks":[]}'});
+ });
+
+ it('stores node issues from the response as validator issues', () => {
+ hoisted.queryResult = {
+ data: {
+ validateWorkflow: {
+ errors: [],
+ nodeIssues: [
+ {
+ kind: 'MISSING_RESOURCE',
+ message: 'gone',
+ nodeName: 'dataTable_1',
+ propertyPath: 'table',
+ severity: 'ERROR',
+ },
+ ],
+ warnings: [],
+ },
+ },
+ };
+
+ renderHook(() => useWorkflowIssuesValidation());
+
+ expect(useWorkflowIssuesStore.getState().validatorIssues).toEqual([
+ {
+ kind: 'MISSING_RESOURCE',
+ message: 'gone',
+ nodeName: 'dataTable_1',
+ propertyPath: 'table',
+ severity: 'ERROR',
+ source: 'VALIDATOR',
+ },
+ ]);
+ });
+
+ it('clears live issues when the definition changes', () => {
+ const {rerender} = renderHook(() => useWorkflowIssuesValidation());
+
+ useWorkflowIssuesStore.getState().recordLookupFailure('dataTable_1', 'table', 'stale');
+
+ hoisted.workflowState = {workflow: {definition: '{"tasks":[{}]}', id: 'wf-1'}};
+
+ rerender();
+
+ expect(useWorkflowIssuesStore.getState().liveIssues).toEqual({});
+ });
+
+ it('clears validator issues when the definition changes', () => {
+ const {rerender} = renderHook(() => useWorkflowIssuesValidation());
+
+ useWorkflowIssuesStore.getState().setValidatorIssues([
+ {
+ kind: 'MISSING_RESOURCE',
+ message: 'gone',
+ nodeName: 'dataTable_1',
+ propertyPath: 'table',
+ severity: 'ERROR',
+ source: 'VALIDATOR',
+ },
+ ]);
+
+ hoisted.workflowState = {workflow: {definition: '{"tasks":[{}]}', id: 'wf-1'}};
+
+ rerender();
+
+ expect(useWorkflowIssuesStore.getState().validatorIssues).toEqual([]);
+ });
+});
diff --git a/client/src/pages/platform/workflow-editor/hooks/useDelayedUnmount.ts b/client/src/pages/platform/workflow-editor/hooks/useDelayedUnmount.ts
new file mode 100644
index 00000000000..b47f42e913b
--- /dev/null
+++ b/client/src/pages/platform/workflow-editor/hooks/useDelayedUnmount.ts
@@ -0,0 +1,42 @@
+import {useEffect, useState} from 'react';
+
+export default function useDelayedUnmount(open: boolean, durationMs = 300): {mounted: boolean; visible: boolean} {
+ const [mounted, setMounted] = useState(open);
+ const [visible, setVisible] = useState(open);
+
+ useEffect(() => {
+ let outerRafId: number | undefined;
+ let innerRafId: number | undefined;
+ let timerId: ReturnType | undefined;
+
+ if (open) {
+ setMounted(true);
+
+ outerRafId = requestAnimationFrame(() => {
+ innerRafId = requestAnimationFrame(() => {
+ setVisible(true);
+ });
+ });
+ } else {
+ setVisible(false);
+
+ timerId = setTimeout(() => setMounted(false), durationMs);
+ }
+
+ return () => {
+ if (outerRafId !== undefined) {
+ cancelAnimationFrame(outerRafId);
+ }
+
+ if (innerRafId !== undefined) {
+ cancelAnimationFrame(innerRafId);
+ }
+
+ if (timerId !== undefined) {
+ clearTimeout(timerId);
+ }
+ };
+ }, [durationMs, open]);
+
+ return {mounted, visible};
+}
diff --git a/client/src/pages/platform/workflow-editor/hooks/useFlowCenterOffset.ts b/client/src/pages/platform/workflow-editor/hooks/useFlowCenterOffset.ts
new file mode 100644
index 00000000000..daedd1931d8
--- /dev/null
+++ b/client/src/pages/platform/workflow-editor/hooks/useFlowCenterOffset.ts
@@ -0,0 +1,72 @@
+import {useStore} from '@xyflow/react';
+import {useLayoutEffect, useState} from 'react';
+
+import useLayoutDirectionStore from '../stores/useLayoutDirectionStore';
+
+export default function useFlowCenterOffset(): number {
+ const [offset, setOffset] = useState(0);
+
+ const layoutDirection = useLayoutDirectionStore((state) => state.layoutDirection);
+
+ const containerWidth = useStore((state) => state.width);
+ const domNode = useStore((state) => state.domNode);
+ const transform = useStore((state) => state.transform.join());
+ const nodePositions = useStore((state) => {
+ let positions = '';
+
+ for (const node of state.nodeLookup.values()) {
+ positions += `${node.position.x},`;
+ }
+
+ return positions;
+ });
+
+ useLayoutEffect(() => {
+ if (!domNode) {
+ return;
+ }
+
+ const nodeElements = domNode.querySelectorAll('.react-flow__node[data-id]');
+
+ let topBox: HTMLElement | undefined;
+ let topY = Number.POSITIVE_INFINITY;
+ let minLeft = Number.POSITIVE_INFINITY;
+ let maxRight = Number.NEGATIVE_INFINITY;
+
+ for (const nodeElement of nodeElements) {
+ const box = nodeElement.querySelector('[data-node-box]');
+
+ if (!box) {
+ continue;
+ }
+
+ const {left, right, top} = box.getBoundingClientRect();
+
+ minLeft = Math.min(minLeft, left);
+ maxRight = Math.max(maxRight, right);
+
+ if (top < topY) {
+ topY = top;
+ topBox = box;
+ }
+ }
+
+ if (!topBox) {
+ setOffset(0);
+
+ return;
+ }
+
+ const containerRect = domNode.getBoundingClientRect();
+ const containerCenterX = containerRect.left + containerRect.width / 2;
+
+ const flowCenterX =
+ layoutDirection === 'LR'
+ ? (minLeft + maxRight) / 2
+ : topBox.getBoundingClientRect().left + topBox.getBoundingClientRect().width / 2;
+
+ setOffset(Math.round(flowCenterX - containerCenterX));
+ }, [containerWidth, domNode, layoutDirection, nodePositions, transform]);
+
+ return offset;
+}
diff --git a/client/src/pages/platform/workflow-editor/hooks/useLayout.tsx b/client/src/pages/platform/workflow-editor/hooks/useLayout.tsx
index 362d9a25710..d6c15ed4cf2 100644
--- a/client/src/pages/platform/workflow-editor/hooks/useLayout.tsx
+++ b/client/src/pages/platform/workflow-editor/hooks/useLayout.tsx
@@ -1,15 +1,12 @@
import {
COPILOT_PANEL_WIDTH,
- DATA_PILL_PANEL_WIDTH,
EDGE_STYLES,
FINAL_PLACEHOLDER_NODE_ID,
LayoutDirectionType,
- NODE_DETAILS_PANEL_WIDTH,
ON_ERROR_WIRE_KEY_ERROR_BRANCH,
ON_ERROR_WIRE_KEY_MAIN_BRANCH,
PROJECT_LEFT_SIDEBAR_WIDTH,
TASK_DISPATCHER_NAMES,
- WORKFLOW_NODES_SIDEBAR_WIDTH,
} from '@/shared/constants';
import {
ComponentDefinitionBasic,
@@ -24,13 +21,9 @@ import {useEffect, useMemo, useRef} from 'react';
import {useShallow} from 'zustand/react/shallow';
import {useStoreWithEqualityFn} from 'zustand/traditional';
-import useDataPillPanelStore from '../stores/useDataPillPanelStore';
import useLayoutDirectionStore from '../stores/useLayoutDirectionStore';
-import useRightSidebarStore from '../stores/useRightSidebarStore';
import useWorkflowDataStore from '../stores/useWorkflowDataStore';
import useWorkflowEditorStore from '../stores/useWorkflowEditorStore';
-import useWorkflowNodeDetailsPanelStore from '../stores/useWorkflowNodeDetailsPanelStore';
-import useWorkflowTestChatStore from '../stores/useWorkflowTestChatStore';
import animateNodePositions from '../utils/animateNodePositions';
import createBranchEdges from '../utils/createBranchEdges';
import createBranchNode from '../utils/createBranchNode';
@@ -175,12 +168,6 @@ export default function useLayout({
setSavedPositionCrossAxisShift: state.setSavedPositionCrossAxisShift,
}))
);
- const dataPillPanelOpen = useDataPillPanelStore((state) => state.dataPillPanelOpen);
- const workflowNodeDetailsPanelOpen = useWorkflowNodeDetailsPanelStore(
- (state) => state.workflowNodeDetailsPanelOpen
- );
- const workflowTestChatPanelOpen = useWorkflowTestChatStore((state) => state.workflowTestChatPanelOpen);
- const rightSidebarOpen = useRightSidebarStore((state) => state.rightSidebarOpen);
const layoutResetCounter = useWorkflowDataStore((state) => state.layoutResetCounter);
const cancelAnimationRef = useRef<(() => void) | null>(null);
@@ -190,11 +177,7 @@ export default function useLayout({
const canvasWidthRef = useRef(canvasWidth);
const canvasHeightRef = useRef(canvasHeight);
const previousCopilotPanelOpenRef = useRef(undefined);
- const previousDataPillPanelOpenRef = useRef(undefined);
- const previousNodeDetailsPanelOpenRef = useRef(undefined);
- const previousTestChatPanelOpenRef = useRef(undefined);
const previousLeftSidebarOpenRef = useRef(undefined);
- const previousRightSidebarOpenRef = useRef(undefined);
canvasWidthRef.current = canvasWidth;
canvasHeightRef.current = canvasHeight;
@@ -640,11 +623,7 @@ export default function useLayout({
useEffect(() => {
if (!useWorkflowDataStore.getState().isWorkflowLoaded) {
previousCopilotPanelOpenRef.current = copilotPanelOpen;
- previousDataPillPanelOpenRef.current = dataPillPanelOpen;
- previousNodeDetailsPanelOpenRef.current = workflowNodeDetailsPanelOpen;
- previousTestChatPanelOpenRef.current = workflowTestChatPanelOpen;
previousLeftSidebarOpenRef.current = leftSidebarOpen;
- previousRightSidebarOpenRef.current = rightSidebarOpen;
return;
}
@@ -667,27 +646,6 @@ export default function useLayout({
widthDelta += copilotPanelOpen ? COPILOT_PANEL_WIDTH : -COPILOT_PANEL_WIDTH;
}
- if (
- previousNodeDetailsPanelOpenRef.current !== undefined &&
- previousNodeDetailsPanelOpenRef.current !== workflowNodeDetailsPanelOpen
- ) {
- widthDelta += workflowNodeDetailsPanelOpen ? NODE_DETAILS_PANEL_WIDTH : -NODE_DETAILS_PANEL_WIDTH;
- }
-
- if (
- previousTestChatPanelOpenRef.current !== undefined &&
- previousTestChatPanelOpenRef.current !== workflowTestChatPanelOpen
- ) {
- widthDelta += workflowTestChatPanelOpen ? NODE_DETAILS_PANEL_WIDTH : -NODE_DETAILS_PANEL_WIDTH;
- }
-
- if (
- previousDataPillPanelOpenRef.current !== undefined &&
- previousDataPillPanelOpenRef.current !== dataPillPanelOpen
- ) {
- widthDelta += dataPillPanelOpen ? DATA_PILL_PANEL_WIDTH : -DATA_PILL_PANEL_WIDTH;
- }
-
if (
previousLeftSidebarOpenRef.current !== undefined &&
previousLeftSidebarOpenRef.current !== leftSidebarOpen
@@ -695,31 +653,13 @@ export default function useLayout({
widthDelta += leftSidebarOpen ? PROJECT_LEFT_SIDEBAR_WIDTH : -PROJECT_LEFT_SIDEBAR_WIDTH;
}
- if (
- previousRightSidebarOpenRef.current !== undefined &&
- previousRightSidebarOpenRef.current !== rightSidebarOpen
- ) {
- widthDelta += rightSidebarOpen ? WORKFLOW_NODES_SIDEBAR_WIDTH : -WORKFLOW_NODES_SIDEBAR_WIDTH;
- }
+ previousCopilotPanelOpenRef.current = copilotPanelOpen;
+ previousLeftSidebarOpenRef.current = leftSidebarOpen;
if (widthDelta === 0) {
- previousCopilotPanelOpenRef.current = copilotPanelOpen;
- previousDataPillPanelOpenRef.current = dataPillPanelOpen;
- previousNodeDetailsPanelOpenRef.current = workflowNodeDetailsPanelOpen;
- previousTestChatPanelOpenRef.current = workflowTestChatPanelOpen;
- previousLeftSidebarOpenRef.current = leftSidebarOpen;
- previousRightSidebarOpenRef.current = rightSidebarOpen;
-
return;
}
- previousCopilotPanelOpenRef.current = copilotPanelOpen;
- previousDataPillPanelOpenRef.current = dataPillPanelOpen;
- previousNodeDetailsPanelOpenRef.current = workflowNodeDetailsPanelOpen;
- previousTestChatPanelOpenRef.current = workflowTestChatPanelOpen;
- previousLeftSidebarOpenRef.current = leftSidebarOpen;
- previousRightSidebarOpenRef.current = rightSidebarOpen;
-
if (cancelAnimationRef.current) {
cancelAnimationRef.current();
cancelAnimationRef.current = null;
@@ -745,14 +685,7 @@ export default function useLayout({
}));
cancelAnimationRef.current = animateNodePositions(currentNodes, shiftedNodes, updateNodes);
- }, [
- copilotPanelOpen,
- dataPillPanelOpen,
- leftSidebarOpen,
- rightSidebarOpen,
- workflowNodeDetailsPanelOpen,
- workflowTestChatPanelOpen,
- ]);
+ }, [copilotPanelOpen, leftSidebarOpen]);
useEffect(() => {
if (useWorkflowDataStore.getState().isNodeDragging) {
diff --git a/client/src/pages/platform/workflow-editor/hooks/useNodeClick.ts b/client/src/pages/platform/workflow-editor/hooks/useNodeClick.ts
index 5eff85fdf2c..d47e990685f 100644
--- a/client/src/pages/platform/workflow-editor/hooks/useNodeClick.ts
+++ b/client/src/pages/platform/workflow-editor/hooks/useNodeClick.ts
@@ -1,6 +1,3 @@
-import useDataPillPanelStore from '@/pages/platform/workflow-editor/stores/useDataPillPanelStore';
-import useRightSidebarStore from '@/pages/platform/workflow-editor/stores/useRightSidebarStore';
-import useWorkflowTestChatStore from '@/pages/platform/workflow-editor/stores/useWorkflowTestChatStore';
import {NodeDataType, TabNameType} from '@/shared/types';
import {NodeProps} from '@xyflow/react';
import {useCallback} from 'react';
@@ -9,22 +6,9 @@ import {useShallow} from 'zustand/react/shallow';
import useClusterElementsDataStore from '../../cluster-element-editor/stores/useClusterElementsDataStore';
import useWorkflowDataStore from '../stores/useWorkflowDataStore';
import useWorkflowEditorStore from '../stores/useWorkflowEditorStore';
-import useWorkflowNodeDetailsPanelStore from '../stores/useWorkflowNodeDetailsPanelStore';
-import {getNodeLabel} from '../utils/getNodeLabel';
+import openNodeDetails from '../utils/openNodeDetails';
export default function useNodeClick(data: NodeDataType, id: NodeProps['id'], activeTab?: TabNameType) {
- const {setActiveTab, setCurrentNode, setWorkflowNodeDetailsPanelOpen} = useWorkflowNodeDetailsPanelStore(
- useShallow((state) => ({
- setActiveTab: state.setActiveTab,
- setCurrentNode: state.setCurrentNode,
- setWorkflowNodeDetailsPanelOpen: state.setWorkflowNodeDetailsPanelOpen,
- }))
- );
-
- const setDataPillPanelOpen = useDataPillPanelStore((state) => state.setDataPillPanelOpen);
- const setRightSidebarOpen = useRightSidebarStore((state) => state.setRightSidebarOpen);
- const setWorkflowTestChatPanelOpen = useWorkflowTestChatStore((state) => state.setWorkflowTestChatPanelOpen);
-
const {nodes} = useWorkflowDataStore(
useShallow((state) => ({
nodes: state.nodes,
@@ -37,7 +21,7 @@ export default function useNodeClick(data: NodeDataType, id: NodeProps['id'], ac
}))
);
- const {clusterElementsCanvasOpen, setClusterElementsCanvasOpen} = useWorkflowEditorStore();
+ const {clusterElementsCanvasOpen} = useWorkflowEditorStore();
return useCallback(() => {
const clickedNode = nodes.find((node) => node.id === id);
@@ -51,46 +35,6 @@ export default function useNodeClick(data: NodeDataType, id: NodeProps['id'], ac
return;
}
- const {currentNode: existingCurrentNode, workflowNodeDetailsPanelOpen: isPanelOpen} =
- useWorkflowNodeDetailsPanelStore.getState();
-
- const isNodeAlreadyOpen = isPanelOpen && existingCurrentNode?.workflowNodeName === data.workflowNodeName;
-
- setRightSidebarOpen(false);
- setWorkflowTestChatPanelOpen(false);
- setActiveTab(activeTab ?? 'description');
-
- if (!isNodeAlreadyOpen) {
- setDataPillPanelOpen(false);
-
- const {workflow} = useWorkflowDataStore.getState();
-
- setCurrentNode((previousCurrentNode) => ({
- ...data,
- description: '',
- displayConditions: previousCurrentNode?.displayConditions,
- label: getNodeLabel({fallbackLabel: data.label, workflow, workflowNodeName: data.workflowNodeName}),
- }));
-
- if (!!data.clusterRoot && !clusterElementsCanvasOpen) {
- setClusterElementsCanvasOpen(true);
- }
- }
-
- setWorkflowNodeDetailsPanelOpen(true);
- }, [
- data,
- id,
- nodes,
- clusterElementsCanvasNodes,
- clusterElementsCanvasOpen,
- setDataPillPanelOpen,
- setRightSidebarOpen,
- setWorkflowTestChatPanelOpen,
- setActiveTab,
- activeTab,
- setCurrentNode,
- setClusterElementsCanvasOpen,
- setWorkflowNodeDetailsPanelOpen,
- ]);
+ openNodeDetails(data, activeTab);
+ }, [activeTab, clusterElementsCanvasNodes, clusterElementsCanvasOpen, data, id, nodes]);
}
diff --git a/client/src/pages/platform/workflow-editor/hooks/useNodeIssues.ts b/client/src/pages/platform/workflow-editor/hooks/useNodeIssues.ts
new file mode 100644
index 00000000000..15b168f4948
--- /dev/null
+++ b/client/src/pages/platform/workflow-editor/hooks/useNodeIssues.ts
@@ -0,0 +1,31 @@
+import {useMemo} from 'react';
+
+import {WorkflowIssueSeverityType} from '../stores/useWorkflowIssuesStore';
+import useWorkflowIssues from './useWorkflowIssues';
+
+export default function useNodeIssues(
+ nodeName: string,
+ clusterElement = false
+): {
+ count: number;
+ severity?: WorkflowIssueSeverityType;
+ title?: string;
+} {
+ const issues = useWorkflowIssues();
+
+ return useMemo(() => {
+ const nodeIssues = issues.filter(
+ (issue) =>
+ issue.nodeName === nodeName ||
+ (clusterElement &&
+ !!issue.propertyPath &&
+ (issue.propertyPath.startsWith(`${nodeName}.`) || issue.propertyPath.includes(`.${nodeName}.`)))
+ );
+
+ return {
+ count: nodeIssues.length,
+ severity: nodeIssues[0]?.severity,
+ title: nodeIssues.map((issue) => issue.message).join('\n') || undefined,
+ };
+ }, [clusterElement, issues, nodeName]);
+}
diff --git a/client/src/pages/platform/workflow-editor/hooks/useOverlayPanelsViewport.ts b/client/src/pages/platform/workflow-editor/hooks/useOverlayPanelsViewport.ts
new file mode 100644
index 00000000000..f0801799c5c
--- /dev/null
+++ b/client/src/pages/platform/workflow-editor/hooks/useOverlayPanelsViewport.ts
@@ -0,0 +1,91 @@
+import useDataPillPanelStore from '@/pages/platform/workflow-editor/stores/useDataPillPanelStore';
+import useRightSidebarStore from '@/pages/platform/workflow-editor/stores/useRightSidebarStore';
+import useWorkflowEditorStore from '@/pages/platform/workflow-editor/stores/useWorkflowEditorStore';
+import useWorkflowIssuesStore from '@/pages/platform/workflow-editor/stores/useWorkflowIssuesStore';
+import useWorkflowNodeDetailsPanelStore from '@/pages/platform/workflow-editor/stores/useWorkflowNodeDetailsPanelStore';
+import useWorkflowTestChatStore from '@/pages/platform/workflow-editor/stores/useWorkflowTestChatStore';
+import {useReactFlow, useStoreApi} from '@xyflow/react';
+import {useCallback, useEffect, useRef} from 'react';
+
+import {easeOutCubic} from '../utils/animateNodePositions';
+import {computeOverlayViewportOffset} from '../utils/overlayPanelViewport';
+
+export const OVERLAY_PAN_DURATION = 300;
+
+interface UseOverlayPanelsViewportProps {
+ enabled: boolean;
+}
+
+export default function useOverlayPanelsViewport({enabled}: UseOverlayPanelsViewportProps) {
+ const appliedOffsetRef = useRef(undefined);
+ const pendingTargetXRef = useRef(undefined);
+ const pendingTimeoutRef = useRef(undefined);
+
+ const clusterElementsCanvasOpen = useWorkflowEditorStore((state) => state.clusterElementsCanvasOpen);
+ const dataPillPanelOpen = useDataPillPanelStore((state) => state.dataPillPanelOpen);
+ const issuesSidebarOpen = useWorkflowIssuesStore((state) => state.issuesSidebarOpen);
+ const rightSidebarOpen = useRightSidebarStore((state) => state.rightSidebarOpen);
+ const workflowTestChatPanelOpen = useWorkflowTestChatStore((state) => state.workflowTestChatPanelOpen);
+ const workflowNodeDetailsPanelOpen = useWorkflowNodeDetailsPanelStore(
+ (state) => state.workflowNodeDetailsPanelOpen
+ );
+
+ const storeApi = useStoreApi();
+ const {setViewport} = useReactFlow();
+
+ const getViewportOffsetX = useCallback(() => appliedOffsetRef.current ?? 0, []);
+
+ useEffect(() => {
+ if (!enabled || clusterElementsCanvasOpen) {
+ return;
+ }
+
+ const [currentX, currentY, zoom] = storeApi.getState().transform;
+
+ const offset = computeOverlayViewportOffset({
+ dataPillPanelOpen,
+ issuesSidebarOpen,
+ rightSidebarOpen,
+ workflowNodeDetailsPanelOpen,
+ workflowTestChatPanelOpen,
+ });
+ const previousOffset = appliedOffsetRef.current;
+ const baseX = pendingTargetXRef.current ?? currentX;
+ const targetX = baseX - (previousOffset ?? 0) + offset;
+
+ appliedOffsetRef.current = offset;
+
+ if (targetX === baseX) {
+ return;
+ }
+
+ const viewport = {x: targetX, y: currentY, zoom};
+
+ if (previousOffset === undefined) {
+ setViewport(viewport);
+
+ return;
+ }
+
+ window.clearTimeout(pendingTimeoutRef.current);
+
+ pendingTargetXRef.current = targetX;
+ pendingTimeoutRef.current = window.setTimeout(() => {
+ pendingTargetXRef.current = undefined;
+ }, OVERLAY_PAN_DURATION);
+
+ setViewport(viewport, {duration: OVERLAY_PAN_DURATION, ease: easeOutCubic, interpolate: 'linear'});
+ }, [
+ clusterElementsCanvasOpen,
+ dataPillPanelOpen,
+ enabled,
+ issuesSidebarOpen,
+ rightSidebarOpen,
+ setViewport,
+ storeApi,
+ workflowNodeDetailsPanelOpen,
+ workflowTestChatPanelOpen,
+ ]);
+
+ return {getViewportOffsetX};
+}
diff --git a/client/src/pages/platform/workflow-editor/hooks/useWorkflowCodeEditorSheet.ts b/client/src/pages/platform/workflow-editor/hooks/useWorkflowCodeEditorSheet.ts
index d9ba9bba711..3bd6a0e2fe8 100644
--- a/client/src/pages/platform/workflow-editor/hooks/useWorkflowCodeEditorSheet.ts
+++ b/client/src/pages/platform/workflow-editor/hooks/useWorkflowCodeEditorSheet.ts
@@ -11,8 +11,7 @@ import {useFeatureFlagsStore} from '@/shared/stores/useFeatureFlagsStore';
import {WorkflowDefinitionType} from '@/shared/types';
import {getTestWorkflowAttachRequest, getTestWorkflowStreamPostRequest} from '@/shared/util/testWorkflow-utils';
import {MarkerSeverity} from 'monaco-editor';
-import {Ref, useCallback, useEffect, useState} from 'react';
-import {PanelImperativeHandle, usePanelCallbackRef} from 'react-resizable-panels';
+import {useCallback, useEffect, useState} from 'react';
import {useShallow} from 'zustand/shallow';
import useWorkflowDataStore from '../stores/useWorkflowDataStore';
@@ -43,10 +42,12 @@ type UseWorkflowCodeEditorSheetReturnType = {
handleWorkflowTestConfigurationDialog: (open: boolean) => void;
hasErrors: boolean;
projectName: string;
- setErrorPanelRef: Ref;
setErrorsAccordionOpen: (open: boolean) => void;
+ setWarningsAccordionOpen: (open: boolean) => void;
showWorkflowTestConfigurationDialog: boolean;
unsavedChangesAlertDialogOpen: boolean;
+ warnings: string[];
+ warningsAccordionOpen: boolean;
workflowIsRunning: boolean;
workflowTestExecution: WorkflowTestExecution | undefined;
};
@@ -66,6 +67,7 @@ const useWorkflowCodeEditorSheet = ({
const [definition, setDefinition] = useState(workflow.definition!);
const [dirty, setDirty] = useState(false);
const [errorsAccordionOpen, setErrorsAccordionOpen] = useState(false);
+ const [warningsAccordionOpen, setWarningsAccordionOpen] = useState(false);
const [jobId, setJobId] = useState(null);
const [showWorkflowTestConfigurationDialog, setShowWorkflowTestConfigurationDialog] = useState(false);
const [unsavedChangesAlertDialogOpen, setUnsavedChangesAlertDialogOpen] = useState(false);
@@ -108,8 +110,6 @@ const useWorkflowCodeEditorSheet = ({
});
const {updateWorkflowMutation} = useWorkflowEditor();
- const [errorPanelRef, setErrorPanelRef] = usePanelCallbackRef();
-
const handleCopilotClick = useCallback(() => {
const {
context: currentContext,
@@ -223,7 +223,7 @@ const useWorkflowCodeEditorSheet = ({
{enabled: !!definition}
);
- const {errors} = validateWorkflowData?.validateWorkflow ?? {errors: [], warnings: []};
+ const {errors, warnings} = validateWorkflowData?.validateWorkflow ?? {errors: [], warnings: []};
const handleValidate = useCallback(
(newMarkers: editor.IMarkerData[]) => {
@@ -262,18 +262,6 @@ const useWorkflowCodeEditorSheet = ({
setStreamRequest(getTestWorkflowAttachRequest({jobId}));
}, [workflow.id, currentEnvironmentId, getPersistedJobId, setWorkflowIsRunning, setJobId, setStreamRequest]);
- useEffect(() => {
- if (!errorPanelRef) {
- return;
- }
-
- if (errorsAccordionOpen) {
- errorPanelRef.resize('250px');
- } else {
- errorPanelRef.collapse();
- }
- }, [errorPanelRef, errorsAccordionOpen]);
-
return {
copilotEnabled,
copilotPanelOpen,
@@ -294,10 +282,12 @@ const useWorkflowCodeEditorSheet = ({
handleWorkflowTestConfigurationDialog: setShowWorkflowTestConfigurationDialog,
hasErrors,
projectName,
- setErrorPanelRef,
setErrorsAccordionOpen,
+ setWarningsAccordionOpen,
showWorkflowTestConfigurationDialog,
unsavedChangesAlertDialogOpen,
+ warnings,
+ warningsAccordionOpen,
workflowIsRunning,
workflowTestExecution,
};
diff --git a/client/src/pages/platform/workflow-editor/hooks/useWorkflowEditorCanvas.ts b/client/src/pages/platform/workflow-editor/hooks/useWorkflowEditorCanvas.ts
index 91214ce67cc..68de2c505f1 100644
--- a/client/src/pages/platform/workflow-editor/hooks/useWorkflowEditorCanvas.ts
+++ b/client/src/pages/platform/workflow-editor/hooks/useWorkflowEditorCanvas.ts
@@ -1,17 +1,11 @@
-import useDataPillPanelStore from '@/pages/platform/workflow-editor/stores/useDataPillPanelStore';
-import useRightSidebarStore from '@/pages/platform/workflow-editor/stores/useRightSidebarStore';
import useWorkflowDataStore from '@/pages/platform/workflow-editor/stores/useWorkflowDataStore';
import useWorkflowEditorStore from '@/pages/platform/workflow-editor/stores/useWorkflowEditorStore';
-import useWorkflowNodeDetailsPanelStore from '@/pages/platform/workflow-editor/stores/useWorkflowNodeDetailsPanelStore';
-import useWorkflowTestChatStore from '@/pages/platform/workflow-editor/stores/useWorkflowTestChatStore';
import useCopilotPanelStore from '@/shared/components/copilot/stores/useCopilotPanelStore';
import {
+ CANVAS_TOP_OFFSET,
COPILOT_PANEL_WIDTH,
- DATA_PILL_PANEL_WIDTH,
FINAL_PLACEHOLDER_NODE_ID,
- NODE_DETAILS_PANEL_WIDTH,
PROJECT_LEFT_SIDEBAR_WIDTH,
- WORKFLOW_NODES_SIDEBAR_WIDTH,
} from '@/shared/constants';
import {
ComponentDefinitionBasic,
@@ -29,6 +23,7 @@ import RoundedSmoothStepEdge from '../edges/RoundedSmoothStepEdge';
import WorkflowEdge from '../edges/WorkflowEdge';
import useHandleDrop from '../hooks/useHandleDrop';
import useLayout from '../hooks/useLayout';
+import useOverlayPanelsViewport from '../hooks/useOverlayPanelsViewport';
import AiAgentNode from '../nodes/AiAgentNode';
import PlaceholderNode from '../nodes/PlaceholderNode';
import ReadOnlyNode from '../nodes/ReadOnlyNode';
@@ -92,13 +87,7 @@ const useWorkflowEditorCanvas = ({
}))
);
const copilotPanelOpen = useCopilotPanelStore((state) => state.copilotPanelOpen);
- const dataPillPanelOpen = useDataPillPanelStore((state) => state.dataPillPanelOpen);
- const rightSidebarOpen = useRightSidebarStore((state) => state.rightSidebarOpen);
const resetWorkflowLayout = useWorkflowEditorStore((state) => state.resetWorkflowLayout);
- const workflowNodeDetailsPanelOpen = useWorkflowNodeDetailsPanelStore(
- (state) => state.workflowNodeDetailsPanelOpen
- );
- const workflowTestChatPanelOpen = useWorkflowTestChatStore((state) => state.workflowTestChatPanelOpen);
const {setViewport} = useReactFlow();
@@ -494,22 +483,10 @@ const useWorkflowEditorCanvas = ({
canvasWidth -= COPILOT_PANEL_WIDTH;
}
- if (dataPillPanelOpen) {
- canvasWidth -= DATA_PILL_PANEL_WIDTH;
- }
-
if (leftSidebarOpen) {
canvasWidth -= PROJECT_LEFT_SIDEBAR_WIDTH;
}
- if (rightSidebarOpen) {
- canvasWidth -= WORKFLOW_NODES_SIDEBAR_WIDTH;
- }
-
- if (workflowNodeDetailsPanelOpen || workflowTestChatPanelOpen) {
- canvasWidth -= NODE_DETAILS_PANEL_WIDTH;
- }
-
const canvasHeight = window.innerHeight - 60;
useEffect(() => {
@@ -555,6 +532,8 @@ const useWorkflowEditorCanvas = ({
taskDispatcherDefinitions,
});
+ const {getViewportOffsetX} = useOverlayPanelsViewport({enabled: !readOnlyWorkflow});
+
const workflowUuid = workflow.workflowUuid;
useEffect(() => {
@@ -578,8 +557,8 @@ const useWorkflowEditorCanvas = ({
setViewport(
{
- x: 0,
- y: 0,
+ x: getViewportOffsetX(),
+ y: CANVAS_TOP_OFFSET,
zoom: 1,
},
{
diff --git a/client/src/pages/platform/workflow-editor/hooks/useWorkflowIssues.ts b/client/src/pages/platform/workflow-editor/hooks/useWorkflowIssues.ts
new file mode 100644
index 00000000000..f3d5d17dda4
--- /dev/null
+++ b/client/src/pages/platform/workflow-editor/hooks/useWorkflowIssues.ts
@@ -0,0 +1,19 @@
+import {useMemo} from 'react';
+import {useShallow} from 'zustand/react/shallow';
+
+import useWorkflowIssuesStore, {WorkflowIssueI, mergeWorkflowIssues} from '../stores/useWorkflowIssuesStore';
+
+export default function useWorkflowIssues(): Array {
+ const {liveIssues, sweepIssues, validatorIssues} = useWorkflowIssuesStore(
+ useShallow((state) => ({
+ liveIssues: state.liveIssues,
+ sweepIssues: state.sweepIssues,
+ validatorIssues: state.validatorIssues,
+ }))
+ );
+
+ return useMemo(
+ () => mergeWorkflowIssues(Object.values(liveIssues), validatorIssues, sweepIssues),
+ [liveIssues, sweepIssues, validatorIssues]
+ );
+}
diff --git a/client/src/pages/platform/workflow-editor/hooks/useWorkflowIssuesSweep.ts b/client/src/pages/platform/workflow-editor/hooks/useWorkflowIssuesSweep.ts
new file mode 100644
index 00000000000..867f90b00cd
--- /dev/null
+++ b/client/src/pages/platform/workflow-editor/hooks/useWorkflowIssuesSweep.ts
@@ -0,0 +1,16 @@
+import {useEffect} from 'react';
+
+import useWorkflowDataStore from '../stores/useWorkflowDataStore';
+import useWorkflowIssuesStore from '../stores/useWorkflowIssuesStore';
+import collectWorkflowIssues from '../utils/collectWorkflowIssues';
+
+export default function useWorkflowIssuesSweep(): void {
+ const workflow = useWorkflowDataStore((state) => state.workflow);
+ const setSweepIssues = useWorkflowIssuesStore((state) => state.setSweepIssues);
+
+ useEffect(() => {
+ setSweepIssues(
+ collectWorkflowIssues({inputs: workflow.inputs, tasks: workflow.tasks, triggers: workflow.triggers})
+ );
+ }, [setSweepIssues, workflow.inputs, workflow.tasks, workflow.triggers]);
+}
diff --git a/client/src/pages/platform/workflow-editor/hooks/useWorkflowIssuesValidation.ts b/client/src/pages/platform/workflow-editor/hooks/useWorkflowIssuesValidation.ts
new file mode 100644
index 00000000000..4d6b981cab9
--- /dev/null
+++ b/client/src/pages/platform/workflow-editor/hooks/useWorkflowIssuesValidation.ts
@@ -0,0 +1,45 @@
+import {useValidateWorkflowQuery} from '@/shared/middleware/graphql';
+import {useEnvironmentStore} from '@/shared/stores/useEnvironmentStore';
+import {useEffect} from 'react';
+import {useShallow} from 'zustand/react/shallow';
+
+import useWorkflowDataStore from '../stores/useWorkflowDataStore';
+import useWorkflowIssuesStore, {WorkflowIssueI} from '../stores/useWorkflowIssuesStore';
+
+export default function useWorkflowIssuesValidation(): void {
+ const workflow = useWorkflowDataStore((state) => state.workflow);
+ const currentEnvironmentId = useEnvironmentStore((state) => state.currentEnvironmentId);
+ const {clearLiveIssues, setValidatorIssues} = useWorkflowIssuesStore(
+ useShallow((state) => ({
+ clearLiveIssues: state.clearLiveIssues,
+ setValidatorIssues: state.setValidatorIssues,
+ }))
+ );
+
+ const {data} = useValidateWorkflowQuery(
+ {environmentId: currentEnvironmentId, workflowDefinition: workflow.definition!},
+ {enabled: !!workflow.definition}
+ );
+
+ useEffect(() => {
+ clearLiveIssues();
+ setValidatorIssues([]);
+ }, [clearLiveIssues, setValidatorIssues, workflow.definition]);
+
+ useEffect(() => {
+ if (!data) {
+ return;
+ }
+
+ const validatorIssues: Array = data.validateWorkflow.nodeIssues.map((nodeIssue) => ({
+ kind: nodeIssue.kind,
+ message: nodeIssue.message,
+ nodeName: nodeIssue.nodeName,
+ propertyPath: nodeIssue.propertyPath ?? undefined,
+ severity: nodeIssue.severity,
+ source: 'VALIDATOR',
+ }));
+
+ setValidatorIssues(validatorIssues);
+ }, [data, setValidatorIssues]);
+}
diff --git a/client/src/pages/platform/workflow-editor/hooks/useWorkflowLayout.ts b/client/src/pages/platform/workflow-editor/hooks/useWorkflowLayout.ts
index a781b8d742f..021988b7750 100644
--- a/client/src/pages/platform/workflow-editor/hooks/useWorkflowLayout.ts
+++ b/client/src/pages/platform/workflow-editor/hooks/useWorkflowLayout.ts
@@ -3,9 +3,11 @@ import useDataPillPanelStore from '@/pages/platform/workflow-editor/stores/useDa
import useRightSidebarStore from '@/pages/platform/workflow-editor/stores/useRightSidebarStore';
import useWorkflowDataStore from '@/pages/platform/workflow-editor/stores/useWorkflowDataStore';
import useWorkflowEditorStore from '@/pages/platform/workflow-editor/stores/useWorkflowEditorStore';
+import useWorkflowIssuesStore from '@/pages/platform/workflow-editor/stores/useWorkflowIssuesStore';
import useWorkflowNodeDetailsPanelStore from '@/pages/platform/workflow-editor/stores/useWorkflowNodeDetailsPanelStore';
import useWorkflowTestChatStore from '@/pages/platform/workflow-editor/stores/useWorkflowTestChatStore';
import filterWorkflowNodeOutputs from '@/pages/platform/workflow-editor/utils/filterWorkflowNodeOutputs';
+import openIssuesSidebar from '@/pages/platform/workflow-editor/utils/openIssuesSidebar';
import useCopilotPanelStore from '@/shared/components/copilot/stores/useCopilotPanelStore';
import {MODE, Source, useCopilotStore} from '@/shared/components/copilot/stores/useCopilotStore';
import {ComponentDefinitionBasic} from '@/shared/middleware/platform/configuration';
@@ -32,6 +34,12 @@ export const useWorkflowLayout = (includeComponents?: string[]) => {
setRightSidebarOpen: state.setRightSidebarOpen,
}))
);
+ const {issuesSidebarOpen, setIssuesSidebarOpen} = useWorkflowIssuesStore(
+ useShallow((state) => ({
+ issuesSidebarOpen: state.issuesSidebarOpen,
+ setIssuesSidebarOpen: state.setIssuesSidebarOpen,
+ }))
+ );
const {workflow, workflowNodes} = useWorkflowDataStore(
useShallow((state) => ({
workflow: state.workflow,
@@ -132,11 +140,22 @@ export const useWorkflowLayout = (includeComponents?: string[]) => {
}
const handleComponentsAndFlowControlsClick = () => {
+ setIssuesSidebarOpen(false);
setWorkflowNodeDetailsPanelOpen(false);
setWorkflowTestChatPanelOpen(false);
setRightSidebarOpen(!rightSidebarOpen);
};
+ const handleWorkflowIssuesClick = () => {
+ if (issuesSidebarOpen) {
+ setIssuesSidebarOpen(false);
+
+ return;
+ }
+
+ openIssuesSidebar();
+ };
+
const handleCopilotClick = () => {
const {context: currentContext} = useCopilotStore.getState();
@@ -181,6 +200,7 @@ export const useWorkflowLayout = (includeComponents?: string[]) => {
handleCopilotClick,
handleWorkflowCodeEditorClick,
handleWorkflowInputsClick,
+ handleWorkflowIssuesClick,
handleWorkflowOutputsClick,
isWorkflowNodeOutputsPending,
previousComponentDefinitions,
diff --git a/client/src/pages/platform/workflow-editor/nodes/AiAgentNode.tsx b/client/src/pages/platform/workflow-editor/nodes/AiAgentNode.tsx
index 00a67a151e4..7c21a279d3e 100644
--- a/client/src/pages/platform/workflow-editor/nodes/AiAgentNode.tsx
+++ b/client/src/pages/platform/workflow-editor/nodes/AiAgentNode.tsx
@@ -4,6 +4,7 @@ import {Skeleton} from '@/components/ui/skeleton';
import {Tooltip, TooltipContent, TooltipTrigger} from '@/components/ui/tooltip';
import WorkflowNodeContextMenu from '@/pages/platform/workflow-editor/components/WorkflowNodeContextMenu';
import WorkflowNodeDropdownMenu from '@/pages/platform/workflow-editor/components/WorkflowNodeDropdownMenu';
+import WorkflowNodeIssueBadge from '@/pages/platform/workflow-editor/components/WorkflowNodeIssueBadge';
import {CLUSTER_ROOT_NODE_LABEL_WIDTH} from '@/shared/constants';
import {useGetWorkflowNodeDescriptionQuery} from '@/shared/queries/platform/workflowNodeDescriptions.queries';
import {useEnvironmentStore} from '@/shared/stores/useEnvironmentStore';
@@ -352,126 +353,132 @@ const AiAgentNode = ({data, id}: {data: NodeDataType; id: string}) => {
)}
-