mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-07 14:09:59 +00:00
feat: workflow sharing and ComfyHub publish flow (#8951)
## Summary Add workflow sharing by URL and a multi-step ComfyHub publish wizard, gated by feature flags and an optional profile gate. ## Changes - **What**: Share dialog with URL generation and asset warnings; ComfyHub publish wizard (Describe → Examples → Finish) with thumbnail upload and tags; profile gate flow; shared workflow URL loader with confirmation dialog - **Dependencies**: None (new `sharing/` module under `src/platform/workflow/`) ## Review Focus - Three new feature flags: `workflow_sharing_enabled`, `comfyhub_upload_enabled`, `comfyhub_profile_gate_enabled` - Share service API contract and stale-share detection (`workflowShareService.ts`) - Publish wizard and profile gate state management - Shared workflow URL loading and query-param preservation ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8951-feat-share-workflow-by-URL-30b6d73d3650813ebbfafdad775bfb33) by [Unito](https://www.unito.io) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Amp <amp@ampcode.com> Co-authored-by: github-actions <github-actions@github.com> Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 93 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 96 KiB |
@@ -330,12 +330,9 @@ test.describe('Workflows sidebar', () => {
|
||||
.getPersistedItem('workflow1.json')
|
||||
.click({ button: 'right' })
|
||||
await comfyPage.contextMenu.clickMenuItem('Duplicate')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await workflowsTab.getOpenedWorkflowNames()).toEqual([
|
||||
'*Unsaved Workflow.json',
|
||||
'*workflow1 (Copy).json'
|
||||
])
|
||||
await expect
|
||||
.poll(() => workflowsTab.getOpenedWorkflowNames())
|
||||
.toEqual(['*Unsaved Workflow.json', '*workflow1 (Copy).json'])
|
||||
})
|
||||
|
||||
test('Can drop workflow from workflows sidebar', async ({ comfyPage }) => {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
@@ -60,6 +60,7 @@
|
||||
"@comfyorg/registry-types": "workspace:*",
|
||||
"@comfyorg/shared-frontend-utils": "workspace:*",
|
||||
"@comfyorg/tailwind-utils": "workspace:*",
|
||||
"@formkit/auto-animate": "catalog:",
|
||||
"@iconify/json": "catalog:",
|
||||
"@primeuix/forms": "catalog:",
|
||||
"@primeuix/styled": "catalog:",
|
||||
|
||||
11
pnpm-lock.yaml
generated
11
pnpm-lock.yaml
generated
@@ -15,6 +15,9 @@ catalogs:
|
||||
'@eslint/js':
|
||||
specifier: ^9.39.1
|
||||
version: 9.39.1
|
||||
'@formkit/auto-animate':
|
||||
specifier: ^0.9.0
|
||||
version: 0.9.0
|
||||
'@iconify-json/lucide':
|
||||
specifier: ^1.1.178
|
||||
version: 1.2.79
|
||||
@@ -392,6 +395,9 @@ importers:
|
||||
'@comfyorg/tailwind-utils':
|
||||
specifier: workspace:*
|
||||
version: link:packages/tailwind-utils
|
||||
'@formkit/auto-animate':
|
||||
specifier: 'catalog:'
|
||||
version: 0.9.0
|
||||
'@iconify/json':
|
||||
specifier: 'catalog:'
|
||||
version: 2.2.380
|
||||
@@ -2185,6 +2191,9 @@ packages:
|
||||
'@floating-ui/vue@1.1.9':
|
||||
resolution: {integrity: sha512-BfNqNW6KA83Nexspgb9DZuz578R7HT8MZw1CfK9I6Ah4QReNWEJsXWHN+SdmOVLNGmTPDi+fDT535Df5PzMLbQ==}
|
||||
|
||||
'@formkit/auto-animate@0.9.0':
|
||||
resolution: {integrity: sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA==}
|
||||
|
||||
'@grpc/grpc-js@1.9.15':
|
||||
resolution: {integrity: sha512-nqE7Hc0AzI+euzUwDAy0aY5hCp10r734gMGRdU+qOPX0XSceI2ULrcXB5U2xSc5VkWwalCj4M7GzCAygZl2KoQ==}
|
||||
engines: {node: ^8.13.0 || >=10.10.0}
|
||||
@@ -10163,6 +10172,8 @@ snapshots:
|
||||
- '@vue/composition-api'
|
||||
- vue
|
||||
|
||||
'@formkit/auto-animate@0.9.0': {}
|
||||
|
||||
'@grpc/grpc-js@1.9.15':
|
||||
dependencies:
|
||||
'@grpc/proto-loader': 0.7.13
|
||||
|
||||
@@ -6,6 +6,7 @@ catalog:
|
||||
'@alloc/quick-lru': ^5.2.0
|
||||
'@comfyorg/comfyui-electron-types': 0.6.2
|
||||
'@eslint/js': ^9.39.1
|
||||
'@formkit/auto-animate': ^0.9.0
|
||||
'@iconify-json/lucide': ^1.1.178
|
||||
'@iconify/json': ^2.2.380
|
||||
'@iconify/tailwind4': ^1.2.0
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
ref="legacyCommandsContainerRef"
|
||||
class="[&:not(:has(*>*:not(:empty)))]:hidden"
|
||||
></div>
|
||||
|
||||
<ComfyActionbar
|
||||
:top-menu-container="actionbarContainerRef"
|
||||
:queue-overlay-expanded="isQueueOverlayExpanded"
|
||||
@@ -61,6 +62,19 @@
|
||||
class="shrink-0"
|
||||
/>
|
||||
<LoginButton v-else-if="isDesktop && !isIntegratedTabBar" />
|
||||
<Button
|
||||
v-if="isCloud && flags.workflowSharingEnabled"
|
||||
v-tooltip.bottom="shareTooltipConfig"
|
||||
variant="secondary"
|
||||
:aria-label="t('actionbar.shareTooltip')"
|
||||
@click="() => openShareDialog().catch(toastErrorHandler)"
|
||||
@pointerenter="prefetchShareDialog"
|
||||
>
|
||||
<i class="icon-[lucide--share-2] size-4" />
|
||||
<span class="not-md:hidden">
|
||||
{{ t('actionbar.share') }}
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!isRightSidePanelOpen"
|
||||
v-tooltip.bottom="rightSidePanelTooltipConfig"
|
||||
@@ -134,7 +148,12 @@ import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useQueueUIStore } from '@/stores/queueStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { isDesktop } from '@/platform/distribution/types'
|
||||
import { isCloud, isDesktop } from '@/platform/distribution/types'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import {
|
||||
openShareDialog,
|
||||
prefetchShareDialog
|
||||
} from '@/platform/workflow/sharing/composables/lazyShareDialog'
|
||||
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
|
||||
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
||||
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
@@ -144,6 +163,7 @@ const settingStore = useSettingStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const managerState = useManagerState()
|
||||
const { flags } = useFeatureFlags()
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
const { t } = useI18n()
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
@@ -195,6 +215,9 @@ const shouldHideInlineProgressSummary = computed(
|
||||
const customNodesManagerTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('menu.manageExtensions'))
|
||||
)
|
||||
const shareTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('actionbar.shareTooltip'))
|
||||
)
|
||||
|
||||
const shouldShowRedDot = computed((): boolean => {
|
||||
return shouldShowConflictRedDot.value
|
||||
|
||||
@@ -1,24 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { statusBadgeVariants } from './statusBadge.variants'
|
||||
import type { StatusBadgeVariants } from './statusBadge.variants'
|
||||
|
||||
const {
|
||||
label,
|
||||
severity = 'default',
|
||||
variant
|
||||
variant,
|
||||
class: className
|
||||
} = defineProps<{
|
||||
label?: string | number
|
||||
severity?: StatusBadgeVariants['severity']
|
||||
variant?: StatusBadgeVariants['variant']
|
||||
class?: string
|
||||
}>()
|
||||
|
||||
const badgeClass = computed(() =>
|
||||
statusBadgeVariants({
|
||||
severity,
|
||||
variant: variant ?? (label == null ? 'dot' : 'label')
|
||||
})
|
||||
cn(
|
||||
statusBadgeVariants({
|
||||
severity,
|
||||
variant: variant ?? (label == null ? 'dot' : 'label')
|
||||
}),
|
||||
className
|
||||
)
|
||||
)
|
||||
</script>
|
||||
|
||||
|
||||
@@ -537,8 +537,13 @@ onMounted(async () => {
|
||||
await workflowPersistence.initializeWorkflow()
|
||||
workflowPersistence.restoreWorkflowTabsState()
|
||||
|
||||
const sharedWorkflowLoadStatus =
|
||||
await workflowPersistence.loadSharedWorkflowFromUrlIfPresent()
|
||||
|
||||
// Load template from URL if present
|
||||
await workflowPersistence.loadTemplateFromUrlIfPresent()
|
||||
if (sharedWorkflowLoadStatus === 'not-present') {
|
||||
await workflowPersistence.loadTemplateFromUrlIfPresent()
|
||||
}
|
||||
|
||||
// Accept workspace invite from URL if present (e.g., ?invite=TOKEN)
|
||||
// WorkspaceAuthGate ensures flag state is resolved before GraphCanvas mounts
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
v-bind="$attrs"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
@mouseup="handleMouseUp"
|
||||
@click="handleClick"
|
||||
>
|
||||
<i
|
||||
@@ -98,6 +99,13 @@ const props = defineProps<{
|
||||
isLast: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
closeToLeft: []
|
||||
closeToRight: []
|
||||
closeOthers: []
|
||||
mouseup: [event: MouseEvent]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
@@ -162,6 +170,10 @@ const handleClick = (event: Event) => {
|
||||
popoverRef.value?.togglePopover(event)
|
||||
}
|
||||
|
||||
const handleMouseUp = (event: MouseEvent) => {
|
||||
emit('mouseup', event)
|
||||
}
|
||||
|
||||
const closeWorkflows = async (options: WorkflowOption[]) => {
|
||||
for (const opt of options) {
|
||||
if (
|
||||
@@ -180,12 +192,6 @@ const onCloseWorkflow = async (option: WorkflowOption) => {
|
||||
await closeWorkflows([option])
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
closeToLeft: []
|
||||
closeToRight: []
|
||||
closeOthers: []
|
||||
}>()
|
||||
|
||||
const commandStore = useCommandStore()
|
||||
const workflow = computed(() => props.workflowOption.workflow)
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
v-if="isLoggedIn"
|
||||
:show-arrow="false"
|
||||
compact
|
||||
class="shrink-0 p-1"
|
||||
class="shrink-0 p-1 grid w-10"
|
||||
/>
|
||||
<LoginButton v-else-if="isDesktop" class="p-1" />
|
||||
</div>
|
||||
|
||||
33
src/components/ui/input/Input.stories.ts
Normal file
33
src/components/ui/input/Input.stories.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import Input from './Input.vue'
|
||||
|
||||
const meta: Meta<typeof Input> = {
|
||||
title: 'Components/Input',
|
||||
component: Input,
|
||||
tags: ['autodocs'],
|
||||
render: (args) => ({
|
||||
components: { Input },
|
||||
setup: () => ({ args }),
|
||||
template: '<Input v-bind="args" placeholder="Enter text..." />'
|
||||
})
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {}
|
||||
|
||||
export const WithValue: Story = {
|
||||
args: {
|
||||
modelValue: 'Hello, world!'
|
||||
}
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
render: (args) => ({
|
||||
components: { Input },
|
||||
setup: () => ({ args }),
|
||||
template: '<Input v-bind="args" placeholder="Disabled input" disabled />'
|
||||
})
|
||||
}
|
||||
32
src/components/ui/input/Input.vue
Normal file
32
src/components/ui/input/Input.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { useTemplateRef } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<string | number>()
|
||||
|
||||
const inputRef = useTemplateRef<HTMLInputElement>('inputEl')
|
||||
|
||||
defineExpose({
|
||||
focus: () => inputRef.value?.focus(),
|
||||
select: () => inputRef.value?.select()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
ref="inputEl"
|
||||
v-model="modelValue"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-10 w-full min-w-0 appearance-none rounded-lg border-none bg-secondary-background px-4 py-2 text-sm text-base-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border-default disabled:pointer-events-none disabled:opacity-50',
|
||||
className
|
||||
)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
@@ -16,9 +16,15 @@ import type { FocusCallback } from './tagsInputContext'
|
||||
|
||||
const {
|
||||
disabled = false,
|
||||
alwaysEditing = false,
|
||||
class: className,
|
||||
...restProps
|
||||
} = defineProps<TagsInputRootProps<T> & { class?: HTMLAttributes['class'] }>()
|
||||
} = defineProps<
|
||||
TagsInputRootProps<T> & {
|
||||
class?: HTMLAttributes['class']
|
||||
alwaysEditing?: boolean
|
||||
}
|
||||
>()
|
||||
const emits = defineEmits<TagsInputRootEmits<T>>()
|
||||
|
||||
const isEditing = ref(false)
|
||||
@@ -28,9 +34,10 @@ const focusInput = ref<FocusCallback>()
|
||||
provide(tagsInputFocusKey, (callback: FocusCallback) => {
|
||||
focusInput.value = callback
|
||||
})
|
||||
provide(tagsInputIsEditingKey, isEditing)
|
||||
const isEditingEnabled = computed(() => alwaysEditing || isEditing.value)
|
||||
provide(tagsInputIsEditingKey, isEditingEnabled)
|
||||
|
||||
const internalDisabled = computed(() => disabled || !isEditing.value)
|
||||
const internalDisabled = computed(() => disabled || !isEditingEnabled.value)
|
||||
|
||||
const delegatedProps = computed(() => ({
|
||||
...restProps,
|
||||
@@ -40,7 +47,7 @@ const delegatedProps = computed(() => ({
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
|
||||
async function enableEditing() {
|
||||
if (!disabled && !isEditing.value) {
|
||||
if (!disabled && !alwaysEditing && !isEditing.value) {
|
||||
isEditing.value = true
|
||||
await nextTick()
|
||||
focusInput.value?.()
|
||||
@@ -48,7 +55,9 @@ async function enableEditing() {
|
||||
}
|
||||
|
||||
onClickOutside(rootEl, () => {
|
||||
isEditing.value = false
|
||||
if (!alwaysEditing) {
|
||||
isEditing.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -61,7 +70,7 @@ onClickOutside(rootEl, () => {
|
||||
'group relative flex flex-wrap items-center gap-2 rounded-lg bg-transparent p-2 text-xs text-base-foreground',
|
||||
!internalDisabled &&
|
||||
'hover:bg-modal-card-background-hovered focus-within:bg-modal-card-background-hovered',
|
||||
!disabled && !isEditing && 'cursor-pointer',
|
||||
!disabled && !isEditingEnabled && 'cursor-pointer',
|
||||
className
|
||||
)
|
||||
"
|
||||
@@ -69,7 +78,7 @@ onClickOutside(rootEl, () => {
|
||||
>
|
||||
<slot :is-empty="modelValue.length === 0" />
|
||||
<i
|
||||
v-if="!disabled && !isEditing"
|
||||
v-if="!disabled && !isEditingEnabled"
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--square-pen] absolute bottom-2 right-2 size-4 text-muted-foreground transition-opacity opacity-0 group-hover:opacity-100"
|
||||
/>
|
||||
|
||||
@@ -16,7 +16,7 @@ const modelValue = defineModel<string | number>()
|
||||
v-model="modelValue"
|
||||
:class="
|
||||
cn(
|
||||
'flex min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'flex min-h-16 w-full rounded-lg border-none bg-secondary-background px-3 py-2 text-sm text-base-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border-default disabled:pointer-events-none disabled:opacity-50',
|
||||
className
|
||||
)
|
||||
"
|
||||
|
||||
@@ -77,9 +77,7 @@
|
||||
>
|
||||
{{ contentTitle }}
|
||||
</h2>
|
||||
<div
|
||||
class="min-h-0 flex-1 px-6 pt-0 pb-10 overflow-y-auto scrollbar-custom"
|
||||
>
|
||||
<div :class="contentContainerClass">
|
||||
<slot name="content" />
|
||||
</div>
|
||||
</main>
|
||||
@@ -153,15 +151,20 @@ const SIZE_CLASSES = {
|
||||
} as const
|
||||
|
||||
type ModalSize = keyof typeof SIZE_CLASSES
|
||||
type ContentPadding = 'default' | 'compact' | 'none'
|
||||
|
||||
const {
|
||||
contentTitle,
|
||||
rightPanelTitle,
|
||||
size = 'lg'
|
||||
size = 'lg',
|
||||
leftPanelWidth = '14rem',
|
||||
contentPadding = 'default'
|
||||
} = defineProps<{
|
||||
contentTitle: string
|
||||
rightPanelTitle?: string
|
||||
size?: ModalSize
|
||||
leftPanelWidth?: string
|
||||
contentPadding?: ContentPadding
|
||||
}>()
|
||||
|
||||
const sizeClasses = computed(() => SIZE_CLASSES[size])
|
||||
@@ -197,10 +200,18 @@ const showLeftPanel = computed(() => {
|
||||
return shouldShow
|
||||
})
|
||||
|
||||
const contentContainerClass = computed(() =>
|
||||
cn(
|
||||
'flex min-h-0 flex-1 flex-col overflow-y-auto scrollbar-custom',
|
||||
contentPadding === 'default' && 'px-6 pt-0 pb-10',
|
||||
contentPadding === 'compact' && 'px-6 pt-0 pb-2'
|
||||
)
|
||||
)
|
||||
|
||||
const gridStyle = computed(() => ({
|
||||
gridTemplateColumns: hasRightPanel.value
|
||||
? `${hasLeftPanel.value && showLeftPanel.value ? '14rem' : '0rem'} 1fr ${isRightPanelOpen.value ? '18rem' : '0rem'}`
|
||||
: `${hasLeftPanel.value && showLeftPanel.value ? '14rem' : '0rem'} 1fr`
|
||||
? `${hasLeftPanel.value && showLeftPanel.value ? leftPanelWidth : '0rem'} 1fr ${isRightPanelOpen.value ? '18rem' : '0rem'}`
|
||||
: `${hasLeftPanel.value && showLeftPanel.value ? leftPanelWidth : '0rem'} 1fr`
|
||||
}))
|
||||
|
||||
const toggleLeftPanel = () => {
|
||||
|
||||
@@ -247,7 +247,7 @@ General-purpose composables:
|
||||
| `useTreeExpansion` | Handles tree node expansion state |
|
||||
|
||||
| `useWorkflowAutoSave` | Handles automatic workflow saving |
|
||||
| `useWorkflowPersistence` | Manages workflow persistence |
|
||||
| `useWorkflowPersistenceV2` | Manages workflow persistence |
|
||||
| `useWorkflowValidation` | Validates workflow integrity |
|
||||
|
||||
## Usage Guidelines
|
||||
|
||||
@@ -23,7 +23,10 @@ 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',
|
||||
WORKFLOW_SHARING_ENABLED = 'workflow_sharing_enabled',
|
||||
COMFYHUB_UPLOAD_ENABLED = 'comfyhub_upload_enabled',
|
||||
COMFYHUB_PROFILE_GATE_ENABLED = 'comfyhub_profile_gate_enabled'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -130,6 +133,29 @@ export function useFeatureFlags() {
|
||||
false
|
||||
)
|
||||
)
|
||||
},
|
||||
get workflowSharingEnabled() {
|
||||
// UI is also gated on `isCloud` in TopMenuSection; default false
|
||||
// to match other flags' opt-in convention.
|
||||
return resolveFlag(
|
||||
ServerFeatureFlag.WORKFLOW_SHARING_ENABLED,
|
||||
remoteConfig.value.workflow_sharing_enabled,
|
||||
false
|
||||
)
|
||||
},
|
||||
get comfyHubUploadEnabled() {
|
||||
return resolveFlag(
|
||||
ServerFeatureFlag.COMFYHUB_UPLOAD_ENABLED,
|
||||
remoteConfig.value.comfyhub_upload_enabled,
|
||||
false
|
||||
)
|
||||
},
|
||||
get comfyHubProfileGateEnabled() {
|
||||
return resolveFlag(
|
||||
ServerFeatureFlag.COMFYHUB_PROFILE_GATE_ENABLED,
|
||||
remoteConfig.value.comfyhub_profile_gate_enabled,
|
||||
false
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -2998,7 +2998,131 @@
|
||||
"actionbar": {
|
||||
"dockToTop": "Dock to top",
|
||||
"feedback": "Feedback",
|
||||
"feedbackTooltip": "Feedback"
|
||||
"feedbackTooltip": "Feedback",
|
||||
"share": "Share",
|
||||
"shareTooltip": "Share workflow"
|
||||
},
|
||||
"shareWorkflow": {
|
||||
"shareLinkTab": "Share",
|
||||
"publishToHubTab": "Publish",
|
||||
"loadingTitle": "Share workflow",
|
||||
"unsavedTitle": "Save workflow first",
|
||||
"unsavedDescription": "You must save your workflow before sharing. Save it now to continue.",
|
||||
"saveButton": "Save workflow",
|
||||
"saving": "Saving...",
|
||||
"workflowNameLabel": "Workflow name",
|
||||
"createLinkTitle": "Share workflow",
|
||||
"createLinkDescription": "When you create a link for your workflow, you will share these media items along with your workflow",
|
||||
"privateAssetsDescription": "Your workflow contains private models and/or media files",
|
||||
"createLinkButton": "Create a link",
|
||||
"creatingLink": "Creating a link...",
|
||||
"successTitle": "Workflow successfully published!",
|
||||
"successDescription": "Anyone with this link can view and use this workflow. If you make changes to this workflow, you can republish to update the shared version.",
|
||||
"hasChangesTitle": "Share workflow",
|
||||
"hasChangesDescription": "You have made changes since this workflow was last published.",
|
||||
"updateLinkButton": "Update link",
|
||||
"updatingLink": "Updating link...",
|
||||
"publishedOn": "Published on {date}",
|
||||
"copyLink": "Copy",
|
||||
"linkCopied": "Copied!",
|
||||
"shareUrlLabel": "Share URL",
|
||||
"loadFailed": "Failed to load shared workflow",
|
||||
"saveFailedTitle": "Save failed",
|
||||
"saveFailedDescription": "Failed to save workflow. Please try again.",
|
||||
"mediaLabel": "{count} Media File | {count} Media Files",
|
||||
"modelsLabel": "{count} Model | {count} Models",
|
||||
"checkingAssets": "Checking media visibility…",
|
||||
"acknowledgeCheckbox": "I understand these media items will be published and made public",
|
||||
"inLibrary": "In library",
|
||||
"comfyHubTitle": "Upload to ComfyHub",
|
||||
"comfyHubDescription": "ComfyHub is ComfyUI's official community hub.\nYour workflow will have a public page viewable by all.",
|
||||
"comfyHubButton": "Upload to ComfyHub"
|
||||
},
|
||||
"openSharedWorkflow": {
|
||||
"dialogTitle": "Open shared workflow",
|
||||
"author": "Author:",
|
||||
"copyDescription": "Opening the workflow will create a new copy in your workspace",
|
||||
"nonPublicAssetsWarningLine1": "This workflow comes with non-public assets.",
|
||||
"nonPublicAssetsWarningLine2": "These will be imported to your library when you open the workflow",
|
||||
"copyAssetsAndOpen": "Import assets & open workflow",
|
||||
"openWorkflow": "Open workflow",
|
||||
"openWithoutImporting": "Open without importing",
|
||||
"importFailed": "Failed to import workflow assets",
|
||||
"loadError": "Could not load this shared workflow. Please try again later."
|
||||
},
|
||||
"comfyHubPublish": {
|
||||
"title": "Publish to ComfyHub",
|
||||
"stepDescribe": "Describe your workflow",
|
||||
"stepExamples": "Add output examples",
|
||||
"stepFinish": "Finish publishing",
|
||||
"workflowName": "Workflow name",
|
||||
"workflowNamePlaceholder": "Tip: enter a descriptive name that's easy to search",
|
||||
"workflowDescription": "Workflow description",
|
||||
"workflowDescriptionPlaceholder": "What makes your workflow exciting and special? Be specific so people know what to expect.",
|
||||
"workflowType": "Workflow type",
|
||||
"workflowTypePlaceholder": "Select the type",
|
||||
"workflowTypeImageGeneration": "Image generation",
|
||||
"workflowTypeVideoGeneration": "Video generation",
|
||||
"workflowTypeUpscaling": "Upscaling",
|
||||
"workflowTypeEditing": "Editing",
|
||||
"tags": "Tags",
|
||||
"tagsDescription": "Select tags so people can find your workflow faster",
|
||||
"tagsPlaceholder": "Enter tags that match your workflow to help people find it e.g #nanobanana, #anime or #faceswap",
|
||||
"selectAThumbnail": "Select a thumbnail",
|
||||
"showMoreTags": "Show more...",
|
||||
"showLessTags": "Show less...",
|
||||
"suggestedTags": "Suggested tags",
|
||||
"thumbnailImage": "Image",
|
||||
"thumbnailVideo": "Video",
|
||||
"thumbnailImageComparison": "Image comparison",
|
||||
"uploadThumbnail": "Upload an image",
|
||||
"uploadVideo": "Upload a video",
|
||||
"uploadComparison": "Upload before and after",
|
||||
"thumbnailPreview": "Thumbnail preview",
|
||||
"uploadPromptClickToBrowse": "Click to browse or",
|
||||
"uploadPromptDropImage": "drop an image here",
|
||||
"uploadPromptDropVideo": "drop a video here",
|
||||
"uploadComparisonBeforePrompt": "Before",
|
||||
"uploadComparisonAfterPrompt": "After",
|
||||
"uploadThumbnailHint": "1:1 preferred, 1080p max",
|
||||
"back": "Back",
|
||||
"next": "Next",
|
||||
"publishButton": "Publish to ComfyHub",
|
||||
"examplesDescription": "Add up to {total} additional sample images",
|
||||
"uploadAnImage": "Click to browse or drag an image",
|
||||
"uploadExampleImage": "Upload example image",
|
||||
"exampleImage": "Example image {index}",
|
||||
"videoPreview": "Video thumbnail preview",
|
||||
"maxExamples": "You can select up to {max} examples",
|
||||
"createProfileToPublish": "Create a profile to publish to ComfyHub",
|
||||
"createProfileCta": "Create a profile"
|
||||
},
|
||||
"comfyHubProfile": {
|
||||
"checkingAccess": "Checking your publishing access...",
|
||||
"profileCreationNav": "Profile creation",
|
||||
"introTitle": "Publish to the ComfyHub",
|
||||
"introDescription": "Publish your workflows, build your portfolio and get discovered by millions of users",
|
||||
"introSubtitle": "To share your workflow on ComfyHub, let's first create your profile.",
|
||||
"createProfileButton": "Create my profile",
|
||||
"startPublishingButton": "Start publishing",
|
||||
"modalTitle": "Create your profile on ComfyHub",
|
||||
"createProfileTitle": "Create your Comfy Hub profile",
|
||||
"uploadCover": "+ Upload a cover",
|
||||
"uploadProfilePicture": "+ Upload a profile picture",
|
||||
"chooseProfilePicture": "Choose a profile picture",
|
||||
"nameLabel": "Your name",
|
||||
"namePlaceholder": "Enter your name here",
|
||||
"usernameLabel": "Your username (required)",
|
||||
"usernamePlaceholder": "@",
|
||||
"descriptionLabel": "Your description",
|
||||
"descriptionPlaceholder": "Tell the community about yourself...",
|
||||
"createProfile": "Create profile",
|
||||
"creatingProfile": "Creating profile...",
|
||||
"successTitle": "Looking good, {'@'}{username}!",
|
||||
"successProfileUrl": "Your profile page is live at",
|
||||
"successProfileLink": "comfy.com/p/{username}",
|
||||
"successDescription": "You can now upload your workflow to your creator page",
|
||||
"uploadWorkflowButton": "Upload my workflow"
|
||||
},
|
||||
"desktopDialogs": {
|
||||
"": {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const PRESERVED_QUERY_NAMESPACES = {
|
||||
TEMPLATE: 'template',
|
||||
INVITE: 'invite'
|
||||
INVITE: 'invite',
|
||||
SHARE: 'share'
|
||||
} as const
|
||||
|
||||
@@ -48,4 +48,7 @@ export type RemoteConfig = {
|
||||
node_library_essentials_enabled?: boolean
|
||||
free_tier_credits?: number
|
||||
new_free_tier_subscriptions?: boolean
|
||||
workflow_sharing_enabled?: boolean
|
||||
comfyhub_upload_enabled?: boolean
|
||||
comfyhub_profile_gate_enabled?: boolean
|
||||
}
|
||||
|
||||
@@ -183,6 +183,31 @@ describe('useWorkflowStore', () => {
|
||||
const workflow = store.createTemporary('a.json')
|
||||
expect(workflow.path).toBe('workflows/a (2).json')
|
||||
})
|
||||
|
||||
it('should assign a workflow id to newly created temporary workflows', () => {
|
||||
const workflow = store.createTemporary('id-test.json')
|
||||
const state = JSON.parse(workflow.content!)
|
||||
|
||||
expect(typeof state.id).toBe('string')
|
||||
expect(state.id.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should assign an id when temporary workflow data is missing one', () => {
|
||||
const workflowDataWithoutId = {
|
||||
...defaultGraph,
|
||||
id: undefined
|
||||
}
|
||||
|
||||
const workflow = store.createTemporary(
|
||||
'missing-id.json',
|
||||
workflowDataWithoutId
|
||||
)
|
||||
const state = JSON.parse(workflow.content!)
|
||||
|
||||
expect(typeof state.id).toBe('string')
|
||||
expect(state.id.length).toBeGreaterThan(0)
|
||||
expect(workflowDataWithoutId.id).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('openWorkflow', () => {
|
||||
|
||||
@@ -255,6 +255,20 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
return workflow
|
||||
}
|
||||
|
||||
const ensureWorkflowId = (
|
||||
workflowData?: ComfyWorkflowJSON
|
||||
): ComfyWorkflowJSON => {
|
||||
const base = workflowData
|
||||
? (JSON.parse(JSON.stringify(workflowData)) as ComfyWorkflowJSON)
|
||||
: (JSON.parse(defaultGraphJSON) as ComfyWorkflowJSON)
|
||||
|
||||
if (!base.id) {
|
||||
base.id = generateUUID()
|
||||
}
|
||||
|
||||
return base
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create a new temporary workflow
|
||||
*/
|
||||
@@ -268,9 +282,9 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
size: -1
|
||||
})
|
||||
|
||||
workflow.originalContent = workflow.content = workflowData
|
||||
? JSON.stringify(workflowData)
|
||||
: defaultGraphJSON
|
||||
const initialWorkflowData = ensureWorkflowId(workflowData)
|
||||
workflow.originalContent = workflow.content =
|
||||
JSON.stringify(initialWorkflowData)
|
||||
|
||||
workflowLookup.value[workflow.path] = workflow
|
||||
return workflow
|
||||
@@ -284,9 +298,13 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
ComfyWorkflow.basePath + (path ?? 'Unsaved Workflow.json')
|
||||
)
|
||||
|
||||
const normalizedWorkflowData = workflowData
|
||||
? ensureWorkflowId(workflowData)
|
||||
: undefined
|
||||
|
||||
// Try to reuse an existing loaded workflow with the same filename
|
||||
// that is not stored in the workflows directory
|
||||
if (path && workflowData) {
|
||||
if (path && normalizedWorkflowData) {
|
||||
const existingWorkflow = workflows.value.find(
|
||||
(w) => w.fullFilename === path
|
||||
)
|
||||
@@ -296,12 +314,12 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
ComfyWorkflow.basePath.slice(0, -1)
|
||||
)
|
||||
) {
|
||||
existingWorkflow.changeTracker.reset(workflowData)
|
||||
existingWorkflow.changeTracker.reset(normalizedWorkflowData)
|
||||
return existingWorkflow
|
||||
}
|
||||
}
|
||||
|
||||
return createNewWorkflow(fullPath, workflowData)
|
||||
return createNewWorkflow(fullPath, normalizedWorkflowData)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,289 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import type * as I18n from 'vue-i18n'
|
||||
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { WorkflowDraftSnapshot } from '@/platform/workflow/persistence/base/draftCache'
|
||||
import { useWorkflowPersistence } from '@/platform/workflow/persistence/composables/useWorkflowPersistence'
|
||||
import { useWorkflowDraftStore } from '@/platform/workflow/persistence/stores/workflowDraftStore'
|
||||
import { defaultGraphJSON } from '@/scripts/defaultGraph'
|
||||
import { setStorageValue } from '@/scripts/utils'
|
||||
|
||||
const settingMocks = vi.hoisted(() => ({
|
||||
persistRef: null as { value: boolean } | null
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', async () => {
|
||||
const { ref } = await import('vue')
|
||||
settingMocks.persistRef = ref(true)
|
||||
return {
|
||||
useSettingStore: vi.fn(() => ({
|
||||
get: vi.fn((key: string) => {
|
||||
if (key === 'Comfy.Workflow.Persist')
|
||||
return settingMocks.persistRef!.value
|
||||
return undefined
|
||||
}),
|
||||
set: vi.fn()
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
const mockToastAdd = vi.fn()
|
||||
vi.mock('primevue', () => ({
|
||||
useToast: () => ({
|
||||
add: mockToastAdd
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof I18n>()
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const loadBlankWorkflow = vi.fn()
|
||||
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
|
||||
useWorkflowService: () => ({
|
||||
loadBlankWorkflow
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workflow/templates/composables/useTemplateUrlLoader',
|
||||
() => ({
|
||||
useTemplateUrlLoader: () => ({
|
||||
loadTemplateFromUrlParams: vi.fn()
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
const executeCommand = vi.fn()
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({
|
||||
execute: executeCommand
|
||||
})
|
||||
}))
|
||||
|
||||
type GraphChangedHandler = (() => void) | null
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const state = {
|
||||
graphChangedHandler: null as GraphChangedHandler,
|
||||
currentGraph: {} as Record<string, unknown>
|
||||
}
|
||||
const serializeMock = vi.fn(() => state.currentGraph)
|
||||
const loadGraphDataMock = vi.fn()
|
||||
const apiMock = {
|
||||
clientId: 'test-client',
|
||||
initialClientId: 'test-client',
|
||||
addEventListener: vi.fn((event: string, handler: () => void) => {
|
||||
if (event === 'graphChanged') {
|
||||
state.graphChangedHandler = handler
|
||||
}
|
||||
}),
|
||||
removeEventListener: vi.fn(),
|
||||
getUserData: vi.fn(),
|
||||
storeUserData: vi.fn(),
|
||||
listUserDataFullInfo: vi.fn(),
|
||||
storeSetting: vi.fn(),
|
||||
getSettings: vi.fn(),
|
||||
deleteUserData: vi.fn(),
|
||||
moveUserData: vi.fn(),
|
||||
apiURL: vi.fn((path: string) => path)
|
||||
}
|
||||
return { state, serializeMock, loadGraphDataMock, apiMock }
|
||||
})
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
graph: {
|
||||
serialize: () => mocks.serializeMock()
|
||||
},
|
||||
rootGraph: {
|
||||
serialize: () => mocks.serializeMock()
|
||||
},
|
||||
loadGraphData: (...args: unknown[]) => mocks.loadGraphDataMock(...args),
|
||||
canvas: {}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: mocks.apiMock
|
||||
}))
|
||||
|
||||
describe('useWorkflowPersistence', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2025-01-01T00:00:00Z'))
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
vi.clearAllMocks()
|
||||
settingMocks.persistRef!.value = true
|
||||
mockToastAdd.mockClear()
|
||||
useWorkflowDraftStore().reset()
|
||||
mocks.state.graphChangedHandler = null
|
||||
mocks.state.currentGraph = { initial: true }
|
||||
mocks.serializeMock.mockImplementation(() => mocks.state.currentGraph)
|
||||
mocks.loadGraphDataMock.mockReset()
|
||||
mocks.apiMock.clientId = 'test-client'
|
||||
mocks.apiMock.initialClientId = 'test-client'
|
||||
mocks.apiMock.addEventListener.mockImplementation(
|
||||
(event: string, handler: () => void) => {
|
||||
if (event === 'graphChanged') {
|
||||
mocks.state.graphChangedHandler = handler
|
||||
}
|
||||
}
|
||||
)
|
||||
mocks.apiMock.removeEventListener.mockImplementation(() => {})
|
||||
mocks.apiMock.listUserDataFullInfo.mockResolvedValue([])
|
||||
mocks.apiMock.getUserData.mockResolvedValue({
|
||||
status: 200,
|
||||
text: () => Promise.resolve(defaultGraphJSON)
|
||||
} as Response)
|
||||
mocks.apiMock.apiURL.mockImplementation((path: string) => path)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('persists snapshots for multiple workflows', async () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const workflowA = workflowStore.createTemporary('DraftA.json')
|
||||
await workflowStore.openWorkflow(workflowA)
|
||||
|
||||
const persistence = useWorkflowPersistence()
|
||||
expect(persistence).toBeDefined()
|
||||
expect(mocks.state.graphChangedHandler).toBeTypeOf('function')
|
||||
|
||||
const graphA = { title: 'A' }
|
||||
mocks.state.currentGraph = graphA
|
||||
mocks.state.graphChangedHandler!()
|
||||
await vi.advanceTimersByTimeAsync(800)
|
||||
|
||||
const workflowB = workflowStore.createTemporary('DraftB.json')
|
||||
await workflowStore.openWorkflow(workflowB)
|
||||
const graphB = { title: 'B' }
|
||||
mocks.state.currentGraph = graphB
|
||||
mocks.state.graphChangedHandler!()
|
||||
await vi.advanceTimersByTimeAsync(800)
|
||||
|
||||
const drafts = JSON.parse(
|
||||
localStorage.getItem('Comfy.Workflow.Drafts') ?? '{}'
|
||||
) as Record<string, { data: string; isTemporary: boolean }>
|
||||
|
||||
expect(Object.keys(drafts)).toEqual(
|
||||
expect.arrayContaining(['workflows/DraftA.json', 'workflows/DraftB.json'])
|
||||
)
|
||||
expect(JSON.parse(drafts['workflows/DraftA.json'].data)).toEqual(graphA)
|
||||
expect(JSON.parse(drafts['workflows/DraftB.json'].data)).toEqual(graphB)
|
||||
expect(drafts['workflows/DraftA.json'].isTemporary).toBe(true)
|
||||
expect(drafts['workflows/DraftB.json'].isTemporary).toBe(true)
|
||||
})
|
||||
|
||||
it('evicts least recently used drafts beyond the limit', async () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
useWorkflowPersistence()
|
||||
expect(mocks.state.graphChangedHandler).toBeTypeOf('function')
|
||||
|
||||
for (let i = 0; i < 33; i++) {
|
||||
const workflow = workflowStore.createTemporary(`Draft${i}.json`)
|
||||
await workflowStore.openWorkflow(workflow)
|
||||
mocks.state.currentGraph = { index: i }
|
||||
mocks.state.graphChangedHandler!()
|
||||
await vi.advanceTimersByTimeAsync(800)
|
||||
vi.setSystemTime(new Date(Date.now() + 60000))
|
||||
}
|
||||
|
||||
const drafts = JSON.parse(
|
||||
localStorage.getItem('Comfy.Workflow.Drafts') ?? '{}'
|
||||
) as Record<string, WorkflowDraftSnapshot>
|
||||
|
||||
expect(Object.keys(drafts).length).toBe(32)
|
||||
expect(drafts['workflows/Draft0.json']).toBeUndefined()
|
||||
expect(drafts['workflows/Draft32.json']).toBeDefined()
|
||||
})
|
||||
|
||||
it('restores temporary tabs from cached drafts', async () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const draftStore = useWorkflowDraftStore()
|
||||
const draftData = JSON.parse(defaultGraphJSON)
|
||||
draftStore.saveDraft('workflows/Unsaved Workflow.json', {
|
||||
data: JSON.stringify(draftData),
|
||||
updatedAt: Date.now(),
|
||||
name: 'Unsaved Workflow.json',
|
||||
isTemporary: true
|
||||
})
|
||||
setStorageValue(
|
||||
'Comfy.OpenWorkflowsPaths',
|
||||
JSON.stringify(['workflows/Unsaved Workflow.json'])
|
||||
)
|
||||
setStorageValue('Comfy.ActiveWorkflowIndex', JSON.stringify(0))
|
||||
|
||||
const { restoreWorkflowTabsState } = useWorkflowPersistence()
|
||||
restoreWorkflowTabsState()
|
||||
|
||||
const restored = workflowStore.getWorkflowByPath(
|
||||
'workflows/Unsaved Workflow.json'
|
||||
)
|
||||
expect(restored).toBeTruthy()
|
||||
expect(restored?.isTemporary).toBe(true)
|
||||
expect(
|
||||
workflowStore.openWorkflows.map((workflow) => workflow?.path)
|
||||
).toContain('workflows/Unsaved Workflow.json')
|
||||
})
|
||||
|
||||
it('shows error toast when draft save fails', async () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const draftStore = useWorkflowDraftStore()
|
||||
|
||||
const workflow = workflowStore.createTemporary('FailingDraft.json')
|
||||
await workflowStore.openWorkflow(workflow)
|
||||
|
||||
useWorkflowPersistence()
|
||||
expect(mocks.state.graphChangedHandler).toBeTypeOf('function')
|
||||
|
||||
vi.spyOn(draftStore, 'saveDraft').mockImplementation(() => {
|
||||
throw new Error('Storage quota exceeded')
|
||||
})
|
||||
|
||||
mocks.state.currentGraph = { title: 'Test' }
|
||||
mocks.state.graphChangedHandler!()
|
||||
await vi.advanceTimersByTimeAsync(800)
|
||||
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
detail: expect.any(String)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('clears all drafts when Persist is switched from true to false', async () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const draftStore = useWorkflowDraftStore()
|
||||
const workflow = workflowStore.createTemporary('ClearDraft.json')
|
||||
await workflowStore.openWorkflow(workflow)
|
||||
|
||||
useWorkflowPersistence()
|
||||
expect(mocks.state.graphChangedHandler).toBeTypeOf('function')
|
||||
|
||||
mocks.state.currentGraph = { title: 'Draft to clear' }
|
||||
mocks.state.graphChangedHandler!()
|
||||
await vi.advanceTimersByTimeAsync(800)
|
||||
|
||||
expect(draftStore.getDraft('workflows/ClearDraft.json')).toBeDefined()
|
||||
|
||||
settingMocks.persistRef!.value = false
|
||||
await nextTick()
|
||||
|
||||
expect(draftStore.getDraft('workflows/ClearDraft.json')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,263 +0,0 @@
|
||||
import { useToast } from 'primevue'
|
||||
import { tryOnScopeDispose } from '@vueuse/core'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import {
|
||||
hydratePreservedQuery,
|
||||
mergePreservedQueryIntoQuery
|
||||
} from '@/platform/navigation/preservedQueryManager'
|
||||
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import {
|
||||
ComfyWorkflow,
|
||||
useWorkflowStore
|
||||
} from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowDraftStore } from '@/platform/workflow/persistence/stores/workflowDraftStore'
|
||||
import { useTemplateUrlLoader } from '@/platform/workflow/templates/composables/useTemplateUrlLoader'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
import { getStorageValue, setStorageValue } from '@/scripts/utils'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
export function useWorkflowPersistence() {
|
||||
const { t } = useI18n()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const settingStore = useSettingStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const templateUrlLoader = useTemplateUrlLoader()
|
||||
const TEMPLATE_NAMESPACE = PRESERVED_QUERY_NAMESPACES.TEMPLATE
|
||||
const workflowDraftStore = useWorkflowDraftStore()
|
||||
const toast = useToast()
|
||||
|
||||
const ensureTemplateQueryFromIntent = async () => {
|
||||
hydratePreservedQuery(TEMPLATE_NAMESPACE)
|
||||
const mergedQuery = mergePreservedQueryIntoQuery(
|
||||
TEMPLATE_NAMESPACE,
|
||||
route.query
|
||||
)
|
||||
|
||||
if (mergedQuery) {
|
||||
await router.replace({ query: mergedQuery })
|
||||
}
|
||||
|
||||
return mergedQuery ?? route.query
|
||||
}
|
||||
|
||||
const workflowPersistenceEnabled = computed(() =>
|
||||
settingStore.get('Comfy.Workflow.Persist')
|
||||
)
|
||||
|
||||
const lastSavedJsonByPath = ref<Record<string, string>>({})
|
||||
|
||||
watch(workflowPersistenceEnabled, (enabled) => {
|
||||
if (!enabled) {
|
||||
workflowDraftStore.reset()
|
||||
lastSavedJsonByPath.value = {}
|
||||
}
|
||||
})
|
||||
|
||||
const persistCurrentWorkflow = () => {
|
||||
if (!workflowPersistenceEnabled.value) return
|
||||
const activeWorkflow = workflowStore.activeWorkflow
|
||||
if (!activeWorkflow) return
|
||||
const graphData = comfyApp.rootGraph.serialize()
|
||||
const workflowJson = JSON.stringify(graphData)
|
||||
const workflowPath = activeWorkflow.path
|
||||
|
||||
if (workflowJson === lastSavedJsonByPath.value[workflowPath]) return
|
||||
|
||||
try {
|
||||
workflowDraftStore.saveDraft(activeWorkflow.path, {
|
||||
data: workflowJson,
|
||||
updatedAt: Date.now(),
|
||||
name: activeWorkflow.key,
|
||||
isTemporary: activeWorkflow.isTemporary
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to save draft', error)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('toastMessages.failedToSaveDraft'),
|
||||
life: 3000
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem('workflow', workflowJson)
|
||||
if (api.clientId) {
|
||||
sessionStorage.setItem(`workflow:${api.clientId}`, workflowJson)
|
||||
}
|
||||
} catch (error) {
|
||||
// Only log our own keys and aggregate stats
|
||||
const ourKeys = Object.keys(sessionStorage).filter(
|
||||
(key) => key.startsWith('workflow:') || key === 'workflow'
|
||||
)
|
||||
console.error('QuotaExceededError details:', {
|
||||
workflowSizeKB: Math.round(workflowJson.length / 1024),
|
||||
totalStorageItems: Object.keys(sessionStorage).length,
|
||||
ourWorkflowKeys: ourKeys.length,
|
||||
ourWorkflowSizes: ourKeys.map((key) => ({
|
||||
key,
|
||||
sizeKB: Math.round(sessionStorage[key].length / 1024)
|
||||
})),
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
throw error
|
||||
}
|
||||
|
||||
lastSavedJsonByPath.value[workflowPath] = workflowJson
|
||||
|
||||
if (!activeWorkflow.isTemporary && !activeWorkflow.isModified) {
|
||||
workflowDraftStore.removeDraft(activeWorkflow.path)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const loadPreviousWorkflowFromStorage = async () => {
|
||||
const workflowName = getStorageValue('Comfy.PreviousWorkflow')
|
||||
const preferredPath = workflowName
|
||||
? `${ComfyWorkflow.basePath}${workflowName}`
|
||||
: null
|
||||
return await workflowDraftStore.loadPersistedWorkflow({
|
||||
workflowName,
|
||||
preferredPath,
|
||||
fallbackToLatestDraft: !workflowName
|
||||
})
|
||||
}
|
||||
|
||||
const loadDefaultWorkflow = async () => {
|
||||
if (!settingStore.get('Comfy.TutorialCompleted')) {
|
||||
await settingStore.set('Comfy.TutorialCompleted', true)
|
||||
await useWorkflowService().loadBlankWorkflow()
|
||||
await useCommandStore().execute('Comfy.BrowseTemplates')
|
||||
} else {
|
||||
await comfyApp.loadGraphData()
|
||||
}
|
||||
}
|
||||
|
||||
const initializeWorkflow = async () => {
|
||||
if (!workflowPersistenceEnabled.value) return
|
||||
|
||||
try {
|
||||
const restored = await loadPreviousWorkflowFromStorage()
|
||||
if (!restored) {
|
||||
await loadDefaultWorkflow()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading previous workflow', err)
|
||||
await loadDefaultWorkflow()
|
||||
}
|
||||
}
|
||||
|
||||
const loadTemplateFromUrlIfPresent = async () => {
|
||||
const query = await ensureTemplateQueryFromIntent()
|
||||
const hasTemplateUrl = query.template && typeof query.template === 'string'
|
||||
|
||||
if (hasTemplateUrl) {
|
||||
await templateUrlLoader.loadTemplateFromUrl()
|
||||
}
|
||||
}
|
||||
|
||||
// Setup watchers
|
||||
watch(
|
||||
() => workflowStore.activeWorkflow?.key,
|
||||
(activeWorkflowKey) => {
|
||||
if (!activeWorkflowKey) return
|
||||
setStorageValue('Comfy.PreviousWorkflow', activeWorkflowKey)
|
||||
// When the activeWorkflow changes, the graph has already been loaded.
|
||||
// Saving the current state of the graph to the localStorage.
|
||||
persistCurrentWorkflow()
|
||||
}
|
||||
)
|
||||
|
||||
api.addEventListener('graphChanged', persistCurrentWorkflow)
|
||||
|
||||
// Clean up event listener when component unmounts
|
||||
tryOnScopeDispose(() => {
|
||||
api.removeEventListener('graphChanged', persistCurrentWorkflow)
|
||||
})
|
||||
|
||||
// Restore workflow tabs states
|
||||
const openWorkflows = computed(() => workflowStore.openWorkflows)
|
||||
const activeWorkflow = computed(() => workflowStore.activeWorkflow)
|
||||
const restoreState = computed<{ paths: string[]; activeIndex: number }>(
|
||||
() => {
|
||||
if (!openWorkflows.value || !activeWorkflow.value) {
|
||||
return { paths: [], activeIndex: -1 }
|
||||
}
|
||||
|
||||
const paths = openWorkflows.value
|
||||
.map((workflow) => workflow?.path)
|
||||
.filter(
|
||||
(path): path is string =>
|
||||
typeof path === 'string' && path.startsWith(ComfyWorkflow.basePath)
|
||||
)
|
||||
const activeIndex = paths.indexOf(activeWorkflow.value.path)
|
||||
|
||||
return { paths, activeIndex }
|
||||
}
|
||||
)
|
||||
|
||||
// Get storage values before setting watchers
|
||||
const parsedWorkflows = JSON.parse(
|
||||
getStorageValue('Comfy.OpenWorkflowsPaths') || '[]'
|
||||
)
|
||||
const storedWorkflows = Array.isArray(parsedWorkflows)
|
||||
? parsedWorkflows.filter(
|
||||
(entry): entry is string => typeof entry === 'string'
|
||||
)
|
||||
: []
|
||||
const parsedIndex = JSON.parse(
|
||||
getStorageValue('Comfy.ActiveWorkflowIndex') || '-1'
|
||||
)
|
||||
const storedActiveIndex =
|
||||
typeof parsedIndex === 'number' && Number.isFinite(parsedIndex)
|
||||
? parsedIndex
|
||||
: -1
|
||||
watch(restoreState, ({ paths, activeIndex }) => {
|
||||
if (workflowPersistenceEnabled.value) {
|
||||
setStorageValue('Comfy.OpenWorkflowsPaths', JSON.stringify(paths))
|
||||
setStorageValue('Comfy.ActiveWorkflowIndex', JSON.stringify(activeIndex))
|
||||
}
|
||||
})
|
||||
|
||||
const restoreWorkflowTabsState = () => {
|
||||
if (!workflowPersistenceEnabled.value) return
|
||||
const isRestorable = storedWorkflows?.length > 0 && storedActiveIndex >= 0
|
||||
if (!isRestorable) return
|
||||
|
||||
storedWorkflows.forEach((path: string) => {
|
||||
if (workflowStore.getWorkflowByPath(path)) return
|
||||
const draft = workflowDraftStore.getDraft(path)
|
||||
if (!draft?.isTemporary) return
|
||||
try {
|
||||
const workflowData = JSON.parse(draft.data)
|
||||
workflowStore.createTemporary(draft.name, workflowData)
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'Failed to parse workflow draft, creating with default',
|
||||
err
|
||||
)
|
||||
workflowDraftStore.removeDraft(path)
|
||||
workflowStore.createTemporary(draft.name)
|
||||
}
|
||||
})
|
||||
|
||||
workflowStore.openWorkflowsInBackground({
|
||||
left: storedWorkflows.slice(0, storedActiveIndex),
|
||||
right: storedWorkflows.slice(storedActiveIndex)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
initializeWorkflow,
|
||||
loadTemplateFromUrlIfPresent,
|
||||
restoreWorkflowTabsState
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import { clearAllV2Storage } from '../base/storageIO'
|
||||
import { migrateV1toV2 } from '../migration/migrateV1toV2'
|
||||
import { useWorkflowDraftStoreV2 } from '../stores/workflowDraftStoreV2'
|
||||
import { useWorkflowTabState } from './useWorkflowTabState'
|
||||
import { useSharedWorkflowUrlLoader } from '@/platform/workflow/sharing/composables/useSharedWorkflowUrlLoader'
|
||||
import { useTemplateUrlLoader } from '@/platform/workflow/templates/composables/useTemplateUrlLoader'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
@@ -44,6 +45,7 @@ export function useWorkflowPersistenceV2() {
|
||||
const settingStore = useSettingStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const sharedWorkflowUrlLoader = useSharedWorkflowUrlLoader()
|
||||
const templateUrlLoader = useTemplateUrlLoader()
|
||||
const TEMPLATE_NAMESPACE = PRESERVED_QUERY_NAMESPACES.TEMPLATE
|
||||
const draftStore = useWorkflowDraftStoreV2()
|
||||
@@ -183,6 +185,10 @@ export function useWorkflowPersistenceV2() {
|
||||
}
|
||||
}
|
||||
|
||||
const loadSharedWorkflowFromUrlIfPresent = async () => {
|
||||
return await sharedWorkflowUrlLoader.loadSharedWorkflowFromUrl()
|
||||
}
|
||||
|
||||
// Setup watchers
|
||||
watch(
|
||||
() => workflowStore.activeWorkflow?.key,
|
||||
@@ -279,6 +285,7 @@ export function useWorkflowPersistenceV2() {
|
||||
|
||||
return {
|
||||
initializeWorkflow,
|
||||
loadSharedWorkflowFromUrlIfPresent,
|
||||
loadTemplateFromUrlIfPresent,
|
||||
restoreWorkflowTabsState
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-1">
|
||||
<CollapsibleRoot
|
||||
v-for="section in sections"
|
||||
:key="section.id"
|
||||
class="overflow-hidden rounded-sm"
|
||||
:open="expandedSectionId === section.id"
|
||||
@update:open="onSectionOpenChange(section.id, $event)"
|
||||
>
|
||||
<CollapsibleTrigger as-child>
|
||||
<Button
|
||||
:data-testid="`section-header-${section.id}`"
|
||||
:aria-expanded="expandedSectionId === section.id"
|
||||
:aria-controls="`section-content-${section.id}`"
|
||||
variant="secondary"
|
||||
class="w-full justify-between px-6 py-1"
|
||||
>
|
||||
<span>
|
||||
{{ $t(section.labelKey, section.items.length) }}
|
||||
</span>
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
'icon-[lucide--chevron-right] size-4 text-muted-foreground transition-transform',
|
||||
expandedSectionId === section.id && 'rotate-90'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent
|
||||
:id="`section-content-${section.id}`"
|
||||
:data-testid="`section-content-${section.id}`"
|
||||
class="overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down"
|
||||
>
|
||||
<ul class="max-h-25 overflow-y-auto px-6 pb-1 pt-0.5">
|
||||
<li
|
||||
v-for="item in section.items"
|
||||
:key="item.id"
|
||||
class="flex items-center gap-2 rounded-sm py-1"
|
||||
>
|
||||
<ShareAssetThumbnail
|
||||
:name="item.name"
|
||||
:preview-url="item.preview_url"
|
||||
@thumbnail-error="
|
||||
onThumbnailError($event.name, $event.previewUrl)
|
||||
"
|
||||
/>
|
||||
<span class="truncate text-xs text-base-foreground">
|
||||
{{ item.name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="item.in_library"
|
||||
class="ml-auto shrink-0 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ $t('shareWorkflow.inLibrary') }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</CollapsibleContent>
|
||||
</CollapsibleRoot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CollapsibleContent,
|
||||
CollapsibleRoot,
|
||||
CollapsibleTrigger
|
||||
} from 'reka-ui'
|
||||
|
||||
import type { AssetInfo } from '@/schemas/apiSchema'
|
||||
import ShareAssetThumbnail from '@/platform/workflow/sharing/components/ShareAssetThumbnail.vue'
|
||||
import { useAssetSections } from '@/platform/workflow/sharing/composables/useAssetSections'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { items } = defineProps<{
|
||||
items: AssetInfo[]
|
||||
}>()
|
||||
|
||||
const { sections, expandedSectionId, onSectionOpenChange } = useAssetSections(
|
||||
() => items
|
||||
)
|
||||
|
||||
function onThumbnailError(name: string, previewUrl: string | null | undefined) {
|
||||
console.warn('[share][assets][thumbnail-error]', {
|
||||
name,
|
||||
previewUrl: previewUrl ?? null
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,329 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { SharedWorkflowPayload } from '@/platform/workflow/sharing/types/shareTypes'
|
||||
import OpenSharedWorkflowDialogContent from '@/platform/workflow/sharing/components/OpenSharedWorkflowDialogContent.vue'
|
||||
|
||||
const mockGetSharedWorkflow = vi.fn()
|
||||
|
||||
vi.mock('@/platform/workflow/sharing/services/workflowShareService', () => ({
|
||||
SharedWorkflowLoadError: class extends Error {},
|
||||
useWorkflowShareService: () => ({
|
||||
getSharedWorkflow: mockGetSharedWorkflow
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: { close: 'Close', cancel: 'Cancel' },
|
||||
openSharedWorkflow: {
|
||||
dialogTitle: 'Open shared workflow',
|
||||
copyDescription:
|
||||
'Opening the workflow will create a new copy in your workspace',
|
||||
nonPublicAssetsWarningLine1:
|
||||
'This workflow comes with non-public assets.',
|
||||
nonPublicAssetsWarningLine2:
|
||||
'These will be added to your library when you open the workflow',
|
||||
copyAssetsAndOpen: 'Copy assets & open workflow',
|
||||
openWorkflow: 'Open workflow',
|
||||
openWithoutImporting: 'Open without importing',
|
||||
loadError:
|
||||
'Could not load this shared workflow. Please try again later.'
|
||||
},
|
||||
shareWorkflow: {
|
||||
mediaLabel: '{count} Media File | {count} Media Files',
|
||||
modelsLabel: '{count} Model | {count} Models'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function makePayload(
|
||||
overrides: Partial<SharedWorkflowPayload> = {}
|
||||
): SharedWorkflowPayload {
|
||||
return {
|
||||
shareId: 'share-id-1',
|
||||
workflowId: 'workflow-id-1',
|
||||
name: 'Test Workflow',
|
||||
listed: true,
|
||||
publishedAt: new Date('2026-02-20T00:00:00Z'),
|
||||
workflowJson: {
|
||||
nodes: []
|
||||
} as unknown as SharedWorkflowPayload['workflowJson'],
|
||||
assets: [],
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function mountComponent(props: Record<string, unknown> = {}) {
|
||||
return mount(OpenSharedWorkflowDialogContent, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
AssetSectionList: { template: '<div class="asset-list-stub" />' },
|
||||
'asset-section-list': { template: '<div class="asset-list-stub" />' }
|
||||
}
|
||||
},
|
||||
props: {
|
||||
shareId: 'test-share-id',
|
||||
onConfirm: vi.fn(),
|
||||
onOpenWithoutImporting: vi.fn(),
|
||||
onCancel: vi.fn(),
|
||||
...props
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('OpenSharedWorkflowDialogContent', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('loading state', () => {
|
||||
it('shows skeleton placeholders while loading', () => {
|
||||
mockGetSharedWorkflow.mockReturnValue(new Promise(() => {}))
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(
|
||||
wrapper.findAllComponents({ name: 'Skeleton' }).length
|
||||
).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('shows dialog title in header while loading', () => {
|
||||
mockGetSharedWorkflow.mockReturnValue(new Promise(() => {}))
|
||||
const wrapper = mountComponent()
|
||||
const header = wrapper.find('header h2')
|
||||
expect(header.text()).toBe('Open shared workflow')
|
||||
})
|
||||
})
|
||||
|
||||
describe('error state', () => {
|
||||
it('shows error message when fetch fails', async () => {
|
||||
mockGetSharedWorkflow.mockRejectedValue(new Error('Network error'))
|
||||
const wrapper = mountComponent()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain(
|
||||
'Could not load this shared workflow. Please try again later.'
|
||||
)
|
||||
})
|
||||
|
||||
it('shows close button in error state', async () => {
|
||||
mockGetSharedWorkflow.mockRejectedValue(new Error('Network error'))
|
||||
const wrapper = mountComponent()
|
||||
await flushPromises()
|
||||
|
||||
const footerButtons = wrapper.findAll('footer button')
|
||||
expect(footerButtons).toHaveLength(1)
|
||||
expect(footerButtons[0].text()).toBe('Close')
|
||||
})
|
||||
|
||||
it('calls onCancel when close is clicked in error state', async () => {
|
||||
mockGetSharedWorkflow.mockRejectedValue(new Error('Network error'))
|
||||
const onCancel = vi.fn()
|
||||
const wrapper = mountComponent({ onCancel })
|
||||
await flushPromises()
|
||||
|
||||
const closeButton = wrapper
|
||||
.findAll('footer button')
|
||||
.find((b) => b.text() === 'Close')
|
||||
await closeButton!.trigger('click')
|
||||
expect(onCancel).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('loaded state - no assets', () => {
|
||||
it('shows workflow name in body', async () => {
|
||||
mockGetSharedWorkflow.mockResolvedValue(
|
||||
makePayload({ name: 'My Workflow' })
|
||||
)
|
||||
const wrapper = mountComponent()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('main h2').text()).toBe('My Workflow')
|
||||
})
|
||||
|
||||
it('shows "Open workflow" as primary CTA', async () => {
|
||||
mockGetSharedWorkflow.mockResolvedValue(makePayload())
|
||||
const wrapper = mountComponent()
|
||||
await flushPromises()
|
||||
|
||||
const buttons = wrapper.findAll('footer button')
|
||||
const primaryButton = buttons[buttons.length - 1]
|
||||
expect(primaryButton.text()).toBe('Open workflow')
|
||||
})
|
||||
|
||||
it('does not show "Open without importing" button', async () => {
|
||||
mockGetSharedWorkflow.mockResolvedValue(makePayload())
|
||||
const wrapper = mountComponent()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).not.toContain('Open without importing')
|
||||
})
|
||||
|
||||
it('does not show warning or asset sections', async () => {
|
||||
mockGetSharedWorkflow.mockResolvedValue(makePayload())
|
||||
const wrapper = mountComponent()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).not.toContain('non-public assets')
|
||||
})
|
||||
|
||||
it('calls onConfirm with payload when primary button is clicked', async () => {
|
||||
const payload = makePayload()
|
||||
mockGetSharedWorkflow.mockResolvedValue(payload)
|
||||
const onConfirm = vi.fn()
|
||||
const wrapper = mountComponent({ onConfirm })
|
||||
await flushPromises()
|
||||
|
||||
const buttons = wrapper.findAll('footer button')
|
||||
await buttons[buttons.length - 1].trigger('click')
|
||||
expect(onConfirm).toHaveBeenCalledWith(payload)
|
||||
})
|
||||
|
||||
it('calls onCancel when cancel button is clicked', async () => {
|
||||
mockGetSharedWorkflow.mockResolvedValue(makePayload())
|
||||
const onCancel = vi.fn()
|
||||
const wrapper = mountComponent({ onCancel })
|
||||
await flushPromises()
|
||||
|
||||
const cancelButton = wrapper
|
||||
.findAll('footer button')
|
||||
.find((b) => b.text() === 'Cancel')
|
||||
await cancelButton!.trigger('click')
|
||||
expect(onCancel).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('loaded state - with assets', () => {
|
||||
const assetsPayload = makePayload({
|
||||
assets: [
|
||||
{
|
||||
id: 'a1',
|
||||
name: 'photo.png',
|
||||
preview_url: '',
|
||||
storage_url: '',
|
||||
model: false,
|
||||
public: false,
|
||||
in_library: false
|
||||
},
|
||||
{
|
||||
id: 'a2',
|
||||
name: 'image.jpg',
|
||||
preview_url: '',
|
||||
storage_url: '',
|
||||
model: false,
|
||||
public: false,
|
||||
in_library: false
|
||||
},
|
||||
{
|
||||
id: 'm1',
|
||||
name: 'model.safetensors',
|
||||
preview_url: '',
|
||||
storage_url: '',
|
||||
model: true,
|
||||
public: false,
|
||||
in_library: false
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
it('shows "Copy assets & open workflow" as primary CTA', async () => {
|
||||
mockGetSharedWorkflow.mockResolvedValue(assetsPayload)
|
||||
const wrapper = mountComponent()
|
||||
await flushPromises()
|
||||
|
||||
const buttons = wrapper.findAll('footer button')
|
||||
const primaryButton = buttons[buttons.length - 1]
|
||||
expect(primaryButton.text()).toBe('Copy assets & open workflow')
|
||||
})
|
||||
|
||||
it('shows non-public assets warning', async () => {
|
||||
mockGetSharedWorkflow.mockResolvedValue(assetsPayload)
|
||||
const wrapper = mountComponent()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('non-public assets')
|
||||
})
|
||||
|
||||
it('shows "Open without importing" button', async () => {
|
||||
mockGetSharedWorkflow.mockResolvedValue(assetsPayload)
|
||||
const wrapper = mountComponent()
|
||||
await flushPromises()
|
||||
|
||||
const openWithoutImporting = wrapper
|
||||
.findAll('button')
|
||||
.find((b) => b.text() === 'Open without importing')
|
||||
expect(openWithoutImporting).toBeDefined()
|
||||
})
|
||||
|
||||
it('calls onOpenWithoutImporting with payload', async () => {
|
||||
mockGetSharedWorkflow.mockResolvedValue(assetsPayload)
|
||||
const onOpenWithoutImporting = vi.fn()
|
||||
const wrapper = mountComponent({ onOpenWithoutImporting })
|
||||
await flushPromises()
|
||||
|
||||
const button = wrapper
|
||||
.findAll('button')
|
||||
.find((b) => b.text() === 'Open without importing')
|
||||
await button!.trigger('click')
|
||||
expect(onOpenWithoutImporting).toHaveBeenCalledWith(assetsPayload)
|
||||
})
|
||||
|
||||
it('calls onConfirm with payload when primary button is clicked', async () => {
|
||||
mockGetSharedWorkflow.mockResolvedValue(assetsPayload)
|
||||
const onConfirm = vi.fn()
|
||||
const wrapper = mountComponent({ onConfirm })
|
||||
await flushPromises()
|
||||
|
||||
const buttons = wrapper.findAll('footer button')
|
||||
await buttons[buttons.length - 1].trigger('click')
|
||||
expect(onConfirm).toHaveBeenCalledWith(assetsPayload)
|
||||
})
|
||||
|
||||
it('filters out assets already in library', async () => {
|
||||
const mixedPayload = makePayload({
|
||||
assets: [
|
||||
{
|
||||
id: 'a1',
|
||||
name: 'needed.png',
|
||||
preview_url: '',
|
||||
storage_url: '',
|
||||
model: false,
|
||||
public: false,
|
||||
in_library: false
|
||||
},
|
||||
{
|
||||
id: 'a2',
|
||||
name: 'already-have.png',
|
||||
preview_url: '',
|
||||
storage_url: '',
|
||||
model: false,
|
||||
public: false,
|
||||
in_library: true
|
||||
}
|
||||
]
|
||||
})
|
||||
mockGetSharedWorkflow.mockResolvedValue(mixedPayload)
|
||||
const wrapper = mountComponent()
|
||||
await flushPromises()
|
||||
|
||||
// Should still show assets panel (has 1 non-owned)
|
||||
expect(wrapper.text()).toContain('non-public assets')
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetches with correct shareId', () => {
|
||||
it('passes shareId to getSharedWorkflow', async () => {
|
||||
mockGetSharedWorkflow.mockResolvedValue(makePayload())
|
||||
mountComponent({ shareId: 'my-share-123' })
|
||||
await flushPromises()
|
||||
|
||||
expect(mockGetSharedWorkflow).toHaveBeenCalledWith('my-share-123')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,175 @@
|
||||
<template>
|
||||
<div class="flex w-full flex-col">
|
||||
<header
|
||||
class="flex h-12 items-center justify-between gap-2 border-b border-border-default px-4"
|
||||
>
|
||||
<h2 class="text-sm text-base-foreground">
|
||||
{{ $t('openSharedWorkflow.dialogTitle') }}
|
||||
</h2>
|
||||
<Button size="icon" :aria-label="$t('g.close')" @click="onCancel">
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<template v-if="isLoading">
|
||||
<main class="flex gap-8 px-8 pt-4 pb-6">
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-12 py-4">
|
||||
<Skeleton class="h-8 w-3/5" />
|
||||
<Skeleton class="h-4 w-4/5" />
|
||||
</div>
|
||||
<div class="flex w-84 shrink-0 flex-col gap-2 py-4">
|
||||
<Skeleton class="h-4 w-full" />
|
||||
<Skeleton class="h-20 w-full rounded-lg" />
|
||||
</div>
|
||||
</main>
|
||||
<footer
|
||||
class="flex items-center justify-end gap-2.5 border-t border-border-default px-8 py-4"
|
||||
>
|
||||
<Skeleton class="h-10 w-24 rounded-md" />
|
||||
<Skeleton class="h-10 w-40 rounded-md" />
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<template v-else-if="error">
|
||||
<main class="flex flex-col items-center gap-4 px-8 py-8">
|
||||
<i
|
||||
class="icon-[lucide--circle-alert] size-8 text-warning-background"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p class="m-0 text-center text-sm text-muted-foreground">
|
||||
{{ $t('openSharedWorkflow.loadError') }}
|
||||
</p>
|
||||
</main>
|
||||
<footer
|
||||
class="flex items-center justify-end gap-2.5 border-t border-border-default px-8 py-4"
|
||||
>
|
||||
<Button variant="secondary" size="lg" @click="onCancel">
|
||||
{{ $t('g.close') }}
|
||||
</Button>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<template v-else-if="sharedWorkflow">
|
||||
<main :class="cn('flex gap-8 px-8 pt-4 pb-6', !hasAssets && 'flex-col')">
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-12 py-4">
|
||||
<h2 class="m-0 text-2xl font-semibold text-base-foreground">
|
||||
{{ workflowName }}
|
||||
</h2>
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{ $t('openSharedWorkflow.copyDescription') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="hasAssets" class="flex w-96 shrink-0 flex-col gap-2 py-4">
|
||||
<CollapsibleRoot
|
||||
v-model:open="isWarningExpanded"
|
||||
class="overflow-hidden rounded-lg bg-secondary-background"
|
||||
>
|
||||
<CollapsibleTrigger as-child>
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="w-full justify-between px-4 py-1 text-sm"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--circle-alert] size-4 shrink-0 text-warning-background"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
class="m-0 flex-1 text-left text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('openSharedWorkflow.nonPublicAssetsWarningLine1') }}
|
||||
</span>
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
'icon-[lucide--chevron-right] size-4 shrink-0 text-muted-foreground transition-transform',
|
||||
isWarningExpanded && 'rotate-90'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent
|
||||
class="overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down"
|
||||
>
|
||||
<AssetSectionList :items="nonOwnedAssets" />
|
||||
</CollapsibleContent>
|
||||
</CollapsibleRoot>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer
|
||||
class="flex items-center justify-end gap-2.5 border-t border-border-default px-8 py-4"
|
||||
>
|
||||
<Button variant="secondary" size="lg" @click="onCancel">
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="hasAssets"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
@click="onOpenWithoutImporting(sharedWorkflow)"
|
||||
>
|
||||
{{ $t('openSharedWorkflow.openWithoutImporting') }}
|
||||
</Button>
|
||||
<Button variant="primary" size="lg" @click="onConfirm(sharedWorkflow)">
|
||||
{{
|
||||
hasAssets
|
||||
? $t('openSharedWorkflow.copyAssetsAndOpen')
|
||||
: $t('openSharedWorkflow.openWorkflow')
|
||||
}}
|
||||
</Button>
|
||||
</footer>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAsyncState } from '@vueuse/core'
|
||||
import {
|
||||
CollapsibleContent,
|
||||
CollapsibleRoot,
|
||||
CollapsibleTrigger
|
||||
} from 'reka-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { SharedWorkflowPayload } from '@/platform/workflow/sharing/types/shareTypes'
|
||||
import AssetSectionList from '@/platform/workflow/sharing/components/AssetSectionList.vue'
|
||||
import { useWorkflowShareService } from '@/platform/workflow/sharing/services/workflowShareService'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { shareId, onConfirm, onOpenWithoutImporting, onCancel } = defineProps<{
|
||||
shareId: string
|
||||
onConfirm: (payload: SharedWorkflowPayload) => void
|
||||
onOpenWithoutImporting: (payload: SharedWorkflowPayload) => void
|
||||
onCancel: () => void
|
||||
}>()
|
||||
|
||||
const workflowShareService = useWorkflowShareService()
|
||||
const isWarningExpanded = ref(true)
|
||||
|
||||
const {
|
||||
state: sharedWorkflow,
|
||||
isLoading,
|
||||
error
|
||||
} = useAsyncState(() => workflowShareService.getSharedWorkflow(shareId), null)
|
||||
|
||||
const nonOwnedAssets = computed(
|
||||
() => sharedWorkflow.value?.assets.filter((a) => !a.in_library) ?? []
|
||||
)
|
||||
|
||||
const hasAssets = computed(() => nonOwnedAssets.value.length > 0)
|
||||
|
||||
const workflowName = computed(() => {
|
||||
if (!sharedWorkflow.value) return ''
|
||||
if (sharedWorkflow.value.name) return sharedWorkflow.value.name
|
||||
const jsonName = (
|
||||
sharedWorkflow.value.workflowJson as Record<string, unknown>
|
||||
).name
|
||||
if (typeof jsonName === 'string' && jsonName) return jsonName
|
||||
return ''
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-md bg-muted"
|
||||
>
|
||||
<Skeleton
|
||||
v-if="normalizedPreviewUrl && isLoading"
|
||||
class="absolute inset-0"
|
||||
/>
|
||||
<img
|
||||
v-if="normalizedPreviewUrl && !error"
|
||||
:src="normalizedPreviewUrl"
|
||||
:alt="name"
|
||||
:class="
|
||||
cn(
|
||||
'size-full object-cover transition-opacity duration-200',
|
||||
isReady ? 'opacity-100' : 'opacity-0'
|
||||
)
|
||||
"
|
||||
@error="
|
||||
$emit('thumbnailError', { name, previewUrl: normalizedPreviewUrl })
|
||||
"
|
||||
/>
|
||||
<i
|
||||
v-if="!normalizedPreviewUrl || error"
|
||||
class="icon-[lucide--image] size-4 text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useImage } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { name, previewUrl } = defineProps<{
|
||||
name: string
|
||||
previewUrl: string | null | undefined
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
thumbnailError: [{ name: string; previewUrl: string | null }]
|
||||
}>()
|
||||
|
||||
const normalizedPreviewUrl = computed(() => {
|
||||
if (typeof previewUrl !== 'string' || previewUrl.length === 0) return null
|
||||
try {
|
||||
const url = new URL(previewUrl, window.location.origin)
|
||||
if (
|
||||
!url.origin.includes('googleapis') &&
|
||||
url.searchParams.has('filename') &&
|
||||
!url.searchParams.has('res')
|
||||
)
|
||||
url.searchParams.set('res', '256')
|
||||
return url.toString()
|
||||
} catch {
|
||||
return previewUrl
|
||||
}
|
||||
})
|
||||
|
||||
const imageOptions = computed(() => ({
|
||||
src: normalizedPreviewUrl.value ?? ''
|
||||
}))
|
||||
|
||||
const { isReady, isLoading, error } = useImage(imageOptions)
|
||||
</script>
|
||||
@@ -0,0 +1,268 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
|
||||
import ShareAssetWarningBox from '@/platform/workflow/sharing/components/ShareAssetWarningBox.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
shareWorkflow: {
|
||||
privateAssetsDescription:
|
||||
'Your workflow contains private models and/or media files',
|
||||
mediaLabel: '{count} Media File | {count} Media Files',
|
||||
modelsLabel: '{count} Model | {count} Models',
|
||||
acknowledgeCheckbox: 'I understand these assets...'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe(ShareAssetWarningBox, () => {
|
||||
function createWrapper(
|
||||
props: Partial<ComponentProps<typeof ShareAssetWarningBox>> = {}
|
||||
) {
|
||||
return mount(ShareAssetWarningBox, {
|
||||
props: {
|
||||
items: [
|
||||
{
|
||||
id: 'asset-image',
|
||||
name: 'image.png',
|
||||
storage_url: '',
|
||||
preview_url: 'https://example.com/a.jpg',
|
||||
model: false,
|
||||
public: false,
|
||||
in_library: false
|
||||
},
|
||||
{
|
||||
id: 'model-default',
|
||||
name: 'model.safetensors',
|
||||
storage_url: '',
|
||||
preview_url: '',
|
||||
model: true,
|
||||
public: false,
|
||||
in_library: false
|
||||
}
|
||||
],
|
||||
acknowledged: false,
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('renders warning text', () => {
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.text()).toContain(
|
||||
'Your workflow contains private models and/or media files'
|
||||
)
|
||||
})
|
||||
|
||||
it('renders media and model collapsible sections', () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
expect(wrapper.text()).toContain('1 Media File')
|
||||
expect(wrapper.text()).toContain('1 Model')
|
||||
})
|
||||
|
||||
it('keeps at most one accordion section open at a time', async () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const mediaHeader = wrapper.get('[data-testid="section-header-media"]')
|
||||
const modelsHeader = wrapper.get('[data-testid="section-header-models"]')
|
||||
const mediaChevron = mediaHeader.get('i')
|
||||
const modelsChevron = modelsHeader.get('i')
|
||||
|
||||
expect(mediaHeader.attributes('aria-expanded')).toBe('true')
|
||||
expect(modelsHeader.attributes('aria-expanded')).toBe('false')
|
||||
expect(mediaHeader.attributes('aria-controls')).toBe(
|
||||
'section-content-media'
|
||||
)
|
||||
expect(modelsHeader.attributes('aria-controls')).toBe(
|
||||
'section-content-models'
|
||||
)
|
||||
expect(mediaChevron.classes()).toContain('rotate-90')
|
||||
expect(modelsChevron.classes()).not.toContain('rotate-90')
|
||||
|
||||
await modelsHeader.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
expect(mediaHeader.attributes('aria-expanded')).toBe('false')
|
||||
expect(modelsHeader.attributes('aria-expanded')).toBe('true')
|
||||
expect(mediaChevron.classes()).not.toContain('rotate-90')
|
||||
expect(modelsChevron.classes()).toContain('rotate-90')
|
||||
|
||||
await mediaHeader.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
expect(mediaHeader.attributes('aria-expanded')).toBe('true')
|
||||
expect(modelsHeader.attributes('aria-expanded')).toBe('false')
|
||||
expect(mediaChevron.classes()).toContain('rotate-90')
|
||||
expect(modelsChevron.classes()).not.toContain('rotate-90')
|
||||
|
||||
await mediaHeader.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
expect(mediaHeader.attributes('aria-expanded')).toBe('false')
|
||||
expect(modelsHeader.attributes('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('defaults to media section when both sections are available', () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const mediaHeader = wrapper.get('[data-testid="section-header-media"]')
|
||||
const modelsHeader = wrapper.get('[data-testid="section-header-models"]')
|
||||
|
||||
expect(mediaHeader.attributes('aria-expanded')).toBe('true')
|
||||
expect(modelsHeader.attributes('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('defaults to models section when media is unavailable', () => {
|
||||
const wrapper = createWrapper({
|
||||
items: [
|
||||
{
|
||||
id: 'model-default',
|
||||
name: 'model.safetensors',
|
||||
storage_url: '',
|
||||
preview_url: '',
|
||||
model: true,
|
||||
public: false,
|
||||
in_library: false
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('1 Model')
|
||||
const modelsHeader = wrapper.get('[data-testid="section-header-models"]')
|
||||
|
||||
expect(modelsHeader.attributes('aria-expanded')).toBe('true')
|
||||
})
|
||||
|
||||
it('allows collapsing the only expanded section when models are unavailable', async () => {
|
||||
const wrapper = createWrapper({
|
||||
items: [
|
||||
{
|
||||
id: 'asset-image',
|
||||
name: 'image.png',
|
||||
storage_url: '',
|
||||
preview_url: 'https://example.com/a.jpg',
|
||||
model: false,
|
||||
public: false,
|
||||
in_library: false
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const mediaHeader = wrapper.get('[data-testid="section-header-media"]')
|
||||
const mediaChevron = mediaHeader.get('i')
|
||||
|
||||
expect(mediaHeader.attributes('aria-expanded')).toBe('true')
|
||||
expect(mediaChevron.classes()).toContain('rotate-90')
|
||||
|
||||
await mediaHeader.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
expect(mediaHeader.attributes('aria-expanded')).toBe('false')
|
||||
expect(mediaChevron.classes()).not.toContain('rotate-90')
|
||||
})
|
||||
|
||||
it('emits acknowledged update when checkbox is toggled', async () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const checkbox = wrapper.find('input[type="checkbox"]')
|
||||
await checkbox.setValue(true)
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.emitted('update:acknowledged')).toBeTruthy()
|
||||
expect(wrapper.emitted('update:acknowledged')![0]).toEqual([true])
|
||||
})
|
||||
|
||||
it('displays asset names in the assets section', () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
expect(wrapper.text()).toContain('image.png')
|
||||
})
|
||||
|
||||
it('renders thumbnail previews for assets when URLs are available', () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const images = wrapper.findAll('img')
|
||||
expect(images).toHaveLength(1)
|
||||
expect(images[0].attributes('src')).toBe('https://example.com/a.jpg')
|
||||
expect(images[0].attributes('alt')).toBe('image.png')
|
||||
})
|
||||
|
||||
it('renders fallback icon when thumbnail is missing', () => {
|
||||
const wrapper = createWrapper({
|
||||
items: [
|
||||
{
|
||||
id: 'asset-image',
|
||||
name: 'image.png',
|
||||
storage_url: '',
|
||||
preview_url: '',
|
||||
model: false,
|
||||
public: false,
|
||||
in_library: false
|
||||
},
|
||||
{
|
||||
id: 'model-default',
|
||||
name: 'model.safetensors',
|
||||
storage_url: '',
|
||||
preview_url: '',
|
||||
model: true,
|
||||
public: false,
|
||||
in_library: false
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const fallbackIcons = wrapper
|
||||
.findAll('i')
|
||||
.filter((icon) => icon.classes().includes('icon-[lucide--image]'))
|
||||
|
||||
expect(fallbackIcons).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('hides assets section when no assets provided', () => {
|
||||
const wrapper = createWrapper({
|
||||
items: [
|
||||
{
|
||||
id: 'model-default',
|
||||
name: 'model.safetensors',
|
||||
storage_url: '',
|
||||
preview_url: '',
|
||||
model: true,
|
||||
public: false,
|
||||
in_library: false
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
expect(wrapper.text()).not.toContain('Media File')
|
||||
})
|
||||
|
||||
it('hides models section when no models provided', () => {
|
||||
const wrapper = createWrapper({
|
||||
items: [
|
||||
{
|
||||
id: 'asset-image',
|
||||
name: 'image.png',
|
||||
storage_url: '',
|
||||
preview_url: '',
|
||||
model: false,
|
||||
public: false,
|
||||
in_library: false
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
expect(wrapper.text()).not.toContain('Model')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<div class="rounded-lg flex flex-col gap-3">
|
||||
<CollapsibleRoot
|
||||
v-model:open="isWarningExpanded"
|
||||
class="overflow-hidden rounded-lg bg-secondary-background"
|
||||
>
|
||||
<CollapsibleTrigger as-child>
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="w-full justify-between px-4 py-1 text-sm"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--circle-alert] size-4 shrink-0 text-warning-background"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="m-0 flex-1 text-left text-sm text-muted-foreground">
|
||||
{{ $t('shareWorkflow.privateAssetsDescription') }}
|
||||
</span>
|
||||
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
'icon-[lucide--chevron-right] size-4 shrink-0 text-muted-foreground transition-transform',
|
||||
isWarningExpanded && 'rotate-90'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent
|
||||
class="overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down"
|
||||
>
|
||||
<AssetSectionList :items />
|
||||
</CollapsibleContent>
|
||||
</CollapsibleRoot>
|
||||
|
||||
<label class="mt-3 flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
v-model="acknowledged"
|
||||
type="checkbox"
|
||||
class="size-3.5 shrink-0 cursor-pointer accent-primary-background"
|
||||
/>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ $t('shareWorkflow.acknowledgeCheckbox') }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CollapsibleContent,
|
||||
CollapsibleRoot,
|
||||
CollapsibleTrigger
|
||||
} from 'reka-ui'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { AssetInfo } from '@/schemas/apiSchema'
|
||||
import AssetSectionList from '@/platform/workflow/sharing/components/AssetSectionList.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const { items } = defineProps<{
|
||||
items: AssetInfo[]
|
||||
}>()
|
||||
|
||||
const acknowledged = defineModel<boolean>('acknowledged')
|
||||
|
||||
const isWarningExpanded = ref(true)
|
||||
</script>
|
||||
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
readonly
|
||||
:model-value="url"
|
||||
:aria-label="$t('shareWorkflow.shareUrlLabel')"
|
||||
class="flex-1"
|
||||
@focus="($event.target as HTMLInputElement).select()"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
class="font-normal"
|
||||
@click="handleCopy"
|
||||
>
|
||||
{{
|
||||
copied ? $t('shareWorkflow.linkCopied') : $t('shareWorkflow.copyLink')
|
||||
}}
|
||||
<i class="icon-[lucide--link] size-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { refAutoReset } from '@vueuse/core'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Input from '@/components/ui/input/Input.vue'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
|
||||
const { url } = defineProps<{
|
||||
url: string
|
||||
}>()
|
||||
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
const copied = refAutoReset(false, 2000)
|
||||
|
||||
async function handleCopy() {
|
||||
await copyToClipboard(url)
|
||||
copied.value = true
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,532 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, reactive } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import ShareWorkflowDialogContent from '@/platform/workflow/sharing/components/ShareWorkflowDialogContent.vue'
|
||||
|
||||
const mockWorkflowStore = reactive<{
|
||||
activeWorkflow: {
|
||||
path: string
|
||||
directory: string
|
||||
filename: string
|
||||
isTemporary: boolean
|
||||
isModified: boolean
|
||||
lastModified: number
|
||||
} | null
|
||||
}>({
|
||||
activeWorkflow: null
|
||||
})
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => mockWorkflowStore
|
||||
}))
|
||||
|
||||
const mockToast = vi.hoisted(() => ({ add: vi.fn() }))
|
||||
|
||||
vi.mock('primevue/usetoast', () => ({
|
||||
useToast: () => mockToast
|
||||
}))
|
||||
|
||||
vi.mock('@formkit/auto-animate/vue', () => ({
|
||||
vAutoAnimate: {}
|
||||
}))
|
||||
|
||||
const mockFlags = vi.hoisted(() => ({
|
||||
comfyHubUploadEnabled: false,
|
||||
comfyHubProfileGateEnabled: true
|
||||
}))
|
||||
|
||||
const mockShowPublishDialog = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({
|
||||
flags: mockFlags
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workflow/sharing/composables/useComfyHubPublishDialog',
|
||||
() => ({
|
||||
useComfyHubPublishDialog: () => ({
|
||||
show: mockShowPublishDialog
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
|
||||
useWorkflowService: () => ({
|
||||
saveWorkflow: vi.fn(),
|
||||
renameWorkflow: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
const mockShareServiceData = vi.hoisted(() => ({
|
||||
items: [
|
||||
{
|
||||
id: 'test.png',
|
||||
name: 'test.png',
|
||||
preview_url: '',
|
||||
storage_url: '',
|
||||
model: false,
|
||||
public: false,
|
||||
in_library: false
|
||||
},
|
||||
{
|
||||
id: 'model.safetensors',
|
||||
name: 'model.safetensors',
|
||||
preview_url: '',
|
||||
storage_url: '',
|
||||
model: true,
|
||||
public: false,
|
||||
in_library: false
|
||||
}
|
||||
]
|
||||
}))
|
||||
|
||||
const mockGetPublishStatus = vi.hoisted(() => vi.fn())
|
||||
const mockPublishWorkflow = vi.hoisted(() => vi.fn())
|
||||
const mockGetShareableAssets = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/platform/workflow/sharing/services/workflowShareService', () => ({
|
||||
useWorkflowShareService: () => ({
|
||||
getPublishStatus: mockGetPublishStatus,
|
||||
publishWorkflow: mockPublishWorkflow,
|
||||
getShareableAssets: mockGetShareableAssets
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: { close: 'Close', error: 'Error' },
|
||||
shareWorkflow: {
|
||||
unsavedDescription: 'You must save your workflow before sharing.',
|
||||
shareLinkTab: 'Share',
|
||||
publishToHubTab: 'Publish',
|
||||
workflowNameLabel: 'Workflow name',
|
||||
saving: 'Saving...',
|
||||
saveButton: 'Save workflow',
|
||||
createLinkButton: 'Create link',
|
||||
creatingLink: 'Creating link...',
|
||||
checkingAssets: 'Checking assets...',
|
||||
successDescription: 'Anyone with this link...',
|
||||
hasChangesDescription: 'You have made changes...',
|
||||
updateLinkButton: 'Update link',
|
||||
updatingLink: 'Updating link...',
|
||||
publishedOn: 'Published on {date}',
|
||||
mediaLabel: '{count} Media File | {count} Media Files',
|
||||
modelsLabel: '{count} Model | {count} Models',
|
||||
acknowledgeCheckbox: 'I understand these assets...',
|
||||
loadFailed: 'Failed to load publish status'
|
||||
},
|
||||
comfyHubProfile: {
|
||||
introTitle: 'Introducing ComfyHub',
|
||||
createProfileButton: 'Create my profile',
|
||||
startPublishingButton: 'Start publishing'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('ShareWorkflowDialogContent', () => {
|
||||
const onClose = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockPublishWorkflow.mockReset()
|
||||
mockGetShareableAssets.mockReset()
|
||||
mockWorkflowStore.activeWorkflow = {
|
||||
path: 'workflows/test.json',
|
||||
directory: 'workflows',
|
||||
filename: 'test.json',
|
||||
isTemporary: false,
|
||||
isModified: false,
|
||||
lastModified: 1000
|
||||
}
|
||||
mockGetPublishStatus.mockResolvedValue({
|
||||
isPublished: false,
|
||||
shareId: null,
|
||||
shareUrl: null,
|
||||
publishedAt: null
|
||||
})
|
||||
mockFlags.comfyHubUploadEnabled = false
|
||||
mockShareServiceData.items = [
|
||||
{
|
||||
id: 'test.png',
|
||||
name: 'test.png',
|
||||
preview_url: '',
|
||||
storage_url: '',
|
||||
model: false,
|
||||
public: false,
|
||||
in_library: false
|
||||
},
|
||||
{
|
||||
id: 'model.safetensors',
|
||||
name: 'model.safetensors',
|
||||
preview_url: '',
|
||||
storage_url: '',
|
||||
model: true,
|
||||
public: false,
|
||||
in_library: false
|
||||
}
|
||||
]
|
||||
mockPublishWorkflow.mockResolvedValue({
|
||||
shareId: 'test-123',
|
||||
shareUrl: 'https://comfy.org/shared/test-123',
|
||||
publishedAt: new Date('2026-01-15')
|
||||
})
|
||||
mockGetShareableAssets.mockResolvedValue(mockShareServiceData.items)
|
||||
})
|
||||
|
||||
function createWrapper() {
|
||||
return mount(ShareWorkflowDialogContent, {
|
||||
props: { onClose },
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
ComfyHubPublishIntroPanel: {
|
||||
template:
|
||||
'<section data-testid="publish-intro"><button data-testid="publish-intro-cta" @click="$props.onCreateProfile()">Start publishing</button></section>',
|
||||
props: ['onCreateProfile']
|
||||
},
|
||||
'comfy-hub-publish-intro-panel': {
|
||||
template:
|
||||
'<section data-testid="publish-intro"><button data-testid="publish-intro-cta" @click="$props.onCreateProfile()">Start publishing</button></section>',
|
||||
props: ['onCreateProfile']
|
||||
},
|
||||
Input: {
|
||||
template: '<input v-bind="$attrs" />',
|
||||
methods: { focus() {}, select() {} }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('renders in unsaved state when workflow is modified', async () => {
|
||||
mockWorkflowStore.activeWorkflow = {
|
||||
path: 'workflows/test.json',
|
||||
directory: 'workflows',
|
||||
filename: 'test.json',
|
||||
isTemporary: false,
|
||||
isModified: true,
|
||||
lastModified: 1000
|
||||
}
|
||||
const wrapper = createWrapper()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain(
|
||||
'You must save your workflow before sharing.'
|
||||
)
|
||||
expect(wrapper.text()).toContain('Save workflow')
|
||||
})
|
||||
|
||||
it('renders share-link and publish tabs when comfy hub upload is enabled', async () => {
|
||||
mockFlags.comfyHubUploadEnabled = true
|
||||
const wrapper = createWrapper()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Share')
|
||||
expect(wrapper.text()).toContain('Publish')
|
||||
const publishTabPanel = wrapper.find('[data-testid="publish-tab-panel"]')
|
||||
expect(publishTabPanel.exists()).toBe(true)
|
||||
expect(publishTabPanel.attributes('style')).toContain('display: none')
|
||||
})
|
||||
|
||||
it('hides the publish tab when comfy hub upload is disabled', async () => {
|
||||
const wrapper = createWrapper()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Share')
|
||||
expect(wrapper.text()).not.toContain('Publish')
|
||||
expect(wrapper.find('[data-testid="publish-intro"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows publish intro panel in the share dialog', async () => {
|
||||
mockFlags.comfyHubUploadEnabled = true
|
||||
const wrapper = createWrapper()
|
||||
await flushPromises()
|
||||
|
||||
const publishTab = wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text().includes('Publish'))
|
||||
|
||||
expect(publishTab).toBeDefined()
|
||||
await publishTab!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-testid="publish-intro"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows start publishing CTA in the publish intro panel', async () => {
|
||||
mockFlags.comfyHubUploadEnabled = true
|
||||
const wrapper = createWrapper()
|
||||
await flushPromises()
|
||||
|
||||
const publishTab = wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text().includes('Publish'))
|
||||
expect(publishTab).toBeDefined()
|
||||
|
||||
await publishTab!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-testid="publish-intro-cta"]').text()).toBe(
|
||||
'Start publishing'
|
||||
)
|
||||
})
|
||||
|
||||
it('opens publish dialog from intro cta and closes share dialog', async () => {
|
||||
mockFlags.comfyHubUploadEnabled = true
|
||||
const wrapper = createWrapper()
|
||||
await flushPromises()
|
||||
|
||||
const publishTab = wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text().includes('Publish'))
|
||||
|
||||
expect(publishTab).toBeDefined()
|
||||
await publishTab!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('[data-testid="publish-intro-cta"]').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
expect(mockShowPublishDialog).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('disables publish button when acknowledgment is unchecked', async () => {
|
||||
const wrapper = createWrapper()
|
||||
await flushPromises()
|
||||
|
||||
const publishButton = wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text().includes('Create link'))
|
||||
|
||||
expect(publishButton?.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('enables publish button when acknowledgment is checked', async () => {
|
||||
const wrapper = createWrapper()
|
||||
await flushPromises()
|
||||
|
||||
const checkbox = wrapper.find('input[type="checkbox"]')
|
||||
await checkbox.setValue(true)
|
||||
await nextTick()
|
||||
|
||||
const publishButton = wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text().includes('Create link'))
|
||||
|
||||
expect(publishButton?.attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('calls onClose when close button is clicked', async () => {
|
||||
const wrapper = createWrapper()
|
||||
await flushPromises()
|
||||
|
||||
const closeButton = wrapper.find('[aria-label="Close"]')
|
||||
await closeButton.trigger('click')
|
||||
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('publishes using acknowledged assets from initial load', async () => {
|
||||
const initialShareableAssets = [
|
||||
{
|
||||
id: 'local-photo-id',
|
||||
name: 'photo.png',
|
||||
preview_url: '',
|
||||
storage_url: '',
|
||||
model: false,
|
||||
public: false,
|
||||
in_library: false
|
||||
},
|
||||
{
|
||||
id: 'local-model-id',
|
||||
name: 'model.safetensors',
|
||||
preview_url: '',
|
||||
storage_url: '',
|
||||
model: true,
|
||||
public: false,
|
||||
in_library: false
|
||||
}
|
||||
]
|
||||
|
||||
mockGetShareableAssets.mockResolvedValueOnce(initialShareableAssets)
|
||||
|
||||
const wrapper = createWrapper()
|
||||
await flushPromises()
|
||||
|
||||
const checkbox = wrapper.find('input[type="checkbox"]')
|
||||
await checkbox.setValue(true)
|
||||
await nextTick()
|
||||
|
||||
const publishButton = wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text().includes('Create link'))
|
||||
expect(publishButton).toBeDefined()
|
||||
|
||||
await publishButton!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockGetShareableAssets).toHaveBeenCalledTimes(1)
|
||||
expect(mockPublishWorkflow).toHaveBeenCalledWith(
|
||||
'workflows/test.json',
|
||||
initialShareableAssets
|
||||
)
|
||||
})
|
||||
|
||||
it('shows update button when workflow was saved after last publish', async () => {
|
||||
const publishedAt = new Date('2026-01-15T00:00:00Z')
|
||||
const savedAfterPublishMs = publishedAt.getTime() + 60_000
|
||||
|
||||
mockWorkflowStore.activeWorkflow = {
|
||||
path: 'workflows/test.json',
|
||||
directory: 'workflows',
|
||||
filename: 'test.json',
|
||||
isTemporary: false,
|
||||
isModified: false,
|
||||
lastModified: savedAfterPublishMs
|
||||
}
|
||||
mockGetPublishStatus.mockResolvedValue({
|
||||
isPublished: true,
|
||||
shareId: 'abc-123',
|
||||
shareUrl: 'https://comfy.org/shared/abc-123',
|
||||
publishedAt
|
||||
})
|
||||
|
||||
const wrapper = createWrapper()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('You have made changes...')
|
||||
expect(wrapper.text()).toContain('Update link')
|
||||
})
|
||||
|
||||
it('shows copy URL when workflow has not changed since publish', async () => {
|
||||
const publishedAt = new Date('2026-01-15T00:00:00Z')
|
||||
const savedBeforePublishMs = publishedAt.getTime() - 60_000
|
||||
|
||||
mockWorkflowStore.activeWorkflow = {
|
||||
path: 'workflows/test.json',
|
||||
directory: 'workflows',
|
||||
filename: 'test.json',
|
||||
isTemporary: false,
|
||||
isModified: false,
|
||||
lastModified: savedBeforePublishMs
|
||||
}
|
||||
mockGetPublishStatus.mockResolvedValue({
|
||||
isPublished: true,
|
||||
shareId: 'abc-123',
|
||||
shareUrl: 'https://comfy.org/shared/abc-123',
|
||||
publishedAt
|
||||
})
|
||||
|
||||
const wrapper = createWrapper()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Anyone with this link...')
|
||||
expect(wrapper.text()).not.toContain('Update link')
|
||||
})
|
||||
|
||||
describe('error and edge cases', () => {
|
||||
it('renders unsaved state when workflow is temporary', async () => {
|
||||
mockWorkflowStore.activeWorkflow = {
|
||||
path: 'workflows/Unsaved Workflow.json',
|
||||
directory: 'workflows',
|
||||
filename: 'Unsaved Workflow.json',
|
||||
isTemporary: true,
|
||||
isModified: false,
|
||||
lastModified: 1000
|
||||
}
|
||||
const wrapper = createWrapper()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain(
|
||||
'You must save your workflow before sharing.'
|
||||
)
|
||||
expect(wrapper.text()).toContain('Workflow name')
|
||||
})
|
||||
|
||||
it('shows error toast when getPublishStatus rejects', async () => {
|
||||
mockGetPublishStatus.mockRejectedValue(new Error('Server down'))
|
||||
|
||||
const wrapper = createWrapper()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Create link')
|
||||
expect(mockToast.add).toHaveBeenCalledWith({
|
||||
severity: 'error',
|
||||
summary: 'Failed to load publish status'
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error toast when publishWorkflow rejects', async () => {
|
||||
mockGetShareableAssets.mockResolvedValue([])
|
||||
|
||||
const wrapper = createWrapper()
|
||||
await flushPromises()
|
||||
|
||||
mockPublishWorkflow.mockRejectedValue(new Error('Publish failed'))
|
||||
|
||||
const publishButton = wrapper
|
||||
.findAll('button')
|
||||
.find((btn) => btn.text().includes('Create link'))
|
||||
expect(publishButton).toBeDefined()
|
||||
await publishButton!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).not.toContain('Anyone with this link...')
|
||||
expect(mockToast.add).toHaveBeenCalledWith({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: 'Publish failed',
|
||||
life: 5000
|
||||
})
|
||||
})
|
||||
|
||||
it('renders unsaved state when no active workflow exists', async () => {
|
||||
mockWorkflowStore.activeWorkflow = null
|
||||
const wrapper = createWrapper()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain(
|
||||
'You must save your workflow before sharing.'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not call publishWorkflow when workflow is null during publish', async () => {
|
||||
mockGetShareableAssets.mockResolvedValue([])
|
||||
|
||||
const wrapper = createWrapper()
|
||||
await flushPromises()
|
||||
|
||||
mockWorkflowStore.activeWorkflow = null
|
||||
|
||||
const publishButton = wrapper
|
||||
.findAll('button')
|
||||
.find((btn) => btn.text().includes('Create link'))
|
||||
if (publishButton) {
|
||||
await publishButton.trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
expect(mockPublishWorkflow).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not switch to publishToHub mode when flag is disabled', async () => {
|
||||
mockFlags.comfyHubUploadEnabled = false
|
||||
const wrapper = createWrapper()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-testid="publish-tab-panel"]').exists()).toBe(
|
||||
false
|
||||
)
|
||||
expect(wrapper.text()).not.toContain('Publish')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,400 @@
|
||||
<template>
|
||||
<div class="flex w-full flex-col">
|
||||
<header
|
||||
class="flex h-12 items-center justify-between gap-2 border-b border-border-default px-4"
|
||||
>
|
||||
<div
|
||||
v-if="showPublishToHubTab"
|
||||
role="tablist"
|
||||
class="flex flex-1 items-center gap-2"
|
||||
>
|
||||
<Button
|
||||
id="tab-share-link"
|
||||
role="tab"
|
||||
:aria-selected="dialogMode === 'shareLink'"
|
||||
:class="tabButtonClass('shareLink')"
|
||||
@click="handleDialogModeChange('shareLink')"
|
||||
>
|
||||
{{ $t('shareWorkflow.shareLinkTab') }}
|
||||
</Button>
|
||||
<Button
|
||||
id="tab-publish"
|
||||
role="tab"
|
||||
:aria-selected="dialogMode === 'publishToHub'"
|
||||
:class="tabButtonClass('publishToHub')"
|
||||
@click="handleDialogModeChange('publishToHub')"
|
||||
>
|
||||
<i class="icon-[lucide--globe] size-4" aria-hidden="true" />
|
||||
{{ $t('shareWorkflow.publishToHubTab') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div v-else class="select-none">
|
||||
{{ $t('shareWorkflow.shareLinkTab') }}
|
||||
</div>
|
||||
<Button size="icon" :aria-label="$t('g.close')" @click="onClose">
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<main v-auto-animate class="flex flex-col gap-4 p-4">
|
||||
<div
|
||||
v-show="dialogMode === 'shareLink'"
|
||||
v-auto-animate
|
||||
:role="showPublishToHubTab ? 'tabpanel' : undefined"
|
||||
:aria-labelledby="showPublishToHubTab ? 'tab-share-link' : undefined"
|
||||
class="flex flex-col gap-4"
|
||||
>
|
||||
<template v-if="dialogState === 'loading'">
|
||||
<Skeleton class="h-3 w-4/5" />
|
||||
<Skeleton class="h-3 w-3/5" />
|
||||
<Skeleton class="h-10 w-full" />
|
||||
</template>
|
||||
|
||||
<template v-if="dialogState === 'unsaved'">
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{ $t('shareWorkflow.unsavedDescription') }}
|
||||
</p>
|
||||
<label v-if="isTemporary" class="flex flex-col gap-1">
|
||||
<span class="text-sm font-medium text-muted-foreground">
|
||||
{{ $t('shareWorkflow.workflowNameLabel') }}
|
||||
</span>
|
||||
<Input
|
||||
ref="nameInputRef"
|
||||
v-model="workflowName"
|
||||
:disabled="isSaving"
|
||||
@keydown.enter="() => handleSave()"
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
:loading="isSaving"
|
||||
@click="() => handleSave()"
|
||||
>
|
||||
{{
|
||||
isSaving
|
||||
? $t('shareWorkflow.saving')
|
||||
: $t('shareWorkflow.saveButton')
|
||||
}}
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<template v-if="dialogState === 'ready' || dialogState === 'stale'">
|
||||
<p
|
||||
v-if="dialogState === 'stale'"
|
||||
class="m-0 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ $t('shareWorkflow.hasChangesDescription') }}
|
||||
</p>
|
||||
<p
|
||||
v-if="isLoadingAssets"
|
||||
class="m-0 text-sm italic text-muted-foreground"
|
||||
>
|
||||
{{ $t('shareWorkflow.checkingAssets') }}
|
||||
</p>
|
||||
<ShareAssetWarningBox
|
||||
v-else-if="requiresAcknowledgment"
|
||||
v-model:acknowledged="acknowledged"
|
||||
:items="assetInfo"
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
:disabled="
|
||||
isPublishing ||
|
||||
isLoadingAssets ||
|
||||
(requiresAcknowledgment && !acknowledged)
|
||||
"
|
||||
@click="() => handlePublish()"
|
||||
>
|
||||
{{ publishButtonLabel }}
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<template v-if="dialogState === 'shared' && publishResult">
|
||||
<ShareUrlCopyField :url="publishResult.shareUrl" />
|
||||
<div class="flex flex-col gap-1">
|
||||
<p
|
||||
v-if="publishResult.publishedAt"
|
||||
class="m-0 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ $t('shareWorkflow.publishedOn', { date: formattedDate }) }}
|
||||
</p>
|
||||
<p class="m-0 text-xs text-muted-foreground">
|
||||
{{ $t('shareWorkflow.successDescription') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
v-if="showPublishToHubTab"
|
||||
v-show="dialogMode === 'publishToHub'"
|
||||
v-auto-animate
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-publish"
|
||||
data-testid="publish-tab-panel"
|
||||
class="min-h-0"
|
||||
>
|
||||
<ComfyHubPublishIntroPanel
|
||||
data-testid="publish-intro"
|
||||
:on-create-profile="handleOpenPublishDialog"
|
||||
:on-close="onClose"
|
||||
:show-close-button="false"
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { vAutoAnimate } from '@formkit/auto-animate/vue'
|
||||
import { useAsyncState } from '@vueuse/core'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ComfyHubPublishIntroPanel from '@/platform/workflow/sharing/components/profile/ComfyHubPublishIntroPanel.vue'
|
||||
import ShareAssetWarningBox from '@/platform/workflow/sharing/components/ShareAssetWarningBox.vue'
|
||||
import ShareUrlCopyField from '@/platform/workflow/sharing/components/ShareUrlCopyField.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Input from '@/components/ui/input/Input.vue'
|
||||
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
|
||||
import { useComfyHubPublishDialog } from '@/platform/workflow/sharing/composables/useComfyHubPublishDialog'
|
||||
import type {
|
||||
WorkflowPublishResult,
|
||||
WorkflowPublishStatus
|
||||
} from '@/platform/workflow/sharing/types/shareTypes'
|
||||
import { useWorkflowShareService } from '@/platform/workflow/sharing/services/workflowShareService'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { appendJsonExt } from '@/utils/formatUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { onClose } = defineProps<{
|
||||
onClose: () => void
|
||||
}>()
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const toast = useToast()
|
||||
const { flags } = useFeatureFlags()
|
||||
const publishDialog = useComfyHubPublishDialog()
|
||||
const shareService = useWorkflowShareService()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const workflowService = useWorkflowService()
|
||||
|
||||
type DialogState = 'loading' | 'unsaved' | 'ready' | 'shared' | 'stale'
|
||||
type DialogMode = 'shareLink' | 'publishToHub'
|
||||
|
||||
function resolveDialogStateFromStatus(
|
||||
status: WorkflowPublishStatus,
|
||||
workflow: { lastModified: number }
|
||||
): { publishResult: WorkflowPublishResult | null; dialogState: DialogState } {
|
||||
if (!status.isPublished) return { publishResult: null, dialogState: 'ready' }
|
||||
const publishedAtMs = status.publishedAt.getTime()
|
||||
const lastModifiedMs = workflow.lastModified
|
||||
return {
|
||||
publishResult: {
|
||||
shareId: status.shareId,
|
||||
shareUrl: status.shareUrl,
|
||||
publishedAt: status.publishedAt
|
||||
},
|
||||
dialogState: lastModifiedMs > publishedAtMs ? 'stale' : 'shared'
|
||||
}
|
||||
}
|
||||
|
||||
const dialogState = ref<DialogState>('loading')
|
||||
const dialogMode = ref<DialogMode>('shareLink')
|
||||
const acknowledged = ref(false)
|
||||
const workflowName = ref('')
|
||||
const nameInputRef = ref<InstanceType<typeof Input> | null>(null)
|
||||
|
||||
function focusNameInput() {
|
||||
nameInputRef.value?.focus()
|
||||
nameInputRef.value?.select()
|
||||
}
|
||||
|
||||
const isTemporary = computed(
|
||||
() => workflowStore.activeWorkflow?.isTemporary ?? false
|
||||
)
|
||||
|
||||
watch(dialogState, async (state) => {
|
||||
if (state === 'unsaved' && isTemporary.value) {
|
||||
await nextTick()
|
||||
focusNameInput()
|
||||
}
|
||||
})
|
||||
|
||||
const {
|
||||
state: assetInfo,
|
||||
isLoading: isLoadingAssets,
|
||||
execute: reloadAssets
|
||||
} = useAsyncState(() => shareService.getShareableAssets(), [])
|
||||
|
||||
const requiresAcknowledgment = computed(() => assetInfo.value.length > 0)
|
||||
const showPublishToHubTab = computed(() => flags.comfyHubUploadEnabled)
|
||||
|
||||
function handleOpenPublishDialog() {
|
||||
onClose()
|
||||
publishDialog.show()
|
||||
}
|
||||
|
||||
function tabButtonClass(mode: DialogMode) {
|
||||
return cn(
|
||||
'cursor-pointer border-none transition-colors',
|
||||
dialogMode.value === mode
|
||||
? 'bg-secondary-background text-base-foreground'
|
||||
: 'bg-transparent text-muted-foreground hover:bg-secondary-background-hover'
|
||||
)
|
||||
}
|
||||
|
||||
function handleDialogModeChange(nextMode: DialogMode) {
|
||||
if (nextMode === dialogMode.value) return
|
||||
if (nextMode === 'publishToHub' && !showPublishToHubTab.value) return
|
||||
dialogMode.value = nextMode
|
||||
}
|
||||
|
||||
watch(showPublishToHubTab, (isVisible) => {
|
||||
if (!isVisible && dialogMode.value === 'publishToHub') {
|
||||
dialogMode.value = 'shareLink'
|
||||
}
|
||||
})
|
||||
|
||||
const formattedDate = computed(() => {
|
||||
if (!publishResult.value) return ''
|
||||
return publishResult.value.publishedAt.toLocaleDateString(locale.value, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})
|
||||
})
|
||||
|
||||
const publishButtonLabel = computed(() => {
|
||||
if (dialogState.value === 'stale') {
|
||||
return isPublishing.value
|
||||
? t('shareWorkflow.updatingLink')
|
||||
: t('shareWorkflow.updateLinkButton')
|
||||
}
|
||||
return isPublishing.value
|
||||
? t('shareWorkflow.creatingLink')
|
||||
: t('shareWorkflow.createLinkButton')
|
||||
})
|
||||
|
||||
function stripJsonExtension(filename: string): string {
|
||||
return filename.replace(/\.json$/i, '')
|
||||
}
|
||||
|
||||
function buildWorkflowPath(directory: string, filename: string): string {
|
||||
const normalizedDirectory = directory.replace(/\/+$/, '')
|
||||
const normalizedFilename = appendJsonExt(stripJsonExtension(filename))
|
||||
|
||||
return normalizedDirectory
|
||||
? `${normalizedDirectory}/${normalizedFilename}`
|
||||
: normalizedFilename
|
||||
}
|
||||
|
||||
async function refreshDialogState() {
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
|
||||
if (!workflow || workflow.isTemporary || workflow.isModified) {
|
||||
dialogState.value = 'unsaved'
|
||||
if (workflow) {
|
||||
workflowName.value = stripJsonExtension(workflow.filename)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const status = await shareService.getPublishStatus(workflow.path)
|
||||
const resolved = resolveDialogStateFromStatus(status, workflow)
|
||||
publishResult.value = resolved.publishResult
|
||||
dialogState.value = resolved.dialogState
|
||||
} catch (error) {
|
||||
console.error('Failed to load publish status:', error)
|
||||
publishResult.value = null
|
||||
dialogState.value = 'ready'
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('shareWorkflow.loadFailed')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void refreshDialogState()
|
||||
})
|
||||
|
||||
const { isLoading: isSaving, execute: handleSave } = useAsyncState(
|
||||
async () => {
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
if (!workflow) return
|
||||
|
||||
if (workflow.isTemporary) {
|
||||
const name = workflowName.value.trim()
|
||||
if (!name) return
|
||||
const newPath = buildWorkflowPath(workflow.directory, name)
|
||||
await workflowService.renameWorkflow(workflow, newPath)
|
||||
await workflowStore.saveWorkflow(workflow)
|
||||
} else {
|
||||
await workflowService.saveWorkflow(workflow)
|
||||
}
|
||||
|
||||
acknowledged.value = false
|
||||
await reloadAssets()
|
||||
|
||||
await refreshDialogState()
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
immediate: false,
|
||||
onError: (error) => {
|
||||
console.error('Failed to save workflow:', error)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('shareWorkflow.saveFailedTitle'),
|
||||
detail: t('shareWorkflow.saveFailedDescription'),
|
||||
life: 5000
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const {
|
||||
state: publishResult,
|
||||
isLoading: isPublishing,
|
||||
execute: handlePublish
|
||||
} = useAsyncState(
|
||||
async (): Promise<WorkflowPublishResult | null> => {
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
if (!workflow) return null
|
||||
|
||||
const publishableAssets = assetInfo.value
|
||||
|
||||
if (publishableAssets.length > 0 && !acknowledged.value) {
|
||||
return null
|
||||
}
|
||||
|
||||
const result = await shareService.publishWorkflow(
|
||||
workflow.path,
|
||||
publishableAssets
|
||||
)
|
||||
dialogState.value = 'shared'
|
||||
acknowledged.value = false
|
||||
|
||||
return result
|
||||
},
|
||||
null,
|
||||
{
|
||||
immediate: false,
|
||||
onError: (error) => {
|
||||
console.error('Failed to publish workflow:', error)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: error instanceof Error ? error.message : t('g.error'),
|
||||
life: 5000
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,193 @@
|
||||
<template>
|
||||
<form
|
||||
class="flex min-h-0 flex-1 flex-col overflow-hidden bg-base-background"
|
||||
@submit.prevent
|
||||
>
|
||||
<header
|
||||
v-if="showCloseButton"
|
||||
class="flex h-16 items-center justify-between px-6"
|
||||
>
|
||||
<h2 class="text-base font-normal text-base-foreground">
|
||||
{{ $t('comfyHubProfile.createProfileTitle') }}
|
||||
</h2>
|
||||
<Button size="icon" :aria-label="$t('g.close')" @click="onClose">
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
</header>
|
||||
<h2 v-else class="px-6 pt-6 text-base font-normal text-base-foreground">
|
||||
{{ $t('comfyHubProfile.createProfileTitle') }}
|
||||
</h2>
|
||||
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto px-6 py-4">
|
||||
<div class="flex flex-col gap-4">
|
||||
<label for="profile-picture" class="text-sm text-muted-foreground">
|
||||
{{ $t('comfyHubProfile.chooseProfilePicture') }}
|
||||
</label>
|
||||
<label
|
||||
class="flex size-13 cursor-pointer items-center justify-center overflow-hidden rounded-full bg-gradient-to-b from-green-600/50 to-green-900"
|
||||
>
|
||||
<input
|
||||
id="profile-picture"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
@change="handleProfileSelect"
|
||||
/>
|
||||
<template v-if="profilePreviewUrl">
|
||||
<img
|
||||
:src="profilePreviewUrl"
|
||||
:alt="$t('comfyHubProfile.chooseProfilePicture')"
|
||||
class="size-full rounded-full object-cover"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="text-base text-white">
|
||||
{{ profileInitial }}
|
||||
</span>
|
||||
</template>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex flex-col gap-4">
|
||||
<label for="profile-name" class="text-sm text-muted-foreground">
|
||||
{{ $t('comfyHubProfile.nameLabel') }}
|
||||
</label>
|
||||
<Input
|
||||
id="profile-name"
|
||||
v-model="name"
|
||||
:placeholder="$t('comfyHubProfile.namePlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="profile-username" class="text-sm text-muted-foreground">
|
||||
{{ $t('comfyHubProfile.usernameLabel') }}
|
||||
</label>
|
||||
<div class="relative">
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-sm',
|
||||
username ? 'text-base-foreground' : 'text-muted-foreground'
|
||||
)
|
||||
"
|
||||
>
|
||||
@
|
||||
</span>
|
||||
<Input id="profile-username" v-model="username" class="pl-7" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label
|
||||
for="profile-description"
|
||||
class="text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('comfyHubProfile.descriptionLabel') }}
|
||||
</label>
|
||||
<Textarea
|
||||
id="profile-description"
|
||||
v-model="description"
|
||||
:placeholder="$t('comfyHubProfile.descriptionPlaceholder')"
|
||||
class="h-24 resize-none rounded-lg border-none bg-secondary-background p-4 text-sm shadow-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer
|
||||
class="flex items-center justify-end gap-4 border-t border-border-default px-6 py-4"
|
||||
>
|
||||
<Button size="lg" @click="onClose">
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
:disabled="!username.trim() || isCreating"
|
||||
@click="handleCreate"
|
||||
>
|
||||
{{
|
||||
isCreating
|
||||
? $t('comfyHubProfile.creatingProfile')
|
||||
: $t('comfyHubProfile.createProfile')
|
||||
}}
|
||||
</Button>
|
||||
</footer>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useObjectUrl } from '@vueuse/core'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Input from '@/components/ui/input/Input.vue'
|
||||
import Textarea from '@/components/ui/textarea/Textarea.vue'
|
||||
import {
|
||||
isFileTooLarge,
|
||||
MAX_IMAGE_SIZE_MB
|
||||
} from '@/platform/workflow/sharing/utils/validateFileSize'
|
||||
import { useComfyHubProfileGate } from '@/platform/workflow/sharing/composables/useComfyHubProfileGate'
|
||||
import type { ComfyHubProfile } from '@/schemas/apiSchema'
|
||||
|
||||
const {
|
||||
onProfileCreated,
|
||||
onClose,
|
||||
showCloseButton = true
|
||||
} = defineProps<{
|
||||
onProfileCreated: (profile: ComfyHubProfile) => void
|
||||
onClose: () => void
|
||||
showCloseButton?: boolean
|
||||
}>()
|
||||
|
||||
const { createProfile } = useComfyHubProfileGate()
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
|
||||
const name = ref('')
|
||||
const username = ref('')
|
||||
const description = ref('')
|
||||
const profilePictureFile = ref<File | null>(null)
|
||||
const profilePreviewUrl = useObjectUrl(profilePictureFile)
|
||||
const isCreating = ref(false)
|
||||
|
||||
const profileInitial = computed(() => {
|
||||
const source = name.value.trim() || username.value.trim()
|
||||
return source ? source[0].toUpperCase() : 'C'
|
||||
})
|
||||
|
||||
function handleProfileSelect(event: Event) {
|
||||
if (!(event.target instanceof HTMLInputElement)) return
|
||||
const file = event.target.files?.[0]
|
||||
if (!file || isFileTooLarge(file, MAX_IMAGE_SIZE_MB)) return
|
||||
profilePictureFile.value = file
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (isCreating.value) return
|
||||
isCreating.value = true
|
||||
try {
|
||||
const profile = await createProfile({
|
||||
username: username.value.trim(),
|
||||
name: name.value.trim() || undefined,
|
||||
description: description.value.trim() || undefined,
|
||||
profilePicture: profilePictureFile.value ?? undefined
|
||||
})
|
||||
onProfileCreated(profile)
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: error instanceof Error ? error.message : t('g.error'),
|
||||
life: 5000
|
||||
})
|
||||
} finally {
|
||||
isCreating.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<div class="flex w-full flex-col overflow-hidden bg-base-background">
|
||||
<!-- Close button -->
|
||||
<div v-if="showCloseButton" class="flex justify-end px-2 pt-2">
|
||||
<Button
|
||||
size="icon"
|
||||
class="rounded-full"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="onClose"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<section class="flex flex-col items-center gap-4 px-4 pb-6 pt-4">
|
||||
<h2 class="m-0 text-base font-semibold text-base-foreground">
|
||||
{{ $t('comfyHubProfile.introTitle') }}
|
||||
</h2>
|
||||
<p class="m-0 text-center text-sm text-muted-foreground">
|
||||
{{ $t('comfyHubProfile.introDescription') }}
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
class="mt-2 w-full"
|
||||
@click="onCreateProfile"
|
||||
>
|
||||
{{ $t('comfyHubProfile.startPublishingButton') }}
|
||||
</Button>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const {
|
||||
onCreateProfile,
|
||||
onClose,
|
||||
showCloseButton = true
|
||||
} = defineProps<{
|
||||
onCreateProfile: () => void
|
||||
onClose: () => void
|
||||
showCloseButton?: boolean
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,192 @@
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-6 px-6 py-4">
|
||||
<label class="flex flex-col gap-2">
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ $t('comfyHubPublish.workflowName') }}
|
||||
</span>
|
||||
<Input
|
||||
:model-value="name"
|
||||
:placeholder="$t('comfyHubPublish.workflowNamePlaceholder')"
|
||||
@update:model-value="$emit('update:name', String($event))"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-2">
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ $t('comfyHubPublish.workflowDescription') }}
|
||||
</span>
|
||||
<Textarea
|
||||
:model-value="description"
|
||||
:placeholder="$t('comfyHubPublish.workflowDescriptionPlaceholder')"
|
||||
rows="5"
|
||||
@update:model-value="$emit('update:description', String($event))"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-2">
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ $t('comfyHubPublish.workflowType') }}
|
||||
</span>
|
||||
<Select
|
||||
:model-value="workflowType"
|
||||
@update:model-value="
|
||||
emit('update:workflowType', $event as ComfyHubWorkflowType)
|
||||
"
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
:placeholder="$t('comfyHubPublish.workflowTypePlaceholder')"
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="option in workflowTypeOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
|
||||
<fieldset class="flex flex-col gap-2">
|
||||
<legend class="text-sm text-base-foreground">
|
||||
{{ $t('comfyHubPublish.tagsDescription') }}
|
||||
</legend>
|
||||
<TagsInput
|
||||
v-slot="{ isEmpty }"
|
||||
always-editing
|
||||
class="select-none bg-secondary-background"
|
||||
:model-value="tags"
|
||||
@update:model-value="$emit('update:tags', $event as string[])"
|
||||
>
|
||||
<TagsInputItem v-for="tag in tags" :key="tag" :value="tag">
|
||||
<TagsInputItemText />
|
||||
<TagsInputItemDelete />
|
||||
</TagsInputItem>
|
||||
<TagsInputInput :is-empty />
|
||||
</TagsInput>
|
||||
|
||||
<TagsInput
|
||||
disabled
|
||||
class="bg-transparent hover:bg-transparent hover-within:bg-transparent p-0"
|
||||
>
|
||||
<div
|
||||
v-if="displayedSuggestions.length > 0"
|
||||
class="basis-full flex flex-wrap gap-2"
|
||||
>
|
||||
<TagsInputItem
|
||||
v-for="tag in displayedSuggestions"
|
||||
:key="tag"
|
||||
v-auto-animate
|
||||
:value="tag"
|
||||
class="cursor-pointer select-none transition-colors bg-secondary-background hover:bg-secondary-background-selected text-muted-foreground px-2"
|
||||
@click="addTag(tag)"
|
||||
>
|
||||
<TagsInputItemText />
|
||||
</TagsInputItem>
|
||||
</div>
|
||||
<Button
|
||||
v-if="shouldShowSuggestionToggle"
|
||||
variant="muted-textonly"
|
||||
size="unset"
|
||||
class="text-xs px-0 hover:bg-unset"
|
||||
@click="showAllSuggestions = !showAllSuggestions"
|
||||
>
|
||||
{{
|
||||
$t(
|
||||
showAllSuggestions
|
||||
? 'comfyHubPublish.showLessTags'
|
||||
: 'comfyHubPublish.showMoreTags'
|
||||
)
|
||||
}}
|
||||
</Button>
|
||||
</TagsInput>
|
||||
</fieldset>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Input from '@/components/ui/input/Input.vue'
|
||||
import Select from '@/components/ui/select/Select.vue'
|
||||
import SelectContent from '@/components/ui/select/SelectContent.vue'
|
||||
import SelectItem from '@/components/ui/select/SelectItem.vue'
|
||||
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
|
||||
import SelectValue from '@/components/ui/select/SelectValue.vue'
|
||||
import TagsInput from '@/components/ui/tags-input/TagsInput.vue'
|
||||
import TagsInputInput from '@/components/ui/tags-input/TagsInputInput.vue'
|
||||
import TagsInputItem from '@/components/ui/tags-input/TagsInputItem.vue'
|
||||
import TagsInputItemDelete from '@/components/ui/tags-input/TagsInputItemDelete.vue'
|
||||
import TagsInputItemText from '@/components/ui/tags-input/TagsInputItemText.vue'
|
||||
import Textarea from '@/components/ui/textarea/Textarea.vue'
|
||||
import { COMFY_HUB_TAG_OPTIONS } from '@/platform/workflow/sharing/constants/comfyHubTags'
|
||||
import type { ComfyHubWorkflowType } from '@/platform/workflow/sharing/types/comfyHubTypes'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { vAutoAnimate } from '@formkit/auto-animate/vue'
|
||||
|
||||
const { tags, workflowType } = defineProps<{
|
||||
name: string
|
||||
description: string
|
||||
workflowType: ComfyHubWorkflowType | ''
|
||||
tags: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:name': [value: string]
|
||||
'update:description': [value: string]
|
||||
'update:workflowType': [value: ComfyHubWorkflowType | '']
|
||||
'update:tags': [value: string[]]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const workflowTypeOptions = computed(() => [
|
||||
{
|
||||
value: 'imageGeneration',
|
||||
label: t('comfyHubPublish.workflowTypeImageGeneration')
|
||||
},
|
||||
{
|
||||
value: 'videoGeneration',
|
||||
label: t('comfyHubPublish.workflowTypeVideoGeneration')
|
||||
},
|
||||
{
|
||||
value: 'upscaling',
|
||||
label: t('comfyHubPublish.workflowTypeUpscaling')
|
||||
},
|
||||
{
|
||||
value: 'editing',
|
||||
label: t('comfyHubPublish.workflowTypeEditing')
|
||||
}
|
||||
])
|
||||
|
||||
const INITIAL_TAG_SUGGESTION_COUNT = 10
|
||||
|
||||
const showAllSuggestions = ref(false)
|
||||
|
||||
const availableSuggestions = computed(() =>
|
||||
COMFY_HUB_TAG_OPTIONS.filter((tag) => !tags.includes(tag))
|
||||
)
|
||||
|
||||
const displayedSuggestions = computed(() =>
|
||||
showAllSuggestions.value
|
||||
? availableSuggestions.value
|
||||
: availableSuggestions.value.slice(0, INITIAL_TAG_SUGGESTION_COUNT)
|
||||
)
|
||||
|
||||
const hasHiddenSuggestions = computed(
|
||||
() =>
|
||||
!showAllSuggestions.value &&
|
||||
availableSuggestions.value.length > INITIAL_TAG_SUGGESTION_COUNT
|
||||
)
|
||||
|
||||
const shouldShowSuggestionToggle = computed(
|
||||
() => showAllSuggestions.value || hasHiddenSuggestions.value
|
||||
)
|
||||
|
||||
function addTag(tag: string) {
|
||||
emit('update:tags', [...tags, tag])
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,145 @@
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-6">
|
||||
<p class="text-sm">
|
||||
{{
|
||||
$t('comfyHubPublish.examplesDescription', {
|
||||
selected: selectedExampleIds.length,
|
||||
total: MAX_EXAMPLES
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-4 gap-2.5 overflow-y-auto">
|
||||
<!-- Upload tile -->
|
||||
<label
|
||||
tabindex="0"
|
||||
role="button"
|
||||
:aria-label="$t('comfyHubPublish.uploadExampleImage')"
|
||||
class="flex h-25 aspect-square text-center cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border-default transition-colors hover:border-muted-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
|
||||
@dragenter.stop
|
||||
@dragleave.stop
|
||||
@dragover.prevent.stop
|
||||
@drop.prevent.stop="handleFileDrop"
|
||||
@keydown.enter.prevent="fileInputRef?.click()"
|
||||
@keydown.space.prevent="fileInputRef?.click()"
|
||||
>
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
class="hidden"
|
||||
@change="handleFileSelect"
|
||||
/>
|
||||
<i
|
||||
class="icon-[lucide--plus] size-4 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="sr-only">{{
|
||||
$t('comfyHubPublish.uploadExampleImage')
|
||||
}}</span>
|
||||
</label>
|
||||
|
||||
<!-- Example images -->
|
||||
<Button
|
||||
v-for="(image, index) in exampleImages"
|
||||
:key="image.id"
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:class="
|
||||
cn(
|
||||
'relative h-25 cursor-pointer overflow-hidden rounded-sm p-0',
|
||||
isSelected(image.id) ? 'ring-2 ring-ring' : 'ring-0'
|
||||
)
|
||||
"
|
||||
@click="toggleSelection(image.id)"
|
||||
>
|
||||
<img
|
||||
:src="image.url"
|
||||
:alt="$t('comfyHubPublish.exampleImage', { index: index + 1 })"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
<div
|
||||
v-if="isSelected(image.id)"
|
||||
class="absolute bottom-1.5 left-1.5 flex size-7 items-center justify-center rounded-full bg-primary-background text-sm font-bold text-base-foreground"
|
||||
>
|
||||
{{ selectionIndex(image.id) }}
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { ExampleImage } from '@/platform/workflow/sharing/types/comfyHubTypes'
|
||||
import {
|
||||
isFileTooLarge,
|
||||
MAX_IMAGE_SIZE_MB
|
||||
} from '@/platform/workflow/sharing/utils/validateFileSize'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const MAX_EXAMPLES = 8
|
||||
|
||||
const { exampleImages, selectedExampleIds } = defineProps<{
|
||||
exampleImages: ExampleImage[]
|
||||
selectedExampleIds: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:exampleImages': [value: ExampleImage[]]
|
||||
'update:selectedExampleIds': [value: string[]]
|
||||
}>()
|
||||
|
||||
function isSelected(id: string): boolean {
|
||||
return selectedExampleIds.includes(id)
|
||||
}
|
||||
|
||||
function selectionIndex(id: string): number {
|
||||
return selectedExampleIds.indexOf(id) + 1
|
||||
}
|
||||
|
||||
function toggleSelection(id: string) {
|
||||
if (isSelected(id)) {
|
||||
emit(
|
||||
'update:selectedExampleIds',
|
||||
selectedExampleIds.filter((sid) => sid !== id)
|
||||
)
|
||||
} else if (selectedExampleIds.length < MAX_EXAMPLES) {
|
||||
emit('update:selectedExampleIds', [...selectedExampleIds, id])
|
||||
}
|
||||
}
|
||||
|
||||
function addImages(files: FileList) {
|
||||
const newImages: ExampleImage[] = Array.from(files)
|
||||
.filter((f) => f.type.startsWith('image/'))
|
||||
.filter((f) => !isFileTooLarge(f, MAX_IMAGE_SIZE_MB))
|
||||
.map((file) => ({
|
||||
id: uuidv4(),
|
||||
url: URL.createObjectURL(file),
|
||||
file
|
||||
}))
|
||||
|
||||
if (newImages.length > 0) {
|
||||
emit('update:exampleImages', [...exampleImages, ...newImages])
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileSelect(event: Event) {
|
||||
if (!(event.target instanceof HTMLInputElement)) return
|
||||
if (event.target.files?.length) {
|
||||
addImages(event.target.files)
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileDrop(event: DragEvent) {
|
||||
if (event.dataTransfer?.files?.length) {
|
||||
addImages(event.dataTransfer.files)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-4 px-6 py-4">
|
||||
<p class="text-sm text-base-foreground">
|
||||
{{ $t('comfyHubPublish.createProfileToPublish') }}
|
||||
</p>
|
||||
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
class="flex w-64 items-center gap-4 rounded-2xl border border-dashed border-border-default px-6 py-4 hover:bg-secondary-background-hover"
|
||||
@click="emit('requestProfile')"
|
||||
>
|
||||
<div
|
||||
class="flex size-12 items-center justify-center rounded-full border border-dashed border-border-default"
|
||||
>
|
||||
<i class="icon-[lucide--user] size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<span class="inline-flex items-center gap-1 text-sm text-base-foreground">
|
||||
<i class="icon-[lucide--plus] size-4" />
|
||||
{{ $t('comfyHubPublish.createProfileCta') }}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
requestProfile: []
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,139 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import ComfyHubPublishDialog from '@/platform/workflow/sharing/components/publish/ComfyHubPublishDialog.vue'
|
||||
|
||||
const mockFetchProfile = vi.hoisted(() => vi.fn())
|
||||
const mockGoToStep = vi.hoisted(() => vi.fn())
|
||||
const mockGoNext = vi.hoisted(() => vi.fn())
|
||||
const mockGoBack = vi.hoisted(() => vi.fn())
|
||||
const mockOpenProfileCreationStep = vi.hoisted(() => vi.fn())
|
||||
const mockCloseProfileCreationStep = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workflow/sharing/composables/useComfyHubProfileGate',
|
||||
() => ({
|
||||
useComfyHubProfileGate: () => ({
|
||||
fetchProfile: mockFetchProfile
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workflow/sharing/composables/useComfyHubPublishWizard',
|
||||
() => ({
|
||||
useComfyHubPublishWizard: () => ({
|
||||
currentStep: ref('finish'),
|
||||
formData: ref({
|
||||
name: '',
|
||||
description: '',
|
||||
workflowType: '',
|
||||
tags: [],
|
||||
thumbnailType: 'image',
|
||||
thumbnailFile: null,
|
||||
comparisonBeforeFile: null,
|
||||
comparisonAfterFile: null,
|
||||
exampleImages: [],
|
||||
selectedExampleIds: []
|
||||
}),
|
||||
isFirstStep: ref(false),
|
||||
isLastStep: ref(true),
|
||||
goToStep: mockGoToStep,
|
||||
goNext: mockGoNext,
|
||||
goBack: mockGoBack,
|
||||
openProfileCreationStep: mockOpenProfileCreationStep,
|
||||
closeProfileCreationStep: mockCloseProfileCreationStep
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
describe('ComfyHubPublishDialog', () => {
|
||||
const onClose = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFetchProfile.mockResolvedValue(null)
|
||||
})
|
||||
|
||||
function createWrapper() {
|
||||
return mount(ComfyHubPublishDialog, {
|
||||
props: { onClose },
|
||||
global: {
|
||||
mocks: {
|
||||
$t: (key: string) => key
|
||||
},
|
||||
stubs: {
|
||||
BaseModalLayout: {
|
||||
template:
|
||||
'<div data-testid="base-modal-layout"><slot name="leftPanelHeaderTitle" /><slot name="leftPanel" /><slot name="header" /><slot name="content" /></div>'
|
||||
},
|
||||
ComfyHubPublishNav: {
|
||||
template: '<nav data-testid="publish-nav" />',
|
||||
props: ['currentStep']
|
||||
},
|
||||
'comfy-hub-publish-nav': {
|
||||
template: '<nav data-testid="publish-nav" />',
|
||||
props: ['currentStep']
|
||||
},
|
||||
ComfyHubPublishWizardContent: {
|
||||
template:
|
||||
'<div><button data-testid="require-profile" @click="$props.onRequireProfile()" /><button data-testid="gate-complete" @click="$props.onGateComplete()" /><button data-testid="gate-close" @click="$props.onGateClose()" /></div>',
|
||||
props: [
|
||||
'currentStep',
|
||||
'formData',
|
||||
'isFirstStep',
|
||||
'isLastStep',
|
||||
'onGoNext',
|
||||
'onGoBack',
|
||||
'onRequireProfile',
|
||||
'onGateComplete',
|
||||
'onGateClose'
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('starts in publish wizard mode and prefetches profile asynchronously', async () => {
|
||||
createWrapper()
|
||||
await flushPromises()
|
||||
|
||||
expect(mockFetchProfile).toHaveBeenCalledWith()
|
||||
})
|
||||
|
||||
it('switches to profile creation step when final-step publish requires profile', async () => {
|
||||
const wrapper = createWrapper()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('[data-testid="require-profile"]').trigger('click')
|
||||
|
||||
expect(mockOpenProfileCreationStep).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('returns to finish state after gate complete and does not auto-close', async () => {
|
||||
const wrapper = createWrapper()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('[data-testid="require-profile"]').trigger('click')
|
||||
await wrapper.find('[data-testid="gate-complete"]').trigger('click')
|
||||
|
||||
expect(mockOpenProfileCreationStep).toHaveBeenCalledOnce()
|
||||
expect(mockCloseProfileCreationStep).toHaveBeenCalledOnce()
|
||||
expect(mockFetchProfile).toHaveBeenCalledWith({ force: true })
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns to finish state when profile gate is closed', async () => {
|
||||
const wrapper = createWrapper()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('[data-testid="require-profile"]').trigger('click')
|
||||
await wrapper.find('[data-testid="gate-close"]').trigger('click')
|
||||
|
||||
expect(mockOpenProfileCreationStep).toHaveBeenCalledOnce()
|
||||
expect(mockCloseProfileCreationStep).toHaveBeenCalledOnce()
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<BaseModalLayout
|
||||
:content-title="$t('comfyHubPublish.title')"
|
||||
content-padding="none"
|
||||
left-panel-width="16.5rem"
|
||||
size="md"
|
||||
>
|
||||
<template #leftPanelHeaderTitle>
|
||||
<h2 class="flex-1 select-none text-base font-semibold">
|
||||
{{ $t('comfyHubPublish.title') }}
|
||||
</h2>
|
||||
</template>
|
||||
|
||||
<template #leftPanel>
|
||||
<ComfyHubPublishNav :current-step @step-click="goToStep" />
|
||||
</template>
|
||||
|
||||
<template #header />
|
||||
<template #content>
|
||||
<ComfyHubPublishWizardContent
|
||||
:current-step
|
||||
:form-data
|
||||
:is-first-step
|
||||
:is-last-step
|
||||
:on-update-form-data="updateFormData"
|
||||
:on-go-next="goNext"
|
||||
:on-go-back="goBack"
|
||||
:on-require-profile="handleRequireProfile"
|
||||
:on-gate-complete="handlePublishGateComplete"
|
||||
:on-gate-close="handlePublishGateClose"
|
||||
:on-publish="onClose"
|
||||
/>
|
||||
</template>
|
||||
</BaseModalLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, provide } from 'vue'
|
||||
|
||||
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
|
||||
import ComfyHubPublishNav from '@/platform/workflow/sharing/components/publish/ComfyHubPublishNav.vue'
|
||||
import ComfyHubPublishWizardContent from '@/platform/workflow/sharing/components/publish/ComfyHubPublishWizardContent.vue'
|
||||
import { useComfyHubPublishWizard } from '@/platform/workflow/sharing/composables/useComfyHubPublishWizard'
|
||||
import { useComfyHubProfileGate } from '@/platform/workflow/sharing/composables/useComfyHubProfileGate'
|
||||
import type { ComfyHubPublishFormData } from '@/platform/workflow/sharing/types/comfyHubTypes'
|
||||
import { OnCloseKey } from '@/types/widgetTypes'
|
||||
|
||||
const { onClose } = defineProps<{
|
||||
onClose: () => void
|
||||
}>()
|
||||
|
||||
const { fetchProfile } = useComfyHubProfileGate()
|
||||
const {
|
||||
currentStep,
|
||||
formData,
|
||||
isFirstStep,
|
||||
isLastStep,
|
||||
goToStep,
|
||||
goNext,
|
||||
goBack,
|
||||
openProfileCreationStep,
|
||||
closeProfileCreationStep
|
||||
} = useComfyHubPublishWizard()
|
||||
|
||||
function handlePublishGateComplete() {
|
||||
closeProfileCreationStep()
|
||||
void fetchProfile({ force: true })
|
||||
}
|
||||
|
||||
function handlePublishGateClose() {
|
||||
closeProfileCreationStep()
|
||||
}
|
||||
|
||||
function handleRequireProfile() {
|
||||
openProfileCreationStep()
|
||||
}
|
||||
|
||||
function updateFormData(patch: Partial<ComfyHubPublishFormData>) {
|
||||
formData.value = { ...formData.value, ...patch }
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Prefetch profile data in the background so finish-step profile context is ready.
|
||||
void fetchProfile()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
for (const image of formData.value.exampleImages) {
|
||||
URL.revokeObjectURL(image.url)
|
||||
}
|
||||
})
|
||||
|
||||
provide(OnCloseKey, onClose)
|
||||
</script>
|
||||
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<footer class="flex shrink items-center justify-between py-2">
|
||||
<div>
|
||||
<Button v-if="!isFirstStep" size="lg" @click="$emit('back')">
|
||||
{{ $t('comfyHubPublish.back') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex gap-4">
|
||||
<Button v-if="!isLastStep" size="lg" @click="$emit('next')">
|
||||
{{ $t('comfyHubPublish.next') }}
|
||||
<i class="icon-[lucide--chevron-right] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
variant="primary"
|
||||
size="lg"
|
||||
:disabled="isPublishDisabled"
|
||||
@click="$emit('publish')"
|
||||
>
|
||||
<i class="icon-[lucide--upload] size-4" />
|
||||
{{ $t('comfyHubPublish.publishButton') }}
|
||||
</Button>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
defineProps<{
|
||||
isFirstStep: boolean
|
||||
isLastStep: boolean
|
||||
isPublishDisabled?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
back: []
|
||||
next: []
|
||||
publish: []
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<nav class="flex flex-col gap-6 px-3 py-4">
|
||||
<ol class="flex flex-col">
|
||||
<li
|
||||
v-for="step in steps"
|
||||
:key="step.name"
|
||||
v-auto-animate
|
||||
:aria-current="isCurrentStep(step.name) ? 'step' : undefined"
|
||||
:class="
|
||||
cn(
|
||||
isProfileCreationFlow &&
|
||||
step.name === 'finish' &&
|
||||
'rounded-lg bg-secondary-background-hover'
|
||||
)
|
||||
"
|
||||
>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:class="
|
||||
cn(
|
||||
'h-10 w-full justify-start rounded-lg px-4 py-3 text-left',
|
||||
isCurrentStep(step.name) &&
|
||||
!(isProfileCreationFlow && step.name === 'finish')
|
||||
? 'bg-secondary-background-selected'
|
||||
: 'hover:bg-interface-menu-component-surface-hovered'
|
||||
)
|
||||
"
|
||||
@click="$emit('stepClick', step.name)"
|
||||
>
|
||||
<StatusBadge
|
||||
:label="step.number"
|
||||
variant="circle"
|
||||
severity="contrast"
|
||||
:class="
|
||||
cn(
|
||||
'size-5 shrink-0 border text-xs font-bold font-inter bg-transparent',
|
||||
isCurrentStep(step.name)
|
||||
? 'border-base-foreground bg-base-foreground text-base-background'
|
||||
: isCompletedStep(step.name)
|
||||
? 'border-base-foreground text-base-foreground'
|
||||
: 'border-muted-foreground text-muted-foreground'
|
||||
)
|
||||
"
|
||||
/>
|
||||
<span class="truncate text-sm text-base-foreground">
|
||||
{{ step.label }}
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<div
|
||||
v-if="isProfileCreationFlow && step.name === 'finish'"
|
||||
v-auto-animate
|
||||
class="flex h-10 w-full items-center rounded-lg bg-secondary-background-selected pl-11 select-none"
|
||||
>
|
||||
<span class="truncate text-sm text-base-foreground">
|
||||
{{ $t('comfyHubProfile.profileCreationNav') }}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { vAutoAnimate } from '@formkit/auto-animate/vue'
|
||||
|
||||
import StatusBadge from '@/components/common/StatusBadge.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { ComfyHubPublishStep } from '@/platform/workflow/sharing/composables/useComfyHubPublishWizard'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
type ComfyHubPrimaryStep = Exclude<ComfyHubPublishStep, 'profileCreation'>
|
||||
|
||||
const { currentStep } = defineProps<{
|
||||
currentStep: ComfyHubPublishStep
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
stepClick: [step: ComfyHubPrimaryStep]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const steps = [
|
||||
{
|
||||
name: 'describe' as const,
|
||||
number: 1,
|
||||
label: t('comfyHubPublish.stepDescribe')
|
||||
},
|
||||
{
|
||||
name: 'examples' as const,
|
||||
number: 2,
|
||||
label: t('comfyHubPublish.stepExamples')
|
||||
},
|
||||
{ name: 'finish' as const, number: 3, label: t('comfyHubPublish.stepFinish') }
|
||||
]
|
||||
|
||||
const isProfileCreationFlow = computed(() => currentStep === 'profileCreation')
|
||||
|
||||
const currentStepNumber = computed(() => {
|
||||
if (isProfileCreationFlow.value) {
|
||||
return 3
|
||||
}
|
||||
|
||||
return steps.find((step) => step.name === currentStep)?.number ?? 0
|
||||
})
|
||||
|
||||
function isCurrentStep(stepName: ComfyHubPrimaryStep) {
|
||||
return currentStep === stepName
|
||||
}
|
||||
|
||||
function isCompletedStep(stepName: ComfyHubPrimaryStep) {
|
||||
return (
|
||||
(steps.find((step) => step.name === stepName)?.number ?? 0) <
|
||||
currentStepNumber.value
|
||||
)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,263 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import ComfyHubPublishWizardContent from './ComfyHubPublishWizardContent.vue'
|
||||
import type { ComfyHubPublishFormData } from '@/platform/workflow/sharing/types/comfyHubTypes'
|
||||
|
||||
const mockCheckProfile = vi.hoisted(() => vi.fn())
|
||||
const mockToastErrorHandler = vi.hoisted(() => vi.fn())
|
||||
const mockHasProfile = ref<boolean | null>(true)
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workflow/sharing/composables/useComfyHubProfileGate',
|
||||
() => ({
|
||||
useComfyHubProfileGate: () => ({
|
||||
checkProfile: mockCheckProfile,
|
||||
hasProfile: mockHasProfile
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/composables/useErrorHandling', () => ({
|
||||
useErrorHandling: () => ({
|
||||
toastErrorHandler: mockToastErrorHandler
|
||||
})
|
||||
}))
|
||||
|
||||
const mockFlags = vi.hoisted(() => ({
|
||||
comfyHubProfileGateEnabled: true
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({
|
||||
flags: mockFlags
|
||||
})
|
||||
}))
|
||||
|
||||
function createDefaultFormData(): ComfyHubPublishFormData {
|
||||
return {
|
||||
name: 'Test Workflow',
|
||||
description: '',
|
||||
workflowType: '',
|
||||
tags: [],
|
||||
thumbnailType: 'image',
|
||||
thumbnailFile: null,
|
||||
comparisonBeforeFile: null,
|
||||
comparisonAfterFile: null,
|
||||
exampleImages: [],
|
||||
selectedExampleIds: []
|
||||
}
|
||||
}
|
||||
|
||||
describe('ComfyHubPublishWizardContent', () => {
|
||||
const onPublish = vi.fn()
|
||||
const onGoNext = vi.fn()
|
||||
const onGoBack = vi.fn()
|
||||
const onUpdateFormData = vi.fn()
|
||||
const onRequireProfile = vi.fn()
|
||||
const onGateComplete = vi.fn()
|
||||
const onGateClose = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCheckProfile.mockResolvedValue(true)
|
||||
mockHasProfile.value = true
|
||||
mockFlags.comfyHubProfileGateEnabled = true
|
||||
})
|
||||
|
||||
function createWrapper(
|
||||
overrides: Partial<
|
||||
InstanceType<typeof ComfyHubPublishWizardContent>['$props']
|
||||
> = {}
|
||||
) {
|
||||
return mount(ComfyHubPublishWizardContent, {
|
||||
props: {
|
||||
currentStep: 'finish',
|
||||
formData: createDefaultFormData(),
|
||||
isFirstStep: false,
|
||||
isLastStep: true,
|
||||
onGoNext,
|
||||
onGoBack,
|
||||
onUpdateFormData,
|
||||
onPublish,
|
||||
onRequireProfile,
|
||||
onGateComplete,
|
||||
onGateClose,
|
||||
...overrides
|
||||
},
|
||||
global: {
|
||||
mocks: {
|
||||
$t: (key: string) => key
|
||||
},
|
||||
stubs: {
|
||||
ComfyHubCreateProfileForm: {
|
||||
template: '<div data-testid="publish-gate-flow" />',
|
||||
props: ['onProfileCreated', 'onClose', 'showCloseButton']
|
||||
},
|
||||
'comfy-hub-create-profile-form': {
|
||||
template: '<div data-testid="publish-gate-flow" />',
|
||||
props: ['onProfileCreated', 'onClose', 'showCloseButton']
|
||||
},
|
||||
ComfyHubDescribeStep: {
|
||||
template: '<div data-testid="describe-step" />'
|
||||
},
|
||||
ComfyHubExamplesStep: {
|
||||
template: '<div data-testid="examples-step" />'
|
||||
},
|
||||
ComfyHubThumbnailStep: {
|
||||
template: '<div data-testid="thumbnail-step" />'
|
||||
},
|
||||
ComfyHubProfilePromptPanel: {
|
||||
template:
|
||||
'<div data-testid="profile-prompt"><button data-testid="request-profile" @click="$emit(\'request-profile\')" /></div>',
|
||||
emits: ['request-profile']
|
||||
},
|
||||
ComfyHubPublishFooter: {
|
||||
template:
|
||||
'<div data-testid="publish-footer" :data-publish-disabled="isPublishDisabled"><button data-testid="publish-btn" @click="$emit(\'publish\')" /><button data-testid="next-btn" @click="$emit(\'next\')" /><button data-testid="back-btn" @click="$emit(\'back\')" /></div>',
|
||||
props: ['isFirstStep', 'isLastStep', 'isPublishDisabled'],
|
||||
emits: ['publish', 'next', 'back']
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('handlePublish — double-click guard', () => {
|
||||
it('prevents concurrent publish calls', async () => {
|
||||
let resolveCheck!: (v: boolean) => void
|
||||
mockCheckProfile.mockReturnValue(
|
||||
new Promise<boolean>((resolve) => {
|
||||
resolveCheck = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const publishBtn = wrapper.find('[data-testid="publish-btn"]')
|
||||
await publishBtn.trigger('click')
|
||||
await publishBtn.trigger('click')
|
||||
|
||||
resolveCheck(true)
|
||||
await flushPromises()
|
||||
|
||||
expect(mockCheckProfile).toHaveBeenCalledTimes(1)
|
||||
expect(onPublish).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handlePublish — feature flag bypass', () => {
|
||||
it('calls onPublish directly when profile gate is disabled', async () => {
|
||||
mockFlags.comfyHubProfileGateEnabled = false
|
||||
|
||||
const wrapper = createWrapper()
|
||||
await wrapper.find('[data-testid="publish-btn"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockCheckProfile).not.toHaveBeenCalled()
|
||||
expect(onPublish).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handlePublish — profile check routing', () => {
|
||||
it('calls onPublish when profile exists', async () => {
|
||||
mockCheckProfile.mockResolvedValue(true)
|
||||
|
||||
const wrapper = createWrapper()
|
||||
await wrapper.find('[data-testid="publish-btn"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockCheckProfile).toHaveBeenCalledOnce()
|
||||
expect(onPublish).toHaveBeenCalledOnce()
|
||||
expect(onRequireProfile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onRequireProfile when no profile exists', async () => {
|
||||
mockCheckProfile.mockResolvedValue(false)
|
||||
|
||||
const wrapper = createWrapper()
|
||||
await wrapper.find('[data-testid="publish-btn"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(onRequireProfile).toHaveBeenCalledOnce()
|
||||
expect(onPublish).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows toast and aborts when checkProfile throws', async () => {
|
||||
const error = new Error('Network error')
|
||||
mockCheckProfile.mockRejectedValue(error)
|
||||
|
||||
const wrapper = createWrapper()
|
||||
await wrapper.find('[data-testid="publish-btn"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockToastErrorHandler).toHaveBeenCalledWith(error)
|
||||
expect(onPublish).not.toHaveBeenCalled()
|
||||
expect(onRequireProfile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resets guard after checkProfile error so retry is possible', async () => {
|
||||
mockCheckProfile.mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
const wrapper = createWrapper()
|
||||
const publishBtn = wrapper.find('[data-testid="publish-btn"]')
|
||||
|
||||
await publishBtn.trigger('click')
|
||||
await flushPromises()
|
||||
expect(onPublish).not.toHaveBeenCalled()
|
||||
|
||||
mockCheckProfile.mockResolvedValue(true)
|
||||
await publishBtn.trigger('click')
|
||||
await flushPromises()
|
||||
expect(onPublish).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isPublishDisabled', () => {
|
||||
it('disables publish when gate enabled and hasProfile is not true', () => {
|
||||
mockHasProfile.value = null
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const footer = wrapper.find('[data-testid="publish-footer"]')
|
||||
expect(footer.attributes('data-publish-disabled')).toBe('true')
|
||||
})
|
||||
|
||||
it('enables publish when gate enabled and hasProfile is true', () => {
|
||||
mockHasProfile.value = true
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const footer = wrapper.find('[data-testid="publish-footer"]')
|
||||
expect(footer.attributes('data-publish-disabled')).toBe('false')
|
||||
})
|
||||
|
||||
it('enables publish when gate is disabled regardless of profile', () => {
|
||||
mockFlags.comfyHubProfileGateEnabled = false
|
||||
mockHasProfile.value = null
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const footer = wrapper.find('[data-testid="publish-footer"]')
|
||||
expect(footer.attributes('data-publish-disabled')).toBe('false')
|
||||
})
|
||||
})
|
||||
|
||||
describe('profileCreation step rendering', () => {
|
||||
it('shows profile creation form when on profileCreation step', () => {
|
||||
const wrapper = createWrapper({ currentStep: 'profileCreation' })
|
||||
expect(wrapper.find('[data-testid="publish-gate-flow"]').exists()).toBe(
|
||||
true
|
||||
)
|
||||
expect(wrapper.find('[data-testid="publish-footer"]').exists()).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('shows wizard content when not on profileCreation step', () => {
|
||||
const wrapper = createWrapper({ currentStep: 'finish' })
|
||||
expect(wrapper.find('[data-testid="publish-gate-flow"]').exists()).toBe(
|
||||
false
|
||||
)
|
||||
expect(wrapper.find('[data-testid="publish-footer"]').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-1 flex-col">
|
||||
<ComfyHubCreateProfileForm
|
||||
v-if="currentStep === 'profileCreation'"
|
||||
data-testid="publish-gate-flow"
|
||||
:on-profile-created="() => onGateComplete()"
|
||||
:on-close="onGateClose"
|
||||
:show-close-button="false"
|
||||
/>
|
||||
<div v-else class="flex min-h-0 flex-1 flex-col px-6 pb-2 pt-4">
|
||||
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||
<ComfyHubDescribeStep
|
||||
v-if="currentStep === 'describe'"
|
||||
:name="formData.name"
|
||||
:description="formData.description"
|
||||
:workflow-type="formData.workflowType"
|
||||
:tags="formData.tags"
|
||||
@update:name="onUpdateFormData({ name: $event })"
|
||||
@update:description="onUpdateFormData({ description: $event })"
|
||||
@update:workflow-type="onUpdateFormData({ workflowType: $event })"
|
||||
@update:tags="onUpdateFormData({ tags: $event })"
|
||||
/>
|
||||
<div
|
||||
v-else-if="currentStep === 'examples'"
|
||||
class="flex min-h-0 flex-1 flex-col gap-6 px-6 py-4"
|
||||
>
|
||||
<ComfyHubThumbnailStep
|
||||
:thumbnail-type="formData.thumbnailType"
|
||||
@update:thumbnail-type="onUpdateFormData({ thumbnailType: $event })"
|
||||
@update:thumbnail-file="onUpdateFormData({ thumbnailFile: $event })"
|
||||
@update:comparison-before-file="
|
||||
onUpdateFormData({ comparisonBeforeFile: $event })
|
||||
"
|
||||
@update:comparison-after-file="
|
||||
onUpdateFormData({ comparisonAfterFile: $event })
|
||||
"
|
||||
/>
|
||||
<ComfyHubExamplesStep
|
||||
:example-images="formData.exampleImages"
|
||||
:selected-example-ids="formData.selectedExampleIds"
|
||||
@update:example-images="onUpdateFormData({ exampleImages: $event })"
|
||||
@update:selected-example-ids="
|
||||
onUpdateFormData({ selectedExampleIds: $event })
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<ComfyHubProfilePromptPanel
|
||||
v-else-if="currentStep === 'finish'"
|
||||
@request-profile="onRequireProfile"
|
||||
/>
|
||||
</div>
|
||||
<ComfyHubPublishFooter
|
||||
:is-first-step
|
||||
:is-last-step
|
||||
:is-publish-disabled
|
||||
@back="onGoBack"
|
||||
@next="onGoNext"
|
||||
@publish="handlePublish"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useComfyHubProfileGate } from '@/platform/workflow/sharing/composables/useComfyHubProfileGate'
|
||||
import ComfyHubCreateProfileForm from '@/platform/workflow/sharing/components/profile/ComfyHubCreateProfileForm.vue'
|
||||
import type { ComfyHubPublishStep } from '@/platform/workflow/sharing/composables/useComfyHubPublishWizard'
|
||||
import type { ComfyHubPublishFormData } from '@/platform/workflow/sharing/types/comfyHubTypes'
|
||||
import ComfyHubDescribeStep from './ComfyHubDescribeStep.vue'
|
||||
import ComfyHubExamplesStep from './ComfyHubExamplesStep.vue'
|
||||
import ComfyHubProfilePromptPanel from './ComfyHubProfilePromptPanel.vue'
|
||||
import ComfyHubThumbnailStep from './ComfyHubThumbnailStep.vue'
|
||||
import ComfyHubPublishFooter from './ComfyHubPublishFooter.vue'
|
||||
|
||||
const {
|
||||
currentStep,
|
||||
formData,
|
||||
isFirstStep,
|
||||
isLastStep,
|
||||
onGoNext,
|
||||
onGoBack,
|
||||
onUpdateFormData,
|
||||
onPublish,
|
||||
onRequireProfile,
|
||||
onGateComplete = () => {},
|
||||
onGateClose = () => {}
|
||||
} = defineProps<{
|
||||
currentStep: ComfyHubPublishStep
|
||||
formData: ComfyHubPublishFormData
|
||||
isFirstStep: boolean
|
||||
isLastStep: boolean
|
||||
onGoNext: () => void
|
||||
onGoBack: () => void
|
||||
onUpdateFormData: (patch: Partial<ComfyHubPublishFormData>) => void
|
||||
onPublish: () => void
|
||||
onRequireProfile: () => void
|
||||
onGateComplete?: () => void
|
||||
onGateClose?: () => void
|
||||
}>()
|
||||
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
const { flags } = useFeatureFlags()
|
||||
const { checkProfile, hasProfile } = useComfyHubProfileGate()
|
||||
const isResolvingPublishAccess = ref(false)
|
||||
const isPublishDisabled = computed(
|
||||
() => flags.comfyHubProfileGateEnabled && hasProfile.value !== true
|
||||
)
|
||||
|
||||
async function handlePublish() {
|
||||
if (isResolvingPublishAccess.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!flags.comfyHubProfileGateEnabled) {
|
||||
onPublish()
|
||||
return
|
||||
}
|
||||
|
||||
isResolvingPublishAccess.value = true
|
||||
try {
|
||||
let profileExists: boolean
|
||||
try {
|
||||
profileExists = await checkProfile()
|
||||
} catch (error) {
|
||||
toastErrorHandler(error)
|
||||
return
|
||||
}
|
||||
|
||||
if (profileExists) {
|
||||
onPublish()
|
||||
return
|
||||
}
|
||||
|
||||
onRequireProfile()
|
||||
} finally {
|
||||
isResolvingPublishAccess.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,391 @@
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-6">
|
||||
<fieldset class="flex flex-col gap-2">
|
||||
<legend class="text-sm text-base-foreground">
|
||||
{{ $t('comfyHubPublish.selectAThumbnail') }}
|
||||
</legend>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
:model-value="thumbnailType"
|
||||
class="grid w-full grid-cols-3 gap-4"
|
||||
@update:model-value="handleThumbnailTypeChange"
|
||||
>
|
||||
<ToggleGroupItem
|
||||
v-for="option in thumbnailOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
class="h-auto w-full rounded-sm bg-node-component-surface p-2 data-[state=on]:bg-muted-background"
|
||||
>
|
||||
<span class="text-center text-sm font-bold text-base-foreground">
|
||||
{{ option.label }}
|
||||
</span>
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</fieldset>
|
||||
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ uploadSectionLabel }}
|
||||
</span>
|
||||
<Button
|
||||
v-if="hasThumbnailContent"
|
||||
variant="muted-textonly"
|
||||
size="sm"
|
||||
@click="clearAllPreviews"
|
||||
>
|
||||
{{ $t('g.clear') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<template v-if="thumbnailType === 'imageComparison'">
|
||||
<div
|
||||
class="flex-1 grid grid-cols-1 grid-rows-1 place-content-center-safe"
|
||||
>
|
||||
<div
|
||||
v-if="hasBothComparisonImages"
|
||||
ref="comparisonPreviewRef"
|
||||
class="relative col-span-full row-span-full cursor-crosshair overflow-hidden rounded-lg"
|
||||
>
|
||||
<img
|
||||
:src="comparisonPreviewUrls.after!"
|
||||
:alt="$t('comfyHubPublish.uploadComparisonAfterPrompt')"
|
||||
class="h-full w-full object-contain"
|
||||
/>
|
||||
<img
|
||||
:src="comparisonPreviewUrls.before!"
|
||||
:alt="$t('comfyHubPublish.uploadComparisonBeforePrompt')"
|
||||
class="absolute inset-0 h-full w-full object-contain"
|
||||
:style="{
|
||||
clipPath: `inset(0 ${100 - previewSliderPosition}% 0 0)`
|
||||
}"
|
||||
/>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-y-0 w-0.5 bg-white/30 backdrop-blur-sm"
|
||||
:style="{ left: `${previewSliderPosition}%` }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'col-span-full row-span-full flex gap-2',
|
||||
hasBothComparisonImages && 'invisible'
|
||||
)
|
||||
"
|
||||
>
|
||||
<label
|
||||
v-for="slot in comparisonSlots"
|
||||
:key="slot.key"
|
||||
:ref="(el) => (comparisonDropRefs[slot.key] = el as HTMLElement)"
|
||||
:class="
|
||||
cn(
|
||||
'flex max-w-1/2 cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed transition-colors',
|
||||
comparisonPreviewUrls[slot.key] ? 'self-start' : 'flex-1',
|
||||
comparisonOverStates[slot.key]
|
||||
? 'border-muted-foreground'
|
||||
: 'border-border-default hover:border-muted-foreground'
|
||||
)
|
||||
"
|
||||
@dragenter.stop
|
||||
@dragleave.stop
|
||||
@dragover.prevent.stop
|
||||
@drop.prevent.stop
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
@change="(e) => handleComparisonSelect(e, slot.key)"
|
||||
/>
|
||||
<template v-if="comparisonPreviewUrls[slot.key]">
|
||||
<img
|
||||
:src="comparisonPreviewUrls[slot.key]!"
|
||||
:alt="slot.label"
|
||||
class="max-h-full max-w-full object-contain"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="text-sm font-medium text-muted-foreground">
|
||||
{{ slot.label }}
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ $t('comfyHubPublish.uploadThumbnailHint') }}
|
||||
</span>
|
||||
</template>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<label
|
||||
ref="singleDropRef"
|
||||
:class="
|
||||
cn(
|
||||
'flex cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed transition-colors',
|
||||
thumbnailPreviewUrl ? 'self-center p-1' : 'flex-1',
|
||||
isOverSingleDrop
|
||||
? 'border-muted-foreground'
|
||||
: 'border-border-default hover:border-muted-foreground'
|
||||
)
|
||||
"
|
||||
@dragenter.stop
|
||||
@dragleave.stop
|
||||
@dragover.prevent.stop
|
||||
@drop.prevent.stop
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
:accept="
|
||||
thumbnailType === 'video'
|
||||
? 'video/*,image/gif,image/webp'
|
||||
: 'image/*'
|
||||
"
|
||||
class="hidden"
|
||||
@change="handleFileSelect"
|
||||
/>
|
||||
<template v-if="thumbnailPreviewUrl">
|
||||
<video
|
||||
v-if="isVideoFile"
|
||||
:src="thumbnailPreviewUrl"
|
||||
:aria-label="$t('comfyHubPublish.videoPreview')"
|
||||
class="max-h-full max-w-full object-contain"
|
||||
muted
|
||||
loop
|
||||
autoplay
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
:src="thumbnailPreviewUrl"
|
||||
:alt="$t('comfyHubPublish.thumbnailPreview')"
|
||||
class="max-h-full max-w-full object-contain"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ $t('comfyHubPublish.uploadPromptClickToBrowse') }}
|
||||
</span>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ uploadDropText }}
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ $t('comfyHubPublish.uploadThumbnailHint') }}
|
||||
</span>
|
||||
</template>
|
||||
</label>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
|
||||
import { useSliderFromMouse } from '@/platform/workflow/sharing/composables/useSliderFromMouse'
|
||||
import type { ThumbnailType } from '@/platform/workflow/sharing/types/comfyHubTypes'
|
||||
import {
|
||||
isFileTooLarge,
|
||||
MAX_IMAGE_SIZE_MB,
|
||||
MAX_VIDEO_SIZE_MB
|
||||
} from '@/platform/workflow/sharing/utils/validateFileSize'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { useDropZone, useObjectUrl } from '@vueuse/core'
|
||||
import { computed, reactive, ref, shallowRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { thumbnailType = 'image' } = defineProps<{
|
||||
thumbnailType?: ThumbnailType
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:thumbnailType': [value: ThumbnailType]
|
||||
'update:thumbnailFile': [value: File | null]
|
||||
'update:comparisonBeforeFile': [value: File | null]
|
||||
'update:comparisonAfterFile': [value: File | null]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
function isThumbnailType(value: string): value is ThumbnailType {
|
||||
return value === 'image' || value === 'video' || value === 'imageComparison'
|
||||
}
|
||||
|
||||
function handleThumbnailTypeChange(value: unknown) {
|
||||
if (typeof value === 'string' && isThumbnailType(value)) {
|
||||
comparisonBeforeFile.value = null
|
||||
comparisonAfterFile.value = null
|
||||
emit('update:thumbnailFile', null)
|
||||
emit('update:comparisonBeforeFile', null)
|
||||
emit('update:comparisonAfterFile', null)
|
||||
emit('update:thumbnailType', value)
|
||||
}
|
||||
}
|
||||
|
||||
const uploadSectionLabel = computed(() => {
|
||||
if (thumbnailType === 'video') return t('comfyHubPublish.uploadVideo')
|
||||
if (thumbnailType === 'imageComparison') {
|
||||
return t('comfyHubPublish.uploadComparison')
|
||||
}
|
||||
return t('comfyHubPublish.uploadThumbnail')
|
||||
})
|
||||
|
||||
const uploadDropText = computed(() =>
|
||||
thumbnailType === 'video'
|
||||
? t('comfyHubPublish.uploadPromptDropVideo')
|
||||
: t('comfyHubPublish.uploadPromptDropImage')
|
||||
)
|
||||
|
||||
const thumbnailOptions = [
|
||||
{
|
||||
value: 'image' as const,
|
||||
label: t('comfyHubPublish.thumbnailImage')
|
||||
},
|
||||
{
|
||||
value: 'video' as const,
|
||||
label: t('comfyHubPublish.thumbnailVideo')
|
||||
},
|
||||
{
|
||||
value: 'imageComparison' as const,
|
||||
label: t('comfyHubPublish.thumbnailImageComparison')
|
||||
}
|
||||
]
|
||||
|
||||
const thumbnailFile = shallowRef<File | null>(null)
|
||||
const thumbnailPreviewUrl = useObjectUrl(thumbnailFile)
|
||||
const isVideoFile = ref(false)
|
||||
|
||||
function setThumbnailPreview(file: File) {
|
||||
const maxSize = file.type.startsWith('video/')
|
||||
? MAX_VIDEO_SIZE_MB
|
||||
: MAX_IMAGE_SIZE_MB
|
||||
if (isFileTooLarge(file, maxSize)) return
|
||||
thumbnailFile.value = file
|
||||
isVideoFile.value = file.type.startsWith('video/')
|
||||
emit('update:thumbnailFile', file)
|
||||
}
|
||||
|
||||
const comparisonBeforeFile = shallowRef<File | null>(null)
|
||||
const comparisonAfterFile = shallowRef<File | null>(null)
|
||||
const comparisonPreviewUrls = reactive({
|
||||
before: useObjectUrl(comparisonBeforeFile),
|
||||
after: useObjectUrl(comparisonAfterFile)
|
||||
})
|
||||
|
||||
const hasBothComparisonImages = computed(
|
||||
() => !!(comparisonPreviewUrls.before && comparisonPreviewUrls.after)
|
||||
)
|
||||
|
||||
const comparisonPreviewRef = ref<HTMLElement | null>(null)
|
||||
const previewSliderPosition = useSliderFromMouse(comparisonPreviewRef)
|
||||
|
||||
const hasThumbnailContent = computed(() => {
|
||||
if (thumbnailType === 'imageComparison') {
|
||||
return !!(comparisonPreviewUrls.before || comparisonPreviewUrls.after)
|
||||
}
|
||||
return !!thumbnailPreviewUrl.value
|
||||
})
|
||||
|
||||
function clearAllPreviews() {
|
||||
if (thumbnailType === 'imageComparison') {
|
||||
comparisonBeforeFile.value = null
|
||||
comparisonAfterFile.value = null
|
||||
emit('update:comparisonBeforeFile', null)
|
||||
emit('update:comparisonAfterFile', null)
|
||||
return
|
||||
}
|
||||
|
||||
thumbnailFile.value = null
|
||||
emit('update:thumbnailFile', null)
|
||||
}
|
||||
|
||||
function handleFileSelect(event: Event) {
|
||||
if (!(event.target instanceof HTMLInputElement)) return
|
||||
const file = event.target.files?.[0]
|
||||
if (file) setThumbnailPreview(file)
|
||||
}
|
||||
|
||||
const singleDropRef = ref<HTMLElement | null>(null)
|
||||
|
||||
function isImageType(types: readonly string[]) {
|
||||
return types.some((type) => type.startsWith('image/'))
|
||||
}
|
||||
|
||||
function isVideoModeMedia(types: readonly string[]) {
|
||||
return types.some(
|
||||
(type) =>
|
||||
type.startsWith('video/') || type === 'image/gif' || type === 'image/webp'
|
||||
)
|
||||
}
|
||||
|
||||
const { isOverDropZone: isOverSingleDrop } = useDropZone(singleDropRef, {
|
||||
dataTypes: (types: readonly string[]) =>
|
||||
thumbnailType === 'video' ? isVideoModeMedia(types) : isImageType(types),
|
||||
multiple: false,
|
||||
onDrop(files) {
|
||||
const file = files?.[0]
|
||||
if (file) {
|
||||
setThumbnailPreview(file)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
type ComparisonSlot = 'before' | 'after'
|
||||
|
||||
const comparisonSlots = [
|
||||
{
|
||||
key: 'before' as const,
|
||||
label: t('comfyHubPublish.uploadComparisonBeforePrompt')
|
||||
},
|
||||
{
|
||||
key: 'after' as const,
|
||||
label: t('comfyHubPublish.uploadComparisonAfterPrompt')
|
||||
}
|
||||
]
|
||||
|
||||
const comparisonFiles: Record<ComparisonSlot, typeof comparisonBeforeFile> = {
|
||||
before: comparisonBeforeFile,
|
||||
after: comparisonAfterFile
|
||||
}
|
||||
|
||||
function setComparisonPreview(file: File, slot: ComparisonSlot) {
|
||||
if (isFileTooLarge(file, MAX_IMAGE_SIZE_MB)) return
|
||||
comparisonFiles[slot].value = file
|
||||
if (slot === 'before') {
|
||||
emit('update:comparisonBeforeFile', file)
|
||||
return
|
||||
}
|
||||
emit('update:comparisonAfterFile', file)
|
||||
}
|
||||
|
||||
function handleComparisonSelect(event: Event, slot: ComparisonSlot) {
|
||||
if (!(event.target instanceof HTMLInputElement)) return
|
||||
const file = event.target.files?.[0]
|
||||
if (file) setComparisonPreview(file, slot)
|
||||
}
|
||||
|
||||
const comparisonDropRefs = reactive<Record<ComparisonSlot, HTMLElement | null>>(
|
||||
{ before: null, after: null }
|
||||
)
|
||||
function useComparisonDropZone(slot: ComparisonSlot) {
|
||||
return useDropZone(
|
||||
computed(() => comparisonDropRefs[slot]),
|
||||
{
|
||||
dataTypes: isImageType,
|
||||
multiple: false,
|
||||
onDrop(files) {
|
||||
const file = files?.[0]
|
||||
if (file) setComparisonPreview(file, slot)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const { isOverDropZone: isOverBefore } = useComparisonDropZone('before')
|
||||
const { isOverDropZone: isOverAfter } = useComparisonDropZone('after')
|
||||
|
||||
const comparisonOverStates = computed(() => ({
|
||||
before: isOverBefore.value,
|
||||
after: isOverAfter.value
|
||||
}))
|
||||
</script>
|
||||
14
src/platform/workflow/sharing/composables/lazyShareDialog.ts
Normal file
14
src/platform/workflow/sharing/composables/lazyShareDialog.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export function prefetchShareDialog() {
|
||||
importShareDialog().catch((error) => {
|
||||
console.error(error)
|
||||
})
|
||||
}
|
||||
|
||||
export async function openShareDialog() {
|
||||
const { useShareDialog } = await importShareDialog()
|
||||
useShareDialog().show()
|
||||
}
|
||||
|
||||
function importShareDialog() {
|
||||
return import('@/platform/workflow/sharing/composables/useShareDialog')
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { partition } from 'es-toolkit'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import type { AssetInfo } from '@/schemas/apiSchema'
|
||||
|
||||
type SectionId = 'media' | 'models'
|
||||
|
||||
interface AssetSection {
|
||||
id: SectionId
|
||||
labelKey: string
|
||||
items: AssetInfo[]
|
||||
}
|
||||
|
||||
export function useAssetSections(items: () => AssetInfo[]) {
|
||||
const sections = computed(() => {
|
||||
const [models, media] = partition(items(), (a) => a.model)
|
||||
const allSections: AssetSection[] = [
|
||||
{
|
||||
id: 'media',
|
||||
labelKey: 'shareWorkflow.mediaLabel',
|
||||
items: media
|
||||
},
|
||||
{
|
||||
id: 'models',
|
||||
labelKey: 'shareWorkflow.modelsLabel',
|
||||
items: models
|
||||
}
|
||||
]
|
||||
return allSections.filter((s) => s.items.length > 0)
|
||||
})
|
||||
|
||||
const expandedSectionId = ref<SectionId | null>(null)
|
||||
|
||||
function getDefaultExpandedSection(
|
||||
availableSections: AssetSection[]
|
||||
): SectionId | null {
|
||||
if (availableSections.length === 0) return null
|
||||
return (
|
||||
availableSections.find((s) => s.id === 'media')?.id ??
|
||||
availableSections[0].id
|
||||
)
|
||||
}
|
||||
|
||||
watch(
|
||||
sections,
|
||||
(availableSections) => {
|
||||
const hasExpanded = availableSections.some(
|
||||
(s) => s.id === expandedSectionId.value
|
||||
)
|
||||
if (hasExpanded) return
|
||||
expandedSectionId.value = getDefaultExpandedSection(availableSections)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function onSectionOpenChange(sectionId: SectionId, open: boolean) {
|
||||
if (open) {
|
||||
expandedSectionId.value = sectionId
|
||||
return
|
||||
}
|
||||
if (expandedSectionId.value === sectionId) {
|
||||
expandedSectionId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sections,
|
||||
expandedSectionId,
|
||||
onSectionOpenChange
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ComfyHubProfile } from '@/schemas/apiSchema'
|
||||
|
||||
const mockFetchApi = vi.hoisted(() => vi.fn())
|
||||
const mockToastErrorHandler = vi.hoisted(() => vi.fn())
|
||||
const mockResolvedUserInfo = vi.hoisted(() => ({
|
||||
value: { id: 'user-a' }
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
fetchApi: mockFetchApi
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: () => ({
|
||||
resolvedUserInfo: mockResolvedUserInfo
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useErrorHandling', () => ({
|
||||
useErrorHandling: () => ({
|
||||
toastErrorHandler: mockToastErrorHandler
|
||||
})
|
||||
}))
|
||||
|
||||
// Must import after vi.mock declarations
|
||||
const { useComfyHubProfileGate } = await import('./useComfyHubProfileGate')
|
||||
|
||||
const mockProfile: ComfyHubProfile = {
|
||||
username: 'testuser',
|
||||
name: 'Test User',
|
||||
description: 'A test profile'
|
||||
}
|
||||
|
||||
function mockSuccessResponse(data?: unknown) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => data ?? mockProfile
|
||||
} as Response
|
||||
}
|
||||
|
||||
function mockErrorResponse(status = 500, message = 'Server error') {
|
||||
return {
|
||||
ok: false,
|
||||
status,
|
||||
json: async () => ({ message })
|
||||
} as Response
|
||||
}
|
||||
|
||||
describe('useComfyHubProfileGate', () => {
|
||||
let gate: ReturnType<typeof useComfyHubProfileGate>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockResolvedUserInfo.value = { id: 'user-a' }
|
||||
|
||||
// Reset module-level singleton refs
|
||||
gate = useComfyHubProfileGate()
|
||||
gate.hasProfile.value = null
|
||||
gate.profile.value = null
|
||||
gate.isCheckingProfile.value = false
|
||||
gate.isFetchingProfile.value = false
|
||||
})
|
||||
|
||||
describe('fetchProfile', () => {
|
||||
it('returns mapped profile when API responds ok', async () => {
|
||||
mockFetchApi.mockResolvedValue(mockSuccessResponse())
|
||||
|
||||
const profile = await gate.fetchProfile()
|
||||
|
||||
expect(profile).toEqual(mockProfile)
|
||||
expect(gate.hasProfile.value).toBe(true)
|
||||
expect(gate.profile.value).toEqual(mockProfile)
|
||||
expect(mockFetchApi).toHaveBeenCalledWith('/hub/profile')
|
||||
})
|
||||
|
||||
it('returns cached profile when already fetched', async () => {
|
||||
mockFetchApi.mockResolvedValue(mockSuccessResponse())
|
||||
|
||||
await gate.fetchProfile()
|
||||
const profile = await gate.fetchProfile()
|
||||
|
||||
expect(profile).toEqual(mockProfile)
|
||||
expect(mockFetchApi).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('re-fetches profile when force option is enabled', async () => {
|
||||
mockFetchApi.mockResolvedValue(mockSuccessResponse())
|
||||
|
||||
await gate.fetchProfile()
|
||||
await gate.fetchProfile({ force: true })
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('returns null when API responds with error', async () => {
|
||||
mockFetchApi.mockResolvedValue(mockErrorResponse(404))
|
||||
|
||||
const profile = await gate.fetchProfile()
|
||||
|
||||
expect(profile).toBeNull()
|
||||
expect(gate.hasProfile.value).toBe(false)
|
||||
expect(gate.profile.value).toBeNull()
|
||||
})
|
||||
|
||||
it('sets isFetchingProfile during fetch', async () => {
|
||||
let resolvePromise: (v: Response) => void
|
||||
mockFetchApi.mockReturnValue(
|
||||
new Promise<Response>((resolve) => {
|
||||
resolvePromise = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const promise = gate.fetchProfile()
|
||||
expect(gate.isFetchingProfile.value).toBe(true)
|
||||
|
||||
resolvePromise!(mockSuccessResponse())
|
||||
await promise
|
||||
|
||||
expect(gate.isFetchingProfile.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkProfile', () => {
|
||||
it('returns true when API responds ok', async () => {
|
||||
mockFetchApi.mockResolvedValue(mockSuccessResponse())
|
||||
|
||||
const result = await gate.checkProfile()
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(gate.hasProfile.value).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when API responds with error', async () => {
|
||||
mockFetchApi.mockResolvedValue(mockErrorResponse(404))
|
||||
|
||||
const result = await gate.checkProfile()
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(gate.hasProfile.value).toBe(false)
|
||||
})
|
||||
|
||||
it('returns cached value without re-fetching', async () => {
|
||||
mockFetchApi.mockResolvedValue(mockSuccessResponse())
|
||||
|
||||
await gate.checkProfile()
|
||||
const result = await gate.checkProfile()
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(mockFetchApi).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('clears cached profile state when the authenticated user changes', async () => {
|
||||
mockFetchApi.mockResolvedValue(mockSuccessResponse())
|
||||
|
||||
await gate.checkProfile()
|
||||
mockResolvedUserInfo.value = { id: 'user-b' }
|
||||
await gate.checkProfile()
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createProfile', () => {
|
||||
it('sends FormData with required username', async () => {
|
||||
mockFetchApi.mockResolvedValue(mockSuccessResponse())
|
||||
|
||||
await gate.createProfile({ username: 'testuser' })
|
||||
|
||||
const [url, options] = mockFetchApi.mock.calls[0]
|
||||
expect(url).toBe('/hub/profile')
|
||||
expect(options.method).toBe('POST')
|
||||
|
||||
const body = options.body as FormData
|
||||
expect(body.get('username')).toBe('testuser')
|
||||
})
|
||||
|
||||
it('includes optional fields when provided', async () => {
|
||||
mockFetchApi.mockResolvedValue(mockSuccessResponse())
|
||||
const coverImage = new File(['img'], 'cover.png')
|
||||
const profilePicture = new File(['img'], 'avatar.png')
|
||||
|
||||
await gate.createProfile({
|
||||
username: 'testuser',
|
||||
name: 'Test User',
|
||||
description: 'Hello',
|
||||
coverImage,
|
||||
profilePicture
|
||||
})
|
||||
|
||||
const body = mockFetchApi.mock.calls[0][1].body as FormData
|
||||
expect(body.get('name')).toBe('Test User')
|
||||
expect(body.get('description')).toBe('Hello')
|
||||
expect(body.get('cover_image')).toBe(coverImage)
|
||||
expect(body.get('profile_picture')).toBe(profilePicture)
|
||||
})
|
||||
|
||||
it('sets profile state on success', async () => {
|
||||
mockFetchApi.mockResolvedValue(mockSuccessResponse())
|
||||
|
||||
await gate.createProfile({ username: 'testuser' })
|
||||
|
||||
expect(gate.hasProfile.value).toBe(true)
|
||||
expect(gate.profile.value).toEqual(mockProfile)
|
||||
})
|
||||
|
||||
it('returns the created profile', async () => {
|
||||
mockFetchApi.mockResolvedValue(
|
||||
mockSuccessResponse({
|
||||
username: 'testuser',
|
||||
name: 'Test User',
|
||||
description: 'A test profile',
|
||||
cover_image_url: 'https://example.com/cover.png',
|
||||
profile_picture_url: 'https://example.com/profile.png'
|
||||
})
|
||||
)
|
||||
|
||||
const profile = await gate.createProfile({ username: 'testuser' })
|
||||
|
||||
expect(profile).toEqual({
|
||||
...mockProfile,
|
||||
coverImageUrl: 'https://example.com/cover.png',
|
||||
profilePictureUrl: 'https://example.com/profile.png'
|
||||
})
|
||||
})
|
||||
|
||||
it('throws with error message from API response', async () => {
|
||||
mockFetchApi.mockResolvedValue(mockErrorResponse(400, 'Username taken'))
|
||||
|
||||
await expect(gate.createProfile({ username: 'taken' })).rejects.toThrow(
|
||||
'Username taken'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,143 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { zHubProfileResponse } from '@/platform/workflow/sharing/schemas/shareSchemas'
|
||||
import type { ComfyHubProfile } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
// TODO: Migrate to a Pinia store for proper singleton state management
|
||||
// User-scoped, session-cached profile state (module-level singleton)
|
||||
const hasProfile = ref<boolean | null>(null)
|
||||
const isCheckingProfile = ref(false)
|
||||
const isFetchingProfile = ref(false)
|
||||
const profile = ref<ComfyHubProfile | null>(null)
|
||||
const cachedUserId = ref<string | null>(null)
|
||||
let inflightFetch: Promise<ComfyHubProfile | null> | null = null
|
||||
|
||||
function mapHubProfileResponse(payload: unknown): ComfyHubProfile | null {
|
||||
const result = zHubProfileResponse.safeParse(payload)
|
||||
return result.success ? result.data : null
|
||||
}
|
||||
|
||||
export function useComfyHubProfileGate() {
|
||||
const { resolvedUserInfo } = useCurrentUser()
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
|
||||
function syncCachedProfileWithCurrentUser(): void {
|
||||
const currentUserId = resolvedUserInfo.value?.id ?? null
|
||||
if (cachedUserId.value === currentUserId) {
|
||||
return
|
||||
}
|
||||
|
||||
hasProfile.value = null
|
||||
profile.value = null
|
||||
cachedUserId.value = currentUserId
|
||||
}
|
||||
|
||||
async function performFetch(): Promise<ComfyHubProfile | null> {
|
||||
isFetchingProfile.value = true
|
||||
try {
|
||||
const response = await api.fetchApi('/hub/profile')
|
||||
if (!response.ok) {
|
||||
hasProfile.value = false
|
||||
profile.value = null
|
||||
return null
|
||||
}
|
||||
|
||||
const nextProfile = mapHubProfileResponse(await response.json())
|
||||
if (!nextProfile) {
|
||||
hasProfile.value = false
|
||||
profile.value = null
|
||||
return null
|
||||
}
|
||||
hasProfile.value = true
|
||||
profile.value = nextProfile
|
||||
return nextProfile
|
||||
} catch (error) {
|
||||
toastErrorHandler(error)
|
||||
return null
|
||||
} finally {
|
||||
isFetchingProfile.value = false
|
||||
inflightFetch = null
|
||||
}
|
||||
}
|
||||
|
||||
function fetchProfile(options?: {
|
||||
force?: boolean
|
||||
}): Promise<ComfyHubProfile | null> {
|
||||
syncCachedProfileWithCurrentUser()
|
||||
|
||||
if (!options?.force && profile.value) {
|
||||
return Promise.resolve(profile.value)
|
||||
}
|
||||
|
||||
if (!options?.force && inflightFetch) return inflightFetch
|
||||
|
||||
inflightFetch = performFetch()
|
||||
return inflightFetch
|
||||
}
|
||||
|
||||
async function checkProfile(): Promise<boolean> {
|
||||
syncCachedProfileWithCurrentUser()
|
||||
|
||||
if (hasProfile.value !== null) return hasProfile.value
|
||||
isCheckingProfile.value = true
|
||||
try {
|
||||
const fetchedProfile = await fetchProfile()
|
||||
return fetchedProfile !== null
|
||||
} finally {
|
||||
isCheckingProfile.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createProfile(data: {
|
||||
username: string
|
||||
name?: string
|
||||
description?: string
|
||||
coverImage?: File
|
||||
profilePicture?: File
|
||||
}): Promise<ComfyHubProfile> {
|
||||
syncCachedProfileWithCurrentUser()
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('username', data.username)
|
||||
if (data.name) formData.append('name', data.name)
|
||||
if (data.description) formData.append('description', data.description)
|
||||
if (data.coverImage) formData.append('cover_image', data.coverImage)
|
||||
if (data.profilePicture)
|
||||
formData.append('profile_picture', data.profilePicture)
|
||||
|
||||
const response = await api.fetchApi('/hub/profile', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const body: unknown = await response.json().catch(() => ({}))
|
||||
const message =
|
||||
body && typeof body === 'object' && 'message' in body
|
||||
? String((body as Record<string, unknown>).message)
|
||||
: 'Failed to create profile'
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
const createdProfile = mapHubProfileResponse(await response.json())
|
||||
if (!createdProfile) {
|
||||
throw new Error('Invalid profile response from server')
|
||||
}
|
||||
hasProfile.value = true
|
||||
profile.value = createdProfile
|
||||
return createdProfile
|
||||
}
|
||||
|
||||
return {
|
||||
hasProfile,
|
||||
profile,
|
||||
isCheckingProfile,
|
||||
isFetchingProfile,
|
||||
checkProfile,
|
||||
fetchProfile,
|
||||
createProfile
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import ComfyHubPublishDialog from '@/platform/workflow/sharing/components/publish/ComfyHubPublishDialog.vue'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const DIALOG_KEY = 'global-comfyhub-publish'
|
||||
|
||||
export function useComfyHubPublishDialog() {
|
||||
const dialogService = useDialogService()
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
function hide() {
|
||||
dialogStore.closeDialog({ key: DIALOG_KEY })
|
||||
}
|
||||
|
||||
function show() {
|
||||
dialogService.showLayoutDialog({
|
||||
key: DIALOG_KEY,
|
||||
component: ComfyHubPublishDialog,
|
||||
props: {
|
||||
onClose: hide
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
show,
|
||||
hide
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockActiveWorkflow = vi.hoisted(() => ({
|
||||
value: { filename: 'my-workflow.json' } as { filename: string } | null
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
get activeWorkflow() {
|
||||
return mockActiveWorkflow.value
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
const { useComfyHubPublishWizard } = await import('./useComfyHubPublishWizard')
|
||||
|
||||
describe('useComfyHubPublishWizard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockActiveWorkflow.value = { filename: 'my-workflow.json' }
|
||||
})
|
||||
|
||||
describe('createDefaultFormData', () => {
|
||||
it('initialises name from active workflow filename', () => {
|
||||
const { formData } = useComfyHubPublishWizard()
|
||||
expect(formData.value.name).toBe('my-workflow.json')
|
||||
})
|
||||
|
||||
it('defaults name to empty string when no active workflow', () => {
|
||||
mockActiveWorkflow.value = null
|
||||
const { formData } = useComfyHubPublishWizard()
|
||||
expect(formData.value.name).toBe('')
|
||||
})
|
||||
|
||||
it('initialises all other form fields to defaults', () => {
|
||||
const { formData } = useComfyHubPublishWizard()
|
||||
expect(formData.value.description).toBe('')
|
||||
expect(formData.value.workflowType).toBe('')
|
||||
expect(formData.value.tags).toEqual([])
|
||||
expect(formData.value.thumbnailType).toBe('image')
|
||||
expect(formData.value.thumbnailFile).toBeNull()
|
||||
expect(formData.value.comparisonBeforeFile).toBeNull()
|
||||
expect(formData.value.comparisonAfterFile).toBeNull()
|
||||
expect(formData.value.exampleImages).toEqual([])
|
||||
expect(formData.value.selectedExampleIds).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('canGoNext', () => {
|
||||
it('returns false on describe step when name is empty', () => {
|
||||
const { canGoNext, formData } = useComfyHubPublishWizard()
|
||||
formData.value.name = ''
|
||||
expect(canGoNext.value).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false on describe step when name is whitespace only', () => {
|
||||
const { canGoNext, formData } = useComfyHubPublishWizard()
|
||||
formData.value.name = ' '
|
||||
expect(canGoNext.value).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true on describe step when name has content', () => {
|
||||
const { canGoNext, formData } = useComfyHubPublishWizard()
|
||||
formData.value.name = 'Valid Name'
|
||||
expect(canGoNext.value).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true on non-describe steps regardless of name', () => {
|
||||
const { canGoNext, goNext, formData } = useComfyHubPublishWizard()
|
||||
formData.value.name = 'something'
|
||||
goNext()
|
||||
formData.value.name = ''
|
||||
expect(canGoNext.value).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('step navigation', () => {
|
||||
it('starts on the describe step', () => {
|
||||
const { currentStep, isFirstStep } = useComfyHubPublishWizard()
|
||||
expect(currentStep.value).toBe('describe')
|
||||
expect(isFirstStep.value).toBe(true)
|
||||
})
|
||||
|
||||
it('navigates forward through steps', () => {
|
||||
const { currentStep, goNext } = useComfyHubPublishWizard()
|
||||
expect(currentStep.value).toBe('describe')
|
||||
|
||||
goNext()
|
||||
expect(currentStep.value).toBe('examples')
|
||||
|
||||
goNext()
|
||||
expect(currentStep.value).toBe('finish')
|
||||
})
|
||||
|
||||
it('navigates backward through steps', () => {
|
||||
const { currentStep, goNext, goBack } = useComfyHubPublishWizard()
|
||||
goNext()
|
||||
goNext()
|
||||
expect(currentStep.value).toBe('finish')
|
||||
|
||||
goBack()
|
||||
expect(currentStep.value).toBe('examples')
|
||||
|
||||
goBack()
|
||||
expect(currentStep.value).toBe('describe')
|
||||
})
|
||||
|
||||
it('reports isLastStep correctly on finish step', () => {
|
||||
const { isLastStep, goNext } = useComfyHubPublishWizard()
|
||||
expect(isLastStep.value).toBe(false)
|
||||
|
||||
goNext()
|
||||
expect(isLastStep.value).toBe(false)
|
||||
|
||||
goNext()
|
||||
expect(isLastStep.value).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('profile creation step', () => {
|
||||
it('navigates to profileCreation step', () => {
|
||||
const { currentStep, openProfileCreationStep } =
|
||||
useComfyHubPublishWizard()
|
||||
openProfileCreationStep()
|
||||
expect(currentStep.value).toBe('profileCreation')
|
||||
})
|
||||
|
||||
it('reports isProfileCreationStep correctly', () => {
|
||||
const { isProfileCreationStep, openProfileCreationStep } =
|
||||
useComfyHubPublishWizard()
|
||||
expect(isProfileCreationStep.value).toBe(false)
|
||||
|
||||
openProfileCreationStep()
|
||||
expect(isProfileCreationStep.value).toBe(true)
|
||||
})
|
||||
|
||||
it('returns to finish step from profileCreation', () => {
|
||||
const { currentStep, openProfileCreationStep, closeProfileCreationStep } =
|
||||
useComfyHubPublishWizard()
|
||||
openProfileCreationStep()
|
||||
expect(currentStep.value).toBe('profileCreation')
|
||||
|
||||
closeProfileCreationStep()
|
||||
expect(currentStep.value).toBe('finish')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useStepper } from '@vueuse/core'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { ComfyHubPublishFormData } from '@/platform/workflow/sharing/types/comfyHubTypes'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
|
||||
const PUBLISH_STEPS = [
|
||||
'describe',
|
||||
'examples',
|
||||
'finish',
|
||||
'profileCreation'
|
||||
] as const
|
||||
|
||||
export type ComfyHubPublishStep = (typeof PUBLISH_STEPS)[number]
|
||||
|
||||
function createDefaultFormData(): ComfyHubPublishFormData {
|
||||
const { activeWorkflow } = useWorkflowStore()
|
||||
return {
|
||||
name: activeWorkflow?.filename ?? '',
|
||||
description: '',
|
||||
workflowType: '',
|
||||
tags: [],
|
||||
thumbnailType: 'image',
|
||||
thumbnailFile: null,
|
||||
comparisonBeforeFile: null,
|
||||
comparisonAfterFile: null,
|
||||
exampleImages: [],
|
||||
selectedExampleIds: []
|
||||
}
|
||||
}
|
||||
|
||||
export function useComfyHubPublishWizard() {
|
||||
const stepper = useStepper([...PUBLISH_STEPS])
|
||||
const formData = ref<ComfyHubPublishFormData>(createDefaultFormData())
|
||||
|
||||
const canGoNext = computed(() => {
|
||||
if (stepper.isCurrent('describe')) {
|
||||
return formData.value.name.trim().length > 0
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const isLastStep = computed(() => stepper.isCurrent('finish'))
|
||||
const isProfileCreationStep = computed(() =>
|
||||
stepper.isCurrent('profileCreation')
|
||||
)
|
||||
|
||||
function openProfileCreationStep() {
|
||||
stepper.goTo('profileCreation')
|
||||
}
|
||||
|
||||
function closeProfileCreationStep() {
|
||||
stepper.goTo('finish')
|
||||
}
|
||||
|
||||
return {
|
||||
currentStep: stepper.current,
|
||||
formData,
|
||||
canGoNext,
|
||||
isFirstStep: stepper.isFirst,
|
||||
isLastStep,
|
||||
isProfileCreationStep,
|
||||
goToStep: stepper.goTo,
|
||||
goNext: stepper.goToNext,
|
||||
goBack: stepper.goToPrevious,
|
||||
openProfileCreationStep,
|
||||
closeProfileCreationStep
|
||||
}
|
||||
}
|
||||
36
src/platform/workflow/sharing/composables/useShareDialog.ts
Normal file
36
src/platform/workflow/sharing/composables/useShareDialog.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import ShareWorkflowDialogContent from '@/platform/workflow/sharing/components/ShareWorkflowDialogContent.vue'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const DIALOG_KEY = 'global-share-workflow'
|
||||
|
||||
export function useShareDialog() {
|
||||
const dialogService = useDialogService()
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
function hide() {
|
||||
dialogStore.closeDialog({ key: DIALOG_KEY })
|
||||
}
|
||||
|
||||
function show() {
|
||||
dialogService.showLayoutDialog({
|
||||
key: DIALOG_KEY,
|
||||
component: ShareWorkflowDialogContent,
|
||||
props: {
|
||||
onClose: hide
|
||||
},
|
||||
dialogComponentProps: {
|
||||
pt: {
|
||||
root: {
|
||||
class: 'rounded-2xl overflow-hidden w-full sm:w-144 max-w-full'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
show,
|
||||
hide
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { SharedWorkflowPayload } from '@/platform/workflow/sharing/types/shareTypes'
|
||||
import { useSharedWorkflowUrlLoader } from '@/platform/workflow/sharing/composables/useSharedWorkflowUrlLoader'
|
||||
|
||||
const preservedQueryMocks = vi.hoisted(() => ({
|
||||
clearPreservedQuery: vi.fn(),
|
||||
hydratePreservedQuery: vi.fn(),
|
||||
mergePreservedQueryIntoQuery: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/platform/navigation/preservedQueryManager',
|
||||
() => preservedQueryMocks
|
||||
)
|
||||
|
||||
let mockQueryParams: Record<string, string | string[] | undefined> = {}
|
||||
const mockRouterReplace = vi.fn()
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: vi.fn(() => ({
|
||||
query: mockQueryParams
|
||||
})),
|
||||
useRouter: vi.fn(() => ({
|
||||
replace: mockRouterReplace
|
||||
}))
|
||||
}))
|
||||
|
||||
const mockImportPublishedAssets = vi.fn()
|
||||
|
||||
vi.mock('@/platform/workflow/sharing/services/workflowShareService', () => ({
|
||||
SharedWorkflowLoadError: class extends Error {
|
||||
readonly isRetryable: boolean
|
||||
constructor(message: string, isRetryable: boolean) {
|
||||
super(message)
|
||||
this.name = 'SharedWorkflowLoadError'
|
||||
this.isRetryable = isRetryable
|
||||
}
|
||||
},
|
||||
useWorkflowShareService: () => ({
|
||||
getSharedWorkflow: vi.fn(),
|
||||
importPublishedAssets: mockImportPublishedAssets
|
||||
})
|
||||
}))
|
||||
|
||||
const mockLoadGraphData = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
loadGraphData: mockLoadGraphData
|
||||
}
|
||||
}))
|
||||
|
||||
const mockToastAdd = vi.fn()
|
||||
vi.mock('primevue/usetoast', () => ({
|
||||
useToast: () => ({
|
||||
add: mockToastAdd
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: vi.fn((key: string) => {
|
||||
if (key === 'g.error') return 'Error'
|
||||
if (key === 'shareWorkflow.loadFailed') {
|
||||
return 'Failed to load shared workflow'
|
||||
}
|
||||
if (key === 'openSharedWorkflow.dialogTitle') {
|
||||
return 'Open shared workflow'
|
||||
}
|
||||
if (key === 'openSharedWorkflow.importFailed') {
|
||||
return 'Failed to import workflow assets'
|
||||
}
|
||||
return key
|
||||
})
|
||||
})
|
||||
}))
|
||||
|
||||
const mockShowLayoutDialog = vi.hoisted(() => vi.fn())
|
||||
const mockCloseDialog = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: () => ({
|
||||
showLayoutDialog: mockShowLayoutDialog
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => ({
|
||||
closeDialog: mockCloseDialog
|
||||
})
|
||||
}))
|
||||
|
||||
function makePayload(
|
||||
overrides: Partial<SharedWorkflowPayload> = {}
|
||||
): SharedWorkflowPayload {
|
||||
return {
|
||||
shareId: 'share-id-1',
|
||||
workflowId: 'workflow-id-1',
|
||||
name: 'Test Workflow',
|
||||
listed: true,
|
||||
publishedAt: new Date('2026-02-20T00:00:00Z'),
|
||||
workflowJson: {
|
||||
nodes: []
|
||||
} as unknown as SharedWorkflowPayload['workflowJson'],
|
||||
assets: [],
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDialogWithConfirm(payload: SharedWorkflowPayload) {
|
||||
const call = mockShowLayoutDialog.mock.calls.at(-1)
|
||||
if (!call) throw new Error('showLayoutDialog was not called')
|
||||
const options = call[0]
|
||||
options.props.onConfirm(payload)
|
||||
}
|
||||
|
||||
function resolveDialogWithOpenOnly(payload: SharedWorkflowPayload) {
|
||||
const call = mockShowLayoutDialog.mock.calls.at(-1)
|
||||
if (!call) throw new Error('showLayoutDialog was not called')
|
||||
const options = call[0]
|
||||
options.props.onOpenWithoutImporting(payload)
|
||||
}
|
||||
|
||||
function resolveDialogWithCancel() {
|
||||
const call = mockShowLayoutDialog.mock.calls.at(-1)
|
||||
if (!call) throw new Error('showLayoutDialog was not called')
|
||||
const options = call[0]
|
||||
options.props.onCancel()
|
||||
}
|
||||
|
||||
describe('useSharedWorkflowUrlLoader', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockQueryParams = {}
|
||||
preservedQueryMocks.mergePreservedQueryIntoQuery.mockReturnValue(null)
|
||||
})
|
||||
|
||||
it('does nothing when no share query param is present', async () => {
|
||||
const { loadSharedWorkflowFromUrl } = useSharedWorkflowUrlLoader()
|
||||
|
||||
const loaded = await loadSharedWorkflowFromUrl()
|
||||
|
||||
expect(loaded).toBe('not-present')
|
||||
expect(mockShowLayoutDialog).not.toHaveBeenCalled()
|
||||
expect(mockLoadGraphData).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens dialog immediately with shareId and loads graph on confirm', async () => {
|
||||
mockQueryParams = { share: 'share-id-1' }
|
||||
const payload = makePayload()
|
||||
mockShowLayoutDialog.mockImplementation(() => {
|
||||
expect(mockLoadGraphData).not.toHaveBeenCalled()
|
||||
resolveDialogWithConfirm(payload)
|
||||
})
|
||||
|
||||
const { loadSharedWorkflowFromUrl } = useSharedWorkflowUrlLoader()
|
||||
const loaded = await loadSharedWorkflowFromUrl()
|
||||
|
||||
expect(loaded).toBe('loaded')
|
||||
const dialogCall = mockShowLayoutDialog.mock.calls[0][0]
|
||||
expect(dialogCall.props.shareId).toBe('share-id-1')
|
||||
expect(mockLoadGraphData).toHaveBeenCalledWith(
|
||||
{ nodes: [] },
|
||||
true,
|
||||
true,
|
||||
'Test Workflow'
|
||||
)
|
||||
expect(mockRouterReplace).toHaveBeenCalledWith({ query: {} })
|
||||
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
|
||||
'share'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not load graph when user cancels dialog', async () => {
|
||||
mockQueryParams = { share: 'share-id-1' }
|
||||
mockShowLayoutDialog.mockImplementation(() => {
|
||||
resolveDialogWithCancel()
|
||||
})
|
||||
|
||||
const { loadSharedWorkflowFromUrl } = useSharedWorkflowUrlLoader()
|
||||
const loaded = await loadSharedWorkflowFromUrl()
|
||||
|
||||
expect(loaded).toBe('cancelled')
|
||||
expect(mockLoadGraphData).not.toHaveBeenCalled()
|
||||
expect(mockRouterReplace).toHaveBeenCalledWith({ query: {} })
|
||||
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
|
||||
'share'
|
||||
)
|
||||
})
|
||||
|
||||
it('calls import when non-owned assets exist and user confirms', async () => {
|
||||
mockQueryParams = { share: 'share-id-1' }
|
||||
const payload = makePayload({
|
||||
assets: [
|
||||
{
|
||||
id: 'a1',
|
||||
name: 'img.png',
|
||||
preview_url: '',
|
||||
storage_url: '',
|
||||
model: false,
|
||||
public: false,
|
||||
in_library: false
|
||||
}
|
||||
]
|
||||
})
|
||||
mockShowLayoutDialog.mockImplementation(() => {
|
||||
resolveDialogWithConfirm(payload)
|
||||
})
|
||||
|
||||
const { loadSharedWorkflowFromUrl } = useSharedWorkflowUrlLoader()
|
||||
await loadSharedWorkflowFromUrl()
|
||||
|
||||
expect(mockImportPublishedAssets).toHaveBeenCalledWith(['a1'])
|
||||
})
|
||||
|
||||
it('does not call import when user chooses open-only', async () => {
|
||||
mockQueryParams = { share: 'share-id-1' }
|
||||
const payload = makePayload({
|
||||
assets: [
|
||||
{
|
||||
id: 'a1',
|
||||
name: 'img.png',
|
||||
preview_url: '',
|
||||
storage_url: '',
|
||||
model: false,
|
||||
public: false,
|
||||
in_library: false
|
||||
}
|
||||
]
|
||||
})
|
||||
mockShowLayoutDialog.mockImplementation(() => {
|
||||
resolveDialogWithOpenOnly(payload)
|
||||
})
|
||||
|
||||
const { loadSharedWorkflowFromUrl } = useSharedWorkflowUrlLoader()
|
||||
await loadSharedWorkflowFromUrl()
|
||||
|
||||
expect(mockLoadGraphData).toHaveBeenCalled()
|
||||
expect(mockImportPublishedAssets).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows toast on import failure and returns loaded-without-assets', async () => {
|
||||
mockQueryParams = { share: 'share-id-1' }
|
||||
const payload = makePayload({
|
||||
assets: [
|
||||
{
|
||||
id: 'm1',
|
||||
name: 'model.safetensors',
|
||||
preview_url: '',
|
||||
storage_url: '',
|
||||
model: true,
|
||||
public: false,
|
||||
in_library: false
|
||||
}
|
||||
]
|
||||
})
|
||||
mockImportPublishedAssets.mockRejectedValue(new Error('Import failed'))
|
||||
mockShowLayoutDialog.mockImplementation(() => {
|
||||
resolveDialogWithConfirm(payload)
|
||||
})
|
||||
|
||||
const { loadSharedWorkflowFromUrl } = useSharedWorkflowUrlLoader()
|
||||
const loaded = await loadSharedWorkflowFromUrl()
|
||||
|
||||
expect(loaded).toBe('loaded-without-assets')
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
detail: 'Failed to import workflow assets'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('filters out in_library assets before importing', async () => {
|
||||
mockQueryParams = { share: 'share-id-1' }
|
||||
const payload = makePayload({
|
||||
assets: [
|
||||
{
|
||||
id: 'a1',
|
||||
name: 'needed.png',
|
||||
preview_url: '',
|
||||
storage_url: '',
|
||||
model: false,
|
||||
public: false,
|
||||
in_library: false
|
||||
},
|
||||
{
|
||||
id: 'a2',
|
||||
name: 'already-have.png',
|
||||
preview_url: '',
|
||||
storage_url: '',
|
||||
model: false,
|
||||
public: false,
|
||||
in_library: true
|
||||
}
|
||||
]
|
||||
})
|
||||
mockShowLayoutDialog.mockImplementation(() => {
|
||||
resolveDialogWithConfirm(payload)
|
||||
})
|
||||
|
||||
const { loadSharedWorkflowFromUrl } = useSharedWorkflowUrlLoader()
|
||||
await loadSharedWorkflowFromUrl()
|
||||
|
||||
expect(mockImportPublishedAssets).toHaveBeenCalledWith(['a1'])
|
||||
})
|
||||
|
||||
it('restores preserved share query before loading', async () => {
|
||||
preservedQueryMocks.mergePreservedQueryIntoQuery.mockReturnValue({
|
||||
share: 'preserved-share-id'
|
||||
})
|
||||
mockShowLayoutDialog.mockImplementation(() => {
|
||||
resolveDialogWithConfirm(makePayload({ shareId: 'preserved-share-id' }))
|
||||
})
|
||||
|
||||
const { loadSharedWorkflowFromUrl } = useSharedWorkflowUrlLoader()
|
||||
await loadSharedWorkflowFromUrl()
|
||||
|
||||
expect(preservedQueryMocks.hydratePreservedQuery).toHaveBeenCalledWith(
|
||||
'share'
|
||||
)
|
||||
expect(mockRouterReplace).toHaveBeenCalledWith({
|
||||
query: { share: 'preserved-share-id' }
|
||||
})
|
||||
const dialogCall = mockShowLayoutDialog.mock.calls[0][0]
|
||||
expect(dialogCall.props.shareId).toBe('preserved-share-id')
|
||||
})
|
||||
|
||||
it('rejects invalid share parameter values', async () => {
|
||||
mockQueryParams = { share: '../../../etc/passwd' }
|
||||
|
||||
const { loadSharedWorkflowFromUrl } = useSharedWorkflowUrlLoader()
|
||||
const loaded = await loadSharedWorkflowFromUrl()
|
||||
|
||||
expect(loaded).toBe('failed')
|
||||
expect(mockShowLayoutDialog).not.toHaveBeenCalled()
|
||||
expect(mockToastAdd).toHaveBeenCalledWith({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: 'Failed to load shared workflow',
|
||||
life: 3000
|
||||
})
|
||||
expect(mockRouterReplace).toHaveBeenCalledWith({ query: {} })
|
||||
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
|
||||
'share'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses fallback name when payload name is empty', async () => {
|
||||
mockQueryParams = { share: 'share-id-1' }
|
||||
const payload = makePayload({ name: '' })
|
||||
mockShowLayoutDialog.mockImplementation(() => {
|
||||
resolveDialogWithConfirm(payload)
|
||||
})
|
||||
|
||||
const { loadSharedWorkflowFromUrl } = useSharedWorkflowUrlLoader()
|
||||
await loadSharedWorkflowFromUrl()
|
||||
|
||||
expect(mockLoadGraphData).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
true,
|
||||
true,
|
||||
'Open shared workflow'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,186 @@
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import OpenSharedWorkflowDialogContent from '@/platform/workflow/sharing/components/OpenSharedWorkflowDialogContent.vue'
|
||||
import type { SharedWorkflowPayload } from '@/platform/workflow/sharing/types/shareTypes'
|
||||
import {
|
||||
clearPreservedQuery,
|
||||
hydratePreservedQuery,
|
||||
mergePreservedQueryIntoQuery
|
||||
} from '@/platform/navigation/preservedQueryManager'
|
||||
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
|
||||
import { useWorkflowShareService } from '@/platform/workflow/sharing/services/workflowShareService'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
type SharedWorkflowUrlLoadStatus =
|
||||
| 'not-present'
|
||||
| 'loaded'
|
||||
| 'loaded-without-assets'
|
||||
| 'cancelled'
|
||||
| 'failed'
|
||||
|
||||
type DialogResult =
|
||||
| { action: 'copy-and-open'; payload: SharedWorkflowPayload }
|
||||
| { action: 'open-only'; payload: SharedWorkflowPayload }
|
||||
| { action: 'cancel' }
|
||||
|
||||
export function useSharedWorkflowUrlLoader() {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
const workflowShareService = useWorkflowShareService()
|
||||
const dialogService = useDialogService()
|
||||
const dialogStore = useDialogStore()
|
||||
const SHARE_NAMESPACE = PRESERVED_QUERY_NAMESPACES.SHARE
|
||||
|
||||
function isValidParameter(param: string): boolean {
|
||||
return /^[a-zA-Z0-9_.-]+$/.test(param)
|
||||
}
|
||||
|
||||
async function ensureShareQueryFromIntent() {
|
||||
hydratePreservedQuery(SHARE_NAMESPACE)
|
||||
const mergedQuery = mergePreservedQueryIntoQuery(
|
||||
SHARE_NAMESPACE,
|
||||
route.query
|
||||
)
|
||||
|
||||
if (mergedQuery) {
|
||||
await router.replace({ query: mergedQuery })
|
||||
}
|
||||
|
||||
return mergedQuery ?? route.query
|
||||
}
|
||||
|
||||
function cleanupUrlParams() {
|
||||
const newQuery = { ...route.query }
|
||||
delete newQuery.share
|
||||
void router.replace({ query: newQuery })
|
||||
}
|
||||
|
||||
function showOpenSharedWorkflowDialog(
|
||||
shareId: string
|
||||
): Promise<DialogResult> {
|
||||
const dialogKey = 'open-shared-workflow'
|
||||
|
||||
return new Promise<DialogResult>((resolve) => {
|
||||
dialogService.showLayoutDialog({
|
||||
key: dialogKey,
|
||||
component: OpenSharedWorkflowDialogContent,
|
||||
props: {
|
||||
shareId,
|
||||
onConfirm: (payload: SharedWorkflowPayload) => {
|
||||
resolve({ action: 'copy-and-open', payload })
|
||||
dialogStore.closeDialog({ key: dialogKey })
|
||||
},
|
||||
onOpenWithoutImporting: (payload: SharedWorkflowPayload) => {
|
||||
resolve({ action: 'open-only', payload })
|
||||
dialogStore.closeDialog({ key: dialogKey })
|
||||
},
|
||||
onCancel: () => {
|
||||
resolve({ action: 'cancel' })
|
||||
dialogStore.closeDialog({ key: dialogKey })
|
||||
}
|
||||
},
|
||||
dialogComponentProps: {
|
||||
onClose: () => resolve({ action: 'cancel' }),
|
||||
pt: {
|
||||
root: {
|
||||
class: 'rounded-2xl overflow-hidden w-full sm:w-176 max-w-full'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function loadSharedWorkflowFromUrl(): Promise<SharedWorkflowUrlLoadStatus> {
|
||||
const query = await ensureShareQueryFromIntent()
|
||||
const shareParam = query.share
|
||||
|
||||
if (shareParam == null) {
|
||||
return 'not-present'
|
||||
}
|
||||
|
||||
if (typeof shareParam !== 'string') {
|
||||
cleanupUrlParams()
|
||||
clearPreservedQuery(SHARE_NAMESPACE)
|
||||
return 'not-present'
|
||||
}
|
||||
|
||||
if (!isValidParameter(shareParam)) {
|
||||
console.warn(
|
||||
`[useSharedWorkflowUrlLoader] Invalid share parameter format: ${shareParam}`
|
||||
)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('shareWorkflow.loadFailed'),
|
||||
life: 3000
|
||||
})
|
||||
cleanupUrlParams()
|
||||
clearPreservedQuery(SHARE_NAMESPACE)
|
||||
return 'failed'
|
||||
}
|
||||
|
||||
const result = await showOpenSharedWorkflowDialog(shareParam)
|
||||
|
||||
if (result.action === 'cancel') {
|
||||
cleanupUrlParams()
|
||||
clearPreservedQuery(SHARE_NAMESPACE)
|
||||
return 'cancelled'
|
||||
}
|
||||
|
||||
const { payload } = result
|
||||
const workflowName = payload.name || t('openSharedWorkflow.dialogTitle')
|
||||
const nonOwnedAssets = payload.assets.filter((a) => !a.in_library)
|
||||
|
||||
try {
|
||||
await app.loadGraphData(payload.workflowJson, true, true, workflowName)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[useSharedWorkflowUrlLoader] Failed to load workflow graph:',
|
||||
error
|
||||
)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('shareWorkflow.loadFailed'),
|
||||
life: 5000
|
||||
})
|
||||
return 'failed'
|
||||
}
|
||||
|
||||
if (result.action === 'copy-and-open' && nonOwnedAssets.length > 0) {
|
||||
try {
|
||||
await workflowShareService.importPublishedAssets(
|
||||
nonOwnedAssets.map((a) => a.id)
|
||||
)
|
||||
} catch (importError) {
|
||||
console.error(
|
||||
'[useSharedWorkflowUrlLoader] Failed to import assets:',
|
||||
importError
|
||||
)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('openSharedWorkflow.importFailed')
|
||||
})
|
||||
cleanupUrlParams()
|
||||
clearPreservedQuery(SHARE_NAMESPACE)
|
||||
return 'loaded-without-assets'
|
||||
}
|
||||
}
|
||||
|
||||
cleanupUrlParams()
|
||||
clearPreservedQuery(SHARE_NAMESPACE)
|
||||
return 'loaded'
|
||||
}
|
||||
|
||||
return {
|
||||
loadSharedWorkflowFromUrl
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { useMouseInElement } from '@vueuse/core'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
export function useSliderFromMouse(target: Ref<HTMLElement | null>) {
|
||||
const position = ref(50)
|
||||
const { elementX, elementWidth, isOutside } = useMouseInElement(target)
|
||||
|
||||
watch([elementX, elementWidth, isOutside], ([x, width, outside]) => {
|
||||
if (!outside && width > 0) {
|
||||
position.value = (x / width) * 100
|
||||
}
|
||||
})
|
||||
|
||||
return position
|
||||
}
|
||||
81
src/platform/workflow/sharing/constants/comfyHubTags.ts
Normal file
81
src/platform/workflow/sharing/constants/comfyHubTags.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { orderBy } from 'es-toolkit/array'
|
||||
|
||||
/**
|
||||
* Curated tag options for ComfyHub workflow publishing.
|
||||
* Sourced from https://github.com/Comfy-Org/workflow_templates/blob/main/templates/index.json
|
||||
*
|
||||
* To regenerate: pnpm dlx tsx temp/scripts/extract-comfyhub-tags.ts
|
||||
*/
|
||||
const COMFY_HUB_TAG_FREQUENCIES = [
|
||||
{ tag: 'API', count: 143 },
|
||||
{ tag: 'Video', count: 102 },
|
||||
{ tag: 'Image', count: 98 },
|
||||
{ tag: 'Text to Image', count: 61 },
|
||||
{ tag: 'Image to Video', count: 48 },
|
||||
{ tag: 'Image Edit', count: 46 },
|
||||
{ tag: 'Text to Video', count: 34 },
|
||||
{ tag: 'Audio', count: 20 },
|
||||
{ tag: '3D', count: 19 },
|
||||
{ tag: 'FLF2V', count: 18 },
|
||||
{ tag: 'ControlNet', count: 16 },
|
||||
{ tag: 'Image Upscale', count: 16 },
|
||||
{ tag: 'Product', count: 11 },
|
||||
{ tag: 'Text to Audio', count: 10 },
|
||||
{ tag: 'Image to 3D', count: 9 },
|
||||
{ tag: 'Inpainting', count: 8 },
|
||||
{ tag: 'Character Reference', count: 6 },
|
||||
{ tag: 'Video to Video', count: 6 },
|
||||
{ tag: 'Video Upscale', count: 6 },
|
||||
{ tag: 'Mockup', count: 5 },
|
||||
{ tag: 'Outpainting', count: 5 },
|
||||
{ tag: 'Preprocessor', count: 5 },
|
||||
{ tag: 'Relight', count: 5 },
|
||||
{ tag: 'Voice Cloning', count: 5 },
|
||||
{ tag: 'Brand Design', count: 4 },
|
||||
{ tag: 'Fashion', count: 4 },
|
||||
{ tag: 'Image to Model', count: 4 },
|
||||
{ tag: 'Portrait', count: 4 },
|
||||
{ tag: 'Text to Model', count: 4 },
|
||||
{ tag: 'Video Edit', count: 4 },
|
||||
{ tag: 'Anime', count: 3 },
|
||||
{ tag: 'Audio to Audio', count: 3 },
|
||||
{ tag: 'Audio to Video', count: 3 },
|
||||
{ tag: 'LLM', count: 3 },
|
||||
{ tag: 'Motion Control', count: 3 },
|
||||
{ tag: 'Music', count: 3 },
|
||||
{ tag: 'Style Reference', count: 3 },
|
||||
{ tag: 'Style Transfer', count: 3 },
|
||||
{ tag: 'Text to Speech', count: 3 },
|
||||
{ tag: '3D Model', count: 2 },
|
||||
{ tag: 'Audio Editing', count: 2 },
|
||||
{ tag: 'Character', count: 2 },
|
||||
{ tag: 'Layer Decompose', count: 2 },
|
||||
{ tag: 'Lip Sync', count: 2 },
|
||||
{ tag: 'Multiple Angles', count: 2 },
|
||||
{ tag: 'Remove Background', count: 2 },
|
||||
{ tag: 'Text-to-Image', count: 2 },
|
||||
{ tag: 'Vector', count: 2 },
|
||||
{ tag: 'Brand', count: 1 },
|
||||
{ tag: 'Canny', count: 1 },
|
||||
{ tag: 'Depth Map', count: 1 },
|
||||
{ tag: 'Frame Interpolation', count: 1 },
|
||||
{ tag: 'icon', count: 1 },
|
||||
{ tag: 'Image Enhancement', count: 1 },
|
||||
{ tag: 'Layout Design', count: 1 },
|
||||
{ tag: 'Normal Map', count: 1 },
|
||||
{ tag: 'OpenPose', count: 1 },
|
||||
{ tag: 'Pose transfer', count: 1 },
|
||||
{ tag: 'Replacement', count: 1 },
|
||||
{ tag: 'Sound Effects', count: 1 },
|
||||
{ tag: 'Speech to Text', count: 1 },
|
||||
{ tag: 'Text Generation', count: 1 },
|
||||
{ tag: 'Turbo', count: 1 },
|
||||
{ tag: 'Video Extension', count: 1 },
|
||||
{ tag: 'Voice Isolation', count: 1 }
|
||||
] as const
|
||||
|
||||
export const COMFY_HUB_TAG_OPTIONS = orderBy(
|
||||
COMFY_HUB_TAG_FREQUENCIES,
|
||||
['count', 'tag'],
|
||||
['desc', 'asc']
|
||||
).map(({ tag }) => tag)
|
||||
54
src/platform/workflow/sharing/schemas/shareSchemas.test.ts
Normal file
54
src/platform/workflow/sharing/schemas/shareSchemas.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { zSharedWorkflowResponse } from '@/platform/workflow/sharing/schemas/shareSchemas'
|
||||
|
||||
function makePayload(name: string) {
|
||||
return {
|
||||
share_id: 'share-1',
|
||||
workflow_id: 'wf-1',
|
||||
name,
|
||||
listed: false,
|
||||
publish_time: null,
|
||||
workflow_json: {},
|
||||
assets: []
|
||||
}
|
||||
}
|
||||
|
||||
describe('zSharedWorkflowResponse name sanitization', () => {
|
||||
it('strips forward slashes from name', () => {
|
||||
const result = zSharedWorkflowResponse.parse(
|
||||
makePayload('../../malicious/path')
|
||||
)
|
||||
expect(result.name).toBe('.._.._malicious_path')
|
||||
})
|
||||
|
||||
it('strips backslashes from name', () => {
|
||||
const result = zSharedWorkflowResponse.parse(
|
||||
makePayload('..\\..\\malicious\\path')
|
||||
)
|
||||
expect(result.name).toBe('.._.._malicious_path')
|
||||
})
|
||||
|
||||
it('strips colons from name', () => {
|
||||
const result = zSharedWorkflowResponse.parse(makePayload('C:\\evil'))
|
||||
expect(result.name).toBe('C__evil')
|
||||
})
|
||||
|
||||
it('truncates names exceeding 200 characters', () => {
|
||||
const longName = 'a'.repeat(300)
|
||||
const result = zSharedWorkflowResponse.parse(makePayload(longName))
|
||||
expect(result.name).toHaveLength(200)
|
||||
})
|
||||
|
||||
it('preserves safe names unchanged', () => {
|
||||
const result = zSharedWorkflowResponse.parse(
|
||||
makePayload('My Cool Workflow (v2)')
|
||||
)
|
||||
expect(result.name).toBe('My Cool Workflow (v2)')
|
||||
})
|
||||
|
||||
it('trims whitespace from sanitized names', () => {
|
||||
const result = zSharedWorkflowResponse.parse(makePayload(' spaced name '))
|
||||
expect(result.name).toBe('spaced name')
|
||||
})
|
||||
})
|
||||
44
src/platform/workflow/sharing/schemas/shareSchemas.ts
Normal file
44
src/platform/workflow/sharing/schemas/shareSchemas.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { zAssetInfo, zComfyHubProfile } from '@/schemas/apiSchema'
|
||||
|
||||
export const zPublishRecordResponse = z.object({
|
||||
workflow_id: z.string(),
|
||||
share_id: z.string().nullable(),
|
||||
listed: z.boolean(),
|
||||
publish_time: z.string().nullable(),
|
||||
assets: z.array(zAssetInfo).optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Strips path separators and control characters from a workflow name to prevent
|
||||
* path traversal when the name is later used as part of a file path.
|
||||
*/
|
||||
function sanitizeWorkflowName(name: string): string {
|
||||
return name
|
||||
.replaceAll(/[/\\:]/g, '_')
|
||||
.slice(0, 200)
|
||||
.trim()
|
||||
}
|
||||
|
||||
export const zSharedWorkflowResponse = z.object({
|
||||
share_id: z.string(),
|
||||
workflow_id: z.string(),
|
||||
name: z.string().transform(sanitizeWorkflowName),
|
||||
listed: z.boolean(),
|
||||
publish_time: z.string().nullable(),
|
||||
workflow_json: z.record(z.string(), z.unknown()),
|
||||
assets: z.array(zAssetInfo)
|
||||
})
|
||||
|
||||
export const zHubProfileResponse = z.preprocess((data) => {
|
||||
if (!data || typeof data !== 'object') return data
|
||||
const d = data as Record<string, unknown>
|
||||
return {
|
||||
username: d.username,
|
||||
name: d.name,
|
||||
description: d.description,
|
||||
coverImageUrl: d.coverImageUrl ?? d.cover_image_url,
|
||||
profilePictureUrl: d.profilePictureUrl ?? d.profile_picture_url
|
||||
}
|
||||
}, zComfyHubProfile)
|
||||
@@ -0,0 +1,437 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { AssetInfo } from '@/schemas/apiSchema'
|
||||
import { useWorkflowShareService } from '@/platform/workflow/sharing/services/workflowShareService'
|
||||
|
||||
const mockApp = vi.hoisted(() => ({
|
||||
rootGraph: {} as object | null,
|
||||
graphToPrompt: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: mockApp
|
||||
}))
|
||||
|
||||
const mockGetShareableAssets = vi.fn()
|
||||
const mockFetchApi = vi.fn()
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workflow/validation/schemas/workflowSchema',
|
||||
async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
validateComfyWorkflow: vi.fn(async (json: unknown) => json)
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
getShareableAssets: (...args: unknown[]) => mockGetShareableAssets(...args),
|
||||
fetchApi: (...args: unknown[]) => mockFetchApi(...args),
|
||||
apiURL: (route: string) => `/api${route}`,
|
||||
fileURL: (route: string) => route
|
||||
}
|
||||
}))
|
||||
|
||||
describe(useWorkflowShareService, () => {
|
||||
const mockShareableAssets: AssetInfo[] = [
|
||||
{
|
||||
id: 'asset-1',
|
||||
name: 'asset.png',
|
||||
storage_url: '',
|
||||
preview_url: '',
|
||||
model: false,
|
||||
public: false,
|
||||
in_library: false
|
||||
},
|
||||
{
|
||||
id: 'model-1',
|
||||
name: 'model.safetensors',
|
||||
storage_url: '',
|
||||
preview_url: '',
|
||||
model: true,
|
||||
public: false,
|
||||
in_library: false
|
||||
}
|
||||
]
|
||||
|
||||
function mockJsonResponse(payload: unknown, ok = true, status = 200) {
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
json: async () => payload
|
||||
} as Response
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
mockApp.rootGraph = {}
|
||||
window.history.replaceState({}, '', '/')
|
||||
})
|
||||
|
||||
it('returns unpublished status for unknown workflow', async () => {
|
||||
mockFetchApi.mockResolvedValue(
|
||||
mockJsonResponse({
|
||||
workflow_id: 'wf-0',
|
||||
publish_time: null,
|
||||
share_id: null,
|
||||
listed: false
|
||||
})
|
||||
)
|
||||
|
||||
const service = useWorkflowShareService()
|
||||
const status = await service.getPublishStatus('unknown-id')
|
||||
|
||||
expect(status.isPublished).toBe(false)
|
||||
expect(status.shareId).toBeNull()
|
||||
expect(status.shareUrl).toBeNull()
|
||||
expect(status.publishedAt).toBeNull()
|
||||
})
|
||||
|
||||
it('publishes a workflow and returns a share URL', async () => {
|
||||
mockFetchApi.mockResolvedValue(
|
||||
mockJsonResponse({
|
||||
workflow_id: 'test-workflow',
|
||||
share_id: 'abc123',
|
||||
publish_time: '2026-02-23T00:00:00Z',
|
||||
listed: false,
|
||||
assets: []
|
||||
})
|
||||
)
|
||||
|
||||
const service = useWorkflowShareService()
|
||||
|
||||
const result = await service.publishWorkflow(
|
||||
'test-workflow',
|
||||
mockShareableAssets
|
||||
)
|
||||
|
||||
expect(result.shareId).toBe('abc123')
|
||||
expect(result.shareUrl).toBe(`${window.location.origin}/?share=abc123`)
|
||||
expect(result.publishedAt).toBeInstanceOf(Date)
|
||||
expect(mockFetchApi).toHaveBeenCalledWith(
|
||||
'/userdata/test-workflow/publish',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
asset_ids: ['asset-1', 'model-1']
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves app subpath when normalizing published share URLs', async () => {
|
||||
window.history.replaceState({}, '', '/comfy/subpath/?foo=bar#section')
|
||||
mockFetchApi.mockResolvedValue(
|
||||
mockJsonResponse({
|
||||
workflow_id: 'test-workflow',
|
||||
share_id: 'subpath-id',
|
||||
publish_time: '2026-02-23T00:00:00Z',
|
||||
listed: false,
|
||||
assets: []
|
||||
})
|
||||
)
|
||||
|
||||
const service = useWorkflowShareService()
|
||||
const result = await service.publishWorkflow(
|
||||
'test-workflow',
|
||||
mockShareableAssets
|
||||
)
|
||||
|
||||
expect(result.shareUrl).toBe(
|
||||
`${window.location.origin}/comfy/subpath/?share=subpath-id`
|
||||
)
|
||||
})
|
||||
|
||||
it('reports published status after publishing', async () => {
|
||||
mockFetchApi.mockResolvedValue(
|
||||
mockJsonResponse({
|
||||
workflow_id: 'wf-1',
|
||||
share_id: 'wf-1',
|
||||
publish_time: '2026-02-23T00:00:00Z',
|
||||
listed: false,
|
||||
assets: []
|
||||
})
|
||||
)
|
||||
|
||||
const service = useWorkflowShareService()
|
||||
const status = await service.getPublishStatus('wf-1')
|
||||
|
||||
expect(status.isPublished).toBe(true)
|
||||
expect(status.shareId).toBe('wf-1')
|
||||
expect(status.shareUrl).toBe(`${window.location.origin}/?share=wf-1`)
|
||||
expect(status.publishedAt).toBeInstanceOf(Date)
|
||||
})
|
||||
|
||||
it('preserves app subpath when normalizing publish status share URLs', async () => {
|
||||
window.history.replaceState({}, '', '/comfy/subpath/')
|
||||
mockFetchApi.mockResolvedValue(
|
||||
mockJsonResponse({
|
||||
workflow_id: 'wf-subpath',
|
||||
share_id: 'wf-subpath',
|
||||
publish_time: '2026-02-23T00:00:00Z',
|
||||
listed: false,
|
||||
assets: []
|
||||
})
|
||||
)
|
||||
|
||||
const service = useWorkflowShareService()
|
||||
const status = await service.getPublishStatus('wf-subpath')
|
||||
|
||||
expect(status.shareUrl).toBe(
|
||||
`${window.location.origin}/comfy/subpath/?share=wf-subpath`
|
||||
)
|
||||
})
|
||||
|
||||
it('returns unpublished when publish record has no share id', async () => {
|
||||
mockFetchApi.mockResolvedValue(
|
||||
mockJsonResponse({
|
||||
workflow_id: 'wf-2',
|
||||
share_id: null,
|
||||
publish_time: '2026-02-23T00:00:00Z',
|
||||
listed: false
|
||||
})
|
||||
)
|
||||
|
||||
const service = useWorkflowShareService()
|
||||
const status = await service.getPublishStatus('wf-2')
|
||||
|
||||
expect(status.isPublished).toBe(false)
|
||||
expect(status.shareId).toBeNull()
|
||||
})
|
||||
|
||||
it('fetches and maps shared workflow payload', async () => {
|
||||
mockFetchApi.mockResolvedValue(
|
||||
mockJsonResponse({
|
||||
share_id: 'share-123',
|
||||
workflow_id: 'wf-123',
|
||||
name: 'Test Workflow',
|
||||
listed: true,
|
||||
publish_time: '2026-02-23T00:00:00Z',
|
||||
workflow_json: { nodes: [] },
|
||||
assets: [
|
||||
{
|
||||
id: 'asset-1',
|
||||
name: 'asset.png',
|
||||
preview_url: 'https://example.com/a.jpg',
|
||||
storage_url: 'storage/a',
|
||||
model: false,
|
||||
public: false,
|
||||
in_library: false
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
const service = useWorkflowShareService()
|
||||
const shared = await service.getSharedWorkflow('share-123')
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledWith('/workflows/published/share-123')
|
||||
expect(shared).toEqual({
|
||||
shareId: 'share-123',
|
||||
workflowId: 'wf-123',
|
||||
name: 'Test Workflow',
|
||||
listed: true,
|
||||
publishedAt: new Date('2026-02-23T00:00:00Z'),
|
||||
workflowJson: { nodes: [] },
|
||||
assets: [
|
||||
{
|
||||
id: 'asset-1',
|
||||
name: 'asset.png',
|
||||
preview_url: 'https://example.com/a.jpg',
|
||||
storage_url: 'storage/a',
|
||||
model: false,
|
||||
public: false,
|
||||
in_library: false
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('throws when shared workflow request fails', async () => {
|
||||
mockFetchApi.mockResolvedValue(mockJsonResponse({}, false, 404))
|
||||
|
||||
const service = useWorkflowShareService()
|
||||
|
||||
await expect(service.getSharedWorkflow('missing')).rejects.toThrow(
|
||||
'Failed to load shared workflow: 404'
|
||||
)
|
||||
})
|
||||
|
||||
it('imports published assets via POST /assets/import', async () => {
|
||||
mockFetchApi.mockResolvedValue(mockJsonResponse({}, true, 200))
|
||||
|
||||
const service = useWorkflowShareService()
|
||||
await service.importPublishedAssets(['pa-1', 'pa-2'])
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledWith('/assets/import', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ published_asset_ids: ['pa-1', 'pa-2'] })
|
||||
})
|
||||
})
|
||||
|
||||
it('throws when import request fails', async () => {
|
||||
mockFetchApi.mockResolvedValue(mockJsonResponse({}, false, 400))
|
||||
|
||||
const service = useWorkflowShareService()
|
||||
|
||||
await expect(service.importPublishedAssets(['bad-id'])).rejects.toThrow(
|
||||
'Failed to import assets: 400'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws when shared workflow payload is invalid', async () => {
|
||||
mockFetchApi.mockResolvedValue(
|
||||
mockJsonResponse({ name: 'Invalid', version: 1 })
|
||||
)
|
||||
|
||||
const service = useWorkflowShareService()
|
||||
|
||||
await expect(service.getSharedWorkflow('invalid')).rejects.toThrow(
|
||||
'Failed to load shared workflow: invalid response'
|
||||
)
|
||||
})
|
||||
|
||||
it('treats malformed publish-status payload as unpublished', async () => {
|
||||
mockFetchApi.mockResolvedValue(mockJsonResponse({ is_published: true }))
|
||||
|
||||
const service = useWorkflowShareService()
|
||||
const status = await service.getPublishStatus('wf-4')
|
||||
|
||||
expect(status).toEqual({
|
||||
isPublished: false,
|
||||
shareId: null,
|
||||
shareUrl: null,
|
||||
publishedAt: null
|
||||
})
|
||||
})
|
||||
|
||||
it('returns empty results when no graph exists', async () => {
|
||||
mockApp.rootGraph = null
|
||||
|
||||
const service = useWorkflowShareService()
|
||||
const result = await service.getShareableAssets()
|
||||
|
||||
expect(result).toEqual([])
|
||||
expect(mockApp.graphToPrompt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls backend API with graph prompt output', async () => {
|
||||
mockApp.graphToPrompt.mockResolvedValue({ output: { '1': {} } })
|
||||
mockGetShareableAssets.mockResolvedValue({ assets: [] })
|
||||
|
||||
const service = useWorkflowShareService()
|
||||
await service.getShareableAssets()
|
||||
|
||||
expect(mockGetShareableAssets).toHaveBeenCalledWith({ '1': {} })
|
||||
})
|
||||
|
||||
it('propagates error when graphToPrompt fails', async () => {
|
||||
mockApp.graphToPrompt.mockRejectedValue(new Error('prompt failed'))
|
||||
|
||||
const service = useWorkflowShareService()
|
||||
|
||||
await expect(service.getShareableAssets()).rejects.toThrow('prompt failed')
|
||||
})
|
||||
|
||||
it('normalizes backend thumbnail field names', async () => {
|
||||
mockApp.graphToPrompt.mockResolvedValue({ output: {} })
|
||||
mockGetShareableAssets.mockResolvedValue({
|
||||
assets: [
|
||||
{
|
||||
id: 'asset-server-1',
|
||||
name: 'server-asset.png',
|
||||
preview_url: 'https://example.com/a.jpg',
|
||||
storage_url: 'storage/a',
|
||||
model: false,
|
||||
public: false,
|
||||
in_library: false
|
||||
},
|
||||
{
|
||||
id: 'model-server-1',
|
||||
name: 'server-model.safetensors',
|
||||
preview_url: 'https://example.com/m.jpg',
|
||||
storage_url: 'storage/m',
|
||||
model: true,
|
||||
public: false,
|
||||
in_library: true
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const service = useWorkflowShareService()
|
||||
const result = await service.getShareableAssets()
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: 'asset-server-1',
|
||||
name: 'server-asset.png',
|
||||
preview_url: 'https://example.com/a.jpg',
|
||||
storage_url: 'storage/a',
|
||||
model: false,
|
||||
public: false,
|
||||
in_library: false
|
||||
},
|
||||
{
|
||||
id: 'model-server-1',
|
||||
name: 'server-model.safetensors',
|
||||
preview_url: 'https://example.com/m.jpg',
|
||||
storage_url: 'storage/m',
|
||||
model: true,
|
||||
public: false,
|
||||
in_library: true
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('returns assets with preview_url intact', async () => {
|
||||
mockApp.graphToPrompt.mockResolvedValue({ output: {} })
|
||||
mockGetShareableAssets.mockResolvedValue({
|
||||
assets: [
|
||||
{
|
||||
id: 'asset-1',
|
||||
name: 'asset.png',
|
||||
preview_url: '/view?filename=asset.png',
|
||||
storage_url: 'storage/asset',
|
||||
model: false,
|
||||
public: false,
|
||||
in_library: false
|
||||
},
|
||||
{
|
||||
id: 'model-1',
|
||||
name: 'model.safetensors',
|
||||
preview_url: '/api/assets/model-thumb',
|
||||
storage_url: 'storage/model',
|
||||
model: true,
|
||||
public: false,
|
||||
in_library: false
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const service = useWorkflowShareService()
|
||||
const result = await service.getShareableAssets()
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: 'asset-1',
|
||||
name: 'asset.png',
|
||||
preview_url: '/view?filename=asset.png',
|
||||
storage_url: 'storage/asset',
|
||||
model: false,
|
||||
public: false,
|
||||
in_library: false
|
||||
},
|
||||
{
|
||||
id: 'model-1',
|
||||
name: 'model.safetensors',
|
||||
preview_url: '/api/assets/model-thumb',
|
||||
storage_url: 'storage/model',
|
||||
model: true,
|
||||
public: false,
|
||||
in_library: false
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
207
src/platform/workflow/sharing/services/workflowShareService.ts
Normal file
207
src/platform/workflow/sharing/services/workflowShareService.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import type {
|
||||
SharedWorkflowPayload,
|
||||
WorkflowPublishResult,
|
||||
WorkflowPublishStatus
|
||||
} from '@/platform/workflow/sharing/types/shareTypes'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { validateComfyWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { AssetInfo } from '@/schemas/apiSchema'
|
||||
import {
|
||||
zPublishRecordResponse,
|
||||
zSharedWorkflowResponse
|
||||
} from '@/platform/workflow/sharing/schemas/shareSchemas'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
class SharedWorkflowLoadError extends Error {
|
||||
readonly status: number | null
|
||||
|
||||
constructor(status: number | null, message?: string) {
|
||||
super(message ?? `Failed to load shared workflow: ${status ?? 'unknown'}`)
|
||||
this.name = 'SharedWorkflowLoadError'
|
||||
this.status = status
|
||||
}
|
||||
|
||||
get isRetryable(): boolean {
|
||||
if (this.status === null) return true
|
||||
return this.status >= 500 || this.status === 408 || this.status === 429
|
||||
}
|
||||
}
|
||||
|
||||
function decodePublishRecord(payload: unknown) {
|
||||
const result = zPublishRecordResponse.safeParse(payload)
|
||||
if (!result.success) return null
|
||||
const r = result.data
|
||||
return {
|
||||
workflowId: r.workflow_id,
|
||||
shareId: r.share_id ?? undefined,
|
||||
listed: r.listed,
|
||||
publishedAt: parsePublishedAt(r.publish_time),
|
||||
shareUrl: r.share_id ? normalizeShareUrl(r.share_id) : undefined
|
||||
}
|
||||
}
|
||||
|
||||
function parsePublishedAt(value: string | null | undefined): Date | null {
|
||||
if (!value) return null
|
||||
|
||||
const parsed = new Date(value)
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed
|
||||
}
|
||||
|
||||
function normalizeShareUrl(shareId: string): string {
|
||||
const queryString = `share=${encodeURIComponent(shareId)}`
|
||||
if (typeof window === 'undefined' || !window.location?.origin) {
|
||||
return `/?${queryString}`
|
||||
}
|
||||
|
||||
const normalizedUrl = new URL(window.location.href)
|
||||
normalizedUrl.search = queryString
|
||||
normalizedUrl.hash = ''
|
||||
return normalizedUrl.toString()
|
||||
}
|
||||
|
||||
function decodeSharedWorkflowPayload(
|
||||
payload: unknown
|
||||
): SharedWorkflowPayload | null {
|
||||
const result = zSharedWorkflowResponse.safeParse(payload)
|
||||
if (!result.success) return null
|
||||
const r = result.data
|
||||
return {
|
||||
shareId: r.share_id,
|
||||
workflowId: r.workflow_id,
|
||||
name: r.name,
|
||||
listed: r.listed,
|
||||
publishedAt: r.publish_time ? parsePublishedAt(r.publish_time) : null,
|
||||
workflowJson: r.workflow_json as ComfyWorkflowJSON,
|
||||
assets: r.assets
|
||||
}
|
||||
}
|
||||
|
||||
const UNPUBLISHED = {
|
||||
isPublished: false,
|
||||
shareId: null,
|
||||
shareUrl: null,
|
||||
publishedAt: null
|
||||
} as const satisfies WorkflowPublishStatus
|
||||
|
||||
export function useWorkflowShareService() {
|
||||
async function publishWorkflow(
|
||||
workflowPath: string,
|
||||
shareableAssets: AssetInfo[]
|
||||
): Promise<WorkflowPublishResult> {
|
||||
const assetIds = shareableAssets.map((a) => a.id)
|
||||
const response = await api.fetchApi(
|
||||
`/userdata/${encodeURIComponent(workflowPath)}/publish`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ asset_ids: assetIds })
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to publish workflow: ${response.status}`)
|
||||
}
|
||||
|
||||
const record = decodePublishRecord(await response.json())
|
||||
if (!record?.shareId || !record.publishedAt) {
|
||||
throw new Error('Failed to publish workflow: invalid response')
|
||||
}
|
||||
|
||||
return {
|
||||
shareId: record.shareId,
|
||||
shareUrl: normalizeShareUrl(record.shareId),
|
||||
publishedAt: record.publishedAt
|
||||
}
|
||||
}
|
||||
|
||||
async function getPublishStatus(
|
||||
workflowPath: string
|
||||
): Promise<WorkflowPublishStatus> {
|
||||
const response = await api.fetchApi(
|
||||
`/userdata/${encodeURIComponent(workflowPath)}/publish`
|
||||
)
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) return UNPUBLISHED
|
||||
throw new Error(
|
||||
`Failed to fetch publish status: ${response.status} ${response.statusText}`
|
||||
)
|
||||
}
|
||||
|
||||
const json = await response.json()
|
||||
const record = decodePublishRecord(json)
|
||||
if (!record || !record.shareId || !record.publishedAt) return UNPUBLISHED
|
||||
|
||||
return {
|
||||
isPublished: true,
|
||||
shareId: record.shareId,
|
||||
shareUrl: normalizeShareUrl(record.shareId),
|
||||
publishedAt: record.publishedAt
|
||||
}
|
||||
}
|
||||
|
||||
async function getShareableAssets(
|
||||
includingPublic = false
|
||||
): Promise<AssetInfo[]> {
|
||||
const graph = app.rootGraph
|
||||
if (!graph) return []
|
||||
|
||||
const { output } = await app.graphToPrompt(graph)
|
||||
const { assets } = await api.getShareableAssets(output)
|
||||
|
||||
return includingPublic ? assets : assets.filter((asset) => !asset.public)
|
||||
}
|
||||
|
||||
async function getSharedWorkflow(
|
||||
shareId: string
|
||||
): Promise<SharedWorkflowPayload> {
|
||||
let response: Response
|
||||
try {
|
||||
response = await api.fetchApi(
|
||||
`/workflows/published/${encodeURIComponent(shareId)}`
|
||||
)
|
||||
} catch {
|
||||
throw new SharedWorkflowLoadError(
|
||||
null,
|
||||
'Failed to load shared workflow: network error'
|
||||
)
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new SharedWorkflowLoadError(response.status)
|
||||
}
|
||||
|
||||
const workflow = decodeSharedWorkflowPayload(await response.json())
|
||||
if (!workflow) {
|
||||
throw new Error('Failed to load shared workflow: invalid response')
|
||||
}
|
||||
|
||||
const validated = await validateComfyWorkflow(workflow.workflowJson)
|
||||
if (!validated) {
|
||||
throw new Error('Failed to load shared workflow: invalid workflow data')
|
||||
}
|
||||
workflow.workflowJson = validated
|
||||
|
||||
return workflow
|
||||
}
|
||||
|
||||
async function importPublishedAssets(assetIds: string[]): Promise<void> {
|
||||
const response = await api.fetchApi('/assets/import', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ published_asset_ids: assetIds })
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to import assets: ${response.status}`)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
publishWorkflow,
|
||||
getPublishStatus,
|
||||
getShareableAssets,
|
||||
getSharedWorkflow,
|
||||
importPublishedAssets
|
||||
}
|
||||
}
|
||||
26
src/platform/workflow/sharing/types/comfyHubTypes.ts
Normal file
26
src/platform/workflow/sharing/types/comfyHubTypes.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export type ThumbnailType = 'image' | 'video' | 'imageComparison'
|
||||
|
||||
export type ComfyHubWorkflowType =
|
||||
| 'imageGeneration'
|
||||
| 'videoGeneration'
|
||||
| 'upscaling'
|
||||
| 'editing'
|
||||
|
||||
export interface ExampleImage {
|
||||
id: string
|
||||
url: string
|
||||
file?: File
|
||||
}
|
||||
|
||||
export interface ComfyHubPublishFormData {
|
||||
name: string
|
||||
description: string
|
||||
workflowType: ComfyHubWorkflowType | ''
|
||||
tags: string[]
|
||||
thumbnailType: ThumbnailType
|
||||
thumbnailFile: File | null
|
||||
comparisonBeforeFile: File | null
|
||||
comparisonAfterFile: File | null
|
||||
exampleImages: ExampleImage[]
|
||||
selectedExampleIds: string[]
|
||||
}
|
||||
27
src/platform/workflow/sharing/types/shareTypes.ts
Normal file
27
src/platform/workflow/sharing/types/shareTypes.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { AssetInfo } from '@/schemas/apiSchema'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
export interface WorkflowPublishResult {
|
||||
publishedAt: Date
|
||||
shareId: string
|
||||
shareUrl: string
|
||||
}
|
||||
|
||||
export type WorkflowPublishStatus =
|
||||
| { isPublished: false; publishedAt: null; shareId: null; shareUrl: null }
|
||||
| {
|
||||
isPublished: true
|
||||
publishedAt: Date
|
||||
shareId: string
|
||||
shareUrl: string
|
||||
}
|
||||
|
||||
export interface SharedWorkflowPayload {
|
||||
assets: AssetInfo[]
|
||||
listed: boolean
|
||||
name: string
|
||||
publishedAt: Date | null
|
||||
shareId: string
|
||||
workflowId: string
|
||||
workflowJson: ComfyWorkflowJSON
|
||||
}
|
||||
20
src/platform/workflow/sharing/utils/validateFileSize.ts
Normal file
20
src/platform/workflow/sharing/utils/validateFileSize.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { t } from '@/i18n'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
|
||||
export const MAX_IMAGE_SIZE_MB = 10
|
||||
export const MAX_VIDEO_SIZE_MB = 50
|
||||
|
||||
export function isFileTooLarge(file: File, maxSizeMB: number): boolean {
|
||||
const fileSizeMB = file.size / 1024 / 1024
|
||||
if (fileSizeMB <= maxSizeMB) return false
|
||||
|
||||
useToastStore().add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('toastMessages.fileTooLarge', {
|
||||
size: fileSizeMB.toFixed(1),
|
||||
maxSize: maxSizeMB
|
||||
})
|
||||
})
|
||||
return true
|
||||
}
|
||||
@@ -99,6 +99,10 @@ installPreservedQueryTracker(router, [
|
||||
namespace: PRESERVED_QUERY_NAMESPACES.TEMPLATE,
|
||||
keys: ['template', 'source', 'mode']
|
||||
},
|
||||
{
|
||||
namespace: PRESERVED_QUERY_NAMESPACES.SHARE,
|
||||
keys: ['share']
|
||||
},
|
||||
{
|
||||
namespace: PRESERVED_QUERY_NAMESPACES.INVITE,
|
||||
keys: ['invite']
|
||||
|
||||
@@ -480,3 +480,31 @@ export type UserDataFullInfo = z.infer<typeof zUserDataFullInfo>
|
||||
export type TerminalSize = z.infer<typeof zTerminalSize>
|
||||
export type LogEntry = z.infer<typeof zLogEntry>
|
||||
export type LogsRawResponse = z.infer<typeof zLogRawResponse>
|
||||
|
||||
export const zComfyHubProfile = z.object({
|
||||
username: z.string(),
|
||||
name: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
coverImageUrl: z.string().nullish(),
|
||||
profilePictureUrl: z.string().nullish()
|
||||
})
|
||||
|
||||
export type ComfyHubProfile = z.infer<typeof zComfyHubProfile>
|
||||
|
||||
export const zAssetInfo = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
preview_url: z.string(),
|
||||
storage_url: z.string(),
|
||||
model: z.boolean(),
|
||||
public: z.boolean(),
|
||||
in_library: z.boolean()
|
||||
})
|
||||
|
||||
export type AssetInfo = z.infer<typeof zAssetInfo>
|
||||
|
||||
export const zShareableAssetsResponse = z.object({
|
||||
assets: z.array(zAssetInfo)
|
||||
})
|
||||
|
||||
export type ShareableAssetsResponse = z.infer<typeof zShareableAssetsResponse>
|
||||
|
||||
@@ -12,6 +12,8 @@ import type {
|
||||
} from '@/platform/assets/schemas/assetSchema'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import type { ShareableAssetsResponse } from '@/schemas/apiSchema'
|
||||
import { zShareableAssetsResponse } from '@/schemas/apiSchema'
|
||||
import type { IFuseOptions } from 'fuse.js'
|
||||
import type {
|
||||
TemplateIncludeOnDistributionEnum,
|
||||
@@ -873,6 +875,30 @@ export class ComfyApi extends EventTarget {
|
||||
return await res.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the list of assets and models referenced by a prompt that would
|
||||
* need user consent before sharing.
|
||||
*/
|
||||
async getShareableAssets(
|
||||
prompt: ComfyApiWorkflow,
|
||||
options?: { owned?: boolean }
|
||||
): Promise<ShareableAssetsResponse> {
|
||||
const body: Record<string, unknown> = { workflow_api_json: prompt }
|
||||
if (options?.owned !== undefined) {
|
||||
body.owned = options.owned
|
||||
}
|
||||
const res = await this.fetchApi('/assets/from-workflow', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
if (res.status !== 200) {
|
||||
throw new Error(`Failed to fetch shareable assets: ${res.status}`)
|
||||
}
|
||||
const data = await res.json()
|
||||
return zShareableAssetsResponse.parse(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a list of model folder keys (eg ['checkpoints', 'loras', ...])
|
||||
* @returns The list of model folder keys
|
||||
|
||||
@@ -8,6 +8,19 @@ import { getPathDetails } from '@/utils/formatUtil'
|
||||
import { syncEntities } from '@/utils/syncUtil'
|
||||
import { buildTree } from '@/utils/treeUtil'
|
||||
|
||||
/**
|
||||
* Normalizes a timestamp value that may be either a number (milliseconds)
|
||||
* or an ISO 8601 string (from Go's time.Time JSON serialization) into
|
||||
* a consistent millisecond timestamp.
|
||||
*/
|
||||
function normalizeTimestamp(value: number | string): number {
|
||||
if (typeof value === 'string') {
|
||||
const ms = new Date(value).getTime()
|
||||
return Number.isNaN(ms) ? Date.now() : ms
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a file in the user's data directory.
|
||||
*/
|
||||
@@ -140,7 +153,7 @@ export class UserFile {
|
||||
// https://github.com/comfyanonymous/ComfyUI/pull/5446
|
||||
const updatedFile = (await resp.json()) as string | UserDataFullInfo
|
||||
if (typeof updatedFile === 'object') {
|
||||
this.lastModified = updatedFile.modified
|
||||
this.lastModified = normalizeTimestamp(updatedFile.modified)
|
||||
this.size = updatedFile.size
|
||||
}
|
||||
this.originalContent = this.content
|
||||
@@ -175,7 +188,7 @@ export class UserFile {
|
||||
// https://github.com/comfyanonymous/ComfyUI/pull/5446
|
||||
const updatedFile = (await resp.json()) as string | UserDataFullInfo
|
||||
if (typeof updatedFile === 'object') {
|
||||
this.lastModified = updatedFile.modified
|
||||
this.lastModified = normalizeTimestamp(updatedFile.modified)
|
||||
this.size = updatedFile.size
|
||||
}
|
||||
return this
|
||||
|
||||
Reference in New Issue
Block a user