From 742b95e5cac1f4a200693f6a039a2da4b21e09dc Mon Sep 17 00:00:00 2001 From: Riley Evans Date: Tue, 15 Sep 2026 10:03:19 -0500 Subject: [PATCH 1/8] feat(designer): add node navigation and retain details tabs Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- apps/Standalone/README.md | 18 + e2e/ephemeral/preview.spec.ts | 139 ++++++ .../src/lib/panel/panelcontent.tsx | 3 +- .../src/lib/core/state/panel/panelSlice.ts | 2 - .../core/state/workflow/workflowSelectors.ts | 13 +- .../src/lib/ui/CustomNodes/ScopeCardNode.tsx | 2 +- .../src/lib/ui/DesignerReactFlow.tsx | 2 + .../designer-v2/src/lib/ui/NodeNavigation.tsx | 50 +++ .../ui/__test__/DesignerReactFlow.spec.tsx | 15 + .../lib/ui/__test__/NodeNavigation.spec.tsx | 26 ++ .../src/lib/core/state/panel/panelSlice.ts | 1 - .../core/state/workflow/workflowSelectors.ts | 11 +- .../src/lib/ui/CustomNodes/ScopeCardNode.tsx | 2 +- .../designer/src/lib/ui/DesignerReactFlow.tsx | 2 + libs/designer/src/lib/ui/NodeNavigation.tsx | 50 +++ .../ui/__test__/DesignerReactFlow.spec.tsx | 17 +- .../lib/ui/__test__/NodeNavigation.spec.tsx | 24 + .../ui/__test__/keyboardNavigation.spec.tsx | 8 +- .../ui/__test__/nodeNavigationTestSuite.tsx | 416 ++++++++++++++++++ .../helpers/__test__/nodeNavigation.spec.ts | 106 +++++ .../src/utils/src/lib/helpers/index.ts | 1 + .../utils/src/lib/helpers/nodeNavigation.ts | 32 ++ 22 files changed, 925 insertions(+), 15 deletions(-) create mode 100644 libs/designer-v2/src/lib/ui/NodeNavigation.tsx create mode 100644 libs/designer-v2/src/lib/ui/__test__/NodeNavigation.spec.tsx create mode 100644 libs/designer/src/lib/ui/NodeNavigation.tsx create mode 100644 libs/designer/src/lib/ui/__test__/NodeNavigation.spec.tsx create mode 100644 libs/designer/src/lib/ui/__test__/nodeNavigationTestSuite.tsx create mode 100644 libs/logic-apps-shared/src/utils/src/lib/helpers/__test__/nodeNavigation.spec.ts create mode 100644 libs/logic-apps-shared/src/utils/src/lib/helpers/nodeNavigation.ts diff --git a/apps/Standalone/README.md b/apps/Standalone/README.md index ab7742d2e1f..568675d859f 100644 --- a/apps/Standalone/README.md +++ b/apps/Standalone/README.md @@ -47,6 +47,24 @@ For local-only PR deployments triggered by the `ephemeral` label, see [PR previe Unknown routes fall back to the production designer development shell. +## Designer keyboard navigation + +In either designer (`/` or `/v2`), focus a card on the canvas and use +**Ctrl/Cmd + Down** to select the next action or trigger, or **Ctrl/Cmd + Up** +to select the previous one. Selection follows the existing tab order, including +scope cards and nested actions, rather than recalculating graph connections. +Edges, add buttons, notes, and collapsed content are skipped. + +Navigation stops at the first and last nodes. With no current selection, Down +starts at the first node and Up at the last. The selected card receives focus +and is brought into view, including off-screen cards. Selection uses the usual +operation-panel behavior and remains available in read-only and monitoring views. +The shortcuts do not override typing in form fields or content-editable controls. + +The operation details panel retains your selected tab when you click or navigate +to another node. If that node does not offer the tab, its first available tab is +shown instead; your preferred tab is restored on the next node that supports it. + ## Development model `src/App.tsx` owns route registration and lazy-loads each experience with its Redux store. `src/designer/app/DesignerShell` configures the designer host and its environment-specific services. Changes to workspace libraries are picked up by Vite during local development. diff --git a/e2e/ephemeral/preview.spec.ts b/e2e/ephemeral/preview.spec.ts index 62261a66312..75d3e288bf3 100644 --- a/e2e/ephemeral/preview.spec.ts +++ b/e2e/ephemeral/preview.spec.ts @@ -120,3 +120,142 @@ test('trusted hosting config blocks SWA auth paths in the static harness', async } } }); + +const allScopeActionIds = [ + 'Recurrence', + 'Switch', + 'Condition', + 'Terminate', + 'Increment_variable_4', + 'Terminate_2', + 'ForEach', + 'ForEach_Action_1', + 'ForEach_nested', + 'ForEach_Action_2', + 'ForEach_Action_3', + 'ForEach_empty', + 'Scope', + 'Scope_Action_1', + 'Scope_Action_2', + 'Scope_nested', + 'Scope_Action_3', + 'Scope_empty', + 'Until', + 'Until_Action_1', + 'Until_Action_2', + 'Until_Nested', + 'Until_Action_3', + 'Default-Compose', + 'Initialize_owner', +]; + +const selectAllScopeWorkflow = async (page: Page) => { + await expectLocalOnlySettings(page); + await page.getByRole('combobox', { name: 'Workflow File To Load' }).click(); + await page.getByRole('option', { name: 'All Scope Nodes', exact: true }).click(); + await expect(page.locator('[id="msla-node-Recurrence"]')).toBeVisible(); + await page.getByRole('button', { name: 'Toolbox' }).click(); +}; + +const expectSelectedAndFocused = async (page: Page, nodeId: string) => { + await expect(page.locator(`.msla-panel-card-header input[id="${nodeId}-title"]`), `${nodeId} operation details`).toBeVisible(); + await expect(page.locator(`[id="msla-node-${nodeId}"]`), `${nodeId} keyboard focus`).toBeFocused(); +}; + +const renderedOperationOrder = (page: Page) => + page + .locator( + '.react-flow__node-OPERATION_NODE [id^="msla-node-"][tabindex], .react-flow__node-SCOPE_CARD_NODE [id^="msla-node-"][tabindex]' + ) + .evaluateAll((elements) => + elements + .filter((element): element is HTMLElement => element instanceof HTMLElement && element.tabIndex > 0) + .map((element) => ({ id: element.id.slice('msla-node-'.length), index: element.tabIndex })) + .sort((left, right) => left.index - right.index) + ); + +for (const path of ['/', '/v2']) { + for (const modifier of ['Control', 'Meta']) { + test(`${path} ${modifier}+arrows traverse branches and nested scopes without wrapping or stealing editor keys`, async ({ + page, + baseURL, + }) => { + const assertHealthyPreview = await observePreview(page, baseURL!); + await page.goto(path); + await selectAllScopeWorkflow(page); + await page.locator('[id="msla-node-Recurrence"]').click(); + await expect(page.locator('.msla-panel-card-header input[id="Recurrence-title"]')).toBeVisible(); + await page.locator('[id="msla-node-Recurrence"]').focus(); + await expectSelectedAndFocused(page, 'Recurrence'); + + await page.keyboard.press(`${modifier}+ArrowUp`); + await expectSelectedAndFocused(page, 'Recurrence'); + await page.keyboard.press(`${modifier}+ArrowDown`); + await expectSelectedAndFocused(page, 'Switch'); + + await expect.poll(async () => (await renderedOperationOrder(page)).length).toBe(allScopeActionIds.length); + const ordered = await renderedOperationOrder(page); + expect(ordered.map(({ id }) => id).sort()).toEqual([...allScopeActionIds].sort()); + expect(ordered.slice(0, 2).map(({ id }) => id)).toEqual(['Recurrence', 'Switch']); + + for (const { id } of ordered.slice(2)) { + await page.keyboard.press(`${modifier}+ArrowDown`); + await expectSelectedAndFocused(page, id); + } + const lastId = ordered[ordered.length - 1].id; + await page.keyboard.press(`${modifier}+ArrowDown`); + await page.keyboard.press(`${modifier}+ArrowDown`); + await expectSelectedAndFocused(page, lastId); + + for (const { id } of ordered.slice(0, -1).reverse()) { + await page.keyboard.press(`${modifier}+ArrowUp`); + await expectSelectedAndFocused(page, id); + } + await page.keyboard.press(`${modifier}+ArrowUp`); + await expectSelectedAndFocused(page, 'Recurrence'); + + const interval = page.getByPlaceholder('Specify the interval.'); + await interval.fill('7'); + await interval.press(`${modifier}+ArrowDown`); + await interval.press(`${modifier}+ArrowUp`); + await expect(interval).toBeFocused(); + await expect(page.locator('.msla-panel-card-header input[id="Recurrence-title"]')).toBeVisible(); + assertHealthyPreview(); + }); + } + + test(`${path} next selection mounts and focuses an offscreen scope without fit-to-view`, async ({ page, baseURL }) => { + await page.setViewportSize({ width: 1440, height: 550 }); + const assertHealthyPreview = await observePreview(page, baseURL!); + await page.goto(path); + await selectAllScopeWorkflow(page); + const first = page.locator('[id="msla-node-Recurrence"]'); + await first.click(); + + const pane = await page.locator('.react-flow__pane').boundingBox(); + const firstBounds = await first.boundingBox(); + expect(pane).not.toBeNull(); + expect(firstBounds).not.toBeNull(); + if (!pane || !firstBounds) { + throw new Error('Cannot pan the production canvas: the pane or trigger has no bounding box.'); + } + // Pan with real pointer input so the immediate next card is virtualized out. + const deltaY = pane.y + pane.height - 12 - (firstBounds.y + firstBounds.height); + await page.mouse.move(pane.x + 20, pane.y + 30); + await page.mouse.down(); + await page.mouse.move(pane.x + 20, pane.y + 30 + deltaY, { steps: 8 }); + await page.mouse.up(); + await expect(first).toBeInViewport(); + await expect(page.locator('[id="msla-node-Switch"]'), 'The next scope must be absent before keyboard navigation').toHaveCount(0); + await first.click(); + await first.focus(); + await expectSelectedAndFocused(page, 'Recurrence'); + + await page.keyboard.press('Control+ArrowDown'); + await expectSelectedAndFocused(page, 'Switch'); + await expect(page.locator('[id="msla-node-Switch"]')).toBeInViewport(); + await page.keyboard.press('Control+ArrowUp'); + await expectSelectedAndFocused(page, 'Recurrence'); + assertHealthyPreview(); + }); +} diff --git a/libs/designer-ui/src/lib/panel/panelcontent.tsx b/libs/designer-ui/src/lib/panel/panelcontent.tsx index 47e723f6e89..7bfe226c774 100644 --- a/libs/designer-ui/src/lib/panel/panelcontent.tsx +++ b/libs/designer-ui/src/lib/panel/panelcontent.tsx @@ -31,7 +31,7 @@ export interface PanelContentProps { export const PanelContent = ({ nodeId, tabs = [], selectedTab, selectTab }: PanelContentProps): JSX.Element => { const intl = useIntl(); - const selectedTabId = selectedTab ?? tabs[0]?.id; + const selectedTabId = tabs.find((tab) => tab.id === selectedTab)?.id ?? tabs[0]?.id; const onTabSelected = (e?: SelectTabEvent, data?: SelectTabData): void => { if (data) { @@ -70,6 +70,7 @@ export const PanelContent = ({ nodeId, tabs = [], selectedTab, selectTab }: Pane ) : null}
{ 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/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/DesignerReactFlow.tsx b/libs/designer-v2/src/lib/ui/DesignerReactFlow.tsx index addff8e707b..1e762777d8e 100644 --- a/libs/designer-v2/src/lib/ui/DesignerReactFlow.tsx +++ b/libs/designer-v2/src/lib/ui/DesignerReactFlow.tsx @@ -55,6 +55,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 +704,7 @@ const DesignerReactFlow = (props: any) => { hideAttribution: true, }} > + 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..bd1802d84c9 --- /dev/null +++ b/libs/designer-v2/src/lib/ui/NodeNavigation.tsx @@ -0,0 +1,50 @@ +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) || !canvasRef.current?.contains(event.target)) { + 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..2db12f848ae 100644 --- a/libs/designer-v2/src/lib/ui/__test__/DesignerReactFlow.spec.tsx +++ b/libs/designer-v2/src/lib/ui/__test__/DesignerReactFlow.spec.tsx @@ -136,6 +136,13 @@ 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'; @@ -169,6 +176,14 @@ describe('DesignerReactFlow (designer-v2)', () => { // ────────────────────────────────────────────────────────── describe('Rendering', () => { + it('mounts navigation inside ReactFlow and renders offscreen nodes when navigation starts', () => { + render(); + expect(screen.getByTestId('react-flow')).toContainElement(screen.getByTestId('node-navigation')); + expect(capturedReactFlowProps.onlyRenderVisibleElements).toBe(true); + fireEvent.click(screen.getByTestId('node-navigation')); + expect(capturedReactFlowProps.onlyRenderVisibleElements).toBe(false); + }); + 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..d97bfdfa8ce --- /dev/null +++ b/libs/designer-v2/src/lib/ui/__test__/NodeNavigation.spec.tsx @@ -0,0 +1,26 @@ +import { describe } from 'vitest'; +import { nodeNavigationTestSuite } from '../../../../../designer/src/lib/ui/__test__/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/src/lib/core/state/panel/panelSlice.ts b/libs/designer/src/lib/core/state/panel/panelSlice.ts index c2696a4897f..40e5da89da4 100644 --- a/libs/designer/src/lib/core/state/panel/panelSlice.ts +++ b/libs/designer/src/lib/core/state/panel/panelSlice.ts @@ -172,7 +172,6 @@ export const panelSlice = createSlice({ state.currentPanelMode = 'Operation'; state.connectionContent.selectedNodeIds = selectedNodes; state.operationContent.selectedNodeId = selectedNodes[0]; - state.operationContent.selectedNodeActiveTabId = undefined; if (state.operationContent.alternateSelectedNode?.persistence === 'selected') { state.operationContent.alternateSelectedNode.nodeId = ''; } diff --git a/libs/designer/src/lib/core/state/workflow/workflowSelectors.ts b/libs/designer/src/lib/core/state/workflow/workflowSelectors.ts index b8f90a18df7..58285d8a2c9 100644 --- a/libs/designer/src/lib/core/state/workflow/workflowSelectors.ts +++ b/libs/designer/src/lib/core/state/workflow/workflowSelectors.ts @@ -41,8 +41,13 @@ export const useActionMetadata = (id?: string) => export const useNodeDescription = (id: string) => useSelector(createSelector(getWorkflowState, (state: WorkflowState) => getRecordEntry(state.operations, id)?.description)); -export const useShouldNodeFocus = (id: string) => - useSelector(createSelector(getWorkflowState, (state: WorkflowState) => state.focusedCanvasNodeId === id)); +export const useShouldNodeFocus = (id: string, canvasNodeId = id) => + useSelector( + createSelector( + getWorkflowState, + (state: WorkflowState) => state.focusedCanvasNodeId === id || state.focusedCanvasNodeId === canvasNodeId + ) + ); export const useFocusElement = () => useSelector(createSelector(getWorkflowState, (state: WorkflowState) => state.focusElement)); @@ -324,7 +329,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/ScopeCardNode.tsx b/libs/designer/src/lib/ui/CustomNodes/ScopeCardNode.tsx index f640c83b7f2..9793a813061 100644 --- a/libs/designer/src/lib/ui/CustomNodes/ScopeCardNode.tsx +++ b/libs/designer/src/lib/ui/CustomNodes/ScopeCardNode.tsx @@ -66,7 +66,7 @@ import { useIsA2AWorkflow } from '../../core/state/designerView/designerViewSele const ScopeCardNode = ({ id }: NodeProps) => { const scopeId = useMemo(() => removeIdTag(id), [id]); const nodeComment = useNodeDescription(scopeId); - const shouldFocus = useShouldNodeFocus(scopeId); + const shouldFocus = useShouldNodeFocus(scopeId, id); const node = useActionMetadata(scopeId); const operationsInfo = useAllOperations(); diff --git a/libs/designer/src/lib/ui/DesignerReactFlow.tsx b/libs/designer/src/lib/ui/DesignerReactFlow.tsx index 5991f9fc787..f6ec3485c72 100644 --- a/libs/designer/src/lib/ui/DesignerReactFlow.tsx +++ b/libs/designer/src/lib/ui/DesignerReactFlow.tsx @@ -39,6 +39,7 @@ import SubgraphCardNode from './CustomNodes/SubgraphCardNode'; 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; @@ -389,6 +390,7 @@ const DesignerReactFlow = (props: any) => { hideAttribution: true, }} > + setUserInferredTabNavigation(true)} /> {props.children} ); diff --git a/libs/designer/src/lib/ui/NodeNavigation.tsx b/libs/designer/src/lib/ui/NodeNavigation.tsx new file mode 100644 index 00000000000..bd1802d84c9 --- /dev/null +++ b/libs/designer/src/lib/ui/NodeNavigation.tsx @@ -0,0 +1,50 @@ +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) || !canvasRef.current?.contains(event.target)) { + 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/src/lib/ui/__test__/DesignerReactFlow.spec.tsx b/libs/designer/src/lib/ui/__test__/DesignerReactFlow.spec.tsx index a9fead9ccc3..a51591d86ad 100644 --- a/libs/designer/src/lib/ui/__test__/DesignerReactFlow.spec.tsx +++ b/libs/designer/src/lib/ui/__test__/DesignerReactFlow.spec.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { render, screen, fireEvent } from '@testing-library/react'; import React from 'react'; // ── Mock state toggles ────────────────────────────────────────────────────── @@ -111,6 +111,13 @@ 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 ─────────────────────────────────────────────────────── import DesignerReactFlow from '../DesignerReactFlow'; @@ -141,6 +148,14 @@ describe('DesignerReactFlow (designer v1)', () => { // ────────────────────────────────────────────────────────── describe('Rendering', () => { + it('mounts navigation inside ReactFlow and renders offscreen nodes when navigation starts', () => { + render(); + expect(screen.getByTestId('react-flow')).toContainElement(screen.getByTestId('node-navigation')); + expect(capturedReactFlowProps.onlyRenderVisibleElements).toBe(true); + fireEvent.click(screen.getByTestId('node-navigation')); + expect(capturedReactFlowProps.onlyRenderVisibleElements).toBe(false); + }); + it('should render ReactFlow component', () => { render(); expect(screen.getByTestId('react-flow')).toBeInTheDocument(); diff --git a/libs/designer/src/lib/ui/__test__/NodeNavigation.spec.tsx b/libs/designer/src/lib/ui/__test__/NodeNavigation.spec.tsx new file mode 100644 index 00000000000..fb0f4e0f87e --- /dev/null +++ b/libs/designer/src/lib/ui/__test__/NodeNavigation.spec.tsx @@ -0,0 +1,24 @@ +import { describe } from 'vitest'; +import { nodeNavigationTestSuite } from './nodeNavigationTestSuite'; +import panelReducer, { + changePanelNode, + setAlternateSelectedNode, + setPinnedPanelActiveTab, + setSelectedNodeId, + setSelectedPanelActiveTab, +} from '../../core/state/panel/panelSlice'; +import { setFocusNode } from '../../core/state/workflow/workflowSlice'; +import { NodeNavigation } from '../NodeNavigation'; + +describe('NodeNavigation (designer)', () => { + nodeNavigationTestSuite({ + Navigation: NodeNavigation, + panelReducer, + changePanelNode, + setSelectedNodeId, + setAlternateSelectedNode, + setFocusNode, + setSelectedPanelActiveTab, + setPinnedPanelActiveTab, + }); +}); diff --git a/libs/designer/src/lib/ui/__test__/keyboardNavigation.spec.tsx b/libs/designer/src/lib/ui/__test__/keyboardNavigation.spec.tsx index 71d8c84114d..96cb77d721d 100644 --- a/libs/designer/src/lib/ui/__test__/keyboardNavigation.spec.tsx +++ b/libs/designer/src/lib/ui/__test__/keyboardNavigation.spec.tsx @@ -7,15 +7,17 @@ * The previous ExTester scenario `keyboardNavigation.test.ts` (Phase 4.6, * ADO #10273324) claimed to verify Ctrl+Up / Ctrl+Down navigation between * designer canvas nodes. In practice it only logged whether focus moved — - * it never `assert`ed it — because in this codebase node-to-node arrow-key - * navigation is intentionally disabled at the React Flow layer: + * it never `assert`ed it. React Flow's built-in navigation remains disabled: * - `nodesFocusable={false}` * - `edgesFocusable={false}` * - `elementsSelectable={false}` * - `disableKeyboardA11y={true}` * (see `libs/designer/src/lib/ui/DesignerReactFlow.tsx`). * - * The *real* keyboard-navigation surface in `` is the + * Ctrl/Cmd+Up/Down selection is now implemented separately by NodeNavigation; + * its real-keyboard regression tests live in NodeNavigation.spec.tsx. + * + * This file covers the independent * "go to operation / node search" hotkey wired up in * `libs/designer/src/lib/ui/Designer.tsx` via `react-hotkeys-hook`: * - Web (non-VS-Code): Ctrl/Cmd + Shift + P -> opens NodeSearch panel diff --git a/libs/designer/src/lib/ui/__test__/nodeNavigationTestSuite.tsx b/libs/designer/src/lib/ui/__test__/nodeNavigationTestSuite.tsx new file mode 100644 index 00000000000..91222e27b03 --- /dev/null +++ b/libs/designer/src/lib/ui/__test__/nodeNavigationTestSuite.tsx @@ -0,0 +1,416 @@ +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 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[]; + } = {}) => { + 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 ( + 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 +
+ + +