Compare commits

...

1 Commits

Author SHA1 Message Date
Robin Huang
46ceec8021 feat: add custom nodes waitlist survey to Manager button on Cloud
Show the Manager button on Comfy Cloud and open a hosted survey in the
manager modal. The hosted survey URL is provided per environment by cloud
config (manager_survey_url) and embedded via iframe, with the logged-in
user's distinct_id appended so responses link to the user.
2026-06-24 20:28:33 -07:00
7 changed files with 292 additions and 1 deletions

View File

@@ -13,7 +13,7 @@
<div class="mx-1 flex flex-col items-end gap-1">
<div class="flex items-center gap-2">
<div
v-if="managerState.shouldShowManagerButtons.value"
v-if="managerState.shouldShowManagerButtons.value || isCloud"
class="pointer-events-auto flex h-12 shrink-0 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
>
<Button
@@ -163,6 +163,7 @@ import {
} from '@/platform/workflow/sharing/composables/lazyShareDialog'
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { useManagerSurveyDialog } from '@/workbench/extensions/manager/composables/useManagerSurveyDialog'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
import { cn } from '@comfyorg/tailwind-utils'
@@ -170,6 +171,7 @@ const settingStore = useSettingStore()
const workspaceStore = useWorkspaceStore()
const rightSidePanelStore = useRightSidePanelStore()
const managerState = useManagerState()
const managerSurveyDialog = useManagerSurveyDialog()
const { flags } = useFeatureFlags()
const { isLoggedIn } = useCurrentUser()
const { t } = useI18n()
@@ -337,6 +339,10 @@ onBeforeUnmount(() => {
})
const openCustomNodeManager = async () => {
if (isCloud) {
managerSurveyDialog.show()
return
}
try {
await managerState.openManager({
initialTab: ManagerTab.All,

View File

@@ -389,6 +389,11 @@
},
"manager": {
"title": "Nodes Manager",
"survey": {
"title": "Join the waitlist",
"intro": "Installing custom nodes is coming soon to Comfy Cloud. Answer a few quick questions to join the waitlist and help shape this feature.",
"error": "This survey couldn't be loaded. Please try again later."
},
"nodePackInfo": "Node Pack Info",
"basicInfo": "Basic Info",
"actions": "Actions",

View File

@@ -100,6 +100,8 @@ export type RemoteConfig = {
private_models_enabled?: boolean
onboarding_survey_enabled?: boolean
onboarding_survey?: OnboardingSurvey
/** Full hosted (external) survey URL embedded in the Nodes Manager modal on Cloud. */
manager_survey_url?: string
linear_toggle_enabled?: boolean
team_workspaces_enabled?: boolean
user_secrets_enabled?: boolean

View File

@@ -0,0 +1,90 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
const remoteConfig = vi.hoisted(() => ({ value: {} as RemoteConfig }))
const resolvedUserInfo = vi.hoisted(
() => ({ value: null }) as { value: { id: string } | null }
)
vi.mock('@/platform/remoteConfig/remoteConfig', () => ({
remoteConfig
}))
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => ({ resolvedUserInfo })
}))
import ManagerSurveyDialog from '@/workbench/extensions/manager/components/survey/ManagerSurveyDialog.vue'
const SURVEY_URL = 'https://us.posthog.com/external_surveys/survey-123'
function renderDialog(onClose = vi.fn()) {
return render(ManagerSurveyDialog, {
props: { onClose },
global: { mocks: { $t: (key: string) => key } }
})
}
describe('ManagerSurveyDialog', () => {
beforeEach(() => {
remoteConfig.value = {}
resolvedUserInfo.value = null
})
it('embeds the configured survey URL linked to the logged-in user', () => {
remoteConfig.value = { manager_survey_url: SURVEY_URL }
resolvedUserInfo.value = { id: 'user-123' }
renderDialog()
const src = new URL(
screen.getByTestId('manager-survey-iframe').getAttribute('src')!
)
expect(src.origin + src.pathname).toBe(SURVEY_URL)
expect(src.searchParams.get('distinct_id')).toBe('user-123')
})
it('omits distinct_id when there is no logged-in user', () => {
remoteConfig.value = { manager_survey_url: SURVEY_URL }
renderDialog()
const src = new URL(
screen.getByTestId('manager-survey-iframe').getAttribute('src')!
)
expect(src.searchParams.has('distinct_id')).toBe(false)
})
it('shows the error state when no survey url is configured', () => {
renderDialog()
expect(screen.queryByTestId('manager-survey-iframe')).toBeNull()
expect(screen.getByTestId('manager-survey-error')).toBeTruthy()
})
it('clears the loading state once the iframe loads', async () => {
remoteConfig.value = { manager_survey_url: SURVEY_URL }
renderDialog()
expect(screen.getByTestId('manager-survey-loading')).toBeTruthy()
await screen
.getByTestId('manager-survey-iframe')
.dispatchEvent(new Event('load'))
expect(screen.queryByTestId('manager-survey-loading')).toBeNull()
})
it('closes the dialog when the close button is clicked', async () => {
const onClose = vi.fn()
remoteConfig.value = { manager_survey_url: SURVEY_URL }
renderDialog(onClose)
await userEvent.click(screen.getByRole('button', { name: 'g.close' }))
expect(onClose).toHaveBeenCalledOnce()
})
})

View File

@@ -0,0 +1,120 @@
<template>
<div class="flex w-full flex-col">
<header
class="flex h-12 items-center justify-between gap-2 border-b border-border-default px-4"
>
<div class="flex items-center gap-2">
<i class="icon-[comfy--extensions-blocks]" />
<h2 class="text-neutral m-0 text-base">
{{ $t('manager.survey.title') }}
</h2>
</div>
<Button size="icon" :aria-label="$t('g.close')" @click="onClose">
<i class="icon-[lucide--x] size-4" />
</Button>
</header>
<main class="px-5 py-4">
<p
v-if="status !== 'error'"
class="mt-1 mb-4 text-sm text-muted-foreground"
>
{{ $t('manager.survey.intro') }}
</p>
<div class="relative">
<iframe
v-if="surveyUrl"
:name="IFRAME_NAME"
:src="surveyUrl"
:title="$t('manager.survey.title')"
:style="{ height: `${iframeHeight}px` }"
data-testid="manager-survey-iframe"
class="w-full rounded-lg border-0"
@load="onIframeLoad"
/>
<div
v-if="status === 'loading'"
data-testid="manager-survey-loading"
class="pointer-events-none absolute inset-0 flex items-center justify-center"
>
<i
class="icon-[lucide--loader-2] size-6 animate-spin text-muted-foreground"
/>
</div>
<div
v-else-if="status === 'error'"
data-testid="manager-survey-error"
class="flex min-h-56 flex-col items-center justify-center gap-2 text-center"
>
<i
class="icon-[lucide--triangle-alert] size-6 text-muted-foreground"
/>
<p class="m-0 text-sm text-muted-foreground">
{{ $t('manager.survey.error') }}
</p>
</div>
</div>
</main>
</div>
</template>
<script setup lang="ts">
import { useEventListener, useTimeoutFn } from '@vueuse/core'
import { computed, ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
const IFRAME_NAME = 'comfy-manager-survey'
const DEFAULT_IFRAME_HEIGHT = 460
const SURVEY_LOAD_TIMEOUT_MS = 8000
defineProps<{
onClose: () => void
}>()
const { resolvedUserInfo } = useCurrentUser()
// The hosted (external) survey URL is provided per environment by cloud config.
const surveyUrl = computed(() => {
const base = remoteConfig.value.manager_survey_url
if (!base) return undefined
const url = new URL(base)
// Link responses to the logged-in user; omit for anonymous responses.
const distinctId = resolvedUserInfo.value?.id
if (distinctId) url.searchParams.set('distinct_id', distinctId)
return url.toString()
})
const surveyOrigin = computed(() =>
surveyUrl.value ? new URL(surveyUrl.value).origin : undefined
)
const status = ref<'loading' | 'ready' | 'error'>(
surveyUrl.value ? 'loading' : 'error'
)
const iframeHeight = ref(DEFAULT_IFRAME_HEIGHT)
const { stop: stopLoadTimeout } = useTimeoutFn(() => {
if (status.value === 'loading') status.value = 'error'
}, SURVEY_LOAD_TIMEOUT_MS)
const onIframeLoad = () => {
if (status.value === 'loading') status.value = 'ready'
stopLoadTimeout()
}
// PostHog's embedded survey posts its content height for auto-resizing.
useEventListener(window, 'message', (event: MessageEvent) => {
if (event.origin !== surveyOrigin.value) return
const data = event.data
if (
data?.event === 'posthog:dimensions' &&
data?.name === IFRAME_NAME &&
typeof data.height === 'number'
) {
iframeHeight.value = data.height
}
})
</script>

View File

@@ -0,0 +1,36 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const showDialog = vi.hoisted(() => vi.fn())
const closeDialog = vi.hoisted(() => vi.fn())
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({ showDialog, closeDialog })
}))
import { useManagerSurveyDialog } from '@/workbench/extensions/manager/composables/useManagerSurveyDialog'
describe('useManagerSurveyDialog', () => {
beforeEach(() => {
showDialog.mockReset()
closeDialog.mockReset()
})
it('show() opens the survey dialog under its own key via the Reka layout renderer', () => {
useManagerSurveyDialog().show()
const [args] = showDialog.mock.calls[0]
expect(args.key).toBe('global-manager-survey')
expect(args.dialogComponentProps.renderer).toBe('reka')
})
it('show() wires onClose to close the survey dialog', () => {
useManagerSurveyDialog().show()
const [args] = showDialog.mock.calls[0]
args.props.onClose()
expect(closeDialog).toHaveBeenCalledWith({ key: 'global-manager-survey' })
})
it('hide() closes the global-manager-survey dialog', () => {
useManagerSurveyDialog().hide()
expect(closeDialog).toHaveBeenCalledWith({ key: 'global-manager-survey' })
})
})

View File

@@ -0,0 +1,32 @@
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
import ManagerSurveyDialog from '@/workbench/extensions/manager/components/survey/ManagerSurveyDialog.vue'
const DIALOG_KEY = 'global-manager-survey'
export function useManagerSurveyDialog() {
const dialogService = useDialogService()
const dialogStore = useDialogStore()
function hide() {
dialogStore.closeDialog({ key: DIALOG_KEY })
}
function show() {
dialogService.showLayoutDialog({
key: DIALOG_KEY,
component: ManagerSurveyDialog,
props: {
onClose: hide
},
dialogComponentProps: {
contentClass: 'w-full sm:max-w-lg rounded-2xl overflow-hidden'
}
})
}
return {
show,
hide
}
}