Compare commits

...

8 Commits

Author SHA1 Message Date
pythongosssss
27511af809 Merge branch 'main' into pysssss/in-app-feedback-survey 2026-06-23 12:51:03 +01:00
pythongosssss
301b24621a Merge branch 'main' into pysssss/in-app-feedback-survey 2026-06-19 16:39:36 +01:00
pythongosssss
46b80e6f06 test: drop type cast and dedupe WorkflowTabs feedback button tests 2026-06-18 13:39:54 -07:00
pythongosssss
967c8b5341 test: use real i18n instead of mocking vue-i18n in survey tests 2026-06-18 13:38:10 -07:00
pythongosssss
b7113223f3 test: cover TypeformPopoverButton desktop/mobile/inactive branches 2026-06-18 13:31:07 -07:00
pythongosssss
a2bafc8717 test: update WorkflowTabs feedback button test for in-app dialog 2026-06-18 13:15:27 -07:00
pythongosssss
ad7961293c feat: include logged-in user email as a feedback form hidden field 2026-06-18 12:53:55 -07:00
pythongosssss
2ec2886332 feat: update typeform feedback to be an in app dialog
- update feedback to show via in app dialog
- extract reusable TypeformEmbed from existing surveys
- track feedback opens
2026-06-18 12:32:26 -07:00
20 changed files with 505 additions and 121 deletions

View File

@@ -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', () => {

View File

@@ -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)

View 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'
)
})
})

View File

@@ -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>

View File

@@ -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 () => {

View File

@@ -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')
}
]

View File

@@ -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": {

View File

@@ -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'
)
})
})

View File

@@ -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.

View 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()
})
})

View 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 } : {}
)
})
}

View File

@@ -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
}

View File

@@ -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()

View 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>

View 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()
})
})

View 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>

View 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' })
)
})
})

View 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'
}
})
}

View File

@@ -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', () => {

View File

@@ -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
}
}