From 81fd1da3ff9a0e8b959040ef23b7eab37d38892c Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:33:26 +0800 Subject: [PATCH] fix: refactor account about dialog ownership (#39765) --- .../account-about/__tests__/index.spec.tsx | 114 ---- .../components/header/account-about/index.tsx | 124 ----- .../account-dropdown/__tests__/index.spec.tsx | 507 +++--------------- .../account-dropdown/default-menu-content.tsx | 240 --------- .../header/account-dropdown/index.tsx | 68 +-- .../main-nav/__tests__/index.spec.tsx | 59 ++ .../main-nav/components/account-section.tsx | 1 - .../main-nav/components/help-menu.tsx | 18 +- .../help-menu/account-about-dialog.tsx | 115 ++++ 9 files changed, 280 insertions(+), 966 deletions(-) delete mode 100644 web/app/components/header/account-about/__tests__/index.spec.tsx delete mode 100644 web/app/components/header/account-about/index.tsx delete mode 100644 web/app/components/header/account-dropdown/default-menu-content.tsx create mode 100644 web/app/components/main-nav/components/help-menu/account-about-dialog.tsx diff --git a/web/app/components/header/account-about/__tests__/index.spec.tsx b/web/app/components/header/account-about/__tests__/index.spec.tsx deleted file mode 100644 index ff965d9a08c..00000000000 --- a/web/app/components/header/account-about/__tests__/index.spec.tsx +++ /dev/null @@ -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( - , - { - 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( - , - { - 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( - , - { 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( - , - { 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( - , - { 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( - , - { systemFeatures: { deployment_edition: 'COMMUNITY' } }, - ) - - expect(screen.queryByText(/about.updateNow/)).not.toBeInTheDocument() - }) - }) - - describe('User Interactions', () => { - it('should call onCancel when close button is clicked', () => { - renderWithConsoleQuery( - , - { systemFeatures: { deployment_edition: 'CLOUD' } }, - ) - - fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' })) - - expect(mockOnCancel).toHaveBeenCalled() - }) - }) -}) diff --git a/web/app/components/header/account-about/index.tsx b/web/app/components/header/account-about/index.tsx deleted file mode 100644 index 51a922c15d5..00000000000 --- a/web/app/components/header/account-about/index.tsx +++ /dev/null @@ -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 ( - { - if (!open) onCancel() - }} - > - -
- -
- {systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo ? ( - logo - ) : ( - - )} - -
- Version - {langGeniusVersionInfo?.current_version} -
-
-
©{dayjs().year()} LangGenius, Inc., Contributors.
-
- {isNonCloudEdition && ( - - Open Source License - - )} - {systemFeatures.deployment_edition === 'CLOUD' && ( - <> - - Privacy Policy - - ,  - - Terms of Service - - - )} -
-
-
-
-
-
- {isLatest - ? t(($) => $['about.latestAvailable'], { - ns: 'common', - version: langGeniusVersionInfo.latest_version, - }) - : t(($) => $['about.nowAvailable'], { - ns: 'common', - version: langGeniusVersionInfo.latest_version, - })} -
-
- - {!isLatest && systemFeatures.deployment_edition === 'CLOUD' && ( - - )} -
-
-
- -
- ) -} diff --git a/web/app/components/header/account-dropdown/__tests__/index.spec.tsx b/web/app/components/header/account-dropdown/__tests__/index.spec.tsx index ae45a322562..2ee71387918 100644 --- a/web/app/components/header/account-dropdown/__tests__/index.spec.tsx +++ b/web/app/components/header/account-dropdown/__tests__/index.spec.tsx @@ -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: () =>
AccountSetting
, +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 }) => ( -
- Version - -
- ), +vi.mock('@/app/components/base/amplitude/utils', () => ({ + resetUser: mockResetUser, })) -vi.mock('@/app/components/header/github-star', () => ({ - default: () =>
GithubStar
, -})) - -vi.mock('@/app/components/base/theme-switcher', () => ({ - default: () => ( - - ), -})) - -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() - return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] } -}) - vi.mock('@/service/use-common', async (importOriginal) => ({ ...(await importOriginal()), 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() +vi.mock('nuqs', async (importOriginal) => { + const actual = await importOriginal() 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( + ( + + )} + />, + { 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 } = {}, - ) => { - 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) - 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(, { 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( + ( + + )} + />, + ) + const container = document.createElement('div') + container.innerHTML = html - renderWithConsoleQuery(, { - 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() - 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() - - // 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() - - 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() - 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() - 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() - 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() - 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() - 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() - 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() - 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() - 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() - 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() - 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(, { - 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() - 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() - 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() - 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') }) }) }) diff --git a/web/app/components/header/account-dropdown/default-menu-content.tsx b/web/app/components/header/account-dropdown/default-menu-content.tsx deleted file mode 100644 index 10bd82acfa1..00000000000 --- a/web/app/components/header/account-dropdown/default-menu-content.tsx +++ /dev/null @@ -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 ( - }> - - - ) -} - -type AccountMenuExternalItemProps = { - href: string - iconClassName: string - label: ReactNode - trailing?: ReactNode -} - -function AccountMenuExternalItem({ - href, - iconClassName, - label, - trailing, -}: AccountMenuExternalItemProps) { - return ( - - - - ) -} - -type AccountMenuActionItemProps = { - iconClassName: string - label: ReactNode - onClick?: MouseEventHandler - trailing?: ReactNode -} - -function AccountMenuActionItem({ - iconClassName, - label, - onClick, - trailing, -}: AccountMenuActionItemProps) { - return ( - - - - ) -} - -type AccountMenuSectionProps = { - children: ReactNode -} - -function AccountMenuSection({ children }: AccountMenuSectionProps) { - return {children} -} - -type DefaultMenuContentProps = { - closeAccountDropdown: () => void - onShowAbout: () => void - onLogout: () => Promise -} - -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 ( - <> - -
-
-
- {userProfile.name} - {isEducationAccount && ( - - - EDU - - )} -
-
- {userProfile.email} -
-
- -
- $['account.account'], { ns: 'common' })} - trailing={} - /> - $['userProfile.settings'], { ns: 'common' })} - onClick={() => setSettingsDestination('members')} - /> -
- - {!systemFeatures.branding.enabled && ( - <> - - $['userProfile.helpCenter'], { ns: 'common' })} - trailing={} - /> - {systemFeatures.deployment_edition === 'CLOUD' && isCurrentWorkspaceOwner && ( - - )} - - - - $['userProfile.roadmap'], { ns: 'common' })} - trailing={} - /> - $['userProfile.github'], { ns: 'common' })} - trailing={ -
- - -
- } - /> - {env.NEXT_PUBLIC_SITE_ABOUT !== 'hide' && ( - $['userProfile.about'], { ns: 'common' })} - onClick={() => { - onShowAbout() - closeAccountDropdown() - }} - trailing={ -
-
- {langGeniusVersionInfo.current_version} -
- -
- } - /> - )} -
- - - )} - - - $['theme.theme'], { ns: 'common' })} - trailing={} - /> - - - - - $['userProfile.logout'], { ns: 'common' })} - onClick={() => { - void onLogout() - }} - /> - - - ) -} diff --git a/web/app/components/header/account-dropdown/index.tsx b/web/app/components/header/account-dropdown/index.tsx index c98e696ef06..cc887ab8025 100644 --- a/web/app/components/header/account-dropdown/index.tsx +++ b/web/app/components/header/account-dropdown/index.tsx @@ -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 (
- {trigger ? ( - $['account.account'], { ns: 'common' }), - })} - /> - ) : ( - $['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', - )} - > - - - )} + $['account.account'], { ns: 'common' }), + })} + /> - {variant === 'mainNav' ? ( - - ) : ( - setIsAccountMenuOpen(false)} - onShowAbout={() => setAboutVisible(true)} - onLogout={handleLogout} - /> - )} + - {aboutVisible && ( - setAboutVisible(false)} - langGeniusVersionInfo={langGeniusVersionInfo} - /> - )}
) } diff --git a/web/app/components/main-nav/__tests__/index.spec.tsx b/web/app/components/main-nav/__tests__/index.spec.tsx index d3cac90f78c..c0acc04429f 100644 --- a/web/app/components/main-nav/__tests__/index.spec.tsx +++ b/web/app/components/main-nav/__tests__/index.spec.tsx @@ -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() diff --git a/web/app/components/main-nav/components/account-section.tsx b/web/app/components/main-nav/components/account-section.tsx index b6932d81269..d818f2ca903 100644 --- a/web/app/components/main-nav/components/account-section.tsx +++ b/web/app/components/main-nav/components/account-section.tsx @@ -18,7 +18,6 @@ const AccountSection = ({ compact = false }: AccountSectionProps) => { return ( (