fix: refactor account about dialog ownership (#39765)

This commit is contained in:
yyh 2026-07-29 20:33:26 +08:00 committed by GitHub
parent fc2f1f4f75
commit 81fd1da3ff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 280 additions and 966 deletions

View File

@ -1,114 +0,0 @@
import type { LangGeniusVersionInfo } from '@/context/app-context-types'
import { fireEvent, screen } from '@testing-library/react'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import AccountAbout from '../index'
describe('AccountAbout', () => {
const mockVersionInfo: LangGeniusVersionInfo = {
current_version: '0.6.0',
latest_version: '0.6.0',
release_notes: 'https://github.com/langgenius/dify/releases/tag/0.6.0',
version: '0.6.0',
release_date: '2024-01-01',
features: {
can_replace_logo: false,
model_load_balancing_enabled: false,
},
can_auto_update: false,
current_env: 'production',
}
const mockOnCancel = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render correctly with version information', () => {
renderWithConsoleQuery(
<AccountAbout langGeniusVersionInfo={mockVersionInfo} onCancel={mockOnCancel} />,
{
systemFeatures: { deployment_edition: 'CLOUD', branding: { enabled: false } },
},
)
expect(screen.getByText(/^Version/)).toBeInTheDocument()
expect(screen.getAllByText(/0.6.0/).length).toBeGreaterThan(0)
})
it('should render branding logo if enabled', () => {
renderWithConsoleQuery(
<AccountAbout langGeniusVersionInfo={mockVersionInfo} onCancel={mockOnCancel} />,
{
systemFeatures: {
deployment_edition: 'CLOUD',
branding: { enabled: true, workspace_logo: 'custom-logo.png' },
},
},
)
const img = screen.getByAltText('logo')
expect(img).toBeInTheDocument()
expect(img).toHaveAttribute('src', 'custom-logo.png')
})
})
describe('Version Logic', () => {
it('should show "Latest Available" when current version equals latest', () => {
renderWithConsoleQuery(
<AccountAbout langGeniusVersionInfo={mockVersionInfo} onCancel={mockOnCancel} />,
{ systemFeatures: { deployment_edition: 'CLOUD' } },
)
expect(screen.getByText(/about.latestAvailable/)).toBeInTheDocument()
})
it('should show "Now Available" when current version is behind', () => {
const behindVersionInfo = { ...mockVersionInfo, latest_version: '0.7.0' }
renderWithConsoleQuery(
<AccountAbout langGeniusVersionInfo={behindVersionInfo} onCancel={mockOnCancel} />,
{ systemFeatures: { deployment_edition: 'CLOUD' } },
)
expect(screen.getByText(/about.nowAvailable/)).toBeInTheDocument()
expect(screen.getByText(/about.updateNow/)).toBeInTheDocument()
})
})
describe('Community Edition', () => {
it('should render correctly in Community Edition', () => {
renderWithConsoleQuery(
<AccountAbout langGeniusVersionInfo={mockVersionInfo} onCancel={mockOnCancel} />,
{ systemFeatures: { deployment_edition: 'COMMUNITY' } },
)
expect(screen.getByText(/Open Source License/)).toBeInTheDocument()
})
it('should hide update button in Community Edition when behind version', () => {
const behindVersionInfo = { ...mockVersionInfo, latest_version: '0.7.0' }
renderWithConsoleQuery(
<AccountAbout langGeniusVersionInfo={behindVersionInfo} onCancel={mockOnCancel} />,
{ systemFeatures: { deployment_edition: 'COMMUNITY' } },
)
expect(screen.queryByText(/about.updateNow/)).not.toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onCancel when close button is clicked', () => {
renderWithConsoleQuery(
<AccountAbout langGeniusVersionInfo={mockVersionInfo} onCancel={mockOnCancel} />,
{ systemFeatures: { deployment_edition: 'CLOUD' } },
)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' }))
expect(mockOnCancel).toHaveBeenCalled()
})
})
})

View File

