From a0d8c2856fecd813ab0ac447a360b713866f6bc9 Mon Sep 17 00:00:00 2001 From: David Anthony Date: Wed, 22 Jul 2026 13:33:56 -0700 Subject: [PATCH 01/11] fix(apollo-react): edge labels lose their background and can render under crossing lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs in CanvasEdge/SequenceEdge labels, plus a new Storybook reference page documenting label behavior across themes and edge cases. Bug 1 — line renders over the label: EdgeLabel rendered as a raw foreignObject inside its own edge's , so it competed in the same per-edge z-index/DOM-order stack as every other edge's stroke. An unselected edge crossing near a label could paint its line over that label. Fixed by portaling the label through xyflow's EdgeLabelRenderer instead (the pattern StageEdge/EdgeToolbar already use), which always paints after every edge's own , regardless of z-index or array order. Bug 2 — label background can render fully transparent: `background: var(--canvas-background)` had no fallback value. That variable only resolves under a themed ancestor (body.light/.future-*/.vertex/.canvas per canvas/styles/variables.css); a host that mounts the canvas without one (e.g. a shadow-DOM host) got a transparent label with illegible text over the line. Added a `--color-background` fallback to both EdgeLabel.tsx and StageEdge.tsx's StageEdgeLabel. New: Components/Edges/EdgeLabels Storybook page Consolidates label documentation that was previously scattered (and, in one spot, stale — SequenceEdge.stories.tsx's old EdgeLabels story still claimed labels render via "SVG foreignObject") into one dedicated reference page: - Orientation, Diff States: baseline label behavior - Crossing Labeled Edges: regression coverage for Bug 1 - Themes: the same label across all 9 canvas themes side by side - Missing Theme Fallback: reproduces Bug 2's scenario directly (simulates an unthemed ancestor via CSS custom-property `initial`), and documents the one remaining gap the fallback doesn't cover (no themed ancestor at all, background still transparent) - Overflow: long label text and short-edge crowding (known, undocumented limitations, not fixed here) - Execution Status, Bent Path, Read Only: label composes correctly with execution-status coloring, multi-segment waypoint routing, and readonly mode Co-Authored-By: Claude Sonnet 5 --- .../components/Edges/EdgeLabel.stories.tsx | 717 ++++++++++++++++++ .../components/Edges/SequenceEdge.stories.tsx | 170 +---- .../shared/primitives/EdgeLabel.test.tsx | 35 +- .../Edges/shared/primitives/EdgeLabel.tsx | 51 +- .../canvas/components/StageNode/StageEdge.tsx | 4 +- 5 files changed, 767 insertions(+), 210 deletions(-) create mode 100644 packages/apollo-react/src/canvas/components/Edges/EdgeLabel.stories.tsx diff --git a/packages/apollo-react/src/canvas/components/Edges/EdgeLabel.stories.tsx b/packages/apollo-react/src/canvas/components/Edges/EdgeLabel.stories.tsx new file mode 100644 index 000000000..ec4316386 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Edges/EdgeLabel.stories.tsx @@ -0,0 +1,717 @@ +/** + * Edge Label Stories + * + * Reference page for `data.label` on CanvasEdge (and the SequenceEdge preset, + * which composes CanvasEdge under the hood): orientation, diff states, and + * the crossing-edge stacking behavior. + */ +import type { Meta, StoryObj } from '@storybook/react'; +import type { Edge, Node } from '@uipath/apollo-react/canvas/xyflow/react'; +import { Position, ReactFlowProvider } from '@uipath/apollo-react/canvas/xyflow/react'; +import type { CSSProperties } from 'react'; +import { useMemo } from 'react'; +import { + createNode as createMockNode, + useCanvasStory, + withCanvasProviders, +} from '../../storybook-utils'; +import { BaseCanvas } from '../BaseCanvas'; +import { CanvasEdge } from './CanvasEdge'; +import type { CanvasEdgeData, Waypoint } from './shared/types'; + +const meta: Meta = { + title: 'Components/Edges/EdgeLabels', + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: + 'Set `data.label` on a CanvasEdge (or SequenceEdge) to render a label at the midpoint of the path. The label portals through `EdgeLabelRenderer`, so it always paints after every edge\'s own line, and falls back to `--color-background` when a host does not theme its ancestor with one of the canvas theme classes. Labels are decorative: `pointer-events: none`, no hover state, not clickable.', + }, + }, + }, + decorators: [withCanvasProviders()], +}; + +export default meta; +type Story = StoryObj; + +const edgeTypes = { 'canvas-edge': CanvasEdge }; + +interface NodeConfig { + id: string; + label: string; + x: number; + y: number; + sourcePositions?: Position[]; + targetPositions?: Position[]; +} + +/** Thin sugar over the shared mock factory: positions → handleConfigurations. */ +function createNode(config: NodeConfig): Node { + const { id, label, x, y, sourcePositions = [], targetPositions = [] } = config; + return createMockNode({ + id, + type: 'uipath.blank-node', + position: { x, y }, + display: { label }, + handleConfigurations: [ + ...sourcePositions.map((position) => ({ + position, + handles: [ + { id: `out-${position}`, type: 'source' as const, handleType: 'output' as const }, + ], + })), + ...targetPositions.map((position) => ({ + position, + handles: [{ id: `in-${position}`, type: 'target' as const, handleType: 'input' as const }], + })), + ], + }); +} + +/** + * Small two-node/one-edge canvas used to compare the label across themes and + * fallback scenarios. Each instance gets its own `ReactFlowProvider` so + * several can sit side by side on one page. + */ +function MiniLabeledEdgeCanvas({ label = 'Success' }: { label?: string }) { + const initialNodes = useMemo( + () => [ + createNode({ id: 'mini-source', label: 'Source', x: 10, y: 50, sourcePositions: [Position.Right] }), + createNode({ id: 'mini-target', label: 'Target', x: 195, y: 50, targetPositions: [Position.Left] }), + ], + [] + ); + + const initialEdges: Edge[] = useMemo( + () => [ + { + id: 'mini-edge', + source: 'mini-source', + target: 'mini-target', + sourceHandle: `out-${Position.Right}`, + targetHandle: `in-${Position.Left}`, + type: 'canvas-edge', + data: { label }, + }, + ], + [label] + ); + + const { canvasProps } = useCanvasStory({ initialNodes, initialEdges }); + return ; +} + +/** + * A horizontal and a vertical edge, each labeled. Confirms the label centers + * on the path midpoint regardless of routing direction. + */ +function OrientationStory() { + const initialNodes = useMemo( + () => [ + createNode({ id: 'h-source', label: 'Source', x: 100, y: 120, sourcePositions: [Position.Right] }), + createNode({ id: 'h-target', label: 'Target', x: 450, y: 120, targetPositions: [Position.Left] }), + + createNode({ id: 'v-source', label: 'Start', x: 250, y: 280, sourcePositions: [Position.Bottom] }), + createNode({ id: 'v-target', label: 'End', x: 250, y: 480, targetPositions: [Position.Top] }), + ], + [] + ); + + const initialEdges: Edge[] = useMemo( + () => [ + { + id: 'e-horizontal', + source: 'h-source', + target: 'h-target', + sourceHandle: `out-${Position.Right}`, + targetHandle: `in-${Position.Left}`, + type: 'canvas-edge', + data: { label: 'Success' }, + }, + { + id: 'e-vertical', + source: 'v-source', + target: 'v-target', + sourceHandle: `out-${Position.Bottom}`, + targetHandle: `in-${Position.Top}`, + type: 'canvas-edge', + data: { label: 'Next step' }, + }, + ], + [] + ); + + const { canvasProps } = useCanvasStory({ initialNodes, initialEdges }); + return ; +} + +export const Orientation: Story = { + render: () => , + parameters: { + docs: { + description: { + story: 'Labels on a horizontal and a vertical edge, both centered on the path midpoint.', + }, + }, + }, +}; + +/** + * Labels alongside diff styling. `isDiffAdded`/`isDiffRemoved` drive the + * stroke color via `resolveEdgeColor`; the label renders unaffected. + */ +function DiffStatesStory() { + const initialNodes = useMemo( + () => [ + createNode({ + id: 'added-source', + label: 'Source', + x: 100, + y: 120, + sourcePositions: [Position.Right], + }), + createNode({ + id: 'added-target', + label: 'Target', + x: 450, + y: 120, + targetPositions: [Position.Left], + }), + + createNode({ + id: 'removed-source', + label: 'Source', + x: 100, + y: 260, + sourcePositions: [Position.Right], + }), + createNode({ + id: 'removed-target', + label: 'Target', + x: 450, + y: 260, + targetPositions: [Position.Left], + }), + ], + [] + ); + + const initialEdges: Edge[] = useMemo( + () => [ + { + id: 'e-added', + source: 'added-source', + target: 'added-target', + sourceHandle: `out-${Position.Right}`, + targetHandle: `in-${Position.Left}`, + type: 'canvas-edge', + data: { isDiffAdded: true, label: 'New connection' }, + }, + { + id: 'e-removed', + source: 'removed-source', + target: 'removed-target', + sourceHandle: `out-${Position.Right}`, + targetHandle: `in-${Position.Left}`, + type: 'canvas-edge', + data: { isDiffRemoved: true, label: 'Deprecated' }, + }, + ], + [] + ); + + const { canvasProps } = useCanvasStory({ initialNodes, initialEdges }); + return ; +} + +export const DiffStates: Story = { + render: () => , + parameters: { + docs: { + description: { + story: + 'Labels alongside diff styling. isDiffAdded and isDiffRemoved color the stroke, the label renders the same either way.', + }, + }, + }, +}; + +/** + * Regression coverage for the label/line stacking fix. `EdgeLabel` portals + * through xyflow's `EdgeLabelRenderer`, which always paints after every + * edge's own ``, so a label stays legible no matter which edge is + * later in the array and would otherwise win the per-edge z-index/DOM-order + * stacking contest. Both edges below route through the same center point so + * their paths, and labels, land directly on top of each other. + */ +const CROSSING_WAYPOINT: Waypoint = { id: 'crossing-waypoint', x: 325, y: 250 }; + +function CrossingLabeledEdgesStory() { + const initialNodes = useMemo( + () => [ + createNode({ + id: 'a1', + label: 'Start Alpha', + x: 100, + y: 120, + sourcePositions: [Position.Right], + }), + createNode({ id: 'b1', label: 'End Alpha', x: 550, y: 380, targetPositions: [Position.Left] }), + createNode({ + id: 'a2', + label: 'Start Beta', + x: 100, + y: 380, + sourcePositions: [Position.Right], + }), + createNode({ id: 'b2', label: 'End Beta', x: 550, y: 120, targetPositions: [Position.Left] }), + ], + [] + ); + + const initialEdges: Edge[] = useMemo( + () => [ + { + id: 'e1', + source: 'a1', + target: 'b1', + sourceHandle: `out-${Position.Right}`, + targetHandle: `in-${Position.Left}`, + type: 'canvas-edge', + data: { routing: 'waypoint', waypoints: [CROSSING_WAYPOINT], label: 'Alpha' }, + }, + { + id: 'e2', + source: 'a2', + target: 'b2', + sourceHandle: `out-${Position.Right}`, + targetHandle: `in-${Position.Left}`, + type: 'canvas-edge', + data: { routing: 'waypoint', waypoints: [CROSSING_WAYPOINT], label: 'Beta' }, + }, + ], + [] + ); + + const { canvasProps } = useCanvasStory({ initialNodes, initialEdges }); + return ; +} + +export const CrossingLabeledEdges: Story = { + render: () => , + parameters: { + docs: { + description: { + story: + 'Two labeled edges routed through the same center point. Both labels stay legible on top of the crossing line, regardless of array order. Before the EdgeLabelRenderer fix, whichever edge was later in the array could paint its line over the other edge\'s label.', + }, + }, + }, +}; + +/** + * The same labeled edge under every canvas theme. Each cell forces its theme + * class on a wrapping div (the same selectors variables.css matches on + * `body.`), independent of the global Storybook theme toolbar, so all + * nine render simultaneously for comparison. + */ +const THEME_CLASSES = [ + 'light', + 'dark', + 'light-hc', + 'dark-hc', + 'future-light', + 'future-dark', + 'wireframe', + 'vertex', + 'canvas', +] as const; + +function ThemesStory() { + return ( +
+ {THEME_CLASSES.map((theme) => ( +
+
+ {theme} +
+
+ + + +
+
+ ))} +
+ ); +} + +export const Themes: Story = { + render: () => , + parameters: { + docs: { + description: { + story: + 'The same labeled edge rendered under all nine canvas themes at once, to compare label contrast against each theme\'s background and border colors.', + }, + }, + }, +}; + +/** + * Simulates a host that does not theme its canvas ancestor. `--canvas-background` + * and `--color-background` are set to `initial` (the CSS-spec guaranteed-invalid + * value) on a wrapping div, which is what actually happens when a host mounts + * the canvas outside any `body.` / `.future-*` / `.vertex` / `.canvas` + * ancestor (for example, a shadow-DOM host). Column 2 shows the fix's fallback + * resolving; column 3 is the known remaining gap: when neither variable + * resolves, the label background is transparent. + */ +function MissingThemeFallbackStory() { + const columnStyle: CSSProperties = { + flex: 1, + display: 'flex', + flexDirection: 'column', + gap: 8, + minWidth: 0, + }; + const canvasBoxStyle: CSSProperties = { + flex: 1, + border: '1px solid rgba(128, 128, 128, 0.3)', + borderRadius: 8, + overflow: 'hidden', + }; + + return ( +
+
+

+ Normal: the theme ancestor provides --canvas-background. +

+
+ + + +
+
+
+

+ --canvas-background unset (variables.css not imported): falls back to --color-background. +

+
+ + + +
+
+
+

+ Both unset, no themed ancestor at all: background is transparent. Known gap, not covered + by the current fallback chain. +

+
+ + + +
+
+
+ ); +} + +export const MissingThemeFallback: Story = { + render: () => , + parameters: { + docs: { + description: { + story: + 'Simulates a host that never themes the canvas ancestor (e.g. a shadow-DOM host, or an app that forgot to theme body). Demonstrates the fallback chain added in EdgeLabel.tsx, and documents the remaining gap when neither variable resolves.', + }, + }, + }, +}; + +/** + * Two known-limitation cases. The label is `whitespace-nowrap` with no + * truncation, so long text simply overflows; and a short edge with close + * nodes crowds the label against both node bodies. + */ +function OverflowStory() { + const initialNodes = useMemo( + () => [ + createNode({ + id: 'long-source', + label: 'Source', + x: 100, + y: 120, + sourcePositions: [Position.Right], + }), + createNode({ + id: 'long-target', + label: 'Target', + x: 500, + y: 120, + targetPositions: [Position.Left], + }), + + createNode({ + id: 'short-source', + label: 'A', + x: 100, + y: 300, + sourcePositions: [Position.Right], + }), + createNode({ + id: 'short-target', + label: 'B', + x: 180, + y: 300, + targetPositions: [Position.Left], + }), + ], + [] + ); + + const initialEdges: Edge[] = useMemo( + () => [ + { + id: 'e-long-text', + source: 'long-source', + target: 'long-target', + sourceHandle: `out-${Position.Right}`, + targetHandle: `in-${Position.Left}`, + type: 'canvas-edge', + data: { label: 'This label is intentionally long enough to overflow the edge' }, + }, + { + id: 'e-short-edge', + source: 'short-source', + target: 'short-target', + sourceHandle: `out-${Position.Right}`, + targetHandle: `in-${Position.Left}`, + type: 'canvas-edge', + data: { label: 'Crowded' }, + }, + ], + [] + ); + + const { canvasProps } = useCanvasStory({ initialNodes, initialEdges }); + return ; +} + +export const Overflow: Story = { + render: () => , + parameters: { + docs: { + description: { + story: + 'Long label text overflows since the label has no truncation, and a short edge with close nodes crowds the label against both node bodies. Neither case is currently guarded against.', + }, + }, + }, +}; + +/** + * A label on an edge with `enableExecution: true`, confirming the label + * renders unaffected alongside the animated in-progress dot and status color. + */ +function ExecutionStatusStory() { + const initialNodes = useMemo( + () => [ + createNode({ id: 'exec-source', label: 'Start', x: 100, y: 120, sourcePositions: [Position.Right] }), + createNode({ + id: 'exec-InProgress', + label: 'In Progress', + x: 450, + y: 120, + targetPositions: [Position.Left], + }), + ], + [] + ); + + const initialEdges: Edge[] = useMemo( + () => [ + { + id: 'edge-InProgress-demo', + source: 'exec-source', + target: 'exec-InProgress', + sourceHandle: `out-${Position.Right}`, + targetHandle: `in-${Position.Left}`, + type: 'canvas-edge', + data: { enableExecution: true, label: 'Running' }, + }, + ], + [] + ); + + const { canvasProps } = useCanvasStory({ initialNodes, initialEdges }); + return ; +} + +export const ExecutionStatus: Story = { + render: () => , + parameters: { + docs: { + description: { + story: + 'Label composes with enableExecution: true. The animated in-progress dot and status stroke color are unaffected by the label, and vice versa.', + }, + }, + }, +}; + +/** + * A label on a multi-segment waypoint-routed edge. `labelPoint` is the + * arc-length midpoint of the whole path (see getPathArcMidpoint), so on a + * bent path it lands wherever that midpoint falls, not necessarily at a + * visually obvious spot. + */ +const BENT_PATH_WAYPOINTS: Waypoint[] = [ + { id: 'bent-wp-1', x: 300, y: 120 }, + { id: 'bent-wp-2', x: 300, y: 400 }, +]; + +function BentPathStory() { + const initialNodes = useMemo( + () => [ + createNode({ + id: 'bent-source', + label: 'Source', + x: 100, + y: 120, + sourcePositions: [Position.Right], + }), + createNode({ + id: 'bent-target', + label: 'Target', + x: 500, + y: 400, + targetPositions: [Position.Left], + }), + ], + [] + ); + + const initialEdges: Edge[] = useMemo( + () => [ + { + id: 'e-bent', + source: 'bent-source', + target: 'bent-target', + sourceHandle: `out-${Position.Right}`, + targetHandle: `in-${Position.Left}`, + type: 'canvas-edge', + data: { + routing: 'waypoint', + waypoints: BENT_PATH_WAYPOINTS, + enableEditing: true, + label: 'Multi-segment', + }, + }, + ], + [] + ); + + const { canvasProps } = useCanvasStory({ initialNodes, initialEdges }); + return ; +} + +export const BentPath: Story = { + render: () => , + parameters: { + docs: { + description: { + story: + 'Label on a multi-segment waypoint-routed edge. The label sits at the arc-length midpoint of the full path, which on a bent path is not necessarily the visual center of any one segment.', + }, + }, + }, +}; + +/** + * Label rendering in readonly mode. Visually identical to design mode; no + * editing chrome is available regardless. + */ +function ReadOnlyStory() { + const initialNodes = useMemo( + () => [ + createNode({ + id: 'ro-source', + label: 'Source', + x: 100, + y: 120, + sourcePositions: [Position.Right], + }), + createNode({ + id: 'ro-target', + label: 'Target', + x: 450, + y: 120, + targetPositions: [Position.Left], + }), + ], + [] + ); + + const initialEdges: Edge[] = useMemo( + () => [ + { + id: 'e-readonly', + source: 'ro-source', + target: 'ro-target', + sourceHandle: `out-${Position.Right}`, + targetHandle: `in-${Position.Left}`, + type: 'canvas-edge', + data: { label: 'Completed' }, + }, + ], + [] + ); + + const { canvasProps } = useCanvasStory({ initialNodes, initialEdges }); + return ; +} + +export const ReadOnly: Story = { + render: () => , + parameters: { + docs: { + description: { + story: 'Label rendering in readonly mode, for edges representing a completed workflow run.', + }, + }, + }, +}; diff --git a/packages/apollo-react/src/canvas/components/Edges/SequenceEdge.stories.tsx b/packages/apollo-react/src/canvas/components/Edges/SequenceEdge.stories.tsx index f95ac9d13..39f78f593 100644 --- a/packages/apollo-react/src/canvas/components/Edges/SequenceEdge.stories.tsx +++ b/packages/apollo-react/src/canvas/components/Edges/SequenceEdge.stories.tsx @@ -2,7 +2,9 @@ * SequenceEdge Stories * * Demonstrates the SequenceEdge component which renders smooth step edges - * with directional arrows for workflow connections. + * with directional arrows for workflow connections. For label-specific + * examples (orientation, diff states, crossing edges), see the EdgeLabels + * story page. */ import type { Meta, StoryObj } from '@storybook/react'; @@ -290,160 +292,6 @@ function DefaultStory() { ); } -// ============================================================================ -// Edge Labels Story -// ============================================================================ - -function EdgeLabelsStory() { - const initialNodes = useMemo( - () => [ - createStickyNote( - 'sticky-labels', - 'green', - '**Edge Labels**\nLabels rendered at edge midpoint via data.label', - { x: 100, y: 80 }, - { width: 550, height: 590 } - ), - - // Horizontal edges with labels - createNode({ - id: 'label-source-1', - label: 'Source', - x: 130, - y: 160, - sourcePositions: [Position.Right], - }), - createNode({ - id: 'label-target-1', - label: 'Target', - x: 470, - y: 160, - targetPositions: [Position.Left], - }), - - // Vertical edges with labels - createNode({ - id: 'label-source-2', - label: 'Start', - x: 300, - y: 300, - sourcePositions: [Position.Bottom], - }), - createNode({ - id: 'label-target-2', - label: 'End', - x: 300, - y: 480, - targetPositions: [Position.Top], - }), - - // Diff edge with label - createStickyNote( - 'sticky-labels-diff', - 'white', - '**Labels + Diff States**\nEdge labels work alongside diff styling', - { x: 760, y: 80 }, - { width: 420, height: 340 } - ), - createNode({ - id: 'label-added-source', - label: 'Added', - x: 800, - y: 160, - sourcePositions: [Position.Right], - }), - createNode({ - id: 'label-added-target', - label: 'Target', - x: 1020, - y: 160, - targetPositions: [Position.Left], - }), - createNode({ - id: 'label-removed-source', - label: 'Removed', - x: 800, - y: 300, - sourcePositions: [Position.Right], - }), - createNode({ - id: 'label-removed-target', - label: 'Target', - x: 1020, - y: 300, - targetPositions: [Position.Left], - }), - - createStickyNote( - 'sticky-info-labels', - 'pink', - '## Edge Labels\n\nSet `data.label` on an edge to display a label at the edge midpoint.\n\n**Features:**\n- Rendered via SVG foreignObject\n- Positioned at computed midpoint (labelX, labelY)\n- Styled consistently with StageEdge labels\n- Works with all edge states (diff, selected, etc.)', - { x: 100, y: 720 }, - { width: 350, height: 280 } - ), - ], - [] - ); - - const initialEdges: Edge[] = useMemo( - () => [ - { - id: 'e-label-horizontal', - source: 'label-source-1', - target: 'label-target-1', - sourceHandle: `out-${Position.Right}`, - targetHandle: `in-${Position.Left}`, - type: 'sequence', - data: { label: 'Success' }, - }, - { - id: 'e-label-vertical', - source: 'label-source-2', - target: 'label-target-2', - sourceHandle: `out-${Position.Bottom}`, - targetHandle: `in-${Position.Top}`, - type: 'sequence', - data: { label: 'Next Step' }, - }, - { - id: 'e-label-added', - source: 'label-added-source', - target: 'label-added-target', - sourceHandle: `out-${Position.Right}`, - targetHandle: `in-${Position.Left}`, - type: 'sequence', - data: { isDiffAdded: true, label: 'New connection' }, - style: { stroke: 'var(--palette-positive-base)' }, - }, - { - id: 'e-label-removed', - source: 'label-removed-source', - target: 'label-removed-target', - sourceHandle: `out-${Position.Right}`, - targetHandle: `in-${Position.Left}`, - type: 'sequence', - data: { isDiffRemoved: true, label: 'Deprecated' }, - style: { stroke: 'var(--palette-negative-base)', strokeDasharray: '5,5' }, - }, - ], - [] - ); - - const { canvasProps } = useCanvasStory({ - initialNodes, - initialEdges, - additionalNodeTypes: nodeTypes, - }); - - return ( - - - - - - ); -} - // ============================================================================ // Plain Line Story (artifact-style: no arrow head, no edge toolbar) // ============================================================================ @@ -1147,18 +995,6 @@ export const Default: Story = { render: () => , }; -export const EdgeLabels: Story = { - render: () => , - parameters: { - docs: { - description: { - story: - 'Demonstrates edge labels rendered at the midpoint via data.label. Labels work alongside all edge states including diff styling.', - }, - }, - }, -}; - export const PlainLine: Story = { render: () => , parameters: { diff --git a/packages/apollo-react/src/canvas/components/Edges/shared/primitives/EdgeLabel.test.tsx b/packages/apollo-react/src/canvas/components/Edges/shared/primitives/EdgeLabel.test.tsx index 86d45aa41..c604d4555 100644 --- a/packages/apollo-react/src/canvas/components/Edges/shared/primitives/EdgeLabel.test.tsx +++ b/packages/apollo-react/src/canvas/components/Edges/shared/primitives/EdgeLabel.test.tsx @@ -1,37 +1,38 @@ import { render } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { EdgeLabel } from './EdgeLabel'; +// EdgeLabelRenderer portals into a DOM node xyflow only creates once a full +// instance has mounted, which this unit test doesn't set up. +// Render straight through so we can assert on the label markup itself. +vi.mock('@uipath/apollo-react/canvas/xyflow/react', () => ({ + EdgeLabelRenderer: ({ children }: { children: React.ReactNode }) => children, +})); + function renderLabel(props: Partial> = {}) { - const { container } = render( - - - - ); + const { container } = render(); return { - foreignObject: container.querySelector('foreignObject'), label: container.querySelector('.react-flow__edge-label') as HTMLDivElement, }; } -/** Locks the label markup the legacy SequenceEdge rendered inline. */ describe('EdgeLabel', () => { it('renders the text centered on the given point, opted out of pan/drag', () => { - const { foreignObject, label } = renderLabel(); + const { label } = renderLabel(); expect(label.textContent).toBe('Run'); - expect(foreignObject?.getAttribute('x')).toBe('150'); - expect(foreignObject?.getAttribute('y')).toBe('50'); expect(label.className).toContain('nodrag'); expect(label.className).toContain('nopan'); - expect(label.style.transform).toBe('translate(-50%, -50%)'); - expect(label.style.pointerEvents).toBe('none'); + expect(label.style.transform).toBe('translate(-50%, -50%) translate(150px, 50px)'); }); it('uses the primary border when selected and the default border otherwise', () => { - // assert on the raw attribute — happy-dom's border shorthand drops var() colors - expect(renderLabel().label.getAttribute('style')).toContain('var(--canvas-border)'); - expect(renderLabel({ selected: true }).label.getAttribute('style')).toContain( - 'var(--canvas-primary)' + expect(renderLabel().label.className).toContain('border-(--canvas-border)'); + expect(renderLabel({ selected: true }).label.className).toContain('border-(--canvas-primary)'); + }); + + it('falls back to --color-background when --canvas-background is unresolved', () => { + expect(renderLabel().label.className).toContain( + 'bg-[var(--canvas-background,var(--color-background))]' ); }); }); diff --git a/packages/apollo-react/src/canvas/components/Edges/shared/primitives/EdgeLabel.tsx b/packages/apollo-react/src/canvas/components/Edges/shared/primitives/EdgeLabel.tsx index 10bc89106..4b3d88f6a 100644 --- a/packages/apollo-react/src/canvas/components/Edges/shared/primitives/EdgeLabel.tsx +++ b/packages/apollo-react/src/canvas/components/Edges/shared/primitives/EdgeLabel.tsx @@ -1,3 +1,7 @@ +import { EdgeLabelRenderer } from '@uipath/apollo-react/canvas/xyflow/react'; +import { cn } from '@uipath/apollo-wind'; +import { useMemo } from 'react'; + export type EdgeLabelProps = { x: number; y: number; @@ -5,35 +9,34 @@ export type EdgeLabelProps = { selected?: boolean; }; +// Falls back to --color-background when a host doesn't import canvas/styles/variables.css +// (e.g. a shadow-DOM host), so the label never renders with a transparent background. +const EDGE_LABEL_BASE_CLASS = + 'react-flow__edge-label nodrag nopan absolute top-0 left-0 whitespace-nowrap pointer-events-none ' + + 'px-2 py-1 rounded text-xs font-medium border shadow-[0_1px_3px_0_rgba(0,0,0,0.1)] ' + + 'text-(--canvas-foreground) bg-[var(--canvas-background,var(--color-background))]'; + +/** + * Portals into xyflow's `edgelabel-renderer` div, which is a DOM sibling that + * always paints after every edge's own ``. Rendering the label as a plain + * `foreignObject` inside the edge's own `` (the old approach) left it + * competing in the same per-edge z-index/DOM-order stack as every other edge's + * stroke, so a crossing unselected edge could paint over the label. + */ export function EdgeLabel({ x, y, text, selected }: EdgeLabelProps) { + const transform = useMemo(() => `translate(-50%, -50%) translate(${x}px, ${y}px)`, [x, y]); + return ( - +
{text}
- +
); } diff --git a/packages/apollo-react/src/canvas/components/StageNode/StageEdge.tsx b/packages/apollo-react/src/canvas/components/StageNode/StageEdge.tsx index 3e198ab1e..7739cc13c 100644 --- a/packages/apollo-react/src/canvas/components/StageNode/StageEdge.tsx +++ b/packages/apollo-react/src/canvas/components/StageNode/StageEdge.tsx @@ -10,7 +10,7 @@ import { memo, useMemo } from 'react'; export const StageEdgeLabel = styled.div` position: absolute; color: var(--canvas-foreground); - background: var(--canvas-background); + background: var(--canvas-background, var(--color-background)); padding: 4px 8px; border-radius: 4px; font-size: 12px; @@ -20,7 +20,7 @@ export const StageEdgeLabel = styled.div` pointer-events: all; &:hover { - background: var(--canvas-background-hover); + background: var(--canvas-background-hover, var(--color-background-hover)); border-color: var(--canvas-border-hover); } `; From e8a65e86076b5ba2c1e0579b1509fbdc0274761f Mon Sep 17 00:00:00 2001 From: David Anthony Date: Wed, 22 Jul 2026 13:41:59 -0700 Subject: [PATCH 02/11] docs(apollo-react): flag missing inline editing on edge labels for review Node labels support double-click-to-edit (BaseNode/NodeLabel's EditableLabel), but edge labels do not. EdgeLabel is pointer-events: none and CanvasEdgeData has no onLabelChange field, so there's no way to rename a label from the canvas. Clicking the label also passes straight through to the edge underneath and selects the whole edge, since the label isn't a hit target itself, the same root cause as the missing editing support: enabling pointer events on the label to support editing also changes what clicking it does today. Adds a story that surfaces both observations together and lays out two paths forward (keep labels pure-display vs. add inline editing, with the concrete changes each would require) as an open question for design/ product review, rather than a decision made here. Co-Authored-By: Claude Sonnet 5 --- .../components/Edges/EdgeLabel.stories.tsx | 125 ++++++++++++++++-- 1 file changed, 115 insertions(+), 10 deletions(-) diff --git a/packages/apollo-react/src/canvas/components/Edges/EdgeLabel.stories.tsx b/packages/apollo-react/src/canvas/components/Edges/EdgeLabel.stories.tsx index ec4316386..0f5640ad3 100644 --- a/packages/apollo-react/src/canvas/components/Edges/EdgeLabel.stories.tsx +++ b/packages/apollo-react/src/canvas/components/Edges/EdgeLabel.stories.tsx @@ -26,7 +26,7 @@ const meta: Meta = { docs: { description: { component: - 'Set `data.label` on a CanvasEdge (or SequenceEdge) to render a label at the midpoint of the path. The label portals through `EdgeLabelRenderer`, so it always paints after every edge\'s own line, and falls back to `--color-background` when a host does not theme its ancestor with one of the canvas theme classes. Labels are decorative: `pointer-events: none`, no hover state, not clickable.', + "Set `data.label` on a CanvasEdge (or SequenceEdge) to render a label at the midpoint of the path. The label portals through `EdgeLabelRenderer`, so it always paints after every edge's own line, and falls back to `--color-background` when a host does not theme its ancestor with one of the canvas theme classes. Labels are decorative: `pointer-events: none`, no hover state, not clickable.", }, }, }, @@ -78,8 +78,20 @@ function createNode(config: NodeConfig): Node { function MiniLabeledEdgeCanvas({ label = 'Success' }: { label?: string }) { const initialNodes = useMemo( () => [ - createNode({ id: 'mini-source', label: 'Source', x: 10, y: 50, sourcePositions: [Position.Right] }), - createNode({ id: 'mini-target', label: 'Target', x: 195, y: 50, targetPositions: [Position.Left] }), + createNode({ + id: 'mini-source', + label: 'Source', + x: 10, + y: 50, + sourcePositions: [Position.Right], + }), + createNode({ + id: 'mini-target', + label: 'Target', + x: 195, + y: 50, + targetPositions: [Position.Left], + }), ], [] ); @@ -110,10 +122,28 @@ function MiniLabeledEdgeCanvas({ label = 'Success' }: { label?: string }) { function OrientationStory() { const initialNodes = useMemo( () => [ - createNode({ id: 'h-source', label: 'Source', x: 100, y: 120, sourcePositions: [Position.Right] }), - createNode({ id: 'h-target', label: 'Target', x: 450, y: 120, targetPositions: [Position.Left] }), + createNode({ + id: 'h-source', + label: 'Source', + x: 100, + y: 120, + sourcePositions: [Position.Right], + }), + createNode({ + id: 'h-target', + label: 'Target', + x: 450, + y: 120, + targetPositions: [Position.Left], + }), - createNode({ id: 'v-source', label: 'Start', x: 250, y: 280, sourcePositions: [Position.Bottom] }), + createNode({ + id: 'v-source', + label: 'Start', + x: 250, + y: 280, + sourcePositions: [Position.Bottom], + }), createNode({ id: 'v-target', label: 'End', x: 250, y: 480, targetPositions: [Position.Top] }), ], [] @@ -258,7 +288,13 @@ function CrossingLabeledEdgesStory() { y: 120, sourcePositions: [Position.Right], }), - createNode({ id: 'b1', label: 'End Alpha', x: 550, y: 380, targetPositions: [Position.Left] }), + createNode({ + id: 'b1', + label: 'End Alpha', + x: 550, + y: 380, + targetPositions: [Position.Left], + }), createNode({ id: 'a2', label: 'Start Beta', @@ -305,7 +341,7 @@ export const CrossingLabeledEdges: Story = { docs: { description: { story: - 'Two labeled edges routed through the same center point. Both labels stay legible on top of the crossing line, regardless of array order. Before the EdgeLabelRenderer fix, whichever edge was later in the array could paint its line over the other edge\'s label.', + "Two labeled edges routed through the same center point. Both labels stay legible on top of the crossing line, regardless of array order. Before the EdgeLabelRenderer fix, whichever edge was later in the array could paint its line over the other edge's label.", }, }, }, @@ -375,7 +411,7 @@ export const Themes: Story = { docs: { description: { story: - 'The same labeled edge rendered under all nine canvas themes at once, to compare label contrast against each theme\'s background and border colors.', + "The same labeled edge rendered under all nine canvas themes at once, to compare label contrast against each theme's background and border colors.", }, }, }, @@ -550,7 +586,13 @@ export const Overflow: Story = { function ExecutionStatusStory() { const initialNodes = useMemo( () => [ - createNode({ id: 'exec-source', label: 'Start', x: 100, y: 120, sourcePositions: [Position.Right] }), + createNode({ + id: 'exec-source', + label: 'Start', + x: 100, + y: 120, + sourcePositions: [Position.Right], + }), createNode({ id: 'exec-InProgress', label: 'In Progress', @@ -715,3 +757,66 @@ export const ReadOnly: Story = { }, }, }; + +/** + * Open question for review, not resolved here. Node labels support + * double-click-to-edit (see BaseNode's NodeLabel and its EditableLabel + * sub-component), but edge labels are pure display: EdgeLabel is + * `pointer-events: none` and CanvasEdgeData has no `onLabelChange` field, + * so there is no way to rename this label from the canvas. Click it; the + * click passes straight through to the edge underneath and selects the + * whole edge instead, since the label has no hit target of its own. Any + * editing solution has to decide that click behavior too: enabling + * pointer events on the label changes what clicking it does today. + */ +function NoInlineEditingStory() { + const initialNodes = useMemo( + () => [ + createNode({ + id: 'edit-source', + label: 'Source', + x: 100, + y: 120, + sourcePositions: [Position.Right], + }), + createNode({ + id: 'edit-target', + label: 'Target', + x: 450, + y: 120, + targetPositions: [Position.Left], + }), + ], + [] + ); + + const initialEdges: Edge[] = useMemo( + () => [ + { + id: 'e-not-editable', + source: 'edit-source', + target: 'edit-target', + sourceHandle: `out-${Position.Right}`, + targetHandle: `in-${Position.Left}`, + type: 'canvas-edge', + data: { label: 'Try clicking me' }, + }, + ], + [] + ); + + const { canvasProps } = useCanvasStory({ initialNodes, initialEdges }); + return ; +} + +export const NoInlineEditing: Story = { + render: () => , + parameters: { + docs: { + description: { + story: + "Open question for review: node labels support double-click-to-edit (BaseNode/NodeLabel's EditableLabel), but edge labels do not. EdgeLabel is pointer-events: none and CanvasEdgeData has no onLabelChange field. Click this label: the click passes through to the edge underneath and selects the whole edge instead, since the label isn't a hit target itself, the same root cause as the missing editing support. Enabling pointer events on the label to support editing also changes what clicking it does today, so the two questions have to be decided together.\n\nTwo paths forward. Path A, keep labels as pure display: clicking continues to select the edge, labels stay a rendered property of the edge rather than a directly editable element, no new interaction model or engineering risk, but renaming a label still requires going through whatever set data.label upstream (host app UI, not the canvas itself). Path B, add inline editing for parity with node labels: needs an onLabelChange callback on CanvasEdgeData, pointer-events: auto on the label (likely gated on a handler being provided, so consumers who don't opt in see no behavior change), an explicit decision on click semantics (does a single click still select the edge, does double-click enter edit mode like NodeLabel does), an inline textarea reusing that same editing pattern, and handling for empty labels, Escape-to-cancel, and Enter-to-commit.", + }, + }, + }, +}; From c6f3a6d5d3d2f5412df6f7d399413ec89e75a50e Mon Sep 17 00:00:00 2001 From: David Anthony Date: Thu, 30 Jul 2026 10:31:13 -0700 Subject: [PATCH 03/11] fix(apollo-react): improve edge label interactions --- .../canvas/components/Edges/CanvasEdge.tsx | 34 ++- .../components/Edges/EdgeLabel.stories.tsx | 278 +----------------- .../shared/primitives/EdgeLabel.test.tsx | 31 +- .../Edges/shared/primitives/EdgeLabel.tsx | 41 ++- .../src/canvas/styles/reactflow-reset.css | 9 + 5 files changed, 105 insertions(+), 288 deletions(-) diff --git a/packages/apollo-react/src/canvas/components/Edges/CanvasEdge.tsx b/packages/apollo-react/src/canvas/components/Edges/CanvasEdge.tsx index e3c314ac7..08f033ee0 100644 --- a/packages/apollo-react/src/canvas/components/Edges/CanvasEdge.tsx +++ b/packages/apollo-react/src/canvas/components/Edges/CanvasEdge.tsx @@ -1,5 +1,5 @@ -import { Position } from '@uipath/apollo-react/canvas/xyflow/react'; -import { memo, useCallback, useRef, useState } from 'react'; +import { Position, useReactFlow } from '@uipath/apollo-react/canvas/xyflow/react'; +import { memo, type MouseEvent, useCallback, useRef, useState } from 'react'; import { isPreviewEdge } from '../../utils/createPreviewNode'; import { useBaseCanvasMode } from '../BaseCanvas/BaseCanvasModeProvider'; import { EdgeToolbar, useEdgeToolbarState } from '../Toolbar'; @@ -53,6 +53,30 @@ export const CanvasEdge = memo(function CanvasEdge({ const onMouseEnter = useCallback(() => setIsHovered(true), []); const onMouseLeave = useCallback(() => setIsHovered(false), []); const pathRef = useRef(null); + const { setEdges, setNodes } = useReactFlow(); + + const onLabelClick = useCallback( + (event: MouseEvent) => { + event.stopPropagation(); + const additiveSelection = event.metaKey || event.ctrlKey; + + setEdges((edges) => + edges.map((edge) => { + if (edge.id === id) { + return { ...edge, selected: additiveSelection ? !edge.selected : true }; + } + return additiveSelection ? edge : { ...edge, selected: false }; + }) + ); + + if (!additiveSelection) { + setNodes((nodes) => + nodes.map((node) => (node.selected ? { ...node, selected: false } : node)) + ); + } + }, + [id, setEdges, setNodes] + ); const routing = data?.routing ?? 'waypoint'; const storedWaypoints = data?.waypoints ?? EMPTY_WAYPOINTS; @@ -150,6 +174,7 @@ export const CanvasEdge = memo(function CanvasEdge({ return ( <> )} diff --git a/packages/apollo-react/src/canvas/components/Edges/EdgeLabel.stories.tsx b/packages/apollo-react/src/canvas/components/Edges/EdgeLabel.stories.tsx index 0f5640ad3..c75de4334 100644 --- a/packages/apollo-react/src/canvas/components/Edges/EdgeLabel.stories.tsx +++ b/packages/apollo-react/src/canvas/components/Edges/EdgeLabel.stories.tsx @@ -7,8 +7,7 @@ */ import type { Meta, StoryObj } from '@storybook/react'; import type { Edge, Node } from '@uipath/apollo-react/canvas/xyflow/react'; -import { Position, ReactFlowProvider } from '@uipath/apollo-react/canvas/xyflow/react'; -import type { CSSProperties } from 'react'; +import { Position } from '@uipath/apollo-react/canvas/xyflow/react'; import { useMemo } from 'react'; import { createNode as createMockNode, @@ -26,7 +25,7 @@ const meta: Meta = { docs: { description: { component: - "Set `data.label` on a CanvasEdge (or SequenceEdge) to render a label at the midpoint of the path. The label portals through `EdgeLabelRenderer`, so it always paints after every edge's own line, and falls back to `--color-background` when a host does not theme its ancestor with one of the canvas theme classes. Labels are decorative: `pointer-events: none`, no hover state, not clickable.", + "Set `data.label` on a CanvasEdge (or SequenceEdge) to render a label at the midpoint of the path. The label portals through `EdgeLabelRenderer`, so it always paints after every edge's own line. Its border matches the resolved edge color across default, hover, selection, diff, validation, and execution states. Long text truncates with an ellipsis and reveals its full value in a tooltip. Hovering or clicking a label interacts with its owning edge; read-only canvases preserve hover tracing but disable selection.", }, }, }, @@ -70,51 +69,6 @@ function createNode(config: NodeConfig): Node { }); } -/** - * Small two-node/one-edge canvas used to compare the label across themes and - * fallback scenarios. Each instance gets its own `ReactFlowProvider` so - * several can sit side by side on one page. - */ -function MiniLabeledEdgeCanvas({ label = 'Success' }: { label?: string }) { - const initialNodes = useMemo( - () => [ - createNode({ - id: 'mini-source', - label: 'Source', - x: 10, - y: 50, - sourcePositions: [Position.Right], - }), - createNode({ - id: 'mini-target', - label: 'Target', - x: 195, - y: 50, - targetPositions: [Position.Left], - }), - ], - [] - ); - - const initialEdges: Edge[] = useMemo( - () => [ - { - id: 'mini-edge', - source: 'mini-source', - target: 'mini-target', - sourceHandle: `out-${Position.Right}`, - targetHandle: `in-${Position.Left}`, - type: 'canvas-edge', - data: { label }, - }, - ], - [label] - ); - - const { canvasProps } = useCanvasStory({ initialNodes, initialEdges }); - return ; -} - /** * A horizontal and a vertical edge, each labeled. Confirms the label centers * on the path midpoint regardless of routing direction. @@ -190,7 +144,7 @@ export const Orientation: Story = { /** * Labels alongside diff styling. `isDiffAdded`/`isDiffRemoved` drive the - * stroke color via `resolveEdgeColor`; the label renders unaffected. + * stroke and label-border color via `resolveEdgeColor`. */ function DiffStatesStory() { const initialNodes = useMemo( @@ -262,7 +216,7 @@ export const DiffStates: Story = { docs: { description: { story: - 'Labels alongside diff styling. isDiffAdded and isDiffRemoved color the stroke, the label renders the same either way.', + 'Labels alongside diff styling. isDiffAdded and isDiffRemoved color both the stroke and its associated label border.', }, }, }, @@ -341,158 +295,7 @@ export const CrossingLabeledEdges: Story = { docs: { description: { story: - "Two labeled edges routed through the same center point. Both labels stay legible on top of the crossing line, regardless of array order. Before the EdgeLabelRenderer fix, whichever edge was later in the array could paint its line over the other edge's label.", - }, - }, - }, -}; - -/** - * The same labeled edge under every canvas theme. Each cell forces its theme - * class on a wrapping div (the same selectors variables.css matches on - * `body.`), independent of the global Storybook theme toolbar, so all - * nine render simultaneously for comparison. - */ -const THEME_CLASSES = [ - 'light', - 'dark', - 'light-hc', - 'dark-hc', - 'future-light', - 'future-dark', - 'wireframe', - 'vertex', - 'canvas', -] as const; - -function ThemesStory() { - return ( -
- {THEME_CLASSES.map((theme) => ( -
-
- {theme} -
-
- - - -
-
- ))} -
- ); -} - -export const Themes: Story = { - render: () => , - parameters: { - docs: { - description: { - story: - "The same labeled edge rendered under all nine canvas themes at once, to compare label contrast against each theme's background and border colors.", - }, - }, - }, -}; - -/** - * Simulates a host that does not theme its canvas ancestor. `--canvas-background` - * and `--color-background` are set to `initial` (the CSS-spec guaranteed-invalid - * value) on a wrapping div, which is what actually happens when a host mounts - * the canvas outside any `body.` / `.future-*` / `.vertex` / `.canvas` - * ancestor (for example, a shadow-DOM host). Column 2 shows the fix's fallback - * resolving; column 3 is the known remaining gap: when neither variable - * resolves, the label background is transparent. - */ -function MissingThemeFallbackStory() { - const columnStyle: CSSProperties = { - flex: 1, - display: 'flex', - flexDirection: 'column', - gap: 8, - minWidth: 0, - }; - const canvasBoxStyle: CSSProperties = { - flex: 1, - border: '1px solid rgba(128, 128, 128, 0.3)', - borderRadius: 8, - overflow: 'hidden', - }; - - return ( -
-
-

- Normal: the theme ancestor provides --canvas-background. -

-
- - - -
-
-
-

- --canvas-background unset (variables.css not imported): falls back to --color-background. -

-
- - - -
-
-
-

- Both unset, no themed ancestor at all: background is transparent. Known gap, not covered - by the current fallback chain. -

-
- - - -
-
-
- ); -} - -export const MissingThemeFallback: Story = { - render: () => , - parameters: { - docs: { - description: { - story: - 'Simulates a host that never themes the canvas ancestor (e.g. a shadow-DOM host, or an app that forgot to theme body). Demonstrates the fallback chain added in EdgeLabel.tsx, and documents the remaining gap when neither variable resolves.', + "Two labeled edges routed through the same center point. Both labels stay legible on top of the crossing line, regardless of array order. Hovering or selecting an edge gives its label a matching one-pixel border, making the path-to-label relationship clear. The hovered edge also rises above intersecting edges so its full path remains traceable. Before the EdgeLabelRenderer fix, whichever edge was later in the array could paint its line over the other edge's label.", }, }, }, @@ -573,7 +376,7 @@ export const Overflow: Story = { docs: { description: { story: - 'Long label text overflows since the label has no truncation, and a short edge with close nodes crowds the label against both node bodies. Neither case is currently guarded against.', + 'Long labels are capped by pixel width and truncated with an ellipsis. Hover a truncated label to reveal its full value in a tooltip; hovering and clicking the label still interact with its owning edge. The short-edge example remains a separate crowding case because truncation cannot create space between nearby nodes.', }, }, }, @@ -704,8 +507,9 @@ export const BentPath: Story = { }; /** - * Label rendering in readonly mode. Visually identical to design mode; no - * editing chrome is available regardless. + * Label rendering in readonly mode. Hover remains available for tracing the + * edge and revealing truncated text, while neither the path nor label can be + * selected and no editing chrome is available. */ function ReadOnlyStory() { const initialNodes = useMemo( @@ -749,73 +553,11 @@ function ReadOnlyStory() { export const ReadOnly: Story = { render: () => , - parameters: { - docs: { - description: { - story: 'Label rendering in readonly mode, for edges representing a completed workflow run.', - }, - }, - }, -}; - -/** - * Open question for review, not resolved here. Node labels support - * double-click-to-edit (see BaseNode's NodeLabel and its EditableLabel - * sub-component), but edge labels are pure display: EdgeLabel is - * `pointer-events: none` and CanvasEdgeData has no `onLabelChange` field, - * so there is no way to rename this label from the canvas. Click it; the - * click passes straight through to the edge underneath and selects the - * whole edge instead, since the label has no hit target of its own. Any - * editing solution has to decide that click behavior too: enabling - * pointer events on the label changes what clicking it does today. - */ -function NoInlineEditingStory() { - const initialNodes = useMemo( - () => [ - createNode({ - id: 'edit-source', - label: 'Source', - x: 100, - y: 120, - sourcePositions: [Position.Right], - }), - createNode({ - id: 'edit-target', - label: 'Target', - x: 450, - y: 120, - targetPositions: [Position.Left], - }), - ], - [] - ); - - const initialEdges: Edge[] = useMemo( - () => [ - { - id: 'e-not-editable', - source: 'edit-source', - target: 'edit-target', - sourceHandle: `out-${Position.Right}`, - targetHandle: `in-${Position.Left}`, - type: 'canvas-edge', - data: { label: 'Try clicking me' }, - }, - ], - [] - ); - - const { canvasProps } = useCanvasStory({ initialNodes, initialEdges }); - return ; -} - -export const NoInlineEditing: Story = { - render: () => , parameters: { docs: { description: { story: - "Open question for review: node labels support double-click-to-edit (BaseNode/NodeLabel's EditableLabel), but edge labels do not. EdgeLabel is pointer-events: none and CanvasEdgeData has no onLabelChange field. Click this label: the click passes through to the edge underneath and selects the whole edge instead, since the label isn't a hit target itself, the same root cause as the missing editing support. Enabling pointer events on the label to support editing also changes what clicking it does today, so the two questions have to be decided together.\n\nTwo paths forward. Path A, keep labels as pure display: clicking continues to select the edge, labels stay a rendered property of the edge rather than a directly editable element, no new interaction model or engineering risk, but renaming a label still requires going through whatever set data.label upstream (host app UI, not the canvas itself). Path B, add inline editing for parity with node labels: needs an onLabelChange callback on CanvasEdgeData, pointer-events: auto on the label (likely gated on a handler being provided, so consumers who don't opt in see no behavior change), an explicit decision on click semantics (does a single click still select the edge, does double-click enter edit mode like NodeLabel does), an inline textarea reusing that same editing pattern, and handling for empty labels, Escape-to-cancel, and Enter-to-commit.", + 'Label rendering in readonly mode for a completed workflow run. Hover still traces the path, but clicking either the path or label does not create persistent selection or expose editing controls.', }, }, }, diff --git a/packages/apollo-react/src/canvas/components/Edges/shared/primitives/EdgeLabel.test.tsx b/packages/apollo-react/src/canvas/components/Edges/shared/primitives/EdgeLabel.test.tsx index c604d4555..9548fb549 100644 --- a/packages/apollo-react/src/canvas/components/Edges/shared/primitives/EdgeLabel.test.tsx +++ b/packages/apollo-react/src/canvas/components/Edges/shared/primitives/EdgeLabel.test.tsx @@ -1,4 +1,4 @@ -import { render } from '@testing-library/react'; +import { fireEvent, render } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import { EdgeLabel } from './EdgeLabel'; @@ -25,9 +25,12 @@ describe('EdgeLabel', () => { expect(label.style.transform).toBe('translate(-50%, -50%) translate(150px, 50px)'); }); - it('uses the primary border when selected and the default border otherwise', () => { - expect(renderLabel().label.className).toContain('border-(--canvas-border)'); - expect(renderLabel({ selected: true }).label.className).toContain('border-(--canvas-primary)'); + it('uses the supplied edge color for its border and defaults to the canvas border', () => { + const defaultLabel = renderLabel().label; + const coloredLabel = renderLabel({ borderColor: 'var(--canvas-success-icon)' }).label; + + expect(defaultLabel.style.borderColor).toBe('var(--canvas-border)'); + expect(coloredLabel.style.borderColor).toBe('var(--canvas-success-icon)'); }); it('falls back to --color-background when --canvas-background is unresolved', () => { @@ -35,4 +38,24 @@ describe('EdgeLabel', () => { 'bg-[var(--canvas-background,var(--color-background))]' ); }); + + it('truncates by width and forwards interaction to its owning edge', () => { + const onClick = vi.fn(); + const onMouseEnter = vi.fn(); + const onMouseLeave = vi.fn(); + const { label } = renderLabel({ onClick, onMouseEnter, onMouseLeave }); + + expect(label.className).toContain('max-w-48'); + expect(label.className).toContain('overflow-hidden'); + expect(label.className).toContain('text-ellipsis'); + expect(label.className).toContain('pointer-events-auto'); + + fireEvent.mouseEnter(label); + fireEvent.mouseLeave(label); + fireEvent.click(label); + + expect(onMouseEnter).toHaveBeenCalledOnce(); + expect(onMouseLeave).toHaveBeenCalledOnce(); + expect(onClick).toHaveBeenCalledOnce(); + }); }); diff --git a/packages/apollo-react/src/canvas/components/Edges/shared/primitives/EdgeLabel.tsx b/packages/apollo-react/src/canvas/components/Edges/shared/primitives/EdgeLabel.tsx index 4b3d88f6a..b74dce0e2 100644 --- a/packages/apollo-react/src/canvas/components/Edges/shared/primitives/EdgeLabel.tsx +++ b/packages/apollo-react/src/canvas/components/Edges/shared/primitives/EdgeLabel.tsx @@ -1,18 +1,23 @@ import { EdgeLabelRenderer } from '@uipath/apollo-react/canvas/xyflow/react'; -import { cn } from '@uipath/apollo-wind'; +import type { MouseEventHandler } from 'react'; import { useMemo } from 'react'; +import { CanvasTooltip } from '../../../CanvasTooltip'; export type EdgeLabelProps = { x: number; y: number; text: string; - selected?: boolean; + borderColor?: string; + onClick?: MouseEventHandler; + onMouseEnter?: () => void; + onMouseLeave?: () => void; }; // Falls back to --color-background when a host doesn't import canvas/styles/variables.css // (e.g. a shadow-DOM host), so the label never renders with a transparent background. const EDGE_LABEL_BASE_CLASS = - 'react-flow__edge-label nodrag nopan absolute top-0 left-0 whitespace-nowrap pointer-events-none ' + + 'react-flow__edge-label nodrag nopan absolute top-0 left-0 max-w-48 overflow-hidden text-ellipsis ' + + 'whitespace-nowrap pointer-events-auto cursor-default ' + 'px-2 py-1 rounded text-xs font-medium border shadow-[0_1px_3px_0_rgba(0,0,0,0.1)] ' + 'text-(--canvas-foreground) bg-[var(--canvas-background,var(--color-background))]'; @@ -23,20 +28,30 @@ const EDGE_LABEL_BASE_CLASS = * competing in the same per-edge z-index/DOM-order stack as every other edge's * stroke, so a crossing unselected edge could paint over the label. */ -export function EdgeLabel({ x, y, text, selected }: EdgeLabelProps) { +export function EdgeLabel({ + x, + y, + text, + borderColor = 'var(--canvas-border)', + onClick, + onMouseEnter, + onMouseLeave, +}: EdgeLabelProps) { const transform = useMemo(() => `translate(-50%, -50%) translate(${x}px, ${y}px)`, [x, y]); return ( -
- {text} -
+ +
+ {text} +
+
); } diff --git a/packages/apollo-react/src/canvas/styles/reactflow-reset.css b/packages/apollo-react/src/canvas/styles/reactflow-reset.css index 1b4ba2eb4..718e62768 100644 --- a/packages/apollo-react/src/canvas/styles/reactflow-reset.css +++ b/packages/apollo-react/src/canvas/styles/reactflow-reset.css @@ -49,6 +49,15 @@ z-index: 1002 !important; } +/** + * Keep the edge currently under the pointer visually continuous at crossings. + * React Flow elevates selected edges to 1000; hover takes temporary precedence + * at 1001 while remaining below hovered node controls at 1002. + */ +.react-flow__edge:has([data-edge-hovered='true']) { + z-index: 1001 !important; +} + /* Keep loop shells behind sticky-note annotations; empty loop body still hit-tests to the loop. */ .react-flow__node:has([data-loop-container]) { z-index: -20 !important; From 93b08ec62b07e157413881b87322c344a0d8a520 Mon Sep 17 00:00:00 2001 From: David Anthony Date: Thu, 30 Jul 2026 10:36:40 -0700 Subject: [PATCH 04/11] fix(apollo-react): complete edge label fallbacks --- .../canvas/components/Edges/shared/primitives/EdgeLabel.tsx | 4 ++-- .../src/canvas/components/StageNode/StageEdge.tsx | 6 +++--- packages/apollo-react/src/canvas/styles/reactflow-reset.css | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/apollo-react/src/canvas/components/Edges/shared/primitives/EdgeLabel.tsx b/packages/apollo-react/src/canvas/components/Edges/shared/primitives/EdgeLabel.tsx index b74dce0e2..ff4a5590d 100644 --- a/packages/apollo-react/src/canvas/components/Edges/shared/primitives/EdgeLabel.tsx +++ b/packages/apollo-react/src/canvas/components/Edges/shared/primitives/EdgeLabel.tsx @@ -13,8 +13,8 @@ export type EdgeLabelProps = { onMouseLeave?: () => void; }; -// Falls back to --color-background when a host doesn't import canvas/styles/variables.css -// (e.g. a shadow-DOM host), so the label never renders with a transparent background. +// Falls back to --color-background when the canvas-specific background token is unavailable, +// such as in a host that doesn't import canvas/styles/variables.css. const EDGE_LABEL_BASE_CLASS = 'react-flow__edge-label nodrag nopan absolute top-0 left-0 max-w-48 overflow-hidden text-ellipsis ' + 'whitespace-nowrap pointer-events-auto cursor-default ' + diff --git a/packages/apollo-react/src/canvas/components/StageNode/StageEdge.tsx b/packages/apollo-react/src/canvas/components/StageNode/StageEdge.tsx index 7739cc13c..bf2c938cf 100644 --- a/packages/apollo-react/src/canvas/components/StageNode/StageEdge.tsx +++ b/packages/apollo-react/src/canvas/components/StageNode/StageEdge.tsx @@ -9,19 +9,19 @@ import { memo, useMemo } from 'react'; export const StageEdgeLabel = styled.div` position: absolute; - color: var(--canvas-foreground); + color: var(--canvas-foreground, var(--color-foreground)); background: var(--canvas-background, var(--color-background)); padding: 4px 8px; border-radius: 4px; font-size: 12px; font-weight: 500; - border: 1px solid var(--canvas-border); + border: 1px solid var(--canvas-border, var(--color-border)); box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1); pointer-events: all; &:hover { background: var(--canvas-background-hover, var(--color-background-hover)); - border-color: var(--canvas-border-hover); + border-color: var(--canvas-border-hover, var(--color-border-hover)); } `; diff --git a/packages/apollo-react/src/canvas/styles/reactflow-reset.css b/packages/apollo-react/src/canvas/styles/reactflow-reset.css index 718e62768..40b28f664 100644 --- a/packages/apollo-react/src/canvas/styles/reactflow-reset.css +++ b/packages/apollo-react/src/canvas/styles/reactflow-reset.css @@ -54,7 +54,7 @@ * React Flow elevates selected edges to 1000; hover takes temporary precedence * at 1001 while remaining below hovered node controls at 1002. */ -.react-flow__edge:has([data-edge-hovered='true']) { +.react-flow__edge:has([data-edge-hovered="true"]) { z-index: 1001 !important; } From 8eee8018a629dc917febc4b9a4f22d9ff9189f40 Mon Sep 17 00:00:00 2001 From: David Anthony Date: Fri, 31 Jul 2026 08:54:49 -0700 Subject: [PATCH 05/11] fix(apollo-react): address edge label review feedback --- .../components/Edges/CanvasEdge.test.tsx | 142 ++++++++++++++++++ .../canvas/components/Edges/CanvasEdge.tsx | 35 ++--- .../components/Edges/shared/constants.ts | 12 +- .../shared/primitives/EdgeLabel.test.tsx | 14 +- .../Edges/shared/primitives/EdgeLabel.tsx | 9 +- .../Edges/shared/primitives/EdgePath.test.tsx | 2 +- .../src/canvas/styles/reactflow-reset.css | 12 +- 7 files changed, 192 insertions(+), 34 deletions(-) create mode 100644 packages/apollo-react/src/canvas/components/Edges/CanvasEdge.test.tsx diff --git a/packages/apollo-react/src/canvas/components/Edges/CanvasEdge.test.tsx b/packages/apollo-react/src/canvas/components/Edges/CanvasEdge.test.tsx new file mode 100644 index 000000000..a679eba34 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Edges/CanvasEdge.test.tsx @@ -0,0 +1,142 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { Position } from '@uipath/apollo-react/canvas/xyflow/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { BaseCanvasModeProvider } from '../BaseCanvas/BaseCanvasModeProvider'; +import { CanvasEdge } from './CanvasEdge'; +import type { CanvasEdgeProps } from './shared/types'; + +const { addSelectedEdges, edgeLookup, getState, setState, unselectNodesAndEdges } = vi.hoisted( + () => ({ + addSelectedEdges: vi.fn(), + edgeLookup: new Map(), + getState: vi.fn(), + setState: vi.fn(), + unselectNodesAndEdges: vi.fn(), + }) +); + +vi.mock('@uipath/apollo-react/canvas/xyflow/react', async () => { + const actual = await vi.importActual('@uipath/apollo-react/canvas/xyflow/react'); + return { ...actual, useStoreApi: () => ({ getState, setState }) }; +}); + +vi.mock('./shared/hooks', () => ({ + useEdgeGeometry: () => ({ + arrow: { angle: 0, offset: 0 }, + edgePath: 'M 0 0 L 100 0', + labelPoint: { x: 50, y: 0 }, + pathPoints: [], + segments: [], + }), + useExecutionEdge: () => ({ animation: null, statusColor: undefined }), + useNodeDragRebalance: ({ waypoints }: { waypoints: unknown[] }) => waypoints, + useWaypointEditor: () => ({ + isDragging: false, + segmentHandlers: {}, + waypointHandlers: {}, + }), +})); + +vi.mock('../Toolbar', () => ({ + EdgeToolbar: () => null, + useEdgeToolbarState: () => ({ showToolbar: false }), +})); + +vi.mock('./shared/primitives', () => ({ + EdgeArrow: () => null, + EdgeLabel: ({ text, onClick }: { text: string; onClick?: () => void }) => ( + + ), + EdgePath: () => null, + SegmentDragHandle: () => null, + WaypointHandle: () => null, +})); + +const baseProps = { + id: 'e1', + source: 'a', + target: 'b', + sourcePosition: Position.Right, + targetPosition: Position.Left, + sourceX: 0, + sourceY: 0, + targetX: 100, + targetY: 0, + data: { label: 'Alpha' }, +} as unknown as CanvasEdgeProps; + +function renderEdge( + mode: 'design' | 'readonly' = 'design', + edge: { selected?: boolean; selectable?: boolean } = {} +) { + edgeLookup.set('e1', { id: 'e1', ...edge }); + getState.mockReturnValue({ + addSelectedEdges, + edgeLookup, + elementsSelectable: true, + multiSelectionActive: false, + unselectNodesAndEdges, + }); + + render( + + + + + + ); +} + +describe('CanvasEdge label selection', () => { + beforeEach(() => { + vi.clearAllMocks(); + edgeLookup.clear(); + }); + + it('uses the xyflow selection action and closes an active node selection', () => { + renderEdge(); + + fireEvent.click(screen.getByRole('button', { name: 'Alpha' })); + + expect(setState).toHaveBeenCalledWith({ nodesSelectionActive: false }); + expect(addSelectedEdges).toHaveBeenCalledWith(['e1']); + }); + + it('toggles an already-selected edge when multi-selection is active', () => { + renderEdge('design', { selected: true }); + + getState.mockReturnValue({ + addSelectedEdges, + edgeLookup, + elementsSelectable: true, + multiSelectionActive: true, + unselectNodesAndEdges, + }); + fireEvent.click(screen.getByRole('button', { name: 'Alpha' })); + + expect(unselectNodesAndEdges).toHaveBeenCalledWith({ + nodes: [], + edges: [expect.objectContaining({ id: 'e1' })], + }); + }); + + it('does not select a label when the edge is not selectable', () => { + renderEdge('design', { selectable: false }); + + fireEvent.click(screen.getByRole('button', { name: 'Alpha' })); + + expect(addSelectedEdges).not.toHaveBeenCalled(); + expect(setState).not.toHaveBeenCalled(); + }); + + it('does not attach label selection in read-only mode', () => { + renderEdge('readonly'); + + fireEvent.click(screen.getByRole('button', { name: 'Alpha' })); + + expect(addSelectedEdges).not.toHaveBeenCalled(); + expect(setState).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/Edges/CanvasEdge.tsx b/packages/apollo-react/src/canvas/components/Edges/CanvasEdge.tsx index 08f033ee0..78cf9cfd8 100644 --- a/packages/apollo-react/src/canvas/components/Edges/CanvasEdge.tsx +++ b/packages/apollo-react/src/canvas/components/Edges/CanvasEdge.tsx @@ -1,5 +1,5 @@ -import { Position, useReactFlow } from '@uipath/apollo-react/canvas/xyflow/react'; -import { memo, type MouseEvent, useCallback, useRef, useState } from 'react'; +import { Position, useStoreApi } from '@uipath/apollo-react/canvas/xyflow/react'; +import { type MouseEvent, memo, useCallback, useRef, useState } from 'react'; import { isPreviewEdge } from '../../utils/createPreviewNode'; import { useBaseCanvasMode } from '../BaseCanvas/BaseCanvasModeProvider'; import { EdgeToolbar, useEdgeToolbarState } from '../Toolbar'; @@ -53,29 +53,30 @@ export const CanvasEdge = memo(function CanvasEdge({ const onMouseEnter = useCallback(() => setIsHovered(true), []); const onMouseLeave = useCallback(() => setIsHovered(false), []); const pathRef = useRef(null); - const { setEdges, setNodes } = useReactFlow(); + const store = useStoreApi(); const onLabelClick = useCallback( (event: MouseEvent) => { event.stopPropagation(); - const additiveSelection = event.metaKey || event.ctrlKey; + const { + addSelectedEdges, + edgeLookup, + elementsSelectable, + multiSelectionActive, + unselectNodesAndEdges, + } = store.getState(); + const edge = edgeLookup.get(id); - setEdges((edges) => - edges.map((edge) => { - if (edge.id === id) { - return { ...edge, selected: additiveSelection ? !edge.selected : true }; - } - return additiveSelection ? edge : { ...edge, selected: false }; - }) - ); + if (!edge || !(edge.selectable ?? elementsSelectable)) return; - if (!additiveSelection) { - setNodes((nodes) => - nodes.map((node) => (node.selected ? { ...node, selected: false } : node)) - ); + store.setState({ nodesSelectionActive: false }); + if (edge.selected && multiSelectionActive) { + unselectNodesAndEdges({ nodes: [], edges: [edge] }); + } else { + addSelectedEdges([id]); } }, - [id, setEdges, setNodes] + [id, store] ); const routing = data?.routing ?? 'waypoint'; diff --git a/packages/apollo-react/src/canvas/components/Edges/shared/constants.ts b/packages/apollo-react/src/canvas/components/Edges/shared/constants.ts index 22498caa6..18864a7b2 100644 --- a/packages/apollo-react/src/canvas/components/Edges/shared/constants.ts +++ b/packages/apollo-react/src/canvas/components/Edges/shared/constants.ts @@ -55,12 +55,12 @@ export const EDGE_DASHARRAY = { * in canvas/styles/variables.css. */ export const EDGE_COLORS = { - default: 'var(--canvas-border)', - hover: 'var(--canvas-primary-hover)', - selected: 'var(--canvas-primary)', - invalid: 'var(--canvas-error-icon)', - diffAdded: 'var(--canvas-success-icon)', - diffRemoved: 'var(--canvas-error-icon)', + default: 'var(--canvas-border, var(--color-border))', + hover: 'var(--canvas-primary-hover, var(--color-primary-hover))', + selected: 'var(--canvas-primary, var(--color-primary))', + invalid: 'var(--canvas-error-icon, var(--color-error-icon))', + diffAdded: 'var(--canvas-success-icon, var(--color-success-icon))', + diffRemoved: 'var(--canvas-error-icon, var(--color-error-icon))', } as const; /** diff --git a/packages/apollo-react/src/canvas/components/Edges/shared/primitives/EdgeLabel.test.tsx b/packages/apollo-react/src/canvas/components/Edges/shared/primitives/EdgeLabel.test.tsx index 9548fb549..4c42451aa 100644 --- a/packages/apollo-react/src/canvas/components/Edges/shared/primitives/EdgeLabel.test.tsx +++ b/packages/apollo-react/src/canvas/components/Edges/shared/primitives/EdgeLabel.test.tsx @@ -1,6 +1,6 @@ import { fireEvent, render } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; -import { EdgeLabel } from './EdgeLabel'; +import { EDGE_LABEL_DEFAULT_BORDER_COLOR, EdgeLabel } from './EdgeLabel'; // EdgeLabelRenderer portals into a DOM node xyflow only creates once a full // instance has mounted, which this unit test doesn't set up. @@ -29,14 +29,16 @@ describe('EdgeLabel', () => { const defaultLabel = renderLabel().label; const coloredLabel = renderLabel({ borderColor: 'var(--canvas-success-icon)' }).label; - expect(defaultLabel.style.borderColor).toBe('var(--canvas-border)'); + expect(EDGE_LABEL_DEFAULT_BORDER_COLOR).toBe('var(--canvas-border,var(--color-border))'); + // happy-dom drops nested var() fallback values from CSSStyleDeclaration. + expect(defaultLabel.style.borderColor).toBe(''); expect(coloredLabel.style.borderColor).toBe('var(--canvas-success-icon)'); }); - it('falls back to --color-background when --canvas-background is unresolved', () => { - expect(renderLabel().label.className).toContain( - 'bg-[var(--canvas-background,var(--color-background))]' - ); + it('falls back to core color tokens when canvas tokens are unresolved', () => { + const { className } = renderLabel().label; + expect(className).toContain('bg-[var(--canvas-background,var(--color-background))]'); + expect(className).toContain('text-[var(--canvas-foreground,var(--color-foreground))]'); }); it('truncates by width and forwards interaction to its owning edge', () => { diff --git a/packages/apollo-react/src/canvas/components/Edges/shared/primitives/EdgeLabel.tsx b/packages/apollo-react/src/canvas/components/Edges/shared/primitives/EdgeLabel.tsx index ff4a5590d..bf0af41da 100644 --- a/packages/apollo-react/src/canvas/components/Edges/shared/primitives/EdgeLabel.tsx +++ b/packages/apollo-react/src/canvas/components/Edges/shared/primitives/EdgeLabel.tsx @@ -13,13 +13,16 @@ export type EdgeLabelProps = { onMouseLeave?: () => void; }; -// Falls back to --color-background when the canvas-specific background token is unavailable, +// Falls back to core color tokens when canvas-specific tokens are unavailable, // such as in a host that doesn't import canvas/styles/variables.css. const EDGE_LABEL_BASE_CLASS = 'react-flow__edge-label nodrag nopan absolute top-0 left-0 max-w-48 overflow-hidden text-ellipsis ' + 'whitespace-nowrap pointer-events-auto cursor-default ' + 'px-2 py-1 rounded text-xs font-medium border shadow-[0_1px_3px_0_rgba(0,0,0,0.1)] ' + - 'text-(--canvas-foreground) bg-[var(--canvas-background,var(--color-background))]'; + 'text-[var(--canvas-foreground,var(--color-foreground))] ' + + 'bg-[var(--canvas-background,var(--color-background))]'; + +export const EDGE_LABEL_DEFAULT_BORDER_COLOR = 'var(--canvas-border,var(--color-border))'; /** * Portals into xyflow's `edgelabel-renderer` div, which is a DOM sibling that @@ -32,7 +35,7 @@ export function EdgeLabel({ x, y, text, - borderColor = 'var(--canvas-border)', + borderColor = EDGE_LABEL_DEFAULT_BORDER_COLOR, onClick, onMouseEnter, onMouseLeave, diff --git a/packages/apollo-react/src/canvas/components/Edges/shared/primitives/EdgePath.test.tsx b/packages/apollo-react/src/canvas/components/Edges/shared/primitives/EdgePath.test.tsx index 29ab2ea54..89db73da4 100644 --- a/packages/apollo-react/src/canvas/components/Edges/shared/primitives/EdgePath.test.tsx +++ b/packages/apollo-react/src/canvas/components/Edges/shared/primitives/EdgePath.test.tsx @@ -49,7 +49,7 @@ describe('EdgePath', () => { expect(renderPath().outline).toBeNull(); const { outline } = renderPath({ selected: true }); expect(outline).not.toBeNull(); - expect(outline?.getAttribute('stroke')).toBe('var(--canvas-primary)'); + expect(outline?.getAttribute('stroke')).toBe('var(--canvas-primary, var(--color-primary))'); }); it('applies opacity to the visible path and keeps the 20px interaction layer', () => { diff --git a/packages/apollo-react/src/canvas/styles/reactflow-reset.css b/packages/apollo-react/src/canvas/styles/reactflow-reset.css index 40b28f664..3ea400fa5 100644 --- a/packages/apollo-react/src/canvas/styles/reactflow-reset.css +++ b/packages/apollo-react/src/canvas/styles/reactflow-reset.css @@ -54,10 +54,20 @@ * React Flow elevates selected edges to 1000; hover takes temporary precedence * at 1001 while remaining below hovered node controls at 1002. */ -.react-flow__edge:has([data-edge-hovered="true"]) { +.react-flow__edges > svg:has([data-edge-hovered="true"]) { z-index: 1001 !important; } +/** + * Edge labels portal into this sibling layer. Lift it above xyflow's selected + * edge band (1000) and our hovered-edge band (1001), so no edge stroke can + * paint through a label. Hovered node controls also use 1002 and win by DOM + * order because the node layer follows the label renderer. + */ +.react-flow__edgelabel-renderer { + z-index: 1002; +} + /* Keep loop shells behind sticky-note annotations; empty loop body still hit-tests to the loop. */ .react-flow__node:has([data-loop-container]) { z-index: -20 !important; From 3ed817c24288b6385b5d397b2ddfce299c9a73fd Mon Sep 17 00:00:00 2001 From: David Anthony Date: Fri, 31 Jul 2026 10:20:34 -0700 Subject: [PATCH 06/11] docs(apollo-react): align edge label review details --- .../src/canvas/components/Edges/EdgeLabel.stories.tsx | 6 +++--- packages/apollo-react/src/canvas/styles/reactflow-reset.css | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/apollo-react/src/canvas/components/Edges/EdgeLabel.stories.tsx b/packages/apollo-react/src/canvas/components/Edges/EdgeLabel.stories.tsx index c75de4334..2b0af3f28 100644 --- a/packages/apollo-react/src/canvas/components/Edges/EdgeLabel.stories.tsx +++ b/packages/apollo-react/src/canvas/components/Edges/EdgeLabel.stories.tsx @@ -302,9 +302,9 @@ export const CrossingLabeledEdges: Story = { }; /** - * Two known-limitation cases. The label is `whitespace-nowrap` with no - * truncation, so long text simply overflows; and a short edge with close - * nodes crowds the label against both node bodies. + * Two edge-label boundary cases: long text truncates with an ellipsis and + * exposes its full value in a tooltip, while a short edge with close nodes + * crowds the label against both node bodies. */ function OverflowStory() { const initialNodes = useMemo( diff --git a/packages/apollo-react/src/canvas/styles/reactflow-reset.css b/packages/apollo-react/src/canvas/styles/reactflow-reset.css index 3ea400fa5..b9aeb5a9f 100644 --- a/packages/apollo-react/src/canvas/styles/reactflow-reset.css +++ b/packages/apollo-react/src/canvas/styles/reactflow-reset.css @@ -65,7 +65,7 @@ * order because the node layer follows the label renderer. */ .react-flow__edgelabel-renderer { - z-index: 1002; + z-index: 1002 !important; } /* Keep loop shells behind sticky-note annotations; empty loop body still hit-tests to the loop. */ From e66f49eed5c12582952b9948bed30e7d1947601e Mon Sep 17 00:00:00 2001 From: David Anthony Date: Tue, 4 Aug 2026 08:07:41 -0700 Subject: [PATCH 07/11] feat(apollo-wind): add DAP component patterns page --- .../components/ui/dap-components.stories.tsx | 1654 +++++++++++++++++ 1 file changed, 1654 insertions(+) create mode 100644 packages/apollo-wind/src/components/ui/dap-components.stories.tsx diff --git a/packages/apollo-wind/src/components/ui/dap-components.stories.tsx b/packages/apollo-wind/src/components/ui/dap-components.stories.tsx new file mode 100644 index 000000000..36c48b9f1 --- /dev/null +++ b/packages/apollo-wind/src/components/ui/dap-components.stories.tsx @@ -0,0 +1,1654 @@ +import MonacoEditor from '@monaco-editor/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { + AlignCenter, + AtSign, + Bold, + Braces, + ChevronDown, + CircleAlert, + CirclePlus, + Code2, + ExternalLink, + Folder, + FolderCog, + Info, + Italic, + Lightbulb, + List, + ListOrdered, + Maximize, + MoreVertical, + Paperclip, + Plus, + SlidersHorizontal, + Trash2, + Type, + Underline, + UserRound, + WandSparkles, + X, +} from 'lucide-react'; +import { useMemo, useState } from 'react'; +import { apolloFutureLightMonaco } from '../../editor-themes'; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from './accordion'; +import { Alert, AlertDescription } from './alert'; +import { Button } from './button'; +import { Checkbox } from './checkbox'; +import { Combobox } from './combobox'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from './dropdown-menu'; +import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from './input-group'; +import { Label } from './label'; +import { Popover, PopoverContent, PopoverTrigger } from './popover'; +import { RadioGroup, RadioGroupItem } from './radio-group'; +import { Search } from './search'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './select'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './table'; +import { Textarea } from './textarea'; +import { Toggle } from './toggle'; + +interface PropertyRow { + id: string; + label: string; + type?: string; + direction?: string; + kind: 'property' | 'group'; + level?: number; + selected: boolean; +} + +const initialRows: PropertyRow[] = [ + { + id: 'save-as-draft', + label: 'Save as draft', + type: 'Boolean', + direction: 'In', + kind: 'property', + selected: true, + }, + { id: 'message', label: 'message', kind: 'group', selected: false }, + { + id: 'subject', + label: 'Subject', + type: 'String', + direction: 'In', + kind: 'property', + level: 1, + selected: true, + }, + { id: 'body-group', label: 'body', kind: 'group', selected: false }, + { + id: 'body', + label: 'Body', + type: 'String', + direction: 'In', + kind: 'property', + level: 1, + selected: true, + }, + { + id: 'content-type', + label: 'Message body content type', + type: 'String', + direction: 'In', + kind: 'property', + level: 1, + selected: false, + }, + { + id: 'reply-to', + label: 'Reply to', + type: 'String', + direction: 'In', + kind: 'property', + selected: true, + }, + { + id: 'importance', + label: 'Importance', + type: 'String', + direction: 'In', + kind: 'property', + selected: true, + }, +]; + +function PropertyIcon({ kind }: Pick) { + if (kind === 'group') { + return