mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-25 06:47:35 +00:00
Compare commits
9 Commits
refactor/e
...
AddTemplat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf63a5cc71 | ||
|
|
8361122586 | ||
|
|
55dea32e00 | ||
|
|
07d49cbe64 | ||
|
|
fdd963a630 | ||
|
|
b638e6a577 | ||
|
|
c7409b6830 | ||
|
|
ff972fbefb | ||
|
|
b18fd8e57a |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.41.3",
|
||||
"version": "1.46.0",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -267,6 +267,16 @@
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="template.vram" #top-left>
|
||||
<SquareChip
|
||||
:label="formatSize(template.vram)"
|
||||
:title="t('templateWorkflows.vramEstimateTooltip')"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--cpu] h-3 w-3" />
|
||||
</template>
|
||||
</SquareChip>
|
||||
</template>
|
||||
<template #bottom-right>
|
||||
<template v-if="template.tags && template.tags.length > 0">
|
||||
<SquareChip
|
||||
@@ -387,6 +397,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAsyncState } from '@vueuse/core'
|
||||
import { formatSize } from '@/utils/formatUtil'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -724,6 +735,10 @@ const sortOptions = computed(() => [
|
||||
name: t('templateWorkflows.sort.recommended', 'Recommended'),
|
||||
value: 'recommended'
|
||||
},
|
||||
{
|
||||
name: t('templateWorkflows.sort.similarToCurrent', 'Similar to Current'),
|
||||
value: 'similar-to-current'
|
||||
},
|
||||
{
|
||||
name: t('templateWorkflows.sort.popular', 'Popular'),
|
||||
value: 'popular'
|
||||
|
||||
367
src/components/developerProfile/DeveloperProfileDialog.test.ts
Normal file
367
src/components/developerProfile/DeveloperProfileDialog.test.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type {
|
||||
DeveloperProfile,
|
||||
MarketplaceTemplate,
|
||||
TemplateReview,
|
||||
TemplateRevenue
|
||||
} from '@/types/templateMarketplace'
|
||||
|
||||
vi.mock('@vueuse/core', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>
|
||||
return {
|
||||
...actual,
|
||||
watchDebounced: vi.fn((source: unknown, cb: unknown, opts: unknown) => {
|
||||
const typedActual = actual as {
|
||||
watchDebounced: (...args: unknown[]) => unknown
|
||||
}
|
||||
return typedActual.watchDebounced(source, cb, {
|
||||
...(opts as object),
|
||||
debounce: 0
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const stubProfile: DeveloperProfile = {
|
||||
username: '@StoneCypher',
|
||||
displayName: 'Stone Cypher',
|
||||
avatarUrl: undefined,
|
||||
bannerUrl: undefined,
|
||||
bio: 'Workflow designer',
|
||||
isVerified: true,
|
||||
monetizationEnabled: true,
|
||||
joinedAt: new Date('2024-03-15'),
|
||||
dependencies: 371,
|
||||
totalDownloads: 1000,
|
||||
totalFavorites: 50,
|
||||
averageRating: 4.2,
|
||||
templateCount: 2
|
||||
}
|
||||
|
||||
const stubReviews: TemplateReview[] = [
|
||||
{
|
||||
id: 'rev-1',
|
||||
authorName: 'Reviewer',
|
||||
rating: 4.5,
|
||||
text: 'Great work!',
|
||||
createdAt: new Date('2025-10-01'),
|
||||
templateId: 'tpl-1'
|
||||
}
|
||||
]
|
||||
|
||||
const stubTemplate: MarketplaceTemplate = {
|
||||
id: 'tpl-1',
|
||||
title: 'Test Template',
|
||||
description: 'Desc',
|
||||
shortDescription: 'Short',
|
||||
author: {
|
||||
id: 'usr-1',
|
||||
name: 'Stone Cypher',
|
||||
isVerified: true,
|
||||
profileUrl: '/u'
|
||||
},
|
||||
categories: [],
|
||||
tags: [],
|
||||
difficulty: 'beginner',
|
||||
requiredModels: [],
|
||||
requiredNodes: [],
|
||||
requiresCustomNodes: [],
|
||||
vramRequirement: 0,
|
||||
thumbnail: '',
|
||||
gallery: [],
|
||||
workflowPreview: '',
|
||||
license: 'mit',
|
||||
version: '1.0.0',
|
||||
status: 'approved',
|
||||
updatedAt: new Date(),
|
||||
stats: {
|
||||
downloads: 500,
|
||||
favorites: 30,
|
||||
rating: 4,
|
||||
reviewCount: 5,
|
||||
weeklyTrend: 1
|
||||
}
|
||||
}
|
||||
|
||||
const stubRevenue: TemplateRevenue[] = [
|
||||
{
|
||||
templateId: 'tpl-1',
|
||||
totalRevenue: 5000,
|
||||
monthlyRevenue: 500,
|
||||
currency: 'USD'
|
||||
}
|
||||
]
|
||||
|
||||
const mockService = vi.hoisted(() => ({
|
||||
getCurrentUsername: vi.fn(() => '@StoneCypher'),
|
||||
fetchDeveloperProfile: vi.fn(() => Promise.resolve({ ...stubProfile })),
|
||||
fetchDeveloperReviews: vi.fn(() => Promise.resolve([...stubReviews])),
|
||||
fetchPublishedTemplates: vi.fn(() => Promise.resolve([{ ...stubTemplate }])),
|
||||
fetchTemplateRevenue: vi.fn(() => Promise.resolve([...stubRevenue])),
|
||||
fetchDownloadHistory: vi.fn(() => Promise.resolve([])),
|
||||
unpublishTemplate: vi.fn(() => Promise.resolve()),
|
||||
saveDeveloperProfile: vi.fn((p: Partial<DeveloperProfile>) =>
|
||||
Promise.resolve({ ...stubProfile, ...p })
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('@/services/developerProfileService', () => mockService)
|
||||
|
||||
import DeveloperProfileDialog from './DeveloperProfileDialog.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
developerProfile: {
|
||||
dialogTitle: 'Developer Profile',
|
||||
username: 'Username',
|
||||
bio: 'Bio',
|
||||
reviews: 'Reviews',
|
||||
publishedTemplates: 'Published Templates',
|
||||
dependencies: 'Dependencies',
|
||||
totalDownloads: 'Downloads',
|
||||
totalFavorites: 'Favorites',
|
||||
averageRating: 'Avg. Rating',
|
||||
templateCount: 'Templates',
|
||||
revenue: 'Revenue',
|
||||
monthlyRevenue: 'Monthly',
|
||||
totalRevenue: 'Total',
|
||||
noReviews: 'No reviews yet',
|
||||
noTemplates: 'No published templates yet',
|
||||
unpublish: 'Unpublish',
|
||||
save: 'Save Profile',
|
||||
saving: 'Saving...',
|
||||
verified: 'Verified',
|
||||
quickActions: 'Quick Actions',
|
||||
bannerPlaceholder: 'Banner image',
|
||||
editUsername: 'Edit username',
|
||||
editBio: 'Edit bio',
|
||||
lookupHandle: 'Enter developer handle\u2026',
|
||||
downloads: 'Downloads',
|
||||
favorites: 'Favorites',
|
||||
rating: 'Rating'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function mountDialog(props?: { username?: string }) {
|
||||
return mount(DeveloperProfileDialog, {
|
||||
props: {
|
||||
onClose: vi.fn(),
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
BaseModalLayout: {
|
||||
template: `
|
||||
<div data-testid="modal">
|
||||
<div data-testid="header"><slot name="header" /></div>
|
||||
<div data-testid="header-right"><slot name="header-right-area" /></div>
|
||||
<div data-testid="content"><slot name="content" /></div>
|
||||
</div>
|
||||
`
|
||||
},
|
||||
ReviewCard: {
|
||||
template: '<div data-testid="review-card" />',
|
||||
props: ['review']
|
||||
},
|
||||
TemplateListItem: {
|
||||
template:
|
||||
'<div data-testid="template-list-item" :data-show-revenue="showRevenue" :data-is-current-user="isCurrentUser" />',
|
||||
props: ['template', 'revenue', 'showRevenue', 'isCurrentUser']
|
||||
},
|
||||
DownloadHistoryChart: {
|
||||
template: '<div data-testid="download-history-chart" />',
|
||||
props: ['entries']
|
||||
},
|
||||
Button: {
|
||||
template: '<button><slot /></button>',
|
||||
props: ['variant', 'size', 'disabled']
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('DeveloperProfileDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockService.getCurrentUsername.mockReturnValue('@StoneCypher')
|
||||
mockService.fetchDeveloperProfile.mockResolvedValue({ ...stubProfile })
|
||||
mockService.fetchDeveloperReviews.mockResolvedValue([...stubReviews])
|
||||
mockService.fetchPublishedTemplates.mockResolvedValue([{ ...stubTemplate }])
|
||||
mockService.fetchTemplateRevenue.mockResolvedValue([...stubRevenue])
|
||||
})
|
||||
|
||||
it('renders the banner section', async () => {
|
||||
const wrapper = mountDialog()
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-testid="banner-section"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows username input when viewing own profile', async () => {
|
||||
const wrapper = mountDialog()
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-testid="username-input"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="username-text"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows username text when viewing another profile', async () => {
|
||||
const wrapper = mountDialog({ username: '@OtherUser' })
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-testid="username-text"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="username-input"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows bio input when viewing own profile', async () => {
|
||||
const wrapper = mountDialog()
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-testid="bio-input"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows bio text when viewing another profile', async () => {
|
||||
const wrapper = mountDialog({ username: '@OtherUser' })
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-testid="bio-text"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders review cards', async () => {
|
||||
const wrapper = mountDialog()
|
||||
await flushPromises()
|
||||
expect(wrapper.findAll('[data-testid="review-card"]')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('renders template list items', async () => {
|
||||
const wrapper = mountDialog()
|
||||
await flushPromises()
|
||||
expect(wrapper.findAll('[data-testid="template-list-item"]')).toHaveLength(
|
||||
1
|
||||
)
|
||||
})
|
||||
|
||||
it('passes showRevenue=true when current user with monetization', async () => {
|
||||
const wrapper = mountDialog()
|
||||
await flushPromises()
|
||||
const item = wrapper.find('[data-testid="template-list-item"]')
|
||||
expect(item.attributes('data-show-revenue')).toBe('true')
|
||||
})
|
||||
|
||||
it('passes showRevenue=false when not current user', async () => {
|
||||
const wrapper = mountDialog({ username: '@OtherUser' })
|
||||
await flushPromises()
|
||||
const item = wrapper.find('[data-testid="template-list-item"]')
|
||||
expect(item.attributes('data-show-revenue')).toBe('false')
|
||||
})
|
||||
|
||||
it('shows quick actions when viewing own profile', async () => {
|
||||
const wrapper = mountDialog()
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-testid="quick-actions"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('hides quick actions when viewing another profile', async () => {
|
||||
const wrapper = mountDialog({ username: '@OtherUser' })
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-testid="quick-actions"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows save button when viewing own profile', async () => {
|
||||
const wrapper = mountDialog()
|
||||
await flushPromises()
|
||||
const headerRight = wrapper.find('[data-testid="header-right"]')
|
||||
expect(headerRight.text()).toContain('Save Profile')
|
||||
})
|
||||
|
||||
it('hides save button when viewing another profile', async () => {
|
||||
const wrapper = mountDialog({ username: '@OtherUser' })
|
||||
await flushPromises()
|
||||
const headerRight = wrapper.find('[data-testid="header-right"]')
|
||||
expect(headerRight.text()).not.toContain('Save Profile')
|
||||
})
|
||||
|
||||
it('renders summary stats', async () => {
|
||||
const wrapper = mountDialog()
|
||||
await flushPromises()
|
||||
const stats = wrapper.find('[data-testid="summary-stats"]')
|
||||
expect(stats.exists()).toBe(true)
|
||||
expect(stats.text()).toContain('371')
|
||||
expect(stats.text()).toContain('1,000')
|
||||
expect(stats.text()).toContain('50')
|
||||
})
|
||||
|
||||
it('renders the handle input with the default username', async () => {
|
||||
const wrapper = mountDialog()
|
||||
await flushPromises()
|
||||
const handleInput = wrapper.find('[data-testid="handle-input"]')
|
||||
expect(handleInput.exists()).toBe(true)
|
||||
expect((handleInput.element as HTMLInputElement).value).toBe('@StoneCypher')
|
||||
})
|
||||
|
||||
it('reloads data when the handle input changes', async () => {
|
||||
const otherProfile: DeveloperProfile = {
|
||||
...stubProfile,
|
||||
username: '@OtherDev',
|
||||
displayName: 'Other Dev',
|
||||
bio: 'Another developer',
|
||||
isVerified: false,
|
||||
monetizationEnabled: false,
|
||||
totalDownloads: 42
|
||||
}
|
||||
const wrapper = mountDialog()
|
||||
await flushPromises()
|
||||
|
||||
// Initial load
|
||||
expect(mockService.fetchDeveloperProfile).toHaveBeenCalledWith(
|
||||
'@StoneCypher'
|
||||
)
|
||||
vi.clearAllMocks()
|
||||
|
||||
mockService.fetchDeveloperProfile.mockResolvedValue(otherProfile)
|
||||
mockService.fetchDeveloperReviews.mockResolvedValue([])
|
||||
mockService.fetchPublishedTemplates.mockResolvedValue([])
|
||||
|
||||
const handleInput = wrapper.find('[data-testid="handle-input"]')
|
||||
await handleInput.setValue('@OtherDev')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockService.fetchDeveloperProfile).toHaveBeenCalledWith('@OtherDev')
|
||||
expect(wrapper.find('[data-testid="username-text"]').text()).toBe(
|
||||
'Other Dev'
|
||||
)
|
||||
})
|
||||
|
||||
it('clears revenue when switching to a non-current-user handle', async () => {
|
||||
const wrapper = mountDialog()
|
||||
await flushPromises()
|
||||
|
||||
// Revenue was loaded for current user
|
||||
expect(mockService.fetchTemplateRevenue).toHaveBeenCalled()
|
||||
vi.clearAllMocks()
|
||||
|
||||
const otherProfile: DeveloperProfile = {
|
||||
...stubProfile,
|
||||
username: '@Someone',
|
||||
monetizationEnabled: false
|
||||
}
|
||||
mockService.fetchDeveloperProfile.mockResolvedValue(otherProfile)
|
||||
mockService.fetchDeveloperReviews.mockResolvedValue([])
|
||||
mockService.fetchPublishedTemplates.mockResolvedValue([])
|
||||
|
||||
const handleInput = wrapper.find('[data-testid="handle-input"]')
|
||||
await handleInput.setValue('@Someone')
|
||||
await flushPromises()
|
||||
|
||||
// Revenue should NOT be fetched for other user
|
||||
expect(mockService.fetchTemplateRevenue).not.toHaveBeenCalled()
|
||||
// showRevenue should be false
|
||||
const item = wrapper.find('[data-testid="template-list-item"]')
|
||||
expect(item.exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
322
src/components/developerProfile/DeveloperProfileDialog.vue
Normal file
322
src/components/developerProfile/DeveloperProfileDialog.vue
Normal file
@@ -0,0 +1,322 @@
|
||||
<template>
|
||||
<BaseModalLayout :content-title="t('developerProfile.dialogTitle')" size="sm">
|
||||
<template #header>
|
||||
<input
|
||||
v-model="viewedUsername"
|
||||
type="text"
|
||||
:placeholder="t('developerProfile.lookupHandle')"
|
||||
class="h-8 w-48 rounded border border-border-default bg-secondary-background px-2 text-sm text-muted-foreground focus:outline-none"
|
||||
data-testid="handle-input"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-if="isCurrentUser" #header-right-area>
|
||||
<div class="mr-6">
|
||||
<Button size="lg" :disabled="isSaving" @click="saveProfile">
|
||||
{{
|
||||
isSaving ? t('developerProfile.saving') : t('developerProfile.save')
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div class="flex flex-col gap-6">
|
||||
<!-- Banner Image -->
|
||||
<div
|
||||
class="h-48 w-full overflow-hidden rounded-lg bg-secondary-background"
|
||||
data-testid="banner-section"
|
||||
>
|
||||
<img
|
||||
v-if="profile?.bannerUrl"
|
||||
:src="profile.bannerUrl"
|
||||
:alt="t('developerProfile.bannerPlaceholder')"
|
||||
class="size-full object-cover"
|
||||
/>
|
||||
<div v-else class="flex size-full items-center justify-center">
|
||||
<i
|
||||
class="icon-[lucide--image] size-10 text-muted-foreground opacity-40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Avatar + Username + Bio -->
|
||||
<div class="flex items-start gap-4" data-testid="identity-section">
|
||||
<div
|
||||
class="flex size-16 shrink-0 items-center justify-center rounded-full bg-modal-panel-background"
|
||||
>
|
||||
<i class="icon-[lucide--user] size-8 text-muted-foreground" />
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<template v-if="isCurrentUser">
|
||||
<input
|
||||
v-model="editableUsername"
|
||||
type="text"
|
||||
:placeholder="t('developerProfile.editUsername')"
|
||||
class="h-8 rounded border border-border-default bg-secondary-background px-2 text-sm focus:outline-none"
|
||||
data-testid="username-input"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="text-lg font-semibold" data-testid="username-text">
|
||||
{{ profile?.displayName ?? viewedUsername }}
|
||||
</span>
|
||||
</template>
|
||||
<span
|
||||
v-if="profile?.isVerified"
|
||||
class="inline-flex items-center gap-1 rounded-full bg-blue-500/10 px-2 py-0.5 text-xs text-blue-400"
|
||||
>
|
||||
<i class="icon-[lucide--badge-check] size-3" />
|
||||
{{ t('developerProfile.verified') }}
|
||||
</span>
|
||||
</div>
|
||||
<template v-if="isCurrentUser">
|
||||
<textarea
|
||||
v-model="editableBio"
|
||||
:placeholder="t('developerProfile.editBio')"
|
||||
rows="2"
|
||||
class="resize-none rounded border border-border-default bg-secondary-background px-2 py-1 text-sm text-muted-foreground focus:outline-none"
|
||||
data-testid="bio-input"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p
|
||||
v-if="profile?.bio"
|
||||
class="m-0 text-sm text-muted-foreground"
|
||||
data-testid="bio-text"
|
||||
>
|
||||
{{ profile.bio }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Stats -->
|
||||
<div class="grid grid-cols-5 gap-3" data-testid="summary-stats">
|
||||
<div
|
||||
v-for="stat in summaryStats"
|
||||
:key="stat.label"
|
||||
class="flex flex-col items-center rounded-lg bg-secondary-background p-3"
|
||||
>
|
||||
<span class="text-lg font-semibold">{{ stat.value }}</span>
|
||||
<span class="text-xs text-muted-foreground">{{ stat.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Download History Chart -->
|
||||
<DownloadHistoryChart
|
||||
v-if="downloadHistory.length > 0"
|
||||
:entries="downloadHistory"
|
||||
/>
|
||||
|
||||
<!-- Quick Actions (current user only) -->
|
||||
<div
|
||||
v-if="isCurrentUser"
|
||||
class="rounded-lg border border-border-default p-4"
|
||||
data-testid="quick-actions"
|
||||
>
|
||||
<h3 class="m-0 mb-3 text-sm font-semibold">
|
||||
{{ t('developerProfile.quickActions') }}
|
||||
</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button
|
||||
v-for="tpl in templates"
|
||||
:key="tpl.id"
|
||||
variant="destructive-textonly"
|
||||
size="sm"
|
||||
@click="handleUnpublish(tpl.id)"
|
||||
>
|
||||
{{ t('developerProfile.unpublish') }}: {{ tpl.title }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reviews Section -->
|
||||
<div data-testid="reviews-section">
|
||||
<h3 class="m-0 mb-3 text-sm font-semibold">
|
||||
{{ t('developerProfile.reviews') }}
|
||||
</h3>
|
||||
<div
|
||||
v-if="reviews.length === 0"
|
||||
class="py-4 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ t('developerProfile.noReviews') }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex max-h-80 flex-col gap-2 overflow-y-auto scrollbar-custom"
|
||||
>
|
||||
<ReviewCard
|
||||
v-for="review in reviews"
|
||||
:key="review.id"
|
||||
:review="review"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Published Templates Section -->
|
||||
<div data-testid="templates-section">
|
||||
<h3 class="m-0 mb-3 text-sm font-semibold">
|
||||
{{ t('developerProfile.publishedTemplates') }}
|
||||
</h3>
|
||||
<div
|
||||
v-if="templates.length === 0"
|
||||
class="py-4 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ t('developerProfile.noTemplates') }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex max-h-96 flex-col gap-2 overflow-y-auto scrollbar-custom"
|
||||
>
|
||||
<TemplateListItem
|
||||
v-for="tpl in templates"
|
||||
:key="tpl.id"
|
||||
:template="tpl"
|
||||
:revenue="revenueByTemplateId[tpl.id]"
|
||||
:show-revenue="showRevenueColumn"
|
||||
:is-current-user="isCurrentUser"
|
||||
@unpublish="handleUnpublish"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</BaseModalLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, provide, ref } from 'vue'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
|
||||
import {
|
||||
fetchDeveloperProfile,
|
||||
fetchDeveloperReviews,
|
||||
fetchDownloadHistory,
|
||||
fetchPublishedTemplates,
|
||||
fetchTemplateRevenue,
|
||||
getCurrentUsername,
|
||||
saveDeveloperProfile,
|
||||
unpublishTemplate
|
||||
} from '@/services/developerProfileService'
|
||||
import type {
|
||||
DeveloperProfile,
|
||||
DownloadHistoryEntry,
|
||||
MarketplaceTemplate,
|
||||
TemplateReview,
|
||||
TemplateRevenue
|
||||
} from '@/types/templateMarketplace'
|
||||
import { OnCloseKey } from '@/types/widgetTypes'
|
||||
|
||||
import DownloadHistoryChart from './DownloadHistoryChart.vue'
|
||||
import ReviewCard from './ReviewCard.vue'
|
||||
import TemplateListItem from './TemplateListItem.vue'
|
||||
|
||||
const { onClose, username } = defineProps<{
|
||||
onClose: () => void
|
||||
username?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
provide(OnCloseKey, onClose)
|
||||
|
||||
const viewedUsername = ref(username ?? getCurrentUsername())
|
||||
const isCurrentUser = computed(
|
||||
() => viewedUsername.value === getCurrentUsername()
|
||||
)
|
||||
|
||||
const profile = ref<DeveloperProfile | null>(null)
|
||||
const reviews = ref<TemplateReview[]>([])
|
||||
const templates = ref<MarketplaceTemplate[]>([])
|
||||
const revenue = ref<TemplateRevenue[]>([])
|
||||
const downloadHistory = ref<DownloadHistoryEntry[]>([])
|
||||
const isSaving = ref(false)
|
||||
|
||||
const editableUsername = ref('')
|
||||
const editableBio = ref('')
|
||||
|
||||
const revenueByTemplateId = computed(() => {
|
||||
const map: Record<string, TemplateRevenue> = {}
|
||||
for (const entry of revenue.value) {
|
||||
map[entry.templateId] = entry
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const showRevenueColumn = computed(
|
||||
() => isCurrentUser.value && (profile.value?.monetizationEnabled ?? false)
|
||||
)
|
||||
|
||||
const summaryStats = computed(() => [
|
||||
{
|
||||
label: t('developerProfile.dependencies'),
|
||||
value: (profile.value?.dependencies ?? 371).toLocaleString()
|
||||
},
|
||||
{
|
||||
label: t('developerProfile.totalDownloads'),
|
||||
value: (profile.value?.totalDownloads ?? 0).toLocaleString()
|
||||
},
|
||||
{
|
||||
label: t('developerProfile.totalFavorites'),
|
||||
value: (profile.value?.totalFavorites ?? 0).toLocaleString()
|
||||
},
|
||||
{
|
||||
label: t('developerProfile.averageRating'),
|
||||
value: (profile.value?.averageRating ?? 0).toFixed(1)
|
||||
},
|
||||
{
|
||||
label: t('developerProfile.templateCount'),
|
||||
value: String(profile.value?.templateCount ?? 0)
|
||||
}
|
||||
])
|
||||
|
||||
watchDebounced(viewedUsername, () => void loadData(), { debounce: 500 })
|
||||
|
||||
async function loadData() {
|
||||
const handle = viewedUsername.value
|
||||
const [profileData, reviewsData, templatesData, historyData] =
|
||||
await Promise.all([
|
||||
fetchDeveloperProfile(handle),
|
||||
fetchDeveloperReviews(handle),
|
||||
fetchPublishedTemplates(handle),
|
||||
fetchDownloadHistory(handle)
|
||||
])
|
||||
profile.value = profileData
|
||||
reviews.value = reviewsData
|
||||
templates.value = templatesData
|
||||
downloadHistory.value = historyData
|
||||
editableUsername.value = profileData.displayName
|
||||
editableBio.value = profileData.bio ?? ''
|
||||
|
||||
if (isCurrentUser.value && profileData.monetizationEnabled) {
|
||||
revenue.value = await fetchTemplateRevenue(handle)
|
||||
} else {
|
||||
revenue.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
isSaving.value = true
|
||||
try {
|
||||
profile.value = await saveDeveloperProfile({
|
||||
...profile.value,
|
||||
displayName: editableUsername.value,
|
||||
bio: editableBio.value
|
||||
})
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnpublish(templateId: string) {
|
||||
await unpublishTemplate(templateId)
|
||||
templates.value = templates.value.filter((t) => t.id !== templateId)
|
||||
}
|
||||
|
||||
void loadData()
|
||||
</script>
|
||||
217
src/components/developerProfile/DownloadHistoryChart.test.ts
Normal file
217
src/components/developerProfile/DownloadHistoryChart.test.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { nextTick } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { DownloadHistoryEntry } from '@/types/templateMarketplace'
|
||||
|
||||
const { MockChart } = vi.hoisted(() => {
|
||||
const mockDestroyFn = vi.fn()
|
||||
|
||||
class MockChartClass {
|
||||
static register = vi.fn()
|
||||
static instances: MockChartClass[] = []
|
||||
type: string
|
||||
data: unknown
|
||||
destroy = mockDestroyFn
|
||||
|
||||
constructor(_canvas: unknown, config: { type: string; data: unknown }) {
|
||||
this.type = config.type
|
||||
this.data = config.data
|
||||
MockChartClass.instances.push(this)
|
||||
}
|
||||
}
|
||||
|
||||
return { MockChart: MockChartClass, mockDestroyFn }
|
||||
})
|
||||
|
||||
vi.mock('chart.js', () => ({
|
||||
Chart: MockChart,
|
||||
BarController: {},
|
||||
BarElement: {},
|
||||
CategoryScale: {},
|
||||
Filler: {},
|
||||
LineController: {},
|
||||
LineElement: {},
|
||||
LinearScale: {},
|
||||
PointElement: {},
|
||||
Tooltip: {}
|
||||
}))
|
||||
|
||||
import DownloadHistoryChart from './DownloadHistoryChart.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
developerProfile: {
|
||||
downloadHistory: 'Download History',
|
||||
range: {
|
||||
week: 'Week',
|
||||
month: 'Month',
|
||||
year: 'Year',
|
||||
allTime: 'All Time'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function makeEntries(count: number): DownloadHistoryEntry[] {
|
||||
const entries: DownloadHistoryEntry[] = []
|
||||
const now = new Date()
|
||||
for (let i = count - 1; i >= 0; i--) {
|
||||
const date = new Date(now)
|
||||
date.setDate(date.getDate() - i)
|
||||
date.setHours(0, 0, 0, 0)
|
||||
entries.push({ date, downloads: 10 + i })
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
async function mountChart(entries?: DownloadHistoryEntry[]) {
|
||||
const wrapper = mount(DownloadHistoryChart, {
|
||||
props: { entries: entries ?? makeEntries(730) },
|
||||
global: { plugins: [i18n] },
|
||||
attachTo: document.createElement('div')
|
||||
})
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
function lastInstance() {
|
||||
return MockChart.instances.at(-1)
|
||||
}
|
||||
|
||||
describe('DownloadHistoryChart', () => {
|
||||
beforeEach(() => {
|
||||
MockChart.instances = []
|
||||
})
|
||||
|
||||
it('renders all four range buttons', async () => {
|
||||
const wrapper = await mountChart()
|
||||
const buttons = wrapper.find('[data-testid="range-buttons"]')
|
||||
expect(buttons.exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="range-btn-week"]').text()).toBe('Week')
|
||||
expect(wrapper.find('[data-testid="range-btn-month"]').text()).toBe('Month')
|
||||
expect(wrapper.find('[data-testid="range-btn-year"]').text()).toBe('Year')
|
||||
expect(wrapper.find('[data-testid="range-btn-allTime"]').text()).toBe(
|
||||
'All Time'
|
||||
)
|
||||
})
|
||||
|
||||
it('defaults to week range with active styling', async () => {
|
||||
const wrapper = await mountChart()
|
||||
const weekBtn = wrapper.find('[data-testid="range-btn-week"]')
|
||||
expect(weekBtn.classes()).toContain('font-semibold')
|
||||
})
|
||||
|
||||
it('creates a bar chart for week range', async () => {
|
||||
await mountChart()
|
||||
expect(lastInstance()?.type).toBe('bar')
|
||||
})
|
||||
|
||||
it('switches to month and creates a bar chart', async () => {
|
||||
const wrapper = await mountChart()
|
||||
await wrapper.find('[data-testid="range-btn-month"]').trigger('click')
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
expect(lastInstance()?.type).toBe('bar')
|
||||
})
|
||||
|
||||
it('switches to year and creates a line chart', async () => {
|
||||
const wrapper = await mountChart()
|
||||
await wrapper.find('[data-testid="range-btn-year"]').trigger('click')
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
expect(lastInstance()?.type).toBe('line')
|
||||
})
|
||||
|
||||
it('switches to allTime and creates a line chart', async () => {
|
||||
const wrapper = await mountChart()
|
||||
await wrapper.find('[data-testid="range-btn-allTime"]').trigger('click')
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
expect(lastInstance()?.type).toBe('line')
|
||||
})
|
||||
|
||||
it('destroys previous chart when switching ranges', async () => {
|
||||
const wrapper = await mountChart()
|
||||
const firstInstance = lastInstance()!
|
||||
|
||||
await wrapper.find('[data-testid="range-btn-month"]').trigger('click')
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
|
||||
expect(firstInstance.destroy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders the heading text', async () => {
|
||||
const wrapper = await mountChart()
|
||||
expect(wrapper.text()).toContain('Download History')
|
||||
})
|
||||
|
||||
it('passes 7 data points for week range', async () => {
|
||||
await mountChart()
|
||||
const chart = lastInstance()!
|
||||
const labels = (chart.data as { labels: string[] }).labels
|
||||
expect(labels).toHaveLength(7)
|
||||
})
|
||||
|
||||
it('passes 31 data points for month range', async () => {
|
||||
const wrapper = await mountChart()
|
||||
await wrapper.find('[data-testid="range-btn-month"]').trigger('click')
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
const chart = lastInstance()!
|
||||
const labels = (chart.data as { labels: string[] }).labels
|
||||
expect(labels).toHaveLength(31)
|
||||
})
|
||||
|
||||
it('downsamples year range to weekly buckets', async () => {
|
||||
const wrapper = await mountChart()
|
||||
await wrapper.find('[data-testid="range-btn-year"]').trigger('click')
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
const chart = lastInstance()!
|
||||
const labels = (chart.data as { labels: string[] }).labels
|
||||
// 365 days / 7 per bucket = 52 full + 1 partial = 53
|
||||
expect(labels).toHaveLength(Math.ceil(365 / 7))
|
||||
})
|
||||
|
||||
it('downsamples allTime range to monthly buckets', async () => {
|
||||
const wrapper = await mountChart()
|
||||
await wrapper.find('[data-testid="range-btn-allTime"]').trigger('click')
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
const chart = lastInstance()!
|
||||
const labels = (chart.data as { labels: string[] }).labels
|
||||
// 730 days / 30 per bucket = 24 full + 1 partial = 25
|
||||
expect(labels).toHaveLength(Math.ceil(730 / 30))
|
||||
})
|
||||
|
||||
it('sums downloads within each aggregated bucket', async () => {
|
||||
// 14 entries with downloads = 1 each, aggregated by 7 → 2 buckets of 7
|
||||
const entries = makeEntries(14).map((e) => ({ ...e, downloads: 1 }))
|
||||
const wrapper = mount(DownloadHistoryChart, {
|
||||
props: { entries },
|
||||
global: { plugins: [i18n] },
|
||||
attachTo: document.createElement('div')
|
||||
})
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
await wrapper.find('[data-testid="range-btn-allTime"]').trigger('click')
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
|
||||
const chart = lastInstance()!
|
||||
const datasets = (chart.data as { datasets: { data: number[] }[] }).datasets
|
||||
// 14 / 30 per bucket → 1 bucket with all 14 summed
|
||||
expect(datasets[0].data).toEqual([14])
|
||||
})
|
||||
})
|
||||
209
src/components/developerProfile/DownloadHistoryChart.vue
Normal file
209
src/components/developerProfile/DownloadHistoryChart.vue
Normal file
@@ -0,0 +1,209 @@
|
||||
<template>
|
||||
<div
|
||||
class="rounded-lg bg-secondary-background p-4"
|
||||
data-testid="download-history-chart"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h3 class="m-0 text-sm font-semibold">
|
||||
{{ t('developerProfile.downloadHistory') }}
|
||||
</h3>
|
||||
<div
|
||||
class="flex gap-1 rounded-md bg-modal-panel-background p-0.5"
|
||||
data-testid="range-buttons"
|
||||
>
|
||||
<button
|
||||
v-for="range in RANGES"
|
||||
:key="range"
|
||||
:class="
|
||||
cn(
|
||||
'cursor-pointer rounded border-none px-2 py-1 text-xs transition-colors',
|
||||
selectedRange === range
|
||||
? 'bg-secondary-background font-semibold text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)
|
||||
"
|
||||
:data-testid="`range-btn-${range}`"
|
||||
@click="selectedRange = range"
|
||||
>
|
||||
{{ t(`developerProfile.range.${range}`) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-62.5">
|
||||
<canvas ref="canvasRef" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
/** * Download history chart for the developer profile dashboard. * * Renders
|
||||
daily download counts using Chart.js, with a toggle group in the * upper-right
|
||||
corner that switches between four time ranges: * * - **Week** (7 bars) and
|
||||
**Month** (31 bars) render as bar charts. * - **Year** (weekly buckets) and
|
||||
**All Time** (monthly buckets) render as * filled area charts, with entries
|
||||
aggregated into summed buckets to keep * the point count manageable. * * @prop
|
||||
entries - Chronologically-ordered daily download history produced by * {@link
|
||||
fetchDownloadHistory}. */
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
BarController,
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
Chart,
|
||||
Filler,
|
||||
LineController,
|
||||
LineElement,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
Tooltip
|
||||
} from 'chart.js'
|
||||
import { onBeforeUnmount, ref, shallowRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type {
|
||||
DownloadHistoryEntry,
|
||||
DownloadHistoryRange
|
||||
} from '@/types/templateMarketplace'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
Chart.register(
|
||||
BarController,
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
Filler,
|
||||
LineController,
|
||||
LineElement,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
Tooltip
|
||||
)
|
||||
|
||||
const RANGES: DownloadHistoryRange[] = ['week', 'month', 'year', 'allTime']
|
||||
|
||||
const BAR_COLOR = '#185A8B'
|
||||
|
||||
const { entries } = defineProps<{
|
||||
entries: DownloadHistoryEntry[]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const selectedRange = ref<DownloadHistoryRange>('week')
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
const chartInstance = shallowRef<Chart | null>(null)
|
||||
|
||||
/**
|
||||
* Aggregates entries into buckets by summing downloads and using the last
|
||||
* date in each bucket for the label.
|
||||
*/
|
||||
function aggregate(
|
||||
source: DownloadHistoryEntry[],
|
||||
bucketSize: number
|
||||
): DownloadHistoryEntry[] {
|
||||
const result: DownloadHistoryEntry[] = []
|
||||
for (let i = 0; i < source.length; i += bucketSize) {
|
||||
const bucket = source.slice(i, i + bucketSize)
|
||||
const downloads = bucket.reduce((sum, e) => sum + e.downloads, 0)
|
||||
result.push({ date: bucket[bucket.length - 1].date, downloads })
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the tail slice of entries matching the selected range, downsampled
|
||||
* for larger views, along with formatted date labels.
|
||||
*/
|
||||
function sliceEntries(range: DownloadHistoryRange): {
|
||||
labels: string[]
|
||||
data: number[]
|
||||
} {
|
||||
const count =
|
||||
range === 'week' ? 7 : range === 'month' ? 31 : range === 'year' ? 365 : 0
|
||||
|
||||
const sliced = count > 0 ? entries.slice(-count) : entries
|
||||
const sampled =
|
||||
range === 'year'
|
||||
? aggregate(sliced, 7)
|
||||
: range === 'allTime'
|
||||
? aggregate(sliced, 30)
|
||||
: sliced
|
||||
|
||||
const labels = sampled.map((e) => {
|
||||
const d = e.date
|
||||
if (range === 'week')
|
||||
return d.toLocaleDateString(undefined, { weekday: 'short' })
|
||||
if (range === 'month') return String(d.getDate())
|
||||
if (range === 'year')
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||
return d.toLocaleDateString(undefined, { month: 'short', year: '2-digit' })
|
||||
})
|
||||
|
||||
return { labels, data: sampled.map((e) => e.downloads) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds or replaces the Chart.js instance on the canvas whenever range
|
||||
* or data changes.
|
||||
*/
|
||||
function renderChart() {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return
|
||||
|
||||
chartInstance.value?.destroy()
|
||||
|
||||
const range = selectedRange.value
|
||||
const isBar = range === 'week' || range === 'month'
|
||||
const { labels, data } = sliceEntries(range)
|
||||
|
||||
chartInstance.value = new Chart(canvas, {
|
||||
type: isBar ? 'bar' : 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
data,
|
||||
backgroundColor: isBar ? BAR_COLOR : `${BAR_COLOR}33`,
|
||||
borderColor: BAR_COLOR,
|
||||
borderWidth: isBar ? 0 : 2,
|
||||
borderRadius: isBar ? { topLeft: 4, topRight: 4 } : undefined,
|
||||
fill: !isBar,
|
||||
tension: 0.3,
|
||||
pointRadius: 0
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
transitions: { active: { animation: { duration: 0 } } },
|
||||
plugins: { legend: { display: false }, tooltip: { mode: 'index' } },
|
||||
scales: {
|
||||
x: {
|
||||
grid: { display: false },
|
||||
ticks: {
|
||||
color: '#9FA2BD',
|
||||
maxRotation: 0,
|
||||
autoSkip: true,
|
||||
maxTicksLimit: isBar ? undefined : 12
|
||||
}
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: { color: '#9FA2BD22' },
|
||||
ticks: { color: '#9FA2BD' }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
watch([selectedRange, () => entries], renderChart, { flush: 'post' })
|
||||
|
||||
watch(canvasRef, (el) => {
|
||||
if (el) renderChart()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
chartInstance.value?.destroy()
|
||||
})
|
||||
</script>
|
||||
63
src/components/developerProfile/ReviewCard.test.ts
Normal file
63
src/components/developerProfile/ReviewCard.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: vi.fn(() => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
}))
|
||||
|
||||
import type { TemplateReview } from '@/types/templateMarketplace'
|
||||
|
||||
import ReviewCard from './ReviewCard.vue'
|
||||
|
||||
function makeReview(overrides?: Partial<TemplateReview>): TemplateReview {
|
||||
return {
|
||||
id: 'rev-1',
|
||||
authorName: 'TestUser',
|
||||
authorAvatarUrl: undefined,
|
||||
rating: 4,
|
||||
text: 'Great template!',
|
||||
createdAt: new Date('2025-10-15'),
|
||||
templateId: 'tpl-1',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function mountCard(review: TemplateReview) {
|
||||
return mount(ReviewCard, {
|
||||
props: { review },
|
||||
global: {
|
||||
stubs: {
|
||||
StarRating: {
|
||||
template: '<span data-testid="star-rating" :data-rating="rating" />',
|
||||
props: ['rating', 'size']
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('ReviewCard', () => {
|
||||
it('renders the author name', () => {
|
||||
const wrapper = mountCard(makeReview({ authorName: 'PixelWizard' }))
|
||||
expect(wrapper.text()).toContain('PixelWizard')
|
||||
})
|
||||
|
||||
it('renders the review text', () => {
|
||||
const wrapper = mountCard(makeReview({ text: 'Awesome workflow!' }))
|
||||
expect(wrapper.text()).toContain('Awesome workflow!')
|
||||
})
|
||||
|
||||
it('passes the rating to StarRating', () => {
|
||||
const wrapper = mountCard(makeReview({ rating: 3.5 }))
|
||||
const starRating = wrapper.find('[data-testid="star-rating"]')
|
||||
expect(starRating.exists()).toBe(true)
|
||||
expect(starRating.attributes('data-rating')).toBe('3.5')
|
||||
})
|
||||
|
||||
it('renders a formatted date', () => {
|
||||
const wrapper = mountCard(makeReview({ createdAt: new Date('2025-10-15') }))
|
||||
expect(wrapper.text()).toContain('2025')
|
||||
})
|
||||
})
|
||||
38
src/components/developerProfile/ReviewCard.vue
Normal file
38
src/components/developerProfile/ReviewCard.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-2 rounded-lg bg-secondary-background p-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="flex size-8 shrink-0 items-center justify-center rounded-full bg-modal-panel-background"
|
||||
>
|
||||
<i class="icon-[lucide--user] size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<span class="text-sm font-medium">{{ review.authorName }}</span>
|
||||
<StarRating :rating="review.rating" size="sm" />
|
||||
<span class="ml-auto text-xs text-muted-foreground">
|
||||
{{ formattedDate }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="m-0 text-sm text-muted-foreground">{{ review.text }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { TemplateReview } from '@/types/templateMarketplace'
|
||||
|
||||
import StarRating from './StarRating.vue'
|
||||
|
||||
const { review } = defineProps<{
|
||||
/** The review to display. */
|
||||
review: TemplateReview
|
||||
}>()
|
||||
|
||||
const formattedDate = computed(() =>
|
||||
review.createdAt.toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})
|
||||
)
|
||||
</script>
|
||||
72
src/components/developerProfile/StarRating.test.ts
Normal file
72
src/components/developerProfile/StarRating.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: vi.fn(() => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
}))
|
||||
|
||||
import StarRating from './StarRating.vue'
|
||||
|
||||
function mountRating(rating: number, size?: 'sm' | 'md') {
|
||||
return mount(StarRating, {
|
||||
props: { rating, size }
|
||||
})
|
||||
}
|
||||
|
||||
describe('StarRating', () => {
|
||||
it('renders five star containers', () => {
|
||||
const wrapper = mountRating(3)
|
||||
const starContainers = wrapper.findAll('[role="img"] > div')
|
||||
expect(starContainers).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('fills all stars for a rating of 5', () => {
|
||||
const wrapper = mountRating(5)
|
||||
const fills = wrapper.findAll('[role="img"] > div > div')
|
||||
expect(fills).toHaveLength(5)
|
||||
for (const fill of fills) {
|
||||
expect(fill.attributes('style')).toContain('width: 100%')
|
||||
}
|
||||
})
|
||||
|
||||
it('fills no stars for a rating of 0', () => {
|
||||
const wrapper = mountRating(0)
|
||||
const fills = wrapper.findAll('[role="img"] > div > div')
|
||||
expect(fills).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('renders correct fills for a half-star rating of 3.5', () => {
|
||||
const wrapper = mountRating(3.5)
|
||||
const fills = wrapper.findAll('[role="img"] > div > div')
|
||||
expect(fills).toHaveLength(4)
|
||||
|
||||
expect(fills[0].attributes('style')).toContain('width: 100%')
|
||||
expect(fills[1].attributes('style')).toContain('width: 100%')
|
||||
expect(fills[2].attributes('style')).toContain('width: 100%')
|
||||
expect(fills[3].attributes('style')).toContain('width: 50%')
|
||||
})
|
||||
|
||||
it('renders correct fills for a half-star rating of 2.5', () => {
|
||||
const wrapper = mountRating(2.5)
|
||||
const fills = wrapper.findAll('[role="img"] > div > div')
|
||||
expect(fills).toHaveLength(3)
|
||||
|
||||
expect(fills[0].attributes('style')).toContain('width: 100%')
|
||||
expect(fills[1].attributes('style')).toContain('width: 100%')
|
||||
expect(fills[2].attributes('style')).toContain('width: 50%')
|
||||
})
|
||||
|
||||
it('uses smaller size class when size is sm', () => {
|
||||
const wrapper = mountRating(3, 'sm')
|
||||
const html = wrapper.html()
|
||||
expect(html).toContain('size-3.5')
|
||||
})
|
||||
|
||||
it('uses default size class when size is md', () => {
|
||||
const wrapper = mountRating(3, 'md')
|
||||
const html = wrapper.html()
|
||||
expect(html).toContain('size-4')
|
||||
})
|
||||
})
|
||||
56
src/components/developerProfile/StarRating.vue
Normal file
56
src/components/developerProfile/StarRating.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<div
|
||||
class="inline-flex items-center gap-0.5"
|
||||
role="img"
|
||||
:aria-label="ariaLabel"
|
||||
>
|
||||
<div v-for="i in 5" :key="i" class="relative" :class="starSizeClass">
|
||||
<i
|
||||
:class="
|
||||
cn('icon-[lucide--star]', starSizeClass, 'text-muted-foreground')
|
||||
"
|
||||
/>
|
||||
<div
|
||||
v-if="fillWidth(i) > 0"
|
||||
class="absolute inset-0 overflow-hidden"
|
||||
:style="{ width: `${fillWidth(i)}%` }"
|
||||
>
|
||||
<i
|
||||
:class="cn('icon-[lucide--star]', starSizeClass, 'text-amber-400')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { rating, size = 'md' } = defineProps<{
|
||||
/** Star rating value from 0 to 5, supporting 0.5 increments. */
|
||||
rating: number
|
||||
/** Visual size variant. */
|
||||
size?: 'sm' | 'md'
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const starSizeClass = computed(() => (size === 'sm' ? 'size-3.5' : 'size-4'))
|
||||
|
||||
const ariaLabel = computed(
|
||||
() => t('developerProfile.rating') + ': ' + String(rating) + '/5'
|
||||
)
|
||||
|
||||
/**
|
||||
* Returns the fill percentage (0, 50, or 100) for the star at position `i`.
|
||||
* @param i - 1-indexed star position.
|
||||
*/
|
||||
function fillWidth(i: number): number {
|
||||
if (rating >= i) return 100
|
||||
if (rating >= i - 0.5) return 50
|
||||
return 0
|
||||
}
|
||||
</script>
|
||||
154
src/components/developerProfile/TemplateListItem.test.ts
Normal file
154
src/components/developerProfile/TemplateListItem.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: vi.fn(() => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
}))
|
||||
|
||||
import type {
|
||||
MarketplaceTemplate,
|
||||
TemplateRevenue
|
||||
} from '@/types/templateMarketplace'
|
||||
|
||||
import TemplateListItem from './TemplateListItem.vue'
|
||||
|
||||
function makeTemplate(
|
||||
overrides?: Partial<MarketplaceTemplate>
|
||||
): MarketplaceTemplate {
|
||||
return {
|
||||
id: 'tpl-1',
|
||||
title: 'Test Template',
|
||||
description: 'Full description',
|
||||
shortDescription: 'Short desc',
|
||||
author: {
|
||||
id: 'usr-1',
|
||||
name: 'Author',
|
||||
isVerified: true,
|
||||
profileUrl: '/author'
|
||||
},
|
||||
categories: [],
|
||||
tags: [],
|
||||
difficulty: 'beginner',
|
||||
requiredModels: [],
|
||||
requiredNodes: [],
|
||||
requiresCustomNodes: [],
|
||||
vramRequirement: 0,
|
||||
thumbnail: '',
|
||||
gallery: [],
|
||||
workflowPreview: '',
|
||||
license: 'mit',
|
||||
version: '1.0.0',
|
||||
status: 'approved',
|
||||
updatedAt: new Date(),
|
||||
stats: {
|
||||
downloads: 1000,
|
||||
favorites: 50,
|
||||
rating: 4.5,
|
||||
reviewCount: 10,
|
||||
weeklyTrend: 2
|
||||
},
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
const stubRevenue: TemplateRevenue = {
|
||||
templateId: 'tpl-1',
|
||||
totalRevenue: 10_000,
|
||||
monthlyRevenue: 1_500,
|
||||
currency: 'USD'
|
||||
}
|
||||
|
||||
interface MountOptions {
|
||||
template?: MarketplaceTemplate
|
||||
revenue?: TemplateRevenue
|
||||
showRevenue?: boolean
|
||||
isCurrentUser?: boolean
|
||||
}
|
||||
|
||||
function mountItem(options: MountOptions = {}) {
|
||||
return mount(TemplateListItem, {
|
||||
props: {
|
||||
template: options.template ?? makeTemplate(),
|
||||
revenue: options.revenue,
|
||||
showRevenue: options.showRevenue ?? false,
|
||||
isCurrentUser: options.isCurrentUser ?? false
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
StarRating: {
|
||||
template: '<span data-testid="star-rating" />',
|
||||
props: ['rating', 'size']
|
||||
},
|
||||
Button: {
|
||||
template: '<button data-testid="unpublish-button"><slot /></button>',
|
||||
props: ['variant', 'size']
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('TemplateListItem', () => {
|
||||
it('renders the template title and description', () => {
|
||||
const wrapper = mountItem({
|
||||
template: makeTemplate({
|
||||
title: 'My Workflow',
|
||||
shortDescription: 'A cool workflow'
|
||||
})
|
||||
})
|
||||
expect(wrapper.text()).toContain('My Workflow')
|
||||
expect(wrapper.text()).toContain('A cool workflow')
|
||||
})
|
||||
|
||||
it('renders download and favorite stats', () => {
|
||||
const wrapper = mountItem({
|
||||
template: makeTemplate({
|
||||
stats: {
|
||||
downloads: 5_000,
|
||||
favorites: 200,
|
||||
rating: 4,
|
||||
reviewCount: 15,
|
||||
weeklyTrend: 1
|
||||
}
|
||||
})
|
||||
})
|
||||
expect(wrapper.text()).toContain('5,000')
|
||||
expect(wrapper.text()).toContain('200')
|
||||
})
|
||||
|
||||
it('hides revenue column when showRevenue is false', () => {
|
||||
const wrapper = mountItem({
|
||||
revenue: stubRevenue,
|
||||
showRevenue: false
|
||||
})
|
||||
expect(wrapper.find('[data-testid="revenue-column"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows revenue column when showRevenue is true', () => {
|
||||
const wrapper = mountItem({
|
||||
revenue: stubRevenue,
|
||||
showRevenue: true
|
||||
})
|
||||
expect(wrapper.find('[data-testid="revenue-column"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('hides unpublish button when isCurrentUser is false', () => {
|
||||
const wrapper = mountItem({ isCurrentUser: false })
|
||||
expect(wrapper.find('[data-testid="unpublish-button"]').exists()).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('shows unpublish button when isCurrentUser is true', () => {
|
||||
const wrapper = mountItem({ isCurrentUser: true })
|
||||
expect(wrapper.find('[data-testid="unpublish-button"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('emits unpublish event with template ID when button is clicked', async () => {
|
||||
const wrapper = mountItem({ isCurrentUser: true })
|
||||
await wrapper.find('[data-testid="unpublish-button"]').trigger('click')
|
||||
expect(wrapper.emitted('unpublish')).toEqual([['tpl-1']])
|
||||
})
|
||||
})
|
||||
114
src/components/developerProfile/TemplateListItem.vue
Normal file
114
src/components/developerProfile/TemplateListItem.vue
Normal file
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center gap-4 rounded-lg bg-secondary-background p-3"
|
||||
data-testid="template-list-item"
|
||||
>
|
||||
<div
|
||||
class="size-12 shrink-0 overflow-hidden rounded bg-modal-panel-background"
|
||||
>
|
||||
<img
|
||||
v-if="template.thumbnail"
|
||||
:src="template.thumbnail"
|
||||
:alt="template.title"
|
||||
class="size-full object-cover"
|
||||
/>
|
||||
<div v-else class="flex size-full items-center justify-center">
|
||||
<i class="icon-[lucide--image] size-5 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<h4 class="m-0 truncate text-sm font-medium">{{ template.title }}</h4>
|
||||
<p class="m-0 truncate text-xs text-muted-foreground">
|
||||
{{ template.shortDescription }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-4 text-xs text-muted-foreground">
|
||||
<span
|
||||
class="flex items-center gap-1"
|
||||
:title="t('developerProfile.downloads')"
|
||||
>
|
||||
<i class="icon-[lucide--download] size-3.5" />
|
||||
{{ template.stats.downloads.toLocaleString() }}
|
||||
</span>
|
||||
<span
|
||||
class="flex items-center gap-1"
|
||||
:title="t('developerProfile.favorites')"
|
||||
>
|
||||
<i class="icon-[lucide--heart] size-3.5" />
|
||||
{{ template.stats.favorites.toLocaleString() }}
|
||||
</span>
|
||||
<StarRating :rating="template.stats.rating" size="sm" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showRevenue && revenue"
|
||||
class="shrink-0 text-right text-xs"
|
||||
data-testid="revenue-column"
|
||||
>
|
||||
<div class="font-medium">{{ formatCurrency(revenue.totalRevenue) }}</div>
|
||||
<div class="text-muted-foreground">
|
||||
{{ formatCurrency(revenue.monthlyRevenue) }}/{{
|
||||
t('developerProfile.monthlyRevenue').toLowerCase()
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
v-if="isCurrentUser"
|
||||
variant="destructive-textonly"
|
||||
size="sm"
|
||||
data-testid="unpublish-button"
|
||||
@click="emit('unpublish', template.id)"
|
||||
>
|
||||
{{ t('developerProfile.unpublish') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type {
|
||||
MarketplaceTemplate,
|
||||
TemplateRevenue
|
||||
} from '@/types/templateMarketplace'
|
||||
|
||||
import StarRating from './StarRating.vue'
|
||||
|
||||
const {
|
||||
template,
|
||||
revenue,
|
||||
showRevenue = false,
|
||||
isCurrentUser = false
|
||||
} = defineProps<{
|
||||
/** The template to display. */
|
||||
template: MarketplaceTemplate
|
||||
/** Revenue data for this template, shown when showRevenue is true. */
|
||||
revenue?: TemplateRevenue
|
||||
/** Whether to display the revenue column. */
|
||||
showRevenue?: boolean
|
||||
/** Whether the profile being viewed belongs to the current user. */
|
||||
isCurrentUser?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** Emitted when the unpublish button is clicked. */
|
||||
unpublish: [templateId: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
/**
|
||||
* Formats a value in cents as a currency string.
|
||||
* @param cents - Amount in cents.
|
||||
*/
|
||||
function formatCurrency(cents: number): string {
|
||||
return (cents / 100).toLocaleString(undefined, {
|
||||
style: 'currency',
|
||||
currency: 'USD'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
31
src/components/graph/VramEstimateIndicator.vue
Normal file
31
src/components/graph/VramEstimateIndicator.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<!--
|
||||
Floating indicator that displays the estimated VRAM requirement
|
||||
for the currently loaded workflow graph.
|
||||
-->
|
||||
<template>
|
||||
<div
|
||||
v-if="vramEstimate > 0"
|
||||
class="pointer-events-auto absolute bottom-3 right-3 z-10 inline-flex items-center gap-1.5 rounded-lg bg-zinc-500/40 px-2.5 py-1.5 text-xs font-medium text-white/90 backdrop-blur-sm"
|
||||
:title="t('templateWorkflows.vramEstimateTooltip')"
|
||||
>
|
||||
<i class="icon-[lucide--cpu] h-3.5 w-3.5" />
|
||||
{{ formatSize(vramEstimate) }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { formatSize } from '@/utils/formatUtil'
|
||||
import { ref, watchEffect } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { estimateWorkflowVram } from '@/composables/useVramEstimation'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const vramEstimate = ref(0)
|
||||
|
||||
watchEffect(() => {
|
||||
vramEstimate.value = estimateWorkflowVram(app.rootGraph)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,109 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { CachedAsset } from '@/types/templateMarketplace'
|
||||
|
||||
import TemplateAssetUploadZone from './TemplateAssetUploadZone.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
templatePublishing: {
|
||||
steps: {
|
||||
previewGeneration: {
|
||||
uploadPrompt: 'Click to upload',
|
||||
removeFile: 'Remove'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function makeAsset(name: string): CachedAsset {
|
||||
return {
|
||||
file: new File(['data'], name, { type: 'image/png' }),
|
||||
objectUrl: `blob:http://localhost/${name}`,
|
||||
originalName: name
|
||||
}
|
||||
}
|
||||
|
||||
function mountZone(props: Record<string, unknown> = {}) {
|
||||
return mount(TemplateAssetUploadZone, {
|
||||
props,
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
}
|
||||
|
||||
describe('TemplateAssetUploadZone', () => {
|
||||
it('shows the upload prompt when no asset is provided', () => {
|
||||
const wrapper = mountZone()
|
||||
|
||||
expect(wrapper.text()).toContain('Click to upload')
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows an image preview when an asset is provided', () => {
|
||||
const asset = makeAsset('photo.png')
|
||||
const wrapper = mountZone({ asset })
|
||||
|
||||
const img = wrapper.find('img')
|
||||
expect(img.exists()).toBe(true)
|
||||
expect(img.attributes('src')).toBe(asset.objectUrl)
|
||||
expect(wrapper.text()).toContain('photo.png')
|
||||
})
|
||||
|
||||
it('shows a video element when previewType is video', () => {
|
||||
const asset = makeAsset('demo.mp4')
|
||||
const wrapper = mountZone({ asset, previewType: 'video' })
|
||||
|
||||
expect(wrapper.find('video').exists()).toBe(true)
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('emits upload with the selected file', async () => {
|
||||
const wrapper = mountZone()
|
||||
const input = wrapper.find('input[type="file"]')
|
||||
|
||||
const file = new File(['bytes'], 'test.png', { type: 'image/png' })
|
||||
Object.defineProperty(input.element, 'files', { value: [file] })
|
||||
await input.trigger('change')
|
||||
|
||||
expect(wrapper.emitted('upload')).toHaveLength(1)
|
||||
expect(wrapper.emitted('upload')![0]).toEqual([file])
|
||||
})
|
||||
|
||||
it('emits remove when the remove button is clicked', async () => {
|
||||
const wrapper = mountZone({ asset: makeAsset('photo.png') })
|
||||
const removeBtn = wrapper.find('button[aria-label="Remove"]')
|
||||
|
||||
await removeBtn.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('remove')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('applies the provided sizeClass to the upload zone', () => {
|
||||
const wrapper = mountZone({ sizeClass: 'h-40 w-64' })
|
||||
const zone = wrapper.find('[role="button"]')
|
||||
|
||||
expect(zone.classes()).toContain('h-40')
|
||||
expect(zone.classes()).toContain('w-64')
|
||||
})
|
||||
|
||||
it('uses image/* accept filter by default', () => {
|
||||
const wrapper = mountZone()
|
||||
const input = wrapper.find('input[type="file"]')
|
||||
|
||||
expect(input.attributes('accept')).toBe('image/*')
|
||||
})
|
||||
|
||||
it('applies a custom accept filter', () => {
|
||||
const wrapper = mountZone({ accept: 'video/*' })
|
||||
const input = wrapper.find('input[type="file"]')
|
||||
|
||||
expect(input.attributes('accept')).toBe('video/*')
|
||||
})
|
||||
})
|
||||
109
src/components/templatePublishing/TemplateAssetUploadZone.vue
Normal file
109
src/components/templatePublishing/TemplateAssetUploadZone.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<!--
|
||||
Reusable upload zone for a single file asset. Shows a dashed click-to-upload
|
||||
area when empty, and a preview with filename overlay when populated.
|
||||
-->
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
v-if="!asset"
|
||||
:class="
|
||||
cn(
|
||||
'flex cursor-pointer items-center justify-center rounded-lg border-2 border-dashed border-border-default hover:border-muted-foreground',
|
||||
sizeClass
|
||||
)
|
||||
"
|
||||
role="button"
|
||||
:tabindex="0"
|
||||
:aria-label="t('templatePublishing.steps.previewGeneration.uploadPrompt')"
|
||||
@click="fileInput?.click()"
|
||||
@keydown.enter="fileInput?.click()"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-1 text-muted-foreground">
|
||||
<i class="icon-[lucide--upload] h-5 w-5" />
|
||||
<span class="text-xs">
|
||||
{{ t('templatePublishing.steps.previewGeneration.uploadPrompt') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
:class="cn('group relative overflow-hidden rounded-lg', sizeClass)"
|
||||
>
|
||||
<img
|
||||
v-if="previewType === 'image'"
|
||||
:src="asset.objectUrl"
|
||||
:alt="asset.originalName"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
<video
|
||||
v-else
|
||||
:src="asset.objectUrl"
|
||||
controls
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-x-0 bottom-0 flex items-center justify-between bg-black/60 px-2 py-1 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
>
|
||||
<span class="truncate text-xs text-white">
|
||||
{{ asset.originalName }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 text-white hover:text-danger"
|
||||
:aria-label="
|
||||
t('templatePublishing.steps.previewGeneration.removeFile')
|
||||
"
|
||||
@click="emit('remove')"
|
||||
>
|
||||
<i class="icon-[lucide--x] h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
:accept="accept"
|
||||
class="hidden"
|
||||
@change="onFileSelect"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { CachedAsset } from '@/types/templateMarketplace'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
asset = null,
|
||||
accept = 'image/*',
|
||||
previewType = 'image',
|
||||
sizeClass = 'h-32 w-48'
|
||||
} = defineProps<{
|
||||
asset?: CachedAsset | null
|
||||
accept?: string
|
||||
previewType?: 'image' | 'video'
|
||||
sizeClass?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
upload: [file: File]
|
||||
remove: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
function onFileSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (file) {
|
||||
emit('upload', file)
|
||||
input.value = ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,182 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workflow/templates/composables/useTemplatePublishStorage',
|
||||
() => ({
|
||||
loadTemplateUnderway: vi.fn(() => null),
|
||||
saveTemplateUnderway: vi.fn()
|
||||
})
|
||||
)
|
||||
|
||||
import TemplatePublishingDialog from './TemplatePublishingDialog.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
templatePublishing: {
|
||||
dialogTitle: 'Template Publishing',
|
||||
next: 'Next',
|
||||
previous: 'Previous',
|
||||
saveDraft: 'Save Draft',
|
||||
stepProgress: 'Step {current} of {total}',
|
||||
steps: {
|
||||
landing: {
|
||||
title: 'Getting Started',
|
||||
description: 'Overview of the publishing process'
|
||||
},
|
||||
metadata: {
|
||||
title: 'Metadata',
|
||||
description: 'Title, description, and author info'
|
||||
},
|
||||
description: {
|
||||
title: 'Description',
|
||||
description: 'Write a detailed description of your template'
|
||||
},
|
||||
previewGeneration: {
|
||||
title: 'Preview',
|
||||
description: 'Generate preview images and videos'
|
||||
},
|
||||
categoryAndTagging: {
|
||||
title: 'Categories & Tags',
|
||||
description: 'Categorize and tag your template'
|
||||
},
|
||||
preview: {
|
||||
title: 'Preview',
|
||||
description: 'Review your template before submitting'
|
||||
},
|
||||
submissionForReview: {
|
||||
title: 'Submit',
|
||||
description: 'Submit your template for review'
|
||||
},
|
||||
complete: {
|
||||
title: 'Complete',
|
||||
description: 'Your template has been submitted'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function mountDialog(props?: { initialPage?: string }) {
|
||||
return mount(TemplatePublishingDialog, {
|
||||
props: {
|
||||
onClose: vi.fn(),
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
BaseModalLayout: {
|
||||
template: `
|
||||
<div data-testid="modal">
|
||||
<div data-testid="left-panel"><slot name="leftPanel" /></div>
|
||||
<div data-testid="header"><slot name="header" /></div>
|
||||
<div data-testid="header-right"><slot name="header-right-area" /></div>
|
||||
<div data-testid="content"><slot name="content" /></div>
|
||||
</div>
|
||||
`
|
||||
},
|
||||
TemplatePublishingStepperNav: {
|
||||
template: '<div data-testid="stepper-nav" />',
|
||||
props: ['currentStep', 'stepDefinitions']
|
||||
},
|
||||
StepTemplatePublishingLanding: {
|
||||
template: '<div data-testid="step-landing" />'
|
||||
},
|
||||
StepTemplatePublishingMetadata: {
|
||||
template: '<div data-testid="step-metadata" />'
|
||||
},
|
||||
StepTemplatePublishingDescription: {
|
||||
template: '<div data-testid="step-description" />'
|
||||
},
|
||||
StepTemplatePublishingPreviewGeneration: {
|
||||
template: '<div data-testid="step-preview-generation" />'
|
||||
},
|
||||
StepTemplatePublishingCategoryAndTagging: {
|
||||
template: '<div data-testid="step-category" />'
|
||||
},
|
||||
StepTemplatePublishingPreview: {
|
||||
template: '<div data-testid="step-preview" />'
|
||||
},
|
||||
StepTemplatePublishingSubmissionForReview: {
|
||||
template: '<div data-testid="step-submission" />'
|
||||
},
|
||||
StepTemplatePublishingComplete: {
|
||||
template: '<div data-testid="step-complete" />'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('TemplatePublishingDialog', () => {
|
||||
it('renders the dialog with the first step by default', () => {
|
||||
const wrapper = mountDialog()
|
||||
expect(wrapper.find('[data-testid="modal"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="step-landing"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders the stepper nav in the left panel', () => {
|
||||
const wrapper = mountDialog()
|
||||
const leftPanel = wrapper.find('[data-testid="left-panel"]')
|
||||
expect(leftPanel.find('[data-testid="stepper-nav"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('maps initialPage to the correct starting step', () => {
|
||||
const wrapper = mountDialog({ initialPage: 'metadata' })
|
||||
expect(wrapper.find('[data-testid="step-metadata"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('defaults to step 1 for unknown initialPage', () => {
|
||||
const wrapper = mountDialog({ initialPage: 'nonexistent' })
|
||||
expect(wrapper.find('[data-testid="step-landing"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows Previous button when not on first step', () => {
|
||||
const wrapper = mountDialog({ initialPage: 'metadata' })
|
||||
const headerRight = wrapper.find('[data-testid="header-right"]')
|
||||
|
||||
const buttons = headerRight.findAll('button')
|
||||
const buttonTexts = buttons.map((b) => b.text())
|
||||
expect(buttonTexts.some((text) => text.includes('Previous'))).toBe(true)
|
||||
})
|
||||
|
||||
it('disables Previous button on first step', () => {
|
||||
const wrapper = mountDialog()
|
||||
const headerRight = wrapper.find('[data-testid="header-right"]')
|
||||
|
||||
const prevButton = headerRight
|
||||
.findAll('button')
|
||||
.find((b) => b.text().includes('Previous'))
|
||||
expect(prevButton?.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('disables Next button on last step', () => {
|
||||
const wrapper = mountDialog({
|
||||
initialPage: 'complete'
|
||||
})
|
||||
const headerRight = wrapper.find('[data-testid="header-right"]')
|
||||
|
||||
const nextButton = headerRight
|
||||
.findAll('button')
|
||||
.find((b) => b.text().includes('Next'))
|
||||
expect(nextButton?.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('disables Next button on submit step', () => {
|
||||
const wrapper = mountDialog({
|
||||
initialPage: 'submissionForReview'
|
||||
})
|
||||
const headerRight = wrapper.find('[data-testid="header-right"]')
|
||||
|
||||
const nextButton = headerRight
|
||||
.findAll('button')
|
||||
.find((b) => b.text().includes('Next'))
|
||||
expect(nextButton?.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
})
|
||||
152
src/components/templatePublishing/TemplatePublishingDialog.vue
Normal file
152
src/components/templatePublishing/TemplatePublishingDialog.vue
Normal file
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<BaseModalLayout
|
||||
:content-title="t('templatePublishing.dialogTitle')"
|
||||
size="md"
|
||||
>
|
||||
<template #leftPanelHeaderTitle>
|
||||
<i class="icon-[lucide--upload]" />
|
||||
<h2 class="text-neutral text-base">
|
||||
{{ t('templatePublishing.dialogTitle') }}
|
||||
</h2>
|
||||
</template>
|
||||
|
||||
<template #leftPanel>
|
||||
<TemplatePublishingStepperNav
|
||||
:current-step="currentStep"
|
||||
:step-definitions="stepDefinitions"
|
||||
@update:current-step="goToStep"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-muted-foreground text-sm">
|
||||
{{
|
||||
t('templatePublishing.stepProgress', {
|
||||
current: currentStep,
|
||||
total: totalSteps
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #header-right-area>
|
||||
<div class="mr-6 flex gap-2">
|
||||
<Button
|
||||
:disabled="isFirstStep"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
@click="prevStep"
|
||||
>
|
||||
<i class="icon-[lucide--arrow-left]" />
|
||||
{{ t('templatePublishing.previous') }}
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="
|
||||
currentStep >= totalSteps - 1 ||
|
||||
currentStep === STEP_PAGE_MAP.preview
|
||||
"
|
||||
size="lg"
|
||||
@click="nextStep"
|
||||
>
|
||||
{{ t('templatePublishing.next') }}
|
||||
<i class="icon-[lucide--arrow-right]" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<component :is="activeStepComponent" />
|
||||
</template>
|
||||
</BaseModalLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
import { computed, provide } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { OnCloseKey } from '@/types/widgetTypes'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
|
||||
import { useTemplatePublishingStepper } from '@/composables/useTemplatePublishingStepper'
|
||||
|
||||
import TemplatePublishingStepperNav from './TemplatePublishingStepperNav.vue'
|
||||
import StepTemplatePublishingCategoryAndTagging from './steps/StepTemplatePublishingCategoryAndTagging.vue'
|
||||
import StepTemplatePublishingComplete from './steps/StepTemplatePublishingComplete.vue'
|
||||
import StepTemplatePublishingDescription from './steps/StepTemplatePublishingDescription.vue'
|
||||
import StepTemplatePublishingLanding from './steps/StepTemplatePublishingLanding.vue'
|
||||
import StepTemplatePublishingMetadata from './steps/StepTemplatePublishingMetadata.vue'
|
||||
import StepTemplatePublishingPreview from './steps/StepTemplatePublishingPreview.vue'
|
||||
import StepTemplatePublishingPreviewGeneration from './steps/StepTemplatePublishingPreviewGeneration.vue'
|
||||
import StepTemplatePublishingSubmissionForReview from './steps/StepTemplatePublishingSubmissionForReview.vue'
|
||||
import { PublishingStepperKey } from './types'
|
||||
|
||||
const { onClose, initialPage } = defineProps<{
|
||||
onClose: () => void
|
||||
initialPage?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
provide(OnCloseKey, onClose)
|
||||
|
||||
const STEP_PAGE_MAP: Record<string, number> = {
|
||||
publishingLanding: 1,
|
||||
metadata: 2,
|
||||
description: 3,
|
||||
previewGeneration: 4,
|
||||
categoryAndTagging: 5,
|
||||
preview: 6,
|
||||
submissionForReview: 7,
|
||||
complete: 8
|
||||
}
|
||||
|
||||
const initialStep = initialPage ? (STEP_PAGE_MAP[initialPage] ?? 1) : 1
|
||||
|
||||
const {
|
||||
currentStep,
|
||||
totalSteps,
|
||||
template,
|
||||
stepDefinitions,
|
||||
isFirstStep,
|
||||
isLastStep,
|
||||
canProceed,
|
||||
nextStep,
|
||||
prevStep,
|
||||
goToStep,
|
||||
saveDraft,
|
||||
setStepValid
|
||||
} = useTemplatePublishingStepper({ initialStep })
|
||||
|
||||
const STEP_COMPONENTS: Component[] = [
|
||||
StepTemplatePublishingLanding,
|
||||
StepTemplatePublishingMetadata,
|
||||
StepTemplatePublishingDescription,
|
||||
StepTemplatePublishingPreviewGeneration,
|
||||
StepTemplatePublishingCategoryAndTagging,
|
||||
StepTemplatePublishingPreview,
|
||||
StepTemplatePublishingSubmissionForReview,
|
||||
StepTemplatePublishingComplete
|
||||
]
|
||||
|
||||
const activeStepComponent = computed(
|
||||
() => STEP_COMPONENTS[currentStep.value - 1]
|
||||
)
|
||||
|
||||
provide(PublishingStepperKey, {
|
||||
currentStep,
|
||||
totalSteps,
|
||||
isFirstStep,
|
||||
isLastStep,
|
||||
canProceed,
|
||||
template,
|
||||
nextStep,
|
||||
prevStep,
|
||||
goToStep,
|
||||
saveDraft,
|
||||
setStepValid
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,103 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { PublishingStepDefinition } from './types'
|
||||
|
||||
import TemplatePublishingStepperNav from './TemplatePublishingStepperNav.vue'
|
||||
|
||||
const STEP_DEFINITIONS: PublishingStepDefinition[] = [
|
||||
{
|
||||
number: 1,
|
||||
titleKey: 'steps.landing.title',
|
||||
descriptionKey: 'steps.landing.description'
|
||||
},
|
||||
{
|
||||
number: 2,
|
||||
titleKey: 'steps.metadata.title',
|
||||
descriptionKey: 'steps.metadata.description'
|
||||
},
|
||||
{
|
||||
number: 3,
|
||||
titleKey: 'steps.preview.title',
|
||||
descriptionKey: 'steps.preview.description'
|
||||
}
|
||||
]
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
steps: {
|
||||
landing: { title: 'Getting Started', description: '' },
|
||||
metadata: { title: 'Metadata', description: '' },
|
||||
preview: { title: 'Preview', description: '' }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function mountNav(props?: { currentStep?: number }) {
|
||||
return mount(TemplatePublishingStepperNav, {
|
||||
props: {
|
||||
currentStep: props?.currentStep ?? 1,
|
||||
stepDefinitions: STEP_DEFINITIONS
|
||||
},
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
}
|
||||
|
||||
describe('TemplatePublishingStepperNav', () => {
|
||||
it('renders a button for each step definition', () => {
|
||||
const wrapper = mountNav()
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons).toHaveLength(STEP_DEFINITIONS.length)
|
||||
})
|
||||
|
||||
it('displays translated step titles', () => {
|
||||
const wrapper = mountNav()
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons[0].text()).toContain('Getting Started')
|
||||
expect(buttons[1].text()).toContain('Metadata')
|
||||
expect(buttons[2].text()).toContain('Preview')
|
||||
})
|
||||
|
||||
it('marks the current step button as aria-selected', () => {
|
||||
const wrapper = mountNav({ currentStep: 2 })
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons[0].attributes('aria-selected')).toBe('false')
|
||||
expect(buttons[1].attributes('aria-selected')).toBe('true')
|
||||
expect(buttons[2].attributes('aria-selected')).toBe('false')
|
||||
})
|
||||
|
||||
it('shows a check icon for completed steps', () => {
|
||||
const wrapper = mountNav({ currentStep: 3 })
|
||||
const buttons = wrapper.findAll('button')
|
||||
|
||||
expect(buttons[0].find('i.icon-\\[lucide--check\\]').exists()).toBe(true)
|
||||
expect(buttons[1].find('i.icon-\\[lucide--check\\]').exists()).toBe(true)
|
||||
expect(buttons[2].find('i.icon-\\[lucide--check\\]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows step numbers for current and future steps', () => {
|
||||
const wrapper = mountNav({ currentStep: 2 })
|
||||
const buttons = wrapper.findAll('button')
|
||||
|
||||
expect(buttons[0].find('i.icon-\\[lucide--check\\]').exists()).toBe(true)
|
||||
expect(buttons[1].text()).toContain('2')
|
||||
expect(buttons[2].text()).toContain('3')
|
||||
})
|
||||
|
||||
it('emits update:currentStep when a step button is clicked', async () => {
|
||||
const wrapper = mountNav({ currentStep: 1 })
|
||||
await wrapper.findAll('button')[1].trigger('click')
|
||||
expect(wrapper.emitted('update:currentStep')).toEqual([[2]])
|
||||
})
|
||||
|
||||
it('renders separators between steps', () => {
|
||||
const wrapper = mountNav()
|
||||
const separators = wrapper.findAll('div.bg-border-default')
|
||||
expect(separators).toHaveLength(STEP_DEFINITIONS.length - 1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<nav
|
||||
class="flex flex-col gap-1 px-4 py-2"
|
||||
role="tablist"
|
||||
aria-orientation="vertical"
|
||||
>
|
||||
<template v-for="(step, index) in stepDefinitions" :key="step.number">
|
||||
<button
|
||||
role="tab"
|
||||
:aria-selected="step.number === currentStep"
|
||||
:class="
|
||||
cn(
|
||||
'flex w-full items-center gap-3 rounded-md px-3 py-2 text-left text-sm',
|
||||
'focus-visible:ring-ring focus-visible:outline-none focus-visible:ring-2',
|
||||
step.number === currentStep &&
|
||||
step.number === stepDefinitions.length &&
|
||||
'bg-blue-900 font-medium text-neutral',
|
||||
step.number === currentStep &&
|
||||
step.number < stepDefinitions.length &&
|
||||
'font-medium text-neutral',
|
||||
step.number < currentStep && 'bg-green-900 text-muted-foreground',
|
||||
step.number > currentStep && 'text-muted-foreground opacity-50'
|
||||
)
|
||||
"
|
||||
:disabled="step.number === stepDefinitions.length"
|
||||
@click="emit('update:currentStep', step.number)"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs',
|
||||
step.number === currentStep &&
|
||||
'bg-comfy-accent text-comfy-accent-foreground',
|
||||
step.number < currentStep && 'bg-comfy-accent/20 text-neutral',
|
||||
step.number > currentStep &&
|
||||
'bg-secondary-background text-muted-foreground'
|
||||
)
|
||||
"
|
||||
>
|
||||
<i
|
||||
v-if="step.number < currentStep"
|
||||
class="icon-[lucide--check] h-3.5 w-3.5"
|
||||
/>
|
||||
<span v-else>{{ step.number }}</span>
|
||||
</span>
|
||||
<span class="leading-tight">
|
||||
{{ t(step.titleKey)
|
||||
}}<template
|
||||
v-if="
|
||||
step.number === currentStep &&
|
||||
step.number === stepDefinitions.length
|
||||
"
|
||||
>
|
||||
🎉</template
|
||||
>
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="index < stepDefinitions.length - 1"
|
||||
class="bg-border-default ml-5 h-4 w-px"
|
||||
/>
|
||||
</template>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { PublishingStepDefinition } from './types'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
defineProps<{
|
||||
currentStep: number
|
||||
stepDefinitions: PublishingStepDefinition[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:currentStep': [step: number]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
@@ -0,0 +1,189 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { computed, ref } from 'vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { MarketplaceTemplate } from '@/types/templateMarketplace'
|
||||
|
||||
import type { PublishingStepperContext } from '../types'
|
||||
import { PublishingStepperKey } from '../types'
|
||||
import StepTemplatePublishingCategoryAndTagging from './StepTemplatePublishingCategoryAndTagging.vue'
|
||||
|
||||
vi.mock('@vueuse/core', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>
|
||||
return {
|
||||
...actual,
|
||||
watchDebounced: vi.fn((source: unknown, cb: unknown, opts: unknown) => {
|
||||
const typedActual = actual as {
|
||||
watchDebounced: (...args: unknown[]) => unknown
|
||||
}
|
||||
return typedActual.watchDebounced(source, cb, {
|
||||
...(opts as object),
|
||||
debounce: 0
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
templatePublishing: {
|
||||
steps: {
|
||||
metadata: {
|
||||
categoryLabel: 'Categories',
|
||||
tagsLabel: 'Tags',
|
||||
tagsPlaceholder: 'Type to search tags…',
|
||||
category: {
|
||||
imageGeneration: 'Image Generation',
|
||||
videoGeneration: 'Video Generation',
|
||||
audio: 'Audio',
|
||||
text: 'Text',
|
||||
threeD: '3D',
|
||||
upscaling: 'Upscaling',
|
||||
inpainting: 'Inpainting',
|
||||
controlNet: 'ControlNet',
|
||||
styleTransfer: 'Style Transfer',
|
||||
other: 'Other'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function createContext(
|
||||
templateData: Partial<MarketplaceTemplate> = {}
|
||||
): PublishingStepperContext {
|
||||
const template = ref<Partial<MarketplaceTemplate>>(templateData)
|
||||
const currentStep = ref(5)
|
||||
return {
|
||||
currentStep,
|
||||
totalSteps: 8,
|
||||
isFirstStep: computed(() => currentStep.value === 1),
|
||||
isLastStep: computed(() => currentStep.value === 8),
|
||||
canProceed: computed(() => false),
|
||||
template,
|
||||
nextStep: vi.fn(),
|
||||
prevStep: vi.fn(),
|
||||
goToStep: vi.fn(),
|
||||
saveDraft: vi.fn(),
|
||||
setStepValid: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
function mountStep(ctx?: PublishingStepperContext) {
|
||||
const context = ctx ?? createContext()
|
||||
return {
|
||||
wrapper: mount(StepTemplatePublishingCategoryAndTagging, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
provide: { [PublishingStepperKey as symbol]: context }
|
||||
}
|
||||
}),
|
||||
ctx: context
|
||||
}
|
||||
}
|
||||
|
||||
describe('StepTemplatePublishingCategoryAndTagging', () => {
|
||||
it('renders category and tag labels', () => {
|
||||
const { wrapper } = mountStep()
|
||||
expect(wrapper.text()).toContain('Categories')
|
||||
expect(wrapper.text()).toContain('Tags')
|
||||
})
|
||||
|
||||
it('renders all category checkboxes', () => {
|
||||
const { wrapper } = mountStep()
|
||||
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||
expect(checkboxes).toHaveLength(10)
|
||||
expect(wrapper.text()).toContain('Image Generation')
|
||||
expect(wrapper.text()).toContain('ControlNet')
|
||||
})
|
||||
|
||||
it('toggles category when checkbox is clicked', async () => {
|
||||
const ctx = createContext({ categories: [] })
|
||||
const { wrapper } = mountStep(ctx)
|
||||
|
||||
const checkbox = wrapper.find('#tpl-category-audio')
|
||||
await checkbox.setValue(true)
|
||||
|
||||
expect(ctx.template.value.categories).toContain('audio')
|
||||
|
||||
await checkbox.setValue(false)
|
||||
expect(ctx.template.value.categories).not.toContain('audio')
|
||||
})
|
||||
|
||||
it('preserves existing categories when toggling', async () => {
|
||||
const ctx = createContext({ categories: ['text', '3d'] })
|
||||
const { wrapper } = mountStep(ctx)
|
||||
|
||||
const audioCheckbox = wrapper.find('#tpl-category-audio')
|
||||
await audioCheckbox.setValue(true)
|
||||
|
||||
expect(ctx.template.value.categories).toContain('text')
|
||||
expect(ctx.template.value.categories).toContain('3d')
|
||||
expect(ctx.template.value.categories).toContain('audio')
|
||||
})
|
||||
|
||||
it('adds a tag when pressing enter in the tags input', async () => {
|
||||
const ctx = createContext({ tags: [] })
|
||||
const { wrapper } = mountStep(ctx)
|
||||
|
||||
const tagInput = wrapper.find('input[type="text"]')
|
||||
await tagInput.setValue('my-tag')
|
||||
await tagInput.trigger('keydown.enter')
|
||||
|
||||
expect(ctx.template.value.tags).toContain('my-tag')
|
||||
})
|
||||
|
||||
it('does not add duplicate tags', async () => {
|
||||
const ctx = createContext({ tags: ['existing'] })
|
||||
const { wrapper } = mountStep(ctx)
|
||||
|
||||
const tagInput = wrapper.find('input[type="text"]')
|
||||
await tagInput.setValue('existing')
|
||||
await tagInput.trigger('keydown.enter')
|
||||
|
||||
expect(ctx.template.value.tags).toEqual(['existing'])
|
||||
})
|
||||
|
||||
it('removes a tag when the remove button is clicked', async () => {
|
||||
const ctx = createContext({ tags: ['alpha', 'beta'] })
|
||||
const { wrapper } = mountStep(ctx)
|
||||
|
||||
const removeButtons = wrapper.findAll('button[aria-label^="Remove tag"]')
|
||||
await removeButtons[0].trigger('click')
|
||||
|
||||
expect(ctx.template.value.tags).toEqual(['beta'])
|
||||
})
|
||||
|
||||
it('shows filtered suggestions when typing in tags input', async () => {
|
||||
const ctx = createContext({ tags: [] })
|
||||
const { wrapper } = mountStep(ctx)
|
||||
|
||||
const tagInput = wrapper.find('input[type="text"]')
|
||||
await tagInput.setValue('flux')
|
||||
await tagInput.trigger('focus')
|
||||
|
||||
const suggestions = wrapper.findAll('li')
|
||||
expect(suggestions.length).toBeGreaterThan(0)
|
||||
expect(suggestions[0].text()).toBe('flux')
|
||||
})
|
||||
|
||||
it('adds a suggestion tag when clicking it', async () => {
|
||||
const ctx = createContext({ tags: [] })
|
||||
const { wrapper } = mountStep(ctx)
|
||||
|
||||
const tagInput = wrapper.find('input[type="text"]')
|
||||
await tagInput.setValue('flux')
|
||||
await tagInput.trigger('focus')
|
||||
|
||||
const suggestion = wrapper.find('li')
|
||||
await suggestion.trigger('mousedown')
|
||||
|
||||
expect(ctx.template.value.tags).toContain('flux')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,179 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 p-6">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<div class="form-label flex w-28 shrink-0 items-center">
|
||||
<span id="tpl-category-label" class="text-muted">
|
||||
{{ t('templatePublishing.steps.metadata.categoryLabel') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-wrap gap-2"
|
||||
role="group"
|
||||
aria-labelledby="tpl-category-label"
|
||||
>
|
||||
<label
|
||||
v-for="cat in CATEGORIES"
|
||||
:key="cat.value"
|
||||
:for="`tpl-category-${cat.value}`"
|
||||
class="flex cursor-pointer items-center gap-1.5 text-sm"
|
||||
>
|
||||
<input
|
||||
:id="`tpl-category-${cat.value}`"
|
||||
type="checkbox"
|
||||
:checked="ctx.template.value.categories?.includes(cat.value)"
|
||||
@change="toggleCategory(cat.value)"
|
||||
/>
|
||||
{{ t(`templatePublishing.steps.metadata.category.${cat.key}`) }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<div class="form-label flex w-28 shrink-0 items-center">
|
||||
<span id="tpl-tags-label" class="text-muted">
|
||||
{{ t('templatePublishing.steps.metadata.tagsLabel') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div
|
||||
v-if="(ctx.template.value.tags ?? []).length > 0"
|
||||
class="flex max-h-20 flex-wrap gap-1 overflow-y-auto scrollbar-custom"
|
||||
>
|
||||
<span
|
||||
v-for="tag in ctx.template.value.tags ?? []"
|
||||
:key="tag"
|
||||
class="inline-flex items-center gap-1 rounded-full bg-comfy-input-background px-2 py-0.5 text-xs"
|
||||
>
|
||||
{{ tag }}
|
||||
<button
|
||||
type="button"
|
||||
class="hover:text-danger"
|
||||
:aria-label="`Remove tag ${tag}`"
|
||||
@click="removeTag(tag)"
|
||||
>
|
||||
<i class="icon-[lucide--x] h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="tagQuery"
|
||||
type="text"
|
||||
class="h-8 w-44 rounded border border-border-default bg-secondary-background px-2 text-sm focus:outline-none"
|
||||
:placeholder="
|
||||
t('templatePublishing.steps.metadata.tagsPlaceholder')
|
||||
"
|
||||
aria-labelledby="tpl-tags-label"
|
||||
@focus="showSuggestions = true"
|
||||
@keydown.enter.prevent="addTag(tagQuery)"
|
||||
/>
|
||||
<ul
|
||||
v-if="showSuggestions && filteredSuggestions.length > 0"
|
||||
class="absolute z-10 mt-1 max-h-40 w-44 overflow-auto rounded border border-border-default bg-secondary-background shadow-md"
|
||||
>
|
||||
<li
|
||||
v-for="suggestion in filteredSuggestions"
|
||||
:key="suggestion"
|
||||
class="cursor-pointer px-2 py-1 text-sm hover:bg-comfy-input-background"
|
||||
@mousedown.prevent="addTag(suggestion)"
|
||||
>
|
||||
{{ suggestion }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, ref } from 'vue'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { PublishingStepperKey } from '../types'
|
||||
|
||||
const { t } = useI18n()
|
||||
const ctx = inject(PublishingStepperKey)!
|
||||
|
||||
const CATEGORIES = [
|
||||
{ key: 'threeD', value: '3d' },
|
||||
{ key: 'audio', value: 'audio' },
|
||||
{ key: 'controlNet', value: 'controlnet' },
|
||||
{ key: 'imageGeneration', value: 'image-generation' },
|
||||
{ key: 'inpainting', value: 'inpainting' },
|
||||
{ key: 'other', value: 'other' },
|
||||
{ key: 'styleTransfer', value: 'style-transfer' },
|
||||
{ key: 'text', value: 'text' },
|
||||
{ key: 'upscaling', value: 'upscaling' },
|
||||
{ key: 'videoGeneration', value: 'video-generation' }
|
||||
] as const
|
||||
|
||||
const TAG_SUGGESTIONS = [
|
||||
'stable-diffusion',
|
||||
'flux',
|
||||
'sdxl',
|
||||
'sd1.5',
|
||||
'img2img',
|
||||
'txt2img',
|
||||
'upscale',
|
||||
'face-restore',
|
||||
'animation',
|
||||
'video',
|
||||
'lora',
|
||||
'controlnet',
|
||||
'ipadapter',
|
||||
'inpainting',
|
||||
'outpainting',
|
||||
'depth',
|
||||
'pose',
|
||||
'segmentation',
|
||||
'latent',
|
||||
'sampler'
|
||||
]
|
||||
|
||||
const tagQuery = ref('')
|
||||
const showSuggestions = ref(false)
|
||||
|
||||
const filteredSuggestions = computed(() => {
|
||||
const query = tagQuery.value.toLowerCase().trim()
|
||||
if (!query) return []
|
||||
const existing = ctx.template.value.tags ?? []
|
||||
return TAG_SUGGESTIONS.filter(
|
||||
(s) => s.includes(query) && !existing.includes(s)
|
||||
)
|
||||
})
|
||||
|
||||
function toggleCategory(value: string) {
|
||||
const categories = ctx.template.value.categories ?? []
|
||||
const index = categories.indexOf(value)
|
||||
if (index >= 0) {
|
||||
categories.splice(index, 1)
|
||||
} else {
|
||||
categories.push(value)
|
||||
}
|
||||
ctx.template.value.categories = [...categories]
|
||||
}
|
||||
|
||||
function addTag(tag: string) {
|
||||
const trimmed = tag.trim().toLowerCase()
|
||||
if (!trimmed) return
|
||||
const tags = ctx.template.value.tags ?? []
|
||||
if (!tags.includes(trimmed)) {
|
||||
ctx.template.value.tags = [...tags, trimmed]
|
||||
}
|
||||
tagQuery.value = ''
|
||||
showSuggestions.value = false
|
||||
}
|
||||
|
||||
function removeTag(tag: string) {
|
||||
const tags = ctx.template.value.tags ?? []
|
||||
ctx.template.value.tags = tags.filter((t) => t !== tag)
|
||||
}
|
||||
|
||||
watchDebounced(
|
||||
() => ctx.template.value,
|
||||
() => ctx.saveDraft(),
|
||||
{ deep: true, debounce: 500 }
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 p-6">
|
||||
<p class="text-muted-foreground">
|
||||
{{ t('templatePublishing.steps.complete.description') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
@@ -0,0 +1,111 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { computed, ref } from 'vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { MarketplaceTemplate } from '@/types/templateMarketplace'
|
||||
|
||||
import type { PublishingStepperContext } from '../types'
|
||||
import { PublishingStepperKey } from '../types'
|
||||
import StepTemplatePublishingDescription from './StepTemplatePublishingDescription.vue'
|
||||
|
||||
vi.mock('@/utils/markdownRendererUtil', () => ({
|
||||
renderMarkdownToHtml: vi.fn((md: string) => `<p>${md}</p>`)
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
templatePublishing: {
|
||||
steps: {
|
||||
description: {
|
||||
title: 'Description',
|
||||
description: 'Write a detailed description of your template',
|
||||
editorLabel: 'Description (Markdown)',
|
||||
previewLabel: 'Description (Render preview)'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function createContext(
|
||||
templateData: Partial<MarketplaceTemplate> = {}
|
||||
): PublishingStepperContext {
|
||||
const template = ref<Partial<MarketplaceTemplate>>(templateData)
|
||||
const currentStep = ref(3)
|
||||
return {
|
||||
currentStep,
|
||||
totalSteps: 8,
|
||||
isFirstStep: computed(() => currentStep.value === 1),
|
||||
isLastStep: computed(() => currentStep.value === 8),
|
||||
canProceed: computed(() => false),
|
||||
template,
|
||||
nextStep: vi.fn(),
|
||||
prevStep: vi.fn(),
|
||||
goToStep: vi.fn(),
|
||||
saveDraft: vi.fn(),
|
||||
setStepValid: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
function mountStep(ctx?: PublishingStepperContext) {
|
||||
const context = ctx ?? createContext()
|
||||
return {
|
||||
wrapper: mount(StepTemplatePublishingDescription, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
provide: { [PublishingStepperKey as symbol]: context }
|
||||
}
|
||||
}),
|
||||
ctx: context
|
||||
}
|
||||
}
|
||||
|
||||
describe('StepTemplatePublishingDescription', () => {
|
||||
it('renders editor and preview labels', () => {
|
||||
const { wrapper } = mountStep()
|
||||
expect(wrapper.text()).toContain('Description (Markdown)')
|
||||
expect(wrapper.text()).toContain('Description (Render preview)')
|
||||
})
|
||||
|
||||
it('renders a textarea for markdown editing', () => {
|
||||
const { wrapper } = mountStep()
|
||||
const textarea = wrapper.find('textarea')
|
||||
expect(textarea.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('binds textarea to template.description', () => {
|
||||
const ctx = createContext({ description: 'Hello **world**' })
|
||||
const { wrapper } = mountStep(ctx)
|
||||
const textarea = wrapper.find('textarea')
|
||||
expect((textarea.element as HTMLTextAreaElement).value).toBe(
|
||||
'Hello **world**'
|
||||
)
|
||||
})
|
||||
|
||||
it('updates template.description when textarea changes', async () => {
|
||||
const ctx = createContext({ description: '' })
|
||||
const { wrapper } = mountStep(ctx)
|
||||
const textarea = wrapper.find('textarea')
|
||||
await textarea.setValue('New content')
|
||||
expect(ctx.template.value.description).toBe('New content')
|
||||
})
|
||||
|
||||
it('renders markdown preview from template.description', () => {
|
||||
const ctx = createContext({ description: 'Some markdown' })
|
||||
const { wrapper } = mountStep(ctx)
|
||||
const preview = wrapper.find('[class*="prose"]')
|
||||
expect(preview.html()).toContain('<p>Some markdown</p>')
|
||||
})
|
||||
|
||||
it('renders empty preview when description is undefined', () => {
|
||||
const ctx = createContext({})
|
||||
const { wrapper } = mountStep(ctx)
|
||||
const preview = wrapper.find('[class*="prose"]')
|
||||
expect(preview.html()).toContain('<p></p>')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-row gap-4 p-6">
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<label for="tpl-description-editor" class="text-sm text-muted-foreground">
|
||||
{{ t('templatePublishing.steps.description.editorLabel') }}
|
||||
</label>
|
||||
<textarea
|
||||
id="tpl-description-editor"
|
||||
v-model="ctx.template.value.description"
|
||||
class="min-h-0 flex-1 resize-none rounded-lg border border-border-default bg-secondary-background p-3 font-mono text-sm text-base-foreground focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ t('templatePublishing.steps.description.previewLabel') }}
|
||||
</span>
|
||||
<div
|
||||
class="prose prose-invert min-h-0 flex-1 overflow-y-auto rounded-lg border border-border-default bg-secondary-background p-3 text-sm scrollbar-custom"
|
||||
v-html="renderedHtml"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, inject } from 'vue'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil'
|
||||
|
||||
import { PublishingStepperKey } from '../types'
|
||||
|
||||
const { t } = useI18n()
|
||||
const ctx = inject(PublishingStepperKey)!
|
||||
|
||||
const renderedHtml = computed(() =>
|
||||
renderMarkdownToHtml(ctx.template.value.description ?? '')
|
||||
)
|
||||
|
||||
watchDebounced(
|
||||
() => ctx.template.value,
|
||||
() => ctx.saveDraft(),
|
||||
{ deep: true, debounce: 500 }
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 p-6">
|
||||
<p class="text-muted-foreground">
|
||||
{{ t('templatePublishing.steps.landing.description') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:valid': [valid: boolean]
|
||||
}>()
|
||||
|
||||
onMounted(() => {
|
||||
emit('update:valid', true)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,299 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { MarketplaceTemplate } from '@/types/templateMarketplace'
|
||||
import { NodeSourceType } from '@/types/nodeSource'
|
||||
|
||||
import type { PublishingStepperContext } from '../types'
|
||||
import { PublishingStepperKey } from '../types'
|
||||
import StepTemplatePublishingMetadata from './StepTemplatePublishingMetadata.vue'
|
||||
|
||||
const mockNodes = vi.hoisted(() => [
|
||||
{ type: 'KSampler', isSubgraphNode: () => false },
|
||||
{ type: 'MyCustomNode', isSubgraphNode: () => false },
|
||||
{ type: 'AnotherCustom', isSubgraphNode: () => false },
|
||||
{ type: 'MyCustomNode', isSubgraphNode: () => false },
|
||||
{ type: 'ExtraCustomPack', isSubgraphNode: () => false }
|
||||
])
|
||||
|
||||
vi.mock('@vueuse/core', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>
|
||||
return {
|
||||
...actual,
|
||||
watchDebounced: vi.fn((source: unknown, cb: unknown, opts: unknown) => {
|
||||
const typedActual = actual as {
|
||||
watchDebounced: (...args: unknown[]) => unknown
|
||||
}
|
||||
return typedActual.watchDebounced(source, cb, {
|
||||
...(opts as object),
|
||||
debounce: 0
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
rootGraph: {
|
||||
nodes: mockNodes
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
mapAllNodes: vi.fn(
|
||||
(
|
||||
graph: { nodes: Array<{ type: string }> },
|
||||
mapFn: (node: { type: string }) => string | undefined
|
||||
) => graph.nodes.map(mapFn).filter(Boolean)
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useVramEstimation', () => ({
|
||||
estimateWorkflowVram: vi.fn(() => 5_000_000_000)
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeDefStore', () => ({
|
||||
useNodeDefStore: () => ({
|
||||
nodeDefsByName: {
|
||||
KSampler: {
|
||||
name: 'KSampler',
|
||||
python_module: 'nodes',
|
||||
nodeSource: { type: NodeSourceType.Core }
|
||||
},
|
||||
MyCustomNode: {
|
||||
name: 'MyCustomNode',
|
||||
python_module: 'custom_nodes.MyPack@1.0.nodes',
|
||||
nodeSource: { type: NodeSourceType.CustomNodes }
|
||||
},
|
||||
AnotherCustom: {
|
||||
name: 'AnotherCustom',
|
||||
python_module: 'custom_nodes.MyPack@1.0.extra',
|
||||
nodeSource: { type: NodeSourceType.CustomNodes }
|
||||
},
|
||||
ExtraCustomPack: {
|
||||
name: 'ExtraCustomPack',
|
||||
python_module: 'custom_nodes.ExtraPack.nodes',
|
||||
nodeSource: { type: NodeSourceType.CustomNodes }
|
||||
},
|
||||
UnusedCustomNode: {
|
||||
name: 'UnusedCustomNode',
|
||||
python_module: 'custom_nodes.UnusedPack@2.0.nodes',
|
||||
nodeSource: { type: NodeSourceType.CustomNodes }
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
templatePublishing: {
|
||||
steps: {
|
||||
metadata: {
|
||||
title: 'Metadata',
|
||||
description: 'Title, description, and author info',
|
||||
titleLabel: 'Title',
|
||||
difficultyLabel: 'Difficulty',
|
||||
licenseLabel: 'License',
|
||||
requiredNodesLabel: 'Custom Nodes',
|
||||
requiredNodesDetected: 'Detected from workflow',
|
||||
requiredNodesManualPlaceholder: 'Add custom node name…',
|
||||
requiredNodesManualLabel: 'Additional custom nodes',
|
||||
vramLabel: 'Estimated VRAM Requirement',
|
||||
vramAutoDetected: 'Auto-detected from workflow:',
|
||||
vramManualOverride: 'Manual override (GB):',
|
||||
difficulty: {
|
||||
beginner: 'Beginner',
|
||||
intermediate: 'Intermediate',
|
||||
advanced: 'Advanced'
|
||||
},
|
||||
license: {
|
||||
mit: 'MIT',
|
||||
ccBy: 'CC BY',
|
||||
ccBySa: 'CC BY-SA',
|
||||
ccByNc: 'CC BY-NC',
|
||||
apache: 'Apache',
|
||||
custom: 'Custom'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function createContext(
|
||||
templateData: Partial<MarketplaceTemplate> = {}
|
||||
): PublishingStepperContext {
|
||||
const template = ref<Partial<MarketplaceTemplate>>(templateData)
|
||||
const currentStep = ref(2)
|
||||
return {
|
||||
currentStep,
|
||||
totalSteps: 8,
|
||||
isFirstStep: computed(() => currentStep.value === 1),
|
||||
isLastStep: computed(() => currentStep.value === 8),
|
||||
canProceed: computed(() => false),
|
||||
template,
|
||||
nextStep: vi.fn(),
|
||||
prevStep: vi.fn(),
|
||||
goToStep: vi.fn(),
|
||||
saveDraft: vi.fn(),
|
||||
setStepValid: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
function mountStep(ctx?: PublishingStepperContext) {
|
||||
const context = ctx ?? createContext()
|
||||
return {
|
||||
wrapper: mount(StepTemplatePublishingMetadata, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
provide: { [PublishingStepperKey as symbol]: context },
|
||||
stubs: {
|
||||
FormItem: {
|
||||
template:
|
||||
'<div :data-testid="`form-item-${id}`"><input :value="formValue" @input="$emit(\'update:formValue\', $event.target.value)" /></div>',
|
||||
props: ['item', 'id', 'formValue', 'labelClass'],
|
||||
emits: ['update:formValue']
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
ctx: context
|
||||
}
|
||||
}
|
||||
|
||||
describe('StepTemplatePublishingMetadata', () => {
|
||||
it('renders all form fields', () => {
|
||||
const { wrapper } = mountStep()
|
||||
|
||||
expect(wrapper.find('#tpl-title').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('Difficulty')
|
||||
expect(wrapper.find('[data-testid="form-item-tpl-license"]').exists()).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('selects difficulty when radio button is clicked', async () => {
|
||||
const ctx = createContext({})
|
||||
const { wrapper } = mountStep(ctx)
|
||||
|
||||
const intermediateRadio = wrapper.find('#tpl-difficulty-intermediate')
|
||||
await intermediateRadio.setValue(true)
|
||||
|
||||
expect(ctx.template.value.difficulty).toBe('intermediate')
|
||||
})
|
||||
|
||||
it('displays detected custom nodes from the workflow', async () => {
|
||||
const { wrapper } = mountStep()
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('AnotherCustom')
|
||||
expect(wrapper.text()).toContain('MyCustomNode')
|
||||
expect(wrapper.text()).not.toContain('KSampler')
|
||||
})
|
||||
|
||||
it('populates requiredNodes on mount when empty', () => {
|
||||
const ctx = createContext({ requiredNodes: [] })
|
||||
mountStep(ctx)
|
||||
|
||||
expect(ctx.template.value.requiredNodes).toContain('AnotherCustom')
|
||||
expect(ctx.template.value.requiredNodes).toContain('MyCustomNode')
|
||||
expect(ctx.template.value.requiredNodes).not.toContain('KSampler')
|
||||
})
|
||||
|
||||
it('does not overwrite existing requiredNodes on mount', () => {
|
||||
const ctx = createContext({ requiredNodes: ['PreExisting'] })
|
||||
mountStep(ctx)
|
||||
|
||||
expect(ctx.template.value.requiredNodes).toEqual(['PreExisting'])
|
||||
})
|
||||
|
||||
it('populates requiresCustomNodes with deduplicated package IDs on mount', () => {
|
||||
const ctx = createContext({})
|
||||
mountStep(ctx)
|
||||
|
||||
// MyCustomNode and AnotherCustom both come from MyPack@1.0 (@ stripped)
|
||||
// ExtraCustomPack comes from ExtraPack (no @version in module path)
|
||||
expect(ctx.template.value.requiresCustomNodes).toEqual([
|
||||
'ExtraPack',
|
||||
'MyPack'
|
||||
])
|
||||
})
|
||||
|
||||
it('does not overwrite existing requiresCustomNodes on mount', () => {
|
||||
const ctx = createContext({ requiresCustomNodes: ['PreExisting'] })
|
||||
mountStep(ctx)
|
||||
|
||||
expect(ctx.template.value.requiresCustomNodes).toEqual(['PreExisting'])
|
||||
})
|
||||
|
||||
it('adds a manual custom node via the input', async () => {
|
||||
const ctx = createContext({ requiredNodes: [] })
|
||||
const { wrapper } = mountStep(ctx)
|
||||
|
||||
const input = wrapper.find('.relative input[type="text"]')
|
||||
await input.setValue('ManualNode')
|
||||
await input.trigger('keydown.enter')
|
||||
|
||||
expect(ctx.template.value.requiredNodes).toContain('ManualNode')
|
||||
})
|
||||
|
||||
it('removes a manual custom node when its remove button is clicked', async () => {
|
||||
const ctx = createContext({
|
||||
requiredNodes: ['AnotherCustom', 'MyCustomNode', 'ManualNode']
|
||||
})
|
||||
const { wrapper } = mountStep(ctx)
|
||||
|
||||
const removeButtons = wrapper.findAll(
|
||||
'button[aria-label="Remove ManualNode"]'
|
||||
)
|
||||
await removeButtons[0].trigger('click')
|
||||
|
||||
expect(ctx.template.value.requiredNodes).not.toContain('ManualNode')
|
||||
})
|
||||
|
||||
it('shows filtered custom node suggestions when typing', async () => {
|
||||
const ctx = createContext({ requiredNodes: [] })
|
||||
const { wrapper } = mountStep(ctx)
|
||||
|
||||
const input = wrapper.find('.relative input[type="text"]')
|
||||
await input.trigger('focus')
|
||||
await input.setValue('Unused')
|
||||
|
||||
const suggestions = wrapper.findAll('.relative ul li')
|
||||
expect(suggestions.length).toBe(1)
|
||||
expect(suggestions[0].text()).toBe('UnusedCustomNode')
|
||||
})
|
||||
|
||||
it('excludes already-added nodes from suggestions', async () => {
|
||||
const ctx = createContext({ requiredNodes: ['UnusedCustomNode'] })
|
||||
const { wrapper } = mountStep(ctx)
|
||||
|
||||
const input = wrapper.find('.relative input[type="text"]')
|
||||
await input.trigger('focus')
|
||||
await input.setValue('Unused')
|
||||
|
||||
const suggestions = wrapper.findAll('.relative ul li')
|
||||
expect(suggestions.length).toBe(0)
|
||||
})
|
||||
|
||||
it('adds a node from the suggestion dropdown', async () => {
|
||||
const ctx = createContext({ requiredNodes: [] })
|
||||
const { wrapper } = mountStep(ctx)
|
||||
|
||||
const input = wrapper.find('.relative input[type="text"]')
|
||||
await input.trigger('focus')
|
||||
await input.setValue('Unused')
|
||||
|
||||
const suggestion = wrapper.find('.relative ul li')
|
||||
await suggestion.trigger('mousedown')
|
||||
|
||||
expect(ctx.template.value.requiredNodes).toContain('UnusedCustomNode')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,384 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 p-6">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<div class="form-label flex w-28 shrink-0 items-center">
|
||||
<span id="tpl-title-label" class="text-muted">
|
||||
{{ t('templatePublishing.steps.metadata.titleLabel') }}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
id="tpl-title"
|
||||
v-model="ctx.template.value.title"
|
||||
type="text"
|
||||
class="h-8 w-[100em] rounded border border-border-default bg-secondary-background px-2 text-sm focus:outline-none"
|
||||
aria-labelledby="tpl-title-label"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<div class="form-label flex w-28 shrink-0 items-center">
|
||||
<span id="tpl-difficulty-label" class="text-muted">
|
||||
{{ t('templatePublishing.steps.metadata.difficultyLabel') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-row gap-4"
|
||||
role="radiogroup"
|
||||
aria-labelledby="tpl-difficulty-label"
|
||||
>
|
||||
<label
|
||||
v-for="option in DIFFICULTY_OPTIONS"
|
||||
:key="option.value"
|
||||
:for="`tpl-difficulty-${option.value}`"
|
||||
class="flex cursor-pointer items-center gap-1.5 text-sm"
|
||||
>
|
||||
<input
|
||||
:id="`tpl-difficulty-${option.value}`"
|
||||
type="radio"
|
||||
name="tpl-difficulty"
|
||||
:value="option.value"
|
||||
:checked="ctx.template.value.difficulty === option.value"
|
||||
:class="
|
||||
cn(
|
||||
'h-5 w-5 appearance-none rounded-full border-2 checked:bg-current checked:shadow-[inset_0_0_0_1px_white]',
|
||||
option.borderClass
|
||||
)
|
||||
"
|
||||
@change="ctx.template.value.difficulty = option.value"
|
||||
/>
|
||||
{{ option.text }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormItem
|
||||
id="tpl-license"
|
||||
v-model:form-value="ctx.template.value.license"
|
||||
:item="licenseField"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<span id="tpl-required-nodes-label" class="text-sm text-muted">
|
||||
{{ t('templatePublishing.steps.metadata.requiredNodesLabel') }}
|
||||
</span>
|
||||
|
||||
<div
|
||||
v-if="detectedCustomNodes.length > 0"
|
||||
aria-labelledby="tpl-required-nodes-label"
|
||||
>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ t('templatePublishing.steps.metadata.requiredNodesDetected') }}
|
||||
</span>
|
||||
<ul class="mt-1 flex flex-col gap-1">
|
||||
<li
|
||||
v-for="nodeName in detectedCustomNodes"
|
||||
:key="nodeName"
|
||||
class="flex items-center gap-2 rounded bg-secondary-background px-2 py-1 text-sm"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--puzzle] h-3.5 w-3.5 text-muted-foreground"
|
||||
/>
|
||||
{{ nodeName }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ t('templatePublishing.steps.metadata.requiredNodesManualLabel') }}
|
||||
</span>
|
||||
<div class="relative mt-1">
|
||||
<input
|
||||
v-model="manualNodeQuery"
|
||||
type="text"
|
||||
class="h-8 w-56 rounded border border-border-default bg-secondary-background px-2 text-sm focus:outline-none"
|
||||
:placeholder="
|
||||
t(
|
||||
'templatePublishing.steps.metadata.requiredNodesManualPlaceholder'
|
||||
)
|
||||
"
|
||||
@focus="showNodeSuggestions = true"
|
||||
@keydown.enter.prevent="addManualNode(manualNodeQuery)"
|
||||
/>
|
||||
<ul
|
||||
v-if="showNodeSuggestions && filteredNodeSuggestions.length > 0"
|
||||
class="absolute z-10 mt-1 max-h-40 w-56 overflow-auto rounded border border-border-default bg-secondary-background shadow-md"
|
||||
>
|
||||
<li
|
||||
v-for="suggestion in filteredNodeSuggestions"
|
||||
:key="suggestion"
|
||||
class="cursor-pointer px-2 py-1 text-sm hover:bg-comfy-input-background"
|
||||
@mousedown.prevent="addManualNode(suggestion)"
|
||||
>
|
||||
{{ suggestion }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div
|
||||
v-if="manualNodes.length > 0"
|
||||
class="mt-1 flex flex-wrap items-center gap-1"
|
||||
>
|
||||
<span
|
||||
v-for="node in manualNodes"
|
||||
:key="node"
|
||||
class="inline-flex items-center gap-1 rounded-full bg-comfy-input-background px-2 py-0.5 text-xs"
|
||||
>
|
||||
{{ node }}
|
||||
<button
|
||||
type="button"
|
||||
class="hover:text-danger"
|
||||
:aria-label="`Remove ${node}`"
|
||||
@click="removeManualNode(node)"
|
||||
>
|
||||
<i class="icon-[lucide--x] h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<span id="tpl-vram-label" class="text-sm text-muted">
|
||||
{{ t('templatePublishing.steps.metadata.vramLabel') }}
|
||||
</span>
|
||||
<div class="flex items-center gap-3">
|
||||
<i class="icon-[lucide--cpu] h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ t('templatePublishing.steps.metadata.vramAutoDetected') }}
|
||||
</span>
|
||||
<span class="text-sm font-medium">
|
||||
{{ formatSize(autoDetectedVram) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
id="tpl-vram-override"
|
||||
v-model.number="manualVramGb"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.5"
|
||||
class="h-8 w-24 rounded border border-border-default bg-secondary-background px-2 text-sm focus:outline-none"
|
||||
aria-labelledby="tpl-vram-label"
|
||||
/>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ t('templatePublishing.steps.metadata.vramManualOverride') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, onMounted, ref } from 'vue'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import { formatSize } from '@/utils/formatUtil'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import FormItem from '@/components/common/FormItem.vue'
|
||||
import { estimateWorkflowVram } from '@/composables/useVramEstimation'
|
||||
import type { FormItem as FormItemType } from '@/platform/settings/types'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { NodeSourceType } from '@/types/nodeSource'
|
||||
import { mapAllNodes } from '@/utils/graphTraversalUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { PublishingStepperKey } from '../types'
|
||||
|
||||
const { t } = useI18n()
|
||||
const ctx = inject(PublishingStepperKey)!
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
|
||||
const DIFFICULTY_OPTIONS = [
|
||||
{
|
||||
text: t('templatePublishing.steps.metadata.difficulty.beginner'),
|
||||
value: 'beginner' as const,
|
||||
borderClass: 'border-green-400'
|
||||
},
|
||||
{
|
||||
text: t('templatePublishing.steps.metadata.difficulty.intermediate'),
|
||||
value: 'intermediate' as const,
|
||||
borderClass: 'border-amber-400'
|
||||
},
|
||||
{
|
||||
text: t('templatePublishing.steps.metadata.difficulty.advanced'),
|
||||
value: 'advanced' as const,
|
||||
borderClass: 'border-red-400'
|
||||
}
|
||||
]
|
||||
|
||||
const licenseField: FormItemType = {
|
||||
name: t('templatePublishing.steps.metadata.licenseLabel'),
|
||||
type: 'combo',
|
||||
options: [
|
||||
{ text: t('templatePublishing.steps.metadata.license.mit'), value: 'mit' },
|
||||
{
|
||||
text: t('templatePublishing.steps.metadata.license.ccBy'),
|
||||
value: 'cc-by'
|
||||
},
|
||||
{
|
||||
text: t('templatePublishing.steps.metadata.license.ccBySa'),
|
||||
value: 'cc-by-sa'
|
||||
},
|
||||
{
|
||||
text: t('templatePublishing.steps.metadata.license.ccByNc'),
|
||||
value: 'cc-by-nc'
|
||||
},
|
||||
{
|
||||
text: t('templatePublishing.steps.metadata.license.apache'),
|
||||
value: 'apache'
|
||||
},
|
||||
{
|
||||
text: t('templatePublishing.steps.metadata.license.custom'),
|
||||
value: 'custom'
|
||||
}
|
||||
],
|
||||
attrs: { filter: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects unique custom node type names from the current workflow graph.
|
||||
* Excludes core, essentials, and blueprint nodes.
|
||||
*/
|
||||
function detectCustomNodes(): string[] {
|
||||
if (!app.rootGraph) return []
|
||||
|
||||
const nodeTypes = mapAllNodes(app.rootGraph, (node) => node.type)
|
||||
const unique = new Set(nodeTypes)
|
||||
|
||||
return [...unique]
|
||||
.filter((type) => {
|
||||
const def = nodeDefStore.nodeDefsByName[type]
|
||||
if (!def) return false
|
||||
return def.nodeSource.type === NodeSourceType.CustomNodes
|
||||
})
|
||||
.sort()
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the custom node package ID from a `python_module` string.
|
||||
*
|
||||
* Custom node modules follow the pattern
|
||||
* `custom_nodes.PackageName@version.submodule`, so the package ID is the
|
||||
* second dot-segment with the `@version` suffix stripped.
|
||||
*
|
||||
* @returns The package folder name, or `undefined` when the module does not
|
||||
* match the expected pattern.
|
||||
*/
|
||||
function extractPackageId(pythonModule: string): string | undefined {
|
||||
const segments = pythonModule.split('.')
|
||||
if (segments[0] !== 'custom_nodes' || !segments[1]) return undefined
|
||||
return segments[1].split('@')[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects unique custom node package IDs from the current workflow graph.
|
||||
*/
|
||||
function detectCustomNodePackages(): string[] {
|
||||
if (!app.rootGraph) return []
|
||||
|
||||
const nodeTypes = mapAllNodes(app.rootGraph, (node) => node.type)
|
||||
const packages = new Set<string>()
|
||||
|
||||
for (const type of nodeTypes) {
|
||||
const def = nodeDefStore.nodeDefsByName[type]
|
||||
if (!def || def.nodeSource.type !== NodeSourceType.CustomNodes) continue
|
||||
const pkgId = extractPackageId(def.python_module)
|
||||
if (pkgId) packages.add(pkgId)
|
||||
}
|
||||
|
||||
return [...packages].sort()
|
||||
}
|
||||
|
||||
const detectedCustomNodes = ref<string[]>([])
|
||||
const autoDetectedVram = ref(0)
|
||||
|
||||
const GB = 1_073_741_824
|
||||
|
||||
/**
|
||||
* Manual VRAM override in GB. When set to a positive number, this
|
||||
* value (converted to bytes) takes precedence over the auto-detected
|
||||
* estimate for `vramRequirement`.
|
||||
*/
|
||||
const manualVramGb = computed({
|
||||
get: () => {
|
||||
const stored = ctx.template.value.vramRequirement
|
||||
if (!stored || stored === autoDetectedVram.value) return undefined
|
||||
return Math.round((stored / GB) * 10) / 10
|
||||
},
|
||||
set: (gb: number | undefined) => {
|
||||
if (gb && gb > 0) {
|
||||
ctx.template.value.vramRequirement = Math.round(gb * GB)
|
||||
} else {
|
||||
ctx.template.value.vramRequirement = autoDetectedVram.value
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
detectedCustomNodes.value = detectCustomNodes()
|
||||
|
||||
const existing = ctx.template.value.requiredNodes ?? []
|
||||
if (existing.length === 0) {
|
||||
ctx.template.value.requiredNodes = [...detectedCustomNodes.value]
|
||||
}
|
||||
|
||||
const existingPackages = ctx.template.value.requiresCustomNodes ?? []
|
||||
if (existingPackages.length === 0) {
|
||||
ctx.template.value.requiresCustomNodes = detectCustomNodePackages()
|
||||
}
|
||||
|
||||
autoDetectedVram.value = estimateWorkflowVram(app.rootGraph)
|
||||
if (!ctx.template.value.vramRequirement) {
|
||||
ctx.template.value.vramRequirement = autoDetectedVram.value
|
||||
}
|
||||
})
|
||||
|
||||
const manualNodes = computed(() => {
|
||||
const all = ctx.template.value.requiredNodes ?? []
|
||||
const detected = new Set(detectedCustomNodes.value)
|
||||
return all.filter((n) => !detected.has(n))
|
||||
})
|
||||
|
||||
const manualNodeQuery = ref('')
|
||||
const showNodeSuggestions = ref(false)
|
||||
|
||||
/** All installed custom node type names for searchable suggestions. */
|
||||
const allCustomNodeNames = computed(() =>
|
||||
Object.values(nodeDefStore.nodeDefsByName)
|
||||
.filter((def) => def.nodeSource.type === NodeSourceType.CustomNodes)
|
||||
.map((def) => def.name)
|
||||
.sort()
|
||||
)
|
||||
|
||||
const filteredNodeSuggestions = computed(() => {
|
||||
const query = manualNodeQuery.value.toLowerCase().trim()
|
||||
if (!query) return []
|
||||
const existing = new Set(ctx.template.value.requiredNodes ?? [])
|
||||
return allCustomNodeNames.value.filter(
|
||||
(name) => name.toLowerCase().includes(query) && !existing.has(name)
|
||||
)
|
||||
})
|
||||
|
||||
function addManualNode(name: string) {
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) return
|
||||
const nodes = ctx.template.value.requiredNodes ?? []
|
||||
if (!nodes.includes(trimmed)) {
|
||||
ctx.template.value.requiredNodes = [...nodes, trimmed]
|
||||
}
|
||||
manualNodeQuery.value = ''
|
||||
showNodeSuggestions.value = false
|
||||
}
|
||||
|
||||
function removeManualNode(name: string) {
|
||||
const nodes = ctx.template.value.requiredNodes ?? []
|
||||
ctx.template.value.requiredNodes = nodes.filter((n) => n !== name)
|
||||
}
|
||||
|
||||
watchDebounced(
|
||||
() => ctx.template.value,
|
||||
() => ctx.saveDraft(),
|
||||
{ deep: true, debounce: 500 }
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,288 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { computed, ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useTemplatePreviewAssets } from '@/composables/useTemplatePreviewAssets'
|
||||
import type { MarketplaceTemplate } from '@/types/templateMarketplace'
|
||||
|
||||
import type { PublishingStepperContext } from '../types'
|
||||
import { PublishingStepperKey } from '../types'
|
||||
import StepTemplatePublishingPreview from './StepTemplatePublishingPreview.vue'
|
||||
|
||||
let blobCounter = 0
|
||||
URL.createObjectURL = vi.fn(() => `blob:http://localhost/mock-${++blobCounter}`)
|
||||
URL.revokeObjectURL = vi.fn()
|
||||
|
||||
vi.mock('@/utils/markdownRendererUtil', () => ({
|
||||
renderMarkdownToHtml: vi.fn((md: string) => `<p>${md}</p>`)
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
templatePublishing: {
|
||||
steps: {
|
||||
metadata: {
|
||||
titleLabel: 'Title',
|
||||
difficultyLabel: 'Difficulty',
|
||||
licenseLabel: 'License',
|
||||
categoryLabel: 'Categories',
|
||||
tagsLabel: 'Tags',
|
||||
requiredNodesLabel: 'Custom Nodes',
|
||||
difficulty: {
|
||||
beginner: 'Beginner',
|
||||
intermediate: 'Intermediate',
|
||||
advanced: 'Advanced'
|
||||
},
|
||||
license: {
|
||||
mit: 'MIT',
|
||||
ccBy: 'CC BY',
|
||||
ccBySa: 'CC BY-SA',
|
||||
ccByNc: 'CC BY-NC',
|
||||
apache: 'Apache',
|
||||
custom: 'Custom'
|
||||
},
|
||||
category: {
|
||||
imageGeneration: 'Image Generation',
|
||||
videoGeneration: 'Video Generation',
|
||||
audio: 'Audio',
|
||||
text: 'Text',
|
||||
threeD: '3D',
|
||||
upscaling: 'Upscaling',
|
||||
inpainting: 'Inpainting',
|
||||
controlNet: 'ControlNet',
|
||||
styleTransfer: 'Style Transfer',
|
||||
other: 'Other'
|
||||
}
|
||||
},
|
||||
preview: {
|
||||
sectionMetadata: 'Metadata',
|
||||
sectionDescription: 'Description',
|
||||
sectionPreviewAssets: 'Preview Assets',
|
||||
sectionCategoriesAndTags: 'Categories & Tags',
|
||||
thumbnailLabel: 'Thumbnail',
|
||||
comparisonLabel: 'Before & After',
|
||||
workflowPreviewLabel: 'Workflow Graph',
|
||||
videoPreviewLabel: 'Video Preview',
|
||||
galleryLabel: 'Gallery',
|
||||
notProvided: 'Not provided',
|
||||
noneDetected: 'None detected',
|
||||
correct: 'Correct',
|
||||
editStep: 'Edit'
|
||||
},
|
||||
previewGeneration: {
|
||||
beforeImageLabel: 'Before',
|
||||
afterImageLabel: 'After'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function createContext(
|
||||
templateData: Partial<MarketplaceTemplate> = {}
|
||||
): PublishingStepperContext {
|
||||
const template = ref<Partial<MarketplaceTemplate>>(templateData)
|
||||
const currentStep = ref(6)
|
||||
return {
|
||||
currentStep,
|
||||
totalSteps: 8,
|
||||
isFirstStep: computed(() => currentStep.value === 1),
|
||||
isLastStep: computed(() => currentStep.value === 8),
|
||||
canProceed: computed(() => false),
|
||||
template,
|
||||
nextStep: vi.fn(),
|
||||
prevStep: vi.fn(),
|
||||
goToStep: vi.fn(),
|
||||
saveDraft: vi.fn(),
|
||||
setStepValid: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
function mountStep(ctx?: PublishingStepperContext) {
|
||||
const context = ctx ?? createContext()
|
||||
return {
|
||||
wrapper: mount(StepTemplatePublishingPreview, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
provide: { [PublishingStepperKey as symbol]: context }
|
||||
}
|
||||
}),
|
||||
ctx: context
|
||||
}
|
||||
}
|
||||
|
||||
describe('StepTemplatePublishingPreview', () => {
|
||||
beforeEach(() => {
|
||||
const assets = useTemplatePreviewAssets()
|
||||
assets.clearAll()
|
||||
vi.clearAllMocks()
|
||||
blobCounter = 0
|
||||
})
|
||||
|
||||
it('renders all section headings', () => {
|
||||
const { wrapper } = mountStep()
|
||||
expect(wrapper.text()).toContain('Metadata')
|
||||
expect(wrapper.text()).toContain('Description')
|
||||
expect(wrapper.text()).toContain('Preview Assets')
|
||||
expect(wrapper.text()).toContain('Categories & Tags')
|
||||
})
|
||||
|
||||
it('displays template title', () => {
|
||||
const ctx = createContext({ title: 'My Workflow' })
|
||||
const { wrapper } = mountStep(ctx)
|
||||
expect(wrapper.text()).toContain('My Workflow')
|
||||
})
|
||||
|
||||
it('displays difficulty level', () => {
|
||||
const ctx = createContext({ difficulty: 'advanced' })
|
||||
const { wrapper } = mountStep(ctx)
|
||||
expect(wrapper.text()).toContain('Advanced')
|
||||
})
|
||||
|
||||
it('displays license type', () => {
|
||||
const ctx = createContext({ license: 'mit' })
|
||||
const { wrapper } = mountStep(ctx)
|
||||
expect(wrapper.text()).toContain('MIT')
|
||||
})
|
||||
|
||||
it('displays required custom nodes', () => {
|
||||
const ctx = createContext({
|
||||
requiredNodes: ['ComfyUI-Impact-Pack', 'ComfyUI-Manager']
|
||||
})
|
||||
const { wrapper } = mountStep(ctx)
|
||||
expect(wrapper.text()).toContain('ComfyUI-Impact-Pack')
|
||||
expect(wrapper.text()).toContain('ComfyUI-Manager')
|
||||
})
|
||||
|
||||
it('shows "None detected" when no custom nodes', () => {
|
||||
const ctx = createContext({ requiredNodes: [] })
|
||||
const { wrapper } = mountStep(ctx)
|
||||
expect(wrapper.text()).toContain('None detected')
|
||||
})
|
||||
|
||||
it('renders description as markdown HTML', () => {
|
||||
const ctx = createContext({ description: 'Hello **bold**' })
|
||||
const { wrapper } = mountStep(ctx)
|
||||
const prose = wrapper.find('[class*="prose"]')
|
||||
expect(prose.html()).toContain('<p>Hello **bold**</p>')
|
||||
})
|
||||
|
||||
it('shows "Not provided" when description is empty', () => {
|
||||
const ctx = createContext({})
|
||||
const { wrapper } = mountStep(ctx)
|
||||
const text = wrapper.text()
|
||||
expect(text).toContain('Not provided')
|
||||
})
|
||||
|
||||
it('displays categories as pills', () => {
|
||||
const ctx = createContext({
|
||||
categories: ['image-generation', 'controlnet']
|
||||
})
|
||||
const { wrapper } = mountStep(ctx)
|
||||
expect(wrapper.text()).toContain('Image Generation')
|
||||
expect(wrapper.text()).toContain('ControlNet')
|
||||
})
|
||||
|
||||
it('displays tags as pills', () => {
|
||||
const ctx = createContext({ tags: ['flux', 'sdxl'] })
|
||||
const { wrapper } = mountStep(ctx)
|
||||
expect(wrapper.text()).toContain('flux')
|
||||
expect(wrapper.text()).toContain('sdxl')
|
||||
})
|
||||
|
||||
it('displays thumbnail when asset is cached', () => {
|
||||
const assets = useTemplatePreviewAssets()
|
||||
assets.setThumbnail(new File([''], 'thumb.png'))
|
||||
|
||||
const ctx = createContext({ thumbnail: 'blob:thumb' })
|
||||
const { wrapper } = mountStep(ctx)
|
||||
const imgs = wrapper.findAll('img')
|
||||
const thumbImg = imgs.find((img) =>
|
||||
img.attributes('alt')?.includes('thumb.png')
|
||||
)
|
||||
expect(thumbImg?.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('displays gallery images when assets are cached', () => {
|
||||
const assets = useTemplatePreviewAssets()
|
||||
assets.addGalleryImage(new File([''], 'a.png'))
|
||||
assets.addGalleryImage(new File([''], 'b.png'))
|
||||
|
||||
const ctx = createContext({
|
||||
gallery: [
|
||||
{ type: 'image', url: 'blob:a', caption: 'a.png' },
|
||||
{ type: 'image', url: 'blob:b', caption: 'b.png' }
|
||||
]
|
||||
})
|
||||
const { wrapper } = mountStep(ctx)
|
||||
const imgs = wrapper.findAll('img')
|
||||
const galleryImgs = imgs.filter(
|
||||
(img) =>
|
||||
img.attributes('alt') === 'a.png' || img.attributes('alt') === 'b.png'
|
||||
)
|
||||
expect(galleryImgs).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('renders a "Correct" button', () => {
|
||||
const { wrapper } = mountStep()
|
||||
const correctBtn = wrapper
|
||||
.findAll('button')
|
||||
.find((b) => b.text().includes('Correct'))
|
||||
expect(correctBtn?.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('calls nextStep when "Correct" button is clicked', async () => {
|
||||
const ctx = createContext()
|
||||
const { wrapper } = mountStep(ctx)
|
||||
const correctBtn = wrapper
|
||||
.findAll('button')
|
||||
.find((b) => b.text().includes('Correct'))
|
||||
await correctBtn!.trigger('click')
|
||||
expect(ctx.nextStep).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('navigates to metadata step when edit is clicked on metadata section', async () => {
|
||||
const ctx = createContext()
|
||||
const { wrapper } = mountStep(ctx)
|
||||
const editButtons = wrapper
|
||||
.findAll('button')
|
||||
.filter((b) => b.text().includes('Edit'))
|
||||
await editButtons[0].trigger('click')
|
||||
expect(ctx.goToStep).toHaveBeenCalledWith(2)
|
||||
})
|
||||
|
||||
it('navigates to description step when edit is clicked on description section', async () => {
|
||||
const ctx = createContext()
|
||||
const { wrapper } = mountStep(ctx)
|
||||
const editButtons = wrapper
|
||||
.findAll('button')
|
||||
.filter((b) => b.text().includes('Edit'))
|
||||
await editButtons[1].trigger('click')
|
||||
expect(ctx.goToStep).toHaveBeenCalledWith(3)
|
||||
})
|
||||
|
||||
it('navigates to preview generation step when edit is clicked on assets section', async () => {
|
||||
const ctx = createContext()
|
||||
const { wrapper } = mountStep(ctx)
|
||||
const editButtons = wrapper
|
||||
.findAll('button')
|
||||
.filter((b) => b.text().includes('Edit'))
|
||||
await editButtons[2].trigger('click')
|
||||
expect(ctx.goToStep).toHaveBeenCalledWith(4)
|
||||
})
|
||||
|
||||
it('navigates to category step when edit is clicked on categories section', async () => {
|
||||
const ctx = createContext()
|
||||
const { wrapper } = mountStep(ctx)
|
||||
const editButtons = wrapper
|
||||
.findAll('button')
|
||||
.filter((b) => b.text().includes('Edit'))
|
||||
await editButtons[3].trigger('click')
|
||||
expect(ctx.goToStep).toHaveBeenCalledWith(5)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,298 @@
|
||||
<!--
|
||||
Step 6 of the template publishing wizard. Displays a read-only summary
|
||||
of all user-provided data so the author can audit it before submission.
|
||||
-->
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 overflow-y-auto p-6">
|
||||
<!-- Metadata -->
|
||||
<PreviewSection
|
||||
:label="t('templatePublishing.steps.preview.sectionMetadata')"
|
||||
@edit="ctx.goToStep(2)"
|
||||
>
|
||||
<PreviewField
|
||||
:label="t('templatePublishing.steps.metadata.titleLabel')"
|
||||
:value="tpl.title"
|
||||
/>
|
||||
<PreviewField
|
||||
:label="t('templatePublishing.steps.metadata.difficultyLabel')"
|
||||
:value="difficultyLabel"
|
||||
/>
|
||||
<PreviewField
|
||||
:label="t('templatePublishing.steps.metadata.licenseLabel')"
|
||||
:value="licenseLabel"
|
||||
/>
|
||||
<PreviewField
|
||||
:label="t('templatePublishing.steps.metadata.requiredNodesLabel')"
|
||||
>
|
||||
<ul
|
||||
v-if="(tpl.requiredNodes ?? []).length > 0"
|
||||
class="flex flex-col gap-0.5"
|
||||
>
|
||||
<li
|
||||
v-for="node in tpl.requiredNodes"
|
||||
:key="node"
|
||||
class="flex items-center gap-1.5 text-sm"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--puzzle] h-3.5 w-3.5 text-muted-foreground"
|
||||
/>
|
||||
{{ node }}
|
||||
</li>
|
||||
</ul>
|
||||
<span v-else class="text-sm text-muted-foreground">
|
||||
{{ t('templatePublishing.steps.preview.noneDetected') }}
|
||||
</span>
|
||||
</PreviewField>
|
||||
<PreviewField
|
||||
:label="t('templatePublishing.steps.preview.vramLabel')"
|
||||
:value="vramLabel"
|
||||
/>
|
||||
</PreviewSection>
|
||||
|
||||
<!-- Description -->
|
||||
<PreviewSection
|
||||
:label="t('templatePublishing.steps.preview.sectionDescription')"
|
||||
@edit="ctx.goToStep(3)"
|
||||
>
|
||||
<div
|
||||
v-if="tpl.description"
|
||||
class="prose prose-invert max-h-48 overflow-y-auto rounded-lg border border-border-default bg-secondary-background p-3 text-sm scrollbar-custom"
|
||||
v-html="renderedDescription"
|
||||
/>
|
||||
<span v-else class="text-sm text-muted-foreground">
|
||||
{{ t('templatePublishing.steps.preview.notProvided') }}
|
||||
</span>
|
||||
</PreviewSection>
|
||||
|
||||
<!-- Preview Assets -->
|
||||
<PreviewSection
|
||||
:label="t('templatePublishing.steps.preview.sectionPreviewAssets')"
|
||||
@edit="ctx.goToStep(4)"
|
||||
>
|
||||
<!-- Thumbnail -->
|
||||
<PreviewField
|
||||
:label="t('templatePublishing.steps.preview.thumbnailLabel')"
|
||||
>
|
||||
<img
|
||||
v-if="assets.thumbnail.value"
|
||||
:src="assets.thumbnail.value.objectUrl"
|
||||
:alt="assets.thumbnail.value.originalName"
|
||||
class="h-28 w-44 rounded-lg object-cover"
|
||||
/>
|
||||
<span v-else class="text-sm text-muted-foreground">
|
||||
{{ t('templatePublishing.steps.preview.notProvided') }}
|
||||
</span>
|
||||
</PreviewField>
|
||||
|
||||
<!-- Before & After -->
|
||||
<PreviewField
|
||||
:label="t('templatePublishing.steps.preview.comparisonLabel')"
|
||||
>
|
||||
<div
|
||||
v-if="assets.beforeImage.value || assets.afterImage.value"
|
||||
class="flex gap-3"
|
||||
>
|
||||
<div v-if="assets.beforeImage.value" class="flex flex-col gap-0.5">
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{
|
||||
t('templatePublishing.steps.previewGeneration.beforeImageLabel')
|
||||
}}
|
||||
</span>
|
||||
<img
|
||||
:src="assets.beforeImage.value.objectUrl"
|
||||
:alt="assets.beforeImage.value.originalName"
|
||||
class="h-24 w-24 rounded-lg object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="assets.afterImage.value" class="flex flex-col gap-0.5">
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{
|
||||
t('templatePublishing.steps.previewGeneration.afterImageLabel')
|
||||
}}
|
||||
</span>
|
||||
<img
|
||||
:src="assets.afterImage.value.objectUrl"
|
||||
:alt="assets.afterImage.value.originalName"
|
||||
class="h-24 w-24 rounded-lg object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="text-sm text-muted-foreground">
|
||||
{{ t('templatePublishing.steps.preview.notProvided') }}
|
||||
</span>
|
||||
</PreviewField>
|
||||
|
||||
<!-- Workflow Graph -->
|
||||
<PreviewField
|
||||
:label="t('templatePublishing.steps.preview.workflowPreviewLabel')"
|
||||
>
|
||||
<img
|
||||
v-if="assets.workflowPreview.value"
|
||||
:src="assets.workflowPreview.value.objectUrl"
|
||||
:alt="assets.workflowPreview.value.originalName"
|
||||
class="h-28 w-48 rounded-lg object-cover"
|
||||
/>
|
||||
<span v-else class="text-sm text-muted-foreground">
|
||||
{{ t('templatePublishing.steps.preview.notProvided') }}
|
||||
</span>
|
||||
</PreviewField>
|
||||
|
||||
<!-- Video Preview -->
|
||||
<PreviewField
|
||||
:label="t('templatePublishing.steps.preview.videoPreviewLabel')"
|
||||
>
|
||||
<video
|
||||
v-if="assets.videoPreview.value"
|
||||
:src="assets.videoPreview.value.objectUrl"
|
||||
controls
|
||||
class="h-28 w-48 rounded-lg"
|
||||
/>
|
||||
<span v-else class="text-sm text-muted-foreground">
|
||||
{{ t('templatePublishing.steps.preview.notProvided') }}
|
||||
</span>
|
||||
</PreviewField>
|
||||
|
||||
<!-- Gallery -->
|
||||
<PreviewField :label="t('templatePublishing.steps.preview.galleryLabel')">
|
||||
<div
|
||||
v-if="assets.galleryImages.value.length > 0"
|
||||
class="flex flex-wrap gap-2"
|
||||
>
|
||||
<img
|
||||
v-for="(img, i) in assets.galleryImages.value"
|
||||
:key="img.originalName + i"
|
||||
:src="img.objectUrl"
|
||||
:alt="img.originalName"
|
||||
class="h-20 w-20 rounded-lg object-cover"
|
||||
/>
|
||||
</div>
|
||||
<span v-else class="text-sm text-muted-foreground">
|
||||
{{ t('templatePublishing.steps.preview.notProvided') }}
|
||||
</span>
|
||||
</PreviewField>
|
||||
</PreviewSection>
|
||||
|
||||
<!-- Categories & Tags -->
|
||||
<PreviewSection
|
||||
:label="t('templatePublishing.steps.preview.sectionCategoriesAndTags')"
|
||||
@edit="ctx.goToStep(5)"
|
||||
>
|
||||
<PreviewField
|
||||
:label="t('templatePublishing.steps.metadata.categoryLabel')"
|
||||
>
|
||||
<div
|
||||
v-if="(tpl.categories ?? []).length > 0"
|
||||
class="flex flex-wrap gap-1"
|
||||
>
|
||||
<span
|
||||
v-for="cat in tpl.categories"
|
||||
:key="cat"
|
||||
class="rounded-full bg-comfy-input-background px-2 py-0.5 text-xs"
|
||||
>
|
||||
{{ categoryDisplayName(cat) }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-else class="text-sm text-muted-foreground">
|
||||
{{ t('templatePublishing.steps.preview.notProvided') }}
|
||||
</span>
|
||||
</PreviewField>
|
||||
|
||||
<PreviewField :label="t('templatePublishing.steps.metadata.tagsLabel')">
|
||||
<div v-if="(tpl.tags ?? []).length > 0" class="flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="tag in tpl.tags"
|
||||
:key="tag"
|
||||
class="rounded-full bg-comfy-input-background px-2 py-0.5 text-xs"
|
||||
>
|
||||
{{ tag }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-else class="text-sm text-muted-foreground">
|
||||
{{ t('templatePublishing.steps.preview.notProvided') }}
|
||||
</span>
|
||||
</PreviewField>
|
||||
</PreviewSection>
|
||||
|
||||
<!-- Correct button -->
|
||||
<div class="flex justify-end pt-2">
|
||||
<Button size="lg" @click="ctx.nextStep()">
|
||||
<i class="icon-[lucide--check] mr-1" />
|
||||
{{ t('templatePublishing.steps.preview.correct') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { formatSize } from '@/utils/formatUtil'
|
||||
import { computed, inject } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { LicenseType } from '@/types/templateMarketplace'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTemplatePreviewAssets } from '@/composables/useTemplatePreviewAssets'
|
||||
import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil'
|
||||
|
||||
import { PublishingStepperKey } from '../types'
|
||||
import PreviewField from './preview/PreviewField.vue'
|
||||
import PreviewSection from './preview/PreviewSection.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const ctx = inject(PublishingStepperKey)!
|
||||
const assets = useTemplatePreviewAssets()
|
||||
|
||||
const tpl = computed(() => ctx.template.value)
|
||||
|
||||
const renderedDescription = computed(() =>
|
||||
renderMarkdownToHtml(tpl.value.description ?? '')
|
||||
)
|
||||
|
||||
const CATEGORY_KEY_MAP: Record<string, string> = {
|
||||
'3d': 'threeD',
|
||||
audio: 'audio',
|
||||
controlnet: 'controlNet',
|
||||
'image-generation': 'imageGeneration',
|
||||
inpainting: 'inpainting',
|
||||
other: 'other',
|
||||
'style-transfer': 'styleTransfer',
|
||||
text: 'text',
|
||||
upscaling: 'upscaling',
|
||||
'video-generation': 'videoGeneration'
|
||||
}
|
||||
|
||||
function categoryDisplayName(value: string): string {
|
||||
const key = CATEGORY_KEY_MAP[value]
|
||||
if (!key) return value
|
||||
return t(`templatePublishing.steps.metadata.category.${key}`)
|
||||
}
|
||||
|
||||
const LICENSE_KEY_MAP: Record<string, string> = {
|
||||
mit: 'mit',
|
||||
'cc-by': 'ccBy',
|
||||
'cc-by-sa': 'ccBySa',
|
||||
'cc-by-nc': 'ccByNc',
|
||||
apache: 'apache',
|
||||
custom: 'custom'
|
||||
}
|
||||
|
||||
const licenseLabel = computed(() => {
|
||||
const license = tpl.value.license
|
||||
if (!license) return t('templatePublishing.steps.preview.notProvided')
|
||||
const key = LICENSE_KEY_MAP[license as LicenseType]
|
||||
if (!key) return license
|
||||
return t(`templatePublishing.steps.metadata.license.${key}`)
|
||||
})
|
||||
|
||||
const difficultyLabel = computed(() => {
|
||||
const difficulty = tpl.value.difficulty
|
||||
if (!difficulty) return t('templatePublishing.steps.preview.notProvided')
|
||||
return t(`templatePublishing.steps.metadata.difficulty.${difficulty}`)
|
||||
})
|
||||
|
||||
const vramLabel = computed(() => {
|
||||
const vram = tpl.value.vramRequirement
|
||||
if (!vram) return t('templatePublishing.steps.preview.notProvided')
|
||||
return formatSize(vram)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,239 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { computed, ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useTemplatePreviewAssets } from '@/composables/useTemplatePreviewAssets'
|
||||
import type { MarketplaceTemplate } from '@/types/templateMarketplace'
|
||||
|
||||
import type { PublishingStepperContext } from '../types'
|
||||
import { PublishingStepperKey } from '../types'
|
||||
import StepTemplatePublishingPreviewGeneration from './StepTemplatePublishingPreviewGeneration.vue'
|
||||
|
||||
let blobCounter = 0
|
||||
URL.createObjectURL = vi.fn(() => `blob:http://localhost/mock-${++blobCounter}`)
|
||||
URL.revokeObjectURL = vi.fn()
|
||||
|
||||
vi.mock('@vueuse/core', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>
|
||||
return {
|
||||
...actual,
|
||||
watchDebounced: vi.fn((source: unknown, cb: unknown, opts: unknown) => {
|
||||
const typedActual = actual as {
|
||||
watchDebounced: (...args: unknown[]) => unknown
|
||||
}
|
||||
return typedActual.watchDebounced(source, cb, {
|
||||
...(opts as object),
|
||||
debounce: 0
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
templatePublishing: {
|
||||
steps: {
|
||||
previewGeneration: {
|
||||
thumbnailLabel: 'Thumbnail',
|
||||
thumbnailHint: 'Primary image shown in marketplace listings',
|
||||
comparisonLabel: 'Before & After Comparison',
|
||||
comparisonHint: 'Show what the workflow transforms',
|
||||
beforeImageLabel: 'Before',
|
||||
afterImageLabel: 'After',
|
||||
workflowPreviewLabel: 'Workflow Graph',
|
||||
workflowPreviewHint: 'Screenshot of the workflow graph layout',
|
||||
videoPreviewLabel: 'Video Preview',
|
||||
videoPreviewHint: 'Optional short video demonstrating the workflow',
|
||||
galleryLabel: 'Example Gallery',
|
||||
galleryHint: 'Up to {max} example output images',
|
||||
uploadPrompt: 'Click to upload',
|
||||
removeFile: 'Remove'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function createContext(
|
||||
templateData: Partial<MarketplaceTemplate> = {}
|
||||
): PublishingStepperContext {
|
||||
const template = ref<Partial<MarketplaceTemplate>>(templateData)
|
||||
const currentStep = ref(4)
|
||||
return {
|
||||
currentStep,
|
||||
totalSteps: 8,
|
||||
isFirstStep: computed(() => currentStep.value === 1),
|
||||
isLastStep: computed(() => currentStep.value === 8),
|
||||
canProceed: computed(() => false),
|
||||
template,
|
||||
nextStep: vi.fn(),
|
||||
prevStep: vi.fn(),
|
||||
goToStep: vi.fn(),
|
||||
saveDraft: vi.fn(),
|
||||
setStepValid: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
function mountStep(ctx?: PublishingStepperContext) {
|
||||
const context = ctx ?? createContext()
|
||||
return {
|
||||
wrapper: mount(StepTemplatePublishingPreviewGeneration, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
provide: { [PublishingStepperKey as symbol]: context }
|
||||
}
|
||||
}),
|
||||
ctx: context
|
||||
}
|
||||
}
|
||||
|
||||
describe('StepTemplatePublishingPreviewGeneration', () => {
|
||||
beforeEach(() => {
|
||||
const assets = useTemplatePreviewAssets()
|
||||
assets.clearAll()
|
||||
vi.clearAllMocks()
|
||||
blobCounter = 0
|
||||
})
|
||||
|
||||
it('renders all upload sections', () => {
|
||||
const { wrapper } = mountStep()
|
||||
|
||||
expect(wrapper.text()).toContain('Thumbnail')
|
||||
expect(wrapper.text()).toContain('Before & After Comparison')
|
||||
expect(wrapper.text()).toContain('Workflow Graph')
|
||||
expect(wrapper.text()).toContain('Video Preview')
|
||||
expect(wrapper.text()).toContain('Example Gallery')
|
||||
})
|
||||
|
||||
it('renders before and after upload zones side by side', () => {
|
||||
const { wrapper } = mountStep()
|
||||
|
||||
expect(wrapper.text()).toContain('Before')
|
||||
expect(wrapper.text()).toContain('After')
|
||||
})
|
||||
|
||||
it('updates template thumbnail on upload', () => {
|
||||
const ctx = createContext()
|
||||
const { wrapper } = mountStep(ctx)
|
||||
|
||||
const uploadZones = wrapper.findAllComponents({
|
||||
name: 'TemplateAssetUploadZone'
|
||||
})
|
||||
uploadZones[0].vm.$emit('upload', new File([''], 'thumb.png'))
|
||||
|
||||
expect(ctx.template.value.thumbnail).toMatch(/^blob:/)
|
||||
})
|
||||
|
||||
it('clears template thumbnail on remove', () => {
|
||||
const assets = useTemplatePreviewAssets()
|
||||
assets.setThumbnail(new File([''], 'thumb.png'))
|
||||
|
||||
const ctx = createContext({ thumbnail: 'blob:old' })
|
||||
const { wrapper } = mountStep(ctx)
|
||||
|
||||
const uploadZones = wrapper.findAllComponents({
|
||||
name: 'TemplateAssetUploadZone'
|
||||
})
|
||||
uploadZones[0].vm.$emit('remove')
|
||||
|
||||
expect(ctx.template.value.thumbnail).toBe('')
|
||||
expect(assets.thumbnail.value).toBeNull()
|
||||
})
|
||||
|
||||
it('updates template beforeImage on upload', () => {
|
||||
const ctx = createContext()
|
||||
const { wrapper } = mountStep(ctx)
|
||||
|
||||
const uploadZones = wrapper.findAllComponents({
|
||||
name: 'TemplateAssetUploadZone'
|
||||
})
|
||||
uploadZones[1].vm.$emit('upload', new File([''], 'before.png'))
|
||||
|
||||
expect(ctx.template.value.beforeImage).toMatch(/^blob:/)
|
||||
})
|
||||
|
||||
it('updates template afterImage on upload', () => {
|
||||
const ctx = createContext()
|
||||
const { wrapper } = mountStep(ctx)
|
||||
|
||||
const uploadZones = wrapper.findAllComponents({
|
||||
name: 'TemplateAssetUploadZone'
|
||||
})
|
||||
uploadZones[2].vm.$emit('upload', new File([''], 'after.png'))
|
||||
|
||||
expect(ctx.template.value.afterImage).toMatch(/^blob:/)
|
||||
})
|
||||
|
||||
it('updates template workflowPreview on upload', () => {
|
||||
const ctx = createContext()
|
||||
const { wrapper } = mountStep(ctx)
|
||||
|
||||
const uploadZones = wrapper.findAllComponents({
|
||||
name: 'TemplateAssetUploadZone'
|
||||
})
|
||||
uploadZones[3].vm.$emit('upload', new File([''], 'graph.png'))
|
||||
|
||||
expect(ctx.template.value.workflowPreview).toMatch(/^blob:/)
|
||||
})
|
||||
|
||||
it('updates template videoPreview on upload', () => {
|
||||
const ctx = createContext()
|
||||
const { wrapper } = mountStep(ctx)
|
||||
|
||||
const uploadZones = wrapper.findAllComponents({
|
||||
name: 'TemplateAssetUploadZone'
|
||||
})
|
||||
uploadZones[4].vm.$emit(
|
||||
'upload',
|
||||
new File([''], 'demo.mp4', { type: 'video/mp4' })
|
||||
)
|
||||
|
||||
expect(ctx.template.value.videoPreview).toMatch(/^blob:/)
|
||||
})
|
||||
|
||||
it('shows the gallery add button when gallery is empty', () => {
|
||||
const { wrapper } = mountStep()
|
||||
|
||||
const addButton = wrapper.find('[role="button"]')
|
||||
expect(addButton.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('adds gallery images to the template on upload', async () => {
|
||||
const ctx = createContext({ gallery: [] })
|
||||
const { wrapper } = mountStep(ctx)
|
||||
|
||||
const galleryInput = wrapper.find('input[multiple]')
|
||||
const file = new File([''], 'output.png', { type: 'image/png' })
|
||||
Object.defineProperty(galleryInput.element, 'files', { value: [file] })
|
||||
await galleryInput.trigger('change')
|
||||
|
||||
expect(ctx.template.value.gallery).toHaveLength(1)
|
||||
expect(ctx.template.value.gallery![0].url).toMatch(/^blob:/)
|
||||
expect(ctx.template.value.gallery![0].caption).toBe('output.png')
|
||||
})
|
||||
|
||||
it('removes a gallery image by index', async () => {
|
||||
const assets = useTemplatePreviewAssets()
|
||||
assets.addGalleryImage(new File([''], 'a.png'))
|
||||
assets.addGalleryImage(new File([''], 'b.png'))
|
||||
|
||||
const ctx = createContext({
|
||||
gallery: [
|
||||
{ type: 'image', url: 'blob:a', caption: 'a.png' },
|
||||
{ type: 'image', url: 'blob:b', caption: 'b.png' }
|
||||
]
|
||||
})
|
||||
const { wrapper } = mountStep(ctx)
|
||||
|
||||
const removeButtons = wrapper.findAll('button[aria-label="Remove"]')
|
||||
await removeButtons[0].trigger('click')
|
||||
|
||||
expect(ctx.template.value.gallery).toHaveLength(1)
|
||||
expect(ctx.template.value.gallery![0].caption).toBe('b.png')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,258 @@
|
||||
<!--
|
||||
Step 4 of the template publishing wizard. Collects preview assets:
|
||||
thumbnail, before/after comparison, workflow graph, optional video,
|
||||
and an optional gallery of up to six example output images.
|
||||
-->
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 overflow-y-auto p-6">
|
||||
<!-- Thumbnail -->
|
||||
<section class="flex flex-col gap-1">
|
||||
<span class="text-sm font-medium text-muted">
|
||||
{{ t('templatePublishing.steps.previewGeneration.thumbnailLabel') }}
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ t('templatePublishing.steps.previewGeneration.thumbnailHint') }}
|
||||
</span>
|
||||
<TemplateAssetUploadZone
|
||||
:asset="assets.thumbnail.value"
|
||||
size-class="h-40 w-64"
|
||||
@upload="onThumbnailUpload"
|
||||
@remove="onThumbnailRemove"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<!-- Before & After Comparison -->
|
||||
<section class="flex flex-col gap-1">
|
||||
<span class="text-sm font-medium text-muted">
|
||||
{{ t('templatePublishing.steps.previewGeneration.comparisonLabel') }}
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ t('templatePublishing.steps.previewGeneration.comparisonHint') }}
|
||||
</span>
|
||||
<div class="flex flex-row gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{
|
||||
t('templatePublishing.steps.previewGeneration.beforeImageLabel')
|
||||
}}
|
||||
</span>
|
||||
<TemplateAssetUploadZone
|
||||
:asset="assets.beforeImage.value"
|
||||
@upload="onBeforeUpload"
|
||||
@remove="onBeforeRemove"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{
|
||||
t('templatePublishing.steps.previewGeneration.afterImageLabel')
|
||||
}}
|
||||
</span>
|
||||
<TemplateAssetUploadZone
|
||||
:asset="assets.afterImage.value"
|
||||
@upload="onAfterUpload"
|
||||
@remove="onAfterRemove"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Workflow Graph Preview -->
|
||||
<section class="flex flex-col gap-1">
|
||||
<span class="text-sm font-medium text-muted">
|
||||
{{
|
||||
t('templatePublishing.steps.previewGeneration.workflowPreviewLabel')
|
||||
}}
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{
|
||||
t('templatePublishing.steps.previewGeneration.workflowPreviewHint')
|
||||
}}
|
||||
</span>
|
||||
<TemplateAssetUploadZone
|
||||
:asset="assets.workflowPreview.value"
|
||||
size-class="h-40 w-72"
|
||||
@upload="onWorkflowUpload"
|
||||
@remove="onWorkflowRemove"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<!-- Video Preview (optional) -->
|
||||
<section class="flex flex-col gap-1">
|
||||
<span class="text-sm font-medium text-muted">
|
||||
{{ t('templatePublishing.steps.previewGeneration.videoPreviewLabel') }}
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ t('templatePublishing.steps.previewGeneration.videoPreviewHint') }}
|
||||
</span>
|
||||
<TemplateAssetUploadZone
|
||||
:asset="assets.videoPreview.value"
|
||||
accept="video/*"
|
||||
preview-type="video"
|
||||
size-class="h-40 w-72"
|
||||
@upload="onVideoUpload"
|
||||
@remove="onVideoRemove"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<!-- Example Output Gallery -->
|
||||
<section class="flex flex-col gap-1">
|
||||
<span class="text-sm font-medium text-muted">
|
||||
{{ t('templatePublishing.steps.previewGeneration.galleryLabel') }}
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{
|
||||
t('templatePublishing.steps.previewGeneration.galleryHint', {
|
||||
max: MAX_GALLERY_IMAGES
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<div
|
||||
v-for="(asset, index) in assets.galleryImages.value"
|
||||
:key="asset.originalName + index"
|
||||
class="group relative h-28 w-28 overflow-hidden rounded-lg"
|
||||
>
|
||||
<img
|
||||
:src="asset.objectUrl"
|
||||
:alt="asset.originalName"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-x-0 bottom-0 flex items-center justify-between bg-black/60 px-1.5 py-0.5 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
>
|
||||
<span class="truncate text-[10px] text-white">
|
||||
{{ asset.originalName }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 text-white hover:text-danger"
|
||||
:aria-label="
|
||||
t('templatePublishing.steps.previewGeneration.removeFile')
|
||||
"
|
||||
@click="onGalleryRemove(index)"
|
||||
>
|
||||
<i class="icon-[lucide--x] h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="assets.galleryImages.value.length < MAX_GALLERY_IMAGES"
|
||||
class="flex h-28 w-28 cursor-pointer items-center justify-center rounded-lg border-2 border-dashed border-border-default hover:border-muted-foreground"
|
||||
role="button"
|
||||
:tabindex="0"
|
||||
:aria-label="
|
||||
t('templatePublishing.steps.previewGeneration.uploadPrompt')
|
||||
"
|
||||
@click="galleryInput?.click()"
|
||||
@keydown.enter="galleryInput?.click()"
|
||||
>
|
||||
<i class="icon-[lucide--plus] h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
ref="galleryInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
class="hidden"
|
||||
@change="onGallerySelect"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { inject, ref } from 'vue'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import {
|
||||
MAX_GALLERY_IMAGES,
|
||||
useTemplatePreviewAssets
|
||||
} from '@/composables/useTemplatePreviewAssets'
|
||||
|
||||
import { PublishingStepperKey } from '../types'
|
||||
import TemplateAssetUploadZone from '../TemplateAssetUploadZone.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const ctx = inject(PublishingStepperKey)!
|
||||
const assets = useTemplatePreviewAssets()
|
||||
const galleryInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
function onThumbnailUpload(file: File) {
|
||||
ctx.template.value.thumbnail = assets.setThumbnail(file)
|
||||
}
|
||||
|
||||
function onThumbnailRemove() {
|
||||
assets.clearThumbnail()
|
||||
ctx.template.value.thumbnail = ''
|
||||
}
|
||||
|
||||
function onBeforeUpload(file: File) {
|
||||
ctx.template.value.beforeImage = assets.setBeforeImage(file)
|
||||
}
|
||||
|
||||
function onBeforeRemove() {
|
||||
assets.clearBeforeImage()
|
||||
ctx.template.value.beforeImage = undefined
|
||||
}
|
||||
|
||||
function onAfterUpload(file: File) {
|
||||
ctx.template.value.afterImage = assets.setAfterImage(file)
|
||||
}
|
||||
|
||||
function onAfterRemove() {
|
||||
assets.clearAfterImage()
|
||||
ctx.template.value.afterImage = undefined
|
||||
}
|
||||
|
||||
function onWorkflowUpload(file: File) {
|
||||
ctx.template.value.workflowPreview = assets.setWorkflowPreview(file)
|
||||
}
|
||||
|
||||
function onWorkflowRemove() {
|
||||
assets.clearWorkflowPreview()
|
||||
ctx.template.value.workflowPreview = ''
|
||||
}
|
||||
|
||||
function onVideoUpload(file: File) {
|
||||
ctx.template.value.videoPreview = assets.setVideoPreview(file)
|
||||
}
|
||||
|
||||
function onVideoRemove() {
|
||||
assets.clearVideoPreview()
|
||||
ctx.template.value.videoPreview = undefined
|
||||
}
|
||||
|
||||
function onGallerySelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const files = input.files
|
||||
if (!files) return
|
||||
|
||||
for (const file of files) {
|
||||
const url = assets.addGalleryImage(file)
|
||||
if (url) {
|
||||
const gallery = ctx.template.value.gallery ?? []
|
||||
ctx.template.value.gallery = [
|
||||
...gallery,
|
||||
{ type: 'image', url, caption: file.name }
|
||||
]
|
||||
}
|
||||
}
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
function onGalleryRemove(index: number) {
|
||||
assets.removeGalleryImage(index)
|
||||
const gallery = ctx.template.value.gallery ?? []
|
||||
ctx.template.value.gallery = gallery.filter((_, i) => i !== index)
|
||||
}
|
||||
|
||||
watchDebounced(
|
||||
() => ctx.template.value,
|
||||
() => ctx.saveDraft(),
|
||||
{ deep: true, debounce: 500 }
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,84 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { computed, ref } from 'vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { MarketplaceTemplate } from '@/types/templateMarketplace'
|
||||
|
||||
import type { PublishingStepperContext } from '../types'
|
||||
import { PublishingStepperKey } from '../types'
|
||||
import StepTemplatePublishingSubmissionForReview from './StepTemplatePublishingSubmissionForReview.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
templatePublishing: {
|
||||
submit: 'Submit for Review',
|
||||
steps: {
|
||||
submissionForReview: {
|
||||
title: 'Submit',
|
||||
description: 'Submit your template for review.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function createContext(
|
||||
overrides: Partial<PublishingStepperContext> = {}
|
||||
): PublishingStepperContext {
|
||||
const template = ref<Partial<MarketplaceTemplate>>({})
|
||||
const currentStep = ref(7)
|
||||
return {
|
||||
currentStep,
|
||||
totalSteps: 8,
|
||||
isFirstStep: computed(() => currentStep.value === 1),
|
||||
isLastStep: computed(() => currentStep.value === 8),
|
||||
canProceed: computed(() => false),
|
||||
template,
|
||||
nextStep: vi.fn(),
|
||||
prevStep: vi.fn(),
|
||||
goToStep: vi.fn(),
|
||||
saveDraft: vi.fn(),
|
||||
setStepValid: vi.fn(),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function mountStep(ctx?: PublishingStepperContext) {
|
||||
const context = ctx ?? createContext()
|
||||
return {
|
||||
wrapper: mount(StepTemplatePublishingSubmissionForReview, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
provide: { [PublishingStepperKey as symbol]: context }
|
||||
}
|
||||
}),
|
||||
ctx: context
|
||||
}
|
||||
}
|
||||
|
||||
describe('StepTemplatePublishingSubmissionForReview', () => {
|
||||
it('renders the description text', () => {
|
||||
const { wrapper } = mountStep()
|
||||
expect(wrapper.text()).toContain('Submit your template for review.')
|
||||
})
|
||||
|
||||
it('renders a submit button', () => {
|
||||
const { wrapper } = mountStep()
|
||||
const button = wrapper.find('button')
|
||||
expect(button.exists()).toBe(true)
|
||||
expect(button.text()).toBe('Submit for Review')
|
||||
})
|
||||
|
||||
it('calls nextStep when the submit button is clicked', async () => {
|
||||
const ctx = createContext()
|
||||
const { wrapper } = mountStep(ctx)
|
||||
const button = wrapper.find('button')
|
||||
await button.trigger('click')
|
||||
expect(ctx.nextStep).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 p-6">
|
||||
<p class="text-muted-foreground">
|
||||
{{ t('templatePublishing.steps.submissionForReview.description') }}
|
||||
</p>
|
||||
<div class="flex justify-end">
|
||||
<Button size="lg" @click="stepper.nextStep()">
|
||||
{{ t('templatePublishing.submit') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { inject } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { PublishingStepperKey } from '@/components/templatePublishing/types'
|
||||
|
||||
const { t } = useI18n()
|
||||
const stepper = inject(PublishingStepperKey)!
|
||||
</script>
|
||||
@@ -0,0 +1,28 @@
|
||||
<!--
|
||||
A labeled field within a preview section. Shows a label on the left
|
||||
and either the value text or a default slot on the right.
|
||||
-->
|
||||
<template>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-xs font-medium text-muted-foreground">{{ label }}</span>
|
||||
<div class="text-sm">
|
||||
<slot>
|
||||
<span v-if="value">{{ value }}</span>
|
||||
<span v-else class="text-muted-foreground">
|
||||
{{ t('templatePublishing.steps.preview.notProvided') }}
|
||||
</span>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
defineProps<{
|
||||
label: string
|
||||
value?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
@@ -0,0 +1,38 @@
|
||||
<!--
|
||||
A collapsible section in the preview step, showing a heading with an
|
||||
"Edit" button that navigates back to the originating step.
|
||||
-->
|
||||
<template>
|
||||
<section class="flex flex-col gap-3">
|
||||
<div
|
||||
class="flex items-center justify-between border-b border-border-default pb-1"
|
||||
>
|
||||
<h3 class="text-sm font-semibold text-muted">{{ label }}</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1 text-xs text-muted-foreground hover:text-base-foreground"
|
||||
@click="emit('edit')"
|
||||
>
|
||||
<i class="icon-[lucide--pencil] h-3 w-3" />
|
||||
{{ t('templatePublishing.steps.preview.editStep') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 pl-1">
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
defineProps<{
|
||||
label: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
edit: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
83
src/components/templatePublishing/types.ts
Normal file
83
src/components/templatePublishing/types.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import type { InjectionKey, Ref } from 'vue'
|
||||
|
||||
import type { MarketplaceTemplate } from '@/types/templateMarketplace'
|
||||
|
||||
/**
|
||||
* Definition of a single step in the template publishing wizard.
|
||||
*/
|
||||
export interface PublishingStepDefinition {
|
||||
/** 1-indexed step number */
|
||||
number: number
|
||||
/** i18n key for the step's display title */
|
||||
titleKey: string
|
||||
/** i18n key for the step's short description */
|
||||
descriptionKey: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Context shared between the publishing dialog and its step panels
|
||||
* via provide/inject.
|
||||
*/
|
||||
export interface PublishingStepperContext {
|
||||
currentStep: Readonly<Ref<number>>
|
||||
totalSteps: number
|
||||
isFirstStep: Readonly<Ref<boolean>>
|
||||
isLastStep: Readonly<Ref<boolean>>
|
||||
canProceed: Readonly<Ref<boolean>>
|
||||
template: Ref<Partial<MarketplaceTemplate>>
|
||||
nextStep: () => void
|
||||
prevStep: () => void
|
||||
goToStep: (step: number) => void
|
||||
saveDraft: () => void
|
||||
setStepValid: (step: number, valid: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Injection key for the publishing stepper context, allowing step panel
|
||||
* components to access shared navigation and draft state.
|
||||
*/
|
||||
export const PublishingStepperKey: InjectionKey<PublishingStepperContext> =
|
||||
Symbol('PublishingStepperContext')
|
||||
|
||||
export const PUBLISHING_STEP_DEFINITIONS: PublishingStepDefinition[] = [
|
||||
{
|
||||
number: 1,
|
||||
titleKey: 'templatePublishing.steps.landing.title',
|
||||
descriptionKey: 'templatePublishing.steps.landing.description'
|
||||
},
|
||||
{
|
||||
number: 2,
|
||||
titleKey: 'templatePublishing.steps.metadata.title',
|
||||
descriptionKey: 'templatePublishing.steps.metadata.description'
|
||||
},
|
||||
{
|
||||
number: 3,
|
||||
titleKey: 'templatePublishing.steps.description.title',
|
||||
descriptionKey: 'templatePublishing.steps.description.description'
|
||||
},
|
||||
{
|
||||
number: 4,
|
||||
titleKey: 'templatePublishing.steps.previewGeneration.title',
|
||||
descriptionKey: 'templatePublishing.steps.previewGeneration.description'
|
||||
},
|
||||
{
|
||||
number: 5,
|
||||
titleKey: 'templatePublishing.steps.categoryAndTagging.title',
|
||||
descriptionKey: 'templatePublishing.steps.categoryAndTagging.description'
|
||||
},
|
||||
{
|
||||
number: 6,
|
||||
titleKey: 'templatePublishing.steps.preview.title',
|
||||
descriptionKey: 'templatePublishing.steps.preview.description'
|
||||
},
|
||||
{
|
||||
number: 7,
|
||||
titleKey: 'templatePublishing.steps.submissionForReview.title',
|
||||
descriptionKey: 'templatePublishing.steps.submissionForReview.description'
|
||||
},
|
||||
{
|
||||
number: 8,
|
||||
titleKey: 'templatePublishing.steps.complete.title',
|
||||
descriptionKey: 'templatePublishing.steps.complete.description'
|
||||
}
|
||||
]
|
||||
@@ -307,6 +307,18 @@ describe('useCoreCommands', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('ShowDeveloperProfile command', () => {
|
||||
it('is registered as a command', () => {
|
||||
const commands = useCoreCommands()
|
||||
const command = commands.find(
|
||||
(cmd) => cmd.id === 'Comfy.ShowDeveloperProfile'
|
||||
)
|
||||
|
||||
expect(command).toBeDefined()
|
||||
expect(command!.label).toBe('Developer Profile')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Canvas clipboard commands', () => {
|
||||
function findCommand(id: string) {
|
||||
return useCoreCommands().find((cmd) => cmd.id === id)!
|
||||
|
||||
@@ -65,6 +65,8 @@ import {
|
||||
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
|
||||
import { useWorkflowTemplateSelectorDialog } from './useWorkflowTemplateSelectorDialog'
|
||||
import { useDeveloperProfileDialog } from './useDeveloperProfileDialog'
|
||||
import { useTemplatePublishingDialog } from './useTemplatePublishingDialog'
|
||||
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
@@ -337,6 +339,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
}
|
||||
},
|
||||
{
|
||||
// comeback
|
||||
id: 'Comfy.BrowseTemplates',
|
||||
icon: 'pi pi-folder-open',
|
||||
label: 'Browse Templates',
|
||||
@@ -344,6 +347,23 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
useWorkflowTemplateSelectorDialog().show()
|
||||
}
|
||||
},
|
||||
{
|
||||
// comeback
|
||||
id: 'Comfy.ShowTemplatePublishing',
|
||||
icon: 'pi pi-objects-column',
|
||||
label: t('templatePublishing.dialogTitle'),
|
||||
function: () => {
|
||||
useTemplatePublishingDialog().show()
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.ShowDeveloperProfile',
|
||||
icon: 'pi pi-user',
|
||||
label: t('developerProfile.dialogTitle'),
|
||||
function: () => {
|
||||
useDeveloperProfileDialog().show()
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Canvas.ZoomIn',
|
||||
icon: 'pi pi-plus',
|
||||
|
||||
91
src/composables/useDeveloperProfileDialog.test.ts
Normal file
91
src/composables/useDeveloperProfileDialog.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockDialogService = vi.hoisted(() => ({
|
||||
showLayoutDialog: vi.fn()
|
||||
}))
|
||||
|
||||
const mockDialogStore = vi.hoisted(() => ({
|
||||
closeDialog: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: () => mockDialogService
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => mockDialogStore
|
||||
}))
|
||||
|
||||
vi.mock('@/components/developerProfile/DeveloperProfileDialog.vue', () => ({
|
||||
default: { name: 'MockDeveloperProfileDialog' }
|
||||
}))
|
||||
|
||||
import { useDeveloperProfileDialog } from './useDeveloperProfileDialog'
|
||||
|
||||
describe('useDeveloperProfileDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('show', () => {
|
||||
it('opens the dialog via dialogService', () => {
|
||||
const { show } = useDeveloperProfileDialog()
|
||||
show()
|
||||
|
||||
expect(mockDialogService.showLayoutDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
key: 'global-developer-profile'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('passes username to the dialog component', () => {
|
||||
const { show } = useDeveloperProfileDialog()
|
||||
show('@TestUser')
|
||||
|
||||
expect(mockDialogService.showLayoutDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
props: expect.objectContaining({
|
||||
username: '@TestUser'
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('passes undefined username when no argument given', () => {
|
||||
const { show } = useDeveloperProfileDialog()
|
||||
show()
|
||||
|
||||
expect(mockDialogService.showLayoutDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
props: expect.objectContaining({
|
||||
username: undefined
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('provides an onClose callback that closes the dialog', () => {
|
||||
const { show } = useDeveloperProfileDialog()
|
||||
show()
|
||||
|
||||
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
|
||||
call.props.onClose()
|
||||
|
||||
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
|
||||
key: 'global-developer-profile'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('hide', () => {
|
||||
it('closes the dialog via dialogStore', () => {
|
||||
const { hide } = useDeveloperProfileDialog()
|
||||
hide()
|
||||
|
||||
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
|
||||
key: 'global-developer-profile'
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
32
src/composables/useDeveloperProfileDialog.ts
Normal file
32
src/composables/useDeveloperProfileDialog.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import DeveloperProfileDialog from '@/components/developerProfile/DeveloperProfileDialog.vue'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const DIALOG_KEY = 'global-developer-profile'
|
||||
|
||||
/**
|
||||
* Manages the lifecycle of the developer profile dialog.
|
||||
*
|
||||
* @returns `show` to open the dialog for a given username, and `hide` to close it.
|
||||
*/
|
||||
export function useDeveloperProfileDialog() {
|
||||
const dialogService = useDialogService()
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
function hide() {
|
||||
dialogStore.closeDialog({ key: DIALOG_KEY })
|
||||
}
|
||||
|
||||
function show(username?: string) {
|
||||
dialogService.showLayoutDialog({
|
||||
key: DIALOG_KEY,
|
||||
component: DeveloperProfileDialog,
|
||||
props: {
|
||||
onClose: hide,
|
||||
username
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return { show, hide }
|
||||
}
|
||||
@@ -176,6 +176,30 @@ describe('useFeatureFlags', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('templateMarketplaceEnabled', () => {
|
||||
it('should return false by default', () => {
|
||||
vi.mocked(api.getServerFeature).mockImplementation(
|
||||
(_path, defaultValue) => defaultValue
|
||||
)
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.templateMarketplaceEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it('should return true when server feature flag is enabled', () => {
|
||||
vi.mocked(api.getServerFeature).mockImplementation(
|
||||
(path, defaultValue) => {
|
||||
if (path === ServerFeatureFlag.TEMPLATE_MARKETPLACE_ENABLED)
|
||||
return true
|
||||
return defaultValue
|
||||
}
|
||||
)
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.templateMarketplaceEnabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dev override via localStorage', () => {
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
|
||||
@@ -23,7 +23,8 @@ export enum ServerFeatureFlag {
|
||||
TEAM_WORKSPACES_ENABLED = 'team_workspaces_enabled',
|
||||
USER_SECRETS_ENABLED = 'user_secrets_enabled',
|
||||
NODE_REPLACEMENTS = 'node_replacements',
|
||||
NODE_LIBRARY_ESSENTIALS_ENABLED = 'node_library_essentials_enabled'
|
||||
NODE_LIBRARY_ESSENTIALS_ENABLED = 'node_library_essentials_enabled',
|
||||
TEMPLATE_MARKETPLACE_ENABLED = 'template_marketplace_enabled'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -120,6 +121,13 @@ export function useFeatureFlags() {
|
||||
get nodeReplacementsEnabled() {
|
||||
return api.getServerFeature(ServerFeatureFlag.NODE_REPLACEMENTS, false)
|
||||
},
|
||||
get templateMarketplaceEnabled() {
|
||||
return resolveFlag(
|
||||
ServerFeatureFlag.TEMPLATE_MARKETPLACE_ENABLED,
|
||||
remoteConfig.value.template_marketplace_enabled,
|
||||
false
|
||||
)
|
||||
},
|
||||
get nodeLibraryEssentialsEnabled() {
|
||||
if (isNightly || import.meta.env.DEV) return true
|
||||
|
||||
|
||||
@@ -44,6 +44,15 @@ vi.mock('@/platform/telemetry', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
const mockGraphNodes = vi.hoisted(() => ({ value: [] as { type: string }[] }))
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
get graph() {
|
||||
return { _nodes: mockGraphNodes.value }
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
const mockGetFuseOptions = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
@@ -280,6 +289,48 @@ describe('useTemplateFiltering', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('sorts by similarity to current workflow nodes', () => {
|
||||
mockGraphNodes.value = [
|
||||
{ type: 'KSampler' },
|
||||
{ type: 'VAEDecode' },
|
||||
{ type: 'CLIPTextEncode' }
|
||||
]
|
||||
|
||||
const templates = ref<TemplateInfo[]>([
|
||||
{
|
||||
name: 'unrelated',
|
||||
description: 'no overlap',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png',
|
||||
requiresCustomNodes: ['ThreeDLoader']
|
||||
},
|
||||
{
|
||||
name: 'best-match',
|
||||
description: 'shares two nodes',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png',
|
||||
requiresCustomNodes: ['KSampler', 'VAEDecode']
|
||||
},
|
||||
{
|
||||
name: 'partial-match',
|
||||
description: 'shares one node',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png',
|
||||
requiresCustomNodes: ['KSampler', 'ThreeDLoader']
|
||||
}
|
||||
])
|
||||
|
||||
const { sortBy, filteredTemplates } = useTemplateFiltering(templates)
|
||||
|
||||
sortBy.value = 'similar-to-current'
|
||||
|
||||
expect(filteredTemplates.value.map((t) => t.name)).toEqual([
|
||||
'best-match',
|
||||
'partial-match',
|
||||
'unrelated'
|
||||
])
|
||||
})
|
||||
|
||||
describe('loadFuseOptions', () => {
|
||||
it('updates fuseOptions when getFuseOptions returns valid options', async () => {
|
||||
const templates = ref<TemplateInfo[]>([
|
||||
|
||||
@@ -7,7 +7,13 @@ import type { Ref } from 'vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useTemplateRankingStore } from '@/stores/templateRankingStore'
|
||||
import type { TemplateSimilarityInput } from '@/utils/templateSimilarity'
|
||||
import {
|
||||
computeSimilarity,
|
||||
toSimilarityInput
|
||||
} from '@/utils/templateSimilarity'
|
||||
import { debounce } from 'es-toolkit/compat'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
@@ -50,6 +56,7 @@ export function useTemplateFiltering(
|
||||
| 'newest'
|
||||
| 'vram-low-to-high'
|
||||
| 'model-size-low-to-high'
|
||||
| 'similar-to-current'
|
||||
>(settingStore.get('Comfy.Templates.SortBy'))
|
||||
|
||||
const fuseOptions = ref<IFuseOptions<TemplateInfo>>(defaultFuseOptions)
|
||||
@@ -270,6 +277,18 @@ export function useTemplateFiltering(
|
||||
if (sizeA === sizeB) return 0
|
||||
return sizeA - sizeB
|
||||
})
|
||||
case 'similar-to-current': {
|
||||
const nodes = app.graph?._nodes ?? []
|
||||
const reference: TemplateSimilarityInput = {
|
||||
name: '',
|
||||
requiredNodes: nodes.map((n) => n.type)
|
||||
}
|
||||
return templates.sort((a, b) => {
|
||||
const scoreA = computeSimilarity(reference, toSimilarityInput(a))
|
||||
const scoreB = computeSimilarity(reference, toSimilarityInput(b))
|
||||
return scoreB - scoreA
|
||||
})
|
||||
}
|
||||
case 'default':
|
||||
default:
|
||||
return templates
|
||||
|
||||
159
src/composables/useTemplatePreviewAssets.test.ts
Normal file
159
src/composables/useTemplatePreviewAssets.test.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
MAX_GALLERY_IMAGES,
|
||||
useTemplatePreviewAssets
|
||||
} from './useTemplatePreviewAssets'
|
||||
|
||||
let blobCounter = 0
|
||||
const mockCreateObjectURL = vi.fn(
|
||||
() => `blob:http://localhost/mock-${++blobCounter}`
|
||||
)
|
||||
const mockRevokeObjectURL = vi.fn()
|
||||
|
||||
URL.createObjectURL = mockCreateObjectURL
|
||||
URL.revokeObjectURL = mockRevokeObjectURL
|
||||
|
||||
function makeFile(name: string): File {
|
||||
return new File(['pixels'], name, { type: 'image/png' })
|
||||
}
|
||||
|
||||
describe('useTemplatePreviewAssets', () => {
|
||||
let assets: ReturnType<typeof useTemplatePreviewAssets>
|
||||
|
||||
beforeEach(() => {
|
||||
assets = useTemplatePreviewAssets()
|
||||
assets.clearAll()
|
||||
vi.clearAllMocks()
|
||||
blobCounter = 0
|
||||
})
|
||||
|
||||
describe('single-asset slots', () => {
|
||||
it('setThumbnail caches the file and returns a blob URL', () => {
|
||||
const file = makeFile('thumb.png')
|
||||
const url = assets.setThumbnail(file)
|
||||
|
||||
expect(url).toMatch(/^blob:/)
|
||||
expect(assets.thumbnail.value).not.toBeNull()
|
||||
expect(assets.thumbnail.value!.originalName).toBe('thumb.png')
|
||||
expect(assets.thumbnail.value!.file.name).toBe(file.name)
|
||||
})
|
||||
|
||||
it('setThumbnail revokes the previous blob URL', () => {
|
||||
assets.setThumbnail(makeFile('a.png'))
|
||||
const firstUrl = assets.thumbnail.value!.objectUrl
|
||||
|
||||
assets.setThumbnail(makeFile('b.png'))
|
||||
|
||||
expect(mockRevokeObjectURL).toHaveBeenCalledWith(firstUrl)
|
||||
})
|
||||
|
||||
it('clearThumbnail revokes and nullifies', () => {
|
||||
assets.setThumbnail(makeFile('thumb.png'))
|
||||
const url = assets.thumbnail.value!.objectUrl
|
||||
|
||||
assets.clearThumbnail()
|
||||
|
||||
expect(assets.thumbnail.value).toBeNull()
|
||||
expect(mockRevokeObjectURL).toHaveBeenCalledWith(url)
|
||||
})
|
||||
|
||||
it('clearThumbnail is safe to call when empty', () => {
|
||||
expect(() => assets.clearThumbnail()).not.toThrow()
|
||||
expect(assets.thumbnail.value).toBeNull()
|
||||
})
|
||||
|
||||
it('setBeforeImage and setAfterImage operate independently', () => {
|
||||
assets.setBeforeImage(makeFile('before.png'))
|
||||
assets.setAfterImage(makeFile('after.png'))
|
||||
|
||||
expect(assets.beforeImage.value!.originalName).toBe('before.png')
|
||||
expect(assets.afterImage.value!.originalName).toBe('after.png')
|
||||
})
|
||||
|
||||
it('setVideoPreview caches a video file', () => {
|
||||
const video = new File(['frames'], 'demo.mp4', { type: 'video/mp4' })
|
||||
assets.setVideoPreview(video)
|
||||
|
||||
expect(assets.videoPreview.value!.originalName).toBe('demo.mp4')
|
||||
})
|
||||
|
||||
it('setWorkflowPreview caches an image file', () => {
|
||||
assets.setWorkflowPreview(makeFile('graph.png'))
|
||||
|
||||
expect(assets.workflowPreview.value!.originalName).toBe('graph.png')
|
||||
})
|
||||
})
|
||||
|
||||
describe('gallery', () => {
|
||||
it('addGalleryImage appends to the gallery and returns the URL', () => {
|
||||
const url = assets.addGalleryImage(makeFile('output1.png'))
|
||||
|
||||
expect(url).toMatch(/^blob:/)
|
||||
expect(assets.galleryImages.value).toHaveLength(1)
|
||||
expect(assets.galleryImages.value[0].originalName).toBe('output1.png')
|
||||
})
|
||||
|
||||
it('addGalleryImage returns null when gallery is full', () => {
|
||||
for (let i = 0; i < MAX_GALLERY_IMAGES; i++) {
|
||||
assets.addGalleryImage(makeFile(`img${i}.png`))
|
||||
}
|
||||
|
||||
const result = assets.addGalleryImage(makeFile('overflow.png'))
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(assets.galleryImages.value).toHaveLength(MAX_GALLERY_IMAGES)
|
||||
})
|
||||
|
||||
it('removeGalleryImage removes by index and revokes its URL', () => {
|
||||
assets.addGalleryImage(makeFile('a.png'))
|
||||
assets.addGalleryImage(makeFile('b.png'))
|
||||
assets.addGalleryImage(makeFile('c.png'))
|
||||
const removedUrl = assets.galleryImages.value[1].objectUrl
|
||||
|
||||
assets.removeGalleryImage(1)
|
||||
|
||||
expect(assets.galleryImages.value).toHaveLength(2)
|
||||
expect(assets.galleryImages.value.map((a) => a.originalName)).toEqual([
|
||||
'a.png',
|
||||
'c.png'
|
||||
])
|
||||
expect(mockRevokeObjectURL).toHaveBeenCalledWith(removedUrl)
|
||||
})
|
||||
|
||||
it('removeGalleryImage is safe with out-of-range index', () => {
|
||||
assets.addGalleryImage(makeFile('a.png'))
|
||||
|
||||
expect(() => assets.removeGalleryImage(99)).not.toThrow()
|
||||
expect(assets.galleryImages.value).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearAll', () => {
|
||||
it('revokes all blob URLs and resets all slots', () => {
|
||||
assets.setThumbnail(makeFile('thumb.png'))
|
||||
assets.setBeforeImage(makeFile('before.png'))
|
||||
assets.setAfterImage(makeFile('after.png'))
|
||||
assets.setVideoPreview(new File(['v'], 'vid.mp4', { type: 'video/mp4' }))
|
||||
assets.setWorkflowPreview(makeFile('graph.png'))
|
||||
assets.addGalleryImage(makeFile('g1.png'))
|
||||
assets.addGalleryImage(makeFile('g2.png'))
|
||||
|
||||
vi.clearAllMocks()
|
||||
assets.clearAll()
|
||||
|
||||
expect(assets.thumbnail.value).toBeNull()
|
||||
expect(assets.beforeImage.value).toBeNull()
|
||||
expect(assets.afterImage.value).toBeNull()
|
||||
expect(assets.videoPreview.value).toBeNull()
|
||||
expect(assets.workflowPreview.value).toBeNull()
|
||||
expect(assets.galleryImages.value).toEqual([])
|
||||
// 5 single slots + 2 gallery = 7 revocations
|
||||
expect(mockRevokeObjectURL).toHaveBeenCalledTimes(7)
|
||||
})
|
||||
})
|
||||
|
||||
it('MAX_GALLERY_IMAGES is 6', () => {
|
||||
expect(MAX_GALLERY_IMAGES).toBe(6)
|
||||
})
|
||||
})
|
||||
121
src/composables/useTemplatePreviewAssets.ts
Normal file
121
src/composables/useTemplatePreviewAssets.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Manages in-memory cached file assets for the template publishing preview
|
||||
* step. Files are held as reactive refs with `blob:` URLs for local display.
|
||||
*
|
||||
* State is module-level so it persists across step navigation within the
|
||||
* publishing dialog but is lost on page reload.
|
||||
*/
|
||||
import type { Ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { CachedAsset } from '@/types/templateMarketplace'
|
||||
|
||||
/** Maximum number of images allowed in the example output gallery. */
|
||||
export const MAX_GALLERY_IMAGES = 6
|
||||
|
||||
const thumbnail = ref<CachedAsset | null>(null)
|
||||
const beforeImage = ref<CachedAsset | null>(null)
|
||||
const afterImage = ref<CachedAsset | null>(null)
|
||||
const videoPreview = ref<CachedAsset | null>(null)
|
||||
const workflowPreview = ref<CachedAsset | null>(null)
|
||||
const galleryImages = ref<CachedAsset[]>([])
|
||||
|
||||
/**
|
||||
* Creates a {@link CachedAsset} from a File, generating a blob URL for
|
||||
* local display.
|
||||
*/
|
||||
function cacheFile(file: File): CachedAsset {
|
||||
return {
|
||||
file,
|
||||
objectUrl: URL.createObjectURL(file),
|
||||
originalName: file.name
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the value of a single-asset ref, revoking the previous blob URL
|
||||
* if one existed.
|
||||
*
|
||||
* @returns The blob URL of the newly cached asset.
|
||||
*/
|
||||
function setAsset(slot: Ref<CachedAsset | null>, file: File): string {
|
||||
if (slot.value) URL.revokeObjectURL(slot.value.objectUrl)
|
||||
slot.value = cacheFile(file)
|
||||
return slot.value.objectUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears a single-asset ref and revokes its blob URL.
|
||||
*/
|
||||
function clearAsset(slot: Ref<CachedAsset | null>): void {
|
||||
if (slot.value) {
|
||||
URL.revokeObjectURL(slot.value.objectUrl)
|
||||
slot.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides reactive access to all preview asset slots and methods to
|
||||
* populate, clear, and query them.
|
||||
*/
|
||||
export function useTemplatePreviewAssets() {
|
||||
/**
|
||||
* Adds an image to the example output gallery.
|
||||
*
|
||||
* @returns The blob URL of the added image, or `null` if the gallery
|
||||
* is already at {@link MAX_GALLERY_IMAGES}.
|
||||
*/
|
||||
function addGalleryImage(file: File): string | null {
|
||||
if (galleryImages.value.length >= MAX_GALLERY_IMAGES) return null
|
||||
const asset = cacheFile(file)
|
||||
galleryImages.value = [...galleryImages.value, asset]
|
||||
return asset.objectUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a gallery image by its index and revokes its blob URL.
|
||||
*/
|
||||
function removeGalleryImage(index: number): void {
|
||||
const asset = galleryImages.value[index]
|
||||
if (!asset) return
|
||||
URL.revokeObjectURL(asset.objectUrl)
|
||||
galleryImages.value = galleryImages.value.filter((_, i) => i !== index)
|
||||
}
|
||||
|
||||
/**
|
||||
* Revokes all blob URLs and resets every asset slot to its empty state.
|
||||
*/
|
||||
function clearAll(): void {
|
||||
clearAsset(thumbnail)
|
||||
clearAsset(beforeImage)
|
||||
clearAsset(afterImage)
|
||||
clearAsset(videoPreview)
|
||||
clearAsset(workflowPreview)
|
||||
for (const asset of galleryImages.value) {
|
||||
URL.revokeObjectURL(asset.objectUrl)
|
||||
}
|
||||
galleryImages.value = []
|
||||
}
|
||||
|
||||
return {
|
||||
thumbnail,
|
||||
beforeImage,
|
||||
afterImage,
|
||||
videoPreview,
|
||||
workflowPreview,
|
||||
galleryImages,
|
||||
setThumbnail: (file: File) => setAsset(thumbnail, file),
|
||||
clearThumbnail: () => clearAsset(thumbnail),
|
||||
setBeforeImage: (file: File) => setAsset(beforeImage, file),
|
||||
clearBeforeImage: () => clearAsset(beforeImage),
|
||||
setAfterImage: (file: File) => setAsset(afterImage, file),
|
||||
clearAfterImage: () => clearAsset(afterImage),
|
||||
setVideoPreview: (file: File) => setAsset(videoPreview, file),
|
||||
clearVideoPreview: () => clearAsset(videoPreview),
|
||||
setWorkflowPreview: (file: File) => setAsset(workflowPreview, file),
|
||||
clearWorkflowPreview: () => clearAsset(workflowPreview),
|
||||
addGalleryImage,
|
||||
removeGalleryImage,
|
||||
clearAll
|
||||
}
|
||||
}
|
||||
91
src/composables/useTemplatePublishingDialog.test.ts
Normal file
91
src/composables/useTemplatePublishingDialog.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockDialogService = vi.hoisted(() => ({
|
||||
showLayoutDialog: vi.fn()
|
||||
}))
|
||||
|
||||
const mockDialogStore = vi.hoisted(() => ({
|
||||
closeDialog: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: () => mockDialogService
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => mockDialogStore
|
||||
}))
|
||||
|
||||
vi.mock('@/components/templatePublishing/TemplatePublishingDialog.vue', () => ({
|
||||
default: { name: 'MockTemplatePublishingDialog' }
|
||||
}))
|
||||
|
||||
import { useTemplatePublishingDialog } from './useTemplatePublishingDialog'
|
||||
|
||||
describe('useTemplatePublishingDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('show', () => {
|
||||
it('opens the dialog via dialogService', () => {
|
||||
const { show } = useTemplatePublishingDialog()
|
||||
show()
|
||||
|
||||
expect(mockDialogService.showLayoutDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
key: 'global-template-publishing'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('passes initialPage to the dialog component', () => {
|
||||
const { show } = useTemplatePublishingDialog()
|
||||
show({ initialPage: 'metadata' })
|
||||
|
||||
expect(mockDialogService.showLayoutDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
props: expect.objectContaining({
|
||||
initialPage: 'metadata'
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('passes undefined initialPage when no options given', () => {
|
||||
const { show } = useTemplatePublishingDialog()
|
||||
show()
|
||||
|
||||
expect(mockDialogService.showLayoutDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
props: expect.objectContaining({
|
||||
initialPage: undefined
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('provides an onClose callback that closes the dialog', () => {
|
||||
const { show } = useTemplatePublishingDialog()
|
||||
show()
|
||||
|
||||
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
|
||||
call.props.onClose()
|
||||
|
||||
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
|
||||
key: 'global-template-publishing'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('hide', () => {
|
||||
it('closes the dialog via dialogStore', () => {
|
||||
const { hide } = useTemplatePublishingDialog()
|
||||
hide()
|
||||
|
||||
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
|
||||
key: 'global-template-publishing'
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
38
src/composables/useTemplatePublishingDialog.ts
Normal file
38
src/composables/useTemplatePublishingDialog.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import TemplatePublishingDialog from '@/components/templatePublishing/TemplatePublishingDialog.vue'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const DIALOG_KEY = 'global-template-publishing'
|
||||
|
||||
/**
|
||||
* Manages the lifecycle of the template publishing dialog.
|
||||
*
|
||||
* @returns `show` to open the dialog and `hide` to close it.
|
||||
*/
|
||||
export const useTemplatePublishingDialog = () => {
|
||||
const dialogService = useDialogService()
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
function hide() {
|
||||
dialogStore.closeDialog({ key: DIALOG_KEY })
|
||||
}
|
||||
|
||||
function show(options?: { initialPage?: string }) {
|
||||
// comeback need a new telemetry for this
|
||||
// useTelemetry()?.trackTemplatePublishingOpened({ source })
|
||||
|
||||
dialogService.showLayoutDialog({
|
||||
key: DIALOG_KEY,
|
||||
component: TemplatePublishingDialog,
|
||||
props: {
|
||||
onClose: hide,
|
||||
initialPage: options?.initialPage
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
show,
|
||||
hide
|
||||
}
|
||||
}
|
||||
131
src/composables/useTemplatePublishingStepper.test.ts
Normal file
131
src/composables/useTemplatePublishingStepper.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockLoad, mockSave } = vi.hoisted(() => ({
|
||||
mockLoad: vi.fn(),
|
||||
mockSave: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workflow/templates/composables/useTemplatePublishStorage',
|
||||
() => ({
|
||||
loadTemplateUnderway: mockLoad,
|
||||
saveTemplateUnderway: mockSave
|
||||
})
|
||||
)
|
||||
|
||||
import { useTemplatePublishingStepper } from './useTemplatePublishingStepper'
|
||||
|
||||
describe('useTemplatePublishingStepper', () => {
|
||||
it('starts at step 1 by default', () => {
|
||||
const { currentStep } = useTemplatePublishingStepper()
|
||||
expect(currentStep.value).toBe(1)
|
||||
})
|
||||
|
||||
it('starts at the given initialStep', () => {
|
||||
const { currentStep } = useTemplatePublishingStepper({ initialStep: 4 })
|
||||
expect(currentStep.value).toBe(4)
|
||||
})
|
||||
|
||||
it('clamps initialStep to valid range', () => {
|
||||
const low = useTemplatePublishingStepper({ initialStep: 0 })
|
||||
expect(low.currentStep.value).toBe(1)
|
||||
|
||||
const high = useTemplatePublishingStepper({ initialStep: 99 })
|
||||
expect(high.currentStep.value).toBe(high.totalSteps)
|
||||
})
|
||||
|
||||
it('nextStep advances by one', () => {
|
||||
const { currentStep, nextStep } = useTemplatePublishingStepper()
|
||||
nextStep()
|
||||
expect(currentStep.value).toBe(2)
|
||||
})
|
||||
|
||||
it('nextStep does not exceed totalSteps', () => {
|
||||
const { currentStep, nextStep, totalSteps } = useTemplatePublishingStepper({
|
||||
initialStep: 8
|
||||
})
|
||||
nextStep()
|
||||
expect(currentStep.value).toBe(totalSteps)
|
||||
})
|
||||
|
||||
it('prevStep goes back by one', () => {
|
||||
const { currentStep, prevStep } = useTemplatePublishingStepper({
|
||||
initialStep: 3
|
||||
})
|
||||
prevStep()
|
||||
expect(currentStep.value).toBe(2)
|
||||
})
|
||||
|
||||
it('prevStep does not go below 1', () => {
|
||||
const { currentStep, prevStep } = useTemplatePublishingStepper()
|
||||
prevStep()
|
||||
expect(currentStep.value).toBe(1)
|
||||
})
|
||||
|
||||
it('goToStep navigates to the given step', () => {
|
||||
const { currentStep, goToStep } = useTemplatePublishingStepper()
|
||||
goToStep(5)
|
||||
expect(currentStep.value).toBe(5)
|
||||
})
|
||||
|
||||
it('goToStep clamps out-of-range values', () => {
|
||||
const { currentStep, goToStep, totalSteps } = useTemplatePublishingStepper()
|
||||
goToStep(100)
|
||||
expect(currentStep.value).toBe(totalSteps)
|
||||
goToStep(-1)
|
||||
expect(currentStep.value).toBe(1)
|
||||
})
|
||||
|
||||
it('isFirstStep and isLastStep reflect current position', () => {
|
||||
const { isFirstStep, isLastStep, nextStep, goToStep, totalSteps } =
|
||||
useTemplatePublishingStepper()
|
||||
|
||||
expect(isFirstStep.value).toBe(true)
|
||||
expect(isLastStep.value).toBe(false)
|
||||
|
||||
nextStep()
|
||||
expect(isFirstStep.value).toBe(false)
|
||||
|
||||
goToStep(totalSteps)
|
||||
expect(isLastStep.value).toBe(true)
|
||||
})
|
||||
|
||||
it('canProceed reflects step validity', () => {
|
||||
const { canProceed, setStepValid } = useTemplatePublishingStepper()
|
||||
expect(canProceed.value).toBe(false)
|
||||
|
||||
setStepValid(1, true)
|
||||
expect(canProceed.value).toBe(true)
|
||||
|
||||
setStepValid(1, false)
|
||||
expect(canProceed.value).toBe(false)
|
||||
})
|
||||
|
||||
it('saveDraft delegates to saveTemplateUnderway', () => {
|
||||
const { template, saveDraft } = useTemplatePublishingStepper()
|
||||
template.value = { title: 'Test Template' }
|
||||
saveDraft()
|
||||
expect(mockSave).toHaveBeenCalledWith({ title: 'Test Template' })
|
||||
})
|
||||
|
||||
it('loads existing draft on initialisation', () => {
|
||||
const draft = { title: 'Saved Draft', description: 'A draft' }
|
||||
mockLoad.mockReturnValueOnce(draft)
|
||||
|
||||
const { template } = useTemplatePublishingStepper()
|
||||
expect(template.value).toEqual(draft)
|
||||
})
|
||||
|
||||
it('uses empty object when no draft is stored', () => {
|
||||
mockLoad.mockReturnValueOnce(null)
|
||||
|
||||
const { template } = useTemplatePublishingStepper()
|
||||
expect(template.value).toEqual({})
|
||||
})
|
||||
|
||||
it('exposes the correct number of step definitions', () => {
|
||||
const { stepDefinitions, totalSteps } = useTemplatePublishingStepper()
|
||||
expect(stepDefinitions).toHaveLength(totalSteps)
|
||||
expect(totalSteps).toBe(8)
|
||||
})
|
||||
})
|
||||
85
src/composables/useTemplatePublishingStepper.ts
Normal file
85
src/composables/useTemplatePublishingStepper.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { MarketplaceTemplate } from '@/types/templateMarketplace'
|
||||
|
||||
import type { PublishingStepDefinition } from '@/components/templatePublishing/types'
|
||||
import { PUBLISHING_STEP_DEFINITIONS } from '@/components/templatePublishing/types'
|
||||
import {
|
||||
loadTemplateUnderway,
|
||||
saveTemplateUnderway
|
||||
} from '@/platform/workflow/templates/composables/useTemplatePublishStorage'
|
||||
|
||||
/**
|
||||
* Manages the state and navigation logic for the template publishing
|
||||
* wizard.
|
||||
*
|
||||
* Owns the current step, per-step validity tracking, and draft
|
||||
* persistence via {@link saveTemplateUnderway}/{@link loadTemplateUnderway}.
|
||||
*
|
||||
* @param options.initialStep - 1-indexed step to start on (defaults to 1)
|
||||
*/
|
||||
export function useTemplatePublishingStepper(options?: {
|
||||
initialStep?: number
|
||||
}) {
|
||||
const totalSteps = PUBLISHING_STEP_DEFINITIONS.length
|
||||
|
||||
const currentStep = ref(clampStep(options?.initialStep ?? 1, totalSteps))
|
||||
|
||||
const template = ref<Partial<MarketplaceTemplate>>(
|
||||
loadTemplateUnderway() ?? {}
|
||||
)
|
||||
|
||||
const stepValidity = ref<Record<number, boolean>>({})
|
||||
|
||||
const stepDefinitions: PublishingStepDefinition[] =
|
||||
PUBLISHING_STEP_DEFINITIONS
|
||||
|
||||
const isFirstStep = computed(() => currentStep.value === 1)
|
||||
const isLastStep = computed(() => currentStep.value === totalSteps)
|
||||
const canProceed = computed(
|
||||
() => stepValidity.value[currentStep.value] === true
|
||||
)
|
||||
|
||||
function goToStep(step: number) {
|
||||
currentStep.value = clampStep(step, totalSteps)
|
||||
}
|
||||
|
||||
function nextStep() {
|
||||
if (!isLastStep.value) {
|
||||
currentStep.value = clampStep(currentStep.value + 1, totalSteps)
|
||||
}
|
||||
}
|
||||
|
||||
function prevStep() {
|
||||
if (!isFirstStep.value) {
|
||||
currentStep.value = clampStep(currentStep.value - 1, totalSteps)
|
||||
}
|
||||
}
|
||||
|
||||
function saveDraft() {
|
||||
saveTemplateUnderway(template.value)
|
||||
}
|
||||
|
||||
function setStepValid(step: number, valid: boolean) {
|
||||
stepValidity.value[step] = valid
|
||||
}
|
||||
|
||||
return {
|
||||
currentStep,
|
||||
totalSteps,
|
||||
template,
|
||||
stepDefinitions,
|
||||
isFirstStep,
|
||||
isLastStep,
|
||||
canProceed,
|
||||
goToStep,
|
||||
nextStep,
|
||||
prevStep,
|
||||
saveDraft,
|
||||
setStepValid
|
||||
}
|
||||
}
|
||||
|
||||
function clampStep(step: number, max: number): number {
|
||||
return Math.max(1, Math.min(step, max))
|
||||
}
|
||||
220
src/composables/useVramEstimation.test.ts
Normal file
220
src/composables/useVramEstimation.test.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
detectModelNodes,
|
||||
estimateWorkflowVram,
|
||||
MODEL_VRAM_ESTIMATES,
|
||||
RUNTIME_OVERHEAD
|
||||
} from './useVramEstimation'
|
||||
|
||||
const mockGetCategoryForNodeType = vi.fn<(type: string) => string | undefined>()
|
||||
const mockGetAllNodeProviders = vi.fn()
|
||||
|
||||
vi.mock('@/stores/modelToNodeStore', () => ({
|
||||
useModelToNodeStore: () => ({
|
||||
getCategoryForNodeType: mockGetCategoryForNodeType,
|
||||
getAllNodeProviders: mockGetAllNodeProviders
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
mapAllNodes: vi.fn(
|
||||
(
|
||||
graph: { nodes: Array<Record<string, unknown>> },
|
||||
mapFn: (node: Record<string, unknown>) => unknown
|
||||
) => graph.nodes.map(mapFn).filter((r) => r !== undefined)
|
||||
)
|
||||
}))
|
||||
|
||||
function makeNode(
|
||||
type: string,
|
||||
widgets: Array<{ name: string; value: unknown }> = []
|
||||
) {
|
||||
return {
|
||||
type,
|
||||
isSubgraphNode: () => false,
|
||||
widgets
|
||||
}
|
||||
}
|
||||
|
||||
function makeGraph(nodes: ReturnType<typeof makeNode>[]) {
|
||||
return { nodes } as never
|
||||
}
|
||||
|
||||
describe('useVramEstimation', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
mockGetCategoryForNodeType.mockReset()
|
||||
mockGetAllNodeProviders.mockReset()
|
||||
mockGetAllNodeProviders.mockReturnValue([])
|
||||
})
|
||||
|
||||
describe('detectModelNodes', () => {
|
||||
it('returns empty array for graph with no model nodes', () => {
|
||||
mockGetCategoryForNodeType.mockReturnValue(undefined)
|
||||
|
||||
const graph = makeGraph([makeNode('KSampler'), makeNode('SaveImage')])
|
||||
expect(detectModelNodes(graph)).toEqual([])
|
||||
})
|
||||
|
||||
it('detects checkpoint loader nodes', () => {
|
||||
mockGetCategoryForNodeType.mockImplementation((type: string) =>
|
||||
type === 'CheckpointLoaderSimple' ? 'checkpoints' : undefined
|
||||
)
|
||||
|
||||
const graph = makeGraph([
|
||||
makeNode('CheckpointLoaderSimple'),
|
||||
makeNode('KSampler')
|
||||
])
|
||||
|
||||
const result = detectModelNodes(graph)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].category).toBe('checkpoints')
|
||||
})
|
||||
|
||||
it('deduplicates models with same category and filename', () => {
|
||||
mockGetCategoryForNodeType.mockImplementation((type: string) =>
|
||||
type === 'CheckpointLoaderSimple' ? 'checkpoints' : undefined
|
||||
)
|
||||
mockGetAllNodeProviders.mockReturnValue([
|
||||
{
|
||||
nodeDef: { name: 'CheckpointLoaderSimple' },
|
||||
key: 'ckpt_name'
|
||||
}
|
||||
])
|
||||
|
||||
const graph = makeGraph([
|
||||
makeNode('CheckpointLoaderSimple', [
|
||||
{ name: 'ckpt_name', value: 'model.safetensors' }
|
||||
]),
|
||||
makeNode('CheckpointLoaderSimple', [
|
||||
{ name: 'ckpt_name', value: 'model.safetensors' }
|
||||
])
|
||||
])
|
||||
|
||||
expect(detectModelNodes(graph)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps models with same category but different filenames', () => {
|
||||
mockGetCategoryForNodeType.mockImplementation((type: string) =>
|
||||
type === 'LoraLoader' ? 'loras' : undefined
|
||||
)
|
||||
mockGetAllNodeProviders.mockReturnValue([
|
||||
{ nodeDef: { name: 'LoraLoader' }, key: 'lora_name' }
|
||||
])
|
||||
|
||||
const graph = makeGraph([
|
||||
makeNode('LoraLoader', [
|
||||
{ name: 'lora_name', value: 'lora_a.safetensors' }
|
||||
]),
|
||||
makeNode('LoraLoader', [
|
||||
{ name: 'lora_name', value: 'lora_b.safetensors' }
|
||||
])
|
||||
])
|
||||
|
||||
expect(detectModelNodes(graph)).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('estimateWorkflowVram', () => {
|
||||
it('returns 0 for null/undefined graph', () => {
|
||||
expect(estimateWorkflowVram(null)).toBe(0)
|
||||
expect(estimateWorkflowVram(undefined)).toBe(0)
|
||||
})
|
||||
|
||||
it('returns 0 for graph with no model nodes', () => {
|
||||
mockGetCategoryForNodeType.mockReturnValue(undefined)
|
||||
expect(estimateWorkflowVram(makeGraph([makeNode('KSampler')]))).toBe(0)
|
||||
})
|
||||
|
||||
it('estimates checkpoint-only workflow as base + overhead', () => {
|
||||
mockGetCategoryForNodeType.mockImplementation((type: string) =>
|
||||
type === 'CheckpointLoaderSimple' ? 'checkpoints' : undefined
|
||||
)
|
||||
|
||||
const result = estimateWorkflowVram(
|
||||
makeGraph([makeNode('CheckpointLoaderSimple'), makeNode('KSampler')])
|
||||
)
|
||||
|
||||
expect(result).toBe(MODEL_VRAM_ESTIMATES.checkpoints + RUNTIME_OVERHEAD)
|
||||
})
|
||||
|
||||
it('uses only the largest base model when multiple checkpoints exist', () => {
|
||||
mockGetCategoryForNodeType.mockImplementation((type: string) => {
|
||||
if (type === 'CheckpointLoaderSimple') return 'checkpoints'
|
||||
if (type === 'UNETLoader') return 'diffusion_models'
|
||||
return undefined
|
||||
})
|
||||
|
||||
const result = estimateWorkflowVram(
|
||||
makeGraph([makeNode('CheckpointLoaderSimple'), makeNode('UNETLoader')])
|
||||
)
|
||||
|
||||
const largestBase = Math.max(
|
||||
MODEL_VRAM_ESTIMATES.checkpoints,
|
||||
MODEL_VRAM_ESTIMATES.diffusion_models
|
||||
)
|
||||
expect(result).toBe(largestBase + RUNTIME_OVERHEAD)
|
||||
})
|
||||
|
||||
it('sums checkpoint + lora + controlnet correctly', () => {
|
||||
mockGetCategoryForNodeType.mockImplementation((type: string) => {
|
||||
const map: Record<string, string> = {
|
||||
CheckpointLoaderSimple: 'checkpoints',
|
||||
LoraLoader: 'loras',
|
||||
ControlNetLoader: 'controlnet'
|
||||
}
|
||||
return map[type]
|
||||
})
|
||||
|
||||
const result = estimateWorkflowVram(
|
||||
makeGraph([
|
||||
makeNode('CheckpointLoaderSimple'),
|
||||
makeNode('LoraLoader'),
|
||||
makeNode('ControlNetLoader')
|
||||
])
|
||||
)
|
||||
|
||||
expect(result).toBe(
|
||||
MODEL_VRAM_ESTIMATES.checkpoints +
|
||||
MODEL_VRAM_ESTIMATES.loras +
|
||||
MODEL_VRAM_ESTIMATES.controlnet +
|
||||
RUNTIME_OVERHEAD
|
||||
)
|
||||
})
|
||||
|
||||
it('handles unknown model categories with default estimate', () => {
|
||||
mockGetCategoryForNodeType.mockReturnValue('some_unknown_category')
|
||||
|
||||
const result = estimateWorkflowVram(
|
||||
makeGraph([makeNode('UnknownModelLoader')])
|
||||
)
|
||||
|
||||
// Unknown category uses 500 MB default + runtime overhead
|
||||
expect(result).toBe(500_000_000 + RUNTIME_OVERHEAD)
|
||||
})
|
||||
|
||||
it('counts multiple unique loras separately', () => {
|
||||
mockGetCategoryForNodeType.mockImplementation((type: string) =>
|
||||
type === 'LoraLoader' ? 'loras' : undefined
|
||||
)
|
||||
mockGetAllNodeProviders.mockReturnValue([
|
||||
{ nodeDef: { name: 'LoraLoader' }, key: 'lora_name' }
|
||||
])
|
||||
|
||||
const result = estimateWorkflowVram(
|
||||
makeGraph([
|
||||
makeNode('LoraLoader', [
|
||||
{ name: 'lora_name', value: 'lora_a.safetensors' }
|
||||
]),
|
||||
makeNode('LoraLoader', [
|
||||
{ name: 'lora_name', value: 'lora_b.safetensors' }
|
||||
])
|
||||
])
|
||||
)
|
||||
|
||||
expect(result).toBe(MODEL_VRAM_ESTIMATES.loras * 2 + RUNTIME_OVERHEAD)
|
||||
})
|
||||
})
|
||||
})
|
||||
139
src/composables/useVramEstimation.ts
Normal file
139
src/composables/useVramEstimation.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import type {
|
||||
LGraph,
|
||||
LGraphNode,
|
||||
Subgraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
||||
import { mapAllNodes } from '@/utils/graphTraversalUtil'
|
||||
|
||||
/**
|
||||
* A model detected in a workflow graph, identified by the directory
|
||||
* category it belongs to and the filename selected in its widget.
|
||||
*/
|
||||
export interface DetectedModel {
|
||||
/** Model directory category (e.g. 'checkpoints', 'loras'). */
|
||||
category: string
|
||||
/** Selected model filename from the node's widget, if available. */
|
||||
filename: string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Approximate VRAM consumption in bytes per model directory category.
|
||||
* Values represent typical fp16 model sizes loaded into GPU memory.
|
||||
*/
|
||||
export const MODEL_VRAM_ESTIMATES: Record<string, number> = {
|
||||
checkpoints: 4_500_000_000,
|
||||
diffusion_models: 4_500_000_000,
|
||||
loras: 200_000_000,
|
||||
controlnet: 1_500_000_000,
|
||||
vae: 350_000_000,
|
||||
clip_vision: 600_000_000,
|
||||
text_encoders: 1_200_000_000,
|
||||
upscale_models: 200_000_000,
|
||||
style_models: 500_000_000,
|
||||
gligen: 500_000_000
|
||||
}
|
||||
|
||||
/** Default VRAM estimate for unrecognised model categories. */
|
||||
const DEFAULT_MODEL_VRAM = 500_000_000
|
||||
|
||||
/** Flat overhead for intermediate tensors and activations. */
|
||||
export const RUNTIME_OVERHEAD = 500_000_000
|
||||
|
||||
/**
|
||||
* Categories whose models act as the "base" diffusion backbone.
|
||||
* Only the single largest base model is counted because ComfyUI
|
||||
* does not keep multiple base models resident simultaneously.
|
||||
*/
|
||||
const BASE_MODEL_CATEGORIES = new Set(['checkpoints', 'diffusion_models'])
|
||||
|
||||
/**
|
||||
* Extracts the widget value for the model input key from a graph node.
|
||||
*
|
||||
* @param node - The graph node to inspect
|
||||
* @param category - The model category, used to look up the expected input key
|
||||
* @returns The string widget value, or undefined if not found
|
||||
*/
|
||||
function getModelWidgetValue(
|
||||
node: LGraphNode,
|
||||
category: string
|
||||
): string | undefined {
|
||||
const store = useModelToNodeStore()
|
||||
const providers = store.getAllNodeProviders(category)
|
||||
for (const provider of providers) {
|
||||
if (provider.nodeDef?.name !== node.type) continue
|
||||
if (!provider.key) return undefined
|
||||
|
||||
const widget = node.widgets?.find((w) => w.name === provider.key)
|
||||
if (widget?.value && typeof widget.value === 'string') {
|
||||
return widget.value
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects all model-loading nodes in a graph hierarchy and returns
|
||||
* a deduplicated list of models with their category and filename.
|
||||
*
|
||||
* @param graph - The root graph (or subgraph) to traverse
|
||||
* @returns Array of unique detected models
|
||||
*/
|
||||
export function detectModelNodes(graph: LGraph | Subgraph): DetectedModel[] {
|
||||
const store = useModelToNodeStore()
|
||||
|
||||
const raw = mapAllNodes(graph, (node) => {
|
||||
if (!node.type) return undefined
|
||||
const category = store.getCategoryForNodeType(node.type)
|
||||
if (!category) return undefined
|
||||
|
||||
const filename = getModelWidgetValue(node, category)
|
||||
return { category, filename } satisfies DetectedModel
|
||||
})
|
||||
|
||||
const seen = new Set<string>()
|
||||
return raw.filter((model) => {
|
||||
const key = `${model.category}::${model.filename ?? ''}`
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimates peak VRAM consumption (in bytes) for a workflow graph.
|
||||
*
|
||||
* The heuristic:
|
||||
* 1. Detect all model-loading nodes in the graph.
|
||||
* 2. For base model categories (checkpoints, diffusion_models), take only
|
||||
* the largest single model — ComfyUI offloads others.
|
||||
* 3. Sum all other model categories (LoRAs, ControlNets, VAEs, etc.)
|
||||
* as they can be co-resident.
|
||||
* 4. Add a flat runtime overhead for activations and intermediates.
|
||||
*
|
||||
* @param graph - The root graph to analyse
|
||||
* @returns Estimated VRAM in bytes, or 0 if no models detected
|
||||
*/
|
||||
export function estimateWorkflowVram(
|
||||
graph: LGraph | Subgraph | null | undefined
|
||||
): number {
|
||||
if (!graph) return 0
|
||||
|
||||
const models = detectModelNodes(graph)
|
||||
if (models.length === 0) return 0
|
||||
|
||||
let baseCost = 0
|
||||
let additionalCost = 0
|
||||
|
||||
for (const model of models) {
|
||||
const estimate = MODEL_VRAM_ESTIMATES[model.category] ?? DEFAULT_MODEL_VRAM
|
||||
|
||||
if (BASE_MODEL_CATEGORIES.has(model.category)) {
|
||||
baseCost = Math.max(baseCost, estimate)
|
||||
} else {
|
||||
additionalCost += estimate
|
||||
}
|
||||
}
|
||||
|
||||
return baseCost + additionalCost + RUNTIME_OVERHEAD
|
||||
}
|
||||
@@ -45,7 +45,7 @@ const mockCanvasStore = vi.hoisted(() => ({
|
||||
}))
|
||||
|
||||
const mockFeatureFlags = vi.hoisted(() => ({
|
||||
flags: { linearToggleEnabled: false }
|
||||
flags: { linearToggleEnabled: false, templateMarketplaceEnabled: false }
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
@@ -102,6 +102,7 @@ describe('useWorkflowActionsMenu', () => {
|
||||
mockMenuItemStore.hasSeenLinear = false
|
||||
mockCanvasStore.linearMode = false
|
||||
mockFeatureFlags.flags.linearToggleEnabled = false
|
||||
mockFeatureFlags.flags.templateMarketplaceEnabled = false
|
||||
mockWorkflowStore.activeWorkflow = {
|
||||
path: 'test.json',
|
||||
isPersisted: true
|
||||
@@ -312,6 +313,33 @@ describe('useWorkflowActionsMenu', () => {
|
||||
expect(rename.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('shows developer profile when templateMarketplaceEnabled flag is set', () => {
|
||||
mockFeatureFlags.flags.templateMarketplaceEnabled = true
|
||||
|
||||
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
|
||||
const labels = menuLabels(menuItems.value)
|
||||
|
||||
expect(labels).toContain('menuLabels.developerProfile')
|
||||
})
|
||||
|
||||
it('hides developer profile when templateMarketplaceEnabled flag is not set', () => {
|
||||
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
|
||||
const labels = menuLabels(menuItems.value)
|
||||
|
||||
expect(labels).not.toContain('menuLabels.developerProfile')
|
||||
})
|
||||
|
||||
it('developer profile executes Comfy.ShowDeveloperProfile', async () => {
|
||||
mockFeatureFlags.flags.templateMarketplaceEnabled = true
|
||||
|
||||
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
|
||||
await findItem(menuItems.value, 'menuLabels.developerProfile').command?.()
|
||||
|
||||
expect(mockCommandStore.execute).toHaveBeenCalledWith(
|
||||
'Comfy.ShowDeveloperProfile'
|
||||
)
|
||||
})
|
||||
|
||||
it('bookmark is disabled for temporary workflows', () => {
|
||||
mockWorkflowStore.activeWorkflow = {
|
||||
path: 'test.json',
|
||||
|
||||
@@ -197,6 +197,25 @@ export function useWorkflowActionsMenu(
|
||||
prependSeparator: true
|
||||
})
|
||||
|
||||
addItem({
|
||||
label: t('menuLabels.templatePublishing'),
|
||||
icon: 'pi pi-objects-column',
|
||||
command: async () => {
|
||||
await commandStore.execute('Comfy.ShowTemplatePublishing')
|
||||
},
|
||||
visible: isRoot && flags.templateMarketplaceEnabled,
|
||||
prependSeparator: true
|
||||
})
|
||||
|
||||
addItem({
|
||||
label: t('menuLabels.developerProfile'),
|
||||
icon: 'pi pi-user',
|
||||
command: async () => {
|
||||
await commandStore.execute('Comfy.ShowDeveloperProfile')
|
||||
},
|
||||
visible: isRoot && flags.templateMarketplaceEnabled
|
||||
})
|
||||
|
||||
addItem({
|
||||
label: t('subgraphStore.publish'),
|
||||
icon: 'pi pi-upload',
|
||||
|
||||
@@ -1004,8 +1004,10 @@
|
||||
"searchPlaceholder": "Search...",
|
||||
"vramLowToHigh": "VRAM Usage (Low to High)",
|
||||
"modelSizeLowToHigh": "Model Size (Low to High)",
|
||||
"default": "Default"
|
||||
"default": "Default",
|
||||
"similarToCurrent": "Similar to Current"
|
||||
},
|
||||
"vramEstimateTooltip": "Estimated GPU memory required to run this workflow",
|
||||
"error": {
|
||||
"templateNotFound": "Template \"{templateName}\" not found"
|
||||
}
|
||||
@@ -1054,6 +1056,155 @@
|
||||
"enterFilenamePrompt": "Enter the filename:",
|
||||
"saveWorkflow": "Save workflow"
|
||||
},
|
||||
"templatePublishing": {
|
||||
"publishToMarketplace": "Publish to Marketplace",
|
||||
"saveDraft": "Save Draft",
|
||||
"previewThenSave": "Preview then Save",
|
||||
"dialogTitle": "Template Publishing",
|
||||
"next": "Next",
|
||||
"previous": "Previous",
|
||||
"submit": "Submit for Review",
|
||||
"stepProgress": "Step {current} of {total}",
|
||||
"steps": {
|
||||
"landing": {
|
||||
"title": "Getting Started",
|
||||
"description": "Overview of the publishing process"
|
||||
},
|
||||
"metadata": {
|
||||
"title": "Metadata",
|
||||
"description": "Title, description, and author info",
|
||||
"titleLabel": "Title",
|
||||
"categoryLabel": "Categories",
|
||||
"tagsLabel": "Tags",
|
||||
"tagsPlaceholder": "Type to search tags…",
|
||||
"difficultyLabel": "Difficulty",
|
||||
"licenseLabel": "License",
|
||||
"requiredNodesLabel": "Custom Nodes",
|
||||
"requiredNodesDetected": "Detected from workflow",
|
||||
"requiredNodesManualPlaceholder": "Add custom node name…",
|
||||
"requiredNodesManualLabel": "Additional custom nodes",
|
||||
"vramLabel": "Estimated VRAM Requirement",
|
||||
"vramAutoDetected": "Auto-detected from workflow:",
|
||||
"vramManualOverride": "Manual override (GB):",
|
||||
"difficulty": {
|
||||
"beginner": "Beginner",
|
||||
"intermediate": "Intermediate",
|
||||
"advanced": "Advanced"
|
||||
},
|
||||
"license": {
|
||||
"mit": "MIT",
|
||||
"ccBy": "CC BY",
|
||||
"ccBySa": "CC BY-SA",
|
||||
"ccByNc": "CC BY-NC",
|
||||
"apache": "Apache",
|
||||
"custom": "Custom"
|
||||
},
|
||||
"category": {
|
||||
"imageGeneration": "Image Generation",
|
||||
"videoGeneration": "Video Generation",
|
||||
"audio": "Audio",
|
||||
"text": "Text",
|
||||
"threeD": "3D",
|
||||
"upscaling": "Upscaling",
|
||||
"inpainting": "Inpainting",
|
||||
"controlNet": "ControlNet",
|
||||
"styleTransfer": "Style Transfer",
|
||||
"other": "Other"
|
||||
}
|
||||
},
|
||||
"description": {
|
||||
"title": "Description",
|
||||
"description": "Write a detailed description of your template",
|
||||
"editorLabel": "Description (Markdown)",
|
||||
"previewLabel": "Description (Render preview)"
|
||||
},
|
||||
"previewGeneration": {
|
||||
"title": "Preview",
|
||||
"description": "Generate preview images and videos",
|
||||
"thumbnailLabel": "Thumbnail",
|
||||
"thumbnailHint": "Primary image shown in marketplace listings",
|
||||
"comparisonLabel": "Before & After Comparison",
|
||||
"comparisonHint": "Show what the workflow transforms",
|
||||
"beforeImageLabel": "Before",
|
||||
"afterImageLabel": "After",
|
||||
"workflowPreviewLabel": "Workflow Graph",
|
||||
"workflowPreviewHint": "Screenshot of the workflow graph layout",
|
||||
"videoPreviewLabel": "Video Preview",
|
||||
"videoPreviewHint": "Optional short video demonstrating the workflow",
|
||||
"galleryLabel": "Example Gallery",
|
||||
"galleryHint": "Up to {max} example output images",
|
||||
"uploadPrompt": "Click to upload",
|
||||
"removeFile": "Remove",
|
||||
"uploadingProgress": "Uploading… {percent}%"
|
||||
},
|
||||
"categoryAndTagging": {
|
||||
"title": "Categories & Tags",
|
||||
"description": "Categorize and tag your template"
|
||||
},
|
||||
"preview": {
|
||||
"title": "Preview",
|
||||
"description": "Review your template before submitting",
|
||||
"sectionMetadata": "Metadata",
|
||||
"sectionDescription": "Description",
|
||||
"sectionPreviewAssets": "Preview Assets",
|
||||
"sectionCategoriesAndTags": "Categories & Tags",
|
||||
"thumbnailLabel": "Thumbnail",
|
||||
"comparisonLabel": "Before & After",
|
||||
"workflowPreviewLabel": "Workflow Graph",
|
||||
"videoPreviewLabel": "Video Preview",
|
||||
"galleryLabel": "Gallery",
|
||||
"vramLabel": "VRAM Requirement",
|
||||
"notProvided": "Not provided",
|
||||
"noneDetected": "None detected",
|
||||
"correct": "Correct",
|
||||
"editStep": "Edit"
|
||||
},
|
||||
"submissionForReview": {
|
||||
"title": "Submit",
|
||||
"description": "Submit your template for review. Comfy staff will evaluate the template, and either publish it or contact you here with next steps."
|
||||
},
|
||||
"complete": {
|
||||
"title": "Complete",
|
||||
"description": "Your template has been submitted"
|
||||
}
|
||||
}
|
||||
},
|
||||
"developerProfile": {
|
||||
"dialogTitle": "Developer Profile",
|
||||
"username": "Username",
|
||||
"bio": "Bio",
|
||||
"reviews": "Reviews",
|
||||
"publishedTemplates": "Published Templates",
|
||||
"dependencies": "Dependencies",
|
||||
"totalDownloads": "Downloads",
|
||||
"totalFavorites": "Favorites",
|
||||
"averageRating": "Avg. Rating",
|
||||
"templateCount": "Templates",
|
||||
"revenue": "Revenue",
|
||||
"monthlyRevenue": "Monthly",
|
||||
"totalRevenue": "Total",
|
||||
"noReviews": "No reviews yet",
|
||||
"noTemplates": "No published templates yet",
|
||||
"unpublish": "Unpublish",
|
||||
"save": "Save Profile",
|
||||
"saving": "Saving...",
|
||||
"verified": "Verified",
|
||||
"quickActions": "Quick Actions",
|
||||
"bannerPlaceholder": "Banner image",
|
||||
"editUsername": "Edit username",
|
||||
"editBio": "Edit bio",
|
||||
"lookupHandle": "Enter developer handle\u2026",
|
||||
"downloads": "Downloads",
|
||||
"favorites": "Favorites",
|
||||
"rating": "Rating",
|
||||
"downloadHistory": "Download History",
|
||||
"range": {
|
||||
"week": "Week",
|
||||
"month": "Month",
|
||||
"year": "Year",
|
||||
"allTime": "All Time"
|
||||
}
|
||||
},
|
||||
"subgraphStore": {
|
||||
"confirmDeleteTitle": "Delete blueprint?",
|
||||
"confirmDelete": "This action will permanently remove the blueprint from your library",
|
||||
@@ -1327,7 +1478,9 @@
|
||||
"Assets": "Assets",
|
||||
"Model Library": "Model Library",
|
||||
"Node Library": "Node Library",
|
||||
"Workflows": "Workflows"
|
||||
"Workflows": "Workflows",
|
||||
"templatePublishing": "Template Publishing",
|
||||
"developerProfile": "Developer Profile"
|
||||
},
|
||||
"desktopMenu": {
|
||||
"reinstall": "Reinstall",
|
||||
|
||||
@@ -44,4 +44,5 @@ export type RemoteConfig = {
|
||||
team_workspaces_enabled?: boolean
|
||||
user_secrets_enabled?: boolean
|
||||
node_library_essentials_enabled?: boolean
|
||||
template_marketplace_enabled?: boolean
|
||||
}
|
||||
|
||||
@@ -222,6 +222,7 @@ export interface TemplateFilterMetadata {
|
||||
| 'newest'
|
||||
| 'vram-low-to-high'
|
||||
| 'model-size-low-to-high'
|
||||
| 'similar-to-current'
|
||||
filtered_count: number
|
||||
total_count: number
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
loadTemplateUnderway,
|
||||
saveTemplateUnderway
|
||||
} from '@/platform/workflow/templates/composables/useTemplatePublishStorage'
|
||||
import type { MarketplaceTemplate } from '@/types/templateMarketplace'
|
||||
|
||||
const STORAGE_KEY = 'Comfy.TemplateMarketplace.TemplateUnderway'
|
||||
|
||||
const storageMocks = vi.hoisted(() => ({
|
||||
getStorageValue: vi.fn(),
|
||||
setStorageValue: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/utils', () => storageMocks)
|
||||
|
||||
describe('useTemplatePublishStorage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const template: Partial<MarketplaceTemplate> = {
|
||||
title: 'My Template',
|
||||
description: 'A test template',
|
||||
difficulty: 'beginner',
|
||||
tags: ['test']
|
||||
}
|
||||
|
||||
describe('saveTemplateUnderway', () => {
|
||||
it('serialises the template and writes it to storage', () => {
|
||||
saveTemplateUnderway(template)
|
||||
|
||||
expect(storageMocks.setStorageValue).toHaveBeenCalledWith(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify(template)
|
||||
)
|
||||
})
|
||||
|
||||
it('throws TypeError when the template cannot be serialised', () => {
|
||||
const circular = { title: 'oops' } as Partial<MarketplaceTemplate>
|
||||
;(circular as Record<string, unknown>).self = circular
|
||||
|
||||
expect(() => saveTemplateUnderway(circular)).toThrow(TypeError)
|
||||
expect(storageMocks.setStorageValue).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadTemplateUnderway', () => {
|
||||
it('returns the parsed template when storage contains valid JSON', () => {
|
||||
storageMocks.getStorageValue.mockReturnValue(JSON.stringify(template))
|
||||
|
||||
expect(loadTemplateUnderway()).toEqual(template)
|
||||
expect(storageMocks.getStorageValue).toHaveBeenCalledWith(STORAGE_KEY)
|
||||
})
|
||||
|
||||
it('returns null when no value is stored', () => {
|
||||
storageMocks.getStorageValue.mockReturnValue(null)
|
||||
|
||||
expect(loadTemplateUnderway()).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when stored value is invalid JSON', () => {
|
||||
storageMocks.getStorageValue.mockReturnValue('not json{{{')
|
||||
|
||||
expect(loadTemplateUnderway()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('round-trip', () => {
|
||||
beforeEach(() => {
|
||||
let stored: string | null = null
|
||||
storageMocks.setStorageValue.mockImplementation(
|
||||
(_key: string, value: string) => {
|
||||
stored = value
|
||||
}
|
||||
)
|
||||
storageMocks.getStorageValue.mockImplementation(() => stored)
|
||||
})
|
||||
|
||||
it.each<{
|
||||
label: string
|
||||
input: Partial<MarketplaceTemplate>
|
||||
}>([
|
||||
{
|
||||
label: 'string values',
|
||||
input: { title: 'hello', description: '' }
|
||||
},
|
||||
{
|
||||
label: 'number values',
|
||||
input: { vramRequirement: 0 }
|
||||
},
|
||||
{
|
||||
label: 'negative and fractional numbers',
|
||||
input: { vramRequirement: -1.5 }
|
||||
},
|
||||
{
|
||||
label: 'boolean values',
|
||||
input: {
|
||||
author: { id: '1', name: 'a', isVerified: false, profileUrl: '' }
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'null values',
|
||||
input: { videoPreview: undefined, reviewFeedback: undefined }
|
||||
},
|
||||
{
|
||||
label: 'array values',
|
||||
input: { tags: ['a', 'b'], categories: [], requiredNodes: ['node1'] }
|
||||
},
|
||||
{
|
||||
label: 'nested objects',
|
||||
input: {
|
||||
author: {
|
||||
id: '1',
|
||||
name: 'Author',
|
||||
isVerified: true,
|
||||
profileUrl: '/u/1'
|
||||
},
|
||||
stats: {
|
||||
downloads: 42,
|
||||
favorites: 7,
|
||||
rating: 4.5,
|
||||
reviewCount: 3,
|
||||
weeklyTrend: -2.1
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'mixed types in a single template',
|
||||
input: {
|
||||
id: '123',
|
||||
title: 'Full',
|
||||
description: 'A template with all JSON types',
|
||||
tags: ['mixed'],
|
||||
vramRequirement: 8_000_000_000,
|
||||
author: { id: '1', name: 'Test', isVerified: true, profileUrl: '' },
|
||||
gallery: [
|
||||
{
|
||||
type: 'image',
|
||||
url: 'https://example.com/img.png',
|
||||
isBefore: true
|
||||
}
|
||||
],
|
||||
requiredModels: [{ name: 'model', type: 'checkpoint', size: 0 }],
|
||||
stats: {
|
||||
downloads: 0,
|
||||
favorites: 0,
|
||||
rating: 0,
|
||||
reviewCount: 0,
|
||||
weeklyTrend: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
])('preserves $label through save/load', ({ input }) => {
|
||||
saveTemplateUnderway(input)
|
||||
const result = loadTemplateUnderway()
|
||||
|
||||
expect(result).toEqual(structuredClone(input))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Persists and retrieves the in-progress template being published to the
|
||||
* marketplace.
|
||||
*
|
||||
* Uses {@link setStorageValue} / {@link getStorageValue} so the draft
|
||||
* survives page reloads (localStorage) while also being per-tab aware
|
||||
* (sessionStorage scoped by clientId).
|
||||
*/
|
||||
import type { MarketplaceTemplate } from '@/types/templateMarketplace'
|
||||
|
||||
import { getStorageValue, setStorageValue } from '@/scripts/utils'
|
||||
|
||||
const STORAGE_KEY = 'Comfy.TemplateMarketplace.TemplateUnderway'
|
||||
|
||||
/**
|
||||
* Saves a template that is in the process of being published.
|
||||
*
|
||||
* The template is JSON-serialised and written to both localStorage and
|
||||
* the per-tab sessionStorage slot via {@link setStorageValue}.
|
||||
*
|
||||
* @param template - The current state of the template being published.
|
||||
* @throws {TypeError} If the template cannot be serialised to JSON
|
||||
* (e.g. circular references or BigInt values).
|
||||
*/
|
||||
export function saveTemplateUnderway(
|
||||
template: Partial<MarketplaceTemplate>
|
||||
): void {
|
||||
let json: string
|
||||
try {
|
||||
json = JSON.stringify(template)
|
||||
} catch (error) {
|
||||
throw new TypeError(
|
||||
`Template cannot be serialised to JSON: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
setStorageValue(STORAGE_KEY, json)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the locally stored in-progress template, if one exists.
|
||||
*
|
||||
* Reads from the per-tab sessionStorage first, falling back to
|
||||
* localStorage, via {@link getStorageValue}.
|
||||
*
|
||||
* @returns The partially completed template, or `null` when no draft
|
||||
* is stored or the stored value cannot be parsed.
|
||||
*/
|
||||
export function loadTemplateUnderway(): Partial<MarketplaceTemplate> | null {
|
||||
const raw = getStorageValue(STORAGE_KEY)
|
||||
if (!raw) return null
|
||||
|
||||
try {
|
||||
return JSON.parse(raw) as Partial<MarketplaceTemplate>
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
141
src/renderer/core/thumbnail/templateScreenshotRenderer.test.ts
Normal file
141
src/renderer/core/thumbnail/templateScreenshotRenderer.test.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { renderMinimapToCanvas } from '@/renderer/extensions/minimap/minimapCanvasRenderer'
|
||||
|
||||
import { createTemplateScreenshot } from './templateScreenshotRenderer'
|
||||
|
||||
vi.mock('@/renderer/extensions/minimap/minimapCanvasRenderer', () => ({
|
||||
renderMinimapToCanvas: vi.fn()
|
||||
}))
|
||||
|
||||
const fakeBlob = new Blob(['fake'], { type: 'image/png' })
|
||||
|
||||
function stubCanvas() {
|
||||
const clearRect = vi.fn()
|
||||
const ctx = { clearRect } as unknown as CanvasRenderingContext2D
|
||||
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
|
||||
ctx as never
|
||||
)
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(
|
||||
function (this: HTMLCanvasElement, cb, _type?, _quality?) {
|
||||
cb(fakeBlob)
|
||||
}
|
||||
)
|
||||
|
||||
return { ctx, clearRect }
|
||||
}
|
||||
|
||||
function makeGraph(nodeCount: number): LGraph {
|
||||
const nodes = Array.from({ length: nodeCount }, (_, i) => ({
|
||||
pos: [i * 300, i * 200] as [number, number],
|
||||
size: [200, 100] as [number, number]
|
||||
}))
|
||||
return { _nodes: nodes } as unknown as LGraph
|
||||
}
|
||||
|
||||
describe('createTemplateScreenshot', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns null for an empty graph', async () => {
|
||||
const graph = { _nodes: [] } as unknown as LGraph
|
||||
const result = await createTemplateScreenshot(graph)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when _nodes is undefined', async () => {
|
||||
const graph = {} as unknown as LGraph
|
||||
const result = await createTemplateScreenshot(graph)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('renders a graph and returns a Blob', async () => {
|
||||
stubCanvas()
|
||||
const graph = makeGraph(3)
|
||||
|
||||
const blob = await createTemplateScreenshot(graph)
|
||||
|
||||
expect(renderMinimapToCanvas).toHaveBeenCalledOnce()
|
||||
expect(blob).toBe(fakeBlob)
|
||||
})
|
||||
|
||||
it('uses default 1920x1080 dimensions', async () => {
|
||||
stubCanvas()
|
||||
const graph = makeGraph(2)
|
||||
|
||||
await createTemplateScreenshot(graph)
|
||||
|
||||
const call = vi.mocked(renderMinimapToCanvas).mock.calls[0]
|
||||
const context = call[2]
|
||||
expect(context.width).toBe(1920)
|
||||
expect(context.height).toBe(1080)
|
||||
})
|
||||
|
||||
it('respects custom dimensions', async () => {
|
||||
stubCanvas()
|
||||
const graph = makeGraph(2)
|
||||
|
||||
await createTemplateScreenshot(graph, { width: 800, height: 600 })
|
||||
|
||||
const context = vi.mocked(renderMinimapToCanvas).mock.calls[0][2]
|
||||
expect(context.width).toBe(800)
|
||||
expect(context.height).toBe(600)
|
||||
})
|
||||
|
||||
it('enables groups, links, and node colors by default', async () => {
|
||||
stubCanvas()
|
||||
const graph = makeGraph(2)
|
||||
|
||||
await createTemplateScreenshot(graph)
|
||||
|
||||
const { settings } = vi.mocked(renderMinimapToCanvas).mock.calls[0][2]
|
||||
expect(settings.showGroups).toBe(true)
|
||||
expect(settings.showLinks).toBe(true)
|
||||
expect(settings.nodeColors).toBe(true)
|
||||
})
|
||||
|
||||
it('passes showGroups and showLinks overrides', async () => {
|
||||
stubCanvas()
|
||||
const graph = makeGraph(2)
|
||||
|
||||
await createTemplateScreenshot(graph, {
|
||||
showGroups: false,
|
||||
showLinks: false,
|
||||
nodeColors: false
|
||||
})
|
||||
|
||||
const { settings } = vi.mocked(renderMinimapToCanvas).mock.calls[0][2]
|
||||
expect(settings.showGroups).toBe(false)
|
||||
expect(settings.showLinks).toBe(false)
|
||||
expect(settings.nodeColors).toBe(false)
|
||||
})
|
||||
|
||||
it('cleans up canvas after rendering', async () => {
|
||||
const { clearRect } = stubCanvas()
|
||||
const graph = makeGraph(2)
|
||||
|
||||
await createTemplateScreenshot(graph)
|
||||
|
||||
expect(clearRect).toHaveBeenCalledWith(0, 0, 1920, 1080)
|
||||
})
|
||||
|
||||
it('returns null when toBlob yields null', async () => {
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
|
||||
clearRect: vi.fn()
|
||||
} as never)
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(
|
||||
function (this: HTMLCanvasElement, cb) {
|
||||
cb(null)
|
||||
}
|
||||
)
|
||||
const graph = makeGraph(2)
|
||||
|
||||
const result = await createTemplateScreenshot(graph)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
97
src/renderer/core/thumbnail/templateScreenshotRenderer.ts
Normal file
97
src/renderer/core/thumbnail/templateScreenshotRenderer.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
calculateMinimapScale,
|
||||
calculateNodeBounds
|
||||
} from '@/renderer/core/spatial/boundsCalculator'
|
||||
|
||||
import { renderMinimapToCanvas } from '../../extensions/minimap/minimapCanvasRenderer'
|
||||
|
||||
/** Configuration options for {@link createTemplateScreenshot}. */
|
||||
export interface TemplateScreenshotOptions {
|
||||
/** Output image width in pixels. @defaultValue 1920 */
|
||||
width?: number
|
||||
/** Output image height in pixels. @defaultValue 1080 */
|
||||
height?: number
|
||||
/** Render group rectangles behind nodes. @defaultValue true */
|
||||
showGroups?: boolean
|
||||
/** Render connection lines between nodes. @defaultValue true */
|
||||
showLinks?: boolean
|
||||
/** Use per-node background colours. @defaultValue true */
|
||||
nodeColors?: boolean
|
||||
/** Image MIME type passed to `canvas.toBlob`. @defaultValue 'image/png' */
|
||||
mimeType?: string
|
||||
/** Image quality (0-1) for lossy formats like `image/jpeg`. */
|
||||
quality?: number
|
||||
}
|
||||
|
||||
const DEFAULT_WIDTH = 1920
|
||||
const DEFAULT_HEIGHT = 1080
|
||||
|
||||
/**
|
||||
* Renders a high-resolution screenshot of a workflow graph suitable for
|
||||
* template marketplace previews.
|
||||
*
|
||||
* Uses the minimap renderer to draw a simplified but complete overview of
|
||||
* every node, group, and connection onto an off-screen canvas, then
|
||||
* converts the result to a {@link Blob}.
|
||||
*
|
||||
* @param graph - The LiteGraph instance to capture.
|
||||
* @param options - Optional rendering and output settings.
|
||||
* @returns A `Blob` containing the rendered image, or `null` when the
|
||||
* graph is empty or canvas creation fails.
|
||||
*/
|
||||
export function createTemplateScreenshot(
|
||||
graph: LGraph,
|
||||
options: TemplateScreenshotOptions = {}
|
||||
): Promise<Blob | null> {
|
||||
const {
|
||||
width = DEFAULT_WIDTH,
|
||||
height = DEFAULT_HEIGHT,
|
||||
showGroups = true,
|
||||
showLinks = true,
|
||||
nodeColors = true,
|
||||
mimeType = 'image/png',
|
||||
quality
|
||||
} = options
|
||||
|
||||
if (!graph._nodes || graph._nodes.length === 0) {
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
|
||||
const bounds = calculateNodeBounds(graph._nodes)
|
||||
if (!bounds) {
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
|
||||
const scale = calculateMinimapScale(bounds, width, height)
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
|
||||
renderMinimapToCanvas(canvas, graph, {
|
||||
bounds,
|
||||
scale,
|
||||
settings: {
|
||||
nodeColors,
|
||||
showLinks,
|
||||
showGroups,
|
||||
renderBypass: true,
|
||||
renderError: true
|
||||
},
|
||||
width,
|
||||
height
|
||||
})
|
||||
|
||||
return new Promise<Blob | null>((resolve) => {
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (ctx) ctx.clearRect(0, 0, width, height)
|
||||
resolve(blob)
|
||||
},
|
||||
mimeType,
|
||||
quality
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -450,7 +450,8 @@ const zSettings = z.object({
|
||||
'alphabetical',
|
||||
'newest',
|
||||
'vram-low-to-high',
|
||||
'model-size-low-to-high'
|
||||
'model-size-low-to-high',
|
||||
'similar-to-current'
|
||||
]),
|
||||
/** Settings used for testing */
|
||||
'test.setting': z.any(),
|
||||
|
||||
@@ -69,6 +69,7 @@ The following table lists ALL services in the system as of 2025-09-01:
|
||||
| comfyManagerService.ts | Manages ComfyUI application packages and updates | Manager |
|
||||
| comfyRegistryService.ts | Handles registration and discovery of ComfyUI extensions | Registry |
|
||||
| customerEventsService.ts | Handles customer event tracking and audit logs | Analytics |
|
||||
| developerProfileService.ts | Fetches developer profiles, templates, and analytics | Market |
|
||||
| dialogService.ts | Provides dialog and modal management | UI |
|
||||
| extensionService.ts | Manages extension registration and lifecycle | Extensions |
|
||||
| litegraphService.ts | Provides utilities for working with the LiteGraph library | Graph |
|
||||
|
||||
152
src/services/developerProfileService.test.ts
Normal file
152
src/services/developerProfileService.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
fetchDeveloperProfile,
|
||||
fetchDeveloperReviews,
|
||||
fetchDownloadHistory,
|
||||
fetchPublishedTemplates,
|
||||
fetchTemplateRevenue,
|
||||
getCurrentUsername,
|
||||
saveDeveloperProfile,
|
||||
unpublishTemplate
|
||||
} from '@/services/developerProfileService'
|
||||
|
||||
describe('developerProfileService', () => {
|
||||
it('getCurrentUsername returns @StoneCypher', () => {
|
||||
expect(getCurrentUsername()).toBe('@StoneCypher')
|
||||
})
|
||||
|
||||
it('fetchDeveloperProfile returns a valid profile shape', async () => {
|
||||
const profile = await fetchDeveloperProfile('@StoneCypher')
|
||||
|
||||
expect(profile.username).toBe('@StoneCypher')
|
||||
expect(typeof profile.displayName).toBe('string')
|
||||
expect(typeof profile.isVerified).toBe('boolean')
|
||||
expect(typeof profile.monetizationEnabled).toBe('boolean')
|
||||
expect(profile.joinedAt).toBeInstanceOf(Date)
|
||||
expect(typeof profile.totalDownloads).toBe('number')
|
||||
expect(typeof profile.totalFavorites).toBe('number')
|
||||
expect(profile.averageRating).toBeGreaterThanOrEqual(0)
|
||||
expect(profile.averageRating).toBeLessThanOrEqual(5)
|
||||
expect(typeof profile.templateCount).toBe('number')
|
||||
})
|
||||
|
||||
it('fetchDeveloperReviews returns reviews with valid ratings', async () => {
|
||||
const reviews = await fetchDeveloperReviews('@StoneCypher')
|
||||
|
||||
expect(reviews.length).toBeGreaterThan(0)
|
||||
for (const review of reviews) {
|
||||
expect(review.rating).toBeGreaterThanOrEqual(1)
|
||||
expect(review.rating).toBeLessThanOrEqual(5)
|
||||
expect(review.rating % 0.5).toBe(0)
|
||||
expect(typeof review.text).toBe('string')
|
||||
expect(typeof review.authorName).toBe('string')
|
||||
expect(review.createdAt).toBeInstanceOf(Date)
|
||||
}
|
||||
})
|
||||
|
||||
it('fetchPublishedTemplates returns templates with stats', async () => {
|
||||
const templates = await fetchPublishedTemplates('@StoneCypher')
|
||||
|
||||
expect(templates.length).toBeGreaterThan(0)
|
||||
for (const tpl of templates) {
|
||||
expect(typeof tpl.id).toBe('string')
|
||||
expect(typeof tpl.title).toBe('string')
|
||||
expect(typeof tpl.stats.downloads).toBe('number')
|
||||
expect(typeof tpl.stats.favorites).toBe('number')
|
||||
expect(typeof tpl.stats.rating).toBe('number')
|
||||
}
|
||||
})
|
||||
|
||||
it('fetchTemplateRevenue returns revenue per template', async () => {
|
||||
const revenue = await fetchTemplateRevenue('@StoneCypher')
|
||||
|
||||
expect(revenue.length).toBeGreaterThan(0)
|
||||
for (const entry of revenue) {
|
||||
expect(typeof entry.templateId).toBe('string')
|
||||
expect(typeof entry.totalRevenue).toBe('number')
|
||||
expect(typeof entry.monthlyRevenue).toBe('number')
|
||||
expect(typeof entry.currency).toBe('string')
|
||||
}
|
||||
})
|
||||
|
||||
it('unpublishTemplate resolves without error', async () => {
|
||||
await expect(unpublishTemplate('tpl-1')).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('saveDeveloperProfile echoes back a complete profile', async () => {
|
||||
const result = await saveDeveloperProfile({ bio: 'Updated bio' })
|
||||
|
||||
expect(result.bio).toBe('Updated bio')
|
||||
expect(typeof result.username).toBe('string')
|
||||
expect(typeof result.displayName).toBe('string')
|
||||
expect(typeof result.isVerified).toBe('boolean')
|
||||
})
|
||||
|
||||
it('fetchDeveloperProfile returns a different profile for @PixelWizard', async () => {
|
||||
const profile = await fetchDeveloperProfile('@PixelWizard')
|
||||
|
||||
expect(profile.username).toBe('@PixelWizard')
|
||||
expect(profile.displayName).toBe('Pixel Wizard')
|
||||
expect(profile.isVerified).toBe(false)
|
||||
})
|
||||
|
||||
it('fetchDeveloperProfile returns a fallback for unknown usernames', async () => {
|
||||
const profile = await fetchDeveloperProfile('@Unknown')
|
||||
|
||||
expect(profile.username).toBe('@Unknown')
|
||||
expect(profile.displayName).toBe('Unknown')
|
||||
expect(profile.totalDownloads).toBe(0)
|
||||
})
|
||||
|
||||
it('fetchPublishedTemplates returns different templates per developer', async () => {
|
||||
const stoneTemplates = await fetchPublishedTemplates('@StoneCypher')
|
||||
const pixelTemplates = await fetchPublishedTemplates('@PixelWizard')
|
||||
const unknownTemplates = await fetchPublishedTemplates('@Unknown')
|
||||
|
||||
expect(stoneTemplates).toHaveLength(3)
|
||||
expect(pixelTemplates).toHaveLength(1)
|
||||
expect(pixelTemplates[0].title).toBe('Dreamy Landscapes img2img')
|
||||
expect(unknownTemplates).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('fetchDeveloperReviews returns empty for unknown developers', async () => {
|
||||
const reviews = await fetchDeveloperReviews('@Unknown')
|
||||
expect(reviews).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('fetchTemplateRevenue returns empty for non-monetized developers', async () => {
|
||||
const revenue = await fetchTemplateRevenue('@PixelWizard')
|
||||
expect(revenue).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('fetchDownloadHistory returns 730 days of entries', async () => {
|
||||
const history = await fetchDownloadHistory('@StoneCypher')
|
||||
|
||||
expect(history).toHaveLength(730)
|
||||
for (const entry of history) {
|
||||
expect(entry.date).toBeInstanceOf(Date)
|
||||
expect(entry.downloads).toBeGreaterThanOrEqual(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('fetchDownloadHistory returns entries in chronological order', async () => {
|
||||
const history = await fetchDownloadHistory('@StoneCypher')
|
||||
|
||||
for (let i = 1; i < history.length; i++) {
|
||||
expect(history[i].date.getTime()).toBeGreaterThan(
|
||||
history[i - 1].date.getTime()
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('fetchDownloadHistory produces different data per username', async () => {
|
||||
const stoneHistory = await fetchDownloadHistory('@StoneCypher')
|
||||
const pixelHistory = await fetchDownloadHistory('@PixelWizard')
|
||||
|
||||
const stoneTotal = stoneHistory.reduce((s, e) => s + e.downloads, 0)
|
||||
const pixelTotal = pixelHistory.reduce((s, e) => s + e.downloads, 0)
|
||||
|
||||
expect(stoneTotal).not.toBe(pixelTotal)
|
||||
})
|
||||
})
|
||||
430
src/services/developerProfileService.ts
Normal file
430
src/services/developerProfileService.ts
Normal file
@@ -0,0 +1,430 @@
|
||||
/**
|
||||
* Stub service for fetching and mutating developer profile data.
|
||||
*
|
||||
* Every function is named after the endpoint it will eventually call
|
||||
* but currently returns hardcoded fake data. When multiple developers
|
||||
* are known, the stub dispatches on the `username` parameter so the UI
|
||||
* can be exercised with different profiles.
|
||||
*/
|
||||
import type {
|
||||
DeveloperProfile,
|
||||
DownloadHistoryEntry,
|
||||
MarketplaceTemplate,
|
||||
TemplateReview,
|
||||
TemplateRevenue
|
||||
} from '@/types/templateMarketplace'
|
||||
|
||||
const CURRENT_USERNAME = '@StoneCypher'
|
||||
|
||||
/**
|
||||
* Returns the hardcoded current user's username for profile ownership checks.
|
||||
*/
|
||||
export function getCurrentUsername(): string {
|
||||
return CURRENT_USERNAME
|
||||
}
|
||||
|
||||
/** Stub profiles keyed by handle. */
|
||||
const STUB_PROFILES: Record<string, DeveloperProfile> = {
|
||||
'@StoneCypher': {
|
||||
username: '@StoneCypher',
|
||||
displayName: 'Stone Cypher',
|
||||
avatarUrl: undefined,
|
||||
bannerUrl: undefined,
|
||||
bio: 'Workflow designer and custom node author. Building tools for the ComfyUI community.',
|
||||
isVerified: true,
|
||||
monetizationEnabled: true,
|
||||
joinedAt: new Date('2024-03-15'),
|
||||
dependencies: 371,
|
||||
totalDownloads: 18_420,
|
||||
totalFavorites: 1_273,
|
||||
averageRating: 4.3,
|
||||
templateCount: 3
|
||||
},
|
||||
'@PixelWizard': {
|
||||
username: '@PixelWizard',
|
||||
displayName: 'Pixel Wizard',
|
||||
avatarUrl: undefined,
|
||||
bannerUrl: undefined,
|
||||
bio: 'Generative artist exploring the intersection of AI and traditional media.',
|
||||
isVerified: false,
|
||||
monetizationEnabled: false,
|
||||
joinedAt: new Date('2025-01-10'),
|
||||
dependencies: 12,
|
||||
totalDownloads: 3_840,
|
||||
totalFavorites: 295,
|
||||
averageRating: 4.7,
|
||||
templateCount: 1
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackProfile(username: string): DeveloperProfile {
|
||||
return {
|
||||
username,
|
||||
displayName: username.replace(/^@/, ''),
|
||||
avatarUrl: undefined,
|
||||
bannerUrl: undefined,
|
||||
bio: undefined,
|
||||
isVerified: false,
|
||||
monetizationEnabled: false,
|
||||
joinedAt: new Date(),
|
||||
dependencies: 0,
|
||||
totalDownloads: 0,
|
||||
totalFavorites: 0,
|
||||
averageRating: 0,
|
||||
templateCount: 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a developer's public profile by username.
|
||||
*/
|
||||
export async function fetchDeveloperProfile(
|
||||
username: string
|
||||
): Promise<DeveloperProfile> {
|
||||
// comeback todo — replace with GET /api/v1/developers/{username}
|
||||
return STUB_PROFILES[username] ?? fallbackProfile(username)
|
||||
}
|
||||
|
||||
/** Stub reviews keyed by developer handle. */
|
||||
const STUB_REVIEWS: Record<string, TemplateReview[]> = {
|
||||
'@StoneCypher': [
|
||||
{
|
||||
id: 'rev-1',
|
||||
authorName: 'PixelWizard',
|
||||
authorAvatarUrl: undefined,
|
||||
rating: 5,
|
||||
text: 'Incredible workflow — saved me hours of manual setup. The ControlNet integration is seamless.',
|
||||
createdAt: new Date('2025-11-02'),
|
||||
templateId: 'tpl-1'
|
||||
},
|
||||
{
|
||||
id: 'rev-2',
|
||||
authorName: 'NeuralNomad',
|
||||
authorAvatarUrl: undefined,
|
||||
rating: 3.5,
|
||||
text: 'Good starting point but the VRAM requirements are higher than listed. Works well on a 4090.',
|
||||
createdAt: new Date('2025-10-18'),
|
||||
templateId: 'tpl-2'
|
||||
},
|
||||
{
|
||||
id: 'rev-3',
|
||||
authorName: 'DiffusionDan',
|
||||
authorAvatarUrl: undefined,
|
||||
rating: 4,
|
||||
text: 'Clean graph layout and well-documented. Would love to see a video tutorial added.',
|
||||
createdAt: new Date('2025-09-30'),
|
||||
templateId: 'tpl-1'
|
||||
},
|
||||
{
|
||||
id: 'rev-4',
|
||||
authorName: 'ArtBotAlice',
|
||||
authorAvatarUrl: undefined,
|
||||
rating: 2.5,
|
||||
text: 'Had trouble with the LoRA loader step — might be a version mismatch. Otherwise decent.',
|
||||
createdAt: new Date('2025-08-14'),
|
||||
templateId: 'tpl-3'
|
||||
}
|
||||
],
|
||||
'@PixelWizard': [
|
||||
{
|
||||
id: 'rev-5',
|
||||
authorName: 'Stone Cypher',
|
||||
authorAvatarUrl: undefined,
|
||||
rating: 4.5,
|
||||
text: 'Elegant approach to img2img. The parameter presets are a nice touch.',
|
||||
createdAt: new Date('2025-12-01'),
|
||||
templateId: 'tpl-pw-1'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches reviews left on a developer's published templates.
|
||||
*/
|
||||
export async function fetchDeveloperReviews(
|
||||
username: string
|
||||
): Promise<TemplateReview[]> {
|
||||
// comeback todo — replace with GET /api/v1/developers/{username}/reviews
|
||||
return STUB_REVIEWS[username] ?? []
|
||||
}
|
||||
|
||||
function makeAuthor(
|
||||
username: string,
|
||||
profile: DeveloperProfile
|
||||
): MarketplaceTemplate['author'] {
|
||||
return {
|
||||
id: `usr-${username}`,
|
||||
name: profile.displayName,
|
||||
isVerified: profile.isVerified,
|
||||
profileUrl: `/developers/${username}`
|
||||
}
|
||||
}
|
||||
|
||||
/** Stub templates keyed by developer handle. */
|
||||
function stubTemplatesFor(username: string): MarketplaceTemplate[] {
|
||||
const profile = STUB_PROFILES[username]
|
||||
if (!profile) return []
|
||||
const author = makeAuthor(username, profile)
|
||||
const now = new Date()
|
||||
|
||||
if (username === '@PixelWizard') {
|
||||
return [
|
||||
{
|
||||
id: 'tpl-pw-1',
|
||||
title: 'Dreamy Landscapes img2img',
|
||||
description:
|
||||
'Transform rough sketches into dreamy landscape paintings with guided diffusion.',
|
||||
shortDescription: 'Sketch-to-landscape with img2img',
|
||||
author,
|
||||
categories: ['image-generation'],
|
||||
tags: ['img2img', 'landscape', 'painting'],
|
||||
difficulty: 'beginner',
|
||||
requiredModels: [
|
||||
{ name: 'SD 1.5', type: 'checkpoint', size: 4_200_000_000 }
|
||||
],
|
||||
requiredNodes: [],
|
||||
requiresCustomNodes: [],
|
||||
vramRequirement: 6_000_000_000,
|
||||
thumbnail: '',
|
||||
gallery: [],
|
||||
workflowPreview: '',
|
||||
license: 'cc-by',
|
||||
version: '1.0.0',
|
||||
status: 'approved',
|
||||
publishedAt: new Date('2025-02-15'),
|
||||
updatedAt: now,
|
||||
stats: {
|
||||
downloads: 3_840,
|
||||
favorites: 295,
|
||||
rating: 4.7,
|
||||
reviewCount: 8,
|
||||
weeklyTrend: 3.1
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// @StoneCypher templates
|
||||
return [
|
||||
{
|
||||
id: 'tpl-1',
|
||||
title: 'SDXL Turbo Portrait Studio',
|
||||
description:
|
||||
'End-to-end portrait generation with face correction and upscaling.',
|
||||
shortDescription: 'Fast portrait generation with SDXL Turbo',
|
||||
author,
|
||||
categories: ['image-generation'],
|
||||
tags: ['portrait', 'sdxl', 'turbo'],
|
||||
difficulty: 'beginner',
|
||||
requiredModels: [
|
||||
{ name: 'SDXL Turbo', type: 'checkpoint', size: 6_500_000_000 }
|
||||
],
|
||||
requiredNodes: [],
|
||||
requiresCustomNodes: [],
|
||||
vramRequirement: 8_000_000_000,
|
||||
thumbnail: '',
|
||||
gallery: [],
|
||||
workflowPreview: '',
|
||||
license: 'cc-by',
|
||||
version: '1.2.0',
|
||||
status: 'approved',
|
||||
publishedAt: new Date('2025-06-01'),
|
||||
updatedAt: now,
|
||||
stats: {
|
||||
downloads: 9_200,
|
||||
favorites: 680,
|
||||
rating: 4.5,
|
||||
reviewCount: 42,
|
||||
weeklyTrend: 5.2
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'tpl-2',
|
||||
title: 'ControlNet Depth-to-Image',
|
||||
description:
|
||||
'Depth-guided image generation using MiDaS depth estimation and ControlNet.',
|
||||
shortDescription: 'Depth-guided generation with ControlNet',
|
||||
author,
|
||||
categories: ['image-generation'],
|
||||
tags: ['controlnet', 'depth', 'midas'],
|
||||
difficulty: 'intermediate',
|
||||
requiredModels: [
|
||||
{ name: 'SD 1.5', type: 'checkpoint', size: 4_200_000_000 },
|
||||
{ name: 'ControlNet Depth', type: 'controlnet', size: 1_400_000_000 }
|
||||
],
|
||||
requiredNodes: [],
|
||||
requiresCustomNodes: [],
|
||||
vramRequirement: 10_000_000_000,
|
||||
thumbnail: '',
|
||||
gallery: [],
|
||||
workflowPreview: '',
|
||||
license: 'mit',
|
||||
version: '2.0.1',
|
||||
status: 'approved',
|
||||
publishedAt: new Date('2025-04-12'),
|
||||
updatedAt: now,
|
||||
stats: {
|
||||
downloads: 6_800,
|
||||
favorites: 410,
|
||||
rating: 3.8,
|
||||
reviewCount: 27,
|
||||
weeklyTrend: -1.3
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'tpl-3',
|
||||
title: 'LoRA Style Transfer Pipeline',
|
||||
description:
|
||||
'Apply artistic styles via LoRA fine-tunes with automatic strength scheduling.',
|
||||
shortDescription: 'Artistic style transfer with LoRA',
|
||||
author,
|
||||
categories: ['style-transfer'],
|
||||
tags: ['lora', 'style', 'art'],
|
||||
difficulty: 'advanced',
|
||||
requiredModels: [
|
||||
{ name: 'SD 1.5', type: 'checkpoint', size: 4_200_000_000 },
|
||||
{ name: 'Watercolor LoRA', type: 'lora', size: 150_000_000 }
|
||||
],
|
||||
requiredNodes: [],
|
||||
requiresCustomNodes: [],
|
||||
vramRequirement: 6_000_000_000,
|
||||
thumbnail: '',
|
||||
gallery: [],
|
||||
workflowPreview: '',
|
||||
license: 'cc-by-sa',
|
||||
version: '1.0.0',
|
||||
status: 'approved',
|
||||
publishedAt: new Date('2025-08-20'),
|
||||
updatedAt: now,
|
||||
stats: {
|
||||
downloads: 2_420,
|
||||
favorites: 183,
|
||||
rating: 4.1,
|
||||
reviewCount: 11,
|
||||
weeklyTrend: 12.7
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches all templates published by a developer.
|
||||
*/
|
||||
export async function fetchPublishedTemplates(
|
||||
username: string
|
||||
): Promise<MarketplaceTemplate[]> {
|
||||
// comeback todo — replace with GET /api/v1/developers/{username}/templates
|
||||
return stubTemplatesFor(username)
|
||||
}
|
||||
|
||||
/** Stub revenue keyed by developer handle. */
|
||||
const STUB_REVENUE: Record<string, TemplateRevenue[]> = {
|
||||
'@StoneCypher': [
|
||||
{
|
||||
templateId: 'tpl-1',
|
||||
totalRevenue: 45_230,
|
||||
monthlyRevenue: 3_800,
|
||||
currency: 'USD'
|
||||
},
|
||||
{
|
||||
templateId: 'tpl-2',
|
||||
totalRevenue: 22_110,
|
||||
monthlyRevenue: 1_450,
|
||||
currency: 'USD'
|
||||
},
|
||||
{
|
||||
templateId: 'tpl-3',
|
||||
totalRevenue: 8_920,
|
||||
monthlyRevenue: 920,
|
||||
currency: 'USD'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches revenue data for a developer's templates.
|
||||
* Only accessible by the template author.
|
||||
*/
|
||||
export async function fetchTemplateRevenue(
|
||||
username: string
|
||||
): Promise<TemplateRevenue[]> {
|
||||
// comeback todo — replace with GET /api/v1/developers/{username}/revenue
|
||||
return STUB_REVENUE[username] ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* Unpublishes a template by its ID.
|
||||
*/
|
||||
export async function unpublishTemplate(_templateId: string): Promise<void> {
|
||||
// comeback todo — replace with POST /api/v1/templates/{templateId}/unpublish
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves changes to a developer's profile.
|
||||
*/
|
||||
export async function saveDeveloperProfile(
|
||||
profile: Partial<DeveloperProfile>
|
||||
): Promise<DeveloperProfile> {
|
||||
// comeback todo — replace with PUT /api/v1/developers/{username}
|
||||
const base =
|
||||
STUB_PROFILES[profile.username ?? CURRENT_USERNAME] ??
|
||||
fallbackProfile(profile.username ?? CURRENT_USERNAME)
|
||||
return { ...base, ...profile } as DeveloperProfile
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a deterministic pseudo-random daily download history starting
|
||||
* from 730 days ago (two years). The seed is derived from the username so
|
||||
* different profiles produce different but stable curves.
|
||||
*/
|
||||
function stubDownloadHistory(username: string): DownloadHistoryEntry[] {
|
||||
const days = 730
|
||||
const entries: DownloadHistoryEntry[] = []
|
||||
const baseDownloads = username === '@StoneCypher' ? 42 : 12
|
||||
|
||||
let seed = 0
|
||||
for (const ch of username) seed = (seed * 31 + ch.charCodeAt(0)) | 0
|
||||
|
||||
function nextRand(): number {
|
||||
seed = (seed * 16807 + 0) % 2147483647
|
||||
return (seed & 0x7fffffff) / 2147483647
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
const date = new Date(now)
|
||||
date.setDate(date.getDate() - i)
|
||||
date.setHours(0, 0, 0, 0)
|
||||
|
||||
const dayOfWeek = date.getDay()
|
||||
const weekendMultiplier = dayOfWeek === 0 || dayOfWeek === 6 ? 1.4 : 1.0
|
||||
const trendMultiplier = 1 + (days - i) / days
|
||||
const noise = 0.5 + nextRand()
|
||||
|
||||
entries.push({
|
||||
date,
|
||||
downloads: Math.round(
|
||||
baseDownloads * weekendMultiplier * trendMultiplier * noise
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches daily download history for a developer's templates.
|
||||
*
|
||||
* Returns one entry per day, spanning two years, in chronological order.
|
||||
* Each entry records the aggregate download count across all of the
|
||||
* developer's published templates for that calendar day.
|
||||
*
|
||||
* @param username - Developer handle (e.g. '@StoneCypher').
|
||||
* @returns Chronologically-ordered daily download entries.
|
||||
*/
|
||||
export async function fetchDownloadHistory(
|
||||
username: string
|
||||
): Promise<DownloadHistoryEntry[]> {
|
||||
// comeback
|
||||
return stubDownloadHistory(username)
|
||||
}
|
||||
236
src/types/templateMarketplace.ts
Normal file
236
src/types/templateMarketplace.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* An in-memory cached file awaiting upload, retaining its original filename
|
||||
* and a blob URL for local preview.
|
||||
*/
|
||||
export interface CachedAsset {
|
||||
/** The raw File object from the user's file picker. */
|
||||
file: File
|
||||
/** A `blob:` URL created via `URL.createObjectURL` for local display. */
|
||||
objectUrl: string
|
||||
/** The filename as it appeared on the user's filesystem. */
|
||||
originalName: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A workflow template listed in the marketplace, including its metadata,
|
||||
* media assets, technical requirements, and publication status.
|
||||
*/
|
||||
export interface MarketplaceTemplate {
|
||||
/** Unique identifier for this template. */
|
||||
id: string
|
||||
/** Display title shown in the marketplace. */
|
||||
title: string
|
||||
/** Full description with usage details and context. */
|
||||
description: string
|
||||
/** One-line summary shown in card views and search results. */
|
||||
shortDescription: string
|
||||
/** Profile of the template author. */
|
||||
author: AuthorInfo
|
||||
|
||||
/** Semantic categories (e.g. 'image-generation', 'audio'). */
|
||||
categories: string[]
|
||||
/** Freeform tags for search and filtering. */
|
||||
tags: string[]
|
||||
/** Skill level assumed by the template's instructions and complexity. */
|
||||
difficulty: 'beginner' | 'intermediate' | 'advanced'
|
||||
|
||||
/** Model files that must be downloaded before running this template. */
|
||||
requiredModels: ModelRequirement[]
|
||||
/** Custom node package IDs required by this template. */
|
||||
requiredNodes: string[]
|
||||
/**
|
||||
* Custom node package IDs (folder names from `custom_nodes/`) required by
|
||||
* this template. Derived from the `python_module` of each custom node
|
||||
* definition found in the workflow graph.
|
||||
*/
|
||||
requiresCustomNodes: string[]
|
||||
/** Minimum VRAM in bytes needed to run this template. */
|
||||
vramRequirement: number
|
||||
|
||||
/** URL to the primary thumbnail image. */
|
||||
thumbnail: string
|
||||
/** URL to the "before" image in a before/after comparison. */
|
||||
beforeImage?: string
|
||||
/** URL to the "after" image in a before/after comparison. */
|
||||
afterImage?: string
|
||||
/** Ordered collection of images and videos showcasing the template. */
|
||||
gallery: GalleryItem[]
|
||||
/** URL to an optional video walkthrough or demo. */
|
||||
videoPreview?: string
|
||||
/** URL to a static image preview of the workflow graph. */
|
||||
workflowPreview: string
|
||||
|
||||
/** License governing usage and redistribution. */
|
||||
license: LicenseType
|
||||
/** URL to an external tutorial or guide for this template. */
|
||||
tutorialUrl?: string
|
||||
/** Semantic version string (e.g. '1.2.0'). */
|
||||
version: string
|
||||
|
||||
/** Current publication lifecycle stage. */
|
||||
status: 'draft' | 'pending_review' | 'approved' | 'rejected' | 'unpublished'
|
||||
/** Reviewer notes returned when the template is rejected. */
|
||||
reviewFeedback?: string
|
||||
/** Timestamp when the template was first published. */
|
||||
publishedAt?: Date
|
||||
/** Timestamp of the most recent content update. */
|
||||
updatedAt: Date
|
||||
|
||||
/** Aggregate engagement and popularity metrics. */
|
||||
stats: TemplateStats
|
||||
}
|
||||
|
||||
/**
|
||||
* Author profile information displayed alongside a marketplace template.
|
||||
*/
|
||||
export interface AuthorInfo {
|
||||
/** Unique author identifier. */
|
||||
id: string
|
||||
/** Display name. */
|
||||
name: string
|
||||
/** URL to the author's avatar image. */
|
||||
avatarUrl?: string
|
||||
/** Whether the author's identity has been verified by the platform. */
|
||||
isVerified: boolean
|
||||
/** URL to the author's public profile page. */
|
||||
profileUrl: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate engagement and popularity statistics for a marketplace template.
|
||||
*/
|
||||
export interface TemplateStats {
|
||||
/** Total number of times this template has been downloaded. */
|
||||
downloads: number
|
||||
/** Number of users who have favorited this template. */
|
||||
favorites: number
|
||||
/** Average user rating (e.g. 0-5 scale). */
|
||||
rating: number
|
||||
/** Total number of user reviews submitted. */
|
||||
reviewCount: number
|
||||
/** Week-over-week percentage change in downloads. */
|
||||
weeklyTrend: number
|
||||
}
|
||||
|
||||
/**
|
||||
* A single image or video entry in a template's gallery.
|
||||
*/
|
||||
export interface GalleryItem {
|
||||
/** Media format of this gallery entry. */
|
||||
type: 'image' | 'video'
|
||||
/** URL to the media asset. */
|
||||
url: string
|
||||
/** Optional descriptive caption displayed below the media. */
|
||||
caption?: string
|
||||
/** When true, marks this item as the "before" half of a before/after pair. */
|
||||
isBefore?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* A model dependency required to run a marketplace template.
|
||||
*/
|
||||
export interface ModelRequirement {
|
||||
/** Human-readable model name. */
|
||||
name: string
|
||||
/** Architecture category of the model. */
|
||||
type: 'checkpoint' | 'lora' | 'controlnet' | 'vae'
|
||||
/** Download URL for the model file. */
|
||||
url?: string
|
||||
/** File size in bytes. */
|
||||
size: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Supported license types for marketplace templates.
|
||||
*/
|
||||
export type LicenseType =
|
||||
| 'cc-by'
|
||||
| 'cc-by-sa'
|
||||
| 'cc-by-nc'
|
||||
| 'mit'
|
||||
| 'apache'
|
||||
| 'custom'
|
||||
|
||||
/**
|
||||
* A developer's public profile containing their identity,
|
||||
* published templates, and aggregate statistics.
|
||||
*/
|
||||
export interface DeveloperProfile {
|
||||
/** Handle shown in URLs and mentions (e.g. '@StoneCypher'). */
|
||||
username: string
|
||||
/** Human-readable display name. */
|
||||
displayName: string
|
||||
/** URL to the developer's avatar image. */
|
||||
avatarUrl?: string
|
||||
/** URL to a wide banner image displayed at the top of the profile. */
|
||||
bannerUrl?: string
|
||||
/** Short biography or tagline. */
|
||||
bio?: string
|
||||
/** Whether the developer's identity has been verified by the platform. */
|
||||
isVerified: boolean
|
||||
/** Whether the developer has opted into revenue sharing. */
|
||||
monetizationEnabled: boolean
|
||||
/** Date the developer joined the platform. */
|
||||
joinedAt: Date
|
||||
/** Number of other templates that depend on this developer's work. */
|
||||
dependencies: number
|
||||
/** Lifetime download count across all published templates. */
|
||||
totalDownloads: number
|
||||
/** Lifetime favorite count across all published templates. */
|
||||
totalFavorites: number
|
||||
/** Average star rating across all published templates. */
|
||||
averageRating: number
|
||||
/** Number of published templates. */
|
||||
templateCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* A user review of a marketplace template, including a star rating
|
||||
* and optional text commentary.
|
||||
*/
|
||||
export interface TemplateReview {
|
||||
/** Unique review identifier. */
|
||||
id: string
|
||||
/** Display name of the reviewer. */
|
||||
authorName: string
|
||||
/** URL to the reviewer's avatar image. */
|
||||
authorAvatarUrl?: string
|
||||
/** Star rating from 1 to 5, supporting 0.5 increments. */
|
||||
rating: number
|
||||
/** Review body text. */
|
||||
text: string
|
||||
/** When the review was submitted. */
|
||||
createdAt: Date
|
||||
/** ID of the template being reviewed. */
|
||||
templateId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A single day's download count in a developer's download history.
|
||||
*/
|
||||
export interface DownloadHistoryEntry {
|
||||
/** The calendar date this count represents (midnight UTC). */
|
||||
date: Date
|
||||
/** Number of downloads recorded on this date. */
|
||||
downloads: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Time range options for the download history chart.
|
||||
*/
|
||||
export type DownloadHistoryRange = 'week' | 'month' | 'year' | 'allTime'
|
||||
|
||||
/**
|
||||
* Revenue information for a single published template,
|
||||
* visible only to the template's author when monetization is enabled.
|
||||
*/
|
||||
export interface TemplateRevenue {
|
||||
/** ID of the template this revenue data belongs to. */
|
||||
templateId: string
|
||||
/** Lifetime revenue in cents. */
|
||||
totalRevenue: number
|
||||
/** Revenue earned in the current calendar month, in cents. */
|
||||
monthlyRevenue: number
|
||||
/** ISO 4217 currency code (e.g. 'USD'). */
|
||||
currency: string
|
||||
}
|
||||
304
src/utils/templateSimilarity.test.ts
Normal file
304
src/utils/templateSimilarity.test.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { TemplateSimilarityInput } from '@/utils/templateSimilarity'
|
||||
|
||||
import {
|
||||
findSimilarTemplates,
|
||||
toSimilarityInput
|
||||
} from '@/utils/templateSimilarity'
|
||||
|
||||
function makeTemplate(
|
||||
overrides: Partial<TemplateSimilarityInput> & { name: string }
|
||||
): TemplateSimilarityInput {
|
||||
return {
|
||||
categories: [],
|
||||
tags: [],
|
||||
models: [],
|
||||
requiredNodes: [],
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('templateSimilarity', () => {
|
||||
describe('findSimilarTemplates', () => {
|
||||
it('returns templates sorted by descending similarity score', () => {
|
||||
const reference = makeTemplate({
|
||||
name: 'ref',
|
||||
categories: ['image-generation'],
|
||||
tags: ['flux', 'txt2img'],
|
||||
models: ['flux-dev']
|
||||
})
|
||||
const bestMatch = makeTemplate({
|
||||
name: 'best',
|
||||
categories: ['image-generation'],
|
||||
tags: ['flux', 'txt2img'],
|
||||
models: ['flux-dev']
|
||||
})
|
||||
const partialMatch = makeTemplate({
|
||||
name: 'partial',
|
||||
categories: ['image-generation'],
|
||||
tags: ['sdxl']
|
||||
})
|
||||
const weakMatch = makeTemplate({
|
||||
name: 'weak',
|
||||
tags: ['flux']
|
||||
})
|
||||
|
||||
const results = findSimilarTemplates(reference, [
|
||||
weakMatch,
|
||||
bestMatch,
|
||||
partialMatch
|
||||
])
|
||||
|
||||
expect(results.map((r) => r.template.name)).toEqual([
|
||||
'best',
|
||||
'partial',
|
||||
'weak'
|
||||
])
|
||||
})
|
||||
|
||||
it('excludes the reference template from results', () => {
|
||||
const reference = makeTemplate({
|
||||
name: 'ref',
|
||||
categories: ['image-generation']
|
||||
})
|
||||
const clone = makeTemplate({
|
||||
name: 'ref',
|
||||
categories: ['image-generation']
|
||||
})
|
||||
const other = makeTemplate({
|
||||
name: 'other',
|
||||
categories: ['image-generation']
|
||||
})
|
||||
|
||||
const results = findSimilarTemplates(reference, [clone, other])
|
||||
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0].template.name).toBe('other')
|
||||
})
|
||||
|
||||
it('excludes templates with zero similarity', () => {
|
||||
const reference = makeTemplate({
|
||||
name: 'ref',
|
||||
categories: ['image-generation'],
|
||||
tags: ['flux']
|
||||
})
|
||||
const noOverlap = makeTemplate({
|
||||
name: 'none',
|
||||
categories: ['audio'],
|
||||
tags: ['tts']
|
||||
})
|
||||
|
||||
const results = findSimilarTemplates(reference, [noOverlap])
|
||||
|
||||
expect(results).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('respects the limit parameter', () => {
|
||||
const reference = makeTemplate({
|
||||
name: 'ref',
|
||||
categories: ['image-generation']
|
||||
})
|
||||
const candidates = Array.from({ length: 20 }, (_, i) =>
|
||||
makeTemplate({ name: `t-${i}`, categories: ['image-generation'] })
|
||||
)
|
||||
|
||||
const results = findSimilarTemplates(reference, candidates, 5)
|
||||
|
||||
expect(results).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('returns empty array when no candidates match', () => {
|
||||
const reference = makeTemplate({
|
||||
name: 'ref',
|
||||
categories: ['video-generation']
|
||||
})
|
||||
const candidates = [
|
||||
makeTemplate({ name: 'a', categories: ['audio'] }),
|
||||
makeTemplate({ name: 'b', categories: ['text'] })
|
||||
]
|
||||
|
||||
expect(findSimilarTemplates(reference, candidates)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array when candidates list is empty', () => {
|
||||
const reference = makeTemplate({ name: 'ref', tags: ['flux'] })
|
||||
|
||||
expect(findSimilarTemplates(reference, [])).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array when all templates have empty metadata', () => {
|
||||
const reference = makeTemplate({ name: 'ref' })
|
||||
const candidates = [
|
||||
makeTemplate({ name: 'a' }),
|
||||
makeTemplate({ name: 'b' })
|
||||
]
|
||||
|
||||
expect(findSimilarTemplates(reference, candidates)).toEqual([])
|
||||
})
|
||||
|
||||
it('ranks category match higher than tag-only match', () => {
|
||||
const reference = makeTemplate({
|
||||
name: 'ref',
|
||||
categories: ['image-generation'],
|
||||
tags: ['flux']
|
||||
})
|
||||
const categoryOnly = makeTemplate({
|
||||
name: 'cat',
|
||||
categories: ['image-generation']
|
||||
})
|
||||
const tagOnly = makeTemplate({
|
||||
name: 'tag',
|
||||
tags: ['flux']
|
||||
})
|
||||
|
||||
const results = findSimilarTemplates(reference, [tagOnly, categoryOnly])
|
||||
|
||||
expect(results[0].template.name).toBe('cat')
|
||||
expect(results[0].score).toBeGreaterThan(results[1].score)
|
||||
})
|
||||
|
||||
it('ranks shared models higher than shared tags', () => {
|
||||
const reference = makeTemplate({
|
||||
name: 'ref',
|
||||
tags: ['txt2img'],
|
||||
models: ['flux-dev']
|
||||
})
|
||||
const modelMatch = makeTemplate({
|
||||
name: 'model',
|
||||
models: ['flux-dev']
|
||||
})
|
||||
const tagMatch = makeTemplate({
|
||||
name: 'tag',
|
||||
tags: ['txt2img']
|
||||
})
|
||||
|
||||
const results = findSimilarTemplates(reference, [tagMatch, modelMatch])
|
||||
|
||||
expect(results[0].template.name).toBe('model')
|
||||
expect(results[0].score).toBeGreaterThan(results[1].score)
|
||||
})
|
||||
|
||||
it('scores identical templates at 1.0', () => {
|
||||
const reference = makeTemplate({
|
||||
name: 'ref',
|
||||
categories: ['image-generation'],
|
||||
tags: ['flux', 'txt2img'],
|
||||
models: ['flux-dev'],
|
||||
requiredNodes: ['node-a']
|
||||
})
|
||||
const identical = makeTemplate({
|
||||
name: 'twin',
|
||||
categories: ['image-generation'],
|
||||
tags: ['flux', 'txt2img'],
|
||||
models: ['flux-dev'],
|
||||
requiredNodes: ['node-a']
|
||||
})
|
||||
|
||||
const results = findSimilarTemplates(reference, [identical])
|
||||
|
||||
expect(results[0].score).toBeCloseTo(1.0)
|
||||
})
|
||||
|
||||
it('scores a real-world scenario correctly', () => {
|
||||
const reference = makeTemplate({
|
||||
name: 'flux-basic',
|
||||
categories: ['image-generation'],
|
||||
tags: ['flux', 'txt2img'],
|
||||
models: ['flux-dev']
|
||||
})
|
||||
const similar = makeTemplate({
|
||||
name: 'flux-advanced',
|
||||
categories: ['image-generation'],
|
||||
tags: ['flux', 'img2img'],
|
||||
models: ['flux-dev']
|
||||
})
|
||||
const unrelated = makeTemplate({
|
||||
name: 'audio-gen',
|
||||
categories: ['audio'],
|
||||
tags: ['tts', 'speech'],
|
||||
models: ['bark']
|
||||
})
|
||||
|
||||
const results = findSimilarTemplates(reference, [unrelated, similar])
|
||||
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0].template.name).toBe('flux-advanced')
|
||||
expect(results[0].score).toBeGreaterThan(0.5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('toSimilarityInput', () => {
|
||||
it('wraps single category string into array', () => {
|
||||
const result = toSimilarityInput({
|
||||
name: 'test',
|
||||
category: 'image-generation',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png',
|
||||
description: ''
|
||||
})
|
||||
|
||||
expect(result.categories).toEqual(['image-generation'])
|
||||
})
|
||||
|
||||
it('returns empty categories when category is undefined', () => {
|
||||
const result = toSimilarityInput({
|
||||
name: 'test',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png',
|
||||
description: ''
|
||||
})
|
||||
|
||||
expect(result.categories).toEqual([])
|
||||
})
|
||||
|
||||
it('passes tags through unchanged', () => {
|
||||
const result = toSimilarityInput({
|
||||
name: 'test',
|
||||
tags: ['flux', 'txt2img'],
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png',
|
||||
description: ''
|
||||
})
|
||||
|
||||
expect(result.tags).toEqual(['flux', 'txt2img'])
|
||||
})
|
||||
|
||||
it('passes models through unchanged', () => {
|
||||
const result = toSimilarityInput({
|
||||
name: 'test',
|
||||
models: ['flux-dev', 'sd-vae'],
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png',
|
||||
description: ''
|
||||
})
|
||||
|
||||
expect(result.models).toEqual(['flux-dev', 'sd-vae'])
|
||||
})
|
||||
|
||||
it('maps requiresCustomNodes to requiredNodes', () => {
|
||||
const result = toSimilarityInput({
|
||||
name: 'test',
|
||||
requiresCustomNodes: ['node-a', 'node-b'],
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png',
|
||||
description: ''
|
||||
})
|
||||
|
||||
expect(result.requiredNodes).toEqual(['node-a', 'node-b'])
|
||||
})
|
||||
|
||||
it('defaults missing arrays to empty arrays', () => {
|
||||
const result = toSimilarityInput({
|
||||
name: 'test',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png',
|
||||
description: ''
|
||||
})
|
||||
|
||||
expect(result.tags).toEqual([])
|
||||
expect(result.models).toEqual([])
|
||||
expect(result.requiredNodes).toEqual([])
|
||||
})
|
||||
})
|
||||
})
|
||||
113
src/utils/templateSimilarity.ts
Normal file
113
src/utils/templateSimilarity.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
||||
|
||||
import { intersection, union } from 'es-toolkit'
|
||||
|
||||
/**
|
||||
* Minimal metadata fields used by the similarity scorer.
|
||||
* Both EnhancedTemplate and MarketplaceTemplate can satisfy this
|
||||
* interface directly or via {@link toSimilarityInput}.
|
||||
*/
|
||||
export interface TemplateSimilarityInput {
|
||||
readonly name: string
|
||||
readonly categories?: readonly string[]
|
||||
readonly tags?: readonly string[]
|
||||
readonly models?: readonly string[]
|
||||
readonly requiredNodes?: readonly string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* A candidate template paired with its similarity score.
|
||||
*/
|
||||
export interface SimilarTemplate<T extends TemplateSimilarityInput> {
|
||||
readonly template: T
|
||||
readonly score: number
|
||||
}
|
||||
|
||||
/** Per-dimension weights for the similarity formula. */
|
||||
const SIMILARITY_WEIGHTS = {
|
||||
categories: 0.35,
|
||||
tags: 0.25,
|
||||
models: 0.3,
|
||||
requiredNodes: 0.1
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Compute Jaccard similarity between two string arrays.
|
||||
* Returns 0 when both arrays are empty (no evidence of similarity).
|
||||
*/
|
||||
function jaccardSimilarity(a: readonly string[], b: readonly string[]): number {
|
||||
const u = union([...a], [...b])
|
||||
if (u.length === 0) return 0
|
||||
return intersection([...a], [...b]).length / u.length
|
||||
}
|
||||
|
||||
/**
|
||||
* Score the similarity between two templates based on shared metadata.
|
||||
* Returns a value in [0, 1] where 1 is identical and 0 is no overlap.
|
||||
*/
|
||||
export function computeSimilarity(
|
||||
reference: TemplateSimilarityInput,
|
||||
candidate: TemplateSimilarityInput
|
||||
): number {
|
||||
return (
|
||||
SIMILARITY_WEIGHTS.categories *
|
||||
jaccardSimilarity(
|
||||
reference.categories ?? [],
|
||||
candidate.categories ?? []
|
||||
) +
|
||||
SIMILARITY_WEIGHTS.tags *
|
||||
jaccardSimilarity(reference.tags ?? [], candidate.tags ?? []) +
|
||||
SIMILARITY_WEIGHTS.models *
|
||||
jaccardSimilarity(reference.models ?? [], candidate.models ?? []) +
|
||||
SIMILARITY_WEIGHTS.requiredNodes *
|
||||
jaccardSimilarity(
|
||||
reference.requiredNodes ?? [],
|
||||
candidate.requiredNodes ?? []
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Find templates similar to a reference, sorted by descending similarity.
|
||||
* Excludes the reference template itself (matched by name) and any
|
||||
* candidates with zero similarity.
|
||||
*
|
||||
* @param reference - The template to find similar templates for
|
||||
* @param candidates - The pool of templates to score against
|
||||
* @param limit - Maximum number of results to return (default: 10)
|
||||
* @returns Sorted array of similar templates with their scores
|
||||
*/
|
||||
export function findSimilarTemplates<T extends TemplateSimilarityInput>(
|
||||
reference: TemplateSimilarityInput,
|
||||
candidates: readonly T[],
|
||||
limit: number = 10
|
||||
): SimilarTemplate<T>[] {
|
||||
return candidates
|
||||
.filter((c) => c.name !== reference.name)
|
||||
.map((candidate) => ({
|
||||
template: candidate,
|
||||
score: computeSimilarity(reference, candidate)
|
||||
}))
|
||||
.filter((result) => result.score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, limit)
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize an EnhancedTemplate (or TemplateInfo with category) to
|
||||
* the shape expected by the similarity scorer.
|
||||
*
|
||||
* Wraps the single `category` string into an array and maps
|
||||
* `requiresCustomNodes` to `requiredNodes`.
|
||||
*/
|
||||
export function toSimilarityInput(
|
||||
template: TemplateInfo & { category?: string }
|
||||
): TemplateSimilarityInput {
|
||||
return {
|
||||
name: template.name,
|
||||
categories: template.category ? [template.category] : [],
|
||||
tags: template.tags ?? [],
|
||||
models: template.models ?? [],
|
||||
requiredNodes: template.requiresCustomNodes ?? []
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,10 @@
|
||||
v-show="!linearMode"
|
||||
id="graph-canvas-container"
|
||||
ref="graphCanvasContainerRef"
|
||||
class="graph-canvas-container"
|
||||
class="graph-canvas-container relative"
|
||||
>
|
||||
<GraphCanvas @ready="onGraphReady" />
|
||||
<VramEstimateIndicator />
|
||||
</div>
|
||||
<LinearView v-if="linearMode" />
|
||||
<BuilderToolbar v-if="appModeStore.isBuilderMode" />
|
||||
@@ -46,6 +47,7 @@ import { runWhenGlobalIdle } from '@/base/common/async'
|
||||
import MenuHamburger from '@/components/MenuHamburger.vue'
|
||||
import UnloadWindowConfirmDialog from '@/components/dialog/UnloadWindowConfirmDialog.vue'
|
||||
import GraphCanvas from '@/components/graph/GraphCanvas.vue'
|
||||
import VramEstimateIndicator from '@/components/graph/VramEstimateIndicator.vue'
|
||||
import GlobalToast from '@/components/toast/GlobalToast.vue'
|
||||
import InviteAcceptedToast from '@/platform/workspace/components/toasts/InviteAcceptedToast.vue'
|
||||
import RerouteMigrationToast from '@/components/toast/RerouteMigrationToast.vue'
|
||||
|
||||
Reference in New Issue
Block a user