This commit is contained in:
yyh 2026-05-09 12:45:26 +08:00 committed by GitHub
commit 0bcbafc159
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
78 changed files with 824 additions and 1763 deletions

View File

@ -272,11 +272,6 @@
"count": 1
}
},
"web/app/components/app/app-access-control/specific-groups-or-members.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/app/app-publisher/features-wrapper.tsx": {
"ts/no-explicit-any": {
"count": 4
@ -323,11 +318,6 @@
"count": 4
}
},
"web/app/components/app/configuration/config-var/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/app/configuration/config-var/select-var-type.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -341,11 +331,6 @@
"count": 1
}
},
"web/app/components/app/configuration/config/agent/agent-setting/item-panel.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/app/configuration/config/agent/agent-tools/index.tsx": {
"ts/no-explicit-any": {
"count": 9
@ -593,11 +578,6 @@
"count": 2
}
},
"web/app/components/app/workflow-log/detail.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/app/workflow-log/filter.tsx": {
"react-refresh/only-export-components": {
"count": 1
@ -967,11 +947,6 @@
"count": 1
}
},
"web/app/components/base/features/new-feature-panel/annotation-reply/config-param.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/base/features/new-feature-panel/annotation-reply/index.tsx": {
"ts/no-explicit-any": {
"count": 3
@ -1005,11 +980,6 @@
"count": 2
}
},
"web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/base/features/types.ts": {
"erasable-syntax-only/enums": {
"count": 2
@ -2096,11 +2066,6 @@
"count": 4
}
},
"web/app/components/datasets/documents/components/operations.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/datasets/documents/components/rename-modal.tsx": {
"no-restricted-imports": {
"count": 1
@ -2116,11 +2081,6 @@
"count": 1
}
},
"web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/item.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/index.tsx": {
"react/set-state-in-effect": {
"count": 5
@ -2166,11 +2126,6 @@
"count": 2
}
},
"web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/datasets/documents/create-from-pipeline/steps/index.ts": {
"no-barrel-files/no-barrel-files": {
"count": 3
@ -2196,11 +2151,6 @@
"count": 1
}
},
"web/app/components/datasets/documents/detail/completed/display-toggle.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/datasets/documents/detail/completed/hooks/index.ts": {
"no-barrel-files/no-barrel-files": {
"count": 5
@ -2217,11 +2167,6 @@
"count": 1
}
},
"web/app/components/datasets/documents/detail/completed/segment-card/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/datasets/documents/detail/context.ts": {
"ts/no-explicit-any": {
"count": 1
@ -2310,7 +2255,7 @@
},
"web/app/components/datasets/metadata/edit-metadata-batch/modal.tsx": {
"no-restricted-imports": {
"count": 2
"count": 1
}
},
"web/app/components/datasets/metadata/hooks/use-edit-dataset-metadata.ts": {
@ -2338,7 +2283,7 @@
},
"web/app/components/datasets/metadata/metadata-dataset/dataset-metadata-drawer.tsx": {
"no-restricted-imports": {
"count": 3
"count": 2
}
},
"web/app/components/datasets/metadata/metadata-dataset/select-metadata-modal.tsx": {
@ -2571,11 +2516,6 @@
"count": 4
}
},
"web/app/components/header/account-setting/model-provider-page/model-auth/config-provider.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/header/account-setting/model-provider-page/model-auth/hooks/index.ts": {
"no-barrel-files/no-barrel-files": {
"count": 6
@ -2602,9 +2542,6 @@
}
},
"web/app/components/header/account-setting/model-provider-page/model-auth/switch-credential-in-load-balancing.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 3
}
@ -2630,9 +2567,6 @@
}
},
"web/app/components/header/account-setting/model-provider-page/model-parameter-modal/status-indicators.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 2
}
@ -2647,11 +2581,6 @@
"count": 2
}
},
"web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list-item.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-configs.tsx": {
"ts/no-explicit-any": {
"count": 5
@ -2900,11 +2829,6 @@
"count": 2
}
},
"web/app/components/plugins/plugin-detail-panel/subscription-list/subscription-card.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/plugins/plugin-detail-panel/subscription-list/types.ts": {
"erasable-syntax-only/enums": {
"count": 1
@ -2915,11 +2839,6 @@
"count": 7
}
},
"web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-item.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/index.ts": {
"no-barrel-files/no-barrel-files": {
"count": 2
@ -2934,9 +2853,6 @@
}
},
"web/app/components/plugins/plugin-item/index.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}
@ -3028,11 +2944,6 @@
"count": 1
}
},
"web/app/components/rag-pipeline/components/panel/input-field/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/index.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -3193,7 +3104,7 @@
},
"web/app/components/tools/edit-custom-collection-modal/config-credentials.tsx": {
"no-restricted-imports": {
"count": 2
"count": 1
}
},
"web/app/components/tools/edit-custom-collection-modal/get-schema.tsx": {
@ -3380,11 +3291,6 @@
"count": 1
}
},
"web/app/components/workflow/block-selector/tabs.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/block-selector/tool/tool-list-flat-view/list.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -3651,11 +3557,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/_base/components/mcp-tool-not-support-tooltip.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/_base/components/memory-config.tsx": {
"unicorn/prefer-number-properties": {
"count": 1
@ -3691,11 +3592,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/_base/components/switch-plugin-version.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/_base/components/variable/match-schema-type.ts": {
"ts/no-explicit-any": {
"count": 8
@ -4050,11 +3946,6 @@
"count": 5
}
},
"web/app/components/workflow/nodes/iteration-start/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/iteration/default.ts": {
"ts/no-explicit-any": {
"count": 1
@ -4075,11 +3966,6 @@
"count": 4
}
},
"web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/search-method-option.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/type.ts": {
"ts/no-explicit-any": {
"count": 2
@ -4113,11 +3999,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-filter/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/knowledge-retrieval/default.ts": {
"ts/no-explicit-any": {
"count": 1
@ -4240,11 +4121,6 @@
"count": 7
}
},
"web/app/components/workflow/nodes/loop-start/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/loop/components/condition-list/condition-input.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -4306,11 +4182,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/parameter-extractor/panel.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/parameter-extractor/types.ts": {
"erasable-syntax-only/enums": {
"count": 2
@ -4329,11 +4200,6 @@
"count": 9
}
},
"web/app/components/workflow/nodes/question-classifier/components/advanced-setting.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/question-classifier/components/class-item.tsx": {
"react/set-state-in-effect": {
"count": 1
@ -4352,11 +4218,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/question-classifier/node.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/question-classifier/use-config.ts": {
"react/set-state-in-effect": {
"count": 2
@ -4464,9 +4325,6 @@
}
},
"web/app/components/workflow/nodes/trigger-plugin/components/trigger-form/item.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}
@ -4522,11 +4380,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/trigger-webhook/panel.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/utils.ts": {
"ts/no-explicit-any": {
"count": 1
@ -4637,9 +4490,6 @@
}
},
"web/app/components/workflow/panel/env-panel/variable-modal.tsx": {
"no-restricted-imports": {
"count": 1
},
"react/set-state-in-effect": {
"count": 4
},
@ -4870,9 +4720,6 @@
}
},
"web/app/components/workflow/variable-inspect/listening.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 2
}
@ -4911,26 +4758,11 @@
"count": 5
}
},
"web/app/components/workflow/workflow-preview/components/nodes/base.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/workflow-preview/components/nodes/constants.ts": {
"ts/no-explicit-any": {
"count": 1
}
},
"web/app/components/workflow/workflow-preview/components/nodes/iteration-start/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/workflow-preview/components/nodes/loop-start/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/workflow-preview/components/zoom-in-out.tsx": {
"erasable-syntax-only/enums": {
"count": 1

View File

@ -99,6 +99,13 @@ See `[web/docs/overlay-migration.md](../../web/docs/overlay-migration.md)` for t
- Never create an extra manual portal on top of our primitives — use the exported content / portal parts such as `DialogContent`, `PopoverContent`, and `DrawerPortal`. Base UI handles focus management, scroll-locking, and dismissal.
- When a primitive needs additional presentation chrome (e.g. a custom backdrop), add it **inside** the exported component, not at call sites.
### Tooltip, infotip, and popover semantics
- Use `Tooltip` only for short, non-interactive visual labels. The trigger must already have visible text or an `aria-label`; the tooltip is not the accessible name and must not contain links, buttons, forms, or structured prose.
- Use `Popover` for explanatory content, long text, rich layout, or anything users may need to reach on touch or with assistive technology. In `web/`, the `Infotip` wrapper is the preferred pattern for a `?` help glyph backed by `Popover`.
- Pick a `placement` and let the primitive own spacing. Avoid per-call-site offsets unless the component API explicitly needs a measured layout exception.
- When passing a Base UI trigger `render` prop, render a real `<button type="button">` for button-like triggers. If a Popover trigger must render a `div`, `span`, or another non-button element, pass `nativeButton={false}`.
## Development
- `pnpm -C packages/dify-ui test` — Vitest unit tests for primitives.

View File

@ -90,6 +90,8 @@ const renderCloudPlanItem = ({
)
}
const getPlanButton = (name: string) => screen.getByRole('button', { name })
// ═══════════════════════════════════════════════════════════════════════════════
describe('Cloud Plan Payment Flow', () => {
beforeEach(() => {
@ -180,30 +182,30 @@ describe('Cloud Plan Payment Flow', () => {
it('should disable sandbox button when user is on professional plan (downgrade)', () => {
renderCloudPlanItem({ currentPlan: Plan.professional, plan: Plan.sandbox })
const button = screen.getByRole('button')
const button = getPlanButton('billing.plansCommon.startForFree')
expect(button).toBeDisabled()
})
it('should disable sandbox and professional buttons when user is on team plan', () => {
const { unmount } = renderCloudPlanItem({ currentPlan: Plan.team, plan: Plan.sandbox })
expect(screen.getByRole('button')).toBeDisabled()
expect(getPlanButton('billing.plansCommon.startForFree')).toBeDisabled()
unmount()
renderCloudPlanItem({ currentPlan: Plan.team, plan: Plan.professional })
expect(screen.getByRole('button')).toBeDisabled()
expect(getPlanButton('billing.plansCommon.startBuilding')).toBeDisabled()
})
it('should not disable current paid plan button (for invoice management)', () => {
renderCloudPlanItem({ currentPlan: Plan.professional, plan: Plan.professional })
const button = screen.getByRole('button')
const button = getPlanButton('billing.plansCommon.currentPlan')
expect(button).not.toBeDisabled()
})
it('should enable higher-tier plan buttons for upgrade', () => {
renderCloudPlanItem({ currentPlan: Plan.sandbox, plan: Plan.team })
const button = screen.getByRole('button')
const button = getPlanButton('billing.plansCommon.getStarted')
expect(button).not.toBeDisabled()
})
})
@ -219,7 +221,7 @@ describe('Cloud Plan Payment Flow', () => {
planRange: PlanRange.monthly,
})
const button = screen.getByRole('button')
const button = getPlanButton('billing.plansCommon.startBuilding')
await user.click(button)
await waitFor(() => {
@ -235,7 +237,7 @@ describe('Cloud Plan Payment Flow', () => {
planRange: PlanRange.yearly,
})
const button = screen.getByRole('button')
const button = getPlanButton('billing.plansCommon.getStarted')
await user.click(button)
await waitFor(() => {
@ -247,7 +249,7 @@ describe('Cloud Plan Payment Flow', () => {
const user = userEvent.setup()
renderCloudPlanItem({ currentPlan: Plan.professional, plan: Plan.professional })
const button = screen.getByRole('button')
const button = getPlanButton('billing.plansCommon.currentPlan')
await user.click(button)
await waitFor(() => {
@ -261,7 +263,7 @@ describe('Cloud Plan Payment Flow', () => {
const user = userEvent.setup()
renderCloudPlanItem({ currentPlan: Plan.sandbox, plan: Plan.sandbox })
const button = screen.getByRole('button')
const button = getPlanButton('billing.plansCommon.currentPlan')
await user.click(button)
// Wait a tick and verify no actions were taken
@ -279,7 +281,7 @@ describe('Cloud Plan Payment Flow', () => {
const user = userEvent.setup()
renderCloudPlanItem({ currentPlan: Plan.sandbox, plan: Plan.professional })
const button = screen.getByRole('button')
const button = getPlanButton('billing.plansCommon.startBuilding')
await user.click(button)
await waitFor(() => {

View File

@ -1,14 +1,14 @@
'use client'
import type { AccessControlAccount, AccessControlGroup } from '@/models/access-control'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { RiAlertFill, RiCloseCircleFill, RiLockLine, RiOrganizationChart } from '@remixicon/react'
import { RiCloseCircleFill, RiLockLine, RiOrganizationChart } from '@remixicon/react'
import { useCallback, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { AccessMode } from '@/models/access-control'
import { useAppWhiteListSubjects } from '@/service/access-control'
import useAccessControlStore from '../../../../context/access-control-store'
import { Infotip } from '../../base/infotip'
import Loading from '../../base/loading'
import Tooltip from '../../base/tooltip'
import AddMemberOrGroupDialog from './add-member-or-group-pop'
export default function SpecificGroupsOrMembers() {
@ -137,9 +137,14 @@ function BaseItem({ icon, onRemove, children }: BaseItemProps) {
export function WebAppSSONotEnabledTip() {
const { t } = useTranslation()
const tip = t('accessControlDialog.webAppSSONotEnabledTip', { ns: 'app' })
return (
<Tooltip asChild={false} popupContent={t('accessControlDialog.webAppSSONotEnabledTip', { ns: 'app' })}>
<RiAlertFill className="h-4 w-4 shrink-0 text-text-warning-secondary" />
</Tooltip>
<Infotip
aria-label={tip}
iconClassName="h-4 w-4 shrink-0 text-text-warning-secondary hover:text-text-warning-secondary"
>
{tip}
</Infotip>
)
}

View File

@ -22,7 +22,7 @@ import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ReactSortable } from 'react-sortablejs'
import { useContext } from 'use-context-selector'
import Tooltip from '@/app/components/base/tooltip'
import { Infotip } from '@/app/components/base/infotip'
import { InputVarType } from '@/app/components/workflow/types'
import ConfigContext from '@/context/debug-configuration'
import { useEventEmitterContextContext } from '@/context/event-emitter'
@ -257,13 +257,9 @@ const ConfigVar: FC<IConfigVarProps> = ({ promptVariables, readonly, onPromptVar
<div className="flex items-center">
<div className="mr-1">{t('variableTitle', { ns: 'appDebug' })}</div>
{!readonly && (
<Tooltip
popupContent={(
<div className="w-[180px]">
{t('variableTip', { ns: 'appDebug' })}
</div>
)}
/>
<Infotip aria-label={t('variableTip', { ns: 'appDebug' })} popupClassName="w-[180px]">
{t('variableTip', { ns: 'appDebug' })}
</Infotip>
)}
</div>
)}

View File

@ -2,7 +2,7 @@
import type { FC } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
import Tooltip from '@/app/components/base/tooltip'
import { Infotip } from '@/app/components/base/infotip'
type Props = {
className?: string
@ -24,14 +24,9 @@ const ItemPanel: FC<Props> = ({
<div className="flex items-center">
{icon}
<div className="mr-1 ml-3 text-sm leading-6 font-semibold text-text-secondary">{name}</div>
<Tooltip
popupContent={(
<div className="w-[180px]">
{description}
</div>
)}
>
</Tooltip>
<Infotip aria-label={description} popupClassName="w-[180px]">
{description}
</Infotip>
</div>
<div>
{children}

View File

@ -84,10 +84,6 @@ vi.mock('@/app/components/app/store', () => ({
}),
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ children }: { children: ReactNode }) => <>{children}</>,
}))
vi.mock('@/app/components/base/drawer', () => ({
default: ({ children, isOpen, onClose }: { children: ReactNode, isOpen: boolean, onClose: () => void }) => (
isOpen

View File

@ -1,4 +1,4 @@
import type { ReactElement, ReactNode } from 'react'
import type { ReactElement } from 'react'
import type { AppDetailResponse } from '@/models/app'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
@ -98,15 +98,6 @@ vi.mock('../../app-access-control', () => ({
),
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ children, popupContent }: { children: ReactNode, popupContent?: ReactNode }) => (
<div>
{children}
{popupContent}
</div>
),
}))
const mockWindowOpen = vi.fn()
Object.defineProperty(window, 'open', {
writable: true,

View File

@ -1,9 +1,9 @@
'use client'
import type { FC } from 'react'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { RiCloseLine, RiPlayLargeLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useStore } from '@/app/components/app/store'
import TooltipPlus from '@/app/components/base/tooltip'
import { WorkflowContextProvider } from '@/app/components/workflow/context'
import Run from '@/app/components/workflow/run'
import { useRouter } from '@/next/navigation'
@ -33,19 +33,23 @@ const DetailPanel: FC<ILogDetail> = ({ runID, onClose, canReplay = false }) => {
<div className="flex items-center bg-components-panel-bg">
<h1 className="shrink-0 px-4 py-1 system-xl-semibold text-text-primary">{t('runDetail.workflowTitle', { ns: 'appLog' })}</h1>
{canReplay && (
<TooltipPlus
popupContent={t('runDetail.testWithParams', { ns: 'appLog' })}
popupClassName="rounded-xl"
>
<button
type="button"
className="mr-1 flex h-6 w-6 items-center justify-center rounded-md hover:bg-state-base-hover"
aria-label={t('runDetail.testWithParams', { ns: 'appLog' })}
onClick={handleReplay}
>
<RiPlayLargeLine className="h-4 w-4 text-text-tertiary" />
</button>
</TooltipPlus>
<Tooltip>
<TooltipTrigger
render={(
<button
type="button"
className="mr-1 flex h-6 w-6 items-center justify-center rounded-md hover:bg-state-base-hover"
aria-label={t('runDetail.testWithParams', { ns: 'appLog' })}
onClick={handleReplay}
>
<RiPlayLargeLine className="h-4 w-4 text-text-tertiary" />
</button>
)}
/>
<TooltipContent className="rounded-xl">
{t('runDetail.testWithParams', { ns: 'appLog' })}
</TooltipContent>
</Tooltip>
)}
</div>
<WorkflowContextProvider>

View File

@ -296,11 +296,6 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', () => {
}
})
// Tooltip uses portals - minimal mock preserving popup content as title attribute
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ children, popupContent }: { children: React.ReactNode, popupContent: React.ReactNode }) => React.createElement('div', { title: popupContent }, children),
}))
// AppCardTags has tag API dependencies - mock for isolated testing
vi.mock('@/features/tag-management/components/app-card-tags', () => ({
AppCardTags: ({ tags }: { tags?: { id: string, name: string }[] }) => {

View File

@ -23,14 +23,10 @@ const ProgressTooltip: FC<ProgressTooltipProps> = ({
onOpenChange={setOpen}
>
<TooltipTrigger
render={(
<div
data-testid="progress-trigger-content"
className="flex grow items-center"
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
/>
)}
data-testid="progress-trigger-content"
className="flex grow items-center border-0 bg-transparent p-0 text-left"
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
>
<div className="mr-1 h-1.5 w-16 overflow-hidden rounded-[3px] border border-components-progress-gray-border">
<div
@ -45,7 +41,6 @@ const ProgressTooltip: FC<ProgressTooltipProps> = ({
<TooltipContent
data-testid="progress-tooltip-popup"
placement="top-start"
sideOffset={0}
className="rounded-lg bg-components-tooltip-bg p-3 system-xs-medium text-text-quaternary shadow-lg"
>
{t('chat.citation.hitScore', { ns: 'common' })}

View File

@ -26,14 +26,10 @@ const Tooltip: FC<TooltipProps> = ({
onOpenChange={setOpen}
>
<TooltipTrigger
render={(
<div
data-testid="tooltip-trigger-content"
className="mr-6 flex items-center"
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
/>
)}
data-testid="tooltip-trigger-content"
className="mr-6 flex items-center border-0 bg-transparent p-0 text-left"
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
>
{icon}
{data}
@ -41,7 +37,6 @@ const Tooltip: FC<TooltipProps> = ({
<TooltipContent
data-testid="tooltip-popup"
placement="top-start"
sideOffset={0}
className="rounded-lg bg-components-tooltip-bg p-3 system-xs-medium text-text-quaternary shadow-lg"
>
{text}

View File

@ -1,8 +1,8 @@
'use client'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useClipboard } from '@/hooks/use-clipboard'
import Tooltip from '../tooltip'
type Props = {
content: string
@ -25,14 +25,25 @@ const CopyIcon = ({ content }: Props) => {
const safeTooltipText = tooltipText || ''
return (
<Tooltip
popupContent={safeTooltipText}
>
<div onMouseLeave={reset}>
{!copied
? (<span className="mx-1 i-custom-vender-line-files-copy h-3.5 w-3.5 cursor-pointer text-text-tertiary" onClick={handleCopy} data-testid="copy-icon" />)
: (<span className="mx-1 i-custom-vender-line-files-copy-check h-3.5 w-3.5 text-text-tertiary" data-testid="copied-icon" />)}
</div>
<Tooltip>
<TooltipTrigger
render={(
<button
type="button"
aria-label={safeTooltipText}
className="mx-1 inline-flex h-3.5 w-3.5 cursor-pointer border-0 bg-transparent p-0 text-text-tertiary"
onClick={handleCopy}
onMouseLeave={reset}
>
{!copied
? (<span aria-hidden className="i-custom-vender-line-files-copy h-3.5 w-3.5" data-testid="copy-icon" />)
: (<span aria-hidden className="i-custom-vender-line-files-copy-check h-3.5 w-3.5" data-testid="copied-icon" />)}
</button>
)}
/>
<TooltipContent>
{safeTooltipText}
</TooltipContent>
</Tooltip>
)
}

View File

@ -1,7 +1,7 @@
'use client'
import type { FC } from 'react'
import * as React from 'react'
import Tooltip from '@/app/components/base/tooltip'
import { Infotip } from '@/app/components/base/infotip'
export const Item: FC<{ title: string, tooltip: string, children: React.JSX.Element }> = ({
title,
@ -12,11 +12,9 @@ export const Item: FC<{ title: string, tooltip: string, children: React.JSX.Elem
<div>
<div className="mb-1 flex items-center space-x-1">
<div className="py-1 system-sm-semibold text-text-secondary">{title}</div>
<Tooltip
popupContent={
<div className="max-w-[200px] system-sm-regular text-text-secondary">{tooltip}</div>
}
/>
<Infotip aria-label={tooltip} popupClassName="max-w-[200px] system-sm-regular text-text-secondary">
{tooltip}
</Infotip>
</div>
<div>{children}</div>
</div>

View File

@ -110,8 +110,7 @@ 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', () => {

View File

@ -10,7 +10,7 @@ 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'
@ -89,17 +89,16 @@ const VoiceParamConfig = ({
<div className="mb-3">
<div className="mb-1 flex items-center py-1 system-sm-semibold text-text-secondary">
{t('voice.voiceSettings.language', { ns: 'appDebug' })}
<Tooltip
popupContent={(
<div className="w-[180px]">
{t('voice.voiceSettings.resolutionTooltip', { ns: 'appDebug' }).split('\n').map(item => (
<div key={item}>
{item}
</div>
))}
<Infotip
aria-label={t('voice.voiceSettings.resolutionTooltip', { ns: 'appDebug' })}
popupClassName="w-[180px]"
>
{t('voice.voiceSettings.resolutionTooltip', { ns: 'appDebug' }).split('\n').map(item => (
<div key={item}>
{item}
</div>
)}
/>
))}
</Infotip>
</div>
<Listbox
value={languageItem}

View File

@ -1,11 +1,11 @@
import type { VariantProps } from 'class-variance-authority'
import { cn } from '@langgenius/dify-ui/cn'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { cva } from 'class-variance-authority'
import * as React from 'react'
import { useCallback } from 'react'
import { FileTypeIcon } from '../file-uploader'
import { getFileAppearanceType } from '../file-uploader/utils'
import Tooltip from '../tooltip'
import ImageRender from './image-render'
const FileThumbVariants = cva(
@ -46,43 +46,49 @@ const FileThumb = ({
const { name, mimeType, sourceUrl } = file
const isImage = mimeType.startsWith('image/')
const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const handleClick = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
e.preventDefault()
onClick?.(file)
}, [onClick, file])
return (
<Tooltip
popupContent={name}
popupClassName="p-1.5 rounded-lg system-xs-medium text-text-secondary"
position="top"
>
<div
className={cn(
FileThumbVariants({ size, className }),
isImage
? 'p-px'
: 'rounded-md border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg shadow-xs hover:bg-components-panel-on-panel-item-bg-alt',
<Tooltip>
<TooltipTrigger
render={(
<button
type="button"
aria-label={name}
className={cn(
FileThumbVariants({ size, className }),
'border-0 bg-transparent p-0',
isImage
? 'p-px'
: 'rounded-md border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg shadow-xs hover:bg-components-panel-on-panel-item-bg-alt',
)}
onClick={handleClick}
>
{
isImage
? (
<ImageRender
sourceUrl={sourceUrl}
name={name}
/>
)
: (
<FileTypeIcon
type={getFileAppearanceType(name, mimeType)}
size="sm"
/>
)
}
</button>
)}
onClick={handleClick}
>
{
isImage
? (
<ImageRender
sourceUrl={sourceUrl}
name={name}
/>
)
: (
<FileTypeIcon
type={getFileAppearanceType(name, mimeType)}
size="sm"
/>
)
}
</div>
/>
<TooltipContent placement="top" className="rounded-lg p-1.5 system-xs-medium text-text-secondary">
{name}
</TooltipContent>
</Tooltip>
)
}

View File

@ -41,8 +41,8 @@ describe('Label', () => {
const tooltipText = 'Test Tooltip'
render(<Label {...defaultProps} tooltip={tooltipText} />)
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', () => {

View File

@ -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 && <div className="ml-1 system-xs-regular text-text-tertiary">{t('label.optional', { ns: 'common' })}</div>}
{isRequired && <div className="ml-1 system-xs-regular text-text-destructive-secondary">*</div>}
{tooltip && (
<Tooltip
popupContent={
<div className="w-[200px]">{tooltip}</div>
}
triggerClassName="ml-0.5 w-4 h-4"
triggerTestId={`${htmlFor}-tooltip`}
/>
<Infotip aria-label={tooltip} className="ml-0.5 h-4 w-4" popupClassName="w-[200px]">
{tooltip}
</Infotip>
)}
</div>
)

View File

@ -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<HTMLInputElement, InputWithCopyProps>((
onMouseLeave={reset}
data-testid="copy-button-wrapper"
>
<Tooltip
popupContent={safeTooltipText}
>
<ActionButton
size="xs"
onClick={handleCopy}
className="hover:bg-components-button-ghost-bg-hover"
>
{copied
? (<span className="i-ri-clipboard-fill h-3.5 w-3.5 text-text-tertiary" data-testid="copied-icon" />)
: (<span className="i-ri-clipboard-line h-3.5 w-3.5 text-text-tertiary" data-testid="copy-icon" />)}
</ActionButton>
<Tooltip>
<TooltipTrigger
render={(
<ActionButton
size="xs"
aria-label={safeTooltipText}
onClick={handleCopy}
className="hover:bg-components-button-ghost-bg-hover"
>
{copied
? (<span className="i-ri-clipboard-fill h-3.5 w-3.5 text-text-tertiary" data-testid="copied-icon" />)
: (<span className="i-ri-clipboard-line h-3.5 w-3.5 text-text-tertiary" data-testid="copy-icon" />)}
</ActionButton>
)}
/>
<TooltipContent>
{safeTooltipText}
</TooltipContent>
</Tooltip>
</div>
)}

View File

@ -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()

View File

@ -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)
})
})
})

View File

@ -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(
<ToolTipContent>
<span>Tooltip body text</span>
</ToolTipContent>,
)
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(
<ToolTipContent title="Tooltip Title">
<span>Tooltip body text</span>
</ToolTipContent>,
)
expect(screen.getByTestId('tooltip-content-title')).toHaveTextContent('Tooltip Title')
})
it('should render action when provided', () => {
render(
<ToolTipContent action={<span>Action Text</span>}>
<span>Tooltip body text</span>
</ToolTipContent>,
)
expect(screen.getByTestId('tooltip-content-action')).toHaveTextContent('Action Text')
})
it('should handle action click', async () => {
const user = userEvent.setup()
const handleActionClick = vi.fn()
render(
<ToolTipContent action={<span onClick={handleActionClick}>Action Text</span>}>
<span>Tooltip body text</span>
</ToolTipContent>,
)
await user.click(screen.getByText('Action Text'))
expect(handleActionClick).toHaveBeenCalledTimes(1)
})
})

View File

@ -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(<Tooltip popupContent="Tooltip content" triggerClassName={triggerClassName} />)
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(
<Tooltip popupContent="Tooltip content">
<button>Hover me</button>
</Tooltip>,
)
expect(getByText('Hover me').textContent).toBe('Hover me')
})
it('should render correctly when asChild is false', () => {
const { container } = render(
<Tooltip popupContent="Tooltip" asChild={false} triggerClassName="custom-parent-trigger">
<span>Trigger</span>
</Tooltip>,
)
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(
<Tooltip popupContent="Tooltip" triggerClassName="custom-fallback-trigger">
{null}
</Tooltip>,
)
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(<Tooltip popupContent="Tooltip content" disabled triggerClassName={triggerClassName} />)
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(<Tooltip popupContent="Tooltip content" triggerClassName={triggerClassName} />)
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(<Tooltip popupContent="Tooltip content" triggerClassName={triggerClassName} needsDelay={false} />)
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(<Tooltip popupContent="Tooltip content" triggerMethod="click" triggerClassName={triggerClassName} />)
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(<Tooltip popupContent="Tooltip content" triggerMethod="click" triggerClassName={triggerClassName} />)
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(<Tooltip popupContent="Tooltip content" triggerMethod="hover" needsDelay triggerClassName={triggerClassName} />)
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(<Tooltip popupContent="Tooltip content" triggerMethod="hover" needsDelay triggerClassName={triggerClassName} />)
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(<Tooltip popupContent="Tooltip content" triggerMethod="click" needsDelay triggerClassName={triggerClassName} />)
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(<Tooltip popupContent="Tooltip content" triggerMethod="hover" needsDelay triggerClassName={triggerClassName} />)
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(<Tooltip popupContent="Tooltip content" triggerMethod="hover" needsDelay triggerClassName={triggerClassName} />)
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(
<div>
<Tooltip popupContent="Tooltip content 1" triggerMethod="hover" triggerClassName={triggerClassName1} />
<Tooltip popupContent="Tooltip content 2" triggerMethod="hover" triggerClassName={triggerClassName2} />
</div>,
)
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(<Tooltip popupContent="Tooltip content" triggerClassName={triggerClassName} />)
const trigger = container.querySelector(`.${triggerClassName}`)
expect(trigger?.className).toContain('custom-trigger')
})
it('should pass triggerTestId to the fallback icon wrapper', () => {
render(<Tooltip popupContent="Tooltip content" triggerTestId="test-tooltip-icon" />)
expect(screen.getByTestId('test-tooltip-icon')).toBeInTheDocument()
})
it('should apply custom popup className', async () => {
const triggerClassName = 'custom-trigger'
const { container } = render(<Tooltip popupContent="Tooltip content" triggerClassName={triggerClassName} popupClassName="custom-popup" />)
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(
<Tooltip
popupContent="Tooltip content"
triggerClassName={triggerClassName}
noDecoration
/>,
)
const trigger = container.querySelector(`.${triggerClassName}`)
act(() => {
fireEvent.mouseEnter(trigger!)
})
expect((await screen.findByText('Tooltip content'))?.className).not.toContain('bg-components-panel-bg')
})
})
})

View File

@ -1,22 +0,0 @@
import type { FC, PropsWithChildren, ReactNode } from 'react'
type ToolTipContentProps = {
title?: ReactNode
action?: ReactNode
} & PropsWithChildren
export const ToolTipContent: FC<ToolTipContentProps> = ({
title,
action,
children,
}) => {
return (
<div className="w-[180px]" data-testid="tooltip-content">
{!!title && (
<div className="mb-1.5 font-semibold text-text-secondary" data-testid="tooltip-content-title">{title}</div>
)}
<div className="mb-1.5 text-text-tertiary" data-testid="tooltip-content-body">{children}</div>
{!!action && <div className="cursor-pointer text-text-accent" data-testid="tooltip-content-action">{action}</div>}
</div>
)
}

View File

@ -1,60 +0,0 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import Tooltip from '.'
const TooltipGrid = () => {
return (
<div className="flex w-full max-w-xl flex-col gap-6 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
<div className="text-xs tracking-[0.18em] text-text-tertiary uppercase">Hover tooltips</div>
<div className="flex flex-wrap gap-4">
<Tooltip popupContent="Helpful hint explaining the setting.">
<button
type="button"
className="rounded-md border border-divider-subtle bg-background-default px-3 py-1 text-xs font-medium text-text-secondary hover:bg-state-base-hover"
>
Hover me
</button>
</Tooltip>
<Tooltip popupContent="Placement can vary." position="right">
<span className="rounded-md bg-background-default px-3 py-1 text-xs text-text-secondary">
Right tooltip
</span>
</Tooltip>
</div>
<div className="text-xs tracking-[0.18em] text-text-tertiary uppercase">Click tooltips</div>
<div className="flex flex-wrap gap-4">
<Tooltip popupContent="Click again to close." triggerMethod="click" position="bottom-start">
<button
type="button"
className="rounded-md border border-divider-subtle bg-background-default px-3 py-1 text-xs font-medium text-text-secondary hover:bg-state-base-hover"
>
Click trigger
</button>
</Tooltip>
<Tooltip popupContent="Decoration disabled" triggerMethod="click" noDecoration>
<span className="rounded-md border border-dashed border-divider-regular px-3 py-1 text-xs text-text-secondary">
Plain content
</span>
</Tooltip>
</div>
</div>
)
}
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<typeof TooltipGrid>
export default meta
type Story = StoryObj<typeof meta>
export const Playground: Story = {}

View File

@ -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<TooltipProps> = ({
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<ReturnType<typeof setTimeout> | 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 = (
<div data-testid={triggerTestId} className={triggerClassName || 'h-3.5 w-3.5 shrink-0 p-px'}>
<RiQuestionLine className="h-full w-full text-text-quaternary hover:text-text-tertiary" />
</div>
)
const triggerContent = children || fallbackTrigger
const childElement = React.isValidElement<React.HTMLAttributes<HTMLElement>>(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<HTMLElement>) => {
childProps.onMouseEnter?.(event)
handleTriggerMouseEnter()
},
onMouseLeave: (event: React.MouseEvent<HTMLElement>) => {
childProps.onMouseLeave?.(event)
handleTriggerMouseLeave()
},
})
}
const effectiveOpen = !disabled && open
return (
<Popover
open={effectiveOpen}
onOpenChange={handleOpenChange}
>
{asChild
? (
<PopoverTrigger
nativeButton={nativeButton}
disabled={disabled}
render={renderAsChildTrigger()}
/>
)
: (
<PopoverTrigger
nativeButton={false}
disabled={disabled}
render={(
<div
className={triggerClassName}
onMouseEnter={handleTriggerMouseEnter}
onMouseLeave={handleTriggerMouseLeave}
/>
)}
>
{triggerContent}
</PopoverTrigger>
)}
{effectiveOpen && !!popupContent && (
<PopoverContent
placement={position}
sideOffset={sideOffset}
alignOffset={alignOffset}
className={portalContentClassName}
popupClassName={cn(
noDecoration
? 'border-0 bg-transparent p-0 shadow-none'
: 'relative max-w-[300px] rounded-md border-0 bg-components-panel-bg px-3 py-2 text-left system-xs-regular wrap-break-word text-text-tertiary shadow-lg',
popupClassName,
)}
popupProps={{
onMouseEnter: handlePopupMouseEnter,
onMouseLeave: handlePopupMouseLeave,
}}
>
{popupContent}
</PopoverContent>
)}
</Popover>
)
}
export default React.memo(Tooltip)

View File

@ -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(() => {

View File

@ -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(<List plan={Plan.professional} />)
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()
})

View File

@ -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(<Item label={label} tooltip={tooltip} />)
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()
})

View File

@ -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(<Tooltip content={content} />)
await user.hover(screen.getByRole('button', { name: content }))
expect(() => screen.getByText(content)).not.toThrow()
expect(await screen.findByText(content)).toBeInTheDocument()
})
})

View File

@ -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 (
<div className="group relative z-10 size-[18px] overflow-visible">
<div className="absolute right-0 bottom-0 -z-10 hidden w-[260px] bg-saas-dify-blue-static px-5 py-[18px] system-xs-regular text-text-primary-on-surface group-hover:block">
{content}
</div>
<div className="flex h-full w-full items-center justify-center rounded-sm bg-state-base-hover transition-all duration-500 ease-in-out group-hover:rounded-none group-hover:bg-saas-dify-blue-static">
<Popover>
<PopoverTrigger
openOnHover
delay={0}
closeDelay={0}
aria-label={content}
className="group relative z-10 flex size-[18px] items-center justify-center rounded-sm border-0 bg-state-base-hover p-0 transition-[border-radius,background-color] duration-500 ease-in-out hover:rounded-none hover:bg-saas-dify-blue-static"
>
<RiInfoI className="size-3.5 text-text-tertiary group-hover:text-text-primary-on-surface" data-testid="tooltip-icon" />
</div>
</div>
</PopoverTrigger>
<PopoverContent placement="top-end" popupClassName="w-[260px] rounded-none border-0 bg-saas-dify-blue-static px-5 py-[18px] system-xs-regular text-text-primary-on-surface shadow-none">
{content}
</PopoverContent>
</Popover>
)
}

View File

@ -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
? (
<Tooltip popupContent={t('list.action.enableWarning', { ns: 'datasetDocuments' })} popupClassName="!font-semibold">
<div>
<Switch checked={false} onCheckedChange={noop} disabled={true} size="md" />
</div>
</Tooltip>
<Popover>
<PopoverTrigger nativeButton={false} openOnHover render={<div><Switch checked={false} onCheckedChange={noop} disabled={true} size="md" /></div>} />
<PopoverContent popupClassName="px-3 py-2 font-semibold system-xs-regular text-text-tertiary">
{t('list.action.enableWarning', { ns: 'datasetDocuments' })}
</PopoverContent>
</Popover>
)
: <Switch checked={enabled} onCheckedChange={v => handleSwitch(v ? 'enable' : 'disable')} size="md" />}
<Divider className="!mr-2 !ml-4 !h-3" type="vertical" />
@ -217,16 +219,24 @@ const Operations = ({ embeddingAvailable, datasetId, detail, selectedIds, onSele
)}
{embeddingAvailable && (
<>
<Tooltip popupContent={t('list.action.settings', { ns: 'datasetDocuments' })} popupClassName="text-text-secondary system-xs-medium" needsDelay={false}>
<button
type="button"
className={cn('mr-2 cursor-pointer rounded-lg', !isListScene
? 'border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg p-2 shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px] hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover'
: 'p-0.5 hover:bg-state-base-hover')}
onClick={() => router.push(`/datasets/${datasetId}/documents/${detail.id}/settings`)}
>
<span aria-hidden className="i-ri-equalizer-2-line h-4 w-4 text-components-button-secondary-text" />
</button>
<Tooltip>
<TooltipTrigger
render={(
<button
type="button"
aria-label={t('list.action.settings', { ns: 'datasetDocuments' })}
className={cn('mr-2 cursor-pointer rounded-lg', !isListScene
? 'border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg p-2 shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px] hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover'
: 'p-0.5 hover:bg-state-base-hover')}
onClick={() => router.push(`/datasets/${datasetId}/documents/${detail.id}/settings`)}
>
<span aria-hidden className="i-ri-equalizer-2-line h-4 w-4 text-components-button-secondary-text" />
</button>
)}
/>
<TooltipContent className="system-xs-medium text-text-secondary">
{t('list.action.settings', { ns: 'datasetDocuments' })}
</TooltipContent>
</Tooltip>
<DropdownMenu open={isOperationsMenuOpen} onOpenChange={setIsOperationsMenuOpen}>
<DropdownMenuTrigger

View File

@ -15,12 +15,6 @@ vi.mock('@/app/components/base/radio/ui', () => ({
),
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ children, popupContent }: { children: React.ReactNode, popupContent: string }) => (
<div data-testid="tooltip" title={popupContent}>{children}</div>
),
}))
vi.mock('../file-icon', () => ({
default: () => <span data-testid="file-icon" />,
}))

View File

@ -1,12 +1,10 @@
import type { Placement } from '@floating-ui/react'
import type { OnlineDriveFile } from '@/models/pipeline'
import { cn } from '@langgenius/dify-ui/cn'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import * as React from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import Checkbox from '@/app/components/base/checkbox'
import Radio from '@/app/components/base/radio/ui'
import Tooltip from '@/app/components/base/tooltip'
import { formatFileSize } from '@/utils/format'
import FileIcon from './file-icon'
@ -33,14 +31,7 @@ const Item = ({
const isBucket = type === 'bucket'
const isFolder = type === 'folder'
const Wrapper = disabled ? Tooltip : React.Fragment
const wrapperProps = disabled
? {
popupContent: t('onlineDrive.notSupportedFileType', { ns: 'datasetPipeline' }),
position: 'top-end' as Placement,
offset: { mainAxis: 4, crossAxis: -104 },
}
: {}
const disabledTip = t('onlineDrive.notSupportedFileType', { ns: 'datasetPipeline' })
const handleSelect = useCallback((e: React.MouseEvent<HTMLDivElement> | React.KeyboardEvent<HTMLDivElement>) => {
e.stopPropagation()
@ -80,27 +71,44 @@ const Item = ({
onCheck={handleSelect}
/>
)}
<Wrapper
{...wrapperProps}
>
<div
className={cn(
'flex grow items-center gap-x-1 overflow-hidden py-0.5',
disabled && 'opacity-30',
{disabled
? (
<Popover>
<PopoverTrigger
openOnHover
aria-label={disabledTip}
className="flex grow items-center gap-x-1 overflow-hidden border-0 bg-transparent p-0 py-0.5 text-left opacity-30"
>
<FileIcon type={type} fileName={name} className="shrink-0 transform-gpu" />
<span
className="grow truncate system-sm-medium text-text-secondary"
title={name}
>
{name}
</span>
{!isFolder && typeof size === 'number' && (
<span className="shrink-0 system-xs-regular text-text-tertiary">{formatFileSize(size)}</span>
)}
</PopoverTrigger>
<PopoverContent placement="top-end" popupClassName="px-3 py-2 system-xs-regular text-text-tertiary">
{disabledTip}
</PopoverContent>
</Popover>
)
: (
<div className="flex grow items-center gap-x-1 overflow-hidden py-0.5">
<FileIcon type={type} fileName={name} className="shrink-0 transform-gpu" />
<span
className="grow truncate system-sm-medium text-text-secondary"
title={name}
>
{name}
</span>
{!isFolder && typeof size === 'number' && (
<span className="shrink-0 system-xs-regular text-text-tertiary">{formatFileSize(size)}</span>
)}
</div>
)}
>
<FileIcon type={type} fileName={name} className="shrink-0 transform-gpu" />
<span
className="grow truncate system-sm-medium text-text-secondary"
title={name}
>
{name}
</span>
{!isFolder && typeof size === 'number' && (
<span className="shrink-0 system-xs-regular text-text-tertiary">{formatFileSize(size)}</span>
)}
</div>
</Wrapper>
</div>
)
}

View File

@ -4,6 +4,7 @@ import type { InitialDocumentDetail } from '@/models/pipeline'
import type { RETRIEVE_METHOD } from '@/types/app'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import {
RiAedFill,
RiArrowRightLine,
@ -17,7 +18,6 @@ import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import NotionIcon from '@/app/components/base/notion-icon'
import Tooltip from '@/app/components/base/tooltip'
import PriorityLabel from '@/app/components/billing/priority-label'
import { Plan } from '@/app/components/billing/type'
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
@ -203,15 +203,18 @@ const EmbeddingProcess = ({
<div className="shrink-0 text-xs text-text-secondary">{`${getSourcePercent(indexingStatusDetail)}%`}</div>
)}
{indexingStatusDetail.indexing_status === 'error' && (
<Tooltip
popupClassName="px-4 py-[14px] max-w-60 body-xs-regular text-text-secondary border-[0.5px] border-components-panel-border rounded-xl"
offset={4}
popupContent={indexingStatusDetail.error}
>
<span>
<Popover>
<PopoverTrigger
openOnHover
aria-label={indexingStatusDetail.error}
className="inline-flex border-0 bg-transparent p-0"
>
<RiErrorWarningFill className="size-4 shrink-0 text-text-destructive" />
</span>
</Tooltip>
</PopoverTrigger>
<PopoverContent popupClassName="max-w-60 rounded-xl border-[0.5px] border-components-panel-border px-4 py-[14px] body-xs-regular text-text-secondary">
{indexingStatusDetail.error}
</PopoverContent>
</Popover>
)}
{indexingStatusDetail.indexing_status === 'completed' && (
<RiCheckboxCircleFill className="size-4 shrink-0 text-text-success" />

View File

@ -1,9 +1,9 @@
import type { FC } from 'react'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { RiLineHeight } from '@remixicon/react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { Collapse } from '@/app/components/base/icons/src/vender/knowledge'
import Tooltip from '@/app/components/base/tooltip'
type DisplayToggleProps = {
isCollapsed: boolean
@ -15,25 +15,30 @@ const DisplayToggle: FC<DisplayToggleProps> = ({
toggleCollapsed,
}) => {
const { t } = useTranslation()
const label = isCollapsed ? t('segment.expandChunks', { ns: 'datasetDocuments' }) : t('segment.collapseChunks', { ns: 'datasetDocuments' })
return (
<Tooltip
popupContent={isCollapsed ? t('segment.expandChunks', { ns: 'datasetDocuments' }) : t('segment.collapseChunks', { ns: 'datasetDocuments' })}
popupClassName="text-text-secondary system-xs-medium border-[0.5px] border-components-panel-border"
>
<button
type="button"
className="flex items-center justify-center rounded-lg border-[0.5px] border-components-button-secondary-border
bg-components-button-secondary-bg p-2 shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]"
onClick={toggleCollapsed}
>
{
isCollapsed
? <RiLineHeight className="h-4 w-4 text-components-button-secondary-text" />
: <Collapse className="h-4 w-4 text-components-button-secondary-text" />
}
</button>
<Tooltip>
<TooltipTrigger
render={(
<button
type="button"
aria-label={label}
className="flex items-center justify-center rounded-lg border-[0.5px] border-components-button-secondary-border
bg-components-button-secondary-bg p-2 shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]"
onClick={toggleCollapsed}
>
{
isCollapsed
? <RiLineHeight className="h-4 w-4 text-components-button-secondary-text" />
: <Collapse className="h-4 w-4 text-components-button-secondary-text" />
}
</button>
)}
/>
<TooltipContent className="border-[0.5px] border-components-panel-border system-xs-medium text-text-secondary">
{label}
</TooltipContent>
</Tooltip>
)
}

View File

@ -10,13 +10,13 @@ import {
} from '@langgenius/dify-ui/alert-dialog'
import { cn } from '@langgenius/dify-ui/cn'
import { Switch } from '@langgenius/dify-ui/switch'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { RiDeleteBinLine, RiEditLine } from '@remixicon/react'
import * as React from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Badge from '@/app/components/base/badge'
import Divider from '@/app/components/base/divider'
import Tooltip from '@/app/components/base/tooltip'
import ImageList from '@/app/components/datasets/common/image-list'
import { ChunkingMode } from '@/models/datasets'
import { formatNumber } from '@/utils/format'
@ -182,35 +182,43 @@ const SegmentCard: FC<ISegmentCardProps> = ({
>
{!archived && (
<>
<Tooltip
popupContent="Edit"
popupClassName="text-text-secondary system-xs-medium"
>
<div
data-testid="segment-edit-button"
className="flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded-lg hover:bg-state-base-hover"
onClick={(e) => {
e.stopPropagation()
onClickEdit?.()
}}
>
<RiEditLine className="h-4 w-4 text-text-tertiary" />
</div>
<Tooltip>
<TooltipTrigger
render={(
<button
type="button"
aria-label="Edit"
data-testid="segment-edit-button"
className="flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded-lg hover:bg-state-base-hover"
onClick={(e) => {
e.stopPropagation()
onClickEdit?.()
}}
>
<RiEditLine className="h-4 w-4 text-text-tertiary" />
</button>
)}
/>
<TooltipContent className="system-xs-medium text-text-secondary">Edit</TooltipContent>
</Tooltip>
<Tooltip
popupContent="Delete"
popupClassName="text-text-secondary system-xs-medium"
>
<div
data-testid="segment-delete-button"
className="group/delete flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded-lg hover:bg-state-destructive-hover"
onClick={(e) => {
e.stopPropagation()
setShowModal(true)
}}
>
<RiDeleteBinLine className="h-4 w-4 text-text-tertiary group-hover/delete:text-text-destructive" />
</div>
<Tooltip>
<TooltipTrigger
render={(
<button
type="button"
aria-label="Delete"
data-testid="segment-delete-button"
className="group/delete flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded-lg hover:bg-state-destructive-hover"
onClick={(e) => {
e.stopPropagation()
setShowModal(true)
}}
>
<RiDeleteBinLine className="h-4 w-4 text-text-tertiary group-hover/delete:text-text-destructive" />
</button>
)}
/>
<TooltipContent className="system-xs-medium text-text-secondary">Delete</TooltipContent>
</Tooltip>
<Divider type="vertical" className="h-3.5 bg-divider-regular" />
</>

View File

@ -3,7 +3,6 @@ import type { FC } from 'react'
import type { BuiltInMetadataItem, MetadataItemInBatchEdit, MetadataItemWithEdit } from '../types'
import { Button } from '@langgenius/dify-ui/button'
import { toast } from '@langgenius/dify-ui/toast'
import { RiQuestionLine } from '@remixicon/react'
import { produce } from 'immer'
import * as React from 'react'
import { useCallback, useState } from 'react'
@ -11,8 +10,8 @@ import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import { useCreateMetaData } from '@/service/knowledge/use-metadata'
import Checkbox from '../../../base/checkbox'
import { Infotip } from '../../../base/infotip'
import Modal from '../../../base/modal'
import Tooltip from '../../../base/tooltip'
import AddMetadataButton from '../add-metadata-button'
import useCheckMetadataName from '../hooks/use-check-metadata-name'
import SelectMetadataModal from '../metadata-dataset/select-metadata-modal'
@ -115,11 +114,14 @@ const EditMetadataBatchModal: FC<Props> = ({ datasetId, documentNum, list, onSav
<div className="flex items-center select-none">
<Checkbox checked={isApplyToAllSelectDocument} onCheck={() => setIsApplyToAllSelectDocument(!isApplyToAllSelectDocument)} id="apply-to-all" />
<div className="mr-1 ml-2 system-xs-medium text-text-secondary">{t(`${i18nPrefix}.applyToAllSelectDocument`, { ns: 'dataset' })}</div>
<Tooltip popupContent={<div className="max-w-[240px]">{t(`${i18nPrefix}.applyToAllSelectDocumentTip`, { ns: 'dataset' })}</div>}>
<div className="cursor-pointer p-px">
<RiQuestionLine className="size-3.5 text-text-tertiary" />
</div>
</Tooltip>
<Infotip
aria-label={t(`${i18nPrefix}.applyToAllSelectDocumentTip`, { ns: 'dataset' })}
className="p-px"
iconClassName="size-3.5 text-text-tertiary"
popupClassName="max-w-[240px]"
>
{t(`${i18nPrefix}.applyToAllSelectDocumentTip`, { ns: 'dataset' })}
</Infotip>
</div>
<div className="flex items-center space-x-2">
<Button onClick={onHide}>

View File

@ -20,9 +20,9 @@ import * as React from 'react'
import { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Drawer from '@/app/components/base/drawer'
import { Infotip } from '@/app/components/base/infotip'
import Input from '@/app/components/base/input'
import Modal from '@/app/components/base/modal'
import Tooltip from '@/app/components/base/tooltip'
import CreateModal from '@/app/components/datasets/metadata/metadata-dataset/create-metadata-modal'
import { getIcon } from '../utils/get-icon'
import Field from './field'
@ -215,7 +215,9 @@ const DatasetMetadataDrawer: FC<Props> = ({
onCheckedChange={onIsBuiltInEnabledChange}
/>
<div className="mr-0.5 ml-2 system-sm-semibold text-text-secondary">{t(`${i18nPrefix}.builtIn`, { ns: 'dataset' })}</div>
<Tooltip popupContent={<div className="max-w-[100px]">{t(`${i18nPrefix}.builtInDescription`, { ns: 'dataset' })}</div>} />
<Infotip aria-label={t(`${i18nPrefix}.builtInDescription`, { ns: 'dataset' })} popupClassName="max-w-[100px]">
{t(`${i18nPrefix}.builtInDescription`, { ns: 'dataset' })}
</Infotip>
</div>
<div className="mt-1 space-y-1">

View File

@ -1,5 +1,6 @@
import type { CustomModel, ModelProvider } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import SwitchCredentialInLoadBalancing from '../switch-credential-in-load-balancing'
@ -105,7 +106,8 @@ describe('SwitchCredentialInLoadBalancing', () => {
expect(mockSetCustomModelCredential).toHaveBeenCalledWith(mockCredentials[0])
})
it('should show tooltip when empty and custom credentials not allowed', () => {
it('should show tooltip when empty and custom credentials not allowed', async () => {
const user = userEvent.setup()
const restrictedProvider = { ...mockProvider, allow_custom_token: false }
render(
<SwitchCredentialInLoadBalancing
@ -117,8 +119,8 @@ describe('SwitchCredentialInLoadBalancing', () => {
/>,
)
fireEvent.mouseEnter(screen.getByText(/auth.credentialUnavailableInButton/))
expect(screen.getByText('plugin.auth.credentialUnavailable'))!.toBeInTheDocument()
await user.hover(screen.getByRole('button', { name: /auth.credentialUnavailableInButton/ }))
expect(await screen.findByText('plugin.auth.credentialUnavailable'))!.toBeInTheDocument()
})
// Empty credentials with allowed custom: no tooltip but still shows unavailable text

View File

@ -5,6 +5,7 @@ import type {
import {
Button,
} from '@langgenius/dify-ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import {
RiEqualizer2Line,
} from '@remixicon/react'
@ -13,7 +14,6 @@ import {
useCallback,
} from 'react'
import { useTranslation } from 'react-i18next'
import Tooltip from '@/app/components/base/tooltip'
import { ConfigurationMethodEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import Authorized from './authorized'
import { useCredentialStatus } from './hooks'
@ -53,11 +53,11 @@ const ConfigProvider = ({
)
if (notAllowCustomCredential && !hasCredential) {
return (
<Tooltip
asChild
popupContent={t('auth.credentialUnavailable', { ns: 'plugin' })}
>
{Item}
<Tooltip>
<TooltipTrigger render={Item} />
<TooltipContent>
{t('auth.credentialUnavailable', { ns: 'plugin' })}
</TooltipContent>
</Tooltip>
)
}

View File

@ -6,6 +6,7 @@ import type {
} from '../declarations'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { RiArrowDownSLine } from '@remixicon/react'
import {
memo,
@ -13,7 +14,6 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import Badge from '@/app/components/base/badge'
import Tooltip from '@/app/components/base/tooltip'
import { ConfigurationMethodEnum, ModelModalModeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import Indicator from '@/app/components/header/indicator'
import Authorized from './authorized'
@ -89,11 +89,11 @@ const SwitchCredentialInLoadBalancing = ({
)
if (empty && notAllowCustomCredential) {
return (
<Tooltip
asChild
popupContent={t('auth.credentialUnavailable', { ns: 'plugin' })}
>
{Item}
<Tooltip>
<TooltipTrigger render={Item} />
<TooltipContent>
{t('auth.credentialUnavailable', { ns: 'plugin' })}
</TooltipContent>
</Tooltip>
)
}

View File

@ -21,10 +21,10 @@ describe('StatusIndicators', () => {
installedPlugins = [{ name: 'demo-plugin', plugin_unique_identifier: 'demo@1.0.0' }]
})
const getTooltipTrigger = (container: HTMLElement) => {
const trigger = container.querySelector('[role="button"][aria-haspopup="dialog"]')
const getPopoverTrigger = (name: string) => {
const trigger = screen.getByRole('button', { name })
expect(trigger).toBeInTheDocument()
return trigger as HTMLElement
return trigger
}
it('should render nothing when model is available and enabled', () => {
@ -43,7 +43,7 @@ describe('StatusIndicators', () => {
it('should render deprecated tooltip when provider model is disabled and in model list', async () => {
const user = userEvent.setup()
const { container } = render(
render(
<StatusIndicators
needsConfiguration={false}
modelProvider={true}
@ -54,14 +54,14 @@ describe('StatusIndicators', () => {
/>,
)
await user.hover(getTooltipTrigger(container))
await user.hover(getPopoverTrigger('nodes.agent.modelSelectorTooltips.deprecated'))
expect(await screen.findByText('nodes.agent.modelSelectorTooltips.deprecated')).toBeInTheDocument()
})
it('should render model-not-support tooltip when disabled model is not in model list and has no pluginInfo', async () => {
const user = userEvent.setup()
const { container } = render(
render(
<StatusIndicators
needsConfiguration={false}
modelProvider={true}
@ -72,7 +72,7 @@ describe('StatusIndicators', () => {
/>,
)
await user.hover(getTooltipTrigger(container))
await user.hover(getPopoverTrigger('nodes.agent.modelNotSupport.title'))
expect(await screen.findByText('nodes.agent.modelNotSupport.title')).toBeInTheDocument()
})
@ -125,7 +125,7 @@ describe('StatusIndicators', () => {
it('should render marketplace warning tooltip when provider is unavailable', async () => {
const user = userEvent.setup()
const { container } = render(
render(
<StatusIndicators
needsConfiguration={false}
modelProvider={false}
@ -136,7 +136,7 @@ describe('StatusIndicators', () => {
/>,
)
await user.hover(getTooltipTrigger(container))
await user.hover(getPopoverTrigger('nodes.agent.modelNotInMarketplace.title'))
expect(await screen.findByText('nodes.agent.modelNotInMarketplace.title')).toBeInTheDocument()
})

View File

@ -1,5 +1,6 @@
import type { ReactNode } from 'react'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { RiErrorWarningFill } from '@remixicon/react'
import Tooltip from '@/app/components/base/tooltip'
import { SwitchPluginVersion } from '@/app/components/workflow/nodes/_base/components/switch-plugin-version'
import Link from '@/next/link'
import { useInstalledPluginList } from '@/service/use-plugins'
@ -13,6 +14,28 @@ type StatusIndicatorsProps = {
t: any
}
type StatusPopoverProps = {
ariaLabel: string
content: ReactNode
children: ReactNode
}
const StatusPopover = ({ ariaLabel, content, children }: StatusPopoverProps) => (
<Popover>
<PopoverTrigger
openOnHover
aria-label={ariaLabel}
className="inline-flex border-0 bg-transparent p-0"
onClick={e => e.stopPropagation()}
>
{children}
</PopoverTrigger>
<PopoverContent placement="top" popupClassName="rounded-md px-3 py-2 system-xs-regular text-text-tertiary">
{content}
</PopoverContent>
</Popover>
)
const StatusIndicators = ({ needsConfiguration, modelProvider, inModelList, disabled, pluginInfo, t }: StatusIndicatorsProps) => {
const { data: pluginList } = useInstalledPluginList()
const renderTooltipContent = (title: string, description?: string, linkText?: string, linkHref?: string) => {
@ -48,27 +71,26 @@ const StatusIndicators = ({ needsConfiguration, modelProvider, inModelList, disa
<>
{inModelList
? (
<Tooltip
popupContent={t('nodes.agent.modelSelectorTooltips.deprecated', { ns: 'workflow' })}
asChild={false}
needsDelay={false}
<StatusPopover
ariaLabel={t('nodes.agent.modelSelectorTooltips.deprecated', { ns: 'workflow' })}
content={t('nodes.agent.modelSelectorTooltips.deprecated', { ns: 'workflow' })}
>
<RiErrorWarningFill className="h-4 w-4 text-text-destructive" />
</Tooltip>
</StatusPopover>
)
: !pluginInfo
? (
<Tooltip
popupContent={renderTooltipContent(
<StatusPopover
ariaLabel={t('nodes.agent.modelNotSupport.title', { ns: 'workflow' })}
content={renderTooltipContent(
t('nodes.agent.modelNotSupport.title', { ns: 'workflow' }),
t('nodes.agent.modelNotSupport.desc', { ns: 'workflow' }),
t('nodes.agent.linkToPlugin', { ns: 'workflow' }),
'/plugins',
)}
asChild={false}
>
<RiErrorWarningFill className="h-4 w-4 text-text-destructive" />
</Tooltip>
</StatusPopover>
)
: (
<SwitchPluginVersion
@ -82,17 +104,17 @@ const StatusIndicators = ({ needsConfiguration, modelProvider, inModelList, disa
</>
)}
{!modelProvider && !pluginInfo && (
<Tooltip
popupContent={renderTooltipContent(
<StatusPopover
ariaLabel={t('nodes.agent.modelNotInMarketplace.title', { ns: 'workflow' })}
content={renderTooltipContent(
t('nodes.agent.modelNotInMarketplace.title', { ns: 'workflow' }),
t('nodes.agent.modelNotInMarketplace.desc', { ns: 'workflow' }),
t('nodes.agent.linkToPlugin', { ns: 'workflow' }),
'/plugins',
)}
asChild={false}
>
<RiErrorWarningFill className="h-4 w-4 text-text-destructive" />
</Tooltip>
</StatusPopover>
)}
</>
)

View File

@ -42,10 +42,6 @@ vi.mock('../feature-icon', () => ({
default: ({ feature }: { feature: string }) => <span data-testid="feature-icon">{feature}</span>,
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}))
const mockCredentialPanelState = vi.hoisted(() => vi.fn())
vi.mock('../../provider-added-card/use-credential-panel-state', () => ({
useCredentialPanelState: mockCredentialPanelState,

View File

@ -1,5 +1,6 @@
import type { ModelItem, ModelProvider } from '../declarations'
import { cn } from '@langgenius/dify-ui/cn'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { Switch } from '@langgenius/dify-ui/switch'
import { useQueryClient } from '@tanstack/react-query'
import { useDebounceFn } from 'ahooks'
@ -7,7 +8,6 @@ import { memo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import Badge from '@/app/components/base/badge'
import { Balance } from '@/app/components/base/icons/src/vender/line/financeAndECommerce'
import Tooltip from '@/app/components/base/tooltip'
import { Plan } from '@/app/components/billing/type'
import { useAppContext } from '@/context/app-context'
import { useProviderContext, useProviderContextSelector } from '@/context/provider-context'
@ -102,14 +102,12 @@ const ModelListItem = ({ model, provider, isConfigurable, onChange, onModifyLoad
{
model.deprecated
? (
<Tooltip
popupContent={
<span className="font-semibold">{t('modelProvider.modelHasBeenDeprecated', { ns: 'common' })}</span>
}
offset={{ mainAxis: 4 }}
>
<Switch checked={false} disabled size="md" />
</Tooltip>
<Popover>
<PopoverTrigger nativeButton={false} openOnHover render={<span><Switch checked={false} disabled size="md" /></span>} />
<PopoverContent popupClassName="px-3 py-2 font-semibold system-xs-regular text-text-tertiary">
{t('modelProvider.modelHasBeenDeprecated', { ns: 'common' })}
</PopoverContent>
</Popover>
)
: (isCurrentWorkspaceManager && (
<Switch

View File

@ -2,6 +2,7 @@
import type { PluginDetail } from '@/app/components/plugins/types'
import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types'
import { cn } from '@langgenius/dify-ui/cn'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import {
RiDeleteBinLine,
RiEditLine,
@ -10,7 +11,6 @@ import {
import { useBoolean } from 'ahooks'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import Tooltip from '@/app/components/base/tooltip'
import { DeleteConfirm } from './delete-confirm'
import { EditModal } from './edit'
@ -65,19 +65,26 @@ const SubscriptionCard = ({ data, pluginDetail }: Props) => {
</div>
<div className="mt-1 flex items-center justify-between">
<Tooltip
disabled={!data.endpoint}
popupContent={data.endpoint && (
<div className="max-w-[320px] break-all">
{data.endpoint}
</div>
)}
position="left"
>
<div className="flex-1 truncate system-xs-regular text-text-tertiary">
{data.endpoint}
</div>
</Tooltip>
{data.endpoint
? (
<Popover>
<PopoverTrigger
openOnHover
aria-label={data.endpoint}
className="flex-1 truncate border-0 bg-transparent p-0 text-left system-xs-regular text-text-tertiary"
>
{data.endpoint}
</PopoverTrigger>
<PopoverContent placement="left" popupClassName="max-w-[320px] break-all px-3 py-2 system-xs-regular text-text-tertiary">
{data.endpoint}
</PopoverContent>
</Popover>
)
: (
<div className="flex-1 truncate system-xs-regular text-text-tertiary">
{data.endpoint}
</div>
)}
<div className="mx-2 text-xs text-text-tertiary opacity-30">·</div>
<div className="shrink-0 system-xs-regular text-text-tertiary">
{data.workflows_in_use > 0 ? t('subscription.list.item.usedByNum', { ns: 'pluginTrigger', num: data.workflows_in_use }) : t('subscription.list.item.noUsed', { ns: 'pluginTrigger' })}

View File

@ -54,10 +54,6 @@ vi.mock('@langgenius/dify-ui/switch', () => ({
),
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useLanguage: () => 'en_US',
}))
@ -233,7 +229,7 @@ describe('ReasoningConfigForm', () => {
it('should open schema modal for object fields and support app selection', () => {
const onChange = vi.fn()
const { container } = render(
render(
<ReasoningConfigForm
value={{
app: {
@ -265,7 +261,7 @@ describe('ReasoningConfigForm', () => {
/>,
)
fireEvent.click(container.querySelector('div.ml-0\\.5.cursor-pointer')!)
fireEvent.click(screen.getByRole('button', { name: 'workflow.nodes.agent.clickToViewParameterSchema' }))
expect(screen.getByTestId('schema-modal')).toHaveTextContent('Config')
fireEvent.click(screen.getByTestId('close-schema'))

View File

@ -9,6 +9,7 @@ import type {
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 { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import {
RiArrowRightUpLine,
RiBracesLine,
@ -16,9 +17,8 @@ import {
import { useBoolean } from 'ahooks'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Infotip } from '@/app/components/base/infotip'
import Input from '@/app/components/base/input'
// eslint-disable-next-line no-restricted-imports -- legacy tooltip migration is handled separately from this change
import Tooltip from '@/app/components/base/tooltip'
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { AppSelector } from '@/app/components/plugins/plugin-detail-panel/app-selector'
@ -127,17 +127,16 @@ const ReasoningConfigForm: React.FC<Props> = ({
} = schema
const auto = value[variable]?.auto
const fieldTitle = getFieldTitle(label, language)
const tooltipContent = (tooltip && (
<Tooltip
popupContent={(
<div className="w-[200px]">
{tooltip[language] || tooltip.en_US}
</div>
)}
triggerClassName="ml-0.5 w-4 h-4"
asChild={false}
/>
))
const tooltipText = tooltip?.[language] || tooltip?.en_US
const tooltipContent = tooltipText && (
<Infotip
aria-label={tooltipText}
className="ml-0.5 h-4 w-4"
popupClassName="w-[200px]"
>
{tooltipText}
</Infotip>
)
const varInput = value[variable]!.value
const {
isString,
@ -173,20 +172,22 @@ const ReasoningConfigForm: React.FC<Props> = ({
<span className="mx-1 system-xs-regular text-text-quaternary">·</span>
<span className="system-xs-regular text-text-tertiary">{resolveTargetVarType(type)}</span>
{isShowJSONEditor && (
<Tooltip
popupContent={(
<div className="system-xs-medium text-text-secondary">
{t('nodes.agent.clickToViewParameterSchema', { ns: 'workflow' })}
</div>
)}
asChild={false}
>
<div
className="ml-0.5 cursor-pointer rounded-sm p-px text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
onClick={() => showSchema(input_schema as SchemaRoot, fieldTitle!)}
>
<RiBracesLine className="size-3.5" />
</div>
<Tooltip>
<TooltipTrigger
render={(
<button
type="button"
aria-label={t('nodes.agent.clickToViewParameterSchema', { ns: 'workflow' })}
className="ml-0.5 cursor-pointer rounded-sm border-0 bg-transparent p-px text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
onClick={() => showSchema(input_schema as SchemaRoot, fieldTitle!)}
>
<RiBracesLine className="size-3.5" />
</button>
)}
/>
<TooltipContent className="system-xs-medium text-text-secondary">
{t('nodes.agent.clickToViewParameterSchema', { ns: 'workflow' })}
</TooltipContent>
</Tooltip>
)}

View File

@ -1,8 +1,8 @@
'use client'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { Switch } from '@langgenius/dify-ui/switch'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import {
RiDeleteBinLine,
RiEqualizer2Line,
@ -14,7 +14,6 @@ import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import AppIcon from '@/app/components/base/app-icon'
import { Group } from '@/app/components/base/icons/src/vender/other'
import { ToolTipContent } from '@/app/components/base/tooltip/content'
import Indicator from '@/app/components/header/indicator'
import { InstallPluginButton } from '@/app/components/workflow/nodes/_base/components/install-plugin-button'
import { useMCPToolAvailability } from '@/app/components/workflow/nodes/_base/components/mcp-tool-availability'
@ -144,11 +143,14 @@ const ToolItem = ({
className="-mt-1"
uniqueIdentifier={installInfo}
tooltip={(
<ToolTipContent
title={t('detailPanel.toolSelector.unsupportedTitle', { ns: 'plugin' })}
>
{`${t('detailPanel.toolSelector.unsupportedContent', { ns: 'plugin' })} ${t('detailPanel.toolSelector.unsupportedContent2', { ns: 'plugin' })}`}
</ToolTipContent>
<div className="w-[180px]" data-testid="tooltip-content">
<div className="mb-1.5 font-semibold text-text-secondary" data-testid="tooltip-content-title">
{t('detailPanel.toolSelector.unsupportedTitle', { ns: 'plugin' })}
</div>
<div className="mb-1.5 text-text-tertiary" data-testid="tooltip-content-body">
{`${t('detailPanel.toolSelector.unsupportedContent', { ns: 'plugin' })} ${t('detailPanel.toolSelector.unsupportedContent2', { ns: 'plugin' })}`}
</div>
</div>
)}
onChange={() => {
onInstall?.()
@ -167,18 +169,18 @@ const ToolItem = ({
/>
)}
{isError && (
<Tooltip>
<TooltipTrigger
render={(
<div aria-label={typeof errorTip === 'string' ? errorTip : undefined}>
<RiErrorWarningFill className="h-4 w-4 text-text-destructive" />
</div>
)}
/>
<TooltipContent>
<Popover>
<PopoverTrigger
openOnHover
aria-label={typeof errorTip === 'string' ? errorTip : t('detailPanel.toolSelector.unsupportedTitle', { ns: 'plugin' })}
className="inline-flex border-0 bg-transparent p-0"
>
<RiErrorWarningFill className="h-4 w-4 text-text-destructive" />
</PopoverTrigger>
<PopoverContent popupClassName="px-3 py-2 system-xs-regular text-text-tertiary">
{errorTip}
</TooltipContent>
</Tooltip>
</PopoverContent>
</Popover>
)}
</div>
)

View File

@ -2,6 +2,7 @@
import type { FC } from 'react'
import type { PluginDetail } from '../types'
import { cn } from '@langgenius/dify-ui/cn'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import {
RiArrowRightUpLine,
RiBugLine,
@ -13,7 +14,6 @@ import { useSuspenseQuery } from '@tanstack/react-query'
import * as React from 'react'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Tooltip from '@/app/components/base/tooltip'
import useRefreshPluginList from '@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list'
import { API_PREFIX } from '@/config'
import { useAppContext } from '@/context/app-context'
@ -124,12 +124,18 @@ const PluginItem: FC<Props> = ({
<Title title={title} />
{verified && <Verified className="ml-0.5 h-4 w-4" text={t('marketplace.verifiedTip', { ns: 'plugin' })} />}
{!isDifyVersionCompatible && (
<Tooltip popupContent={
t('difyVersionNotCompatible', { ns: 'plugin', minimalDifyVersion: declarationMeta.minimum_dify_version })
}
>
<RiErrorWarningLine color="red" className="ml-0.5 h-4 w-4 shrink-0 text-text-accent" />
</Tooltip>
<Popover>
<PopoverTrigger
openOnHover
aria-label={t('difyVersionNotCompatible', { ns: 'plugin', minimalDifyVersion: declarationMeta.minimum_dify_version })}
className="ml-0.5 inline-flex h-4 w-4 shrink-0 border-0 bg-transparent p-0"
>
<RiErrorWarningLine color="red" className="h-4 w-4 text-text-accent" />
</PopoverTrigger>
<PopoverContent popupClassName="px-3 py-2 system-xs-regular text-text-tertiary">
{t('difyVersionNotCompatible', { ns: 'plugin', minimalDifyVersion: declarationMeta.minimum_dify_version })}
</PopoverContent>
</Popover>
)}
<Badge
className="ml-1 shrink-0"

View File

@ -13,7 +13,7 @@ import {
import { useTranslation } from 'react-i18next'
import { useNodes } from 'reactflow'
import Divider from '@/app/components/base/divider'
import Tooltip from '@/app/components/base/tooltip'
import { Infotip } from '@/app/components/base/infotip'
import { useInputFieldPanel } from '@/app/components/rag-pipeline/hooks'
import { useNodesSyncDraft } from '@/app/components/workflow/hooks'
import { useStore } from '@/app/components/workflow/store'
@ -137,10 +137,12 @@ const InputFieldPanel = () => {
<span className="system-sm-semibold-uppercase text-text-secondary">
{t('inputFieldPanel.uniqueInputs.title', { ns: 'datasetPipeline' })}
</span>
<Tooltip
popupContent={t('inputFieldPanel.uniqueInputs.tooltip', { ns: 'datasetPipeline' })}
<Infotip
aria-label={t('inputFieldPanel.uniqueInputs.tooltip', { ns: 'datasetPipeline' })}
popupClassName="max-w-[240px]"
/>
>
{t('inputFieldPanel.uniqueInputs.tooltip', { ns: 'datasetPipeline' })}
</Infotip>
</div>
<div className="flex flex-col gap-y-1 py-1">
{

View File

@ -6,9 +6,9 @@ import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Drawer from '@/app/components/base/drawer-plus'
import { Infotip } from '@/app/components/base/infotip'
import Input from '@/app/components/base/input'
import Radio from '@/app/components/base/radio/ui'
import Tooltip from '@/app/components/base/tooltip'
import { AuthHeaderPrefix, AuthType } from '@/app/components/tools/types'
type Props = {
@ -123,14 +123,13 @@ const ConfigCredential: FC<Props> = ({
<div>
<div className="flex items-center py-2 system-sm-medium text-text-primary">
{t('createTool.authMethod.key', { ns: 'tools' })}
<Tooltip
popupContent={(
<div className="w-[261px] text-text-tertiary">
{t('createTool.authMethod.keyTooltip', { ns: 'tools' })}
</div>
)}
triggerClassName="ml-0.5 w-4 h-4"
/>
<Infotip
aria-label={t('createTool.authMethod.keyTooltip', { ns: 'tools' })}
className="ml-0.5 h-4 w-4"
popupClassName="w-[261px] text-text-tertiary"
>
{t('createTool.authMethod.keyTooltip', { ns: 'tools' })}
</Infotip>
</div>
<Input
value={tempCredential.api_key_header}
@ -153,14 +152,13 @@ const ConfigCredential: FC<Props> = ({
<div>
<div className="flex items-center py-2 system-sm-medium text-text-primary">
{t('createTool.authMethod.queryParam', { ns: 'tools' })}
<Tooltip
popupContent={(
<div className="w-[261px] text-text-tertiary">
{t('createTool.authMethod.queryParamTooltip', { ns: 'tools' })}
</div>
)}
triggerClassName="ml-0.5 w-4 h-4"
/>
<Infotip
aria-label={t('createTool.authMethod.queryParamTooltip', { ns: 'tools' })}
className="ml-0.5 h-4 w-4"
popupClassName="w-[261px] text-text-tertiary"
>
{t('createTool.authMethod.queryParamTooltip', { ns: 'tools' })}
</Infotip>
</div>
<Input
value={tempCredential.api_key_query_param}

View File

@ -1,4 +1,3 @@
import type { ReactNode } from 'react'
import type { WorkflowToolDrawerPayload } from '../index'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
@ -28,21 +27,6 @@ vi.mock('@/app/components/tools/labels/selector', () => ({
),
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({
children,
popupContent,
}: {
children?: ReactNode
popupContent?: ReactNode
}) => (
<div>
{children}
{popupContent}
</div>
),
}))
vi.mock('../confirm-modal', () => ({
default: ({ show, onClose, onConfirm }: { show: boolean, onClose: () => void, onConfirm: () => void }) => (
show

View File

@ -23,21 +23,6 @@ const {
},
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({
children,
popupContent,
}: {
children: React.ReactNode
popupContent: React.ReactNode
}) => (
<div>
<span>{popupContent}</span>
{children}
</div>
),
}))
vi.mock('@/service/use-plugins', () => ({
useFeaturedToolsRecommendations: () => ({
plugins: [],
@ -121,11 +106,13 @@ describe('Tabs', () => {
filterElem: <div>filter</div>,
}
it('should render start content and disabled tab tooltip text', () => {
it('should render start content and disabled tab tooltip text', async () => {
const user = userEvent.setup()
render(<Tabs {...baseProps} />)
expect(screen.getByText('start-content'))!.toBeInTheDocument()
expect(screen.getByText('workflow.tabs.startDisabledTip'))!.toBeInTheDocument()
await user.hover(screen.getByText('Blocks'))
expect(await screen.findByText('workflow.tabs.startDisabledTip'))!.toBeInTheDocument()
})
it('should switch tabs through click handlers and render tools content with normalized icons', () => {

View File

@ -6,10 +6,10 @@ import type {
ToolWithProvider,
} from '../types'
import { cn } from '@langgenius/dify-ui/cn'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useSuspenseQuery } from '@tanstack/react-query'
import { memo, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Tooltip from '@/app/components/base/tooltip'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { useFeaturedToolsRecommendations } from '@/service/use-plugins'
import { useAllBuiltInTools, useAllCustomTools, useAllMCPTools, useAllWorkflowTools, useInvalidateAllBuiltInTools } from '@/service/use-tools'
@ -129,19 +129,22 @@ const TabHeaderItem = ({
if (tab.disabled) {
return (
<Tooltip
key={tab.key}
position="top"
popupClassName="max-w-[200px]"
popupContent={disabledTip}
>
<div
className={className}
aria-disabled={tab.disabled}
onClick={handleClick}
>
{tab.name}
</div>
<Tooltip key={tab.key}>
<TooltipTrigger
render={(
<button
type="button"
className={className}
aria-disabled={tab.disabled}
onClick={handleClick}
>
{tab.name}
</button>
)}
/>
<TooltipContent placement="top" className="max-w-[200px]">
{disabledTip}
</TooltipContent>
</Tooltip>
)
}

View File

@ -1,22 +1,23 @@
'use client'
import type { FC } from 'react'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { RiAlertFill } from '@remixicon/react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Tooltip from '@/app/components/base/tooltip'
const McpToolNotSupportTooltip: FC = () => {
const { t } = useTranslation()
const tip = t('detailPanel.toolSelector.unsupportedMCPTool', { ns: 'plugin' })
return (
<Tooltip
popupContent={(
<div className="w-[256px]">
{t('detailPanel.toolSelector.unsupportedMCPTool', { ns: 'plugin' })}
</div>
)}
>
<RiAlertFill className="size-4 text-text-warning-secondary" />
</Tooltip>
<Popover>
<PopoverTrigger openOnHover aria-label={tip} className="inline-flex border-0 bg-transparent p-0">
<RiAlertFill className="size-4 text-text-warning-secondary" />
</PopoverTrigger>
<PopoverContent popupClassName="w-[256px] px-3 py-2 system-xs-regular text-text-tertiary">
{tip}
</PopoverContent>
</Popover>
)
}
export default React.memo(McpToolNotSupportTooltip)

View File

@ -2,13 +2,13 @@
import type { FC, ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { RiArrowLeftRightLine, RiExternalLinkLine } from '@remixicon/react'
import { useBoolean } from 'ahooks'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Badge from '@/app/components/base/badge'
import { Badge as Badge2, BadgeState } from '@/app/components/base/badge/index'
import Tooltip from '@/app/components/base/tooltip'
import useGetIcon from '@/app/components/plugins/install-plugin/base/use-get-icon'
import { pluginManifestToCardPluginProps } from '@/app/components/plugins/install-plugin/utils'
import PluginMutationModel from '@/app/components/plugins/plugin-mutation-model'
@ -67,76 +67,91 @@ export const SwitchPluginVersion: FC<SwitchPluginVersionProps> = (props) => {
if (!uniqueIdentifier || !pluginId)
return null
const content = (
<div className={cn('flex w-fit items-center justify-center', className)} onClick={e => e.stopPropagation()}>
{isShowUpdateModal && pluginDetail && (
<PluginMutationModel
onCancel={hideUpdateModal}
plugin={pluginManifestToCardPluginProps({
...pluginDetail.declaration,
icon: icon!,
})}
mutation={mutation}
mutate={install}
confirmButtonText={t('nodes.agent.installPlugin.install', { ns: 'workflow' })}
cancelButtonText={t('nodes.agent.installPlugin.cancel', { ns: 'workflow' })}
modelTitle={t('nodes.agent.installPlugin.title', { ns: 'workflow' })}
description={t('nodes.agent.installPlugin.desc', { ns: 'workflow' })}
cardTitleLeft={(
<>
<Badge2 className="mx-1" size="s" state={BadgeState.Warning}>
{`${pluginDetail.version} -> ${target!.version}`}
</Badge2>
</>
)}
modalBottomLeft={(
<Link
className="flex items-center justify-center gap-1"
href={getMarketplaceUrl(`/plugins/${pluginDetail.declaration.author}/${pluginDetail.declaration.name}`)}
target="_blank"
rel="noopener noreferrer"
>
<span className="system-xs-regular text-xs text-text-accent">
{t('nodes.agent.installPlugin.changelog', { ns: 'workflow' })}
</span>
<RiExternalLinkLine className="size-3 text-text-accent" />
</Link>
)}
/>
)}
{pluginDetail && (
<PluginVersionPicker
isShow={isShow}
onShowChange={setIsShow}
pluginID={pluginId}
currentVersion={pluginDetail.version}
onSelect={(state) => {
setTarget({
pluginUniqueIden: state.unique_identifier,
version: state.version,
})
showUpdateModal()
}}
trigger={(
<Badge
className={cn(
'mx-1 flex hover:bg-state-base-hover',
isShow && 'bg-state-base-hover',
)}
uppercase={true}
text={(
<>
<div>{pluginDetail.version}</div>
<RiArrowLeftRightLine className="ml-1 h-3 w-3 text-text-tertiary" />
</>
)}
hasRedCornerMark={true}
/>
)}
/>
)}
</div>
)
if (!tooltip || isShow || isShowUpdateModal)
return content
return (
<Tooltip popupContent={!isShow && !isShowUpdateModal && tooltip} triggerMethod="hover">
<div className={cn('flex w-fit items-center justify-center', className)} onClick={e => e.stopPropagation()}>
{isShowUpdateModal && pluginDetail && (
<PluginMutationModel
onCancel={hideUpdateModal}
plugin={pluginManifestToCardPluginProps({
...pluginDetail.declaration,
icon: icon!,
})}
mutation={mutation}
mutate={install}
confirmButtonText={t('nodes.agent.installPlugin.install', { ns: 'workflow' })}
cancelButtonText={t('nodes.agent.installPlugin.cancel', { ns: 'workflow' })}
modelTitle={t('nodes.agent.installPlugin.title', { ns: 'workflow' })}
description={t('nodes.agent.installPlugin.desc', { ns: 'workflow' })}
cardTitleLeft={(
<>
<Badge2 className="mx-1" size="s" state={BadgeState.Warning}>
{`${pluginDetail.version} -> ${target!.version}`}
</Badge2>
</>
)}
modalBottomLeft={(
<Link
className="flex items-center justify-center gap-1"
href={getMarketplaceUrl(`/plugins/${pluginDetail.declaration.author}/${pluginDetail.declaration.name}`)}
target="_blank"
rel="noopener noreferrer"
>
<span className="system-xs-regular text-xs text-text-accent">
{t('nodes.agent.installPlugin.changelog', { ns: 'workflow' })}
</span>
<RiExternalLinkLine className="size-3 text-text-accent" />
</Link>
)}
/>
)}
{pluginDetail && (
<PluginVersionPicker
isShow={isShow}
onShowChange={setIsShow}
pluginID={pluginId}
currentVersion={pluginDetail.version}
onSelect={(state) => {
setTarget({
pluginUniqueIden: state.unique_identifier,
version: state.version,
})
showUpdateModal()
}}
trigger={(
<Badge
className={cn(
'mx-1 flex hover:bg-state-base-hover',
isShow && 'bg-state-base-hover',
)}
uppercase={true}
text={(
<>
<div>{pluginDetail.version}</div>
<RiArrowLeftRightLine className="ml-1 h-3 w-3 text-text-tertiary" />
</>
)}
hasRedCornerMark={true}
/>
)}
/>
)}
</div>
</Tooltip>
<Popover>
<PopoverTrigger
openOnHover
nativeButton={false}
aria-label={typeof tooltip === 'string' ? tooltip : t('nodes.agent.installPlugin.title', { ns: 'workflow' })}
render={content}
/>
<PopoverContent popupClassName="px-3 py-2 system-xs-regular text-text-tertiary">
{tooltip}
</PopoverContent>
</Popover>
)
}

View File

@ -1,8 +1,8 @@
import type { NodeProps } from 'reactflow'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { RiHome5Fill } from '@remixicon/react'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import Tooltip from '@/app/components/base/tooltip'
import { NodeSourceHandle } from '@/app/components/workflow/nodes/_base/components/node-handle'
const IterationStartNode = ({ id, data }: NodeProps) => {
@ -10,10 +10,14 @@ const IterationStartNode = ({ id, data }: NodeProps) => {
return (
<div className="nodrag group mt-1 flex h-11 w-11 items-center justify-center rounded-2xl border border-workflow-block-border bg-workflow-block-bg shadow-xs">
<Tooltip popupContent={t('blocks.iteration-start', { ns: 'workflow' })} asChild={false}>
<div className="flex h-6 w-6 items-center justify-center rounded-full border-[0.5px] border-components-panel-border-subtle bg-util-colors-blue-brand-blue-brand-500">
<Tooltip>
<TooltipTrigger
aria-label={t('blocks.iteration-start', { ns: 'workflow' })}
className="flex h-6 w-6 items-center justify-center rounded-full border-[0.5px] border-components-panel-border-subtle bg-util-colors-blue-brand-blue-brand-500 p-0"
>
<RiHome5Fill className="h-3 w-3 text-text-primary-on-surface" />
</div>
</TooltipTrigger>
<TooltipContent>{t('blocks.iteration-start', { ns: 'workflow' })}</TooltipContent>
</Tooltip>
<NodeSourceHandle
id={id}
@ -30,10 +34,14 @@ export const IterationStartNodeDumb = () => {
return (
<div className="nodrag relative top-[21px] left-[17px] z-11 flex h-11 w-11 items-center justify-center rounded-2xl border border-workflow-block-border bg-workflow-block-bg">
<Tooltip popupContent={t('blocks.iteration-start', { ns: 'workflow' })} asChild={false}>
<div className="flex h-6 w-6 items-center justify-center rounded-full border-[0.5px] border-components-panel-border-subtle bg-util-colors-blue-brand-blue-brand-500">
<Tooltip>
<TooltipTrigger
aria-label={t('blocks.iteration-start', { ns: 'workflow' })}
className="flex h-6 w-6 items-center justify-center rounded-full border-[0.5px] border-components-panel-border-subtle bg-util-colors-blue-brand-blue-brand-500 p-0"
>
<RiHome5Fill className="h-3 w-3 text-text-primary-on-surface" />
</div>
</TooltipTrigger>
<TooltipContent>{t('blocks.iteration-start', { ns: 'workflow' })}</TooltipContent>
</Tooltip>
</div>
)

View File

@ -17,7 +17,7 @@ import {
import { useTranslation } from 'react-i18next'
import WeightedScoreComponent from '@/app/components/app/configuration/dataset-config/params-config/weighted-score'
import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
import Tooltip from '@/app/components/base/tooltip'
import { Infotip } from '@/app/components/base/infotip'
import { DEFAULT_WEIGHTED_SCORE } from '@/models/datasets'
import {
HybridSearchModeEnum,
@ -174,10 +174,13 @@ const SearchMethodOption = ({
disabled={readonly}
/>
{t('modelProvider.rerankModel.key', { ns: 'common' })}
<Tooltip
triggerClassName="ml-0.5 shrink-0 w-3.5 h-3.5"
popupContent={t('modelProvider.rerankModel.tip', { ns: 'common' })}
/>
<Infotip
aria-label={t('modelProvider.rerankModel.tip', { ns: 'common' })}
className="ml-0.5 h-3.5 w-3.5 shrink-0"
iconClassName="h-3.5 w-3.5"
>
{t('modelProvider.rerankModel.tip', { ns: 'common' })}
</Infotip>
</div>
)
}

View File

@ -5,7 +5,7 @@ import {
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import Tooltip from '@/app/components/base/tooltip'
import { Infotip } from '@/app/components/base/infotip'
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
import Collapse from '@/app/components/workflow/nodes/_base/components/collapse'
import { MetadataFilteringModeEnum } from '@/app/components/workflow/nodes/knowledge-retrieval/types'
@ -46,13 +46,9 @@ const MetadataFilter = ({
<div className="mr-0.5 system-sm-semibold-uppercase text-text-secondary">
{t('nodes.knowledgeRetrieval.metadata.title', { ns: 'workflow' })}
</div>
<Tooltip
popupContent={(
<div className="w-[200px]">
{t('nodes.knowledgeRetrieval.metadata.tip', { ns: 'workflow' })}
</div>
)}
/>
<Infotip aria-label={t('nodes.knowledgeRetrieval.metadata.tip', { ns: 'workflow' })} popupClassName="w-[200px]">
{t('nodes.knowledgeRetrieval.metadata.tip', { ns: 'workflow' })}
</Infotip>
{collapseIcon}
</div>
<div className="flex items-center">

View File

@ -1,8 +1,8 @@
import type { NodeProps } from 'reactflow'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { RiHome5Fill } from '@remixicon/react'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import Tooltip from '@/app/components/base/tooltip'
import { NodeSourceHandle } from '@/app/components/workflow/nodes/_base/components/node-handle'
const LoopStartNode = ({ id, data }: NodeProps) => {
@ -10,10 +10,14 @@ const LoopStartNode = ({ id, data }: NodeProps) => {
return (
<div className="nodrag group mt-1 flex h-11 w-11 items-center justify-center rounded-2xl border border-workflow-block-border bg-workflow-block-bg">
<Tooltip popupContent={t('blocks.loop-start', { ns: 'workflow' })} asChild={false}>
<div className="flex h-6 w-6 items-center justify-center rounded-full border-[0.5px] border-components-panel-border-subtle bg-util-colors-blue-brand-blue-brand-500">
<Tooltip>
<TooltipTrigger
aria-label={t('blocks.loop-start', { ns: 'workflow' })}
className="flex h-6 w-6 items-center justify-center rounded-full border-[0.5px] border-components-panel-border-subtle bg-util-colors-blue-brand-blue-brand-500 p-0"
>
<RiHome5Fill className="h-3 w-3 text-text-primary-on-surface" />
</div>
</TooltipTrigger>
<TooltipContent>{t('blocks.loop-start', { ns: 'workflow' })}</TooltipContent>
</Tooltip>
<NodeSourceHandle
id={id}
@ -30,10 +34,14 @@ export const LoopStartNodeDumb = () => {
return (
<div className="nodrag relative top-[21px] left-[17px] z-11 flex h-11 w-11 items-center justify-center rounded-2xl border border-workflow-block-border bg-workflow-block-bg">
<Tooltip popupContent={t('blocks.loop-start', { ns: 'workflow' })} asChild={false}>
<div className="flex h-6 w-6 items-center justify-center rounded-full border-[0.5px] border-components-panel-border-subtle bg-util-colors-blue-brand-blue-brand-500">
<Tooltip>
<TooltipTrigger
aria-label={t('blocks.loop-start', { ns: 'workflow' })}
className="flex h-6 w-6 items-center justify-center rounded-full border-[0.5px] border-components-panel-border-subtle bg-util-colors-blue-brand-blue-brand-500 p-0"
>
<RiHome5Fill className="h-3 w-3 text-text-primary-on-surface" />
</div>
</TooltipTrigger>
<TooltipContent>{t('blocks.loop-start', { ns: 'workflow' })}</TooltipContent>
</Tooltip>
</div>
)

View File

@ -3,7 +3,7 @@ import type { ParameterExtractorNodeType } from './types'
import type { NodePanelProps } from '@/app/components/workflow/types'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Tooltip from '@/app/components/base/tooltip'
import { Infotip } from '@/app/components/base/infotip'
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
import { FieldCollapse } from '@/app/components/workflow/nodes/_base/components/collapse'
import Field from '@/app/components/workflow/nodes/_base/components/field'
@ -131,14 +131,14 @@ const Panel: FC<NodePanelProps<ParameterExtractorNodeType>> = ({
title={(
<div className="flex items-center space-x-1">
<span className="uppercase">{t(`${i18nPrefix}.instruction`, { ns: 'workflow' })}</span>
<Tooltip
popupContent={(
<div className="w-[120px]">
{t(`${i18nPrefix}.instructionTip`, { ns: 'workflow' })}
</div>
)}
triggerClassName="w-3.5 h-3.5 ml-0.5"
/>
<Infotip
aria-label={t(`${i18nPrefix}.instructionTip`, { ns: 'workflow' })}
className="ml-0.5 h-3.5 w-3.5"
iconClassName="h-3.5 w-3.5"
popupClassName="w-[120px]"
>
{t(`${i18nPrefix}.instructionTip`, { ns: 'workflow' })}
</Infotip>
</div>
)}
value={inputs.instruction}

View File

@ -344,8 +344,8 @@ describe('question-classifier path', () => {
)
expect(screen.getByText(`${longName.slice(0, 50)}...`)).toBeInTheDocument()
await user.hover(screen.getByText(`${longName.slice(0, 50)}...`))
expect(screen.getByText(longName)).toBeInTheDocument()
await user.hover(screen.getByRole('button', { name: longName }))
expect(await screen.findByText(longName)).toBeInTheDocument()
rerender(
<Node

View File

@ -1,25 +1,10 @@
import type { QuestionClassifierNodeType, Topic } from '../types'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useTextGenerationCurrentProviderAndModelAndModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { BlockEnum } from '@/app/components/workflow/types'
import Node from '../node'
vi.mock('@/app/components/base/tooltip', () => ({
__esModule: true,
default: ({
children,
popupContent,
}: {
children: React.ReactNode
popupContent: React.ReactNode
}) => (
<div>
{children}
{popupContent}
</div>
),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useTextGenerationCurrentProviderAndModelAndModelList: vi.fn(),
}))
@ -101,7 +86,8 @@ describe('question-classifier/node', () => {
expect(screen.getByText('handle-topic-2')).toBeInTheDocument()
})
it('returns nothing when neither model nor classes are configured and truncates long class names', () => {
it('returns nothing when neither model nor classes are configured and truncates long class names', async () => {
const user = userEvent.setup()
const longName = 'L'.repeat(60)
const { container, rerender } = render(
<Node
@ -119,7 +105,8 @@ describe('question-classifier/node', () => {
)
expect(screen.getByText(`${longName.slice(0, 50)}...`)).toBeInTheDocument()
expect(screen.getByText(longName)).toBeInTheDocument()
await user.hover(screen.getByRole('button', { name: longName }))
expect(await screen.findByText(longName)).toBeInTheDocument()
rerender(
<Node

View File

@ -3,7 +3,7 @@ import type { FC } from 'react'
import type { Memory, Node, NodeOutPutVar } from '@/app/components/workflow/types'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Tooltip from '@/app/components/base/tooltip'
import { Infotip } from '@/app/components/base/infotip'
import Editor from '@/app/components/workflow/nodes/_base/components/prompt/editor'
import MemoryConfig from '../../_base/components/memory-config'
@ -48,14 +48,14 @@ const AdvancedSetting: FC<Props> = ({
title={(
<div className="flex items-center space-x-1">
<span className="uppercase">{t(`${i18nPrefix}.instruction`, { ns: 'workflow' })}</span>
<Tooltip
popupContent={(
<div className="w-[120px]">
{t(`${i18nPrefix}.instructionTip`, { ns: 'workflow' })}
</div>
)}
triggerClassName="w-3.5 h-3.5 ml-0.5"
/>
<Infotip
aria-label={t(`${i18nPrefix}.instructionTip`, { ns: 'workflow' })}
className="ml-0.5 h-3.5 w-3.5"
iconClassName="h-3.5 w-3.5"
popupClassName="w-[120px]"
>
{t(`${i18nPrefix}.instructionTip`, { ns: 'workflow' })}
</Infotip>
</div>
)}
value={instruction}

View File

@ -2,9 +2,9 @@ import type { TFunction } from 'i18next'
import type { FC } from 'react'
import type { NodeProps } from 'reactflow'
import type { QuestionClassifierNodeType } from './types'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Tooltip from '@/app/components/base/tooltip'
import {
useTextGenerationCurrentProviderAndModelAndModelList,
} from '@/app/components/header/account-setting/model-provider-page/hooks'
@ -47,15 +47,18 @@ const TruncatedClassItem: FC<TruncatedClassItemProps> = ({ topic, index, nodeId,
</div>
{shouldShowTooltip
? (
<Tooltip
popupContent={(
<div className="max-w-[300px] wrap-break-word">
<ReadonlyInputWithSelectVar value={topic.name} nodeId={nodeId} />
</div>
)}
>
{content}
</Tooltip>
<Popover>
<PopoverTrigger
openOnHover
aria-label={topic.name}
className="w-full border-0 bg-transparent p-0 text-left"
>
{content}
</PopoverTrigger>
<PopoverContent popupClassName="max-w-[300px] px-3 py-2 system-xs-regular wrap-break-word text-text-tertiary">
<ReadonlyInputWithSelectVar value={topic.name} nodeId={nodeId} />
</PopoverContent>
</Popover>
)
: content}
</div>

View File

@ -9,7 +9,7 @@ import {
RiBracesLine,
} from '@remixicon/react'
import { useBoolean } from 'ahooks'
import Tooltip from '@/app/components/base/tooltip'
import { Infotip } from '@/app/components/base/infotip'
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { SchemaModal } from '@/app/components/plugins/plugin-detail-panel/tool-selector/components'
@ -57,15 +57,13 @@ const TriggerFormItem: FC<Props> = ({
<div className="ml-1 system-xs-regular text-text-destructive-secondary">*</div>
)}
{!showDescription && tooltip && (
<Tooltip
popupContent={(
<div className="w-[200px]">
{tooltip[language] || tooltip.en_US}
</div>
)}
triggerClassName="ml-1 w-4 h-4"
asChild={false}
/>
<Infotip
aria-label={tooltip[language] || tooltip.en_US}
className="ml-1 h-4 w-4"
popupClassName="w-[200px]"
>
{tooltip[language] || tooltip.en_US}
</Infotip>
)}
{showSchemaButton && (
<>

View File

@ -94,10 +94,6 @@ vi.mock('@/app/components/base/input-with-copy', () => ({
),
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({
default: ({ title, children }: { title: string, children: React.ReactNode }) => (
<div>

View File

@ -11,12 +11,12 @@ import {
} from '@langgenius/dify-ui/number-field'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { toast } from '@langgenius/dify-ui/toast'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import copy from 'copy-to-clipboard'
import * as React from 'react'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import InputWithCopy from '@/app/components/base/input-with-copy'
import Tooltip from '@/app/components/base/tooltip'
import Field from '@/app/components/workflow/nodes/_base/components/field'
import OutputVars from '@/app/components/workflow/nodes/_base/components/output-vars'
import Split from '@/app/components/workflow/nodes/_base/components/split'
@ -118,32 +118,38 @@ const Panel: FC<NodePanelProps<WebhookTriggerNodeType>> = ({
</div>
{inputs.webhook_debug_url && (
<div className="space-y-2">
<Tooltip
popupContent={debugUrlCopied ? t(`${i18nPrefix}.debugUrlCopied`, { ns: 'workflow' }) : t(`${i18nPrefix}.debugUrlCopy`, { ns: 'workflow' })}
popupClassName="system-xs-regular text-text-primary bg-components-tooltip-bg border border-components-panel-border shadow-lg backdrop-blur-xs rounded-md px-1.5 py-1"
position="top"
offset={{ mainAxis: -20 }}
needsDelay={true}
>
<div
className="flex cursor-pointer gap-1.5 rounded-lg px-1 py-1.5 transition-colors"
style={{ width: '368px', height: '38px' }}
onClick={() => {
copy(inputs.webhook_debug_url || '')
setDebugUrlCopied(true)
setTimeout(() => setDebugUrlCopied(false), 2000)
}}
<Tooltip>
<TooltipTrigger
render={(
<button
type="button"
aria-label={t(`${i18nPrefix}.debugUrlCopy`, { ns: 'workflow' })}
className="flex cursor-pointer gap-1.5 rounded-lg px-1 py-1.5 text-left transition-colors"
style={{ width: '368px', height: '38px' }}
onClick={() => {
copy(inputs.webhook_debug_url || '')
setDebugUrlCopied(true)
setTimeout(() => setDebugUrlCopied(false), 2000)
}}
>
<span className="mt-0.5 w-0.5 bg-divider-regular" style={{ height: '28px' }} />
<span className="flex-1" style={{ width: '352px', height: '32px' }}>
<span className="block text-xs leading-4 text-text-tertiary">
{t(`${i18nPrefix}.debugUrlTitle`, { ns: 'workflow' })}
</span>
<span className="block truncate text-xs leading-4 text-text-primary">
{inputs.webhook_debug_url}
</span>
</span>
</button>
)}
/>
<TooltipContent
placement="top"
className="rounded-md border border-components-panel-border bg-components-tooltip-bg px-1.5 py-1 system-xs-regular text-text-primary shadow-lg backdrop-blur-xs"
>
<div className="mt-0.5 w-0.5 bg-divider-regular" style={{ height: '28px' }}></div>
<div className="flex-1" style={{ width: '352px', height: '32px' }}>
<div className="text-xs leading-4 text-text-tertiary">
{t(`${i18nPrefix}.debugUrlTitle`, { ns: 'workflow' })}
</div>
<div className="truncate text-xs leading-4 text-text-primary">
{inputs.webhook_debug_url}
</div>
</div>
</div>
{debugUrlCopied ? t(`${i18nPrefix}.debugUrlCopied`, { ns: 'workflow' }) : t(`${i18nPrefix}.debugUrlCopy`, { ns: 'workflow' })}
</TooltipContent>
</Tooltip>
{isPrivateOrLocalAddress(inputs.webhook_debug_url) && (
<div className="mt-1 px-0 py-[2px] system-xs-regular text-text-warning">

View File

@ -7,8 +7,8 @@ import * as React from 'react'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { v4 as uuid4 } from 'uuid'
import { Infotip } from '@/app/components/base/infotip'
import Input from '@/app/components/base/input'
import Tooltip from '@/app/components/base/tooltip'
import { useWorkflowStore } from '@/app/components/workflow/store'
import { checkKeys, replaceSpaceWithUnderscoreInVarNameInput } from '@/utils/var'
@ -129,14 +129,14 @@ const VariableModal = ({
onClick={() => setType('secret')}
>
<span>Secret</span>
<Tooltip
popupContent={(
<div className="w-[240px]">
{t('env.modal.secretTip', { ns: 'workflow' })}
</div>
)}
triggerClassName="ml-0.5 w-3.5 h-3.5"
/>
<Infotip
aria-label={t('env.modal.secretTip', { ns: 'workflow' })}
className="ml-0.5 h-3.5 w-3.5"
iconClassName="h-3.5 w-3.5"
popupClassName="w-[240px]"
>
{t('env.modal.secretTip', { ns: 'workflow' })}
</Infotip>
</div>
</div>
</div>

View File

@ -4,12 +4,12 @@ import type { Node } from 'reactflow'
import type { ScheduleTriggerNodeType } from '@/app/components/workflow/nodes/trigger-schedule/types'
import type { WebhookTriggerNodeType } from '@/app/components/workflow/nodes/trigger-webhook/types'
import { Button } from '@langgenius/dify-ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import copy from 'copy-to-clipboard'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useStoreApi } from 'reactflow'
import { StopCircle } from '@/app/components/base/icons/src/vender/line/mediaAndDevices'
import Tooltip from '@/app/components/base/tooltip'
import BlockIcon from '@/app/components/workflow/block-icon'
import { useGetToolIcon } from '@/app/components/workflow/hooks/use-tool-icon'
import { getNextExecutionTime } from '@/app/components/workflow/nodes/trigger-schedule/utils/execution-time-calculator'
@ -179,28 +179,32 @@ const Listening: FC<ListeningProps> = ({
<div className="shrink-0 system-xs-regular whitespace-pre-line text-text-tertiary">
{t('nodes.triggerWebhook.debugUrlTitle', { ns: 'workflow' })}
</div>
<Tooltip
popupContent={debugUrlCopied
? t('nodes.triggerWebhook.debugUrlCopied', { ns: 'workflow' })
: t('nodes.triggerWebhook.debugUrlCopy', { ns: 'workflow' })}
popupClassName="system-xs-regular text-text-primary bg-components-tooltip-bg border border-components-panel-border shadow-lg backdrop-blur-xs rounded-md px-1.5 py-1"
position="top"
offset={{ mainAxis: -4 }}
needsDelay={true}
>
<button
type="button"
aria-label={t('nodes.triggerWebhook.debugUrlCopy', { ns: 'workflow' }) || ''}
className={`inline-flex items-center rounded-md border border-divider-regular bg-components-badge-white-to-dark px-1.5 py-[2px] font-mono text-[13px] leading-[18px] text-text-secondary transition-colors hover:bg-components-panel-on-panel-item-bg-hover focus:outline-hidden focus-visible:outline-2 focus-visible:outline-components-panel-border focus-visible:outline-solid ${debugUrlCopied ? 'bg-components-panel-on-panel-item-bg-hover text-text-primary' : ''}`}
onClick={() => {
copy(webhookDebugUrl)
setDebugUrlCopied(true)
}}
<Tooltip>
<TooltipTrigger
render={(
<button
type="button"
aria-label={t('nodes.triggerWebhook.debugUrlCopy', { ns: 'workflow' }) || ''}
className={`inline-flex items-center rounded-md border border-divider-regular bg-components-badge-white-to-dark px-1.5 py-[2px] font-mono text-[13px] leading-[18px] text-text-secondary transition-colors hover:bg-components-panel-on-panel-item-bg-hover focus:outline-hidden focus-visible:outline-2 focus-visible:outline-components-panel-border focus-visible:outline-solid ${debugUrlCopied ? 'bg-components-panel-on-panel-item-bg-hover text-text-primary' : ''}`}
onClick={() => {
copy(webhookDebugUrl)
setDebugUrlCopied(true)
}}
>
<span className="whitespace-nowrap text-text-primary">
{webhookDebugUrl}
</span>
</button>
)}
/>
<TooltipContent
placement="top"
className="rounded-md border border-components-panel-border bg-components-tooltip-bg px-1.5 py-1 system-xs-regular text-text-primary shadow-lg backdrop-blur-xs"
>
<span className="whitespace-nowrap text-text-primary">
{webhookDebugUrl}
</span>
</button>
{debugUrlCopied
? t('nodes.triggerWebhook.debugUrlCopied', { ns: 'workflow' })
: t('nodes.triggerWebhook.debugUrlCopy', { ns: 'workflow' })}
</TooltipContent>
</Tooltip>
</div>
)}

View File

@ -6,12 +6,12 @@ import type {
NodeProps,
} from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import {
cloneElement,
memo,
} from 'react'
import { useTranslation } from 'react-i18next'
import Tooltip from '@/app/components/base/tooltip'
import BlockIcon from '@/app/components/workflow/block-icon'
import {
BlockEnum,
@ -91,19 +91,21 @@ const BaseCard = ({
</div>
{
data.type === BlockEnum.Iteration && (data as IterationNodeType).is_parallel && (
<Tooltip popupContent={(
<div className="w-[180px]">
<Popover>
<PopoverTrigger
openOnHover
aria-label={t('nodes.iteration.parallelModeEnableTitle', { ns: 'workflow' })}
className="ml-1 flex items-center justify-center rounded-[5px] border border-text-warning bg-transparent px-[5px] py-[3px] system-2xs-medium-uppercase text-text-warning"
>
{t('nodes.iteration.parallelModeUpper', { ns: 'workflow' })}
</PopoverTrigger>
<PopoverContent popupClassName="w-[180px] px-3 py-2 system-xs-regular text-text-tertiary">
<div className="font-extrabold">
{t('nodes.iteration.parallelModeEnableTitle', { ns: 'workflow' })}
</div>
{t('nodes.iteration.parallelModeEnableDesc', { ns: 'workflow' })}
</div>
)}
>
<div className="ml-1 flex items-center justify-center rounded-[5px] border border-text-warning px-[5px] py-[3px] system-2xs-medium-uppercase text-text-warning">
{t('nodes.iteration.parallelModeUpper', { ns: 'workflow' })}
</div>
</Tooltip>
</PopoverContent>
</Popover>
)
}
</div>

View File

@ -1,8 +1,8 @@
import type { NodeProps } from 'reactflow'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { RiHome5Fill } from '@remixicon/react'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import Tooltip from '@/app/components/base/tooltip'
import { NodeSourceHandle } from '../../node-handle'
const IterationStartNode = ({ id, data }: NodeProps) => {
@ -10,10 +10,14 @@ const IterationStartNode = ({ id, data }: NodeProps) => {
return (
<div className="nodrag group mt-1 flex h-11 w-11 items-center justify-center rounded-2xl border border-workflow-block-border bg-workflow-block-bg shadow-xs">
<Tooltip popupContent={t('blocks.iteration-start', { ns: 'workflow' })} asChild={false}>
<div className="flex h-6 w-6 items-center justify-center rounded-full border-[0.5px] border-components-panel-border-subtle bg-util-colors-blue-brand-blue-brand-500">
<Tooltip>
<TooltipTrigger
aria-label={t('blocks.iteration-start', { ns: 'workflow' })}
className="flex h-6 w-6 items-center justify-center rounded-full border-[0.5px] border-components-panel-border-subtle bg-util-colors-blue-brand-blue-brand-500 p-0"
>
<RiHome5Fill className="h-3 w-3 text-text-primary-on-surface" />
</div>
</TooltipTrigger>
<TooltipContent>{t('blocks.iteration-start', { ns: 'workflow' })}</TooltipContent>
</Tooltip>
<NodeSourceHandle
id={id}

View File

@ -1,8 +1,8 @@
import type { NodeProps } from 'reactflow'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { RiHome5Fill } from '@remixicon/react'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import Tooltip from '@/app/components/base/tooltip'
import { NodeSourceHandle } from '../../node-handle'
const LoopStartNode = ({ id, data }: NodeProps) => {
@ -10,10 +10,14 @@ const LoopStartNode = ({ id, data }: NodeProps) => {
return (
<div className="nodrag group mt-1 flex h-11 w-11 items-center justify-center rounded-2xl border border-workflow-block-border bg-workflow-block-bg">
<Tooltip popupContent={t('blocks.loop-start', { ns: 'workflow' })} asChild={false}>
<div className="flex h-6 w-6 items-center justify-center rounded-full border-[0.5px] border-components-panel-border-subtle bg-util-colors-blue-brand-blue-brand-500">
<Tooltip>
<TooltipTrigger
aria-label={t('blocks.loop-start', { ns: 'workflow' })}
className="flex h-6 w-6 items-center justify-center rounded-full border-[0.5px] border-components-panel-border-subtle bg-util-colors-blue-brand-blue-brand-500 p-0"
>
<RiHome5Fill className="h-3 w-3 text-text-primary-on-surface" />
</div>
</TooltipTrigger>
<TooltipContent>{t('blocks.loop-start', { ns: 'workflow' })}</TooltipContent>
</Tooltip>
<NodeSourceHandle
id={id}

View File

@ -7,7 +7,6 @@ This document tracks the Dify-web migration away from legacy overlay APIs.
## Scope
- Deprecated imports:
- `@/app/components/base/tooltip`
- `@/app/components/base/modal`
- `@/app/components/base/dialog`
- `@/app/components/base/drawer`
@ -36,6 +35,8 @@ This document tracks the Dify-web migration away from legacy overlay APIs.
1. Business/UI features outside `app/components/base/**`
- Migrate old calls to semantic primitives from `@langgenius/dify-ui/*`.
- Keep deprecated imports out of newly touched files.
- Use `@langgenius/dify-ui/tooltip` only for short, non-interactive labels where the trigger already has its own accessible name.
- Use `@langgenius/dify-ui/popover` or the web `Infotip` wrapper for explanatory, long-form, structured, or interactive content.
1. Legacy base components
- Migrate legacy base callers gradually.
- Keep deprecated imports out of newly touched files.
@ -75,6 +76,9 @@ back to `z-9999`.
parent legacy overlay should be migrated instead.
- When migrating a legacy overlay that has a high z-index, remove the z-index
entirely — the new primitive's default `z-1002` handles it.
- When using Base UI trigger `render`, render a real `button` for button-like
triggers. If the trigger must render a non-button element, the primitive must
explicitly opt out of the native button behavior where that API is available.
### Post-migration cleanup

View File

@ -45,13 +45,6 @@ export const WEB_RESTRICTED_IMPORT_PATTERNS = [
]
export const OVERLAY_RESTRICTED_IMPORT_PATTERNS = [
{
group: [
'**/base/tooltip',
'**/base/tooltip/index',
],
message: 'Deprecated: use @langgenius/dify-ui/tooltip instead. See issue #32767.',
},
{
group: [
'**/base/modal',