diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 60ed887058a..a9975b4476e 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -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 diff --git a/web/app/components/base/zendesk/__tests__/utils.spec.ts b/web/app/components/base/zendesk/__tests__/utils.spec.ts index 7697be3e3fd..da110ff8ecf 100644 --- a/web/app/components/base/zendesk/__tests__/utils.spec.ts +++ b/web/app/components/base/zendesk/__tests__/utils.spec.ts @@ -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() + }) + }) }) diff --git a/web/app/components/base/zendesk/utils.ts b/web/app/components/base/zendesk/utils.ts index 35f3da74113..825cd887569 100644 --- a/web/app/components/base/zendesk/utils.ts +++ b/web/app/components/base/zendesk/utils.ts @@ -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) +} diff --git a/web/app/components/main-nav/__tests__/index.spec.tsx b/web/app/components/main-nav/__tests__/index.spec.tsx index d03bcc87ffe..d0ef0b0b67c 100644 --- a/web/app/components/main-nav/__tests__/index.spec.tsx +++ b/web/app/components/main-nav/__tests__/index.spec.tsx @@ -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 } }) diff --git a/web/app/components/main-nav/components/__tests__/support-menu.spec.tsx b/web/app/components/main-nav/components/__tests__/support-menu.spec.tsx index 8d06d44ef0f..f37580748ad 100644 --- a/web/app/components/main-nav/components/__tests__/support-menu.spec.tsx +++ b/web/app/components/main-nav/components/__tests__/support-menu.spec.tsx @@ -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() + 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( { }}> open - + , ) } - 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', () => { diff --git a/web/app/components/main-nav/components/help-menu.tsx b/web/app/components/main-nav/components/help-menu.tsx index 2f74454fd2e..647ef49bdb2 100644 --- a/web/app/components/main-nav/components/help-menu.tsx +++ b/web/app/components/main-nav/components/help-menu.tsx @@ -124,7 +124,7 @@ const HelpMenu = ({ - + setOpen(false)} /> diff --git a/web/app/components/main-nav/components/support-menu.tsx b/web/app/components/main-nav/components/support-menu.tsx index cd904a300d3..e55b90ca43e 100644 --- a/web/app/components/main-nav/components/support-menu.tsx +++ b/web/app/components/main-nav/components/support-menu.tsx @@ -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 && ( + { + event.preventDefault() + }} + > + + {t('userProfile.contactUs', { ns: 'common' })} + + )} + trailing={( + + )} + /> + + )} + {!shouldShowUpgradeContact && hasDedicatedChannel && hasZendeskWidget && ( + { + openZendeskWindow() + onContactUsClick?.() + }} + > + + + )} + {!shouldShowUpgradeContact && hasDedicatedChannel && !hasZendeskWidget && ( + + } + /> + + )}