Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions apps/Standalone/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
342 changes: 342 additions & 0 deletions e2e/ephemeral/preview.spec.ts

Large diffs are not rendered by default.

67 changes: 67 additions & 0 deletions libs/designer-ui/src/lib/card/__test__/card.focus.spec.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
<IntlProvider locale="en">
<Component {...props} title={title} setFocus={setFocus} />
</IntlProvider>
);

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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ exports[`ui/panel/panelContainer > should construct 1`] = `
>
<PanelHeader
commentChange={[Function]}
enableNodeNavigation={false}
handleTitleUpdate={[MockFunction spy]}
headerItems={[]}
headerLocation="RIGHT"
Expand Down Expand Up @@ -59,6 +60,7 @@ exports[`ui/panel/panelContainer > should construct 1`] = `
className="msla-panel-contents"
>
<PanelContent
enableNodeNavigation={false}
nodeId="nodeId"
selectTab={[MockFunction spy]}
tabs={[]}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { useState, type ReactNode } from 'react';
import { IntlProvider } from 'react-intl';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { PanelContent, type PanelContentProps } from '../panelcontent';
import type { PanelTab } from '../panelUtil';

const tabsFor = (nodeId: string): PanelTab[] => [
{ id: 'PARAMETERS', title: 'Parameters', visible: true, order: 0, content: <div>{nodeId} parameters</div> },
{ id: 'SETTINGS', title: 'Settings', visible: true, order: 1, content: <div>{nodeId} settings</div> },
{ id: 'ABOUT', title: 'About', visible: true, order: 2, content: <div>{nodeId} about</div> },
];

const wrapper = ({ children }: { children: ReactNode }) => <IntlProvider locale="en">{children}</IntlProvider>;

const StatefulPreview = ({ serializedContent }: { serializedContent: string }) => {
const [editCount, setEditCount] = useState(0);
return (
<>
<input aria-label="Draft JSON" defaultValue={serializedContent} onChange={() => setEditCount((count) => count + 1)} />
<p>Edits: {editCount}</p>
</>
);
};

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(<PanelContent {...props} />, { wrapper });
expect(screen.getByRole('tab', { name: 'Settings' })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByText('First settings')).toBeVisible();

rerender(<PanelContent {...props} nodeId="Second" tabs={tabsFor('Second')} />);
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(<PanelContent {...props} enableNodeNavigation={false} tabs={tabsFor('First').filter(({ id }) => 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: <StatefulPreview serializedContent="First" /> }];
const { rerender } = render(<PanelContent {...props} enableNodeNavigation={false} selectedTab="PARAMETERS" tabs={tabs} />, { wrapper });
fireEvent.change(screen.getByLabelText('Draft JSON'), { target: { value: 'Unsaved' } });
rerender(<PanelContent {...props} enableNodeNavigation={false} nodeId="Second" selectedTab="PARAMETERS" tabs={tabs} />);
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(<PanelContent {...props} />, { wrapper });
const limitedTabs = tabsFor('Limited').filter(({ id }) => id !== 'SETTINGS');
rerender(<PanelContent {...props} nodeId="Limited" tabs={limitedTabs} />);
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(<PanelContent {...props} nodeId="Compatible" tabs={tabsFor('Compatible')} />);
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(<PanelContent {...props} />, { wrapper });
rerender(<PanelContent {...props} nodeId="AboutOnly" tabs={tabsFor('AboutOnly').filter(({ id }) => 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(<PanelContent {...props} selectedTab={undefined} />, { 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(<PanelContent {...props} />, { wrapper });
rerender(<PanelContent {...props} nodeId="Empty" tabs={[]} />);
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(<PanelContent {...props} nodeId="Restored" tabs={tabsFor('Restored')} />);
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(<PanelContent {...props} nodeId="SwitchCase" tabs={[singleTab]} />, { 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(<PanelContent {...props} />, { wrapper });
fireEvent.click(screen.getByRole('tab', { name: 'About' }));
expect(props.selectTab).toHaveBeenCalledExactlyOnceWith('ABOUT');
rerender(<PanelContent {...props} selectedTab="ABOUT" />);
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: <StatefulPreview serializedContent={serializedContent} />,
},
];
const { rerender } = render(<PanelContent {...props} nodeId="NodeA" tabs={previewTabs()} selectedTab="CODE_VIEW" />, { 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(<PanelContent {...props} nodeId={nextNodeId} tabs={previewTabs()} selectedTab="CODE_VIEW" />);
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(<PanelContent {...props} nodeId="NodeA" tabs={previewTabs()} selectedTab="CODE_VIEW" />);
expect(screen.getByRole('textbox', { name: 'Draft JSON' })).toHaveValue(serializedContent);
expect(screen.getByText('Edits: 0')).toBeVisible();
}
});
});
15 changes: 13 additions & 2 deletions libs/designer-ui/src/lib/panel/panelcontainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import constants from '../constants';
import { TeachingPopup } from '../teachingPopup';

export type PanelContainerProps = {
enableNodeNavigation?: boolean;
panelScope: PanelScope;
suppressDefaultNodeSelectFunctionality?: boolean;
pivotDisabled?: boolean;
Expand Down Expand Up @@ -47,6 +48,7 @@ export type PanelContainerProps = {
} & CommonPanelProps;

export const PanelContainer = ({
enableNodeNavigation = false,
isCollapsed,
panelLocation,
panelScope,
Expand Down Expand Up @@ -147,6 +149,7 @@ export const PanelContainer = ({

return (
<PanelHeader
enableNodeNavigation={enableNodeNavigation}
nodeData={headerNode}
headerItems={isAlternateNode ? alternateSelectedNodeHeaderItems : nodeHeaderItems}
headerLocation={panelLocation}
Expand All @@ -172,6 +175,7 @@ export const PanelContainer = ({
);
},
[
enableNodeNavigation,
alternateSelectedNode,
onUnpinAction,
alternateSelectedNodePersistence,
Expand Down Expand Up @@ -247,13 +251,20 @@ export const PanelContainer = ({
</MessageBarBody>
</MessageBar>
) : (
<PanelContent tabs={tabs} trackEvent={trackEvent} nodeId={nodeId} selectedTab={selectedTab} selectTab={onSelectTab} />
<PanelContent
enableNodeNavigation={enableNodeNavigation}
tabs={tabs}
trackEvent={trackEvent}
nodeId={nodeId}
selectedTab={selectedTab}
selectTab={onSelectTab}
/>
)}
</div>
</div>
);
},
[renderHeader, panelErrorMessage, trackEvent, panelErrorTitle, alternateSelectedNodeContainerId]
[renderHeader, panelErrorMessage, trackEvent, panelErrorTitle, alternateSelectedNodeContainerId, enableNodeNavigation]
);

const minWidth = isDualView ? Number.parseInt(PanelSize.DualView, 10) : undefined;
Expand Down
14 changes: 12 additions & 2 deletions libs/designer-ui/src/lib/panel/panelcontent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -70,6 +79,7 @@ export const PanelContent = ({ nodeId, tabs = [], selectedTab, selectTab }: Pane
</Overflow>
) : null}
<div
key={enableNodeNavigation ? nodeId : undefined}
className="msla-panel-content-container"
tabIndex={selectedTabId === 'ABOUT' ? 0 : undefined}
role={selectedTabId === 'ABOUT' ? 'region' : undefined}
Expand Down
Loading
Loading