mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-11 00:38:37 +00:00
Compare commits
3 Commits
main
...
jaeone/fe-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9491e68511 | ||
|
|
0f67162521 | ||
|
|
a2cdfb6716 |
61
browser_tests/assets/missing/node_replacement_same_type.json
Normal file
61
browser_tests/assets/missing/node_replacement_same_type.json
Normal 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
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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/ })
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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<{
|
||||
|
||||
Reference in New Issue
Block a user