mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-01 03:31:58 +00:00
feat: Add Vue node subgraph title button and fix subgraph navigation with vue nodes (#5572)
## Summary - Adds subgraph title button to Vue node headers (matching LiteGraph behavior) - Fixes Vue node lifecycle issues during subgraph navigation and tab switching - Extracts reusable `useSubgraphNavigation` composable with callback-based API - Adds comprehensive tests for subgraph functionality - Ensures proper graph context restoration during tab switches https://github.com/user-attachments/assets/fd4ff16a-4071-4da6-903f-b2be8dd6e672 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-5572-feat-Add-Vue-node-subgraph-title-button-with-lifecycle-management-26f6d73d365081bfbd9cfd7d2775e1ef) by [Unito](https://www.unito.io) --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: DrJKL <DrJKL@users.noreply.github.com>
This commit is contained in:
@@ -76,6 +76,7 @@
|
|||||||
import { useEventListener, whenever } from '@vueuse/core'
|
import { useEventListener, whenever } from '@vueuse/core'
|
||||||
import {
|
import {
|
||||||
computed,
|
computed,
|
||||||
|
nextTick,
|
||||||
onMounted,
|
onMounted,
|
||||||
onUnmounted,
|
onUnmounted,
|
||||||
provide,
|
provide,
|
||||||
@@ -182,6 +183,26 @@ const viewportCulling = useViewportCulling(
|
|||||||
)
|
)
|
||||||
const nodeEventHandlers = useNodeEventHandlers(vueNodeLifecycle.nodeManager)
|
const nodeEventHandlers = useNodeEventHandlers(vueNodeLifecycle.nodeManager)
|
||||||
|
|
||||||
|
const handleVueNodeLifecycleReset = async () => {
|
||||||
|
if (isVueNodesEnabled.value) {
|
||||||
|
vueNodeLifecycle.disposeNodeManagerAndSyncs()
|
||||||
|
await nextTick()
|
||||||
|
vueNodeLifecycle.initializeNodeManager()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => canvasStore.currentGraph, handleVueNodeLifecycleReset)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => canvasStore.isInSubgraph,
|
||||||
|
async (newValue, oldValue) => {
|
||||||
|
if (oldValue && !newValue) {
|
||||||
|
useWorkflowStore().updateActiveGraph()
|
||||||
|
}
|
||||||
|
await handleVueNodeLifecycleReset()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
const nodePositions = vueNodeLifecycle.nodePositions
|
const nodePositions = vueNodeLifecycle.nodePositions
|
||||||
const nodeSizes = vueNodeLifecycle.nodeSizes
|
const nodeSizes = vueNodeLifecycle.nodeSizes
|
||||||
const allNodes = viewportCulling.allNodes
|
const allNodes = viewportCulling.allNodes
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { whenever } from '@vueuse/core'
|
import { computed, watch } from 'vue'
|
||||||
import { computed } from 'vue'
|
|
||||||
|
|
||||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
@@ -39,7 +38,12 @@ export const useCurrentUser = () => {
|
|||||||
callback(resolvedUserInfo.value)
|
callback(resolvedUserInfo.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const stop = whenever(resolvedUserInfo, callback)
|
const stop = watch(resolvedUserInfo, (value) => {
|
||||||
|
if (value) {
|
||||||
|
callback(value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return () => stop()
|
return () => stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,10 +57,12 @@ export function useVueNodeLifecycle(isVueNodesEnabled: Ref<boolean>) {
|
|||||||
const isNodeManagerReady = computed(() => nodeManager.value !== null)
|
const isNodeManagerReady = computed(() => nodeManager.value !== null)
|
||||||
|
|
||||||
const initializeNodeManager = () => {
|
const initializeNodeManager = () => {
|
||||||
if (!comfyApp.graph || nodeManager.value) return
|
// Use canvas graph if available (handles subgraph contexts), fallback to app graph
|
||||||
|
const activeGraph = comfyApp.canvas?.graph || comfyApp.graph
|
||||||
|
if (!activeGraph || nodeManager.value) return
|
||||||
|
|
||||||
// Initialize the core node manager
|
// Initialize the core node manager
|
||||||
const manager = useGraphNodeManager(comfyApp.graph)
|
const manager = useGraphNodeManager(activeGraph)
|
||||||
nodeManager.value = manager
|
nodeManager.value = manager
|
||||||
cleanupNodeManager.value = manager.cleanup
|
cleanupNodeManager.value = manager.cleanup
|
||||||
|
|
||||||
@@ -71,8 +73,8 @@ export function useVueNodeLifecycle(isVueNodesEnabled: Ref<boolean>) {
|
|||||||
nodeSizes.value = manager.nodeSizes
|
nodeSizes.value = manager.nodeSizes
|
||||||
detectChangesInRAF.value = manager.detectChangesInRAF
|
detectChangesInRAF.value = manager.detectChangesInRAF
|
||||||
|
|
||||||
// Initialize layout system with existing nodes
|
// Initialize layout system with existing nodes from active graph
|
||||||
const nodes = comfyApp.graph._nodes.map((node: LGraphNode) => ({
|
const nodes = activeGraph._nodes.map((node: LGraphNode) => ({
|
||||||
id: node.id.toString(),
|
id: node.id.toString(),
|
||||||
pos: [node.pos[0], node.pos[1]] as [number, number],
|
pos: [node.pos[0], node.pos[1]] as [number, number],
|
||||||
size: [node.size[0], node.size[1]] as [number, number]
|
size: [node.size[0], node.size[1]] as [number, number]
|
||||||
@@ -80,7 +82,7 @@ export function useVueNodeLifecycle(isVueNodesEnabled: Ref<boolean>) {
|
|||||||
layoutStore.initializeFromLiteGraph(nodes)
|
layoutStore.initializeFromLiteGraph(nodes)
|
||||||
|
|
||||||
// Seed reroutes into the Layout Store so hit-testing uses the new path
|
// Seed reroutes into the Layout Store so hit-testing uses the new path
|
||||||
for (const reroute of comfyApp.graph.reroutes.values()) {
|
for (const reroute of activeGraph.reroutes.values()) {
|
||||||
const [x, y] = reroute.pos
|
const [x, y] = reroute.pos
|
||||||
const parent = reroute.parentId ?? undefined
|
const parent = reroute.parentId ?? undefined
|
||||||
const linkIds = Array.from(reroute.linkIds)
|
const linkIds = Array.from(reroute.linkIds)
|
||||||
@@ -88,7 +90,7 @@ export function useVueNodeLifecycle(isVueNodesEnabled: Ref<boolean>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Seed existing links into the Layout Store (topology only)
|
// Seed existing links into the Layout Store (topology only)
|
||||||
for (const link of comfyApp.graph._links.values()) {
|
for (const link of activeGraph._links.values()) {
|
||||||
layoutMutations.createLink(
|
layoutMutations.createLink(
|
||||||
link.id,
|
link.id,
|
||||||
link.origin_id,
|
link.origin_id,
|
||||||
@@ -142,7 +144,9 @@ export function useVueNodeLifecycle(isVueNodesEnabled: Ref<boolean>) {
|
|||||||
|
|
||||||
// Watch for Vue nodes enabled state changes
|
// Watch for Vue nodes enabled state changes
|
||||||
watch(
|
watch(
|
||||||
() => isVueNodesEnabled.value && Boolean(comfyApp.graph),
|
() =>
|
||||||
|
isVueNodesEnabled.value &&
|
||||||
|
Boolean(comfyApp.canvas?.graph || comfyApp.graph),
|
||||||
(enabled) => {
|
(enabled) => {
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
initializeNodeManager()
|
initializeNodeManager()
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import { useEventListener, whenever } from '@vueuse/core'
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { type Raw, computed, markRaw, ref, shallowRef } from 'vue'
|
import { type Raw, computed, markRaw, ref, shallowRef } from 'vue'
|
||||||
|
|
||||||
import type { Point, Positionable } from '@/lib/litegraph/src/interfaces'
|
import type { Point, Positionable } from '@/lib/litegraph/src/interfaces'
|
||||||
import type {
|
import type {
|
||||||
|
LGraph,
|
||||||
LGraphCanvas,
|
LGraphCanvas,
|
||||||
LGraphGroup,
|
LGraphGroup,
|
||||||
LGraphNode
|
LGraphNode
|
||||||
@@ -94,6 +96,29 @@ export const useCanvasStore = defineStore('canvas', () => {
|
|||||||
appScalePercentage.value = Math.round(newScale * 100)
|
appScalePercentage.value = Math.round(newScale * 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentGraph = shallowRef<LGraph | null>(null)
|
||||||
|
const isInSubgraph = ref(false)
|
||||||
|
|
||||||
|
whenever(
|
||||||
|
() => canvas.value,
|
||||||
|
(newCanvas) => {
|
||||||
|
useEventListener(
|
||||||
|
newCanvas.canvas,
|
||||||
|
'litegraph:set-graph',
|
||||||
|
(event: CustomEvent<{ newGraph: LGraph; oldGraph: LGraph }>) => {
|
||||||
|
const newGraph = event.detail?.newGraph || app.canvas?.graph
|
||||||
|
currentGraph.value = newGraph
|
||||||
|
isInSubgraph.value = Boolean(app.canvas?.subgraph)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
useEventListener(newCanvas.canvas, 'subgraph-opened', () => {
|
||||||
|
isInSubgraph.value = true
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
canvas,
|
canvas,
|
||||||
selectedItems,
|
selectedItems,
|
||||||
@@ -105,6 +130,8 @@ export const useCanvasStore = defineStore('canvas', () => {
|
|||||||
getCanvas,
|
getCanvas,
|
||||||
setAppZoomFromPercentage,
|
setAppZoomFromPercentage,
|
||||||
initScaleSync,
|
initScaleSync,
|
||||||
cleanupScaleSync
|
cleanupScaleSync,
|
||||||
|
currentGraph,
|
||||||
|
isInSubgraph
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -55,6 +55,7 @@
|
|||||||
:collapsed="isCollapsed"
|
:collapsed="isCollapsed"
|
||||||
@collapse="handleCollapse"
|
@collapse="handleCollapse"
|
||||||
@update:title="handleTitleUpdate"
|
@update:title="handleTitleUpdate"
|
||||||
|
@enter-subgraph="handleEnterSubgraph"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -164,7 +165,10 @@ import type { ExecutedWsMessage } from '@/schemas/apiSchema'
|
|||||||
import { app } from '@/scripts/app'
|
import { app } from '@/scripts/app'
|
||||||
import { useExecutionStore } from '@/stores/executionStore'
|
import { useExecutionStore } from '@/stores/executionStore'
|
||||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||||
import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
|
import {
|
||||||
|
getLocatorIdFromNodeData,
|
||||||
|
getNodeByLocatorId
|
||||||
|
} from '@/utils/graphTraversalUtil'
|
||||||
import { cn } from '@/utils/tailwindUtil'
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
import { useVueElementTracking } from '../composables/useVueNodeResizeTracking'
|
import { useVueElementTracking } from '../composables/useVueNodeResizeTracking'
|
||||||
@@ -453,14 +457,36 @@ const handleTitleUpdate = (newTitle: string) => {
|
|||||||
emit('update:title', nodeData.id, newTitle)
|
emit('update:title', nodeData.id, newTitle)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleEnterSubgraph = () => {
|
||||||
|
const graph = app.graph?.rootGraph || app.graph
|
||||||
|
if (!graph) {
|
||||||
|
console.warn('LGraphNode: No graph available for subgraph navigation')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const locatorId = getLocatorIdFromNodeData(nodeData)
|
||||||
|
|
||||||
|
const litegraphNode = getNodeByLocatorId(graph, locatorId)
|
||||||
|
|
||||||
|
if (!litegraphNode?.isSubgraphNode() || !('subgraph' in litegraphNode)) {
|
||||||
|
console.warn('LGraphNode: Node is not a valid subgraph node', litegraphNode)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = app.canvas
|
||||||
|
if (!canvas || typeof canvas.openSubgraph !== 'function') {
|
||||||
|
console.warn('LGraphNode: Canvas or openSubgraph method not available')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.openSubgraph(litegraphNode.subgraph)
|
||||||
|
}
|
||||||
|
|
||||||
const nodeOutputs = useNodeOutputStore()
|
const nodeOutputs = useNodeOutputStore()
|
||||||
|
|
||||||
const nodeImageUrls = ref<string[]>([])
|
const nodeImageUrls = ref<string[]>([])
|
||||||
const onNodeOutputsUpdate = (newOutputs: ExecutedWsMessage['output']) => {
|
const onNodeOutputsUpdate = (newOutputs: ExecutedWsMessage['output']) => {
|
||||||
// Construct proper locator ID using subgraph ID from VueNodeData
|
const locatorId = getLocatorIdFromNodeData(nodeData)
|
||||||
const locatorId = nodeData.subgraphId
|
|
||||||
? `${nodeData.subgraphId}:${nodeData.id}`
|
|
||||||
: nodeData.id
|
|
||||||
|
|
||||||
// Use root graph for getNodeByLocatorId since it needs to traverse from root
|
// Use root graph for getNodeByLocatorId since it needs to traverse from root
|
||||||
const rootGraph = app.graph?.rootGraph || app.graph
|
const rootGraph = app.graph?.rootGraph || app.graph
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
class="lg-node-header flex items-center justify-between p-4 rounded-t-2xl cursor-move"
|
class="lg-node-header flex items-center justify-between p-4 rounded-t-2xl cursor-move w-full"
|
||||||
:data-testid="`node-header-${nodeData?.id || ''}`"
|
:data-testid="`node-header-${nodeData?.id || ''}`"
|
||||||
@dblclick="handleDoubleClick"
|
@dblclick="handleDoubleClick"
|
||||||
>
|
>
|
||||||
@@ -36,17 +36,39 @@
|
|||||||
@cancel="handleTitleCancel"
|
@cancel="handleTitleCancel"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Title Buttons -->
|
||||||
|
<div v-if="!readonly" class="flex items-center">
|
||||||
|
<IconButton
|
||||||
|
v-if="isSubgraphNode"
|
||||||
|
size="sm"
|
||||||
|
type="transparent"
|
||||||
|
class="text-stone-200 dark-theme:text-slate-300"
|
||||||
|
data-testid="subgraph-enter-button"
|
||||||
|
title="Enter Subgraph"
|
||||||
|
@click.stop="handleEnterSubgraph"
|
||||||
|
@dblclick.stop
|
||||||
|
>
|
||||||
|
<i class="pi pi-external-link"></i>
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { type Ref, computed, inject, onErrorCaptured, ref, watch } from 'vue'
|
import { type Ref, computed, inject, onErrorCaptured, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import IconButton from '@/components/button/IconButton.vue'
|
||||||
import EditableText from '@/components/common/EditableText.vue'
|
import EditableText from '@/components/common/EditableText.vue'
|
||||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||||
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
|
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
|
||||||
import type { LODLevel } from '@/renderer/extensions/vueNodes/lod/useLOD'
|
import type { LODLevel } from '@/renderer/extensions/vueNodes/lod/useLOD'
|
||||||
|
import { app } from '@/scripts/app'
|
||||||
|
import {
|
||||||
|
getLocatorIdFromNodeData,
|
||||||
|
getNodeByLocatorId
|
||||||
|
} from '@/utils/graphTraversalUtil'
|
||||||
|
|
||||||
interface NodeHeaderProps {
|
interface NodeHeaderProps {
|
||||||
nodeData?: VueNodeData
|
nodeData?: VueNodeData
|
||||||
@@ -60,6 +82,7 @@ const { nodeData, readonly, collapsed } = defineProps<NodeHeaderProps>()
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
collapse: []
|
collapse: []
|
||||||
'update:title': [newTitle: string]
|
'update:title': [newTitle: string]
|
||||||
|
'enter-subgraph': []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// Error boundary implementation
|
// Error boundary implementation
|
||||||
@@ -111,6 +134,22 @@ watch(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Subgraph detection
|
||||||
|
const isSubgraphNode = computed(() => {
|
||||||
|
if (!nodeData?.id) return false
|
||||||
|
|
||||||
|
// Get the underlying LiteGraph node
|
||||||
|
const graph = app.graph?.rootGraph || app.graph
|
||||||
|
if (!graph) return false
|
||||||
|
|
||||||
|
const locatorId = getLocatorIdFromNodeData(nodeData)
|
||||||
|
|
||||||
|
const litegraphNode = getNodeByLocatorId(graph, locatorId)
|
||||||
|
|
||||||
|
// Use the official type guard method
|
||||||
|
return litegraphNode?.isSubgraphNode() ?? false
|
||||||
|
})
|
||||||
|
|
||||||
// Event handlers
|
// Event handlers
|
||||||
const handleCollapse = () => {
|
const handleCollapse = () => {
|
||||||
emit('collapse')
|
emit('collapse')
|
||||||
@@ -134,4 +173,8 @@ const handleTitleEdit = (newTitle: string) => {
|
|||||||
const handleTitleCancel = () => {
|
const handleTitleCancel = () => {
|
||||||
isEditing.value = false
|
isEditing.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleEnterSubgraph = () => {
|
||||||
|
emit('enter-subgraph')
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -8,6 +8,23 @@ import { parseNodeLocatorId } from '@/types/nodeIdentification'
|
|||||||
|
|
||||||
import { isSubgraphIoNode } from './typeGuardUtil'
|
import { isSubgraphIoNode } from './typeGuardUtil'
|
||||||
|
|
||||||
|
interface NodeWithId {
|
||||||
|
id: string | number
|
||||||
|
subgraphId?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a locator ID from node data with optional subgraph context.
|
||||||
|
*
|
||||||
|
* @param nodeData - Node data containing id and optional subgraphId
|
||||||
|
* @returns The locator ID string
|
||||||
|
*/
|
||||||
|
export function getLocatorIdFromNodeData(nodeData: NodeWithId): string {
|
||||||
|
return nodeData.subgraphId
|
||||||
|
? `${nodeData.subgraphId}:${String(nodeData.id)}`
|
||||||
|
: String(nodeData.id)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses an execution ID into its component parts.
|
* Parses an execution ID into its component parts.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
/**
|
||||||
|
* Tests for NodeHeader subgraph functionality
|
||||||
|
*/
|
||||||
|
import { createTestingPinia } from '@pinia/testing'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||||
|
import NodeHeader from '@/renderer/extensions/vueNodes/components/NodeHeader.vue'
|
||||||
|
import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
vi.mock('@/scripts/app', () => ({
|
||||||
|
app: {
|
||||||
|
graph: null as any
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||||
|
getNodeByLocatorId: vi.fn(),
|
||||||
|
getLocatorIdFromNodeData: vi.fn((nodeData) =>
|
||||||
|
nodeData.subgraphId
|
||||||
|
? `${nodeData.subgraphId}:${String(nodeData.id)}`
|
||||||
|
: String(nodeData.id)
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/useErrorHandling', () => ({
|
||||||
|
useErrorHandling: () => ({
|
||||||
|
toastErrorHandler: vi.fn()
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('vue-i18n', () => ({
|
||||||
|
useI18n: () => ({
|
||||||
|
t: vi.fn((key) => key)
|
||||||
|
}),
|
||||||
|
createI18n: vi.fn(() => ({
|
||||||
|
global: {
|
||||||
|
t: vi.fn((key) => key)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/i18n', () => ({
|
||||||
|
st: vi.fn((key) => key),
|
||||||
|
t: vi.fn((key) => key),
|
||||||
|
i18n: {
|
||||||
|
global: {
|
||||||
|
t: vi.fn((key) => key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('NodeHeader - Subgraph Functionality', () => {
|
||||||
|
// Helper to setup common mocks
|
||||||
|
const setupMocks = async (isSubgraph = true, hasGraph = true) => {
|
||||||
|
const { app } = await import('@/scripts/app')
|
||||||
|
|
||||||
|
if (hasGraph) {
|
||||||
|
;(app as any).graph = { rootGraph: {} }
|
||||||
|
} else {
|
||||||
|
;(app as any).graph = null
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mocked(getNodeByLocatorId).mockReturnValue({
|
||||||
|
isSubgraphNode: () => isSubgraph
|
||||||
|
} as any)
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
const createMockNodeData = (
|
||||||
|
id: string,
|
||||||
|
subgraphId?: string
|
||||||
|
): VueNodeData => ({
|
||||||
|
id,
|
||||||
|
title: 'Test Node',
|
||||||
|
type: 'TestNode',
|
||||||
|
mode: 0,
|
||||||
|
selected: false,
|
||||||
|
executing: false,
|
||||||
|
subgraphId,
|
||||||
|
widgets: [],
|
||||||
|
inputs: [],
|
||||||
|
outputs: [],
|
||||||
|
hasErrors: false,
|
||||||
|
flags: {}
|
||||||
|
})
|
||||||
|
|
||||||
|
const createWrapper = (props = {}) => {
|
||||||
|
return mount(NodeHeader, {
|
||||||
|
props,
|
||||||
|
global: {
|
||||||
|
plugins: [createTestingPinia({ createSpy: vi.fn })],
|
||||||
|
mocks: {
|
||||||
|
$t: vi.fn((key: string) => key),
|
||||||
|
$primevue: { config: {} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
it('should show subgraph button for subgraph nodes', async () => {
|
||||||
|
await setupMocks(true) // isSubgraph = true
|
||||||
|
|
||||||
|
const wrapper = createWrapper({
|
||||||
|
nodeData: createMockNodeData('test-node-1'),
|
||||||
|
readonly: false
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
const subgraphButton = wrapper.find('[data-testid="subgraph-enter-button"]')
|
||||||
|
expect(subgraphButton.exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not show subgraph button for regular nodes', async () => {
|
||||||
|
await setupMocks(false) // isSubgraph = false
|
||||||
|
|
||||||
|
const wrapper = createWrapper({
|
||||||
|
nodeData: createMockNodeData('test-node-1'),
|
||||||
|
readonly: false
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
const subgraphButton = wrapper.find('[data-testid="subgraph-enter-button"]')
|
||||||
|
expect(subgraphButton.exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not show subgraph button in readonly mode', async () => {
|
||||||
|
await setupMocks(true) // isSubgraph = true
|
||||||
|
|
||||||
|
const wrapper = createWrapper({
|
||||||
|
nodeData: createMockNodeData('test-node-1'),
|
||||||
|
readonly: true
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
const subgraphButton = wrapper.find('[data-testid="subgraph-enter-button"]')
|
||||||
|
expect(subgraphButton.exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should emit enter-subgraph event when button is clicked', async () => {
|
||||||
|
await setupMocks(true) // isSubgraph = true
|
||||||
|
|
||||||
|
const wrapper = createWrapper({
|
||||||
|
nodeData: createMockNodeData('test-node-1'),
|
||||||
|
readonly: false
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
const subgraphButton = wrapper.find('[data-testid="subgraph-enter-button"]')
|
||||||
|
await subgraphButton.trigger('click')
|
||||||
|
|
||||||
|
expect(wrapper.emitted('enter-subgraph')).toBeTruthy()
|
||||||
|
expect(wrapper.emitted('enter-subgraph')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle subgraph context correctly', async () => {
|
||||||
|
await setupMocks(true) // isSubgraph = true
|
||||||
|
|
||||||
|
const wrapper = createWrapper({
|
||||||
|
nodeData: createMockNodeData('test-node-1', 'subgraph-id'),
|
||||||
|
readonly: false
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
// Should call getNodeByLocatorId with correct locator ID
|
||||||
|
expect(vi.mocked(getNodeByLocatorId)).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
'subgraph-id:test-node-1'
|
||||||
|
)
|
||||||
|
|
||||||
|
const subgraphButton = wrapper.find('[data-testid="subgraph-enter-button"]')
|
||||||
|
expect(subgraphButton.exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle missing graph gracefully', async () => {
|
||||||
|
await setupMocks(true, false) // isSubgraph = true, hasGraph = false
|
||||||
|
|
||||||
|
const wrapper = createWrapper({
|
||||||
|
nodeData: createMockNodeData('test-node-1'),
|
||||||
|
readonly: false
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
const subgraphButton = wrapper.find('[data-testid="subgraph-enter-button"]')
|
||||||
|
expect(subgraphButton.exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should prevent event propagation on double click', async () => {
|
||||||
|
await setupMocks(true) // isSubgraph = true
|
||||||
|
|
||||||
|
const wrapper = createWrapper({
|
||||||
|
nodeData: createMockNodeData('test-node-1'),
|
||||||
|
readonly: false
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
const subgraphButton = wrapper.find('[data-testid="subgraph-enter-button"]')
|
||||||
|
|
||||||
|
// Mock event object
|
||||||
|
const mockEvent = {
|
||||||
|
stopPropagation: vi.fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger dblclick event
|
||||||
|
await subgraphButton.trigger('dblclick', mockEvent)
|
||||||
|
|
||||||
|
// Should prevent propagation (handled by @dblclick.stop directive)
|
||||||
|
// This is tested by ensuring the component doesn't error and renders correctly
|
||||||
|
expect(subgraphButton.exists()).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user