diff --git a/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/param-config-content.spec.tsx b/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/param-config-content.spec.tsx
index 754bde98a6..27a6cd96d0 100644
--- a/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/param-config-content.spec.tsx
+++ b/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/param-config-content.spec.tsx
@@ -64,6 +64,9 @@ const renderWithProvider = (
)
}
+const getLanguageSelect = () => screen.getByRole('combobox', { name: /voice\.voiceSettings\.language/ })
+const getVoiceSelect = () => screen.getByRole('combobox', { name: /voice\.voiceSettings\.voice/ })
+
describe('ParamConfigContent', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -110,23 +113,19 @@ describe('ParamConfigContent', () => {
const languageLabel = screen.getByText(/voice\.voiceSettings\.language/)
expect(languageLabel)!.toBeInTheDocument()
- const tooltip = languageLabel.parentElement as HTMLElement
- expect(tooltip.querySelector('svg'))!.toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /voice\.voiceSettings\.resolutionTooltip/ }))!.toBeInTheDocument()
})
it('should display language listbox button', () => {
renderWithProvider()
- const buttons = screen.getAllByRole('button')
- expect(buttons.length).toBeGreaterThanOrEqual(1)
+ expect(getLanguageSelect()).toBeInTheDocument()
})
it('should display current voice in listbox button', () => {
renderWithProvider()
- const buttons = screen.getAllByRole('button')
- const voiceButton = buttons.find(btn => btn.textContent?.includes('Alloy'))
- expect(voiceButton)!.toBeInTheDocument()
+ expect(getVoiceSelect()).toHaveTextContent('Alloy')
})
it('should render audition button when language has example', () => {
@@ -153,8 +152,7 @@ describe('ParamConfigContent', () => {
text2speech: { enabled: true, language: '', voice: '', autoPlay: TtsAutoPlay.disabled },
})
- const buttons = screen.getAllByRole('button')
- expect(buttons.length).toBeGreaterThan(0)
+ expect(getLanguageSelect()).toBeInTheDocument()
})
it('should render with no voice set and use first as default', () => {
@@ -162,9 +160,7 @@ describe('ParamConfigContent', () => {
text2speech: { enabled: true, language: 'en-US', voice: 'nonexistent', autoPlay: TtsAutoPlay.disabled },
})
- const buttons = screen.getAllByRole('button')
- const voiceButton = buttons.find(btn => btn.textContent?.includes('Alloy'))
- expect(voiceButton)!.toBeInTheDocument()
+ expect(getVoiceSelect()).toHaveTextContent('Alloy')
})
})
@@ -240,10 +236,7 @@ describe('ParamConfigContent', () => {
it('should open language listbox and show options', async () => {
renderWithProvider()
- const buttons = screen.getAllByRole('button')
- const languageButton = buttons.find(btn => btn.textContent?.includes('voice.language.'))
- expect(languageButton).toBeDefined()
- await userEvent.click(languageButton!)
+ await userEvent.click(getLanguageSelect())
const options = await screen.findAllByRole('option')
expect(options.length).toBeGreaterThanOrEqual(2)
@@ -253,10 +246,7 @@ describe('ParamConfigContent', () => {
const onChange = vi.fn()
renderWithProvider({ onChange })
- const buttons = screen.getAllByRole('button')
- const languageButton = buttons.find(btn => btn.textContent?.includes('voice.language.'))
- expect(languageButton).toBeDefined()
- await userEvent.click(languageButton!)
+ await userEvent.click(getLanguageSelect())
const options = await screen.findAllByRole('option')
expect(options.length).toBeGreaterThan(1)
await userEvent.click(options[1]!)
@@ -267,10 +257,7 @@ describe('ParamConfigContent', () => {
const onChange = vi.fn()
renderWithProvider({ onChange })
- const buttons = screen.getAllByRole('button')
- const voiceButton = buttons.find(btn => btn.textContent?.includes('Alloy'))
- expect(voiceButton).toBeDefined()
- await userEvent.click(voiceButton!)
+ await userEvent.click(getVoiceSelect())
const options = await screen.findAllByRole('option')
expect(options.length).toBeGreaterThan(1)
await userEvent.click(options[1]!)
@@ -280,10 +267,7 @@ describe('ParamConfigContent', () => {
it('should show selected language option in listbox', async () => {
renderWithProvider()
- const buttons = screen.getAllByRole('button')
- const languageButton = buttons.find(btn => btn.textContent?.includes('voice.language.'))
- expect(languageButton).toBeDefined()
- await userEvent.click(languageButton!)
+ await userEvent.click(getLanguageSelect())
const options = await screen.findAllByRole('option')
expect(options.length).toBeGreaterThanOrEqual(1)
@@ -295,10 +279,7 @@ describe('ParamConfigContent', () => {
it('should show selected voice option in listbox', async () => {
renderWithProvider()
- const buttons = screen.getAllByRole('button')
- const voiceButton = buttons.find(btn => btn.textContent?.includes('Alloy'))
- expect(voiceButton).toBeDefined()
- await userEvent.click(voiceButton!)
+ await userEvent.click(getVoiceSelect())
const options = await screen.findAllByRole('option')
expect(options.length).toBeGreaterThanOrEqual(1)
@@ -321,11 +302,7 @@ describe('ParamConfigContent', () => {
const placeholderTexts = screen.getAllByText(/placeholder\.select/)
expect(placeholderTexts.length).toBeGreaterThanOrEqual(2)
- const disabledButtons = screen
- .getAllByRole('button')
- .filter(button => button.hasAttribute('disabled') || button.getAttribute('aria-disabled') === 'true')
-
- expect(disabledButtons.length).toBeGreaterThanOrEqual(1)
+ expect(getVoiceSelect()).toHaveAttribute('data-disabled')
})
it('should call useAppVoices with empty appId when pathname has no app segment', () => {
diff --git a/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx b/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx
index 199cbecccb..24670fa748 100644
--- a/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx
+++ b/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx
@@ -1,16 +1,20 @@
'use client'
import type { OnFeaturesChange } from '@/app/components/base/features/types'
-import { Listbox, ListboxButton, ListboxOption, ListboxOptions, Transition } from '@headlessui/react'
-import { cn } from '@langgenius/dify-ui/cn'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectItemIndicator,
+ SelectItemText,
+ SelectTrigger,
+} from '@langgenius/dify-ui/select'
import { Switch } from '@langgenius/dify-ui/switch'
import { produce } from 'immer'
-import * as React from 'react'
-import { Fragment } from 'react'
import { useTranslation } from 'react-i18next'
import { replace } from 'string-ts'
import AudioBtn from '@/app/components/base/audio-btn'
import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks'
-import Tooltip from '@/app/components/base/tooltip'
+import { Infotip } from '@/app/components/base/infotip'
import { languages } from '@/i18n-config/language'
import { usePathname } from '@/next/navigation'
import { useAppVoices } from '@/service/use-apps'
@@ -35,6 +39,9 @@ const VoiceParamConfig = ({
const appId = (matched?.length && matched[1]) ? matched[1] : ''
const text2speech = useFeatures(state => state.features.text2speech)
const featuresStore = useFeaturesStore()
+ const formatLanguageName = (item: SelectOption) => {
+ return t(`voice.language.${replace(String(item.value), '-', '')}`, item.name, { ns: 'common' as const })
+ }
let languageItem = languages.find(item => item.value === text2speech?.language)
if (languages && !languageItem)
@@ -70,160 +77,86 @@ const VoiceParamConfig = ({
<>
{t('voice.voiceSettings.voice', { ns: 'appDebug' })}
-
{
+ onValueChange={(nextValue) => {
+ if (!nextValue)
+ return
handleChange({
- voice: String(value.value),
+ voice: nextValue,
})
}}
>
-
-
-
- {voiceItem?.name ?? localVoicePlaceholder}
-
-
-
-
-
-
-
-
- {voiceItems?.map((item: SelectOption) => (
-
- {({ /* active, */ selected }) => (
- <>
- {item.name}
- {(selected || item.value === text2speech?.voice) && (
-
-
-
- )}
- >
- )}
-
- ))}
-
-
+
+
+ {voiceItem?.name ?? localVoicePlaceholder}
+
+
+ {voiceItems?.map((item: SelectOption) => (
+
+
+ {item.name}
+
+
+
+ ))}
+
-
+
{languageItem?.example && (
) => {
+ const handleClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation()
e.preventDefault()
onClick?.(file)
}, [onClick, file])
return (
-
-
+
+ {
+ isImage
+ ? (
+
+ )
+ : (
+
+ )
+ }
+
)}
- onClick={handleClick}
- >
- {
- isImage
- ? (
-
- )
- : (
-
- )
- }
-
+ />
+
+ {name}
+
)
}
diff --git a/web/app/components/base/form/components/__tests__/label.spec.tsx b/web/app/components/base/form/components/__tests__/label.spec.tsx
index a3f564dafe..99471e5171 100644
--- a/web/app/components/base/form/components/__tests__/label.spec.tsx
+++ b/web/app/components/base/form/components/__tests__/label.spec.tsx
@@ -41,8 +41,8 @@ describe('Label', () => {
const tooltipText = 'Test Tooltip'
render()
- await user.hover(screen.getByTestId('test-input-tooltip'))
- expect(screen.getByText(tooltipText)).toBeInTheDocument()
+ await user.hover(screen.getByRole('button', { name: tooltipText }))
+ expect(await screen.findByText(tooltipText)).toBeInTheDocument()
})
it('should hide optional text when required is true', () => {
diff --git a/web/app/components/base/form/components/label.tsx b/web/app/components/base/form/components/label.tsx
index cd23043593..8987e350ff 100644
--- a/web/app/components/base/form/components/label.tsx
+++ b/web/app/components/base/form/components/label.tsx
@@ -1,6 +1,6 @@
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
-import Tooltip from '../../tooltip'
+import { Infotip } from '../../infotip'
export type LabelProps = {
htmlFor: string
@@ -33,13 +33,9 @@ const Label = ({
{!isRequired && showOptional && {t('label.optional', { ns: 'common' })}
}
{isRequired && *
}
{tooltip && (
- {tooltip}
- }
- triggerClassName="ml-0.5 w-4 h-4"
- triggerTestId={`${htmlFor}-tooltip`}
- />
+
+ {tooltip}
+
)}
)
diff --git a/web/app/components/base/input-with-copy/index.tsx b/web/app/components/base/input-with-copy/index.tsx
index 2da2e547c2..38a7ceed9c 100644
--- a/web/app/components/base/input-with-copy/index.tsx
+++ b/web/app/components/base/input-with-copy/index.tsx
@@ -1,11 +1,11 @@
'use client'
import type { InputProps } from '../input'
import { cn } from '@langgenius/dify-ui/cn'
+import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { useClipboard } from '@/hooks/use-clipboard'
import ActionButton from '../action-button'
-import Tooltip from '../tooltip'
type InputWithCopyProps = {
showCopyButton?: boolean
@@ -64,18 +64,24 @@ const InputWithCopy = React.forwardRef((
onMouseLeave={reset}
data-testid="copy-button-wrapper"
>
-
-
- {copied
- ? ()
- : ()}
-
+
+
+ {copied
+ ? ()
+ : ()}
+
+ )}
+ />
+
+ {safeTooltipText}
+
)}
diff --git a/web/app/components/base/notion-page-selector/credential-selector/index.tsx b/web/app/components/base/notion-page-selector/credential-selector/index.tsx
index c8db7bc978..81ee1c06d8 100644
--- a/web/app/components/base/notion-page-selector/credential-selector/index.tsx
+++ b/web/app/components/base/notion-page-selector/credential-selector/index.tsx
@@ -1,7 +1,12 @@
'use client'
-import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'
-import * as React from 'react'
-import { Fragment, useMemo } from 'react'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectItemIndicator,
+ SelectItemText,
+ SelectTrigger,
+} from '@langgenius/dify-ui/select'
import { CredentialIcon } from '@/app/components/datasets/common/credential-icon'
export type NotionCredential = {
@@ -17,99 +22,66 @@ type CredentialSelectorProps = {
onSelect: (v: string) => void
}
+const getDisplayName = (item?: NotionCredential) => {
+ return item?.workspaceName || item?.credentialName || ''
+}
+
const CredentialSelector = ({
value,
items,
onSelect,
}: CredentialSelectorProps) => {
- const currentCredential = items.find(item => item.credentialId === value)!
-
- const getDisplayName = (item: NotionCredential) => {
- return item.workspaceName || item.credentialName
- }
-
- const currentDisplayName = useMemo(() => {
- return getDisplayName(currentCredential)
- }, [currentCredential])
+ const currentCredential = items.find(item => item.credentialId === value) ?? items[0]
+ const currentDisplayName = getDisplayName(currentCredential)
return (
-
+
+ {displayName}
+
+
+
+ )
+ })}
+
+
)
}
-export default React.memo(CredentialSelector)
+export default CredentialSelector
diff --git a/web/app/components/base/tooltip/TooltipManager.ts b/web/app/components/base/tooltip/TooltipManager.ts
deleted file mode 100644
index b0138af4b3..0000000000
--- a/web/app/components/base/tooltip/TooltipManager.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-class TooltipManager {
- private activeCloser: (() => void) | null = null
-
- register(closeFn: () => void) {
- if (this.activeCloser)
- this.activeCloser()
- this.activeCloser = closeFn
- }
-
- clear(closeFn: () => void) {
- if (this.activeCloser === closeFn)
- this.activeCloser = null
- }
-
- /**
- * Closes the currently active tooltip by calling its closer function
- * and clearing the reference to it
- */
- closeActiveTooltip() {
- if (this.activeCloser) {
- this.activeCloser()
- this.activeCloser = null
- }
- }
-}
-
-export const tooltipManager = new TooltipManager()
diff --git a/web/app/components/base/tooltip/__tests__/TooltipManager.spec.ts b/web/app/components/base/tooltip/__tests__/TooltipManager.spec.ts
deleted file mode 100644
index 406c48259a..0000000000
--- a/web/app/components/base/tooltip/__tests__/TooltipManager.spec.ts
+++ /dev/null
@@ -1,129 +0,0 @@
-import { tooltipManager } from '../TooltipManager'
-
-describe('TooltipManager', () => {
- // Test the singleton instance directly
- let manager: typeof tooltipManager
-
- beforeEach(() => {
- // Get fresh reference to the singleton
- manager = tooltipManager
- // Clean up any active tooltip by calling closeActiveTooltip
- // This ensures each test starts with a clean state
- manager.closeActiveTooltip()
- })
-
- describe('register', () => {
- it('should register a close function', () => {
- const closeFn = vi.fn()
- manager.register(closeFn)
- expect(closeFn).not.toHaveBeenCalled()
- })
-
- it('should call the existing close function when registering a new one', () => {
- const firstCloseFn = vi.fn()
- const secondCloseFn = vi.fn()
-
- manager.register(firstCloseFn)
- manager.register(secondCloseFn)
-
- expect(firstCloseFn).toHaveBeenCalledTimes(1)
- expect(secondCloseFn).not.toHaveBeenCalled()
- })
-
- it('should replace the active closer with the new one', () => {
- const firstCloseFn = vi.fn()
- const secondCloseFn = vi.fn()
-
- // Register first function
- manager.register(firstCloseFn)
-
- // Register second function - this should call firstCloseFn and replace it
- manager.register(secondCloseFn)
-
- // Verify firstCloseFn was called during register (replacement behavior)
- expect(firstCloseFn).toHaveBeenCalledTimes(1)
-
- // Now close the active tooltip - this should call secondCloseFn
- manager.closeActiveTooltip()
-
- // Verify secondCloseFn was called, not firstCloseFn
- expect(secondCloseFn).toHaveBeenCalledTimes(1)
- })
- })
-
- describe('clear', () => {
- it('should not clear if the close function does not match', () => {
- const closeFn = vi.fn()
- const otherCloseFn = vi.fn()
-
- manager.register(closeFn)
- manager.clear(otherCloseFn)
-
- manager.closeActiveTooltip()
- expect(closeFn).toHaveBeenCalledTimes(1)
- })
-
- it('should clear the close function if it matches', () => {
- const closeFn = vi.fn()
-
- manager.register(closeFn)
- manager.clear(closeFn)
-
- manager.closeActiveTooltip()
- expect(closeFn).not.toHaveBeenCalled()
- })
-
- it('should not call the close function when clearing', () => {
- const closeFn = vi.fn()
-
- manager.register(closeFn)
- manager.clear(closeFn)
-
- expect(closeFn).not.toHaveBeenCalled()
- })
- })
-
- describe('closeActiveTooltip', () => {
- it('should do nothing when no active closer is registered', () => {
- expect(() => manager.closeActiveTooltip()).not.toThrow()
- })
-
- it('should call the active closer function', () => {
- const closeFn = vi.fn()
- manager.register(closeFn)
-
- manager.closeActiveTooltip()
-
- expect(closeFn).toHaveBeenCalledTimes(1)
- })
-
- it('should clear the active closer after calling it', () => {
- const closeFn = vi.fn()
- manager.register(closeFn)
-
- manager.closeActiveTooltip()
- manager.closeActiveTooltip()
-
- expect(closeFn).toHaveBeenCalledTimes(1)
- })
-
- it('should handle multiple register and close cycles', () => {
- const closeFn1 = vi.fn()
- const closeFn2 = vi.fn()
- const closeFn3 = vi.fn()
-
- manager.register(closeFn1)
- manager.closeActiveTooltip()
-
- manager.register(closeFn2)
- manager.closeActiveTooltip()
-
- manager.register(closeFn3)
- manager.closeActiveTooltip()
-
- expect(closeFn1).toHaveBeenCalledTimes(1)
- expect(closeFn2).toHaveBeenCalledTimes(1)
- expect(closeFn3).toHaveBeenCalledTimes(1)
- })
- })
-})
diff --git a/web/app/components/base/tooltip/__tests__/content.spec.tsx b/web/app/components/base/tooltip/__tests__/content.spec.tsx
deleted file mode 100644
index fa5d86756e..0000000000
--- a/web/app/components/base/tooltip/__tests__/content.spec.tsx
+++ /dev/null
@@ -1,49 +0,0 @@
-import { render, screen } from '@testing-library/react'
-import userEvent from '@testing-library/user-event'
-import { describe, expect, it, vi } from 'vitest'
-import { ToolTipContent } from '../content'
-
-describe('ToolTipContent', () => {
- it('should render children correctly', () => {
- render(
-
- Tooltip body text
- ,
- )
- expect(screen.getByTestId('tooltip-content')).toBeInTheDocument()
- expect(screen.getByTestId('tooltip-content-body')).toHaveTextContent('Tooltip body text')
- expect(screen.queryByTestId('tooltip-content-title')).not.toBeInTheDocument()
- expect(screen.queryByTestId('tooltip-content-action')).not.toBeInTheDocument()
- })
-
- it('should render title when provided', () => {
- render(
-
- Tooltip body text
- ,
- )
- expect(screen.getByTestId('tooltip-content-title')).toHaveTextContent('Tooltip Title')
- })
-
- it('should render action when provided', () => {
- render(
-
Action Text}>
- Tooltip body text
- ,
- )
- expect(screen.getByTestId('tooltip-content-action')).toHaveTextContent('Action Text')
- })
-
- it('should handle action click', async () => {
- const user = userEvent.setup()
- const handleActionClick = vi.fn()
- render(
-
Action Text}>
- Tooltip body text
- ,
- )
-
- await user.click(screen.getByText('Action Text'))
- expect(handleActionClick).toHaveBeenCalledTimes(1)
- })
-})
diff --git a/web/app/components/base/tooltip/__tests__/index.spec.tsx b/web/app/components/base/tooltip/__tests__/index.spec.tsx
deleted file mode 100644
index 39f8f1b503..0000000000
--- a/web/app/components/base/tooltip/__tests__/index.spec.tsx
+++ /dev/null
@@ -1,333 +0,0 @@
-import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
-import * as React from 'react'
-import Tooltip from '../index'
-import { tooltipManager } from '../TooltipManager'
-
-afterEach(() => {
- cleanup()
- vi.clearAllTimers()
- vi.useRealTimers()
-})
-
-describe('Tooltip', () => {
- describe('Rendering', () => {
- it('should render default tooltip with question icon', () => {
- const triggerClassName = 'custom-trigger'
- const { container } = render(
)
- const trigger = container.querySelector(`.${triggerClassName}`)
- expect(trigger).not.toBeNull()
- expect(trigger?.querySelector('svg')).not.toBeNull() // question icon
- })
-
- it('should render with custom children', () => {
- const { getByText } = render(
-
-
- ,
- )
- expect(getByText('Hover me').textContent).toBe('Hover me')
- })
-
- it('should render correctly when asChild is false', () => {
- const { container } = render(
-
- Trigger
- ,
- )
- const trigger = container.querySelector('.custom-parent-trigger')
- expect(trigger).not.toBeNull()
- })
-
- it('should render with a fallback question icon when children are null', () => {
- const { container } = render(
-
- {null}
- ,
- )
- const trigger = container.querySelector('.custom-fallback-trigger')
- expect(trigger).not.toBeNull()
- expect(trigger?.querySelector('svg')).not.toBeNull()
- })
- })
-
- describe('Disabled state', () => {
- it('should not show tooltip when disabled', () => {
- const triggerClassName = 'custom-trigger'
- const { container } = render(
)
- const trigger = container.querySelector(`.${triggerClassName}`)
- act(() => {
- fireEvent.mouseEnter(trigger!)
- })
- expect(screen.queryByText('Tooltip content')).not.toBeInTheDocument()
- })
- })
-
- describe('Trigger methods', () => {
- beforeEach(() => {
- vi.useFakeTimers()
- })
-
- it('should open on hover when triggerMethod is hover', () => {
- const triggerClassName = 'custom-trigger'
- const { container } = render(
)
- const trigger = container.querySelector(`.${triggerClassName}`)
- act(() => {
- fireEvent.mouseEnter(trigger!)
- })
- expect(screen.queryByText('Tooltip content')).toBeInTheDocument()
- })
-
- it('should close on mouse leave when triggerMethod is hover and needsDelay is false', () => {
- const triggerClassName = 'custom-trigger'
- const { container } = render(
)
- const trigger = container.querySelector(`.${triggerClassName}`)
- act(() => {
- fireEvent.mouseEnter(trigger!)
- fireEvent.mouseLeave(trigger!)
- })
- expect(screen.queryByText('Tooltip content')).not.toBeInTheDocument()
- })
-
- it('should toggle on click when triggerMethod is click', () => {
- const triggerClassName = 'custom-trigger'
- const { container } = render(
)
- const trigger = container.querySelector(`.${triggerClassName}`)
- act(() => {
- fireEvent.click(trigger!)
- })
- expect(screen.queryByText('Tooltip content')).toBeInTheDocument()
-
- // Test toggle off
- act(() => {
- fireEvent.click(trigger!)
- })
- expect(screen.queryByText('Tooltip content')).not.toBeInTheDocument()
- })
-
- it('should do nothing on mouse enter if triggerMethod is click', () => {
- const triggerClassName = 'custom-trigger'
- const { container } = render(
)
- const trigger = container.querySelector(`.${triggerClassName}`)
- act(() => {
- fireEvent.mouseEnter(trigger!)
- })
- expect(screen.queryByText('Tooltip content')).not.toBeInTheDocument()
- })
-
- it('should delay closing on mouse leave when needsDelay is true', () => {
- const triggerClassName = 'custom-trigger'
- const { container } = render(
)
- const trigger = container.querySelector(`.${triggerClassName}`)
-
- act(() => {
- fireEvent.mouseEnter(trigger!)
- })
- expect(screen.getByText('Tooltip content')).toBeInTheDocument()
-
- act(() => {
- fireEvent.mouseLeave(trigger!)
- })
- // Shouldn't close immediately
- expect(screen.getByText('Tooltip content')).toBeInTheDocument()
-
- act(() => {
- vi.advanceTimersByTime(350)
- })
- // Should close after delay
- expect(screen.queryByText('Tooltip content')).not.toBeInTheDocument()
- })
-
- it('should not close if mouse enters popup before delay finishes', () => {
- const triggerClassName = 'custom-trigger'
- const { container } = render(
)
- const trigger = container.querySelector(`.${triggerClassName}`)
-
- act(() => {
- fireEvent.mouseEnter(trigger!)
- })
-
- const popup = screen.getByText('Tooltip content')
- expect(popup).toBeInTheDocument()
-
- act(() => {
- fireEvent.mouseLeave(trigger!)
- })
-
- act(() => {
- vi.advanceTimersByTime(150)
- // Simulate mouse entering popup area itself during the delay timeframe
- fireEvent.mouseEnter(popup)
- })
-
- act(() => {
- vi.advanceTimersByTime(200) // Complete the 300ms original delay
- })
-
- // Should still be open because we are hovering the popup
- expect(screen.getByText('Tooltip content')).toBeInTheDocument()
-
- // Now mouse leaves popup
- act(() => {
- fireEvent.mouseLeave(popup)
- })
-
- act(() => {
- vi.advanceTimersByTime(350)
- })
- // Should now close
- expect(screen.queryByText('Tooltip content')).not.toBeInTheDocument()
- })
-
- it('should do nothing on mouse enter/leave of popup when triggerMethod is not hover', () => {
- const triggerClassName = 'custom-trigger'
- const { container } = render(
)
- const trigger = container.querySelector(`.${triggerClassName}`)
-
- act(() => {
- fireEvent.click(trigger!)
- })
-
- const popup = screen.getByText('Tooltip content')
-
- act(() => {
- fireEvent.mouseEnter(popup)
- fireEvent.mouseLeave(popup)
- vi.advanceTimersByTime(350)
- })
-
- // Should still be open because click method requires another click to close, not hover leave
- expect(screen.getByText('Tooltip content')).toBeInTheDocument()
- })
-
- it('should clear close timeout if trigger is hovered again before delay finishes', () => {
- const triggerClassName = 'custom-trigger'
- const { container } = render(
)
- const trigger = container.querySelector(`.${triggerClassName}`)
-
- act(() => {
- fireEvent.mouseEnter(trigger!)
- })
- expect(screen.getByText('Tooltip content')).toBeInTheDocument()
-
- act(() => {
- fireEvent.mouseLeave(trigger!)
- })
-
- act(() => {
- vi.advanceTimersByTime(150)
- // Re-hover trigger before it closes
- fireEvent.mouseEnter(trigger!)
- })
-
- act(() => {
- vi.advanceTimersByTime(200) // Original 300ms would be up
- })
-
- // Should still be open because we reset it
- expect(screen.getByText('Tooltip content')).toBeInTheDocument()
- })
-
- it('should test clear close timeout if trigger is hovered again before delay finishes and isHoverPopupRef is true', () => {
- const triggerClassName = 'custom-trigger'
- const { container } = render(
)
- const trigger = container.querySelector(`.${triggerClassName}`)
-
- act(() => {
- fireEvent.mouseEnter(trigger!)
- })
-
- const popup = screen.getByText('Tooltip content')
- expect(popup).toBeInTheDocument()
-
- act(() => {
- fireEvent.mouseEnter(popup)
- fireEvent.mouseLeave(trigger!)
- })
-
- act(() => {
- vi.advanceTimersByTime(350)
- })
-
- // Should still be open because we are hovering the popup
- expect(screen.getByText('Tooltip content')).toBeInTheDocument()
- })
- })
-
- describe('TooltipManager', () => {
- it('should close active tooltips when triggered centrally, overriding other closes', () => {
- const triggerClassName1 = 'custom-trigger-1'
- const triggerClassName2 = 'custom-trigger-2'
-
- const { container } = render(
-
-
-
-
,
- )
-
- const trigger1 = container.querySelector(`.${triggerClassName1}`)
- const trigger2 = container.querySelector(`.${triggerClassName2}`)
-
- expect(trigger2).not.toBeNull()
-
- // Open first tooltip
- act(() => {
- fireEvent.mouseEnter(trigger1!)
- })
- expect(screen.queryByText('Tooltip content 1')).toBeInTheDocument()
-
- // TooltipManager should keep track of it
- // Next, immediately open the second one without leaving first (e.g., via TooltipManager)
- // TooltipManager registers the newest one and closes the old one when doing full external operations, but internally the manager allows direct closing
-
- act(() => {
- tooltipManager.closeActiveTooltip()
- })
-
- expect(screen.queryByText('Tooltip content 1')).not.toBeInTheDocument()
-
- // Safe to call again
- expect(() => tooltipManager.closeActiveTooltip()).not.toThrow()
- })
- })
-
- describe('Styling and positioning', () => {
- it('should apply custom trigger className', () => {
- const triggerClassName = 'custom-trigger'
- const { container } = render(
)
- const trigger = container.querySelector(`.${triggerClassName}`)
- expect(trigger?.className).toContain('custom-trigger')
- })
-
- it('should pass triggerTestId to the fallback icon wrapper', () => {
- render(
)
- expect(screen.getByTestId('test-tooltip-icon')).toBeInTheDocument()
- })
-
- it('should apply custom popup className', async () => {
- const triggerClassName = 'custom-trigger'
- const { container } = render(
)
- const trigger = container.querySelector(`.${triggerClassName}`)
- act(() => {
- fireEvent.mouseEnter(trigger!)
- })
- expect((await screen.findByText('Tooltip content'))?.className).toContain('custom-popup')
- })
-
- it('should apply noDecoration when specified', async () => {
- const triggerClassName = 'custom-trigger'
- const { container } = render(
-
,
- )
- const trigger = container.querySelector(`.${triggerClassName}`)
- act(() => {
- fireEvent.mouseEnter(trigger!)
- })
- expect((await screen.findByText('Tooltip content'))?.className).not.toContain('bg-components-panel-bg')
- })
- })
-})
diff --git a/web/app/components/base/tooltip/content.tsx b/web/app/components/base/tooltip/content.tsx
deleted file mode 100644
index 191ee933f1..0000000000
--- a/web/app/components/base/tooltip/content.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import type { FC, PropsWithChildren, ReactNode } from 'react'
-
-type ToolTipContentProps = {
- title?: ReactNode
- action?: ReactNode
-} & PropsWithChildren
-
-export const ToolTipContent: FC
= ({
- title,
- action,
- children,
-}) => {
- return (
-
- {!!title && (
-
{title}
- )}
-
{children}
- {!!action &&
{action}
}
-
- )
-}
diff --git a/web/app/components/base/tooltip/index.stories.tsx b/web/app/components/base/tooltip/index.stories.tsx
deleted file mode 100644
index 69d0c5d2b6..0000000000
--- a/web/app/components/base/tooltip/index.stories.tsx
+++ /dev/null
@@ -1,60 +0,0 @@
-import type { Meta, StoryObj } from '@storybook/nextjs-vite'
-import Tooltip from '.'
-
-const TooltipGrid = () => {
- return (
-
-
Hover tooltips
-
-
-
-
-
-
- Right tooltip
-
-
-
-
Click tooltips
-
-
-
-
-
-
- Plain content
-
-
-
-
- )
-}
-
-const meta = {
- title: 'Base/Feedback/Tooltip',
- component: TooltipGrid,
- parameters: {
- layout: 'centered',
- docs: {
- description: {
- component: 'Portal-based tooltip component supporting hover and click triggers, custom placements, and decorated content.',
- },
- },
- },
- tags: ['autodocs'],
-} satisfies Meta
-
-export default meta
-type Story = StoryObj
-
-export const Playground: Story = {}
diff --git a/web/app/components/base/tooltip/index.tsx b/web/app/components/base/tooltip/index.tsx
deleted file mode 100644
index 85c63cdeaf..0000000000
--- a/web/app/components/base/tooltip/index.tsx
+++ /dev/null
@@ -1,231 +0,0 @@
-'use client'
-import type { Placement } from '@langgenius/dify-ui/popover'
-/**
- * @deprecated Use `@langgenius/dify-ui/tooltip` instead.
- * This component will be removed after migration is complete.
- * See: https://github.com/langgenius/dify/issues/32767
- */
-import type { FC } from 'react'
-import { cn } from '@langgenius/dify-ui/cn'
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from '@langgenius/dify-ui/popover'
-import { RiQuestionLine } from '@remixicon/react'
-import { useBoolean } from 'ahooks'
-import * as React from 'react'
-import { useCallback, useEffect, useRef, useState } from 'react'
-import { tooltipManager } from './TooltipManager'
-
-type TooltipOffset = number | {
- mainAxis?: number
- crossAxis?: number
-}
-
-type TooltipProps = {
- position?: Placement
- triggerMethod?: 'hover' | 'click'
- triggerClassName?: string
- triggerTestId?: string
- disabled?: boolean
- popupContent?: React.ReactNode
- children?: React.ReactNode
- popupClassName?: string
- portalContentClassName?: string
- noDecoration?: boolean
- offset?: TooltipOffset
- needsDelay?: boolean
- asChild?: boolean
-}
-
-const Tooltip: FC = ({
- position = 'top',
- triggerMethod = 'hover',
- triggerClassName,
- triggerTestId,
- disabled = false,
- popupContent,
- children,
- popupClassName,
- portalContentClassName,
- noDecoration,
- offset,
- asChild = true,
- needsDelay = true,
-}) => {
- const [open, setOpen] = useState(false)
- const resolvedOffset = offset ?? 8
- const sideOffset = typeof resolvedOffset === 'number' ? resolvedOffset : (resolvedOffset.mainAxis ?? 0)
- const alignOffset = typeof resolvedOffset === 'number' ? 0 : (resolvedOffset.crossAxis ?? 0)
- const [isHoverPopup, {
- setTrue: setHoverPopup,
- setFalse: setNotHoverPopup,
- }] = useBoolean(false)
-
- const isHoverPopupRef = useRef(isHoverPopup)
- useEffect(() => {
- isHoverPopupRef.current = isHoverPopup
- }, [isHoverPopup])
-
- const [isHoverTrigger, {
- setTrue: setHoverTrigger,
- setFalse: setNotHoverTrigger,
- }] = useBoolean(false)
-
- const isHoverTriggerRef = useRef(isHoverTrigger)
- useEffect(() => {
- isHoverTriggerRef.current = isHoverTrigger
- }, [isHoverTrigger])
-
- const closeTimeoutRef = useRef | null>(null)
- const clearCloseTimeout = useCallback(() => {
- if (closeTimeoutRef.current) {
- clearTimeout(closeTimeoutRef.current)
- closeTimeoutRef.current = null
- }
- }, [])
-
- useEffect(() => {
- return () => {
- clearCloseTimeout()
- }
- }, [clearCloseTimeout])
-
- const close = () => setOpen(false)
- const handleOpenChange = (nextOpen: boolean) => {
- if (disabled) {
- setOpen(false)
- return
- }
- if (triggerMethod === 'click')
- setOpen(nextOpen)
- else if (!nextOpen)
- setOpen(false)
- }
-
- const handleLeave = (isTrigger: boolean) => {
- if (isTrigger)
- setNotHoverTrigger()
- else
- setNotHoverPopup()
-
- // give time to move to the popup
- if (needsDelay) {
- clearCloseTimeout()
- closeTimeoutRef.current = setTimeout(() => {
- closeTimeoutRef.current = null
- if (!isHoverPopupRef.current && !isHoverTriggerRef.current) {
- setOpen(false)
- tooltipManager.clear(close)
- }
- }, 300)
- }
- else {
- clearCloseTimeout()
- setOpen(false)
- tooltipManager.clear(close)
- }
- }
- const handleTriggerMouseEnter = () => {
- if (triggerMethod === 'hover') {
- clearCloseTimeout()
- setHoverTrigger()
- tooltipManager.register(close)
- setOpen(true)
- }
- }
- const handleTriggerMouseLeave = () => {
- if (triggerMethod === 'hover')
- handleLeave(true)
- }
- const handlePopupMouseEnter = () => {
- if (triggerMethod === 'hover') {
- clearCloseTimeout()
- setHoverPopup()
- }
- }
- const handlePopupMouseLeave = () => {
- if (triggerMethod === 'hover')
- handleLeave(false)
- }
-
- const fallbackTrigger = (
-
-
-
- )
- const triggerContent = children || fallbackTrigger
- const childElement = React.isValidElement>(triggerContent)
- ? triggerContent
- : fallbackTrigger
- const nativeButton = typeof childElement.type !== 'string' || childElement.type === 'button'
-
- const renderAsChildTrigger = () => {
- const childProps = childElement.props
- return React.cloneElement(childElement, {
- onMouseEnter: (event: React.MouseEvent) => {
- childProps.onMouseEnter?.(event)
- handleTriggerMouseEnter()
- },
- onMouseLeave: (event: React.MouseEvent) => {
- childProps.onMouseLeave?.(event)
- handleTriggerMouseLeave()
- },
- })
- }
- const effectiveOpen = !disabled && open
-
- return (
-
- {asChild
- ? (
-
- )
- : (
-
- )}
- >
- {triggerContent}
-
- )}
- {effectiveOpen && !!popupContent && (
-
- {popupContent}
-
- )}
-
- )
-}
-
-export default React.memo(Tooltip)
diff --git a/web/app/components/billing/pricing/plans/cloud-plan-item/__tests__/index.spec.tsx b/web/app/components/billing/pricing/plans/cloud-plan-item/__tests__/index.spec.tsx
index 615579bc6c..568a2656ba 100644
--- a/web/app/components/billing/pricing/plans/cloud-plan-item/__tests__/index.spec.tsx
+++ b/web/app/components/billing/pricing/plans/cloud-plan-item/__tests__/index.spec.tsx
@@ -241,7 +241,7 @@ describe('CloudPlanItem', () => {
)
// Sandbox viewed from a higher plan is disabled, but let's verify no API calls
- const button = screen.getByRole('button')
+ const button = screen.getByRole('button', { name: 'billing.plansCommon.startForFree' })
fireEvent.click(button)
await waitFor(() => {
diff --git a/web/app/components/billing/pricing/plans/cloud-plan-item/list/__tests__/index.spec.tsx b/web/app/components/billing/pricing/plans/cloud-plan-item/list/__tests__/index.spec.tsx
index 5a06509355..e6a0d78273 100644
--- a/web/app/components/billing/pricing/plans/cloud-plan-item/list/__tests__/index.spec.tsx
+++ b/web/app/components/billing/pricing/plans/cloud-plan-item/list/__tests__/index.spec.tsx
@@ -1,4 +1,5 @@
import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { Plan } from '../../../../../type'
import List from '../index'
@@ -12,11 +13,13 @@ describe('CloudPlanItem/List', () => {
expect(screen.getByText('billing.plansCommon.startNodes.limited:{"count":2}')).toBeInTheDocument()
})
- it('should show professional monthly quotas and tooltips', () => {
+ it('should show professional monthly quotas and tooltips', async () => {
+ const user = userEvent.setup()
render(
)
expect(screen.getByText('billing.plansCommon.messageRequest.titlePerMonth:{"count":5000}')).toBeInTheDocument()
- expect(screen.getByText('billing.plansCommon.vectorSpaceTooltip')).toBeInTheDocument()
+ await user.hover(screen.getByRole('button', { name: 'billing.plansCommon.vectorSpaceTooltip' }))
+ expect(await screen.findByText('billing.plansCommon.vectorSpaceTooltip')).toBeInTheDocument()
expect(screen.getByText('billing.plansCommon.workflowExecution.faster')).toBeInTheDocument()
})
diff --git a/web/app/components/billing/pricing/plans/cloud-plan-item/list/item/__tests__/index.spec.tsx b/web/app/components/billing/pricing/plans/cloud-plan-item/list/item/__tests__/index.spec.tsx
index e1aada80f8..f75b334fd9 100644
--- a/web/app/components/billing/pricing/plans/cloud-plan-item/list/item/__tests__/index.spec.tsx
+++ b/web/app/components/billing/pricing/plans/cloud-plan-item/list/item/__tests__/index.spec.tsx
@@ -1,4 +1,5 @@
import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
import Item from '../index'
describe('Item', () => {
@@ -20,14 +21,16 @@ describe('Item', () => {
// Toggling the optional tooltip indicator
describe('Tooltip behavior', () => {
- it('should render tooltip content when tooltip text is provided', () => {
+ it('should render tooltip content when tooltip text is provided', async () => {
+ const user = userEvent.setup()
const label = 'Workspace seats'
const tooltip = 'Seats define how many teammates can join the workspace.'
const { container } = render( )
expect(screen.getByText(label)).toBeInTheDocument()
- expect(screen.getByText(tooltip)).toBeInTheDocument()
+ await user.hover(screen.getByRole('button', { name: tooltip }))
+ expect(await screen.findByText(tooltip)).toBeInTheDocument()
expect(container.querySelector('.group')).not.toBeNull()
})
diff --git a/web/app/components/billing/pricing/plans/cloud-plan-item/list/item/__tests__/tooltip.spec.tsx b/web/app/components/billing/pricing/plans/cloud-plan-item/list/item/__tests__/tooltip.spec.tsx
index 86e4cb1061..c744fdb60e 100644
--- a/web/app/components/billing/pricing/plans/cloud-plan-item/list/item/__tests__/tooltip.spec.tsx
+++ b/web/app/components/billing/pricing/plans/cloud-plan-item/list/item/__tests__/tooltip.spec.tsx
@@ -1,4 +1,5 @@
import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
import Tooltip from '../tooltip'
describe('Tooltip', () => {
@@ -8,12 +9,14 @@ describe('Tooltip', () => {
// Rendering the info tooltip container
describe('Rendering', () => {
- it('should render the content panel when provide with text', () => {
+ it('should render the content panel when hovered', async () => {
+ const user = userEvent.setup()
const content = 'Usage resets on the first day of every month.'
render()
+ await user.hover(screen.getByRole('button', { name: content }))
- expect(() => screen.getByText(content)).not.toThrow()
+ expect(await screen.findByText(content)).toBeInTheDocument()
})
})
diff --git a/web/app/components/billing/pricing/plans/cloud-plan-item/list/item/tooltip.tsx b/web/app/components/billing/pricing/plans/cloud-plan-item/list/item/tooltip.tsx
index fe6aa9c2cb..be53ef6b1b 100644
--- a/web/app/components/billing/pricing/plans/cloud-plan-item/list/item/tooltip.tsx
+++ b/web/app/components/billing/pricing/plans/cloud-plan-item/list/item/tooltip.tsx
@@ -1,3 +1,4 @@
+import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { RiInfoI } from '@remixicon/react'
import * as React from 'react'
@@ -11,14 +12,20 @@ const Tooltip = ({
if (!content)
return null
return (
-
+
+
+ {content}
+
+
)
}
diff --git a/web/app/components/datasets/documents/components/operations.tsx b/web/app/components/datasets/documents/components/operations.tsx
index 8692da927d..7dc184aee4 100644
--- a/web/app/components/datasets/documents/components/operations.tsx
+++ b/web/app/components/datasets/documents/components/operations.tsx
@@ -16,15 +16,16 @@ import {
DropdownMenuContent,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
+import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { Switch } from '@langgenius/dify-ui/switch'
import { toast } from '@langgenius/dify-ui/toast'
+import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useBoolean, useDebounceFn } from 'ahooks'
import { noop } from 'es-toolkit/function'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
-import Tooltip from '@/app/components/base/tooltip'
import { IS_CE_EDITION } from '@/config'
import { DataSourceType, DocumentActionType } from '@/models/datasets'
import { useRouter } from '@/next/navigation'
@@ -205,11 +206,12 @@ const Operations = ({ embeddingAvailable, datasetId, detail, selectedIds, onSele
<>
{archived
? (
-
-
-
-
-
+
+