mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-22 07:44:11 +00:00
fix(queue): allow deleting failed jobs from queue progress UI (#8478)
## Summary Failed jobs could not be removed from the Media Assets queue progress panel because `useJobActions` only supported cancel for pending/running jobs. ## Changes - Add `deleteAction`, `canDeleteJob`, `runDeleteJob` to `useJobActions` composable - Export `removeFailedJob` from `useJobMenu` with optional task parameter - Update `ActiveMediaAssetCard.vue` to show delete button on failed jobs ## Testing 1. Queue a workflow that will fail (e.g., missing model) 2. Open Media Assets panel 3. Hover over the failed job card → delete button (circle-minus icon) appears 4. Click delete → job is removed from queue ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8478-fix-queue-allow-deleting-failed-jobs-from-queue-progress-UI-2f86d73d3650810ba3aaf6cf38703bf5) by [Unito](https://www.unito.io) --------- Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
import { useJobMenu } from '@/composables/queue/useJobMenu'
|
||||
import type { TaskItemImpl } from '@/stores/queueStore'
|
||||
import { isActiveJobState } from '@/utils/queueUtil'
|
||||
|
||||
export type JobAction = {
|
||||
@@ -18,7 +19,7 @@ export function useJobActions(
|
||||
) {
|
||||
const { t } = useI18n()
|
||||
const { wrapWithErrorHandlingAsync } = useErrorHandling()
|
||||
const { cancelJob } = useJobMenu()
|
||||
const { cancelJob, removeFailedJob } = useJobMenu()
|
||||
|
||||
const cancelAction: JobAction = {
|
||||
icon: 'icon-[lucide--x]',
|
||||
@@ -26,6 +27,12 @@ export function useJobActions(
|
||||
variant: 'destructive'
|
||||
}
|
||||
|
||||
const deleteAction: JobAction = {
|
||||
icon: 'icon-[lucide--circle-minus]',
|
||||
label: t('queue.jobMenu.removeJob'),
|
||||
variant: 'destructive'
|
||||
}
|
||||
|
||||
const jobRef = computed(() => toValue(job) ?? null)
|
||||
|
||||
const canCancelJob = computed(() => {
|
||||
@@ -37,6 +44,15 @@ export function useJobActions(
|
||||
return currentJob.showClear !== false && isActiveJobState(currentJob.state)
|
||||
})
|
||||
|
||||
const canDeleteJob = computed(() => {
|
||||
const currentJob = jobRef.value
|
||||
if (!currentJob) {
|
||||
return false
|
||||
}
|
||||
|
||||
return currentJob.state === 'failed'
|
||||
})
|
||||
|
||||
const runCancelJob = wrapWithErrorHandlingAsync(async () => {
|
||||
const currentJob = jobRef.value
|
||||
if (!currentJob) {
|
||||
@@ -46,9 +62,22 @@ export function useJobActions(
|
||||
await cancelJob(currentJob)
|
||||
})
|
||||
|
||||
const runDeleteJob = wrapWithErrorHandlingAsync(async () => {
|
||||
const currentJob = jobRef.value
|
||||
const task = currentJob?.taskRef as TaskItemImpl | undefined
|
||||
if (!task) {
|
||||
return
|
||||
}
|
||||
|
||||
await removeFailedJob(task)
|
||||
})
|
||||
|
||||
return {
|
||||
cancelAction,
|
||||
canCancelJob,
|
||||
runCancelJob
|
||||
runCancelJob,
|
||||
deleteAction,
|
||||
canDeleteJob,
|
||||
runDeleteJob
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,10 +217,11 @@ export function useJobMenu(
|
||||
}
|
||||
}
|
||||
|
||||
const removeFailedJob = async () => {
|
||||
const task = currentMenuItem()?.taskRef as TaskItemImpl | undefined
|
||||
if (!task) return
|
||||
await queueStore.delete(task)
|
||||
const removeFailedJob = async (task?: TaskItemImpl | null) => {
|
||||
const target =
|
||||
task ?? (currentMenuItem()?.taskRef as TaskItemImpl | undefined)
|
||||
if (!target) return
|
||||
await queueStore.delete(target)
|
||||
}
|
||||
|
||||
const jobMenuOpenWorkflowLabel = computed(() =>
|
||||
@@ -369,6 +370,7 @@ export function useJobMenu(
|
||||
jobMenuEntries,
|
||||
openJobWorkflow,
|
||||
copyJobId,
|
||||
cancelJob
|
||||
cancelJob,
|
||||
removeFailedJob
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { computed } from 'vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import ActiveJobCard from './ActiveMediaAssetCard.vue'
|
||||
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
|
||||
const mockRunCancelJob = vi.fn()
|
||||
const mockRunDeleteJob = vi.fn()
|
||||
const mockCanCancelJob = ref(false)
|
||||
const mockCanDeleteJob = ref(false)
|
||||
|
||||
vi.mock('@/composables/queue/useJobActions', () => ({
|
||||
useJobActions: () => ({
|
||||
cancelAction: {
|
||||
@@ -14,8 +19,15 @@ vi.mock('@/composables/queue/useJobActions', () => ({
|
||||
label: 'Cancel',
|
||||
variant: 'destructive'
|
||||
},
|
||||
canCancelJob: computed(() => false),
|
||||
runCancelJob: vi.fn()
|
||||
canCancelJob: mockCanCancelJob,
|
||||
runCancelJob: mockRunCancelJob,
|
||||
deleteAction: {
|
||||
icon: 'icon-[lucide--circle-minus]',
|
||||
label: 'Remove job',
|
||||
variant: 'destructive'
|
||||
},
|
||||
canDeleteJob: mockCanDeleteJob,
|
||||
runDeleteJob: mockRunDeleteJob
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -58,6 +70,13 @@ const mountComponent = (job: JobListItem) =>
|
||||
})
|
||||
|
||||
describe('ActiveJobCard', () => {
|
||||
beforeEach(() => {
|
||||
mockCanCancelJob.value = false
|
||||
mockCanDeleteJob.value = false
|
||||
mockRunCancelJob.mockReset()
|
||||
mockRunDeleteJob.mockReset()
|
||||
})
|
||||
|
||||
it('displays percentage and progress bar when job is running', () => {
|
||||
const wrapper = mountComponent(
|
||||
createJob({ state: 'running', progressTotalPercent: 65 })
|
||||
@@ -121,4 +140,74 @@ describe('ActiveJobCard', () => {
|
||||
expect(container.exists()).toBe(true)
|
||||
expect(container.attributes('aria-label')).toBe('Active job: Generating...')
|
||||
})
|
||||
|
||||
it('shows delete button on hover for failed jobs', async () => {
|
||||
mockCanDeleteJob.value = true
|
||||
|
||||
const wrapper = mountComponent(
|
||||
createJob({ state: 'failed', title: 'Failed' })
|
||||
)
|
||||
|
||||
expect(wrapper.findComponent({ name: 'Button' }).exists()).toBe(false)
|
||||
|
||||
await wrapper.find('[role="status"]').trigger('mouseenter')
|
||||
|
||||
const button = wrapper.findComponent({ name: 'Button' })
|
||||
expect(button.exists()).toBe(true)
|
||||
expect(button.attributes('aria-label')).toBe('Remove job')
|
||||
})
|
||||
|
||||
it('calls runDeleteJob when delete button is clicked on a failed job', async () => {
|
||||
mockCanDeleteJob.value = true
|
||||
|
||||
const wrapper = mountComponent(
|
||||
createJob({ state: 'failed', title: 'Failed' })
|
||||
)
|
||||
|
||||
await wrapper.find('[role="status"]').trigger('mouseenter')
|
||||
|
||||
const button = wrapper.findComponent({ name: 'Button' })
|
||||
await button.trigger('click')
|
||||
|
||||
expect(mockRunDeleteJob).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not show action button when job cannot be cancelled or deleted', async () => {
|
||||
const wrapper = mountComponent(
|
||||
createJob({ state: 'running', progressTotalPercent: 50 })
|
||||
)
|
||||
|
||||
await wrapper.find('[role="status"]').trigger('mouseenter')
|
||||
|
||||
expect(wrapper.findComponent({ name: 'Button' }).exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows cancel button on hover for cancellable jobs', async () => {
|
||||
mockCanCancelJob.value = true
|
||||
|
||||
const wrapper = mountComponent(
|
||||
createJob({ state: 'running', progressTotalPercent: 50 })
|
||||
)
|
||||
|
||||
await wrapper.find('[role="status"]').trigger('mouseenter')
|
||||
|
||||
const button = wrapper.findComponent({ name: 'Button' })
|
||||
expect(button.exists()).toBe(true)
|
||||
expect(button.attributes('aria-label')).toBe('Cancel')
|
||||
})
|
||||
|
||||
it('calls runCancelJob when cancel button is clicked', async () => {
|
||||
mockCanCancelJob.value = true
|
||||
|
||||
const wrapper = mountComponent(
|
||||
createJob({ state: 'running', progressTotalPercent: 50 })
|
||||
)
|
||||
|
||||
await wrapper.find('[role="status"]').trigger('mouseenter')
|
||||
|
||||
const button = wrapper.findComponent({ name: 'Button' })
|
||||
await button.trigger('click')
|
||||
|
||||
expect(mockRunCancelJob).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -39,16 +39,16 @@
|
||||
class="icon-[lucide--loader-circle] size-8 animate-spin text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<!-- Cancel button overlay -->
|
||||
<!-- Cancel/Delete button overlay -->
|
||||
<Button
|
||||
v-if="hovered && canCancelJob"
|
||||
v-if="hovered && showActionButton"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:aria-label="cancelAction.label"
|
||||
:aria-label="activeAction.label"
|
||||
class="absolute top-2 right-2"
|
||||
@click.stop="runCancelJob()"
|
||||
@click.stop="runActiveAction()"
|
||||
>
|
||||
<i :class="cancelAction.icon" />
|
||||
<i :class="activeAction.icon" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -90,7 +90,28 @@ const { job } = defineProps<{ job: JobListItem }>()
|
||||
const { t } = useI18n()
|
||||
const hovered = ref(false)
|
||||
|
||||
const { cancelAction, canCancelJob, runCancelJob } = useJobActions(() => job)
|
||||
const {
|
||||
cancelAction,
|
||||
canCancelJob,
|
||||
runCancelJob,
|
||||
deleteAction,
|
||||
canDeleteJob,
|
||||
runDeleteJob
|
||||
} = useJobActions(() => job)
|
||||
|
||||
const showActionButton = computed(
|
||||
() => canCancelJob.value || canDeleteJob.value
|
||||
)
|
||||
const activeAction = computed(() =>
|
||||
canCancelJob.value ? cancelAction : deleteAction
|
||||
)
|
||||
const runActiveAction = () => {
|
||||
if (canCancelJob.value) {
|
||||
runCancelJob()
|
||||
} else if (canDeleteJob.value) {
|
||||
runDeleteJob()
|
||||
}
|
||||
}
|
||||
|
||||
const { progressBarPrimaryClass, hasProgressPercent, progressPercentStyle } =
|
||||
useProgressBarBackground()
|
||||
|
||||
Reference in New Issue
Block a user