mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-17 17:28:58 +00:00
Compare commits
5 Commits
feat/onboa
...
feat/dashb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3d842cc32 | ||
|
|
d129ab23e1 | ||
|
|
4d774bfa99 | ||
|
|
146a88480c | ||
|
|
cd57484695 |
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json",
|
||||
"entry": ["src/main.ts", "index.html"],
|
||||
"duplicates": {
|
||||
"minOccurrences": 3,
|
||||
"ignore": ["**/*.generated.*", "**/generatedManagerTypes.ts"]
|
||||
},
|
||||
"rules": {}
|
||||
}
|
||||
3
.gitattributes
vendored
3
.gitattributes
vendored
@@ -6,6 +6,3 @@ packages/registry-types/src/comfyRegistryTypes.ts linguist-generated=true
|
||||
packages/ingest-types/src/types.gen.ts linguist-generated=true
|
||||
packages/ingest-types/src/zod.gen.ts linguist-generated=true
|
||||
src/workbench/extensions/manager/types/generatedManagerTypes.ts linguist-generated=true
|
||||
|
||||
# Vendored upstream workflow templates used as resolver test fixtures
|
||||
src/renderer/extensions/firstRunTour/__fixtures__/templates/*.json linguist-generated=true
|
||||
|
||||
70
.github/workflows/ci-dashboard-deploy.yaml
vendored
Normal file
70
.github/workflows/ci-dashboard-deploy.yaml
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
# Triggered by workflow_run (not push) so this only runs after 'CI: Tests E2E' has
|
||||
# finished merging its sharded blob reports into an HTML report on main — the
|
||||
# 'playwright-report-chromium' artifact this job downloads doesn't exist until that
|
||||
# merge-reports job completes.
|
||||
name: 'CI: Dashboard Deploy'
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['CI: Tests E2E']
|
||||
types:
|
||||
- completed
|
||||
|
||||
concurrency:
|
||||
group: dashboard-deploy-${{ github.event.workflow_run.head_branch }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: >
|
||||
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
github.event.workflow_run.head_branch == 'main' &&
|
||||
(github.event.workflow_run.conclusion == 'success' || github.event.workflow_run.conclusion == 'failure')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
pages: write
|
||||
id-token: write
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Download merged Playwright report
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
with:
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
name: playwright-report-chromium
|
||||
path: dashboard-site/playwright-report
|
||||
if_no_artifact_found: warn
|
||||
|
||||
- name: Check Playwright report was found
|
||||
id: report
|
||||
run: |
|
||||
if [ -f dashboard-site/playwright-report/index.html ]; then
|
||||
echo "found=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "found=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No merged Playwright report artifact found for this run; skipping the dashboard deploy so the existing Pages deployment isn't wiped out." >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Assemble dashboard index
|
||||
if: steps.report.outputs.found == 'true'
|
||||
run: cp scripts/cicd/dashboard/index.html dashboard-site/index.html
|
||||
|
||||
- name: Upload to GitHub Pages
|
||||
if: steps.report.outputs.found == 'true'
|
||||
uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3.0.1
|
||||
with:
|
||||
path: dashboard-site
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
if: steps.report.outputs.found == 'true'
|
||||
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -16,7 +16,6 @@ yarn.lock
|
||||
.eslintcache
|
||||
.prettiercache
|
||||
.stylelintcache
|
||||
.fallow/
|
||||
|
||||
node_modules
|
||||
.pnpm-store
|
||||
|
||||
@@ -88,11 +88,6 @@ const config: StorybookConfig = {
|
||||
replacement:
|
||||
process.cwd() + '/src/storybook/mocks/useFeatureFlags.ts'
|
||||
},
|
||||
{
|
||||
find: '@/platform/workspace/composables/useWorkspaceUI',
|
||||
replacement:
|
||||
process.cwd() + '/src/storybook/mocks/useWorkspaceUI.ts'
|
||||
},
|
||||
{
|
||||
find: '@/platform/workspace/stores/teamWorkspaceStore',
|
||||
replacement:
|
||||
|
||||
@@ -14,8 +14,7 @@ const SITEMAP_EXCLUDED_PATHNAMES = new Set([
|
||||
...LOCALE_PREFIXES.flatMap((prefix) =>
|
||||
PAYMENT_STATUSES.map((status) => `${prefix}/payment/${status}`)
|
||||
),
|
||||
...LOCALE_PREFIXES.map((prefix) => `${prefix}/individual-submission`),
|
||||
...LOCALE_PREFIXES.map((prefix) => `${prefix}/booking-confirmation`)
|
||||
'/individual-submission'
|
||||
])
|
||||
|
||||
function isExcludedFromSitemap(page: string): boolean {
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
const { resources } = defineProps<{
|
||||
resources: { label: string; href: string; display: string }[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul class="flex flex-col gap-3 text-base font-light lg:text-lg">
|
||||
<li v-for="resource in resources" :key="resource.href">
|
||||
<span class="text-primary-comfy-canvas">{{ resource.label }}</span>
|
||||
<span class="text-primary-warm-gray"> — </span>
|
||||
<a
|
||||
:href="resource.href"
|
||||
class="text-primary-comfy-yellow underline underline-offset-4 hover:no-underline"
|
||||
>
|
||||
{{ resource.display }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
@@ -1,54 +0,0 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import ResourceList from '../components/booking-confirmation/ResourceList.vue'
|
||||
import HeroSection from '../components/legal/HeroSection.vue'
|
||||
import { externalLinks, getRoutes } from '../config/routes'
|
||||
|
||||
const routes = getRoutes('en')
|
||||
|
||||
const resources = [
|
||||
{
|
||||
label: 'Learning Center',
|
||||
href: routes.learning,
|
||||
display: 'comfy.org/learning'
|
||||
},
|
||||
{
|
||||
label: 'Workflow templates',
|
||||
href: externalLinks.workflows,
|
||||
display: 'comfy.org/workflows'
|
||||
},
|
||||
{
|
||||
label: 'Customer stories',
|
||||
href: routes.customers,
|
||||
display: 'comfy.org/customers'
|
||||
},
|
||||
{
|
||||
label: 'Docs',
|
||||
href: externalLinks.docs,
|
||||
display: 'docs.comfy.org'
|
||||
}
|
||||
]
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="You're booked - Comfy"
|
||||
description="Your meeting is booked. Check your email for the calendar invite and meeting link."
|
||||
noindex
|
||||
>
|
||||
<HeroSection title="You're booked." class="text-center" />
|
||||
|
||||
<section class="-mt-8 px-6 pb-24 lg:-mt-12 lg:pb-40">
|
||||
<div
|
||||
class="text-primary-comfy-canvas mx-auto flex max-w-2xl flex-col gap-10 text-center text-base font-light lg:text-lg"
|
||||
>
|
||||
<p>Check your email for the calendar invite and meeting link!</p>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<h2 class="text-primary-comfy-yellow text-xl font-semibold italic lg:text-2xl">
|
||||
Resources while you wait
|
||||
</h2>
|
||||
<ResourceList resources={resources} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
@@ -1,54 +0,0 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import ResourceList from '../../components/booking-confirmation/ResourceList.vue'
|
||||
import HeroSection from '../../components/legal/HeroSection.vue'
|
||||
import { externalLinks, getRoutes } from '../../config/routes'
|
||||
|
||||
const routes = getRoutes('zh-CN')
|
||||
|
||||
const resources = [
|
||||
{
|
||||
label: '学习中心',
|
||||
href: routes.learning,
|
||||
display: 'comfy.org/learning'
|
||||
},
|
||||
{
|
||||
label: '工作流模板',
|
||||
href: externalLinks.workflows,
|
||||
display: 'comfy.org/workflows'
|
||||
},
|
||||
{
|
||||
label: '客户案例',
|
||||
href: routes.customers,
|
||||
display: 'comfy.org/customers'
|
||||
},
|
||||
{
|
||||
label: '文档',
|
||||
href: externalLinks.docs,
|
||||
display: 'docs.comfy.org'
|
||||
}
|
||||
]
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="预约成功 - Comfy"
|
||||
description="您的会议已预约成功。请查收邮件中的日历邀请和会议链接。"
|
||||
noindex
|
||||
>
|
||||
<HeroSection title="预约成功。" class="text-center" />
|
||||
|
||||
<section class="-mt-8 px-6 pb-24 lg:-mt-12 lg:pb-40">
|
||||
<div
|
||||
class="text-primary-comfy-canvas mx-auto flex max-w-2xl flex-col gap-10 text-center text-base font-light lg:text-lg"
|
||||
>
|
||||
<p>请查收邮件中的日历邀请和会议链接!</p>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<h2 class="text-primary-comfy-yellow text-xl font-semibold italic lg:text-2xl">
|
||||
等待期间的资源
|
||||
</h2>
|
||||
<ResourceList resources={resources} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
@@ -54,11 +54,6 @@
|
||||
"source": "/press",
|
||||
"destination": "/about",
|
||||
"permanent": true
|
||||
},
|
||||
{
|
||||
"source": "/login",
|
||||
"destination": "https://cloud.comfy.org/login",
|
||||
"permanent": false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { config as dotenvConfig } from 'dotenv'
|
||||
import MCR from 'monocart-coverage-reports'
|
||||
|
||||
import { COVERAGE_OUTPUT_DIR } from '@e2e/coverageConfig'
|
||||
import { TOURS, TOUR_SEEN_SETTING } from '@/platform/onboarding/onboardingTours'
|
||||
import { NodeBadgeMode } from '@/types/nodeSource'
|
||||
import { ComfyActionbar } from '@e2e/fixtures/components/Actionbar'
|
||||
import { ComfyTemplates } from '@e2e/fixtures/components/Templates'
|
||||
@@ -543,8 +542,6 @@ export const comfyPageFixture = base.extend<{
|
||||
'Comfy.userId': userId,
|
||||
// Set tutorial completed to true to avoid loading the tutorial workflow.
|
||||
'Comfy.TutorialCompleted': true,
|
||||
// An auto-opened tour's blocker would break unrelated tests.
|
||||
[TOUR_SEEN_SETTING]: Object.keys(TOURS),
|
||||
'Comfy.Queue.MaxHistoryItems': 64,
|
||||
'Comfy.SnapToGrid.GridSize': testComfySnapToGridGridSize,
|
||||
// Disable toast warning about version compatibility, as they may or
|
||||
|
||||
@@ -8,13 +8,11 @@ export class ComfyActionbar {
|
||||
public readonly root: Locator
|
||||
public readonly queueButton: ComfyQueueButton
|
||||
public readonly propertiesButton: Locator
|
||||
public readonly dragHandle: Locator
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.root = page.locator('.actionbar-container')
|
||||
this.queueButton = new ComfyQueueButton(this)
|
||||
this.propertiesButton = this.root.getByLabel('Toggle properties panel')
|
||||
this.dragHandle = this.root.locator('.drag-handle')
|
||||
}
|
||||
|
||||
async isDocked() {
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
export class FreeTierQuota {
|
||||
readonly root: Locator
|
||||
|
||||
constructor(comfyPage: ComfyPage) {
|
||||
this.root = comfyPage.page.getByTestId(TestIds.topbar.freeTierQuota)
|
||||
}
|
||||
|
||||
async getMax() {
|
||||
const text = await this.root.textContent()
|
||||
return text?.match(/(\d+) \/ (\d+)/)?.[2]
|
||||
}
|
||||
async getAvailable() {
|
||||
const text = await this.root.textContent()
|
||||
return text?.match(/(\d+) \/ (\d+)/)?.[1]
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { TOUR_SEEN_SETTING } from '@/platform/onboarding/onboardingTours'
|
||||
|
||||
export type CoachTour = 'appMode'
|
||||
|
||||
/** Accessible name of each tour's in-app replay (help) button. */
|
||||
const TOUR_REPLAY_BUTTONS: Record<CoachTour, string> = {
|
||||
appMode: 'Take a tour of App Mode'
|
||||
}
|
||||
|
||||
/** Coach-mark overlay (src/platform/onboarding/TourOverlay.vue). */
|
||||
export class OnboardingCoachmarks {
|
||||
public readonly landing: Locator
|
||||
public readonly landingStartButton: Locator
|
||||
public readonly landingSkipButton: Locator
|
||||
/** The current spotlight step card (the dialog carrying a "Step N of M" label). */
|
||||
public readonly card: Locator
|
||||
public readonly cardNextButton: Locator
|
||||
public readonly cardDoneButton: Locator
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.landing = page.getByTestId('coach-landing')
|
||||
this.landingStartButton = this.landing.getByRole('button', {
|
||||
name: 'Start tutorial'
|
||||
})
|
||||
this.landingSkipButton = this.landing.getByRole('button', {
|
||||
name: 'Skip',
|
||||
exact: true
|
||||
})
|
||||
this.card = page.getByRole('dialog').filter({ hasText: /Step \d+ of \d+/ })
|
||||
this.cardNextButton = this.card.getByRole('button', { name: 'Next' })
|
||||
this.cardDoneButton = this.card.getByRole('button', { name: 'Done' })
|
||||
}
|
||||
|
||||
/** The tour's in-app help button, which replays it past the seen-flag. */
|
||||
replayButton(tour: CoachTour): Locator {
|
||||
return this.page.getByRole('button', { name: TOUR_REPLAY_BUTTONS[tour] })
|
||||
}
|
||||
|
||||
/** The spotlight card while it is showing the given step number. */
|
||||
cardForStep(step: number): Locator {
|
||||
return this.card.filter({ hasText: new RegExp(`Step ${step} of `) })
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the pre-seeded seen-flag (so dismissal assertions observe it being
|
||||
* set again) and clicks the tour's replay button, which must be mounted.
|
||||
*/
|
||||
async startTour(tour: CoachTour) {
|
||||
await this.clearSeen()
|
||||
await this.replayButton(tour).click()
|
||||
}
|
||||
|
||||
private async clearSeen() {
|
||||
await this.page.evaluate(
|
||||
async (key) => window.app!.extensionManager.setting.set(key, []),
|
||||
TOUR_SEEN_SETTING
|
||||
)
|
||||
}
|
||||
|
||||
/** An element a tour points at, by its `data-coach-id` anchor. */
|
||||
coachAnchor(id: string): Locator {
|
||||
return this.page.locator(`[data-coach-id="${id}"]`)
|
||||
}
|
||||
|
||||
async seen(tour: CoachTour): Promise<boolean> {
|
||||
const seen = await this.page.evaluate(
|
||||
async (key) =>
|
||||
(await window.app!.extensionManager.setting.get(key)) as
|
||||
| string[]
|
||||
| undefined,
|
||||
TOUR_SEEN_SETTING
|
||||
)
|
||||
return !!seen?.includes(tour)
|
||||
}
|
||||
}
|
||||
@@ -103,8 +103,7 @@ export const TestIds = {
|
||||
loginButtonPopoverLearnMore: 'login-button-popover-learn-more',
|
||||
workflowTabs: 'topbar-workflow-tabs',
|
||||
integratedTabBarActions: 'integrated-tab-bar-actions',
|
||||
actionBarButtons: 'action-bar-buttons',
|
||||
freeTierQuota: 'free-tier-quota'
|
||||
actionBarButtons: 'action-bar-buttons'
|
||||
},
|
||||
nodeLibrary: {
|
||||
bookmarksSection: 'node-library-bookmarks-section'
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { test as base } from '@playwright/test'
|
||||
|
||||
import { OnboardingCoachmarks } from '@e2e/fixtures/components/Tour'
|
||||
|
||||
export const onboardingFixture = base.extend<{
|
||||
onboarding: OnboardingCoachmarks
|
||||
}>({
|
||||
onboarding: async ({ page }, use) => {
|
||||
await use(new OnboardingCoachmarks(page))
|
||||
}
|
||||
})
|
||||
@@ -28,11 +28,11 @@ const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
// matches it against the members self-row.
|
||||
const SELF_EMAIL = 'e2e@test.comfy.org'
|
||||
|
||||
// billing_control_enabled routes personal workspaces to the unified pricing
|
||||
// table asserted here; without it they fall back to the legacy table.
|
||||
// consolidated_billing_enabled routes personal workspaces to the unified
|
||||
// pricing table asserted here; without it they fall back to the legacy table.
|
||||
const BOOT_FEATURES = {
|
||||
team_workspaces_enabled: true,
|
||||
billing_control_enabled: true
|
||||
consolidated_billing_enabled: true
|
||||
} satisfies RemoteConfig
|
||||
// Disable the experimental Asset API: with it on (cloud default) the unmocked
|
||||
// asset endpoints 403 and workflow restore throws uncaught, aborting the
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
|
||||
import { FreeTierQuota } from '@e2e/fixtures/components/FreeTierQuota'
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
|
||||
const wstest = mergeTests(test, webSocketFixture)
|
||||
|
||||
test.describe('Free Tier Quota', { tag: ['@cloud', '@vue-nodes'] }, () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const features = {
|
||||
free_tier_job_allowance_enabled: true,
|
||||
free_tier_balance: { allowance: 5, remaining: 3, used: 0 }
|
||||
}
|
||||
await page.route('**/api/features', (r) => r.fulfill(jsonRoute(features)))
|
||||
})
|
||||
|
||||
wstest('Free Tier Quota', async ({ comfyPage, comfyMouse, getWebSocket }) => {
|
||||
const execution = new ExecutionHelper(comfyPage, await getWebSocket())
|
||||
const freeTierQuota = new FreeTierQuota(comfyPage)
|
||||
|
||||
await test.step('Populates initial state from config', async () => {
|
||||
await expect.poll(() => freeTierQuota.getAvailable()).toBe('3')
|
||||
expect(await freeTierQuota.getMax()).toBe('5')
|
||||
})
|
||||
|
||||
await test.step('available decrements on run', async () => {
|
||||
await execution.run()
|
||||
await expect.poll(() => freeTierQuota.getAvailable()).toBe('2')
|
||||
})
|
||||
|
||||
await test.step('connects to detached run button', async () => {
|
||||
const handle = comfyPage.actionbar.dragHandle
|
||||
await comfyMouse.dragElementBy(handle, { x: -100, y: 100 })
|
||||
await expect.poll(() => comfyPage.actionbar.isDocked()).toBe(false)
|
||||
expect(await freeTierQuota.getAvailable()).toBe('2')
|
||||
await comfyMouse.dragElementBy(handle, { x: 100, y: -100 })
|
||||
await expect.poll(() => comfyPage.actionbar.isDocked()).toBe(true)
|
||||
})
|
||||
|
||||
await test.step('Detects workflows with Partner nodes', async () => {
|
||||
await comfyPage.searchBoxV2.addNode('Node With Price Badge')
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle('Price Badge')
|
||||
await expect.poll(() => freeTierQuota.getAvailable()).toBe(undefined)
|
||||
await node.delete()
|
||||
await expect.poll(() => freeTierQuota.getAvailable()).toBe('2')
|
||||
})
|
||||
|
||||
await test.step('Does not decrease past 0', async () => {
|
||||
await execution.run()
|
||||
await expect.poll(() => freeTierQuota.getAvailable()).toBe('1')
|
||||
await execution.run()
|
||||
await expect.poll(() => freeTierQuota.getAvailable()).toBe(undefined)
|
||||
await execution.run()
|
||||
await execution.run()
|
||||
await execution.run()
|
||||
await comfyPage.nextFrame()
|
||||
expect(await freeTierQuota.getAvailable()).toBe(undefined)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,135 +0,0 @@
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { onboardingFixture } from '@e2e/fixtures/tourFixture'
|
||||
|
||||
import {
|
||||
COACH_IDS,
|
||||
TOUR_SEEN_SETTING
|
||||
} from '@/platform/onboarding/onboardingTours'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, onboardingFixture)
|
||||
|
||||
// Relies on the test server's default workflow (locally: pnpm dev:test).
|
||||
test.describe('Onboarding coachmarks', { tag: '@ui' }, () => {
|
||||
test.describe('app-mode tour', () => {
|
||||
// With no tour pre-seeded as seen, entering the app auto-opens it.
|
||||
test.use({
|
||||
initialSettings: { [TOUR_SEEN_SETTING]: [] }
|
||||
})
|
||||
|
||||
test('auto-opens on the welcome landing, focuses Start, and Skip dismisses it', async ({
|
||||
comfyPage,
|
||||
onboarding
|
||||
}) => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
const coach = onboarding
|
||||
|
||||
await expect(coach.landing).toBeVisible()
|
||||
await expect(coach.landing.getByRole('heading')).toHaveText(
|
||||
'Welcome to Apps'
|
||||
)
|
||||
await expect(coach.landingStartButton).toBeFocused()
|
||||
|
||||
await coach.landingSkipButton.click()
|
||||
await expect(coach.landing).toBeHidden()
|
||||
await expect.poll(() => coach.seen('appMode')).toBe(true)
|
||||
})
|
||||
|
||||
test('Escape dismisses the welcome landing and marks it seen', async ({
|
||||
comfyPage,
|
||||
onboarding
|
||||
}) => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
const coach = onboarding
|
||||
await expect(coach.landing).toBeVisible()
|
||||
await expect(coach.landingStartButton).toBeFocused()
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(coach.landing).toBeHidden()
|
||||
await expect.poll(() => coach.seen('appMode')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('coach anchors', () => {
|
||||
test('every registry id resolves to an element (drift guard)', async ({
|
||||
comfyPage,
|
||||
onboarding
|
||||
}) => {
|
||||
const coach = onboarding
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
// The assets panel only mounts once the assets sidebar tab is open.
|
||||
for (const id of Object.values(COACH_IDS).filter(
|
||||
(id) => id !== COACH_IDS.assetsPanel
|
||||
)) {
|
||||
await expect(coach.coachAnchor(id)).toBeVisible()
|
||||
}
|
||||
await comfyPage.menu.assetsTab.tabButton.click()
|
||||
await expect(coach.coachAnchor(COACH_IDS.assetsPanel)).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('spotlight focus', () => {
|
||||
test('focuses the primary action, traps Tab in the card, and re-focuses per step', async ({
|
||||
comfyPage,
|
||||
onboarding
|
||||
}) => {
|
||||
const coach = onboarding
|
||||
await comfyPage.page.emulateMedia({ reducedMotion: 'reduce' })
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
|
||||
await coach.startTour('appMode')
|
||||
await expect(coach.landing).toBeVisible()
|
||||
await coach.landingStartButton.click()
|
||||
|
||||
const step1 = coach.cardForStep(1)
|
||||
await expect(step1).toBeVisible()
|
||||
// FocusScope's mount-auto-focus is suppressed so focus lands on the
|
||||
// primary action rather than the first focusable (Skip).
|
||||
await expect(coach.cardNextButton).toBeFocused()
|
||||
|
||||
// Tab more times than the card has controls; the looped trap must keep
|
||||
// focus inside the card, never escaping to the app behind the overlay.
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await comfyPage.page.keyboard.press('Tab')
|
||||
await expect(step1.locator(':focus')).toBeVisible()
|
||||
}
|
||||
await comfyPage.page.keyboard.press('Shift+Tab')
|
||||
await expect(step1.locator(':focus')).toBeVisible()
|
||||
|
||||
await coach.cardNextButton.click()
|
||||
await expect(coach.cardForStep(2)).toBeVisible()
|
||||
await expect(coach.cardNextButton).toBeFocused()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('spotlight placement', () => {
|
||||
test('every spotlight card stays fully within the viewport and Done completes the tour', async ({
|
||||
comfyPage,
|
||||
onboarding
|
||||
}) => {
|
||||
const coach = onboarding
|
||||
// Read settled placements, not a transient mid-animation frame.
|
||||
await comfyPage.page.emulateMedia({ reducedMotion: 'reduce' })
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
|
||||
await coach.startTour('appMode')
|
||||
await expect(coach.landing).toBeVisible()
|
||||
await coach.landingStartButton.click()
|
||||
|
||||
for (const step of [1, 2, 3]) {
|
||||
const card = coach.cardForStep(step)
|
||||
await expect(card).toBeVisible()
|
||||
await expect(card).toBeInViewport({ ratio: 1 })
|
||||
await coach.cardNextButton.click()
|
||||
}
|
||||
|
||||
// The final assets step auto-opens the assets panel — no target click.
|
||||
await expect(coach.cardForStep(4)).toBeInViewport({ ratio: 1 })
|
||||
|
||||
await coach.cardDoneButton.click()
|
||||
await expect(coach.card).toBeHidden()
|
||||
await expect.poll(() => coach.seen('appMode')).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -29,8 +29,6 @@
|
||||
"dev:test": "cross-env VITE_USE_LEGACY_DEFAULT_GRAPH=true vite --config vite.config.mts",
|
||||
"dev": "vite --config vite.config.mts",
|
||||
"devtools:pycheck": "python3 -m compileall -q tools/devtools",
|
||||
"fallow": "fallow",
|
||||
"fallow:audit": "fallow audit",
|
||||
"format:check": "oxfmt --check",
|
||||
"format": "oxfmt --write",
|
||||
"json-schema": "tsx scripts/generate-json-schema.ts",
|
||||
@@ -74,7 +72,6 @@
|
||||
"@comfyorg/shared-frontend-utils": "workspace:*",
|
||||
"@comfyorg/tailwind-utils": "workspace:*",
|
||||
"@customerio/cdp-analytics-browser": "catalog:",
|
||||
"@floating-ui/vue": "catalog:",
|
||||
"@formkit/auto-animate": "catalog:",
|
||||
"@iconify/json": "catalog:",
|
||||
"@primeuix/forms": "catalog:",
|
||||
@@ -175,7 +172,6 @@
|
||||
"eslint-plugin-testing-library": "catalog:",
|
||||
"eslint-plugin-unused-imports": "catalog:",
|
||||
"eslint-plugin-vue": "catalog:",
|
||||
"fallow": "catalog:",
|
||||
"fast-check": "catalog:",
|
||||
"fs-extra": "^11.2.0",
|
||||
"globals": "catalog:",
|
||||
|
||||
@@ -102,10 +102,6 @@
|
||||
--color-alpha-magenta-700-60: #6a246a99;
|
||||
--color-alpha-magenta-300-60: #ceaac999;
|
||||
|
||||
/* Onboarding coachmark overlay (always renders over a dark scrim) */
|
||||
--color-coach-scrim: rgb(0 0 0 / 0.6);
|
||||
--color-coach-ring: #fff;
|
||||
|
||||
/* PrimeVue pulled colors */
|
||||
--color-muted: var(--p-text-muted-color);
|
||||
--color-highlight: var(--p-primary-color);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
appendWorkflowJsonExt,
|
||||
ensureWorkflowSuffix,
|
||||
escapeVueI18nMessageSyntax,
|
||||
formatLocalizedMediumDate,
|
||||
formatLocalizedNumber,
|
||||
getFilePathSeparatorVariants,
|
||||
@@ -477,4 +478,49 @@ describe('formatUtil', () => {
|
||||
expect(formatLocalizedMediumDate('not a date', 'en')).toBe('—')
|
||||
})
|
||||
})
|
||||
|
||||
describe('escapeVueI18nMessageSyntax', () => {
|
||||
it('escapes a literal @ that would break linked-message compilation', () => {
|
||||
expect(
|
||||
escapeVueI18nMessageSyntax('clips (tagged @Audio1-3 in the prompt)')
|
||||
).toBe("clips (tagged {'@'}Audio1-3 in the prompt)")
|
||||
})
|
||||
|
||||
it('escapes @ in an email address', () => {
|
||||
expect(escapeVueI18nMessageSyntax('support@comfy.org')).toBe(
|
||||
"support{'@'}comfy.org"
|
||||
)
|
||||
})
|
||||
|
||||
it('escapes interpolation braces', () => {
|
||||
expect(escapeVueI18nMessageSyntax('size {w}x{h}')).toBe(
|
||||
"size {'{'}w{'}'}x{'{'}h{'}'}"
|
||||
)
|
||||
})
|
||||
|
||||
it('escapes the plural pipe separator', () => {
|
||||
expect(escapeVueI18nMessageSyntax('foreground | background')).toBe(
|
||||
"foreground {'|'} background"
|
||||
)
|
||||
})
|
||||
|
||||
it('escapes the modulo percent so it cannot re-form %{', () => {
|
||||
expect(escapeVueI18nMessageSyntax('50%{done}')).toBe(
|
||||
"50{'%'}{'{'}done{'}'}"
|
||||
)
|
||||
})
|
||||
|
||||
it('escapes every occurrence in a single pass', () => {
|
||||
expect(escapeVueI18nMessageSyntax('@a @b @c')).toBe(
|
||||
"{'@'}a {'@'}b {'@'}c"
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves strings without syntax characters unchanged', () => {
|
||||
expect(escapeVueI18nMessageSyntax('no special chars here')).toBe(
|
||||
'no special chars here'
|
||||
)
|
||||
expect(escapeVueI18nMessageSyntax('')).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -178,6 +178,40 @@ export function normalizeI18nKey(key: string) {
|
||||
return typeof key === 'string' ? key.replace(/\./g, '_') : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Characters that vue-i18n's message compiler treats as syntax in message text,
|
||||
* so plain text has to escape them to render verbatim through `t()`/`st()`:
|
||||
*
|
||||
* - `@` starts a linked-message reference (`@:key`); malformed usage throws
|
||||
* `Invalid linked format`.
|
||||
* - `{` / `}` delimit interpolation (`{name}`, `{'literal'}`); an unbalanced
|
||||
* brace throws `Unterminated/Unbalanced closing brace`.
|
||||
* - `|` separates plural branches, so `a | b` silently renders as one branch.
|
||||
* - `%` forms modulo interpolation when immediately followed by `{` (`%{name}`);
|
||||
* it must be escaped too, otherwise escaping a following `{` re-forms `%{`.
|
||||
*
|
||||
* The set is a build-inlined `const enum` (`TokenChars`) in
|
||||
* `@intlify/message-compiler` and is not exported, so it is hardcoded here.
|
||||
*
|
||||
* @see https://vue-i18n.intlify.dev/guide/essentials/syntax (Special Characters, Literal interpolation)
|
||||
* @see https://vue-i18n.intlify.dev/guide/essentials/pluralization
|
||||
*/
|
||||
const VUE_I18N_SYNTAX_CHARS = /[@{}|%]/g
|
||||
|
||||
/**
|
||||
* Escapes vue-i18n message-syntax characters as literal interpolations (`{'x'}`)
|
||||
* so arbitrary text renders verbatim instead of being parsed as syntax. This is
|
||||
* the only escape vue-i18n supports; see {@link VUE_I18N_SYNTAX_CHARS}.
|
||||
*
|
||||
* Only apply to values read through the compiler (`t()`/`st()`). Values read raw
|
||||
* via `tm()`/`stRaw()` (e.g. node tooltips) must be left untouched, or the
|
||||
* literal `{'x'}` would surface to users. Apply exactly once to raw text: the
|
||||
* escape output itself contains `{`/`}`, so it is not idempotent.
|
||||
*/
|
||||
export function escapeVueI18nMessageSyntax(text: string): string {
|
||||
return text.replace(VUE_I18N_SYNTAX_CHARS, (char) => `{'${char}'}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a dynamic prompt in the format {opt1|opt2|{optA|optB}|} and randomly replaces groups. Supports C style comments.
|
||||
* @param input The dynamic prompt to process
|
||||
|
||||
94
pnpm-lock.yaml
generated
94
pnpm-lock.yaml
generated
@@ -30,9 +30,6 @@ catalogs:
|
||||
'@eslint/js':
|
||||
specifier: ^10.0.1
|
||||
version: 10.0.1
|
||||
'@floating-ui/vue':
|
||||
specifier: ^1.1.11
|
||||
version: 1.1.11
|
||||
'@formkit/auto-animate':
|
||||
specifier: ^0.9.0
|
||||
version: 0.9.0
|
||||
@@ -243,9 +240,6 @@ catalogs:
|
||||
eslint-plugin-vue:
|
||||
specifier: ^10.9.1
|
||||
version: 10.9.1
|
||||
fallow:
|
||||
specifier: ^2.102.0
|
||||
version: 2.102.0
|
||||
fast-check:
|
||||
specifier: ^4.5.3
|
||||
version: 4.5.3
|
||||
@@ -474,9 +468,6 @@ importers:
|
||||
'@customerio/cdp-analytics-browser':
|
||||
specifier: 'catalog:'
|
||||
version: 0.5.3
|
||||
'@floating-ui/vue':
|
||||
specifier: 'catalog:'
|
||||
version: 1.1.11(vue@3.5.34(typescript@5.9.3))
|
||||
'@formkit/auto-animate':
|
||||
specifier: 'catalog:'
|
||||
version: 0.9.0
|
||||
@@ -772,9 +763,6 @@ importers:
|
||||
eslint-plugin-vue:
|
||||
specifier: 'catalog:'
|
||||
version: 10.9.1(@typescript-eslint/parser@8.60.0(eslint@10.4.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.4.0(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.4.0(jiti@2.6.1)))
|
||||
fallow:
|
||||
specifier: 'catalog:'
|
||||
version: 2.102.0
|
||||
fast-check:
|
||||
specifier: 'catalog:'
|
||||
version: 4.5.3
|
||||
@@ -1920,46 +1908,6 @@ packages:
|
||||
'@exodus/crypto':
|
||||
optional: true
|
||||
|
||||
'@fallow-cli/darwin-arm64@2.102.0':
|
||||
resolution: {integrity: sha512-B8wzfzJgoX6h5Gv2xQ9ZidO5Jb8/PWdssAxYbWs1pb5oJHZ6S5PLwXuUdINmNSIaRGQwTk4DC9/tIMFHFvd9uw==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@fallow-cli/darwin-x64@2.102.0':
|
||||
resolution: {integrity: sha512-aLbTWWzQnleKdi56obAPXMJm7YA3qAnkIX9T3eocRHiagYqp8nsf4cslM0rZKvu2WwK34NaBm8x886gl4cl+zg==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@fallow-cli/linux-arm64-gnu@2.102.0':
|
||||
resolution: {integrity: sha512-8nYeOSLSewqcKH/KUcKZaCq5QII5VTRX62l60B6UM1iKe/0jcmlQ2mOtx4rkHpeq3LL5emvT/ph4NNgGmWKSBg==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@fallow-cli/linux-arm64-musl@2.102.0':
|
||||
resolution: {integrity: sha512-2Zi33PXzZxD7HU5sPVwyElZGP7zyEdGo4hK0ewy6gMYgQ9BDfLnFhgaSSOzN1J4paIhYtBmVsLmqakyDKy22Jw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@fallow-cli/linux-x64-gnu@2.102.0':
|
||||
resolution: {integrity: sha512-7Hys4X6hKuR/lqUaGXwezRzDrwXwu9KfahUy85WTuiG1to+ZbzDCqdbZ04LtnI8kK8ufrPDcq+ZXdyt5ksOJHA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@fallow-cli/linux-x64-musl@2.102.0':
|
||||
resolution: {integrity: sha512-RuDY1jOEPgJOuHBgEpHVl6J7Xf2QLFklnNy6zO1nw8R1fLgWUAAKlFirn1Y93pb9bXzqpn5Gre3YlhgIZ3+LBA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@fallow-cli/win32-arm64-msvc@2.102.0':
|
||||
resolution: {integrity: sha512-wsvHjLzWFvsYmCqnQLm1doSEHb9Z038+sFp1RzMcffPUBC5tqS3vCr8J8nolcLlPk7o3QWHQMXAqb1xSJe+doA==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@fallow-cli/win32-x64-msvc@2.102.0':
|
||||
resolution: {integrity: sha512-rH1hd0PD0mm6pCxh1pw5jubpJsvV6f5rjixMoD5AZLzWa6NPJBpPPuzruLGtH/9CYy8B0y7zPrGGTKRO2PCGzg==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@firebase/analytics-compat@0.2.18':
|
||||
resolution: {integrity: sha512-Hw9mzsSMZaQu6wrTbi3kYYwGw9nBqOHr47pVLxfr5v8CalsdrG5gfs9XUlPOZjHRVISp3oQrh1j7d3E+ulHPjQ==}
|
||||
peerDependencies:
|
||||
@@ -5734,11 +5682,6 @@ packages:
|
||||
extendable-media-recorder@9.2.27:
|
||||
resolution: {integrity: sha512-2X+Ixi1cxLek0Cj9x9atmhQ+apG+LwJpP2p3ypP8Pxau0poDnicrg7FTfPVQV5PW/3DHFm/eQ16vbgo5Yk3HGQ==}
|
||||
|
||||
fallow@2.102.0:
|
||||
resolution: {integrity: sha512-bkOT58kPVCB12d2apQjIKBw/qSdsGRPQFrN5ff9Yl5WzXRqlDTbT/MVdMXld4sJD5JQW1ftw2bTxJWCINggh6g==}
|
||||
engines: {node: '>=16'}
|
||||
hasBin: true
|
||||
|
||||
fast-check@4.5.3:
|
||||
resolution: {integrity: sha512-IE9csY7lnhxBnA8g/WI5eg/hygA6MGWJMSNfFRrBlXUciADEhS1EDB0SIsMSvzubzIlOBbVITSsypCsW717poA==}
|
||||
engines: {node: '>=12.17.0'}
|
||||
@@ -10101,30 +10044,6 @@ snapshots:
|
||||
|
||||
'@exodus/bytes@1.7.0': {}
|
||||
|
||||
'@fallow-cli/darwin-arm64@2.102.0':
|
||||
optional: true
|
||||
|
||||
'@fallow-cli/darwin-x64@2.102.0':
|
||||
optional: true
|
||||
|
||||
'@fallow-cli/linux-arm64-gnu@2.102.0':
|
||||
optional: true
|
||||
|
||||
'@fallow-cli/linux-arm64-musl@2.102.0':
|
||||
optional: true
|
||||
|
||||
'@fallow-cli/linux-x64-gnu@2.102.0':
|
||||
optional: true
|
||||
|
||||
'@fallow-cli/linux-x64-musl@2.102.0':
|
||||
optional: true
|
||||
|
||||
'@fallow-cli/win32-arm64-msvc@2.102.0':
|
||||
optional: true
|
||||
|
||||
'@fallow-cli/win32-x64-msvc@2.102.0':
|
||||
optional: true
|
||||
|
||||
'@firebase/analytics-compat@0.2.18(@firebase/app-compat@0.2.53)(@firebase/app@0.11.4)':
|
||||
dependencies:
|
||||
'@firebase/analytics': 0.10.12(@firebase/app@0.11.4)
|
||||
@@ -14176,19 +14095,6 @@ snapshots:
|
||||
subscribable-things: 2.1.53
|
||||
tslib: 2.8.1
|
||||
|
||||
fallow@2.102.0:
|
||||
dependencies:
|
||||
detect-libc: 2.1.2
|
||||
optionalDependencies:
|
||||
'@fallow-cli/darwin-arm64': 2.102.0
|
||||
'@fallow-cli/darwin-x64': 2.102.0
|
||||
'@fallow-cli/linux-arm64-gnu': 2.102.0
|
||||
'@fallow-cli/linux-arm64-musl': 2.102.0
|
||||
'@fallow-cli/linux-x64-gnu': 2.102.0
|
||||
'@fallow-cli/linux-x64-musl': 2.102.0
|
||||
'@fallow-cli/win32-arm64-msvc': 2.102.0
|
||||
'@fallow-cli/win32-x64-msvc': 2.102.0
|
||||
|
||||
fast-check@4.5.3:
|
||||
dependencies:
|
||||
pure-rand: 7.0.1
|
||||
|
||||
@@ -18,7 +18,6 @@ catalog:
|
||||
'@comfyorg/comfyui-electron-types': 0.6.2
|
||||
'@customerio/cdp-analytics-browser': ^0.5.3
|
||||
'@eslint/js': ^10.0.1
|
||||
'@floating-ui/vue': ^1.1.11
|
||||
'@formkit/auto-animate': ^0.9.0
|
||||
'@iconify-json/lucide': ^1.1.178
|
||||
'@iconify/json': ^2.2.380
|
||||
@@ -90,7 +89,6 @@ catalog:
|
||||
eslint-plugin-testing-library: ^7.16.1
|
||||
eslint-plugin-unused-imports: ^4.4.1
|
||||
eslint-plugin-vue: ^10.9.1
|
||||
fallow: ^2.102.0
|
||||
fast-check: ^4.5.3
|
||||
firebase: ^11.6.0
|
||||
glob: ^13.0.6
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 281 KiB |
@@ -15,7 +15,6 @@ const IGNORE_PATTERNS = [
|
||||
/^dataTypes\./, // Data types might be referenced dynamically
|
||||
/^contextMenu\./, // Context menu items might be dynamic
|
||||
/^color\./, // Color names might be used dynamically
|
||||
/^onboardingCoachmarks\.[^.]+\.[^.]+\./, // Step keys derived as onboardingCoachmarks.<tour>.<step>.*
|
||||
// Auto-generated categories from collect-i18n-general.ts
|
||||
/^menuLabels\./, // Menu labels generated from command labels
|
||||
/^settingsCategories\./, // Settings categories generated from setting definitions
|
||||
|
||||
126
scripts/cicd/dashboard/index.html
Normal file
126
scripts/cicd/dashboard/index.html
Normal file
@@ -0,0 +1,126 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="robots" content="noindex" />
|
||||
<title>ComfyUI_frontend CI Dashboard</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #ffffff;
|
||||
--fg: #1a1a1a;
|
||||
--muted: #59636e;
|
||||
--border: #d8dee4;
|
||||
--card-bg: #f6f8fa;
|
||||
--link: #0969da;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #0d1117;
|
||||
--fg: #e6edf3;
|
||||
--muted: #9198a1;
|
||||
--border: #30363d;
|
||||
--card-bg: #161b22;
|
||||
--link: #4493f8;
|
||||
}
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 2.5rem 1.5rem;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial,
|
||||
sans-serif;
|
||||
line-height: 1.5;
|
||||
}
|
||||
main {
|
||||
max-width: 40rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
p.lede {
|
||||
color: var(--muted);
|
||||
margin-top: 0;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
ul.reports {
|
||||
list-style: none;
|
||||
margin: 0 0 2rem;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
ul.reports li {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--card-bg);
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
ul.reports a {
|
||||
color: var(--link);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
ul.reports a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
ul.reports .description {
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
footer {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
code {
|
||||
background: var(--card-bg);
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>ComfyUI_frontend CI Dashboard</h1>
|
||||
<p class="lede">
|
||||
Persistent links to CI-generated reports and tools for the
|
||||
<code>main</code> branch, rebuilt on every push.
|
||||
</p>
|
||||
|
||||
<ul class="reports">
|
||||
<li>
|
||||
<a href="https://main.comfy-storybook.pages.dev">Storybook</a>
|
||||
<div class="description">
|
||||
Component library, built and deployed from
|
||||
<code>ci-tests-storybook.yaml</code>.
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<a href="./playwright-report/">Playwright report</a>
|
||||
<div class="description">
|
||||
Merged chromium E2E report from the latest <code>main</code> run of
|
||||
<code>ci-tests-e2e.yaml</code>.
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<footer>
|
||||
More report types (coverage, bundle size, lint, etc.) will be added here
|
||||
in follow-up PRs as they get a persistent, hosted home. This page is
|
||||
intentionally minimal for now.
|
||||
</footer>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -3,9 +3,11 @@ import * as fs from 'fs'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
|
||||
import { comfyPageFixture as test } from '../browser_tests/fixtures/ComfyPage'
|
||||
import {
|
||||
escapeVueI18nMessageSyntax,
|
||||
normalizeI18nKey
|
||||
} from '@/utils/formatUtil'
|
||||
import type { ComfyNodeDefImpl } from '../src/stores/nodeDefStore'
|
||||
import type { WidgetLabels } from './nodeDefLocaleSerializer'
|
||||
import { serializeNodeDefLocales } from './nodeDefLocaleSerializer'
|
||||
|
||||
const localePath = './src/locales/en/main.json'
|
||||
const nodeDefsPath = './src/locales/en/nodeDefs.json'
|
||||
@@ -15,6 +17,10 @@ interface WidgetInfo {
|
||||
label?: string
|
||||
}
|
||||
|
||||
interface WidgetLabels {
|
||||
[key: string]: Record<string, { name: string }>
|
||||
}
|
||||
|
||||
test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
// Mock view route
|
||||
await comfyPage.page.route('**/view**', async (route) => {
|
||||
@@ -41,6 +47,26 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
}
|
||||
)
|
||||
|
||||
const allDataTypesLocale = Object.fromEntries(
|
||||
nodeDefs
|
||||
.flatMap((nodeDef) => {
|
||||
const inputDataTypes = Object.values(nodeDef.inputs).map(
|
||||
(inputSpec) => inputSpec.type
|
||||
)
|
||||
const outputDataTypes = nodeDef.outputs.map(
|
||||
(outputSpec) => outputSpec.type
|
||||
)
|
||||
const allDataTypes = [...inputDataTypes, ...outputDataTypes].flatMap(
|
||||
(type: string) => type.split(',')
|
||||
)
|
||||
return allDataTypes.map((dataType) => [
|
||||
normalizeI18nKey(dataType),
|
||||
escapeVueI18nMessageSyntax(dataType)
|
||||
])
|
||||
})
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
)
|
||||
|
||||
async function extractWidgetLabels() {
|
||||
const nodeLabels: WidgetLabels = {}
|
||||
|
||||
@@ -69,10 +95,14 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
[nodeDef.name, nodeDef.display_name, inputNames]
|
||||
)
|
||||
|
||||
// Format runtime widgets
|
||||
const runtimeWidgets = Object.fromEntries(
|
||||
Object.entries(widgetsMappings)
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.map(([key, name]) => [key, { name }])
|
||||
.map(([key, value]) => [
|
||||
normalizeI18nKey(key),
|
||||
{ name: value ? escapeVueI18nMessageSyntax(value) : value }
|
||||
])
|
||||
)
|
||||
|
||||
if (Object.keys(runtimeWidgets).length > 0) {
|
||||
@@ -91,8 +121,97 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
}
|
||||
|
||||
const nodeDefLabels = await extractWidgetLabels()
|
||||
const { dataTypes, nodeCategories, nodeDefinitions } =
|
||||
serializeNodeDefLocales(nodeDefs, nodeDefLabels)
|
||||
|
||||
function extractInputs(nodeDef: ComfyNodeDefImpl) {
|
||||
const inputs = Object.fromEntries(
|
||||
Object.values(nodeDef.inputs).flatMap((input) => {
|
||||
const name =
|
||||
input.name === undefined
|
||||
? undefined
|
||||
: escapeVueI18nMessageSyntax(input.name)
|
||||
const tooltip = input.tooltip
|
||||
|
||||
if (name === undefined && tooltip === undefined) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [
|
||||
[
|
||||
normalizeI18nKey(input.name),
|
||||
{
|
||||
name,
|
||||
tooltip
|
||||
}
|
||||
]
|
||||
]
|
||||
})
|
||||
)
|
||||
return Object.keys(inputs).length > 0 ? inputs : undefined
|
||||
}
|
||||
|
||||
function extractOutputs(nodeDef: ComfyNodeDefImpl) {
|
||||
const outputs = Object.fromEntries(
|
||||
nodeDef.outputs.flatMap((output, i) => {
|
||||
// Ignore data types if they are already translated in allDataTypesLocale.
|
||||
const name =
|
||||
output.name === undefined || output.name in allDataTypesLocale
|
||||
? undefined
|
||||
: escapeVueI18nMessageSyntax(output.name)
|
||||
const tooltip = output.tooltip
|
||||
|
||||
if (name === undefined && tooltip === undefined) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [
|
||||
[
|
||||
i.toString(),
|
||||
{
|
||||
name,
|
||||
tooltip
|
||||
}
|
||||
]
|
||||
]
|
||||
})
|
||||
)
|
||||
return Object.keys(outputs).length > 0 ? outputs : undefined
|
||||
}
|
||||
|
||||
const allNodeDefsLocale = Object.fromEntries(
|
||||
nodeDefs
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((nodeDef) => {
|
||||
const inputs = {
|
||||
...extractInputs(nodeDef),
|
||||
...(nodeDefLabels[nodeDef.name] ?? {})
|
||||
}
|
||||
|
||||
return [
|
||||
normalizeI18nKey(nodeDef.name),
|
||||
{
|
||||
display_name: escapeVueI18nMessageSyntax(
|
||||
nodeDef.display_name ?? nodeDef.name
|
||||
),
|
||||
description: nodeDef.description
|
||||
? escapeVueI18nMessageSyntax(nodeDef.description)
|
||||
: undefined,
|
||||
inputs: Object.keys(inputs).length > 0 ? inputs : undefined,
|
||||
outputs: extractOutputs(nodeDef)
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
const allNodeCategoriesLocale = Object.fromEntries(
|
||||
nodeDefs.flatMap((nodeDef) =>
|
||||
nodeDef.category
|
||||
.split('/')
|
||||
.map((category) => [
|
||||
normalizeI18nKey(category),
|
||||
escapeVueI18nMessageSyntax(category)
|
||||
])
|
||||
)
|
||||
)
|
||||
|
||||
const locale = JSON.parse(fs.readFileSync(localePath, 'utf-8'))
|
||||
fs.writeFileSync(
|
||||
@@ -100,13 +219,13 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
JSON.stringify(
|
||||
{
|
||||
...locale,
|
||||
dataTypes,
|
||||
nodeCategories
|
||||
dataTypes: allDataTypesLocale,
|
||||
nodeCategories: allNodeCategoriesLocale
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
|
||||
fs.writeFileSync(nodeDefsPath, JSON.stringify(nodeDefinitions, null, 2))
|
||||
fs.writeFileSync(nodeDefsPath, JSON.stringify(allNodeDefsLocale, null, 2))
|
||||
})
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { serializeNodeDefLocales } from './nodeDefLocaleSerializer'
|
||||
|
||||
function render(message: string): string {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: { value: message } }
|
||||
})
|
||||
return i18n.global.t('value')
|
||||
}
|
||||
|
||||
describe('serializeNodeDefLocales', () => {
|
||||
it('escapes compiled fields and preserves raw tooltips', () => {
|
||||
const syntax = '@ $ {value} | 50%{done}'
|
||||
const inputName = `Input ${syntax}`
|
||||
const outputName = `Output ${syntax}`
|
||||
const dataType = `TYPE ${syntax}`
|
||||
const category = `Category ${syntax}`
|
||||
const nodeDef = {
|
||||
name: 'Test.Node',
|
||||
display_name: `Display ${syntax}`,
|
||||
description: `Description ${syntax}`,
|
||||
category,
|
||||
inputs: {
|
||||
input: {
|
||||
name: inputName,
|
||||
type: dataType,
|
||||
tooltip: `Input tooltip ${syntax}`
|
||||
}
|
||||
},
|
||||
outputs: [
|
||||
{
|
||||
name: outputName,
|
||||
type: 'OTHER',
|
||||
tooltip: `Output tooltip ${syntax}`
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const { dataTypes, nodeCategories, nodeDefinitions } =
|
||||
serializeNodeDefLocales([nodeDef], {
|
||||
'Test.Node': {
|
||||
'Runtime.Widget': { name: `Widget ${syntax}` }
|
||||
}
|
||||
})
|
||||
const serializedNode = nodeDefinitions.Test_Node
|
||||
const serializedInput =
|
||||
serializedNode.inputs['Input @ $ {value} | 50%{done}']
|
||||
const serializedOutput = serializedNode.outputs['0']
|
||||
|
||||
expect(render(serializedNode.display_name)).toBe(nodeDef.display_name)
|
||||
expect(render(serializedNode.description)).toBe(nodeDef.description)
|
||||
expect(render(serializedInput.name)).toBe(inputName)
|
||||
expect(render(serializedOutput.name)).toBe(outputName)
|
||||
expect(render(serializedNode.inputs.Runtime_Widget.name)).toBe(
|
||||
`Widget ${syntax}`
|
||||
)
|
||||
expect(render(dataTypes[dataType])).toBe(dataType)
|
||||
expect(render(nodeCategories[category])).toBe(category)
|
||||
expect(serializedInput.tooltip).toBe(nodeDef.inputs.input.tooltip)
|
||||
expect(serializedOutput.tooltip).toBe(nodeDef.outputs[0].tooltip)
|
||||
})
|
||||
|
||||
it('preserves locale shapes and ordering', () => {
|
||||
const { dataTypes, nodeCategories, nodeDefinitions } =
|
||||
serializeNodeDefLocales(
|
||||
[
|
||||
{
|
||||
name: 'Z.Node',
|
||||
description: '',
|
||||
category: 'group/sub.group',
|
||||
inputs: {
|
||||
omitted: { type: 'Z.TYPE' },
|
||||
tooltipOnly: { type: 'A_TYPE', tooltip: 'raw @ tooltip' }
|
||||
},
|
||||
outputs: [
|
||||
{ name: 'A_TYPE', type: 'A_TYPE' },
|
||||
{ name: 'Custom.Output', type: 'Z.TYPE' },
|
||||
{ tooltip: 'raw output @ tooltip', type: 'Z.TYPE' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'A.Node',
|
||||
category: 'group',
|
||||
inputs: {},
|
||||
outputs: []
|
||||
}
|
||||
],
|
||||
{
|
||||
'Z.Node': {
|
||||
'Runtime.Widget': { name: 'Runtime.Label' }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
expect(dataTypes).toEqual({
|
||||
A_TYPE: 'A_TYPE',
|
||||
Z_TYPE: 'Z.TYPE'
|
||||
})
|
||||
expect(nodeCategories).toEqual({
|
||||
group: 'group',
|
||||
sub_group: 'sub.group'
|
||||
})
|
||||
expect(nodeDefinitions).toEqual({
|
||||
A_Node: {
|
||||
display_name: 'A.Node',
|
||||
description: undefined,
|
||||
inputs: undefined,
|
||||
outputs: undefined
|
||||
},
|
||||
Z_Node: {
|
||||
display_name: 'Z.Node',
|
||||
description: undefined,
|
||||
inputs: {
|
||||
'': { name: undefined, tooltip: 'raw @ tooltip' },
|
||||
Runtime_Widget: { name: 'Runtime.Label' }
|
||||
},
|
||||
outputs: {
|
||||
1: { name: 'Custom.Output', tooltip: undefined },
|
||||
2: { name: undefined, tooltip: 'raw output @ tooltip' }
|
||||
}
|
||||
}
|
||||
})
|
||||
expect(Object.keys(dataTypes)).toEqual(['A_TYPE', 'Z_TYPE'])
|
||||
expect(Object.keys(nodeDefinitions)).toEqual(['A_Node', 'Z_Node'])
|
||||
})
|
||||
})
|
||||
@@ -1,127 +0,0 @@
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
|
||||
interface LocalizableInput {
|
||||
type: string
|
||||
name?: string
|
||||
tooltip?: string
|
||||
}
|
||||
|
||||
interface LocalizableOutput {
|
||||
type: string
|
||||
name?: string
|
||||
tooltip?: string
|
||||
}
|
||||
|
||||
interface LocalizableNodeDef {
|
||||
category: string
|
||||
inputs: Record<string, LocalizableInput>
|
||||
name: string
|
||||
outputs: LocalizableOutput[]
|
||||
description?: string
|
||||
display_name?: string
|
||||
}
|
||||
|
||||
export type WidgetLabels = Record<
|
||||
string,
|
||||
Record<string, { name: string | undefined }>
|
||||
>
|
||||
|
||||
const VUE_I18N_SYNTAX_CHARS = /[@${}|%]/g
|
||||
|
||||
function escapeMessage(text: string): string {
|
||||
return text.replace(VUE_I18N_SYNTAX_CHARS, (char) => `{'${char}'}`)
|
||||
}
|
||||
|
||||
export function serializeNodeDefLocales(
|
||||
nodeDefs: readonly LocalizableNodeDef[],
|
||||
widgetLabels: WidgetLabels = {}
|
||||
) {
|
||||
const dataTypes = Object.fromEntries(
|
||||
nodeDefs
|
||||
.flatMap((nodeDef) => [
|
||||
...Object.values(nodeDef.inputs).map(({ type }) => type),
|
||||
...nodeDef.outputs.map(({ type }) => type)
|
||||
])
|
||||
.flatMap((type) => type.split(','))
|
||||
.map((dataType) => [normalizeI18nKey(dataType), escapeMessage(dataType)])
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
)
|
||||
|
||||
function serializeInputs(nodeDef: LocalizableNodeDef) {
|
||||
const inputs = Object.fromEntries(
|
||||
Object.values(nodeDef.inputs).flatMap(({ name, tooltip }) => {
|
||||
if (name === undefined && tooltip === undefined) return []
|
||||
|
||||
return [
|
||||
[
|
||||
normalizeI18nKey(name ?? ''),
|
||||
{
|
||||
name: name === undefined ? undefined : escapeMessage(name),
|
||||
tooltip
|
||||
}
|
||||
]
|
||||
]
|
||||
})
|
||||
)
|
||||
return Object.keys(inputs).length > 0 ? inputs : undefined
|
||||
}
|
||||
|
||||
function serializeOutputs(nodeDef: LocalizableNodeDef) {
|
||||
const outputs = Object.fromEntries(
|
||||
nodeDef.outputs.flatMap(({ name, tooltip }, index) => {
|
||||
const serializedName =
|
||||
name === undefined || name in dataTypes
|
||||
? undefined
|
||||
: escapeMessage(name)
|
||||
if (serializedName === undefined && tooltip === undefined) return []
|
||||
|
||||
return [[index.toString(), { name: serializedName, tooltip }]]
|
||||
})
|
||||
)
|
||||
return Object.keys(outputs).length > 0 ? outputs : undefined
|
||||
}
|
||||
|
||||
function serializeWidgetLabels(nodeName: string) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(widgetLabels[nodeName] ?? {}).map(([name, label]) => [
|
||||
normalizeI18nKey(name),
|
||||
{
|
||||
name: label.name === undefined ? undefined : escapeMessage(label.name)
|
||||
}
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
const nodeDefinitions = Object.fromEntries(
|
||||
[...nodeDefs]
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((nodeDef) => {
|
||||
const inputs = {
|
||||
...serializeInputs(nodeDef),
|
||||
...serializeWidgetLabels(nodeDef.name)
|
||||
}
|
||||
|
||||
return [
|
||||
normalizeI18nKey(nodeDef.name),
|
||||
{
|
||||
display_name: escapeMessage(nodeDef.display_name ?? nodeDef.name),
|
||||
description: nodeDef.description
|
||||
? escapeMessage(nodeDef.description)
|
||||
: undefined,
|
||||
inputs: Object.keys(inputs).length > 0 ? inputs : undefined,
|
||||
outputs: serializeOutputs(nodeDef)
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
const nodeCategories = Object.fromEntries(
|
||||
nodeDefs.flatMap(({ category }) =>
|
||||
category
|
||||
.split('/')
|
||||
.map((part) => [normalizeI18nKey(part), escapeMessage(part)])
|
||||
)
|
||||
)
|
||||
|
||||
return { dataTypes, nodeCategories, nodeDefinitions }
|
||||
}
|
||||
@@ -629,7 +629,7 @@ describe('TopMenuSection', () => {
|
||||
await nextTick()
|
||||
|
||||
expect(querySpy).toHaveBeenCalledTimes(1)
|
||||
expect(actionbarContainer!.classList).not.toContain('w-0')
|
||||
expect(actionbarContainer!.classList).toContain('px-2')
|
||||
} finally {
|
||||
unmount()
|
||||
vi.unstubAllGlobals()
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</div>
|
||||
|
||||
<div class="mx-1 flex flex-col items-end gap-1">
|
||||
<div class="flex items-start gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
v-if="managerState.shouldShowManagerButtons.value || isCloud"
|
||||
class="pointer-events-auto flex h-12 shrink-0 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
|
||||
@@ -34,75 +34,61 @@
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="pointer-events-auto z-1 flex flex-col rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 py-1.75 shadow-interface"
|
||||
>
|
||||
<div ref="actionbarContainerRef" :class="actionbarContainerClass">
|
||||
<ActionBarButtons />
|
||||
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
|
||||
<div
|
||||
ref="actionbarContainerRef"
|
||||
:class="
|
||||
cn(
|
||||
'actionbar-container relative flex items-center gap-2',
|
||||
isActionbarContainerEmpty &&
|
||||
'-ml-2 w-0 min-w-0 border-transparent shadow-none has-[.border-dashed]:ml-0 has-[.border-dashed]:w-auto has-[.border-dashed]:min-w-auto has-[.border-dashed]:border-interface-stroke has-[.border-dashed]:pl-2 has-[.border-dashed]:shadow-interface'
|
||||
)
|
||||
"
|
||||
>
|
||||
<ActionBarButtons />
|
||||
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
|
||||
<div
|
||||
ref="legacyCommandsContainerRef"
|
||||
data-testid="legacy-topbar-container"
|
||||
class="[&:not(:has(*>*:not(:empty)))]:hidden"
|
||||
></div>
|
||||
ref="legacyCommandsContainerRef"
|
||||
data-testid="legacy-topbar-container"
|
||||
class="[&:not(:has(*>*:not(:empty)))]:hidden"
|
||||
></div>
|
||||
|
||||
<ComfyActionbar
|
||||
:top-menu-container="actionbarContainerRef"
|
||||
:queue-overlay-expanded="isQueueOverlayExpanded"
|
||||
@update:progress-target="updateProgressTarget"
|
||||
/>
|
||||
<CurrentUserButton
|
||||
v-if="isLoggedIn && !isIntegratedTabBar"
|
||||
class="shrink-0"
|
||||
/>
|
||||
<LoginButton v-else-if="isDesktop && !isIntegratedTabBar" />
|
||||
<ComfyActionbar
|
||||
:top-menu-container="actionbarContainerRef"
|
||||
:queue-overlay-expanded="isQueueOverlayExpanded"
|
||||
@update:progress-target="updateProgressTarget"
|
||||
/>
|
||||
<CurrentUserButton
|
||||
v-if="isLoggedIn && !isIntegratedTabBar"
|
||||
class="shrink-0"
|
||||
/>
|
||||
<LoginButton v-else-if="isDesktop && !isIntegratedTabBar" />
|
||||
<Button
|
||||
v-if="isCloud && flags.workflowSharingEnabled"
|
||||
v-tooltip.bottom="shareTooltipConfig"
|
||||
variant="secondary"
|
||||
:aria-label="t('actionbar.shareTooltip')"
|
||||
@click="() => openShareDialog().catch(toastErrorHandler)"
|
||||
@pointerenter="prefetchShareDialog"
|
||||
>
|
||||
<i class="icon-[comfy--send] size-4" />
|
||||
<span class="not-md:hidden">
|
||||
{{ t('actionbar.share') }}
|
||||
</span>
|
||||
</Button>
|
||||
<div v-if="!isRightSidePanelOpen" class="relative">
|
||||
<Button
|
||||
v-if="isCloud && flags.workflowSharingEnabled"
|
||||
v-tooltip.bottom="shareTooltipConfig"
|
||||
v-tooltip.bottom="rightSidePanelTooltipConfig"
|
||||
:class="
|
||||
cn(
|
||||
showErrorIndicatorOnPanelButton &&
|
||||
'outline-1 outline-destructive-background'
|
||||
)
|
||||
"
|
||||
variant="secondary"
|
||||
:aria-label="t('actionbar.shareTooltip')"
|
||||
@click="() => openShareDialog().catch(toastErrorHandler)"
|
||||
@pointerenter="prefetchShareDialog"
|
||||
size="icon"
|
||||
:aria-label="t('rightSidePanel.togglePanel')"
|
||||
@click="openRightSidePanel"
|
||||
>
|
||||
<i class="icon-[comfy--send] size-4" />
|
||||
<span class="not-md:hidden">
|
||||
{{ t('actionbar.share') }}
|
||||
</span>
|
||||
<i class="icon-[lucide--panel-right] size-4" />
|
||||
</Button>
|
||||
<div v-if="!isRightSidePanelOpen" class="relative">
|
||||
<Button
|
||||
v-tooltip.bottom="rightSidePanelTooltipConfig"
|
||||
:class="
|
||||
cn(
|
||||
showErrorIndicatorOnPanelButton &&
|
||||
'outline-1 outline-destructive-background'
|
||||
)
|
||||
"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
:aria-label="t('rightSidePanel.togglePanel')"
|
||||
@click="openRightSidePanel"
|
||||
>
|
||||
<i class="icon-[lucide--panel-right] size-4" />
|
||||
</Button>
|
||||
<StatusBadge
|
||||
v-if="showErrorIndicatorOnPanelButton"
|
||||
variant="dot"
|
||||
severity="danger"
|
||||
class="absolute -top-1 -right-1"
|
||||
/>
|
||||
</div>
|
||||
<StatusBadge
|
||||
v-if="showErrorIndicatorOnPanelButton"
|
||||
variant="dot"
|
||||
severity="danger"
|
||||
class="absolute -top-1 -right-1"
|
||||
/>
|
||||
</div>
|
||||
<FreeTierQuota v-if="!isActionbarFloating" />
|
||||
</div>
|
||||
</div>
|
||||
<ErrorOverlay />
|
||||
@@ -161,7 +147,6 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import FreeTierQuota from '@/platform/cloud/subscription/components/FreeTierQuota.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { app } from '@/scripts/app'
|
||||
@@ -224,6 +209,21 @@ const hasDockedButtons = computed(() => {
|
||||
const isActionbarContainerEmpty = computed(
|
||||
() => isActionbarFloating.value && !hasDockedButtons.value
|
||||
)
|
||||
const actionbarContainerClass = computed(() => {
|
||||
const base =
|
||||
'actionbar-container pointer-events-auto relative flex h-12 items-center gap-2 rounded-lg border bg-comfy-menu-bg shadow-interface'
|
||||
|
||||
if (isActionbarContainerEmpty.value) {
|
||||
return cn(
|
||||
base,
|
||||
'-ml-2 w-0 min-w-0 border-transparent shadow-none',
|
||||
'has-[.border-dashed]:ml-0 has-[.border-dashed]:w-auto has-[.border-dashed]:min-w-auto',
|
||||
'has-[.border-dashed]:border-interface-stroke has-[.border-dashed]:pl-2 has-[.border-dashed]:shadow-interface'
|
||||
)
|
||||
}
|
||||
|
||||
return cn(base, 'px-2', 'border-interface-stroke')
|
||||
})
|
||||
const isIntegratedTabBar = computed(
|
||||
() => settingStore.get('Comfy.UI.TabBarLayout') !== 'Legacy'
|
||||
)
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
|
||||
<Panel
|
||||
ref="panelRef"
|
||||
data-testid="comfy-actionbar"
|
||||
class="pointer-events-auto"
|
||||
:style="style"
|
||||
:class="panelClass"
|
||||
@@ -76,7 +75,6 @@
|
||||
</Button>
|
||||
<ContextMenu ref="queueContextMenu" :model="queueContextMenuItems" />
|
||||
</div>
|
||||
<FreeTierQuota v-if="!isDocked" />
|
||||
</Panel>
|
||||
|
||||
<Teleport v-if="inlineProgressTarget" :to="inlineProgressTarget">
|
||||
@@ -111,7 +109,6 @@ import QueueInlineProgress from '@/components/queue/QueueInlineProgress.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import FreeTierQuota from '@/platform/cloud/subscription/components/FreeTierQuota.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
@@ -4,11 +4,11 @@ import { nextTick, ref } from 'vue'
|
||||
|
||||
import CloudRunButtonWrapper from './CloudRunButtonWrapper.vue'
|
||||
|
||||
const mockCanRunWorkflows = ref(true)
|
||||
const mockIsActiveSubscription = ref(true)
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
canRunWorkflows: mockCanRunWorkflows
|
||||
isActiveSubscription: mockIsActiveSubscription
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -32,7 +32,7 @@ function renderWrapper() {
|
||||
|
||||
describe('CloudRunButtonWrapper', () => {
|
||||
beforeEach(() => {
|
||||
mockCanRunWorkflows.value = true
|
||||
mockIsActiveSubscription.value = true
|
||||
})
|
||||
|
||||
it('renders the runnable queue button when the subscription is active', () => {
|
||||
@@ -45,7 +45,7 @@ describe('CloudRunButtonWrapper', () => {
|
||||
})
|
||||
|
||||
it('locks the run button when the subscription is inactive', () => {
|
||||
mockCanRunWorkflows.value = false
|
||||
mockIsActiveSubscription.value = false
|
||||
renderWrapper()
|
||||
|
||||
expect(screen.getByTestId('subscribe-to-run-button')).toBeInTheDocument()
|
||||
@@ -53,12 +53,12 @@ describe('CloudRunButtonWrapper', () => {
|
||||
})
|
||||
|
||||
it('unlocks the run button once the subscription becomes active again', async () => {
|
||||
mockCanRunWorkflows.value = false
|
||||
mockIsActiveSubscription.value = false
|
||||
renderWrapper()
|
||||
|
||||
expect(screen.getByTestId('subscribe-to-run-button')).toBeInTheDocument()
|
||||
|
||||
mockCanRunWorkflows.value = true
|
||||
mockIsActiveSubscription.value = true
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('queue-button')).toBeInTheDocument()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<component
|
||||
:is="currentButton"
|
||||
:key="canRunWorkflows ? 'queue' : 'subscribe'"
|
||||
:key="isActiveSubscription ? 'queue' : 'subscribe'"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
@@ -11,9 +11,9 @@ import ComfyQueueButton from '@/components/actionbar/ComfyRunButton/ComfyQueueBu
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import SubscribeToRunButton from '@/platform/cloud/subscription/components/SubscribeToRun.vue'
|
||||
|
||||
const { canRunWorkflows } = useBillingContext()
|
||||
const { isActiveSubscription } = useBillingContext()
|
||||
|
||||
const currentButton = computed(() =>
|
||||
canRunWorkflows.value ? ComfyQueueButton : SubscribeToRunButton
|
||||
isActiveSubscription.value ? ComfyQueueButton : SubscribeToRunButton
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { ZIndex } from '@primeuix/utils/zindex'
|
||||
import type { Directive } from 'vue'
|
||||
|
||||
/** Shared PrimeVue/Reka modal stacking sequence; later registrations cover earlier ones. */
|
||||
export const MODAL_Z_KEY = 'modal'
|
||||
export const MODAL_Z_BASE = 1700
|
||||
|
||||
// Both Reka and PrimeVue dialogs can appear at any depth in dialogStack, in
|
||||
// any order. PrimeVue auto-increments a per-key z-index counter so later
|
||||
// dialogs always cover earlier ones; Reka uses a static z-1700 class which
|
||||
@@ -13,7 +9,7 @@ export const MODAL_Z_BASE = 1700
|
||||
// renderers share one stacking sequence: whichever dialog opens last wins.
|
||||
export const vRekaZIndex: Directive<HTMLElement> = {
|
||||
mounted(el) {
|
||||
ZIndex.set(MODAL_Z_KEY, el, MODAL_Z_BASE)
|
||||
ZIndex.set('modal', el, 1700)
|
||||
},
|
||||
beforeUnmount(el) {
|
||||
ZIndex.clear(el)
|
||||
|
||||
@@ -180,7 +180,6 @@ import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteracti
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
|
||||
import MiniMap from '@/renderer/extensions/minimap/MiniMap.vue'
|
||||
import { useFirstRunTourController } from '@/renderer/extensions/firstRunTour/useFirstRunTourController'
|
||||
import LGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
|
||||
import { requestSlotLayoutSyncForAllNodes } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
|
||||
import { UnauthorizedError } from '@/scripts/api'
|
||||
@@ -505,9 +504,6 @@ useEventListener(
|
||||
onMounted(async () => {
|
||||
comfyApp.vueAppReady = true
|
||||
workspaceStore.spinner = true
|
||||
let templateFromUrl: Awaited<
|
||||
ReturnType<typeof workflowPersistence.loadTemplateFromUrlIfPresent>
|
||||
>
|
||||
try {
|
||||
// ChangeTracker needs to be initialized before setup, as it will overwrite
|
||||
// some listeners of litegraph canvas.
|
||||
@@ -565,38 +561,11 @@ onMounted(async () => {
|
||||
// Restore saved workflow and workflow tabs state
|
||||
await workflowPersistence.initializeWorkflow()
|
||||
await workflowPersistence.restoreWorkflowTabsState()
|
||||
templateFromUrl = await workflowPersistence.loadTemplateFromUrlIfPresent()
|
||||
await workflowPersistence.loadTemplateFromUrlIfPresent()
|
||||
} finally {
|
||||
workspaceStore.spinner = false
|
||||
}
|
||||
const sharedFromUrl =
|
||||
await workflowPersistence.loadSharedWorkflowFromUrlIfPresent()
|
||||
|
||||
// A ?template=/?share= URL loads a workflow directly (Getting Started is
|
||||
// skipped). Start the onboarding tour on it; beginTour() self-gates and reads
|
||||
// the now-loaded graph, so the loaders above must have finished first.
|
||||
const startTourFromUrl = (
|
||||
options?: Parameters<
|
||||
ReturnType<typeof useFirstRunTourController>['beginTour']
|
||||
>[0]
|
||||
) =>
|
||||
useFirstRunTourController()
|
||||
.beginTour(options)
|
||||
.catch((error: unknown) => {
|
||||
console.error('[onboardingTour] failed to start from URL', error)
|
||||
})
|
||||
|
||||
if (templateFromUrl.loaded) {
|
||||
void startTourFromUrl({
|
||||
templateId: templateFromUrl.templateId,
|
||||
entry: 'template_url'
|
||||
})
|
||||
} else if (
|
||||
sharedFromUrl === 'loaded' ||
|
||||
sharedFromUrl === 'loaded-without-assets'
|
||||
) {
|
||||
void startTourFromUrl({ entry: 'share_url' })
|
||||
}
|
||||
await workflowPersistence.loadSharedWorkflowFromUrlIfPresent()
|
||||
|
||||
comfyApp.canvas.onSelectionChange = useChainCallback(
|
||||
comfyApp.canvas.onSelectionChange,
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
<template>
|
||||
<div role="tablist" :class="cn('flex w-full items-center gap-2', className)">
|
||||
<div role="tablist" class="flex w-full items-center gap-2">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="T extends string = string">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { provide } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { TAB_LIST_INJECTION_KEY } from './tabKeys'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<T>({ required: true })
|
||||
|
||||
function select(value: string) {
|
||||
|
||||
@@ -112,13 +112,5 @@ export interface BillingContext extends BillingState, BillingActions {
|
||||
* (legacy) per-member tier plan, which keeps the old team pricing table.
|
||||
*/
|
||||
isLegacyTeamPlan: ComputedRef<boolean>
|
||||
/**
|
||||
* True when the subscription is a team plan of either generation. Unlike
|
||||
* `isLegacyTeamPlan` this does not require an active subscription: the spend
|
||||
* gate folds billing_status into is_active, so a paused or payment-failed team
|
||||
* plan reports is_active=false and must still read as a team plan.
|
||||
*/
|
||||
isTeamPlan: ComputedRef<boolean>
|
||||
getMaxSeats: (tierKey: TierKey) => number
|
||||
canRunWorkflows: ComputedRef<boolean>
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ const DEFAULT_BILLING_STATUS: BillingStatusResponse = {
|
||||
|
||||
const {
|
||||
mockTeamWorkspacesEnabled,
|
||||
mockBillingControlEnabled,
|
||||
mockConsolidatedBillingEnabled,
|
||||
mockIsPersonal,
|
||||
mockPlans,
|
||||
mockPurchaseCredits,
|
||||
@@ -27,7 +27,7 @@ const {
|
||||
mockBillingStatus
|
||||
} = vi.hoisted(() => ({
|
||||
mockTeamWorkspacesEnabled: { value: false },
|
||||
mockBillingControlEnabled: { value: false },
|
||||
mockConsolidatedBillingEnabled: { value: false },
|
||||
mockIsPersonal: { value: true },
|
||||
mockPlans: { value: [] as Plan[] },
|
||||
mockPurchaseCredits: vi.fn(),
|
||||
@@ -59,11 +59,13 @@ vi.mock('@/composables/useFeatureFlags', async () => {
|
||||
teamWorkspacesEnabledRef.value = value
|
||||
}
|
||||
})
|
||||
const billingControlEnabledRef = ref(mockBillingControlEnabled.value)
|
||||
Object.defineProperty(mockBillingControlEnabled, 'value', {
|
||||
get: () => billingControlEnabledRef.value,
|
||||
const consolidatedBillingEnabledRef = ref(
|
||||
mockConsolidatedBillingEnabled.value
|
||||
)
|
||||
Object.defineProperty(mockConsolidatedBillingEnabled, 'value', {
|
||||
get: () => consolidatedBillingEnabledRef.value,
|
||||
set: (value: boolean) => {
|
||||
billingControlEnabledRef.value = value
|
||||
consolidatedBillingEnabledRef.value = value
|
||||
}
|
||||
})
|
||||
return {
|
||||
@@ -72,8 +74,8 @@ vi.mock('@/composables/useFeatureFlags', async () => {
|
||||
get teamWorkspacesEnabled() {
|
||||
return mockTeamWorkspacesEnabled.value
|
||||
},
|
||||
get billingControlEnabled() {
|
||||
return mockBillingControlEnabled.value
|
||||
get consolidatedBillingEnabled() {
|
||||
return mockConsolidatedBillingEnabled.value
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -163,7 +165,7 @@ describe('useBillingContext', () => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
mockTeamWorkspacesEnabled.value = false
|
||||
mockBillingControlEnabled.value = false
|
||||
mockConsolidatedBillingEnabled.value = false
|
||||
mockIsPersonal.value = true
|
||||
mockPlans.value = []
|
||||
mockBillingStatus.value = { ...DEFAULT_BILLING_STATUS }
|
||||
@@ -175,27 +177,27 @@ describe('useBillingContext', () => {
|
||||
expect(type.value).toBe('legacy')
|
||||
})
|
||||
|
||||
it('keeps personal on legacy when billing control is disabled', () => {
|
||||
it('keeps personal on legacy when consolidated billing is disabled', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockBillingControlEnabled.value = false
|
||||
mockConsolidatedBillingEnabled.value = false
|
||||
mockIsPersonal.value = true
|
||||
|
||||
const { type } = useBillingContext()
|
||||
expect(type.value).toBe('legacy')
|
||||
})
|
||||
|
||||
it('selects workspace type for personal when billing control is enabled', () => {
|
||||
it('selects workspace type for personal when consolidated billing is enabled', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockBillingControlEnabled.value = true
|
||||
mockConsolidatedBillingEnabled.value = true
|
||||
mockIsPersonal.value = true
|
||||
|
||||
const { type } = useBillingContext()
|
||||
expect(type.value).toBe('workspace')
|
||||
})
|
||||
|
||||
it('selects workspace type for team regardless of billing control', () => {
|
||||
it('selects workspace type for team regardless of consolidated billing', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockBillingControlEnabled.value = false
|
||||
mockConsolidatedBillingEnabled.value = false
|
||||
mockIsPersonal.value = false
|
||||
|
||||
const { type } = useBillingContext()
|
||||
@@ -296,7 +298,7 @@ describe('useBillingContext', () => {
|
||||
expect(workspaceApi.getBillingStatus).not.toHaveBeenCalled()
|
||||
|
||||
// Authenticated remote config resolves the flag on for the same workspace
|
||||
mockBillingControlEnabled.value = true
|
||||
mockConsolidatedBillingEnabled.value = true
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
|
||||
await vi.waitFor(() => {
|
||||
@@ -305,16 +307,16 @@ describe('useBillingContext', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('moves a personal workspace to workspace billing when billing control flips on', async () => {
|
||||
it('moves a personal workspace to workspace billing when consolidated billing flips on', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockBillingControlEnabled.value = false
|
||||
mockConsolidatedBillingEnabled.value = false
|
||||
mockIsPersonal.value = true
|
||||
|
||||
const { type } = useBillingContext()
|
||||
await nextTick()
|
||||
expect(type.value).toBe('legacy')
|
||||
|
||||
mockBillingControlEnabled.value = true
|
||||
mockConsolidatedBillingEnabled.value = true
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(type.value).toBe('workspace')
|
||||
@@ -323,9 +325,9 @@ describe('useBillingContext', () => {
|
||||
})
|
||||
|
||||
describe('subscription mirror to workspace store', () => {
|
||||
it('mirrors subscription for personal workspaces on the billing control flow', async () => {
|
||||
it('mirrors subscription for personal workspaces on the consolidated billing flow', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockBillingControlEnabled.value = true
|
||||
mockConsolidatedBillingEnabled.value = true
|
||||
mockIsPersonal.value = true
|
||||
|
||||
const { initialize } = useBillingContext()
|
||||
@@ -553,110 +555,4 @@ describe('useBillingContext', () => {
|
||||
expect(isLegacyTeamPlan.value).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isTeamPlan', () => {
|
||||
it('is false for a personal workspace', () => {
|
||||
const { isTeamPlan } = useBillingContext()
|
||||
expect(isTeamPlan.value).toBe(false)
|
||||
})
|
||||
|
||||
// subscription_tier is omitted throughout: the backend sends 'TEAM' here, but
|
||||
// the FE's SubscriptionTier resolves to the registry spec, which has no TEAM
|
||||
// (tierPricing.ts imports comfyRegistryTypes for what is an ingest field).
|
||||
// isTeamPlan reads the credit stop and the slug, never the tier — which is
|
||||
// what keeps it working despite that divergence.
|
||||
it('is true for a credit-slider team sub, which carries a credit stop', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
mockBillingStatus.value = {
|
||||
is_active: true,
|
||||
has_funds: true,
|
||||
plan_slug: 'team_per_credit_monthly',
|
||||
team_credit_stop: {
|
||||
id: 'team_700',
|
||||
credits_monthly: 700,
|
||||
stop_usd: 332
|
||||
}
|
||||
}
|
||||
|
||||
const { initialize, isTeamPlan } = useBillingContext()
|
||||
await initialize()
|
||||
|
||||
expect(isTeamPlan.value).toBe(true)
|
||||
})
|
||||
|
||||
it('is true for a legacy team sub, identified by slug rather than credit stop', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
mockBillingStatus.value = {
|
||||
is_active: true,
|
||||
has_funds: true,
|
||||
subscription_tier: 'STANDARD',
|
||||
plan_slug: 'team-standard-annual'
|
||||
}
|
||||
|
||||
const { initialize, isTeamPlan } = useBillingContext()
|
||||
await initialize()
|
||||
|
||||
expect(isTeamPlan.value).toBe(true)
|
||||
})
|
||||
|
||||
// The banner states that need isTeamPlan most — paused and payment_failed —
|
||||
// are exactly the ones the backend reports with is_active=false, because the
|
||||
// spend gate folds billing_status into it. Coupling isTeamPlan to an active
|
||||
// subscription would blank the banner precisely when it is needed.
|
||||
it('stays true for a paused team plan, which the backend reports inactive', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
mockBillingStatus.value = {
|
||||
is_active: false,
|
||||
has_funds: true,
|
||||
billing_status: 'paused',
|
||||
plan_slug: 'team_per_credit_monthly',
|
||||
team_credit_stop: {
|
||||
id: 'team_700',
|
||||
credits_monthly: 700,
|
||||
stop_usd: 332
|
||||
}
|
||||
}
|
||||
|
||||
const { initialize, isTeamPlan } = useBillingContext()
|
||||
await initialize()
|
||||
|
||||
expect(isTeamPlan.value).toBe(true)
|
||||
})
|
||||
|
||||
it('stays true for a legacy team plan whose payment failed', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
mockBillingStatus.value = {
|
||||
is_active: false,
|
||||
has_funds: true,
|
||||
billing_status: 'payment_failed',
|
||||
subscription_tier: 'STANDARD',
|
||||
plan_slug: 'team-standard-annual'
|
||||
}
|
||||
|
||||
const { initialize, isTeamPlan } = useBillingContext()
|
||||
await initialize()
|
||||
|
||||
expect(isTeamPlan.value).toBe(true)
|
||||
})
|
||||
|
||||
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: 'PRO',
|
||||
plan_slug: 'pro-monthly'
|
||||
}
|
||||
|
||||
const { initialize, isTeamPlan } = useBillingContext()
|
||||
await initialize()
|
||||
|
||||
expect(isTeamPlan.value).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
getTierFeatures
|
||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import { useFreeTierQuota } from '@/platform/cloud/subscription/composables/useFreeTierQuota'
|
||||
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import type {
|
||||
PreviewSubscribeOptions,
|
||||
@@ -36,8 +35,8 @@ const LEGACY_TEAM_PLAN_SLUG_PREFIX = 'team-'
|
||||
*
|
||||
* - Team workspaces disabled (OSS/Desktop): legacy billing via /customers/*
|
||||
* - Team workspaces enabled: workspace billing via /api/billing/* for team
|
||||
* workspaces, and for personal workspaces once billing control is enabled;
|
||||
* personal workspaces otherwise stay on legacy billing
|
||||
* workspaces, and for personal workspaces once consolidated billing is
|
||||
* enabled; personal workspaces otherwise stay on legacy billing
|
||||
*
|
||||
* The context automatically initializes when the workspace changes and provides
|
||||
* a unified interface for subscription status, balance, and billing actions.
|
||||
@@ -130,16 +129,6 @@ function useBillingContextInternal(): BillingContext {
|
||||
|
||||
const isFreeTier = computed(() => subscription.value?.tier === 'FREE')
|
||||
|
||||
const freeTierQuota = useFreeTierQuota()
|
||||
|
||||
const canRunWorkflows = computed(
|
||||
() =>
|
||||
isActiveSubscription.value &&
|
||||
(!isFreeTier.value ||
|
||||
!freeTierQuota.quotaEnabled.value ||
|
||||
freeTierQuota.freeTierExecutionPermitted.value)
|
||||
)
|
||||
|
||||
const isLegacyTeamPlan = computed(
|
||||
() =>
|
||||
type.value === 'workspace' &&
|
||||
@@ -152,21 +141,6 @@ function useBillingContextInternal(): BillingContext {
|
||||
false)
|
||||
)
|
||||
|
||||
// Plan identity, independent of subscription health: the per-credit Team plan
|
||||
// carries a credit stop, the retired seat-based ones a `team-` slug. Kept off
|
||||
// isActiveSubscription on purpose — paused and payment_failed both force
|
||||
// is_active=false, which is exactly when callers still need to know this is a
|
||||
// team plan.
|
||||
const isTeamPlan = computed(
|
||||
() =>
|
||||
type.value === 'workspace' &&
|
||||
(currentTeamCreditStop.value !== null ||
|
||||
(currentPlanSlug.value
|
||||
?.toLowerCase()
|
||||
.startsWith(LEGACY_TEAM_PLAN_SLUG_PREFIX) ??
|
||||
false))
|
||||
)
|
||||
|
||||
const billingStatus = computed(() =>
|
||||
toValue(activeContext.value.billingStatus)
|
||||
)
|
||||
@@ -217,9 +191,9 @@ function useBillingContextInternal(): BillingContext {
|
||||
error.value = null
|
||||
}
|
||||
|
||||
// type flips when the team-workspaces or billing-control flag resolves from
|
||||
// authenticated config, swapping the active backend. Reset then reinit on
|
||||
// every workspace-id or type change.
|
||||
// type flips when the team-workspaces or consolidated-billing flag resolves
|
||||
// from authenticated config, swapping the active backend. Reset then reinit
|
||||
// on every workspace-id or type change.
|
||||
watch(
|
||||
[() => store.activeWorkspace?.id, () => type.value],
|
||||
async ([newWorkspaceId]) => {
|
||||
@@ -323,10 +297,8 @@ function useBillingContextInternal(): BillingContext {
|
||||
isLoading,
|
||||
error,
|
||||
isActiveSubscription,
|
||||
canRunWorkflows,
|
||||
isFreeTier,
|
||||
isLegacyTeamPlan,
|
||||
isTeamPlan,
|
||||
billingStatus,
|
||||
subscriptionStatus,
|
||||
tier,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useBillingRouting } from './useBillingRouting'
|
||||
const { mockFlags, mockActiveWorkspace } = vi.hoisted(() => ({
|
||||
mockFlags: {
|
||||
teamWorkspacesEnabled: false,
|
||||
billingControlEnabled: false
|
||||
consolidatedBillingEnabled: false
|
||||
},
|
||||
mockActiveWorkspace: {
|
||||
value: null as { id: string; type: 'personal' | 'team' } | null
|
||||
@@ -30,7 +30,7 @@ const team = { id: 'w-team', type: 'team' as const }
|
||||
describe('useBillingRouting', () => {
|
||||
beforeEach(() => {
|
||||
mockFlags.teamWorkspacesEnabled = false
|
||||
mockFlags.billingControlEnabled = false
|
||||
mockFlags.consolidatedBillingEnabled = false
|
||||
mockActiveWorkspace.value = personal
|
||||
})
|
||||
|
||||
@@ -44,9 +44,9 @@ describe('useBillingRouting', () => {
|
||||
expect(shouldUseWorkspaceBilling.value).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps personal on legacy when billing control is disabled', () => {
|
||||
it('keeps personal on legacy when consolidated billing is disabled', () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
mockFlags.billingControlEnabled = false
|
||||
mockFlags.consolidatedBillingEnabled = false
|
||||
mockActiveWorkspace.value = personal
|
||||
|
||||
const { type } = useBillingRouting()
|
||||
@@ -54,9 +54,9 @@ describe('useBillingRouting', () => {
|
||||
expect(type.value).toBe('legacy')
|
||||
})
|
||||
|
||||
it('moves personal to workspace billing when billing control is enabled', () => {
|
||||
it('moves personal to workspace billing when consolidated billing is enabled', () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
mockFlags.billingControlEnabled = true
|
||||
mockFlags.consolidatedBillingEnabled = true
|
||||
mockActiveWorkspace.value = personal
|
||||
|
||||
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
|
||||
@@ -65,9 +65,9 @@ describe('useBillingRouting', () => {
|
||||
expect(shouldUseWorkspaceBilling.value).toBe(true)
|
||||
})
|
||||
|
||||
it('uses workspace billing for team workspaces regardless of billing control', () => {
|
||||
it('uses workspace billing for team workspaces regardless of consolidated billing', () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
mockFlags.billingControlEnabled = false
|
||||
mockFlags.consolidatedBillingEnabled = false
|
||||
mockActiveWorkspace.value = team
|
||||
|
||||
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
|
||||
@@ -76,9 +76,9 @@ describe('useBillingRouting', () => {
|
||||
expect(shouldUseWorkspaceBilling.value).toBe(true)
|
||||
})
|
||||
|
||||
it('uses workspace billing for team workspaces with billing control enabled', () => {
|
||||
it('uses workspace billing for team workspaces with consolidated billing enabled', () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
mockFlags.billingControlEnabled = true
|
||||
mockFlags.consolidatedBillingEnabled = true
|
||||
mockActiveWorkspace.value = team
|
||||
|
||||
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
|
||||
@@ -89,7 +89,7 @@ describe('useBillingRouting', () => {
|
||||
|
||||
it('defaults to legacy while the workspace has not loaded', () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
mockFlags.billingControlEnabled = true
|
||||
mockFlags.consolidatedBillingEnabled = true
|
||||
mockActiveWorkspace.value = null
|
||||
|
||||
const { type } = useBillingRouting()
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { BillingType } from './types'
|
||||
/**
|
||||
* Selects the billing backend for the active workspace: legacy user-scoped
|
||||
* (`/customers/*`) or workspace-scoped (`/api/billing/*`). Personal workspaces
|
||||
* stay legacy until `billingControlEnabled`; team workspaces are always
|
||||
* stay legacy until `consolidatedBillingEnabled`; team workspaces are always
|
||||
* workspace-scoped. The routing matrix is covered in useBillingRouting.test.ts.
|
||||
*/
|
||||
export function useBillingRouting() {
|
||||
@@ -23,7 +23,7 @@ export function useBillingRouting() {
|
||||
const workspaceType = workspaceStore.activeWorkspace?.type
|
||||
if (!workspaceType) return 'legacy'
|
||||
|
||||
if (workspaceType === 'personal' && !flags.billingControlEnabled) {
|
||||
if (workspaceType === 'personal' && !flags.consolidatedBillingEnabled) {
|
||||
return 'legacy'
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
import { computed, toValue } from 'vue'
|
||||
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphBadge } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
|
||||
import { useNodePricing } from '@/composables/node/useNodePricing'
|
||||
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput'
|
||||
import { trackNodePrice } from '@/renderer/extensions/vueNodes/composables/usePartitionedBadges'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { adjustColor } from '@/utils/colorUtil'
|
||||
import { mapAllNodes } from '@/utils/graphTraversalUtil'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
type LinkedWidgetInput = INodeInputSlot & {
|
||||
_subgraphSlot?: SubgraphInput
|
||||
@@ -157,20 +150,3 @@ export const usePriceBadge = () => {
|
||||
updateSubgraphCredits
|
||||
}
|
||||
}
|
||||
export const useCreditsBadgesInGraph = createSharedComposable(() => {
|
||||
const { isCreditsBadge } = usePriceBadge()
|
||||
const vueNodeLifecycle = useVueNodeLifecycle()
|
||||
return computed(() => {
|
||||
void vueNodeLifecycle.nodeManager.value?.vueNodeData.size
|
||||
if (!app.graph) return []
|
||||
return mapAllNodes(app.graph, (node) => {
|
||||
if (node.isSubgraphNode()) return
|
||||
|
||||
const priceBadge = node.badges.find(isCreditsBadge)
|
||||
if (!priceBadge) return
|
||||
|
||||
trackNodePrice(node)
|
||||
return [node.title, toValue(priceBadge).text, node.id] as const
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import {
|
||||
breakpointsTailwind,
|
||||
createSharedComposable,
|
||||
useBreakpoints
|
||||
} from '@vueuse/core'
|
||||
|
||||
/** `md`+ viewport — the width the onboarding tours need to place their coach-marks. */
|
||||
export const useDesktopLayout = createSharedComposable(() =>
|
||||
useBreakpoints(breakpointsTailwind).greaterOrEqual('md')
|
||||
)
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from '@/composables/useFeatureFlags'
|
||||
import * as distributionTypes from '@/platform/distribution/types'
|
||||
import {
|
||||
cachedBillingControlEnabled,
|
||||
cachedConsolidatedBillingEnabled,
|
||||
cachedTeamWorkspacesEnabled,
|
||||
remoteConfig,
|
||||
remoteConfigState
|
||||
@@ -226,19 +226,19 @@ describe('useFeatureFlags', () => {
|
||||
expect(flags.teamWorkspacesEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('billingControlEnabled override bypasses isCloud and isAuthenticatedConfigLoaded guards', () => {
|
||||
it('consolidatedBillingEnabled override bypasses isCloud and isAuthenticatedConfigLoaded guards', () => {
|
||||
vi.mocked(distributionTypes).isCloud = false
|
||||
localStorage.setItem('ff:billing_control_enabled', 'true')
|
||||
localStorage.setItem('ff:consolidated_billing_enabled', 'true')
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.billingControlEnabled).toBe(true)
|
||||
expect(flags.consolidatedBillingEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('billingControlEnabled is false off-cloud even without an override', () => {
|
||||
it('consolidatedBillingEnabled is false off-cloud even without an override', () => {
|
||||
vi.mocked(distributionTypes).isCloud = false
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.billingControlEnabled).toBe(false)
|
||||
expect(flags.consolidatedBillingEnabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -248,7 +248,7 @@ describe('useFeatureFlags', () => {
|
||||
remoteConfigState.value = 'unloaded'
|
||||
remoteConfig.value = {}
|
||||
cachedTeamWorkspacesEnabled.value = undefined
|
||||
cachedBillingControlEnabled.value = undefined
|
||||
cachedConsolidatedBillingEnabled.value = undefined
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
@@ -257,36 +257,36 @@ describe('useFeatureFlags', () => {
|
||||
remoteConfigState.value = 'unloaded'
|
||||
remoteConfig.value = {}
|
||||
cachedTeamWorkspacesEnabled.value = undefined
|
||||
cachedBillingControlEnabled.value = undefined
|
||||
cachedConsolidatedBillingEnabled.value = undefined
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('returns the cached session value during the auth window', () => {
|
||||
cachedTeamWorkspacesEnabled.value = false
|
||||
cachedBillingControlEnabled.value = true
|
||||
cachedConsolidatedBillingEnabled.value = true
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.teamWorkspacesEnabled).toBe(false)
|
||||
expect(flags.billingControlEnabled).toBe(true)
|
||||
expect(flags.consolidatedBillingEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('defaults to false during the auth window when nothing is cached', () => {
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.teamWorkspacesEnabled).toBe(false)
|
||||
expect(flags.billingControlEnabled).toBe(false)
|
||||
expect(flags.consolidatedBillingEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it('prefers authenticated remoteConfig over the server feature fallback', () => {
|
||||
remoteConfigState.value = 'authenticated'
|
||||
remoteConfig.value = {
|
||||
team_workspaces_enabled: true,
|
||||
billing_control_enabled: true
|
||||
consolidated_billing_enabled: true
|
||||
}
|
||||
vi.mocked(api.getServerFeature).mockReturnValue(false)
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.teamWorkspacesEnabled).toBe(true)
|
||||
expect(flags.billingControlEnabled).toBe(true)
|
||||
expect(flags.consolidatedBillingEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to api.getServerFeature when authenticated config omits the flag', () => {
|
||||
@@ -295,14 +295,15 @@ describe('useFeatureFlags', () => {
|
||||
vi.mocked(api.getServerFeature).mockImplementation(
|
||||
(path, defaultValue) => {
|
||||
if (path === ServerFeatureFlag.TEAM_WORKSPACES_ENABLED) return true
|
||||
if (path === ServerFeatureFlag.BILLING_CONTROL_ENABLED) return true
|
||||
if (path === ServerFeatureFlag.CONSOLIDATED_BILLING_ENABLED)
|
||||
return true
|
||||
return defaultValue
|
||||
}
|
||||
)
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.teamWorkspacesEnabled).toBe(true)
|
||||
expect(flags.billingControlEnabled).toBe(true)
|
||||
expect(flags.consolidatedBillingEnabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Ref } from 'vue'
|
||||
|
||||
import { isCloud, isNightly } from '@/platform/distribution/types'
|
||||
import {
|
||||
cachedBillingControlEnabled,
|
||||
cachedConsolidatedBillingEnabled,
|
||||
cachedTeamWorkspacesEnabled,
|
||||
isAuthenticatedConfigLoaded,
|
||||
remoteConfig
|
||||
@@ -32,10 +32,8 @@ export enum ServerFeatureFlag {
|
||||
COMFYHUB_PROFILE_GATE_ENABLED = 'comfyhub_profile_gate_enabled',
|
||||
SHOW_SIGNIN_BUTTON = 'show_signin_button',
|
||||
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth',
|
||||
BILLING_CONTROL_ENABLED = 'billing_control_enabled',
|
||||
FREE_TIER_JOB_ALLOWANCE_ENABLED = 'free_tier_job_allowance_enabled',
|
||||
SIGNUP_TURNSTILE = 'signup_turnstile',
|
||||
ONBOARDING_TOUR_ENABLED = 'onboarding_tour_enabled'
|
||||
CONSOLIDATED_BILLING_ENABLED = 'consolidated_billing_enabled',
|
||||
SIGNUP_TURNSTILE = 'signup_turnstile'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -193,25 +191,15 @@ export function useFeatureFlags() {
|
||||
)
|
||||
},
|
||||
/**
|
||||
* Whether personal workspaces use the workspace-scoped billing flow. While
|
||||
* false (default), personal workspaces stay on the legacy per-user billing
|
||||
* flow; team workspaces are unaffected.
|
||||
* Whether personal workspaces use the consolidated (workspace-scoped)
|
||||
* billing flow. While false (default), personal workspaces stay on the
|
||||
* legacy per-user billing flow; team workspaces are unaffected.
|
||||
*/
|
||||
get billingControlEnabled() {
|
||||
get consolidatedBillingEnabled() {
|
||||
return resolveAuthGatedFlag(
|
||||
ServerFeatureFlag.BILLING_CONTROL_ENABLED,
|
||||
remoteConfig.value.billing_control_enabled,
|
||||
cachedBillingControlEnabled
|
||||
)
|
||||
},
|
||||
get freeTierJobAllowanceEnabled() {
|
||||
const config = remoteConfig.value as typeof remoteConfig.value & {
|
||||
free_tier_job_allowance_enabled?: boolean
|
||||
}
|
||||
return resolveFlag(
|
||||
ServerFeatureFlag.FREE_TIER_JOB_ALLOWANCE_ENABLED,
|
||||
config.free_tier_job_allowance_enabled,
|
||||
false
|
||||
ServerFeatureFlag.CONSOLIDATED_BILLING_ENABLED,
|
||||
remoteConfig.value.consolidated_billing_enabled,
|
||||
cachedConsolidatedBillingEnabled
|
||||
)
|
||||
},
|
||||
get signupTurnstileMode() {
|
||||
@@ -220,13 +208,6 @@ export function useFeatureFlags() {
|
||||
remoteConfig.value.signup_turnstile,
|
||||
'off'
|
||||
)
|
||||
},
|
||||
get onboardingTourEnabled() {
|
||||
return resolveFlag(
|
||||
ServerFeatureFlag.ONBOARDING_TOUR_ENABLED,
|
||||
remoteConfig.value.onboarding_tour_enabled,
|
||||
false
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -2317,91 +2317,6 @@
|
||||
"member": "Member"
|
||||
}
|
||||
},
|
||||
"onboardingTour": {
|
||||
"overlayLabel": "Getting started tour",
|
||||
"preparing": "Building your template…",
|
||||
"skip": "Skip",
|
||||
"back": "Back",
|
||||
"next": "Next",
|
||||
"complete": "Finish",
|
||||
"stepCounter": "{current} of {total}",
|
||||
"step": {
|
||||
"upload": {
|
||||
"t2i": {
|
||||
"title": "Start with an image",
|
||||
"body": "This image feeds the workflow. Swap in your own whenever you like."
|
||||
},
|
||||
"i2v": {
|
||||
"title": "Start with an image",
|
||||
"body": "This picture is your first frame. Swap in your own whenever you like."
|
||||
},
|
||||
"image-edit": {
|
||||
"title": "Start with an image",
|
||||
"body": "This is the picture you'll edit. Swap in your own whenever you like."
|
||||
},
|
||||
"other": {
|
||||
"title": "Start with an image",
|
||||
"body": "This image feeds the workflow. Swap in your own whenever you like."
|
||||
}
|
||||
},
|
||||
"prompt": {
|
||||
"t2i": {
|
||||
"title": "Describe what you want",
|
||||
"body": "Your image gets built from this description. Try anything you like."
|
||||
},
|
||||
"i2v": {
|
||||
"title": "Describe how it should move",
|
||||
"body": "The image sets the scene. This tells it what happens next."
|
||||
},
|
||||
"image-edit": {
|
||||
"title": "Say what to change",
|
||||
"body": "Your image stays as it is. Describe what you want different."
|
||||
},
|
||||
"other": {
|
||||
"title": "Tell it what to make",
|
||||
"body": "This is your prompt. Change it to change your result."
|
||||
}
|
||||
},
|
||||
"run": {
|
||||
"title": "Run your workflow",
|
||||
"body": "Press Run to start generating your result"
|
||||
},
|
||||
"result": {
|
||||
"image": {
|
||||
"title": "And there it is",
|
||||
"body": "Your new image lands right here — ready to download or share."
|
||||
},
|
||||
"video": {
|
||||
"title": "And there it is",
|
||||
"body": "Your new video lands right here — ready to download or share."
|
||||
}
|
||||
}
|
||||
},
|
||||
"generating": "Generating…",
|
||||
"gettingStarted": {
|
||||
"title": "Get started with graph",
|
||||
"subtitle": "Start a workflow from scratch or load one that's ready to go.",
|
||||
"loadFailed": "Failed to load templates",
|
||||
"screenLabel": "Get started with graph",
|
||||
"tabsLabel": "Getting started options",
|
||||
"tabs": {
|
||||
"templates": "Templates",
|
||||
"tutorials": "Tutorials"
|
||||
},
|
||||
"tutorials": {
|
||||
"interfaceOverview": "Interface overview",
|
||||
"textToImage": "Text to image",
|
||||
"imageToImage": "Image to image",
|
||||
"inpaint": "Inpainting"
|
||||
}
|
||||
},
|
||||
"nudge": {
|
||||
"title": "That was one of hundreds",
|
||||
"body": "You just made your first. Explore what else you can build.",
|
||||
"dismiss": "Not now",
|
||||
"explore": "Explore templates"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"apiKey": {
|
||||
"title": "API Key",
|
||||
@@ -2558,11 +2473,6 @@
|
||||
},
|
||||
"credits": {
|
||||
"activity": "Activity",
|
||||
"insufficient": {
|
||||
"memberTitle": "This workspace is out of credits",
|
||||
"memberDescription": "Your team has used all its credits. Your workspace admins need to add more credits to run workflows.",
|
||||
"memberCta": "Ok, got it"
|
||||
},
|
||||
"credits": "Credits",
|
||||
"yourCreditBalance": "Your credit balance",
|
||||
"purchaseCredits": "Purchase Credits",
|
||||
@@ -2972,32 +2882,6 @@
|
||||
"updatePassword": "Update Password"
|
||||
},
|
||||
"workspacePanel": {
|
||||
"billingStatus": {
|
||||
"warning": {
|
||||
"title": "Payment declined",
|
||||
"body": "Your last payment didn't go through. Your subscription will pause on {date} unless payment is updated.",
|
||||
"bodyNoDate": "Your last payment didn't go through. Update payment to avoid a pause."
|
||||
},
|
||||
"paused": {
|
||||
"title": "Subscription paused",
|
||||
"body": "This workspace's subscription is paused. Update payment to resume.",
|
||||
"memberBody": "This workspace's subscription is paused. Your workspace admins need to update the payment method."
|
||||
},
|
||||
"outOfCredits": {
|
||||
"title": "Out of credits",
|
||||
"body": "Your team has used all its credits. Add more credits to continue generating or wait until credits refill on {date}.",
|
||||
"bodyNoDate": "Your team has used all its credits. Add more credits to continue generating.",
|
||||
"memberBody": "Your team has used all its credits. Your workspace admins need to add more credits to continue generating.",
|
||||
"addCredits": "Add credits",
|
||||
"dismiss": "Dismiss"
|
||||
},
|
||||
"ending": {
|
||||
"title": "Your team plan ends on {date}",
|
||||
"body": "Members keep full access until then. Reactivate to keep your shared credits and seats.",
|
||||
"reactivate": "Reactivate plan"
|
||||
},
|
||||
"updatePayment": "Update payment"
|
||||
},
|
||||
"invite": "Invite",
|
||||
"inviteMember": "Invite member",
|
||||
"inviteLimitReached": "You've reached the maximum of {count} members",
|
||||
@@ -3672,9 +3556,6 @@
|
||||
"dockToTop": "Dock to top",
|
||||
"feedback": "Feedback",
|
||||
"feedbackTooltip": "Feedback",
|
||||
"freeTierRuns": "{available} / {MAX_AVAILABLE} runs left",
|
||||
"freeTierRunsExhausted": "No runs left",
|
||||
"freeTierPartner": "Partner nodes need a paid plan",
|
||||
"share": "Share",
|
||||
"shareTooltip": "Share workflow"
|
||||
},
|
||||
@@ -4667,37 +4548,5 @@
|
||||
"training": "Training…",
|
||||
"processingVideo": "Processing video…",
|
||||
"running": "Running…"
|
||||
},
|
||||
"onboardingCoachmarks": {
|
||||
"stepLabel": "Step {current} of {total}",
|
||||
"skip": "Skip",
|
||||
"next": "Next",
|
||||
"back": "Back",
|
||||
"done": "Done",
|
||||
"loadError": "Something went wrong showing this tour",
|
||||
"appMode": {
|
||||
"replay": "Take a tour of App Mode",
|
||||
"landing": {
|
||||
"title": "Welcome to Apps",
|
||||
"body": "A quick tour of the essentials, in about a minute. We'll show you where to add inputs, run your app, and find your results.",
|
||||
"primary": "Start tutorial"
|
||||
},
|
||||
"inputs": {
|
||||
"title": "Add your inputs",
|
||||
"body": "Add what you want to work with. Your inputs are what the app turns into results."
|
||||
},
|
||||
"run": {
|
||||
"title": "Run your app",
|
||||
"body": "Happy with your inputs? Hit Run and your result appears in the center the moment it's ready."
|
||||
},
|
||||
"outputs": {
|
||||
"title": "Get your results",
|
||||
"body": "Your finished results show up here in the center. Download them, or tweak an input and run again."
|
||||
},
|
||||
"assets": {
|
||||
"title": "Find all your assets",
|
||||
"body": "Every generation and import lives in Media Assets. Open it anytime to browse, download, or reuse past work."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
35
src/locales/escapeNodeDefI18n.test.ts
Normal file
35
src/locales/escapeNodeDefI18n.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { escapeVueI18nMessageSyntax } from '@comfyorg/shared-frontend-utils/formatUtil'
|
||||
|
||||
/**
|
||||
* Node descriptions are compiled by vue-i18n via `t()`/`st()`, which parses
|
||||
* `@ { } | %` as message syntax — a literal `@` even crashes the compiler with
|
||||
* `Invalid linked format` (this broke the whole app after the 1.47.7 locale
|
||||
* sync). `collect-i18n-node-defs.ts` escapes such values with
|
||||
* `escapeVueI18nMessageSyntax` before writing them; this guards that the escaped
|
||||
* output actually compiles and renders the original literal text.
|
||||
*/
|
||||
describe('escapeVueI18nMessageSyntax output is compiled safely by vue-i18n', () => {
|
||||
const compile = (message: string) => {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: { value: message } }
|
||||
})
|
||||
return i18n.global.t('value')
|
||||
}
|
||||
|
||||
it.for([
|
||||
'clips (tagged @Audio1-3 in the prompt)',
|
||||
'support@comfy.org',
|
||||
'resolution {width}x{height}',
|
||||
'foreground | background',
|
||||
'50%{done}',
|
||||
'all of @ { } | % together',
|
||||
'no special chars here'
|
||||
])('renders %s as the original literal text', (raw) => {
|
||||
expect(compile(escapeVueI18nMessageSyntax(raw))).toBe(raw)
|
||||
})
|
||||
})
|
||||
@@ -1,65 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useFreeTierQuota } from '@/platform/cloud/subscription/composables/useFreeTierQuota'
|
||||
|
||||
const DOT_COLORS = [
|
||||
'bg-destructive-background',
|
||||
'bg-warning-background',
|
||||
'bg-success-background'
|
||||
]
|
||||
|
||||
const { showSubscriptionDialog } = useBillingContext()
|
||||
const { t } = useI18n()
|
||||
const { available, hasInvalidNodes, maxAvailable, quotaEnabled } =
|
||||
useFreeTierQuota()
|
||||
|
||||
const dotColor = computed(() => {
|
||||
const ratio = maxAvailable.value ? available.value / maxAvailable.value : 0
|
||||
return DOT_COLORS[
|
||||
Math.min(Math.floor(ratio * DOT_COLORS.length), DOT_COLORS.length - 1)
|
||||
]
|
||||
})
|
||||
const label = computed(() =>
|
||||
available.value === 0
|
||||
? t('actionbar.freeTierRunsExhausted')
|
||||
: t('actionbar.freeTierRuns', {
|
||||
available: available.value,
|
||||
MAX_AVAILABLE: maxAvailable.value
|
||||
})
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
v-if="quotaEnabled"
|
||||
class="mt-2 w-full cursor-pointer border-t border-border-subtle bg-comfy-menu-bg px-4 pt-2 select-none"
|
||||
data-testid="free-tier-quota"
|
||||
@click="showSubscriptionDialog({ reason: 'free_tier_quota' })"
|
||||
>
|
||||
<div
|
||||
v-if="hasInvalidNodes"
|
||||
class="flex w-full items-center justify-center gap-2"
|
||||
>
|
||||
<i class="icon-[comfy--credits] bg-amber-400" />
|
||||
{{ t('actionbar.freeTierPartner') }}
|
||||
</div>
|
||||
<div v-else class="flex w-full items-center justify-between">
|
||||
<div class="flex gap-2" :aria-label="label" role="img">
|
||||
<div
|
||||
v-for="index in maxAvailable"
|
||||
:key="index"
|
||||
:class="
|
||||
cn(
|
||||
'size-1.5 rounded-full',
|
||||
index > available ? 'bg-secondary-background-selected' : dotColor
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div v-text="label" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -8,19 +8,10 @@ const mockDialogService = {
|
||||
showTopUpCreditsDialog: vi.fn()
|
||||
}
|
||||
|
||||
const mockBilling = {
|
||||
fetchStatus: vi.fn(),
|
||||
fetchBalance: vi.fn()
|
||||
}
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: vi.fn(() => mockDialogService)
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: vi.fn(() => mockBilling)
|
||||
}))
|
||||
|
||||
describe('useAccountPreconditionDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -64,18 +55,4 @@ describe('useAccountPreconditionDialog', () => {
|
||||
mockDialogService.showSubscriptionRequiredDialog
|
||||
).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refreshes the billing snapshot on a credit precondition so exhausted-state surfaces converge', () => {
|
||||
useAccountPreconditionDialog().open('credits')
|
||||
|
||||
expect(mockBilling.fetchStatus).toHaveBeenCalledTimes(1)
|
||||
expect(mockBilling.fetchBalance).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not touch billing state for non-credit preconditions', () => {
|
||||
useAccountPreconditionDialog().open('subscription')
|
||||
|
||||
expect(mockBilling.fetchStatus).not.toHaveBeenCalled()
|
||||
expect(mockBilling.fetchBalance).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import type { AccountPrecondition } from '@/platform/errorCatalog/accountPreconditionRouting'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
|
||||
@@ -29,19 +28,11 @@ export function useAccountPreconditionDialog() {
|
||||
reason: 'subscription_required'
|
||||
})
|
||||
return
|
||||
case 'credits': {
|
||||
// The server just declared the balance exhausted; there is no push or
|
||||
// polling for billing state, so refresh it here to converge
|
||||
// hasFunds-keyed surfaces such as the credits-exhausted banner. The
|
||||
// refresh is best-effort: allSettled keeps a flaky billing API from
|
||||
// surfacing as unhandled rejections.
|
||||
const { fetchStatus, fetchBalance } = useBillingContext()
|
||||
void Promise.allSettled([fetchStatus(), fetchBalance()])
|
||||
case 'credits':
|
||||
void dialogService.showTopUpCreditsDialog({
|
||||
isInsufficientCredits: true
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { useCreditsBadgesInGraph } from '@/composables/node/usePriceBadge'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
|
||||
export const useFreeTierQuota = createSharedComposable(function () {
|
||||
const { flags } = useFeatureFlags()
|
||||
const creditsBadges = useCreditsBadgesInGraph()
|
||||
|
||||
const available = ref(0)
|
||||
const maxAvailable = ref(0)
|
||||
watch(
|
||||
() => remoteConfig.value.free_tier_balance?.remaining,
|
||||
(val) => (available.value = val ?? 0),
|
||||
{ immediate: true }
|
||||
)
|
||||
watch(
|
||||
() => remoteConfig.value.free_tier_balance?.allowance,
|
||||
(val) => (maxAvailable.value = val ?? 0),
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const quotaEnabled = computed(
|
||||
() => flags.freeTierJobAllowanceEnabled && maxAvailable.value > 0
|
||||
)
|
||||
const hasInvalidNodes = computed(() => creditsBadges.value.length > 0)
|
||||
const freeTierExecutionPermitted = computed(
|
||||
() => !hasInvalidNodes.value && quotaEnabled.value && available.value > 0
|
||||
)
|
||||
|
||||
function trackRun() {
|
||||
if (available.value > 0) available.value--
|
||||
}
|
||||
|
||||
return {
|
||||
available,
|
||||
freeTierExecutionPermitted,
|
||||
hasInvalidNodes,
|
||||
maxAvailable,
|
||||
quotaEnabled,
|
||||
trackRun
|
||||
}
|
||||
})
|
||||
@@ -266,21 +266,6 @@ describe('useSubscriptionDialog', () => {
|
||||
expect(mockTrackSubscription).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows the read-only member dialog for out-of-credits too, not the pricing table', () => {
|
||||
mockShouldUseWorkspaceBilling.value = true
|
||||
mockIsInPersonalWorkspace.value = false
|
||||
mockCanManageSubscription.value = false
|
||||
const { showPricingTable } = useSubscriptionDialog()
|
||||
|
||||
showPricingTable({ reason: 'out_of_credits' })
|
||||
|
||||
expect(mockShowLayoutDialog).toHaveBeenCalledTimes(1)
|
||||
const props = mockShowLayoutDialog.mock.calls[0][0].props
|
||||
expect(props).toHaveProperty('onClose')
|
||||
expect(props).not.toHaveProperty('reason')
|
||||
expect(props).not.toHaveProperty('initialPlanMode')
|
||||
})
|
||||
|
||||
it('does not track on non-cloud', () => {
|
||||
mockIsCloud.value = false
|
||||
const { showPricingTable } = useSubscriptionDialog()
|
||||
|
||||
@@ -13,9 +13,6 @@ const DIALOG_KEY = 'subscription-required'
|
||||
const FREE_TIER_DIALOG_KEY = 'free-tier-info'
|
||||
const RESUME_PRICING_KEY = 'comfy:resume-team-pricing'
|
||||
|
||||
/** Dialog keys the subscription/upgrade flow opens; callers gate around these. */
|
||||
export const UPGRADE_DIALOG_KEYS = [FREE_TIER_DIALOG_KEY, DIALOG_KEY] as const
|
||||
|
||||
export interface SubscriptionDialogOptions {
|
||||
reason?: PaymentIntentSource
|
||||
/**
|
||||
@@ -58,12 +55,12 @@ export const useSubscriptionDialog = () => {
|
||||
|
||||
// Members can't manage the workspace subscription, so a blocked run shows a
|
||||
// small read-only "ask your owner to reactivate" modal instead of the
|
||||
// pricing table — including out-of-credits, whose member recovery path is
|
||||
// also owner-only (FE-1246).
|
||||
// pricing table. Out-of-credits still routes everyone to the credits flow.
|
||||
if (
|
||||
shouldUseWorkspaceBilling.value &&
|
||||
!workspaceStore.isInPersonalWorkspace &&
|
||||
!permissions.value.canManageSubscription
|
||||
!permissions.value.canManageSubscription &&
|
||||
options?.reason !== 'out_of_credits'
|
||||
) {
|
||||
dialogService.showLayoutDialog({
|
||||
key: DIALOG_KEY,
|
||||
@@ -91,9 +88,9 @@ export const useSubscriptionDialog = () => {
|
||||
} as const
|
||||
|
||||
// Jun-5 model: a single unified pricing table (personal/team plan toggle on
|
||||
// one workspace) for workspaces on the workspace-scoped billing flow.
|
||||
// Replaces the old personal-vs-team workspace fork. Personal workspaces
|
||||
// still on the legacy flow (billing control disabled) get the legacy table.
|
||||
// one workspace) for workspaces on the consolidated billing flow. Replaces
|
||||
// the old personal-vs-team workspace fork. Personal workspaces still on the
|
||||
// legacy flow (consolidated billing disabled) get the legacy table.
|
||||
if (shouldUseWorkspaceBilling.value) {
|
||||
// Existing per-member (legacy) team subscribers keep the old tier-based
|
||||
// team table; the unified credit-slider table is for everyone else.
|
||||
|
||||
@@ -73,33 +73,6 @@ describe('resolveAccountPrecondition', () => {
|
||||
).toBe('credits')
|
||||
})
|
||||
|
||||
it('classifies the submit-time 402 body by its insufficient_credits type regardless of message', () => {
|
||||
expect(
|
||||
resolveAccountPrecondition({
|
||||
exceptionType: 'insufficient_credits',
|
||||
exceptionMessage: 'Workspace balance exhausted'
|
||||
})
|
||||
).toBe('credits')
|
||||
})
|
||||
|
||||
it('classifies the team submit-time 429 (PAYMENT_REQUIRED / insufficient credits) as a credits precondition', () => {
|
||||
expect(
|
||||
resolveAccountPrecondition({
|
||||
exceptionType: 'PAYMENT_REQUIRED',
|
||||
exceptionMessage: 'Insufficient credits to queue workflows'
|
||||
})
|
||||
).toBe('credits')
|
||||
})
|
||||
|
||||
it('keeps the team submit-time 429 for an inactive subscription on the subscription precondition', () => {
|
||||
expect(
|
||||
resolveAccountPrecondition({
|
||||
exceptionType: 'PAYMENT_REQUIRED',
|
||||
exceptionMessage: 'Subscription required to queue workflows'
|
||||
})
|
||||
).toBe('subscription')
|
||||
})
|
||||
|
||||
it('returns undefined for an ordinary workflow error', () => {
|
||||
expect(
|
||||
resolveAccountPrecondition({
|
||||
|
||||
@@ -33,13 +33,7 @@ const INSUFFICIENT_CREDITS_MESSAGES = new Set([
|
||||
'Payment Required: Please add credits to your account to use this node.'
|
||||
])
|
||||
const WORKSPACE_INSUFFICIENT_CREDITS_MESSAGES = new Set([
|
||||
// Execution-time (pre-GPU) WebSocket failure for a queued team job.
|
||||
'Payment Required: Please add credits to your workspace to continue.',
|
||||
// Submit-time 429 rejection for a team workspace out of credits
|
||||
// (checkTeamWorkspaceSubscription). It shares the PAYMENT_REQUIRED error type
|
||||
// with the subscription-required rejection, so it can only be told apart by
|
||||
// message.
|
||||
'Insufficient credits to queue workflows'
|
||||
'Payment Required: Please add credits to your workspace to continue.'
|
||||
])
|
||||
const SUBSCRIPTION_REQUIRED_MESSAGES = new Set([
|
||||
'Workspace has no active subscription. Please subscribe to a plan to continue.',
|
||||
@@ -249,12 +243,8 @@ const RUNTIME_MATCH_RULES: RuntimeMatchRule[] = [
|
||||
resolve: () => catalogMatch(WORKSPACE_INSUFFICIENT_CREDITS_CATALOG_ID)
|
||||
},
|
||||
{
|
||||
// 'insufficient_credits' is the error.type BE-2866 will emit on the
|
||||
// personal submit-time 402; the team submit path is a 429 matched by
|
||||
// message in WORKSPACE_INSUFFICIENT_CREDITS_MESSAGES above.
|
||||
matches: (info, message) =>
|
||||
info.exceptionType === 'InsufficientFundsError' ||
|
||||
info.exceptionType === 'insufficient_credits' ||
|
||||
INSUFFICIENT_CREDITS_MESSAGES.has(message),
|
||||
resolve: () => catalogMatch(INSUFFICIENT_CREDITS_CATALOG_ID)
|
||||
},
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import { cleanup, render, screen } from '@testing-library/vue'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { h } from 'vue'
|
||||
|
||||
import CoachmarkCard from './CoachmarkCard.vue'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('CoachmarkCard', () => {
|
||||
it('renders the title, message and subtitle', () => {
|
||||
render(CoachmarkCard, {
|
||||
props: {
|
||||
title: 'This is your canvas',
|
||||
message: 'Scroll to zoom.',
|
||||
subtitle: 'Step 1 of 3'
|
||||
}
|
||||
})
|
||||
expect(screen.getByRole('heading')).toHaveTextContent('This is your canvas')
|
||||
expect(screen.getByText('Scroll to zoom.')).toBeTruthy()
|
||||
expect(screen.getByText('Step 1 of 3')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('applies titleId to the heading for aria-labelledby wiring', () => {
|
||||
render(CoachmarkCard, {
|
||||
props: { title: 'Heading', message: 'M', titleId: 'title-1' }
|
||||
})
|
||||
expect(screen.getByRole('heading').id).toBe('title-1')
|
||||
})
|
||||
|
||||
it('applies messageId to the message for aria-describedby wiring', () => {
|
||||
render(CoachmarkCard, {
|
||||
props: { title: 'T', message: 'Body copy', messageId: 'desc-1' }
|
||||
})
|
||||
expect(screen.getByText('Body copy').id).toBe('desc-1')
|
||||
})
|
||||
|
||||
it('omits the subtitle when not provided', () => {
|
||||
render(CoachmarkCard, { props: { title: 'T', message: 'M' } })
|
||||
expect(screen.queryByText('Step 1 of 3')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders the image when an image src is given', () => {
|
||||
render(CoachmarkCard, {
|
||||
props: { title: 'T', message: 'M', image: '/foo.png' }
|
||||
})
|
||||
expect(screen.getByAltText('')).toHaveAttribute('src', '/foo.png')
|
||||
})
|
||||
|
||||
it('renders an image slot in place of the default image', () => {
|
||||
render(CoachmarkCard, {
|
||||
props: { title: 'T', message: 'M' },
|
||||
slots: { image: () => h('img', { src: '/slot.png', alt: 'preview' }) }
|
||||
})
|
||||
expect(screen.getByRole('img', { name: 'preview' })).toHaveAttribute(
|
||||
'src',
|
||||
'/slot.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('renders the actions slot', () => {
|
||||
render(CoachmarkCard, {
|
||||
props: { title: 'T', message: 'M' },
|
||||
slots: { actions: () => h('button', 'Next') }
|
||||
})
|
||||
expect(screen.getByRole('button', { name: 'Next' })).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -1,50 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full flex-col items-start justify-center gap-3 rounded-2xl bg-secondary-background p-4 drop-shadow-[1px_1px_8px_rgba(0,0,0,0.4)]"
|
||||
>
|
||||
<div
|
||||
v-if="image || $slots.image"
|
||||
class="flex h-[146px] flex-col items-start justify-center gap-4 self-stretch overflow-hidden rounded-xl bg-base-background"
|
||||
>
|
||||
<slot name="image">
|
||||
<img v-if="image" :src="image" alt="" class="size-full object-cover" />
|
||||
</slot>
|
||||
</div>
|
||||
<div class="flex flex-col items-end justify-end gap-6 self-stretch">
|
||||
<div class="flex flex-col items-start gap-2 self-stretch">
|
||||
<p
|
||||
v-if="subtitle"
|
||||
:id="subtitleId"
|
||||
class="m-0 text-xs/normal text-base-foreground"
|
||||
>
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
<h3
|
||||
:id="titleId"
|
||||
class="m-0 text-base/normal font-semibold text-base-foreground"
|
||||
>
|
||||
{{ title }}
|
||||
</h3>
|
||||
<p :id="messageId" class="m-0 text-sm/normal text-muted-foreground">
|
||||
{{ message }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="$slots.actions" class="flex w-full items-center gap-3">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { title, titleId, message, subtitle, subtitleId, image, messageId } =
|
||||
defineProps<{
|
||||
title: string
|
||||
titleId?: string
|
||||
message: string
|
||||
subtitle?: string
|
||||
subtitleId?: string
|
||||
image?: string
|
||||
messageId?: string
|
||||
}>()
|
||||
</script>
|
||||
@@ -1,67 +0,0 @@
|
||||
import { cleanup, render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
import CoachmarkLanding from './CoachmarkLanding.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
function renderLanding() {
|
||||
return render(CoachmarkLanding, {
|
||||
props: {
|
||||
title: 'Welcome to Apps',
|
||||
message: 'A quick tour of the essentials.',
|
||||
primaryLabel: 'Start tutorial',
|
||||
skipLabel: 'Skip',
|
||||
waitingForTarget: false
|
||||
},
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
}
|
||||
|
||||
describe('CoachmarkLanding', () => {
|
||||
afterEach(cleanup)
|
||||
|
||||
it('renders a modal backdrop behind the landing card', async () => {
|
||||
renderLanding()
|
||||
expect(await screen.findByTestId('coach-landing-overlay')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders the title and message', async () => {
|
||||
renderLanding()
|
||||
expect(await screen.findByText('Welcome to Apps')).toBeTruthy()
|
||||
expect(screen.getByText('A quick tour of the essentials.')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('emits start when the primary action is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { emitted } = renderLanding()
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: 'Start tutorial' })
|
||||
)
|
||||
expect(emitted().start).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('emits skip when Skip is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { emitted } = renderLanding()
|
||||
await user.click(await screen.findByRole('button', { name: 'Skip' }))
|
||||
expect(emitted().skip).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('emits skip when Escape is pressed', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { emitted } = renderLanding()
|
||||
await screen.findByText('Welcome to Apps')
|
||||
await user.keyboard('{Escape}')
|
||||
// The explicit listener and Reka's own dismiss may both fire here.
|
||||
expect(emitted().skip?.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
@@ -1,120 +0,0 @@
|
||||
<template>
|
||||
<!-- The Dialog wrapper boolean-casts an absent `modal` to false, which
|
||||
suppresses the DialogOverlay backdrop; it must be passed explicitly. -->
|
||||
<Dialog :open="true" modal @update:open="(value) => !value && emit('skip')">
|
||||
<DialogPortal>
|
||||
<DialogOverlay
|
||||
v-reka-z-index
|
||||
data-testid="coach-landing-overlay"
|
||||
class="bg-coach-scrim"
|
||||
/>
|
||||
<DialogContent
|
||||
v-reka-z-index
|
||||
data-testid="coach-landing"
|
||||
class="w-[800px] max-w-[calc(100vw-2.5rem)] overflow-hidden rounded-2xl border-border-default bg-secondary-background p-0 shadow-[0_24px_80px_rgba(0,0,0,0.85)] md:h-fit md:min-h-100 md:max-w-[800px] md:flex-row"
|
||||
@pointer-down-outside.prevent
|
||||
@open-auto-focus="onOpenAutoFocus"
|
||||
>
|
||||
<DialogClose as-child>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
:aria-label="t('g.close')"
|
||||
class="absolute top-3 right-3 z-20"
|
||||
>
|
||||
<i class="icon-[lucide--x]" />
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<div
|
||||
class="flex aspect-video items-center justify-center bg-base-background md:aspect-auto md:w-1/2"
|
||||
>
|
||||
<img
|
||||
v-if="image"
|
||||
:src="image"
|
||||
alt=""
|
||||
class="size-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col items-start justify-between self-stretch px-15 pt-15 pb-10 md:w-1/2"
|
||||
>
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<DialogTitle
|
||||
class="m-0 text-[28px] leading-normal font-medium text-base-foreground"
|
||||
>
|
||||
{{ title }}
|
||||
</DialogTitle>
|
||||
<DialogDescription
|
||||
class="flex flex-col items-start gap-2 self-stretch text-base/5 text-muted-foreground"
|
||||
>
|
||||
{{ message }}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<div class="flex w-full items-center justify-end gap-2">
|
||||
<Button variant="secondary" size="lg" @click="emit('skip')">
|
||||
{{ skipLabel }}
|
||||
</Button>
|
||||
<Button
|
||||
ref="startButtonRef"
|
||||
variant="inverted"
|
||||
size="lg"
|
||||
class="grow"
|
||||
:disabled="waitingForTarget"
|
||||
@click="emit('start')"
|
||||
>
|
||||
{{ primaryLabel }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Dialog from '@/components/ui/dialog/Dialog.vue'
|
||||
import DialogClose from '@/components/ui/dialog/DialogClose.vue'
|
||||
import DialogContent from '@/components/ui/dialog/DialogContent.vue'
|
||||
import DialogDescription from '@/components/ui/dialog/DialogDescription.vue'
|
||||
import DialogOverlay from '@/components/ui/dialog/DialogOverlay.vue'
|
||||
import DialogPortal from '@/components/ui/dialog/DialogPortal.vue'
|
||||
import DialogTitle from '@/components/ui/dialog/DialogTitle.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { vRekaZIndex } from '@/components/dialog/vRekaZIndex'
|
||||
|
||||
const { title, message, image, primaryLabel, skipLabel, waitingForTarget } =
|
||||
defineProps<{
|
||||
title: string
|
||||
message: string
|
||||
image?: string
|
||||
primaryLabel: string
|
||||
skipLabel: string
|
||||
waitingForTarget: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
start: []
|
||||
skip: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// The global keybinding handler preventDefaults Escape before Reka's
|
||||
// DismissableLayer sees it, so Reka skips its own dismiss; do it explicitly.
|
||||
useEventListener(document, 'keydown', (e) => {
|
||||
if (e.key === 'Escape') emit('skip')
|
||||
})
|
||||
|
||||
const startButtonRef = useTemplateRef('startButtonRef')
|
||||
|
||||
// Land focus on the primary action, not the close button Reka would pick.
|
||||
function onOpenAutoFocus(event: Event) {
|
||||
event.preventDefault()
|
||||
const el = startButtonRef.value?.$el as HTMLButtonElement | undefined
|
||||
el?.focus()
|
||||
}
|
||||
</script>
|
||||
@@ -1,127 +0,0 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { cleanup, render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, h, reactive, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
import TourOverlay from './TourOverlay.vue'
|
||||
import type { CoachStep, EntryPath } from './onboardingTours'
|
||||
import { useOnboardingTourStore } from './onboardingTourStore'
|
||||
|
||||
vi.mock('./onboardingTourStore', () => ({ useOnboardingTourStore: vi.fn() }))
|
||||
|
||||
function makeTourState() {
|
||||
return {
|
||||
activeTour: ref<EntryPath>('appMode'),
|
||||
step: ref<CoachStep | null>(null),
|
||||
title: ref('Canvas title'),
|
||||
body: ref('Canvas body'),
|
||||
isLast: ref(false),
|
||||
primaryLabel: ref('Next'),
|
||||
skipLabel: ref('Skip'),
|
||||
countedStepIdx: ref(0),
|
||||
countedStepsTotal: ref(0),
|
||||
waitingForTarget: ref(false),
|
||||
next: vi.fn(),
|
||||
skip: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
// Stubbed so the suite covers only TourOverlay's branching and intent wiring.
|
||||
vi.mock('./TourSpotlight.vue', () => ({
|
||||
default: defineComponent({
|
||||
emits: ['advance', 'skip'],
|
||||
setup(_, { emit }) {
|
||||
return () =>
|
||||
h('div', { 'data-testid': 'spotlight' }, [
|
||||
h('button', { onClick: () => emit('advance') }, 'advance'),
|
||||
h('button', { onClick: () => emit('skip') }, 'skip')
|
||||
])
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
let s: ReturnType<typeof makeTourState>
|
||||
|
||||
const spotlightStep: CoachStep = {
|
||||
name: 'run',
|
||||
placement: 'right'
|
||||
}
|
||||
|
||||
function landingStep(): CoachStep {
|
||||
return { name: 'landing', placement: 'center', landing: true }
|
||||
}
|
||||
|
||||
function renderOverlay() {
|
||||
return render(TourOverlay, { global: { plugins: [i18n] } })
|
||||
}
|
||||
|
||||
describe('TourOverlay', () => {
|
||||
beforeEach(() => {
|
||||
s = makeTourState()
|
||||
vi.mocked(useOnboardingTourStore).mockReturnValue(fromPartial(reactive(s)))
|
||||
})
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
it('renders nothing when no tour step is active', () => {
|
||||
renderOverlay()
|
||||
expect(screen.queryByTestId('spotlight')).toBeNull()
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders the spotlight for a non-landing step and wires its intents', async () => {
|
||||
const user = userEvent.setup()
|
||||
s.step.value = spotlightStep
|
||||
renderOverlay()
|
||||
|
||||
expect(screen.getByTestId('spotlight')).toBeTruthy()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'advance' }))
|
||||
expect(s.next).toHaveBeenCalledOnce()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'skip' }))
|
||||
expect(s.skip).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('renders the landing step and starts the tour on its primary action', async () => {
|
||||
const user = userEvent.setup()
|
||||
s.step.value = landingStep()
|
||||
s.primaryLabel.value = 'Start tutorial'
|
||||
renderOverlay()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: 'Start tutorial' })
|
||||
)
|
||||
expect(s.next).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('disables the landing primary action while waiting for a deferred target', async () => {
|
||||
s.step.value = landingStep()
|
||||
s.primaryLabel.value = 'Start tutorial'
|
||||
s.waitingForTarget.value = true
|
||||
renderOverlay()
|
||||
|
||||
expect(
|
||||
await screen.findByRole('button', { name: 'Start tutorial' })
|
||||
).toBeDisabled()
|
||||
})
|
||||
|
||||
it('ends the tour when the landing is dismissed', async () => {
|
||||
const user = userEvent.setup()
|
||||
s.step.value = landingStep()
|
||||
renderOverlay()
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Skip' }))
|
||||
expect(s.skip).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -1,46 +0,0 @@
|
||||
<template>
|
||||
<CoachmarkLanding
|
||||
v-if="isRegistryTour && tour.step?.landing"
|
||||
:title="tour.title"
|
||||
:message="tour.body"
|
||||
:image="tour.step.image"
|
||||
:primary-label="tour.primaryLabel"
|
||||
:skip-label="tour.skipLabel"
|
||||
:waiting-for-target="tour.waitingForTarget"
|
||||
@start="tour.next"
|
||||
@skip="tour.skip"
|
||||
/>
|
||||
<TourSpotlight
|
||||
v-else-if="isRegistryTour && tour.step"
|
||||
:step="tour.step"
|
||||
:title="tour.title"
|
||||
:body="tour.body"
|
||||
:is-last="tour.isLast"
|
||||
:can-go-back="tour.canGoBack"
|
||||
:primary-label="tour.primaryLabel"
|
||||
:skip-label="tour.skipLabel"
|
||||
:back-label="tour.backLabel"
|
||||
:counted-step-idx="tour.countedStepIdx"
|
||||
:counted-steps-total="tour.countedStepsTotal"
|
||||
:waiting-for-target="tour.waitingForTarget"
|
||||
@advance="tour.next"
|
||||
@back="tour.back"
|
||||
@skip="tour.skip"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import CoachmarkLanding from './CoachmarkLanding.vue'
|
||||
import TourSpotlight from './TourSpotlight.vue'
|
||||
import { TOURS } from './onboardingTours'
|
||||
import { useOnboardingTourStore } from './onboardingTourStore'
|
||||
|
||||
const tour = useOnboardingTourStore()
|
||||
|
||||
// firstRun renders its own overlay; here, render only registry-backed tours.
|
||||
const isRegistryTour = computed(
|
||||
() => tour.activeTour != null && tour.activeTour in TOURS
|
||||
)
|
||||
</script>
|
||||
@@ -1,147 +0,0 @@
|
||||
import { cleanup, render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
import { clearCoachmarks } from './coachmarkRegistry'
|
||||
import TourSpotlight from './TourSpotlight.vue'
|
||||
import type { CoachStep } from './onboardingTours'
|
||||
|
||||
vi.mock('@primeuix/utils/zindex', () => ({
|
||||
ZIndex: { set: vi.fn(), clear: vi.fn() }
|
||||
}))
|
||||
|
||||
import { ZIndex } from '@primeuix/utils/zindex'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
function spotlightStep(overrides: Partial<CoachStep> = {}): CoachStep {
|
||||
return { name: 'run', placement: 'right', ...overrides }
|
||||
}
|
||||
|
||||
const baseProps = {
|
||||
title: 'Run your app',
|
||||
body: 'Press to run',
|
||||
isLast: false,
|
||||
canGoBack: false,
|
||||
primaryLabel: 'Next',
|
||||
skipLabel: 'Skip',
|
||||
backLabel: 'Back',
|
||||
countedStepIdx: 0,
|
||||
countedStepsTotal: 1,
|
||||
waitingForTarget: false
|
||||
}
|
||||
|
||||
function renderSpotlight(
|
||||
props: Partial<ComponentProps<typeof TourSpotlight>> = {}
|
||||
) {
|
||||
return render(TourSpotlight, {
|
||||
props: { step: spotlightStep(), ...baseProps, ...props },
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
}
|
||||
|
||||
describe('TourSpotlight', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
clearCoachmarks()
|
||||
document.body.replaceChildren()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('renders the spotlight and card for a step', () => {
|
||||
renderSpotlight()
|
||||
expect(screen.getByTestId('coach-spotlight')).toBeTruthy()
|
||||
expect(screen.getByRole('dialog', { name: 'Run your app' })).toBeTruthy()
|
||||
expect(screen.getByText('Press to run')).toBeTruthy()
|
||||
expect(screen.getByText('Step 1 of 1')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('hides the Skip button on the last step', () => {
|
||||
renderSpotlight({ isLast: true })
|
||||
expect(screen.queryByRole('button', { name: 'Skip' })).toBeNull()
|
||||
expect(screen.getByRole('button', { name: 'Next' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows Back and emits back when there is a previous step', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { emitted } = renderSpotlight({ canGoBack: true })
|
||||
await user.click(screen.getByRole('button', { name: 'Back' }))
|
||||
expect(emitted().back).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('hides Back on the first step', () => {
|
||||
renderSpotlight({ canGoBack: false })
|
||||
expect(screen.queryByRole('button', { name: 'Back' })).toBeNull()
|
||||
})
|
||||
|
||||
it('claims the modal stack on mount and releases it on unmount', async () => {
|
||||
vi.mocked(ZIndex.set).mockClear()
|
||||
vi.mocked(ZIndex.clear).mockClear()
|
||||
|
||||
const { unmount } = renderSpotlight()
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
expect(ZIndex.set).toHaveBeenCalled()
|
||||
|
||||
unmount()
|
||||
expect(ZIndex.clear).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('re-claims the modal stack per step without leaking entries', async () => {
|
||||
vi.mocked(ZIndex.set).mockClear()
|
||||
vi.mocked(ZIndex.clear).mockClear()
|
||||
|
||||
const { rerender, unmount } = renderSpotlight()
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
await rerender({ step: spotlightStep({ placement: 'left' }) })
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
unmount()
|
||||
// Sets must pair with clears or entries leak; the +1 is the unmount clear.
|
||||
expect(vi.mocked(ZIndex.clear).mock.calls.length).toBe(
|
||||
vi.mocked(ZIndex.set).mock.calls.length + 1
|
||||
)
|
||||
expect(ZIndex.set).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('emits advance on the primary button and skip on the secondary', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { emitted } = renderSpotlight()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Next' }))
|
||||
expect(emitted().advance).toHaveLength(1)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Skip' }))
|
||||
expect(emitted().skip).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('emits skip when Escape is pressed', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { emitted } = renderSpotlight()
|
||||
|
||||
await user.keyboard('{Escape}')
|
||||
expect(emitted().skip).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('disables the primary button while waiting for a deferred target', () => {
|
||||
renderSpotlight({ waitingForTarget: true })
|
||||
expect(screen.getByRole('button', { name: 'Next' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('hides the spotlight and dims via the blocker for a step with no target', () => {
|
||||
renderSpotlight({ step: spotlightStep({ placement: 'center' }) })
|
||||
expect(screen.getByTestId('coach-spotlight').style.opacity).toBe('0')
|
||||
})
|
||||
})
|
||||
@@ -1,234 +0,0 @@
|
||||
<template>
|
||||
<div ref="overlayRef" class="pointer-events-none fixed inset-0">
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'pointer-events-auto absolute inset-0',
|
||||
!targetRect && 'bg-coach-scrim'
|
||||
)
|
||||
"
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-testid="coach-spotlight"
|
||||
class="pointer-events-none absolute rounded-xl shadow-[0_0_0_9999px_var(--color-coach-scrim)] outline-2 outline-coach-ring motion-safe:transition-[left,top,width,height,opacity] motion-safe:duration-300"
|
||||
:style="spotlightStyle"
|
||||
/>
|
||||
<FocusScope as-child trapped loop @mount-auto-focus.prevent>
|
||||
<div
|
||||
ref="cardRef"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-labelledby="titleId"
|
||||
:aria-describedby="`${subtitleId} ${bodyId}`"
|
||||
class="pointer-events-auto absolute max-h-[calc(100vh-var(--comfy-topbar-height)-2rem)] overflow-y-auto motion-safe:transition-[left,top] motion-safe:duration-300"
|
||||
:style="cardStyle"
|
||||
>
|
||||
<CoachmarkCard
|
||||
:subtitle="
|
||||
t('onboardingCoachmarks.stepLabel', {
|
||||
current: countedStepIdx + 1,
|
||||
total: countedStepsTotal
|
||||
})
|
||||
"
|
||||
:subtitle-id="subtitleId"
|
||||
:title
|
||||
:title-id="titleId"
|
||||
:message="body"
|
||||
:message-id="bodyId"
|
||||
:image="step.image"
|
||||
>
|
||||
<template #actions>
|
||||
<Button
|
||||
v-if="showSkip"
|
||||
variant="textonly"
|
||||
size="md"
|
||||
@click="emit('skip')"
|
||||
>
|
||||
{{ skipLabel }}
|
||||
</Button>
|
||||
<div class="ml-auto flex items-center gap-3">
|
||||
<Button
|
||||
v-if="canGoBack"
|
||||
variant="secondary"
|
||||
size="md"
|
||||
class="border border-solid border-border-default"
|
||||
@click="emit('back')"
|
||||
>
|
||||
<i class="icon-[lucide--arrow-left]" />
|
||||
{{ backLabel }}
|
||||
</Button>
|
||||
<Button
|
||||
ref="primaryButton"
|
||||
variant="inverted"
|
||||
size="md"
|
||||
:disabled="waitingForTarget"
|
||||
@click="emit('advance')"
|
||||
>
|
||||
{{ primaryLabel }}
|
||||
<i v-if="!isLast" class="icon-[lucide--arrow-right]" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</CoachmarkCard>
|
||||
</div>
|
||||
</FocusScope>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { useEventListener, useWindowSize } from '@vueuse/core'
|
||||
import { ZIndex } from '@primeuix/utils/zindex'
|
||||
import { FocusScope } from 'reka-ui'
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
ref,
|
||||
useId,
|
||||
useTemplateRef,
|
||||
watch
|
||||
} from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { MODAL_Z_BASE, MODAL_Z_KEY } from '@/components/dialog/vRekaZIndex'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
import CoachmarkCard from './CoachmarkCard.vue'
|
||||
import {
|
||||
CARD_WIDTH,
|
||||
SPOTLIGHT_PAD,
|
||||
VIEWPORT_MARGIN,
|
||||
clampSpotlight,
|
||||
noTargetCardLeft
|
||||
} from './coachmarkLayout'
|
||||
import type { CoachStep } from './onboardingTours'
|
||||
import { useCoachmarkTarget } from './useCoachmarkTarget'
|
||||
|
||||
const {
|
||||
step,
|
||||
title,
|
||||
body,
|
||||
isLast,
|
||||
canGoBack,
|
||||
primaryLabel,
|
||||
skipLabel,
|
||||
backLabel,
|
||||
countedStepIdx,
|
||||
countedStepsTotal,
|
||||
waitingForTarget
|
||||
} = defineProps<{
|
||||
step: CoachStep
|
||||
title: string
|
||||
body: string
|
||||
isLast: boolean
|
||||
canGoBack: boolean
|
||||
primaryLabel: string
|
||||
skipLabel: string
|
||||
backLabel: string
|
||||
countedStepIdx: number
|
||||
countedStepsTotal: number
|
||||
waitingForTarget: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
advance: []
|
||||
back: []
|
||||
skip: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const bodyId = useId()
|
||||
const subtitleId = useId()
|
||||
const titleId = useId()
|
||||
|
||||
const overlayRef = ref<HTMLElement | null>(null)
|
||||
const cardRef = ref<HTMLElement | null>(null)
|
||||
const { width: windowWidth, height: windowHeight } = useWindowSize()
|
||||
|
||||
const { targetRect, targetEl, floatingStyles, isPositioned } =
|
||||
useCoachmarkTarget(() => step, cardRef)
|
||||
|
||||
// Last step's "Done" already dismisses, so hide Skip there.
|
||||
const showSkip = computed(() => !isLast)
|
||||
|
||||
const primaryButton =
|
||||
useTemplateRef<InstanceType<typeof Button>>('primaryButton')
|
||||
|
||||
async function focusPrimary() {
|
||||
await nextTick()
|
||||
const el = primaryButton.value?.$el as HTMLElement | undefined
|
||||
el?.focus()
|
||||
}
|
||||
|
||||
useEventListener(
|
||||
document,
|
||||
'keydown',
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape') return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
emit('skip')
|
||||
},
|
||||
{ capture: true }
|
||||
)
|
||||
|
||||
async function raiseOverlay() {
|
||||
await nextTick()
|
||||
const el = overlayRef.value
|
||||
if (!el) return
|
||||
// ZIndex.set pushes a fresh entry into the shared modal sequence on every
|
||||
// call, so clear the previous one or per-step re-raises leak entries.
|
||||
ZIndex.clear(el)
|
||||
ZIndex.set(MODAL_Z_KEY, el, MODAL_Z_BASE)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => step,
|
||||
() => void raiseOverlay(),
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
[() => step, () => waitingForTarget],
|
||||
() => {
|
||||
if (!waitingForTarget) void focusPrimary()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (overlayRef.value) ZIndex.clear(overlayRef.value)
|
||||
})
|
||||
|
||||
function viewport() {
|
||||
return { width: windowWidth.value, height: windowHeight.value }
|
||||
}
|
||||
|
||||
const spotlightStyle = computed(() => {
|
||||
const r = targetRect.value
|
||||
if (!r) return { opacity: '0' }
|
||||
return { ...clampSpotlight(r, SPOTLIGHT_PAD, viewport()), opacity: '1' }
|
||||
})
|
||||
|
||||
const cardStyle = computed(() => {
|
||||
const width = `${CARD_WIDTH}px`
|
||||
const maxWidth = `calc(100vw - ${VIEWPORT_MARGIN * 2}px)`
|
||||
if (!targetEl.value) {
|
||||
return {
|
||||
width,
|
||||
maxWidth,
|
||||
left: `${noTargetCardLeft(windowWidth.value)}px`,
|
||||
top: '30%'
|
||||
}
|
||||
}
|
||||
// Hidden until Floating UI positions it, avoiding a first-frame jump.
|
||||
return {
|
||||
...floatingStyles.value,
|
||||
width,
|
||||
maxWidth,
|
||||
opacity: isPositioned.value ? '1' : '0'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,72 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
CARD_GAP,
|
||||
clampSpotlight,
|
||||
noTargetCardLeft,
|
||||
topSafeInset
|
||||
} from './coachmarkLayout'
|
||||
|
||||
const VIEWPORT = { width: 1000, height: 800 }
|
||||
|
||||
describe('clampSpotlight', () => {
|
||||
it('grows the target rect by the pad on every side', () => {
|
||||
const r = new DOMRect(100, 100, 50, 40)
|
||||
expect(clampSpotlight(r, 8, VIEWPORT)).toEqual({
|
||||
left: '92px',
|
||||
top: '92px',
|
||||
width: '66px',
|
||||
height: '56px'
|
||||
})
|
||||
})
|
||||
|
||||
it('clamps the near edges to the viewport inset', () => {
|
||||
const r = new DOMRect(0, 0, 10, 10)
|
||||
expect(clampSpotlight(r, 8, VIEWPORT)).toMatchObject({
|
||||
left: '2px',
|
||||
top: '2px'
|
||||
})
|
||||
})
|
||||
|
||||
it('clamps the far edges to the viewport inset', () => {
|
||||
const r = new DOMRect(990, 100, 50, 40)
|
||||
const { left, width } = clampSpotlight(r, 8, VIEWPORT)
|
||||
expect(left).toBe('982px')
|
||||
expect(width).toBe('16px')
|
||||
})
|
||||
|
||||
it('never produces a negative size for an off-screen target', () => {
|
||||
const r = new DOMRect(2000, 100, 50, 40)
|
||||
expect(clampSpotlight(r, 8, VIEWPORT)).toMatchObject({ width: '0px' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('topSafeInset', () => {
|
||||
afterEach(() => {
|
||||
document.documentElement.style.removeProperty('--comfy-topbar-height')
|
||||
})
|
||||
|
||||
it('converts a rem top bar height to px and adds the card gap', () => {
|
||||
document.documentElement.style.setProperty('--comfy-topbar-height', '3rem')
|
||||
expect(topSafeInset()).toBe(48 + CARD_GAP)
|
||||
})
|
||||
|
||||
it('reads a px top bar height directly and adds the card gap', () => {
|
||||
document.documentElement.style.setProperty('--comfy-topbar-height', '50px')
|
||||
expect(topSafeInset()).toBe(50 + CARD_GAP)
|
||||
})
|
||||
|
||||
it('falls back to the card gap alone when the token is unset', () => {
|
||||
expect(topSafeInset()).toBe(CARD_GAP)
|
||||
})
|
||||
})
|
||||
|
||||
describe('noTargetCardLeft', () => {
|
||||
it('centers the card on a wide viewport', () => {
|
||||
expect(noTargetCardLeft(1000)).toBe(350)
|
||||
})
|
||||
|
||||
it('clamps to the viewport margin when the viewport is narrower than the card', () => {
|
||||
expect(noTargetCardLeft(200)).toBe(12)
|
||||
})
|
||||
})
|
||||
@@ -1,55 +0,0 @@
|
||||
export interface Viewport {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export interface BoxStyle {
|
||||
left: string
|
||||
top: string
|
||||
width: string
|
||||
height: string
|
||||
}
|
||||
|
||||
const SPOTLIGHT_EDGE_INSET = 2
|
||||
|
||||
export const CARD_WIDTH = 300
|
||||
export const VIEWPORT_MARGIN = 12
|
||||
export const CARD_GAP = 16
|
||||
// Kept tight so the spotlight glow doesn't spill onto an adjacent clickable control.
|
||||
export const SPOTLIGHT_PAD = 4
|
||||
|
||||
export function clampSpotlight(
|
||||
r: DOMRect,
|
||||
pad: number,
|
||||
viewport: Viewport
|
||||
): BoxStyle {
|
||||
const left = Math.max(SPOTLIGHT_EDGE_INSET, r.left - pad)
|
||||
const top = Math.max(SPOTLIGHT_EDGE_INSET, r.top - pad)
|
||||
const right = Math.min(viewport.width - SPOTLIGHT_EDGE_INSET, r.right + pad)
|
||||
const bottom = Math.min(
|
||||
viewport.height - SPOTLIGHT_EDGE_INSET,
|
||||
r.bottom + pad
|
||||
)
|
||||
return {
|
||||
left: `${left}px`,
|
||||
top: `${top}px`,
|
||||
width: `${Math.max(0, right - left)}px`,
|
||||
height: `${Math.max(0, bottom - top)}px`
|
||||
}
|
||||
}
|
||||
|
||||
export function noTargetCardLeft(viewportWidth: number): number {
|
||||
return Math.max(VIEWPORT_MARGIN, (viewportWidth - CARD_WIDTH) / 2)
|
||||
}
|
||||
|
||||
const TOP_BAR_HEIGHT_VAR = '--comfy-topbar-height'
|
||||
|
||||
/** The top bar's height, read from the theme token, plus the standard gap. */
|
||||
export function topSafeInset(): number {
|
||||
const root = document.documentElement
|
||||
const raw = getComputedStyle(root).getPropertyValue(TOP_BAR_HEIGHT_VAR).trim()
|
||||
const px = raw.endsWith('rem')
|
||||
? parseFloat(raw) * parseFloat(getComputedStyle(root).fontSize)
|
||||
: parseFloat(raw)
|
||||
return (Number.isFinite(px) ? px : 0) + CARD_GAP
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import {
|
||||
clearCoachmarks,
|
||||
coachmarkElements,
|
||||
registerCoachmark,
|
||||
targetMounted,
|
||||
unregisterCoachmark,
|
||||
waitForTarget
|
||||
} from './coachmarkRegistry'
|
||||
|
||||
/** An element with a non-zero measured rect, so it counts as laid out. */
|
||||
function laidOut(): HTMLElement {
|
||||
const el = document.createElement('div')
|
||||
el.getBoundingClientRect = () => new DOMRect(0, 0, 80, 30)
|
||||
return el
|
||||
}
|
||||
|
||||
describe('coachmarkRegistry', () => {
|
||||
const a = document.createElement('div')
|
||||
const b = document.createElement('div')
|
||||
|
||||
afterEach(clearCoachmarks)
|
||||
|
||||
it('resolves every element registered for an id', () => {
|
||||
registerCoachmark('app-run-button', a)
|
||||
registerCoachmark('app-run-button', b)
|
||||
expect(coachmarkElements('app-run-button')).toEqual([a, b])
|
||||
})
|
||||
|
||||
it('keeps the remaining elements when one of several unregisters', () => {
|
||||
registerCoachmark('app-run-button', a)
|
||||
registerCoachmark('app-run-button', b)
|
||||
unregisterCoachmark('app-run-button', a)
|
||||
expect(coachmarkElements('app-run-button')).toEqual([b])
|
||||
})
|
||||
})
|
||||
|
||||
describe('targetMounted', () => {
|
||||
afterEach(clearCoachmarks)
|
||||
|
||||
it('is true once a laid-out element is registered', () => {
|
||||
expect(targetMounted('app-run-button')).toBe(false)
|
||||
registerCoachmark('app-run-button', laidOut())
|
||||
expect(targetMounted('app-run-button')).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores a registered target that is not laid out (e.g. hidden)', () => {
|
||||
registerCoachmark('outputs', document.createElement('div'))
|
||||
expect(targetMounted('outputs')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('waitForTarget', () => {
|
||||
let frames: Array<() => void>
|
||||
|
||||
beforeEach(() => {
|
||||
frames = []
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
||||
frames.push(() => cb(0))
|
||||
return frames.length
|
||||
})
|
||||
vi.stubGlobal('cancelAnimationFrame', () => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clearCoachmarks()
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
// Drain the poll queue; an unresolved poll reschedules, so cap the drain to
|
||||
// avoid spinning when the target never lays out.
|
||||
function runFrames(max = 50) {
|
||||
let ran = 0
|
||||
while (frames.length && ran < max) {
|
||||
frames.shift()!()
|
||||
ran++
|
||||
}
|
||||
}
|
||||
|
||||
it('resolves true immediately when a laid-out target is already mounted', async () => {
|
||||
registerCoachmark('app-run-button', laidOut())
|
||||
const signal = new AbortController().signal
|
||||
await expect(waitForTarget('app-run-button', signal, 1000)).resolves.toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves true once the target lays out before the timeout', async () => {
|
||||
const signal = new AbortController().signal
|
||||
const found = waitForTarget('app-run-button', signal, 1000)
|
||||
registerCoachmark('app-run-button', laidOut())
|
||||
runFrames()
|
||||
await expect(found).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('keeps waiting for a registered target until it lays out', async () => {
|
||||
const el = document.createElement('div')
|
||||
registerCoachmark('outputs', el)
|
||||
const signal = new AbortController().signal
|
||||
let resolved: boolean | undefined
|
||||
void waitForTarget('outputs', signal, 1000).then((v) => (resolved = v))
|
||||
|
||||
runFrames()
|
||||
await Promise.resolve()
|
||||
expect(resolved).toBeUndefined()
|
||||
|
||||
el.getBoundingClientRect = () => new DOMRect(0, 0, 80, 30)
|
||||
runFrames()
|
||||
await Promise.resolve()
|
||||
expect(resolved).toBe(true)
|
||||
})
|
||||
|
||||
it('does not schedule rAF polling while no candidate is registered', () => {
|
||||
const signal = new AbortController().signal
|
||||
void waitForTarget('outputs', signal, 1000)
|
||||
expect(frames).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('parks the poll when the last candidate unregisters and resumes on re-registration', async () => {
|
||||
const el = document.createElement('div')
|
||||
const signal = new AbortController().signal
|
||||
let resolved: boolean | undefined
|
||||
void waitForTarget('outputs', signal, 1000).then((v) => (resolved = v))
|
||||
|
||||
registerCoachmark('outputs', el)
|
||||
await nextTick()
|
||||
expect(frames.length).toBeGreaterThan(0)
|
||||
|
||||
unregisterCoachmark('outputs', el)
|
||||
await nextTick()
|
||||
runFrames()
|
||||
expect(frames).toHaveLength(0)
|
||||
|
||||
registerCoachmark('outputs', laidOut())
|
||||
await nextTick()
|
||||
await Promise.resolve()
|
||||
expect(resolved).toBe(true)
|
||||
})
|
||||
|
||||
it('resolves false when the target never mounts (transient failure)', async () => {
|
||||
vi.useFakeTimers()
|
||||
const signal = new AbortController().signal
|
||||
const found = waitForTarget('outputs', signal, 1000)
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
await expect(found).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('resolves false when aborted before the target mounts', async () => {
|
||||
const controller = new AbortController()
|
||||
const found = waitForTarget('outputs', controller.signal, 10000)
|
||||
controller.abort()
|
||||
await expect(found).resolves.toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,84 +0,0 @@
|
||||
import { shallowReactive, watch } from 'vue'
|
||||
|
||||
import type { CoachId } from './onboardingTours'
|
||||
|
||||
const EMPTY: readonly HTMLElement[] = []
|
||||
|
||||
/** Laid out — a registered target that is currently visible and has a size. */
|
||||
export function isLaidOut(el: HTMLElement): boolean {
|
||||
const r = el.getBoundingClientRect()
|
||||
return r.width > 0 && r.height > 0
|
||||
}
|
||||
|
||||
// An id can map to several elements (e.g. responsive variants); consumers pick
|
||||
// the first laid-out one.
|
||||
const registry = shallowReactive(new Map<CoachId, readonly HTMLElement[]>())
|
||||
|
||||
export function registerCoachmark(id: CoachId, el: HTMLElement) {
|
||||
registry.set(id, [...(registry.get(id) ?? EMPTY), el])
|
||||
}
|
||||
|
||||
export function unregisterCoachmark(id: CoachId, el: HTMLElement) {
|
||||
const next = (registry.get(id) ?? EMPTY).filter((entry) => entry !== el)
|
||||
if (next.length) registry.set(id, next)
|
||||
else registry.delete(id)
|
||||
}
|
||||
|
||||
export function coachmarkElements(id: CoachId): readonly HTMLElement[] {
|
||||
return registry.get(id) ?? EMPTY
|
||||
}
|
||||
|
||||
export function targetMounted(id: CoachId): boolean {
|
||||
return coachmarkElements(id).some(isLaidOut)
|
||||
}
|
||||
|
||||
/** Resolves once a laid-out element for the id exists; false on timeout or abort. */
|
||||
export function waitForTarget(
|
||||
id: CoachId,
|
||||
signal: AbortSignal,
|
||||
timeoutMs: number
|
||||
): Promise<boolean> {
|
||||
if (targetMounted(id)) return Promise.resolve(true)
|
||||
// An already-aborted signal never fires 'abort', so resolve up front.
|
||||
if (signal.aborted) return Promise.resolve(false)
|
||||
return new Promise((resolve) => {
|
||||
let done = false
|
||||
let frame = 0
|
||||
function finish(found: boolean) {
|
||||
if (done) return
|
||||
done = true
|
||||
stopWatch()
|
||||
cancelAnimationFrame(frame)
|
||||
clearTimeout(timer)
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
resolve(found)
|
||||
}
|
||||
function onAbort() {
|
||||
finish(false)
|
||||
}
|
||||
// Laid-out-ness is a layout read the registry can't observe, so it needs
|
||||
// polling — but only while a candidate exists. Registration is reactive,
|
||||
// so the watch (re)starts the poll instead of spinning every frame while
|
||||
// the target hasn't even mounted.
|
||||
function poll() {
|
||||
if (targetMounted(id)) finish(true)
|
||||
else if (coachmarkElements(id).length) frame = requestAnimationFrame(poll)
|
||||
}
|
||||
const stopWatch = watch(
|
||||
() => coachmarkElements(id).length,
|
||||
() => {
|
||||
cancelAnimationFrame(frame)
|
||||
poll()
|
||||
},
|
||||
{ flush: 'post' }
|
||||
)
|
||||
const timer = setTimeout(() => finish(false), timeoutMs)
|
||||
signal.addEventListener('abort', onAbort)
|
||||
poll()
|
||||
})
|
||||
}
|
||||
|
||||
/** Resets shared state between tests. */
|
||||
export function clearCoachmarks() {
|
||||
registry.clear()
|
||||
}
|
||||
@@ -1,516 +0,0 @@
|
||||
import type { DetachedWindowAPI } from 'happy-dom'
|
||||
import { createPinia, disposePinia, setActivePinia } from 'pinia'
|
||||
import type { Pinia } from 'pinia'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
import type { AppMode } from '@/utils/appMode'
|
||||
|
||||
import { clearCoachmarks, registerCoachmark } from './coachmarkRegistry'
|
||||
import { TOUR_SEEN_SETTING } from './onboardingTours'
|
||||
import type { CoachId } from './onboardingTours'
|
||||
import { useOnboardingTourStore } from './onboardingTourStore'
|
||||
|
||||
const settings = vi.hoisted(() => ({ store: new Map<string, unknown>() }))
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: (key: string) =>
|
||||
settings.store.get(key) ?? (key === TOUR_SEEN_SETTING ? [] : undefined),
|
||||
set: (key: string, value: unknown) => {
|
||||
settings.store.set(key, value)
|
||||
return Promise.resolve()
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
const telemetry = vi.hoisted(() => ({ track: vi.fn() }))
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({ trackOnboardingTour: telemetry.track })
|
||||
}))
|
||||
|
||||
const appModeMock = vi.hoisted(
|
||||
() =>
|
||||
({ mode: null, hasOutputs: null }) as {
|
||||
mode: Ref<AppMode> | null
|
||||
hasOutputs: Ref<boolean> | null
|
||||
}
|
||||
)
|
||||
vi.mock('@/composables/useAppMode', async () => {
|
||||
const { ref: r } = await import('vue')
|
||||
appModeMock.mode = r<AppMode>('graph')
|
||||
return { useAppMode: () => ({ mode: appModeMock.mode }) }
|
||||
})
|
||||
vi.mock('@/stores/appModeStore', async () => {
|
||||
const { ref: r } = await import('vue')
|
||||
appModeMock.hasOutputs = r(false)
|
||||
const hasOutputs = appModeMock.hasOutputs
|
||||
return {
|
||||
useAppModeStore: () => ({
|
||||
get hasOutputs() {
|
||||
return hasOutputs.value
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
const APP_MODE_TARGETS: CoachId[] = [
|
||||
'inputs-list',
|
||||
'app-run-button',
|
||||
'outputs',
|
||||
'assets-panel'
|
||||
]
|
||||
|
||||
function seenTours(): string[] {
|
||||
return (settings.store.get(TOUR_SEEN_SETTING) as string[] | undefined) ?? []
|
||||
}
|
||||
|
||||
function setViewport(viewport: { width: number; height: number }) {
|
||||
const happyDOM = (window as unknown as { happyDOM?: DetachedWindowAPI })
|
||||
.happyDOM
|
||||
if (!happyDOM) {
|
||||
throw new Error('window.happyDOM is unavailable to set viewport')
|
||||
}
|
||||
happyDOM.setViewport(viewport)
|
||||
}
|
||||
|
||||
let pinia: Pinia | undefined
|
||||
|
||||
function mountStore() {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
return useOnboardingTourStore()
|
||||
}
|
||||
|
||||
function startedCount() {
|
||||
return telemetry.track.mock.calls.filter(([stage]) => stage === 'started')
|
||||
.length
|
||||
}
|
||||
|
||||
describe('onboardingTourStore', () => {
|
||||
// Removed in teardown even when a test throws before its own cleanup.
|
||||
const appendedTargets: HTMLElement[] = []
|
||||
|
||||
afterEach(() => {
|
||||
if (pinia) disposePinia(pinia)
|
||||
pinia = undefined
|
||||
clearCoachmarks()
|
||||
appendedTargets.forEach((el) => el.remove())
|
||||
appendedTargets.length = 0
|
||||
settings.store.clear()
|
||||
setViewport({ width: 1024, height: 768 })
|
||||
if (appModeMock.mode) appModeMock.mode.value = 'graph'
|
||||
if (appModeMock.hasOutputs) appModeMock.hasOutputs.value = false
|
||||
telemetry.track.mockClear()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
/** Register one laid-out element for a coach id, so its step resolves at once. */
|
||||
function mountTarget(id: CoachId): HTMLElement {
|
||||
const el = document.createElement('div')
|
||||
el.getBoundingClientRect = () => new DOMRect(0, 0, 100, 100)
|
||||
document.body.appendChild(el)
|
||||
appendedTargets.push(el)
|
||||
registerCoachmark(id, el)
|
||||
return el
|
||||
}
|
||||
|
||||
function registerAppModeTargets(
|
||||
ids: CoachId[] = APP_MODE_TARGETS
|
||||
): Map<CoachId, HTMLElement> {
|
||||
return new Map(ids.map((id) => [id, mountTarget(id)]))
|
||||
}
|
||||
|
||||
function enterApp(mode: AppMode, hasOutputs: boolean) {
|
||||
const modeRef = appModeMock.mode
|
||||
const outputsRef = appModeMock.hasOutputs
|
||||
if (!modeRef || !outputsRef)
|
||||
throw new Error('app mode mock not initialised')
|
||||
modeRef.value = mode
|
||||
outputsRef.value = hasOutputs
|
||||
}
|
||||
|
||||
it('auto-opens when entering a populated app it has not seen', async () => {
|
||||
mountStore()
|
||||
enterApp('app', true)
|
||||
await nextTick()
|
||||
expect(startedCount()).toBe(1)
|
||||
})
|
||||
|
||||
it('auto-opens when instantiated in an already-populated app', async () => {
|
||||
enterApp('app', true)
|
||||
mountStore()
|
||||
await nextTick()
|
||||
expect(startedCount()).toBe(1)
|
||||
})
|
||||
|
||||
it('does not auto-open in an empty app with no linear controls', async () => {
|
||||
mountStore()
|
||||
enterApp('app', false)
|
||||
await nextTick()
|
||||
expect(startedCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('does not auto-open in arrange (builder) mode', async () => {
|
||||
mountStore()
|
||||
enterApp('builder:arrange', true)
|
||||
await nextTick()
|
||||
expect(startedCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('does not auto-open on a mobile-width viewport', async () => {
|
||||
setViewport({ width: 500, height: 800 })
|
||||
enterApp('app', true)
|
||||
mountStore()
|
||||
await nextTick()
|
||||
expect(startedCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('does not auto-open a populated app once the tour has been dismissed', async () => {
|
||||
settings.store.set(TOUR_SEEN_SETTING, ['appMode'])
|
||||
mountStore()
|
||||
enterApp('app', true)
|
||||
await nextTick()
|
||||
expect(startedCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('replays a seen tour when explicitly requested', async () => {
|
||||
settings.store.set(TOUR_SEEN_SETTING, ['appMode'])
|
||||
const store = mountStore()
|
||||
store.replayTour('appMode')
|
||||
await nextTick()
|
||||
expect(startedCount()).toBe(1)
|
||||
})
|
||||
|
||||
it('dismisses a replayed tour without the seen-flag when the user leaves its mode', async () => {
|
||||
settings.store.set(TOUR_SEEN_SETTING, ['appMode'])
|
||||
const store = mountStore()
|
||||
enterApp('app', false)
|
||||
await nextTick()
|
||||
store.replayTour('appMode')
|
||||
await nextTick()
|
||||
expect(store.step?.name).toBe('landing')
|
||||
|
||||
enterApp('graph', false)
|
||||
await nextTick()
|
||||
|
||||
expect(store.step).toBeNull()
|
||||
const skipped = telemetry.track.mock.calls.find(
|
||||
([stage]) => stage === 'skipped'
|
||||
)
|
||||
expect(skipped?.[1]).toMatchObject({ skip_reason: 'trigger_lost' })
|
||||
})
|
||||
|
||||
it('keeps the tour running when outputs disappear but the mode still holds', async () => {
|
||||
registerAppModeTargets()
|
||||
const store = mountStore()
|
||||
enterApp('app', true)
|
||||
await nextTick()
|
||||
expect(startedCount()).toBe(1)
|
||||
|
||||
enterApp('app', false)
|
||||
await nextTick()
|
||||
|
||||
expect(store.step).not.toBeNull()
|
||||
})
|
||||
|
||||
it('marks the tour seen when it ends normally', async () => {
|
||||
const store = mountStore()
|
||||
store.replayTour('appMode')
|
||||
await nextTick()
|
||||
store.skip()
|
||||
expect(seenTours()).toContain('appMode')
|
||||
|
||||
// A deliberate dismissal reports the user as the skip reason.
|
||||
const skipped = telemetry.track.mock.calls.find(
|
||||
([stage]) => stage === 'skipped'
|
||||
)
|
||||
expect(skipped?.[1]).toMatchObject({ skip_reason: 'user' })
|
||||
})
|
||||
|
||||
it('skips without the seen-flag and toasts when a deferred target never appears', async () => {
|
||||
vi.useFakeTimers()
|
||||
const store = mountStore()
|
||||
store.replayTour('appMode')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
store.next()
|
||||
expect(store.waitingForTarget).toBe(true)
|
||||
// The deferred target (inputs list) is never registered; exhaust the wait.
|
||||
await vi.advanceTimersByTimeAsync(8000)
|
||||
expect(seenTours()).not.toContain('appMode')
|
||||
|
||||
// The skipped event reports the timed-out step, not the prior landing,
|
||||
// and attributes the skip to the timeout rather than the user.
|
||||
const skipped = telemetry.track.mock.calls.find(
|
||||
([stage]) => stage === 'skipped'
|
||||
)
|
||||
expect(skipped?.[1]).toMatchObject({
|
||||
step_number: 1,
|
||||
coach_id: 'inputs-list',
|
||||
skip_reason: 'target_timeout'
|
||||
})
|
||||
|
||||
expect(useToastStore().messagesToAdd).toContainEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
detail: 'Something went wrong showing this tour'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('advances once a deferred target mounts during the wait', async () => {
|
||||
const store = mountStore()
|
||||
store.replayTour('appMode')
|
||||
await nextTick()
|
||||
store.next()
|
||||
expect(store.waitingForTarget).toBe(true)
|
||||
|
||||
mountTarget('inputs-list')
|
||||
await nextTick()
|
||||
|
||||
expect(store.waitingForTarget).toBe(false)
|
||||
expect(store.step?.coachId).toBe('inputs-list')
|
||||
})
|
||||
|
||||
it('keeps the original deadline when advance is requested again mid-wait', async () => {
|
||||
vi.useFakeTimers()
|
||||
const store = mountStore()
|
||||
store.replayTour('appMode')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
store.next()
|
||||
expect(store.waitingForTarget).toBe(true)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
store.next()
|
||||
await vi.advanceTimersByTimeAsync(3000)
|
||||
|
||||
expect(useToastStore().messagesToAdd).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not toast or double-report when the user skips during a deferred wait', async () => {
|
||||
vi.useFakeTimers()
|
||||
const store = mountStore()
|
||||
store.replayTour('appMode')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
store.next()
|
||||
expect(store.waitingForTarget).toBe(true)
|
||||
|
||||
store.skip()
|
||||
await vi.advanceTimersByTimeAsync(8000)
|
||||
|
||||
const skipped = telemetry.track.mock.calls.filter(
|
||||
([stage]) => stage === 'skipped'
|
||||
)
|
||||
expect(skipped).toHaveLength(1)
|
||||
expect(skipped[0]?.[1]).toMatchObject({ skip_reason: 'user' })
|
||||
expect(useToastStore().messagesToAdd).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('ends an active tour without the seen-flag when its trigger stops holding', async () => {
|
||||
registerAppModeTargets()
|
||||
const store = mountStore()
|
||||
enterApp('app', true)
|
||||
await nextTick()
|
||||
expect(startedCount()).toBe(1)
|
||||
|
||||
enterApp('graph', true)
|
||||
await nextTick()
|
||||
|
||||
expect(store.step).toBeNull()
|
||||
expect(seenTours()).not.toContain('appMode')
|
||||
const skipped = telemetry.track.mock.calls.find(
|
||||
([stage]) => stage === 'skipped'
|
||||
)
|
||||
expect(skipped?.[1]).toMatchObject({ skip_reason: 'trigger_lost' })
|
||||
|
||||
enterApp('app', true)
|
||||
await nextTick()
|
||||
expect(startedCount()).toBe(2)
|
||||
})
|
||||
|
||||
it('aborts a pending deferred wait without a toast when the trigger stops holding', async () => {
|
||||
vi.useFakeTimers()
|
||||
const store = mountStore()
|
||||
enterApp('app', true)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
store.next()
|
||||
expect(store.waitingForTarget).toBe(true)
|
||||
|
||||
enterApp('graph', true)
|
||||
await vi.advanceTimersByTimeAsync(8000)
|
||||
|
||||
expect(useToastStore().messagesToAdd).toHaveLength(0)
|
||||
const skipReasons = telemetry.track.mock.calls
|
||||
.filter(([stage]) => stage === 'skipped')
|
||||
.map(([, meta]) => meta.skip_reason)
|
||||
expect(skipReasons).toEqual(['trigger_lost'])
|
||||
})
|
||||
|
||||
it('ignores a second concurrent request while the first tour is resolving', async () => {
|
||||
const store = mountStore()
|
||||
store.replayTour('appMode')
|
||||
store.replayTour('appMode')
|
||||
await nextTick()
|
||||
expect(startedCount()).toBe(1)
|
||||
})
|
||||
|
||||
it('advances through every step to completion and marks the tour seen', async () => {
|
||||
registerAppModeTargets()
|
||||
const store = mountStore()
|
||||
store.replayTour('appMode')
|
||||
await nextTick()
|
||||
|
||||
// Capped so a stuck tour fails instead of hanging.
|
||||
for (let i = 0; i < 12 && !seenTours().includes('appMode'); i++) {
|
||||
store.next()
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
expect(seenTours()).toContain('appMode')
|
||||
const completed = telemetry.track.mock.calls.find(
|
||||
([stage]) => stage === 'completed'
|
||||
)
|
||||
expect(completed).toBeTruthy()
|
||||
expect(completed?.[1]).not.toHaveProperty('skip_reason')
|
||||
})
|
||||
|
||||
it('opens the assets sidebar tab and resolves its deferred panel on the assets step', async () => {
|
||||
registerAppModeTargets(
|
||||
APP_MODE_TARGETS.filter((id) => id !== 'assets-panel')
|
||||
)
|
||||
const store = mountStore()
|
||||
store.replayTour('appMode')
|
||||
await nextTick()
|
||||
|
||||
expect(store.countedStepsTotal).toBe(4)
|
||||
// Advance landing -> inputs -> run -> outputs, then onto the assets step.
|
||||
for (let i = 0; i < 4; i++) {
|
||||
store.next()
|
||||
await nextTick()
|
||||
}
|
||||
// The assets step defers on its panel; the tab auto-opens while it waits.
|
||||
expect(store.waitingForTarget).toBe(true)
|
||||
expect(useSidebarTabStore().activeSidebarTabId).toBe('assets')
|
||||
|
||||
mountTarget('assets-panel')
|
||||
await nextTick()
|
||||
|
||||
expect(store.waitingForTarget).toBe(false)
|
||||
expect(store.step?.coachId).toBe('assets-panel')
|
||||
})
|
||||
|
||||
it('leaves an already-open assets tab open when reaching the assets step', async () => {
|
||||
registerAppModeTargets()
|
||||
const store = mountStore()
|
||||
const sidebar = useSidebarTabStore()
|
||||
sidebar.toggleSidebarTab('assets')
|
||||
expect(sidebar.activeSidebarTabId).toBe('assets')
|
||||
|
||||
store.replayTour('appMode')
|
||||
await nextTick()
|
||||
for (let i = 0; i < 4; i++) {
|
||||
store.next()
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
expect(store.step?.coachId).toBe('assets-panel')
|
||||
expect(sidebar.activeSidebarTabId).toBe('assets')
|
||||
})
|
||||
|
||||
it('steps back to the previous step', async () => {
|
||||
registerAppModeTargets()
|
||||
const store = mountStore()
|
||||
store.replayTour('appMode')
|
||||
await nextTick()
|
||||
|
||||
store.next()
|
||||
await nextTick()
|
||||
store.next()
|
||||
await nextTick()
|
||||
expect(store.step?.coachId).toBe('app-run-button')
|
||||
expect(store.canGoBack).toBe(true)
|
||||
|
||||
store.back()
|
||||
await nextTick()
|
||||
expect(store.step?.coachId).toBe('inputs-list')
|
||||
})
|
||||
|
||||
it('reports step index 0 while no tour is active', () => {
|
||||
const store = mountStore()
|
||||
expect(store.countedStepIdx).toBe(0)
|
||||
})
|
||||
|
||||
it('labels the buttons from the step, falling back to Next then Done', async () => {
|
||||
registerAppModeTargets()
|
||||
const store = mountStore()
|
||||
store.replayTour('appMode')
|
||||
await nextTick()
|
||||
|
||||
// The landing's `primary` entry overrides only the primary label.
|
||||
expect(store.step?.landing).toBe(true)
|
||||
expect(store.primaryLabel).toBe('Start tutorial')
|
||||
expect(store.skipLabel).toBe('Skip')
|
||||
|
||||
expect(store.countedStepsTotal).toBe(4)
|
||||
|
||||
store.next()
|
||||
await nextTick()
|
||||
expect(store.primaryLabel).toBe('Next')
|
||||
expect(store.skipLabel).toBe('Skip')
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
store.next()
|
||||
await nextTick()
|
||||
}
|
||||
expect(store.isLast).toBe(true)
|
||||
expect(store.primaryLabel).toBe('Done')
|
||||
})
|
||||
|
||||
it('reports the user-visible step numbering, omitting it for the landing', async () => {
|
||||
registerAppModeTargets()
|
||||
const store = mountStore()
|
||||
store.replayTour('appMode')
|
||||
await nextTick()
|
||||
|
||||
// The landing isn't numbered, so the four spotlight steps carry the count.
|
||||
const started = telemetry.track.mock.calls.find(
|
||||
([stage]) => stage === 'started'
|
||||
)
|
||||
expect(started?.[1]).toEqual({ tour: 'appMode', step_count: 4 })
|
||||
|
||||
const landingShown = telemetry.track.mock.calls.find(
|
||||
([stage]) => stage === 'step_shown'
|
||||
)
|
||||
expect(landingShown?.[1]).toEqual({ tour: 'appMode', step_count: 4 })
|
||||
|
||||
store.next()
|
||||
await nextTick()
|
||||
const shown = telemetry.track.mock.calls
|
||||
.filter(([stage]) => stage === 'step_shown')
|
||||
.at(-1)
|
||||
expect(shown?.[1]).toEqual({
|
||||
tour: 'appMode',
|
||||
step_count: 4,
|
||||
step_number: 1,
|
||||
coach_id: 'inputs-list'
|
||||
})
|
||||
})
|
||||
|
||||
it('advancing off the landing does not mark the tour skipped', async () => {
|
||||
registerAppModeTargets()
|
||||
const store = mountStore()
|
||||
store.replayTour('appMode')
|
||||
await nextTick()
|
||||
expect(store.step?.landing).toBe(true)
|
||||
|
||||
store.next()
|
||||
await nextTick()
|
||||
|
||||
expect(store.step?.landing).toBeFalsy()
|
||||
const skipped = telemetry.track.mock.calls.some(
|
||||
([stage]) => stage === 'skipped'
|
||||
)
|
||||
expect(skipped).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,238 +0,0 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, readonly, ref, shallowRef, watch } from 'vue'
|
||||
|
||||
import { t, te } from '@/i18n'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type {
|
||||
OnboardingTourSkipReason,
|
||||
OnboardingTourStage
|
||||
} from '@/platform/telemetry/types'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
|
||||
import { targetMounted, waitForTarget } from './coachmarkRegistry'
|
||||
import { TOURS, TOUR_SEEN_SETTING, resolveSteps } from './onboardingTours'
|
||||
import type { CoachStep, EntryPath } from './onboardingTours'
|
||||
import { useTourTriggers } from './useTourTriggers'
|
||||
|
||||
const DEFER_TIMEOUT_MS = 8000
|
||||
|
||||
/**
|
||||
* The tour state machine: which tour starts and when, which steps run, and the
|
||||
* advance/skip/complete lifecycle.
|
||||
*/
|
||||
export const useOnboardingTourStore = defineStore('onboardingTour', () => {
|
||||
const settingStore = useSettingStore()
|
||||
const telemetry = useTelemetry()
|
||||
|
||||
const steps = shallowRef<CoachStep[]>([])
|
||||
const stepIdx = ref(0)
|
||||
const waitingForTarget = ref(false)
|
||||
const activeTour = ref<EntryPath | null>(null)
|
||||
let stepController: AbortController | null = null
|
||||
|
||||
const step = computed<CoachStep | null>(
|
||||
() => steps.value[stepIdx.value] ?? null
|
||||
)
|
||||
const isLast = computed(() => stepIdx.value === steps.value.length - 1)
|
||||
|
||||
const countedSteps = computed(() => steps.value.filter((s) => !s.landing))
|
||||
const countedStepsTotal = computed(() => countedSteps.value.length)
|
||||
const countedStepIdx = computed(() => {
|
||||
const s = step.value
|
||||
return s ? countedSteps.value.indexOf(s) : 0
|
||||
})
|
||||
// Back navigates the numbered steps only — never into the landing.
|
||||
const canGoBack = computed(() => countedStepIdx.value > 0)
|
||||
|
||||
function trackTour(
|
||||
stage: OnboardingTourStage,
|
||||
skipReason?: OnboardingTourSkipReason
|
||||
) {
|
||||
const tour = activeTour.value
|
||||
// Definition-driven tours (firstRun) aren't in TOURS and report their own telemetry.
|
||||
if (!tour || !TOURS[tour]) return
|
||||
telemetry?.trackOnboardingTour(stage, {
|
||||
tour,
|
||||
step_count: countedSteps.value.length,
|
||||
...(stage !== 'started' &&
|
||||
countedStepIdx.value >= 0 && {
|
||||
step_number: countedStepIdx.value + 1,
|
||||
coach_id: step.value?.coachId
|
||||
}),
|
||||
...(skipReason && { skip_reason: skipReason })
|
||||
})
|
||||
}
|
||||
|
||||
function stepKey(suffix: string) {
|
||||
return `onboardingCoachmarks.${activeTour.value}.${step.value?.name}.${suffix}`
|
||||
}
|
||||
|
||||
const title = computed(() => (step.value ? t(stepKey('title')) : ''))
|
||||
const body = computed(() => (step.value ? t(stepKey('body')) : ''))
|
||||
|
||||
// A step overrides the generic button labels by declaring `primary`/`skip`
|
||||
// entries under its translation keys.
|
||||
const primaryLabel = computed(() => {
|
||||
if (step.value && te(stepKey('primary'))) return t(stepKey('primary'))
|
||||
return isLast.value
|
||||
? t('onboardingCoachmarks.done')
|
||||
: t('onboardingCoachmarks.next')
|
||||
})
|
||||
|
||||
const skipLabel = computed(() =>
|
||||
step.value && te(stepKey('skip'))
|
||||
? t(stepKey('skip'))
|
||||
: t('onboardingCoachmarks.skip')
|
||||
)
|
||||
|
||||
const backLabel = computed(() => t('onboardingCoachmarks.back'))
|
||||
|
||||
async function showStep(idx: number) {
|
||||
const nextStep = steps.value[idx]
|
||||
if (!nextStep) return
|
||||
stepController?.abort()
|
||||
const controller = new AbortController()
|
||||
stepController = controller
|
||||
const { signal } = controller
|
||||
waitingForTarget.value = false
|
||||
if (nextStep.openSidebarTab) openSidebarTab(nextStep.openSidebarTab)
|
||||
if (
|
||||
nextStep.deferTarget &&
|
||||
nextStep.coachId &&
|
||||
!targetMounted(nextStep.coachId)
|
||||
) {
|
||||
waitingForTarget.value = true
|
||||
const found = await waitForTarget(
|
||||
nextStep.coachId,
|
||||
signal,
|
||||
DEFER_TIMEOUT_MS
|
||||
)
|
||||
if (signal.aborted) {
|
||||
if (stepController === controller) waitingForTarget.value = false
|
||||
return
|
||||
}
|
||||
waitingForTarget.value = false
|
||||
// Point at the timed-out step so telemetry reports it, and skip without
|
||||
// the seen-flag so a missed target isn't permanent.
|
||||
if (!found) {
|
||||
stepIdx.value = idx
|
||||
finish('skipped', { markSeen: false, skipReason: 'target_timeout' })
|
||||
useToastStore().add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('onboardingCoachmarks.loadError')
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
stepIdx.value = idx
|
||||
trackTour('step_shown')
|
||||
}
|
||||
|
||||
function openSidebarTab(tabId: string) {
|
||||
const sidebar = useSidebarTabStore()
|
||||
if (sidebar.activeSidebarTabId !== tabId) sidebar.toggleSidebarTab(tabId)
|
||||
}
|
||||
|
||||
function next() {
|
||||
if (waitingForTarget.value) return
|
||||
if (isLast.value) {
|
||||
finish('completed')
|
||||
return
|
||||
}
|
||||
void showStep(stepIdx.value + 1)
|
||||
}
|
||||
|
||||
function back() {
|
||||
if (canGoBack.value) void showStep(stepIdx.value - 1)
|
||||
}
|
||||
|
||||
function skip() {
|
||||
finish('skipped')
|
||||
}
|
||||
|
||||
function finish(
|
||||
outcome: 'completed' | 'skipped',
|
||||
{
|
||||
markSeen = true,
|
||||
skipReason = 'user'
|
||||
}: { markSeen?: boolean; skipReason?: OnboardingTourSkipReason } = {}
|
||||
) {
|
||||
trackTour(outcome, outcome === 'skipped' ? skipReason : undefined)
|
||||
stepController?.abort()
|
||||
waitingForTarget.value = false
|
||||
steps.value = []
|
||||
stepIdx.value = 0
|
||||
if (markSeen && activeTour.value) markTourSeen(activeTour.value)
|
||||
activeTour.value = null
|
||||
}
|
||||
|
||||
for (const [entryPath, trigger] of useTourTriggers()) {
|
||||
watch(
|
||||
trigger.autoOpen,
|
||||
(visible) => {
|
||||
if (visible) startTour(entryPath)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
watch(trigger.holds, (holding) => {
|
||||
if (!holding && activeTour.value === entryPath)
|
||||
finish('skipped', { markSeen: false, skipReason: 'trigger_lost' })
|
||||
})
|
||||
}
|
||||
|
||||
function hasSeenTour(entryPath: EntryPath): boolean {
|
||||
return settingStore.get(TOUR_SEEN_SETTING).includes(entryPath)
|
||||
}
|
||||
|
||||
function markTourSeen(entryPath: EntryPath) {
|
||||
const seen = settingStore.get(TOUR_SEEN_SETTING)
|
||||
if (seen.includes(entryPath)) return
|
||||
void settingStore.set(TOUR_SEEN_SETTING, [...seen, entryPath])
|
||||
}
|
||||
|
||||
function startTour(
|
||||
entryPath: EntryPath,
|
||||
{
|
||||
force = false,
|
||||
definition
|
||||
}: { force?: boolean; definition?: CoachStep[] } = {}
|
||||
) {
|
||||
if (steps.value.length) return
|
||||
if (!force && hasSeenTour(entryPath)) return
|
||||
const def = definition ?? TOURS[entryPath]
|
||||
if (!def) return
|
||||
const resolved = resolveSteps(def, targetMounted)
|
||||
if (!resolved.length) return
|
||||
steps.value = resolved
|
||||
activeTour.value = entryPath
|
||||
trackTour('started')
|
||||
void showStep(0)
|
||||
}
|
||||
|
||||
function replayTour(entryPath: EntryPath) {
|
||||
startTour(entryPath, { force: true })
|
||||
}
|
||||
|
||||
return {
|
||||
step,
|
||||
isLast,
|
||||
canGoBack,
|
||||
title,
|
||||
body,
|
||||
primaryLabel,
|
||||
skipLabel,
|
||||
backLabel,
|
||||
countedStepIdx,
|
||||
countedStepsTotal,
|
||||
waitingForTarget,
|
||||
activeTour: readonly(activeTour),
|
||||
startTour,
|
||||
replayTour,
|
||||
next,
|
||||
back,
|
||||
skip
|
||||
}
|
||||
})
|
||||
@@ -1,36 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { resolveSteps } from './onboardingTours'
|
||||
import type { CoachStep } from './onboardingTours'
|
||||
|
||||
function step(overrides: Partial<CoachStep> = {}): CoachStep {
|
||||
return {
|
||||
name: 'step',
|
||||
placement: 'center',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('resolveSteps', () => {
|
||||
const isMounted = (mounted: boolean) => () => mounted
|
||||
|
||||
it('keeps a targetless step', () => {
|
||||
const steps = [step()]
|
||||
expect(resolveSteps(steps, isMounted(false))).toEqual(steps)
|
||||
})
|
||||
|
||||
it('drops a step whose target is not mounted', () => {
|
||||
const steps = [step({ coachId: 'app-run-button' })]
|
||||
expect(resolveSteps(steps, isMounted(false))).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps a mounted step', () => {
|
||||
const steps = [step({ coachId: 'app-run-button' })]
|
||||
expect(resolveSteps(steps, isMounted(true))).toEqual(steps)
|
||||
})
|
||||
|
||||
it('keeps a deferred step even before its target mounts', () => {
|
||||
const steps = [step({ coachId: 'app-run-button', deferTarget: true })]
|
||||
expect(resolveSteps(steps, isMounted(false))).toEqual(steps)
|
||||
})
|
||||
})
|
||||
@@ -1,92 +0,0 @@
|
||||
export type EntryPath = 'appMode' | 'firstRun'
|
||||
|
||||
/** Setting holding the tours the user has completed or dismissed. */
|
||||
export const TOUR_SEEN_SETTING = 'Comfy.OnboardingCoachmarks.Seen'
|
||||
|
||||
export type CoachPlacement =
|
||||
| 'left'
|
||||
| 'right'
|
||||
| 'center'
|
||||
| 'bottom'
|
||||
/** Left of the target, vertically centred on it (clamps to the viewport edge). */
|
||||
| 'leftCenter'
|
||||
/** Sits on whichever horizontal side of the target has more room. */
|
||||
| 'auto'
|
||||
|
||||
/** Every element a tour can point at; the e2e drift guard asserts each resolves. */
|
||||
export const COACH_IDS = {
|
||||
appRunButton: 'app-run-button',
|
||||
inputsList: 'inputs-list',
|
||||
outputs: 'outputs',
|
||||
assetsPanel: 'assets-panel'
|
||||
} as const
|
||||
|
||||
export type CoachId = (typeof COACH_IDS)[keyof typeof COACH_IDS]
|
||||
|
||||
export interface CoachStep {
|
||||
/**
|
||||
* Derives the step's translation keys:
|
||||
* `onboardingCoachmarks.<tour>.<name>.title|body`, plus optional
|
||||
* `primary`/`skip` button-label overrides.
|
||||
*/
|
||||
name: string
|
||||
/** Element to spotlight (the first laid-out registered candidate wins). */
|
||||
coachId?: CoachId
|
||||
placement: CoachPlacement
|
||||
/** Open this sidebar tab when the step starts (e.g. to reveal its target). */
|
||||
openSidebarTab?: string
|
||||
/** Target mounts later (e.g. a dialog); wait for it instead of dropping the step. */
|
||||
deferTarget?: boolean
|
||||
/** Renders the landing dialog instead of a spotlight. */
|
||||
landing?: boolean
|
||||
image?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixes the running step set (and so the step count) at tour start: drops steps
|
||||
* whose own target isn't mounted, keeping targetless and deferred steps.
|
||||
*/
|
||||
export function resolveSteps(
|
||||
steps: CoachStep[],
|
||||
isMounted: (id: CoachId) => boolean
|
||||
): CoachStep[] {
|
||||
return steps.filter(
|
||||
(s) => !s.coachId || s.deferTarget || isMounted(s.coachId)
|
||||
)
|
||||
}
|
||||
|
||||
export const TOURS: Partial<Record<EntryPath, CoachStep[]>> = {
|
||||
appMode: [
|
||||
{
|
||||
name: 'landing',
|
||||
landing: true,
|
||||
placement: 'center',
|
||||
image: '/assets/images/app-mode-landing.png'
|
||||
},
|
||||
{
|
||||
name: 'inputs',
|
||||
coachId: COACH_IDS.inputsList,
|
||||
placement: 'auto',
|
||||
deferTarget: true
|
||||
},
|
||||
{
|
||||
name: 'run',
|
||||
coachId: COACH_IDS.appRunButton,
|
||||
placement: 'auto',
|
||||
deferTarget: true
|
||||
},
|
||||
{
|
||||
name: 'outputs',
|
||||
coachId: COACH_IDS.outputs,
|
||||
placement: 'leftCenter',
|
||||
deferTarget: true
|
||||
},
|
||||
{
|
||||
name: 'assets',
|
||||
coachId: COACH_IDS.assetsPanel,
|
||||
placement: 'auto',
|
||||
deferTarget: true,
|
||||
openSidebarTab: 'assets'
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { effectScope, ref } from 'vue'
|
||||
|
||||
import { clearCoachmarks, registerCoachmark } from './coachmarkRegistry'
|
||||
import type { CoachId, CoachStep } from './onboardingTours'
|
||||
import { useCoachmarkTarget } from './useCoachmarkTarget'
|
||||
|
||||
function step(coachId: CoachId): CoachStep {
|
||||
return { name: 'step', placement: 'right', coachId }
|
||||
}
|
||||
|
||||
function laidOut(): HTMLElement {
|
||||
const el = document.createElement('div')
|
||||
el.getBoundingClientRect = () => new DOMRect(10, 10, 80, 30)
|
||||
return el
|
||||
}
|
||||
|
||||
function hidden(): HTMLElement {
|
||||
const el = document.createElement('div')
|
||||
el.getBoundingClientRect = () => new DOMRect(0, 0, 0, 0)
|
||||
return el
|
||||
}
|
||||
|
||||
describe('useCoachmarkTarget', () => {
|
||||
afterEach(clearCoachmarks)
|
||||
|
||||
function setup(coachId: CoachId) {
|
||||
const scope = effectScope()
|
||||
const stepRef = ref<CoachStep | null>(step(coachId))
|
||||
const cardRef = ref<HTMLElement | null>(null)
|
||||
const api = scope.run(() => useCoachmarkTarget(stepRef, cardRef))!
|
||||
return { scope, api }
|
||||
}
|
||||
|
||||
it('resolves the first laid-out candidate for the step target', () => {
|
||||
const el = laidOut()
|
||||
registerCoachmark('outputs', el)
|
||||
const { scope, api } = setup('outputs')
|
||||
expect(api.targetEl.value).toBe(el)
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('skips a registered target that is not laid out', () => {
|
||||
registerCoachmark('outputs', hidden())
|
||||
const laid = laidOut()
|
||||
registerCoachmark('outputs', laid)
|
||||
const { scope, api } = setup('outputs')
|
||||
expect(api.targetEl.value).toBe(laid)
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('picks up a target that registers after the step starts', () => {
|
||||
const { scope, api } = setup('outputs')
|
||||
expect(api.targetEl.value).toBeNull()
|
||||
|
||||
const el = laidOut()
|
||||
registerCoachmark('outputs', el)
|
||||
expect(api.targetEl.value).toBe(el)
|
||||
scope.stop()
|
||||
})
|
||||
})
|
||||
@@ -1,136 +0,0 @@
|
||||
import { autoUpdate, flip, offset, shift, useFloating } from '@floating-ui/vue'
|
||||
import type { Middleware, Placement, Rect } from '@floating-ui/vue'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { computed, ref, toValue, watch, watchEffect } from 'vue'
|
||||
import type { MaybeRefOrGetter, Ref } from 'vue'
|
||||
|
||||
import { CARD_GAP, VIEWPORT_MARGIN, topSafeInset } from './coachmarkLayout'
|
||||
import { coachmarkElements, isLaidOut } from './coachmarkRegistry'
|
||||
import type { CoachPlacement, CoachStep } from './onboardingTours'
|
||||
|
||||
// A target animating in via CSS transform reports through neither scroll nor
|
||||
// resize events; poll each frame until its rect holds still this long.
|
||||
const MOTION_SETTLE_MS = 250
|
||||
|
||||
const PLACEMENT: Record<
|
||||
Exclude<CoachPlacement, 'auto' | 'center'>,
|
||||
Placement
|
||||
> = {
|
||||
left: 'left-start',
|
||||
right: 'right-start',
|
||||
leftCenter: 'left',
|
||||
bottom: 'bottom'
|
||||
}
|
||||
|
||||
function floatingPlacement(step: CoachStep | null): Placement {
|
||||
const placement = step?.placement
|
||||
if (!placement || placement === 'auto' || placement === 'center')
|
||||
return 'right-start'
|
||||
return PLACEMENT[placement]
|
||||
}
|
||||
|
||||
// Surfaces the measured target rect so the spotlight can trace it.
|
||||
const captureReference: Middleware = {
|
||||
name: 'captureReference',
|
||||
fn: (state) => ({ data: { rect: state.rects.reference } })
|
||||
}
|
||||
|
||||
function middleware(step: CoachStep | null, topInset: number): Middleware[] {
|
||||
const list: Middleware[] = [offset(CARD_GAP)]
|
||||
if (!step?.placement || step.placement === 'auto') list.push(flip())
|
||||
// shift only guards the main axis by default; crossAxis keeps vertically-
|
||||
// centred placements (leftCenter) on-screen too.
|
||||
list.push(
|
||||
shift({
|
||||
crossAxis: true,
|
||||
padding: {
|
||||
top: topInset,
|
||||
left: VIEWPORT_MARGIN,
|
||||
right: VIEWPORT_MARGIN,
|
||||
bottom: CARD_GAP
|
||||
}
|
||||
}),
|
||||
captureReference
|
||||
)
|
||||
return list
|
||||
}
|
||||
|
||||
/**
|
||||
* Locates a step's target in the registry and positions the card beside it with
|
||||
* Floating UI, following the target until its rect settles.
|
||||
*/
|
||||
export function useCoachmarkTarget(
|
||||
step: MaybeRefOrGetter<CoachStep | null>,
|
||||
cardRef: Ref<HTMLElement | null>
|
||||
) {
|
||||
const candidateEls = computed<readonly HTMLElement[]>(() => {
|
||||
const id = toValue(step)?.coachId
|
||||
return id ? coachmarkElements(id) : []
|
||||
})
|
||||
|
||||
const targetEl = computed<HTMLElement | null>(
|
||||
() => candidateEls.value.find(isLaidOut) ?? null
|
||||
)
|
||||
|
||||
// The top bar's height only changes on resize, so read it once and refresh
|
||||
// then — Floating UI re-runs the middleware every frame while tracking motion.
|
||||
const topInset = ref(topSafeInset())
|
||||
useEventListener('resize', () => {
|
||||
topInset.value = topSafeInset()
|
||||
})
|
||||
|
||||
const { floatingStyles, middlewareData, isPositioned, update } = useFloating(
|
||||
targetEl,
|
||||
cardRef,
|
||||
{
|
||||
strategy: 'fixed',
|
||||
transform: false,
|
||||
placement: () => floatingPlacement(toValue(step)),
|
||||
middleware: () => middleware(toValue(step), topInset.value)
|
||||
}
|
||||
)
|
||||
|
||||
const targetRect = computed<DOMRect | null>(() => {
|
||||
const data = middlewareData.value.captureReference as
|
||||
| { rect: Rect }
|
||||
| undefined
|
||||
const rect = data?.rect
|
||||
if (!rect || rect.width === 0) return null
|
||||
return new DOMRect(rect.x, rect.y, rect.width, rect.height)
|
||||
})
|
||||
|
||||
const trackMotion = ref(false)
|
||||
const rectKey = computed(() => {
|
||||
const r = targetRect.value
|
||||
return r ? `${r.x},${r.y},${r.width},${r.height}` : ''
|
||||
})
|
||||
|
||||
watch(
|
||||
() => toValue(step),
|
||||
(s) => {
|
||||
trackMotion.value = !!s?.deferTarget
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(rectKey, (_key, _prev, onCleanup) => {
|
||||
if (!trackMotion.value) return
|
||||
const timer = setTimeout(() => {
|
||||
trackMotion.value = false
|
||||
}, MOTION_SETTLE_MS)
|
||||
onCleanup(() => clearTimeout(timer))
|
||||
})
|
||||
|
||||
watchEffect((onCleanup) => {
|
||||
const reference = targetEl.value
|
||||
const floating = cardRef.value
|
||||
if (!reference || !floating) return
|
||||
onCleanup(
|
||||
autoUpdate(reference, floating, update, {
|
||||
animationFrame: trackMotion.value
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
return { targetEl, targetRect, floatingStyles, isPositioned }
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { computed } from 'vue'
|
||||
import type { ComputedRef } from 'vue'
|
||||
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useDesktopLayout } from '@/composables/useDesktopLayout'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
|
||||
import type { EntryPath } from './onboardingTours'
|
||||
|
||||
export interface TourTrigger {
|
||||
holds: ComputedRef<boolean>
|
||||
autoOpen: ComputedRef<boolean>
|
||||
}
|
||||
|
||||
/** Each tour's auto-open condition, paired with its entry path. */
|
||||
export function useTourTriggers(): [EntryPath, TourTrigger][] {
|
||||
const { mode } = useAppMode()
|
||||
const appModeStore = useAppModeStore()
|
||||
const desktopLayout = useDesktopLayout()
|
||||
const inAppMode = computed(() => desktopLayout.value && mode.value === 'app')
|
||||
return [
|
||||
[
|
||||
'appMode',
|
||||
{
|
||||
holds: inAppMode,
|
||||
autoOpen: computed(() => inAppMode.value && appModeStore.hasOutputs)
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { cleanup, render } from '@testing-library/vue'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { defineComponent, h, nextTick, ref, withDirectives } from 'vue'
|
||||
|
||||
import { coachmarkElements } from './coachmarkRegistry'
|
||||
import type { CoachId } from './onboardingTours'
|
||||
import { vCoachmark } from './vCoachmark'
|
||||
|
||||
// Mounts a host element carrying `v-coachmark`, so the directive runs through
|
||||
// Vue's real mount/update/unmount lifecycle rather than hand-called hooks.
|
||||
function mountHost(initial: CoachId | null) {
|
||||
const coachId = ref<CoachId | null>(initial)
|
||||
const rev = ref(0)
|
||||
const Host = defineComponent({
|
||||
setup: () => () =>
|
||||
withDirectives(
|
||||
h('div', { 'data-testid': 'host', 'data-rev': rev.value }),
|
||||
[[vCoachmark, coachId.value]]
|
||||
)
|
||||
})
|
||||
return {
|
||||
...render(Host),
|
||||
coachId,
|
||||
// Forces a re-render without touching the bound id, to exercise the
|
||||
// no-op-update guard.
|
||||
rerender: () => {
|
||||
rev.value++
|
||||
return nextTick()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('vCoachmark', () => {
|
||||
afterEach(cleanup)
|
||||
|
||||
it('registers the element and mirrors the id to data-coach-id on mount', () => {
|
||||
const { getByTestId } = mountHost('app-run-button')
|
||||
const host = getByTestId('host')
|
||||
expect(host.dataset.coachId).toBe('app-run-button')
|
||||
expect(coachmarkElements('app-run-button')).toContain(host)
|
||||
})
|
||||
|
||||
it('does not register an element bound to a falsy id', () => {
|
||||
const { getByTestId } = mountHost(null)
|
||||
expect(getByTestId('host').dataset.coachId).toBeUndefined()
|
||||
expect(coachmarkElements('app-run-button')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('moves the element to the new id when the binding changes', async () => {
|
||||
const { getByTestId, coachId } = mountHost('app-run-button')
|
||||
const host = getByTestId('host')
|
||||
coachId.value = 'outputs'
|
||||
await nextTick()
|
||||
expect(host.dataset.coachId).toBe('outputs')
|
||||
expect(coachmarkElements('app-run-button')).not.toContain(host)
|
||||
expect(coachmarkElements('outputs')).toContain(host)
|
||||
})
|
||||
|
||||
it('ignores a re-render that leaves the id unchanged', async () => {
|
||||
const { getByTestId, rerender } = mountHost('app-run-button')
|
||||
const host = getByTestId('host')
|
||||
await rerender()
|
||||
expect(coachmarkElements('app-run-button')).toEqual([host])
|
||||
})
|
||||
|
||||
it('unregisters and clears data-coach-id on unmount', () => {
|
||||
const { getByTestId, unmount } = mountHost('app-run-button')
|
||||
const host = getByTestId('host')
|
||||
unmount()
|
||||
expect(host.dataset.coachId).toBeUndefined()
|
||||
expect(coachmarkElements('app-run-button')).not.toContain(host)
|
||||
})
|
||||
})
|
||||
@@ -1,29 +0,0 @@
|
||||
import type { Directive } from 'vue'
|
||||
|
||||
import { registerCoachmark, unregisterCoachmark } from './coachmarkRegistry'
|
||||
import type { CoachId } from './onboardingTours'
|
||||
|
||||
// The element's `data-coach-id` is the record of what it is registered as.
|
||||
function sync(el: HTMLElement, id: CoachId | undefined | null) {
|
||||
const prev = el.dataset.coachId as CoachId | undefined
|
||||
if (prev === id) return
|
||||
if (prev) {
|
||||
unregisterCoachmark(prev, el)
|
||||
delete el.dataset.coachId
|
||||
}
|
||||
if (id) {
|
||||
el.dataset.coachId = id
|
||||
registerCoachmark(id, el)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks an element as a coach-mark target: registers it in the reactive
|
||||
* registry and mirrors the id to `data-coach-id` for e2e locators. A falsy
|
||||
* value is a no-op, so it can be bound to a conditional id.
|
||||
*/
|
||||
export const vCoachmark: Directive<HTMLElement, CoachId | undefined | null> = {
|
||||
mounted: (el, { value }) => sync(el, value),
|
||||
updated: (el, { value }) => sync(el, value),
|
||||
unmounted: (el) => sync(el, null)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
cachedBillingControlEnabled,
|
||||
cachedConsolidatedBillingEnabled,
|
||||
cachedTeamWorkspacesEnabled,
|
||||
remoteConfig,
|
||||
remoteConfigState
|
||||
@@ -60,8 +60,8 @@ export async function refreshRemoteConfig(
|
||||
cachedTeamWorkspacesEnabled.value = Boolean(
|
||||
config.team_workspaces_enabled
|
||||
)
|
||||
cachedBillingControlEnabled.value = Boolean(
|
||||
config.billing_control_enabled
|
||||
cachedConsolidatedBillingEnabled.value = Boolean(
|
||||
config.consolidated_billing_enabled
|
||||
)
|
||||
}
|
||||
return
|
||||
|
||||
@@ -60,7 +60,7 @@ export const cachedTeamWorkspacesEnabled = useStorage<boolean | undefined>(
|
||||
undefined
|
||||
)
|
||||
|
||||
export const cachedBillingControlEnabled = useStorage<boolean | undefined>(
|
||||
'billing_control_enabled' satisfies `${ServerFeatureFlag.BILLING_CONTROL_ENABLED}`,
|
||||
export const cachedConsolidatedBillingEnabled = useStorage<boolean | undefined>(
|
||||
'consolidated_billing_enabled' satisfies `${ServerFeatureFlag.CONSOLIDATED_BILLING_ENABLED}`,
|
||||
undefined
|
||||
)
|
||||
|
||||
@@ -110,18 +110,12 @@ export type RemoteConfig = {
|
||||
user_secrets_enabled?: boolean
|
||||
node_library_essentials_enabled?: boolean
|
||||
free_tier_credits?: number
|
||||
free_tier_balance?: {
|
||||
allowance: number
|
||||
used: number
|
||||
remaining: number
|
||||
}
|
||||
new_free_tier_subscriptions?: boolean
|
||||
workflow_sharing_enabled?: boolean
|
||||
comfyhub_upload_enabled?: boolean
|
||||
comfyhub_profile_gate_enabled?: boolean
|
||||
unified_cloud_auth?: boolean
|
||||
billing_control_enabled?: boolean
|
||||
onboarding_tour_enabled?: boolean
|
||||
consolidated_billing_enabled?: boolean
|
||||
sentry_dsn?: string
|
||||
turnstile_sitekey?: string
|
||||
// Raw, unvalidated wire value (a server typo like 'enfroce' is possible).
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
SUPPORTED_LOCALE_OPTIONS
|
||||
} from '@/locales/localeConfig'
|
||||
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
|
||||
import { TOUR_SEEN_SETTING } from '@/platform/onboarding/onboardingTours'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import type { SettingParams } from '@/platform/settings/types'
|
||||
import type { ColorPalettes } from '@/schemas/colorPaletteSchema'
|
||||
@@ -978,13 +977,6 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
defaultValue: false,
|
||||
versionAdded: '1.8.7'
|
||||
},
|
||||
{
|
||||
id: TOUR_SEEN_SETTING,
|
||||
name: 'Onboarding coachmark tours the user has already seen',
|
||||
type: 'hidden',
|
||||
defaultValue: [],
|
||||
versionAdded: '1.48.0'
|
||||
},
|
||||
{
|
||||
id: 'Comfy.InstalledVersion',
|
||||
name: 'The frontend version that was running when the user first installed ComfyUI',
|
||||
|
||||
@@ -17,8 +17,6 @@ import type {
|
||||
NodeAddedMetadata,
|
||||
NodeSearchMetadata,
|
||||
NodeSearchResultMetadata,
|
||||
OnboardingTourMetadata,
|
||||
OnboardingTourStage,
|
||||
SearchQueryMetadata,
|
||||
PageViewMetadata,
|
||||
PageVisibilityMetadata,
|
||||
@@ -172,13 +170,6 @@ export class TelemetryRegistry implements TelemetryDispatcher {
|
||||
this.dispatch((provider) => provider.trackSurvey?.(stage, responses))
|
||||
}
|
||||
|
||||
trackOnboardingTour(
|
||||
stage: OnboardingTourStage,
|
||||
metadata: OnboardingTourMetadata
|
||||
): void {
|
||||
this.dispatch((provider) => provider.trackOnboardingTour?.(stage, metadata))
|
||||
}
|
||||
|
||||
trackEmailVerification(stage: 'opened' | 'requested' | 'completed'): void {
|
||||
this.dispatch((provider) => provider.trackEmailVerification?.(stage))
|
||||
}
|
||||
|
||||
@@ -38,8 +38,6 @@ import type {
|
||||
AuthMetadata,
|
||||
DefaultViewSetMetadata,
|
||||
EnterLinearMetadata,
|
||||
OnboardingTourMetadata,
|
||||
OnboardingTourStage,
|
||||
RunButtonProperties,
|
||||
ShareFlowMetadata,
|
||||
ShellLayoutMetadata,
|
||||
@@ -462,42 +460,6 @@ describe('MixpanelTelemetryProvider — direct event tracking methods', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it.for<
|
||||
[
|
||||
OnboardingTourStage,
|
||||
(typeof TelemetryEvents)[keyof typeof TelemetryEvents]
|
||||
]
|
||||
>([
|
||||
['started', TelemetryEvents.ONBOARDING_TOUR_STARTED],
|
||||
['step_shown', TelemetryEvents.ONBOARDING_TOUR_STEP_SHOWN],
|
||||
['completed', TelemetryEvents.ONBOARDING_TOUR_COMPLETED],
|
||||
['skipped', TelemetryEvents.ONBOARDING_TOUR_SKIPPED],
|
||||
['run_triggered', TelemetryEvents.ONBOARDING_TOUR_RUN_TRIGGERED],
|
||||
['upgrade_shown', TelemetryEvents.ONBOARDING_TOUR_UPGRADE_SHOWN],
|
||||
['nudge_shown', TelemetryEvents.ONBOARDING_TOUR_NUDGE_SHOWN],
|
||||
[
|
||||
'explore_templates_clicked',
|
||||
TelemetryEvents.ONBOARDING_TOUR_EXPLORE_TEMPLATES_CLICKED
|
||||
]
|
||||
])(
|
||||
'trackOnboardingTour(%s) dispatches %s',
|
||||
async ([stage, expectedEvent]) => {
|
||||
const provider = new MixpanelTelemetryProvider()
|
||||
await waitForMixpanelInit()
|
||||
mockMixpanel.track.mockClear()
|
||||
|
||||
const metadata: OnboardingTourMetadata = {
|
||||
tour: 'appMode',
|
||||
step_count: 6,
|
||||
step_number: 2,
|
||||
coach_id: 'app-run-button'
|
||||
}
|
||||
provider.trackOnboardingTour(stage, metadata)
|
||||
|
||||
expect(mockMixpanel.track).toHaveBeenCalledWith(expectedEvent, metadata)
|
||||
}
|
||||
)
|
||||
|
||||
it('omits share_id from existing Mixpanel events', async () => {
|
||||
const provider = new MixpanelTelemetryProvider()
|
||||
await waitForMixpanelInit()
|
||||
|
||||
@@ -20,8 +20,6 @@ import type {
|
||||
HelpResourceClickedMetadata,
|
||||
NodeSearchMetadata,
|
||||
NodeSearchResultMetadata,
|
||||
OnboardingTourMetadata,
|
||||
OnboardingTourStage,
|
||||
PageVisibilityMetadata,
|
||||
RunButtonProperties,
|
||||
SettingChangedMetadata,
|
||||
@@ -46,7 +44,7 @@ import type {
|
||||
} from '../../types'
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
import { OnboardingTourEvents, TelemetryEvents } from '../../types'
|
||||
import { TelemetryEvents } from '../../types'
|
||||
import { normalizeSurveyResponses } from '../../utils/surveyNormalization'
|
||||
|
||||
const DEFAULT_DISABLED_EVENTS = [
|
||||
@@ -282,13 +280,6 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
|
||||
this.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, properties)
|
||||
}
|
||||
|
||||
trackOnboardingTour(
|
||||
stage: OnboardingTourStage,
|
||||
metadata: OnboardingTourMetadata
|
||||
): void {
|
||||
this.trackEvent(OnboardingTourEvents[stage], metadata)
|
||||
}
|
||||
|
||||
trackSurvey(
|
||||
stage: 'opened' | 'submitted',
|
||||
responses?: SurveyResponses
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { Ref } from 'vue'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import { TelemetryEvents } from '../../types'
|
||||
import type { OnboardingTourStage } from '../../types'
|
||||
|
||||
const hoisted = vi.hoisted(() => {
|
||||
const mockCapture = vi.fn()
|
||||
@@ -719,45 +718,6 @@ describe('PostHogTelemetryProvider', () => {
|
||||
shellLayoutMetadata
|
||||
)
|
||||
})
|
||||
|
||||
it.for<
|
||||
[
|
||||
OnboardingTourStage,
|
||||
(typeof TelemetryEvents)[keyof typeof TelemetryEvents]
|
||||
]
|
||||
>([
|
||||
['started', TelemetryEvents.ONBOARDING_TOUR_STARTED],
|
||||
['step_shown', TelemetryEvents.ONBOARDING_TOUR_STEP_SHOWN],
|
||||
['completed', TelemetryEvents.ONBOARDING_TOUR_COMPLETED],
|
||||
['skipped', TelemetryEvents.ONBOARDING_TOUR_SKIPPED],
|
||||
['run_triggered', TelemetryEvents.ONBOARDING_TOUR_RUN_TRIGGERED],
|
||||
['upgrade_shown', TelemetryEvents.ONBOARDING_TOUR_UPGRADE_SHOWN],
|
||||
['nudge_shown', TelemetryEvents.ONBOARDING_TOUR_NUDGE_SHOWN],
|
||||
[
|
||||
'explore_templates_clicked',
|
||||
TelemetryEvents.ONBOARDING_TOUR_EXPLORE_TEMPLATES_CLICKED
|
||||
]
|
||||
])(
|
||||
'maps onboarding tour stage %s to %s',
|
||||
async ([stage, expectedEvent]) => {
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
const metadata = {
|
||||
tour: 'appMode',
|
||||
step_count: 6,
|
||||
step_number: 2,
|
||||
coach_id: 'app-run-button'
|
||||
} as const
|
||||
|
||||
provider.trackOnboardingTour(stage, metadata)
|
||||
|
||||
expect(hoisted.mockCapture).toHaveBeenCalledWith(
|
||||
expectedEvent,
|
||||
metadata
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('survey tracking', () => {
|
||||
|
||||
@@ -24,8 +24,6 @@ import type {
|
||||
NodeAddedMetadata,
|
||||
NodeSearchMetadata,
|
||||
NodeSearchResultMetadata,
|
||||
OnboardingTourMetadata,
|
||||
OnboardingTourStage,
|
||||
SearchQueryMetadata,
|
||||
PageViewMetadata,
|
||||
PageVisibilityMetadata,
|
||||
@@ -52,11 +50,7 @@ import type {
|
||||
WorkflowSavedMetadata,
|
||||
WorkspaceInviteMetadata
|
||||
} from '../../types'
|
||||
import {
|
||||
CANCELLATION_STAGE_EVENTS,
|
||||
OnboardingTourEvents,
|
||||
TelemetryEvents
|
||||
} from '../../types'
|
||||
import { CANCELLATION_STAGE_EVENTS, TelemetryEvents } from '../../types'
|
||||
import { normalizeSurveyResponses } from '../../utils/surveyNormalization'
|
||||
|
||||
const DEFAULT_DISABLED_EVENTS = [
|
||||
@@ -427,13 +421,6 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
this.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, properties)
|
||||
}
|
||||
|
||||
trackOnboardingTour(
|
||||
stage: OnboardingTourStage,
|
||||
metadata: OnboardingTourMetadata
|
||||
): void {
|
||||
this.trackEvent(OnboardingTourEvents[stage], metadata)
|
||||
}
|
||||
|
||||
trackSurvey(
|
||||
stage: 'opened' | 'submitted',
|
||||
responses?: SurveyResponses
|
||||
|
||||
@@ -33,7 +33,6 @@ export type PaymentIntentSource =
|
||||
| 'invite_member_upsell'
|
||||
| 'upload_model_upgrade'
|
||||
| 'team_upgrade_resume'
|
||||
| 'free_tier_quota'
|
||||
|
||||
export type SubscriptionCheckoutType = 'new' | 'change'
|
||||
export type SubscriptionCheckoutTier = TierKey | 'team'
|
||||
@@ -94,57 +93,6 @@ export interface SurveyResponses {
|
||||
usage?: string
|
||||
}
|
||||
|
||||
export type OnboardingTourStage =
|
||||
| 'started'
|
||||
| 'step_shown'
|
||||
| 'completed'
|
||||
| 'skipped'
|
||||
| 'run_triggered'
|
||||
| 'upgrade_shown'
|
||||
| 'nudge_shown'
|
||||
| 'explore_templates_clicked'
|
||||
|
||||
export type OnboardingTourSkipReason =
|
||||
| 'user'
|
||||
| 'target_timeout'
|
||||
| 'trigger_lost'
|
||||
|
||||
/**
|
||||
* `step_number` is 1-based and matches the "Step N of M" indicator the user
|
||||
* sees, with `step_count` as M. Both `step_number` and `coach_id` are absent
|
||||
* for steps with no numbered spotlight (e.g. the landing). `skip_reason` is
|
||||
* present only on the `skipped` stage. `step_count` is absent on `nudge_shown`
|
||||
* and `explore_templates_clicked`, which fire outside the step sequence.
|
||||
*/
|
||||
export interface OnboardingTourMetadata {
|
||||
tour: string
|
||||
step_count?: number
|
||||
step_number?: number
|
||||
coach_id?: string
|
||||
skip_reason?: OnboardingTourSkipReason
|
||||
}
|
||||
|
||||
/** `shape` labels the role-derived sequence, not the template — `'other'` is the
|
||||
* honest bucket for graphs the resolver handles best-effort but that aren't a
|
||||
* named shape. */
|
||||
export type OnboardingTourShape = 't2i' | 'i2v' | 'image-edit' | 'other'
|
||||
export type OnboardingTourEntry =
|
||||
| 'getting_started'
|
||||
| 'share_url'
|
||||
| 'template_url'
|
||||
export type OnboardingTourStepKey = 'upload' | 'prompt' | 'run' | 'result'
|
||||
export type OnboardingTourRunStatus = 'success' | 'error' | 'interrupted'
|
||||
|
||||
/** Reported only by the first-run tour. No field carries user content or a
|
||||
* share id, so no PII. */
|
||||
export interface FirstRunTourMetadata extends OnboardingTourMetadata {
|
||||
template_id?: string
|
||||
shape?: OnboardingTourShape
|
||||
entry?: OnboardingTourEntry
|
||||
step_key?: OnboardingTourStepKey
|
||||
status?: OnboardingTourRunStatus
|
||||
}
|
||||
|
||||
export interface SurveyResponsesNormalized extends SurveyResponses {
|
||||
industry_normalized?: string
|
||||
industry_raw?: string
|
||||
@@ -635,12 +583,6 @@ export interface TelemetryProvider {
|
||||
// Survey flow events
|
||||
trackSurvey?(stage: 'opened' | 'submitted', responses?: SurveyResponses): void
|
||||
|
||||
// Onboarding coachmark tour events
|
||||
trackOnboardingTour?(
|
||||
stage: OnboardingTourStage,
|
||||
metadata: OnboardingTourMetadata
|
||||
): void
|
||||
|
||||
// Email verification events
|
||||
trackEmailVerification?(stage: 'opened' | 'requested' | 'completed'): void
|
||||
|
||||
@@ -747,17 +689,6 @@ export const TelemetryEvents = {
|
||||
USER_SURVEY_OPENED: 'app:user_survey_opened',
|
||||
USER_SURVEY_SUBMITTED: 'app:user_survey_submitted',
|
||||
|
||||
// Onboarding Tour
|
||||
ONBOARDING_TOUR_STARTED: 'app:onboarding_tour_started',
|
||||
ONBOARDING_TOUR_STEP_SHOWN: 'app:onboarding_tour_step_shown',
|
||||
ONBOARDING_TOUR_COMPLETED: 'app:onboarding_tour_completed',
|
||||
ONBOARDING_TOUR_SKIPPED: 'app:onboarding_tour_skipped',
|
||||
ONBOARDING_TOUR_RUN_TRIGGERED: 'app:onboarding_tour_run_triggered',
|
||||
ONBOARDING_TOUR_UPGRADE_SHOWN: 'app:onboarding_tour_upgrade_shown',
|
||||
ONBOARDING_TOUR_NUDGE_SHOWN: 'app:onboarding_tour_nudge_shown',
|
||||
ONBOARDING_TOUR_EXPLORE_TEMPLATES_CLICKED:
|
||||
'app:onboarding_tour_explore_templates_clicked',
|
||||
|
||||
// Email Verification
|
||||
USER_EMAIL_VERIFY_OPENED: 'app:user_email_verify_opened',
|
||||
USER_EMAIL_VERIFY_REQUESTED: 'app:user_email_verify_requested',
|
||||
@@ -821,21 +752,6 @@ export const TelemetryEvents = {
|
||||
export type TelemetryEventName =
|
||||
(typeof TelemetryEvents)[keyof typeof TelemetryEvents]
|
||||
|
||||
export const OnboardingTourEvents: Record<
|
||||
OnboardingTourStage,
|
||||
TelemetryEventName
|
||||
> = {
|
||||
started: TelemetryEvents.ONBOARDING_TOUR_STARTED,
|
||||
step_shown: TelemetryEvents.ONBOARDING_TOUR_STEP_SHOWN,
|
||||
completed: TelemetryEvents.ONBOARDING_TOUR_COMPLETED,
|
||||
skipped: TelemetryEvents.ONBOARDING_TOUR_SKIPPED,
|
||||
run_triggered: TelemetryEvents.ONBOARDING_TOUR_RUN_TRIGGERED,
|
||||
upgrade_shown: TelemetryEvents.ONBOARDING_TOUR_UPGRADE_SHOWN,
|
||||
nudge_shown: TelemetryEvents.ONBOARDING_TOUR_NUDGE_SHOWN,
|
||||
explore_templates_clicked:
|
||||
TelemetryEvents.ONBOARDING_TOUR_EXPLORE_TEMPLATES_CLICKED
|
||||
}
|
||||
|
||||
export const CANCELLATION_STAGE_EVENTS = {
|
||||
flow_opened: TelemetryEvents.SUBSCRIPTION_CANCEL_FLOW_OPENED,
|
||||
confirmed: TelemetryEvents.SUBSCRIPTION_CANCEL_CONFIRMED,
|
||||
@@ -856,7 +772,6 @@ export type ExecutionTriggerSource =
|
||||
export type TelemetryEventProperties =
|
||||
| AuthMetadata
|
||||
| AuthErrorMetadata
|
||||
| OnboardingTourMetadata
|
||||
| SurveyResponses
|
||||
| TemplateMetadata
|
||||
| ExecutionContext
|
||||
|
||||
@@ -5,8 +5,6 @@ import { createApp, defineComponent, nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { TemplateUrlLoadResult } from '@/platform/workflow/templates/composables/useTemplateUrlLoader'
|
||||
import { useOnboardingEntryStore } from '../onboardingEntryStore'
|
||||
import { useWorkflowDraftStoreV2 } from '../stores/workflowDraftStoreV2'
|
||||
import { useWorkflowPersistenceV2 } from './useWorkflowPersistenceV2'
|
||||
|
||||
@@ -60,17 +58,11 @@ vi.mock('@/platform/workflow/core/services/workflowService', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
const templateLoaderMocks = vi.hoisted(() => ({
|
||||
loadTemplateFromUrl: vi.fn(
|
||||
async () => ({ loaded: false }) as TemplateUrlLoadResult
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workflow/templates/composables/useTemplateUrlLoader',
|
||||
() => ({
|
||||
useTemplateUrlLoader: () => ({
|
||||
loadTemplateFromUrl: templateLoaderMocks.loadTemplateFromUrl
|
||||
loadTemplateFromUrl: vi.fn()
|
||||
})
|
||||
})
|
||||
)
|
||||
@@ -133,57 +125,7 @@ vi.mock('@/platform/navigation/preservedQueryNamespaces', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return onboardingMocks.isCloud
|
||||
}
|
||||
}))
|
||||
|
||||
const onboardingMocks = vi.hoisted(() => ({
|
||||
isCloud: false,
|
||||
onboardingTourEnabled: false,
|
||||
isNewUser: null as boolean | null,
|
||||
isSubscriptionEnabled: true,
|
||||
isDesktop: true,
|
||||
loadWorkflowTemplates: vi.fn(async () => {})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useDesktopLayout', () => ({
|
||||
useDesktopLayout: () => ({
|
||||
get value() {
|
||||
return onboardingMocks.isDesktop
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workflow/templates/repositories/workflowTemplatesStore',
|
||||
() => ({
|
||||
useWorkflowTemplatesStore: () => ({
|
||||
loadWorkflowTemplates: onboardingMocks.loadWorkflowTemplates
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({
|
||||
flags: {
|
||||
get onboardingTourEnabled() {
|
||||
return onboardingMocks.onboardingTourEnabled
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/services/useNewUserService', () => ({
|
||||
useNewUserService: () => ({
|
||||
isNewUser: () => onboardingMocks.isNewUser
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
useSubscription: () => ({
|
||||
isSubscriptionEnabled: () => onboardingMocks.isSubscriptionEnabled
|
||||
})
|
||||
isCloud: false
|
||||
}))
|
||||
|
||||
vi.mock('../migration/migrateV1toV2', () => ({
|
||||
@@ -264,12 +206,6 @@ describe('useWorkflowPersistenceV2', () => {
|
||||
commandStoreMocks.execute.mockReset()
|
||||
routeMocks.query = {}
|
||||
preservedQueryMocks.payloads = {}
|
||||
onboardingMocks.isCloud = false
|
||||
onboardingMocks.onboardingTourEnabled = false
|
||||
onboardingMocks.isNewUser = null
|
||||
onboardingMocks.isSubscriptionEnabled = true
|
||||
templateLoaderMocks.loadTemplateFromUrl.mockReset()
|
||||
templateLoaderMocks.loadTemplateFromUrl.mockResolvedValue({ loaded: false })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -681,156 +617,5 @@ describe('useWorkflowPersistenceV2', () => {
|
||||
'Comfy.BrowseTemplates'
|
||||
)
|
||||
})
|
||||
|
||||
it('shows Getting Started instead of the templates browser for a flagged new user', async () => {
|
||||
onboardingMocks.isCloud = true
|
||||
onboardingMocks.onboardingTourEnabled = true
|
||||
onboardingMocks.isNewUser = true
|
||||
const entryStore = useOnboardingEntryStore()
|
||||
|
||||
const { initializeWorkflow } = mountWorkflowPersistence()
|
||||
await initializeWorkflow()
|
||||
|
||||
expect(loadBlankWorkflowMock).toHaveBeenCalled()
|
||||
expect(entryStore.shouldShowGettingStarted).toBe(true)
|
||||
expect(commandStoreMocks.execute).not.toHaveBeenCalledWith(
|
||||
'Comfy.BrowseTemplates'
|
||||
)
|
||||
})
|
||||
|
||||
it('opens the templates browser off the Cloud build, matching the tour gate', async () => {
|
||||
onboardingMocks.isCloud = false
|
||||
onboardingMocks.onboardingTourEnabled = true
|
||||
onboardingMocks.isNewUser = true
|
||||
const entryStore = useOnboardingEntryStore()
|
||||
|
||||
const { initializeWorkflow } = mountWorkflowPersistence()
|
||||
await initializeWorkflow()
|
||||
|
||||
expect(entryStore.shouldShowGettingStarted).toBe(false)
|
||||
expect(commandStoreMocks.execute).toHaveBeenCalledWith(
|
||||
'Comfy.BrowseTemplates'
|
||||
)
|
||||
})
|
||||
|
||||
it('prefetches templates when Getting Started is shown so its cards are ready', async () => {
|
||||
onboardingMocks.isCloud = true
|
||||
onboardingMocks.onboardingTourEnabled = true
|
||||
onboardingMocks.isNewUser = true
|
||||
|
||||
const { initializeWorkflow } = mountWorkflowPersistence()
|
||||
await initializeWorkflow()
|
||||
|
||||
expect(onboardingMocks.loadWorkflowTemplates).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens the templates browser when the flag is on but the user is not new', async () => {
|
||||
onboardingMocks.isCloud = true
|
||||
onboardingMocks.onboardingTourEnabled = true
|
||||
onboardingMocks.isNewUser = false
|
||||
const entryStore = useOnboardingEntryStore()
|
||||
|
||||
const { initializeWorkflow } = mountWorkflowPersistence()
|
||||
await initializeWorkflow()
|
||||
|
||||
expect(entryStore.shouldShowGettingStarted).toBe(false)
|
||||
expect(commandStoreMocks.execute).toHaveBeenCalledWith(
|
||||
'Comfy.BrowseTemplates'
|
||||
)
|
||||
})
|
||||
|
||||
it('opens the templates browser when the flag is off', async () => {
|
||||
onboardingMocks.isCloud = true
|
||||
onboardingMocks.onboardingTourEnabled = false
|
||||
onboardingMocks.isNewUser = true
|
||||
const entryStore = useOnboardingEntryStore()
|
||||
|
||||
const { initializeWorkflow } = mountWorkflowPersistence()
|
||||
await initializeWorkflow()
|
||||
|
||||
expect(entryStore.shouldShowGettingStarted).toBe(false)
|
||||
expect(commandStoreMocks.execute).toHaveBeenCalledWith(
|
||||
'Comfy.BrowseTemplates'
|
||||
)
|
||||
})
|
||||
|
||||
it('opens the templates browser, not Getting Started, when subscriptions are disabled', async () => {
|
||||
// The tour refuses when subscriptions are off, so the takeover must not show
|
||||
// — otherwise it would dismiss into a bare canvas with no tour.
|
||||
onboardingMocks.isCloud = true
|
||||
onboardingMocks.onboardingTourEnabled = true
|
||||
onboardingMocks.isNewUser = true
|
||||
onboardingMocks.isSubscriptionEnabled = false
|
||||
const entryStore = useOnboardingEntryStore()
|
||||
|
||||
const { initializeWorkflow } = mountWorkflowPersistence()
|
||||
await initializeWorkflow()
|
||||
|
||||
expect(entryStore.shouldShowGettingStarted).toBe(false)
|
||||
expect(commandStoreMocks.execute).toHaveBeenCalledWith(
|
||||
'Comfy.BrowseTemplates'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not show Getting Started for a flagged new user arriving via a template URL', async () => {
|
||||
onboardingMocks.isCloud = true
|
||||
onboardingMocks.onboardingTourEnabled = true
|
||||
onboardingMocks.isNewUser = true
|
||||
routeMocks.query = { template: 'default-template-id' }
|
||||
const entryStore = useOnboardingEntryStore()
|
||||
|
||||
const { initializeWorkflow } = mountWorkflowPersistence()
|
||||
await initializeWorkflow()
|
||||
|
||||
expect(entryStore.shouldShowGettingStarted).toBe(false)
|
||||
expect(commandStoreMocks.execute).not.toHaveBeenCalledWith(
|
||||
'Comfy.BrowseTemplates'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadTemplateFromUrlIfPresent', () => {
|
||||
it('surfaces the validated template id the loader reports', async () => {
|
||||
routeMocks.query = { template: 'image_z_image_turbo' }
|
||||
templateLoaderMocks.loadTemplateFromUrl.mockResolvedValue({
|
||||
loaded: true,
|
||||
templateId: 'image_z_image_turbo'
|
||||
})
|
||||
|
||||
const { loadTemplateFromUrlIfPresent } = mountWorkflowPersistence()
|
||||
|
||||
await expect(loadTemplateFromUrlIfPresent()).resolves.toEqual({
|
||||
loaded: true,
|
||||
templateId: 'image_z_image_turbo'
|
||||
})
|
||||
})
|
||||
|
||||
it('reports not-loaded when the loader loads nothing', async () => {
|
||||
routeMocks.query = {}
|
||||
templateLoaderMocks.loadTemplateFromUrl.mockResolvedValue({
|
||||
loaded: false
|
||||
})
|
||||
|
||||
const { loadTemplateFromUrlIfPresent } = mountWorkflowPersistence()
|
||||
|
||||
await expect(loadTemplateFromUrlIfPresent()).resolves.toEqual({
|
||||
loaded: false
|
||||
})
|
||||
})
|
||||
|
||||
it('hydrates preserved template intent before delegating to the loader', async () => {
|
||||
preservedQueryMocks.payloads.template = {
|
||||
template: 'image_z_image_turbo'
|
||||
}
|
||||
templateLoaderMocks.loadTemplateFromUrl.mockResolvedValue({
|
||||
loaded: true,
|
||||
templateId: 'image_z_image_turbo'
|
||||
})
|
||||
|
||||
const { loadTemplateFromUrlIfPresent } = mountWorkflowPersistence()
|
||||
await loadTemplateFromUrlIfPresent()
|
||||
|
||||
expect(templateLoaderMocks.loadTemplateFromUrl).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,14 +16,11 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useDesktopLayout } from '@/composables/useDesktopLayout'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import {
|
||||
hydratePreservedQuery,
|
||||
mergePreservedQueryIntoQuery
|
||||
} from '@/platform/navigation/preservedQueryManager'
|
||||
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
@@ -31,17 +28,9 @@ import {
|
||||
ComfyWorkflow,
|
||||
useWorkflowStore
|
||||
} from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
|
||||
import { useNewUserService } from '@/services/useNewUserService'
|
||||
|
||||
import { PERSIST_DEBOUNCE_MS } from '../base/draftTypes'
|
||||
import { clearAllV2Storage } from '../base/storageIO'
|
||||
import { migrateV1toV2 } from '../migration/migrateV1toV2'
|
||||
import type { OnboardingCandidateDeps } from '../onboardingEntryStore'
|
||||
import {
|
||||
isOnboardingCandidate,
|
||||
useOnboardingEntryStore
|
||||
} from '../onboardingEntryStore'
|
||||
import { useWorkflowDraftStoreV2 } from '../stores/workflowDraftStoreV2'
|
||||
import { useWorkflowTabState } from './useWorkflowTabState'
|
||||
import { useSharedWorkflowUrlLoader } from '@/platform/workflow/sharing/composables/useSharedWorkflowUrlLoader'
|
||||
@@ -63,14 +52,6 @@ export function useWorkflowPersistenceV2() {
|
||||
const draftStore = useWorkflowDraftStoreV2()
|
||||
const tabState = useWorkflowTabState()
|
||||
const toast = useToast()
|
||||
const templatesStore = useWorkflowTemplatesStore()
|
||||
const entryStore = useOnboardingEntryStore()
|
||||
const onboardingDeps: OnboardingCandidateDeps = {
|
||||
subscription: useSubscription(),
|
||||
newUserService: useNewUserService(),
|
||||
featureFlags: useFeatureFlags(),
|
||||
desktop: useDesktopLayout()
|
||||
}
|
||||
const { onUserLogout } = useCurrentUser()
|
||||
|
||||
// Run migration on module load, passing clientId for tab state migration
|
||||
@@ -198,12 +179,7 @@ export function useWorkflowPersistenceV2() {
|
||||
await settingStore.set('Comfy.TutorialCompleted', true)
|
||||
await useWorkflowService().loadBlankWorkflow()
|
||||
if (!hasSharedWorkflowIntent() && !hasTemplateUrlIntent()) {
|
||||
if (isOnboardingCandidate(onboardingDeps)) {
|
||||
void templatesStore.loadWorkflowTemplates()
|
||||
entryStore.showGettingStarted()
|
||||
} else {
|
||||
await useCommandStore().execute('Comfy.BrowseTemplates')
|
||||
}
|
||||
await useCommandStore().execute('Comfy.BrowseTemplates')
|
||||
}
|
||||
} else {
|
||||
await comfyApp.loadGraphData()
|
||||
@@ -247,10 +223,12 @@ export function useWorkflowPersistenceV2() {
|
||||
}
|
||||
|
||||
const loadTemplateFromUrlIfPresent = async () => {
|
||||
// Hydrate any preserved ?template= intent into the route first; the loader
|
||||
// reads the route and returns the id only after validating it.
|
||||
await ensureTemplateQueryFromIntent()
|
||||
return templateUrlLoader.loadTemplateFromUrl()
|
||||
const query = await ensureTemplateQueryFromIntent()
|
||||
const hasTemplateUrl = query.template && typeof query.template === 'string'
|
||||
|
||||
if (hasTemplateUrl) {
|
||||
await templateUrlLoader.loadTemplateFromUrl()
|
||||
}
|
||||
}
|
||||
|
||||
const loadSharedWorkflowFromUrlIfPresent = async () => {
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import type { ComputedRef } from 'vue'
|
||||
|
||||
import type { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import type { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type { useNewUserService } from '@/services/useNewUserService'
|
||||
|
||||
export interface OnboardingCandidateDeps {
|
||||
subscription: ReturnType<typeof useSubscription>
|
||||
newUserService: ReturnType<typeof useNewUserService>
|
||||
featureFlags: ReturnType<typeof useFeatureFlags>
|
||||
desktop: ComputedRef<boolean>
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared by the Getting Started screen and the tour: the screen dismisses even
|
||||
* when the tour then declines, so a looser gate on either side strands the user
|
||||
* on a bare canvas. Deps are resolved by callers during setup — this runs after
|
||||
* an await, where a first composable call would have no injection context.
|
||||
*/
|
||||
export function isOnboardingCandidate({
|
||||
subscription,
|
||||
newUserService,
|
||||
featureFlags,
|
||||
desktop
|
||||
}: OnboardingCandidateDeps): boolean {
|
||||
if (!desktop.value) return false
|
||||
if (!isCloud) return false
|
||||
if (!subscription.isSubscriptionEnabled()) return false
|
||||
if (newUserService.isNewUser() !== true) return false
|
||||
if (!featureFlags.flags.onboardingTourEnabled) return false
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Seam between the persistence layer and the onboarding Getting Started screen.
|
||||
*
|
||||
* `loadDefaultWorkflow` decides when a fresh user should land on Getting
|
||||
* Started, but the screen lives in `renderer/` and cannot be imported from this
|
||||
* layer. Both sides share this flag: persistence turns it on; the
|
||||
* renderer-mounted overlay shows itself while set and clears it on exit.
|
||||
*/
|
||||
export const useOnboardingEntryStore = defineStore('onboardingEntry', () => {
|
||||
const shouldShowGettingStarted = ref(false)
|
||||
|
||||
function showGettingStarted() {
|
||||
shouldShowGettingStarted.value = true
|
||||
}
|
||||
|
||||
function dismissGettingStarted() {
|
||||
shouldShowGettingStarted.value = false
|
||||
}
|
||||
|
||||
return { shouldShowGettingStarted, showGettingStarted, dismissGettingStarted }
|
||||
})
|
||||
@@ -97,51 +97,6 @@ describe('useTemplateUrlLoader', () => {
|
||||
expect(mockLoadWorkflowTemplate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns the loaded template id when the template loads successfully', async () => {
|
||||
mockQueryParams = { template: 'flux_simple' }
|
||||
|
||||
const { loadTemplateFromUrl } = useTemplateUrlLoader()
|
||||
|
||||
await expect(loadTemplateFromUrl()).resolves.toEqual({
|
||||
loaded: true,
|
||||
templateId: 'flux_simple'
|
||||
})
|
||||
})
|
||||
|
||||
it('returns not-loaded when no template param is present', async () => {
|
||||
mockQueryParams = {}
|
||||
|
||||
const { loadTemplateFromUrl } = useTemplateUrlLoader()
|
||||
|
||||
await expect(loadTemplateFromUrl()).resolves.toEqual({ loaded: false })
|
||||
})
|
||||
|
||||
it('returns not-loaded without a template id when the template fails to load', async () => {
|
||||
mockQueryParams = { template: 'invalid-template' }
|
||||
mockLoadWorkflowTemplate.mockResolvedValueOnce(false)
|
||||
|
||||
const { loadTemplateFromUrl } = useTemplateUrlLoader()
|
||||
|
||||
await expect(loadTemplateFromUrl()).resolves.toEqual({ loaded: false })
|
||||
})
|
||||
|
||||
it('returns not-loaded when loading throws', async () => {
|
||||
mockQueryParams = { template: 'flux_simple' }
|
||||
mockLoadTemplates.mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
const { loadTemplateFromUrl } = useTemplateUrlLoader()
|
||||
|
||||
await expect(loadTemplateFromUrl()).resolves.toEqual({ loaded: false })
|
||||
})
|
||||
|
||||
it('returns not-loaded for an invalid template parameter', async () => {
|
||||
mockQueryParams = { template: '../../../etc/passwd' }
|
||||
|
||||
const { loadTemplateFromUrl } = useTemplateUrlLoader()
|
||||
|
||||
await expect(loadTemplateFromUrl()).resolves.toEqual({ loaded: false })
|
||||
})
|
||||
|
||||
it('loads template when query param is present', async () => {
|
||||
mockQueryParams = { template: 'flux_simple' }
|
||||
|
||||
|
||||
@@ -10,11 +10,6 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
import { useTemplateWorkflows } from './useTemplateWorkflows'
|
||||
|
||||
export interface TemplateUrlLoadResult {
|
||||
loaded: boolean
|
||||
templateId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for loading templates from URL query parameters
|
||||
*
|
||||
@@ -69,18 +64,18 @@ export function useTemplateUrlLoader() {
|
||||
* Loads template from URL query parameters if present
|
||||
* Handles errors internally and shows appropriate user feedback
|
||||
*/
|
||||
const loadTemplateFromUrl = async (): Promise<TemplateUrlLoadResult> => {
|
||||
const loadTemplateFromUrl = async () => {
|
||||
const templateParam = route.query.template
|
||||
|
||||
if (!templateParam || typeof templateParam !== 'string') {
|
||||
return { loaded: false }
|
||||
return
|
||||
}
|
||||
|
||||
if (!isValidParameter(templateParam)) {
|
||||
console.warn(
|
||||
`[useTemplateUrlLoader] Invalid template parameter format: ${templateParam}`
|
||||
)
|
||||
return { loaded: false }
|
||||
return
|
||||
}
|
||||
|
||||
const sourceParam = (route.query.source as string | undefined) || 'default'
|
||||
@@ -89,7 +84,7 @@ export function useTemplateUrlLoader() {
|
||||
console.warn(
|
||||
`[useTemplateUrlLoader] Invalid source parameter format: ${sourceParam}`
|
||||
)
|
||||
return { loaded: false }
|
||||
return
|
||||
}
|
||||
|
||||
const modeParam = route.query.mode as string | undefined
|
||||
@@ -101,7 +96,7 @@ export function useTemplateUrlLoader() {
|
||||
console.warn(
|
||||
`[useTemplateUrlLoader] Invalid mode parameter format: ${modeParam}`
|
||||
)
|
||||
return { loaded: false }
|
||||
return
|
||||
}
|
||||
|
||||
if (modeParam && !isSupportedMode(modeParam)) {
|
||||
@@ -126,16 +121,11 @@ export function useTemplateUrlLoader() {
|
||||
templateName: templateParam
|
||||
})
|
||||
})
|
||||
return { loaded: false }
|
||||
}
|
||||
|
||||
if (modeParam === 'linear') {
|
||||
} else if (modeParam === 'linear') {
|
||||
// Set linear mode after successful template load
|
||||
useTelemetry()?.trackEnterLinear({ source: 'template_url' })
|
||||
canvasStore.linearMode = true
|
||||
}
|
||||
|
||||
return { loaded: true, templateId: templateParam }
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[useTemplateUrlLoader] Failed to load template from URL:',
|
||||
@@ -146,7 +136,6 @@ export function useTemplateUrlLoader() {
|
||||
summary: t('g.error'),
|
||||
detail: t('g.errorLoadingTemplate')
|
||||
})
|
||||
return { loaded: false }
|
||||
} finally {
|
||||
cleanupUrlParams()
|
||||
clearPreservedQuery(TEMPLATE_NAMESPACE)
|
||||
|
||||
@@ -250,9 +250,6 @@ export type BillingStatus =
|
||||
| 'pending_payment'
|
||||
| 'paid'
|
||||
| 'payment_failed'
|
||||
// A Stripe-paused subscription stays `active` on the activity axis; the pause
|
||||
// is a payment-lifecycle fact. Not emitted until cloud#5075 ships.
|
||||
| 'paused'
|
||||
| 'inactive'
|
||||
|
||||
export interface CurrentTeamCreditStop {
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col overflow-hidden rounded-2xl border border-border-default bg-base-background"
|
||||
data-testid="insufficient-credits-member-message"
|
||||
>
|
||||
<div
|
||||
class="flex h-12 items-center gap-2 border-b border-border-default p-4"
|
||||
>
|
||||
<p class="m-0 min-w-0 flex-1 font-inter text-sm text-base-foreground">
|
||||
{{ $t('credits.insufficient.memberTitle') }}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="$t('g.close')"
|
||||
class="flex size-4 shrink-0 cursor-pointer items-center justify-center border-none bg-transparent text-base-foreground"
|
||||
@click="onClose"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<p class="m-0 font-inter text-sm text-muted-foreground">
|
||||
{{ $t('credits.insufficient.memberDescription') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end p-4">
|
||||
<Button variant="secondary" size="lg" @click="onClose">
|
||||
{{ $t('credits.insufficient.memberCta') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const { onClose } = defineProps<{
|
||||
onClose: () => void
|
||||
}>()
|
||||
</script>
|
||||
@@ -1,178 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import type { SubscriptionInfo } from '@/composables/billing/types'
|
||||
import { i18n } from '@/i18n'
|
||||
import type { BillingContextMockState } from '@/storybook/mocks/useBillingContext'
|
||||
import { setBillingContextMock } from '@/storybook/mocks/useBillingContext'
|
||||
import type { WorkspaceUIMockState } from '@/storybook/mocks/useWorkspaceUI'
|
||||
import { setWorkspaceUIMock } from '@/storybook/mocks/useWorkspaceUI'
|
||||
|
||||
import BillingStatusBanner from './BillingStatusBanner.vue'
|
||||
|
||||
/**
|
||||
* The single billing banner slot for team workspaces (FE-1246), rendered in
|
||||
* priority order: paused > payment declined > out of credits > ending. At most
|
||||
* one state shows at a time. Each story drives the real `deriveBillingBanner`
|
||||
* through the stubbed billing context, so these are the states the backend can
|
||||
* actually emit — not hand-set banner kinds.
|
||||
*
|
||||
* The amber triangle-alert marks action-needed states; the muted circle-alert is
|
||||
* reserved for the informational "plan ends" notice (DES-380 severity rule).
|
||||
*
|
||||
* RUN WITH `DISTRIBUTION=cloud pnpm storybook`. The banner is cloud-only, and
|
||||
* `isCloud` is compile-time, so under a plain `pnpm storybook` every story below
|
||||
* renders empty.
|
||||
*/
|
||||
const meta: Meta<typeof BillingStatusBanner> = {
|
||||
title: 'Platform/Workspace/BillingStatusBanner',
|
||||
component: BillingStatusBanner,
|
||||
parameters: { layout: 'fullscreen' }
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof BillingStatusBanner>
|
||||
|
||||
const RENEWAL_DATE = '2026-08-01T00:00:00Z'
|
||||
const PLAN_END_DATE = '2026-08-01T00:00:00Z'
|
||||
|
||||
const teamSubscription: SubscriptionInfo = {
|
||||
isActive: true,
|
||||
tier: null,
|
||||
duration: 'MONTHLY',
|
||||
planSlug: 'team-monthly',
|
||||
renewalDate: RENEWAL_DATE,
|
||||
endDate: null,
|
||||
isCancelled: false,
|
||||
hasFunds: true
|
||||
}
|
||||
|
||||
const funded = teamSubscription
|
||||
const exhausted: SubscriptionInfo = { ...teamSubscription, hasFunds: false }
|
||||
const cancelled: SubscriptionInfo = {
|
||||
...teamSubscription,
|
||||
isCancelled: true,
|
||||
endDate: PLAN_END_DATE
|
||||
}
|
||||
|
||||
const owner: Partial<WorkspaceUIMockState> = {}
|
||||
const member: Partial<WorkspaceUIMockState> = {
|
||||
canManageSubscription: false,
|
||||
canManageSubscriptionLifecycle: false,
|
||||
canTopUp: false
|
||||
}
|
||||
|
||||
function story(
|
||||
billing: Partial<BillingContextMockState>,
|
||||
workspace: Partial<WorkspaceUIMockState>
|
||||
): Story {
|
||||
return {
|
||||
beforeEach() {
|
||||
// Dates in the copy go through vue-i18n's `d()`, so pin the locale rather
|
||||
// than inherit the developer's.
|
||||
i18n.global.locale.value = 'en'
|
||||
setBillingContextMock({ isTeamPlan: true, ...billing })
|
||||
setWorkspaceUIMock(workspace)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stripe suspended the subscription. The backend folds billing_status into
|
||||
* is_active, so a paused workspace always reports `is_active: false` — the
|
||||
* pairing these stories pin.
|
||||
*/
|
||||
export const PausedOwner: Story = story(
|
||||
{
|
||||
subscription: funded,
|
||||
isActiveSubscription: false,
|
||||
billingStatus: 'paused',
|
||||
subscriptionStatus: 'active'
|
||||
},
|
||||
owner
|
||||
)
|
||||
|
||||
/** Members get an admin-directed notice and no action. */
|
||||
export const PausedMember: Story = story(
|
||||
{
|
||||
subscription: funded,
|
||||
isActiveSubscription: false,
|
||||
billingStatus: 'paused',
|
||||
subscriptionStatus: 'active'
|
||||
},
|
||||
member
|
||||
)
|
||||
|
||||
/**
|
||||
* A failed charge with Stripe still retrying. Owner-only, and `is_active: false`
|
||||
* for the same reason paused is — payment_failed also denies spend.
|
||||
*/
|
||||
export const PaymentDeclined: Story = story(
|
||||
{
|
||||
subscription: funded,
|
||||
isActiveSubscription: false,
|
||||
billingStatus: 'payment_failed',
|
||||
subscriptionStatus: 'active',
|
||||
renewalDate: RENEWAL_DATE
|
||||
},
|
||||
owner
|
||||
)
|
||||
|
||||
/** Payment declined before a renewal date is known. */
|
||||
export const PaymentDeclinedNoDate: Story = story(
|
||||
{
|
||||
subscription: funded,
|
||||
isActiveSubscription: false,
|
||||
billingStatus: 'payment_failed',
|
||||
subscriptionStatus: 'active'
|
||||
},
|
||||
owner
|
||||
)
|
||||
|
||||
/** Shared team credits exhausted. Session-dismissible. */
|
||||
export const OutOfCreditsOwner: Story = story(
|
||||
{
|
||||
subscription: exhausted,
|
||||
isActiveSubscription: true,
|
||||
billingStatus: 'paid',
|
||||
subscriptionStatus: 'active',
|
||||
renewalDate: RENEWAL_DATE
|
||||
},
|
||||
owner
|
||||
)
|
||||
|
||||
/** Members can't top up, so they get contact-admin copy and no Add credits. */
|
||||
export const OutOfCreditsMember: Story = story(
|
||||
{
|
||||
subscription: exhausted,
|
||||
isActiveSubscription: true,
|
||||
billingStatus: 'paid',
|
||||
subscriptionStatus: 'active',
|
||||
renewalDate: RENEWAL_DATE
|
||||
},
|
||||
member
|
||||
)
|
||||
|
||||
/** Cancelled but still active until the period end. Informational. */
|
||||
export const EndingOwner: Story = story(
|
||||
{
|
||||
subscription: cancelled,
|
||||
isActiveSubscription: true,
|
||||
billingStatus: 'paid',
|
||||
subscriptionStatus: 'canceled'
|
||||
},
|
||||
owner
|
||||
)
|
||||
|
||||
/**
|
||||
* Reactivate is lifecycle-gated to the original owner, so a non-original owner
|
||||
* sees the notice read-only.
|
||||
*/
|
||||
export const EndingNonOriginalOwner: Story = story(
|
||||
{
|
||||
subscription: cancelled,
|
||||
isActiveSubscription: true,
|
||||
billingStatus: 'paid',
|
||||
subscriptionStatus: 'canceled'
|
||||
},
|
||||
{ canManageSubscriptionLifecycle: false }
|
||||
)
|
||||
@@ -1,317 +0,0 @@
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type {
|
||||
BillingStatus,
|
||||
WorkspaceType
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
import BillingStatusBanner from '@/platform/workspace/components/dialogs/settings/BillingStatusBanner.vue'
|
||||
|
||||
interface Subscription {
|
||||
hasFunds: boolean
|
||||
isCancelled: boolean
|
||||
endDate: string | null
|
||||
}
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
billingControlEnabled: true,
|
||||
isActiveSubscription: true,
|
||||
isTeamPlan: true,
|
||||
billingStatus: 'paid' as string | null,
|
||||
subscription: {
|
||||
hasFunds: true,
|
||||
isCancelled: false,
|
||||
endDate: null
|
||||
} as Subscription | null,
|
||||
renewalDate: null as string | null,
|
||||
workspaceType: 'team' as string,
|
||||
canManageSubscription: true,
|
||||
canManageSubscriptionLifecycle: true,
|
||||
canTopUp: true,
|
||||
showTopUpCreditsDialog: vi.fn(),
|
||||
manageSubscription: vi.fn(),
|
||||
handleResubscribe: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({ isCloud: true }))
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({
|
||||
flags: {
|
||||
get billingControlEnabled() {
|
||||
return state.billingControlEnabled
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
isActiveSubscription: computed(() => state.isActiveSubscription),
|
||||
isTeamPlan: computed(() => state.isTeamPlan),
|
||||
billingStatus: computed(() => state.billingStatus as BillingStatus | null),
|
||||
subscription: computed(() => state.subscription),
|
||||
renewalDate: computed(() => state.renewalDate),
|
||||
manageSubscription: state.manageSubscription
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
|
||||
useWorkspaceUI: () => ({
|
||||
permissions: computed(() => ({
|
||||
canManageSubscription: state.canManageSubscription,
|
||||
canManageSubscriptionLifecycle: state.canManageSubscriptionLifecycle,
|
||||
canTopUp: state.canTopUp
|
||||
})),
|
||||
workspaceType: computed(() => state.workspaceType as WorkspaceType)
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useResubscribe', () => ({
|
||||
useResubscribe: () => ({
|
||||
isResubscribing: computed(() => false),
|
||||
handleResubscribe: state.handleResubscribe
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: () => ({
|
||||
showTopUpCreditsDialog: state.showTopUpCreditsDialog
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
workspacePanel: {
|
||||
billingStatus: {
|
||||
warning: {
|
||||
title: 'Payment declined',
|
||||
body: "Your last payment didn't go through. Your subscription will pause on {date} unless payment is updated.",
|
||||
bodyNoDate:
|
||||
"Your last payment didn't go through. Update payment to avoid a pause."
|
||||
},
|
||||
paused: {
|
||||
title: 'Subscription paused',
|
||||
body: "This workspace's subscription is paused. Update payment to resume.",
|
||||
memberBody:
|
||||
"This workspace's subscription is paused. Your workspace admins need to update the payment method."
|
||||
},
|
||||
outOfCredits: {
|
||||
title: 'Out of credits',
|
||||
body: 'Your team has used all its credits. Add more credits to continue generating or wait until credits refill on {date}.',
|
||||
bodyNoDate:
|
||||
'Your team has used all its credits. Add more credits to continue generating.',
|
||||
memberBody:
|
||||
'Your team has used all its credits. Your workspace admins need to add more credits to continue generating.',
|
||||
addCredits: 'Add credits',
|
||||
dismiss: 'Dismiss'
|
||||
},
|
||||
ending: {
|
||||
title: 'Your team plan ends on {date}',
|
||||
body: 'Members keep full access until then. Reactivate to keep your shared credits and seats.',
|
||||
reactivate: 'Reactivate plan'
|
||||
},
|
||||
updatePayment: 'Update payment'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const globalOptions = {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
Button: {
|
||||
template:
|
||||
'<button v-bind="$attrs" @click="$emit(\'click\')"><slot/></button>',
|
||||
props: ['variant', 'size', 'loading'],
|
||||
emits: ['click']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderBanner() {
|
||||
return render(BillingStatusBanner, { global: globalOptions })
|
||||
}
|
||||
|
||||
function exhausted() {
|
||||
state.subscription = { hasFunds: false, isCancelled: false, endDate: null }
|
||||
}
|
||||
|
||||
// The spend gate folds billing_status into is_active, so the backend never emits
|
||||
// paused alongside an active subscription.
|
||||
function pausedState() {
|
||||
state.billingStatus = 'paused'
|
||||
state.isActiveSubscription = false
|
||||
}
|
||||
|
||||
function paymentFailedState() {
|
||||
state.billingStatus = 'payment_failed'
|
||||
state.isActiveSubscription = false
|
||||
}
|
||||
|
||||
describe('BillingStatusBanner', () => {
|
||||
beforeEach(() => {
|
||||
state.billingControlEnabled = true
|
||||
state.isActiveSubscription = true
|
||||
state.isTeamPlan = true
|
||||
state.billingStatus = 'paid'
|
||||
state.subscription = { hasFunds: true, isCancelled: false, endDate: null }
|
||||
state.renewalDate = null
|
||||
state.workspaceType = 'team'
|
||||
state.canManageSubscription = true
|
||||
state.canManageSubscriptionLifecycle = true
|
||||
state.canTopUp = true
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders nothing for a healthy funded team', () => {
|
||||
renderBanner()
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders nothing when billing control is rolled back, even out of credits', () => {
|
||||
state.billingControlEnabled = false
|
||||
state.subscription = { hasFunds: false, isCancelled: false, endDate: null }
|
||||
renderBanner()
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows out-of-credits with an Add credits action for owners', async () => {
|
||||
state.subscription = { hasFunds: false, isCancelled: false, endDate: null }
|
||||
renderBanner()
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Out of credits')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Add credits' }))
|
||||
expect(state.showTopUpCreditsDialog).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('shows out-of-credits contact-admin copy without an Add credits action for members', () => {
|
||||
state.subscription = { hasFunds: false, isCancelled: false, endDate: null }
|
||||
state.canManageSubscription = false
|
||||
state.canTopUp = false
|
||||
renderBanner()
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent(
|
||||
'Your workspace admins need to add more credits'
|
||||
)
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Add credits' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Dismiss' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('dismisses the out-of-credits banner for the session', async () => {
|
||||
exhausted()
|
||||
renderBanner()
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Dismiss' }))
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shares one dismiss across instances rather than tracking it per mount', async () => {
|
||||
exhausted()
|
||||
render(
|
||||
{
|
||||
components: { BillingStatusBanner },
|
||||
template: '<div><BillingStatusBanner /><BillingStatusBanner /></div>'
|
||||
},
|
||||
{ global: globalOptions }
|
||||
)
|
||||
|
||||
expect(screen.getAllByRole('status')).toHaveLength(2)
|
||||
await userEvent.click(screen.getAllByRole('button', { name: 'Dismiss' })[0])
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the paused banner with Update payment for owners', async () => {
|
||||
pausedState()
|
||||
renderBanner()
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Subscription paused')
|
||||
expect(screen.getByRole('status')).toHaveTextContent(
|
||||
'Update payment to resume'
|
||||
)
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', { name: 'Update payment' })
|
||||
)
|
||||
expect(state.manageSubscription).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('shows the paused member notice without an action', () => {
|
||||
pausedState()
|
||||
state.canManageSubscription = false
|
||||
state.canTopUp = false
|
||||
renderBanner()
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent(
|
||||
'Your workspace admins need to update the payment method'
|
||||
)
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the payment-declined banner with Update payment for owners', () => {
|
||||
paymentFailedState()
|
||||
state.renewalDate = '2026-08-01T00:00:00Z'
|
||||
renderBanner()
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Payment declined')
|
||||
expect(screen.getByRole('status')).toHaveTextContent(/will pause on \S+/)
|
||||
expect(screen.getByRole('status')).not.toHaveTextContent('{date}')
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Update payment' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('falls back to the no-date payment-declined copy when there is no renewal date', () => {
|
||||
paymentFailedState()
|
||||
state.renewalDate = null
|
||||
renderBanner()
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent(
|
||||
'Update payment to avoid a pause'
|
||||
)
|
||||
expect(screen.getByRole('status')).not.toHaveTextContent('will pause on')
|
||||
expect(screen.getByRole('status')).not.toHaveTextContent('{date}')
|
||||
})
|
||||
|
||||
it('shows the ending banner with a Reactivate action', async () => {
|
||||
state.subscription = {
|
||||
hasFunds: true,
|
||||
isCancelled: true,
|
||||
endDate: '2026-08-01T00:00:00Z'
|
||||
}
|
||||
renderBanner()
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent(
|
||||
'Your team plan ends on'
|
||||
)
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', { name: 'Reactivate plan' })
|
||||
)
|
||||
expect(state.handleResubscribe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('shows the ending banner read-only to a non-original owner (Reactivate is lifecycle-gated)', () => {
|
||||
state.subscription = {
|
||||
hasFunds: true,
|
||||
isCancelled: true,
|
||||
endDate: '2026-08-01T00:00:00Z'
|
||||
}
|
||||
state.canManageSubscriptionLifecycle = false
|
||||
renderBanner()
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent(
|
||||
'Your team plan ends on'
|
||||
)
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Reactivate plan' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,165 +0,0 @@
|
||||
<template>
|
||||
<div v-if="banner" class="@container">
|
||||
<div
|
||||
role="status"
|
||||
class="flex flex-col gap-3 rounded-2xl border border-interface-stroke/60 bg-base-background p-4 @2xl:flex-row @2xl:items-center @2xl:gap-2"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
'size-4 shrink-0',
|
||||
banner.muted
|
||||
? 'icon-[lucide--circle-alert] text-muted-foreground'
|
||||
: 'icon-[lucide--triangle-alert] text-warning-background'
|
||||
)
|
||||
"
|
||||
/>
|
||||
<span class="text-sm text-base-foreground">{{ banner.title }}</span>
|
||||
</div>
|
||||
<p class="m-0 pl-6 text-sm text-muted-foreground">{{ banner.body }}</p>
|
||||
</div>
|
||||
<div
|
||||
v-if="banner.dismissible || banner.action"
|
||||
class="flex shrink-0 flex-wrap items-center gap-2 pl-6 @2xl:pl-0"
|
||||
>
|
||||
<Button
|
||||
v-if="banner.dismissible"
|
||||
variant="textonly"
|
||||
size="lg"
|
||||
@click="dismiss"
|
||||
>
|
||||
{{ $t('workspacePanel.billingStatus.outOfCredits.dismiss') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="banner.action === 'addCredits'"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
@click="handleAddCredits"
|
||||
>
|
||||
{{ $t('workspacePanel.billingStatus.outOfCredits.addCredits') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="banner.action === 'reactivate'"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
:loading="isResubscribing"
|
||||
@click="handleResubscribe"
|
||||
>
|
||||
{{ $t('workspacePanel.billingStatus.ending.reactivate') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="banner.action === 'updatePayment'"
|
||||
variant="inverted"
|
||||
size="lg"
|
||||
@click="handleUpdatePayment"
|
||||
>
|
||||
{{ $t('workspacePanel.billingStatus.updatePayment') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useBillingBanner } from '@/platform/workspace/composables/useBillingBanner'
|
||||
import { useResubscribe } from '@/platform/workspace/composables/useResubscribe'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
|
||||
type BannerAction = 'addCredits' | 'reactivate' | 'updatePayment'
|
||||
|
||||
const { t, d } = useI18n()
|
||||
const { renewalDate, subscription, manageSubscription } = useBillingContext()
|
||||
const { permissions } = useWorkspaceUI()
|
||||
const { kind, dismiss } = useBillingBanner()
|
||||
const { isResubscribing, handleResubscribe } = useResubscribe()
|
||||
const dialogService = useDialogService()
|
||||
|
||||
const canManage = computed(() => permissions.value.canManageSubscription)
|
||||
const canManageLifecycle = computed(
|
||||
() => permissions.value.canManageSubscriptionLifecycle
|
||||
)
|
||||
const canTopUp = computed(() => permissions.value.canTopUp)
|
||||
|
||||
const cycleResetDate = computed(() => {
|
||||
const raw = renewalDate.value
|
||||
return raw ? d(new Date(raw), { month: 'short', day: 'numeric' }) : ''
|
||||
})
|
||||
const planEndDate = computed(() => {
|
||||
const raw = subscription.value?.endDate
|
||||
return raw
|
||||
? d(new Date(raw), { year: 'numeric', month: 'long', day: 'numeric' })
|
||||
: ''
|
||||
})
|
||||
|
||||
interface BannerView {
|
||||
muted: boolean
|
||||
title: string
|
||||
body: string
|
||||
action: BannerAction | null
|
||||
dismissible: boolean
|
||||
}
|
||||
|
||||
const banner = computed<BannerView | null>(() => {
|
||||
const bs = 'workspacePanel.billingStatus'
|
||||
switch (kind.value) {
|
||||
case 'paused':
|
||||
return {
|
||||
muted: false,
|
||||
title: t(`${bs}.paused.title`),
|
||||
body: canManage.value
|
||||
? t(`${bs}.paused.body`)
|
||||
: t(`${bs}.paused.memberBody`),
|
||||
action: canManage.value ? 'updatePayment' : null,
|
||||
dismissible: false
|
||||
}
|
||||
case 'paymentFailed':
|
||||
return {
|
||||
muted: false,
|
||||
title: t(`${bs}.warning.title`),
|
||||
body: cycleResetDate.value
|
||||
? t(`${bs}.warning.body`, { date: cycleResetDate.value })
|
||||
: t(`${bs}.warning.bodyNoDate`),
|
||||
action: 'updatePayment',
|
||||
dismissible: false
|
||||
}
|
||||
case 'outOfCredits':
|
||||
return {
|
||||
muted: false,
|
||||
title: t(`${bs}.outOfCredits.title`),
|
||||
body: canTopUp.value
|
||||
? cycleResetDate.value
|
||||
? t(`${bs}.outOfCredits.body`, { date: cycleResetDate.value })
|
||||
: t(`${bs}.outOfCredits.bodyNoDate`)
|
||||
: t(`${bs}.outOfCredits.memberBody`),
|
||||
action: canTopUp.value ? 'addCredits' : null,
|
||||
dismissible: true
|
||||
}
|
||||
case 'ending':
|
||||
return {
|
||||
muted: true,
|
||||
title: t(`${bs}.ending.title`, { date: planEndDate.value }),
|
||||
body: t(`${bs}.ending.body`),
|
||||
action: canManageLifecycle.value ? 'reactivate' : null,
|
||||
dismissible: false
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
function handleAddCredits() {
|
||||
void dialogService.showTopUpCreditsDialog()
|
||||
}
|
||||
function handleUpdatePayment() {
|
||||
void manageSubscription()
|
||||
}
|
||||
</script>
|
||||
@@ -65,16 +65,6 @@ vi.mock(
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workspace/components/dialogs/settings/BillingStatusBanner.vue',
|
||||
() => ({
|
||||
default: {
|
||||
name: 'BillingStatusBanner',
|
||||
template: '<div data-testid="billing-banner" />'
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
@@ -103,25 +93,6 @@ function renderComponent() {
|
||||
})
|
||||
}
|
||||
|
||||
describe('WorkspacePanelContent billing banner', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockMembers.value = []
|
||||
mockWorkspaceType.value = 'team'
|
||||
})
|
||||
|
||||
it('hosts a single banner slot above the tab content, so it shows on every tab', () => {
|
||||
renderComponent()
|
||||
|
||||
const banner = screen.getByTestId('billing-banner')
|
||||
const planPanel = screen.getByText('workspacePanel.tabs.planCredits')
|
||||
expect(
|
||||
planPanel.compareDocumentPosition(banner) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING
|
||||
).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('WorkspacePanelContent members tab label', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user