diff --git a/apps/Standalone/README.md b/apps/Standalone/README.md index ab7742d2e1f..b3c3976c7e0 100644 --- a/apps/Standalone/README.md +++ b/apps/Standalone/README.md @@ -47,6 +47,30 @@ For local-only PR deployments triggered by the `ephemeral` label, see [PR previe Unknown routes fall back to the production designer development shell. +## Designer v2 keyboard navigation + +In designer v2 (`/v2`), click 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 also work while a non-editing control in the selected node's +details panel has focus, including the Close button focused after clicking a card. +They do not override typing in form fields or content-editable controls, or handle +keys from pinned panels, other designers, dialogs, or menus. + +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. + +The legacy designer (`/`) does not enable these shortcuts or retain the selected +details tab across node selections. Its existing keyboard and panel behavior is unchanged. + ## 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 4fc36086a0d..756a825eaf8 100644 --- a/e2e/ephemeral/preview.spec.ts +++ b/e2e/ephemeral/preview.spec.ts @@ -69,6 +69,71 @@ const selectRecurrenceWorkflow = async (page: Page, hasBackground: boolean) => { }; for (const path of ['/', '/v2']) { + for (const modifier of ['Control', 'Meta']) { + test(`${path} ${modifier}+arrows ${path === '/v2' ? 'navigate immediately after a normal card click' : 'preserve legacy selection and tab reset'}`, async ({ + page, + baseURL, + }) => { + const assertHealthyPreview = await observePreview(page, baseURL!); + await page.goto(path); + await expectLocalOnlySettings(page); + await page.getByRole('combobox', { name: 'Workflow File To Load' }).click(); + await page.getByRole('option', { name: 'Panel', exact: true }).click(); + await page.getByRole('button', { name: 'Toolbox' }).click(); + await page.getByLabel('Zoom view to fit').click(); + + const card = (nodeId: string) => page.locator(`[id="msla-node-${nodeId}"]`); + await card('manual').click(); + await expect(page.locator('[id="msla-panel-header-close-nav"]')).toBeFocused(); + if (path === '/') { + for (const direction of ['Down', 'Up']) { + await page.keyboard.press(`${modifier}+Arrow${direction}`); + await expect(page.locator('[id="msla-panel-header-close-nav"]')).toBeFocused(); + await expect(page.locator('[id="msla-node-details-panel-manual"]')).toBeVisible(); + } + // The legacy canvas itself must not register the new shortcut either. + await card('manual').focus(); + for (const direction of ['Down', 'Up']) { + await page.keyboard.press(`${modifier}+Arrow${direction}`); + await expectSelectedAndFocused(page, 'manual'); + } + await page.getByRole('tab', { name: 'Settings', exact: true }).click(); + await card('Initialize_ArrayVariable').click(); + await expect(page.getByRole('tab', { name: 'Parameters', exact: true })).toHaveAttribute('aria-selected', 'true'); + assertHealthyPreview(); + return; + } + await page.keyboard.press(`${modifier}+ArrowDown`); + await expectSelectedAndFocused(page, 'Initialize_ArrayVariable'); + await expect(card('Initialize_ArrayVariable')).toBeInViewport(); + await page.keyboard.press(`${modifier}+ArrowUp`); + await expectSelectedAndFocused(page, 'manual'); + await expect(card('manual')).toBeInViewport(); + + await page.getByRole('tab', { name: 'Settings', exact: true }).click(); + await page.keyboard.press(`${modifier}+ArrowDown`); + await expectSelectedAndFocused(page, 'Initialize_ArrayVariable'); + await expect(page.getByRole('tab', { name: 'Settings', exact: true })).toHaveAttribute('aria-selected', 'true'); + + await page.locator('[id="Initialize_ArrayVariable-title"]').click(); + await page.keyboard.press(`${modifier}+ArrowDown`); + await expect(page.locator('[id="Initialize_ArrayVariable-title"]')).toBeFocused(); + await page.keyboard.press(`${modifier}+ArrowUp`); + await expect(page.locator('[id="Initialize_ArrayVariable-title"]')).toBeFocused(); + + await page.getByLabel('Zoom view to fit').click(); + await card('Parse_JSON').click(); + await expect(page.locator('[id="msla-panel-header-close-nav"]')).toBeFocused(); + await page.keyboard.press(`${modifier}+ArrowUp`); + await expectSelectedAndFocused(page, 'Initialize_ArrayVariable'); + await expect(card('Initialize_ArrayVariable')).toBeInViewport(); + await page.keyboard.press(`${modifier}+ArrowUp`); + await expectSelectedAndFocused(page, 'manual'); + await expect(card('manual')).toBeInViewport(); + assertHealthyPreview(); + }); + } + test(`${path} loads and edits a local workflow, then survives a direct reload`, async ({ page, baseURL }) => { const assertHealthyPreview = await observePreview(page, baseURL!); @@ -125,3 +190,280 @@ test('trusted hosting config retains exact provider blocks in the static harness } } }); + +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']) { + test(`${path} click and keyboard selection retain Settings and fall back when Testing is unavailable`, async ({ + page, + baseURL, + }, testInfo) => { + const assertHealthyPreview = await observePreview(page, baseURL!); + await page.goto(path); + await expectLocalOnlySettings(page); + await page.getByRole('combobox', { name: 'Workflow File To Load' }).click(); + await page.getByRole('option', { name: 'Panel', exact: true }).click(); + await page.getByRole('button', { name: 'Toolbox' }).click(); + await page.getByLabel('Zoom view to fit').click(); + + const card = (nodeId: string) => page.locator(`[id="msla-node-${nodeId}"]`); + const details = (nodeId: string) => page.locator(`[id="msla-node-details-panel-${nodeId}"]`); + const clickCard = async (nodeId: string) => { + await page.getByLabel('Zoom view to fit').click(); + await card(nodeId).click(); + }; + const expectSettings = async (nodeId: string) => { + await expect(details(nodeId).getByRole('tab', { name: 'Settings', exact: true })).toHaveAttribute('aria-selected', 'true'); + await expect(details(nodeId).locator('.msla-setting-section').first()).toBeVisible(); + }; + const expectTriggerFallback = async () => { + await expect(details('manual').getByRole('tab', { name: 'Testing', exact: true })).toHaveCount(0); + await expect(details('manual').getByRole('tab', { name: 'Parameters', exact: true })).toHaveAttribute('aria-selected', 'true'); + await expect(details('manual').getByText('Request Body JSON Schema', { exact: true })).toBeVisible(); + }; + + await clickCard('manual'); + await details('manual').getByRole('tab', { name: 'Settings', exact: true }).click(); + await expectSettings('manual'); + await card('manual').focus(); + await page.keyboard.press('Control+ArrowDown'); + await expectSelectedAndFocused(page, 'Initialize_ArrayVariable'); + await expectSettings('Initialize_ArrayVariable'); + + await clickCard('Parse_JSON'); + await expectSettings('Parse_JSON'); + await card('Parse_JSON').focus(); + await page.keyboard.press('Meta+ArrowDown'); + await expectSelectedAndFocused(page, 'Filter_array'); + await expectSettings('Filter_array'); + const screenshotPath = testInfo.outputPath('settings-retained-after-navigation.png'); + await page.screenshot({ path: screenshotPath, animations: 'disabled' }); + await testInfo.attach('Settings retained after keyboard navigation', { path: screenshotPath, contentType: 'image/png' }); + + await clickCard('HTTP'); + await expectSettings('HTTP'); + await details('HTTP').getByRole('tab', { name: 'Testing', exact: true }).click(); + await expect(details('HTTP').getByRole('tab', { name: 'Testing', exact: true })).toHaveAttribute('aria-selected', 'true'); + await clickCard('manual'); + await expectTriggerFallback(); + + await clickCard('HTTP'); + await expect(details('HTTP').getByRole('tab', { name: 'Testing', exact: true })).toHaveAttribute('aria-selected', 'true'); + await card('HTTP').focus(); + for (const nodeId of ['Filter_array', 'Parse_JSON', 'Initialize_ArrayVariable', 'manual']) { + await page.keyboard.press('Control+ArrowUp'); + await expectSelectedAndFocused(page, nodeId); + } + await expectTriggerFallback(); + await clickCard('HTTP'); + await expect(details('HTTP').getByRole('tab', { name: 'Testing', exact: true })).toHaveAttribute('aria-selected', 'true'); + assertHealthyPreview(); + }); + + 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 }, testInfo) => { + 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-Switch"]'); + const next = page.locator('[id="msla-node-Condition"]'); + const nextFlowNode = page.locator('.react-flow__node[data-id="Condition-#scope"]'); + const nativeCanvas = page.locator('.react-flow'); + const readNativeScroll = () => nativeCanvas.evaluate((element) => ({ scrollTop: element.scrollTop, scrollLeft: element.scrollLeft })); + + await test.step('Prepare an unmounted next scope using only mouse navigation', async () => { + await page.getByLabel('Zoom in', { exact: true }).click(); + await first.click(); + await expect(page.locator('.msla-panel-card-header input[id="Switch-title"]')).toBeVisible(); + + const pane = await page.locator('.react-flow__pane').boundingBox(); + const firstBounds = await first.boundingBox(); + if (!pane || !firstBounds) { + throw new Error('Cannot pan the production canvas: the pane or selected scope has no bounding box.'); + } + // Unlike the trigger, Switch can reach the bottom without hitting the graph's pan limit. + const desiredBottom = pane.y + pane.height - 24; + const deltaY = desiredBottom - (firstBounds.y + firstBounds.height); + const startY = deltaY >= 0 ? pane.y + 16 : pane.y + pane.height - 16; + await page.mouse.move(pane.x + 20, startY); + await page.mouse.down(); + await page.mouse.move(pane.x + 20, startY + deltaY, { steps: 8 }); + await page.mouse.up(); + // Mouse coordinates are rounded, while zoomed card bounds can contain fractional pixels. + await expect + .poll(() => first.evaluate((element, bottom) => Math.abs(element.getBoundingClientRect().bottom - bottom), desiredBottom), { + message: 'The pan must actually place the selected scope at the bottom of the canvas', + }) + .toBeLessThan(2); + + await first.focus(); + await expectSelectedAndFocused(page, 'Switch'); + await expect(first).toBeInViewport(); + await expect(nextFlowNode, 'The next React Flow card itself must be unmounted, not just its focusable header').toHaveCount(0); + await expect(next, 'Condition must still be unmounted after focusing Switch, immediately before the first shortcut').toHaveCount(0); + expect(await readNativeScroll(), 'Fixture setup must not natively scroll the React Flow container').toEqual({ + scrollTop: 0, + scrollLeft: 0, + }); + await testInfo.attach('offscreen-precondition', { + contentType: 'application/json', + body: JSON.stringify({ + selectedScope: 'Switch', + selectedBounds: await first.boundingBox(), + canvasBounds: pane, + nextScope: 'Condition', + nextScopeMounted: await next.count(), + nextFlowNodeMounted: await nextFlowNode.count(), + focusedElement: await page.evaluate(() => document.activeElement?.id), + viewportTransform: await page.locator('.react-flow__viewport').evaluate((element) => getComputedStyle(element).transform), + nativeCanvasScroll: await readNativeScroll(), + }), + }); + }); + + const nativeScrollObservation = await nativeCanvas.evaluateHandle((element) => { + const samples: { scrollTop: number; scrollLeft: number }[] = []; + const record = () => samples.push({ scrollTop: element.scrollTop, scrollLeft: element.scrollLeft }); + record(); + element.addEventListener('scroll', record); + element.addEventListener('focusin', record); + return { + samples, + stop: () => { + element.removeEventListener('scroll', record); + element.removeEventListener('focusin', record); + }, + }; + }); + try { + await page.keyboard.press('Control+ArrowDown'); + await expect(nextFlowNode).toHaveCount(1); + await expectSelectedAndFocused(page, 'Condition'); + expect(await readNativeScroll(), 'Forward navigation must not natively scroll the canvas').toEqual({ + scrollTop: 0, + scrollLeft: 0, + }); + await expect(next).toBeInViewport(); + await page.keyboard.press('Control+ArrowUp'); + await expectSelectedAndFocused(page, 'Switch'); + expect(await readNativeScroll(), 'Reverse navigation must not natively scroll the canvas').toEqual({ scrollTop: 0, scrollLeft: 0 }); + await expect(first).toBeInViewport(); + const samples = await nativeScrollObservation.evaluate((observation) => observation.samples); + expect( + samples.filter(({ scrollTop, scrollLeft }) => scrollTop !== 0 || scrollLeft !== 0), + 'No observed focus or scroll event may introduce a native canvas offset' + ).toEqual([]); + } finally { + const samples = await nativeScrollObservation.evaluate((observation) => { + observation.stop(); + return observation.samples; + }); + await nativeScrollObservation.dispose(); + await testInfo.attach('native-canvas-scroll', { + contentType: 'application/json', + body: JSON.stringify(samples), + }); + } + assertHealthyPreview(); + }); +} diff --git a/libs/designer-ui/src/lib/card/__test__/card.focus.spec.tsx b/libs/designer-ui/src/lib/card/__test__/card.focus.spec.tsx new file mode 100644 index 00000000000..6678e29ecfd --- /dev/null +++ b/libs/designer-ui/src/lib/card/__test__/card.focus.spec.tsx @@ -0,0 +1,67 @@ +import { cleanup, render, screen } from '@testing-library/react'; +import { IntlProvider } from 'react-intl'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { Card, type CardProps } from '../index'; +import { ScopeCard } from '../scopeCard'; + +const props: CardProps = { + id: 'node', + title: 'Test node', + brandColor: '#474747', + drag: () => null, + dragPreview: () => null, + draggable: false, + nodeIndex: 1, +}; + +describe.each([ + { name: 'Card', Component: Card }, + { name: 'ScopeCard', Component: ScopeCard }, +])('$name programmatic focus', ({ Component }) => { + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + }); + + const card = (setFocus?: boolean, title = props.title) => ( + + + + ); + + it.each([undefined, false])('does not request focus when setFocus=%s', (setFocus) => { + const focus = vi.spyOn(HTMLElement.prototype, 'focus'); + render(card(setFocus)); + expect(screen.getByRole('button', { name: 'Test node operation' })).not.toHaveFocus(); + expect(focus).not.toHaveBeenCalled(); + }); + + it('preserves native focus behavior on initial mount', () => { + const focus = vi.spyOn(HTMLElement.prototype, 'focus'); + render(card(true)); + expect(focus.mock.calls).toEqual([[]]); + expect(screen.getByRole('button', { name: 'Test node operation' })).toHaveFocus(); + }); + + it('preserves native focus for repeated requests without refocusing on ordinary rerenders', () => { + const { rerender } = render(card(false)); + const target = screen.getByRole('button', { name: 'Test node operation' }); + const focus = vi.spyOn(target, 'focus'); + rerender(card(true)); + expect(focus.mock.calls).toEqual([[]]); + expect(target).toHaveFocus(); + + rerender(card(true, 'Updated node')); + expect(screen.getByRole('button', { name: 'Updated node operation' })).toBe(target); + expect(focus).toHaveBeenCalledOnce(); + rerender(card(false)); + expect(focus).toHaveBeenCalledOnce(); + target.blur(); + expect(target).not.toHaveFocus(); + + rerender(card(true)); + expect(focus).toHaveBeenCalledTimes(2); + expect(focus.mock.calls[1]).toEqual([]); + expect(target).toHaveFocus(); + }); +}); diff --git a/libs/designer-ui/src/lib/panel/__test__/__snapshots__/panelcontainer.spec.tsx.snap b/libs/designer-ui/src/lib/panel/__test__/__snapshots__/panelcontainer.spec.tsx.snap index 7b51094b6e4..056020c69d3 100644 --- a/libs/designer-ui/src/lib/panel/__test__/__snapshots__/panelcontainer.spec.tsx.snap +++ b/libs/designer-ui/src/lib/panel/__test__/__snapshots__/panelcontainer.spec.tsx.snap @@ -31,6 +31,7 @@ exports[`ui/panel/panelContainer > should construct 1`] = ` > should construct 1`] = ` className="msla-panel-contents" > [ + { id: 'PARAMETERS', title: 'Parameters', visible: true, order: 0, content:
{nodeId} parameters
}, + { id: 'SETTINGS', title: 'Settings', visible: true, order: 1, content:
{nodeId} settings
}, + { id: 'ABOUT', title: 'About', visible: true, order: 2, content:
{nodeId} about
}, +]; + +const wrapper = ({ children }: { children: ReactNode }) => {children}; + +const StatefulPreview = ({ serializedContent }: { serializedContent: string }) => { + const [editCount, setEditCount] = useState(0); + return ( + <> + setEditCount((count) => count + 1)} /> +

Edits: {editCount}

+ + ); +}; + +describe('PanelContent tab preference', () => { + let props: PanelContentProps; + let restoreLayout: () => void; + + beforeEach(() => { + // The DOM emulator has no layout; keep the real Fluent tabs out of the overflow menu. + 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 = () => { + clientWidth.mockRestore(); + offsetWidth.mockRestore(); + bounds.mockRestore(); + }; + props = { + enableNodeNavigation: true, + nodeId: 'First', + tabs: tabsFor('First'), + selectedTab: 'SETTINGS', + selectTab: vi.fn(), + trackEvent: vi.fn(), + }; + }); + + afterEach(() => { + cleanup(); + restoreLayout(); + }); + + it('retains a valid preferred tab while rerendering content for another node', () => { + const { rerender } = render(, { wrapper }); + expect(screen.getByRole('tab', { name: 'Settings' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByText('First settings')).toBeVisible(); + + rerender(); + expect(screen.getByRole('tab', { name: 'Settings' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByText('Second settings')).toBeVisible(); + expect(screen.queryByText('First settings')).not.toBeInTheDocument(); + expect(props.selectTab).not.toHaveBeenCalled(); + }); + + it('preserves legacy missing-tab behavior without the v2 opt-in', () => { + render( id !== 'SETTINGS')} />, { + wrapper, + }); + expect(screen.getByRole('tab', { name: 'Parameters' })).toHaveAttribute('aria-selected', 'false'); + expect(screen.queryByText('First parameters')).not.toBeInTheDocument(); + }); + + it('preserves legacy node-local state without the v2 opt-in', () => { + const tabs = [{ ...tabsFor('First')[0], content: }]; + const { rerender } = render(, { wrapper }); + fireEvent.change(screen.getByLabelText('Draft JSON'), { target: { value: 'Unsaved' } }); + rerender(); + expect(screen.getByLabelText('Draft JSON')).toHaveValue('Unsaved'); + expect(screen.getByText('Edits: 1')).toBeVisible(); + }); + + it('falls back without replacing the preference, then restores it on a compatible node', () => { + const { rerender } = render(, { wrapper }); + const limitedTabs = tabsFor('Limited').filter(({ id }) => id !== 'SETTINGS'); + rerender(); + expect(screen.queryByRole('tab', { name: 'Settings' })).not.toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Parameters' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByText('Limited parameters')).toBeVisible(); + expect(props.selectTab).not.toHaveBeenCalled(); + + rerender(); + expect(screen.getByRole('tab', { name: 'Settings' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByText('Compatible settings')).toBeVisible(); + expect(screen.queryByText('Limited parameters')).not.toBeInTheDocument(); + expect(props.selectTab).not.toHaveBeenCalled(); + }); + + it('uses the first available tab rather than a hard-coded Parameters fallback', () => { + const { rerender } = render(, { wrapper }); + rerender( id === 'ABOUT')} />); + expect(screen.getByRole('region', { name: 'About' })).toHaveAttribute('tabindex', '0'); + expect(screen.getByText('AboutOnly about')).toBeVisible(); + expect(screen.queryByRole('tablist')).not.toBeInTheDocument(); + expect(props.selectTab).not.toHaveBeenCalled(); + }); + + it('uses the first available tab when no preference was selected', () => { + render(, { wrapper }); + expect(screen.getByRole('tab', { name: 'Parameters' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByText('First parameters')).toBeVisible(); + expect(props.selectTab).not.toHaveBeenCalled(); + }); + + it('handles an empty available-tab list and restores the preference when tabs return', () => { + const { rerender, container } = render(, { wrapper }); + rerender(); + expect(screen.queryByRole('tablist')).not.toBeInTheDocument(); + expect(container.querySelector('.msla-panel-content-container')).toBeEmptyDOMElement(); + expect(screen.queryByText('First settings')).not.toBeInTheDocument(); + expect(props.selectTab).not.toHaveBeenCalled(); + + rerender(); + expect(screen.getByRole('tab', { name: 'Settings' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByText('Restored settings')).toBeVisible(); + expect(props.selectTab).not.toHaveBeenCalled(); + }); + + it('preserves the single-subgraph content exception even when its tab is not visible', () => { + const singleTab = { ...tabsFor('SwitchCase')[0], visible: false }; + render(, { wrapper }); + expect(screen.queryByRole('tablist')).not.toBeInTheDocument(); + expect(screen.getByText('SwitchCase parameters')).toBeVisible(); + expect(props.selectTab).not.toHaveBeenCalled(); + }); + + it('only changes the preference through an explicit tab selection', () => { + const { rerender } = render(, { wrapper }); + fireEvent.click(screen.getByRole('tab', { name: 'About' })); + expect(props.selectTab).toHaveBeenCalledExactlyOnceWith('ABOUT'); + rerender(); + expect(screen.getByRole('tab', { name: 'About' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByText('First about')).toBeVisible(); + expect(props.selectTab).toHaveBeenCalledOnce(); + }); + + it.each([ + { name: 'same-node rerender preserves the draft and local state', nextNodeId: 'NodeA', preservesDraft: true }, + { name: 'different-node rerender discards the draft and local state', nextNodeId: 'NodeB', preservesDraft: false }, + ])('$name even when serialized content and selected tab are identical', ({ nextNodeId, preservesDraft }) => { + const serializedContent = '{"type":"Compose","inputs":"original"}'; + const unsavedContent = '{"type":"Compose","inputs":"unsaved node A edit"}'; + const previewTabs = (): PanelTab[] => [ + ...tabsFor('Identical'), + { + id: 'CODE_VIEW', + title: 'Code preview', + visible: true, + order: 3, + content: , + }, + ]; + const { rerender } = render(, { wrapper }); + const tabStrip = screen.getByRole('tablist'); + const originalInput = screen.getByRole('textbox', { name: 'Draft JSON' }); + fireEvent.change(originalInput, { target: { value: unsavedContent } }); + expect(originalInput).toHaveValue(unsavedContent); + expect(screen.getByText('Edits: 1')).toBeVisible(); + + rerender(); + const currentInput = screen.getByRole('textbox', { name: 'Draft JSON' }); + expect(currentInput === originalInput).toBe(preservesDraft); + expect(currentInput).toHaveValue(preservesDraft ? unsavedContent : serializedContent); + expect(screen.getByText(`Edits: ${preservesDraft ? 1 : 0}`)).toBeVisible(); + expect(screen.getByRole('tablist')).toBe(tabStrip); + expect(screen.getByRole('tab', { name: 'Code preview' })).toHaveAttribute('aria-selected', 'true'); + expect(props.selectTab).not.toHaveBeenCalled(); + + if (!preservesDraft) { + expect(originalInput).not.toBeInTheDocument(); + rerender(); + expect(screen.getByRole('textbox', { name: 'Draft JSON' })).toHaveValue(serializedContent); + expect(screen.getByText('Edits: 0')).toBeVisible(); + } + }); +}); diff --git a/libs/designer-ui/src/lib/panel/panelcontainer.tsx b/libs/designer-ui/src/lib/panel/panelcontainer.tsx index f98776045e9..184cb156a76 100644 --- a/libs/designer-ui/src/lib/panel/panelcontainer.tsx +++ b/libs/designer-ui/src/lib/panel/panelcontainer.tsx @@ -15,6 +15,7 @@ import constants from '../constants'; import { TeachingPopup } from '../teachingPopup'; export type PanelContainerProps = { + enableNodeNavigation?: boolean; panelScope: PanelScope; suppressDefaultNodeSelectFunctionality?: boolean; pivotDisabled?: boolean; @@ -47,6 +48,7 @@ export type PanelContainerProps = { } & CommonPanelProps; export const PanelContainer = ({ + enableNodeNavigation = false, isCollapsed, panelLocation, panelScope, @@ -147,6 +149,7 @@ export const PanelContainer = ({ return ( ) : ( - + )} ); }, - [renderHeader, panelErrorMessage, trackEvent, panelErrorTitle, alternateSelectedNodeContainerId] + [renderHeader, panelErrorMessage, trackEvent, panelErrorTitle, alternateSelectedNodeContainerId, enableNodeNavigation] ); const minWidth = isDualView ? Number.parseInt(PanelSize.DualView, 10) : undefined; diff --git a/libs/designer-ui/src/lib/panel/panelcontent.tsx b/libs/designer-ui/src/lib/panel/panelcontent.tsx index 47e723f6e89..70f747e3901 100644 --- a/libs/designer-ui/src/lib/panel/panelcontent.tsx +++ b/libs/designer-ui/src/lib/panel/panelcontent.tsx @@ -22,16 +22,25 @@ import { useIntl } from 'react-intl'; const MoreHorizontal = bundleIcon(MoreHorizontalFilled, MoreHorizontalRegular); export interface PanelContentProps { + enableNodeNavigation?: boolean; nodeId: string; tabs: PanelTab[]; selectedTab?: string; selectTab: (tabId: string) => void; trackEvent(data: PageActionTelemetryData): void; } -export const PanelContent = ({ nodeId, tabs = [], selectedTab, selectTab }: PanelContentProps): JSX.Element => { +export const PanelContent = ({ + nodeId, + tabs = [], + selectedTab, + selectTab, + enableNodeNavigation = false, +}: PanelContentProps): JSX.Element => { const intl = useIntl(); - const selectedTabId = selectedTab ?? tabs[0]?.id; + const selectedTabId = enableNodeNavigation + ? (tabs.find((tab) => tab.id === selectedTab)?.id ?? tabs[0]?.id) + : (selectedTab ?? tabs[0]?.id); const onTabSelected = (e?: SelectTabEvent, data?: SelectTabData): void => { if (data) { @@ -70,6 +79,7 @@ export const PanelContent = ({ nodeId, tabs = [], selectedTab, selectTab }: Pane ) : null}
({ + 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 ( +
+ + +