refactor: stabilize selector preview cards (#36105)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
yyh 2026-05-13 14:16:26 +08:00 committed by GitHub
parent c34fc429ae
commit 03861bcee3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 609 additions and 307 deletions

View File

@ -2987,22 +2987,6 @@
"count": 1
}
},
"web/app/components/workflow/block-selector/featured-tools.tsx": {
"react/set-state-in-effect": {
"count": 2
},
"ts/no-explicit-any": {
"count": 2
}
},
"web/app/components/workflow/block-selector/featured-triggers.tsx": {
"react/set-state-in-effect": {
"count": 2
},
"ts/no-explicit-any": {
"count": 2
}
},
"web/app/components/workflow/block-selector/hooks.ts": {
"react/set-state-in-effect": {
"count": 1
@ -3038,29 +3022,6 @@
"count": 1
}
},
"web/app/components/workflow/block-selector/tool/tool-list-flat-view/list.tsx": {
"ts/no-explicit-any": {
"count": 1
}
},
"web/app/components/workflow/block-selector/tool/tool.tsx": {
"react/set-state-in-effect": {
"count": 2
}
},
"web/app/components/workflow/block-selector/trigger-plugin/action-item.tsx": {
"ts/no-explicit-any": {
"count": 1
}
},
"web/app/components/workflow/block-selector/trigger-plugin/item.tsx": {
"react/set-state-in-effect": {
"count": 2
},
"ts/no-explicit-any": {
"count": 1
}
},
"web/app/components/workflow/block-selector/types.ts": {
"erasable-syntax-only/enums": {
"count": 4

View File

@ -118,6 +118,23 @@ describe('ModelSelector', () => {
expect(triggerButton).toHaveAttribute('aria-expanded', 'false')
})
it('should use the default model settings popup width when the trigger is narrow', () => {
renderWithQueryClient(
<div className="w-[355px]">
<ModelSelector modelList={[makeModel()]} />
</div>,
)
fireEvent.click(screen.getByRole('combobox'))
expect(
Array.from(document.body.querySelectorAll('[class]')).some(element =>
element.className.includes('w-[432px]')
&& element.className.includes('max-w-[432px]'),
),
).toBe(true)
})
it('should not open popup when readonly', () => {
renderWithQueryClient(<ModelSelector modelList={[makeModel()]} readonly />)

View File

@ -1,6 +1,7 @@
import type { ReactElement, ReactNode } from 'react'
import type { DefaultModel, Model, ModelItem } from '../../declarations'
import { Combobox } from '@langgenius/dify-ui/combobox'
import { createPreviewCardHandle } from '@langgenius/dify-ui/preview-card'
import { fireEvent, render, screen } from '@testing-library/react'
import {
ConfigurationMethodEnum,
@ -106,6 +107,11 @@ const makeProvider = (overrides: Record<string, unknown> = {}) => ({
...overrides,
})
const previewCardProps = () => ({
previewCardHandle: createPreviewCardHandle(),
onPreviewCardClose: vi.fn(),
})
const createComboboxNode = (
node: ReactElement,
onValueChange = vi.fn(),
@ -152,7 +158,7 @@ describe('PopupItem', () => {
})
const { container } = renderWithCombobox(
<PopupItem model={makeModel()} onHide={vi.fn()} />,
<PopupItem {...previewCardProps()} model={makeModel()} onHide={vi.fn()} />,
)
expect(container.textContent).toBe('')
@ -160,7 +166,7 @@ describe('PopupItem', () => {
it('should select the combobox value when clicking an active model', () => {
const onValueChange = vi.fn()
renderWithCombobox(<PopupItem model={makeModel()} onHide={vi.fn()} />, onValueChange)
renderWithCombobox(<PopupItem {...previewCardProps()} model={makeModel()} onHide={vi.fn()} />, onValueChange)
fireEvent.click(screen.getByText('GPT-4'))
@ -170,10 +176,27 @@ describe('PopupItem', () => {
)
})
it('should close the shared preview before pressing an active model', () => {
const onPreviewCardClose = vi.fn()
renderWithCombobox(
<PopupItem
previewCardHandle={createPreviewCardHandle()}
onPreviewCardClose={onPreviewCardClose}
model={makeModel()}
onHide={vi.fn()}
/>,
)
fireEvent.pointerDown(screen.getByText('GPT-4'))
expect(onPreviewCardClose).toHaveBeenCalledTimes(1)
})
it('should not select the combobox value when model is not active', () => {
const onValueChange = vi.fn()
renderWithCombobox(
<PopupItem
{...previewCardProps()}
model={makeModel({ models: [makeModelItem({ status: ModelStatusEnum.disabled })] })}
onHide={vi.fn()}
/>,
@ -188,7 +211,7 @@ describe('PopupItem', () => {
it('should open model modal when clicking add on unconfigured model', () => {
const onValueChange = vi.fn()
const { rerender } = renderWithCombobox(
<PopupItem model={makeModel({ models: [makeModelItem({ status: ModelStatusEnum.noConfigure })] })} onHide={vi.fn()} />,
<PopupItem {...previewCardProps()} model={makeModel({ models: [makeModelItem({ status: ModelStatusEnum.noConfigure })] })} onHide={vi.fn()} />,
onValueChange,
)
@ -206,6 +229,7 @@ describe('PopupItem', () => {
rerender(createComboboxNode(
<PopupItem
{...previewCardProps()}
model={makeModel({
models: [makeModelItem({ status: ModelStatusEnum.noConfigure, model_type: undefined as unknown as ModelTypeEnum })],
})}
@ -225,6 +249,7 @@ describe('PopupItem', () => {
const defaultModel: DefaultModel = { provider: 'openai', model: 'gpt-4' }
renderWithCombobox(
<PopupItem
{...previewCardProps()}
defaultModel={defaultModel}
model={makeModel()}
onHide={vi.fn()}
@ -239,6 +264,7 @@ describe('PopupItem', () => {
renderWithCombobox(
<PopupItem
{...previewCardProps()}
model={makeModel({
label: { en_US: 'OpenAI only' } as Model['label'],
models: [makeModelItem({ label: { en_US: 'GPT-4 only' } as ModelItem['label'] })],
@ -252,7 +278,7 @@ describe('PopupItem', () => {
})
it('should toggle collapsed state when clicking provider header', () => {
renderWithCombobox(<PopupItem model={makeModel()} onHide={vi.fn()} />)
renderWithCombobox(<PopupItem {...previewCardProps()} model={makeModel()} onHide={vi.fn()} />)
expect(screen.getByText('GPT-4'))!.toBeInTheDocument()
@ -266,7 +292,7 @@ describe('PopupItem', () => {
})
it('should show credential name when using custom provider', () => {
renderWithCombobox(<PopupItem model={makeModel()} onHide={vi.fn()} />)
renderWithCombobox(<PopupItem {...previewCardProps()} model={makeModel()} onHide={vi.fn()} />)
expect(screen.getByText('my-api-key'))!.toBeInTheDocument()
})
@ -283,7 +309,7 @@ describe('PopupItem', () => {
credits: 200,
})
renderWithCombobox(<PopupItem model={makeModel()} onHide={vi.fn()} />)
renderWithCombobox(<PopupItem {...previewCardProps()} model={makeModel()} onHide={vi.fn()} />)
expect(screen.getByText('stale-key'))!.toBeInTheDocument()
expect(document.querySelector('.bg-components-badge-status-light-error-bg')).not.toBeNull()
@ -309,7 +335,7 @@ describe('PopupItem', () => {
credits: 0,
})
renderWithCombobox(<PopupItem model={makeModel()} onHide={vi.fn()} />)
renderWithCombobox(<PopupItem {...previewCardProps()} model={makeModel()} onHide={vi.fn()} />)
expect(screen.getByText(/modelProvider\.selector\.configureRequired/))!.toBeInTheDocument()
})
@ -331,7 +357,7 @@ describe('PopupItem', () => {
credits: 200,
})
renderWithCombobox(<PopupItem model={makeModel()} onHide={vi.fn()} />)
renderWithCombobox(<PopupItem {...previewCardProps()} model={makeModel()} onHide={vi.fn()} />)
expect(screen.getByText(/modelProvider\.selector\.aiCredits/))!.toBeInTheDocument()
})
@ -356,7 +382,7 @@ describe('PopupItem', () => {
credits: 0,
})
renderWithCombobox(<PopupItem model={makeModel()} onHide={vi.fn()} />)
renderWithCombobox(<PopupItem {...previewCardProps()} model={makeModel()} onHide={vi.fn()} />)
expect(screen.getByText(/modelProvider\.selector\.creditsExhausted/))!.toBeInTheDocument()
})
@ -364,7 +390,7 @@ describe('PopupItem', () => {
it('should close the dropdown through dropdown content callbacks', () => {
const onHide = vi.fn()
renderWithCombobox(<PopupItem model={makeModel()} onHide={onHide} />)
renderWithCombobox(<PopupItem {...previewCardProps()} model={makeModel()} onHide={onHide} />)
fireEvent.click(screen.getByRole('button', { name: /my-api-key/ }))
fireEvent.click(screen.getByRole('button', { name: 'close dropdown' }))

View File

@ -130,7 +130,7 @@ function ModelSelector({
<ComboboxContent
placement="bottom-start"
sideOffset={4}
popupClassName={cn('min-w-[320px] overflow-hidden rounded-xl', popupClassName)}
popupClassName={cn('w-[432px] max-w-[432px] overflow-hidden rounded-xl', popupClassName)}
>
<Popup
defaultModel={defaultModel}

View File

@ -1,32 +1,41 @@
import type { DefaultModel, Model } from '../declarations'
import type { ComponentProps } from 'react'
import type { DefaultModel, Model, ModelItem } from '../declarations'
import { cn } from '@langgenius/dify-ui/cn'
import { ComboboxGroup, ComboboxItem, ComboboxItemIndicator } from '@langgenius/dify-ui/combobox'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { PreviewCard, PreviewCardContent, PreviewCardTrigger } from '@langgenius/dify-ui/preview-card'
import { PreviewCardTrigger } from '@langgenius/dify-ui/preview-card'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { CreditsCoin } from '@/app/components/base/icons/src/vender/line/financeAndECommerce'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { ConfigurationMethodEnum, ModelFeatureEnum, ModelStatusEnum, ModelTypeEnum } from '../declarations'
import { ConfigurationMethodEnum, ModelStatusEnum } from '../declarations'
import { useLanguage, useUpdateModelList, useUpdateModelProviders } from '../hooks'
import ModelBadge from '../model-badge'
import ModelIcon from '../model-icon'
import ModelName from '../model-name'
import DropdownContent from '../provider-added-card/model-auth-dropdown/dropdown-content'
import { useChangeProviderPriority } from '../provider-added-card/use-change-provider-priority'
import { useCredentialPanelState } from '../provider-added-card/use-credential-panel-state'
import { modelTypeFormat, sizeFormat } from '../utils'
import FeatureIcon from './feature-icon'
export type ModelSelectorPreviewPayload = {
provider: Model
modelItem: ModelItem
}
type PreviewCardHandle = NonNullable<ComponentProps<typeof PreviewCardTrigger>['handle']>
type PopupItemProps = {
defaultModel?: DefaultModel
model: Model
previewCardHandle: PreviewCardHandle
onPreviewCardClose: () => void
onHide: () => void
}
function PopupItem({
defaultModel,
model,
previewCardHandle,
onPreviewCardClose,
onHide,
}: PopupItemProps) {
const [collapsed, setCollapsed] = useState(false)
@ -167,7 +176,11 @@ function PopupItem({
)
const itemRender = modelItem.status === ModelStatusEnum.noConfigure
? (
<div className={rowClassName} aria-disabled="true">
<div
className={rowClassName}
aria-disabled="true"
onPointerDown={onPreviewCardClose}
>
{rowContent}
<button
type="button"
@ -186,67 +199,21 @@ function PopupItem({
}}
disabled={modelItem.status !== ModelStatusEnum.active}
className={rowClassName}
onPointerDown={onPreviewCardClose}
>
{rowContent}
</ComboboxItem>
)
return (
<PreviewCard key={modelItem.model}>
<PreviewCardTrigger
delay={150}
closeDelay={150}
render={itemRender}
/>
<PreviewCardContent
placement="right"
popupClassName="w-[206px] bg-components-panel-bg-blur p-3 shadow-none backdrop-blur-xs"
>
<div className="flex flex-col gap-1">
<div className="flex flex-col items-start gap-2">
<ModelIcon
className={cn('h-5 w-5 shrink-0')}
provider={model}
modelName={modelItem.model}
/>
<div className="system-md-medium text-wrap wrap-break-word text-text-primary">{modelItem.label[language] || modelItem.label.en_US}</div>
</div>
<div className="flex flex-wrap gap-1">
{!!modelItem.model_type && (
<ModelBadge>
{modelTypeFormat(modelItem.model_type)}
</ModelBadge>
)}
{!!modelItem.model_properties.mode && (
<ModelBadge>
{(modelItem.model_properties.mode as string).toLocaleUpperCase()}
</ModelBadge>
)}
{!!modelItem.model_properties.context_size && (
<ModelBadge>
{sizeFormat(modelItem.model_properties.context_size as number)}
</ModelBadge>
)}
</div>
{[ModelTypeEnum.textGeneration, ModelTypeEnum.textEmbedding, ModelTypeEnum.rerank].includes(modelItem.model_type as ModelTypeEnum)
&& modelItem.features?.some(feature => [ModelFeatureEnum.vision, ModelFeatureEnum.audio, ModelFeatureEnum.video, ModelFeatureEnum.document].includes(feature))
&& (
<div className="pt-2">
<div className="mb-1 system-2xs-medium-uppercase text-text-tertiary">{t('model.capabilities', { ns: 'common' })}</div>
<div className="flex flex-wrap gap-1">
{modelItem.features?.map(feature => (
<FeatureIcon
key={feature}
feature={feature}
showFeaturesLabel
/>
))}
</div>
</div>
)}
</div>
</PreviewCardContent>
</PreviewCard>
<PreviewCardTrigger
key={modelItem.model}
delay={150}
closeDelay={150}
handle={previewCardHandle}
payload={{ provider: model, modelItem }}
render={itemRender}
/>
)
})}
</ComboboxGroup>

View File

@ -1,6 +1,8 @@
import type { DefaultModel, Model, ModelFeatureEnum } from '../declarations'
import type { DefaultModel, Model } from '../declarations'
import type { ModelSelectorPreviewPayload } from './popup-item'
import type { ModelProviderQuotaGetPaid } from '@/types/model-provider'
import { ComboboxList } from '@langgenius/dify-ui/combobox'
import { createPreviewCardHandle, PreviewCard, PreviewCardContent } from '@langgenius/dify-ui/preview-card'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useTheme } from 'next-themes'
import { useCallback, useMemo, useState } from 'react'
@ -12,12 +14,15 @@ import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { useInstallPackageFromMarketPlace } from '@/service/use-plugins'
import { CustomConfigurationStatusEnum, ModelStatusEnum } from '../declarations'
import { CustomConfigurationStatusEnum, ModelFeatureEnum, ModelStatusEnum, ModelTypeEnum } from '../declarations'
import { useLanguage, useMarketplaceAllPlugins } from '../hooks'
import ModelBadge from '../model-badge'
import ModelIcon from '../model-icon'
import CreditsExhaustedAlert from '../provider-added-card/model-auth-dropdown/credits-exhausted-alert'
import { useTrialCredits } from '../provider-added-card/use-trial-credits'
import { providerSupportsCredits } from '../supports-credits'
import { MODEL_PROVIDER_QUOTA_GET_PAID, providerKeyToPluginId } from '../utils'
import { MODEL_PROVIDER_QUOTA_GET_PAID, modelTypeFormat, providerKeyToPluginId, sizeFormat } from '../utils'
import FeatureIcon from './feature-icon'
import MarketplaceSection from './marketplace-section'
import { createModelSelectorSearchIndex, filterModelSelectorModels } from './model-search'
import ModelSelectorEmptyState from './popup-empty-state'
@ -43,6 +48,7 @@ function Popup({
const { t } = useTranslation()
const { theme } = useTheme()
const language = useLanguage()
const previewCardHandle = useMemo(() => createPreviewCardHandle<ModelSelectorPreviewPayload>(), [])
const [marketplaceCollapsed, setMarketplaceCollapsed] = useState(false)
const { setShowAccountSettingModal } = useModalContext()
const { modelProviders } = useProviderContext()
@ -151,6 +157,9 @@ function Popup({
onHide()
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PROVIDER })
}, [onHide, setShowAccountSettingModal])
const handleClosePreviewCard = useCallback(() => {
previewCardHandle.close()
}, [previewCardHandle])
return (
<ModelSelectorPopupFrame>
@ -170,6 +179,8 @@ function Popup({
key={model.provider}
defaultModel={defaultModel}
model={model}
previewCardHandle={previewCardHandle}
onPreviewCardClose={handleClosePreviewCard}
onHide={onHide}
/>
))
@ -201,9 +212,86 @@ function Popup({
/>
</div>
</ModelSelectorScrollBody>
<PreviewCard handle={previewCardHandle}>
{({ payload }) => (
<ModelSelectorPreviewCard
capabilitiesLabel={t('model.capabilities', { ns: 'common' })}
language={language}
payload={payload as ModelSelectorPreviewPayload | undefined}
/>
)}
</PreviewCard>
<ModelProviderSettingsFooter onOpenSettings={handleOpenSettings} />
</ModelSelectorPopupFrame>
)
}
type ModelSelectorPreviewCardProps = {
capabilitiesLabel: string
language: string
payload?: ModelSelectorPreviewPayload
}
function ModelSelectorPreviewCard({
capabilitiesLabel,
language,
payload,
}: ModelSelectorPreviewCardProps) {
if (!payload)
return null
const { provider, modelItem } = payload
return (
<PreviewCardContent
placement="right"
popupClassName="w-[206px] bg-components-panel-bg-blur p-3 shadow-none backdrop-blur-xs"
>
<div className="flex flex-col gap-1">
<div className="flex flex-col items-start gap-2">
<ModelIcon
className="h-5 w-5 shrink-0"
provider={provider}
modelName={modelItem.model}
/>
<div className="system-md-medium text-wrap wrap-break-word text-text-primary">{modelItem.label[language] || modelItem.label.en_US}</div>
</div>
<div className="flex flex-wrap gap-1">
{!!modelItem.model_type && (
<ModelBadge>
{modelTypeFormat(modelItem.model_type)}
</ModelBadge>
)}
{!!modelItem.model_properties.mode && (
<ModelBadge>
{(modelItem.model_properties.mode as string).toLocaleUpperCase()}
</ModelBadge>
)}
{!!modelItem.model_properties.context_size && (
<ModelBadge>
{sizeFormat(modelItem.model_properties.context_size as number)}
</ModelBadge>
)}
</div>
{[ModelTypeEnum.textGeneration, ModelTypeEnum.textEmbedding, ModelTypeEnum.rerank].includes(modelItem.model_type as ModelTypeEnum)
&& modelItem.features?.some(feature => [ModelFeatureEnum.vision, ModelFeatureEnum.audio, ModelFeatureEnum.video, ModelFeatureEnum.document].includes(feature))
&& (
<div className="pt-2">
<div className="mb-1 system-2xs-medium-uppercase text-text-tertiary">{capabilitiesLabel}</div>
<div className="flex flex-wrap gap-1">
{modelItem.features?.map(feature => (
<FeatureIcon
key={feature}
feature={feature}
showFeaturesLabel
/>
))}
</div>
</div>
)}
</div>
</PreviewCardContent>
)
}
export default Popup

View File

@ -1,6 +1,7 @@
import type { NodeDefault } from '../types'
import type { BlockClassificationEnum } from './types'
import {
createPreviewCardHandle,
PreviewCard,
PreviewCardContent,
PreviewCardTrigger,
@ -25,6 +26,10 @@ type BlocksProps = {
availableBlocksTypes?: BlockEnum[]
blocks?: NodeDefault[]
}
type BlockPreviewPayload = {
block: NodeDefault
}
const Blocks = ({
searchText,
onSelect,
@ -34,6 +39,7 @@ const Blocks = ({
const { t } = useTranslation()
const store = useStoreApi()
const blocksFromHooks = useBlocks()
const previewCardHandle = useMemo(() => createPreviewCardHandle<BlockPreviewPayload>(), [])
// Use external blocks if provided, otherwise fallback to hook-based blocks
const blocks = blocksFromProps || blocksFromHooks.map(block => ({
@ -101,51 +107,38 @@ const Blocks = ({
// hover/focus-only activation is a11y-safe. See
// packages/dify-ui/AGENTS.md → Overlay Primitive Selection.
filteredList.map(block => (
<PreviewCard key={block.metaData.type}>
<PreviewCardTrigger
delay={150}
closeDelay={150}
render={(
<div
className="flex h-8 w-full cursor-pointer items-center rounded-lg px-3 hover:bg-state-base-hover"
onClick={() => onSelect(block.metaData.type)}
>
<BlockIcon
className="mr-2 shrink-0"
type={block.metaData.type}
/>
<div className="grow text-sm text-text-secondary">{block.metaData.title}</div>
{
block.metaData.type === BlockEnum.LoopEnd && (
<Badge
text={t('nodes.loop.loopNode', { ns: 'workflow' })}
className="ml-2 shrink-0"
/>
)
}
</div>
)}
/>
<PreviewCardContent
placement="right"
popupClassName="w-[200px] border-none px-3 py-2"
>
<div>
<PreviewCardTrigger
key={block.metaData.type}
delay={150}
closeDelay={150}
handle={previewCardHandle}
payload={{ block }}
render={(
<div
className="flex h-8 w-full cursor-pointer items-center rounded-lg px-3 hover:bg-state-base-hover"
onClick={() => onSelect(block.metaData.type)}
>
<BlockIcon
size="md"
className="mb-2"
className="mr-2 shrink-0"
type={block.metaData.type}
/>
<div className="mb-1 system-md-medium text-text-primary">{block.metaData.title}</div>
<div className="system-xs-regular text-text-tertiary">{block.metaData.description}</div>
<div className="grow text-sm text-text-secondary">{block.metaData.title}</div>
{
block.metaData.type === BlockEnum.LoopEnd && (
<Badge
text={t('nodes.loop.loopNode', { ns: 'workflow' })}
className="ml-2 shrink-0"
/>
)
}
</div>
</PreviewCardContent>
</PreviewCard>
)}
/>
))
}
</div>
)
}, [groups, onSelect, t, store])
}, [groups, onSelect, previewCardHandle, t, store])
return (
<div className="max-h-[480px] max-w-[500px] overflow-y-auto p-1">
@ -157,8 +150,43 @@ const Blocks = ({
{
!isEmpty && BLOCK_CLASSIFICATIONS.map(renderGroup)
}
<PreviewCard handle={previewCardHandle}>
{({ payload }) => (
<BlockPreviewCard payload={payload as BlockPreviewPayload | undefined} />
)}
</PreviewCard>
</div>
)
}
type BlockPreviewCardProps = {
payload?: BlockPreviewPayload
}
function BlockPreviewCard({
payload,
}: BlockPreviewCardProps) {
if (!payload)
return null
const { block } = payload
return (
<PreviewCardContent
placement="right"
popupClassName="w-[200px] border-none px-3 py-2"
>
<div>
<BlockIcon
size="md"
className="mb-2"
type={block.metaData.type}
/>
<div className="mb-1 system-md-medium text-text-primary">{block.metaData.title}</div>
<div className="system-xs-regular wrap-break-word text-text-tertiary">{block.metaData.description}</div>
</div>
</PreviewCardContent>
)
}
export default memo(Blocks)

View File

@ -1,9 +1,10 @@
'use client'
import type { TFunction } from 'i18next'
import type { ToolWithProvider } from '../types'
import type { ToolDefaultValue, ToolValue } from './types'
import type { Plugin } from '@/app/components/plugins/types'
import type { Locale } from '@/i18n-config'
import { PreviewCard, PreviewCardContent, PreviewCardTrigger } from '@langgenius/dify-ui/preview-card'
import { createPreviewCardHandle, PreviewCard, PreviewCardContent, PreviewCardTrigger } from '@langgenius/dify-ui/preview-card'
import { RiMoreLine } from '@remixicon/react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -33,6 +34,11 @@ type FeaturedToolsProps = {
isLoading?: boolean
onInstallSuccess?: () => void
}
type FeaturedToolPreviewPayload = {
plugin: Plugin
label: string
description: string
}
const STORAGE_KEY = 'workflow_tools_featured_collapsed'
@ -46,7 +52,9 @@ const FeaturedTools = ({
}: FeaturedToolsProps) => {
const { t } = useTranslation()
const language = useGetLanguage()
const previewCardHandle = useMemo(() => createPreviewCardHandle<FeaturedToolPreviewPayload>(), [])
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_COUNT)
const [visibleCountPlugins, setVisibleCountPlugins] = useState(plugins)
const [isCollapsed, setIsCollapsed] = useState<boolean>(() => {
if (isServer)
return false
@ -54,23 +62,16 @@ const FeaturedTools = ({
return stored === 'true'
})
useEffect(() => {
if (isServer)
return
const stored = window.localStorage.getItem(STORAGE_KEY)
if (stored !== null)
setIsCollapsed(stored === 'true')
}, [])
useEffect(() => {
if (isServer)
return
window.localStorage.setItem(STORAGE_KEY, String(isCollapsed))
}, [isCollapsed])
useEffect(() => {
if (visibleCountPlugins !== plugins) {
setVisibleCountPlugins(plugins)
setVisibleCount(INITIAL_VISIBLE_COUNT)
}, [plugins])
}
const limitedPlugins = useMemo(
() => plugins.slice(0, MAX_RECOMMENDED_COUNT),
@ -174,10 +175,11 @@ const FeaturedTools = ({
key={plugin.plugin_id}
plugin={plugin}
language={language}
previewCardHandle={previewCardHandle}
onInstallSuccess={async () => {
await onInstallSuccess?.()
}}
t={t as any}
t={t}
/>
))}
</div>
@ -214,6 +216,11 @@ const FeaturedTools = ({
)}
</>
)}
<PreviewCard handle={previewCardHandle}>
{({ payload }) => (
<FeaturedToolPreviewCard payload={payload as FeaturedToolPreviewPayload | undefined} />
)}
</PreviewCard>
</div>
)
}
@ -221,13 +228,15 @@ const FeaturedTools = ({
type FeaturedToolUninstalledItemProps = {
plugin: Plugin
language: Locale
previewCardHandle: ReturnType<typeof createPreviewCardHandle<FeaturedToolPreviewPayload>>
onInstallSuccess?: () => Promise<void> | void
t: (key: string, options?: Record<string, any>) => string
t: TFunction
}
function FeaturedToolUninstalledItem({
plugin,
language,
previewCardHandle,
onInstallSuccess,
t,
}: FeaturedToolUninstalledItemProps) {
@ -296,16 +305,13 @@ function FeaturedToolUninstalledItem({
// Preview is supplementary: icon / label / brief are all reachable from
// the InstallFromMarketplace modal that opens on click, so hover/focus-only
// activation is a11y-safe. See packages/dify-ui/AGENTS.md → Overlay Primitive Selection.
<PreviewCard>
<PreviewCardTrigger delay={150} closeDelay={150} render={row} />
<PreviewCardContent placement="right" popupClassName="w-[224px] px-3 py-2.5">
<div>
<BlockIcon size="md" className="mb-2" type={BlockEnum.Tool} toolIcon={plugin.icon} />
<div className="mb-1 text-sm leading-5 text-text-primary">{label}</div>
<div className="text-xs leading-[18px] text-text-secondary">{description}</div>
</div>
</PreviewCardContent>
</PreviewCard>
<PreviewCardTrigger
delay={150}
closeDelay={150}
handle={previewCardHandle}
payload={{ plugin, label, description }}
render={row}
/>
)
: row}
{isInstallModalOpen && (
@ -325,4 +331,25 @@ function FeaturedToolUninstalledItem({
)
}
type FeaturedToolPreviewCardProps = {
payload?: FeaturedToolPreviewPayload
}
function FeaturedToolPreviewCard({
payload,
}: FeaturedToolPreviewCardProps) {
if (!payload)
return null
return (
<PreviewCardContent placement="right" popupClassName="w-[224px] px-3 py-2.5">
<div>
<BlockIcon size="md" className="mb-2" type={BlockEnum.Tool} toolIcon={payload.plugin.icon} />
<div className="mb-1 text-sm leading-5 text-text-primary">{payload.label}</div>
<div className="text-xs leading-[18px] wrap-break-word text-text-secondary">{payload.description}</div>
</div>
</PreviewCardContent>
)
}
export default FeaturedTools

View File

@ -1,8 +1,10 @@
'use client'
import type { TFunction } from 'i18next'
import type { TriggerPluginActionPreviewPayload } from './trigger-plugin/action-item'
import type { TriggerDefaultValue, TriggerWithProvider } from './types'
import type { Plugin } from '@/app/components/plugins/types'
import type { Locale } from '@/i18n-config'
import { PreviewCard, PreviewCardContent, PreviewCardTrigger } from '@langgenius/dify-ui/preview-card'
import { createPreviewCardHandle, PreviewCard, PreviewCardContent, PreviewCardTrigger } from '@langgenius/dify-ui/preview-card'
import { RiMoreLine } from '@remixicon/react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -17,6 +19,7 @@ import { formatNumber } from '@/utils/format'
import { getMarketplaceUrl } from '@/utils/var'
import BlockIcon from '../block-icon'
import { BlockEnum } from '../types'
import { TriggerPluginActionPreviewCard } from './trigger-plugin/action-item'
import TriggerPluginItem from './trigger-plugin/item'
const MAX_RECOMMENDED_COUNT = 15
@ -29,6 +32,11 @@ type FeaturedTriggersProps = {
isLoading?: boolean
onInstallSuccess?: () => void | Promise<void>
}
type FeaturedTriggerPreviewPayload = {
plugin: Plugin
label: string
description: string
}
const STORAGE_KEY = 'workflow_triggers_featured_collapsed'
@ -41,7 +49,10 @@ const FeaturedTriggers = ({
}: FeaturedTriggersProps) => {
const { t } = useTranslation()
const language = useGetLanguage()
const previewCardHandle = useMemo(() => createPreviewCardHandle<FeaturedTriggerPreviewPayload>(), [])
const triggerActionPreviewCardHandle = useMemo(() => createPreviewCardHandle<TriggerPluginActionPreviewPayload>(), [])
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_COUNT)
const [visibleCountPlugins, setVisibleCountPlugins] = useState(plugins)
const [isCollapsed, setIsCollapsed] = useState<boolean>(() => {
if (isServer)
return false
@ -49,23 +60,16 @@ const FeaturedTriggers = ({
return stored === 'true'
})
useEffect(() => {
if (isServer)
return
const stored = window.localStorage.getItem(STORAGE_KEY)
if (stored !== null)
setIsCollapsed(stored === 'true')
}, [])
useEffect(() => {
if (isServer)
return
window.localStorage.setItem(STORAGE_KEY, String(isCollapsed))
}, [isCollapsed])
useEffect(() => {
if (visibleCountPlugins !== plugins) {
setVisibleCountPlugins(plugins)
setVisibleCount(INITIAL_VISIBLE_COUNT)
}, [plugins])
}
const limitedPlugins = useMemo(
() => plugins.slice(0, MAX_RECOMMENDED_COUNT),
@ -156,6 +160,7 @@ const FeaturedTriggers = ({
key={provider.id}
payload={provider}
hasSearchText={false}
previewCardHandle={triggerActionPreviewCardHandle}
onSelect={onSelect}
/>
))}
@ -169,10 +174,11 @@ const FeaturedTriggers = ({
key={plugin.plugin_id}
plugin={plugin}
language={language}
previewCardHandle={previewCardHandle}
onInstallSuccess={async () => {
await onInstallSuccess?.()
}}
t={t as any}
t={t}
/>
))}
</div>
@ -209,6 +215,16 @@ const FeaturedTriggers = ({
)}
</>
)}
<PreviewCard handle={previewCardHandle}>
{({ payload }) => (
<FeaturedTriggerPreviewCard payload={payload as FeaturedTriggerPreviewPayload | undefined} />
)}
</PreviewCard>
<PreviewCard handle={triggerActionPreviewCardHandle}>
{({ payload }) => (
<TriggerPluginActionPreviewCard payload={payload as TriggerPluginActionPreviewPayload | undefined} />
)}
</PreviewCard>
</div>
)
}
@ -216,13 +232,15 @@ const FeaturedTriggers = ({
type FeaturedTriggerUninstalledItemProps = {
plugin: Plugin
language: Locale
previewCardHandle: ReturnType<typeof createPreviewCardHandle<FeaturedTriggerPreviewPayload>>
onInstallSuccess?: () => Promise<void> | void
t: (key: string, options?: Record<string, any>) => string
t: TFunction
}
function FeaturedTriggerUninstalledItem({
plugin,
language,
previewCardHandle,
onInstallSuccess,
t,
}: FeaturedTriggerUninstalledItemProps) {
@ -291,16 +309,13 @@ function FeaturedTriggerUninstalledItem({
// Preview is supplementary: icon / label / brief are all reachable from
// the InstallFromMarketplace modal that opens on click, so hover/focus-only
// activation is a11y-safe. See packages/dify-ui/AGENTS.md → Overlay Primitive Selection.
<PreviewCard>
<PreviewCardTrigger delay={150} closeDelay={150} render={row} />
<PreviewCardContent placement="right" popupClassName="w-[224px] px-3 py-2.5">
<div>
<BlockIcon size="md" className="mb-2" type={BlockEnum.TriggerPlugin} toolIcon={plugin.icon} />
<div className="mb-1 text-sm leading-5 text-text-primary">{label}</div>
<div className="text-xs leading-[18px] text-text-secondary">{description}</div>
</div>
</PreviewCardContent>
</PreviewCard>
<PreviewCardTrigger
delay={150}
closeDelay={150}
handle={previewCardHandle}
payload={{ plugin, label, description }}
render={row}
/>
)
: row}
{isInstallModalOpen && (
@ -320,4 +335,25 @@ function FeaturedTriggerUninstalledItem({
)
}
type FeaturedTriggerPreviewCardProps = {
payload?: FeaturedTriggerPreviewPayload
}
function FeaturedTriggerPreviewCard({
payload,
}: FeaturedTriggerPreviewCardProps) {
if (!payload)
return null
return (
<PreviewCardContent placement="right" popupClassName="w-[224px] px-3 py-2.5">
<div>
<BlockIcon size="md" className="mb-2" type={BlockEnum.TriggerPlugin} toolIcon={payload.plugin.icon} />
<div className="mb-1 text-sm leading-5 text-text-primary">{payload.label}</div>
<div className="text-xs leading-[18px] wrap-break-word text-text-secondary">{payload.description}</div>
</div>
</PreviewCardContent>
)
}
export default FeaturedTriggers

View File

@ -1,11 +1,14 @@
import type { BlockEnum, ToolWithProvider } from '../../types'
import type { ToolActionPreviewPayload } from '../tool/action-item'
import type { ToolDefaultValue } from '../types'
import type { Plugin } from '@/app/components/plugins/types'
import type { OnSelectBlock } from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
import { createPreviewCardHandle, PreviewCard } from '@langgenius/dify-ui/preview-card'
import { useCallback, useMemo, useRef } from 'react'
import { useGetLanguage } from '@/context/i18n'
import { groupItems } from '../index-bar'
import { ToolActionPreviewCard } from '../tool/action-item'
import ToolListFlatView from '../tool/tool-list-flat-view/list'
import ToolListTreeView from '../tool/tool-list-tree-view/list'
import { ViewType } from '../view-type-select'
@ -27,6 +30,7 @@ const List = ({
className,
}: ListProps) => {
const language = useGetLanguage()
const previewCardHandle = useMemo(() => createPreviewCardHandle<ToolActionPreviewPayload>(), [])
const isFlatView = viewType === ViewType.flat
const { letters, groups: withLetterAndGroupViewToolsData } = groupItems(tools, tool => tool.label[language]![0]!)
@ -58,7 +62,7 @@ const List = ({
return result
}, [withLetterAndGroupViewToolsData, letters])
const toolRefs = useRef({})
const toolRefsRef = useRef<Record<string, HTMLDivElement | null>>({})
const handleSelect = useCallback((type: BlockEnum, tool: ToolDefaultValue) => {
onSelect(type, tool)
@ -70,9 +74,10 @@ const List = ({
isFlatView
? (
<ToolListFlatView
toolRefs={toolRefs}
toolRefs={toolRefsRef}
letters={letters}
payload={listViewToolData}
previewCardHandle={previewCardHandle}
isShowLetterIndex={false}
hasSearchText={false}
onSelect={handleSelect}
@ -83,12 +88,18 @@ const List = ({
: (
<ToolListTreeView
payload={treeViewToolsData}
previewCardHandle={previewCardHandle}
hasSearchText={false}
onSelect={handleSelect}
canNotSelectMultiple
/>
)
)}
<PreviewCard handle={previewCardHandle}>
{({ payload }) => (
<ToolActionPreviewCard payload={payload as ToolActionPreviewPayload | undefined} />
)}
</PreviewCard>
{
unInstalledPlugins.map((item) => {
return (

View File

@ -1,6 +1,7 @@
import type { BlockEnum, CommonNodeType } from '../types'
import type { TriggerDefaultValue } from './types'
import {
createPreviewCardHandle,
PreviewCard,
PreviewCardContent,
PreviewCardTrigger,
@ -25,6 +26,9 @@ type StartBlocksProps = {
onContentStateChange?: (hasContent: boolean) => void
hideUserInput?: boolean
}
type StartBlockPreviewPayload = {
block: typeof START_BLOCKS[number]
}
const StartBlocks = ({
searchText,
@ -35,6 +39,7 @@ const StartBlocks = ({
}: StartBlocksProps) => {
const { t } = useTranslation()
const nodes = useNodes()
const previewCardHandle = useMemo(() => createPreviewCardHandle<StartBlockPreviewPayload>(), [])
// const nodeMetaData = useNodeMetaData()
const filteredBlocks = useMemo(() => {
@ -74,54 +79,31 @@ const StartBlocks = ({
// the start node, so hover/focus-only activation is a11y-safe. See
// packages/dify-ui/AGENTS.md → Overlay Primitive Selection.
const renderBlock = useCallback((block: typeof START_BLOCKS[number]) => (
<PreviewCard key={block.type}>
<PreviewCardTrigger
delay={150}
closeDelay={150}
render={(
<div
className="flex h-8 w-full cursor-pointer items-center rounded-lg px-3 hover:bg-state-base-hover"
onClick={() => onSelect(block.type)}
>
<BlockIcon
className="mr-2 shrink-0"
type={block.type}
/>
<div className="flex w-0 grow items-center justify-between text-sm text-text-secondary">
<span className="truncate">{t(`blocks.${block.type}`, { ns: 'workflow' })}</span>
{block.type === BlockEnumValues.Start && (
<span className="ml-2 shrink-0 system-xs-regular text-text-quaternary">{t('blocks.originalStartNode', { ns: 'workflow' })}</span>
)}
</div>
</div>
)}
/>
<PreviewCardContent placement="right" popupClassName="w-[224px] px-3 py-2.5">
<div>
<PreviewCardTrigger
key={block.type}
delay={150}
closeDelay={150}
handle={previewCardHandle}
payload={{ block }}
render={(
<div
className="flex h-8 w-full cursor-pointer items-center rounded-lg px-3 hover:bg-state-base-hover"
onClick={() => onSelect(block.type)}
>
<BlockIcon
size="md"
className="mb-2"
className="mr-2 shrink-0"
type={block.type}
/>
<div className="mb-1 system-md-medium text-text-primary">
{block.type === BlockEnumValues.TriggerWebhook
? t('customWebhook', { ns: 'workflow' })
: t(`blocks.${block.type}`, { ns: 'workflow' })}
<div className="flex w-0 grow items-center justify-between text-sm text-text-secondary">
<span className="truncate">{t(`blocks.${block.type}`, { ns: 'workflow' })}</span>
{block.type === BlockEnumValues.Start && (
<span className="ml-2 shrink-0 system-xs-regular text-text-quaternary">{t('blocks.originalStartNode', { ns: 'workflow' })}</span>
)}
</div>
<div className="system-xs-regular text-text-secondary">
{t(`blocksAbout.${block.type}`, { ns: 'workflow' })}
</div>
{(block.type === BlockEnumValues.TriggerWebhook || block.type === BlockEnumValues.TriggerSchedule) && (
<div className="mt-1 mb-1 system-xs-regular text-text-tertiary">
{t('author', { ns: 'tools' })}
{' '}
{t('difyTeam', { ns: 'workflow' })}
</div>
)}
</div>
</PreviewCardContent>
</PreviewCard>
), [onSelect, t])
)}
/>
), [onSelect, previewCardHandle, t])
if (isEmpty)
return null
@ -140,8 +122,58 @@ const StartBlocks = ({
</div>
))}
</div>
<PreviewCard handle={previewCardHandle}>
{({ payload }) => (
<StartBlockPreviewCard
payload={payload as StartBlockPreviewPayload | undefined}
t={t}
/>
)}
</PreviewCard>
</div>
)
}
type StartBlockPreviewCardProps = {
payload?: StartBlockPreviewPayload
t: ReturnType<typeof useTranslation>['t']
}
function StartBlockPreviewCard({
payload,
t,
}: StartBlockPreviewCardProps) {
if (!payload)
return null
const { block } = payload
return (
<PreviewCardContent placement="right" popupClassName="w-[224px] px-3 py-2.5">
<div>
<BlockIcon
size="md"
className="mb-2"
type={block.type}
/>
<div className="mb-1 system-md-medium text-text-primary">
{block.type === BlockEnumValues.TriggerWebhook
? t('customWebhook', { ns: 'workflow' })
: t(`blocks.${block.type}`, { ns: 'workflow' })}
</div>
<div className="system-xs-regular wrap-break-word text-text-secondary">
{t(`blocksAbout.${block.type}`, { ns: 'workflow' })}
</div>
{(block.type === BlockEnumValues.TriggerWebhook || block.type === BlockEnumValues.TriggerSchedule) && (
<div className="mt-1 mb-1 system-xs-regular text-text-tertiary">
{t('author', { ns: 'tools' })}
{' '}
{t('difyTeam', { ns: 'workflow' })}
</div>
)}
</div>
</PreviewCardContent>
)
}
export default memo(StartBlocks)

View File

@ -1,3 +1,4 @@
import { createPreviewCardHandle } from '@langgenius/dify-ui/preview-card'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { trackEvent } from '@/app/components/base/amplitude'
@ -51,6 +52,7 @@ describe('Tool', () => {
createTool('tool-b', 'Tool B'),
],
})}
previewCardHandle={createPreviewCardHandle()}
viewType={ViewType.flat}
hasSearchText={false}
onSelect={onSelect}
@ -82,6 +84,7 @@ describe('Tool', () => {
type: CollectionType.workflow,
tools: [createTool('workflow-tool', 'Workflow Tool')],
})}
previewCardHandle={createPreviewCardHandle()}
viewType={ViewType.flat}
hasSearchText={false}
onSelect={onSelect}

View File

@ -1,10 +1,10 @@
'use client'
import type { FC } from 'react'
import type { ComponentProps, FC } from 'react'
import type { ToolWithProvider } from '../../types'
import type { ToolDefaultValue } from '../types'
import type { Tool } from '@/app/components/tools/types'
import { cn } from '@langgenius/dify-ui/cn'
import { PreviewCard, PreviewCardContent, PreviewCardTrigger } from '@langgenius/dify-ui/preview-card'
import { PreviewCardContent, PreviewCardTrigger } from '@langgenius/dify-ui/preview-card'
import * as React from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
@ -27,14 +27,25 @@ const normalizeProviderIcon = (icon?: ToolWithProvider['icon']) => {
type Props = {
provider: ToolWithProvider
payload: Tool
previewCardHandle: PreviewCardHandle
disabled?: boolean
isAdded?: boolean
onSelect: (type: BlockEnum, tool: ToolDefaultValue) => void
}
export type ToolActionPreviewPayload = {
providerIcon: ToolWithProvider['icon']
payload: Tool
language: ReturnType<typeof useGetLanguage>
}
type PreviewCardHandle = NonNullable<ComponentProps<typeof PreviewCardTrigger>['handle']>
export type ToolActionPreviewCardHandle = PreviewCardHandle
const ToolItem: FC<Props> = ({
provider,
payload,
previewCardHandle,
onSelect,
disabled,
isAdded,
@ -107,21 +118,45 @@ const ToolItem: FC<Props> = ({
// reachable from the node inspector after the row is clicked to add the tool,
// so hover/focus-only activation is a11y-safe. See
// packages/dify-ui/AGENTS.md → Overlay Primitive Selection.
<PreviewCard key={payload.name}>
<PreviewCardTrigger delay={150} closeDelay={150} render={row} />
<PreviewCardContent placement="right" popupClassName="w-[200px] px-3 py-2.5">
<div>
<BlockIcon
size="md"
className="mb-2"
type={BlockEnum.Tool}
toolIcon={providerIcon}
/>
<div className="mb-1 text-sm leading-5 text-text-primary">{payload.label[language]}</div>
<div className="text-xs leading-[18px] text-text-secondary">{payload.description[language]}</div>
</div>
</PreviewCardContent>
</PreviewCard>
<PreviewCardTrigger
key={payload.name}
delay={150}
closeDelay={150}
handle={previewCardHandle}
payload={{
providerIcon,
payload,
language,
}}
render={row}
/>
)
}
type ToolActionPreviewCardProps = {
payload?: ToolActionPreviewPayload
}
export function ToolActionPreviewCard({
payload,
}: ToolActionPreviewCardProps) {
if (!payload)
return null
return (
<PreviewCardContent placement="right" popupClassName="w-[200px] px-3 py-2.5">
<div>
<BlockIcon
size="md"
className="mb-2"
type={BlockEnum.Tool}
toolIcon={payload.providerIcon}
/>
<div className="mb-1 text-sm leading-5 text-text-primary">{payload.payload.label[payload.language]}</div>
<div className="text-xs leading-[18px] wrap-break-word text-text-secondary">{payload.payload.description[payload.language]}</div>
</div>
</PreviewCardContent>
)
}
export default React.memo(ToolItem)

View File

@ -1,3 +1,4 @@
import { createPreviewCardHandle } from '@langgenius/dify-ui/preview-card'
import { render, screen } from '@testing-library/react'
import { useGetLanguage } from '@/context/i18n'
import useTheme from '@/hooks/use-theme'
@ -37,6 +38,7 @@ describe('ToolListFlatView', () => {
render(
<List
letters={['A', 'B']}
previewCardHandle={createPreviewCardHandle()}
payload={[
createToolProvider({
id: 'provider-a',

View File

@ -1,7 +1,8 @@
'use client'
import type { FC } from 'react'
import type { FC, RefObject } from 'react'
import type { BlockEnum, ToolWithProvider } from '../../../types'
import type { ToolDefaultValue, ToolValue } from '../../types'
import type { ToolActionPreviewCardHandle } from '../action-item'
import * as React from 'react'
import { useMemo } from 'react'
import { ViewType } from '../../view-type-select'
@ -9,6 +10,7 @@ import Tool from '../tool'
type Props = {
payload: ToolWithProvider[]
previewCardHandle: ToolActionPreviewCardHandle
isShowLetterIndex: boolean
indexBar: React.ReactNode
hasSearchText: boolean
@ -16,13 +18,14 @@ type Props = {
canNotSelectMultiple?: boolean
onSelectMultiple?: (type: BlockEnum, tools: ToolDefaultValue[]) => void
letters: string[]
toolRefs: any
toolRefs: RefObject<Record<string, HTMLDivElement | null>>
selectedTools?: ToolValue[]
}
const ToolViewFlatView: FC<Props> = ({
letters,
payload,
previewCardHandle,
isShowLetterIndex,
indexBar,
hasSearchText,
@ -55,6 +58,7 @@ const ToolViewFlatView: FC<Props> = ({
>
<Tool
payload={tool}
previewCardHandle={previewCardHandle}
viewType={ViewType.flat}
hasSearchText={hasSearchText}
onSelect={onSelect}

View File

@ -1,3 +1,4 @@
import { createPreviewCardHandle } from '@langgenius/dify-ui/preview-card'
import { render, screen } from '@testing-library/react'
import { useGetLanguage } from '@/context/i18n'
import useTheme from '@/hooks/use-theme'
@ -36,6 +37,7 @@ describe('ToolListTreeView Item', () => {
toolList={[createToolProvider({
label: { en_US: 'Provider Alpha', zh_Hans: 'Provider Alpha' },
})]}
previewCardHandle={createPreviewCardHandle()}
hasSearchText={false}
onSelect={vi.fn()}
/>,

View File

@ -1,3 +1,4 @@
import { createPreviewCardHandle } from '@langgenius/dify-ui/preview-card'
import { render, screen } from '@testing-library/react'
import { useGetLanguage } from '@/context/i18n'
import useTheme from '@/hooks/use-theme'
@ -43,6 +44,7 @@ describe('ToolListTreeView', () => {
label: { en_US: 'Custom Provider', zh_Hans: 'Custom Provider' },
})],
}}
previewCardHandle={createPreviewCardHandle()}
hasSearchText={false}
onSelect={vi.fn()}
/>,

View File

@ -2,6 +2,7 @@
import type { FC } from 'react'
import type { BlockEnum, ToolWithProvider } from '../../../types'
import type { ToolDefaultValue, ToolValue } from '../../types'
import type { ToolActionPreviewCardHandle } from '../action-item'
import * as React from 'react'
import { ViewType } from '../../view-type-select'
import Tool from '../tool'
@ -9,6 +10,7 @@ import Tool from '../tool'
type Props = {
groupName: string
toolList: ToolWithProvider[]
previewCardHandle: ToolActionPreviewCardHandle
hasSearchText: boolean
onSelect: (type: BlockEnum, tool: ToolDefaultValue) => void
canNotSelectMultiple?: boolean
@ -19,6 +21,7 @@ type Props = {
const Item: FC<Props> = ({
groupName,
toolList,
previewCardHandle,
hasSearchText,
onSelect,
canNotSelectMultiple,
@ -35,6 +38,7 @@ const Item: FC<Props> = ({
<Tool
key={tool.id}
payload={tool}
previewCardHandle={previewCardHandle}
viewType={ViewType.tree}
hasSearchText={hasSearchText}
onSelect={onSelect}

View File

@ -2,6 +2,7 @@
import type { FC } from 'react'
import type { BlockEnum, ToolWithProvider } from '../../../types'
import type { ToolDefaultValue, ToolValue } from '../../types'
import type { ToolActionPreviewCardHandle } from '../action-item'
import * as React from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
@ -10,6 +11,7 @@ import Item from './item'
type Props = {
payload: Record<string, ToolWithProvider[]>
previewCardHandle: ToolActionPreviewCardHandle
hasSearchText: boolean
onSelect: (type: BlockEnum, tool: ToolDefaultValue) => void
canNotSelectMultiple?: boolean
@ -19,6 +21,7 @@ type Props = {
const ToolListTreeView: FC<Props> = ({
payload,
previewCardHandle,
hasSearchText,
onSelect,
canNotSelectMultiple,
@ -49,6 +52,7 @@ const ToolListTreeView: FC<Props> = ({
key={groupName}
groupName={getI18nGroupName(groupName)}
toolList={payload[groupName]!}
previewCardHandle={previewCardHandle}
hasSearchText={hasSearchText}
onSelect={onSelect}
canNotSelectMultiple={canNotSelectMultiple}

View File

@ -3,11 +3,12 @@ import type { FC } from 'react'
import type { Tool as ToolType } from '../../../tools/types'
import type { ToolWithProvider } from '../../types'
import type { ToolDefaultValue, ToolValue } from '../types'
import type { ToolActionPreviewCardHandle } from './action-item'
import { cn } from '@langgenius/dify-ui/cn'
import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react'
import { useHover } from 'ahooks'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useCallback, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { Mcp } from '@/app/components/base/icons/src/vender/other'
import { useMCPToolAvailability } from '@/app/components/workflow/nodes/_base/components/mcp-tool-availability'
@ -33,6 +34,7 @@ const normalizeProviderIcon = (icon?: ToolWithProvider['icon']) => {
type Props = {
className?: string
payload: ToolWithProvider
previewCardHandle: ToolActionPreviewCardHandle
viewType: ViewType
hasSearchText: boolean
onSelect: (type: BlockEnum, tool: ToolDefaultValue) => void
@ -45,6 +47,7 @@ type Props = {
const Tool: FC<Props> = ({
className,
payload,
previewCardHandle,
viewType,
hasSearchText,
onSelect,
@ -59,7 +62,8 @@ const Tool: FC<Props> = ({
const notShowProvider = payload.type === CollectionType.workflow
const actions = payload.tools
const hasAction = !notShowProvider
const [isFold, setFold] = React.useState<boolean>(true)
const [isFold, setIsFold] = React.useState<boolean>(true)
const [isFoldHasSearchText, setIsFoldHasSearchText] = React.useState(hasSearchText)
const ref = useRef(null)
const isHovering = useHover(ref)
const isMCPTool = payload.type === CollectionType.mcp
@ -146,14 +150,10 @@ const Tool: FC<Props> = ({
)
}, [actions, getIsDisabled, isAllSelected, isHovering, language, onSelectMultiple, payload.id, payload.is_team_authorization, payload.name, payload.type, selectedToolsNum, t, totalToolsNum])
useEffect(() => {
if (hasSearchText && isFold) {
setFold(false)
return
}
if (!hasSearchText && !isFold)
setFold(true)
}, [hasSearchText])
if (isFoldHasSearchText !== hasSearchText) {
setIsFoldHasSearchText(hasSearchText)
setIsFold(!hasSearchText)
}
const FoldIcon = isFold ? RiArrowRightSLine : RiArrowDownSLine
@ -181,7 +181,7 @@ const Tool: FC<Props> = ({
className="group/item flex w-full cursor-pointer items-center justify-between rounded-lg pr-1 pl-3 select-none hover:bg-state-base-hover"
onClick={() => {
if (hasAction) {
setFold(!isFold)
setIsFold(!isFold)
return
}
@ -240,6 +240,7 @@ const Tool: FC<Props> = ({
key={action.name}
provider={payload}
payload={action}
previewCardHandle={previewCardHandle}
onSelect={onSelect}
disabled={getIsDisabled(action) || isShowCanNotChooseMCPTip}
isAdded={getIsDisabled(action)}

View File

@ -1,10 +1,13 @@
import type { BlockEnum, ToolWithProvider } from '../types'
import type { ToolActionPreviewPayload } from './tool/action-item'
import type { ToolDefaultValue, ToolTypeEnum, ToolValue } from './types'
import { cn } from '@langgenius/dify-ui/cn'
import { createPreviewCardHandle, PreviewCard } from '@langgenius/dify-ui/preview-card'
import { memo, useMemo, useRef } from 'react'
import Empty from '@/app/components/tools/provider/empty'
import { useGetLanguage } from '@/context/i18n'
import IndexBar, { groupItems } from './index-bar'
import { ToolActionPreviewCard } from './tool/action-item'
import ToolListFlatView from './tool/tool-list-flat-view/list'
import ToolListTreeView from './tool/tool-list-tree-view/list'
import { ViewType } from './view-type-select'
@ -35,8 +38,8 @@ const Tools = ({
indexBarClassName,
selectedTools,
}: ToolsProps) => {
// const tools: any = []
const language = useGetLanguage()
const previewCardHandle = useMemo(() => createPreviewCardHandle<ToolActionPreviewPayload>(), [])
const isFlatView = viewType === ViewType.flat
const isShowLetterIndex = isFlatView && tools.length > 10
@ -85,7 +88,7 @@ const Tools = ({
return result
}, [withLetterAndGroupViewToolsData, letters])
const toolRefs = useRef({})
const toolRefsRef = useRef<Record<string, HTMLDivElement | null>>({})
return (
<div className={cn('max-w-full p-1', className)}>
@ -98,21 +101,23 @@ const Tools = ({
isFlatView
? (
<ToolListFlatView
toolRefs={toolRefs}
toolRefs={toolRefsRef}
letters={letters}
payload={listViewToolData}
previewCardHandle={previewCardHandle}
isShowLetterIndex={isShowLetterIndex}
hasSearchText={hasSearchText}
onSelect={onSelect}
canNotSelectMultiple={canNotSelectMultiple}
onSelectMultiple={onSelectMultiple}
selectedTools={selectedTools}
indexBar={<IndexBar letters={letters} itemRefs={toolRefs} className={indexBarClassName} />}
indexBar={<IndexBar letters={letters} itemRefs={toolRefsRef} className={indexBarClassName} />}
/>
)
: (
<ToolListTreeView
payload={treeViewToolsData}
previewCardHandle={previewCardHandle}
hasSearchText={hasSearchText}
onSelect={onSelect}
canNotSelectMultiple={canNotSelectMultiple}
@ -121,6 +126,11 @@ const Tools = ({
/>
)
)}
<PreviewCard handle={previewCardHandle}>
{({ payload }) => (
<ToolActionPreviewCard payload={payload as ToolActionPreviewPayload | undefined} />
)}
</PreviewCard>
</div>
)
}

View File

@ -1,9 +1,9 @@
'use client'
import type { FC } from 'react'
import type { ComponentProps, FC } from 'react'
import type { TriggerDefaultValue, TriggerWithProvider } from '../types'
import type { Event } from '@/app/components/tools/types'
import { cn } from '@langgenius/dify-ui/cn'
import { PreviewCard, PreviewCardContent, PreviewCardTrigger } from '@langgenius/dify-ui/preview-card'
import { PreviewCardContent, PreviewCardTrigger } from '@langgenius/dify-ui/preview-card'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { useGetLanguage } from '@/context/i18n'
@ -13,14 +13,25 @@ import { BlockEnum } from '../../types'
type Props = {
provider: TriggerWithProvider
payload: Event
previewCardHandle: TriggerPluginActionPreviewCardHandle
disabled?: boolean
isAdded?: boolean
onSelect: (type: BlockEnum, trigger?: TriggerDefaultValue) => void
}
export type TriggerPluginActionPreviewPayload = {
provider: TriggerWithProvider
payload: Event
language: ReturnType<typeof useGetLanguage>
}
type PreviewCardHandle = NonNullable<ComponentProps<typeof PreviewCardTrigger>['handle']>
export type TriggerPluginActionPreviewCardHandle = PreviewCardHandle
const TriggerPluginActionItem: FC<Props> = ({
provider,
payload,
previewCardHandle,
onSelect,
disabled,
isAdded,
@ -37,7 +48,7 @@ const TriggerPluginActionItem: FC<Props> = ({
return
const params: Record<string, string> = {}
if (payload.parameters) {
payload.parameters.forEach((item: any) => {
payload.parameters.forEach((item) => {
params[item.name] = ''
})
}
@ -73,21 +84,41 @@ const TriggerPluginActionItem: FC<Props> = ({
// reachable from the node inspector after the row is clicked to add the trigger,
// so hover/focus-only activation is a11y-safe. See
// packages/dify-ui/AGENTS.md → Overlay Primitive Selection.
<PreviewCard key={payload.name}>
<PreviewCardTrigger delay={150} closeDelay={150} render={row} />
<PreviewCardContent placement="right" popupClassName="w-[224px] px-3 py-2.5">
<div>
<BlockIcon
size="md"
className="mb-2"
type={BlockEnum.TriggerPlugin}
toolIcon={provider.icon}
/>
<div className="mb-1 text-sm leading-5 text-text-primary">{payload.label[language]}</div>
<div className="text-xs leading-[18px] text-text-secondary">{payload.description[language]}</div>
</div>
</PreviewCardContent>
</PreviewCard>
<PreviewCardTrigger
key={payload.name}
delay={150}
closeDelay={150}
handle={previewCardHandle}
payload={{ provider, payload, language }}
render={row}
/>
)
}
type TriggerPluginActionPreviewCardProps = {
payload?: TriggerPluginActionPreviewPayload
}
export function TriggerPluginActionPreviewCard({
payload,
}: TriggerPluginActionPreviewCardProps) {
if (!payload)
return null
return (
<PreviewCardContent placement="right" popupClassName="w-[224px] px-3 py-2.5">
<div>
<BlockIcon
size="md"
className="mb-2"
type={BlockEnum.TriggerPlugin}
toolIcon={payload.provider.icon}
/>
<div className="mb-1 text-sm leading-5 text-text-primary">{payload.payload.label[payload.language]}</div>
<div className="text-xs leading-[18px] wrap-break-word text-text-secondary">{payload.payload.description[payload.language]}</div>
</div>
</PreviewCardContent>
)
}
export default React.memo(TriggerPluginActionItem)

View File

@ -1,10 +1,11 @@
'use client'
import type { FC } from 'react'
import type { TriggerPluginActionPreviewCardHandle } from './action-item'
import type { TriggerDefaultValue, TriggerWithProvider } from '@/app/components/workflow/block-selector/types'
import { cn } from '@langgenius/dify-ui/cn'
import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react'
import * as React from 'react'
import { useEffect, useMemo, useRef } from 'react'
import { useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { CollectionType } from '@/app/components/tools/types'
import BlockIcon from '@/app/components/workflow/block-icon'
@ -27,6 +28,7 @@ type Props = {
className?: string
payload: TriggerWithProvider
hasSearchText: boolean
previewCardHandle: TriggerPluginActionPreviewCardHandle
onSelect: (type: BlockEnum, trigger?: TriggerDefaultValue) => void
}
@ -34,6 +36,7 @@ const TriggerPluginItem: FC<Props> = ({
className,
payload,
hasSearchText,
previewCardHandle,
onSelect,
}) => {
const { t } = useTranslation()
@ -42,17 +45,14 @@ const TriggerPluginItem: FC<Props> = ({
const notShowProvider = payload.type === CollectionType.workflow
const actions = payload.events
const hasAction = !notShowProvider
const [isFold, setFold] = React.useState<boolean>(true)
const [isFold, setIsFold] = React.useState<boolean>(true)
const [isFoldHasSearchText, setIsFoldHasSearchText] = React.useState(hasSearchText)
const ref = useRef(null)
useEffect(() => {
if (hasSearchText && isFold) {
setFold(false)
return
}
if (!hasSearchText && !isFold)
setFold(true)
}, [hasSearchText])
if (isFoldHasSearchText !== hasSearchText) {
setIsFoldHasSearchText(hasSearchText)
setIsFold(!hasSearchText)
}
const FoldIcon = isFold ? RiArrowRightSLine : RiArrowDownSLine
@ -97,14 +97,14 @@ const TriggerPluginItem: FC<Props> = ({
className="group/item flex w-full cursor-pointer items-center justify-between rounded-lg pr-1 pl-3 select-none hover:bg-state-base-hover"
onClick={() => {
if (hasAction) {
setFold(!isFold)
setIsFold(!isFold)
return
}
const event = actions[0]
const params: Record<string, string> = {}
if (event!.parameters) {
event!.parameters.forEach((item: any) => {
event!.parameters.forEach((item) => {
params[item.name] = ''
})
}
@ -150,6 +150,7 @@ const TriggerPluginItem: FC<Props> = ({
key={action.name}
provider={providerWithResolvedIcon}
payload={action}
previewCardHandle={previewCardHandle}
onSelect={onSelect}
disabled={false}
isAdded={false}

View File

@ -1,9 +1,12 @@
'use client'
import type { BlockEnum } from '../../types'
import type { TriggerDefaultValue, TriggerWithProvider } from '../types'
import type { TriggerPluginActionPreviewPayload } from './action-item'
import { createPreviewCardHandle, PreviewCard } from '@langgenius/dify-ui/preview-card'
import { memo, useEffect, useMemo } from 'react'
import { useGetLanguage } from '@/context/i18n'
import { useAllTriggerPlugins } from '@/service/use-triggers'
import { TriggerPluginActionPreviewCard } from './action-item'
import TriggerPluginItem from './item'
type TriggerPluginListProps = {
@ -20,6 +23,7 @@ const TriggerPluginList = ({
}: TriggerPluginListProps) => {
const { data: triggerPluginsData } = useAllTriggerPlugins()
const language = useGetLanguage()
const previewCardHandle = useMemo(() => createPreviewCardHandle<TriggerPluginActionPreviewPayload>(), [])
const normalizedSearch = searchText.trim().toLowerCase()
const triggerPlugins = useMemo(() => {
@ -96,8 +100,14 @@ const TriggerPluginList = ({
payload={plugin}
onSelect={onSelect}
hasSearchText={!!searchText}
previewCardHandle={previewCardHandle}
/>
))}
<PreviewCard handle={previewCardHandle}>
{({ payload }) => (
<TriggerPluginActionPreviewCard payload={payload as TriggerPluginActionPreviewPayload | undefined} />
)}
</PreviewCard>
</div>
)
}