diff --git a/apps/storybook/.storybook/preview.tsx b/apps/storybook/.storybook/preview.tsx index 0d0cef717..a2279a382 100644 --- a/apps/storybook/.storybook/preview.tsx +++ b/apps/storybook/.storybook/preview.tsx @@ -183,6 +183,7 @@ const preview: Preview = { 'Layout', 'Nodes', 'Panels', + ['Node Property Trigger', 'Node Property Panel', 'Node Flyout Panel', '*'], 'Primitives', '*', ], diff --git a/packages/apollo-react/src/canvas/components/NodePropertyPanel/NodePropertyPanel.stories.tsx b/packages/apollo-react/src/canvas/components/NodePropertyPanel/NodePropertyPanel.stories.tsx new file mode 100644 index 000000000..341aa96b0 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/NodePropertyPanel/NodePropertyPanel.stories.tsx @@ -0,0 +1,471 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import type { FormSchema } from '@uipath/apollo-wind'; +import { Play } from 'lucide-react'; +import { NodeRegistryProvider } from '../../core'; +import type { NodeManifest } from '../../schema'; +import { allCategoryManifests } from '../../storybook-utils'; +import { NodePropertyPanel } from './NodePropertyPanel'; + +// ============================================================================ +// Layout helpers +// ============================================================================ + +const CanvasBackground = ({ children }: { children: React.ReactNode }) => ( +
+ {children} +
+); + +const PanelFrame = ({ children }: { children: React.ReactNode }) => ( +
+ {children} +
+); + +function RunButton() { + return ( + + ); +} + +// ============================================================================ +// Meta +// ============================================================================ + +const meta: Meta = { + title: 'Components/Panels/Node Property Panel', + component: NodePropertyPanel, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: ` +The **NodePropertyPanel** is a docked properties panel that renders a node's +\`form: FormSchema\` directly from its manifest in the \`NodeRegistryProvider\`. + +## Usage + +\`\`\`tsx +// 1. Define the form schema in the node manifest (tabs = steps): +const manifest: NodeManifest = { + nodeType: 'uipath.http-request', + form: { + id: 'http-request', + title: 'HTTP Request', + steps: [ + { id: 'parameters', title: 'Parameters', sections: [{ id: 'main', fields: [...] }] }, + { id: 'error-handling', title: 'Error handling', sections: [...] }, + ], + }, + // ... +}; + +// 2. Render the panel — no manifest prop needed: + + saveNodeConfig(nodeId, data)} + onClose={() => setSelectedNode(null)} + /> + +\`\`\` + +Tabs are defined as \`steps\` in the FormSchema — the component never hardcodes +tab names or field types. Single-page schemas (\`sections\`) are rendered as a +flat scrollable form. + `, + }, + }, + }, + decorators: [ + (Story) => ( + + + + ), + ], + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +// ============================================================================ +// Shared FormSchema definitions +// Tabs = steps; sections within each step hold the fields. +// ============================================================================ + +const httpRequestForm: FormSchema = { + id: 'http-request', + title: 'HTTP Request', + mode: 'onChange', + steps: [ + { + id: 'parameters', + title: 'Parameters', + sections: [ + { + id: 'main', + fields: [ + { + type: 'text', + name: 'endpoint', + label: 'Endpoint', + placeholder: 'https://…', + description: 'The URL of the HTTP endpoint to call.', + defaultValue: 'https://finance.internal/api/invoices', + }, + { + type: 'select', + name: 'method', + label: 'Method', + defaultValue: 'GET', + dataSource: { + type: 'static', + options: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].map((v) => ({ + label: v, + value: v, + })), + }, + }, + { + type: 'select', + name: 'auth_type', + label: 'Auth type', + defaultValue: 'bearer', + dataSource: { + type: 'static', + options: ['none', 'bearer', 'api-key', 'oauth'].map((v) => ({ + label: v, + value: v, + })), + }, + }, + { + type: 'number', + name: 'timeout_ms', + label: 'Timeout (ms)', + placeholder: '5000', + description: 'Request timeout in milliseconds.', + defaultValue: 10000, + }, + { + type: 'switch', + name: 'retry_on_failure', + label: 'Retry on failure', + defaultValue: true, + }, + ], + }, + ], + }, + { + id: 'error-handling', + title: 'Error handling', + sections: [ + { + id: 'errors', + fields: [ + { + type: 'select', + name: 'on_error', + label: 'On error', + defaultValue: 'throw', + dataSource: { + type: 'static', + options: [ + { label: 'Throw exception', value: 'throw' }, + { label: 'Return empty', value: 'empty' }, + { label: 'Retry', value: 'retry' }, + ], + }, + }, + { + type: 'number', + name: 'max_retries', + label: 'Max retries', + defaultValue: 3, + }, + ], + }, + ], + }, + { + id: 'advanced', + title: 'Advanced', + sections: [ + { + id: 'adv', + fields: [ + { + type: 'switch', + name: 'follow_redirects', + label: 'Follow redirects', + defaultValue: true, + }, + { + type: 'switch', + name: 'verify_ssl', + label: 'Verify SSL', + defaultValue: true, + }, + ], + }, + ], + }, + ], +}; + +const humanTaskForm: FormSchema = { + id: 'human-task', + title: 'Human Task', + mode: 'onChange', + steps: [ + { + id: 'parameters', + title: 'Parameters', + sections: [ + { + id: 'main', + fields: [ + { + type: 'text', + name: 'assignee', + label: 'Assignee', + placeholder: 'user@example.com', + defaultValue: 'finance.manager@acme.com', + validation: { required: true, email: true }, + }, + { + type: 'number', + name: 'timeout_hours', + label: 'Timeout (hours)', + placeholder: '24', + description: 'Hours before task auto-escalates.', + defaultValue: 48, + }, + { + type: 'text', + name: 'escalation_email', + label: 'Escalation email', + placeholder: 'escalation@example.com', + defaultValue: 'director@acme.com', + }, + { + type: 'switch', + name: 'require_comment', + label: 'Require comment', + defaultValue: true, + }, + ], + }, + ], + }, + { + id: 'error-handling', + title: 'Error handling', + sections: [{ id: 'errors', fields: [] }], + }, + { + id: 'advanced', + title: 'Advanced', + sections: [{ id: 'adv', fields: [] }], + }, + ], +}; + +const agentForm: FormSchema = { + id: 'ai-agent', + title: 'AI Agent', + mode: 'onChange', + steps: [ + { + id: 'parameters', + title: 'Parameters', + sections: [ + { + id: 'main', + fields: [ + { + type: 'text', + name: 'model', + label: 'Model', + description: 'AI model identifier.', + defaultValue: 'claude-sonnet-4-5', + }, + { + type: 'text', + name: 'policy_version', + label: 'Policy version', + defaultValue: 'v2.3', + }, + { + type: 'number', + name: 'approval_threshold', + label: 'Approval threshold ($)', + description: 'Invoice amount above which human approval is required.', + defaultValue: 5000, + }, + { + type: 'switch', + name: 'strict_mode', + label: 'Strict mode', + defaultValue: true, + }, + ], + }, + ], + }, + { + id: 'error-handling', + title: 'Error handling', + sections: [{ id: 'errors', fields: [] }], + }, + { + id: 'advanced', + title: 'Advanced', + sections: [{ id: 'adv', fields: [] }], + }, + ], +}; + +// ============================================================================ +// Story node manifests — minimal wrappers that provide the form schema +// ============================================================================ + +function makeManifest( + nodeType: string, + label: string, + category: string, + form: FormSchema +): NodeManifest { + return { + nodeType, + version: '1.0.0', + category, + tags: [], + sortOrder: 0, + display: { label, shape: 'square' }, + handleConfiguration: [], + form, + }; +} + +function withRegistry(manifest: NodeManifest) { + return (Story: React.ComponentType) => ( + + + + ); +} + +// ============================================================================ +// Stories +// ============================================================================ + +export const HttpRequest: Story = { + decorators: [ + withRegistry( + makeManifest('uipath.http-request', 'HTTP Request', 'integration', httpRequestForm) + ), + ], + render: () => ( + + } + onSubmit={(data) => console.log('submit', data)} + onClose={() => console.log('close')} + /> + + ), +}; + +export const HumanTask: Story = { + decorators: [ + withRegistry(makeManifest('uipath.human-task', 'Human Task', 'collaboration', humanTaskForm)), + ], + render: () => ( + + } + onSubmit={(data) => console.log('submit', data)} + onClose={() => console.log('close')} + /> + + ), +}; + +export const AiAgent: Story = { + decorators: [withRegistry(makeManifest('uipath.ai-agent', 'AI Agent', 'ai', agentForm))], + render: () => ( + + } + onSubmit={(data) => console.log('submit', data)} + onClose={() => console.log('close')} + /> + + ), +}; + +export const NoParameters: Story = { + decorators: [ + withRegistry( + makeManifest('uipath.log-event', 'Log Event', 'utility', { + id: 'log-event', + title: 'Log Event', + sections: [{ id: 'main', fields: [] }], + }) + ), + ], + render: () => ( + + console.log('close')} + /> + + ), +}; + +export const ContentOnly: Story = { + decorators: [ + withRegistry( + makeManifest('uipath.http-request', 'HTTP Request', 'integration', httpRequestForm) + ), + ], + render: () => ( + + console.log('submit', data)} + /> + + ), +}; diff --git a/packages/apollo-react/src/canvas/components/NodePropertyPanel/NodePropertyPanel.test.tsx b/packages/apollo-react/src/canvas/components/NodePropertyPanel/NodePropertyPanel.test.tsx new file mode 100644 index 000000000..d57fdbd60 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/NodePropertyPanel/NodePropertyPanel.test.tsx @@ -0,0 +1,147 @@ +import type { FormSchema } from '@uipath/apollo-wind'; +import { describe, expect, it, vi } from 'vitest'; +import { NodeRegistryProvider } from '../../core/NodeRegistryProvider'; +import type { NodeManifest } from '../../schema'; +import { render, screen } from '../../utils/testing'; +import { NodePropertyPanel } from './NodePropertyPanel'; + +// Keep MetadataForm lightweight — we only need to verify it is rendered and +// receives the correct schema id/title, not that the full form is interactive. +vi.mock('@uipath/apollo-wind', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + MetadataForm: ({ schema }: { schema: { id?: string; title?: string } }) => ( +
+ ), + }; +}); + +// ─── Test fixtures ──────────────────────────────────────────────────────────── + +const SINGLE_PAGE_FORM: FormSchema = { + id: 'http-request', + title: 'HTTP Request', + sections: [ + { + id: 'main', + fields: [{ id: 'url', type: 'text', name: 'url', label: 'URL' }], + }, + ], +}; + +const MULTI_STEP_FORM: FormSchema = { + id: 'agent', + title: 'Agent', + steps: [ + { + id: 'parameters', + title: 'Parameters', + sections: [ + { id: 's1', fields: [{ id: 'model', type: 'text', name: 'model', label: 'Model' }] }, + ], + }, + { + id: 'error-handling', + title: 'Error handling', + sections: [], + }, + ], +}; + +function makeManifest(nodeType: string, form?: FormSchema): NodeManifest { + return { + nodeType, + version: '1.0.0', + tags: [], + sortOrder: 0, + display: { label: 'Test Node', icon: nodeType, shape: 'rectangle' }, + handleConfiguration: [], + form, + } as NodeManifest; +} + +function renderInRegistry(ui: React.ReactElement, manifests: NodeManifest[] = []) { + return render({ui}); +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe('NodePropertyPanel', () => { + it('renders panel title bar when panelTitle is provided', () => { + renderInRegistry(, [ + makeManifest('uipath.test'), + ]); + expect(screen.getByText('Properties')).toBeInTheDocument(); + }); + + it('does not render title bar when panelTitle is omitted', () => { + renderInRegistry(, [makeManifest('uipath.test')]); + expect(screen.queryByLabelText('Close')).not.toBeInTheDocument(); + }); + + it('calls onClose when the close button is clicked', async () => { + const onClose = vi.fn(); + const { getByRole } = renderInRegistry( + , + [makeManifest('uipath.test')] + ); + getByRole('button', { name: 'Close' }).click(); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it('renders node label and category in the identity row', () => { + renderInRegistry( + , + [makeManifest('uipath.test')] + ); + expect(screen.getByText('My Activity')).toBeInTheDocument(); + expect(screen.getByText('HTTP')).toBeInTheDocument(); + }); + + it('renders empty-state when no form schema is defined', () => { + renderInRegistry(, [makeManifest('uipath.test')]); + expect(screen.getByText(/No form schema defined/i)).toBeInTheDocument(); + }); + + it('renders a single MetadataForm for a flat (non-multi-step) form schema', () => { + renderInRegistry(, [ + makeManifest('uipath.http', SINGLE_PAGE_FORM), + ]); + expect(screen.getByTestId('metadata-form')).toBeInTheDocument(); + expect(screen.getByTestId('metadata-form')).toHaveAttribute('data-schema-id', 'http-request'); + }); + + it('renders tab triggers for each step in a multi-step form', () => { + renderInRegistry(, [ + makeManifest('uipath.agent', MULTI_STEP_FORM), + ]); + expect(screen.getByRole('tab', { name: 'Parameters' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Error handling' })).toBeInTheDocument(); + }); + + it('resets the active tab when nodeType changes to a different node', () => { + const agentManifest = makeManifest('uipath.agent', MULTI_STEP_FORM); + const httpManifest = makeManifest('uipath.http', SINGLE_PAGE_FORM); + + const { rerender } = renderInRegistry(, [ + agentManifest, + httpManifest, + ]); + + // Switch to a different node type — the component should reset internal tab state + rerender( + + + + ); + + // The single-page form (no tabs) should now be rendered + expect(screen.getByTestId('metadata-form')).toBeInTheDocument(); + expect(screen.queryByRole('tab')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/NodePropertyPanel/NodePropertyPanel.tsx b/packages/apollo-react/src/canvas/components/NodePropertyPanel/NodePropertyPanel.tsx new file mode 100644 index 000000000..326817647 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/NodePropertyPanel/NodePropertyPanel.tsx @@ -0,0 +1,190 @@ +import type { FormSchema, FormStep } from '@uipath/apollo-wind'; +import { cn, MetadataForm, Tabs, TabsContent, TabsList, TabsTrigger } from '@uipath/apollo-wind'; +import { GripVertical, X } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { useNodeManifest } from '../../core/useNodeTypeRegistry'; +import type { NodePropertyPanelProps } from './NodePropertyPanel.types'; + +// ============================================================================ +// Helpers +// ============================================================================ + +function isMultiStep(form: FormSchema): form is FormSchema & { steps: FormStep[] } { + return 'steps' in form && Array.isArray((form as { steps?: unknown }).steps); +} + +// ============================================================================ +// NodePropertyPanel +// ============================================================================ + +/** + * NodePropertyPanel — docked properties panel driven by the node manifest. + * + * The panel reads `manifest.form` (a `FormSchema`) from the `NodeRegistryProvider` + * in the tree using `nodeType`. It renders: + * + * - **Multi-step FormSchema** (`steps`): each step becomes a tab. Tab labels and + * the fields within each tab are fully consumer-defined in the FormSchema — the + * component does not hardcode any tab names or field types. + * - **Single-page FormSchema** (`sections`): rendered as a flat scrollable form. + * - **No form schema**: renders an empty-state message. + * + * @example + * ```tsx + * // Register the manifest (with form schema) once at app startup: + * + * saveNodeConfig(nodeId, data)} + * onClose={() => setSelectedNode(null)} + * /> + * + * ``` + */ +export function NodePropertyPanel({ + panelTitle, + onClose, + nodeType, + nodeLabel, + nodeCategory, + nodeIcon, + action, + onSubmit, + className, +}: NodePropertyPanelProps) { + const manifest = useNodeManifest(nodeType); + const form = manifest?.form; + const subtitle = nodeCategory ?? manifest?.category ?? nodeType; + const hasNodeHeader = !!(nodeLabel || nodeCategory || nodeIcon || action); + + const steps = form && isMultiStep(form) ? form.steps : null; + const [activeStep, setActiveStep] = useState(''); + const currentStep = activeStep || steps?.[0]?.id || ''; + + // biome-ignore lint/correctness/useExhaustiveDependencies: nodeType is the trigger; setActiveStep is stable + useEffect(() => { + setActiveStep(''); + }, [nodeType]); + + return ( +
+ {/* ── Title bar ── */} + {panelTitle && ( +
+
+
+ +
+ {panelTitle} +
+ {onClose && ( + + )} +
+ )} + + {/* ── Node identity row ── */} + {hasNodeHeader && ( +
+
+ {nodeIcon &&
{nodeIcon}
} +
+ {nodeLabel && ( +

+ {nodeLabel} +

+ )} + {subtitle && ( +

+ {subtitle} +

+ )} +
+
+ {action &&
{action}
} +
+ )} + + {/* ── Form ── */} + {!form ? ( +

+ No form schema defined for this node type. +

+ ) : steps && steps.length === 0 ? ( +

+ No configuration fields defined for this node type. +

+ ) : steps ? ( + // Multi-step: steps become tabs — consumer defines the step titles and fields + + + {steps.map((step) => ( + + {step.title} + + ))} + + + {steps.map((step) => ( + + {/* Remap surface-raised → surface-overlay so inputs appear lighter than the panel; labels → foreground-muted (zinc-400) */} +
+ +
+
+ ))} +
+ ) : ( + // Single-page: sections rendered by MetadataForm directly +
+ {/* Remap surface-raised → surface-overlay so inputs appear lighter than the panel; labels → foreground-muted (zinc-400) */} +
+ +
+
+ )} +
+ ); +} diff --git a/packages/apollo-react/src/canvas/components/NodePropertyPanel/NodePropertyPanel.types.ts b/packages/apollo-react/src/canvas/components/NodePropertyPanel/NodePropertyPanel.types.ts new file mode 100644 index 000000000..3003e0dd1 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/NodePropertyPanel/NodePropertyPanel.types.ts @@ -0,0 +1,26 @@ +export interface NodePropertyPanelProps { + /** Title shown in the drag-handle header row (e.g. "Properties"). Omit to hide the header row. */ + panelTitle?: string; + /** Called when the X close button is clicked. Only rendered when `panelTitle` is set. */ + onClose?: () => void; + /** + * Node type identifier (e.g. "uipath.http-request"). The component reads the + * node's manifest — including its `form: FormSchema` — from the nearest + * `NodeRegistryProvider` in the tree. + */ + nodeType: string; + /** The node's display label shown in the node identity row. */ + nodeLabel?: string; + /** Category text shown below nodeLabel. Falls back to nodeType when omitted. */ + nodeCategory?: string; + /** Optional icon rendered left of the node name. */ + nodeIcon?: React.ReactNode; + /** Optional action slot rendered on the right of the node identity row (e.g. a Run button). */ + action?: React.ReactNode; + /** + * Called when the form is submitted. Receives the full form data object whose + * keys match the field `name` values in the FormSchema. + */ + onSubmit?: (data: unknown) => void | Promise; + className?: string; +} diff --git a/packages/apollo-react/src/canvas/components/NodePropertyPanel/index.ts b/packages/apollo-react/src/canvas/components/NodePropertyPanel/index.ts new file mode 100644 index 000000000..f06f630e9 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/NodePropertyPanel/index.ts @@ -0,0 +1,2 @@ +export { NodePropertyPanel } from './NodePropertyPanel'; +export type { NodePropertyPanelProps } from './NodePropertyPanel.types'; diff --git a/packages/apollo-react/src/canvas/components/ProbeCard/ProbeCard.test.tsx b/packages/apollo-react/src/canvas/components/ProbeCard/ProbeCard.test.tsx new file mode 100644 index 000000000..5c8bbb362 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/ProbeCard/ProbeCard.test.tsx @@ -0,0 +1,135 @@ +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '../../utils/testing'; +import { ProbeCard, type ProbeCardProps } from './ProbeCard'; + +function makeProps(overrides: Partial = {}): ProbeCardProps { + return { + watches: [], + onAddWatch: vi.fn(), + onUpdateWatch: vi.fn(), + onRemoveWatch: vi.fn(), + onDragStart: vi.fn(), + onDrag: vi.fn(), + onDragEnd: vi.fn(), + onResizeStart: vi.fn(), + onResize: vi.fn(), + onResizeEnd: vi.fn(), + onClose: vi.fn(), + ...overrides, + }; +} + +describe('ProbeCard', () => { + describe('keyboard shortcuts', () => { + it('calls onClose when Delete is pressed while the card has focus', () => { + const onClose = vi.fn(); + render(); + screen.getByRole('group', { name: 'Probe' }).focus(); + window.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Delete', bubbles: true, cancelable: true }) + ); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it('calls onClose when Backspace is pressed while the card has focus', () => { + const onClose = vi.fn(); + render(); + screen.getByRole('group', { name: 'Probe' }).focus(); + window.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true, cancelable: true }) + ); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it('does not call onClose when Delete is pressed while a watch input has focus', () => { + const onClose = vi.fn(); + render( + + ); + screen.getByRole('textbox', { name: 'Watch expression' }).focus(); + window.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Delete', bubbles: true, cancelable: true }) + ); + expect(onClose).not.toHaveBeenCalled(); + }); + }); + + describe('wheel event forwarding', () => { + it('does not stopPropagation on ctrl+wheel when onCanvasZoom is not provided', () => { + render(); + const card = screen.getByRole('group', { name: 'Probe' }); + const event = new WheelEvent('wheel', { deltaY: -100, bubbles: true, cancelable: true }); + Object.defineProperty(event, 'ctrlKey', { value: true, configurable: true }); + const spy = vi.spyOn(event, 'stopPropagation'); + card.dispatchEvent(event); + expect(spy).not.toHaveBeenCalled(); + }); + + it('calls onCanvasZoom and stops propagation when ctrl+wheel fires with a handler provided', () => { + const onCanvasZoom = vi.fn(); + render(); + const card = screen.getByRole('group', { name: 'Probe' }); + // happy-dom does not forward modifier keys via the WheelEvent init dict, + // so we set ctrlKey directly on the event object. + const event = new WheelEvent('wheel', { + deltaY: -100, + clientX: 200, + clientY: 150, + bubbles: true, + cancelable: true, + }); + Object.defineProperty(event, 'ctrlKey', { value: true, configurable: true }); + card.dispatchEvent(event); + // happy-dom drops clientX/clientY from the WheelEvent init dict, + // so only assert the fields the component actually uses for routing. + expect(onCanvasZoom).toHaveBeenCalledWith( + expect.objectContaining({ deltaY: -100, ctrlKey: true }) + ); + }); + + it('does not stopPropagation on plain wheel when onCanvasPan is not provided', () => { + render(); + const card = screen.getByRole('group', { name: 'Probe' }); + const event = new WheelEvent('wheel', { deltaY: 10, bubbles: true, cancelable: true }); + const spy = vi.spyOn(event, 'stopPropagation'); + card.dispatchEvent(event); + expect(spy).not.toHaveBeenCalled(); + }); + + it('calls onCanvasPan and stops propagation when plain wheel fires with a handler provided', () => { + const onCanvasPan = vi.fn(); + render(); + const card = screen.getByRole('group', { name: 'Probe' }); + card.dispatchEvent( + new WheelEvent('wheel', { deltaY: 20, deltaX: 0, bubbles: true, cancelable: true }) + ); + // Use any(Number) for both axes — -0 and 0 are distinct under Object.is + expect(onCanvasPan).toHaveBeenCalledWith({ + x: expect.any(Number), + y: expect.any(Number), + }); + }); + }); + + describe('drag session cleanup', () => { + it('removes window mousemove and mouseup listeners on unmount during an active drag', () => { + const { container, unmount } = render(); + const header = container.querySelector('.cursor-move') as HTMLElement; + + // Start a drag session by pressing the header with the primary button + fireEvent.mouseDown(header, { button: 0, clientX: 50, clientY: 50 }); + + const spy = vi.spyOn(window, 'removeEventListener'); + unmount(); + + expect(spy).toHaveBeenCalledWith('mousemove', expect.any(Function)); + expect(spy).toHaveBeenCalledWith('mouseup', expect.any(Function)); + spy.mockRestore(); + }); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/ProbeCard/ProbeCard.tsx b/packages/apollo-react/src/canvas/components/ProbeCard/ProbeCard.tsx new file mode 100644 index 000000000..36d07f08b --- /dev/null +++ b/packages/apollo-react/src/canvas/components/ProbeCard/ProbeCard.tsx @@ -0,0 +1,607 @@ +import { Button } from '@uipath/apollo-wind'; +import { ChevronDown, ChevronLeft, ChevronRight, Plus, X } from 'lucide-react'; +import { memo, useEffect, useLayoutEffect, useRef, useState } from 'react'; + +import { ProbeResizeHandles, type ResizeEdges } from './ProbeResizeHandles'; +import { useDragSession } from './useDragSession'; +import { useLatestRef } from './useLatestRef'; + +// ============================================================================ +// Public types +// ============================================================================ + +/** Iteration cycler shown for a node inside a loop. */ +export interface IterationControl { + current: number; + total: number; + onPrev: () => void; + onNext: () => void; +} + +/** A watch expression with its evaluated value. */ +export interface WatchResult { + id: string; + expression: string; + value: unknown; + /** True once a debug snapshot exists and the expression is non-empty. */ + hasValue: boolean; +} + +export interface ProbeCardProps { + watches: readonly WatchResult[]; + iterationControl?: IterationControl; + onAddWatch: () => void; + onUpdateWatch: (watchId: string, expression: string) => void; + onRemoveWatch: (watchId: string) => void; + onDragStart: () => void; + onDrag: (cumulativeDelta: { x: number; y: number }) => void; + onDragEnd: () => void; + onResizeStart: () => void; + onResize: (cumulativeDelta: { x: number; y: number }, edges: ResizeEdges) => void; + onResizeEnd: () => void; + onClose: () => void; + /** + * Called when the user scrolls or middle-mouse-drags over the card while + * it is embedded in a canvas — allows the card to pan the underlying canvas + * instead of swallowing the gesture silently. + */ + onCanvasPan?: (delta: { x: number; y: number }) => void; + /** + * Called when the user Ctrl+scrolls (pinch-to-zoom) over the card while + * embedded in a canvas — forwards the gesture to the canvas zoom handler. + */ + onCanvasZoom?: (params: { + clientX: number; + clientY: number; + deltaY: number; + deltaMode: number; + ctrlKey: boolean; + }) => void; +} + +// ============================================================================ +// Inline value renderer (no external dependencies) +// ============================================================================ + +function WatchValueView({ value, depth = 0 }: { value: unknown; depth?: number }) { + if (value === null) { + return null; + } + if (value === undefined) { + return undefined; + } + if (typeof value === 'string') { + return ( + + "{value}" + + ); + } + if (typeof value === 'number') { + return ( + + {String(value)} + + ); + } + if (typeof value === 'boolean') { + return ( + + {String(value)} + + ); + } + if (typeof value === 'object') { + const isArray = Array.isArray(value); + const entries = isArray + ? (value as unknown[]).map((v, i) => [String(i), v] as [string, unknown]) + : Object.entries(value as Record); + + if (entries.length === 0) { + return ( + + {isArray ? '[]' : '{}'} + + ); + } + + if (depth >= 3) { + return ( + + {isArray ? `[…${entries.length}]` : '{…}'} + + ); + } + + return ( +
+ {entries.map(([k, v]) => ( +
+ {k}: + +
+ ))} +
+ ); + } + return {String(value)}; +} + +// ============================================================================ +// Sub-components +// ============================================================================ + +function RemoveButton({ onRemove, label }: { onRemove: () => void; label: string }) { + return ( + + ); +} + +function DisclosureButton({ + expanded, + onToggle, + label, +}: { + expanded: boolean; + onToggle: () => void; + label: string; +}) { + return ( + + ); +} + +function WatchRow({ + watch, + onChange, + onRemove, +}: { + watch: WatchResult; + onChange: (expression: string) => void; + onRemove: () => void; +}) { + const [expr, setExpr] = useState(watch.expression); + const [expanded, setExpanded] = useState(true); + + useEffect(() => { + setExpr(watch.expression); + }, [watch.expression]); + + const commit = () => { + if (expr !== watch.expression) onChange(expr); + }; + + return ( +
+
+ setExpanded((e) => !e)} + label="Toggle value" + /> + setExpr(e.target.value)} + onBlur={commit} + onKeyDown={(e) => { + e.stopPropagation(); + if (e.key === 'Enter') commit(); + }} + placeholder="e.g. output.items[0].id" + aria-label="Watch expression" + data-probe-watch-input="true" + className="flex-1 min-w-0 text-sm font-mono text-foreground-secondary bg-transparent outline-none border-b border-transparent focus:border-foreground-accent" + /> + +
+ {expanded && watch.hasValue && ( +
+ +
+ )} +
+ ); +} + +// ============================================================================ +// ProbeCard +// ============================================================================ + +const PAN_ON_SCROLL_SPEED = 0.5; + +function ProbeCardComponent({ + watches, + iterationControl, + onAddWatch, + onUpdateWatch, + onRemoveWatch, + onDragStart, + onDrag, + onDragEnd, + onResizeStart, + onResize, + onResizeEnd, + onClose, + onCanvasPan, + onCanvasZoom, +}: ProbeCardProps) { + const [hovered, setHovered] = useState(false); + const cardRef = useRef(null); + const watchListRef = useRef(null); + const pendingFocusNewWatchRef = useRef(false); + const previousWatchCountRef = useRef(watches.length); + const onCloseRef = useLatestRef(onClose); + const onCanvasPanRef = useLatestRef(onCanvasPan); + const onCanvasZoomRef = useLatestRef(onCanvasZoom); + const isSpacePressedRef = useRef(false); + const canvasPanCleanupRef = useRef<(() => void) | null>(null); + const canvasPanLastPointRef = useRef<{ x: number; y: number } | null>(null); + const suppressNextClickRef = useRef(false); + + const handleHeaderMouseDown = useDragSession({ + onStart: onDragStart, + onMove: onDrag, + onEnd: onDragEnd, + }); + + const handleAddWatch = () => { + pendingFocusNewWatchRef.current = true; + onAddWatch(); + }; + + useLayoutEffect(() => { + const previousCount = previousWatchCountRef.current; + previousWatchCountRef.current = watches.length; + if (!pendingFocusNewWatchRef.current || watches.length <= previousCount) return; + pendingFocusNewWatchRef.current = false; + const inputs = watchListRef.current?.querySelectorAll( + '[data-probe-watch-input="true"]' + ); + const input = inputs?.[inputs.length - 1]; + if (!input) return; + input.scrollIntoView({ block: 'nearest' }); + input.focus({ preventScroll: true }); + }, [watches.length]); + + // Delete/Backspace removes the probe when the card has focus and no editable + // field is active. Capture phase runs before ReactFlow's document-level handler. + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key !== 'Delete' && e.key !== 'Backspace') return; + const card = cardRef.current; + if (!card || !card.contains(document.activeElement)) return; + const active = document.activeElement as HTMLElement | null; + const isEditable = + !!active && + (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable); + if (isEditable) return; + e.stopPropagation(); + e.preventDefault(); + onCloseRef.current(); + }; + window.addEventListener('keydown', handler, true); + return () => window.removeEventListener('keydown', handler, true); + }, [onCloseRef]); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === ' ' || e.code === 'Space') isSpacePressedRef.current = true; + }; + const handleKeyUp = (e: KeyboardEvent) => { + if (e.key === ' ' || e.code === 'Space') isSpacePressedRef.current = false; + }; + window.addEventListener('keydown', handleKeyDown, true); + window.addEventListener('keyup', handleKeyUp, true); + return () => { + window.removeEventListener('keydown', handleKeyDown, true); + window.removeEventListener('keyup', handleKeyUp, true); + canvasPanCleanupRef.current?.(); + }; + }, []); + + const startCanvasPan = (e: React.MouseEvent) => { + if (!onCanvasPanRef.current) return; + e.stopPropagation(); + e.preventDefault(); + suppressNextClickRef.current = true; + canvasPanCleanupRef.current?.(); + canvasPanLastPointRef.current = { x: e.clientX, y: e.clientY }; + + const handleMove = (ev: MouseEvent) => { + const last = canvasPanLastPointRef.current; + if (!last) return; + const next = { x: ev.clientX, y: ev.clientY }; + canvasPanLastPointRef.current = next; + onCanvasPanRef.current?.({ x: next.x - last.x, y: next.y - last.y }); + }; + const handleUp = () => { + window.removeEventListener('mousemove', handleMove); + window.removeEventListener('mouseup', handleUp); + canvasPanCleanupRef.current = null; + canvasPanLastPointRef.current = null; + window.setTimeout(() => { + suppressNextClickRef.current = false; + }, 0); + }; + canvasPanCleanupRef.current = handleUp; + window.addEventListener('mousemove', handleMove); + window.addEventListener('mouseup', handleUp); + }; + + const handleMouseDownCapture = (e: React.MouseEvent) => { + const target = e.target instanceof Element ? e.target : null; + const startedMiddlePan = e.button === 1; + const startedSpacePan = + e.button === 0 && isSpacePressedRef.current && !isInteractiveTarget(target); + if (startedMiddlePan || startedSpacePan) { + startCanvasPan(e); + return; + } + if (e.button === 0) cardRef.current?.focus(); + }; + + const handleClickCapture = (e: React.MouseEvent) => { + if (!suppressNextClickRef.current) return; + suppressNextClickRef.current = false; + e.stopPropagation(); + e.preventDefault(); + }; + + // Non-passive wheel listener so we can preventDefault on pinch-to-zoom + useEffect(() => { + const card = cardRef.current; + if (!card) return; + + const onWheel = (e: WheelEvent) => { + if (e.ctrlKey) { + // Only consume pinch-to-zoom when a handler is wired; otherwise let + // the gesture bubble up to the parent canvas. + if (onCanvasZoomRef.current) { + e.stopPropagation(); + e.preventDefault(); + onCanvasZoomRef.current({ + clientX: e.clientX, + clientY: e.clientY, + deltaY: e.deltaY, + deltaMode: e.deltaMode, + ctrlKey: e.ctrlKey, + }); + } + return; + } + const target = e.target instanceof Node ? e.target : null; + if ( + target && + watchListRef.current?.contains(target) && + canScrollVertically(watchListRef.current, e.deltaY) + ) { + // Watch list is scrolling — consume but don't forward to canvas. + e.stopPropagation(); + return; + } + // Only forward pan when a handler is wired; otherwise let the wheel + // event bubble to the canvas so pan still works over the card. + if (onCanvasPanRef.current) { + e.stopPropagation(); + e.preventDefault(); + const deltaNormalize = e.deltaMode === 1 ? 20 : 1; + let deltaX = e.deltaX * deltaNormalize; + let deltaY = e.deltaY * deltaNormalize; + if (!isMac() && e.shiftKey) { + deltaX = e.deltaY * deltaNormalize; + deltaY = 0; + } + onCanvasPanRef.current({ + x: -deltaX * PAN_ON_SCROLL_SPEED, + y: -deltaY * PAN_ON_SCROLL_SPEED, + }); + } + }; + + card.addEventListener('wheel', onWheel, { passive: false }); + return () => card.removeEventListener('wheel', onWheel); + }, [onCanvasZoomRef, onCanvasPanRef]); + + return ( + // biome-ignore lint/a11y/useSemanticElements: no semantic element covers a draggable, resizable, keyboard-navigable floating overlay +
e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + onMouseEnter={() => setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + {/* Header */} +
+ Probe + + {iterationControl && ( +
+ + + {iterationControl.current + 1}/{iterationControl.total} + + +
+ )} + + + +
+ + {/* Watch list */} +
+ {watches.length === 0 ? ( +
+ + No watches — use + to add one + +
+ ) : ( + watches.map((w) => ( + onUpdateWatch(w.id, expr)} + onRemove={() => onRemoveWatch(w.id)} + /> + )) + )} +
+ + +
+ ); +} + +/** + * ProbeCard — a floating debug card for inspecting canvas node output values. + * + * Memoized so it skips re-rendering when the parent re-renders only because + * the canvas viewport panned or zoomed — all props are referentially stable + * across those frames. + * + * The card is fully controlled: the caller owns position, size, and the watch + * list. ProbeCard handles all interactions internally (drag, resize, keyboard + * shortcuts, scroll-to-pan, and pinch-to-zoom forwarding). + * + * ### Integration pattern + * + * 1. **State** — maintain `offset`, `size`, and `watches` in your own store. + * 2. **Position** — render the card `position: absolute` inside a container + * that covers the canvas viewport. Calculate `left`/`top` from the anchor + * node's canvas-to-screen coordinates and the stored `offset`. + * 3. **Connector** — draw an SVG dashed line from the anchor node's edge to + * the card's center using the same coordinate conversion. + * 4. **Watch values** — evaluate `watch.expression` against the node's runtime + * output snapshot and pass results as `WatchResult[]`. Set `hasValue: true` + * only when a snapshot is available so the value row is shown. + * 5. **Canvas pan/zoom** — pass `onCanvasPan` and `onCanvasZoom` so scroll and + * middle-mouse gestures over the card are forwarded to the canvas viewport + * instead of being swallowed. + * + * @example + * ```tsx + * import { ProbeCard } from '@uipath/apollo-react/canvas'; + * + *
+ * addWatch(probeId)} + * onUpdateWatch={(id, expr) => updateWatch(probeId, id, expr)} + * onRemoveWatch={(id) => removeWatch(probeId, id)} + * onDragStart={() => captureOffset()} + * onDrag={(delta) => setOffset(prev => ({ x: prev.x + delta.x / zoom, y: prev.y + delta.y / zoom }))} + * onDragEnd={() => persistOffset()} + * onResizeStart={() => captureSize()} + * onResize={(delta, edges) => applyResize(delta, edges)} + * onResizeEnd={() => persistSize()} + * onClose={() => removeProbe(probeId)} + * onCanvasPan={(delta) => panBy(delta)} + * onCanvasZoom={(params) => zoomAtPoint(params)} + * /> + *
+ * ``` + */ +export const ProbeCard = memo(ProbeCardComponent); + +// ============================================================================ +// Helpers +// ============================================================================ + +function isInteractiveTarget(target: Element | null): boolean { + return !!target?.closest( + 'input, textarea, select, button, a, [contenteditable], [role="button"], [role="menuitem"]' + ); +} + +function isMac(): boolean { + return typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac'); +} + +function canScrollVertically(element: HTMLElement, deltaY: number): boolean { + if (deltaY === 0) return false; + const maxScrollTop = element.scrollHeight - element.clientHeight; + if (maxScrollTop <= 0) return false; + return deltaY < 0 ? element.scrollTop > 0 : element.scrollTop < maxScrollTop; +} diff --git a/packages/apollo-react/src/canvas/components/ProbeCard/ProbeResizeHandles.tsx b/packages/apollo-react/src/canvas/components/ProbeCard/ProbeResizeHandles.tsx new file mode 100644 index 000000000..430e7d273 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/ProbeCard/ProbeResizeHandles.tsx @@ -0,0 +1,79 @@ +import { useDragSession } from './useDragSession'; + +export interface ResizeEdges { + left?: boolean; + right?: boolean; + top?: boolean; + bottom?: boolean; +} + +interface ProbeResizeHandlesProps { + active: boolean; + onResizeStart: () => void; + onResize: (cumulativeDelta: { x: number; y: number }, edges: ResizeEdges) => void; + onResizeEnd: () => void; +} + +const HANDLES: { edges: ResizeEdges; style: React.CSSProperties; cursor: string }[] = [ + { edges: { top: true, left: true }, style: { top: -4, left: -4 }, cursor: 'nwse-resize' }, + { edges: { top: true, right: true }, style: { top: -4, right: -4 }, cursor: 'nesw-resize' }, + { edges: { bottom: true, right: true }, style: { bottom: -4, right: -4 }, cursor: 'nwse-resize' }, + { edges: { bottom: true, left: true }, style: { bottom: -4, left: -4 }, cursor: 'nesw-resize' }, +]; + +export function ProbeResizeHandles({ + active, + onResizeStart, + onResize, + onResizeEnd, +}: ProbeResizeHandlesProps) { + return ( +
+
+ {HANDLES.map((h, i) => ( + + ))} +
+ ); +} + +function ResizeHandle({ + edges, + style, + cursor, + onResizeStart, + onResize, + onResizeEnd, +}: { + edges: ResizeEdges; + style: React.CSSProperties; + cursor: string; + onResizeStart: () => void; + onResize: (cumulativeDelta: { x: number; y: number }, edges: ResizeEdges) => void; + onResizeEnd: () => void; +}) { + const handleMouseDown = useDragSession({ + onStart: onResizeStart, + onMove: (delta) => onResize(delta, edges), + onEnd: onResizeEnd, + }); + + return ( +
+ ); +} diff --git a/packages/apollo-react/src/canvas/components/ProbeCard/index.ts b/packages/apollo-react/src/canvas/components/ProbeCard/index.ts new file mode 100644 index 000000000..fa5808bbc --- /dev/null +++ b/packages/apollo-react/src/canvas/components/ProbeCard/index.ts @@ -0,0 +1,21 @@ +/** + * ProbeCard — floating debug card for canvas node output inspection. + * + * Import from the canvas barrel: + * ```ts + * import { ProbeCard } from '@uipath/apollo-react/canvas'; + * import type { WatchResult, IterationControl, ProbeCardProps, ResizeEdges } from '@uipath/apollo-react/canvas'; + * ``` + * + * The card is UI-only. Callers own: + * - **Position & size** — calculated from node canvas→screen coordinates + * - **Watch list** — expressions + pre-evaluated `WatchResult[]` values + * - **Connector line** — SVG dashed line from node edge to card center + * - **Store** — persistence (localStorage, Zustand, etc.) + * + * See `ProbeCardProps` for the full callback contract. + */ + +export type { IterationControl, ProbeCardProps, WatchResult } from './ProbeCard'; +export { ProbeCard } from './ProbeCard'; +export type { ResizeEdges } from './ProbeResizeHandles'; diff --git a/packages/apollo-react/src/canvas/components/ProbeCard/useDragSession.ts b/packages/apollo-react/src/canvas/components/ProbeCard/useDragSession.ts new file mode 100644 index 000000000..e3cc97fee --- /dev/null +++ b/packages/apollo-react/src/canvas/components/ProbeCard/useDragSession.ts @@ -0,0 +1,49 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { useLatestRef } from './useLatestRef'; + +interface DragSessionHandlers { + onStart?: () => void; + onMove?: (cumulativeDelta: { x: number; y: number }) => void; + onEnd?: () => void; +} + +/** + * Pointer-drag session: captures clientX/Y on mousedown, calls `onMove` with + * cumulative deltas, and cleans up window listeners on mouseup or unmount. + */ +export function useDragSession(handlers: DragSessionHandlers): (e: React.MouseEvent) => void { + const handlersRef = useLatestRef(handlers); + const startRef = useRef<{ x: number; y: number } | null>(null); + const cleanupRef = useRef<(() => void) | null>(null); + + useEffect(() => () => cleanupRef.current?.(), []); + + // biome-ignore lint/correctness/useExhaustiveDependencies: handlers are read via a stable ref so the callback never needs to be recreated + return useCallback((e: React.MouseEvent) => { + if (e.button !== 0) return; + e.stopPropagation(); + e.preventDefault(); + startRef.current = { x: e.clientX, y: e.clientY }; + handlersRef.current.onStart?.(); + + const handleMove = (ev: MouseEvent) => { + const start = startRef.current; + if (!start) return; + handlersRef.current.onMove?.({ x: ev.clientX - start.x, y: ev.clientY - start.y }); + }; + const handleUp = () => { + startRef.current = null; + window.removeEventListener('mousemove', handleMove); + window.removeEventListener('mouseup', handleUp); + cleanupRef.current = null; + handlersRef.current.onEnd?.(); + }; + cleanupRef.current = () => { + window.removeEventListener('mousemove', handleMove); + window.removeEventListener('mouseup', handleUp); + startRef.current = null; + }; + window.addEventListener('mousemove', handleMove); + window.addEventListener('mouseup', handleUp); + }, []); +} diff --git a/packages/apollo-react/src/canvas/components/ProbeCard/useLatestRef.ts b/packages/apollo-react/src/canvas/components/ProbeCard/useLatestRef.ts new file mode 100644 index 000000000..efd541039 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/ProbeCard/useLatestRef.ts @@ -0,0 +1,11 @@ +import { useEffect, useLayoutEffect, useRef } from 'react'; + +const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect; + +export function useLatestRef(value: T) { + const ref = useRef(value); + useIsomorphicLayoutEffect(() => { + ref.current = value; + }, [value]); + return ref; +} diff --git a/packages/apollo-react/src/canvas/components/index.ts b/packages/apollo-react/src/canvas/components/index.ts index eec8b1c64..08d0cbe12 100644 --- a/packages/apollo-react/src/canvas/components/index.ts +++ b/packages/apollo-react/src/canvas/components/index.ts @@ -18,9 +18,11 @@ export * from './MiniCanvasNavigator'; export * from './NodeContextMenu'; export * from './NodeInspector'; export * from './NodePropertiesPanel'; -export * from './shared'; +export * from './NodePropertyPanel'; +export * from './ProbeCard'; export * from './StageNode'; export * from './StickyNoteNode'; +export * from './shared'; export * from './TaskIcon'; export * from './Toolbar'; export * from './Toolbox';