[refactor] Move ActiveJobCard to platform/assets and add ActiveMediaAssetCard story (#8242)

This commit is contained in:
Jin Yi
2026-01-23 13:49:33 +09:00
committed by GitHub
parent 6bbea48d8e
commit df85c4d463
4 changed files with 150 additions and 3 deletions

View 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>
`
})
}

View 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...')
})
})

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