mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-27 15:57:48 +00:00
Compare commits
10 Commits
test/asset
...
bl/com-304
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c0d7e72ec | ||
|
|
cf697a4f60 | ||
|
|
59c04795be | ||
|
|
daa8782f88 | ||
|
|
ef453b31fd | ||
|
|
61d968eb23 | ||
|
|
545841649e | ||
|
|
0e5cbe8529 | ||
|
|
3709d56825 | ||
|
|
2e86cb2759 |
127
browser_tests/tests/sidebar/jobHistory.spec.ts
Normal file
127
browser_tests/tests/sidebar/jobHistory.spec.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import type { RawJobListItem } from '../../../src/platform/remote/comfyui/jobs/jobTypes'
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
|
||||
const VIRTUALIZED_ROW_LIMIT = 48
|
||||
const HEADER_ROW_HEIGHT = 20
|
||||
const JOB_ROW_HEIGHT = 48
|
||||
const GROUP_ROW_GAP = 16
|
||||
const GROUP_JOB_COUNTS = [6, 58]
|
||||
const GROUP_BASE_TIMES = [
|
||||
Date.parse('2026-03-26T20:00:00-07:00'),
|
||||
Date.parse('2026-03-25T20:00:00-07:00')
|
||||
]
|
||||
const GROUP_BOUNDARY_JOB_ID = `job-${GROUP_JOB_COUNTS[0] - 1}`
|
||||
const LAST_JOB_ID = `job-${GROUP_JOB_COUNTS[0] + GROUP_JOB_COUNTS[1] - 1}`
|
||||
|
||||
function buildCompletedJobs(): RawJobListItem[] {
|
||||
return GROUP_BASE_TIMES.flatMap((baseTime, groupIndex) =>
|
||||
Array.from({ length: GROUP_JOB_COUNTS[groupIndex] }, (_, jobIndex) => {
|
||||
const endTime = baseTime - jobIndex * 5 * 60_000
|
||||
const id =
|
||||
GROUP_JOB_COUNTS.slice(0, groupIndex).reduce(
|
||||
(total, count) => total + count,
|
||||
0
|
||||
) + jobIndex
|
||||
|
||||
return {
|
||||
id: `job-${id}`,
|
||||
status: 'completed',
|
||||
create_time: endTime,
|
||||
execution_start_time: endTime - 10_000,
|
||||
execution_end_time: endTime,
|
||||
workflow_id: 'workflow-1'
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
async function getRowMetrics(jobHistoryList: Locator, boundaryJobId: string) {
|
||||
return await jobHistoryList.evaluate((element, jobId) => {
|
||||
const wrapper = element.firstElementChild
|
||||
const firstHeader = wrapper?.firstElementChild
|
||||
const firstJob = element.querySelector('[data-job-id="job-0"]')
|
||||
const boundaryJob = element.querySelector(`[data-job-id="${jobId}"]`)
|
||||
|
||||
if (
|
||||
!(wrapper instanceof HTMLElement) ||
|
||||
!(firstHeader instanceof HTMLElement) ||
|
||||
!(firstJob instanceof HTMLElement) ||
|
||||
!(boundaryJob instanceof HTMLElement) ||
|
||||
!(firstJob.parentElement instanceof HTMLElement) ||
|
||||
!(boundaryJob.parentElement instanceof HTMLElement)
|
||||
) {
|
||||
throw new Error('Expected virtualized job rows to be present')
|
||||
}
|
||||
|
||||
return {
|
||||
firstHeaderHeight: firstHeader.getBoundingClientRect().height,
|
||||
firstHeaderScrollHeight: firstHeader.scrollHeight,
|
||||
firstJobContentHeight: firstJob.getBoundingClientRect().height,
|
||||
firstJobRowHeight: firstJob.parentElement.getBoundingClientRect().height,
|
||||
boundaryJobContentHeight: boundaryJob.getBoundingClientRect().height,
|
||||
boundaryJobRowHeight:
|
||||
boundaryJob.parentElement.getBoundingClientRect().height
|
||||
}
|
||||
}, boundaryJobId)
|
||||
}
|
||||
|
||||
test.describe('Job history sidebar', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(buildCompletedJobs())
|
||||
await comfyPage.setup()
|
||||
await comfyPage.settings.setSetting('Comfy.Queue.QPOV2', true)
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('virtualizes large job history lists', async ({ comfyPage }) => {
|
||||
const jobHistoryButton = comfyPage.page.locator('.job-history-tab-button')
|
||||
const jobHistoryList = comfyPage.page.getByTestId('job-assets-list')
|
||||
const jobRows = comfyPage.page.locator(
|
||||
'.sidebar-content-container [data-job-id]'
|
||||
)
|
||||
const jobRow = (jobId: string) =>
|
||||
comfyPage.page.locator(
|
||||
`.sidebar-content-container [data-job-id="${jobId}"]`
|
||||
)
|
||||
|
||||
await jobHistoryButton.click()
|
||||
await jobHistoryList.waitFor({ state: 'visible' })
|
||||
|
||||
await expect(jobRow('job-0')).toBeVisible()
|
||||
await expect(jobRow(LAST_JOB_ID)).toHaveCount(0)
|
||||
|
||||
const initialRenderedRows = await jobRows.count()
|
||||
expect(initialRenderedRows).toBeLessThan(VIRTUALIZED_ROW_LIMIT)
|
||||
|
||||
const rowMetrics = await getRowMetrics(
|
||||
jobHistoryList,
|
||||
GROUP_BOUNDARY_JOB_ID
|
||||
)
|
||||
expect(rowMetrics.firstHeaderHeight).toBe(HEADER_ROW_HEIGHT)
|
||||
expect(rowMetrics.firstHeaderScrollHeight).toBeLessThanOrEqual(
|
||||
HEADER_ROW_HEIGHT
|
||||
)
|
||||
expect(rowMetrics.firstJobContentHeight).toBe(JOB_ROW_HEIGHT)
|
||||
expect(rowMetrics.firstJobRowHeight).toBe(JOB_ROW_HEIGHT)
|
||||
expect(rowMetrics.boundaryJobContentHeight).toBe(JOB_ROW_HEIGHT)
|
||||
expect(rowMetrics.boundaryJobRowHeight).toBe(JOB_ROW_HEIGHT + GROUP_ROW_GAP)
|
||||
|
||||
await jobHistoryList.evaluate((element) => {
|
||||
element.scrollTop = element.scrollHeight
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(jobRow(LAST_JOB_ID)).toBeVisible()
|
||||
await expect(jobRow('job-0')).toHaveCount(0)
|
||||
|
||||
const renderedRowsAfterScroll = await jobRows.count()
|
||||
expect(renderedRowsAfterScroll).toBeLessThan(VIRTUALIZED_ROW_LIMIT)
|
||||
})
|
||||
})
|
||||
@@ -20,7 +20,7 @@
|
||||
@update:selected-sort-mode="$emit('update:selectedSortMode', $event)"
|
||||
/>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||
<div class="min-h-0 flex-1">
|
||||
<JobAssetsList
|
||||
:displayed-job-groups="displayedJobGroups"
|
||||
@cancel-item="onCancelItemEvent"
|
||||
|
||||
@@ -8,6 +8,30 @@ import { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
|
||||
|
||||
import JobAssetsList from './JobAssetsList.vue'
|
||||
|
||||
vi.mock('@vueuse/core', async () => {
|
||||
const actual = await vi.importActual<Record<string, unknown>>('@vueuse/core')
|
||||
const vue = await import('vue')
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useVirtualList: (rows: unknown) => ({
|
||||
list: vue.computed(() =>
|
||||
vue.unref(rows as readonly unknown[]).map((data: unknown, index) => ({
|
||||
data,
|
||||
index
|
||||
}))
|
||||
),
|
||||
scrollTo: vi.fn(),
|
||||
containerProps: {
|
||||
ref: vue.ref(null),
|
||||
onScroll: vi.fn(),
|
||||
style: {}
|
||||
},
|
||||
wrapperProps: vue.computed(() => ({ style: {} }))
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const JobDetailsPopoverStub = defineComponent({
|
||||
name: 'JobDetailsPopover',
|
||||
props: {
|
||||
@@ -122,6 +146,63 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('JobAssetsList', () => {
|
||||
it('renders grouped headers alongside job rows', () => {
|
||||
const wrapper = mount(JobAssetsList, {
|
||||
props: {
|
||||
displayedJobGroups: [
|
||||
{
|
||||
key: 'today',
|
||||
label: 'Today',
|
||||
items: [buildJob({ id: 'job-1' })]
|
||||
},
|
||||
{
|
||||
key: 'yesterday',
|
||||
label: 'Yesterday',
|
||||
items: [buildJob({ id: 'job-2', title: 'Job 2' })]
|
||||
}
|
||||
]
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
teleport: true,
|
||||
JobDetailsPopover: JobDetailsPopoverStub
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Today')
|
||||
expect(wrapper.text()).toContain('Yesterday')
|
||||
expect(wrapper.find('[data-job-id="job-1"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-job-id="job-2"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('forwards parent attrs to the scroll container', () => {
|
||||
const wrapper = mount(JobAssetsList, {
|
||||
attrs: {
|
||||
class: 'min-h-0 flex-1'
|
||||
},
|
||||
props: {
|
||||
displayedJobGroups: [
|
||||
{
|
||||
key: 'today',
|
||||
label: 'Today',
|
||||
items: [buildJob({ id: 'job-1' })]
|
||||
}
|
||||
]
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
teleport: true,
|
||||
JobDetailsPopover: JobDetailsPopoverStub
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.get('[data-testid="job-assets-list"]').classes()).toEqual(
|
||||
expect.arrayContaining(['min-h-0', 'flex-1', 'h-full', 'overflow-y-auto'])
|
||||
)
|
||||
})
|
||||
|
||||
it('emits viewItem on preview-click for completed jobs with preview', async () => {
|
||||
const job = buildJob()
|
||||
const wrapper = mountJobAssetsList([job])
|
||||
|
||||
@@ -1,79 +1,93 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 px-3 pb-4">
|
||||
<div
|
||||
v-for="group in displayedJobGroups"
|
||||
:key="group.key"
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<div class="text-xs leading-none text-text-secondary">
|
||||
{{ group.label }}
|
||||
</div>
|
||||
<div
|
||||
v-for="job in group.items"
|
||||
:key="job.id"
|
||||
:data-job-id="job.id"
|
||||
@mouseenter="onJobEnter(job, $event)"
|
||||
@mouseleave="onJobLeave(job.id)"
|
||||
>
|
||||
<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
|
||||
<div
|
||||
v-bind="$attrs"
|
||||
:ref="containerProps.ref"
|
||||
:style="containerProps.style"
|
||||
data-testid="job-assets-list"
|
||||
class="h-full overflow-y-auto"
|
||||
@scroll="onListScroll"
|
||||
>
|
||||
<div :style="virtualWrapperStyle">
|
||||
<template v-for="{ data: row } in virtualRows" :key="row.key">
|
||||
<div
|
||||
v-if="row.type === 'header'"
|
||||
class="box-border px-3 pb-2 text-xs leading-none text-text-secondary"
|
||||
:style="{ height: `${row.height}px` }"
|
||||
>
|
||||
<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)"
|
||||
{{ row.label }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="row.type === 'job'"
|
||||
class="box-border px-3"
|
||||
:style="{ height: `${row.height}px` }"
|
||||
>
|
||||
<div
|
||||
:data-job-id="row.job.id"
|
||||
class="h-12"
|
||||
@mouseenter="onJobEnter(row.job, $event)"
|
||||
@mouseleave="onJobLeave(row.job.id)"
|
||||
>
|
||||
<AssetsListItem
|
||||
:class="
|
||||
cn(
|
||||
'size-full shrink-0 cursor-default text-text-primary transition-colors hover:bg-secondary-background-hover',
|
||||
row.job.state === 'running' && 'bg-secondary-background'
|
||||
)
|
||||
"
|
||||
:preview-url="getJobPreviewUrl(row.job)"
|
||||
:is-video-preview="isVideoPreviewJob(row.job)"
|
||||
:preview-alt="row.job.title"
|
||||
:icon-name="row.job.iconName ?? iconForJobState(row.job.state)"
|
||||
:icon-class="getJobIconClass(row.job)"
|
||||
:primary-text="row.job.title"
|
||||
:secondary-text="row.job.meta"
|
||||
:progress-total-percent="row.job.progressTotalPercent"
|
||||
:progress-current-percent="row.job.progressCurrentPercent"
|
||||
@contextmenu.prevent.stop="$emit('menu', row.job, $event)"
|
||||
@dblclick.stop="emitViewItem(row.job)"
|
||||
@preview-click="emitViewItem(row.job)"
|
||||
@click.stop
|
||||
>
|
||||
<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>
|
||||
<template v-if="hoveredJobId === row.job.id" #actions>
|
||||
<Button
|
||||
v-if="isCancelable(row.job)"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:aria-label="t('g.cancel')"
|
||||
@click.stop="emitCancelItem(row.job)"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="isFailedDeletable(row.job)"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:aria-label="t('g.delete')"
|
||||
@click.stop="emitDeleteItem(row.job)"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="row.job.state === 'completed'"
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
@click.stop="emitCompletedViewItem(row.job)"
|
||||
>
|
||||
{{ t('menuLabels.View') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
:aria-label="t('g.more')"
|
||||
@click.stop="$emit('menu', row.job, $event)"
|
||||
>
|
||||
<i class="icon-[lucide--ellipsis] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
</AssetsListItem>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -97,8 +111,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useVirtualList } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import JobDetailsPopover from '@/components/queue/job/JobDetailsPopover.vue'
|
||||
import { getHoverPopoverPosition } from '@/components/queue/job/getHoverPopoverPosition'
|
||||
@@ -110,6 +125,16 @@ import { cn } from '@/utils/tailwindUtil'
|
||||
import { iconForJobState } from '@/utils/queueDisplay'
|
||||
import { isActiveJobState } from '@/utils/queueUtil'
|
||||
|
||||
import {
|
||||
buildVirtualJobRows,
|
||||
JOB_ROW_HEIGHT,
|
||||
VIRTUAL_JOB_LIST_BOTTOM_PADDING
|
||||
} from './buildVirtualJobRows'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
const { displayedJobGroups } = defineProps<{ displayedJobGroups: JobGroup[] }>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -123,6 +148,23 @@ const { t } = useI18n()
|
||||
const hoveredJobId = ref<string | null>(null)
|
||||
const activeRowElement = ref<HTMLElement | null>(null)
|
||||
const popoverPosition = ref<{ top: number; left: number } | null>(null)
|
||||
const flatRows = computed(() => buildVirtualJobRows(displayedJobGroups))
|
||||
const {
|
||||
list: virtualRows,
|
||||
containerProps,
|
||||
wrapperProps
|
||||
} = useVirtualList(flatRows, {
|
||||
itemHeight: (index) => flatRows.value[index]?.height ?? JOB_ROW_HEIGHT,
|
||||
overscan: 12
|
||||
})
|
||||
const virtualWrapperStyle = computed(() => ({
|
||||
...wrapperProps.value.style,
|
||||
width: '100%',
|
||||
paddingBottom:
|
||||
flatRows.value.length > 0
|
||||
? `${VIRTUAL_JOB_LIST_BOTTOM_PADDING}px`
|
||||
: undefined
|
||||
}))
|
||||
const {
|
||||
activeDetails,
|
||||
clearHoverTimers,
|
||||
@@ -135,6 +177,12 @@ const {
|
||||
onReset: clearPopoverAnchor
|
||||
})
|
||||
|
||||
function onListScroll() {
|
||||
containerProps.onScroll()
|
||||
hoveredJobId.value = null
|
||||
resetActiveDetails()
|
||||
}
|
||||
|
||||
function clearPopoverAnchor() {
|
||||
activeRowElement.value = null
|
||||
popoverPosition.value = null
|
||||
|
||||
89
src/components/queue/job/buildVirtualJobRows.test.ts
Normal file
89
src/components/queue/job/buildVirtualJobRows.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
|
||||
|
||||
import { buildVirtualJobRows } from './buildVirtualJobRows'
|
||||
|
||||
function buildJob(id: string): JobListItem {
|
||||
return {
|
||||
id,
|
||||
title: `Job ${id}`,
|
||||
meta: 'meta',
|
||||
state: 'completed'
|
||||
}
|
||||
}
|
||||
|
||||
describe('buildVirtualJobRows', () => {
|
||||
it('flattens grouped jobs into headers and rows in display order', () => {
|
||||
const displayedJobGroups: JobGroup[] = [
|
||||
{
|
||||
key: 'today',
|
||||
label: 'Today',
|
||||
items: [buildJob('job-1'), buildJob('job-2')]
|
||||
},
|
||||
{
|
||||
key: 'yesterday',
|
||||
label: 'Yesterday',
|
||||
items: [buildJob('job-3')]
|
||||
}
|
||||
]
|
||||
|
||||
expect(buildVirtualJobRows(displayedJobGroups)).toEqual([
|
||||
{
|
||||
key: 'header-today',
|
||||
type: 'header',
|
||||
label: 'Today',
|
||||
height: 20
|
||||
},
|
||||
{
|
||||
key: 'job-job-1',
|
||||
type: 'job',
|
||||
job: displayedJobGroups[0].items[0],
|
||||
height: 48
|
||||
},
|
||||
{
|
||||
key: 'job-job-2',
|
||||
type: 'job',
|
||||
job: displayedJobGroups[0].items[1],
|
||||
height: 64
|
||||
},
|
||||
{
|
||||
key: 'header-yesterday',
|
||||
type: 'header',
|
||||
label: 'Yesterday',
|
||||
height: 20
|
||||
},
|
||||
{
|
||||
key: 'job-job-3',
|
||||
type: 'job',
|
||||
job: displayedJobGroups[1].items[0],
|
||||
height: 48
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the last group rows at the base job height', () => {
|
||||
const displayedJobGroups: JobGroup[] = [
|
||||
{
|
||||
key: 'today',
|
||||
label: 'Today',
|
||||
items: [buildJob('job-1')]
|
||||
}
|
||||
]
|
||||
|
||||
expect(buildVirtualJobRows(displayedJobGroups)).toEqual([
|
||||
{
|
||||
key: 'header-today',
|
||||
type: 'header',
|
||||
label: 'Today',
|
||||
height: 20
|
||||
},
|
||||
{
|
||||
key: 'job-job-1',
|
||||
type: 'job',
|
||||
job: displayedJobGroups[0].items[0],
|
||||
height: 48
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
52
src/components/queue/job/buildVirtualJobRows.ts
Normal file
52
src/components/queue/job/buildVirtualJobRows.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
|
||||
|
||||
const HEADER_ROW_HEIGHT = 20
|
||||
const GROUP_ROW_GAP = 16
|
||||
export const JOB_ROW_HEIGHT = 48
|
||||
export const VIRTUAL_JOB_LIST_BOTTOM_PADDING = 16
|
||||
|
||||
export type VirtualJobRow =
|
||||
| {
|
||||
key: string
|
||||
type: 'header'
|
||||
label: string
|
||||
height: number
|
||||
}
|
||||
| {
|
||||
key: string
|
||||
type: 'job'
|
||||
job: JobListItem
|
||||
height: number
|
||||
}
|
||||
|
||||
export function buildVirtualJobRows(
|
||||
displayedJobGroups: JobGroup[]
|
||||
): VirtualJobRow[] {
|
||||
const rows: VirtualJobRow[] = []
|
||||
const lastGroupIndex = displayedJobGroups.length - 1
|
||||
|
||||
displayedJobGroups.forEach((group, groupIndex) => {
|
||||
rows.push({
|
||||
key: `header-${group.key}`,
|
||||
type: 'header',
|
||||
label: group.label,
|
||||
height: HEADER_ROW_HEIGHT
|
||||
})
|
||||
|
||||
group.items.forEach((job, jobIndex) => {
|
||||
const isLastJobInGroup = jobIndex === group.items.length - 1
|
||||
const isLastGroup = groupIndex === lastGroupIndex
|
||||
|
||||
rows.push({
|
||||
key: `job-${job.id}`,
|
||||
type: 'job',
|
||||
job,
|
||||
height:
|
||||
JOB_ROW_HEIGHT +
|
||||
(isLastJobInGroup && !isLastGroup ? GROUP_ROW_GAP : 0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return rows
|
||||
}
|
||||
@@ -5,6 +5,30 @@ import { defineComponent, nextTick } from 'vue'
|
||||
|
||||
import JobHistorySidebarTab from './JobHistorySidebarTab.vue'
|
||||
|
||||
vi.mock('@vueuse/core', async () => {
|
||||
const actual = await vi.importActual<Record<string, unknown>>('@vueuse/core')
|
||||
const vue = await import('vue')
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useVirtualList: (rows: unknown) => ({
|
||||
list: vue.computed(() =>
|
||||
vue.unref(rows as readonly unknown[]).map((data: unknown, index) => ({
|
||||
data,
|
||||
index
|
||||
}))
|
||||
),
|
||||
scrollTo: vi.fn(),
|
||||
containerProps: {
|
||||
ref: vue.ref(null),
|
||||
onScroll: vi.fn(),
|
||||
style: {}
|
||||
},
|
||||
wrapperProps: vue.computed(() => ({ style: {} }))
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const JobDetailsPopoverStub = defineComponent({
|
||||
name: 'JobDetailsPopover',
|
||||
props: {
|
||||
|
||||
@@ -46,22 +46,25 @@
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<JobAssetsList
|
||||
:displayed-job-groups="displayedJobGroups"
|
||||
@cancel-item="onCancelItem"
|
||||
@delete-item="onDeleteItem"
|
||||
@view-item="onViewItem"
|
||||
@menu="onMenuItem"
|
||||
/>
|
||||
<JobContextMenu
|
||||
ref="jobContextMenuRef"
|
||||
:entries="jobMenuEntries"
|
||||
@action="onJobMenuAction"
|
||||
/>
|
||||
<MediaLightbox
|
||||
v-model:active-index="galleryActiveIndex"
|
||||
:all-gallery-items="galleryItems"
|
||||
/>
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<JobAssetsList
|
||||
class="min-h-0 flex-1"
|
||||
:displayed-job-groups="displayedJobGroups"
|
||||
@cancel-item="onCancelItem"
|
||||
@delete-item="onDeleteItem"
|
||||
@view-item="onViewItem"
|
||||
@menu="onMenuItem"
|
||||
/>
|
||||
<JobContextMenu
|
||||
ref="jobContextMenuRef"
|
||||
:entries="jobMenuEntries"
|
||||
@action="onJobMenuAction"
|
||||
/>
|
||||
<MediaLightbox
|
||||
v-model:active-index="galleryActiveIndex"
|
||||
:all-gallery-items="galleryItems"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</SidebarTabTemplate>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user