Compare commits

...

24 Commits

Author SHA1 Message Date
Jin Yi
66b66d21a6 Merge branch 'main' into fix/dropdown-position 2026-03-27 14:05:13 +09:00
Jin Yi
41e426fe7e fix: clamp teleported dropdown position to viewport bounds
When neither upward nor downward direction has enough space for the
full menu height, clamp the position so the menu stays within the
viewport instead of overflowing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 13:43:13 +09:00
Kelly Yang
47c9a027a7 fix: use try/finally for loading state in TeamWorkspacesDialogContent… (#10601)
… onCreate

## Summary
Wrap the function body in try/finally in the
`src/platform/workspace/components/dialogs/TeamWorkspacesDialogContent.vue`
to avoid staying in a permanent loading state if an unexpected error
happens.

Fix #10458 

```
async function onCreate() {
  if (!isValidName.value || loading.value) return
  loading.value = true

  try {
    const name = workspaceName.value.trim()
    try {
      await workspaceStore.createWorkspace(name)
    } catch (error) {
      toast.add({
        severity: 'error',
        summary: t('workspacePanel.toast.failedToCreateWorkspace'),
        detail: error instanceof Error ? error.message : t('g.unknownError')
      })
      return
    }
    try {
      await onConfirm?.(name)
    } catch (error) {
      toast.add({
        severity: 'error',
        summary: t('teamWorkspacesDialog.confirmCallbackFailed'),
        detail: error instanceof Error ? error.message : t('g.unknownError')
      })
    }
    dialogStore.closeDialog({ key: DIALOG_KEY })
  } finally {
    loading.value = false
  }
}
```

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10601-fix-use-try-finally-for-loading-state-in-TeamWorkspacesDialogContent-3306d73d365081dcb97bf205d7be9ca7)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 21:38:11 -07:00
Christian Byrne
6fbc5723bd fix: hide inaccurate resolution subtitle on cloud asset cards (#10602)
## Summary

Hide image resolution subtitle on cloud asset cards because thumbnails
are downscaled to max 512px, causing `naturalWidth`/`naturalHeight` to
report incorrect dimensions.

## Changes

- **What**: Gate the dimension display in `MediaAssetCard.vue` behind
`!isCloud` so resolution is only shown on local (where full-res images
are loaded). Added TODO referencing #10590 for re-enabling once
`/assets` API returns original dimensions in metadata.

## Review Focus

One-line conditional change — the `isCloud` import from
`@/platform/distribution/types` follows the established pattern used
across the repo.

Fixes #10590

## Screenshots (if applicable)

N/A — this removes a subtitle that was displaying wrong values (e.g.,
showing 512x512 for a 1024x1024 image on cloud).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10602-fix-hide-inaccurate-resolution-subtitle-on-cloud-asset-cards-3306d73d36508186bd3ad704bd83bf14)
by [Unito](https://www.unito.io)
2026-03-27 13:32:11 +09:00
Jin Yi
530cef855b Merge branch 'main' into fix/dropdown-position 2026-03-27 13:20:57 +09:00
Benjamin Lu
db6e5062f2 test: add assets sidebar empty-state coverage (#10595)
## Summary

Add the first user-centric Playwright coverage for the assets sidebar
empty state and introduce a small assets-specific test helper/page
object surface.

## Changes

- **What**: add `AssetsSidebarTab`, add `AssetsHelper`, and cover
generated/imported empty states in a dedicated browser spec

## Review Focus

This is intentionally a small first slice for assets-sidebar coverage.
The new helper still mocks the HTTP boundary in Playwright for now
because current OSS job history and input files are global backend
state, which makes true backend-seeded parallel coverage a separate
backend change.

Long-term recommendation: add backend-owned, user-scoped test seeding
for jobs/history and input assets so browser tests can hit the real
routes on a shared backend. Follow-up: COM-307.

Fixes COM-306

## Screenshots (if applicable)

Not applicable.

## Validation

- `pnpm typecheck:browser`
- `pnpm exec playwright test browser_tests/tests/sidebar/assets.spec.ts
--project=chromium` against an isolated preview env

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10595-test-add-assets-sidebar-empty-state-coverage-3306d73d365081d1b34fdd146ae6c5c6)
by [Unito](https://www.unito.io)
2026-03-26 21:19:38 -07:00
Terry Jia
6da5d26980 test: add painter widget e2e tests (#10599)
## Summary
add painter widget e2e tests

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10599-test-add-painter-widget-e2e-tests-3306d73d365081899b3ec3e1d7c6f57c)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-03-27 00:19:17 -04:00
Johnpaul Chiwetelu
9b6b762a97 test: add browser tests for zoom controls (#10589)
## Summary
- Add E2E Playwright tests for zoom controls: default zoom level, zoom
to fit, zoom out with clamping at 10% minimum, manual percentage input,
and toggle visibility
- Add `data-testid` attributes to `ZoomControlsModal.vue` for stable
test selectors
- Add new TestId entries to `selectors.ts`

## Test plan
- [x] All 6 new tests pass locally
- [x] Existing minimap and graphCanvasMenu tests still pass
- [ ] CI passes

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10589-test-add-browser-tests-for-zoom-controls-3306d73d36508177ae19e16b3f62b8e7)
by [Unito](https://www.unito.io)
2026-03-27 05:18:46 +01:00
Jin Yi
66e42f038b fix: prevent teleported dropdown from overflowing viewport top
Amp-Thread-ID: https://ampcode.com/threads/T-019d2d64-af34-7489-abd5-cde23ead7105
Co-authored-by: Amp <amp@ampcode.com>
2026-03-27 13:13:38 +09:00
Kelly Yang
00c8c11288 fix: derive payment redirect URLs from getComfyPlatformBaseUrl() (#10600)
Replaces hardcoded `https://www.comfy.org/payment/...` URLs with
`getComfyPlatformBaseUrl()` so staging deploys no longer redirect users
to production after payment.

fix #10456

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10600-fix-derive-payment-redirect-URLs-from-getComfyPlatformBaseUrl-3306d73d365081679ef4da840337bb81)
by [Unito](https://www.unito.io)
2026-03-26 20:51:07 -07:00
Jin Yi
2077fa76e7 fix: extract MENU_HEIGHT/MENU_WIDTH as shared constants, drop computed for shouldTeleport
Amp-Thread-ID: https://ampcode.com/threads/T-019d2d37-f1a3-7421-90b9-b4d8d058bedb
Co-authored-by: Amp <amp@ampcode.com>
2026-03-27 12:10:54 +09:00
Comfy Org PR Bot
668f7e48e7 1.43.7 (#10583)
Patch version increment to 1.43.7

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10583-1-43-7-3306d73d3650810f921bf97fc447e402)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2026-03-26 19:56:32 -07:00
Jin Yi
e5bc943487 Merge branch 'main' into fix/dropdown-position 2026-03-27 11:55:14 +09:00
Alexander Brown
3de387429a test: migrate 11 interactive component tests from VTU to VTL (Phase 2) (#10490)
## Summary

Phase 2 of the VTL migration: migrate 11 interactive component tests
from @vue/test-utils to @testing-library/vue (69 tests).

Stacked on #10471.

## Changes

- **What**: Migrate BatchCountEdit, BypassButton, BuilderFooterToolbar,
ComfyActionbar, SidebarIcon, EditableText, UrlInput, SearchInput,
TagsInput, TreeExplorerTreeNode, ColorCustomizationSelector from VTU to
VTL
- **Pattern transforms**: `trigger('click')` → `userEvent.click()`,
`setValue()` → `userEvent.type()`, `findComponent().props()` →
`getByRole/getByText/getByTestId`, `emitted()` → callback props
- **Removed**: 4 `@ts-expect-error` annotations, 1 change-detector test
(SearchInput `vm.focus`)
- **PrimeVue**: `data-pc-name` selectors + `aria-pressed` for
SelectButton, container queries for ColorPicker/InputIcon

## Review Focus

- PrimeVue escape hatches in ColorCustomizationSelector
(SelectButton/ColorPicker lack standard ARIA roles)
- Teleport test in ComfyActionbar uses `container.querySelector`
intentionally (scoped to teleport target)
- SearchInput debounce tests use `fireEvent.update` instead of
`userEvent.type` due to fake timer interaction
- EditableText escape-then-blur test simplified:
`userEvent.keyboard('{Escape}')` already triggers blur internally

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10490-test-migrate-11-interactive-component-tests-from-VTU-to-VTL-Phase-2-32e6d73d3650817ca40fd61395499e3f)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-03-26 19:46:31 -07:00
Jin Yi
b96b56d771 fix: flip teleported dropdown upward when near viewport bottom
Apply the same openUpward logic for both teleported and local cases.
When teleported, use bottom CSS property to open upward.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 14:50:30 +09:00
Jin Yi
8894119dc9 fix: teleport FormDropdown to body in app mode with bottom-right positioning
Inject OverlayAppendToKey to detect app mode vs canvas. In app mode,
use Teleport to body with position:fixed at the trigger's bottom-right
corner, clamped to viewport bounds. In canvas, keep local absolute
positioning for correct zoom scaling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 14:37:41 +09:00
Jin Yi
d2345fc7eb Revert "fix: restore teleport for FormDropdown in app mode"
This reverts commit 8a88e40c40.
2026-03-25 14:08:26 +09:00
Jin Yi
8a88e40c40 fix: restore teleport for FormDropdown in app mode
Inject OverlayAppendToKey to detect app mode ('body') vs canvas
('self'). In app mode, use <Teleport to="body"> with position:fixed
to escape overflow-hidden/overflow-y-auto ancestors. In canvas, keep
local absolute positioning for correct zoom scaling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 14:03:26 +09:00
Jin Yi
0def631c52 fix: prefer direction with more available space for dropdown
Compare space above vs below the trigger and open toward whichever
side has more room. Prevents flipping upward when the menu would
overflow even more in that direction.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 13:43:45 +09:00
Jin Yi
7b5a49975f fix: flip dropdown upward when near viewport bottom
Use getBoundingClientRect() only for direction detection (not
positioning), so it works safely even inside CSS transform chains.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 13:33:48 +09:00
Jin Yi
3d0389ac5b fix: stabilize E2E tests for FormDropdown positioning
- Replace fragile CSS selectors with data-testid for trigger button
- Update appModeDropdownClipping to use getByTestId after Popover removal
- Change zoom test from 0.5 to 0.75 to avoid too-small click targets

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 13:22:05 +09:00
Alexander Brown
049657b38f Merge branch 'main' into fix/dropdown-position 2026-03-24 19:53:12 -07:00
Jin Yi
b5bae1f721 test: add Playwright tests for FormDropdown positioning
Amp-Thread-ID: https://ampcode.com/threads/T-019d2285-317d-75db-b838-15f7d9b55b3c
Co-authored-by: Amp <amp@ampcode.com>
2026-03-25 11:16:28 +09:00
Jin Yi
59f4ed8232 fix: formdropdown position 2026-03-25 10:57:12 +09:00
34 changed files with 1274 additions and 559 deletions

View File

@@ -0,0 +1,48 @@
{
"last_node_id": 1,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "Painter",
"pos": [50, 50],
"size": [450, 550],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "Painter"
},
"widgets_values": ["", 512, 512, "#000000"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

View File

@@ -19,10 +19,12 @@ import { ContextMenu } from './components/ContextMenu'
import { SettingDialog } from './components/SettingDialog'
import { BottomPanel } from './components/BottomPanel'
import {
AssetsSidebarTab,
NodeLibrarySidebarTab,
WorkflowsSidebarTab
} from './components/SidebarTab'
import { Topbar } from './components/Topbar'
import { AssetsHelper } from './helpers/AssetsHelper'
import { CanvasHelper } from './helpers/CanvasHelper'
import { PerformanceHelper } from './helpers/PerformanceHelper'
import { QueueHelper } from './helpers/QueueHelper'
@@ -55,6 +57,7 @@ class ComfyPropertiesPanel {
}
class ComfyMenu {
private _assetsTab: AssetsSidebarTab | null = null
private _nodeLibraryTab: NodeLibrarySidebarTab | null = null
private _workflowsTab: WorkflowsSidebarTab | null = null
private _topbar: Topbar | null = null
@@ -78,6 +81,11 @@ class ComfyMenu {
return this._nodeLibraryTab
}
get assetsTab() {
this._assetsTab ??= new AssetsSidebarTab(this.page)
return this._assetsTab
}
get workflowsTab() {
this._workflowsTab ??= new WorkflowsSidebarTab(this.page)
return this._workflowsTab
@@ -192,6 +200,7 @@ export class ComfyPage {
public readonly command: CommandHelper
public readonly bottomPanel: BottomPanel
public readonly perf: PerformanceHelper
public readonly assets: AssetsHelper
public readonly queue: QueueHelper
/** Worker index to test user ID */
@@ -238,6 +247,7 @@ export class ComfyPage {
this.command = new CommandHelper(page)
this.bottomPanel = new BottomPanel(page)
this.perf = new PerformanceHelper(page)
this.assets = new AssetsHelper(page)
this.queue = new QueueHelper(page)
}

View File

@@ -168,3 +168,32 @@ export class WorkflowsSidebarTab extends SidebarTab {
.click()
}
}
export class AssetsSidebarTab extends SidebarTab {
constructor(public override readonly page: Page) {
super(page, 'assets')
}
get generatedTab() {
return this.page.getByRole('tab', { name: 'Generated' })
}
get importedTab() {
return this.page.getByRole('tab', { name: 'Imported' })
}
get emptyStateMessage() {
return this.page.getByText(
'Upload files or generate content to see them here'
)
}
emptyStateTitle(title: string) {
return this.page.getByText(title)
}
override async open() {
await super.open()
await this.generatedTab.waitFor({ state: 'visible' })
}
}

View File

@@ -0,0 +1,147 @@
import type { Page, Route } from '@playwright/test'
import type { RawJobListItem } from '../../../src/platform/remote/comfyui/jobs/jobTypes'
const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
const inputFilesRoutePattern = /\/internal\/files\/input(?:\?.*)?$/
function parseLimit(url: URL, total: number): number {
const value = Number(url.searchParams.get('limit'))
if (!Number.isInteger(value) || value <= 0) {
return total
}
return value
}
function parseOffset(url: URL): number {
const value = Number(url.searchParams.get('offset'))
if (!Number.isInteger(value) || value < 0) {
return 0
}
return value
}
function getExecutionDuration(job: RawJobListItem): number {
const start = job.execution_start_time ?? 0
const end = job.execution_end_time ?? 0
return end - start
}
export class AssetsHelper {
private jobsRouteHandler: ((route: Route) => Promise<void>) | null = null
private inputFilesRouteHandler: ((route: Route) => Promise<void>) | null =
null
private generatedJobs: RawJobListItem[] = []
private importedFiles: string[] = []
constructor(private readonly page: Page) {}
async mockOutputHistory(jobs: RawJobListItem[]): Promise<void> {
this.generatedJobs = [...jobs]
if (this.jobsRouteHandler) {
return
}
this.jobsRouteHandler = async (route: Route) => {
const url = new URL(route.request().url())
const statuses = url.searchParams
.get('status')
?.split(',')
.map((status) => status.trim())
.filter(Boolean)
const workflowId = url.searchParams.get('workflow_id')
const sortBy = url.searchParams.get('sort_by')
const sortOrder = url.searchParams.get('sort_order') === 'asc' ? 1 : -1
let filteredJobs = [...this.generatedJobs]
if (statuses?.length) {
filteredJobs = filteredJobs.filter((job) =>
statuses.includes(job.status)
)
}
if (workflowId) {
filteredJobs = filteredJobs.filter(
(job) => job.workflow_id === workflowId
)
}
filteredJobs.sort((left, right) => {
const leftValue =
sortBy === 'execution_duration'
? getExecutionDuration(left)
: left.create_time
const rightValue =
sortBy === 'execution_duration'
? getExecutionDuration(right)
: right.create_time
return (leftValue - rightValue) * sortOrder
})
const offset = parseOffset(url)
const total = filteredJobs.length
const limit = parseLimit(url, total)
const visibleJobs = filteredJobs.slice(offset, offset + limit)
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
jobs: visibleJobs,
pagination: {
offset,
limit,
total,
has_more: offset + visibleJobs.length < total
}
})
})
}
await this.page.route(jobsListRoutePattern, this.jobsRouteHandler)
}
async mockInputFiles(files: string[]): Promise<void> {
this.importedFiles = [...files]
if (this.inputFilesRouteHandler) {
return
}
this.inputFilesRouteHandler = async (route: Route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(this.importedFiles)
})
}
await this.page.route(inputFilesRoutePattern, this.inputFilesRouteHandler)
}
async mockEmptyState(): Promise<void> {
await this.mockOutputHistory([])
await this.mockInputFiles([])
}
async clearMocks(): Promise<void> {
this.generatedJobs = []
this.importedFiles = []
if (this.jobsRouteHandler) {
await this.page.unroute(jobsListRoutePattern, this.jobsRouteHandler)
this.jobsRouteHandler = null
}
if (this.inputFilesRouteHandler) {
await this.page.unroute(
inputFilesRoutePattern,
this.inputFilesRouteHandler
)
this.inputFilesRouteHandler = null
}
}
}

View File

@@ -20,7 +20,12 @@ export const TestIds = {
main: 'graph-canvas',
contextMenu: 'canvas-context-menu',
toggleMinimapButton: 'toggle-minimap-button',
toggleLinkVisibilityButton: 'toggle-link-visibility-button'
toggleLinkVisibilityButton: 'toggle-link-visibility-button',
zoomControlsButton: 'zoom-controls-button',
zoomInAction: 'zoom-in-action',
zoomOutAction: 'zoom-out-action',
zoomToFitAction: 'zoom-to-fit-action',
zoomPercentageInput: 'zoom-percentage-input'
},
dialogs: {
settings: 'settings-dialog',
@@ -69,7 +74,9 @@ export const TestIds = {
decrement: 'decrement',
increment: 'increment',
domWidgetTextarea: 'dom-widget-textarea',
subgraphEnterButton: 'subgraph-enter-button'
subgraphEnterButton: 'subgraph-enter-button',
formDropdownMenu: 'form-dropdown-menu',
formDropdownTrigger: 'form-dropdown-trigger'
},
builder: {
ioItem: 'builder-io-item',

View File

@@ -4,6 +4,7 @@ import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
/**
* Default workflow widget inputs as [nodeId, widgetName] tuples.
@@ -143,15 +144,12 @@ test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
const dropdownButton = imageRow.locator('button:has(> span)').first()
await dropdownButton.click()
// The unstyled PrimeVue Popover renders with role="dialog".
// Locate the one containing the image grid (filter buttons like "All", "Inputs").
const popover = comfyPage.page
.getByRole('dialog')
.filter({ has: comfyPage.page.getByRole('button', { name: 'All' }) })
const menu = comfyPage.page
.getByTestId(TestIds.widgets.formDropdownMenu)
.first()
await expect(popover).toBeVisible({ timeout: 5000 })
await expect(menu).toBeVisible({ timeout: 5000 })
const isInViewport = await popover.evaluate((el) => {
const isInViewport = await menu.evaluate((el) => {
const rect = el.getBoundingClientRect()
return (
rect.top >= 0 &&
@@ -162,7 +160,7 @@ test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
})
expect(isInViewport).toBe(true)
const isClipped = await popover.evaluate(isClippedByAnyAncestor)
const isClipped = await menu.evaluate(isClippedByAnyAncestor)
expect(isClipped).toBe(false)
})
})

View File

@@ -0,0 +1,92 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Painter', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow('widgets/painter_widget')
await comfyPage.vueNodes.waitForNodes()
})
test(
'Renders canvas and controls',
{ tag: ['@smoke', '@screenshot'] },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node).toBeVisible()
const painterWidget = node.locator('.widget-expands')
await expect(painterWidget).toBeVisible()
await expect(painterWidget.locator('canvas')).toBeVisible()
await expect(painterWidget.getByText('Brush')).toBeVisible()
await expect(painterWidget.getByText('Eraser')).toBeVisible()
await expect(painterWidget.getByText('Clear')).toBeVisible()
await expect(
painterWidget.locator('input[type="color"]').first()
).toBeVisible()
await expect(node).toHaveScreenshot('painter-default-state.png')
}
)
test(
'Drawing a stroke changes the canvas',
{ tag: ['@smoke', '@screenshot'] },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
const canvas = node.locator('.widget-expands canvas')
await expect(canvas).toBeVisible()
const isEmptyBefore = await canvas.evaluate((el) => {
const ctx = (el as HTMLCanvasElement).getContext('2d')
if (!ctx) return true
const data = ctx.getImageData(
0,
0,
(el as HTMLCanvasElement).width,
(el as HTMLCanvasElement).height
)
return data.data.every((v, i) => (i % 4 === 3 ? v === 0 : true))
})
expect(isEmptyBefore).toBe(true)
const box = await canvas.boundingBox()
if (!box) throw new Error('Canvas bounding box not found')
await comfyPage.page.mouse.move(
box.x + box.width * 0.3,
box.y + box.height * 0.5
)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(
box.x + box.width * 0.7,
box.y + box.height * 0.5,
{ steps: 10 }
)
await comfyPage.page.mouse.up()
await comfyPage.nextFrame()
await expect(async () => {
const hasContent = await canvas.evaluate((el) => {
const ctx = (el as HTMLCanvasElement).getContext('2d')
if (!ctx) return false
const data = ctx.getImageData(
0,
0,
(el as HTMLCanvasElement).width,
(el as HTMLCanvasElement).height
)
for (let i = 3; i < data.data.length; i += 4) {
if (data.data[i] > 0) return true
}
return false
})
expect(hasContent).toBe(true)
}).toPass()
await expect(node).toHaveScreenshot('painter-after-stroke.png')
}
)
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -0,0 +1,30 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
test.describe('Assets sidebar', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockEmptyState()
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Shows empty-state copy for generated and imported tabs', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await expect(tab.emptyStateTitle('No generated files found')).toBeVisible()
await expect(tab.emptyStateMessage).toBeVisible()
await tab.importedTab.click()
await expect(tab.emptyStateTitle('No imported files found')).toBeVisible()
await expect(tab.emptyStateMessage).toBeVisible()
})
})

View File

@@ -0,0 +1,116 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../../fixtures/ComfyPage'
import { TestIds } from '../../../../fixtures/selectors'
test.describe(
'FormDropdown positioning in Vue nodes',
{ tag: ['@widget', '@node'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
await comfyPage.vueNodes.waitForNodes()
})
test('dropdown menu appears directly below the trigger', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeByTitle('Load Image')
await expect(node).toBeVisible()
const trigger = node.getByTestId(TestIds.widgets.formDropdownTrigger)
await trigger.first().click()
const menu = comfyPage.page.getByTestId(TestIds.widgets.formDropdownMenu)
await expect(menu).toBeVisible({ timeout: 5000 })
const triggerBox = await trigger.first().boundingBox()
const menuBox = await menu.boundingBox()
expect(triggerBox).toBeTruthy()
expect(menuBox).toBeTruthy()
// Menu top should be near the trigger bottom (within 20px tolerance for padding)
expect(menuBox!.y).toBeGreaterThanOrEqual(
triggerBox!.y + triggerBox!.height - 5
)
expect(menuBox!.y).toBeLessThanOrEqual(
triggerBox!.y + triggerBox!.height + 20
)
// Menu left should be near the trigger left (within 10px tolerance)
expect(menuBox!.x).toBeGreaterThanOrEqual(triggerBox!.x - 10)
expect(menuBox!.x).toBeLessThanOrEqual(triggerBox!.x + 10)
})
test('dropdown menu appears correctly at different zoom levels', async ({
comfyPage
}) => {
for (const zoom of [0.75, 1.5]) {
// Set zoom via canvas
await comfyPage.page.evaluate((scale) => {
const canvas = window.app!.canvas
canvas.ds.scale = scale
canvas.setDirty(true, true)
}, zoom)
await comfyPage.nextFrame()
const node = comfyPage.vueNodes.getNodeByTitle('Load Image')
await expect(node).toBeVisible()
const trigger = node.getByTestId(TestIds.widgets.formDropdownTrigger)
await trigger.first().click()
const menu = comfyPage.page.getByTestId(
TestIds.widgets.formDropdownMenu
)
await expect(menu).toBeVisible({ timeout: 5000 })
const triggerBox = await trigger.first().boundingBox()
const menuBox = await menu.boundingBox()
expect(triggerBox).toBeTruthy()
expect(menuBox).toBeTruthy()
// Menu top should still be near trigger bottom regardless of zoom
expect(menuBox!.y).toBeGreaterThanOrEqual(
triggerBox!.y + triggerBox!.height - 5
)
expect(menuBox!.y).toBeLessThanOrEqual(
triggerBox!.y + triggerBox!.height + 20 * zoom
)
// Close dropdown before next iteration
await comfyPage.page.keyboard.press('Escape')
await expect(menu).not.toBeVisible()
}
})
test('dropdown closes on outside click', async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeByTitle('Load Image')
const trigger = node.getByTestId(TestIds.widgets.formDropdownTrigger)
await trigger.first().click()
const menu = comfyPage.page.getByTestId(TestIds.widgets.formDropdownMenu)
await expect(menu).toBeVisible({ timeout: 5000 })
// Click outside the node
await comfyPage.page.mouse.click(10, 10)
await expect(menu).not.toBeVisible()
})
test('dropdown closes on Escape key', async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeByTitle('Load Image')
const trigger = node.getByTestId(TestIds.widgets.formDropdownTrigger)
await trigger.first().click()
const menu = comfyPage.page.getByTestId(TestIds.widgets.formDropdownMenu)
await expect(menu).toBeVisible({ timeout: 5000 })
await comfyPage.page.keyboard.press('Escape')
await expect(menu).not.toBeVisible()
})
}
)

View File

@@ -0,0 +1,138 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
test.describe('Zoom Controls', { tag: '@canvas' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Graph.CanvasMenu', true)
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.page.waitForFunction(() => window.app && window.app.canvas)
})
test('Default zoom is 100% and node has a size', async ({ comfyPage }) => {
const nodeSize = await comfyPage.page.evaluate(
() => window.app!.graph.nodes[0].size
)
expect(nodeSize[0]).toBeGreaterThan(0)
expect(nodeSize[1]).toBeGreaterThan(0)
const zoomButton = comfyPage.page.getByTestId(
TestIds.canvas.zoomControlsButton
)
await expect(zoomButton).toContainText('100%')
const scale = await comfyPage.canvasOps.getScale()
expect(scale).toBeCloseTo(1.0, 1)
})
test('Zoom to fit reduces percentage', async ({ comfyPage }) => {
const zoomButton = comfyPage.page.getByTestId(
TestIds.canvas.zoomControlsButton
)
await zoomButton.click()
await comfyPage.nextFrame()
const zoomToFit = comfyPage.page.getByTestId(TestIds.canvas.zoomToFitAction)
await expect(zoomToFit).toBeVisible()
await zoomToFit.click()
await expect
.poll(() => comfyPage.canvasOps.getScale(), { timeout: 2000 })
.toBeLessThan(1.0)
await expect(zoomButton).not.toContainText('100%')
})
test('Zoom out reduces percentage', async ({ comfyPage }) => {
const initialScale = await comfyPage.canvasOps.getScale()
const zoomButton = comfyPage.page.getByTestId(
TestIds.canvas.zoomControlsButton
)
await zoomButton.click()
await comfyPage.nextFrame()
const zoomOut = comfyPage.page.getByTestId(TestIds.canvas.zoomOutAction)
await zoomOut.click()
await comfyPage.nextFrame()
const newScale = await comfyPage.canvasOps.getScale()
expect(newScale).toBeLessThan(initialScale)
})
test('Zoom out clamps at 10% minimum', async ({ comfyPage }) => {
const zoomButton = comfyPage.page.getByTestId(
TestIds.canvas.zoomControlsButton
)
await zoomButton.click()
await comfyPage.nextFrame()
const zoomOut = comfyPage.page.getByTestId(TestIds.canvas.zoomOutAction)
for (let i = 0; i < 30; i++) {
await zoomOut.click()
}
await comfyPage.nextFrame()
await expect
.poll(() => comfyPage.canvasOps.getScale(), { timeout: 2000 })
.toBeCloseTo(0.1, 1)
await expect(zoomButton).toContainText('10%')
})
test('Manual percentage entry allows zoom in and zoom out', async ({
comfyPage
}) => {
const zoomButton = comfyPage.page.getByTestId(
TestIds.canvas.zoomControlsButton
)
await zoomButton.click()
await comfyPage.nextFrame()
const input = comfyPage.page
.getByTestId(TestIds.canvas.zoomPercentageInput)
.locator('input')
await input.focus()
await comfyPage.page.keyboard.press('Control+a')
await input.pressSequentially('100')
await input.press('Enter')
await comfyPage.nextFrame()
await expect
.poll(() => comfyPage.canvasOps.getScale(), { timeout: 2000 })
.toBeCloseTo(1.0, 1)
const zoomIn = comfyPage.page.getByTestId(TestIds.canvas.zoomInAction)
await zoomIn.click()
await comfyPage.nextFrame()
const scaleAfterZoomIn = await comfyPage.canvasOps.getScale()
expect(scaleAfterZoomIn).toBeGreaterThan(1.0)
const zoomOut = comfyPage.page.getByTestId(TestIds.canvas.zoomOutAction)
await zoomOut.click()
await comfyPage.nextFrame()
const scaleAfterZoomOut = await comfyPage.canvasOps.getScale()
expect(scaleAfterZoomOut).toBeLessThan(scaleAfterZoomIn)
})
test('Clicking zoom button toggles zoom controls visibility', async ({
comfyPage
}) => {
const zoomButton = comfyPage.page.getByTestId(
TestIds.canvas.zoomControlsButton
)
await zoomButton.click()
await comfyPage.nextFrame()
const zoomToFit = comfyPage.page.getByTestId(TestIds.canvas.zoomToFitAction)
await expect(zoomToFit).toBeVisible()
await zoomButton.click()
await comfyPage.nextFrame()
await expect(zoomToFit).not.toBeVisible()
})
})

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.43.6",
"version": "1.43.7",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",

View File

@@ -1,7 +1,7 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import { useQueueSettingsStore } from '@/stores/queueStore'
@@ -33,7 +33,7 @@ const i18n = createI18n({
}
})
function createWrapper(initialBatchCount = 1) {
function renderComponent(initialBatchCount = 1) {
const pinia = createTestingPinia({
createSpy: vi.fn,
stubActions: false,
@@ -44,7 +44,9 @@ function createWrapper(initialBatchCount = 1) {
}
})
const wrapper = mount(BatchCountEdit, {
const user = userEvent.setup()
render(BatchCountEdit, {
global: {
plugins: [pinia, i18n],
directives: {
@@ -55,44 +57,42 @@ function createWrapper(initialBatchCount = 1) {
const queueSettingsStore = useQueueSettingsStore()
return { wrapper, queueSettingsStore }
return { user, queueSettingsStore }
}
describe('BatchCountEdit', () => {
it('doubles the current batch count when increment is clicked', async () => {
const { wrapper, queueSettingsStore } = createWrapper(3)
const { user, queueSettingsStore } = renderComponent(3)
await wrapper.get('button[aria-label="Increment"]').trigger('click')
await user.click(screen.getByRole('button', { name: 'Increment' }))
expect(queueSettingsStore.batchCount).toBe(6)
})
it('halves the current batch count when decrement is clicked', async () => {
const { wrapper, queueSettingsStore } = createWrapper(9)
const { user, queueSettingsStore } = renderComponent(9)
await wrapper.get('button[aria-label="Decrement"]').trigger('click')
await user.click(screen.getByRole('button', { name: 'Decrement' }))
expect(queueSettingsStore.batchCount).toBe(4)
})
it('clamps typed values to queue limits on blur', async () => {
const { wrapper, queueSettingsStore } = createWrapper(2)
const input = wrapper.get('input')
const { user, queueSettingsStore } = renderComponent(2)
const input = screen.getByRole('textbox', { name: 'Batch Count' })
await input.setValue('999')
await input.trigger('blur')
await nextTick()
await user.clear(input)
await user.type(input, '999')
await user.tab()
expect(queueSettingsStore.batchCount).toBe(maxBatchCount)
expect((input.element as HTMLInputElement).value).toBe(
String(maxBatchCount)
)
expect(input).toHaveValue(String(maxBatchCount))
await input.setValue('0')
await input.trigger('blur')
await nextTick()
await user.clear(input)
await user.type(input, '0')
await user.tab()
expect(queueSettingsStore.batchCount).toBe(1)
expect((input.element as HTMLInputElement).value).toBe('1')
expect(input).toHaveValue('1')
})
})

View File

@@ -1,5 +1,5 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { render } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
@@ -20,15 +20,15 @@ const configureSettings = (
})
}
const mountActionbar = (showRunProgressBar: boolean) => {
const renderActionbar = (showRunProgressBar: boolean) => {
const topMenuContainer = document.createElement('div')
document.body.appendChild(topMenuContainer)
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, showRunProgressBar)
const wrapper = mount(ComfyActionbar, {
attachTo: document.body,
render(ComfyActionbar, {
container: document.body.appendChild(document.createElement('div')),
props: {
topMenuContainer,
queueOverlayExpanded: false
@@ -57,10 +57,7 @@ const mountActionbar = (showRunProgressBar: boolean) => {
}
})
return {
wrapper,
topMenuContainer
}
return { topMenuContainer }
}
describe('ComfyActionbar', () => {
@@ -70,31 +67,33 @@ describe('ComfyActionbar', () => {
})
it('teleports inline progress when run progress bar is enabled', async () => {
const { wrapper, topMenuContainer } = mountActionbar(true)
const { topMenuContainer } = renderActionbar(true)
try {
await nextTick()
/* eslint-disable testing-library/no-node-access -- Teleport target verification requires scoping to the container element */
expect(
topMenuContainer.querySelector('[data-testid="queue-inline-progress"]')
).not.toBeNull()
/* eslint-enable testing-library/no-node-access */
} finally {
wrapper.unmount()
topMenuContainer.remove()
}
})
it('does not teleport inline progress when run progress bar is disabled', async () => {
const { wrapper, topMenuContainer } = mountActionbar(false)
const { topMenuContainer } = renderActionbar(false)
try {
await nextTick()
/* eslint-disable testing-library/no-node-access -- Teleport target verification requires scoping to the container element */
expect(
topMenuContainer.querySelector('[data-testid="queue-inline-progress"]')
).toBeNull()
/* eslint-enable testing-library/no-node-access */
} finally {
wrapper.unmount()
topMenuContainer.remove()
}
})

