mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-10 00:58:01 +00:00
Compare commits
44 Commits
fix/load3d
...
pysssss/pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62806d5ebd | ||
|
|
dff76dbc9d | ||
|
|
66b9f329ce | ||
|
|
cb1be63fb6 | ||
|
|
3f5b71a472 | ||
|
|
7df585e606 | ||
|
|
f1f7c53fbe | ||
|
|
25ce1ebbe4 | ||
|
|
97308500e9 | ||
|
|
ccb3cd428b | ||
|
|
9a71b54d05 | ||
|
|
61756e977b | ||
|
|
7630cc9314 | ||
|
|
bb39a51d46 | ||
|
|
76abe8eb3f | ||
|
|
9bcfda88f6 | ||
|
|
e7cdfc8c35 | ||
|
|
e97746fd16 | ||
|
|
549200a76c | ||
|
|
96c2ae1182 | ||
|
|
2312b213ce | ||
|
|
ce8b107322 | ||
|
|
831813a9db | ||
|
|
64d10da9d7 | ||
|
|
3f84d4f5f2 | ||
|
|
5383e23d24 | ||
|
|
9b8dd27f3d | ||
|
|
a818b7eee8 | ||
|
|
87d0a110cd | ||
|
|
b65da23915 | ||
|
|
07356e3253 | ||
|
|
51182127f3 | ||
|
|
fdfa9882b1 | ||
|
|
88cd848245 | ||
|
|
5dbb560ef0 | ||
|
|
f973626ebc | ||
|
|
e9729ca272 | ||
|
|
c51f963ef2 | ||
|
|
5e23f76642 | ||
|
|
f642384674 | ||
|
|
f80deb9655 | ||
|
|
75af0430fc | ||
|
|
cc74e1dc65 | ||
|
|
989773995a |
@@ -4,6 +4,7 @@ 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'
|
||||
@@ -535,6 +536,8 @@ 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
|
||||
|
||||
77
browser_tests/fixtures/components/Tour.ts
Normal file
77
browser_tests/fixtures/components/Tour.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
11
browser_tests/fixtures/tourFixture.ts
Normal file
11
browser_tests/fixtures/tourFixture.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
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))
|
||||
}
|
||||
})
|
||||
136
browser_tests/tests/tour.spec.ts
Normal file
136
browser_tests/tests/tour.spec.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
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.page.getByRole('button', { name: 'Media Assets' }).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()
|
||||
|
||||
// Advancing to the next step moves focus onto its primary action.
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -72,6 +72,7 @@
|
||||
"@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:",
|
||||
|
||||
@@ -102,6 +102,10 @@
|
||||
--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);
|
||||
|
||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -30,6 +30,9 @@ 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
|
||||
@@ -465,6 +468,9 @@ 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
|
||||
|
||||
@@ -18,6 +18,7 @@ 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
|
||||
|
||||
BIN
public/assets/images/app-mode-landing.png
Normal file
BIN
public/assets/images/app-mode-landing.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 281 KiB |
@@ -15,6 +15,7 @@ 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
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
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
|
||||
@@ -9,7 +13,7 @@ import type { Directive } from 'vue'
|
||||
// renderers share one stacking sequence: whichever dialog opens last wins.
|
||||
export const vRekaZIndex: Directive<HTMLElement> = {
|
||||
mounted(el) {
|
||||
ZIndex.set('modal', el, 1700)
|
||||
ZIndex.set(MODAL_Z_KEY, el, MODAL_Z_BASE)
|
||||
},
|
||||
beforeUnmount(el) {
|
||||
ZIndex.clear(el)
|
||||
|
||||
@@ -4504,5 +4504,37 @@
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
67
src/platform/onboarding/CoachmarkCard.test.ts
Normal file
67
src/platform/onboarding/CoachmarkCard.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
50
src/platform/onboarding/CoachmarkCard.vue
Normal file
50
src/platform/onboarding/CoachmarkCard.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<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>
|
||||
67
src/platform/onboarding/CoachmarkLanding.test.ts
Normal file
67
src/platform/onboarding/CoachmarkLanding.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
120
src/platform/onboarding/CoachmarkLanding.vue
Normal file
120
src/platform/onboarding/CoachmarkLanding.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<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>
|
||||
126
src/platform/onboarding/TourOverlay.test.ts
Normal file
126
src/platform/onboarding/TourOverlay.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
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 } from './onboardingTours'
|
||||
import { useOnboardingTourStore } from './onboardingTourStore'
|
||||
|
||||
vi.mock('./onboardingTourStore', () => ({ useOnboardingTourStore: vi.fn() }))
|
||||
|
||||
function makeTourState() {
|
||||
return {
|
||||
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()
|
||||
})
|
||||
})
|
||||
38
src/platform/onboarding/TourOverlay.vue
Normal file
38
src/platform/onboarding/TourOverlay.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<CoachmarkLanding
|
||||
v-if="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="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 CoachmarkLanding from './CoachmarkLanding.vue'
|
||||
import TourSpotlight from './TourSpotlight.vue'
|
||||
import { useOnboardingTourStore } from './onboardingTourStore'
|
||||
|
||||
const tour = useOnboardingTourStore()
|
||||
</script>
|
||||
147
src/platform/onboarding/TourSpotlight.test.ts
Normal file
147
src/platform/onboarding/TourSpotlight.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
239
src/platform/onboarding/TourSpotlight.vue
Normal file
239
src/platform/onboarding/TourSpotlight.vue
Normal file
@@ -0,0 +1,239 @@
|
||||
<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="!waitingForTarget"
|
||||
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>
|
||||
72
src/platform/onboarding/coachmarkLayout.test.ts
Normal file
72
src/platform/onboarding/coachmarkLayout.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
55
src/platform/onboarding/coachmarkLayout.ts
Normal file
55
src/platform/onboarding/coachmarkLayout.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
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
|
||||
}
|
||||
157
src/platform/onboarding/coachmarkRegistry.test.ts
Normal file
157
src/platform/onboarding/coachmarkRegistry.test.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
84
src/platform/onboarding/coachmarkRegistry.ts
Normal file
84
src/platform/onboarding/coachmarkRegistry.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
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()
|
||||
}
|
||||
516
src/platform/onboarding/onboardingTourStore.test.ts
Normal file
516
src/platform/onboarding/onboardingTourStore.test.ts
Normal file
@@ -0,0 +1,516 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
227
src/platform/onboarding/onboardingTourStore.ts
Normal file
227
src/platform/onboarding/onboardingTourStore.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, 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
|
||||
if (!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) {
|
||||
if (steps.value.length) return
|
||||
if (!force && hasSeenTour(entryPath)) return
|
||||
const resolved = resolveSteps(TOURS[entryPath], targetMounted)
|
||||
if (!resolved.length) return
|
||||
steps.value = resolved
|
||||
activeTour.value = entryPath
|
||||
trackTour('started')
|
||||
void showStep(0)
|
||||
}
|
||||
|
||||
function replayTour(entryPath: EntryPath) {
|
||||
startTour(entryPath, true)
|
||||
}
|
||||
|
||||
return {
|
||||
step,
|
||||
isLast,
|
||||
canGoBack,
|
||||
title,
|
||||
body,
|
||||
primaryLabel,
|
||||
skipLabel,
|
||||
backLabel,
|
||||
countedStepIdx,
|
||||
countedStepsTotal,
|
||||
waitingForTarget,
|
||||
replayTour,
|
||||
next,
|
||||
back,
|
||||
skip
|
||||
}
|
||||
})
|
||||
36
src/platform/onboarding/onboardingTours.test.ts
Normal file
36
src/platform/onboarding/onboardingTours.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
92
src/platform/onboarding/onboardingTours.ts
Normal file
92
src/platform/onboarding/onboardingTours.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
export type EntryPath = 'appMode'
|
||||
|
||||
/** 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: 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'
|
||||
}
|
||||
]
|
||||
}
|
||||
61
src/platform/onboarding/useCoachmarkTarget.test.ts
Normal file
61
src/platform/onboarding/useCoachmarkTarget.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
136
src/platform/onboarding/useCoachmarkTarget.ts
Normal file
136
src/platform/onboarding/useCoachmarkTarget.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
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 }
|
||||
}
|
||||
30
src/platform/onboarding/useTourTriggers.ts
Normal file
30
src/platform/onboarding/useTourTriggers.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
import type { ComputedRef } from 'vue'
|
||||
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
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 = useBreakpoints(breakpointsTailwind).greaterOrEqual('md')
|
||||
const inAppMode = computed(() => desktopLayout.value && mode.value === 'app')
|
||||
return [
|
||||
[
|
||||
'appMode',
|
||||
{
|
||||
holds: inAppMode,
|
||||
autoOpen: computed(() => inAppMode.value && appModeStore.hasOutputs)
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
73
src/platform/onboarding/vCoachmark.test.ts
Normal file
73
src/platform/onboarding/vCoachmark.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
29
src/platform/onboarding/vCoachmark.ts
Normal file
29
src/platform/onboarding/vCoachmark.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
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)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ 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'
|
||||
@@ -977,6 +978,13 @@ 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',
|
||||
|
||||
@@ -16,6 +16,8 @@ import type {
|
||||
NodeAddedMetadata,
|
||||
NodeSearchMetadata,
|
||||
NodeSearchResultMetadata,
|
||||
OnboardingTourMetadata,
|
||||
OnboardingTourStage,
|
||||
SearchQueryMetadata,
|
||||
PageViewMetadata,
|
||||
PageVisibilityMetadata,
|
||||
@@ -165,6 +167,13 @@ 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,6 +38,8 @@ import type {
|
||||
AuthMetadata,
|
||||
DefaultViewSetMetadata,
|
||||
EnterLinearMetadata,
|
||||
OnboardingTourMetadata,
|
||||
OnboardingTourStage,
|
||||
RunButtonProperties,
|
||||
ShareFlowMetadata,
|
||||
ShellLayoutMetadata,
|
||||
@@ -460,6 +462,35 @@ 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]
|
||||
])(
|
||||
'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,6 +20,8 @@ import type {
|
||||
HelpResourceClickedMetadata,
|
||||
NodeSearchMetadata,
|
||||
NodeSearchResultMetadata,
|
||||
OnboardingTourMetadata,
|
||||
OnboardingTourStage,
|
||||
PageVisibilityMetadata,
|
||||
RunButtonProperties,
|
||||
SettingChangedMetadata,
|
||||
@@ -44,7 +46,7 @@ import type {
|
||||
} from '../../types'
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
import { TelemetryEvents } from '../../types'
|
||||
import { OnboardingTourEvents, TelemetryEvents } from '../../types'
|
||||
import { normalizeSurveyResponses } from '../../utils/surveyNormalization'
|
||||
|
||||
const DEFAULT_DISABLED_EVENTS = [
|
||||
@@ -280,6 +282,13 @@ 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,6 +4,7 @@ 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()
|
||||
@@ -700,6 +701,38 @@ 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]
|
||||
])(
|
||||
'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', () => {
|
||||
|
||||
@@ -23,6 +23,8 @@ import type {
|
||||
NodeAddedMetadata,
|
||||
NodeSearchMetadata,
|
||||
NodeSearchResultMetadata,
|
||||
OnboardingTourMetadata,
|
||||
OnboardingTourStage,
|
||||
SearchQueryMetadata,
|
||||
PageViewMetadata,
|
||||
PageVisibilityMetadata,
|
||||
@@ -49,7 +51,11 @@ import type {
|
||||
WorkflowSavedMetadata,
|
||||
WorkspaceInviteMetadata
|
||||
} from '../../types'
|
||||
import { CANCELLATION_STAGE_EVENTS, TelemetryEvents } from '../../types'
|
||||
import {
|
||||
CANCELLATION_STAGE_EVENTS,
|
||||
OnboardingTourEvents,
|
||||
TelemetryEvents
|
||||
} from '../../types'
|
||||
import { normalizeSurveyResponses } from '../../utils/surveyNormalization'
|
||||
|
||||
const DEFAULT_DISABLED_EVENTS = [
|
||||
@@ -416,6 +422,13 @@ 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
|
||||
|
||||
@@ -68,6 +68,31 @@ export interface SurveyResponses {
|
||||
intent?: string[]
|
||||
}
|
||||
|
||||
export type OnboardingTourStage =
|
||||
| 'started'
|
||||
| 'step_shown'
|
||||
| 'completed'
|
||||
| 'skipped'
|
||||
|
||||
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.
|
||||
*/
|
||||
export interface OnboardingTourMetadata {
|
||||
tour: string
|
||||
step_count: number
|
||||
step_number?: number
|
||||
coach_id?: string
|
||||
skip_reason?: OnboardingTourSkipReason
|
||||
}
|
||||
|
||||
export interface SurveyResponsesNormalized extends SurveyResponses {
|
||||
industry_normalized?: string
|
||||
industry_raw?: string
|
||||
@@ -556,6 +581,12 @@ 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
|
||||
|
||||
@@ -661,6 +692,12 @@ export const TelemetryEvents = {
|
||||
USER_SURVEY_OPENED: 'app:user_survey_opened',
|
||||
USER_SURVEY_SUBMITTED: 'app:user_survey_submitted',
|
||||
|
||||
// Onboarding Coachmarks
|
||||
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',
|
||||
|
||||
// Email Verification
|
||||
USER_EMAIL_VERIFY_OPENED: 'app:user_email_verify_opened',
|
||||
USER_EMAIL_VERIFY_REQUESTED: 'app:user_email_verify_requested',
|
||||
@@ -724,6 +761,16 @@ 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
|
||||
}
|
||||
|
||||
export const CANCELLATION_STAGE_EVENTS = {
|
||||
flow_opened: TelemetryEvents.SUBSCRIPTION_CANCEL_FLOW_OPENED,
|
||||
confirmed: TelemetryEvents.SUBSCRIPTION_CANCEL_CONFIRMED,
|
||||
@@ -743,6 +790,7 @@ export type ExecutionTriggerSource =
|
||||
*/
|
||||
export type TelemetryEventProperties =
|
||||
| AuthMetadata
|
||||
| OnboardingTourMetadata
|
||||
| SurveyResponses
|
||||
| TemplateMetadata
|
||||
| ExecutionContext
|
||||
|
||||
@@ -8,6 +8,9 @@ import AppModeWidgetList from '@/components/builder/AppModeWidgetList.vue'
|
||||
import { useErrorOverlayState } from '@/components/error/useErrorOverlayState'
|
||||
import Loader from '@/components/loader/Loader.vue'
|
||||
import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
|
||||
import { COACH_IDS } from '@/platform/onboarding/onboardingTours'
|
||||
import { useOnboardingTourStore } from '@/platform/onboarding/onboardingTourStore'
|
||||
import { vCoachmark } from '@/platform/onboarding/vCoachmark'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
@@ -89,6 +92,10 @@ async function runButtonClick(e: Event) {
|
||||
function handleDragDrop() {
|
||||
return widgetListRef.value?.handleDragDrop()
|
||||
}
|
||||
|
||||
function replayAppModeTour() {
|
||||
useOnboardingTourStore().replayTour('appMode')
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
@@ -102,16 +109,29 @@ function handleDragDrop() {
|
||||
class="flex h-12 items-center gap-2 border-x border-border-subtle bg-comfy-menu-bg px-4 py-2 contain-size"
|
||||
>
|
||||
<span
|
||||
class="truncate font-bold"
|
||||
class="min-w-0 flex-1 truncate font-bold"
|
||||
v-text="workflowStore.activeWorkflow?.filename"
|
||||
/>
|
||||
<div class="flex-1" />
|
||||
<Button v-if="false"> {{ t('menuLabels.publish') }} </Button>
|
||||
<Button
|
||||
v-tooltip.bottom="{
|
||||
value: t('onboardingCoachmarks.appMode.replay'),
|
||||
showDelay: 300,
|
||||
hideDelay: 300
|
||||
}"
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
:aria-label="t('onboardingCoachmarks.appMode.replay')"
|
||||
class="rounded-lg border border-solid border-border-default text-muted-foreground hover:border-interface-stroke hover:text-base-foreground"
|
||||
@click="replayAppModeTour"
|
||||
>
|
||||
<i class="icon-[lucide--circle-question-mark] size-4" />
|
||||
</Button>
|
||||
</section>
|
||||
<div
|
||||
class="flex h-full flex-col gap-2 border-x border-(--interface-stroke) bg-comfy-menu-bg px-2 md:border-y"
|
||||
>
|
||||
<section
|
||||
v-coachmark="COACH_IDS.inputsList"
|
||||
data-testid="linear-widgets"
|
||||
class="grow scroll-shadows-comfy-menu-bg overflow-y-auto contain-size"
|
||||
>
|
||||
@@ -152,34 +172,78 @@ function handleDragDrop() {
|
||||
class="border-t border-node-component-border p-4 pb-6"
|
||||
>
|
||||
<LinearRunErrorWarning v-if="showRunErrorWarning" />
|
||||
<SubscribeToRunButton
|
||||
v-if="!isActiveSubscription"
|
||||
class="mt-4 w-full"
|
||||
/>
|
||||
<div v-else class="mt-4 flex">
|
||||
<PartnerNodesList mobile />
|
||||
<Popover side="top" @open-auto-focus.prevent>
|
||||
<template #button>
|
||||
<Button size="lg" class="-mr-3 pr-7">
|
||||
<i v-if="batchCount == 1" class="icon-[lucide--chevron-down]" />
|
||||
<div v-else class="tabular-nums" v-text="`${batchCount}x`" />
|
||||
</Button>
|
||||
</template>
|
||||
<div
|
||||
class="m-1 mb-2 text-node-component-slot-text"
|
||||
v-text="t('linearMode.runCount')"
|
||||
/>
|
||||
<ScrubableNumberInput
|
||||
v-model="batchCount"
|
||||
:aria-label="t('linearMode.runCount')"
|
||||
:min="1"
|
||||
:max="settingStore.get('Comfy.QueueButton.BatchCountLimit')"
|
||||
class="h-10 min-w-40"
|
||||
/>
|
||||
</Popover>
|
||||
<div v-coachmark="COACH_IDS.appRunButton">
|
||||
<SubscribeToRunButton
|
||||
v-if="!isActiveSubscription"
|
||||
class="mt-4 w-full"
|
||||
/>
|
||||
<div v-else class="mt-4 flex">
|
||||
<PartnerNodesList mobile />
|
||||
<Popover side="top" @open-auto-focus.prevent>
|
||||
<template #button>
|
||||
<Button size="lg" class="-mr-3 pr-7">
|
||||
<i
|
||||
v-if="batchCount == 1"
|
||||
class="icon-[lucide--chevron-down]"
|
||||
/>
|
||||
<div v-else class="tabular-nums" v-text="`${batchCount}x`" />
|
||||
</Button>
|
||||
</template>
|
||||
<div
|
||||
class="m-1 mb-2 text-node-component-slot-text"
|
||||
v-text="t('linearMode.runCount')"
|
||||
/>
|
||||
<ScrubableNumberInput
|
||||
v-model="batchCount"
|
||||
:aria-label="t('linearMode.runCount')"
|
||||
:min="1"
|
||||
:max="settingStore.get('Comfy.QueueButton.BatchCountLimit')"
|
||||
class="h-10 min-w-40"
|
||||
/>
|
||||
</Popover>
|
||||
<Button
|
||||
variant="primary"
|
||||
class="grow"
|
||||
size="lg"
|
||||
:aria-describedby="
|
||||
showRunErrorWarning
|
||||
? LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
|
||||
: undefined
|
||||
"
|
||||
@click="runButtonClick"
|
||||
>
|
||||
<i aria-hidden="true" class="icon-[lucide--play]" />
|
||||
{{ t('menu.run') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
v-else
|
||||
:data-testid="linearRunButtonTestId"
|
||||
class="border-t border-node-component-border p-4 pb-6"
|
||||
>
|
||||
<LinearRunErrorWarning v-if="showRunErrorWarning" />
|
||||
<div v-coachmark="COACH_IDS.appRunButton">
|
||||
<div
|
||||
class="m-1 mb-2 text-node-component-slot-text"
|
||||
v-text="t('linearMode.runCount')"
|
||||
/>
|
||||
<ScrubableNumberInput
|
||||
v-model="batchCount"
|
||||
:aria-label="t('linearMode.runCount')"
|
||||
:min="1"
|
||||
:max="settingStore.get('Comfy.QueueButton.BatchCountLimit')"
|
||||
class="h-7 min-w-40"
|
||||
/>
|
||||
<SubscribeToRunButton
|
||||
v-if="!isActiveSubscription"
|
||||
class="mt-4 w-full"
|
||||
/>
|
||||
<Button
|
||||
v-else
|
||||
variant="primary"
|
||||
class="grow"
|
||||
class="mt-4 w-full text-sm"
|
||||
size="lg"
|
||||
:aria-describedby="
|
||||
showRunErrorWarning
|
||||
@@ -193,43 +257,6 @@ function handleDragDrop() {
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
v-else
|
||||
:data-testid="linearRunButtonTestId"
|
||||
class="border-t border-node-component-border p-4 pb-6"
|
||||
>
|
||||
<LinearRunErrorWarning v-if="showRunErrorWarning" />
|
||||
<div
|
||||
class="m-1 mb-2 text-node-component-slot-text"
|
||||
v-text="t('linearMode.runCount')"
|
||||
/>
|
||||
<ScrubableNumberInput
|
||||
v-model="batchCount"
|
||||
:aria-label="t('linearMode.runCount')"
|
||||
:min="1"
|
||||
:max="settingStore.get('Comfy.QueueButton.BatchCountLimit')"
|
||||
class="h-7 min-w-40"
|
||||
/>
|
||||
<SubscribeToRunButton
|
||||
v-if="!isActiveSubscription"
|
||||
class="mt-4 w-full"
|
||||
/>
|
||||
<Button
|
||||
v-else
|
||||
variant="primary"
|
||||
class="mt-4 w-full text-sm"
|
||||
size="lg"
|
||||
:aria-describedby="
|
||||
showRunErrorWarning
|
||||
? LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
|
||||
: undefined
|
||||
"
|
||||
@click="runButtonClick"
|
||||
>
|
||||
<i aria-hidden="true" class="icon-[lucide--play]" />
|
||||
{{ t('menu.run') }}
|
||||
</Button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { setMode } = useAppMode()
|
||||
|
||||
@@ -414,6 +414,7 @@ const zSettings = z.object({
|
||||
'Comfy.Toast.DisableReconnectingToast': z.boolean(),
|
||||
'Comfy.Workflow.Persist': z.boolean(),
|
||||
'Comfy.TutorialCompleted': z.boolean(),
|
||||
'Comfy.OnboardingCoachmarks.Seen': z.array(z.string()),
|
||||
'Comfy.InstalledVersion': z.string().nullable(),
|
||||
'Comfy.Node.AllowImageSizeDraw': z.boolean(),
|
||||
'Comfy.Minimap.Visible': z.boolean(),
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
<DesktopCloudNotificationController />
|
||||
<UnloadWindowConfirmDialog v-if="!isDesktop" />
|
||||
<MenuHamburger />
|
||||
<TourOverlay v-if="graphReady" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -49,6 +50,7 @@ import { runWhenGlobalIdle } from '@/base/common/async'
|
||||
import MenuHamburger from '@/components/MenuHamburger.vue'
|
||||
import UnloadWindowConfirmDialog from '@/components/dialog/UnloadWindowConfirmDialog.vue'
|
||||
import GraphCanvas from '@/components/graph/GraphCanvas.vue'
|
||||
import TourOverlay from '@/platform/onboarding/TourOverlay.vue'
|
||||
import GlobalToast from '@/components/toast/GlobalToast.vue'
|
||||
import InviteAcceptedToast from '@/platform/workspace/components/toasts/InviteAcceptedToast.vue'
|
||||
import RerouteMigrationToast from '@/components/toast/RerouteMigrationToast.vue'
|
||||
@@ -111,6 +113,7 @@ const queueStore = useQueueStore()
|
||||
const assetsStore = useAssetsStore()
|
||||
const versionCompatibilityStore = useVersionCompatibilityStore()
|
||||
const graphCanvasContainerRef = ref<HTMLDivElement | null>(null)
|
||||
const graphReady = ref(false)
|
||||
const { isBuilderMode, mode, isAppMode } = useAppMode()
|
||||
const { linearMode } = storeToRefs(useCanvasStore())
|
||||
|
||||
@@ -294,6 +297,7 @@ void nextTick(() => {
|
||||
})
|
||||
|
||||
const onGraphReady = () => {
|
||||
graphReady.value = true
|
||||
runWhenGlobalIdle(() => {
|
||||
// Track user login when app is ready in graph view (cloud only)
|
||||
if (isCloud && authStore.isAuthenticated && !hasTrackedLogin) {
|
||||
|
||||
@@ -12,6 +12,8 @@ import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
|
||||
import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
|
||||
import TopbarSubscribeButton from '@/components/topbar/TopbarSubscribeButton.vue'
|
||||
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
|
||||
import { COACH_IDS } from '@/platform/onboarding/onboardingTours'
|
||||
import { vCoachmark } from '@/platform/onboarding/vCoachmark'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import LinearControls from '@/renderer/extensions/linearMode/LinearControls.vue'
|
||||
@@ -38,6 +40,9 @@ const { hasOutputs } = storeToRefs(appModeStore)
|
||||
const mobileDisplay = useBreakpoints(breakpointsTailwind).smaller('md')
|
||||
|
||||
const activeTab = computed(() => workspaceStore.sidebarTab.activeSidebarTab)
|
||||
const assetsPanelCoach = computed(() =>
|
||||
activeTab.value?.id === 'assets' ? COACH_IDS.assetsPanel : undefined
|
||||
)
|
||||
const sidebarOnLeft = computed(
|
||||
() => settingStore.get('Comfy.Sidebar.Location') === 'left'
|
||||
)
|
||||
@@ -134,6 +139,7 @@ function dragDrop(e: DragEvent) {
|
||||
<AppBuilder v-if="showLeftBuilder" />
|
||||
<div
|
||||
v-else-if="sidebarOnLeft && activeTab"
|
||||
v-coachmark="assetsPanelCoach"
|
||||
class="size-full overflow-x-hidden border-r border-border-subtle"
|
||||
>
|
||||
<ExtensionSlot :extension="activeTab" />
|
||||
@@ -146,6 +152,7 @@ function dragDrop(e: DragEvent) {
|
||||
</SplitterPanel>
|
||||
<SplitterPanel
|
||||
id="linearCenterPanel"
|
||||
v-coachmark="COACH_IDS.outputs"
|
||||
data-testid="linear-center-panel"
|
||||
:size="CENTER_PANEL_SIZE"
|
||||
class="relative flex min-w-[20vw] flex-col gap-4 text-muted-foreground outline-none"
|
||||
@@ -188,6 +195,7 @@ function dragDrop(e: DragEvent) {
|
||||
/>
|
||||
<div
|
||||
v-else-if="activeTab"
|
||||
v-coachmark="assetsPanelCoach"
|
||||
class="h-full overflow-x-hidden border-l border-border-subtle"
|
||||
>
|
||||
<ExtensionSlot :extension="activeTab" />
|
||||
|
||||
Reference in New Issue
Block a user