[QPOv2] Add stories for list view and general job card (#7743)

Add stories for the media assets sidebar tab for easier prototyping.

Includes mocks for storybook.

Because some functions in the mocks are only used in the storybook
main.ts resolve, knip flags them as unused because it doesn't check that
path. So knipIgnoreUnusedButUsedByStorybook was added.

Part of the QPO v2 iteration, figma design can be found
[here](https://www.figma.com/design/LVilZgHGk5RwWOkVN6yCEK/Queue-Progress-Modal?node-id=3330-37286&m=dev).
This will be implemented in a series of stacked PRs that can be reviewed
and merged individually.

main <-- #7737, #7743, #7745

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7743-QPOv2-Add-stories-for-list-view-and-general-job-card-2d26d73d365081bca59afa925fb232d7)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
Benjamin Lu
2026-01-10 21:34:37 -08:00
committed by GitHub
parent dbb0bd961f
commit 818c5c32e5
7 changed files with 379 additions and 55 deletions

View File

@@ -69,9 +69,32 @@ const config: StorybookConfig = {
allowedHosts: true
},
resolve: {
alias: {
'@': process.cwd() + '/src'
}
alias: [
{
find: '@/composables/queue/useJobList',
replacement: process.cwd() + '/src/storybook/mocks/useJobList.ts'
},
{
find: '@/composables/queue/useJobActions',
replacement: process.cwd() + '/src/storybook/mocks/useJobActions.ts'
},
{
find: '@/utils/formatUtil',
replacement:
process.cwd() +
'/packages/shared-frontend-utils/src/formatUtil.ts'
},
{
find: '@/utils/networkUtil',
replacement:
process.cwd() +
'/packages/shared-frontend-utils/src/networkUtil.ts'
},
{
find: '@',
replacement: process.cwd() + '/src'
}
]
},
esbuild: {
// Prevent minification of identifiers to preserve _sfc_main

View File

@@ -8,7 +8,8 @@ const config: KnipConfig = {
'src/assets/css/style.css',
'src/main.ts',
'src/scripts/ui/menu/index.ts',
'src/types/index.ts'
'src/types/index.ts',
'src/storybook/mocks/**/*.ts'
],
project: ['**/*.{js,ts,vue}', '*.{js,ts,mts}']
},

View File

@@ -0,0 +1,154 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import type { JobAction } from '@/composables/queue/useJobActions'
import type { JobListItem } from '@/composables/queue/useJobList'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { setMockJobActions } from '@/storybook/mocks/useJobActions'
import { setMockJobItems } from '@/storybook/mocks/useJobList'
import { iconForJobState } from '@/utils/queueDisplay'
import AssetsSidebarListView from './AssetsSidebarListView.vue'
type StoryArgs = {
assets: AssetItem[]
jobs: JobListItem[]
selectedAssetIds?: string[]
actionsByJobId?: Record<string, JobAction[]>
}
function baseDecorator() {
return {
template: `
<div class="bg-base-background p-6">
<story />
</div>
`
}
}
const meta: Meta<StoryArgs> = {
title: 'Components/Sidebar/AssetsSidebarListView',
component: AssetsSidebarListView,
parameters: {
layout: 'centered'
},
decorators: [baseDecorator]
}
export default meta
type Story = StoryObj<typeof meta>
const baseTimestamp = '2024-01-15T10:00:00Z'
const sampleJobs: JobListItem[] = [
{
id: 'job-pending-1',
title: 'In queue',
meta: '8:59:30pm',
state: 'pending',
iconName: iconForJobState('pending'),
showClear: true
},
{
id: 'job-init-1',
title: 'Initializing...',
meta: '8:59:35pm',
state: 'initialization',
iconName: iconForJobState('initialization'),
showClear: true
},
{
id: 'job-running-1',
title: 'Total: 30%',
meta: 'KSampler: 70%',
state: 'running',
iconName: iconForJobState('running'),
showClear: true,
progressTotalPercent: 30,
progressCurrentPercent: 70
}
]
const sampleAssets: AssetItem[] = [
{
id: 'asset-image-1',
name: 'image-032.png',
created_at: baseTimestamp,
preview_url: '/assets/images/comfy-logo-single.svg',
size: 1887437,
tags: [],
user_metadata: {
promptId: 'job-running-1',
nodeId: 12,
executionTimeInSeconds: 1.84
}
},
{
id: 'asset-video-1',
name: 'clip-01.mp4',
created_at: baseTimestamp,
preview_url: '/assets/images/default-template.png',
size: 8394820,
tags: [],
user_metadata: {
duration: 132000
}
},
{
id: 'asset-audio-1',
name: 'soundtrack-01.mp3',
created_at: baseTimestamp,
size: 5242880,
tags: [],
user_metadata: {
duration: 200000
}
},
{
id: 'asset-3d-1',
name: 'scene-01.glb',
created_at: baseTimestamp,
size: 134217728,
tags: []
}
]
const cancelAction: JobAction = {
icon: 'icon-[lucide--x]',
label: 'Cancel',
variant: 'destructive'
}
export const RunningAndGenerated: Story = {
args: {
assets: sampleAssets,
jobs: sampleJobs,
actionsByJobId: {
'job-pending-1': [cancelAction],
'job-init-1': [cancelAction],
'job-running-1': [cancelAction]
}
},
render: renderAssetsSidebarListView
}
function renderAssetsSidebarListView(args: StoryArgs) {
return {
components: { AssetsSidebarListView },
setup() {
setMockJobItems(args.jobs)
setMockJobActions(args.actionsByJobId ?? {})
const selectedIds = new Set(args.selectedAssetIds ?? [])
function isSelected(assetId: string) {
return selectedIds.has(assetId)
}
return { args, isSelected }
},
template: `
<div class="h-[520px] w-[320px] overflow-hidden rounded-lg border border-panel-border">
<AssetsSidebarListView :assets="args.assets" :is-selected="isSelected" />
</div>
`
}
}

View File

@@ -7,7 +7,7 @@ import type { JobListItem } from '@/composables/queue/useJobList'
import { useJobMenu } from '@/composables/queue/useJobMenu'
import type { JobState } from '@/types/queue'
type JobAction = {
export type JobAction = {
icon: string
label: string
variant: 'destructive' | 'secondary' | 'textonly'

View File

@@ -2,6 +2,7 @@ import type { Meta, StoryObj } from '@storybook/vue3-vite'
import Button from '@/components/ui/button/Button.vue'
import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
import { iconForJobState } from '@/utils/queueDisplay'
const meta: Meta<typeof AssetsListItem> = {
title: 'Platform/Assets/AssetsListItem',
@@ -18,11 +19,95 @@ const meta: Meta<typeof AssetsListItem> = {
export default meta
type Story = StoryObj<typeof meta>
const IMAGE_PREVIEW = '/assets/images/comfy-logo-single.svg'
const VIDEO_PREVIEW = '/assets/images/default-template.png'
export const PendingJob: Story = {
args: {
iconName: iconForJobState('pending'),
iconClass: 'animate-spin',
primaryText: 'In queue',
secondaryText: '8:59:30pm'
}
}
export const InitializationJob: Story = {
args: {
iconName: iconForJobState('initialization'),
primaryText: 'Initializing...',
secondaryText: '8:59:35pm'
}
}
export const RunningJob: Story = {
args: {
iconName: iconForJobState('running'),
primaryText: 'Total: 30%',
secondaryText: 'CLIP Text Encode: 70%',
progressTotalPercent: 30,
progressCurrentPercent: 70
}
}
export const RunningJobWithActions: Story = {
args: {
iconName: iconForJobState('running'),
progressTotalPercent: 30,
progressCurrentPercent: 70
},
render: renderRunningJobWithActions
}
export const FailedJob: Story = {
args: {
iconName: iconForJobState('failed'),
iconClass: 'text-destructive-background',
iconWrapperClass: 'bg-modal-card-placeholder-background',
primaryText: 'Failed',
secondaryText: '8:59:30pm'
}
}
export const GeneratedImage: Story = {
args: {
previewUrl: IMAGE_PREVIEW,
previewAlt: 'image-032.png',
primaryText: 'image-032.png',
secondaryText: '1.84s'
}
}
export const GeneratedVideo: Story = {
args: {
previewUrl: VIDEO_PREVIEW,
previewAlt: 'clip-01.mp4',
primaryText: 'clip-01.mp4',
secondaryText: '2m 12s'
}
}
export const GeneratedAudio: Story = {
args: {
iconName: 'icon-[lucide--music]',
primaryText: 'soundtrack-01.mp3',
secondaryText: '3m 20s'
}
}
export const Generated3D: Story = {
args: {
iconName: 'icon-[lucide--box]',
primaryText: 'scene-01.glb',
secondaryText: '128 MB'
}
}
type AssetsListItemProps = InstanceType<typeof AssetsListItem>['$props']
function renderActiveJob(args: AssetsListItemProps) {
function renderRunningJobWithActions(args: AssetsListItemProps) {
return {
components: { Button, AssetsListItem },
components: { AssetsListItem, Button },
setup() {
return { args }
},
@@ -49,51 +134,3 @@ function renderActiveJob(args: AssetsListItemProps) {
`
}
}
function renderGeneratedAsset(args: AssetsListItemProps) {
return {
components: { AssetsListItem },
setup() {
return { args }
},
template: `
<AssetsListItem v-bind="args">
<template #secondary>
<div class="flex items-center gap-2 text-text-secondary">
<span>1m 56s</span>
<span>512x512</span>
</div>
</template>
</AssetsListItem>
`
}
}
export const ActiveJob: Story = {
args: {
previewUrl: '/assets/images/comfy-logo-single.svg',
previewAlt: 'Job preview',
progressTotalPercent: 30,
progressCurrentPercent: 70
},
render: renderActiveJob
}
export const FailedJob: Story = {
args: {
iconName: 'icon-[lucide--circle-alert]',
iconClass: 'text-destructive-background',
iconWrapperClass: 'bg-modal-card-placeholder-background',
primaryText: 'Failed',
secondaryText: '8:59:30pm'
}
}
export const GeneratedAsset: Story = {
args: {
previewUrl: '/assets/images/comfy-logo-single.svg',
previewAlt: 'image03.png',
primaryText: 'image03.png'
},
render: renderGeneratedAsset
}

View File

@@ -0,0 +1,51 @@
import { computed, ref, toValue } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
import type { JobAction } from '../../composables/queue/useJobActions'
import type { JobListItem } from '../../composables/queue/useJobList'
import type { JobState } from '../../types/queue'
const actionsByJobId = ref<Record<string, JobAction[]>>({})
const cancellableStates: JobState[] = ['pending', 'initialization', 'running']
const cancelAction: JobAction = {
icon: 'icon-[lucide--x]',
label: 'Cancel',
variant: 'destructive'
}
export function setMockJobActions(actions: Record<string, JobAction[]>) {
actionsByJobId.value = actions
}
export function useJobActions(
job?: MaybeRefOrGetter<JobListItem | null | undefined>
) {
const jobRef = computed(() => (job ? (toValue(job) ?? null) : null))
const canCancelJob = computed(() => {
const currentJob = jobRef.value
if (!currentJob) {
return false
}
const configuredActions = actionsByJobId.value[currentJob.id]
if (configuredActions) {
return configuredActions.length > 0
}
return (
currentJob.showClear !== false &&
cancellableStates.includes(currentJob.state)
)
})
async function runCancelJob() {
return undefined
}
return {
cancelAction,
canCancelJob,
runCancelJob
}
}

View File

@@ -0,0 +1,58 @@
import { computed, ref } from 'vue'
import type { TaskItemImpl } from '../../stores/queueStore'
import type {
JobGroup,
JobListItem,
JobSortMode,
JobTab
} from '../../composables/queue/useJobList'
const jobItems = ref<JobListItem[]>([])
function buildGroupedJobItems(): JobGroup[] {
return [
{
key: 'storybook',
label: 'Storybook',
items: jobItems.value
}
]
}
const groupedJobItems = computed<JobGroup[]>(buildGroupedJobItems)
const selectedJobTab = ref<JobTab>('All')
const selectedWorkflowFilter = ref<'all' | 'current'>('all')
const selectedSortMode = ref<JobSortMode>('mostRecent')
const currentNodeName = ref('KSampler')
function buildEmptyTasks(): TaskItemImpl[] {
return []
}
const allTasksSorted = computed<TaskItemImpl[]>(buildEmptyTasks)
const filteredTasks = computed<TaskItemImpl[]>(buildEmptyTasks)
function buildHasFailedJobs() {
return jobItems.value.some((item) => item.state === 'failed')
}
const hasFailedJobs = computed(buildHasFailedJobs)
export function setMockJobItems(items: JobListItem[]) {
jobItems.value = items
}
export function useJobList() {
return {
selectedJobTab,
selectedWorkflowFilter,
selectedSortMode,
hasFailedJobs,
allTasksSorted,
filteredTasks,
jobItems,
groupedJobItems,
currentNodeName
}
}