Skip to content

Commit aee32f1

Browse files
BillLeoutsakosvl346Bill Leoutsakos
andauthored
fix(preview): scope cursor styling per instance (#8245)
* Scope preview workflow cursors to each instance * test(preview): verify scoped cursor CSS across mounted previews --------- Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local>
1 parent 63853e3 commit aee32f1

3 files changed

Lines changed: 245 additions & 28 deletions

File tree

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
5+
import { readFileSync } from 'node:fs'
6+
import { act, type ReactNode } from 'react'
7+
import { createRoot } from 'react-dom/client'
8+
import { afterEach, describe, expect, it, vi } from 'vitest'
9+
import type { WorkflowState } from '@/stores/workflows/workflow/types'
10+
11+
vi.mock('@xyflow/react', () => ({
12+
ConnectionLineType: { SmoothStep: 'smoothstep' },
13+
ReactFlowProvider: ({ children }: { children: ReactNode }) => children,
14+
ReactFlow: () => (
15+
<div className='react-flow'>
16+
<div className='react-flow__pane' />
17+
<div className='react-flow__selectionpane' />
18+
<div className='react-flow__renderer'>
19+
<div className='react-flow__node'>Node</div>
20+
</div>
21+
</div>
22+
),
23+
useReactFlow: () => ({ fitView: vi.fn() }),
24+
}))
25+
26+
vi.mock('next/navigation', () => ({ useParams: () => ({ workspaceId: 'workspace-1' }) }))
27+
vi.mock('@/hooks/queries/workflows', () => ({
28+
useWorkflowMap: () => ({ data: {}, isSuccess: true, isPlaceholderData: false }),
29+
}))
30+
vi.mock('@sim/workflow-renderer', () => ({
31+
BLOCK_DIMENSIONS: { FIXED_WIDTH: 200, MIN_HEIGHT: 100 },
32+
BLOCK_Z_BASE: 1,
33+
CANVAS_Z_INDEX_MODE: 'manual',
34+
CONTAINER_CHILD_Z_BASE: 2,
35+
CONTAINER_DIMENSIONS: {
36+
MIN_WIDTH: 200,
37+
MIN_HEIGHT: 100,
38+
DEFAULT_WIDTH: 200,
39+
DEFAULT_HEIGHT: 100,
40+
},
41+
EDGE_Z_BASE: 0,
42+
EDGE_Z_MAX: 10,
43+
getEdgeZIndexForTarget: () => 0,
44+
sortNodesParentsFirst: (nodes: unknown[]) => nodes,
45+
useCanvasColorMode: () => 'light',
46+
}))
47+
vi.mock('@sim/workflow-types/workflow', () => ({
48+
normalizeWorkflowEdgeHandles: (edges: unknown[]) => edges,
49+
}))
50+
vi.mock(
51+
'@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge',
52+
() => ({
53+
WorkflowEdge: () => null,
54+
})
55+
)
56+
vi.mock('@/app/workspace/[workspaceId]/w/[workflowId]/utils', () => ({
57+
estimateBlockDimensions: () => ({ width: 200, height: 100 }),
58+
SUBFLOW_CHILD_NODE_CLASS: 'subflow-child',
59+
}))
60+
vi.mock(
61+
'@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block',
62+
() => ({
63+
PreviewBlock: () => null,
64+
})
65+
)
66+
vi.mock(
67+
'@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/subflow',
68+
() => ({
69+
PreviewSubflow: () => null,
70+
})
71+
)
72+
73+
import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow'
74+
75+
const workflowState = { blocks: {}, edges: [] } as WorkflowState
76+
let host: HTMLDivElement | undefined
77+
let root: ReturnType<typeof createRoot> | undefined
78+
let cursorStyles: HTMLStyleElement | undefined
79+
80+
afterEach(() => {
81+
if (root) act(() => root?.unmount())
82+
host?.remove()
83+
cursorStyles?.remove()
84+
root = undefined
85+
host = undefined
86+
cursorStyles = undefined
87+
vi.unstubAllGlobals()
88+
})
89+
90+
describe('PreviewWorkflow cursors', () => {
91+
it('keeps two mounted previews independent when either cursor changes', () => {
92+
vi.stubGlobal(
93+
'ResizeObserver',
94+
class {
95+
observe() {}
96+
disconnect() {}
97+
}
98+
)
99+
host = document.createElement('div')
100+
document.body.appendChild(host)
101+
root = createRoot(host)
102+
103+
act(() =>
104+
root?.render(
105+
<>
106+
<PreviewWorkflow workflowState={workflowState} cursorStyle='grab' />
107+
<PreviewWorkflow
108+
workflowState={workflowState}
109+
cursorStyle='pointer'
110+
onNodeClick={() => {}}
111+
/>
112+
</>
113+
)
114+
)
115+
116+
const previews = host.querySelectorAll<HTMLElement>('.preview-mode')
117+
expect(previews).toHaveLength(2)
118+
expect(previews[0].style.getPropertyValue('--preview-cursor')).toBe('grab')
119+
expect(previews[0]).toHaveAttribute('data-preview-grab')
120+
expect(previews[1].style.getPropertyValue('--preview-cursor')).toBe('pointer')
121+
expect(previews[1]).not.toHaveAttribute('data-preview-grab')
122+
expect(previews[1]).toHaveClass('interactive-nodes')
123+
124+
act(() =>
125+
root?.render(
126+
<>
127+
<PreviewWorkflow workflowState={workflowState} cursorStyle='default' />
128+
<PreviewWorkflow
129+
workflowState={workflowState}
130+
cursorStyle='grab'
131+
onNodeClick={() => {}}
132+
/>
133+
</>
134+
)
135+
)
136+
137+
expect(previews[0].style.getPropertyValue('--preview-cursor')).toBe('default')
138+
expect(previews[0]).not.toHaveAttribute('data-preview-grab')
139+
expect(previews[1].style.getPropertyValue('--preview-cursor')).toBe('grab')
140+
expect(previews[1]).toHaveAttribute('data-preview-grab')
141+
expect(previews[1]).toHaveClass('interactive-nodes')
142+
})
143+
144+
it('applies the production cursor CSS only within each mounted preview', () => {
145+
vi.stubGlobal(
146+
'ResizeObserver',
147+
class {
148+
observe() {}
149+
disconnect() {}
150+
}
151+
)
152+
153+
// JSDOM computes stylesheet rules but cannot keep an element in :active after
154+
// a pointer event. Map that pseudo-class to an attribute for this CSS test.
155+
cursorStyles = document.createElement('style')
156+
cursorStyles.textContent = readFileSync(
157+
'app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.css',
158+
'utf8'
159+
).replaceAll(':active', '[data-test-active]')
160+
document.head.appendChild(cursorStyles)
161+
162+
host = document.createElement('div')
163+
document.body.appendChild(host)
164+
root = createRoot(host)
165+
166+
const render = (first: 'grab' | 'default', second: 'grab' | 'pointer') =>
167+
act(() =>
168+
root?.render(
169+
<>
170+
<PreviewWorkflow workflowState={workflowState} cursorStyle={first} />
171+
<PreviewWorkflow
172+
workflowState={workflowState}
173+
cursorStyle={second}
174+
onNodeClick={() => {}}
175+
/>
176+
</>
177+
)
178+
)
179+
180+
render('grab', 'pointer')
181+
182+
const previews = host.querySelectorAll<HTMLElement>('.preview-mode')
183+
expect(previews).toHaveLength(2)
184+
const panes = Array.from(
185+
previews,
186+
(preview) => preview.querySelector<HTMLElement>('.react-flow__pane')!
187+
)
188+
const nodes = Array.from(
189+
previews,
190+
(preview) => preview.querySelector<HTMLElement>('.react-flow__node')!
191+
)
192+
193+
for (const element of [...panes, ...nodes]) element.setAttribute('data-test-active', '')
194+
195+
expect(getComputedStyle(panes[0]).cursor).toBe('grabbing')
196+
expect(getComputedStyle(nodes[0]).cursor).toBe('grabbing')
197+
expect(getComputedStyle(panes[1]).cursor).toBe('var(--preview-cursor)')
198+
expect(getComputedStyle(nodes[1]).cursor).toBe('pointer')
199+
200+
render('default', 'grab')
201+
202+
expect(getComputedStyle(panes[0]).cursor).toBe('var(--preview-cursor)')
203+
expect(getComputedStyle(nodes[0]).cursor).not.toBe('grabbing')
204+
expect(getComputedStyle(panes[1]).cursor).toBe('grabbing')
205+
expect(getComputedStyle(nodes[1]).cursor).toBe('pointer')
206+
})
207+
})
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
.preview-mode .react-flow,
2+
.preview-mode .react-flow__renderer {
3+
cursor: var(--preview-cursor);
4+
}
5+
6+
.preview-mode .react-flow__pane,
7+
.preview-mode .react-flow__selectionpane {
8+
cursor: var(--preview-cursor) !important;
9+
}
10+
11+
.preview-mode:where([data-preview-grab]) .react-flow:active,
12+
.preview-mode:where([data-preview-grab]) .react-flow__renderer:active {
13+
cursor: grabbing;
14+
}
15+
16+
.preview-mode:where([data-preview-grab]) .react-flow__pane:active,
17+
.preview-mode:where([data-preview-grab]) .react-flow__selectionpane:active,
18+
.preview-mode:where([data-preview-grab]) .react-flow__node:active,
19+
.preview-mode:where([data-preview-grab]) .react-flow__node:active * {
20+
cursor: grabbing !important;
21+
}
22+
23+
.preview-mode.interactive-nodes .react-flow__node,
24+
.preview-mode.interactive-nodes .react-flow__node > div,
25+
.preview-mode.interactive-nodes .react-flow__node * {
26+
cursor: pointer !important;
27+
}

‎apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx‎

Lines changed: 11 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { useEffect, useMemo, useRef } from 'react'
3+
import { type CSSProperties, useEffect, useMemo, useRef } from 'react'
44
import {
55
ConnectionLineType,
66
type Edge,
@@ -38,6 +38,7 @@ import { PreviewBlock } from '@/app/workspace/[workspaceId]/w/components/preview
3838
import { PreviewSubflow } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/subflow'
3939
import { useWorkflowMap } from '@/hooks/queries/workflows'
4040
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
41+
import '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.css'
4142

4243
const logger = createLogger('PreviewWorkflow')
4344

@@ -627,35 +628,17 @@ export function PreviewWorkflow({
627628
<ReactFlowProvider>
628629
<div
629630
ref={containerRef}
630-
style={{ height, width, backgroundColor: 'var(--bg)' }}
631+
style={
632+
{
633+
height,
634+
width,
635+
backgroundColor: 'var(--bg)',
636+
'--preview-cursor': cursorStyle,
637+
} as CSSProperties
638+
}
631639
className={cn('preview-mode', onNodeClick && 'interactive-nodes', className)}
640+
data-preview-grab={cursorStyle === 'grab' ? '' : undefined}
632641
>
633-
<style>{`
634-
/* Canvas cursor - grab on the flow container and pane */
635-
.preview-mode .react-flow { cursor: ${cursorStyle}; }
636-
.preview-mode .react-flow__pane { cursor: ${cursorStyle} !important; }
637-
.preview-mode .react-flow__selectionpane { cursor: ${cursorStyle} !important; }
638-
.preview-mode .react-flow__renderer { cursor: ${cursorStyle}; }
639-
640-
/* Active/grabbing cursor when dragging */
641-
${
642-
cursorStyle === 'grab'
643-
? `
644-
.preview-mode .react-flow:active { cursor: grabbing; }
645-
.preview-mode .react-flow__pane:active { cursor: grabbing !important; }
646-
.preview-mode .react-flow__selectionpane:active { cursor: grabbing !important; }
647-
.preview-mode .react-flow__renderer:active { cursor: grabbing; }
648-
.preview-mode .react-flow__node:active { cursor: grabbing !important; }
649-
.preview-mode .react-flow__node:active * { cursor: grabbing !important; }
650-
`
651-
: ''
652-
}
653-
654-
/* Node cursor - pointer on nodes when onNodeClick is provided */
655-
.preview-mode.interactive-nodes .react-flow__node { cursor: pointer !important; }
656-
.preview-mode.interactive-nodes .react-flow__node > div { cursor: pointer !important; }
657-
.preview-mode.interactive-nodes .react-flow__node * { cursor: pointer !important; }
658-
`}</style>
659642
<ReactFlow
660643
colorMode={colorMode}
661644
zIndexMode={CANVAS_Z_INDEX_MODE}

0 commit comments

Comments
 (0)