mirror of
https://github.com/langgenius/dify.git
synced 2026-05-09 21:28:25 +08:00
fix: align main nav gating and account popup behavior
This commit is contained in:
parent
baaae00fa8
commit
37ac79cb87
138
docs/main-nav-gating-follow-ups.md
Normal file
138
docs/main-nav-gating-follow-ups.md
Normal file
@ -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.
|
||||
@ -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'
|
||||
? <MainNavMenuContent mainNavBadge={mainNavBadge} onLogout={handleLogout} />
|
||||
? <MainNavMenuContent onLogout={handleLogout} />
|
||||
: (
|
||||
<DefaultMenuContent
|
||||
closeAccountDropdown={() => setIsAccountMenuOpen(false)}
|
||||
|
||||
@ -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<void>
|
||||
}
|
||||
|
||||
export function MainNavMenuContent({
|
||||
mainNavBadge,
|
||||
onLogout,
|
||||
}: MainNavMenuContentProps) {
|
||||
const { t } = useTranslation()
|
||||
const { userProfile } = useAppContext()
|
||||
const { isEducationAccount } = useProviderContext()
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuGroup className={mainNavMenuGroupClassName}>
|
||||
<div className="flex items-center gap-1 rounded-xl bg-gradient-to-b from-background-section-burn to-background-section p-3">
|
||||
<div className="flex items-center gap-3 rounded-xl bg-gradient-to-b from-background-section-burn to-background-section p-3">
|
||||
<div className="flex min-w-0 grow flex-col gap-1">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<div className="max-w-[80px] min-w-0 truncate body-md-medium text-text-primary" title={userProfile.name}>{userProfile.name}</div>
|
||||
{mainNavBadge}
|
||||
<div className="min-w-0 flex-1 truncate body-md-medium text-text-primary" title={userProfile.name}>{userProfile.name}</div>
|
||||
{isEducationAccount && (
|
||||
<PremiumBadge size="s" color="blue" className="shrink-0 px-2!">
|
||||
<span aria-hidden className="mr-1 i-ri-graduation-cap-fill h-3 w-3" />
|
||||
<span className="system-2xs-medium">EDU</span>
|
||||
</PremiumBadge>
|
||||
)}
|
||||
</div>
|
||||
<div className="truncate system-xs-regular text-text-tertiary" title={userProfile.email}>{userProfile.email}</div>
|
||||
</div>
|
||||
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size="lg" />
|
||||
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size="lg" className="shrink-0" />
|
||||
</div>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuGroup className={mainNavMenuGroupClassName}>
|
||||
|
||||
@ -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' } }),
|
||||
|
||||
@ -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(<WorkspaceCard />, {
|
||||
systemFeatures: {
|
||||
license: {
|
||||
status: LicenseStatus.ACTIVE,
|
||||
expired_at: null,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByText('Enterprise')).toBeInTheDocument()
|
||||
expect(screen.queryByText(Plan.sandbox)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 (
|
||||
<AccountDropdown
|
||||
mainNavBadge={<WorkspacePlanBadge plan={workspacePlan} />}
|
||||
variant="mainNav"
|
||||
trigger={({ isOpen, ariaLabel }) => (
|
||||
<button
|
||||
|
||||
@ -7,6 +7,7 @@ import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
||||
import LicenseNav from '@/app/components/header/license-env'
|
||||
import { IS_CLOUD_EDITION } from '@/config'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
@ -51,7 +52,7 @@ const WorkspaceMenuItemContent = ({
|
||||
|
||||
const WorkspaceCard = () => {
|
||||
const { t } = useTranslation()
|
||||
const { currentWorkspace } = useAppContext()
|
||||
const { currentWorkspace, isCurrentWorkspaceDatasetOperator, isCurrentWorkspaceManager } = useAppContext()
|
||||
const { workspaces } = useWorkspacesContext()
|
||||
const { enableBilling, plan } = useProviderContext()
|
||||
const { setShowPricingModal, setShowAccountSettingModal } = useModalContext()
|
||||
@ -60,6 +61,11 @@ const WorkspaceCard = () => {
|
||||
const formattedCredits = formatCredits(credits)
|
||||
const workspacePlan = (workspaces.find(workspace => workspace.current)?.plan || currentWorkspace.plan || plan.type) as Plan
|
||||
const isFreePlan = plan.type === Plan.sandbox
|
||||
const showCloudBilling = IS_CLOUD_EDITION && enableBilling
|
||||
const showUpgradeAction = showCloudBilling && isFreePlan
|
||||
const showWorkspaceSettings = !isCurrentWorkspaceDatasetOperator
|
||||
const showInviteMembers = showWorkspaceSettings && isCurrentWorkspaceManager
|
||||
const renderWorkspaceStatus = () => enableBilling ? <WorkspacePlanBadge plan={workspacePlan} /> : <LicenseNav />
|
||||
|
||||
const handlePlanClick = () => {
|
||||
if (isFreePlan)
|
||||
@ -109,12 +115,12 @@ const WorkspaceCard = () => {
|
||||
<div className="min-w-0 grow">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="max-w-[120px] truncate system-sm-medium text-text-primary" title={currentWorkspace.name}>{currentWorkspace.name}</span>
|
||||
<WorkspacePlanBadge plan={workspacePlan} />
|
||||
{renderWorkspaceStatus()}
|
||||
</div>
|
||||
</div>
|
||||
<span aria-hidden className="i-ri-expand-up-down-line h-4 w-4 shrink-0 text-text-tertiary" />
|
||||
</button>
|
||||
{IS_CLOUD_EDITION && (
|
||||
{showCloudBilling && (
|
||||
<div className="flex items-center justify-center gap-1.5 border-t border-divider-subtle py-2 pr-2.5 pl-2">
|
||||
<button
|
||||
type="button"
|
||||
@ -129,7 +135,7 @@ const WorkspaceCard = () => {
|
||||
<span className="truncate system-xs-medium" title={formattedCredits}>{formattedCredits}</span>
|
||||
<span className="shrink-0 system-xs-regular">{t('mainNav.workspace.creditsUnit', { ns: 'common' })}</span>
|
||||
</button>
|
||||
{enableBilling && (
|
||||
{showUpgradeAction && (
|
||||
<button
|
||||
type="button"
|
||||
className="max-w-[120px] shrink-0 truncate px-1 system-xs-semibold-uppercase text-saas-dify-blue-accessible transition-colors hover:text-saas-dify-blue-static-hover"
|
||||
@ -150,37 +156,41 @@ const WorkspaceCard = () => {
|
||||
<div className="rounded-xl bg-gradient-to-b from-background-section-burn to-background-section pb-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded-xl p-3 text-left transition-colors hover:bg-state-base-hover"
|
||||
className="flex w-full items-start gap-2 rounded-xl p-3 text-left transition-colors hover:bg-state-base-hover"
|
||||
aria-expanded={open}
|
||||
aria-label={t('mainNav.workspace.openMenu', { ns: 'common' })}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<div className="flex min-w-0 grow flex-col items-start justify-center gap-1">
|
||||
<div className="max-w-[120px] shrink-0 truncate system-xl-medium leading-5 text-text-primary" title={currentWorkspace.name}>{currentWorkspace.name}</div>
|
||||
<WorkspacePlanBadge plan={workspacePlan} />
|
||||
{renderWorkspaceStatus()}
|
||||
</div>
|
||||
<WorkspaceIcon name={currentWorkspace.name} className="h-9 w-9" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-full items-center gap-1 rounded-lg px-2 py-1 text-left transition-colors hover:bg-state-base-hover"
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.BILLING })
|
||||
}}
|
||||
>
|
||||
<WorkspaceMenuItemContent icon={<span aria-hidden className="i-custom-vender-main-nav-workspace-settings h-4 w-4" />} label={t('mainNav.workspace.settings', { ns: 'common' })} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-full items-center gap-1 rounded-lg px-2 py-1 text-left transition-colors hover:bg-state-base-hover"
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.MEMBERS })
|
||||
}}
|
||||
>
|
||||
<WorkspaceMenuItemContent icon={<span aria-hidden className="i-ri-user-add-line h-4 w-4" />} label={t('mainNav.workspace.inviteMembers', { ns: 'common' })} />
|
||||
</button>
|
||||
{showWorkspaceSettings && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-full items-center gap-1 rounded-lg px-2 py-1 text-left transition-colors hover:bg-state-base-hover"
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.BILLING })
|
||||
}}
|
||||
>
|
||||
<WorkspaceMenuItemContent icon={<span aria-hidden className="i-custom-vender-main-nav-workspace-settings h-4 w-4" />} label={t('mainNav.workspace.settings', { ns: 'common' })} />
|
||||
</button>
|
||||
)}
|
||||
{showInviteMembers && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-full items-center gap-1 rounded-lg px-2 py-1 text-left transition-colors hover:bg-state-base-hover"
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.MEMBERS })
|
||||
}}
|
||||
>
|
||||
<WorkspaceMenuItemContent icon={<span aria-hidden className="i-ri-user-add-line h-4 w-4" />} label={t('mainNav.workspace.inviteMembers', { ns: 'common' })} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{workspaces.length > 0 && (
|
||||
<div className="mt-1 flex flex-col">
|
||||
|
||||
@ -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 (
|
||||
<Badge size="xs" variant="dimm" className="shrink-0">
|
||||
{plan === Plan.professional ? 'pro' : plan}
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{plan === Plan.professional && isEducationWorkspace && <span aria-hidden className="i-ri-graduation-cap-fill h-3 w-3" />}
|
||||
{plan === Plan.professional ? 'pro' : plan}
|
||||
</span>
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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<MainNavItem[]>(() => [
|
||||
{
|
||||
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 = () => (
|
||||
<h1 className="min-w-0">
|
||||
<Link href="/apps" className="flex h-8 shrink-0 items-center overflow-hidden px-2 indent-[-9999px] whitespace-nowrap">
|
||||
<Link href={isCurrentWorkspaceDatasetOperator ? '/datasets' : '/apps'} className="flex h-8 shrink-0 items-center overflow-hidden px-2 indent-[-9999px] whitespace-nowrap">
|
||||
{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 = ({
|
||||
<MainNavLink key={item.href} item={item} pathname={pathname} />
|
||||
))}
|
||||
</nav>
|
||||
<WebAppsSection />
|
||||
{!isCurrentWorkspaceDatasetOperator && <WebAppsSection />}
|
||||
{showEnvTag && (
|
||||
<div className="relative z-30 px-3 pb-2">
|
||||
<EnvNav />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex w-[240px] items-center justify-between bg-gradient-to-b from-background-body-transparent to-background-body to-50% py-3 pr-1 pl-3 backdrop-blur-[2px]">
|
||||
<AccountSection workspacePlan={workspacePlan} />
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<AccountSection />
|
||||
</div>
|
||||
<HelpMenu />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user