mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 06:20:11 +00:00
Compare commits
3 Commits
test/tier2
...
bl/pr-1030
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f00c5b96b6 | ||
|
|
47f9b0a20e | ||
|
|
97c8be9ce9 |
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
55
browser_tests/tests/sidebar/assetsSidebar.spec.ts
Normal file
55
browser_tests/tests/sidebar/assetsSidebar.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
70
browser_tests/tests/sidebar/jobHistorySidebar.spec.ts
Normal file
70
browser_tests/tests/sidebar/jobHistorySidebar.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
88
src/components/queue/job/JobMenuItems.vue
Normal file
88
src/components/queue/job/JobMenuItems.vue
Normal 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>
|
||||
@@ -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`
|
||||
|
||||
@@ -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))',
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
57
src/components/ui/context-menu/ContextMenu.test.ts
Normal file
57
src/components/ui/context-menu/ContextMenu.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
16
src/components/ui/context-menu/ContextMenu.vue
Normal file
16
src/components/ui/context-menu/ContextMenu.vue
Normal 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>
|
||||
58
src/components/ui/context-menu/ContextMenuContent.vue
Normal file
58
src/components/ui/context-menu/ContextMenuContent.vue
Normal 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>
|
||||
32
src/components/ui/context-menu/ContextMenuItem.vue
Normal file
32
src/components/ui/context-menu/ContextMenuItem.vue
Normal 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>
|
||||
21
src/components/ui/context-menu/ContextMenuSeparator.vue
Normal file
21
src/components/ui/context-menu/ContextMenuSeparator.vue
Normal 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>
|
||||
15
src/components/ui/context-menu/ContextMenuTrigger.vue
Normal file
15
src/components/ui/context-menu/ContextMenuTrigger.vue
Normal 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>
|
||||
57
src/components/ui/dropdown-menu/DropdownMenu.test.ts
Normal file
57
src/components/ui/dropdown-menu/DropdownMenu.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
16
src/components/ui/dropdown-menu/DropdownMenu.vue
Normal file
16
src/components/ui/dropdown-menu/DropdownMenu.vue
Normal 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>
|
||||
62
src/components/ui/dropdown-menu/DropdownMenuContent.vue
Normal file
62
src/components/ui/dropdown-menu/DropdownMenuContent.vue
Normal 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>
|
||||
31
src/components/ui/dropdown-menu/DropdownMenuItem.vue
Normal file
31
src/components/ui/dropdown-menu/DropdownMenuItem.vue
Normal 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>
|
||||
23
src/components/ui/dropdown-menu/DropdownMenuSeparator.vue
Normal file
23
src/components/ui/dropdown-menu/DropdownMenuSeparator.vue
Normal 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>
|
||||
15
src/components/ui/dropdown-menu/DropdownMenuTrigger.vue
Normal file
15
src/components/ui/dropdown-menu/DropdownMenuTrigger.vue
Normal 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
64
src/platform/assets/components/MediaAssetMenuItems.vue
Normal file
64
src/platform/assets/components/MediaAssetMenuItems.vue
Normal 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>
|
||||
268
src/platform/assets/composables/useMediaAssetMenu.ts
Normal file
268
src/platform/assets/composables/useMediaAssetMenu.ts
Normal 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
15
src/types/menuTypes.ts
Normal 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
|
||||
Reference in New Issue
Block a user