mirror of
https://github.com/langgenius/dify.git
synced 2026-07-31 01:09:32 +08:00
fix: refactor account about dialog ownership (#39765)
This commit is contained in:
parent
fc2f1f4f75
commit
81fd1da3ff
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
,
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -18,7 +18,6 @@ const AccountSection = ({ compact = false }: AccountSectionProps) => {
|
||||
|
||||
return (
|
||||
<AccountDropdown
|
||||
variant="mainNav"
|
||||
trigger={({ isOpen, ariaLabel }) => (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
,
|
||||
<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>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user