mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-23 16:26:44 +00:00
Compare commits
3 Commits
add-cla-wo
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e97cca9e4a | ||
|
|
49a7b7b558 | ||
|
|
8d82944441 |
@@ -78,6 +78,11 @@ const config: StorybookConfig = {
|
||||
find: '@/composables/queue/useJobActions',
|
||||
replacement: process.cwd() + '/src/storybook/mocks/useJobActions.ts'
|
||||
},
|
||||
{
|
||||
find: '@/composables/billing/useBillingContext',
|
||||
replacement:
|
||||
process.cwd() + '/src/storybook/mocks/useBillingContext.ts'
|
||||
},
|
||||
{
|
||||
find: '@/utils/formatUtil',
|
||||
replacement:
|
||||
|
||||
@@ -30,9 +30,9 @@ function toggle(index: number) {
|
||||
<div class="flex flex-col gap-6 md:flex-row md:gap-16">
|
||||
<!-- Left heading -->
|
||||
<div
|
||||
class="bg-primary-comfy-ink sticky top-20 z-10 w-full shrink-0 self-start py-4 md:top-28 md:w-80 md:py-0"
|
||||
class="sticky top-20 z-10 w-full shrink-0 self-start bg-primary-comfy-ink py-4 md:top-28 md:w-80 md:py-0"
|
||||
>
|
||||
<h2 class="text-primary-comfy-canvas text-4xl font-light md:text-5xl">
|
||||
<h2 class="text-4xl font-light text-primary-comfy-canvas md:text-5xl">
|
||||
{{ heading }}
|
||||
</h2>
|
||||
</div>
|
||||
@@ -42,7 +42,7 @@ function toggle(index: number) {
|
||||
<div
|
||||
v-for="(faq, index) in faqs"
|
||||
:key="faq.id"
|
||||
class="border-primary-comfy-canvas/20 border-b"
|
||||
class="border-b border-primary-comfy-canvas/20"
|
||||
>
|
||||
<button
|
||||
:id="`faq-trigger-${faq.id}`"
|
||||
@@ -83,7 +83,7 @@ function toggle(index: number) {
|
||||
:aria-labelledby="`faq-trigger-${faq.id}`"
|
||||
class="pb-6"
|
||||
>
|
||||
<p class="text-primary-comfy-canvas/70 text-sm whitespace-pre-line">
|
||||
<p class="text-sm whitespace-pre-line text-primary-comfy-canvas/70">
|
||||
{{ faq.answer }}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -25,7 +25,7 @@ const {
|
||||
<section class="max-w-9xl mx-auto px-6 py-20 lg:py-32">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<h2
|
||||
class="text-primary-comfy-canvas max-w-5xl text-3xl font-light tracking-tight lg:text-5xl"
|
||||
class="max-w-5xl text-3xl font-light tracking-tight text-primary-comfy-canvas lg:text-5xl"
|
||||
>
|
||||
{{ t(headingKey, locale) }}
|
||||
</h2>
|
||||
|
||||
@@ -40,12 +40,12 @@ const {
|
||||
<div class="grid grid-cols-1 gap-12 lg:grid-cols-2 lg:gap-16">
|
||||
<div class="flex flex-col gap-8">
|
||||
<h2
|
||||
class="text-primary-comfy-canvas text-4xl font-light tracking-tight lg:text-6xl"
|
||||
class="text-4xl font-light tracking-tight text-primary-comfy-canvas lg:text-6xl"
|
||||
>
|
||||
{{ t(headingKey, locale) }}
|
||||
</h2>
|
||||
<p
|
||||
class="text-primary-comfy-canvas max-w-sm text-sm/relaxed lg:text-base"
|
||||
class="max-w-sm text-sm/relaxed text-primary-comfy-canvas lg:text-base"
|
||||
>
|
||||
{{ t(descriptionKey, locale) }}
|
||||
</p>
|
||||
@@ -66,10 +66,10 @@ const {
|
||||
v-for="(event, i) in events"
|
||||
:key="i"
|
||||
:href="event.href"
|
||||
class="group border-primary-comfy-canvas/15 flex items-center gap-4 border-b py-6 lg:gap-8"
|
||||
class="group flex items-center gap-4 border-b border-primary-comfy-canvas/15 py-6 lg:gap-8"
|
||||
>
|
||||
<span
|
||||
class="text-primary-comfy-canvas shrink-0 text-sm font-medium"
|
||||
class="shrink-0 text-sm font-medium text-primary-comfy-canvas"
|
||||
>
|
||||
{{ event.label[locale] }}
|
||||
</span>
|
||||
|
||||
@@ -109,7 +109,7 @@ const contactColumn: { title: string; links: FooterLink[] } = {
|
||||
<template>
|
||||
<footer
|
||||
ref="footerRef"
|
||||
class="bg-primary-comfy-ink text-primary-comfy-canvas px-6 py-8 lg:px-20"
|
||||
class="bg-primary-comfy-ink px-6 py-8 text-primary-comfy-canvas lg:px-20"
|
||||
>
|
||||
<div
|
||||
class="border-primary-warm-gray grid gap-12 border-t pt-16 lg:grid-cols-2 lg:gap-4"
|
||||
|
||||
@@ -53,7 +53,7 @@ defineEmits<{ click: [] }>()
|
||||
<div class="flex w-full items-end justify-between p-4">
|
||||
<div class="gap-2">
|
||||
<p class="text-sm font-bold text-white">{{ item.title }}</p>
|
||||
<p class="text-primary-comfy-canvas text-xs">
|
||||
<p class="text-xs text-primary-comfy-canvas">
|
||||
<GalleryItemAttribution :item :locale />
|
||||
</p>
|
||||
</div>
|
||||
@@ -82,7 +82,7 @@ defineEmits<{ click: [] }>()
|
||||
<!-- Mobile metadata -->
|
||||
<div v-if="mobile" class="mt-2 gap-2">
|
||||
<p class="text-sm font-bold text-white">{{ item.title }}</p>
|
||||
<p class="text-primary-comfy-canvas text-xs">
|
||||
<p class="text-xs text-primary-comfy-canvas">
|
||||
<GalleryItemAttribution :item :locale />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
class="max-w-9xl mx-auto flex flex-col items-center px-6 pt-24 pb-12 text-center"
|
||||
>
|
||||
<h1
|
||||
class="text-primary-comfy-canvas max-w-4xl text-3xl leading-[110%] font-light tracking-tight lg:text-5xl"
|
||||
class="max-w-4xl text-3xl leading-[110%] font-light tracking-tight text-primary-comfy-canvas lg:text-5xl"
|
||||
>
|
||||
{{ t('learning.heroTitle.before', locale) }}
|
||||
<span class="text-primary-comfy-yellow">ComfyUI</span
|
||||
|
||||
61
browser_tests/tests/browseModelAssets.spec.ts
Normal file
61
browser_tests/tests/browseModelAssets.spec.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { Asset } from '@comfyorg/ingest-types'
|
||||
import { createCloudAssetsFixture } from '@e2e/fixtures/assetApiFixture'
|
||||
import { STABLE_CHECKPOINT } from '@e2e/fixtures/data/assetFixtures'
|
||||
|
||||
const CLOUD_ASSETS: Asset[] = [STABLE_CHECKPOINT]
|
||||
|
||||
const test = createCloudAssetsFixture(CLOUD_ASSETS)
|
||||
|
||||
test.describe('Browse Model Assets - Use button', { tag: '@cloud' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Assets.UseAssetAPI', true)
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
})
|
||||
|
||||
test('Use button ghost-places a loader populated with the model', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseModelAssets')
|
||||
|
||||
const modal = comfyPage.page.locator(
|
||||
'[data-component-id="AssetBrowserModal"]'
|
||||
)
|
||||
await expect(modal).toBeVisible()
|
||||
|
||||
const card = comfyPage.page.locator(
|
||||
`[data-component-id="AssetCard"][data-asset-id="${STABLE_CHECKPOINT.id}"]`
|
||||
)
|
||||
await expect(card).toBeVisible()
|
||||
await card.getByRole('button', { name: 'Use' }).click()
|
||||
|
||||
// Dialog closes and the ghost is armed; the node is not placed until the
|
||||
// user clicks the canvas.
|
||||
await expect(modal).toBeHidden()
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 1000 })
|
||||
.toBe(0)
|
||||
|
||||
const canvasBox = (await comfyPage.canvas.boundingBox())!
|
||||
await comfyPage.canvas.click({
|
||||
position: { x: canvasBox.width / 2, y: canvasBox.height / 2 }
|
||||
})
|
||||
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
|
||||
.toBe(1)
|
||||
|
||||
const [loader] = await comfyPage.nodeOps.getNodeRefsByType(
|
||||
'CheckpointLoaderSimple'
|
||||
)
|
||||
expect(loader).toBeDefined()
|
||||
const widget = await loader.getWidgetByName('ckpt_name')
|
||||
expect(await widget.getValue()).toBe(STABLE_CHECKPOINT.name)
|
||||
})
|
||||
})
|
||||
@@ -143,7 +143,7 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
const objectInfo = await response.json()
|
||||
const ckptName =
|
||||
objectInfo.CheckpointLoaderSimple.input.required.ckpt_name
|
||||
ckptName[0] = [...ckptName[0], 'fake_model.safetensors']
|
||||
ckptName[0] = [...ckptName[0], FAKE_MODEL_NAME]
|
||||
await route.fulfill({ response, json: objectInfo })
|
||||
})
|
||||
|
||||
@@ -151,21 +151,11 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
const url = new URL(response.url())
|
||||
return url.pathname.endsWith('/object_info') && response.ok()
|
||||
})
|
||||
const modelFoldersResponse = comfyPage.page.waitForResponse(
|
||||
(response) => {
|
||||
const url = new URL(response.url())
|
||||
return url.pathname.endsWith('/experiment/models') && response.ok()
|
||||
}
|
||||
)
|
||||
const refreshButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelRefresh
|
||||
)
|
||||
|
||||
await Promise.all([
|
||||
objectInfoResponse,
|
||||
modelFoldersResponse,
|
||||
refreshButton.click()
|
||||
])
|
||||
await Promise.all([objectInfoResponse, refreshButton.click()])
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingModelsGroup)
|
||||
).toBeHidden()
|
||||
|
||||
@@ -233,4 +233,64 @@ test.describe('Model library sidebar - empty state', () => {
|
||||
await expect(tab.folderNodes).toHaveCount(0)
|
||||
await expect(tab.leafNodes).toHaveCount(0)
|
||||
})
|
||||
|
||||
test.describe('Model library sidebar - add node', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.modelLibrary.mockFoldersWithFiles(MOCK_FOLDERS)
|
||||
await comfyPage.setup()
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.modelLibrary.clearMocks()
|
||||
})
|
||||
|
||||
test('Clicking a model defers creation until placed on the canvas', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
await tab.open()
|
||||
await tab.getFolderByLabel('checkpoints').click()
|
||||
await expect(tab.getLeafByLabel('sd_xl_base_1.0')).toBeVisible()
|
||||
|
||||
await tab.getLeafByLabel('sd_xl_base_1.0').click()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 1000 })
|
||||
.toBe(0)
|
||||
|
||||
const canvasBox = (await comfyPage.canvas.boundingBox())!
|
||||
await comfyPage.canvas.click({
|
||||
position: { x: canvasBox.width / 2, y: canvasBox.height / 2 }
|
||||
})
|
||||
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
|
||||
.toBe(1)
|
||||
|
||||
const [loader] = await comfyPage.nodeOps.getNodeRefsByType(
|
||||
'CheckpointLoaderSimple'
|
||||
)
|
||||
expect(loader).toBeDefined()
|
||||
const widget = await loader.getWidgetByName('ckpt_name')
|
||||
expect(await widget.getValue()).toBe('sd_xl_base_1.0.safetensors')
|
||||
})
|
||||
|
||||
test('Ghost preview shows the model in the loader widget before placing', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
await tab.open()
|
||||
await tab.getFolderByLabel('checkpoints').click()
|
||||
await expect(tab.getLeafByLabel('sd_xl_base_1.0')).toBeVisible()
|
||||
|
||||
await tab.getLeafByLabel('sd_xl_base_1.0').click()
|
||||
|
||||
const ghost = comfyPage.page.locator(
|
||||
'[data-node-id="preview-CheckpointLoaderSimple"]'
|
||||
)
|
||||
await expect(ghost).toContainText('sd_xl_base_1.0.safetensors')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -355,7 +355,7 @@ describe('TreeExplorerV2Node', () => {
|
||||
const nodeDiv = getTreeNode(container)
|
||||
await fireEvent.dragStart(nodeDiv)
|
||||
|
||||
expect(mockStartDrag).toHaveBeenCalledWith(mockData, 'native')
|
||||
expect(mockStartDrag).toHaveBeenCalledWith(mockData, { mode: 'native' })
|
||||
})
|
||||
|
||||
it('does not call startDrag for folder items on dragstart', async () => {
|
||||
|
||||
@@ -93,6 +93,7 @@
|
||||
|
||||
<NodeTooltip v-if="tooltipEnabled" />
|
||||
<NodeSearchboxPopover ref="nodeSearchboxPopoverRef" />
|
||||
<NodeDragPreview />
|
||||
<VueNodeSwitchPopup />
|
||||
|
||||
<!-- Initialize components after comfyApp is ready. useAbsolutePosition requires
|
||||
@@ -136,6 +137,7 @@ import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
|
||||
import LinkOverlayCanvas from '@/components/graph/LinkOverlayCanvas.vue'
|
||||
import NodeTooltip from '@/components/graph/NodeTooltip.vue'
|
||||
import NodeContextMenu from '@/components/graph/NodeContextMenu.vue'
|
||||
import NodeDragPreview from '@/components/graph/NodeDragPreview.vue'
|
||||
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
|
||||
import TitleEditor from '@/components/graph/TitleEditor.vue'
|
||||
import NodePropertiesPanel from '@/components/rightSidePanel/RightSidePanel.vue'
|
||||
|
||||
97
src/components/graph/NodeDragPreview.test.ts
Normal file
97
src/components/graph/NodeDragPreview.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { render } from '@testing-library/vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import NodeDragPreview from '@/components/graph/NodeDragPreview.vue'
|
||||
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/components/LGraphNodePreview.vue',
|
||||
() => ({
|
||||
default: { template: '<div data-testid="node-preview" />' }
|
||||
})
|
||||
)
|
||||
|
||||
const nodeDef = fromPartial<ComfyNodeDefImpl>({ name: 'TestNode' })
|
||||
|
||||
function moveMouse(clientX: number, clientY: number) {
|
||||
window.dispatchEvent(new MouseEvent('mousemove', { clientX, clientY }))
|
||||
}
|
||||
|
||||
function ghostElement() {
|
||||
return document.querySelector('[data-testid="node-preview"]')?.parentElement
|
||||
?.parentElement
|
||||
}
|
||||
|
||||
describe('NodeDragPreview', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useNodeDragToCanvas().cancelDrag()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('shows no ghost when nothing is being dragged', async () => {
|
||||
render(NodeDragPreview)
|
||||
|
||||
moveMouse(100, 200)
|
||||
vi.advanceTimersByTime(16)
|
||||
await nextTick()
|
||||
|
||||
expect(ghostElement()).toBeFalsy()
|
||||
})
|
||||
|
||||
it('keeps the ghost hidden until the mouse position is known', async () => {
|
||||
render(NodeDragPreview)
|
||||
|
||||
useNodeDragToCanvas().startDrag(nodeDef)
|
||||
await nextTick()
|
||||
vi.advanceTimersByTime(16)
|
||||
await nextTick()
|
||||
|
||||
expect(ghostElement()).toBeFalsy()
|
||||
})
|
||||
|
||||
it('follows the mouse with an offset while dragging', async () => {
|
||||
render(NodeDragPreview)
|
||||
|
||||
useNodeDragToCanvas().startDrag(nodeDef)
|
||||
await nextTick()
|
||||
moveMouse(100, 200)
|
||||
vi.advanceTimersByTime(16)
|
||||
await nextTick()
|
||||
|
||||
expect(ghostElement()?.style.transform).toBe('translate(112px, 212px)')
|
||||
|
||||
vi.advanceTimersByTime(16)
|
||||
await nextTick()
|
||||
|
||||
expect(ghostElement()?.style.transform).toBe('translate(112px, 212px)')
|
||||
|
||||
moveMouse(300, 400)
|
||||
vi.advanceTimersByTime(16)
|
||||
await nextTick()
|
||||
|
||||
expect(ghostElement()?.style.transform).toBe('translate(312px, 412px)')
|
||||
})
|
||||
|
||||
it('removes the ghost when the drag is cancelled', async () => {
|
||||
render(NodeDragPreview)
|
||||
|
||||
useNodeDragToCanvas().startDrag(nodeDef)
|
||||
await nextTick()
|
||||
moveMouse(100, 200)
|
||||
vi.advanceTimersByTime(16)
|
||||
await nextTick()
|
||||
expect(ghostElement()).toBeTruthy()
|
||||
|
||||
useNodeDragToCanvas().cancelDrag()
|
||||
await nextTick()
|
||||
|
||||
expect(ghostElement()).toBeFalsy()
|
||||
})
|
||||
})
|
||||
57
src/components/graph/NodeDragPreview.vue
Normal file
57
src/components/graph/NodeDragPreview.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showGhost && rafPosition"
|
||||
class="pointer-events-none fixed top-0 left-0 z-10000 will-change-transform"
|
||||
:style="{
|
||||
transform: `translate(${rafPosition.x + 12}px, ${rafPosition.y + 12}px)`
|
||||
}"
|
||||
>
|
||||
<div class="origin-top-left scale-50 opacity-80">
|
||||
<LGraphNodePreview
|
||||
:node-def="draggedNode!"
|
||||
:widget-values="pendingWidgetValues"
|
||||
position="relative"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMouse, useRafFn } from '@vueuse/core'
|
||||
import { computed, shallowRef, watch } from 'vue'
|
||||
|
||||
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
|
||||
import LGraphNodePreview from '@/renderer/extensions/vueNodes/components/LGraphNodePreview.vue'
|
||||
|
||||
const { isDragging, draggedNode, pendingWidgetValues } = useNodeDragToCanvas()
|
||||
|
||||
const { x, y, sourceType } = useMouse({ type: 'client' })
|
||||
|
||||
const showGhost = computed(() => Boolean(isDragging.value && draggedNode.value))
|
||||
const rafPosition = shallowRef<{ x: number; y: number }>()
|
||||
|
||||
const { pause, resume } = useRafFn(
|
||||
() => {
|
||||
if (sourceType.value === null) return
|
||||
const pos = rafPosition.value
|
||||
if (pos && pos.x === x.value && pos.y === y.value) return
|
||||
rafPosition.value = { x: x.value, y: y.value }
|
||||
},
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
watch(
|
||||
showGhost,
|
||||
(show) => {
|
||||
if (show) {
|
||||
resume()
|
||||
} else {
|
||||
pause()
|
||||
rafPosition.value = undefined
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
@@ -14,7 +14,7 @@ const {
|
||||
captureRoot,
|
||||
getRoot,
|
||||
resetRoot,
|
||||
mockAddNodeOnGraph,
|
||||
mockStartDrag,
|
||||
mockGetNodeProvider,
|
||||
mockToggleNodeOnEvent,
|
||||
mockRefreshModelFolder,
|
||||
@@ -29,7 +29,7 @@ const {
|
||||
resetRoot: () => {
|
||||
capturedRoot = null
|
||||
},
|
||||
mockAddNodeOnGraph: vi.fn(),
|
||||
mockStartDrag: vi.fn(),
|
||||
mockGetNodeProvider: vi.fn(),
|
||||
mockToggleNodeOnEvent: vi.fn(),
|
||||
mockRefreshModelFolder: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -37,8 +37,8 @@ const {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ addNodeOnGraph: mockAddNodeOnGraph })
|
||||
vi.mock('@/composables/node/useNodeDragToCanvas', () => ({
|
||||
useNodeDragToCanvas: () => ({ startDrag: mockStartDrag })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/modelToNodeStore', () => ({
|
||||
@@ -173,16 +173,13 @@ describe('ModelLibrarySidebarTab', () => {
|
||||
expect(screen.getByTestId('search-input')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles model click and adds node to graph', async () => {
|
||||
it('starts a ghost drag carrying the widget value to fill on placement', async () => {
|
||||
const mockNodeDef = { name: 'CheckpointLoaderSimple' }
|
||||
const mockWidget = { name: 'ckpt_name', value: '' }
|
||||
const mockGraphNode = { widgets: [mockWidget] }
|
||||
|
||||
mockGetNodeProvider.mockReturnValue({
|
||||
nodeDef: mockNodeDef,
|
||||
key: 'ckpt_name'
|
||||
})
|
||||
mockAddNodeOnGraph.mockReturnValue(mockGraphNode)
|
||||
|
||||
renderComponent()
|
||||
await nextTick()
|
||||
@@ -198,8 +195,10 @@ describe('ModelLibrarySidebarTab', () => {
|
||||
await modelLeaf?.handleClick?.(mockEvent)
|
||||
|
||||
expect(mockGetNodeProvider).toHaveBeenCalledWith('checkpoints')
|
||||
expect(mockAddNodeOnGraph).toHaveBeenCalledWith(mockNodeDef)
|
||||
expect(mockWidget.value).toBe('model.safetensors')
|
||||
expect(mockStartDrag).toHaveBeenCalledWith(mockNodeDef, {
|
||||
widgetValues: { ckpt_name: 'model.safetensors' },
|
||||
source: 'sidebar_drag'
|
||||
})
|
||||
})
|
||||
|
||||
it('toggles folder expansion on click', async () => {
|
||||
|
||||
@@ -63,10 +63,9 @@ import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue
|
||||
import ElectronDownloadItems from '@/components/sidebar/tabs/modelLibrary/ElectronDownloadItems.vue'
|
||||
import ModelTreeLeaf from '@/components/sidebar/tabs/modelLibrary/ModelTreeLeaf.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { startModelLoaderDrag } from '@/composables/node/startModelNodeDragFromAsset'
|
||||
import { useTreeExpansion } from '@/composables/useTreeExpansion'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
|
||||
import type { ComfyModelDef, ModelFolder } from '@/stores/modelStore'
|
||||
import { ResourceState, useModelStore } from '@/stores/modelStore'
|
||||
@@ -156,15 +155,7 @@ const renderedRoot = computed<TreeExplorerNode<ModelOrFolder>>(() => {
|
||||
if (this.leaf && model) {
|
||||
const provider = modelToNodeStore.getNodeProvider(model.directory)
|
||||
if (provider) {
|
||||
const graphNode = withNodeAddSource('sidebar_drag', () =>
|
||||
useLitegraphService().addNodeOnGraph(provider.nodeDef)
|
||||
)
|
||||
const widget = graphNode?.widgets?.find(
|
||||
(widget) => widget.name === provider.key
|
||||
)
|
||||
if (widget) {
|
||||
widget.value = model.file_name
|
||||
}
|
||||
startModelLoaderDrag(provider, model.file_name)
|
||||
}
|
||||
} else {
|
||||
toggleNodeOnEvent(e, node)
|
||||
|
||||
@@ -31,11 +31,8 @@ vi.mock('@/composables/node/useNodeDragToCanvas', () => ({
|
||||
useNodeDragToCanvas: () => ({
|
||||
isDragging: { value: false },
|
||||
draggedNode: { value: null },
|
||||
cursorPosition: { value: { x: 0, y: 0 } },
|
||||
startDrag: vi.fn(),
|
||||
cancelDrag: vi.fn(),
|
||||
setupGlobalListeners: vi.fn(),
|
||||
cleanupGlobalListeners: vi.fn()
|
||||
cancelDrag: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
|
||||
@@ -115,7 +115,6 @@
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<NodeDragPreview />
|
||||
<div class="flex h-full flex-col">
|
||||
<div
|
||||
v-if="hasNoMatches"
|
||||
@@ -215,7 +214,6 @@ import type {
|
||||
import AllNodesPanel from './nodeLibrary/AllNodesPanel.vue'
|
||||
import BlueprintsPanel from './nodeLibrary/BlueprintsPanel.vue'
|
||||
import EssentialNodesPanel from './nodeLibrary/EssentialNodesPanel.vue'
|
||||
import NodeDragPreview from './nodeLibrary/NodeDragPreview.vue'
|
||||
import SidebarTabTemplate from './SidebarTabTemplate.vue'
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isDragging && draggedNode && showPreview"
|
||||
class="pointer-events-none fixed z-10000"
|
||||
:style="{
|
||||
left: `${previewPosition.x + 12}px`,
|
||||
top: `${previewPosition.y + 12}px`
|
||||
}"
|
||||
>
|
||||
<div class="origin-top-left scale-50 opacity-80">
|
||||
<LGraphNodePreview :node-def="draggedNode" position="relative" />
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
|
||||
import LGraphNodePreview from '@/renderer/extensions/vueNodes/components/LGraphNodePreview.vue'
|
||||
|
||||
const {
|
||||
isDragging,
|
||||
draggedNode,
|
||||
cursorPosition,
|
||||
dragMode,
|
||||
setupGlobalListeners,
|
||||
cleanupGlobalListeners
|
||||
} = useNodeDragToCanvas()
|
||||
|
||||
const nativeDragPosition = ref({ x: 0, y: 0 })
|
||||
|
||||
const previewPosition = computed(() => {
|
||||
if (dragMode.value === 'native') {
|
||||
return nativeDragPosition.value
|
||||
}
|
||||
return cursorPosition.value
|
||||
})
|
||||
|
||||
const showPreview = computed(() => {
|
||||
if (dragMode.value === 'native') {
|
||||
return nativeDragPosition.value.x > 0 || nativeDragPosition.value.y > 0
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
function handleDrag(e: DragEvent) {
|
||||
if (e.clientX === 0 && e.clientY === 0) return
|
||||
nativeDragPosition.value = { x: e.clientX, y: e.clientY }
|
||||
}
|
||||
|
||||
function handleDragEnd() {
|
||||
nativeDragPosition.value = { x: 0, y: 0 }
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setupGlobalListeners()
|
||||
document.addEventListener('drag', handleDrag)
|
||||
document.addEventListener('dragend', handleDragEnd)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanupGlobalListeners()
|
||||
document.removeEventListener('drag', handleDrag)
|
||||
document.removeEventListener('dragend', handleDragEnd)
|
||||
})
|
||||
</script>
|
||||
110
src/components/ui/credit-slider/CreditSlider.stories.ts
Normal file
110
src/components/ui/credit-slider/CreditSlider.stories.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import CreditSlider from './CreditSlider.vue'
|
||||
|
||||
const meta: Meta<typeof CreditSlider> = {
|
||||
title: 'Components/CreditSlider',
|
||||
component: CreditSlider,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'centered' },
|
||||
argTypes: {
|
||||
disabled: { control: 'boolean' }
|
||||
},
|
||||
args: {
|
||||
disabled: false
|
||||
},
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
// Previews at the real layout width: the Figma "Team Plan" card column is
|
||||
// 512px wide with 32px padding (DES-197), i.e. a 448px content area — the
|
||||
// width the slider actually renders into inside PricingTableWorkspace.
|
||||
template: '<div class="w-[512px] px-8"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
components: { CreditSlider },
|
||||
setup() {
|
||||
const value = ref(700)
|
||||
return { args, value }
|
||||
},
|
||||
template: '<CreditSlider v-model="value" :disabled="args.disabled" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: { disabled: true },
|
||||
render: (args) => ({
|
||||
components: { CreditSlider },
|
||||
setup() {
|
||||
const value = ref(700)
|
||||
return { args, value }
|
||||
},
|
||||
template: '<CreditSlider v-model="value" :disabled="args.disabled" />'
|
||||
})
|
||||
}
|
||||
|
||||
// Sample `GET /api/billing/plans → team_credit_stops` payload (DES-197 yearly).
|
||||
// In production this comes from the API; here it shows the stops being driven
|
||||
// entirely through props rather than the hardcoded default constant.
|
||||
const apiTeamCreditStops = {
|
||||
default_stop_index: 2,
|
||||
stops: [
|
||||
{
|
||||
id: 'team_200',
|
||||
credits: 42_200,
|
||||
yearly: { price_cents: 20_000, discount_percent: 0 }
|
||||
},
|
||||
{
|
||||
id: 'team_400',
|
||||
credits: 84_400,
|
||||
yearly: { price_cents: 38_000, discount_percent: 5 }
|
||||
},
|
||||
{
|
||||
id: 'team_700',
|
||||
credits: 147_700,
|
||||
yearly: { price_cents: 63_000, discount_percent: 10 }
|
||||
},
|
||||
{
|
||||
id: 'team_1400',
|
||||
credits: 295_400,
|
||||
yearly: { price_cents: 119_000, discount_percent: 15 }
|
||||
},
|
||||
{
|
||||
id: 'team_2500',
|
||||
credits: 527_500,
|
||||
yearly: { price_cents: 200_000, discount_percent: 20 }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// Reference adapter (FE-934 will own this in the data layer): API → CreditStop[].
|
||||
// The pre-discount list price is recovered as discounted / (1 - discount).
|
||||
const mappedStops = apiTeamCreditStops.stops.map((s) => ({
|
||||
credits: s.credits,
|
||||
discountPercentYearly: s.yearly.discount_percent,
|
||||
usd: Math.round(
|
||||
s.yearly.price_cents / 100 / (1 - s.yearly.discount_percent / 100)
|
||||
)
|
||||
}))
|
||||
|
||||
export const BackendDrivenStops: Story = {
|
||||
name: 'Backend-driven stops (props)',
|
||||
render: (args) => ({
|
||||
components: { CreditSlider },
|
||||
setup() {
|
||||
const defaultStopIndex = apiTeamCreditStops.default_stop_index
|
||||
const value = ref(mappedStops[defaultStopIndex].usd)
|
||||
return { args, value, mappedStops, defaultStopIndex }
|
||||
},
|
||||
template:
|
||||
'<CreditSlider v-model="value" :stops="mappedStops" :default-stop-index="defaultStopIndex" :disabled="args.disabled" />'
|
||||
})
|
||||
}
|
||||
208
src/components/ui/credit-slider/CreditSlider.test.ts
Normal file
208
src/components/ui/credit-slider/CreditSlider.test.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { render, screen, within } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { usdToCredits } from '@/base/credits/comfyCredits'
|
||||
import { TEAM_PLAN_CREDIT_STOPS } from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
|
||||
|
||||
import CreditSlider from './CreditSlider.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
subscription: {
|
||||
usdPerMonth: 'USD / mo',
|
||||
billedYearly: '{total} Billed yearly',
|
||||
billedMonthly: 'Billed monthly',
|
||||
creditSliderSave: 'Save {percent}% ({amount})'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function renderSlider(props: Record<string, unknown> = {}) {
|
||||
return render(CreditSlider, { props, global: { plugins: [i18n] } })
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
describe('CreditSlider', () => {
|
||||
it('defaults to the $700 stop (index 2) when no value is bound', async () => {
|
||||
renderSlider()
|
||||
await flush()
|
||||
|
||||
const thumb = screen.getByRole('slider')
|
||||
expect(thumb).toHaveAttribute('aria-valuemin', '0')
|
||||
expect(thumb).toHaveAttribute('aria-valuemax', '4')
|
||||
expect(thumb).toHaveAttribute('aria-valuenow', '2')
|
||||
})
|
||||
|
||||
it('snaps to the next fixed stop on ArrowRight (never a value in between)', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onUpdate = vi.fn<(usd: number) => void>()
|
||||
|
||||
renderSlider({ modelValue: 700, 'onUpdate:modelValue': onUpdate })
|
||||
await flush()
|
||||
|
||||
screen.getByRole('slider').focus()
|
||||
await user.keyboard('{ArrowRight}')
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith(1400)
|
||||
})
|
||||
|
||||
it('snaps to the previous fixed stop on ArrowLeft', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onUpdate = vi.fn<(usd: number) => void>()
|
||||
|
||||
renderSlider({ modelValue: 700, 'onUpdate:modelValue': onUpdate })
|
||||
await flush()
|
||||
|
||||
screen.getByRole('slider').focus()
|
||||
await user.keyboard('{ArrowLeft}')
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith(400)
|
||||
})
|
||||
|
||||
it('emits change with the full {index, usd, credits} payload', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
|
||||
renderSlider({ modelValue: 700, onChange })
|
||||
await flush()
|
||||
|
||||
screen.getByRole('slider').focus()
|
||||
await user.keyboard('{ArrowRight}')
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
index: 3,
|
||||
usd: 1400,
|
||||
credits: 295_400
|
||||
})
|
||||
})
|
||||
|
||||
it('emits nothing when disabled (keyboard interaction suppressed)', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onUpdate = vi.fn<(usd: number) => void>()
|
||||
const onChange = vi.fn()
|
||||
|
||||
renderSlider({
|
||||
modelValue: 700,
|
||||
disabled: true,
|
||||
'onUpdate:modelValue': onUpdate,
|
||||
onChange
|
||||
})
|
||||
await flush()
|
||||
|
||||
screen.getByRole('slider').focus()
|
||||
await user.keyboard('{ArrowRight}')
|
||||
|
||||
expect(onUpdate).not.toHaveBeenCalled()
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows the discounted price, struck original, save badge and yearly total (DES-197)', async () => {
|
||||
renderSlider() // default $700 stop → 10% yearly discount
|
||||
await flush()
|
||||
|
||||
expect(screen.getByTestId('credit-slider-price')).toHaveTextContent('$630')
|
||||
expect(
|
||||
screen.getByTestId('credit-slider-original-price')
|
||||
).toHaveTextContent('$700')
|
||||
expect(screen.getByTestId('credit-slider-save')).toHaveTextContent(
|
||||
'Save 10% ($70)'
|
||||
)
|
||||
expect(screen.getByTestId('credit-slider-billed-yearly')).toHaveTextContent(
|
||||
'$7,560'
|
||||
)
|
||||
})
|
||||
|
||||
it('halves the discount and reads "billed monthly" when cycle=monthly (PRD)', async () => {
|
||||
renderSlider({ cycle: 'monthly' }) // default $700 stop → 10% yearly → 5% monthly
|
||||
await flush()
|
||||
|
||||
expect(screen.getByTestId('credit-slider-price')).toHaveTextContent('$665')
|
||||
expect(
|
||||
screen.getByTestId('credit-slider-original-price')
|
||||
).toHaveTextContent('$700')
|
||||
expect(screen.getByTestId('credit-slider-save')).toHaveTextContent(
|
||||
'Save 5% ($35)'
|
||||
)
|
||||
expect(screen.getByTestId('credit-slider-billed-yearly')).toHaveTextContent(
|
||||
'Billed monthly'
|
||||
)
|
||||
})
|
||||
|
||||
it('applies the fractional monthly discount at $400 (2.5%)', async () => {
|
||||
renderSlider({ modelValue: 400, cycle: 'monthly' })
|
||||
await flush()
|
||||
|
||||
expect(screen.getByTestId('credit-slider-price')).toHaveTextContent('$390')
|
||||
expect(screen.getByTestId('credit-slider-save')).toHaveTextContent(
|
||||
'Save 2.5% ($10)'
|
||||
)
|
||||
})
|
||||
|
||||
it('hides the discount UI at the 0% stop ($200)', async () => {
|
||||
renderSlider({ modelValue: 200 })
|
||||
await flush()
|
||||
|
||||
expect(screen.getByTestId('credit-slider-price')).toHaveTextContent('$200')
|
||||
expect(
|
||||
screen.queryByTestId('credit-slider-original-price')
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('credit-slider-save')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders all five fixed credit stop labels', async () => {
|
||||
renderSlider({ modelValue: 700 })
|
||||
await flush()
|
||||
|
||||
const stops = within(screen.getByTestId('credit-slider-stops'))
|
||||
for (const label of ['42.2K', '84.4K', '147.7K', '295.4K', '527.5K']) {
|
||||
expect(stops.getByText(label)).toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
|
||||
it('renders stops + default index supplied via props (BE-sourced override)', async () => {
|
||||
const stops = [
|
||||
{ usd: 50, credits: 10_550, discountPercentYearly: 0 },
|
||||
{ usd: 100, credits: 21_100, discountPercentYearly: 25 }
|
||||
]
|
||||
// No modelValue → the model default ($700) matches no stop, so selectedIndex
|
||||
// falls back to defaultStopIndex (here index 1 → $100).
|
||||
renderSlider({ stops, defaultStopIndex: 1 })
|
||||
await flush()
|
||||
|
||||
const thumb = screen.getByRole('slider')
|
||||
expect(thumb).toHaveAttribute('aria-valuemax', '1') // 2 stops → max index 1
|
||||
expect(thumb).toHaveAttribute('aria-valuenow', '1') // default index honored
|
||||
|
||||
// index 1 → $100 at 25% yearly → $75 discounted, struck $100, save $25
|
||||
expect(screen.getByTestId('credit-slider-price')).toHaveTextContent('$75')
|
||||
expect(
|
||||
screen.getByTestId('credit-slider-original-price')
|
||||
).toHaveTextContent('$100')
|
||||
expect(screen.getByTestId('credit-slider-save')).toHaveTextContent(
|
||||
'Save 25% ($25)'
|
||||
)
|
||||
|
||||
// Only the prop's labels render — none of the DES-197 defaults.
|
||||
const labels = within(screen.getByTestId('credit-slider-stops'))
|
||||
expect(labels.getByText('10.6K')).toBeInTheDocument()
|
||||
expect(labels.getByText('21.1K')).toBeInTheDocument()
|
||||
expect(labels.queryByText('147.7K')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps every credit amount equal to usdToCredits(usd) (guards rate drift)', () => {
|
||||
for (const stop of TEAM_PLAN_CREDIT_STOPS) {
|
||||
expect(stop.credits).toBe(usdToCredits(stop.usd))
|
||||
}
|
||||
})
|
||||
})
|
||||
235
src/components/ui/credit-slider/CreditSlider.vue
Normal file
235
src/components/ui/credit-slider/CreditSlider.vue
Normal file
@@ -0,0 +1,235 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
TransitionPresets,
|
||||
usePreferredReducedMotion,
|
||||
useTransition
|
||||
} from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
import {
|
||||
DEFAULT_TEAM_PLAN_STOP_INDEX,
|
||||
TEAM_PLAN_CREDIT_STOPS
|
||||
} from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
|
||||
import type { CreditStop } from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
|
||||
|
||||
const {
|
||||
disabled = false,
|
||||
class: rootClass,
|
||||
stops = TEAM_PLAN_CREDIT_STOPS,
|
||||
defaultStopIndex = DEFAULT_TEAM_PLAN_STOP_INDEX,
|
||||
cycle = 'yearly'
|
||||
} = defineProps<{
|
||||
disabled?: boolean
|
||||
class?: HTMLAttributes['class']
|
||||
/**
|
||||
* The fixed credit stops the slider snaps to. Must be non-empty. Defaults to
|
||||
* the hardcoded DES-197 set; pass the backend-sourced stops once the contract
|
||||
* lands — map `GET /api/billing/plans → team_credit_stops.stops` to
|
||||
* `CreditStop[]` (credits, the pre-discount `usd`, and `discountPercentYearly`).
|
||||
*/
|
||||
stops?: readonly CreditStop[]
|
||||
/**
|
||||
* Stop selected when the bound value matches none (e.g. first render).
|
||||
* Maps to `team_credit_stops.default_stop_index`. Defaults to DES-197 ($700).
|
||||
*/
|
||||
defaultStopIndex?: number
|
||||
/**
|
||||
* Billing cycle. Yearly applies the full `discountPercentYearly`; monthly
|
||||
* applies half of it (PRD: GA Team Billing — "for monthly the discount is
|
||||
* halved": yearly 0/5/10/15/20% → monthly 0/2.5/5/7.5/10%).
|
||||
*/
|
||||
cycle?: 'monthly' | 'yearly'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** Fired when the selected stop changes, with the full derived payload. */
|
||||
change: [stop: { index: number; usd: number; credits: number }]
|
||||
}>()
|
||||
|
||||
/**
|
||||
* v-model carries the selected USD value (one of the `stops`). The literal
|
||||
* default keeps `defineModel` statically analyzable; when custom `stops` are
|
||||
* passed without a matching v-model, `selectedIndex` falls back to
|
||||
* `defaultStopIndex`, so the displayed stop is still correct.
|
||||
*/
|
||||
const usd = defineModel<number>({
|
||||
default: TEAM_PLAN_CREDIT_STOPS[DEFAULT_TEAM_PLAN_STOP_INDEX].usd
|
||||
})
|
||||
|
||||
const selectedIndex = computed(() => {
|
||||
const i = stops.findIndex((stop) => stop.usd === usd.value)
|
||||
if (i !== -1) return i
|
||||
// Fall back to the default stop, clamped into range: a backend-driven `stops`
|
||||
// array can be shorter than expected (or `defaultStopIndex` out of bounds), so
|
||||
// clamping keeps `current` defined and the price computeds below from reading
|
||||
// `undefined.usd` at runtime. (`stops` is required to be non-empty.)
|
||||
return Math.min(Math.max(defaultStopIndex, 0), Math.max(stops.length - 1, 0))
|
||||
})
|
||||
|
||||
const current = computed<CreditStop>(() => stops[selectedIndex.value])
|
||||
|
||||
// The discount applies to the monthly figure. Yearly uses the full
|
||||
// `discountPercentYearly`; monthly halves it (PRD: GA Team Billing). The card
|
||||
// shows the discounted monthly price, the struck pre-discount price, the
|
||||
// saving, and — for yearly — the annual total.
|
||||
const effectiveDiscountPercent = computed(() =>
|
||||
cycle === 'monthly'
|
||||
? current.value.discountPercentYearly / 2
|
||||
: current.value.discountPercentYearly
|
||||
)
|
||||
const discountedMonthly = computed(() =>
|
||||
Math.round(current.value.usd * (1 - effectiveDiscountPercent.value / 100))
|
||||
)
|
||||
const saveAmount = computed(() => current.value.usd - discountedMonthly.value)
|
||||
const hasDiscount = computed(() => effectiveDiscountPercent.value > 0)
|
||||
|
||||
/**
|
||||
* Smoothly count the price figures up/down as the slider moves between stops
|
||||
* instead of snapping. Honors the user's reduced-motion preference. The save
|
||||
* badge ("X% ($Y)") is intentionally left snapping — its percent is a discrete
|
||||
* tier, so animating the bracketed amount alone would read inconsistently.
|
||||
*/
|
||||
const prefersReducedMotion = usePreferredReducedMotion()
|
||||
const priceTween = {
|
||||
duration: 350,
|
||||
easing: TransitionPresets.easeOutCubic,
|
||||
disabled: computed(() => prefersReducedMotion.value === 'reduce')
|
||||
}
|
||||
const animatedMonthly = useTransition(discountedMonthly, priceTween)
|
||||
const animatedOriginal = useTransition(() => current.value.usd, priceTween)
|
||||
|
||||
const displayMonthly = computed(() => Math.round(animatedMonthly.value))
|
||||
const displayOriginal = computed(() => Math.round(animatedOriginal.value))
|
||||
// Derive the yearly total from the displayed monthly so it always reads as
|
||||
// exactly 12× the price shown — even mid-count — rather than drifting as a
|
||||
// second, independently-phased tween would.
|
||||
const displayBilledYearly = computed(() => displayMonthly.value * 12)
|
||||
|
||||
/**
|
||||
* Bridge the discrete stop index (0..n-1) to the reka-ui slider's `number[]`
|
||||
* model. Driving the slider in index space with `step = 1` guarantees the
|
||||
* thumb can only land on the fixed stops — never a value in between.
|
||||
*/
|
||||
const sliderModel = computed<number[]>({
|
||||
get: () => [selectedIndex.value],
|
||||
set: ([index]) => {
|
||||
const stop = stops[index]
|
||||
if (!stop) return
|
||||
usd.value = stop.usd
|
||||
emit('change', { index, usd: stop.usd, credits: stop.credits })
|
||||
}
|
||||
})
|
||||
|
||||
const lastIndex = computed(() => Math.max(stops.length - 1, 0))
|
||||
|
||||
const formatUsd = (value: number) => `$${value.toLocaleString('en-US')}`
|
||||
const formatCreditsCompact = (value: number) =>
|
||||
new Intl.NumberFormat('en-US', {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1
|
||||
}).format(value)
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('flex w-full flex-col gap-3', rootClass)">
|
||||
<!-- Price: discounted monthly + struck pre-discount + save badge -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span class="flex shrink-0 items-baseline gap-1.5 whitespace-nowrap">
|
||||
<span
|
||||
class="text-[2rem]/none font-semibold text-base-foreground tabular-nums"
|
||||
data-testid="credit-slider-price"
|
||||
>
|
||||
{{ formatUsd(displayMonthly) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="hasDiscount"
|
||||
class="text-base text-muted-foreground tabular-nums line-through"
|
||||
data-testid="credit-slider-original-price"
|
||||
>
|
||||
{{ formatUsd(displayOriginal) }}
|
||||
</span>
|
||||
<span class="text-base text-muted-foreground">
|
||||
{{ t('subscription.usdPerMonth') }}
|
||||
</span>
|
||||
</span>
|
||||
<!-- Save badge: outlined primary pill. On wide layouts it's pushed to
|
||||
the right of the price; when the column narrows (mobile) it wraps
|
||||
and aligns left under the price instead (DES QA). -->
|
||||
<span
|
||||
v-if="hasDiscount"
|
||||
data-testid="credit-slider-save"
|
||||
class="shrink-0 rounded-full border-2 border-primary-background px-2 py-1 text-sm font-bold whitespace-nowrap text-primary-background xl:ms-auto"
|
||||
>
|
||||
{{
|
||||
t('subscription.creditSliderSave', {
|
||||
percent: effectiveDiscountPercent,
|
||||
amount: formatUsd(saveAmount)
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
class="m-0 text-sm text-muted-foreground tabular-nums"
|
||||
data-testid="credit-slider-billed-yearly"
|
||||
>
|
||||
{{
|
||||
cycle === 'monthly'
|
||||
? t('subscription.billedMonthly')
|
||||
: t('subscription.billedYearly', {
|
||||
total: formatUsd(displayBilledYearly)
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Discrete slider: snaps to the 5 fixed DES-197 stops -->
|
||||
<Slider
|
||||
v-model="sliderModel"
|
||||
:min="0"
|
||||
:max="lastIndex"
|
||||
:step="1"
|
||||
:disabled="disabled"
|
||||
range-class="bg-base-foreground"
|
||||
thumb-class="bg-base-foreground"
|
||||
/>
|
||||
|
||||
<!-- Credit stop labels; the selected stop is emphasized -->
|
||||
<ol
|
||||
data-testid="credit-slider-stops"
|
||||
class="m-0 flex list-none justify-between p-0"
|
||||
>
|
||||
<li
|
||||
v-for="(stop, i) in stops"
|
||||
:key="stop.usd"
|
||||
:data-selected="i === selectedIndex ? '' : undefined"
|
||||
:class="
|
||||
cn(
|
||||
'flex items-center gap-1 text-xs tabular-nums',
|
||||
i === selectedIndex
|
||||
? 'font-semibold text-base-foreground'
|
||||
: 'text-muted-foreground'
|
||||
)
|
||||
"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
'icon-[comfy--credits] size-3 shrink-0',
|
||||
i === selectedIndex ? 'bg-amber-400' : 'bg-muted-foreground'
|
||||
)
|
||||
"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ formatCreditsCompact(stop.credits) }}
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</template>
|
||||
@@ -15,7 +15,11 @@ import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const props = defineProps<
|
||||
// eslint-disable-next-line vue/no-unused-properties
|
||||
SliderRootProps & { class?: HTMLAttributes['class'] }
|
||||
SliderRootProps & {
|
||||
class?: HTMLAttributes['class']
|
||||
rangeClass?: HTMLAttributes['class']
|
||||
thumbClass?: HTMLAttributes['class']
|
||||
}
|
||||
>()
|
||||
|
||||
const pressed = ref(false)
|
||||
@@ -25,7 +29,7 @@ const setPressed = (val: boolean) => {
|
||||
|
||||
const emits = defineEmits<SliderRootEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
const delegatedProps = reactiveOmit(props, 'class', 'rangeClass', 'thumbClass')
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
@@ -60,7 +64,12 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
>
|
||||
<SliderRange
|
||||
data-slot="slider-range"
|
||||
class="absolute bg-node-component-surface-highlight data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
|
||||
:class="
|
||||
cn(
|
||||
'absolute bg-node-component-surface-highlight data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full',
|
||||
props.rangeClass
|
||||
)
|
||||
"
|
||||
/>
|
||||
</SliderTrack>
|
||||
|
||||
@@ -74,7 +83,8 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
'cursor-grab',
|
||||
'before:absolute before:-inset-1 before:block before:rounded-full before:bg-transparent',
|
||||
'hover:ring-2 focus-visible:ring-2 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50',
|
||||
{ 'cursor-grabbing': pressed }
|
||||
{ 'cursor-grabbing': pressed },
|
||||
props.thumbClass
|
||||
)
|
||||
"
|
||||
/>
|
||||
|
||||
@@ -5,11 +5,13 @@ import type {
|
||||
BillingStatus,
|
||||
BillingSubscriptionStatus,
|
||||
CreateTopupResponse,
|
||||
CurrentTeamCreditStop,
|
||||
Plan,
|
||||
PreviewSubscribeResponse,
|
||||
SubscribeResponse,
|
||||
SubscriptionDuration,
|
||||
SubscriptionTier
|
||||
SubscriptionTier,
|
||||
TeamCreditStops
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
export type BillingType = 'legacy' | 'workspace'
|
||||
@@ -71,6 +73,10 @@ export interface BillingState {
|
||||
balance: ComputedRef<BalanceInfo | null>
|
||||
plans: ComputedRef<Plan[]>
|
||||
currentPlanSlug: ComputedRef<string | null>
|
||||
/** Team per-credit pricing ladder; null for personal/legacy. */
|
||||
teamCreditStops: ComputedRef<TeamCreditStops | null>
|
||||
/** The team's currently-subscribed credit stop; null for personal/legacy. */
|
||||
currentTeamCreditStop: ComputedRef<CurrentTeamCreditStop | null>
|
||||
isLoading: Ref<boolean>
|
||||
error: Ref<string | null>
|
||||
isActiveSubscription: ComputedRef<boolean>
|
||||
@@ -83,5 +89,10 @@ export interface BillingState {
|
||||
|
||||
export interface BillingContext extends BillingState, BillingActions {
|
||||
type: ComputedRef<BillingType>
|
||||
/**
|
||||
* True when the active team workspace is still on a pre-credit-slider
|
||||
* (legacy) per-member tier plan, which keeps the old team pricing table.
|
||||
*/
|
||||
isLegacyTeamPlan: ComputedRef<boolean>
|
||||
getMaxSeats: (tierKey: TierKey) => number
|
||||
}
|
||||
|
||||
@@ -1,20 +1,39 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { Plan } from '@/platform/workspace/api/workspaceApi'
|
||||
import type {
|
||||
BillingStatusResponse,
|
||||
Plan
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import { useBillingContext } from './useBillingContext'
|
||||
|
||||
const DEFAULT_BILLING_STATUS: BillingStatusResponse = {
|
||||
is_active: true,
|
||||
has_funds: true,
|
||||
subscription_tier: 'PRO',
|
||||
subscription_duration: 'MONTHLY'
|
||||
}
|
||||
|
||||
const {
|
||||
mockTeamWorkspacesEnabled,
|
||||
mockIsPersonal,
|
||||
mockPlans,
|
||||
mockPurchaseCredits
|
||||
mockPurchaseCredits,
|
||||
mockBillingStatus
|
||||
} = vi.hoisted(() => ({
|
||||
mockTeamWorkspacesEnabled: { value: false },
|
||||
mockIsPersonal: { value: true },
|
||||
mockPlans: { value: [] as Plan[] },
|
||||
mockPurchaseCredits: vi.fn()
|
||||
mockPurchaseCredits: vi.fn(),
|
||||
mockBillingStatus: {
|
||||
value: {
|
||||
is_active: true,
|
||||
has_funds: true,
|
||||
subscription_tier: 'PRO',
|
||||
subscription_duration: 'MONTHLY'
|
||||
} as BillingStatusResponse
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', async (importOriginal) => {
|
||||
@@ -103,12 +122,7 @@ vi.mock('@/platform/cloud/subscription/composables/useBillingPlans', () => ({
|
||||
|
||||
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
|
||||
workspaceApi: {
|
||||
getBillingStatus: vi.fn().mockResolvedValue({
|
||||
is_active: true,
|
||||
has_funds: true,
|
||||
subscription_tier: 'PRO',
|
||||
subscription_duration: 'MONTHLY'
|
||||
}),
|
||||
getBillingStatus: vi.fn(() => Promise.resolve(mockBillingStatus.value)),
|
||||
getBillingBalance: vi.fn().mockResolvedValue({
|
||||
amount_micros: 10000000,
|
||||
currency: 'usd'
|
||||
@@ -125,6 +139,7 @@ describe('useBillingContext', () => {
|
||||
mockTeamWorkspacesEnabled.value = false
|
||||
mockIsPersonal.value = true
|
||||
mockPlans.value = []
|
||||
mockBillingStatus.value = { ...DEFAULT_BILLING_STATUS }
|
||||
})
|
||||
|
||||
it('returns legacy type for personal workspace', () => {
|
||||
@@ -252,4 +267,158 @@ describe('useBillingContext', () => {
|
||||
expect(getMaxSeats('creator')).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isLegacyTeamPlan', () => {
|
||||
it('is false for a personal workspace', () => {
|
||||
const { isLegacyTeamPlan } = useBillingContext()
|
||||
expect(isLegacyTeamPlan.value).toBe(false)
|
||||
})
|
||||
|
||||
it('is true for an active team plan: team- slug and no credit stop', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
mockBillingStatus.value = {
|
||||
is_active: true,
|
||||
has_funds: true,
|
||||
subscription_tier: 'STANDARD',
|
||||
subscription_duration: 'ANNUAL',
|
||||
plan_slug: 'team-standard-annual'
|
||||
}
|
||||
|
||||
const { initialize, isLegacyTeamPlan } = useBillingContext()
|
||||
await initialize()
|
||||
|
||||
expect(isLegacyTeamPlan.value).toBe(true)
|
||||
})
|
||||
|
||||
it('is true for any legacy team tier, not just standard', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
mockBillingStatus.value = {
|
||||
is_active: true,
|
||||
has_funds: true,
|
||||
subscription_tier: 'PRO',
|
||||
subscription_duration: 'ANNUAL',
|
||||
plan_slug: 'team-pro-annual'
|
||||
}
|
||||
|
||||
const { initialize, isLegacyTeamPlan } = useBillingContext()
|
||||
await initialize()
|
||||
|
||||
expect(isLegacyTeamPlan.value).toBe(true)
|
||||
})
|
||||
|
||||
it('is false for a new credit-slider team subscriber', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
// Real BE shape: underscore slug + populated credit stop. (subscription_tier
|
||||
// is 'TEAM' on the wire, not yet in the FE SubscriptionTier union, so it is
|
||||
// omitted here — the predicate does not depend on it.)
|
||||
mockBillingStatus.value = {
|
||||
is_active: true,
|
||||
has_funds: true,
|
||||
subscription_status: 'active',
|
||||
subscription_duration: 'ANNUAL',
|
||||
plan_slug: 'team_per_credit_annual',
|
||||
team_credit_stop: {
|
||||
id: 'team_700',
|
||||
credits_monthly: 147700,
|
||||
stop_usd: 700
|
||||
}
|
||||
}
|
||||
|
||||
const { initialize, isLegacyTeamPlan } = useBillingContext()
|
||||
await initialize()
|
||||
|
||||
expect(isLegacyTeamPlan.value).toBe(false)
|
||||
})
|
||||
|
||||
it('is false for a new team sub even before its credit stop is populated', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
// Provisioning lag: credit stop not yet attached. The underscore slug
|
||||
// (team_per_credit, not team-) must still exclude it from the legacy table.
|
||||
mockBillingStatus.value = {
|
||||
is_active: true,
|
||||
has_funds: true,
|
||||
subscription_status: 'active',
|
||||
subscription_duration: 'ANNUAL',
|
||||
plan_slug: 'team_per_credit_annual'
|
||||
}
|
||||
|
||||
const { initialize, isLegacyTeamPlan } = useBillingContext()
|
||||
await initialize()
|
||||
|
||||
expect(isLegacyTeamPlan.value).toBe(false)
|
||||
})
|
||||
|
||||
it('is false for a team workspace on a personal-tier plan', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
mockBillingStatus.value = {
|
||||
is_active: true,
|
||||
has_funds: true,
|
||||
subscription_tier: 'STANDARD',
|
||||
subscription_duration: 'ANNUAL',
|
||||
plan_slug: 'standard-annual'
|
||||
}
|
||||
|
||||
const { initialize, isLegacyTeamPlan } = useBillingContext()
|
||||
await initialize()
|
||||
|
||||
expect(isLegacyTeamPlan.value).toBe(false)
|
||||
})
|
||||
|
||||
it('stays true for a cancelled-but-still-active legacy team sub', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
mockBillingStatus.value = {
|
||||
is_active: true,
|
||||
has_funds: true,
|
||||
subscription_status: 'canceled',
|
||||
subscription_tier: 'STANDARD',
|
||||
subscription_duration: 'ANNUAL',
|
||||
plan_slug: 'team-standard-annual',
|
||||
cancel_at: '2099-01-01T00:00:00Z'
|
||||
}
|
||||
|
||||
const { initialize, isLegacyTeamPlan } = useBillingContext()
|
||||
await initialize()
|
||||
|
||||
expect(isLegacyTeamPlan.value).toBe(true)
|
||||
})
|
||||
|
||||
it('is false for a FREE-tier team even on a team- prefixed slug', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
mockBillingStatus.value = {
|
||||
is_active: true,
|
||||
has_funds: true,
|
||||
subscription_tier: 'FREE',
|
||||
plan_slug: 'team-free'
|
||||
}
|
||||
|
||||
const { initialize, isLegacyTeamPlan } = useBillingContext()
|
||||
await initialize()
|
||||
|
||||
expect(isLegacyTeamPlan.value).toBe(false)
|
||||
})
|
||||
|
||||
it('matches the legacy slug case-insensitively', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
mockBillingStatus.value = {
|
||||
is_active: true,
|
||||
has_funds: true,
|
||||
subscription_tier: 'STANDARD',
|
||||
subscription_duration: 'ANNUAL',
|
||||
plan_slug: 'Team-Standard-Annual'
|
||||
}
|
||||
|
||||
const { initialize, isLegacyTeamPlan } = useBillingContext()
|
||||
await initialize()
|
||||
|
||||
expect(isLegacyTeamPlan.value).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,6 +20,12 @@ import type {
|
||||
import { useLegacyBilling } from './useLegacyBilling'
|
||||
import { useWorkspaceBilling } from '@/platform/workspace/composables/useWorkspaceBilling'
|
||||
|
||||
// Legacy per-member team plans use a hyphenated `team-{tier}-{cycle}` slug; the
|
||||
// new credit-slider plan uses an underscore `team_per_credit_{cycle}` slug and
|
||||
// carries a team_credit_stop. The hyphen prefix alone separates the two, so a
|
||||
// new sub is never misrouted even before its credit stop is populated.
|
||||
const LEGACY_TEAM_PLAN_SLUG_PREFIX = 'team-'
|
||||
|
||||
/**
|
||||
* Unified billing context that automatically switches between legacy (user-scoped)
|
||||
* and workspace billing based on the active workspace type.
|
||||
@@ -116,12 +122,32 @@ function useBillingContextInternal(): BillingContext {
|
||||
toValue(activeContext.value.currentPlanSlug)
|
||||
)
|
||||
|
||||
const teamCreditStops = computed(() =>
|
||||
toValue(activeContext.value.teamCreditStops)
|
||||
)
|
||||
|
||||
const currentTeamCreditStop = computed(() =>
|
||||
toValue(activeContext.value.currentTeamCreditStop)
|
||||
)
|
||||
|
||||
const isActiveSubscription = computed(() =>
|
||||
toValue(activeContext.value.isActiveSubscription)
|
||||
)
|
||||
|
||||
const isFreeTier = computed(() => subscription.value?.tier === 'FREE')
|
||||
|
||||
const isLegacyTeamPlan = computed(
|
||||
() =>
|
||||
type.value === 'workspace' &&
|
||||
isActiveSubscription.value &&
|
||||
!isFreeTier.value &&
|
||||
currentTeamCreditStop.value === null &&
|
||||
(currentPlanSlug.value
|
||||
?.toLowerCase()
|
||||
.startsWith(LEGACY_TEAM_PLAN_SLUG_PREFIX) ??
|
||||
false)
|
||||
)
|
||||
|
||||
const billingStatus = computed(() =>
|
||||
toValue(activeContext.value.billingStatus)
|
||||
)
|
||||
@@ -254,10 +280,13 @@ function useBillingContextInternal(): BillingContext {
|
||||
balance,
|
||||
plans,
|
||||
currentPlanSlug,
|
||||
teamCreditStops,
|
||||
currentTeamCreditStop,
|
||||
isLoading,
|
||||
error,
|
||||
isActiveSubscription,
|
||||
isFreeTier,
|
||||
isLegacyTeamPlan,
|
||||
billingStatus,
|
||||
subscriptionStatus,
|
||||
tier,
|
||||
|
||||
@@ -93,6 +93,8 @@ export function useLegacyBilling(): BillingState & BillingActions {
|
||||
// Legacy billing doesn't have workspace-style plans
|
||||
const plans = computed(() => [])
|
||||
const currentPlanSlug = computed(() => null)
|
||||
const teamCreditStops = computed(() => null)
|
||||
const currentTeamCreditStop = computed(() => null)
|
||||
|
||||
async function initialize(): Promise<void> {
|
||||
if (isInitialized.value) return
|
||||
@@ -200,6 +202,8 @@ export function useLegacyBilling(): BillingState & BillingActions {
|
||||
balance,
|
||||
plans,
|
||||
currentPlanSlug,
|
||||
teamCreditStops,
|
||||
currentTeamCreditStop,
|
||||
isLoading,
|
||||
error,
|
||||
isActiveSubscription,
|
||||
|
||||
87
src/composables/node/startModelNodeDragFromAsset.test.ts
Normal file
87
src/composables/node/startModelNodeDragFromAsset.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { startModelNodeDragFromAsset } from '@/composables/node/startModelNodeDragFromAsset'
|
||||
|
||||
const { mockStartDrag, mockGetNodeProvider } = vi.hoisted(() => ({
|
||||
mockStartDrag: vi.fn(),
|
||||
mockGetNodeProvider: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/node/useNodeDragToCanvas', () => ({
|
||||
useNodeDragToCanvas: () => ({ startDrag: mockStartDrag })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/modelToNodeStore', () => ({
|
||||
useModelToNodeStore: () => ({ getNodeProvider: mockGetNodeProvider })
|
||||
}))
|
||||
|
||||
function createAsset(overrides: Partial<AssetItem> = {}): AssetItem {
|
||||
return {
|
||||
id: 'asset-123',
|
||||
name: 'sd_xl_base_1.0.safetensors',
|
||||
size: 1024,
|
||||
created_at: '2025-10-01T00:00:00Z',
|
||||
tags: ['models', 'checkpoints'],
|
||||
user_metadata: { filename: 'sd_xl_base_1.0.safetensors' },
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('startModelNodeDragFromAsset', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
it('starts a ghost drag for the resolved node carrying the widget value', () => {
|
||||
const nodeDef = { name: 'CheckpointLoaderSimple' }
|
||||
mockGetNodeProvider.mockReturnValue({ nodeDef, key: 'ckpt_name' })
|
||||
|
||||
const error = startModelNodeDragFromAsset(createAsset())
|
||||
|
||||
expect(error).toBeUndefined()
|
||||
expect(mockStartDrag).toHaveBeenCalledWith(nodeDef, {
|
||||
widgetValues: { ckpt_name: 'sd_xl_base_1.0.safetensors' },
|
||||
source: 'sidebar_drag'
|
||||
})
|
||||
})
|
||||
|
||||
it('threads the node-add source through to the drag', () => {
|
||||
const nodeDef = { name: 'CheckpointLoaderSimple' }
|
||||
mockGetNodeProvider.mockReturnValue({ nodeDef, key: 'ckpt_name' })
|
||||
|
||||
startModelNodeDragFromAsset(createAsset(), 'asset_browser')
|
||||
|
||||
expect(mockStartDrag).toHaveBeenCalledWith(nodeDef, {
|
||||
widgetValues: { ckpt_name: 'sd_xl_base_1.0.safetensors' },
|
||||
source: 'asset_browser'
|
||||
})
|
||||
})
|
||||
|
||||
it('carries no widget value when the provider has no key', () => {
|
||||
const nodeDef = { name: 'FL_ChatterboxVC' }
|
||||
mockGetNodeProvider.mockReturnValue({ nodeDef, key: '' })
|
||||
|
||||
startModelNodeDragFromAsset(
|
||||
createAsset({
|
||||
tags: ['models', 'chatterbox/chatterbox_vc'],
|
||||
user_metadata: { filename: 'chatterbox_vc_model.pt' }
|
||||
})
|
||||
)
|
||||
|
||||
expect(mockStartDrag).toHaveBeenCalledWith(nodeDef, {
|
||||
widgetValues: undefined,
|
||||
source: 'sidebar_drag'
|
||||
})
|
||||
})
|
||||
|
||||
it('returns the resolution error and does not start a drag for an invalid asset', () => {
|
||||
mockGetNodeProvider.mockReturnValue(null)
|
||||
|
||||
const error = startModelNodeDragFromAsset(createAsset())
|
||||
|
||||
expect(error?.code).toBe('NO_PROVIDER')
|
||||
expect(mockStartDrag).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
38
src/composables/node/startModelNodeDragFromAsset.ts
Normal file
38
src/composables/node/startModelNodeDragFromAsset.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { resolveModelNodeFromAsset } from '@/platform/assets/utils/resolveModelNodeFromAsset'
|
||||
import type { ResolveModelNodeError } from '@/platform/assets/utils/resolveModelNodeFromAsset'
|
||||
import type { NodeAddSource } from '@/platform/telemetry/types'
|
||||
import type { ModelNodeProvider } from '@/stores/modelToNodeStore'
|
||||
|
||||
/**
|
||||
* Arms a ghost drag for a model loader node. Providers with no widget key
|
||||
* (auto-load nodes) start the drag without widget values.
|
||||
*/
|
||||
export function startModelLoaderDrag(
|
||||
provider: ModelNodeProvider,
|
||||
filename: string,
|
||||
source: NodeAddSource = 'sidebar_drag'
|
||||
) {
|
||||
const widgetValues = provider.key ? { [provider.key]: filename } : undefined
|
||||
useNodeDragToCanvas().startDrag(provider.nodeDef, { widgetValues, source })
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a ghost drag for the model loader node described by an asset. The
|
||||
* node is created where the user next clicks the canvas, with the asset's
|
||||
* filename written into the loader widget.
|
||||
*
|
||||
* @returns the resolution error when the asset cannot be mapped to a node,
|
||||
* otherwise `undefined`.
|
||||
*/
|
||||
export function startModelNodeDragFromAsset(
|
||||
asset: AssetItem,
|
||||
source: NodeAddSource = 'sidebar_drag'
|
||||
): ResolveModelNodeError | undefined {
|
||||
const resolved = resolveModelNodeFromAsset(asset)
|
||||
if (!resolved.success) return resolved.error
|
||||
|
||||
const { provider, filename } = resolved.value
|
||||
startModelLoaderDrag(provider, filename, source)
|
||||
}
|
||||
@@ -7,7 +7,8 @@ const {
|
||||
mockAddNodeOnGraph,
|
||||
mockConvertEventToCanvasOffset,
|
||||
mockSelectItems,
|
||||
mockCanvas
|
||||
mockCanvas,
|
||||
mockToastAdd
|
||||
} = vi.hoisted(() => {
|
||||
const mockConvertEventToCanvasOffset = vi.fn()
|
||||
const mockSelectItems = vi.fn()
|
||||
@@ -15,6 +16,7 @@ const {
|
||||
mockAddNodeOnGraph: vi.fn(),
|
||||
mockConvertEventToCanvasOffset,
|
||||
mockSelectItems,
|
||||
mockToastAdd: vi.fn(),
|
||||
mockCanvas: {
|
||||
canvas: {
|
||||
getBoundingClientRect: vi.fn()
|
||||
@@ -37,6 +39,12 @@ vi.mock('@/services/litegraphService', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: vi.fn(() => ({ add: mockToastAdd }))
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({ t: (key: string) => key }))
|
||||
|
||||
describe('useNodeDragToCanvas', () => {
|
||||
let useNodeDragToCanvas: typeof UseNodeDragToCanvasType
|
||||
|
||||
@@ -54,8 +62,8 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
const { cleanupGlobalListeners } = useNodeDragToCanvas()
|
||||
cleanupGlobalListeners()
|
||||
const { cancelDrag } = useNodeDragToCanvas()
|
||||
cancelDrag()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
@@ -71,22 +79,6 @@ describe('useNodeDragToCanvas', () => {
|
||||
expect(isDragging.value).toBe(true)
|
||||
expect(draggedNode.value).toBe(mockNodeDef)
|
||||
})
|
||||
|
||||
it('should set dragMode to click by default', () => {
|
||||
const { dragMode, startDrag } = useNodeDragToCanvas()
|
||||
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
expect(dragMode.value).toBe('click')
|
||||
})
|
||||
|
||||
it('should set dragMode to native when specified', () => {
|
||||
const { dragMode, startDrag } = useNodeDragToCanvas()
|
||||
|
||||
startDrag(mockNodeDef, 'native')
|
||||
|
||||
expect(dragMode.value).toBe('native')
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancelDrag', () => {
|
||||
@@ -102,30 +94,15 @@ describe('useNodeDragToCanvas', () => {
|
||||
expect(isDragging.value).toBe(false)
|
||||
expect(draggedNode.value).toBeNull()
|
||||
})
|
||||
|
||||
it('should reset dragMode to click', () => {
|
||||
const { dragMode, startDrag, cancelDrag } = useNodeDragToCanvas()
|
||||
|
||||
startDrag(mockNodeDef, 'native')
|
||||
expect(dragMode.value).toBe('native')
|
||||
|
||||
cancelDrag()
|
||||
|
||||
expect(dragMode.value).toBe('click')
|
||||
})
|
||||
})
|
||||
|
||||
describe('setupGlobalListeners', () => {
|
||||
it('should add event listeners to document', () => {
|
||||
describe('drag listener lifecycle', () => {
|
||||
it('should attach document listeners on startDrag', () => {
|
||||
const addEventListenerSpy = vi.spyOn(document, 'addEventListener')
|
||||
const { setupGlobalListeners } = useNodeDragToCanvas()
|
||||
const { startDrag } = useNodeDragToCanvas()
|
||||
|
||||
setupGlobalListeners()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith(
|
||||
'pointermove',
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith(
|
||||
'pointerdown',
|
||||
expect.any(Function),
|
||||
@@ -142,35 +119,53 @@ describe('useNodeDragToCanvas', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should only setup listeners once', () => {
|
||||
it('should not attach drag listeners until a drag starts', () => {
|
||||
const addEventListenerSpy = vi.spyOn(document, 'addEventListener')
|
||||
const { setupGlobalListeners } = useNodeDragToCanvas()
|
||||
useNodeDragToCanvas()
|
||||
|
||||
setupGlobalListeners()
|
||||
expect(addEventListenerSpy).not.toHaveBeenCalledWith(
|
||||
'pointerup',
|
||||
expect.any(Function),
|
||||
true
|
||||
)
|
||||
expect(addEventListenerSpy).not.toHaveBeenCalledWith(
|
||||
'keydown',
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
|
||||
it('should detach document listeners on cancelDrag', () => {
|
||||
const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener')
|
||||
const { startDrag, cancelDrag } = useNodeDragToCanvas()
|
||||
|
||||
startDrag(mockNodeDef)
|
||||
cancelDrag()
|
||||
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith(
|
||||
'pointerdown',
|
||||
expect.any(Function),
|
||||
true
|
||||
)
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith(
|
||||
'pointerup',
|
||||
expect.any(Function),
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('should only attach listeners once across re-arms', () => {
|
||||
const addEventListenerSpy = vi.spyOn(document, 'addEventListener')
|
||||
const { startDrag } = useNodeDragToCanvas()
|
||||
|
||||
startDrag(mockNodeDef)
|
||||
const callCount = addEventListenerSpy.mock.calls.length
|
||||
|
||||
setupGlobalListeners()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
expect(addEventListenerSpy.mock.calls.length).toBe(callCount)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cursorPosition', () => {
|
||||
it('should update on pointermove', () => {
|
||||
const { cursorPosition, setupGlobalListeners } = useNodeDragToCanvas()
|
||||
|
||||
setupGlobalListeners()
|
||||
|
||||
const pointerEvent = new PointerEvent('pointermove', {
|
||||
clientX: 100,
|
||||
clientY: 200
|
||||
})
|
||||
document.dispatchEvent(pointerEvent)
|
||||
|
||||
expect(cursorPosition.value).toEqual({ x: 100, y: 200 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('endDrag behavior', () => {
|
||||
it('should add node when pointer is over canvas', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
@@ -181,9 +176,7 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
|
||||
|
||||
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
|
||||
|
||||
setupGlobalListeners()
|
||||
const { startDrag } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
const pointerEvent = new PointerEvent('pointerup', {
|
||||
@@ -206,10 +199,7 @@ describe('useNodeDragToCanvas', () => {
|
||||
bottom: 500
|
||||
})
|
||||
|
||||
const { startDrag, setupGlobalListeners, isDragging } =
|
||||
useNodeDragToCanvas()
|
||||
|
||||
setupGlobalListeners()
|
||||
const { startDrag, isDragging } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
const pointerEvent = new PointerEvent('pointerup', {
|
||||
@@ -224,10 +214,7 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
|
||||
it('should cancel drag on Escape key', () => {
|
||||
const { startDrag, setupGlobalListeners, isDragging } =
|
||||
useNodeDragToCanvas()
|
||||
|
||||
setupGlobalListeners()
|
||||
const { startDrag, isDragging } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
expect(isDragging.value).toBe(true)
|
||||
@@ -239,10 +226,7 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
|
||||
it('should not cancel drag on other keys', () => {
|
||||
const { startDrag, setupGlobalListeners, isDragging } =
|
||||
useNodeDragToCanvas()
|
||||
|
||||
setupGlobalListeners()
|
||||
const { startDrag, isDragging } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
const keyEvent = new KeyboardEvent('keydown', { key: 'Enter' })
|
||||
@@ -262,8 +246,7 @@ describe('useNodeDragToCanvas', () => {
|
||||
const placedNode = { id: 1 }
|
||||
mockAddNodeOnGraph.mockReturnValue(placedNode)
|
||||
|
||||
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
|
||||
setupGlobalListeners()
|
||||
const { startDrag } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
document.dispatchEvent(
|
||||
@@ -277,6 +260,102 @@ describe('useNodeDragToCanvas', () => {
|
||||
expect(mockSelectItems).toHaveBeenCalledWith([placedNode])
|
||||
})
|
||||
|
||||
it('should apply the requested widget values to the placed node', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 500
|
||||
})
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
|
||||
const widget = { name: 'ckpt_name', value: '' }
|
||||
mockAddNodeOnGraph.mockReturnValue({ id: 1, widgets: [widget] })
|
||||
|
||||
const { startDrag } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef, {
|
||||
widgetValues: { ckpt_name: 'model.safetensors' }
|
||||
})
|
||||
|
||||
document.dispatchEvent(
|
||||
new PointerEvent('pointerup', {
|
||||
clientX: 250,
|
||||
clientY: 250,
|
||||
bubbles: true
|
||||
})
|
||||
)
|
||||
|
||||
expect(widget.value).toBe('model.safetensors')
|
||||
})
|
||||
|
||||
it('should warn but still place the node when a requested widget is missing', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 500
|
||||
})
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
|
||||
const placedNode = { id: 1, widgets: [] }
|
||||
mockAddNodeOnGraph.mockReturnValue(placedNode)
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
const { startDrag } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef, {
|
||||
widgetValues: { ckpt_name: 'model.safetensors' }
|
||||
})
|
||||
|
||||
document.dispatchEvent(
|
||||
new PointerEvent('pointerup', {
|
||||
clientX: 250,
|
||||
clientY: 250,
|
||||
bubbles: true
|
||||
})
|
||||
)
|
||||
|
||||
expect(mockSelectItems).toHaveBeenCalledWith([placedNode])
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
severity: 'warn',
|
||||
detail: 'assetBrowser.failedToSetModelValue'
|
||||
})
|
||||
)
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('ckpt_name')
|
||||
)
|
||||
})
|
||||
|
||||
it('should show an error toast when the graph fails to add the node', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 500
|
||||
})
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
|
||||
mockAddNodeOnGraph.mockReturnValue(null)
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const { startDrag } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
document.dispatchEvent(
|
||||
new PointerEvent('pointerup', {
|
||||
clientX: 250,
|
||||
clientY: 250,
|
||||
bubbles: true
|
||||
})
|
||||
)
|
||||
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
detail: 'assetBrowser.failedToCreateNode'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should not call selectItems when graph returns no node', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
@@ -286,9 +365,9 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
|
||||
mockAddNodeOnGraph.mockReturnValue(null)
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
|
||||
setupGlobalListeners()
|
||||
const { startDrag } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
document.dispatchEvent(
|
||||
@@ -311,11 +390,8 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
|
||||
|
||||
const { startDrag, setupGlobalListeners, isDragging } =
|
||||
useNodeDragToCanvas()
|
||||
|
||||
setupGlobalListeners()
|
||||
startDrag(mockNodeDef, 'native')
|
||||
const { startDrag, isDragging } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef, { mode: 'native' })
|
||||
|
||||
const pointerEvent = new PointerEvent('pointerup', {
|
||||
clientX: 250,
|
||||
@@ -341,7 +417,7 @@ describe('useNodeDragToCanvas', () => {
|
||||
|
||||
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
|
||||
|
||||
startDrag(mockNodeDef, 'native')
|
||||
startDrag(mockNodeDef, { mode: 'native' })
|
||||
handleNativeDrop(250, 250)
|
||||
|
||||
expect(mockAddNodeOnGraph).toHaveBeenCalledWith(mockNodeDef, {
|
||||
@@ -359,7 +435,7 @@ describe('useNodeDragToCanvas', () => {
|
||||
|
||||
const { startDrag, handleNativeDrop, isDragging } = useNodeDragToCanvas()
|
||||
|
||||
startDrag(mockNodeDef, 'native')
|
||||
startDrag(mockNodeDef, { mode: 'native' })
|
||||
handleNativeDrop(600, 250)
|
||||
|
||||
expect(mockAddNodeOnGraph).not.toHaveBeenCalled()
|
||||
@@ -377,7 +453,7 @@ describe('useNodeDragToCanvas', () => {
|
||||
|
||||
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
|
||||
|
||||
startDrag(mockNodeDef, 'click')
|
||||
startDrag(mockNodeDef)
|
||||
handleNativeDrop(250, 250)
|
||||
|
||||
expect(mockAddNodeOnGraph).not.toHaveBeenCalled()
|
||||
@@ -392,14 +468,12 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([200, 200])
|
||||
|
||||
const { startDrag, handleNativeDrop, isDragging, dragMode } =
|
||||
useNodeDragToCanvas()
|
||||
const { startDrag, handleNativeDrop, isDragging } = useNodeDragToCanvas()
|
||||
|
||||
startDrag(mockNodeDef, 'native')
|
||||
startDrag(mockNodeDef, { mode: 'native' })
|
||||
handleNativeDrop(250, 250)
|
||||
|
||||
expect(isDragging.value).toBe(false)
|
||||
expect(dragMode.value).toBe('click')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -426,31 +500,29 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
|
||||
it('should stop propagation when in click-drag mode over canvas', () => {
|
||||
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
|
||||
setupGlobalListeners()
|
||||
const { startDrag } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
expect(dispatchPointerDown(250, 250)).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not stop propagation when not dragging', () => {
|
||||
const { setupGlobalListeners } = useNodeDragToCanvas()
|
||||
setupGlobalListeners()
|
||||
it('should not stop propagation once the drag is cancelled', () => {
|
||||
const { startDrag, cancelDrag } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef)
|
||||
cancelDrag()
|
||||
|
||||
expect(dispatchPointerDown(250, 250)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not stop propagation in native drag mode', () => {
|
||||
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
|
||||
setupGlobalListeners()
|
||||
startDrag(mockNodeDef, 'native')
|
||||
const { startDrag } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef, { mode: 'native' })
|
||||
|
||||
expect(dispatchPointerDown(250, 250)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not stop propagation when pointer is outside canvas', () => {
|
||||
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
|
||||
setupGlobalListeners()
|
||||
const { startDrag } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
expect(dispatchPointerDown(600, 250)).not.toHaveBeenCalled()
|
||||
@@ -477,10 +549,8 @@ describe('useNodeDragToCanvas', () => {
|
||||
}
|
||||
|
||||
it('should prefer tracked drag position over dragend coordinates', () => {
|
||||
const { startDrag, setupGlobalListeners, handleNativeDrop } =
|
||||
useNodeDragToCanvas()
|
||||
setupGlobalListeners()
|
||||
startDrag(mockNodeDef, 'native')
|
||||
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef, { mode: 'native' })
|
||||
|
||||
fireDrag(250, 250)
|
||||
// dragend supplies a bad position (the Firefox bug); the tracked one
|
||||
@@ -494,10 +564,8 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
|
||||
it('should ignore drag events with (0, 0)', () => {
|
||||
const { startDrag, setupGlobalListeners, handleNativeDrop } =
|
||||
useNodeDragToCanvas()
|
||||
setupGlobalListeners()
|
||||
startDrag(mockNodeDef, 'native')
|
||||
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef, { mode: 'native' })
|
||||
|
||||
fireDrag(250, 250)
|
||||
fireDrag(0, 0)
|
||||
@@ -510,10 +578,8 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
|
||||
it('should fall back to dragend coordinates when no drag fired', () => {
|
||||
const { startDrag, setupGlobalListeners, handleNativeDrop } =
|
||||
useNodeDragToCanvas()
|
||||
setupGlobalListeners()
|
||||
startDrag(mockNodeDef, 'native')
|
||||
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef, { mode: 'native' })
|
||||
|
||||
handleNativeDrop(250, 250)
|
||||
|
||||
@@ -523,32 +589,14 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should ignore dragover events fired before startDrag', () => {
|
||||
const { startDrag, setupGlobalListeners, handleNativeDrop } =
|
||||
useNodeDragToCanvas()
|
||||
setupGlobalListeners()
|
||||
|
||||
fireDrag(250, 250)
|
||||
startDrag(mockNodeDef, 'native')
|
||||
handleNativeDrop(300, 300)
|
||||
|
||||
expect(mockConvertEventToCanvasOffset).toHaveBeenCalledWith({
|
||||
clientX: 300,
|
||||
clientY: 300
|
||||
})
|
||||
})
|
||||
|
||||
it('should clear tracked position between drags', () => {
|
||||
const { startDrag, setupGlobalListeners, handleNativeDrop } =
|
||||
useNodeDragToCanvas()
|
||||
setupGlobalListeners()
|
||||
|
||||
startDrag(mockNodeDef, 'native')
|
||||
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef, { mode: 'native' })
|
||||
fireDrag(250, 250)
|
||||
handleNativeDrop(1505, 102)
|
||||
|
||||
// Second drag - no drag events, so we should fall back to args.
|
||||
startDrag(mockNodeDef, 'native')
|
||||
startDrag(mockNodeDef, { mode: 'native' })
|
||||
handleNativeDrop(300, 300)
|
||||
|
||||
expect(mockConvertEventToCanvasOffset).toHaveBeenLastCalledWith({
|
||||
|
||||
@@ -1,23 +1,32 @@
|
||||
import { ref, shallowRef } from 'vue'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
|
||||
import type { NodeAddSource } from '@/platform/telemetry/types'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
|
||||
type DragMode = 'click' | 'native'
|
||||
type WidgetValues = Record<string, string>
|
||||
type Position = { x: number; y: number }
|
||||
|
||||
interface StartDragOptions {
|
||||
mode?: DragMode
|
||||
widgetValues?: WidgetValues
|
||||
source?: NodeAddSource
|
||||
}
|
||||
|
||||
const isDragging = ref(false)
|
||||
const draggedNode = shallowRef<ComfyNodeDefImpl | null>(null)
|
||||
const cursorPosition = ref({ x: 0, y: 0 })
|
||||
const dragMode = ref<DragMode>('click')
|
||||
const lastNativeDragPosition = shallowRef<{ x: number; y: number }>()
|
||||
const lastNativeDragPosition = shallowRef<Position>()
|
||||
const pendingWidgetValues = shallowRef<WidgetValues>()
|
||||
const pendingSource = ref<NodeAddSource>('sidebar_drag')
|
||||
let listenersSetup = false
|
||||
|
||||
function updatePosition(e: PointerEvent) {
|
||||
cursorPosition.value = { x: e.clientX, y: e.clientY }
|
||||
}
|
||||
|
||||
// Firefox dragend can report stale clientX/Y and `drag` can fire with
|
||||
// (0, 0). dragover on the target reliably reports real client coords.
|
||||
// https://bugzilla.mozilla.org/show_bug.cgi?id=1773886
|
||||
@@ -27,11 +36,20 @@ function trackNativeDragPosition(e: DragEvent) {
|
||||
lastNativeDragPosition.value = { x: e.clientX, y: e.clientY }
|
||||
}
|
||||
|
||||
function cancelDrag() {
|
||||
isDragging.value = false
|
||||
draggedNode.value = null
|
||||
dragMode.value = 'click'
|
||||
lastNativeDragPosition.value = undefined
|
||||
function applyWidgetValues(node: LGraphNode, values: WidgetValues) {
|
||||
for (const [name, value] of Object.entries(values)) {
|
||||
const widget = node.widgets?.find((w) => w.name === name)
|
||||
if (!widget) {
|
||||
console.error(`Widget ${name} not found on node ${node.type}`)
|
||||
useToastStore().add({
|
||||
severity: 'warn',
|
||||
summary: t('g.warning'),
|
||||
detail: t('assetBrowser.failedToSetModelValue')
|
||||
})
|
||||
continue
|
||||
}
|
||||
widget.value = value
|
||||
}
|
||||
}
|
||||
|
||||
function isOverCanvas(clientX: number, clientY: number): boolean {
|
||||
@@ -59,10 +77,22 @@ function addNodeAtPosition(clientX: number, clientY: number): boolean {
|
||||
clientX,
|
||||
clientY
|
||||
} as PointerEvent)
|
||||
const node = withNodeAddSource('sidebar_drag', () =>
|
||||
const node = withNodeAddSource(pendingSource.value, () =>
|
||||
useLitegraphService().addNodeOnGraph(nodeDef, { pos })
|
||||
)
|
||||
if (node) canvas.selectItems([node])
|
||||
if (!node) {
|
||||
console.error(`Failed to add node to graph: ${nodeDef.name}`)
|
||||
useToastStore().add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('assetBrowser.failedToCreateNode')
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
if (pendingWidgetValues.value)
|
||||
applyWidgetValues(node, pendingWidgetValues.value)
|
||||
canvas.selectItems([node])
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -92,7 +122,6 @@ function setupGlobalListeners() {
|
||||
if (listenersSetup) return
|
||||
listenersSetup = true
|
||||
|
||||
document.addEventListener('pointermove', updatePosition)
|
||||
document.addEventListener('pointerdown', blockCommitPointerDown, true)
|
||||
document.addEventListener('pointerup', endDrag, true)
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
@@ -103,22 +132,37 @@ function cleanupGlobalListeners() {
|
||||
if (!listenersSetup) return
|
||||
listenersSetup = false
|
||||
|
||||
document.removeEventListener('pointermove', updatePosition)
|
||||
document.removeEventListener('pointerdown', blockCommitPointerDown, true)
|
||||
document.removeEventListener('pointerup', endDrag, true)
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
document.removeEventListener('dragover', trackNativeDragPosition)
|
||||
}
|
||||
|
||||
if (isDragging.value && dragMode.value === 'click') {
|
||||
cancelDrag()
|
||||
}
|
||||
function cancelDrag() {
|
||||
isDragging.value = false
|
||||
draggedNode.value = null
|
||||
dragMode.value = 'click'
|
||||
lastNativeDragPosition.value = undefined
|
||||
pendingWidgetValues.value = undefined
|
||||
pendingSource.value = 'sidebar_drag'
|
||||
cleanupGlobalListeners()
|
||||
}
|
||||
|
||||
export function useNodeDragToCanvas() {
|
||||
function startDrag(nodeDef: ComfyNodeDefImpl, mode: DragMode = 'click') {
|
||||
function startDrag(
|
||||
nodeDef: ComfyNodeDefImpl,
|
||||
{
|
||||
mode = 'click',
|
||||
widgetValues,
|
||||
source = 'sidebar_drag'
|
||||
}: StartDragOptions = {}
|
||||
) {
|
||||
isDragging.value = true
|
||||
draggedNode.value = nodeDef
|
||||
dragMode.value = mode
|
||||
pendingWidgetValues.value = widgetValues
|
||||
pendingSource.value = source
|
||||
setupGlobalListeners()
|
||||
}
|
||||
|
||||
function handleNativeDrop(clientX: number, clientY: number) {
|
||||
@@ -134,12 +178,9 @@ export function useNodeDragToCanvas() {
|
||||
return {
|
||||
isDragging,
|
||||
draggedNode,
|
||||
cursorPosition,
|
||||
dragMode,
|
||||
pendingWidgetValues,
|
||||
startDrag,
|
||||
cancelDrag,
|
||||
handleNativeDrop,
|
||||
setupGlobalListeners,
|
||||
cleanupGlobalListeners
|
||||
handleNativeDrop
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,7 +124,9 @@ describe('useNodePreviewAndDrag', () => {
|
||||
|
||||
expect(result.isDragging.value).toBe(true)
|
||||
expect(result.isHovered.value).toBe(false)
|
||||
expect(mockStartDrag).toHaveBeenCalledWith(mockNodeDef, 'native')
|
||||
expect(mockStartDrag).toHaveBeenCalledWith(mockNodeDef, {
|
||||
mode: 'native'
|
||||
})
|
||||
expect(mockDataTransfer.effectAllowed).toBe('copy')
|
||||
expect(mockDataTransfer.setData).toHaveBeenCalledWith(
|
||||
'application/x-comfy-node',
|
||||
|
||||
@@ -125,7 +125,7 @@ export function useNodePreviewAndDrag(
|
||||
isDragging.value = true
|
||||
isHovered.value = false
|
||||
|
||||
startDrag(nodeDef.value, 'native')
|
||||
startDrag(nodeDef.value, { mode: 'native' })
|
||||
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = 'copy'
|
||||
|
||||
@@ -5,11 +5,13 @@ import { ref } from 'vue'
|
||||
import { useCoreCommands } from '@/composables/useCoreCommands'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import type * as ModelStoreModule from '@/stores/modelStore'
|
||||
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
|
||||
// Mock vue-i18n for useExternalLink
|
||||
const mockLocale = ref('en')
|
||||
@@ -135,6 +137,23 @@ vi.mock('@/stores/toastStore', () => ({
|
||||
useToastStore: vi.fn(() => ({}))
|
||||
}))
|
||||
|
||||
const mockToastAdd = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: vi.fn(() => ({ add: mockToastAdd }))
|
||||
}))
|
||||
|
||||
const mockAssetBrowse = vi.hoisted(() =>
|
||||
vi.fn<(options: { onAssetSelected?: (asset: AssetItem) => void }) => void>()
|
||||
)
|
||||
vi.mock('@/platform/assets/composables/useAssetBrowserDialog', () => ({
|
||||
useAssetBrowserDialog: vi.fn(() => ({ browse: mockAssetBrowse }))
|
||||
}))
|
||||
|
||||
const mockStartModelNodeDrag = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/composables/node/startModelNodeDragFromAsset', () => ({
|
||||
startModelNodeDragFromAsset: mockStartModelNodeDrag
|
||||
}))
|
||||
|
||||
const mockChangeTracker = vi.hoisted(() => ({
|
||||
captureCanvasState: vi.fn()
|
||||
}))
|
||||
@@ -618,4 +637,47 @@ describe('useCoreCommands', () => {
|
||||
expect(mockShowAbout).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('BrowseModelAssets command', () => {
|
||||
const asset = fromPartial<AssetItem>({ id: 'asset-1' })
|
||||
|
||||
async function selectAssetFromBrowser() {
|
||||
vi.mocked(useSettingStore).mockReturnValue(createMockSettingStore(true))
|
||||
|
||||
const command = useCoreCommands().find(
|
||||
(cmd) => cmd.id === 'Comfy.BrowseModelAssets'
|
||||
)!
|
||||
await command.function()
|
||||
|
||||
const { onAssetSelected } = mockAssetBrowse.mock.calls[0][0]
|
||||
onAssetSelected?.(asset)
|
||||
}
|
||||
|
||||
it('starts a model node drag for the selected asset', async () => {
|
||||
mockStartModelNodeDrag.mockReturnValue(undefined)
|
||||
|
||||
await selectAssetFromBrowser()
|
||||
|
||||
expect(mockStartModelNodeDrag).toHaveBeenCalledWith(
|
||||
asset,
|
||||
'asset_browser'
|
||||
)
|
||||
expect(mockToastAdd).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows an error toast when the asset cannot start a drag', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
mockStartModelNodeDrag.mockReturnValue({
|
||||
code: 'NO_PROVIDER',
|
||||
message: 'No node provider registered',
|
||||
assetId: 'asset-1'
|
||||
})
|
||||
|
||||
await selectAssetFromBrowser()
|
||||
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'error' })
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
|
||||
import { useSubgraphOperations } from '@/composables/graph/useSubgraphOperations'
|
||||
import { startModelNodeDragFromAsset } from '@/composables/node/startModelNodeDragFromAsset'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useModelSelectorDialog } from '@/composables/useModelSelectorDialog'
|
||||
import { useRunButtonTelemetry } from '@/composables/useRunButtonTelemetry'
|
||||
@@ -21,7 +22,6 @@ import {
|
||||
import type { Point } from '@/lib/litegraph/src/litegraph'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
|
||||
import { createModelNodeFromAsset } from '@/platform/assets/utils/createModelNodeFromAsset'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { buildSupportUrl } from '@/platform/support/config'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
@@ -1307,14 +1307,14 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
assetType: 'models',
|
||||
title: t('sideToolbar.modelLibrary'),
|
||||
onAssetSelected: (asset) => {
|
||||
const result = createModelNodeFromAsset(asset)
|
||||
if (!result.success) {
|
||||
const error = startModelNodeDragFromAsset(asset, 'asset_browser')
|
||||
if (error) {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('assetBrowser.failedToCreateNode')
|
||||
})
|
||||
console.error('Node creation failed:', result.error)
|
||||
console.error('Node creation failed:', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -2529,13 +2529,17 @@
|
||||
"name": "Founder's Edition"
|
||||
},
|
||||
"standard": {
|
||||
"name": "Standard"
|
||||
"name": "Standard",
|
||||
"feature1": "30 minute max workflow runtime",
|
||||
"feature2": "Add more credits anytime"
|
||||
},
|
||||
"creator": {
|
||||
"name": "Creator"
|
||||
"name": "Creator",
|
||||
"feature1": "Import your own models"
|
||||
},
|
||||
"pro": {
|
||||
"name": "Pro"
|
||||
"name": "Pro",
|
||||
"feature1": "Longer workflow runtime (up to 1 hr)"
|
||||
}
|
||||
},
|
||||
"required": {
|
||||
@@ -2563,10 +2567,52 @@
|
||||
"subscriptionRequiredMessage": "A subscription is required for members to run workflows on Cloud and invite members",
|
||||
"contactOwnerToSubscribe": "Contact the workspace owner to subscribe",
|
||||
"description": "Choose the best plan for you",
|
||||
"descriptionWorkspace": "Choose the best plan for your workspace",
|
||||
"descriptionWorkspace": "Choose a Plan",
|
||||
"haveQuestions": "Have questions or wondering about enterprise?",
|
||||
"contactUs": "Contact us",
|
||||
"viewEnterprise": "View enterprise",
|
||||
"planScope": {
|
||||
"personal": "For Personal",
|
||||
"team": "For Teams"
|
||||
},
|
||||
"teamHeader": "For teams wanting to collaborate. Need more members? {learnMore} about enterprise.",
|
||||
"teamHeaderLearnMore": "Learn more",
|
||||
"personalHeader": "Personal plans are for individual use only. {action}",
|
||||
"personalHeaderAction": "To add teammates, subscribe to the team plan.",
|
||||
"whatsIncluded": "What's included:",
|
||||
"everythingInPlus": "Everything in {plan}, plus:",
|
||||
"monthlyCredits": "monthly credits",
|
||||
"videoEstimate": "Generates ~{count} 5s videos*",
|
||||
"saveYearly": "Save 20%",
|
||||
"saveYearlyUpTo": "Save up to 20%",
|
||||
"teamPlan": {
|
||||
"name": "Team Plan",
|
||||
"tagline": "Choose your own monthly credit subscription. Get a larger discount with a larger credit subscription.",
|
||||
"detailsTitle": "Details",
|
||||
"perkInviteMembers": "Invite team members",
|
||||
"perkConcurrentRuns": "Members can run workflows concurrently",
|
||||
"perkSharedPool": "Shared credit pool for all members",
|
||||
"perkRolePermissions": "Role-based permissions",
|
||||
"comingSoonLabel": "Coming soon:",
|
||||
"perkProjectAssets": "Project & asset management",
|
||||
"cta": "Subscribe to Team Yearly",
|
||||
"ctaMonthly": "Subscribe to Team Monthly",
|
||||
"changePlan": "Change plan",
|
||||
"currentPlan": "Current plan",
|
||||
"checkoutComingSoon": "Team plan checkout is coming soon."
|
||||
},
|
||||
"enterprise": {
|
||||
"name": "Enterprise",
|
||||
"needMoreMembers": "Need more members?",
|
||||
"flexibility": "Looking for more flexibility or custom features?",
|
||||
"reachOut": "Reach out to us and let's schedule a chat.",
|
||||
"cta": "Learn more"
|
||||
},
|
||||
"pricingBlurb": "*Based on this template, {seeDetails}. Contact us for {questions} or {enterpriseDiscussions}. For more pricing details, {clickHere}.",
|
||||
"pricingBlurbSeeDetails": "see details",
|
||||
"pricingBlurbQuestions": "questions",
|
||||
"pricingBlurbEnterprise": "enterprise discussions",
|
||||
"pricingBlurbClickHere": "click here",
|
||||
"freeTier": {
|
||||
"title": "You're on the Free plan",
|
||||
"description": "Your free plan includes {credits} credits each month to try Comfy Cloud.",
|
||||
@@ -2626,6 +2672,9 @@
|
||||
"starting": "Starting {date}",
|
||||
"ends": "Ends {date}",
|
||||
"eachMonthCreditsRefill": "Each month credits refill to",
|
||||
"everyMonthStarting": "Every month starting {date}",
|
||||
"creditsRefillTo": "Credits refill to",
|
||||
"youllBeCharged": "You'll be charged",
|
||||
"perMember": "/ member",
|
||||
"showMoreFeatures": "Show more features",
|
||||
"hideFeatures": "Hide features",
|
||||
@@ -2638,7 +2687,14 @@
|
||||
"privacyPolicy": "Privacy Policy",
|
||||
"addCreditCard": "Add credit card",
|
||||
"confirm": "Confirm",
|
||||
"subscribeToPlan": "Subscribe to {plan}",
|
||||
"switchToPlan": "Switch to {plan}",
|
||||
"backToAllPlans": "Back to all plans"
|
||||
},
|
||||
"success": {
|
||||
"allSet": "You're all set",
|
||||
"planUpdated": "Your plan has been successfully updated.",
|
||||
"receiptEmailed": "A receipt has been emailed to you."
|
||||
}
|
||||
},
|
||||
"userSettings": {
|
||||
@@ -3121,6 +3177,7 @@
|
||||
"invalidFilename": "Invalid Filename",
|
||||
"invalidFilenameDetail": "The asset filename could not be determined. Please try again.",
|
||||
"failedToCreateNode": "Failed to create node. Please try again or check console for details.",
|
||||
"failedToSetModelValue": "Node added, but its model could not be set automatically. Check the console for details.",
|
||||
"fileFormats": "File formats",
|
||||
"fileName": "File Name",
|
||||
"fileSize": "File Size",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* input key where the model name is inserted.
|
||||
*
|
||||
* An empty key ('') means the node auto-loads models without a widget
|
||||
* selector (createModelNodeFromAsset skips widget assignment).
|
||||
* selector, so no widget value is assigned when the node is added.
|
||||
*
|
||||
* Hierarchical fallback is handled by the store: "a/b/c" tries
|
||||
* "a/b/c" → "a/b" → "a", so registering a parent directory covers
|
||||
|
||||
@@ -1,439 +0,0 @@
|
||||
// oxlint-disable no-misused-spread
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { markRaw } from 'vue'
|
||||
import type { Raw } from 'vue'
|
||||
|
||||
import type { LGraphNode, Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type * as LitegraphModule from '@/lib/litegraph/src/litegraph'
|
||||
import type * as ModelToNodeStoreModule from '@/stores/modelToNodeStore'
|
||||
import type * as WorkflowStoreModule from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type * as LitegraphServiceModule from '@/services/litegraphService'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { createModelNodeFromAsset } from '@/platform/assets/utils/createModelNodeFromAsset'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
apiURL: vi.fn((path: string) => `http://localhost:8188${path}`)
|
||||
}
|
||||
}))
|
||||
vi.mock('@/stores/modelToNodeStore', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof ModelToNodeStoreModule>()
|
||||
return {
|
||||
...actual,
|
||||
useModelToNodeStore: vi.fn()
|
||||
}
|
||||
})
|
||||
vi.mock(
|
||||
'@/platform/workflow/management/stores/workflowStore',
|
||||
async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof WorkflowStoreModule>()
|
||||
return {
|
||||
...actual,
|
||||
useWorkflowStore: vi.fn()
|
||||
}
|
||||
}
|
||||
)
|
||||
vi.mock('@/services/litegraphService', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof LitegraphServiceModule>()
|
||||
return {
|
||||
...actual,
|
||||
useLitegraphService: vi.fn()
|
||||
}
|
||||
})
|
||||
vi.mock('@/lib/litegraph/src/litegraph', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof LitegraphModule>()
|
||||
return {
|
||||
...actual,
|
||||
LiteGraph: {
|
||||
...actual.LiteGraph,
|
||||
createNode: vi.fn()
|
||||
}
|
||||
}
|
||||
})
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
canvas: {
|
||||
graph: {
|
||||
add: vi.fn()
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
function createMockAsset(overrides: Partial<AssetItem> = {}): AssetItem {
|
||||
return {
|
||||
id: 'asset-123',
|
||||
name: 'test-model.safetensors',
|
||||
size: 1024,
|
||||
created_at: '2025-10-01T00:00:00Z',
|
||||
tags: ['models', 'checkpoints'],
|
||||
user_metadata: {
|
||||
filename: 'models/checkpoints/test-model.safetensors'
|
||||
},
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
async function createMockNode(overrides?: {
|
||||
widgetName?: string
|
||||
widgetValue?: string
|
||||
hasWidgets?: boolean
|
||||
}): Promise<LGraphNode> {
|
||||
const {
|
||||
widgetName = 'ckpt_name',
|
||||
widgetValue = '',
|
||||
hasWidgets = true
|
||||
} = overrides || {}
|
||||
|
||||
const { LGraphNode: ActualLGraphNode } = await vi.importActual<
|
||||
typeof LitegraphModule
|
||||
>('@/lib/litegraph/src/litegraph')
|
||||
|
||||
if (!hasWidgets) {
|
||||
return Object.create(ActualLGraphNode.prototype)
|
||||
}
|
||||
|
||||
type Widget = NonNullable<LGraphNode['widgets']>[number]
|
||||
const widget: Pick<Widget, 'name' | 'value' | 'type' | 'options' | 'y'> = {
|
||||
name: widgetName,
|
||||
value: widgetValue,
|
||||
type: 'string',
|
||||
options: {},
|
||||
y: 0
|
||||
}
|
||||
|
||||
return Object.create(ActualLGraphNode.prototype, {
|
||||
widgets: { value: [widget], writable: true }
|
||||
})
|
||||
}
|
||||
function createMockNodeProvider(
|
||||
overrides: {
|
||||
nodeDef?: { name: string; display_name: string }
|
||||
key?: string
|
||||
} = {}
|
||||
) {
|
||||
return {
|
||||
nodeDef: {
|
||||
name: 'CheckpointLoaderSimple',
|
||||
display_name: 'Load Checkpoint',
|
||||
...overrides.nodeDef
|
||||
},
|
||||
key: overrides.key ?? 'ckpt_name'
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Configures all mocked dependencies with sensible defaults.
|
||||
* Uses semantic parameters for clearer test intent.
|
||||
* For error paths or edge cases, pass null values or specific overrides.
|
||||
*/
|
||||
async function setupMocks(
|
||||
overrides: {
|
||||
nodeProvider?: ReturnType<typeof createMockNodeProvider> | null
|
||||
canvasCenter?: [number, number]
|
||||
activeSubgraph?: Raw<Subgraph>
|
||||
createdNode?: Awaited<ReturnType<typeof createMockNode>> | null
|
||||
} = {}
|
||||
) {
|
||||
const {
|
||||
nodeProvider = createMockNodeProvider(),
|
||||
canvasCenter = [100, 200],
|
||||
activeSubgraph,
|
||||
createdNode = await createMockNode()
|
||||
} = overrides
|
||||
|
||||
vi.mocked(useModelToNodeStore).mockReturnValue({
|
||||
...useModelToNodeStore(),
|
||||
getNodeProvider: vi.fn().mockReturnValue(nodeProvider)
|
||||
})
|
||||
|
||||
vi.mocked(useLitegraphService).mockReturnValue({
|
||||
...useLitegraphService(),
|
||||
getCanvasCenter: vi.fn().mockReturnValue(canvasCenter)
|
||||
})
|
||||
|
||||
vi.mocked(useWorkflowStore).mockReturnValue({
|
||||
...useWorkflowStore(),
|
||||
activeSubgraph,
|
||||
isSubgraphActive: !!activeSubgraph
|
||||
})
|
||||
vi.mocked(LiteGraph.createNode).mockReturnValue(createdNode)
|
||||
}
|
||||
describe('createModelNodeFromAsset', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
})
|
||||
describe('when creating nodes from valid assets', () => {
|
||||
it('should create the appropriate loader node for the asset category', async () => {
|
||||
const asset = createMockAsset()
|
||||
await setupMocks()
|
||||
const result = createModelNodeFromAsset(asset)
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(
|
||||
vi.mocked(useModelToNodeStore)().getNodeProvider
|
||||
).toHaveBeenCalledWith('checkpoints')
|
||||
expect(LiteGraph.createNode).toHaveBeenCalledWith(
|
||||
'CheckpointLoaderSimple',
|
||||
'Load Checkpoint',
|
||||
{ pos: [100, 200] }
|
||||
)
|
||||
}
|
||||
})
|
||||
it('should place node at canvas center by default', async () => {
|
||||
const asset = createMockAsset()
|
||||
await setupMocks({
|
||||
canvasCenter: [150, 250]
|
||||
})
|
||||
const result = createModelNodeFromAsset(asset)
|
||||
expect(result.success).toBe(true)
|
||||
expect(
|
||||
vi.mocked(useLitegraphService)().getCanvasCenter
|
||||
).toHaveBeenCalled()
|
||||
expect(LiteGraph.createNode).toHaveBeenCalledWith(
|
||||
'CheckpointLoaderSimple',
|
||||
'Load Checkpoint',
|
||||
{ pos: [150, 250] }
|
||||
)
|
||||
})
|
||||
it('should place node at specified position when position is provided', async () => {
|
||||
const asset = createMockAsset()
|
||||
await setupMocks()
|
||||
const result = createModelNodeFromAsset(asset, { position: [300, 400] })
|
||||
expect(result.success).toBe(true)
|
||||
expect(
|
||||
vi.mocked(useLitegraphService)().getCanvasCenter
|
||||
).not.toHaveBeenCalled()
|
||||
expect(LiteGraph.createNode).toHaveBeenCalledWith(
|
||||
'CheckpointLoaderSimple',
|
||||
'Load Checkpoint',
|
||||
{ pos: [300, 400] }
|
||||
)
|
||||
})
|
||||
it('should populate the loader widget with the asset file path', async () => {
|
||||
const asset = createMockAsset()
|
||||
const mockNode = await createMockNode()
|
||||
await setupMocks({ createdNode: mockNode })
|
||||
const result = createModelNodeFromAsset(asset)
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockNode.widgets?.[0].value).toBe(
|
||||
'models/checkpoints/test-model.safetensors'
|
||||
)
|
||||
})
|
||||
it('should add node to root graph when no subgraph is active', async () => {
|
||||
const asset = createMockAsset()
|
||||
const mockNode = await createMockNode()
|
||||
await setupMocks({ createdNode: mockNode })
|
||||
const result = createModelNodeFromAsset(asset)
|
||||
expect(result.success).toBe(true)
|
||||
expect(vi.mocked(app).canvas.graph!.add).toHaveBeenCalledWith(mockNode)
|
||||
})
|
||||
it('should fallback to asset.metadata.filename when user_metadata.filename missing', async () => {
|
||||
const asset = createMockAsset({
|
||||
user_metadata: {},
|
||||
metadata: { filename: 'models/checkpoints/from-metadata.safetensors' }
|
||||
})
|
||||
const mockNode = await createMockNode()
|
||||
await setupMocks({ createdNode: mockNode })
|
||||
const result = createModelNodeFromAsset(asset)
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockNode.widgets?.[0].value).toBe(
|
||||
'models/checkpoints/from-metadata.safetensors'
|
||||
)
|
||||
})
|
||||
it('should fallback to asset.name when both filename sources missing', async () => {
|
||||
const asset = createMockAsset({
|
||||
user_metadata: {},
|
||||
metadata: undefined
|
||||
})
|
||||
const mockNode = await createMockNode()
|
||||
await setupMocks({ createdNode: mockNode })
|
||||
const result = createModelNodeFromAsset(asset)
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockNode.widgets?.[0].value).toBe('test-model.safetensors')
|
||||
})
|
||||
it('should add node to active subgraph when present', async () => {
|
||||
const asset = createMockAsset()
|
||||
const mockNode = await createMockNode()
|
||||
const { Subgraph } = await vi.importActual<typeof LitegraphModule>(
|
||||
'@/lib/litegraph/src/litegraph'
|
||||
)
|
||||
const mockSubgraph = markRaw(
|
||||
Object.create(Subgraph.prototype, {
|
||||
add: { value: vi.fn() }
|
||||
})
|
||||
)
|
||||
await setupMocks({
|
||||
createdNode: mockNode,
|
||||
activeSubgraph: mockSubgraph
|
||||
})
|
||||
const result = createModelNodeFromAsset(asset)
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockSubgraph.add).toHaveBeenCalledWith(mockNode)
|
||||
expect(vi.mocked(app).canvas.graph!.add).not.toHaveBeenCalled()
|
||||
})
|
||||
it('should succeed when provider has empty key (auto-load nodes)', async () => {
|
||||
const asset = createMockAsset({
|
||||
tags: ['models', 'chatterbox/chatterbox_vc'],
|
||||
user_metadata: { filename: 'chatterbox_vc_model.pt' }
|
||||
})
|
||||
const mockNode = await createMockNode({ hasWidgets: false })
|
||||
const nodeProvider = createMockNodeProvider({
|
||||
nodeDef: {
|
||||
name: 'FL_ChatterboxVC',
|
||||
display_name: 'FL Chatterbox VC'
|
||||
},
|
||||
key: ''
|
||||
})
|
||||
await setupMocks({ createdNode: mockNode, nodeProvider })
|
||||
const result = createModelNodeFromAsset(asset)
|
||||
expect(result.success).toBe(true)
|
||||
expect(vi.mocked(app).canvas.graph!.add).toHaveBeenCalledWith(mockNode)
|
||||
})
|
||||
})
|
||||
describe('when asset data is incomplete or invalid', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
it.for([
|
||||
{
|
||||
case: 'missing user_metadata with no fallback',
|
||||
overrides: { user_metadata: undefined, metadata: undefined, name: '' },
|
||||
expectedCode: 'INVALID_ASSET' as const,
|
||||
errorPattern: /Invalid filename.*expected non-empty string/
|
||||
},
|
||||
{
|
||||
case: 'empty filename with no fallback',
|
||||
overrides: {
|
||||
user_metadata: { filename: '' },
|
||||
metadata: undefined,
|
||||
name: ''
|
||||
},
|
||||
expectedCode: 'INVALID_ASSET' as const,
|
||||
errorPattern: /Invalid filename.*expected non-empty string/
|
||||
}
|
||||
])(
|
||||
'should fail when asset has $case',
|
||||
({ overrides, expectedCode, errorPattern }) => {
|
||||
const asset = createMockAsset(overrides)
|
||||
const result = createModelNodeFromAsset(asset)
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.error.code).toBe(expectedCode)
|
||||
expect(result.error.message).toMatch(errorPattern)
|
||||
expect(result.error.assetId).toBe('asset-123')
|
||||
}
|
||||
}
|
||||
)
|
||||
it.for([
|
||||
{
|
||||
case: 'no tags',
|
||||
overrides: { tags: undefined },
|
||||
expectedCode: 'INVALID_ASSET' as const,
|
||||
errorMessage: 'Asset has no tags defined'
|
||||
},
|
||||
{
|
||||
case: 'only excluded tags',
|
||||
overrides: { tags: ['models', 'missing'] },
|
||||
expectedCode: 'INVALID_ASSET' as const,
|
||||
errorMessage: 'Asset has no valid category tag'
|
||||
},
|
||||
{
|
||||
case: 'only the models tag',
|
||||
overrides: { tags: ['models'] },
|
||||
expectedCode: 'INVALID_ASSET' as const,
|
||||
errorMessage: 'Asset has no valid category tag'
|
||||
}
|
||||
])(
|
||||
'should fail when asset has $case',
|
||||
({ overrides, expectedCode, errorMessage }) => {
|
||||
const asset = createMockAsset(overrides)
|
||||
const result = createModelNodeFromAsset(asset)
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.error.code).toBe(expectedCode)
|
||||
expect(result.error.message).toBe(errorMessage)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
describe('when system resources are unavailable', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
it('should fail when no provider registered for category', async () => {
|
||||
const asset = createMockAsset()
|
||||
await setupMocks({ nodeProvider: null })
|
||||
const result = createModelNodeFromAsset(asset)
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.error.code).toBe('NO_PROVIDER')
|
||||
expect(result.error.message).toContain('checkpoints')
|
||||
expect(result.error.details?.category).toBe('checkpoints')
|
||||
}
|
||||
})
|
||||
it('should fail when node creation fails', async () => {
|
||||
const asset = createMockAsset()
|
||||
await setupMocks()
|
||||
vi.mocked(LiteGraph.createNode).mockReturnValue(null)
|
||||
const result = createModelNodeFromAsset(asset)
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.error.code).toBe('NODE_CREATION_FAILED')
|
||||
expect(result.error.message).toContain('CheckpointLoaderSimple')
|
||||
}
|
||||
})
|
||||
it('should fail when widget is missing from node', async () => {
|
||||
const asset = createMockAsset()
|
||||
const mockNode = await createMockNode({ widgetName: 'wrong_widget' })
|
||||
await setupMocks({ createdNode: mockNode })
|
||||
const result = createModelNodeFromAsset(asset)
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.error.code).toBe('MISSING_WIDGET')
|
||||
expect(result.error.message).toContain('ckpt_name')
|
||||
expect(result.error.message).toContain('CheckpointLoaderSimple')
|
||||
expect(result.error.details?.widgetName).toBe('ckpt_name')
|
||||
}
|
||||
})
|
||||
it('should fail when node has no widgets array', async () => {
|
||||
const asset = createMockAsset()
|
||||
const mockNode = await createMockNode({ hasWidgets: false })
|
||||
await setupMocks({ createdNode: mockNode })
|
||||
const result = createModelNodeFromAsset(asset)
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.error.code).toBe('MISSING_WIDGET')
|
||||
expect(result.error.message).toContain('ckpt_name not found')
|
||||
}
|
||||
})
|
||||
it('should not add node to graph when widget validation fails', async () => {
|
||||
const asset = createMockAsset()
|
||||
const mockNode = await createMockNode({ hasWidgets: false })
|
||||
await setupMocks({ createdNode: mockNode })
|
||||
createModelNodeFromAsset(asset)
|
||||
expect(vi.mocked(app).canvas.graph!.add).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
describe('when graph is null', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
vi.mocked(app).canvas.graph = null
|
||||
})
|
||||
it('should fail when no graph is available', async () => {
|
||||
const asset = createMockAsset()
|
||||
const mockNode = await createMockNode()
|
||||
await setupMocks({ createdNode: mockNode })
|
||||
const result = createModelNodeFromAsset(asset)
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.error.code).toBe('NO_GRAPH')
|
||||
expect(result.error.message).toBe('No active graph available')
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,198 +0,0 @@
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode, Point } from '@/lib/litegraph/src/litegraph'
|
||||
import { assetItemSchema } from '@/platform/assets/schemas/assetSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import {
|
||||
MISSING_TAG,
|
||||
MODELS_TAG
|
||||
} from '@/platform/assets/services/assetService'
|
||||
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
||||
|
||||
interface ModelNodeCreateOptions {
|
||||
position?: Point
|
||||
}
|
||||
|
||||
type NodeCreationErrorCode =
|
||||
| 'INVALID_ASSET'
|
||||
| 'NO_PROVIDER'
|
||||
| 'NODE_CREATION_FAILED'
|
||||
| 'MISSING_WIDGET'
|
||||
| 'NO_GRAPH'
|
||||
|
||||
interface NodeCreationError {
|
||||
code: NodeCreationErrorCode
|
||||
message: string
|
||||
assetId: string
|
||||
details?: Record<string, unknown>
|
||||
}
|
||||
|
||||
type Result<T, E> = { success: true; value: T } | { success: false; error: E }
|
||||
|
||||
/**
|
||||
* Creates a LiteGraph node from an asset item.
|
||||
*
|
||||
* **Boundary Function**: Bridges Vue reactive domain with LiteGraph canvas domain.
|
||||
*
|
||||
* @param asset - Asset item to create node from (Vue domain)
|
||||
* @param options - Optional position and configuration
|
||||
* @returns Result with LiteGraph node (Canvas domain) or error details
|
||||
*
|
||||
* @remarks
|
||||
* This function performs side effects on the canvas graph. Validation failures
|
||||
* return error results rather than throwing to allow graceful degradation in UI contexts.
|
||||
* Widget validation occurs before graph mutation to prevent orphaned nodes.
|
||||
*/
|
||||
export function createModelNodeFromAsset(
|
||||
asset: AssetItem,
|
||||
options?: ModelNodeCreateOptions
|
||||
): Result<LGraphNode, NodeCreationError> {
|
||||
const validatedAsset = assetItemSchema.safeParse(asset)
|
||||
|
||||
if (!validatedAsset.success) {
|
||||
const errorMessage = validatedAsset.error.errors
|
||||
.map((e) => `${e.path.join('.')}: ${e.message}`)
|
||||
.join(', ')
|
||||
console.error('Invalid asset item:', errorMessage)
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'INVALID_ASSET',
|
||||
message: 'Asset schema validation failed',
|
||||
assetId: asset.id,
|
||||
details: { validationErrors: errorMessage }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const validAsset = validatedAsset.data
|
||||
|
||||
const filename = getAssetFilename(validAsset)
|
||||
if (filename.length === 0) {
|
||||
console.error(
|
||||
`Asset ${validAsset.id} has invalid user_metadata.filename (expected non-empty string, got ${typeof filename})`
|
||||
)
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'INVALID_ASSET',
|
||||
message: `Invalid filename (expected non-empty string, got ${typeof filename})`,
|
||||
assetId: validAsset.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (validAsset.tags.length === 0) {
|
||||
console.error(
|
||||
`Asset ${validAsset.id} has no tags defined (expected at least one category tag)`
|
||||
)
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'INVALID_ASSET',
|
||||
message: 'Asset has no tags defined',
|
||||
assetId: validAsset.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const category = validAsset.tags.find(
|
||||
(tag) => tag !== MODELS_TAG && tag !== MISSING_TAG
|
||||
)
|
||||
if (!category) {
|
||||
console.error(
|
||||
`Asset ${validAsset.id} has no valid category tag. Available tags: ${validAsset.tags.join(', ')} (expected tag other than '${MODELS_TAG}' or '${MISSING_TAG}')`
|
||||
)
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'INVALID_ASSET',
|
||||
message: 'Asset has no valid category tag',
|
||||
assetId: validAsset.id,
|
||||
details: { availableTags: validAsset.tags }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
const provider = modelToNodeStore.getNodeProvider(category)
|
||||
if (!provider) {
|
||||
console.error(`No node provider registered for category: ${category}`)
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'NO_PROVIDER',
|
||||
message: `No node provider registered for category: ${category}`,
|
||||
assetId: validAsset.id,
|
||||
details: { category }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const litegraphService = useLitegraphService()
|
||||
const pos = options?.position ?? litegraphService.getCanvasCenter()
|
||||
|
||||
const node = LiteGraph.createNode(
|
||||
provider.nodeDef.name,
|
||||
provider.nodeDef.display_name,
|
||||
{ pos }
|
||||
)
|
||||
|
||||
if (!node) {
|
||||
console.error(`Failed to create node for type: ${provider.nodeDef.name}`)
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'NODE_CREATION_FAILED',
|
||||
message: `Failed to create node for type: ${provider.nodeDef.name}`,
|
||||
assetId: validAsset.id,
|
||||
details: { nodeType: provider.nodeDef.name }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const workflowStore = useWorkflowStore()
|
||||
const targetGraph = workflowStore.isSubgraphActive
|
||||
? workflowStore.activeSubgraph
|
||||
: app.canvas.graph
|
||||
|
||||
if (!targetGraph) {
|
||||
console.error('No active graph available')
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'NO_GRAPH',
|
||||
message: 'No active graph available',
|
||||
assetId: validAsset.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set widget value if provider specifies a key (some nodes auto-load models without a widget)
|
||||
if (provider.key) {
|
||||
const widget = node.widgets?.find((w) => w.name === provider.key)
|
||||
if (!widget) {
|
||||
console.error(
|
||||
`Widget ${provider.key} not found on node ${provider.nodeDef.name}`
|
||||
)
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'MISSING_WIDGET',
|
||||
message: `Widget ${provider.key} not found on node ${provider.nodeDef.name}`,
|
||||
assetId: validAsset.id,
|
||||
details: { widgetName: provider.key, nodeType: provider.nodeDef.name }
|
||||
}
|
||||
}
|
||||
}
|
||||
widget.value = filename
|
||||
}
|
||||
|
||||
// Add the node to the graph
|
||||
targetGraph.add(node)
|
||||
|
||||
return { success: true, value: node }
|
||||
}
|
||||
208
src/platform/assets/utils/resolveModelNodeFromAsset.test.ts
Normal file
208
src/platform/assets/utils/resolveModelNodeFromAsset.test.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { resolveModelNodeFromAsset } from '@/platform/assets/utils/resolveModelNodeFromAsset'
|
||||
|
||||
const mockGetNodeProvider = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/stores/modelToNodeStore', () => ({
|
||||
useModelToNodeStore: () => ({ getNodeProvider: mockGetNodeProvider })
|
||||
}))
|
||||
|
||||
function createMockAsset(overrides: Partial<AssetItem> = {}): AssetItem {
|
||||
return {
|
||||
id: 'asset-123',
|
||||
name: 'test-model.safetensors',
|
||||
size: 1024,
|
||||
created_at: '2025-10-01T00:00:00Z',
|
||||
tags: ['models', 'checkpoints'],
|
||||
user_metadata: {
|
||||
filename: 'models/checkpoints/test-model.safetensors'
|
||||
},
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function createMockNodeProvider(
|
||||
overrides: {
|
||||
nodeDef?: { name: string; display_name: string }
|
||||
key?: string
|
||||
} = {}
|
||||
) {
|
||||
return {
|
||||
nodeDef: {
|
||||
name: 'CheckpointLoaderSimple',
|
||||
display_name: 'Load Checkpoint',
|
||||
...overrides.nodeDef
|
||||
},
|
||||
key: overrides.key ?? 'ckpt_name'
|
||||
}
|
||||
}
|
||||
|
||||
function mockProvider(
|
||||
provider: ReturnType<typeof createMockNodeProvider> | null
|
||||
) {
|
||||
mockGetNodeProvider.mockReturnValue(provider)
|
||||
}
|
||||
|
||||
describe('resolveModelNodeFromAsset', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
describe('valid assets', () => {
|
||||
it('resolves the provider for the asset category and the filename', () => {
|
||||
mockProvider(createMockNodeProvider())
|
||||
const result = resolveModelNodeFromAsset(createMockAsset())
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(mockGetNodeProvider).toHaveBeenCalledWith('checkpoints')
|
||||
expect(result.value.provider.nodeDef.name).toBe(
|
||||
'CheckpointLoaderSimple'
|
||||
)
|
||||
expect(result.value.filename).toBe(
|
||||
'models/checkpoints/test-model.safetensors'
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('falls back to metadata.filename when user_metadata.filename missing', () => {
|
||||
mockProvider(createMockNodeProvider())
|
||||
const result = resolveModelNodeFromAsset(
|
||||
createMockAsset({
|
||||
user_metadata: {},
|
||||
metadata: { filename: 'models/checkpoints/from-metadata.safetensors' }
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.value.filename).toBe(
|
||||
'models/checkpoints/from-metadata.safetensors'
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('falls back to asset.name when both filename sources missing', () => {
|
||||
mockProvider(createMockNodeProvider())
|
||||
const result = resolveModelNodeFromAsset(
|
||||
createMockAsset({ user_metadata: {}, metadata: undefined })
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.value.filename).toBe('test-model.safetensors')
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves a provider with an empty key (auto-load nodes)', () => {
|
||||
mockProvider(
|
||||
createMockNodeProvider({
|
||||
nodeDef: {
|
||||
name: 'FL_ChatterboxVC',
|
||||
display_name: 'FL Chatterbox VC'
|
||||
},
|
||||
key: ''
|
||||
})
|
||||
)
|
||||
const result = resolveModelNodeFromAsset(
|
||||
createMockAsset({
|
||||
tags: ['models', 'chatterbox/chatterbox_vc'],
|
||||
user_metadata: { filename: 'chatterbox_vc_model.pt' }
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.value.provider.key).toBe('')
|
||||
expect(result.value.filename).toBe('chatterbox_vc_model.pt')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('invalid assets', () => {
|
||||
it('fails when the asset does not match the schema', () => {
|
||||
const invalid = {
|
||||
id: 'asset-123',
|
||||
tags: ['models', 'checkpoints']
|
||||
} as unknown as AssetItem
|
||||
|
||||
const result = resolveModelNodeFromAsset(invalid)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.error.code).toBe('INVALID_ASSET')
|
||||
expect(result.error.message).toBe('Asset schema validation failed')
|
||||
expect(result.error.assetId).toBe('asset-123')
|
||||
expect(result.error.details?.validationErrors).toBeTruthy()
|
||||
}
|
||||
})
|
||||
|
||||
it.for([
|
||||
{
|
||||
case: 'missing user_metadata with no fallback',
|
||||
overrides: {
|
||||
user_metadata: undefined,
|
||||
metadata: undefined,
|
||||
name: ''
|
||||
},
|
||||
errorPattern: /Invalid filename.*expected non-empty string/
|
||||
},
|
||||
{
|
||||
case: 'empty filename with no fallback',
|
||||
overrides: {
|
||||
user_metadata: { filename: '' },
|
||||
metadata: undefined,
|
||||
name: ''
|
||||
},
|
||||
errorPattern: /Invalid filename.*expected non-empty string/
|
||||
}
|
||||
])('fails when asset has $case', ({ overrides, errorPattern }) => {
|
||||
const result = resolveModelNodeFromAsset(createMockAsset(overrides))
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.error.code).toBe('INVALID_ASSET')
|
||||
expect(result.error.message).toMatch(errorPattern)
|
||||
expect(result.error.assetId).toBe('asset-123')
|
||||
}
|
||||
})
|
||||
|
||||
it.for([
|
||||
{
|
||||
case: 'no tags',
|
||||
overrides: { tags: undefined },
|
||||
message: 'Asset has no tags defined'
|
||||
},
|
||||
{
|
||||
case: 'only excluded tags',
|
||||
overrides: { tags: ['models', 'missing'] },
|
||||
message: 'Asset has no valid category tag'
|
||||
},
|
||||
{
|
||||
case: 'only the models tag',
|
||||
overrides: { tags: ['models'] },
|
||||
message: 'Asset has no valid category tag'
|
||||
}
|
||||
])('fails when asset has $case', ({ overrides, message }) => {
|
||||
const result = resolveModelNodeFromAsset(createMockAsset(overrides))
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.error.code).toBe('INVALID_ASSET')
|
||||
expect(result.error.message).toBe(message)
|
||||
}
|
||||
})
|
||||
|
||||
it('fails when no provider is registered for the category', () => {
|
||||
mockProvider(null)
|
||||
const result = resolveModelNodeFromAsset(createMockAsset())
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.error.code).toBe('NO_PROVIDER')
|
||||
expect(result.error.message).toContain('checkpoints')
|
||||
expect(result.error.details?.category).toBe('checkpoints')
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
117
src/platform/assets/utils/resolveModelNodeFromAsset.ts
Normal file
117
src/platform/assets/utils/resolveModelNodeFromAsset.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { assetItemSchema } from '@/platform/assets/schemas/assetSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import {
|
||||
MISSING_TAG,
|
||||
MODELS_TAG
|
||||
} from '@/platform/assets/services/assetService'
|
||||
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
||||
import type { ModelNodeProvider } from '@/stores/modelToNodeStore'
|
||||
|
||||
type ResolveErrorCode = 'INVALID_ASSET' | 'NO_PROVIDER'
|
||||
|
||||
export interface ResolveModelNodeError {
|
||||
code: ResolveErrorCode
|
||||
message: string
|
||||
assetId: string
|
||||
details?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface ResolvedModelNode {
|
||||
provider: ModelNodeProvider
|
||||
filename: string
|
||||
}
|
||||
|
||||
type Result<T, E> = { success: true; value: T } | { success: false; error: E }
|
||||
|
||||
/**
|
||||
* Resolves an asset item to the node provider and filename needed to add a
|
||||
* model loader node. Validation failures return error results rather than
|
||||
* throwing, so callers can degrade gracefully in UI contexts.
|
||||
*/
|
||||
export function resolveModelNodeFromAsset(
|
||||
asset: AssetItem
|
||||
): Result<ResolvedModelNode, ResolveModelNodeError> {
|
||||
const validatedAsset = assetItemSchema.safeParse(asset)
|
||||
|
||||
if (!validatedAsset.success) {
|
||||
const errorMessage = validatedAsset.error.errors
|
||||
.map((e) => `${e.path.join('.')}: ${e.message}`)
|
||||
.join(', ')
|
||||
console.error('Invalid asset item:', errorMessage)
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'INVALID_ASSET',
|
||||
message: 'Asset schema validation failed',
|
||||
assetId: typeof asset?.id === 'string' ? asset.id : 'unknown',
|
||||
details: { validationErrors: errorMessage }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const validAsset = validatedAsset.data
|
||||
|
||||
const filename = getAssetFilename(validAsset)
|
||||
if (filename.length === 0) {
|
||||
console.error(
|
||||
`Asset ${validAsset.id} has invalid user_metadata.filename (expected non-empty string, got ${typeof filename})`
|
||||
)
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'INVALID_ASSET',
|
||||
message: `Invalid filename (expected non-empty string, got ${typeof filename})`,
|
||||
assetId: validAsset.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (validAsset.tags.length === 0) {
|
||||
console.error(
|
||||
`Asset ${validAsset.id} has no tags defined (expected at least one category tag)`
|
||||
)
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'INVALID_ASSET',
|
||||
message: 'Asset has no tags defined',
|
||||
assetId: validAsset.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const category = validAsset.tags.find(
|
||||
(tag) => tag !== MODELS_TAG && tag !== MISSING_TAG
|
||||
)
|
||||
if (!category) {
|
||||
console.error(
|
||||
`Asset ${validAsset.id} has no valid category tag. Available tags: ${validAsset.tags.join(', ')} (expected tag other than '${MODELS_TAG}' or '${MISSING_TAG}')`
|
||||
)
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'INVALID_ASSET',
|
||||
message: 'Asset has no valid category tag',
|
||||
assetId: validAsset.id,
|
||||
details: { availableTags: validAsset.tags }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const provider = useModelToNodeStore().getNodeProvider(category)
|
||||
if (!provider) {
|
||||
console.error(`No node provider registered for category: ${category}`)
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'NO_PROVIDER',
|
||||
message: `No node provider registered for category: ${category}`,
|
||||
assetId: validAsset.id,
|
||||
details: { category }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, value: { provider, filename } }
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { Plan } from '@/platform/workspace/api/workspaceApi'
|
||||
import type {
|
||||
Plan,
|
||||
TeamCreditStops
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
const plans = ref<Plan[]>([])
|
||||
const currentPlanSlug = ref<string | null>(null)
|
||||
const teamCreditStops = ref<TeamCreditStops | null>(null)
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
@@ -19,6 +23,7 @@ export function useBillingPlans() {
|
||||
const response = await workspaceApi.getBillingPlans()
|
||||
plans.value = response.plans
|
||||
currentPlanSlug.value = response.current_plan_slug ?? null
|
||||
teamCreditStops.value = response.team_credit_stops ?? null
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch plans'
|
||||
console.error('[useBillingPlans] Failed to fetch plans:', err)
|
||||
@@ -48,6 +53,7 @@ export function useBillingPlans() {
|
||||
return {
|
||||
plans,
|
||||
currentPlanSlug,
|
||||
teamCreditStops,
|
||||
isLoading,
|
||||
error,
|
||||
monthlyPlans,
|
||||
|
||||
@@ -9,6 +9,8 @@ const mockIsInPersonalWorkspace = vi.hoisted(() => ({ value: true }))
|
||||
const mockIsFreeTier = vi.hoisted(() => ({ value: false }))
|
||||
const mockTeamWorkspacesEnabled = vi.hoisted(() => ({ value: false }))
|
||||
const mockIsCloud = vi.hoisted(() => ({ value: true }))
|
||||
const mockIsLegacyTeamPlan = vi.hoisted(() => ({ value: false }))
|
||||
const mockCanManageSubscription = vi.hoisted(() => ({ value: true }))
|
||||
|
||||
vi.mock('vue', async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
@@ -61,6 +63,22 @@ vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
isLegacyTeamPlan: mockIsLegacyTeamPlan
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
|
||||
useWorkspaceUI: () => ({
|
||||
permissions: {
|
||||
get value() {
|
||||
return { canManageSubscription: mockCanManageSubscription.value }
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
describe('useSubscriptionDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -68,6 +86,8 @@ describe('useSubscriptionDialog', () => {
|
||||
mockIsInPersonalWorkspace.value = true
|
||||
mockIsFreeTier.value = false
|
||||
mockTeamWorkspacesEnabled.value = false
|
||||
mockIsLegacyTeamPlan.value = false
|
||||
mockCanManageSubscription.value = true
|
||||
|
||||
try {
|
||||
sessionStorage.clear()
|
||||
@@ -94,6 +114,82 @@ describe('useSubscriptionDialog', () => {
|
||||
|
||||
expect(mockShowLayoutDialog).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses the unified table (no onChooseTeam) when team workspaces are enabled', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
// Unified table is workspace-type-agnostic (Jun-5 model): same path for
|
||||
// a personal-plan or team-plan workspace.
|
||||
mockIsInPersonalWorkspace.value = true
|
||||
const { showPricingTable } = useSubscriptionDialog()
|
||||
|
||||
showPricingTable()
|
||||
|
||||
expect(mockShowLayoutDialog).toHaveBeenCalledTimes(1)
|
||||
const props = mockShowLayoutDialog.mock.calls[0][0].props
|
||||
expect(props).not.toHaveProperty('onChooseTeam')
|
||||
})
|
||||
|
||||
it('defaults to the personal tab in a personal workspace', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsInPersonalWorkspace.value = true
|
||||
const { showPricingTable } = useSubscriptionDialog()
|
||||
|
||||
showPricingTable()
|
||||
|
||||
const props = mockShowLayoutDialog.mock.calls[0][0].props
|
||||
expect(props.initialPlanMode).toBe('personal')
|
||||
})
|
||||
|
||||
it('opens the team tab when planMode is forced from a personal workspace', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsInPersonalWorkspace.value = true
|
||||
const { showPricingTable } = useSubscriptionDialog()
|
||||
|
||||
showPricingTable({ planMode: 'team' })
|
||||
|
||||
const props = mockShowLayoutDialog.mock.calls[0][0].props
|
||||
expect(props.initialPlanMode).toBe('team')
|
||||
})
|
||||
|
||||
it('uses the legacy table (with onChooseTeam) when team workspaces are disabled', () => {
|
||||
mockTeamWorkspacesEnabled.value = false
|
||||
const { showPricingTable } = useSubscriptionDialog()
|
||||
|
||||
showPricingTable()
|
||||
|
||||
const props = mockShowLayoutDialog.mock.calls[0][0].props
|
||||
expect(props).toHaveProperty('onChooseTeam')
|
||||
})
|
||||
|
||||
it('routes an existing per-member (legacy) team subscriber to the old team table', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsInPersonalWorkspace.value = false
|
||||
mockIsLegacyTeamPlan.value = true
|
||||
const { showPricingTable } = useSubscriptionDialog()
|
||||
|
||||
showPricingTable()
|
||||
|
||||
expect(mockShowLayoutDialog).toHaveBeenCalledTimes(1)
|
||||
const props = mockShowLayoutDialog.mock.calls[0][0].props
|
||||
// The legacy team dialog takes onClose + reason and none of the unified
|
||||
// props. `reason` separates it from the read-only member dialog (onClose
|
||||
// only); the absent initialPlanMode separates it from the unified table.
|
||||
expect(props).toHaveProperty('reason')
|
||||
expect(props).not.toHaveProperty('initialPlanMode')
|
||||
expect(props).not.toHaveProperty('onChooseTeam')
|
||||
})
|
||||
|
||||
it('keeps a non-legacy (credit-slider) team subscriber on the unified table', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsInPersonalWorkspace.value = false
|
||||
mockIsLegacyTeamPlan.value = false
|
||||
const { showPricingTable } = useSubscriptionDialog()
|
||||
|
||||
showPricingTable()
|
||||
|
||||
const props = mockShowLayoutDialog.mock.calls[0][0].props
|
||||
expect(props.initialPlanMode).toBe('team')
|
||||
})
|
||||
})
|
||||
|
||||
describe('startTeamWorkspaceUpgradeFlow', () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
@@ -16,6 +17,16 @@ export type SubscriptionDialogReason =
|
||||
| 'out_of_credits'
|
||||
| 'top_up_blocked'
|
||||
|
||||
export interface SubscriptionDialogOptions {
|
||||
reason?: SubscriptionDialogReason
|
||||
/**
|
||||
* Forces the unified pricing dialog to open on a specific plan tab,
|
||||
* overriding the workspace-derived default (e.g. an "Upgrade to Team" CTA
|
||||
* always lands on the team tab even from a personal workspace).
|
||||
*/
|
||||
planMode?: 'personal' | 'team'
|
||||
}
|
||||
|
||||
export const useSubscriptionDialog = () => {
|
||||
const { flags } = useFeatureFlags()
|
||||
const dialogService = useDialogService()
|
||||
@@ -29,7 +40,7 @@ export const useSubscriptionDialog = () => {
|
||||
dialogStore.closeDialog({ key: FREE_TIER_DIALOG_KEY })
|
||||
}
|
||||
|
||||
function showPricingTable(options?: { reason?: SubscriptionDialogReason }) {
|
||||
function showPricingTable(options?: SubscriptionDialogOptions) {
|
||||
if (!isCloud) return
|
||||
|
||||
// Members can't manage the workspace subscription, so a blocked run shows a
|
||||
@@ -57,47 +68,100 @@ export const useSubscriptionDialog = () => {
|
||||
return
|
||||
}
|
||||
|
||||
const useWorkspaceVariant =
|
||||
flags.teamWorkspacesEnabled && !workspaceStore.isInPersonalWorkspace
|
||||
|
||||
const component = useWorkspaceVariant
|
||||
? defineAsyncComponent(
|
||||
() =>
|
||||
import('@/platform/workspace/components/SubscriptionRequiredDialogContentWorkspace.vue')
|
||||
)
|
||||
: defineAsyncComponent(
|
||||
() =>
|
||||
import('@/platform/cloud/subscription/components/SubscriptionRequiredDialogContent.vue')
|
||||
)
|
||||
|
||||
const personalProps = {
|
||||
onClose: hide,
|
||||
reason: options?.reason,
|
||||
onChooseTeam: () => startTeamWorkspaceUpgradeFlow()
|
||||
// Shared dialog shell styling for both variants.
|
||||
const dialogComponentProps = {
|
||||
style: 'width: min(1328px, 95vw); max-height: 958px;',
|
||||
pt: {
|
||||
root: {
|
||||
class: 'rounded-2xl bg-transparent h-full'
|
||||
},
|
||||
content: {
|
||||
class:
|
||||
'!p-0 rounded-2xl border border-border-default bg-secondary-background shadow-[0_25px_80px_rgba(5,6,12,0.45)] h-full'
|
||||
}
|
||||
}
|
||||
}
|
||||
const workspaceProps = {
|
||||
onClose: hide,
|
||||
reason: options?.reason
|
||||
|
||||
// Jun-5 model: a single unified pricing table (personal/team plan toggle on
|
||||
// one workspace) when team workspaces are enabled. Replaces the old
|
||||
// personal-vs-team workspace fork. Flag-off keeps the legacy table.
|
||||
if (flags.teamWorkspacesEnabled) {
|
||||
// Existing per-member (legacy) team subscribers keep the old tier-based
|
||||
// team table; the unified credit-slider table is for everyone else.
|
||||
// Resolved lazily (not at composable setup): these three composables form
|
||||
// an import cycle (useBillingContext -> useWorkspaceBilling ->
|
||||
// useSubscriptionDialog), so a setup-time read would deref the shared
|
||||
// context before its state is constructed.
|
||||
const { isLegacyTeamPlan } = useBillingContext()
|
||||
if (isLegacyTeamPlan.value) {
|
||||
dialogService.showLayoutDialog({
|
||||
key: DIALOG_KEY,
|
||||
component: defineAsyncComponent(
|
||||
() =>
|
||||
import('@/platform/workspace/components/SubscriptionRequiredDialogContentWorkspace.vue')
|
||||
),
|
||||
props: {
|
||||
onClose: hide,
|
||||
reason: options?.reason
|
||||
},
|
||||
// The legacy table hosts a PrimeVue Popover teleported to body; Reka
|
||||
// modal mode traps focus and disables body pointer-events, making it
|
||||
// unclickable. The unified table has no such overlay.
|
||||
dialogComponentProps: { ...dialogComponentProps, modal: false }
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
dialogService.showLayoutDialog({
|
||||
key: DIALOG_KEY,
|
||||
component: defineAsyncComponent(
|
||||
() =>
|
||||
import('@/platform/workspace/components/SubscriptionRequiredDialogContentUnified.vue')
|
||||
),
|
||||
props: {
|
||||
onClose: hide,
|
||||
reason: options?.reason,
|
||||
// A team workspace lands on the For Teams tab; personal on For
|
||||
// Personal. An explicit caller (e.g. an "Upgrade to Team" CTA) can
|
||||
// override via options.planMode.
|
||||
initialPlanMode:
|
||||
options?.planMode ??
|
||||
(workspaceStore.isInPersonalWorkspace ? 'personal' : 'team')
|
||||
},
|
||||
dialogComponentProps: {
|
||||
// The dialog hugs its content so each step sizes itself: the pricing
|
||||
// table stays wide/fixed (cards fill it, DES QA 2026-06-13) while the
|
||||
// compact confirm/success steps shrink instead of floating in the big
|
||||
// pricing modal. Sizes are set on the content root per checkoutStep.
|
||||
style: 'max-width: 95vw; max-height: 90vh;',
|
||||
pt: {
|
||||
root: { class: 'rounded-2xl bg-transparent' },
|
||||
content: {
|
||||
class:
|
||||
'!p-0 rounded-2xl border border-border-default bg-secondary-background shadow-[0_25px_80px_rgba(5,6,12,0.45)]'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
dialogService.showLayoutDialog({
|
||||
key: DIALOG_KEY,
|
||||
component,
|
||||
props: useWorkspaceVariant ? workspaceProps : personalProps,
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
size: 'full',
|
||||
// The pricing tables host a PrimeVue Popover teleported to body.
|
||||
// Reka's modal mode traps focus and disables body pointer-events,
|
||||
// making the popover unclickable. Mirrors Settings/Manager.
|
||||
modal: false,
|
||||
contentClass:
|
||||
'w-[min(1328px,95vw)] max-w-[min(1328px,95vw)] sm:max-w-[min(1328px,95vw)] h-full max-h-[958px] overflow-hidden rounded-2xl border-border-default bg-base-background/60 shadow-[0_25px_80px_rgba(5,6,12,0.45)] backdrop-blur-md'
|
||||
}
|
||||
component: defineAsyncComponent(
|
||||
() =>
|
||||
import('@/platform/cloud/subscription/components/SubscriptionRequiredDialogContent.vue')
|
||||
),
|
||||
props: {
|
||||
onClose: hide,
|
||||
reason: options?.reason,
|
||||
onChooseTeam: () => startTeamWorkspaceUpgradeFlow()
|
||||
},
|
||||
dialogComponentProps
|
||||
})
|
||||
}
|
||||
|
||||
function show(options?: { reason?: SubscriptionDialogReason }) {
|
||||
function show(options?: SubscriptionDialogOptions) {
|
||||
if (isFreeTier.value && workspaceStore.isInPersonalWorkspace) {
|
||||
const component = defineAsyncComponent(
|
||||
() =>
|
||||
|
||||
@@ -6,14 +6,25 @@ export interface CreditStop {
|
||||
/**
|
||||
* Yearly-commitment discount applied to `usd`, as a whole-number percent.
|
||||
* Threshold-based per the pricing decision (Slack — Alex Tov, 2026-05-08):
|
||||
* yearly tiers are 0 / 5 / 10 / 15 / 20% with nothing in between (monthly is
|
||||
* halved, but still being iterated). Only the $700 → 10% tier is
|
||||
* design-confirmed (DES-197 shows "Save 10% ($70)"); the rest follow the
|
||||
* agreed 0/5/10/15/20 sequence and should be re-confirmed with design/BE.
|
||||
* yearly tiers are 0 / 5 / 10 / 15 / 20% with nothing in between.
|
||||
* Monthly halves these (0 / 2.5 / 5 / 7.5 / 10%) — confirmed in PRD: GA Team
|
||||
* Billing ("for monthly the discount is halved"). `CreditSlider` derives the
|
||||
* monthly value from this field via its `cycle` prop, so only the yearly
|
||||
* tiers are stored here.
|
||||
*/
|
||||
discountPercentYearly: number
|
||||
}
|
||||
|
||||
/** A selected slider stop, as emitted by the pricing table's team column. */
|
||||
export interface TeamPlanSelection {
|
||||
/** Pre-discount monthly price in USD (the struck-through list price). */
|
||||
usd: number
|
||||
/** Monthly credit grant at this stop. */
|
||||
credits: number
|
||||
/** Cycle-adjusted discounted monthly price in USD — what the user actually pays. */
|
||||
discountedUsd: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Team-plan credit-subscription slider stops.
|
||||
*
|
||||
@@ -37,3 +48,22 @@ export const TEAM_PLAN_CREDIT_STOPS: readonly CreditStop[] = [
|
||||
|
||||
/** Default stop per DES-197: index 2 = $700 / 147,700 credits. */
|
||||
export const DEFAULT_TEAM_PLAN_STOP_INDEX = 2
|
||||
|
||||
/**
|
||||
* Discounted monthly price for a stop's list `usd`, applying the billing-cycle
|
||||
* discount (yearly = full `discountPercentYearly`; monthly halves it). Shared by
|
||||
* the slider display and the checkout confirm step so the two never drift.
|
||||
* Falls back to the list price when `usd` is not a known stop.
|
||||
*/
|
||||
export function getDiscountedMonthlyUsd(
|
||||
usd: number,
|
||||
cycle: 'monthly' | 'yearly'
|
||||
): number {
|
||||
const stop = TEAM_PLAN_CREDIT_STOPS.find((s) => s.usd === usd)
|
||||
if (!stop) return usd
|
||||
const percent =
|
||||
cycle === 'monthly'
|
||||
? stop.discountPercentYearly / 2
|
||||
: stop.discountPercentYearly
|
||||
return Math.round(usd * (1 - percent / 100))
|
||||
}
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "KSampler",
|
||||
"pos": [0, 0],
|
||||
"size": [100, 100],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"properties": {},
|
||||
"widgets_values": [0, "randomize", 20]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "subgraph-x",
|
||||
"pos": [300, 0],
|
||||
"size": [100, 100],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"properties": {},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {},
|
||||
"version": 0.4,
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "subgraph-x",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 1,
|
||||
"lastLinkId": 0,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "x",
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [0, 0],
|
||||
"size": [100, 100],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"properties": {
|
||||
"models": [
|
||||
{
|
||||
"name": "rare_model.safetensors",
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
]
|
||||
},
|
||||
"widgets_values": ["some_other_model.safetensors"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"inputNode": { "id": -10, "bounding": [0, 0, 0, 0] },
|
||||
"outputNode": { "id": -20, "bounding": [0, 0, 0, 0] },
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"widgets": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"models": [
|
||||
{
|
||||
"name": "rare_model.safetensors",
|
||||
"url": "https://example.com/rare",
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "KSampler",
|
||||
"pos": [0, 0],
|
||||
"size": [100, 100],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"properties": {},
|
||||
"widgets_values": [0, "randomize", 20]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "subgraph-x",
|
||||
"pos": [300, 0],
|
||||
"size": [100, 100],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 4,
|
||||
"properties": {},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {},
|
||||
"version": 0.4,
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "subgraph-x",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 1,
|
||||
"lastLinkId": 0,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "x",
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [0, 0],
|
||||
"size": [100, 100],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"properties": {
|
||||
"models": [
|
||||
{
|
||||
"name": "rare_model.safetensors",
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
]
|
||||
},
|
||||
"widgets_values": ["some_other_model.safetensors"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"inputNode": { "id": -10, "bounding": [0, 0, 0, 0] },
|
||||
"outputNode": { "id": -20, "bounding": [0, 0, 0, 0] },
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"widgets": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"models": [
|
||||
{
|
||||
"name": "rare_model.safetensors",
|
||||
"url": "https://example.com/rare",
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -34,10 +34,6 @@ const { mockHandles } = vi.hoisted(() => {
|
||||
executionErrorStore: {
|
||||
surfaceMissingModels: vi.fn()
|
||||
},
|
||||
modelStore: {
|
||||
loadModelFolders: vi.fn(),
|
||||
getLoadedModelFolder: vi.fn()
|
||||
},
|
||||
modelToNodeStore: {
|
||||
getCategoryForNodeType: vi.fn()
|
||||
},
|
||||
@@ -49,14 +45,9 @@ const { mockHandles } = vi.hoisted(() => {
|
||||
): MissingModelCandidate[] => []
|
||||
),
|
||||
enrichWithEmbeddedMetadata: vi.fn(
|
||||
async (
|
||||
(
|
||||
_candidates: readonly MissingModelCandidate[],
|
||||
_graphData: ComfyWorkflowJSON,
|
||||
_checkModelInstalled: (
|
||||
name: string,
|
||||
directory: string
|
||||
) => Promise<boolean>,
|
||||
_isAssetSupported?: (nodeType: string, widgetName: string) => boolean
|
||||
_graphData: ComfyWorkflowJSON
|
||||
) => state.enrichedCandidates
|
||||
),
|
||||
verifyAssetSupportedCandidates: vi.fn(
|
||||
@@ -104,10 +95,6 @@ vi.mock('@/stores/executionErrorStore', () => ({
|
||||
useExecutionErrorStore: () => mockHandles.executionErrorStore
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/modelStore', () => ({
|
||||
useModelStore: () => mockHandles.modelStore
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/modelToNodeStore', () => ({
|
||||
useModelToNodeStore: () => mockHandles.modelToNodeStore
|
||||
}))
|
||||
@@ -121,16 +108,8 @@ vi.mock('@/platform/missingModel/missingModelScan', () => ({
|
||||
mockHandles.scanAllModelCandidates(graph, isAssetSupported, getDirectory),
|
||||
enrichWithEmbeddedMetadata: (
|
||||
candidates: readonly MissingModelCandidate[],
|
||||
graphData: ComfyWorkflowJSON,
|
||||
checkModelInstalled: (name: string, directory: string) => Promise<boolean>,
|
||||
isAssetSupported?: (nodeType: string, widgetName: string) => boolean
|
||||
) =>
|
||||
mockHandles.enrichWithEmbeddedMetadata(
|
||||
candidates,
|
||||
graphData,
|
||||
checkModelInstalled,
|
||||
isAssetSupported
|
||||
),
|
||||
graphData: ComfyWorkflowJSON
|
||||
) => mockHandles.enrichWithEmbeddedMetadata(candidates, graphData),
|
||||
verifyAssetSupportedCandidates: (
|
||||
candidates: readonly MissingModelCandidate[],
|
||||
signal: AbortSignal
|
||||
@@ -186,8 +165,6 @@ describe('missingModelPipeline', () => {
|
||||
mockHandles.missingModelStore.createVerificationAbortController.mockImplementation(
|
||||
() => new AbortController()
|
||||
)
|
||||
mockHandles.modelStore.loadModelFolders.mockResolvedValue(undefined)
|
||||
mockHandles.modelStore.getLoadedModelFolder.mockResolvedValue(undefined)
|
||||
mockHandles.modelToNodeStore.getCategoryForNodeType.mockReturnValue(
|
||||
undefined
|
||||
)
|
||||
@@ -253,9 +230,7 @@ describe('missingModelPipeline', () => {
|
||||
|
||||
expect(mockHandles.enrichWithEmbeddedMetadata).toHaveBeenCalledWith(
|
||||
expect.any(Array),
|
||||
expect.objectContaining({ models: activeModels }),
|
||||
expect.any(Function),
|
||||
undefined
|
||||
expect.objectContaining({ models: activeModels })
|
||||
)
|
||||
expect(
|
||||
mockHandles.executionErrorStore.surfaceMissingModels
|
||||
@@ -305,9 +280,7 @@ describe('missingModelPipeline', () => {
|
||||
hash_type: 'sha256'
|
||||
}
|
||||
]
|
||||
}),
|
||||
expect.any(Function),
|
||||
undefined
|
||||
})
|
||||
)
|
||||
expect(
|
||||
mockHandles.executionErrorStore.surfaceMissingModels
|
||||
@@ -325,9 +298,7 @@ describe('missingModelPipeline', () => {
|
||||
|
||||
expect(mockHandles.enrichWithEmbeddedMetadata).toHaveBeenCalledWith(
|
||||
expect.any(Array),
|
||||
graphData,
|
||||
expect.any(Function),
|
||||
undefined
|
||||
graphData
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ import type { ComfyWorkflow } from '@/platform/workflow/management/stores/comfyW
|
||||
import type { ModelFile } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useModelStore } from '@/stores/modelStore'
|
||||
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
@@ -121,20 +120,7 @@ export async function runMissingModelPipeline({
|
||||
getDirectory
|
||||
)
|
||||
|
||||
const modelStore = useModelStore()
|
||||
await modelStore.loadModelFolders()
|
||||
const enrichedAll = await enrichWithEmbeddedMetadata(
|
||||
candidates,
|
||||
graphData,
|
||||
async (name, directory) => {
|
||||
const folder = await modelStore.getLoadedModelFolder(directory)
|
||||
const models = folder?.models
|
||||
return !!(
|
||||
models && Object.values(models).some((m) => m.file_name === name)
|
||||
)
|
||||
},
|
||||
isCloud ? isAssetBrowserWidget : undefined
|
||||
)
|
||||
const enrichedAll = enrichWithEmbeddedMetadata(candidates, graphData)
|
||||
|
||||
// Drop candidates whose enclosing subgraph is muted/bypassed. Per-node
|
||||
// scans only checked each node's own mode; the cascade from an
|
||||
|
||||
@@ -15,8 +15,6 @@ import {
|
||||
verifyAssetSupportedCandidates,
|
||||
MODEL_FILE_EXTENSIONS
|
||||
} from '@/platform/missingModel/missingModelScan'
|
||||
import activeSubgraphUnmatchedModel from '@/platform/missingModel/__fixtures__/activeSubgraphUnmatchedModel.json' with { type: 'json' }
|
||||
import bypassedSubgraphUnmatchedModel from '@/platform/missingModel/__fixtures__/bypassedSubgraphUnmatchedModel.json' with { type: 'json' }
|
||||
import type { MissingModelCandidate } from '@/platform/missingModel/types'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
@@ -671,11 +669,8 @@ function makeCandidate(
|
||||
}
|
||||
}
|
||||
|
||||
const alwaysMissing = async () => false
|
||||
const alwaysInstalled = async () => true
|
||||
|
||||
describe('enrichWithEmbeddedMetadata', () => {
|
||||
it('enriches existing candidate with url and directory from embedded metadata', async () => {
|
||||
it('enriches existing candidate with url and directory from embedded metadata', () => {
|
||||
const candidates = [makeCandidate('model_a.safetensors')]
|
||||
const graphData = fromPartial<ComfyWorkflowJSON>({
|
||||
last_node_id: 1,
|
||||
@@ -709,18 +704,14 @@ describe('enrichWithEmbeddedMetadata', () => {
|
||||
]
|
||||
})
|
||||
|
||||
const result = await enrichWithEmbeddedMetadata(
|
||||
candidates,
|
||||
graphData,
|
||||
alwaysMissing
|
||||
)
|
||||
const result = enrichWithEmbeddedMetadata(candidates, graphData)
|
||||
|
||||
expect(result[0].url).toBe('https://example.com/model_a')
|
||||
expect(result[0].directory).toBe('checkpoints')
|
||||
expect(result[0].hash).toBe('abc123')
|
||||
})
|
||||
|
||||
it('does not overwrite existing fields on candidate', async () => {
|
||||
it('does not overwrite existing fields on candidate', () => {
|
||||
const candidates = [
|
||||
makeCandidate('model_a.safetensors', {
|
||||
directory: 'existing_dir',
|
||||
@@ -757,18 +748,13 @@ describe('enrichWithEmbeddedMetadata', () => {
|
||||
]
|
||||
})
|
||||
|
||||
const result = await enrichWithEmbeddedMetadata(
|
||||
candidates,
|
||||
graphData,
|
||||
alwaysMissing
|
||||
)
|
||||
const result = enrichWithEmbeddedMetadata(candidates, graphData)
|
||||
|
||||
// ??= should not overwrite existing values
|
||||
expect(result[0].url).toBe('https://existing.com')
|
||||
expect(result[0].directory).toBe('existing_dir')
|
||||
})
|
||||
|
||||
it('does not mutate the original candidates array', async () => {
|
||||
it('does not mutate the original candidates array', () => {
|
||||
const candidates = [makeCandidate('model_a.safetensors')]
|
||||
const graphData = fromPartial<ComfyWorkflowJSON>({
|
||||
last_node_id: 1,
|
||||
@@ -801,12 +787,12 @@ describe('enrichWithEmbeddedMetadata', () => {
|
||||
})
|
||||
|
||||
const originalUrl = candidates[0].url
|
||||
await enrichWithEmbeddedMetadata(candidates, graphData, alwaysMissing)
|
||||
enrichWithEmbeddedMetadata(candidates, graphData)
|
||||
|
||||
expect(candidates[0].url).toBe(originalUrl)
|
||||
})
|
||||
|
||||
it('adds new candidate for embedded model not found by COMBO scan', async () => {
|
||||
it('does not add a candidate for embedded metadata without a live candidate', () => {
|
||||
const candidates: MissingModelCandidate[] = []
|
||||
const graphData = fromPartial<ComfyWorkflowJSON>({
|
||||
last_node_id: 1,
|
||||
@@ -838,18 +824,12 @@ describe('enrichWithEmbeddedMetadata', () => {
|
||||
]
|
||||
})
|
||||
|
||||
const result = await enrichWithEmbeddedMetadata(
|
||||
candidates,
|
||||
graphData,
|
||||
alwaysMissing
|
||||
)
|
||||
const result = enrichWithEmbeddedMetadata(candidates, graphData)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].name).toBe('model_a.safetensors')
|
||||
expect(result[0].isMissing).toBe(true)
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not add candidate when model is already installed', async () => {
|
||||
it('does not add a candidate from root metadata without live references', () => {
|
||||
const candidates: MissingModelCandidate[] = []
|
||||
const graphData = fromPartial<ComfyWorkflowJSON>({
|
||||
last_node_id: 0,
|
||||
@@ -869,117 +849,12 @@ describe('enrichWithEmbeddedMetadata', () => {
|
||||
]
|
||||
})
|
||||
|
||||
const result = await enrichWithEmbeddedMetadata(
|
||||
candidates,
|
||||
graphData,
|
||||
alwaysInstalled
|
||||
)
|
||||
const result = enrichWithEmbeddedMetadata(candidates, graphData)
|
||||
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('skips embedded models from muted nodes', async () => {
|
||||
const candidates: MissingModelCandidate[] = []
|
||||
const graphData = fromPartial<ComfyWorkflowJSON>({
|
||||
last_node_id: 1,
|
||||
last_link_id: 0,
|
||||
nodes: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'CheckpointLoaderSimple',
|
||||
pos: [0, 0],
|
||||
size: [100, 100],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 2, // NEVER (muted)
|
||||
properties: {},
|
||||
widgets_values: { ckpt_name: 'model.safetensors' }
|
||||
}
|
||||
],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
extra: {},
|
||||
version: 0.4,
|
||||
models: [
|
||||
{
|
||||
name: 'model.safetensors',
|
||||
url: 'https://example.com/model',
|
||||
directory: 'checkpoints'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const result = await enrichWithEmbeddedMetadata(
|
||||
candidates,
|
||||
graphData,
|
||||
alwaysMissing
|
||||
)
|
||||
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('drops workflow-level model entries when only referencing nodes are bypassed (other active nodes present)', async () => {
|
||||
// Regression: a previous `hasActiveNodes` check kept workflow-level
|
||||
// models in a mixed graph if ANY active node existed, even when every
|
||||
// node that actually referenced the model was bypassed. The correct
|
||||
// check drops unmatched workflow-level entries since candidates are
|
||||
// derived from active-node widgets.
|
||||
const candidates: MissingModelCandidate[] = []
|
||||
const graphData = fromPartial<ComfyWorkflowJSON>({
|
||||
last_node_id: 2,
|
||||
last_link_id: 0,
|
||||
nodes: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'CheckpointLoaderSimple',
|
||||
pos: [0, 0],
|
||||
size: [100, 100],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 4, // BYPASS — only node referencing the model
|
||||
properties: {},
|
||||
widgets_values: { ckpt_name: 'model.safetensors' }
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'KSampler',
|
||||
pos: [200, 0],
|
||||
size: [100, 100],
|
||||
flags: {},
|
||||
order: 1,
|
||||
mode: 0, // ALWAYS — unrelated active node
|
||||
properties: {},
|
||||
widgets_values: {}
|
||||
}
|
||||
],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
extra: {},
|
||||
version: 0.4,
|
||||
models: [
|
||||
{
|
||||
name: 'model.safetensors',
|
||||
url: 'https://example.com/model',
|
||||
directory: 'checkpoints'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const result = await enrichWithEmbeddedMetadata(
|
||||
candidates,
|
||||
graphData,
|
||||
alwaysMissing
|
||||
)
|
||||
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps unmatched node-sourced entries in a mixed graph', async () => {
|
||||
// A node-sourced unmatched entry (sourceNodeType !== '') must survive
|
||||
// the workflow-level filter. This ensures the simplification does not
|
||||
// over-filter legitimate per-node missing models.
|
||||
it('enriches existing candidates from node-sourced metadata', () => {
|
||||
const candidates = [
|
||||
makeCandidate('node_model.safetensors', { nodeId: '1' })
|
||||
]
|
||||
@@ -1015,18 +890,14 @@ describe('enrichWithEmbeddedMetadata', () => {
|
||||
models: []
|
||||
})
|
||||
|
||||
const result = await enrichWithEmbeddedMetadata(
|
||||
candidates,
|
||||
graphData,
|
||||
alwaysMissing
|
||||
)
|
||||
const result = enrichWithEmbeddedMetadata(candidates, graphData)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].name).toBe('node_model.safetensors')
|
||||
expect(result[0].url).toBe('https://example.com/node_model')
|
||||
})
|
||||
|
||||
it('skips embedded models from bypassed nodes', async () => {
|
||||
const candidates: MissingModelCandidate[] = []
|
||||
it('does not enrich from muted node metadata', () => {
|
||||
const candidates = [makeCandidate('model.safetensors')]
|
||||
const graphData = fromPartial<ComfyWorkflowJSON>({
|
||||
last_node_id: 1,
|
||||
last_link_id: 0,
|
||||
@@ -1038,8 +909,16 @@ describe('enrichWithEmbeddedMetadata', () => {
|
||||
size: [100, 100],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 4, // BYPASS
|
||||
properties: {},
|
||||
mode: 2,
|
||||
properties: {
|
||||
models: [
|
||||
{
|
||||
name: 'model.safetensors',
|
||||
url: 'https://example.com/model',
|
||||
directory: 'checkpoints'
|
||||
}
|
||||
]
|
||||
},
|
||||
widgets_values: { ckpt_name: 'model.safetensors' }
|
||||
}
|
||||
],
|
||||
@@ -1048,58 +927,152 @@ describe('enrichWithEmbeddedMetadata', () => {
|
||||
config: {},
|
||||
extra: {},
|
||||
version: 0.4,
|
||||
models: [
|
||||
{
|
||||
name: 'model.safetensors',
|
||||
url: 'https://example.com/model',
|
||||
directory: 'checkpoints'
|
||||
}
|
||||
]
|
||||
models: []
|
||||
})
|
||||
|
||||
const result = await enrichWithEmbeddedMetadata(
|
||||
candidates,
|
||||
graphData,
|
||||
alwaysMissing
|
||||
)
|
||||
const result = enrichWithEmbeddedMetadata(candidates, graphData)
|
||||
|
||||
expect(result).toHaveLength(0)
|
||||
expect(result[0].url).toBeUndefined()
|
||||
})
|
||||
|
||||
it('drops workflow-level entries when only reference is in a bypassed subgraph interior', async () => {
|
||||
// Interior properties.models references the workflow-level model
|
||||
// but its widget value does not — forcing the workflow-level entry
|
||||
// down the unmatched path where isModelReferencedByActiveNode
|
||||
// decides. Previously the helper ignored the bypassed container.
|
||||
const result = await enrichWithEmbeddedMetadata(
|
||||
[],
|
||||
fromAny<ComfyWorkflowJSON, unknown>(bypassedSubgraphUnmatchedModel),
|
||||
alwaysMissing
|
||||
)
|
||||
it('does not enrich from bypassed node metadata', () => {
|
||||
const candidates = [makeCandidate('model.safetensors')]
|
||||
const graphData = fromPartial<ComfyWorkflowJSON>({
|
||||
last_node_id: 1,
|
||||
last_link_id: 0,
|
||||
nodes: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'CheckpointLoaderSimple',
|
||||
pos: [0, 0],
|
||||
size: [100, 100],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 4,
|
||||
properties: {
|
||||
models: [
|
||||
{
|
||||
name: 'model.safetensors',
|
||||
url: 'https://example.com/model',
|
||||
directory: 'checkpoints'
|
||||
}
|
||||
]
|
||||
},
|
||||
widgets_values: { ckpt_name: 'model.safetensors' }
|
||||
}
|
||||
],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
extra: {},
|
||||
version: 0.4,
|
||||
models: []
|
||||
})
|
||||
|
||||
expect(result).toHaveLength(0)
|
||||
const result = enrichWithEmbeddedMetadata(candidates, graphData)
|
||||
|
||||
expect(result[0].url).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps workflow-level entries when reference is in an active subgraph interior', async () => {
|
||||
// Positive control for the bypassed case above: identical fixture
|
||||
// with container mode=0 must still surface the unmatched workflow-
|
||||
// level model. Guards against a regression where the ancestor gate
|
||||
// drops every workflow-level entry regardless of context.
|
||||
const result = await enrichWithEmbeddedMetadata(
|
||||
[],
|
||||
fromAny<ComfyWorkflowJSON, unknown>(activeSubgraphUnmatchedModel),
|
||||
alwaysMissing
|
||||
)
|
||||
it.for([
|
||||
{ state: 'muted', ancestorMode: 2 },
|
||||
{ state: 'bypassed', ancestorMode: 4 }
|
||||
])(
|
||||
'does not enrich from metadata inside a $state ancestor subgraph',
|
||||
({ ancestorMode }) => {
|
||||
const candidates = [
|
||||
makeCandidate('shared_model.safetensors', {
|
||||
directory: 'checkpoints'
|
||||
})
|
||||
]
|
||||
const graphData = fromPartial<ComfyWorkflowJSON>({
|
||||
last_node_id: 2,
|
||||
last_link_id: 0,
|
||||
nodes: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'CheckpointLoaderSimple',
|
||||
pos: [0, 0],
|
||||
size: [100, 100],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
properties: {},
|
||||
widgets_values: { ckpt_name: 'shared_model.safetensors' }
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'InactiveSubgraph',
|
||||
pos: [200, 0],
|
||||
size: [100, 100],
|
||||
flags: {},
|
||||
order: 1,
|
||||
mode: ancestorMode,
|
||||
properties: {},
|
||||
widgets_values: {}
|
||||
}
|
||||
],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
extra: {},
|
||||
version: 0.4,
|
||||
models: [],
|
||||
definitions: {
|
||||
subgraphs: [
|
||||
{
|
||||
id: 'InactiveSubgraph',
|
||||
name: 'InactiveSubgraph',
|
||||
nodes: [
|
||||
{
|
||||
id: 10,
|
||||
type: 'CheckpointLoaderSimple',
|
||||
pos: [0, 0],
|
||||
size: [100, 100],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
properties: {
|
||||
models: [
|
||||
{
|
||||
name: 'shared_model.safetensors',
|
||||
url: 'https://example.com/inactive-branch',
|
||||
directory: 'checkpoints',
|
||||
hash: 'inactive-hash',
|
||||
hash_type: 'sha256'
|
||||
}
|
||||
]
|
||||
},
|
||||
widgets_values: {
|
||||
ckpt_name: 'shared_model.safetensors'
|
||||
}
|
||||
}
|
||||
],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
extra: {},
|
||||
inputNode: {},
|
||||
outputNode: {}
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].name).toBe('rare_model.safetensors')
|
||||
})
|
||||
const result = enrichWithEmbeddedMetadata(candidates, graphData)
|
||||
|
||||
it('drops workflow-level entries when interior reference is under a different directory', async () => {
|
||||
// Same name, different directory: the interior's properties.models
|
||||
// entry is not the same asset as the workflow-level entry, so the
|
||||
// fallback helper must not treat it as a reference that keeps the
|
||||
// workflow-level model alive.
|
||||
expect(result[0].url).toBeUndefined()
|
||||
expect(result[0].hash).toBeUndefined()
|
||||
expect(result[0].hashType).toBeUndefined()
|
||||
}
|
||||
)
|
||||
|
||||
it('does not enrich candidates from different-directory metadata', () => {
|
||||
const candidates = [
|
||||
makeCandidate('collide_model.safetensors', {
|
||||
directory: 'checkpoints'
|
||||
})
|
||||
]
|
||||
const graphData = fromPartial<ComfyWorkflowJSON>({
|
||||
last_node_id: 1,
|
||||
last_link_id: 0,
|
||||
@@ -1132,43 +1105,19 @@ describe('enrichWithEmbeddedMetadata', () => {
|
||||
{
|
||||
name: 'collide_model.safetensors',
|
||||
url: 'https://example.com/collide',
|
||||
directory: 'checkpoints'
|
||||
directory: 'loras'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const result = await enrichWithEmbeddedMetadata(
|
||||
[],
|
||||
graphData,
|
||||
alwaysMissing
|
||||
)
|
||||
const result = enrichWithEmbeddedMetadata(candidates, graphData)
|
||||
|
||||
expect(result).toHaveLength(0)
|
||||
expect(result[0].url).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('OSS missing model detection (non-Cloud path)', () => {
|
||||
it('scanAllModelCandidates returns empty array when not called (simulating isCloud === false guard)', () => {
|
||||
// In the app, when isCloud is false, scanAllModelCandidates is not called
|
||||
// and an empty array is used instead. This test verifies the OSS path
|
||||
// starts with an empty candidates list.
|
||||
const isCloud = false
|
||||
const graph = makeGraph([
|
||||
makeNode(1, 'CheckpointLoaderSimple', [
|
||||
makeComboWidget('ckpt_name', 'missing_model.safetensors', [])
|
||||
])
|
||||
])
|
||||
|
||||
const modelCandidates = isCloud
|
||||
? scanAllModelCandidates(graph, noAssetSupport)
|
||||
: []
|
||||
|
||||
expect(modelCandidates).toEqual([])
|
||||
})
|
||||
|
||||
it('enrichWithEmbeddedMetadata detects missing embedded models without prior COMBO scan (OSS dialog path)', async () => {
|
||||
// OSS path: candidates start empty, enrichWithEmbeddedMetadata adds
|
||||
// missing embedded models so the dialog can show them.
|
||||
it('does not detect embedded models without prior candidate scan', () => {
|
||||
const candidates: MissingModelCandidate[] = []
|
||||
const graphData = fromPartial<ComfyWorkflowJSON>({
|
||||
last_node_id: 2,
|
||||
@@ -1216,67 +1165,15 @@ describe('OSS missing model detection (non-Cloud path)', () => {
|
||||
]
|
||||
})
|
||||
|
||||
const result = await enrichWithEmbeddedMetadata(
|
||||
candidates,
|
||||
graphData,
|
||||
alwaysMissing
|
||||
)
|
||||
const result = enrichWithEmbeddedMetadata(candidates, graphData)
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result.every((c) => c.isMissing === true)).toBe(true)
|
||||
expect(result.map((c) => c.name)).toEqual([
|
||||
'sd_xl_base_1.0.safetensors',
|
||||
'detail_enhancer.safetensors'
|
||||
])
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('enrichWithEmbeddedMetadata sets isMissing=true when isAssetSupported is not provided (OSS)', async () => {
|
||||
// When isAssetSupported is omitted (OSS), unmatched embedded models
|
||||
// should have isMissing=true (not undefined), enabling the dialog.
|
||||
const candidates: MissingModelCandidate[] = []
|
||||
const graphData = fromPartial<ComfyWorkflowJSON>({
|
||||
last_node_id: 1,
|
||||
last_link_id: 0,
|
||||
nodes: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'CheckpointLoaderSimple',
|
||||
pos: [0, 0],
|
||||
size: [100, 100],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
properties: {},
|
||||
widgets_values: { ckpt_name: 'missing_model.safetensors' }
|
||||
}
|
||||
],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
extra: {},
|
||||
version: 0.4,
|
||||
models: [
|
||||
{
|
||||
name: 'missing_model.safetensors',
|
||||
url: 'https://example.com/model',
|
||||
directory: 'checkpoints'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const result = await enrichWithEmbeddedMetadata(
|
||||
candidates,
|
||||
graphData,
|
||||
alwaysMissing
|
||||
)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].isMissing).toBe(true)
|
||||
expect(result[0].isAssetSupported).toBe(false)
|
||||
})
|
||||
|
||||
it('enrichWithEmbeddedMetadata correctly filters for dialog: only isMissing=true with url', async () => {
|
||||
const candidates: MissingModelCandidate[] = []
|
||||
it('enriches live OSS candidates for dialog filtering', () => {
|
||||
const candidates: MissingModelCandidate[] = [
|
||||
makeCandidate('missing_model.safetensors')
|
||||
]
|
||||
const graphData = fromPartial<ComfyWorkflowJSON>({
|
||||
last_node_id: 1,
|
||||
last_link_id: 0,
|
||||
@@ -1312,64 +1209,13 @@ describe('OSS missing model detection (non-Cloud path)', () => {
|
||||
]
|
||||
})
|
||||
|
||||
const selectiveInstallCheck = async (name: string) =>
|
||||
name === 'installed_model.safetensors'
|
||||
|
||||
const result = await enrichWithEmbeddedMetadata(
|
||||
candidates,
|
||||
graphData,
|
||||
selectiveInstallCheck
|
||||
)
|
||||
const result = enrichWithEmbeddedMetadata(candidates, graphData)
|
||||
|
||||
const dialogModels = result.filter((c) => c.isMissing === true && c.url)
|
||||
expect(dialogModels).toHaveLength(1)
|
||||
expect(dialogModels[0].name).toBe('missing_model.safetensors')
|
||||
expect(dialogModels[0].url).toBe('https://example.com/model')
|
||||
})
|
||||
|
||||
it('enrichWithEmbeddedMetadata with isAssetSupported leaves isMissing undefined for asset-supported models (Cloud path)', async () => {
|
||||
const candidates: MissingModelCandidate[] = []
|
||||
const graphData = fromPartial<ComfyWorkflowJSON>({
|
||||
last_node_id: 1,
|
||||
last_link_id: 0,
|
||||
nodes: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'CheckpointLoaderSimple',
|
||||
pos: [0, 0],
|
||||
size: [100, 100],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
properties: {},
|
||||
widgets_values: { ckpt_name: 'model.safetensors' }
|
||||
}
|
||||
],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
extra: {},
|
||||
version: 0.4,
|
||||
models: [
|
||||
{
|
||||
name: 'model.safetensors',
|
||||
url: 'https://example.com/model',
|
||||
directory: 'checkpoints'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const result = await enrichWithEmbeddedMetadata(
|
||||
candidates,
|
||||
graphData,
|
||||
alwaysMissing,
|
||||
() => true
|
||||
)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].isMissing).toBeUndefined()
|
||||
expect(result[0].isAssetSupported).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
const { mockUpdateModelsForNodeType, mockGetAssets } = vi.hoisted(() => ({
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import type { ModelFile } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { FlattenableWorkflowGraph } from '@/platform/workflow/core/utils/workflowFlattening'
|
||||
import { flattenWorkflowNodes } from '@/platform/workflow/core/utils/workflowFlattening'
|
||||
import type {
|
||||
MissingModelCandidate,
|
||||
MissingModelViewModel,
|
||||
EmbeddedModelWithSource
|
||||
} from './types'
|
||||
import type { MissingModelCandidate, MissingModelViewModel } from './types'
|
||||
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
// eslint-disable-next-line import-x/no-restricted-paths
|
||||
@@ -17,13 +13,13 @@ import type {
|
||||
IBaseWidget,
|
||||
IComboWidget
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import { getParentExecutionIds } from '@/types/nodeIdentification'
|
||||
import {
|
||||
collectAllNodes,
|
||||
getExecutionIdByNode
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { resolveComboValues } from '@/utils/litegraphUtil'
|
||||
import { getParentExecutionIds } from '@/types/nodeIdentification'
|
||||
|
||||
export type MissingModelWorkflowData = FlattenableWorkflowGraph & {
|
||||
models?: ModelFile[]
|
||||
@@ -70,6 +66,10 @@ function isAssetWidget(widget: IBaseWidget): widget is IAssetWidget {
|
||||
return widget.type === 'asset'
|
||||
}
|
||||
|
||||
function isInactiveMode(mode: number | undefined): boolean {
|
||||
return mode === LGraphEventMode.NEVER || mode === LGraphEventMode.BYPASS
|
||||
}
|
||||
|
||||
// Full set of model file extensions used for scanning candidate widgets.
|
||||
// Intentionally broader than ALLOWED_SUFFIXES in missingModelDownload.ts,
|
||||
// which restricts which files are eligible for download.
|
||||
@@ -111,11 +111,7 @@ export function scanAllModelCandidates(
|
||||
// Skip subgraph container nodes: their promoted widgets are synthetic
|
||||
// views of interior widgets, which are already scanned via recursion.
|
||||
if (node.isSubgraphNode?.()) continue
|
||||
if (
|
||||
node.mode === LGraphEventMode.NEVER ||
|
||||
node.mode === LGraphEventMode.BYPASS
|
||||
)
|
||||
continue
|
||||
if (isInactiveMode(node.mode)) continue
|
||||
|
||||
candidates.push(
|
||||
...scanNodeModelCandidates(
|
||||
@@ -217,14 +213,12 @@ function scanComboWidget(
|
||||
}
|
||||
}
|
||||
|
||||
export async function enrichWithEmbeddedMetadata(
|
||||
export function enrichWithEmbeddedMetadata(
|
||||
candidates: readonly MissingModelCandidate[],
|
||||
graphData: MissingModelWorkflowData,
|
||||
checkModelInstalled: (name: string, directory: string) => Promise<boolean>,
|
||||
isAssetSupported?: (nodeType: string, widgetName: string) => boolean
|
||||
): Promise<MissingModelCandidate[]> {
|
||||
graphData: MissingModelWorkflowData
|
||||
): MissingModelCandidate[] {
|
||||
const allNodes = flattenWorkflowNodes(graphData)
|
||||
const embeddedModels = collectEmbeddedModelsWithSource(allNodes, graphData)
|
||||
const embeddedModels = collectEmbeddedModels(allNodes, graphData)
|
||||
|
||||
const enriched = candidates.map((c) => ({ ...c }))
|
||||
const candidatesByKey = new Map<string, MissingModelCandidate[]>()
|
||||
@@ -240,7 +234,7 @@ export async function enrichWithEmbeddedMetadata(
|
||||
else candidatesByKey.set(nameKey, [c])
|
||||
}
|
||||
|
||||
const deduped: EmbeddedModelWithSource[] = []
|
||||
const deduped: ModelFile[] = []
|
||||
const enrichedKeys = new Set<string>()
|
||||
for (const model of embeddedModels) {
|
||||
const dedupeKey = `${model.name}::${model.directory}`
|
||||
@@ -249,195 +243,60 @@ export async function enrichWithEmbeddedMetadata(
|
||||
deduped.push(model)
|
||||
}
|
||||
|
||||
const unmatched: EmbeddedModelWithSource[] = []
|
||||
for (const model of deduped) {
|
||||
const dirKey = `${model.name}::${model.directory}`
|
||||
const exact = candidatesByKey.get(dirKey)
|
||||
const fallback = candidatesByKey.get(model.name)
|
||||
const existing = exact?.length ? exact : fallback
|
||||
if (existing) {
|
||||
for (const c of existing) {
|
||||
if (c.directory && c.directory !== model.directory) continue
|
||||
c.directory ??= model.directory
|
||||
c.url ??= model.url
|
||||
c.hash ??= model.hash
|
||||
c.hashType ??= model.hash_type
|
||||
}
|
||||
} else {
|
||||
unmatched.push(model)
|
||||
if (!existing) continue
|
||||
for (const c of existing) {
|
||||
if (c.directory && c.directory !== model.directory) continue
|
||||
c.directory ??= model.directory
|
||||
c.url ??= model.url
|
||||
c.hash ??= model.hash
|
||||
c.hashType ??= model.hash_type
|
||||
}
|
||||
}
|
||||
|
||||
// Workflow-level entries (sourceNodeType === '') survive only when
|
||||
// some active (non-muted, non-bypassed) node actually references the
|
||||
// model — not merely because any unrelated active node exists. A
|
||||
// reference is any widget value (or node.properties.models entry)
|
||||
// that matches the model name on an active node.
|
||||
// Hoist the id→node map once; isModelReferencedByActiveNode would
|
||||
// otherwise rebuild it on every unmatched entry.
|
||||
const flattenedNodeById = new Map(allNodes.map((n) => [String(n.id), n]))
|
||||
const activeUnmatched = unmatched.filter(
|
||||
(m) =>
|
||||
m.sourceNodeType !== '' ||
|
||||
isModelReferencedByActiveNode(
|
||||
m.name,
|
||||
m.directory,
|
||||
allNodes,
|
||||
flattenedNodeById
|
||||
)
|
||||
)
|
||||
|
||||
const settled = await Promise.allSettled(
|
||||
activeUnmatched.map(async (model) => {
|
||||
const installed = await checkModelInstalled(model.name, model.directory)
|
||||
if (installed) return null
|
||||
|
||||
const nodeIsAssetSupported = isAssetSupported
|
||||
? isAssetSupported(model.sourceNodeType, model.sourceWidgetName)
|
||||
: false
|
||||
|
||||
return {
|
||||
nodeId: model.sourceNodeId,
|
||||
nodeType: model.sourceNodeType,
|
||||
widgetName: model.sourceWidgetName,
|
||||
isAssetSupported: nodeIsAssetSupported,
|
||||
name: model.name,
|
||||
directory: model.directory,
|
||||
url: model.url,
|
||||
hash: model.hash,
|
||||
hashType: model.hash_type,
|
||||
isMissing: nodeIsAssetSupported ? undefined : true
|
||||
} satisfies MissingModelCandidate
|
||||
})
|
||||
)
|
||||
|
||||
for (const r of settled) {
|
||||
if (r.status === 'rejected') {
|
||||
console.warn(
|
||||
'[Missing Model Pipeline] checkModelInstalled failed:',
|
||||
r.reason
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (r.value) enriched.push(r.value)
|
||||
}
|
||||
|
||||
return enriched
|
||||
}
|
||||
|
||||
function isModelReferencedByActiveNode(
|
||||
modelName: string,
|
||||
modelDirectory: string | undefined,
|
||||
allNodes: ReturnType<typeof flattenWorkflowNodes>,
|
||||
nodeById: Map<string, ReturnType<typeof flattenWorkflowNodes>[number]>
|
||||
): boolean {
|
||||
for (const node of allNodes) {
|
||||
if (
|
||||
node.mode === LGraphEventMode.NEVER ||
|
||||
node.mode === LGraphEventMode.BYPASS
|
||||
)
|
||||
continue
|
||||
if (!isAncestorPathActiveInFlattened(String(node.id), nodeById)) continue
|
||||
|
||||
// Require directory agreement when both sides specify one, so a
|
||||
// same-name entry under a different folder does not keep an
|
||||
// unrelated workflow-level model alive as missing.
|
||||
const embeddedModels = (
|
||||
node.properties as
|
||||
| { models?: Array<{ name: string; directory?: string }> }
|
||||
| undefined
|
||||
)?.models
|
||||
if (
|
||||
embeddedModels?.some(
|
||||
(m) =>
|
||||
m.name === modelName &&
|
||||
(modelDirectory === undefined ||
|
||||
m.directory === undefined ||
|
||||
m.directory === modelDirectory)
|
||||
)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
// widgets_values carries only the name, so directory cannot be
|
||||
// checked here — fall back to filename matching.
|
||||
const values = node.widgets_values
|
||||
if (!values) continue
|
||||
const valueArray = Array.isArray(values) ? values : Object.values(values)
|
||||
for (const v of valueArray) {
|
||||
if (typeof v === 'string' && v === modelName) return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function isAncestorPathActiveInFlattened(
|
||||
executionId: string,
|
||||
nodeById: Map<string, ReturnType<typeof flattenWorkflowNodes>[number]>
|
||||
): boolean {
|
||||
for (const ancestorId of getParentExecutionIds(executionId)) {
|
||||
const ancestor = nodeById.get(ancestorId)
|
||||
if (!ancestor) continue
|
||||
if (
|
||||
ancestor.mode === LGraphEventMode.NEVER ||
|
||||
ancestor.mode === LGraphEventMode.BYPASS
|
||||
)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function collectEmbeddedModelsWithSource(
|
||||
function collectEmbeddedModels(
|
||||
allNodes: ReturnType<typeof flattenWorkflowNodes>,
|
||||
graphData: MissingModelWorkflowData
|
||||
): EmbeddedModelWithSource[] {
|
||||
const result: EmbeddedModelWithSource[] = []
|
||||
): ModelFile[] {
|
||||
const result: ModelFile[] = []
|
||||
const nodesById = new Map(allNodes.map((node) => [String(node.id), node]))
|
||||
|
||||
for (const node of allNodes) {
|
||||
if (
|
||||
node.mode === LGraphEventMode.NEVER ||
|
||||
node.mode === LGraphEventMode.BYPASS
|
||||
)
|
||||
continue
|
||||
if (!isNodeAndAncestorsActive(node, nodesById)) continue
|
||||
|
||||
const selected = getSelectedModelsMetadata(node)
|
||||
if (!selected?.length) continue
|
||||
|
||||
for (const model of selected) {
|
||||
result.push({
|
||||
...model,
|
||||
sourceNodeId: node.id,
|
||||
sourceNodeType: node.type,
|
||||
sourceWidgetName: findWidgetNameForModel(node, model.name)
|
||||
})
|
||||
}
|
||||
result.push(...selected)
|
||||
}
|
||||
|
||||
// Workflow-level model entries have no originating node; sourceNodeId
|
||||
// remains undefined and empty-string node type/widget are handled by
|
||||
// groupCandidatesByName (no nodeId → no referencing node entry).
|
||||
if (graphData.models?.length) {
|
||||
for (const model of graphData.models) {
|
||||
result.push({
|
||||
...model,
|
||||
sourceNodeType: '',
|
||||
sourceWidgetName: ''
|
||||
})
|
||||
}
|
||||
}
|
||||
if (graphData.models?.length) result.push(...graphData.models)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function findWidgetNameForModel(
|
||||
function isNodeAndAncestorsActive(
|
||||
node: ReturnType<typeof flattenWorkflowNodes>[number],
|
||||
modelName: string
|
||||
): string {
|
||||
if (Array.isArray(node.widgets_values) || !node.widgets_values) return ''
|
||||
for (const [key, val] of Object.entries(node.widgets_values)) {
|
||||
if (val === modelName) return key
|
||||
nodesById: ReadonlyMap<
|
||||
string,
|
||||
ReturnType<typeof flattenWorkflowNodes>[number]
|
||||
>
|
||||
): boolean {
|
||||
if (isInactiveMode(node.mode)) return false
|
||||
|
||||
for (const ancestorId of getParentExecutionIds(String(node.id))) {
|
||||
const ancestor = nodesById.get(ancestorId)
|
||||
if (isInactiveMode(ancestor?.mode)) return false
|
||||
}
|
||||
return ''
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
interface AssetVerifier {
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import type {
|
||||
ModelFile,
|
||||
NodeId
|
||||
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
/**
|
||||
* A single (node, widget, model) binding detected by the missing model pipeline.
|
||||
@@ -28,13 +25,6 @@ export interface MissingModelCandidate {
|
||||
isMissing: boolean | undefined
|
||||
}
|
||||
|
||||
export interface EmbeddedModelWithSource extends ModelFile {
|
||||
/** Undefined for workflow-level models not tied to a specific node. */
|
||||
sourceNodeId?: NodeId
|
||||
sourceNodeType: string
|
||||
sourceWidgetName: string
|
||||
}
|
||||
|
||||
/** View model grouping multiple candidate references under a single model name. */
|
||||
export interface MissingModelViewModel {
|
||||
name: string
|
||||
|
||||
@@ -309,6 +309,7 @@ export interface SearchQueryMetadata {
|
||||
*/
|
||||
export type NodeAddSource =
|
||||
| 'sidebar_drag'
|
||||
| 'asset_browser'
|
||||
| 'search_modal'
|
||||
| 'paste'
|
||||
| 'programmatic'
|
||||
|
||||
@@ -118,9 +118,27 @@ export interface Plan {
|
||||
seat_summary: PlanSeatSummary
|
||||
}
|
||||
|
||||
interface TeamCreditStopPrice {
|
||||
list_price_cents: number
|
||||
price_cents: number
|
||||
}
|
||||
|
||||
interface TeamCreditStop {
|
||||
id: string
|
||||
credits: number
|
||||
monthly: TeamCreditStopPrice
|
||||
yearly: TeamCreditStopPrice
|
||||
}
|
||||
|
||||
export interface TeamCreditStops {
|
||||
default_stop_index: number
|
||||
stops: TeamCreditStop[]
|
||||
}
|
||||
|
||||
interface BillingPlansResponse {
|
||||
current_plan_slug?: string
|
||||
plans: Plan[]
|
||||
team_credit_stops?: TeamCreditStops
|
||||
}
|
||||
|
||||
type SubscriptionTransitionType =
|
||||
@@ -214,6 +232,12 @@ export type BillingStatus =
|
||||
| 'payment_failed'
|
||||
| 'inactive'
|
||||
|
||||
export interface CurrentTeamCreditStop {
|
||||
id: string
|
||||
credits_monthly: number
|
||||
stop_usd: number
|
||||
}
|
||||
|
||||
export interface BillingStatusResponse {
|
||||
is_active: boolean
|
||||
subscription_status?: BillingSubscriptionStatus
|
||||
@@ -224,6 +248,7 @@ export interface BillingStatusResponse {
|
||||
has_funds: boolean
|
||||
cancel_at?: string
|
||||
renewal_date?: string
|
||||
team_credit_stop?: CurrentTeamCreditStop
|
||||
}
|
||||
|
||||
export interface BillingBalanceResponse {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import SubscriptionAddPaymentPreviewWorkspace from './SubscriptionAddPaymentPreviewWorkspace.vue'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
n: (value: number) => value.toLocaleString('en-US')
|
||||
})
|
||||
}))
|
||||
|
||||
const globalOptions = {
|
||||
mocks: { $t: (key: string) => key },
|
||||
stubs: {
|
||||
'i18n-t': { template: '<span />' },
|
||||
Button: {
|
||||
template: '<button @click="$emit(\'click\')"><slot /></button>'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('SubscriptionAddPaymentPreviewWorkspace', () => {
|
||||
it('renders personal tier price and credits from tierKey', () => {
|
||||
render(SubscriptionAddPaymentPreviewWorkspace, {
|
||||
props: { tierKey: 'creator' },
|
||||
global: globalOptions
|
||||
})
|
||||
expect(screen.getByText('subscription.tiers.creator.name')).toBeTruthy()
|
||||
expect(screen.getByText('$35')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders the team plan from the selected slider stop', () => {
|
||||
render(SubscriptionAddPaymentPreviewWorkspace, {
|
||||
props: { teamPlan: { usd: 400, credits: 84_400, discountedUsd: 380 } },
|
||||
global: globalOptions
|
||||
})
|
||||
expect(screen.getByText('subscription.teamPlan.name')).toBeTruthy()
|
||||
expect(screen.getByText('$380')).toBeTruthy()
|
||||
expect(screen.getAllByText('84,400').length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('$380.00')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('emits addCreditCard from the team confirm CTA', async () => {
|
||||
const { emitted } = render(SubscriptionAddPaymentPreviewWorkspace, {
|
||||
props: { teamPlan: { usd: 400, credits: 84_400, discountedUsd: 380 } },
|
||||
global: globalOptions
|
||||
})
|
||||
await userEvent.click(
|
||||
screen.getByText('subscription.preview.subscribeToPlan')
|
||||
)
|
||||
expect(emitted().addCreditCard).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -16,9 +16,16 @@
|
||||
${{ displayPrice }}
|
||||
</span>
|
||||
<span class="text-xl text-base-foreground">
|
||||
{{ $t('subscription.usdPerMonthPerMember') }}
|
||||
{{ $t('subscription.usdPerMonth') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="teamPlan"
|
||||
class="flex items-center gap-1 text-sm text-muted-foreground"
|
||||
>
|
||||
<i class="icon-[comfy--credits] size-3.5 shrink-0 bg-amber-400" />
|
||||
<span>{{ displayCredits }} {{ $t('subscription.perMonth') }}</span>
|
||||
</div>
|
||||
<span class="text-muted-foreground">
|
||||
{{ $t('subscription.preview.startingToday') }}
|
||||
</span>
|
||||
@@ -31,13 +38,10 @@
|
||||
{{ $t('subscription.preview.eachMonthCreditsRefill') }}
|
||||
</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<i class="icon-[lucide--component] text-sm text-amber-400" />
|
||||
<i class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400" />
|
||||
<span class="font-bold text-base-foreground">
|
||||
{{ displayCredits }}
|
||||
</span>
|
||||
<span class="text-base-foreground">
|
||||
{{ $t('subscription.preview.perMember') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -63,36 +67,48 @@
|
||||
/>
|
||||
</button>
|
||||
<div v-show="!isFeaturesCollapsed" class="flex flex-col gap-2 pt-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ $t('subscription.maxDurationLabel') }}
|
||||
</span>
|
||||
<span class="text-sm font-bold text-base-foreground">
|
||||
{{ maxDuration }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ $t('subscription.gpuLabel') }}
|
||||
</span>
|
||||
<i class="pi pi-check text-success-foreground text-xs" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ $t('subscription.addCreditsLabel') }}
|
||||
</span>
|
||||
<i class="pi pi-check text-success-foreground text-xs" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ $t('subscription.customLoRAsLabel') }}
|
||||
</span>
|
||||
<i
|
||||
v-if="hasCustomLoRAs"
|
||||
class="pi pi-check text-success-foreground text-xs"
|
||||
/>
|
||||
<i v-else class="pi pi-times text-xs text-muted-foreground" />
|
||||
</div>
|
||||
<template v-if="teamPlan">
|
||||
<div
|
||||
v-for="perk in teamPerks"
|
||||
:key="perk"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<i class="pi pi-check text-success-foreground text-xs" />
|
||||
<span class="text-sm text-base-foreground">{{ perk }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ $t('subscription.maxDurationLabel') }}
|
||||
</span>
|
||||
<span class="text-sm font-bold text-base-foreground">
|
||||
{{ maxDuration }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ $t('subscription.gpuLabel') }}
|
||||
</span>
|
||||
<i class="pi pi-check text-success-foreground text-xs" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ $t('subscription.addCreditsLabel') }}
|
||||
</span>
|
||||
<i class="pi pi-check text-success-foreground text-xs" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ $t('subscription.customLoRAsLabel') }}
|
||||
</span>
|
||||
<i
|
||||
v-if="hasCustomLoRAs"
|
||||
class="pi pi-check text-success-foreground text-xs"
|
||||
/>
|
||||
<i v-else class="pi pi-times text-xs text-muted-foreground" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -118,30 +134,7 @@
|
||||
<!-- Footer -->
|
||||
<div class="flex flex-col gap-2 pt-8">
|
||||
<!-- Terms Agreement -->
|
||||
<p class="text-center text-xs text-muted-foreground">
|
||||
<i18n-t keypath="subscription.preview.termsAgreement" tag="span">
|
||||
<template #terms>
|
||||
<a
|
||||
href="https://www.comfy.org/terms"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="underline hover:text-base-foreground"
|
||||
>
|
||||
{{ $t('subscription.preview.terms') }}
|
||||
</a>
|
||||
</template>
|
||||
<template #privacy>
|
||||
<a
|
||||
href="https://www.comfy.org/privacy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="underline hover:text-base-foreground"
|
||||
>
|
||||
{{ $t('subscription.preview.privacyPolicy') }}
|
||||
</a>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</p>
|
||||
<SubscriptionTermsNote />
|
||||
|
||||
<!-- Add Credit Card Button -->
|
||||
<Button
|
||||
@@ -151,7 +144,7 @@
|
||||
:loading="isLoading"
|
||||
@click="$emit('addCreditCard')"
|
||||
>
|
||||
{{ $t('subscription.preview.addCreditCard') }}
|
||||
{{ $t('subscription.preview.subscribeToPlan', { plan: tierName }) }}
|
||||
</Button>
|
||||
|
||||
<!-- Back Link -->
|
||||
@@ -171,6 +164,7 @@ import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { TeamPlanSelection } from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
|
||||
import {
|
||||
getTierCredits,
|
||||
getTierFeatures,
|
||||
@@ -181,18 +175,24 @@ import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscript
|
||||
import type { PreviewSubscribeResponse } from '@/platform/workspace/api/workspaceApi'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import SubscriptionTermsNote from './SubscriptionTermsNote.vue'
|
||||
|
||||
interface Props {
|
||||
tierKey: Exclude<TierKey, 'free' | 'founder'>
|
||||
/** Personal-tier checkout. Required unless `teamPlan` is set. */
|
||||
tierKey?: Exclude<TierKey, 'free' | 'founder'>
|
||||
billingCycle?: BillingCycle
|
||||
isLoading?: boolean
|
||||
previewData?: PreviewSubscribeResponse | null
|
||||
/** Team-plan checkout (selected slider stop); overrides tier-derived display. */
|
||||
teamPlan?: TeamPlanSelection | null
|
||||
}
|
||||
|
||||
const {
|
||||
tierKey,
|
||||
billingCycle = 'monthly',
|
||||
isLoading = false,
|
||||
previewData = null
|
||||
previewData = null,
|
||||
teamPlan = null
|
||||
} = defineProps<Props>()
|
||||
|
||||
defineEmits<{
|
||||
@@ -204,24 +204,42 @@ const { t, n } = useI18n()
|
||||
|
||||
const isFeaturesCollapsed = ref(true)
|
||||
|
||||
const tierName = computed(() => t(`subscription.tiers.${tierKey}.name`))
|
||||
const tierName = computed(() =>
|
||||
teamPlan
|
||||
? t('subscription.teamPlan.name')
|
||||
: t(`subscription.tiers.${tierKey}.name`)
|
||||
)
|
||||
|
||||
const displayPrice = computed(() => {
|
||||
if (teamPlan) return teamPlan.discountedUsd
|
||||
if (previewData?.new_plan) {
|
||||
return (previewData.new_plan.price_cents / 100).toFixed(0)
|
||||
}
|
||||
return getTierPrice(tierKey, billingCycle === 'yearly')
|
||||
return tierKey ? getTierPrice(tierKey, billingCycle === 'yearly') : 0
|
||||
})
|
||||
|
||||
const displayCredits = computed(() => n(getTierCredits(tierKey) ?? 0))
|
||||
const displayCredits = computed(() =>
|
||||
n(teamPlan ? teamPlan.credits : tierKey ? (getTierCredits(tierKey) ?? 0) : 0)
|
||||
)
|
||||
|
||||
const hasCustomLoRAs = computed(() => getTierFeatures(tierKey).customLoRAs)
|
||||
const teamPerks = computed(() => [
|
||||
t('subscription.teamPlan.perkInviteMembers'),
|
||||
t('subscription.teamPlan.perkConcurrentRuns'),
|
||||
t('subscription.teamPlan.perkSharedPool'),
|
||||
t('subscription.teamPlan.perkRolePermissions')
|
||||
])
|
||||
|
||||
const hasCustomLoRAs = computed(() =>
|
||||
tierKey ? getTierFeatures(tierKey).customLoRAs : false
|
||||
)
|
||||
const maxDuration = computed(() => t(`subscription.maxDuration.${tierKey}`))
|
||||
|
||||
const totalDueToday = computed(() => {
|
||||
if (teamPlan) return teamPlan.discountedUsd.toFixed(2)
|
||||
if (previewData) {
|
||||
return (previewData.cost_today_cents / 100).toFixed(2)
|
||||
}
|
||||
if (!tierKey) return '0.00'
|
||||
const priceValue = getTierPrice(tierKey, billingCycle === 'yearly')
|
||||
if (billingCycle === 'yearly') {
|
||||
return (priceValue * 12).toFixed(2)
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import type { PreviewSubscribeResponse } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import SubscriptionAddPaymentPreviewWorkspace from './SubscriptionAddPaymentPreviewWorkspace.vue'
|
||||
import SubscriptionSuccessWorkspace from './SubscriptionSuccessWorkspace.vue'
|
||||
import SubscriptionTransitionPreviewWorkspace from './SubscriptionTransitionPreviewWorkspace.vue'
|
||||
|
||||
type PreviewPlanInfo = PreviewSubscribeResponse['new_plan']
|
||||
|
||||
/**
|
||||
* Checkout steps of the unified subscription dialog (FE-934): the
|
||||
* "Confirm your payment" / "Confirm your plan change" preview screens and the
|
||||
* "You're all set" success screen. Driven by props (no API in Storybook).
|
||||
*/
|
||||
const meta: Meta = {
|
||||
title: 'Components/SubscriptionCheckoutSteps',
|
||||
parameters: { layout: 'centered' }
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj
|
||||
|
||||
const creatorPlan: PreviewPlanInfo = {
|
||||
slug: 'creator-annual',
|
||||
tier: 'CREATOR',
|
||||
duration: 'ANNUAL',
|
||||
price_cents: 2800,
|
||||
credits_cents: 740000,
|
||||
seat_summary: {
|
||||
seat_count: 1,
|
||||
total_cost_cents: 2800,
|
||||
total_credits_cents: 740000
|
||||
},
|
||||
period_end: '2027-07-10T00:00:00Z'
|
||||
}
|
||||
|
||||
const proPlan: PreviewPlanInfo = {
|
||||
slug: 'pro-annual',
|
||||
tier: 'PRO',
|
||||
duration: 'ANNUAL',
|
||||
price_cents: 8000,
|
||||
credits_cents: 2110000,
|
||||
seat_summary: {
|
||||
seat_count: 1,
|
||||
total_cost_cents: 8000,
|
||||
total_credits_cents: 2110000
|
||||
},
|
||||
period_end: '2026-07-10T00:00:00Z'
|
||||
}
|
||||
|
||||
const shell =
|
||||
'<div class="mx-auto flex h-[680px] w-[460px] flex-col rounded-2xl border border-border-default bg-secondary-background p-12">'
|
||||
|
||||
/** New subscription — "Confirm your payment" (AddPayment preview). */
|
||||
export const ConfirmNewSubscription: Story = {
|
||||
render: () => ({
|
||||
components: { SubscriptionAddPaymentPreviewWorkspace },
|
||||
data: () => ({
|
||||
previewData: {
|
||||
allowed: true,
|
||||
transition_type: 'new_subscription',
|
||||
effective_at: '2026-07-10T00:00:00Z',
|
||||
is_immediate: true,
|
||||
cost_today_cents: 2800,
|
||||
cost_next_period_cents: 2800,
|
||||
credits_today_cents: 740000,
|
||||
credits_next_period_cents: 740000,
|
||||
new_plan: creatorPlan
|
||||
} satisfies PreviewSubscribeResponse
|
||||
}),
|
||||
template: `${shell}<SubscriptionAddPaymentPreviewWorkspace tier-key="creator" billing-cycle="yearly" :preview-data="previewData" /></div>`
|
||||
})
|
||||
}
|
||||
|
||||
/** Team subscription — "Confirm your payment" rendered from a slider stop. */
|
||||
export const ConfirmTeamSubscription: Story = {
|
||||
render: () => ({
|
||||
components: { SubscriptionAddPaymentPreviewWorkspace },
|
||||
data: () => ({ teamPlan: { usd: 400, credits: 84_400 } }),
|
||||
template: `${shell}<SubscriptionAddPaymentPreviewWorkspace :team-plan="teamPlan" /></div>`
|
||||
})
|
||||
}
|
||||
|
||||
/** Plan change — "Confirm your plan change" (Transition preview, Pro → Creator). */
|
||||
export const ConfirmPlanChange: Story = {
|
||||
render: () => ({
|
||||
components: { SubscriptionTransitionPreviewWorkspace },
|
||||
data: () => ({
|
||||
previewData: {
|
||||
allowed: true,
|
||||
transition_type: 'downgrade',
|
||||
effective_at: '2026-07-10T00:00:00Z',
|
||||
is_immediate: false,
|
||||
cost_today_cents: 0,
|
||||
cost_next_period_cents: 2800,
|
||||
credits_today_cents: 0,
|
||||
credits_next_period_cents: 740000,
|
||||
current_plan: proPlan,
|
||||
new_plan: creatorPlan
|
||||
} satisfies PreviewSubscribeResponse
|
||||
}),
|
||||
template: `${shell}<SubscriptionTransitionPreviewWorkspace :preview-data="previewData" /></div>`
|
||||
})
|
||||
}
|
||||
|
||||
/** Success — "You're all set". */
|
||||
export const SuccessAllSet: Story = {
|
||||
render: () => ({
|
||||
components: { SubscriptionSuccessWorkspace },
|
||||
data: () => ({
|
||||
previewData: {
|
||||
allowed: true,
|
||||
transition_type: 'new_subscription',
|
||||
effective_at: '2026-07-10T00:00:00Z',
|
||||
is_immediate: true,
|
||||
cost_today_cents: 2800,
|
||||
cost_next_period_cents: 2800,
|
||||
credits_today_cents: 740000,
|
||||
credits_next_period_cents: 740000,
|
||||
new_plan: creatorPlan
|
||||
} satisfies PreviewSubscribeResponse
|
||||
}),
|
||||
template: `${shell}<SubscriptionSuccessWorkspace tier-key="creator" :preview-data="previewData" /></div>`
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'relative flex h-full flex-col gap-4 overflow-y-auto p-4 pt-6',
|
||||
checkoutStep === 'pricing' &&
|
||||
'xl:min-h-[min(740px,90vh)] xl:w-[min(1280px,95vw)]'
|
||||
)
|
||||
"
|
||||
>
|
||||
<Button
|
||||
v-if="checkoutStep === 'preview'"
|
||||
size="icon"
|
||||
variant="muted-textonly"
|
||||
class="absolute top-2.5 left-2.5 shrink-0 rounded-full text-text-secondary hover:bg-white/10"
|
||||
:aria-label="$t('g.back')"
|
||||
@click="handleBackToPricing"
|
||||
>
|
||||
<i class="pi pi-arrow-left text-xl" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
variant="muted-textonly"
|
||||
class="absolute top-2.5 right-2.5 shrink-0 rounded-full text-text-secondary hover:bg-white/10"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="onClose"
|
||||
>
|
||||
<i class="pi pi-times text-xl" />
|
||||
</Button>
|
||||
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<h2 class="m-0 font-inter text-2xl font-semibold text-base-foreground">
|
||||
{{ $t('subscription.descriptionWorkspace') }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div v-if="reason === 'out_of_credits'" class="text-center">
|
||||
<h2 class="m-0 text-xl text-muted-foreground lg:text-2xl">
|
||||
{{ $t('credits.topUp.insufficientTitle') }}
|
||||
</h2>
|
||||
<p class="m-0 mt-2 text-sm text-text-secondary">
|
||||
{{ $t('credits.topUp.insufficientMessage') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Pricing Table Step (unified: personal/team plan toggle) -->
|
||||
<UnifiedPricingTable
|
||||
v-if="checkoutStep === 'pricing'"
|
||||
class="xl:flex-1"
|
||||
:initial-plan-mode="initialPlanMode"
|
||||
:is-loading="isLoadingPreview || isResubscribing"
|
||||
:loading-tier="loadingTier"
|
||||
@subscribe="handleSubscribeClick"
|
||||
@resubscribe="handleResubscribe"
|
||||
@subscribe-team="handleSubscribeTeamClick"
|
||||
/>
|
||||
|
||||
<!-- Subscription Preview Step - New Subscription -->
|
||||
<SubscriptionAddPaymentPreviewWorkspace
|
||||
v-else-if="
|
||||
checkoutStep === 'preview' &&
|
||||
previewData &&
|
||||
previewData.transition_type === 'new_subscription'
|
||||
"
|
||||
:preview-data="previewData"
|
||||
:tier-key="selectedTierKey!"
|
||||
:billing-cycle="selectedBillingCycle"
|
||||
:is-loading="isSubscribing || isPolling"
|
||||
@add-credit-card="handleAddCreditCard"
|
||||
@back="handleBackToPricing"
|
||||
/>
|
||||
|
||||
<!-- Subscription Preview Step - Plan Transition -->
|
||||
<SubscriptionTransitionPreviewWorkspace
|
||||
v-else-if="
|
||||
checkoutStep === 'preview' &&
|
||||
previewData &&
|
||||
previewData.transition_type !== 'new_subscription'
|
||||
"
|
||||
:preview-data="previewData"
|
||||
:is-loading="isSubscribing || isPolling"
|
||||
@confirm="handleConfirmTransition"
|
||||
@back="handleBackToPricing"
|
||||
/>
|
||||
|
||||
<!-- Subscription Preview Step - Team (display-only until the BE slider
|
||||
contract lands; the confirm CTA is stubbed below) -->
|
||||
<SubscriptionAddPaymentPreviewWorkspace
|
||||
v-else-if="checkoutStep === 'preview' && selectedTeamStop"
|
||||
:team-plan="selectedTeamStop"
|
||||
@add-credit-card="handleTeamSubscribe"
|
||||
@back="handleBackToPricing"
|
||||
/>
|
||||
|
||||
<!-- Success Step - "You're all set" -->
|
||||
<SubscriptionSuccessWorkspace
|
||||
v-else-if="checkoutStep === 'success' && selectedTierKey"
|
||||
:tier-key="selectedTierKey"
|
||||
:preview-data="previewData"
|
||||
@close="handleSuccessClose"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import { useSubscriptionCheckout } from '@/platform/workspace/composables/useSubscriptionCheckout'
|
||||
|
||||
import SubscriptionAddPaymentPreviewWorkspace from './SubscriptionAddPaymentPreviewWorkspace.vue'
|
||||
import SubscriptionSuccessWorkspace from './SubscriptionSuccessWorkspace.vue'
|
||||
import SubscriptionTransitionPreviewWorkspace from './SubscriptionTransitionPreviewWorkspace.vue'
|
||||
import UnifiedPricingTable from './UnifiedPricingTable.vue'
|
||||
|
||||
const { onClose, reason, initialPlanMode } = defineProps<{
|
||||
onClose: () => void
|
||||
reason?: SubscriptionDialogReason
|
||||
initialPlanMode?: 'personal' | 'team'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [subscribed: boolean]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
const {
|
||||
checkoutStep,
|
||||
isLoadingPreview,
|
||||
loadingTier,
|
||||
isSubscribing,
|
||||
isResubscribing,
|
||||
previewData,
|
||||
selectedTierKey,
|
||||
selectedTeamStop,
|
||||
selectedBillingCycle,
|
||||
isPolling,
|
||||
handleSubscribeClick,
|
||||
handleSubscribeTeamClick,
|
||||
handleBackToPricing,
|
||||
handleSuccessClose,
|
||||
handleAddCreditCard,
|
||||
handleConfirmTransition,
|
||||
handleResubscribe
|
||||
} = useSubscriptionCheckout(emit)
|
||||
|
||||
// Personal-tier checkout reuses the full useSubscriptionCheckout flow above.
|
||||
// Team-plan checkout renders the confirm step from the selected slider stop,
|
||||
// but the final subscribe is blocked on the BE discount-breakpoint contract
|
||||
// (FE-934 / doc Open Q#2: the slider stop -> plan-slug / subscribe-request shape
|
||||
// is undefined), so the confirm CTA is stubbed until that lands.
|
||||
function handleTeamSubscribe() {
|
||||
toast.add({
|
||||
severity: 'info',
|
||||
summary: t('subscription.teamPlan.name'),
|
||||
detail: t('subscription.teamPlan.checkoutComingSoon'),
|
||||
life: 4000
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -14,7 +14,8 @@ const mockHandleBackToPricing = vi.fn()
|
||||
const mockHandleAddCreditCard = vi.fn()
|
||||
const mockHandleConfirmTransition = vi.fn()
|
||||
const mockHandleResubscribe = vi.fn()
|
||||
const mockCheckoutStep = ref<'pricing' | 'preview'>('pricing')
|
||||
const mockHandleSuccessClose = vi.fn()
|
||||
const mockCheckoutStep = ref<'pricing' | 'preview' | 'success'>('pricing')
|
||||
const mockPreviewData = ref<{ transition_type: string } | null>(null)
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useSubscriptionCheckout', () => ({
|
||||
@@ -32,7 +33,8 @@ vi.mock('@/platform/workspace/composables/useSubscriptionCheckout', () => ({
|
||||
handleBackToPricing: mockHandleBackToPricing,
|
||||
handleAddCreditCard: mockHandleAddCreditCard,
|
||||
handleConfirmTransition: mockHandleConfirmTransition,
|
||||
handleResubscribe: mockHandleResubscribe
|
||||
handleResubscribe: mockHandleResubscribe,
|
||||
handleSuccessClose: mockHandleSuccessClose
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -78,6 +80,13 @@ const TransitionPreviewStub = {
|
||||
</div>`
|
||||
}
|
||||
|
||||
const SuccessStub = {
|
||||
name: 'SubscriptionSuccessWorkspace',
|
||||
template: `<div data-testid="success">
|
||||
<button data-testid="success-close-btn" @click="$emit('close')">Done</button>
|
||||
</div>`
|
||||
}
|
||||
|
||||
function renderComponent(
|
||||
props: { onClose?: () => void; reason?: SubscriptionDialogReason } = {}
|
||||
) {
|
||||
@@ -94,7 +103,8 @@ function renderComponent(
|
||||
stubs: {
|
||||
PricingTableWorkspace: PricingTableStub,
|
||||
SubscriptionAddPaymentPreviewWorkspace: AddPaymentPreviewStub,
|
||||
SubscriptionTransitionPreviewWorkspace: TransitionPreviewStub
|
||||
SubscriptionTransitionPreviewWorkspace: TransitionPreviewStub,
|
||||
SubscriptionSuccessWorkspace: SuccessStub
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -195,4 +205,21 @@ describe('SubscriptionRequiredDialogContentWorkspace', () => {
|
||||
|
||||
expect(mockHandleBackToPricing).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows the success screen on the success step', () => {
|
||||
mockCheckoutStep.value = 'success'
|
||||
renderComponent()
|
||||
expect(screen.getByTestId('success')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('pricing-table')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('wires the success close event to handleSuccessClose', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockCheckoutStep.value = 'success'
|
||||
renderComponent()
|
||||
|
||||
await user.click(screen.getByTestId('success-close-btn'))
|
||||
|
||||
expect(mockHandleSuccessClose).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -90,6 +90,14 @@
|
||||
@confirm="handleConfirmTransition"
|
||||
@back="handleBackToPricing"
|
||||
/>
|
||||
|
||||
<!-- Success Step - subscribe/change-plan confirmation -->
|
||||
<SubscriptionSuccessWorkspace
|
||||
v-else-if="checkoutStep === 'success' && selectedTierKey"
|
||||
:tier-key="selectedTierKey"
|
||||
:preview-data="previewData"
|
||||
@close="handleSuccessClose"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -100,6 +108,7 @@ import { useSubscriptionCheckout } from '@/platform/workspace/composables/useSub
|
||||
|
||||
import PricingTableWorkspace from './PricingTableWorkspace.vue'
|
||||
import SubscriptionAddPaymentPreviewWorkspace from './SubscriptionAddPaymentPreviewWorkspace.vue'
|
||||
import SubscriptionSuccessWorkspace from './SubscriptionSuccessWorkspace.vue'
|
||||
import SubscriptionTransitionPreviewWorkspace from './SubscriptionTransitionPreviewWorkspace.vue'
|
||||
|
||||
const { onClose, reason } = defineProps<{
|
||||
@@ -125,7 +134,8 @@ const {
|
||||
handleBackToPricing,
|
||||
handleAddCreditCard,
|
||||
handleConfirmTransition,
|
||||
handleResubscribe
|
||||
handleResubscribe,
|
||||
handleSuccessClose
|
||||
} = useSubscriptionCheckout(emit)
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { PreviewSubscribeResponse } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import SubscriptionSuccessWorkspace from './SubscriptionSuccessWorkspace.vue'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
n: (value: number) => String(value)
|
||||
})
|
||||
}))
|
||||
|
||||
function makePreviewData(priceCents: number): PreviewSubscribeResponse {
|
||||
return {
|
||||
allowed: true,
|
||||
transition_type: 'new_subscription',
|
||||
effective_at: '2026-07-10T00:00:00Z',
|
||||
is_immediate: true,
|
||||
cost_today_cents: priceCents,
|
||||
cost_next_period_cents: priceCents,
|
||||
credits_today_cents: 0,
|
||||
credits_next_period_cents: 0,
|
||||
new_plan: {
|
||||
slug: 'standard-monthly',
|
||||
tier: 'STANDARD',
|
||||
duration: 'MONTHLY',
|
||||
price_cents: priceCents,
|
||||
credits_cents: 0,
|
||||
seat_summary: {
|
||||
seat_count: 1,
|
||||
total_cost_cents: priceCents,
|
||||
total_credits_cents: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderCard() {
|
||||
return render(SubscriptionSuccessWorkspace, {
|
||||
props: {
|
||||
tierKey: 'standard',
|
||||
previewData: makePreviewData(1600)
|
||||
},
|
||||
global: {
|
||||
mocks: { $t: (key: string) => key },
|
||||
stubs: {
|
||||
Button: {
|
||||
template: '<button @click="$emit(\'click\')"><slot /></button>'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('SubscriptionSuccessWorkspace', () => {
|
||||
it('renders the all-set heading and plan price', () => {
|
||||
renderCard()
|
||||
expect(screen.getByText('subscription.success.allSet')).toBeTruthy()
|
||||
expect(screen.getByText('$16')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('emits close when the close button is clicked', async () => {
|
||||
const { emitted } = renderCard()
|
||||
await userEvent.click(screen.getByRole('button'))
|
||||
expect(emitted().close).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<div
|
||||
class="mx-auto flex h-full max-w-[400px] flex-col items-stretch justify-between text-sm"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-4 pt-8">
|
||||
<i class="pi pi-check-circle text-success-foreground text-5xl" />
|
||||
<h2
|
||||
class="m-0 text-center text-xl font-semibold text-base-foreground lg:text-2xl"
|
||||
>
|
||||
{{ $t('subscription.success.allSet') }}
|
||||
</h2>
|
||||
<p class="m-0 text-center text-sm text-muted-foreground">
|
||||
{{ $t('subscription.success.planUpdated') }}
|
||||
{{ $t('subscription.success.receiptEmailed') }}
|
||||
</p>
|
||||
|
||||
<!-- Plan summary -->
|
||||
<div
|
||||
class="mt-4 flex w-full flex-col gap-1 rounded-xl border border-border-default bg-base-background p-4"
|
||||
>
|
||||
<span class="text-sm text-base-foreground">{{ tierName }}</span>
|
||||
<div class="flex items-baseline gap-1">
|
||||
<span class="text-2xl font-semibold text-base-foreground">
|
||||
${{ displayPrice }}
|
||||
</span>
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ $t('subscription.usdPerMonth') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<i class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400" />
|
||||
<span>{{ displayCredits }} {{ $t('subscription.perMonth') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Team success "Invite your team" block renders here (FE-965 / DES-394). -->
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 pt-8">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
class="w-full rounded-lg"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
{{ $t('g.close') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { getTierCredits } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { PreviewSubscribeResponse } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
const { tierKey, previewData = null } = defineProps<{
|
||||
tierKey: Exclude<TierKey, 'free' | 'founder'>
|
||||
previewData?: PreviewSubscribeResponse | null
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const { t, n } = useI18n()
|
||||
|
||||
const tierName = computed(() => t(`subscription.tiers.${tierKey}.name`))
|
||||
|
||||
const displayPrice = computed(() =>
|
||||
previewData?.new_plan
|
||||
? (previewData.new_plan.price_cents / 100).toFixed(0)
|
||||
: '0'
|
||||
)
|
||||
|
||||
const displayCredits = computed(() => n(getTierCredits(tierKey) ?? 0))
|
||||
</script>
|
||||
26
src/platform/workspace/components/SubscriptionTermsNote.vue
Normal file
26
src/platform/workspace/components/SubscriptionTermsNote.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<p class="m-0 text-center text-xs text-muted-foreground">
|
||||
<i18n-t keypath="subscription.preview.termsAgreement" tag="span">
|
||||
<template #terms>
|
||||
<a
|
||||
href="https://www.comfy.org/terms"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="underline hover:text-base-foreground"
|
||||
>
|
||||
{{ $t('subscription.preview.terms') }}
|
||||
</a>
|
||||
</template>
|
||||
<template #privacy>
|
||||
<a
|
||||
href="https://www.comfy.org/privacy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="underline hover:text-base-foreground"
|
||||
>
|
||||
{{ $t('subscription.preview.privacyPolicy') }}
|
||||
</a>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</p>
|
||||
</template>
|
||||
@@ -9,7 +9,7 @@
|
||||
<!-- Plan Comparison Header -->
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Current Plan -->
|
||||
<div class="flex w-[250px] flex-col gap-1">
|
||||
<div class="flex flex-1 flex-col gap-1">
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ currentTierName }}
|
||||
</span>
|
||||
@@ -18,11 +18,11 @@
|
||||
${{ currentDisplayPrice }}
|
||||
</span>
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ $t('subscription.usdPerMonthPerMember') }}
|
||||
{{ $t('subscription.usdPerMonth') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<i class="icon-[lucide--component] text-xs text-amber-400" />
|
||||
<i class="icon-[comfy--credits] size-3.5 shrink-0 bg-amber-400" />
|
||||
<span
|
||||
>{{ currentDisplayCredits }}
|
||||
{{ $t('subscription.perMonth') }}</span
|
||||
@@ -36,10 +36,10 @@
|
||||
</div>
|
||||
|
||||
<!-- Arrow -->
|
||||
<i class="pi pi-arrow-right size-8 text-muted-foreground" />
|
||||
<i class="pi pi-arrow-right size-8 shrink-0 text-muted-foreground" />
|
||||
|
||||
<!-- New Plan -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex flex-1 flex-col gap-1">
|
||||
<span class="text-sm font-semibold text-base-foreground">
|
||||
{{ newTierName }}
|
||||
</span>
|
||||
@@ -48,11 +48,11 @@
|
||||
${{ newDisplayPrice }}
|
||||
</span>
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ $t('subscription.usdPerMonthPerMember') }}
|
||||
{{ $t('subscription.usdPerMonth') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<i class="icon-[lucide--component] text-xs text-amber-400" />
|
||||
<i class="icon-[comfy--credits] size-3.5 shrink-0 bg-amber-400" />
|
||||
<span
|
||||
>{{ newDisplayCredits }} {{ $t('subscription.perMonth') }}</span
|
||||
>
|
||||
@@ -63,19 +63,32 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Credits Section -->
|
||||
<!-- Next Cycle Section -->
|
||||
<div class="flex flex-col gap-3 pt-12 pb-6">
|
||||
<span class="text-base-foreground">
|
||||
{{
|
||||
$t('subscription.preview.everyMonthStarting', {
|
||||
date: effectiveDate
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-base-foreground">
|
||||
{{ $t('subscription.preview.eachMonthCreditsRefill') }}
|
||||
<span class="text-muted-foreground">
|
||||
{{ $t('subscription.preview.creditsRefillTo') }}
|
||||
</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<i class="icon-[lucide--component] text-sm text-amber-400" />
|
||||
<i class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400" />
|
||||
<span class="font-bold text-base-foreground">
|
||||
{{ newDisplayCredits }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-muted-foreground">
|
||||
{{ $t('subscription.preview.youllBeCharged') }}
|
||||
</span>
|
||||
<span class="text-base-foreground">${{ newMonthlyCharge }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Proration Section -->
|
||||
@@ -131,6 +144,8 @@
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex flex-col gap-2 pt-8">
|
||||
<SubscriptionTermsNote />
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
@@ -138,7 +153,7 @@
|
||||
:loading="isLoading"
|
||||
@click="$emit('confirm')"
|
||||
>
|
||||
{{ $t('subscription.preview.confirm') }}
|
||||
{{ $t('subscription.preview.switchToPlan', { plan: newTierName }) }}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@@ -160,6 +175,8 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
import { getTierCredits } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { PreviewSubscribeResponse } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import SubscriptionTermsNote from './SubscriptionTermsNote.vue'
|
||||
|
||||
interface Props {
|
||||
previewData: PreviewSubscribeResponse
|
||||
isLoading?: boolean
|
||||
@@ -202,6 +219,10 @@ const newDisplayPrice = computed(() =>
|
||||
(previewData.new_plan.price_cents / 100).toFixed(0)
|
||||
)
|
||||
|
||||
const newMonthlyCharge = computed(() =>
|
||||
(previewData.new_plan.price_cents / 100).toFixed(2)
|
||||
)
|
||||
|
||||
const currentDisplayCredits = computed(() => {
|
||||
if (!previewData.current_plan) return n(0)
|
||||
const tierKey = previewData.current_plan.tier.toLowerCase() as
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import UnifiedPricingTable from './UnifiedPricingTable.vue'
|
||||
|
||||
/**
|
||||
* The unified pricing table (B4 / FE-934): one table for the new billing model,
|
||||
* with a personal/team **plan** toggle on a single workspace (Gamma-style).
|
||||
*
|
||||
* Note: the personal/team toggle itself only renders when `teamWorkspacesEnabled`
|
||||
* is on (a server flag, off in Storybook), so these stories drive the view via
|
||||
* `initialPlanMode` instead. Personal prices fall back to the static
|
||||
* `TIER_PRICING` (no API in Storybook); the team column uses the locked DES-197
|
||||
* credit-slider stops.
|
||||
*/
|
||||
const meta: Meta<typeof UnifiedPricingTable> = {
|
||||
title: 'Components/UnifiedPricingTable',
|
||||
component: UnifiedPricingTable,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'fullscreen' },
|
||||
argTypes: {
|
||||
initialPlanMode: {
|
||||
control: 'inline-radio',
|
||||
options: ['personal', 'team']
|
||||
}
|
||||
},
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
template:
|
||||
'<div class="mx-auto max-w-[1328px] bg-base-background p-8"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
/** Personal plans (Standard / Creator / Pro) with the monthly/yearly toggle. */
|
||||
export const Personal: Story = {
|
||||
args: { initialPlanMode: 'personal' }
|
||||
}
|
||||
|
||||
/** Team plan: the credit slider + Enterprise card. */
|
||||
export const TeamPlan: Story = {
|
||||
args: { initialPlanMode: 'team' }
|
||||
}
|
||||
233
src/platform/workspace/components/UnifiedPricingTable.test.ts
Normal file
233
src/platform/workspace/components/UnifiedPricingTable.test.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
import UnifiedPricingTable from '@/platform/workspace/components/UnifiedPricingTable.vue'
|
||||
|
||||
interface MockSubscription {
|
||||
tier: string
|
||||
isCancelled?: boolean
|
||||
duration?: string
|
||||
}
|
||||
|
||||
interface MockTeamStop {
|
||||
id: string
|
||||
credits_monthly: number
|
||||
stop_usd: number
|
||||
}
|
||||
|
||||
const mockSubscription = ref<MockSubscription | null>(null)
|
||||
const mockCurrentPlanSlug = ref<string | null>(null)
|
||||
const mockCurrentTeamCreditStop = ref<MockTeamStop | null>(null)
|
||||
const mockTeamFlag = ref(false)
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
plans: ref([]),
|
||||
currentPlanSlug: computed(() => mockCurrentPlanSlug.value),
|
||||
fetchPlans: vi.fn(),
|
||||
subscription: computed(() => mockSubscription.value),
|
||||
currentTeamCreditStop: computed(() => mockCurrentTeamCreditStop.value)
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({
|
||||
flags: { teamWorkspacesEnabled: mockTeamFlag.value }
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
function renderComponent(props: Record<string, unknown> = {}) {
|
||||
return render(UnifiedPricingTable, {
|
||||
props,
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
components: { Button },
|
||||
stubs: {
|
||||
SelectButton: { template: '<div />' },
|
||||
// Clicking emits a change to a different stop ($200) so tests can move
|
||||
// the selection off the current stop.
|
||||
CreditSlider: {
|
||||
template:
|
||||
'<button data-testid="team-slider" @click="$emit(\'change\', { index: 0, usd: 200, credits: 42200 })" />',
|
||||
emits: ['change', 'update:modelValue']
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('UnifiedPricingTable plan CTA labels', () => {
|
||||
beforeEach(() => {
|
||||
mockSubscription.value = null
|
||||
mockCurrentPlanSlug.value = null
|
||||
mockCurrentTeamCreditStop.value = null
|
||||
mockTeamFlag.value = false
|
||||
})
|
||||
|
||||
it('prompts free-tier users to subscribe, never to "change"', () => {
|
||||
mockSubscription.value = { tier: 'FREE', duration: 'ANNUAL' }
|
||||
|
||||
renderComponent()
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Subscribe to Standard Yearly' })
|
||||
).toBeTruthy()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Subscribe to Creator Yearly' })
|
||||
).toBeTruthy()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Subscribe to Pro Yearly' })
|
||||
).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: /^Change to/ })).toBeNull()
|
||||
})
|
||||
|
||||
it('offers a plan change to users already on a paid plan', () => {
|
||||
mockSubscription.value = { tier: 'STANDARD', duration: 'ANNUAL' }
|
||||
|
||||
renderComponent()
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Change to Creator Yearly' })
|
||||
).toBeTruthy()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Change to Pro Yearly' })
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps personal tier cards actionable for a team subscriber', () => {
|
||||
mockSubscription.value = { tier: 'TEAM', duration: 'ANNUAL' }
|
||||
mockCurrentTeamCreditStop.value = {
|
||||
id: 'team_700',
|
||||
credits_monthly: 147_700,
|
||||
stop_usd: 700
|
||||
}
|
||||
|
||||
renderComponent()
|
||||
|
||||
expect(screen.queryByRole('button', { name: /Not available/ })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('UnifiedPricingTable team plan CTA', () => {
|
||||
const TEAM_STOP = {
|
||||
id: 'team_2500',
|
||||
credits_monthly: 527_500,
|
||||
stop_usd: 2_500
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockSubscription.value = null
|
||||
mockCurrentPlanSlug.value = null
|
||||
mockCurrentTeamCreditStop.value = null
|
||||
mockTeamFlag.value = true
|
||||
})
|
||||
|
||||
it('disables the CTA while sitting on the active current plan', () => {
|
||||
mockSubscription.value = {
|
||||
tier: 'TEAM',
|
||||
duration: 'ANNUAL',
|
||||
isCancelled: false
|
||||
}
|
||||
mockCurrentTeamCreditStop.value = TEAM_STOP
|
||||
|
||||
renderComponent({ initialPlanMode: 'team' })
|
||||
|
||||
const cta = screen.getByRole('button', { name: 'Current plan' })
|
||||
expect(cta).toBeDisabled()
|
||||
})
|
||||
|
||||
it('lets an active sub change to a different stop', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockSubscription.value = {
|
||||
tier: 'TEAM',
|
||||
duration: 'ANNUAL',
|
||||
isCancelled: false
|
||||
}
|
||||
mockCurrentTeamCreditStop.value = TEAM_STOP
|
||||
|
||||
const { emitted } = renderComponent({ initialPlanMode: 'team' })
|
||||
|
||||
await user.click(screen.getByTestId('team-slider'))
|
||||
|
||||
const cta = screen.getByRole('button', { name: 'Change plan' })
|
||||
expect(cta).toBeEnabled()
|
||||
await user.click(cta)
|
||||
expect(emitted().subscribeTeam).toBeTruthy()
|
||||
})
|
||||
|
||||
it('lets an active sub change billing cycle at the current stop', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockSubscription.value = {
|
||||
tier: 'TEAM',
|
||||
duration: 'MONTHLY',
|
||||
isCancelled: false
|
||||
}
|
||||
mockCurrentTeamCreditStop.value = TEAM_STOP
|
||||
|
||||
const { emitted } = renderComponent({ initialPlanMode: 'team' })
|
||||
|
||||
// The subscription is monthly; the default view is yearly, so the same stop
|
||||
// on the other cycle is a change, not the current plan.
|
||||
const cta = screen.getByRole('button', { name: 'Change plan' })
|
||||
expect(cta).toBeEnabled()
|
||||
await user.click(cta)
|
||||
expect(emitted().subscribeTeam).toBeTruthy()
|
||||
expect(emitted().resubscribe).toBeFalsy()
|
||||
})
|
||||
|
||||
it('re-subscribes (not change) for a cancelled team subscription', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockSubscription.value = {
|
||||
tier: 'TEAM',
|
||||
duration: 'ANNUAL',
|
||||
isCancelled: true
|
||||
}
|
||||
mockCurrentTeamCreditStop.value = TEAM_STOP
|
||||
|
||||
const { emitted } = renderComponent({ initialPlanMode: 'team' })
|
||||
|
||||
const cta = screen.getByRole('button', { name: 'Resubscribe' })
|
||||
expect(cta).toBeEnabled()
|
||||
await user.click(cta)
|
||||
expect(emitted().resubscribe).toBeTruthy()
|
||||
})
|
||||
|
||||
it('lets a cancelled sub change to a different stop (not re-subscribe)', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockSubscription.value = {
|
||||
tier: 'TEAM',
|
||||
duration: 'ANNUAL',
|
||||
isCancelled: true
|
||||
}
|
||||
mockCurrentTeamCreditStop.value = TEAM_STOP
|
||||
|
||||
const { emitted } = renderComponent({ initialPlanMode: 'team' })
|
||||
|
||||
await user.click(screen.getByTestId('team-slider'))
|
||||
|
||||
const cta = screen.getByRole('button', { name: 'Change plan' })
|
||||
expect(cta).toBeEnabled()
|
||||
await user.click(cta)
|
||||
expect(emitted().subscribeTeam).toBeTruthy()
|
||||
expect(emitted().resubscribe).toBeFalsy()
|
||||
})
|
||||
|
||||
it('prompts a fresh subscribe when on no team plan', () => {
|
||||
renderComponent({ initialPlanMode: 'team' })
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Subscribe to Team Yearly' })
|
||||
).toBeTruthy()
|
||||
})
|
||||
})
|
||||
833
src/platform/workspace/components/UnifiedPricingTable.vue
Normal file
833
src/platform/workspace/components/UnifiedPricingTable.vue
Normal file
@@ -0,0 +1,833 @@
|
||||
<template>
|
||||
<div class="flex flex-col xl:h-full">
|
||||
<!-- Plan-scope toggle (personal vs team PLAN on one workspace): sits directly
|
||||
on top of the content area — outside it, attached with no gap (DES QA).
|
||||
Only shown when team plans are available (teamWorkspacesEnabled). -->
|
||||
<div v-if="showTeam" class="flex justify-center">
|
||||
<SelectButton
|
||||
v-model="planMode"
|
||||
:options="planScopeOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
:allow-empty="false"
|
||||
unstyled
|
||||
:pt="planScopeButtonPt"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Content well: a borderless base-background area (DES-197 "Personal Plan,
|
||||
Yearly" 2951:584251 — bg base-background, rounded-2xl, NO border, 32px
|
||||
padding) holding the description, billing toggle and plan cards on one
|
||||
uniform surface. "Remove the outline" = drop the border, not the area.
|
||||
Grows to fill the dialog so its height stays constant across the
|
||||
personal/team toggle. -->
|
||||
<div
|
||||
class="flex min-h-0 flex-col gap-6 rounded-2xl bg-base-background p-8 xl:flex-1"
|
||||
>
|
||||
<!-- Plan-scope description, above the billing toggle (DES-197). -->
|
||||
<I18nT
|
||||
v-if="planMode === 'personal'"
|
||||
keypath="subscription.personalHeader"
|
||||
tag="p"
|
||||
class="m-0 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
<template #action>
|
||||
<button
|
||||
type="button"
|
||||
class="cursor-pointer border-none bg-transparent p-0 text-sm text-base-foreground hover:text-muted-foreground"
|
||||
@click="planMode = 'team'"
|
||||
>
|
||||
{{ t('subscription.personalHeaderAction') }}
|
||||
</button>
|
||||
</template>
|
||||
</I18nT>
|
||||
<I18nT
|
||||
v-else
|
||||
keypath="subscription.teamHeader"
|
||||
tag="p"
|
||||
class="m-0 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
<template #learnMore>
|
||||
<button
|
||||
type="button"
|
||||
class="cursor-pointer border-none bg-transparent p-0 text-sm text-base-foreground hover:text-muted-foreground"
|
||||
@click="handleViewEnterprise"
|
||||
>
|
||||
{{ t('subscription.teamHeaderLearnMore') }}
|
||||
</button>
|
||||
</template>
|
||||
</I18nT>
|
||||
|
||||
<!-- Billing-cycle toggle: drives both the personal tier cards and the
|
||||
team credit slider (team monthly halves the yearly discount). -->
|
||||
<div class="flex justify-center">
|
||||
<SelectButton
|
||||
v-model="currentBillingCycle"
|
||||
:options="billingCycleOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
:allow-empty="false"
|
||||
unstyled
|
||||
:pt="toggleButtonPt"
|
||||
>
|
||||
<template #option="{ option }">
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{{ option.label }}</span>
|
||||
<div
|
||||
v-if="option.value === 'yearly'"
|
||||
class="flex items-center rounded-full bg-primary-background px-2 py-0.5 text-2xs font-bold whitespace-nowrap text-white"
|
||||
>
|
||||
{{
|
||||
planMode === 'team'
|
||||
? t('subscription.saveYearlyUpTo')
|
||||
: t('subscription.saveYearly')
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</SelectButton>
|
||||
</div>
|
||||
|
||||
<!-- PERSONAL PLANS: tier cards (data-driven via the billing facade,
|
||||
falling back to TIER_PRICING). -->
|
||||
<div
|
||||
v-if="planMode === 'personal'"
|
||||
class="flex flex-col items-stretch gap-6 xl:flex-1 xl:flex-row xl:justify-center"
|
||||
>
|
||||
<div
|
||||
v-for="tier in tiers"
|
||||
:key="tier.id"
|
||||
class="flex flex-col rounded-2xl border border-border-default bg-base-background shadow-[0_0_12px_rgba(0,0,0,0.1)] xl:w-80"
|
||||
>
|
||||
<div class="flex flex-1 flex-col gap-4 p-6 pb-0">
|
||||
<div class="flex flex-row items-center justify-between gap-2">
|
||||
<span
|
||||
class="font-inter text-base/normal font-bold text-base-foreground"
|
||||
>
|
||||
{{ tier.name }}
|
||||
</span>
|
||||
<div
|
||||
v-if="tier.isPopular"
|
||||
class="flex h-5 items-center rounded-full bg-base-foreground px-1.5 text-2xs font-bold tracking-tight text-base-background uppercase"
|
||||
>
|
||||
{{ t('subscription.mostPopular') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-row items-baseline gap-2">
|
||||
<span
|
||||
class="font-inter text-[28px] leading-normal font-semibold text-base-foreground tabular-nums"
|
||||
>
|
||||
${{ getPrice(tier) }}
|
||||
<span
|
||||
v-show="currentBillingCycle === 'yearly'"
|
||||
class="text-2xl text-muted-foreground line-through"
|
||||
>
|
||||
${{ getMonthlyPrice(tier) }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="font-inter text-sm/normal text-base-foreground">
|
||||
{{ t('subscription.usdPerMonth') }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{
|
||||
currentBillingCycle === 'yearly'
|
||||
? t('subscription.billedYearly', {
|
||||
total: `$${getAnnualTotal(tier)}`
|
||||
})
|
||||
: t('subscription.billedMonthly')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="h-px w-full bg-border-default" />
|
||||
|
||||
<!-- Progressive feature list: "What's included" then
|
||||
"Everything in {prev tier}, plus:" (DES-197). -->
|
||||
<div class="flex flex-col gap-3">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ tier.featuresHeader }}
|
||||
</span>
|
||||
<div
|
||||
v-for="feature in tier.features"
|
||||
:key="feature"
|
||||
class="flex flex-row items-start gap-2"
|
||||
>
|
||||
<i class="pi pi-check mt-0.5 text-xs text-base-foreground" />
|
||||
<span class="text-sm font-normal text-muted-foreground">
|
||||
{{ feature }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Credit grant + template-based video estimate, pinned to the
|
||||
card bottom so the figures align across tiers. -->
|
||||
<div class="mt-auto flex flex-col gap-1">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<i
|
||||
class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
class="font-inter text-sm/normal font-bold text-base-foreground tabular-nums"
|
||||
>
|
||||
{{ n(tier.pricing.credits) }}
|
||||
</span>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ t('subscription.monthlyCredits') }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{
|
||||
t('subscription.videoEstimate', {
|
||||
count: n(tier.pricing.videoEstimate)
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col p-6">
|
||||
<Button
|
||||
:variant="getButtonSeverity(tier)"
|
||||
:disabled="isButtonDisabled(tier)"
|
||||
:loading="loadingTier === tier.key"
|
||||
:class="
|
||||
cn(
|
||||
'h-10 w-full',
|
||||
getButtonTextClass(tier),
|
||||
tier.key === 'creator'
|
||||
? 'border-transparent bg-base-foreground hover:bg-inverted-background-hover'
|
||||
: 'border-transparent bg-secondary-background hover:bg-secondary-background-hover focus:bg-secondary-background-selected'
|
||||
)
|
||||
"
|
||||
@click="() => handleSubscribe(tier.key)"
|
||||
>
|
||||
{{ getButtonLabel(tier) }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TEAM PLAN: [Team Plan | Details] card + Enterprise card -->
|
||||
<div v-else class="flex min-h-0 flex-col gap-6 xl:flex-1">
|
||||
<div
|
||||
class="flex flex-col items-stretch gap-6 xl:flex-1 xl:flex-row xl:justify-center"
|
||||
>
|
||||
<!-- Team Plan + Details share one card, split by a divider. -->
|
||||
<div
|
||||
class="flex flex-[2.6] flex-col rounded-2xl border border-border-default bg-base-background shadow-[0_0_12px_rgba(0,0,0,0.1)] xl:flex-row xl:overflow-hidden"
|
||||
>
|
||||
<!-- Team Plan column -->
|
||||
<div class="flex flex-[1.6] flex-col gap-6 p-6">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span
|
||||
class="font-inter text-base/normal font-bold text-base-foreground"
|
||||
>
|
||||
{{ t('subscription.teamPlan.name') }}
|
||||
</span>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ t('subscription.teamPlan.tagline') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Credit slider owns its price/discount/billed display; the
|
||||
cycle halves the yearly discount when monthly. -->
|
||||
<CreditSlider
|
||||
v-model="teamUsd"
|
||||
:cycle="currentBillingCycle"
|
||||
@change="onTeamChange"
|
||||
/>
|
||||
|
||||
<!-- Selected credit grant + template-based video estimate -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<i
|
||||
class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
class="font-inter text-sm/normal font-bold text-base-foreground tabular-nums"
|
||||
>
|
||||
{{ n(teamCredits) }}
|
||||
</span>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ t('subscription.monthlyCredits') }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{
|
||||
t('subscription.videoEstimate', {
|
||||
count: n(teamVideoEstimate)
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- CTA pinned to the card bottom (aligns with Enterprise CTA) -->
|
||||
<Button
|
||||
variant="inverted"
|
||||
:disabled="isTeamButtonDisabled"
|
||||
class="mt-auto h-10 w-full font-inter text-sm/normal font-bold"
|
||||
@click="handleSubscribeTeam"
|
||||
>
|
||||
{{ teamButtonLabel }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Divider: horizontal when stacked, vertical at xl -->
|
||||
<div
|
||||
class="h-px w-full shrink-0 self-stretch bg-border-default xl:h-auto xl:w-px"
|
||||
/>
|
||||
|
||||
<!-- Details column -->
|
||||
<div class="flex flex-1 flex-col gap-4 p-6">
|
||||
<span
|
||||
class="font-inter text-base/normal font-bold text-base-foreground"
|
||||
>
|
||||
{{ t('subscription.teamPlan.detailsTitle') }}
|
||||
</span>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{
|
||||
t('subscription.everythingInPlus', {
|
||||
plan: t('subscription.tiers.pro.name')
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<div
|
||||
v-for="perk in teamDetailPerks"
|
||||
:key="perk"
|
||||
class="flex flex-row items-start gap-2"
|
||||
>
|
||||
<i class="pi pi-check mt-0.5 text-xs text-base-foreground" />
|
||||
<span class="text-sm font-normal text-muted-foreground">
|
||||
{{ perk }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ t('subscription.teamPlan.comingSoonLabel') }}
|
||||
</span>
|
||||
<div class="flex flex-row items-start gap-2">
|
||||
<i class="pi pi-clock mt-0.5 text-xs text-muted-foreground" />
|
||||
<span class="text-sm font-normal text-muted-foreground">
|
||||
{{ t('subscription.teamPlan.perkProjectAssets') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Enterprise card -->
|
||||
<div
|
||||
class="flex flex-1 flex-col gap-4 rounded-2xl border border-border-default bg-base-background p-6 shadow-[0_0_12px_rgba(0,0,0,0.1)]"
|
||||
>
|
||||
<span
|
||||
class="font-inter text-base/normal font-bold text-base-foreground"
|
||||
>
|
||||
{{ t('subscription.enterprise.name') }}
|
||||
</span>
|
||||
<div class="flex flex-col gap-3">
|
||||
<span class="text-sm/relaxed font-normal text-muted-foreground">
|
||||
{{ t('subscription.enterprise.needMoreMembers') }}
|
||||
</span>
|
||||
<span class="text-sm/relaxed font-normal text-muted-foreground">
|
||||
{{ t('subscription.enterprise.flexibility') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-px w-full bg-border-default" />
|
||||
<span class="text-sm/relaxed font-normal text-muted-foreground">
|
||||
{{ t('subscription.enterprise.reachOut') }}
|
||||
</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="mt-auto h-10 w-full border-transparent bg-secondary-background font-bold hover:bg-secondary-background-hover"
|
||||
@click="handleViewEnterprise"
|
||||
>
|
||||
{{ t('subscription.enterprise.cta') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footnote: template caveat + contact / pricing links -->
|
||||
<I18nT
|
||||
keypath="subscription.pricingBlurb"
|
||||
tag="p"
|
||||
class="m-0 mt-auto pt-4 text-center text-sm text-text-secondary"
|
||||
>
|
||||
<template #seeDetails>
|
||||
<a
|
||||
:href="VIDEO_TEMPLATE_URL"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="cursor-pointer text-sm text-base-foreground no-underline hover:text-muted-foreground"
|
||||
>
|
||||
{{ t('subscription.pricingBlurbSeeDetails') }}
|
||||
</a>
|
||||
</template>
|
||||
<template #questions>
|
||||
<a
|
||||
:href="QUESTIONS_URL"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="cursor-pointer text-sm text-base-foreground no-underline hover:text-muted-foreground"
|
||||
>
|
||||
{{ t('subscription.pricingBlurbQuestions') }}
|
||||
</a>
|
||||
</template>
|
||||
<template #enterpriseDiscussions>
|
||||
<a
|
||||
:href="ENTERPRISE_URL"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="cursor-pointer text-sm text-base-foreground no-underline hover:text-muted-foreground"
|
||||
>
|
||||
{{ t('subscription.pricingBlurbEnterprise') }}
|
||||
</a>
|
||||
</template>
|
||||
<template #clickHere>
|
||||
<a
|
||||
:href="PRICING_URL"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="cursor-pointer text-sm text-base-foreground no-underline hover:text-muted-foreground"
|
||||
>
|
||||
{{ t('subscription.pricingBlurbClickHere') }}
|
||||
</a>
|
||||
</template>
|
||||
</I18nT>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import SelectButton from 'primevue/selectbutton'
|
||||
import type { ToggleButtonPassThroughMethodOptions } from 'primevue/togglebutton'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { I18nT, useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import CreditSlider from '@/components/ui/credit-slider/CreditSlider.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import {
|
||||
TIER_PRICING,
|
||||
TIER_TO_KEY
|
||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type {
|
||||
SubscriptionTier,
|
||||
TierKey,
|
||||
TierPricing
|
||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import {
|
||||
DEFAULT_TEAM_PLAN_STOP_INDEX,
|
||||
getDiscountedMonthlyUsd,
|
||||
TEAM_PLAN_CREDIT_STOPS
|
||||
} from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
|
||||
import type { TeamPlanSelection } from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
|
||||
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
|
||||
import type { Plan } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
type CheckoutTierKey = Exclude<TierKey, 'free' | 'founder'>
|
||||
|
||||
interface Props {
|
||||
isLoading?: boolean
|
||||
loadingTier?: CheckoutTierKey | null
|
||||
/** Initial plan scope. The toggle to switch is only shown when team plans
|
||||
* are available (`teamWorkspacesEnabled`). */
|
||||
initialPlanMode?: 'personal' | 'team'
|
||||
}
|
||||
|
||||
const {
|
||||
isLoading,
|
||||
loadingTier = null,
|
||||
initialPlanMode = 'personal'
|
||||
} = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
subscribe: [payload: { tierKey: CheckoutTierKey; billingCycle: BillingCycle }]
|
||||
resubscribe: []
|
||||
// Team-plan checkout. NOTE: the slider stop -> plan-slug mapping is blocked on
|
||||
// the BE discount-breakpoint contract (FE-934 / doc Open Q#2); the host shows
|
||||
// the confirm step but stubs the final subscribe until the contract lands.
|
||||
// TODO(FE-934): once the contract lands, also carry `currentBillingCycle`
|
||||
// (yearly | monthly) so checkout subscribes to the selected cycle, not just
|
||||
// the stop. The pricing-table view already toggles cycle; the confirm/checkout
|
||||
// chain still assumes yearly.
|
||||
subscribeTeam: [payload: TeamPlanSelection]
|
||||
}>()
|
||||
|
||||
const { t, n } = useI18n()
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
/** Team plans only exist behind the flag (mirrors useBillingContext type). */
|
||||
const showTeam = computed(() => flags.teamWorkspacesEnabled)
|
||||
|
||||
const planMode = ref<'personal' | 'team'>(initialPlanMode)
|
||||
|
||||
/** The Wan 2.2 i2v template the video estimates are based on. */
|
||||
const VIDEO_TEMPLATE_URL =
|
||||
'https://cloud.comfy.org/?template=video_wan2_2_14B_i2v'
|
||||
|
||||
/** External footnote destinations — rendered as real links (open in a new tab). */
|
||||
const QUESTIONS_URL = 'https://portal.usepylon.com/comfy-org/forms/question'
|
||||
const ENTERPRISE_URL = 'https://www.comfy.org/enterprise'
|
||||
const PRICING_URL = 'https://www.comfy.org/pricing'
|
||||
|
||||
/** Videos-per-credit ratio is constant across tiers; reuse it for the team
|
||||
* plan's template-based estimate until the BE carries a team figure. */
|
||||
const VIDEO_PER_CREDIT =
|
||||
TIER_PRICING.pro.videoEstimate / TIER_PRICING.pro.credits
|
||||
|
||||
interface BillingCycleOption {
|
||||
label: string
|
||||
value: BillingCycle
|
||||
}
|
||||
|
||||
interface PlanScopeOption {
|
||||
label: string
|
||||
value: 'personal' | 'team'
|
||||
}
|
||||
|
||||
interface PricingTierConfig {
|
||||
id: SubscriptionTier
|
||||
key: CheckoutTierKey
|
||||
name: string
|
||||
pricing: TierPricing
|
||||
/** "What's included:" (Standard) or "Everything in {prev}, plus:". */
|
||||
featuresHeader: string
|
||||
features: string[]
|
||||
isPopular?: boolean
|
||||
}
|
||||
|
||||
// Billing-cycle toggle: the active option is a solid white pill (DES-197).
|
||||
const toggleButtonPt = {
|
||||
root: {
|
||||
class: 'flex gap-1 bg-secondary-background rounded-lg p-1.5'
|
||||
},
|
||||
pcToggleButton: {
|
||||
root: ({ context }: ToggleButtonPassThroughMethodOptions) => ({
|
||||
class: [
|
||||
// min-w keeps Yearly (with its discount badge) and Monthly the same
|
||||
// width so the active pill doesn't resize when toggling (DES QA).
|
||||
'h-8 min-w-44 px-5 rounded-md transition-colors cursor-pointer border-none outline-none ring-0 text-sm font-medium flex items-center justify-center',
|
||||
context.active
|
||||
? 'bg-base-foreground text-base-background'
|
||||
: 'bg-transparent text-muted-foreground hover:bg-secondary-background-hover'
|
||||
]
|
||||
}),
|
||||
label: { class: 'flex items-center gap-2 ' }
|
||||
}
|
||||
}
|
||||
|
||||
// Plan-scope toggle (For Personal / For Teams): active is a subtle raised pill,
|
||||
// not the solid white of the billing toggle (DES-197 2951:592113).
|
||||
const planScopeButtonPt = {
|
||||
// No pill container (DES "Plan Type Tabs" 2812:818371 has no bg) — just the
|
||||
// tabs, so the active base-background tab sits flush on top of the content area.
|
||||
root: {
|
||||
class: 'flex gap-1'
|
||||
},
|
||||
pcToggleButton: {
|
||||
root: ({ context }: ToggleButtonPassThroughMethodOptions) => ({
|
||||
class: [
|
||||
'h-8 px-4 rounded-t-md transition cursor-pointer border-none outline-none ring-0 text-sm font-medium flex items-center justify-center',
|
||||
// Inactive tab is the active tab at half opacity (DES QA) — same fill
|
||||
// and text, faded as one, not a separate muted colour.
|
||||
context.active
|
||||
? 'bg-base-background text-base-foreground'
|
||||
: 'bg-base-background text-base-foreground opacity-50 hover:opacity-100'
|
||||
]
|
||||
}),
|
||||
label: { class: 'flex items-center gap-2' }
|
||||
}
|
||||
}
|
||||
|
||||
const planScopeOptions: PlanScopeOption[] = [
|
||||
{ label: t('subscription.planScope.personal'), value: 'personal' },
|
||||
{ label: t('subscription.planScope.team'), value: 'team' }
|
||||
]
|
||||
|
||||
const billingCycleOptions: BillingCycleOption[] = [
|
||||
{ label: t('subscription.yearly'), value: 'yearly' },
|
||||
{ label: t('subscription.monthly'), value: 'monthly' }
|
||||
]
|
||||
|
||||
/** Team-plan "Details" column perks (DES-197), shown under "Everything in Pro". */
|
||||
const teamDetailPerks: string[] = [
|
||||
t('subscription.teamPlan.perkInviteMembers'),
|
||||
t('subscription.teamPlan.perkConcurrentRuns'),
|
||||
t('subscription.teamPlan.perkSharedPool'),
|
||||
t('subscription.teamPlan.perkRolePermissions')
|
||||
]
|
||||
|
||||
const tiers: PricingTierConfig[] = [
|
||||
{
|
||||
id: 'STANDARD',
|
||||
key: 'standard',
|
||||
name: t('subscription.tiers.standard.name'),
|
||||
pricing: TIER_PRICING.standard,
|
||||
featuresHeader: t('subscription.whatsIncluded'),
|
||||
features: [
|
||||
t('subscription.tiers.standard.feature1'),
|
||||
t('subscription.tiers.standard.feature2')
|
||||
],
|
||||
isPopular: false
|
||||
},
|
||||
{
|
||||
id: 'CREATOR',
|
||||
key: 'creator',
|
||||
name: t('subscription.tiers.creator.name'),
|
||||
pricing: TIER_PRICING.creator,
|
||||
featuresHeader: t('subscription.everythingInPlus', {
|
||||
plan: t('subscription.tiers.standard.name')
|
||||
}),
|
||||
features: [t('subscription.tiers.creator.feature1')],
|
||||
isPopular: true
|
||||
},
|
||||
{
|
||||
id: 'PRO',
|
||||
key: 'pro',
|
||||
name: t('subscription.tiers.pro.name'),
|
||||
pricing: TIER_PRICING.pro,
|
||||
featuresHeader: t('subscription.everythingInPlus', {
|
||||
plan: t('subscription.tiers.creator.name')
|
||||
}),
|
||||
features: [t('subscription.tiers.pro.feature1')],
|
||||
isPopular: false
|
||||
}
|
||||
]
|
||||
|
||||
const {
|
||||
plans: apiPlans,
|
||||
currentPlanSlug,
|
||||
fetchPlans,
|
||||
subscription,
|
||||
currentTeamCreditStop
|
||||
} = useBillingContext()
|
||||
|
||||
const isCancelled = computed(() => subscription.value?.isCancelled ?? false)
|
||||
|
||||
const currentBillingCycle = ref<BillingCycle>('yearly')
|
||||
|
||||
// Team plan selection (slider). Stop -> slug mapping is BE-blocked (see emit).
|
||||
const teamUsd = ref<number>(
|
||||
TEAM_PLAN_CREDIT_STOPS[DEFAULT_TEAM_PLAN_STOP_INDEX].usd
|
||||
)
|
||||
const teamCredits = ref<number>(
|
||||
TEAM_PLAN_CREDIT_STOPS[DEFAULT_TEAM_PLAN_STOP_INDEX].credits
|
||||
)
|
||||
const teamVideoEstimate = computed(() =>
|
||||
Math.round(teamCredits.value * VIDEO_PER_CREDIT)
|
||||
)
|
||||
|
||||
// The team's currently-subscribed stop (null when on no team plan). Matched to
|
||||
// the slider stops by list price so the current stop can be disabled.
|
||||
const isTeamSubscribed = computed(() => currentTeamCreditStop.value !== null)
|
||||
const currentTeamStopIndex = computed(() => {
|
||||
const usd = currentTeamCreditStop.value?.stop_usd
|
||||
if (usd == null) return null
|
||||
const i = TEAM_PLAN_CREDIT_STOPS.findIndex((stop) => stop.usd === usd)
|
||||
return i === -1 ? null : i
|
||||
})
|
||||
|
||||
// Start the slider on the current stop so an active subscriber sees their plan
|
||||
// (disabled) and must move off it to change.
|
||||
watch(
|
||||
currentTeamCreditStop,
|
||||
(stop) => {
|
||||
if (!stop) return
|
||||
teamUsd.value = stop.stop_usd
|
||||
teamCredits.value = stop.credits_monthly
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// The CTA — not the slider stop — reflects the current plan: on the active stop
|
||||
// it reads "Current plan" (disabled); a cancelled plan re-subscribes on its
|
||||
// stop. Any other stop is locked because the credit stop can't be changed.
|
||||
const isTeamCurrentStopSelected = computed(
|
||||
() =>
|
||||
currentTeamStopIndex.value !== null &&
|
||||
TEAM_PLAN_CREDIT_STOPS[currentTeamStopIndex.value]?.usd === teamUsd.value
|
||||
)
|
||||
|
||||
// Yearly and monthly at the same credit stop are distinct plans, so toggling
|
||||
// the cycle is a change, not the current plan.
|
||||
const subscribedCycle = computed<BillingCycle>(() =>
|
||||
subscription.value?.duration === 'MONTHLY' ? 'monthly' : 'yearly'
|
||||
)
|
||||
const isTeamCurrentPlanSelected = computed(
|
||||
() =>
|
||||
isTeamCurrentStopSelected.value &&
|
||||
currentBillingCycle.value === subscribedCycle.value
|
||||
)
|
||||
|
||||
const teamButtonLabel = computed(() => {
|
||||
if (!isTeamSubscribed.value) {
|
||||
return currentBillingCycle.value === 'yearly'
|
||||
? t('subscription.teamPlan.cta')
|
||||
: t('subscription.teamPlan.ctaMonthly')
|
||||
}
|
||||
// The exact current plan re-subscribes (cancelled) or reads "Current plan"
|
||||
// (active); any other stop or cycle is a change.
|
||||
if (isTeamCurrentPlanSelected.value) {
|
||||
return isCancelled.value
|
||||
? t('subscription.resubscribe')
|
||||
: t('subscription.teamPlan.currentPlan')
|
||||
}
|
||||
return t('subscription.teamPlan.changePlan')
|
||||
})
|
||||
|
||||
const isTeamButtonDisabled = computed(
|
||||
() =>
|
||||
isLoading ||
|
||||
(isTeamSubscribed.value &&
|
||||
isTeamCurrentPlanSelected.value &&
|
||||
!isCancelled.value)
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
void fetchPlans()
|
||||
})
|
||||
|
||||
function getApiPlanForTier(
|
||||
tierKey: CheckoutTierKey,
|
||||
duration: BillingCycle
|
||||
): Plan | undefined {
|
||||
const apiDuration = duration === 'yearly' ? 'ANNUAL' : 'MONTHLY'
|
||||
const apiTier = tierKey.toUpperCase() as Plan['tier']
|
||||
return apiPlans.value.find(
|
||||
(p) => p.tier === apiTier && p.duration === apiDuration
|
||||
)
|
||||
}
|
||||
|
||||
function getPriceFromApi(tier: PricingTierConfig): number | null {
|
||||
const plan = getApiPlanForTier(tier.key, currentBillingCycle.value)
|
||||
if (!plan) return null
|
||||
const price = plan.price_cents / 100
|
||||
return currentBillingCycle.value === 'yearly' ? price / 12 : price
|
||||
}
|
||||
|
||||
const currentTierKey = computed<TierKey | null>(() =>
|
||||
subscription.value?.tier ? TIER_TO_KEY[subscription.value.tier] : null
|
||||
)
|
||||
|
||||
const isYearlySubscription = computed(
|
||||
() => subscription.value?.duration === 'ANNUAL'
|
||||
)
|
||||
|
||||
const isCurrentPlan = (tierKey: CheckoutTierKey): boolean => {
|
||||
if (currentPlanSlug.value) {
|
||||
const plan = getApiPlanForTier(tierKey, currentBillingCycle.value)
|
||||
return plan?.slug === currentPlanSlug.value
|
||||
}
|
||||
if (!currentTierKey.value) return false
|
||||
const selectedIsYearly = currentBillingCycle.value === 'yearly'
|
||||
return (
|
||||
currentTierKey.value === tierKey &&
|
||||
isYearlySubscription.value === selectedIsYearly
|
||||
)
|
||||
}
|
||||
|
||||
const getButtonLabel = (tier: PricingTierConfig): string => {
|
||||
const planName =
|
||||
currentBillingCycle.value === 'yearly'
|
||||
? t('subscription.tierNameYearly', { name: tier.name })
|
||||
: tier.name
|
||||
|
||||
if (isCurrentPlan(tier.key)) {
|
||||
return isCancelled.value
|
||||
? t('subscription.resubscribeTo', { plan: planName })
|
||||
: t('subscription.currentPlan')
|
||||
}
|
||||
|
||||
// Free tier is not a paid plan to "change" from — those users subscribe.
|
||||
const hasActivePaidPlan =
|
||||
currentTierKey.value !== null && currentTierKey.value !== 'free'
|
||||
|
||||
return hasActivePaidPlan
|
||||
? t('subscription.changeTo', { plan: planName })
|
||||
: t('subscription.subscribeTo', { plan: planName })
|
||||
}
|
||||
|
||||
const getButtonSeverity = (
|
||||
tier: PricingTierConfig
|
||||
): 'primary' | 'secondary' => {
|
||||
if (isCurrentPlan(tier.key)) {
|
||||
return isCancelled.value ? 'primary' : 'secondary'
|
||||
}
|
||||
if (tier.key === 'creator') return 'primary'
|
||||
return 'secondary'
|
||||
}
|
||||
|
||||
const isButtonDisabled = (tier: PricingTierConfig): boolean => {
|
||||
if (isLoading) return true
|
||||
if (isCurrentPlan(tier.key)) {
|
||||
return !isCancelled.value
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const getButtonTextClass = (tier: PricingTierConfig): string =>
|
||||
tier.key === 'creator'
|
||||
? 'font-inter text-sm font-bold leading-normal text-base-background'
|
||||
: 'font-inter text-sm font-bold leading-normal text-primary-foreground'
|
||||
|
||||
const getPrice = (tier: PricingTierConfig): number =>
|
||||
getPriceFromApi(tier) ?? tier.pricing[currentBillingCycle.value]
|
||||
|
||||
const getMonthlyPrice = (tier: PricingTierConfig): number => {
|
||||
const plan = getApiPlanForTier(tier.key, 'monthly')
|
||||
return plan ? plan.price_cents / 100 : tier.pricing.monthly
|
||||
}
|
||||
|
||||
const getAnnualTotal = (tier: PricingTierConfig): number => {
|
||||
const plan = getApiPlanForTier(tier.key, 'yearly')
|
||||
return plan ? plan.price_cents / 100 : tier.pricing.yearly * 12
|
||||
}
|
||||
|
||||
function handleSubscribe(tierKey: CheckoutTierKey) {
|
||||
if (isLoading) return
|
||||
if (isCurrentPlan(tierKey)) {
|
||||
if (isCancelled.value) {
|
||||
emit('resubscribe')
|
||||
}
|
||||
return
|
||||
}
|
||||
emit('subscribe', { tierKey, billingCycle: currentBillingCycle.value })
|
||||
}
|
||||
|
||||
function onTeamChange(stop: { index: number; usd: number; credits: number }) {
|
||||
teamUsd.value = stop.usd
|
||||
teamCredits.value = stop.credits
|
||||
}
|
||||
|
||||
function handleSubscribeTeam() {
|
||||
if (isTeamButtonDisabled.value) return
|
||||
// Re-subscribe only when keeping the exact current plan; any other stop or
|
||||
// cycle is a change.
|
||||
if (isCancelled.value && isTeamCurrentPlanSelected.value) {
|
||||
emit('resubscribe')
|
||||
return
|
||||
}
|
||||
emit('subscribeTeam', {
|
||||
usd: teamUsd.value,
|
||||
credits: teamCredits.value,
|
||||
discountedUsd: getDiscountedMonthlyUsd(
|
||||
teamUsd.value,
|
||||
currentBillingCycle.value
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function handleViewEnterprise() {
|
||||
window.open(ENTERPRISE_URL, '_blank')
|
||||
}
|
||||
</script>
|
||||
@@ -52,10 +52,12 @@
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
const { isActiveSubscription, showSubscriptionDialog } = useBillingContext()
|
||||
const { isActiveSubscription } = useBillingContext()
|
||||
const subscriptionDialog = useSubscriptionDialog()
|
||||
|
||||
function onDismiss() {
|
||||
dialogStore.closeDialog({ key: 'invite-member-upsell' })
|
||||
@@ -63,6 +65,6 @@ function onDismiss() {
|
||||
|
||||
function onUpgrade() {
|
||||
dialogStore.closeDialog({ key: 'invite-member-upsell' })
|
||||
showSubscriptionDialog()
|
||||
subscriptionDialog.show({ planMode: 'team' })
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -14,7 +14,7 @@ import type {
|
||||
const mockHandleCopyInviteLink = vi.fn()
|
||||
const mockHandleRevokeInvite = vi.fn()
|
||||
const mockHandleCreateWorkspace = vi.fn()
|
||||
const mockShowSubscriptionDialog = vi.fn()
|
||||
const mockShowTeamPlans = vi.fn()
|
||||
const mockSelectMember = vi.fn()
|
||||
const mockToggleSort = vi.fn()
|
||||
|
||||
@@ -99,7 +99,7 @@ vi.mock('@/platform/workspace/composables/useMembersPanel', () => ({
|
||||
m.email.toLowerCase() === 'owner@example.com',
|
||||
selectMember: mockSelectMember,
|
||||
toggleSort: mockToggleSort,
|
||||
showSubscriptionDialog: mockShowSubscriptionDialog,
|
||||
showTeamPlans: mockShowTeamPlans,
|
||||
handleCopyInviteLink: mockHandleCopyInviteLink,
|
||||
handleRevokeInvite: mockHandleRevokeInvite,
|
||||
handleCreateWorkspace: mockHandleCreateWorkspace,
|
||||
@@ -320,13 +320,13 @@ describe('MembersPanelContent', () => {
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
it('opens subscription dialog on view plans click', async () => {
|
||||
it('opens team plans on view plans click', async () => {
|
||||
renderComponent()
|
||||
const viewPlansBtn = screen.getByRole('button', {
|
||||
name: /workspacePanel\.members\.viewPlans/
|
||||
})
|
||||
await userEvent.click(viewPlansBtn)
|
||||
expect(mockShowSubscriptionDialog).toHaveBeenCalled()
|
||||
expect(mockShowTeamPlans).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hides search input', () => {
|
||||
|
||||
@@ -168,7 +168,7 @@
|
||||
<MemberUpsellBanner
|
||||
v-if="isSingleSeatPlan"
|
||||
:is-active-subscription="isActiveSubscription"
|
||||
@show-plans="showSubscriptionDialog()"
|
||||
@show-plans="showTeamPlans()"
|
||||
/>
|
||||
|
||||
<!-- Pending Invites -->
|
||||
@@ -229,7 +229,7 @@ const {
|
||||
isCurrentUser,
|
||||
selectMember,
|
||||
toggleSort,
|
||||
showSubscriptionDialog,
|
||||
showTeamPlans,
|
||||
handleCopyInviteLink,
|
||||
handleRevokeInvite,
|
||||
handleCreateWorkspace
|
||||
|
||||
@@ -321,6 +321,13 @@ vi.mock('@/platform/cloud/subscription/constants/tierPricing', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
|
||||
() => ({
|
||||
useSubscriptionDialog: () => ({ show: vi.fn() })
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: () => ({
|
||||
showRemoveMemberDialog: mockShowRemoveMemberDialog,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import { TIER_TO_KEY } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import type {
|
||||
@@ -84,12 +85,9 @@ export function useMembersPanel() {
|
||||
} = storeToRefs(workspaceStore)
|
||||
const { copyInviteLink } = workspaceStore
|
||||
const { permissions, uiConfig } = useWorkspaceUI()
|
||||
const {
|
||||
isActiveSubscription,
|
||||
subscription,
|
||||
showSubscriptionDialog,
|
||||
getMaxSeats
|
||||
} = useBillingContext()
|
||||
const { isActiveSubscription, subscription, getMaxSeats } =
|
||||
useBillingContext()
|
||||
const subscriptionDialog = useSubscriptionDialog()
|
||||
|
||||
const maxSeats = computed(() => {
|
||||
if (isPersonalWorkspace.value) return 1
|
||||
@@ -189,6 +187,10 @@ export function useMembersPanel() {
|
||||
void showRemoveMemberDialog(member.id)
|
||||
}
|
||||
|
||||
function showTeamPlans() {
|
||||
subscriptionDialog.show({ planMode: 'team' })
|
||||
}
|
||||
|
||||
return {
|
||||
searchQuery,
|
||||
activeView,
|
||||
@@ -211,7 +213,7 @@ export function useMembersPanel() {
|
||||
isCurrentUser,
|
||||
selectMember,
|
||||
toggleSort,
|
||||
showSubscriptionDialog,
|
||||
showTeamPlans,
|
||||
handleCopyInviteLink,
|
||||
handleRevokeInvite,
|
||||
handleCreateWorkspace,
|
||||
|
||||
@@ -235,6 +235,27 @@ describe('useSubscriptionCheckout', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleSubscribeTeamClick', () => {
|
||||
it('transitions to preview with the selected team stop', async () => {
|
||||
const checkout = await setup()
|
||||
|
||||
checkout.handleSubscribeTeamClick({
|
||||
usd: 400,
|
||||
credits: 84_400,
|
||||
discountedUsd: 380
|
||||
})
|
||||
|
||||
expect(checkout.checkoutStep.value).toBe('preview')
|
||||
expect(checkout.selectedTeamStop.value).toStrictEqual({
|
||||
usd: 400,
|
||||
credits: 84_400,
|
||||
discountedUsd: 380
|
||||
})
|
||||
expect(checkout.previewData.value).toBeNull()
|
||||
expect(checkout.selectedTierKey.value).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleBackToPricing', () => {
|
||||
it('resets to pricing step and clears preview data', async () => {
|
||||
const checkout = await setup()
|
||||
@@ -246,10 +267,24 @@ describe('useSubscriptionCheckout', () => {
|
||||
expect(checkout.checkoutStep.value).toBe('pricing')
|
||||
expect(checkout.previewData.value).toBeNull()
|
||||
})
|
||||
|
||||
it('clears the selected team stop', async () => {
|
||||
const checkout = await setup()
|
||||
checkout.handleSubscribeTeamClick({
|
||||
usd: 400,
|
||||
credits: 84_400,
|
||||
discountedUsd: 380
|
||||
})
|
||||
|
||||
checkout.handleBackToPricing()
|
||||
|
||||
expect(checkout.checkoutStep.value).toBe('pricing')
|
||||
expect(checkout.selectedTeamStop.value).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleAddCreditCard', () => {
|
||||
it('emits close on subscribed status', async () => {
|
||||
it('transitions to success step on subscribed status', async () => {
|
||||
const checkout = await setup()
|
||||
checkout.selectedTierKey.value = 'standard'
|
||||
checkout.selectedBillingCycle.value = 'yearly'
|
||||
@@ -267,7 +302,7 @@ describe('useSubscriptionCheckout', () => {
|
||||
'https://platform.comfy.org/payment/success',
|
||||
'https://platform.comfy.org/payment/failed'
|
||||
)
|
||||
expect(emit).toHaveBeenCalledWith('close', true)
|
||||
expect(checkout.checkoutStep.value).toBe('success')
|
||||
})
|
||||
|
||||
it('opens payment URL when needs_payment_method', async () => {
|
||||
@@ -305,7 +340,7 @@ describe('useSubscriptionCheckout', () => {
|
||||
})
|
||||
|
||||
describe('handleConfirmTransition', () => {
|
||||
it('emits close on subscribed status', async () => {
|
||||
it('transitions to success step on subscribed status', async () => {
|
||||
const checkout = await setup()
|
||||
checkout.selectedTierKey.value = 'standard'
|
||||
checkout.selectedBillingCycle.value = 'yearly'
|
||||
@@ -318,7 +353,7 @@ describe('useSubscriptionCheckout', () => {
|
||||
|
||||
await checkout.handleConfirmTransition()
|
||||
|
||||
expect(emit).toHaveBeenCalledWith('close', true)
|
||||
expect(checkout.checkoutStep.value).toBe('success')
|
||||
})
|
||||
|
||||
it('shows error toast on failure', async () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
|
||||
import type { TeamPlanSelection } from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
@@ -13,7 +14,7 @@ import type {
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
import { useBillingOperationStore } from '@/platform/workspace/stores/billingOperationStore'
|
||||
|
||||
type CheckoutStep = 'pricing' | 'preview'
|
||||
type CheckoutStep = 'pricing' | 'preview' | 'success'
|
||||
type CheckoutTierKey = Exclude<TierKey, 'free' | 'founder'>
|
||||
|
||||
export function findPlanSlug(
|
||||
@@ -52,6 +53,7 @@ export function useSubscriptionCheckout(emit: {
|
||||
const isResubscribing = ref(false)
|
||||
const previewData = ref<PreviewSubscribeResponse | null>(null)
|
||||
const selectedTierKey = ref<CheckoutTierKey | null>(null)
|
||||
const selectedTeamStop = ref<TeamPlanSelection | null>(null)
|
||||
const selectedBillingCycle = ref<BillingCycle>('yearly')
|
||||
const isPolling = computed(() => billingOperationStore.hasPendingOperations)
|
||||
|
||||
@@ -112,9 +114,26 @@ export function useSubscriptionCheckout(emit: {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Team-plan checkout entry: show the "Confirm your payment" step rendered
|
||||
* from the selected slider stop. The final subscribe call stays stubbed in
|
||||
* the host until the BE slider contract lands (FE-934 / doc Open Q#2).
|
||||
*/
|
||||
function handleSubscribeTeamClick(payload: TeamPlanSelection) {
|
||||
selectedTeamStop.value = payload
|
||||
selectedTierKey.value = null
|
||||
previewData.value = null
|
||||
checkoutStep.value = 'preview'
|
||||
}
|
||||
|
||||
function handleBackToPricing() {
|
||||
checkoutStep.value = 'pricing'
|
||||
previewData.value = null
|
||||
selectedTeamStop.value = null
|
||||
}
|
||||
|
||||
function handleSuccessClose() {
|
||||
emit('close', true)
|
||||
}
|
||||
|
||||
async function handleSubscription() {
|
||||
@@ -137,13 +156,8 @@ export function useSubscriptionCheckout(emit: {
|
||||
|
||||
if (response.status === 'subscribed') {
|
||||
telemetry?.trackMonthlySubscriptionSucceeded()
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('subscription.required.pollingSuccess'),
|
||||
life: 5000
|
||||
})
|
||||
await Promise.all([fetchStatus(), fetchBalance()])
|
||||
emit('close', true)
|
||||
checkoutStep.value = 'success'
|
||||
} else if (
|
||||
response.status === 'needs_payment_method' &&
|
||||
response.payment_method_url
|
||||
@@ -203,10 +217,13 @@ export function useSubscriptionCheckout(emit: {
|
||||
isResubscribing,
|
||||
previewData,
|
||||
selectedTierKey,
|
||||
selectedTeamStop,
|
||||
selectedBillingCycle,
|
||||
isPolling,
|
||||
handleSubscribeClick,
|
||||
handleSubscribeTeamClick,
|
||||
handleBackToPricing,
|
||||
handleSuccessClose,
|
||||
handleAddCreditCard: handleSubscription,
|
||||
handleConfirmTransition: handleSubscription,
|
||||
handleResubscribe
|
||||
|
||||
@@ -82,6 +82,10 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
|
||||
const currentPlanSlug = computed(
|
||||
() => statusData.value?.plan_slug ?? billingPlans.currentPlanSlug.value
|
||||
)
|
||||
const teamCreditStops = computed(() => billingPlans.teamCreditStops.value)
|
||||
const currentTeamCreditStop = computed(
|
||||
() => statusData.value?.team_credit_stop ?? null
|
||||
)
|
||||
|
||||
async function initialize(): Promise<void> {
|
||||
if (isInitialized.value) return
|
||||
@@ -287,6 +291,8 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
|
||||
balance,
|
||||
plans,
|
||||
currentPlanSlug,
|
||||
teamCreditStops,
|
||||
currentTeamCreditStop,
|
||||
isLoading,
|
||||
error,
|
||||
isActiveSubscription,
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ComfyNodeDef as ComfyNodeDefV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import LGraphNodePreview from '@/renderer/extensions/vueNodes/components/LGraphNodePreview.vue'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
|
||||
vi.mock('@/stores/widgetStore', () => ({
|
||||
useWidgetStore: () => ({ inputIsWidget: () => true })
|
||||
}))
|
||||
|
||||
// Serializes the nodeData prop so tests can assert on the data contract
|
||||
// LGraphNodePreview hands to NodeWidgets. How that data renders is covered
|
||||
// by NodeWidgets.test.ts and browser_tests/tests/sidebar/modelLibrary.spec.ts.
|
||||
const NodeWidgetsProbe = {
|
||||
props: ['nodeData'],
|
||||
template: '<div data-testid="node-data">{{ JSON.stringify(nodeData) }}</div>'
|
||||
}
|
||||
|
||||
interface ProbedWidget {
|
||||
name: string
|
||||
value?: unknown
|
||||
options?: { values?: string[] }
|
||||
}
|
||||
|
||||
const nodeDef = fromPartial<ComfyNodeDefV2>({
|
||||
name: 'CheckpointLoaderSimple',
|
||||
display_name: 'Load Checkpoint',
|
||||
inputs: {
|
||||
ckpt_name: { type: 'COMBO', options: ['a.safetensors', 'b.safetensors'] }
|
||||
},
|
||||
outputs: []
|
||||
})
|
||||
|
||||
function renderedWidgets(
|
||||
def: ComfyNodeDefV2,
|
||||
props: { widgetValues?: Record<string, string> } = {}
|
||||
) {
|
||||
render(LGraphNodePreview, {
|
||||
props: { nodeDef: def, ...props },
|
||||
global: {
|
||||
stubs: {
|
||||
NodeHeader: true,
|
||||
NodeSlots: true,
|
||||
NodeWidgets: NodeWidgetsProbe
|
||||
}
|
||||
}
|
||||
})
|
||||
const nodeData: { widgets?: ProbedWidget[] } = JSON.parse(
|
||||
screen.getByTestId('node-data').textContent ?? ''
|
||||
)
|
||||
return nodeData.widgets ?? []
|
||||
}
|
||||
|
||||
function renderedComboWidget(
|
||||
props: { widgetValues?: Record<string, string> } = {}
|
||||
) {
|
||||
return renderedWidgets(nodeDef, props).find((w) => w.name === 'ckpt_name')
|
||||
}
|
||||
|
||||
describe('LGraphNodePreview', () => {
|
||||
it('leads the combo options with the provided widget value', () => {
|
||||
const widget = renderedComboWidget({
|
||||
widgetValues: { ckpt_name: 'sd_xl_base_1.0.safetensors' }
|
||||
})
|
||||
|
||||
expect(widget?.options?.values).toEqual([
|
||||
'sd_xl_base_1.0.safetensors',
|
||||
'a.safetensors',
|
||||
'b.safetensors'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the combo options untouched when no value is provided', () => {
|
||||
const widget = renderedComboWidget()
|
||||
|
||||
expect(widget?.options?.values).toEqual(['a.safetensors', 'b.safetensors'])
|
||||
})
|
||||
|
||||
it('uses the input default when defined and empty string otherwise', () => {
|
||||
const widgets = renderedWidgets(
|
||||
fromPartial<ComfyNodeDefV2>({
|
||||
name: 'TestNode',
|
||||
inputs: {
|
||||
steps: { type: 'INT', default: 20 },
|
||||
text: { type: 'STRING' }
|
||||
},
|
||||
outputs: []
|
||||
})
|
||||
)
|
||||
|
||||
expect(widgets.find((w) => w.name === 'steps')?.value).toBe(20)
|
||||
expect(widgets.find((w) => w.name === 'text')?.value).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -45,9 +45,14 @@ import type { ComfyNodeDef as ComfyNodeDefV2 } from '@/schemas/nodeDef/nodeDefSc
|
||||
import { useWidgetStore } from '@/stores/widgetStore'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { nodeDef, position = 'absolute' } = defineProps<{
|
||||
const {
|
||||
nodeDef,
|
||||
position = 'absolute',
|
||||
widgetValues
|
||||
} = defineProps<{
|
||||
nodeDef: ComfyNodeDefV2
|
||||
position?: 'absolute' | 'relative'
|
||||
widgetValues?: Record<string, string>
|
||||
}>()
|
||||
|
||||
const widgetStore = useWidgetStore()
|
||||
@@ -56,27 +61,32 @@ const widgetStore = useWidgetStore()
|
||||
const nodeData = computed<VueNodeData>(() => {
|
||||
const widgets = Object.entries(nodeDef.inputs || {})
|
||||
.filter(([_, input]) => widgetStore.inputIsWidget(input))
|
||||
.map(([name, input]) => ({
|
||||
nodeId: '-1',
|
||||
name,
|
||||
type: input.widgetType || input.type,
|
||||
value:
|
||||
input.default !== undefined
|
||||
? input.default
|
||||
: input.type === 'COMBO' &&
|
||||
Array.isArray(input.options) &&
|
||||
input.options.length > 0
|
||||
? input.options[0]
|
||||
: '',
|
||||
options: {
|
||||
hidden: input.hidden,
|
||||
advanced: input.advanced,
|
||||
values:
|
||||
input.type === 'COMBO' && Array.isArray(input.options)
|
||||
? input.options
|
||||
: undefined
|
||||
} satisfies IWidgetOptions
|
||||
}))
|
||||
.map(([name, input]) => {
|
||||
const comboValues =
|
||||
input.type === 'COMBO' && Array.isArray(input.options)
|
||||
? input.options
|
||||
: undefined
|
||||
// Preview nodes have no widget-value store entry, so combo widgets
|
||||
// render their first option; lead with the requested value to show it.
|
||||
const leadValue = widgetValues?.[name]
|
||||
return {
|
||||
nodeId: '-1',
|
||||
name,
|
||||
type: input.widgetType || input.type,
|
||||
value:
|
||||
input.default !== undefined
|
||||
? input.default
|
||||
: (comboValues?.[0] ?? ''),
|
||||
options: {
|
||||
hidden: input.hidden,
|
||||
advanced: input.advanced,
|
||||
values:
|
||||
leadValue && comboValues
|
||||
? [leadValue, ...comboValues.filter((o) => o !== leadValue)]
|
||||
: comboValues
|
||||
} satisfies IWidgetOptions
|
||||
}
|
||||
})
|
||||
|
||||
const inputs: INodeInputSlot[] = Object.entries(nodeDef.inputs || {})
|
||||
.filter(([_, input]) => !widgetStore.inputIsWidget(input))
|
||||
|
||||
50
src/storybook/mocks/useBillingContext.ts
Normal file
50
src/storybook/mocks/useBillingContext.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { BillingContext } from '@/composables/billing/types'
|
||||
|
||||
/**
|
||||
* Storybook mock for `useBillingContext`.
|
||||
*
|
||||
* The real facade lazily instantiates the legacy billing adapter, which pulls
|
||||
* in Firebase auth (`setPersistence`) and crashes in the Storybook environment
|
||||
* (no Firebase). This static stub lets presentational billing components — e.g.
|
||||
* UnifiedPricingTable — render against their `TIER_PRICING` / DES-197 fallbacks
|
||||
* without any network or auth.
|
||||
*
|
||||
* Typed against `BillingContext` so the stub stays in lockstep with the real
|
||||
* composable's return shape: drifted or removed keys fail to compile.
|
||||
*/
|
||||
export function useBillingContext(): BillingContext {
|
||||
return {
|
||||
type: computed(() => 'legacy' as const),
|
||||
isInitialized: ref(true),
|
||||
subscription: computed(() => null),
|
||||
balance: computed(() => null),
|
||||
plans: computed(() => []),
|
||||
currentPlanSlug: computed(() => null),
|
||||
teamCreditStops: computed(() => null),
|
||||
currentTeamCreditStop: computed(() => null),
|
||||
isLoading: ref(false),
|
||||
error: ref<string | null>(null),
|
||||
isActiveSubscription: computed(() => false),
|
||||
isFreeTier: computed(() => false),
|
||||
isLegacyTeamPlan: computed(() => false),
|
||||
billingStatus: computed(() => null),
|
||||
subscriptionStatus: computed(() => null),
|
||||
tier: computed(() => null),
|
||||
renewalDate: computed(() => null),
|
||||
getMaxSeats: () => 1,
|
||||
initialize: async () => {},
|
||||
fetchStatus: async () => {},
|
||||
fetchBalance: async () => {},
|
||||
subscribe: async () => {},
|
||||
previewSubscribe: async () => null,
|
||||
manageSubscription: async () => {},
|
||||
cancelSubscription: async () => {},
|
||||
resubscribe: async () => {},
|
||||
topup: async () => {},
|
||||
fetchPlans: async () => {},
|
||||
requireActiveSubscription: async () => {},
|
||||
showSubscriptionDialog: () => {}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user