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 e3c314ac7..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 } from '@uipath/apollo-react/canvas/xyflow/react'; -import { memo, 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,6 +53,31 @@ export const CanvasEdge = memo(function CanvasEdge({ const onMouseEnter = useCallback(() => setIsHovered(true), []); const onMouseLeave = useCallback(() => setIsHovered(false), []); const pathRef = useRef(null); + const store = useStoreApi(); + + const onLabelClick = useCallback( + (event: MouseEvent) => { + event.stopPropagation(); + const { + addSelectedEdges, + edgeLookup, + elementsSelectable, + multiSelectionActive, + unselectNodesAndEdges, + } = store.getState(); + const edge = edgeLookup.get(id); + + if (!edge || !(edge.selectable ?? elementsSelectable)) return; + + store.setState({ nodesSelectionActive: false }); + if (edge.selected && multiSelectionActive) { + unselectNodesAndEdges({ nodes: [], edges: [edge] }); + } else { + addSelectedEdges([id]); + } + }, + [id, store] + ); const routing = data?.routing ?? 'waypoint'; const storedWaypoints = data?.waypoints ?? EMPTY_WAYPOINTS; @@ -150,6 +175,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 new file mode 100644 index 000000000..2b0af3f28 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Edges/EdgeLabel.stories.tsx @@ -0,0 +1,564 @@ +/** + * 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 } from '@uipath/apollo-react/canvas/xyflow/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. 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.", + }, + }, + }, + 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 }], + })), + ], + }); +} + +/** + * 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 and label-border color via `resolveEdgeColor`. + */ +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 both the stroke and its associated label border.', + }, + }, + }, +}; + +/** + * 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. 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.", + }, + }, + }, +}; + +/** + * 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( + () => [ + 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 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.', + }, + }, + }, +}; + +/** + * 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. 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( + () => [ + 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 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/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/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 86d45aa41..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,37 +1,63 @@ -import { render } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; -import { EdgeLabel } from './EdgeLabel'; +import { fireEvent, render } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +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. +// 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)' - ); + 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(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 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', () => { + 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 10bc89106..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 @@ -1,39 +1,60 @@ +import { EdgeLabelRenderer } from '@uipath/apollo-react/canvas/xyflow/react'; +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; }; -export function EdgeLabel({ x, y, text, selected }: EdgeLabelProps) { +// 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-[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 + * 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, + borderColor = EDGE_LABEL_DEFAULT_BORDER_COLOR, + 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/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/components/StageNode/StageEdge.tsx b/packages/apollo-react/src/canvas/components/StageNode/StageEdge.tsx index 3e198ab1e..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); - background: var(--canvas-background); + 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); - border-color: var(--canvas-border-hover); + background: var(--canvas-background-hover, var(--color-background-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 1b4ba2eb4..b9aeb5a9f 100644 --- a/packages/apollo-react/src/canvas/styles/reactflow-reset.css +++ b/packages/apollo-react/src/canvas/styles/reactflow-reset.css @@ -49,6 +49,25 @@ 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__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 !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; 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..569e0ca11 --- /dev/null +++ b/packages/apollo-wind/src/components/ui/dap-components.stories.tsx @@ -0,0 +1,1651 @@ +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