From ca1252ff8378974996b4612d5348b6932e3e8e65 Mon Sep 17 00:00:00 2001 From: BenGSchulz Date: Thu, 20 Aug 2026 15:18:25 -0700 Subject: [PATCH] fix(apollo-react): hand the mocked-output adornment to consumers [MST-13618] The bottom-right badge rendered an empty dashed square for mocked output, which read as a missing icon rather than a state. Drop the library-side indicator and the `isOutputPinned` read so the slot is owned by consumers, who supply `adornments.bottomRight` through `BaseNodeOverrideConfig` and can match the icon already used in the output config. `isOutputPinned` stays on `NodeExecutionStateWithDebug` as deprecated and inert: removing it breaks consumers that annotate the type or read the field, so it goes in the next major. Also export `CanvasTooltip` from the canvas barrel, which consumers need to build their own adornments, and demo both variants in the Adornments story (`file-braces-corner` for static, `file-sparkles-corner` for generated). --- .../components/BaseNode/BaseNode.stories.tsx | 95 +++++++++++++++---- .../src/canvas/components/index.ts | 1 + .../canvas/icons/FileSparklesCornerIcon.tsx | 51 ++++++++++ .../apollo-react/src/canvas/icons/index.ts | 1 + .../src/canvas/types/execution.ts | 6 ++ .../canvas/utils/adornment-resolver.test.tsx | 10 -- .../src/canvas/utils/adornment-resolver.tsx | 13 +-- .../src/canvas/utils/icon-registry.test.tsx | 17 ++++ .../src/canvas/utils/icon-registry.tsx | 3 + 9 files changed, 159 insertions(+), 38 deletions(-) create mode 100644 packages/apollo-react/src/canvas/icons/FileSparklesCornerIcon.tsx diff --git a/packages/apollo-react/src/canvas/components/BaseNode/BaseNode.stories.tsx b/packages/apollo-react/src/canvas/components/BaseNode/BaseNode.stories.tsx index 337fe366e..a602cb594 100644 --- a/packages/apollo-react/src/canvas/components/BaseNode/BaseNode.stories.tsx +++ b/packages/apollo-react/src/canvas/components/BaseNode/BaseNode.stories.tsx @@ -6,7 +6,7 @@ import type { Meta, StoryObj } from '@storybook/react'; import { Column } from '@uipath/apollo-react/canvas/layouts'; -import type { Edge, Node } from '@uipath/apollo-react/canvas/xyflow/react'; +import type { Edge, Node, NodeProps } from '@uipath/apollo-react/canvas/xyflow/react'; import { Panel } from '@uipath/apollo-react/canvas/xyflow/react'; import { Button, cn, Input, Label, Slider, Switch } from '@uipath/apollo-wind'; import { useCallback, useEffect, useMemo, useState } from 'react'; @@ -34,9 +34,14 @@ import type { ValidationErrorSeverity } from '../../types/validation'; import { CanvasIcon } from '../../utils/icon-registry'; import { BaseCanvas } from '../BaseCanvas'; import { CanvasPositionControls } from '../CanvasPositionControls'; +import { CanvasTooltip } from '../CanvasTooltip'; import { NodeInspector } from '../NodeInspector'; -import type { BaseNodeData } from './BaseNode.types'; -import { BaseNodeOverrideConfigProvider } from './BaseNodeConfigContext'; +import { BaseNode } from './BaseNode'; +import type { BaseNodeData, NodeAdornments } from './BaseNode.types'; +import { + type BaseNodeOverrideConfig, + BaseNodeOverrideConfigProvider, +} from './BaseNodeConfigContext'; // ============================================================================ // Meta Configuration @@ -1496,11 +1501,74 @@ const ADORNMENT_ROWS = [ { key: 'status-inprogress', label: 'Status: InProgress (top-right)' }, { key: 'status-failed', label: 'Status: Failed (top-right)' }, { key: 'start-point', label: 'Start Point (bottom-left)' }, - { key: 'square-dashed', label: 'Square Dashed (bottom-right)' }, + { key: 'mock-static', label: 'Custom: static mock (bottom-right)' }, + { key: 'mock-generated', label: 'Custom: generated mock (bottom-right)' }, { key: 'all', label: 'All Adornments' }, { key: 'multi-exec', label: 'Multi-execution (count: 5)' }, ] as const; +/** + * Both mocked-output icons come from the icon registry and share one truncated + * file outline, so static and generated read as siblings. See + * `FileSparklesCornerIcon` for how the sparkle is composed. + */ +const MOCK_OUTPUT_VARIANTS = { + static: { + generated: false, + icon: 'file-braces-corner', + tooltip: 'Node output is mocked', + }, + generated: { + generated: true, + icon: 'file-sparkles-corner', + tooltip: 'Node output is generated by LLM', + }, +} as const; + +function MockOutputIndicator({ variant }: { variant: keyof typeof MOCK_OUTPUT_VARIANTS }) { + const { icon, tooltip } = MOCK_OUTPUT_VARIANTS[variant]; + + return ( + + + + + + ); +} + +/** + * Bottom-right adornments per row, supplied through the node override config. + * The execution-status API no longer carries a mocked-output flag, so consumers + * own this slot and decide what it renders. + */ +const ADORNMENT_OVERRIDES: Record = { + 'mock-static': { bottomRight: }, + 'mock-generated': { bottomRight: }, +}; + +/** Adornment node ids are `adorn--`, and row keys may contain dashes. */ +function getAdornmentRowKey(nodeId: string): string { + return nodeId.split('-').slice(1, -1).join('-'); +} + +/** + * Story-only node component that wires each row's custom adornments in via + * `BaseNodeOverrideConfigProvider`, the same path product code uses. + */ +function AdornmentOverrideNode(props: NodeProps>) { + const overrideConfig = useMemo( + () => ({ adornments: ADORNMENT_OVERRIDES[getAdornmentRowKey(props.id)] }), + [props.id] + ); + + return ( + + + + ); +} + /** * Creates nodes demonstrating all adornment types across shapes. */ @@ -1549,14 +1617,14 @@ function getAdornmentExecutionState(key: string) { return { status: 'Failed' as const }; case 'start-point': return { status: 'None' as const, isExecutionStartPoint: true }; - case 'square-dashed': - return { status: 'None' as const, isOutputPinned: true }; + case 'mock-static': + case 'mock-generated': + return { status: 'None' as const }; case 'all': return { status: 'Completed' as const, debug: true, isExecutionStartPoint: true, - isOutputPinned: true, }; case 'multi-exec': return { status: 'Completed' as const, count: 5 }; @@ -1567,7 +1635,7 @@ function getAdornmentExecutionState(key: string) { function AdornmentsStory() { const initialNodes = useMemo(() => createAdornmentGrid(), []); - const { canvasProps } = useCanvasStory({ initialNodes }); + const { canvasProps } = useCanvasStory({ initialNodes, nodeComponent: AdornmentOverrideNode }); return ( @@ -1576,7 +1644,7 @@ function AdornmentsStory() { ); @@ -1587,13 +1655,8 @@ export const Adornments: Story = { decorators: [ withCanvasProviders({ executionState: { - getNodeExecutionState: (nodeId: string) => { - // Extract the adornment key from node IDs like "adorn-breakpoint-circle" - const parts = nodeId.split('-'); - // Rejoin all parts between first and last to get the key (handles keys with dashes) - const key = parts.slice(1, -1).join('-'); - return getAdornmentExecutionState(key); - }, + getNodeExecutionState: (nodeId: string) => + getAdornmentExecutionState(getAdornmentRowKey(nodeId)), getEdgeExecutionState: () => undefined, }, validationState: { diff --git a/packages/apollo-react/src/canvas/components/index.ts b/packages/apollo-react/src/canvas/components/index.ts index 211e1498e..27e8880aa 100644 --- a/packages/apollo-react/src/canvas/components/index.ts +++ b/packages/apollo-react/src/canvas/components/index.ts @@ -9,6 +9,7 @@ export * from './CanvasLeftSidebar'; export * from './CanvasModeToolbar'; export * from './CanvasPositionControls'; export * from './CanvasTakeoverModal'; +export * from './CanvasTooltip'; export * from './CanvasZoomControls'; export * from './CaseFlow'; export * from './CodedAgent'; diff --git a/packages/apollo-react/src/canvas/icons/FileSparklesCornerIcon.tsx b/packages/apollo-react/src/canvas/icons/FileSparklesCornerIcon.tsx new file mode 100644 index 000000000..1f060dd06 --- /dev/null +++ b/packages/apollo-react/src/canvas/icons/FileSparklesCornerIcon.tsx @@ -0,0 +1,51 @@ +/** + * File with a sparkle in an opened corner, for nodes whose output is mocked by + * generation. Sibling of Lucide's `file-braces-corner`, which marks a static + * mock: both share the same truncated file outline so the two states read as one + * family, and the outline stops short of the glyph so no stroke passes behind it. + * + * The glyph follows Lucide's `sparkles`: a star with a cross up-right and a dot + * down-left, seated on a 45 degree axis at offsets of +/-8 scaled by the star's + * 0.5, so +/-4 from the star's centre at (8, 17). Every mark stays inside the + * 2..22 box Lucide keeps its paths in, and the cluster clears the outline's cut + * end at x=14. + * + * Every mark runs lighter than the outline's 2: the star's interior counter + * closes up and fills in solid at that weight, so a concave glyph needs a + * thinner stroke than a plain outline does to read at the same size. + */ +export const FileSparklesCornerIcon = ({ + w = 24, + h = 24, + color = 'currentColor', +}: { + w?: number | string; + h?: number | string; + color?: string; +}) => ( + + {/* Outline truncated at the bottom-left, identical to `file-braces-corner`. */} + + + {/* Lucide `sparkle` at 0.5; stroke is 1.4/0.5 so it renders at 1.4. */} + + + + + + + + + +); diff --git a/packages/apollo-react/src/canvas/icons/index.ts b/packages/apollo-react/src/canvas/icons/index.ts index 3714926f3..b46ad0d44 100644 --- a/packages/apollo-react/src/canvas/icons/index.ts +++ b/packages/apollo-react/src/canvas/icons/index.ts @@ -25,6 +25,7 @@ export { DecisionIcon } from './DecisionIcon'; export { EarlyExitStatusIcon } from './EarlyExitStatusIcon'; export { EntryConditionIcon } from './EntryConditionIcon'; export { ExitConditionIcon } from './ExitConditionIcon'; +export { FileSparklesCornerIcon } from './FileSparklesCornerIcon'; export { FlaskRunIcon } from './FlaskRunIcon'; export { FlowProject } from './FlowProject'; export { FunctionProject } from './FunctionProject'; diff --git a/packages/apollo-react/src/canvas/types/execution.ts b/packages/apollo-react/src/canvas/types/execution.ts index 2ab49dc0b..5d2c607cb 100644 --- a/packages/apollo-react/src/canvas/types/execution.ts +++ b/packages/apollo-react/src/canvas/types/execution.ts @@ -22,6 +22,12 @@ export interface NodeExecutionStateWithDebug { count?: number; debug?: boolean; isExecutionStartPoint?: boolean; + /** + * @deprecated No longer read. The bottom-right adornment slot is owned by the + * consumer: supply `adornments.bottomRight` through `BaseNodeOverrideConfig` + * instead. Setting this has no effect and the field will be removed in the + * next major. + */ isOutputPinned?: boolean; } diff --git a/packages/apollo-react/src/canvas/utils/adornment-resolver.test.tsx b/packages/apollo-react/src/canvas/utils/adornment-resolver.test.tsx index aac25b752..624d897a8 100644 --- a/packages/apollo-react/src/canvas/utils/adornment-resolver.test.tsx +++ b/packages/apollo-react/src/canvas/utils/adornment-resolver.test.tsx @@ -78,16 +78,6 @@ describe('resolveAdornments', () => { expect(result.bottomLeft).toBeTruthy(); }); - // ── Square dashed (bottomRight) ───────────────────────── - - it('shows square dashed indicator when isOutputPinned', () => { - const result = resolveAdornments({ - ...baseContext, - executionState: { status: 'None', isOutputPinned: true }, - }); - expect(result.bottomRight).toBeTruthy(); - }); - // ── Validation errors (topRight) ──────────────────────── it('shows validation indicator for ERROR severity', () => { diff --git a/packages/apollo-react/src/canvas/utils/adornment-resolver.tsx b/packages/apollo-react/src/canvas/utils/adornment-resolver.tsx index 45ab4b908..516d439af 100644 --- a/packages/apollo-react/src/canvas/utils/adornment-resolver.tsx +++ b/packages/apollo-react/src/canvas/utils/adornment-resolver.tsx @@ -57,16 +57,6 @@ function ExecutionStatusIndicatorInternal({ status, count }: { status?: string; ); } -function SquareDashedIndicator() { - return ( - - - - - - ); -} - export const ExecutionStatusIndicator = memo(ExecutionStatusIndicatorInternal); export function ValidationErrorIndicator({ message }: { message?: string }) { @@ -104,7 +94,6 @@ const getDefaultAdornments = ( const hasBreakpoint = typeof executionState === 'object' && executionState?.debug; const isExecutionStartPoint = typeof executionState === 'object' && executionState?.isExecutionStartPoint; - const isOutputPinned = typeof executionState === 'object' && executionState?.isOutputPinned; const hasValidationError = context.validationState?.validationStatus === ValidationErrorSeverity.ERROR || @@ -132,7 +121,7 @@ const getDefaultAdornments = ( topLeft: hasBreakpoint ? : undefined, topRight: getTopRight(), bottomLeft: isExecutionStartPoint ? : undefined, - bottomRight: isOutputPinned ? : undefined, + bottomRight: undefined, }; }; diff --git a/packages/apollo-react/src/canvas/utils/icon-registry.test.tsx b/packages/apollo-react/src/canvas/utils/icon-registry.test.tsx index 1263f99ba..2211c806b 100644 --- a/packages/apollo-react/src/canvas/utils/icon-registry.test.tsx +++ b/packages/apollo-react/src/canvas/utils/icon-registry.test.tsx @@ -43,6 +43,23 @@ describe('getIcon', () => { expect(container.querySelector('#case-management-project')).toBeInTheDocument(); }); + it('returns the FileSparklesCornerIcon for the registered file-sparkles-corner id', () => { + const Icon = getIcon('file-sparkles-corner'); + const { container } = render(); + // There is no Lucide `FileSparklesCorner`, so a registry miss would degrade to Box. + const svg = container.querySelector('svg.file-sparkles-corner-icon'); + expect(svg).toBeInTheDocument(); + // Truncated outline plus the fold, the scaled star, and the cross's two arms. + expect(svg?.querySelectorAll('path')).toHaveLength(5); + expect(svg?.querySelector('circle')).toBeInTheDocument(); + }); + + it('applies the color prop to the file-sparkles-corner stroke', () => { + const Icon = getIcon('file-sparkles-corner'); + const { container } = render(); + expect(container.querySelector('svg')).toHaveAttribute('stroke', 'rgb(1, 2, 3)'); + }); + it('returns the LayersArrowUpRight icon for the registered layers-arrow-up-right id', () => { const Icon = getIcon('layers-arrow-up-right'); const { container } = render(); diff --git a/packages/apollo-react/src/canvas/utils/icon-registry.tsx b/packages/apollo-react/src/canvas/utils/icon-registry.tsx index 3e5f25ac9..dafc008db 100644 --- a/packages/apollo-react/src/canvas/utils/icon-registry.tsx +++ b/packages/apollo-react/src/canvas/utils/icon-registry.tsx @@ -45,6 +45,9 @@ const iconRegistry: Record = { mcp: ({ w, h }) => , a2a: ({ w, h }) => , context: ({ w, h }) => , + 'file-sparkles-corner': ({ w, h, color }) => ( + + ), }; /**