mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-25 16:59:45 +00:00
[refactor] Move ActiveJobCard to platform/assets and add ActiveMediaAssetCard story (#8242)
This commit is contained in:
143
src/platform/assets/components/ActiveMediaAssetCard.stories.ts
Normal file
143
src/platform/assets/components/ActiveMediaAssetCard.stories.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
|
||||
import ActiveMediaAssetCard from './ActiveMediaAssetCard.vue'
|
||||
|
||||
const meta: Meta<typeof ActiveMediaAssetCard> = {
|
||||
title: 'Platform/Assets/ActiveMediaAssetCard',
|
||||
component: ActiveMediaAssetCard
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const SAMPLE_PREVIEW =
|
||||
'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/images/BigBuckBunny.jpg'
|
||||
|
||||
function createJob(overrides: Partial<JobListItem> = {}): JobListItem {
|
||||
return {
|
||||
id: 'job-1',
|
||||
title: 'Running...',
|
||||
meta: 'Step 5/10',
|
||||
state: 'running',
|
||||
progressTotalPercent: 50,
|
||||
progressCurrentPercent: 75,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
export const Running: Story = {
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div style="max-width: 220px;"><story /></div>'
|
||||
})
|
||||
],
|
||||
args: {
|
||||
job: createJob({
|
||||
state: 'running',
|
||||
progressTotalPercent: 65,
|
||||
iconImageUrl: SAMPLE_PREVIEW
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const RunningWithoutPreview: Story = {
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div style="max-width: 220px;"><story /></div>'
|
||||
})
|
||||
],
|
||||
args: {
|
||||
job: createJob({
|
||||
state: 'running',
|
||||
progressTotalPercent: 30
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const Pending: Story = {
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div style="max-width: 220px;"><story /></div>'
|
||||
})
|
||||
],
|
||||
args: {
|
||||
job: createJob({
|
||||
state: 'pending',
|
||||
title: 'In queue...',
|
||||
progressTotalPercent: undefined
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const Initialization: Story = {
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div style="max-width: 220px;"><story /></div>'
|
||||
})
|
||||
],
|
||||
args: {
|
||||
job: createJob({
|
||||
state: 'initialization',
|
||||
title: 'Initializing...',
|
||||
progressTotalPercent: undefined
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const Failed: Story = {
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div style="max-width: 220px;"><story /></div>'
|
||||
})
|
||||
],
|
||||
args: {
|
||||
job: createJob({
|
||||
state: 'failed',
|
||||
title: 'Failed'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const GridLayout: Story = {
|
||||
render: () => ({
|
||||
components: { ActiveMediaAssetCard },
|
||||
setup() {
|
||||
const jobs: JobListItem[] = [
|
||||
createJob({
|
||||
id: 'job-1',
|
||||
state: 'running',
|
||||
progressTotalPercent: 75,
|
||||
iconImageUrl: SAMPLE_PREVIEW
|
||||
}),
|
||||
createJob({
|
||||
id: 'job-2',
|
||||
state: 'running',
|
||||
progressTotalPercent: 45
|
||||
}),
|
||||
createJob({
|
||||
id: 'job-3',
|
||||
state: 'pending',
|
||||
title: 'In queue...',
|
||||
progressTotalPercent: undefined
|
||||
}),
|
||||
createJob({
|
||||
id: 'job-4',
|
||||
state: 'failed',
|
||||
title: 'Failed'
|
||||
})
|
||||
]
|
||||
return { jobs }
|
||||
},
|
||||
template: `
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 8px; padding: 8px;">
|
||||
<ActiveMediaAssetCard
|
||||
v-for="job in jobs"
|
||||
:key="job.id"
|
||||
:job="job"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
111
src/platform/assets/components/ActiveMediaAssetCard.test.ts
Normal file
111
src/platform/assets/components/ActiveMediaAssetCard.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 './ActiveMediaAssetCard.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/platform/assets/components/ActiveMediaAssetCard.vue
Normal file
85
src/platform/assets/components/ActiveMediaAssetCard.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>
|
||||
Reference in New Issue
Block a user