Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/locales/en/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -2529,6 +2529,14 @@
"renderBypassState": "Render Bypass State",
"renderErrorState": "Render Error State"
},
"nightlySurvey": {
"title": "Help Us Improve",
"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."
},
"cloudOnboarding": {
"skipToCloudApp": "Skip to the cloud app",
"survey": {
Expand Down
194 changes: 194 additions & 0 deletions src/platform/surveys/NightlySurveyPopover.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'

const FEATURE_USAGE_KEY = 'Comfy.FeatureUsage'
const POPOVER_SELECTOR = '[data-testid="nightly-survey-popover"]'

const mockIsNightly = vi.hoisted(() => ({ value: true }))
const mockIsCloud = vi.hoisted(() => ({ value: false }))
const mockIsDesktop = vi.hoisted(() => ({ value: false }))

vi.mock('@/platform/distribution/types', () => ({
get isNightly() {
return mockIsNightly.value
},
get isCloud() {
return mockIsCloud.value
},
get isDesktop() {
return mockIsDesktop.value
}
}))

vi.mock('vue-i18n', () => ({
useI18n: vi.fn(() => ({
t: (key: string) => key
}))
}))

describe('NightlySurveyPopover', () => {
const defaultConfig = {
featureId: 'test-feature',
typeformId: 'abc123',
triggerThreshold: 3,
delayMs: 100,
enabled: true
}

function setFeatureUsage(featureId: string, useCount: number) {
const existing = JSON.parse(localStorage.getItem(FEATURE_USAGE_KEY) ?? '{}')
existing[featureId] = {
useCount,
firstUsed: Date.now() - 1000,
lastUsed: Date.now()
}
localStorage.setItem(FEATURE_USAGE_KEY, JSON.stringify(existing))
}

beforeEach(() => {
localStorage.clear()
vi.resetModules()
vi.useFakeTimers()
vi.setSystemTime(new Date('2024-06-15T12:00:00Z'))

mockIsNightly.value = true
mockIsCloud.value = false
mockIsDesktop.value = false
})

afterEach(() => {
localStorage.clear()
vi.useRealTimers()
document.body.innerHTML = ''
})

async function mountComponent(config = defaultConfig) {
const { default: NightlySurveyPopover } =
await import('./NightlySurveyPopover.vue')
return mount(NightlySurveyPopover, {
props: { config },
global: {
stubs: {
Teleport: true
}
},
attachTo: document.body
})
}

describe('visibility', () => {
it('shows popover after delay when eligible', async () => {
setFeatureUsage('test-feature', 5)

const wrapper = await mountComponent()
await nextTick()

expect(wrapper.find(POPOVER_SELECTOR).exists()).toBe(false)

await vi.advanceTimersByTimeAsync(100)
await nextTick()

expect(wrapper.find(POPOVER_SELECTOR).exists()).toBe(true)
})

it('does not show when not eligible', async () => {
setFeatureUsage('test-feature', 1)

const wrapper = await mountComponent()
await nextTick()
await vi.advanceTimersByTimeAsync(1000)
await nextTick()

expect(wrapper.find(POPOVER_SELECTOR).exists()).toBe(false)
})

it('does not show on cloud', async () => {
mockIsCloud.value = true
setFeatureUsage('test-feature', 5)

const wrapper = await mountComponent()
await nextTick()
await vi.advanceTimersByTimeAsync(1000)
await nextTick()

expect(wrapper.find(POPOVER_SELECTOR).exists()).toBe(false)
})
})

describe('user actions', () => {
it('emits shown event when displayed', async () => {
setFeatureUsage('test-feature', 5)

const wrapper = await mountComponent()
await vi.advanceTimersByTimeAsync(100)
await nextTick()

expect(wrapper.emitted('shown')).toHaveLength(1)
})

it('emits dismissed when close button clicked', async () => {
setFeatureUsage('test-feature', 5)

const wrapper = await mountComponent()
await vi.advanceTimersByTimeAsync(100)
await nextTick()

const closeButton = wrapper.find('[aria-label="g.close"]')
await closeButton.trigger('click')

expect(wrapper.emitted('dismissed')).toHaveLength(1)
})

it('emits optedOut when opt out button clicked', async () => {
setFeatureUsage('test-feature', 5)

const wrapper = await mountComponent()
await vi.advanceTimersByTimeAsync(100)
await nextTick()

const buttons = wrapper.findAll('button')
const optOutButton = buttons.find((b) =>
b.text().includes('nightlySurvey.dontAskAgain')
)
expect(optOutButton).toBeDefined()
await optOutButton!.trigger('click')

expect(wrapper.emitted('optedOut')).toHaveLength(1)
})
})

describe('config', () => {
it('uses custom delay from config', async () => {
setFeatureUsage('test-feature', 5)

const wrapper = await mountComponent({
...defaultConfig,
delayMs: 500
})
await nextTick()

await vi.advanceTimersByTimeAsync(400)
await nextTick()
expect(wrapper.find(POPOVER_SELECTOR).exists()).toBe(false)

await vi.advanceTimersByTimeAsync(100)
await nextTick()
expect(wrapper.find(POPOVER_SELECTOR).exists()).toBe(true)
})

it('does not show when config is disabled', async () => {
setFeatureUsage('test-feature', 5)

const wrapper = await mountComponent({
...defaultConfig,
enabled: false
})
await nextTick()
await vi.advanceTimersByTimeAsync(1000)
await nextTick()

expect(wrapper.find(POPOVER_SELECTOR).exists()).toBe(false)
})
})
})
166 changes: 166 additions & 0 deletions src/platform/surveys/NightlySurveyPopover.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
<script setup lang="ts">
import { whenever } from '@vueuse/core'
import { computed, onUnmounted, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'

import Button from '@/components/ui/button/Button.vue'

import type { FeatureSurveyConfig } from './useSurveyEligibility'
import { useSurveyEligibility } from './useSurveyEligibility'

const { config } = defineProps<{
config: FeatureSurveyConfig
}>()

const emit = defineEmits<{
shown: []
dismissed: []
optedOut: []
}>()

const { t } = useI18n()

const { isEligible, delayMs, markSurveyShown, optOut } = useSurveyEligibility(
() => config
)

const TYPEFORM_SRC = 'https://embed.typeform.com/next/embed.js'

const isVisible = ref(false)
const typeformError = ref(false)
const typeformRef = useTemplateRef<HTMLDivElement>('typeformRef')

let showTimeout: ReturnType<typeof setTimeout> | null = null

const isValidTypeformId = computed(() =>
/^[A-Za-z0-9]+$/.test(config.typeformId)
)
const typeformId = computed(() =>
isValidTypeformId.value ? config.typeformId : ''
)

watch(
isEligible,
(eligible) => {
if (!eligible) {
if (showTimeout) {
clearTimeout(showTimeout)
showTimeout = null
}
return
}

if (isVisible.value || showTimeout) return

showTimeout = setTimeout(() => {
showTimeout = null
isVisible.value = true
emit('shown')
}, delayMs.value)
},
{ immediate: true }
)

onUnmounted(() => {
if (showTimeout) {
clearTimeout(showTimeout)
}
})

whenever(typeformRef, () => {
if (document.querySelector(`script[src="${TYPEFORM_SRC}"]`)) return

const scriptEl = document.createElement('script')
scriptEl.src = TYPEFORM_SRC
scriptEl.async = true
scriptEl.onerror = () => {
typeformError.value = true
}
document.head.appendChild(scriptEl)
})

function handleAccept() {
markSurveyShown()
}

function handleDismiss() {
isVisible.value = false
emit('dismissed')
}

function handleOptOut() {
optOut()
isVisible.value = false
emit('optedOut')
}
</script>

<template>
<Teleport to="body">
<Transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="translate-x-full opacity-0"
enter-to-class="translate-x-0 opacity-100"
leave-active-class="transition duration-300 ease-in"
leave-from-class="translate-x-0 opacity-100"
leave-to-class="translate-x-full opacity-0"
>
<div
v-if="isVisible"
data-testid="nightly-survey-popover"
class="fixed bottom-4 right-4 z-[10000] w-80 rounded-lg border border-border-subtle bg-base-background p-4 shadow-lg"
>
<div class="mb-3 flex items-start justify-between">
<h3 class="text-sm font-medium text-text-primary">
{{ t('nightlySurvey.title') }}
</h3>
<button
class="text-text-muted hover:text-text-primary"
:aria-label="t('g.close')"
@click="handleDismiss"
>
<i class="icon-[lucide--x] size-4" />
</button>
</div>

<p class="mb-4 text-sm text-text-secondary">
{{ t('nightlySurvey.description') }}
</p>

<div v-if="typeformError" class="mb-4 text-sm text-danger">
{{ t('nightlySurvey.loadError') }}
</div>

<div
v-show="isVisible && !typeformError && isValidTypeformId"
ref="typeformRef"
data-tf-auto-resize
:data-tf-widget="typeformId"
class="min-h-[300px]"
/>

<div class="mt-4 flex flex-col gap-2">
<Button variant="primary" class="w-full" @click="handleAccept">
{{ t('nightlySurvey.accept') }}
</Button>
<div class="flex gap-2">
<Button
variant="textonly"
class="flex-1 text-xs"
@click="handleDismiss"
>
{{ t('nightlySurvey.notNow') }}
</Button>
<Button
variant="muted-textonly"
class="flex-1 text-xs"
@click="handleOptOut"
>
{{ t('nightlySurvey.dontAskAgain') }}
</Button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
Loading