diff --git a/web/app/components/header/account-dropdown/compliance.spec.tsx b/web/app/components/header/account-dropdown/compliance.spec.tsx index 1eb747e154..54a0460f82 100644 --- a/web/app/components/header/account-dropdown/compliance.spec.tsx +++ b/web/app/components/header/account-dropdown/compliance.spec.tsx @@ -1,7 +1,6 @@ import type { ModalContextState } from '@/context/modal-context' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/app/components/base/ui/dropdown-menu' import { Plan } from '@/app/components/billing/type' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' import { useModalContext } from '@/context/modal-context' @@ -71,26 +70,16 @@ describe('Compliance', () => { ) } - const renderCompliance = () => { - return renderWithQueryClient( - {}}> - open - - - - , - ) - } - + // Wrapper for tests that need the menu open const openMenuAndRender = () => { - renderCompliance() - fireEvent.click(screen.getByText('common.userProfile.compliance')) + renderWithQueryClient() + fireEvent.click(screen.getByRole('button')) } describe('Rendering', () => { it('should render compliance menu trigger', () => { // Act - renderCompliance() + renderWithQueryClient() // Assert expect(screen.getByText('common.userProfile.compliance')).toBeInTheDocument() diff --git a/web/app/components/header/account-dropdown/compliance.tsx b/web/app/components/header/account-dropdown/compliance.tsx index 6bf19674d0..a8d505627f 100644 --- a/web/app/components/header/account-dropdown/compliance.tsx +++ b/web/app/components/header/account-dropdown/compliance.tsx @@ -1,9 +1,9 @@ -import type { ReactNode } from 'react' +import type { FC, MouseEvent } from 'react' +import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react' +import { RiArrowDownCircleLine, RiArrowRightSLine, RiVerifiedBadgeLine } from '@remixicon/react' import { useMutation } from '@tanstack/react-query' -import { useCallback } from 'react' +import { Fragment, useCallback } from 'react' import { useTranslation } from 'react-i18next' -import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger } from '@/app/components/base/ui/dropdown-menu' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip' import { Plan } from '@/app/components/billing/type' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' import { useModalContext } from '@/context/modal-context' @@ -11,19 +11,14 @@ import { useProviderContext } from '@/context/provider-context' import { getDocDownloadUrl } from '@/service/common' import { cn } from '@/utils/classnames' import { downloadUrl } from '@/utils/download' +import Button from '../../base/button' import Gdpr from '../../base/icons/src/public/common/Gdpr' import Iso from '../../base/icons/src/public/common/Iso' import Soc2 from '../../base/icons/src/public/common/Soc2' import SparklesSoft from '../../base/icons/src/public/common/SparklesSoft' import PremiumBadge from '../../base/premium-badge' -import Spinner from '../../base/spinner' import Toast from '../../base/toast' - -const submenuTriggerClassName = '!mx-0 !h-8 !rounded-lg !px-3 data-[highlighted]:!bg-state-base-hover' -const submenuItemClassName = '!mx-0 !h-10 !rounded-lg !py-1 !pl-1 !pr-2 data-[highlighted]:!bg-state-base-hover' -const menuLabelClassName = 'grow px-1 text-text-secondary system-md-regular' -const menuLeadingIconClassName = 'size-4 shrink-0 text-text-tertiary' -const menuTrailingIconClassName = 'size-[14px] shrink-0 text-text-tertiary' +import Tooltip from '../../base/tooltip' enum DocName { SOC2_Type_I = 'SOC2_Type_I', @@ -32,103 +27,27 @@ enum DocName { GDPR = 'GDPR', } -type ComplianceMenuItemContentProps = { - iconClassName: string - label: ReactNode - trailing?: ReactNode +type UpgradeOrDownloadProps = { + doc_name: DocName } - -function ComplianceMenuItemContent({ - iconClassName, - label, - trailing, -}: ComplianceMenuItemContentProps) { - return ( - <> - -
{label}
- {trailing} - - ) -} - -type ComplianceDocActionVisualProps = { - isCurrentPlanCanDownload: boolean - isPending: boolean - tooltipText: string - downloadText: string - upgradeText: string -} - -function ComplianceDocActionVisual({ - isCurrentPlanCanDownload, - isPending, - tooltipText, - downloadText, - upgradeText, -}: ComplianceDocActionVisualProps) { - if (isCurrentPlanCanDownload) { - return ( -
- - {downloadText} - {isPending && } -
- ) - } - - const canShowUpgradeTooltip = tooltipText.length > 0 - - return ( - - - -
- {upgradeText} -
- - )} - /> - {canShowUpgradeTooltip && ( - - {tooltipText} - - )} -
- ) -} - -type ComplianceDocRowItemProps = { - icon: ReactNode - label: ReactNode - docName: DocName -} - -function ComplianceDocRowItem({ - icon, - label, - docName, -}: ComplianceDocRowItemProps) { +const UpgradeOrDownload: FC = ({ doc_name }) => { const { t } = useTranslation() const { plan } = useProviderContext() const { setShowPricingModal, setShowAccountSettingModal } = useModalContext() const isFreePlan = plan.type === Plan.sandbox + const handlePlanClick = useCallback(() => { + if (isFreePlan) + setShowPricingModal() + else + setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.BILLING }) + }, [isFreePlan, setShowAccountSettingModal, setShowPricingModal]) + const { isPending, mutate: downloadCompliance } = useMutation({ - mutationKey: ['downloadCompliance', docName], + mutationKey: ['downloadCompliance', doc_name], mutationFn: async () => { try { - const ret = await getDocDownloadUrl(docName) + const ret = await getDocDownloadUrl(doc_name) downloadUrl({ url: ret.url }) Toast.notify({ type: 'success', @@ -144,7 +63,6 @@ function ComplianceDocRowItem({ } }, }) - const whichPlanCanDownloadCompliance = { [DocName.SOC2_Type_I]: [Plan.professional, Plan.team], [DocName.SOC2_Type_II]: [Plan.team], @@ -152,86 +70,118 @@ function ComplianceDocRowItem({ [DocName.GDPR]: [Plan.team, Plan.professional, Plan.sandbox], } - const isCurrentPlanCanDownload = whichPlanCanDownloadCompliance[docName].includes(plan.type) - - const handleSelect = useCallback(() => { - if (isCurrentPlanCanDownload) { - if (!isPending) - downloadCompliance() - return - } - - if (isFreePlan) - setShowPricingModal() - else - setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.BILLING }) - }, [downloadCompliance, isCurrentPlanCanDownload, isFreePlan, isPending, setShowAccountSettingModal, setShowPricingModal]) - + const isCurrentPlanCanDownload = whichPlanCanDownloadCompliance[doc_name].includes(plan.type) + const handleDownloadClick = useCallback((e: MouseEvent) => { + e.preventDefault() + downloadCompliance() + }, [downloadCompliance]) + if (isCurrentPlanCanDownload) { + return ( + + ) + } const upgradeTooltip: Record = { [Plan.sandbox]: t('compliance.sandboxUpgradeTooltip', { ns: 'common' }), [Plan.professional]: t('compliance.professionalUpgradeTooltip', { ns: 'common' }), [Plan.team]: '', [Plan.enterprise]: '', } - return ( - - {icon} -
{label}
- -
+ + + +
+ + {t('upgradeBtn.encourageShort', { ns: 'billing' })} + +
+
+
) } -// Submenu-only: this component must be rendered within an existing DropdownMenu root. export default function Compliance() { + const itemClassName = ` + flex items-center w-full h-10 pl-1 pr-2 py-1 text-text-secondary system-md-regular + rounded-lg hover:bg-state-base-hover gap-1 +` const { t } = useTranslation() return ( - - - } - /> - - - - } - label={t('compliance.soc2Type1', { ns: 'common' })} - docName={DocName.SOC2_Type_I} - /> - } - label={t('compliance.soc2Type2', { ns: 'common' })} - docName={DocName.SOC2_Type_II} - /> - } - label={t('compliance.iso27001', { ns: 'common' })} - docName={DocName.ISO_27001} - /> - } - label={t('compliance.gdpr', { ns: 'common' })} - docName={DocName.GDPR} - /> - - - + + { + ({ open }) => ( + <> + + +
{t('userProfile.compliance', { ns: 'common' })}
+ +
+ + +
+ +
+ +
{t('compliance.soc2Type1', { ns: 'common' })}
+ +
+
+ +
+ +
{t('compliance.soc2Type2', { ns: 'common' })}
+ +
+
+ +
+ +
{t('compliance.iso27001', { ns: 'common' })}
+ +
+
+ +
+ +
{t('compliance.gdpr', { ns: 'common' })}
+ +
+
+
+
+
+ + ) + } +
) } diff --git a/web/app/components/header/account-dropdown/index.spec.tsx b/web/app/components/header/account-dropdown/index.spec.tsx index 65399af36d..a954351267 100644 --- a/web/app/components/header/account-dropdown/index.spec.tsx +++ b/web/app/components/header/account-dropdown/index.spec.tsx @@ -65,7 +65,6 @@ vi.mock('@/context/i18n', () => ({ const { mockConfig, mockEnv } = vi.hoisted(() => ({ mockConfig: { IS_CLOUD_EDITION: false, - ZENDESK_WIDGET_KEY: '', }, mockEnv: { env: { @@ -75,7 +74,6 @@ const { mockConfig, mockEnv } = vi.hoisted(() => ({ })) vi.mock('@/config', () => ({ get IS_CLOUD_EDITION() { return mockConfig.IS_CLOUD_EDITION }, - get ZENDESK_WIDGET_KEY() { return mockConfig.ZENDESK_WIDGET_KEY }, IS_DEV: false, IS_CE_EDITION: false, })) diff --git a/web/app/components/header/account-dropdown/index.tsx b/web/app/components/header/account-dropdown/index.tsx index d682d0ccb8..c61ffb4a7a 100644 --- a/web/app/components/header/account-dropdown/index.tsx +++ b/web/app/components/header/account-dropdown/index.tsx @@ -1,15 +1,26 @@ 'use client' - -import type { MouseEventHandler, ReactNode } from 'react' +import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react' +import { + RiAccountCircleLine, + RiArrowRightUpLine, + RiBookOpenLine, + RiGithubLine, + RiGraduationCapFill, + RiInformation2Line, + RiLogoutBoxRLine, + RiMap2Line, + RiSettings3Line, + RiStarLine, + RiTShirt2Line, +} from '@remixicon/react' import Link from 'next/link' import { useRouter } from 'next/navigation' -import { useState } from 'react' +import { Fragment, useState } from 'react' import { useTranslation } from 'react-i18next' import { resetUser } from '@/app/components/base/amplitude/utils' import Avatar from '@/app/components/base/avatar' import PremiumBadge from '@/app/components/base/premium-badge' import ThemeSwitcher from '@/app/components/base/theme-switcher' -import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/app/components/base/ui/dropdown-menu' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' import { IS_CLOUD_EDITION } from '@/config' import { useAppContext } from '@/context/app-context' @@ -26,117 +37,13 @@ import Indicator from '../indicator' import Compliance from './compliance' import Support from './support' -const menuItemClassName = '!mx-0 !h-8 !rounded-lg !px-3 data-[highlighted]:!bg-state-base-hover' -const menuStaticRowClassName = 'flex h-8 w-full items-center rounded-lg px-3 text-text-secondary system-md-regular' -const menuLabelClassName = 'grow px-1 text-text-secondary system-md-regular' -const menuLeadingIconClassName = 'size-4 shrink-0 text-text-tertiary' -const menuTrailingIconClassName = 'size-[14px] shrink-0 text-text-tertiary' - -type AccountMenuItemContentProps = { - iconClassName: string - label: ReactNode - trailing?: ReactNode -} - -function AccountMenuItemContent({ - iconClassName, - label, - trailing, -}: AccountMenuItemContentProps) { - return ( - <> - -
{label}
- {trailing} - - ) -} - -type AccountMenuRouteItemProps = { - href: string - iconClassName: string - label: ReactNode - trailing?: ReactNode -} - -function AccountMenuRouteItem({ - href, - iconClassName, - label, - trailing, -}: AccountMenuRouteItemProps) { - return ( - } - > - - - ) -} - -type AccountMenuExternalItemProps = { - href: string - iconClassName: string - label: ReactNode - trailing?: ReactNode -} - -function AccountMenuExternalItem({ - href, - iconClassName, - label, - trailing, -}: AccountMenuExternalItemProps) { - return ( - } - > - - - ) -} - -type AccountMenuActionItemProps = { - iconClassName: string - label: ReactNode - onClick?: MouseEventHandler - trailing?: ReactNode -} - -function AccountMenuActionItem({ - iconClassName, - label, - onClick, - trailing, -}: AccountMenuActionItemProps) { - return ( - - - - ) -} - -function ExternalLinkIndicator() { - return -} - -type AccountMenuSectionProps = { - children: ReactNode -} - -function AccountMenuSection({ children }: AccountMenuSectionProps) { - return {children} -} - export default function AppSelector() { + const itemClassName = ` + flex items-center w-full h-8 pl-3 pr-2 text-text-secondary system-md-regular + rounded-lg hover:bg-state-base-hover cursor-pointer gap-1 + ` const router = useRouter() const [aboutVisible, setAboutVisible] = useState(false) - const [isAccountMenuOpen, setIsAccountMenuOpen] = useState(false) const { systemFeatures } = useGlobalPublicStore() const { t } = useTranslation() @@ -162,116 +69,160 @@ export default function AppSelector() { return (
- - - - - - -
-
-
- {userProfile.name} - {isEducationAccount && ( - - - EDU - - )} -
-
{userProfile.email}
-
- -
- } - /> - setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.MEMBERS })} - /> -
- - {!systemFeatures.branding.enabled && ( + + { + ({ open, close }) => ( <> - - } - /> - setIsAccountMenuOpen(false)} /> - {IS_CLOUD_EDITION && isCurrentWorkspaceOwner && } - - - - } - /> - - - -
- )} - /> - { - env.NEXT_PUBLIC_SITE_ABOUT !== 'hide' && ( - { - setAboutVisible(true) - setIsAccountMenuOpen(false) - }} - trailing={( -
-
{langGeniusVersionInfo.current_version}
- + + + + + +
+ +
+
+
+ {userProfile.name} + {isEducationAccount && ( + + + EDU + + )} +
+
{userProfile.email}
- )} - /> - ) - } - - + +
+
+ + + +
{t('account.account', { ns: 'common' })}
+ + +
+ +
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.MEMBERS })} + > + +
{t('userProfile.settings', { ns: 'common' })}
+
+
+
+ {!systemFeatures.branding.enabled && ( + <> +
+ + + +
{t('userProfile.helpCenter', { ns: 'common' })}
+ + +
+ + {IS_CLOUD_EDITION && isCurrentWorkspaceOwner && } +
+
+ + + +
{t('userProfile.roadmap', { ns: 'common' })}
+ + +
+ + + +
{t('userProfile.github', { ns: 'common' })}
+
+ + +
+ +
+ { + env.NEXT_PUBLIC_SITE_ABOUT !== 'hide' && ( + +
setAboutVisible(true)} + > + +
{t('userProfile.about', { ns: 'common' })}
+
+
{langGeniusVersionInfo.current_version}
+ +
+
+
+ ) + } +
+ + )} + +
+
+ +
{t('theme.theme', { ns: 'common' })}
+ +
+
+
+ +
handleLogout()}> +
+ +
{t('userProfile.logout', { ns: 'common' })}
+
+
+
+
+
- )} - -
- } - /> -
-
- - - { - void handleLogout() - }} - /> - - - + ) + } + { aboutVisible && setAboutVisible(false)} langGeniusVersionInfo={langGeniusVersionInfo} /> } diff --git a/web/app/components/header/account-dropdown/support.spec.tsx b/web/app/components/header/account-dropdown/support.spec.tsx index 90bcb9f3ec..b30a290ea5 100644 --- a/web/app/components/header/account-dropdown/support.spec.tsx +++ b/web/app/components/header/account-dropdown/support.spec.tsx @@ -1,7 +1,6 @@ import type { AppContextValue } from '@/context/app-context' import { fireEvent, render, screen } from '@testing-library/react' -import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/app/components/base/ui/dropdown-menu' import { Plan } from '@/app/components/billing/type' import { useAppContext } from '@/context/app-context' import { baseProviderContextValue, useProviderContext } from '@/context/provider-context' @@ -94,21 +93,10 @@ describe('Support', () => { }) }) - const renderSupport = () => { - return render( - {}}> - open - - - - , - ) - } - describe('Rendering', () => { it('should render support menu trigger', () => { // Act - renderSupport() + render() // Assert expect(screen.getByText('common.userProfile.support')).toBeInTheDocument() @@ -116,8 +104,8 @@ describe('Support', () => { it('should show forum and community links when opened', () => { // Act - renderSupport() - fireEvent.click(screen.getByText('common.userProfile.support')) + render() + fireEvent.click(screen.getByRole('button')) // Assert expect(screen.getByText('common.userProfile.forum')).toBeInTheDocument() @@ -128,8 +116,8 @@ describe('Support', () => { describe('Plan-based Channels', () => { it('should show "Contact Us" when ZENDESK_WIDGET_KEY is present', () => { // Act - renderSupport() - fireEvent.click(screen.getByText('common.userProfile.support')) + render() + fireEvent.click(screen.getByRole('button')) // Assert expect(screen.getByText('common.userProfile.contactUs')).toBeInTheDocument() @@ -146,8 +134,8 @@ describe('Support', () => { }) // Act - renderSupport() - fireEvent.click(screen.getByText('common.userProfile.support')) + render() + fireEvent.click(screen.getByRole('button')) // Assert expect(screen.queryByText('common.userProfile.contactUs')).not.toBeInTheDocument() @@ -159,8 +147,8 @@ describe('Support', () => { mockZendeskKey.value = '' // Act - renderSupport() - fireEvent.click(screen.getByText('common.userProfile.support')) + render() + fireEvent.click(screen.getByRole('button')) // Assert expect(screen.getByText('common.userProfile.emailSupport')).toBeInTheDocument() @@ -171,8 +159,8 @@ describe('Support', () => { describe('Interactions and Links', () => { it('should call toggleZendeskWindow and closeAccountDropdown when "Contact Us" is clicked', () => { // Act - renderSupport() - fireEvent.click(screen.getByText('common.userProfile.support')) + render() + fireEvent.click(screen.getByRole('button')) fireEvent.click(screen.getByText('common.userProfile.contactUs')) // Assert @@ -182,8 +170,8 @@ describe('Support', () => { it('should have correct forum and community links', () => { // Act - renderSupport() - fireEvent.click(screen.getByText('common.userProfile.support')) + render() + fireEvent.click(screen.getByRole('button')) // Assert const forumLink = screen.getByText('common.userProfile.forum').closest('a') diff --git a/web/app/components/header/account-dropdown/support.tsx b/web/app/components/header/account-dropdown/support.tsx index d13ca5335e..566a9ff56f 100644 --- a/web/app/components/header/account-dropdown/support.tsx +++ b/web/app/components/header/account-dropdown/support.tsx @@ -1,6 +1,8 @@ -import type { ReactNode } from 'react' +import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react' +import { RiArrowRightSLine, RiArrowRightUpLine, RiChatSmile2Line, RiDiscordLine, RiDiscussLine, RiMailSendLine, RiQuestionLine } from '@remixicon/react' +import Link from 'next/link' +import { Fragment } from 'react' import { useTranslation } from 'react-i18next' -import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger } from '@/app/components/base/ui/dropdown-menu' import { toggleZendeskWindow } from '@/app/components/base/zendesk/utils' import { Plan } from '@/app/components/billing/type' import { ZENDESK_WIDGET_KEY } from '@/config' @@ -9,109 +11,109 @@ import { useProviderContext } from '@/context/provider-context' import { cn } from '@/utils/classnames' import { mailToSupport } from '../utils/util' -const submenuTriggerClassName = '!mx-0 !h-8 !rounded-lg !px-3 data-[highlighted]:!bg-state-base-hover' -const submenuItemClassName = '!mx-0 !h-8 !rounded-lg !px-3 data-[highlighted]:!bg-state-base-hover' -const menuLabelClassName = 'grow px-1 text-text-secondary system-md-regular' -const menuLeadingIconClassName = 'size-4 shrink-0 text-text-tertiary' -const menuTrailingIconClassName = 'size-[14px] shrink-0 text-text-tertiary' - type SupportProps = { closeAccountDropdown: () => void } -type SupportMenuItemContentProps = { - iconClassName: string - label: ReactNode - trailing?: ReactNode -} - -function SupportMenuItemContent({ - iconClassName, - label, - trailing, -}: SupportMenuItemContentProps) { - return ( - <> - -
{label}
- {trailing} - - ) -} - -function SupportExternalLinkIndicator() { - return -} - -// Submenu-only: this component must be rendered within an existing DropdownMenu root. export default function Support({ closeAccountDropdown }: SupportProps) { + const itemClassName = ` + flex items-center w-full h-9 pl-3 pr-2 text-text-secondary system-md-regular + rounded-lg hover:bg-state-base-hover cursor-pointer gap-1 +` const { t } = useTranslation() const { plan } = useProviderContext() const { userProfile, langGeniusVersionInfo } = useAppContext() const hasDedicatedChannel = plan.type !== Plan.sandbox - const hasZendeskWidget = !!ZENDESK_WIDGET_KEY?.trim() return ( - - - } - /> - - - - {hasDedicatedChannel && hasZendeskWidget && ( - { - toggleZendeskWindow(true) - closeAccountDropdown() - }} + + { + ({ open }) => ( + <> + - - - )} - {hasDedicatedChannel && !hasZendeskWidget && ( - } + +
{t('userProfile.support', { ns: 'common' })}
+ +
+ - } - /> - - )} - } - > - } - /> - - } - > - } - /> - - - - + +
+ {hasDedicatedChannel && ( + + {ZENDESK_WIDGET_KEY && ZENDESK_WIDGET_KEY.trim() !== '' + ? ( + + ) + : ( + + +
{t('userProfile.emailSupport', { ns: 'common' })}
+ +
+ )} +
+ )} + + + +
{t('userProfile.forum', { ns: 'common' })}
+ + +
+ + + +
{t('userProfile.community', { ns: 'common' })}
+ + +
+
+
+
+ + ) + } +
) }