fix(web): restore contact us support menu (#37774)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Jingyi 2026-06-22 18:04:17 -07:00 committed by GitHub
parent 0cc27dd401
commit ab11083c2d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 346 additions and 18 deletions

View File

@ -2610,11 +2610,6 @@
"count": 1
}
},
"web/app/components/base/zendesk/utils.ts": {
"ts/no-explicit-any": {
"count": 4
}
},
"web/app/components/billing/header-billing-btn/index.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1

View File

@ -10,6 +10,7 @@ describe('zendesk/utils', () => {
})
afterEach(() => {
vi.useRealTimers()
// Clean up window.zE after each test
window.zE = mockZE
})
@ -120,4 +121,40 @@ describe('zendesk/utils', () => {
expect(window.zE).not.toHaveBeenCalled()
})
})
describe('openZendeskWindow', () => {
it('should show and open messenger when zE exists', async () => {
vi.doMock('@/config', () => ({ IS_CE_EDITION: false }))
const { openZendeskWindow } = await import('../utils')
openZendeskWindow()
expect(window.zE).toHaveBeenCalledWith('messenger', 'show')
expect(window.zE).toHaveBeenCalledWith('messenger', 'open')
})
it('should retry opening until zE is ready', async () => {
vi.useFakeTimers()
vi.doMock('@/config', () => ({ IS_CE_EDITION: false }))
const { openZendeskWindow } = await import('../utils')
window.zE = undefined
openZendeskWindow({ interval: 10, retries: 2 })
window.zE = mockZE
vi.advanceTimersByTime(10)
expect(window.zE).toHaveBeenCalledWith('messenger', 'show')
expect(window.zE).toHaveBeenCalledWith('messenger', 'open')
vi.useRealTimers()
})
it('should not call window.zE when IS_CE_EDITION is true', async () => {
vi.doMock('@/config', () => ({ IS_CE_EDITION: true }))
const { openZendeskWindow } = await import('../utils')
openZendeskWindow()
expect(window.zE).not.toHaveBeenCalled()
})
})
})

View File

