fix: resolve backport typecheck issues on core/1.41 (#10921)

Fixes #9679 backport: adds missing useJobDetailsHover composable and
correct JobAssetsList versions. pnpm typecheck passes.
This commit is contained in:
Christian Byrne
2026-04-06 15:24:47 -07:00
committed by GitHub
parent 5d5a6def0c
commit 8fa11de47a
3 changed files with 260 additions and 69 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,7 +82,13 @@ const mountJobAssetsList = (jobs: JobListItem[]) => {
]
return mount(JobAssetsList, {
props: { displayedJobGroups }
props: { displayedJobGroups },
global: {
stubs: {
teleport: true,
JobDetailsPopover: JobDetailsPopoverStub
}
}
})
}

View File

@@ -8,63 +8,72 @@
<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="
cn(
'w-full shrink-0 cursor-default text-text-primary transition-colors hover:bg-secondary-background-hover',
job.state === 'running' && 'bg-secondary-background'
)
"
: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>
@@ -89,17 +98,19 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { ref } from 'vue'
import { nextTick, ref } from 'vue'
import JobDetailsPopover from '@/components/queue/job/JobDetailsPopover.vue'
import { getHoverPopoverPosition } from '@/components/queue/job/getHoverPopoverPosition'
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 { cn } from '@/utils/tailwindUtil'
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
@@ -141,17 +152,47 @@ function onJobLeave(jobId: string) {
if (hoveredJobId.value === jobId) {
hoveredJobId.value = null
}
scheduleDetailsHide(jobId, clearPopoverAnchor)
}
const isCancelable = (job: JobListItem) =>
job.showClear !== false && isActiveJobState(job.state)
function onJobEnter(job: JobListItem, event: MouseEvent) {
hoveredJobId.value = job.id
const isFailedDeletable = (job: JobListItem) =>
job.showClear !== false && job.state === 'failed'
const rowElement = event.currentTarget
if (!(rowElement instanceof HTMLElement)) return
const getPreviewOutput = (job: JobListItem) => job.taskRef?.previewOutput
activeRowElement.value = rowElement
if (activeDetails.value?.jobId === job.id) {
clearHoverTimers()
void nextTick(updatePopoverPosition)
return
}
const getJobPreviewUrl = (job: JobListItem) => {
scheduleDetailsShow(
{
jobId: job.id,
workflowId: job.taskRef?.workflowId
},
() => {
activeRowElement.value = rowElement
void nextTick(updatePopoverPosition)
}
)
}
function isCancelable(job: JobListItem) {
return job.showClear !== false && isActiveJobState(job.state)
}
function isFailedDeletable(job: JobListItem) {
return job.showClear !== false && job.state === 'failed'
}
function getPreviewOutput(job: JobListItem) {
return job.taskRef?.previewOutput
}
function getJobPreviewUrl(job: JobListItem) {
const preview = getPreviewOutput(job)
if (preview?.isImage || preview?.isVideo) {
return preview.url
@@ -159,19 +200,45 @@ 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 getJobIconClass = (job: JobListItem): string | undefined => {
function emitCompletedViewItem(job: JobListItem) {
resetActiveDetails()
emit('viewItem', job)
}
function emitCancelItem(job: JobListItem) {
resetActiveDetails()
emit('cancelItem', job)
}
function emitDeleteItem(job: JobListItem) {
resetActiveDetails()
emit('deleteItem', job)
}
function onPopoverEnter() {
clearHoverTimers()
}
function onPopoverLeave() {
scheduleDetailsHide(activeDetails.value?.jobId, clearPopoverAnchor)
}
function getJobIconClass(job: JobListItem): string | undefined {
const iconName = job.iconName ?? iconForJobState(job.state)
if (!job.iconImageUrl && iconName === iconForJobState('pending')) {
return 'animate-spin'

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