Compare commits

..

4 Commits

Author SHA1 Message Date
jaeone94
620b84e881 fix: address missing node pack review gaps 2026-06-10 10:39:53 +09:00
jaeone94
5b2ddf525a test: cover missing node pack error interactions 2026-06-10 00:36:55 +09:00
jaeone94
bc98f71c2d refactor: simplify missing node pack errors 2026-06-09 22:13:05 +09:00
jaeone94
1b90696459 Simplify missing media error presentation (#12705)
## Summary

This is the second PR in the planned Workflow Overview error-tab
modernization stack. It follows the merged execution-error slice in
#12683 and focuses only on Missing Media / Missing Inputs.

The intent is to simplify missing-media errors from
media-type/file-resolution cards into compact node-level guidance.
Missing media is now presented as a flat list of affected node inputs,
matching the new catalog-driven item-row direction introduced by the
first PR while intentionally avoiding the larger missing-node,
swap-node, and missing-model redesigns.

This PR also removes the upload/library selection workflow from the
error tab. Missing media rows now help the user locate the affected
node/input directly, rather than attempting to resolve the missing file
inside the error card.

## Changes

- **What**: Simplifies the Missing Inputs card into a flat list of
affected node/widget rows.
- **What**: Displays each missing media row with a catalog-resolved item
label in the form `{nodeName} - {inputName}`.
- **What**: Makes both the row label and right-aligned locate icon
navigate to the affected node on canvas.
- **What**: Adds item-specific accessible labels for locate icon buttons
and an explicit focus-visible state for clickable row labels.
- **What**: Uses the existing `list-scale` transition pattern so missing
media rows animate consistently when they enter or leave.
- **What**: Removes the old image/video/audio sub-sections from the
missing-media card.
- **What**: Removes missing-media
upload/dropzone/library-selection/status/confirm/cancel UI, because
resolution is no longer handled inside this card.
- **What**: Removes stale E2E selectors and tests tied to the deleted
upload/library controls.
- **What**: Removes stale English missing-media locale keys for the
deleted controls; translated locale files are intentionally left to the
localization sync process.
- **What**: Removes stale missing-media store interaction state
(`expandState`, `uploadState`, `pendingSelection`) and the now-unused
name-based removal API that only supported the deleted upload/library
flow.
- **What**: Keeps the remaining widget/node/prefix candidate-removal
APIs used by the missing-media lifecycle.
- **What**: Carries `nodeType` through missing-media referencing nodes
so fallback labels remain correct when the same missing filename is
referenced by multiple node types.
- **Breaking**: None expected. This changes the missing-media error-tab
UX, but does not change the workflow schema or backend APIs.
- **Dependencies**: None.

## Planned stack

This PR is part of the same staged redesign plan described in #12683.
The goal is to keep each error category reviewable and stable before
moving to the next slice.

1. Execution errors, merged in #12683: validation, runtime, and prompt
error grouping/presentation.
2. Missing media, this PR: simplify image/video/audio missing-media
cards into node-level catalog item rows with locate actions.
3. Missing node and swap node: align missing-pack rows, nested node
references, install/replace actions, and locate behavior.
4. Missing model: unify OSS and Cloud presentation, simplify
download/import actions, and improve import/download progress behavior.

This PR intentionally does not change missing node packs, swap nodes,
missing models, runtime errors, validation errors, or prompt errors.

## Review Focus

- Missing media rows should now be node-level rows, not file-level rows
and not image/video/audio sections.
- If the same missing media filename is referenced by multiple nodes,
each affected node/input should render as its own row.
- Clicking either the row label or locate icon should focus the
corresponding affected node.
- Removed upload/library controls are intentional and should not be
restored in this PR.
- The missing-media store cleanup should not remove any state or API
still used by the remaining missing-media lifecycle.
- Only `rightSidePanel.missingMedia.missingMediaTitle` should remain in
the English locale for the old missing-media UI namespace; new row copy
lives in the error catalog.
- This PR should stay scoped to Missing Media / Missing Inputs and
should not include the larger missing model/node/swap redesign work.

## Screenshots (if applicable)

This PR
<img width="483" height="339" alt="스크린샷 2026-06-08 오후 8 26 43"
src="https://github.com/user-attachments/assets/e029411e-b1db-41cd-aa6e-2bd5d86762f6"
/>

Before (Main)
<img width="484" height="802" alt="스크린샷 2026-06-08 오후 8 32 19"
src="https://github.com/user-attachments/assets/c183c18d-babe-4f4e-b4e0-706ce41298de"
/>

## Validation

- `pnpm format`
- `pnpm test:unit src/platform/missingMedia/missingMediaStore.test.ts
src/components/rightSidePanel/errors/TabErrors.test.ts
src/platform/errorCatalog/errorMessageResolver.test.ts
src/platform/missingMedia/missingMediaScan.test.ts
src/platform/missingMedia/missingMediaAssetResolver.test.ts`
- `pnpm typecheck`
- `pnpm knip --cache`
- `pnpm test:browser:local
browser_tests/tests/propertiesPanel/errorsTabMissingMedia.spec.ts
--project=chromium`
- Pre-push hook: `pnpm knip --cache`

## Review notes

Claude review was run after the main implementation and again after
cleanup. No blocker or major issues remained. The only final minor
finding was stale inline i18n fixture data in `TabErrors.test.ts`, which
was removed in the final commit.
2026-06-09 06:52:35 +00:00
26 changed files with 883 additions and 1338 deletions

View File

@@ -0,0 +1,48 @@
{
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "TEST_MISSING_PACK_NODE_A",
"pos": [48, 86],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"Node name for S&R": "TEST_MISSING_PACK_NODE_A",
"cnr_id": "test-missing-node-pack"
},
"widgets_values": []
},
{
"id": 2,
"type": "TEST_MISSING_PACK_NODE_B",
"pos": [520, 86],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"Node name for S&R": "TEST_MISSING_PACK_NODE_B",
"cnr_id": "test-missing-node-pack"
},
"widgets_values": []
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"version": 0.4
}

View File

@@ -45,6 +45,8 @@ export const TestIds = {
errorOverlayMessages: 'error-overlay-messages',
runtimeErrorPanel: 'runtime-error-panel',
missingNodeCard: 'missing-node-card',
missingNodePackExpand: 'missing-node-pack-expand',
missingNodePackCount: 'missing-node-pack-count',
errorCardFindOnGithub: 'error-card-find-on-github',
errorCardCopy: 'error-card-copy',
errorDialog: 'error-dialog',
@@ -69,11 +71,6 @@ export const TestIds = {
missingMediaGroup: 'error-group-missing-media',
swapNodesGroup: 'error-group-swap-nodes',
missingMediaRow: 'missing-media-row',
missingMediaUploadDropzone: 'missing-media-upload-dropzone',
missingMediaLibrarySelect: 'missing-media-library-select',
missingMediaStatusCard: 'missing-media-status-card',
missingMediaConfirmButton: 'missing-media-confirm-button',
missingMediaCancelButton: 'missing-media-cancel-button',
missingMediaLocateButton: 'missing-media-locate-button',
publishTabPanel: 'publish-tab-panel',
apiSignin: 'api-signin-dialog',

View File

@@ -5,37 +5,10 @@ import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
async function uploadFileViaDropzone(comfyPage: ComfyPage) {
const dropzone = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaUploadDropzone
)
const [fileChooser] = await Promise.all([
comfyPage.page.waitForEvent('filechooser'),
dropzone.click()
])
await fileChooser.setFiles(comfyPage.assetPath('test_upload_image.png'))
}
async function confirmPendingSelection(comfyPage: ComfyPage) {
const confirmButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaConfirmButton
)
await expect(confirmButton).toBeEnabled()
await confirmButton.click()
}
function getMediaRow(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId(TestIds.dialogs.missingMediaRow)
}
function getStatusCard(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId(TestIds.dialogs.missingMediaStatusCard)
}
function getDropzone(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId(TestIds.dialogs.missingMediaUploadDropzone)
}
function getErrorOverlay(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
}
@@ -81,7 +54,7 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
await expect(getMediaRow(comfyPage)).toHaveCount(2)
})
test('Shows upload dropzone and library select for each missing item', async ({
test('Shows missing item label and locate action', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
@@ -89,32 +62,15 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
'missing/missing_media_single'
)
await expect(getDropzone(comfyPage)).toBeVisible()
await expect(getMediaRow(comfyPage)).toHaveText(/Load Image - image/)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaLibrarySelect)
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaLocateButton)
).toBeVisible()
})
})
test.describe('Upload flow', () => {
test('Upload via file picker shows status card then allows confirm', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
await uploadFileViaDropzone(comfyPage)
await expect(getStatusCard(comfyPage)).toBeVisible()
await confirmPendingSelection(comfyPage)
await expect(getMediaRow(comfyPage)).toHaveCount(0)
})
})
test.describe('Library select flow', () => {
test('Selecting from library shows status card then allows confirm', async ({
test.describe('List behavior', () => {
test('Clicking the missing item label navigates canvas to the node', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
@@ -122,63 +78,27 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
'missing/missing_media_single'
)
const librarySelect = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaLibrarySelect
)
await librarySelect.getByRole('combobox').click()
const offsetBefore = await comfyPage.page.evaluate(() => {
const canvas = window['app']?.canvas
return canvas?.ds?.offset
? [canvas.ds.offset[0], canvas.ds.offset[1]]
: null
})
const optionCount = await comfyPage.page.getByRole('option').count()
if (optionCount === 0) {
// oxlint-disable-next-line playwright/no-skipped-test -- no library options available in CI
test.skip()
return
}
await comfyPage.page.getByRole('option').first().click()
await expect(getStatusCard(comfyPage)).toBeVisible()
await confirmPendingSelection(comfyPage)
await expect(getMediaRow(comfyPage)).toHaveCount(0)
})
})
test.describe('Cancel selection', () => {
test('Cancelling pending selection returns to upload/library UI', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
await uploadFileViaDropzone(comfyPage)
await expect(getStatusCard(comfyPage)).toBeVisible()
await expect(getDropzone(comfyPage)).toBeHidden()
await comfyPage.page
.getByTestId(TestIds.dialogs.missingMediaCancelButton)
await getMediaRow(comfyPage)
.getByRole('button', { name: 'Load Image - image', exact: true })
.click()
await expect(getStatusCard(comfyPage)).toBeHidden()
await expect(getDropzone(comfyPage)).toBeVisible()
})
})
test.describe('All resolved', () => {
test('Missing Inputs group disappears when all items are resolved', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
await uploadFileViaDropzone(comfyPage)
await confirmPendingSelection(comfyPage)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaGroup)
).toBeHidden()
await expect
.poll(async () => {
return await comfyPage.page.evaluate(() => {
const canvas = window['app']?.canvas
return canvas?.ds?.offset
? [canvas.ds.offset[0], canvas.ds.offset[1]]
: null
})
})
.not.toEqual(offsetBefore)
})
})

View File

