chore(web): enforce tooltip styling with shadcn lint (#42667)

This commit is contained in:
yyh authored and GitHub committed 2026-09-21 11:45:05 +00:00
1 parent e811c23d37
commit fb2dba0cf7
29 files changed
+409 -151

No files matched your search

+26
View File
@@ -6,6 +6,27 @@ import path from 'node:path'
const rootDir = import.meta.dirname
const difyUiPackageJson = path.resolve(rootDir, 'packages/dify-ui/package.json')
const enableTailwindCanonicalClasses = process.env.TAILWIND_CANONICAL_CLASSES === 'true'
export const tooltipContentRules = {
'shadcn/no-restyle': [
'error',
{
componentImports: ['^@langgenius/dify-ui/tooltip$'],
allow: ['*'],
contracts: [
{
pattern: '^TooltipContent$',
allow: ['flex', 'items-center', 'gap-1', 'w-*', 'max-w-*'],
// `flex` also names a shadcn class group; allow display flex without flex sizing.
deny: ['flex-*'],
message:
'"{{className}}" is outside the TooltipContent styling contract. Use the default surface, spacing, and typography from packages/dify-ui/src/tooltip/index.tsx; callers may compose a label/shortcut row or constrain width.',
},
],
},
],
} satisfies NonNullable<OxlintConfig['rules']>
const tailwindCanonicalClassesOverride = {
files: [
'web/**/*.{js,cjs,mjs,jsx,ts,cts,mts,tsx}',
@@ -174,6 +195,7 @@ export const lintConfig = {
// Keep JavaScript plugins ordered by rule namespace. The `-js` aliases distinguish
// plugins that supplement an active native Oxlint plugin with the same namespace.
jsPlugins: [
'@shadcn/lint',
'@tanstack/eslint-plugin-query',
'eslint-plugin-antfu',
...(enableTailwindCanonicalClasses ? ['eslint-plugin-better-tailwindcss'] : []),
@@ -808,6 +830,10 @@ export const lintConfig = {
files: ['web/**/*.{jsx,tsx}', 'packages/dify-ui/**/*.{jsx,tsx}'],
rules: jsxA11yRules,
},
{
files: ['web/**/*.{jsx,tsx}'],
rules: tooltipContentRules,
},
{
files: ['web/**/*.stories.{js,cjs,mjs,jsx,ts,tsx}', 'web/**/*.story.{js,cjs,mjs,jsx,ts,tsx}'],
rules: {
+1
View File
@@ -25,6 +25,7 @@
"@eslint/markdown": "catalog:",
"@iconify-json/heroicons": "catalog:",
"@iconify-json/ri": "catalog:",
"@shadcn/lint": "catalog:",
"@tanstack/eslint-plugin-query": "catalog:",
"@types/node": "catalog:",
"@typescript-eslint/parser": "catalog:",
+43
View File
@@ -266,6 +266,9 @@ catalogs:
'@sentry/react':
specifier: 10.75.0
version: 10.75.0
'@shadcn/lint':
specifier: 0.1.3
version: 0.1.3
'@storybook/addon-a11y':
specifier: 10.6.0
version: 10.6.0
@@ -828,6 +831,9 @@ importers:
'@iconify-json/ri':
specifier: 'catalog:'
version: 1.2.10
'@shadcn/lint':
specifier: 'catalog:'
version: 0.1.3(@typescript-eslint/parser@8.70.0(@typescript/typescript6@6.0.2)(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0))(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))
'@tanstack/eslint-plugin-query':
specifier: 'catalog:'
version: 5.103.1(@typescript/typescript6@6.0.2)(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0)
@@ -2482,6 +2488,10 @@ packages:
resolution: {integrity: sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
'@eslint/core@0.17.0':
resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/core@1.2.1':
resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
@@ -4224,6 +4234,18 @@ packages:
resolution: {integrity: sha512-p4xxEo/ayiMZG9tygr09YjqYAd52uq8Qi0EVoRRgUe70nQda0HVPVW519JLq0G+Th/5O5sKME9y1layLU3xlPg==}
engines: {node: '>=18'}
'@shadcn/lint@0.1.3':
resolution: {integrity: sha512-eRUt1NfIrmkqWq4CJxvHWNq2O6UH0qIpiFemgJmRucUx+qxisqhBJzsZbPH31x1UU7gczTB7T6p/tQt/cAQgiw==}
engines: {node: '>=20.19'}
peerDependencies:
'@typescript-eslint/parser': '>=8.40.0'
eslint: '>=9.30.0'
peerDependenciesMeta:
'@typescript-eslint/parser':
optional: true
eslint:
optional: true
'@shikijs/core@4.4.3':
resolution: {integrity: sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg==}
engines: {node: '>=20'}
@@ -5877,6 +5899,11 @@ packages:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
cn@0.2.6:
resolution: {integrity: sha512-+i4L0zUGgRcEnhsxueVrP7iBGxBx5iD0WOTYg1MwFEu2ZyCmH5Ov2V1cul2Ht5UchRQQgftCf4be/RxspuW6QQ==}
engines: {node: '>=20'}
hasBin: true
cn@0.3.0:
resolution: {integrity: sha512-qRoU0ARrZD3Bbj1wKFftEKxPjSnzTEdFkf22NVhW+zU9D/wajhoP+qUZ5DChhhSOBCH1onV12hrmGqfu1+5Z2Q==}
engines: {node: '>=20'}
@@ -10420,6 +10447,10 @@ snapshots:
dependencies:
'@eslint/core': 1.2.1
'@eslint/core@0.17.0':
dependencies:
'@types/json-schema': 7.0.15
'@eslint/core@1.2.1':
dependencies:
'@types/json-schema': 7.0.15
@@ -11782,6 +11813,15 @@ snapshots:
'@sentry/browser-utils': 10.75.0
'@sentry/core': 10.75.0
'@shadcn/lint@0.1.3(@typescript-eslint/parser@8.70.0(@typescript/typescript6@6.0.2)(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0))(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))':
dependencies:
'@eslint/core': 0.17.0
cn: 0.2.6
optionalDependencies:
'@typescript-eslint/parser': 8.70.0(@typescript/typescript6@6.0.2)(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0)
eslint: 10.10.0(jiti@2.7.0)(supports-color@11.0.0)
oxc-parser: 0.148.0
'@shikijs/core@4.4.3':
dependencies:
'@shikijs/primitive': 4.4.3
@@ -13481,6 +13521,8 @@ snapshots:
clsx@2.1.1: {}
cn@0.2.6: {}
cn@0.3.0: {}
code-inspector-plugin@1.6.6(supports-color@11.0.0):
@@ -18251,6 +18293,7 @@ time:
'@remixicon/react@4.9.0': '2026-01-29T10:53:18.993Z'
'@rgrove/parse-xml@5.0.0': '2026-08-30T03:19:42.049Z'
'@sentry/react@10.75.0': '2026-09-16T14:58:08.681Z'
'@shadcn/lint@0.1.3': '2026-09-20T18:26:44.252Z'
'@storybook/addon-a11y@10.6.0': '2026-09-02T13:57:15.272Z'
'@storybook/addon-docs@10.6.0': '2026-09-02T13:56:43.113Z'
'@storybook/addon-links@10.6.0': '2026-09-02T13:57:52.366Z'
+1
View File
@@ -96,6 +96,7 @@ catalog:
'@remixicon/react': 4.9.0
'@rgrove/parse-xml': 5.0.0
'@sentry/react': 10.75.0
'@shadcn/lint': 0.1.3
'@storybook/addon-a11y': 10.6.0
'@storybook/addon-docs': 10.6.0
'@storybook/addon-links': 10.6.0
@@ -652,12 +652,19 @@ describe('PriorityLabel Integration', () => {
setupConsoleState()
})
it('should display "standard" priority for sandbox plan', () => {
it('opens the standard priority explanation without changing the badge', async () => {
const user = userEvent.setup()
setupBilling({ billing: { subscription: { plan: 'sandbox' } } })
render(<PriorityLabel />)
expect(screen.getByText(/plansCommon\.priority\.standard/i)).toBeInTheDocument()
await user.click(
screen.getByRole('button', { name: /plansCommon\.documentProcessingPriority$/i }),
)
expect(await screen.findByRole('dialog')).toHaveTextContent(
/plansCommon\.documentProcessingPriorityTip/i,
)
})
it('should display "priority" for professional plan with icon', () => {
+4 -4
View File
@@ -898,7 +898,7 @@ const ConversationList: FC<IConversationList> = ({ logs, appDetail, onRefresh })
annotation?: LogAnnotation,
) => {
return (
<Tooltip>
<Tooltip disabled={!isHighlight || isChatMode}>
<TooltipTrigger
render={
<div
@@ -912,9 +912,9 @@ const ConversationList: FC<IConversationList> = ({ logs, appDetail, onRefresh })
</div>
}
/>
<TooltipContent className={isHighlight && !isChatMode ? '' : 'hidden!'}>
<span className="inline-flex items-center text-xs text-text-tertiary">
<RiEditFill className="mr-1 size-3" />
<TooltipContent className="flex items-center gap-1">
<RiEditFill aria-hidden className="size-3 shrink-0" />
<span>
{`${t(($) => $['detail.annotationTip'], { ns: 'appLog', user: annotation?.account?.name })} ${formatTime(annotation?.created_at || dayjs().unix(), 'MM-DD hh:mm A')}`}
</span>
</TooltipContent>
@@ -24,11 +24,7 @@ const ProgressTooltip: FC<ProgressTooltipProps> = ({ data }) => {
</div>
{data}
</TooltipTrigger>
<TooltipContent
data-testid="progress-tooltip-popup"
placement="top-start"
className="p-3 text-text-quaternary"
>
<TooltipContent data-testid="progress-tooltip-popup" placement="top-start">
{t(($) => $['chat.citation.hitScore'], { ns: 'common' })} {data}
</TooltipContent>
</Tooltip>
@@ -18,11 +18,7 @@ const CitationTooltip: FC<CitationTooltipProps> = ({ data, text, icon }) => {
{icon}
{data}
</TooltipTrigger>
<TooltipContent
data-testid="tooltip-popup"
placement="top-start"
className="p-3 text-text-quaternary"
>
<TooltipContent data-testid="tooltip-popup" placement="top-start">
{text} {data}
</TooltipContent>
</Tooltip>
@@ -1,9 +1,9 @@
import { cn } from '@langgenius/dify-ui/cn'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { RiAedFill } from '@remixicon/react'
import { useQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useTranslation } from 'react-i18next'
import { Infotip } from '@/app/components/base/infotip'
import { deploymentEditionAtom } from '@/features/system-features/state'
import { consoleQuery } from '@/service/console'
@@ -26,32 +26,27 @@ const PriorityLabel = ({ className }: PriorityLabelProps) => {
const label = priority[plan]
return (
<Tooltip>
<TooltipTrigger
render={
<div
className={cn(
'ml-1 inline-flex h-4.5 shrink-0 items-center rounded-[5px] border border-text-accent-secondary bg-components-badge-bg-dimm px-1.25 system-2xs-medium text-text-accent-secondary',
className,
)}
/>
}
<div className={cn('ml-1 inline-flex shrink-0 items-center gap-1', className)}>
<span className="inline-flex h-4.5 items-center rounded-[5px] border border-text-accent-secondary bg-components-badge-bg-dimm px-1.25 system-2xs-medium text-text-accent-secondary">
{(plan === 'professional' || plan === 'team') && (
<RiAedFill aria-hidden className="mr-0.5 size-3" />
)}
{t(($) => $[`plansCommon.priority.${label}`], { ns: 'billing' })}
</span>
<Infotip
aria-label={t(($) => $['plansCommon.documentProcessingPriority'], { ns: 'billing' })}
>
{(plan === 'professional' || plan === 'team') && <RiAedFill className="mr-0.5 size-3" />}
<span>{t(($) => $[`plansCommon.priority.${label}`], { ns: 'billing' })}</span>
</TooltipTrigger>
<TooltipContent>
<div className="mb-1 text-xs font-semibold text-text-primary">
<div className="font-semibold text-text-primary">
{t(($) => $['plansCommon.documentProcessingPriority'], { ns: 'billing' })}:{' '}
{t(($) => $[`plansCommon.priority.${label}`], { ns: 'billing' })}
</div>
{label !== 'top-priority' && (
<div className="text-xs text-text-secondary">
<div className="mt-1">
{t(($) => $['plansCommon.documentProcessingPriorityTip'], { ns: 'billing' })}
</div>
)}
</TooltipContent>
</Tooltip>
</Infotip>
</div>
)
}
@@ -1,5 +1,6 @@
import type { IndexingStatusResponse } from '@/models/datasets'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { DataSourceType } from '@/models/datasets'
import IndexingProgressItem from '../indexing-progress-item'
@@ -85,7 +86,8 @@ describe('IndexingProgressItem', () => {
expect(screen.queryByText(/%/)).not.toBeInTheDocument()
})
it('should render error icon with tooltip for error status', () => {
it('opens the full indexing error from the warning button', async () => {
const user = userEvent.setup()
render(
<IndexingProgressItem
detail={makeDetail({ indexing_status: 'error', error: 'Parse failed' })}
@@ -93,10 +95,15 @@ describe('IndexingProgressItem', () => {
/>,
)
expect(screen.getByText('Parse failed')).toBeInTheDocument()
expect(screen.queryByText('Parse failed')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'common.error' }))
expect(await screen.findByRole('dialog')).toHaveTextContent('Parse failed')
await user.keyboard('{Escape}')
expect(screen.getByRole('button', { name: 'common.error' })).toHaveFocus()
})
it('should use the localized fallback when an error has no message', () => {
it('should use the localized fallback when an error has no message', async () => {
const user = userEvent.setup()
render(
<IndexingProgressItem
detail={makeDetail({ indexing_status: 'error', error: null })}
@@ -104,6 +111,7 @@ describe('IndexingProgressItem', () => {
/>,
)
expect(screen.getByText('common.error')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'common.error' }))
expect(await screen.findByRole('dialog')).toHaveTextContent('common.error')
})
})
@@ -1,9 +1,9 @@
import type { FC } from 'react'
import type { IndexingStatusResponse } from '@/models/datasets'
import { cn } from '@langgenius/dify-ui/cn'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { RiCheckboxCircleFill, RiErrorWarningFill } from '@remixicon/react'
import { RiCheckboxCircleFill } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { Infotip } from '@/app/components/base/infotip'
import NotionIcon from '@/app/components/base/notion-icon'
import PriorityLabel from '@/app/components/billing/priority-label'
import { DataSourceType } from '@/models/datasets'
@@ -28,15 +28,16 @@ const StatusIcon: FC<{ status: string; error?: string }> = ({ status, error }) =
const errorLabel = error || t(($) => $.error, { ns: 'common' })
return (
<Tooltip>
<TooltipTrigger render={<span />}>
<RiErrorWarningFill aria-hidden className="size-4 shrink-0 text-text-destructive" />
<span className="sr-only">{errorLabel}</span>
</TooltipTrigger>
<TooltipContent sideOffset={4} className="max-w-60 rounded-xl px-4 py-3.5 body-xs-regular">
{errorLabel}
</TooltipContent>
</Tooltip>
<Infotip
aria-label={t(($) => $.error, { ns: 'common' })}
iconVariant="warning"
iconSize="large"
className="text-text-destructive"
popupClassName="wrap-anywhere whitespace-pre-wrap"
sideOffset={4}
>
{errorLabel}
</Infotip>
)
}
@@ -1,6 +1,7 @@
import type { CloudPlan } from '@dify/contracts/api/console/features/types.gen'
import type { GetWorkflowRunArchivesResponse } from '@dify/contracts/api/console/workflow-run-archives/types.gen'
import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { consoleQuery } from '@/service/console'
@@ -219,6 +220,38 @@ describe('WorkflowLogArchivesPage', () => {
)
})
it('opens failure details independently of the retry action', async () => {
const user = userEvent.setup()
renderPage({
...archiveData,
months: [
{
...archiveMonth,
download_task: {
download_id: 'failed-export',
status: 'failed',
error: 'Storage unavailable',
year: 2025,
month: 3,
archive_bytes: 1048576,
bundle_count: 2,
created_at: '2025-03-03T00:00:00Z',
updated_at: '2025-03-03T00:00:00Z',
expires_at: '2025-03-04T00:00:00Z',
},
},
],
})
const retry = screen.getByRole('button', { name: 'common.operation.retry 2025-03' })
expect(retry).toBeEnabled()
await user.click(screen.getByRole('button', { name: 'common.operation.learnMore: 2025-03' }))
expect(await screen.findByRole('dialog')).toHaveTextContent(
'appLog.archives.downloadHint.failedWithReason',
)
expect(retry).toBeEnabled()
})
it('should show archive content for paid workspaces', () => {
// Arrange
plan = 'professional'
@@ -7,11 +7,11 @@ import type {
} from '@dify/contracts/api/console/workflow-run-archives/types.gen'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { skipToken, useMutation, useQuery, useSuspenseQuery } from '@tanstack/react-query'
import { useQueryState } from 'nuqs'
import { useEffect, useId, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Infotip } from '@/app/components/base/infotip'
import { SkeletonRectangle } from '@/app/components/base/skeleton'
import {
pricingQueryParamName,
@@ -408,34 +408,28 @@ function WorkflowArchiveMonthRow({ archive }: { archive: WorkflowRunArchiveMonth
{formatBytes(archive.archive_bytes)}
</td>
<td className="px-2 py-3 text-center">
<Tooltip>
<TooltipTrigger
render={
<Button
size="small"
variant="secondary"
loading={isPreparing}
className="px-2"
aria-labelledby={`${downloadActionLabelId} ${archiveMonthLabelId}`}
onClick={onAction}
>
{!isPreparing && (
<span className={cn(buttonIconClassName, 'size-3.5')} aria-hidden="true" />
)}
<span id={downloadActionLabelId}>{buttonContent}</span>
</Button>
}
/>
<TooltipContent
placement="top"
className={cn(
'max-w-65 text-center text-text-tertiary',
isFailed && 'max-w-75 text-start wrap-anywhere whitespace-pre-wrap',
<div className="flex items-center justify-center gap-1">
<Button
size="small"
variant="secondary"
loading={isPreparing}
className="px-2"
aria-labelledby={`${downloadActionLabelId} ${archiveMonthLabelId}`}
onClick={onAction}
>
{!isPreparing && (
<span className={cn(buttonIconClassName, 'size-3.5')} aria-hidden="true" />
)}
<span id={downloadActionLabelId}>{buttonContent}</span>
</Button>
<Infotip
aria-label={`${t(($) => $['operation.learnMore'], { ns: 'common' })}: ${archiveMonth}`}
iconVariant={isFailed ? 'warning' : 'information'}
popupClassName="wrap-anywhere whitespace-pre-wrap"
>
{downloadHint}
</TooltipContent>
</Tooltip>
</Infotip>
</div>
</td>
</tr>
)
@@ -30,8 +30,6 @@ type PermissionTooltipWrapperProps = {
show: boolean
}
const permissionTooltipClassName = 'w-[112px] text-left'
function PermissionTooltipWrapper({
children,
className,
@@ -51,7 +49,7 @@ function PermissionTooltipWrapper({
return (
<Tooltip>
<TooltipTrigger render={trigger} />
<TooltipContent placement={placement} sideOffset={8} className={permissionTooltipClassName}>
<TooltipContent placement={placement} sideOffset={8}>
{content}
</TooltipContent>
</Tooltip>
@@ -79,7 +79,7 @@ function TabHeaderItem({
return (
<Tooltip>
<TooltipTrigger render={tabElement} />
<TooltipContent placement="top" className="max-w-57.5 rounded-xl px-4 py-3.5">
<TooltipContent placement="top" className="max-w-57.5">
{tab.disabledTip || fallbackDisabledTip}
</TooltipContent>
</Tooltip>
@@ -476,7 +476,7 @@ export const CommentThread: FC<CommentThreadProps> = memo(
</button>
}
/>
<TooltipContent placement="top" className="px-2">
<TooltipContent placement="top">
{t(($) => $['comments.aria.deleteComment'], { ns: 'workflow' })}
</TooltipContent>
</Tooltip>
@@ -500,7 +500,7 @@ export const CommentThread: FC<CommentThreadProps> = memo(
</button>
}
/>
<TooltipContent placement="top" className="px-2">
<TooltipContent placement="top">
{t(($) => $['comments.aria.resolveComment'], { ns: 'workflow' })}
</TooltipContent>
</Tooltip>
@@ -521,7 +521,7 @@ export const CommentThread: FC<CommentThreadProps> = memo(
</button>
}
/>
<TooltipContent placement="top" className="px-2">
<TooltipContent placement="top">
{t(($) => $['comments.aria.previousComment'], { ns: 'workflow' })}
</TooltipContent>
</Tooltip>
@@ -541,7 +541,7 @@ export const CommentThread: FC<CommentThreadProps> = memo(
</button>
}
/>
<TooltipContent placement="top" className="px-2">
<TooltipContent placement="top">
{t(($) => $['comments.aria.nextComment'], { ns: 'workflow' })}
</TooltipContent>
</Tooltip>
@@ -158,7 +158,7 @@ const OnlineUsers = () => {
)
}
/>
<TooltipContent placement="bottom" sideOffset={4} className="max-w-55 px-3">
<TooltipContent placement="bottom" sideOffset={4} className="max-w-55">
{displayName}
{isCurrentUser && (
<span className="ml-1 text-text-quaternary">{currentUserSuffix}</span>
@@ -61,7 +61,9 @@ describe('node sections', () => {
)
expect(screen.getByText('nodes.iteration.parallelModeUpper')).toBeInTheDocument()
await user.hover(screen.getByText('nodes.iteration.parallelModeUpper'))
await user.click(
screen.getByRole('button', { name: 'nodes.iteration.parallelModeEnableTitle' }),
)
expect(await screen.findByText('nodes.iteration.parallelModeEnableTitle')).toBeInTheDocument()
expect(screen.getByText('nodes.iteration.parallelModeEnableDesc')).toBeInTheDocument()
expect(screen.getByText('3/3')).toBeInTheDocument()
@@ -180,8 +180,9 @@ describe('BaseNode', () => {
</BaseNode>,
)
expect(screen.getByText('workflow.nodes.iteration.parallelModeUpper')).toBeInTheDocument()
const parallelButton = screen.getByRole('button', {
name: /workflow\.nodes\.iteration\.parallelModeUpper/,
name: /workflow\.nodes\.iteration\.parallelModeEnableTitle/,
})
expect(parallelButton).toBeInTheDocument()
@@ -310,7 +310,9 @@ describe('AgentStrategySelector', () => {
/>,
)
await user.hover(document.querySelector('[data-base-ui-tooltip-trigger]')!)
await user.click(
screen.getByRole('button', { name: /(?:^|\.)nodes\.agent\.pluginNotInstalled(?=$|:)/ }),
)
expect(
await screen.findByText(/(?:^|\.)nodes\.agent\.pluginNotInstalled(?=$|:)/),
@@ -318,6 +320,26 @@ describe('AgentStrategySelector', () => {
expect(
screen.getByText(/(?:^|\.)nodes\.agent\.pluginNotInstalledDesc(?=$|:)/),
).toBeInTheDocument()
expect(screen.getByRole('link', { name: /nodes\.agent\.linkToPlugin/ })).toHaveAttribute(
'href',
'/plugins',
)
expect(screen.getAllByRole('dialog')).toHaveLength(1)
for (const key of ['{Enter}', ' ']) {
await user.keyboard('{Escape}')
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(
screen.getByRole('button', { name: /(?:^|\.)nodes\.agent\.pluginNotInstalled(?=$|:)/ }),
).toHaveFocus()
await user.keyboard(key)
expect(await screen.findByRole('dialog')).toBeInTheDocument()
expect(screen.queryByTestId('tools-list')).not.toBeInTheDocument()
expect(screen.getByRole('link', { name: /nodes\.agent\.linkToPlugin/ })).toHaveAttribute(
'href',
'/plugins',
)
}
})
it('renders install and switch-version actions for marketplace strategies', async () => {
@@ -8,10 +8,10 @@ import type {
} from '@/app/components/workflow/block-selector/marketplace-plugin/list'
import { cn } from '@langgenius/dify-ui/cn'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useSuspenseQuery } from '@tanstack/react-query'
import { memo, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Infotip } from '@/app/components/base/infotip'
import { SearchInput } from '@/app/components/base/search-input'
import useGetIcon from '@/app/components/plugins/install-plugin/base/use-get-icon'
import { useMarketplacePlugins } from '@/app/components/plugins/marketplace/hooks'
@@ -30,34 +30,28 @@ import { SwitchPluginVersion } from './switch-plugin-version'
const DEFAULT_TAGS: ListProps['tags'] = []
const NotFoundWarn = (props: { title: ReactNode; description: ReactNode }) => {
const NotFoundWarn = (props: { title: string; description: ReactNode }) => {
const { title, description } = props
const { t } = useTranslation()
return (
<Tooltip>
<TooltipTrigger
render={
<div>
<span
className="i-ri-error-warning-fill size-4 text-text-destructive"
aria-hidden="true"
/>
</div>
}
/>
<TooltipContent className="w-45">
<div className="space-y-1 text-xs">
<h3 className="font-semibold text-text-primary">{title}</h3>
<p className="tracking-tight text-text-secondary">{description}</p>
<p>
<Link href="/plugins" className="tracking-tight text-text-accent">
{t(($) => $['nodes.agent.linkToPlugin'], { ns: 'workflow' })}
</Link>
</p>
</div>
</TooltipContent>
</Tooltip>
<Infotip
aria-label={title}
iconVariant="warning"
iconSize="large"
className="text-text-destructive"
popupClassName="w-45"
>
<div className="space-y-1">
<h3 className="font-semibold text-text-primary">{title}</h3>
<p>{description}</p>
<p>
<Link href="/plugins" className="text-text-accent">
{t(($) => $['nodes.agent.linkToPlugin'], { ns: 'workflow' })}
</Link>
</p>
</div>
</Infotip>
)
}
@@ -618,7 +618,7 @@ const BasePanel: FC<BasePanelProps> = ({ id, data, children }) => {
</IconButton>
}
/>
<TooltipContent className="mr-1">{runThisStepLabel}</TooltipContent>
<TooltipContent>{runThisStepLabel}</TooltipContent>
</Tooltip>
)}
<HelpLink nodeType={nodeMetaType} />
@@ -2,7 +2,7 @@ import type { SelectorParam } from 'i18next'
import type { ReactElement } from 'react'
import type { IterationNodeType } from '@/app/components/workflow/nodes/iteration/types'
import type { NodeProps } from '@/app/components/workflow/types'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { Infotip } from '@/app/components/base/infotip'
import { BlockEnum, NodeRunningStatus } from '@/app/components/workflow/types'
type HeaderMetaProps = {
@@ -22,19 +22,20 @@ export const NodeHeaderMeta = ({ data, hasVarValue, isLoading, loopIndex, t }: H
return (
<>
{data.type === BlockEnum.Iteration && (data as IterationNodeType).is_parallel && (
<Tooltip>
<TooltipTrigger>
<div className="ml-1 flex items-center justify-center rounded-[5px] border border-text-warning px-1.25 py-0.75 system-2xs-medium-uppercase text-text-warning">
{t(($) => $['nodes.iteration.parallelModeUpper'], { ns: 'workflow' })}
</div>
</TooltipTrigger>
<TooltipContent className="w-45">
<div className="font-extrabold">
<div className="ml-1 flex items-center gap-1">
<span className="rounded-[5px] border border-text-warning px-1.25 py-0.75 system-2xs-medium-uppercase text-text-warning">
{t(($) => $['nodes.iteration.parallelModeUpper'], { ns: 'workflow' })}
</span>
<Infotip
aria-label={t(($) => $['nodes.iteration.parallelModeEnableTitle'], { ns: 'workflow' })}
popupClassName="w-45"
>
<div className="font-semibold text-text-primary">
{t(($) => $['nodes.iteration.parallelModeEnableTitle'], { ns: 'workflow' })}
</div>
{t(($) => $['nodes.iteration.parallelModeEnableDesc'], { ns: 'workflow' })}
</TooltipContent>
</Tooltip>
</Infotip>
</div>
)}
{!!(
data._iterationLength &&
@@ -121,4 +121,26 @@ describe('llm/panel-output-section', () => {
await user.click(screen.getByRole('switch'))
expect(handleStructureOutputEnableChange).toHaveBeenCalledWith(false)
})
it('opens the model warning with the keyboard without changing structured output', async () => {
const user = userEvent.setup()
const onEnable = vi.fn()
render(
<PanelOutputSection
readOnly={false}
inputs={createInputs({ structured_output_enabled: true })}
isModelSupportStructuredOutput={false}
structuredOutputCollapsed={false}
setStructuredOutputCollapsed={vi.fn()}
handleStructureOutputEnableChange={onEnable}
handleStructureOutputChange={vi.fn()}
/>,
)
await user.tab()
expect(screen.getByRole('button', { name: 'app.structOutput.modelNotSupported' })).toHaveFocus()
await user.keyboard('{Enter}')
expect(await screen.findByRole('dialog')).toHaveTextContent(
'app.structOutput.modelNotSupportedTip',
)
expect(onEnable).not.toHaveBeenCalled()
})
})
@@ -1,7 +1,6 @@
import type { FC } from 'react'
import type { LLMNodeType, StructuredOutput } from '../types'
import { Switch } from '@langgenius/dify-ui/switch'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { Infotip } from '@/app/components/base/infotip'
@@ -41,23 +40,20 @@ const PanelOutputSection: FC<Props> = ({
operations={
<div className="mr-4 flex shrink-0 items-center">
{!isModelSupportStructuredOutput && !!inputs.structured_output_enabled && (
<Tooltip>
<TooltipTrigger
render={
<div>
<span className="mr-1 i-ri-alert-fill size-4 text-text-warning-secondary" />
</div>
}
/>
<TooltipContent className="w-58 rounded-xl px-4 py-3.5">
<div className="title-xs-semi-bold text-text-primary">
{t(($) => $['structOutput.modelNotSupported'], { ns: 'app' })}
</div>
<div className="mt-1 body-xs-regular text-text-secondary">
{t(($) => $['structOutput.modelNotSupportedTip'], { ns: 'app' })}
</div>
</TooltipContent>
</Tooltip>
<Infotip
aria-label={t(($) => $['structOutput.modelNotSupported'], { ns: 'app' })}
iconVariant="warning"
iconSize="large"
className="mr-1 text-text-warning-secondary"
popupClassName="w-58"
>
<div className="title-xs-semi-bold text-text-primary">
{t(($) => $['structOutput.modelNotSupported'], { ns: 'app' })}
</div>
<div className="mt-1">
{t(($) => $['structOutput.modelNotSupportedTip'], { ns: 'app' })}
</div>
</Infotip>
)}
<div className="mr-0.5 system-xs-medium-uppercase text-text-tertiary">
{t(($) => $['structOutput.structured'], { ns: 'app' })}
@@ -204,11 +204,7 @@ const Panel: FC<NodePanelProps<WebhookTriggerNodeType>> = ({ id, data }) => {
</button>
}
/>
<TooltipContent
placement="bottom"
sideOffset={4}
className="py-1 system-xs-regular text-text-primary"
>
<TooltipContent placement="bottom" sideOffset={4}>
{debugUrlCopied
? t(($) => $[`${i18nPrefix}.debugUrlCopied`], { ns: 'workflow' })
: t(($) => $[`${i18nPrefix}.debugUrlCopy`], { ns: 'workflow' })}
+1 -3
View File
@@ -229,9 +229,7 @@ const NodePanel: FC<Props> = ({
</Header>
}
/>
<TooltipContent>
<div className="max-w-xs">{nodeInfo.title}</div>
</TooltipContent>
<TooltipContent>{nodeInfo.title}</TooltipContent>
</Tooltip>
{!hideProcessDetail && (
<div id={detailsId} hidden={collapseState} className="px-1 pb-1">
@@ -202,7 +202,7 @@ const Listening: FC<ListeningProps> = ({ onStop, message }) => {
</button>
}
/>
<TooltipContent placement="top" className="py-1 system-xs-regular text-text-primary">
<TooltipContent placement="top">
{debugUrlCopied
? t(($) => $['nodes.triggerWebhook.debugUrlCopied'], { ns: 'workflow' })
: t(($) => $['nodes.triggerWebhook.debugUrlCopy'], { ns: 'workflow' })}
+127
View File
@@ -0,0 +1,127 @@
import { spawnSync } from 'node:child_process'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { expect, it } from 'vite-plus/test'
import { tooltipContentRules } from '../../../lint.config'
function lintFixture(source: string) {
const require = createRequire(import.meta.url)
const viteRequire = createRequire(require.resolve('vite-plus/package.json'))
const oxlintBin = join(dirname(dirname(viteRequire.resolve('oxlint'))), 'bin', 'oxlint')
const directory = mkdtempSync(join(tmpdir(), 'dify-tooltip-contract-'))
try {
writeFileSync(
join(directory, '.oxlintrc.json'),
JSON.stringify({
categories: { correctness: 'off' },
jsPlugins: [require.resolve('@shadcn/lint')],
rules: tooltipContentRules,
}),
)
writeFileSync(join(directory, 'fixture.tsx'), source)
const result = spawnSync(
process.execPath,
[oxlintBin, '--config', join(directory, '.oxlintrc.json'), '--format', 'json', 'fixture.tsx'],
{ cwd: directory, encoding: 'utf8' },
)
expect(result.error).toBeUndefined()
expect([0, 1], result.stderr + result.stdout).toContain(result.status)
return JSON.parse(result.stdout).diagnostics as Array<{ message: string }>
} finally {
rmSync(directory, { recursive: true, force: true })
}
}
it('allows shortcut row composition and width constraints without restricting other components', () => {
const diagnostics = lintFixture(`
import { TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { PopoverContent } from '@langgenius/dify-ui/popover'
import { Infotip } from '@/app/components/base/infotip'
const examples = <>
<TooltipContent className="flex items-center gap-1 max-w-60" />
<TooltipContent className="w-58 max-w-65" />
<TooltipContent className="flex items-center gap-1 max-w-none" />
<TooltipContent />
<TooltipTrigger className="border-0 bg-transparent" />
<PopoverContent className="shadow-none" />
<Infotip popupClassName="shadow-none" />
<div className="shadow-none" />
</>
`)
expect(diagnostics).toEqual([])
})
it('rejects surface overrides through aliases, variants, important modifiers, and arbitrary properties', () => {
const classes = [
'p-1.5',
'overflow-clip',
'text-start',
'wrap-break-word',
'transition-opacity',
'data-ending-style:opacity-0',
'origin-(--transform-origin)',
'shadow-[0px_12px_16px_-4px_var(--color-shadow-shadow-5),0px_4px_6px_-2px_var(--color-shadow-shadow-1)]',
'rounded-lg',
'system-xs-medium',
'text-text-secondary',
'bg-components-tooltip-bg',
'border-[0.5px]',
'border-components-panel-border',
'border-x-0',
'dark:shadow-none!',
'backdrop-blur-[5px]',
'[box-shadow:none]',
'[background:red]',
]
const diagnostics = lintFixture(`
import { TooltipContent as Hint } from '@langgenius/dify-ui/tooltip'
const example = <Hint className="${classes.join(' ')}" />
`)
expect(diagnostics).toHaveLength(classes.length)
for (const className of classes)
expect(diagnostics.some(({ message }) => message.includes(`"${className}"`))).toBe(true)
})
it('rejects caller typography, padding, alignment, visibility, and positioning policies', () => {
const classes = [
'rounded-xl',
'px-4',
'py-3.5',
'body-xs-regular',
'text-left',
'text-text-primary',
'mr-1',
'hidden!',
'z-50',
'flex-1',
'items-start',
'gap-2',
'truncate',
'whitespace-nowrap',
]
const diagnostics = lintFixture(`
import { TooltipContent } from '@langgenius/dify-ui/tooltip'
const example = <TooltipContent className="${classes.join(' ')}" />
`)
expect(diagnostics).toHaveLength(classes.length)
for (const className of classes)
expect(diagnostics.some(({ message }) => message.includes(`"${className}"`))).toBe(true)
})
it('checks readable class helpers, local values, and props spreads', () => {
const diagnostics = lintFixture(`
import { TooltipContent } from '@langgenius/dify-ui/tooltip'
import { cn } from '@langgenius/dify-ui/cn'
const classes = 'shadow-none'
const props = { className: 'border-0' }
const example = <>
<TooltipContent className={cn('max-w-60', classes)} />
<TooltipContent {...props} />
</>
`)
expect(diagnostics).toHaveLength(2)
expect(diagnostics.some(({ message }) => message.includes('"shadow-none"'))).toBe(true)
expect(diagnostics.some(({ message }) => message.includes('"border-0"'))).toBe(true)
})