mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 14:30:41 +00:00
refactor: simplify job menu handling
This commit is contained in:
@@ -72,9 +72,7 @@ const emit = defineEmits<{
|
||||
|
||||
const { wrapWithErrorHandlingAsync } = useErrorHandling()
|
||||
|
||||
const { getJobMenuEntries } = useJobMenu(undefined, (item) =>
|
||||
emit('viewItem', item)
|
||||
)
|
||||
const { getJobMenuEntries } = useJobMenu((item) => emit('viewItem', item))
|
||||
|
||||
const onCancelItemEvent = (item: JobListItem) => {
|
||||
emit('cancelItem', item)
|
||||
|
||||
@@ -83,27 +83,25 @@
|
||||
<i class="icon-[lucide--ellipsis] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
<template
|
||||
#content="{ close, itemComponent, separatorComponent }"
|
||||
>
|
||||
<template #content="{ itemComponent, separatorComponent }">
|
||||
<MenuPanel
|
||||
:entries="getMenuEntries(job)"
|
||||
:item-component="itemComponent"
|
||||
:separator-component="separatorComponent"
|
||||
v-bind="jobMenuPanelProps"
|
||||
@action="onMenuAction($event, close)"
|
||||
@action="onMenuAction($event)"
|
||||
/>
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
</AssetsListItem>
|
||||
<template #content="{ close, itemComponent, separatorComponent }">
|
||||
<template #content="{ itemComponent, separatorComponent }">
|
||||
<MenuPanel
|
||||
:entries="getMenuEntries(job)"
|
||||
:item-component="itemComponent"
|
||||
:separator-component="separatorComponent"
|
||||
v-bind="jobMenuPanelProps"
|
||||
@action="onMenuAction($event, close)"
|
||||
@action="onMenuAction($event)"
|
||||
/>
|
||||
</template>
|
||||
</ContextMenu>
|
||||
@@ -256,8 +254,7 @@ function onActionsMenuOpenChange(jobId: string, isOpen: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
function onMenuAction(entry: MenuActionEntry, close: () => void) {
|
||||
close()
|
||||
function onMenuAction(entry: MenuActionEntry) {
|
||||
emit('menu-action', entry)
|
||||
}
|
||||
|
||||
|
||||
@@ -30,13 +30,13 @@
|
||||
@bulk-export-workflow="emit('bulk-export-workflow', $event)"
|
||||
@output-count-click="emit('output-count-click', item.asset)"
|
||||
/>
|
||||
<template #content="{ close, itemComponent, separatorComponent }">
|
||||
<template #content="{ itemComponent, separatorComponent }">
|
||||
<MenuPanel
|
||||
:entries="getAssetMenuEntries(item.asset)"
|
||||
:item-component="itemComponent"
|
||||
:separator-component="separatorComponent"
|
||||
v-bind="mediaAssetMenuPanelProps"
|
||||
@action="void onAssetMenuAction($event, close)"
|
||||
@action="void onAssetMenuAction($event)"
|
||||
/>
|
||||
</template>
|
||||
</ContextMenu>
|
||||
@@ -119,8 +119,7 @@ function getAssetMenuEntries(asset: AssetItem): MenuEntry[] {
|
||||
})
|
||||
}
|
||||
|
||||
async function onAssetMenuAction(entry: MenuActionEntry, close: () => void) {
|
||||
close()
|
||||
async function onAssetMenuAction(entry: MenuActionEntry) {
|
||||
await entry.onClick?.()
|
||||
}
|
||||
|
||||
|
||||
@@ -71,27 +71,25 @@
|
||||
<i class="icon-[lucide--ellipsis] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
<template
|
||||
#content="{ close, itemComponent, separatorComponent }"
|
||||
>
|
||||
<template #content="{ itemComponent, separatorComponent }">
|
||||
<MenuPanel
|
||||
:entries="getAssetMenuEntries(item.asset)"
|
||||
:item-component="itemComponent"
|
||||
:separator-component="separatorComponent"
|
||||
v-bind="mediaAssetMenuPanelProps"
|
||||
@action="void onAssetMenuAction($event, close)"
|
||||
@action="void onAssetMenuAction($event)"
|
||||
/>
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
</AssetsListItem>
|
||||
<template #content="{ close, itemComponent, separatorComponent }">
|
||||
<template #content="{ itemComponent, separatorComponent }">
|
||||
<MenuPanel
|
||||
:entries="getAssetMenuEntries(item.asset)"
|
||||
:item-component="itemComponent"
|
||||
:separator-component="separatorComponent"
|
||||
v-bind="mediaAssetMenuPanelProps"
|
||||
@action="void onAssetMenuAction($event, close)"
|
||||
@action="void onAssetMenuAction($event)"
|
||||
/>
|
||||
</template>
|
||||
</ContextMenu>
|
||||
@@ -272,8 +270,7 @@ function onActionsMenuOpenChange(assetId: string, isOpen: boolean): void {
|
||||
}
|
||||
}
|
||||
|
||||
async function onAssetMenuAction(entry: MenuActionEntry, close: () => void) {
|
||||
close()
|
||||
async function onAssetMenuAction(entry: MenuActionEntry) {
|
||||
await entry.onClick?.()
|
||||
}
|
||||
|
||||
|
||||
@@ -177,7 +177,7 @@ const onInspectAsset = (item: JobListItem) => {
|
||||
void onViewItem(item)
|
||||
}
|
||||
|
||||
const { getJobMenuEntries, cancelJob } = useJobMenu(undefined, onInspectAsset)
|
||||
const { getJobMenuEntries, cancelJob } = useJobMenu(onInspectAsset)
|
||||
|
||||
const onCancelItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
|
||||
await cancelJob(item)
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
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 '@/types/menuTypes'
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -27,13 +27,9 @@ import { appendJsonExt } from '@/utils/formatUtil'
|
||||
/**
|
||||
* 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()
|
||||
@@ -43,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
|
||||
@@ -57,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) {
|
||||
@@ -79,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
|
||||
@@ -108,7 +101,7 @@ 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 (item?: JobListItem | null) => {
|
||||
const target = resolveItem(item)
|
||||
const target = item
|
||||
if (!target) return
|
||||
const result: ResultItemImpl | undefined = target.taskRef?.previewOutput
|
||||
if (!result) return
|
||||
@@ -159,7 +152,7 @@ export function useJobMenu(
|
||||
* Trigger a download of the job's previewable output asset.
|
||||
*/
|
||||
const downloadPreviewAsset = (item?: JobListItem | null) => {
|
||||
const target = resolveItem(item)
|
||||
const target = item
|
||||
if (!target) return
|
||||
const result: ResultItemImpl | undefined = target.taskRef?.previewOutput
|
||||
if (!result) return
|
||||
@@ -170,7 +163,7 @@ export function useJobMenu(
|
||||
* Export the workflow JSON attached to the job.
|
||||
*/
|
||||
const exportJobWorkflow = async (item?: JobListItem | null) => {
|
||||
const target = resolveItem(item)
|
||||
const target = item
|
||||
if (!target) return
|
||||
const data = await getJobWorkflow(target.id)
|
||||
if (!data) return
|
||||
@@ -194,7 +187,7 @@ export function useJobMenu(
|
||||
}
|
||||
|
||||
const deleteJobAsset = async (item?: JobListItem | null) => {
|
||||
const target = resolveItem(item)
|
||||
const target = item
|
||||
if (!target) return
|
||||
const task = target.taskRef as TaskItemImpl | undefined
|
||||
const preview = task?.previewOutput
|
||||
@@ -208,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)
|
||||
}
|
||||
@@ -228,7 +220,7 @@ export function useJobMenu(
|
||||
)
|
||||
|
||||
const buildJobMenuEntries = (item?: JobListItem | null): MenuEntry[] => {
|
||||
const target = resolveItem(item)
|
||||
const target = item
|
||||
const state = target?.state
|
||||
if (!state) return []
|
||||
const hasPreviewAsset = !!target?.taskRef?.previewOutput
|
||||
@@ -355,10 +347,7 @@ export function useJobMenu(
|
||||
]
|
||||
}
|
||||
|
||||
const jobMenuEntries = computed<MenuEntry[]>(() => buildJobMenuEntries())
|
||||
|
||||
return {
|
||||
jobMenuEntries,
|
||||
getJobMenuEntries: buildJobMenuEntries,
|
||||
openJobWorkflow,
|
||||
copyJobId,
|
||||
|
||||
@@ -85,13 +85,13 @@
|
||||
<i class="icon-[lucide--ellipsis] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
<template #content="{ close, itemComponent, separatorComponent }">
|
||||
<template #content="{ itemComponent, separatorComponent }">
|
||||
<MenuPanel
|
||||
:entries="getAssetMenuEntries()"
|
||||
:item-component="itemComponent"
|
||||
:separator-component="separatorComponent"
|
||||
v-bind="mediaAssetMenuPanelProps"
|
||||
@action="void onAssetMenuAction($event, close)"
|
||||
@action="void onAssetMenuAction($event)"
|
||||
/>
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
@@ -375,8 +375,7 @@ function getAssetMenuEntries(): MenuEntry[] {
|
||||
})
|
||||
}
|
||||
|
||||
async function onAssetMenuAction(entry: MenuActionEntry, close: () => void) {
|
||||
close()
|
||||
async function onAssetMenuAction(entry: MenuActionEntry) {
|
||||
await entry.onClick?.()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user