Compare commits

...

3 Commits

Author SHA1 Message Date
jaeone94
9491e68511 test: align swap node unit expectations 2026-06-11 03:59:53 +09:00
jaeone94
0f67162521 test: add swap node error e2e coverage 2026-06-11 03:35:11 +09:00
jaeone94
a2cdfb6716 feat: simplify swap node error presentation 2026-06-11 03:05:39 +09:00
9 changed files with 490 additions and 163 deletions

View File

@@ -0,0 +1,61 @@
{
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "E2E_OldSampler",
"pos": [100, 100],
"size": [400, 262],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [],
"slot_index": 0
}
],
"properties": { "Node name for S&R": "E2E_OldSampler" },
"widgets_values": [42, 20, 7, "euler", "normal"]
},
{
"id": 2,
"type": "E2E_OldSampler",
"pos": [520, 100],
"size": [400, 262],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [],
"slot_index": 0
}
],
"properties": { "Node name for S&R": "E2E_OldSampler" },
"widgets_values": [43, 20, 7, "euler", "normal"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": { "ds": { "scale": 1, "offset": [0, 0] } },
"version": 0.4
}

View File

@@ -70,6 +70,7 @@ export const TestIds = {
missingModelImportUnsupported: 'missing-model-import-unsupported',
missingMediaGroup: 'error-group-missing-media',
swapNodesGroup: 'error-group-swap-nodes',
swapNodeGroupCount: 'swap-node-group-count',
missingMediaRow: 'missing-media-row',
missingMediaLocateButton: 'missing-media-locate-button',
publishTabPanel: 'publish-tab-panel',

View File

@@ -48,6 +48,36 @@ test.describe('Node replacement', { tag: ['@node', '@ui'] }, () => {
).toBeVisible()
})
test('Shows direct row label and locate action for a single replacement group', async ({
comfyPage
}) => {
const swapGroup = getSwapNodesGroup(comfyPage.page)
const rowLabel = swapGroup.getByRole('button', {
name: 'E2E_OldSampler',
exact: true
})
await expect(rowLabel).toBeVisible()
await expect(
swapGroup.getByRole('button', {
name: 'Locate node on canvas',
exact: true
})
).toBeVisible()
await expect(
swapGroup.getByTestId(TestIds.dialogs.swapNodeGroupCount)
).toHaveCount(0)
await comfyPage.canvasOps.pan({ x: -800, y: -800 })
const offsetBeforeLocate = await comfyPage.canvasOps.getOffset()
await rowLabel.click()
await expect
.poll(() => comfyPage.canvasOps.getOffset())
.not.toEqual(offsetBeforeLocate)
})
test('Replace Node replaces a single group in-place', async ({
comfyPage
}) => {
@@ -116,6 +146,55 @@ test.describe('Node replacement', { tag: ['@node', '@ui'] }, () => {
})
})
test.describe('Same-type replacement group', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.VueNodes.Enabled',
mode.vueNodesEnabled
)
await setupNodeReplacement(comfyPage, mockNodeReplacementsSingle)
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/node_replacement_same_type'
)
})
test('Groups same-type replacement rows behind the title disclosure', async ({
comfyPage
}) => {
const swapGroup = getSwapNodesGroup(comfyPage.page)
const countBadge = swapGroup.getByTestId(
TestIds.dialogs.swapNodeGroupCount
)
const childRows = swapGroup.getByRole('listitem')
const expandButton = swapGroup.getByRole('button', {
name: 'Expand E2E_OldSampler',
exact: true
})
await expect(expandButton).toBeVisible()
await expect(countBadge).toHaveText('2')
await expect(childRows).toHaveCount(0)
await expandButton.click()
await expect(childRows).toHaveCount(2)
await expect(
swapGroup.getByRole('button', {
name: 'E2E_OldSampler',
exact: true
})
).toHaveCount(2)
await swapGroup
.getByRole('button', {
name: 'Collapse E2E_OldSampler',
exact: true
})
.click()
await expect(childRows).toHaveCount(0)
})
})
test.describe('Multi-type replacement', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(

View File

@@ -530,7 +530,9 @@ describe('TabErrors.vue', () => {
expect(
screen.getByText('Some nodes can be replaced with alternatives')
).toBeInTheDocument()
expect(screen.getByText('OldSampler (1)')).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'OldSampler' })
).toBeInTheDocument()
expect(screen.getByText('KSampler')).toBeInTheDocument()
expect(
screen.getByRole('button', { name: /Replace Node/ })

View File

@@ -157,7 +157,6 @@
<SwapNodesCard
v-if="group.type === 'swap_nodes'"
:swap-node-groups="swapNodeGroups"
:show-node-id-badge="showNodeIdBadge"
@locate-node="handleLocateMissingNode"
@replace="handleReplaceGroup"
/>

View File

@@ -1,6 +1,6 @@
import { createTestingPinia } from '@pinia/testing'
import { fromAny } from '@total-typescript/shoehorn'
import { render, screen } from '@testing-library/vue'
import { render, screen, within } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import PrimeVue from 'primevue/config'
import { describe, expect, it, vi } from 'vitest'
@@ -15,8 +15,11 @@ const i18n = createI18n({
locale: 'en',
messages: {
en: {
g: {
nodesCount: '{count} node | {count} nodes'
},
rightSidePanel: {
locateNode: 'Locate Node',
locateNode: 'Locate node on canvas',
missingNodePacks: {
collapse: 'Collapse',
expand: 'Expand'
@@ -48,7 +51,6 @@ function makeGroup(overrides: Partial<SwapNodeGroup> = {}): SwapNodeGroup {
function renderRow(
props: Partial<{
group: SwapNodeGroup
showNodeIdBadge: boolean
'onLocate-node': (nodeId: string) => void
onReplace: (group: SwapNodeGroup) => void
}> = {}
@@ -56,7 +58,6 @@ function renderRow(
return render(SwapNodeGroupRow, {
props: {
group: makeGroup(),
showNodeIdBadge: false,
...props
},
global: {
@@ -75,13 +76,15 @@ describe('SwapNodeGroupRow', () => {
expect(container.textContent).toContain('OldNodeType')
})
it('renders node count in parentheses', () => {
const { container } = renderRow()
expect(container.textContent).toContain('(2)')
it('renders node count as a badge', () => {
renderRow()
const badge = screen.getByLabelText('2 nodes')
expect(badge).toBeInTheDocument()
expect(within(badge).getByText('2')).toBeInTheDocument()
})
it('renders node count of 5 for 5 nodeTypes', () => {
const { container } = renderRow({
renderRow({
group: makeGroup({
nodeTypes: Array.from({ length: 5 }, (_, i) => ({
type: 'OldNodeType',
@@ -90,7 +93,9 @@ describe('SwapNodeGroupRow', () => {
}))
})
})
expect(container.textContent).toContain('(5)')
const badge = screen.getByLabelText('5 nodes')
expect(badge).toBeInTheDocument()
expect(within(badge).getByText('5')).toBeInTheDocument()
})
it('renders the replacement target name', () => {
@@ -115,106 +120,147 @@ describe('SwapNodeGroupRow', () => {
describe('Expand / Collapse', () => {
it('starts collapsed — node list not visible', () => {
const { container } = renderRow({ showNodeIdBadge: true })
expect(container.textContent).not.toContain('#1')
renderRow()
expect(
screen.queryByRole('button', { name: 'Locate node on canvas' })
).not.toBeInTheDocument()
})
it('expands when chevron is clicked', async () => {
it('expands when title is clicked', async () => {
const user = userEvent.setup()
const { container } = renderRow({ showNodeIdBadge: true })
await user.click(screen.getByRole('button', { name: 'Expand' }))
expect(container.textContent).toContain('#1')
expect(container.textContent).toContain('#2')
renderRow()
await user.click(
screen.getByRole('button', { name: 'Expand OldNodeType' })
)
expect(
screen.getAllByRole('button', { name: 'Locate node on canvas' })
).toHaveLength(2)
})
it('collapses when chevron is clicked again', async () => {
it('collapses when title is clicked again', async () => {
const user = userEvent.setup()
const { container } = renderRow({ showNodeIdBadge: true })
await user.click(screen.getByRole('button', { name: 'Expand' }))
expect(container.textContent).toContain('#1')
await user.click(screen.getByRole('button', { name: 'Collapse' }))
expect(container.textContent).not.toContain('#1')
renderRow()
await user.click(
screen.getByRole('button', { name: 'Expand OldNodeType' })
)
expect(
screen.getAllByRole('button', { name: 'Locate node on canvas' })
).toHaveLength(2)
await user.click(
screen.getByRole('button', { name: 'Collapse OldNodeType' })
)
expect(
screen.queryByRole('button', { name: 'Locate node on canvas' })
).not.toBeInTheDocument()
})
it('updates the toggle control state when expanded', async () => {
const user = userEvent.setup()
renderRow()
expect(screen.getByRole('button', { name: 'Expand' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Expand' }))
expect(
screen.getByRole('button', { name: 'Collapse' })
).toBeInTheDocument()
const titleButton = screen.getByRole('button', {
name: 'Expand OldNodeType'
})
expect(titleButton).toHaveAttribute('aria-expanded', 'false')
await user.click(titleButton)
const collapseButton = screen.getByRole('button', {
name: 'Collapse OldNodeType'
})
expect(collapseButton).toHaveAttribute('aria-expanded', 'true')
})
})
describe('Node Type List (Expanded)', () => {
async function expand() {
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: 'Expand' }))
await user.click(screen.getByRole('button', { name: /^Expand / }))
}
it('renders all nodeTypes when expanded', async () => {
const { container } = renderRow({
renderRow({
group: makeGroup({
type: 'GroupedNodeType',
nodeTypes: [
{ type: 'OldNodeType', nodeId: '10', isReplaceable: true },
{ type: 'OldNodeType', nodeId: '20', isReplaceable: true },
{ type: 'OldNodeType', nodeId: '30', isReplaceable: true }
{ type: 'GroupedNodeType', nodeId: '10', isReplaceable: true },
{ type: 'GroupedNodeType', nodeId: '20', isReplaceable: true },
{ type: 'GroupedNodeType', nodeId: '30', isReplaceable: true }
]
}),
showNodeIdBadge: true
})
})
await expand()
expect(container.textContent).toContain('#10')
expect(container.textContent).toContain('#20')
expect(container.textContent).toContain('#30')
})
expect(screen.queryByRole('list')).not.toBeInTheDocument()
it('shows nodeId badge when showNodeIdBadge is true', async () => {
const { container } = renderRow({ showNodeIdBadge: true })
await expand()
expect(container.textContent).toContain('#1')
expect(container.textContent).toContain('#2')
})
it('hides nodeId badge when showNodeIdBadge is false', async () => {
const { container } = renderRow({ showNodeIdBadge: false })
await expand()
expect(container.textContent).not.toContain('#1')
expect(container.textContent).not.toContain('#2')
expect(
within(screen.getByRole('list')).getAllByRole('listitem')
).toHaveLength(3)
expect(
within(screen.getByRole('list')).getAllByText('GroupedNodeType')
).toHaveLength(3)
})
it('renders Locate button for each nodeType with nodeId', async () => {
renderRow({ showNodeIdBadge: true })
renderRow()
await expand()
expect(
screen.getAllByRole('button', { name: 'Locate Node' })
screen.getAllByRole('button', { name: 'Locate node on canvas' })
).toHaveLength(2)
})
it('does not render Locate button for nodeTypes without nodeId', async () => {
renderRow({
group: makeGroup({
// Intentionally omits nodeId to test graceful handling of incomplete node data
nodeTypes: fromAny<MissingNodeType[], unknown>([
{ type: 'NoIdNode', isReplaceable: true }
{ type: 'NoIdNode', isReplaceable: true },
{ type: 'OtherNoIdNode', isReplaceable: true }
])
})
})
await expand()
expect(
screen.queryByRole('button', { name: 'Locate Node' })
screen.queryByRole('button', { name: 'Locate node on canvas' })
).not.toBeInTheDocument()
})
it('renders locate controls only for locatable nodeTypes', async () => {
renderRow({
group: makeGroup({
type: 'MixedNodeType',
nodeTypes: fromAny<MissingNodeType[], unknown>([
{ type: 'MixedNodeType', nodeId: '10', isReplaceable: true },
{ type: 'MixedNodeType', isReplaceable: true }
])
})
})
await expand()
expect(
within(screen.getByRole('list')).getAllByText('MixedNodeType')
).toHaveLength(2)
expect(
within(screen.getByRole('list')).getAllByRole('button', {
name: 'MixedNodeType'
})
).toHaveLength(1)
expect(
screen.getAllByRole('button', { name: 'Locate node on canvas' })
).toHaveLength(1)
})
})
describe('Events', () => {
it('emits locate-node with correct nodeId', async () => {
const onLocateNode = vi.fn()
const user = userEvent.setup()
renderRow({ showNodeIdBadge: true, 'onLocate-node': onLocateNode })
await user.click(screen.getByRole('button', { name: 'Expand' }))
const locateBtns = screen.getAllByRole('button', { name: 'Locate Node' })
renderRow({ 'onLocate-node': onLocateNode })
await user.click(
screen.getByRole('button', { name: 'Expand OldNodeType' })
)
const locateBtns = screen.getAllByRole('button', {
name: 'Locate node on canvas'
})
await user.click(locateBtns[0])
expect(onLocateNode).toHaveBeenCalledWith('1')
@@ -233,24 +279,100 @@ describe('SwapNodeGroupRow', () => {
})
})
describe('Single Node Groups', () => {
it('locates a single node without expanding', async () => {
const onLocateNode = vi.fn()
const user = userEvent.setup()
renderRow({
group: makeGroup({
type: 'SingleNodeType',
nodeTypes: [
{ type: 'SingleNodeType', nodeId: '42', isReplaceable: true }
]
}),
'onLocate-node': onLocateNode
})
expect(
screen.queryByRole('button', { name: /^Expand / })
).not.toBeInTheDocument()
expect(screen.queryByLabelText('1 node')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'SingleNodeType' }))
expect(onLocateNode).toHaveBeenCalledWith('42')
await user.click(
screen.getByRole('button', { name: 'Locate node on canvas' })
)
expect(onLocateNode).toHaveBeenCalledTimes(2)
expect(onLocateNode).toHaveBeenLastCalledWith('42')
})
it('renders a single node without nodeId as non-locatable text', () => {
renderRow({
group: makeGroup({
type: 'NoIdNode',
nodeTypes: fromAny<MissingNodeType[], unknown>([
{ type: 'NoIdNode', isReplaceable: true }
])
})
})
expect(screen.getByText('NoIdNode')).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'NoIdNode' })
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Locate node on canvas' })
).not.toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('handles empty nodeTypes array', () => {
const { container } = renderRow({
group: makeGroup({ nodeTypes: [] })
renderRow({
group: makeGroup({
nodeTypes: []
})
})
expect(container.textContent).toContain('(0)')
expect(screen.getByText('OldNodeType')).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'OldNodeType' })
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: /^Expand / })
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Locate node on canvas' })
).not.toBeInTheDocument()
})
it('handles string nodeType entries', async () => {
const user = userEvent.setup()
const { container } = renderRow({
renderRow({
group: makeGroup({
// Intentionally uses a plain string entry to test legacy node type handling
nodeTypes: fromAny<MissingNodeType[], unknown>(['StringType'])
nodeTypes: fromAny<MissingNodeType[], unknown>([
'StringType',
'OtherStringType'
])
})
})
await user.click(screen.getByRole('button', { name: 'Expand' }))
expect(container.textContent).toContain('StringType')
await user.click(
screen.getByRole('button', { name: 'Expand OldNodeType' })
)
expect(screen.getByText('StringType')).toBeInTheDocument()
expect(screen.getByText('OtherStringType')).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'StringType' })
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'OtherStringType' })
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Locate node on canvas' })
).not.toBeInTheDocument()
})
})
})

View File

@@ -1,100 +1,152 @@
<template>
<div class="mb-4 flex w-full flex-col">
<!-- Type header row: type name + chevron -->
<div class="flex h-8 w-full items-center">
<p class="text-foreground min-w-0 flex-1 truncate text-sm font-medium">
{{ `${group.type} (${group.nodeTypes.length})` }}
</p>
<div class="mb-1 flex w-full flex-col gap-0.5 last:mb-0">
<div class="flex min-h-8 w-full items-center gap-1">
<Button
v-if="hasMultipleNodeTypes"
variant="textonly"
size="icon-sm"
size="unset"
:class="
cn(
'size-8 shrink-0 transition-transform duration-200 hover:bg-transparent',
{ 'rotate-180': expanded }
'h-8 w-4 shrink-0 p-0 transition-transform duration-200 hover:bg-transparent',
expanded && 'rotate-90'
)
"
:aria-label="
expanded
? t('rightSidePanel.missingNodePacks.collapse', 'Collapse')
: t('rightSidePanel.missingNodePacks.expand', 'Expand')
"
aria-hidden="true"
tabindex="-1"
@click="toggleExpand"
>
<i
class="icon-[lucide--chevron-down] size-4 text-muted-foreground group-hover:text-base-foreground"
aria-hidden="true"
class="icon-[lucide--chevron-right] size-4 text-muted-foreground"
/>
</Button>
</div>
<!-- Sub-labels: individual node instances, each with their own Locate button -->
<TransitionCollapse>
<div
v-if="expanded"
class="mb-2 flex flex-col gap-0.5 overflow-hidden pl-2"
>
<div
v-for="nodeType in group.nodeTypes"
:key="getKey(nodeType)"
class="flex h-7 items-center"
>
<span
v-if="
showNodeIdBadge &&
typeof nodeType !== 'string' &&
nodeType.nodeId != null
"
class="mr-1 shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 font-mono text-xs font-bold text-muted-foreground"
>
#{{ nodeType.nodeId }}
<span class="flex min-w-0 flex-1 flex-col gap-0">
<span class="flex min-w-0 items-center gap-2">
<span class="flex min-w-0 items-center gap-2.5">
<button
v-if="hasMultipleNodeTypes"
type="button"
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-base-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-1 focus-visible:outline-none"
:title="group.type"
:aria-label="titleToggleAriaLabel"
:aria-expanded="expanded"
@click="toggleExpand"
>
{{ group.type }}
</button>
<button
v-else-if="primaryLocatableNodeType"
type="button"
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-base-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-1 focus-visible:outline-none"
:title="group.type"
@click="handleLocateNode(primaryLocatableNodeType)"
>
{{ group.type }}
</button>
<span
v-else
class="min-w-0 truncate text-sm/relaxed font-normal text-base-foreground"
:title="group.type"
>
{{ group.type }}
</span>
<span
v-if="hasMultipleNodeTypes"
data-testid="swap-node-group-count"
role="img"
class="flex size-6 shrink-0 items-center justify-center rounded-md bg-secondary-background-selected text-xs font-bold text-muted-foreground"
:aria-label="t('g.nodesCount', group.nodeTypes.length)"
>
{{ group.nodeTypes.length }}
</span>
</span>
<p class="min-w-0 flex-1 truncate text-xs text-muted-foreground">
{{ getLabel(nodeType) }}
</p>
<Button
v-if="typeof nodeType !== 'string' && nodeType.nodeId != null"
variant="textonly"
size="icon-sm"
class="mr-1 size-6 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="t('rightSidePanel.locateNode', 'Locate Node')"
@click="handleLocateNode(nodeType)"
</span>
<span class="min-w-0 text-xs/relaxed text-muted-foreground">
{{
t(
'nodeReplacement.willBeReplacedBy',
'This node will be replaced by:'
)
}}
<span
class="inline-flex rounded-sm bg-modal-card-tag-background px-1.5 py-0.5 text-xs/none font-medium text-modal-card-tag-foreground"
>
<i class="icon-[lucide--locate] size-3" />
</Button>
</div>
</div>
</TransitionCollapse>
{{ replacementLabel }}
</span>
</span>
</span>
<!-- Description rows: what it is replaced by -->
<div class="mt-1 mb-2 flex flex-col gap-0.5 px-1 text-[13px]">
<span class="text-muted-foreground">{{
t('nodeReplacement.willBeReplacedBy', 'This node will be replaced by:')
}}</span>
<span class="text-foreground font-bold">{{
group.newNodeId ?? t('nodeReplacement.unknownNode', 'Unknown')
}}</span>
</div>
<!-- Replace Action Button -->
<div class="flex w-full items-start py-1">
<Button
variant="secondary"
size="md"
class="flex w-full flex-1"
size="sm"
class="h-8 shrink-0 rounded-lg text-sm"
@click="handleReplaceNode"
>
<i class="text-foreground mr-1 icon-[lucide--repeat] size-4 shrink-0" />
<span class="text-foreground min-w-0 truncate text-sm">
<i
aria-hidden="true"
class="text-foreground mr-1 icon-[lucide--repeat] size-4 shrink-0"
/>
<span class="text-foreground min-w-0 truncate">
{{ t('nodeReplacement.replaceNode', 'Replace Node') }}
</span>
</Button>
<Button
v-if="primaryLocatableNodeType"
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="locateNodeLabel"
@click="handleLocateNode(primaryLocatableNodeType)"
>
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
</Button>
</div>
<TransitionCollapse>
<ul v-if="expanded" class="m-0 list-none space-y-1 p-0 pl-5">
<li
v-for="(nodeType, index) in group.nodeTypes"
:key="getKey(nodeType, index)"
class="min-w-0"
>
<div class="flex min-w-0 items-center gap-2">
<span class="flex min-w-0 flex-1 items-center gap-1">
<button
v-if="isLocatableNodeType(nodeType)"
type="button"
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-1 focus-visible:outline-none"
@click="handleLocateNode(nodeType)"
>
{{ getLabel(nodeType) }}
</button>
<span
v-else
class="text-sm/relaxed wrap-break-word text-muted-foreground"
>
{{ getLabel(nodeType) }}
</span>
</span>
<Button
v-if="isLocatableNodeType(nodeType)"
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="locateNodeLabel"
@click="handleLocateNode(nodeType)"
>
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
</Button>
</div>
</li>
</ul>
</TransitionCollapse>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
@@ -102,9 +154,8 @@ import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCol
import type { MissingNodeType } from '@/types/comfy'
import type { SwapNodeGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
const props = defineProps<{
const { group } = defineProps<{
group: SwapNodeGroup
showNodeIdBadge: boolean
}>()
const emit = defineEmits<{
@@ -115,28 +166,54 @@ const emit = defineEmits<{
const { t } = useI18n()
const expanded = ref(false)
const hasMultipleNodeTypes = computed(() => group.nodeTypes.length > 1)
const replacementLabel = computed(
() => group.newNodeId ?? t('nodeReplacement.unknownNode', 'Unknown')
)
const locateNodeLabel = computed(() =>
t('rightSidePanel.locateNode', 'Locate node on canvas')
)
const titleToggleAriaLabel = computed(
() =>
`${
expanded.value
? t('rightSidePanel.missingNodePacks.collapse', 'Collapse')
: t('rightSidePanel.missingNodePacks.expand', 'Expand')
} ${group.type}`
)
const primaryLocatableNodeType = computed(() => {
if (group.nodeTypes.length !== 1) return null
const [nodeType] = group.nodeTypes
return isLocatableNodeType(nodeType) ? nodeType : null
})
function toggleExpand() {
expanded.value = !expanded.value
}
function getKey(nodeType: MissingNodeType): string {
if (typeof nodeType === 'string') return nodeType
return nodeType.nodeId != null ? String(nodeType.nodeId) : nodeType.type
function getKey(nodeType: MissingNodeType, index: number): string {
if (typeof nodeType === 'string') return `${nodeType}-${index}`
return nodeType.nodeId != null
? String(nodeType.nodeId)
: `${nodeType.type}-${index}`
}
function getLabel(nodeType: MissingNodeType): string {
return typeof nodeType === 'string' ? nodeType : nodeType.type
}
function isLocatableNodeType(
nodeType: MissingNodeType
): nodeType is Exclude<MissingNodeType, string> & { nodeId: string | number } {
return typeof nodeType !== 'string' && nodeType.nodeId != null
}
function handleLocateNode(nodeType: MissingNodeType) {
if (typeof nodeType === 'string') return
if (nodeType.nodeId != null) {
emit('locate-node', String(nodeType.nodeId))
}
if (!isLocatableNodeType(nodeType)) return
emit('locate-node', String(nodeType.nodeId))
}
function handleReplaceNode() {
emit('replace', props.group)
emit('replace', group)
}
</script>

View File

@@ -10,8 +10,8 @@ vi.mock('./SwapNodeGroupRow.vue', () => ({
default: {
name: 'SwapNodeGroupRow',
template:
'<div class="swap-row" :data-show-node-id-badge="showNodeIdBadge" :data-group-type="group?.type"><button class="locate-trigger" @click="$emit(\'locate-node\', group?.nodeTypes?.[0]?.nodeId)">Locate</button><button class="replace-trigger" @click="$emit(\'replace\', group)">Replace</button></div>',
props: ['group', 'showNodeIdBadge'],
'<div class="swap-row" :data-group-type="group?.type"><button class="locate-trigger" @click="$emit(\'locate-node\', group?.nodeTypes?.[0]?.nodeId)">Locate</button><button class="replace-trigger" @click="$emit(\'replace\', group)">Replace</button></div>',
props: ['group'],
emits: ['locate-node', 'replace']
}
}))
@@ -29,7 +29,6 @@ function makeGroups(count = 2): SwapNodeGroup[] {
function mountCard(
props: Partial<{
swapNodeGroups: SwapNodeGroup[]
showNodeIdBadge: boolean
}> = {},
callbacks?: {
onLocateNode?: (nodeId: string) => void
@@ -39,7 +38,6 @@ function mountCard(
return render(SwapNodesCard, {
props: {
swapNodeGroups: makeGroups(),
showNodeIdBadge: false,
...props,
...(callbacks?.onLocateNode
? { 'onLocate-node': callbacks.onLocateNode }
@@ -72,16 +70,6 @@ describe('SwapNodesCard', () => {
expect(container.querySelectorAll('.swap-row')).toHaveLength(1)
})
it('passes showNodeIdBadge to children', () => {
const { container } = mountCard({
swapNodeGroups: makeGroups(1),
showNodeIdBadge: true
})
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const row = container.querySelector('.swap-row')
expect(row!.getAttribute('data-show-node-id-badge')).toBe('true')
})
it('passes group prop to children', () => {
const groups = makeGroups(1)
const { container } = mountCard({ swapNodeGroups: groups })

View File

@@ -4,7 +4,6 @@
v-for="group in swapNodeGroups"
:key="group.type"
:group="group"
:show-node-id-badge="showNodeIdBadge"
@locate-node="emit('locate-node', $event)"
@replace="emit('replace', $event)"
/>
@@ -15,9 +14,8 @@
import type { SwapNodeGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
import SwapNodeGroupRow from '@/platform/nodeReplacement/components/SwapNodeGroupRow.vue'
const { swapNodeGroups, showNodeIdBadge } = defineProps<{
const { swapNodeGroups } = defineProps<{
swapNodeGroups: SwapNodeGroup[]
showNodeIdBadge: boolean
}>()
const emit = defineEmits<{