mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-19 22:09:37 +00:00
Compare commits
3 Commits
range-edit
...
fix/highli
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c330529da8 | ||
|
|
50d339e2ba | ||
|
|
ed10909f38 |
76
src/composables/graph/useGraphErrorState.ts
Normal file
76
src/composables/graph/useGraphErrorState.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { watch } from 'vue'
|
||||||
|
|
||||||
|
import type { LGraphNode, Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||||
|
import { app } from '@/scripts/app'
|
||||||
|
import { useGraphErrorStateStore } from '@/stores/graphErrorStateStore'
|
||||||
|
import { parseNodeLocatorId } from '@/types/nodeIdentification'
|
||||||
|
import {
|
||||||
|
findSubgraphByUuid,
|
||||||
|
forEachNode,
|
||||||
|
forEachSubgraphNode
|
||||||
|
} from '@/utils/graphTraversalUtil'
|
||||||
|
|
||||||
|
export function useGraphErrorState(): void {
|
||||||
|
const store = useGraphErrorStateStore()
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => store.version,
|
||||||
|
() => {
|
||||||
|
const rootGraph = app.rootGraph
|
||||||
|
if (!rootGraph) return
|
||||||
|
|
||||||
|
forEachNode(rootGraph, (node) => {
|
||||||
|
node.has_errors = false
|
||||||
|
if (node.inputs) {
|
||||||
|
for (const slot of node.inputs) {
|
||||||
|
slot.hasErrors = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const [nodeId, keys] of store.keysByNode) {
|
||||||
|
if (keys.size === 0) continue
|
||||||
|
|
||||||
|
const parsed = parseNodeLocatorId(nodeId)
|
||||||
|
if (!parsed) continue
|
||||||
|
|
||||||
|
const targetGraph = parsed.subgraphUuid
|
||||||
|
? findSubgraphByUuid(rootGraph, parsed.subgraphUuid)
|
||||||
|
: rootGraph
|
||||||
|
if (!targetGraph) continue
|
||||||
|
|
||||||
|
const node = targetGraph.getNodeById(parsed.localNodeId)
|
||||||
|
if (!node) continue
|
||||||
|
|
||||||
|
node.has_errors = true
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
const error = store.errorsByKey.get(key)
|
||||||
|
if (error && error.target.kind === 'slot' && node.inputs) {
|
||||||
|
const slotName = error.target.slotName
|
||||||
|
const slot = node.inputs.find((s) => s.name === slotName)
|
||||||
|
if (slot) {
|
||||||
|
slot.hasErrors = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
propagateErrorToParents(node)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function propagateErrorToParents(node: LGraphNode): void {
|
||||||
|
const subgraph = node.graph as Subgraph | undefined
|
||||||
|
if (!subgraph || subgraph.isRootGraph) return
|
||||||
|
|
||||||
|
const subgraphId = subgraph.id
|
||||||
|
if (!subgraphId) return
|
||||||
|
|
||||||
|
forEachSubgraphNode(app.rootGraph, subgraphId, (subgraphNode) => {
|
||||||
|
subgraphNode.has_errors = true
|
||||||
|
propagateErrorToParents(subgraphNode)
|
||||||
|
})
|
||||||
|
}
|
||||||
122
src/composables/graph/useRequiredConnectionValidator.test.ts
Normal file
122
src/composables/graph/useRequiredConnectionValidator.test.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { useGraphErrorStateStore } from '@/stores/graphErrorStateStore'
|
||||||
|
|
||||||
|
vi.mock('@/scripts/api', () => ({
|
||||||
|
api: {
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn()
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/scripts/app', () => ({
|
||||||
|
app: {
|
||||||
|
rootGraph: null
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/stores/nodeDefStore', () => ({
|
||||||
|
useNodeDefStore: vi.fn(() => ({
|
||||||
|
nodeDefsByName: {}
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||||
|
forEachNode: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('useRequiredConnectionValidator', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('store integration', () => {
|
||||||
|
it('adds errors for missing required connections', () => {
|
||||||
|
const store = useGraphErrorStateStore()
|
||||||
|
|
||||||
|
store.execute({
|
||||||
|
type: 'REPLACE_SOURCE',
|
||||||
|
source: 'frontend',
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
key: 'frontend:missing:1:model',
|
||||||
|
source: 'frontend',
|
||||||
|
target: { kind: 'slot', nodeId: '1', slotName: 'model' },
|
||||||
|
code: 'MISSING_REQUIRED_INPUT'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(store.hasSlotError('1', 'model')).toBe(true)
|
||||||
|
expect(store.hasErrorsForNode('1')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears errors when connections are made', () => {
|
||||||
|
const store = useGraphErrorStateStore()
|
||||||
|
|
||||||
|
store.execute({
|
||||||
|
type: 'REPLACE_SOURCE',
|
||||||
|
source: 'frontend',
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
key: 'frontend:missing:1:model',
|
||||||
|
source: 'frontend',
|
||||||
|
target: { kind: 'slot', nodeId: '1', slotName: 'model' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(store.hasSlotError('1', 'model')).toBe(true)
|
||||||
|
|
||||||
|
store.execute({
|
||||||
|
type: 'REPLACE_SOURCE',
|
||||||
|
source: 'frontend',
|
||||||
|
errors: []
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(store.hasSlotError('1', 'model')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves backend errors when frontend errors change', () => {
|
||||||
|
const store = useGraphErrorStateStore()
|
||||||
|
|
||||||
|
store.execute({
|
||||||
|
type: 'REPLACE_SOURCE',
|
||||||
|
source: 'backend',
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
key: 'backend:node:2',
|
||||||
|
source: 'backend',
|
||||||
|
target: { kind: 'node', nodeId: '2' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
store.execute({
|
||||||
|
type: 'REPLACE_SOURCE',
|
||||||
|
source: 'frontend',
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
key: 'frontend:missing:1:model',
|
||||||
|
source: 'frontend',
|
||||||
|
target: { kind: 'slot', nodeId: '1', slotName: 'model' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(store.hasErrorsForNode('1')).toBe(true)
|
||||||
|
expect(store.hasErrorsForNode('2')).toBe(true)
|
||||||
|
|
||||||
|
store.execute({
|
||||||
|
type: 'REPLACE_SOURCE',
|
||||||
|
source: 'frontend',
|
||||||
|
errors: []
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(store.hasErrorsForNode('1')).toBe(false)
|
||||||
|
expect(store.hasErrorsForNode('2')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
74
src/composables/graph/useRequiredConnectionValidator.ts
Normal file
74
src/composables/graph/useRequiredConnectionValidator.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { useDebounceFn } from '@vueuse/core'
|
||||||
|
import { onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||||
|
import { api } from '@/scripts/api'
|
||||||
|
import { app } from '@/scripts/app'
|
||||||
|
import { useGraphErrorStateStore } from '@/stores/graphErrorStateStore'
|
||||||
|
import type { GraphError } from '@/stores/graphErrorStateStore'
|
||||||
|
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||||
|
import { createNodeLocatorId } from '@/types/nodeIdentification'
|
||||||
|
import { forEachNode } from '@/utils/graphTraversalUtil'
|
||||||
|
|
||||||
|
export function useRequiredConnectionValidator(): void {
|
||||||
|
const errorStore = useGraphErrorStateStore()
|
||||||
|
const nodeDefStore = useNodeDefStore()
|
||||||
|
|
||||||
|
function validate(): void {
|
||||||
|
const rootGraph = app.rootGraph
|
||||||
|
if (!rootGraph) return
|
||||||
|
|
||||||
|
const errors: GraphError[] = []
|
||||||
|
|
||||||
|
forEachNode(rootGraph, (node: LGraphNode) => {
|
||||||
|
const nodeDef = nodeDefStore.nodeDefsByName[node.type ?? '']
|
||||||
|
if (!nodeDef?.input?.required) return
|
||||||
|
|
||||||
|
const subgraphId =
|
||||||
|
node.graph && !node.graph.isRootGraph ? node.graph.id : null
|
||||||
|
const locatorId = subgraphId
|
||||||
|
? createNodeLocatorId(subgraphId, node.id)
|
||||||
|
: String(node.id)
|
||||||
|
|
||||||
|
for (const inputName of Object.keys(nodeDef.input.required)) {
|
||||||
|
const slot = node.inputs?.find((s) => s.name === inputName)
|
||||||
|
|
||||||
|
const hasConnection = slot?.link !== null && slot?.link !== undefined
|
||||||
|
const hasWidgetValue = hasWidgetValueForInput(node, inputName)
|
||||||
|
|
||||||
|
if (!hasConnection && !hasWidgetValue) {
|
||||||
|
errors.push({
|
||||||
|
key: `frontend:missing:${locatorId}:${inputName}`,
|
||||||
|
source: 'frontend',
|
||||||
|
target: { kind: 'slot', nodeId: locatorId, slotName: inputName },
|
||||||
|
code: 'MISSING_REQUIRED_INPUT'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
errorStore.execute({ type: 'REPLACE_SOURCE', source: 'frontend', errors })
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasWidgetValueForInput(
|
||||||
|
node: LGraphNode,
|
||||||
|
inputName: string
|
||||||
|
): boolean {
|
||||||
|
if (!node.widgets) return false
|
||||||
|
const widget = node.widgets.find((w) => w.name === inputName)
|
||||||
|
if (!widget) return false
|
||||||
|
return (
|
||||||
|
widget.value !== undefined && widget.value !== null && widget.value !== ''
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const debouncedValidate = useDebounceFn(validate, 200)
|
||||||
|
|
||||||
|
api.addEventListener('graphChanged', debouncedValidate)
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
api.removeEventListener('graphChanged', debouncedValidate)
|
||||||
|
})
|
||||||
|
|
||||||
|
validate()
|
||||||
|
}
|
||||||
@@ -29,10 +29,11 @@ import type {
|
|||||||
} from '@/schemas/apiSchema'
|
} from '@/schemas/apiSchema'
|
||||||
import { api } from '@/scripts/api'
|
import { api } from '@/scripts/api'
|
||||||
import { app } from '@/scripts/app'
|
import { app } from '@/scripts/app'
|
||||||
|
import { useGraphErrorStateStore } from '@/stores/graphErrorStateStore'
|
||||||
|
import type { GraphError } from '@/stores/graphErrorStateStore'
|
||||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||||
import type { NodeLocatorId } from '@/types/nodeIdentification'
|
import type { NodeLocatorId } from '@/types/nodeIdentification'
|
||||||
import { createNodeLocatorId } from '@/types/nodeIdentification'
|
import { createNodeLocatorId } from '@/types/nodeIdentification'
|
||||||
import { forEachNode, getNodeByExecutionId } from '@/utils/graphTraversalUtil'
|
|
||||||
|
|
||||||
interface QueuedPrompt {
|
interface QueuedPrompt {
|
||||||
/**
|
/**
|
||||||
@@ -584,59 +585,47 @@ export const useExecutionStore = defineStore('execution', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update node and slot error flags when validation errors change.
|
* Sync backend validation errors to centralized graph error store.
|
||||||
* Propagates errors up subgraph chains.
|
* The store handles flag updates and subgraph propagation.
|
||||||
*/
|
*/
|
||||||
watch(lastNodeErrors, () => {
|
watch(lastNodeErrors, () => {
|
||||||
if (!app.rootGraph) return
|
const errorStore = useGraphErrorStateStore()
|
||||||
|
|
||||||
// Clear all error flags
|
if (!lastNodeErrors.value) {
|
||||||
forEachNode(app.rootGraph, (node) => {
|
errorStore.execute({ type: 'CLEAR_SOURCE', source: 'backend' })
|
||||||
node.has_errors = false
|
return
|
||||||
if (node.inputs) {
|
}
|
||||||
for (const slot of node.inputs) {
|
|
||||||
slot.hasErrors = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!lastNodeErrors.value) return
|
const errors: GraphError[] = []
|
||||||
|
|
||||||
// Set error flags on nodes and slots
|
|
||||||
for (const [executionId, nodeError] of Object.entries(
|
for (const [executionId, nodeError] of Object.entries(
|
||||||
lastNodeErrors.value
|
lastNodeErrors.value
|
||||||
)) {
|
)) {
|
||||||
const node = getNodeByExecutionId(app.rootGraph, executionId)
|
const locatorId = executionIdToNodeLocatorId(executionId)
|
||||||
if (!node) continue
|
if (!locatorId) continue
|
||||||
|
|
||||||
node.has_errors = true
|
errors.push({
|
||||||
|
key: `backend:node:${locatorId}`,
|
||||||
|
source: 'backend',
|
||||||
|
target: { kind: 'node', nodeId: locatorId },
|
||||||
|
message: nodeError.errors[0]?.message
|
||||||
|
})
|
||||||
|
|
||||||
// Mark input slots with errors
|
for (const error of nodeError.errors) {
|
||||||
if (node.inputs) {
|
const slotName = error.extra_info?.input_name
|
||||||
for (const error of nodeError.errors) {
|
if (slotName) {
|
||||||
const slotName = error.extra_info?.input_name
|
errors.push({
|
||||||
if (!slotName) continue
|
key: `backend:slot:${locatorId}:${slotName}`,
|
||||||
|
source: 'backend',
|
||||||
const slot = node.inputs.find((s) => s.name === slotName)
|
target: { kind: 'slot', nodeId: locatorId, slotName },
|
||||||
if (slot) {
|
code: 'VALIDATION_ERROR',
|
||||||
slot.hasErrors = true
|
message: error.message
|
||||||
}
|
})
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Propagate errors to parent subgraph nodes
|
|
||||||
const parts = executionId.split(':')
|
|
||||||
for (let i = parts.length - 1; i > 0; i--) {
|
|
||||||
const parentExecutionId = parts.slice(0, i).join(':')
|
|
||||||
const parentNode = getNodeByExecutionId(
|
|
||||||
app.rootGraph,
|
|
||||||
parentExecutionId
|
|
||||||
)
|
|
||||||
if (parentNode) {
|
|
||||||
parentNode.has_errors = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
errorStore.execute({ type: 'REPLACE_SOURCE', source: 'backend', errors })
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
257
src/stores/graphErrorStateStore.test.ts
Normal file
257
src/stores/graphErrorStateStore.test.ts
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { beforeEach, describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { useGraphErrorStateStore } from './graphErrorStateStore'
|
||||||
|
import type { GraphError } from './graphErrorStateStore'
|
||||||
|
|
||||||
|
describe('graphErrorStateStore', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('REPLACE_SOURCE command', () => {
|
||||||
|
it('adds errors for a source', () => {
|
||||||
|
const store = useGraphErrorStateStore()
|
||||||
|
const errors: GraphError[] = [
|
||||||
|
{
|
||||||
|
key: 'frontend:slot:123:model',
|
||||||
|
source: 'frontend',
|
||||||
|
target: { kind: 'slot', nodeId: '123', slotName: 'model' },
|
||||||
|
code: 'MISSING_REQUIRED_INPUT'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
store.execute({ type: 'REPLACE_SOURCE', source: 'frontend', errors })
|
||||||
|
|
||||||
|
expect(store.hasErrorsForNode('123')).toBe(true)
|
||||||
|
expect(store.hasSlotError('123', 'model')).toBe(true)
|
||||||
|
expect(store.version).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('replaces all errors for a source', () => {
|
||||||
|
const store = useGraphErrorStateStore()
|
||||||
|
|
||||||
|
store.execute({
|
||||||
|
type: 'REPLACE_SOURCE',
|
||||||
|
source: 'frontend',
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
key: 'frontend:slot:1:a',
|
||||||
|
source: 'frontend',
|
||||||
|
target: { kind: 'slot', nodeId: '1', slotName: 'a' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'frontend:slot:2:b',
|
||||||
|
source: 'frontend',
|
||||||
|
target: { kind: 'slot', nodeId: '2', slotName: 'b' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(store.hasErrorsForNode('1')).toBe(true)
|
||||||
|
expect(store.hasErrorsForNode('2')).toBe(true)
|
||||||
|
|
||||||
|
store.execute({
|
||||||
|
type: 'REPLACE_SOURCE',
|
||||||
|
source: 'frontend',
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
key: 'frontend:slot:3:c',
|
||||||
|
source: 'frontend',
|
||||||
|
target: { kind: 'slot', nodeId: '3', slotName: 'c' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(store.hasErrorsForNode('1')).toBe(false)
|
||||||
|
expect(store.hasErrorsForNode('2')).toBe(false)
|
||||||
|
expect(store.hasErrorsForNode('3')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves errors from other sources', () => {
|
||||||
|
const store = useGraphErrorStateStore()
|
||||||
|
|
||||||
|
store.execute({
|
||||||
|
type: 'REPLACE_SOURCE',
|
||||||
|
source: 'backend',
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
key: 'backend:node:1',
|
||||||
|
source: 'backend',
|
||||||
|
target: { kind: 'node', nodeId: '1' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
store.execute({
|
||||||
|
type: 'REPLACE_SOURCE',
|
||||||
|
source: 'frontend',
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
key: 'frontend:slot:2:a',
|
||||||
|
source: 'frontend',
|
||||||
|
target: { kind: 'slot', nodeId: '2', slotName: 'a' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(store.hasErrorsForNode('1')).toBe(true)
|
||||||
|
expect(store.hasErrorsForNode('2')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('CLEAR_SOURCE command', () => {
|
||||||
|
it('clears errors for a source', () => {
|
||||||
|
const store = useGraphErrorStateStore()
|
||||||
|
|
||||||
|
store.execute({
|
||||||
|
type: 'REPLACE_SOURCE',
|
||||||
|
source: 'frontend',
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
key: 'frontend:slot:1:a',
|
||||||
|
source: 'frontend',
|
||||||
|
target: { kind: 'slot', nodeId: '1', slotName: 'a' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
store.execute({ type: 'CLEAR_SOURCE', source: 'frontend' })
|
||||||
|
|
||||||
|
expect(store.hasErrorsForNode('1')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves other sources', () => {
|
||||||
|
const store = useGraphErrorStateStore()
|
||||||
|
|
||||||
|
store.execute({
|
||||||
|
type: 'REPLACE_SOURCE',
|
||||||
|
source: 'backend',
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
key: 'backend:node:1',
|
||||||
|
source: 'backend',
|
||||||
|
target: { kind: 'node', nodeId: '1' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
store.execute({
|
||||||
|
type: 'REPLACE_SOURCE',
|
||||||
|
source: 'frontend',
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
key: 'frontend:slot:2:a',
|
||||||
|
source: 'frontend',
|
||||||
|
target: { kind: 'slot', nodeId: '2', slotName: 'a' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
store.execute({ type: 'CLEAR_SOURCE', source: 'frontend' })
|
||||||
|
|
||||||
|
expect(store.hasErrorsForNode('1')).toBe(true)
|
||||||
|
expect(store.hasErrorsForNode('2')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('CLEAR_ALL command', () => {
|
||||||
|
it('clears all errors', () => {
|
||||||
|
const store = useGraphErrorStateStore()
|
||||||
|
|
||||||
|
store.execute({
|
||||||
|
type: 'REPLACE_SOURCE',
|
||||||
|
source: 'backend',
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
key: 'backend:node:1',
|
||||||
|
source: 'backend',
|
||||||
|
target: { kind: 'node', nodeId: '1' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
store.execute({
|
||||||
|
type: 'REPLACE_SOURCE',
|
||||||
|
source: 'frontend',
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
key: 'frontend:slot:2:a',
|
||||||
|
source: 'frontend',
|
||||||
|
target: { kind: 'slot', nodeId: '2', slotName: 'a' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
store.execute({ type: 'CLEAR_ALL' })
|
||||||
|
|
||||||
|
expect(store.hasErrorsForNode('1')).toBe(false)
|
||||||
|
expect(store.hasErrorsForNode('2')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getErrorsForNode', () => {
|
||||||
|
it('returns all errors for a node', () => {
|
||||||
|
const store = useGraphErrorStateStore()
|
||||||
|
|
||||||
|
store.execute({
|
||||||
|
type: 'REPLACE_SOURCE',
|
||||||
|
source: 'frontend',
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
key: 'frontend:slot:1:a',
|
||||||
|
source: 'frontend',
|
||||||
|
target: { kind: 'slot', nodeId: '1', slotName: 'a' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'frontend:slot:1:b',
|
||||||
|
source: 'frontend',
|
||||||
|
target: { kind: 'slot', nodeId: '1', slotName: 'b' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const errors = store.getErrorsForNode('1')
|
||||||
|
expect(errors).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns empty array for node without errors', () => {
|
||||||
|
const store = useGraphErrorStateStore()
|
||||||
|
expect(store.getErrorsForNode('999')).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getSlotErrors', () => {
|
||||||
|
it('returns only slot errors for specific slot', () => {
|
||||||
|
const store = useGraphErrorStateStore()
|
||||||
|
|
||||||
|
store.execute({
|
||||||
|
type: 'REPLACE_SOURCE',
|
||||||
|
source: 'frontend',
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
key: 'frontend:node:1',
|
||||||
|
source: 'frontend',
|
||||||
|
target: { kind: 'node', nodeId: '1' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'frontend:slot:1:model',
|
||||||
|
source: 'frontend',
|
||||||
|
target: { kind: 'slot', nodeId: '1', slotName: 'model' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'frontend:slot:1:clip',
|
||||||
|
source: 'frontend',
|
||||||
|
target: { kind: 'slot', nodeId: '1', slotName: 'clip' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const slotErrors = store.getSlotErrors('1', 'model')
|
||||||
|
expect(slotErrors).toHaveLength(1)
|
||||||
|
expect(slotErrors[0].target).toEqual({
|
||||||
|
kind: 'slot',
|
||||||
|
nodeId: '1',
|
||||||
|
slotName: 'model'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
144
src/stores/graphErrorStateStore.ts
Normal file
144
src/stores/graphErrorStateStore.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { shallowRef } from 'vue'
|
||||||
|
|
||||||
|
import type { NodeLocatorId } from '@/types/nodeIdentification'
|
||||||
|
|
||||||
|
type GraphErrorSource = 'frontend' | 'backend'
|
||||||
|
|
||||||
|
type GraphErrorTarget =
|
||||||
|
| { kind: 'node'; nodeId: NodeLocatorId }
|
||||||
|
| { kind: 'slot'; nodeId: NodeLocatorId; slotName: string }
|
||||||
|
|
||||||
|
export interface GraphError {
|
||||||
|
key: string
|
||||||
|
source: GraphErrorSource
|
||||||
|
target: GraphErrorTarget
|
||||||
|
code?: string
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type GraphErrorCommand =
|
||||||
|
| { type: 'REPLACE_SOURCE'; source: GraphErrorSource; errors: GraphError[] }
|
||||||
|
| { type: 'CLEAR_SOURCE'; source: GraphErrorSource }
|
||||||
|
| { type: 'CLEAR_ALL' }
|
||||||
|
|
||||||
|
export const useGraphErrorStateStore = defineStore('graphErrorState', () => {
|
||||||
|
const errorsByKey = shallowRef(new Map<string, GraphError>())
|
||||||
|
const keysBySource = shallowRef(new Map<GraphErrorSource, Set<string>>())
|
||||||
|
const keysByNode = shallowRef(new Map<NodeLocatorId, Set<string>>())
|
||||||
|
const version = shallowRef(0)
|
||||||
|
|
||||||
|
function addErrorInternal(error: GraphError): void {
|
||||||
|
const newErrorsByKey = new Map(errorsByKey.value)
|
||||||
|
newErrorsByKey.set(error.key, error)
|
||||||
|
errorsByKey.value = newErrorsByKey
|
||||||
|
|
||||||
|
const newKeysBySource = new Map(keysBySource.value)
|
||||||
|
if (!newKeysBySource.has(error.source)) {
|
||||||
|
newKeysBySource.set(error.source, new Set())
|
||||||
|
}
|
||||||
|
newKeysBySource.get(error.source)!.add(error.key)
|
||||||
|
keysBySource.value = newKeysBySource
|
||||||
|
|
||||||
|
const nodeId = error.target.nodeId
|
||||||
|
const newKeysByNode = new Map(keysByNode.value)
|
||||||
|
if (!newKeysByNode.has(nodeId)) {
|
||||||
|
newKeysByNode.set(nodeId, new Set())
|
||||||
|
}
|
||||||
|
newKeysByNode.get(nodeId)!.add(error.key)
|
||||||
|
keysByNode.value = newKeysByNode
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSourceInternal(source: GraphErrorSource): void {
|
||||||
|
const keys = keysBySource.value.get(source)
|
||||||
|
if (!keys || keys.size === 0) return
|
||||||
|
|
||||||
|
const newErrorsByKey = new Map(errorsByKey.value)
|
||||||
|
const newKeysByNode = new Map(keysByNode.value)
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
const error = newErrorsByKey.get(key)
|
||||||
|
if (error) {
|
||||||
|
const nodeId = error.target.nodeId
|
||||||
|
const nodeKeys = newKeysByNode.get(nodeId)
|
||||||
|
if (nodeKeys) {
|
||||||
|
const newNodeKeys = new Set(nodeKeys)
|
||||||
|
newNodeKeys.delete(key)
|
||||||
|
if (newNodeKeys.size === 0) {
|
||||||
|
newKeysByNode.delete(nodeId)
|
||||||
|
} else {
|
||||||
|
newKeysByNode.set(nodeId, newNodeKeys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newErrorsByKey.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
errorsByKey.value = newErrorsByKey
|
||||||
|
keysByNode.value = newKeysByNode
|
||||||
|
|
||||||
|
const newKeysBySource = new Map(keysBySource.value)
|
||||||
|
newKeysBySource.delete(source)
|
||||||
|
keysBySource.value = newKeysBySource
|
||||||
|
}
|
||||||
|
|
||||||
|
function execute(command: GraphErrorCommand): void {
|
||||||
|
switch (command.type) {
|
||||||
|
case 'REPLACE_SOURCE': {
|
||||||
|
clearSourceInternal(command.source)
|
||||||
|
for (const error of command.errors) {
|
||||||
|
addErrorInternal(error)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'CLEAR_SOURCE': {
|
||||||
|
clearSourceInternal(command.source)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'CLEAR_ALL': {
|
||||||
|
errorsByKey.value = new Map()
|
||||||
|
keysBySource.value = new Map()
|
||||||
|
keysByNode.value = new Map()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
version.value++
|
||||||
|
}
|
||||||
|
|
||||||
|
function getErrorsForNode(nodeId: NodeLocatorId): GraphError[] {
|
||||||
|
const keys = keysByNode.value.get(nodeId)
|
||||||
|
if (!keys) return []
|
||||||
|
return [...keys]
|
||||||
|
.map((k) => errorsByKey.value.get(k))
|
||||||
|
.filter((e): e is GraphError => e !== undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasErrorsForNode(nodeId: NodeLocatorId): boolean {
|
||||||
|
const keys = keysByNode.value.get(nodeId)
|
||||||
|
return keys !== undefined && keys.size > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSlotErrors(
|
||||||
|
nodeId: NodeLocatorId,
|
||||||
|
slotName: string
|
||||||
|
): GraphError[] {
|
||||||
|
return getErrorsForNode(nodeId).filter(
|
||||||
|
(e) => e.target.kind === 'slot' && e.target.slotName === slotName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasSlotError(nodeId: NodeLocatorId, slotName: string): boolean {
|
||||||
|
return getSlotErrors(nodeId, slotName).length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
version,
|
||||||
|
errorsByKey,
|
||||||
|
keysByNode,
|
||||||
|
execute,
|
||||||
|
getErrorsForNode,
|
||||||
|
hasErrorsForNode,
|
||||||
|
getSlotErrors,
|
||||||
|
hasSlotError
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -45,6 +45,8 @@ import UnloadWindowConfirmDialog from '@/components/dialog/UnloadWindowConfirmDi
|
|||||||
import GraphCanvas from '@/components/graph/GraphCanvas.vue'
|
import GraphCanvas from '@/components/graph/GraphCanvas.vue'
|
||||||
import GlobalToast from '@/components/toast/GlobalToast.vue'
|
import GlobalToast from '@/components/toast/GlobalToast.vue'
|
||||||
import RerouteMigrationToast from '@/components/toast/RerouteMigrationToast.vue'
|
import RerouteMigrationToast from '@/components/toast/RerouteMigrationToast.vue'
|
||||||
|
import { useGraphErrorState } from '@/composables/graph/useGraphErrorState'
|
||||||
|
import { useRequiredConnectionValidator } from '@/composables/graph/useRequiredConnectionValidator'
|
||||||
import { useBrowserTabTitle } from '@/composables/useBrowserTabTitle'
|
import { useBrowserTabTitle } from '@/composables/useBrowserTabTitle'
|
||||||
import { useCoreCommands } from '@/composables/useCoreCommands'
|
import { useCoreCommands } from '@/composables/useCoreCommands'
|
||||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||||
@@ -85,6 +87,8 @@ import ManagerProgressToast from '@/workbench/extensions/manager/components/Mana
|
|||||||
setupAutoQueueHandler()
|
setupAutoQueueHandler()
|
||||||
useProgressFavicon()
|
useProgressFavicon()
|
||||||
useBrowserTabTitle()
|
useBrowserTabTitle()
|
||||||
|
useGraphErrorState()
|
||||||
|
useRequiredConnectionValidator()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|||||||
Reference in New Issue
Block a user