mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-12 07:06:24 +00:00
Compare commits
4 Commits
test/botto
...
version-bu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f641ad319 | ||
|
|
6f579c5992 | ||
|
|
e729e5edb8 | ||
|
|
3043b181d7 |
@@ -150,7 +150,7 @@
|
||||
"playwright/no-element-handle": "error",
|
||||
"playwright/no-eval": "error",
|
||||
"playwright/no-focused-test": "error",
|
||||
"playwright/no-force-option": "off",
|
||||
"playwright/no-force-option": "error",
|
||||
"playwright/no-networkidle": "error",
|
||||
"playwright/no-page-pause": "error",
|
||||
"playwright/no-skipped-test": "error",
|
||||
|
||||
@@ -351,7 +351,7 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
async dismissToasts() {
|
||||
const closeButtons = this.page.locator('.p-toast-close-button')
|
||||
for (const btn of await closeButtons.all()) {
|
||||
await btn.click({ force: true }).catch(() => {})
|
||||
await btn.click().catch(() => {})
|
||||
}
|
||||
// Wait for all toast elements to fully animate out and detach from DOM
|
||||
await expect(this.page.locator('.p-toast-message'))
|
||||
|
||||
@@ -71,7 +71,7 @@ export class Topbar {
|
||||
async closeWorkflowTab(tabName: string) {
|
||||
const tab = this.getWorkflowTab(tabName)
|
||||
await tab.hover()
|
||||
await tab.locator('.close-button').click({ force: true })
|
||||
await tab.locator('.close-button').click()
|
||||
}
|
||||
|
||||
getSaveDialog(): Locator {
|
||||
|
||||
@@ -151,6 +151,7 @@ export class BuilderSelectHelper {
|
||||
const widgetLocator = this.comfyPage.vueNodes
|
||||
.getNodeLocator(String(nodeRef.id))
|
||||
.getByLabel(widgetName, { exact: true })
|
||||
// oxlint-disable-next-line playwright/no-force-option -- Node container has conditional pointer-events:none that blocks actionability
|
||||
await widgetLocator.click({ force: true })
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
@@ -199,6 +200,7 @@ export class BuilderSelectHelper {
|
||||
const nodeLocator = this.comfyPage.vueNodes.getNodeLocator(
|
||||
String(nodeRef.id)
|
||||
)
|
||||
// oxlint-disable-next-line playwright/no-force-option -- Node container has conditional pointer-events:none that blocks actionability
|
||||
await nodeLocator.click({ force: true })
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
@@ -74,6 +74,51 @@ export class CanvasHelper {
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a canvas-element-relative position to absolute page coordinates.
|
||||
* Use with `page.mouse` APIs when Vue DOM overlays above the canvas would
|
||||
* cause Playwright's actionability check to fail on the canvas locator.
|
||||
*/
|
||||
private async toAbsolute(position: Position): Promise<Position> {
|
||||
const box = await this.canvas.boundingBox()
|
||||
if (!box) throw new Error('Canvas bounding box not available')
|
||||
return { x: box.x + position.x, y: box.y + position.y }
|
||||
}
|
||||
|
||||
/**
|
||||
* Click at canvas-element-relative coordinates using `page.mouse.click()`.
|
||||
* Bypasses Playwright's actionability checks on the canvas locator, which
|
||||
* can fail when Vue-rendered DOM nodes overlay the `<canvas>` element.
|
||||
*/
|
||||
async mouseClickAt(
|
||||
position: Position,
|
||||
options?: {
|
||||
button?: 'left' | 'right' | 'middle'
|
||||
modifiers?: ('Shift' | 'Control' | 'Alt' | 'Meta')[]
|
||||
}
|
||||
): Promise<void> {
|
||||
const abs = await this.toAbsolute(position)
|
||||
const modifiers = options?.modifiers ?? []
|
||||
for (const mod of modifiers) await this.page.keyboard.down(mod)
|
||||
try {
|
||||
await this.page.mouse.click(abs.x, abs.y, {
|
||||
button: options?.button
|
||||
})
|
||||
} finally {
|
||||
for (const mod of modifiers) await this.page.keyboard.up(mod)
|
||||
}
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Double-click at canvas-element-relative coordinates using `page.mouse`.
|
||||
*/
|
||||
async mouseDblclickAt(position: Position): Promise<void> {
|
||||
const abs = await this.toAbsolute(position)
|
||||
await this.page.mouse.dblclick(abs.x, abs.y)
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
async clickEmptySpace(): Promise<void> {
|
||||
await this.canvas.click({ position: DefaultGraphPositions.emptySpaceClick })
|
||||
await this.nextFrame()
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { ManageGroupNode } from '@e2e/helpers/manageGroupNode'
|
||||
@@ -356,7 +355,11 @@ export class NodeReference {
|
||||
}
|
||||
async click(
|
||||
position: 'title' | 'collapse',
|
||||
options?: Parameters<Page['click']>[1] & { moveMouseToEmptyArea?: boolean }
|
||||
options?: {
|
||||
button?: 'left' | 'right' | 'middle'
|
||||
modifiers?: ('Shift' | 'Control' | 'Alt' | 'Meta')[]
|
||||
moveMouseToEmptyArea?: boolean
|
||||
}
|
||||
) {
|
||||
let clickPos: Position
|
||||
switch (position) {
|
||||
@@ -377,12 +380,7 @@ export class NodeReference {
|
||||
delete options.moveMouseToEmptyArea
|
||||
}
|
||||
|
||||
await this.comfyPage.canvas.click({
|
||||
...options,
|
||||
position: clickPos,
|
||||
force: true
|
||||
})
|
||||
await this.comfyPage.nextFrame()
|
||||
await this.comfyPage.canvasOps.mouseClickAt(clickPos, options)
|
||||
if (moveMouseToEmptyArea) {
|
||||
await this.comfyPage.canvasOps.moveMouseToEmptyArea()
|
||||
}
|
||||
@@ -499,31 +497,18 @@ export class NodeReference {
|
||||
|
||||
await expect(async () => {
|
||||
// Try just clicking the enter button first
|
||||
await this.comfyPage.canvas.click({
|
||||
position: { x: 250, y: 250 },
|
||||
force: true
|
||||
})
|
||||
await this.comfyPage.nextFrame()
|
||||
await this.comfyPage.canvasOps.mouseClickAt({ x: 250, y: 250 })
|
||||
|
||||
await this.comfyPage.canvas.click({
|
||||
position: subgraphButtonPos,
|
||||
force: true
|
||||
})
|
||||
await this.comfyPage.nextFrame()
|
||||
await this.comfyPage.canvasOps.mouseClickAt(subgraphButtonPos)
|
||||
|
||||
if (await checkIsInSubgraph()) return
|
||||
|
||||
for (const position of clickPositions) {
|
||||
// Clear any selection first
|
||||
await this.comfyPage.canvas.click({
|
||||
position: { x: 250, y: 250 },
|
||||
force: true
|
||||
})
|
||||
await this.comfyPage.nextFrame()
|
||||
await this.comfyPage.canvasOps.mouseClickAt({ x: 250, y: 250 })
|
||||
|
||||
// Double-click to enter subgraph
|
||||
await this.comfyPage.canvas.dblclick({ position, force: true })
|
||||
await this.comfyPage.nextFrame()
|
||||
await this.comfyPage.canvasOps.mouseDblclickAt(position)
|
||||
|
||||
if (await checkIsInSubgraph()) return
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ async function openSelectionToolboxHelp(comfyPage: ComfyPage) {
|
||||
|
||||
const helpButton = comfyPage.selectionToolbox.getByTestId('info-button')
|
||||
await expect(helpButton).toBeVisible()
|
||||
await helpButton.click({ force: true })
|
||||
await helpButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
return comfyPage.page.getByTestId('properties-panel')
|
||||
|
||||
@@ -49,7 +49,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
|
||||
|
||||
const deleteButton = comfyPage.page.getByTestId('delete-button')
|
||||
await expect(deleteButton).toBeVisible()
|
||||
await deleteButton.click({ force: true })
|
||||
await deleteButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
@@ -65,7 +65,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
|
||||
|
||||
const infoButton = comfyPage.page.getByTestId('info-button')
|
||||
await expect(infoButton).toBeVisible()
|
||||
await infoButton.click({ force: true })
|
||||
await infoButton.click()
|
||||
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -98,7 +98,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
|
||||
|
||||
const deleteButton = comfyPage.page.getByTestId('delete-button')
|
||||
await expect(deleteButton).toBeVisible()
|
||||
await deleteButton.click({ force: true })
|
||||
await deleteButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
@@ -120,7 +120,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
|
||||
|
||||
const bypassButton = comfyPage.page.getByTestId('bypass-button')
|
||||
await expect(bypassButton).toBeVisible()
|
||||
await bypassButton.click({ force: true })
|
||||
await bypassButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect.poll(() => nodeRef.isBypassed()).toBe(true)
|
||||
@@ -128,7 +128,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
|
||||
BYPASS_CLASS
|
||||
)
|
||||
|
||||
await bypassButton.click({ force: true })
|
||||
await bypassButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect.poll(() => nodeRef.isBypassed()).toBe(false)
|
||||
@@ -147,7 +147,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
|
||||
'convert-to-subgraph-button'
|
||||
)
|
||||
await expect(convertButton).toBeVisible()
|
||||
await convertButton.click({ force: true })
|
||||
await convertButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// KSampler should be gone, replaced by a subgraph node
|
||||
@@ -175,7 +175,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
|
||||
'convert-to-subgraph-button'
|
||||
)
|
||||
await expect(convertButton).toBeVisible()
|
||||
await convertButton.click({ force: true })
|
||||
await convertButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
@@ -200,13 +200,14 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler', 'Empty Latent Image'])
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const frameButton = comfyPage.page.getByRole('button', {
|
||||
name: /Frame Nodes/i
|
||||
})
|
||||
await expect(frameButton).toBeVisible()
|
||||
await comfyPage.page
|
||||
await expect(
|
||||
comfyPage.selectionToolbox.getByRole('button', {
|
||||
name: /Frame Nodes/i
|
||||
})
|
||||
).toBeVisible()
|
||||
await comfyPage.selectionToolbox
|
||||
.getByRole('button', { name: /Frame Nodes/i })
|
||||
.click({ force: true })
|
||||
.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
|
||||
@@ -62,7 +62,7 @@ test.describe(
|
||||
return
|
||||
}
|
||||
|
||||
await moreOptionsBtn.click({ force: true })
|
||||
await moreOptionsBtn.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const menuOptionsVisibleAfterClick = await comfyPage.page
|
||||
@@ -126,9 +126,7 @@ test.describe(
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
|
||||
)[0]
|
||||
await openMoreOptions(comfyPage)
|
||||
await comfyPage.page
|
||||
.getByText('Rename', { exact: true })
|
||||
.click({ force: true })
|
||||
await comfyPage.page.getByText('Rename', { exact: true }).click()
|
||||
const input = comfyPage.page.locator(
|
||||
'.group-title-editor.node-title-editor .editable-text input'
|
||||
)
|
||||
@@ -153,11 +151,7 @@ test.describe(
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
await comfyPage.page
|
||||
.locator('#graph-canvas')
|
||||
.click({ position: { x: 0, y: 50 }, force: true })
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.canvasOps.mouseClickAt({ x: 0, y: 50 })
|
||||
await expect(
|
||||
comfyPage.page.getByText('Rename', { exact: true })
|
||||
).toBeHidden()
|
||||
|
||||
@@ -199,12 +199,7 @@ test.describe(
|
||||
|
||||
const stepsWidget = await ksampler.getWidget(2)
|
||||
const widgetPos = await stepsWidget.getPosition()
|
||||
await comfyPage.canvas.click({
|
||||
position: widgetPos,
|
||||
button: 'right',
|
||||
force: true
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.canvasOps.mouseClickAt(widgetPos, { button: 'right' })
|
||||
|
||||
// Look for the Promote Widget menu entry
|
||||
const promoteEntry = comfyPage.page
|
||||
@@ -235,12 +230,7 @@ test.describe(
|
||||
const stepsWidget = await ksampler.getWidget(2)
|
||||
const widgetPos = await stepsWidget.getPosition()
|
||||
|
||||
await comfyPage.canvas.click({
|
||||
position: widgetPos,
|
||||
button: 'right',
|
||||
force: true
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.canvasOps.mouseClickAt(widgetPos, { button: 'right' })
|
||||
|
||||
const promoteEntry = comfyPage.page
|
||||
.locator('.litemenu-entry')
|
||||
@@ -266,12 +256,7 @@ test.describe(
|
||||
const stepsWidget2 = await ksampler2.getWidget(2)
|
||||
const widgetPos2 = await stepsWidget2.getPosition()
|
||||
|
||||
await comfyPage.canvas.click({
|
||||
position: widgetPos2,
|
||||
button: 'right',
|
||||
force: true
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.canvasOps.mouseClickAt(widgetPos2, { button: 'right' })
|
||||
|
||||
const unpromoteEntry = comfyPage.page
|
||||
.locator('.litemenu-entry')
|
||||
|
||||
@@ -94,6 +94,7 @@ async function connectSlots(
|
||||
const fromLoc = slotLocator(page, from.nodeId, from.index, false)
|
||||
const toLoc = slotLocator(page, to.nodeId, to.index, true)
|
||||
await expectVisibleAll(fromLoc, toLoc)
|
||||
// oxlint-disable-next-line playwright/no-force-option -- Slot dot's parent wrapper div intercepts actionability check on inner dot
|
||||
await fromLoc.dragTo(toLoc, { force: true })
|
||||
await nextFrame()
|
||||
}
|
||||
@@ -192,6 +193,7 @@ test.describe('Vue Node Link Interaction', { tag: '@screenshot' }, () => {
|
||||
const inputSlot = slotLocator(comfyPage.page, clipNode.id, 0, true)
|
||||
await expectVisibleAll(outputSlot, inputSlot)
|
||||
|
||||
// oxlint-disable-next-line playwright/no-force-option -- Slot dot's parent wrapper div intercepts actionability check on inner dot
|
||||
await outputSlot.dragTo(inputSlot, { force: true })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
@@ -218,6 +220,7 @@ test.describe('Vue Node Link Interaction', { tag: '@screenshot' }, () => {
|
||||
const inputSlot = slotLocator(comfyPage.page, samplerNode.id, 3, true)
|
||||
await expectVisibleAll(outputSlot, inputSlot)
|
||||
|
||||
// oxlint-disable-next-line playwright/no-force-option -- Slot dot's parent wrapper div intercepts actionability check on inner dot
|
||||
await outputSlot.dragTo(inputSlot, { force: true })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
|
||||
@@ -22,10 +22,8 @@ test.describe('Vue Integer Widget', () => {
|
||||
const initialValue = Number(await controls.input.inputValue())
|
||||
|
||||
// Verify widget is disabled when linked
|
||||
await controls.incrementButton.click({ force: true })
|
||||
await expect(controls.input).toHaveValue(initialValue.toString())
|
||||
|
||||
await controls.decrementButton.click({ force: true })
|
||||
await expect(controls.incrementButton).toBeDisabled()
|
||||
await expect(controls.decrementButton).toBeDisabled()
|
||||
await expect(controls.input).toHaveValue(initialValue.toString())
|
||||
|
||||
await expect(seedWidget).toBeVisible()
|
||||
|
||||
@@ -23,7 +23,7 @@ See `docs/testing/*.md` for detailed patterns.
|
||||
|
||||
## Component Testing
|
||||
|
||||
- Use Vue Test Utils for component tests
|
||||
- Use `@testing-library/vue` with `@testing-library/user-event` for component tests (an ESLint rule bans `@vue/test-utils` in new tests)
|
||||
- Follow advice about making components easy to test
|
||||
- Wait for reactivity with `await nextTick()` after state changes
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ Our tests use the following frameworks and libraries:
|
||||
- [Vitest](https://vitest.dev/) - Test runner and assertion library
|
||||
- [@testing-library/vue](https://testing-library.com/docs/vue-testing-library/intro/) - Preferred for user-centric component testing
|
||||
- [@testing-library/user-event](https://testing-library.com/docs/user-event/intro/) - Realistic user interaction simulation
|
||||
- [@vue/test-utils](https://test-utils.vuejs.org/) - Vue component testing utilities (also accepted)
|
||||
- [@vue/test-utils](https://test-utils.vuejs.org/) - Vue component testing utilities (legacy; new tests must use @testing-library/vue)
|
||||
- [Pinia](https://pinia.vuejs.org/cookbook/testing.html) - For store testing
|
||||
|
||||
## Getting Started
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Component Testing Guide
|
||||
|
||||
> **Note**: New component tests must use `@testing-library/vue` with `@testing-library/user-event`. The examples below that use `@vue/test-utils` (`mount`, `wrapper`) are from legacy tests. An ESLint rule enforces this — importing from `@vue/test-utils` in `*.test.ts` files produces a lint error.
|
||||
|
||||
This guide covers patterns and examples for testing Vue components in the ComfyUI Frontend codebase.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -432,6 +432,23 @@ export default defineConfig([
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['**/*.test.ts'],
|
||||
rules: {
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
paths: [
|
||||
{
|
||||
name: '@vue/test-utils',
|
||||
message:
|
||||
'Use @testing-library/vue with @testing-library/user-event instead.'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
// Browser tests must use comfyPageFixture, not raw @playwright/test test
|
||||
{
|
||||
files: ['browser_tests/tests/**/*.spec.ts'],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.44.2",
|
||||
"version": "1.44.3",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -150,7 +150,6 @@
|
||||
"@vitejs/plugin-vue": "catalog:",
|
||||
"@vitest/coverage-v8": "catalog:",
|
||||
"@vitest/ui": "catalog:",
|
||||
"@vue/test-utils": "catalog:",
|
||||
"@webgpu/types": "catalog:",
|
||||
"cross-env": "catalog:",
|
||||
"eslint": "catalog:",
|
||||
|
||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -171,9 +171,6 @@ catalogs:
|
||||
'@vitest/ui':
|
||||
specifier: ^4.0.16
|
||||
version: 4.0.16
|
||||
'@vue/test-utils':
|
||||
specifier: ^2.4.6
|
||||
version: 2.4.6
|
||||
'@vueuse/core':
|
||||
specifier: ^14.2.0
|
||||
version: 14.2.0
|
||||
@@ -693,9 +690,6 @@ importers:
|
||||
'@vitest/ui':
|
||||
specifier: 'catalog:'
|
||||
version: 4.0.16(vitest@4.0.16)
|
||||
'@vue/test-utils':
|
||||
specifier: 'catalog:'
|
||||
version: 2.4.6
|
||||
'@webgpu/types':
|
||||
specifier: 'catalog:'
|
||||
version: 0.1.66
|
||||
|
||||
@@ -58,7 +58,6 @@ catalog:
|
||||
'@vitejs/plugin-vue': ^6.0.0
|
||||
'@vitest/coverage-v8': ^4.0.16
|
||||
'@vitest/ui': ^4.0.16
|
||||
'@vue/test-utils': ^2.4.6
|
||||
'@vueuse/core': ^14.2.0
|
||||
'@vueuse/integrations': ^14.2.0
|
||||
'@webgpu/types': ^0.1.66
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
@@ -6,8 +6,11 @@ import RangeEditor from './RangeEditor.vue'
|
||||
|
||||
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
|
||||
|
||||
function mountEditor(props: InstanceType<typeof RangeEditor>['$props']) {
|
||||
return mount(RangeEditor, {
|
||||
function renderEditor(props: {
|
||||
modelValue: { min: number; max: number; midpoint?: number }
|
||||
[key: string]: unknown
|
||||
}) {
|
||||
return render(RangeEditor, {
|
||||
props,
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
@@ -15,20 +18,19 @@ function mountEditor(props: InstanceType<typeof RangeEditor>['$props']) {
|
||||
|
||||
describe('RangeEditor', () => {
|
||||
it('renders with min and max handles', () => {
|
||||
const wrapper = mountEditor({ modelValue: { min: 0.2, max: 0.8 } })
|
||||
renderEditor({ modelValue: { min: 0.2, max: 0.8 } })
|
||||
|
||||
expect(wrapper.find('svg').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="handle-min"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="handle-max"]').exists()).toBe(true)
|
||||
expect(screen.getByTestId('handle-min')).toBeDefined()
|
||||
expect(screen.getByTestId('handle-max')).toBeDefined()
|
||||
})
|
||||
|
||||
it('highlights selected range in plain mode', () => {
|
||||
const wrapper = mountEditor({ modelValue: { min: 0.2, max: 0.8 } })
|
||||
renderEditor({ modelValue: { min: 0.2, max: 0.8 } })
|
||||
|
||||
const highlight = wrapper.find('[data-testid="range-highlight"]')
|
||||
expect(highlight.attributes('x')).toBe('0.2')
|
||||
const highlight = screen.getByTestId('range-highlight')
|
||||
expect(highlight.getAttribute('x')).toBe('0.2')
|
||||
expect(
|
||||
Number.parseFloat(highlight.attributes('width') ?? 'NaN')
|
||||
Number.parseFloat(highlight.getAttribute('width') ?? 'NaN')
|
||||
).toBeCloseTo(0.6, 6)
|
||||
})
|
||||
|
||||
@@ -37,37 +39,37 @@ describe('RangeEditor', () => {
|
||||
for (let i = 0; i < 256; i++)
|
||||
histogram[i] = Math.floor(50 + 50 * Math.sin(i / 20))
|
||||
|
||||
const wrapper = mountEditor({
|
||||
renderEditor({
|
||||
modelValue: { min: 0.2, max: 0.8 },
|
||||
display: 'histogram',
|
||||
histogram
|
||||
})
|
||||
|
||||
const left = wrapper.find('[data-testid="range-dim-left"]')
|
||||
const right = wrapper.find('[data-testid="range-dim-right"]')
|
||||
expect(left.attributes('width')).toBe('0.2')
|
||||
expect(right.attributes('x')).toBe('0.8')
|
||||
const left = screen.getByTestId('range-dim-left')
|
||||
const right = screen.getByTestId('range-dim-right')
|
||||
expect(left.getAttribute('width')).toBe('0.2')
|
||||
expect(right.getAttribute('x')).toBe('0.8')
|
||||
})
|
||||
|
||||
it('hides midpoint handle by default', () => {
|
||||
const wrapper = mountEditor({
|
||||
renderEditor({
|
||||
modelValue: { min: 0, max: 1, midpoint: 0.5 }
|
||||
})
|
||||
|
||||
expect(wrapper.find('[data-testid="handle-midpoint"]').exists()).toBe(false)
|
||||
expect(screen.queryByTestId('handle-midpoint')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows midpoint handle when showMidpoint is true', () => {
|
||||
const wrapper = mountEditor({
|
||||
renderEditor({
|
||||
modelValue: { min: 0, max: 1, midpoint: 0.5 },
|
||||
showMidpoint: true
|
||||
})
|
||||
|
||||
expect(wrapper.find('[data-testid="handle-midpoint"]').exists()).toBe(true)
|
||||
expect(screen.getByTestId('handle-midpoint')).toBeDefined()
|
||||
})
|
||||
|
||||
it('renders gradient background when display is gradient', () => {
|
||||
const wrapper = mountEditor({
|
||||
renderEditor({
|
||||
modelValue: { min: 0, max: 1 },
|
||||
display: 'gradient',
|
||||
gradientStops: [
|
||||
@@ -76,8 +78,8 @@ describe('RangeEditor', () => {
|
||||
]
|
||||
})
|
||||
|
||||
expect(wrapper.find('[data-testid="gradient-bg"]').exists()).toBe(true)
|
||||
expect(wrapper.find('linearGradient').exists()).toBe(true)
|
||||
expect(screen.getByTestId('gradient-bg')).toBeDefined()
|
||||
expect(screen.getByTestId('gradient-def')).toBeDefined()
|
||||
})
|
||||
|
||||
it('renders histogram path when display is histogram with data', () => {
|
||||
@@ -85,47 +87,43 @@ describe('RangeEditor', () => {
|
||||
for (let i = 0; i < 256; i++)
|
||||
histogram[i] = Math.floor(50 + 50 * Math.sin(i / 20))
|
||||
|
||||
const wrapper = mountEditor({
|
||||
renderEditor({
|
||||
modelValue: { min: 0, max: 1 },
|
||||
display: 'histogram',
|
||||
histogram
|
||||
})
|
||||
|
||||
expect(wrapper.find('[data-testid="histogram-path"]').exists()).toBe(true)
|
||||
expect(screen.getByTestId('histogram-path')).toBeDefined()
|
||||
})
|
||||
|
||||
it('renders inputs for min and max', () => {
|
||||
const wrapper = mountEditor({ modelValue: { min: 0.2, max: 0.8 } })
|
||||
renderEditor({ modelValue: { min: 0.2, max: 0.8 } })
|
||||
|
||||
const inputs = wrapper.findAll('input')
|
||||
const inputs = screen.getAllByRole('textbox')
|
||||
expect(inputs).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('renders midpoint input when showMidpoint is true', () => {
|
||||
const wrapper = mountEditor({
|
||||
renderEditor({
|
||||
modelValue: { min: 0, max: 1, midpoint: 0.5 },
|
||||
showMidpoint: true
|
||||
})
|
||||
|
||||
const inputs = wrapper.findAll('input')
|
||||
const inputs = screen.getAllByRole('textbox')
|
||||
expect(inputs).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('normalizes handle positions with custom value range', () => {
|
||||
const wrapper = mountEditor({
|
||||
renderEditor({
|
||||
modelValue: { min: 64, max: 192 },
|
||||
valueMin: 0,
|
||||
valueMax: 255
|
||||
})
|
||||
|
||||
const minHandle = wrapper.find('[data-testid="handle-min"]')
|
||||
const maxHandle = wrapper.find('[data-testid="handle-max"]')
|
||||
const minHandle = screen.getByTestId('handle-min')
|
||||
const maxHandle = screen.getByTestId('handle-max')
|
||||
|
||||
expect(
|
||||
Number.parseFloat((minHandle.element as HTMLElement).style.left)
|
||||
).toBeCloseTo(25, 0)
|
||||
expect(
|
||||
Number.parseFloat((maxHandle.element as HTMLElement).style.left)
|
||||
).toBeCloseTo(75, 0)
|
||||
expect(Number.parseFloat(minHandle.style.left)).toBeCloseTo(25, 0)
|
||||
expect(Number.parseFloat(maxHandle.style.left)).toBeCloseTo(75, 0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,7 +17,14 @@
|
||||
"
|
||||
>
|
||||
<defs v-if="display === 'gradient'">
|
||||
<linearGradient :id="gradientId" x1="0" y1="0" x2="1" y2="0">
|
||||
<linearGradient
|
||||
:id="gradientId"
|
||||
data-testid="gradient-def"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="1"
|
||||
y2="0"
|
||||
>
|
||||
<stop
|
||||
v-for="(stop, i) in computedStops"
|
||||
:key="i"
|
||||
|
||||
198
src/lib/litegraph/src/LGraphCanvas.cloneZIndex.test.ts
Normal file
198
src/lib/litegraph/src/LGraphCanvas.cloneZIndex.test.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import type { NodeLayout } from '@/renderer/core/layout/types'
|
||||
|
||||
import {
|
||||
LGraph,
|
||||
LGraphCanvas,
|
||||
LGraphNode,
|
||||
LiteGraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
const TEST_NODE_TYPE = 'test/CloneZIndex' as const
|
||||
|
||||
class TestNode extends LGraphNode {
|
||||
static override type = TEST_NODE_TYPE
|
||||
|
||||
constructor(title?: string) {
|
||||
super(title ?? TEST_NODE_TYPE)
|
||||
this.type = TEST_NODE_TYPE
|
||||
}
|
||||
}
|
||||
|
||||
function createCanvas(graph: LGraph): LGraphCanvas {
|
||||
const el = document.createElement('canvas')
|
||||
el.width = 800
|
||||
el.height = 600
|
||||
|
||||
const ctx = {
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
translate: vi.fn(),
|
||||
scale: vi.fn(),
|
||||
fillRect: vi.fn(),
|
||||
strokeRect: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
measureText: vi.fn().mockReturnValue({ width: 50 }),
|
||||
beginPath: vi.fn(),
|
||||
moveTo: vi.fn(),
|
||||
lineTo: vi.fn(),
|
||||
stroke: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
closePath: vi.fn(),
|
||||
arc: vi.fn(),
|
||||
rect: vi.fn(),
|
||||
clip: vi.fn(),
|
||||
clearRect: vi.fn(),
|
||||
setTransform: vi.fn(),
|
||||
roundRect: vi.fn(),
|
||||
getTransform: vi
|
||||
.fn()
|
||||
.mockReturnValue({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }),
|
||||
font: '',
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
lineWidth: 1,
|
||||
globalAlpha: 1,
|
||||
textAlign: 'left' as CanvasTextAlign,
|
||||
textBaseline: 'alphabetic' as CanvasTextBaseline
|
||||
} satisfies Partial<CanvasRenderingContext2D>
|
||||
|
||||
el.getContext = vi
|
||||
.fn()
|
||||
.mockReturnValue(ctx as unknown as CanvasRenderingContext2D)
|
||||
el.getBoundingClientRect = vi.fn().mockReturnValue({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 800,
|
||||
height: 600
|
||||
})
|
||||
|
||||
return new LGraphCanvas(el, graph, { skip_render: true })
|
||||
}
|
||||
|
||||
function createLayoutEntry(node: LGraphNode, zIndex: number) {
|
||||
const nodeId = String(node.id)
|
||||
const layout: NodeLayout = {
|
||||
id: nodeId,
|
||||
position: { x: node.pos[0], y: node.pos[1] },
|
||||
size: { width: node.size[0], height: node.size[1] },
|
||||
zIndex,
|
||||
visible: true,
|
||||
bounds: {
|
||||
x: node.pos[0],
|
||||
y: node.pos[1],
|
||||
width: node.size[0],
|
||||
height: node.size[1]
|
||||
}
|
||||
}
|
||||
layoutStore.applyOperation({
|
||||
type: 'createNode',
|
||||
entity: 'node',
|
||||
nodeId,
|
||||
layout,
|
||||
timestamp: Date.now(),
|
||||
source: LayoutSource.Canvas,
|
||||
actor: 'test'
|
||||
})
|
||||
}
|
||||
|
||||
function setZIndex(nodeId: string, zIndex: number, previousZIndex: number) {
|
||||
layoutStore.applyOperation({
|
||||
type: 'setNodeZIndex',
|
||||
entity: 'node',
|
||||
nodeId,
|
||||
zIndex,
|
||||
previousZIndex,
|
||||
timestamp: Date.now(),
|
||||
source: LayoutSource.Canvas,
|
||||
actor: 'test'
|
||||
})
|
||||
}
|
||||
|
||||
describe('cloned node z-index in Vue renderer', () => {
|
||||
let graph: LGraph
|
||||
let canvas: LGraphCanvas
|
||||
let previousVueNodesMode: boolean
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
previousVueNodesMode = LiteGraph.vueNodesMode
|
||||
LiteGraph.vueNodesMode = true
|
||||
LiteGraph.registerNodeType(TEST_NODE_TYPE, TestNode)
|
||||
|
||||
graph = new LGraph()
|
||||
canvas = createCanvas(graph)
|
||||
LGraphCanvas.active_canvas = canvas
|
||||
|
||||
layoutStore.initializeFromLiteGraph([])
|
||||
|
||||
// Simulate Vue runtime: create layout entries when nodes are added
|
||||
graph.onNodeAdded = (node: LGraphNode) => {
|
||||
createLayoutEntry(node, 0)
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
LiteGraph.vueNodesMode = previousVueNodesMode
|
||||
})
|
||||
|
||||
it('places cloned nodes above the original node z-index', () => {
|
||||
const originalNode = new TestNode()
|
||||
originalNode.pos = [100, 100]
|
||||
originalNode.size = [200, 100]
|
||||
graph.add(originalNode)
|
||||
|
||||
const originalNodeId = String(originalNode.id)
|
||||
|
||||
setZIndex(originalNodeId, 5, 0)
|
||||
|
||||
const originalLayout = layoutStore.getNodeLayoutRef(originalNodeId).value
|
||||
expect(originalLayout?.zIndex).toBe(5)
|
||||
|
||||
// Clone the node via cloneNodes (same path as right-click > clone)
|
||||
const result = LGraphCanvas.cloneNodes([originalNode])
|
||||
expect(result).toBeDefined()
|
||||
expect(result!.created.length).toBe(1)
|
||||
|
||||
const clonedNode = result!.created[0] as LGraphNode
|
||||
const clonedNodeId = String(clonedNode.id)
|
||||
|
||||
// The cloned node should have a z-index higher than the original
|
||||
const clonedLayout = layoutStore.getNodeLayoutRef(clonedNodeId).value
|
||||
expect(clonedLayout).toBeDefined()
|
||||
expect(clonedLayout!.zIndex).toBeGreaterThan(originalLayout!.zIndex)
|
||||
})
|
||||
|
||||
it('assigns distinct sequential z-indices when cloning multiple nodes', () => {
|
||||
const nodeA = new TestNode()
|
||||
nodeA.pos = [100, 100]
|
||||
nodeA.size = [200, 100]
|
||||
graph.add(nodeA)
|
||||
setZIndex(String(nodeA.id), 3, 0)
|
||||
|
||||
const nodeB = new TestNode()
|
||||
nodeB.pos = [400, 100]
|
||||
nodeB.size = [200, 100]
|
||||
graph.add(nodeB)
|
||||
setZIndex(String(nodeB.id), 7, 0)
|
||||
|
||||
const result = LGraphCanvas.cloneNodes([nodeA, nodeB])
|
||||
expect(result).toBeDefined()
|
||||
expect(result!.created.length).toBe(2)
|
||||
|
||||
const clonedA = result!.created[0] as LGraphNode
|
||||
const clonedB = result!.created[1] as LGraphNode
|
||||
const layoutA = layoutStore.getNodeLayoutRef(String(clonedA.id)).value!
|
||||
const layoutB = layoutStore.getNodeLayoutRef(String(clonedB.id)).value!
|
||||
|
||||
// Both cloned nodes should be above the highest original (z-index 7)
|
||||
expect(layoutA.zIndex).toBeGreaterThan(7)
|
||||
expect(layoutB.zIndex).toBeGreaterThan(7)
|
||||
|
||||
// Each cloned node should have a distinct z-index
|
||||
expect(layoutA.zIndex).not.toBe(layoutB.zIndex)
|
||||
})
|
||||
})
|
||||
@@ -7,6 +7,7 @@ import { AutoPanController } from '@/renderer/core/canvas/useAutoPan'
|
||||
import { LitegraphLinkAdapter } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
|
||||
import type { LinkRenderContext } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
|
||||
import { getSlotPosition } from '@/renderer/core/canvas/litegraph/slotCalculations'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import { forEachNode } from '@/utils/graphTraversalUtil'
|
||||
@@ -4270,6 +4271,17 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
if (newPositions.length) layoutStore.setSource(LayoutSource.Canvas)
|
||||
layoutStore.batchUpdateNodeBounds(newPositions)
|
||||
|
||||
// Bring cloned/pasted nodes to front so they render above the originals
|
||||
const allNodes = layoutStore.getAllNodes().value
|
||||
let maxZIndex = 0
|
||||
for (const [, layout] of allNodes) {
|
||||
if (layout.zIndex > maxZIndex) maxZIndex = layout.zIndex
|
||||
}
|
||||
const { setNodeZIndex } = useLayoutMutations()
|
||||
for (let i = 0; i < newPositions.length; i++) {
|
||||
setNodeZIndex(newPositions[i].nodeId, maxZIndex + i + 1)
|
||||
}
|
||||
|
||||
this.selectItems(created)
|
||||
forEachNode(graph, (n) => n.onGraphConfigured?.())
|
||||
forEachNode(graph, (n) => n.onAfterGraphConfigured?.())
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
@@ -17,8 +17,8 @@ const i18n = createI18n({
|
||||
}
|
||||
})
|
||||
|
||||
function mountRoleBadge(role: 'owner' | 'member') {
|
||||
return mount(RoleBadge, {
|
||||
function renderRoleBadge(role: 'owner' | 'member') {
|
||||
return render(RoleBadge, {
|
||||
props: { role },
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
@@ -26,12 +26,12 @@ function mountRoleBadge(role: 'owner' | 'member') {
|
||||
|
||||
describe('RoleBadge', () => {
|
||||
it('renders the owner label', () => {
|
||||
const wrapper = mountRoleBadge('owner')
|
||||
expect(wrapper.text()).toBe('Owner')
|
||||
renderRoleBadge('owner')
|
||||
expect(screen.getByText('Owner')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the member label', () => {
|
||||
const wrapper = mountRoleBadge('member')
|
||||
expect(wrapper.text()).toBe('Member')
|
||||
renderRoleBadge('member')
|
||||
expect(screen.getByText('Member')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import LinearWelcome from './LinearWelcome.vue'
|
||||
|
||||
const hasNodes = ref(false)
|
||||
const hasOutputs = ref(false)
|
||||
const enterBuilder = vi.fn()
|
||||
const { hasNodes, hasOutputs, enterBuilder } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { ref } = require('vue')
|
||||
return {
|
||||
hasNodes: ref(false),
|
||||
hasOutputs: ref(false),
|
||||
enterBuilder: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/useAppMode', () => ({
|
||||
useAppMode: () => ({ setMode: vi.fn() })
|
||||
@@ -33,12 +39,12 @@ vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
|
||||
const i18n = createI18n({ legacy: false, locale: 'en', missingWarn: false })
|
||||
|
||||
function mountComponent(
|
||||
function renderComponent(
|
||||
opts: { hasNodes?: boolean; hasOutputs?: boolean } = {}
|
||||
) {
|
||||
hasNodes.value = opts.hasNodes ?? false
|
||||
hasOutputs.value = opts.hasOutputs ?? false
|
||||
return mount(LinearWelcome, {
|
||||
return render(LinearWelcome, {
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
}
|
||||
@@ -51,30 +57,27 @@ describe('LinearWelcome', () => {
|
||||
})
|
||||
|
||||
it('shows empty workflow text when there are no nodes', () => {
|
||||
const wrapper = mountComponent({ hasNodes: false })
|
||||
renderComponent({ hasNodes: false })
|
||||
expect(
|
||||
wrapper.find('[data-testid="linear-welcome-empty-workflow"]').exists()
|
||||
).toBe(true)
|
||||
screen.getByTestId('linear-welcome-empty-workflow')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
wrapper.find('[data-testid="linear-welcome-build-app"]').exists()
|
||||
).toBe(false)
|
||||
screen.queryByTestId('linear-welcome-build-app')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows build app button when there are nodes but no outputs', () => {
|
||||
const wrapper = mountComponent({ hasNodes: true, hasOutputs: false })
|
||||
renderComponent({ hasNodes: true, hasOutputs: false })
|
||||
expect(
|
||||
wrapper.find('[data-testid="linear-welcome-empty-workflow"]').exists()
|
||||
).toBe(false)
|
||||
expect(
|
||||
wrapper.find('[data-testid="linear-welcome-build-app"]').exists()
|
||||
).toBe(true)
|
||||
screen.queryByTestId('linear-welcome-empty-workflow')
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('linear-welcome-build-app')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('clicking build app button calls enterBuilder', async () => {
|
||||
const wrapper = mountComponent({ hasNodes: true, hasOutputs: false })
|
||||
await wrapper
|
||||
.find('[data-testid="linear-welcome-build-app"]')
|
||||
.trigger('click')
|
||||
const user = userEvent.setup()
|
||||
renderComponent({ hasNodes: true, hasOutputs: false })
|
||||
await user.click(screen.getByTestId('linear-welcome-build-app'))
|
||||
expect(enterBuilder).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
/* eslint-disable testing-library/no-container */
|
||||
/* eslint-disable testing-library/no-node-access */
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { render } from '@testing-library/vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
@@ -13,7 +11,6 @@ import type {
|
||||
VueNodeData
|
||||
} from '@/composables/graph/useGraphNodeManager'
|
||||
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
@@ -98,35 +95,6 @@ describe('NodeWidgets', () => {
|
||||
})
|
||||
}
|
||||
|
||||
function mountComponent(nodeData?: VueNodeData, setupStores?: () => void) {
|
||||
const pinia = createTestingPinia({ stubActions: false })
|
||||
setActivePinia(pinia)
|
||||
setupStores?.()
|
||||
|
||||
return mount(NodeWidgets, {
|
||||
props: { nodeData },
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
stubs: { InputSlot: true },
|
||||
mocks: { $t: (key: string) => key }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const getBorderStyles = (wrapper: ReturnType<typeof mount>) =>
|
||||
fromAny<{ processedWidgets: unknown[] }, unknown>(
|
||||
wrapper.vm
|
||||
).processedWidgets.map(
|
||||
(entry) =>
|
||||
(
|
||||
entry as {
|
||||
simplified: {
|
||||
borderStyle?: string
|
||||
}
|
||||
}
|
||||
).simplified.borderStyle
|
||||
)
|
||||
|
||||
describe('node-type prop passing', () => {
|
||||
it('passes node type to widget components', () => {
|
||||
const widget = createMockWidget()
|
||||
@@ -155,19 +123,6 @@ describe('NodeWidgets', () => {
|
||||
expect(stub).not.toBeNull()
|
||||
expect(stub!.getAttribute('data-node-type')).toBe('')
|
||||
})
|
||||
|
||||
it.for(['CheckpointLoaderSimple', 'LoraLoader', 'VAELoader', 'KSampler'])(
|
||||
'passes correct node type: %s',
|
||||
(nodeType) => {
|
||||
const widget = createMockWidget()
|
||||
const nodeData = createMockNodeData(nodeType, [widget])
|
||||
const { container } = renderComponent(nodeData)
|
||||
|
||||
const stub = container.querySelector('.widget-stub')
|
||||
expect(stub).not.toBeNull()
|
||||
expect(stub!.getAttribute('data-node-type')).toBe(nodeType)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('deduplicates widgets with identical render identity while keeping distinct promoted sources', () => {
|
||||
@@ -318,54 +273,6 @@ describe('NodeWidgets', () => {
|
||||
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('applies promoted border styling to intermediate promoted widgets using host node identity', async () => {
|
||||
const promotedWidget = createMockWidget({
|
||||
name: 'text',
|
||||
type: 'combo',
|
||||
nodeId: 'inner-subgraph:1',
|
||||
storeNodeId: 'inner-subgraph:1',
|
||||
storeName: 'text',
|
||||
slotName: 'text'
|
||||
})
|
||||
const nodeData = createMockNodeData('SubgraphNode', [promotedWidget], '3')
|
||||
const wrapper = mountComponent(nodeData, () => {
|
||||
usePromotionStore().promote('graph-test', '4', {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '1'
|
||||
})
|
||||
})
|
||||
await nextTick()
|
||||
const borderStyles = getBorderStyles(wrapper)
|
||||
|
||||
expect(borderStyles.some((style) => style?.includes('promoted'))).toBe(true)
|
||||
})
|
||||
|
||||
it('does not apply promoted border styling to outermost widgets', async () => {
|
||||
const promotedWidget = createMockWidget({
|
||||
name: 'text',
|
||||
type: 'combo',
|
||||
nodeId: 'inner-subgraph:1',
|
||||
storeNodeId: 'inner-subgraph:1',
|
||||
storeName: 'text',
|
||||
slotName: 'text'
|
||||
})
|
||||
const nodeData = createMockNodeData('SubgraphNode', [promotedWidget], '4')
|
||||
const wrapper = mountComponent(nodeData, () => {
|
||||
usePromotionStore().promote('graph-test', '4', {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '1'
|
||||
})
|
||||
})
|
||||
await nextTick()
|
||||
const borderStyles = getBorderStyles(wrapper)
|
||||
|
||||
expect(borderStyles.some((style) => style?.includes('promoted'))).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('hides widgets when merged store options mark them hidden', async () => {
|
||||
const nodeData = createMockNodeData('TestNode', [
|
||||
createMockWidget({
|
||||
|
||||
@@ -80,56 +80,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TooltipOptions } from 'primevue'
|
||||
import { computed, onErrorCaptured, ref, toValue } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
import { onErrorCaptured, ref } from 'vue'
|
||||
|
||||
import type {
|
||||
SafeWidgetData,
|
||||
VueNodeData,
|
||||
WidgetSlotMetadata
|
||||
} from '@/composables/graph/useGraphNodeManager'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { showNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { st } from '@/i18n'
|
||||
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import AppInput from '@/renderer/extensions/linearMode/AppInput.vue'
|
||||
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
|
||||
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
|
||||
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
|
||||
import WidgetDOM from '@/renderer/extensions/vueNodes/widgets/components/WidgetDOM.vue'
|
||||
// Import widget components directly
|
||||
import WidgetLegacy from '@/renderer/extensions/vueNodes/widgets/components/WidgetLegacy.vue'
|
||||
import {
|
||||
getComponent,
|
||||
shouldExpand,
|
||||
shouldRenderAsVue
|
||||
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
|
||||
import { nodeTypeValidForApp } from '@/stores/appModeStore'
|
||||
import type { WidgetState } from '@/stores/widgetValueStore'
|
||||
import {
|
||||
stripGraphPrefix,
|
||||
useWidgetValueStore
|
||||
} from '@/stores/widgetValueStore'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import type {
|
||||
LinkedUpstreamInfo,
|
||||
SimplifiedWidget,
|
||||
WidgetValue
|
||||
} from '@/types/simplifiedWidget'
|
||||
import { useProcessedWidgets } from '@/renderer/extensions/vueNodes/composables/useProcessedWidgets'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import {
|
||||
getExecutionIdFromNodeData,
|
||||
getLocatorIdFromNodeData
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
import InputSlot from './InputSlot.vue'
|
||||
|
||||
@@ -141,12 +101,7 @@ const { nodeData } = defineProps<NodeWidgetsProps>()
|
||||
|
||||
const { shouldHandleNodePointerEvents, forwardEventToCanvas } =
|
||||
useCanvasInteractions()
|
||||
const { isSelectInputsMode } = useAppMode()
|
||||
const canvasStore = useCanvasStore()
|
||||
const { bringNodeToFront } = useNodeZIndex()
|
||||
const promotionStore = usePromotionStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const missingModelStore = useMissingModelStore()
|
||||
|
||||
function handleWidgetPointerEvent(event: PointerEvent) {
|
||||
if (shouldHandleNodePointerEvents.value) return
|
||||
@@ -160,8 +115,6 @@ function handleBringToFront() {
|
||||
}
|
||||
}
|
||||
|
||||
const { handleNodeRightClick } = useNodeEventHandlers()
|
||||
|
||||
// Error boundary implementation
|
||||
const renderError = ref<string | null>(null)
|
||||
|
||||
@@ -173,314 +126,11 @@ onErrorCaptured((error) => {
|
||||
return false
|
||||
})
|
||||
|
||||
const canSelectInputs = computed(
|
||||
() =>
|
||||
isSelectInputsMode.value &&
|
||||
nodeData?.mode === LGraphEventMode.ALWAYS &&
|
||||
nodeTypeValidForApp(nodeData.type) &&
|
||||
!nodeData.hasErrors
|
||||
)
|
||||
const nodeType = computed(() => nodeData?.type || '')
|
||||
const settingStore = useSettingStore()
|
||||
const showAdvanced = computed(
|
||||
() =>
|
||||
nodeData?.showAdvanced ||
|
||||
settingStore.get('Comfy.Node.AlwaysShowAdvancedWidgets')
|
||||
)
|
||||
const { getWidgetTooltip, createTooltipConfig } = useNodeTooltips(
|
||||
nodeType.value
|
||||
)
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
|
||||
function createWidgetUpdateHandler(
|
||||
widgetState: WidgetState | undefined,
|
||||
widget: SafeWidgetData,
|
||||
nodeExecId: string,
|
||||
widgetOptions: IWidgetOptions | Record<string, never>
|
||||
): (newValue: WidgetValue) => void {
|
||||
return (newValue: WidgetValue) => {
|
||||
if (widgetState) widgetState.value = newValue
|
||||
widget.callback?.(newValue)
|
||||
const effectiveExecId = widget.sourceExecutionId ?? nodeExecId
|
||||
executionErrorStore.clearWidgetRelatedErrors(
|
||||
effectiveExecId,
|
||||
widget.slotName ?? widget.name,
|
||||
widget.name,
|
||||
newValue,
|
||||
{ min: widgetOptions?.min, max: widgetOptions?.max }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
interface ProcessedWidget {
|
||||
advanced: boolean
|
||||
handleContextMenu: (e: PointerEvent) => void
|
||||
hasLayoutSize: boolean
|
||||
hasError: boolean
|
||||
hidden: boolean
|
||||
id: string
|
||||
name: string
|
||||
renderKey: string
|
||||
simplified: SimplifiedWidget
|
||||
tooltipConfig: TooltipOptions
|
||||
type: string
|
||||
updateHandler: (value: WidgetValue) => void
|
||||
value: WidgetValue
|
||||
vueComponent: Component
|
||||
slotMetadata?: WidgetSlotMetadata
|
||||
}
|
||||
|
||||
function hasWidgetError(
|
||||
widget: SafeWidgetData,
|
||||
nodeExecId: string,
|
||||
nodeErrors: { errors: { extra_info?: { input_name?: string } }[] } | undefined
|
||||
): boolean {
|
||||
const errors = widget.sourceExecutionId
|
||||
? executionErrorStore.lastNodeErrors?.[widget.sourceExecutionId]?.errors
|
||||
: nodeErrors?.errors
|
||||
const inputName = widget.slotName ?? widget.name
|
||||
return (
|
||||
!!errors?.some((e) => e.extra_info?.input_name === inputName) ||
|
||||
missingModelStore.isWidgetMissingModel(
|
||||
widget.sourceExecutionId ?? nodeExecId,
|
||||
widget.name
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function getWidgetIdentity(
|
||||
widget: SafeWidgetData,
|
||||
nodeId: string | number | undefined,
|
||||
index: number
|
||||
): {
|
||||
dedupeIdentity?: string
|
||||
renderKey: string
|
||||
} {
|
||||
const rawWidgetId = widget.storeNodeId ?? widget.nodeId
|
||||
const storeWidgetName = widget.storeName ?? widget.name
|
||||
const slotNameForIdentity = widget.slotName ?? widget.name
|
||||
const stableIdentityRoot = rawWidgetId
|
||||
? `node:${String(stripGraphPrefix(rawWidgetId))}`
|
||||
: widget.sourceExecutionId
|
||||
? `exec:${widget.sourceExecutionId}`
|
||||
: undefined
|
||||
|
||||
const dedupeIdentity = stableIdentityRoot
|
||||
? `${stableIdentityRoot}:${storeWidgetName}:${slotNameForIdentity}:${widget.type}`
|
||||
: undefined
|
||||
const renderKey =
|
||||
dedupeIdentity ??
|
||||
`transient:${String(nodeId ?? '')}:${storeWidgetName}:${slotNameForIdentity}:${widget.type}:${index}`
|
||||
|
||||
return {
|
||||
dedupeIdentity,
|
||||
renderKey
|
||||
}
|
||||
}
|
||||
|
||||
function isWidgetVisible(options: IWidgetOptions): boolean {
|
||||
const hidden = options.hidden ?? false
|
||||
const advanced = options.advanced ?? false
|
||||
return !hidden && (!advanced || showAdvanced.value)
|
||||
}
|
||||
|
||||
const processedWidgets = computed((): ProcessedWidget[] => {
|
||||
if (!nodeData?.widgets) return []
|
||||
|
||||
// nodeData.id is the local node ID; subgraph nodes need the full execution
|
||||
// path (e.g. "65:63") to match keys in lastNodeErrors.
|
||||
const nodeExecId = app.isGraphReady
|
||||
? getExecutionIdFromNodeData(app.rootGraph, nodeData)
|
||||
: String(nodeData.id ?? '')
|
||||
|
||||
const nodeErrors = executionErrorStore.lastNodeErrors?.[nodeExecId]
|
||||
const graphId = canvasStore.canvas?.graph?.rootGraph.id
|
||||
|
||||
const nodeId = nodeData.id
|
||||
const { widgets } = nodeData
|
||||
const result: ProcessedWidget[] = []
|
||||
const uniqueWidgets: Array<{
|
||||
widget: SafeWidgetData
|
||||
identity: ReturnType<typeof getWidgetIdentity>
|
||||
mergedOptions: IWidgetOptions
|
||||
widgetState: WidgetState | undefined
|
||||
isVisible: boolean
|
||||
}> = []
|
||||
const dedupeIndexByIdentity = new Map<string, number>()
|
||||
|
||||
for (const [index, widget] of widgets.entries()) {
|
||||
if (!shouldRenderAsVue(widget)) continue
|
||||
|
||||
const identity = getWidgetIdentity(widget, nodeId, index)
|
||||
const storeWidgetName = widget.storeName ?? widget.name
|
||||
const bareWidgetId = String(
|
||||
stripGraphPrefix(widget.storeNodeId ?? widget.nodeId ?? nodeId ?? '')
|
||||
)
|
||||
const widgetState = graphId
|
||||
? widgetValueStore.getWidget(graphId, bareWidgetId, storeWidgetName)
|
||||
: undefined
|
||||
const mergedOptions: IWidgetOptions = {
|
||||
...(widget.options ?? {}),
|
||||
...(widgetState?.options ?? {})
|
||||
}
|
||||
const visible = isWidgetVisible(mergedOptions)
|
||||
if (!identity.dedupeIdentity) {
|
||||
uniqueWidgets.push({
|
||||
widget,
|
||||
identity,
|
||||
mergedOptions,
|
||||
widgetState,
|
||||
isVisible: visible
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const existingIndex = dedupeIndexByIdentity.get(identity.dedupeIdentity)
|
||||
if (existingIndex === undefined) {
|
||||
dedupeIndexByIdentity.set(identity.dedupeIdentity, uniqueWidgets.length)
|
||||
uniqueWidgets.push({
|
||||
widget,
|
||||
identity,
|
||||
mergedOptions,
|
||||
widgetState,
|
||||
isVisible: visible
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const existingWidget = uniqueWidgets[existingIndex]
|
||||
if (existingWidget && !existingWidget.isVisible && visible) {
|
||||
uniqueWidgets[existingIndex] = {
|
||||
widget,
|
||||
identity,
|
||||
mergedOptions,
|
||||
widgetState,
|
||||
isVisible: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const {
|
||||
widget,
|
||||
mergedOptions,
|
||||
widgetState,
|
||||
identity: { renderKey }
|
||||
} of uniqueWidgets) {
|
||||
const hostNodeId = String(nodeId ?? '')
|
||||
const bareWidgetId = String(
|
||||
stripGraphPrefix(widget.storeNodeId ?? widget.nodeId ?? nodeId ?? '')
|
||||
)
|
||||
const promotionSourceNodeId = widget.storeName
|
||||
? String(bareWidgetId)
|
||||
: undefined
|
||||
|
||||
const vueComponent =
|
||||
getComponent(widget.type) ||
|
||||
(widget.isDOMWidget ? WidgetDOM : WidgetLegacy)
|
||||
|
||||
const { slotMetadata } = widget
|
||||
|
||||
// Get value from store (falls back to undefined if not registered)
|
||||
const value = widgetState?.value as WidgetValue
|
||||
|
||||
// Build options from store state, with disabled override for
|
||||
// slot-linked widgets or widgets with disabled state (e.g. display-only)
|
||||
const isDisabled = slotMetadata?.linked || widgetState?.disabled
|
||||
const widgetOptions = isDisabled
|
||||
? { ...mergedOptions, disabled: true }
|
||||
: mergedOptions
|
||||
|
||||
const borderStyle =
|
||||
graphId &&
|
||||
promotionStore.isPromotedByAny(graphId, {
|
||||
sourceNodeId: hostNodeId,
|
||||
sourceWidgetName: widget.storeName ?? widget.name,
|
||||
disambiguatingSourceNodeId: promotionSourceNodeId
|
||||
})
|
||||
? 'ring ring-component-node-widget-promoted'
|
||||
: mergedOptions.advanced
|
||||
? 'ring ring-component-node-widget-advanced'
|
||||
: undefined
|
||||
|
||||
const linkedUpstream: LinkedUpstreamInfo | undefined =
|
||||
slotMetadata?.linked && slotMetadata.originNodeId
|
||||
? {
|
||||
nodeId: slotMetadata.originNodeId,
|
||||
outputName: slotMetadata.originOutputName
|
||||
}
|
||||
: undefined
|
||||
|
||||
const nodeLocatorId = widget.nodeId
|
||||
? widget.nodeId
|
||||
: nodeData
|
||||
? getLocatorIdFromNodeData(nodeData)
|
||||
: undefined
|
||||
|
||||
const simplified: SimplifiedWidget = {
|
||||
name: widget.name,
|
||||
type: widget.type,
|
||||
value,
|
||||
borderStyle,
|
||||
callback: widget.callback,
|
||||
controlWidget: widget.controlWidget,
|
||||
label: widget.promotedLabel ?? widgetState?.label,
|
||||
linkedUpstream,
|
||||
nodeLocatorId,
|
||||
options: widgetOptions,
|
||||
spec: widget.spec
|
||||
}
|
||||
|
||||
const updateHandler = createWidgetUpdateHandler(
|
||||
widgetState,
|
||||
widget,
|
||||
nodeExecId,
|
||||
widgetOptions
|
||||
)
|
||||
|
||||
const tooltipText = getWidgetTooltip(widget)
|
||||
const tooltipConfig = createTooltipConfig(tooltipText)
|
||||
const handleContextMenu = (e: PointerEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleNodeRightClick(e, nodeId)
|
||||
showNodeOptions(
|
||||
e,
|
||||
widget.name,
|
||||
widget.nodeId !== undefined
|
||||
? String(stripGraphPrefix(widget.nodeId))
|
||||
: undefined
|
||||
)
|
||||
}
|
||||
|
||||
result.push({
|
||||
advanced: mergedOptions.advanced ?? false,
|
||||
handleContextMenu,
|
||||
hasLayoutSize: widget.hasLayoutSize ?? false,
|
||||
hasError: hasWidgetError(widget, nodeExecId, nodeErrors),
|
||||
hidden: mergedOptions.hidden ?? false,
|
||||
id: String(bareWidgetId),
|
||||
name: widget.name,
|
||||
renderKey,
|
||||
type: widget.type,
|
||||
vueComponent,
|
||||
simplified,
|
||||
value,
|
||||
updateHandler,
|
||||
tooltipConfig,
|
||||
slotMetadata
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const gridTemplateRows = computed((): string => {
|
||||
// Use processedWidgets directly since it already has store-based hidden/advanced
|
||||
return toValue(processedWidgets)
|
||||
.filter((w) => !w.hidden && (!w.advanced || showAdvanced.value))
|
||||
.map((w) =>
|
||||
shouldExpand(w.type) || w.hasLayoutSize ? 'auto' : 'min-content'
|
||||
)
|
||||
.join(' ')
|
||||
})
|
||||
const {
|
||||
canSelectInputs,
|
||||
gridTemplateRows,
|
||||
nodeType,
|
||||
processedWidgets,
|
||||
showAdvanced
|
||||
} = useProcessedWidgets(() => nodeData)
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,499 @@
|
||||
import type { TooltipOptions } from 'primevue'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { SafeWidgetData } from '@/composables/graph/useGraphNodeManager'
|
||||
import {
|
||||
computeProcessedWidgets,
|
||||
getWidgetIdentity,
|
||||
hasWidgetError,
|
||||
isWidgetVisible
|
||||
} from '@/renderer/extensions/vueNodes/composables/useProcessedWidgets'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
canvas: {
|
||||
graph: {
|
||||
rootGraph: {
|
||||
id: 'graph-test'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
const createMockWidget = (
|
||||
overrides: Partial<SafeWidgetData> = {}
|
||||
): SafeWidgetData => ({
|
||||
nodeId: 'test_node',
|
||||
name: 'test_widget',
|
||||
type: 'combo',
|
||||
options: undefined,
|
||||
callback: undefined,
|
||||
spec: undefined,
|
||||
isDOMWidget: false,
|
||||
slotMetadata: undefined,
|
||||
...overrides
|
||||
})
|
||||
|
||||
describe('getWidgetIdentity', () => {
|
||||
it('returns stable dedupeIdentity for widgets with storeNodeId', () => {
|
||||
const widget = createMockWidget({
|
||||
storeNodeId: 'subgraph:19',
|
||||
storeName: 'text',
|
||||
slotName: 'text',
|
||||
type: 'text'
|
||||
})
|
||||
const { dedupeIdentity, renderKey } = getWidgetIdentity(widget, '1', 0)
|
||||
expect(dedupeIdentity).toBe('node:19:text:text:text')
|
||||
expect(renderKey).toBe(dedupeIdentity)
|
||||
})
|
||||
|
||||
it('returns transient renderKey for widgets without stable identity', () => {
|
||||
const widget = createMockWidget({
|
||||
nodeId: undefined,
|
||||
storeNodeId: undefined,
|
||||
sourceExecutionId: undefined
|
||||
})
|
||||
const { dedupeIdentity, renderKey } = getWidgetIdentity(widget, '5', 3)
|
||||
expect(dedupeIdentity).toBeUndefined()
|
||||
expect(renderKey).toBe('transient:5:test_widget:test_widget:combo:3')
|
||||
})
|
||||
|
||||
it('uses sourceExecutionId for identity when no nodeId', () => {
|
||||
const widget = createMockWidget({
|
||||
nodeId: undefined,
|
||||
storeNodeId: undefined,
|
||||
sourceExecutionId: '65:18'
|
||||
})
|
||||
const { dedupeIdentity } = getWidgetIdentity(widget, '1', 0)
|
||||
expect(dedupeIdentity).toBe('exec:65:18:test_widget:test_widget:combo')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isWidgetVisible', () => {
|
||||
it('returns true for normal widgets', () => {
|
||||
expect(isWidgetVisible({}, false)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for hidden widgets', () => {
|
||||
expect(isWidgetVisible({ hidden: true }, false)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for advanced widgets when showAdvanced is false', () => {
|
||||
expect(isWidgetVisible({ advanced: true }, false)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true for advanced widgets when showAdvanced is true', () => {
|
||||
expect(isWidgetVisible({ advanced: true }, true)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasWidgetError', () => {
|
||||
let executionErrorStore: ReturnType<typeof useExecutionErrorStore>
|
||||
let missingModelStore: ReturnType<typeof useMissingModelStore>
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
executionErrorStore = useExecutionErrorStore()
|
||||
missingModelStore = useMissingModelStore()
|
||||
})
|
||||
|
||||
it('returns false when no errors', () => {
|
||||
const widget = createMockWidget()
|
||||
expect(
|
||||
hasWidgetError(
|
||||
widget,
|
||||
'1',
|
||||
undefined,
|
||||
executionErrorStore,
|
||||
missingModelStore
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when node has matching input error', () => {
|
||||
const widget = createMockWidget({ name: 'seed' })
|
||||
const nodeErrors = {
|
||||
errors: [{ extra_info: { input_name: 'seed' } }]
|
||||
}
|
||||
expect(
|
||||
hasWidgetError(
|
||||
widget,
|
||||
'1',
|
||||
nodeErrors,
|
||||
executionErrorStore,
|
||||
missingModelStore
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true via sourceExecutionId when execution store has matching error', () => {
|
||||
const widget = createMockWidget({
|
||||
name: 'seed',
|
||||
sourceExecutionId: '65:18'
|
||||
})
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'65:18': {
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'seed is required',
|
||||
details: '',
|
||||
extra_info: { input_name: 'seed' }
|
||||
}
|
||||
],
|
||||
class_type: 'TestNode',
|
||||
dependent_outputs: []
|
||||
}
|
||||
}
|
||||
expect(
|
||||
hasWidgetError(
|
||||
widget,
|
||||
'1',
|
||||
undefined,
|
||||
executionErrorStore,
|
||||
missingModelStore
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true when widget has missing model', () => {
|
||||
const widget = createMockWidget({ name: 'ckpt_name' })
|
||||
vi.spyOn(missingModelStore, 'isWidgetMissingModel').mockReturnValue(true)
|
||||
expect(
|
||||
hasWidgetError(
|
||||
widget,
|
||||
'1',
|
||||
undefined,
|
||||
executionErrorStore,
|
||||
missingModelStore
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('uses slotName for error matching when present', () => {
|
||||
const widget = createMockWidget({
|
||||
name: 'internal_name',
|
||||
slotName: 'display_slot'
|
||||
})
|
||||
const nodeErrors = {
|
||||
errors: [{ extra_info: { input_name: 'display_slot' } }]
|
||||
}
|
||||
expect(
|
||||
hasWidgetError(
|
||||
widget,
|
||||
'1',
|
||||
nodeErrors,
|
||||
executionErrorStore,
|
||||
missingModelStore
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
const noopUi = {
|
||||
getTooltipConfig: () => ({}) as TooltipOptions,
|
||||
handleNodeRightClick: () => {}
|
||||
}
|
||||
|
||||
describe('computeProcessedWidgets borderStyle', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('applies promoted border styling to intermediate promoted widgets', () => {
|
||||
const promotedWidget = createMockWidget({
|
||||
name: 'text',
|
||||
type: 'combo',
|
||||
nodeId: 'inner-subgraph:1',
|
||||
storeNodeId: 'inner-subgraph:1',
|
||||
storeName: 'text',
|
||||
slotName: 'text'
|
||||
})
|
||||
|
||||
usePromotionStore().promote('graph-test', '4', {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '1'
|
||||
})
|
||||
|
||||
const result = computeProcessedWidgets({
|
||||
nodeData: {
|
||||
id: '3',
|
||||
type: 'SubgraphNode',
|
||||
widgets: [promotedWidget],
|
||||
title: 'Test',
|
||||
mode: 0,
|
||||
selected: false,
|
||||
executing: false,
|
||||
inputs: [],
|
||||
outputs: []
|
||||
},
|
||||
graphId: 'graph-test',
|
||||
showAdvanced: false,
|
||||
isGraphReady: false,
|
||||
rootGraph: null,
|
||||
ui: noopUi
|
||||
})
|
||||
|
||||
expect(
|
||||
result.some((w) => w.simplified.borderStyle?.includes('promoted'))
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('does not apply promoted border styling to outermost widgets', () => {
|
||||
const promotedWidget = createMockWidget({
|
||||
name: 'text',
|
||||
type: 'combo',
|
||||
nodeId: 'inner-subgraph:1',
|
||||
storeNodeId: 'inner-subgraph:1',
|
||||
storeName: 'text',
|
||||
slotName: 'text'
|
||||
})
|
||||
|
||||
usePromotionStore().promote('graph-test', '4', {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '1'
|
||||
})
|
||||
|
||||
const result = computeProcessedWidgets({
|
||||
nodeData: {
|
||||
id: '4',
|
||||
type: 'SubgraphNode',
|
||||
widgets: [promotedWidget],
|
||||
title: 'Test',
|
||||
mode: 0,
|
||||
selected: false,
|
||||
executing: false,
|
||||
inputs: [],
|
||||
outputs: []
|
||||
},
|
||||
graphId: 'graph-test',
|
||||
showAdvanced: false,
|
||||
isGraphReady: false,
|
||||
rootGraph: null,
|
||||
ui: noopUi
|
||||
})
|
||||
|
||||
expect(
|
||||
result.some((w) => w.simplified.borderStyle?.includes('promoted'))
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('applies advanced border styling to advanced widgets', () => {
|
||||
const advancedWidget = createMockWidget({
|
||||
name: 'text',
|
||||
type: 'combo',
|
||||
options: { advanced: true }
|
||||
})
|
||||
|
||||
const result = computeProcessedWidgets({
|
||||
nodeData: {
|
||||
id: '1',
|
||||
type: 'TestNode',
|
||||
widgets: [advancedWidget],
|
||||
title: 'Test',
|
||||
mode: 0,
|
||||
selected: false,
|
||||
executing: false,
|
||||
inputs: [],
|
||||
outputs: []
|
||||
},
|
||||
graphId: 'graph-test',
|
||||
showAdvanced: true,
|
||||
isGraphReady: false,
|
||||
rootGraph: null,
|
||||
ui: noopUi
|
||||
})
|
||||
|
||||
expect(result[0].simplified.borderStyle).toBe(
|
||||
'ring ring-component-node-widget-advanced'
|
||||
)
|
||||
})
|
||||
|
||||
it('deduplication keeps visible widget over hidden duplicate', () => {
|
||||
const hiddenWidget = createMockWidget({
|
||||
name: 'text',
|
||||
type: 'combo',
|
||||
nodeId: '1',
|
||||
storeNodeId: '1',
|
||||
storeName: 'text',
|
||||
slotName: 'text',
|
||||
options: { hidden: true }
|
||||
})
|
||||
|
||||
const visibleWidget = createMockWidget({
|
||||
name: 'text',
|
||||
type: 'combo',
|
||||
nodeId: '1',
|
||||
storeNodeId: '1',
|
||||
storeName: 'text',
|
||||
slotName: 'text'
|
||||
})
|
||||
|
||||
const result = computeProcessedWidgets({
|
||||
nodeData: {
|
||||
id: '1',
|
||||
type: 'TestNode',
|
||||
widgets: [hiddenWidget, visibleWidget],
|
||||
title: 'Test',
|
||||
mode: 0,
|
||||
selected: false,
|
||||
executing: false,
|
||||
inputs: [],
|
||||
outputs: []
|
||||
},
|
||||
graphId: 'graph-test',
|
||||
showAdvanced: false,
|
||||
isGraphReady: false,
|
||||
rootGraph: null,
|
||||
ui: noopUi
|
||||
})
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].hidden).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => {
|
||||
const GRAPH_ID = 'graph-test'
|
||||
const NODE_ID = '1'
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
function processWidgets(widgets: SafeWidgetData[]) {
|
||||
return computeProcessedWidgets({
|
||||
nodeData: {
|
||||
id: NODE_ID,
|
||||
type: 'TestNode',
|
||||
widgets,
|
||||
title: 'Test',
|
||||
mode: 0,
|
||||
selected: false,
|
||||
executing: false,
|
||||
inputs: [],
|
||||
outputs: []
|
||||
},
|
||||
graphId: GRAPH_ID,
|
||||
showAdvanced: false,
|
||||
isGraphReady: false,
|
||||
rootGraph: null,
|
||||
ui: noopUi
|
||||
})
|
||||
}
|
||||
|
||||
it('calls widget.callback with the new value when widgetState exists', () => {
|
||||
const callback = vi.fn()
|
||||
const widget = createMockWidget({
|
||||
name: 'seed',
|
||||
nodeId: NODE_ID,
|
||||
callback
|
||||
})
|
||||
|
||||
useWidgetValueStore().registerWidget(GRAPH_ID, {
|
||||
nodeId: NODE_ID,
|
||||
name: 'seed',
|
||||
type: 'combo',
|
||||
value: 0,
|
||||
options: {}
|
||||
})
|
||||
|
||||
const [processed] = processWidgets([widget])
|
||||
processed.updateHandler(42)
|
||||
|
||||
expect(callback).toHaveBeenCalledWith(42)
|
||||
})
|
||||
|
||||
it('calls widget.callback even when widgetState is undefined (no store entry)', () => {
|
||||
const callback = vi.fn()
|
||||
const widget = createMockWidget({
|
||||
name: 'unregistered_widget',
|
||||
nodeId: NODE_ID,
|
||||
callback
|
||||
})
|
||||
|
||||
const [processed] = processWidgets([widget])
|
||||
processed.updateHandler('new-value')
|
||||
|
||||
expect(callback).toHaveBeenCalledWith('new-value')
|
||||
})
|
||||
|
||||
it('updates widgetState.value when store entry exists', () => {
|
||||
const widget = createMockWidget({
|
||||
name: 'seed',
|
||||
nodeId: NODE_ID
|
||||
})
|
||||
|
||||
useWidgetValueStore().registerWidget(GRAPH_ID, {
|
||||
nodeId: NODE_ID,
|
||||
name: 'seed',
|
||||
type: 'combo',
|
||||
value: 0,
|
||||
options: {}
|
||||
})
|
||||
|
||||
const [processed] = processWidgets([widget])
|
||||
processed.updateHandler(99)
|
||||
|
||||
const state = useWidgetValueStore().getWidget(GRAPH_ID, NODE_ID, 'seed')
|
||||
expect(state?.value).toBe(99)
|
||||
})
|
||||
|
||||
it('clears execution errors on update', () => {
|
||||
const widget = createMockWidget({
|
||||
name: 'seed',
|
||||
nodeId: NODE_ID
|
||||
})
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const missingModelStore = useMissingModelStore()
|
||||
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
[NODE_ID]: {
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'seed is required',
|
||||
details: '',
|
||||
extra_info: { input_name: 'seed' }
|
||||
}
|
||||
],
|
||||
class_type: 'TestNode',
|
||||
dependent_outputs: []
|
||||
}
|
||||
}
|
||||
|
||||
const [processed] = processWidgets([widget])
|
||||
|
||||
expect(
|
||||
hasWidgetError(
|
||||
widget,
|
||||
NODE_ID,
|
||||
executionErrorStore.lastNodeErrors[NODE_ID],
|
||||
executionErrorStore,
|
||||
missingModelStore
|
||||
)
|
||||
).toBe(true)
|
||||
|
||||
processed.updateHandler('fixed-value')
|
||||
|
||||
expect(
|
||||
hasWidgetError(
|
||||
widget,
|
||||
NODE_ID,
|
||||
executionErrorStore.lastNodeErrors?.[NODE_ID],
|
||||
executionErrorStore,
|
||||
missingModelStore
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,431 @@
|
||||
import type { TooltipOptions } from 'primevue'
|
||||
import { computed } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
|
||||
import type {
|
||||
SafeWidgetData,
|
||||
VueNodeData,
|
||||
WidgetSlotMetadata
|
||||
} from '@/composables/graph/useGraphNodeManager'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { showNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
|
||||
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
|
||||
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
|
||||
import WidgetDOM from '@/renderer/extensions/vueNodes/widgets/components/WidgetDOM.vue'
|
||||
import WidgetLegacy from '@/renderer/extensions/vueNodes/widgets/components/WidgetLegacy.vue'
|
||||
import {
|
||||
getComponent,
|
||||
shouldExpand,
|
||||
shouldRenderAsVue
|
||||
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
|
||||
import { nodeTypeValidForApp } from '@/stores/appModeStore'
|
||||
import type { WidgetState } from '@/stores/widgetValueStore'
|
||||
import {
|
||||
stripGraphPrefix,
|
||||
useWidgetValueStore
|
||||
} from '@/stores/widgetValueStore'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
LinkedUpstreamInfo,
|
||||
SimplifiedWidget,
|
||||
WidgetValue
|
||||
} from '@/types/simplifiedWidget'
|
||||
import {
|
||||
getExecutionIdFromNodeData,
|
||||
getLocatorIdFromNodeData
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
|
||||
interface ProcessedWidget {
|
||||
advanced: boolean
|
||||
handleContextMenu: (e: PointerEvent) => void
|
||||
hasLayoutSize: boolean
|
||||
hasError: boolean
|
||||
hidden: boolean
|
||||
id: string
|
||||
name: string
|
||||
renderKey: string
|
||||
simplified: SimplifiedWidget
|
||||
tooltipConfig: TooltipOptions
|
||||
type: string
|
||||
updateHandler: (value: WidgetValue) => void
|
||||
value: WidgetValue
|
||||
vueComponent: Component
|
||||
slotMetadata?: WidgetSlotMetadata
|
||||
}
|
||||
|
||||
interface WidgetUiCallbacks {
|
||||
getTooltipConfig: (widget: SafeWidgetData) => TooltipOptions
|
||||
handleNodeRightClick: (e: PointerEvent, nodeId: string) => void
|
||||
}
|
||||
|
||||
interface ComputeProcessedWidgetsOptions {
|
||||
nodeData: VueNodeData | undefined
|
||||
graphId: string | undefined
|
||||
showAdvanced: boolean
|
||||
isGraphReady: boolean
|
||||
rootGraph: LGraph | null
|
||||
ui: WidgetUiCallbacks
|
||||
}
|
||||
|
||||
function createWidgetUpdateHandler(
|
||||
widgetState: WidgetState | undefined,
|
||||
widget: SafeWidgetData,
|
||||
nodeExecId: string,
|
||||
widgetOptions: IWidgetOptions | Record<string, never>,
|
||||
executionErrorStore: ReturnType<typeof useExecutionErrorStore>
|
||||
): (newValue: WidgetValue) => void {
|
||||
return (newValue: WidgetValue) => {
|
||||
if (widgetState) widgetState.value = newValue
|
||||
widget.callback?.(newValue)
|
||||
const effectiveExecId = widget.sourceExecutionId ?? nodeExecId
|
||||
executionErrorStore.clearWidgetRelatedErrors(
|
||||
effectiveExecId,
|
||||
widget.slotName ?? widget.name,
|
||||
widget.name,
|
||||
newValue,
|
||||
{ min: widgetOptions?.min, max: widgetOptions?.max }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function hasWidgetError(
|
||||
widget: SafeWidgetData,
|
||||
nodeExecId: string,
|
||||
nodeErrors:
|
||||
| { errors: { extra_info?: { input_name?: string } }[] }
|
||||
| undefined,
|
||||
executionErrorStore: ReturnType<typeof useExecutionErrorStore>,
|
||||
missingModelStore: ReturnType<typeof useMissingModelStore>
|
||||
): boolean {
|
||||
const errors = widget.sourceExecutionId
|
||||
? executionErrorStore.lastNodeErrors?.[widget.sourceExecutionId]?.errors
|
||||
: nodeErrors?.errors
|
||||
const inputName = widget.slotName ?? widget.name
|
||||
return (
|
||||
!!errors?.some((e) => e.extra_info?.input_name === inputName) ||
|
||||
missingModelStore.isWidgetMissingModel(
|
||||
widget.sourceExecutionId ?? nodeExecId,
|
||||
widget.name
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export function getWidgetIdentity(
|
||||
widget: SafeWidgetData,
|
||||
nodeId: string | number | undefined,
|
||||
index: number
|
||||
): {
|
||||
dedupeIdentity?: string
|
||||
renderKey: string
|
||||
} {
|
||||
const rawWidgetId = widget.storeNodeId ?? widget.nodeId
|
||||
const storeWidgetName = widget.storeName ?? widget.name
|
||||
const slotNameForIdentity = widget.slotName ?? widget.name
|
||||
const stableIdentityRoot = rawWidgetId
|
||||
? `node:${String(stripGraphPrefix(rawWidgetId))}`
|
||||
: widget.sourceExecutionId
|
||||
? `exec:${widget.sourceExecutionId}`
|
||||
: undefined
|
||||
|
||||
const dedupeIdentity = stableIdentityRoot
|
||||
? `${stableIdentityRoot}:${storeWidgetName}:${slotNameForIdentity}:${widget.type}`
|
||||
: undefined
|
||||
const renderKey =
|
||||
dedupeIdentity ??
|
||||
`transient:${String(nodeId ?? '')}:${storeWidgetName}:${slotNameForIdentity}:${widget.type}:${index}`
|
||||
|
||||
return {
|
||||
dedupeIdentity,
|
||||
renderKey
|
||||
}
|
||||
}
|
||||
|
||||
export function isWidgetVisible(
|
||||
options: IWidgetOptions,
|
||||
showAdvanced: boolean
|
||||
): boolean {
|
||||
const hidden = options.hidden ?? false
|
||||
const advanced = options.advanced ?? false
|
||||
return !hidden && (!advanced || showAdvanced)
|
||||
}
|
||||
|
||||
export function computeProcessedWidgets({
|
||||
nodeData,
|
||||
graphId,
|
||||
showAdvanced,
|
||||
isGraphReady,
|
||||
rootGraph,
|
||||
ui
|
||||
}: ComputeProcessedWidgetsOptions): ProcessedWidget[] {
|
||||
if (!nodeData?.widgets) return []
|
||||
|
||||
const promotionStore = usePromotionStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const missingModelStore = useMissingModelStore()
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
|
||||
const nodeExecId =
|
||||
isGraphReady && rootGraph
|
||||
? getExecutionIdFromNodeData(rootGraph, nodeData)
|
||||
: String(nodeData.id ?? '')
|
||||
|
||||
const nodeErrors = executionErrorStore.lastNodeErrors?.[nodeExecId]
|
||||
|
||||
const nodeId = nodeData.id
|
||||
const { widgets } = nodeData
|
||||
const result: ProcessedWidget[] = []
|
||||
const uniqueWidgets: Array<{
|
||||
widget: SafeWidgetData
|
||||
identity: ReturnType<typeof getWidgetIdentity>
|
||||
mergedOptions: IWidgetOptions
|
||||
widgetState: WidgetState | undefined
|
||||
isVisible: boolean
|
||||
}> = []
|
||||
const dedupeIndexByIdentity = new Map<string, number>()
|
||||
|
||||
for (const [index, widget] of widgets.entries()) {
|
||||
if (!shouldRenderAsVue(widget)) continue
|
||||
|
||||
const identity = getWidgetIdentity(widget, nodeId, index)
|
||||
const storeWidgetName = widget.storeName ?? widget.name
|
||||
const bareWidgetId = String(
|
||||
stripGraphPrefix(widget.storeNodeId ?? widget.nodeId ?? nodeId ?? '')
|
||||
)
|
||||
const widgetState = graphId
|
||||
? widgetValueStore.getWidget(graphId, bareWidgetId, storeWidgetName)
|
||||
: undefined
|
||||
const mergedOptions: IWidgetOptions = {
|
||||
...(widget.options ?? {}),
|
||||
...(widgetState?.options ?? {})
|
||||
}
|
||||
const visible = isWidgetVisible(mergedOptions, showAdvanced)
|
||||
if (!identity.dedupeIdentity) {
|
||||
uniqueWidgets.push({
|
||||
widget,
|
||||
identity,
|
||||
mergedOptions,
|
||||
widgetState,
|
||||
isVisible: visible
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const existingIndex = dedupeIndexByIdentity.get(identity.dedupeIdentity)
|
||||
if (existingIndex === undefined) {
|
||||
dedupeIndexByIdentity.set(identity.dedupeIdentity, uniqueWidgets.length)
|
||||
uniqueWidgets.push({
|
||||
widget,
|
||||
identity,
|
||||
mergedOptions,
|
||||
widgetState,
|
||||
isVisible: visible
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const existingWidget = uniqueWidgets[existingIndex]
|
||||
if (existingWidget && !existingWidget.isVisible && visible) {
|
||||
uniqueWidgets[existingIndex] = {
|
||||
widget,
|
||||
identity,
|
||||
mergedOptions,
|
||||
widgetState,
|
||||
isVisible: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const {
|
||||
widget,
|
||||
mergedOptions,
|
||||
widgetState,
|
||||
identity: { renderKey }
|
||||
} of uniqueWidgets) {
|
||||
const hostNodeId = String(nodeId ?? '')
|
||||
const bareWidgetId = String(
|
||||
stripGraphPrefix(widget.storeNodeId ?? widget.nodeId ?? nodeId ?? '')
|
||||
)
|
||||
const promotionSourceNodeId = widget.storeName
|
||||
? String(bareWidgetId)
|
||||
: undefined
|
||||
|
||||
const vueComponent =
|
||||
getComponent(widget.type) ||
|
||||
(widget.isDOMWidget ? WidgetDOM : WidgetLegacy)
|
||||
|
||||
const { slotMetadata } = widget
|
||||
|
||||
const value = widgetState?.value as WidgetValue
|
||||
|
||||
const isDisabled = slotMetadata?.linked || widgetState?.disabled
|
||||
const widgetOptions = isDisabled
|
||||
? { ...mergedOptions, disabled: true }
|
||||
: mergedOptions
|
||||
|
||||
const borderStyle =
|
||||
graphId &&
|
||||
promotionStore.isPromotedByAny(graphId, {
|
||||
sourceNodeId: hostNodeId,
|
||||
sourceWidgetName: widget.storeName ?? widget.name,
|
||||
disambiguatingSourceNodeId: promotionSourceNodeId
|
||||
})
|
||||
? 'ring ring-component-node-widget-promoted'
|
||||
: mergedOptions.advanced
|
||||
? 'ring ring-component-node-widget-advanced'
|
||||
: undefined
|
||||
|
||||
const linkedUpstream: LinkedUpstreamInfo | undefined =
|
||||
slotMetadata?.linked && slotMetadata.originNodeId
|
||||
? {
|
||||
nodeId: slotMetadata.originNodeId,
|
||||
outputName: slotMetadata.originOutputName
|
||||
}
|
||||
: undefined
|
||||
|
||||
const nodeLocatorId = widget.nodeId
|
||||
? widget.nodeId
|
||||
: nodeData
|
||||
? getLocatorIdFromNodeData(nodeData)
|
||||
: undefined
|
||||
|
||||
const simplified: SimplifiedWidget = {
|
||||
name: widget.name,
|
||||
type: widget.type,
|
||||
value,
|
||||
borderStyle,
|
||||
callback: widget.callback,
|
||||
controlWidget: widget.controlWidget,
|
||||
label: widget.promotedLabel ?? widgetState?.label,
|
||||
linkedUpstream,
|
||||
nodeLocatorId,
|
||||
options: widgetOptions,
|
||||
spec: widget.spec
|
||||
}
|
||||
|
||||
const updateHandler = createWidgetUpdateHandler(
|
||||
widgetState,
|
||||
widget,
|
||||
nodeExecId,
|
||||
widgetOptions,
|
||||
executionErrorStore
|
||||
)
|
||||
|
||||
const tooltipConfig = ui.getTooltipConfig(widget)
|
||||
const handleContextMenu = (e: PointerEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (nodeId !== undefined) ui.handleNodeRightClick(e, nodeId)
|
||||
showNodeOptions(
|
||||
e,
|
||||
widget.name,
|
||||
widget.nodeId !== undefined
|
||||
? String(stripGraphPrefix(widget.nodeId))
|
||||
: undefined
|
||||
)
|
||||
}
|
||||
|
||||
result.push({
|
||||
advanced: mergedOptions.advanced ?? false,
|
||||
handleContextMenu,
|
||||
hasLayoutSize: widget.hasLayoutSize ?? false,
|
||||
hasError: hasWidgetError(
|
||||
widget,
|
||||
nodeExecId,
|
||||
nodeErrors,
|
||||
executionErrorStore,
|
||||
missingModelStore
|
||||
),
|
||||
hidden: mergedOptions.hidden ?? false,
|
||||
id: String(bareWidgetId),
|
||||
name: widget.name,
|
||||
renderKey,
|
||||
type: widget.type,
|
||||
vueComponent,
|
||||
simplified,
|
||||
value,
|
||||
updateHandler,
|
||||
tooltipConfig,
|
||||
slotMetadata
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function useProcessedWidgets(
|
||||
nodeDataGetter: () => VueNodeData | undefined
|
||||
) {
|
||||
const canvasStore = useCanvasStore()
|
||||
const settingStore = useSettingStore()
|
||||
const { isSelectInputsMode } = useAppMode()
|
||||
const { handleNodeRightClick } = useNodeEventHandlers()
|
||||
|
||||
const nodeType = computed(() => nodeDataGetter()?.type || '')
|
||||
const { getWidgetTooltip, createTooltipConfig } = useNodeTooltips(nodeType)
|
||||
|
||||
const ui: WidgetUiCallbacks = {
|
||||
getTooltipConfig: (widget) => createTooltipConfig(getWidgetTooltip(widget)),
|
||||
handleNodeRightClick
|
||||
}
|
||||
|
||||
const showAdvanced = computed(
|
||||
() =>
|
||||
nodeDataGetter()?.showAdvanced ||
|
||||
settingStore.get('Comfy.Node.AlwaysShowAdvancedWidgets')
|
||||
)
|
||||
|
||||
const canSelectInputs = computed(() => {
|
||||
const nodeData = nodeDataGetter()
|
||||
return (
|
||||
isSelectInputsMode.value &&
|
||||
nodeData?.mode === LGraphEventMode.ALWAYS &&
|
||||
nodeTypeValidForApp(nodeData.type) &&
|
||||
!nodeData.hasErrors
|
||||
)
|
||||
})
|
||||
|
||||
const processedWidgets = computed((): ProcessedWidget[] =>
|
||||
computeProcessedWidgets({
|
||||
nodeData: nodeDataGetter(),
|
||||
graphId: canvasStore.canvas?.graph?.rootGraph.id,
|
||||
showAdvanced: showAdvanced.value,
|
||||
isGraphReady: app.isGraphReady,
|
||||
rootGraph: app.isGraphReady ? app.rootGraph : null,
|
||||
ui
|
||||
})
|
||||
)
|
||||
|
||||
const visibleWidgets = computed(() =>
|
||||
processedWidgets.value.filter((w) =>
|
||||
isWidgetVisible(
|
||||
{ hidden: w.hidden, advanced: w.advanced },
|
||||
showAdvanced.value
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
const gridTemplateRows = computed((): string =>
|
||||
visibleWidgets.value
|
||||
.map((w) =>
|
||||
shouldExpand(w.type) || w.hasLayoutSize ? 'auto' : 'min-content'
|
||||
)
|
||||
.join(' ')
|
||||
)
|
||||
|
||||
return {
|
||||
canSelectInputs,
|
||||
gridTemplateRows,
|
||||
nodeType,
|
||||
processedWidgets,
|
||||
showAdvanced,
|
||||
visibleWidgets
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,14 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import type { VueWrapper } from '@vue/test-utils'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { computed } from 'vue'
|
||||
import type { ComponentPublicInstance } from 'vue'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import type { FormDropdownItem } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
|
||||
import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue'
|
||||
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import { createMockWidget } from './widgetTestUtils'
|
||||
|
||||
@@ -55,7 +52,7 @@ vi.mock(
|
||||
})
|
||||
)
|
||||
|
||||
const { mockMediaAssets, mockResolveOutputAssetItems } = vi.hoisted(() => {
|
||||
const { mockMediaAssets } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { ref } = require('vue')
|
||||
return {
|
||||
@@ -68,8 +65,7 @@ const { mockMediaAssets, mockResolveOutputAssetItems } = vi.hoisted(() => {
|
||||
loadMore: vi.fn(),
|
||||
hasMore: ref(false),
|
||||
isLoadingMore: ref(false)
|
||||
},
|
||||
mockResolveOutputAssetItems: vi.fn()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -78,732 +74,187 @@ vi.mock('@/platform/assets/composables/media/useMediaAssets', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/utils/outputAssetUtil', () => ({
|
||||
resolveOutputAssetItems: (...args: unknown[]) =>
|
||||
mockResolveOutputAssetItems(...args)
|
||||
resolveOutputAssetItems: vi.fn().mockResolvedValue([])
|
||||
}))
|
||||
|
||||
const mockUpdateSelectedItems = vi.hoisted(() => vi.fn())
|
||||
const mockHandleFilesUpdate = vi.hoisted(() => vi.fn())
|
||||
|
||||
const { mockItemsRef, mockSelectedSetRef, mockFilterSelectedRef } = vi.hoisted(
|
||||
() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { ref } = require('vue')
|
||||
return {
|
||||
mockItemsRef: ref([]) as Ref<FormDropdownItem[]>,
|
||||
mockSelectedSetRef: ref(new Set()) as Ref<Set<string>>,
|
||||
mockFilterSelectedRef: ref('all') as Ref<string>
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectItems',
|
||||
() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { computed } = require('vue')
|
||||
return {
|
||||
useWidgetSelectItems: () => ({
|
||||
dropdownItems: computed(() => mockItemsRef.value),
|
||||
displayItems: computed(() => mockItemsRef.value),
|
||||
filterSelected: mockFilterSelectedRef,
|
||||
filterOptions: computed(() => [
|
||||
{ name: 'All', value: 'all' },
|
||||
{ name: 'Inputs', value: 'inputs' }
|
||||
]),
|
||||
ownershipSelected: ref('all'),
|
||||
showOwnershipFilter: computed(() => false),
|
||||
ownershipOptions: computed(() => []),
|
||||
baseModelSelected: ref(new Set<string>()),
|
||||
showBaseModelFilter: computed(() => false),
|
||||
baseModelOptions: computed(() => []),
|
||||
selectedSet: computed(() => mockSelectedSetRef.value)
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectActions',
|
||||
() => ({
|
||||
useWidgetSelectActions: () => ({
|
||||
updateSelectedItems: mockUpdateSelectedItems,
|
||||
handleFilesUpdate: mockHandleFilesUpdate
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} }
|
||||
})
|
||||
|
||||
interface WidgetSelectDropdownInstance extends ComponentPublicInstance {
|
||||
inputItems: FormDropdownItem[]
|
||||
outputItems: FormDropdownItem[]
|
||||
dropdownItems: FormDropdownItem[]
|
||||
filterSelected: string
|
||||
updateSelectedItems: (selectedSet: Set<string>) => void
|
||||
}
|
||||
|
||||
describe('WidgetSelectDropdown custom label mapping', () => {
|
||||
const createSelectDropdownWidget = (
|
||||
value: string = 'img_001.png',
|
||||
options: {
|
||||
values?: string[]
|
||||
getOptionLabel?: (value?: string | null) => string
|
||||
} = {},
|
||||
spec?: ComboInputSpec
|
||||
) =>
|
||||
createMockWidget<string | undefined>({
|
||||
value,
|
||||
name: 'test_image_select',
|
||||
type: 'combo',
|
||||
options: {
|
||||
values: ['img_001.png', 'photo_abc.jpg', 'hash789.png'],
|
||||
...options
|
||||
},
|
||||
spec
|
||||
})
|
||||
|
||||
const mountComponent = (
|
||||
widget: SimplifiedWidget<string | undefined>,
|
||||
modelValue: string | undefined,
|
||||
assetKind: 'image' | 'video' | 'audio' = 'image'
|
||||
): VueWrapper<WidgetSelectDropdownInstance> => {
|
||||
return fromAny<VueWrapper<WidgetSelectDropdownInstance>, unknown>(
|
||||
mount(WidgetSelectDropdown, {
|
||||
props: {
|
||||
widget,
|
||||
modelValue,
|
||||
assetKind,
|
||||
allowUpload: true,
|
||||
uploadFolder: 'input'
|
||||
},
|
||||
global: {
|
||||
plugins: [PrimeVue, createTestingPinia(), i18n]
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
describe('when custom labels are not provided', () => {
|
||||
it('uses values as labels when no mapping provided', () => {
|
||||
const widget = createSelectDropdownWidget('img_001.png')
|
||||
const wrapper = mountComponent(widget, 'img_001.png')
|
||||
|
||||
const inputItems = wrapper.vm.inputItems
|
||||
expect(inputItems).toHaveLength(3)
|
||||
expect(inputItems[0].name).toBe('img_001.png')
|
||||
expect(inputItems[0].label).toBe('img_001.png')
|
||||
expect(inputItems[1].name).toBe('photo_abc.jpg')
|
||||
expect(inputItems[1].label).toBe('photo_abc.jpg')
|
||||
expect(inputItems[2].name).toBe('hash789.png')
|
||||
expect(inputItems[2].label).toBe('hash789.png')
|
||||
})
|
||||
})
|
||||
|
||||
describe('when custom labels are provided via getOptionLabel', () => {
|
||||
it('displays custom labels while preserving original values', () => {
|
||||
const getOptionLabel = vi.fn((value?: string | null) => {
|
||||
if (!value) return 'No file'
|
||||
const mapping: Record<string, string> = {
|
||||
'img_001.png': 'Vacation Photo',
|
||||
'photo_abc.jpg': 'Family Portrait',
|
||||
'hash789.png': 'Sunset Beach'
|
||||
}
|
||||
return mapping[value] || value
|
||||
})
|
||||
|
||||
const widget = createSelectDropdownWidget('img_001.png', {
|
||||
getOptionLabel
|
||||
})
|
||||
const wrapper = mountComponent(widget, 'img_001.png')
|
||||
|
||||
const inputItems = wrapper.vm.inputItems
|
||||
expect(inputItems).toHaveLength(3)
|
||||
expect(inputItems[0].name).toBe('img_001.png')
|
||||
expect(inputItems[0].label).toBe('Vacation Photo')
|
||||
expect(inputItems[1].name).toBe('photo_abc.jpg')
|
||||
expect(inputItems[1].label).toBe('Family Portrait')
|
||||
expect(inputItems[2].name).toBe('hash789.png')
|
||||
expect(inputItems[2].label).toBe('Sunset Beach')
|
||||
|
||||
expect(getOptionLabel).toHaveBeenCalledWith('img_001.png')
|
||||
expect(getOptionLabel).toHaveBeenCalledWith('photo_abc.jpg')
|
||||
expect(getOptionLabel).toHaveBeenCalledWith('hash789.png')
|
||||
})
|
||||
|
||||
it('emits original values when items with custom labels are selected', async () => {
|
||||
const getOptionLabel = vi.fn((value?: string | null) => {
|
||||
if (!value) return 'No file'
|
||||
return `Custom: ${value}`
|
||||
})
|
||||
|
||||
const widget = createSelectDropdownWidget('img_001.png', {
|
||||
getOptionLabel
|
||||
})
|
||||
const wrapper = mountComponent(widget, 'img_001.png')
|
||||
|
||||
// Simulate selecting an item
|
||||
const selectedSet = new Set(['input-1']) // index 1 = photo_abc.jpg
|
||||
wrapper.vm.updateSelectedItems(selectedSet)
|
||||
|
||||
// Should emit the original value, not the custom label
|
||||
expect(wrapper.emitted('update:modelValue')).toBeDefined()
|
||||
expect(wrapper.emitted('update:modelValue')![0]).toEqual([
|
||||
'photo_abc.jpg'
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to original value when label mapping fails', () => {
|
||||
const getOptionLabel = vi.fn((value?: string | null) => {
|
||||
if (value === 'photo_abc.jpg') {
|
||||
throw new Error('Mapping failed')
|
||||
}
|
||||
return `Labeled: ${value}`
|
||||
})
|
||||
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
const widget = createSelectDropdownWidget('img_001.png', {
|
||||
getOptionLabel
|
||||
})
|
||||
const wrapper = mountComponent(widget, 'img_001.png')
|
||||
|
||||
const inputItems = wrapper.vm.inputItems
|
||||
expect(inputItems[0].name).toBe('img_001.png')
|
||||
expect(inputItems[0].label).toBe('Labeled: img_001.png')
|
||||
expect(inputItems[1].name).toBe('photo_abc.jpg')
|
||||
expect(inputItems[1].label).toBe('photo_abc.jpg')
|
||||
expect(inputItems[2].name).toBe('hash789.png')
|
||||
expect(inputItems[2].label).toBe('Labeled: hash789.png')
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalled()
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('falls back to original value when label mapping returns empty string', () => {
|
||||
const getOptionLabel = vi.fn((value?: string | null) => {
|
||||
if (value === 'photo_abc.jpg') {
|
||||
return ''
|
||||
}
|
||||
return `Labeled: ${value}`
|
||||
})
|
||||
|
||||
const widget = createSelectDropdownWidget('img_001.png', {
|
||||
getOptionLabel
|
||||
})
|
||||
const wrapper = mountComponent(widget, 'img_001.png')
|
||||
|
||||
const inputItems = wrapper.vm.inputItems
|
||||
expect(inputItems[0].name).toBe('img_001.png')
|
||||
expect(inputItems[0].label).toBe('Labeled: img_001.png')
|
||||
expect(inputItems[1].name).toBe('photo_abc.jpg')
|
||||
expect(inputItems[1].label).toBe('photo_abc.jpg')
|
||||
expect(inputItems[2].name).toBe('hash789.png')
|
||||
expect(inputItems[2].label).toBe('Labeled: hash789.png')
|
||||
})
|
||||
|
||||
it('falls back to original value when label mapping returns undefined', () => {
|
||||
const getOptionLabel = vi.fn((value?: string | null) => {
|
||||
if (value === 'hash789.png') {
|
||||
return fromAny<string, unknown>(undefined)
|
||||
}
|
||||
return `Labeled: ${value}`
|
||||
})
|
||||
|
||||
const widget = createSelectDropdownWidget('img_001.png', {
|
||||
getOptionLabel
|
||||
})
|
||||
const wrapper = mountComponent(widget, 'img_001.png')
|
||||
|
||||
const inputItems = wrapper.vm.inputItems
|
||||
expect(inputItems[0].name).toBe('img_001.png')
|
||||
expect(inputItems[0].label).toBe('Labeled: img_001.png')
|
||||
expect(inputItems[1].name).toBe('photo_abc.jpg')
|
||||
expect(inputItems[1].label).toBe('Labeled: photo_abc.jpg')
|
||||
expect(inputItems[2].name).toBe('hash789.png')
|
||||
expect(inputItems[2].label).toBe('hash789.png')
|
||||
})
|
||||
})
|
||||
|
||||
describe('output items with custom label mapping', () => {
|
||||
it('applies custom label mapping to output items from queue history', () => {
|
||||
const getOptionLabel = vi.fn((value?: string | null) => {
|
||||
if (!value) return 'No file'
|
||||
return `Output: ${value}`
|
||||
})
|
||||
|
||||
const widget = createSelectDropdownWidget('img_001.png', {
|
||||
getOptionLabel
|
||||
})
|
||||
const wrapper = mountComponent(widget, 'img_001.png')
|
||||
|
||||
const outputItems = wrapper.vm.outputItems
|
||||
expect(outputItems).toBeDefined()
|
||||
expect(Array.isArray(outputItems)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('missing value handling for template-loaded nodes', () => {
|
||||
it('creates a fallback item in "all" filter when modelValue is not in available items', () => {
|
||||
const widget = createSelectDropdownWidget('template_image.png', {
|
||||
values: ['img_001.png', 'photo_abc.jpg']
|
||||
})
|
||||
const wrapper = mountComponent(widget, 'template_image.png')
|
||||
|
||||
const inputItems = wrapper.vm.inputItems
|
||||
expect(inputItems).toHaveLength(2)
|
||||
expect(
|
||||
inputItems.some((item) => item.name === 'template_image.png')
|
||||
).toBe(false)
|
||||
|
||||
// The missing value should be accessible via dropdownItems when filter is 'all' (default)
|
||||
const dropdownItems = wrapper.vm.dropdownItems
|
||||
expect(
|
||||
dropdownItems.some((item) => item.name === 'template_image.png')
|
||||
).toBe(true)
|
||||
expect(dropdownItems[0].name).toBe('template_image.png')
|
||||
expect(dropdownItems[0].id).toBe('missing-template_image.png')
|
||||
})
|
||||
|
||||
it('does not include fallback item when filter is "inputs"', async () => {
|
||||
const widget = createSelectDropdownWidget('template_image.png', {
|
||||
values: ['img_001.png', 'photo_abc.jpg']
|
||||
})
|
||||
const wrapper = mountComponent(widget, 'template_image.png')
|
||||
|
||||
wrapper.vm.filterSelected = 'inputs'
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const dropdownItems = wrapper.vm.dropdownItems
|
||||
expect(dropdownItems).toHaveLength(2)
|
||||
expect(
|
||||
dropdownItems.every((item) => !String(item.id).startsWith('missing-'))
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('does not include fallback item when filter is "outputs"', async () => {
|
||||
const widget = createSelectDropdownWidget('template_image.png', {
|
||||
values: ['img_001.png', 'photo_abc.jpg']
|
||||
})
|
||||
const wrapper = mountComponent(widget, 'template_image.png')
|
||||
|
||||
wrapper.vm.filterSelected = 'outputs'
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const dropdownItems = wrapper.vm.dropdownItems
|
||||
expect(dropdownItems).toHaveLength(wrapper.vm.outputItems.length)
|
||||
expect(
|
||||
dropdownItems.every((item) => !String(item.id).startsWith('missing-'))
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('does not create a fallback item when modelValue exists in available items', () => {
|
||||
const widget = createSelectDropdownWidget('img_001.png', {
|
||||
values: ['img_001.png', 'photo_abc.jpg']
|
||||
})
|
||||
const wrapper = mountComponent(widget, 'img_001.png')
|
||||
|
||||
const dropdownItems = wrapper.vm.dropdownItems
|
||||
expect(dropdownItems).toHaveLength(2)
|
||||
expect(
|
||||
dropdownItems.every((item) => !String(item.id).startsWith('missing-'))
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('does not create a fallback item when modelValue is undefined', () => {
|
||||
const widget = createSelectDropdownWidget(
|
||||
fromAny<string, unknown>(undefined),
|
||||
{
|
||||
values: ['img_001.png', 'photo_abc.jpg']
|
||||
}
|
||||
)
|
||||
const wrapper = mountComponent(widget, undefined)
|
||||
|
||||
const dropdownItems = wrapper.vm.dropdownItems
|
||||
expect(dropdownItems).toHaveLength(2)
|
||||
expect(
|
||||
dropdownItems.every((item) => !String(item.id).startsWith('missing-'))
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('WidgetSelectDropdown cloud asset mode (COM-14333)', () => {
|
||||
interface CloudModeInstance extends ComponentPublicInstance {
|
||||
dropdownItems: FormDropdownItem[]
|
||||
displayItems: FormDropdownItem[]
|
||||
selectedSet: Set<string>
|
||||
}
|
||||
|
||||
const createTestAsset = (
|
||||
id: string,
|
||||
name: string,
|
||||
preview_url: string
|
||||
): AssetItem => ({
|
||||
id,
|
||||
name,
|
||||
preview_url,
|
||||
tags: []
|
||||
})
|
||||
|
||||
const createCloudModeWidget = (
|
||||
value: string = 'model.safetensors'
|
||||
): SimplifiedWidget<string | undefined> => ({
|
||||
name: 'test_model_select',
|
||||
type: 'combo',
|
||||
value,
|
||||
options: {
|
||||
values: [],
|
||||
nodeType: 'CheckpointLoaderSimple'
|
||||
}
|
||||
})
|
||||
|
||||
const mountCloudComponent = (
|
||||
widget: SimplifiedWidget<string | undefined>,
|
||||
modelValue: string | undefined
|
||||
): VueWrapper<CloudModeInstance> => {
|
||||
return fromAny<VueWrapper<CloudModeInstance>, unknown>(
|
||||
mount(WidgetSelectDropdown, {
|
||||
props: {
|
||||
widget,
|
||||
modelValue,
|
||||
assetKind: 'model',
|
||||
isAssetMode: true,
|
||||
nodeType: 'CheckpointLoaderSimple'
|
||||
},
|
||||
global: {
|
||||
plugins: [PrimeVue, createTestingPinia(), i18n]
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockAssetsData.items = []
|
||||
})
|
||||
|
||||
it('does not include missing items in cloud asset mode dropdown', () => {
|
||||
mockAssetsData.items = [
|
||||
createTestAsset(
|
||||
'asset-1',
|
||||
'existing_model.safetensors',
|
||||
'https://example.com/preview.jpg'
|
||||
)
|
||||
]
|
||||
|
||||
const widget = createCloudModeWidget('missing_model.safetensors')
|
||||
const wrapper = mountCloudComponent(widget, 'missing_model.safetensors')
|
||||
|
||||
const dropdownItems = wrapper.vm.dropdownItems
|
||||
expect(dropdownItems).toHaveLength(1)
|
||||
expect(dropdownItems[0].name).toBe('existing_model.safetensors')
|
||||
expect(
|
||||
dropdownItems.some((item) => item.name === 'missing_model.safetensors')
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('shows only available cloud assets in dropdown', () => {
|
||||
mockAssetsData.items = [
|
||||
createTestAsset(
|
||||
'asset-1',
|
||||
'model_a.safetensors',
|
||||
'https://example.com/a.jpg'
|
||||
),
|
||||
createTestAsset(
|
||||
'asset-2',
|
||||
'model_b.safetensors',
|
||||
'https://example.com/b.jpg'
|
||||
)
|
||||
]
|
||||
|
||||
const widget = createCloudModeWidget('model_a.safetensors')
|
||||
const wrapper = mountCloudComponent(widget, 'model_a.safetensors')
|
||||
|
||||
const dropdownItems = wrapper.vm.dropdownItems
|
||||
expect(dropdownItems).toHaveLength(2)
|
||||
expect(dropdownItems.map((item) => item.name)).toEqual([
|
||||
'model_a.safetensors',
|
||||
'model_b.safetensors'
|
||||
])
|
||||
})
|
||||
|
||||
it('returns empty dropdown when no cloud assets available', () => {
|
||||
mockAssetsData.items = []
|
||||
|
||||
const widget = createCloudModeWidget('missing_model.safetensors')
|
||||
const wrapper = mountCloudComponent(widget, 'missing_model.safetensors')
|
||||
|
||||
const dropdownItems = wrapper.vm.dropdownItems
|
||||
expect(dropdownItems).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('includes missing cloud asset in displayItems for input field visibility', () => {
|
||||
mockAssetsData.items = [
|
||||
createTestAsset(
|
||||
'asset-1',
|
||||
'existing_model.safetensors',
|
||||
'https://example.com/preview.jpg'
|
||||
)
|
||||
]
|
||||
|
||||
const widget = createCloudModeWidget('missing_model.safetensors')
|
||||
const wrapper = mountCloudComponent(widget, 'missing_model.safetensors')
|
||||
|
||||
const displayItems = wrapper.vm.displayItems
|
||||
expect(displayItems).toHaveLength(2)
|
||||
expect(displayItems[0].name).toBe('missing_model.safetensors')
|
||||
expect(displayItems[0].id).toBe('missing-missing_model.safetensors')
|
||||
expect(displayItems[1].name).toBe('existing_model.safetensors')
|
||||
|
||||
const selectedSet = wrapper.vm.selectedSet
|
||||
expect(selectedSet.has('missing-missing_model.safetensors')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('WidgetSelectDropdown multi-output jobs', () => {
|
||||
interface MultiOutputInstance extends ComponentPublicInstance {
|
||||
outputItems: FormDropdownItem[]
|
||||
}
|
||||
|
||||
function makeMultiOutputAsset(
|
||||
jobId: string,
|
||||
name: string,
|
||||
nodeId: string,
|
||||
outputCount: number
|
||||
) {
|
||||
return {
|
||||
id: jobId,
|
||||
name,
|
||||
preview_url: `/api/view?filename=${name}&type=output`,
|
||||
tags: ['output'],
|
||||
user_metadata: {
|
||||
jobId,
|
||||
nodeId,
|
||||
subfolder: '',
|
||||
outputCount,
|
||||
allOutputs: [
|
||||
{
|
||||
filename: name,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId,
|
||||
mediaType: 'images'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mountMultiOutput(
|
||||
widget: SimplifiedWidget<string | undefined>,
|
||||
modelValue: string | undefined
|
||||
): VueWrapper<MultiOutputInstance> {
|
||||
return fromAny<VueWrapper<MultiOutputInstance>, unknown>(
|
||||
mount(WidgetSelectDropdown, {
|
||||
props: { widget, modelValue, assetKind: 'image' as const },
|
||||
global: { plugins: [PrimeVue, createTestingPinia(), i18n] }
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const defaultWidget = () =>
|
||||
createMockWidget<string | undefined>({
|
||||
value: 'output_001.png',
|
||||
name: 'test_image',
|
||||
type: 'combo',
|
||||
options: { values: [] }
|
||||
})
|
||||
|
||||
describe('WidgetSelectDropdown', () => {
|
||||
beforeEach(() => {
|
||||
mockMediaAssets.media.value = []
|
||||
mockResolveOutputAssetItems.mockReset()
|
||||
})
|
||||
|
||||
it('shows all outputs after resolving multi-output jobs', async () => {
|
||||
mockMediaAssets.media.value = [
|
||||
makeMultiOutputAsset('job-1', 'preview.png', '5', 3)
|
||||
]
|
||||
|
||||
mockResolveOutputAssetItems.mockResolvedValue([
|
||||
{
|
||||
id: 'job-1-5-output_001.png',
|
||||
name: 'output_001.png',
|
||||
preview_url: '/api/view?filename=output_001.png&type=output',
|
||||
tags: ['output']
|
||||
},
|
||||
{
|
||||
id: 'job-1-5-output_002.png',
|
||||
name: 'output_002.png',
|
||||
preview_url: '/api/view?filename=output_002.png&type=output',
|
||||
tags: ['output']
|
||||
},
|
||||
{
|
||||
id: 'job-1-5-output_003.png',
|
||||
name: 'output_003.png',
|
||||
preview_url: '/api/view?filename=output_003.png&type=output',
|
||||
tags: ['output']
|
||||
}
|
||||
])
|
||||
|
||||
const wrapper = mountMultiOutput(defaultWidget(), 'output_001.png')
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.vm.outputItems).toHaveLength(3)
|
||||
})
|
||||
|
||||
expect(wrapper.vm.outputItems.map((i) => i.name)).toEqual([
|
||||
'output_001.png [output]',
|
||||
'output_002.png [output]',
|
||||
'output_003.png [output]'
|
||||
])
|
||||
})
|
||||
|
||||
it('shows preview output when job has only one output', () => {
|
||||
mockMediaAssets.media.value = [
|
||||
makeMultiOutputAsset('job-2', 'single.png', '3', 1)
|
||||
]
|
||||
|
||||
const widget = createMockWidget<string | undefined>({
|
||||
value: 'single.png',
|
||||
name: 'test_image',
|
||||
type: 'combo',
|
||||
options: { values: [] }
|
||||
})
|
||||
const wrapper = mountMultiOutput(widget, 'single.png')
|
||||
|
||||
expect(wrapper.vm.outputItems).toHaveLength(1)
|
||||
expect(wrapper.vm.outputItems[0].name).toBe('single.png [output]')
|
||||
expect(mockResolveOutputAssetItems).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resolves two multi-output jobs independently', async () => {
|
||||
mockMediaAssets.media.value = [
|
||||
makeMultiOutputAsset('job-A', 'previewA.png', '1', 2),
|
||||
makeMultiOutputAsset('job-B', 'previewB.png', '2', 2)
|
||||
]
|
||||
|
||||
mockResolveOutputAssetItems.mockImplementation(async (meta) => {
|
||||
if (meta.jobId === 'job-A') {
|
||||
return [
|
||||
{ id: 'A-1', name: 'a1.png', preview_url: '', tags: ['output'] },
|
||||
{ id: 'A-2', name: 'a2.png', preview_url: '', tags: ['output'] }
|
||||
]
|
||||
}
|
||||
return [
|
||||
{ id: 'B-1', name: 'b1.png', preview_url: '', tags: ['output'] },
|
||||
{ id: 'B-2', name: 'b2.png', preview_url: '', tags: ['output'] }
|
||||
]
|
||||
})
|
||||
|
||||
const wrapper = mountMultiOutput(defaultWidget(), undefined)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.vm.outputItems).toHaveLength(4)
|
||||
})
|
||||
|
||||
const names = wrapper.vm.outputItems.map((i) => i.name)
|
||||
expect(names).toContain('a1.png [output]')
|
||||
expect(names).toContain('a2.png [output]')
|
||||
expect(names).toContain('b1.png [output]')
|
||||
expect(names).toContain('b2.png [output]')
|
||||
})
|
||||
|
||||
it('resolves outputs when allOutputs already contains all items', async () => {
|
||||
mockMediaAssets.media.value = [
|
||||
{
|
||||
id: 'job-complete',
|
||||
name: 'preview.png',
|
||||
preview_url: '/api/view?filename=preview.png&type=output',
|
||||
tags: ['output'],
|
||||
user_metadata: {
|
||||
jobId: 'job-complete',
|
||||
nodeId: '1',
|
||||
subfolder: '',
|
||||
outputCount: 2,
|
||||
allOutputs: [
|
||||
{
|
||||
filename: 'out1.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
},
|
||||
{
|
||||
filename: 'out2.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
mockResolveOutputAssetItems.mockResolvedValue([
|
||||
{ id: 'c-1', name: 'out1.png', preview_url: '', tags: ['output'] },
|
||||
{ id: 'c-2', name: 'out2.png', preview_url: '', tags: ['output'] }
|
||||
])
|
||||
|
||||
const wrapper = mountMultiOutput(defaultWidget(), undefined)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.vm.outputItems).toHaveLength(2)
|
||||
})
|
||||
|
||||
expect(mockResolveOutputAssetItems).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ jobId: 'job-complete' }),
|
||||
expect.any(Object)
|
||||
)
|
||||
const names = wrapper.vm.outputItems.map((i) => i.name)
|
||||
expect(names).toEqual(['out1.png [output]', 'out2.png [output]'])
|
||||
})
|
||||
|
||||
it('falls back to preview when resolver rejects', async () => {
|
||||
const consoleWarnSpy = vi
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
mockMediaAssets.media.value = [
|
||||
makeMultiOutputAsset('job-fail', 'preview.png', '1', 3)
|
||||
]
|
||||
mockResolveOutputAssetItems.mockRejectedValue(new Error('network error'))
|
||||
|
||||
const wrapper = mountMultiOutput(defaultWidget(), undefined)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
'Failed to resolve multi-output job',
|
||||
'job-fail',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
|
||||
expect(wrapper.vm.outputItems).toHaveLength(1)
|
||||
expect(wrapper.vm.outputItems[0].name).toBe('preview.png [output]')
|
||||
consoleWarnSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('WidgetSelectDropdown undo tracking', () => {
|
||||
interface UndoTrackingInstance extends ComponentPublicInstance {
|
||||
updateSelectedItems: (selectedSet: Set<string>) => void
|
||||
handleFilesUpdate: (files: File[]) => Promise<void>
|
||||
}
|
||||
|
||||
const mountForUndo = (
|
||||
widget: SimplifiedWidget<string | undefined>,
|
||||
modelValue: string | undefined
|
||||
): VueWrapper<UndoTrackingInstance> => {
|
||||
return fromAny<VueWrapper<UndoTrackingInstance>, unknown>(
|
||||
mount(WidgetSelectDropdown, {
|
||||
props: {
|
||||
widget,
|
||||
modelValue,
|
||||
assetKind: 'image',
|
||||
allowUpload: true,
|
||||
uploadFolder: 'input'
|
||||
},
|
||||
global: {
|
||||
plugins: [PrimeVue, createTestingPinia(), i18n]
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockCheckState.mockClear()
|
||||
mockAssetsData.items = []
|
||||
mockItemsRef.value = []
|
||||
mockSelectedSetRef.value = new Set()
|
||||
mockFilterSelectedRef.value = 'all'
|
||||
mockUpdateSelectedItems.mockClear()
|
||||
mockHandleFilesUpdate.mockClear()
|
||||
})
|
||||
|
||||
it('calls checkState after dropdown selection changes modelValue', () => {
|
||||
function renderComponent(
|
||||
widget: SimplifiedWidget<string | undefined>,
|
||||
modelValue: string | undefined,
|
||||
extraProps: Record<string, unknown> = {}
|
||||
) {
|
||||
return render(WidgetSelectDropdown, {
|
||||
props: {
|
||||
widget,
|
||||
modelValue,
|
||||
assetKind: 'image',
|
||||
allowUpload: true,
|
||||
uploadFolder: 'input',
|
||||
...extraProps
|
||||
},
|
||||
global: {
|
||||
plugins: [PrimeVue, createTestingPinia(), i18n]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('renders the dropdown component', () => {
|
||||
mockItemsRef.value = [
|
||||
{ id: 'input-0', name: 'img_001.png' },
|
||||
{ id: 'input-1', name: 'photo_abc.jpg' }
|
||||
]
|
||||
mockSelectedSetRef.value = new Set(['input-0'])
|
||||
const widget = createMockWidget<string | undefined>({
|
||||
value: 'img_001.png',
|
||||
name: 'test_image',
|
||||
type: 'combo',
|
||||
options: { values: ['img_001.png', 'photo_abc.jpg'] }
|
||||
options: {
|
||||
values: ['img_001.png', 'photo_abc.jpg']
|
||||
}
|
||||
})
|
||||
const wrapper = mountForUndo(widget, 'img_001.png')
|
||||
|
||||
wrapper.vm.updateSelectedItems(new Set(['input-1']))
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['photo_abc.jpg'])
|
||||
expect(mockCheckState).toHaveBeenCalledOnce()
|
||||
renderComponent(widget, 'img_001.png')
|
||||
expect(screen.getByText('img_001.png')).toBeDefined()
|
||||
})
|
||||
|
||||
it('calls checkState after file upload completes', async () => {
|
||||
const { api } = await import('@/scripts/api')
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ name: 'uploaded.png', subfolder: '' })
|
||||
} as Response)
|
||||
|
||||
it('renders in cloud asset mode', () => {
|
||||
mockAssetsData.items = [
|
||||
{
|
||||
id: 'asset-1',
|
||||
name: 'model_a.safetensors',
|
||||
preview_url: 'https://example.com/a.jpg',
|
||||
tags: []
|
||||
}
|
||||
]
|
||||
mockItemsRef.value = [{ id: 'asset-1', name: 'model_a.safetensors' }]
|
||||
mockSelectedSetRef.value = new Set(['asset-1'])
|
||||
const widget = createMockWidget<string | undefined>({
|
||||
value: 'img_001.png',
|
||||
name: 'test_image',
|
||||
value: 'model_a.safetensors',
|
||||
name: 'test_model',
|
||||
type: 'combo',
|
||||
options: { values: ['img_001.png'] }
|
||||
options: {
|
||||
values: [],
|
||||
nodeType: 'CheckpointLoaderSimple'
|
||||
}
|
||||
})
|
||||
const wrapper = mountForUndo(widget, 'img_001.png')
|
||||
renderComponent(widget, 'model_a.safetensors', {
|
||||
assetKind: 'model',
|
||||
isAssetMode: true,
|
||||
nodeType: 'CheckpointLoaderSimple'
|
||||
})
|
||||
expect(screen.getByText('model_a.safetensors')).toBeDefined()
|
||||
})
|
||||
|
||||
const file = new File(['test'], 'uploaded.png', { type: 'image/png' })
|
||||
await wrapper.vm.handleFilesUpdate([file])
|
||||
describe('composable wiring', () => {
|
||||
const items: FormDropdownItem[] = [
|
||||
{ id: 'input-0', name: 'cat.png', label: 'cat.png' },
|
||||
{ id: 'input-1', name: 'dog.png', label: 'dog.png' }
|
||||
]
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['uploaded.png'])
|
||||
expect(mockCheckState).toHaveBeenCalledOnce()
|
||||
function renderDefault() {
|
||||
mockItemsRef.value = items
|
||||
const widget = createMockWidget<string | undefined>({
|
||||
value: 'cat.png',
|
||||
name: 'test_image',
|
||||
type: 'combo',
|
||||
options: { values: ['cat.png', 'dog.png'] }
|
||||
})
|
||||
return renderComponent(widget, 'cat.png')
|
||||
}
|
||||
|
||||
it('displays the item whose id is in selectedSet', async () => {
|
||||
mockSelectedSetRef.value = new Set(['input-1'])
|
||||
renderDefault()
|
||||
|
||||
expect(screen.getByText('dog.png')).toBeDefined()
|
||||
expect(screen.queryByText('cat.png')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows placeholder when selectedSet is empty', () => {
|
||||
mockSelectedSetRef.value = new Set()
|
||||
renderDefault()
|
||||
|
||||
expect(screen.queryByText('cat.png')).toBeNull()
|
||||
expect(screen.queryByText('dog.png')).toBeNull()
|
||||
})
|
||||
|
||||
it('updates displayed selection when selectedSet changes', async () => {
|
||||
mockSelectedSetRef.value = new Set(['input-0'])
|
||||
renderDefault()
|
||||
expect(screen.getByText('cat.png')).toBeDefined()
|
||||
|
||||
mockSelectedSetRef.value = new Set(['input-1'])
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByText('dog.png')).toBeDefined()
|
||||
expect(screen.queryByText('cat.png')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,45 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { capitalize } from 'es-toolkit'
|
||||
import { computed, provide, ref, shallowRef, toRef, watch } from 'vue'
|
||||
import { computed, provide, ref, toRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
|
||||
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
|
||||
import { SUPPORTED_EXTENSIONS_ACCEPT } from '@/extensions/core/load3d/constants'
|
||||
import { useAssetFilterOptions } from '@/platform/assets/composables/useAssetFilterOptions'
|
||||
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
|
||||
import {
|
||||
filterItemByBaseModels,
|
||||
filterItemByOwnership
|
||||
} from '@/platform/assets/utils/assetFilterUtils'
|
||||
import {
|
||||
getAssetBaseModels,
|
||||
getAssetDisplayName,
|
||||
getAssetFilename
|
||||
} from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import FormDropdown from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.vue'
|
||||
import type {
|
||||
FilterOption,
|
||||
OwnershipOption
|
||||
} from '@/platform/assets/types/filterTypes'
|
||||
import { AssetKindKey } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
|
||||
import type {
|
||||
FormDropdownItem,
|
||||
LayoutMode
|
||||
} from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
|
||||
import type { LayoutMode } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
|
||||
import WidgetLayoutField from '@/renderer/extensions/vueNodes/widgets/components/layout/WidgetLayoutField.vue'
|
||||
import { useAssetWidgetData } from '@/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData'
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil'
|
||||
import { useWidgetSelectActions } from '@/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectActions'
|
||||
import { useWidgetSelectItems } from '@/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectItems'
|
||||
import type { ResultItemType } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import type { AssetKind } from '@/types/widgetTypes'
|
||||
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
|
||||
import {
|
||||
PANEL_EXCLUDED_PROPS,
|
||||
filterWidgetProps
|
||||
@@ -71,7 +46,6 @@ const modelValue = defineModel<string | undefined>({
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const toastStore = useToastStore()
|
||||
|
||||
const outputMediaAssets = useMediaAssets('output')
|
||||
|
||||
@@ -92,261 +66,34 @@ const getAssetData = () => {
|
||||
}
|
||||
const assetData = getAssetData()
|
||||
|
||||
const filterSelected = ref('all')
|
||||
const filterOptions = computed<FilterOption[]>(() => {
|
||||
if (props.isAssetMode) {
|
||||
const categoryName = assetData?.category.value ?? 'All'
|
||||
return [{ name: capitalize(categoryName), value: 'all' }]
|
||||
}
|
||||
return [
|
||||
{ name: 'All', value: 'all' },
|
||||
{ name: 'Inputs', value: 'inputs' },
|
||||
{ name: 'Outputs', value: 'outputs' }
|
||||
]
|
||||
const {
|
||||
dropdownItems,
|
||||
displayItems,
|
||||
filterSelected,
|
||||
filterOptions,
|
||||
ownershipSelected,
|
||||
showOwnershipFilter,
|
||||
ownershipOptions,
|
||||
baseModelSelected,
|
||||
showBaseModelFilter,
|
||||
baseModelOptions,
|
||||
selectedSet
|
||||
} = useWidgetSelectItems({
|
||||
values: () => props.widget.options?.values as unknown[] | undefined,
|
||||
getOptionLabel: () => props.widget.options?.getOptionLabel,
|
||||
modelValue,
|
||||
assetKind: () => props.assetKind,
|
||||
outputMediaAssets,
|
||||
assetData,
|
||||
isAssetMode: () => props.isAssetMode
|
||||
})
|
||||
|
||||
const ownershipSelected = ref<OwnershipOption>('all')
|
||||
const showOwnershipFilter = computed(() => props.isAssetMode)
|
||||
|
||||
const { ownershipOptions, availableBaseModels } = useAssetFilterOptions(
|
||||
() => assetData?.assets.value ?? []
|
||||
)
|
||||
|
||||
const baseModelSelected = ref<Set<string>>(new Set())
|
||||
const showBaseModelFilter = computed(() => props.isAssetMode)
|
||||
const baseModelOptions = computed<FilterOption[]>(() => {
|
||||
if (!props.isAssetMode || !assetData) return []
|
||||
return availableBaseModels.value
|
||||
})
|
||||
|
||||
const selectedSet = ref<Set<string>>(new Set())
|
||||
|
||||
/**
|
||||
* Transforms a value using getOptionLabel if available.
|
||||
* Falls back to the original value if getOptionLabel is not provided,
|
||||
* returns undefined/null, or throws an error.
|
||||
*/
|
||||
function getDisplayLabel(value: string): string {
|
||||
const getOptionLabel = props.widget.options?.getOptionLabel
|
||||
if (!getOptionLabel) return value
|
||||
|
||||
try {
|
||||
return getOptionLabel(value) || value
|
||||
} catch (e) {
|
||||
console.error('Failed to map value:', e)
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
const inputItems = computed<FormDropdownItem[]>(() => {
|
||||
const values = props.widget.options?.values || []
|
||||
|
||||
if (!Array.isArray(values)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return values.map((value, index) => ({
|
||||
id: `input-${index}`,
|
||||
preview_url: getMediaUrl(String(value), 'input'),
|
||||
name: String(value),
|
||||
label: getDisplayLabel(String(value))
|
||||
}))
|
||||
})
|
||||
function assetKindToMediaType(kind: AssetKind): string {
|
||||
return kind === 'mesh' ? '3D' : kind
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-job cache of resolved outputs for multi-output jobs.
|
||||
* Keyed by jobId, populated lazily via resolveOutputAssetItems which
|
||||
* fetches full outputs through getJobDetail (itself LRU-cached).
|
||||
*/
|
||||
const resolvedByJobId = shallowRef(new Map<string, AssetItem[]>())
|
||||
const pendingJobIds = new Set<string>()
|
||||
|
||||
watch(
|
||||
() => outputMediaAssets.media.value,
|
||||
(assets, _, onCleanup) => {
|
||||
let cancelled = false
|
||||
onCleanup(() => {
|
||||
cancelled = true
|
||||
})
|
||||
pendingJobIds.clear()
|
||||
|
||||
for (const asset of assets) {
|
||||
const meta = getOutputAssetMetadata(asset.user_metadata)
|
||||
if (!meta) continue
|
||||
|
||||
const outputCount = meta.outputCount ?? meta.allOutputs?.length ?? 0
|
||||
if (
|
||||
outputCount <= 1 ||
|
||||
resolvedByJobId.value.has(meta.jobId) ||
|
||||
pendingJobIds.has(meta.jobId)
|
||||
)
|
||||
continue
|
||||
|
||||
pendingJobIds.add(meta.jobId)
|
||||
void resolveOutputAssetItems(meta, { createdAt: asset.created_at })
|
||||
.then((resolved) => {
|
||||
if (cancelled || !resolved.length) return
|
||||
const next = new Map(resolvedByJobId.value)
|
||||
next.set(meta.jobId, resolved)
|
||||
resolvedByJobId.value = next
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('Failed to resolve multi-output job', meta.jobId, error)
|
||||
})
|
||||
.finally(() => {
|
||||
pendingJobIds.delete(meta.jobId)
|
||||
})
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const outputItems = computed<FormDropdownItem[]>(() => {
|
||||
if (!['image', 'video', 'audio', 'mesh'].includes(props.assetKind ?? ''))
|
||||
return []
|
||||
|
||||
const targetMediaType = assetKindToMediaType(props.assetKind!)
|
||||
const seen = new Set<string>()
|
||||
const items: FormDropdownItem[] = []
|
||||
|
||||
const assets = outputMediaAssets.media.value.flatMap((asset) => {
|
||||
const meta = getOutputAssetMetadata(asset.user_metadata)
|
||||
const resolved = meta ? resolvedByJobId.value.get(meta.jobId) : undefined
|
||||
return resolved ?? [asset]
|
||||
})
|
||||
|
||||
for (const asset of assets) {
|
||||
if (getMediaTypeFromFilename(asset.name) !== targetMediaType) continue
|
||||
if (seen.has(asset.id)) continue
|
||||
seen.add(asset.id)
|
||||
const annotatedPath = `${asset.name} [output]`
|
||||
items.push({
|
||||
id: `output-${annotatedPath}`,
|
||||
preview_url: asset.preview_url || getMediaUrl(asset.name, 'output'),
|
||||
name: annotatedPath,
|
||||
label: getDisplayLabel(annotatedPath)
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
/**
|
||||
* Creates a fallback item for the current modelValue when it doesn't exist
|
||||
* in the available items list. This handles cases like template-loaded nodes
|
||||
* where the saved value may not exist in the current server environment.
|
||||
* Works for both local mode (inputItems/outputItems) and cloud mode (assetData).
|
||||
*/
|
||||
const missingValueItem = computed<FormDropdownItem | undefined>(() => {
|
||||
const currentValue = modelValue.value
|
||||
if (!currentValue) return undefined
|
||||
|
||||
// Check in cloud mode assets
|
||||
if (props.isAssetMode && assetData) {
|
||||
const existsInAssets = assetData.assets.value.some(
|
||||
(asset) => getAssetFilename(asset) === currentValue
|
||||
)
|
||||
if (existsInAssets) return undefined
|
||||
|
||||
return {
|
||||
id: `missing-${currentValue}`,
|
||||
preview_url: '',
|
||||
name: currentValue,
|
||||
label: getDisplayLabel(currentValue)
|
||||
}
|
||||
}
|
||||
|
||||
// Check in local mode inputs/outputs
|
||||
const existsInInputs = inputItems.value.some(
|
||||
(item) => item.name === currentValue
|
||||
)
|
||||
const existsInOutputs = outputItems.value.some(
|
||||
(item) => item.name === currentValue
|
||||
)
|
||||
|
||||
if (existsInInputs || existsInOutputs) return undefined
|
||||
|
||||
const isOutput = currentValue.endsWith(' [output]')
|
||||
const strippedValue = isOutput
|
||||
? currentValue.replace(' [output]', '')
|
||||
: currentValue
|
||||
|
||||
return {
|
||||
id: `missing-${currentValue}`,
|
||||
preview_url: getMediaUrl(strippedValue, isOutput ? 'output' : 'input'),
|
||||
name: currentValue,
|
||||
label: getDisplayLabel(currentValue)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Transforms AssetItem[] to FormDropdownItem[] for cloud mode.
|
||||
* Uses getAssetFilename for display name, asset.name for label.
|
||||
*/
|
||||
const assetItems = computed<FormDropdownItem[]>(() => {
|
||||
if (!props.isAssetMode || !assetData) return []
|
||||
return assetData.assets.value.map((asset) => ({
|
||||
id: asset.id,
|
||||
name: getAssetFilename(asset),
|
||||
label: getAssetDisplayName(asset),
|
||||
preview_url: asset.preview_url,
|
||||
is_immutable: asset.is_immutable,
|
||||
base_models: getAssetBaseModels(asset)
|
||||
}))
|
||||
})
|
||||
|
||||
const ownershipFilteredAssetItems = computed<FormDropdownItem[]>(() =>
|
||||
filterItemByOwnership(assetItems.value, ownershipSelected.value)
|
||||
)
|
||||
|
||||
const baseModelFilteredAssetItems = computed<FormDropdownItem[]>(() =>
|
||||
filterItemByBaseModels(
|
||||
ownershipFilteredAssetItems.value,
|
||||
baseModelSelected.value
|
||||
)
|
||||
)
|
||||
|
||||
const allItems = computed<FormDropdownItem[]>(() => {
|
||||
if (props.isAssetMode && assetData) {
|
||||
// Cloud assets not in user's library shouldn't appear as search results (COM-14333).
|
||||
// Unlike local mode, cloud users can't access files they don't own.
|
||||
return baseModelFilteredAssetItems.value
|
||||
}
|
||||
return [
|
||||
...(missingValueItem.value ? [missingValueItem.value] : []),
|
||||
...inputItems.value,
|
||||
...outputItems.value
|
||||
]
|
||||
})
|
||||
|
||||
const dropdownItems = computed<FormDropdownItem[]>(() => {
|
||||
if (props.isAssetMode) {
|
||||
return allItems.value
|
||||
}
|
||||
|
||||
switch (filterSelected.value) {
|
||||
case 'inputs':
|
||||
return inputItems.value
|
||||
case 'outputs':
|
||||
return outputItems.value
|
||||
case 'all':
|
||||
default:
|
||||
return allItems.value
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Items used for display in the input field. In cloud mode, includes
|
||||
* missing items so users can see their selected value even if not in library.
|
||||
*/
|
||||
const displayItems = computed<FormDropdownItem[]>(() => {
|
||||
if (props.isAssetMode && assetData && missingValueItem.value) {
|
||||
return [missingValueItem.value, ...baseModelFilteredAssetItems.value]
|
||||
}
|
||||
return dropdownItems.value
|
||||
const { updateSelectedItems, handleFilesUpdate } = useWidgetSelectActions({
|
||||
modelValue,
|
||||
dropdownItems,
|
||||
widget: () => props.widget,
|
||||
uploadFolder: () => props.uploadFolder,
|
||||
uploadSubfolder: () => props.uploadSubfolder
|
||||
})
|
||||
|
||||
const mediaPlaceholder = computed(() => {
|
||||
@@ -392,141 +139,12 @@ const acceptTypes = computed(() => {
|
||||
case 'mesh':
|
||||
return SUPPORTED_EXTENSIONS_ACCEPT
|
||||
default:
|
||||
return undefined // model or unknown
|
||||
return undefined
|
||||
}
|
||||
})
|
||||
|
||||
const layoutMode = ref<LayoutMode>(props.defaultLayoutMode ?? 'grid')
|
||||
|
||||
watch(
|
||||
[modelValue, displayItems],
|
||||
([currentValue]) => {
|
||||
if (currentValue === undefined) {
|
||||
selectedSet.value.clear()
|
||||
return
|
||||
}
|
||||
|
||||
const item = displayItems.value.find((item) => item.name === currentValue)
|
||||
if (!item) {
|
||||
selectedSet.value.clear()
|
||||
return
|
||||
}
|
||||
selectedSet.value.clear()
|
||||
selectedSet.value.add(item.id)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function updateSelectedItems(selectedItems: Set<string>) {
|
||||
let id: string | undefined = undefined
|
||||
if (selectedItems.size > 0) {
|
||||
id = selectedItems.values().next().value!
|
||||
}
|
||||
if (id == null) {
|
||||
modelValue.value = undefined
|
||||
return
|
||||
}
|
||||
const name = dropdownItems.value.find((item) => item.id === id)?.name
|
||||
if (!name) {
|
||||
modelValue.value = undefined
|
||||
return
|
||||
}
|
||||
modelValue.value = name
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()
|
||||
}
|
||||
|
||||
const uploadFile = async (
|
||||
file: File,
|
||||
isPasted: boolean = false,
|
||||
formFields: Partial<{ type: ResultItemType }> = {}
|
||||
) => {
|
||||
const body = new FormData()
|
||||
body.append('image', file)
|
||||
if (isPasted) body.append('subfolder', 'pasted')
|
||||
else if (props.uploadSubfolder)
|
||||
body.append('subfolder', props.uploadSubfolder)
|
||||
if (formFields.type) body.append('type', formFields.type)
|
||||
|
||||
const resp = await api.fetchApi('/upload/image', {
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
|
||||
if (resp.status !== 200) {
|
||||
toastStore.addAlert(resp.status + ' - ' + resp.statusText)
|
||||
return null
|
||||
}
|
||||
|
||||
const data = await resp.json()
|
||||
|
||||
// Update AssetsStore when uploading to input folder
|
||||
if (formFields.type === 'input' || (!formFields.type && !isPasted)) {
|
||||
const assetsStore = useAssetsStore()
|
||||
await assetsStore.updateInputs()
|
||||
}
|
||||
|
||||
return data.subfolder ? `${data.subfolder}/${data.name}` : data.name
|
||||
}
|
||||
|
||||
const uploadFiles = async (files: File[]): Promise<string[]> => {
|
||||
const folder = props.uploadFolder ?? 'input'
|
||||
const uploadPromises = files.map((file) =>
|
||||
uploadFile(file, false, { type: folder })
|
||||
)
|
||||
const results = await Promise.all(uploadPromises)
|
||||
return results.filter((path): path is string => path !== null)
|
||||
}
|
||||
|
||||
async function handleFilesUpdate(files: File[]) {
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
try {
|
||||
// 1. Upload files to server
|
||||
const uploadedPaths = await uploadFiles(files)
|
||||
|
||||
if (uploadedPaths.length === 0) {
|
||||
toastStore.addAlert('File upload failed')
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Update widget options to include new files
|
||||
// This simulates what addToComboValues does but for SimplifiedWidget
|
||||
const values = props.widget.options?.values
|
||||
if (Array.isArray(values)) {
|
||||
uploadedPaths.forEach((path) => {
|
||||
if (!values.includes(path)) {
|
||||
values.push(path)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 3. Update widget value to the first uploaded file
|
||||
modelValue.value = uploadedPaths[0]
|
||||
|
||||
// 4. Trigger callback to notify underlying LiteGraph widget
|
||||
if (props.widget.callback) {
|
||||
props.widget.callback(uploadedPaths[0])
|
||||
}
|
||||
|
||||
// 5. Snapshot undo state so the image change gets its own undo entry
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error)
|
||||
toastStore.addAlert(`Upload failed: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
function getMediaUrl(
|
||||
filename: string,
|
||||
type: 'input' | 'output' = 'input'
|
||||
): string {
|
||||
if (!['image', 'video', 'audio', 'mesh'].includes(props.assetKind ?? ''))
|
||||
return ''
|
||||
const params = new URLSearchParams({ filename, type })
|
||||
appendCloudResParam(params, filename)
|
||||
return `/api/view?${params}`
|
||||
}
|
||||
|
||||
function handleIsOpenUpdate(isOpen: boolean) {
|
||||
if (isOpen && !outputMediaAssets.loading.value) {
|
||||
void outputMediaAssets.refresh()
|
||||
@@ -537,11 +155,11 @@ function handleIsOpenUpdate(isOpen: boolean) {
|
||||
<template>
|
||||
<WidgetLayoutField :widget>
|
||||
<FormDropdown
|
||||
v-model:selected="selectedSet"
|
||||
v-model:filter-selected="filterSelected"
|
||||
v-model:layout-mode="layoutMode"
|
||||
v-model:ownership-selected="ownershipSelected"
|
||||
v-model:base-model-selected="baseModelSelected"
|
||||
:selected="selectedSet"
|
||||
:items="dropdownItems"
|
||||
:display-items="displayItems"
|
||||
:placeholder="mediaPlaceholder"
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { FormDropdownItem } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
|
||||
import { useWidgetSelectActions } from '@/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectActions'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
const mockCheckState = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', async () => {
|
||||
const actual = await vi.importActual(
|
||||
'@/platform/workflow/management/stores/workflowStore'
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
useWorkflowStore: () => ({
|
||||
activeWorkflow: {
|
||||
changeTracker: {
|
||||
checkState: mockCheckState
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
fetchApi: vi.fn(),
|
||||
apiURL: vi.fn((url: string) => url),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
function createItems(...names: string[]): FormDropdownItem[] {
|
||||
return names.map((name, i) => ({
|
||||
id: `input-${i}`,
|
||||
name,
|
||||
label: name,
|
||||
preview_url: ''
|
||||
}))
|
||||
}
|
||||
|
||||
describe('useWidgetSelectActions', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
mockCheckState.mockClear()
|
||||
})
|
||||
|
||||
describe('updateSelectedItems', () => {
|
||||
it('sets modelValue to the selected item name', () => {
|
||||
const modelValue = ref<string | undefined>('img_001.png')
|
||||
const items = createItems('img_001.png', 'photo_abc.jpg')
|
||||
const { updateSelectedItems } = useWidgetSelectActions({
|
||||
modelValue,
|
||||
dropdownItems: computed(() => items),
|
||||
widget: () =>
|
||||
fromPartial<SimplifiedWidget<string | undefined>>({
|
||||
name: 'test',
|
||||
type: 'combo',
|
||||
value: 'img_001.png'
|
||||
}),
|
||||
uploadFolder: () => 'input',
|
||||
uploadSubfolder: () => undefined
|
||||
})
|
||||
|
||||
updateSelectedItems(new Set(['input-1']))
|
||||
|
||||
expect(modelValue.value).toBe('photo_abc.jpg')
|
||||
expect(mockCheckState).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('clears modelValue when empty set', () => {
|
||||
const modelValue = ref<string | undefined>('img_001.png')
|
||||
const items = createItems('img_001.png')
|
||||
const { updateSelectedItems } = useWidgetSelectActions({
|
||||
modelValue,
|
||||
dropdownItems: computed(() => items),
|
||||
widget: () =>
|
||||
fromPartial<SimplifiedWidget<string | undefined>>({
|
||||
name: 'test',
|
||||
type: 'combo',
|
||||
value: 'img_001.png'
|
||||
}),
|
||||
uploadFolder: () => 'input',
|
||||
uploadSubfolder: () => undefined
|
||||
})
|
||||
|
||||
updateSelectedItems(new Set())
|
||||
|
||||
expect(modelValue.value).toBeUndefined()
|
||||
expect(mockCheckState).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleFilesUpdate', () => {
|
||||
it('uploads file and updates modelValue', async () => {
|
||||
const { api } = await import('@/scripts/api')
|
||||
vi.mocked(api.fetchApi).mockResolvedValue(
|
||||
fromPartial<Response>({
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ name: 'uploaded.png', subfolder: '' })
|
||||
})
|
||||
)
|
||||
|
||||
const modelValue = ref<string | undefined>('img_001.png')
|
||||
const items = createItems('img_001.png')
|
||||
const widgetValues = ['img_001.png']
|
||||
const { handleFilesUpdate } = useWidgetSelectActions({
|
||||
modelValue,
|
||||
dropdownItems: computed(() => items),
|
||||
widget: () =>
|
||||
fromPartial<SimplifiedWidget<string | undefined>>({
|
||||
name: 'test',
|
||||
type: 'combo',
|
||||
value: 'img_001.png',
|
||||
options: { values: widgetValues }
|
||||
}),
|
||||
uploadFolder: () => 'input',
|
||||
uploadSubfolder: () => undefined
|
||||
})
|
||||
|
||||
const file = new File(['test'], 'uploaded.png', {
|
||||
type: 'image/png'
|
||||
})
|
||||
await handleFilesUpdate([file])
|
||||
|
||||
expect(modelValue.value).toBe('uploaded.png')
|
||||
expect(mockCheckState).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('adds uploaded path to widget values array', async () => {
|
||||
const { api } = await import('@/scripts/api')
|
||||
vi.mocked(api.fetchApi).mockResolvedValue(
|
||||
fromPartial<Response>({
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ name: 'new.png', subfolder: '' })
|
||||
})
|
||||
)
|
||||
|
||||
const modelValue = ref<string | undefined>()
|
||||
const widgetValues = ['existing.png']
|
||||
const { handleFilesUpdate } = useWidgetSelectActions({
|
||||
modelValue,
|
||||
dropdownItems: computed(() => []),
|
||||
widget: () =>
|
||||
fromPartial<SimplifiedWidget<string | undefined>>({
|
||||
name: 'test',
|
||||
type: 'combo',
|
||||
options: { values: widgetValues }
|
||||
}),
|
||||
uploadFolder: () => 'input',
|
||||
uploadSubfolder: () => undefined
|
||||
})
|
||||
|
||||
await handleFilesUpdate([new File(['test'], 'new.png')])
|
||||
|
||||
expect(widgetValues).toContain('new.png')
|
||||
expect(widgetValues).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('calls widget callback after upload', async () => {
|
||||
const { api } = await import('@/scripts/api')
|
||||
vi.mocked(api.fetchApi).mockResolvedValue(
|
||||
fromPartial<Response>({
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ name: 'uploaded.png', subfolder: '' })
|
||||
})
|
||||
)
|
||||
|
||||
const mockCallback = vi.fn()
|
||||
const modelValue = ref<string | undefined>()
|
||||
const { handleFilesUpdate } = useWidgetSelectActions({
|
||||
modelValue,
|
||||
dropdownItems: computed(() => []),
|
||||
widget: () =>
|
||||
fromPartial<SimplifiedWidget<string | undefined>>({
|
||||
name: 'test',
|
||||
type: 'combo',
|
||||
callback: mockCallback,
|
||||
options: { values: [] }
|
||||
}),
|
||||
uploadFolder: () => 'input',
|
||||
uploadSubfolder: () => undefined
|
||||
})
|
||||
|
||||
await handleFilesUpdate([new File(['test'], 'uploaded.png')])
|
||||
|
||||
expect(mockCallback).toHaveBeenCalledWith('uploaded.png')
|
||||
})
|
||||
|
||||
it('shows alert toast on upload failure', async () => {
|
||||
const { api } = await import('@/scripts/api')
|
||||
vi.mocked(api.fetchApi).mockResolvedValue(
|
||||
fromPartial<Response>({
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error'
|
||||
})
|
||||
)
|
||||
|
||||
const modelValue = ref<string | undefined>('original.png')
|
||||
const { handleFilesUpdate } = useWidgetSelectActions({
|
||||
modelValue,
|
||||
dropdownItems: computed(() => []),
|
||||
widget: () =>
|
||||
fromPartial<SimplifiedWidget<string | undefined>>({
|
||||
name: 'test',
|
||||
type: 'combo',
|
||||
options: { values: [] }
|
||||
}),
|
||||
uploadFolder: () => 'input',
|
||||
uploadSubfolder: () => undefined
|
||||
})
|
||||
|
||||
await handleFilesUpdate([new File(['test'], 'fail.png')])
|
||||
|
||||
expect(modelValue.value).toBe('original.png')
|
||||
|
||||
const toastStore = useToastStore()
|
||||
expect(toastStore.addAlert).toHaveBeenCalledWith(
|
||||
'500 - Internal Server Error'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
import { toValue } from 'vue'
|
||||
import type { ComputedRef, MaybeRefOrGetter, Ref } from 'vue'
|
||||
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { FormDropdownItem } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
|
||||
import type { ResultItemType } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
interface UseWidgetSelectActionsOptions {
|
||||
modelValue: Ref<string | undefined>
|
||||
dropdownItems: ComputedRef<FormDropdownItem[]>
|
||||
widget: MaybeRefOrGetter<SimplifiedWidget<string | undefined>>
|
||||
uploadFolder: MaybeRefOrGetter<ResultItemType | undefined>
|
||||
uploadSubfolder: MaybeRefOrGetter<string | undefined>
|
||||
}
|
||||
|
||||
export function useWidgetSelectActions(options: UseWidgetSelectActionsOptions) {
|
||||
const { modelValue, dropdownItems } = options
|
||||
const toastStore = useToastStore()
|
||||
const { wrapWithErrorHandlingAsync } = useErrorHandling()
|
||||
|
||||
function checkWorkflowState() {
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()
|
||||
}
|
||||
|
||||
function updateSelectedItems(selectedItems: Set<string>) {
|
||||
const id =
|
||||
selectedItems.size > 0 ? selectedItems.values().next().value : undefined
|
||||
const name =
|
||||
id == null
|
||||
? undefined
|
||||
: dropdownItems.value.find((item) => item.id === id)?.name
|
||||
|
||||
modelValue.value = name
|
||||
checkWorkflowState()
|
||||
}
|
||||
|
||||
async function uploadFile(
|
||||
file: File,
|
||||
isPasted: boolean = false,
|
||||
formFields: Partial<{ type: ResultItemType }> = {}
|
||||
) {
|
||||
const body = new FormData()
|
||||
body.append('image', file)
|
||||
if (isPasted) body.append('subfolder', 'pasted')
|
||||
else {
|
||||
const subfolder = toValue(options.uploadSubfolder)
|
||||
if (subfolder) body.append('subfolder', subfolder)
|
||||
}
|
||||
if (formFields.type) body.append('type', formFields.type)
|
||||
|
||||
const resp = await api.fetchApi('/upload/image', {
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
|
||||
if (resp.status !== 200) {
|
||||
toastStore.addAlert(resp.status + ' - ' + resp.statusText)
|
||||
return null
|
||||
}
|
||||
|
||||
const data = await resp.json()
|
||||
|
||||
if (formFields.type === 'input' || (!formFields.type && !isPasted)) {
|
||||
const assetsStore = useAssetsStore()
|
||||
await assetsStore.updateInputs()
|
||||
}
|
||||
|
||||
return data.subfolder ? `${data.subfolder}/${data.name}` : data.name
|
||||
}
|
||||
|
||||
async function uploadFiles(files: File[]): Promise<string[]> {
|
||||
const folder = toValue(options.uploadFolder) ?? 'input'
|
||||
const uploadPromises = files.map((file) =>
|
||||
uploadFile(file, false, { type: folder })
|
||||
)
|
||||
const results = await Promise.all(uploadPromises)
|
||||
return results.filter((path): path is string => path !== null)
|
||||
}
|
||||
|
||||
const handleFilesUpdate = wrapWithErrorHandlingAsync(
|
||||
async (files: File[]) => {
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
const uploadedPaths = await uploadFiles(files)
|
||||
|
||||
if (uploadedPaths.length === 0) {
|
||||
toastStore.addAlert('File upload failed')
|
||||
return
|
||||
}
|
||||
|
||||
const widget = toValue(options.widget)
|
||||
const values = widget.options?.values
|
||||
if (Array.isArray(values)) {
|
||||
uploadedPaths.forEach((path) => {
|
||||
if (!values.includes(path)) {
|
||||
values.push(path)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
modelValue.value = uploadedPaths[0]
|
||||
|
||||
if (widget.callback) {
|
||||
widget.callback(uploadedPaths[0])
|
||||
}
|
||||
|
||||
checkWorkflowState()
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
updateSelectedItems,
|
||||
handleFilesUpdate
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,668 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { useWidgetSelectItems } from '@/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectItems'
|
||||
|
||||
const mockAssetsData = vi.hoisted(() => ({ items: [] as AssetItem[] }))
|
||||
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData',
|
||||
() => ({
|
||||
useAssetWidgetData: () => ({
|
||||
category: computed(() => 'checkpoints'),
|
||||
assets: computed(() => mockAssetsData.items),
|
||||
isLoading: computed(() => false),
|
||||
error: computed(() => null)
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
const mockResolveOutputAssetItems = vi.fn()
|
||||
|
||||
function createMockMediaAssets() {
|
||||
return {
|
||||
media: ref<AssetItem[]>([]),
|
||||
loading: ref(false),
|
||||
error: ref(null),
|
||||
fetchMediaList: vi.fn().mockResolvedValue([]),
|
||||
refresh: vi.fn().mockResolvedValue([]),
|
||||
loadMore: vi.fn(),
|
||||
hasMore: ref(false),
|
||||
isLoadingMore: ref(false)
|
||||
}
|
||||
}
|
||||
|
||||
let mockMediaAssets = createMockMediaAssets()
|
||||
|
||||
vi.mock('@/platform/assets/composables/media/useMediaAssets', () => ({
|
||||
useMediaAssets: () => mockMediaAssets
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/composables/useAssetFilterOptions', () => ({
|
||||
useAssetFilterOptions: () => ({
|
||||
ownershipOptions: computed(() => []),
|
||||
availableBaseModels: computed(() => []),
|
||||
availableFileFormats: computed(() => [])
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/utils/outputAssetUtil', () => ({
|
||||
resolveOutputAssetItems: (...args: unknown[]) =>
|
||||
mockResolveOutputAssetItems(...args)
|
||||
}))
|
||||
|
||||
function createDefaultOptions(
|
||||
overrides: Partial<Parameters<typeof useWidgetSelectItems>[0]> = {}
|
||||
) {
|
||||
return {
|
||||
values: () => ['img_001.png', 'photo_abc.jpg', 'hash789.png'],
|
||||
getOptionLabel: () =>
|
||||
undefined as ((value?: string | null) => string) | undefined,
|
||||
modelValue: ref<string | undefined>('img_001.png'),
|
||||
assetKind: () => 'image' as const,
|
||||
outputMediaAssets: mockMediaAssets,
|
||||
assetData: null,
|
||||
isAssetMode: () => false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('display label behavior', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('uses values as labels when no label function provided', () => {
|
||||
const { dropdownItems } = useWidgetSelectItems(createDefaultOptions())
|
||||
expect(dropdownItems.value[0]).toMatchObject({
|
||||
name: 'img_001.png',
|
||||
label: 'img_001.png'
|
||||
})
|
||||
})
|
||||
|
||||
it('applies custom label function', () => {
|
||||
const getOptionLabel = (v?: string | null) => `Custom: ${v}`
|
||||
const { dropdownItems } = useWidgetSelectItems(
|
||||
createDefaultOptions({ getOptionLabel: () => getOptionLabel })
|
||||
)
|
||||
expect(dropdownItems.value[0].label).toBe('Custom: img_001.png')
|
||||
})
|
||||
|
||||
it('falls back to value on label function error', () => {
|
||||
const consoleWarnSpy = vi
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation(() => {})
|
||||
const getOptionLabel = (v?: string | null) => {
|
||||
if (v === 'photo_abc.jpg') throw new Error('fail')
|
||||
return `Labeled: ${v}`
|
||||
}
|
||||
const { dropdownItems } = useWidgetSelectItems(
|
||||
createDefaultOptions({ getOptionLabel: () => getOptionLabel })
|
||||
)
|
||||
expect(dropdownItems.value[0].label).toBe('Labeled: img_001.png')
|
||||
expect(dropdownItems.value[1].label).toBe('photo_abc.jpg')
|
||||
expect(dropdownItems.value[2].label).toBe('Labeled: hash789.png')
|
||||
expect(consoleWarnSpy).toHaveBeenCalled()
|
||||
consoleWarnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('falls back to value when label function returns empty string', () => {
|
||||
const getOptionLabel = (v?: string | null) => {
|
||||
if (v === 'photo_abc.jpg') return ''
|
||||
return `Labeled: ${v}`
|
||||
}
|
||||
const { dropdownItems } = useWidgetSelectItems(
|
||||
createDefaultOptions({ getOptionLabel: () => getOptionLabel })
|
||||
)
|
||||
expect(dropdownItems.value[1].label).toBe('photo_abc.jpg')
|
||||
})
|
||||
|
||||
it('falls back to value when label function returns undefined', () => {
|
||||
const getOptionLabel = (v?: string | null) => {
|
||||
if (v === 'hash789.png') return undefined as unknown as string
|
||||
return `Labeled: ${v}`
|
||||
}
|
||||
const { dropdownItems } = useWidgetSelectItems(
|
||||
createDefaultOptions({ getOptionLabel: () => getOptionLabel })
|
||||
)
|
||||
expect(dropdownItems.value[2].label).toBe('hash789.png')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useWidgetSelectItems', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
mockMediaAssets = createMockMediaAssets()
|
||||
mockResolveOutputAssetItems.mockReset()
|
||||
mockAssetsData.items = []
|
||||
})
|
||||
|
||||
describe('dropdownItems', () => {
|
||||
it('maps values to items with names as labels', () => {
|
||||
const { dropdownItems } = useWidgetSelectItems(createDefaultOptions())
|
||||
expect(dropdownItems.value).toHaveLength(3)
|
||||
expect(dropdownItems.value[0]).toMatchObject({
|
||||
name: 'img_001.png',
|
||||
label: 'img_001.png'
|
||||
})
|
||||
})
|
||||
|
||||
it('returns empty when values is undefined and no modelValue', () => {
|
||||
const { dropdownItems } = useWidgetSelectItems(
|
||||
createDefaultOptions({
|
||||
values: () => undefined,
|
||||
modelValue: ref(undefined)
|
||||
})
|
||||
)
|
||||
expect(dropdownItems.value).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('missing value handling', () => {
|
||||
it('creates fallback item when modelValue not in inputs', () => {
|
||||
const { dropdownItems } = useWidgetSelectItems(
|
||||
createDefaultOptions({
|
||||
values: () => ['img_001.png', 'photo_abc.jpg'],
|
||||
modelValue: ref('template_image.png')
|
||||
})
|
||||
)
|
||||
expect(
|
||||
dropdownItems.value.some((item) => item.name === 'template_image.png')
|
||||
).toBe(true)
|
||||
expect(dropdownItems.value[0].id).toBe('missing-template_image.png')
|
||||
})
|
||||
|
||||
it('does not include fallback when filter is inputs', async () => {
|
||||
const { dropdownItems, filterSelected } = useWidgetSelectItems(
|
||||
createDefaultOptions({
|
||||
values: () => ['img_001.png', 'photo_abc.jpg'],
|
||||
modelValue: ref('template_image.png')
|
||||
})
|
||||
)
|
||||
filterSelected.value = 'inputs'
|
||||
await nextTick()
|
||||
|
||||
expect(dropdownItems.value).toHaveLength(2)
|
||||
expect(
|
||||
dropdownItems.value.every(
|
||||
(item) => !String(item.id).startsWith('missing-')
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('does not include fallback when filter is outputs', async () => {
|
||||
const { dropdownItems, filterSelected } = useWidgetSelectItems(
|
||||
createDefaultOptions({
|
||||
values: () => ['img_001.png', 'photo_abc.jpg'],
|
||||
modelValue: ref('template_image.png')
|
||||
})
|
||||
)
|
||||
filterSelected.value = 'outputs'
|
||||
await nextTick()
|
||||
|
||||
expect(
|
||||
dropdownItems.value.every(
|
||||
(item) => !String(item.id).startsWith('missing-')
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('no fallback when modelValue exists in inputs', () => {
|
||||
const { dropdownItems } = useWidgetSelectItems(
|
||||
createDefaultOptions({
|
||||
values: () => ['img_001.png', 'photo_abc.jpg'],
|
||||
modelValue: ref('img_001.png')
|
||||
})
|
||||
)
|
||||
expect(dropdownItems.value).toHaveLength(2)
|
||||
expect(
|
||||
dropdownItems.value.every(
|
||||
(item) => !String(item.id).startsWith('missing-')
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('no fallback when modelValue is undefined', () => {
|
||||
const { dropdownItems } = useWidgetSelectItems(
|
||||
createDefaultOptions({
|
||||
values: () => ['img_001.png', 'photo_abc.jpg'],
|
||||
modelValue: ref(undefined)
|
||||
})
|
||||
)
|
||||
expect(dropdownItems.value).toHaveLength(2)
|
||||
expect(
|
||||
dropdownItems.value.every(
|
||||
(item) => !String(item.id).startsWith('missing-')
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cloud asset mode', () => {
|
||||
const createTestAsset = (
|
||||
id: string,
|
||||
name: string,
|
||||
preview_url: string
|
||||
): AssetItem => ({
|
||||
id,
|
||||
name,
|
||||
preview_url,
|
||||
tags: []
|
||||
})
|
||||
|
||||
it('excludes missing items from cloud dropdown', () => {
|
||||
mockAssetsData.items = [
|
||||
createTestAsset(
|
||||
'asset-1',
|
||||
'existing_model.safetensors',
|
||||
'https://example.com/preview.jpg'
|
||||
)
|
||||
]
|
||||
|
||||
const assetData = {
|
||||
category: computed(() => 'checkpoints'),
|
||||
assets: computed(() => mockAssetsData.items),
|
||||
isLoading: computed(() => false),
|
||||
error: computed(() => null)
|
||||
}
|
||||
|
||||
const { dropdownItems } = useWidgetSelectItems(
|
||||
createDefaultOptions({
|
||||
values: () => [],
|
||||
modelValue: ref('missing_model.safetensors'),
|
||||
assetKind: () => 'model',
|
||||
isAssetMode: () => true,
|
||||
assetData
|
||||
})
|
||||
)
|
||||
|
||||
expect(dropdownItems.value).toHaveLength(1)
|
||||
expect(dropdownItems.value[0].name).toBe('existing_model.safetensors')
|
||||
})
|
||||
|
||||
it('shows only available cloud assets', () => {
|
||||
mockAssetsData.items = [
|
||||
createTestAsset(
|
||||
'asset-1',
|
||||
'model_a.safetensors',
|
||||
'https://example.com/a.jpg'
|
||||
),
|
||||
createTestAsset(
|
||||
'asset-2',
|
||||
'model_b.safetensors',
|
||||
'https://example.com/b.jpg'
|
||||
)
|
||||
]
|
||||
|
||||
const assetData = {
|
||||
category: computed(() => 'checkpoints'),
|
||||
assets: computed(() => mockAssetsData.items),
|
||||
isLoading: computed(() => false),
|
||||
error: computed(() => null)
|
||||
}
|
||||
|
||||
const { dropdownItems } = useWidgetSelectItems(
|
||||
createDefaultOptions({
|
||||
values: () => [],
|
||||
modelValue: ref('model_a.safetensors'),
|
||||
assetKind: () => 'model',
|
||||
isAssetMode: () => true,
|
||||
assetData
|
||||
})
|
||||
)
|
||||
|
||||
expect(dropdownItems.value).toHaveLength(2)
|
||||
expect(dropdownItems.value.map((i) => i.name)).toEqual([
|
||||
'model_a.safetensors',
|
||||
'model_b.safetensors'
|
||||
])
|
||||
})
|
||||
|
||||
it('returns empty dropdown when no cloud assets', () => {
|
||||
const assetData = {
|
||||
category: computed(() => 'checkpoints'),
|
||||
assets: computed(() => [] as AssetItem[]),
|
||||
isLoading: computed(() => false),
|
||||
error: computed(() => null)
|
||||
}
|
||||
|
||||
const { dropdownItems } = useWidgetSelectItems(
|
||||
createDefaultOptions({
|
||||
values: () => [],
|
||||
modelValue: ref('missing.safetensors'),
|
||||
assetKind: () => 'model',
|
||||
isAssetMode: () => true,
|
||||
assetData
|
||||
})
|
||||
)
|
||||
|
||||
expect(dropdownItems.value).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('includes missing cloud asset in displayItems', () => {
|
||||
mockAssetsData.items = [
|
||||
createTestAsset(
|
||||
'asset-1',
|
||||
'existing_model.safetensors',
|
||||
'https://example.com/preview.jpg'
|
||||
)
|
||||
]
|
||||
|
||||
const assetData = {
|
||||
category: computed(() => 'checkpoints'),
|
||||
assets: computed(() => mockAssetsData.items),
|
||||
isLoading: computed(() => false),
|
||||
error: computed(() => null)
|
||||
}
|
||||
|
||||
const { displayItems, selectedSet } = useWidgetSelectItems(
|
||||
createDefaultOptions({
|
||||
values: () => [],
|
||||
modelValue: ref('missing_model.safetensors'),
|
||||
assetKind: () => 'model',
|
||||
isAssetMode: () => true,
|
||||
assetData
|
||||
})
|
||||
)
|
||||
|
||||
expect(displayItems.value).toHaveLength(2)
|
||||
expect(displayItems.value[0].name).toBe('missing_model.safetensors')
|
||||
expect(displayItems.value[0].id).toBe('missing-missing_model.safetensors')
|
||||
expect(selectedSet.value.has('missing-missing_model.safetensors')).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('multi-output jobs', () => {
|
||||
function makeMultiOutputAsset(
|
||||
jobId: string,
|
||||
name: string,
|
||||
nodeId: string,
|
||||
outputCount: number
|
||||
) {
|
||||
return {
|
||||
id: jobId,
|
||||
name,
|
||||
preview_url: `/api/view?filename=${name}&type=output`,
|
||||
tags: ['output'],
|
||||
user_metadata: {
|
||||
jobId,
|
||||
nodeId,
|
||||
subfolder: '',
|
||||
outputCount,
|
||||
allOutputs: [
|
||||
{
|
||||
filename: name,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId,
|
||||
mediaType: 'images'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('shows all outputs after resolving multi-output jobs', async () => {
|
||||
mockMediaAssets.media.value = [
|
||||
makeMultiOutputAsset('job-1', 'preview.png', '5', 3)
|
||||
]
|
||||
|
||||
mockResolveOutputAssetItems.mockResolvedValue([
|
||||
{
|
||||
id: 'job-1-5-output_001.png',
|
||||
name: 'output_001.png',
|
||||
preview_url: '/api/view?filename=output_001.png&type=output',
|
||||
tags: ['output']
|
||||
},
|
||||
{
|
||||
id: 'job-1-5-output_002.png',
|
||||
name: 'output_002.png',
|
||||
preview_url: '/api/view?filename=output_002.png&type=output',
|
||||
tags: ['output']
|
||||
},
|
||||
{
|
||||
id: 'job-1-5-output_003.png',
|
||||
name: 'output_003.png',
|
||||
preview_url: '/api/view?filename=output_003.png&type=output',
|
||||
tags: ['output']
|
||||
}
|
||||
])
|
||||
|
||||
const { dropdownItems, filterSelected } = useWidgetSelectItems(
|
||||
createDefaultOptions({
|
||||
values: () => [],
|
||||
modelValue: ref('output_001.png')
|
||||
})
|
||||
)
|
||||
filterSelected.value = 'outputs'
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(dropdownItems.value).toHaveLength(3)
|
||||
})
|
||||
|
||||
expect(dropdownItems.value.map((i) => i.name)).toEqual([
|
||||
'output_001.png [output]',
|
||||
'output_002.png [output]',
|
||||
'output_003.png [output]'
|
||||
])
|
||||
})
|
||||
|
||||
it('shows preview when job has only one output', async () => {
|
||||
mockMediaAssets.media.value = [
|
||||
makeMultiOutputAsset('job-2', 'single.png', '3', 1)
|
||||
]
|
||||
|
||||
const { dropdownItems, filterSelected } = useWidgetSelectItems(
|
||||
createDefaultOptions({
|
||||
values: () => [],
|
||||
modelValue: ref('single.png')
|
||||
})
|
||||
)
|
||||
filterSelected.value = 'outputs'
|
||||
await nextTick()
|
||||
|
||||
expect(dropdownItems.value).toHaveLength(1)
|
||||
expect(dropdownItems.value[0].name).toBe('single.png [output]')
|
||||
expect(mockResolveOutputAssetItems).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resolves two multi-output jobs independently', async () => {
|
||||
mockMediaAssets.media.value = [
|
||||
makeMultiOutputAsset('job-A', 'previewA.png', '1', 2),
|
||||
makeMultiOutputAsset('job-B', 'previewB.png', '2', 2)
|
||||
]
|
||||
|
||||
mockResolveOutputAssetItems.mockImplementation(
|
||||
async (meta: { jobId: string }) => {
|
||||
if (meta.jobId === 'job-A') {
|
||||
return [
|
||||
{
|
||||
id: 'A-1',
|
||||
name: 'a1.png',
|
||||
preview_url: '',
|
||||
tags: ['output']
|
||||
},
|
||||
{
|
||||
id: 'A-2',
|
||||
name: 'a2.png',
|
||||
preview_url: '',
|
||||
tags: ['output']
|
||||
}
|
||||
]
|
||||
}
|
||||
return [
|
||||
{
|
||||
id: 'B-1',
|
||||
name: 'b1.png',
|
||||
preview_url: '',
|
||||
tags: ['output']
|
||||
},
|
||||
{
|
||||
id: 'B-2',
|
||||
name: 'b2.png',
|
||||
preview_url: '',
|
||||
tags: ['output']
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
const { dropdownItems, filterSelected } = useWidgetSelectItems(
|
||||
createDefaultOptions({
|
||||
values: () => [],
|
||||
modelValue: ref(undefined)
|
||||
})
|
||||
)
|
||||
filterSelected.value = 'outputs'
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(dropdownItems.value).toHaveLength(4)
|
||||
})
|
||||
|
||||
const names = dropdownItems.value.map((i) => i.name)
|
||||
expect(names).toContain('a1.png [output]')
|
||||
expect(names).toContain('a2.png [output]')
|
||||
expect(names).toContain('b1.png [output]')
|
||||
expect(names).toContain('b2.png [output]')
|
||||
})
|
||||
|
||||
it('resolves outputs when allOutputs already contains all items', async () => {
|
||||
mockMediaAssets.media.value = [
|
||||
{
|
||||
id: 'job-complete',
|
||||
name: 'preview.png',
|
||||
preview_url: '/api/view?filename=preview.png&type=output',
|
||||
tags: ['output'],
|
||||
user_metadata: {
|
||||
jobId: 'job-complete',
|
||||
nodeId: '1',
|
||||
subfolder: '',
|
||||
outputCount: 2,
|
||||
allOutputs: [
|
||||
{
|
||||
filename: 'out1.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
},
|
||||
{
|
||||
filename: 'out2.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
mockResolveOutputAssetItems.mockResolvedValue([
|
||||
{
|
||||
id: 'c-1',
|
||||
name: 'out1.png',
|
||||
preview_url: '',
|
||||
tags: ['output']
|
||||
},
|
||||
{
|
||||
id: 'c-2',
|
||||
name: 'out2.png',
|
||||
preview_url: '',
|
||||
tags: ['output']
|
||||
}
|
||||
])
|
||||
|
||||
const { dropdownItems, filterSelected } = useWidgetSelectItems(
|
||||
createDefaultOptions({
|
||||
values: () => [],
|
||||
modelValue: ref(undefined)
|
||||
})
|
||||
)
|
||||
filterSelected.value = 'outputs'
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(dropdownItems.value).toHaveLength(2)
|
||||
})
|
||||
|
||||
expect(mockResolveOutputAssetItems).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ jobId: 'job-complete' }),
|
||||
expect.any(Object)
|
||||
)
|
||||
const names = dropdownItems.value.map((i) => i.name)
|
||||
expect(names).toEqual(['out1.png [output]', 'out2.png [output]'])
|
||||
})
|
||||
|
||||
it('falls back to preview when resolver rejects', async () => {
|
||||
const consoleWarnSpy = vi
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
mockMediaAssets.media.value = [
|
||||
makeMultiOutputAsset('job-fail', 'preview.png', '1', 3)
|
||||
]
|
||||
mockResolveOutputAssetItems.mockRejectedValue(new Error('network error'))
|
||||
|
||||
const { dropdownItems, filterSelected } = useWidgetSelectItems(
|
||||
createDefaultOptions({
|
||||
values: () => [],
|
||||
modelValue: ref(undefined)
|
||||
})
|
||||
)
|
||||
filterSelected.value = 'outputs'
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
'Failed to resolve multi-output job',
|
||||
'job-fail',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
|
||||
expect(dropdownItems.value).toHaveLength(1)
|
||||
expect(dropdownItems.value[0].name).toBe('preview.png [output]')
|
||||
consoleWarnSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectedSet', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('returns empty set when modelValue is undefined', () => {
|
||||
const { selectedSet } = useWidgetSelectItems(
|
||||
createDefaultOptions({
|
||||
modelValue: ref(undefined)
|
||||
})
|
||||
)
|
||||
expect(selectedSet.value.size).toBe(0)
|
||||
})
|
||||
|
||||
it('returns set with matching item id when modelValue matches', () => {
|
||||
const { selectedSet } = useWidgetSelectItems(
|
||||
createDefaultOptions({
|
||||
modelValue: ref('img_001.png')
|
||||
})
|
||||
)
|
||||
expect(selectedSet.value.size).toBe(1)
|
||||
expect(selectedSet.value.has('input-0')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns set with missing item id when modelValue matches no input', () => {
|
||||
const { selectedSet } = useWidgetSelectItems(
|
||||
createDefaultOptions({
|
||||
modelValue: ref('nonexistent.png'),
|
||||
values: () => ['img_001.png']
|
||||
})
|
||||
)
|
||||
expect(selectedSet.value.size).toBe(1)
|
||||
expect(selectedSet.value.has('missing-nonexistent.png')).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,314 @@
|
||||
import { capitalize } from 'es-toolkit'
|
||||
import { computed, ref, shallowRef, toValue, watch } from 'vue'
|
||||
import type { MaybeRefOrGetter, Ref } from 'vue'
|
||||
|
||||
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
|
||||
import { useAssetFilterOptions } from '@/platform/assets/composables/useAssetFilterOptions'
|
||||
import {
|
||||
filterItemByBaseModels,
|
||||
filterItemByOwnership
|
||||
} from '@/platform/assets/utils/assetFilterUtils'
|
||||
import {
|
||||
getAssetBaseModels,
|
||||
getAssetDisplayName,
|
||||
getAssetFilename
|
||||
} from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import type {
|
||||
FilterOption,
|
||||
OwnershipOption
|
||||
} from '@/platform/assets/types/filterTypes'
|
||||
import type { FormDropdownItem } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
|
||||
import type { useAssetWidgetData } from '@/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData'
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil'
|
||||
import type { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
|
||||
import type { AssetKind } from '@/types/widgetTypes'
|
||||
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
|
||||
|
||||
function getDisplayLabel(
|
||||
value: string,
|
||||
getOptionLabel?: ((value?: string | null) => string) | undefined
|
||||
): string {
|
||||
if (!getOptionLabel) return value
|
||||
|
||||
try {
|
||||
return getOptionLabel(value) || value
|
||||
} catch (e) {
|
||||
console.warn('Failed to map value:', e)
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
function assetKindToMediaType(kind: AssetKind): string {
|
||||
return kind === 'mesh' ? '3D' : kind
|
||||
}
|
||||
|
||||
function getMediaUrl(
|
||||
filename: string,
|
||||
type: 'input' | 'output',
|
||||
assetKind: AssetKind | undefined
|
||||
): string {
|
||||
if (!['image', 'video', 'audio', 'mesh'].includes(assetKind ?? '')) return ''
|
||||
const params = new URLSearchParams({ filename, type })
|
||||
appendCloudResParam(params, filename)
|
||||
return `/api/view?${params}`
|
||||
}
|
||||
|
||||
interface UseWidgetSelectItemsOptions {
|
||||
values: MaybeRefOrGetter<unknown[] | undefined>
|
||||
getOptionLabel: MaybeRefOrGetter<
|
||||
((value?: string | null) => string) | undefined
|
||||
>
|
||||
modelValue: Ref<string | undefined>
|
||||
assetKind: MaybeRefOrGetter<AssetKind | undefined>
|
||||
outputMediaAssets: ReturnType<typeof useMediaAssets>
|
||||
assetData: ReturnType<typeof useAssetWidgetData> | null
|
||||
isAssetMode: MaybeRefOrGetter<boolean | undefined>
|
||||
}
|
||||
|
||||
export function useWidgetSelectItems(options: UseWidgetSelectItemsOptions) {
|
||||
const { modelValue, outputMediaAssets, assetData } = options
|
||||
|
||||
const filterSelected = ref('all')
|
||||
const filterOptions = computed<FilterOption[]>(() => {
|
||||
const isAsset = toValue(options.isAssetMode)
|
||||
if (isAsset) {
|
||||
const categoryName = assetData?.category.value ?? 'All'
|
||||
return [{ name: capitalize(categoryName), value: 'all' }]
|
||||
}
|
||||
return [
|
||||
{ name: 'All', value: 'all' },
|
||||
{ name: 'Inputs', value: 'inputs' },
|
||||
{ name: 'Outputs', value: 'outputs' }
|
||||
]
|
||||
})
|
||||
|
||||
const ownershipSelected = ref<OwnershipOption>('all')
|
||||
const showOwnershipFilter = computed(() => !!toValue(options.isAssetMode))
|
||||
|
||||
const { ownershipOptions, availableBaseModels } = useAssetFilterOptions(
|
||||
() => assetData?.assets.value ?? []
|
||||
)
|
||||
|
||||
const baseModelSelected = ref<Set<string>>(new Set())
|
||||
const showBaseModelFilter = computed(() => !!toValue(options.isAssetMode))
|
||||
const baseModelOptions = computed<FilterOption[]>(() => {
|
||||
if (!toValue(options.isAssetMode) || !assetData) return []
|
||||
return availableBaseModels.value
|
||||
})
|
||||
|
||||
const resolvedByJobId = shallowRef(new Map<string, AssetItem[]>())
|
||||
const pendingJobIds = new Set<string>()
|
||||
|
||||
watch(
|
||||
() => outputMediaAssets.media.value,
|
||||
(assets, _, onCleanup) => {
|
||||
let cancelled = false
|
||||
onCleanup(() => {
|
||||
cancelled = true
|
||||
pendingJobIds.clear()
|
||||
})
|
||||
|
||||
for (const asset of assets) {
|
||||
const meta = getOutputAssetMetadata(asset.user_metadata)
|
||||
if (!meta) continue
|
||||
|
||||
const outputCount = meta.outputCount ?? meta.allOutputs?.length ?? 0
|
||||
if (
|
||||
outputCount <= 1 ||
|
||||
resolvedByJobId.value.has(meta.jobId) ||
|
||||
pendingJobIds.has(meta.jobId)
|
||||
)
|
||||
continue
|
||||
|
||||
pendingJobIds.add(meta.jobId)
|
||||
void resolveOutputAssetItems(meta, { createdAt: asset.created_at })
|
||||
.then((resolved) => {
|
||||
if (cancelled || !resolved.length) return
|
||||
const next = new Map(resolvedByJobId.value)
|
||||
next.set(meta.jobId, resolved)
|
||||
resolvedByJobId.value = next
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn(
|
||||
'Failed to resolve multi-output job',
|
||||
meta.jobId,
|
||||
error
|
||||
)
|
||||
})
|
||||
.finally(() => {
|
||||
pendingJobIds.delete(meta.jobId)
|
||||
})
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const inputItems = computed<FormDropdownItem[]>(() => {
|
||||
const values = toValue(options.values) || []
|
||||
if (!Array.isArray(values)) return []
|
||||
|
||||
const labelFn = toValue(options.getOptionLabel)
|
||||
const kind = toValue(options.assetKind)
|
||||
return values.map((value, index) => ({
|
||||
id: `input-${index}`,
|
||||
preview_url: getMediaUrl(String(value), 'input', kind),
|
||||
name: String(value),
|
||||
label: getDisplayLabel(String(value), labelFn)
|
||||
}))
|
||||
})
|
||||
|
||||
const outputItems = computed<FormDropdownItem[]>(() => {
|
||||
const kind = toValue(options.assetKind)
|
||||
if (!['image', 'video', 'audio', 'mesh'].includes(kind ?? '')) return []
|
||||
|
||||
const targetMediaType = assetKindToMediaType(kind!)
|
||||
const seen = new Set<string>()
|
||||
const items: FormDropdownItem[] = []
|
||||
const labelFn = toValue(options.getOptionLabel)
|
||||
|
||||
const assets = outputMediaAssets.media.value.flatMap((asset) => {
|
||||
const meta = getOutputAssetMetadata(asset.user_metadata)
|
||||
const resolved = meta ? resolvedByJobId.value.get(meta.jobId) : undefined
|
||||
return resolved ?? [asset]
|
||||
})
|
||||
|
||||
for (const asset of assets) {
|
||||
if (getMediaTypeFromFilename(asset.name) !== targetMediaType) continue
|
||||
if (seen.has(asset.id)) continue
|
||||
seen.add(asset.id)
|
||||
const annotatedPath = `${asset.name} [output]`
|
||||
items.push({
|
||||
id: `output-${asset.id}`,
|
||||
preview_url:
|
||||
asset.preview_url || getMediaUrl(asset.name, 'output', kind),
|
||||
name: annotatedPath,
|
||||
label: getDisplayLabel(annotatedPath, labelFn)
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
const missingValueItem = computed<FormDropdownItem | undefined>(() => {
|
||||
const currentValue = modelValue.value
|
||||
if (!currentValue) return undefined
|
||||
const labelFn = toValue(options.getOptionLabel)
|
||||
const kind = toValue(options.assetKind)
|
||||
|
||||
if (toValue(options.isAssetMode) && assetData) {
|
||||
const existsInAssets = assetData.assets.value.some(
|
||||
(asset) => getAssetFilename(asset) === currentValue
|
||||
)
|
||||
if (existsInAssets) return undefined
|
||||
|
||||
return {
|
||||
id: `missing-${currentValue}`,
|
||||
preview_url: '',
|
||||
name: currentValue,
|
||||
label: getDisplayLabel(currentValue, labelFn)
|
||||
}
|
||||
}
|
||||
|
||||
const existsInInputs = inputItems.value.some(
|
||||
(item) => item.name === currentValue
|
||||
)
|
||||
const existsInOutputs = outputItems.value.some(
|
||||
(item) => item.name === currentValue
|
||||
)
|
||||
|
||||
if (existsInInputs || existsInOutputs) return undefined
|
||||
|
||||
const isOutput = currentValue.endsWith(' [output]')
|
||||
const strippedValue = isOutput
|
||||
? currentValue.replace(' [output]', '')
|
||||
: currentValue
|
||||
|
||||
return {
|
||||
id: `missing-${currentValue}`,
|
||||
preview_url: getMediaUrl(
|
||||
strippedValue,
|
||||
isOutput ? 'output' : 'input',
|
||||
kind
|
||||
),
|
||||
name: currentValue,
|
||||
label: getDisplayLabel(currentValue, labelFn)
|
||||
}
|
||||
})
|
||||
|
||||
const assetItems = computed<FormDropdownItem[]>(() => {
|
||||
if (!toValue(options.isAssetMode) || !assetData) return []
|
||||
return assetData.assets.value.map((asset) => ({
|
||||
id: asset.id,
|
||||
name: getAssetFilename(asset),
|
||||
label: getAssetDisplayName(asset),
|
||||
preview_url: asset.preview_url,
|
||||
is_immutable: asset.is_immutable,
|
||||
base_models: getAssetBaseModels(asset)
|
||||
}))
|
||||
})
|
||||
|
||||
const filteredAssetItems = computed<FormDropdownItem[]>(() =>
|
||||
filterItemByBaseModels(
|
||||
filterItemByOwnership(assetItems.value, ownershipSelected.value),
|
||||
baseModelSelected.value
|
||||
)
|
||||
)
|
||||
|
||||
const allItems = computed<FormDropdownItem[]>(() => {
|
||||
if (toValue(options.isAssetMode) && assetData) {
|
||||
return filteredAssetItems.value
|
||||
}
|
||||
return [
|
||||
...(missingValueItem.value ? [missingValueItem.value] : []),
|
||||
...inputItems.value,
|
||||
...outputItems.value
|
||||
]
|
||||
})
|
||||
|
||||
const dropdownItems = computed<FormDropdownItem[]>(() => {
|
||||
if (toValue(options.isAssetMode)) {
|
||||
return allItems.value
|
||||
}
|
||||
|
||||
switch (filterSelected.value) {
|
||||
case 'inputs':
|
||||
return inputItems.value
|
||||
case 'outputs':
|
||||
return outputItems.value
|
||||
case 'all':
|
||||
default:
|
||||
return allItems.value
|
||||
}
|
||||
})
|
||||
|
||||
const displayItems = computed<FormDropdownItem[]>(() => {
|
||||
if (toValue(options.isAssetMode) && assetData && missingValueItem.value) {
|
||||
return [missingValueItem.value, ...filteredAssetItems.value]
|
||||
}
|
||||
return dropdownItems.value
|
||||
})
|
||||
|
||||
const selectedSet = computed<Set<string>>(() => {
|
||||
const currentValue = modelValue.value
|
||||
if (currentValue === undefined) return new Set()
|
||||
|
||||
const item = displayItems.value.find((item) => item.name === currentValue)
|
||||
return item ? new Set([item.id]) : new Set()
|
||||
})
|
||||
|
||||
return {
|
||||
dropdownItems,
|
||||
displayItems,
|
||||
filterSelected,
|
||||
filterOptions,
|
||||
ownershipSelected,
|
||||
showOwnershipFilter,
|
||||
ownershipOptions,
|
||||
baseModelSelected,
|
||||
showBaseModelFilter,
|
||||
baseModelOptions,
|
||||
selectedSet
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user