mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-28 02:27:21 +00:00
Compare commits
8 Commits
DynamicGro
...
pysssss/in
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27511af809 | ||
|
|
301b24621a | ||
|
|
46b80e6f06 | ||
|
|
967c8b5341 | ||
|
|
b7113223f3 | ||
|
|
a2bafc8717 | ||
|
|
ad7961293c | ||
|
|
2ec2886332 |
@@ -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', () => ({
|
||||
@@ -132,44 +140,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', () => {
|
||||
|
||||
@@ -120,7 +120,7 @@ import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useWorkflowStatusDismissal } from '@/composables/useWorkflowStatusDismissal'
|
||||
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'
|
||||
@@ -157,11 +157,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)
|
||||
|
||||
73
src/components/ui/TypeformPopoverButton.test.ts
Normal file
73
src/components/ui/TypeformPopoverButton.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
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) })
|
||||
}
|
||||
})
|
||||
|
||||
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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,23 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { breakpointsTailwind, useBreakpoints, whenever } from '@vueuse/core'
|
||||
import { useTemplateRef } from 'vue'
|
||||
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
|
||||
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import TypeformEmbed from '@/platform/surveys/TypeformEmbed.vue'
|
||||
|
||||
const { 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)
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<Button
|
||||
@@ -41,6 +34,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')
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -2963,13 +2963,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": {
|
||||
|
||||
@@ -50,3 +50,38 @@ describe('buildFeedbackTypeformUrl', () => {
|
||||
expect(url.hash).toBe('#distribution=ccloud&source=topbar')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildFeedbackHiddenFields', () => {
|
||||
beforeEach(() => {
|
||||
distribution.isCloud = false
|
||||
distribution.isNightly = false
|
||||
})
|
||||
|
||||
async function build(
|
||||
source: 'topbar' | 'action-bar' | 'help-center',
|
||||
extraTags?: Record<string, string>
|
||||
) {
|
||||
vi.resetModules()
|
||||
const { buildFeedbackHiddenFields } = await import('./config')
|
||||
return buildFeedbackHiddenFields(source, extraTags)
|
||||
}
|
||||
|
||||
it('formats tags comma-separated for the Typeform embed data-tf-hidden attribute', 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'
|
||||
)
|
||||
})
|
||||
|
||||
it('appends extra tags after the base segmentation tags', async () => {
|
||||
distribution.isCloud = true
|
||||
expect(await build('topbar', { email: 'user@example.com' })).toBe(
|
||||
'distribution=ccloud,source=topbar,email=user@example.com'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -25,7 +25,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 +43,20 @@ 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 {
|
||||
const params = new URLSearchParams({
|
||||
distribution: getDistribution(),
|
||||
source
|
||||
})
|
||||
export function buildFeedbackTypeformUrl(source: FeedbackSource): string {
|
||||
const params = new URLSearchParams(getFeedbackTags(source))
|
||||
return `${FEEDBACK_TYPEFORM_BASE_URL}#${params.toString()}`
|
||||
}
|
||||
|
||||
export function buildFeedbackHiddenFields(
|
||||
source: FeedbackSource,
|
||||
extraTags: Record<string, string> = {}
|
||||
): string {
|
||||
return Object.entries({ ...getFeedbackTags(source), ...extraTags })
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join(',')
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
|
||||
79
src/platform/support/feedbackDialog.test.ts
Normal file
79
src/platform/support/feedbackDialog.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
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 }))
|
||||
}))
|
||||
|
||||
const userEmail = vi.hoisted((): { value: string | undefined } => ({
|
||||
value: undefined
|
||||
}))
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: () => ({ userEmail })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: true,
|
||||
isNightly: false
|
||||
}))
|
||||
|
||||
describe('openFeedbackDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
userEmail.value = undefined
|
||||
})
|
||||
|
||||
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('includes the logged-in user email as a hidden field', () => {
|
||||
userEmail.value = 'user@example.com'
|
||||
|
||||
openFeedbackDialog('action-bar')
|
||||
|
||||
expect(openTypeformDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
hiddenFields:
|
||||
'distribution=ccloud,source=action-bar,email=user@example.com'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
25
src/platform/support/feedbackDialog.ts
Normal file
25
src/platform/support/feedbackDialog.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
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) {
|
||||
const { userEmail } = useCurrentUser()
|
||||
|
||||
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,
|
||||
userEmail.value ? { email: userEmail.value } : {}
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
75
src/platform/surveys/TypeformEmbed.test.ts
Normal file
75
src/platform/surveys/TypeformEmbed.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
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'
|
||||
|
||||
const embedState = vi.hoisted(() => ({
|
||||
typeformError: false,
|
||||
isValidTypeformId: true
|
||||
}))
|
||||
|
||||
vi.mock('./useTypeformEmbed', () => ({
|
||||
useTypeformEmbed: vi.fn(() => ({
|
||||
typeformError: ref(embedState.typeformError),
|
||||
isValidTypeformId: ref(embedState.isValidTypeformId)
|
||||
}))
|
||||
}))
|
||||
|
||||
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('forwards hidden fields and leaves redirect target to Typeform by default', () => {
|
||||
renderEmbed({ typeformId: 'abc123', hiddenFields: 'source=topbar' })
|
||||
|
||||
const embed = screen.getByTestId('typeform-embed')
|
||||
expect(embed).toHaveAttribute('data-tf-widget', 'abc123')
|
||||
expect(embed).toHaveAttribute('data-tf-hidden', 'source=topbar')
|
||||
expect(embed).not.toHaveAttribute('data-tf-redirect-target')
|
||||
expect(embed).not.toHaveAttribute('data-tf-auto-resize')
|
||||
})
|
||||
|
||||
it('keeps redirect-on-completion inside the iframe when requested', () => {
|
||||
renderEmbed({ typeformId: 'abc123', redirectTarget: '_self' })
|
||||
|
||||
expect(screen.getByTestId('typeform-embed')).toHaveAttribute(
|
||||
'data-tf-redirect-target',
|
||||
'_self'
|
||||
)
|
||||
})
|
||||
|
||||
it('enables auto-resize when requested', () => {
|
||||
renderEmbed({ typeformId: 'abc123', autoResize: true })
|
||||
|
||||
expect(screen.getByTestId('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()
|
||||
})
|
||||
})
|
||||
48
src/platform/surveys/TypeformEmbed.vue
Normal file
48
src/platform/surveys/TypeformEmbed.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import { useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
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
|
||||
)
|
||||
</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
|
||||
ref="typeformRef"
|
||||
data-testid="typeform-embed"
|
||||
:data-tf-widget="typeformId"
|
||||
:data-tf-hidden="hiddenFields"
|
||||
:data-tf-redirect-target="redirectTarget"
|
||||
:data-tf-auto-resize="autoResize || undefined"
|
||||
:class="autoResize ? 'w-full' : 'size-full'"
|
||||
/>
|
||||
</template>
|
||||
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'
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user