@@ -4,7 +4,7 @@ import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
test.describe('Errors tab - Missing nodes', { tag: ['@ui', '@canvas'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
@@ -12,27 +12,39 @@ test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
)
})
test('Should show MissingNodeCard in errors tab', async ({ comfyPage }) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingNodeCard)
).toBeVisible()
})
test('Should show missing node packs group', async ({ comfyPage }) => {
test('Should show missing node pack card with guidance', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
const missingNodeGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodePacksGroup
)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingNodeCard)
).toBeVisible()
await expect(missingNodeGroup).toBeVisible()
await expect(
missingNodeGroup.getByTestId(TestIds.dialogs.errorGroupDisplayMessage)
).toHaveText(/\S/)
})
test('Should expand pack group to reveal node type names', async ({
test('Should show unknown pack node rows by default', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
const missingNodeCard = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodeCard
)
await expect(missingNodeCard.getByText('Unknown pack')).toBeVisible()
await expect(
missingNodeCard.getByRole('button', { name: 'UNKNOWN NODE' })
).toBeVisible()
})
test('Should show subgraph missing node rows by default', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
@@ -43,66 +55,72 @@ test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
const missingNodeCard = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodeCard
)
await expect(missingNodeCard).toBeVisible()
await missingNodeCard
.getByRole('button', { name: /expand/i })
.first()
.click()
await expect(
missingNodeCard.getByText('MISSING_NODE_TYPE_IN_SUBGRAPH')
missingNodeCard.getByRole('button', {
name: 'MISSING_NODE_TYPE_IN_SUBGRAPH'
})
).toBeVisible()
})
test('Should collapse expanded pack group', async ({ comfyPage }) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_nodes_in_subgraph'
)
test('Should locate missing node from the row label', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
const missingNodeCard = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodeCard
)
await missingNodeCard
.getByRole('button', { name: /expand/i })
.first()
.click()
await expect(
missingNodeCard.getByText('MISSING_NODE_TYPE_IN_SUBGRAPH')
).toBeVisible()
await comfyPage.canvasOps.pan({ x: -800, y: -800 })
const offsetBeforeLocate = await comfyPage.canvasOps.getOffset()
await missingNodeCard
.getByRole('button', { name: /collapse/i })
.first()
.click()
await expect(
missingNodeCard.getByText('MISSING_NODE_TYPE_IN_SUBGRAPH')
).toBeHidden()
await missingNodeCard.getByRole('button', { name: 'UNKNOWN NODE' }).click()
await expect
.poll(() => comfyPage.canvasOps.getOffset())
.not.toEqual(offsetBeforeLocate)
})
test('Locate node button is visible for expanded pack nodes', async ({
test('Should toggle grouped pack nodes from chevron and title', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_nodes_in_subgraph'
'missing/missing_nodes_same_pack'
)
const missingNodeCard = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodeCard
)
await missingNodeCard
.getByRole('button', { name: /expand/i })
.first()
.click()
const locateButton = missingNodeCard.getByRole('button', {
name: /locate/i
const packTitle = missingNodeCard.getByRole('button', {
name: 'test-missing-node-pack'
})
await expect(locateButton.first()).toBeVisible()
// TODO: Add navigation assertion once subgraph node ID deduplication
// timing is fixed. Currently, collectMissingNodes runs before
// configure(), so execution IDs use pre-remapped node IDs that don't
// match the runtime graph. See PR #9510 / #8762.
const expandButton = missingNodeCard.getByTestId(
TestIds.dialogs.missingNodePackExpand
)
const firstNode = missingNodeCard.getByRole('button', {
name: 'TEST_MISSING_PACK_NODE_A'
})
const secondNode = missingNodeCard.getByRole('button', {
name: 'TEST_MISSING_PACK_NODE_B'
})
await expect(packTitle).toBeVisible()
await expect(
missingNodeCard.getByTestId(TestIds.dialogs.missingNodePackCount)
).toHaveText('2')
await expect(firstNode).toBeHidden()
await expect(secondNode).toBeHidden()
await expandButton.click()
await expect(firstNode).toBeVisible()
await expect(secondNode).toBeVisible()
await packTitle.click()
await expect(firstNode).toBeHidden()
await expect(secondNode).toBeHidden()
await packTitle.click()
await expect(firstNode).toBeVisible()
await expect(secondNode).toBeVisible()
})
})

View File