@ -2,7 +2,7 @@ import { IS_CE_EDITION } from '@/config'
type ConversationField = {
id: string
value: any
value: unknown
}
declare global {
@ -11,13 +11,13 @@ declare global {
zE?: (
command: string,
value: string,
payload?: ConversationField[] | string | string[] | (() => any),
callback?: () => any,
payload?: ConversationField[] | string | string[] | (() => unknown),
callback?: () => unknown,
) => void
}
}
export const setZendeskConversationFields = (fields: ConversationField[], callback?: () => any) => {
export const setZendeskConversationFields = (fields: ConversationField[], callback?: () => unknown) => {
if (!IS_CE_EDITION && window.zE)
window.zE('messenger:set', 'conversationFields', fields, callback)
}
@ -31,3 +31,35 @@ export const toggleZendeskWindow = (open: boolean) => {
if (!IS_CE_EDITION && window.zE)
window.zE('messenger', open ? 'open' : 'close')
}
type OpenZendeskWindowOptions = {
interval?: number
retries?: number
}
const openZendeskWindowOnce = () => {
if (IS_CE_EDITION || !window.zE)
return false
window.zE('messenger', 'show')
window.zE('messenger', 'open')
return true
}
export const openZendeskWindow = ({
interval = 100,
retries = 20,
}: OpenZendeskWindowOptions = {}) => {
if (IS_CE_EDITION)
return
if (openZendeskWindowOnce())
return
let attempts = 0
const timer = window.setInterval(() => {
attempts += 1
if (openZendeskWindowOnce() || attempts >= retries)
window.clearInterval(timer)
}, interval)
}

View File

@ -194,6 +194,8 @@ vi.mock('@/config', async (importOriginal) => {
return {
...actual,
IS_CLOUD_EDITION: true,
SUPPORT_EMAIL_ADDRESS: '',
ZENDESK_WIDGET_KEY: '',
}
})
@ -944,6 +946,20 @@ describe('MainNav', () => {
})
})
it('closes the help menu from the support upgrade action', async () => {
renderMainNav()
fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.help.openMenu' }))
expect(await screen.findByText('common.userProfile.contactUs')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'billing.upgradeBtn.encourageShort' }))
await waitFor(() => {
expect(screen.queryByText('common.userProfile.forum')).not.toBeInTheDocument()
})
expect(mockSetShowPricingModal).toHaveBeenCalled()
})
it('hides the help menu when branding is enabled', () => {
renderMainNav({ branding: { enabled: true } })

View File

@ -1,30 +1,203 @@
import type { Mock } from 'vitest'
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@langgenius/dify-ui/dropdown-menu'
import { render, screen } from '@testing-library/react'
import { fireEvent, render, screen } from '@testing-library/react'
import { openZendeskWindow } from '@/app/components/base/zendesk/utils'
import { Plan } from '@/app/components/billing/type'
import { mailToSupport } from '@/app/components/header/utils/util'
import { useAppContext } from '@/context/app-context'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import SupportMenu from '../support-menu'
const { mockConfig, mockOpenZendeskWindow, mockMailToSupport, mockSetShowPricingModal } = vi.hoisted(() => ({
mockConfig: {
isCloudEdition: true,
supportEmailAddress: '',
zendeskWidgetKey: 'zendesk-key',
},
mockOpenZendeskWindow: vi.fn(),
mockMailToSupport: vi.fn(),
mockSetShowPricingModal: vi.fn(),
}))
vi.mock('@/app/components/base/zendesk/utils', () => ({
openZendeskWindow: mockOpenZendeskWindow,
}))
vi.mock('@/app/components/header/utils/util', () => ({
mailToSupport: mockMailToSupport,
}))
vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/config')>()
return {
...actual,
get IS_CLOUD_EDITION() {
return mockConfig.isCloudEdition
},
get SUPPORT_EMAIL_ADDRESS() {
return mockConfig.supportEmailAddress
},
get ZENDESK_WIDGET_KEY() {
return mockConfig.zendeskWidgetKey
},
}
})
vi.mock('@/context/app-context', () => ({
useAppContext: vi.fn(),
}))
vi.mock('@/context/modal-context', () => ({
useModalContext: vi.fn(),
}))
vi.mock('@/context/provider-context', () => ({
useProviderContext: vi.fn(),
}))
describe('SupportMenu', () => {
beforeEach(() => {
vi.clearAllMocks()
mockConfig.isCloudEdition = true
mockConfig.supportEmailAddress = ''
mockConfig.zendeskWidgetKey = 'zendesk-key'
;(useAppContext as Mock).mockReturnValue({
langGeniusVersionInfo: { current_version: '1.0.0' },
userProfile: { email: 'user@example.com' },
})
;(useProviderContext as Mock).mockReturnValue({
enableBilling: true,
plan: { type: Plan.team },
})
;(useModalContext as Mock).mockReturnValue({
setShowPricingModal: mockSetShowPricingModal,
})
;(mailToSupport as Mock).mockReturnValue('mailto:support@example.com')
})
const renderSupportMenu = () => {
const renderSupportMenu = (onContactUsClick = vi.fn()) => {
return render(
<DropdownMenu open={true} onOpenChange={() => { }}>
<DropdownMenuTrigger>open</DropdownMenuTrigger>
<DropdownMenuContent>
<SupportMenu />
<SupportMenu onContactUsClick={onContactUsClick} />
</DropdownMenuContent>
</DropdownMenu>,
)
}
it('renders support entries as flat main nav help menu items', () => {
it('renders contact us before community support entries when Zendesk is configured', () => {
const onContactUsClick = vi.fn()
renderSupportMenu(onContactUsClick)
expect(screen.getByText('common.userProfile.contactUs')).toBeInTheDocument()
expect(screen.getByText('common.userProfile.forum')).toBeInTheDocument()
expect(screen.getByText('common.userProfile.community')).toBeInTheDocument()
expect(screen.getByText('common.userProfile.contactUs').compareDocumentPosition(screen.getByText('common.userProfile.forum'))).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
expect(screen.getByRole('menuitem', { name: 'common.userProfile.forum' })).toHaveClass('mx-0', 'px-3')
fireEvent.click(screen.getByRole('menuitem', { name: 'common.userProfile.contactUs' }))
expect(openZendeskWindow).toHaveBeenCalled()
expect(onContactUsClick).toHaveBeenCalled()
})
it('renders contact us with upgrade badge for Cloud sandbox plan without dedicated support', () => {
;(useProviderContext as Mock).mockReturnValue({
enableBilling: true,
plan: { type: Plan.sandbox },
})
const onContactUsClick = vi.fn()
renderSupportMenu(onContactUsClick)
expect(screen.getByText('common.userProfile.contactUs')).toHaveClass('text-text-disabled')
expect(screen.getByText('billing.upgradeBtn.encourageShort')).toHaveClass('system-xs-semibold-uppercase', 'text-saas-dify-blue-accessible')
expect(screen.queryByText('common.userProfile.emailSupport')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('menuitem', { name: 'common.userProfile.contactUs billing.upgradeBtn.encourageShort' }))
expect(mockSetShowPricingModal).not.toHaveBeenCalled()
expect(onContactUsClick).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'billing.upgradeBtn.encourageShort' }))
expect(mockSetShowPricingModal).toHaveBeenCalled()
expect(openZendeskWindow).not.toHaveBeenCalled()
expect(onContactUsClick).toHaveBeenCalled()
})
it('hides upgrade contact for Cloud sandbox plan when billing is disabled', () => {
;(useProviderContext as Mock).mockReturnValue({
enableBilling: false,
plan: { type: Plan.sandbox },
})
renderSupportMenu()
expect(screen.queryByText('common.userProfile.contactUs')).not.toBeInTheDocument()
expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument()
expect(screen.queryByText('common.userProfile.emailSupport')).not.toBeInTheDocument()
expect(screen.getByText('common.userProfile.forum')).toBeInTheDocument()
expect(screen.getByText('common.userProfile.community')).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: 'common.userProfile.forum' })).toHaveClass('mx-0', 'px-3')
})
it('keeps Zendesk contact us for Cloud sandbox plan with support email and Zendesk configured', () => {
mockConfig.supportEmailAddress = 'support@example.com'
;(useProviderContext as Mock).mockReturnValue({
enableBilling: true,
plan: { type: Plan.sandbox },
})
renderSupportMenu()
expect(screen.getByText('common.userProfile.contactUs')).toBeInTheDocument()
expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('menuitem', { name: 'common.userProfile.contactUs' }))
expect(openZendeskWindow).toHaveBeenCalled()
expect(mockSetShowPricingModal).not.toHaveBeenCalled()
})
it('keeps email support for Cloud sandbox plan with support email and no Zendesk configured', () => {
mockConfig.supportEmailAddress = 'support@example.com'
mockConfig.zendeskWidgetKey = ''
;(useProviderContext as Mock).mockReturnValue({
enableBilling: true,
plan: { type: Plan.sandbox },
})
renderSupportMenu()
expect(screen.queryByText('common.userProfile.contactUs')).not.toBeInTheDocument()
expect(screen.getByText('common.userProfile.emailSupport')).toBeInTheDocument()
expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument()
expect(mailToSupport).toHaveBeenCalledWith('user@example.com', Plan.sandbox, '1.0.0', 'support@example.com')
})
it('hides dedicated support channels for non-Cloud sandbox plan without support email', () => {
mockConfig.isCloudEdition = false
;(useProviderContext as Mock).mockReturnValue({
enableBilling: true,
plan: { type: Plan.sandbox },
})
renderSupportMenu()
expect(screen.queryByText('common.userProfile.contactUs')).not.toBeInTheDocument()
expect(screen.queryByText('common.userProfile.emailSupport')).not.toBeInTheDocument()
expect(screen.getByText('common.userProfile.forum')).toBeInTheDocument()
})
it('renders email support when Zendesk is not configured for a dedicated support channel', () => {
mockConfig.zendeskWidgetKey = ''
renderSupportMenu()
expect(screen.queryByText('common.userProfile.contactUs')).not.toBeInTheDocument()
expect(screen.getByText('common.userProfile.emailSupport')).toBeInTheDocument()
expect(mailToSupport).toHaveBeenCalledWith('user@example.com', Plan.team, '1.0.0', '')
expect(screen.getByRole('menuitem', { name: 'common.userProfile.emailSupport' })).toHaveAttribute('href', 'mailto:support@example.com')
})
it('has correct forum and community links', () => {

View File

@ -124,7 +124,7 @@ const HelpMenu = ({
</DropdownMenuGroup>
<DropdownMenuSeparator className="my-0!" />
<DropdownMenuGroup className="p-1">
<SupportMenu />
<SupportMenu onContactUsClick={() => setOpen(false)} />
</DropdownMenuGroup>
<DropdownMenuSeparator className="my-0!" />
<DropdownMenuGroup className="p-1">

View File

@ -1,12 +1,87 @@
import { DropdownMenuLinkItem } from '@langgenius/dify-ui/dropdown-menu'
import { DropdownMenuItem, DropdownMenuLinkItem } from '@langgenius/dify-ui/dropdown-menu'
import { useTranslation } from 'react-i18next'
import { openZendeskWindow } from '@/app/components/base/zendesk/utils'
import { Plan } from '@/app/components/billing/type'
import { ExternalLinkIndicator, MenuItemContent } from '@/app/components/header/account-dropdown/menu-item-content'
import { mailToSupport } from '@/app/components/header/utils/util'
import { IS_CLOUD_EDITION, SUPPORT_EMAIL_ADDRESS, ZENDESK_WIDGET_KEY } from '@/config'
import { useAppContext } from '@/context/app-context'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
export default function SupportMenu() {
type SupportMenuProps = {
onContactUsClick?: () => void
}
export default function SupportMenu({ onContactUsClick }: SupportMenuProps) {
const { t } = useTranslation()
const { enableBilling, plan } = useProviderContext()
const { userProfile, langGeniusVersionInfo } = useAppContext()
const { setShowPricingModal } = useModalContext()
const hasDedicatedChannel = plan.type !== Plan.sandbox || Boolean(SUPPORT_EMAIL_ADDRESS.trim())
const shouldShowUpgradeContact = IS_CLOUD_EDITION && enableBilling && plan.type === Plan.sandbox && !hasDedicatedChannel
const hasZendeskWidget = Boolean(ZENDESK_WIDGET_KEY.trim())
return (
<>
{shouldShowUpgradeContact && (
<DropdownMenuItem
className="mx-0 h-8 cursor-default gap-1 px-3 py-1"
onClick={(event) => {
event.preventDefault()
}}
>
<MenuItemContent
iconClassName="i-ri-chat-smile-2-line text-text-disabled"
label={(
<span className="text-text-disabled">
{t('userProfile.contactUs', { ns: 'common' })}
</span>
)}
trailing={(
<button
type="button"
className="max-w-30 shrink-0 truncate px-1 system-xs-semibold-uppercase text-saas-dify-blue-accessible transition-colors hover:text-saas-dify-blue-static-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden focus-visible:ring-inset"
onClick={(event) => {
event.stopPropagation()
setShowPricingModal()
onContactUsClick?.()
}}
>
{t('upgradeBtn.encourageShort', { ns: 'billing' })}
</button>
)}
/>
</DropdownMenuItem>
)}
{!shouldShowUpgradeContact && hasDedicatedChannel && hasZendeskWidget && (
<DropdownMenuItem
className="mx-0 h-8 gap-1 px-3 py-1"
onClick={() => {
openZendeskWindow()
onContactUsClick?.()
}}
>
<MenuItemContent
iconClassName="i-ri-chat-smile-2-line"
label={t('userProfile.contactUs', { ns: 'common' })}
/>
</DropdownMenuItem>
)}
{!shouldShowUpgradeContact && hasDedicatedChannel && !hasZendeskWidget && (
<DropdownMenuLinkItem
className="mx-0 h-8 gap-1 px-3 py-1"
href={mailToSupport(userProfile.email, plan.type, langGeniusVersionInfo?.current_version, SUPPORT_EMAIL_ADDRESS)}
rel="noopener noreferrer"
target="_blank"
>
<MenuItemContent
iconClassName="i-ri-mail-send-line"
label={t('userProfile.emailSupport', { ns: 'common' })}
trailing={<ExternalLinkIndicator />}
/>
</DropdownMenuLinkItem>
)}
<DropdownMenuLinkItem
className="mx-0 h-8 gap-1 px-3 py-1"
href="https://forum.dify.ai/"