mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 10:59:53 +00:00
[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:
@@ -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
|
||||
|
||||
@@ -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}']
|
||||
},
|
||||
|
||||
154
src/components/sidebar/tabs/AssetsSidebarListView.stories.ts
Normal file
154
src/components/sidebar/tabs/AssetsSidebarListView.stories.ts
Normal 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>
|
||||
`
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
51
src/storybook/mocks/useJobActions.ts
Normal file
51
src/storybook/mocks/useJobActions.ts
Normal 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
|
||||
}
|
||||
}
|
||||
58
src/storybook/mocks/useJobList.ts
Normal file
58
src/storybook/mocks/useJobList.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user