feat: template publishing dialog, stepper, and step components

Rename template marketplace to template publishing throughout:
- Replace useTemplateMarketplaceDialog with useTemplatePublishingDialog
- Update core command from Comfy.ShowTemplateMarketplace to
  Comfy.ShowTemplatePublishing
- Update workflow actions menu label and command reference

Add multi-step publishing dialog infrastructure:
- TemplatePublishingDialog.vue with step-based navigation
- TemplatePublishingStepperNav.vue for step progress indicator
- useTemplatePublishingStepper composable managing step state,
  navigation, and validation
- Step components for each phase: landing, metadata, description,
  preview generation, category/tagging, preview, submission, complete
- StepTemplatePublishingMetadata with form fields for title, category,
  tags, difficulty, and license selection
- StepTemplatePublishingDescription with markdown editor and live
  preview via vue-i18n

Add comprehensive i18n entries for all publishing steps, form labels,
difficulty levels, license types, and category names.

Add tests for dialog lifecycle, stepper navigation/validation, metadata
form interaction, and description editing.

Bump version to 1.42.0.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
John Haugeland
2026-02-24 13:30:25 -08:00
parent b638e6a577
commit fdd963a630
23 changed files with 1790 additions and 18 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.41.3",
"version": "1.42.0",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",

View File

@@ -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()
})
})

View File

@@ -0,0 +1,153 @@
<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="flex gap-2">
<Button
:disabled="isFirstStep"
variant="secondary"
size="lg"
@click="prevStep"
>
<i class="icon-[lucide--arrow-left]" />
{{ t('templatePublishing.previous') }}
</Button>
<Button variant="secondary" size="lg" @click="saveDraft">
<i class="icon-[lucide--save]" />
{{ t('templatePublishing.saveDraft') }}
</Button>
<Button
:disabled="currentStep >= totalSteps - 1"
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>

View File

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

View File

@@ -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
"
>
&#127881;</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>

View File

@@ -0,0 +1,16 @@
<template>
<div class="flex flex-col gap-6 p-6">
<h2 class="text-lg font-semibold">
{{ t('templatePublishing.steps.categoryAndTagging.title') }}
</h2>
<p class="text-muted-foreground">
{{ t('templatePublishing.steps.categoryAndTagging.description') }}
</p>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>

View File

@@ -0,0 +1,16 @@
<template>
<div class="flex flex-col gap-6 p-6">
<h2 class="text-lg font-semibold">
{{ t('templatePublishing.steps.complete.title') }}
</h2>
<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>

View File

@@ -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>')
})
})

View File

@@ -0,0 +1,47 @@
<template>
<div class="flex h-full flex-col gap-4 p-6">
<div class="flex min-h-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-h-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>

View File

@@ -0,0 +1,25 @@
<template>
<div class="flex flex-col gap-6 p-6">
<h2 class="text-lg font-semibold">
{{ t('templatePublishing.steps.landing.title') }}
</h2>
<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>

View File

@@ -0,0 +1,234 @@
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 StepTemplatePublishingMetadata from './StepTemplatePublishingMetadata.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: {
title: 'Metadata',
description: 'Title, description, and author info',
titleLabel: 'Title',
categoryLabel: 'Categories',
tagsLabel: 'Tags',
tagsPlaceholder: 'Type to search tags…',
difficultyLabel: 'Difficulty',
licenseLabel: 'License',
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'
}
}
}
}
}
}
})
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 heading and all form fields', () => {
const { wrapper } = mountStep()
expect(wrapper.find('h2').text()).toBe('Metadata')
expect(wrapper.find('[data-testid="form-item-tpl-title"]').exists()).toBe(
true
)
expect(wrapper.text()).toContain('Difficulty')
expect(wrapper.find('[data-testid="form-item-tpl-license"]').exists()).toBe(
true
)
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')
})
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')
})
})

View File

@@ -0,0 +1,290 @@
<template>
<div class="flex flex-col gap-6 p-6">
<h2 class="text-lg font-semibold">
{{ t('templatePublishing.steps.metadata.title') }}
</h2>
<p class="text-muted-foreground">
{{ t('templatePublishing.steps.metadata.description') }}
</p>
<FormItem
id="tpl-title"
v-model:form-value="ctx.template.value.title"
:item="titleField"
/>
<div class="flex flex-row items-center gap-2">
<div class="form-label flex grow 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 grow items-center">
<span id="tpl-tags-label" class="text-muted">
{{ t('templatePublishing.steps.metadata.tagsLabel') }}
</span>
</div>
<div class="flex flex-col gap-2">
<div class="relative">
<input
v-model="tagQuery"
type="text"
class="h-8 w-44 rounded border border-border-default bg-transparent 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-comfy-menu-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
v-if="ctx.template.value.tags?.length"
class="flex flex-wrap gap-1"
>
<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>
</div>
<div class="flex flex-row items-center gap-2">
<div class="form-label flex grow 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 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>
</template>
<script setup lang="ts">
import { computed, inject, ref } from 'vue'
import { watchDebounced } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import FormItem from '@/components/common/FormItem.vue'
import type { FormItem as FormItemType } from '@/platform/settings/types'
import { cn } from '@/utils/tailwindUtil'
import { PublishingStepperKey } from '../types'
const { t } = useI18n()
const ctx = inject(PublishingStepperKey)!
const CATEGORIES = [
{ key: 'imageGeneration', value: 'image-generation' },
{ key: 'videoGeneration', value: 'video-generation' },
{ key: 'audio', value: 'audio' },
{ key: 'text', value: 'text' },
{ key: 'threeD', value: '3d' },
{ key: 'upscaling', value: 'upscaling' },
{ key: 'inpainting', value: 'inpainting' },
{ key: 'controlNet', value: 'controlnet' },
{ key: 'styleTransfer', value: 'style-transfer' },
{ key: 'other', value: 'other' }
] 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 titleField: FormItemType = {
name: t('templatePublishing.steps.metadata.titleLabel'),
type: 'text'
}
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 }
}
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>

