Compare commits

...

1 Commits

Author SHA1 Message Date
Benjamin Lu
3bdcf21057 fix: enable QPOV2 job right-click context menus 2026-02-07 21:08:59 -08:00
6 changed files with 262 additions and 1 deletions

View File

@@ -0,0 +1,100 @@
import { mount } from '@vue/test-utils'
import { createI18n } from 'vue-i18n'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { JobListItem } from '@/composables/queue/useJobList'
const { jobItems, settingGetMock } = vi.hoisted(() => ({
jobItems: [] as JobListItem[],
settingGetMock: vi.fn()
}))
vi.mock('@/composables/queue/useJobList', () => ({
useJobList: () => ({
jobItems: {
get value() {
return jobItems
}
}
})
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: settingGetMock
})
}))
vi.mock('@/platform/assets/components/MediaAssetCard.vue', () => ({
default: {
name: 'MediaAssetCard',
template: '<div class="media-asset-card-stub" />'
}
}))
vi.mock('@/platform/assets/components/ActiveMediaAssetCard.vue', () => ({
default: {
name: 'ActiveMediaAssetCard',
emits: ['context-menu'],
template:
'<div class="active-media-asset-card-stub" @contextmenu="$emit(\'context-menu\', $event)" />'
}
}))
import AssetsSidebarGridView from '@/components/sidebar/tabs/AssetsSidebarGridView.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} }
})
const VirtualGridStub = {
name: 'VirtualGrid',
props: {
items: { type: Array, required: true }
},
template:
'<div><div v-for="item in items" :key="item.key"><slot name="item" :item="item" /></div></div>'
}
const createJobItem = (overrides: Partial<JobListItem> = {}): JobListItem => ({
id: 'job-1',
title: 'Active job',
meta: 'In progress',
state: 'running',
...overrides
})
describe('AssetsSidebarGridView', () => {
beforeEach(() => {
jobItems.splice(0, jobItems.length)
settingGetMock.mockReset()
settingGetMock.mockReturnValue(true)
})
it('emits job context menu for active job cards in QPOV2', async () => {
const activeJob = createJobItem()
jobItems.push(activeJob)
const wrapper = mount(AssetsSidebarGridView, {
props: {
assets: [],
isSelected: () => false,
showOutputCount: () => false,
getOutputCount: () => 0
},
global: {
plugins: [i18n],
stubs: {
VirtualGrid: VirtualGridStub
}
}
})
await wrapper.get('.active-media-asset-card-stub').trigger('contextmenu')
expect(wrapper.emitted('job-context-menu')).toHaveLength(1)
expect(wrapper.emitted('job-context-menu')?.[0]?.[1]).toEqual(activeJob)
})
})

View File

@@ -10,6 +10,7 @@
v-for="job in activeJobItems"
:key="job.id"
:job="job"
@context-menu="emit('job-context-menu', $event, job)"
/>
</div>
@@ -60,6 +61,7 @@ import { useI18n } from 'vue-i18n'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import ActiveMediaAssetCard from '@/platform/assets/components/ActiveMediaAssetCard.vue'
import type { JobListItem } from '@/composables/queue/useJobList'
import { useJobList } from '@/composables/queue/useJobList'
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
@@ -84,6 +86,7 @@ const {
const emit = defineEmits<{
(e: 'select-asset', asset: AssetItem): void
(e: 'context-menu', event: MouseEvent, asset: AssetItem): void
(e: 'job-context-menu', event: MouseEvent, job: JobListItem): void
(e: 'approach-end'): void
(e: 'zoom', asset: AssetItem): void
(e: 'output-count-click', asset: AssetItem): void

View File

@@ -0,0 +1,113 @@
import { mount } from '@vue/test-utils'
import { computed } from 'vue'
import { createI18n } from 'vue-i18n'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { JobListItem } from '@/composables/queue/useJobList'
const { jobItems, settingGetMock } = vi.hoisted(() => ({
jobItems: [] as JobListItem[],
settingGetMock: vi.fn()
}))
vi.mock('@/composables/queue/useJobList', () => ({
useJobList: () => ({
jobItems: {
get value() {
return jobItems
}
}
})
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: settingGetMock
})
}))
vi.mock('@/stores/assetsStore', () => ({
useAssetsStore: () => ({
isAssetDeleting: () => false
})
}))
vi.mock('@/composables/queue/useJobActions', () => ({
useJobActions: () => ({
cancelAction: {
icon: 'icon-[lucide--x]',
label: 'Cancel',
variant: 'destructive'
},
canCancelJob: computed(() => true),
runCancelJob: vi.fn()
})
}))
import AssetsSidebarListView from '@/components/sidebar/tabs/AssetsSidebarListView.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} }
})
const VirtualGridStub = {
name: 'VirtualGrid',
props: {
items: { type: Array, required: true }
},
template:
'<div><div v-for="item in items" :key="item.key"><slot name="item" :item="item" /></div></div>'
}
const AssetsListItemStub = {
name: 'AssetsListItem',
emits: ['contextmenu'],
template:
'<div class="assets-list-item-stub" @contextmenu="$emit(\'contextmenu\', $event)"><slot name="actions" /></div>'
}
const createJobItem = (overrides: Partial<JobListItem> = {}): JobListItem => ({
id: 'job-1',
title: 'Active job',
meta: 'In progress',
state: 'running',
...overrides
})
describe('AssetsSidebarListView', () => {
beforeEach(() => {
jobItems.splice(0, jobItems.length)
settingGetMock.mockReset()
settingGetMock.mockReturnValue(true)
})
it('emits job context menu for active jobs in QPOV2 list mode', async () => {
const activeJob = createJobItem()
jobItems.push(activeJob)
const wrapper = mount(AssetsSidebarListView, {
props: {
assets: [],
isSelected: () => false
},
global: {
plugins: [i18n],
stubs: {
VirtualGrid: VirtualGridStub,
AssetsListItem: AssetsListItemStub,
Button: true,
LoadingOverlay: true
}
}
})
wrapper
.findComponent(AssetsListItemStub)
.vm.$emit('contextmenu', new MouseEvent('contextmenu'))
expect(wrapper.emitted('job-context-menu')).toHaveLength(1)
expect(wrapper.emitted('job-context-menu')?.[0]?.[1]).toEqual(activeJob)
})
})

