[backport core/1.42] fix: share queue details hover state (#10882)

Backport of #9924 to `core/1.42`

Automatically created by backport workflow.

Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
This commit is contained in:
Comfy Org PR Bot
2026-04-07 06:35:21 +09:00
committed by GitHub
parent 9e42d94598
commit aca584dc31
5 changed files with 235 additions and 163 deletions

View File

@@ -303,4 +303,21 @@ describe('JobAssetsList', () => {
expect(popover.exists()).toBe(true)
expect(popover.props('jobId')).toBe('job-2')
})
it('does not show details if the hovered row disappears before the show delay ends', async () => {
vi.useFakeTimers()
const job = buildJob()
const wrapper = mountJobAssetsList([job])
const jobRow = wrapper.find(`[data-job-id="${job.id}"]`)
await jobRow.trigger('mouseenter')
await wrapper.setProps({ displayedJobGroups: [] })
await nextTick()
await vi.advanceTimersByTimeAsync(200)
await nextTick()
expect(wrapper.findComponent(JobDetailsPopoverStub).exists()).toBe(false)
expect(wrapper.find('.job-details-popover').exists()).toBe(false)
})
})

View File

@@ -93,11 +93,12 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { nextTick, ref } 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 { useJobDetailsHover } from '@/composables/queue/useJobDetailsHover'
import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
import { iconForJobState } from '@/utils/queueDisplay'
import { isActiveJobState } from '@/utils/queueUtil'
@@ -113,29 +114,26 @@ 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 {
activeDetails,
clearHoverTimers,
resetActiveDetails,
scheduleDetailsHide,
scheduleDetailsShow
} = useJobDetailsHover<{ jobId: string; workflowId?: string }>({
getActiveId: (details) => details.jobId,
getDisplayedJobGroups: () => displayedJobGroups,
onReset: clearPopoverAnchor
})
const clearHideTimer = () => {
if (hideTimer.value !== null) {
clearTimeout(hideTimer.value)
hideTimer.value = null
}
hideTimerJobId.value = null
function clearPopoverAnchor() {
activeRowElement.value = null
popoverPosition.value = null
}
const clearShowTimer = () => {
if (showTimer.value !== null) {
clearTimeout(showTimer.value)
showTimer.value = null
}
}
const updatePopoverPosition = () => {
function updatePopoverPosition() {
const rowElement = activeRowElement.value
if (!rowElement) return
@@ -147,56 +145,14 @@ const updatePopoverPosition = () => {
}
}
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) => {
function onJobLeave(jobId: string) {
if (hoveredJobId.value === jobId) {
hoveredJobId.value = null
}
scheduleDetailsHide(jobId)
scheduleDetailsHide(jobId, clearPopoverAnchor)
}
const onJobEnter = (job: JobListItem, event: MouseEvent) => {
function onJobEnter(job: JobListItem, event: MouseEvent) {
hoveredJobId.value = job.id
const rowElement = event.currentTarget
@@ -204,24 +160,36 @@ const onJobEnter = (job: JobListItem, event: MouseEvent) => {
activeRowElement.value = rowElement
if (activeDetails.value?.jobId === job.id) {
clearHideTimer()
clearShowTimer()
clearHoverTimers()
void nextTick(updatePopoverPosition)
return
}
scheduleDetailsShow(job, rowElement)
scheduleDetailsShow(
{
jobId: job.id,
workflowId: job.taskRef?.workflowId
},
() => {
activeRowElement.value = rowElement
void nextTick(updatePopoverPosition)
}
)
}
const isCancelable = (job: JobListItem) =>
job.showClear !== false && isActiveJobState(job.state)
function isCancelable(job: JobListItem) {
return job.showClear !== false && isActiveJobState(job.state)
}
const isFailedDeletable = (job: JobListItem) =>
job.showClear !== false && job.state === 'failed'
function isFailedDeletable(job: JobListItem) {
return job.showClear !== false && job.state === 'failed'
}
const getPreviewOutput = (job: JobListItem) => job.taskRef?.previewOutput
function getPreviewOutput(job: JobListItem) {
return job.taskRef?.previewOutput
}
const getJobPreviewUrl = (job: JobListItem) => {
function getJobPreviewUrl(job: JobListItem) {
const preview = getPreviewOutput(job)
if (preview?.isImage || preview?.isVideo) {
return preview.url
@@ -229,66 +197,49 @@ const getJobPreviewUrl = (job: JobListItem) => {
return job.iconImageUrl
}
const isVideoPreviewJob = (job: JobListItem) =>
job.state === 'completed' && !!getPreviewOutput(job)?.isVideo
function isVideoPreviewJob(job: JobListItem) {
return job.state === 'completed' && !!getPreviewOutput(job)?.isVideo
}
const isPreviewableCompletedJob = (job: JobListItem) =>
job.state === 'completed' && !!getPreviewOutput(job)
function isPreviewableCompletedJob(job: JobListItem) {
return job.state === 'completed' && !!getPreviewOutput(job)
}
const emitViewItem = (job: JobListItem) => {
function emitViewItem(job: JobListItem) {
if (isPreviewableCompletedJob(job)) {
resetActiveDetails()
emit('viewItem', job)
}
}
const emitCompletedViewItem = (job: JobListItem) => {
function emitCompletedViewItem(job: JobListItem) {
resetActiveDetails()
emit('viewItem', job)
}
const emitCancelItem = (job: JobListItem) => {
function emitCancelItem(job: JobListItem) {
resetActiveDetails()
emit('cancelItem', job)
}
const emitDeleteItem = (job: JobListItem) => {
function emitDeleteItem(job: JobListItem) {
resetActiveDetails()
emit('deleteItem', job)
}
const onPopoverEnter = () => {
clearHideTimer()
clearShowTimer()
function onPopoverEnter() {
clearHoverTimers()
}
const onPopoverLeave = () => {
scheduleDetailsHide(activeDetails.value?.jobId)
function onPopoverLeave() {
scheduleDetailsHide(activeDetails.value?.jobId, clearPopoverAnchor)
}
const getJobIconClass = (job: JobListItem): string | undefined => {
function getJobIconClass(job: JobListItem): string | undefined {
const iconName = job.iconName ?? iconForJobState(job.state)
if (!job.iconImageUrl && iconName === iconForJobState('pending')) {
return 'animate-spin'
}
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>

View File

@@ -56,11 +56,11 @@ const mountComponent = (groups: JobGroup[]) =>
}
})
afterEach(() => {
vi.useRealTimers()
})
describe('JobGroupsList hover behavior', () => {
afterEach(() => {
vi.useRealTimers()
})
it('delays showing and hiding details while hovering over job rows', async () => {
vi.useFakeTimers()
const job = createJobItem({ id: 'job-d' })
@@ -95,4 +95,33 @@ describe('JobGroupsList hover behavior', () => {
wrapper.findComponent(QueueJobItemStub).props('activeDetailsId')
).toBeNull()
})
it('clears the previous popover when hovering a new row briefly and leaving', async () => {
vi.useFakeTimers()
const firstJob = createJobItem({ id: 'job-1', title: 'First job' })
const secondJob = createJobItem({ id: 'job-2', title: 'Second job' })
const wrapper = mountComponent([
{ key: 'today', label: 'Today', items: [firstJob, secondJob] }
])
const jobItems = wrapper.findAllComponents(QueueJobItemStub)
jobItems[0].vm.$emit('details-enter', firstJob.id)
vi.advanceTimersByTime(200)
await nextTick()
expect(jobItems[0].props('activeDetailsId')).toBe(firstJob.id)
jobItems[0].vm.$emit('details-leave', firstJob.id)
jobItems[1].vm.$emit('details-enter', secondJob.id)
vi.advanceTimersByTime(100)
await nextTick()
jobItems[1].vm.$emit('details-leave', secondJob.id)
vi.advanceTimersByTime(50)
await nextTick()
expect(jobItems[0].props('activeDetailsId')).toBeNull()
vi.advanceTimersByTime(50)
await nextTick()
expect(jobItems[1].props('activeDetailsId')).toBeNull()
})
})

View File

@@ -36,12 +36,11 @@
</template>
<script setup lang="ts">
import { onBeforeUnmount, ref, watch } from 'vue'
import QueueJobItem from '@/components/queue/job/QueueJobItem.vue'
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
import { useJobDetailsHover } from '@/composables/queue/useJobDetailsHover'
const props = defineProps<{ displayedJobGroups: JobGroup[] }>()
const { displayedJobGroups } = defineProps<{ displayedJobGroups: JobGroup[] }>()
const emit = defineEmits<{
(e: 'cancelItem', item: JobListItem): void
@@ -50,65 +49,34 @@ const emit = defineEmits<{
(e: 'viewItem', item: JobListItem): void
}>()
const emitCancelItem = (item: JobListItem) => {
const {
activeDetails: activeDetailsId,
clearHoverTimers,
scheduleDetailsHide,
scheduleDetailsShow
} = useJobDetailsHover<string>({
getActiveId: (jobId) => jobId,
getDisplayedJobGroups: () => displayedJobGroups
})
function emitCancelItem(item: JobListItem) {
emit('cancelItem', item)
}
const emitDeleteItem = (item: JobListItem) => {
function emitDeleteItem(item: JobListItem) {
emit('deleteItem', item)
}
const activeDetailsId = ref<string | null>(null)
const hideTimer = ref<number | null>(null)
const showTimer = ref<number | null>(null)
const clearHideTimer = () => {
if (hideTimer.value !== null) {
clearTimeout(hideTimer.value)
hideTimer.value = null
function onDetailsEnter(jobId: string) {
if (activeDetailsId.value === jobId) {
clearHoverTimers()
return
}
}
const clearShowTimer = () => {
if (showTimer.value !== null) {
clearTimeout(showTimer.value)
showTimer.value = null
}
}
const onDetailsEnter = (jobId: string) => {
clearHideTimer()
clearShowTimer()
showTimer.value = window.setTimeout(() => {
activeDetailsId.value = jobId
showTimer.value = null
}, 200)
}
const onDetailsLeave = (jobId: string) => {
clearHideTimer()
clearShowTimer()
hideTimer.value = window.setTimeout(() => {
if (activeDetailsId.value === jobId) activeDetailsId.value = null
hideTimer.value = null
}, 150)
scheduleDetailsShow(jobId)
}
const resetActiveDetails = () => {
clearHideTimer()
clearShowTimer()
activeDetailsId.value = null
function onDetailsLeave(jobId: string) {
scheduleDetailsHide(jobId)
}
watch(
() => props.displayedJobGroups,
(groups) => {
const activeId = activeDetailsId.value
if (!activeId) return
const hasActiveJob = groups.some((group) =>
group.items.some((item) => item.id === activeId)
)
if (!hasActiveJob) resetActiveDetails()
}
)
onBeforeUnmount(resetActiveDetails)
</script>

View File

@@ -0,0 +1,107 @@
import { onBeforeUnmount, ref, watch } from 'vue'
import type { JobGroup } from '@/composables/queue/useJobList'
const DETAILS_SHOW_DELAY_MS = 200
const DETAILS_HIDE_DELAY_MS = 150
interface UseJobDetailsHoverOptions<TActive> {
getActiveId: (active: TActive) => string
getDisplayedJobGroups: () => JobGroup[]
onReset?: () => void
}
export function useJobDetailsHover<TActive>({
getActiveId,
getDisplayedJobGroups,
onReset
}: UseJobDetailsHoverOptions<TActive>) {
const activeDetails = ref<TActive | null>(null)
const hideTimer = ref<number | null>(null)
const hideTimerJobId = ref<string | null>(null)
const showTimer = ref<number | null>(null)
function clearHideTimer() {
if (hideTimer.value !== null) {
clearTimeout(hideTimer.value)
hideTimer.value = null
}
hideTimerJobId.value = null
}
function clearShowTimer() {
if (showTimer.value !== null) {
clearTimeout(showTimer.value)
showTimer.value = null
}
}
function clearHoverTimers() {
clearHideTimer()
clearShowTimer()
}
function resetActiveDetails() {
clearHoverTimers()
activeDetails.value = null
onReset?.()
}
function hasDisplayedJob(jobId: string) {
return getDisplayedJobGroups().some((group) =>
group.items.some((item) => item.id === jobId)
)
}
function scheduleDetailsShow(nextActive: TActive, onShow?: () => void) {
const nextActiveId = getActiveId(nextActive)
clearShowTimer()
showTimer.value = window.setTimeout(() => {
showTimer.value = null
if (!hasDisplayedJob(nextActiveId)) return
activeDetails.value = nextActive
onShow?.()
}, DETAILS_SHOW_DELAY_MS)
}
function scheduleDetailsHide(jobId?: string, onHide?: () => void) {
if (!jobId) return
clearShowTimer()
if (hideTimerJobId.value && hideTimerJobId.value !== jobId) {
return
}
clearHideTimer()
hideTimerJobId.value = jobId
hideTimer.value = window.setTimeout(() => {
const currentActive = activeDetails.value
if (currentActive && getActiveId(currentActive) === jobId) {
activeDetails.value = null
onHide?.()
}
hideTimer.value = null
hideTimerJobId.value = null
}, DETAILS_HIDE_DELAY_MS)
}
watch(getDisplayedJobGroups, () => {
const currentActive = activeDetails.value
if (!currentActive) return
if (!hasDisplayedJob(getActiveId(currentActive))) {
resetActiveDetails()
}
})
onBeforeUnmount(resetActiveDetails)
return {
activeDetails,
clearHoverTimers,
resetActiveDetails,
scheduleDetailsHide,
scheduleDetailsShow
}
}