From 818c5c32e58d9eb953a6f77a8b487da0d93bb67b Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Sat, 10 Jan 2026 21:34:37 -0800 Subject: [PATCH] [QPOv2] Add stories for list view and general job card (#7743) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .storybook/main.ts | 29 +++- knip.config.ts | 3 +- .../tabs/AssetsSidebarListView.stories.ts | 154 ++++++++++++++++++ src/composables/queue/useJobActions.ts | 2 +- .../components/AssetsListItem.stories.ts | 137 ++++++++++------ src/storybook/mocks/useJobActions.ts | 51 ++++++ src/storybook/mocks/useJobList.ts | 58 +++++++ 7 files changed, 379 insertions(+), 55 deletions(-) create mode 100644 src/components/sidebar/tabs/AssetsSidebarListView.stories.ts create mode 100644 src/storybook/mocks/useJobActions.ts create mode 100644 src/storybook/mocks/useJobList.ts diff --git a/.storybook/main.ts b/.storybook/main.ts index af23c6db3..5b7c126e9 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -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 diff --git a/knip.config.ts b/knip.config.ts index f2907a266..d6a4a7517 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -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}'] }, diff --git a/src/components/sidebar/tabs/AssetsSidebarListView.stories.ts b/src/components/sidebar/tabs/AssetsSidebarListView.stories.ts new file mode 100644 index 000000000..2c50cab17 --- /dev/null +++ b/src/components/sidebar/tabs/AssetsSidebarListView.stories.ts @@ -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 +} + +function baseDecorator() { + return { + template: ` +
+ +
+ ` + } +} + +const meta: Meta = { + title: 'Components/Sidebar/AssetsSidebarListView', + component: AssetsSidebarListView, + parameters: { + layout: 'centered' + }, + decorators: [baseDecorator] +} + +export default meta +type Story = StoryObj + +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: ` +
+ +
+ ` + } +} diff --git a/src/composables/queue/useJobActions.ts b/src/composables/queue/useJobActions.ts index 280a45224..d350503d9 100644 --- a/src/composables/queue/useJobActions.ts +++ b/src/composables/queue/useJobActions.ts @@ -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' diff --git a/src/platform/assets/components/AssetsListItem.stories.ts b/src/platform/assets/components/AssetsListItem.stories.ts index 4d54f6477..ebf66fcd7 100644 --- a/src/platform/assets/components/AssetsListItem.stories.ts +++ b/src/platform/assets/components/AssetsListItem.stories.ts @@ -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 = { title: 'Platform/Assets/AssetsListItem', @@ -18,11 +19,95 @@ const meta: Meta = { export default meta type Story = StoryObj + +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['$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: ` - - - - ` - } -} - -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 -} diff --git a/src/storybook/mocks/useJobActions.ts b/src/storybook/mocks/useJobActions.ts new file mode 100644 index 000000000..b2b823341 --- /dev/null +++ b/src/storybook/mocks/useJobActions.ts @@ -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>({}) +const cancellableStates: JobState[] = ['pending', 'initialization', 'running'] +const cancelAction: JobAction = { + icon: 'icon-[lucide--x]', + label: 'Cancel', + variant: 'destructive' +} + +export function setMockJobActions(actions: Record) { + actionsByJobId.value = actions +} + +export function useJobActions( + job?: MaybeRefOrGetter +) { + 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 + } +} diff --git a/src/storybook/mocks/useJobList.ts b/src/storybook/mocks/useJobList.ts new file mode 100644 index 000000000..702c1cebc --- /dev/null +++ b/src/storybook/mocks/useJobList.ts @@ -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([]) + +function buildGroupedJobItems(): JobGroup[] { + return [ + { + key: 'storybook', + label: 'Storybook', + items: jobItems.value + } + ] +} + +const groupedJobItems = computed(buildGroupedJobItems) + +const selectedJobTab = ref('All') +const selectedWorkflowFilter = ref<'all' | 'current'>('all') +const selectedSortMode = ref('mostRecent') +const currentNodeName = ref('KSampler') +function buildEmptyTasks(): TaskItemImpl[] { + return [] +} + +const allTasksSorted = computed(buildEmptyTasks) +const filteredTasks = computed(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 + } +}