mirror of
https://github.com/langgenius/dify.git
synced 2026-04-24 04:45:51 +08:00
chore(web): keep phase0 branch focused on base ui primitives
This commit is contained in:
@ -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(
|
||||
<DropdownMenu open={true} onOpenChange={() => {}}>
|
||||
<DropdownMenuTrigger>open</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<Compliance />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
)
|
||||
}
|
||||
|
||||
// Wrapper for tests that need the menu open
|
||||
const openMenuAndRender = () => {
|
||||
renderCompliance()
|
||||
fireEvent.click(screen.getByText('common.userProfile.compliance'))
|
||||
renderWithQueryClient(<Compliance />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
}
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render compliance menu trigger', () => {
|
||||
// Act
|
||||
renderCompliance()
|
||||
renderWithQueryClient(<Compliance />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('common.userProfile.compliance')).toBeInTheDocument()
|
||||
|
||||
@ -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 (
|
||||
<>
|
||||
<span aria-hidden className={cn(menuLeadingIconClassName, iconClassName)} />
|
||||
<div className={menuLabelClassName}>{label}</div>
|
||||
{trailing}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type ComplianceDocActionVisualProps = {
|
||||
isCurrentPlanCanDownload: boolean
|
||||
isPending: boolean
|
||||
tooltipText: string
|
||||
downloadText: string
|
||||
upgradeText: string
|
||||
}
|
||||
|
||||
function ComplianceDocActionVisual({
|
||||
isCurrentPlanCanDownload,
|
||||
isPending,
|
||||
tooltipText,
|
||||
downloadText,
|
||||
upgradeText,
|
||||
}: ComplianceDocActionVisualProps) {
|
||||
if (isCurrentPlanCanDownload) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'btn btn-small btn-secondary pointer-events-none flex items-center gap-[1px]',
|
||||
isPending && 'btn-disabled',
|
||||
)}
|
||||
>
|
||||
<span className="i-ri-arrow-down-circle-line size-[14px] text-components-button-secondary-text-disabled" />
|
||||
<span className="px-[3px] text-components-button-secondary-text system-xs-medium">{downloadText}</span>
|
||||
{isPending && <Spinner loading={true} className="!ml-1 !h-3 !w-3 !border-2 !text-text-tertiary" />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const canShowUpgradeTooltip = tooltipText.length > 0
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
delay={0}
|
||||
disabled={!canShowUpgradeTooltip}
|
||||
render={(
|
||||
<PremiumBadge color="blue" allowHover={true}>
|
||||
<SparklesSoft className="flex h-3.5 w-3.5 items-center py-[1px] pl-[3px] text-components-premium-badge-indigo-text-stop-0" />
|
||||
<div className="px-1 system-xs-medium">
|
||||
{upgradeText}
|
||||
</div>
|
||||
</PremiumBadge>
|
||||
)}
|
||||
/>
|
||||
{canShowUpgradeTooltip && (
|
||||
<TooltipContent>
|
||||
{tooltipText}
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
type ComplianceDocRowItemProps = {
|
||||
icon: ReactNode
|
||||
label: ReactNode
|
||||
docName: DocName
|
||||
}
|
||||
|
||||
function ComplianceDocRowItem({
|
||||
icon,
|
||||
label,
|
||||
docName,
|
||||
}: ComplianceDocRowItemProps) {
|
||||
const UpgradeOrDownload: FC<UpgradeOrDownloadProps> = ({ 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<HTMLButtonElement>) => {
|
||||
e.preventDefault()
|
||||
downloadCompliance()
|
||||
}, [downloadCompliance])
|
||||
if (isCurrentPlanCanDownload) {
|
||||
return (
|
||||
<Button loading={isPending} disabled={isPending} size="small" variant="secondary" className="flex items-center gap-[1px]" onClick={handleDownloadClick}>
|
||||
<RiArrowDownCircleLine className="size-[14px] text-components-button-secondary-text-disabled" />
|
||||
<span className="px-[3px] text-components-button-secondary-text system-xs-medium">{t('operation.download', { ns: 'common' })}</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
const upgradeTooltip: Record<Plan, string> = {
|
||||
[Plan.sandbox]: t('compliance.sandboxUpgradeTooltip', { ns: 'common' }),
|
||||
[Plan.professional]: t('compliance.professionalUpgradeTooltip', { ns: 'common' }),
|
||||
[Plan.team]: '',
|
||||
[Plan.enterprise]: '',
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
className={cn(submenuItemClassName, 'justify-between')}
|
||||
closeOnClick={false}
|
||||
onClick={handleSelect}
|
||||
>
|
||||
{icon}
|
||||
<div className="grow truncate px-1 text-text-secondary system-md-regular">{label}</div>
|
||||
<ComplianceDocActionVisual
|
||||
isCurrentPlanCanDownload={isCurrentPlanCanDownload}
|
||||
isPending={isPending}
|
||||
tooltipText={upgradeTooltip[plan.type]}
|
||||
downloadText={t('operation.download', { ns: 'common' })}
|
||||
upgradeText={t('upgradeBtn.encourageShort', { ns: 'billing' })}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
<Tooltip asChild={false} popupContent={upgradeTooltip[plan.type]}>
|
||||
<PremiumBadge color="blue" allowHover={true} onClick={handlePlanClick}>
|
||||
<SparklesSoft className="flex h-3.5 w-3.5 items-center py-[1px] pl-[3px] text-components-premium-badge-indigo-text-stop-0" />
|
||||
<div className="system-xs-medium">
|
||||
<span className="p-1">
|
||||
{t('upgradeBtn.encourageShort', { ns: 'billing' })}
|
||||
</span>
|
||||
</div>
|
||||
</PremiumBadge>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger className={cn(submenuTriggerClassName, 'justify-between')}>
|
||||
<ComplianceMenuItemContent
|
||||
iconClassName="i-ri-verified-badge-line"
|
||||
label={t('userProfile.compliance', { ns: 'common' })}
|
||||
trailing={<span aria-hidden className={cn('i-ri-arrow-right-s-line', menuTrailingIconClassName)} />}
|
||||
/>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent
|
||||
popupClassName="!w-[337px] !max-h-[70vh] !overflow-y-auto !divide-y !divide-divider-subtle !rounded-xl !bg-components-panel-bg-blur !py-0 !shadow-lg !backdrop-blur-sm"
|
||||
>
|
||||
<DropdownMenuGroup className="p-1">
|
||||
<ComplianceDocRowItem
|
||||
icon={<Soc2 aria-hidden className="size-7 shrink-0" />}
|
||||
label={t('compliance.soc2Type1', { ns: 'common' })}
|
||||
docName={DocName.SOC2_Type_I}
|
||||
/>
|
||||
<ComplianceDocRowItem
|
||||
icon={<Soc2 aria-hidden className="size-7 shrink-0" />}
|
||||
label={t('compliance.soc2Type2', { ns: 'common' })}
|
||||
docName={DocName.SOC2_Type_II}
|
||||
/>
|
||||
<ComplianceDocRowItem
|
||||
icon={<Iso aria-hidden className="size-7 shrink-0" />}
|
||||
label={t('compliance.iso27001', { ns: 'common' })}
|
||||
docName={DocName.ISO_27001}
|
||||
/>
|
||||
<ComplianceDocRowItem
|
||||
icon={<Gdpr aria-hidden className="size-7 shrink-0" />}
|
||||
label={t('compliance.gdpr', { ns: 'common' })}
|
||||
docName={DocName.GDPR}
|
||||
/>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<Menu as="div" className="relative h-full w-full">
|
||||
{
|
||||
({ open }) => (
|
||||
<>
|
||||
<MenuButton className={
|
||||
cn('group flex h-9 w-full items-center gap-1 rounded-lg py-2 pl-3 pr-2 hover:bg-state-base-hover', open && 'bg-state-base-hover')
|
||||
}
|
||||
>
|
||||
<RiVerifiedBadgeLine className="size-4 shrink-0 text-text-tertiary" />
|
||||
<div className="grow px-1 text-left text-text-secondary system-md-regular">{t('userProfile.compliance', { ns: 'common' })}</div>
|
||||
<RiArrowRightSLine className="size-[14px] shrink-0 text-text-tertiary" />
|
||||
</MenuButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<MenuItems
|
||||
className={cn(
|
||||
`absolute top-[1px] z-10 max-h-[70vh] w-[337px] origin-top-right -translate-x-full divide-y divide-divider-subtle overflow-y-scroll
|
||||
rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px] focus:outline-none
|
||||
`,
|
||||
)}
|
||||
>
|
||||
<div className="px-1 py-1">
|
||||
<MenuItem>
|
||||
<div
|
||||
className={cn(itemClassName, 'group justify-between', 'data-[active]:bg-state-base-hover')}
|
||||
>
|
||||
<Soc2 className="size-7 shrink-0" />
|
||||
<div className="grow truncate px-1 text-text-secondary system-md-regular">{t('compliance.soc2Type1', { ns: 'common' })}</div>
|
||||
<UpgradeOrDownload doc_name={DocName.SOC2_Type_I} />
|
||||
</div>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<div
|
||||
className={cn(itemClassName, 'group justify-between', 'data-[active]:bg-state-base-hover')}
|
||||
>
|
||||
<Soc2 className="size-7 shrink-0" />
|
||||
<div className="grow truncate px-1 text-text-secondary system-md-regular">{t('compliance.soc2Type2', { ns: 'common' })}</div>
|
||||
<UpgradeOrDownload doc_name={DocName.SOC2_Type_II} />
|
||||
</div>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<div
|
||||
className={cn(itemClassName, 'group justify-between', 'data-[active]:bg-state-base-hover')}
|
||||
>
|
||||
<Iso className="size-7 shrink-0" />
|
||||
<div className="grow truncate px-1 text-text-secondary system-md-regular">{t('compliance.iso27001', { ns: 'common' })}</div>
|
||||
<UpgradeOrDownload doc_name={DocName.ISO_27001} />
|
||||
</div>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<div
|
||||
className={cn(itemClassName, 'group justify-between', 'data-[active]:bg-state-base-hover')}
|
||||
>
|
||||
<Gdpr className="size-7 shrink-0" />
|
||||
<div className="grow truncate px-1 text-text-secondary system-md-regular">{t('compliance.gdpr', { ns: 'common' })}</div>
|
||||
<UpgradeOrDownload doc_name={DocName.GDPR} />
|
||||
</div>
|
||||
</MenuItem>
|
||||
</div>
|
||||
</MenuItems>
|
||||
</Transition>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}))
|
||||
|
||||
@ -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 (
|
||||
<>
|
||||
<span aria-hidden className={cn(menuLeadingIconClassName, iconClassName)} />
|
||||
<div className={menuLabelClassName}>{label}</div>
|
||||
{trailing}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type AccountMenuRouteItemProps = {
|
||||
href: string
|
||||
iconClassName: string
|
||||
label: ReactNode
|
||||
trailing?: ReactNode
|
||||
}
|
||||
|
||||
function AccountMenuRouteItem({
|
||||
href,
|
||||
iconClassName,
|
||||
label,
|
||||
trailing,
|
||||
}: AccountMenuRouteItemProps) {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
className={cn(menuItemClassName, 'justify-between')}
|
||||
render={<Link href={href} />}
|
||||
>
|
||||
<AccountMenuItemContent iconClassName={iconClassName} label={label} trailing={trailing} />
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
type AccountMenuExternalItemProps = {
|
||||
href: string
|
||||
iconClassName: string
|
||||
label: ReactNode
|
||||
trailing?: ReactNode
|
||||
}
|
||||
|
||||
function AccountMenuExternalItem({
|
||||
href,
|
||||
iconClassName,
|
||||
label,
|
||||
trailing,
|
||||
}: AccountMenuExternalItemProps) {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
className={cn(menuItemClassName, 'justify-between')}
|
||||
render={<a href={href} rel="noopener noreferrer" target="_blank" />}
|
||||
>
|
||||
<AccountMenuItemContent iconClassName={iconClassName} label={label} trailing={trailing} />
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
type AccountMenuActionItemProps = {
|
||||
iconClassName: string
|
||||
label: ReactNode
|
||||
onClick?: MouseEventHandler<HTMLElement>
|
||||
trailing?: ReactNode
|
||||
}
|
||||
|
||||
function AccountMenuActionItem({
|
||||
iconClassName,
|
||||
label,
|
||||
onClick,
|
||||
trailing,
|
||||
}: AccountMenuActionItemProps) {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
className={cn(menuItemClassName, 'justify-between')}
|
||||
onClick={onClick}
|
||||
>
|
||||
<AccountMenuItemContent iconClassName={iconClassName} label={label} trailing={trailing} />
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
function ExternalLinkIndicator() {
|
||||
return <span aria-hidden className={cn('i-ri-arrow-right-up-line', menuTrailingIconClassName)} />
|
||||
}
|
||||
|
||||
type AccountMenuSectionProps = {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
function AccountMenuSection({ children }: AccountMenuSectionProps) {
|
||||
return <DropdownMenuGroup className="p-1">{children}</DropdownMenuGroup>
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="">
|
||||
<DropdownMenu open={isAccountMenuOpen} onOpenChange={setIsAccountMenuOpen}>
|
||||
<DropdownMenuTrigger className={cn('inline-flex items-center rounded-[20px] p-0.5 hover:bg-background-default-dodge', isAccountMenuOpen && 'bg-background-default-dodge')}>
|
||||
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={36} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
popupClassName="!mt-1.5 !w-60 !max-w-80 !rounded-xl !bg-components-panel-bg-blur !py-0 !shadow-lg !backdrop-blur-sm"
|
||||
>
|
||||
<DropdownMenuGroup className="px-1 py-1">
|
||||
<div className="flex flex-nowrap items-center py-2 pl-3 pr-2">
|
||||
<div className="grow">
|
||||
<div className="break-all text-text-primary system-md-medium">
|
||||
{userProfile.name}
|
||||
{isEducationAccount && (
|
||||
<PremiumBadge size="s" color="blue" className="ml-1 !px-2">
|
||||
<span aria-hidden className="i-ri-graduation-cap-fill mr-1 h-3 w-3" />
|
||||
<span className="system-2xs-medium">EDU</span>
|
||||
</PremiumBadge>
|
||||
)}
|
||||
</div>
|
||||
<div className="break-all text-text-tertiary system-xs-regular">{userProfile.email}</div>
|
||||
</div>
|
||||
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={36} />
|
||||
</div>
|
||||
<AccountMenuRouteItem
|
||||
href="/account"
|
||||
iconClassName="i-ri-account-circle-line"
|
||||
label={t('account.account', { ns: 'common' })}
|
||||
trailing={<ExternalLinkIndicator />}
|
||||
/>
|
||||
<AccountMenuActionItem
|
||||
iconClassName="i-ri-settings-3-line"
|
||||
label={t('userProfile.settings', { ns: 'common' })}
|
||||
onClick={() => setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.MEMBERS })}
|
||||
/>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator className="!my-0 bg-divider-subtle" />
|
||||
{!systemFeatures.branding.enabled && (
|
||||
<Menu as="div" className="relative inline-block text-left">
|
||||
{
|
||||
({ open, close }) => (
|
||||
<>
|
||||
<AccountMenuSection>
|
||||
<AccountMenuExternalItem
|
||||
href={docLink('/use-dify/getting-started/introduction')}
|
||||
iconClassName="i-ri-book-open-line"
|
||||
label={t('userProfile.helpCenter', { ns: 'common' })}
|
||||
trailing={<ExternalLinkIndicator />}
|
||||
/>
|
||||
<Support closeAccountDropdown={() => setIsAccountMenuOpen(false)} />
|
||||
{IS_CLOUD_EDITION && isCurrentWorkspaceOwner && <Compliance />}
|
||||
</AccountMenuSection>
|
||||
<DropdownMenuSeparator className="!my-0 bg-divider-subtle" />
|
||||
<AccountMenuSection>
|
||||
<AccountMenuExternalItem
|
||||
href="https://roadmap.dify.ai"
|
||||
iconClassName="i-ri-map-2-line"
|
||||
label={t('userProfile.roadmap', { ns: 'common' })}
|
||||
trailing={<ExternalLinkIndicator />}
|
||||
/>
|
||||
<AccountMenuExternalItem
|
||||
href="https://github.com/langgenius/dify"
|
||||
iconClassName="i-ri-github-line"
|
||||
label={t('userProfile.github', { ns: 'common' })}
|
||||
trailing={(
|
||||
<div className="flex items-center gap-0.5 rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-[5px] py-[3px]">
|
||||
<span aria-hidden className="i-ri-star-line size-3 shrink-0 text-text-tertiary" />
|
||||
<GithubStar className="text-text-tertiary system-2xs-medium-uppercase" />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{
|
||||
env.NEXT_PUBLIC_SITE_ABOUT !== 'hide' && (
|
||||
<AccountMenuActionItem
|
||||
iconClassName="i-ri-information-2-line"
|
||||
label={t('userProfile.about', { ns: 'common' })}
|
||||
onClick={() => {
|
||||
setAboutVisible(true)
|
||||
setIsAccountMenuOpen(false)
|
||||
}}
|
||||
trailing={(
|
||||
<div className="flex shrink-0 items-center">
|
||||
<div className="mr-2 text-text-tertiary system-xs-regular">{langGeniusVersionInfo.current_version}</div>
|
||||
<Indicator color={langGeniusVersionInfo.current_version === langGeniusVersionInfo.latest_version ? 'green' : 'orange'} />
|
||||
<MenuButton className={cn('inline-flex items-center rounded-[20px] p-0.5 hover:bg-background-default-dodge', open && 'bg-background-default-dodge')}>
|
||||
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={36} />
|
||||
</MenuButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<MenuItems
|
||||
className="
|
||||
absolute right-0 mt-1.5 w-60 max-w-80
|
||||
origin-top-right divide-y divide-divider-subtle rounded-xl bg-components-panel-bg-blur shadow-lg
|
||||
backdrop-blur-sm focus:outline-none
|
||||
"
|
||||
>
|
||||
<div className="px-1 py-1">
|
||||
<MenuItem disabled>
|
||||
<div className="flex flex-nowrap items-center py-2 pl-3 pr-2">
|
||||
<div className="grow">
|
||||
<div className="break-all text-text-primary system-md-medium">
|
||||
{userProfile.name}
|
||||
{isEducationAccount && (
|
||||
<PremiumBadge size="s" color="blue" className="ml-1 !px-2">
|
||||
<RiGraduationCapFill className="mr-1 h-3 w-3" />
|
||||
<span className="system-2xs-medium">EDU</span>
|
||||
</PremiumBadge>
|
||||
)}
|
||||
</div>
|
||||
<div className="break-all text-text-tertiary system-xs-regular">{userProfile.email}</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</AccountMenuSection>
|
||||
<DropdownMenuSeparator className="!my-0 bg-divider-subtle" />
|
||||
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={36} />
|
||||
</div>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<Link
|
||||
className={cn(itemClassName, 'group', 'data-[active]:bg-state-base-hover')}
|
||||
href="/account"
|
||||
target="_self"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<RiAccountCircleLine className="size-4 shrink-0 text-text-tertiary" />
|
||||
<div className="grow px-1 text-text-secondary system-md-regular">{t('account.account', { ns: 'common' })}</div>
|
||||
<RiArrowRightUpLine className="size-[14px] shrink-0 text-text-tertiary" />
|
||||
</Link>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<div
|
||||
className={cn(itemClassName, 'data-[active]:bg-state-base-hover')}
|
||||
onClick={() => setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.MEMBERS })}
|
||||
>
|
||||
<RiSettings3Line className="size-4 shrink-0 text-text-tertiary" />
|
||||
<div className="grow px-1 text-text-secondary system-md-regular">{t('userProfile.settings', { ns: 'common' })}</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
</div>
|
||||
{!systemFeatures.branding.enabled && (
|
||||
<>
|
||||
<div className="p-1">
|
||||
<MenuItem>
|
||||
<Link
|
||||
className={cn(itemClassName, 'group justify-between', 'data-[active]:bg-state-base-hover')}
|
||||
href={docLink('/use-dify/getting-started/introduction')}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<RiBookOpenLine className="size-4 shrink-0 text-text-tertiary" />
|
||||
<div className="grow px-1 text-text-secondary system-md-regular">{t('userProfile.helpCenter', { ns: 'common' })}</div>
|
||||
<RiArrowRightUpLine className="size-[14px] shrink-0 text-text-tertiary" />
|
||||
</Link>
|
||||
</MenuItem>
|
||||
<Support closeAccountDropdown={close} />
|
||||
{IS_CLOUD_EDITION && isCurrentWorkspaceOwner && <Compliance />}
|
||||
</div>
|
||||
<div className="p-1">
|
||||
<MenuItem>
|
||||
<Link
|
||||
className={cn(itemClassName, 'group justify-between', 'data-[active]:bg-state-base-hover')}
|
||||
href="https://roadmap.dify.ai"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<RiMap2Line className="size-4 shrink-0 text-text-tertiary" />
|
||||
<div className="grow px-1 text-text-secondary system-md-regular">{t('userProfile.roadmap', { ns: 'common' })}</div>
|
||||
<RiArrowRightUpLine className="size-[14px] shrink-0 text-text-tertiary" />
|
||||
</Link>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<Link
|
||||
className={cn(itemClassName, 'group justify-between', 'data-[active]:bg-state-base-hover')}
|
||||
href="https://github.com/langgenius/dify"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<RiGithubLine className="size-4 shrink-0 text-text-tertiary" />
|
||||
<div className="grow px-1 text-text-secondary system-md-regular">{t('userProfile.github', { ns: 'common' })}</div>
|
||||
<div className="flex items-center gap-0.5 rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-[5px] py-[3px]">
|
||||
<RiStarLine className="size-3 shrink-0 text-text-tertiary" />
|
||||
<GithubStar className="text-text-tertiary system-2xs-medium-uppercase" />
|
||||
</div>
|
||||
</Link>
|
||||
</MenuItem>
|
||||
{
|
||||
env.NEXT_PUBLIC_SITE_ABOUT !== 'hide' && (
|
||||
<MenuItem>
|
||||
<div
|
||||
className={cn(itemClassName, 'justify-between', 'data-[active]:bg-state-base-hover')}
|
||||
onClick={() => setAboutVisible(true)}
|
||||
>
|
||||
<RiInformation2Line className="size-4 shrink-0 text-text-tertiary" />
|
||||
<div className="grow px-1 text-text-secondary system-md-regular">{t('userProfile.about', { ns: 'common' })}</div>
|
||||
<div className="flex shrink-0 items-center">
|
||||
<div className="mr-2 text-text-tertiary system-xs-regular">{langGeniusVersionInfo.current_version}</div>
|
||||
<Indicator color={langGeniusVersionInfo.current_version === langGeniusVersionInfo.latest_version ? 'green' : 'orange'} />
|
||||
</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<MenuItem disabled>
|
||||
<div className="p-1">
|
||||
<div className={cn(itemClassName, 'hover:bg-transparent')}>
|
||||
<RiTShirt2Line className="size-4 shrink-0 text-text-tertiary" />
|
||||
<div className="grow px-1 text-text-secondary system-md-regular">{t('theme.theme', { ns: 'common' })}</div>
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<div className="p-1" onClick={() => handleLogout()}>
|
||||
<div
|
||||
className={cn(itemClassName, 'group justify-between', 'data-[active]:bg-state-base-hover')}
|
||||
>
|
||||
<RiLogoutBoxRLine className="size-4 shrink-0 text-text-tertiary" />
|
||||
<div className="grow px-1 text-text-secondary system-md-regular">{t('userProfile.logout', { ns: 'common' })}</div>
|
||||
</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
<AccountMenuSection>
|
||||
<div className={cn(menuStaticRowClassName, 'hover:bg-transparent')}>
|
||||
<AccountMenuItemContent
|
||||
iconClassName="i-ri-t-shirt-2-line"
|
||||
label={t('theme.theme', { ns: 'common' })}
|
||||
trailing={<ThemeSwitcher />}
|
||||
/>
|
||||
</div>
|
||||
</AccountMenuSection>
|
||||
<DropdownMenuSeparator className="!my-0 bg-divider-subtle" />
|
||||
<AccountMenuSection>
|
||||
<AccountMenuActionItem
|
||||
iconClassName="i-ri-logout-box-r-line"
|
||||
label={t('userProfile.logout', { ns: 'common' })}
|
||||
onClick={() => {
|
||||
void handleLogout()
|
||||
}}
|
||||
/>
|
||||
</AccountMenuSection>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
</Menu>
|
||||
{
|
||||
aboutVisible && <AccountAbout onCancel={() => setAboutVisible(false)} langGeniusVersionInfo={langGeniusVersionInfo} />
|
||||
}
|
||||
|
||||
@ -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(
|
||||
<DropdownMenu open={true} onOpenChange={() => {}}>
|
||||
<DropdownMenuTrigger>open</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<Support closeAccountDropdown={mockCloseAccountDropdown} />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render support menu trigger', () => {
|
||||
// Act
|
||||
renderSupport()
|
||||
render(<Support closeAccountDropdown={mockCloseAccountDropdown} />)
|
||||
|
||||
// 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(<Support closeAccountDropdown={mockCloseAccountDropdown} />)
|
||||
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(<Support closeAccountDropdown={mockCloseAccountDropdown} />)
|
||||
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(<Support closeAccountDropdown={mockCloseAccountDropdown} />)
|
||||
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(<Support closeAccountDropdown={mockCloseAccountDropdown} />)
|
||||
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(<Support closeAccountDropdown={mockCloseAccountDropdown} />)
|
||||
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(<Support closeAccountDropdown={mockCloseAccountDropdown} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
// Assert
|
||||
const forumLink = screen.getByText('common.userProfile.forum').closest('a')
|
||||
|
||||
@ -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 (
|
||||
<>
|
||||
<span aria-hidden className={cn(menuLeadingIconClassName, iconClassName)} />
|
||||
<div className={menuLabelClassName}>{label}</div>
|
||||
{trailing}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function SupportExternalLinkIndicator() {
|
||||
return <span aria-hidden className={cn('i-ri-arrow-right-up-line', menuTrailingIconClassName)} />
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger className={cn(submenuTriggerClassName, 'justify-between')}>
|
||||
<SupportMenuItemContent
|
||||
iconClassName="i-ri-question-line"
|
||||
label={t('userProfile.support', { ns: 'common' })}
|
||||
trailing={<span aria-hidden className={cn('i-ri-arrow-right-s-line', menuTrailingIconClassName)} />}
|
||||
/>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent
|
||||
popupClassName="!w-[216px] !max-h-[70vh] !overflow-y-auto !divide-y !divide-divider-subtle !rounded-xl !bg-components-panel-bg-blur !py-0 !shadow-lg !backdrop-blur-sm"
|
||||
>
|
||||
<DropdownMenuGroup className="p-1">
|
||||
{hasDedicatedChannel && hasZendeskWidget && (
|
||||
<DropdownMenuItem
|
||||
className={cn(submenuItemClassName, 'justify-between')}
|
||||
onClick={() => {
|
||||
toggleZendeskWindow(true)
|
||||
closeAccountDropdown()
|
||||
}}
|
||||
<Menu as="div" className="relative h-full w-full">
|
||||
{
|
||||
({ open }) => (
|
||||
<>
|
||||
<MenuButton className={
|
||||
cn('group flex h-9 w-full items-center gap-1 rounded-lg py-2 pl-3 pr-2 hover:bg-state-base-hover', open && 'bg-state-base-hover')
|
||||
}
|
||||
>
|
||||
<SupportMenuItemContent
|
||||
iconClassName="i-ri-chat-smile-2-line"
|
||||
label={t('userProfile.contactUs', { ns: 'common' })}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{hasDedicatedChannel && !hasZendeskWidget && (
|
||||
<DropdownMenuItem
|
||||
className={cn(submenuItemClassName, 'justify-between')}
|
||||
render={<a href={mailToSupport(userProfile.email, plan.type, langGeniusVersionInfo?.current_version)} rel="noopener noreferrer" target="_blank" />}
|
||||
<RiQuestionLine className="size-4 shrink-0 text-text-tertiary" />
|
||||
<div className="grow px-1 text-left text-text-secondary system-md-regular">{t('userProfile.support', { ns: 'common' })}</div>
|
||||
<RiArrowRightSLine className="size-[14px] shrink-0 text-text-tertiary" />
|
||||
</MenuButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<SupportMenuItemContent
|
||||
iconClassName="i-ri-mail-send-line"
|
||||
label={t('userProfile.emailSupport', { ns: 'common' })}
|
||||
trailing={<SupportExternalLinkIndicator />}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className={cn(submenuItemClassName, 'justify-between')}
|
||||
render={<a href="https://forum.dify.ai/" rel="noopener noreferrer" target="_blank" />}
|
||||
>
|
||||
<SupportMenuItemContent
|
||||
iconClassName="i-ri-discuss-line"
|
||||
label={t('userProfile.forum', { ns: 'common' })}
|
||||
trailing={<SupportExternalLinkIndicator />}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className={cn(submenuItemClassName, 'justify-between')}
|
||||
render={<a href="https://discord.gg/5AEfbxcd9k" rel="noopener noreferrer" target="_blank" />}
|
||||
>
|
||||
<SupportMenuItemContent
|
||||
iconClassName="i-ri-discord-line"
|
||||
label={t('userProfile.community', { ns: 'common' })}
|
||||
trailing={<SupportExternalLinkIndicator />}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<MenuItems
|
||||
className={cn(
|
||||
`absolute top-[1px] z-10 max-h-[70vh] w-[216px] origin-top-right -translate-x-full divide-y divide-divider-subtle overflow-y-auto
|
||||
rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px] focus:outline-none
|
||||
`,
|
||||
)}
|
||||
>
|
||||
<div className="px-1 py-1">
|
||||
{hasDedicatedChannel && (
|
||||
<MenuItem>
|
||||
{ZENDESK_WIDGET_KEY && ZENDESK_WIDGET_KEY.trim() !== ''
|
||||
? (
|
||||
<button
|
||||
className={cn(itemClassName, 'group justify-between text-left data-[active]:bg-state-base-hover')}
|
||||
onClick={() => {
|
||||
toggleZendeskWindow(true)
|
||||
closeAccountDropdown()
|
||||
}}
|
||||
>
|
||||
<RiChatSmile2Line className="size-4 shrink-0 text-text-tertiary" />
|
||||
<div className="grow px-1 text-text-secondary system-md-regular">{t('userProfile.contactUs', { ns: 'common' })}</div>
|
||||
</button>
|
||||
)
|
||||
: (
|
||||
<a
|
||||
className={cn(itemClassName, 'group justify-between', 'data-[active]:bg-state-base-hover')}
|
||||
href={mailToSupport(userProfile.email, plan.type, langGeniusVersionInfo?.current_version)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<RiMailSendLine className="size-4 shrink-0 text-text-tertiary" />
|
||||
<div className="grow px-1 text-text-secondary system-md-regular">{t('userProfile.emailSupport', { ns: 'common' })}</div>
|
||||
<RiArrowRightUpLine className="size-[14px] shrink-0 text-text-tertiary" />
|
||||
</a>
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem>
|
||||
<Link
|
||||
className={cn(itemClassName, 'group justify-between', 'data-[active]:bg-state-base-hover')}
|
||||
href="https://forum.dify.ai/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<RiDiscussLine className="size-4 shrink-0 text-text-tertiary" />
|
||||
<div className="grow px-1 text-text-secondary system-md-regular">{t('userProfile.forum', { ns: 'common' })}</div>
|
||||
<RiArrowRightUpLine className="size-[14px] shrink-0 text-text-tertiary" />
|
||||
</Link>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<Link
|
||||
className={cn(itemClassName, 'group justify-between', 'data-[active]:bg-state-base-hover')}
|
||||
href="https://discord.gg/5AEfbxcd9k"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<RiDiscordLine className="size-4 shrink-0 text-text-tertiary" />
|
||||
<div className="grow px-1 text-text-secondary system-md-regular">{t('userProfile.community', { ns: 'common' })}</div>
|
||||
<RiArrowRightUpLine className="size-[14px] shrink-0 text-text-tertiary" />
|
||||
</Link>
|
||||
</MenuItem>
|
||||
</div>
|
||||
</MenuItems>
|
||||
</Transition>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user