feat(web): overlay migration guardrails + Base UI primitives (#32824)

Signed-off-by: yyh <yuanyouhuilyz@gmail.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
yyh 2026-03-03 16:56:13 +08:00 committed by GitHub
parent 5e79d35881
commit 7f67e1a2fc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 2926 additions and 423 deletions

View File

@ -1,3 +1,8 @@
/**
* @deprecated Use `@/app/components/base/ui/dialog` instead.
* This component will be removed after migration is complete.
* See: https://github.com/langgenius/dify/issues/32767
*/
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react' import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react'
import { noop } from 'es-toolkit/function' import { noop } from 'es-toolkit/function'
import { Fragment } from 'react' import { Fragment } from 'react'

View File

@ -1,3 +1,8 @@
/**
* @deprecated Use `@/app/components/base/ui/dialog` instead.
* This component will be removed after migration is complete.
* See: https://github.com/langgenius/dify/issues/32767
*/
import type { ButtonProps } from '@/app/components/base/button' import type { ButtonProps } from '@/app/components/base/button'
import { noop } from 'es-toolkit/function' import { noop } from 'es-toolkit/function'
import { memo } from 'react' import { memo } from 'react'

View File

@ -1,4 +1,16 @@
'use client' 'use client'
/**
* @deprecated Use semantic overlay primitives from `@/app/components/base/ui/` instead.
* This component will be removed after migration is complete.
* See: https://github.com/langgenius/dify/issues/32767
*
* Migration guide:
* - Tooltip `@/app/components/base/ui/tooltip`
* - Menu/Dropdown `@/app/components/base/ui/dropdown-menu`
* - Popover `@/app/components/base/ui/popover`
* - Dialog/Modal `@/app/components/base/ui/dialog`
* - Select `@/app/components/base/ui/select`
*/
import type { OffsetOptions, Placement } from '@floating-ui/react' import type { OffsetOptions, Placement } from '@floating-ui/react'
import { import {
autoUpdate, autoUpdate,
@ -33,6 +45,7 @@ export type PortalToFollowElemOptions = {
triggerPopupSameWidth?: boolean triggerPopupSameWidth?: boolean
} }
/** @deprecated Use semantic overlay primitives instead. See #32767. */
export function usePortalToFollowElem({ export function usePortalToFollowElem({
placement = 'bottom', placement = 'bottom',
open: controlledOpen, open: controlledOpen,
@ -110,6 +123,7 @@ export function usePortalToFollowElemContext() {
return context return context
} }
/** @deprecated Use semantic overlay primitives instead. See #32767. */
export function PortalToFollowElem({ export function PortalToFollowElem({
children, children,
...options ...options
@ -124,6 +138,7 @@ export function PortalToFollowElem({
) )
} }
/** @deprecated Use semantic overlay primitives instead. See #32767. */
export const PortalToFollowElemTrigger = ( export const PortalToFollowElemTrigger = (
{ {
ref: propRef, ref: propRef,
@ -164,6 +179,7 @@ export const PortalToFollowElemTrigger = (
} }
PortalToFollowElemTrigger.displayName = 'PortalToFollowElemTrigger' PortalToFollowElemTrigger.displayName = 'PortalToFollowElemTrigger'
/** @deprecated Use semantic overlay primitives instead. See #32767. */
export const PortalToFollowElemContent = ( export const PortalToFollowElemContent = (
{ {
ref: propRef, ref: propRef,

View File

@ -1,4 +1,9 @@
'use client' 'use client'
/**
* @deprecated Use `@/app/components/base/ui/select` instead.
* This component will be removed after migration is complete.
* See: https://github.com/langgenius/dify/issues/32767
*/
import type { FC } from 'react' import type { FC } from 'react'
import { Combobox, ComboboxButton, ComboboxInput, ComboboxOption, ComboboxOptions, Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react' import { Combobox, ComboboxButton, ComboboxInput, ComboboxOption, ComboboxOptions, Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react'
import { ChevronDownIcon, ChevronUpIcon, XMarkIcon } from '@heroicons/react/20/solid' import { ChevronDownIcon, ChevronUpIcon, XMarkIcon } from '@heroicons/react/20/solid'
@ -236,7 +241,7 @@ const SimpleSelect: FC<ISelectProps> = ({
}} }}
className={cn(`flex h-full w-full items-center rounded-lg border-0 bg-components-input-bg-normal pl-3 pr-10 focus-visible:bg-state-base-hover-alt focus-visible:outline-none group-hover/simple-select:bg-state-base-hover-alt sm:text-sm sm:leading-6 ${disabled ? 'cursor-not-allowed' : 'cursor-pointer'}`, className)} className={cn(`flex h-full w-full items-center rounded-lg border-0 bg-components-input-bg-normal pl-3 pr-10 focus-visible:bg-state-base-hover-alt focus-visible:outline-none group-hover/simple-select:bg-state-base-hover-alt sm:text-sm sm:leading-6 ${disabled ? 'cursor-not-allowed' : 'cursor-pointer'}`, className)}
> >
<span className={cn('system-sm-regular block truncate text-left text-components-input-text-filled', !selectedItem?.name && 'text-components-input-text-placeholder')}>{selectedItem?.name ?? localPlaceholder}</span> <span className={cn('block truncate text-left text-components-input-text-filled system-sm-regular', !selectedItem?.name && 'text-components-input-text-placeholder')}>{selectedItem?.name ?? localPlaceholder}</span>
<span className="absolute inset-y-0 right-0 flex items-center pr-2"> <span className="absolute inset-y-0 right-0 flex items-center pr-2">
{isLoading {isLoading
? <RiLoader4Line className="h-3.5 w-3.5 animate-spin text-text-secondary" /> ? <RiLoader4Line className="h-3.5 w-3.5 animate-spin text-text-secondary" />

View File

@ -1,4 +1,9 @@
'use client' 'use client'
/**
* @deprecated Use `@/app/components/base/ui/tooltip` instead.
* This component will be removed after migration is complete.
* See: https://github.com/langgenius/dify/issues/32767
*/
import type { OffsetOptions, Placement } from '@floating-ui/react' import type { OffsetOptions, Placement } from '@floating-ui/react'
import type { FC } from 'react' import type { FC } from 'react'
import { RiQuestionLine } from '@remixicon/react' import { RiQuestionLine } from '@remixicon/react'
@ -130,7 +135,7 @@ const Tooltip: FC<TooltipProps> = ({
{!!popupContent && ( {!!popupContent && (
<div <div
className={cn( className={cn(
!noDecoration && 'system-xs-regular relative max-w-[300px] break-words rounded-md bg-components-panel-bg px-3 py-2 text-left text-text-tertiary shadow-lg', !noDecoration && 'relative max-w-[300px] break-words rounded-md bg-components-panel-bg px-3 py-2 text-left text-text-tertiary shadow-lg system-xs-regular',
popupClassName, popupClassName,
)} )}
onMouseEnter={() => { onMouseEnter={() => {

View File

@ -0,0 +1,58 @@
'use client'
// z-index strategy (relies on root `isolation: isolate` in layout.tsx):
// All overlay primitives (Tooltip / Popover / Dropdown / Select / Dialog) — z-50
// Overlays share the same z-index; DOM order handles stacking when multiple are open.
// This ensures overlays inside a Dialog (e.g. a Tooltip on a dialog button) render
// above the dialog backdrop instead of being clipped by it.
// Toast — z-[99], always on top (defined in toast component)
import { Dialog as BaseDialog } from '@base-ui/react/dialog'
import * as React from 'react'
import { cn } from '@/utils/classnames'
export const Dialog = BaseDialog.Root
export const DialogTrigger = BaseDialog.Trigger
export const DialogTitle = BaseDialog.Title
export const DialogDescription = BaseDialog.Description
export const DialogClose = BaseDialog.Close
type DialogContentProps = {
children: React.ReactNode
className?: string
overlayClassName?: string
closable?: boolean
}
export function DialogContent({
children,
className,
overlayClassName,
closable = false,
}: DialogContentProps) {
return (
<BaseDialog.Portal>
<BaseDialog.Backdrop
className={cn(
'fixed inset-0 z-50 bg-background-overlay',
'transition-opacity duration-150 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 motion-reduce:transition-none',
overlayClassName,
)}
/>
<BaseDialog.Popup
className={cn(
'fixed left-1/2 top-1/2 z-50 max-h-[80dvh] w-[480px] max-w-[calc(100vw-2rem)] -translate-x-1/2 -translate-y-1/2 overflow-y-auto rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-6 shadow-xl',
'transition-[transform,scale,opacity] duration-150 data-[ending-style]:scale-95 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 motion-reduce:transition-none',
className,
)}
>
{closable && (
<BaseDialog.Close aria-label="Close" className="absolute right-6 top-6 z-10 flex h-5 w-5 cursor-pointer items-center justify-center rounded-2xl hover:bg-state-base-hover">
<span className="i-ri-close-line h-4 w-4 text-text-tertiary" />
</BaseDialog.Close>
)}
{children}
</BaseDialog.Popup>
</BaseDialog.Portal>
)
}

View File

@ -0,0 +1,277 @@
'use client'
import type { Placement } from '@/app/components/base/ui/placement'
import { Menu } from '@base-ui/react/menu'
import * as React from 'react'
import { parsePlacement } from '@/app/components/base/ui/placement'
import { cn } from '@/utils/classnames'
export const DropdownMenu = Menu.Root
export const DropdownMenuPortal = Menu.Portal
export const DropdownMenuTrigger = Menu.Trigger
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 rounded-lg px-2 outline-none'
const menuRowStateClassName = 'data-[highlighted]:bg-state-base-hover data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50'
export function DropdownMenuRadioItem({
className,
...props
}: React.ComponentPropsWithoutRef<typeof Menu.RadioItem>) {
return (
<Menu.RadioItem
className={cn(
menuRowBaseClassName,
menuRowStateClassName,
className,
)}
{...props}
/>
)
}
export function DropdownMenuRadioItemIndicator({
className,
...props
}: Omit<React.ComponentPropsWithoutRef<typeof Menu.RadioItemIndicator>, 'children'>) {
return (
<Menu.RadioItemIndicator
className={cn(
'ml-auto flex shrink-0 items-center text-text-accent',
className,
)}
{...props}
>
<span aria-hidden className="i-ri-check-line h-4 w-4" />
</Menu.RadioItemIndicator>
)
}
export function DropdownMenuCheckboxItem({
className,
...props
}: React.ComponentPropsWithoutRef<typeof Menu.CheckboxItem>) {
return (
<Menu.CheckboxItem
className={cn(
menuRowBaseClassName,
menuRowStateClassName,
className,
)}
{...props}
/>
)
}
export function DropdownMenuCheckboxItemIndicator({
className,
...props
}: Omit<React.ComponentPropsWithoutRef<typeof Menu.CheckboxItemIndicator>, 'children'>) {
return (
<Menu.CheckboxItemIndicator
className={cn(
'ml-auto flex shrink-0 items-center text-text-accent',
className,
)}
{...props}
>
<span aria-hidden className="i-ri-check-line h-4 w-4" />
</Menu.CheckboxItemIndicator>
)
}
export function DropdownMenuGroupLabel({
className,
...props
}: React.ComponentPropsWithoutRef<typeof Menu.GroupLabel>) {
return (
<Menu.GroupLabel
className={cn(
'px-3 py-1 text-text-tertiary system-2xs-medium-uppercase',
className,
)}
{...props}
/>
)
}
type DropdownMenuContentProps = {
children: React.ReactNode
placement?: Placement
sideOffset?: number
alignOffset?: number
className?: string
popupClassName?: string
positionerProps?: Omit<
React.ComponentPropsWithoutRef<typeof Menu.Positioner>,
'children' | 'className' | 'side' | 'align' | 'sideOffset' | 'alignOffset'
>
popupProps?: Omit<
React.ComponentPropsWithoutRef<typeof Menu.Popup>,
'children' | 'className'
>
}
type DropdownMenuPopupRenderProps = Required<Pick<DropdownMenuContentProps, 'children'>> & {
placement: Placement
sideOffset: number
alignOffset: number
className?: string
popupClassName?: string
positionerProps?: DropdownMenuContentProps['positionerProps']
popupProps?: DropdownMenuContentProps['popupProps']
}
function renderDropdownMenuPopup({
children,
placement,
sideOffset,
alignOffset,
className,
popupClassName,
positionerProps,
popupProps,
}: DropdownMenuPopupRenderProps) {
const { side, align } = parsePlacement(placement)
return (
<Menu.Portal>
<Menu.Positioner
side={side}
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
className={cn('z-50 outline-none', className)}
{...positionerProps}
>
<Menu.Popup
className={cn(
'max-h-[var(--available-height)] overflow-y-auto overflow-x-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg py-1 text-sm text-text-secondary shadow-lg',
'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',
popupClassName,
)}
{...popupProps}
>
{children}
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
)
}
export function DropdownMenuContent({
children,
placement = 'bottom-end',
sideOffset = 4,
alignOffset = 0,
className,
popupClassName,
positionerProps,
popupProps,
}: DropdownMenuContentProps) {
return renderDropdownMenuPopup({
children,
placement,
sideOffset,
alignOffset,
className,
popupClassName,
positionerProps,
popupProps,
})
}
type DropdownMenuSubTriggerProps = React.ComponentPropsWithoutRef<typeof Menu.SubmenuTrigger> & {
destructive?: boolean
}
export function DropdownMenuSubTrigger({
className,
destructive,
children,
...props
}: DropdownMenuSubTriggerProps) {
return (
<Menu.SubmenuTrigger
className={cn(
menuRowBaseClassName,
menuRowStateClassName,
destructive && 'text-text-destructive',
className,
)}
{...props}
>
{children}
<span aria-hidden className="i-ri-arrow-right-s-line ml-auto size-[14px] shrink-0 text-text-tertiary" />
</Menu.SubmenuTrigger>
)
}
type DropdownMenuSubContentProps = {
children: React.ReactNode
placement?: Placement
sideOffset?: number
alignOffset?: number
className?: string
popupClassName?: string
positionerProps?: DropdownMenuContentProps['positionerProps']
popupProps?: DropdownMenuContentProps['popupProps']
}
export function DropdownMenuSubContent({
children,
placement = 'left-start',
sideOffset = 4,
alignOffset = 0,
className,
popupClassName,
positionerProps,
popupProps,
}: DropdownMenuSubContentProps) {
return renderDropdownMenuPopup({
children,
placement,
sideOffset,
alignOffset,
className,
popupClassName,
positionerProps,
popupProps,
})
}
type DropdownMenuItemProps = React.ComponentPropsWithoutRef<typeof Menu.Item> & {
destructive?: boolean
}
export function DropdownMenuItem({
className,
destructive,
...props
}: DropdownMenuItemProps) {
return (
<Menu.Item
className={cn(
menuRowBaseClassName,
menuRowStateClassName,
destructive && 'text-text-destructive',
className,
)}
{...props}
/>
)
}
export function DropdownMenuSeparator({
className,
...props
}: React.ComponentPropsWithoutRef<typeof Menu.Separator>) {
return (
<Menu.Separator
className={cn('my-1 h-px bg-divider-regular', className)}
{...props}
/>
)
}

View File

@ -0,0 +1,29 @@
// Placement type for overlay positioning.
// Mirrors the Floating UI Placement spec — a stable set of 12 CSS-based position values.
// Reference: https://floating-ui.com/docs/useFloating#placement
type Side = 'top' | 'bottom' | 'left' | 'right'
type Align = 'start' | 'center' | 'end'
export type Placement
= 'top'
| 'top-start'
| 'top-end'
| 'right'
| 'right-start'
| 'right-end'
| 'bottom'
| 'bottom-start'
| 'bottom-end'
| 'left'
| 'left-start'
| 'left-end'
export function parsePlacement(placement: Placement): { side: Side, align: Align } {
const [side, align] = placement.split('-') as [Side, Align | undefined]
return {
side,
align: align ?? 'center',
}
}

View File

@ -0,0 +1,67 @@
'use client'
import type { Placement } from '@/app/components/base/ui/placement'
import { Popover as BasePopover } from '@base-ui/react/popover'
import * as React from 'react'
import { parsePlacement } from '@/app/components/base/ui/placement'
import { cn } from '@/utils/classnames'
export const Popover = BasePopover.Root
export const PopoverTrigger = BasePopover.Trigger
export const PopoverClose = BasePopover.Close
export const PopoverTitle = BasePopover.Title
export const PopoverDescription = BasePopover.Description
type PopoverContentProps = {
children: React.ReactNode
placement?: Placement
sideOffset?: number
alignOffset?: number
className?: string
popupClassName?: string
positionerProps?: Omit<
React.ComponentPropsWithoutRef<typeof BasePopover.Positioner>,
'children' | 'className' | 'side' | 'align' | 'sideOffset' | 'alignOffset'
>
popupProps?: Omit<
React.ComponentPropsWithoutRef<typeof BasePopover.Popup>,
'children' | 'className'
>
}
export function PopoverContent({
children,
placement = 'bottom',
sideOffset = 8,
alignOffset = 0,
className,
popupClassName,
positionerProps,
popupProps,
}: PopoverContentProps) {
const { side, align } = parsePlacement(placement)
return (
<BasePopover.Portal>
<BasePopover.Positioner
side={side}
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
className={cn('z-50 outline-none', className)}
{...positionerProps}
>
<BasePopover.Popup
className={cn(
'rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg',
'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',
popupClassName,
)}
{...popupProps}
>
{children}
</BasePopover.Popup>
</BasePopover.Positioner>
</BasePopover.Portal>
)
}

View File

@ -0,0 +1,163 @@
'use client'
import type { Placement } from '@/app/components/base/ui/placement'
import { Select as BaseSelect } from '@base-ui/react/select'
import * as React from 'react'
import { parsePlacement } from '@/app/components/base/ui/placement'
import { cn } from '@/utils/classnames'
export const Select = BaseSelect.Root
export const SelectValue = BaseSelect.Value
export const SelectGroup = BaseSelect.Group
export const SelectGroupLabel = BaseSelect.GroupLabel
export const SelectSeparator = BaseSelect.Separator
type SelectTriggerProps = React.ComponentPropsWithoutRef<typeof BaseSelect.Trigger> & {
clearable?: boolean
onClear?: () => void
loading?: boolean
}
export function SelectTrigger({
className,
children,
clearable = false,
onClear,
loading = false,
...props
}: SelectTriggerProps) {
const showClear = clearable && !loading
return (
<BaseSelect.Trigger
className={cn(
'group relative flex h-8 w-full items-center rounded-lg border-0 bg-components-input-bg-normal px-2 text-left text-components-input-text-filled outline-none',
'hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
>
<span className="grow truncate">{children}</span>
{loading
? (
<span className="ml-1 shrink-0 text-text-quaternary">
<span className="i-ri-loader-4-line h-3.5 w-3.5 animate-spin" />
</span>
)
: showClear
? (
<span
role="button"
aria-label="Clear selection"
tabIndex={-1}
className="ml-1 shrink-0 cursor-pointer text-text-quaternary hover:text-text-secondary"
onClick={(e) => {
e.stopPropagation()
onClear?.()
}}
onMouseDown={(e) => {
e.stopPropagation()
}}
>
<span className="i-ri-close-circle-fill h-3.5 w-3.5" />
</span>
)
: (
<BaseSelect.Icon className="ml-1 shrink-0 text-text-quaternary transition-colors group-hover:text-text-secondary data-[open]:text-text-secondary">
<span className="i-ri-arrow-down-s-line h-4 w-4" />
</BaseSelect.Icon>
)}
</BaseSelect.Trigger>
)
}
type SelectContentProps = {
children: React.ReactNode
placement?: Placement
sideOffset?: number
alignOffset?: number
className?: string
popupClassName?: string
listClassName?: string
positionerProps?: Omit<
React.ComponentPropsWithoutRef<typeof BaseSelect.Positioner>,
'children' | 'className' | 'side' | 'align' | 'sideOffset' | 'alignOffset'
>
popupProps?: Omit<
React.ComponentPropsWithoutRef<typeof BaseSelect.Popup>,
'children' | 'className'
>
listProps?: Omit<
React.ComponentPropsWithoutRef<typeof BaseSelect.List>,
'children' | 'className'
>
}
export function SelectContent({
children,
placement = 'bottom-start',
sideOffset = 4,
alignOffset = 0,
className,
popupClassName,
listClassName,
positionerProps,
popupProps,
listProps,
}: SelectContentProps) {
const { side, align } = parsePlacement(placement)
return (
<BaseSelect.Portal>
<BaseSelect.Positioner
side={side}
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
alignItemWithTrigger={false}
className={cn('z-50 outline-none', className)}
{...positionerProps}
>
<BaseSelect.Popup
className={cn(
'rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg',
'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',
popupClassName,
)}
{...popupProps}
>
<BaseSelect.List
className={cn('max-h-80 min-w-[10rem] overflow-auto p-1 outline-none', listClassName)}
{...listProps}
>
{children}
</BaseSelect.List>
</BaseSelect.Popup>
</BaseSelect.Positioner>
</BaseSelect.Portal>
)
}
export function SelectItem({
className,
children,
...props
}: React.ComponentPropsWithoutRef<typeof BaseSelect.Item>) {
return (
<BaseSelect.Item
className={cn(
'flex h-8 cursor-pointer items-center rounded-lg px-2 text-text-secondary outline-none system-sm-medium',
'data-[disabled]:cursor-not-allowed data-[highlighted]:bg-state-base-hover data-[disabled]:opacity-50',
className,
)}
{...props}
>
<BaseSelect.ItemText className="mr-1 grow truncate px-1">
{children}
</BaseSelect.ItemText>
<BaseSelect.ItemIndicator className="flex shrink-0 items-center text-text-accent">
<span className="i-ri-check-line h-4 w-4" />
</BaseSelect.ItemIndicator>
</BaseSelect.Item>
)
}

View File

@ -0,0 +1,59 @@
'use client'
import type { Placement } from '@/app/components/base/ui/placement'
import { Tooltip as BaseTooltip } from '@base-ui/react/tooltip'
import * as React from 'react'
import { parsePlacement } from '@/app/components/base/ui/placement'
import { cn } from '@/utils/classnames'
type TooltipContentVariant = 'default' | 'plain'
export type TooltipContentProps = {
children: React.ReactNode
placement?: Placement
sideOffset?: number
alignOffset?: number
className?: string
popupClassName?: string
variant?: TooltipContentVariant
} & Omit<React.ComponentPropsWithoutRef<typeof BaseTooltip.Popup>, 'children' | 'className'>
export function TooltipContent({
children,
placement = 'top',
sideOffset = 8,
alignOffset = 0,
className,
popupClassName,
variant = 'default',
...props
}: TooltipContentProps) {
const { side, align } = parsePlacement(placement)
return (
<BaseTooltip.Portal>
<BaseTooltip.Positioner
side={side}
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
className={cn('z-50 outline-none', className)}
>
<BaseTooltip.Popup
className={cn(
variant === 'default' && 'max-w-[300px] break-words rounded-md bg-components-panel-bg px-3 py-2 text-left text-text-tertiary shadow-lg system-xs-regular',
'origin-[var(--transform-origin)] transition-[opacity] data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 data-[instant]:transition-none motion-reduce:transition-none',
popupClassName,
)}
{...props}
>
{children}
</BaseTooltip.Popup>
</BaseTooltip.Positioner>
</BaseTooltip.Portal>
)
}
export const TooltipProvider = BaseTooltip.Provider
export const Tooltip = BaseTooltip.Root
export const TooltipTrigger = BaseTooltip.Trigger

View File

@ -1,6 +1,7 @@
import type { ModalContextState } from '@/context/modal-context' import type { ModalContextState } from '@/context/modal-context'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/app/components/base/ui/dropdown-menu'
import { Plan } from '@/app/components/billing/type' import { Plan } from '@/app/components/billing/type'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useModalContext } from '@/context/modal-context' import { useModalContext } from '@/context/modal-context'
@ -70,16 +71,26 @@ describe('Compliance', () => {
) )
} }
// Wrapper for tests that need the menu open const renderCompliance = () => {
return renderWithQueryClient(
<DropdownMenu open={true} onOpenChange={() => {}}>
<DropdownMenuTrigger>open</DropdownMenuTrigger>
<DropdownMenuContent>
<Compliance />
</DropdownMenuContent>
</DropdownMenu>,
)
}
const openMenuAndRender = () => { const openMenuAndRender = () => {
renderWithQueryClient(<Compliance />) renderCompliance()
fireEvent.click(screen.getByRole('button')) fireEvent.click(screen.getByText('common.userProfile.compliance'))
} }
describe('Rendering', () => { describe('Rendering', () => {
it('should render compliance menu trigger', () => { it('should render compliance menu trigger', () => {
// Act // Act
renderWithQueryClient(<Compliance />) renderCompliance()
// Assert // Assert
expect(screen.getByText('common.userProfile.compliance')).toBeInTheDocument() expect(screen.getByText('common.userProfile.compliance')).toBeInTheDocument()

View File

@ -1,9 +1,9 @@
import type { FC, MouseEvent } from 'react' import type { ReactNode } from 'react'
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'
import { RiArrowDownCircleLine, RiArrowRightSLine, RiVerifiedBadgeLine } from '@remixicon/react'
import { useMutation } from '@tanstack/react-query' import { useMutation } from '@tanstack/react-query'
import { Fragment, useCallback } from 'react' import { useCallback } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger } from '@/app/components/base/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
import { Plan } from '@/app/components/billing/type' import { Plan } from '@/app/components/billing/type'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useModalContext } from '@/context/modal-context' import { useModalContext } from '@/context/modal-context'
@ -11,14 +11,14 @@ import { useProviderContext } from '@/context/provider-context'
import { getDocDownloadUrl } from '@/service/common' import { getDocDownloadUrl } from '@/service/common'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
import { downloadUrl } from '@/utils/download' import { downloadUrl } from '@/utils/download'
import Button from '../../base/button'
import Gdpr from '../../base/icons/src/public/common/Gdpr' import Gdpr from '../../base/icons/src/public/common/Gdpr'
import Iso from '../../base/icons/src/public/common/Iso' import Iso from '../../base/icons/src/public/common/Iso'
import Soc2 from '../../base/icons/src/public/common/Soc2' import Soc2 from '../../base/icons/src/public/common/Soc2'
import SparklesSoft from '../../base/icons/src/public/common/SparklesSoft' import SparklesSoft from '../../base/icons/src/public/common/SparklesSoft'
import PremiumBadge from '../../base/premium-badge' import PremiumBadge from '../../base/premium-badge'
import Spinner from '../../base/spinner'
import Toast from '../../base/toast' import Toast from '../../base/toast'
import Tooltip from '../../base/tooltip' import { MenuItemContent } from './menu-item-content'
enum DocName { enum DocName {
SOC2_Type_I = 'SOC2_Type_I', SOC2_Type_I = 'SOC2_Type_I',
@ -27,27 +27,83 @@ enum DocName {
GDPR = 'GDPR', GDPR = 'GDPR',
} }
type UpgradeOrDownloadProps = { type ComplianceDocActionVisualProps = {
doc_name: DocName isCurrentPlanCanDownload: boolean
isPending: boolean
tooltipText: string
downloadText: string
upgradeText: string
} }
const UpgradeOrDownload: FC<UpgradeOrDownloadProps> = ({ doc_name }) => {
function ComplianceDocActionVisual({
isCurrentPlanCanDownload,
isPending,
tooltipText,
downloadText,
upgradeText,
}: ComplianceDocActionVisualProps) {
if (isCurrentPlanCanDownload) {
return (
<div
aria-hidden
className={cn(
'btn btn-small btn-secondary pointer-events-none flex items-center gap-[1px]',
isPending && 'btn-disabled',
)}
>
<span className="i-ri-arrow-down-circle-line size-[14px] text-components-button-secondary-text-disabled" />
<span className="px-[3px] text-components-button-secondary-text system-xs-medium">{downloadText}</span>
{isPending && <Spinner loading={true} className="!ml-1 !h-3 !w-3 !border-2 !text-text-tertiary" />}
</div>
)
}
const canShowUpgradeTooltip = tooltipText.length > 0
return (
<Tooltip>
<TooltipTrigger
delay={0}
disabled={!canShowUpgradeTooltip}
render={(
<PremiumBadge color="blue" allowHover={true}>
<SparklesSoft className="flex h-3.5 w-3.5 items-center py-[1px] pl-[3px] text-components-premium-badge-indigo-text-stop-0" />
<div className="px-1 system-xs-medium">
{upgradeText}
</div>
</PremiumBadge>
)}
/>
{canShowUpgradeTooltip && (
<TooltipContent>
{tooltipText}
</TooltipContent>
)}
</Tooltip>
)
}
type ComplianceDocRowItemProps = {
icon: ReactNode
label: ReactNode
docName: DocName
}
function ComplianceDocRowItem({
icon,
label,
docName,
}: ComplianceDocRowItemProps) {
const { t } = useTranslation() const { t } = useTranslation()
const { plan } = useProviderContext() const { plan } = useProviderContext()
const { setShowPricingModal, setShowAccountSettingModal } = useModalContext() const { setShowPricingModal, setShowAccountSettingModal } = useModalContext()
const isFreePlan = plan.type === Plan.sandbox const isFreePlan = plan.type === Plan.sandbox
const handlePlanClick = useCallback(() => {
if (isFreePlan)
setShowPricingModal()
else
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.BILLING })
}, [isFreePlan, setShowAccountSettingModal, setShowPricingModal])
const { isPending, mutate: downloadCompliance } = useMutation({ const { isPending, mutate: downloadCompliance } = useMutation({
mutationKey: ['downloadCompliance', doc_name], mutationKey: ['downloadCompliance', docName],
mutationFn: async () => { mutationFn: async () => {
try { try {
const ret = await getDocDownloadUrl(doc_name) const ret = await getDocDownloadUrl(docName)
downloadUrl({ url: ret.url }) downloadUrl({ url: ret.url })
Toast.notify({ Toast.notify({
type: 'success', type: 'success',
@ -63,6 +119,7 @@ const UpgradeOrDownload: FC<UpgradeOrDownloadProps> = ({ doc_name }) => {
} }
}, },
}) })
const whichPlanCanDownloadCompliance = { const whichPlanCanDownloadCompliance = {
[DocName.SOC2_Type_I]: [Plan.professional, Plan.team], [DocName.SOC2_Type_I]: [Plan.professional, Plan.team],
[DocName.SOC2_Type_II]: [Plan.team], [DocName.SOC2_Type_II]: [Plan.team],
@ -70,118 +127,85 @@ const UpgradeOrDownload: FC<UpgradeOrDownloadProps> = ({ doc_name }) => {
[DocName.GDPR]: [Plan.team, Plan.professional, Plan.sandbox], [DocName.GDPR]: [Plan.team, Plan.professional, Plan.sandbox],
} }
const isCurrentPlanCanDownload = whichPlanCanDownloadCompliance[doc_name].includes(plan.type) const isCurrentPlanCanDownload = whichPlanCanDownloadCompliance[docName].includes(plan.type)
const handleDownloadClick = useCallback((e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault() const handleSelect = useCallback(() => {
downloadCompliance() if (isCurrentPlanCanDownload) {
}, [downloadCompliance]) if (!isPending)
if (isCurrentPlanCanDownload) { downloadCompliance()
return ( return
<Button loading={isPending} disabled={isPending} size="small" variant="secondary" className="flex items-center gap-[1px]" onClick={handleDownloadClick}> }
<RiArrowDownCircleLine className="size-[14px] text-components-button-secondary-text-disabled" />
<span className="system-xs-medium px-[3px] text-components-button-secondary-text">{t('operation.download', { ns: 'common' })}</span> if (isFreePlan)
</Button> setShowPricingModal()
) else
} setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.BILLING })
}, [downloadCompliance, isCurrentPlanCanDownload, isFreePlan, isPending, setShowAccountSettingModal, setShowPricingModal])
const upgradeTooltip: Record<Plan, string> = { const upgradeTooltip: Record<Plan, string> = {
[Plan.sandbox]: t('compliance.sandboxUpgradeTooltip', { ns: 'common' }), [Plan.sandbox]: t('compliance.sandboxUpgradeTooltip', { ns: 'common' }),
[Plan.professional]: t('compliance.professionalUpgradeTooltip', { ns: 'common' }), [Plan.professional]: t('compliance.professionalUpgradeTooltip', { ns: 'common' }),
[Plan.team]: '', [Plan.team]: '',
[Plan.enterprise]: '', [Plan.enterprise]: '',
} }
return ( return (
<Tooltip asChild={false} popupContent={upgradeTooltip[plan.type]}> <DropdownMenuItem
<PremiumBadge color="blue" allowHover={true} onClick={handlePlanClick}> className="h-10 justify-between py-1 pl-1 pr-2"
<SparklesSoft className="flex h-3.5 w-3.5 items-center py-[1px] pl-[3px] text-components-premium-badge-indigo-text-stop-0" /> closeOnClick={!isCurrentPlanCanDownload}
<div className="system-xs-medium"> onClick={handleSelect}
<span className="p-1"> >
{t('upgradeBtn.encourageShort', { ns: 'billing' })} {icon}
</span> <div className="grow truncate px-1 text-text-secondary system-md-regular">{label}</div>
</div> <ComplianceDocActionVisual
</PremiumBadge> isCurrentPlanCanDownload={isCurrentPlanCanDownload}
</Tooltip> isPending={isPending}
tooltipText={upgradeTooltip[plan.type]}
downloadText={t('operation.download', { ns: 'common' })}
upgradeText={t('upgradeBtn.encourageShort', { ns: 'billing' })}
/>
</DropdownMenuItem>
) )
} }
// Submenu-only: this component must be rendered within an existing DropdownMenu root.
export default function Compliance() { export default function Compliance() {
const itemClassName = `
flex items-center w-full h-10 pl-1 pr-2 py-1 text-text-secondary system-md-regular
rounded-lg hover:bg-state-base-hover gap-1
`
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<Menu as="div" className="relative h-full w-full"> <DropdownMenuSub>
{ <DropdownMenuSubTrigger>
({ open }) => ( <MenuItemContent
<> iconClassName="i-ri-verified-badge-line"
<MenuButton className={ label={t('userProfile.compliance', { ns: 'common' })}
cn('group flex h-9 w-full items-center gap-1 rounded-lg py-2 pl-3 pr-2 hover:bg-state-base-hover', open && 'bg-state-base-hover') />
} </DropdownMenuSubTrigger>
> <DropdownMenuSubContent
<RiVerifiedBadgeLine className="size-4 shrink-0 text-text-tertiary" /> popupClassName="w-[337px] divide-y divide-divider-subtle !bg-components-panel-bg-blur !py-0 backdrop-blur-sm"
<div className="system-md-regular grow px-1 text-left text-text-secondary">{t('userProfile.compliance', { ns: 'common' })}</div> >
<RiArrowRightSLine className="size-[14px] shrink-0 text-text-tertiary" /> <DropdownMenuGroup className="p-1">
</MenuButton> <ComplianceDocRowItem
<Transition icon={<Soc2 aria-hidden className="size-7 shrink-0" />}
as={Fragment} label={t('compliance.soc2Type1', { ns: 'common' })}
enter="transition ease-out duration-100" docName={DocName.SOC2_Type_I}
enterFrom="transform opacity-0 scale-95" />
enterTo="transform opacity-100 scale-100" <ComplianceDocRowItem
leave="transition ease-in duration-75" icon={<Soc2 aria-hidden className="size-7 shrink-0" />}
leaveFrom="transform opacity-100 scale-100" label={t('compliance.soc2Type2', { ns: 'common' })}
leaveTo="transform opacity-0 scale-95" docName={DocName.SOC2_Type_II}
> />
<MenuItems <ComplianceDocRowItem
className={cn( icon={<Iso aria-hidden className="size-7 shrink-0" />}
`absolute top-[1px] z-10 max-h-[70vh] w-[337px] origin-top-right -translate-x-full divide-y divide-divider-subtle overflow-y-scroll label={t('compliance.iso27001', { ns: 'common' })}
rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px] focus:outline-none docName={DocName.ISO_27001}
`, />
)} <ComplianceDocRowItem
> icon={<Gdpr aria-hidden className="size-7 shrink-0" />}
<div className="px-1 py-1"> label={t('compliance.gdpr', { ns: 'common' })}
<MenuItem> docName={DocName.GDPR}
<div />
className={cn(itemClassName, 'group justify-between', 'data-[active]:bg-state-base-hover')} </DropdownMenuGroup>
> </DropdownMenuSubContent>
<Soc2 className="size-7 shrink-0" /> </DropdownMenuSub>
<div className="system-md-regular grow truncate px-1 text-text-secondary">{t('compliance.soc2Type1', { ns: 'common' })}</div>
<UpgradeOrDownload doc_name={DocName.SOC2_Type_I} />
</div>
</MenuItem>
<MenuItem>
<div
className={cn(itemClassName, 'group justify-between', 'data-[active]:bg-state-base-hover')}
>
<Soc2 className="size-7 shrink-0" />
<div className="system-md-regular grow truncate px-1 text-text-secondary">{t('compliance.soc2Type2', { ns: 'common' })}</div>
<UpgradeOrDownload doc_name={DocName.SOC2_Type_II} />
</div>
</MenuItem>
<MenuItem>
<div
className={cn(itemClassName, 'group justify-between', 'data-[active]:bg-state-base-hover')}
>
<Iso className="size-7 shrink-0" />
<div className="system-md-regular grow truncate px-1 text-text-secondary">{t('compliance.iso27001', { ns: 'common' })}</div>
<UpgradeOrDownload doc_name={DocName.ISO_27001} />
</div>
</MenuItem>
<MenuItem>
<div
className={cn(itemClassName, 'group justify-between', 'data-[active]:bg-state-base-hover')}
>
<Gdpr className="size-7 shrink-0" />
<div className="system-md-regular grow truncate px-1 text-text-secondary">{t('compliance.gdpr', { ns: 'common' })}</div>
<UpgradeOrDownload doc_name={DocName.GDPR} />
</div>
</MenuItem>
</div>
</MenuItems>
</Transition>
</>
)
}
</Menu>
) )
} }

View File

@ -65,6 +65,7 @@ vi.mock('@/context/i18n', () => ({
const { mockConfig, mockEnv } = vi.hoisted(() => ({ const { mockConfig, mockEnv } = vi.hoisted(() => ({
mockConfig: { mockConfig: {
IS_CLOUD_EDITION: false, IS_CLOUD_EDITION: false,
ZENDESK_WIDGET_KEY: '',
}, },
mockEnv: { mockEnv: {
env: { env: {
@ -74,6 +75,7 @@ const { mockConfig, mockEnv } = vi.hoisted(() => ({
})) }))
vi.mock('@/config', () => ({ vi.mock('@/config', () => ({
get IS_CLOUD_EDITION() { return mockConfig.IS_CLOUD_EDITION }, get IS_CLOUD_EDITION() { return mockConfig.IS_CLOUD_EDITION },
get ZENDESK_WIDGET_KEY() { return mockConfig.ZENDESK_WIDGET_KEY },
IS_DEV: false, IS_DEV: false,
IS_CE_EDITION: false, IS_CE_EDITION: false,
})) }))
@ -187,6 +189,14 @@ describe('AccountDropdown', () => {
expect(screen.getByText('test@example.com')).toBeInTheDocument() expect(screen.getByText('test@example.com')).toBeInTheDocument()
}) })
it('should set an accessible label on avatar trigger when menu trigger is rendered', () => {
// Act
renderWithRouter(<AppSelector />)
// Assert
expect(screen.getByRole('button', { name: 'common.account.account' })).toBeInTheDocument()
})
it('should show EDU badge for education accounts', () => { it('should show EDU badge for education accounts', () => {
// Arrange // Arrange
vi.mocked(useProviderContext).mockReturnValue({ vi.mocked(useProviderContext).mockReturnValue({

View File

@ -1,26 +1,15 @@
'use client' 'use client'
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'
import { import type { MouseEventHandler, ReactNode } from 'react'
RiAccountCircleLine,
RiArrowRightUpLine,
RiBookOpenLine,
RiGithubLine,
RiGraduationCapFill,
RiInformation2Line,
RiLogoutBoxRLine,
RiMap2Line,
RiSettings3Line,
RiStarLine,
RiTShirt2Line,
} from '@remixicon/react'
import Link from 'next/link' import Link from 'next/link'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { Fragment, useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { resetUser } from '@/app/components/base/amplitude/utils' import { resetUser } from '@/app/components/base/amplitude/utils'
import Avatar from '@/app/components/base/avatar' import Avatar from '@/app/components/base/avatar'
import PremiumBadge from '@/app/components/base/premium-badge' import PremiumBadge from '@/app/components/base/premium-badge'
import ThemeSwitcher from '@/app/components/base/theme-switcher' import ThemeSwitcher from '@/app/components/base/theme-switcher'
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/app/components/base/ui/dropdown-menu'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { IS_CLOUD_EDITION } from '@/config' import { IS_CLOUD_EDITION } from '@/config'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
@ -35,15 +24,90 @@ import AccountAbout from '../account-about'
import GithubStar from '../github-star' import GithubStar from '../github-star'
import Indicator from '../indicator' import Indicator from '../indicator'
import Compliance from './compliance' import Compliance from './compliance'
import { ExternalLinkIndicator, MenuItemContent } from './menu-item-content'
import Support from './support' import Support from './support'
type AccountMenuRouteItemProps = {
href: string
iconClassName: string
label: ReactNode
trailing?: ReactNode
}
function AccountMenuRouteItem({
href,
iconClassName,
label,
trailing,
}: AccountMenuRouteItemProps) {
return (
<DropdownMenuItem
className="justify-between"
render={<Link href={href} />}
>
<MenuItemContent iconClassName={iconClassName} label={label} trailing={trailing} />
</DropdownMenuItem>
)
}
type AccountMenuExternalItemProps = {
href: string
iconClassName: string
label: ReactNode
trailing?: ReactNode
}
function AccountMenuExternalItem({
href,
iconClassName,
label,
trailing,
}: AccountMenuExternalItemProps) {
return (
<DropdownMenuItem
className="justify-between"
render={<a href={href} rel="noopener noreferrer" target="_blank" />}
>
<MenuItemContent iconClassName={iconClassName} label={label} trailing={trailing} />
</DropdownMenuItem>
)
}
type AccountMenuActionItemProps = {
iconClassName: string
label: ReactNode
onClick?: MouseEventHandler<HTMLElement>
trailing?: ReactNode
}
function AccountMenuActionItem({
iconClassName,
label,
onClick,
trailing,
}: AccountMenuActionItemProps) {
return (
<DropdownMenuItem
className="justify-between"
onClick={onClick}
>
<MenuItemContent iconClassName={iconClassName} label={label} trailing={trailing} />
</DropdownMenuItem>
)
}
type AccountMenuSectionProps = {
children: ReactNode
}
function AccountMenuSection({ children }: AccountMenuSectionProps) {
return <DropdownMenuGroup className="p-1">{children}</DropdownMenuGroup>
}
export default function AppSelector() { export default function AppSelector() {
const itemClassName = `
flex items-center w-full h-8 pl-3 pr-2 text-text-secondary system-md-regular
rounded-lg hover:bg-state-base-hover cursor-pointer gap-1
`
const router = useRouter() const router = useRouter()
const [aboutVisible, setAboutVisible] = useState(false) const [aboutVisible, setAboutVisible] = useState(false)
const [isAccountMenuOpen, setIsAccountMenuOpen] = useState(false)
const { systemFeatures } = useGlobalPublicStore() const { systemFeatures } = useGlobalPublicStore()
const { t } = useTranslation() const { t } = useTranslation()
@ -68,161 +132,124 @@ export default function AppSelector() {
} }
return ( return (
<div className=""> <div>
<Menu as="div" className="relative inline-block text-left"> <DropdownMenu open={isAccountMenuOpen} onOpenChange={setIsAccountMenuOpen}>
{ <DropdownMenuTrigger
({ open, close }) => ( aria-label={t('account.account', { ns: 'common' })}
<> className={cn('inline-flex items-center rounded-[20px] p-0.5 hover:bg-background-default-dodge', isAccountMenuOpen && 'bg-background-default-dodge')}
<MenuButton className={cn('inline-flex items-center rounded-[20px] p-0.5 hover:bg-background-default-dodge', open && 'bg-background-default-dodge')}> >
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={36} /> <Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={36} />
</MenuButton> </DropdownMenuTrigger>
<Transition <DropdownMenuContent
as={Fragment} sideOffset={6}
enter="transition ease-out duration-100" popupClassName="w-60 max-w-80 !bg-components-panel-bg-blur !py-0 backdrop-blur-sm"
enterFrom="transform opacity-0 scale-95" >
enterTo="transform opacity-100 scale-100" <DropdownMenuGroup className="px-1 py-1">
leave="transition ease-in duration-75" <div className="flex flex-nowrap items-center py-2 pl-3 pr-2">
leaveFrom="transform opacity-100 scale-100" <div className="grow">
leaveTo="transform opacity-0 scale-95" <div className="break-all text-text-primary system-md-medium">
> {userProfile.name}
<MenuItems {isEducationAccount && (
className=" <PremiumBadge size="s" color="blue" className="ml-1 !px-2">
absolute right-0 mt-1.5 w-60 max-w-80 <span aria-hidden className="i-ri-graduation-cap-fill mr-1 h-3 w-3" />
origin-top-right divide-y divide-divider-subtle rounded-xl bg-components-panel-bg-blur shadow-lg <span className="system-2xs-medium">EDU</span>
backdrop-blur-sm focus:outline-none </PremiumBadge>
"
>
<div className="px-1 py-1">
<MenuItem disabled>
<div className="flex flex-nowrap items-center py-2 pl-3 pr-2">
<div className="grow">
<div className="system-md-medium break-all text-text-primary">
{userProfile.name}
{isEducationAccount && (
<PremiumBadge size="s" color="blue" className="ml-1 !px-2">
<RiGraduationCapFill className="mr-1 h-3 w-3" />
<span className="system-2xs-medium">EDU</span>
</PremiumBadge>
)}
</div>
<div className="system-xs-regular break-all text-text-tertiary">{userProfile.email}</div>
</div>
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={36} />
</div>
</MenuItem>
<MenuItem>
<Link
className={cn(itemClassName, 'group', 'data-[active]:bg-state-base-hover')}
href="/account"
target="_self"
rel="noopener noreferrer"
>
<RiAccountCircleLine className="size-4 shrink-0 text-text-tertiary" />
<div className="system-md-regular grow px-1 text-text-secondary">{t('account.account', { ns: 'common' })}</div>
<RiArrowRightUpLine className="size-[14px] shrink-0 text-text-tertiary" />
</Link>
</MenuItem>
<MenuItem>
<div
className={cn(itemClassName, 'data-[active]:bg-state-base-hover')}
onClick={() => setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.MEMBERS })}
>
<RiSettings3Line className="size-4 shrink-0 text-text-tertiary" />
<div className="system-md-regular grow px-1 text-text-secondary">{t('userProfile.settings', { ns: 'common' })}</div>
</div>
</MenuItem>
</div>
{!systemFeatures.branding.enabled && (
<>
<div className="p-1">
<MenuItem>
<Link
className={cn(itemClassName, 'group justify-between', 'data-[active]:bg-state-base-hover')}
href={docLink('/use-dify/getting-started/introduction')}
target="_blank"
rel="noopener noreferrer"
>
<RiBookOpenLine className="size-4 shrink-0 text-text-tertiary" />
<div className="system-md-regular grow px-1 text-text-secondary">{t('userProfile.helpCenter', { ns: 'common' })}</div>
<RiArrowRightUpLine className="size-[14px] shrink-0 text-text-tertiary" />
</Link>
</MenuItem>
<Support closeAccountDropdown={close} />
{IS_CLOUD_EDITION && isCurrentWorkspaceOwner && <Compliance />}
</div>
<div className="p-1">
<MenuItem>
<Link
className={cn(itemClassName, 'group justify-between', 'data-[active]:bg-state-base-hover')}
href="https://roadmap.dify.ai"
target="_blank"
rel="noopener noreferrer"
>
<RiMap2Line className="size-4 shrink-0 text-text-tertiary" />
<div className="system-md-regular grow px-1 text-text-secondary">{t('userProfile.roadmap', { ns: 'common' })}</div>
<RiArrowRightUpLine className="size-[14px] shrink-0 text-text-tertiary" />
</Link>
</MenuItem>
<MenuItem>
<Link
className={cn(itemClassName, 'group justify-between', 'data-[active]:bg-state-base-hover')}
href="https://github.com/langgenius/dify"
target="_blank"
rel="noopener noreferrer"
>
<RiGithubLine className="size-4 shrink-0 text-text-tertiary" />
<div className="system-md-regular grow px-1 text-text-secondary">{t('userProfile.github', { ns: 'common' })}</div>
<div className="flex items-center gap-0.5 rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-[5px] py-[3px]">
<RiStarLine className="size-3 shrink-0 text-text-tertiary" />
<GithubStar className="system-2xs-medium-uppercase text-text-tertiary" />
</div>
</Link>
</MenuItem>
{
env.NEXT_PUBLIC_SITE_ABOUT !== 'hide' && (
<MenuItem>
<div
className={cn(itemClassName, 'justify-between', 'data-[active]:bg-state-base-hover')}
onClick={() => setAboutVisible(true)}
>
<RiInformation2Line className="size-4 shrink-0 text-text-tertiary" />
<div className="system-md-regular grow px-1 text-text-secondary">{t('userProfile.about', { ns: 'common' })}</div>
<div className="flex shrink-0 items-center">
<div className="system-xs-regular mr-2 text-text-tertiary">{langGeniusVersionInfo.current_version}</div>
<Indicator color={langGeniusVersionInfo.current_version === langGeniusVersionInfo.latest_version ? 'green' : 'orange'} />
</div>
</div>
</MenuItem>
)
}
</div>
</>
)} )}
<MenuItem disabled> </div>
<div className="p-1"> <div className="break-all text-text-tertiary system-xs-regular">{userProfile.email}</div>
<div className={cn(itemClassName, 'hover:bg-transparent')}> </div>
<RiTShirt2Line className="size-4 shrink-0 text-text-tertiary" /> <Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={36} />
<div className="system-md-regular grow px-1 text-text-secondary">{t('theme.theme', { ns: 'common' })}</div> </div>
<ThemeSwitcher /> <AccountMenuRouteItem
</div> href="/account"
iconClassName="i-ri-account-circle-line"
label={t('account.account', { ns: 'common' })}
trailing={<ExternalLinkIndicator />}
/>
<AccountMenuActionItem
iconClassName="i-ri-settings-3-line"
label={t('userProfile.settings', { ns: 'common' })}
onClick={() => setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.MEMBERS })}
/>
</DropdownMenuGroup>
<DropdownMenuSeparator className="!my-0 bg-divider-subtle" />
{!systemFeatures.branding.enabled && (
<>
<AccountMenuSection>
<AccountMenuExternalItem
href={docLink('/use-dify/getting-started/introduction')}
iconClassName="i-ri-book-open-line"
label={t('userProfile.helpCenter', { ns: 'common' })}
trailing={<ExternalLinkIndicator />}
/>
<Support closeAccountDropdown={() => setIsAccountMenuOpen(false)} />
{IS_CLOUD_EDITION && isCurrentWorkspaceOwner && <Compliance />}
</AccountMenuSection>
<DropdownMenuSeparator className="!my-0 bg-divider-subtle" />
<AccountMenuSection>
<AccountMenuExternalItem
href="https://roadmap.dify.ai"
iconClassName="i-ri-map-2-line"
label={t('userProfile.roadmap', { ns: 'common' })}
trailing={<ExternalLinkIndicator />}
/>
<AccountMenuExternalItem
href="https://github.com/langgenius/dify"
iconClassName="i-ri-github-line"
label={t('userProfile.github', { ns: 'common' })}
trailing={(
<div className="flex items-center gap-0.5 rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-[5px] py-[3px]">
<span aria-hidden className="i-ri-star-line size-3 shrink-0 text-text-tertiary" />
<GithubStar className="text-text-tertiary system-2xs-medium-uppercase" />
</div> </div>
</MenuItem> )}
<MenuItem> />
<div className="p-1" onClick={() => handleLogout()}> {
<div env.NEXT_PUBLIC_SITE_ABOUT !== 'hide' && (
className={cn(itemClassName, 'group justify-between', 'data-[active]:bg-state-base-hover')} <AccountMenuActionItem
> iconClassName="i-ri-information-2-line"
<RiLogoutBoxRLine className="size-4 shrink-0 text-text-tertiary" /> label={t('userProfile.about', { ns: 'common' })}
<div className="system-md-regular grow px-1 text-text-secondary">{t('userProfile.logout', { ns: 'common' })}</div> onClick={() => {
</div> setAboutVisible(true)
</div> setIsAccountMenuOpen(false)
</MenuItem> }}
</MenuItems> trailing={(
</Transition> <div className="flex shrink-0 items-center">
<div className="mr-2 text-text-tertiary system-xs-regular">{langGeniusVersionInfo.current_version}</div>
<Indicator color={langGeniusVersionInfo.current_version === langGeniusVersionInfo.latest_version ? 'green' : 'orange'} />
</div>
)}
/>
)
}
</AccountMenuSection>
<DropdownMenuSeparator className="!my-0 bg-divider-subtle" />
</> </>
) )}
} <AccountMenuSection>
</Menu> <DropdownMenuItem
className="cursor-default data-[highlighted]:bg-transparent"
onSelect={e => e.preventDefault()}
>
<MenuItemContent
iconClassName="i-ri-t-shirt-2-line"
label={t('theme.theme', { ns: 'common' })}
trailing={<ThemeSwitcher />}
/>
</DropdownMenuItem>
</AccountMenuSection>
<DropdownMenuSeparator className="!my-0 bg-divider-subtle" />
<AccountMenuSection>
<AccountMenuActionItem
iconClassName="i-ri-logout-box-r-line"
label={t('userProfile.logout', { ns: 'common' })}
onClick={() => {
void handleLogout()
}}
/>
</AccountMenuSection>
</DropdownMenuContent>
</DropdownMenu>
{ {
aboutVisible && <AccountAbout onCancel={() => setAboutVisible(false)} langGeniusVersionInfo={langGeniusVersionInfo} /> aboutVisible && <AccountAbout onCancel={() => setAboutVisible(false)} langGeniusVersionInfo={langGeniusVersionInfo} />
} }

View File

@ -0,0 +1,31 @@
import type { ReactNode } from 'react'
import { cn } from '@/utils/classnames'
const menuLabelClassName = 'min-w-0 grow truncate px-1 text-text-secondary system-md-regular'
const menuLeadingIconClassName = 'size-4 shrink-0 text-text-tertiary'
export const menuTrailingIconClassName = 'size-[14px] shrink-0 text-text-tertiary'
type MenuItemContentProps = {
iconClassName: string
label: ReactNode
trailing?: ReactNode
}
export function MenuItemContent({
iconClassName,
label,
trailing,
}: MenuItemContentProps) {
return (
<>
<span aria-hidden className={cn(menuLeadingIconClassName, iconClassName)} />
<div className={menuLabelClassName}>{label}</div>
{trailing}
</>
)
}
export function ExternalLinkIndicator() {
return <span aria-hidden className={cn('i-ri-arrow-right-up-line', menuTrailingIconClassName)} />
}

View File

@ -1,6 +1,7 @@
import type { AppContextValue } from '@/context/app-context' import type { AppContextValue } from '@/context/app-context'
import { fireEvent, render, screen } from '@testing-library/react' import { fireEvent, render, screen } from '@testing-library/react'
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/app/components/base/ui/dropdown-menu'
import { Plan } from '@/app/components/billing/type' import { Plan } from '@/app/components/billing/type'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
import { baseProviderContextValue, useProviderContext } from '@/context/provider-context' import { baseProviderContextValue, useProviderContext } from '@/context/provider-context'
@ -93,10 +94,21 @@ describe('Support', () => {
}) })
}) })
const renderSupport = () => {
return render(
<DropdownMenu open={true} onOpenChange={() => {}}>
<DropdownMenuTrigger>open</DropdownMenuTrigger>
<DropdownMenuContent>
<Support closeAccountDropdown={mockCloseAccountDropdown} />
</DropdownMenuContent>
</DropdownMenu>,
)
}
describe('Rendering', () => { describe('Rendering', () => {
it('should render support menu trigger', () => { it('should render support menu trigger', () => {
// Act // Act
render(<Support closeAccountDropdown={mockCloseAccountDropdown} />) renderSupport()
// Assert // Assert
expect(screen.getByText('common.userProfile.support')).toBeInTheDocument() expect(screen.getByText('common.userProfile.support')).toBeInTheDocument()
@ -104,8 +116,8 @@ describe('Support', () => {
it('should show forum and community links when opened', () => { it('should show forum and community links when opened', () => {
// Act // Act
render(<Support closeAccountDropdown={mockCloseAccountDropdown} />) renderSupport()
fireEvent.click(screen.getByRole('button')) fireEvent.click(screen.getByText('common.userProfile.support'))
// Assert // Assert
expect(screen.getByText('common.userProfile.forum')).toBeInTheDocument() expect(screen.getByText('common.userProfile.forum')).toBeInTheDocument()
@ -116,8 +128,8 @@ describe('Support', () => {
describe('Plan-based Channels', () => { describe('Plan-based Channels', () => {
it('should show "Contact Us" when ZENDESK_WIDGET_KEY is present', () => { it('should show "Contact Us" when ZENDESK_WIDGET_KEY is present', () => {
// Act // Act
render(<Support closeAccountDropdown={mockCloseAccountDropdown} />) renderSupport()
fireEvent.click(screen.getByRole('button')) fireEvent.click(screen.getByText('common.userProfile.support'))
// Assert // Assert
expect(screen.getByText('common.userProfile.contactUs')).toBeInTheDocument() expect(screen.getByText('common.userProfile.contactUs')).toBeInTheDocument()
@ -134,8 +146,8 @@ describe('Support', () => {
}) })
// Act // Act
render(<Support closeAccountDropdown={mockCloseAccountDropdown} />) renderSupport()
fireEvent.click(screen.getByRole('button')) fireEvent.click(screen.getByText('common.userProfile.support'))
// Assert // Assert
expect(screen.queryByText('common.userProfile.contactUs')).not.toBeInTheDocument() expect(screen.queryByText('common.userProfile.contactUs')).not.toBeInTheDocument()
@ -147,8 +159,8 @@ describe('Support', () => {
mockZendeskKey.value = '' mockZendeskKey.value = ''
// Act // Act
render(<Support closeAccountDropdown={mockCloseAccountDropdown} />) renderSupport()
fireEvent.click(screen.getByRole('button')) fireEvent.click(screen.getByText('common.userProfile.support'))
// Assert // Assert
expect(screen.getByText('common.userProfile.emailSupport')).toBeInTheDocument() expect(screen.getByText('common.userProfile.emailSupport')).toBeInTheDocument()
@ -159,8 +171,8 @@ describe('Support', () => {
describe('Interactions and Links', () => { describe('Interactions and Links', () => {
it('should call toggleZendeskWindow and closeAccountDropdown when "Contact Us" is clicked', () => { it('should call toggleZendeskWindow and closeAccountDropdown when "Contact Us" is clicked', () => {
// Act // Act
render(<Support closeAccountDropdown={mockCloseAccountDropdown} />) renderSupport()
fireEvent.click(screen.getByRole('button')) fireEvent.click(screen.getByText('common.userProfile.support'))
fireEvent.click(screen.getByText('common.userProfile.contactUs')) fireEvent.click(screen.getByText('common.userProfile.contactUs'))
// Assert // Assert
@ -170,8 +182,8 @@ describe('Support', () => {
it('should have correct forum and community links', () => { it('should have correct forum and community links', () => {
// Act // Act
render(<Support closeAccountDropdown={mockCloseAccountDropdown} />) renderSupport()
fireEvent.click(screen.getByRole('button')) fireEvent.click(screen.getByText('common.userProfile.support'))
// Assert // Assert
const forumLink = screen.getByText('common.userProfile.forum').closest('a') const forumLink = screen.getByText('common.userProfile.forum').closest('a')

View File

@ -1,119 +1,85 @@
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'
import { RiArrowRightSLine, RiArrowRightUpLine, RiChatSmile2Line, RiDiscordLine, RiDiscussLine, RiMailSendLine, RiQuestionLine } from '@remixicon/react'
import Link from 'next/link'
import { Fragment } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger } from '@/app/components/base/ui/dropdown-menu'
import { toggleZendeskWindow } from '@/app/components/base/zendesk/utils' import { toggleZendeskWindow } from '@/app/components/base/zendesk/utils'
import { Plan } from '@/app/components/billing/type' import { Plan } from '@/app/components/billing/type'
import { ZENDESK_WIDGET_KEY } from '@/config' import { ZENDESK_WIDGET_KEY } from '@/config'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context' import { useProviderContext } from '@/context/provider-context'
import { cn } from '@/utils/classnames'
import { mailToSupport } from '../utils/util' import { mailToSupport } from '../utils/util'
import { ExternalLinkIndicator, MenuItemContent } from './menu-item-content'
type SupportProps = { type SupportProps = {
closeAccountDropdown: () => void closeAccountDropdown: () => void
} }
// Submenu-only: this component must be rendered within an existing DropdownMenu root.
export default function Support({ closeAccountDropdown }: SupportProps) { export default function Support({ closeAccountDropdown }: SupportProps) {
const itemClassName = `
flex items-center w-full h-9 pl-3 pr-2 text-text-secondary system-md-regular
rounded-lg hover:bg-state-base-hover cursor-pointer gap-1
`
const { t } = useTranslation() const { t } = useTranslation()
const { plan } = useProviderContext() const { plan } = useProviderContext()
const { userProfile, langGeniusVersionInfo } = useAppContext() const { userProfile, langGeniusVersionInfo } = useAppContext()
const hasDedicatedChannel = plan.type !== Plan.sandbox const hasDedicatedChannel = plan.type !== Plan.sandbox
const hasZendeskWidget = !!ZENDESK_WIDGET_KEY?.trim()
return ( return (
<Menu as="div" className="relative h-full w-full"> <DropdownMenuSub>
{ <DropdownMenuSubTrigger>
({ open }) => ( <MenuItemContent
<> iconClassName="i-ri-question-line"
<MenuButton className={ label={t('userProfile.support', { ns: 'common' })}
cn('group flex h-9 w-full items-center gap-1 rounded-lg py-2 pl-3 pr-2 hover:bg-state-base-hover', open && 'bg-state-base-hover') />
} </DropdownMenuSubTrigger>
<DropdownMenuSubContent
popupClassName="w-[216px] divide-y divide-divider-subtle !bg-components-panel-bg-blur !py-0 backdrop-blur-sm"
>
<DropdownMenuGroup className="p-1">
{hasDedicatedChannel && hasZendeskWidget && (
<DropdownMenuItem
className="justify-between"
onClick={() => {
toggleZendeskWindow(true)
closeAccountDropdown()
}}
> >
<RiQuestionLine className="size-4 shrink-0 text-text-tertiary" /> <MenuItemContent
<div className="system-md-regular grow px-1 text-left text-text-secondary">{t('userProfile.support', { ns: 'common' })}</div> iconClassName="i-ri-chat-smile-2-line"
<RiArrowRightSLine className="size-[14px] shrink-0 text-text-tertiary" /> label={t('userProfile.contactUs', { ns: 'common' })}
</MenuButton> />
<Transition </DropdownMenuItem>
as={Fragment} )}
enter="transition ease-out duration-100" {hasDedicatedChannel && !hasZendeskWidget && (
enterFrom="transform opacity-0 scale-95" <DropdownMenuItem
enterTo="transform opacity-100 scale-100" className="justify-between"
leave="transition ease-in duration-75" render={<a href={mailToSupport(userProfile.email, plan.type, langGeniusVersionInfo?.current_version)} rel="noopener noreferrer" target="_blank" />}
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
> >
<MenuItems <MenuItemContent
className={cn( iconClassName="i-ri-mail-send-line"
`absolute top-[1px] z-10 max-h-[70vh] w-[216px] origin-top-right -translate-x-full divide-y divide-divider-subtle overflow-y-auto label={t('userProfile.emailSupport', { ns: 'common' })}
rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px] focus:outline-none trailing={<ExternalLinkIndicator />}
`, />
)} </DropdownMenuItem>
> )}
<div className="px-1 py-1"> <DropdownMenuItem
{hasDedicatedChannel && ( className="justify-between"
<MenuItem> render={<a href="https://forum.dify.ai/" rel="noopener noreferrer" target="_blank" />}
{ZENDESK_WIDGET_KEY && ZENDESK_WIDGET_KEY.trim() !== '' >
? ( <MenuItemContent
<button iconClassName="i-ri-discuss-line"
className={cn(itemClassName, 'group justify-between text-left data-[active]:bg-state-base-hover')} label={t('userProfile.forum', { ns: 'common' })}
onClick={() => { trailing={<ExternalLinkIndicator />}
toggleZendeskWindow(true) />
closeAccountDropdown() </DropdownMenuItem>
}} <DropdownMenuItem
> className="justify-between"
<RiChatSmile2Line className="size-4 shrink-0 text-text-tertiary" /> render={<a href="https://discord.gg/5AEfbxcd9k" rel="noopener noreferrer" target="_blank" />}
<div className="system-md-regular grow px-1 text-text-secondary">{t('userProfile.contactUs', { ns: 'common' })}</div> >
</button> <MenuItemContent
) iconClassName="i-ri-discord-line"
: ( label={t('userProfile.community', { ns: 'common' })}
<a trailing={<ExternalLinkIndicator />}
className={cn(itemClassName, 'group justify-between', 'data-[active]:bg-state-base-hover')} />
href={mailToSupport(userProfile.email, plan.type, langGeniusVersionInfo?.current_version)} </DropdownMenuItem>
target="_blank" </DropdownMenuGroup>
rel="noopener noreferrer" </DropdownMenuSubContent>
> </DropdownMenuSub>
<RiMailSendLine className="size-4 shrink-0 text-text-tertiary" />
<div className="system-md-regular grow px-1 text-text-secondary">{t('userProfile.emailSupport', { ns: 'common' })}</div>
<RiArrowRightUpLine className="size-[14px] shrink-0 text-text-tertiary" />
</a>
)}
</MenuItem>
)}
<MenuItem>
<Link
className={cn(itemClassName, 'group justify-between', 'data-[active]:bg-state-base-hover')}
href="https://forum.dify.ai/"
target="_blank"
rel="noopener noreferrer"
>
<RiDiscussLine className="size-4 shrink-0 text-text-tertiary" />
<div className="system-md-regular grow px-1 text-text-secondary">{t('userProfile.forum', { ns: 'common' })}</div>
<RiArrowRightUpLine className="size-[14px] shrink-0 text-text-tertiary" />
</Link>
</MenuItem>
<MenuItem>
<Link
className={cn(itemClassName, 'group justify-between', 'data-[active]:bg-state-base-hover')}
href="https://discord.gg/5AEfbxcd9k"
target="_blank"
rel="noopener noreferrer"
>
<RiDiscordLine className="size-4 shrink-0 text-text-tertiary" />
<div className="system-md-regular grow px-1 text-text-secondary">{t('userProfile.community', { ns: 'common' })}</div>
<RiArrowRightUpLine className="size-[14px] shrink-0 text-text-tertiary" />
</Link>
</MenuItem>
</div>
</MenuItems>
</Transition>
</>
)
}
</Menu>
) )
} }

View File

@ -9,6 +9,7 @@ import { getDatasetMap } from '@/env'
import { getLocaleOnServer } from '@/i18n-config/server' import { getLocaleOnServer } from '@/i18n-config/server'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
import { ToastProvider } from './components/base/toast' import { ToastProvider } from './components/base/toast'
import { TooltipProvider } from './components/base/ui/tooltip'
import BrowserInitializer from './components/browser-initializer' import BrowserInitializer from './components/browser-initializer'
import { ReactScanLoader } from './components/devtools/react-scan/loader' import { ReactScanLoader } from './components/devtools/react-scan/loader'
import { I18nServerProvider } from './components/provider/i18n-server' import { I18nServerProvider } from './components/provider/i18n-server'
@ -79,7 +80,9 @@ const LocaleLayout = async ({
<I18nServerProvider> <I18nServerProvider>
<ToastProvider> <ToastProvider>
<GlobalPublicStoreProvider> <GlobalPublicStoreProvider>
{children} <TooltipProvider delay={300} closeDelay={200}>
{children}
</TooltipProvider>
</GlobalPublicStoreProvider> </GlobalPublicStoreProvider>
</ToastProvider> </ToastProvider>
</I18nServerProvider> </I18nServerProvider>

View File

@ -43,6 +43,8 @@ This command lints the entire project and is intended for final verification bef
If a new rule causes many existing code errors or automatic fixes generate too many diffs, do not use the `--fix` option for automatic fixes. If a new rule causes many existing code errors or automatic fixes generate too many diffs, do not use the `--fix` option for automatic fixes.
You can introduce the rule first, then use the `--suppress-all` option to temporarily suppress these errors, and gradually fix them in subsequent changes. You can introduce the rule first, then use the `--suppress-all` option to temporarily suppress these errors, and gradually fix them in subsequent changes.
For overlay migration policy and cleanup phases, see [Overlay Migration Guide](./overlay-migration.md).
## Type Check ## Type Check
You should be able to see suggestions from TypeScript in your editor for all open files. You should be able to see suggestions from TypeScript in your editor for all open files.

View File

@ -0,0 +1,50 @@
# Overlay Migration Guide
This document tracks the migration away from legacy `portal-to-follow-elem` APIs.
## Scope
- Deprecated API: `@/app/components/base/portal-to-follow-elem`
- Replacement primitives:
- `@/app/components/base/ui/tooltip`
- `@/app/components/base/ui/dropdown-menu`
- `@/app/components/base/ui/popover`
- `@/app/components/base/ui/dialog`
- `@/app/components/base/ui/select`
- Tracking issue: https://github.com/langgenius/dify/issues/32767
## ESLint policy
- `no-restricted-imports` blocks new usage of `portal-to-follow-elem`.
- The rule is enabled for normal source files and test files are excluded.
- Legacy `app/components/base/*` callers are temporarily allowlisted in ESLint config.
- New files must not be added to the allowlist without migration owner approval.
## Migration phases
1. Business/UI features outside `app/components/base/**`
- Migrate old calls to semantic primitives.
- Keep `eslint-suppressions.json` stable or shrinking.
1. Legacy base components in allowlist
- Migrate allowlisted base callers gradually.
- Remove migrated files from allowlist immediately.
1. Cleanup
- Remove remaining suppressions for `no-restricted-imports`.
- Remove legacy `portal-to-follow-elem` implementation.
## Suppression maintenance
- After each migration batch, run:
```sh
pnpm eslint --prune-suppressions --pass-on-unpruned-suppressions <changed-files>
```
- Never increase suppressions to bypass new code.
- Prefer direct migration over adding suppression entries.
## React Refresh policy for base UI primitives
- We keep primitive aliases (for example `DropdownMenu = Menu.Root`) in the same module.
- For `app/components/base/ui/**/*.tsx`, `react-refresh/only-export-components` is currently set to `off` in ESLint to avoid false positives and IDE noise during migration.
- Do not use file-level `eslint-disable` comments for this policy; keep control in the scoped ESLint override.

File diff suppressed because it is too large Load Diff

View File

@ -6,6 +6,7 @@ import hyoban from 'eslint-plugin-hyoban'
import sonar from 'eslint-plugin-sonarjs' import sonar from 'eslint-plugin-sonarjs'
import storybook from 'eslint-plugin-storybook' import storybook from 'eslint-plugin-storybook'
import dify from './eslint-rules/index.js' import dify from './eslint-rules/index.js'
import { OVERLAY_MIGRATION_LEGACY_BASE_FILES } from './eslint.constants.mjs'
// Enable Tailwind CSS IntelliSense mode for ESLint runs // Enable Tailwind CSS IntelliSense mode for ESLint runs
// See: tailwind-css-plugin.ts // See: tailwind-css-plugin.ts
@ -145,4 +146,51 @@ export default antfu(
'hyoban/no-dependency-version-prefix': 'error', 'hyoban/no-dependency-version-prefix': 'error',
}, },
}, },
{
name: 'dify/base-ui-primitives',
files: ['app/components/base/ui/**/*.tsx'],
rules: {
'react-refresh/only-export-components': 'off',
},
},
{
name: 'dify/overlay-migration',
files: [GLOB_TS, GLOB_TSX],
ignores: [
...GLOB_TESTS,
...OVERLAY_MIGRATION_LEGACY_BASE_FILES,
],
rules: {
'no-restricted-imports': ['error', {
patterns: [{
group: [
'**/portal-to-follow-elem',
'**/portal-to-follow-elem/index',
],
message: 'Deprecated: use semantic overlay primitives from @/app/components/base/ui/ instead. See issue #32767.',
}, {
group: [
'**/base/tooltip',
'**/base/tooltip/index',
],
message: 'Deprecated: use @/app/components/base/ui/tooltip instead. See issue #32767.',
}, {
group: [
'**/base/modal',
'**/base/modal/index',
'**/base/modal/modal',
],
message: 'Deprecated: use @/app/components/base/ui/dialog instead. See issue #32767.',
}, {
group: [
'**/base/select',
'**/base/select/index',
'**/base/select/custom',
'**/base/select/pure',
],
message: 'Deprecated: use @/app/components/base/ui/select instead. See issue #32767.',
}],
}],
},
},
) )

29
web/eslint.constants.mjs Normal file
View File

@ -0,0 +1,29 @@
export const OVERLAY_MIGRATION_LEGACY_BASE_FILES = [
'app/components/base/chat/chat-with-history/header/mobile-operation-dropdown.tsx',
'app/components/base/chat/chat-with-history/header/operation.tsx',
'app/components/base/chat/chat-with-history/inputs-form/view-form-dropdown.tsx',
'app/components/base/chat/chat-with-history/sidebar/operation.tsx',
'app/components/base/chat/chat/citation/popup.tsx',
'app/components/base/chat/chat/citation/progress-tooltip.tsx',
'app/components/base/chat/chat/citation/tooltip.tsx',
'app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown.tsx',
'app/components/base/chip/index.tsx',
'app/components/base/date-and-time-picker/date-picker/index.tsx',
'app/components/base/date-and-time-picker/time-picker/index.tsx',
'app/components/base/dropdown/index.tsx',
'app/components/base/features/new-feature-panel/file-upload/setting-modal.tsx',
'app/components/base/features/new-feature-panel/text-to-speech/voice-settings.tsx',
'app/components/base/file-uploader/file-from-link-or-local/index.tsx',
'app/components/base/image-uploader/chat-image-uploader.tsx',
'app/components/base/image-uploader/text-generation-image-uploader.tsx',
'app/components/base/modal/modal.tsx',
'app/components/base/prompt-editor/plugins/context-block/component.tsx',
'app/components/base/prompt-editor/plugins/history-block/component.tsx',
'app/components/base/select/custom.tsx',
'app/components/base/select/index.tsx',
'app/components/base/select/pure.tsx',
'app/components/base/sort/index.tsx',
'app/components/base/tag-management/filter.tsx',
'app/components/base/theme-selector.tsx',
'app/components/base/tooltip/index.tsx',
]

View File

@ -63,6 +63,7 @@
"dependencies": { "dependencies": {
"@amplitude/analytics-browser": "2.33.1", "@amplitude/analytics-browser": "2.33.1",
"@amplitude/plugin-session-replay-browser": "1.23.6", "@amplitude/plugin-session-replay-browser": "1.23.6",
"@base-ui/react": "1.2.0",
"@emoji-mart/data": "1.2.1", "@emoji-mart/data": "1.2.1",
"@floating-ui/react": "0.26.28", "@floating-ui/react": "0.26.28",
"@formatjs/intl-localematcher": "0.5.10", "@formatjs/intl-localematcher": "0.5.10",

53
web/pnpm-lock.yaml generated
View File

@ -60,6 +60,9 @@ importers:
'@amplitude/plugin-session-replay-browser': '@amplitude/plugin-session-replay-browser':
specifier: 1.23.6 specifier: 1.23.6
version: 1.23.6(@amplitude/rrweb@2.0.0-alpha.35)(rollup@4.56.0) version: 1.23.6(@amplitude/rrweb@2.0.0-alpha.35)(rollup@4.56.0)
'@base-ui/react':
specifier: 1.2.0
version: 1.2.0(@types/react@19.2.9)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@emoji-mart/data': '@emoji-mart/data':
specifier: 1.2.1 specifier: 1.2.1
version: 1.2.1 version: 1.2.1
@ -900,6 +903,27 @@ packages:
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@base-ui/react@1.2.0':
resolution: {integrity: sha512-O6aEQHcm+QyGTFY28xuwRD3SEJGZOBDpyjN2WvpfWYFVhg+3zfXPysAILqtM0C1kWC82MccOE/v1j+GHXE4qIw==}
engines: {node: '>=14.0.0'}
peerDependencies:
'@types/react': ^17 || ^18 || ^19
react: ^17 || ^18 || ^19
react-dom: ^17 || ^18 || ^19
peerDependenciesMeta:
'@types/react':
optional: true
'@base-ui/utils@0.2.5':
resolution: {integrity: sha512-oYC7w0gp76RI5MxprlGLV0wze0SErZaRl3AAkeP3OnNB/UBMb6RqNf6ZSIlxOc9Qp68Ab3C2VOcJQyRs7Xc7Vw==}
peerDependencies:
'@types/react': ^17 || ^18 || ^19
react: ^17 || ^18 || ^19
react-dom: ^17 || ^18 || ^19
peerDependenciesMeta:
'@types/react':
optional: true
'@bcoe/v8-coverage@1.0.2': '@bcoe/v8-coverage@1.0.2':
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -6812,6 +6836,9 @@ packages:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
reselect@5.1.1:
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
reserved-identifiers@1.2.0: reserved-identifiers@1.2.0:
resolution: {integrity: sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==} resolution: {integrity: sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -8316,6 +8343,30 @@ snapshots:
'@babel/helper-string-parser': 7.27.1 '@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5 '@babel/helper-validator-identifier': 7.28.5
'@base-ui/react@1.2.0(@types/react@19.2.9)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@babel/runtime': 7.28.6
'@base-ui/utils': 0.2.5(@types/react@19.2.9)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@floating-ui/react-dom': 2.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@floating-ui/utils': 0.2.10
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
tabbable: 6.4.0
use-sync-external-store: 1.6.0(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.9
'@base-ui/utils@0.2.5(@types/react@19.2.9)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@babel/runtime': 7.28.6
'@floating-ui/utils': 0.2.10
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
reselect: 5.1.1
use-sync-external-store: 1.6.0(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.9
'@bcoe/v8-coverage@1.0.2': {} '@bcoe/v8-coverage@1.0.2': {}
'@braintree/sanitize-url@7.1.1': {} '@braintree/sanitize-url@7.1.1': {}
@ -15127,6 +15178,8 @@ snapshots:
require-from-string@2.0.2: {} require-from-string@2.0.2: {}
reselect@5.1.1: {}
reserved-identifiers@1.2.0: {} reserved-identifiers@1.2.0: {}
resize-observer-polyfill@1.5.1: {} resize-observer-polyfill@1.5.1: {}