mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 06:20:11 +00:00
## Summary Resolves six open issues by reorganizing node replacement components into a domain-driven folder structure, refactoring event handling to follow the emit pattern, and adding comprehensive test coverage across all affected modules. ## Changes - **What**: - Moved `SwapNodeGroupRow.vue` and `SwapNodesCard.vue` from `src/components/rightSidePanel/errors/` to `src/platform/nodeReplacement/components/` (Issues #9255) - Moved `useMissingNodeScan.ts` from `src/composables/` to `src/platform/nodeReplacement/missingNodeScan.ts`, renamed to reflect it is a plain function not a Vue composable (Issues #9254) - Refactored `SwapNodeGroupRow.vue` to emit a `'replace'` event instead of calling `useNodeReplacement()` and `useExecutionErrorStore()` directly; replacement logic now handled in `TabErrors.vue` (Issue #9267) - Added unit tests for `removeMissingNodesByType` (`executionErrorStore.test.ts`), `scanMissingNodes` (`missingNodeScan.test.ts`), and `swapNodeGroups` computed (`swapNodeGroups.test.ts`, `useErrorGroups.test.ts`) (Issue #9270) - Added placeholder detection tests covering unregistered-type detection when `has_errors` is false, and exclusion of registered types (`useNodeReplacement.test.ts`) (Issue #9271) - Added component tests for `MissingNodeCard` and `MissingPackGroupRow` covering rendering, expand/collapse, events, install states, and edge cases (Issue #9231) - Added component tests for `SwapNodeGroupRow` and `SwapNodesCard` (Issues #9255, #9267) ## Additional Changes (Post-Review) - **Edge case guard in placeholder detection** (`useNodeReplacement.ts`): When `last_serialization.type` is absent (old serialization format), the predicate falls back to `n.type`, which the app may have already run through `sanitizeNodeName` — stripping HTML special characters (`& < > " ' \` =`). In that case, a `Set.has()` lookup against the original unsanitized type name would silently miss, causing replacement to be skipped. Fixed by including sanitized variants of each target type in the `targetTypes` Set at construction time. For the overwhelmingly common case (no special characters in type names), the Set deduplicates the entries and runtime behavior is identical to before. A regression test was added to cover the specific scenario: `last_serialization.type` absent + live `n.type` already sanitized. ## Review Focus - `TabErrors.vue`: confirm the new `@replace` event handler correctly replaces nodes and removes them from missing nodes list (mirrors the old inline logic in `SwapNodeGroupRow`) - `missingNodeScan.ts`: filename/export name change from `useMissingNodeScan` — verify all call sites updated via `app.ts` - Test mocking strategy: module-level `vi.mock()` factories use closures over `ref`/plain objects to allow per-test overrides without global mutable state - Fixes #9231 - Fixes #9254 - Fixes #9255 - Fixes #9267 - Fixes #9270 - Fixes #9271
295 lines
10 KiB
Vue
295 lines
10 KiB
Vue
<template>
|
|
<div class="flex flex-col h-full min-w-0">
|
|
<!-- Search bar -->
|
|
<div
|
|
class="px-4 pt-1 pb-4 flex gap-2 border-b border-interface-stroke shrink-0 min-w-0"
|
|
>
|
|
<FormSearchInput v-model="searchQuery" />
|
|
</div>
|
|
|
|
<!-- Scrollable content -->
|
|
<div class="flex-1 overflow-y-auto min-w-0">
|
|
<div
|
|
v-if="filteredGroups.length === 0"
|
|
class="text-sm text-muted-foreground px-4 text-center pt-5 pb-15"
|
|
>
|
|
{{
|
|
searchQuery.trim()
|
|
? t('rightSidePanel.noneSearchDesc')
|
|
: t('rightSidePanel.noErrors')
|
|
}}
|
|
</div>
|
|
|
|
<div v-else>
|
|
<!-- Group by Class Type -->
|
|
<PropertiesAccordionItem
|
|
v-for="group in filteredGroups"
|
|
:key="group.title"
|
|
:collapse="collapseState[group.title] ?? false"
|
|
class="border-b border-interface-stroke"
|
|
:size="
|
|
group.type === 'missing_node' || group.type === 'swap_nodes'
|
|
? 'lg'
|
|
: 'default'
|
|
"
|
|
@update:collapse="collapseState[group.title] = $event"
|
|
>
|
|
<template #label>
|
|
<div class="flex items-center gap-2 flex-1 min-w-0">
|
|
<span class="flex-1 flex items-center gap-2 min-w-0">
|
|
<i
|
|
class="icon-[lucide--octagon-alert] size-4 text-destructive-background-hover shrink-0"
|
|
/>
|
|
<span class="text-destructive-background-hover truncate">
|
|
{{
|
|
group.type === 'missing_node'
|
|
? `${group.title} (${missingPackGroups.length})`
|
|
: group.type === 'swap_nodes'
|
|
? `${group.title} (${swapNodeGroups.length})`
|
|
: group.title
|
|
}}
|
|
</span>
|
|
<span
|
|
v-if="group.type === 'execution' && group.cards.length > 1"
|
|
class="text-destructive-background-hover"
|
|
>
|
|
({{ group.cards.length }})
|
|
</span>
|
|
</span>
|
|
<Button
|
|
v-if="
|
|
group.type === 'missing_node' &&
|
|
missingNodePacks.length > 0 &&
|
|
shouldShowInstallButton
|
|
"
|
|
variant="secondary"
|
|
size="sm"
|
|
class="shrink-0 mr-2 h-8 rounded-lg text-sm"
|
|
:disabled="isInstallingAll"
|
|
@click.stop="installAll"
|
|
>
|
|
<DotSpinner v-if="isInstallingAll" duration="1s" :size="12" />
|
|
{{
|
|
isInstallingAll
|
|
? t('rightSidePanel.missingNodePacks.installing')
|
|
: t('rightSidePanel.missingNodePacks.installAll')
|
|
}}
|
|
</Button>
|
|
<Button
|
|
v-else-if="group.type === 'swap_nodes'"
|
|
v-tooltip.top="
|
|
t(
|
|
'nodeReplacement.replaceAllWarning',
|
|
'Replaces all available nodes in this group.'
|
|
)
|
|
"
|
|
variant="secondary"
|
|
size="sm"
|
|
class="shrink-0 mr-2 h-8 rounded-lg text-sm"
|
|
@click.stop="handleReplaceAll()"
|
|
>
|
|
{{ t('nodeReplacement.replaceAll', 'Replace All') }}
|
|
</Button>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Missing Node Packs -->
|
|
<MissingNodeCard
|
|
v-if="group.type === 'missing_node'"
|
|
:show-info-button="shouldShowManagerButtons"
|
|
:show-node-id-badge="showNodeIdBadge"
|
|
:missing-pack-groups="missingPackGroups"
|
|
@locate-node="handleLocateMissingNode"
|
|
@open-manager-info="handleOpenManagerInfo"
|
|
/>
|
|
|
|
<!-- Swap Nodes -->
|
|
<SwapNodesCard
|
|
v-else-if="group.type === 'swap_nodes'"
|
|
:swap-node-groups="swapNodeGroups"
|
|
:show-node-id-badge="showNodeIdBadge"
|
|
@locate-node="handleLocateMissingNode"
|
|
@replace="handleReplaceGroup"
|
|
/>
|
|
|
|
<!-- Execution Errors -->
|
|
<div v-else-if="group.type === 'execution'" class="px-4 space-y-3">
|
|
<ErrorNodeCard
|
|
v-for="card in group.cards"
|
|
:key="card.id"
|
|
:card="card"
|
|
:show-node-id-badge="showNodeIdBadge"
|
|
:compact="isSingleNodeSelected"
|
|
@locate-node="handleLocateNode"
|
|
@enter-subgraph="handleEnterSubgraph"
|
|
@copy-to-clipboard="copyToClipboard"
|
|
/>
|
|
</div>
|
|
</PropertiesAccordionItem>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Fixed Footer: Help Links -->
|
|
<div class="shrink-0 border-t border-interface-stroke p-4 min-w-0">
|
|
<i18n-t
|
|
keypath="rightSidePanel.errorHelp"
|
|
tag="p"
|
|
class="m-0 text-sm text-muted-foreground leading-tight break-words"
|
|
>
|
|
<template #github>
|
|
<Button
|
|
variant="textonly"
|
|
size="unset"
|
|
class="inline underline text-inherit text-sm whitespace-nowrap"
|
|
@click="openGitHubIssues"
|
|
>
|
|
{{ t('rightSidePanel.errorHelpGithub') }}
|
|
</Button>
|
|
</template>
|
|
<template #support>
|
|
<Button
|
|
variant="textonly"
|
|
size="unset"
|
|
class="inline underline text-inherit text-sm whitespace-nowrap"
|
|
@click="contactSupport"
|
|
>
|
|
{{ t('rightSidePanel.errorHelpSupport') }}
|
|
</Button>
|
|
</template>
|
|
</i18n-t>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, ref, watch } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
|
|
import { useCommandStore } from '@/stores/commandStore'
|
|
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
|
import { useFocusNode } from '@/composables/canvas/useFocusNode'
|
|
import { useExternalLink } from '@/composables/useExternalLink'
|
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
|
import { useTelemetry } from '@/platform/telemetry'
|
|
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
|
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
|
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
|
import { NodeBadgeMode } from '@/types/nodeSource'
|
|
|
|
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
|
|
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
|
import ErrorNodeCard from './ErrorNodeCard.vue'
|
|
import MissingNodeCard from './MissingNodeCard.vue'
|
|
import SwapNodesCard from '@/platform/nodeReplacement/components/SwapNodesCard.vue'
|
|
import Button from '@/components/ui/button/Button.vue'
|
|
import DotSpinner from '@/components/common/DotSpinner.vue'
|
|
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
|
|
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
|
import { useErrorGroups } from './useErrorGroups'
|
|
import type { SwapNodeGroup } from './useErrorGroups'
|
|
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
|
|
|
|
const { t } = useI18n()
|
|
const { copyToClipboard } = useCopyToClipboard()
|
|
const { focusNode, enterSubgraph } = useFocusNode()
|
|
const { staticUrls } = useExternalLink()
|
|
const settingStore = useSettingStore()
|
|
const rightSidePanelStore = useRightSidePanelStore()
|
|
const { shouldShowManagerButtons, shouldShowInstallButton, openManager } =
|
|
useManagerState()
|
|
const { missingNodePacks } = useMissingNodes()
|
|
const { isInstalling: isInstallingAll, installAllPacks: installAll } =
|
|
usePackInstall(() => missingNodePacks.value)
|
|
const { replaceGroup, replaceAllGroups } = useNodeReplacement()
|
|
|
|
const searchQuery = ref('')
|
|
|
|
const showNodeIdBadge = computed(
|
|
() =>
|
|
(settingStore.get('Comfy.NodeBadge.NodeIdBadgeMode') as NodeBadgeMode) !==
|
|
NodeBadgeMode.None
|
|
)
|
|
|
|
const {
|
|
allErrorGroups,
|
|
filteredGroups,
|
|
collapseState,
|
|
isSingleNodeSelected,
|
|
errorNodeCache,
|
|
missingNodeCache,
|
|
missingPackGroups,
|
|
swapNodeGroups
|
|
} = useErrorGroups(searchQuery, t)
|
|
|
|
/**
|
|
* When an external trigger (e.g. "See Error" button in SectionWidgets)
|
|
* sets focusedErrorNodeId, expand only the group containing the target
|
|
* node and collapse all others so the user sees the relevant errors
|
|
* immediately.
|
|
*/
|
|
watch(
|
|
() => rightSidePanelStore.focusedErrorNodeId,
|
|
(graphNodeId) => {
|
|
if (!graphNodeId) return
|
|
const prefix = `${graphNodeId}:`
|
|
for (const group of allErrorGroups.value) {
|
|
const hasMatch =
|
|
group.type === 'execution' &&
|
|
group.cards.some(
|
|
(card) =>
|
|
card.graphNodeId === graphNodeId ||
|
|
(card.nodeId?.startsWith(prefix) ?? false)
|
|
)
|
|
collapseState[group.title] = !hasMatch
|
|
}
|
|
rightSidePanelStore.focusedErrorNodeId = null
|
|
},
|
|
{ immediate: true }
|
|
)
|
|
|
|
function handleLocateNode(nodeId: string) {
|
|
focusNode(nodeId, errorNodeCache.value)
|
|
}
|
|
|
|
function handleLocateMissingNode(nodeId: string) {
|
|
focusNode(nodeId, missingNodeCache.value)
|
|
}
|
|
|
|
function handleOpenManagerInfo(packId: string) {
|
|
const isKnownToRegistry = missingNodePacks.value.some((p) => p.id === packId)
|
|
if (isKnownToRegistry) {
|
|
openManager({ initialTab: ManagerTab.Missing, initialPackId: packId })
|
|
} else {
|
|
openManager({ initialTab: ManagerTab.All, initialPackId: packId })
|
|
}
|
|
}
|
|
|
|
function handleReplaceGroup(group: SwapNodeGroup) {
|
|
replaceGroup(group)
|
|
}
|
|
|
|
function handleReplaceAll() {
|
|
replaceAllGroups(swapNodeGroups.value)
|
|
}
|
|
|
|
function handleEnterSubgraph(nodeId: string) {
|
|
enterSubgraph(nodeId, errorNodeCache.value)
|
|
}
|
|
|
|
function openGitHubIssues() {
|
|
useTelemetry()?.trackUiButtonClicked({
|
|
button_id: 'error_tab_github_issues_clicked'
|
|
})
|
|
window.open(staticUrls.githubIssues, '_blank', 'noopener,noreferrer')
|
|
}
|
|
|
|
async function contactSupport() {
|
|
useTelemetry()?.trackHelpResourceClicked({
|
|
resource_type: 'help_feedback',
|
|
is_external: true,
|
|
source: 'error_dialog'
|
|
})
|
|
useCommandStore().execute('Comfy.ContactSupport')
|
|
}
|
|
</script>
|