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