From c116faada8dd0ac7604be79633accb6d93347bd7 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Thu, 24 Sep 2026 11:36:46 -0700 Subject: [PATCH 1/2] Scope preview workflow cursors to each instance --- .../preview-workflow-cursor.test.tsx | 139 ++++++++++++++++++ .../preview-workflow/preview-workflow.css | 27 ++++ .../preview-workflow/preview-workflow.tsx | 39 ++--- 3 files changed, 177 insertions(+), 28 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow-cursor.test.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.css diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow-cursor.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow-cursor.test.tsx new file mode 100644 index 00000000000..59c82f42678 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow-cursor.test.tsx @@ -0,0 +1,139 @@ +/** + * @vitest-environment jsdom + */ + +import { act, type ReactNode } from 'react' +import { createRoot } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { WorkflowState } from '@/stores/workflows/workflow/types' + +vi.mock('@xyflow/react', () => ({ + ConnectionLineType: { SmoothStep: 'smoothstep' }, + ReactFlowProvider: ({ children }: { children: ReactNode }) => children, + ReactFlow: () => ( +
+
+
+
+
Node
+
+
+ ), + useReactFlow: () => ({ fitView: vi.fn() }), +})) + +vi.mock('next/navigation', () => ({ useParams: () => ({ workspaceId: 'workspace-1' }) })) +vi.mock('@/hooks/queries/workflows', () => ({ + useWorkflowMap: () => ({ data: {}, isSuccess: true, isPlaceholderData: false }), +})) +vi.mock('@sim/workflow-renderer', () => ({ + BLOCK_DIMENSIONS: { FIXED_WIDTH: 200, MIN_HEIGHT: 100 }, + BLOCK_Z_BASE: 1, + CANVAS_Z_INDEX_MODE: 'manual', + CONTAINER_CHILD_Z_BASE: 2, + CONTAINER_DIMENSIONS: { + MIN_WIDTH: 200, + MIN_HEIGHT: 100, + DEFAULT_WIDTH: 200, + DEFAULT_HEIGHT: 100, + }, + EDGE_Z_BASE: 0, + EDGE_Z_MAX: 10, + getEdgeZIndexForTarget: () => 0, + sortNodesParentsFirst: (nodes: unknown[]) => nodes, + useCanvasColorMode: () => 'light', +})) +vi.mock('@sim/workflow-types/workflow', () => ({ + normalizeWorkflowEdgeHandles: (edges: unknown[]) => edges, +})) +vi.mock( + '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge', + () => ({ + WorkflowEdge: () => null, + }) +) +vi.mock('@/app/workspace/[workspaceId]/w/[workflowId]/utils', () => ({ + estimateBlockDimensions: () => ({ width: 200, height: 100 }), + SUBFLOW_CHILD_NODE_CLASS: 'subflow-child', +})) +vi.mock( + '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block', + () => ({ + PreviewBlock: () => null, + }) +) +vi.mock( + '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/subflow', + () => ({ + PreviewSubflow: () => null, + }) +) + +import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow' + +const workflowState = { blocks: {}, edges: [] } as WorkflowState +let host: HTMLDivElement | undefined +let root: ReturnType | undefined + +afterEach(() => { + if (root) act(() => root?.unmount()) + host?.remove() + root = undefined + host = undefined + vi.unstubAllGlobals() +}) + +describe('PreviewWorkflow cursors', () => { + it('keeps two mounted previews independent when either cursor changes', () => { + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + disconnect() {} + } + ) + host = document.createElement('div') + document.body.appendChild(host) + root = createRoot(host) + + act(() => + root?.render( + <> + + {}} + /> + + ) + ) + + const previews = host.querySelectorAll('.preview-mode') + expect(previews).toHaveLength(2) + expect(previews[0].style.getPropertyValue('--preview-cursor')).toBe('grab') + expect(previews[0]).toHaveAttribute('data-preview-grab') + expect(previews[1].style.getPropertyValue('--preview-cursor')).toBe('pointer') + expect(previews[1]).not.toHaveAttribute('data-preview-grab') + expect(previews[1]).toHaveClass('interactive-nodes') + + act(() => + root?.render( + <> + + {}} + /> + + ) + ) + + expect(previews[0].style.getPropertyValue('--preview-cursor')).toBe('default') + expect(previews[0]).not.toHaveAttribute('data-preview-grab') + expect(previews[1].style.getPropertyValue('--preview-cursor')).toBe('grab') + expect(previews[1]).toHaveAttribute('data-preview-grab') + expect(previews[1]).toHaveClass('interactive-nodes') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.css b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.css new file mode 100644 index 00000000000..fa7f7f69ce9 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.css @@ -0,0 +1,27 @@ +.preview-mode .react-flow, +.preview-mode .react-flow__renderer { + cursor: var(--preview-cursor); +} + +.preview-mode .react-flow__pane, +.preview-mode .react-flow__selectionpane { + cursor: var(--preview-cursor) !important; +} + +.preview-mode:where([data-preview-grab]) .react-flow:active, +.preview-mode:where([data-preview-grab]) .react-flow__renderer:active { + cursor: grabbing; +} + +.preview-mode:where([data-preview-grab]) .react-flow__pane:active, +.preview-mode:where([data-preview-grab]) .react-flow__selectionpane:active, +.preview-mode:where([data-preview-grab]) .react-flow__node:active, +.preview-mode:where([data-preview-grab]) .react-flow__node:active * { + cursor: grabbing !important; +} + +.preview-mode.interactive-nodes .react-flow__node, +.preview-mode.interactive-nodes .react-flow__node > div, +.preview-mode.interactive-nodes .react-flow__node * { + cursor: pointer !important; +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx index cdadfef7750..a4c175a64eb 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useMemo, useRef } from 'react' +import { type CSSProperties, useEffect, useMemo, useRef } from 'react' import { ConnectionLineType, type Edge, @@ -38,6 +38,7 @@ import { PreviewBlock } from '@/app/workspace/[workspaceId]/w/components/preview import { PreviewSubflow } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/subflow' import { useWorkflowMap } from '@/hooks/queries/workflows' import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types' +import '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.css' const logger = createLogger('PreviewWorkflow') @@ -627,35 +628,17 @@ export function PreviewWorkflow({
- Date: Thu, 24 Sep 2026 12:18:37 -0700 Subject: [PATCH 2/2] test(preview): verify scoped cursor CSS across mounted previews --- .../preview-workflow-cursor.test.tsx | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow-cursor.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow-cursor.test.tsx index 59c82f42678..b84520a72cf 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow-cursor.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow-cursor.test.tsx @@ -2,6 +2,7 @@ * @vitest-environment jsdom */ +import { readFileSync } from 'node:fs' import { act, type ReactNode } from 'react' import { createRoot } from 'react-dom/client' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -74,12 +75,15 @@ import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/prev const workflowState = { blocks: {}, edges: [] } as WorkflowState let host: HTMLDivElement | undefined let root: ReturnType | undefined +let cursorStyles: HTMLStyleElement | undefined afterEach(() => { if (root) act(() => root?.unmount()) host?.remove() + cursorStyles?.remove() root = undefined host = undefined + cursorStyles = undefined vi.unstubAllGlobals() }) @@ -136,4 +140,68 @@ describe('PreviewWorkflow cursors', () => { expect(previews[1]).toHaveAttribute('data-preview-grab') expect(previews[1]).toHaveClass('interactive-nodes') }) + + it('applies the production cursor CSS only within each mounted preview', () => { + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + disconnect() {} + } + ) + + // JSDOM computes stylesheet rules but cannot keep an element in :active after + // a pointer event. Map that pseudo-class to an attribute for this CSS test. + cursorStyles = document.createElement('style') + cursorStyles.textContent = readFileSync( + 'app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.css', + 'utf8' + ).replaceAll(':active', '[data-test-active]') + document.head.appendChild(cursorStyles) + + host = document.createElement('div') + document.body.appendChild(host) + root = createRoot(host) + + const render = (first: 'grab' | 'default', second: 'grab' | 'pointer') => + act(() => + root?.render( + <> + + {}} + /> + + ) + ) + + render('grab', 'pointer') + + const previews = host.querySelectorAll('.preview-mode') + expect(previews).toHaveLength(2) + const panes = Array.from( + previews, + (preview) => preview.querySelector('.react-flow__pane')! + ) + const nodes = Array.from( + previews, + (preview) => preview.querySelector('.react-flow__node')! + ) + + for (const element of [...panes, ...nodes]) element.setAttribute('data-test-active', '') + + expect(getComputedStyle(panes[0]).cursor).toBe('grabbing') + expect(getComputedStyle(nodes[0]).cursor).toBe('grabbing') + expect(getComputedStyle(panes[1]).cursor).toBe('var(--preview-cursor)') + expect(getComputedStyle(nodes[1]).cursor).toBe('pointer') + + render('default', 'grab') + + expect(getComputedStyle(panes[0]).cursor).toBe('var(--preview-cursor)') + expect(getComputedStyle(nodes[0]).cursor).not.toBe('grabbing') + expect(getComputedStyle(panes[1]).cursor).toBe('grabbing') + expect(getComputedStyle(nodes[1]).cursor).toBe('pointer') + }) })