mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +00:00
[feat] Add active jobs display to grid view (#8209)
## Summary Show active jobs in grid view matching the list view behavior, with refactored component structure. ## Changes - **ActiveJobCard**: New component for grid view job display with progress bar - **AssetsSidebarGridView**: Extracted grid view logic from AssetsSidebarTab (matching ListView pattern) - **Progress styling**: Use `useProgressBarBackground` composable for consistent progress bar styling - **Assets header**: Add "Generated/Imported assets" header in grid view
This commit is contained in:
109
src/components/sidebar/tabs/AssetsSidebarGridView.vue
Normal file
109
src/components/sidebar/tabs/AssetsSidebarGridView.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col">
|
||||
<!-- Active Jobs Grid -->
|
||||
<div
|
||||
v-if="activeJobItems.length"
|
||||
class="grid max-h-[50%] scrollbar-custom overflow-y-auto"
|
||||
:style="gridStyle"
|
||||
>
|
||||
<ActiveJobCard v-for="job in activeJobItems" :key="job.id" :job="job" />
|
||||
</div>
|
||||
|
||||
<!-- Assets Header -->
|
||||
<div
|
||||
v-if="assets.length"
|
||||
:class="cn('px-2 2xl:px-4', activeJobItems.length && 'mt-2')"
|
||||
>
|
||||
<div
|
||||
class="flex items-center py-2 text-sm font-normal leading-normal text-muted-foreground font-inter"
|
||||
>
|
||||
{{
|
||||
t(
|
||||
assetType === 'input'
|
||||
? 'sideToolbar.importedAssetsHeader'
|
||||
: 'sideToolbar.generatedAssetsHeader'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Assets Grid -->
|
||||
<VirtualGrid
|
||||
class="flex-1"
|
||||
:items="assetItems"
|
||||
:grid-style="gridStyle"
|
||||
@approach-end="emit('approach-end')"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<MediaAssetCard
|
||||
:asset="item.asset"
|
||||
:selected="isSelected(item.asset.id)"
|
||||
:show-output-count="showOutputCount(item.asset)"
|
||||
:output-count="getOutputCount(item.asset)"
|
||||
@click="emit('select-asset', item.asset)"
|
||||
@context-menu="emit('context-menu', $event, item.asset)"
|
||||
@zoom="emit('zoom', item.asset)"
|
||||
@output-count-click="emit('output-count-click', item.asset)"
|
||||
/>
|
||||
</template>
|
||||
</VirtualGrid>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import VirtualGrid from '@/components/common/VirtualGrid.vue'
|
||||
import ActiveJobCard from '@/components/sidebar/tabs/assets/ActiveJobCard.vue'
|
||||
import { useJobList } from '@/composables/queue/useJobList'
|
||||
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { isActiveJobState } from '@/utils/queueUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
assets,
|
||||
isSelected,
|
||||
assetType = 'output',
|
||||
showOutputCount,
|
||||
getOutputCount
|
||||
} = defineProps<{
|
||||
assets: AssetItem[]
|
||||
isSelected: (assetId: string) => boolean
|
||||
assetType?: 'input' | 'output'
|
||||
showOutputCount: (asset: AssetItem) => boolean
|
||||
getOutputCount: (asset: AssetItem) => number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select-asset', asset: AssetItem): void
|
||||
(e: 'context-menu', event: MouseEvent, asset: AssetItem): void
|
||||
(e: 'approach-end'): void
|
||||
(e: 'zoom', asset: AssetItem): void
|
||||
(e: 'output-count-click', asset: AssetItem): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { jobItems } = useJobList()
|
||||
|
||||
type AssetGridItem = { key: string; asset: AssetItem }
|
||||
|
||||
const activeJobItems = computed(() =>
|
||||
jobItems.value.filter((item) => isActiveJobState(item.state))
|
||||
)
|
||||
|
||||
const assetItems = computed<AssetGridItem[]>(() =>
|
||||
assets.map((asset) => ({
|
||||
key: `asset-${asset.id}`,
|
||||
asset
|
||||
}))
|
||||
)
|
||||
|
||||
const gridStyle = {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
|
||||
padding: '0 0.5rem',
|
||||
gap: '0.5rem'
|
||||
}
|
||||
</script>
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="flex h-full flex-col">
|
||||
<div
|
||||
v-if="activeJobItems.length"
|
||||
class="flex max-h-[50%] flex-col gap-2 overflow-y-auto px-2"
|
||||
class="flex max-h-[50%] scrollbar-custom flex-col gap-2 overflow-y-auto px-2"
|
||||
>
|
||||
<AssetsListItem
|
||||
v-for="job in activeJobItems"
|
||||
@@ -114,7 +114,7 @@ import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { iconForMediaType } from '@/platform/assets/utils/mediaIconUtil'
|
||||
import type { JobState } from '@/types/queue'
|
||||
import { isActiveJobState } from '@/utils/queueUtil'
|
||||
import {
|
||||
formatDuration,
|
||||
formatSize,
|
||||
@@ -172,12 +172,6 @@ const listGridStyle = {
|
||||
gap: '0.5rem'
|
||||
}
|
||||
|
||||
function isActiveJobState(state: JobState): boolean {
|
||||
return (
|
||||
state === 'pending' || state === 'initialization' || state === 'running'
|
||||
)
|
||||
}
|
||||
|
||||
function getAssetPrimaryText(asset: AssetItem): string {
|
||||
return truncateFilename(asset.name)
|
||||
}
|
||||
|
||||
@@ -105,30 +105,19 @@
|
||||
@context-menu="handleAssetContextMenu"
|
||||
@approach-end="handleApproachEnd"
|
||||
/>
|
||||
<VirtualGrid
|
||||
<AssetsSidebarGridView
|
||||
v-else
|
||||
:items="mediaAssetsWithKey"
|
||||
:grid-style="{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
|
||||
padding: '0 0.5rem',
|
||||
gap: '0.5rem'
|
||||
}"
|
||||
:assets="displayAssets"
|
||||
:is-selected="isSelected"
|
||||
:asset-type="activeTab"
|
||||
:show-output-count="shouldShowOutputCount"
|
||||
:get-output-count="getOutputCount"
|
||||
@select-asset="handleAssetSelect"
|
||||
@context-menu="handleAssetContextMenu"
|
||||
@approach-end="handleApproachEnd"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<MediaAssetCard
|
||||
:asset="item"
|
||||
:selected="isSelected(item.id)"
|
||||
:show-output-count="shouldShowOutputCount(item)"
|
||||
:output-count="getOutputCount(item)"
|
||||
@click="handleAssetSelect(item)"
|
||||
@context-menu="handleAssetContextMenu"
|
||||
@zoom="handleZoomClick(item)"
|
||||
@output-count-click="enterFolderView(item)"
|
||||
/>
|
||||
</template>
|
||||
</VirtualGrid>
|
||||
@zoom="handleZoomClick"
|
||||
@output-count-click="enterFolderView"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
@@ -220,15 +209,14 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import VirtualGrid from '@/components/common/VirtualGrid.vue'
|
||||
import Load3dViewerContent from '@/components/load3d/Load3dViewerContent.vue'
|
||||
import AssetsSidebarGridView from '@/components/sidebar/tabs/AssetsSidebarGridView.vue'
|
||||
import AssetsSidebarListView from '@/components/sidebar/tabs/AssetsSidebarListView.vue'
|
||||
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
|
||||
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
|
||||
import Tab from '@/components/tab/Tab.vue'
|
||||
import TabList from '@/components/tab/TabList.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
|
||||
import MediaAssetContextMenu from '@/platform/assets/components/MediaAssetContextMenu.vue'
|
||||
import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBar.vue'
|
||||
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
|
||||
@@ -407,14 +395,14 @@ const showLoadingState = computed(
|
||||
() =>
|
||||
loading.value &&
|
||||
displayAssets.value.length === 0 &&
|
||||
(!isListView.value || activeJobsCount.value === 0)
|
||||
activeJobsCount.value === 0
|
||||
)
|
||||
|
||||
const showEmptyState = computed(
|
||||
() =>
|
||||
!loading.value &&
|
||||
displayAssets.value.length === 0 &&
|
||||
(!isListView.value || activeJobsCount.value === 0)
|
||||
activeJobsCount.value === 0
|
||||
)
|
||||
|
||||
watch(displayAssets, (newAssets) => {
|
||||
@@ -456,14 +444,6 @@ const galleryItems = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
// Add key property for VirtualGrid
|
||||
const mediaAssetsWithKey = computed(() => {
|
||||
return displayAssets.value.map((asset) => ({
|
||||
...asset,
|
||||
key: asset.id
|
||||
}))
|
||||
})
|
||||
|
||||
const refreshAssets = async () => {
|
||||
await currentAssets.value.fetchMediaList()
|
||||
if (error.value) {
|
||||
|
||||
111
src/components/sidebar/tabs/assets/ActiveJobCard.test.ts
Normal file
111
src/components/sidebar/tabs/assets/ActiveJobCard.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import ActiveJobCard from './ActiveJobCard.vue'
|
||||
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
|
||||
vi.mock('@/composables/useProgressBarBackground', () => ({
|
||||
useProgressBarBackground: () => ({
|
||||
progressBarPrimaryClass: 'bg-blue-500',
|
||||
hasProgressPercent: (val: number | undefined) => typeof val === 'number',
|
||||
progressPercentStyle: (val: number) => ({ width: `${val}%` })
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
sideToolbar: {
|
||||
activeJobStatus: 'Active job: {status}'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const createJob = (overrides: Partial<JobListItem> = {}): JobListItem => ({
|
||||
id: 'test-job-1',
|
||||
title: 'Running...',
|
||||
meta: 'Step 5/10',
|
||||
state: 'running',
|
||||
progressTotalPercent: 50,
|
||||
progressCurrentPercent: 75,
|
||||
...overrides
|
||||
})
|
||||
|
||||
const mountComponent = (job: JobListItem) =>
|
||||
mount(ActiveJobCard, {
|
||||
props: { job },
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
|
||||
describe('ActiveJobCard', () => {
|
||||
it('displays percentage and progress bar when job is running', () => {
|
||||
const wrapper = mountComponent(
|
||||
createJob({ state: 'running', progressTotalPercent: 65 })
|
||||
)
|
||||
|
||||
expect(wrapper.text()).toContain('65%')
|
||||
const progressBar = wrapper.find('.bg-blue-500')
|
||||
expect(progressBar.exists()).toBe(true)
|
||||
expect(progressBar.attributes('style')).toContain('width: 65%')
|
||||
})
|
||||
|
||||
it('displays status text when job is pending', () => {
|
||||
const wrapper = mountComponent(
|
||||
createJob({
|
||||
state: 'pending',
|
||||
title: 'In queue...',
|
||||
progressTotalPercent: undefined
|
||||
})
|
||||
)
|
||||
|
||||
expect(wrapper.text()).toContain('In queue...')
|
||||
const progressBar = wrapper.find('.bg-blue-500')
|
||||
expect(progressBar.exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows spinner for pending state', () => {
|
||||
const wrapper = mountComponent(createJob({ state: 'pending' }))
|
||||
|
||||
const spinner = wrapper.find('.icon-\\[lucide--loader-circle\\]')
|
||||
expect(spinner.exists()).toBe(true)
|
||||
expect(spinner.classes()).toContain('animate-spin')
|
||||
})
|
||||
|
||||
it('shows error icon for failed state', () => {
|
||||
const wrapper = mountComponent(
|
||||
createJob({ state: 'failed', title: 'Failed' })
|
||||
)
|
||||
|
||||
const errorIcon = wrapper.find('.icon-\\[lucide--circle-alert\\]')
|
||||
expect(errorIcon.exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('Failed')
|
||||
})
|
||||
|
||||
it('shows preview image when running with iconImageUrl', () => {
|
||||
const wrapper = mountComponent(
|
||||
createJob({
|
||||
state: 'running',
|
||||
iconImageUrl: 'https://example.com/preview.jpg'
|
||||
})
|
||||
)
|
||||
|
||||
const img = wrapper.find('img')
|
||||
expect(img.exists()).toBe(true)
|
||||
expect(img.attributes('src')).toBe('https://example.com/preview.jpg')
|
||||
})
|
||||
|
||||
it('has proper accessibility attributes', () => {
|
||||
const wrapper = mountComponent(createJob({ title: 'Generating...' }))
|
||||
|
||||
const container = wrapper.find('[role="status"]')
|
||||
expect(container.exists()).toBe(true)
|
||||
expect(container.attributes('aria-label')).toBe('Active job: Generating...')
|
||||
})
|
||||
})
|
||||
85
src/components/sidebar/tabs/assets/ActiveJobCard.vue
Normal file
85
src/components/sidebar/tabs/assets/ActiveJobCard.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<div
|
||||
role="status"
|
||||
:aria-label="t('sideToolbar.activeJobStatus', { status: statusText })"
|
||||
class="flex flex-col gap-2 p-2 rounded-lg"
|
||||
>
|
||||
<!-- Thumbnail -->
|
||||
<div class="relative aspect-square overflow-hidden rounded-lg">
|
||||
<!-- Running state with preview image -->
|
||||
<img
|
||||
v-if="isRunning && job.iconImageUrl"
|
||||
:src="job.iconImageUrl"
|
||||
:alt="statusText"
|
||||
class="size-full object-cover"
|
||||
/>
|
||||
<!-- Placeholder for queued/failed states or running without preview -->
|
||||
<div
|
||||
v-else
|
||||
class="absolute inset-0 flex items-center justify-center bg-modal-card-placeholder-background"
|
||||
>
|
||||
<!-- Spinner for queued/initialization states -->
|
||||
<i
|
||||
v-if="isQueued"
|
||||
class="icon-[lucide--loader-circle] size-8 animate-spin text-muted-foreground"
|
||||
/>
|
||||
<!-- Error icon for failed state -->
|
||||
<i
|
||||
v-else-if="isFailed"
|
||||
class="icon-[lucide--circle-alert] size-8 text-red-500"
|
||||
/>
|
||||
<!-- Spinner for running without preview -->
|
||||
<i
|
||||
v-else
|
||||
class="icon-[lucide--loader-circle] size-8 animate-spin text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer: Progress bar or status text -->
|
||||
<div class="flex gap-1.5 items-center h-5">
|
||||
<!-- Running state: percentage + progress bar -->
|
||||
<template v-if="isRunning && hasProgressPercent(progressPercent)">
|
||||
<span class="shrink-0 text-sm text-muted-foreground">
|
||||
{{ Math.round(progressPercent ?? 0) }}%
|
||||
</span>
|
||||
<div class="flex-1 relative h-1 rounded-sm bg-secondary-background">
|
||||
<div
|
||||
:class="progressBarPrimaryClass"
|
||||
:style="progressPercentStyle(progressPercent)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Non-running states: status text only -->
|
||||
<template v-else>
|
||||
<div class="w-full truncate text-center text-sm text-muted-foreground">
|
||||
{{ statusText }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
import { useProgressBarBackground } from '@/composables/useProgressBarBackground'
|
||||
|
||||
const { job } = defineProps<{ job: JobListItem }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { progressBarPrimaryClass, hasProgressPercent, progressPercentStyle } =
|
||||
useProgressBarBackground()
|
||||
|
||||
const statusText = computed(() => job.title)
|
||||
const progressPercent = computed(() => job.progressTotalPercent)
|
||||
|
||||
const isQueued = computed(
|
||||
() => job.state === 'pending' || job.state === 'initialization'
|
||||
)
|
||||
const isRunning = computed(() => job.state === 'running')
|
||||
const isFailed = computed(() => job.state === 'failed')
|
||||
</script>
|
||||
@@ -709,6 +709,7 @@
|
||||
"noGeneratedFiles": "No generated files found",
|
||||
"generatedAssetsHeader": "Generated assets",
|
||||
"importedAssetsHeader": "Imported assets",
|
||||
"activeJobStatus": "Active job: {status}",
|
||||
"noFilesFoundMessage": "Upload files or generate content to see them here",
|
||||
"browseTemplates": "Browse example templates",
|
||||
"openWorkflow": "Open workflow in local file system",
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import type { TaskItemImpl } from '@/stores/queueStore'
|
||||
import type { JobState } from '@/types/queue'
|
||||
|
||||
/**
|
||||
* Checks if a job state represents an active (in-progress) job.
|
||||
*/
|
||||
export function isActiveJobState(state: JobState): boolean {
|
||||
return (
|
||||
state === 'pending' || state === 'initialization' || state === 'running'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a task to a UI job state, including initialization override.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user