Compare commits

...

3 Commits

Author SHA1 Message Date
Benjamin Lu
f00c5b96b6 fix: prune unused shadcn menu components 2026-03-23 17:56:51 -07:00
Benjamin Lu
47f9b0a20e feat: use shadcn menus for queue and asset actions 2026-03-23 17:52:18 -07:00
Benjamin Lu
97c8be9ce9 feat: add shadcn menu ui components 2026-03-23 17:40:36 -07:00
40 changed files with 2032 additions and 1502 deletions

View File

@@ -3,6 +3,9 @@ import type { Page, Route } from '@playwright/test'
export class QueueHelper {
private queueRouteHandler: ((route: Route) => void) | null = null
private historyRouteHandler: ((route: Route) => void) | null = null
private jobsRouteHandler: ((route: Route) => void) | null = null
private queueJobs: Array<Record<string, unknown>> = []
private historyJobs: Array<Record<string, unknown>> = []
constructor(private readonly page: Page) {}
@@ -13,6 +16,26 @@ export class QueueHelper {
running: number = 0,
pending: number = 0
): Promise<void> {
const baseTime = Date.now()
this.queueJobs = [
...Array.from({ length: running }, (_, i) => ({
id: `running-${i}`,
status: 'in_progress',
create_time: baseTime - i * 1000,
execution_start_time: baseTime - 5000 - i * 1000,
execution_end_time: null,
priority: i + 1
})),
...Array.from({ length: pending }, (_, i) => ({
id: `pending-${i}`,
status: 'pending',
create_time: baseTime - (running + i) * 1000,
execution_start_time: null,
execution_end_time: null,
priority: running + i + 1
}))
]
this.queueRouteHandler = (route: Route) =>
route.fulfill({
status: 200,
@@ -35,6 +58,7 @@ export class QueueHelper {
})
})
await this.page.route('**/api/queue', this.queueRouteHandler)
await this.installJobsRoute()
}
/**
@@ -43,6 +67,30 @@ export class QueueHelper {
async mockHistory(
jobs: Array<{ promptId: string; status: 'success' | 'error' }>
): Promise<void> {
const baseTime = Date.now()
this.historyJobs = jobs.map((job, index) => {
const completed = job.status === 'success'
return {
id: job.promptId,
status: completed ? 'completed' : 'failed',
create_time: baseTime - index * 1000,
execution_start_time: baseTime - 5000 - index * 1000,
execution_end_time: baseTime - index * 1000,
outputs_count: completed ? 1 : 0,
workflow_id: `workflow-${job.promptId}`,
preview_output: completed
? {
filename: `${job.promptId}.png`,
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
}
: null
}
})
const history: Record<string, unknown> = {}
for (const job of jobs) {
history[job.promptId] = {
@@ -61,6 +109,44 @@ export class QueueHelper {
body: JSON.stringify(history)
})
await this.page.route('**/api/history**', this.historyRouteHandler)
await this.installJobsRoute()
}
private async installJobsRoute() {
if (this.jobsRouteHandler) {
return
}
this.jobsRouteHandler = (route: Route) => {
const url = new URL(route.request().url())
const statuses =
url.searchParams
.get('status')
?.split(',')
.filter((status) => status.length > 0) ?? []
const offset = Number(url.searchParams.get('offset') ?? 0)
const limit = Number(url.searchParams.get('limit') ?? 200)
const jobs = [...this.queueJobs, ...this.historyJobs].filter(
(job) => statuses.length === 0 || statuses.includes(String(job.status))
)
const paginatedJobs = jobs.slice(offset, offset + limit)
void route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
jobs: paginatedJobs,
pagination: {
offset,
limit,
total: jobs.length,
has_more: offset + paginatedJobs.length < jobs.length
}
})
})
}
await this.page.route('**/api/jobs**', this.jobsRouteHandler)
}
/**
@@ -75,5 +161,9 @@ export class QueueHelper {
await this.page.unroute('**/api/history**', this.historyRouteHandler)
this.historyRouteHandler = null
}
if (this.jobsRouteHandler) {
await this.page.unroute('**/api/jobs**', this.jobsRouteHandler)
this.jobsRouteHandler = null
}
}
}

View File

@@ -0,0 +1,55 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../../fixtures/ComfyPage'
test.describe('Assets Sidebar', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.queue.mockHistory([
{ promptId: 'history-asset-1', status: 'success' }
])
await comfyPage.setup({ clearStorage: false })
await comfyPage.page.getByRole('button', { name: /^Assets/ }).click()
await expect(
comfyPage.page.getByRole('button', {
name: /history-asset-1\.png/i
})
).toBeVisible()
})
test('right-click menu can inspect an asset', async ({ comfyPage }) => {
const assetCard = comfyPage.page.getByRole('button', {
name: /history-asset-1\.png/i
})
await assetCard.click({ button: 'right' })
const menuPanel = comfyPage.page.locator('.media-asset-menu-panel')
await expect(menuPanel).toBeVisible()
await menuPanel.getByRole('menuitem', { name: /inspect asset/i }).click()
const dialog = comfyPage.page.getByRole('dialog')
await expect(dialog).toBeVisible()
await expect(dialog.getByLabel('Close')).toBeVisible()
})
test('actions menu closes on scroll', async ({ comfyPage }) => {
const assetCard = comfyPage.page.getByRole('button', {
name: /history-asset-1\.png/i
})
await assetCard.hover()
await assetCard.getByRole('button', { name: /more options/i }).click()
const menuPanel = comfyPage.page.locator('.media-asset-menu-panel')
await expect(menuPanel).toBeVisible()
await comfyPage.page.evaluate(() => {
window.dispatchEvent(new Event('scroll'))
})
await expect(menuPanel).toBeHidden()
})
})

View File

@@ -0,0 +1,70 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../../fixtures/ComfyPage'
test.describe('Job History Sidebar', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.queue.mockQueueState()
await comfyPage.queue.mockHistory([
{ promptId: 'history-job-1', status: 'success' }
])
await comfyPage.settings.setSetting('Comfy.Queue.QPOV2', true)
await comfyPage.setup({ clearStorage: false })
await comfyPage.page.getByTestId('queue-overlay-toggle').click()
await expect(
comfyPage.page.locator('[data-job-id="history-job-1"]')
).toBeVisible()
})
test('right-click menu can inspect a completed job asset', async ({
comfyPage
}) => {
const jobRow = comfyPage.page.locator('[data-job-id="history-job-1"]')
await jobRow.click({ button: 'right' })
const menuPanel = comfyPage.page.locator('.job-menu-panel')
await expect(menuPanel).toBeVisible()
await menuPanel.getByRole('menuitem', { name: /inspect asset/i }).click()
const dialog = comfyPage.page.getByRole('dialog')
await expect(dialog).toBeVisible()
await expect(dialog.getByLabel('Close')).toBeVisible()
})
test('hover popover and actions menu stay clickable', async ({
comfyPage
}) => {
const jobRow = comfyPage.page.locator('[data-job-id="history-job-1"]')
await jobRow.hover()
const popover = comfyPage.page.locator('.job-details-popover')
await expect(popover).toBeVisible()
await popover.getByRole('button', { name: /^copy$/i }).click()
await jobRow.hover()
const moreButton = jobRow.locator('.job-actions-menu-trigger')
await expect(moreButton).toBeVisible()
await moreButton.click()
const menuPanel = comfyPage.page.locator('.job-menu-panel')
await expect(menuPanel).toBeVisible()
const box = await menuPanel.boundingBox()
if (!box) {
throw new Error('Job actions menu did not render a bounding box')
}
await comfyPage.page.mouse.move(
box.x + box.width / 2,
box.y + Math.min(box.height / 2, 24)
)
await expect(menuPanel).toBeVisible()
await menuPanel.getByRole('menuitem', { name: /copy job id/i }).click()
await expect(menuPanel).toBeHidden()
})
})

View File

