mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 10:59:53 +00:00
feat: add NightlySurveyPopover component for feature surveys
Amp-Thread-ID: https://ampcode.com/threads/T-019bdda3-cd9e-73ff-b74e-02ceb885f330 Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
@@ -2251,6 +2251,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": {
|
||||
|
||||
192
src/platform/surveys/NightlySurveyPopover.test.ts
Normal file
192
src/platform/surveys/NightlySurveyPopover.test.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
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 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('[class*="fixed"]').exists()).toBe(false)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('[class*="fixed"]').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('[class*="fixed"]').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('[class*="fixed"]').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')
|
||||
)
|
||||
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('[class*="fixed"]').exists()).toBe(false)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await nextTick()
|
||||
expect(wrapper.find('[class*="fixed"]').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('[class*="fixed"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
164
src/platform/surveys/NightlySurveyPopover.vue
Normal file
164
src/platform/surveys/NightlySurveyPopover.vue
Normal file
@@ -0,0 +1,164 @@
|
||||
<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 isVisible = ref(false)
|
||||
const isTypeformLoaded = ref(false)
|
||||
const typeformError = ref(false)
|
||||
const typeformRef = useTemplateRef('typeformRef')
|
||||
|
||||
let showTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const typeformId = computed(() => config.typeformId)
|
||||
|
||||
watch(
|
||||
isEligible,
|
||||
(eligible) => {
|
||||
if (eligible && !isVisible.value) {
|
||||
showTimeout = setTimeout(() => {
|
||||
isVisible.value = true
|
||||
emit('shown')
|
||||
}, delayMs.value)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
if (showTimeout) {
|
||||
clearTimeout(showTimeout)
|
||||
}
|
||||
})
|
||||
|
||||
whenever(typeformRef, () => {
|
||||
const scriptEl = document.createElement('script')
|
||||
scriptEl.src = '//embed.typeform.com/next/embed.js'
|
||||
scriptEl.onload = () => {
|
||||
isTypeformLoaded.value = true
|
||||
}
|
||||
scriptEl.onerror = () => {
|
||||
typeformError.value = true
|
||||
}
|
||||
typeformRef.value?.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 name="slide-in">
|
||||
<div
|
||||
v-if="isVisible"
|
||||
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"
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
.slide-in-enter-active,
|
||||
.slide-in-leave-active {
|
||||
transition:
|
||||
transform 0.3s ease,
|
||||
opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.slide-in-enter-from,
|
||||
.slide-in-leave-to {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-in-enter-to,
|
||||
.slide-in-leave-from {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user