From 3b4c0b4f97fa2a77944e2f67978e91bc3d44b1ba Mon Sep 17 00:00:00 2001 From: Jingyi-Dify Date: Fri, 1 May 2026 12:25:43 -0700 Subject: [PATCH] feat: add desktop main navigation --- .../assets/vender/main-nav/credits.svg | 5 + .../assets/vender/main-nav/help.svg | 4 + .../assets/vender/main-nav/home-active.svg | 6 + .../assets/vender/main-nav/home.svg | 4 + .../vender/main-nav/integrations-active.svg | 7 + .../assets/vender/main-nav/integrations.svg | 5 + .../vender/main-nav/knowledge-active.svg | 5 + .../assets/vender/main-nav/knowledge.svg | 6 + .../vender/main-nav/marketplace-active.svg | 5 + .../assets/vender/main-nav/marketplace.svg | 3 + .../assets/vender/main-nav/quick-search.svg | 4 + .../assets/vender/main-nav/studio-active.svg | 8 + .../assets/vender/main-nav/studio.svg | 6 + .../custom-vender/icons.json | 65 ++ .../custom-vender/info.json | 2 +- web/app/(commonLayout)/layout.tsx | 14 +- .../explore/__tests__/index.spec.tsx | 28 +- web/app/components/explore/index.tsx | 6 +- .../__tests__/use-goto-anything-modal.spec.ts | 16 +- .../components/goto-anything/hooks/index.ts | 2 +- .../hooks/use-goto-anything-modal.ts | 9 + .../header/account-dropdown/index.tsx | 36 +- .../main-nav/__tests__/index.spec.tsx | 286 ++++++++ .../main-nav/__tests__/layout.spec.tsx | 79 +++ web/app/components/main-nav/index.tsx | 634 ++++++++++++++++++ web/app/components/main-nav/layout.tsx | 64 ++ web/i18n/en-US/common.json | 11 + 27 files changed, 1299 insertions(+), 21 deletions(-) create mode 100644 packages/iconify-collections/assets/vender/main-nav/credits.svg create mode 100644 packages/iconify-collections/assets/vender/main-nav/help.svg create mode 100644 packages/iconify-collections/assets/vender/main-nav/home-active.svg create mode 100644 packages/iconify-collections/assets/vender/main-nav/home.svg create mode 100644 packages/iconify-collections/assets/vender/main-nav/integrations-active.svg create mode 100644 packages/iconify-collections/assets/vender/main-nav/integrations.svg create mode 100644 packages/iconify-collections/assets/vender/main-nav/knowledge-active.svg create mode 100644 packages/iconify-collections/assets/vender/main-nav/knowledge.svg create mode 100644 packages/iconify-collections/assets/vender/main-nav/marketplace-active.svg create mode 100644 packages/iconify-collections/assets/vender/main-nav/marketplace.svg create mode 100644 packages/iconify-collections/assets/vender/main-nav/quick-search.svg create mode 100644 packages/iconify-collections/assets/vender/main-nav/studio-active.svg create mode 100644 packages/iconify-collections/assets/vender/main-nav/studio.svg create mode 100644 web/app/components/main-nav/__tests__/index.spec.tsx create mode 100644 web/app/components/main-nav/__tests__/layout.spec.tsx create mode 100644 web/app/components/main-nav/index.tsx create mode 100644 web/app/components/main-nav/layout.tsx diff --git a/packages/iconify-collections/assets/vender/main-nav/credits.svg b/packages/iconify-collections/assets/vender/main-nav/credits.svg new file mode 100644 index 0000000000..e956861d72 --- /dev/null +++ b/packages/iconify-collections/assets/vender/main-nav/credits.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/iconify-collections/assets/vender/main-nav/help.svg b/packages/iconify-collections/assets/vender/main-nav/help.svg new file mode 100644 index 0000000000..58d0a92dd6 --- /dev/null +++ b/packages/iconify-collections/assets/vender/main-nav/help.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/iconify-collections/assets/vender/main-nav/home-active.svg b/packages/iconify-collections/assets/vender/main-nav/home-active.svg new file mode 100644 index 0000000000..ce1204492c --- /dev/null +++ b/packages/iconify-collections/assets/vender/main-nav/home-active.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/iconify-collections/assets/vender/main-nav/home.svg b/packages/iconify-collections/assets/vender/main-nav/home.svg new file mode 100644 index 0000000000..cf685e7f23 --- /dev/null +++ b/packages/iconify-collections/assets/vender/main-nav/home.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/iconify-collections/assets/vender/main-nav/integrations-active.svg b/packages/iconify-collections/assets/vender/main-nav/integrations-active.svg new file mode 100644 index 0000000000..a7367871d1 --- /dev/null +++ b/packages/iconify-collections/assets/vender/main-nav/integrations-active.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/iconify-collections/assets/vender/main-nav/integrations.svg b/packages/iconify-collections/assets/vender/main-nav/integrations.svg new file mode 100644 index 0000000000..af030453f9 --- /dev/null +++ b/packages/iconify-collections/assets/vender/main-nav/integrations.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/iconify-collections/assets/vender/main-nav/knowledge-active.svg b/packages/iconify-collections/assets/vender/main-nav/knowledge-active.svg new file mode 100644 index 0000000000..869982f117 --- /dev/null +++ b/packages/iconify-collections/assets/vender/main-nav/knowledge-active.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/iconify-collections/assets/vender/main-nav/knowledge.svg b/packages/iconify-collections/assets/vender/main-nav/knowledge.svg new file mode 100644 index 0000000000..c54812ffc2 --- /dev/null +++ b/packages/iconify-collections/assets/vender/main-nav/knowledge.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/iconify-collections/assets/vender/main-nav/marketplace-active.svg b/packages/iconify-collections/assets/vender/main-nav/marketplace-active.svg new file mode 100644 index 0000000000..8a2d7a7911 --- /dev/null +++ b/packages/iconify-collections/assets/vender/main-nav/marketplace-active.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/iconify-collections/assets/vender/main-nav/marketplace.svg b/packages/iconify-collections/assets/vender/main-nav/marketplace.svg new file mode 100644 index 0000000000..cbec531fe9 --- /dev/null +++ b/packages/iconify-collections/assets/vender/main-nav/marketplace.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/iconify-collections/assets/vender/main-nav/quick-search.svg b/packages/iconify-collections/assets/vender/main-nav/quick-search.svg new file mode 100644 index 0000000000..f96f09d4e4 --- /dev/null +++ b/packages/iconify-collections/assets/vender/main-nav/quick-search.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/iconify-collections/assets/vender/main-nav/studio-active.svg b/packages/iconify-collections/assets/vender/main-nav/studio-active.svg new file mode 100644 index 0000000000..59826145ab --- /dev/null +++ b/packages/iconify-collections/assets/vender/main-nav/studio-active.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/iconify-collections/assets/vender/main-nav/studio.svg b/packages/iconify-collections/assets/vender/main-nav/studio.svg new file mode 100644 index 0000000000..ac64f1100a --- /dev/null +++ b/packages/iconify-collections/assets/vender/main-nav/studio.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/iconify-collections/custom-vender/icons.json b/packages/iconify-collections/custom-vender/icons.json index bbed34e313..a6dcd536b9 100644 --- a/packages/iconify-collections/custom-vender/icons.json +++ b/packages/iconify-collections/custom-vender/icons.json @@ -577,6 +577,36 @@ "width": 24, "height": 24 }, + "main-nav-home": { + "body": "", + "width": 20, + "height": 20 + }, + "main-nav-home-active": { + "body": "", + "width": 20, + "height": 20 + }, + "main-nav-integrations": { + "body": "", + "width": 20, + "height": 20 + }, + "main-nav-integrations-active": { + "body": "", + "width": 20, + "height": 20 + }, + "main-nav-studio": { + "body": "", + "width": 20, + "height": 20 + }, + "main-nav-studio-active": { + "body": "", + "width": 20, + "height": 20 + }, "other-anthropic-text": { "body": "", "width": 90, @@ -1093,6 +1123,41 @@ "body": "", "width": 16, "height": 16 + }, + "main-nav-credits": { + "body": "", + "width": 24, + "height": 24 + }, + "main-nav-help": { + "body": "", + "width": 24, + "height": 24 + }, + "main-nav-quick-search": { + "body": "", + "width": 24, + "height": 24 + }, + "main-nav-knowledge": { + "body": "", + "width": 20, + "height": 20 + }, + "main-nav-knowledge-active": { + "body": "", + "width": 20, + "height": 20 + }, + "main-nav-marketplace": { + "body": "", + "width": 20, + "height": 20 + }, + "main-nav-marketplace-active": { + "body": "", + "width": 20, + "height": 20 } } } diff --git a/packages/iconify-collections/custom-vender/info.json b/packages/iconify-collections/custom-vender/info.json index 0a84c45bbd..fd25449385 100644 --- a/packages/iconify-collections/custom-vender/info.json +++ b/packages/iconify-collections/custom-vender/info.json @@ -1,7 +1,7 @@ { "prefix": "custom-vender", "name": "Dify Custom Vender", - "total": 277, + "total": 290, "version": "0.0.0-private", "author": { "name": "LangGenius, Inc.", diff --git a/web/app/(commonLayout)/layout.tsx b/web/app/(commonLayout)/layout.tsx index 699d2a4348..dd8574a753 100644 --- a/web/app/(commonLayout)/layout.tsx +++ b/web/app/(commonLayout)/layout.tsx @@ -5,8 +5,7 @@ import InSiteMessageNotification from '@/app/components/app/in-site-message/noti import GA, { GaType } from '@/app/components/base/ga' import Zendesk from '@/app/components/base/zendesk' import { GotoAnything } from '@/app/components/goto-anything' -import Header from '@/app/components/header' -import HeaderWrapper from '@/app/components/header/header-wrapper' +import MainNavLayout from '@/app/components/main-nav/layout' import ReadmePanel from '@/app/components/plugins/readme-panel' import { AppContextProvider } from '@/context/app-context-provider' import { EventEmitterContextProvider } from '@/context/event-emitter-provider' @@ -24,12 +23,11 @@ const Layout = ({ children }: { children: ReactNode }) => { - -
- - - {children} - + + + {children} + + diff --git a/web/app/components/explore/__tests__/index.spec.tsx b/web/app/components/explore/__tests__/index.spec.tsx index 5c743928e8..4b77ae99ea 100644 --- a/web/app/components/explore/__tests__/index.spec.tsx +++ b/web/app/components/explore/__tests__/index.spec.tsx @@ -7,6 +7,9 @@ import Explore from '../index' const mockReplace = vi.fn() const mockPush = vi.fn() const mockInstalledAppsData = { installed_apps: [] as const } +type MediaTypeValue = (typeof MediaType)[keyof typeof MediaType] + +let mockMediaType: MediaTypeValue = MediaType.pc vi.mock('@/next/navigation', () => ({ useRouter: () => ({ @@ -17,7 +20,7 @@ vi.mock('@/next/navigation', () => ({ })) vi.mock('@/hooks/use-breakpoints', () => ({ - default: () => MediaType.pc, + default: () => mockMediaType, MediaType: { mobile: 'mobile', tablet: 'tablet', @@ -45,6 +48,7 @@ vi.mock('@/context/app-context', () => ({ describe('Explore', () => { beforeEach(() => { vi.clearAllMocks() + mockMediaType = MediaType.pc ;(useAppContext as Mock).mockReturnValue({ isCurrentWorkspaceDatasetOperator: false, }) @@ -60,6 +64,28 @@ describe('Explore', () => { expect(screen.getByText('child')).toBeInTheDocument() }) + + it('should not render the legacy explore sidebar on desktop', () => { + render(( + +
child
+
+ )) + + expect(screen.queryByText('explore.sidebar.title')).not.toBeInTheDocument() + }) + + it('should keep the legacy explore sidebar on mobile', () => { + mockMediaType = MediaType.mobile + + render(( + +
child
+
+ )) + + expect(screen.getByRole('link', { name: 'explore.sidebar.title' })).toBeInTheDocument() + }) }) describe('Effects', () => { diff --git a/web/app/components/explore/index.tsx b/web/app/components/explore/index.tsx index f29ae3156e..9717ff3df8 100644 --- a/web/app/components/explore/index.tsx +++ b/web/app/components/explore/index.tsx @@ -1,15 +1,19 @@ 'use client' import * as React from 'react' import Sidebar from '@/app/components/explore/sidebar' +import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' const Explore = ({ children, }: { children: React.ReactNode }) => { + const media = useBreakpoints() + const isMobile = media === MediaType.mobile + return (
- + {isMobile && }
{children}
diff --git a/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-modal.spec.ts b/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-modal.spec.ts index 45bbfb7447..839e118aa8 100644 --- a/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-modal.spec.ts +++ b/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-modal.spec.ts @@ -1,5 +1,5 @@ import { act, renderHook } from '@testing-library/react' -import { useGotoAnythingModal } from '../use-goto-anything-modal' +import { GOTO_ANYTHING_OPEN_EVENT, useGotoAnythingModal } from '../use-goto-anything-modal' type KeyPressEvent = { preventDefault: () => void @@ -167,6 +167,20 @@ describe('useGotoAnythingModal', () => { }) }) + describe('open event', () => { + it('should open the modal when the global open event is dispatched', () => { + const { result } = renderHook(() => useGotoAnythingModal()) + + expect(result.current.show).toBe(false) + + act(() => { + window.dispatchEvent(new Event(GOTO_ANYTHING_OPEN_EVENT)) + }) + + expect(result.current.show).toBe(true) + }) + }) + describe('setShow', () => { it('should accept boolean value', () => { const { result } = renderHook(() => useGotoAnythingModal()) diff --git a/web/app/components/goto-anything/hooks/index.ts b/web/app/components/goto-anything/hooks/index.ts index c918a0c2bb..5388fd5e03 100644 --- a/web/app/components/goto-anything/hooks/index.ts +++ b/web/app/components/goto-anything/hooks/index.ts @@ -1,4 +1,4 @@ -export { useGotoAnythingModal } from './use-goto-anything-modal' +export { GOTO_ANYTHING_OPEN_EVENT, useGotoAnythingModal } from './use-goto-anything-modal' export { useGotoAnythingNavigation } from './use-goto-anything-navigation' diff --git a/web/app/components/goto-anything/hooks/use-goto-anything-modal.ts b/web/app/components/goto-anything/hooks/use-goto-anything-modal.ts index 3e616cdd95..548d6d111d 100644 --- a/web/app/components/goto-anything/hooks/use-goto-anything-modal.ts +++ b/web/app/components/goto-anything/hooks/use-goto-anything-modal.ts @@ -12,6 +12,8 @@ type UseGotoAnythingModalReturn = { handleClose: () => void } +export const GOTO_ANYTHING_OPEN_EVENT = 'dify:goto-anything-open' + export const useGotoAnythingModal = (): UseGotoAnythingModalReturn => { const [show, setShow] = useState(false) const inputRef = useRef(null) @@ -37,6 +39,13 @@ export const useGotoAnythingModal = (): UseGotoAnythingModalReturn => { } }) + useEffect(() => { + const handleOpen = () => setShow(true) + + window.addEventListener(GOTO_ANYTHING_OPEN_EVENT, handleOpen) + return () => window.removeEventListener(GOTO_ANYTHING_OPEN_EVENT, handleOpen) + }, []) + const handleClose = useCallback(() => { setShow(false) }, []) diff --git a/web/app/components/header/account-dropdown/index.tsx b/web/app/components/header/account-dropdown/index.tsx index b62eb42482..ce9a828aba 100644 --- a/web/app/components/header/account-dropdown/index.tsx +++ b/web/app/components/header/account-dropdown/index.tsx @@ -1,6 +1,6 @@ 'use client' -import type { MouseEventHandler, ReactNode } from 'react' +import type { MouseEventHandler, ReactElement, ReactNode } from 'react' import { Avatar } from '@langgenius/dify-ui/avatar' import { cn } from '@langgenius/dify-ui/cn' import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLinkItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@langgenius/dify-ui/dropdown-menu' @@ -107,7 +107,16 @@ function AccountMenuSection({ children }: AccountMenuSectionProps) { return {children} } -export default function AppSelector() { +type AccountDropdownProps = { + trigger?: (props: { + isOpen: boolean + ariaLabel: string + }) => ReactElement +} + +export default function AppSelector({ + trigger, +}: AccountDropdownProps = {}) { const router = useRouter() const [aboutVisible, setAboutVisible] = useState(false) const [isAccountMenuOpen, setIsAccountMenuOpen] = useState(false) @@ -137,12 +146,23 @@ export default function AppSelector() { return (
- - - + {trigger + ? ( + + ) + : ( + + + + )} ({ + mockToastSuccess: vi.fn(), +})) + +vi.mock('@/context/app-context', () => ({ + useAppContext: vi.fn(), +})) + +vi.mock('@/context/provider-context', () => ({ + useProviderContext: vi.fn(), +})) + +vi.mock('@/context/modal-context', () => ({ + useModalContext: vi.fn(), +})) + +vi.mock('@/context/workspace-context', () => ({ + useWorkspacesContext: vi.fn(), +})) + +vi.mock('@/next/navigation', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + usePathname: vi.fn(), + useRouter: vi.fn(), + } +}) + +vi.mock('@/service/common', () => ({ + switchWorkspace: vi.fn(), +})) + +vi.mock('@/service/use-explore', () => ({ + useGetInstalledApps: vi.fn(), + useUninstallApp: vi.fn(), + useUpdateAppPinStatus: vi.fn(), +})) + +vi.mock('@langgenius/dify-ui/toast', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + toast: { + ...actual.toast, + success: mockToastSuccess, + }, + } +}) + +vi.mock('@/context/i18n', () => ({ + useDocLink: () => (path: string) => `https://docs.dify.ai${path}`, +})) + +const mockPush = vi.fn() +const mockSetShowPricingModal = vi.fn() +const mockSetShowAccountSettingModal = vi.fn() +const mockUninstall = vi.fn() +const mockUpdatePinStatus = vi.fn() +let mockPathname = '/apps' +let mockInstalledApps: InstalledApp[] = [] + +const createInstalledApp = (overrides: Partial = {}): InstalledApp => ({ + id: overrides.id ?? 'installed-1', + uninstallable: overrides.uninstallable ?? false, + is_pinned: overrides.is_pinned ?? false, + app: { + id: overrides.app?.id ?? 'app-1', + mode: overrides.app?.mode ?? AppModeEnum.CHAT, + icon_type: overrides.app?.icon_type ?? 'emoji', + icon: overrides.app?.icon ?? '🤖', + icon_background: overrides.app?.icon_background ?? '#fff', + icon_url: overrides.app?.icon_url ?? '', + name: overrides.app?.name ?? 'Alpha App', + description: overrides.app?.description ?? '', + use_icon_as_answer_icon: overrides.app?.use_icon_as_answer_icon ?? false, + }, +}) + +const appContextValue: AppContextValue = { + userProfile: { + id: 'user-1', + name: 'Evan Z', + email: 'evan@example.com', + avatar: '', + avatar_url: '', + is_password_set: true, + }, + mutateUserProfile: vi.fn(), + currentWorkspace: { + id: 'workspace-1', + name: 'Solar Studio', + plan: Plan.sandbox, + status: 'normal', + created_at: 0, + role: 'owner', + providers: [], + trial_credits: 10000, + trial_credits_used: 2500, + next_credit_reset_date: 0, + }, + isCurrentWorkspaceManager: true, + isCurrentWorkspaceOwner: true, + isCurrentWorkspaceEditor: true, + isCurrentWorkspaceDatasetOperator: false, + mutateCurrentWorkspace: vi.fn(), + langGeniusVersionInfo: { + current_env: 'testing', + current_version: '1.0.0', + latest_version: '1.0.0', + release_date: '', + release_notes: '', + version: '1.0.0', + can_auto_update: false, + }, + useSelector: vi.fn(), + isLoadingCurrentWorkspace: false, + isValidatingCurrentWorkspace: false, +} + +const renderMainNav = () => renderWithSystemFeatures(, { + systemFeatures: { branding: { enabled: false } }, +}) + +describe('MainNav', () => { + beforeEach(() => { + vi.clearAllMocks() + mockPathname = '/apps' + mockInstalledApps = [] + + ;(usePathname as Mock).mockImplementation(() => mockPathname) + ;(useRouter as Mock).mockReturnValue({ + push: mockPush, + replace: vi.fn(), + prefetch: vi.fn(), + back: vi.fn(), + forward: vi.fn(), + refresh: vi.fn(), + }) + ;(useAppContext as Mock).mockReturnValue(appContextValue) + ;(useProviderContext as Mock).mockReturnValue({ + enableBilling: true, + isEducationAccount: false, + plan: { type: Plan.sandbox }, + } as ProviderContextState) + ;(useModalContext as Mock).mockReturnValue({ + setShowPricingModal: mockSetShowPricingModal, + setShowAccountSettingModal: mockSetShowAccountSettingModal, + } as unknown as ModalContextState) + ;(useWorkspacesContext as Mock).mockReturnValue({ + workspaces: [ + { id: 'workspace-1', name: 'Solar Studio', current: true }, + { id: 'workspace-2', name: 'Evan Workspace', current: false }, + ], + }) + ;(useGetInstalledApps as Mock).mockImplementation(() => ({ + isPending: false, + data: { installed_apps: mockInstalledApps }, + })) + ;(useUninstallApp as Mock).mockReturnValue({ + mutateAsync: mockUninstall, + isPending: false, + }) + ;(useUpdateAppPinStatus as Mock).mockReturnValue({ + mutateAsync: mockUpdatePinStatus, + }) + ;(switchWorkspace as Mock).mockReturnValue(new Promise(() => {})) + }) + + it('renders primary navigation with the planned routes', () => { + renderMainNav() + + expect(screen.getByRole('link', { name: /common.mainNav.home/ })).toHaveAttribute('href', '/explore/apps') + expect(screen.getByRole('link', { name: /common.menus.apps/ })).toHaveAttribute('href', '/apps') + expect(screen.getByRole('link', { name: /common.menus.datasets/ })).toHaveAttribute('href', '/datasets') + expect(screen.getByRole('link', { name: /common.mainNav.integrations/ })).toHaveAttribute('href', '/tools') + expect(screen.getByRole('link', { name: /common.mainNav.marketplace/ })).toHaveAttribute('href', '/plugins') + }) + + it('marks the matching primary route active', () => { + mockPathname = '/datasets' + + renderMainNav() + + expect(screen.getByRole('link', { name: /common.menus.datasets/ })).toHaveClass('bg-components-main-nav-nav-button-bg-active') + }) + + it('dispatches the goto anything open event from the search button', () => { + const handleOpen = vi.fn() + window.addEventListener(GOTO_ANYTHING_OPEN_EVENT, handleOpen) + + renderMainNav() + fireEvent.click(screen.getByRole('button', { name: 'app.gotoAnything.searchTitle' })) + + expect(handleOpen).toHaveBeenCalledTimes(1) + window.removeEventListener(GOTO_ANYTHING_OPEN_EVENT, handleOpen) + }) + + it('opens workspace settings, members, provider credits, upgrade, and workspace switching actions', async () => { + renderMainNav() + + fireEvent.click(screen.getByText(/common\.mainNav\.workspace\.credits|7,500 credits/)) + expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: ACCOUNT_SETTING_TAB.PROVIDER }) + + fireEvent.click(screen.getByText('billing.upgradeBtn.encourageShort')) + expect(mockSetShowPricingModal).toHaveBeenCalled() + + fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' })) + fireEvent.click(await screen.findByText('common.mainNav.workspace.settings')) + expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: ACCOUNT_SETTING_TAB.BILLING }) + + fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' })) + fireEvent.click(await screen.findByText('common.mainNav.workspace.inviteMembers')) + expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: ACCOUNT_SETTING_TAB.MEMBERS }) + + fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' })) + fireEvent.click(await screen.findByText('Evan Workspace')) + await waitFor(() => { + expect(switchWorkspace).toHaveBeenCalledWith({ url: '/workspaces/switch', body: { tenant_id: 'workspace-2' } }) + }) + }) + + it('filters installed web apps and navigates to an installed app', () => { + mockInstalledApps = [ + createInstalledApp({ id: 'installed-1', app: { ...createInstalledApp().app, name: 'Alpha App' } }), + createInstalledApp({ id: 'installed-2', app: { ...createInstalledApp().app, name: 'Beta Tool' } }), + ] + + renderMainNav() + + fireEvent.click(screen.getByRole('button', { name: 'common.operation.search' })) + fireEvent.change(screen.getByPlaceholderText('common.mainNav.webApps.searchPlaceholder'), { + target: { value: 'beta' }, + }) + + expect(screen.queryByText('Alpha App')).not.toBeInTheDocument() + fireEvent.click(screen.getByText('Beta Tool')) + expect(mockPush).toHaveBeenCalledWith('/explore/installed/installed-2') + }) + + it('updates pin status and reuses the existing delete confirmation for installed web apps', async () => { + mockInstalledApps = [createInstalledApp()] + mockUninstall.mockResolvedValue(undefined) + mockUpdatePinStatus.mockResolvedValue(undefined) + + renderMainNav() + + fireEvent.mouseEnter(screen.getByTitle('Alpha App')) + fireEvent.click(screen.getByTestId('item-operation-trigger')) + fireEvent.click(await screen.findByText('explore.sidebar.action.pin')) + + await waitFor(() => { + expect(mockUpdatePinStatus).toHaveBeenCalledWith({ appId: 'installed-1', isPinned: true }) + }) + + fireEvent.mouseEnter(screen.getByTitle('Alpha App')) + fireEvent.click(screen.getByTestId('item-operation-trigger')) + fireEvent.click(await screen.findByText('explore.sidebar.action.delete')) + fireEvent.click(await screen.findByText('common.operation.confirm')) + + await waitFor(() => { + expect(mockUninstall).toHaveBeenCalledWith('installed-1') + expect(mockToastSuccess).toHaveBeenCalledWith('common.api.remove') + }) + }) +}) diff --git a/web/app/components/main-nav/__tests__/layout.spec.tsx b/web/app/components/main-nav/__tests__/layout.spec.tsx new file mode 100644 index 0000000000..f74d016da9 --- /dev/null +++ b/web/app/components/main-nav/__tests__/layout.spec.tsx @@ -0,0 +1,79 @@ +import type { ReactNode } from 'react' +import { render, screen } from '@testing-library/react' +import { MediaType } from '@/hooks/use-breakpoints' +import MainNavLayout from '../layout' + +type MediaTypeValue = (typeof MediaType)[keyof typeof MediaType] + +let mockMediaType: MediaTypeValue = MediaType.pc +let mockPathname = '/apps' + +vi.mock('@/hooks/use-breakpoints', () => ({ + default: () => mockMediaType, + MediaType: { + mobile: 'mobile', + tablet: 'tablet', + pc: 'pc', + }, +})) + +vi.mock('@/next/navigation', () => ({ + usePathname: () => mockPathname, +})) + +vi.mock('@/context/event-emitter', () => ({ + useEventEmitterContextContext: () => ({ + eventEmitter: undefined, + }), +})) + +vi.mock('@/context/workspace-context-provider', () => ({ + WorkspaceProvider: ({ children }: { children: ReactNode }) =>
{children}
, +})) + +vi.mock('@/app/components/header', () => ({ + default: () =>
Header
, +})) + +vi.mock('@/app/components/header/header-wrapper', () => ({ + default: ({ children }: { children: ReactNode }) =>
{children}
, +})) + +vi.mock('../index', () => ({ + default: ({ className }: { className?: string }) => , +})) + +describe('MainNavLayout', () => { + beforeEach(() => { + mockMediaType = MediaType.pc + mockPathname = '/apps' + localStorage.clear() + }) + + it('renders desktop main nav instead of the desktop header', () => { + render(
content
) + + expect(screen.getByTestId('main-nav')).toBeInTheDocument() + expect(screen.queryByTestId('desktop-header')).not.toBeInTheDocument() + expect(screen.getByText('content')).toBeInTheDocument() + }) + + it('keeps the current header on mobile', () => { + mockMediaType = MediaType.mobile + + render(
content
) + + expect(screen.getByTestId('header-wrapper')).toBeInTheDocument() + expect(screen.getByTestId('desktop-header')).toBeInTheDocument() + expect(screen.queryByTestId('main-nav')).not.toBeInTheDocument() + }) + + it('hides the desktop main nav on fullscreen workflow canvases', () => { + mockPathname = '/apps/app-1/workflow' + localStorage.setItem('workflow-canvas-maximize', 'true') + + render(
content
) + + expect(screen.getByTestId('main-nav')).toHaveClass('hidden') + }) +}) diff --git a/web/app/components/main-nav/index.tsx b/web/app/components/main-nav/index.tsx new file mode 100644 index 0000000000..c3d07521af --- /dev/null +++ b/web/app/components/main-nav/index.tsx @@ -0,0 +1,634 @@ +'use client' + +import type { ReactNode } from 'react' +import type { InstalledApp } from '@/models/explore' +import { + AlertDialog, + AlertDialogActions, + AlertDialogCancelButton, + AlertDialogConfirmButton, + AlertDialogContent, + AlertDialogDescription, + AlertDialogTitle, +} from '@langgenius/dify-ui/alert-dialog' +import { Avatar } from '@langgenius/dify-ui/avatar' +import { cn } from '@langgenius/dify-ui/cn' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLinkItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@langgenius/dify-ui/dropdown-menu' +import { toast } from '@langgenius/dify-ui/toast' +import { useSuspenseQuery } from '@tanstack/react-query' +import { useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import AppIcon from '@/app/components/base/app-icon' +import DifyLogo from '@/app/components/base/logo/dify-logo' +import { Plan } from '@/app/components/billing/type' +import ItemOperation from '@/app/components/explore/item-operation' +import { GOTO_ANYTHING_OPEN_EVENT } from '@/app/components/goto-anything/hooks' +import AccountAbout from '@/app/components/header/account-about' +import AccountDropdown from '@/app/components/header/account-dropdown' +import Compliance from '@/app/components/header/account-dropdown/compliance' +import Support from '@/app/components/header/account-dropdown/support' +import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' +import GithubStar from '@/app/components/header/github-star' +import Indicator from '@/app/components/header/indicator' +import PlanBadge from '@/app/components/header/plan-badge' +import { IS_CLOUD_EDITION } from '@/config' +import { useAppContext } from '@/context/app-context' +import { useDocLink } from '@/context/i18n' +import { useModalContext } from '@/context/modal-context' +import { useProviderContext } from '@/context/provider-context' +import { useWorkspacesContext } from '@/context/workspace-context' +import { env } from '@/env' +import Link from '@/next/link' +import { usePathname, useRouter } from '@/next/navigation' +import { switchWorkspace } from '@/service/common' +import { systemFeaturesQueryOptions } from '@/service/system-features' +import { useGetInstalledApps, useUninstallApp, useUpdateAppPinStatus } from '@/service/use-explore' +import { basePath } from '@/utils/var' + +type MainNavProps = { + className?: string +} + +type MainNavItem = { + href: string + label: string + active: (pathname: string) => boolean + icon: string + activeIcon: string +} + +const navItemClassName = 'group relative flex h-9 items-center gap-2 rounded-xl p-2 transition-colors' + +const activeNavItemClassName = [ + 'overflow-hidden border border-components-main-nav-glass-edge-reflection-first bg-components-main-nav-nav-button-bg-active', + 'bg-[linear-gradient(98deg,var(--color-components-main-nav-glass-surface-first)_0%,var(--color-components-main-nav-glass-surface-middle-1)_18%,var(--color-components-main-nav-glass-surface-middle-2)_59%,var(--color-components-main-nav-glass-surface-end)_100%)]', + 'system-md-semibold text-components-main-nav-text-active backdrop-blur-[5px]', + 'shadow-[0px_4px_8px_0px_var(--color-components-main-nav-glass-shadow-reflection-glow),0px_12px_16px_-4px_var(--color-shadow-shadow-5),0px_4px_6px_-2px_var(--color-shadow-shadow-1),0px_10px_16px_-4px_var(--color-components-main-nav-glass-shadow-reflection)]', + 'before:pointer-events-none before:absolute before:inset-[-1px] before:rounded-xl before:border before:border-components-main-nav-glass-edge-highlight-first before:shadow-[inset_0px_0px_8px_0px_var(--color-components-main-nav-glass-inner-glow)] before:content-[""]', +].join(' ') + +const inactiveNavItemClassName = 'system-md-medium bg-components-main-nav-nav-button-bg text-components-main-nav-text hover:bg-state-base-hover hover:text-components-main-nav-text' + +const getWorkspaceInitial = (name?: string) => name?.[0]?.toLocaleUpperCase() || '?' + +const getRemainingCredits = (total: number, used: number) => Math.max(total - used, 0) + +const formatCredits = (value: number) => new Intl.NumberFormat().format(value) + +const WorkspaceIcon = ({ + name, + className, +}: { + name?: string + className?: string +}) => ( +
+ {getWorkspaceInitial(name)} +
+) + +const MenuIcon = ({ + className, +}: { + className?: string +}) => ( + +) + +const NavIcon = ({ + icon, + className, +}: { + icon: string + className?: string +}) => ( + +) + +const WorkspaceMenuItemContent = ({ + icon, + label, + trailing, +}: { + icon: ReactNode + label: ReactNode + trailing?: ReactNode +}) => ( + <> + {icon} + {label} + {trailing} + +) + +const WorkspaceCard = () => { + const { t } = useTranslation() + const { currentWorkspace } = useAppContext() + const { workspaces } = useWorkspacesContext() + const { enableBilling, plan } = useProviderContext() + const { setShowPricingModal, setShowAccountSettingModal } = useModalContext() + const credits = getRemainingCredits(currentWorkspace.trial_credits, currentWorkspace.trial_credits_used) + const isFreePlan = plan.type === Plan.sandbox + + const handlePlanClick = () => { + if (isFreePlan) + setShowPricingModal() + else + setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.BILLING }) + } + + const handleSwitchWorkspace = async (tenant_id: string) => { + try { + if (currentWorkspace.id === tenant_id) + return + + await switchWorkspace({ url: '/workspaces/switch', body: { tenant_id } }) + toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' })) + location.assign(`${location.origin}${basePath}`) + } + catch { + toast.error(t('actionMsg.modifiedUnsuccessfully', { ns: 'common' })) + } + } + + return ( + + + )} + > +
+ +
+
+ {currentWorkspace.name} + +
+
+ +
+
+ + {enableBilling && ( + + )} +
+
+ + +
+ +
+
{currentWorkspace.name}
+ +
+
+ setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.BILLING })} + > + } label={t('mainNav.workspace.settings', { ns: 'common' })} /> + + setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.MEMBERS })} + > + } label={t('mainNav.workspace.inviteMembers', { ns: 'common' })} /> + +
+ {workspaces.length > 0 && ( + <> + + +
+ {t('mainNav.workspace.switchWorkspace', { ns: 'common' })} +
+ {workspaces.map(workspace => ( + { + void handleSwitchWorkspace(workspace.id) + }} + > + } + label={workspace.name} + trailing={workspace.current ? : undefined} + /> + + ))} +
+ + )} +
+
+ ) +} + +const MainNavLink = ({ + item, + pathname, +}: { + item: MainNavItem + pathname: string +}) => { + const activated = item.active(pathname) + return ( + + + {item.label} + + ) +} + +const MainNavSearchButton = () => { + const { t } = useTranslation() + + return ( + + ) +} + +const WebAppItem = ({ + app, + isSelected, + onDelete, + onTogglePin, +}: { + app: InstalledApp + isSelected: boolean + onDelete: (id: string) => void + onTogglePin: () => void +}) => { + const router = useRouter() + const url = `/explore/installed/${app.id}` + const [isHovering, setIsHovering] = useState(false) + + return ( +
router.push(url)} + onMouseEnter={() => setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + title={app.app.name} + > +
+ + {app.app.name} +
+
e.stopPropagation()}> + onDelete(app.id)} + /> +
+
+ ) +} + +const WebAppsSection = () => { + const { t } = useTranslation() + const pathname = usePathname() + const { data, isPending } = useGetInstalledApps() + const installedApps = useMemo(() => data?.installed_apps ?? [], [data?.installed_apps]) + const { mutateAsync: uninstallApp, isPending: isUninstalling } = useUninstallApp() + const { mutateAsync: updatePinStatus } = useUpdateAppPinStatus() + const [searchVisible, setSearchVisible] = useState(false) + const [searchText, setSearchText] = useState('') + const [showConfirm, setShowConfirm] = useState(false) + const [currentId, setCurrentId] = useState('') + + const filteredApps = useMemo(() => { + const normalizedSearch = searchText.trim().toLowerCase() + if (!normalizedSearch) + return installedApps + + return installedApps.filter(item => item.app.name.toLowerCase().includes(normalizedSearch)) + }, [installedApps, searchText]) + + const handleDelete = async () => { + await uninstallApp(currentId) + setShowConfirm(false) + toast.success(t('api.remove', { ns: 'common' })) + } + + const handleUpdatePinStatus = async (id: string, isPinned: boolean) => { + await updatePinStatus({ appId: id, isPinned }) + toast.success(t('api.success', { ns: 'common' })) + } + + return ( +
+
+ +
+ +
+
+ {searchVisible && ( +
+ setSearchText(e.target.value)} + placeholder={t('mainNav.webApps.searchPlaceholder', { ns: 'common' })} + className="h-8 w-full rounded-lg border border-transparent bg-components-input-bg-normal px-2 system-sm-regular text-text-secondary outline-none placeholder:text-text-quaternary hover:border-components-input-border-hover focus:border-components-input-border-active" + /> +
+ )} +
+ {isPending && ( +
{t('loading', { ns: 'common' })}
+ )} + {!isPending && filteredApps.length === 0 && ( +
+ {searchText ? t('mainNav.webApps.noResults', { ns: 'common' }) : t('sidebar.noApps.title', { ns: 'explore' })} +
+ )} + {filteredApps.map(app => ( + { + setCurrentId(id) + setShowConfirm(true) + }} + onTogglePin={() => { + void handleUpdatePinStatus(app.id, !app.is_pinned) + }} + /> + ))} +
+ + +
+ + {t('sidebar.delete.title', { ns: 'explore' })} + + + {t('sidebar.delete.content', { ns: 'explore' })} + +
+ + + {t('operation.cancel', { ns: 'common' })} + + + {t('operation.confirm', { ns: 'common' })} + + +
+
+
+ ) +} + +const HelpMenu = () => { + const { t } = useTranslation() + const docLink = useDocLink() + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) + const { langGeniusVersionInfo, isCurrentWorkspaceOwner } = useAppContext() + const [aboutVisible, setAboutVisible] = useState(false) + const [open, setOpen] = useState(false) + + return ( + <> + + + + + + {!systemFeatures.branding.enabled && ( + <> + + + + {t('userProfile.helpCenter', { ns: 'common' })} + + setOpen(false)} /> + {IS_CLOUD_EDITION && isCurrentWorkspaceOwner && } + + + + + + {t('userProfile.roadmap', { ns: 'common' })} + + + + {t('userProfile.github', { ns: 'common' })} +
+ + +
+
+ {env.NEXT_PUBLIC_SITE_ABOUT !== 'hide' && ( + { + setAboutVisible(true) + setOpen(false) + }} + > + + {t('userProfile.about', { ns: 'common' })} +
+
{langGeniusVersionInfo.current_version}
+ +
+
+ )} +
+ + )} +
+
+ {aboutVisible && setAboutVisible(false)} langGeniusVersionInfo={langGeniusVersionInfo} />} + + ) +} + +const MainNav = ({ + className, +}: MainNavProps) => { + const { t } = useTranslation() + const pathname = usePathname() + const { userProfile } = useAppContext() + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) + const navItems = useMemo(() => [ + { + href: '/explore/apps', + label: t('mainNav.home', { ns: 'common' }), + active: path => path.startsWith('/explore'), + icon: 'i-custom-vender-main-nav-home', + activeIcon: 'i-custom-vender-main-nav-home-active', + }, + { + href: '/apps', + label: t('menus.apps', { ns: 'common' }), + active: path => path.startsWith('/apps') || path.startsWith('/app/'), + icon: 'i-custom-vender-main-nav-studio', + activeIcon: 'i-custom-vender-main-nav-studio-active', + }, + { + href: '/datasets', + label: t('menus.datasets', { ns: 'common' }), + active: path => path.startsWith('/datasets'), + icon: 'i-custom-vender-main-nav-knowledge', + activeIcon: 'i-custom-vender-main-nav-knowledge-active', + }, + { + href: '/tools', + label: t('mainNav.integrations', { ns: 'common' }), + active: path => path.startsWith('/tools'), + icon: 'i-custom-vender-main-nav-integrations', + activeIcon: 'i-custom-vender-main-nav-integrations-active', + }, + { + href: '/plugins', + label: t('mainNav.marketplace', { ns: 'common' }), + active: path => path.startsWith('/plugins'), + icon: 'i-custom-vender-main-nav-marketplace', + activeIcon: 'i-custom-vender-main-nav-marketplace-active', + }, + ], [t]) + + const renderLogo = () => ( +

+ + {systemFeatures.branding.enabled && systemFeatures.branding.application_title ? systemFeatures.branding.application_title : 'Dify'} + {systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo + ? ( + logo + ) + : } + +

+ ) + + return ( + + ) +} + +export default MainNav diff --git a/web/app/components/main-nav/layout.tsx b/web/app/components/main-nav/layout.tsx new file mode 100644 index 0000000000..df8e046c97 --- /dev/null +++ b/web/app/components/main-nav/layout.tsx @@ -0,0 +1,64 @@ +'use client' + +import type { ReactNode } from 'react' +import type { EventEmitterValue } from '@/context/event-emitter' +import { cn } from '@langgenius/dify-ui/cn' +import { useState } from 'react' +import Header from '@/app/components/header' +import HeaderWrapper from '@/app/components/header/header-wrapper' +import { useEventEmitterContextContext } from '@/context/event-emitter' +import { WorkspaceProvider } from '@/context/workspace-context-provider' +import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' +import { usePathname } from '@/next/navigation' +import MainNav from './index' + +type MainNavLayoutProps = { + children: ReactNode +} + +const MainNavLayout = ({ + children, +}: MainNavLayoutProps) => { + const media = useBreakpoints() + const isMobile = media === MediaType.mobile + const pathname = usePathname() + const inWorkflowCanvas = pathname.endsWith('/workflow') + const isPipelineCanvas = pathname.endsWith('/pipeline') + const [hideMainNav, setHideMainNav] = useState(() => ( + globalThis.localStorage?.getItem('workflow-canvas-maximize') === 'true' + )) + const { eventEmitter } = useEventEmitterContextContext() + + eventEmitter?.useSubscription((v: EventEmitterValue) => { + if (typeof v !== 'string' && v.type === 'workflow-canvas-maximize' && typeof v.payload === 'boolean') + setHideMainNav(v.payload) + }) + + if (isMobile) { + return ( + <> + +
+ + {children} + + ) + } + + return ( +
+ + + +
+ {children} +
+
+ ) +} + +export default MainNavLayout diff --git a/web/i18n/en-US/common.json b/web/i18n/en-US/common.json index d4f72c242d..3fdfc77334 100644 --- a/web/i18n/en-US/common.json +++ b/web/i18n/en-US/common.json @@ -214,6 +214,17 @@ "license.expiring_plural": "Expiring in {{count}} days", "license.unlimited": "Unlimited", "loading": "Loading", + "mainNav.help.openMenu": "Open help menu", + "mainNav.home": "Home", + "mainNav.integrations": "Integrations", + "mainNav.marketplace": "Marketplace", + "mainNav.webApps.noResults": "No web apps found", + "mainNav.webApps.searchPlaceholder": "Search web apps", + "mainNav.workspace.credits": "{{count}} credits", + "mainNav.workspace.inviteMembers": "Invite and manage members", + "mainNav.workspace.openMenu": "Open workspace menu", + "mainNav.workspace.settings": "Settings", + "mainNav.workspace.switchWorkspace": "Switch workspace", "members.admin": "Admin", "members.adminTip": "Can build apps & manage team settings", "members.builder": "Builder",