publish storage for temporaries, some i18n keys, an actions menu stub, the beginnings of a component dialog with a pure vue stub, and the first core command

This commit is contained in:
John Haugeland
2026-02-24 10:48:57 -08:00
parent c7409b6830
commit b638e6a577
6 changed files with 283 additions and 1 deletions

View File

@@ -65,6 +65,7 @@ import {
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
import { useWorkflowTemplateSelectorDialog } from './useWorkflowTemplateSelectorDialog'
import { useTemplateMarketplaceDialog } from './useTemplateMarketplaceDialog'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import { useDialogStore } from '@/stores/dialogStore'
@@ -337,6 +338,7 @@ export function useCoreCommands(): ComfyCommand[] {
}
},
{
// comeback
id: 'Comfy.BrowseTemplates',
icon: 'pi pi-folder-open',
label: 'Browse Templates',
@@ -344,6 +346,15 @@ export function useCoreCommands(): ComfyCommand[] {
useWorkflowTemplateSelectorDialog().show()
}
},
{
// comeback
id: 'Comfy.ShowTemplateMarketplace',
icon: 'pi pi-objects-column',
label: 'Show Template Marketplace',
function: () => {
useTemplateMarketplaceDialog().show()
}
},
{
id: 'Comfy.Canvas.ZoomIn',
icon: 'pi pi-plus',

View File

@@ -0,0 +1,36 @@
import { h } from '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
export const useTemplateMarketplaceDialog = () => {
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()?.trackTemplateLibraryOpened({ source })
dialogService.showLayoutDialog({
key: DIALOG_KEY,
component: () => h('div', 'Placeholder comeback'),
// component: TemplateMarketplaceDialog,
props: {
onClose: hide,
initialPage: options?.initialPage
}
})
}
return {
show,
hide
}
}

View File

@@ -197,6 +197,16 @@ export function useWorkflowActionsMenu(
prependSeparator: true
})
addItem({
label: t('menuLabels.templateMarketplace'),
icon: 'pi pi-objects-column',
command: async () => {
await commandStore.execute('Comfy.OpenTemplateMarketplace')
},
visible: isRoot && flags.templateMarketplaceEnabled,
prependSeparator: true
})
addItem({
label: t('subgraphStore.publish'),
icon: 'pi pi-upload',

View File

@@ -1054,6 +1054,11 @@
"enterFilenamePrompt": "Enter the filename:",
"saveWorkflow": "Save workflow"
},
"templateMarketplace": {
"publishToMarketplace": "Publish to Marketplace",
"saveDraft": "Save Draft",
"previewThenSave": "Preview then Save"
},
"subgraphStore": {
"confirmDeleteTitle": "Delete blueprint?",
"confirmDelete": "This action will permanently remove the blueprint from your library",
@@ -1327,7 +1332,8 @@
"Assets": "Assets",
"Model Library": "Model Library",
"Node Library": "Node Library",
"Workflows": "Workflows"
"Workflows": "Workflows",
"templateMarketplace": "Template Marketplace"
},
"desktopMenu": {
"reinstall": "Reinstall",

View File

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

View File

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