diff --git a/web/AGENTS.md b/web/AGENTS.md index 5dd41b8a3c..71000eafdb 100644 --- a/web/AGENTS.md +++ b/web/AGENTS.md @@ -2,6 +2,12 @@ - Refer to the `./docs/test.md` and `./docs/lint.md` for detailed frontend workflow instructions. +## Overlay Components (Mandatory) + +- `./docs/overlay-migration.md` is the source of truth for overlay-related work. +- In new or modified code, use only overlay primitives from `@/app/components/base/ui/*`. +- Do not introduce deprecated overlay imports from `@/app/components/base/*`; when touching legacy callers, prefer migrating them and keep the allowlist shrinking (never expanding). + ## Automated Test Generation - Use `./docs/test.md` as the canonical instruction set for generating frontend automated tests. diff --git a/web/app/components/base/ui/context-menu/__tests__/index.spec.tsx b/web/app/components/base/ui/context-menu/__tests__/index.spec.tsx new file mode 100644 index 0000000000..2f2dacc8ba --- /dev/null +++ b/web/app/components/base/ui/context-menu/__tests__/index.spec.tsx @@ -0,0 +1,257 @@ +import { fireEvent, render, screen, within } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuLinkItem, + ContextMenuSeparator, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuTrigger, +} from '../index' + +describe('context-menu wrapper', () => { + describe('ContextMenuContent', () => { + it('should position content at bottom-start with default placement when props are omitted', () => { + render( + + Open + + Content action + + , + ) + + const positioner = screen.getByRole('group', { name: 'content positioner' }) + const popup = screen.getByRole('menu') + expect(positioner).toHaveAttribute('data-side', 'bottom') + expect(positioner).toHaveAttribute('data-align', 'start') + expect(within(popup).getByRole('menuitem', { name: 'Content action' })).toBeInTheDocument() + }) + + it('should apply custom placement when custom positioning props are provided', () => { + render( + + Open + + Custom content + + , + ) + + const positioner = screen.getByRole('group', { name: 'custom content positioner' }) + const popup = screen.getByRole('menu') + expect(positioner).toHaveAttribute('data-side', 'top') + expect(positioner).toHaveAttribute('data-align', 'end') + expect(within(popup).getByRole('menuitem', { name: 'Custom content' })).toBeInTheDocument() + }) + + it('should forward passthrough attributes and handlers when positionerProps and popupProps are provided', () => { + const handlePositionerMouseEnter = vi.fn() + const handlePopupClick = vi.fn() + + render( + + Open + + Passthrough content + + , + ) + + const positioner = screen.getByRole('group', { name: 'context content positioner' }) + const popup = screen.getByRole('menu') + fireEvent.mouseEnter(positioner) + fireEvent.click(popup) + expect(positioner).toHaveAttribute('id', 'context-content-positioner') + expect(popup).toHaveAttribute('id', 'context-content-popup') + expect(handlePositionerMouseEnter).toHaveBeenCalledTimes(1) + expect(handlePopupClick).toHaveBeenCalledTimes(1) + }) + }) + + describe('ContextMenuSubContent', () => { + it('should position sub-content at right-start with default placement when props are omitted', () => { + render( + + Open + + + More actions + + Sub action + + + + , + ) + + const positioner = screen.getByRole('group', { name: 'sub positioner' }) + expect(positioner).toHaveAttribute('data-side', 'right') + expect(positioner).toHaveAttribute('data-align', 'start') + expect(screen.getByRole('menuitem', { name: 'Sub action' })).toBeInTheDocument() + }) + }) + + describe('destructive prop behavior', () => { + it.each([true, false])('should remain interactive and not leak destructive prop on item when destructive is %s', (destructive) => { + const handleClick = vi.fn() + + render( + + Open + + + Item label + + + , + ) + + const item = screen.getByRole('menuitem', { name: 'menu action' }) + fireEvent.click(item) + expect(item).toHaveAttribute('id', `context-item-${String(destructive)}`) + expect(item).not.toHaveAttribute('destructive') + expect(handleClick).toHaveBeenCalledTimes(1) + }) + + it.each([true, false])('should remain interactive and not leak destructive prop on submenu trigger when destructive is %s', (destructive) => { + const handleClick = vi.fn() + + render( + + Open + + + + Trigger item + + + + , + ) + + const trigger = screen.getByRole('menuitem', { name: 'submenu action' }) + fireEvent.click(trigger) + expect(trigger).toHaveAttribute('id', `context-sub-${String(destructive)}`) + expect(trigger).not.toHaveAttribute('destructive') + expect(handleClick).toHaveBeenCalledTimes(1) + }) + + it.each([true, false])('should remain interactive and not leak destructive prop on link item when destructive is %s', (destructive) => { + render( + + Open + + + Docs + + + , + ) + + const link = screen.getByRole('menuitem', { name: 'context docs link' }) + expect(link.tagName.toLowerCase()).toBe('a') + expect(link).toHaveAttribute('id', `context-link-${String(destructive)}`) + expect(link).not.toHaveAttribute('destructive') + }) + }) + + describe('ContextMenuLinkItem close behavior', () => { + it('should keep link semantics and not leak closeOnClick prop when closeOnClick is false', () => { + render( + + Open + + + Docs + + + , + ) + + const link = screen.getByRole('menuitem', { name: 'docs link' }) + expect(link.tagName.toLowerCase()).toBe('a') + expect(link).toHaveAttribute('href', 'https://example.com/docs') + expect(link).not.toHaveAttribute('closeOnClick') + }) + }) + + describe('ContextMenuTrigger interaction', () => { + it('should open menu when right-clicking trigger area', () => { + render( + + + Trigger area + + + Open on right click + + , + ) + + const trigger = screen.getByLabelText('context trigger area') + fireEvent.contextMenu(trigger) + expect(screen.getByRole('menuitem', { name: 'Open on right click' })).toBeInTheDocument() + }) + }) + + describe('ContextMenuSeparator', () => { + it('should render separator and keep surrounding rows when separator is between items', () => { + render( + + Open + + First action + + Second action + + , + ) + + expect(screen.getByRole('menuitem', { name: 'First action' })).toBeInTheDocument() + expect(screen.getByRole('menuitem', { name: 'Second action' })).toBeInTheDocument() + expect(screen.getAllByRole('separator')).toHaveLength(1) + }) + }) +}) diff --git a/web/app/components/base/ui/context-menu/index.stories.tsx b/web/app/components/base/ui/context-menu/index.stories.tsx new file mode 100644 index 0000000000..7c57a81c65 --- /dev/null +++ b/web/app/components/base/ui/context-menu/index.stories.tsx @@ -0,0 +1,215 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite' +import { useState } from 'react' +import { + ContextMenu, + ContextMenuCheckboxItem, + ContextMenuCheckboxItemIndicator, + ContextMenuContent, + ContextMenuGroup, + ContextMenuGroupLabel, + ContextMenuItem, + ContextMenuLinkItem, + ContextMenuRadioGroup, + ContextMenuRadioItem, + ContextMenuRadioItemIndicator, + ContextMenuSeparator, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuTrigger, +} from '.' + +const TriggerArea = ({ label = 'Right-click inside this area' }: { label?: string }) => ( + } + > + {label} + +) + +const meta = { + title: 'Base/Navigation/ContextMenu', + component: ContextMenu, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Compound context menu built on Base UI ContextMenu. Open by right-clicking the trigger area.', + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: () => ( + + + + Edit + Duplicate + Archive + + + ), +} + +export const WithSubmenu: Story = { + render: () => ( + + + + Copy + Paste + + + Share + + Email + Slack + Copy link + + + + + ), +} + +export const WithGroupLabel: Story = { + render: () => ( + + + + + Actions + Rename + Duplicate + + + + Danger Zone + Delete + + + + ), +} + +const WithRadioItemsDemo = () => { + const [value, setValue] = useState('comfortable') + + return ( + + + + + + Compact + + + + Comfortable + + + + Spacious + + + + + + ) +} + +export const WithRadioItems: Story = { + render: () => , +} + +const WithCheckboxItemsDemo = () => { + const [showToolbar, setShowToolbar] = useState(true) + const [showSidebar, setShowSidebar] = useState(false) + const [showStatusBar, setShowStatusBar] = useState(true) + + return ( + + + + + Toolbar + + + + Sidebar + + + + Status bar + + + + + ) +} + +export const WithCheckboxItems: Story = { + render: () => , +} + +export const WithLinkItems: Story = { + render: () => ( + + + + + Dify Docs + + + Product Roadmap + + + + Dangerous External Action + + + + ), +} + +export const Complex: Story = { + render: () => ( + + + + + + Rename + + + + Duplicate + + + + + + Share + + + Email + Slack + Copy Link + + + + + + Delete + + + + ), +} diff --git a/web/app/components/base/ui/context-menu/index.tsx b/web/app/components/base/ui/context-menu/index.tsx new file mode 100644 index 0000000000..1a130549ca --- /dev/null +++ b/web/app/components/base/ui/context-menu/index.tsx @@ -0,0 +1,302 @@ +'use client' + +import type { Placement } from '@/app/components/base/ui/placement' +import { ContextMenu as BaseContextMenu } from '@base-ui/react/context-menu' +import * as React from 'react' +import { + menuBackdropClassName, + menuGroupLabelClassName, + menuIndicatorClassName, + menuPopupAnimationClassName, + menuPopupBaseClassName, + menuRowClassName, + menuSeparatorClassName, +} from '@/app/components/base/ui/menu-shared' +import { parsePlacement } from '@/app/components/base/ui/placement' +import { cn } from '@/utils/classnames' + +export const ContextMenu = BaseContextMenu.Root +export const ContextMenuTrigger = BaseContextMenu.Trigger +export const ContextMenuPortal = BaseContextMenu.Portal +export const ContextMenuBackdrop = BaseContextMenu.Backdrop +export const ContextMenuSub = BaseContextMenu.SubmenuRoot +export const ContextMenuGroup = BaseContextMenu.Group +export const ContextMenuRadioGroup = BaseContextMenu.RadioGroup + +type ContextMenuContentProps = { + children: React.ReactNode + placement?: Placement + sideOffset?: number + alignOffset?: number + className?: string + popupClassName?: string + positionerProps?: Omit< + React.ComponentPropsWithoutRef, + 'children' | 'className' | 'side' | 'align' | 'sideOffset' | 'alignOffset' + > + popupProps?: Omit< + React.ComponentPropsWithoutRef, + 'children' | 'className' + > +} + +type ContextMenuPopupRenderProps = Required> & { + placement: Placement + sideOffset: number + alignOffset: number + className?: string + popupClassName?: string + positionerProps?: ContextMenuContentProps['positionerProps'] + popupProps?: ContextMenuContentProps['popupProps'] + withBackdrop?: boolean +} + +function renderContextMenuPopup({ + children, + placement, + sideOffset, + alignOffset, + className, + popupClassName, + positionerProps, + popupProps, + withBackdrop = false, +}: ContextMenuPopupRenderProps) { + const { side, align } = parsePlacement(placement) + + return ( + + {withBackdrop && ( + + )} + + + {children} + + + + ) +} + +export function ContextMenuContent({ + children, + placement = 'bottom-start', + sideOffset = 0, + alignOffset = 0, + className, + popupClassName, + positionerProps, + popupProps, +}: ContextMenuContentProps) { + return renderContextMenuPopup({ + children, + placement, + sideOffset, + alignOffset, + className, + popupClassName, + positionerProps, + popupProps, + withBackdrop: true, + }) +} + +type ContextMenuItemProps = React.ComponentPropsWithoutRef & { + destructive?: boolean +} + +export function ContextMenuItem({ + className, + destructive, + ...props +}: ContextMenuItemProps) { + return ( + + ) +} + +type ContextMenuLinkItemProps = React.ComponentPropsWithoutRef & { + destructive?: boolean +} + +export function ContextMenuLinkItem({ + className, + destructive, + closeOnClick = true, + ...props +}: ContextMenuLinkItemProps) { + return ( + + ) +} + +export function ContextMenuRadioItem({ + className, + ...props +}: React.ComponentPropsWithoutRef) { + return ( + + ) +} + +export function ContextMenuCheckboxItem({ + className, + ...props +}: React.ComponentPropsWithoutRef) { + return ( + + ) +} + +type ContextMenuIndicatorProps = Omit, 'children'> & { + children?: React.ReactNode +} + +export function ContextMenuItemIndicator({ + className, + children, + ...props +}: ContextMenuIndicatorProps) { + return ( + + {children ?? } + + ) +} + +export function ContextMenuCheckboxItemIndicator({ + className, + ...props +}: Omit, 'children'>) { + return ( + + + + ) +} + +export function ContextMenuRadioItemIndicator({ + className, + ...props +}: Omit, 'children'>) { + return ( + + + + ) +} + +type ContextMenuSubTriggerProps = React.ComponentPropsWithoutRef & { + destructive?: boolean +} + +export function ContextMenuSubTrigger({ + className, + destructive, + children, + ...props +}: ContextMenuSubTriggerProps) { + return ( + + {children} + + + ) +} + +type ContextMenuSubContentProps = { + children: React.ReactNode + placement?: Placement + sideOffset?: number + alignOffset?: number + className?: string + popupClassName?: string + positionerProps?: ContextMenuContentProps['positionerProps'] + popupProps?: ContextMenuContentProps['popupProps'] +} + +export function ContextMenuSubContent({ + children, + placement = 'right-start', + sideOffset = 4, + alignOffset = 0, + className, + popupClassName, + positionerProps, + popupProps, +}: ContextMenuSubContentProps) { + return renderContextMenuPopup({ + children, + placement, + sideOffset, + alignOffset, + className, + popupClassName, + positionerProps, + popupProps, + }) +} + +export function ContextMenuGroupLabel({ + className, + ...props +}: React.ComponentPropsWithoutRef) { + return ( + + ) +} + +export function ContextMenuSeparator({ + className, + ...props +}: React.ComponentPropsWithoutRef) { + return ( + + ) +} diff --git a/web/app/components/base/ui/dropdown-menu/__tests__/index.spec.tsx b/web/app/components/base/ui/dropdown-menu/__tests__/index.spec.tsx index b381078180..c5fb532d98 100644 --- a/web/app/components/base/ui/dropdown-menu/__tests__/index.spec.tsx +++ b/web/app/components/base/ui/dropdown-menu/__tests__/index.spec.tsx @@ -1,13 +1,12 @@ -import { Menu } from '@base-ui/react/menu' +import type { ComponentPropsWithoutRef, ReactNode } from 'react' import { fireEvent, render, screen, within } from '@testing-library/react' +import Link from 'next/link' import { describe, expect, it, vi } from 'vitest' import { DropdownMenu, DropdownMenuContent, - DropdownMenuGroup, DropdownMenuItem, - DropdownMenuPortal, - DropdownMenuRadioGroup, + DropdownMenuLinkItem, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, @@ -15,18 +14,22 @@ import { DropdownMenuTrigger, } from '../index' -describe('dropdown-menu wrapper', () => { - describe('alias exports', () => { - it('should map direct aliases to the corresponding Menu primitive when importing menu roots', () => { - expect(DropdownMenu).toBe(Menu.Root) - expect(DropdownMenuPortal).toBe(Menu.Portal) - expect(DropdownMenuTrigger).toBe(Menu.Trigger) - expect(DropdownMenuSub).toBe(Menu.SubmenuRoot) - expect(DropdownMenuGroup).toBe(Menu.Group) - expect(DropdownMenuRadioGroup).toBe(Menu.RadioGroup) - }) - }) +vi.mock('next/link', () => ({ + default: ({ + href, + children, + ...props + }: { + href: string + children?: ReactNode + } & Omit, 'href'>) => ( + + {children} + + ), +})) +describe('dropdown-menu wrapper', () => { describe('DropdownMenuContent', () => { it('should position content at bottom-end with default placement when props are omitted', () => { render( @@ -250,6 +253,99 @@ describe('dropdown-menu wrapper', () => { }) }) + describe('DropdownMenuLinkItem', () => { + it('should render as anchor and keep href/target attributes when link props are provided', () => { + render( + + Open + + + Docs + + + , + ) + + const link = screen.getByRole('menuitem', { name: 'Docs' }) + expect(link.tagName.toLowerCase()).toBe('a') + expect(link).toHaveAttribute('href', 'https://example.com/docs') + expect(link).toHaveAttribute('target', '_blank') + expect(link).toHaveAttribute('rel', 'noopener noreferrer') + }) + + it('should keep link semantics and not leak closeOnClick prop when closeOnClick is false', () => { + render( + + Open + + + Docs + + + , + ) + + const link = screen.getByRole('menuitem', { name: 'docs link' }) + expect(link.tagName.toLowerCase()).toBe('a') + expect(link).toHaveAttribute('href', 'https://example.com/docs') + expect(link).not.toHaveAttribute('closeOnClick') + }) + + it('should preserve link semantics when render prop uses a custom link component', () => { + render( + + Open + + } + aria-label="account link" + > + Account settings + + + , + ) + + const link = screen.getByRole('menuitem', { name: 'account link' }) + expect(link.tagName.toLowerCase()).toBe('a') + expect(link).toHaveAttribute('href', '/account') + expect(link).toHaveTextContent('Account settings') + }) + + it.each([true, false])('should remain interactive and not leak destructive prop when destructive is %s', (destructive) => { + const handleClick = vi.fn() + + render( + + Open + + + Docs + + + , + ) + + const link = screen.getByRole('menuitem', { name: 'docs link' }) + fireEvent.click(link) + + expect(link.tagName.toLowerCase()).toBe('a') + expect(link).toHaveAttribute('id', `menu-link-${String(destructive)}`) + expect(link).not.toHaveAttribute('destructive') + expect(handleClick).toHaveBeenCalledTimes(1) + }) + }) + describe('DropdownMenuSeparator', () => { it('should forward passthrough props and handlers when separator props are provided', () => { const handleMouseEnter = vi.fn() diff --git a/web/app/components/base/ui/dropdown-menu/index.stories.tsx b/web/app/components/base/ui/dropdown-menu/index.stories.tsx index 70afc07819..0e2f21dd54 100644 --- a/web/app/components/base/ui/dropdown-menu/index.stories.tsx +++ b/web/app/components/base/ui/dropdown-menu/index.stories.tsx @@ -8,6 +8,7 @@ import { DropdownMenuGroup, DropdownMenuGroupLabel, DropdownMenuItem, + DropdownMenuLinkItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuRadioItemIndicator, @@ -234,6 +235,22 @@ export const WithIcons: Story = { ), } +export const WithLinkItems: Story = { + render: () => ( + + + + + Dify Docs + + + Product Roadmap + + + + ), +} + const ComplexDemo = () => { const [sortOrder, setSortOrder] = useState('newest') const [showArchived, setShowArchived] = useState(false) diff --git a/web/app/components/base/ui/dropdown-menu/index.tsx b/web/app/components/base/ui/dropdown-menu/index.tsx index 8d4f630adc..4c49ab2b58 100644 --- a/web/app/components/base/ui/dropdown-menu/index.tsx +++ b/web/app/components/base/ui/dropdown-menu/index.tsx @@ -3,6 +3,14 @@ import type { Placement } from '@/app/components/base/ui/placement' import { Menu } from '@base-ui/react/menu' import * as React from 'react' +import { + menuGroupLabelClassName, + menuIndicatorClassName, + menuPopupAnimationClassName, + menuPopupBaseClassName, + menuRowClassName, + menuSeparatorClassName, +} from '@/app/components/base/ui/menu-shared' import { parsePlacement } from '@/app/components/base/ui/placement' import { cn } from '@/utils/classnames' @@ -13,20 +21,13 @@ export const DropdownMenuSub = Menu.SubmenuRoot export const DropdownMenuGroup = Menu.Group export const DropdownMenuRadioGroup = Menu.RadioGroup -const menuRowBaseClassName = 'mx-1 flex h-8 cursor-pointer select-none items-center gap-1 rounded-lg px-2 outline-none' -const menuRowStateClassName = 'data-[highlighted]:bg-state-base-hover data-[disabled]:cursor-not-allowed data-[disabled]:opacity-30' - export function DropdownMenuRadioItem({ className, ...props }: React.ComponentPropsWithoutRef) { return ( ) @@ -38,10 +39,7 @@ export function DropdownMenuRadioItemIndicator({ }: Omit, 'children'>) { return ( @@ -55,11 +53,7 @@ export function DropdownMenuCheckboxItem({ }: React.ComponentPropsWithoutRef) { return ( ) @@ -71,10 +65,7 @@ export function DropdownMenuCheckboxItemIndicator({ }: Omit, 'children'>) { return ( @@ -88,10 +79,7 @@ export function DropdownMenuGroupLabel({ }: React.ComponentPropsWithoutRef) { return ( ) @@ -148,8 +136,8 @@ function renderDropdownMenuPopup({ > {children} @@ -253,12 +236,26 @@ export function DropdownMenuItem({ }: DropdownMenuItemProps) { return ( + ) +} + +type DropdownMenuLinkItemProps = React.ComponentPropsWithoutRef & { + destructive?: boolean +} + +export function DropdownMenuLinkItem({ + className, + destructive, + closeOnClick = true, + ...props +}: DropdownMenuLinkItemProps) { + return ( + ) @@ -270,7 +267,7 @@ export function DropdownMenuSeparator({ }: React.ComponentPropsWithoutRef) { return ( ) diff --git a/web/app/components/base/ui/menu-shared.ts b/web/app/components/base/ui/menu-shared.ts new file mode 100644 index 0000000000..a72147f29d --- /dev/null +++ b/web/app/components/base/ui/menu-shared.ts @@ -0,0 +1,7 @@ +export const menuRowClassName = 'mx-1 flex h-8 cursor-pointer select-none items-center gap-1 rounded-lg px-2 outline-none data-[highlighted]:bg-state-base-hover data-[disabled]:cursor-not-allowed data-[disabled]:opacity-30' +export const menuIndicatorClassName = 'ml-auto flex shrink-0 items-center text-text-accent' +export const menuGroupLabelClassName = 'px-3 pb-0.5 pt-1 text-text-tertiary system-xs-medium-uppercase' +export const menuSeparatorClassName = 'my-1 h-px bg-divider-subtle' +export const menuPopupBaseClassName = 'max-h-[var(--available-height)] overflow-y-auto overflow-x-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur py-1 text-sm text-text-secondary shadow-lg outline-none focus:outline-none focus-visible:outline-none backdrop-blur-[5px]' +export const menuPopupAnimationClassName = 'origin-[var(--transform-origin)] transition-[transform,scale,opacity] data-[ending-style]:scale-95 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 motion-reduce:transition-none' +export const menuBackdropClassName = 'fixed inset-0 z-50 bg-transparent transition-opacity duration-150 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 motion-reduce:transition-none' diff --git a/web/app/components/header/account-dropdown/compliance.tsx b/web/app/components/header/account-dropdown/compliance.tsx index c048f25c1e..fc1d27ace5 100644 --- a/web/app/components/header/account-dropdown/compliance.tsx +++ b/web/app/components/header/account-dropdown/compliance.tsx @@ -184,7 +184,7 @@ export default function Compliance() { - + } label={t('compliance.soc2Type1', { ns: 'common' })} diff --git a/web/app/components/header/account-dropdown/index.tsx b/web/app/components/header/account-dropdown/index.tsx index c4f1c5699f..87b286f319 100644 --- a/web/app/components/header/account-dropdown/index.tsx +++ b/web/app/components/header/account-dropdown/index.tsx @@ -9,7 +9,7 @@ import { resetUser } from '@/app/components/base/amplitude/utils' import Avatar from '@/app/components/base/avatar' import PremiumBadge from '@/app/components/base/premium-badge' import ThemeSwitcher from '@/app/components/base/theme-switcher' -import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/app/components/base/ui/dropdown-menu' +import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLinkItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/app/components/base/ui/dropdown-menu' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' import { IS_CLOUD_EDITION } from '@/config' import { useAppContext } from '@/context/app-context' @@ -41,12 +41,12 @@ function AccountMenuRouteItem({ trailing, }: AccountMenuRouteItemProps) { return ( - } > - + ) } @@ -64,12 +64,14 @@ function AccountMenuExternalItem({ trailing, }: AccountMenuExternalItemProps) { return ( - } + href={href} + rel="noopener noreferrer" + target="_blank" > - + ) } @@ -101,7 +103,7 @@ type AccountMenuSectionProps = { } function AccountMenuSection({ children }: AccountMenuSectionProps) { - return {children} + return {children} } export default function AppSelector() { @@ -144,8 +146,8 @@ export default function AppSelector() { sideOffset={6} popupClassName="w-60 max-w-80 !bg-components-panel-bg-blur !py-0 backdrop-blur-sm" > - -
+ +
{userProfile.name} diff --git a/web/app/components/header/account-dropdown/support.tsx b/web/app/components/header/account-dropdown/support.tsx index ead4509cce..687915349f 100644 --- a/web/app/components/header/account-dropdown/support.tsx +++ b/web/app/components/header/account-dropdown/support.tsx @@ -1,5 +1,5 @@ import { useTranslation } from 'react-i18next' -import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger } from '@/app/components/base/ui/dropdown-menu' +import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuLinkItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger } from '@/app/components/base/ui/dropdown-menu' import { toggleZendeskWindow } from '@/app/components/base/zendesk/utils' import { Plan } from '@/app/components/billing/type' import { SUPPORT_EMAIL_ADDRESS, ZENDESK_WIDGET_KEY } from '@/config' @@ -31,7 +31,7 @@ export default function Support({ closeAccountDropdown }: SupportProps) { - + {hasDedicatedChannel && hasZendeskWidget && ( )} {hasDedicatedChannel && !hasZendeskWidget && ( - } + href={mailToSupport(userProfile.email, plan.type, langGeniusVersionInfo?.current_version, SUPPORT_EMAIL_ADDRESS)} + rel="noopener noreferrer" + target="_blank" > } /> - + )} - } + href="https://forum.dify.ai/" + rel="noopener noreferrer" + target="_blank" > } /> - - + } + href="https://discord.gg/5AEfbxcd9k" + rel="noopener noreferrer" + target="_blank" > } /> - + diff --git a/web/docs/overlay-migration.md b/web/docs/overlay-migration.md index 3e78b1bf39..3c9da4f3fb 100644 --- a/web/docs/overlay-migration.md +++ b/web/docs/overlay-migration.md @@ -16,6 +16,7 @@ This document tracks the migration away from legacy overlay APIs. - Replacement primitives: - `@/app/components/base/ui/tooltip` - `@/app/components/base/ui/dropdown-menu` + - `@/app/components/base/ui/context-menu` - `@/app/components/base/ui/popover` - `@/app/components/base/ui/dialog` - `@/app/components/base/ui/alert-dialog` diff --git a/web/vitest.setup.ts b/web/vitest.setup.ts index 13322d9ba6..4e3e4806b5 100644 --- a/web/vitest.setup.ts +++ b/web/vitest.setup.ts @@ -80,6 +80,16 @@ if (typeof globalThis.IntersectionObserver === 'undefined') { if (typeof Element !== 'undefined' && !Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = function () { /* noop */ } +// Mock DOMRect.fromRect for tests (not available in jsdom) +if (typeof DOMRect !== 'undefined' && typeof (DOMRect as typeof DOMRect & { fromRect?: unknown }).fromRect !== 'function') { + (DOMRect as typeof DOMRect & { fromRect: (rect?: DOMRectInit) => DOMRect }).fromRect = (rect = {}) => new DOMRect( + rect.x ?? 0, + rect.y ?? 0, + rect.width ?? 0, + rect.height ?? 0, + ) +} + afterEach(async () => { // Wrap cleanup in act() to flush pending React scheduler work // This prevents "window is not defined" errors from React 19's scheduler