mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-19 22:09:37 +00:00
[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:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
107
src/composables/queue/useJobDetailsHover.ts
Normal file
107
src/composables/queue/useJobDetailsHover.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user