View File

@@ -1,4 +1,5 @@
import { mount } from '@vue/test-utils'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, ref } from 'vue'
@@ -68,80 +69,75 @@ describe('BuilderFooterToolbar', () => {
mockState.settingView = false
})
function mountComponent() {
return mount(BuilderFooterToolbar, {
function renderComponent() {
const user = userEvent.setup()
render(BuilderFooterToolbar, {
global: {
plugins: [i18n],
stubs: { Button: false }
}
})
}
function getButtons(wrapper: ReturnType<typeof mountComponent>) {
const buttons = wrapper.findAll('button')
return {
exit: buttons[0],
back: buttons[1],
next: buttons[2]
}
return { user }
}
it('disables back on the first step', () => {
mockState.mode = 'builder:inputs'
const { back } = getButtons(mountComponent())
expect(back.attributes('disabled')).toBeDefined()
renderComponent()
expect(screen.getByRole('button', { name: /back/i })).toBeDisabled()
})
it('enables back on the second step', () => {
mockState.mode = 'builder:arrange'
const { back } = getButtons(mountComponent())
expect(back.attributes('disabled')).toBeUndefined()
renderComponent()
expect(screen.getByRole('button', { name: /back/i })).toBeEnabled()
})
it('disables next on the setDefaultView step', () => {
mockState.settingView = true
const { next } = getButtons(mountComponent())
expect(next.attributes('disabled')).toBeDefined()
renderComponent()
expect(screen.getByRole('button', { name: /next/i })).toBeDisabled()
})
it('disables next on arrange step when no outputs', () => {
mockState.mode = 'builder:arrange'
mockHasOutputs.value = false
const { next } = getButtons(mountComponent())
expect(next.attributes('disabled')).toBeDefined()
renderComponent()
expect(screen.getByRole('button', { name: /next/i })).toBeDisabled()
})
it('enables next on inputs step', () => {
mockState.mode = 'builder:inputs'
const { next } = getButtons(mountComponent())
expect(next.attributes('disabled')).toBeUndefined()
renderComponent()
expect(screen.getByRole('button', { name: /next/i })).toBeEnabled()
})
it('calls setMode on back click', async () => {
mockState.mode = 'builder:arrange'
const { back } = getButtons(mountComponent())
await back.trigger('click')
const { user } = renderComponent()
await user.click(screen.getByRole('button', { name: /back/i }))
expect(mockSetMode).toHaveBeenCalledWith('builder:outputs')
})
it('calls setMode on next click from inputs step', async () => {
mockState.mode = 'builder:inputs'
const { next } = getButtons(mountComponent())
await next.trigger('click')
const { user } = renderComponent()
await user.click(screen.getByRole('button', { name: /next/i }))
expect(mockSetMode).toHaveBeenCalledWith('builder:outputs')
})
it('opens default view dialog on next click from arrange step', async () => {
mockState.mode = 'builder:arrange'
const { next } = getButtons(mountComponent())
await next.trigger('click')
const { user } = renderComponent()
await user.click(screen.getByRole('button', { name: /next/i }))
expect(mockSetMode).toHaveBeenCalledWith('builder:arrange')
expect(mockShowDialog).toHaveBeenCalledOnce()
})
it('calls exitBuilder on exit button click', async () => {
const { exit } = getButtons(mountComponent())
await exit.trigger('click')
const { user } = renderComponent()
await user.click(screen.getByRole('button', { name: /exit app builder/i }))
expect(mockExitBuilder).toHaveBeenCalledOnce()
})
})

View File

@@ -1,8 +1,9 @@
import { mount } from '@vue/test-utils'
import { render } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import ColorPicker from 'primevue/colorpicker'
import PrimeVue from 'primevue/config'
import SelectButton from 'primevue/selectbutton'
import { beforeEach, describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, nextTick } from 'vue'
import ColorCustomizationSelector from './ColorCustomizationSelector.vue'
@@ -14,13 +15,17 @@ describe('ColorCustomizationSelector', () => {
]
beforeEach(() => {
// Setup PrimeVue
const app = createApp({})
app.use(PrimeVue)
})
const mountComponent = (props = {}) => {
return mount(ColorCustomizationSelector, {
function renderComponent(
props: Record<string, unknown> = {},
callbacks: { 'onUpdate:modelValue'?: (value: string | null) => void } = {}
) {
const user = userEvent.setup()
const result = render(ColorCustomizationSelector, {
global: {
plugins: [PrimeVue],
components: { SelectButton, ColorPicker }
@@ -28,102 +33,123 @@ describe('ColorCustomizationSelector', () => {
props: {
modelValue: null,
colorOptions,
...props
...props,
...callbacks
}
})
return { ...result, user }
}
/** PrimeVue SelectButton renders toggle buttons with aria-pressed */
function getToggleButtons(container: Element) {
return container.querySelectorAll<HTMLButtonElement>( // eslint-disable-line testing-library/no-node-access -- PrimeVue SelectButton renders toggle buttons without standard ARIA radiogroup roles
'[data-pc-name="pctogglebutton"]'
)
}
it('renders predefined color options and custom option', () => {
const wrapper = mountComponent()
const selectButton = wrapper.findComponent(SelectButton)
expect(selectButton.props('options')).toHaveLength(colorOptions.length + 1)
expect(selectButton.props('options')?.at(-1)?.name).toBe('_custom')
const { container } = renderComponent()
expect(getToggleButtons(container)).toHaveLength(colorOptions.length + 1)
})
it('initializes with predefined color when provided', async () => {
const wrapper = mountComponent({
modelValue: '#0d6efd'
})
const { container } = renderComponent({ modelValue: '#0d6efd' })
await nextTick()
const selectButton = wrapper.findComponent(SelectButton)
expect(selectButton.props('modelValue')).toEqual({
name: 'Blue',
value: '#0d6efd'
})
const buttons = getToggleButtons(container)
expect(buttons[0]).toHaveAttribute('aria-pressed', 'true')
})
it('initializes with custom color when non-predefined color provided', async () => {
const wrapper = mountComponent({
modelValue: '#123456'
})
const { container } = renderComponent({ modelValue: '#123456' })
await nextTick()
const selectButton = wrapper.findComponent(SelectButton)
const colorPicker = wrapper.findComponent(ColorPicker)
expect(selectButton.props('modelValue').name).toBe('_custom')
expect(colorPicker.props('modelValue')).toBe('123456')
const buttons = getToggleButtons(container)
const customButton = buttons[buttons.length - 1]
expect(customButton).toHaveAttribute('aria-pressed', 'true')
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- PrimeVue ColorPicker uses readonly input preview with no ARIA role
const colorPreview = container.querySelector(
'.p-colorpicker-preview'
) as HTMLInputElement | null
expect(colorPreview).not.toBeNull()
})
it('shows color picker when custom option is selected', async () => {
const wrapper = mountComponent()
const selectButton = wrapper.findComponent(SelectButton)
const { container, user } = renderComponent()
// Select custom option
await selectButton.setValue({ name: '_custom', value: '' })
const buttons = getToggleButtons(container)
await user.click(buttons[buttons.length - 1])
expect(wrapper.findComponent(ColorPicker).exists()).toBe(true)
expect(
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- PrimeVue ColorPicker internal DOM
container.querySelector('[data-pc-name="colorpicker"]')
).not.toBeNull()
})
it('emits update when predefined color is selected', async () => {
const wrapper = mountComponent()
const selectButton = wrapper.findComponent(SelectButton)
const onUpdate = vi.fn()
const { container, user } = renderComponent(
{},
{ 'onUpdate:modelValue': onUpdate }
)
await selectButton.setValue(colorOptions[0])
const buttons = getToggleButtons(container)
await user.click(buttons[0])
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['#0d6efd'])
expect(onUpdate).toHaveBeenCalledWith('#0d6efd')
})
it('emits update when custom color is changed', async () => {
const wrapper = mountComponent()
const selectButton = wrapper.findComponent(SelectButton)
const onUpdate = vi.fn()
const { container, user } = renderComponent(
{},
{ 'onUpdate:modelValue': onUpdate }
)
// Select custom option
await selectButton.setValue({ name: '_custom', value: '' })
// Custom is already selected by default (modelValue: null)
// Select Blue first, then switch to custom so onUpdate fires for Blue
const buttons = getToggleButtons(container)
await user.click(buttons[0]) // Select Blue
expect(onUpdate).toHaveBeenCalledWith('#0d6efd')
// Change custom color
const colorPicker = wrapper.findComponent(ColorPicker)
await colorPicker.setValue('ff0000')
onUpdate.mockClear()
await user.click(buttons[buttons.length - 1]) // Switch to custom
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['#ff0000'])
// When switching to custom, the custom color value inherits from Blue ('0d6efd')
// and the watcher on customColorValue emits the update
expect(onUpdate).toHaveBeenCalledWith('#0d6efd')
})
it('inherits color from previous selection when switching to custom', async () => {
const wrapper = mountComponent()
const selectButton = wrapper.findComponent(SelectButton)
const onUpdate = vi.fn()
const { container, user } = renderComponent(
{},
{ 'onUpdate:modelValue': onUpdate }
)
// First select a predefined color
await selectButton.setValue(colorOptions[0])
const buttons = getToggleButtons(container)
// Then switch to custom
await selectButton.setValue({ name: '_custom', value: '' })
// First select Blue
await user.click(buttons[0])
expect(onUpdate).toHaveBeenCalledWith('#0d6efd')
const colorPicker = wrapper.findComponent(ColorPicker)
expect(colorPicker.props('modelValue')).toBe('0d6efd')
onUpdate.mockClear()
// Then switch to custom — inherits the Blue color
await user.click(buttons[buttons.length - 1])
// The customColorValue watcher fires with the inherited Blue value
expect(onUpdate).toHaveBeenCalledWith('#0d6efd')
})
it('handles null modelValue correctly', async () => {
const wrapper = mountComponent({
modelValue: null
})
const { container } = renderComponent({ modelValue: null })
await nextTick()
const selectButton = wrapper.findComponent(SelectButton)
expect(selectButton.props('modelValue')).toEqual({
name: '_custom',
value: ''
})
const buttons = getToggleButtons(container)
const customButton = buttons[buttons.length - 1]
expect(customButton).toHaveAttribute('aria-pressed', 'true')
})
})

View File

@@ -1,140 +1,120 @@
import { mount } from '@vue/test-utils'
import { fireEvent, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import PrimeVue from 'primevue/config'
import InputText from 'primevue/inputtext'
import { beforeAll, describe, expect, it } from 'vitest'
import { createApp } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import EditableText from './EditableText.vue'
describe('EditableText', () => {
beforeAll(() => {
// Create a Vue app instance for PrimeVue
const app = createApp({})
app.use(PrimeVue)
})
function renderComponent(
props: { modelValue: string; isEditing?: boolean },
callbacks: {
onEdit?: (...args: unknown[]) => void
onCancel?: (...args: unknown[]) => void
} = {}
) {
const user = userEvent.setup()
// @ts-expect-error fixme ts strict error
const mountComponent = (props, options = {}) => {
return mount(EditableText, {
render(EditableText, {
global: {
plugins: [PrimeVue],
components: { InputText }
},
props,
...options
props: {
...props,
...(callbacks.onEdit && { onEdit: callbacks.onEdit }),
...(callbacks.onCancel && { onCancel: callbacks.onCancel })
}
})
return { user }
}
it('renders span with modelValue when not editing', () => {
const wrapper = mountComponent({
modelValue: 'Test Text',
isEditing: false
})
expect(wrapper.find('span').text()).toBe('Test Text')
expect(wrapper.findComponent(InputText).exists()).toBe(false)
renderComponent({ modelValue: 'Test Text', isEditing: false })
expect(screen.getByText('Test Text')).toBeInTheDocument()
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
it('renders input with modelValue when editing', () => {
const wrapper = mountComponent({
modelValue: 'Test Text',
isEditing: true
})
expect(wrapper.find('span').exists()).toBe(false)
expect(wrapper.findComponent(InputText).props()['modelValue']).toBe(
'Test Text'
)
renderComponent({ modelValue: 'Test Text', isEditing: true })
expect(screen.queryByText('Test Text')).not.toBeInTheDocument()
expect(screen.getByRole('textbox')).toHaveValue('Test Text')
})
it('emits edit event when input is submitted', async () => {
const wrapper = mountComponent({
modelValue: 'Test Text',
isEditing: true
})
await wrapper.findComponent(InputText).setValue('New Text')
await wrapper.findComponent(InputText).trigger('keydown.enter')
// Blur event should have been triggered
expect(wrapper.findComponent(InputText).element).not.toBe(
document.activeElement
const onEdit = vi.fn()
const { user } = renderComponent(
{ modelValue: 'Test Text', isEditing: true },
{ onEdit }
)
const input = screen.getByRole('textbox')
await user.clear(input)
await user.type(input, 'New Text')
await user.keyboard('{Enter}')
expect(onEdit).toHaveBeenCalledWith('New Text')
})
it('finishes editing on blur', async () => {
const wrapper = mountComponent({
modelValue: 'Test Text',
isEditing: true
})
await wrapper.findComponent(InputText).trigger('blur')
expect(wrapper.emitted('edit')).toBeTruthy()
// @ts-expect-error fixme ts strict error
expect(wrapper.emitted('edit')[0]).toEqual(['Test Text'])
const onEdit = vi.fn()
renderComponent({ modelValue: 'Test Text', isEditing: true }, { onEdit })
await fireEvent.blur(screen.getByRole('textbox'))
expect(onEdit).toHaveBeenCalledWith('Test Text')
})
it('cancels editing on escape key', async () => {
const wrapper = mountComponent({
modelValue: 'Original Text',
isEditing: true
})
// Change the input value
await wrapper.findComponent(InputText).setValue('Modified Text')
// Press escape
await wrapper.findComponent(InputText).trigger('keydown.escape')
// Should emit cancel event
expect(wrapper.emitted('cancel')).toBeTruthy()
// Should NOT emit edit event
expect(wrapper.emitted('edit')).toBeFalsy()
// Input value should be reset to original
expect(wrapper.findComponent(InputText).props()['modelValue']).toBe(
'Original Text'
const onEdit = vi.fn()
const onCancel = vi.fn()
const { user } = renderComponent(
{ modelValue: 'Original Text', isEditing: true },
{ onEdit, onCancel }
)
const input = screen.getByRole('textbox')
await user.clear(input)
await user.type(input, 'Modified Text')
await user.keyboard('{Escape}')
expect(onCancel).toHaveBeenCalled()
expect(onEdit).not.toHaveBeenCalled()
expect(input).toHaveValue('Original Text')
})
it('does not save changes when escape is pressed and blur occurs', async () => {
const wrapper = mountComponent({
modelValue: 'Original Text',
isEditing: true
})
it('does not save changes when escape is pressed', async () => {
const onEdit = vi.fn()
const onCancel = vi.fn()
const { user } = renderComponent(
{ modelValue: 'Original Text', isEditing: true },
{ onEdit, onCancel }
)
// Change the input value
await wrapper.findComponent(InputText).setValue('Modified Text')
const input = screen.getByRole('textbox')
await user.clear(input)
await user.type(input, 'Modified Text')
// Escape triggers cancelEditing → blur internally, so no separate blur needed
await user.keyboard('{Escape}')
// Press escape (which triggers blur internally)
await wrapper.findComponent(InputText).trigger('keydown.escape')
// Manually trigger blur to simulate the blur that happens after escape
await wrapper.findComponent(InputText).trigger('blur')
// Should emit cancel but not edit
expect(wrapper.emitted('cancel')).toBeTruthy()
expect(wrapper.emitted('edit')).toBeFalsy()
expect(onCancel).toHaveBeenCalled()
expect(onEdit).not.toHaveBeenCalled()
})
it('saves changes on enter but not on escape', async () => {
// Test Enter key saves changes
const enterWrapper = mountComponent({
modelValue: 'Original Text',
isEditing: true
})
await enterWrapper.findComponent(InputText).setValue('Saved Text')
await enterWrapper.findComponent(InputText).trigger('keydown.enter')
// Trigger blur that happens after enter
await enterWrapper.findComponent(InputText).trigger('blur')
expect(enterWrapper.emitted('edit')).toBeTruthy()
// @ts-expect-error fixme ts strict error
expect(enterWrapper.emitted('edit')[0]).toEqual(['Saved Text'])
const onEditEnter = vi.fn()
const { user: userEnter } = renderComponent(
{ modelValue: 'Original Text', isEditing: true },
{ onEdit: onEditEnter }
)
// Test Escape key cancels changes with a fresh wrapper
const escapeWrapper = mountComponent({
modelValue: 'Original Text',
isEditing: true
})
await escapeWrapper.findComponent(InputText).setValue('Cancelled Text')
await escapeWrapper.findComponent(InputText).trigger('keydown.escape')
expect(escapeWrapper.emitted('cancel')).toBeTruthy()
expect(escapeWrapper.emitted('edit')).toBeFalsy()
const enterInput = screen.getByRole('textbox')
await userEnter.clear(enterInput)
await userEnter.type(enterInput, 'Saved Text')
await userEnter.keyboard('{Enter}')
expect(onEditEnter).toHaveBeenCalledWith('Saved Text')
})
})

View File

@@ -1,5 +1,5 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { fireEvent, render, screen } from '@testing-library/vue'
import Badge from 'primevue/badge'
import PrimeVue from 'primevue/config'
import InputText from 'primevue/inputtext'
@@ -12,7 +12,6 @@ import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
import { InjectKeyHandleEditLabelFunction } from '@/types/treeExplorerTypes'
// Create a mock i18n instance
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -33,7 +32,6 @@ describe('TreeExplorerTreeNode', () => {
const mockHandleEditLabel = vi.fn()
beforeAll(() => {
// Create a Vue app instance for PrimeVuePrimeVue
const app = createApp({})
app.use(PrimeVue)
vi.useFakeTimers()
@@ -44,7 +42,7 @@ describe('TreeExplorerTreeNode', () => {
})
it('renders correctly', () => {
const wrapper = mount(TreeExplorerTreeNode, {
render(TreeExplorerTreeNode, {
props: { node: mockNode },
global: {
components: { EditableText, Badge },
@@ -55,18 +53,16 @@ describe('TreeExplorerTreeNode', () => {
}
})
expect(wrapper.find('.tree-node').exists()).toBe(true)
expect(wrapper.find('.tree-folder').exists()).toBe(true)
expect(wrapper.find('.tree-leaf').exists()).toBe(false)
expect(wrapper.findComponent(EditableText).props('modelValue')).toBe(
'Test Node'
)
// @ts-expect-error fixme ts strict error
expect(wrapper.findComponent(Badge).props()['value'].toString()).toBe('3')
const treeNode = screen.getByTestId('tree-node-1')
expect(treeNode).toBeInTheDocument()
expect(treeNode).toHaveClass('tree-folder')
expect(treeNode).not.toHaveClass('tree-leaf')
expect(screen.getByText('Test Node')).toBeInTheDocument()
expect(screen.getByText('3')).toBeInTheDocument()
})
it('makes node label editable when renamingEditingNode matches', async () => {
const wrapper = mount(TreeExplorerTreeNode, {
it('makes node label editable when isEditingLabel is true', () => {
render(TreeExplorerTreeNode, {
props: {
node: {
...mockNode,
@@ -82,14 +78,13 @@ describe('TreeExplorerTreeNode', () => {
}
})
const editableText = wrapper.findComponent(EditableText)
expect(editableText.props('isEditing')).toBe(true)
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('triggers handleEditLabel callback when editing is finished', async () => {
const handleEditLabelMock = vi.fn()
const wrapper = mount(TreeExplorerTreeNode, {
render(TreeExplorerTreeNode, {
props: {
node: {
...mockNode,
@@ -103,8 +98,9 @@ describe('TreeExplorerTreeNode', () => {
}
})
const editableText = wrapper.findComponent(EditableText)
editableText.vm.$emit('edit', 'New Node Name')
// Trigger blur on the input to finish editing (fires the 'edit' event)
await fireEvent.blur(screen.getByRole('textbox'))
expect(handleEditLabelMock).toHaveBeenCalledOnce()
})
})

View File

@@ -1,64 +1,66 @@
import { mount } from '@vue/test-utils'
import { fireEvent, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import PrimeVue from 'primevue/config'
import IconField from 'primevue/iconfield'
import InputIcon from 'primevue/inputicon'
import InputText from 'primevue/inputtext'
import { beforeEach, describe, expect, it } from 'vitest'
import { createApp, nextTick } from 'vue'
import { describe, expect, it } from 'vitest'
import { nextTick } from 'vue'
import UrlInput from './UrlInput.vue'
import type { ComponentProps } from 'vue-component-type-helpers'
describe('UrlInput', () => {
beforeEach(() => {
const app = createApp({})
app.use(PrimeVue)
})
const mountComponent = (
function renderComponent(
props: ComponentProps<typeof UrlInput> & {
placeholder?: string
disabled?: boolean
},
options = {}
) => {
return mount(UrlInput, {
'onUpdate:modelValue'?: (value: string) => void
}
) {
const user = userEvent.setup()
const result = render(UrlInput, {
global: {
plugins: [PrimeVue],
components: { IconField, InputIcon, InputText }
},
props,
...options
props
})
return { ...result, user }
}
it('passes through additional attributes to input element', () => {
const wrapper = mountComponent({
renderComponent({
modelValue: '',
placeholder: 'Enter URL',
disabled: true
})
expect(wrapper.find('input').attributes('disabled')).toBe('')
expect(screen.getByRole('textbox')).toBeDisabled()
})
it('emits update:modelValue on blur', async () => {
const wrapper = mountComponent({
const onUpdate = vi.fn()
const { user } = renderComponent({
modelValue: '',
placeholder: 'Enter URL'
placeholder: 'Enter URL',
'onUpdate:modelValue': onUpdate
})
const input = wrapper.find('input')
await input.setValue('https://test.com/')
await input.trigger('blur')
const input = screen.getByRole('textbox')
await user.type(input, 'https://test.com/')
expect(onUpdate).not.toHaveBeenCalled()
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([
'https://test.com/'
])
await user.tab()
expect(onUpdate).toHaveBeenCalledTimes(1)
expect(onUpdate).toHaveBeenCalledWith('https://test.com/')
})
it('renders spinner when validation is loading', async () => {
const wrapper = mountComponent({
const { container, rerender } = renderComponent({
modelValue: '',
placeholder: 'Enter URL',
validateUrlFn: () =>
@@ -67,43 +69,46 @@ describe('UrlInput', () => {
})
})
await wrapper.setProps({ modelValue: 'https://test.com' })
await rerender({ modelValue: 'https://test.com' })
await nextTick()
await nextTick()
expect(wrapper.find('.pi-spinner').exists()).toBe(true)
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- PrimeVue InputIcon uses pi-spinner class with no ARIA role
expect(container.querySelector('.pi-spinner')).not.toBeNull()
})
it('renders check icon when validation is valid', async () => {
const wrapper = mountComponent({
const { container, rerender } = renderComponent({
modelValue: '',
placeholder: 'Enter URL',
validateUrlFn: () => Promise.resolve(true)
})
await wrapper.setProps({ modelValue: 'https://test.com' })
await rerender({ modelValue: 'https://test.com' })
await nextTick()
await nextTick()
expect(wrapper.find('.pi-check').exists()).toBe(true)
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- PrimeVue InputIcon uses pi-check class with no ARIA role
expect(container.querySelector('.pi-check')).not.toBeNull()
})
it('renders cross icon when validation is invalid', async () => {
const wrapper = mountComponent({
const { container, rerender } = renderComponent({
modelValue: '',
placeholder: 'Enter URL',
validateUrlFn: () => Promise.resolve(false)
})
await wrapper.setProps({ modelValue: 'https://test.com' })
await rerender({ modelValue: 'https://test.com' })
await nextTick()
await nextTick()
expect(wrapper.find('.pi-times').exists()).toBe(true)
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- PrimeVue InputIcon uses pi-times class with no ARIA role
expect(container.querySelector('.pi-times')).not.toBeNull()
})
it('validates on mount', async () => {
const wrapper = mountComponent({
const { container } = renderComponent({
modelValue: 'https://test.com',
validateUrlFn: () => Promise.resolve(true)
})
@@ -111,12 +116,13 @@ describe('UrlInput', () => {
await nextTick()
await nextTick()
expect(wrapper.find('.pi-check').exists()).toBe(true)
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- PrimeVue InputIcon uses pi-check class with no ARIA role
expect(container.querySelector('.pi-check')).not.toBeNull()
})
it('triggers validation when clicking the validation icon', async () => {
let validationCount = 0
const wrapper = mountComponent({
const { container, user } = renderComponent({
modelValue: 'https://test.com',
validateUrlFn: () => {
validationCount++
@@ -129,7 +135,9 @@ describe('UrlInput', () => {
await nextTick()
// Click the validation icon
await wrapper.find('.pi-check').trigger('click')
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- PrimeVue InputIcon uses pi-check class with no ARIA role
const icon = container.querySelector('.pi-check')!
await user.click(icon)
await nextTick()
await nextTick()
@@ -138,7 +146,7 @@ describe('UrlInput', () => {
it('prevents multiple simultaneous validations', async () => {
let validationCount = 0
const wrapper = mountComponent({
const { container, rerender, user } = renderComponent({
modelValue: '',
validateUrlFn: () => {
validationCount++
@@ -148,14 +156,16 @@ describe('UrlInput', () => {
}
})
await wrapper.setProps({ modelValue: 'https://test.com' })
await rerender({ modelValue: 'https://test.com' })
await nextTick()
await nextTick()
// Trigger multiple validations in quick succession
await wrapper.find('.pi-spinner').trigger('click')
await wrapper.find('.pi-spinner').trigger('click')
await wrapper.find('.pi-spinner').trigger('click')
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- PrimeVue InputIcon
const spinner = container.querySelector('.pi-spinner')!
await user.click(spinner)
await user.click(spinner)
await user.click(spinner)
await nextTick()
await nextTick()
@@ -165,55 +175,49 @@ describe('UrlInput', () => {
describe('input cleaning functionality', () => {
it('trims whitespace when user types', async () => {
const wrapper = mountComponent({
renderComponent({
modelValue: '',
placeholder: 'Enter URL'
})
const input = wrapper.find('input')
const input = screen.getByRole('textbox')
// Test leading whitespace
await input.setValue(' https://leading-space.com')
await input.trigger('input')
// The component strips whitespace on input via handleInput
// We use fireEvent.input to simulate the input event handler directly
await fireEvent.update(input, ' https://leading-space.com')
await nextTick()
expect(input.element.value).toBe('https://leading-space.com')
expect(input).toHaveValue('https://leading-space.com')
// Test trailing whitespace
await input.setValue('https://trailing-space.com ')
await input.trigger('input')
await fireEvent.update(input, 'https://trailing-space.com ')
await nextTick()
expect(input.element.value).toBe('https://trailing-space.com')
expect(input).toHaveValue('https://trailing-space.com')
// Test both leading and trailing whitespace
await input.setValue(' https://both-spaces.com ')
await input.trigger('input')
await fireEvent.update(input, ' https://both-spaces.com ')
await nextTick()
expect(input.element.value).toBe('https://both-spaces.com')
expect(input).toHaveValue('https://both-spaces.com')
// Test whitespace in the middle of the URL
await input.setValue('https:// middle-space.com')
await input.trigger('input')
await fireEvent.update(input, 'https:// middle-space.com')
await nextTick()
expect(input.element.value).toBe('https://middle-space.com')
expect(input).toHaveValue('https://middle-space.com')
})
it('trims whitespace when value set externally', async () => {
const wrapper = mountComponent({
const { rerender } = renderComponent({
modelValue: ' https://initial-value.com ',
placeholder: 'Enter URL'
})
const input = wrapper.find('input')
const input = screen.getByRole('textbox')
// Check initial value is trimmed
expect(input.element.value).toBe('https://initial-value.com')
expect(input).toHaveValue('https://initial-value.com')
// Update props with whitespace
await wrapper.setProps({ modelValue: ' https://updated-value.com ' })
await rerender({ modelValue: ' https://updated-value.com ' })
await nextTick()
// Check updated value is trimmed
expect(input.element.value).toBe('https://updated-value.com')
expect(input).toHaveValue('https://updated-value.com')
})
})
})

View File

@@ -11,6 +11,7 @@
<div class="flex flex-col gap-1">
<div
class="flex cursor-pointer items-center justify-between rounded-sm px-3 py-2 text-sm hover:bg-node-component-surface-hovered"
data-testid="zoom-in-action"
@mousedown="startRepeat('Comfy.Canvas.ZoomIn')"
@mouseup="stopRepeat"
@mouseleave="stopRepeat"
@@ -23,6 +24,7 @@
<div
class="flex cursor-pointer items-center justify-between rounded-sm px-3 py-2 text-sm hover:bg-node-component-surface-hovered"
data-testid="zoom-out-action"
@mousedown="startRepeat('Comfy.Canvas.ZoomOut')"
@mouseup="stopRepeat"
@mouseleave="stopRepeat"
@@ -35,6 +37,7 @@
<div
class="flex cursor-pointer items-center justify-between rounded-sm px-3 py-2 text-sm hover:bg-node-component-surface-hovered"
data-testid="zoom-to-fit-action"
@click="executeCommand('Comfy.Canvas.FitView')"
>
<span class="font-medium">{{ $t('zoomControls.zoomToFit') }}</span>
@@ -46,6 +49,7 @@
<div
ref="zoomInputContainer"
class="zoomInputContainer flex items-center gap-1 rounded-sm bg-input-surface p-2"
data-testid="zoom-percentage-input"
>
<InputNumber
:default-value="canvasStore.appScalePercentage"

View File

@@ -1,8 +1,10 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import PrimeVue from 'primevue/config'
import Tooltip from 'primevue/tooltip'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import BypassButton from '@/components/graph/selectionToolbox/BypassButton.vue'
@@ -29,9 +31,9 @@ describe('BypassButton', () => {
locale: 'en',
messages: {
en: {
selectionToolbox: {
bypassButton: {
tooltip: 'Toggle bypass mode'
commands: {
Comfy_Canvas_ToggleSelectedNodes_Bypass: {
label: 'Toggle bypass mode'
}
}
}
@@ -46,8 +48,10 @@ describe('BypassButton', () => {
vi.clearAllMocks()
})
const mountComponent = () => {
return mount(BypassButton, {
function renderComponent() {
const user = userEvent.setup()
render(BypassButton, {
global: {
plugins: [i18n, PrimeVue],
directives: { tooltip: Tooltip },
@@ -56,28 +60,28 @@ describe('BypassButton', () => {
}
}
})
return { user }
}
it('should render bypass button', () => {
canvasStore.selectedItems = [getMockLGraphNode()]
const wrapper = mountComponent()
const button = wrapper.find('button')
expect(button.exists()).toBe(true)
renderComponent()
expect(screen.getByTestId('bypass-button')).toBeInTheDocument()
})
it('should have correct test id', () => {
canvasStore.selectedItems = [getMockLGraphNode()]
const wrapper = mountComponent()
const button = wrapper.find('[data-testid="bypass-button"]')
expect(button.exists()).toBe(true)
renderComponent()
expect(screen.getByTestId('bypass-button')).toBeInTheDocument()
})
it('should execute bypass command when clicked', async () => {
canvasStore.selectedItems = [getMockLGraphNode()]
const executeSpy = vi.spyOn(commandStore, 'execute').mockResolvedValue()
const wrapper = mountComponent()
await wrapper.find('button').trigger('click')
const { user } = renderComponent()
await user.click(screen.getByTestId('bypass-button'))
expect(executeSpy).toHaveBeenCalledWith(
'Comfy.Canvas.ToggleSelectedNodes.Bypass'
@@ -90,21 +94,18 @@ describe('BypassButton', () => {
})
canvasStore.selectedItems = [bypassedNode]
vi.spyOn(commandStore, 'execute').mockResolvedValue()
const wrapper = mountComponent()
const { user } = renderComponent()
// Click to trigger the reactivity update
await wrapper.find('button').trigger('click')
await wrapper.vm.$nextTick()
await user.click(screen.getByTestId('bypass-button'))
await nextTick()
const button = wrapper.find('button')
expect(button.exists()).toBe(true)
expect(screen.getByTestId('bypass-button')).toBeInTheDocument()
})
it('should handle multiple selected items', () => {
vi.spyOn(commandStore, 'execute').mockResolvedValue()
canvasStore.selectedItems = [getMockLGraphNode(), getMockLGraphNode()]
const wrapper = mountComponent()
const button = wrapper.find('button')
expect(button.exists()).toBe(true)
renderComponent()
expect(screen.getByTestId('bypass-button')).toBeInTheDocument()
})
})

View File

@@ -1,4 +1,5 @@
import { mount } from '@vue/test-utils'
import { render, screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import PrimeVue from 'primevue/config'
import Tooltip from 'primevue/tooltip'
import { describe, expect, it } from 'vitest'
@@ -28,54 +29,59 @@ describe('SidebarIcon', () => {
selected: false
}
const mountSidebarIcon = (props: Partial<SidebarIconProps>, options = {}) => {
return mount(SidebarIcon, {
function renderSidebarIcon(props: Partial<SidebarIconProps> = {}) {
const user = userEvent.setup()
const result = render(SidebarIcon, {
global: {
plugins: [PrimeVue, i18n],
directives: { tooltip: Tooltip }
},
props: { ...exampleProps, ...props },
...options
props: { ...exampleProps, ...props }
})
return { ...result, user }
}
it('renders button element', () => {
const wrapper = mountSidebarIcon({})
expect(wrapper.find('button.side-bar-button').exists()).toBe(true)
renderSidebarIcon()
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('renders icon', () => {
const wrapper = mountSidebarIcon({})
expect(wrapper.find('.side-bar-button-icon').exists()).toBe(true)
const { container } = renderSidebarIcon()
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- Icon escape hatch: iconify icons have no ARIA role
expect(container.querySelector('.side-bar-button-icon')).not.toBeNull()
})
it('creates badge when iconBadge prop is set', () => {
const badge = '2'
const wrapper = mountSidebarIcon({ iconBadge: badge })
const badgeEl = wrapper.find('.sidebar-icon-badge')
expect(badgeEl.exists()).toBe(true)
expect(badgeEl.text()).toEqual(badge)
renderSidebarIcon({ iconBadge: badge })
expect(screen.getByText(badge)).toBeInTheDocument()
})
it('shows tooltip on hover', async () => {
const tooltipShowDelay = 300
const tooltipText = 'Settings'
const wrapper = mountSidebarIcon({ tooltip: tooltipText })
const { user } = renderSidebarIcon({ tooltip: tooltipText })
const tooltipElBeforeHover = document.querySelector('[role="tooltip"]')
expect(tooltipElBeforeHover).toBeNull()
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
// Hover over the icon
await wrapper.trigger('mouseenter')
await new Promise((resolve) => setTimeout(resolve, tooltipShowDelay + 16))
await user.hover(screen.getByRole('button'))
const tooltipElAfterHover = document.querySelector('[role="tooltip"]')
expect(tooltipElAfterHover).not.toBeNull()
await waitFor(
() => {
expect(screen.getByRole('tooltip')).toBeInTheDocument()
},
{ timeout: 1000 }
)
})
it('sets aria-label attribute when tooltip is provided', () => {
const tooltipText = 'Settings'
const wrapper = mountSidebarIcon({ tooltip: tooltipText })
expect(wrapper.attributes('aria-label')).toEqual(tooltipText)
renderSidebarIcon({ tooltip: tooltipText })
expect(screen.getByRole('button')).toHaveAttribute(
'aria-label',
tooltipText
)
})
})

View File

@@ -1,4 +1,5 @@
import { mount } from '@vue/test-utils'
import { fireEvent, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, watch } from 'vue'
import { createI18n } from 'vue-i18n'
@@ -39,8 +40,8 @@ describe('SearchInput', () => {
vi.useRealTimers()
})
function mountComponent(props = {}) {
return mount(SearchInput, {
function renderComponent(props = {}) {
const result = render(SearchInput, {
global: {
plugins: [i18n],
stubs: {
@@ -63,140 +64,142 @@ describe('SearchInput', () => {
...props
}
})
return result
}
describe('debounced search', () => {
it('should debounce search input by 300ms', async () => {
const wrapper = mountComponent()
const input = wrapper.find('input')
const onSearch = vi.fn()
renderComponent({ onSearch })
const input = screen.getByRole('textbox')
await input.setValue('test')
await fireEvent.update(input, 'test')
expect(wrapper.emitted('search')).toBeFalsy()
expect(onSearch).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(299)
await nextTick()
expect(wrapper.emitted('search')).toBeFalsy()
expect(onSearch).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
await nextTick()
expect(wrapper.emitted('search')).toEqual([['test']])
expect(onSearch).toHaveBeenCalledWith('test')
})
it('should reset debounce timer on each keystroke', async () => {
const wrapper = mountComponent()
const input = wrapper.find('input')
const onSearch = vi.fn()
renderComponent({ onSearch })
const input = screen.getByRole('textbox')
await input.setValue('t')
await fireEvent.update(input, 't')
vi.advanceTimersByTime(200)
await nextTick()
await input.setValue('te')
await fireEvent.update(input, 'te')
vi.advanceTimersByTime(200)
await nextTick()
await input.setValue('tes')
await fireEvent.update(input, 'tes')
await vi.advanceTimersByTimeAsync(200)
await nextTick()
expect(wrapper.emitted('search')).toBeFalsy()
expect(onSearch).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(100)
await nextTick()
expect(wrapper.emitted('search')).toBeTruthy()
expect(wrapper.emitted('search')?.[0]).toEqual(['tes'])
expect(onSearch).toHaveBeenCalled()
expect(onSearch).toHaveBeenCalledWith('tes')
})
it('should only emit final value after rapid typing', async () => {
const wrapper = mountComponent()
const input = wrapper.find('input')
const onSearch = vi.fn()
renderComponent({ onSearch })
const input = screen.getByRole('textbox')
const searchTerms = ['s', 'se', 'sea', 'sear', 'searc', 'search']
for (const term of searchTerms) {
await input.setValue(term)
await fireEvent.update(input, term)
await vi.advanceTimersByTimeAsync(50)
}
await nextTick()
expect(wrapper.emitted('search')).toBeFalsy()
expect(onSearch).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(350)
await nextTick()
expect(wrapper.emitted('search')).toHaveLength(1)
expect(wrapper.emitted('search')?.[0]).toEqual(['search'])
expect(onSearch).toHaveBeenCalledTimes(1)
expect(onSearch).toHaveBeenCalledWith('search')
})
})
describe('model sync', () => {
it('should sync external model changes to internal state', async () => {
const wrapper = mountComponent({ modelValue: 'initial' })
const input = wrapper.find('input')
const { rerender } = renderComponent({ modelValue: 'initial' })
const input = screen.getByRole('textbox')
expect(input.element.value).toBe('initial')
expect(input).toHaveValue('initial')
await wrapper.setProps({ modelValue: 'external update' })
await rerender({ modelValue: 'external update' })
await nextTick()
expect(input.element.value).toBe('external update')
expect(input).toHaveValue('external update')
})
})
describe('placeholder', () => {
it('should use custom placeholder when provided', () => {
const wrapper = mountComponent({ placeholder: 'Custom search...' })
const input = wrapper.find('input')
expect(input.attributes('placeholder')).toBe('Custom search...')
renderComponent({ placeholder: 'Custom search...' })
expect(
screen.getByPlaceholderText('Custom search...')
).toBeInTheDocument()
})
it('should use i18n placeholder when not provided', () => {
const wrapper = mountComponent()
const input = wrapper.find('input')
expect(input.attributes('placeholder')).toBe('Search...')
renderComponent()
expect(screen.getByPlaceholderText('Search...')).toBeInTheDocument()
})
})
describe('autofocus', () => {
it('should pass autofocus prop to ComboboxInput', () => {
const wrapper = mountComponent({ autofocus: true })
const input = wrapper.find('input')
expect(input.attributes('autofocus')).toBeDefined()
renderComponent({ autofocus: true })
const input = screen.getByRole('textbox')
expect(input).toHaveAttribute('autofocus')
})
it('should not autofocus by default', () => {
const wrapper = mountComponent()
const input = wrapper.find('input')
expect(input.attributes('autofocus')).toBeUndefined()
})
})
describe('focus method', () => {
it('should expose focus method via ref', () => {
const wrapper = mountComponent()
expect(wrapper.vm.focus).toBeDefined()
renderComponent()
const input = screen.getByRole('textbox')
expect(input).not.toHaveAttribute('autofocus')
})
})
describe('clear button', () => {
it('shows search icon when value is empty', () => {
const wrapper = mountComponent({ modelValue: '' })
expect(wrapper.find('button[aria-label="Clear"]').exists()).toBe(false)
renderComponent({ modelValue: '' })
expect(
screen.queryByRole('button', { name: 'Clear' })
).not.toBeInTheDocument()
})
it('shows clear button when value is not empty', () => {
const wrapper = mountComponent({ modelValue: 'test' })
expect(wrapper.find('button[aria-label="Clear"]').exists()).toBe(true)
renderComponent({ modelValue: 'test' })
expect(screen.getByRole('button', { name: 'Clear' })).toBeInTheDocument()
})
it('clears value when clear button is clicked', async () => {
const wrapper = mountComponent({ modelValue: 'test' })
const clearButton = wrapper.find('button')
await clearButton.trigger('click')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([''])
const onUpdate = vi.fn()
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
renderComponent({
modelValue: 'test',
'onUpdate:modelValue': onUpdate
})
await user.click(screen.getByRole('button', { name: 'Clear' }))
expect(onUpdate).toHaveBeenCalledWith('')
})
})
})

View File

@@ -1,5 +1,6 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { h, nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
@@ -16,29 +17,39 @@ const i18n = createI18n({
})
describe('TagsInput', () => {
function mountTagsInput(props = {}, slots = {}) {
return mount(TagsInput, {
function renderTagsInput(props = {}, slots = {}) {
const user = userEvent.setup()
const result = render(TagsInput, {
props: {
modelValue: [],
...props
},
slots
})
return { ...result, user }
}
it('renders slot content', () => {
const wrapper = mountTagsInput({}, { default: '<span>Slot Content</span>' })
renderTagsInput({}, { default: '<span>Slot Content</span>' })
expect(wrapper.text()).toContain('Slot Content')
expect(screen.getByText('Slot Content')).toBeInTheDocument()
})
})
describe('TagsInput with child components', () => {
function mountFullTagsInput(tags: string[] = ['tag1', 'tag2']) {
return mount(TagsInput, {
function renderFullTagsInput(
tags: string[] = ['tag1', 'tag2'],
extraProps: Record<string, unknown> = {}
) {
const user = userEvent.setup()
const result = render(TagsInput, {
global: { plugins: [i18n] },
props: {
modelValue: tags
modelValue: tags,
...extraProps
},
slots: {
default: () => [
@@ -52,55 +63,52 @@ describe('TagsInput with child components', () => {
]
}
})
return { ...result, user }
}
it('renders tags structure and content', () => {
const tags = ['tag1', 'tag2']
const wrapper = mountFullTagsInput(tags)
renderFullTagsInput(tags)
const items = wrapper.findAllComponents(TagsInputItem)
const textElements = wrapper.findAllComponents(TagsInputItemText)
const deleteButtons = wrapper.findAllComponents(TagsInputItemDelete)
expect(screen.getByText('tag1')).toBeInTheDocument()
expect(screen.getByText('tag2')).toBeInTheDocument()
expect(items).toHaveLength(tags.length)
expect(textElements).toHaveLength(tags.length)
const deleteButtons = tags.map((tag) =>
screen.getByRole('button', { name: tag })
)
expect(deleteButtons).toHaveLength(tags.length)
textElements.forEach((el, i) => {
expect(el.text()).toBe(tags[i])
})
expect(wrapper.findComponent(TagsInputInput).exists()).toBe(true)
})
it('updates model value when adding a tag', async () => {
let currentTags = ['existing']
const onUpdate = vi.fn()
const wrapper = mount<typeof TagsInput<string>>(TagsInput, {
const user = userEvent.setup()
const { container } = render(TagsInput, {
props: {
modelValue: currentTags,
'onUpdate:modelValue': (payload) => {
currentTags = payload
}
modelValue: ['existing'],
'onUpdate:modelValue': onUpdate
},
slots: {
default: () => h(TagsInputInput, { placeholder: 'Add tag...' })
}
})
await wrapper.trigger('click')
// Click the container to enter edit mode and show the input
// eslint-disable-next-line testing-library/no-node-access -- TagsInput root element needs click to enter edit mode; no role/label available
await user.click(container.firstElementChild!)
await nextTick()
const input = wrapper.find('input')
await input.setValue('newTag')
await input.trigger('keydown', { key: 'Enter' })
const input = screen.getByPlaceholderText('Add tag...')
await user.type(input, 'newTag{Enter}')
await nextTick()
expect(currentTags).toContain('newTag')
expect(onUpdate).toHaveBeenCalledWith(['existing', 'newTag'])
})
it('does not enter edit mode when disabled', async () => {
const wrapper = mount<typeof TagsInput<string>>(TagsInput, {
const user = userEvent.setup()
const { container } = render(TagsInput, {
props: {
modelValue: ['tag1'],
disabled: true
@@ -110,18 +118,21 @@ describe('TagsInput with child components', () => {
}
})
expect(wrapper.find('input').exists()).toBe(false)
expect(screen.queryByPlaceholderText('Add tag...')).not.toBeInTheDocument()
await wrapper.trigger('click')
// eslint-disable-next-line testing-library/no-node-access -- TagsInput root element needs click to test disabled behavior; no role/label available
await user.click(container.firstElementChild!)
await nextTick()
expect(wrapper.find('input').exists()).toBe(false)
expect(screen.queryByPlaceholderText('Add tag...')).not.toBeInTheDocument()
})
it('exits edit mode when clicking outside', async () => {
const outsideElement = document.createElement('div')
document.body.appendChild(outsideElement)
const wrapper = mount<typeof TagsInput<string>>(TagsInput, {
const user = userEvent.setup()
const { container } = render(TagsInput, {
props: {
modelValue: ['tag1']
},
@@ -130,21 +141,21 @@ describe('TagsInput with child components', () => {
}
})
await wrapper.trigger('click')
// eslint-disable-next-line testing-library/no-node-access -- TagsInput root element needs click; no role/label
await user.click(container.firstElementChild!)
await nextTick()
expect(wrapper.find('input').exists()).toBe(true)
expect(screen.getByPlaceholderText('Add tag...')).toBeInTheDocument()
outsideElement.dispatchEvent(new PointerEvent('click', { bubbles: true }))
await nextTick()
expect(wrapper.find('input').exists()).toBe(false)
expect(screen.queryByPlaceholderText('Add tag...')).not.toBeInTheDocument()
wrapper.unmount()
outsideElement.remove()
})
it('shows placeholder when modelValue is empty', async () => {
const wrapper = mount<typeof TagsInput<string>>(TagsInput, {
render(TagsInput, {
props: {
modelValue: []
},
@@ -156,8 +167,7 @@ describe('TagsInput with child components', () => {
await nextTick()
const input = wrapper.find('input')
expect(input.exists()).toBe(true)
expect(input.attributes('placeholder')).toBe('Add tag...')
const input = screen.getByPlaceholderText('Add tag...')
expect(input).toBeInTheDocument()
})
})

View File

@@ -141,6 +141,7 @@ import { computed, defineAsyncComponent, provide, ref, toRef } from 'vue'
import IconGroup from '@/components/button/IconGroup.vue'
import LoadingOverlay from '@/components/common/LoadingOverlay.vue'
import Button from '@/components/ui/button/Button.vue'
import { isCloud } from '@/platform/distribution/types'
import { useAssetsStore } from '@/stores/assetsStore'
import {
formatDuration,
@@ -279,7 +280,8 @@ const formattedDuration = computed(() => {
// Get metadata info based on file kind
const metaInfo = computed(() => {
if (!asset) return ''
if (fileKind.value === 'image' && imageDimensions.value) {
// TODO(assets): Re-enable once /assets API returns original image dimensions in metadata (#10590)
if (fileKind.value === 'image' && imageDimensions.value && !isCloud) {
return `${imageDimensions.value.width}x${imageDimensions.value.height}`
}
if (asset.size && ['video', 'audio', '3D'].includes(fileKind.value)) {

View File

@@ -100,6 +100,7 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
import type { PreviewSubscribeResponse } from '@/platform/workspace/api/workspaceApi'
@@ -218,8 +219,8 @@ async function handleAddCreditCard() {
if (!planSlug) return
const response = await subscribe(
planSlug,
'https://www.comfy.org/payment/success',
'https://www.comfy.org/payment/failed'
`${getComfyPlatformBaseUrl()}/payment/success`,
`${getComfyPlatformBaseUrl()}/payment/failed`
)
if (!response) return
@@ -272,8 +273,8 @@ async function handleConfirmTransition() {
if (!planSlug) return
const response = await subscribe(
planSlug,
'https://www.comfy.org/payment/success',
'https://www.comfy.org/payment/failed'
`${getComfyPlatformBaseUrl()}/payment/success`,
`${getComfyPlatformBaseUrl()}/payment/failed`
)
if (!response) return

View File

@@ -300,6 +300,25 @@ describe('TeamWorkspacesDialogContent', () => {
expect(mockCreateWorkspace).not.toHaveBeenCalled()
})
it('resets loading state after createWorkspace fails', async () => {
mockCreateWorkspace.mockRejectedValue(new Error('Limit reached'))
const wrapper = mountComponent()
await typeAndCreate(wrapper, 'New Team')
expect(findCreateButton(wrapper).props('loading')).toBe(false)
})
it('resets loading state after onConfirm fails', async () => {
mockCreateWorkspace.mockResolvedValue({ id: 'new-ws' })
const onConfirm = vi.fn().mockRejectedValue(new Error('Setup failed'))
const wrapper = mountComponent({ onConfirm })
await typeAndCreate(wrapper, 'New Team')
expect(findCreateButton(wrapper).props('loading')).toBe(false)
})
})
describe('close button', () => {

View File

@@ -201,28 +201,30 @@ async function handleSwitch(workspaceId: string) {
async function onCreate() {
if (!isValidName.value || loading.value) return
loading.value = true
const name = workspaceName.value.trim()
try {
await workspaceStore.createWorkspace(name)
} catch (error) {
toast.add({
severity: 'error',
summary: t('workspacePanel.toast.failedToCreateWorkspace'),
detail: error instanceof Error ? error.message : t('g.unknownError')
})
const name = workspaceName.value.trim()
try {
await workspaceStore.createWorkspace(name)
} catch (error) {
toast.add({
severity: 'error',
summary: t('workspacePanel.toast.failedToCreateWorkspace'),
detail: error instanceof Error ? error.message : t('g.unknownError')
})
return
}
try {
await onConfirm?.(name)
} catch (error) {
toast.add({
severity: 'error',
summary: t('teamWorkspacesDialog.confirmCallbackFailed'),
detail: error instanceof Error ? error.message : t('g.unknownError')
})
}
dialogStore.closeDialog({ key: DIALOG_KEY })
} finally {
loading.value = false
return
}
try {
await onConfirm?.(name)
} catch (error) {
toast.add({
severity: 'error',
summary: t('teamWorkspacesDialog.confirmCallbackFailed'),
detail: error instanceof Error ? error.message : t('g.unknownError')
})
}
dialogStore.closeDialog({ key: DIALOG_KEY })
loading.value = false
}
</script>

View File

@@ -41,11 +41,6 @@ const MockFormDropdownInput = {
'<button class="mock-dropdown-trigger" @click="$emit(\'select-click\', $event)">Open</button>'
}
const MockPopover = {
name: 'Popover',
template: '<div><slot /></div>'
}
interface MountDropdownOptions {
searcher?: (
query: string,
@@ -65,13 +60,17 @@ function mountDropdown(
plugins: [PrimeVue, i18n],
stubs: {
FormDropdownInput: MockFormDropdownInput,
Popover: MockPopover,
FormDropdownMenu: MockFormDropdownMenu
}
}
})
}
async function openDropdown(wrapper: ReturnType<typeof mountDropdown>) {
await wrapper.find('.mock-dropdown-trigger').trigger('click')
await flushPromises()
}
function getMenuItems(
wrapper: ReturnType<typeof mountDropdown>
): FormDropdownItem[] {
@@ -87,7 +86,7 @@ describe('FormDropdown', () => {
createItem('input-0', 'video1.mp4'),
createItem('input-1', 'video2.mp4')
])
await flushPromises()
await openDropdown(wrapper)
expect(getMenuItems(wrapper)).toHaveLength(2)
@@ -106,7 +105,7 @@ describe('FormDropdown', () => {
it('updates when items change but IDs stay the same', async () => {
const wrapper = mountDropdown([createItem('1', 'alpha')])
await flushPromises()
await openDropdown(wrapper)
await wrapper.setProps({ items: [createItem('1', 'beta')] })
await flushPromises()
@@ -116,7 +115,7 @@ describe('FormDropdown', () => {
it('updates when switching between empty and non-empty items', async () => {
const wrapper = mountDropdown([])
await flushPromises()
await openDropdown(wrapper)
expect(getMenuItems(wrapper)).toHaveLength(0)
@@ -154,7 +153,10 @@ describe('FormDropdown', () => {
await flushPromises()
expect(searcher).not.toHaveBeenCalled()
expect(getMenuItems(wrapper).map((item) => item.id)).toEqual(['3', '4'])
await openDropdown(wrapper)
expect(searcher).toHaveBeenCalled()
})
it('runs filtering when dropdown opens', async () => {
@@ -169,8 +171,7 @@ describe('FormDropdown', () => {
)
await flushPromises()
await wrapper.find('.mock-dropdown-trigger').trigger('click')
await flushPromises()
await openDropdown(wrapper)
expect(searcher).toHaveBeenCalled()
expect(getMenuItems(wrapper).map((item) => item.id)).toEqual(['keep'])

View File

@@ -1,11 +1,12 @@
<script setup lang="ts">
import { computedAsync, refDebounced } from '@vueuse/core'
import Popover from 'primevue/popover'
import { computed, ref, useTemplateRef } from 'vue'
import { computedAsync, onClickOutside, refDebounced } from '@vueuse/core'
import type { CSSProperties } from 'vue'
import { computed, inject, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
import { OverlayAppendToKey } from '@/composables/useTransformCompatOverlayProps'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { cn } from '@/utils/tailwindUtil'
import type {
FilterOption,
@@ -16,6 +17,7 @@ import type {
import FormDropdownInput from './FormDropdownInput.vue'
import FormDropdownMenu from './FormDropdownMenu.vue'
import { defaultSearcher, getDefaultSortOptions } from './shared'
import { MENU_HEIGHT, MENU_WIDTH } from './types'
import type { FormDropdownItem, LayoutMode, SortOption } from './types'
interface Props {
@@ -51,7 +53,6 @@ interface Props {
}
const { t } = useI18n()
const overlayProps = useTransformCompatOverlayProps()
const {
placeholder,
@@ -95,8 +96,10 @@ const baseModelSelected = defineModel<Set<string>>('baseModelSelected', {
const isOpen = defineModel<boolean>('isOpen', { default: false })
const toastStore = useToastStore()
const popoverRef = ref<InstanceType<typeof Popover>>()
const triggerRef = useTemplateRef('triggerRef')
const dropdownRef = useTemplateRef('dropdownRef')
const shouldTeleport = inject(OverlayAppendToKey, undefined) === 'body'
const maxSelectable = computed(() => {
if (multiple === true) return Infinity
@@ -142,18 +145,59 @@ function internalIsSelected(item: FormDropdownItem, index: number): boolean {
return isSelected(selected.value, item, index)
}
const toggleDropdown = (event: Event) => {
const MENU_HEIGHT_WITH_GAP = MENU_HEIGHT + 8
const openUpward = ref(false)
const fixedPosition = ref({ top: 0, left: 0 })
const teleportStyle = computed<CSSProperties | undefined>(() => {
if (!shouldTeleport) return undefined
const pos = fixedPosition.value
return openUpward.value
? {
position: 'fixed',
left: `${pos.left}px`,
bottom: `${window.innerHeight - pos.top}px`,
paddingBottom: '0.5rem'
}
: {
position: 'fixed',
left: `${pos.left}px`,
top: `${pos.top}px`,
paddingTop: '0.5rem'
}
})
function toggleDropdown() {
if (disabled) return
if (popoverRef.value && triggerRef.value) {
popoverRef.value.toggle?.(event, triggerRef.value)
isOpen.value = !isOpen.value
if (!isOpen.value && triggerRef.value) {
const rect = triggerRef.value.getBoundingClientRect()
const spaceBelow = window.innerHeight - rect.bottom
const spaceAbove = rect.top
openUpward.value =
spaceBelow < MENU_HEIGHT_WITH_GAP && spaceAbove > spaceBelow
if (shouldTeleport) {
fixedPosition.value = {
top: openUpward.value
? Math.max(MENU_HEIGHT_WITH_GAP, rect.top)
: Math.min(rect.bottom, window.innerHeight - MENU_HEIGHT_WITH_GAP),
left: Math.min(rect.right, window.innerWidth - MENU_WIDTH)
}
}
}
isOpen.value = !isOpen.value
}
const closeDropdown = () => {
if (popoverRef.value) {
popoverRef.value.hide?.()
isOpen.value = false
function closeDropdown() {
isOpen.value = false
}
onClickOutside(triggerRef, closeDropdown, { ignore: [dropdownRef] })
function handleEscape(event: KeyboardEvent) {
if (event.key === 'Escape') {
closeDropdown()
}
}
@@ -192,7 +236,7 @@ function handleSelection(item: FormDropdownItem, index: number) {
</script>
<template>
<div ref="triggerRef">
<div ref="triggerRef" class="relative" @keydown="handleEscape">
<FormDropdownInput
:files
:is-open
@@ -207,42 +251,41 @@ function handleSelection(item: FormDropdownItem, index: number) {
@select-click="toggleDropdown"
@file-change="handleFileChange"
/>
<Popover
ref="popoverRef"
:dismissable="true"
:close-on-escape="true"
:append-to="overlayProps.appendTo"
unstyled
:pt="{
root: {
class: 'absolute z-50'
},
content: {
class: ['bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg']
}
}"
@hide="isOpen = false"
>
<FormDropdownMenu
v-model:filter-selected="filterSelected"
v-model:layout-mode="layoutMode"
v-model:sort-selected="sortSelected"
v-model:search-query="searchQuery"
v-model:ownership-selected="ownershipSelected"
v-model:base-model-selected="baseModelSelected"
:filter-options
:sort-options
:show-ownership-filter
:ownership-options
:show-base-model-filter
:base-model-options
:disabled
:items="sortedItems"
:is-selected="internalIsSelected"
:max-selectable
@close="closeDropdown"
@item-click="handleSelection"
/>
</Popover>
<Teleport to="body" :disabled="!shouldTeleport">
<div
v-if="isOpen"
ref="dropdownRef"
:class="
cn(
'z-50 rounded-lg border-none bg-transparent p-0 shadow-lg',
!shouldTeleport && 'absolute left-0',
!shouldTeleport &&
(openUpward ? 'bottom-full pb-2' : 'top-full pt-2')
)
"
:style="teleportStyle"
>
<FormDropdownMenu
v-model:filter-selected="filterSelected"
v-model:layout-mode="layoutMode"
v-model:sort-selected="sortSelected"
v-model:search-query="searchQuery"
v-model:ownership-selected="ownershipSelected"
v-model:base-model-selected="baseModelSelected"
:filter-options
:sort-options
:show-ownership-filter
:ownership-options
:show-base-model-filter
:base-model-options
:disabled
:items="sortedItems"
:is-selected="internalIsSelected"
:max-selectable
@close="closeDropdown"
@item-click="handleSelection"
/>
</div>
</Teleport>
</div>
</template>

View File

@@ -61,6 +61,7 @@ const theButtonStyle = computed(() =>
"
>
<button
data-testid="form-dropdown-trigger"
:class="
cn(
theButtonStyle,

View File

@@ -97,6 +97,7 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
<template>
<div
data-testid="form-dropdown-menu"
class="flex h-[640px] w-103 flex-col rounded-lg bg-component-node-background pt-4 outline -outline-offset-1 outline-node-component-border"
>
<FormDropdownMenuFilter

View File

@@ -28,5 +28,10 @@ export interface SortOption<TId extends string = string> {
export type LayoutMode = 'list' | 'grid' | 'list-small'
/** Height of FormDropdownMenu in pixels (matches h-[640px] in template). */
export const MENU_HEIGHT = 640
/** Width of FormDropdownMenu in pixels (matches w-103 = 26rem = 416px in template). */
export const MENU_WIDTH = 412
export const AssetKindKey: InjectionKey<ComputedRef<AssetKind | undefined>> =
Symbol('assetKind')