Compare commits

...

5 Commits

Author SHA1 Message Date
Benjamin Lu
1fb9bb5990 fix: apply queue popover review cleanups 2026-03-14 10:04:38 -07:00
Benjamin Lu
8da207973b fix: preserve queue popover row anchor 2026-03-07 18:16:23 -08:00
Benjamin Lu
f0281e4bd4 fix: preserve queue popover hide timer 2026-03-07 17:34:08 -08:00
Benjamin Lu
1227cda689 fix: restore completed queue view action 2026-03-07 17:24:55 -08:00
Benjamin Lu
108fa128b7 fix: restore queue job details popover 2026-03-07 16:52:37 -08:00
2 changed files with 376 additions and 57 deletions

View File

@@ -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')
})
})

View File

@@ -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>