diff --git a/docs/main-nav-gating-follow-ups.md b/docs/main-nav-gating-follow-ups.md new file mode 100644 index 0000000000..7e3439dd51 --- /dev/null +++ b/docs/main-nav-gating-follow-ups.md @@ -0,0 +1,138 @@ +# Main Nav Gating Follow-ups + +Context: the desktop MainNav rewrite moved several workspace, account, tools, and marketplace entry points out of the old header/account-setting layout. These notes track product-contract questions that should be resolved before treating the rewrite as behavior-complete. + +## 1. Account-setting modal naming and moved destinations + +Current branch behavior: + +- `setShowAccountSettingModal(PROVIDER)` routes to `/tools?section=provider`. +- `setShowAccountSettingModal(DATA_SOURCE)` routes to `/tools?section=data-source`. +- `setShowAccountSettingModal(API_BASED_EXTENSION)` routes to `/tools?section=api-based-extension`. + +Old behavior: these calls opened the account-setting modal and switched to the matching tab. + +Question: + +- Since Provider, Data Source, and API-based Extension are no longer inside Account Settings in the new design, should this API still be named `setShowAccountSettingModal` for those destinations? + +Follow-up decision needed: + +- Either keep the compatibility shim but document that these payloads are now route destinations, or introduce a clearer navigation API for integration destinations and update call sites intentionally. +- Re-check call sites launched from workflows, datasets, and app configuration. Some contexts may expect an in-place modal instead of leaving the current page. + +## 2. Integrations sidebar disabled entries + +Current branch behavior: + +- Integrations includes disabled entries for Trigger, Agent Strategy, and Extension. +- These are visible but not actionable. + +Status: + +- Currently being handled separately. + +Follow-up decision needed: + +- Decide whether disabled future entries should remain visible, be hidden until supported, or be gated by feature flags/edition/role. +- If they stay visible, define the tooltip or disabled-state copy so users understand why the option is unavailable. + +## 3. Plugin and marketplace status parity + +Old header behavior: + +- `PluginsNav` showed plugin install progress and error state through the installing icon and red indicator. +- Installing tasks showed the downloading icon. +- Failed or erroring install tasks showed the red status indicator. + +Current MainNav behavior: + +- MainNav has a Marketplace link, but it does not surface plugin installing/error state. + +Follow-up decision needed: + +- Decide whether MainNav Marketplace should preserve the old plugin task status indicator. +- If yes, reuse the existing `usePluginTaskStatus` behavior instead of creating a parallel status source. + +## 4. Mobile/default account language entry + +Current branch behavior: + +- Desktop MainNav account menu includes Language and Timezone submenus. +- Mobile still uses the default header/account dropdown path. +- The default account dropdown does not expose the Language settings entry. + +Decision: + +- Preserve the old language-access contract across breakpoints. +- The desktop MainNav path is acceptable; the missing case is mainly mobile/default account dropdown, including dataset-operator users on mobile. + +Follow-up decision needed: + +- Add an equivalent language entry to the default/mobile account path, or otherwise ensure mobile users can still reach language settings. +- Keep this as gate-contract parity, not a visual requirement to recreate the old Account Settings sidebar. + +## 5. Apps and Datasets quick-switch/create parity + +Old header behavior: + +- `AppNav` could show the current app, list more apps, load more results, and launch create-app flows from the header nav. +- `DatasetNav` could show the current dataset, list more datasets, load more results, and launch dataset creation from the header nav. + +Current MainNav behavior: + +- Apps and Datasets are static navigation links. +- App/dataset quick switching and create actions are not present in the desktop MainNav. + +Follow-up decision needed: + +- Decide whether the new design intentionally removes these quick-switch/create affordances. +- If not, add equivalent behavior in the MainNav flow without copying the old header UI directly. + +## 6. Branding-gated Help and Support behavior + +Old account dropdown behavior: + +- When `systemFeatures.branding.enabled` is `true`, the whole Dify help/community group is hidden. +- That hidden group includes Docs, Support, Compliance, Roadmap, GitHub, and About. + +Current MainNav behavior: + +- HelpMenu keeps the trigger visible, but its content is gated by `!systemFeatures.branding.enabled`. +- This can produce an empty Help popup when branding is enabled. + +Open question: + +- The old coarse gate also hides Support, but Support can contain instance-specific channels such as configured Zendesk or support email in addition to Dify forum/Discord links. +- Confirm whether branded deployments should hide Support entirely, or keep configured customer support channels while hiding Dify official/community links. + +Follow-up decision needed: + +- If strict old parity is required, hide the HelpMenu trigger when branding is enabled. +- If branded deployments should retain support access, split Support into customer-support and Dify-community items with separate gates. + +## 7. Paid plan Billing access from workspace plan + +Old header behavior: + +- The header plan badge was clickable. +- For sandbox/free plans, clicking the badge opened the pricing modal. +- For non-sandbox paid plans, clicking the badge opened Account Settings on the Billing tab. + +Current MainNav behavior: + +- Sandbox/free plans have an explicit Upgrade action in the WorkspaceCard credit row. +- Non-sandbox paid plans show the workspace plan badge, but the badge is display-only. +- The WorkspaceCard Settings menu item routes to Account Settings on the Billing tab. +- Invite Members remains the Members entry, so Settings and Invite Members do not duplicate the same destination. + +Open question: + +- Confirm whether product still wants the plan badge itself to be clickable, or whether the Settings-to-Billing menu item is the intended MainNav access path. +- Avoid making the plan badge itself clickable unless the interaction is explicitly approved, because the WorkspaceCard already uses a button to open the workspace menu. + +Recommended default: + +- Keep sandbox/free behavior as the explicit Upgrade action. +- Keep the plan badge display-only. +- Use the WorkspaceCard Settings item as the Billing entry. diff --git a/web/app/components/header/account-dropdown/index.tsx b/web/app/components/header/account-dropdown/index.tsx index 011f18d08f..1925651a66 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 { ReactElement, ReactNode } from 'react' +import type { ReactElement } from 'react' import { Avatar } from '@langgenius/dify-ui/avatar' import { cn } from '@langgenius/dify-ui/cn' import { @@ -23,14 +23,12 @@ type AccountDropdownProps = { isOpen: boolean ariaLabel: string }) => ReactElement - mainNavBadge?: ReactNode variant?: 'default' | 'mainNav' } const mainNavMenuPopupClassName = 'w-60 max-w-80 overflow-hidden bg-components-panel-bg-blur! p-0! backdrop-blur-[5px]' export default function AppSelector({ - mainNavBadge, trigger, variant = 'default', }: AccountDropdownProps = {}) { @@ -82,7 +80,7 @@ export default function AppSelector({ popupClassName={variant === 'mainNav' ? mainNavMenuPopupClassName : 'w-60 max-w-80 bg-components-panel-bg-blur! py-0! backdrop-blur-xs'} > {variant === 'mainNav' - ? + ? : ( setIsAccountMenuOpen(false)} diff --git a/web/app/components/header/account-dropdown/main-nav-menu-content.tsx b/web/app/components/header/account-dropdown/main-nav-menu-content.tsx index f7adf08d9e..dff780c42d 100644 --- a/web/app/components/header/account-dropdown/main-nav-menu-content.tsx +++ b/web/app/components/header/account-dropdown/main-nav-menu-content.tsx @@ -21,8 +21,10 @@ import { toast } from '@langgenius/dify-ui/toast' import { useTheme } from 'next-themes' import { useState } from 'react' import { useTranslation } from 'react-i18next' +import PremiumBadge from '@/app/components/base/premium-badge' import { useAppContext } from '@/context/app-context' import { useLocale } from '@/context/i18n' +import { useProviderContext } from '@/context/provider-context' import { setLocaleOnClient } from '@/i18n-config' import { languages } from '@/i18n-config/language' import Link from '@/next/link' @@ -190,29 +192,33 @@ function LanguageSubmenu() { } type MainNavMenuContentProps = { - mainNavBadge?: ReactNode onLogout: () => Promise } export function MainNavMenuContent({ - mainNavBadge, onLogout, }: MainNavMenuContentProps) { const { t } = useTranslation() const { userProfile } = useAppContext() + const { isEducationAccount } = useProviderContext() return ( <> -
+
-
{userProfile.name}
- {mainNavBadge} +
{userProfile.name}
+ {isEducationAccount && ( + + + EDU + + )}
{userProfile.email}
- +
diff --git a/web/app/components/main-nav/__tests__/index.spec.tsx b/web/app/components/main-nav/__tests__/index.spec.tsx index cca8b221f0..f594f2f7ca 100644 --- a/web/app/components/main-nav/__tests__/index.spec.tsx +++ b/web/app/components/main-nav/__tests__/index.spec.tsx @@ -71,6 +71,7 @@ vi.mock('@langgenius/dify-ui/toast', async (importOriginal) => { }) vi.mock('@/context/i18n', () => ({ + useLocale: () => 'en-US', useDocLink: () => (path: string) => `https://docs.dify.ai${path}`, })) @@ -171,6 +172,7 @@ describe('MainNav', () => { ;(useProviderContext as Mock).mockReturnValue({ enableBilling: true, isEducationAccount: false, + isEducationWorkspace: false, isFetchedPlan: true, plan: { type: Plan.sandbox }, } as ProviderContextState) @@ -210,6 +212,91 @@ describe('MainNav', () => { expect(screen.getByRole('link', { name: /common.mainNav.marketplace/ })).toHaveAttribute('href', '/plugins') }) + it('renders the desktop environment tag from the old header contract', () => { + ;(useAppContext as Mock).mockReturnValue({ + ...appContextValue, + langGeniusVersionInfo: { + ...appContextValue.langGeniusVersionInfo, + current_env: 'TESTING', + }, + }) + + renderMainNav() + + expect(screen.getByText('common.environment.testing')).toBeInTheDocument() + }) + + it('does not reserve environment tag space when the environment is not shown', () => { + const { container } = renderMainNav() + + expect(screen.queryByText('common.environment.testing')).not.toBeInTheDocument() + expect(screen.queryByText('common.environment.development')).not.toBeInTheDocument() + expect(container.querySelector('.relative.z-30')).not.toBeInTheDocument() + }) + + it('shows the user education badge in the account popup without adding the workspace plan there', async () => { + ;(useProviderContext as Mock).mockReturnValue({ + enableBilling: true, + isEducationAccount: true, + isEducationWorkspace: false, + isFetchedPlan: true, + plan: { type: Plan.sandbox }, + } as ProviderContextState) + + renderMainNav() + + fireEvent.click(screen.getByRole('button', { name: 'common.account.account' })) + + expect(await screen.findByText('EDU')).toBeInTheDocument() + expect(screen.getByText('evan@example.com')).toBeInTheDocument() + expect(screen.getAllByText(Plan.team)).toHaveLength(1) + }) + + it('hides app and tools entries for dataset operators', () => { + ;(useAppContext as Mock).mockReturnValue({ + ...appContextValue, + currentWorkspace: { + ...appContextValue.currentWorkspace, + role: 'dataset_operator', + }, + isCurrentWorkspaceDatasetOperator: true, + isCurrentWorkspaceEditor: false, + isCurrentWorkspaceManager: false, + isCurrentWorkspaceOwner: false, + }) + + renderMainNav() + + expect(screen.queryByRole('link', { name: /common.mainNav.home/ })).not.toBeInTheDocument() + expect(screen.queryByRole('link', { name: /common.menus.apps/ })).not.toBeInTheDocument() + expect(screen.getByRole('link', { name: /common.menus.datasets/ })).toHaveAttribute('href', '/datasets') + expect(screen.queryByRole('link', { name: /common.mainNav.integrations/ })).not.toBeInTheDocument() + expect(screen.getByRole('link', { name: /common.mainNav.marketplace/ })).toHaveAttribute('href', '/plugins') + expect(screen.queryByRole('button', { name: 'explore.sidebar.webApps' })).not.toBeInTheDocument() + }) + + it('hides datasets for members without editor or dataset-operator access', () => { + ;(useAppContext as Mock).mockReturnValue({ + ...appContextValue, + currentWorkspace: { + ...appContextValue.currentWorkspace, + role: 'normal', + }, + isCurrentWorkspaceDatasetOperator: false, + isCurrentWorkspaceEditor: false, + isCurrentWorkspaceManager: false, + isCurrentWorkspaceOwner: false, + }) + + renderMainNav() + + expect(screen.getByRole('link', { name: /common.mainNav.home/ })).toBeInTheDocument() + expect(screen.getByRole('link', { name: /common.menus.apps/ })).toBeInTheDocument() + expect(screen.queryByRole('link', { name: /common.menus.datasets/ })).not.toBeInTheDocument() + expect(screen.getByRole('link', { name: /common.mainNav.integrations/ })).toBeInTheDocument() + expect(screen.getByRole('link', { name: /common.mainNav.marketplace/ })).toBeInTheDocument() + }) + it('marks the matching primary route active', () => { mockPathname = '/datasets' @@ -272,6 +359,60 @@ describe('MainNav', () => { }) }) + it('hides the upgrade shortcut for paid plans', () => { + ;(useProviderContext as Mock).mockReturnValue({ + enableBilling: true, + isEducationAccount: false, + isEducationWorkspace: false, + isFetchedPlan: true, + plan: { type: Plan.team }, + } as ProviderContextState) + + renderMainNav() + + expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument() + }) + + it('limits workspace settings and invite actions by role', async () => { + ;(useAppContext as Mock).mockReturnValue({ + ...appContextValue, + currentWorkspace: { + ...appContextValue.currentWorkspace, + role: 'normal', + }, + isCurrentWorkspaceManager: false, + isCurrentWorkspaceOwner: false, + }) + + renderMainNav() + + fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' })) + + expect(await screen.findByText('common.mainNav.workspace.settings')).toBeInTheDocument() + expect(screen.queryByText('common.mainNav.workspace.inviteMembers')).not.toBeInTheDocument() + }) + + it('hides workspace settings actions for dataset operators', () => { + ;(useAppContext as Mock).mockReturnValue({ + ...appContextValue, + currentWorkspace: { + ...appContextValue.currentWorkspace, + role: 'dataset_operator', + }, + isCurrentWorkspaceDatasetOperator: true, + isCurrentWorkspaceEditor: false, + isCurrentWorkspaceManager: false, + isCurrentWorkspaceOwner: false, + }) + + renderMainNav() + + fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' })) + + expect(screen.queryByText('common.mainNav.workspace.settings')).not.toBeInTheDocument() + expect(screen.queryByText('common.mainNav.workspace.inviteMembers')).not.toBeInTheDocument() + }) + it('filters installed web apps and navigates to an installed app', () => { mockInstalledApps = [ createInstalledApp({ id: 'installed-1', app: { ...createInstalledApp().app, name: 'Alpha App' } }), diff --git a/web/app/components/main-nav/components/__tests__/workspace-card.spec.tsx b/web/app/components/main-nav/components/__tests__/workspace-card.spec.tsx index a380aaea1d..2f3a15b42b 100644 --- a/web/app/components/main-nav/components/__tests__/workspace-card.spec.tsx +++ b/web/app/components/main-nav/components/__tests__/workspace-card.spec.tsx @@ -8,6 +8,7 @@ import { useAppContext } from '@/context/app-context' import { useModalContext } from '@/context/modal-context' import { useProviderContext } from '@/context/provider-context' import { useWorkspacesContext } from '@/context/workspace-context' +import { LicenseStatus } from '@/types/feature' import WorkspaceCard from '../workspace-card' vi.mock('@/config', async (importOriginal) => { @@ -86,6 +87,7 @@ describe('WorkspaceCard', () => { vi.mocked(useProviderContext).mockReturnValue({ enableBilling: true, isEducationAccount: false, + isEducationWorkspace: false, isFetchedPlan: true, plan: { type: Plan.sandbox }, } as ProviderContextState) @@ -107,4 +109,26 @@ describe('WorkspaceCard', () => { expect(screen.queryByRole('button', { name: /common\.mainNav\.workspace\.credits/ })).not.toBeInTheDocument() expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument() }) + + it('shows the license status instead of a billing plan when billing is disabled', () => { + vi.mocked(useProviderContext).mockReturnValue({ + enableBilling: false, + isEducationAccount: false, + isEducationWorkspace: false, + isFetchedPlan: false, + plan: { type: Plan.sandbox }, + } as ProviderContextState) + + renderWithSystemFeatures(, { + systemFeatures: { + license: { + status: LicenseStatus.ACTIVE, + expired_at: null, + }, + }, + }) + + expect(screen.getByText('Enterprise')).toBeInTheDocument() + expect(screen.queryByText(Plan.sandbox)).not.toBeInTheDocument() + }) }) diff --git a/web/app/components/main-nav/components/account-section.tsx b/web/app/components/main-nav/components/account-section.tsx index 96aec31b74..43d7167c82 100644 --- a/web/app/components/main-nav/components/account-section.tsx +++ b/web/app/components/main-nav/components/account-section.tsx @@ -1,24 +1,15 @@ 'use client' -import type { Plan } from '@/app/components/billing/type' import { Avatar } from '@langgenius/dify-ui/avatar' import { cn } from '@langgenius/dify-ui/cn' import AccountDropdown from '@/app/components/header/account-dropdown' import { useAppContext } from '@/context/app-context' -import WorkspacePlanBadge from './workspace-plan-badge' -type AccountSectionProps = { - workspacePlan: Plan -} - -const AccountSection = ({ - workspacePlan, -}: AccountSectionProps) => { +const AccountSection = () => { const { userProfile } = useAppContext() return ( } variant="mainNav" trigger={({ isOpen, ariaLabel }) => ( - {IS_CLOUD_EDITION && ( + {showCloudBilling && (
- {enableBilling && ( + {showUpgradeAction && ( - - + {showWorkspaceSettings && ( + + )} + {showInviteMembers && ( + + )}
{workspaces.length > 0 && (
diff --git a/web/app/components/main-nav/components/workspace-plan-badge.tsx b/web/app/components/main-nav/components/workspace-plan-badge.tsx index 35b9044578..153447fdab 100644 --- a/web/app/components/main-nav/components/workspace-plan-badge.tsx +++ b/web/app/components/main-nav/components/workspace-plan-badge.tsx @@ -1,5 +1,6 @@ import Badge from '@/app/components/base/badge' import { Plan } from '@/app/components/billing/type' +import { useProviderContext } from '@/context/provider-context' type WorkspacePlanBadgeProps = { plan: Plan @@ -8,9 +9,17 @@ type WorkspacePlanBadgeProps = { const WorkspacePlanBadge = ({ plan, }: WorkspacePlanBadgeProps) => { + const { isEducationWorkspace, isFetchedPlan } = useProviderContext() + + if (!isFetchedPlan) + return null + return ( - {plan === Plan.professional ? 'pro' : plan} + + {plan === Plan.professional && isEducationWorkspace && } + {plan === Plan.professional ? 'pro' : plan} + ) } diff --git a/web/app/components/main-nav/index.tsx b/web/app/components/main-nav/index.tsx index 45092cd9fb..e9500d1814 100644 --- a/web/app/components/main-nav/index.tsx +++ b/web/app/components/main-nav/index.tsx @@ -1,15 +1,13 @@ 'use client' import type { MainNavItem, MainNavProps } from './types' -import type { Plan } from '@/app/components/billing/type' import { cn } from '@langgenius/dify-ui/cn' import { useSuspenseQuery } from '@tanstack/react-query' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import DifyLogo from '@/app/components/base/logo/dify-logo' +import EnvNav from '@/app/components/header/env-nav' import { useAppContext } from '@/context/app-context' -import { useProviderContext } from '@/context/provider-context' -import { useWorkspacesContext } from '@/context/workspace-context' import Link from '@/next/link' import { usePathname } from '@/next/navigation' import { systemFeaturesQueryOptions } from '@/service/system-features' @@ -25,40 +23,50 @@ const MainNav = ({ }: MainNavProps) => { const { t } = useTranslation() const pathname = usePathname() - const { currentWorkspace } = useAppContext() - const { plan } = useProviderContext() - const { workspaces } = useWorkspacesContext() + const { langGeniusVersionInfo, isCurrentWorkspaceDatasetOperator, isCurrentWorkspaceEditor } = useAppContext() const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) - const workspacePlan = (workspaces.find(workspace => workspace.current)?.plan || currentWorkspace.plan || plan.type) as Plan + const showEnvTag = langGeniusVersionInfo.current_env === 'TESTING' || langGeniusVersionInfo.current_env === 'DEVELOPMENT' 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?section=provider', - 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', - }, + ...(!isCurrentWorkspaceDatasetOperator + ? [ + { + href: '/explore/apps', + label: t('mainNav.home', { ns: 'common' }), + active: (path: string) => 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: string) => path.startsWith('/apps') || path.startsWith('/app/'), + icon: 'i-custom-vender-main-nav-studio', + activeIcon: 'i-custom-vender-main-nav-studio-active', + }, + ] + : []), + ...((isCurrentWorkspaceEditor || isCurrentWorkspaceDatasetOperator) + ? [ + { + href: '/datasets', + label: t('menus.datasets', { ns: 'common' }), + active: (path: string) => path.startsWith('/datasets'), + icon: 'i-custom-vender-main-nav-knowledge', + activeIcon: 'i-custom-vender-main-nav-knowledge-active', + }, + ] + : []), + ...(!isCurrentWorkspaceDatasetOperator + ? [ + { + href: '/tools?section=provider', + label: t('mainNav.integrations', { ns: 'common' }), + active: (path: string) => 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' }), @@ -66,11 +74,11 @@ const MainNav = ({ icon: 'i-custom-vender-main-nav-marketplace', activeIcon: 'i-custom-vender-main-nav-marketplace-active', }, - ], [t]) + ], [isCurrentWorkspaceDatasetOperator, isCurrentWorkspaceEditor, t]) const renderLogo = () => (

- + {systemFeatures.branding.enabled && systemFeatures.branding.application_title ? systemFeatures.branding.application_title : 'Dify'} {systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo ? ( @@ -100,10 +108,17 @@ const MainNav = ({ ))} - + {!isCurrentWorkspaceDatasetOperator && } + {showEnvTag && ( +
+ +
+ )}

- +
+ +