@@ -71,12 +71,11 @@ vi.mock('./MissingPackGroupRow.vue', () => ({
name: 'MissingPackGroupRow',
template: `<div class="pack-row" data-testid="pack-row"
:data-show-info-button="String(showInfoButton)"
:data-show-node-id-badge="String(showNodeIdBadge)"
>
<button data-testid="locate-node" @click="$emit('locate-node', group.nodeTypes[0]?.nodeId)" />
<button data-testid="open-manager-info" @click="$emit('open-manager-info', group.packId)" />
</div>`,
props: ['group', 'showInfoButton', 'showNodeIdBadge'],
props: ['group', 'showInfoButton'],
emits: ['locate-node', 'open-manager-info']
}
}))
@@ -122,7 +121,6 @@ function makePackGroups(count = 2): MissingPackGroup[] {
function renderCard(
props: Partial<{
showInfoButton: boolean
showNodeIdBadge: boolean
missingPackGroups: MissingPackGroup[]
}> = {}
) {
@@ -130,7 +128,6 @@ function renderCard(
const result = render(MissingNodeCard, {
props: {
showInfoButton: false,
showNodeIdBadge: false,
missingPackGroups: makePackGroups(),
...props
},
@@ -169,12 +166,10 @@ describe('MissingNodeCard', () => {
it('passes props correctly to MissingPackGroupRow children', () => {
renderCard({
showInfoButton: true,
showNodeIdBadge: true
showInfoButton: true
})
const row = screen.getAllByTestId('pack-row')[0]
expect(row.getAttribute('data-show-info-button')).toBe('true')
expect(row.getAttribute('data-show-node-id-badge')).toBe('true')
})
})
@@ -256,7 +251,6 @@ describe('MissingNodeCard', () => {
render(MissingNodeCard, {
props: {
showInfoButton: false,
showNodeIdBadge: false,
missingPackGroups: makePackGroups(),
onLocateNode
},
@@ -279,7 +273,6 @@ describe('MissingNodeCard', () => {
render(MissingNodeCard, {
props: {
showInfoButton: false,
showNodeIdBadge: false,
missingPackGroups: makePackGroups(),
onOpenManagerInfo
},

View File

@@ -56,27 +56,29 @@
>
</template>
</i18n-t>
<MissingPackGroupRow
v-for="group in missingPackGroups"
:key="group.packId ?? '__unknown__'"
:group="group"
:show-info-button="showInfoButton"
:show-node-id-badge="showNodeIdBadge"
@locate-node="emit('locateNode', $event)"
@open-manager-info="emit('openManagerInfo', $event)"
/>
<div class="flex flex-col gap-1 overflow-hidden py-2">
<MissingPackGroupRow
v-for="group in missingPackGroups"
:key="group.packId ?? '__unknown__'"
:group="group"
:show-info-button="showInfoButton"
@locate-node="emit('locateNode', $event)"
@open-manager-info="emit('openManagerInfo', $event)"
/>
</div>
</div>
<!-- Apply Changes: shown when manager enabled and at least one pack install succeeded -->
<div v-if="shouldShowManagerButtons" class="px-4">
<Button
v-if="hasInstalledPacksPendingRestart"
variant="primary"
variant="secondary"
size="sm"
:disabled="isRestarting"
class="mt-2 h-9 w-full justify-center gap-2 text-sm font-semibold"
class="mt-2 h-8 w-full min-w-0 rounded-lg text-sm"
@click="applyChanges()"
>
<DotSpinner v-if="isRestarting" duration="1s" :size="14" />
<DotSpinner v-if="isRestarting" duration="1s" :size="12" />
<i
v-else
aria-hidden="true"
@@ -105,9 +107,8 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { MissingPackGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
import MissingPackGroupRow from '@/components/rightSidePanel/errors/MissingPackGroupRow.vue'
const { showInfoButton, showNodeIdBadge, missingPackGroups } = defineProps<{
const { showInfoButton, missingPackGroups } = defineProps<{
showInfoButton: boolean
showNodeIdBadge: boolean
missingPackGroups: MissingPackGroup[]
}>()

View File

@@ -61,16 +61,16 @@ const i18n = createI18n({
messages: {
en: {
g: {
loading: 'Loading'
install: 'Install',
loading: 'Loading',
search: 'Search'
},
rightSidePanel: {
locateNode: 'Locate node on canvas',
missingNodePacks: {
unknownPack: 'Unknown pack',
installNodePack: 'Install node pack',
installing: 'Installing...',
installed: 'Installed',
searchInManager: 'Search in Node Manager',
viewInManager: 'View in Manager',
collapse: 'Collapse',
expand: 'Expand'
@@ -100,7 +100,6 @@ function renderRow(
props: Partial<{
group: MissingPackGroup
showInfoButton: boolean
showNodeIdBadge: boolean
}> = {}
) {
const user = userEvent.setup()
@@ -110,7 +109,6 @@ function renderRow(
props: {
group: makeGroup(),
showInfoButton: false,
showNodeIdBadge: false,
onLocateNode,
onOpenManagerInfo,
...props
@@ -118,7 +116,6 @@ function renderRow(
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), PrimeVue, i18n],
stubs: {
TransitionCollapse: { template: '<div><slot /></div>' },
DotSpinner: {
template: '<span role="status" aria-label="loading" />'
}
@@ -156,9 +153,22 @@ describe('MissingPackGroupRow', () => {
expect(screen.getByText(/Loading/)).toBeInTheDocument()
})
it('does not render header locate while pack metadata is resolving', () => {
renderRow({
group: makeGroup({
isResolving: true,
nodeTypes: [{ type: 'OnlyNode', nodeId: '100', isReplaceable: false }]
})
})
expect(
screen.queryByRole('button', { name: 'Locate node on canvas' })
).not.toBeInTheDocument()
})
it('renders node count', () => {
renderRow()
expect(screen.getByText(/\(2\)/)).toBeInTheDocument()
expect(screen.getByText('2')).toBeInTheDocument()
})
it('renders count of 5 for 5 nodeTypes', () => {
@@ -171,38 +181,29 @@ describe('MissingPackGroupRow', () => {
}))
})
})
expect(screen.getByText(/\(5\)/)).toBeInTheDocument()
})
})
describe('Expand / Collapse', () => {
it('starts collapsed', () => {
renderRow()
expect(screen.queryByText('MissingA')).not.toBeInTheDocument()
})
it('expands when chevron is clicked', async () => {
const { user } = renderRow()
await user.click(screen.getByRole('button', { name: 'Expand' }))
expect(screen.getByText('MissingA')).toBeInTheDocument()
expect(screen.getByText('MissingB')).toBeInTheDocument()
})
it('collapses when chevron is clicked again', async () => {
const { user } = renderRow()
await user.click(screen.getByRole('button', { name: 'Expand' }))
expect(screen.getByText('MissingA')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Collapse' }))
expect(screen.queryByText('MissingA')).not.toBeInTheDocument()
expect(screen.getByText('5')).toBeInTheDocument()
})
})
describe('Node Type List', () => {
async function expand(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole('button', { name: 'Expand' }))
}
it('hides multiple nodeTypes behind the expand control by default', () => {
renderRow()
expect(screen.queryByText('MissingA')).not.toBeInTheDocument()
expect(screen.queryByText('MissingB')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Expand' })).toBeInTheDocument()
})
it('renders all nodeTypes when expanded', async () => {
it('shows unknown pack nodeTypes by default', () => {
renderRow({ group: makeGroup({ packId: null }) })
expect(
screen.getByRole('button', { name: 'Collapse' })
).toBeInTheDocument()
expect(screen.getByText('MissingA')).toBeInTheDocument()
expect(screen.getByText('MissingB')).toBeInTheDocument()
})
it('renders all nodeTypes after expanding', async () => {
const { user } = renderRow({
group: makeGroup({
nodeTypes: [
@@ -212,40 +213,87 @@ describe('MissingPackGroupRow', () => {
]
})
})
await expand(user)
await user.click(screen.getByRole('button', { name: 'Expand' }))
expect(screen.getByText('NodeA')).toBeInTheDocument()
expect(screen.getByText('NodeB')).toBeInTheDocument()
expect(screen.getByText('NodeC')).toBeInTheDocument()
})
it('shows nodeId badge when showNodeIdBadge is true', async () => {
const { user } = renderRow({ showNodeIdBadge: true })
await expand(user)
expect(screen.getByText('#10')).toBeInTheDocument()
it('hides multiple nodeTypes again after collapsing', async () => {
const { user } = renderRow()
await user.click(screen.getByRole('button', { name: 'Expand' }))
expect(screen.getByText('MissingA')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Collapse' }))
expect(screen.queryByText('MissingA')).not.toBeInTheDocument()
})
it('hides nodeId badge when showNodeIdBadge is false', async () => {
const { user } = renderRow({ showNodeIdBadge: false })
await expand(user)
expect(screen.queryByText('#10')).not.toBeInTheDocument()
it('hides a single nodeType without an expand control', () => {
renderRow({
group: makeGroup({
nodeTypes: [{ type: 'OnlyNode', nodeId: '1', isReplaceable: false }]
})
})
expect(screen.queryByText('OnlyNode')).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Expand' })
).not.toBeInTheDocument()
})
it('emits locateNode when Locate button is clicked', async () => {
const { user, onLocateNode } = renderRow({ showNodeIdBadge: true })
await expand(user)
it('emits locateNode when the pack label is clicked for one nodeType', async () => {
const { user, onLocateNode } = renderRow({
group: makeGroup({
nodeTypes: [{ type: 'OnlyNode', nodeId: '100', isReplaceable: false }]
})
})
await user.click(screen.getByRole('button', { name: 'my-pack' }))
expect(onLocateNode).toHaveBeenCalledWith('100')
})
it('moves locate to the header when there is one nodeType', async () => {
const { user, onLocateNode } = renderRow({
group: makeGroup({
nodeTypes: [{ type: 'OnlyNode', nodeId: '100', isReplaceable: false }]
})
})
await user.click(
screen.getByRole('button', { name: 'Locate node on canvas' })
)
expect(onLocateNode).toHaveBeenCalledWith('100')
})
it('emits locateNode when expanded child Locate button is clicked', async () => {
const { user, onLocateNode } = renderRow()
await user.click(screen.getByRole('button', { name: 'Expand' }))
await user.click(
screen.getAllByRole('button', { name: 'Locate node on canvas' })[0]
)
expect(onLocateNode).toHaveBeenCalledWith('10')
})
it('does not show Locate for nodeType without nodeId', async () => {
const { user } = renderRow({
it('emits locateNode when node label is clicked', async () => {
const { user, onLocateNode } = renderRow()
await user.click(screen.getByRole('button', { name: 'Expand' }))
await user.click(screen.getByRole('button', { name: 'MissingA' }))
expect(onLocateNode).toHaveBeenCalledWith('10')
})
it('does not show Locate for nodeType without nodeId', () => {
renderRow({
group: makeGroup({
nodeTypes: [{ type: 'NoId', isReplaceable: false } as never]
})
})
await expand(user)
expect(
screen.queryByRole('button', { name: 'Locate node on canvas' })
).not.toBeInTheDocument()
@@ -253,7 +301,6 @@ describe('MissingPackGroupRow', () => {
it('handles mixed nodeTypes with and without nodeId', async () => {
const { user } = renderRow({
showNodeIdBadge: true,
group: makeGroup({
nodeTypes: [
{ type: 'WithId', nodeId: '100', isReplaceable: false },
@@ -261,7 +308,7 @@ describe('MissingPackGroupRow', () => {
]
})
})
await expand(user)
await user.click(screen.getByRole('button', { name: 'Expand' }))
expect(screen.getByText('WithId')).toBeInTheDocument()
expect(screen.getByText('WithoutId')).toBeInTheDocument()
expect(
@@ -274,21 +321,25 @@ describe('MissingPackGroupRow', () => {
it('hides install UI when shouldShowManagerButtons is false', () => {
mockShouldShowManagerButtons.value = false
renderRow()
expect(screen.queryByText('Install node pack')).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Install' })
).not.toBeInTheDocument()
})
it('hides install UI when packId is null', () => {
mockShouldShowManagerButtons.value = true
renderRow({ group: makeGroup({ packId: null }) })
expect(screen.queryByText('Install node pack')).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Install' })
).not.toBeInTheDocument()
})
it('shows "Search in Node Manager" when packId exists but pack not in registry', () => {
it('shows Search when packId exists but pack not in registry', () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(false)
mockMissingNodePacks.value = []
renderRow()
expect(screen.getByText('Search in Node Manager')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Search' })).toBeInTheDocument()
})
it('shows "Installed" state when pack is installed', () => {
@@ -312,7 +363,9 @@ describe('MissingPackGroupRow', () => {
mockIsPackInstalled.mockReturnValue(false)
mockMissingNodePacks.value = [{ id: 'my-pack', name: 'My Pack' }]
renderRow()
expect(screen.getByText('Install node pack')).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Install' })
).toBeInTheDocument()
})
it('calls installAllPacks when Install button is clicked', async () => {
@@ -320,9 +373,7 @@ describe('MissingPackGroupRow', () => {
mockIsPackInstalled.mockReturnValue(false)
mockMissingNodePacks.value = [{ id: 'my-pack', name: 'My Pack' }]
const { user } = renderRow()
await user.click(
screen.getByRole('button', { name: /Install node pack/ })
)
await user.click(screen.getByRole('button', { name: 'Install' }))
expect(mockInstallAllPacks).toHaveBeenCalledOnce()
})
@@ -369,7 +420,7 @@ describe('MissingPackGroupRow', () => {
describe('Edge Cases', () => {
it('handles empty nodeTypes array', () => {
renderRow({ group: makeGroup({ nodeTypes: [] }) })
expect(screen.getByText(/\(0\)/)).toBeInTheDocument()
expect(screen.getByText('0')).toBeInTheDocument()
})
})
})

View File

@@ -1,187 +1,221 @@
<template>
<div class="mb-2 flex w-full flex-col">
<!-- Pack header row: pack name + info + chevron -->
<div class="flex h-8 w-full items-center">
<!-- Warning icon for unknown packs -->
<i
v-if="group.packId === null && !group.isResolving"
class="mr-1.5 icon-[lucide--triangle-alert] size-4 shrink-0 text-warning-background"
/>
<p
class="min-w-0 flex-1 truncate text-sm font-medium"
:class="
group.packId === null && !group.isResolving
? 'text-warning-background'
: 'text-foreground'
"
>
<span v-if="group.isResolving" class="text-muted-foreground italic">
{{ t('g.loading') }}...
</span>
<span v-else>
{{
`${group.packId ?? t('rightSidePanel.missingNodePacks.unknownPack')} (${group.nodeTypes.length})`
}}
</span>
</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="showInfoButton && group.packId !== null"
v-if="hasMultipleNodeTypes"
data-testid="missing-node-pack-expand"
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="t('rightSidePanel.missingNodePacks.viewInManager')"
@click="emit('openManagerInfo', group.packId ?? '')"
>
<i class="icon-[lucide--info] size-4" />
</Button>
<Button
variant="textonly"
size="icon-sm"
:class="
cn(
'size-8 shrink-0 transition-transform duration-200 hover:bg-transparent',
{ 'rotate-180': expanded }
)
"
size="unset"
:aria-label="
expanded
? t('rightSidePanel.missingNodePacks.collapse')
: t('rightSidePanel.missingNodePacks.expand')
"
:aria-expanded="expanded"
:class="
cn(
'h-8 w-4 shrink-0 p-0 transition-transform duration-200 hover:bg-transparent',
expanded && 'rotate-90'
)
"
@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-1 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
<i
v-if="isUnknownPack"
class="icon-[lucide--triangle-alert] size-4 shrink-0 text-warning-background"
/>
<span class="flex min-w-0 flex-1 items-center gap-2">
<span class="flex min-w-0 items-center gap-2.5">
<button
v-if="hasMultipleNodeTypes && !group.isResolving"
type="button"
:class="
cn(
packTextButtonClass,
isUnknownPack
? 'text-warning-background'
: 'text-base-foreground'
)
"
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"
:aria-expanded="expanded"
@click="toggleExpand"
>
#{{ nodeType.nodeId }}
{{ packDisplayName }}
</button>
<button
v-else-if="primaryLocatableNodeType"
type="button"
:class="
cn(
packTextButtonClass,
isUnknownPack
? 'text-warning-background'
: 'text-base-foreground'
)
"
@click="handleLocateNode(primaryLocatableNodeType)"
>
{{ packDisplayName }}
</button>
<span
v-else
class="min-w-0 truncate text-sm/relaxed font-normal"
:class="
isUnknownPack ? 'text-warning-background' : 'text-base-foreground'
"
>
<span v-if="group.isResolving" class="text-muted-foreground italic">
{{ t('g.loading') }}...
</span>
<span v-else>
{{ packDisplayName }}
</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"
v-if="showInfoButton && group.packId !== 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')"
@click="handleLocateNode(nodeType)"
class="size-7 shrink-0 text-muted-foreground hover:bg-transparent hover:text-base-foreground"
:aria-label="t('rightSidePanel.missingNodePacks.viewInManager')"
@click="emit('openManagerInfo', group.packId ?? '')"
>
<i class="icon-[lucide--locate] size-3" />
<i class="icon-[lucide--info] size-4" />
</Button>
</div>
</div>
</TransitionCollapse>
<!-- Install button: shown when manager enabled, registry knows the pack or it's already installed -->
<div
v-if="
shouldShowManagerButtons &&
group.packId !== null &&
(nodePack || comfyManagerStore.isPackInstalled(group.packId))
"
class="flex w-full items-start py-1"
>
<Button
variant="secondary"
size="md"
class="flex w-full flex-1 rounded-lg"
:disabled="
comfyManagerStore.isPackInstalled(group.packId) || isInstalling
"
@click="handlePackInstallClick"
>
<DotSpinner
v-if="isInstalling"
duration="1s"
:size="12"
class="mr-1.5 shrink-0"
/>
<i
v-else-if="comfyManagerStore.isPackInstalled(group.packId)"
class="text-foreground mr-1 icon-[lucide--check] size-4 shrink-0"
/>
<i
v-else
class="text-foreground mr-1 icon-[lucide--download] size-4 shrink-0"
/>
<span class="text-foreground min-w-0 truncate text-sm">
{{
isInstalling
? t('rightSidePanel.missingNodePacks.installing')
: comfyManagerStore.isPackInstalled(group.packId)
? t('rightSidePanel.missingNodePacks.installed')
: t('rightSidePanel.missingNodePacks.installNodePack')
}}
<span
v-if="showNodeCount"
data-testid="missing-node-pack-count"
class="flex size-6 shrink-0 items-center justify-center rounded-md bg-secondary-background-selected text-xs font-bold text-muted-foreground"
>
{{ group.nodeTypes.length }}
</span>
</span>
</Button>
</div>
<!-- Registry still loading: packId known but result not yet available -->
<div
v-else-if="group.packId !== null && shouldShowManagerButtons && isLoading"
class="flex w-full items-start py-1"
>
</span>
<div v-if="showInstallAction" class="ml-auto shrink-0">
<Button
variant="secondary"
size="sm"
class="h-8 shrink-0 rounded-lg text-sm"
:disabled="isPackInstalled || isInstalling"
@click="handlePackInstallClick"
>
<DotSpinner
v-if="isInstalling"
duration="1s"
:size="12"
class="mr-1.5 shrink-0"
/>
<span class="text-foreground min-w-0 truncate">
{{
isInstalling
? t('rightSidePanel.missingNodePacks.installing')
: isPackInstalled
? t('rightSidePanel.missingNodePacks.installed')
: t('g.install')
}}
</span>
</Button>
</div>
<div
class="flex h-8 min-w-0 flex-1 cursor-not-allowed items-center justify-center overflow-hidden rounded-lg bg-secondary-background p-2 opacity-60 select-none"
v-else-if="showLoadingAction"
class="ml-auto flex h-8 shrink-0 cursor-not-allowed items-center justify-center overflow-hidden rounded-lg bg-secondary-background px-2 py-1 text-sm opacity-60 select-none"
>
<DotSpinner duration="1s" :size="12" class="mr-1.5 shrink-0" />
<span class="text-foreground min-w-0 truncate text-sm">
{{ t('g.loading') }}
</span>
</div>
</div>
<!-- Search in Manager: fetch done but pack not found in registry -->
<div
v-else-if="group.packId !== null && shouldShowManagerButtons"
class="flex w-full items-start py-1"
>
<div v-else-if="showSearchAction" class="ml-auto shrink-0">
<Button
variant="secondary"
size="sm"
class="h-8 shrink-0 rounded-lg text-sm"
@click="
openManager({
initialTab: ManagerTab.All,
initialPackId: group.packId!
})
"
>
<span class="text-foreground min-w-0 truncate">
{{ t('g.search') }}
</span>
</Button>
</div>
<Button
variant="secondary"
size="md"
class="flex w-full flex-1 rounded-lg"
@click="
openManager({
initialTab: ManagerTab.All,
initialPackId: group.packId!
})
"
v-if="primaryLocatableNodeType"
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="t('rightSidePanel.locateNode')"
@click="handleLocateNode(primaryLocatableNodeType)"
>
<i class="text-foreground mr-1 icon-[lucide--search] size-4 shrink-0" />
<span class="text-foreground min-w-0 truncate text-sm">
{{ t('rightSidePanel.missingNodePacks.searchInManager') }}
</span>
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
</Button>
</div>
<TransitionCollapse>
<ul
v-if="showNodeTypeList"
:class="
cn(
'm-0 list-none space-y-1 p-0',
(hasMultipleNodeTypes || isUnknownPack) && 'pl-5'
)
"
>
<li
v-for="nodeType in group.nodeTypes"
:key="getKey(nodeType)"
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="
cn(
packTextButtonClass,
'text-muted-foreground hover:text-base-foreground'
)
"
@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="t('rightSidePanel.locateNode')"
@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, computed } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import Button from '@/components/ui/button/Button.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
@@ -193,10 +227,9 @@ import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTyp
import type { MissingNodeType } from '@/types/comfy'
import type { MissingPackGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
const { group, showInfoButton, showNodeIdBadge } = defineProps<{
const { group, showInfoButton } = defineProps<{
group: MissingPackGroup
showInfoButton: boolean
showNodeIdBadge: boolean
}>()
const emit = defineEmits<{
@@ -205,6 +238,10 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const expandedOverride = ref<boolean | null>(null)
const packTextButtonClass =
'm-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word outline-none focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none'
const { missingNodePacks, isLoading } = useMissingNodes()
const comfyManagerStore = useComfyManagerStore()
@@ -219,17 +256,73 @@ const { isInstalling, installAllPacks } = usePackInstall(() =>
nodePack.value ? [nodePack.value] : []
)
const isUnknownPack = computed(
() => group.packId === null && !group.isResolving
)
const packDisplayName = computed(() => {
if (group.packId === null) {
return t('rightSidePanel.missingNodePacks.unknownPack')
}
return nodePack.value?.name ?? group.packId
})
const isPackInstalled = computed(
() => group.packId !== null && comfyManagerStore.isPackInstalled(group.packId)
)
const showInstallAction = computed(
() =>
shouldShowManagerButtons.value &&
group.packId !== null &&
(nodePack.value !== null || isPackInstalled.value)
)
const showLoadingAction = computed(
() =>
shouldShowManagerButtons.value &&
group.packId !== null &&
!showInstallAction.value &&
isLoading.value
)
const showSearchAction = computed(
() =>
shouldShowManagerButtons.value &&
group.packId !== null &&
!showInstallAction.value &&
!showLoadingAction.value
)
const hasMultipleNodeTypes = computed(() => group.nodeTypes.length > 1)
const showNodeCount = computed(() => group.nodeTypes.length !== 1)
const expanded = computed(
() =>
expandedOverride.value ??
(isUnknownPack.value && hasMultipleNodeTypes.value)
)
const showNodeTypeList = computed(
() =>
(isUnknownPack.value && group.nodeTypes.length === 1) ||
(hasMultipleNodeTypes.value && expanded.value)
)
const primaryLocatableNodeType = computed(() => {
if (group.isResolving) return null
if (isUnknownPack.value) return null
if (group.nodeTypes.length !== 1) return null
const [nodeType] = group.nodeTypes
return isLocatableNodeType(nodeType) ? nodeType : null
})
function handlePackInstallClick() {
if (!group.packId) return
if (!comfyManagerStore.isPackInstalled(group.packId)) {
if (!isPackInstalled.value) {
void installAllPacks()
}
}
const expanded = ref(false)
function toggleExpand() {
expanded.value = !expanded.value
expandedOverride.value = !expanded.value
}
function getKey(nodeType: MissingNodeType): string {
@@ -241,10 +334,14 @@ 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('locateNode', String(nodeType.nodeId))
}
if (!isLocatableNodeType(nodeType)) return
emit('locateNode', String(nodeType.nodeId))
}
</script>

View File

@@ -93,16 +93,7 @@ describe('TabErrors.vue', () => {
refreshing: 'Refreshing missing models.'
},
missingMedia: {
missingMediaTitle: 'Missing Inputs',
image: 'Images',
uploadFile: 'Upload {type}',
useFromLibrary: 'Use from Library',
confirmSelection: 'Confirm selection',
locateNode: 'Locate node',
expandNodes: 'Show referencing nodes',
collapseNodes: 'Hide referencing nodes',
cancelSelection: 'Cancel selection',
or: 'OR'
missingMediaTitle: 'Missing Inputs'
}
}
}
@@ -468,6 +459,50 @@ describe('TabErrors.vue', () => {
).toBeInTheDocument()
})
it('renders one missing media item per referencing node and locates the selected node', async () => {
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
vi.mocked(getNodeByExecutionId).mockImplementation((_, nodeId) => {
const titles: Record<string, string> = {
'3': 'First Loader',
'4': 'Second Loader'
}
return {
title: titles[String(nodeId)] ?? ''
} as ReturnType<typeof getNodeByExecutionId>
})
const { user } = renderComponent({
missingMedia: {
missingMediaCandidates: [
{
nodeId: '3',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'shared.png',
isMissing: true
},
{
nodeId: '4',
nodeType: 'PreviewImage',
widgetName: 'image',
mediaType: 'image',
name: 'shared.png',
isMissing: true
}
] satisfies MissingMediaCandidate[]
}
})
expect(screen.getAllByTestId('missing-media-row')).toHaveLength(2)
await user.click(
screen.getByRole('button', { name: 'Second Loader - image' })
)
expect(mockFocusNode.mock.calls.at(-1)?.[0]).toBe('4')
})
it('renders swap node rows below the section display message', () => {
const swapNode = {
type: 'OldSampler',

View File

@@ -148,7 +148,6 @@
<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"
@@ -256,7 +255,6 @@
<MissingMediaCard
v-if="group.type === 'missing_media'"
:missing-media-groups="missingMediaGroups"
:show-node-id-badge="showNodeIdBadge"
@locate-node="handleLocateAssetNode"
/>
</PropertiesAccordionItem>

View File

@@ -131,6 +131,7 @@ import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { isLGraphNode } from '@/utils/litegraphUtil'
import { useErrorGroups } from './useErrorGroups'
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
function makeMissingNodeType(
type: string,
@@ -178,6 +179,24 @@ function makeModel(
}
}
function makeMedia(
name: string,
opts: {
nodeId: string | number
nodeType?: string
widgetName?: string
}
): MissingMediaCandidate {
return {
name,
nodeId: opts.nodeId,
nodeType: opts.nodeType ?? 'LoadImage',
widgetName: opts.widgetName ?? 'image',
mediaType: 'image',
isMissing: true
}
}
function createErrorGroups() {
const store = useExecutionErrorStore()
const searchQuery = ref('')
@@ -1060,6 +1079,27 @@ describe('useErrorGroups', () => {
groups.missingModelGroups.value
)
})
it('counts missing media by affected node rows, not grouped filenames', async () => {
const { store, groups } = createErrorGroups()
store.surfaceMissingMedia([
makeMedia('shared.png', { nodeId: '1', nodeType: 'LoadImage' }),
makeMedia('shared.png', { nodeId: '2', nodeType: 'PreviewImage' })
])
await nextTick()
expect(store.totalErrorCount).toBe(2)
expect(groups.missingMediaGroups.value).toHaveLength(1)
expect(groups.missingMediaGroups.value[0].items).toHaveLength(1)
expect(
groups.missingMediaGroups.value[0].items[0].referencingNodes
).toHaveLength(2)
const missingMediaGroup = groups.allErrorGroups.value.find(
(group) => group.type === 'missing_media'
)
expect(missingMediaGroup?.displayTitle).toBe('Missing Inputs (2)')
})
})
describe('tabErrorGroups', () => {

View File

@@ -34,6 +34,7 @@ import type { ResolvedCatalogErrorMessage } from '@/platform/errorCatalog/types'
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
import { groupCandidatesByName } from '@/platform/missingModel/missingModelScan'
import { groupCandidatesByMediaType } from '@/platform/missingMedia/missingMediaScan'
import { countMissingMediaReferences } from '@/platform/missingMedia/missingMediaGrouping'
import {
resolveMissingErrorMessage,
resolveRunErrorMessage
@@ -690,10 +691,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
function buildMissingMediaGroups(): ErrorGroup[] {
if (!missingMediaGroups.value.length) return []
const totalItems = missingMediaGroups.value.reduce(
(count, group) => count + group.items.length,
0
)
const totalRows = countMissingMediaReferences(missingMediaGroups.value)
return [
{
type: 'missing_media' as const,
@@ -702,7 +700,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
...resolveMissingErrorMessage({
kind: 'missing_media',
groups: missingMediaGroups.value,
count: totalItems,
count: totalRows,
isCloud
})
}
@@ -806,9 +804,8 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
function buildMissingMediaGroupsFiltered(): ErrorGroup[] {
if (!filteredMissingMediaGroups.value.length) return []
const totalItems = filteredMissingMediaGroups.value.reduce(
(count, group) => count + group.items.length,
0
const totalRows = countMissingMediaReferences(
filteredMissingMediaGroups.value
)
return [
{
@@ -818,7 +815,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
...resolveMissingErrorMessage({
kind: 'missing_media',
groups: filteredMissingMediaGroups.value,
count: totalItems,
count: totalRows,
isCloud
})
}

View File

@@ -3628,12 +3628,10 @@
"unsupportedTitle": "Unsupported Node Packs",
"ossManagerDisabledHint": "To install missing nodes, first run {pipCmd} in your Python environment to install Node Manager, then restart ComfyUI with the {flag} flag.",
"installAll": "Install All",
"installNodePack": "Install node pack",
"unknownPack": "Unknown pack",
"installing": "Installing...",
"installed": "Installed",
"applyChanges": "Apply Changes",
"searchInManager": "Search in Node Manager",
"viewInManager": "View in Manager",
"collapse": "Collapse",
"expand": "Expand"
@@ -3671,21 +3669,7 @@
"refreshFailed": "Failed to refresh missing models. Please try again."
},
"missingMedia": {
"missingMediaTitle": "Missing Inputs",
"image": "Images",
"video": "Videos",
"audio": "Audio",
"locateNode": "Locate node",
"expandNodes": "Show referencing nodes",
"collapseNodes": "Hide referencing nodes",
"uploadFile": "Upload {type}",
"uploading": "Uploading...",
"uploaded": "Uploaded",
"selectedFromLibrary": "Selected from library",
"useFromLibrary": "Use from Library",
"confirmSelection": "Confirm selection",
"cancelSelection": "Cancel selection",
"or": "OR"
"missingMediaTitle": "Missing Inputs"
}
},
"errorOverlay": {
@@ -3736,6 +3720,7 @@
},
"missing_media": {
"displayMessage": "A required media input has no file selected.",
"itemLabel": "{nodeName} - {inputName}",
"toastTitleOne": "Media input missing",
"toastTitleMany": "Missing media inputs",
"toastMessageWithNode": "{nodeName} is missing a required media file.",

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
resolveMissingErrorMessage,
resolveMissingMediaItemLabel,
resolveRunErrorMessage
} from './errorMessageResolver'
import type { NodeValidationError } from './types'
@@ -1579,6 +1580,32 @@ describe('errorMessageResolver', () => {
})
})
it.for([
{
source: { nodeType: 'LoadImage', widgetName: 'image' },
displayItemLabel: 'Load Image - image'
},
{
source: {
nodeDisplayName: 'Custom Loader',
nodeType: 'LoadImage',
widgetName: 'image'
},
displayItemLabel: 'Custom Loader - image'
},
{
source: { nodeType: '', widgetName: '' },
displayItemLabel: 'This node - unknown input'
}
] as const)(
'resolves missing media item labels from $source',
({ source, displayItemLabel }) => {
expect(resolveMissingMediaItemLabel(source)).toEqual({
displayItemLabel
})
}
)
it.for([
[
'image',
@@ -1649,6 +1676,44 @@ describe('errorMessageResolver', () => {
}
)
it('summarizes a shared missing media file by affected node references', () => {
expect(
resolveMissingErrorMessage({
kind: 'missing_media',
groups: [
{
mediaType: 'image',
items: [
{
name: 'shared.png',
mediaType: 'image',
representative: {
nodeId: '1',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'shared.png',
isMissing: true
},
referencingNodes: [
{ nodeId: '1', widgetName: 'image' },
{ nodeId: '2', widgetName: 'image' }
]
}
]
}
],
count: 2,
isCloud: false
})
).toMatchObject({
displayTitle: 'Missing Inputs (2)',
toastTitle: 'Missing media inputs',
toastMessage:
'Please select the missing media inputs before running this workflow.'
})
})
it('summarizes multiple missing model and media items', () => {
const modelGroups = missingModelGroups('a.safetensors', 'b.safetensors')

View File

@@ -5,13 +5,15 @@ import type {
} from './types'
import { resolveExecutionErrorMessage } from './executionErrorResolver'
import { resolveMissingErrorMessage } from './missingErrorResolver'
import { resolvePromptErrorMessage } from './promptErrorResolver'
import { resolveNodeValidationErrorMessage } from './validationErrorResolver'
// Public facade for error catalog resolution. Source-specific resolver modules
// own the actual matching/copy rules so this file stays as the routing boundary.
export { resolveMissingErrorMessage }
export {
resolveMissingErrorMessage,
resolveMissingMediaItemLabel
} from './missingErrorResolver'
export function resolveRunErrorMessage(
source: Extract<RunErrorMessageSource, { kind: 'node_validation' }>

View File

@@ -2,7 +2,8 @@ import type {
MissingErrorMessageSource,
ResolvedMissingErrorMessage
} from './types'
import { translateCatalogMessage } from './catalogI18n'
import { normalizeNodeName, translateCatalogMessage } from './catalogI18n'
import { countMissingMediaReferences } from '@/platform/missingMedia/missingMediaGrouping'
import { st } from '@/i18n'
function formatCountTitle(title: string, count: number): string {
@@ -255,6 +256,12 @@ type MissingMediaSource = Extract<
{ kind: 'missing_media' }
>
interface MissingMediaItemLabelSource {
nodeDisplayName?: string
nodeType?: string
widgetName?: string
}
function getMissingMediaItems(source: MissingMediaSource) {
return source.groups.flatMap((group) => group.items)
}
@@ -272,9 +279,29 @@ function resolveMissingMediaDisplayMessage(): string {
)
}
export function resolveMissingMediaItemLabel(
source: MissingMediaItemLabelSource
): { displayItemLabel: string } {
const nodeName = normalizeNodeName(
source.nodeDisplayName ||
formatNodeTypeName(source.nodeType ?? '') ||
undefined
)
const inputName =
source.widgetName?.trim() ||
translateCatalogMessage('errorCatalog.fallbacks.inputName', 'unknown input')
return {
displayItemLabel: translateCatalogMessage(
'errorCatalog.missingErrors.missing_media.itemLabel',
'{nodeName} - {inputName}',
{ nodeName, inputName }
)
}
}
function resolveMissingMediaToastTitle(source: MissingMediaSource): string {
const items = getMissingMediaItems(source)
if (items.length !== 1) {
if (countMissingMediaReferences(source.groups) !== 1) {
return translateCatalogMessage(
'errorCatalog.missingErrors.missing_media.toastTitleMany',
'Missing media inputs'
@@ -290,7 +317,7 @@ function resolveMissingMediaToastTitle(source: MissingMediaSource): string {
function resolveMissingMediaToastMessage(source: MissingMediaSource): string {
const items = getMissingMediaItems(source)
const [firstItem] = items
if (!firstItem || items.length !== 1) {
if (!firstItem || countMissingMediaReferences(source.groups) !== 1) {
return translateCatalogMessage(
'errorCatalog.missingErrors.missing_media.toastMessageMany',
'Please select the missing media inputs before running this workflow.'

View File

@@ -1,50 +1,61 @@
<template>
<div class="px-4 pb-2">
<div
v-for="group in missingMediaGroups"
:key="group.mediaType"
class="flex w-full flex-col border-t border-interface-stroke py-2 first:border-t-0 first:pt-0"
<TransitionGroup
tag="ul"
name="list-scale"
class="relative m-0 list-none space-y-1 p-0"
>
<!-- Media type header -->
<div class="flex h-8 w-full items-center">
<p
class="min-w-0 flex-1 truncate text-sm font-medium text-destructive-background-hover"
>
<i
aria-hidden="true"
:class="MEDIA_TYPE_ICONS[group.mediaType]"
class="mr-1 size-3.5 align-text-bottom"
/>
{{ t(`rightSidePanel.missingMedia.${group.mediaType}`) }}
({{ group.items.length }})
</p>
</div>
<!-- Media file rows -->
<div class="flex flex-col gap-1 overflow-hidden pl-2">
<MissingMediaRow
v-for="item in group.items"
:key="item.name"
:item="item"
:show-node-id-badge="showNodeIdBadge"
@locate-node="emit('locateNode', $event)"
/>
</div>
</div>
<li
v-for="item in missingMediaItems"
:key="item.key"
data-testid="missing-media-row"
class="min-w-0"
>
<div class="flex min-w-0 items-center gap-2">
<span class="flex min-w-0 flex-1">
<button
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="emit('locateNode', item.nodeId)"
>
{{ item.displayItemLabel }}
</button>
</span>
<Button
data-testid="missing-media-locate-button"
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="
t('rightSidePanel.locateNodeFor', {
item: item.displayItemLabel
})
"
@click.stop="emit('locateNode', item.nodeId)"
>
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
</Button>
</div>
</li>
</TransitionGroup>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type {
MissingMediaGroup,
MediaType
} from '@/platform/missingMedia/types'
import MissingMediaRow from '@/platform/missingMedia/components/MissingMediaRow.vue'
import Button from '@/components/ui/button/Button.vue'
import { resolveMissingMediaItemLabel } from '@/platform/errorCatalog/errorMessageResolver'
import { getMissingMediaReferences } from '@/platform/missingMedia/missingMediaGrouping'
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
import { app } from '@/scripts/app'
import { st } from '@/i18n'
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
const { missingMediaGroups } = defineProps<{
missingMediaGroups: MissingMediaGroup[]
showNodeIdBadge: boolean
}>()
const emit = defineEmits<{
@@ -53,9 +64,60 @@ const emit = defineEmits<{
const { t } = useI18n()
const MEDIA_TYPE_ICONS: Record<MediaType, string> = {
image: 'icon-[lucide--image]',
video: 'icon-[lucide--video]',
audio: 'icon-[lucide--music]'
interface MissingMediaItemEntry {
key: string
nodeId: string
displayItemLabel: string
}
const missingMediaItems = computed(() => {
return getMissingMediaReferences(missingMediaGroups)
.map(({ mediaItem, nodeRef }) => {
const nodeId = String(nodeRef.nodeId)
return {
key: `${nodeId}:${nodeRef.widgetName}:${mediaItem.name}`,
nodeId,
displayItemLabel: getDisplayItemLabel(
nodeId,
nodeRef.nodeType ?? mediaItem.representative.nodeType,
nodeRef.widgetName
)
}
})
.sort(compareMissingMediaItems)
})
function getDisplayItemLabel(
nodeId: string,
nodeType: string,
widgetName: string
) {
const nodeDisplayName = getNodeDisplayLabel(nodeId, '')
return resolveMissingMediaItemLabel({
nodeDisplayName,
nodeType,
widgetName
}).displayItemLabel
}
function compareMissingMediaItems(
a: MissingMediaItemEntry,
b: MissingMediaItemEntry
) {
return (
a.nodeId.localeCompare(b.nodeId, undefined, { numeric: true }) ||
a.displayItemLabel.localeCompare(b.displayItemLabel)
)
}
function getNodeDisplayLabel(nodeId: string, fallback: string): string {
const graph = app.rootGraph
if (!graph) return fallback
const node = getNodeByExecutionId(graph, nodeId)
return resolveNodeDisplayName(node, {
emptyLabel: fallback,
untitledLabel: fallback,
st
})
}
</script>

View File

@@ -1,147 +0,0 @@
<template>
<div class="flex flex-col gap-2">
<div v-if="showDivider" class="flex items-center justify-center py-0.5">
<span class="text-xs font-bold text-muted-foreground">
{{ t('rightSidePanel.missingMedia.or') }}
</span>
</div>
<Select
:model-value="modelValue"
:disabled="options.length === 0"
@update:model-value="handleSelect"
>
<SelectTrigger
size="md"
class="border-transparent bg-secondary-background text-xs hover:border-interface-stroke"
>
<SelectValue
:placeholder="t('rightSidePanel.missingMedia.useFromLibrary')"
/>
</SelectTrigger>
<SelectContent class="max-h-72">
<template v-if="options.length > SEARCH_THRESHOLD" #prepend>
<div class="px-1 pb-1.5">
<div
class="flex items-center gap-1.5 rounded-md border border-border-default px-2"
>
<i
aria-hidden="true"
class="icon-[lucide--search] size-3.5 shrink-0 text-muted-foreground"
/>
<input
v-model="filterQuery"
type="text"
:aria-label="t('g.searchPlaceholder', { subject: '' })"
class="h-7 w-full border-none bg-transparent text-xs outline-none placeholder:text-muted-foreground"
:placeholder="t('g.searchPlaceholder', { subject: '' })"
@keydown.stop
/>
</div>
</div>
</template>
<SelectItem
v-for="option in filteredOptions"
:key="option.value"
:value="option.value"
class="text-xs"
>
<div class="flex items-center gap-2">
<img
v-if="mediaType === 'image'"
:src="getPreviewUrl(option.value)"
alt=""
class="size-8 shrink-0 rounded-sm object-cover"
loading="lazy"
/>
<video
v-else-if="mediaType === 'video'"
aria-hidden="true"
:src="getPreviewUrl(option.value)"
class="size-8 shrink-0 rounded-sm object-cover"
preload="metadata"
muted
/>
<i
v-else
aria-hidden="true"
class="icon-[lucide--music] size-5 shrink-0 text-muted-foreground"
/>
<span class="min-w-0 truncate">{{ option.name }}</span>
</div>
</SelectItem>
<div
v-if="filteredOptions.length === 0"
role="status"
class="px-3 py-2 text-xs text-muted-foreground"
>
{{ t('g.noResultsFound') }}
</div>
</SelectContent>
</Select>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useFuse } from '@vueuse/integrations/useFuse'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
import type { MediaType } from '@/platform/missingMedia/types'
import { api } from '@/scripts/api'
const {
options,
showDivider = false,
mediaType
} = defineProps<{
modelValue: string | undefined
options: { name: string; value: string }[]
showDivider?: boolean
mediaType: MediaType
}>()
const emit = defineEmits<{
select: [value: string]
}>()
const { t } = useI18n()
const SEARCH_THRESHOLD = 4
const filterQuery = ref('')
watch(
() => options.length,
(len) => {
if (len <= SEARCH_THRESHOLD) filterQuery.value = ''
}
)
const { results: fuseResults } = useFuse(filterQuery, () => options, {
fuseOptions: {
keys: ['name'],
threshold: 0.4,
ignoreLocation: true
},
matchAllWhenSearchEmpty: true
})
const filteredOptions = computed(() => fuseResults.value.map((r) => r.item))
function getPreviewUrl(filename: string): string {
return api.apiURL(`/view?filename=${encodeURIComponent(filename)}&type=input`)
}
function handleSelect(value: unknown) {
if (typeof value === 'string') {
filterQuery.value = ''
emit('select', value)
}
}
</script>

View File

@@ -1,318 +0,0 @@
<template>
<div data-testid="missing-media-row" class="flex w-full flex-col pb-3">
<!-- File header -->
<div class="flex h-8 w-full items-center gap-2">
<i
aria-hidden="true"
class="text-foreground icon-[lucide--file] size-4 shrink-0"
/>
<!-- Single node: show node display name instead of filename -->
<template v-if="isSingleNode">
<span
v-if="showNodeIdBadge"
class="shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 font-mono text-xs font-bold text-muted-foreground"
>
#{{ item.referencingNodes[0].nodeId }}
</span>
<p
class="text-foreground min-w-0 flex-1 truncate text-sm font-medium"
:title="singleNodeLabel"
>
{{ singleNodeLabel }}
</p>
</template>
<!-- Multiple nodes: show filename with count -->
<p
v-else
class="text-foreground min-w-0 flex-1 truncate text-sm font-medium"
:title="displayName"
>
{{ displayName }}
({{ item.referencingNodes.length }})
</p>
<!-- Confirm button (visible when pending selection exists) -->
<Button
data-testid="missing-media-confirm-button"
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingMedia.confirmSelection')"
:disabled="!isPending"
:class="
cn(
'size-8 shrink-0 rounded-lg transition-colors',
isPending ? 'bg-primary/10 hover:bg-primary/15' : 'opacity-20'
)
"
@click="confirmSelection(item.name)"
>
<i
aria-hidden="true"
class="icon-[lucide--check] size-4"
:class="isPending ? 'text-primary' : 'text-foreground'"
/>
</Button>
<!-- Locate button (single node only) -->
<Button
v-if="isSingleNode"
data-testid="missing-media-locate-button"
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingMedia.locateNode')"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
@click="emit('locateNode', String(item.referencingNodes[0].nodeId))"
>
<i aria-hidden="true" class="icon-[lucide--locate] size-3" />
</Button>
<!-- Expand button (multiple nodes only) -->
<Button
v-if="!isSingleNode"
variant="textonly"
size="icon-sm"
:aria-label="
expanded
? t('rightSidePanel.missingMedia.collapseNodes')
: t('rightSidePanel.missingMedia.expandNodes')
"
:aria-expanded="expanded"
:class="
cn(
'size-8 shrink-0 transition-transform duration-200 hover:bg-transparent',
expanded && 'rotate-180'
)
"
@click="toggleExpand(item.name)"
>
<i
aria-hidden="true"
class="icon-[lucide--chevron-down] size-4 text-muted-foreground"
/>
</Button>
</div>
<!-- Referencing nodes (expandable) -->
<TransitionCollapse>
<div
v-if="expanded && item.referencingNodes.length > 1"
class="mb-1 flex flex-col gap-0.5 overflow-hidden pl-6"
>
<div
v-for="nodeRef in item.referencingNodes"
:key="`${String(nodeRef.nodeId)}::${nodeRef.widgetName}`"
class="flex h-7 items-center"
>
<span
v-if="showNodeIdBadge"
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"
>
#{{ nodeRef.nodeId }}
</span>
<p class="min-w-0 flex-1 truncate text-xs text-muted-foreground">
{{ getNodeDisplayLabel(String(nodeRef.nodeId), item.name) }}
</p>
<Button
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingMedia.locateNode')"
class="mr-1 size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
@click="emit('locateNode', String(nodeRef.nodeId))"
>
<i aria-hidden="true" class="icon-[lucide--locate] size-3" />
</Button>
</div>
</div>
</TransitionCollapse>
<!-- Status card (uploading, uploaded, or library select) -->
<TransitionCollapse>
<div
v-if="isPending || isUploading"
data-testid="missing-media-status-card"
role="status"
aria-live="polite"
class="bg-foreground/5 relative mt-1 overflow-hidden rounded-lg border border-interface-stroke p-2"
>
<div class="relative z-10 flex items-center gap-2">
<div class="flex size-8 shrink-0 items-center justify-center">
<i
v-if="currentUpload?.status === 'uploading'"
aria-hidden="true"
class="icon-[lucide--loader-circle] size-5 animate-spin text-muted-foreground"
/>
<i
v-else
aria-hidden="true"
class="icon-[lucide--file-check] size-5 text-muted-foreground"
/>
</div>
<div class="flex min-w-0 flex-1 flex-col justify-center">
<span class="text-foreground truncate text-xs/tight font-medium">
{{ pendingDisplayName }}
</span>
<span class="mt-0.5 text-xs/tight text-muted-foreground">
<template v-if="currentUpload?.status === 'uploading'">
{{ t('rightSidePanel.missingMedia.uploading') }}
</template>
<template v-else-if="currentUpload?.status === 'uploaded'">
{{ t('rightSidePanel.missingMedia.uploaded') }}
</template>
<template v-else>
{{ t('rightSidePanel.missingMedia.selectedFromLibrary') }}
</template>
</span>
</div>
<Button
data-testid="missing-media-cancel-button"
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingMedia.cancelSelection')"
class="relative z-10 size-6 shrink-0 text-muted-foreground hover:text-base-foreground"
@click="cancelSelection(item.name)"
>
<i aria-hidden="true" class="icon-[lucide--circle-x] size-4" />
</Button>
</div>
</div>
</TransitionCollapse>
<!-- Upload + Library (when no pending selection) -->
<TransitionCollapse>
<div v-if="!isPending && !isUploading" class="mt-1 flex flex-col gap-1">
<!-- Upload dropzone -->
<div ref="dropZoneRef" class="flex w-full flex-col py-1">
<button
data-testid="missing-media-upload-dropzone"
type="button"
:class="
cn(
'flex w-full cursor-pointer items-center justify-center rounded-lg border border-dashed border-component-node-border bg-transparent px-3 py-2 text-xs text-muted-foreground transition-colors hover:border-base-foreground hover:text-base-foreground',
isOverDropZone && 'border-primary text-primary'
)
"
@click="openFilePicker()"
>
{{
t('rightSidePanel.missingMedia.uploadFile', {
type: extensionHint
})
}}
</button>
</div>
<!-- OR separator + Use from Library -->
<MissingMediaLibrarySelect
data-testid="missing-media-library-select"
:model-value="undefined"
:options="libraryOptions"
:show-divider="true"
:media-type="item.mediaType"
@select="handleLibrarySelect(item.name, $event)"
/>
</div>
</TransitionCollapse>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useDropZone, useFileDialog } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import Button from '@/components/ui/button/Button.vue'
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
import MissingMediaLibrarySelect from '@/platform/missingMedia/components/MissingMediaLibrarySelect.vue'
import type { MissingMediaViewModel } from '@/platform/missingMedia/types'
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
import {
useMissingMediaInteractions,
getNodeDisplayLabel,
getMediaDisplayName
} from '@/platform/missingMedia/composables/useMissingMediaInteractions'
const { item, showNodeIdBadge } = defineProps<{
item: MissingMediaViewModel
showNodeIdBadge: boolean
}>()
const emit = defineEmits<{
locateNode: [nodeId: string]
}>()
const { t } = useI18n()
const store = useMissingMediaStore()
const { uploadState, pendingSelection } = storeToRefs(store)
const {
isExpanded,
toggleExpand,
getAcceptType,
getExtensionHint,
getLibraryOptions,
handleLibrarySelect,
handleUpload,
confirmSelection,
cancelSelection,
hasPendingSelection
} = useMissingMediaInteractions()
const displayName = getMediaDisplayName(item.name)
const isSingleNode = item.referencingNodes.length === 1
const singleNodeLabel = isSingleNode
? getNodeDisplayLabel(String(item.referencingNodes[0].nodeId), item.name)
: ''
const acceptType = getAcceptType(item.mediaType)
const extensionHint = getExtensionHint(item.mediaType)
const expanded = computed(() => isExpanded(item.name))
const matchingCandidate = computed(() => {
const candidates = store.missingMediaCandidates
if (!candidates?.length) return null
return candidates.find((c) => c.name === item.name) ?? null
})
const libraryOptions = computed(() => {
const candidate = matchingCandidate.value
if (!candidate) return []
return getLibraryOptions(candidate)
})
const isPending = computed(() => hasPendingSelection(item.name))
const isUploading = computed(
() => uploadState.value[item.name]?.status === 'uploading'
)
const currentUpload = computed(() => uploadState.value[item.name])
const pendingDisplayName = computed(() => {
if (currentUpload.value) return currentUpload.value.fileName
const pending = pendingSelection.value[item.name]
return pending ? getMediaDisplayName(pending) : ''
})
const dropZoneRef = ref<HTMLElement | null>(null)
const { isOverDropZone } = useDropZone(dropZoneRef, {
onDrop: (_files, event) => {
event?.stopPropagation()
const file = _files?.[0]
if (file) {
handleUpload(file, item.name, item.mediaType)
}
}
})
const { open: openFilePicker, onChange: onFileSelected } = useFileDialog({
accept: acceptType,
multiple: false
})
onFileSelected((files) => {
const file = files?.[0]
if (file) {
handleUpload(file, item.name, item.mediaType)
}
})
</script>

View File

@@ -1,224 +0,0 @@
import { app } from '@/scripts/app'
import { api } from '@/scripts/api'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useAssetsStore } from '@/stores/assetsStore'
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
import type {
MissingMediaCandidate,
MediaType
} from '@/platform/missingMedia/types'
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
import { isCloud } from '@/platform/distribution/types'
import { addToComboValues, resolveComboValues } from '@/utils/litegraphUtil'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { st } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IComboWidget } from '@/lib/litegraph/src/types/widgets'
import {
ACCEPTED_IMAGE_TYPES,
ACCEPTED_VIDEO_TYPES
} from '@/utils/mediaUploadUtil'
const MEDIA_ACCEPT_MAP: Record<MediaType, string> = {
image: ACCEPTED_IMAGE_TYPES,
video: ACCEPTED_VIDEO_TYPES,
audio: 'audio/*'
}
function getMediaComboWidget(
candidate: MissingMediaCandidate
): { node: LGraphNode; widget: IComboWidget } | null {
const graph = app.rootGraph
if (!graph || candidate.nodeId == null) return null
const node = getNodeByExecutionId(graph, String(candidate.nodeId))
if (!node) return null
const widget = node.widgets?.find(
(w) => w.name === candidate.widgetName && w.type === 'combo'
) as IComboWidget | undefined
if (!widget) return null
return { node, widget }
}
function resolveLibraryOptions(
candidate: MissingMediaCandidate
): { name: string; value: string }[] {
const result = getMediaComboWidget(candidate)
if (!result) return []
return resolveComboValues(result.widget)
.filter((v) => v !== candidate.name)
.map((v) => ({ name: getMediaDisplayName(v), value: v }))
}
function applyValueToNodes(
candidates: MissingMediaCandidate[],
name: string,
newValue: string
) {
const matching = candidates.filter((c) => c.name === name)
for (const c of matching) {
const result = getMediaComboWidget(c)
if (!result) continue
addToComboValues(result.widget, newValue)
result.widget.value = newValue
result.widget.callback?.(newValue)
result.node.graph?.setDirtyCanvas(true, true)
}
}
export function getNodeDisplayLabel(
nodeId: string | number,
fallback: string
): string {
const graph = app.rootGraph
if (!graph) return fallback
const node = getNodeByExecutionId(graph, String(nodeId))
return resolveNodeDisplayName(node, {
emptyLabel: fallback,
untitledLabel: fallback,
st
})
}
/**
* Resolve display name for a media file.
* Cloud widgets store asset hashes as values; this resolves them to
* human-readable names via assetsStore.getInputName().
*/
export function getMediaDisplayName(name: string): string {
if (!isCloud) return name
return useAssetsStore().getInputName(name)
}
export function useMissingMediaInteractions() {
const store = useMissingMediaStore()
const assetsStore = useAssetsStore()
function isExpanded(key: string): boolean {
return store.expandState[key] ?? false
}
function toggleExpand(key: string) {
store.expandState[key] = !isExpanded(key)
}
function getAcceptType(mediaType: MediaType): string {
return MEDIA_ACCEPT_MAP[mediaType]
}
function getExtensionHint(mediaType: MediaType): string {
if (mediaType === 'audio') return 'audio'
const exts = MEDIA_ACCEPT_MAP[mediaType]
.split(',')
.map((mime) => mime.split('/')[1])
.join(', ')
return `${exts}, ...`
}
function getLibraryOptions(
candidate: MissingMediaCandidate
): { name: string; value: string }[] {
return resolveLibraryOptions(candidate)
}
/** Step 1: Store selection from library (does not apply yet). */
function handleLibrarySelect(name: string, value: string) {
store.pendingSelection[name] = value
}
/** Step 1: Upload file and store result as pending (does not apply yet). */
async function handleUpload(file: File, name: string, mediaType: MediaType) {
if (!file.type || !file.type.startsWith(`${mediaType}/`)) {
useToastStore().addAlert(
st(
'toastMessages.unsupportedFileType',
'Unsupported file type. Please select a valid file.'
)
)
return
}
store.uploadState[name] = { fileName: file.name, status: 'uploading' }
try {
const body = new FormData()
body.append('image', file)
const resp = await api.fetchApi('/upload/image', {
method: 'POST',
body
})
if (resp.status !== 200) {
useToastStore().addAlert(
st(
'toastMessages.uploadFailed',
'Failed to upload file. Please try again.'
)
)
delete store.uploadState[name]
return
}
const data = await resp.json()
const uploadedPath: string = data.subfolder
? `${data.subfolder}/${data.name}`
: data.name
store.uploadState[name] = { fileName: file.name, status: 'uploaded' }
store.pendingSelection[name] = uploadedPath
// Refresh assets store (non-critical — upload already succeeded)
try {
await assetsStore.updateInputs()
} catch {
// Asset list refresh failed but upload is valid; selection can proceed
}
} catch {
useToastStore().addAlert(
st(
'toastMessages.uploadFailed',
'Failed to upload file. Please try again.'
)
)
delete store.uploadState[name]
}
}
/** Step 2: Apply pending selection to widgets and remove from missing list. */
function confirmSelection(name: string) {
const value = store.pendingSelection[name]
if (!value || !store.missingMediaCandidates) return
applyValueToNodes(store.missingMediaCandidates, name, value)
store.removeMissingMediaByName(name)
delete store.pendingSelection[name]
delete store.uploadState[name]
}
function cancelSelection(name: string) {
delete store.pendingSelection[name]
delete store.uploadState[name]
}
function hasPendingSelection(name: string): boolean {
return name in store.pendingSelection
}
return {
isExpanded,
toggleExpand,
getAcceptType,
getExtensionHint,
getLibraryOptions,
handleLibrarySelect,
handleUpload,
confirmSelection,
cancelSelection,
hasPendingSelection
}
}

View File

@@ -0,0 +1,26 @@
import { sumBy } from 'es-toolkit'
import type { MissingMediaGroup, MissingMediaViewModel } from './types'
export interface MissingMediaReference {
mediaItem: MissingMediaViewModel
nodeRef: MissingMediaViewModel['referencingNodes'][number]
}
export function getMissingMediaReferences(
groups: MissingMediaGroup[]
): MissingMediaReference[] {
return groups.flatMap((group) =>
group.items.flatMap((mediaItem) =>
mediaItem.referencingNodes.map((nodeRef) => ({ mediaItem, nodeRef }))
)
)
}
export function countMissingMediaReferences(
groups: MissingMediaGroup[]
): number {
return sumBy(groups, (group) =>
sumBy(group.items, (item) => item.referencingNodes.length)
)
}

View File

@@ -16,6 +16,10 @@ import {
groupCandidatesByName,
groupCandidatesByMediaType
} from './missingMediaScan'
import {
countMissingMediaReferences,
getMissingMediaReferences
} from './missingMediaGrouping'
import type { MissingMediaCandidate } from './types'
const { mockGetInputAssetsIncludingPublic, mockGetAssetsPageByTag } =
@@ -421,6 +425,11 @@ describe('groupCandidatesByName', () => {
const photoGroup = result.find((g) => g.name === 'photo.png')
expect(photoGroup?.referencingNodes).toHaveLength(2)
expect(photoGroup?.referencingNodes[0]).toMatchObject({
nodeId: '1',
nodeType: 'LoadImage',
widgetName: 'image'
})
expect(photoGroup?.mediaType).toBe('image')
expect(photoGroup?.representative.nodeType).toBe('LoadImage')
@@ -487,6 +496,27 @@ describe('groupCandidatesByMediaType', () => {
})
})
describe('missing media references', () => {
it('flattens references without deduping shared filenames', () => {
const groups = groupCandidatesByMediaType([
makeCandidate('1', 'shared.png'),
makeCandidate('2', 'shared.png'),
makeCandidate('3', 'other.png')
])
expect(groups).toHaveLength(1)
expect(groups[0].items).toHaveLength(2)
expect(countMissingMediaReferences(groups)).toBe(3)
expect(
getMissingMediaReferences(groups).map(({ nodeRef }) => nodeRef)
).toEqual([
expect.objectContaining({ nodeId: '1' }),
expect.objectContaining({ nodeId: '2' }),
expect.objectContaining({ nodeId: '3' })
])
})
})
describe('verifyMediaCandidates', () => {
const existingHash =
'blake3:1111111111111111111111111111111111111111111111111111111111111111'

View File

@@ -256,6 +256,7 @@ export function groupCandidatesByName(
if (existing) {
existing.referencingNodes.push({
nodeId: c.nodeId,
nodeType: c.nodeType,
widgetName: c.widgetName
})
} else {
@@ -263,7 +264,9 @@ export function groupCandidatesByName(
name: c.name,
mediaType: c.mediaType,
representative: c,
referencingNodes: [{ nodeId: c.nodeId, widgetName: c.widgetName }]
referencingNodes: [
{ nodeId: c.nodeId, nodeType: c.nodeType, widgetName: c.widgetName }
]
})
}
}

View File

@@ -67,18 +67,12 @@ describe('useMissingMediaStore', () => {
expect(store.hasMissingMedia).toBe(false)
})
it('clearMissingMedia resets all state including interaction state', () => {
it('clearMissingMedia resets candidates and aborts verification', () => {
const store = useMissingMediaStore()
store.setMissingMedia([
makeCandidate('1', 'photo.png'),
makeCandidate('2', 'clip.mp4', 'video')
])
store.expandState['photo.png'] = true
store.uploadState['photo.png'] = {
fileName: 'photo.png',
status: 'uploaded'
}
store.pendingSelection['photo.png'] = 'uploaded/photo.png'
const controller = store.createVerificationAbortController()
store.clearMissingMedia()
@@ -87,9 +81,6 @@ describe('useMissingMediaStore', () => {
expect(store.hasMissingMedia).toBe(false)
expect(store.missingMediaCount).toBe(0)
expect(controller.signal.aborted).toBe(true)
expect(store.expandState).toEqual({})
expect(store.uploadState).toEqual({})
expect(store.pendingSelection).toEqual({})
})
it('missingMediaNodeIds tracks unique node IDs', () => {
@@ -145,47 +136,6 @@ describe('useMissingMediaStore', () => {
expect(store.missingMediaCandidates).toHaveLength(1)
})
it('removeMissingMediaByName clears interaction state for removed name', () => {
const store = useMissingMediaStore()
store.setMissingMedia([makeCandidate('1', 'photo.png')])
store.expandState['photo.png'] = true
store.uploadState['photo.png'] = {
fileName: 'photo.png',
status: 'uploaded'
}
store.pendingSelection['photo.png'] = 'uploaded/photo.png'
store.removeMissingMediaByName('photo.png')
expect(store.expandState['photo.png']).toBeUndefined()
expect(store.uploadState['photo.png']).toBeUndefined()
expect(store.pendingSelection['photo.png']).toBeUndefined()
})
it('removeMissingMediaByWidget clears interaction state for removed name', () => {
const store = useMissingMediaStore()
store.setMissingMedia([makeCandidate('1', 'photo.png')])
store.pendingSelection['photo.png'] = 'library/photo.png'
store.removeMissingMediaByWidget('1', 'image')
expect(store.pendingSelection['photo.png']).toBeUndefined()
})
it('removeMissingMediaByWidget preserves interaction state when other candidates share the name', () => {
const store = useMissingMediaStore()
store.setMissingMedia([
makeCandidate('1', 'photo.png'),
makeCandidate('2', 'photo.png')
])
store.pendingSelection['photo.png'] = 'library/photo.png'
store.removeMissingMediaByWidget('1', 'image')
expect(store.missingMediaCandidates).toHaveLength(1)
expect(store.pendingSelection['photo.png']).toBe('library/photo.png')
})
it('createVerificationAbortController aborts previous controller', () => {
const store = useMissingMediaStore()
const first = store.createVerificationAbortController()
@@ -264,40 +214,6 @@ describe('useMissingMediaStore', () => {
expect(store.hasMissingMedia).toBe(false)
})
it('cleans interaction state for removed names', () => {
const store = useMissingMediaStore()
store.setMissingMedia([
makeCandidate('1', 'photo.png'),
makeCandidate('2', 'clip.mp4', 'video')
])
store.expandState['photo.png'] = true
store.uploadState['photo.png'] = {
fileName: 'photo.png',
status: 'uploaded'
}
store.pendingSelection['photo.png'] = 'uploaded/photo.png'
store.removeMissingMediaByNodeId('1')
expect(store.expandState['photo.png']).toBeUndefined()
expect(store.uploadState['photo.png']).toBeUndefined()
expect(store.pendingSelection['photo.png']).toBeUndefined()
})
it('preserves interaction state when other candidates share the name', () => {
const store = useMissingMediaStore()
store.setMissingMedia([
makeCandidate('1', 'photo.png'),
makeCandidate('2', 'photo.png')
])
store.pendingSelection['photo.png'] = 'library/photo.png'
store.removeMissingMediaByNodeId('1')
expect(store.missingMediaCandidates).toHaveLength(1)
expect(store.pendingSelection['photo.png']).toBe('library/photo.png')
})
it('does nothing when candidates are null', () => {
const store = useMissingMediaStore()
store.removeMissingMediaByNodeId('1')
@@ -397,21 +313,5 @@ describe('useMissingMediaStore', () => {
expect(store.missingMediaCandidates).toHaveLength(1)
expect(store.missingMediaCandidates![0].name).toBe('orphan.png')
})
it('clears interaction state for removed names not used elsewhere', () => {
const store = useMissingMediaStore()
store.setMissingMedia([
makeCandidate('65:70:63', 'shared.png'),
makeCandidate('65:80:5', 'shared.png'),
makeCandidate('65:70:64', 'only-interior.png')
])
store.pendingSelection['shared.png'] = 'library/shared.png'
store.pendingSelection['only-interior.png'] = 'library/interior.png'
store.removeMissingMediaByPrefix('65:70:')
expect(store.pendingSelection['only-interior.png']).toBeUndefined()
expect(store.pendingSelection['shared.png']).toBe('library/shared.png')
})
})
})

View File

@@ -56,14 +56,6 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
)
})
// Interaction state — persists across component re-mounts
const expandState = ref<Record<string, boolean>>({})
const uploadState = ref<
Record<string, { fileName: string; status: 'uploading' | 'uploaded' }>
>({})
/** Pending selection: value to apply on confirm. */
const pendingSelection = ref<Record<string, string>>({})
let _verificationAbortController: AbortController | null = null
function createVerificationAbortController(): AbortController {
@@ -84,58 +76,20 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
return activeMissingMediaGraphIds.value.has(String(node.id))
}
function clearInteractionStateForName(name: string) {
delete expandState.value[name]
delete uploadState.value[name]
delete pendingSelection.value[name]
}
function removeMissingMediaByName(name: string) {
if (!missingMediaCandidates.value) return
missingMediaCandidates.value = missingMediaCandidates.value.filter(
(m) => m.name !== name
)
clearInteractionStateForName(name)
if (!missingMediaCandidates.value.length)
missingMediaCandidates.value = null
}
function removeMissingMediaByWidget(nodeId: string, widgetName: string) {
if (!missingMediaCandidates.value) return
const removedNames = new Set(
missingMediaCandidates.value
.filter(
(m) => String(m.nodeId) === nodeId && m.widgetName === widgetName
)
.map((m) => m.name)
)
missingMediaCandidates.value = missingMediaCandidates.value.filter(
(m) => !(String(m.nodeId) === nodeId && m.widgetName === widgetName)
)
for (const name of removedNames) {
if (!missingMediaCandidates.value.some((m) => m.name === name)) {
clearInteractionStateForName(name)
}
}
if (!missingMediaCandidates.value.length)
missingMediaCandidates.value = null
}
function removeMissingMediaByNodeId(nodeId: string) {
if (!missingMediaCandidates.value) return
const removedNames = new Set(
missingMediaCandidates.value
.filter((m) => String(m.nodeId) === nodeId)
.map((m) => m.name)
)
missingMediaCandidates.value = missingMediaCandidates.value.filter(
(m) => String(m.nodeId) !== nodeId
)
for (const name of removedNames) {
if (!missingMediaCandidates.value.some((m) => m.name === name)) {
clearInteractionStateForName(name)
}
}
if (!missingMediaCandidates.value.length)
missingMediaCandidates.value = null
}
@@ -150,7 +104,6 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
*/
function removeMissingMediaByPrefix(prefix: string) {
if (!missingMediaCandidates.value) return
const removedNames = new Set<string>()
const remaining: MissingMediaCandidate[] = []
for (const m of missingMediaCandidates.value) {
// Preserve candidates without a nodeId; they cannot belong to any
@@ -160,19 +113,12 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
remaining.push(m)
continue
}
if (String(m.nodeId).startsWith(prefix)) {
removedNames.add(m.name)
} else {
if (!String(m.nodeId).startsWith(prefix)) {
remaining.push(m)
}
}
if (removedNames.size === 0) return
if (remaining.length === missingMediaCandidates.value.length) return
missingMediaCandidates.value = remaining.length ? remaining : null
for (const name of removedNames) {
if (!remaining.some((m) => m.name === name)) {
clearInteractionStateForName(name)
}
}
}
function addMissingMedia(media: MissingMediaCandidate[]) {
@@ -193,9 +139,6 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
_verificationAbortController?.abort()
_verificationAbortController = null
missingMediaCandidates.value = null
expandState.value = {}
uploadState.value = {}
pendingSelection.value = {}
}
return {
@@ -208,7 +151,6 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
setMissingMedia,
addMissingMedia,
removeMissingMediaByName,
removeMissingMediaByWidget,
removeMissingMediaByNodeId,
removeMissingMediaByPrefix,
@@ -216,10 +158,6 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
createVerificationAbortController,
hasMissingMediaOnNode,
isContainerWithMissingMedia,
expandState,
uploadState,
pendingSelection
isContainerWithMissingMedia
}
})

View File

@@ -30,6 +30,7 @@ export interface MissingMediaViewModel {
representative: MissingMediaCandidate
referencingNodes: Array<{
nodeId: NodeId
nodeType?: string
widgetName: string
}>
}