mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-25 17:17:19 +00:00
Compare commits
1 Commits
DynamicGro
...
feat/manag
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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