Compare commits
10 Commits
feat/websi
...
chore/code
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27dac2d013 | ||
|
|
b951ae9160 | ||
|
|
156f2f59b7 | ||
|
|
d855466fdf | ||
|
|
9d5719871a | ||
|
|
7610a61250 | ||
|
|
47c8b09ebf | ||
|
|
65b4c53bcb | ||
|
|
15b31d69ea | ||
|
|
471236e08d |
@@ -63,3 +63,25 @@ reviews:
|
||||
Pass if none of these patterns are found in the diff.
|
||||
|
||||
When warning, reference the specific ADR by number and link to `docs/adr/` for context. Frame findings as directional guidance since ADR 0003 and 0008 are in Proposed status.
|
||||
|
||||
path_instructions:
|
||||
- path: '{src,packages,apps}/**/*.test.ts'
|
||||
instructions: |
|
||||
Build partial mocks with fromPartial<T>() from @total-typescript/shoehorn; flag `as unknown as` double assertions and fromAny().
|
||||
Reuse shared factories in src/utils/__tests__/litegraphTestUtils.ts instead of hand-rolling mock builders.
|
||||
Mock only at seams (Pinia stores, settings, third-party libs); flag mocked type guards, litegraph classes, or sibling composables.
|
||||
Use a real createI18n instance rather than vi.mock('vue-i18n').
|
||||
Flag bare expect(fn).not.toThrow() as a sole assertion, assertions that echo stub return values, and .mock.results assertions.
|
||||
Use @testing-library/vue for component tests, not @vue/test-utils.
|
||||
- path: 'browser_tests/**/*.spec.ts'
|
||||
instructions: |
|
||||
Every route.fulfill() body must be typed with generated types or schemas from packages/ingest-types, packages/registry-types, src/workbench/extensions/manager/types/generatedManagerTypes.ts, or src/schemas/; flag untyped inline JSON objects.
|
||||
Never use waitForTimeout; use Locator actions and auto-retrying assertions instead.
|
||||
Restrict page.evaluate() to reading internal state or fixture setup; flag any page.evaluate() that drives UI actions when a Playwright action method exists.
|
||||
New shared test helpers must be Playwright fixtures via base.extend(), not properties added to ComfyPage.
|
||||
- path: 'src/**/*.vue'
|
||||
instructions: |
|
||||
Do not introduce new PrimeVue component usage; use existing design-system components or Reka UI/shadcn-vue primitives.
|
||||
Apply Tailwind semantic tokens from the design system; flag hardcoded hex colors and the dark: variant.
|
||||
Merge classes via cn() from @comfyorg/tailwind-utils; flag :class="[]" array bindings.
|
||||
Avoid <style> blocks except for documented third-party :deep() exceptions.
|
||||
|
||||
1
.github/workflows/ci-tests-storybook.yaml
vendored
@@ -95,6 +95,7 @@ jobs:
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch'
|
||||
|| (github.event_name == 'pull_request'
|
||||
&& github.event.pull_request.head.repo.fork == false
|
||||
&& startsWith(github.head_ref, 'version-bump-')
|
||||
&& (needs.changes.outputs.storybook-changes == 'true'
|
||||
|| needs.changes.outputs.app-frontend-changes == 'true'
|
||||
|
||||
@@ -30,7 +30,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
deploy-preview:
|
||||
if: github.event_name == 'pull_request'
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
10
.github/workflows/ci-website-e2e.yaml
vendored
@@ -67,7 +67,15 @@ jobs:
|
||||
|
||||
- name: Deploy report to Cloudflare
|
||||
id: deploy
|
||||
if: always() && !cancelled()
|
||||
if: >-
|
||||
${{
|
||||
always() &&
|
||||
!cancelled() &&
|
||||
(
|
||||
github.event_name != 'pull_request' ||
|
||||
github.event.pull_request.head.repo.fork == false
|
||||
)
|
||||
}}
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
|
||||
13
.github/workflows/cloud-dispatch-build.yaml
vendored
@@ -32,12 +32,13 @@ jobs:
|
||||
if: >
|
||||
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
|
||||
(github.event_name != 'pull_request' ||
|
||||
(github.event.action == 'labeled' &&
|
||||
contains(fromJSON('["preview","preview-cpu","preview-gpu"]'), github.event.label.name)) ||
|
||||
(github.event.action == 'synchronize' &&
|
||||
(contains(github.event.pull_request.labels.*.name, 'preview') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'preview-gpu'))))
|
||||
(github.event.pull_request.head.repo.fork == false &&
|
||||
((github.event.action == 'labeled' &&
|
||||
contains(fromJSON('["preview","preview-cpu","preview-gpu"]'), github.event.label.name)) ||
|
||||
(github.event.action == 'synchronize' &&
|
||||
(contains(github.event.pull_request.labels.*.name, 'preview') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'preview-gpu'))))))
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Build client payload
|
||||
|
||||
@@ -21,6 +21,7 @@ jobs:
|
||||
# - Preview label specifically removed
|
||||
if: >
|
||||
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
|
||||
github.event.pull_request.head.repo.fork == false &&
|
||||
((github.event.action == 'closed' &&
|
||||
(contains(github.event.pull_request.labels.*.name, 'preview') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||
|
||||
|
||||
@@ -56,7 +56,7 @@ const columnClass: Record<ColumnCount, string> = {
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
|
||||
<SectionHeader :label="eyebrow" align="start">
|
||||
<SectionHeader max-width="xl" :label="eyebrow" align="start">
|
||||
{{ heading }}
|
||||
<template v-if="subtitle" #subtitle>
|
||||
<p class="mt-4 max-w-xl text-sm text-smoke-700 lg:text-base">
|
||||
|
||||
@@ -33,36 +33,41 @@ useHeroAnimation({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section ref="sectionRef" class="px-4 py-20 lg:flex lg:px-20 lg:py-24">
|
||||
<section
|
||||
ref="sectionRef"
|
||||
class="px-4 py-20 lg:flex lg:gap-16 lg:px-20 lg:py-24"
|
||||
>
|
||||
<!-- Left column: intro + image -->
|
||||
<div class="lg:w-1/2">
|
||||
<SectionLabel ref="badgeRef">
|
||||
{{ t(tk('badge'), locale) }}
|
||||
</SectionLabel>
|
||||
<div class="lg:max-w-xl">
|
||||
<SectionLabel ref="badgeRef">
|
||||
{{ t(tk('badge'), locale) }}
|
||||
</SectionLabel>
|
||||
|
||||
<h1
|
||||
ref="headingRef"
|
||||
class="text-primary-comfy-canvas mt-4 text-3xl font-light whitespace-pre-line lg:text-5xl"
|
||||
>
|
||||
{{ t(tk('heading'), locale) }}
|
||||
</h1>
|
||||
<h1
|
||||
ref="headingRef"
|
||||
class="mt-4 text-3xl font-light whitespace-pre-line text-primary-comfy-canvas lg:text-5xl"
|
||||
>
|
||||
{{ t(tk('heading'), locale) }}
|
||||
</h1>
|
||||
|
||||
<div ref="descRef">
|
||||
<p class="text-primary-comfy-canvas mt-4 text-sm">
|
||||
{{ t(tk('description'), locale) }}
|
||||
</p>
|
||||
<div ref="descRef">
|
||||
<p class="mt-4 text-sm text-primary-comfy-canvas">
|
||||
{{ t(tk('description'), locale) }}
|
||||
</p>
|
||||
|
||||
<p class="text-primary-comfy-canvas mt-4 text-sm">
|
||||
{{ t(tk('supportLink'), locale) }}
|
||||
<a
|
||||
href="https://docs.comfy.org/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-comfy-yellow underline"
|
||||
>
|
||||
{{ t(tk('supportLinkCta'), locale) }}
|
||||
</a>
|
||||
</p>
|
||||
<p class="mt-4 text-sm text-primary-comfy-canvas">
|
||||
{{ t(tk('supportLink'), locale) }}
|
||||
<a
|
||||
href="https://docs.comfy.org/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-comfy-yellow underline"
|
||||
>
|
||||
{{ t(tk('supportLinkCta'), locale) }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref="imageRef" class="mt-8 overflow-hidden rounded-2xl lg:-ml-20">
|
||||
|
||||
@@ -40,13 +40,13 @@ export function getMainNavigation(locale: Locale): NavItem[] {
|
||||
{
|
||||
label: t('nav.products', locale),
|
||||
featured: {
|
||||
imageSrc: 'https://media.comfy.org/website/nav/featured-model-card.jpg',
|
||||
imageSrc: 'https://media.comfy.org/website/nav/mcp-card.webp',
|
||||
imageAlt: t('nav.featuredProductsAlt', locale),
|
||||
title: t('nav.featuredProductsTitle', locale),
|
||||
cta: {
|
||||
label: t('cta.tryWorkflow', locale),
|
||||
label: t('cta.getStarted', locale),
|
||||
ariaLabel: t('nav.featuredProductsCtaAria', locale),
|
||||
href: 'https://comfy.org/workflows/api_seedance2_0_r2v-64f4db9e3e33/'
|
||||
href: routes.mcp
|
||||
}
|
||||
},
|
||||
columns: [
|
||||
|
||||
@@ -26,6 +26,10 @@ const translations = {
|
||||
en: 'Try Workflow',
|
||||
'zh-CN': '试用工作流'
|
||||
},
|
||||
'cta.getStarted': {
|
||||
en: 'GET STARTED',
|
||||
'zh-CN': '快速开始'
|
||||
},
|
||||
'cta.watchNow': {
|
||||
en: 'Watch Now',
|
||||
'zh-CN': '立即观看'
|
||||
@@ -2196,16 +2200,16 @@ const translations = {
|
||||
// Featured dropdown cards — keys are keyed by parent nav item, not card content,
|
||||
// so the copy can be swapped without renaming the key.
|
||||
'nav.featuredProductsTitle': {
|
||||
en: 'New Release: Seedance 2.0',
|
||||
'zh-CN': '全新发布:Seedance 2.0'
|
||||
en: 'NEW: COMFY MCP',
|
||||
'zh-CN': '全新发布:Comfy MCP'
|
||||
},
|
||||
'nav.featuredProductsAlt': {
|
||||
en: 'Seedance 2.0 release feature image',
|
||||
'zh-CN': 'Seedance 2.0 发布精选图片'
|
||||
en: 'Comfy MCP feature image',
|
||||
'zh-CN': 'Comfy MCP 精选图片'
|
||||
},
|
||||
'nav.featuredProductsCtaAria': {
|
||||
en: 'Try the Seedance 2.0 workflow',
|
||||
'zh-CN': '试用 Seedance 2.0 工作流'
|
||||
en: 'Get started with Comfy MCP',
|
||||
'zh-CN': '开始使用 Comfy MCP'
|
||||
},
|
||||
'nav.featuredCommunityTitle': {
|
||||
en: 'Sky Replacement',
|
||||
|
||||
@@ -537,7 +537,6 @@ export const comfyPageFixture = base.extend<{
|
||||
'Comfy.TutorialCompleted': true,
|
||||
'Comfy.Queue.MaxHistoryItems': 64,
|
||||
'Comfy.SnapToGrid.GridSize': testComfySnapToGridGridSize,
|
||||
'Comfy.VueNodes.AutoScaleLayout': false,
|
||||
// Disable toast warning about version compatibility, as they may or
|
||||
// may not appear - depending on upstream ComfyUI dependencies
|
||||
'Comfy.VersionCompatibility.DisableWarnings': true,
|
||||
|
||||
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 34 KiB |
@@ -46,6 +46,7 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
{ tag: ['@smoke', '@screenshot'] },
|
||||
async ({ comfyPage, maskEditor }) => {
|
||||
const { nodeId } = await maskEditor.loadImageOnNode()
|
||||
await comfyPage.canvasOps.pan({ x: 0, y: 40 }, { x: 300, y: 300 })
|
||||
|
||||
const nodeHeader = comfyPage.vueNodes
|
||||
.getNodeLocator(nodeId)
|
||||
|
||||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 93 KiB |
@@ -691,7 +691,8 @@ test(
|
||||
const emptySlotPos = await seedIOSlot.getOpenSlotPosition()
|
||||
await comfyPage.canvas.hover({ position: emptySlotPos })
|
||||
await comfyPage.page.mouse.down()
|
||||
await stepsSlot.hover()
|
||||
const { width, height } = (await stepsSlot.boundingBox())!
|
||||
await stepsSlot.hover({ position: { x: (width * 3) / 4, y: height / 2 } })
|
||||
await expect.poll(hasSnap).toBe(true)
|
||||
await comfyPage.page.mouse.up()
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
@@ -1238,7 +1238,7 @@ test(
|
||||
{ tag: '@vue-nodes' },
|
||||
async ({ comfyMouse, comfyPage }) => {
|
||||
async function performDisconnect(slot: Locator, isFast: boolean) {
|
||||
await comfyMouse.dragElementBy(slot, { x: isFast ? -25 : -80 })
|
||||
await comfyMouse.dragElementBy(slot, { x: isFast ? -30 : -80 })
|
||||
|
||||
if (!isFast) {
|
||||
await expect(comfyPage.contextMenu.litegraphContextMenu).toBeVisible()
|
||||
@@ -1251,7 +1251,7 @@ test(
|
||||
|
||||
const ksamplerLocator = comfyPage.vueNodes.getNodeByTitle('KSampler')
|
||||
const ksampler = new VueNodeFixture(ksamplerLocator)
|
||||
await comfyMouse.dragElementBy(ksamplerLocator, { x: 100 })
|
||||
await comfyMouse.dragElementBy(ksampler.title, { x: 100 })
|
||||
|
||||
await test.step('Disconnection with normal links', async () => {
|
||||
await performDisconnect(ksampler.getSlot('model'), true)
|
||||
|
||||
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 60 KiB |
@@ -234,7 +234,8 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
|
||||
await comfyPage.page
|
||||
.context()
|
||||
.grantPermissions(['clipboard-read', 'clipboard-write'])
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await comfyPage.searchBoxV2.addNode('Load Image')
|
||||
await comfyPage.vueNodes.waitForNodes(1)
|
||||
await comfyPage.page
|
||||
.locator('[data-node-id] img')
|
||||
|
||||
@@ -14,7 +14,8 @@ const wstest = mergeTests(test, webSocketFixture)
|
||||
|
||||
test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
|
||||
async function loadImageOnNode(comfyPage: ComfyPage) {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await comfyPage.searchBoxV2.addNode('Load Image')
|
||||
|
||||
const loadImageNode = (
|
||||
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
|
||||
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 89 KiB |
@@ -12,14 +12,14 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
|
||||
const getHeaderPos = async (
|
||||
comfyPage: ComfyPage,
|
||||
title: string
|
||||
): Promise<{ x: number; y: number; width: number; height: number }> => {
|
||||
): Promise<{ x: number; y: number }> => {
|
||||
const box = await comfyPage.vueNodes
|
||||
.getNodeByTitle(title)
|
||||
.getByTestId('node-title')
|
||||
.first()
|
||||
.boundingBox()
|
||||
if (!box) throw new Error(`${title} header not found`)
|
||||
return box
|
||||
return { x: box.x + box.width / 2, y: box.y + box.height / 2 }
|
||||
}
|
||||
|
||||
const getLoadCheckpointHeaderPos = async (comfyPage: ComfyPage) =>
|
||||
@@ -84,29 +84,27 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
|
||||
await comfyPage.idleFrames(2)
|
||||
}
|
||||
|
||||
test('should allow moving nodes by dragging', async ({ comfyPage }) => {
|
||||
const loadCheckpointHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
await comfyPage.canvasOps.dragAndDrop(loadCheckpointHeaderPos, {
|
||||
x: 256,
|
||||
y: 256
|
||||
})
|
||||
test('should allow moving nodes by dragging', async ({
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
const initialHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle('Load Checkpoint')
|
||||
await comfyMouse.dragElementBy(node.header, { x: 100, y: 100 })
|
||||
|
||||
const newHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
await expectPosChanged(loadCheckpointHeaderPos, newHeaderPos)
|
||||
await expectPosChanged(initialHeaderPos, newHeaderPos)
|
||||
})
|
||||
|
||||
test('should not move node when pointer moves less than drag threshold', async ({
|
||||
comfyPage
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
const headerPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
|
||||
// Move only 2px — below the 3px drag threshold in useNodePointerInteractions
|
||||
await comfyPage.page.mouse.move(headerPos.x, headerPos.y)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.mouse.move(headerPos.x + 2, headerPos.y + 1, {
|
||||
steps: 5
|
||||
})
|
||||
await comfyPage.page.mouse.up()
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle('Load Checkpoint')
|
||||
await comfyMouse.dragElementBy(node.header, { x: 2, y: 1 })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const afterPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
@@ -295,14 +293,12 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
|
||||
await expect(comfyPage.vueNodes.selectedNodes).toHaveCount(3)
|
||||
|
||||
// Re-fetch drag source after clicks in case the header reflowed.
|
||||
const dragSrc = await getHeaderPos(comfyPage, 'Load Checkpoint')
|
||||
const centerX = dragSrc.x + dragSrc.width / 2
|
||||
const centerY = dragSrc.y + dragSrc.height / 2
|
||||
const headerPos = await getHeaderPos(comfyPage, 'Load Checkpoint')
|
||||
|
||||
await comfyPage.page.mouse.move(centerX, centerY)
|
||||
await comfyPage.page.mouse.move(headerPos.x, headerPos.y)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.page.mouse.move(centerX + dx, centerY + dy, {
|
||||
await comfyPage.page.mouse.move(headerPos.x + dx, headerPos.y + dy, {
|
||||
steps: 20
|
||||
})
|
||||
await comfyPage.page.mouse.up()
|
||||
|
||||
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 101 KiB |
@@ -42,7 +42,10 @@ test.describe('Vue Node Pin', { tag: '@vue-nodes' }, () => {
|
||||
await expect(pinIndicator2).toBeHidden()
|
||||
})
|
||||
|
||||
test('should not allow dragging pinned nodes', async ({ comfyPage }) => {
|
||||
test('should not allow dragging pinned nodes', async ({
|
||||
comfyMouse,
|
||||
comfyPage
|
||||
}) => {
|
||||
const checkpointNodeHeader = comfyPage.page.getByText('Load Checkpoint')
|
||||
await checkpointNodeHeader.click()
|
||||
await comfyPage.page.keyboard.press(PIN_HOTKEY)
|
||||
@@ -50,10 +53,7 @@ test.describe('Vue Node Pin', { tag: '@vue-nodes' }, () => {
|
||||
// Try to drag the node
|
||||
const headerPos = await checkpointNodeHeader.boundingBox()
|
||||
if (!headerPos) throw new Error('Failed to get header position')
|
||||
await comfyPage.canvasOps.dragAndDrop(
|
||||
{ x: headerPos.x, y: headerPos.y },
|
||||
{ x: headerPos.x + 256, y: headerPos.y + 256 }
|
||||
)
|
||||
await comfyMouse.dragElementBy(checkpointNodeHeader, { x: 256, y: 256 })
|
||||
|
||||
// Verify the node is not dragged (same position before and after click-and-drag)
|
||||
await expect
|
||||
@@ -64,11 +64,7 @@ test.describe('Vue Node Pin', { tag: '@vue-nodes' }, () => {
|
||||
await checkpointNodeHeader.click()
|
||||
await comfyPage.page.keyboard.press(PIN_HOTKEY)
|
||||
|
||||
// Try to drag the node again
|
||||
await comfyPage.canvasOps.dragAndDrop(
|
||||
{ x: headerPos.x, y: headerPos.y },
|
||||
{ x: headerPos.x + 256, y: headerPos.y + 256 }
|
||||
)
|
||||
await comfyMouse.dragElementBy(checkpointNodeHeader, { x: 256, y: 256 })
|
||||
|
||||
// Verify the node is dragged
|
||||
await expect
|
||||
|
||||
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 26 KiB |
@@ -5,12 +5,7 @@ import {
|
||||
|
||||
test.describe('Widget copy button', { tag: ['@ui', '@vue-nodes'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
// Add a PreviewAny node which has a read-only textarea with a copy button
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const node = window.LiteGraph!.createNode('PreviewAny')
|
||||
window.app!.graph.add(node)
|
||||
})
|
||||
|
||||
await comfyPage.searchBoxV2.addNode('Preview as Text')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
|
||||
@@ -197,7 +197,7 @@
|
||||
--node-component-executing: var(--color-blue-500);
|
||||
--node-component-header: var(--fg-color);
|
||||
--node-component-header-icon: var(--color-ash-800);
|
||||
--node-component-header-surface: var(--color-smoke-400);
|
||||
--node-component-header-surface: var(--color-smoke-200);
|
||||
--node-component-outline: var(--color-black);
|
||||
--node-component-ring: rgb(from var(--color-smoke-500) r g b / 50%);
|
||||
--node-component-slot-dot-outline-opacity-mult: 1;
|
||||
@@ -343,7 +343,7 @@
|
||||
--node-component-border-executing: var(--color-blue-500);
|
||||
--node-component-border-selected: var(--color-charcoal-200);
|
||||
--node-component-header-icon: var(--color-smoke-800);
|
||||
--node-component-header-surface: var(--color-charcoal-800);
|
||||
--node-component-header-surface: var(--color-charcoal-700);
|
||||
--node-component-outline: var(--color-white);
|
||||
--node-component-ring: rgb(var(--color-smoke-500) / 20%);
|
||||
--node-component-slot-dot-outline-opacity: 10%;
|
||||
@@ -727,14 +727,14 @@ body {
|
||||
/* Shared markdown content styling for consistent rendering across components */
|
||||
.comfy-markdown-content {
|
||||
/* Typography */
|
||||
font-size: 0.875rem; /* text-sm */
|
||||
font-size: var(--comfy-textarea-font-size);
|
||||
line-height: 1.6;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Headings */
|
||||
.comfy-markdown-content h1 {
|
||||
font-size: 22px; /* text-[22px] */
|
||||
font-size: calc(22 / 14 * var(--comfy-textarea-font-size));
|
||||
font-weight: 700; /* font-bold */
|
||||
margin-top: 2rem; /* mt-8 */
|
||||
margin-bottom: 1rem; /* mb-4 */
|
||||
@@ -745,7 +745,7 @@ body {
|
||||
}
|
||||
|
||||
.comfy-markdown-content h2 {
|
||||
font-size: 18px; /* text-[18px] */
|
||||
font-size: calc(18 / 14 * var(--comfy-textarea-font-size));
|
||||
font-weight: 700; /* font-bold */
|
||||
margin-top: 2rem; /* mt-8 */
|
||||
margin-bottom: 1rem; /* mb-4 */
|
||||
@@ -756,7 +756,7 @@ body {
|
||||
}
|
||||
|
||||
.comfy-markdown-content h3 {
|
||||
font-size: 16px; /* text-[16px] */
|
||||
font-size: calc(16 / 14 * var(--comfy-textarea-font-size));
|
||||
font-weight: 700; /* font-bold */
|
||||
margin-top: 2rem; /* mt-8 */
|
||||
margin-bottom: 1rem; /* mb-4 */
|
||||
|
||||
@@ -22,7 +22,7 @@ import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { parseImageWidgetValue } from '@/utils/imageUtil'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { HideLayoutFieldKey } from '@/types/widgetTypes'
|
||||
import { HideLayoutFieldKey, WidgetHeightKey } from '@/types/widgetTypes'
|
||||
import { UNASSIGNED_NODE_ID } from '@/types/nodeId'
|
||||
import { promptRenameWidget } from '@/utils/widgetUtil'
|
||||
|
||||
@@ -50,6 +50,7 @@ const { onPointerDown } = useAppModeWidgetResizing((widget, config) =>
|
||||
)
|
||||
|
||||
provide(HideLayoutFieldKey, true)
|
||||
provide(WidgetHeightKey, mobile ? 'h-10' : 'h-7')
|
||||
|
||||
const resolvedInputs = useResolvedSelectedInputs()
|
||||
|
||||
@@ -236,7 +237,7 @@ defineExpose({ handleDragDrop })
|
||||
:node-data
|
||||
:class="
|
||||
cn(
|
||||
'gap-y-3 rounded-lg py-1 [&_textarea]:resize-y **:[.col-span-2]:grid-cols-1 not-md:**:[.h-7]:h-10',
|
||||
'gap-y-3 rounded-lg py-1 [&_textarea]:resize-y **:[.col-span-2]:grid-cols-1',
|
||||
nodeData.hasErrors && 'ring-2 ring-node-stroke-error ring-inset'
|
||||
)
|
||||
"
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
<template>
|
||||
<div
|
||||
ref="container"
|
||||
class="flex h-7 rounded-lg bg-component-node-widget-background text-xs text-component-node-foreground"
|
||||
:class="
|
||||
cn(
|
||||
'flex overflow-hidden rounded-md bg-component-node-widget-background text-xs text-component-node-foreground',
|
||||
useWidgetHeight()
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot name="background" />
|
||||
<Button
|
||||
v-if="!hideButtons"
|
||||
:aria-label="t('g.decrement')"
|
||||
data-testid="decrement"
|
||||
class="aspect-8/7 h-full rounded-r-none hover:bg-base-foreground/20 disabled:opacity-30"
|
||||
class="aspect-square h-full rounded-none p-0 hover:bg-component-node-widget-background-hovered disabled:opacity-30"
|
||||
variant="muted-textonly"
|
||||
size="unset"
|
||||
:disabled="!canDecrement"
|
||||
tabindex="-1"
|
||||
@click="modelValue = clamp(modelValue - step)"
|
||||
>
|
||||
<i class="pi pi-minus" />
|
||||
<i class="icon-[lucide--minus]" />
|
||||
</Button>
|
||||
<div class="relative my-0.25 min-w-[4ch] flex-1 py-1.5">
|
||||
<input
|
||||
@@ -24,7 +30,7 @@
|
||||
:disabled
|
||||
:class="
|
||||
cn(
|
||||
'absolute inset-0 truncate border-0 bg-transparent p-1 text-sm focus:outline-0'
|
||||
'absolute inset-0 truncate border-0 bg-transparent p-1 text-xs focus:outline-0'
|
||||
)
|
||||
"
|
||||
inputmode="decimal"
|
||||
@@ -54,13 +60,14 @@
|
||||
v-if="!hideButtons"
|
||||
:aria-label="t('g.increment')"
|
||||
data-testid="increment"
|
||||
class="aspect-8/7 h-full rounded-l-none hover:bg-base-foreground/20 disabled:opacity-30"
|
||||
class="aspect-square h-full rounded-none p-0 hover:bg-component-node-widget-background-hovered disabled:opacity-30"
|
||||
variant="muted-textonly"
|
||||
size="unset"
|
||||
:disabled="!canIncrement"
|
||||
tabindex="-1"
|
||||
@click="modelValue = clamp(modelValue + step)"
|
||||
>
|
||||
<i class="pi pi-plus" />
|
||||
<i class="icon-[lucide--plus]" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -71,6 +78,7 @@ import { computed, ref, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useWidgetHeight } from '@/types/widgetTypes'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const {
|
||||
|
||||
@@ -42,22 +42,34 @@ function withStrictMillisecondParser<T>(run: () => T): T {
|
||||
}
|
||||
|
||||
const mockSubscription = vi.hoisted(() => ({
|
||||
value: null as { endDate: string | null } | null
|
||||
value: null as {
|
||||
endDate: string | null
|
||||
duration?: 'ANNUAL' | 'MONTHLY' | null
|
||||
} | null
|
||||
}))
|
||||
|
||||
const mockCancelSubscription = vi.hoisted(() => vi.fn())
|
||||
const mockFetchStatus = vi.hoisted(() => vi.fn())
|
||||
const mockCloseDialog = vi.hoisted(() => vi.fn())
|
||||
const mockToastAdd = vi.hoisted(() => vi.fn())
|
||||
const mockTier = vi.hoisted(() => ({ value: 'STANDARD' as string | null }))
|
||||
const mockTrackCancellation = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: vi.fn(() => ({
|
||||
cancelSubscription: mockCancelSubscription,
|
||||
fetchStatus: mockFetchStatus,
|
||||
subscription: mockSubscription
|
||||
subscription: mockSubscription,
|
||||
tier: mockTier
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({
|
||||
trackSubscriptionCancellation: mockTrackCancellation
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: vi.fn(() => ({
|
||||
closeDialog: mockCloseDialog
|
||||
@@ -94,6 +106,95 @@ function renderComponent(props: { cancelAt?: string } = {}) {
|
||||
describe('CancelSubscriptionDialogContent', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockTier.value = 'STANDARD'
|
||||
})
|
||||
|
||||
describe('cancellation telemetry', () => {
|
||||
it('tracks flow_opened with tier and end date when the dialog mounts', () => {
|
||||
mockSubscription.value = { endDate: '2026-08-01T00:00:00.000Z' }
|
||||
|
||||
renderComponent()
|
||||
|
||||
expect(mockTrackCancellation).toHaveBeenCalledWith('flow_opened', {
|
||||
source: 'cancel_plan_menu',
|
||||
current_tier: 'standard',
|
||||
end_date: '2026-08-01T00:00:00.000Z'
|
||||
})
|
||||
})
|
||||
|
||||
it('tracks confirmed before the cancel request and no abandoned on success', async () => {
|
||||
mockSubscription.value = null
|
||||
mockCancelSubscription.mockResolvedValueOnce(undefined)
|
||||
|
||||
const { unmount } = renderComponent()
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', { name: /^cancel subscription$/i })
|
||||
)
|
||||
|
||||
await waitFor(() => expect(mockCloseDialog).toHaveBeenCalled())
|
||||
unmount()
|
||||
expect(mockTrackCancellation).toHaveBeenCalledWith(
|
||||
'confirmed',
|
||||
expect.objectContaining({ current_tier: 'standard' })
|
||||
)
|
||||
expect(mockTrackCancellation).not.toHaveBeenCalledWith(
|
||||
'abandoned',
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('tracks confirmed and failed with message-carrying rejection values', async () => {
|
||||
mockSubscription.value = null
|
||||
mockCancelSubscription.mockRejectedValueOnce({ message: 'timed out' })
|
||||
|
||||
renderComponent()
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', { name: /^cancel subscription$/i })
|
||||
)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockTrackCancellation).toHaveBeenCalledWith(
|
||||
'failed',
|
||||
expect.objectContaining({ error_message: 'timed out' })
|
||||
)
|
||||
)
|
||||
expect(mockTrackCancellation).toHaveBeenCalledWith(
|
||||
'confirmed',
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('tracks abandoned when the user keeps the subscription', async () => {
|
||||
mockSubscription.value = null
|
||||
|
||||
const { unmount } = renderComponent()
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', { name: /keep subscription/i })
|
||||
)
|
||||
|
||||
expect(mockCloseDialog).toHaveBeenCalledWith({
|
||||
key: 'cancel-subscription'
|
||||
})
|
||||
unmount()
|
||||
expect(mockTrackCancellation).toHaveBeenCalledWith(
|
||||
'abandoned',
|
||||
expect.objectContaining({ current_tier: 'standard' })
|
||||
)
|
||||
expect(mockCancelSubscription).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('tracks abandoned when the dialog is dismissed by the shell', () => {
|
||||
mockSubscription.value = null
|
||||
|
||||
const { unmount } = renderComponent()
|
||||
mockTrackCancellation.mockClear()
|
||||
unmount()
|
||||
|
||||
expect(mockTrackCancellation).toHaveBeenCalledWith(
|
||||
'abandoned',
|
||||
expect.objectContaining({ current_tier: 'standard' })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancel flow', () => {
|
||||
@@ -138,6 +239,35 @@ describe('CancelSubscriptionDialogContent', () => {
|
||||
expect.objectContaining({ severity: 'success' })
|
||||
)
|
||||
})
|
||||
|
||||
it('does not track cancellation failure when status refresh fails after cancellation succeeds', async () => {
|
||||
mockSubscription.value = null
|
||||
mockCancelSubscription.mockResolvedValueOnce(undefined)
|
||||
mockFetchStatus.mockRejectedValueOnce(new Error('Refresh failed'))
|
||||
|
||||
const { unmount } = renderComponent()
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', { name: /^cancel subscription$/i })
|
||||
)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'success' })
|
||||
)
|
||||
)
|
||||
expect(mockCloseDialog).toHaveBeenCalledWith({
|
||||
key: 'cancel-subscription'
|
||||
})
|
||||
expect(
|
||||
mockTrackCancellation.mock.calls.some(([stage]) => stage === 'failed')
|
||||
).toBe(false)
|
||||
|
||||
unmount()
|
||||
expect(mockTrackCancellation).not.toHaveBeenCalledWith(
|
||||
'abandoned',
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formattedEndDate fallbacks', () => {
|
||||
|
||||
@@ -45,13 +45,16 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { SubscriptionCancellationMetadata } from '@/platform/telemetry/types'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { parseIsoDateSafe } from '@/utils/dateTimeUtil'
|
||||
import { getErrorMessage } from '@/utils/errorUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
cancelAt?: string
|
||||
@@ -60,9 +63,41 @@ const props = defineProps<{
|
||||
const { t } = useI18n()
|
||||
const dialogStore = useDialogStore()
|
||||
const toast = useToast()
|
||||
const { cancelSubscription, fetchStatus, subscription } = useBillingContext()
|
||||
const { cancelSubscription, fetchStatus, subscription, tier } =
|
||||
useBillingContext()
|
||||
const telemetry = useTelemetry()
|
||||
|
||||
const isLoading = ref(false)
|
||||
const didCancelSucceed = ref(false)
|
||||
|
||||
function cancellationMetadata(): SubscriptionCancellationMetadata {
|
||||
const endDate = props.cancelAt ?? subscription.value?.endDate
|
||||
return {
|
||||
source: 'cancel_plan_menu' as const,
|
||||
current_tier: tier.value?.toLowerCase(),
|
||||
...(subscription.value?.duration
|
||||
? {
|
||||
cycle:
|
||||
subscription.value.duration === 'ANNUAL'
|
||||
? ('yearly' as const)
|
||||
: ('monthly' as const)
|
||||
}
|
||||
: {}),
|
||||
...(endDate ? { end_date: endDate } : {})
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
telemetry?.trackSubscriptionCancellation(
|
||||
'flow_opened',
|
||||
cancellationMetadata()
|
||||
)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (didCancelSucceed.value || isLoading.value) return
|
||||
telemetry?.trackSubscriptionCancellation('abandoned', cancellationMetadata())
|
||||
})
|
||||
|
||||
const formattedEndDate = computed(() => {
|
||||
const date = parseIsoDateSafe(props.cancelAt ?? subscription.value?.endDate)
|
||||
@@ -84,24 +119,37 @@ function onClose() {
|
||||
}
|
||||
|
||||
async function onConfirmCancel() {
|
||||
telemetry?.trackSubscriptionCancellation('confirmed', cancellationMetadata())
|
||||
isLoading.value = true
|
||||
try {
|
||||
await cancelSubscription()
|
||||
await fetchStatus()
|
||||
dialogStore.closeDialog({ key: 'cancel-subscription' })
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('subscription.cancelSuccess'),
|
||||
life: 5000
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error)
|
||||
telemetry?.trackSubscriptionCancellation('failed', {
|
||||
...cancellationMetadata(),
|
||||
error_message: errorMessage ?? String(error)
|
||||
})
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('subscription.cancelDialog.failed'),
|
||||
detail: error instanceof Error ? error.message : t('g.unknownError')
|
||||
detail: errorMessage ?? t('g.unknownError')
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
didCancelSucceed.value = true
|
||||
try {
|
||||
await fetchStatus()
|
||||
} catch {
|
||||
// Cancellation already succeeded; stale local subscription status should not report failure.
|
||||
}
|
||||
dialogStore.closeDialog({ key: 'cancel-subscription' })
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('subscription.cancelSuccess'),
|
||||
life: 5000
|
||||
})
|
||||
isLoading.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -31,7 +31,7 @@ import { getWidgetDefaultValue } from '@/utils/widgetUtil'
|
||||
import type { WidgetValue } from '@/utils/widgetUtil'
|
||||
|
||||
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
|
||||
import { HideLayoutFieldKey } from '@/types/widgetTypes'
|
||||
import { HideLayoutFieldKey, WidgetHeightKey } from '@/types/widgetTypes'
|
||||
|
||||
import { GetNodeParentGroupKey } from '../shared'
|
||||
import WidgetItem from './WidgetItem.vue'
|
||||
@@ -135,6 +135,7 @@ watchDebounced(
|
||||
onBeforeUnmount(() => draggableList.value?.dispose())
|
||||
|
||||
provide(HideLayoutFieldKey, true)
|
||||
provide(WidgetHeightKey, 'h-7')
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
"color": "Color",
|
||||
"error": "Error",
|
||||
"enter": "Enter",
|
||||
"enterSubgraph": "Enter Subgraph",
|
||||
"enterSubgraph": "Enter subgraph",
|
||||
"inSubgraph": "in subgraph '{name}'",
|
||||
"resizeFromBottomRight": "Resize from bottom-right corner",
|
||||
"resizeFromTopRight": "Resize from top-right corner",
|
||||
|
||||
@@ -426,10 +426,6 @@
|
||||
"Comfy_Validation_Workflows": {
|
||||
"name": "Validate workflows"
|
||||
},
|
||||
"Comfy_VueNodes_AutoScaleLayout": {
|
||||
"name": "Auto-scale layout (Nodes 2.0)",
|
||||
"tooltip": "Automatically scale node positions when switching to Nodes 2.0 rendering to prevent overlap"
|
||||
},
|
||||
"Comfy_VueNodes_Enabled": {
|
||||
"name": "Modern Node Design (Nodes 2.0)",
|
||||
"tooltip": "Modern: DOM-based rendering with enhanced interactivity, native browser features, and updated visual design. Classic: Traditional canvas rendering."
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
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 SubscriptionPanelContentLegacy from './SubscriptionPanelContentLegacy.vue'
|
||||
|
||||
const mockAccessBillingPortal = vi.fn()
|
||||
const mockTrackSubscriptionCancellation = vi.fn()
|
||||
const mockShowSubscriptionDialog = vi.fn()
|
||||
const mockHandleRefresh = vi.fn()
|
||||
|
||||
const mockIsActiveSubscription = ref(true)
|
||||
const mockIsCancelled = ref(false)
|
||||
const mockIsFreeTier = ref(false)
|
||||
const mockSubscriptionTier = ref<'STANDARD' | 'CREATOR' | 'PRO' | null>(
|
||||
'STANDARD'
|
||||
)
|
||||
const mockIsYearlySubscription = ref(true)
|
||||
|
||||
vi.mock('@/composables/auth/useAuthActions', () => ({
|
||||
useAuthActions: () => ({
|
||||
accessBillingPortal: mockAccessBillingPortal
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({
|
||||
trackSubscriptionCancellation: mockTrackSubscriptionCancellation
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
useSubscription: () => ({
|
||||
isActiveSubscription: computed(() => mockIsActiveSubscription.value),
|
||||
isCancelled: computed(() => mockIsCancelled.value),
|
||||
isFreeTier: computed(() => mockIsFreeTier.value),
|
||||
formattedRenewalDate: computed(() => '2026-08-01'),
|
||||
formattedEndDate: computed(() => '2026-08-01'),
|
||||
subscriptionTier: computed(() => mockSubscriptionTier.value),
|
||||
subscriptionTierName: computed(() => 'Standard'),
|
||||
isYearlySubscription: computed(() => mockIsYearlySubscription.value)
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/platform/cloud/subscription/composables/useSubscriptionActions',
|
||||
() => ({
|
||||
useSubscriptionActions: () => ({
|
||||
handleRefresh: mockHandleRefresh
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
|
||||
() => ({
|
||||
useSubscriptionDialog: () => ({
|
||||
show: mockShowSubscriptionDialog
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
subscription: {
|
||||
perMonth: '/ month',
|
||||
manageSubscription: 'Manage subscription',
|
||||
upgradePlan: 'Upgrade plan',
|
||||
subscribeNow: 'Subscribe now',
|
||||
yourPlanIncludes: 'Your plan includes',
|
||||
viewMoreDetailsPlans: 'View more details',
|
||||
renewsDate: 'Renews {date}',
|
||||
expiresDate: 'Expires {date}',
|
||||
monthlyCreditsLabel: 'monthly credits',
|
||||
maxDurationLabel: 'max duration',
|
||||
gpuLabel: 'GPU access',
|
||||
addCreditsLabel: 'Add credits',
|
||||
customLoRAsLabel: 'Custom LoRAs',
|
||||
maxDuration: {
|
||||
standard: '30 min'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function renderComponent() {
|
||||
return render(SubscriptionPanelContentLegacy, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
CreditsTile: true,
|
||||
SubscribeButton: true,
|
||||
Button: {
|
||||
template: '<button @click="$emit(\'click\')"><slot /></button>',
|
||||
emits: ['click']
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('SubscriptionPanelContentLegacy', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockAccessBillingPortal.mockResolvedValue(undefined)
|
||||
mockIsActiveSubscription.value = true
|
||||
mockIsCancelled.value = false
|
||||
mockIsFreeTier.value = false
|
||||
mockSubscriptionTier.value = 'STANDARD'
|
||||
mockIsYearlySubscription.value = true
|
||||
})
|
||||
|
||||
it('tracks cancel intent before opening the billing portal', async () => {
|
||||
renderComponent()
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', { name: /manage subscription/i })
|
||||
)
|
||||
|
||||
expect(mockTrackSubscriptionCancellation).toHaveBeenCalledExactlyOnceWith(
|
||||
'flow_opened',
|
||||
{
|
||||
source: 'manage_subscription_button',
|
||||
current_tier: 'standard',
|
||||
cycle: 'yearly'
|
||||
}
|
||||
)
|
||||
expect(mockAccessBillingPortal).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -36,11 +36,7 @@
|
||||
v-if="isActiveSubscription && !isFreeTier"
|
||||
variant="secondary"
|
||||
class="ml-auto rounded-lg bg-interface-menu-component-surface-selected px-4 py-2 text-sm font-normal text-text-primary"
|
||||
@click="
|
||||
async () => {
|
||||
await authActions.accessBillingPortal()
|
||||
}
|
||||
"
|
||||
@click="handleManageSubscription"
|
||||
>
|
||||
{{ $t('subscription.manageSubscription') }}
|
||||
</Button>
|
||||
@@ -125,6 +121,7 @@ import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import CreditsTile from '@/platform/cloud/subscription/components/CreditsTile.vue'
|
||||
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
|
||||
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import {
|
||||
@@ -160,6 +157,18 @@ const tierPrice = computed(() =>
|
||||
getTierPrice(tierKey.value, isYearlySubscription.value)
|
||||
)
|
||||
|
||||
// The portal is the only place a legacy user can cancel (in-app UI already
|
||||
// covers plan changes), so this click is the closest observable cancel-intent
|
||||
// signal on the mainline path.
|
||||
async function handleManageSubscription() {
|
||||
useTelemetry()?.trackSubscriptionCancellation('flow_opened', {
|
||||
source: 'manage_subscription_button',
|
||||
current_tier: subscriptionTier.value?.toLowerCase(),
|
||||
cycle: isYearlySubscription.value ? 'yearly' : 'monthly'
|
||||
})
|
||||
await authActions.accessBillingPortal()
|
||||
}
|
||||
|
||||
const tierBenefits = computed((): TierBenefit[] =>
|
||||
getCommonTierBenefits(tierKey.value, t, n)
|
||||
)
|
||||
|
||||
@@ -1207,18 +1207,6 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
type: 'hidden',
|
||||
defaultValue: false
|
||||
},
|
||||
{
|
||||
id: 'Comfy.VueNodes.AutoScaleLayout',
|
||||
category: ['Comfy', 'Nodes 2.0', 'AutoScaleLayout'],
|
||||
name: 'Auto-scale layout (Nodes 2.0)',
|
||||
tooltip:
|
||||
'Automatically scale node positions when switching to Nodes 2.0 rendering to prevent overlap',
|
||||
type: 'boolean',
|
||||
sortOrder: 50,
|
||||
experimental: true,
|
||||
defaultValue: true,
|
||||
versionAdded: '1.30.3'
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Assets.UseAssetAPI',
|
||||
name: 'Use Asset API for model library',
|
||||
|
||||
@@ -78,4 +78,43 @@ describe('TelemetryRegistry', () => {
|
||||
})
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('dispatches subscription cancellation telemetry to every registered provider', () => {
|
||||
const a: TelemetryProvider = { trackSubscriptionCancellation: vi.fn() }
|
||||
const b: TelemetryProvider = { trackSubscriptionCancellation: vi.fn() }
|
||||
const registry = new TelemetryRegistry()
|
||||
registry.registerProvider(a)
|
||||
registry.registerProvider(b)
|
||||
|
||||
const payload = {
|
||||
source: 'cancel_plan_menu' as const,
|
||||
current_tier: 'standard',
|
||||
cycle: 'monthly' as const,
|
||||
end_date: '2026-08-01T00:00:00.000Z'
|
||||
}
|
||||
registry.trackSubscriptionCancellation('flow_opened', payload)
|
||||
|
||||
expect(a.trackSubscriptionCancellation).toHaveBeenCalledExactlyOnceWith(
|
||||
'flow_opened',
|
||||
payload
|
||||
)
|
||||
expect(b.trackSubscriptionCancellation).toHaveBeenCalledExactlyOnceWith(
|
||||
'flow_opened',
|
||||
payload
|
||||
)
|
||||
})
|
||||
|
||||
it('dispatches resubscribe click telemetry to every registered provider', () => {
|
||||
const a: TelemetryProvider = { trackResubscribeClicked: vi.fn() }
|
||||
const b: TelemetryProvider = { trackResubscribeClicked: vi.fn() }
|
||||
const registry = new TelemetryRegistry()
|
||||
registry.registerProvider(a)
|
||||
registry.registerProvider(b)
|
||||
|
||||
const payload = { source: 'settings_billing_panel' as const }
|
||||
registry.trackResubscribeClicked(payload)
|
||||
|
||||
expect(a.trackResubscribeClicked).toHaveBeenCalledExactlyOnceWith(payload)
|
||||
expect(b.trackResubscribeClicked).toHaveBeenCalledExactlyOnceWith(payload)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,10 +19,12 @@ import type {
|
||||
SearchQueryMetadata,
|
||||
PageViewMetadata,
|
||||
PageVisibilityMetadata,
|
||||
ResubscribeClickMetadata,
|
||||
RunButtonProperties,
|
||||
SettingChangedMetadata,
|
||||
SharedWorkflowRunMetadata,
|
||||
ShellLayoutMetadata,
|
||||
SubscriptionCancellationMetadata,
|
||||
SubscriptionMetadata,
|
||||
SubscriptionSuccessMetadata,
|
||||
SurveyResponses,
|
||||
@@ -100,6 +102,19 @@ export class TelemetryRegistry implements TelemetryDispatcher {
|
||||
this.dispatch((provider) => provider.trackMonthlySubscriptionCancelled?.())
|
||||
}
|
||||
|
||||
trackSubscriptionCancellation(
|
||||
event: 'flow_opened' | 'confirmed' | 'abandoned' | 'failed',
|
||||
metadata?: SubscriptionCancellationMetadata
|
||||
): void {
|
||||
this.dispatch((provider) =>
|
||||
provider.trackSubscriptionCancellation?.(event, metadata)
|
||||
)
|
||||
}
|
||||
|
||||
trackResubscribeClicked(metadata: ResubscribeClickMetadata): void {
|
||||
this.dispatch((provider) => provider.trackResubscribeClicked?.(metadata))
|
||||
}
|
||||
|
||||
trackAddApiCreditButtonClicked(metadata?: AddCreditsClickMetadata): void {
|
||||
this.dispatch((provider) =>
|
||||
provider.trackAddApiCreditButtonClicked?.(metadata)
|
||||
|
||||
@@ -313,6 +313,45 @@ describe('PostHogTelemetryProvider', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it.for([
|
||||
['flow_opened', TelemetryEvents.SUBSCRIPTION_CANCEL_FLOW_OPENED, {}],
|
||||
['confirmed', TelemetryEvents.SUBSCRIPTION_CANCEL_CONFIRMED, {}],
|
||||
['abandoned', TelemetryEvents.SUBSCRIPTION_CANCEL_ABANDONED, {}],
|
||||
[
|
||||
'failed',
|
||||
TelemetryEvents.SUBSCRIPTION_CANCEL_FAILED,
|
||||
{ error_message: 'timed out' }
|
||||
]
|
||||
] as const)(
|
||||
'captures %s cancellation stage',
|
||||
async ([stage, event, extra]) => {
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackSubscriptionCancellation(stage, {
|
||||
current_tier: 'standard',
|
||||
...extra
|
||||
})
|
||||
|
||||
expect(hoisted.mockCapture).toHaveBeenCalledWith(event, {
|
||||
current_tier: 'standard',
|
||||
...extra
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('captures resubscribe clicks with their source', async () => {
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackResubscribeClicked({ source: 'settings_billing_panel' })
|
||||
|
||||
expect(hoisted.mockCapture).toHaveBeenCalledWith(
|
||||
TelemetryEvents.RESUBSCRIBE_BUTTON_CLICKED,
|
||||
{ source: 'settings_billing_panel' }
|
||||
)
|
||||
})
|
||||
|
||||
it('captures begin_checkout with intent metadata', async () => {
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
@@ -26,10 +26,12 @@ import type {
|
||||
SearchQueryMetadata,
|
||||
PageViewMetadata,
|
||||
PageVisibilityMetadata,
|
||||
ResubscribeClickMetadata,
|
||||
RunButtonProperties,
|
||||
SettingChangedMetadata,
|
||||
SharedWorkflowRunMetadata,
|
||||
ShellLayoutMetadata,
|
||||
SubscriptionCancellationMetadata,
|
||||
SubscriptionMetadata,
|
||||
SubscriptionSuccessMetadata,
|
||||
SurveyResponses,
|
||||
@@ -47,7 +49,7 @@ import type {
|
||||
WorkflowSavedMetadata,
|
||||
WorkspaceInviteMetadata
|
||||
} from '../../types'
|
||||
import { TelemetryEvents } from '../../types'
|
||||
import { CANCELLATION_STAGE_EVENTS, TelemetryEvents } from '../../types'
|
||||
import { normalizeSurveyResponses } from '../../utils/surveyNormalization'
|
||||
|
||||
const DEFAULT_DISABLED_EVENTS = [
|
||||
@@ -370,6 +372,17 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
this.trackEvent(TelemetryEvents.MONTHLY_SUBSCRIPTION_CANCELLED)
|
||||
}
|
||||
|
||||
trackSubscriptionCancellation(
|
||||
event: 'flow_opened' | 'confirmed' | 'abandoned' | 'failed',
|
||||
metadata?: SubscriptionCancellationMetadata
|
||||
): void {
|
||||
this.trackEvent(CANCELLATION_STAGE_EVENTS[event], metadata)
|
||||
}
|
||||
|
||||
trackResubscribeClicked(metadata: ResubscribeClickMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.RESUBSCRIBE_BUTTON_CLICKED, metadata)
|
||||
}
|
||||
|
||||
trackApiCreditTopupButtonPurchaseClicked(amount: number): void {
|
||||
this.trackEvent(TelemetryEvents.API_CREDIT_TOPUP_BUTTON_PURCHASE_CLICKED, {
|
||||
credit_amount: amount
|
||||
|
||||
@@ -115,6 +115,36 @@ describe('HostTelemetrySink', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('forwards subscription cancellation telemetry to the host bridge', () => {
|
||||
new HostTelemetrySink().trackSubscriptionCancellation('confirmed', {
|
||||
source: 'cancel_plan_menu',
|
||||
current_tier: 'standard',
|
||||
cycle: 'yearly',
|
||||
end_date: '2026-08-01T00:00:00.000Z'
|
||||
})
|
||||
|
||||
expect(state.capture).toHaveBeenCalledExactlyOnceWith(
|
||||
TelemetryEvents.SUBSCRIPTION_CANCEL_CONFIRMED,
|
||||
{
|
||||
source: 'cancel_plan_menu',
|
||||
current_tier: 'standard',
|
||||
cycle: 'yearly',
|
||||
end_date: '2026-08-01T00:00:00.000Z'
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('forwards resubscribe click telemetry to the host bridge', () => {
|
||||
new HostTelemetrySink().trackResubscribeClicked({
|
||||
source: 'pricing_dialog'
|
||||
})
|
||||
|
||||
expect(state.capture).toHaveBeenCalledExactlyOnceWith(
|
||||
TelemetryEvents.RESUBSCRIBE_BUTTON_CLICKED,
|
||||
{ source: 'pricing_dialog' }
|
||||
)
|
||||
})
|
||||
|
||||
it('forwards add-credit clicks with their source', () => {
|
||||
new HostTelemetrySink().trackAddApiCreditButtonClicked({
|
||||
source: 'avatar_menu'
|
||||
|
||||
@@ -31,6 +31,8 @@ import type {
|
||||
ShareFlowMetadata,
|
||||
ShareLinkOpenedMetadata,
|
||||
SharedWorkflowRunMetadata,
|
||||
ResubscribeClickMetadata,
|
||||
SubscriptionCancellationMetadata,
|
||||
SubscriptionMetadata,
|
||||
SubscriptionSuccessMetadata,
|
||||
SurveyResponses,
|
||||
@@ -46,7 +48,7 @@ import type {
|
||||
WorkflowImportMetadata,
|
||||
WorkflowSavedMetadata
|
||||
} from '../../types'
|
||||
import { TelemetryEvents } from '../../types'
|
||||
import { CANCELLATION_STAGE_EVENTS, TelemetryEvents } from '../../types'
|
||||
import { normalizeSurveyResponses } from '../../utils/surveyNormalization'
|
||||
|
||||
type HostTelemetryProperties = Parameters<
|
||||
@@ -127,6 +129,17 @@ export class HostTelemetrySink implements TelemetryProvider {
|
||||
this.capture(TelemetryEvents.MONTHLY_SUBSCRIPTION_CANCELLED)
|
||||
}
|
||||
|
||||
trackSubscriptionCancellation(
|
||||
event: 'flow_opened' | 'confirmed' | 'abandoned' | 'failed',
|
||||
metadata?: SubscriptionCancellationMetadata
|
||||
): void {
|
||||
this.capture(CANCELLATION_STAGE_EVENTS[event], metadata)
|
||||
}
|
||||
|
||||
trackResubscribeClicked(metadata: ResubscribeClickMetadata): void {
|
||||
this.capture(TelemetryEvents.RESUBSCRIBE_BUTTON_CLICKED, metadata)
|
||||
}
|
||||
|
||||
trackAddApiCreditButtonClicked(metadata?: AddCreditsClickMetadata): void {
|
||||
this.capture(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED, metadata)
|
||||
}
|
||||
|
||||
@@ -450,6 +450,27 @@ export interface AddCreditsClickMetadata {
|
||||
source: 'credits_panel' | 'avatar_menu' | 'settings_billing_panel'
|
||||
}
|
||||
|
||||
export interface SubscriptionCancellationMetadata {
|
||||
current_tier?: string
|
||||
cycle?: BillingCycle
|
||||
/**
|
||||
* `manage_subscription_button` opens the external billing portal, where
|
||||
* cancellation is one of the few possible actions but not the only one —
|
||||
* treat it as probable, not certain, cancel intent.
|
||||
*/
|
||||
source?: 'cancel_plan_menu' | 'manage_subscription_button'
|
||||
/** ISO date the subscription runs until if the cancel goes through. */
|
||||
end_date?: string
|
||||
/** Present only on the `failed` stage. */
|
||||
error_message?: string
|
||||
}
|
||||
|
||||
export interface ResubscribeClickMetadata {
|
||||
source: 'pricing_dialog' | 'settings_billing_panel'
|
||||
/** Why the pricing dialog was opened, when the click came from one. */
|
||||
payment_intent_source?: PaymentIntentSource
|
||||
}
|
||||
|
||||
export interface BeginCheckoutMetadata
|
||||
extends Record<string, unknown>, CheckoutAttributionMetadata {
|
||||
user_id: string
|
||||
@@ -514,6 +535,11 @@ export interface TelemetryProvider {
|
||||
metadata?: SubscriptionSuccessMetadata
|
||||
): void
|
||||
trackMonthlySubscriptionCancelled?(): void
|
||||
trackSubscriptionCancellation?(
|
||||
event: 'flow_opened' | 'confirmed' | 'abandoned' | 'failed',
|
||||
metadata?: SubscriptionCancellationMetadata
|
||||
): void
|
||||
trackResubscribeClicked?(metadata: ResubscribeClickMetadata): void
|
||||
trackAddApiCreditButtonClicked?(metadata?: AddCreditsClickMetadata): void
|
||||
trackApiCreditTopupButtonPurchaseClicked?(amount: number): void
|
||||
trackApiCreditTopupSucceeded?(): void
|
||||
@@ -617,6 +643,11 @@ export const TelemetryEvents = {
|
||||
SUBSCRIBE_NOW_BUTTON_CLICKED: 'app:subscribe_now_button_clicked',
|
||||
MONTHLY_SUBSCRIPTION_SUCCEEDED: 'app:monthly_subscription_succeeded',
|
||||
MONTHLY_SUBSCRIPTION_CANCELLED: 'app:monthly_subscription_cancelled',
|
||||
SUBSCRIPTION_CANCEL_FLOW_OPENED: 'app:subscription_cancel_flow_opened',
|
||||
SUBSCRIPTION_CANCEL_CONFIRMED: 'app:subscription_cancel_confirmed',
|
||||
SUBSCRIPTION_CANCEL_ABANDONED: 'app:subscription_cancel_abandoned',
|
||||
SUBSCRIPTION_CANCEL_FAILED: 'app:subscription_cancel_failed',
|
||||
RESUBSCRIBE_BUTTON_CLICKED: 'app:resubscribe_button_clicked',
|
||||
ADD_API_CREDIT_BUTTON_CLICKED: 'app:add_api_credit_button_clicked',
|
||||
API_CREDIT_TOPUP_BUTTON_PURCHASE_CLICKED:
|
||||
'app:api_credit_topup_button_purchase_clicked',
|
||||
@@ -691,6 +722,13 @@ export const TelemetryEvents = {
|
||||
export type TelemetryEventName =
|
||||
(typeof TelemetryEvents)[keyof typeof TelemetryEvents]
|
||||
|
||||
export const CANCELLATION_STAGE_EVENTS = {
|
||||
flow_opened: TelemetryEvents.SUBSCRIPTION_CANCEL_FLOW_OPENED,
|
||||
confirmed: TelemetryEvents.SUBSCRIPTION_CANCEL_CONFIRMED,
|
||||
abandoned: TelemetryEvents.SUBSCRIPTION_CANCEL_ABANDONED,
|
||||
failed: TelemetryEvents.SUBSCRIPTION_CANCEL_FAILED
|
||||
} as const
|
||||
|
||||
export type ExecutionTriggerSource =
|
||||
| 'button'
|
||||
| 'keybinding'
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
|
||||
/**
|
||||
* Reactivates a cancelled-but-still-active subscription and surfaces success or
|
||||
@@ -16,6 +17,9 @@ export function useResubscribe() {
|
||||
const isResubscribing = ref(false)
|
||||
|
||||
async function handleResubscribe() {
|
||||
useTelemetry()?.trackResubscribeClicked({
|
||||
source: 'settings_billing_panel'
|
||||
})
|
||||
isResubscribing.value = true
|
||||
try {
|
||||
await resubscribe()
|
||||
|
||||
@@ -123,9 +123,12 @@ vi.mock('primevue/usetoast', () => ({
|
||||
useToast: () => ({ add: mockToastAdd })
|
||||
}))
|
||||
|
||||
const mockTrackResubscribeClicked = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({
|
||||
trackMonthlySubscriptionSucceeded: vi.fn(),
|
||||
trackResubscribeClicked: mockTrackResubscribeClicked,
|
||||
trackBeginCheckout: mockTrackBeginCheckout
|
||||
})
|
||||
}))
|
||||
@@ -854,7 +857,7 @@ describe('useSubscriptionCheckout', () => {
|
||||
|
||||
describe('handleResubscribe', () => {
|
||||
it('emits close on success', async () => {
|
||||
const checkout = await setup()
|
||||
const checkout = await setup('subscribe_to_run')
|
||||
mockResubscribe.mockResolvedValueOnce({
|
||||
billing_op_id: 'op-4',
|
||||
status: 'active'
|
||||
@@ -866,6 +869,10 @@ describe('useSubscriptionCheckout', () => {
|
||||
|
||||
expect(mockResubscribe).toHaveBeenCalled()
|
||||
expect(emit).toHaveBeenCalledWith('close', true)
|
||||
expect(mockTrackResubscribeClicked).toHaveBeenCalledWith({
|
||||
source: 'pricing_dialog',
|
||||
payment_intent_source: 'subscribe_to_run'
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error toast on failure', async () => {
|
||||
|
||||
@@ -343,6 +343,10 @@ export function useSubscriptionCheckout(
|
||||
}
|
||||
|
||||
async function handleResubscribe() {
|
||||
telemetry?.trackResubscribeClicked({
|
||||
source: 'pricing_dialog',
|
||||
payment_intent_source: paymentIntentSource
|
||||
})
|
||||
isResubscribing.value = true
|
||||
try {
|
||||
await resubscribe()
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
cn(
|
||||
'lg-slot lg-slot--input group m-0 flex items-center rounded-r-lg',
|
||||
'cursor-crosshair',
|
||||
dotOnly ? 'lg-slot--dot-only' : 'pr-6',
|
||||
dotOnly ? 'lg-slot--dot-only' : 'h-5 pr-2',
|
||||
{
|
||||
'lg-slot--connected': props.connected,
|
||||
'lg-slot--compatible': props.compatible,
|
||||
|
||||
@@ -11,8 +11,11 @@
|
||||
:data-ghost="nodeData.flags?.ghost || undefined"
|
||||
:class="
|
||||
cn(
|
||||
'group/node lg-node absolute isolate text-sm',
|
||||
'group/node lg-node absolute isolate text-xs',
|
||||
'flex flex-col contain-layout contain-style',
|
||||
isLightTheme
|
||||
? 'drop-shadow-md drop-shadow-black/15'
|
||||
: 'drop-shadow-xl drop-shadow-black/40',
|
||||
isRerouteNode
|
||||
? 'h-(--node-height)'
|
||||
: 'min-h-(--node-height) min-w-(--min-node-width)',
|
||||
@@ -64,20 +67,11 @@
|
||||
)
|
||||
"
|
||||
/>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'pointer-events-none absolute border border-solid border-component-node-border',
|
||||
rootBorderShapeClass,
|
||||
hasAnyError ? '-inset-1' : 'inset-0'
|
||||
)
|
||||
"
|
||||
/>
|
||||
<div
|
||||
data-testid="node-inner-wrapper"
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-1 flex-col border border-solid border-transparent bg-node-component-header-surface',
|
||||
'flex flex-1 flex-col bg-node-component-header-surface',
|
||||
'w-(--node-width)',
|
||||
!isRerouteNode && 'min-w-(--min-node-width)',
|
||||
shapeClass,
|
||||
@@ -235,7 +229,7 @@
|
||||
<path
|
||||
d="M11 1L1 11M11 6L6 11"
|
||||
stroke="var(--color-muted-foreground)"
|
||||
stroke-width="0.975"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
@@ -302,6 +296,7 @@ import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { isVideoOutput } from '@/utils/litegraphUtil'
|
||||
import {
|
||||
@@ -336,6 +331,10 @@ const { t } = useI18n()
|
||||
|
||||
const { isSelectMode, isSelectOutputsMode } = useAppMode()
|
||||
const settingStore = useSettingStore()
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
const isLightTheme = computed(
|
||||
() => !!colorPaletteStore.completedActivePalette.light_theme
|
||||
)
|
||||
|
||||
const { handleNodeCollapse, handleNodeTitleUpdate, handleNodeRightClick } =
|
||||
useNodeEventHandlers()
|
||||
@@ -587,9 +586,9 @@ const bodyRoundingClass = computed(() => {
|
||||
case RenderShape.BOX:
|
||||
return ''
|
||||
case RenderShape.CARD:
|
||||
return 'rounded-br-2xl'
|
||||
return 'rounded-br-xl'
|
||||
default:
|
||||
return 'rounded-b-2xl'
|
||||
return 'rounded-b-xl'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -598,9 +597,9 @@ const shapeClass = computed(() => {
|
||||
case RenderShape.BOX:
|
||||
return ''
|
||||
case RenderShape.CARD:
|
||||
return 'rounded-tl-2xl rounded-br-2xl'
|
||||
return 'rounded-tl-xl rounded-br-xl'
|
||||
default:
|
||||
return 'rounded-2xl'
|
||||
return 'rounded-xl'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -611,22 +610,6 @@ const isTransparentHeaderless = computed(
|
||||
isTransparent(nodeData.bgcolor)
|
||||
)
|
||||
|
||||
const rootBorderShapeClass = computed(() => {
|
||||
if (isTransparentHeaderless.value) return 'border-0'
|
||||
|
||||
const isExpanded = hasAnyError.value
|
||||
switch (nodeData.shape) {
|
||||
case RenderShape.BOX:
|
||||
return ''
|
||||
case RenderShape.CARD:
|
||||
return isExpanded
|
||||
? 'rounded-tl-[20px] rounded-br-[20px]'
|
||||
: 'rounded-tl-2xl rounded-br-2xl'
|
||||
default:
|
||||
return isExpanded ? 'rounded-[20px]' : 'rounded-2xl'
|
||||
}
|
||||
})
|
||||
|
||||
const selectionShapeClass = computed(() => {
|
||||
if (isTransparentHeaderless.value) return 'border-0'
|
||||
|
||||
@@ -639,7 +622,7 @@ const selectionShapeClass = computed(() => {
|
||||
? 'rounded-tl-[23px] rounded-br-[23px]'
|
||||
: 'rounded-tl-[19px] rounded-br-[19px]'
|
||||
default:
|
||||
return isExpanded ? 'rounded-[23px]' : 'rounded-[19px]'
|
||||
return isExpanded ? 'rounded-[19px]' : 'rounded-[15px]'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -651,9 +634,9 @@ const bypassOverlayClass = computed(() => {
|
||||
case RenderShape.BOX:
|
||||
return `${BEFORE_OVERLAY_BASE} before:bg-bypass/60`
|
||||
case RenderShape.CARD:
|
||||
return `before:rounded-tl-2xl before:rounded-br-2xl ${BEFORE_OVERLAY_BASE} before:bg-bypass/60`
|
||||
return `before:rounded-tl-xl before:rounded-br-xl ${BEFORE_OVERLAY_BASE} before:bg-bypass/60`
|
||||
default:
|
||||
return `before:rounded-2xl ${BEFORE_OVERLAY_BASE} before:bg-bypass/60`
|
||||
return `before:rounded-xl ${BEFORE_OVERLAY_BASE} before:bg-bypass/60`
|
||||
}
|
||||
})
|
||||
|
||||
@@ -662,9 +645,9 @@ const mutedOverlayClass = computed(() => {
|
||||
case RenderShape.BOX:
|
||||
return BEFORE_OVERLAY_BASE
|
||||
case RenderShape.CARD:
|
||||
return `before:rounded-tl-2xl before:rounded-br-2xl ${BEFORE_OVERLAY_BASE}`
|
||||
return `before:rounded-tl-xl before:rounded-br-xl ${BEFORE_OVERLAY_BASE}`
|
||||
default:
|
||||
return `before:rounded-2xl ${BEFORE_OVERLAY_BASE}`
|
||||
return `before:rounded-xl ${BEFORE_OVERLAY_BASE}`
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -189,18 +189,13 @@ describe('NodeFooter', () => {
|
||||
it('CARD shape emits rounded-br variant on the single-tab footer', () => {
|
||||
renderFooter({ isSubgraph: true, shape: RenderShape.CARD })
|
||||
const classes = allButtonClasses()
|
||||
expect(classes).toMatch(/rounded-br-\[17px\]/)
|
||||
expect(classes).not.toMatch(/rounded-b-\[/)
|
||||
expect(classes).toMatch(/rounded-br-xl/)
|
||||
expect(classes).not.toMatch(/\srounded-b-\w/)
|
||||
})
|
||||
|
||||
it('default shape emits rounded-b variant on the single-tab footer', () => {
|
||||
renderFooter({ isSubgraph: true })
|
||||
expect(allButtonClasses()).toMatch(/rounded-b-\[17px\]/)
|
||||
})
|
||||
|
||||
it('upgrades to 20px radius when the error tab is present', () => {
|
||||
renderFooter({ hasAnyError: true, showErrorsTabEnabled: true })
|
||||
expect(allButtonClasses()).toMatch(/rounded-b-\[20px\]/)
|
||||
expect(allButtonClasses()).toMatch(/rounded-b-xl/)
|
||||
})
|
||||
|
||||
it('enter tab uses right-only rounding in dual-tab mode (Case 1)', () => {
|
||||
@@ -210,7 +205,7 @@ describe('NodeFooter', () => {
|
||||
showErrorsTabEnabled: true
|
||||
})
|
||||
const enterBtn = screen.getByTestId('subgraph-enter-button')
|
||||
expect(enterBtn.className).toMatch(/rounded-br-\[20px\]/)
|
||||
expect(enterBtn.className).toMatch(/rounded-br-xl/)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
:class="
|
||||
cn(
|
||||
tabStyles,
|
||||
'z-10 box-border w-1/2 rounded-none bg-destructive-background pt-9 pb-4 text-white hover:bg-destructive-background-hover',
|
||||
'z-10 box-border w-1/2 rounded-none bg-destructive-background pt-9 pb-3 text-white hover:bg-destructive-background-hover',
|
||||
errorRadiusClass
|
||||
)
|
||||
"
|
||||
@@ -28,7 +28,7 @@
|
||||
:class="
|
||||
cn(
|
||||
tabStyles,
|
||||
'-ml-5 box-border w-[calc(50%+20px)] rounded-none bg-node-component-header-surface pt-9 pb-4 pl-5',
|
||||
'-ml-5 box-border w-[calc(50%+20px)] rounded-none bg-node-component-header-surface pt-9 pb-3 pl-5 text-node-component-slot-text',
|
||||
enterRadiusClass
|
||||
)
|
||||
"
|
||||
@@ -58,7 +58,7 @@
|
||||
:class="
|
||||
cn(
|
||||
tabStyles,
|
||||
'z-10 box-border w-1/2 rounded-none bg-destructive-background pt-9 pb-4 text-white hover:bg-destructive-background-hover',
|
||||
'z-10 box-border w-1/2 rounded-none bg-destructive-background pt-9 pb-3 text-white hover:bg-destructive-background-hover',
|
||||
errorRadiusClass
|
||||
)
|
||||
"
|
||||
@@ -77,7 +77,7 @@
|
||||
:class="
|
||||
cn(
|
||||
tabStyles,
|
||||
'-ml-5 box-border w-[calc(50%+20px)] rounded-none bg-node-component-header-surface pt-9 pb-4 pl-5',
|
||||
'-ml-5 box-border w-[calc(50%+20px)] rounded-none bg-node-component-header-surface pt-9 pb-3 pl-5 text-node-component-slot-text',
|
||||
enterRadiusClass
|
||||
)
|
||||
"
|
||||
@@ -112,7 +112,7 @@
|
||||
:class="
|
||||
cn(
|
||||
tabStyles,
|
||||
'box-border w-full rounded-none bg-destructive-background pt-9 pb-4 text-white hover:bg-destructive-background-hover',
|
||||
'box-border w-full rounded-none bg-destructive-background pt-9 pb-3 text-white hover:bg-destructive-background-hover',
|
||||
footerRadiusClass
|
||||
)
|
||||
"
|
||||
@@ -142,8 +142,8 @@
|
||||
:class="
|
||||
cn(
|
||||
tabStyles,
|
||||
'box-border w-full rounded-none bg-node-component-header-surface',
|
||||
hasAnyError ? 'pt-9 pb-4' : 'pt-8 pb-4',
|
||||
'box-border w-full rounded-none bg-node-component-header-surface text-node-component-slot-text',
|
||||
hasAnyError ? 'pt-9 pb-3' : 'pt-8 pb-3',
|
||||
footerRadiusClass
|
||||
)
|
||||
"
|
||||
@@ -174,8 +174,8 @@
|
||||
:class="
|
||||
cn(
|
||||
tabStyles,
|
||||
'box-border w-full rounded-none bg-node-component-header-surface',
|
||||
hasAnyError ? 'pt-9 pb-4' : 'pt-8 pb-4',
|
||||
'box-border w-full rounded-none bg-node-component-header-surface text-node-component-slot-text',
|
||||
hasAnyError ? 'pt-9 pb-3' : 'pt-8 pb-3',
|
||||
footerRadiusClass
|
||||
)
|
||||
"
|
||||
@@ -254,37 +254,23 @@ function emitIfNotDragged(
|
||||
else emit('toggleAdvanced')
|
||||
}
|
||||
|
||||
const RADIUS_CLASS = {
|
||||
'rounded-b-17': 'rounded-b-[17px]',
|
||||
'rounded-b-20': 'rounded-b-[20px]',
|
||||
'rounded-br-17': 'rounded-br-[17px]',
|
||||
'rounded-br-20': 'rounded-br-[20px]'
|
||||
} as const
|
||||
|
||||
function getBottomRadius(
|
||||
nodeShape: RenderShape | undefined,
|
||||
size: '17px' | '20px',
|
||||
corners: 'both' | 'right' = 'both'
|
||||
): string {
|
||||
if (nodeShape === RenderShape.BOX) return ''
|
||||
const prefix =
|
||||
nodeShape === RenderShape.CARD || corners === 'right'
|
||||
? 'rounded-br'
|
||||
: 'rounded-b'
|
||||
const key =
|
||||
`${prefix}-${size === '17px' ? '17' : '20'}` as keyof typeof RADIUS_CLASS
|
||||
return RADIUS_CLASS[key]
|
||||
return nodeShape === RenderShape.CARD || corners === 'right'
|
||||
? 'rounded-br-xl'
|
||||
: 'rounded-b-xl'
|
||||
}
|
||||
|
||||
const footerRadiusClass = computed(() =>
|
||||
getBottomRadius(shape, hasAnyError ? '20px' : '17px')
|
||||
)
|
||||
const footerRadiusClass = computed(() => getBottomRadius(shape))
|
||||
|
||||
const errorRadiusClass = computed(() => getBottomRadius(shape, '20px'))
|
||||
const errorRadiusClass = computed(() => getBottomRadius(shape))
|
||||
|
||||
const enterRadiusClass = computed(() => getBottomRadius(shape, '20px', 'right'))
|
||||
const enterRadiusClass = computed(() => getBottomRadius(shape, 'right'))
|
||||
|
||||
const tabStyles = 'pointer-events-auto h-9 text-xs'
|
||||
const tabStyles = 'pointer-events-auto h-11 text-xs font-normal'
|
||||
const footerWrapperBase = 'isolate -z-1 -mt-5 box-border flex'
|
||||
const errorWrapperStyles = cn(
|
||||
footerWrapperBase,
|
||||
|
||||
@@ -6,17 +6,17 @@
|
||||
v-else
|
||||
:class="
|
||||
cn(
|
||||
'lg-node-header w-full min-w-0 py-2 pr-3 pl-2 text-sm',
|
||||
'text-node-component-header',
|
||||
'lg-node-header w-full min-w-0 p-1 text-xs',
|
||||
'text-node-component-slot-text',
|
||||
headerShapeClass
|
||||
)
|
||||
"
|
||||
:data-testid="`node-header-${nodeData?.id || ''}`"
|
||||
@dblclick="handleDoubleClick"
|
||||
>
|
||||
<div class="flex min-w-0 items-center justify-between gap-2.5">
|
||||
<div class="flex min-w-0 items-center justify-between gap-1">
|
||||
<!-- Collapse/Expand Button -->
|
||||
<div class="relative mr-auto flex min-w-0 shrink items-center gap-2.5">
|
||||
<div class="relative mr-auto flex min-w-0 shrink items-center gap-1">
|
||||
<div class="flex shrink-0 items-center px-0.5">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
@@ -29,7 +29,7 @@
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
'icon-[lucide--chevron-down] size-5 transition-transform',
|
||||
'icon-[lucide--chevron-down] size-4 transition-transform',
|
||||
collapsed && '-rotate-90'
|
||||
)
|
||||
"
|
||||
@@ -64,7 +64,7 @@
|
||||
<NodeBadge v-if="statusBadge" v-bind="statusBadge" />
|
||||
<i
|
||||
v-if="isPinned"
|
||||
class="icon-[comfy--pin] size-5"
|
||||
class="icon-[comfy--pin] size-4"
|
||||
data-testid="node-pin-indicator"
|
||||
/>
|
||||
</div>
|
||||
@@ -159,18 +159,18 @@ const headerShapeClass = computed(() => {
|
||||
case RenderShape.BOX:
|
||||
return 'rounded-none'
|
||||
case RenderShape.CARD:
|
||||
return 'rounded-tl-2xl rounded-br-2xl rounded-tr-none rounded-bl-none'
|
||||
return 'rounded-tl-xl rounded-br-xl rounded-tr-none rounded-bl-none'
|
||||
default:
|
||||
return 'rounded-2xl'
|
||||
return 'rounded-xl'
|
||||
}
|
||||
}
|
||||
switch (nodeData?.shape) {
|
||||
case RenderShape.BOX:
|
||||
return 'rounded-t-none'
|
||||
case RenderShape.CARD:
|
||||
return 'rounded-tl-2xl rounded-tr-none'
|
||||
return 'rounded-tl-xl rounded-tr-none'
|
||||
default:
|
||||
return 'rounded-t-2xl'
|
||||
return 'rounded-t-xl'
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
data-testid="node-widgets"
|
||||
:class="
|
||||
cn(
|
||||
'lg-node-widgets grid grid-cols-[min-content_minmax(80px,min-content)_minmax(125px,1fr)] gap-y-1 pr-3',
|
||||
'lg-node-widgets grid grid-cols-[min-content_minmax(80px,min-content)_minmax(125px,1fr)] gap-y-1',
|
||||
shouldHandleNodePointerEvents
|
||||
? 'pointer-events-auto'
|
||||
: 'pointer-events-none'
|
||||
@@ -26,7 +26,7 @@
|
||||
<div
|
||||
v-if="widget.visible"
|
||||
data-testid="node-widget"
|
||||
class="lg-node-widget group col-span-full grid grid-cols-subgrid items-stretch"
|
||||
class="lg-node-widget group col-span-full grid grid-cols-subgrid items-stretch pr-3"
|
||||
>
|
||||
<!-- Widget Input Slot Dot -->
|
||||
<div
|
||||
|
||||
@@ -98,9 +98,9 @@ const shouldDim = computed(() => {
|
||||
|
||||
const slotWrapperClass = computed(() =>
|
||||
cn(
|
||||
'lg-slot lg-slot--output group flex h-6 items-center justify-end rounded-l-lg',
|
||||
'lg-slot lg-slot--output group flex h-5 items-center justify-end rounded-l-lg',
|
||||
'cursor-crosshair',
|
||||
dotOnly.value ? 'lg-slot--dot-only justify-center' : 'pl-6',
|
||||
dotOnly.value ? 'lg-slot--dot-only justify-center' : 'pl-2',
|
||||
{
|
||||
'lg-slot--connected': props.connected,
|
||||
'lg-slot--compatible': props.compatible,
|
||||
|
||||
@@ -51,8 +51,8 @@ const slotClass = computed(() =>
|
||||
'transition-all duration-150',
|
||||
'border border-solid border-node-component-slot-dot-outline',
|
||||
props.multi
|
||||
? 'h-6 w-3'
|
||||
: 'size-3 cursor-crosshair group-hover/slot:scale-125 group-hover/slot:[--node-component-slot-dot-outline-opacity-mult:5]'
|
||||
? 'h-5 w-2'
|
||||
: 'size-2 cursor-crosshair group-hover/slot:scale-125 group-hover/slot:[--node-component-slot-dot-outline-opacity-mult:5]'
|
||||
)
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
|
||||
import { evaluateInput } from '@/lib/litegraph/src/utils/widget'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import { useWidgetHeight } from '@/types/widgetTypes'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import {
|
||||
INPUT_EXCLUDED_PROPS,
|
||||
@@ -164,11 +165,17 @@ const inputAriaAttrs = computed(() => ({
|
||||
:hide-buttons="buttonsDisabled"
|
||||
:parse-value="parseWidgetValue"
|
||||
:input-attrs="inputAriaAttrs"
|
||||
:class="cn(WidgetInputBaseClass, 'relative flex h-7 grow text-xs')"
|
||||
:class="
|
||||
cn(
|
||||
WidgetInputBaseClass,
|
||||
'relative flex grow text-xs',
|
||||
useWidgetHeight()
|
||||
)
|
||||
"
|
||||
>
|
||||
<template #background>
|
||||
<div
|
||||
class="pointer-events-none absolute size-full overflow-clip rounded-lg"
|
||||
class="pointer-events-none absolute size-full overflow-clip rounded-md"
|
||||
>
|
||||
<div
|
||||
class="size-full bg-primary-background/15"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="widget-markdown relative w-full" @dblclick="startEditing">
|
||||
<div
|
||||
class="comfy-markdown-content size-full min-h-[60px] overflow-y-auto rounded-lg text-sm"
|
||||
class="comfy-markdown-content size-full min-h-[60px] overflow-y-auto rounded-lg"
|
||||
:class="isEditing ? 'invisible' : 'visible'"
|
||||
tabindex="0"
|
||||
data-capture-wheel="true"
|
||||
@@ -16,7 +16,7 @@
|
||||
ref="textareaRef"
|
||||
v-model="modelValue"
|
||||
:aria-label="`${$t('g.edit')} ${widget.name || $t('g.markdown')} ${$t('g.content')}`"
|
||||
class="absolute inset-0 min-h-[60px] w-full resize-none text-sm"
|
||||
class="absolute inset-0 min-h-[60px] w-full resize-none text-(length:--comfy-textarea-font-size)"
|
||||
data-capture-wheel="true"
|
||||
@blur="handleBlur"
|
||||
@pointerdown.capture.stop
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<WidgetLayoutField :widget>
|
||||
<ComboboxRoot
|
||||
v-model:open="isOpen"
|
||||
:open="isOpen"
|
||||
:model-value="comboboxValue"
|
||||
:disabled
|
||||
ignore-filter
|
||||
@@ -11,53 +11,61 @@
|
||||
@update:open="handleOpenChange"
|
||||
>
|
||||
<ComboboxAnchor as-child>
|
||||
<ComboboxTrigger as-child>
|
||||
<div
|
||||
data-capture-wheel="true"
|
||||
:class="
|
||||
cn(
|
||||
WidgetInputBaseClass,
|
||||
'flex w-full min-w-0 items-center overflow-hidden',
|
||||
useWidgetHeight(),
|
||||
!disabled && 'hover:bg-component-node-widget-background-hovered',
|
||||
disabled && 'opacity-50',
|
||||
isInvalid && 'ring-1 ring-destructive-background'
|
||||
)
|
||||
"
|
||||
>
|
||||
<ComboboxTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
role="combobox"
|
||||
aria-haspopup="listbox"
|
||||
:aria-label="widget.label || widget.name"
|
||||
:aria-invalid="isInvalid || undefined"
|
||||
:aria-expanded="isOpen"
|
||||
:disabled
|
||||
tabindex="0"
|
||||
data-testid="widget-select-default-trigger"
|
||||
class="flex min-w-0 flex-1 cursor-pointer items-center overflow-hidden border-none bg-transparent p-0 outline-none disabled:cursor-default"
|
||||
>
|
||||
<span
|
||||
class="min-w-[4ch] flex-1 truncate pr-1 pl-2 text-left text-xs"
|
||||
>
|
||||
{{ selectedLabel || placeholder || '\u00a0' }}
|
||||
</span>
|
||||
</button>
|
||||
</ComboboxTrigger>
|
||||
<slot />
|
||||
<button
|
||||
type="button"
|
||||
role="combobox"
|
||||
aria-haspopup="listbox"
|
||||
:aria-label="widget.label || widget.name"
|
||||
:aria-invalid="isInvalid || undefined"
|
||||
:aria-expanded="isOpen"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
:disabled
|
||||
tabindex="0"
|
||||
data-capture-wheel="true"
|
||||
data-testid="widget-select-default-trigger"
|
||||
:class="
|
||||
cn(
|
||||
WidgetInputBaseClass,
|
||||
'flex h-7 w-full min-w-0 cursor-pointer items-center overflow-hidden outline-none hover:bg-component-node-widget-background-hovered disabled:cursor-default disabled:opacity-50 disabled:hover:bg-component-node-widget-background',
|
||||
isInvalid && 'ring-1 ring-destructive-background'
|
||||
)
|
||||
"
|
||||
class="flex h-full w-6 shrink-0 cursor-pointer items-center justify-center border-none bg-transparent outline-none disabled:cursor-default"
|
||||
@click="handleOpenChange(true)"
|
||||
>
|
||||
<span
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
'min-w-[4ch] flex-1 truncate pr-3 pl-1 text-left',
|
||||
$slots.default && 'mr-5'
|
||||
'icon-[lucide--chevron-down] size-4',
|
||||
disabled
|
||||
? 'bg-component-node-foreground-secondary'
|
||||
: 'bg-muted-foreground'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ selectedLabel || placeholder || '\u00a0' }}
|
||||
</span>
|
||||
<span
|
||||
class="flex h-full w-8 shrink-0 items-center justify-center rounded-r-lg"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
'icon-[lucide--chevron-down] size-4 translate-x-1.5',
|
||||
disabled
|
||||
? 'bg-component-node-foreground-secondary'
|
||||
: 'bg-muted-foreground'
|
||||
)
|
||||
"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</ComboboxTrigger>
|
||||
</div>
|
||||
</ComboboxAnchor>
|
||||
|
||||
<ComboboxPortal>
|
||||
@@ -140,10 +148,6 @@
|
||||
</ComboboxContent>
|
||||
</ComboboxPortal>
|
||||
</ComboboxRoot>
|
||||
|
||||
<div class="absolute top-5 right-8 flex h-4 w-7 -translate-y-4/5">
|
||||
<slot />
|
||||
</div>
|
||||
</WidgetLayoutField>
|
||||
</template>
|
||||
|
||||
@@ -163,6 +167,7 @@ import type { CSSProperties } from 'vue'
|
||||
|
||||
import { useRestoreFocusOnViewportPointer } from '@/renderer/extensions/vueNodes/widgets/composables/useRestoreFocusOnViewportPointer'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import { useWidgetHeight } from '@/types/widgetTypes'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { WidgetInputBaseClass } from './layout'
|
||||
|
||||