mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-02 21:28:08 +00:00
Compare commits
4 Commits
feat/creat
...
feat/manag
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fef7fd9d25 | ||
|
|
1fd79dfe0d | ||
|
|
1b36f07bac | ||
|
|
46ceec8021 |
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
|
||||
const mocks = vi.hoisted(
|
||||
() =>
|
||||
({
|
||||
remoteConfig: { value: {} },
|
||||
resolvedUserInfo: { value: null }
|
||||
}) as {
|
||||
remoteConfig: { value: RemoteConfig }
|
||||
resolvedUserInfo: { value: { id: string } | null }
|
||||
}
|
||||
)
|
||||
|
||||
vi.mock('@/platform/remoteConfig/remoteConfig', async () => {
|
||||
const { ref } = await import('vue')
|
||||
mocks.remoteConfig = ref<RemoteConfig>({})
|
||||
return { remoteConfig: mocks.remoteConfig }
|
||||
})
|
||||
|
||||
vi.mock('@/composables/auth/useCurrentUser', async () => {
|
||||
const { ref } = await import('vue')
|
||||
mocks.resolvedUserInfo = ref<{ id: string } | null>(null)
|
||||
return {
|
||||
useCurrentUser: () => ({ resolvedUserInfo: mocks.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(() => {
|
||||
mocks.remoteConfig.value = {}
|
||||
mocks.resolvedUserInfo.value = null
|
||||
})
|
||||
|
||||
it('embeds the configured survey URL with embed flag and the logged-in user', () => {
|
||||
mocks.remoteConfig.value = { manager_survey_url: SURVEY_URL }
|
||||
mocks.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('embed')).toBe('true')
|
||||
expect(src.searchParams.get('distinct_id')).toBe('user-123')
|
||||
})
|
||||
|
||||
it('omits distinct_id when there is no logged-in user', () => {
|
||||
mocks.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('shows the error state when the configured survey url is malformed', () => {
|
||||
mocks.remoteConfig.value = { manager_survey_url: 'not a valid url' }
|
||||
|
||||
renderDialog()
|
||||
|
||||
expect(screen.queryByTestId('manager-survey-iframe')).toBeNull()
|
||||
expect(screen.getByTestId('manager-survey-error')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('recovers from the error state when the survey url arrives after mount', async () => {
|
||||
renderDialog()
|
||||
expect(screen.getByTestId('manager-survey-error')).toBeTruthy()
|
||||
|
||||
mocks.remoteConfig.value = { manager_survey_url: SURVEY_URL }
|
||||
await nextTick()
|
||||
|
||||
expect(screen.queryByTestId('manager-survey-error')).toBeNull()
|
||||
expect(screen.getByTestId('manager-survey-iframe')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('clears the loading state once the iframe loads', async () => {
|
||||
mocks.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('applies survey height messages even when the url has a trailing slash', async () => {
|
||||
mocks.remoteConfig.value = {
|
||||
manager_survey_url: 'https://us.posthog.com/external_surveys/survey-123/'
|
||||
}
|
||||
|
||||
renderDialog()
|
||||
const iframe = screen.getByTestId('manager-survey-iframe')
|
||||
|
||||
window.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
origin: 'https://us.posthog.com',
|
||||
data: {
|
||||
type: 'posthog:survey:height',
|
||||
surveyId: 'survey-123',
|
||||
height: 800
|
||||
}
|
||||
})
|
||||
)
|
||||
await nextTick()
|
||||
|
||||
expect(iframe.getAttribute('style')).toContain('height: 800px')
|
||||
})
|
||||
|
||||
it('removes the iframe and shows the error state when loading times out', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
mocks.remoteConfig.value = { manager_survey_url: SURVEY_URL }
|
||||
|
||||
renderDialog()
|
||||
expect(screen.getByTestId('manager-survey-iframe')).toBeTruthy()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(8000)
|
||||
await nextTick()
|
||||
|
||||
expect(screen.queryByTestId('manager-survey-iframe')).toBeNull()
|
||||
expect(screen.getByTestId('manager-survey-error')).toBeTruthy()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('closes the dialog when the close button is clicked', async () => {
|
||||
const onClose = vi.fn()
|
||||
mocks.remoteConfig.value = { manager_survey_url: SURVEY_URL }
|
||||
renderDialog(onClose)
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'g.close' }))
|
||||
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,150 @@
|
||||
<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 && status !== 'error'"
|
||||
:src="surveyUrl"
|
||||
:title="$t('manager.survey.title')"
|
||||
:style="{ height: `${iframeHeight}px` }"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms"
|
||||
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, watch } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
|
||||
const DEFAULT_IFRAME_HEIGHT = 460
|
||||
const MAX_IFRAME_HEIGHT = 10000
|
||||
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
|
||||
try {
|
||||
const url = new URL(base)
|
||||
url.searchParams.set('embed', 'true')
|
||||
// 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()
|
||||
} catch {
|
||||
// A malformed configured URL falls back to the error state.
|
||||
return undefined
|
||||
}
|
||||
})
|
||||
|
||||
const parsedSurveyUrl = computed(() =>
|
||||
surveyUrl.value ? new URL(surveyUrl.value) : undefined
|
||||
)
|
||||
const surveyOrigin = computed(() => parsedSurveyUrl.value?.origin)
|
||||
// filter(Boolean) tolerates a trailing slash in the configured URL.
|
||||
const surveyId = computed(() =>
|
||||
parsedSurveyUrl.value?.pathname.split('/').filter(Boolean).pop()
|
||||
)
|
||||
|
||||
const status = ref<'loading' | 'ready' | 'error'>('loading')
|
||||
const iframeHeight = ref(DEFAULT_IFRAME_HEIGHT)
|
||||
|
||||
const { start: startLoadTimeout, stop: stopLoadTimeout } = useTimeoutFn(
|
||||
() => {
|
||||
if (status.value === 'loading') status.value = 'error'
|
||||
},
|
||||
SURVEY_LOAD_TIMEOUT_MS,
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
// Re-derive load state whenever the survey URL changes — e.g. cloud config or
|
||||
// the user identity arriving after the dialog opens — so a late URL recovers
|
||||
// from the error state instead of staying stuck.
|
||||
watch(
|
||||
surveyUrl,
|
||||
(url) => {
|
||||
if (!url) {
|
||||
stopLoadTimeout()
|
||||
status.value = 'error'
|
||||
return
|
||||
}
|
||||
status.value = 'loading'
|
||||
startLoadTimeout()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
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?.type === 'posthog:survey:height' &&
|
||||
data?.surveyId === surveyId.value
|
||||
) {
|
||||
const height = Number.parseInt(data.height, 10)
|
||||
if (height > 0 && height < MAX_IFRAME_HEIGHT) iframeHeight.value = height
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user