mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-25 17:17:19 +00:00
Compare commits
8 Commits
DynamicGro
...
codex/desk
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbc93f38f3 | ||
|
|
27b1487b17 | ||
|
|
46b80e6f06 | ||
|
|
967c8b5341 | ||
|
|
b7113223f3 | ||
|
|
a2bafc8717 | ||
|
|
ad7961293c | ||
|
|
2ec2886332 |
@@ -41,6 +41,10 @@ vi.mock('@/platform/settings/settingStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/surveys/surveyIdentity', () => ({
|
||||
getSurveyIdentityTags: () => ({ anon_id: 'anon-1' })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({
|
||||
trackHelpResourceClicked: vi.fn(),
|
||||
@@ -132,7 +136,7 @@ describe('HelpCenterMenuContent feedback item', () => {
|
||||
await user.click(screen.getByRole('menuitem', { name: 'Give Feedback' }))
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'https://form.typeform.com/to/q7azbWPi#distribution=ccloud&source=help-center',
|
||||
'https://form.typeform.com/to/q7azbWPi#distribution=ccloud&source=help-center&anon_id=anon-1',
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
@@ -146,7 +150,7 @@ describe('HelpCenterMenuContent feedback item', () => {
|
||||
await user.click(screen.getByRole('menuitem', { name: 'Give Feedback' }))
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'https://form.typeform.com/to/q7azbWPi#distribution=oss-nightly&source=help-center',
|
||||
'https://form.typeform.com/to/q7azbWPi#distribution=oss-nightly&source=help-center&anon_id=anon-1',
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, h, reactive } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
@@ -36,7 +36,15 @@ vi.mock('@/platform/settings/settingStore', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: () => ({ isLoggedIn: { value: false } })
|
||||
useCurrentUser: () => ({
|
||||
isLoggedIn: { value: false },
|
||||
userEmail: { value: undefined }
|
||||
})
|
||||
}))
|
||||
|
||||
const openFeedbackDialog = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/platform/support/feedbackDialog', () => ({
|
||||
openFeedbackDialog
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
@@ -128,44 +136,28 @@ function renderComponent() {
|
||||
}
|
||||
|
||||
describe('WorkflowTabs feedback button', () => {
|
||||
let openSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
distribution.isCloud = false
|
||||
distribution.isDesktop = false
|
||||
distribution.isNightly = false
|
||||
tabBarLayout.value = 'Default'
|
||||
openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
|
||||
openFeedbackDialog.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('opens the Typeform survey tagged with topbar source on Cloud', async () => {
|
||||
it('opens the feedback dialog tagged with topbar source when clicked', async () => {
|
||||
distribution.isCloud = true
|
||||
const { user } = renderComponent()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Feedback' }))
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'https://form.typeform.com/to/q7azbWPi#distribution=ccloud&source=topbar',
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
expect(openFeedbackDialog).toHaveBeenCalledWith('topbar')
|
||||
})
|
||||
|
||||
it('opens the Typeform survey tagged with topbar source on Nightly', async () => {
|
||||
it('renders the feedback button on Nightly', () => {
|
||||
distribution.isNightly = true
|
||||
const { user } = renderComponent()
|
||||
renderComponent()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Feedback' }))
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'https://form.typeform.com/to/q7azbWPi#distribution=oss-nightly&source=topbar',
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Feedback' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render the feedback button on non-Cloud/non-Nightly builds', () => {
|
||||
|
||||
@@ -119,7 +119,7 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { buildFeedbackTypeformUrl } from '@/platform/support/config'
|
||||
import { openFeedbackDialog } from '@/platform/support/feedbackDialog'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
@@ -153,11 +153,7 @@ const isIntegratedTabBar = computed(
|
||||
const showCurrentUser = computed(() => isCloud || isLoggedIn.value)
|
||||
|
||||
function openFeedback() {
|
||||
window.open(
|
||||
buildFeedbackTypeformUrl('topbar'),
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
openFeedbackDialog('topbar')
|
||||
}
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
|
||||
78
src/components/ui/TypeformPopoverButton.test.ts
Normal file
78
src/components/ui/TypeformPopoverButton.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as VueUse from '@vueuse/core'
|
||||
import { computed, defineComponent, h } from 'vue'
|
||||
|
||||
import TypeformPopoverButton from './TypeformPopoverButton.vue'
|
||||
|
||||
const isMobile = vi.hoisted(() => ({ value: false }))
|
||||
|
||||
vi.mock('@vueuse/core', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof VueUse>()
|
||||
return {
|
||||
...actual,
|
||||
useBreakpoints: () => ({ smaller: () => computed(() => isMobile.value) })
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/surveys/surveyIdentity', () => ({
|
||||
getSurveyIdentityTags: () => ({ anon_id: 'anon-1' }),
|
||||
getSurveyIdentityTagsAsync: () => Promise.resolve({ anon_id: 'anon-1' })
|
||||
}))
|
||||
|
||||
const PopoverStub = defineComponent({
|
||||
name: 'Popover',
|
||||
setup(_, { slots }) {
|
||||
return () => h('div', [slots.button?.(), slots.default?.()])
|
||||
}
|
||||
})
|
||||
|
||||
const TypeformEmbedStub = defineComponent({
|
||||
name: 'TypeformEmbed',
|
||||
props: { typeformId: { type: String, required: true } },
|
||||
setup(props) {
|
||||
return () =>
|
||||
h('div', { 'data-testid': 'embed', 'data-typeform-id': props.typeformId })
|
||||
}
|
||||
})
|
||||
|
||||
function renderButton(props: { dataTfWidget: string; active?: boolean }) {
|
||||
return render(TypeformPopoverButton, {
|
||||
props,
|
||||
global: {
|
||||
stubs: { Popover: PopoverStub, TypeformEmbed: TypeformEmbedStub }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('TypeformPopoverButton', () => {
|
||||
beforeEach(() => {
|
||||
isMobile.value = false
|
||||
})
|
||||
|
||||
it('embeds the active survey in a popover on desktop', () => {
|
||||
renderButton({ dataTfWidget: 'abc123' })
|
||||
|
||||
expect(screen.getByTestId('embed')).toHaveAttribute(
|
||||
'data-typeform-id',
|
||||
'abc123'
|
||||
)
|
||||
})
|
||||
|
||||
it('omits the embed when inactive', () => {
|
||||
renderButton({ dataTfWidget: 'abc123', active: false })
|
||||
|
||||
expect(screen.queryByTestId('embed')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('links directly to the form on mobile instead of embedding', () => {
|
||||
isMobile.value = true
|
||||
renderButton({ dataTfWidget: 'abc123' })
|
||||
|
||||
expect(screen.queryByTestId('embed')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('link')).toHaveAttribute(
|
||||
'href',
|
||||
'https://form.typeform.com/to/abc123#anon_id=anon-1'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,29 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { breakpointsTailwind, useBreakpoints, whenever } from '@vueuse/core'
|
||||
import { useTemplateRef } from 'vue'
|
||||
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
|
||||
import { ref, watchEffect } from 'vue'
|
||||
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import TypeformEmbed from '@/platform/surveys/TypeformEmbed.vue'
|
||||
import {
|
||||
getSurveyIdentityTags,
|
||||
getSurveyIdentityTagsAsync
|
||||
} from '@/platform/surveys/surveyIdentity'
|
||||
|
||||
const { active = true } = defineProps<{
|
||||
const { dataTfWidget, active = true } = defineProps<{
|
||||
dataTfWidget: string
|
||||
active?: boolean
|
||||
}>()
|
||||
|
||||
const feedbackRef = useTemplateRef('feedbackRef')
|
||||
const isMobile = useBreakpoints(breakpointsTailwind).smaller('md')
|
||||
|
||||
whenever(feedbackRef, () => {
|
||||
const scriptEl = document.createElement('script')
|
||||
scriptEl.src = '//embed.typeform.com/next/embed.js'
|
||||
feedbackRef.value?.appendChild(scriptEl)
|
||||
// Mobile opens the form externally, so identity rides in the URL fragment.
|
||||
function buildFormUrl(widgetId: string, tags: Record<string, string>): string {
|
||||
const params = new URLSearchParams(tags)
|
||||
return `https://form.typeform.com/to/${widgetId}#${params.toString()}`
|
||||
}
|
||||
|
||||
const formUrl = ref(buildFormUrl(dataTfWidget, getSurveyIdentityTags()))
|
||||
|
||||
watchEffect((onCleanup) => {
|
||||
const widgetId = dataTfWidget
|
||||
let cancelled = false
|
||||
void getSurveyIdentityTagsAsync().then((tags) => {
|
||||
if (!cancelled) formUrl.value = buildFormUrl(widgetId, tags)
|
||||
})
|
||||
onCleanup(() => {
|
||||
cancelled = true
|
||||
})
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<Button
|
||||
v-if="isMobile"
|
||||
as="a"
|
||||
:href="`https://form.typeform.com/to/${dataTfWidget}`"
|
||||
:href="formUrl"
|
||||
target="_blank"
|
||||
variant="inverted"
|
||||
class="flex h-10 items-center justify-center gap-2.5 px-3 py-2"
|
||||
@@ -41,6 +58,6 @@ whenever(feedbackRef, () => {
|
||||
<i class="icon-[lucide--circle-help] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
<div v-if="active" ref="feedbackRef" data-tf-auto-resize :data-tf-widget />
|
||||
<TypeformEmbed v-if="active" :typeform-id="dataTfWidget" auto-resize />
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ActionBarButton } from '@/types/comfy'
|
||||
|
||||
const distribution = vi.hoisted(() => ({ isCloud: false, isNightly: false }))
|
||||
|
||||
const tabBarLayout = vi.hoisted(() => ({ value: 'Default' }))
|
||||
const registerExtension = vi.hoisted(() => vi.fn())
|
||||
const openFeedbackDialog = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
@@ -24,28 +23,15 @@ vi.mock('@/services/extensionService', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return distribution.isCloud
|
||||
},
|
||||
get isNightly() {
|
||||
return distribution.isNightly
|
||||
}
|
||||
vi.mock('@/platform/support/feedbackDialog', () => ({
|
||||
openFeedbackDialog
|
||||
}))
|
||||
|
||||
describe('cloudFeedbackTopbarButton', () => {
|
||||
let openSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
registerExtension.mockReset()
|
||||
distribution.isCloud = false
|
||||
distribution.isNightly = false
|
||||
openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
openSpy.mockRestore()
|
||||
openFeedbackDialog.mockReset()
|
||||
})
|
||||
|
||||
function getRegisteredButtons(): ActionBarButton[] {
|
||||
@@ -56,22 +42,15 @@ describe('cloudFeedbackTopbarButton', () => {
|
||||
return extension.actionBarButtons
|
||||
}
|
||||
|
||||
it('opens the Typeform survey tagged with action-bar source on Cloud', async () => {
|
||||
it('opens the feedback survey tagged with the action-bar source', async () => {
|
||||
tabBarLayout.value = 'Legacy'
|
||||
distribution.isCloud = true
|
||||
await import('./cloudFeedbackTopbarButton')
|
||||
|
||||
const buttons = getRegisteredButtons()
|
||||
expect(buttons).toHaveLength(1)
|
||||
buttons[0].onClick?.()
|
||||
|
||||
expect(openSpy).toHaveBeenCalledTimes(1)
|
||||
const [url, target, features] = openSpy.mock.calls[0]
|
||||
expect(url).toBe(
|
||||
'https://form.typeform.com/to/q7azbWPi#distribution=ccloud&source=action-bar'
|
||||
)
|
||||
expect(target).toBe('_blank')
|
||||
expect(features).toBe('noopener,noreferrer')
|
||||
expect(openFeedbackDialog).toHaveBeenCalledWith('action-bar')
|
||||
})
|
||||
|
||||
it('only registers the action bar button when the tab bar is Legacy', async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { t } from '@/i18n'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { buildFeedbackTypeformUrl } from '@/platform/support/config'
|
||||
import { openFeedbackDialog } from '@/platform/support/feedbackDialog'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import type { ActionBarButton } from '@/types/comfy'
|
||||
|
||||
@@ -9,13 +9,7 @@ const buttons: ActionBarButton[] = [
|
||||
icon: 'icon-[lucide--message-square-text]',
|
||||
label: t('actionbar.feedback'),
|
||||
tooltip: t('actionbar.feedbackTooltip'),
|
||||
onClick: () => {
|
||||
window.open(
|
||||
buildFeedbackTypeformUrl('action-bar'),
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
}
|
||||
onClick: () => openFeedbackDialog('action-bar')
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -2900,13 +2900,18 @@
|
||||
"description": "You've been using this feature. Would you take a moment to share your feedback?",
|
||||
"accept": "Sure, I'll help!",
|
||||
"notNow": "Not Now",
|
||||
"dontAskAgain": "Don't Ask Again",
|
||||
"loadError": "Failed to load survey. Please try again later."
|
||||
"dontAskAgain": "Don't Ask Again"
|
||||
},
|
||||
"errorPanelSurvey": {
|
||||
"ctaText": "How's the new error panel?",
|
||||
"ctaButton": "Give feedback"
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Share Feedback"
|
||||
},
|
||||
"typeform": {
|
||||
"loadError": "Failed to load the form. Please try again later."
|
||||
},
|
||||
"cloudOnboarding": {
|
||||
"skipToCloudApp": "Skip to the cloud app",
|
||||
"survey": {
|
||||
|
||||
14
src/main.ts
14
src/main.ts
@@ -44,6 +44,20 @@ if (isCloud) {
|
||||
|
||||
const { initTelemetry } = await import('@/platform/telemetry/initTelemetry')
|
||||
await initTelemetry()
|
||||
|
||||
const { setCurrentIdentityProvider } =
|
||||
await import('@/platform/surveys/surveyIdentity')
|
||||
const { cloudIdentityProvider } =
|
||||
await import('@/platform/surveys/cloudSurveyIdentity')
|
||||
setCurrentIdentityProvider(cloudIdentityProvider)
|
||||
}
|
||||
|
||||
if (isDesktop) {
|
||||
const { setCurrentIdentityProvider } =
|
||||
await import('@/platform/surveys/surveyIdentity')
|
||||
const { desktopSurveyIdentityProvider } =
|
||||
await import('@/platform/surveys/desktopSurveyIdentity')
|
||||
setCurrentIdentityProvider(desktopSurveyIdentityProvider)
|
||||
}
|
||||
|
||||
const ComfyUIPreset = definePreset(Aura, {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type * as SurveyIdentityModule from '@/platform/surveys/surveyIdentity'
|
||||
|
||||
const distribution = vi.hoisted(() => ({ isCloud: false, isNightly: false }))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
@@ -11,6 +13,11 @@ vi.mock('@/platform/distribution/types', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/surveys/surveyIdentity', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof SurveyIdentityModule>()),
|
||||
getSurveyIdentityTags: () => ({ anon_id: 'anon-1' })
|
||||
}))
|
||||
|
||||
describe('buildFeedbackTypeformUrl', () => {
|
||||
beforeEach(() => {
|
||||
distribution.isCloud = false
|
||||
@@ -26,27 +33,51 @@ describe('buildFeedbackTypeformUrl', () => {
|
||||
it('tags Cloud builds with distribution=ccloud', async () => {
|
||||
distribution.isCloud = true
|
||||
expect(await build('topbar')).toBe(
|
||||
'https://form.typeform.com/to/q7azbWPi#distribution=ccloud&source=topbar'
|
||||
'https://form.typeform.com/to/q7azbWPi#distribution=ccloud&source=topbar&anon_id=anon-1'
|
||||
)
|
||||
})
|
||||
|
||||
it('tags Nightly builds with distribution=oss-nightly', async () => {
|
||||
distribution.isNightly = true
|
||||
expect(await build('action-bar')).toBe(
|
||||
'https://form.typeform.com/to/q7azbWPi#distribution=oss-nightly&source=action-bar'
|
||||
'https://form.typeform.com/to/q7azbWPi#distribution=oss-nightly&source=action-bar&anon_id=anon-1'
|
||||
)
|
||||
})
|
||||
|
||||
it('tags OSS builds with distribution=oss', async () => {
|
||||
expect(await build('help-center')).toBe(
|
||||
'https://form.typeform.com/to/q7azbWPi#distribution=oss&source=help-center'
|
||||
)
|
||||
it('includes the survey identity for analytics joins', async () => {
|
||||
const url = new URL(await build('help-center'))
|
||||
expect(url.hash).toContain('anon_id=anon-1')
|
||||
})
|
||||
|
||||
it('uses a URL fragment so distribution and source are not sent to the server', async () => {
|
||||
it('uses a URL fragment so the tags are not sent to the server', async () => {
|
||||
distribution.isCloud = true
|
||||
const url = new URL(await build('topbar'))
|
||||
expect(url.search).toBe('')
|
||||
expect(url.hash).toBe('#distribution=ccloud&source=topbar')
|
||||
expect(url.hash).toBe('#distribution=ccloud&source=topbar&anon_id=anon-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildFeedbackHiddenFields', () => {
|
||||
beforeEach(() => {
|
||||
distribution.isCloud = false
|
||||
distribution.isNightly = false
|
||||
})
|
||||
|
||||
async function build(source: 'topbar' | 'action-bar' | 'help-center') {
|
||||
vi.resetModules()
|
||||
const { buildFeedbackHiddenFields } = await import('./config')
|
||||
return buildFeedbackHiddenFields(source)
|
||||
}
|
||||
|
||||
it('formats segmentation tags comma-separated for data-tf-hidden', async () => {
|
||||
distribution.isCloud = true
|
||||
expect(await build('topbar')).toBe('distribution=ccloud,source=topbar')
|
||||
})
|
||||
|
||||
it('reflects the build distribution', async () => {
|
||||
distribution.isNightly = true
|
||||
expect(await build('action-bar')).toBe(
|
||||
'distribution=oss-nightly,source=action-bar'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { isCloud, isNightly } from '@/platform/distribution/types'
|
||||
import {
|
||||
formatTypeformHiddenFields,
|
||||
getSurveyIdentityTags
|
||||
} from '@/platform/surveys/surveyIdentity'
|
||||
|
||||
/**
|
||||
* Zendesk ticket form field IDs.
|
||||
@@ -25,7 +29,17 @@ function getDistribution(): 'ccloud' | 'oss-nightly' | 'oss' {
|
||||
}
|
||||
|
||||
const SUPPORT_BASE_URL = 'https://support.comfy.org/hc/en-us/requests/new'
|
||||
const FEEDBACK_TYPEFORM_BASE_URL = 'https://form.typeform.com/to/q7azbWPi'
|
||||
|
||||
export type FeedbackSource = 'topbar' | 'action-bar' | 'help-center'
|
||||
|
||||
export const FEEDBACK_TYPEFORM_ID = 'q7azbWPi'
|
||||
|
||||
const FEEDBACK_TYPEFORM_BASE_URL = `https://form.typeform.com/to/${FEEDBACK_TYPEFORM_ID}`
|
||||
|
||||
/** Shared by the URL and embed builders so their segmentation tags can't drift. */
|
||||
function getFeedbackTags(source: FeedbackSource): Record<string, string> {
|
||||
return { distribution: getDistribution(), source }
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the feedback Typeform URL tagged with the current build distribution
|
||||
@@ -33,16 +47,18 @@ const FEEDBACK_TYPEFORM_BASE_URL = 'https://form.typeform.com/to/q7azbWPi'
|
||||
* (Typeform's hidden-field convention) so survey responses can be segmented
|
||||
* by distribution (cloud / oss-nightly / oss) and entry point.
|
||||
*/
|
||||
export function buildFeedbackTypeformUrl(
|
||||
source: 'topbar' | 'action-bar' | 'help-center'
|
||||
): string {
|
||||
export function buildFeedbackTypeformUrl(source: FeedbackSource): string {
|
||||
const params = new URLSearchParams({
|
||||
distribution: getDistribution(),
|
||||
source
|
||||
...getFeedbackTags(source),
|
||||
...getSurveyIdentityTags()
|
||||
})
|
||||
return `${FEEDBACK_TYPEFORM_BASE_URL}#${params.toString()}`
|
||||
}
|
||||
|
||||
export function buildFeedbackHiddenFields(source: FeedbackSource): string {
|
||||
return formatTypeformHiddenFields(getFeedbackTags(source))
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the support URL with optional user information for pre-filling.
|
||||
* Users without login information will still get a valid support URL without pre-fill.
|
||||
|
||||
58
src/platform/support/feedbackDialog.test.ts
Normal file
58
src/platform/support/feedbackDialog.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { openTypeformDialog } from '@/platform/surveys/openTypeformDialog'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
|
||||
import { FEEDBACK_TYPEFORM_ID } from './config'
|
||||
import { openFeedbackDialog } from './feedbackDialog'
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/surveys/openTypeformDialog', () => ({
|
||||
openTypeformDialog: vi.fn()
|
||||
}))
|
||||
|
||||
const trackUiButtonClicked = vi.fn()
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: vi.fn(() => ({ trackUiButtonClicked }))
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: true,
|
||||
isNightly: false
|
||||
}))
|
||||
|
||||
describe('openFeedbackDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('opens the feedback form tagged with distribution and source', () => {
|
||||
openFeedbackDialog('action-bar')
|
||||
|
||||
expect(openTypeformDialog).toHaveBeenCalledWith({
|
||||
key: 'global-feedback',
|
||||
typeformId: FEEDBACK_TYPEFORM_ID,
|
||||
title: 'feedback.title',
|
||||
hiddenFields: 'distribution=ccloud,source=action-bar'
|
||||
})
|
||||
})
|
||||
|
||||
it('tracks the button click tagged with the opening source', () => {
|
||||
openFeedbackDialog('topbar')
|
||||
|
||||
expect(trackUiButtonClicked).toHaveBeenCalledWith({
|
||||
button_id: 'feedback_button_clicked',
|
||||
element_group: 'topbar'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not throw when telemetry is unavailable', () => {
|
||||
vi.mocked(useTelemetry).mockReturnValueOnce(null)
|
||||
|
||||
expect(() => openFeedbackDialog('action-bar')).not.toThrow()
|
||||
expect(openTypeformDialog).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
19
src/platform/support/feedbackDialog.ts
Normal file
19
src/platform/support/feedbackDialog.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { t } from '@/i18n'
|
||||
import { openTypeformDialog } from '@/platform/surveys/openTypeformDialog'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
|
||||
import { FEEDBACK_TYPEFORM_ID, buildFeedbackHiddenFields } from './config'
|
||||
import type { FeedbackSource } from './config'
|
||||
|
||||
export function openFeedbackDialog(source: FeedbackSource) {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'feedback_button_clicked',
|
||||
element_group: source
|
||||
})
|
||||
openTypeformDialog({
|
||||
key: 'global-feedback',
|
||||
typeformId: FEEDBACK_TYPEFORM_ID,
|
||||
title: t('feedback.title'),
|
||||
hiddenFields: buildFeedbackHiddenFields(source)
|
||||
})
|
||||
}
|
||||
@@ -2,9 +2,12 @@ import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
const FEATURE_USAGE_KEY = 'Comfy.FeatureUsage'
|
||||
|
||||
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
|
||||
|
||||
const mockIsNightly = vi.hoisted(() => ({ value: true }))
|
||||
const mockIsCloud = vi.hoisted(() => ({ value: false }))
|
||||
const mockIsDesktop = vi.hoisted(() => ({ value: false }))
|
||||
@@ -21,12 +24,6 @@ vi.mock('@/platform/distribution/types', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: vi.fn(() => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
}))
|
||||
|
||||
describe('NightlySurveyPopover', () => {
|
||||
const defaultConfig = {
|
||||
featureId: 'test-feature',
|
||||
@@ -74,6 +71,7 @@ describe('NightlySurveyPopover', () => {
|
||||
...eventHandlers
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
Teleport: true
|
||||
}
|
||||
@@ -111,6 +109,19 @@ describe('NightlySurveyPopover', () => {
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show when typeform id is invalid', async () => {
|
||||
setFeatureUsage('test-feature', 5)
|
||||
|
||||
await renderComponent({ ...defaultConfig, typeformId: 'invalid id!' })
|
||||
await nextTick()
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
await nextTick()
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('nightly-survey-popover')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show on cloud', async () => {
|
||||
mockIsCloud.value = true
|
||||
setFeatureUsage('test-feature', 5)
|
||||
@@ -182,6 +193,7 @@ describe('NightlySurveyPopover', () => {
|
||||
open
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
Teleport: true
|
||||
}
|
||||
|
||||
@@ -25,18 +25,10 @@
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="typeformError" class="text-danger text-sm">
|
||||
{{ t('nightlySurvey.loadError') }}
|
||||
<div class="min-h-[300px]">
|
||||
<TypeformEmbed :typeform-id="config.typeformId" auto-resize />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-show="!typeformError && isValidTypeformId"
|
||||
ref="typeformRef"
|
||||
data-tf-auto-resize
|
||||
:data-tf-widget="typeformId"
|
||||
class="min-h-[300px]"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="mt-3 flex items-center gap-2"
|
||||
:class="mode === 'eligible' ? 'justify-center' : 'justify-end'"
|
||||
@@ -59,14 +51,15 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onUnmounted, ref, useTemplateRef, watch } from 'vue'
|
||||
import { onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
import TypeformEmbed from './TypeformEmbed.vue'
|
||||
import type { FeatureSurveyConfig } from './useSurveyEligibility'
|
||||
import { useSurveyEligibility } from './useSurveyEligibility'
|
||||
import { useTypeformEmbed } from './useTypeformEmbed'
|
||||
import { isTypeformIdValid } from './useTypeformEmbed'
|
||||
|
||||
const { config, mode = 'eligible' } = defineProps<{
|
||||
config: FeatureSurveyConfig
|
||||
@@ -95,12 +88,6 @@ const { isEligible, delayMs, markSurveyShown, optOut } = useSurveyEligibility(
|
||||
)
|
||||
|
||||
const hasOpenedOnce = ref(openModel.value)
|
||||
const typeformRef = useTemplateRef<HTMLDivElement>('typeformRef')
|
||||
|
||||
const { typeformError, isValidTypeformId, typeformId } = useTypeformEmbed(
|
||||
typeformRef,
|
||||
() => config.typeformId
|
||||
)
|
||||
|
||||
// Teleport stays mounted after the first open so the Typeform iframe
|
||||
// persists across consumer-side lifecycle changes.
|
||||
@@ -126,7 +113,7 @@ watch(
|
||||
|
||||
showTimeout = setTimeout(() => {
|
||||
showTimeout = null
|
||||
if (!isValidTypeformId.value) return
|
||||
if (!isTypeformIdValid(config.typeformId)) return
|
||||
if (openModel.value) return
|
||||
openModel.value = true
|
||||
markSurveyShown()
|
||||
|
||||
14
src/platform/surveys/TypeformDialogContent.vue
Normal file
14
src/platform/surveys/TypeformDialogContent.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import TypeformEmbed from './TypeformEmbed.vue'
|
||||
|
||||
const { typeformId, hiddenFields } = defineProps<{
|
||||
typeformId: string
|
||||
hiddenFields?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-[70vh] min-h-96">
|
||||
<TypeformEmbed :typeform-id :hidden-fields redirect-target="_self" />
|
||||
</div>
|
||||
</template>
|
||||
93
src/platform/surveys/TypeformEmbed.test.ts
Normal file
93
src/platform/surveys/TypeformEmbed.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import TypeformEmbed from './TypeformEmbed.vue'
|
||||
import type * as SurveyIdentityModule from './surveyIdentity'
|
||||
|
||||
const embedState = vi.hoisted(() => ({
|
||||
typeformError: false,
|
||||
isValidTypeformId: true
|
||||
}))
|
||||
|
||||
vi.mock('./useTypeformEmbed', () => ({
|
||||
useTypeformEmbed: vi.fn(() => ({
|
||||
typeformError: ref(embedState.typeformError),
|
||||
isValidTypeformId: ref(embedState.isValidTypeformId)
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('./surveyIdentity', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof SurveyIdentityModule>()),
|
||||
getSurveyIdentityTagsAsync: () => Promise.resolve({ anon_id: 'anon-1' })
|
||||
}))
|
||||
|
||||
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
|
||||
|
||||
function renderEmbed(props: ComponentProps<typeof TypeformEmbed>) {
|
||||
return render(TypeformEmbed, { props, global: { plugins: [i18n] } })
|
||||
}
|
||||
|
||||
describe('TypeformEmbed', () => {
|
||||
beforeEach(() => {
|
||||
embedState.typeformError = false
|
||||
embedState.isValidTypeformId = true
|
||||
})
|
||||
|
||||
it('appends the survey identity to the caller hidden fields', async () => {
|
||||
renderEmbed({ typeformId: 'abc123', hiddenFields: 'source=topbar' })
|
||||
|
||||
const embed = await screen.findByTestId('typeform-embed')
|
||||
expect(embed).toHaveAttribute('data-tf-widget', 'abc123')
|
||||
expect(embed).toHaveAttribute(
|
||||
'data-tf-hidden',
|
||||
'source=topbar,anon_id=anon-1'
|
||||
)
|
||||
expect(embed).not.toHaveAttribute('data-tf-redirect-target')
|
||||
expect(embed).not.toHaveAttribute('data-tf-auto-resize')
|
||||
})
|
||||
|
||||
it('sends the survey identity even without caller hidden fields', async () => {
|
||||
renderEmbed({ typeformId: 'abc123' })
|
||||
|
||||
expect(await screen.findByTestId('typeform-embed')).toHaveAttribute(
|
||||
'data-tf-hidden',
|
||||
'anon_id=anon-1'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps redirect-on-completion inside the iframe when requested', async () => {
|
||||
renderEmbed({ typeformId: 'abc123', redirectTarget: '_self' })
|
||||
|
||||
expect(await screen.findByTestId('typeform-embed')).toHaveAttribute(
|
||||
'data-tf-redirect-target',
|
||||
'_self'
|
||||
)
|
||||
})
|
||||
|
||||
it('enables auto-resize when requested', async () => {
|
||||
renderEmbed({ typeformId: 'abc123', autoResize: true })
|
||||
|
||||
expect(await screen.findByTestId('typeform-embed')).toHaveAttribute(
|
||||
'data-tf-auto-resize'
|
||||
)
|
||||
})
|
||||
|
||||
it('shows the load-error message instead of the embed when the script fails', () => {
|
||||
embedState.typeformError = true
|
||||
renderEmbed({ typeformId: 'abc123' })
|
||||
|
||||
expect(screen.getByText('typeform.loadError')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('typeform-embed')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the load-error message instead of the embed for an invalid form id', () => {
|
||||
embedState.isValidTypeformId = false
|
||||
renderEmbed({ typeformId: 'bad id!' })
|
||||
|
||||
expect(screen.getByText('typeform.loadError')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('typeform-embed')).toBeNull()
|
||||
})
|
||||
})
|
||||
70
src/platform/surveys/TypeformEmbed.vue
Normal file
70
src/platform/surveys/TypeformEmbed.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, useTemplateRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import {
|
||||
formatTypeformHiddenFields,
|
||||
getSurveyIdentityTagsAsync
|
||||
} from './surveyIdentity'
|
||||
import { useTypeformEmbed } from './useTypeformEmbed'
|
||||
|
||||
const {
|
||||
typeformId,
|
||||
hiddenFields,
|
||||
autoResize = false,
|
||||
redirectTarget
|
||||
} = defineProps<{
|
||||
typeformId: string
|
||||
/** Comma-separated `key=value` tags passed to Typeform via `data-tf-hidden`. */
|
||||
hiddenFields?: string
|
||||
autoResize?: boolean
|
||||
/** `_self` keeps a form's completion redirect inside the iframe, never navigating the host app. */
|
||||
redirectTarget?: '_self' | '_blank'
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const typeformRef = useTemplateRef<HTMLDivElement>('typeformRef')
|
||||
const { typeformError, isValidTypeformId } = useTypeformEmbed(
|
||||
typeformRef,
|
||||
() => typeformId
|
||||
)
|
||||
|
||||
const dataTfHidden = ref<string>()
|
||||
|
||||
watch(
|
||||
() => hiddenFields,
|
||||
async (_, __, onCleanup) => {
|
||||
let cancelled = false
|
||||
onCleanup(() => {
|
||||
cancelled = true
|
||||
})
|
||||
const identity = formatTypeformHiddenFields(
|
||||
await getSurveyIdentityTagsAsync()
|
||||
)
|
||||
if (cancelled) return
|
||||
dataTfHidden.value = [hiddenFields, identity].filter(Boolean).join(',')
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="typeformError || !isValidTypeformId"
|
||||
class="text-danger flex h-full items-center text-sm"
|
||||
>
|
||||
{{ t('typeform.loadError') }}
|
||||
</div>
|
||||
<!-- `data-tf-auto-resize` is read by presence, so it must be absent (not "false") when disabled -->
|
||||
<div
|
||||
v-else-if="dataTfHidden !== undefined"
|
||||
ref="typeformRef"
|
||||
data-testid="typeform-embed"
|
||||
:data-tf-widget="typeformId"
|
||||
:data-tf-hidden="dataTfHidden"
|
||||
:data-tf-redirect-target="redirectTarget"
|
||||
:data-tf-auto-resize="autoResize || undefined"
|
||||
:class="autoResize ? 'w-full' : 'size-full'"
|
||||
/>
|
||||
</template>
|
||||
54
src/platform/surveys/cloudSurveyIdentity.test.ts
Normal file
54
src/platform/surveys/cloudSurveyIdentity.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { cloudIdentityProvider } from './cloudSurveyIdentity'
|
||||
|
||||
const currentUser = vi.hoisted(
|
||||
(): { resolvedUserInfo: { value: { id: string } | null } } => ({
|
||||
resolvedUserInfo: { value: null }
|
||||
})
|
||||
)
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: () => currentUser
|
||||
}))
|
||||
|
||||
const distinctId = vi.hoisted((): { value: string | null } => ({ value: null }))
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({ getDistinctId: () => distinctId.value })
|
||||
}))
|
||||
|
||||
describe('cloudIdentityProvider', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
currentUser.resolvedUserInfo.value = null
|
||||
distinctId.value = null
|
||||
})
|
||||
|
||||
it('uses the stable local id as anon_id regardless of PostHog', () => {
|
||||
distinctId.value = 'ph-123'
|
||||
|
||||
const identity = cloudIdentityProvider.getIdentity()
|
||||
|
||||
expect(identity.anon_id).toBe(localStorage.getItem('Comfy.SurveyAnonId'))
|
||||
expect(identity.anon_id).not.toBe('ph-123')
|
||||
})
|
||||
|
||||
it('sends the PostHog distinct id as a separate field for stitching', () => {
|
||||
distinctId.value = 'ph-123'
|
||||
|
||||
expect(cloudIdentityProvider.getIdentity().distinct_id).toBe('ph-123')
|
||||
})
|
||||
|
||||
it('omits distinct_id when PostHog has none', () => {
|
||||
expect(cloudIdentityProvider.getIdentity().distinct_id).toBeUndefined()
|
||||
})
|
||||
|
||||
it('includes the authenticated id when signed in', () => {
|
||||
currentUser.resolvedUserInfo.value = { id: 'uid-1' }
|
||||
|
||||
expect(cloudIdentityProvider.getIdentity().comfy_id).toBe('uid-1')
|
||||
})
|
||||
|
||||
it('omits the authenticated id for an anonymous visitor', () => {
|
||||
expect(cloudIdentityProvider.getIdentity().comfy_id).toBeUndefined()
|
||||
})
|
||||
})
|
||||
16
src/platform/surveys/cloudSurveyIdentity.ts
Normal file
16
src/platform/surveys/cloudSurveyIdentity.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
|
||||
import { getOrCreateAnonId } from './surveyIdentity'
|
||||
import type { IdentityProvider } from './surveyIdentity'
|
||||
|
||||
export const cloudIdentityProvider = {
|
||||
getIdentity() {
|
||||
const { resolvedUserInfo } = useCurrentUser()
|
||||
return {
|
||||
anon_id: getOrCreateAnonId(),
|
||||
distinct_id: useTelemetry()?.getDistinctId() ?? undefined,
|
||||
comfy_id: resolvedUserInfo.value?.id
|
||||
}
|
||||
}
|
||||
} satisfies IdentityProvider
|
||||
67
src/platform/surveys/desktopSurveyIdentity.test.ts
Normal file
67
src/platform/surveys/desktopSurveyIdentity.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { desktopSurveyIdentityProvider } from './desktopSurveyIdentity'
|
||||
|
||||
function setDesktopBridge(
|
||||
bridge:
|
||||
| {
|
||||
Surveys?: {
|
||||
getIdentity?: () => Promise<Record<string, string> | null>
|
||||
}
|
||||
}
|
||||
| undefined
|
||||
) {
|
||||
;(window as unknown as { __comfyDesktop2?: typeof bridge }).__comfyDesktop2 =
|
||||
bridge
|
||||
}
|
||||
|
||||
describe('desktopSurveyIdentityProvider', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setDesktopBridge(undefined)
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('uses the Desktop-owned survey identity when available', async () => {
|
||||
setDesktopBridge({
|
||||
Surveys: {
|
||||
getIdentity: vi.fn().mockResolvedValue({
|
||||
anon_id: 'install-1',
|
||||
distinct_id: 'user-1',
|
||||
comfy_id: 'user-1'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
await expect(desktopSurveyIdentityProvider.getIdentity()).resolves.toEqual({
|
||||
anon_id: 'install-1',
|
||||
distinct_id: 'user-1',
|
||||
comfy_id: 'user-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to a local anonymous id when the bridge returns null', async () => {
|
||||
setDesktopBridge({
|
||||
Surveys: {
|
||||
getIdentity: vi.fn().mockResolvedValue(null)
|
||||
}
|
||||
})
|
||||
|
||||
const identity = await desktopSurveyIdentityProvider.getIdentity()
|
||||
|
||||
expect(identity?.anon_id).toBeTruthy()
|
||||
expect(identity?.distinct_id).toBeUndefined()
|
||||
expect(identity?.comfy_id).toBeUndefined()
|
||||
})
|
||||
|
||||
it('falls back to a local anonymous id when the bridge is unavailable', async () => {
|
||||
setDesktopBridge(undefined)
|
||||
|
||||
const identity = await desktopSurveyIdentityProvider.getIdentity()
|
||||
|
||||
expect(identity?.anon_id).toBe(localStorage.getItem('Comfy.SurveyAnonId'))
|
||||
})
|
||||
})
|
||||
27
src/platform/surveys/desktopSurveyIdentity.ts
Normal file
27
src/platform/surveys/desktopSurveyIdentity.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { getOrCreateAnonId } from './surveyIdentity'
|
||||
import type { IdentityProvider, SurveyIdentity } from './surveyIdentity'
|
||||
|
||||
interface DesktopSurveyIdentityBridge {
|
||||
Surveys?: {
|
||||
getIdentity?: () => Promise<Partial<SurveyIdentity> | null>
|
||||
}
|
||||
}
|
||||
|
||||
function getDesktopBridge(): DesktopSurveyIdentityBridge | undefined {
|
||||
return (window as Window & { __comfyDesktop2?: DesktopSurveyIdentityBridge })
|
||||
.__comfyDesktop2
|
||||
}
|
||||
|
||||
export const desktopSurveyIdentityProvider: IdentityProvider = {
|
||||
async getIdentity() {
|
||||
try {
|
||||
const identity = await getDesktopBridge()?.Surveys?.getIdentity?.()
|
||||
return {
|
||||
...identity,
|
||||
anon_id: identity?.anon_id ?? getOrCreateAnonId()
|
||||
}
|
||||
} catch {
|
||||
return { anon_id: getOrCreateAnonId() }
|
||||
}
|
||||
}
|
||||
}
|
||||
43
src/platform/surveys/openTypeformDialog.test.ts
Normal file
43
src/platform/surveys/openTypeformDialog.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
import TypeformDialogContent from './TypeformDialogContent.vue'
|
||||
import { openTypeformDialog } from './openTypeformDialog'
|
||||
|
||||
describe('openTypeformDialog', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('opens the form embed in a Reka dialog with the given id and hidden fields', () => {
|
||||
const showDialog = vi.spyOn(useDialogStore(), 'showDialog')
|
||||
|
||||
openTypeformDialog({
|
||||
typeformId: 'abc123',
|
||||
title: 'A Form',
|
||||
hiddenFields: 'foo=bar'
|
||||
})
|
||||
|
||||
expect(showDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
key: 'typeform-abc123',
|
||||
title: 'A Form',
|
||||
component: TypeformDialogContent,
|
||||
props: { typeformId: 'abc123', hiddenFields: 'foo=bar' },
|
||||
dialogComponentProps: expect.objectContaining({ renderer: 'reka' })
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('honors an explicit dialog key', () => {
|
||||
const showDialog = vi.spyOn(useDialogStore(), 'showDialog')
|
||||
|
||||
openTypeformDialog({ typeformId: 'abc123', title: 'A Form', key: 'custom' })
|
||||
|
||||
expect(showDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ key: 'custom' })
|
||||
)
|
||||
})
|
||||
})
|
||||
29
src/platform/surveys/openTypeformDialog.ts
Normal file
29
src/platform/surveys/openTypeformDialog.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
import TypeformDialogContent from './TypeformDialogContent.vue'
|
||||
|
||||
export interface TypeformDialogOptions {
|
||||
typeformId: string
|
||||
title: string
|
||||
/** Comma-separated `key=value` tags passed to Typeform via `data-tf-hidden`. */
|
||||
hiddenFields?: string
|
||||
key?: string
|
||||
}
|
||||
|
||||
export function openTypeformDialog({
|
||||
typeformId,
|
||||
title,
|
||||
hiddenFields,
|
||||
key
|
||||
}: TypeformDialogOptions) {
|
||||
useDialogStore().showDialog({
|
||||
key: key ?? `typeform-${typeformId}`,
|
||||
title,
|
||||
component: TypeformDialogContent,
|
||||
props: { typeformId, hiddenFields },
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
size: 'lg'
|
||||
}
|
||||
})
|
||||
}
|
||||
95
src/platform/surveys/surveyIdentity.test.ts
Normal file
95
src/platform/surveys/surveyIdentity.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
anonymousIdentityProvider,
|
||||
getSurveyIdentityTags,
|
||||
getSurveyIdentityTagsAsync,
|
||||
setCurrentIdentityProvider
|
||||
} from './surveyIdentity'
|
||||
|
||||
describe('survey identity', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
setCurrentIdentityProvider(anonymousIdentityProvider)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('is anonymous by default', () => {
|
||||
const tags = getSurveyIdentityTags()
|
||||
|
||||
expect(tags.anon_id).toBeTruthy()
|
||||
expect(tags.comfy_id).toBeUndefined()
|
||||
})
|
||||
|
||||
it('mints a stable anonymous id and reuses it across calls', () => {
|
||||
const anonId = getSurveyIdentityTags().anon_id
|
||||
|
||||
expect(localStorage.getItem('Comfy.SurveyAnonId')).toBe(anonId)
|
||||
expect(getSurveyIdentityTags().anon_id).toBe(anonId)
|
||||
})
|
||||
|
||||
it('falls back to a stable in-memory id when storage is unavailable', () => {
|
||||
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||
throw new Error('storage disabled')
|
||||
})
|
||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new Error('storage disabled')
|
||||
})
|
||||
|
||||
const anonId = getSurveyIdentityTags().anon_id
|
||||
|
||||
expect(anonId).toBeTruthy()
|
||||
expect(getSurveyIdentityTags().anon_id).toBe(anonId)
|
||||
})
|
||||
|
||||
it('resolves tags through the registered provider, dropping absent fields', () => {
|
||||
setCurrentIdentityProvider({ getIdentity: () => ({ anon_id: 'fixed' }) })
|
||||
|
||||
expect(getSurveyIdentityTags()).toEqual({ anon_id: 'fixed' })
|
||||
})
|
||||
|
||||
it('includes distinct_id and comfy_id when the provider supplies them', () => {
|
||||
setCurrentIdentityProvider({
|
||||
getIdentity: () => ({
|
||||
anon_id: 'fixed',
|
||||
distinct_id: 'ph-1',
|
||||
comfy_id: 'uid-9'
|
||||
})
|
||||
})
|
||||
|
||||
expect(getSurveyIdentityTags()).toEqual({
|
||||
anon_id: 'fixed',
|
||||
distinct_id: 'ph-1',
|
||||
comfy_id: 'uid-9'
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves async providers for bridge-backed identities', async () => {
|
||||
setCurrentIdentityProvider({
|
||||
getIdentity: () =>
|
||||
Promise.resolve({
|
||||
anon_id: 'desktop-install',
|
||||
distinct_id: 'desktop-user',
|
||||
comfy_id: 'desktop-user'
|
||||
})
|
||||
})
|
||||
|
||||
await expect(getSurveyIdentityTagsAsync()).resolves.toEqual({
|
||||
anon_id: 'desktop-install',
|
||||
distinct_id: 'desktop-user',
|
||||
comfy_id: 'desktop-user'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the sync accessor on an anonymous fallback for async providers', () => {
|
||||
setCurrentIdentityProvider({
|
||||
getIdentity: () => Promise.resolve({ anon_id: 'desktop-install' })
|
||||
})
|
||||
|
||||
expect(getSurveyIdentityTags().anon_id).toBeTruthy()
|
||||
expect(getSurveyIdentityTags().anon_id).not.toBe('desktop-install')
|
||||
})
|
||||
})
|
||||
92
src/platform/surveys/surveyIdentity.ts
Normal file
92
src/platform/surveys/surveyIdentity.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { createUuidv4 } from '@/utils/uuid'
|
||||
|
||||
const ANON_ID_KEY = 'Comfy.SurveyAnonId'
|
||||
|
||||
export interface SurveyIdentity {
|
||||
/** Stable local id, present for the user's whole journey across opt-in/login. */
|
||||
anon_id: string
|
||||
/** PostHog distinct id, bridging responses to product analytics when present. */
|
||||
distinct_id?: string
|
||||
/** Set only for an authenticated (consented) user. */
|
||||
comfy_id?: string
|
||||
}
|
||||
|
||||
type MaybePromise<T> = T | Promise<T>
|
||||
type SurveyIdentityResult = Partial<SurveyIdentity> | null | undefined
|
||||
|
||||
export interface IdentityProvider {
|
||||
getIdentity(): MaybePromise<SurveyIdentityResult>
|
||||
}
|
||||
|
||||
let memoryAnonId: string | undefined
|
||||
|
||||
export function getOrCreateAnonId(): string {
|
||||
try {
|
||||
const existing = localStorage.getItem(ANON_ID_KEY)
|
||||
if (existing) return existing
|
||||
|
||||
const anonId = createUuidv4()
|
||||
localStorage.setItem(ANON_ID_KEY, anonId)
|
||||
return anonId
|
||||
} catch {
|
||||
// Storage disabled: degrade to an in-memory id so a feedback click never throws.
|
||||
return (memoryAnonId ??= createUuidv4())
|
||||
}
|
||||
}
|
||||
|
||||
export const anonymousIdentityProvider: IdentityProvider = {
|
||||
getIdentity: () => ({ anon_id: getOrCreateAnonId() })
|
||||
}
|
||||
|
||||
let currentProvider: IdentityProvider = anonymousIdentityProvider
|
||||
|
||||
export function setCurrentIdentityProvider(provider: IdentityProvider): void {
|
||||
currentProvider = provider
|
||||
}
|
||||
|
||||
function isPromiseLike<T>(value: MaybePromise<T>): value is Promise<T> {
|
||||
return typeof (value as Promise<T>)?.then === 'function'
|
||||
}
|
||||
|
||||
function normalizeSurveyIdentity(
|
||||
identity: SurveyIdentityResult
|
||||
): SurveyIdentity {
|
||||
return {
|
||||
...(identity ?? {}),
|
||||
anon_id: identity?.anon_id ?? getOrCreateAnonId()
|
||||
}
|
||||
}
|
||||
|
||||
function tagsFromIdentity(
|
||||
identity: SurveyIdentityResult
|
||||
): Record<string, string> {
|
||||
const { anon_id, distinct_id, comfy_id } = normalizeSurveyIdentity(identity)
|
||||
return {
|
||||
anon_id,
|
||||
...(distinct_id ? { distinct_id } : {}),
|
||||
...(comfy_id ? { comfy_id } : {})
|
||||
}
|
||||
}
|
||||
|
||||
/** Identity as Typeform hidden-field tags, dropping any absent field. */
|
||||
export function getSurveyIdentityTags(): Record<string, string> {
|
||||
const identity = currentProvider.getIdentity()
|
||||
return isPromiseLike(identity)
|
||||
? tagsFromIdentity(null)
|
||||
: tagsFromIdentity(identity)
|
||||
}
|
||||
|
||||
export async function getSurveyIdentityTagsAsync(): Promise<
|
||||
Record<string, string>
|
||||
> {
|
||||
return tagsFromIdentity(await currentProvider.getIdentity())
|
||||
}
|
||||
|
||||
/** Formats tags as Typeform's comma-separated `key=value` hidden-field string. */
|
||||
export function formatTypeformHiddenFields(
|
||||
tags: Record<string, string>
|
||||
): string {
|
||||
return Object.entries(tags)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join(',')
|
||||
}
|
||||
@@ -24,7 +24,6 @@ describe('useTypeformEmbed', () => {
|
||||
const result = runInScope(() => useTypeformEmbed(containerRef, 'goZLqjKL'))
|
||||
|
||||
expect(result.isValidTypeformId.value).toBe(true)
|
||||
expect(result.typeformId.value).toBe('goZLqjKL')
|
||||
})
|
||||
|
||||
it('marks ids with non-alphanumeric characters as invalid', () => {
|
||||
@@ -34,7 +33,6 @@ describe('useTypeformEmbed', () => {
|
||||
)
|
||||
|
||||
expect(result.isValidTypeformId.value).toBe(false)
|
||||
expect(result.typeformId.value).toBe('')
|
||||
})
|
||||
|
||||
it('marks undefined id as invalid', () => {
|
||||
@@ -42,7 +40,6 @@ describe('useTypeformEmbed', () => {
|
||||
const result = runInScope(() => useTypeformEmbed(containerRef, undefined))
|
||||
|
||||
expect(result.isValidTypeformId.value).toBe(false)
|
||||
expect(result.typeformId.value).toBe('')
|
||||
})
|
||||
|
||||
it('marks empty string id as invalid', () => {
|
||||
@@ -50,7 +47,6 @@ describe('useTypeformEmbed', () => {
|
||||
const result = runInScope(() => useTypeformEmbed(containerRef, ''))
|
||||
|
||||
expect(result.isValidTypeformId.value).toBe(false)
|
||||
expect(result.typeformId.value).toBe('')
|
||||
})
|
||||
|
||||
it('exposes a reactive typeformError ref initialized to false', () => {
|
||||
|
||||
@@ -99,10 +99,6 @@ export function useTypeformEmbed(
|
||||
isTypeformIdValid(toValue(typeformIdInput))
|
||||
)
|
||||
|
||||
const typeformId = computed(() =>
|
||||
isValidTypeformId.value ? (toValue(typeformIdInput) ?? '') : ''
|
||||
)
|
||||
|
||||
whenever(typeformRef, async () => {
|
||||
try {
|
||||
await ensureScriptLoaded()
|
||||
@@ -119,7 +115,6 @@ export function useTypeformEmbed(
|
||||
|
||||
return {
|
||||
typeformError,
|
||||
isValidTypeformId,
|
||||
typeformId
|
||||
isValidTypeformId
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,4 +45,34 @@ describe('TelemetryRegistry', () => {
|
||||
})
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('getDistinctId returns the first provider id, skipping those without one', () => {
|
||||
const registry = new TelemetryRegistry()
|
||||
registry.registerProvider({})
|
||||
registry.registerProvider({ getDistinctId: () => 'ph-1' })
|
||||
registry.registerProvider({ getDistinctId: () => 'ph-2' })
|
||||
|
||||
expect(registry.getDistinctId()).toBe('ph-1')
|
||||
})
|
||||
|
||||
it('getDistinctId returns null when no provider has one', () => {
|
||||
const registry = new TelemetryRegistry()
|
||||
registry.registerProvider({})
|
||||
registry.registerProvider({ getDistinctId: () => null })
|
||||
|
||||
expect(registry.getDistinctId()).toBeNull()
|
||||
})
|
||||
|
||||
it('getDistinctId skips a provider that throws and returns the next id', () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const registry = new TelemetryRegistry()
|
||||
registry.registerProvider({
|
||||
getDistinctId: () => {
|
||||
throw new Error('boom')
|
||||
}
|
||||
})
|
||||
registry.registerProvider({ getDistinctId: () => 'ph-2' })
|
||||
|
||||
expect(registry.getDistinctId()).toBe('ph-2')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -75,6 +75,18 @@ export class TelemetryRegistry implements TelemetryDispatcher {
|
||||
this.dispatch((provider) => provider.trackUserLoggedIn?.())
|
||||
}
|
||||
|
||||
getDistinctId(): string | null {
|
||||
for (const provider of this.providers) {
|
||||
try {
|
||||
const distinctId = provider.getDistinctId?.()
|
||||
if (distinctId) return distinctId
|
||||
} catch (error) {
|
||||
console.error('[Telemetry] getDistinctId failed', error)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
trackSubscription(
|
||||
event: 'modal_opened' | 'subscribe_clicked',
|
||||
metadata?: SubscriptionMetadata
|
||||
|
||||
@@ -10,6 +10,7 @@ const hoisted = vi.hoisted(() => {
|
||||
const mockPeopleSetOnce = vi.fn()
|
||||
const mockRegister = vi.fn()
|
||||
const mockReset = vi.fn()
|
||||
const mockGetDistinctId = vi.fn(() => 'distinct-xyz')
|
||||
const mockOnUserResolved = vi.fn()
|
||||
const mockOnUserLogout = vi.fn()
|
||||
|
||||
@@ -21,6 +22,7 @@ const hoisted = vi.hoisted(() => {
|
||||
mockPeopleSetOnce,
|
||||
mockRegister,
|
||||
mockReset,
|
||||
mockGetDistinctId,
|
||||
mockOnUserResolved,
|
||||
mockOnUserLogout,
|
||||
mockPosthog: {
|
||||
@@ -30,7 +32,8 @@ const hoisted = vi.hoisted(() => {
|
||||
identify: mockIdentify,
|
||||
register: mockRegister,
|
||||
people: { set: mockPeopleSet, set_once: mockPeopleSetOnce },
|
||||
reset: mockReset
|
||||
reset: mockReset,
|
||||
get_distinct_id: mockGetDistinctId
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,6 +91,21 @@ describe('PostHogTelemetryProvider', () => {
|
||||
} as typeof window.__CONFIG__
|
||||
})
|
||||
|
||||
describe('getDistinctId', () => {
|
||||
it('returns the PostHog distinct id once initialized', async () => {
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(provider.getDistinctId()).toBe('distinct-xyz')
|
||||
})
|
||||
|
||||
it('returns null before initialization', () => {
|
||||
const provider = createProvider()
|
||||
|
||||
expect(provider.getDistinctId()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('initialization', () => {
|
||||
it('disables itself when posthog_project_token is not provided', async () => {
|
||||
const provider = createProvider({ posthog_project_token: undefined })
|
||||
|
||||
@@ -338,6 +338,11 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
this.trackEvent(TelemetryEvents.USER_LOGGED_IN)
|
||||
}
|
||||
|
||||
getDistinctId(): string | null {
|
||||
if (!this.isEnabled || !this.isInitialized || !this.posthog) return null
|
||||
return this.posthog.get_distinct_id()
|
||||
}
|
||||
|
||||
trackSubscription(
|
||||
event: 'modal_opened' | 'subscribe_clicked',
|
||||
metadata?: SubscriptionMetadata
|
||||
|
||||
@@ -473,6 +473,9 @@ export interface TelemetryProvider {
|
||||
trackAuth?(metadata: AuthMetadata): void
|
||||
trackUserLoggedIn?(): void
|
||||
|
||||
/** Anonymous/identified id used to stitch activity; null when unavailable. */
|
||||
getDistinctId?(): string | null
|
||||
|
||||
// Subscription flow events
|
||||
trackSubscription?(
|
||||
event: 'modal_opened' | 'subscribe_clicked',
|
||||
|
||||
Reference in New Issue
Block a user