({
+ nodeData: {
+ nodeId,
+ displayName: nodeId,
+ comment: undefined,
+ errorMessage: undefined,
+ iconUri: '',
+ isError: false,
+ isLoading: false,
+ onSelectTab: vi.fn(),
+ runData: undefined,
+ selectedTab: undefined,
+ subgraphType: undefined,
+ tabs: [],
+ },
+ headerItems: [],
+ headerLocation: PanelLocation.Right,
+ panelScope: PanelScope.CardLevel,
+ onClose: vi.fn(),
+ onTitleChange: vi.fn(),
+ commentChange: vi.fn(),
+ handleTitleUpdate: vi.fn(),
+});
+
+const CanvasCard = ({ nodeId, focusedNodeId }: { nodeId: string; focusedNodeId?: string }) => {
+ const ref = useRef
(null);
+ useEffect(() => {
+ if (focusedNodeId === nodeId) {
+ ref.current?.focus();
+ }
+ }, [focusedNodeId, nodeId]);
+
+ return (
+
+ );
+};
+
+interface FixtureProps {
+ enableNodeNavigation?: boolean;
+ selectedNodeId: string;
+ focusedNodeId?: string;
+ panelFirst: boolean;
+ suppressDefaultNodeSelectFunctionality?: boolean;
+}
+
+const Fixture = ({
+ selectedNodeId,
+ focusedNodeId,
+ panelFirst,
+ suppressDefaultNodeSelectFunctionality,
+ enableNodeNavigation = true,
+}: FixtureProps) => {
+ const panel = (
+
+ );
+ const canvas = (
+
+ {nodeIds.map((nodeId) => (
+
+ ))}
+
+ );
+ return (
+
+ {panelFirst ? panel : canvas}
+ {panelFirst ? canvas : panel}
+
+ );
+};
+
+describe.each([
+ { name: 'canvas before panel', panelFirst: false },
+ { name: 'panel before canvas', panelFirst: true },
+])('PanelHeader focus ordering: $name', ({ panelFirst }) => {
+ let focusOrder: string[];
+ const recordFocus = (event: FocusEvent) => {
+ if (event.target instanceof HTMLElement) {
+ focusOrder.push(event.target.id);
+ }
+ };
+
+ beforeEach(() => {
+ focusOrder = [];
+ document.addEventListener('focusin', recordFocus);
+ });
+
+ afterEach(() => {
+ document.removeEventListener('focusin', recordFocus);
+ cleanup();
+ });
+
+ it('keeps default Close autofocus on mount and ordinary selection changes without a canvas request', () => {
+ const { rerender } = render();
+ const close = screen.getByRole('button', { name: 'Close' });
+ expect(close).toHaveFocus();
+
+ screen.getByRole('button', { name: 'Canvas Http' }).focus();
+ rerender();
+ expect(screen.getByRole('button', { name: 'Close' })).toBe(close);
+ expect(close).toHaveFocus();
+ });
+
+ it('preserves passive panel focus ordering without the v2 opt-in', () => {
+ render();
+ expect(focusOrder).toEqual(
+ panelFirst ? ['msla-panel-header-close-nav', 'msla-node-Switch'] : ['msla-node-Switch', 'msla-panel-header-close-nav']
+ );
+ });
+
+ it('lets an explicit canvas passive effect win after default panel focus on initial mount', () => {
+ render();
+ expect(focusOrder).toEqual(['msla-panel-header-close-nav', 'msla-node-Switch']);
+ expect(screen.getByRole('button', { name: 'Canvas Switch' })).toHaveFocus();
+ });
+
+ it('keeps explicit canvas focus through repeated selections without remounting the header or cards', () => {
+ const { rerender } = render();
+ const close = screen.getByRole('button', { name: 'Close' });
+ const originalHttpCard = screen.getByRole('button', { name: 'Canvas Http' });
+ expect(originalHttpCard).toHaveFocus();
+
+ for (const nodeId of ['Switch', 'Condition', 'Http']) {
+ focusOrder.length = 0;
+ rerender();
+ expect(focusOrder).toEqual(['msla-panel-header-close-nav', `msla-node-${nodeId}`]);
+ expect(screen.getByRole('button', { name: `Canvas ${nodeId}` })).toHaveFocus();
+ expect(screen.getByRole('button', { name: 'Close' })).toBe(close);
+ expect(screen.getByRole('button', { name: 'Canvas Http' })).toBe(originalHttpCard);
+ }
+ });
+
+ it('does not steal focus when the one-shot request clears or the same node rerenders', () => {
+ const { rerender } = render();
+ focusOrder.length = 0;
+ rerender();
+ rerender();
+ expect(screen.getByRole('button', { name: 'Canvas Switch' })).toHaveFocus();
+ expect(focusOrder).toEqual([]);
+ });
+
+ it('returns to normal panel autofocus when a later selection has no canvas request', () => {
+ const { rerender } = render();
+ expect(screen.getByRole('button', { name: 'Canvas Switch' })).toHaveFocus();
+ focusOrder.length = 0;
+ rerender();
+ expect(screen.getByRole('button', { name: 'Close' })).toHaveFocus();
+ expect(focusOrder).toEqual(['msla-panel-header-close-nav']);
+ });
+
+ it('does not introduce panel focus when the host suppresses the Close button', () => {
+ render();
+ expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Canvas Switch' })).toHaveFocus();
+ expect(focusOrder).toEqual(['msla-node-Switch']);
+ });
+});
diff --git a/libs/designer-ui/src/lib/panel/panelheader/panelheader.tsx b/libs/designer-ui/src/lib/panel/panelheader/panelheader.tsx
index 95c98d810e4..7692c24ac21 100644
--- a/libs/designer-ui/src/lib/panel/panelheader/panelheader.tsx
+++ b/libs/designer-ui/src/lib/panel/panelheader/panelheader.tsx
@@ -31,7 +31,7 @@ import {
} from '@fluentui/react-icons';
import { Icon } from '@fluentui/react/lib/Icon';
import { isNullOrUndefined } from '@microsoft/logic-apps-shared';
-import { useEffect, useMemo, useRef, useState } from 'react';
+import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useIntl } from 'react-intl';
export const handleOnEscapeDown = (e: React.KeyboardEvent): void => {
@@ -41,6 +41,7 @@ export const handleOnEscapeDown = (e: React.KeyboardEvent {
- const { nodeId, onClose } = props;
+ const { nodeId, onClose, enableNodeNavigation = false } = props;
const intl = useIntl();
@@ -84,13 +85,20 @@ const CloseButton = (props: PanelHeaderProps & { nodeId: string }): JSX.Element
});
const buttonText = panelCloseTitle;
- useEffect(() => {
- if (!nodeId) {
+ // Default panel focus must precede an explicit canvas focus request.
+ useLayoutEffect(() => {
+ if (!nodeId || !enableNodeNavigation) {
return;
}
menuButtonRef.current?.focus();
- }, [nodeId]);
+ }, [nodeId, enableNodeNavigation]);
+
+ useEffect(() => {
+ if (nodeId && !enableNodeNavigation) {
+ menuButtonRef.current?.focus();
+ }
+ }, [nodeId, enableNodeNavigation]);
const restoreFocusSourceAttribute = useRestoreFocusSource();
diff --git a/libs/designer-v2/src/lib/core/state/panel/__test__/panelSlice.tabRetention.spec.ts b/libs/designer-v2/src/lib/core/state/panel/__test__/panelSlice.tabRetention.spec.ts
new file mode 100644
index 00000000000..e5bcbd9a15d
--- /dev/null
+++ b/libs/designer-v2/src/lib/core/state/panel/__test__/panelSlice.tabRetention.spec.ts
@@ -0,0 +1,132 @@
+import { describe, expect, it } from 'vitest';
+import { resetWorkflowState } from '../../global';
+import reducer, {
+ changePanelNode,
+ clearPanel,
+ initialState,
+ openPanel,
+ setAlternateSelectedNode,
+ setNodeSelection,
+ setPinnedPanelActiveTab,
+ setSelectedNodeId,
+ setSelectedPanelActiveTab,
+ toggleNodeSelection,
+} from '../panelSlice';
+
+const selectedAndPinned = () => {
+ let state = reducer(initialState, changePanelNode('First'));
+ state = reducer(state, setSelectedPanelActiveTab('SETTINGS'));
+ state = reducer(state, setAlternateSelectedNode({ nodeId: 'Pinned', panelPersistence: 'pinned' }));
+ return reducer(state, setPinnedPanelActiveTab('ABOUT'));
+};
+
+describe('panel tab preference (designer-v2)', () => {
+ it.each([
+ { name: 'changePanelNode', action: changePanelNode('Second') },
+ { name: 'setSelectedNodeId', action: setSelectedNodeId('Second') },
+ { name: 'openPanel with nodeId', action: openPanel({ panelMode: 'Operation', nodeId: 'Second' }) },
+ { name: 'openPanel with nodeIds', action: openPanel({ panelMode: 'Operation', nodeIds: ['Second'] }) },
+ ])('$name preserves the selected preference and separate pinned tab', ({ action }) => {
+ const previous = selectedAndPinned();
+ const state = reducer(previous, action);
+ expect(state.operationContent.selectedNodeId).toBe('Second');
+ expect(state.operationContent.selectedNodeIds).toEqual(['Second']);
+ expect(state.operationContent.selectedNodeActiveTabId).toBe('SETTINGS');
+ expect(state.operationContent.alternateSelectedNode).toEqual(previous.operationContent.alternateSelectedNode);
+ expect(previous.operationContent.selectedNodeId).toBe('First');
+ });
+
+ it('uses the latest preference through repeated node changes', () => {
+ let state = reducer(selectedAndPinned(), changePanelNode('Second'));
+ state = reducer(state, setSelectedPanelActiveTab('CODE_VIEW'));
+ state = reducer(state, changePanelNode('Third'));
+ state = reducer(state, changePanelNode('First'));
+ expect(state.operationContent.selectedNodeId).toBe('First');
+ expect(state.operationContent.selectedNodeActiveTabId).toBe('CODE_VIEW');
+ expect(state.operationContent.alternateSelectedNode?.activeTabId).toBe('ABOUT');
+ });
+
+ it('keeps an explicitly cleared preference unset when selecting another node', () => {
+ const cleared = reducer(selectedAndPinned(), setSelectedPanelActiveTab(undefined));
+ const state = reducer(cleared, changePanelNode('Second'));
+ expect(state.operationContent.selectedNodeActiveTabId).toBeUndefined();
+ expect(state.operationContent.alternateSelectedNode?.activeTabId).toBe('ABOUT');
+ });
+
+ it('changing the pinned tab does not change the selected preference', () => {
+ const state = reducer(selectedAndPinned(), setPinnedPanelActiveTab('CODE_VIEW'));
+ expect(state.operationContent.selectedNodeActiveTabId).toBe('SETTINGS');
+ expect(state.operationContent.alternateSelectedNode).toEqual({
+ nodeId: 'Pinned',
+ persistence: 'pinned',
+ activeTabId: 'CODE_VIEW',
+ });
+ });
+
+ it('clearPanel resets the selected preference while preserving the pinned tab', () => {
+ const previous = selectedAndPinned();
+ const state = reducer(previous, clearPanel());
+ expect(state.operationContent.selectedNodeId).toBeUndefined();
+ expect(state.operationContent.selectedNodeIds).toEqual([]);
+ expect(state.operationContent.selectedNodeActiveTabId).toBeUndefined();
+ expect(state.operationContent.alternateSelectedNode).toEqual(previous.operationContent.alternateSelectedNode);
+ expect(state.isCollapsed).toBe(false);
+ expect(reducer(state, changePanelNode('Second')).operationContent.selectedNodeActiveTabId).toBeUndefined();
+ });
+
+ it('clearPanel with clearPinnedState resets both tab preferences', () => {
+ const state = reducer(selectedAndPinned(), clearPanel({ clearPinnedState: true }));
+ expect(state.operationContent).toEqual(initialState.operationContent);
+ expect(state.isCollapsed).toBe(true);
+ });
+
+ it('resetWorkflowState clears both preferences and selections', () => {
+ const state = reducer(selectedAndPinned(), resetWorkflowState());
+ expect(state).toEqual(initialState);
+ expect(reducer(state, changePanelNode('NextWorkflowNode')).operationContent.selectedNodeActiveTabId).toBeUndefined();
+ });
+
+ it.each([
+ { ids: [] },
+ { ids: ['First'] },
+ { ids: ['First', 'Second'] },
+ { ids: ['First', 'Second', 'Third'] },
+ { ids: ['Second', 'Second'] },
+ ])('setNodeSelection($ids) retains the primary preference during reconciliation', ({ ids }) => {
+ const previous = reducer(reducer(initialState, changePanelNode('First')), setSelectedPanelActiveTab('SETTINGS'));
+ const state = reducer(previous, setNodeSelection(ids));
+ expect(state.operationContent.selectedNodeIds).toEqual([...new Set(ids)]);
+ expect(state.operationContent.selectedNodeId).toBe(ids[0]);
+ expect(state.operationContent.selectedNodeActiveTabId).toBe('SETTINGS');
+ expect(state.isCollapsed).toBe(ids.length === 0);
+ });
+
+ it('toggleNodeSelection preserves the preference through single, dual, multiple, and empty selections', () => {
+ let state = reducer(reducer(initialState, changePanelNode('First')), setSelectedPanelActiveTab('SETTINGS'));
+ for (const { id, expected } of [
+ { id: 'Second', expected: ['First', 'Second'] },
+ { id: 'Third', expected: ['First', 'Second', 'Third'] },
+ { id: 'First', expected: ['Second', 'Third'] },
+ { id: 'Second', expected: ['Third'] },
+ { id: 'Third', expected: [] },
+ { id: 'Last', expected: ['Last'] },
+ ]) {
+ state = reducer(state, toggleNodeSelection(id));
+ expect(state.operationContent.selectedNodeIds).toEqual(expected);
+ expect(state.operationContent.selectedNodeId).toBe(expected[0]);
+ expect(state.operationContent.selectedNodeActiveTabId).toBe('SETTINGS');
+ expect(state.isCollapsed).toBe(expected.length === 0);
+ }
+ });
+
+ it('does not copy an alternate tab into the primary preference when the primary selection changes', () => {
+ let state = reducer(reducer(initialState, changePanelNode('First')), setSelectedPanelActiveTab('SETTINGS'));
+ state = reducer(state, setNodeSelection(['First', 'Second']));
+ expect(state.operationContent.alternateSelectedNode?.activeTabId).toBeUndefined();
+ state = reducer(state, setPinnedPanelActiveTab('ABOUT'));
+ state = reducer(state, toggleNodeSelection('First'));
+ expect(state.operationContent.selectedNodeId).toBe('Second');
+ expect(state.operationContent.selectedNodeActiveTabId).toBe('SETTINGS');
+ expect(state.operationContent.alternateSelectedNode).toEqual({});
+ });
+});
diff --git a/libs/designer-v2/src/lib/core/state/panel/panelSlice.ts b/libs/designer-v2/src/lib/core/state/panel/panelSlice.ts
index 03e3b2303d6..7b96039b372 100644
--- a/libs/designer-v2/src/lib/core/state/panel/panelSlice.ts
+++ b/libs/designer-v2/src/lib/core/state/panel/panelSlice.ts
@@ -75,7 +75,6 @@ const getInitialWorkflowParametersContentState = (): WorkflowParametersPanelCont
const reconcileNodeSelection = (state: PanelState): void => {
const ids = state.operationContent.selectedNodeIds ?? [];
state.operationContent.selectedNodeId = ids[0];
- state.operationContent.selectedNodeActiveTabId = undefined;
if (ids.length === 2) {
state.operationContent.alternateSelectedNode = {
nodeId: ids[1],
@@ -206,7 +205,6 @@ export const panelSlice = createSlice({
state.connectionContent.selectedNodeIds = selectedNodes;
state.operationContent.selectedNodeId = selectedNodes[0];
state.operationContent.selectedNodeIds = selectedNodes;
- state.operationContent.selectedNodeActiveTabId = undefined;
if (state.operationContent.alternateSelectedNode?.persistence === 'selected') {
state.operationContent.alternateSelectedNode.nodeId = '';
}
diff --git a/libs/designer-v2/src/lib/core/state/workflow/__test__/workflowSelectors.focus.spec.tsx b/libs/designer-v2/src/lib/core/state/workflow/__test__/workflowSelectors.focus.spec.tsx
new file mode 100644
index 00000000000..d237f854381
--- /dev/null
+++ b/libs/designer-v2/src/lib/core/state/workflow/__test__/workflowSelectors.focus.spec.tsx
@@ -0,0 +1,80 @@
+import { configureStore } from '@reduxjs/toolkit';
+import { act, cleanup, renderHook } from '@testing-library/react';
+import type { ReactNode } from 'react';
+import { Provider } from 'react-redux';
+import { afterEach, describe, expect, it } from 'vitest';
+import { useShouldNodeFocus } from '../workflowSelectors';
+import workflowReducer, { clearFocusNode, initialWorkflowState, setFocusNode } from '../workflowSlice';
+
+const setup = (focusedCanvasNodeId?: string) => {
+ const store = configureStore({
+ reducer: { workflow: workflowReducer },
+ preloadedState: { workflow: { ...initialWorkflowState, focusedCanvasNodeId } },
+ });
+ const wrapper = ({ children }: { children: ReactNode }) => {children};
+ return { store, wrapper };
+};
+
+describe('useShouldNodeFocus (designer-v2)', () => {
+ afterEach(cleanup);
+
+ it.each([
+ { focus: 'Scope', expected: true },
+ { focus: 'Scope-#scope', expected: true },
+ { focus: 'Other', expected: false },
+ { focus: 'Other-#scope', expected: false },
+ { focus: undefined, expected: false },
+ ])('matches normalized or rendered scope ids for focus=$focus: $expected', ({ focus, expected }) => {
+ const { wrapper } = setup(focus);
+ const { result } = renderHook(() => useShouldNodeFocus('Scope', 'Scope-#scope'), { wrapper });
+ expect(result.current).toBe(expected);
+ });
+
+ it.each([
+ { focus: 'Operation', expected: true },
+ { focus: 'Operation-#scope', expected: false },
+ { focus: undefined, expected: false },
+ ])('preserves single-id callers for focus=$focus: $expected', ({ focus, expected }) => {
+ const { wrapper } = setup(focus);
+ const { result } = renderHook(() => useShouldNodeFocus('Operation'), { wrapper });
+ expect(result.current).toBe(expected);
+ });
+
+ it('tracks normalized, tagged, mismatched, and cleared focus without remounting', () => {
+ const { store, wrapper } = setup();
+ const { result } = renderHook(() => useShouldNodeFocus('Scope', 'Scope-#scope'), { wrapper });
+ expect(result.current).toBe(false);
+ act(() => store.dispatch(setFocusNode('Scope-#scope')));
+ expect(result.current).toBe(true);
+ act(() => store.dispatch(setFocusNode('Other-#scope')));
+ expect(result.current).toBe(false);
+ act(() => store.dispatch(setFocusNode('Scope')));
+ expect(result.current).toBe(true);
+ act(() => store.dispatch(clearFocusNode()));
+ expect(result.current).toBe(false);
+ });
+
+ it('updates the rendered card id while the normalized action id stays the same', () => {
+ const { wrapper } = setup('Scope-rendered-#scope');
+ const { result, rerender } = renderHook(({ canvasNodeId }) => useShouldNodeFocus('Scope', canvasNodeId), {
+ wrapper,
+ initialProps: { canvasNodeId: 'Scope-#scope' },
+ });
+ expect(result.current).toBe(false);
+ rerender({ canvasNodeId: 'Scope-rendered-#scope' });
+ expect(result.current).toBe(true);
+ rerender({ canvasNodeId: 'Scope-#scope' });
+ expect(result.current).toBe(false);
+ });
+
+ it('updates the normalized action id while the rendered card id stays the same', () => {
+ const { wrapper } = setup('RenamedScope');
+ const { result, rerender } = renderHook(({ id }) => useShouldNodeFocus(id, 'Scope-#scope'), {
+ wrapper,
+ initialProps: { id: 'Scope' },
+ });
+ expect(result.current).toBe(false);
+ rerender({ id: 'RenamedScope' });
+ expect(result.current).toBe(true);
+ });
+});
diff --git a/libs/designer-v2/src/lib/core/state/workflow/workflowSelectors.ts b/libs/designer-v2/src/lib/core/state/workflow/workflowSelectors.ts
index 5bdd8a49a4c..7d3ebea6fd8 100644
--- a/libs/designer-v2/src/lib/core/state/workflow/workflowSelectors.ts
+++ b/libs/designer-v2/src/lib/core/state/workflow/workflowSelectors.ts
@@ -52,8 +52,17 @@ export const useNodeDescription = (id: string) =>
useMemo(() => createSelector(getWorkflowState, (state: WorkflowState) => getRecordEntry(state.operations, id)?.description), [id])
);
-export const useShouldNodeFocus = (id: string) =>
- useSelector(useMemo(() => createSelector(getWorkflowState, (state: WorkflowState) => state.focusedCanvasNodeId === id), [id]));
+export const useShouldNodeFocus = (id: string, canvasNodeId = id) =>
+ useSelector(
+ useMemo(
+ () =>
+ createSelector(
+ getWorkflowState,
+ (state: WorkflowState) => state.focusedCanvasNodeId === id || state.focusedCanvasNodeId === canvasNodeId
+ ),
+ [id, canvasNodeId]
+ )
+ );
const selectFocusElement = createSelector(getWorkflowState, (state: WorkflowState) => state.focusElement);
diff --git a/libs/designer-v2/src/lib/ui/CustomNodes/ScopeCardNode.tsx b/libs/designer-v2/src/lib/ui/CustomNodes/ScopeCardNode.tsx
index 0823aa595bb..5ab94a39d82 100644
--- a/libs/designer-v2/src/lib/ui/CustomNodes/ScopeCardNode.tsx
+++ b/libs/designer-v2/src/lib/ui/CustomNodes/ScopeCardNode.tsx
@@ -66,7 +66,7 @@ import { ErrorLevel } from '../../core/state/operation/operationMetadataSlice';
const ScopeCardNode = ({ id }: NodeProps) => {
const scopeId = useMemo(() => removeIdTag(id), [id]);
- const shouldFocus = useShouldNodeFocus(scopeId);
+ const shouldFocus = useShouldNodeFocus(scopeId, id);
const node = useActionMetadata(scopeId);
const errorInfo = useOperationErrorInfo(scopeId);
const operationsInfo = useAllOperations();
diff --git a/libs/designer-v2/src/lib/ui/CustomNodes/components/card/__test__/actionCard.spec.tsx b/libs/designer-v2/src/lib/ui/CustomNodes/components/card/__test__/actionCard.spec.tsx
index 7e7d79dc1bc..35daee73ca1 100644
--- a/libs/designer-v2/src/lib/ui/CustomNodes/components/card/__test__/actionCard.spec.tsx
+++ b/libs/designer-v2/src/lib/ui/CustomNodes/components/card/__test__/actionCard.spec.tsx
@@ -244,8 +244,42 @@ describe('ActionCard', () => {
expect(onCopyClick).toHaveBeenCalledOnce();
});
- it('should focus element when setFocus is true', () => {
- render();
- expect(screen.getByTestId('card-Test Action')).toHaveFocus();
+ it.each([false, true])('focuses with preventScroll when setFocus is true (scope=%s)', (isScope) => {
+ const focus = vi.spyOn(HTMLElement.prototype, 'focus');
+ try {
+ render();
+ expect(focus).toHaveBeenCalledExactlyOnceWith({ preventScroll: true });
+ expect(screen.getByTestId('card-Test Action')).toHaveFocus();
+ } finally {
+ focus.mockRestore();
+ }
+ });
+
+ it.each([false, true])('only refocuses for a new explicit request without native scrolling (scope=%s)', (isScope) => {
+ const cardProps = { ...defaultProps, nodeIndex: 1, isScope, handleCollapse: vi.fn() };
+ const { rerender } = render();
+ const target = screen.getByTestId('card-Test Action');
+ expect(target).not.toHaveFocus();
+ const focus = vi.spyOn(target, 'focus');
+ try {
+ rerender();
+ expect(focus).toHaveBeenCalledExactlyOnceWith({ preventScroll: true });
+ expect(target).toHaveFocus();
+
+ rerender();
+ expect(screen.getByTestId('card-Updated action')).toBe(target);
+ expect(focus).toHaveBeenCalledOnce();
+ rerender();
+ expect(focus).toHaveBeenCalledOnce();
+ target.blur();
+ expect(target).not.toHaveFocus();
+
+ rerender();
+ expect(focus).toHaveBeenCalledTimes(2);
+ expect(focus).toHaveBeenLastCalledWith({ preventScroll: true });
+ expect(target).toHaveFocus();
+ } finally {
+ focus.mockRestore();
+ }
});
});
diff --git a/libs/designer-v2/src/lib/ui/CustomNodes/components/card/actionCard.tsx b/libs/designer-v2/src/lib/ui/CustomNodes/components/card/actionCard.tsx
index 3d7dcc10ce1..6be90653fda 100644
--- a/libs/designer-v2/src/lib/ui/CustomNodes/components/card/actionCard.tsx
+++ b/libs/designer-v2/src/lib/ui/CustomNodes/components/card/actionCard.tsx
@@ -87,7 +87,8 @@ export const ActionCard: React.FC = ({
useEffect(() => {
if (setFocus) {
- focusRef.current?.focus();
+ // Canvas panning owns visibility; native scrolling would offset the viewport.
+ focusRef.current?.focus({ preventScroll: true });
}
}, [setFocus]);
diff --git a/libs/designer-v2/src/lib/ui/DesignerReactFlow.tsx b/libs/designer-v2/src/lib/ui/DesignerReactFlow.tsx
index addff8e707b..8f154050c58 100644
--- a/libs/designer-v2/src/lib/ui/DesignerReactFlow.tsx
+++ b/libs/designer-v2/src/lib/ui/DesignerReactFlow.tsx
@@ -12,6 +12,7 @@ import type {
} from '@xyflow/react';
import { BezierEdge, ReactFlow, SelectionMode } from '@xyflow/react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { flushSync } from 'react-dom';
import {
agentOperation,
containsIdTag,
@@ -55,6 +56,7 @@ import NoteNode from './CustomNodes/NoteNode';
import ButtonEdge from './connections/edge';
import HandoffEdge from './connections/handoffEdge';
import HiddenEdge from './connections/hiddenEdge';
+import { NodeNavigation } from './NodeNavigation';
const DesignerReactFlow = (props: any) => {
const { canvasRef } = props;
@@ -703,6 +705,15 @@ const DesignerReactFlow = (props: any) => {
hideAttribution: true,
}}
>
+ {
+ if (!userInferredTabNavigation) {
+ // Mount off-screen cards before dispatching the one-shot canvas focus request.
+ flushSync(() => setUserInferredTabNavigation(true));
+ }
+ }}
+ />
{props.children}
);
diff --git a/libs/designer-v2/src/lib/ui/NodeNavigation.tsx b/libs/designer-v2/src/lib/ui/NodeNavigation.tsx
new file mode 100644
index 00000000000..33e4e9ba287
--- /dev/null
+++ b/libs/designer-v2/src/lib/ui/NodeNavigation.tsx
@@ -0,0 +1,64 @@
+import { getAdjacentNode, removeIdTag, WORKFLOW_NODE_TYPES } from '@microsoft/logic-apps-shared';
+import { useReactFlow } from '@xyflow/react';
+import { type RefObject, useCallback } from 'react';
+import { useHotkeys } from 'react-hotkeys-hook';
+import { useDispatch } from 'react-redux';
+import {
+ useNodeSelectAdditionalCallback,
+ useSuppressDefaultNodeSelectFunctionality,
+} from '../core/state/designerOptions/designerOptionsSelectors';
+import { useOperationPanelSelectedNodeId } from '../core/state/panel/panelSelectors';
+import { changePanelNode, setSelectedNodeId } from '../core/state/panel/panelSlice';
+import { setFocusNode } from '../core/state/workflow/workflowSlice';
+import type { AppDispatch } from '../core/store';
+
+interface NodeNavigationProps {
+ canvasRef: RefObject;
+ onNavigate: () => void;
+}
+
+export const NodeNavigation = ({ canvasRef, onNavigate }: NodeNavigationProps) => {
+ const { getNodes } = useReactFlow();
+ const selectedNodeId = useOperationPanelSelectedNodeId();
+ const nodeSelectCallback = useNodeSelectAdditionalCallback();
+ const suppressDefaultNodeSelect = useSuppressDefaultNodeSelectFunctionality();
+ const dispatch = useDispatch();
+
+ const navigate = useCallback(
+ (event: KeyboardEvent, direction: 'next' | 'previous') => {
+ if (!(event.target instanceof Element)) {
+ return;
+ }
+ const canvas = canvasRef.current;
+ const panel = event.target.closest('.msla-panel-layout')?.querySelector('.msla-node-details-panel');
+ const designer = canvas?.closest('.msla-designer-canvas');
+ const isSelectedPanel =
+ selectedNodeId &&
+ panel?.id === `msla-node-details-panel-${selectedNodeId}` &&
+ designer &&
+ panel.closest('.msla-designer-canvas') === designer &&
+ !event.target.closest(
+ '[role="dialog"], [role="alertdialog"], [role="menu"], [role="listbox"], [role="combobox"], [role="textbox"]'
+ );
+ if (!canvas?.contains(event.target) && !isSelectedPanel) {
+ return;
+ }
+ event.preventDefault();
+ const node = getAdjacentNode(getNodes(), selectedNodeId, direction);
+ if (!node) {
+ return;
+ }
+ onNavigate();
+ const actionId = node.type === WORKFLOW_NODE_TYPES.SCOPE_CARD_NODE ? removeIdTag(node.id) : node.id;
+ nodeSelectCallback?.(actionId);
+ dispatch(suppressDefaultNodeSelect ? setSelectedNodeId(actionId) : changePanelNode(actionId));
+ dispatch(setFocusNode(node.id));
+ },
+ [canvasRef, dispatch, getNodes, nodeSelectCallback, onNavigate, selectedNodeId, suppressDefaultNodeSelect]
+ );
+
+ useHotkeys(['ctrl+down', 'meta+down'], (event) => navigate(event, 'next'), [navigate]);
+ useHotkeys(['ctrl+up', 'meta+up'], (event) => navigate(event, 'previous'), [navigate]);
+
+ return null;
+};
diff --git a/libs/designer-v2/src/lib/ui/__test__/DesignerReactFlow.spec.tsx b/libs/designer-v2/src/lib/ui/__test__/DesignerReactFlow.spec.tsx
index e46de6eea99..78a03e3d10f 100644
--- a/libs/designer-v2/src/lib/ui/__test__/DesignerReactFlow.spec.tsx
+++ b/libs/designer-v2/src/lib/ui/__test__/DesignerReactFlow.spec.tsx
@@ -16,6 +16,7 @@ let mockNodesMetadata: Record = {};
let mockNotes: Record = {};
const mockDispatch = vi.fn();
+const mockAfterNavigate = vi.fn();
// ── React-Redux ──────────────────────────────────────────────────────────────
vi.mock('react-redux', () => ({
@@ -30,7 +31,12 @@ let capturedReactFlowProps: Record = {};
vi.mock('@xyflow/react', () => ({
ReactFlow: ({ children, ...props }: any) => {
capturedReactFlowProps = props;
- return {children}
;
+ return (
+
+ {children}
+ {!props.onlyRenderVisibleElements &&
}
+
+ );
},
BezierEdge: () => ,
SelectionMode: { Full: 'full' },
@@ -136,6 +142,20 @@ vi.mock('../connections/edge', () => ({ default: () => }));
vi.mock('../connections/handoffEdge', () => ({ default: () => }));
vi.mock('../connections/hiddenEdge', () => ({ default: () => }));
vi.mock('../connections/draftEdge', () => ({ DraftEdge: () => }));
+vi.mock('../NodeNavigation', () => ({
+ NodeNavigation: ({ onNavigate }: { onNavigate: () => void }) => (
+
+ ),
+}));
// ── Import under test (after mocks) ─────────────────────────────────────────
import DesignerReactFlow from '../DesignerReactFlow';
@@ -161,6 +181,7 @@ describe('DesignerReactFlow (designer-v2)', () => {
mockNodesMetadata = {};
mockNotes = {};
mockDispatch.mockClear();
+ mockAfterNavigate.mockClear();
capturedReactFlowProps = {};
});
@@ -169,6 +190,21 @@ describe('DesignerReactFlow (designer-v2)', () => {
// ──────────────────────────────────────────────────────────
describe('Rendering', () => {
+ it('mounts offscreen DOM before the navigation callback returns, including the first command', () => {
+ render();
+ expect(screen.getByTestId('react-flow')).toContainElement(screen.getByTestId('node-navigation'));
+ expect(capturedReactFlowProps.onlyRenderVisibleElements).toBe(true);
+ expect(screen.queryByTestId('offscreen-node')).not.toBeInTheDocument();
+ fireEvent.click(screen.getByTestId('node-navigation'));
+ expect(mockAfterNavigate).toHaveBeenNthCalledWith(1, true);
+ expect(capturedReactFlowProps.onlyRenderVisibleElements).toBe(false);
+ const offscreenNode = screen.getByTestId('offscreen-node');
+ fireEvent.click(screen.getByTestId('node-navigation'));
+ expect(mockAfterNavigate).toHaveBeenNthCalledWith(2, true);
+ expect(mockAfterNavigate).toHaveBeenCalledTimes(2);
+ expect(screen.getByTestId('offscreen-node')).toBe(offscreenNode);
+ });
+
it('should render ReactFlow with nodes', () => {
render();
expect(screen.getByTestId('react-flow')).toBeInTheDocument();
diff --git a/libs/designer-v2/src/lib/ui/__test__/NodeNavigation.spec.tsx b/libs/designer-v2/src/lib/ui/__test__/NodeNavigation.spec.tsx
new file mode 100644
index 00000000000..3d03c9c823b
--- /dev/null
+++ b/libs/designer-v2/src/lib/ui/__test__/NodeNavigation.spec.tsx
@@ -0,0 +1,26 @@
+import { describe } from 'vitest';
+import { nodeNavigationTestSuite } from './nodeNavigationTestSuite';
+import panelReducer, {
+ changePanelNode,
+ setAlternateSelectedNode,
+ setNodeSelection,
+ setPinnedPanelActiveTab,
+ setSelectedNodeId,
+ setSelectedPanelActiveTab,
+} from '../../core/state/panel/panelSlice';
+import { setFocusNode } from '../../core/state/workflow/workflowSlice';
+import { NodeNavigation } from '../NodeNavigation';
+
+describe('NodeNavigation (designer-v2)', () => {
+ nodeNavigationTestSuite({
+ Navigation: NodeNavigation,
+ panelReducer,
+ changePanelNode,
+ setSelectedNodeId,
+ setAlternateSelectedNode,
+ setNodeSelection,
+ setFocusNode,
+ setSelectedPanelActiveTab,
+ setPinnedPanelActiveTab,
+ });
+});
diff --git a/libs/designer-v2/src/lib/ui/__test__/nodeNavigationTestSuite.tsx b/libs/designer-v2/src/lib/ui/__test__/nodeNavigationTestSuite.tsx
new file mode 100644
index 00000000000..ffedded4dfd
--- /dev/null
+++ b/libs/designer-v2/src/lib/ui/__test__/nodeNavigationTestSuite.tsx
@@ -0,0 +1,500 @@
+import { configureStore, createAction, type PayloadActionCreator, type Reducer, type UnknownAction } from '@reduxjs/toolkit';
+import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { ReactFlowProvider, useReactFlow, type Node, type ReactFlowInstance } from '@xyflow/react';
+import { createRef, type ComponentType, type RefObject } from 'react';
+import { Provider, useSelector } from 'react-redux';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { PanelContent } from '../../../../../designer-ui/src/lib/panel/panelcontent';
+
+interface NavigationPanelState {
+ isCollapsed: boolean;
+ operationContent: {
+ selectedNodeId?: string;
+ selectedNodeIds?: string[];
+ selectedNodeActiveTabId?: string;
+ alternateSelectedNode?: { nodeId?: string; activeTabId?: string; persistence?: 'selected' | 'pinned' };
+ };
+ connectionContent: { selectedNodeIds: string[] };
+}
+
+interface NavigationContract {
+ Navigation: ComponentType<{ canvasRef: RefObject; onNavigate: () => void }>;
+ panelReducer: Reducer;
+ changePanelNode: PayloadActionCreator;
+ setSelectedNodeId: PayloadActionCreator;
+ setFocusNode: PayloadActionCreator;
+ setSelectedPanelActiveTab: PayloadActionCreator;
+ setPinnedPanelActiveTab: PayloadActionCreator;
+ setAlternateSelectedNode: PayloadActionCreator<{
+ nodeId: string;
+ updatePanelOpenState?: boolean;
+ panelPersistence?: 'selected' | 'pinned';
+ }>;
+ setNodeSelection?: PayloadActionCreator;
+}
+
+interface NavigationOptions {
+ suppressDefaultNodeSelectFunctionality: boolean;
+ nodeSelectAdditionalCallback?: (id: string) => void;
+ readOnly: boolean;
+ isMonitoringView: boolean;
+}
+
+const updateHostOptions = createAction>('test/updateHostOptions');
+
+const operation = (id: string, nodeIndex: number, overrides: Partial = {}): Node => ({
+ id,
+ data: { nodeIndex },
+ type: 'OPERATION_NODE',
+ position: { x: 0, y: 0 },
+ ...overrides,
+});
+
+export const nodeNavigationTestSuite = ({
+ Navigation,
+ panelReducer,
+ changePanelNode,
+ setSelectedNodeId,
+ setFocusNode,
+ setSelectedPanelActiveTab,
+ setPinnedPanelActiveTab,
+ setAlternateSelectedNode,
+ setNodeSelection,
+}: NavigationContract) => {
+ const restoreLayout: (() => void)[] = [];
+ const setup = ({
+ selectedId = 'First',
+ suppress = false,
+ callback,
+ readOnly = false,
+ isMonitoringView = false,
+ includePanel = false,
+ nodes = [
+ operation('Last', 80, { position: { x: 10000, y: 10000 } }),
+ operation('Scope-#scope', 20, { type: 'SCOPE_CARD_NODE' }),
+ operation('First', 1),
+ ],
+ }: {
+ selectedId?: string | null;
+ suppress?: boolean;
+ callback?: (id: string) => void;
+ readOnly?: boolean;
+ isMonitoringView?: boolean;
+ includePanel?: boolean;
+ nodes?: Node[];
+ } = {}) => {
+ if (includePanel) {
+ // JSDOM has no layout; give Fluent's overflow calculation room for all tabs.
+ const width = function (this: HTMLElement) {
+ return this.getAttribute('role') === 'tablist' ? 800 : 80;
+ };
+ const clientWidth = vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(width);
+ const offsetWidth = vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(width);
+ const bounds = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
+ return new DOMRect(0, 0, width.call(this), 32);
+ });
+ restoreLayout.push(() => {
+ clientWidth.mockRestore();
+ offsetWidth.mockRestore();
+ bounds.mockRestore();
+ });
+ }
+ const initialOptions: NavigationOptions = {
+ suppressDefaultNodeSelectFunctionality: suppress,
+ nodeSelectAdditionalCallback: callback,
+ readOnly,
+ isMonitoringView,
+ };
+ const store = configureStore({
+ reducer: {
+ panel: panelReducer,
+ designerOptions: (state = initialOptions, action: UnknownAction) =>
+ updateHostOptions.match(action) ? { ...state, ...action.payload } : state,
+ },
+ middleware: (getDefaultMiddleware) => getDefaultMiddleware({ serializableCheck: false }),
+ });
+ if (selectedId !== null) {
+ store.dispatch(setSelectedNodeId(selectedId));
+ }
+ const dispatch = vi.spyOn(store, 'dispatch');
+ const onNavigate = vi.fn();
+ const canvasRef = createRef();
+ const flowRef: { current?: ReactFlowInstance } = {};
+ const Details = () => {
+ const { selectedNodeId = '', selectedNodeActiveTabId } = useSelector((state: { panel: PanelState }) => state.panel.operationContent);
+ const tabs = [
+ { id: 'PARAMETERS', title: 'Parameters', visible: true, order: 0, content: {selectedNodeId} parameters
},
+ ...(selectedNodeId === 'Last'
+ ? []
+ : [{ id: 'SETTINGS', title: 'Settings', visible: true, order: 1, content: {selectedNodeId} settings
}]),
+ { id: 'ABOUT', title: 'About', visible: true, order: 2, content: {selectedNodeId} about
},
+ ];
+ return (
+
+
+
+
+
+
+ Draft
+
+
+
+
+
store.dispatch(setSelectedPanelActiveTab(tabId))}
+ trackEvent={vi.fn()}
+ />
+
+ );
+ };
+ const Harness = () => {
+ flowRef.current = useReactFlow();
+ return (
+
+
+ Last in graph, first in DOM
+
+
+ First in graph, last in DOM
+
+
+
+
+
+ Editable
+
+
+ {includePanel ? (
+ <>
+
+
+ >
+ ) : null}
+
+ );
+ };
+ const result = render(
+
+
+ Outside the canvas
+
+
+
+
+
+ {includePanel ?
: null}
+
+
+
+
+
+
+
+
+
+ );
+ return { ...result, store, dispatch, onNavigate, flowRef };
+ };
+
+ const press = (key: 'ArrowDown' | 'ArrowUp', modifiers: KeyboardEventInit = { ctrlKey: true }, target = screen.getByTestId('canvas')) => {
+ target.focus();
+ const event = new KeyboardEvent('keydown', { key, code: key, bubbles: true, cancelable: true, ...modifiers });
+ fireEvent(target, event);
+ fireEvent.keyUp(target, { key, code: key, ...modifiers });
+ return event;
+ };
+
+ afterEach(() => {
+ cleanup();
+ restoreLayout.splice(0).forEach((restore) => restore());
+ });
+
+ describe.each(['ctrlKey', 'metaKey'] as const)('real %s arrow events', (modifier) => {
+ const modifiers = { [modifier]: true };
+
+ it('selects the normalized scope action and focuses the actual graph card, not the DOM order', () => {
+ const { store, dispatch, onNavigate } = setup();
+ const event = press('ArrowDown', modifiers, screen.getByTestId('last-dom-node'));
+ expect(event.defaultPrevented).toBe(true);
+ expect(dispatch).toHaveBeenNthCalledWith(1, changePanelNode('Scope'));
+ expect(dispatch).toHaveBeenNthCalledWith(2, setFocusNode('Scope-#scope'));
+ expect(store.getState().panel.operationContent.selectedNodeId).toBe('Scope');
+ expect(store.getState().panel.isCollapsed).toBe(false);
+ expect(onNavigate).toHaveBeenCalledOnce();
+ expect(onNavigate.mock.invocationCallOrder[0]).toBeLessThan(dispatch.mock.invocationCallOrder[0]);
+ });
+
+ it('uses fresh selection and getNodes state repeatedly, including an offscreen node absent from the DOM', async () => {
+ const { store, dispatch, flowRef } = setup();
+ press('ArrowDown', modifiers);
+ expect(store.getState().panel.operationContent.selectedNodeId).toBe('Scope');
+ const moved = operation('NewOffscreen', 30, { position: { x: -20000, y: 20000 } });
+ act(() => flowRef.current?.setNodes((nodes) => [moved, ...nodes]));
+ await waitFor(() => expect(flowRef.current?.getNodes().some(({ id }) => id === 'NewOffscreen')).toBe(true));
+ press('ArrowDown', modifiers);
+ expect(store.getState().panel.operationContent.selectedNodeId).toBe('NewOffscreen');
+ expect(dispatch).toHaveBeenLastCalledWith(setFocusNode('NewOffscreen'));
+ expect(screen.queryByText('NewOffscreen')).not.toBeInTheDocument();
+ press('ArrowUp', modifiers);
+ expect(store.getState().panel.operationContent.selectedNodeId).toBe('Scope');
+ press('ArrowUp', modifiers);
+ expect(store.getState().panel.operationContent.selectedNodeId).toBe('First');
+ });
+
+ it('uses updated host callbacks and suppression for consecutive commands without remounting', () => {
+ const originalCallback = vi.fn();
+ const replacementCallback = vi.fn();
+ const { store, dispatch } = setup({ callback: originalCallback });
+ const canvas = screen.getByTestId('canvas');
+ press('ArrowDown', modifiers, canvas);
+ expect(originalCallback).toHaveBeenCalledExactlyOnceWith('Scope');
+
+ act(() =>
+ store.dispatch(
+ updateHostOptions({
+ suppressDefaultNodeSelectFunctionality: true,
+ nodeSelectAdditionalCallback: replacementCallback,
+ })
+ )
+ );
+ dispatch.mockClear();
+ press('ArrowDown', modifiers, canvas);
+ expect(dispatch).toHaveBeenNthCalledWith(1, setSelectedNodeId('Last'));
+ expect(dispatch).toHaveBeenNthCalledWith(2, setFocusNode('Last'));
+ expect(replacementCallback).toHaveBeenCalledExactlyOnceWith('Last');
+ expect(originalCallback).toHaveBeenCalledOnce();
+
+ act(() =>
+ store.dispatch(updateHostOptions({ suppressDefaultNodeSelectFunctionality: false, nodeSelectAdditionalCallback: undefined }))
+ );
+ dispatch.mockClear();
+ press('ArrowUp', modifiers, canvas);
+ expect(dispatch).toHaveBeenNthCalledWith(1, changePanelNode('Scope'));
+ expect(dispatch).toHaveBeenNthCalledWith(2, setFocusNode('Scope-#scope'));
+ expect(replacementCallback).toHaveBeenCalledOnce();
+ expect(screen.getByTestId('canvas')).toBe(canvas);
+ });
+
+ it.each([
+ ['ArrowDown', 'Last'],
+ ['ArrowUp', 'First'],
+ ] as const)('prevents browser scrolling at the %s endpoint without wrapping or invoking callbacks', (key, selectedId) => {
+ const callback = vi.fn();
+ const { dispatch, onNavigate, store } = setup({ selectedId, callback });
+ expect(press(key, modifiers).defaultPrevented).toBe(true);
+ expect(store.getState().panel.operationContent.selectedNodeId).toBe(selectedId);
+ expect(dispatch).not.toHaveBeenCalled();
+ expect(callback).not.toHaveBeenCalled();
+ expect(onNavigate).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ ['ArrowDown', 'First'],
+ ['ArrowUp', 'Last'],
+ ] as const)('chooses %s from no selection or stale selection', (key, expected) => {
+ const { store, dispatch } = setup({ selectedId: null });
+ press(key, modifiers);
+ expect(store.getState().panel.operationContent.selectedNodeId).toBe(expected);
+ act(() => store.dispatch(setSelectedNodeId('Deleted')));
+ dispatch.mockClear();
+ press(key, modifiers);
+ expect(dispatch).toHaveBeenNthCalledWith(1, changePanelNode(expected));
+ });
+
+ it('ignores events outside the supplied canvas without preventing their default behavior', () => {
+ const { dispatch, onNavigate } = setup();
+ expect(press('ArrowDown', modifiers, screen.getByTestId('outside')).defaultPrevented).toBe(false);
+ expect(dispatch).not.toHaveBeenCalled();
+ expect(onNavigate).not.toHaveBeenCalled();
+ });
+
+ it('navigates from the selected details panel Close button and retained tab', () => {
+ const { store, dispatch } = setup({ includePanel: true });
+ expect(press('ArrowDown', modifiers, screen.getByRole('button', { name: 'Close details' })).defaultPrevented).toBe(true);
+ expect(store.getState().panel.operationContent.selectedNodeId).toBe('Scope');
+ expect(dispatch).toHaveBeenLastCalledWith(setFocusNode('Scope-#scope'));
+ const settings = screen.getByRole('tab', { name: 'Settings' });
+ fireEvent.click(settings);
+ press('ArrowUp', modifiers, settings);
+ expect(store.getState().panel.operationContent.selectedNodeId).toBe('First');
+ expect(store.getState().panel.operationContent.selectedNodeActiveTabId).toBe('SETTINGS');
+ });
+
+ it.each(['Pinned control', 'Other designer control', 'Dialog control'])('ignores %s outside the selected panel scope', (name) => {
+ const { dispatch } = setup({ includePanel: true });
+ expect(press('ArrowDown', modifiers, screen.getByRole('button', { name })).defaultPrevented).toBe(false);
+ expect(dispatch).not.toHaveBeenCalled();
+ });
+
+ it.each(['Panel input', 'Panel textarea', 'Panel select', 'panel-rich-editor'])('preserves editing keys in %s', (name) => {
+ const { dispatch } = setup({ includePanel: true });
+ const target = name === 'panel-rich-editor' ? screen.getByTestId(name) : screen.getByLabelText(name);
+ if (name === 'panel-rich-editor') {
+ Object.defineProperty(target, 'isContentEditable', { value: true });
+ }
+ expect(press('ArrowDown', modifiers, target).defaultPrevented).toBe(false);
+ expect(press('ArrowUp', modifiers, target).defaultPrevented).toBe(false);
+ expect(dispatch).not.toHaveBeenCalled();
+ });
+
+ it.each(['Input editor', 'Select editor', 'Textarea editor', 'rich-editor', 'rich-editor-child'])(
+ 'leaves both arrow directions to %s',
+ (editor) => {
+ const { dispatch, onNavigate } = setup();
+ const target = editor.startsWith('rich-') ? screen.getByTestId(editor) : screen.getByLabelText(editor);
+ if (editor.startsWith('rich-')) {
+ // JSDOM does not implement the inherited isContentEditable browser property.
+ Object.defineProperty(target, 'isContentEditable', { value: true });
+ }
+ expect(press('ArrowDown', modifiers, target).defaultPrevented).toBe(false);
+ expect(press('ArrowUp', modifiers, target).defaultPrevented).toBe(false);
+ expect(dispatch).not.toHaveBeenCalled();
+ expect(onNavigate).not.toHaveBeenCalled();
+ }
+ );
+ });
+
+ it.each([{ ctrlKey: true, shiftKey: true }, { metaKey: true, altKey: true }, { ctrlKey: true, metaKey: true }, {}])(
+ 'does not consume unregistered modifier combinations %j',
+ (modifiers) => {
+ const { dispatch } = setup();
+ expect(press('ArrowDown', modifiers).defaultPrevented).toBe(false);
+ expect(press('ArrowUp', modifiers).defaultPrevented).toBe(false);
+ expect(dispatch).not.toHaveBeenCalled();
+ }
+ );
+
+ it.each([false, true])('calls the host with normalized IDs while honoring suppression=%s', (suppress) => {
+ const callback = vi.fn();
+ const { store, dispatch, onNavigate } = setup({ suppress, callback });
+ press('ArrowDown');
+ expect(callback).toHaveBeenCalledExactlyOnceWith('Scope');
+ expect(onNavigate.mock.invocationCallOrder[0]).toBeLessThan(callback.mock.invocationCallOrder[0]);
+ expect(callback.mock.invocationCallOrder[0]).toBeLessThan(dispatch.mock.invocationCallOrder[0]);
+ expect(dispatch).toHaveBeenNthCalledWith(1, suppress ? setSelectedNodeId('Scope') : changePanelNode('Scope'));
+ expect(dispatch).toHaveBeenNthCalledWith(2, setFocusNode('Scope-#scope'));
+ expect(store.getState().panel.isCollapsed).toBe(suppress);
+ });
+
+ it.each([{ readOnly: true }, { isMonitoringView: true }, { readOnly: true, isMonitoringView: true }])(
+ 'keeps non-editing navigation available in %j',
+ (options) => {
+ const { store, dispatch } = setup(options);
+ press('ArrowDown');
+ expect(store.getState().panel.operationContent.selectedNodeId).toBe('Scope');
+ expect(dispatch.mock.calls.map(([action]) => action)).toEqual([changePanelNode('Scope'), setFocusNode('Scope-#scope')]);
+ }
+ );
+
+ it('preserves pinned operation details using the real panel reducer', () => {
+ const { store, dispatch } = setup();
+ act(() => store.dispatch(setAlternateSelectedNode({ nodeId: 'Pinned', panelPersistence: 'pinned' })));
+ dispatch.mockClear();
+ press('ArrowDown');
+ expect(store.getState().panel.operationContent.alternateSelectedNode).toMatchObject({ nodeId: 'Pinned', persistence: 'pinned' });
+ expect(store.getState().panel.operationContent.selectedNodeId).toBe('Scope');
+ });
+
+ describe('operation details tab retention', () => {
+ it.each(['click', 'ctrlKey', 'metaKey'] as const)('retains Settings when another node is selected by %s', (selection) => {
+ const { store } = setup({ includePanel: true });
+ fireEvent.click(screen.getByRole('tab', { name: 'Settings' }));
+ expect(screen.getByText('First settings')).toBeVisible();
+ if (selection === 'click') {
+ fireEvent.click(screen.getByRole('button', { name: 'Select scope' }));
+ } else {
+ press('ArrowDown', { [selection]: true });
+ }
+ expect(store.getState().panel.operationContent.selectedNodeId).toBe('Scope');
+ expect(store.getState().panel.operationContent.selectedNodeActiveTabId).toBe('SETTINGS');
+ expect(screen.getByRole('tab', { name: 'Settings' })).toHaveAttribute('aria-selected', 'true');
+ expect(screen.getByText('Scope settings')).toBeVisible();
+ expect(screen.queryByText('First settings')).not.toBeInTheDocument();
+ });
+
+ it.each(['click', 'ctrlKey', 'metaKey'] as const)('renders a valid fallback when %s selects a node without Settings', (selection) => {
+ const { store } = setup({ includePanel: true, selectedId: 'Scope' });
+ fireEvent.click(screen.getByRole('tab', { name: 'Settings' }));
+ expect(screen.getByText('Scope settings')).toBeVisible();
+ if (selection === 'click') {
+ fireEvent.click(screen.getByRole('button', { name: 'Select last' }));
+ } else {
+ press('ArrowDown', { [selection]: true });
+ }
+ expect(store.getState().panel.operationContent.selectedNodeId).toBe('Last');
+ expect(screen.queryByRole('tab', { name: 'Settings' })).not.toBeInTheDocument();
+ expect(screen.getByRole('tab', { name: 'Parameters' })).toHaveAttribute('aria-selected', 'true');
+ expect(screen.getByText('Last parameters')).toBeVisible();
+ expect(screen.queryByText('Scope settings')).not.toBeInTheDocument();
+ expect(store.getState().panel.operationContent.selectedNodeActiveTabId).toBe('SETTINGS');
+ if (selection === 'click') {
+ fireEvent.click(screen.getByRole('button', { name: 'Select scope' }));
+ } else {
+ press('ArrowUp', { [selection]: true });
+ }
+ expect(screen.getByRole('tab', { name: 'Settings' })).toHaveAttribute('aria-selected', 'true');
+ expect(screen.getByText('Scope settings')).toBeVisible();
+ });
+
+ it('keeps pinned and selected tab preferences independent while navigating', () => {
+ const { store } = setup({ includePanel: true });
+ act(() => {
+ store.dispatch(setAlternateSelectedNode({ nodeId: 'Pinned', panelPersistence: 'pinned' }));
+ store.dispatch(setPinnedPanelActiveTab('ABOUT'));
+ });
+ fireEvent.click(screen.getByRole('tab', { name: 'Settings' }));
+ press('ArrowDown');
+ expect(store.getState().panel.operationContent.selectedNodeActiveTabId).toBe('SETTINGS');
+ expect(store.getState().panel.operationContent.alternateSelectedNode).toMatchObject({
+ nodeId: 'Pinned',
+ persistence: 'pinned',
+ activeTabId: 'ABOUT',
+ });
+ });
+ });
+
+ if (setNodeSelection) {
+ it.each([false, true])('replaces multiselection via the real reducer with suppression=%s', (suppress) => {
+ const { store, dispatch } = setup({ suppress });
+ act(() => store.dispatch(setNodeSelection(['First', 'Last'])));
+ dispatch.mockClear();
+ press('ArrowDown');
+ expect(store.getState().panel.operationContent.selectedNodeIds).toEqual(['Scope']);
+ expect(store.getState().panel.connectionContent.selectedNodeIds).toEqual(['Scope']);
+ expect(store.getState().panel.operationContent.alternateSelectedNode?.nodeId).toBe('');
+ });
+ }
+
+ it('consumes eligible shortcuts safely when no graph nodes are navigable', () => {
+ const { dispatch, onNavigate } = setup({ nodes: [operation('Placeholder', 1, { type: 'PLACEHOLDER_NODE' })] });
+ expect(press('ArrowDown').defaultPrevented).toBe(true);
+ expect(press('ArrowUp').defaultPrevented).toBe(true);
+ expect(dispatch).not.toHaveBeenCalled();
+ expect(onNavigate).not.toHaveBeenCalled();
+ });
+
+ it('unregisters shortcuts when the component unmounts', () => {
+ const { unmount, dispatch } = setup();
+ unmount();
+ const event = new KeyboardEvent('keydown', { key: 'ArrowDown', code: 'ArrowDown', ctrlKey: true, bubbles: true, cancelable: true });
+ fireEvent(document, event);
+ expect(event.defaultPrevented).toBe(false);
+ expect(dispatch).not.toHaveBeenCalled();
+ });
+};
diff --git a/libs/designer-v2/src/lib/ui/panel/nodeDetailsPanel/__test__/nodeDetailsPanel.spec.tsx b/libs/designer-v2/src/lib/ui/panel/nodeDetailsPanel/__test__/nodeDetailsPanel.spec.tsx
new file mode 100644
index 00000000000..77ddf271cbd
--- /dev/null
+++ b/libs/designer-v2/src/lib/ui/panel/nodeDetailsPanel/__test__/nodeDetailsPanel.spec.tsx
@@ -0,0 +1,378 @@
+import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react';
+import { IntlProvider } from 'react-intl';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { CommonPanelProps, PanelContainerProps } from '@microsoft/designer-ui';
+import { PanelContainer, PanelLocation, PanelScope } from '@microsoft/designer-ui';
+import { SUBGRAPH_TYPES } from '@microsoft/logic-apps-shared';
+import { NodeDetailsPanel } from '../nodeDetailsPanel';
+import { usePanelNodeData } from '../usePanelNodeData';
+import { useRawInputsOutputs } from '../useRawInputsOutputs';
+import { isOperationNameValid } from '../../../../core/utils/graph';
+
+const mocks = vi.hoisted(() => ({
+ dispatch: vi.fn(),
+ selected: 'Action',
+ alternate: undefined as { nodeId: string; persistence: 'selected' | 'pinned' } | undefined,
+ readOnly: false,
+ collapsed: false,
+ suppressFocus: false,
+ isA2A: false,
+ isTrigger: false,
+ nodeType: 'Http',
+ runMode: 'Run',
+ runData: undefined as { canResubmit?: boolean } | undefined,
+ runInstance: undefined as { name?: string } | undefined,
+ undoRedo: false,
+ resubmit: vi.fn(),
+ monitor: vi.fn(),
+ hostAvailable: true,
+ monitorAvailable: true,
+ validate: vi.fn<(...args: unknown[]) => string[]>(() => ['validation error']),
+ encode: vi.fn<(...args: unknown[]) => boolean>(() => true),
+ state: {
+ workflow: {
+ operations: { Action: { type: 'Http' } },
+ nodesMetadata: {},
+ idReplacements: {},
+ },
+ operations: {
+ operationInfo: { Action: { type: 'Http' } },
+ inputParameters: {} as Record }>,
+ },
+ },
+}));
+
+vi.mock('react-redux', () => ({
+ useDispatch: () => mocks.dispatch,
+ useSelector: (selector: (state: typeof mocks.state) => unknown) => selector(mocks.state),
+}));
+vi.mock('../../../../core', () => ({
+ clearPanel: () => ({ type: 'clearPanel' }),
+ collapsePanel: () => ({ type: 'collapsePanel' }),
+ updateParameterValidation: (payload: unknown) => ({ type: 'updateParameterValidation', payload }),
+ validateParameter: (...args: unknown[]) => mocks.validate(...args),
+}));
+vi.mock('../../../../core/utils/parameters/helper', () => ({
+ shouldEncodeParameterValueForOperationBasedOnMetadata: (...args: unknown[]) => mocks.encode(...args),
+}));
+vi.mock('../../../../core/state/designerOptions/designerOptionsSelectors', () => ({
+ useReadOnly: () => mocks.readOnly,
+ useSuppressDefaultNodeSelectFunctionality: () => mocks.suppressFocus,
+}));
+vi.mock('../../../../core/state/designerView/designerViewSelectors', () => ({
+ useIsA2AWorkflow: () => mocks.isA2A,
+}));
+vi.mock('../../../../core/state/designerView/designerViewSlice', () => ({
+ setShowDeleteModalNodeId: (payload: string) => ({ type: 'setShowDeleteModalNodeId', payload }),
+}));
+vi.mock('../../../../core/state/panel/panelSelectors', () => ({
+ useIsPanelCollapsed: () => mocks.collapsed,
+ useOperationAlternateSelectedNode: () => mocks.alternate,
+ useOperationPanelSelectedNodeId: () => mocks.selected,
+}));
+vi.mock('../../../../core/state/panel/panelSlice', () => ({
+ setAlternateSelectedNode: (payload: unknown) => ({ type: 'setAlternateSelectedNode', payload }),
+ updatePanelLocation: (payload: string) => ({ type: 'updatePanelLocation', payload }),
+}));
+vi.mock('../../../../core/state/undoRedo/undoRedoSelectors', () => ({
+ useUndoRedoClickToggle: () => mocks.undoRedo,
+}));
+vi.mock('../../../../core/state/workflow/workflowSelectors', () => ({
+ useActionMetadata: () => ({ type: mocks.nodeType }),
+ useRunData: () => mocks.runData,
+ useRunInstance: () => mocks.runInstance,
+ useRunMode: () => mocks.runMode,
+}));
+vi.mock('../../../../core/state/workflow/workflowSlice', () => ({
+ replaceId: (payload: unknown) => ({ type: 'replaceId', payload }),
+ setNodeDescription: (payload: unknown) => ({ type: 'setNodeDescription', payload }),
+}));
+vi.mock('../../../../core/utils/graph', () => ({
+ isTriggerNode: () => mocks.isTrigger,
+ isOperationNameValid: vi.fn(),
+}));
+vi.mock('../usePanelNodeData', () => ({ usePanelNodeData: vi.fn() }));
+vi.mock('../useRawInputsOutputs', () => ({ useRawInputsOutputs: vi.fn() }));
+vi.mock('@microsoft/logic-apps-shared', async (importOriginal) => ({
+ ...(await importOriginal()),
+ WorkflowService: () => ({ resubmitWorkflow: mocks.resubmit }),
+ HostService: () => (mocks.hostAvailable ? { openMonitorView: mocks.monitorAvailable ? mocks.monitor : undefined } : undefined),
+}));
+vi.mock('@microsoft/designer-ui', () => ({
+ PanelScope: { CardLevel: 'CARD_LEVEL' },
+ PanelLocation: { Left: 'LEFT', Right: 'RIGHT' },
+ PanelContainer: vi.fn((props: PanelContainerProps) => (
+
+
{props.nodeHeaderItems}
+
{props.alternateSelectedNodeHeaderItems}
+
+ )),
+}));
+vi.mock('../../../menuItems/commentMenuItem', () => ({
+ CommentMenuItem: ({ onClick, hasComment }: { onClick: () => void; hasComment: boolean }) => (
+
+ ),
+}));
+vi.mock('../../../menuItems/pinMenuItem', () => ({
+ PinMenuItem: ({ onClick }: { onClick: () => void }) => ,
+}));
+vi.mock('../../../menuItems/deleteMenuItem', () => ({
+ DeleteMenuItem: ({ onClick }: { onClick: () => void }) => ,
+}));
+
+const props: CommonPanelProps = {
+ isCollapsed: false,
+ toggleCollapse: vi.fn(),
+ panelLocation: PanelLocation.Right,
+ isResizeable: true,
+};
+
+const nodeData = (nodeId: string): NonNullable> => ({
+ nodeId,
+ displayName: nodeId,
+ iconUri: '',
+ isError: false,
+ isLoading: false,
+ tabs: [],
+ onSelectTab: vi.fn(),
+ comment: undefined,
+ errorMessage: undefined,
+ runData: undefined,
+ selectedTab: undefined,
+ subgraphType: undefined,
+});
+
+const panelProps = (): PanelContainerProps => {
+ const latest = vi.mocked(PanelContainer).mock.calls.at(-1)?.[0];
+ if (!latest) {
+ throw new Error('PanelContainer was not rendered');
+ }
+ return latest;
+};
+
+const mountPanel = (overrides: Partial = {}) =>
+ render(
+
+
+
+ );
+
+describe('NodeDetailsPanel', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.selected = 'Action';
+ mocks.alternate = undefined;
+ mocks.readOnly = false;
+ mocks.collapsed = false;
+ mocks.suppressFocus = false;
+ mocks.isA2A = false;
+ mocks.isTrigger = false;
+ mocks.nodeType = 'Http';
+ mocks.runMode = 'Run';
+ mocks.runData = undefined;
+ mocks.runInstance = undefined;
+ mocks.undoRedo = false;
+ mocks.hostAvailable = true;
+ mocks.monitorAvailable = true;
+ mocks.state.workflow.operations.Action.type = 'Http';
+ mocks.state.operations.operationInfo.Action.type = 'Http';
+ mocks.state.operations.inputParameters = {};
+ vi.mocked(useRawInputsOutputs, { partial: true }).mockReturnValue({ data: undefined });
+ vi.mocked(usePanelNodeData).mockImplementation((nodeId) => (nodeId ? nodeData(nodeId) : undefined));
+ vi.mocked(isOperationNameValid).mockReturnValue({ isValid: true, message: '' });
+ });
+
+ afterEach(cleanup);
+
+ it('opts v2 into navigation and passes selected/pinned data and panel options', () => {
+ mocks.alternate = { nodeId: 'Pinned', persistence: 'pinned' };
+ mocks.collapsed = true;
+ mocks.readOnly = true;
+ mocks.suppressFocus = true;
+ mountPanel();
+ expect(panelProps()).toMatchObject({
+ enableNodeNavigation: true,
+ panelScope: PanelScope.CardLevel,
+ isCollapsed: true,
+ isResizeable: true,
+ readOnlyMode: true,
+ suppressDefaultNodeSelectFunctionality: true,
+ node: { nodeId: 'Action' },
+ alternateSelectedNode: { nodeId: 'Pinned' },
+ alternateSelectedNodePersistence: 'pinned',
+ });
+ expect(mocks.dispatch).toHaveBeenCalledWith({ type: 'updatePanelLocation', payload: PanelLocation.Right });
+ expect(useRawInputsOutputs).toHaveBeenCalledWith('Action');
+ });
+
+ it('updates location and resize state without losing the navigation opt-in', () => {
+ const { rerender } = mountPanel();
+ act(() => panelProps().setOverrideWidth?.('700px'));
+ expect(panelProps().overrideWidth).toBe('700px');
+ rerender(
+
+
+
+ );
+ expect(mocks.dispatch).toHaveBeenLastCalledWith({ type: 'updatePanelLocation', payload: PanelLocation.Left });
+ expect(panelProps().enableNodeNavigation).toBe(true);
+ });
+
+ it('dispatches selected and alternate menu actions for the correct node', () => {
+ mocks.alternate = { nodeId: 'Pinned', persistence: 'pinned' };
+ vi.mocked(usePanelNodeData).mockImplementation((nodeId) =>
+ nodeId ? { ...nodeData(nodeId), comment: nodeId === 'Pinned' ? 'Existing comment' : undefined } : undefined
+ );
+ mountPanel();
+ fireEvent.click(within(screen.getByTestId('selected-menu')).getByText('Add comment'));
+ expect(mocks.dispatch).toHaveBeenLastCalledWith({ type: 'setNodeDescription', payload: { nodeId: 'Action', description: '' } });
+ fireEvent.click(within(screen.getByTestId('alternate-menu')).getByText('Remove comment'));
+ expect(mocks.dispatch).toHaveBeenLastCalledWith({ type: 'setNodeDescription', payload: { nodeId: 'Pinned', description: undefined } });
+ fireEvent.click(within(screen.getByTestId('selected-menu')).getByText('Pin'));
+ expect(mocks.dispatch).toHaveBeenLastCalledWith({ type: 'setAlternateSelectedNode', payload: { nodeId: 'Action' } });
+ expect(within(screen.getByTestId('alternate-menu')).queryByText('Pin')).toBeNull();
+ fireEvent.click(within(screen.getByTestId('alternate-menu')).getByText('Delete'));
+ expect(mocks.dispatch).toHaveBeenLastCalledWith({ type: 'setShowDeleteModalNodeId', payload: 'Pinned' });
+ });
+
+ it('does not offer comments for switch-case containers', () => {
+ vi.mocked(usePanelNodeData).mockReturnValue({
+ ...nodeData('Action'),
+ displayName: 'Case',
+ subgraphType: SUBGRAPH_TYPES.SWITCH_CASE,
+ });
+ mountPanel();
+ expect(screen.queryByText('Add comment')).toBeNull();
+ });
+
+ it.each([
+ { readOnly: false, isA2A: false, agent: false, trigger: true, show: true, hide: false },
+ { readOnly: true, isA2A: false, agent: false, trigger: true, show: false, hide: false },
+ { readOnly: false, isA2A: true, agent: false, trigger: true, show: false, hide: true },
+ { readOnly: false, isA2A: true, agent: true, trigger: false, show: false, hide: false },
+ ])('preserves request/agent trigger presentation: %j', ({ readOnly, isA2A, agent, trigger, show, hide }) => {
+ mocks.isTrigger = true;
+ mocks.readOnly = readOnly;
+ mocks.isA2A = isA2A;
+ mocks.state.operations.operationInfo.Action.type = 'Request';
+ mocks.state.workflow.operations.Action.type = agent ? 'Agent' : 'Request';
+ mountPanel();
+ expect(panelProps()).toMatchObject({ isTrigger: trigger, showTriggerInfo: show, hideComment: hide });
+ expect(within(screen.getByTestId('selected-menu')).queryByText('Add comment') !== null).toBe(!trigger);
+ });
+
+ it.each([true, false])('returns name validation and preserves the old name when valid=%s', (valid) => {
+ vi.mocked(isOperationNameValid).mockReturnValue({ isValid: valid, message: valid ? '' : 'Invalid name' });
+ mountPanel();
+ expect(panelProps().onTitleChange('Action', 'NewName')).toEqual({
+ valid,
+ oldValue: valid ? 'NewName' : 'Action',
+ message: valid ? '' : 'Invalid name',
+ });
+ expect(isOperationNameValid).toHaveBeenCalledWith('Action', 'NewName', false, {}, {}, expect.anything());
+ panelProps().handleTitleUpdate('Action', 'NewName');
+ expect(mocks.dispatch).toHaveBeenLastCalledWith({ type: 'replaceId', payload: { originalId: 'Action', newId: 'NewName' } });
+ });
+
+ it('updates descriptions, dismisses, and unpins via the supplied callbacks', () => {
+ mountPanel();
+ panelProps().onCommentChange('Action', 'New description');
+ expect(mocks.dispatch).toHaveBeenLastCalledWith({
+ type: 'setNodeDescription',
+ payload: { nodeId: 'Action', description: 'New description' },
+ });
+ panelProps().toggleCollapse();
+ expect(mocks.dispatch).toHaveBeenLastCalledWith({ type: 'clearPanel' });
+ panelProps().onUnpinAction?.();
+ expect(mocks.dispatch).toHaveBeenLastCalledWith({
+ type: 'setAlternateSelectedNode',
+ payload: { nodeId: '', updatePanelOpenState: true },
+ });
+ });
+
+ it('validates every parameter before closing and passes the metadata encoding decision', () => {
+ mocks.state.operations.inputParameters.Action = {
+ parameterGroups: {
+ first: { parameters: [{ id: 'one', value: 'value1' }] },
+ second: { parameters: [{ id: 'two', value: 'value2' }] },
+ },
+ };
+ mountPanel();
+ mocks.dispatch.mockClear();
+ panelProps().onClose();
+ expect(mocks.encode).toHaveBeenCalledWith(mocks.state.operations.operationInfo.Action);
+ expect(mocks.validate).toHaveBeenCalledTimes(2);
+ expect(mocks.validate).toHaveBeenCalledWith({ id: 'one', value: 'value1' }, 'value1', undefined, true);
+ expect(mocks.dispatch.mock.calls.map(([action]) => action)).toEqual([
+ {
+ type: 'updateParameterValidation',
+ payload: { nodeId: 'Action', groupId: 'first', parameterId: 'one', validationErrors: ['validation error'] },
+ },
+ {
+ type: 'updateParameterValidation',
+ payload: { nodeId: 'Action', groupId: 'second', parameterId: 'two', validationErrors: ['validation error'] },
+ },
+ { type: 'collapsePanel' },
+ ]);
+ });
+
+ it.each([
+ { alternate: undefined, actions: [{ type: 'collapsePanel' }] },
+ { alternate: { nodeId: 'Pinned', persistence: 'pinned' as const }, actions: [{ type: 'clearPanel' }] },
+ { alternate: { nodeId: 'Pinned', persistence: 'selected' as const }, actions: [{ type: 'collapsePanel' }] },
+ {
+ alternate: { nodeId: 'Action', persistence: 'pinned' as const },
+ actions: [{ type: 'setAlternateSelectedNode', payload: { nodeId: '' } }, { type: 'collapsePanel' }],
+ },
+ ])('closes without parameters while preserving distinct pinned panels: %j', ({ alternate, actions }) => {
+ mocks.alternate = alternate;
+ mountPanel();
+ mocks.dispatch.mockClear();
+ panelProps().onClose();
+ expect(mocks.dispatch.mock.calls.map(([action]) => action)).toEqual(actions);
+ expect(mocks.validate).not.toHaveBeenCalled();
+ });
+
+ it.each(['Run', 'Draft'])('gates resubmission in %s mode and dispatches the selected run', (mode) => {
+ mocks.runMode = mode;
+ mocks.runData = { canResubmit: true };
+ mocks.runInstance = { name: 'run-1' };
+ mountPanel();
+ expect(panelProps().canResubmit).toBe(mode !== 'Draft');
+ panelProps().resubmitOperation?.('Action');
+ expect(mocks.resubmit).toHaveBeenCalledWith('run-1', ['Action']);
+ expect(mocks.dispatch).toHaveBeenLastCalledWith({ type: 'clearPanel' });
+ });
+
+ it('does not resubmit or dismiss without a run instance', () => {
+ mountPanel();
+ mocks.dispatch.mockClear();
+ panelProps().resubmitOperation?.('Action');
+ expect(panelProps().canResubmit).toBe(false);
+ expect(mocks.resubmit).not.toHaveBeenCalled();
+ expect(mocks.dispatch).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ { type: 'Workflow', host: true, monitor: true, runName: 'child-run', workflowId: '/workflows/child', visible: true, opens: true },
+ { type: 'Http', host: true, monitor: true, runName: 'child-run', workflowId: undefined, visible: false, opens: false },
+ { type: 'Workflow', host: false, monitor: true, runName: 'child-run', workflowId: '/workflows/child', visible: false, opens: false },
+ { type: 'Workflow', host: true, monitor: false, runName: 'child-run', workflowId: '/workflows/child', visible: false, opens: false },
+ { type: 'Workflow', host: true, monitor: true, runName: undefined, workflowId: '/workflows/child', visible: false, opens: false },
+ ])('uses raw child-workflow data and available host capabilities: %j', ({ type, host, monitor, runName, workflowId, visible, opens }) => {
+ mocks.nodeType = type;
+ mocks.hostAvailable = host;
+ mocks.monitorAvailable = monitor;
+ vi.mocked(useRawInputsOutputs, { partial: true }).mockReturnValue({
+ data: { inputs: { host: { workflow: { id: workflowId } } }, outputs: { headers: { 'x-ms-workflow-run-id': runName } } },
+ });
+ mountPanel();
+ expect(panelProps().canShowLogicAppRun).toBe(visible);
+ panelProps().showLogicAppRun?.();
+ if (opens) {
+ expect(mocks.monitor).toHaveBeenCalledWith(workflowId, runName);
+ } else {
+ expect(mocks.monitor).not.toHaveBeenCalled();
+ }
+ });
+});
diff --git a/libs/designer-v2/src/lib/ui/panel/nodeDetailsPanel/nodeDetailsPanel.tsx b/libs/designer-v2/src/lib/ui/panel/nodeDetailsPanel/nodeDetailsPanel.tsx
index 82727f7a0e9..c60f1200ae8 100644
--- a/libs/designer-v2/src/lib/ui/panel/nodeDetailsPanel/nodeDetailsPanel.tsx
+++ b/libs/designer-v2/src/lib/ui/panel/nodeDetailsPanel/nodeDetailsPanel.tsx
@@ -218,6 +218,7 @@ export const NodeDetailsPanel = (props: CommonPanelProps): JSX.Element => {
{
+ let state = reducer(initialState, changePanelNode('First'));
+ state = reducer(state, setSelectedPanelActiveTab('SETTINGS'));
+ state = reducer(state, setAlternateSelectedNode({ nodeId: 'Pinned', panelPersistence: 'pinned' }));
+ return reducer(state, setPinnedPanelActiveTab('ABOUT'));
+};
+
+describe('panel tab preference (designer)', () => {
+ it.each([
+ { name: 'setSelectedNodeId', action: setSelectedNodeId('Second') },
+ { name: 'openPanel with nodeId', action: openPanel({ panelMode: 'Operation', nodeId: 'Second' }) },
+ { name: 'openPanel with nodeIds', action: openPanel({ panelMode: 'Operation', nodeIds: ['Second'] }) },
+ ])('$name preserves the selected preference and separate pinned tab', ({ action }) => {
+ const previous = selectedAndPinned();
+ const state = reducer(previous, action);
+ expect(state.operationContent.selectedNodeId).toBe('Second');
+ expect(state.operationContent.selectedNodeActiveTabId).toBe('SETTINGS');
+ expect(state.operationContent.alternateSelectedNode).toEqual(previous.operationContent.alternateSelectedNode);
+ expect(previous.operationContent.selectedNodeId).toBe('First');
+ });
+
+ it('resets the selected tab on v1 mouse selection while preserving the pinned tab', () => {
+ let state = reducer(selectedAndPinned(), changePanelNode('Second'));
+ state = reducer(state, setSelectedPanelActiveTab('CODE_VIEW'));
+ state = reducer(state, changePanelNode('Third'));
+ state = reducer(state, changePanelNode('First'));
+ expect(state.operationContent.selectedNodeId).toBe('First');
+ expect(state.operationContent.selectedNodeActiveTabId).toBeUndefined();
+ expect(state.operationContent.alternateSelectedNode?.activeTabId).toBe('ABOUT');
+ });
+
+ it('keeps an explicitly cleared preference unset when selecting another node', () => {
+ const cleared = reducer(selectedAndPinned(), setSelectedPanelActiveTab(undefined));
+ const state = reducer(cleared, changePanelNode('Second'));
+ expect(state.operationContent.selectedNodeActiveTabId).toBeUndefined();
+ expect(state.operationContent.alternateSelectedNode?.activeTabId).toBe('ABOUT');
+ });
+
+ it('changing the pinned tab does not change the selected preference', () => {
+ const state = reducer(selectedAndPinned(), setPinnedPanelActiveTab('CODE_VIEW'));
+ expect(state.operationContent.selectedNodeActiveTabId).toBe('SETTINGS');
+ expect(state.operationContent.alternateSelectedNode).toEqual({
+ nodeId: 'Pinned',
+ persistence: 'pinned',
+ activeTabId: 'CODE_VIEW',
+ });
+ });
+
+ it('clearPanel resets the selected preference while preserving the pinned tab', () => {
+ const previous = selectedAndPinned();
+ const state = reducer(previous, clearPanel());
+ expect(state.operationContent.selectedNodeId).toBeUndefined();
+ expect(state.operationContent.selectedNodeActiveTabId).toBeUndefined();
+ expect(state.operationContent.alternateSelectedNode).toEqual(previous.operationContent.alternateSelectedNode);
+ expect(state.isCollapsed).toBe(false);
+ expect(reducer(state, changePanelNode('Second')).operationContent.selectedNodeActiveTabId).toBeUndefined();
+ });
+
+ it('clearPanel with clearPinnedState resets both tab preferences', () => {
+ const state = reducer(selectedAndPinned(), clearPanel({ clearPinnedState: true }));
+ expect(state.operationContent).toEqual(initialState.operationContent);
+ expect(state.isCollapsed).toBe(true);
+ });
+
+ it('resetWorkflowState clears both preferences and selections', () => {
+ const state = reducer(selectedAndPinned(), resetWorkflowState());
+ expect(state).toEqual(initialState);
+ expect(reducer(state, changePanelNode('NextWorkflowNode')).operationContent.selectedNodeActiveTabId).toBeUndefined();
+ });
+});
diff --git a/libs/designer/src/lib/core/state/workflow/workflowSelectors.ts b/libs/designer/src/lib/core/state/workflow/workflowSelectors.ts
index b8f90a18df7..07dd74c45c6 100644
--- a/libs/designer/src/lib/core/state/workflow/workflowSelectors.ts
+++ b/libs/designer/src/lib/core/state/workflow/workflowSelectors.ts
@@ -324,7 +324,7 @@ export const useNewAdditiveSubgraphId = (baseId: string) =>
let caseId = baseId;
let caseCount = 1;
const idList = Object.keys(state.nodesMetadata);
- // eslint-disable-next-line no-loop-func
+
while (idList.some((id) => id === caseId)) {
caseCount++;
caseId = `${baseId}_${caseCount}`;
diff --git a/libs/designer/src/lib/ui/CustomNodes/__test__/ScopeCardNode.spec.tsx b/libs/designer/src/lib/ui/CustomNodes/__test__/ScopeCardNode.spec.tsx
new file mode 100644
index 00000000000..06cbd00abe6
--- /dev/null
+++ b/libs/designer/src/lib/ui/CustomNodes/__test__/ScopeCardNode.spec.tsx
@@ -0,0 +1,470 @@
+import React from 'react';
+import type { ComponentProps } from 'react';
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import { MessageBarType } from '@fluentui/react';
+import type { ScopeCard } from '@microsoft/designer-ui';
+import type { NodeProps } from '@xyflow/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import * as workflow from '../../../core/state/workflow/workflowSelectors';
+import * as options from '../../../core/state/designerOptions/designerOptionsSelectors';
+import * as operation from '../../../core/state/operation/operationSelector';
+import * as panel from '../../../core/state/panel/panelSelectors';
+import { useOperationQuery } from '../../../core/state/selectors/actionMetadataSelector';
+import { useSettingValidationErrors } from '../../../core/state/setting/settingSelector';
+import { useIsA2AWorkflow } from '../../../core/state/designerView/designerViewSelectors';
+import { useAgentActionsRepetition, useAgentRepetition, useNodeRepetition } from '../../../core/queries/runs';
+import ScopeCardNode from '../ScopeCardNode';
+
+const mocks = vi.hoisted(() => ({
+ dispatch: vi.fn(),
+ card: vi.fn(),
+ drag: vi.fn(),
+ dragRef: vi.fn(),
+ dragPreview: vi.fn(),
+ hotkeys: vi.fn(),
+}));
+
+vi.mock('react-redux', () => ({ useDispatch: () => mocks.dispatch }));
+vi.mock('react-dnd', () => ({ useDrag: (...args: unknown[]) => mocks.drag(...args) }));
+vi.mock('react-hotkeys-hook', () => ({ useHotkeys: (...args: unknown[]) => mocks.hotkeys(...args) }));
+
+vi.mock('../../../core/state/designerOptions/designerOptionsSelectors', () => ({
+ useReadOnly: vi.fn(),
+ useMonitoringView: vi.fn(),
+}));
+vi.mock('../../../core/state/designerView/designerViewSelectors', () => ({ useIsA2AWorkflow: vi.fn() }));
+vi.mock('../../../core/state/operation/operationSelector', () => ({
+ useBrandColor: vi.fn(),
+ useIconUri: vi.fn(),
+ useParameterValidationErrors: vi.fn(),
+ useTokenDependencies: vi.fn(),
+}));
+vi.mock('../../../core/state/panel/panelSelectors', () => ({
+ useIsNodePinnedToOperationPanel: vi.fn(),
+ useIsNodeSelectedInOperationPanel: vi.fn(),
+}));
+vi.mock('../../../core/state/selectors/actionMetadataSelector', () => ({
+ useAllOperations: () => ({}),
+ useOperationQuery: vi.fn(),
+}));
+vi.mock('../../../core/state/setting/settingSelector', () => ({ useSettingValidationErrors: vi.fn() }));
+vi.mock('../../../core/state/workflow/workflowSelectors', () => ({
+ useActionMetadata: vi.fn(),
+ useIsGraphCollapsed: vi.fn(),
+ useIsLeafNode: vi.fn(),
+ useNodeDisplayName: () => 'Test scope',
+ useNodeMetadata: vi.fn(),
+ useNodesMetadata: vi.fn(),
+ useRunData: vi.fn(),
+ useParentRunIndex: () => 2,
+ useRunInstance: () => ({ id: 'run-1' }),
+ useParentNodeId: () => 'parent',
+ useNodeDescription: vi.fn(),
+ useShouldNodeFocus: vi.fn(),
+ useRunIndex: vi.fn(),
+ useActionTimelineRepetitionCount: () => 4,
+ useTimelineRepetitionIndex: () => 1,
+ useIsActionInSelectedTimelineRepetition: vi.fn(),
+ useHandoffActionsForAgent: vi.fn(),
+ useFlowErrorsForNode: vi.fn(),
+}));
+vi.mock('../../../core/queries/runs', () => ({
+ useNodeRepetition: vi.fn(),
+ useAgentRepetition: vi.fn(),
+ useAgentActionsRepetition: vi.fn(),
+}));
+
+// Keep the component's dispatch contract observable without loading the store or running thunks.
+vi.mock('../../../core/state/panel/panelSlice', () => ({
+ changePanelNode: (payload: unknown) => ({ type: 'panel/changePanelNode', payload }),
+}));
+vi.mock('../../../core/state/designerView/designerViewSlice', () => ({
+ setNodeContextMenuData: (payload: unknown) => ({ type: 'designerView/setNodeContextMenuData', payload }),
+ setShowDeleteModalNodeId: (payload: unknown) => ({ type: 'designerView/setShowDeleteModalNodeId', payload }),
+}));
+vi.mock('../../../core/state/workflow/workflowSlice', () => ({
+ setFocusElement: (payload: unknown) => ({ type: 'workflow/setFocusElement', payload }),
+ setRepetitionRunData: (payload: unknown) => ({ type: 'workflow/setRepetitionRunData', payload }),
+ setSubgraphRunData: (payload: unknown) => ({ type: 'workflow/setSubgraphRunData', payload }),
+ toggleCollapsedGraphId: (payload: unknown) => ({ type: 'workflow/toggleCollapsedGraphId', payload }),
+ updateAgenticGraph: (payload: unknown) => ({ type: 'workflow/updateAgenticGraph', payload }),
+ updateAgenticMetadata: (payload: unknown) => ({ type: 'workflow/updateAgenticMetadata', payload }),
+}));
+vi.mock('../../../core/actions/bjsworkflow/move', () => ({
+ moveOperation: (payload: unknown) => ({ type: 'moveOperation', payload }),
+}));
+vi.mock('../../../core/actions/bjsworkflow/copypaste', () => ({
+ copyScopeOperation: (payload: unknown) => ({ type: 'copyScopeOperation', payload }),
+}));
+
+vi.mock('@microsoft/logic-apps-shared', async (importOriginal) => ({
+ ...((await importOriginal()) as object),
+ useNodeIndex: () => 7,
+}));
+vi.mock('../../common/LoopsPager/helper', () => ({
+ getRepetitionName: () => '000002',
+ getScopeRepetitionName: () => '000001',
+}));
+vi.mock('../../common/LoopsPager/LoopsPager', () => ({
+ LoopsPager: ({
+ scopeId,
+ collapsed,
+ focusElement,
+ }: { scopeId: string; collapsed: boolean; focusElement: (index: number, id: string) => void }) => (
+
+ ),
+}));
+vi.mock('../../connections/dropzone', () => ({
+ DropZone: ({ graphId, parentId, tabIndex }: { graphId: string; parentId: string; tabIndex: number }) => (
+
+ ),
+}));
+vi.mock('../../common/DesignerContextualMenu/CopyTooltip', () => ({
+ CopyTooltip: ({ id, hideTooltip }: { id: string; hideTooltip: () => void }) => ,
+}));
+vi.mock('../handles/EdgeDrawTargetHandle', () => ({
+ EdgeDrawTargetHandle: () => ,
+}));
+vi.mock('../handles/EdgeDrawSourceHandle', () => ({
+ EdgeDrawSourceHandle: () => ,
+}));
+vi.mock('../handles/DefaultHandle', () => ({
+ DefaultHandle: ({ type }: { type: string }) => ,
+}));
+vi.mock('@microsoft/designer-ui', () => ({
+ ScopeCard: (props: ComponentProps) => {
+ mocks.card(props);
+ return (
+
+
+
+
+ {props.errorMessage && {props.errorMessage}}
+
+ );
+ },
+}));
+
+const renderScope = (id = 'testScope-#scope') => render();
+const cardProps = (): ComponentProps => mocks.card.mock.lastCall![0];
+const expectAction = (type: string, payload: unknown) => expect(mocks.dispatch).toHaveBeenCalledWith({ type, payload });
+
+// Partial query/selector fixtures deliberately contain only the fields consumed by this component.
+const returns = (hook: unknown, value: unknown) => vi.mocked(hook as () => unknown).mockReturnValue(value);
+const queryResult = (data?: unknown, isFetching = false) => ({ data, isFetching });
+
+describe('legacy ScopeCardNode', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ returns(options.useReadOnly, false);
+ returns(options.useMonitoringView, false);
+ returns(useIsA2AWorkflow, false);
+ returns(operation.useBrandColor, '#123456');
+ returns(operation.useIconUri, 'scope.svg');
+ returns(operation.useParameterValidationErrors, []);
+ returns(operation.useTokenDependencies, { dependencies: ['input'], loopSources: ['loop'] });
+ returns(panel.useIsNodePinnedToOperationPanel, false);
+ returns(panel.useIsNodeSelectedInOperationPanel, false);
+ returns(useOperationQuery, { isLoading: false, isError: false });
+ returns(useSettingValidationErrors, []);
+ returns(workflow.useActionMetadata, { type: 'Scope' });
+ returns(workflow.useIsGraphCollapsed, false);
+ returns(workflow.useIsLeafNode, false);
+ returns(workflow.useNodeMetadata, { graphId: 'old-graph', actionCount: 3 });
+ returns(workflow.useNodesMetadata, {});
+ returns(workflow.useRunData, undefined);
+ returns(workflow.useNodeDescription, undefined);
+ returns(workflow.useShouldNodeFocus, false);
+ returns(workflow.useRunIndex, 1);
+ returns(workflow.useIsActionInSelectedTimelineRepetition, true);
+ returns(workflow.useHandoffActionsForAgent, []);
+ returns(workflow.useFlowErrorsForNode, []);
+ returns(useNodeRepetition, queryResult());
+ returns(useAgentRepetition, queryResult());
+ returns(useAgentActionsRepetition, queryResult());
+ mocks.drag.mockReturnValue([{ isDragging: false }, mocks.dragRef, mocks.dragPreview]);
+ mocks.hotkeys.mockReturnValue(vi.fn());
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('passes legacy identity, appearance, focus, comments and run information to the card', () => {
+ returns(workflow.useNodeDescription, 'Review these actions');
+ returns(workflow.useShouldNodeFocus, true);
+ renderScope();
+
+ expect(screen.getByRole('button', { name: 'Test scope' })).toBeInTheDocument();
+ expect(cardProps()).toMatchObject({
+ id: 'testScope',
+ title: 'Test scope',
+ brandColor: '#123456',
+ icon: 'scope.svg',
+ active: true,
+ showStatusPill: false,
+ isLoading: false,
+ selectionMode: false,
+ setFocus: true,
+ nodeIndex: 7,
+ timelineRepetitionCount: 4,
+ commentBox: { brandColor: '#123456', comment: 'Review these actions', isDismissed: false, isEditing: false },
+ });
+ expect(screen.getByTestId('target-handle')).toBeInTheDocument();
+ expect(screen.getByTestId('default-source-handle')).toBeInTheDocument();
+ expect(screen.queryByTestId('footer-source-handle')).not.toBeInTheDocument();
+ expect(mocks.dispatch).not.toHaveBeenCalled();
+ });
+
+ it('renders nothing for an operation that no longer exists', () => {
+ returns(workflow.useActionMetadata, undefined);
+ const { container } = renderScope();
+ expect(container).toBeEmptyDOMElement();
+ expect(mocks.card).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ [false, false, false],
+ [true, false, 'pinned'],
+ [true, true, 'selected'],
+ ])('maps pinned=%s and selected=%s to selection mode %s', (pinned, selected, mode) => {
+ returns(panel.useIsNodePinnedToOperationPanel, pinned);
+ returns(panel.useIsNodeSelectedInOperationPanel, selected);
+ renderScope();
+ expect(cardProps().selectionMode).toBe(mode);
+ expect(cardProps().commentBox).toBeUndefined();
+ });
+
+ it('selects, deletes, collapses and opens the context menu using the normalized scope id', () => {
+ renderScope();
+ fireEvent.click(screen.getByRole('button', { name: 'Test scope' }));
+ expectAction('panel/changePanelNode', 'testScope');
+ fireEvent.click(screen.getByRole('button', { name: 'Delete' }));
+ expectAction('designerView/setShowDeleteModalNodeId', 'testScope');
+ fireEvent.click(screen.getByRole('button', { name: 'Collapse', exact: true }));
+ expectAction('workflow/toggleCollapsedGraphId', { id: 'testScope', includeNested: undefined });
+ expect(fireEvent.contextMenu(screen.getByTestId('scope-card'), { clientX: 15, clientY: 29 })).toBe(false);
+ expectAction('designerView/setNodeContextMenuData', { nodeId: 'testScope', location: { x: 15, y: 29 } });
+ });
+
+ it('copies the full canvas id through the hotkey and expires the feedback after three seconds', () => {
+ vi.useFakeTimers();
+ renderScope();
+ expect(mocks.hotkeys).toHaveBeenCalledWith(['meta+c', 'ctrl+c'], expect.any(Function), { preventDefault: true });
+ act(() => mocks.hotkeys.mock.lastCall![1]());
+ expectAction('copyScopeOperation', { nodeId: 'testScope-#scope' });
+ expect(screen.getByRole('button', { name: 'Copied testScope' })).toBeInTheDocument();
+ act(() => vi.advanceTimersByTime(2999));
+ expect(screen.getByRole('button', { name: 'Copied testScope' })).toBeInTheDocument();
+ act(() => vi.advanceTimersByTime(1));
+ expect(screen.queryByRole('button', { name: 'Copied testScope' })).not.toBeInTheDocument();
+ });
+
+ it('dismisses copy feedback and clears its pending timeout', () => {
+ vi.useFakeTimers();
+ renderScope();
+ act(() => mocks.hotkeys.mock.lastCall![1]());
+ fireEvent.click(screen.getByRole('button', { name: 'Copied testScope' }));
+ expect(screen.queryByRole('button', { name: 'Copied testScope' })).not.toBeInTheDocument();
+ expect(vi.getTimerCount()).toBe(0);
+ });
+
+ it.each(['old-graph', undefined])('moves a dragged scope from graph %s only after a successful drop', (graphId) => {
+ returns(workflow.useNodeMetadata, { graphId, actionCount: 3 });
+ mocks.drag.mockReturnValue([{ isDragging: true }, mocks.dragRef, mocks.dragPreview]);
+ renderScope();
+ const spec = mocks.drag.mock.lastCall![0]();
+ expect(spec).toMatchObject({
+ type: 'BOX',
+ canDrag: true,
+ item: { id: 'testScope-#scope', dependencies: ['input'], loopSources: ['loop'], isScope: true, isAgent: false },
+ });
+ expect(spec.collect({ isDragging: () => true })).toEqual({ isDragging: true });
+ expect(cardProps()).toMatchObject({ isDragging: true, drag: mocks.dragRef, dragPreview: mocks.dragPreview });
+ const dropResult = { graphId: 'new-graph', parentId: 'before', childId: 'after' };
+ spec.end(undefined, { getDropResult: () => dropResult });
+ spec.end(spec.item, { getDropResult: () => null });
+ expect(mocks.dispatch).not.toHaveBeenCalled();
+ spec.end(spec.item, { getDropResult: () => dropResult });
+ expectAction('moveOperation', {
+ nodeId: 'testScope',
+ oldGraphId: graphId ?? 'root',
+ newGraphId: 'new-graph',
+ relationshipIds: dropResult,
+ });
+ });
+
+ it('offers an insertion dropzone for an editable empty scope', () => {
+ returns(workflow.useIsLeafNode, true);
+ renderScope();
+ expect(screen.getByTestId('dropzone')).toHaveAttribute('data-graph', 'testScope');
+ expect(screen.getByTestId('dropzone')).toHaveAttribute('data-parent', 'testScope-#scope');
+ expect(screen.getByTestId('dropzone')).toHaveAttribute('tabindex', '7');
+ });
+
+ it('disables dragging and replaces the insertion zone with No actions in read-only mode', () => {
+ returns(workflow.useIsLeafNode, true);
+ returns(options.useReadOnly, true);
+ renderScope();
+ expect(screen.getByText('No actions')).toBeInTheDocument();
+ expect(screen.queryByTestId('dropzone')).not.toBeInTheDocument();
+ expect(cardProps()).toMatchObject({ readOnly: true, draggable: false });
+ expect(mocks.drag.mock.lastCall![0]().canDrag).toBe(false);
+ });
+
+ it.each([
+ ['Scope', '3 Actions'],
+ ['Switch', '3 Cases'],
+ ['If', '3 Cases'],
+ ['Agent', '3 Cases'],
+ ])('shows the collapsed %s count without an insertion zone', (type, text) => {
+ returns(workflow.useActionMetadata, { type });
+ returns(workflow.useIsGraphCollapsed, true);
+ returns(workflow.useIsLeafNode, true);
+ renderScope();
+ expect(screen.getByText(text)).toBeInTheDocument();
+ expect(screen.queryByTestId('dropzone')).not.toBeInTheDocument();
+ expect(cardProps().collapsed).toBe(true);
+ });
+
+ it('uses the footer source handle and suppresses collapsed text and empty insertion UI', () => {
+ returns(workflow.useIsGraphCollapsed, true);
+ returns(workflow.useIsLeafNode, true);
+ renderScope('testScope-#footer');
+ expect(screen.getByTestId('footer-source-handle')).toBeInTheDocument();
+ expect(screen.queryByTestId('default-source-handle')).not.toBeInTheDocument();
+ expect(screen.queryByText('3 Actions')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('dropzone')).not.toBeInTheDocument();
+ });
+
+ it.each([false, true])('excludes single-action handoffs from empty agent messaging (monitoring=%s)', (monitoring) => {
+ returns(workflow.useActionMetadata, { type: 'Agent' });
+ returns(workflow.useNodeMetadata, { actionCount: 1 });
+ returns(workflow.useHandoffActionsForAgent, [{ isSingleAction: true }, { isSingleAction: false }]);
+ returns(workflow.useIsLeafNode, true);
+ returns(options.useMonitoringView, monitoring);
+ renderScope();
+ expect(screen.getByText(monitoring ? 'This iteration has completed without any tool execution' : 'Add tool')).toBeInTheDocument();
+ expect(screen.queryByTestId('dropzone')).not.toBeInTheDocument();
+ expect(cardProps().showStatusPill).toBe(false);
+ expect(mocks.drag.mock.lastCall![0]().item.isAgent).toBe(true);
+ });
+
+ it.each(['manifest', 'settings', 'parameters', 'flow'])('prioritizes %s errors over lower-priority errors', (error) => {
+ returns(useOperationQuery, { isError: error === 'manifest' });
+ returns(useSettingValidationErrors, ['manifest', 'settings'].includes(error) ? ['invalid'] : []);
+ returns(operation.useParameterValidationErrors, error !== 'flow' ? ['invalid'] : []);
+ returns(workflow.useFlowErrorsForNode, ['unreachable']);
+ const messages = {
+ manifest: 'Error fetching manifest',
+ settings: 'Invalid settings',
+ parameters: 'Invalid parameters',
+ flow: 'Action unreachable',
+ };
+ renderScope();
+ expect(screen.getByRole('alert')).toHaveTextContent(messages[error as keyof typeof messages]);
+ expect(cardProps().errorLevel).toBe(error === 'manifest' ? MessageBarType.error : MessageBarType.severeWarning);
+ });
+
+ it('shows the real monitoring error and run status for a failed scope', () => {
+ const runData = { status: 'Failed', code: 'BadRequest', error: { code: 'BadRequest', message: 'Invalid input' } };
+ returns(options.useMonitoringView, true);
+ returns(workflow.useRunData, runData);
+ renderScope();
+ expect(screen.getByRole('alert')).toHaveTextContent('BadRequest. Invalid input');
+ expect(cardProps()).toMatchObject({ active: true, showStatusPill: true, runData, errorLevel: MessageBarType.severeWarning });
+ });
+
+ it('marks an unexecuted monitored scope inactive without a status pill', () => {
+ returns(options.useMonitoringView, true);
+ renderScope();
+ expect(cardProps()).toMatchObject({ active: false, showStatusPill: false });
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
+ });
+
+ it.each(['node', 'agent', 'actions', 'manifest', 'appearance'])('shows loading while %s data is unavailable', (source) => {
+ if (source === 'node') returns(useNodeRepetition, queryResult(undefined, true));
+ if (source === 'agent') returns(useAgentRepetition, queryResult(undefined, true));
+ if (source === 'actions') returns(useAgentActionsRepetition, queryResult(undefined, true));
+ if (source === 'manifest') returns(useOperationQuery, { isLoading: true });
+ if (source === 'appearance') {
+ returns(operation.useBrandColor, undefined);
+ returns(operation.useIconUri, undefined);
+ }
+ renderScope();
+ expect(cardProps().isLoading).toBe(true);
+ });
+
+ it('renders the completed loop pager and dispatches focus for its selected repetition', () => {
+ returns(options.useMonitoringView, true);
+ returns(workflow.useActionMetadata, { type: 'Foreach' });
+ returns(workflow.useNodeMetadata, { actionCount: 3, runData: { status: 'Succeeded' } });
+ returns(workflow.useIsGraphCollapsed, true);
+ renderScope();
+ expect(screen.getByTestId('loops-pager')).toHaveAttribute('data-collapsed', 'true');
+ fireEvent.click(screen.getByRole('button', { name: 'Next repetition' }));
+ expectAction('workflow/setFocusElement', 'testScope-3-0');
+ });
+
+ it.each([undefined, [], { status: 'InProgress' }])('omits the loop pager for incomplete or aggregate run data %j', (runData) => {
+ returns(options.useMonitoringView, true);
+ returns(workflow.useActionMetadata, { type: 'Foreach' });
+ returns(workflow.useNodeMetadata, { runData });
+ renderScope();
+ expect(screen.queryByTestId('loops-pager')).not.toBeInTheDocument();
+ });
+
+ it('loads agent repetitions with the scope, run and parent status and dispatches all fresh run data', () => {
+ returns(options.useMonitoringView, true);
+ returns(workflow.useActionMetadata, { type: 'Agent' });
+ returns(workflow.useRunData, { status: 'Succeeded', correlation: { actionTrackingId: 'old' } });
+ const agent = { inputsLink: { uri: 'new-inputs' } };
+ const repetition = { correlation: { actionTrackingId: 'new' }, status: 'Succeeded' };
+ const actions = [{ name: 'tool-1', properties: { status: 'Succeeded' } }];
+ returns(useAgentRepetition, queryResult({ properties: agent }));
+ returns(useNodeRepetition, queryResult({ properties: repetition }));
+ returns(useAgentActionsRepetition, queryResult(actions));
+ renderScope();
+ expect(useNodeRepetition).toHaveBeenCalledWith(true, 'testScope', 'run-1', '000002', 'Succeeded', 2, false);
+ expect(useAgentRepetition).toHaveBeenCalledWith(true, true, 'testScope', 'run-1', '000001', 'Succeeded', 1);
+ expect(useAgentActionsRepetition).toHaveBeenCalledWith(true, 'testScope', 'run-1', '000001', 'Succeeded', 1);
+ expectAction('workflow/setSubgraphRunData', { nodeId: 'testScope', runData: actions });
+ expectAction('workflow/updateAgenticGraph', { nodeId: 'testScope', scopeRepetitionRunData: agent });
+ expectAction('workflow/updateAgenticMetadata', { nodeId: 'testScope', scopeRepetitionRunData: agent });
+ expectAction('workflow/setRepetitionRunData', { nodeId: 'testScope', runData: repetition });
+ expect(mocks.dispatch).toHaveBeenCalledTimes(4);
+ });
+
+ it('does not dispatch duplicate agent inputs or repetition correlation data', () => {
+ returns(options.useMonitoringView, true);
+ returns(workflow.useActionMetadata, { type: 'Agent' });
+ returns(workflow.useNodesMetadata, { testScope: { runData: { inputsLink: { uri: 'existing' } } } });
+ returns(workflow.useRunData, { correlation: { actionTrackingId: 'existing' } });
+ returns(useAgentRepetition, queryResult({ properties: { inputsLink: { uri: 'existing' } } }));
+ returns(useNodeRepetition, queryResult({ properties: { correlation: { actionTrackingId: 'existing' } } }));
+ renderScope();
+ expect(mocks.dispatch).not.toHaveBeenCalled();
+ });
+
+ it('ignores repetition graph updates outside the selected monitoring timeline', () => {
+ returns(options.useMonitoringView, true);
+ returns(workflow.useActionMetadata, { type: 'Agent' });
+ returns(workflow.useIsActionInSelectedTimelineRepetition, false);
+ returns(useAgentRepetition, queryResult({ properties: { inputsLink: { uri: 'other-inputs' } } }));
+ returns(useNodeRepetition, queryResult({ properties: { correlation: { actionTrackingId: 'other' } } }));
+ renderScope();
+ expect(useAgentRepetition).toHaveBeenCalledWith(true, true, 'testScope', undefined, '000001', undefined, 1);
+ expect(mocks.dispatch).not.toHaveBeenCalled();
+ });
+
+ it('disables agent iteration requests in A2A workflows but still loads their action repetitions', () => {
+ returns(options.useMonitoringView, true);
+ returns(workflow.useActionMetadata, { type: 'Agent' });
+ returns(useIsA2AWorkflow, true);
+ renderScope();
+ expect(useAgentRepetition).toHaveBeenCalledWith(false, true, 'testScope', 'run-1', '000001', undefined, 1);
+ expect(useAgentActionsRepetition).toHaveBeenCalledWith(true, 'testScope', 'run-1', '000001', undefined, 1);
+ });
+});
diff --git a/libs/logic-apps-shared/src/utils/src/lib/helpers/__test__/nodeNavigation.spec.ts b/libs/logic-apps-shared/src/utils/src/lib/helpers/__test__/nodeNavigation.spec.ts
new file mode 100644
index 00000000000..23bc9346663
--- /dev/null
+++ b/libs/logic-apps-shared/src/utils/src/lib/helpers/__test__/nodeNavigation.spec.ts
@@ -0,0 +1,106 @@
+import type { Node } from '@xyflow/react';
+import { describe, expect, it } from 'vitest';
+import { WORKFLOW_NODE_TYPES } from '../../models/workflowNode';
+import { getAdjacentNode } from '../nodeNavigation';
+
+const node = (id: string, nodeIndex: unknown, overrides: Partial = {}): Node => ({
+ id,
+ type: WORKFLOW_NODE_TYPES.OPERATION_NODE,
+ position: { x: 0, y: 0 },
+ data: { nodeIndex },
+ ...overrides,
+});
+
+describe('getAdjacentNode', () => {
+ const first = node('Trigger', 1, { position: { x: 1000, y: 1000 } });
+ const scope = node('Scope-#scope', 10, { type: WORKFLOW_NODE_TYPES.SCOPE_CARD_NODE });
+ const nested = node('Nested', 30, { parentId: 'Scope', position: { x: -1000, y: -1000 } });
+ const last = node('Last', 100, { position: { x: 0, y: -2000 } });
+ const nodes = [last, nested, first, scope];
+
+ it.each([
+ ['Trigger', 'next', scope],
+ ['Scope', 'next', nested],
+ ['Nested', 'next', last],
+ ['Last', 'previous', nested],
+ ['Nested', 'previous', scope],
+ ['Scope', 'previous', first],
+ ] as const)('finds the nearest index from %s going %s, regardless of array/geometry order', (selectedId, direction, expected) => {
+ expect(getAdjacentNode(nodes, selectedId, direction)).toBe(expected);
+ });
+
+ it.each([undefined, '', 'Deleted'])('starts at the first/last index when selection is %s', (selectedId) => {
+ expect(getAdjacentNode(nodes, selectedId, 'next')).toBe(first);
+ expect(getAdjacentNode(nodes, selectedId, 'previous')).toBe(last);
+ });
+
+ it('stops at both ends instead of wrapping', () => {
+ expect(getAdjacentNode(nodes, 'Trigger', 'previous')).toBeUndefined();
+ expect(getAdjacentNode(nodes, 'Last', 'next')).toBeUndefined();
+ });
+
+ it('handles empty and single-node graphs', () => {
+ for (const direction of ['next', 'previous'] as const) {
+ expect(getAdjacentNode([], undefined, direction)).toBeUndefined();
+ expect(getAdjacentNode([first], undefined, direction)).toBe(first);
+ expect(getAdjacentNode([first], 'Trigger', direction)).toBeUndefined();
+ }
+ });
+
+ it.each([
+ WORKFLOW_NODE_TYPES.GRAPH_NODE,
+ WORKFLOW_NODE_TYPES.SUBGRAPH_NODE,
+ WORKFLOW_NODE_TYPES.SUBGRAPH_CARD_NODE,
+ WORKFLOW_NODE_TYPES.HIDDEN_NODE,
+ WORKFLOW_NODE_TYPES.PLACEHOLDER_NODE,
+ WORKFLOW_NODE_TYPES.COLLAPSED_NODE,
+ WORKFLOW_NODE_TYPES.NOTE_NODE,
+ undefined,
+ 'unknown',
+ ])('excludes %s even with a valid index', (type) => {
+ const excluded = node('Excluded', 5, { type });
+ expect(getAdjacentNode([first, excluded, scope], 'Trigger', 'next')).toBe(scope);
+ expect(getAdjacentNode([first, excluded, scope], 'Scope', 'previous')).toBe(first);
+ });
+
+ it.each([undefined, null, 0, -1, Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY, '5', true])(
+ 'excludes invalid index %s rather than coercing it',
+ (index) => {
+ const excluded = node('Excluded', index);
+ expect(getAdjacentNode([first, excluded, scope], 'Trigger', 'next')).toBe(scope);
+ expect(getAdjacentNode([excluded], undefined, 'previous')).toBeUndefined();
+ }
+ );
+
+ it('excludes hidden operations and scope cards and treats their selection as unavailable', () => {
+ const hidden = [
+ node('HiddenOperation', 2, { hidden: true }),
+ node('HiddenScope-#scope', 3, { hidden: true, type: WORKFLOW_NODE_TYPES.SCOPE_CARD_NODE }),
+ ];
+ expect(getAdjacentNode([first, ...hidden, scope], 'Trigger', 'next')).toBe(scope);
+ expect(getAdjacentNode([first, ...hidden, scope], 'HiddenScope', 'previous')).toBe(scope);
+ });
+
+ it('accepts positive finite fractional indices and gaps', () => {
+ const fractional = node('Fractional', 1.5);
+ expect(getAdjacentNode([scope, fractional, first], 'Trigger', 'next')).toBe(fractional);
+ });
+
+ it('uses changed layout indices on subsequent calls', () => {
+ const moved = { ...last, data: { nodeIndex: 2 } };
+ expect(getAdjacentNode(nodes, 'Trigger', 'next')).toBe(scope);
+ expect(getAdjacentNode([scope, first, nested, moved], 'Trigger', 'next')).toBe(moved);
+ });
+
+ it('does not mutate the array, node data, or positions', () => {
+ const frozenNodes = Object.freeze(
+ nodes.map((entry) =>
+ Object.freeze({ ...entry, data: Object.freeze({ ...entry.data }), position: Object.freeze({ ...entry.position }) })
+ )
+ );
+ const originalIds = frozenNodes.map(({ id }) => id);
+ expect(getAdjacentNode(frozenNodes, 'Scope', 'next')).toBe(frozenNodes[1]);
+ expect(getAdjacentNode(frozenNodes, 'Scope', 'previous')).toBe(frozenNodes[2]);
+ expect(frozenNodes.map(({ id }) => id)).toEqual(originalIds);
+ });
+});
diff --git a/libs/logic-apps-shared/src/utils/src/lib/helpers/index.ts b/libs/logic-apps-shared/src/utils/src/lib/helpers/index.ts
index e257d757aa5..cfaa8a98925 100644
--- a/libs/logic-apps-shared/src/utils/src/lib/helpers/index.ts
+++ b/libs/logic-apps-shared/src/utils/src/lib/helpers/index.ts
@@ -10,6 +10,7 @@ export * from './hooks';
export * from './http';
export * from './logicapps';
export * from './navigator';
+export * from './nodeNavigation';
export * from './notes';
export * from './operations';
export * from './recurrence';
diff --git a/libs/logic-apps-shared/src/utils/src/lib/helpers/nodeNavigation.ts b/libs/logic-apps-shared/src/utils/src/lib/helpers/nodeNavigation.ts
new file mode 100644
index 00000000000..7aae37f903f
--- /dev/null
+++ b/libs/logic-apps-shared/src/utils/src/lib/helpers/nodeNavigation.ts
@@ -0,0 +1,32 @@
+import type { Node } from '@xyflow/react';
+import { WORKFLOW_NODE_TYPES } from '../models/workflowNode';
+import { removeIdTag } from './stringFunctions';
+
+export const getAdjacentNode = (
+ nodes: readonly Node[],
+ selectedNodeId: string | undefined,
+ direction: 'next' | 'previous'
+): Node | undefined => {
+ const indexedNodes = nodes.flatMap((node) => {
+ const index = node.data['nodeIndex'];
+ const isOperation = node.type === WORKFLOW_NODE_TYPES['OPERATION_NODE'] || node.type === WORKFLOW_NODE_TYPES['SCOPE_CARD_NODE'];
+ return isOperation && !node.hidden && typeof index === 'number' && Number.isFinite(index) && index > 0 ? [{ node, index }] : [];
+ });
+ const current = indexedNodes.find(({ node }) =>
+ node.type === WORKFLOW_NODE_TYPES['SCOPE_CARD_NODE'] ? removeIdTag(node.id) === selectedNodeId : node.id === selectedNodeId
+ );
+ const currentIndex = current?.index ?? (direction === 'next' ? Number.NEGATIVE_INFINITY : Number.POSITIVE_INFINITY);
+ let adjacent: (typeof indexedNodes)[number] | undefined;
+
+ // Layout already assigns the tab order, including gaps for edges and other controls.
+ for (const candidate of indexedNodes) {
+ if (
+ direction === 'next'
+ ? candidate.index > currentIndex && (!adjacent || candidate.index < adjacent.index)
+ : candidate.index < currentIndex && (!adjacent || candidate.index > adjacent.index)
+ ) {
+ adjacent = candidate;
+ }
+ }
+ return adjacent?.node;
+};