Merge branch 'Pouzor:main' into bug/drag-from-title
This commit is contained in:
@@ -9,7 +9,7 @@ pydantic-settings==2.5.2
|
|||||||
python-jose[cryptography]==3.5.0
|
python-jose[cryptography]==3.5.0
|
||||||
passlib[bcrypt]==1.7.4
|
passlib[bcrypt]==1.7.4
|
||||||
bcrypt==4.0.1
|
bcrypt==4.0.1
|
||||||
python-multipart==0.0.22
|
python-multipart==0.0.26
|
||||||
apscheduler==3.10.4
|
apscheduler==3.10.4
|
||||||
python-nmap==0.7.1
|
python-nmap==0.7.1
|
||||||
pyyaml==6.0.2
|
pyyaml==6.0.2
|
||||||
@@ -21,6 +21,6 @@ zeroconf==0.131.0
|
|||||||
# Dev
|
# Dev
|
||||||
ruff==0.6.9
|
ruff==0.6.9
|
||||||
mypy==1.11.2
|
mypy==1.11.2
|
||||||
pytest==8.3.3
|
pytest==9.0.3
|
||||||
pytest-asyncio==0.24.0
|
pytest-asyncio==1.3.0
|
||||||
pytest-cov==5.0.0
|
pytest-cov==5.0.0
|
||||||
|
|||||||
@@ -343,6 +343,10 @@ export default function App() {
|
|||||||
setEditEdgeId(edge.id)
|
setEditEdgeId(edge.id)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const handleNodeDoubleClick = useCallback((node: Node<NodeData>) => {
|
||||||
|
handleEditNode(node.id)
|
||||||
|
}, [handleEditNode])
|
||||||
|
|
||||||
const handleEdgeUpdate = useCallback((data: EdgeData) => {
|
const handleEdgeUpdate = useCallback((data: EdgeData) => {
|
||||||
if (!editEdgeId) return
|
if (!editEdgeId) return
|
||||||
snapshotHistory()
|
snapshotHistory()
|
||||||
@@ -400,6 +404,7 @@ export default function App() {
|
|||||||
<CanvasContainer
|
<CanvasContainer
|
||||||
onConnect={handleEdgeConnect}
|
onConnect={handleEdgeConnect}
|
||||||
onEdgeDoubleClick={handleEdgeDoubleClick}
|
onEdgeDoubleClick={handleEdgeDoubleClick}
|
||||||
|
onNodeDoubleClick={handleNodeDoubleClick}
|
||||||
onNodeDragStart={snapshotHistory}
|
onNodeDragStart={snapshotHistory}
|
||||||
onOpenPending={(deviceId) => {
|
onOpenPending={(deviceId) => {
|
||||||
setHighlightPendingId(undefined)
|
setHighlightPendingId(undefined)
|
||||||
|
|||||||
@@ -25,11 +25,12 @@ import type { NodeData, EdgeData } from '@/types'
|
|||||||
interface CanvasContainerProps {
|
interface CanvasContainerProps {
|
||||||
onConnect?: (connection: Connection) => void
|
onConnect?: (connection: Connection) => void
|
||||||
onEdgeDoubleClick?: (edge: Edge<EdgeData>) => void
|
onEdgeDoubleClick?: (edge: Edge<EdgeData>) => void
|
||||||
|
onNodeDoubleClick?: (node: Node<NodeData>) => void
|
||||||
onNodeDragStart?: () => void
|
onNodeDragStart?: () => void
|
||||||
onOpenPending?: (deviceId: string) => void
|
onOpenPending?: (deviceId: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDragStart, onOpenPending }: CanvasContainerProps) {
|
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDoubleClick, onNodeDragStart, onOpenPending }: CanvasContainerProps) {
|
||||||
const [lassoMode, setLassoMode] = useState(true)
|
const [lassoMode, setLassoMode] = useState(true)
|
||||||
const {
|
const {
|
||||||
nodes, edges,
|
nodes, edges,
|
||||||
@@ -68,6 +69,20 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
|||||||
onEdgeDoubleClick?.(edge)
|
onEdgeDoubleClick?.(edge)
|
||||||
}, [onEdgeDoubleClick])
|
}, [onEdgeDoubleClick])
|
||||||
|
|
||||||
|
const handleNodeDoubleClick = useCallback((_: React.MouseEvent, node: Node<NodeData>) => {
|
||||||
|
onNodeDoubleClick?.(node)
|
||||||
|
}, [onNodeDoubleClick])
|
||||||
|
|
||||||
|
const handleBeforeDelete = useCallback(async () => {
|
||||||
|
snapshotHistory()
|
||||||
|
return true
|
||||||
|
}, [snapshotHistory])
|
||||||
|
|
||||||
|
const isValidConnection = useCallback(
|
||||||
|
(connection: { source: string | null; target: string | null }) => connection.source !== connection.target,
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full" style={{ background: theme.colors.canvasBackground }}>
|
<div className="w-full h-full" style={{ background: theme.colors.canvasBackground }}>
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
@@ -79,22 +94,25 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
|||||||
onNodeClick={onNodeClick}
|
onNodeClick={onNodeClick}
|
||||||
onPaneClick={onPaneClick}
|
onPaneClick={onPaneClick}
|
||||||
onEdgeDoubleClick={handleEdgeDoubleClick}
|
onEdgeDoubleClick={handleEdgeDoubleClick}
|
||||||
|
onNodeDoubleClick={handleNodeDoubleClick}
|
||||||
onNodeDragStart={onNodeDragStart}
|
onNodeDragStart={onNodeDragStart}
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
edgeTypes={edgeTypes}
|
edgeTypes={edgeTypes}
|
||||||
deleteKeyCode={['Backspace', 'Delete']}
|
deleteKeyCode={['Backspace', 'Delete']}
|
||||||
onBeforeDelete={async () => { snapshotHistory(); return true }}
|
onBeforeDelete={handleBeforeDelete}
|
||||||
selectionOnDrag={lassoMode}
|
selectionOnDrag={lassoMode}
|
||||||
panOnDrag={lassoMode ? [1, 2] : true}
|
panOnDrag={lassoMode ? [1, 2] : true}
|
||||||
panActivationKeyCode="Space"
|
panActivationKeyCode="Space"
|
||||||
selectionMode={SelectionMode.Partial}
|
selectionMode={SelectionMode.Partial}
|
||||||
multiSelectionKeyCode={['Meta', 'Control']}
|
multiSelectionKeyCode={['Meta', 'Control']}
|
||||||
|
minZoom={0.25}
|
||||||
|
maxZoom={2.5}
|
||||||
snapToGrid
|
snapToGrid
|
||||||
snapGrid={[8, 8]}
|
snapGrid={[8, 8]}
|
||||||
colorMode={theme.colors.reactFlowColorMode}
|
colorMode={theme.colors.reactFlowColorMode}
|
||||||
elevateNodesOnSelect={false}
|
elevateNodesOnSelect={false}
|
||||||
connectionMode={ConnectionMode.Loose}
|
connectionMode={ConnectionMode.Loose}
|
||||||
isValidConnection={(connection) => connection.source !== connection.target}
|
isValidConnection={isValidConnection}
|
||||||
>
|
>
|
||||||
<Background
|
<Background
|
||||||
variant={BackgroundVariant.Dots}
|
variant={BackgroundVariant.Dots}
|
||||||
|
|||||||
@@ -1,23 +1,26 @@
|
|||||||
import { describe, it, expect, vi } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
import { render, screen } from '@testing-library/react'
|
import { render, screen } from '@testing-library/react'
|
||||||
import { Server } from 'lucide-react'
|
import { Server } from 'lucide-react'
|
||||||
import { BaseNode } from '../nodes/BaseNode'
|
import { BaseNode } from '../nodes/BaseNode'
|
||||||
import type { NodeData } from '@/types'
|
import type { NodeData } from '@/types'
|
||||||
import type { Node } from '@xyflow/react'
|
import type { Node } from '@xyflow/react'
|
||||||
|
|
||||||
|
let mockZoom = 1
|
||||||
|
|
||||||
vi.mock('@xyflow/react', () => ({
|
vi.mock('@xyflow/react', () => ({
|
||||||
Handle: () => null,
|
Handle: () => null,
|
||||||
Position: { Top: 'top', Bottom: 'bottom' },
|
Position: { Top: 'top', Bottom: 'bottom' },
|
||||||
NodeResizer: () => null,
|
NodeResizer: () => null,
|
||||||
useUpdateNodeInternals: () => vi.fn(),
|
useUpdateNodeInternals: () => vi.fn(),
|
||||||
|
useViewport: () => ({ zoom: mockZoom }),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/stores/themeStore', () => ({
|
vi.mock('@/stores/themeStore', () => ({
|
||||||
useThemeStore: () => 'dark',
|
useThemeStore: (sel: (s: { activeTheme: string }) => unknown) => sel({ activeTheme: 'dark' }),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/stores/canvasStore', () => ({
|
vi.mock('@/stores/canvasStore', () => ({
|
||||||
useCanvasStore: () => ({ hideIp: false }),
|
useCanvasStore: (sel: (s: { hideIp: boolean }) => unknown) => sel({ hideIp: false }),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/utils/themes', () => ({
|
vi.mock('@/utils/themes', () => ({
|
||||||
@@ -47,11 +50,17 @@ vi.mock('@/utils/maskIp', () => ({
|
|||||||
maskIp: (ip: string) => ip,
|
maskIp: (ip: string) => ip,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/utils/propertyIcons', () => ({
|
||||||
|
resolvePropertyIcon: (icon: string | null) => icon ? Server : null,
|
||||||
|
}))
|
||||||
|
|
||||||
vi.mock('@/utils/handleUtils', () => ({
|
vi.mock('@/utils/handleUtils', () => ({
|
||||||
BOTTOM_HANDLE_IDS: ['bottom'],
|
BOTTOM_HANDLE_IDS: ['bottom'],
|
||||||
BOTTOM_HANDLE_POSITIONS: { 1: [50] },
|
BOTTOM_HANDLE_POSITIONS: { 1: [50] },
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
beforeEach(() => { mockZoom = 1 })
|
||||||
|
|
||||||
function makeNode(data: Partial<NodeData>): Node<NodeData> {
|
function makeNode(data: Partial<NodeData>): Node<NodeData> {
|
||||||
return {
|
return {
|
||||||
id: 'n1',
|
id: 'n1',
|
||||||
@@ -85,6 +94,39 @@ function renderBaseNode(data: Partial<NodeData>) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe('BaseNode — borderWidth zoom scaling', () => {
|
||||||
|
beforeEach(() => { mockZoom = 1 })
|
||||||
|
|
||||||
|
it('borderWidth is 1px at zoom=1', () => {
|
||||||
|
mockZoom = 1
|
||||||
|
const { container } = renderBaseNode({})
|
||||||
|
expect((container.firstChild as HTMLElement).style.borderWidth).toBe('1px')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('borderWidth scales to 2px at zoom=0.5', () => {
|
||||||
|
mockZoom = 0.5
|
||||||
|
const { container } = renderBaseNode({})
|
||||||
|
expect((container.firstChild as HTMLElement).style.borderWidth).toBe('2px')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('borderWidth is clamped to 1px at zoom=2', () => {
|
||||||
|
mockZoom = 2
|
||||||
|
const { container } = renderBaseNode({})
|
||||||
|
expect((container.firstChild as HTMLElement).style.borderWidth).toBe('1px')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('boxShadow glow ring uses borderWidth when selected + online at zoom=0.5', () => {
|
||||||
|
mockZoom = 0.5
|
||||||
|
const node = makeNode({ status: 'online' })
|
||||||
|
const { container } = render(
|
||||||
|
<BaseNode id={node.id} data={node.data} selected={true} icon={Server}
|
||||||
|
type="server" dragging={false} zIndex={0} isConnectable={true}
|
||||||
|
positionAbsoluteX={0} positionAbsoluteY={0} />
|
||||||
|
)
|
||||||
|
expect((container.firstChild as HTMLElement).style.boxShadow).toContain('0 0 0 2px')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('BaseNode — properties rendering', () => {
|
describe('BaseNode — properties rendering', () => {
|
||||||
it('renders visible properties on the node', () => {
|
it('renders visible properties on the node', () => {
|
||||||
renderBaseNode({
|
renderBaseNode({
|
||||||
|
|||||||
@@ -104,6 +104,24 @@ describe('CanvasContainer', () => {
|
|||||||
}).not.toThrow()
|
}).not.toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Node double-click ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('calls onNodeDoubleClick prop when a node is double-clicked', () => {
|
||||||
|
const onNodeDoubleClick = vi.fn()
|
||||||
|
const node = makeNode('n1')
|
||||||
|
render(<CanvasContainer onNodeDoubleClick={onNodeDoubleClick} />)
|
||||||
|
;(rfProps.onNodeDoubleClick as (...args: unknown[]) => unknown)({} as MouseEvent, node)
|
||||||
|
expect(onNodeDoubleClick).toHaveBeenCalledWith(node)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not throw when onNodeDoubleClick is not provided', () => {
|
||||||
|
const node = makeNode('n1')
|
||||||
|
render(<CanvasContainer />)
|
||||||
|
expect(() => {
|
||||||
|
;(rfProps.onNodeDoubleClick as (...args: unknown[]) => unknown)({} as MouseEvent, node)
|
||||||
|
}).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
// ── Connection validation ─────────────────────────────────────────────────
|
// ── Connection validation ─────────────────────────────────────────────────
|
||||||
|
|
||||||
it('isValidConnection returns false for self-connections', () => {
|
it('isValidConnection returns false for self-connections', () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createElement, useEffect } from 'react'
|
import { createElement, useEffect, useMemo } from 'react'
|
||||||
import { Handle, Position, NodeResizer, useUpdateNodeInternals, type NodeProps, type Node } from '@xyflow/react'
|
import { Handle, Position, NodeResizer, useUpdateNodeInternals, useViewport, type NodeProps, type Node } from '@xyflow/react'
|
||||||
import { Cpu, MemoryStick, HardDrive, type LucideIcon } from 'lucide-react'
|
import { Cpu, MemoryStick, HardDrive, type LucideIcon } from 'lucide-react'
|
||||||
import type { NodeData } from '@/types'
|
import type { NodeData } from '@/types'
|
||||||
import { resolveNodeColors } from '@/utils/nodeColors'
|
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||||
@@ -24,6 +24,9 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
|||||||
const updateNodeInternals = useUpdateNodeInternals()
|
const updateNodeInternals = useUpdateNodeInternals()
|
||||||
useEffect(() => { updateNodeInternals(id) }, [data.bottom_handles, id, updateNodeInternals])
|
useEffect(() => { updateNodeInternals(id) }, [data.bottom_handles, id, updateNodeInternals])
|
||||||
|
|
||||||
|
const { zoom } = useViewport()
|
||||||
|
const borderWidth = useMemo(() => Math.max(1, 1 / zoom), [zoom])
|
||||||
|
|
||||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||||
const hideIp = useCanvasStore((s) => s.hideIp)
|
const hideIp = useCanvasStore((s) => s.hideIp)
|
||||||
const theme = THEMES[activeTheme]
|
const theme = THEMES[activeTheme]
|
||||||
@@ -44,13 +47,13 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
|||||||
style={{
|
style={{
|
||||||
background: colors.background,
|
background: colors.background,
|
||||||
borderColor: colors.border,
|
borderColor: colors.border,
|
||||||
borderWidth: 1,
|
borderWidth,
|
||||||
boxShadow: isOnline && selected
|
boxShadow: isOnline && selected
|
||||||
? `0 0 0 1px ${colors.border}, 0 0 10px ${colors.border}2e, 0 0 3px ${colors.border}1a`
|
? `0 0 0 ${borderWidth}px ${colors.border}, 0 0 10px ${colors.border}2e, 0 0 3px ${colors.border}1a`
|
||||||
: isOnline
|
: isOnline
|
||||||
? `0 0 10px ${colors.border}2e, 0 0 3px ${colors.border}1a`
|
? `0 0 10px ${colors.border}2e, 0 0 3px ${colors.border}1a`
|
||||||
: selected
|
: selected
|
||||||
? `0 0 0 1px ${colors.border}, 0 0 8px ${colors.border}44`
|
? `0 0 0 ${borderWidth}px ${colors.border}, 0 0 8px ${colors.border}44`
|
||||||
: 'none',
|
: 'none',
|
||||||
opacity: data.status === 'offline' ? 0.55 : 1,
|
opacity: data.status === 'offline' ? 0.55 : 1,
|
||||||
minWidth: 140,
|
minWidth: 140,
|
||||||
@@ -112,10 +115,10 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
|||||||
<>
|
<>
|
||||||
<div style={{ height: 1, background: `${colors.border}44`, margin: '0 8px' }} />
|
<div style={{ height: 1, background: `${colors.border}44`, margin: '0 8px' }} />
|
||||||
<div className="flex flex-col gap-1 px-2.5 py-1.5">
|
<div className="flex flex-col gap-1 px-2.5 py-1.5">
|
||||||
{visibleProperties.map((prop, i) => {
|
{visibleProperties.map((prop) => {
|
||||||
const Icon = resolvePropertyIcon(prop.icon)
|
const Icon = resolvePropertyIcon(prop.icon)
|
||||||
return (
|
return (
|
||||||
<div key={i} className="flex items-center gap-1 font-mono text-[10px]" style={{ color: theme.colors.nodeSubtextColor }}>
|
<div key={prop.key} className="flex items-center gap-1 font-mono text-[10px]" style={{ color: theme.colors.nodeSubtextColor }}>
|
||||||
{Icon && <Icon size={9} className="shrink-0" />}
|
{Icon && <Icon size={9} className="shrink-0" />}
|
||||||
<span className="truncate max-w-[60px] shrink-0" title={prop.key}>{prop.key}</span>
|
<span className="truncate max-w-[60px] shrink-0" title={prop.key}>{prop.key}</span>
|
||||||
<span className="truncate" title={prop.value}>· {prop.value}</span>
|
<span className="truncate" title={prop.value}>· {prop.value}</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user