@@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest'
import type { JobListItem } from '@/composables/queue/useJobList'
vi.mock('@/composables/queue/useJobMenu', () => ({
useJobMenu: () => ({ jobMenuEntries: [] })
useJobMenu: () => ({ getJobMenuEntries: () => [] })
}))
vi.mock('@/composables/useErrorHandling', () => ({
@@ -30,10 +30,6 @@ const JobAssetsListStub = {
template: '<div class="job-assets-list-stub" />'
}
const JobContextMenuStub = {
template: '<div />'
}
const createJob = (): JobListItem => ({
id: 'job-1',
title: 'Job 1',
@@ -56,8 +52,7 @@ const mountComponent = () =>
stubs: {
QueueOverlayHeader: QueueOverlayHeaderStub,
JobFiltersBar: JobFiltersBarStub,
JobAssetsList: JobAssetsListStub,
JobContextMenu: JobContextMenuStub
JobAssetsList: JobAssetsListStub
}
}
})

View File

@@ -23,36 +23,28 @@
<div class="min-h-0 flex-1 overflow-y-auto">
<JobAssetsList
:displayed-job-groups="displayedJobGroups"
:get-menu-entries="getJobMenuEntries"
@cancel-item="onCancelItemEvent"
@delete-item="onDeleteItemEvent"
@menu-action="onJobMenuAction"
@view-item="$emit('viewItem', $event)"
@menu="onMenuItem"
/>
</div>
<JobContextMenu
ref="jobContextMenuRef"
:entries="jobMenuEntries"
@action="onJobMenuAction"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type {
JobGroup,
JobListItem,
JobSortMode,
JobTab
} from '@/composables/queue/useJobList'
import type { MenuEntry } from '@/composables/queue/useJobMenu'
import type { MenuActionEntry } from '@/types/menuTypes'
import { useJobMenu } from '@/composables/queue/useJobMenu'
import { useErrorHandling } from '@/composables/useErrorHandling'
import QueueOverlayHeader from './QueueOverlayHeader.vue'
import JobContextMenu from './job/JobContextMenu.vue'
import JobAssetsList from './job/JobAssetsList.vue'
import JobFiltersBar from './job/JobFiltersBar.vue'
@@ -78,14 +70,9 @@ const emit = defineEmits<{
(e: 'viewItem', item: JobListItem): void
}>()
const currentMenuItem = ref<JobListItem | null>(null)
const jobContextMenuRef = ref<InstanceType<typeof JobContextMenu> | null>(null)
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const { jobMenuEntries } = useJobMenu(
() => currentMenuItem.value,
(item) => emit('viewItem', item)
)
const { getJobMenuEntries } = useJobMenu((item) => emit('viewItem', item))
const onCancelItemEvent = (item: JobListItem) => {
emit('cancelItem', item)
@@ -95,14 +82,9 @@ const onDeleteItemEvent = (item: JobListItem) => {
emit('deleteItem', item)
}
const onMenuItem = (item: JobListItem, event: Event) => {
currentMenuItem.value = item
jobContextMenuRef.value?.open(event)
}
const onJobMenuAction = wrapWithErrorHandlingAsync(async (entry: MenuEntry) => {
if (entry.kind === 'divider') return
if (entry.onClick) await entry.onClick()
jobContextMenuRef.value?.hide()
})
const onJobMenuAction = wrapWithErrorHandlingAsync(
async (entry: MenuActionEntry) => {
if (entry.onClick) await entry.onClick()
}
)
</script>

View File

@@ -3,11 +3,20 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick } from 'vue'
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
import type { JobListItem as ApiJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
import JobAssetsList from './JobAssetsList.vue'
type JobPreviewOutput = NonNullable<
NonNullable<JobListItem['taskRef']>['previewOutput']
>
vi.mock('@/components/queue/job/JobDetailsPopover.vue', () => ({
default: {
name: 'JobDetailsPopover',
template: '<div class="job-details-popover-stub" />'
}
}))
const JobDetailsPopoverStub = defineComponent({
name: 'JobDetailsPopover',
props: {
@@ -32,43 +41,32 @@ vi.mock('vue-i18n', () => {
}
})
const createResultItem = (
const createPreviewOutput = (
filename: string,
mediaType: string = 'images'
): ResultItemImpl => {
const item = new ResultItemImpl({
): JobPreviewOutput =>
({
filename,
subfolder: '',
type: 'output',
nodeId: 'node-1',
mediaType
})
Object.defineProperty(item, 'url', {
get: () => `/api/view/${filename}`
})
return item
}
mediaType,
isImage: mediaType === 'images',
isVideo: mediaType === 'video',
isAudio: mediaType === 'audio',
is3D: mediaType === 'model',
url: `/api/view/${filename}`
}) as JobPreviewOutput
const createTaskRef = (preview?: ResultItemImpl): TaskItemImpl => {
const job: ApiJobListItem = {
id: `task-${Math.random().toString(36).slice(2)}`,
status: 'completed',
create_time: Date.now(),
preview_output: null,
outputs_count: preview ? 1 : 0,
workflow_id: 'workflow-1',
priority: 0
}
const flatOutputs = preview ? [preview] : []
return new TaskItemImpl(job, {}, flatOutputs)
}
const createTaskRef = (preview?: JobPreviewOutput): JobListItem['taskRef'] =>
({
workflowId: 'workflow-1',
previewOutput: preview
}) as JobListItem['taskRef']
const buildJob = (overrides: Partial<JobListItem> = {}): JobListItem => ({
id: 'job-1',
title: 'Job 1',
meta: 'meta',
state: 'completed',
taskRef: createTaskRef(createResultItem('job-1.png')),
taskRef: createTaskRef(createPreviewOutput('job-1.png')),
...overrides
})
@@ -82,7 +80,10 @@ const mountJobAssetsList = (jobs: JobListItem[]) => {
]
return mount(JobAssetsList, {
props: { displayedJobGroups },
props: {
displayedJobGroups,
getMenuEntries: () => []
},
global: {
stubs: {
teleport: true,
@@ -147,7 +148,7 @@ describe('JobAssetsList', () => {
it('emits viewItem on double-click for completed video jobs without icon image', async () => {
const job = buildJob({
iconImageUrl: undefined,
taskRef: createTaskRef(createResultItem('job-1.webm', 'video'))
taskRef: createTaskRef(createPreviewOutput('job-1.webm', 'video'))
})
const wrapper = mountJobAssetsList([job])
@@ -164,7 +165,7 @@ describe('JobAssetsList', () => {
it('emits viewItem on icon click for completed 3D jobs without preview tile', async () => {
const job = buildJob({
iconImageUrl: undefined,
taskRef: createTaskRef(createResultItem('job-1.glb', 'model'))
taskRef: createTaskRef(createPreviewOutput('job-1.glb', 'model'))
})
const wrapper = mountJobAssetsList([job])
@@ -179,7 +180,7 @@ describe('JobAssetsList', () => {
it('does not emit viewItem on double-click for non-completed jobs', async () => {
const job = buildJob({
state: 'running',
taskRef: createTaskRef(createResultItem('job-1.png'))
taskRef: createTaskRef(createPreviewOutput('job-1.png'))
})
const wrapper = mountJobAssetsList([job])

View File

@@ -15,64 +15,97 @@
@mouseenter="onJobEnter(job, $event)"
@mouseleave="onJobLeave(job.id)"
>
<AssetsListItem
:class="
cn(
'w-full shrink-0 cursor-default text-text-primary transition-colors hover:bg-secondary-background-hover',
job.state === 'running' && 'bg-secondary-background'
)
"
:preview-url="getJobPreviewUrl(job)"
:is-video-preview="isVideoPreviewJob(job)"
:preview-alt="job.title"
:icon-name="job.iconName ?? iconForJobState(job.state)"
:icon-class="getJobIconClass(job)"
:primary-text="job.title"
:secondary-text="job.meta"
:progress-total-percent="job.progressTotalPercent"
:progress-current-percent="job.progressCurrentPercent"
@contextmenu.prevent.stop="$emit('menu', job, $event)"
@dblclick.stop="emitViewItem(job)"
@preview-click="emitViewItem(job)"
@click.stop
>
<template v-if="hoveredJobId === job.id" #actions>
<Button
v-if="isCancelable(job)"
variant="destructive"
size="icon"
:aria-label="t('g.cancel')"
@click.stop="emitCancelItem(job)"
<ContextMenu :modal="false">
<ContextMenuTrigger as-child>
<AssetsListItem
:class="
cn(
'w-full shrink-0 cursor-default text-text-primary transition-colors hover:bg-secondary-background-hover',
job.state === 'running' && 'bg-secondary-background'
)
"
:preview-url="getJobPreviewUrl(job)"
:is-video-preview="isVideoPreviewJob(job)"
:preview-alt="job.title"
:icon-name="job.iconName ?? iconForJobState(job.state)"
:icon-class="getJobIconClass(job)"
:primary-text="job.title"
:secondary-text="job.meta"
:progress-total-percent="job.progressTotalPercent"
:progress-current-percent="job.progressCurrentPercent"
@dblclick.stop="emitViewItem(job)"
@preview-click="emitViewItem(job)"
@click.stop
>
<i class="icon-[lucide--x] size-4" />
</Button>
<Button
v-else-if="isFailedDeletable(job)"
variant="destructive"
size="icon"
:aria-label="t('g.delete')"
@click.stop="emitDeleteItem(job)"
>
<i class="icon-[lucide--trash-2] size-4" />
</Button>
<Button
v-else-if="job.state === 'completed'"
variant="textonly"
size="sm"
@click.stop="emitCompletedViewItem(job)"
>
{{ t('menuLabels.View') }}
</Button>
<Button
variant="secondary"
size="icon"
:aria-label="t('g.more')"
@click.stop="$emit('menu', job, $event)"
>
<i class="icon-[lucide--ellipsis] size-4" />
</Button>
</template>
</AssetsListItem>
<template v-if="shouldShowActionsMenu(job.id)" #actions>
<Button
v-if="isCancelable(job)"
variant="destructive"
size="icon"
:aria-label="t('g.cancel')"
@click.stop="emitCancelItem(job)"
>
<i class="icon-[lucide--x] size-4" />
</Button>
<Button
v-else-if="isFailedDeletable(job)"
variant="destructive"
size="icon"
:aria-label="t('g.delete')"
@click.stop="emitDeleteItem(job)"
>
<i class="icon-[lucide--trash-2] size-4" />
</Button>
<Button
v-else-if="job.state === 'completed'"
variant="textonly"
size="sm"
@click.stop="emitCompletedViewItem(job)"
>
{{ t('menuLabels.View') }}
</Button>
<DropdownMenu
:open="openActionsJobId === job.id"
@update:open="onActionsMenuOpenChange(job.id, $event)"
>
<DropdownMenuTrigger as-child>
<Button
class="job-actions-menu-trigger"
variant="secondary"
size="icon"
:aria-label="t('g.more')"
@click.stop
>
<i class="icon-[lucide--ellipsis] size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
close-on-scroll
class="z-1700 bg-transparent p-0 shadow-lg"
:side-offset="4"
:collision-padding="8"
>
<JobMenuItems
:entries="getMenuEntries(job)"
surface="dropdown"
@action="onMenuAction($event)"
/>
</DropdownMenuContent>
</DropdownMenu>
</template>
</AssetsListItem>
</ContextMenuTrigger>
<ContextMenuContent
close-on-scroll
class="z-1700 bg-transparent p-0 font-inter shadow-lg"
>
<JobMenuItems
:entries="getMenuEntries(job)"
surface="context"
@action="onMenuAction($event)"
/>
</ContextMenuContent>
</ContextMenu>
</div>
</div>
</div>
@@ -80,7 +113,7 @@
<Teleport to="body">
<div
v-if="activeDetails && popoverPosition"
class="job-details-popover fixed z-50"
class="job-details-popover fixed z-1700"
:style="{
top: `${popoverPosition.top}px`,
left: `${popoverPosition.left}px`
@@ -103,24 +136,36 @@ import { nextTick, ref } from 'vue'
import JobDetailsPopover from '@/components/queue/job/JobDetailsPopover.vue'
import { getHoverPopoverPosition } from '@/components/queue/job/getHoverPopoverPosition'
import Button from '@/components/ui/button/Button.vue'
import ContextMenu from '@/components/ui/context-menu/ContextMenu.vue'
import ContextMenuContent from '@/components/ui/context-menu/ContextMenuContent.vue'
import ContextMenuTrigger from '@/components/ui/context-menu/ContextMenuTrigger.vue'
import DropdownMenu from '@/components/ui/dropdown-menu/DropdownMenu.vue'
import DropdownMenuContent from '@/components/ui/dropdown-menu/DropdownMenuContent.vue'
import DropdownMenuTrigger from '@/components/ui/dropdown-menu/DropdownMenuTrigger.vue'
import type { MenuActionEntry, MenuEntry } from '@/types/menuTypes'
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
import { useJobDetailsHover } from '@/composables/queue/useJobDetailsHover'
import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
import { cn } from '@/utils/tailwindUtil'
import { iconForJobState } from '@/utils/queueDisplay'
import { isActiveJobState } from '@/utils/queueUtil'
import JobMenuItems from './JobMenuItems.vue'
const { displayedJobGroups } = defineProps<{ displayedJobGroups: JobGroup[] }>()
const { displayedJobGroups, getMenuEntries } = defineProps<{
displayedJobGroups: JobGroup[]
getMenuEntries: (item: JobListItem) => MenuEntry[]
}>()
const emit = defineEmits<{
(e: 'cancelItem', item: JobListItem): void
(e: 'deleteItem', item: JobListItem): void
(e: 'menu', item: JobListItem, ev: MouseEvent): void
(e: 'menu-action', entry: MenuActionEntry): void
(e: 'viewItem', item: JobListItem): void
}>()
const { t } = useI18n()
const hoveredJobId = ref<string | null>(null)
const openActionsJobId = ref<string | null>(null)
const activeRowElement = ref<HTMLElement | null>(null)
const popoverPosition = ref<{ top: number; left: number } | null>(null)
const {
@@ -152,7 +197,7 @@ function onJobLeave(jobId: string) {
if (hoveredJobId.value === jobId) {
hoveredJobId.value = null
}
scheduleDetailsHide(jobId, clearPopoverAnchor)
scheduleDetailsHide(jobId)
}
function onJobEnter(job: JobListItem, event: MouseEvent) {
@@ -188,6 +233,26 @@ function isFailedDeletable(job: JobListItem) {
return job.showClear !== false && job.state === 'failed'
}
function shouldShowActionsMenu(jobId: string) {
return hoveredJobId.value === jobId || openActionsJobId.value === jobId
}
function onActionsMenuOpenChange(jobId: string, isOpen: boolean) {
if (isOpen) {
openActionsJobId.value = jobId
return
}
if (openActionsJobId.value === jobId) {
openActionsJobId.value = null
}
}
function onMenuAction(entry: MenuActionEntry) {
resetActiveDetails()
emit('menu-action', entry)
}
function getPreviewOutput(job: JobListItem) {
return job.taskRef?.previewOutput
}
@@ -235,7 +300,7 @@ function onPopoverEnter() {
}
function onPopoverLeave() {
scheduleDetailsHide(activeDetails.value?.jobId, clearPopoverAnchor)
scheduleDetailsHide(activeDetails.value?.jobId)
}
function getJobIconClass(job: JobListItem): string | undefined {

View File

@@ -1,195 +0,0 @@
import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick } from 'vue'
import JobContextMenu from '@/components/queue/job/JobContextMenu.vue'
import type { MenuEntry } from '@/composables/queue/useJobMenu'
const popoverStub = defineComponent({
name: 'Popover',
emits: ['show', 'hide'],
data() {
return {
visible: false,
container: null as HTMLElement | null,
eventTarget: null as EventTarget | null,
target: null as EventTarget | null
}
},
mounted() {
this.container = this.$refs.container as HTMLElement | null
},
updated() {
this.container = this.$refs.container as HTMLElement | null
},
methods: {
toggle(event: Event, target?: EventTarget | null) {
if (this.visible) {
this.hide()
return
}
this.show(event, target)
},
show(event: Event, target?: EventTarget | null) {
this.visible = true
this.eventTarget = event.currentTarget
this.target = target ?? event.currentTarget
this.$emit('show')
},
hide() {
this.visible = false
this.$emit('hide')
}
},
template: `
<div v-if="visible" ref="container" class="popover-stub">
<slot />
</div>
`
})
const buttonStub = {
props: {
disabled: {
type: Boolean,
default: false
}
},
template: `
<div
class="button-stub"
:data-disabled="String(disabled)"
>
<slot />
</div>
`
}
const createEntries = (): MenuEntry[] => [
{ key: 'enabled', label: 'Enabled action', onClick: vi.fn() },
{
key: 'disabled',
label: 'Disabled action',
disabled: true,
onClick: vi.fn()
},
{ kind: 'divider', key: 'divider-1' }
]
const mountComponent = (entries: MenuEntry[]) =>
mount(JobContextMenu, {
props: { entries },
global: {
stubs: {
Popover: popoverStub,
Button: buttonStub
}
}
})
const createTriggerEvent = (type: string, currentTarget: EventTarget) =>
({
type,
currentTarget,
target: currentTarget
}) as Event
const openMenu = async (
wrapper: ReturnType<typeof mountComponent>,
type: string = 'click'
) => {
const trigger = document.createElement('button')
document.body.append(trigger)
await wrapper.vm.open(createTriggerEvent(type, trigger))
await nextTick()
return trigger
}
afterEach(() => {
document.body.innerHTML = ''
})
describe('JobContextMenu', () => {
it('passes disabled state to action buttons', async () => {
const wrapper = mountComponent(createEntries())
await openMenu(wrapper)
const buttons = wrapper.findAll('.button-stub')
expect(buttons).toHaveLength(2)
expect(buttons[0].attributes('data-disabled')).toBe('false')
expect(buttons[1].attributes('data-disabled')).toBe('true')
wrapper.unmount()
})
it('emits action for enabled entries', async () => {
const entries = createEntries()
const wrapper = mountComponent(entries)
await openMenu(wrapper)
await wrapper.findAll('.button-stub')[0].trigger('click')
expect(wrapper.emitted('action')).toEqual([[entries[0]]])
wrapper.unmount()
})
it('does not emit action for disabled entries', async () => {
const wrapper = mountComponent([
{
key: 'disabled',
label: 'Disabled action',
disabled: true,
onClick: vi.fn()
}
])
await openMenu(wrapper)
await wrapper.get('.button-stub').trigger('click')
expect(wrapper.emitted('action')).toBeUndefined()
wrapper.unmount()
})
it('hides on pointerdown outside the popover', async () => {
const wrapper = mountComponent(createEntries())
const trigger = document.createElement('button')
const outside = document.createElement('div')
document.body.append(trigger, outside)
await wrapper.vm.open(createTriggerEvent('contextmenu', trigger))
await nextTick()
expect(wrapper.find('.popover-stub').exists()).toBe(true)
outside.dispatchEvent(new Event('pointerdown', { bubbles: true }))
await nextTick()
expect(wrapper.find('.popover-stub').exists()).toBe(false)
wrapper.unmount()
})
it('keeps the menu open through trigger pointerdown and closes on same trigger click', async () => {
const wrapper = mountComponent(createEntries())
const trigger = document.createElement('button')
document.body.append(trigger)
await wrapper.vm.open(createTriggerEvent('click', trigger))
await nextTick()
expect(wrapper.find('.popover-stub').exists()).toBe(true)
trigger.dispatchEvent(new Event('pointerdown', { bubbles: true }))
await nextTick()
expect(wrapper.find('.popover-stub').exists()).toBe(true)
await wrapper.vm.open(createTriggerEvent('click', trigger))
await nextTick()
expect(wrapper.find('.popover-stub').exists()).toBe(false)
wrapper.unmount()
})
})

View File

@@ -1,118 +0,0 @@
<template>
<Popover
ref="jobItemPopoverRef"
:dismissable="false"
:close-on-escape="true"
unstyled
:pt="{
root: { class: 'absolute z-50' },
content: {
class: [
'bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg font-inter'
]
}
}"
@show="isVisible = true"
@hide="onHide"
>
<div
ref="contentRef"
class="flex min-w-56 flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3 font-inter"
>
<template v-for="entry in entries" :key="entry.key">
<div v-if="entry.kind === 'divider'" class="px-2 py-1">
<div class="h-px bg-interface-stroke" />
</div>
<Button
v-else
class="w-full justify-start bg-transparent"
variant="textonly"
size="sm"
:aria-label="entry.label"
:disabled="entry.disabled"
@click="onEntry(entry)"
>
<i
v-if="entry.icon"
:class="[
entry.icon,
'block size-4 shrink-0 leading-none text-text-secondary'
]"
/>
<span>{{ entry.label }}</span>
</Button>
</template>
</div>
</Popover>
</template>
<script setup lang="ts">
import Popover from 'primevue/popover'
import { nextTick, ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useDismissableOverlay } from '@/composables/useDismissableOverlay'
import type { MenuEntry } from '@/composables/queue/useJobMenu'
defineProps<{ entries: MenuEntry[] }>()
const emit = defineEmits<{
(e: 'action', entry: MenuEntry): void
}>()
type PopoverHandle = {
hide: () => void
show: (event: Event, target?: EventTarget | null) => void
}
const jobItemPopoverRef = ref<PopoverHandle | null>(null)
const contentRef = ref<HTMLElement | null>(null)
const triggerRef = ref<HTMLElement | null>(null)
const isVisible = ref(false)
const openedByClick = ref(false)
useDismissableOverlay({
isOpen: isVisible,
getOverlayEl: () => contentRef.value,
getTriggerEl: () => (openedByClick.value ? triggerRef.value : null),
onDismiss: hide
})
async function open(event: Event) {
const trigger =
event.currentTarget instanceof HTMLElement ? event.currentTarget : null
const isSameClickTrigger =
event.type === 'click' && trigger === triggerRef.value && isVisible.value
if (isSameClickTrigger) {
hide()
return
}
openedByClick.value = event.type === 'click'
triggerRef.value = trigger
if (isVisible.value) {
hide()
await nextTick()
}
jobItemPopoverRef.value?.show(event, trigger)
}
function hide() {
jobItemPopoverRef.value?.hide()
}
function onHide() {
isVisible.value = false
openedByClick.value = false
}
function onEntry(entry: MenuEntry) {
if (entry.kind === 'divider' || entry.disabled) return
emit('action', entry)
}
defineExpose({ open, hide })
</script>

View File

@@ -0,0 +1,88 @@
<script setup lang="ts">
import type { MenuActionEntry, MenuEntry } from '@/types/menuTypes'
import Button from '@/components/ui/button/Button.vue'
import ContextMenuItem from '@/components/ui/context-menu/ContextMenuItem.vue'
import ContextMenuSeparator from '@/components/ui/context-menu/ContextMenuSeparator.vue'
import DropdownMenuItem from '@/components/ui/dropdown-menu/DropdownMenuItem.vue'
import DropdownMenuSeparator from '@/components/ui/dropdown-menu/DropdownMenuSeparator.vue'
import { cn } from '@/utils/tailwindUtil'
const { entries, surface } = defineProps<{
entries: MenuEntry[]
surface: 'context' | 'dropdown'
}>()
const emit = defineEmits<{
action: [entry: MenuActionEntry]
}>()
function isActionEntry(entry: MenuEntry): entry is MenuActionEntry {
return entry.kind !== 'divider'
}
</script>
<template>
<div
class="job-menu-panel flex min-w-56 flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3 font-inter"
>
<template v-for="entry in entries" :key="entry.key">
<ContextMenuSeparator
v-if="surface === 'context' && entry.kind === 'divider'"
class="mx-2 my-1 h-px bg-interface-stroke"
/>
<DropdownMenuSeparator
v-else-if="surface === 'dropdown' && entry.kind === 'divider'"
class="mx-2 my-1 h-px bg-interface-stroke"
/>
<ContextMenuItem
v-else-if="surface === 'context' && isActionEntry(entry)"
as-child
:disabled="entry.disabled"
:text-value="entry.label"
@select="emit('action', entry)"
>
<Button
variant="textonly"
size="sm"
class="w-full justify-start bg-transparent data-highlighted:bg-secondary-background-hover"
>
<i
v-if="entry.icon"
:class="
cn(
entry.icon,
'block size-4 shrink-0 leading-none text-text-secondary'
)
"
/>
<span>{{ entry.label }}</span>
</Button>
</ContextMenuItem>
<DropdownMenuItem
v-else-if="isActionEntry(entry)"
as-child
:disabled="entry.disabled"
:text-value="entry.label"
@select="emit('action', entry)"
>
<Button
variant="textonly"
size="sm"
class="w-full justify-start bg-transparent data-highlighted:bg-secondary-background-hover"
>
<i
v-if="entry.icon"
:class="
cn(
entry.icon,
'block size-4 shrink-0 leading-none text-text-secondary'
)
"
/>
<span>{{ entry.label }}</span>
</Button>
</DropdownMenuItem>
</template>
</div>
</template>

View File

@@ -9,7 +9,7 @@
<Teleport to="body">
<div
v-if="!isPreviewVisible && showDetails && popoverPosition"
class="fixed z-50"
class="fixed z-1700"
:style="{
top: `${popoverPosition.top}px`,
left: `${popoverPosition.left}px`
@@ -23,7 +23,7 @@
<Teleport to="body">
<div
v-if="isPreviewVisible && canShowPreview && popoverPosition"
class="fixed z-50"
class="fixed z-1700"
:style="{
top: `${popoverPosition.top}px`,
left: `${popoverPosition.left}px`

View File

@@ -9,15 +9,56 @@
>
<template #item="{ item }">
<MediaAssetCard
v-if="assetsStore.isAssetDeleting(item.asset.id)"
:asset="item.asset"
:selected="isSelected(item.asset.id)"
:show-output-count="showOutputCount(item.asset)"
:output-count="getOutputCount(item.asset)"
:show-delete-button="showDeleteButton"
:selected-assets="selectedAssets"
:is-bulk-mode="isBulkMode"
@click="emit('select-asset', item.asset)"
@context-menu="emit('context-menu', $event, item.asset)"
@zoom="emit('zoom', item.asset)"
@asset-deleted="emit('asset-deleted')"
@bulk-download="emit('bulk-download', $event)"
@bulk-delete="emit('bulk-delete', $event)"
@bulk-add-to-workflow="emit('bulk-add-to-workflow', $event)"
@bulk-open-workflow="emit('bulk-open-workflow', $event)"
@bulk-export-workflow="emit('bulk-export-workflow', $event)"
@output-count-click="emit('output-count-click', item.asset)"
/>
<ContextMenu v-else :modal="false">
<ContextMenuTrigger as-child>
<MediaAssetCard
:asset="item.asset"
:selected="isSelected(item.asset.id)"
:show-output-count="showOutputCount(item.asset)"
:output-count="getOutputCount(item.asset)"
:show-delete-button="showDeleteButton"
:selected-assets="selectedAssets"
:is-bulk-mode="isBulkMode"
@click="emit('select-asset', item.asset)"
@zoom="emit('zoom', item.asset)"
@asset-deleted="emit('asset-deleted')"
@bulk-download="emit('bulk-download', $event)"
@bulk-delete="emit('bulk-delete', $event)"
@bulk-add-to-workflow="emit('bulk-add-to-workflow', $event)"
@bulk-open-workflow="emit('bulk-open-workflow', $event)"
@bulk-export-workflow="emit('bulk-export-workflow', $event)"
@output-count-click="emit('output-count-click', item.asset)"
/>
</ContextMenuTrigger>
<ContextMenuContent
close-on-scroll
class="z-1700 bg-transparent p-0 shadow-lg"
>
<MediaAssetMenuItems
:entries="getAssetMenuEntries(item.asset)"
surface="context"
@action="void onAssetMenuAction($event)"
/>
</ContextMenuContent>
</ContextMenu>
</template>
</VirtualGrid>
</div>
@@ -27,24 +68,60 @@
import { computed } from 'vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import ContextMenu from '@/components/ui/context-menu/ContextMenu.vue'
import ContextMenuContent from '@/components/ui/context-menu/ContextMenuContent.vue'
import ContextMenuTrigger from '@/components/ui/context-menu/ContextMenuTrigger.vue'
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
import MediaAssetMenuItems from '@/platform/assets/components/MediaAssetMenuItems.vue'
import { useMediaAssetMenu } from '@/platform/assets/composables/useMediaAssetMenu'
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useAssetsStore } from '@/stores/assetsStore'
import type { MenuActionEntry, MenuEntry } from '@/types/menuTypes'
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
const { assets, isSelected, showOutputCount, getOutputCount } = defineProps<{
const {
assets,
isSelected,
showOutputCount,
getOutputCount,
showDeleteButton,
selectedAssets,
isBulkMode
} = defineProps<{
assets: AssetItem[]
isSelected: (assetId: string) => boolean
showOutputCount: (asset: AssetItem) => boolean
getOutputCount: (asset: AssetItem) => number
showDeleteButton?: boolean
selectedAssets?: AssetItem[]
isBulkMode?: boolean
}>()
const emit = defineEmits<{
(e: 'select-asset', asset: AssetItem): void
(e: 'context-menu', event: MouseEvent, asset: AssetItem): void
(e: 'approach-end'): void
(e: 'zoom', asset: AssetItem): void
(e: 'asset-deleted'): void
(e: 'bulk-download', assets: AssetItem[]): void
(e: 'bulk-delete', assets: AssetItem[]): void
(e: 'bulk-add-to-workflow', assets: AssetItem[]): void
(e: 'bulk-open-workflow', assets: AssetItem[]): void
(e: 'bulk-export-workflow', assets: AssetItem[]): void
(e: 'output-count-click', asset: AssetItem): void
}>()
const assetsStore = useAssetsStore()
const { getMenuEntries } = useMediaAssetMenu({
inspectAsset: (asset) => emit('zoom', asset),
assetDeleted: () => emit('asset-deleted'),
bulkDownload: (assets) => emit('bulk-download', assets),
bulkDelete: (assets) => emit('bulk-delete', assets),
bulkAddToWorkflow: (assets) => emit('bulk-add-to-workflow', assets),
bulkOpenWorkflow: (assets) => emit('bulk-open-workflow', assets),
bulkExportWorkflow: (assets) => emit('bulk-export-workflow', assets)
})
type AssetGridItem = { key: string; asset: AssetItem }
const assetItems = computed<AssetGridItem[]>(() =>
@@ -54,6 +131,21 @@ const assetItems = computed<AssetGridItem[]>(() =>
}))
)
function getAssetMenuEntries(asset: AssetItem): MenuEntry[] {
return getMenuEntries({
asset,
assetType: getAssetType(asset.tags),
fileKind: getMediaTypeFromFilename(asset.name),
showDeleteButton,
selectedAssets,
isBulkMode
})
}
async function onAssetMenuAction(entry: MenuActionEntry) {
await entry.onClick?.()
}
const gridStyle = {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(min(200px, 30vw), 1fr))',

View File

@@ -1,5 +1,6 @@
import { mount } from '@vue/test-utils'
import { defineComponent } from 'vue'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import { describe, expect, it, vi } from 'vitest'
import type { OutputStackListItem } from '@/platform/assets/composables/useOutputStacks'
@@ -7,9 +8,9 @@ import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import AssetsSidebarListView from './AssetsSidebarListView.vue'
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key
vi.mock('@/platform/assets/composables/useMediaAssetMenu', () => ({
useMediaAssetMenu: () => ({
getMenuEntries: () => []
})
}))
@@ -19,7 +20,18 @@ vi.mock('@/stores/assetsStore', () => ({
})
}))
const VirtualGridStub = defineComponent({
const i18n = createI18n({
legacy: false,
locale: 'en',
fallbackLocale: 'en',
missingWarn: false,
fallbackWarn: false,
messages: {
en: {}
}
})
const VirtualGridStub = {
name: 'VirtualGrid',
props: {
items: {
@@ -29,7 +41,59 @@ const VirtualGridStub = defineComponent({
},
template:
'<div><slot v-for="item in items" :key="item.key" name="item" :item="item" /></div>'
})
}
const AssetsListItemStub = {
name: 'AssetsListItem',
template:
'<div class="assets-list-item-stub"><slot /><slot name="actions" /></div>'
}
const ContextMenuStub = {
name: 'ContextMenu',
template: '<div class="context-menu-stub"><slot /></div>'
}
const ContextMenuTriggerStub = {
name: 'ContextMenuTrigger',
template: '<div class="context-menu-trigger-stub"><slot /></div>'
}
const ContextMenuContentStub = {
name: 'ContextMenuContent',
template: '<div class="context-menu-content-stub"><slot /></div>'
}
const DropdownMenuStub = {
name: 'DropdownMenu',
props: {
open: {
type: Boolean,
default: false
}
},
template: '<div class="dropdown-menu-stub"><slot /></div>'
}
const DropdownMenuTriggerStub = {
name: 'DropdownMenuTrigger',
template: '<div class="dropdown-menu-trigger-stub"><slot /></div>'
}
const DropdownMenuContentStub = {
name: 'DropdownMenuContent',
template: '<div class="dropdown-menu-content-stub"><slot /></div>'
}
const ButtonComponentStub = {
name: 'AppButton',
template: '<button class="button-stub" type="button"><slot /></button>'
}
const MediaAssetMenuItemsStub = {
name: 'MediaAssetMenuItems',
template: '<div class="media-asset-menu-items-stub" />'
}
const buildAsset = (id: string, name: string): AssetItem =>
({
@@ -53,7 +117,41 @@ const mountListView = (assetItems: OutputStackListItem[] = []) =>
toggleStack: async () => {}
},
global: {
plugins: [i18n],
stubs: {
ContextMenu: ContextMenuStub,
ContextMenuTrigger: ContextMenuTriggerStub,
ContextMenuContent: ContextMenuContentStub,
DropdownMenu: DropdownMenuStub,
DropdownMenuTrigger: DropdownMenuTriggerStub,
DropdownMenuContent: DropdownMenuContentStub,
MediaAssetMenuItems: MediaAssetMenuItemsStub,
VirtualGrid: VirtualGridStub
}
}
})
const mountInteractiveListView = (assetItems: OutputStackListItem[] = []) =>
mount(AssetsSidebarListView, {
props: {
assetItems,
selectableAssets: [],
isSelected: () => false,
isStackExpanded: () => false,
toggleStack: async () => {}
},
global: {
plugins: [i18n],
stubs: {
AssetsListItem: AssetsListItemStub,
Button: ButtonComponentStub,
ContextMenu: ContextMenuStub,
ContextMenuTrigger: ContextMenuTriggerStub,
ContextMenuContent: ContextMenuContentStub,
DropdownMenu: DropdownMenuStub,
DropdownMenuTrigger: DropdownMenuTriggerStub,
DropdownMenuContent: DropdownMenuContentStub,
MediaAssetMenuItems: MediaAssetMenuItemsStub,
VirtualGrid: VirtualGridStub
}
}
@@ -131,4 +229,46 @@ describe('AssetsSidebarListView', () => {
expect(wrapper.emitted('preview-asset')).toEqual([[imageAsset]])
})
it('keeps the row actions menu available after the row loses hover', async () => {
const imageAsset = {
...buildAsset('image-asset-open', 'image.png'),
user_metadata: {}
} satisfies AssetItem
const wrapper = mountInteractiveListView([buildOutputItem(imageAsset)])
const assetListItem = wrapper.find('.assets-list-item-stub')
await assetListItem.trigger('mouseenter')
const actionsMenu = wrapper.findComponent(DropdownMenuStub)
expect(actionsMenu.exists()).toBe(true)
actionsMenu.vm.$emit('update:open', true)
await nextTick()
await assetListItem.trigger('mouseleave')
await nextTick()
expect(wrapper.findComponent(DropdownMenuStub).exists()).toBe(true)
wrapper.findComponent(DropdownMenuStub).vm.$emit('update:open', false)
await nextTick()
expect(wrapper.findComponent(DropdownMenuStub).exists()).toBe(false)
})
it('does not select the row when clicking the actions trigger', async () => {
const imageAsset = {
...buildAsset('image-asset-actions', 'image.png'),
user_metadata: {}
} satisfies AssetItem
const wrapper = mountInteractiveListView([buildOutputItem(imageAsset)])
const assetListItem = wrapper.find('.assets-list-item-stub')
await assetListItem.trigger('mouseenter')
await wrapper.find('.button-stub').trigger('click')
expect(wrapper.emitted('select-asset')).toBeUndefined()
})
})

View File

@@ -16,49 +16,83 @@
>
<i class="pi pi-trash text-xs" />
</LoadingOverlay>
<AssetsListItem
role="button"
tabindex="0"
:aria-label="
t('assetBrowser.ariaLabel.assetCard', {
name: getAssetDisplayName(item.asset),
type: getAssetMediaType(item.asset)
})
"
:class="
cn(
getAssetCardClass(isSelected(item.asset.id)),
item.isChild && 'pl-6'
)
"
:preview-url="getAssetPreviewUrl(item.asset)"
:preview-alt="getAssetDisplayName(item.asset)"
:icon-name="iconForMediaType(getAssetMediaType(item.asset))"
:is-video-preview="isVideoAsset(item.asset)"
:primary-text="getAssetPrimaryText(item.asset)"
:secondary-text="getAssetSecondaryText(item.asset)"
:stack-count="getStackCount(item.asset)"
:stack-indicator-label="t('mediaAsset.actions.seeMoreOutputs')"
:stack-expanded="isStackExpanded(item.asset)"
@mouseenter="onAssetEnter(item.asset.id)"
@mouseleave="onAssetLeave(item.asset.id)"
@contextmenu.prevent.stop="emit('context-menu', $event, item.asset)"
@click.stop="emit('select-asset', item.asset, selectableAssets)"
@dblclick.stop="emit('preview-asset', item.asset)"
@preview-click="emit('preview-asset', item.asset)"
@stack-toggle="void toggleStack(item.asset)"
>
<template v-if="hoveredAssetId === item.asset.id" #actions>
<Button
variant="secondary"
size="icon"
:aria-label="t('mediaAsset.actions.moreOptions')"
@click.stop="emit('context-menu', $event, item.asset)"
<ContextMenu :modal="false">
<ContextMenuTrigger as-child>
<AssetsListItem
role="button"
tabindex="0"
:aria-label="
t('assetBrowser.ariaLabel.assetCard', {
name: getAssetDisplayName(item.asset),
type: getAssetMediaType(item.asset)
})
"
:class="
cn(
getAssetCardClass(isSelected(item.asset.id)),
item.isChild && 'pl-6'
)
"
:preview-url="getAssetPreviewUrl(item.asset)"
:preview-alt="getAssetDisplayName(item.asset)"
:icon-name="iconForMediaType(getAssetMediaType(item.asset))"
:is-video-preview="isVideoAsset(item.asset)"
:primary-text="getAssetPrimaryText(item.asset)"
:secondary-text="getAssetSecondaryText(item.asset)"
:stack-count="getStackCount(item.asset)"
:stack-indicator-label="t('mediaAsset.actions.seeMoreOutputs')"
:stack-expanded="isStackExpanded(item.asset)"
@mouseenter="onAssetEnter(item.asset.id)"
@mouseleave="onAssetLeave(item.asset.id)"
@click.stop="emit('select-asset', item.asset, selectableAssets)"
@dblclick.stop="emit('preview-asset', item.asset)"
@preview-click="emit('preview-asset', item.asset)"
@stack-toggle="void toggleStack(item.asset)"
>
<i class="icon-[lucide--ellipsis] size-4" />
</Button>
</template>
</AssetsListItem>
<template v-if="shouldShowActionsMenu(item.asset.id)" #actions>
<DropdownMenu
:open="openActionsAssetId === item.asset.id"
@update:open="
onActionsMenuOpenChange(item.asset.id, $event)
"
>
<DropdownMenuTrigger as-child>
<Button
variant="secondary"
size="icon"
:aria-label="t('mediaAsset.actions.moreOptions')"
@click.stop
>
<i class="icon-[lucide--ellipsis] size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
close-on-scroll
class="z-1700 bg-transparent p-0 shadow-lg"
:side-offset="4"
:collision-padding="8"
>
<MediaAssetMenuItems
:entries="getAssetMenuEntries(item.asset)"
surface="dropdown"
@action="void onAssetMenuAction($event)"
/>
</DropdownMenuContent>
</DropdownMenu>
</template>
</AssetsListItem>
</ContextMenuTrigger>
<ContextMenuContent
close-on-scroll
class="z-1700 bg-transparent p-0 shadow-lg"
>
<MediaAssetMenuItems
:entries="getAssetMenuEntries(item.asset)"
surface="context"
@action="void onAssetMenuAction($event)"
/>
</ContextMenuContent>
</ContextMenu>
</div>
</template>
</VirtualGrid>
@@ -72,13 +106,23 @@ import { useI18n } from 'vue-i18n'
import LoadingOverlay from '@/components/common/LoadingOverlay.vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import Button from '@/components/ui/button/Button.vue'
import ContextMenu from '@/components/ui/context-menu/ContextMenu.vue'
import ContextMenuContent from '@/components/ui/context-menu/ContextMenuContent.vue'
import ContextMenuTrigger from '@/components/ui/context-menu/ContextMenuTrigger.vue'
import DropdownMenu from '@/components/ui/dropdown-menu/DropdownMenu.vue'
import DropdownMenuContent from '@/components/ui/dropdown-menu/DropdownMenuContent.vue'
import DropdownMenuTrigger from '@/components/ui/dropdown-menu/DropdownMenuTrigger.vue'
import { useMediaAssetMenu } from '@/platform/assets/composables/useMediaAssetMenu'
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
import MediaAssetMenuItems from '@/platform/assets/components/MediaAssetMenuItems.vue'
import type { OutputStackListItem } from '@/platform/assets/composables/useOutputStacks'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { getAssetDisplayName } from '@/platform/assets/utils/assetMetadataUtils'
import { iconForMediaType } from '@/platform/assets/utils/mediaIconUtil'
import { useAssetsStore } from '@/stores/assetsStore'
import type { MenuActionEntry, MenuEntry } from '@/types/menuTypes'
import {
formatDuration,
formatSize,
@@ -92,13 +136,19 @@ const {
selectableAssets,
isSelected,
isStackExpanded,
toggleStack
toggleStack,
showDeleteButton,
selectedAssets,
isBulkMode
} = defineProps<{
assetItems: OutputStackListItem[]
selectableAssets: AssetItem[]
isSelected: (assetId: string) => boolean
isStackExpanded: (asset: AssetItem) => boolean
toggleStack: (asset: AssetItem) => Promise<void>
showDeleteButton?: boolean
selectedAssets?: AssetItem[]
isBulkMode?: boolean
}>()
const assetsStore = useAssetsStore()
@@ -106,12 +156,27 @@ const assetsStore = useAssetsStore()
const emit = defineEmits<{
(e: 'select-asset', asset: AssetItem, assets?: AssetItem[]): void
(e: 'preview-asset', asset: AssetItem): void
(e: 'context-menu', event: MouseEvent, asset: AssetItem): void
(e: 'approach-end'): void
(e: 'asset-deleted'): void
(e: 'bulk-download', assets: AssetItem[]): void
(e: 'bulk-delete', assets: AssetItem[]): void
(e: 'bulk-add-to-workflow', assets: AssetItem[]): void
(e: 'bulk-open-workflow', assets: AssetItem[]): void
(e: 'bulk-export-workflow', assets: AssetItem[]): void
}>()
const { t } = useI18n()
const hoveredAssetId = ref<string | null>(null)
const openActionsAssetId = ref<string | null>(null)
const { getMenuEntries } = useMediaAssetMenu({
inspectAsset: (asset) => emit('preview-asset', asset),
assetDeleted: () => emit('asset-deleted'),
bulkDownload: (assets) => emit('bulk-download', assets),
bulkDelete: (assets) => emit('bulk-delete', assets),
bulkAddToWorkflow: (assets) => emit('bulk-add-to-workflow', assets),
bulkOpenWorkflow: (assets) => emit('bulk-open-workflow', assets),
bulkExportWorkflow: (assets) => emit('bulk-export-workflow', assets)
})
const listGridStyle = {
display: 'grid',
@@ -128,6 +193,17 @@ function getAssetMediaType(asset: AssetItem) {
return getMediaTypeFromFilename(asset.name)
}
function getAssetMenuEntries(asset: AssetItem): MenuEntry[] {
return getMenuEntries({
asset,
assetType: getAssetType(asset.tags),
fileKind: getAssetMediaType(asset),
showDeleteButton,
selectedAssets,
isBulkMode
})
}
function isVideoAsset(asset: AssetItem): boolean {
return getAssetMediaType(asset) === 'video'
}
@@ -180,6 +256,27 @@ function getAssetCardClass(selected: boolean): string {
)
}
function shouldShowActionsMenu(assetId: string): boolean {
return (
hoveredAssetId.value === assetId || openActionsAssetId.value === assetId
)
}
function onActionsMenuOpenChange(assetId: string, isOpen: boolean): void {
if (isOpen) {
openActionsAssetId.value = assetId
return
}
if (openActionsAssetId.value === assetId) {
openActionsAssetId.value = null
}
}
async function onAssetMenuAction(entry: MenuActionEntry) {
await entry.onClick?.()
}
function onAssetEnter(assetId: string) {
hoveredAssetId.value = assetId
}

View File

@@ -94,10 +94,18 @@
:is-selected="isSelected"
:selectable-assets="listViewSelectableAssets"
:is-stack-expanded="isListViewStackExpanded"
:show-delete-button="shouldShowDeleteButton"
:selected-assets="selectedAssets"
:is-bulk-mode="isBulkMode"
:toggle-stack="toggleListViewStack"
@asset-deleted="refreshAssets"
@bulk-download="handleBulkDownload"
@bulk-delete="handleBulkDelete"
@bulk-add-to-workflow="handleBulkAddToWorkflow"
@bulk-open-workflow="handleBulkOpenWorkflow"
@bulk-export-workflow="handleBulkExportWorkflow"
@select-asset="handleAssetSelect"
@preview-asset="handleZoomClick"
@context-menu="handleAssetContextMenu"
@approach-end="handleApproachEnd"
/>
<AssetsSidebarGridView
@@ -106,8 +114,16 @@
:is-selected="isSelected"
:show-output-count="shouldShowOutputCount"
:get-output-count="getOutputCount"
:show-delete-button="shouldShowDeleteButton"
:selected-assets="selectedAssets"
:is-bulk-mode="isBulkMode"
@asset-deleted="refreshAssets"
@bulk-download="handleBulkDownload"
@bulk-delete="handleBulkDelete"
@bulk-add-to-workflow="handleBulkAddToWorkflow"
@bulk-open-workflow="handleBulkOpenWorkflow"
@bulk-export-workflow="handleBulkExportWorkflow"
@select-asset="handleAssetSelect"
@context-menu="handleAssetContextMenu"
@approach-end="handleApproachEnd"
@zoom="handleZoomClick"
@output-count-click="enterFolderView"
@@ -174,24 +190,6 @@
v-model:active-index="galleryActiveIndex"
:all-gallery-items="galleryItems"
/>
<MediaAssetContextMenu
v-if="contextMenuAsset"
ref="contextMenuRef"
:asset="contextMenuAsset"
:asset-type="contextMenuAssetType"
:file-kind="contextMenuFileKind"
:show-delete-button="shouldShowDeleteButton"
:selected-assets="selectedAssets"
:is-bulk-mode="isBulkMode"
@zoom="handleZoomClick(contextMenuAsset)"
@hide="handleContextMenuHide"
@asset-deleted="refreshAssets"
@bulk-download="handleBulkDownload"
@bulk-delete="handleBulkDelete"
@bulk-add-to-workflow="handleBulkAddToWorkflow"
@bulk-open-workflow="handleBulkOpenWorkflow"
@bulk-export-workflow="handleBulkExportWorkflow"
/>
</template>
<script setup lang="ts">
@@ -200,14 +198,12 @@ import {
useDebounceFn,
useElementHover,
useResizeObserver,
useStorage,
useTimeoutFn
useStorage
} from '@vueuse/core'
import { useToast } from 'primevue/usetoast'
import {
computed,
defineAsyncComponent,
nextTick,
onMounted,
onUnmounted,
ref,
@@ -224,9 +220,7 @@ import MediaLightbox from '@/components/sidebar/tabs/queue/MediaLightbox.vue'
import Tab from '@/components/tab/Tab.vue'
import TabList from '@/components/tab/TabList.vue'
import Button from '@/components/ui/button/Button.vue'
import MediaAssetContextMenu from '@/platform/assets/components/MediaAssetContextMenu.vue'
import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBar.vue'
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
@@ -236,7 +230,6 @@ import type { OutputAssetMetadata } from '@/platform/assets/schemas/assetMetadat
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { getAssetDisplayName } from '@/platform/assets/utils/assetMetadataUtils'
import type { MediaKind } from '@/platform/assets/schemas/mediaAssetSchema'
import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil'
import { isCloud } from '@/platform/distribution/types'
import { useDialogStore } from '@/stores/dialogStore'
@@ -267,9 +260,6 @@ const viewMode = useStorage<'list' | 'grid'>(
)
const isListView = computed(() => viewMode.value === 'list')
const contextMenuRef = ref<InstanceType<typeof MediaAssetContextMenu>>()
const contextMenuAsset = ref<AssetItem | null>(null)
// Determine if delete button should be shown
// Hide delete button when in input tab and not in cloud (OSS mode - files are from local folders)
const shouldShowDeleteButton = computed(() => {
@@ -277,14 +267,6 @@ const shouldShowDeleteButton = computed(() => {
return true
})
const contextMenuAssetType = computed(() =>
contextMenuAsset.value ? getAssetType(contextMenuAsset.value.tags) : 'input'
)
const contextMenuFileKind = computed<MediaKind>(() =>
getMediaTypeFromFilename(contextMenuAsset.value?.name ?? '')
)
const shouldShowOutputCount = (item: AssetItem): boolean => {
if (activeTab.value !== 'output' || isInFolderView.value) {
return false
@@ -502,26 +484,6 @@ function handleAssetSelect(asset: AssetItem, assets?: AssetItem[]) {
handleAssetClick(asset, index, assetList)
}
const { start: scheduleCleanup, stop: cancelCleanup } = useTimeoutFn(
() => {
contextMenuAsset.value = null
},
0,
{ immediate: false }
)
function handleAssetContextMenu(event: MouseEvent, asset: AssetItem) {
cancelCleanup()
contextMenuAsset.value = asset
void nextTick(() => {
contextMenuRef.value?.show(event)
})
}
function handleContextMenuHide() {
scheduleCleanup()
}
const handleBulkDownload = (assets: AssetItem[]) => {
downloadMultipleAssets(assets)
clearSelection()

View File

@@ -1,78 +1,76 @@
import { mount } from '@vue/test-utils'
import { createI18n } from 'vue-i18n'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick } from 'vue'
import { describe, expect, it, beforeEach, vi } from 'vitest'
import { computed, defineComponent, ref } from 'vue'
import type { JobListItem } from '@/composables/queue/useJobList'
import JobHistorySidebarTab from './JobHistorySidebarTab.vue'
const JobDetailsPopoverStub = defineComponent({
name: 'JobDetailsPopover',
const testState = vi.hoisted(() => ({
groupedJobItems: [] as Array<{
key: string
label: string
items: JobListItem[]
}>,
filteredTasks: [] as JobListItem[],
getJobMenuEntries: vi.fn(() => []),
cancelJob: vi.fn(),
openResultGallery: vi.fn(),
showQueueClearHistoryDialog: vi.fn(),
commandExecute: vi.fn(),
showDialog: vi.fn(),
clearInitializationByJobIds: vi.fn(),
queueDelete: vi.fn()
}))
const JobAssetsListStub = defineComponent({
name: 'JobAssetsList',
props: {
jobId: { type: String, required: true },
workflowId: { type: String, default: undefined }
},
template: '<div class="job-details-popover-stub" />'
})
vi.mock('@/composables/queue/useJobList', async () => {
const { ref } = await import('vue')
const jobHistoryItem = {
id: 'job-1',
title: 'Job 1',
meta: 'meta',
state: 'completed',
taskRef: {
workflowId: 'workflow-1',
previewOutput: {
isImage: true,
isVideo: false,
url: '/api/view/job-1.png'
}
displayedJobGroups: {
type: Array,
required: true
},
getMenuEntries: {
type: Function,
required: true
}
}
return {
useJobList: () => ({
selectedJobTab: ref('All'),
selectedWorkflowFilter: ref('all'),
selectedSortMode: ref('mostRecent'),
searchQuery: ref(''),
hasFailedJobs: ref(false),
filteredTasks: ref([]),
groupedJobItems: ref([
{
key: 'group-1',
label: 'Group 1',
items: [jobHistoryItem]
}
])
})
}
},
template: '<div class="job-assets-list-stub" />'
})
vi.mock('@/composables/queue/useJobList', () => ({
useJobList: () => ({
selectedJobTab: ref('All'),
selectedWorkflowFilter: ref('all'),
selectedSortMode: ref('mostRecent'),
searchQuery: ref(''),
hasFailedJobs: ref(false),
filteredTasks: computed(() => testState.filteredTasks),
groupedJobItems: computed(() => testState.groupedJobItems)
})
}))
vi.mock('@/composables/queue/useJobMenu', () => ({
useJobMenu: () => ({
jobMenuEntries: [],
cancelJob: vi.fn()
getJobMenuEntries: testState.getJobMenuEntries,
cancelJob: testState.cancelJob
})
}))
vi.mock('@/composables/queue/useQueueClearHistoryDialog', () => ({
useQueueClearHistoryDialog: () => ({
showQueueClearHistoryDialog: vi.fn()
showQueueClearHistoryDialog: testState.showQueueClearHistoryDialog
})
}))
vi.mock('@/composables/queue/useResultGallery', async () => {
const { ref } = await import('vue')
return {
useResultGallery: () => ({
galleryActiveIndex: ref(-1),
galleryItems: ref([]),
onViewItem: vi.fn()
})
}
})
vi.mock('@/composables/queue/useResultGallery', () => ({
useResultGallery: () => ({
galleryActiveIndex: ref(-1),
galleryItems: ref([]),
onViewItem: testState.openResultGallery
})
}))
vi.mock('@/composables/useErrorHandling', () => ({
useErrorHandling: () => ({
@@ -84,19 +82,19 @@ vi.mock('@/composables/useErrorHandling', () => ({
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({
execute: vi.fn()
execute: testState.commandExecute
})
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({
showDialog: vi.fn()
showDialog: testState.showDialog
})
}))
vi.mock('@/stores/executionStore', () => ({
useExecutionStore: () => ({
clearInitializationByJobIds: vi.fn()
clearInitializationByJobIds: testState.clearInitializationByJobIds
})
}))
@@ -104,7 +102,7 @@ vi.mock('@/stores/queueStore', () => ({
useQueueStore: () => ({
runningTasks: [],
pendingTasks: [],
delete: vi.fn()
delete: testState.queueDelete
})
}))
@@ -114,11 +112,33 @@ const i18n = createI18n({
messages: { en: {} }
})
const SidebarTabTemplateStub = {
name: 'SidebarTabTemplate',
props: ['title'],
template:
'<div><slot name="alt-title" /><slot name="header" /><slot name="body" /></div>'
const buildJob = (overrides: Partial<JobListItem> = {}): JobListItem =>
({
id: 'job-1',
title: 'Job 1',
meta: 'meta',
state: 'completed',
taskRef: {
workflowId: 'workflow-1',
previewOutput: {
isImage: true,
isVideo: false,
is3D: false,
url: '/api/view/job-1.png'
}
},
...overrides
}) as JobListItem
const setDisplayedJobs = (items: JobListItem[]) => {
testState.filteredTasks = items
testState.groupedJobItems = [
{
key: 'group-1',
label: 'Group 1',
items
}
]
}
function mountComponent() {
@@ -126,38 +146,106 @@ function mountComponent() {
global: {
plugins: [i18n],
stubs: {
SidebarTabTemplate: SidebarTabTemplateStub,
SidebarTabTemplate: {
name: 'SidebarTabTemplate',
template:
'<div><slot name="alt-title" /><slot name="header" /><slot name="body" /></div>'
},
JobFilterTabs: true,
JobFilterActions: true,
JobHistoryActionsMenu: true,
JobContextMenu: true,
ResultGallery: true,
teleport: true,
JobDetailsPopover: JobDetailsPopoverStub
MediaLightbox: true,
JobAssetsList: JobAssetsListStub,
teleport: true
}
}
})
}
afterEach(() => {
vi.useRealTimers()
})
describe('JobHistorySidebarTab', () => {
it('shows the job details popover for jobs in the history panel', async () => {
vi.useFakeTimers()
beforeEach(() => {
vi.clearAllMocks()
setDisplayedJobs([buildJob()])
})
it('passes grouped jobs and menu getter to JobAssetsList', () => {
const wrapper = mountComponent()
const jobRow = wrapper.find('[data-job-id="job-1"]')
const jobAssetsList = wrapper.findComponent(JobAssetsListStub)
await jobRow.trigger('mouseenter')
await vi.advanceTimersByTimeAsync(200)
await nextTick()
expect(jobAssetsList.props('displayedJobGroups')).toEqual(
testState.groupedJobItems
)
expect(jobAssetsList.props('getMenuEntries')).toBe(
testState.getJobMenuEntries
)
})
const popover = wrapper.findComponent(JobDetailsPopoverStub)
expect(popover.exists()).toBe(true)
expect(popover.props()).toMatchObject({
jobId: 'job-1',
workflowId: 'workflow-1'
it('forwards regular view-item events to the result gallery', async () => {
const job = buildJob()
setDisplayedJobs([job])
const wrapper = mountComponent()
wrapper.findComponent(JobAssetsListStub).vm.$emit('view-item', job)
expect(testState.openResultGallery).toHaveBeenCalledWith(job)
expect(testState.showDialog).not.toHaveBeenCalled()
})
it('opens the 3D viewer dialog for 3D view-item events', async () => {
const job = buildJob({
taskRef: {
workflowId: 'workflow-1',
previewOutput: {
isImage: false,
isVideo: false,
is3D: true,
url: '/api/view/job-1.glb'
}
} as JobListItem['taskRef']
})
setDisplayedJobs([job])
const wrapper = mountComponent()
wrapper.findComponent(JobAssetsListStub).vm.$emit('view-item', job)
expect(testState.showDialog).toHaveBeenCalledWith(
expect.objectContaining({
key: 'asset-3d-viewer',
title: job.title,
props: { modelUrl: '/api/view/job-1.glb' }
})
)
expect(testState.openResultGallery).not.toHaveBeenCalled()
})
it('forwards cancel-item events to useJobMenu.cancelJob', async () => {
const job = buildJob({ state: 'running' })
setDisplayedJobs([job])
const wrapper = mountComponent()
wrapper.findComponent(JobAssetsListStub).vm.$emit('cancel-item', job)
expect(testState.cancelJob).toHaveBeenCalledWith(job)
})
it('forwards delete-item events to queueStore.delete', async () => {
const job = buildJob()
const taskRef = job.taskRef
const wrapper = mountComponent()
wrapper.findComponent(JobAssetsListStub).vm.$emit('delete-item', job)
expect(testState.queueDelete).toHaveBeenCalledWith(taskRef)
})
it('runs menu actions emitted by JobAssetsList', async () => {
const onClick = vi.fn()
const wrapper = mountComponent()
wrapper
.findComponent(JobAssetsListStub)
.vm.$emit('menu-action', { key: 'test', label: 'Test', onClick })
expect(onClick).toHaveBeenCalled()
})
})

View File

@@ -48,15 +48,11 @@
<template #body>
<JobAssetsList
:displayed-job-groups="displayedJobGroups"
:get-menu-entries="getJobMenuEntries"
@cancel-item="onCancelItem"
@delete-item="onDeleteItem"
@menu-action="onJobMenuAction"
@view-item="onViewItem"
@menu="onMenuItem"
/>
<JobContextMenu
ref="jobContextMenuRef"
:entries="jobMenuEntries"
@action="onJobMenuAction"
/>
<MediaLightbox
v-model:active-index="galleryActiveIndex"
@@ -67,15 +63,14 @@
</template>
<script setup lang="ts">
import { computed, defineAsyncComponent, ref } from 'vue'
import { computed, defineAsyncComponent } from 'vue'
import { useI18n } from 'vue-i18n'
import JobFilterActions from '@/components/queue/job/JobFilterActions.vue'
import JobFilterTabs from '@/components/queue/job/JobFilterTabs.vue'
import JobAssetsList from '@/components/queue/job/JobAssetsList.vue'
import JobContextMenu from '@/components/queue/job/JobContextMenu.vue'
import JobHistoryActionsMenu from '@/components/queue/JobHistoryActionsMenu.vue'
import type { MenuEntry } from '@/composables/queue/useJobMenu'
import type { MenuActionEntry } from '@/types/menuTypes'
import { useJobMenu } from '@/composables/queue/useJobMenu'
import { useJobList } from '@/composables/queue/useJobList'
import type { JobListItem } from '@/composables/queue/useJobList'
@@ -182,13 +177,7 @@ const onInspectAsset = (item: JobListItem) => {
void onViewItem(item)
}
const currentMenuItem = ref<JobListItem | null>(null)
const jobContextMenuRef = ref<InstanceType<typeof JobContextMenu> | null>(null)
const { jobMenuEntries, cancelJob } = useJobMenu(
() => currentMenuItem.value,
onInspectAsset
)
const { getJobMenuEntries, cancelJob } = useJobMenu(onInspectAsset)
const onCancelItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
await cancelJob(item)
@@ -199,14 +188,9 @@ const onDeleteItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
await queueStore.delete(item.taskRef)
})
const onMenuItem = (item: JobListItem, event: Event) => {
currentMenuItem.value = item
jobContextMenuRef.value?.open(event)
}
const onJobMenuAction = wrapWithErrorHandlingAsync(async (entry: MenuEntry) => {
if (entry.kind === 'divider') return
if (entry.onClick) await entry.onClick()
jobContextMenuRef.value?.hide()
})
const onJobMenuAction = wrapWithErrorHandlingAsync(
async (entry: MenuActionEntry) => {
if (entry.onClick) await entry.onClick()
}
)
</script>

View File

@@ -0,0 +1,57 @@
import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it } from 'vitest'
import { defineComponent, nextTick, ref } from 'vue'
import ContextMenu from './ContextMenu.vue'
import ContextMenuContent from './ContextMenuContent.vue'
import ContextMenuItem from './ContextMenuItem.vue'
import ContextMenuSeparator from './ContextMenuSeparator.vue'
import ContextMenuTrigger from './ContextMenuTrigger.vue'
const TestContextMenu = defineComponent({
components: {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator
},
setup() {
const open = ref(false)
return { open }
},
template: `
<ContextMenu v-model:open="open" :modal="false">
<ContextMenuTrigger as-child>
<button type="button">Trigger</button>
</ContextMenuTrigger>
<ContextMenuContent close-on-scroll>
<ContextMenuItem text-value="First item">First item</ContextMenuItem>
<ContextMenuSeparator />
</ContextMenuContent>
</ContextMenu>
`
})
afterEach(() => {
document.body.innerHTML = ''
})
describe('ContextMenu', () => {
it('closes the content on scroll when close-on-scroll is enabled', async () => {
const wrapper = mount(TestContextMenu, {
attachTo: document.body
})
await wrapper.find('button').trigger('contextmenu')
await nextTick()
expect(wrapper.vm.open).toBe(true)
window.dispatchEvent(new Event('scroll'))
await nextTick()
expect(wrapper.vm.open).toBe(false)
})
})

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
/* eslint-disable vue/no-unused-properties */
import type { ContextMenuRootEmits, ContextMenuRootProps } from 'reka-ui'
import { ContextMenuRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<ContextMenuRootProps>()
const emits = defineEmits<ContextMenuRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<ContextMenuRoot v-bind="forwarded">
<slot />
</ContextMenuRoot>
</template>

View File

@@ -0,0 +1,58 @@
<script setup lang="ts">
/* eslint-disable vue/no-unused-properties */
import { reactiveOmit, useEventListener } from '@vueuse/core'
import type { ContextMenuContentEmits, ContextMenuContentProps } from 'reka-ui'
import {
ContextMenuContent,
ContextMenuPortal,
injectContextMenuRootContext,
useForwardPropsEmits
} from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const props = withDefaults(
defineProps<
ContextMenuContentProps & {
class?: HTMLAttributes['class']
closeOnScroll?: boolean
}
>(),
{
closeOnScroll: false
}
)
const emits = defineEmits<ContextMenuContentEmits>()
const delegatedProps = reactiveOmit(props, 'class', 'closeOnScroll')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
const rootContext = injectContextMenuRootContext()
useEventListener(
window,
'scroll',
() => {
if (props.closeOnScroll) {
rootContext.onOpenChange(false)
}
},
{ capture: true, passive: true }
)
</script>
<template>
<ContextMenuPortal>
<ContextMenuContent
v-bind="forwarded"
:class="
cn(
'bg-popover text-popover-foreground z-1700 min-w-32 overflow-hidden rounded-md border p-1 shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
props.class
)
"
>
<slot />
</ContextMenuContent>
</ContextMenuPortal>
</template>

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
/* eslint-disable vue/no-unused-properties */
import type { ContextMenuItemEmits, ContextMenuItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ContextMenuItem, useForwardPropsEmits } from 'reka-ui'
import { cn } from '@/utils/tailwindUtil'
const props = defineProps<
ContextMenuItemProps & { class?: HTMLAttributes['class']; inset?: boolean }
>()
const emits = defineEmits<ContextMenuItemEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<ContextMenuItem
v-bind="forwarded"
:class="
cn(
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-disabled:pointer-events-none data-disabled:opacity-50',
inset && 'pl-8',
props.class
)
"
>
<slot />
</ContextMenuItem>
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
/* eslint-disable vue/no-unused-properties */
import type { ContextMenuSeparatorProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ContextMenuSeparator } from 'reka-ui'
import { cn } from '@/utils/tailwindUtil'
const props = defineProps<
ContextMenuSeparatorProps & { class?: HTMLAttributes['class'] }
>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<ContextMenuSeparator
v-bind="delegatedProps"
:class="cn('bg-border -mx-1 my-1 h-px', props.class)"
/>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
/* eslint-disable vue/no-unused-properties */
import type { ContextMenuTriggerProps } from 'reka-ui'
import { ContextMenuTrigger, useForwardProps } from 'reka-ui'
const props = defineProps<ContextMenuTriggerProps>()
const forwardedProps = useForwardProps(props)
</script>
<template>
<ContextMenuTrigger v-bind="forwardedProps">
<slot />
</ContextMenuTrigger>
</template>

View File

@@ -0,0 +1,57 @@
import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it } from 'vitest'
import { defineComponent, nextTick, ref } from 'vue'
import DropdownMenu from './DropdownMenu.vue'
import DropdownMenuContent from './DropdownMenuContent.vue'
import DropdownMenuItem from './DropdownMenuItem.vue'
import DropdownMenuSeparator from './DropdownMenuSeparator.vue'
import DropdownMenuTrigger from './DropdownMenuTrigger.vue'
const TestDropdownMenu = defineComponent({
components: {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator
},
setup() {
const open = ref(false)
return { open }
},
template: `
<DropdownMenu v-model:open="open">
<DropdownMenuTrigger as-child>
<button type="button">Trigger</button>
</DropdownMenuTrigger>
<DropdownMenuContent close-on-scroll>
<DropdownMenuItem text-value="First item">First item</DropdownMenuItem>
<DropdownMenuSeparator />
</DropdownMenuContent>
</DropdownMenu>
`
})
afterEach(() => {
document.body.innerHTML = ''
})
describe('DropdownMenu', () => {
it('closes the content on scroll when close-on-scroll is enabled', async () => {
const wrapper = mount(TestDropdownMenu, {
attachTo: document.body
})
await wrapper.find('button').trigger('click')
await nextTick()
expect(wrapper.vm.open).toBe(true)
window.dispatchEvent(new Event('scroll'))
await nextTick()
expect(wrapper.vm.open).toBe(false)
})
})

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
/* eslint-disable vue/no-unused-properties */
import type { DropdownMenuRootEmits, DropdownMenuRootProps } from 'reka-ui'
import { DropdownMenuRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<DropdownMenuRootProps>()
const emits = defineEmits<DropdownMenuRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DropdownMenuRoot v-bind="forwarded">
<slot />
</DropdownMenuRoot>
</template>

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
/* eslint-disable vue/no-unused-properties */
import { reactiveOmit, useEventListener } from '@vueuse/core'
import type {
DropdownMenuContentEmits,
DropdownMenuContentProps
} from 'reka-ui'
import {
DropdownMenuContent,
DropdownMenuPortal,
injectDropdownMenuRootContext,
useForwardPropsEmits
} from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const props = withDefaults(
defineProps<
DropdownMenuContentProps & {
class?: HTMLAttributes['class']
closeOnScroll?: boolean
}
>(),
{
closeOnScroll: false,
sideOffset: 4
}
)
const emits = defineEmits<DropdownMenuContentEmits>()
const delegatedProps = reactiveOmit(props, 'class', 'closeOnScroll')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
const rootContext = injectDropdownMenuRootContext()
useEventListener(
window,
'scroll',
() => {
if (props.closeOnScroll) {
rootContext.onOpenChange(false)
}
},
{ capture: true, passive: true }
)
</script>
<template>
<DropdownMenuPortal>
<DropdownMenuContent
v-bind="forwarded"
:class="
cn(
'bg-popover text-popover-foreground z-1700 min-w-32 overflow-hidden rounded-md border p-1 shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
props.class
)
"
>
<slot />
</DropdownMenuContent>
</DropdownMenuPortal>
</template>

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
/* eslint-disable vue/no-unused-properties */
import type { DropdownMenuItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DropdownMenuItem, useForwardProps } from 'reka-ui'
import { cn } from '@/utils/tailwindUtil'
const props = defineProps<
DropdownMenuItemProps & { class?: HTMLAttributes['class']; inset?: boolean }
>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DropdownMenuItem
v-bind="forwardedProps"
:class="
cn(
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm transition-colors outline-none select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0',
inset && 'pl-8',
props.class
)
"
>
<slot />
</DropdownMenuItem>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
/* eslint-disable vue/no-unused-properties */
import type { DropdownMenuSeparatorProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DropdownMenuSeparator } from 'reka-ui'
import { cn } from '@/utils/tailwindUtil'
const props = defineProps<
DropdownMenuSeparatorProps & {
class?: HTMLAttributes['class']
}
>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<DropdownMenuSeparator
v-bind="delegatedProps"
:class="cn('-mx-1 my-1 h-px bg-muted', props.class)"
/>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
/* eslint-disable vue/no-unused-properties */
import type { DropdownMenuTriggerProps } from 'reka-ui'
import { DropdownMenuTrigger, useForwardProps } from 'reka-ui'
const props = defineProps<DropdownMenuTriggerProps>()
const forwardedProps = useForwardProps(props)
</script>
<template>
<DropdownMenuTrigger class="outline-none" v-bind="forwardedProps">
<slot />
</DropdownMenuTrigger>
</template>

View File

@@ -65,7 +65,7 @@ export function useJobDetailsHover<TActive>({
}, DETAILS_SHOW_DELAY_MS)
}
function scheduleDetailsHide(jobId?: string, onHide?: () => void) {
function scheduleDetailsHide(jobId?: string) {
if (!jobId) return
clearShowTimer()
@@ -79,7 +79,7 @@ export function useJobDetailsHover<TActive>({
const currentActive = activeDetails.value
if (currentActive && getActiveId(currentActive) === jobId) {
activeDetails.value = null
onHide?.()
onReset?.()
}
hideTimer.value = null
hideTimerJobId.value = null

View File

@@ -1,9 +1,7 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import type { Ref } from 'vue'
import type { JobListItem } from '@/composables/queue/useJobList'
import type { MenuEntry } from '@/composables/queue/useJobMenu'
import type { MenuEntry } from '@/types/menuTypes'
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
@@ -172,10 +170,13 @@ const createJobItem = (
computeHours: overrides.computeHours
})
let currentItem: Ref<JobListItem | null>
const mountJobMenu = (onInspectAsset?: (item: JobListItem) => void) =>
useJobMenu(() => currentItem.value, onInspectAsset)
useJobMenu(onInspectAsset)
const getMenuEntries = (
item: JobListItem | null,
onInspectAsset?: (item: JobListItem) => void
) => mountJobMenu(onInspectAsset).getJobMenuEntries(item)
const findActionEntry = (entries: MenuEntry[], key: string) =>
entries.find(
@@ -186,7 +187,6 @@ const findActionEntry = (entries: MenuEntry[], key: string) =>
describe('useJobMenu', () => {
beforeEach(() => {
vi.clearAllMocks()
currentItem = ref<JobListItem | null>(null)
settingStoreMock.get.mockReturnValue(false)
dialogServiceMock.prompt.mockResolvedValue(undefined)
litegraphServiceMock.getCanvasCenter.mockReturnValue([100, 200])
@@ -212,18 +212,14 @@ describe('useJobMenu', () => {
getJobWorkflowMock.mockResolvedValue(undefined)
})
const setCurrentItem = (item: JobListItem | null) => {
currentItem.value = item
}
it('opens workflow when workflow data exists', async () => {
const { openJobWorkflow } = mountJobMenu()
const workflow = { nodes: [] }
// Mock lazy loading via fetchJobDetail + extractWorkflow
getJobWorkflowMock.mockResolvedValue(workflow)
setCurrentItem(createJobItem({ id: '55' }))
const item = createJobItem({ id: '55' })
await openJobWorkflow()
await openJobWorkflow(item)
expect(getJobWorkflowMock).toHaveBeenCalledWith('55')
expect(workflowStoreMock.createTemporary).toHaveBeenCalledWith(
@@ -238,9 +234,9 @@ describe('useJobMenu', () => {
it('does nothing when workflow is missing', async () => {
const { openJobWorkflow } = mountJobMenu()
setCurrentItem(createJobItem({ taskRef: {} }))
const item = createJobItem({ taskRef: {} })
await openJobWorkflow()
await openJobWorkflow(item)
expect(workflowStoreMock.createTemporary).not.toHaveBeenCalled()
expect(workflowServiceMock.openWorkflow).not.toHaveBeenCalled()
@@ -248,9 +244,9 @@ describe('useJobMenu', () => {
it('copies job id to clipboard', async () => {
const { copyJobId } = mountJobMenu()
setCurrentItem(createJobItem({ id: 'job-99' }))
const item = createJobItem({ id: 'job-99' })
await copyJobId()
await copyJobId(item)
expect(copyToClipboardMock).toHaveBeenCalledWith('job-99')
})
@@ -268,9 +264,9 @@ describe('useJobMenu', () => {
['initialization', interruptMock, deleteItemMock]
])('cancels %s job via interrupt', async (state) => {
const { cancelJob } = mountJobMenu()
setCurrentItem(createJobItem({ state: state as JobListItem['state'] }))
const item = createJobItem({ state: state as JobListItem['state'] })
await cancelJob()
await cancelJob(item)
expect(interruptMock).toHaveBeenCalledWith('job-1')
expect(deleteItemMock).not.toHaveBeenCalled()
@@ -279,9 +275,9 @@ describe('useJobMenu', () => {
it('cancels pending job via deleteItem', async () => {
const { cancelJob } = mountJobMenu()
setCurrentItem(createJobItem({ state: 'pending' }))
const item = createJobItem({ state: 'pending' })
await cancelJob()
await cancelJob(item)
expect(deleteItemMock).toHaveBeenCalledWith('queue', 'job-1')
expect(queueStoreMock.update).toHaveBeenCalled()
@@ -289,9 +285,9 @@ describe('useJobMenu', () => {
it('still updates queue for uncancellable states', async () => {
const { cancelJob } = mountJobMenu()
setCurrentItem(createJobItem({ state: 'completed' }))
const item = createJobItem({ state: 'completed' })
await cancelJob()
await cancelJob(item)
expect(interruptMock).not.toHaveBeenCalled()
expect(deleteItemMock).not.toHaveBeenCalled()
@@ -299,8 +295,7 @@ describe('useJobMenu', () => {
})
it('copies error message from failed job entry', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
const entries = getMenuEntries(
createJobItem({
state: 'failed',
taskRef: {
@@ -309,8 +304,7 @@ describe('useJobMenu', () => {
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'copy-error')
const entry = findActionEntry(entries, 'copy-error')
await entry?.onClick?.()
expect(copyToClipboardMock).toHaveBeenCalledWith('Something went wrong')
@@ -329,8 +323,7 @@ describe('useJobMenu', () => {
current_inputs: {},
current_outputs: {}
}
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
const entries = getMenuEntries(
createJobItem({
state: 'failed',
taskRef: {
@@ -341,8 +334,7 @@ describe('useJobMenu', () => {
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'report-error')
const entry = findActionEntry(entries, 'report-error')
await entry?.onClick?.()
expect(dialogServiceMock.showExecutionErrorDialog).toHaveBeenCalledTimes(1)
@@ -353,8 +345,7 @@ describe('useJobMenu', () => {
})
it('falls back to simple error dialog when no execution_error', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
const entries = getMenuEntries(
createJobItem({
state: 'failed',
taskRef: {
@@ -363,8 +354,7 @@ describe('useJobMenu', () => {
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'report-error')
const entry = findActionEntry(entries, 'report-error')
await entry?.onClick?.()
expect(dialogServiceMock.showExecutionErrorDialog).not.toHaveBeenCalled()
@@ -377,18 +367,16 @@ describe('useJobMenu', () => {
})
it('ignores error actions when message missing', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
const entries = getMenuEntries(
createJobItem({
state: 'failed',
taskRef: { errorMessage: undefined } as Partial<TaskItemImpl>
})
)
await nextTick()
const copyEntry = findActionEntry(jobMenuEntries.value, 'copy-error')
const copyEntry = findActionEntry(entries, 'copy-error')
await copyEntry?.onClick?.()
const reportEntry = findActionEntry(jobMenuEntries.value, 'report-error')
const reportEntry = findActionEntry(entries, 'report-error')
await reportEntry?.onClick?.()
expect(copyToClipboardMock).not.toHaveBeenCalled()
@@ -426,7 +414,6 @@ describe('useJobMenu', () => {
graph: { setDirtyCanvas: vi.fn() }
}
litegraphServiceMock.addNodeOnGraph.mockReturnValueOnce(node)
const { jobMenuEntries } = mountJobMenu()
const preview = {
filename: 'foo.png',
subfolder: 'bar',
@@ -434,15 +421,14 @@ describe('useJobMenu', () => {
url: 'http://asset',
...flags
}
setCurrentItem(
const entries = getMenuEntries(
createJobItem({
state: 'completed',
taskRef: { previewOutput: preview }
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'add-to-current')
const entry = findActionEntry(entries, 'add-to-current')
await entry?.onClick?.()
expect(litegraphServiceMock.addNodeOnGraph).toHaveBeenCalledWith(
@@ -457,8 +443,7 @@ describe('useJobMenu', () => {
it('skips adding node when no loader definition', async () => {
delete nodeDefStoreMock.nodeDefsByName.LoadImage
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
const entries = getMenuEntries(
createJobItem({
state: 'completed',
taskRef: {
@@ -472,16 +457,14 @@ describe('useJobMenu', () => {
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'add-to-current')
const entry = findActionEntry(entries, 'add-to-current')
await entry?.onClick?.()
expect(litegraphServiceMock.addNodeOnGraph).not.toHaveBeenCalled()
})
it('skips adding node when preview output lacks media flags', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
const entries = getMenuEntries(
createJobItem({
state: 'completed',
taskRef: {
@@ -494,8 +477,7 @@ describe('useJobMenu', () => {
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'add-to-current')
const entry = findActionEntry(entries, 'add-to-current')
await entry?.onClick?.()
expect(litegraphServiceMock.addNodeOnGraph).not.toHaveBeenCalled()
@@ -504,8 +486,7 @@ describe('useJobMenu', () => {
it('skips annotating when litegraph node creation fails', async () => {
litegraphServiceMock.addNodeOnGraph.mockReturnValueOnce(null)
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
const entries = getMenuEntries(
createJobItem({
state: 'completed',
taskRef: {
@@ -519,8 +500,7 @@ describe('useJobMenu', () => {
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'add-to-current')
const entry = findActionEntry(entries, 'add-to-current')
await entry?.onClick?.()
expect(litegraphServiceMock.addNodeOnGraph).toHaveBeenCalled()
@@ -528,24 +508,21 @@ describe('useJobMenu', () => {
})
it('ignores add-to-current entry when preview missing entirely', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
const entries = getMenuEntries(
createJobItem({
state: 'completed',
taskRef: {} as Partial<TaskItemImpl>
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'add-to-current')
const entry = findActionEntry(entries, 'add-to-current')
await entry?.onClick?.()
expect(litegraphServiceMock.addNodeOnGraph).not.toHaveBeenCalled()
})
it('downloads preview asset when requested', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
const entries = getMenuEntries(
createJobItem({
state: 'completed',
taskRef: {
@@ -554,24 +531,21 @@ describe('useJobMenu', () => {
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'download')
const entry = findActionEntry(entries, 'download')
void entry?.onClick?.()
expect(downloadFileMock).toHaveBeenCalledWith('https://asset')
})
it('ignores download request when preview missing', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
const entries = getMenuEntries(
createJobItem({
state: 'completed',
taskRef: {} as Partial<TaskItemImpl>
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'download')
const entry = findActionEntry(entries, 'download')
void entry?.onClick?.()
expect(downloadFileMock).not.toHaveBeenCalled()
@@ -580,16 +554,14 @@ describe('useJobMenu', () => {
it('exports workflow with default filename when prompting disabled', async () => {
const workflow = { foo: 'bar' }
getJobWorkflowMock.mockResolvedValue(workflow)
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
const entries = getMenuEntries(
createJobItem({
id: '7',
state: 'completed'
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'export-workflow')
const entry = findActionEntry(entries, 'export-workflow')
await entry?.onClick?.()
expect(dialogServiceMock.prompt).not.toHaveBeenCalled()
@@ -605,15 +577,13 @@ describe('useJobMenu', () => {
settingStoreMock.get.mockReturnValue(true)
dialogServiceMock.prompt.mockResolvedValue('custom-name')
getJobWorkflowMock.mockResolvedValue({})
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
const entries = getMenuEntries(
createJobItem({
state: 'completed'
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'export-workflow')
const entry = findActionEntry(entries, 'export-workflow')
await entry?.onClick?.()
expect(dialogServiceMock.prompt).toHaveBeenCalledWith({
@@ -629,16 +599,14 @@ describe('useJobMenu', () => {
settingStoreMock.get.mockReturnValue(true)
dialogServiceMock.prompt.mockResolvedValue('existing.json')
getJobWorkflowMock.mockResolvedValue({ foo: 'bar' })
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
const entries = getMenuEntries(
createJobItem({
id: '42',
state: 'completed'
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'export-workflow')
const entry = findActionEntry(entries, 'export-workflow')
await entry?.onClick?.()
expect(appendJsonExtMock).toHaveBeenCalledWith('existing.json')
@@ -650,15 +618,13 @@ describe('useJobMenu', () => {
settingStoreMock.get.mockReturnValue(true)
dialogServiceMock.prompt.mockResolvedValue('')
getJobWorkflowMock.mockResolvedValue({})
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
const entries = getMenuEntries(
createJobItem({
state: 'completed'
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'export-workflow')
const entry = findActionEntry(entries, 'export-workflow')
await entry?.onClick?.()
expect(downloadBlobMock).not.toHaveBeenCalled()
@@ -666,13 +632,13 @@ describe('useJobMenu', () => {
it('deletes preview asset when confirmed', async () => {
mediaAssetActionsMock.deleteAssets.mockResolvedValue(true)
const { jobMenuEntries } = mountJobMenu()
const preview = { filename: 'foo', subfolder: 'bar', type: 'output' }
const taskRef = { previewOutput: preview }
setCurrentItem(createJobItem({ state: 'completed', taskRef }))
const entries = getMenuEntries(
createJobItem({ state: 'completed', taskRef })
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'delete')
const entry = findActionEntry(entries, 'delete')
await entry?.onClick?.()
expect(mapTaskOutputToAssetItemMock).toHaveBeenCalledWith(taskRef, preview)
@@ -681,16 +647,14 @@ describe('useJobMenu', () => {
it('does not refresh queue when delete cancelled', async () => {
mediaAssetActionsMock.deleteAssets.mockResolvedValue(false)
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
const entries = getMenuEntries(
createJobItem({
state: 'completed',
taskRef: { previewOutput: {} }
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'delete')
const entry = findActionEntry(entries, 'delete')
await entry?.onClick?.()
expect(queueStoreMock.update).not.toHaveBeenCalled()
@@ -698,22 +662,18 @@ describe('useJobMenu', () => {
it('removes failed job via menu entry', async () => {
const taskRef = { id: 'task-1' }
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(createJobItem({ state: 'failed', taskRef }))
const entries = getMenuEntries(createJobItem({ state: 'failed', taskRef }))
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'delete')
const entry = findActionEntry(entries, 'delete')
await entry?.onClick?.()
expect(queueStoreMock.delete).toHaveBeenCalledWith(taskRef)
})
it('ignores failed job delete when taskRef missing', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(createJobItem({ state: 'failed' }))
const entries = getMenuEntries(createJobItem({ state: 'failed' }))
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'delete')
const entry = findActionEntry(entries, 'delete')
await entry?.onClick?.()
expect(queueStoreMock.delete).not.toHaveBeenCalled()
@@ -721,16 +681,13 @@ describe('useJobMenu', () => {
it('provides completed menu structure with delete option', async () => {
const inspectSpy = vi.fn()
const { jobMenuEntries } = mountJobMenu(inspectSpy)
setCurrentItem(
createJobItem({
state: 'completed',
taskRef: { previewOutput: {} }
})
)
const item = createJobItem({
state: 'completed',
taskRef: { previewOutput: {} }
})
const entries = getMenuEntries(item, inspectSpy)
await nextTick()
expect(jobMenuEntries.value.map((entry) => entry.key)).toEqual([
expect(entries.map((entry) => entry.key)).toEqual([
'inspect-asset',
'add-to-current',
'download',
@@ -743,66 +700,48 @@ describe('useJobMenu', () => {
'delete'
])
expect(
findActionEntry(jobMenuEntries.value, 'inspect-asset')?.disabled
).toBe(false)
expect(
findActionEntry(jobMenuEntries.value, 'add-to-current')?.disabled
).toBe(false)
expect(findActionEntry(jobMenuEntries.value, 'download')?.disabled).toBe(
false
)
expect(findActionEntry(entries, 'inspect-asset')?.disabled).toBe(false)
expect(findActionEntry(entries, 'add-to-current')?.disabled).toBe(false)
expect(findActionEntry(entries, 'download')?.disabled).toBe(false)
const inspectEntry = findActionEntry(jobMenuEntries.value, 'inspect-asset')
const inspectEntry = findActionEntry(entries, 'inspect-asset')
await inspectEntry?.onClick?.()
expect(inspectSpy).toHaveBeenCalledWith(currentItem.value)
expect(inspectSpy).toHaveBeenCalledWith(item)
})
it('omits inspect handler when callback missing', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
const entries = getMenuEntries(
createJobItem({
state: 'completed',
taskRef: { previewOutput: {} }
})
)
await nextTick()
const inspectEntry = findActionEntry(jobMenuEntries.value, 'inspect-asset')
const inspectEntry = findActionEntry(entries, 'inspect-asset')
expect(inspectEntry?.onClick).toBeUndefined()
expect(inspectEntry?.disabled).toBe(true)
})
it('omits delete asset entry when no preview exists', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(createJobItem({ state: 'completed', taskRef: {} }))
const entries = getMenuEntries(
createJobItem({ state: 'completed', taskRef: {} })
)
await nextTick()
expect(
findActionEntry(jobMenuEntries.value, 'inspect-asset')?.disabled
).toBe(true)
expect(
findActionEntry(jobMenuEntries.value, 'add-to-current')?.disabled
).toBe(true)
expect(findActionEntry(jobMenuEntries.value, 'download')?.disabled).toBe(
true
)
expect(jobMenuEntries.value.some((entry) => entry.key === 'delete')).toBe(
false
)
expect(findActionEntry(entries, 'inspect-asset')?.disabled).toBe(true)
expect(findActionEntry(entries, 'add-to-current')?.disabled).toBe(true)
expect(findActionEntry(entries, 'download')?.disabled).toBe(true)
expect(entries.some((entry) => entry.key === 'delete')).toBe(false)
})
it('returns failed menu entries with error actions', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
const entries = getMenuEntries(
createJobItem({
state: 'failed',
taskRef: { errorMessage: 'Some error' } as Partial<TaskItemImpl>
})
)
await nextTick()
expect(jobMenuEntries.value.map((entry) => entry.key)).toEqual([
expect(entries.map((entry) => entry.key)).toEqual([
'open-workflow',
'd1',
'copy-id',
@@ -814,11 +753,9 @@ describe('useJobMenu', () => {
})
it('returns active job entries with cancel option', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(createJobItem({ state: 'running' }))
const entries = getMenuEntries(createJobItem({ state: 'running' }))
await nextTick()
expect(jobMenuEntries.value.map((entry) => entry.key)).toEqual([
expect(entries.map((entry) => entry.key)).toEqual([
'open-workflow',
'd1',
'copy-id',
@@ -828,18 +765,16 @@ describe('useJobMenu', () => {
})
it('provides pending job entries and triggers cancel action', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(createJobItem({ state: 'pending' }))
const entries = getMenuEntries(createJobItem({ state: 'pending' }))
await nextTick()
expect(jobMenuEntries.value.map((entry) => entry.key)).toEqual([
expect(entries.map((entry) => entry.key)).toEqual([
'open-workflow',
'd1',
'copy-id',
'd2',
'cancel-job'
])
const cancelEntry = findActionEntry(jobMenuEntries.value, 'cancel-job')
const cancelEntry = findActionEntry(entries, 'cancel-job')
await cancelEntry?.onClick?.()
expect(deleteItemMock).toHaveBeenCalledWith('queue', 'job-1')
@@ -847,10 +782,6 @@ describe('useJobMenu', () => {
})
it('returns empty menu when no job selected', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(null)
await nextTick()
expect(jobMenuEntries.value).toEqual([])
expect(getMenuEntries(null)).toEqual([])
})
})

View File

@@ -20,30 +20,16 @@ import { useExecutionStore } from '@/stores/executionStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useQueueStore } from '@/stores/queueStore'
import type { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
import type { MenuEntry } from '@/types/menuTypes'
import { createAnnotatedPath } from '@/utils/createAnnotatedPath'
import { appendJsonExt } from '@/utils/formatUtil'
export type MenuEntry =
| {
kind?: 'item'
key: string
label: string
icon?: string
disabled?: boolean
onClick?: () => void | Promise<void>
}
| { kind: 'divider'; key: string }
/**
* Provides job context menu entries and actions.
*
* @param currentMenuItem Getter for the currently targeted job list item
* @param onInspectAsset Callback to trigger when inspecting a completed job's asset
*/
export function useJobMenu(
currentMenuItem: () => JobListItem | null = () => null,
onInspectAsset?: (item: JobListItem) => void
) {
export function useJobMenu(onInspectAsset?: (item: JobListItem) => void) {
const workflowStore = useWorkflowStore()
const workflowService = useWorkflowService()
const queueStore = useQueueStore()
@@ -53,11 +39,8 @@ export function useJobMenu(
const nodeDefStore = useNodeDefStore()
const mediaAssetActions = useMediaAssetActions()
const resolveItem = (item?: JobListItem | null): JobListItem | null =>
item ?? currentMenuItem()
const openJobWorkflow = async (item?: JobListItem | null) => {
const target = resolveItem(item)
const target = item
if (!target) return
const data = await getJobWorkflow(target.id)
if (!data) return
@@ -67,13 +50,13 @@ export function useJobMenu(
}
const copyJobId = async (item?: JobListItem | null) => {
const target = resolveItem(item)
const target = item
if (!target) return
await copyToClipboard(target.id)
}
const cancelJob = async (item?: JobListItem | null) => {
const target = resolveItem(item)
const target = item
if (!target) return
if (target.state === 'running' || target.state === 'initialization') {
if (isCloud) {
@@ -89,13 +72,13 @@ export function useJobMenu(
}
const copyErrorMessage = async (item?: JobListItem | null) => {
const target = resolveItem(item)
const target = item
const message = target?.taskRef?.errorMessage
if (message) await copyToClipboard(message)
}
const reportError = (item?: JobListItem | null) => {
const target = resolveItem(item)
const target = item
if (!target) return
// Use execution_error from list response if available
@@ -117,10 +100,10 @@ export function useJobMenu(
// This is very magical only because it matches the respective backend implementation
// There is or will be a better way to do this
const addOutputLoaderNode = async () => {
const item = currentMenuItem()
if (!item) return
const result: ResultItemImpl | undefined = item.taskRef?.previewOutput
const addOutputLoaderNode = async (item?: JobListItem | null) => {
const target = item
if (!target) return
const result: ResultItemImpl | undefined = target.taskRef?.previewOutput
if (!result) return
let nodeType: 'LoadImage' | 'LoadVideo' | 'LoadAudio' | null = null
@@ -168,10 +151,10 @@ export function useJobMenu(
/**
* Trigger a download of the job's previewable output asset.
*/
const downloadPreviewAsset = () => {
const item = currentMenuItem()
if (!item) return
const result: ResultItemImpl | undefined = item.taskRef?.previewOutput
const downloadPreviewAsset = (item?: JobListItem | null) => {
const target = item
if (!target) return
const result: ResultItemImpl | undefined = target.taskRef?.previewOutput
if (!result) return
downloadFile(result.url)
}
@@ -179,14 +162,14 @@ export function useJobMenu(
/**
* Export the workflow JSON attached to the job.
*/
const exportJobWorkflow = async () => {
const item = currentMenuItem()
if (!item) return
const data = await getJobWorkflow(item.id)
const exportJobWorkflow = async (item?: JobListItem | null) => {
const target = item
if (!target) return
const data = await getJobWorkflow(target.id)
if (!data) return
const settingStore = useSettingStore()
let filename = `Job ${item.id}.json`
let filename = `Job ${target.id}.json`
if (settingStore.get('Comfy.PromptFilename')) {
const input = await useDialogService().prompt({
@@ -203,10 +186,10 @@ export function useJobMenu(
downloadBlob(filename, blob)
}
const deleteJobAsset = async () => {
const item = currentMenuItem()
if (!item) return
const task = item.taskRef as TaskItemImpl | undefined
const deleteJobAsset = async (item?: JobListItem | null) => {
const target = item
if (!target) return
const task = target.taskRef as TaskItemImpl | undefined
const preview = task?.previewOutput
if (!task || !preview) return
@@ -218,8 +201,7 @@ export function useJobMenu(
}
const removeFailedJob = async (task?: TaskItemImpl | null) => {
const target =
task ?? (currentMenuItem()?.taskRef as TaskItemImpl | undefined)
const target = task
if (!target) return
await queueStore.delete(target)
}
@@ -237,11 +219,11 @@ export function useJobMenu(
st('queue.jobMenu.cancelJob', 'Cancel job')
)
const jobMenuEntries = computed<MenuEntry[]>(() => {
const item = currentMenuItem()
const state = item?.state
const buildJobMenuEntries = (item?: JobListItem | null): MenuEntry[] => {
const target = item
const state = target?.state
if (!state) return []
const hasPreviewAsset = !!item?.taskRef?.previewOutput
const hasPreviewAsset = !!target?.taskRef?.previewOutput
if (state === 'completed') {
return [
{
@@ -251,8 +233,7 @@ export function useJobMenu(
disabled: !hasPreviewAsset || !onInspectAsset,
onClick: onInspectAsset
? () => {
const item = currentMenuItem()
if (item) onInspectAsset(item)
if (target) onInspectAsset(target)
}
: undefined
},
@@ -264,34 +245,34 @@ export function useJobMenu(
),
icon: 'icon-[comfy--node]',
disabled: !hasPreviewAsset,
onClick: addOutputLoaderNode
onClick: () => addOutputLoaderNode(target)
},
{
key: 'download',
label: st('queue.jobMenu.download', 'Download'),
icon: 'icon-[lucide--download]',
disabled: !hasPreviewAsset,
onClick: downloadPreviewAsset
onClick: () => downloadPreviewAsset(target)
},
{ kind: 'divider', key: 'd1' },
{
key: 'open-workflow',
label: jobMenuOpenWorkflowLabel.value,
icon: 'icon-[comfy--workflow]',
onClick: openJobWorkflow
onClick: () => openJobWorkflow(target)
},
{
key: 'export-workflow',
label: st('queue.jobMenu.exportWorkflow', 'Export workflow'),
icon: 'icon-[comfy--file-output]',
onClick: exportJobWorkflow
onClick: () => exportJobWorkflow(target)
},
{ kind: 'divider', key: 'd2' },
{
key: 'copy-id',
label: jobMenuCopyJobIdLabel.value,
icon: 'icon-[lucide--copy]',
onClick: copyJobId
onClick: () => copyJobId(target)
},
{ kind: 'divider', key: 'd3' },
...(hasPreviewAsset
@@ -300,7 +281,7 @@ export function useJobMenu(
key: 'delete',
label: st('queue.jobMenu.deleteAsset', 'Delete asset'),
icon: 'icon-[lucide--trash-2]',
onClick: deleteJobAsset
onClick: () => deleteJobAsset(target)
}
]
: [])
@@ -312,33 +293,33 @@ export function useJobMenu(
key: 'open-workflow',
label: jobMenuOpenWorkflowFailedLabel.value,
icon: 'icon-[comfy--workflow]',
onClick: openJobWorkflow
onClick: () => openJobWorkflow(target)
},
{ kind: 'divider', key: 'd1' },
{
key: 'copy-id',
label: jobMenuCopyJobIdLabel.value,
icon: 'icon-[lucide--copy]',
onClick: copyJobId
onClick: () => copyJobId(target)
},
{
key: 'copy-error',
label: st('queue.jobMenu.copyErrorMessage', 'Copy error message'),
icon: 'icon-[lucide--copy]',
onClick: copyErrorMessage
onClick: () => copyErrorMessage(target)
},
{
key: 'report-error',
label: st('queue.jobMenu.reportError', 'Report error'),
icon: 'icon-[lucide--message-circle-warning]',
onClick: reportError
onClick: () => reportError(target)
},
{ kind: 'divider', key: 'd2' },
{
key: 'delete',
label: st('queue.jobMenu.removeJob', 'Remove job'),
icon: 'icon-[lucide--circle-minus]',
onClick: removeFailedJob
onClick: () => removeFailedJob(target?.taskRef)
}
]
}
@@ -347,27 +328,27 @@ export function useJobMenu(
key: 'open-workflow',
label: jobMenuOpenWorkflowLabel.value,
icon: 'icon-[comfy--workflow]',
onClick: openJobWorkflow
onClick: () => openJobWorkflow(target)
},
{ kind: 'divider', key: 'd1' },
{
key: 'copy-id',
label: jobMenuCopyJobIdLabel.value,
icon: 'icon-[lucide--copy]',
onClick: copyJobId
onClick: () => copyJobId(target)
},
{ kind: 'divider', key: 'd2' },
{
key: 'cancel-job',
label: jobMenuCancelLabel.value,
icon: 'icon-[lucide--x]',
onClick: cancelJob
onClick: () => cancelJob(target)
}
]
})
}
return {
jobMenuEntries,
getJobMenuEntries: buildJobMenuEntries,
openJobWorkflow,
copyJobId,
cancelJob,

View File

@@ -1,108 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { effectScope, ref } from 'vue'
import type { EffectScope, Ref } from 'vue'
import { useDismissableOverlay } from '@/composables/useDismissableOverlay'
describe('useDismissableOverlay', () => {
let scope: EffectScope | undefined
let isOpen: Ref<boolean>
let overlayEl: HTMLElement
let triggerEl: HTMLElement
let outsideEl: HTMLElement
let dismissCount: number
const mountComposable = ({
dismissOnScroll = false,
getTriggerEl
}: {
dismissOnScroll?: boolean
getTriggerEl?: () => HTMLElement | null
} = {}) => {
scope = effectScope()
scope.run(() =>
useDismissableOverlay({
isOpen,
getOverlayEl: () => overlayEl,
getTriggerEl,
onDismiss: () => {
dismissCount += 1
},
dismissOnScroll
})
)
}
beforeEach(() => {
isOpen = ref(true)
overlayEl = document.createElement('div')
triggerEl = document.createElement('button')
outsideEl = document.createElement('div')
dismissCount = 0
document.body.append(overlayEl, triggerEl, outsideEl)
})
afterEach(() => {
scope?.stop()
scope = undefined
document.body.innerHTML = ''
})
it('dismisses on outside pointerdown', () => {
mountComposable()
outsideEl.dispatchEvent(new Event('pointerdown', { bubbles: true }))
expect(dismissCount).toBe(1)
})
it('ignores pointerdown inside the overlay', () => {
mountComposable()
overlayEl.dispatchEvent(new Event('pointerdown', { bubbles: true }))
expect(dismissCount).toBe(0)
})
it('ignores pointerdown inside the trigger', () => {
mountComposable({
getTriggerEl: () => triggerEl
})
triggerEl.dispatchEvent(new Event('pointerdown', { bubbles: true }))
expect(dismissCount).toBe(0)
})
it('dismisses on scroll when enabled', () => {
mountComposable({
dismissOnScroll: true
})
window.dispatchEvent(new Event('scroll'))
expect(dismissCount).toBe(1)
})
it('ignores scroll inside the overlay', () => {
mountComposable({
dismissOnScroll: true
})
overlayEl.dispatchEvent(new Event('scroll'))
expect(dismissCount).toBe(0)
})
it('does not dismiss when closed', () => {
isOpen.value = false
mountComposable({
dismissOnScroll: true
})
outsideEl.dispatchEvent(new Event('pointerdown', { bubbles: true }))
window.dispatchEvent(new Event('scroll'))
expect(dismissCount).toBe(0)
})
})

View File

@@ -1,60 +0,0 @@
import { useEventListener } from '@vueuse/core'
import { toValue } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
interface UseDismissableOverlayOptions {
isOpen: MaybeRefOrGetter<boolean>
getOverlayEl: () => HTMLElement | null
onDismiss: () => void
getTriggerEl?: () => HTMLElement | null
dismissOnScroll?: boolean
}
const isNode = (value: EventTarget | null | undefined): value is Node =>
value instanceof Node
const isInside = (target: Node, element: HTMLElement | null | undefined) =>
!!element?.contains(target)
export function useDismissableOverlay({
isOpen,
getOverlayEl,
onDismiss,
getTriggerEl,
dismissOnScroll = false
}: UseDismissableOverlayOptions) {
const dismissIfOutside = (event: Event) => {
if (!toValue(isOpen)) {
return
}
const overlay = getOverlayEl()
if (!overlay) {
return
}
if (!isNode(event.target)) {
onDismiss()
return
}
if (
isInside(event.target, overlay) ||
isInside(event.target, getTriggerEl?.())
) {
return
}
onDismiss()
}
useEventListener(window, 'pointerdown', dismissIfOutside, { capture: true })
if (dismissOnScroll) {
useEventListener(window, 'scroll', dismissIfOutside, {
capture: true,
passive: true
})
}
}

View File

@@ -23,9 +23,6 @@
:data-selected="selected"
:draggable="true"
@click.stop="$emit('click')"
@contextmenu.prevent.stop="
asset ? emit('context-menu', $event, asset) : undefined
"
@dragstart="dragStart"
>
<!-- Top Area: Media Preview -->
@@ -69,16 +66,30 @@
>
<i class="icon-[lucide--zoom-in] size-4" />
</Button>
<Button
variant="overlay-white"
size="icon"
:aria-label="$t('mediaAsset.actions.moreOptions')"
@click.stop="
asset ? emit('context-menu', $event, asset) : undefined
"
>
<i class="icon-[lucide--ellipsis] size-4" />
</Button>
<DropdownMenu v-if="asset" v-model:open="isActionsMenuOpen">
<DropdownMenuTrigger as-child>
<Button
variant="overlay-white"
size="icon"
:aria-label="$t('mediaAsset.actions.moreOptions')"
@click.stop
>
<i class="icon-[lucide--ellipsis] size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
close-on-scroll
class="z-1700 bg-transparent p-0 shadow-lg"
:side-offset="4"
:collision-padding="8"
>
<MediaAssetMenuItems
:entries="getAssetMenuEntries()"
surface="dropdown"
@action="void onAssetMenuAction($event)"
/>
</DropdownMenuContent>
</DropdownMenu>
</IconGroup>
</div>
</div>
@@ -141,7 +152,11 @@ 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 DropdownMenu from '@/components/ui/dropdown-menu/DropdownMenu.vue'
import DropdownMenuContent from '@/components/ui/dropdown-menu/DropdownMenuContent.vue'
import DropdownMenuTrigger from '@/components/ui/dropdown-menu/DropdownMenuTrigger.vue'
import { useAssetsStore } from '@/stores/assetsStore'
import type { MenuActionEntry, MenuEntry } from '@/types/menuTypes'
import {
formatDuration,
formatSize,
@@ -154,10 +169,12 @@ import { cn } from '@/utils/tailwindUtil'
import { getAssetType } from '../composables/media/assetMappers'
import { getAssetUrl } from '../utils/assetUrlUtil'
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
import { useMediaAssetMenu } from '../composables/useMediaAssetMenu'
import type { AssetItem } from '../schemas/assetSchema'
import { getAssetDisplayName } from '../utils/assetMetadataUtils'
import type { MediaKind } from '../schemas/mediaAssetSchema'
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
import MediaAssetMenuItems from './MediaAssetMenuItems.vue'
import MediaTitle from './MediaTitle.vue'
type PreviewKind = ReturnType<typeof getMediaTypeFromFilename>
@@ -177,12 +194,24 @@ function getTopComponent(kind: PreviewKind) {
return mediaComponents.top[kind] || mediaComponents.top.other
}
const { asset, loading, selected, showOutputCount, outputCount } = defineProps<{
const {
asset,
loading,
selected,
showOutputCount,
outputCount,
showDeleteButton,
selectedAssets,
isBulkMode
} = defineProps<{
asset?: AssetItem
loading?: boolean
selected?: boolean
showOutputCount?: boolean
outputCount?: number
showDeleteButton?: boolean
selectedAssets?: AssetItem[]
isBulkMode?: boolean
}>()
const assetsStore = useAssetsStore()
@@ -196,13 +225,19 @@ const emit = defineEmits<{
click: []
zoom: [asset: AssetItem]
'output-count-click': []
'context-menu': [event: MouseEvent, asset: AssetItem]
'asset-deleted': []
'bulk-download': [assets: AssetItem[]]
'bulk-delete': [assets: AssetItem[]]
'bulk-add-to-workflow': [assets: AssetItem[]]
'bulk-open-workflow': [assets: AssetItem[]]
'bulk-export-workflow': [assets: AssetItem[]]
}>()
const cardContainerRef = ref<HTMLElement>()
const isVideoPlaying = ref(false)
const showVideoControls = ref(false)
const isActionsMenuOpen = ref(false)
// Store actual image dimensions
const imageDimensions = ref<{ width: number; height: number } | undefined>()
@@ -210,6 +245,15 @@ const imageDimensions = ref<{ width: number; height: number } | undefined>()
const isHovered = useElementHover(cardContainerRef)
const actions = useMediaAssetActions()
const { getMenuEntries } = useMediaAssetMenu({
inspectAsset: (target) => emit('zoom', target),
assetDeleted: () => emit('asset-deleted'),
bulkDownload: (assets) => emit('bulk-download', assets),
bulkDelete: (assets) => emit('bulk-delete', assets),
bulkAddToWorkflow: (assets) => emit('bulk-add-to-workflow', assets),
bulkOpenWorkflow: (assets) => emit('bulk-open-workflow', assets),
bulkExportWorkflow: (assets) => emit('bulk-export-workflow', assets)
})
// Get asset type from tags
const assetType = computed(() => {
@@ -290,7 +334,12 @@ const metaInfo = computed(() => {
const showActionsOverlay = computed(() => {
if (loading || !asset || isDeleting.value) return false
return isHovered.value || selected || isVideoPlaying.value
return (
isHovered.value ||
selected ||
isVideoPlaying.value ||
isActionsMenuOpen.value
)
})
const handleZoomClick = () => {
@@ -306,6 +355,26 @@ const handleImageLoaded = (width: number, height: number) => {
const handleOutputCountClick = () => {
emit('output-count-click')
}
function getAssetMenuEntries(): MenuEntry[] {
if (!asset) {
return []
}
return getMenuEntries({
asset,
assetType: assetType.value,
fileKind: fileKind.value,
showDeleteButton,
selectedAssets,
isBulkMode
})
}
async function onAssetMenuAction(entry: MenuActionEntry) {
await entry.onClick?.()
}
function dragStart(e: DragEvent) {
if (!asset?.preview_url) return

View File

@@ -1,143 +0,0 @@
import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick } from 'vue'
import type { ComponentPublicInstance } from 'vue'
import MediaAssetContextMenu from '@/platform/assets/components/MediaAssetContextMenu.vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key
})
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
vi.mock('@/platform/workflow/utils/workflowExtractionUtil', () => ({
supportsWorkflowMetadata: () => true
}))
vi.mock('@/utils/formatUtil', () => ({
isPreviewableMediaType: () => true
}))
vi.mock('@/utils/loaderNodeUtil', () => ({
detectNodeTypeFromFilename: () => ({ nodeType: 'LoadImage' })
}))
const mediaAssetActions = {
addWorkflow: vi.fn(),
downloadAsset: vi.fn(),
openWorkflow: vi.fn(),
exportWorkflow: vi.fn(),
copyJobId: vi.fn(),
deleteAssets: vi.fn().mockResolvedValue(false)
}
vi.mock('../composables/useMediaAssetActions', () => ({
useMediaAssetActions: () => mediaAssetActions
}))
const contextMenuStub = defineComponent({
name: 'ContextMenu',
props: {
pt: {
type: Object,
default: undefined
}
},
emits: ['hide'],
data() {
return {
visible: false
}
},
methods: {
show() {
this.visible = true
},
hide() {
this.visible = false
this.$emit('hide')
}
},
template: `
<div
v-if="visible"
class="context-menu-stub"
v-bind="pt?.root"
/>
`
})
const asset: AssetItem = {
id: 'asset-1',
name: 'image.png',
tags: [],
user_metadata: {}
}
const buttonStub = {
template: '<div class="button-stub"><slot /></div>'
}
type MediaAssetContextMenuExposed = ComponentPublicInstance & {
show: (event: MouseEvent) => void
}
const mountComponent = () =>
mount(MediaAssetContextMenu, {
attachTo: document.body,
props: {
asset,
assetType: 'output',
fileKind: 'image'
},
global: {
stubs: {
ContextMenu: contextMenuStub,
Button: buttonStub
}
}
})
async function showMenu(
wrapper: ReturnType<typeof mountComponent>
): Promise<HTMLElement> {
const exposed = wrapper.vm as MediaAssetContextMenuExposed
const event = new MouseEvent('contextmenu', { bubbles: true })
exposed.show(event)
await nextTick()
return wrapper.get('.context-menu-stub').element as HTMLElement
}
afterEach(() => {
vi.clearAllMocks()
document.body.innerHTML = ''
})
describe('MediaAssetContextMenu', () => {
it('dismisses outside pointerdown using the rendered root id', async () => {
const wrapper = mountComponent()
const outside = document.createElement('div')
document.body.append(outside)
const menu = await showMenu(wrapper)
const menuId = menu.id
expect(menuId).not.toBe('')
expect(document.getElementById(menuId)).toBe(menu)
outside.dispatchEvent(new Event('pointerdown', { bubbles: true }))
await nextTick()
expect(wrapper.find('.context-menu-stub').exists()).toBe(false)
expect(wrapper.emitted('hide')).toEqual([[]])
wrapper.unmount()
})
})

View File

@@ -1,286 +0,0 @@
<template>
<ContextMenu
ref="contextMenu"
:model="contextMenuItems"
:pt="{
root: {
id: contextMenuId,
class: cn(
'rounded-lg',
'bg-secondary-background text-base-foreground',
'shadow-lg'
)
}
}"
@hide="onMenuHide"
>
<template #item="{ item, props }">
<Button
variant="secondary"
class="w-full justify-start"
v-bind="props.action"
>
<i v-if="item.icon" :class="item.icon" class="size-4" />
<span>{{
typeof item.label === 'function' ? item.label() : (item.label ?? '')
}}</span>
</Button>
</template>
</ContextMenu>
</template>
<script setup lang="ts">
import ContextMenu from 'primevue/contextmenu'
import type { MenuItem } from 'primevue/menuitem'
import { computed, ref, useId } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useDismissableOverlay } from '@/composables/useDismissableOverlay'
import { isCloud } from '@/platform/distribution/types'
import { supportsWorkflowMetadata } from '@/platform/workflow/utils/workflowExtractionUtil'
import { isPreviewableMediaType } from '@/utils/formatUtil'
import { detectNodeTypeFromFilename } from '@/utils/loaderNodeUtil'
import { cn } from '@/utils/tailwindUtil'
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
import type { AssetItem } from '../schemas/assetSchema'
import type { AssetContext, MediaKind } from '../schemas/mediaAssetSchema'
const {
asset,
assetType,
fileKind,
showDeleteButton,
selectedAssets,
isBulkMode
} = defineProps<{
asset: AssetItem
assetType: AssetContext['type']
fileKind: MediaKind
showDeleteButton?: boolean
selectedAssets?: AssetItem[]
isBulkMode?: boolean
}>()
const emit = defineEmits<{
zoom: []
hide: []
'asset-deleted': []
'bulk-download': [assets: AssetItem[]]
'bulk-delete': [assets: AssetItem[]]
'bulk-add-to-workflow': [assets: AssetItem[]]
'bulk-open-workflow': [assets: AssetItem[]]
'bulk-export-workflow': [assets: AssetItem[]]
}>()
type ContextMenuHandle = {
show: (event: MouseEvent) => void
hide: () => void
}
const contextMenu = ref<ContextMenuHandle | null>(null)
const contextMenuId = useId()
const isVisible = ref(false)
const actions = useMediaAssetActions()
const { t } = useI18n()
useDismissableOverlay({
isOpen: isVisible,
getOverlayEl: () => document.getElementById(contextMenuId),
onDismiss: hide,
dismissOnScroll: true
})
const showAddToWorkflow = computed(() => {
// Output assets can always be added
if (assetType === 'output') return true
// Input assets: check if file type is supported by loader nodes
if (assetType === 'input' && asset?.name) {
const { nodeType } = detectNodeTypeFromFilename(asset.name)
return nodeType !== null
}
return false
})
const showWorkflowActions = computed(() => {
// Output assets always have workflow metadata
if (assetType === 'output') return true
// Input assets: only formats that support workflow metadata
if (assetType === 'input' && asset?.name) {
return supportsWorkflowMetadata(asset.name)
}
return false
})
const showCopyJobId = computed(() => {
return assetType !== 'input'
})
const shouldShowDeleteButton = computed(() => {
const propAllows = showDeleteButton ?? true
const typeAllows =
assetType === 'output' || (assetType === 'input' && isCloud)
return propAllows && typeAllows
})
// Context menu items
const contextMenuItems = computed<MenuItem[]>(() => {
if (!asset) return []
const items: MenuItem[] = []
// Check if current asset is part of the selection
const isCurrentAssetSelected = selectedAssets?.some(
(selectedAsset) => selectedAsset.id === asset.id
)
// Bulk mode: Show selected count and bulk actions only if current asset is selected
if (
isBulkMode &&
selectedAssets &&
selectedAssets.length > 0 &&
isCurrentAssetSelected
) {
// Header item showing selected count
items.push({
label: t('mediaAsset.selection.multipleSelectedAssets'),
disabled: true
})
// Bulk Add to Workflow
items.push({
label: t('mediaAsset.selection.insertAllAssetsAsNodes'),
icon: 'icon-[comfy--node]',
command: () => emit('bulk-add-to-workflow', selectedAssets)
})
// Bulk Open Workflow
items.push({
label: t('mediaAsset.selection.openWorkflowAll'),
icon: 'icon-[comfy--workflow]',
command: () => emit('bulk-open-workflow', selectedAssets)
})
// Bulk Export Workflow
items.push({
label: t('mediaAsset.selection.exportWorkflowAll'),
icon: 'icon-[lucide--file-output]',
command: () => emit('bulk-export-workflow', selectedAssets)
})
// Bulk Download
items.push({
label: t('mediaAsset.selection.downloadSelectedAll'),
icon: 'icon-[lucide--download]',
command: () => emit('bulk-download', selectedAssets)
})
// Bulk Delete (if allowed)
if (shouldShowDeleteButton.value) {
items.push({
label: t('mediaAsset.selection.deleteSelectedAll'),
icon: 'icon-[lucide--trash-2]',
command: () => emit('bulk-delete', selectedAssets)
})
}
return items
}
// Individual mode: Show all menu options
// Inspect
if (isPreviewableMediaType(fileKind)) {
items.push({
label: t('mediaAsset.actions.inspect'),
icon: 'icon-[lucide--zoom-in]',
command: () => emit('zoom')
})
}
// Add to workflow (conditional)
if (showAddToWorkflow.value) {
items.push({
label: t('mediaAsset.actions.insertAsNodeInWorkflow'),
icon: 'icon-[comfy--node]',
command: () => actions.addWorkflow(asset)
})
}
// Download
items.push({
label: t('mediaAsset.actions.download'),
icon: 'icon-[lucide--download]',
command: () => actions.downloadAsset(asset)
})
// Separator before workflow actions (only if there are workflow actions)
if (showWorkflowActions.value) {
items.push({ separator: true })
items.push({
label: t('mediaAsset.actions.openWorkflow'),
icon: 'icon-[comfy--workflow]',
command: () => actions.openWorkflow(asset)
})
items.push({
label: t('mediaAsset.actions.exportWorkflow'),
icon: 'icon-[lucide--file-output]',
command: () => actions.exportWorkflow(asset)
})
}
// Copy job ID
if (showCopyJobId.value) {
items.push({ separator: true })
items.push({
label: t('mediaAsset.actions.copyJobId'),
icon: 'icon-[lucide--copy]',
command: async () => {
await actions.copyJobId(asset)
}
})
}
// Delete
if (shouldShowDeleteButton.value) {
items.push({ separator: true })
items.push({
label: t('mediaAsset.actions.delete'),
icon: 'icon-[lucide--trash-2]',
command: async () => {
if (asset) {
const confirmed = await actions.deleteAssets(asset)
if (confirmed) {
emit('asset-deleted')
}
}
}
})
}
return items
})
function onMenuHide() {
isVisible.value = false
emit('hide')
}
function show(event: MouseEvent) {
isVisible.value = true
contextMenu.value?.show(event)
}
function hide() {
isVisible.value = false
contextMenu.value?.hide()
}
defineExpose({ show, hide })
</script>

View File

@@ -0,0 +1,64 @@
<script setup lang="ts">
import type { MenuActionEntry, MenuEntry } from '@/types/menuTypes'
import Button from '@/components/ui/button/Button.vue'
import ContextMenuItem from '@/components/ui/context-menu/ContextMenuItem.vue'
import ContextMenuSeparator from '@/components/ui/context-menu/ContextMenuSeparator.vue'
import DropdownMenuItem from '@/components/ui/dropdown-menu/DropdownMenuItem.vue'
import DropdownMenuSeparator from '@/components/ui/dropdown-menu/DropdownMenuSeparator.vue'
import { cn } from '@/utils/tailwindUtil'
const { entries, surface } = defineProps<{
entries: MenuEntry[]
surface: 'context' | 'dropdown'
}>()
const emit = defineEmits<{
action: [entry: MenuActionEntry]
}>()
function isActionEntry(entry: MenuEntry): entry is MenuActionEntry {
return entry.kind !== 'divider'
}
</script>
<template>
<div
class="media-asset-menu-panel flex min-w-56 flex-col rounded-lg border border-border-subtle bg-secondary-background p-2 text-base-foreground"
>
<template v-for="entry in entries" :key="entry.key">
<ContextMenuSeparator
v-if="surface === 'context' && entry.kind === 'divider'"
class="m-1 h-px bg-border-subtle"
/>
<DropdownMenuSeparator
v-else-if="surface === 'dropdown' && entry.kind === 'divider'"
class="m-1 h-px bg-border-subtle"
/>
<ContextMenuItem
v-else-if="surface === 'context' && isActionEntry(entry)"
as-child
:disabled="entry.disabled"
:text-value="entry.label"
@select="emit('action', entry)"
>
<Button variant="secondary" size="sm" class="w-full justify-start">
<i v-if="entry.icon" :class="cn(entry.icon, 'size-4')" />
<span>{{ entry.label }}</span>
</Button>
</ContextMenuItem>
<DropdownMenuItem
v-else-if="isActionEntry(entry)"
as-child
:disabled="entry.disabled"
:text-value="entry.label"
@select="emit('action', entry)"
>
<Button variant="secondary" size="sm" class="w-full justify-start">
<i v-if="entry.icon" :class="cn(entry.icon, 'size-4')" />
<span>{{ entry.label }}</span>
</Button>
</DropdownMenuItem>
</template>
</div>
</template>

View File

@@ -0,0 +1,268 @@
import { useI18n } from 'vue-i18n'
import { isCloud } from '@/platform/distribution/types'
import type { MenuEntry } from '@/types/menuTypes'
import { isPreviewableMediaType } from '@/utils/formatUtil'
import { detectNodeTypeFromFilename } from '@/utils/loaderNodeUtil'
import { supportsWorkflowMetadata } from '@/platform/workflow/utils/workflowExtractionUtil'
import { getAssetType } from './media/assetMappers'
import { useMediaAssetActions } from './useMediaAssetActions'
import type { AssetItem } from '../schemas/assetSchema'
import type { AssetContext, MediaKind } from '../schemas/mediaAssetSchema'
type MediaAssetMenuContext = {
asset: AssetItem
assetType: AssetContext['type']
fileKind: MediaKind
showDeleteButton?: boolean
selectedAssets?: AssetItem[]
isBulkMode?: boolean
}
type MediaAssetMenuHandlers = {
inspectAsset?: (asset: AssetItem) => void | Promise<void>
assetDeleted?: (asset: AssetItem) => void | Promise<void>
bulkDownload?: (assets: AssetItem[]) => void | Promise<void>
bulkDelete?: (assets: AssetItem[]) => void | Promise<void>
bulkAddToWorkflow?: (assets: AssetItem[]) => void | Promise<void>
bulkOpenWorkflow?: (assets: AssetItem[]) => void | Promise<void>
bulkExportWorkflow?: (assets: AssetItem[]) => void | Promise<void>
}
function canAddToWorkflow(
candidate: AssetItem,
assetType: AssetContext['type']
): boolean {
if (assetType === 'output') return true
if (assetType === 'input' && candidate.name) {
return detectNodeTypeFromFilename(candidate.name).nodeType !== null
}
return false
}
function canShowWorkflowActions(
candidate: AssetItem,
assetType: AssetContext['type']
): boolean {
if (assetType === 'output') return true
if (assetType === 'input' && candidate.name) {
return supportsWorkflowMetadata(candidate.name)
}
return false
}
function canDeleteAsset(
assetType: AssetContext['type'],
showDeleteButton?: boolean
): boolean {
const propAllows = showDeleteButton ?? true
const typeAllows =
assetType === 'output' || (assetType === 'input' && isCloud)
return propAllows && typeAllows
}
export function useMediaAssetMenu(handlers: MediaAssetMenuHandlers = {}) {
const { t } = useI18n()
const actions = useMediaAssetActions()
async function deleteAsset(asset: AssetItem) {
const deleted = await actions.deleteAssets(asset)
if (deleted) {
await handlers.assetDeleted?.(asset)
}
}
async function deleteSelectedAssets(selectedAssets: AssetItem[]) {
if (handlers.bulkDelete) {
await handlers.bulkDelete(selectedAssets)
return
}
await actions.deleteAssets(selectedAssets)
}
function getMenuEntries({
asset,
assetType,
fileKind,
showDeleteButton,
selectedAssets,
isBulkMode
}: MediaAssetMenuContext): MenuEntry[] {
const isSelectedAsset = selectedAssets?.some(
(selectedAsset) => selectedAsset.id === asset.id
)
const showBulkActions =
isBulkMode &&
selectedAssets &&
selectedAssets.length > 0 &&
isSelectedAsset
if (showBulkActions) {
const allSelectedCanAddToWorkflow = selectedAssets.every(
(selectedAsset) =>
canAddToWorkflow(selectedAsset, getAssetType(selectedAsset.tags))
)
const allSelectedSupportWorkflowActions = selectedAssets.every(
(selectedAsset) =>
canShowWorkflowActions(
selectedAsset,
getAssetType(selectedAsset.tags)
)
)
const bulkDeleteEnabled = selectedAssets.every((selectedAsset) =>
canDeleteAsset(getAssetType(selectedAsset.tags), showDeleteButton)
)
return [
{
key: 'bulk-selection-header',
label: t('mediaAsset.selection.multipleSelectedAssets'),
disabled: true
},
...(allSelectedCanAddToWorkflow
? [
{
key: 'bulk-add-to-workflow',
label: t('mediaAsset.selection.insertAllAssetsAsNodes'),
icon: 'icon-[comfy--node]',
onClick: () => {
if (handlers.bulkAddToWorkflow) {
return handlers.bulkAddToWorkflow(selectedAssets)
}
return actions.addMultipleToWorkflow(selectedAssets)
}
} satisfies MenuEntry
]
: []),
...(allSelectedSupportWorkflowActions
? [
{
key: 'bulk-open-workflow',
label: t('mediaAsset.selection.openWorkflowAll'),
icon: 'icon-[comfy--workflow]',
onClick: () => {
if (handlers.bulkOpenWorkflow) {
return handlers.bulkOpenWorkflow(selectedAssets)
}
return actions.openMultipleWorkflows(selectedAssets)
}
} satisfies MenuEntry,
{
key: 'bulk-export-workflow',
label: t('mediaAsset.selection.exportWorkflowAll'),
icon: 'icon-[lucide--file-output]',
onClick: () => {
if (handlers.bulkExportWorkflow) {
return handlers.bulkExportWorkflow(selectedAssets)
}
return actions.exportMultipleWorkflows(selectedAssets)
}
} satisfies MenuEntry
]
: []),
{
key: 'bulk-download',
label: t('mediaAsset.selection.downloadSelectedAll'),
icon: 'icon-[lucide--download]',
onClick: () => {
if (handlers.bulkDownload) {
return handlers.bulkDownload(selectedAssets)
}
return actions.downloadMultipleAssets(selectedAssets)
}
},
...(bulkDeleteEnabled
? [
{
key: 'bulk-delete',
label: t('mediaAsset.selection.deleteSelectedAll'),
icon: 'icon-[lucide--trash-2]',
onClick: async () => {
await deleteSelectedAssets(selectedAssets)
}
} satisfies MenuEntry
]
: [])
]
}
const entries: MenuEntry[] = []
const showWorkflowActions = canShowWorkflowActions(asset, assetType)
const deleteEnabled = canDeleteAsset(assetType, showDeleteButton)
if (isPreviewableMediaType(fileKind)) {
entries.push({
key: 'inspect',
label: t('mediaAsset.actions.inspect'),
icon: 'icon-[lucide--zoom-in]',
onClick: () => handlers.inspectAsset?.(asset)
})
}
if (canAddToWorkflow(asset, assetType)) {
entries.push({
key: 'add-to-workflow',
label: t('mediaAsset.actions.insertAsNodeInWorkflow'),
icon: 'icon-[comfy--node]',
onClick: () => actions.addWorkflow(asset)
})
}
entries.push({
key: 'download',
label: t('mediaAsset.actions.download'),
icon: 'icon-[lucide--download]',
onClick: () => actions.downloadAsset(asset)
})
if (showWorkflowActions) {
entries.push({ kind: 'divider', key: 'workflow-divider' })
entries.push({
key: 'open-workflow',
label: t('mediaAsset.actions.openWorkflow'),
icon: 'icon-[comfy--workflow]',
onClick: () => actions.openWorkflow(asset)
})
entries.push({
key: 'export-workflow',
label: t('mediaAsset.actions.exportWorkflow'),
icon: 'icon-[lucide--file-output]',
onClick: () => actions.exportWorkflow(asset)
})
}
if (assetType !== 'input') {
entries.push({ kind: 'divider', key: 'copy-job-id-divider' })
entries.push({
key: 'copy-job-id',
label: t('mediaAsset.actions.copyJobId'),
icon: 'icon-[lucide--copy]',
onClick: async () => {
await actions.copyJobId(asset)
}
})
}
if (deleteEnabled) {
entries.push({ kind: 'divider', key: 'delete-divider' })
entries.push({
key: 'delete',
label: t('mediaAsset.actions.delete'),
icon: 'icon-[lucide--trash-2]',
onClick: async () => deleteAsset(asset)
})
}
return entries
}
return { getMenuEntries }
}

15
src/types/menuTypes.ts Normal file
View File

@@ -0,0 +1,15 @@
export type MenuActionEntry = {
kind?: 'item'
key: string
label: string
icon?: string
disabled?: boolean
onClick?: () => void | Promise<void>
}
type MenuDividerEntry = {
kind: 'divider'
key: string
}
export type MenuEntry = MenuActionEntry | MenuDividerEntry