Skip to content
Merged
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
26 changes: 15 additions & 11 deletions .changeset/flow-designer-nested-regions.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,25 @@
"@object-ui/app-shell": minor
---

feat(studio): visualize loop / parallel / try_catch nested regions on the flow designer canvas (#2670)
feat(studio): expand loop / parallel / try_catch regions inline on the flow designer canvas (#2670)

The flow designer rendered ADR-0031 structured control-flow containers
(`loop` / `parallel` / `try_catch`) as opaque single node cards — their nested
regions (`config.body` / `config.branches[]` / `config.try`/`catch`) were only
visible, and only editable, as raw JSON in the inspector's Advanced block.

A container card now carries a **"show nested regions"** control that opens a
read-only popover rendering the region(s) as a mini-canvas — the same
top-to-bottom node/edge layout as the parent graph, produced by the shared
`computeLayout` and scaled to fit — with a header per region (a named branch or
`Branch N`, and `Try` / `Catch`; a loop body has none). Legacy flat loops (a
`loop` with no `config.body`) and all ordinary nodes render exactly as before.
A container card now carries an expand chevron that grows the card **in place**
to embed its region(s) as a read-only mini-canvas — the same top-to-bottom
node/edge layout as the parent graph, scaled to fit the card width — with a
header per region (a named branch or `Branch N`, and `Try` / `Catch`; a loop
body has none). The canvas layout is geometry-aware: the layers below an
expanded container are **pushed down** by its real height and its outgoing edge
leaves from its true bottom. Collapsed by default; expansion is session-only
view state (never written to the flow draft). Legacy flat loops (a `loop` with
no `config.body`) and all ordinary nodes render exactly as before — with no
expanded container the layout is identical to the previous release, locked by
invariance tests.

Read-only for now (Phase 1 of #2670): the region renders in a floating popover,
so it needs **no change to the canvas layout or edge routing** — zero regression
risk to existing flows. Inline push-down nesting on the canvas and nested editing
are tracked as the next increments on #2670.
Known limitation: a node pinned via a manual drag position sitting at/below an
expanded container can overlap it (manual positions are absolute); drag it
clear or collapse the container.
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import { describe, it, expect, afterEach, vi } from 'vitest';
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
import { FlowCanvas } from './FlowCanvas';
import { extractRegions, NODE_H } from './flow-canvas-layout';
import { predictExpandedNodeHeight } from './flow-region-metrics';
import type { FlowProblem } from './flow-problems';

afterEach(cleanup);
Expand Down Expand Up @@ -99,53 +101,96 @@ describe('FlowCanvas — inline cycle/error surfacing', () => {
});
});

describe('FlowCanvas — nested container regions (#2670)', () => {
it('reveals a loop body region in a popover when the container control is opened', async () => {
const nodes = [
{ id: 'start', type: 'start' },
{
id: 'each',
type: 'loop',
label: 'For each order',
config: { body: { nodes: [{ id: 'charge', type: 'http', label: 'Charge card' }], edges: [] } },
},
];
render(
describe('FlowCanvas — inline nested container regions (#2670 Phase 2)', () => {
const LOOP_NODES = [
{ id: 'start', type: 'start' },
{
id: 'each',
type: 'loop',
label: 'For each order',
config: { body: { nodes: [{ id: 'charge', type: 'http', label: 'Charge card' }], edges: [] } },
},
{ id: 'after', type: 'end', label: 'After' },
];
const LOOP_EDGES = [
{ source: 'start', target: 'each' },
{ source: 'each', target: 'after' },
];

it('expands a loop body INLINE inside the container card, and collapses back', () => {
const { container } = render(
<FlowCanvas
nodes={nodes}
edges={[{ source: 'start', target: 'each' }]}
nodes={LOOP_NODES}
edges={LOOP_EDGES}
editable={false}
designMode={false}
selectedId={null}
onSelect={() => {}}
/>,
);
// Closed by default: the body step is not rendered, but a "show regions" control is.
// Collapsed by default: no body step, an expand control with aria-expanded=false.
expect(screen.queryByText('Charge card')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /show nested regions/i }));
// Open: the loop body node now renders nested in the popover.
expect(await screen.findByText('Charge card')).toBeInTheDocument();
const toggle = screen.getByRole('button', { name: 'Expand nested regions' });
expect(toggle).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(toggle);
// Expanded: the body node renders INLINE inside the container card (not portaled).
const card = container.querySelector('[data-node-id="each"]') as HTMLElement;
expect(card).not.toBeNull();
expect(card.textContent).toContain('Charge card');
const collapse = screen.getByRole('button', { name: 'Collapse nested regions' });
expect(collapse).toHaveAttribute('aria-expanded', 'true');
fireEvent.click(collapse);
expect(screen.queryByText('Charge card')).not.toBeInTheDocument();
});

it('pushes the layer below down by exactly the predicted height delta', () => {
const { container } = render(
<FlowCanvas
nodes={LOOP_NODES}
edges={LOOP_EDGES}
editable={false}
designMode={false}
selectedId={null}
onSelect={() => {}}
/>,
);
const topOfAfter = () =>
parseFloat((container.querySelector('[data-node-id="after"]') as HTMLElement).style.top);
const before = topOfAfter();
fireEvent.click(screen.getByRole('button', { name: 'Expand nested regions' }));
const predicted = predictExpandedNodeHeight(extractRegions(LOOP_NODES[1] as never));
// The card renders at the predicted height, and the node below moved down
// by exactly (predicted − NODE_H) — DOM and layout share one number.
const card = container.querySelector('[data-node-id="each"]') as HTMLElement;
expect(parseFloat(card.style.height)).toBeCloseTo(predicted, 3);
expect(topOfAfter() - before).toBeCloseTo(predicted - NODE_H, 3);
});

it('labels parallel branches and try/catch handlers when opened', async () => {
const nodes = [
{
id: 'p',
type: 'parallel',
label: 'Fan out',
config: {
branches: [
{ name: 'Slack', nodes: [{ id: 'a', type: 'http', label: 'Notify Slack' }], edges: [] },
{ nodes: [{ id: 'b', type: 'http', label: 'Notify CRM' }], edges: [] },
],
},
},
];
it('labels parallel branches when expanded inline', () => {
render(
<FlowCanvas nodes={nodes} edges={[]} editable={false} designMode={false} selectedId={null} onSelect={() => {}} />,
<FlowCanvas
nodes={[
{
id: 'p',
type: 'parallel',
label: 'Fan out',
config: {
branches: [
{ name: 'Slack', nodes: [{ id: 'a', type: 'http', label: 'Notify Slack' }], edges: [] },
{ nodes: [{ id: 'b', type: 'http', label: 'Notify CRM' }], edges: [] },
],
},
},
]}
edges={[]}
editable={false}
designMode={false}
selectedId={null}
onSelect={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: /show nested regions/i }));
expect(await screen.findByText('Slack')).toBeInTheDocument(); // named branch
fireEvent.click(screen.getByRole('button', { name: 'Expand nested regions' }));
expect(screen.getByText('Slack')).toBeInTheDocument(); // named branch
expect(screen.getByText('Branch 2')).toBeInTheDocument(); // unnamed → indexed
expect(screen.getByText('Notify CRM')).toBeInTheDocument();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,7 @@ import { cn } from '@object-ui/components';
import { uniqueId, appendArray, spliceArray } from '../inspectors/_shared';
import { t as tr } from '../i18n';
import {
computeLayout,
diagramSize,
computeLayoutWithGeometry,
NODE_W,
NODE_H,
bottomAnchor,
Expand All @@ -45,7 +44,9 @@ import {
type FlowNode,
type FlowEdge,
type Point,
type LabeledRegion,
} from './flow-canvas-layout';
import { predictExpandedNodeHeight } from './flow-region-metrics';
import { NodeCard, NodePalette, defaultNodeLabel, defaultNodeExtras } from './flow-canvas-parts';
import { useFlowNodePalette } from './useFlowNodePalette';
import { indexProblemBadges, edgeProblemKey, type FlowProblem } from './flow-problems';
Expand Down Expand Up @@ -148,8 +149,41 @@ export function FlowCanvas({
const dragRef = React.useRef<DragState | null>(null);
const panRef = React.useRef<PanState | null>(null);

const layout = React.useMemo(() => computeLayout(nodes, edges), [nodes, edges]);
const size = React.useMemo(() => diagramSize(layout), [layout]);
// #2670: structured-region containers. Which nodes carry regions (memoized so
// NodeCard props keep a stable identity), which are expanded (session-only
// view state — never written to the draft), and the per-node height feeding
// the geometry-aware layout (expanded container = predicted card height;
// everything else = NODE_H, keeping the historical layout byte-identical).
const [expandedIds, setExpandedIds] = React.useState<ReadonlySet<string>>(() => new Set());
const toggleExpanded = React.useCallback((id: string) => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const regionsByNode = React.useMemo(() => {
const map = new Map<string, LabeledRegion[]>();
for (const n of nodes) {
const regions = extractRegions(n);
if (regions.length > 0) map.set(n.id, regions);
}
return map;
}, [nodes]);
const nodeHeights = React.useMemo(() => {
const map = new Map<string, number>();
for (const n of nodes) {
const regions = expandedIds.has(n.id) ? regionsByNode.get(n.id) : undefined;
map.set(n.id, regions ? predictExpandedNodeHeight(regions) : NODE_H);
}
return map;
}, [nodes, regionsByNode, expandedIds]);

const { positions: layout, heights, size } = React.useMemo(
() => computeLayoutWithGeometry(nodes, edges, (n) => nodeHeights.get(n.id) ?? NODE_H),
[nodes, edges, nodeHeights],
);

// Simulation overlay sets (display-only; never drives engine behavior).
const visitedSet = React.useMemo(() => new Set(visitedNodeIds ?? []), [visitedNodeIds]);
Expand Down Expand Up @@ -449,11 +483,13 @@ export function FlowCanvas({
let pt: Point | null = null;
if (t.kind === 'node') {
const p = layout.get(t.nodeId);
if (p) pt = { x: p.x + NODE_W / 2, y: p.y + NODE_H / 2 };
// #2670: center on the card's true (possibly expanded) height.
if (p) pt = { x: p.x + NODE_W / 2, y: p.y + (heights.get(t.nodeId) ?? NODE_H) / 2 };
} else if (t.kind === 'edge') {
const s = layout.get(t.source);
const d = layout.get(t.target);
if (s && d) pt = { x: (s.x + d.x) / 2 + NODE_W / 2, y: (s.y + d.y) / 2 + NODE_H / 2 };
// Midpoint of the edge as actually drawn (source bottom → target top).
if (s && d) pt = edgeMidpoint(bottomAnchor(s, heights.get(t.source) ?? NODE_H), topAnchor(d));
}
if (pt) setPan({ x: vp.clientWidth / 2 - pt.x * zoom, y: vp.clientHeight / 2 - pt.y * zoom });
// eslint-disable-next-line react-hooks/exhaustive-deps
Expand Down Expand Up @@ -661,7 +697,10 @@ export function FlowCanvas({
const invalid = !back && !!invalidEdges?.has(`${edge.source}->${edge.target}`);
const sPos = dragPos?.id === edge.source ? positionOf(edge.source) : sp;
const tPos = dragPos?.id === edge.target ? positionOf(edge.target) : tp;
const from = back ? rightAnchor(sPos) : bottomAnchor(sPos);
// #2670: an expanded container's outgoing edge leaves from its
// TRUE bottom (heights map; drag-independent). Back-edges stay on
// the header band via rightAnchor, so toggling never swings arcs.
const from = back ? rightAnchor(sPos) : bottomAnchor(sPos, heights.get(edge.source) ?? NODE_H);
const to = back ? rightAnchor(tPos) : topAnchor(tPos);
const labelPos = back ? backEdgeLabelAnchor(from, to) : edgeMidpoint(from, to);
const cond = conditionText(edge.condition);
Expand Down Expand Up @@ -823,7 +862,10 @@ export function FlowCanvas({
}
invalid={invalidNodeSet.has(node.id)}
badge={nodeBadges.get(node.id)}
regions={extractRegions(node)}
regions={regionsByNode.get(node.id)}
expanded={expandedIds.has(node.id)}
onToggleExpand={regionsByNode.has(node.id) ? () => toggleExpanded(node.id) : undefined}
height={heights.get(node.id)}
/>
);
})}
Expand Down
Loading
Loading