From 059c408c5198ae8e143ff8a81112a33fea90ff7e Mon Sep 17 00:00:00 2001 From: Rahul Sharma Date: Tue, 17 Feb 2026 17:58:44 -0800 Subject: [PATCH] feat(apollo-react): support skeleton loader in BaseNode --- .../components/BaseNode/BaseNode.stories.tsx | 70 +++++++++++++++++++ .../components/BaseNode/BaseNode.styles.ts | 67 ++++++++++++++---- .../canvas/components/BaseNode/BaseNode.tsx | 28 +++++--- .../components/BaseNode/BaseNode.types.ts | 7 ++ 4 files changed, 150 insertions(+), 22 deletions(-) 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 a0cddcc31..2779ef6a0 100644 --- a/packages/apollo-react/src/canvas/components/BaseNode/BaseNode.stories.tsx +++ b/packages/apollo-react/src/canvas/components/BaseNode/BaseNode.stories.tsx @@ -198,6 +198,24 @@ const sampleManifest: { nodes: NodeManifest[]; categories: CategoryManifest[] } }, ], }, + { + nodeType: 'uipath.control-flow.terminate', + version: '1.0.0', + category: 'control', + tags: ['control', 'terminate'], + sortOrder: 6, + display: { + label: 'Terminate', + icon: 'git-branch', + shape: 'circle', + }, + handleConfiguration: [ + { + position: 'right', + handles: [{ id: 'out', type: 'source', handleType: 'output', label: 'Output' }], + }, + ], + }, ], }; @@ -628,6 +646,53 @@ function DynamicHandlesStory() { ); } +/** + * Creates nodes demonstrating loading/skeleton states for all shapes. + * Uses `data.loading: true` to enable the skeleton state. + */ +function createLoadingGrid(): Node[] { + const shapes = [ + { type: 'uipath.blank-node', shape: 'square' as const, label: 'Square' }, + { type: 'uipath.control-flow.terminate', shape: 'circle' as const, label: 'Circle' }, + { type: 'uipath.agent', shape: 'rectangle' as const, label: 'Rectangle' }, + ]; + + return shapes.map((config, index) => + createNode({ + id: `loading-${config.shape}`, + type: config.type, + position: { x: 96 + index * 192, y: 96 }, + data: { + nodeType: config.type, + version: '1.0.0', + loading: true, // Enable skeleton loading state + display: { + label: config.label, + subLabel: 'Loading...', + shape: config.shape, + }, + }, + }) + ); +} + +function LoadingStory() { + const initialNodes = useMemo(() => createLoadingGrid(), []); + const { canvasProps } = useCanvasStory({ initialNodes }); + + return ( + + + + + + + ); +} + // ============================================================================ // Exported Stories // ============================================================================ @@ -646,3 +711,8 @@ export const DynamicHandles: Story = { name: 'Dynamic Handles', render: () => , }; + +export const Loading: Story = { + name: 'Loading', + render: () => , +}; diff --git a/packages/apollo-react/src/canvas/components/BaseNode/BaseNode.styles.ts b/packages/apollo-react/src/canvas/components/BaseNode/BaseNode.styles.ts index 3f724c36a..5f2238053 100644 --- a/packages/apollo-react/src/canvas/components/BaseNode/BaseNode.styles.ts +++ b/packages/apollo-react/src/canvas/components/BaseNode/BaseNode.styles.ts @@ -1,5 +1,6 @@ import { css, keyframes } from '@emotion/react'; import styled from '@emotion/styled'; +import { ApSkeleton } from '../../../material/components/ap-skeleton'; import type { NodeShape } from '../../schema'; import type { FooterVariant } from './BaseNode.types'; @@ -10,6 +11,31 @@ const NODE_HEIGHT_FOOTER_BUTTON = GRID_UNIT * 9; // 144px const NODE_HEIGHT_FOOTER_SINGLE = GRID_UNIT * 10; // 160px const NODE_HEIGHT_FOOTER_DOUBLE = GRID_UNIT * 11; // 176px +/** + * Computes the icon wrapper dimensions for a given shape and node size. + * Shared between BaseIconWrapper and BaseSkeletonIcon for consistency. + */ +const getIconDimensions = ( + shape: NodeShape | undefined, + nodeHeight: number | undefined, + nodeWidth: number | undefined +) => { + const height = nodeHeight ?? 96; + const width = nodeWidth ?? 96; + + // Width: Use default 3/4 scaling, derived from the height for rectangle, and use width for other shapes + const widthDimension = height !== width && shape === 'rectangle' ? height : width; + const widthScaleFactor = widthDimension / 96; + const iconWidth = 72 * widthScaleFactor; + + // Height: Use 7/8 scaling for a vertical rectangle (expandable node), and use default 3/4 scaling for other shapes + const heightScaleFactor = height / 96; + const isExpandable = height !== width && shape !== 'rectangle'; + const iconHeight = isExpandable ? 84 * heightScaleFactor : 72 * heightScaleFactor; + + return { iconWidth, iconHeight }; +}; + const pulseAnimation = (cssVar: string) => keyframes` 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(${cssVar}) 20%, transparent); @@ -213,19 +239,13 @@ export const BaseIconWrapper = styled.div<{ height?: number; width?: number; }>` - width: ${({ height, width, shape }) => { - // Use default 3/4 scaling, derived from the height for rectangle, and use width for other shapes - const dimension = height !== width && shape === 'rectangle' ? height : width; - const scaleFactor = dimension ? dimension / 96 : 1; - return `${72 * scaleFactor}px`; - }}; - height: ${({ height, width, shape }) => { - // Use 7/8 scaling for a vertical rectangle, and use default 3/4 scaling for other shapes - const scaleFactor = height ? height / 96 : 1; - return height !== width && shape !== 'rectangle' // True for only a expandable node - ? `${84 * scaleFactor}px` - : `${72 * scaleFactor}px`; - }}; + ${({ height, width, shape }) => { + const { iconWidth, iconHeight } = getIconDimensions(shape, height, width); + return css` + width: ${iconWidth}px; + height: ${iconHeight}px; + `; + }} display: flex; align-items: center; justify-content: center; @@ -420,3 +440,24 @@ export const BaseBadgeSlot = styled.div<{ } }} `; + +/** + * Skeleton icon used in loading state. Uses the same dimension calculations as BaseIconWrapper. + */ +export const BaseSkeletonIcon = styled(ApSkeleton)<{ + shape?: NodeShape; + nodeHeight?: number; + nodeWidth?: number; +}>` + flex-grow: 0; + ${({ shape, nodeHeight, nodeWidth }) => { + const { iconWidth, iconHeight } = getIconDimensions(shape, nodeHeight, nodeWidth); + const isCircle = shape === 'circle'; + + return css` + width: ${iconWidth}px; + height: ${iconHeight}px; + border-radius: ${isCircle ? '50%' : '8px'}; + `; + }} +`; diff --git a/packages/apollo-react/src/canvas/components/BaseNode/BaseNode.tsx b/packages/apollo-react/src/canvas/components/BaseNode/BaseNode.tsx index 6748f61c9..e1cd33ba5 100644 --- a/packages/apollo-react/src/canvas/components/BaseNode/BaseNode.tsx +++ b/packages/apollo-react/src/canvas/components/BaseNode/BaseNode.tsx @@ -29,6 +29,7 @@ import { BaseContainer, BaseHeader, BaseIconWrapper, + BaseSkeletonIcon, BaseSubHeader, BaseTextContainer, } from './BaseNode.styles'; @@ -483,16 +484,25 @@ const BaseNodeComponent = (props: NodeProps>) => { hasFooter={!!displayFooter} footerVariant={displayFooterVariant as FooterVariant} > - {Icon && ( - - {Icon} - + nodeHeight={displayFooter ? undefined : height} + nodeWidth={displayFooter ? undefined : (width ?? height)} + /> + ) : ( + Icon && ( + + {Icon} + + ) )} {adornments?.topLeft && ( diff --git a/packages/apollo-react/src/canvas/components/BaseNode/BaseNode.types.ts b/packages/apollo-react/src/canvas/components/BaseNode/BaseNode.types.ts index eaa613a86..493effdc2 100644 --- a/packages/apollo-react/src/canvas/components/BaseNode/BaseNode.types.ts +++ b/packages/apollo-react/src/canvas/components/BaseNode/BaseNode.types.ts @@ -28,6 +28,13 @@ export interface BaseNodeData extends Record { * @default false */ isCollapsed?: boolean; + + /** + * When true, renders the node in a skeleton loading state. + * The icon area displays a shimmer animation. + * @default false + */ + loading?: boolean; } export interface NodeAdornments {