@ -1,124 +0,0 @@
'use client'
import type { LangGeniusVersionInfo } from '@/context/app-context-types'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { RiCloseLine } from '@remixicon/react'
import { useSuspenseQuery } from '@tanstack/react-query'
import dayjs from 'dayjs'
import { useTranslation } from 'react-i18next'
import { DifyLogo } from '@/app/components/base/logo/dify-logo'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import Link from '@/next/link'
type IAccountSettingProps = {
langGeniusVersionInfo: LangGeniusVersionInfo
onCancel: () => void
}
export default function AccountAbout({ langGeniusVersionInfo, onCancel }: IAccountSettingProps) {
const { t } = useTranslation()
const isLatest = langGeniusVersionInfo.current_version === langGeniusVersionInfo.latest_version
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const isNonCloudEdition =
systemFeatures.deployment_edition === 'COMMUNITY' ||
systemFeatures.deployment_edition === 'ENTERPRISE'
return (
<Dialog
open
onOpenChange={(open) => {
if (!open) onCancel()
}}
>
<DialogContent className="w-[calc(100vw-2rem)]! max-w-120! overflow-hidden! border-none px-6! py-4! text-left align-middle">
<div className="relative">
<button
type="button"
className="absolute top-0 right-0 flex size-8 cursor-pointer items-center justify-center border-none bg-transparent p-0 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
onClick={onCancel}
>
<RiCloseLine className="size-4 text-text-tertiary" aria-hidden="true" />
</button>
<div className="flex flex-col items-center gap-4 py-8">
{systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo ? (
<img
src={systemFeatures.branding.workspace_logo}
className="block h-7 w-auto object-contain"
alt="logo"
/>
) : (
<DifyLogo alt="Dify" size="large" className="mx-auto" />
)}
<div className="text-center text-xs font-normal text-text-tertiary">
Version
{langGeniusVersionInfo?.current_version}
</div>
<div className="flex flex-col items-center gap-2 text-center text-xs font-normal text-text-secondary">
<div>©{dayjs().year()} LangGenius, Inc., Contributors.</div>
<div className="text-text-accent">
{isNonCloudEdition && (
<Link
href="https://github.com/langgenius/dify/blob/main/LICENSE"
target="_blank"
rel="noopener noreferrer"
>
Open Source License
</Link>
)}
{systemFeatures.deployment_edition === 'CLOUD' && (
<>
<Link href="https://dify.ai/privacy" target="_blank" rel="noopener noreferrer">
Privacy Policy
</Link>
,&nbsp;
<Link href="https://dify.ai/terms" target="_blank" rel="noopener noreferrer">
Terms of Service
</Link>
</>
)}
</div>
</div>
</div>
<div className="-mx-6 mb-4 h-[0.5px] bg-divider-regular" />
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 text-xs font-medium text-text-tertiary">
{isLatest
? t(($) => $['about.latestAvailable'], {
ns: 'common',
version: langGeniusVersionInfo.latest_version,
})
: t(($) => $['about.nowAvailable'], {
ns: 'common',
version: langGeniusVersionInfo.latest_version,
})}
</div>
<div className="flex shrink-0 items-center">
<Button className="mr-2" size="small">
<Link
href="https://github.com/langgenius/dify/releases"
target="_blank"
rel="noopener noreferrer"
>
{t(($) => $['about.changeLog'], { ns: 'common' })}
</Link>
</Button>
{!isLatest && systemFeatures.deployment_edition === 'CLOUD' && (
<Button variant="primary" size="small">
<Link
href={langGeniusVersionInfo.release_notes}
target="_blank"
rel="noopener noreferrer"
>
{t(($) => $['about.updateNow'], { ns: 'common' })}
</Link>
</Button>
)}
</div>
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@ -1,92 +1,30 @@
import type { GetSystemFeaturesResponse } from '@dify/contracts/api/console/system-features/types.gen'
import type { ModalContextState } from '@/context/modal-context'
import type { ProviderContextState } from '@/context/provider-context'
import type { ConsoleStateFixture } from '@/test/console/state-fixture'
import type { DeepPartial } from '@/test/console/system-features'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderToString } from 'react-dom/server'
import { Plan } from '@/app/components/billing/type'
import { resetUser } from '@/app/components/base/amplitude/utils'
import AccountSection from '@/app/components/main-nav/components/account-section'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { useLogout } from '@/service/use-common'
import { createAccountProfileQueryClient } from '@/test/console/account-profile'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import AppSelector from '../index'
import AccountDropdown from '../index'
vi.mock('../../account-setting', () => ({
default: () => <div data-testid="account-setting">AccountSetting</div>,
const { mockPush, mockResetUser, mockSetSettingsDestination, mockUseRouter } = vi.hoisted(() => ({
mockPush: vi.fn(),
mockResetUser: vi.fn(),
mockSetSettingsDestination: vi.fn(),
mockUseRouter: vi.fn(),
}))
vi.mock('../../account-about', () => ({
default: ({ onCancel }: { onCancel: () => void }) => (
<div data-testid="account-about">
Version
<button onClick={onCancel}>Close</button>
</div>
),
vi.mock('@/app/components/base/amplitude/utils', () => ({
resetUser: mockResetUser,
}))
vi.mock('@/app/components/header/github-star', () => ({
default: () => <div data-testid="github-star">GithubStar</div>,
}))
vi.mock('@/app/components/base/theme-switcher', () => ({
default: () => (
<button type="button" data-testid="theme-switcher-button">
Theme switcher
</button>
),
}))
const { mockSetTheme } = vi.hoisted(() => ({
mockSetTheme: vi.fn(),
}))
const mockConsoleState = vi.hoisted(() => ({
current: undefined as ConsoleStateFixture | undefined,
}))
const mockConsoleStateReader = vi.hoisted(() => vi.fn())
const mockUseRouter = vi.hoisted(() => vi.fn())
vi.mock('next-themes', () => ({
useTheme: () => ({
theme: 'system',
setTheme: mockSetTheme,
}),
}))
vi.mock('@/context/account-state', async () => {
const { createAccountStateModuleMock } = await import('@/test/console/state-fixture')
return createAccountStateModuleMock(() => mockConsoleState.current ?? {})
})
vi.mock('@/context/workspace-state', async () => {
const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture')
return createWorkspaceStateModuleMock(() => mockConsoleState.current ?? {})
})
vi.mock('@/context/permission-state', async () => {
const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture')
return createPermissionStateModuleMock(() => mockConsoleState.current ?? {})
})
vi.mock('@/context/version-state', async () => {
const { createVersionStateModuleMock } = await import('@/test/console/state-fixture')
return createVersionStateModuleMock(() => mockConsoleState.current ?? {})
})
vi.mock('@/context/provider-context', () => ({
useProviderContext: vi.fn(),
}))
vi.mock('@/context/modal-context', () => ({
useModalContext: vi.fn(),
}))
const mockSetSettingsDestination = vi.fn()
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] }
})
vi.mock('@/service/use-common', async (importOriginal) => ({
...(await importOriginal<typeof import('@/service/use-common')>()),
useLogout: vi.fn(),
@ -100,396 +38,119 @@ vi.mock('@/next/navigation', async (importOriginal) => {
}
})
// Mock config and env
const { mockConfig, mockEnv } = vi.hoisted(() => ({
mockConfig: {
AMPLITUDE_API_KEY: '',
ZENDESK_WIDGET_KEY: '',
SUPPORT_EMAIL_ADDRESS: '',
},
mockEnv: {
env: {
NEXT_PUBLIC_SITE_ABOUT: 'show',
},
},
}))
vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/config')>()
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return {
...actual,
get AMPLITUDE_API_KEY() {
return mockConfig.AMPLITUDE_API_KEY
},
get ZENDESK_WIDGET_KEY() {
return mockConfig.ZENDESK_WIDGET_KEY
},
get SUPPORT_EMAIL_ADDRESS() {
return mockConfig.SUPPORT_EMAIL_ADDRESS
},
IS_DEV: false,
useQueryState: () => [null, mockSetSettingsDestination],
}
})
vi.mock('@/env', () => mockEnv)
const baseUserProfile = {
id: '1',
name: 'Test User',
email: 'test@example.com',
avatar: '',
avatar_url: 'avatar.png',
is_password_set: false,
vi.mock('next-themes', () => ({
useTheme: () => ({ theme: 'system', setTheme: vi.fn() }),
}))
const userProfile = {
id: 'current-user',
name: 'Current User',
email: 'current@example.com',
avatar_url: 'current-avatar.png',
}
const baseConsoleState: ConsoleStateFixture = {
userProfile: baseUserProfile,
refreshUserProfile: vi.fn(),
currentWorkspace: {
id: '1',
name: 'Workspace',
plan: '',
status: '',
created_at: 0,
role: 'owner',
providers: [],
trial_credits: 0,
trial_credits_used: 0,
next_credit_reset_date: 0,
},
isCurrentWorkspaceManager: true,
isCurrentWorkspaceOwner: true,
isCurrentWorkspaceEditor: true,
isCurrentWorkspaceDatasetOperator: false,
refreshCurrentWorkspace: vi.fn(),
langGeniusVersionInfo: {
current_env: 'testing',
current_version: '0.6.0',
latest_version: '0.6.0',
release_date: '',
release_notes: '',
version: '0.6.0',
can_auto_update: false,
},
isLoadingCurrentWorkspace: false,
workspacePermissionKeys: [],
}
const renderAccountDropdown = () => {
const queryClient = createAccountProfileQueryClient(userProfile)
const setConsoleState = (value: ConsoleStateFixture) => {
mockConsoleState.current = value
mockConsoleStateReader.mockReturnValue(value)
return renderWithConsoleQuery(
<AccountDropdown
trigger={({ isOpen, ariaLabel }) => (
<button type="button" aria-label={ariaLabel} data-open={isOpen}>
Current account
</button>
)}
/>,
{ queryClient },
)
}
describe('AccountDropdown', () => {
const mockPush = vi.fn()
const mockLogout = vi.fn()
let deploymentEdition: GetSystemFeaturesResponse['deployment_edition'] = 'COMMUNITY'
const renderWithRouter = (
ui: React.ReactElement,
options: { systemFeatures?: DeepPartial<GetSystemFeaturesResponse> } = {},
) => {
const queryClient = createAccountProfileQueryClient({
...baseUserProfile,
...(mockConsoleState.current?.userProfile ?? {}),
})
return renderWithConsoleQuery(ui, {
queryClient,
systemFeatures: options.systemFeatures ?? {
deployment_edition: deploymentEdition,
branding: { enabled: false },
},
})
}
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('localStorage', { removeItem: vi.fn() })
deploymentEdition = 'COMMUNITY'
mockEnv.env.NEXT_PUBLIC_SITE_ABOUT = 'show'
setConsoleState(baseConsoleState)
mockUseRouter.mockReturnValue({ push: mockPush })
vi.mocked(useProviderContext).mockReturnValue({
isEducationAccount: false,
plan: { type: Plan.sandbox },
} as unknown as ProviderContextState)
vi.mocked(useModalContext).mockReturnValue({} as unknown as ModalContextState)
} as ProviderContextState)
vi.mocked(useLogout).mockReturnValue({
mutateAsync: mockLogout,
} as unknown as ReturnType<typeof useLogout>)
mockUseRouter.mockReturnValue({
push: mockPush,
})
})
afterEach(() => {
vi.unstubAllGlobals()
it('reads the signed-in account from the account profile query', async () => {
const user = userEvent.setup()
const queryClient = createAccountProfileQueryClient(userProfile)
renderWithConsoleQuery(<AccountSection />, { queryClient })
expect(screen.getByText('Current User')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'common.account.account' }))
expect(await screen.findByText('current@example.com')).toBeInTheDocument()
})
describe('Rendering', () => {
it('should show the signed-in account in the main navigation menu', async () => {
const user = userEvent.setup()
const queryClient = createAccountProfileQueryClient({
id: 'current-user',
name: 'Current User',
email: 'current@example.com',
avatar_url: 'current-avatar.png',
})
it('keeps the composed trigger disabled in server-rendered markup', () => {
const html = renderToString(
<AccountDropdown
trigger={({ ariaLabel }) => (
<button type="button" aria-label={ariaLabel}>
Current account
</button>
)}
/>,
)
const container = document.createElement('div')
container.innerHTML = html
renderWithConsoleQuery(<AccountSection />, {
queryClient,
systemFeatures: { branding: { enabled: false } },
})
expect(screen.getByText('Current User')).toBeInTheDocument()
expect(screen.queryByText('Test User')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'common.account.account' }))
expect(await screen.findByText('current@example.com')).toBeInTheDocument()
expect(screen.queryByText('test@example.com')).not.toBeInTheDocument()
})
it('should render user profile correctly', () => {
// Act
renderWithRouter(<AppSelector />)
fireEvent.click(screen.getByRole('button'))
// Assert
expect(screen.getByText('Test User')).toBeInTheDocument()
expect(screen.getByText('test@example.com')).toBeInTheDocument()
})
it('should set an accessible label on avatar trigger when menu trigger is rendered', () => {
// Act
renderWithRouter(<AppSelector />)
// Assert
expect(screen.getByRole('button', { name: 'common.account.account' })).toBeInTheDocument()
})
it('should keep the account trigger disabled in server-rendered markup', () => {
const container = document.createElement('div')
container.innerHTML = renderToString(<AppSelector />)
expect(container.querySelector('button[aria-label="common.account.account"]')).toBeDisabled()
})
it('should show EDU badge for education accounts', () => {
// Arrange
vi.mocked(useProviderContext).mockReturnValue({
isEducationAccount: true,
plan: { type: Plan.sandbox },
} as unknown as ProviderContextState)
// Act
renderWithRouter(<AppSelector />)
fireEvent.click(screen.getByRole('button'))
// Assert
expect(screen.getByText('EDU')).toBeInTheDocument()
})
expect(container.querySelector('button[aria-label="common.account.account"]')).toBeDisabled()
})
describe('Settings and Support', () => {
it('should open member settings when settings is clicked', () => {
// Act
renderWithRouter(<AppSelector />)
fireEvent.click(screen.getByRole('button'))
fireEvent.click(screen.getByText('common.userProfile.settings'))
it('opens the main navigation account menu through the composed trigger', async () => {
const user = userEvent.setup()
renderAccountDropdown()
// Assert
expect(mockSetSettingsDestination).toHaveBeenCalledWith('members')
})
const trigger = screen.getByRole('button', { name: 'common.account.account' })
expect(trigger).toHaveAttribute('data-open', 'false')
it('should open preferences from the account dropdown', () => {
// Act
renderWithRouter(<AppSelector variant="mainNav" />)
fireEvent.click(screen.getByRole('button'))
fireEvent.click(screen.getByText('common.settings.preferences'))
await user.click(trigger)
// Assert
expect(mockSetSettingsDestination).toHaveBeenCalledWith('preferences')
})
it('should show Appearance after Preferences in the main nav account dropdown', () => {
// Act
renderWithRouter(<AppSelector variant="mainNav" />)
fireEvent.click(screen.getByRole('button'))
const preferences = screen.getByText('common.settings.preferences')
const appearance = screen.getByText('common.account.appearanceLabel')
// Assert
expect(preferences.compareDocumentPosition(appearance)).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
expect(
screen.getByRole('menuitem', { name: 'common.account.appearanceLabel' }),
).toBeInTheDocument()
})
it('should show Compliance in Cloud Edition for workspace owner', () => {
// Arrange
deploymentEdition = 'CLOUD'
setConsoleState({
...baseConsoleState,
userProfile: { ...baseConsoleState.userProfile, name: 'User' },
isCurrentWorkspaceOwner: true,
langGeniusVersionInfo: {
...baseConsoleState.langGeniusVersionInfo,
current_version: '0.6.0',
latest_version: '0.6.0',
},
})
// Act
renderWithRouter(<AppSelector />)
fireEvent.click(screen.getByRole('button'))
// Assert
expect(screen.getByText('common.userProfile.compliance')).toBeInTheDocument()
})
it('should hide Compliance in Cloud Edition when user is not workspace owner', () => {
// Arrange
deploymentEdition = 'CLOUD'
setConsoleState({
...baseConsoleState,
isCurrentWorkspaceOwner: false,
})
// Act
renderWithRouter(<AppSelector />)
fireEvent.click(screen.getByRole('button'))
// Assert
expect(screen.queryByText('common.userProfile.compliance')).not.toBeInTheDocument()
})
expect(await screen.findByText('current@example.com')).toBeInTheDocument()
expect(trigger).toHaveAttribute('data-open', 'true')
expect(screen.getByText('common.settings.preferences')).toBeInTheDocument()
expect(screen.getByText('common.account.appearanceLabel')).toBeInTheDocument()
})
describe('Actions', () => {
it('should handle logout correctly', async () => {
// Arrange
mockLogout.mockResolvedValue({})
it('opens preferences from the account menu', async () => {
const user = userEvent.setup()
renderAccountDropdown()
// Act
renderWithRouter(<AppSelector />)
fireEvent.click(screen.getByRole('button'))
fireEvent.click(screen.getByText('common.userProfile.logout'))
await user.click(screen.getByRole('button', { name: 'common.account.account' }))
await user.click(await screen.findByText('common.settings.preferences'))
// Assert
await waitFor(() => {
expect(mockLogout).toHaveBeenCalled()
expect(mockPush).toHaveBeenCalledWith('/signin')
})
})
it('should use the shutdown icon for main nav logout', () => {
// Act
renderWithRouter(<AppSelector variant="mainNav" />)
fireEvent.click(screen.getByRole('button'))
// Assert
expect(
screen
.getByRole('menuitem', { name: 'common.userProfile.logout' })
.querySelector('.i-ri-shut-down-line'),
).toBeInTheDocument()
})
it('should show About section when about button is clicked and can close it', () => {
// Act
renderWithRouter(<AppSelector />)
fireEvent.click(screen.getByRole('button'))
fireEvent.click(screen.getByText('common.userProfile.about'))
// Assert
expect(screen.getByTestId('account-about')).toBeInTheDocument()
// Act
fireEvent.click(screen.getByText('Close'))
// Assert
expect(screen.queryByTestId('account-about')).not.toBeInTheDocument()
})
it('should keep account dropdown open when clicking the theme switcher', () => {
// Act
renderWithRouter(<AppSelector />)
fireEvent.click(screen.getByRole('button', { name: 'common.account.account' }))
fireEvent.click(screen.getByTestId('theme-switcher-button'))
// Assert
expect(screen.getByText('common.userProfile.logout')).toBeInTheDocument()
})
expect(mockSetSettingsDestination).toHaveBeenCalledWith('preferences')
})
describe('Branding and Environment', () => {
it('should hide sections when branding is enabled', () => {
// Act
renderWithRouter(<AppSelector />, {
systemFeatures: { branding: { enabled: true } },
})
fireEvent.click(screen.getByRole('button'))
it('logs out and redirects to sign in', async () => {
mockLogout.mockResolvedValue({})
renderAccountDropdown()
// Assert
expect(screen.queryByText('common.userProfile.helpCenter')).not.toBeInTheDocument()
expect(screen.queryByText('common.userProfile.roadmap')).not.toBeInTheDocument()
})
fireEvent.click(screen.getByRole('button', { name: 'common.account.account' }))
fireEvent.click(await screen.findByText('common.userProfile.logout'))
it('should hide About section when NEXT_PUBLIC_SITE_ABOUT is hide', () => {
// Arrange
mockEnv.env.NEXT_PUBLIC_SITE_ABOUT = 'hide'
// Act
renderWithRouter(<AppSelector />)
fireEvent.click(screen.getByRole('button'))
// Assert
expect(screen.queryByText('common.userProfile.about')).not.toBeInTheDocument()
})
})
describe('Version Indicators', () => {
it('should show orange indicator when version is not latest', () => {
// Arrange
setConsoleState({
...baseConsoleState,
userProfile: { ...baseConsoleState.userProfile, name: 'User' },
langGeniusVersionInfo: {
...baseConsoleState.langGeniusVersionInfo,
current_version: '0.6.0',
latest_version: '0.7.0',
},
})
// Act
renderWithRouter(<AppSelector />)
fireEvent.click(screen.getByRole('button'))
// Assert
expect(
document.querySelector('.bg-components-badge-status-light-warning-bg'),
).toBeInTheDocument()
})
it('should show green indicator when version is latest', () => {
// Arrange
setConsoleState({
...baseConsoleState,
userProfile: { ...baseConsoleState.userProfile, name: 'User' },
langGeniusVersionInfo: {
...baseConsoleState.langGeniusVersionInfo,
current_version: '0.7.0',
latest_version: '0.7.0',
},
})
// Act
renderWithRouter(<AppSelector />)
fireEvent.click(screen.getByRole('button'))
// Assert
expect(
document.querySelector('.bg-components-badge-status-light-success-bg'),
).toBeInTheDocument()
await waitFor(() => {
expect(mockLogout).toHaveBeenCalledOnce()
expect(resetUser).toHaveBeenCalledOnce()
expect(mockPush).toHaveBeenCalledWith('/signin')
})
})
})

View File

@ -1,240 +0,0 @@
'use client'
import type { MouseEventHandler, ReactNode } from 'react'
import { Avatar } from '@langgenius/dify-ui/avatar'
import {
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLinkItem,
DropdownMenuSeparator,
} from '@langgenius/dify-ui/dropdown-menu'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useQueryState } from 'nuqs'
import { useTranslation } from 'react-i18next'
import PremiumBadge from '@/app/components/base/premium-badge'
import ThemeSwitcher from '@/app/components/base/theme-switcher'
import {
settingsQueryParamName,
settingsQueryParser,
} from '@/app/components/header/account-setting/query-params'
import { userProfileAtom } from '@/context/account-state'
import { useDocLink } from '@/context/i18n'
import { useProviderContext } from '@/context/provider-context'
import { langGeniusVersionInfoAtom } from '@/context/version-state'
import { isCurrentWorkspaceOwnerAtom } from '@/context/workspace-state'
import { env } from '@/env'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import Link from '@/next/link'
import GithubStar from '../github-star'
import Compliance from './compliance'
import { ExternalLinkIndicator, MenuItemContent } from './menu-item-content'
type AccountMenuRouteItemProps = {
href: string
iconClassName: string
label: ReactNode
trailing?: ReactNode
}
function AccountMenuRouteItem({ href, iconClassName, label, trailing }: AccountMenuRouteItemProps) {
return (
<DropdownMenuLinkItem className="justify-between" render={<Link href={href} />}>
<MenuItemContent iconClassName={iconClassName} label={label} trailing={trailing} />
</DropdownMenuLinkItem>
)
}
type AccountMenuExternalItemProps = {
href: string
iconClassName: string
label: ReactNode
trailing?: ReactNode
}
function AccountMenuExternalItem({
href,
iconClassName,
label,
trailing,
}: AccountMenuExternalItemProps) {
return (
<DropdownMenuLinkItem
className="justify-between"
href={href}
rel="noopener noreferrer"
target="_blank"
>
<MenuItemContent iconClassName={iconClassName} label={label} trailing={trailing} />
</DropdownMenuLinkItem>
)
}
type AccountMenuActionItemProps = {
iconClassName: string
label: ReactNode
onClick?: MouseEventHandler<HTMLElement>
trailing?: ReactNode
}
function AccountMenuActionItem({
iconClassName,
label,
onClick,
trailing,
}: AccountMenuActionItemProps) {
return (
<DropdownMenuItem className="justify-between" onClick={onClick}>
<MenuItemContent iconClassName={iconClassName} label={label} trailing={trailing} />
</DropdownMenuItem>
)
}
type AccountMenuSectionProps = {
children: ReactNode
}
function AccountMenuSection({ children }: AccountMenuSectionProps) {
return <DropdownMenuGroup className="py-1">{children}</DropdownMenuGroup>
}
type DefaultMenuContentProps = {
closeAccountDropdown: () => void
onShowAbout: () => void
onLogout: () => Promise<void>
}
export function DefaultMenuContent({
closeAccountDropdown,
onShowAbout,
onLogout,
}: DefaultMenuContentProps) {
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { t } = useTranslation()
const docLink = useDocLink()
const userProfile = useAtomValue(userProfileAtom)
const langGeniusVersionInfo = useAtomValue(langGeniusVersionInfoAtom)
const isCurrentWorkspaceOwner = useAtomValue(isCurrentWorkspaceOwnerAtom)
const { isEducationAccount } = useProviderContext()
const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser)
return (
<>
<DropdownMenuGroup className="py-1">
<div className="mx-1 flex flex-nowrap items-center py-2 pr-2 pl-3">
<div className="grow">
<div className="system-md-medium break-all text-text-primary">
{userProfile.name}
{isEducationAccount && (
<PremiumBadge size="s" color="blue" className="ml-1 px-2!">
<span aria-hidden className="mr-1 i-ri-graduation-cap-fill h-3 w-3" />
<span className="system-2xs-medium">EDU</span>
</PremiumBadge>
)}
</div>
<div className="system-xs-regular break-all text-text-tertiary">
{userProfile.email}
</div>
</div>
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size="lg" />
</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={() => setSettingsDestination('members')}
/>
</DropdownMenuGroup>
<DropdownMenuSeparator className="my-0! bg-divider-subtle" />
{!systemFeatures.branding.enabled && (
<>
<AccountMenuSection>
<AccountMenuExternalItem
href={docLink('/use-dify/getting-started/introduction')}
iconClassName="i-ri-book-open-line"
label={t(($) => $['userProfile.helpCenter'], { ns: 'common' })}
trailing={<ExternalLinkIndicator />}
/>
{systemFeatures.deployment_edition === 'CLOUD' && 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-1.25 py-0.75">
<span aria-hidden className="i-ri-star-line size-3 shrink-0 text-text-tertiary" />
<GithubStar className="system-2xs-medium-uppercase text-text-tertiary" />
</div>
}
/>
{env.NEXT_PUBLIC_SITE_ABOUT !== 'hide' && (
<AccountMenuActionItem
iconClassName="i-ri-information-2-line"
label={t(($) => $['userProfile.about'], { ns: 'common' })}
onClick={() => {
onShowAbout()
closeAccountDropdown()
}}
trailing={
<div className="flex shrink-0 items-center">
<div className="mr-2 system-xs-regular text-text-tertiary">
{langGeniusVersionInfo.current_version}
</div>
<StatusDot
status={
langGeniusVersionInfo.current_version ===
langGeniusVersionInfo.latest_version
? 'success'
: 'warning'
}
/>
</div>
}
/>
)}
</AccountMenuSection>
<DropdownMenuSeparator className="my-0! bg-divider-subtle" />
</>
)}
<AccountMenuSection>
<DropdownMenuItem
closeOnClick={false}
className="cursor-default data-highlighted:bg-transparent"
>
<MenuItemContent
iconClassName="i-ri-t-shirt-2-line"
label={t(($) => $['theme.theme'], { ns: 'common' })}
trailing={<ThemeSwitcher />}
/>
</DropdownMenuItem>
</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 onLogout()
}}
/>
</AccountMenuSection>
</>
)
}

View File

@ -1,28 +1,20 @@
'use client'
import type { ReactElement } from 'react'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useAtomValue } from 'jotai'
import { useState, useSyncExternalStore } from 'react'
import { useTranslation } from 'react-i18next'
import { resetUser } from '@/app/components/base/amplitude/utils'
import { userProfileAtom } from '@/context/account-state'
import { langGeniusVersionInfoAtom } from '@/context/version-state'
import { useRouter } from '@/next/navigation'
import { useLogout } from '@/service/use-common'
import AccountAbout from '../account-about'
import { DefaultMenuContent } from './default-menu-content'
import { MainNavMenuContent } from './main-nav-menu-content'
type AccountDropdownProps = {
trigger?: (props: { isOpen: boolean; ariaLabel: string }) => ReactElement
variant?: 'default' | 'mainNav'
trigger: (props: { isOpen: boolean; ariaLabel: string }) => ReactElement
}
const mainNavMenuPopupClassName =
@ -32,9 +24,8 @@ const subscribeHydrationState = () => () => {}
const getHydrationSnapshot = () => false
const getServerHydrationSnapshot = () => true
export default function AppSelector({ trigger, variant = 'default' }: AccountDropdownProps = {}) {
export default function AccountDropdown({ trigger }: AccountDropdownProps) {
const router = useRouter()
const [aboutVisible, setAboutVisible] = useState(false)
const [isAccountMenuOpen, setIsAccountMenuOpen] = useState(false)
const isHydrating = useSyncExternalStore(
subscribeHydrationState,
@ -42,8 +33,6 @@ export default function AppSelector({ trigger, variant = 'default' }: AccountDro
getServerHydrationSnapshot,
)
const { t } = useTranslation()
const userProfile = useAtomValue(userProfileAtom)
const langGeniusVersionInfo = useAtomValue(langGeniusVersionInfoAtom)
const { mutateAsync: logout } = useLogout()
@ -58,53 +47,22 @@ export default function AppSelector({ trigger, variant = 'default' }: AccountDro
return (
<div>
<DropdownMenu open={isAccountMenuOpen} onOpenChange={setIsAccountMenuOpen}>
{trigger ? (
<DropdownMenuTrigger
disabled={isHydrating}
render={trigger({
isOpen: isAccountMenuOpen,
ariaLabel: t(($) => $['account.account'], { ns: 'common' }),
})}
/>
) : (
<DropdownMenuTrigger
disabled={isHydrating}
aria-label={t(($) => $['account.account'], { ns: 'common' })}
className={cn(
'inline-flex items-center rounded-[20px] p-0.5 hover:bg-background-default-dodge disabled:cursor-default disabled:hover:bg-transparent',
isAccountMenuOpen && 'bg-background-default-dodge',
)}
>
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size="lg" />
</DropdownMenuTrigger>
)}
<DropdownMenuTrigger
disabled={isHydrating}
render={trigger({
isOpen: isAccountMenuOpen,
ariaLabel: t(($) => $['account.account'], { ns: 'common' }),
})}
/>
<DropdownMenuContent
placement={variant === 'mainNav' ? 'top-start' : 'bottom-end'}
placement="top-start"
sideOffset={6}
alignOffset={variant === 'mainNav' ? 4 : 0}
popupClassName={
variant === 'mainNav'
? mainNavMenuPopupClassName
: 'w-60 max-w-80 bg-components-panel-bg-blur! py-0! backdrop-blur-xs'
}
alignOffset={4}
popupClassName={mainNavMenuPopupClassName}
>
{variant === 'mainNav' ? (
<MainNavMenuContent onLogout={handleLogout} />
) : (
<DefaultMenuContent
closeAccountDropdown={() => setIsAccountMenuOpen(false)}
onShowAbout={() => setAboutVisible(true)}
onLogout={handleLogout}
/>
)}
<MainNavMenuContent onLogout={handleLogout} />
</DropdownMenuContent>
</DropdownMenu>
{aboutVisible && (
<AccountAbout
onCancel={() => setAboutVisible(false)}
langGeniusVersionInfo={langGeniusVersionInfo}
/>
)}
</div>
)
}

View File

@ -1139,6 +1139,65 @@ describe('MainNav', () => {
})
})
it('opens About from its real Help menu owner and restores focus when closed', async () => {
const user = userEvent.setup()
mockConsoleState.current = {
...consoleState,
langGeniusVersionInfo: {
...consoleState.langGeniusVersionInfo,
latest_version: '1.1.0',
release_notes: 'https://github.com/langgenius/dify/releases/tag/1.1.0',
},
}
renderMainNav()
const helpButton = screen.getByRole('button', { name: 'common.mainNav.help.openMenu' })
await user.click(helpButton)
await user.click(await screen.findByRole('menuitem', { name: /common\.userProfile\.about/ }))
expect(
await screen.findByRole('dialog', { name: 'common.userProfile.about' }),
).toBeInTheDocument()
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.close' })).toHaveFocus()
expect(screen.getByRole('link', { name: 'Privacy Policy' })).toHaveAttribute(
'href',
'https://dify.ai/privacy',
)
expect(screen.getByRole('link', { name: 'common.about.changeLog' })).toHaveAttribute(
'href',
'https://github.com/langgenius/dify/releases',
)
expect(screen.getByRole('link', { name: 'common.about.updateNow' })).toHaveAttribute(
'href',
'https://github.com/langgenius/dify/releases/tag/1.1.0',
)
expect(screen.queryByRole('button', { name: 'common.about.changeLog' })).not.toBeInTheDocument()
await user.keyboard('{Escape}')
await waitFor(() => {
expect(
screen.queryByRole('dialog', { name: 'common.userProfile.about' }),
).not.toBeInTheDocument()
expect(helpButton).toHaveFocus()
})
})
it('shows the open-source license in About for non-Cloud editions', async () => {
const user = userEvent.setup()
renderMainNav({ deployment_edition: 'COMMUNITY' })
await user.click(screen.getByRole('button', { name: 'common.mainNav.help.openMenu' }))
await user.click(await screen.findByRole('menuitem', { name: /common\.userProfile\.about/ }))
expect(await screen.findByRole('link', { name: 'Open Source License' })).toHaveAttribute(
'href',
'https://github.com/langgenius/dify/blob/main/LICENSE',
)
expect(screen.queryByRole('link', { name: 'Privacy Policy' })).not.toBeInTheDocument()
})
it('closes the help menu from the support upgrade action', async () => {
renderMainNav()

View File

@ -18,7 +18,6 @@ const AccountSection = ({ compact = false }: AccountSectionProps) => {
return (
<AccountDropdown
variant="mainNav"
trigger={({ isOpen, ariaLabel }) => (
<button
type="button"

View File

@ -21,7 +21,6 @@ import {
useLearnDifyHiddenValue,
useSetLearnDifyHidden,
} from '@/app/components/explore/learn-dify/storage'
import AccountAbout from '@/app/components/header/account-about'
import Compliance from '@/app/components/header/account-dropdown/compliance'
import {
ExternalLinkIndicator,
@ -47,6 +46,7 @@ import {
import { env } from '@/env'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import styles from './help-menu.module.css'
import AccountAboutDialog from './help-menu/account-about-dialog'
import SupportMenu from './support-menu'
type HelpMenuProps = {
@ -99,7 +99,7 @@ const HelpMenu = ({ triggerIcon = defaultTriggerIcon, triggerClassName }: HelpMe
const enableStepByStepTour = useSetAtom(enableStepByStepTourForCurrentWorkspaceAtom)
const disableStepByStepTour = useSetAtom(disableStepByStepTourForCurrentWorkspaceAtom)
const setStepByStepTourShellMode = useSetStepByStepTourShellMode()
const [aboutVisible, setAboutVisible] = useState(false)
const [aboutOpen, setAboutOpen] = useState(false)
const [open, setOpen] = useState(false)
const shouldShowLearnDifySwitch = systemFeatures.enable_learn_app
const shouldShowStepByStepTourSwitch = systemFeatures.enable_step_by_step_tour
@ -250,8 +250,8 @@ const HelpMenu = ({ triggerIcon = defaultTriggerIcon, triggerClassName }: HelpMe
<DropdownMenuItem
className="mx-0 h-8 gap-1 px-3 py-1.5"
onClick={() => {
setAboutVisible(true)
setOpen(false)
setAboutOpen(true)
}}
>
<MenuItemContent
@ -274,12 +274,12 @@ const HelpMenu = ({ triggerIcon = defaultTriggerIcon, triggerClassName }: HelpMe
</>
</DropdownMenuContent>
</DropdownMenu>
{aboutVisible && (
<AccountAbout
onCancel={() => setAboutVisible(false)}
langGeniusVersionInfo={langGeniusVersionInfo}
/>
)}
<AccountAboutDialog
open={aboutOpen}
onOpenChange={setAboutOpen}
langGeniusVersionInfo={langGeniusVersionInfo}
deploymentEdition={systemFeatures.deployment_edition}
/>
</>
)
}

View File

@ -0,0 +1,115 @@
'use client'
import type { DeploymentEdition } from '@dify/contracts/api/console/system-features/types.gen'
import type { LangGeniusVersionInfo } from '@/context/app-context-types'
import { buttonVariants } from '@langgenius/dify-ui/button'
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import dayjs from 'dayjs'
import { useTranslation } from 'react-i18next'
import { DifyLogo } from '@/app/components/base/logo/dify-logo'
import Link from '@/next/link'
type AccountAboutDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
langGeniusVersionInfo: LangGeniusVersionInfo
deploymentEdition: DeploymentEdition
}
export default function AccountAboutDialog({
open,
onOpenChange,
langGeniusVersionInfo,
deploymentEdition,
}: AccountAboutDialogProps) {
const { t } = useTranslation()
const isLatest = langGeniusVersionInfo.current_version === langGeniusVersionInfo.latest_version
const isNonCloudEdition = deploymentEdition === 'COMMUNITY' || deploymentEdition === 'ENTERPRISE'
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="overflow-hidden p-0 text-left">
<DialogTitle className="sr-only">
{t(($) => $['userProfile.about'], { ns: 'common' })}
</DialogTitle>
<DialogCloseButton aria-label={t(($) => $['operation.close'], { ns: 'common' })} />
<div className="flex flex-col items-center gap-4 px-6 py-12">
<DifyLogo alt="Dify" size="large" className="mx-auto" />
<div className="text-center system-xs-regular text-text-tertiary">
Version {langGeniusVersionInfo.current_version}
</div>
<div className="flex flex-col items-center gap-2 text-center system-xs-regular text-text-secondary">
<div>©{dayjs().year()} LangGenius, Inc., Contributors.</div>
<div className="text-text-accent">
{isNonCloudEdition && (
<Link
href="https://github.com/langgenius/dify/blob/main/LICENSE"
target="_blank"
rel="noopener noreferrer"
className="rounded-sm outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid"
>
Open Source License
</Link>
)}
{deploymentEdition === 'CLOUD' && (
<>
<Link
href="https://dify.ai/privacy"
target="_blank"
rel="noopener noreferrer"
className="rounded-sm outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid"
>
Privacy Policy
</Link>
,&nbsp;
<Link
href="https://dify.ai/terms"
target="_blank"
rel="noopener noreferrer"
className="rounded-sm outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid"
>
Terms of Service
</Link>
</>
)}
</div>
</div>
</div>
<div className="flex flex-col gap-3 border-t border-divider-subtle px-6 py-4 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 system-xs-medium text-text-tertiary">
{isLatest
? t(($) => $['about.latestAvailable'], {
ns: 'common',
version: langGeniusVersionInfo.latest_version,
})
: t(($) => $['about.nowAvailable'], {
ns: 'common',
version: langGeniusVersionInfo.latest_version,
})}
</div>
<div className="flex shrink-0 items-center gap-2">
<Link
href="https://github.com/langgenius/dify/releases"
target="_blank"
rel="noopener noreferrer"
className={buttonVariants({ size: 'small' })}
>
{t(($) => $['about.changeLog'], { ns: 'common' })}
</Link>
{!isLatest && deploymentEdition === 'CLOUD' && (
<Link
href={langGeniusVersionInfo.release_notes}
target="_blank"
rel="noopener noreferrer"
className={buttonVariants({ variant: 'primary', size: 'small' })}
>
{t(($) => $['about.updateNow'], { ns: 'common' })}
</Link>
)}
</div>
</div>
</DialogContent>
</Dialog>
)
}