mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-23 16:24:06 +00:00
feat: add NightlySurveyPopover component for feature surveys
Co-authored-by: Amp <amp@ampcode.com> Amp-Thread-ID: https://ampcode.com/threads/T-019c88b1-2d4d-717f-b08c-e2f71c4d80fe
This commit is contained in:
@@ -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": {
|
||||
|
||||
194
src/platform/surveys/NightlySurveyPopover.test.ts
Normal file
194
src/platform/surveys/NightlySurveyPopover.test.ts
Normal file
@@ -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
src/platform/surveys/NightlySurveyPopover.vue
Normal file
166
src/platform/surveys/NightlySurveyPopover.vue
Normal file
@@ -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="!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>
|
||||
Reference in New Issue
Block a user