Add stories for list view and general job card

This commit is contained in:
Benjamin Lu
2025-12-23 13:57:12 -08:00
parent db3edd522d
commit d8e57c60bf
5 changed files with 366 additions and 71 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

@@ -0,0 +1,155 @@
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 = {
key: 'cancel',
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

@@ -1,7 +1,19 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import Button from '@/components/ui/button/Button.vue'
import AssetsListCard from '@/platform/assets/components/AssetsListCard.vue'
import { iconForJobState } from '@/utils/queueDisplay'
import AssetsListCard from './AssetsListCard.vue'
function centeredDecorator() {
return {
template: `
<div style="width: 380px;">
<story />
</div>
`
}
}
const meta: Meta<typeof AssetsListCard> = {
title: 'Platform/Assets/AssetsListCard',
@@ -9,37 +21,107 @@ const meta: Meta<typeof AssetsListCard> = {
parameters: {
layout: 'centered'
},
decorators: [
() => ({
template: '<div class="p-8 bg-base-background"><story /></div>'
})
]
decorators: [centeredDecorator]
}
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'),
primaryText: 'Total: 30%',
secondaryText: 'KSampler: 70%',
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 AssetsListCardProps = InstanceType<typeof AssetsListCard>['$props']
function renderActiveJob(args: AssetsListCardProps) {
function renderRunningJobWithActions(args: AssetsListCardProps) {
return {
components: { Button, AssetsListCard },
components: { AssetsListCard, Button },
setup() {
return { args }
},
template: `
<AssetsListCard v-bind="args">
<template #primary>
<div class="flex items-center gap-1 text-text-primary">
<span>Total:</span>
<span class="font-medium">30%</span>
</div>
</template>
<template #secondary>
<div class="flex items-center gap-1 text-text-secondary">
<span>CLIP Text Encode:</span>
<span>70%</span>
</div>
</template>
<template #actions>
<Button variant="destructive" size="icon" aria-label="Cancel">
<i class="icon-[lucide--x] size-4" />
@@ -49,51 +131,3 @@ function renderActiveJob(args: AssetsListCardProps) {
`
}
}
function renderGeneratedAsset(args: AssetsListCardProps) {
return {
components: { AssetsListCard },
setup() {
return { args }
},
template: `
<AssetsListCard v-bind="args">
<template #secondary>
<div class="flex items-center gap-2 text-text-secondary">
<span>1m 56s</span>
<span>512x512</span>
</div>
</template>
</AssetsListCard>
`
}
}
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,25 @@
import { ref } from 'vue'
import type { JobAction } from '../../composables/queue/useJobActions'
import type { JobListItem } from '../../composables/queue/useJobList'
const actionsByJobId = ref<Record<string, JobAction[]>>({})
export function setMockJobActions(actions: Record<string, JobAction[]>) {
actionsByJobId.value = actions
}
export function useJobActions() {
function getJobActions(job: JobListItem) {
return actionsByJobId.value[job.id] ?? []
}
async function runJobAction() {
return undefined
}
return {
getJobActions,
runJobAction
}
}

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