View File

@@ -23,6 +23,7 @@
:progress-current-percent="job.progressCurrentPercent"
@mouseenter="onJobEnter(job.id)"
@mouseleave="onJobLeave(job.id)"
@contextmenu.prevent.stop="emit('job-context-menu', $event, job)"
@click.stop
>
<template v-if="hoveredJobId === job.id" #actions>
@@ -150,6 +151,7 @@ const assetsStore = useAssetsStore()
const emit = defineEmits<{
(e: 'select-asset', asset: AssetItem): void
(e: 'context-menu', event: MouseEvent, asset: AssetItem): void
(e: 'job-context-menu', event: MouseEvent, job: JobListItem): void
(e: 'approach-end'): void
}>()

View File

@@ -103,6 +103,7 @@
:asset-type="activeTab"
@select-asset="handleAssetSelect"
@context-menu="handleAssetContextMenu"
@job-context-menu="handleJobContextMenu"
@approach-end="handleApproachEnd"
/>
<AssetsSidebarGridView
@@ -114,6 +115,7 @@
:get-output-count="getOutputCount"
@select-asset="handleAssetSelect"
@context-menu="handleAssetContextMenu"
@job-context-menu="handleJobContextMenu"
@approach-end="handleApproachEnd"
@zoom="handleZoomClick"
@output-count-click="enterFolderView"
@@ -198,6 +200,11 @@
@bulk-open-workflow="handleBulkOpenWorkflow"
@bulk-export-workflow="handleBulkExportWorkflow"
/>
<JobContextMenu
ref="jobContextMenuRef"
:entries="jobMenuEntries"
@action="onJobMenuAction"
/>
</template>
<script setup lang="ts">
@@ -218,6 +225,7 @@ import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import Load3dViewerContent from '@/components/load3d/Load3dViewerContent.vue'
import AssetsSidebarGridView from '@/components/sidebar/tabs/AssetsSidebarGridView.vue'
import AssetsSidebarListView from '@/components/sidebar/tabs/AssetsSidebarListView.vue'
import JobContextMenu from '@/components/queue/job/JobContextMenu.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import Tab from '@/components/tab/Tab.vue'
@@ -225,7 +233,13 @@ 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 type { JobListItem } from '@/composables/queue/useJobList'
import type { MenuEntry } from '@/composables/queue/useJobMenu'
import { useJobMenu } from '@/composables/queue/useJobMenu'
import {
getAssetType,
mapTaskOutputToAssetItem
} 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'
@@ -273,6 +287,20 @@ const isListView = computed(
const contextMenuRef = ref<InstanceType<typeof MediaAssetContextMenu>>()
const contextMenuAsset = ref<AssetItem | null>(null)
const currentJobMenuItem = ref<JobListItem | null>(null)
const jobContextMenuRef = ref<InstanceType<typeof JobContextMenu> | null>(null)
const onInspectJobAsset = (item: JobListItem) => {
const task = item.taskRef
const preview = task?.previewOutput
if (!task || !preview) return
handleZoomClick(mapTaskOutputToAssetItem(task, preview))
}
const { jobMenuEntries } = useJobMenu(
() => currentJobMenuItem.value,
onInspectJobAsset
)
// 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)
@@ -482,6 +510,17 @@ function handleAssetContextMenu(event: MouseEvent, asset: AssetItem) {
})
}
function handleJobContextMenu(event: MouseEvent, job: JobListItem) {
currentJobMenuItem.value = job
jobContextMenuRef.value?.open(event)
}
const onJobMenuAction = async (entry: MenuEntry) => {
if (entry.kind === 'divider') return
if (entry.onClick) await entry.onClick()
jobContextMenuRef.value?.hide()
}
function handleContextMenuHide() {
// Delay clearing to allow command callbacks to emit before component unmounts
requestAnimationFrame(() => {

View File

@@ -4,6 +4,7 @@
tabindex="0"
:aria-label="t('sideToolbar.activeJobStatus', { status: statusText })"
class="flex flex-col gap-2 p-2 rounded-lg"
@contextmenu.stop.prevent="emit('context-menu', $event)"
@mouseenter="hovered = true"
@mouseleave="hovered = false"
@focusin="hovered = true"
@@ -86,6 +87,9 @@ import type { JobListItem } from '@/composables/queue/useJobList'
import { useProgressBarBackground } from '@/composables/useProgressBarBackground'
const { job } = defineProps<{ job: JobListItem }>()
const emit = defineEmits<{
(e: 'context-menu', event: MouseEvent): void
}>()
const { t } = useI18n()
const hovered = ref(false)