View File

@@ -0,0 +1,16 @@
<template>
<div class="flex flex-col gap-6 p-6">
<h2 class="text-lg font-semibold">
{{ t('templatePublishing.steps.preview.title') }}
</h2>
<p class="text-muted-foreground">
{{ t('templatePublishing.steps.preview.description') }}
</p>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>

View File

@@ -0,0 +1,16 @@
<template>
<div class="flex flex-col gap-6 p-6">
<h2 class="text-lg font-semibold">
{{ t('templatePublishing.steps.previewGeneration.title') }}
</h2>
<p class="text-muted-foreground">
{{ t('templatePublishing.steps.previewGeneration.description') }}
</p>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>

View File

@@ -0,0 +1,16 @@
<template>
<div class="flex flex-col gap-6 p-6">
<h2 class="text-lg font-semibold">
{{ t('templatePublishing.steps.submissionForReview.title') }}
</h2>
<p class="text-muted-foreground">
{{ t('templatePublishing.steps.submissionForReview.description') }}
</p>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>

View 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'
}
]

View File

@@ -65,7 +65,7 @@ import {
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
import { useWorkflowTemplateSelectorDialog } from './useWorkflowTemplateSelectorDialog'
import { useTemplateMarketplaceDialog } from './useTemplateMarketplaceDialog'
import { useTemplatePublishingDialog } from './useTemplatePublishingDialog'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import { useDialogStore } from '@/stores/dialogStore'
@@ -348,11 +348,11 @@ export function useCoreCommands(): ComfyCommand[] {
},
{
// comeback
id: 'Comfy.ShowTemplateMarketplace',
id: 'Comfy.ShowTemplatePublishing',
icon: 'pi pi-objects-column',
label: 'Show Template Marketplace',
label: 'Show Template Publishing',
function: () => {
useTemplateMarketplaceDialog().show()
useTemplatePublishingDialog().show()
}
},
{

View 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'
})
})
})
})

View File

@@ -1,12 +1,15 @@
import { h } from 'vue'
import TemplatePublishingDialog from '@/components/templatePublishing/TemplatePublishingDialog.vue'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
const DIALOG_KEY = 'global-workflow-template-selector'
// const GETTING_STARTED_CATEGORY_ID = 'basics-getting-started' // comeback when there are tabs to pick between, or remove if they're fundamentally ordered
const DIALOG_KEY = 'global-template-publishing'
export const useTemplateMarketplaceDialog = () => {
/**
* 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()
@@ -16,12 +19,11 @@ export const useTemplateMarketplaceDialog = () => {
function show(options?: { initialPage?: string }) {
// comeback need a new telemetry for this
// useTelemetry()?.trackTemplateLibraryOpened({ source })
// useTelemetry()?.trackTemplatePublishingOpened({ source })
dialogService.showLayoutDialog({
key: DIALOG_KEY,
component: () => h('div', 'Placeholder comeback'),
// component: TemplateMarketplaceDialog,
component: TemplatePublishingDialog,
props: {
onClose: hide,
initialPage: options?.initialPage

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

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

View File

@@ -198,10 +198,10 @@ export function useWorkflowActionsMenu(
})
addItem({
label: t('menuLabels.templateMarketplace'),
label: t('menuLabels.templatePublishing'),
icon: 'pi pi-objects-column',
command: async () => {
await commandStore.execute('Comfy.OpenTemplateMarketplace')
await commandStore.execute('Comfy.ShowTemplatePublishing')
},
visible: isRoot && flags.templateMarketplaceEnabled,
prependSeparator: true

View File

@@ -1054,10 +1054,82 @@
"enterFilenamePrompt": "Enter the filename:",
"saveWorkflow": "Save workflow"
},
"templateMarketplace": {
"templatePublishing": {
"publishToMarketplace": "Publish to Marketplace",
"saveDraft": "Save Draft",
"previewThenSave": "Preview then Save"
"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",
"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"
},
"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"
}
}
},
"subgraphStore": {
"confirmDeleteTitle": "Delete blueprint?",
@@ -1333,7 +1405,7 @@
"Model Library": "Model Library",
"Node Library": "Node Library",
"Workflows": "Workflows",
"templateMarketplace": "Template Marketplace"
"templatePublishing": "Template Publishing"
},
"desktopMenu": {
"reinstall": "Reinstall",