mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-15 01:48:06 +00:00
Compare commits
5 Commits
fix/codera
...
fix/queue-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fb9bb5990 | ||
|
|
8da207973b | ||
|
|
f0281e4bd4 | ||
|
|
1227cda689 | ||
|
|
108fa128b7 |
@@ -1,5 +1,6 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
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'
|
||||
@@ -7,6 +8,15 @@ import { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
|
||||
|
||||
import JobAssetsList from './JobAssetsList.vue'
|
||||
|
||||
const JobDetailsPopoverStub = defineComponent({
|
||||
name: 'JobDetailsPopover',
|
||||
props: {
|
||||
jobId: { type: String, required: true },
|
||||
workflowId: { type: String, default: undefined }
|
||||
},
|
||||
template: '<div class="job-details-popover-stub" />'
|
||||
})
|
||||
|
||||
vi.mock('vue-i18n', () => {
|
||||
return {
|
||||
createI18n: () => ({
|
||||
@@ -46,6 +56,7 @@ const createTaskRef = (preview?: ResultItemImpl): TaskItemImpl => {
|
||||
create_time: Date.now(),
|
||||
preview_output: null,
|
||||
outputs_count: preview ? 1 : 0,
|
||||
workflow_id: 'workflow-1',
|
||||
priority: 0
|
||||
}
|
||||
const flatOutputs = preview ? [preview] : []
|
||||
@@ -71,11 +82,21 @@ const mountJobAssetsList = (jobs: JobListItem[]) => {
|
||||
]
|
||||
|
||||
return mount(JobAssetsList, {
|
||||
props: { displayedJobGroups }
|
||||
props: { displayedJobGroups },
|
||||
global: {
|
||||
stubs: {
|
||||
teleport: true,
|
||||
JobDetailsPopover: JobDetailsPopoverStub
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('JobAssetsList', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('emits viewItem on preview-click for completed jobs with preview', async () => {
|
||||
const job = buildJob()
|
||||
const wrapper = mountJobAssetsList([job])
|
||||
@@ -143,4 +164,143 @@ describe('JobAssetsList', () => {
|
||||
|
||||
expect(wrapper.emitted('viewItem')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('emits viewItem from the View button for completed jobs without preview output', async () => {
|
||||
const job = buildJob({
|
||||
iconImageUrl: undefined,
|
||||
taskRef: createTaskRef()
|
||||
})
|
||||
const wrapper = mountJobAssetsList([job])
|
||||
const jobRow = wrapper.find(`[data-job-id="${job.id}"]`)
|
||||
|
||||
await jobRow.trigger('mouseenter')
|
||||
const viewButton = wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text() === 'menuLabels.View')
|
||||
expect(viewButton).toBeDefined()
|
||||
|
||||
await viewButton!.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.emitted('viewItem')).toEqual([[job]])
|
||||
})
|
||||
|
||||
it('shows and hides the job details popover with hover delays', async () => {
|
||||
vi.useFakeTimers()
|
||||
const job = buildJob()
|
||||
const wrapper = mountJobAssetsList([job])
|
||||
const jobRow = wrapper.find(`[data-job-id="${job.id}"]`)
|
||||
|
||||
await jobRow.trigger('mouseenter')
|
||||
await vi.advanceTimersByTimeAsync(199)
|
||||
await nextTick()
|
||||
expect(wrapper.findComponent(JobDetailsPopoverStub).exists()).toBe(false)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await nextTick()
|
||||
|
||||
const popover = wrapper.findComponent(JobDetailsPopoverStub)
|
||||
expect(popover.exists()).toBe(true)
|
||||
expect(popover.props()).toMatchObject({
|
||||
jobId: job.id,
|
||||
workflowId: 'workflow-1'
|
||||
})
|
||||
|
||||
await jobRow.trigger('mouseleave')
|
||||
await vi.advanceTimersByTimeAsync(149)
|
||||
await nextTick()
|
||||
expect(wrapper.findComponent(JobDetailsPopoverStub).exists()).toBe(true)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await nextTick()
|
||||
expect(wrapper.findComponent(JobDetailsPopoverStub).exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the job details popover open while hovering the popover', async () => {
|
||||
vi.useFakeTimers()
|
||||
const job = buildJob()
|
||||
const wrapper = mountJobAssetsList([job])
|
||||
const jobRow = wrapper.find(`[data-job-id="${job.id}"]`)
|
||||
|
||||
await jobRow.trigger('mouseenter')
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
await nextTick()
|
||||
|
||||
await jobRow.trigger('mouseleave')
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await nextTick()
|
||||
|
||||
const popover = wrapper.find('.job-details-popover')
|
||||
expect(popover.exists()).toBe(true)
|
||||
|
||||
await popover.trigger('mouseenter')
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await nextTick()
|
||||
expect(wrapper.findComponent(JobDetailsPopoverStub).exists()).toBe(true)
|
||||
|
||||
await popover.trigger('mouseleave')
|
||||
await vi.advanceTimersByTimeAsync(149)
|
||||
await nextTick()
|
||||
expect(wrapper.findComponent(JobDetailsPopoverStub).exists()).toBe(true)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await nextTick()
|
||||
expect(wrapper.findComponent(JobDetailsPopoverStub).exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('clears the previous popover when hovering a new row briefly and leaving the list', async () => {
|
||||
vi.useFakeTimers()
|
||||
const firstJob = buildJob({ id: 'job-1' })
|
||||
const secondJob = buildJob({ id: 'job-2', title: 'Job 2' })
|
||||
const wrapper = mountJobAssetsList([firstJob, secondJob])
|
||||
const firstRow = wrapper.find('[data-job-id="job-1"]')
|
||||
const secondRow = wrapper.find('[data-job-id="job-2"]')
|
||||
|
||||
await firstRow.trigger('mouseenter')
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
await nextTick()
|
||||
expect(wrapper.findComponent(JobDetailsPopoverStub).props('jobId')).toBe(
|
||||
'job-1'
|
||||
)
|
||||
|
||||
await firstRow.trigger('mouseleave')
|
||||
await secondRow.trigger('mouseenter')
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await nextTick()
|
||||
await secondRow.trigger('mouseleave')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(150)
|
||||
await nextTick()
|
||||
expect(wrapper.findComponent(JobDetailsPopoverStub).exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows the new popover after the previous row hides while the next row stays hovered', async () => {
|
||||
vi.useFakeTimers()
|
||||
const firstJob = buildJob({ id: 'job-1' })
|
||||
const secondJob = buildJob({ id: 'job-2', title: 'Job 2' })
|
||||
const wrapper = mountJobAssetsList([firstJob, secondJob])
|
||||
const firstRow = wrapper.find('[data-job-id="job-1"]')
|
||||
const secondRow = wrapper.find('[data-job-id="job-2"]')
|
||||
|
||||
await firstRow.trigger('mouseenter')
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
await nextTick()
|
||||
expect(wrapper.findComponent(JobDetailsPopoverStub).props('jobId')).toBe(
|
||||
'job-1'
|
||||
)
|
||||
|
||||
await firstRow.trigger('mouseleave')
|
||||
await secondRow.trigger('mouseenter')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(150)
|
||||
await nextTick()
|
||||
expect(wrapper.findComponent(JobDetailsPopoverStub).exists()).toBe(false)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
await nextTick()
|
||||
|
||||
const popover = wrapper.findComponent(JobDetailsPopoverStub)
|
||||
expect(popover.exists()).toBe(true)
|
||||
expect(popover.props('jobId')).toBe('job-2')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,78 +8,101 @@
|
||||
<div class="text-xs leading-none text-text-secondary">
|
||||
{{ group.label }}
|
||||
</div>
|
||||
<AssetsListItem
|
||||
<div
|
||||
v-for="job in group.items"
|
||||
:key="job.id"
|
||||
class="w-full shrink-0 cursor-default text-text-primary transition-colors hover:bg-secondary-background-hover"
|
||||
: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"
|
||||
@mouseenter="hoveredJobId = job.id"
|
||||
:data-job-id="job.id"
|
||||
@mouseenter="onJobEnter(job, $event)"
|
||||
@mouseleave="onJobLeave(job.id)"
|
||||
@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="$emit('cancelItem', 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="$emit('deleteItem', job)"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="job.state === 'completed'"
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
@click.stop="$emit('viewItem', 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>
|
||||
<AssetsListItem
|
||||
class="w-full shrink-0 cursor-default text-text-primary transition-colors hover:bg-secondary-background-hover"
|
||||
: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)"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="activeDetails && popoverPosition"
|
||||
class="job-details-popover fixed z-50"
|
||||
:style="{
|
||||
top: `${popoverPosition.top}px`,
|
||||
right: `${popoverPosition.right}px`
|
||||
}"
|
||||
@mouseenter="onPopoverEnter"
|
||||
@mouseleave="onPopoverLeave"
|
||||
>
|
||||
<JobDetailsPopover
|
||||
:job-id="activeDetails.jobId"
|
||||
:workflow-id="activeDetails.workflowId"
|
||||
/>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ref } from 'vue'
|
||||
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
import JobDetailsPopover from '@/components/queue/job/JobDetailsPopover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
|
||||
import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
|
||||
import { iconForJobState } from '@/utils/queueDisplay'
|
||||
import { isActiveJobState } from '@/utils/queueUtil'
|
||||
|
||||
defineProps<{ displayedJobGroups: JobGroup[] }>()
|
||||
const { displayedJobGroups } = defineProps<{ displayedJobGroups: JobGroup[] }>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'cancelItem', item: JobListItem): void
|
||||
@@ -90,11 +113,104 @@ const emit = defineEmits<{
|
||||
|
||||
const { t } = useI18n()
|
||||
const hoveredJobId = ref<string | null>(null)
|
||||
const activeDetails = ref<{ jobId: string; workflowId?: string } | null>(null)
|
||||
const activeRowElement = ref<HTMLElement | null>(null)
|
||||
const popoverPosition = ref<{ top: number; right: number } | null>(null)
|
||||
const hideTimer = ref<number | null>(null)
|
||||
const hideTimerJobId = ref<string | null>(null)
|
||||
const showTimer = ref<number | null>(null)
|
||||
|
||||
const clearHideTimer = () => {
|
||||
if (hideTimer.value !== null) {
|
||||
clearTimeout(hideTimer.value)
|
||||
hideTimer.value = null
|
||||
}
|
||||
hideTimerJobId.value = null
|
||||
}
|
||||
|
||||
const clearShowTimer = () => {
|
||||
if (showTimer.value !== null) {
|
||||
clearTimeout(showTimer.value)
|
||||
showTimer.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const updatePopoverPosition = () => {
|
||||
const rowElement = activeRowElement.value
|
||||
if (!rowElement) return
|
||||
|
||||
const rect = rowElement.getBoundingClientRect()
|
||||
const gap = 8
|
||||
popoverPosition.value = {
|
||||
top: rect.top,
|
||||
right: window.innerWidth - rect.left + gap
|
||||
}
|
||||
}
|
||||
|
||||
const resetActiveDetails = () => {
|
||||
clearHideTimer()
|
||||
clearShowTimer()
|
||||
activeDetails.value = null
|
||||
activeRowElement.value = null
|
||||
popoverPosition.value = null
|
||||
}
|
||||
|
||||
const scheduleDetailsShow = (job: JobListItem, rowElement: HTMLElement) => {
|
||||
clearShowTimer()
|
||||
showTimer.value = window.setTimeout(() => {
|
||||
activeRowElement.value = rowElement
|
||||
activeDetails.value = {
|
||||
jobId: job.id,
|
||||
workflowId: job.taskRef?.workflowId
|
||||
}
|
||||
showTimer.value = null
|
||||
void nextTick(updatePopoverPosition)
|
||||
}, 200)
|
||||
}
|
||||
|
||||
const scheduleDetailsHide = (jobId?: string) => {
|
||||
if (!jobId) return
|
||||
|
||||
clearShowTimer()
|
||||
if (hideTimerJobId.value && hideTimerJobId.value !== jobId) {
|
||||
return
|
||||
}
|
||||
|
||||
clearHideTimer()
|
||||
hideTimerJobId.value = jobId
|
||||
hideTimer.value = window.setTimeout(() => {
|
||||
if (activeDetails.value?.jobId === jobId) {
|
||||
activeDetails.value = null
|
||||
activeRowElement.value = null
|
||||
popoverPosition.value = null
|
||||
}
|
||||
hideTimer.value = null
|
||||
hideTimerJobId.value = null
|
||||
}, 150)
|
||||
}
|
||||
|
||||
const onJobLeave = (jobId: string) => {
|
||||
if (hoveredJobId.value === jobId) {
|
||||
hoveredJobId.value = null
|
||||
}
|
||||
scheduleDetailsHide(jobId)
|
||||
}
|
||||
|
||||
const onJobEnter = (job: JobListItem, event: MouseEvent) => {
|
||||
hoveredJobId.value = job.id
|
||||
|
||||
const rowElement = event.currentTarget
|
||||
if (!(rowElement instanceof HTMLElement)) return
|
||||
|
||||
activeRowElement.value = rowElement
|
||||
if (activeDetails.value?.jobId === job.id) {
|
||||
clearHideTimer()
|
||||
clearShowTimer()
|
||||
void nextTick(updatePopoverPosition)
|
||||
return
|
||||
}
|
||||
|
||||
scheduleDetailsShow(job, rowElement)
|
||||
}
|
||||
|
||||
const isCancelable = (job: JobListItem) =>
|
||||
@@ -121,10 +237,35 @@ const isPreviewableCompletedJob = (job: JobListItem) =>
|
||||
|
||||
const emitViewItem = (job: JobListItem) => {
|
||||
if (isPreviewableCompletedJob(job)) {
|
||||
resetActiveDetails()
|
||||
emit('viewItem', job)
|
||||
}
|
||||
}
|
||||
|
||||
const emitCompletedViewItem = (job: JobListItem) => {
|
||||
resetActiveDetails()
|
||||
emit('viewItem', job)
|
||||
}
|
||||
|
||||
const emitCancelItem = (job: JobListItem) => {
|
||||
resetActiveDetails()
|
||||
emit('cancelItem', job)
|
||||
}
|
||||
|
||||
const emitDeleteItem = (job: JobListItem) => {
|
||||
resetActiveDetails()
|
||||
emit('deleteItem', job)
|
||||
}
|
||||
|
||||
const onPopoverEnter = () => {
|
||||
clearHideTimer()
|
||||
clearShowTimer()
|
||||
}
|
||||
|
||||
const onPopoverLeave = () => {
|
||||
scheduleDetailsHide(activeDetails.value?.jobId)
|
||||
}
|
||||
|
||||
const getJobIconClass = (job: JobListItem): string | undefined => {
|
||||
const iconName = job.iconName ?? iconForJobState(job.state)
|
||||
if (!job.iconImageUrl && iconName === iconForJobState('pending')) {
|
||||
@@ -132,4 +273,22 @@ const getJobIconClass = (job: JobListItem): string | undefined => {
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
watch(
|
||||
() => displayedJobGroups,
|
||||
(groups) => {
|
||||
const activeJobId = activeDetails.value?.jobId
|
||||
if (!activeJobId) return
|
||||
|
||||
const hasActiveJob = groups.some((group) =>
|
||||
group.items.some((item) => item.id === activeJobId)
|
||||
)
|
||||
|
||||
if (!hasActiveJob) {
|
||||
resetActiveDetails()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
onBeforeUnmount(resetActiveDetails)
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user