refactor(web): improve workflow block selector (#39377)

This commit is contained in:
yyh 2026-07-21 22:18:38 +08:00 committed by GitHub
parent 45ac70132c
commit b95d313128
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
204 changed files with 4599 additions and 3621 deletions

View File

@ -23,7 +23,7 @@ import { expectProviderToolActionVisible, getCurrentAgentId } from './configure-
const getToolsSection = (world: DifyWorld) => world.getPage().getByRole('region', { name: 'Tools' })
const getToolSelectorSearch = (world: DifyWorld) =>
world.getPage().getByRole('textbox', { name: 'Search integrations...' })
world.getPage().getByRole('searchbox', { name: 'Search integrations...' })
const jsonReplaceRuntimePrompt = [
'You are a Dify Agent E2E JSON tool verifier.',

View File

@ -394,14 +394,6 @@
"count": 1
}
},
"web/app/components/app/configuration/base/operation-btn/index.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
},
"jsx_a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/app/configuration/config-prompt/__tests__/index.spec.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
@ -3282,11 +3274,6 @@
"count": 1
}
},
"web/app/components/plugins/marketplace/search-box/tags-filter.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/plugins/plugin-auth/authorized/index.tsx": {
"no-restricted-imports": {
"count": 1
@ -3382,14 +3369,6 @@
"count": 1
}
},
"web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/index.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
},
"jsx_a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/plugins/plugin-detail-panel/strategy-detail.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
@ -3466,43 +3445,6 @@
"count": 1
}
},
"web/app/components/plugins/plugin-detail-panel/tool-selector/components/index.ts": {
"no-barrel-files/no-barrel-files": {
"count": 6
}
},
"web/app/components/plugins/plugin-detail-panel/tool-selector/components/reasoning-config-form.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
},
"jsx_a11y/no-static-element-interactions": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-base-form.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-item.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 3
},
"jsx_a11y/mouse-events-have-key-events": {
"count": 1
},
"jsx_a11y/no-static-element-interactions": {
"count": 3
}
},
"web/app/components/plugins/plugin-detail-panel/tool-selector/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/plugins/plugin-detail-panel/trigger/event-detail-drawer.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
@ -3970,14 +3912,6 @@
"count": 3
}
},
"web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-option.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
},
"jsx_a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/workflow-app/hooks/index.ts": {
"no-barrel-files/no-barrel-files": {
"count": 13
@ -4018,69 +3952,16 @@
"count": 2
}
},
"web/app/components/workflow/block-selector/all-start-blocks.tsx": {
"eslint-react/set-state-in-effect": {
"count": 1
}
},
"web/app/components/workflow/block-selector/constants.tsx": {
"react/only-export-components": {
"count": 3
}
},
"web/app/components/workflow/block-selector/index-bar.tsx": {
"react/only-export-components": {
"count": 1
}
},
"web/app/components/workflow/block-selector/market-place-plugin/list.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
},
"jsx_a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/workflow/block-selector/rag-tool-recommendations/uninstalled-item.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
},
"jsx_a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/workflow/block-selector/tool-picker.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/block-selector/tool/tool.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 2
},
"jsx_a11y/no-static-element-interactions": {
"count": 2
}
},
"web/app/components/workflow/block-selector/use-sticky-scroll.ts": {
"erasable-syntax-only/enums": {
"count": 1
}
},
"web/app/components/workflow/block-selector/view-type-select.tsx": {
"erasable-syntax-only/enums": {
"count": 1
},
"jsx_a11y/click-events-have-key-events": {
"count": 2
},
"jsx_a11y/no-static-element-interactions": {
"count": 2
},
"react/only-export-components": {
"count": 1
}
},
"web/app/components/workflow/comment/comment-input.tsx": {
"jsx_a11y/no-autofocus": {
"count": 1
@ -4152,14 +4033,6 @@
"count": 1
}
},
"web/app/components/workflow/header/view-workflow-history.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 3
},
"jsx_a11y/no-static-element-interactions": {
"count": 3
}
},
"web/app/components/workflow/hooks-store/index.ts": {
"no-barrel-files/no-barrel-files": {
"count": 2
@ -5329,11 +5202,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/tool/components/tool-form/item.tsx": {
"typescript/no-explicit-any": {
"count": 1
}
},
"web/app/components/workflow/nodes/tool/default.ts": {
"typescript/no-explicit-any": {
"count": 6
@ -5372,11 +5240,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/trigger-plugin/components/trigger-form/item.tsx": {
"typescript/no-explicit-any": {
"count": 1
}
},
"web/app/components/workflow/nodes/trigger-plugin/default.ts": {
"typescript/no-explicit-any": {
"count": 10

View File

@ -21,11 +21,11 @@ Shared design tokens, the `cn()` utility, CSS-first Tailwind styles, and headles
Pick by the **trigger's purpose** and **a11y reach**, not visual richness.
| Primitive | Opens on | Trigger's purpose | Content | Reachable on touch / SR? |
| ------------- | --------------------- | -------------------------- | ------------------------- | ------------------------ |
| `Tooltip` | hover / focus | has its own action | short plain-text label | ❌ (label only) |
| `PreviewCard` | hover / focus | has a primary click target | supplementary preview | ❌ (via click target) |
| `Popover` | click / tap (+ hover) | **to open the popup** | anything, incl. long text | ✅ |
| Primitive | Opens on | Trigger's purpose | Content | Reachable on touch / SR? |
| ------------- | --------------------- | --------------------- | ------------------------- | ------------------------ |
| `Tooltip` | hover / focus | has its own action | short plain-text label | ❌ (label only) |
| `PreviewCard` | hover / focus | navigate through link | link destination preview | ❌ (visual enhancement) |
| `Popover` | click / tap (+ hover) | **open the popup** | anything, incl. long text | ✅ |
Base UI decision rule ([docs]):
@ -35,9 +35,11 @@ Base UI decision rule ([docs]):
Apply this first, then narrow:
- `Tooltip` — ephemeral visual label. Trigger must already carry its own `aria-label` / visible text; tooltip mirrors it for sighted mouse/keyboard users. No interactive UI, no multi-line prose. Not dwell-able.
- `PreviewCard`hover-revealed rich supplementary preview anchored to a trigger whose click goes somewhere (link, selectable row, jumpable chip). **Hard contract:** the popup MUST NOT contain information or actions unreachable from the trigger's click destination — touch and SR users can't open it. If the info is unique to the popup, switch to `Popover` (click or `openOnHover`) or move it to the click destination. Do not hand-roll "hover to open" on top of `Popover` to evade this split.
- `PreviewCard`a visual enhancement for a link that previews its destination. Prefer the canonical anchor trigger and keep the popup non-interactive. Do not place unique or essential information or actions in the popup unless they are also available at the linked destination; touch and screen reader users cannot access the preview. If opening the popup is itself the trigger's purpose, or its content must be accessible across input modes, use `Popover` instead.
- `Popover` — any popup with its own interactions, or any "infotip" (`?` / `(i)` glyph whose sole purpose is to reveal help text). Pass `openOnHover` on `PopoverTrigger` for the infotip case — unlike `Tooltip` / `PreviewCard`, this stays accessible to touch and SR users because the popover still opens on tap and focus.
Product-level polymorphic trigger compositions are local feature decisions. Document them in the owning feature and do not broaden or weaken the shared primitive contract to match one business workflow.
## Border Radius: Figma Token → Tailwind Class Mapping
The Figma design system uses `--radius/*` tokens whose scale is **offset by one step** from Tailwind CSS v4 defaults. When translating Figma specs to code, always use this mapping — never use `radius-*` as a CSS class, and never extend `borderRadius` in the preset.

View File

@ -179,9 +179,10 @@ See `[web/docs/overlay.md](../../web/docs/overlay.md)` for the web app overlay b
- 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
### Tooltip, preview card, 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 `PreviewCard` as a visual enhancement for a link that previews its destination. Its popup must remain non-interactive and must not contain unique or essential information unless that information is also available at the linked destination. Use `Popover` when opening the popup is the trigger's purpose or when users need to access its content on touch or with assistive technology.
- 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}`.

View File

@ -17,7 +17,7 @@ const meta = {
docs: {
description: {
component:
'Hover- and focus-activated rich link preview built on Base UI PreviewCard.\n\n**A11y contract:** touch and screen-reader users cannot open the preview. Keep popup content available on the link destination. A polymorphic action trigger is a Dify application-level extension and is only valid when its click result exposes the same information.',
"A visual enhancement for a link that previews its destination, built on Base UI PreviewCard. The popup is unavailable to touch and screen-reader users, so it must remain non-interactive and must not contain unique or essential information unless that information is also available at the linked destination. Use Popover when opening the popup is the trigger's purpose or when its content must be accessible across input modes.",
},
},
},

View File

@ -10,19 +10,16 @@ import { parsePlacement } from '../placement'
export type { Placement }
/**
* PreviewCard is a hover/focus-triggered rich preview intended to supplement a
* link. Base UI's canonical trigger renders an anchor.
* PreviewCard follows Base UI's canonical semantics: a hover/focus-triggered
* visual enhancement for a link that previews its destination.
*
* A11y contract match Base UI's guidance:
* - The popup MUST NOT contain information or actions that are not also
* reachable from the link destination. Touch and screen reader users cannot
* open the card and must be able to get the same information/actions without
* it.
* - A polymorphic action trigger is an application-level extension and is only
* valid when its primary click result exposes the same information.
* - If content is unique to the popup, either (a) add a separate click-triggered
* affordance (Popover) next to the trigger, or (b) move the unique content
* onto the click destination.
* Contract:
* - Prefer the canonical anchor trigger and keep the popup non-interactive.
* - Do not place unique or essential information or actions in the popup unless
* they are also available at the linked destination.
* - Touch and screen reader users cannot access the preview. Use Popover when
* opening the popup is itself the trigger's purpose or its content must be
* accessible across input modes.
*/
export const PreviewCard = BasePreviewCard.Root
export const PreviewCardTrigger = BasePreviewCard.Trigger

View File

@ -1,7 +1,6 @@
import type { App } from '@/types/app'
import { screen, waitFor } from '@testing-library/react'
import { useStore } from '@/app/components/app/store'
import { usePathname, useRouter } from '@/next/navigation'
import { fetchAppDetailDirect } from '@/service/apps'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import { AppModeEnum } from '@/types/app'
@ -18,6 +17,10 @@ const mockConsoleState = vi.hoisted(() => ({
userProfile: { id: 'user-1' },
workspacePermissionKeys: [] as string[],
}))
const mockNavigation = vi.hoisted(() => ({
usePathname: vi.fn(),
useRouter: vi.fn(),
}))
const render = (ui: Parameters<typeof renderWithConsoleQuery>[0]) =>
renderWithConsoleQuery(ui, {
@ -26,10 +29,7 @@ const render = (ui: Parameters<typeof renderWithConsoleQuery>[0]) =>
},
})
vi.mock('@/next/navigation', () => ({
usePathname: vi.fn(),
useRouter: vi.fn(),
}))
vi.mock('@/next/navigation', () => mockNavigation)
vi.mock('@/service/apps', () => ({
fetchAppDetailDirect: vi.fn(),
@ -52,8 +52,8 @@ vi.mock('@/hooks/use-document-title', () => ({
default: vi.fn(),
}))
const mockUsePathname = vi.mocked(usePathname)
const mockUseRouter = vi.mocked(useRouter)
const mockUsePathname = mockNavigation.usePathname
const mockUseRouter = mockNavigation.useRouter
const mockFetchAppDetailDirect = vi.mocked(fetchAppDetailDirect)
const createAppDetail = (overrides: Partial<App> = {}) =>
@ -83,12 +83,7 @@ describe('AppDetailLayout', () => {
mockConsoleState.workspacePermissionKeys = []
mockUsePathname.mockImplementation(() => mockPathname)
mockUseRouter.mockReturnValue({
back: vi.fn(),
forward: vi.fn(),
refresh: vi.fn(),
push: vi.fn(),
replace: mockReplace,
prefetch: vi.fn(),
})
mockFetchAppDetailDirect.mockResolvedValue(createAppDetail())
useStore.getState().setAppDetail()

View File

@ -1,5 +1,4 @@
import { screen, waitFor } from '@testing-library/react'
import { usePathname, useRouter } from '@/next/navigation'
import { useDatasetDetail } from '@/service/knowledge/use-dataset'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import { DatasetACLPermission } from '@/utils/permission'
@ -7,6 +6,10 @@ import DatasetDetailLayout from '../layout-main'
const mockReplace = vi.fn()
let mockIsRbacEnabled = true
const mockNavigation = vi.hoisted(() => ({
usePathname: vi.fn(),
useRouter: vi.fn(),
}))
const render = (ui: Parameters<typeof renderWithConsoleQuery>[0]) =>
renderWithConsoleQuery(ui, {
@ -15,10 +18,7 @@ const render = (ui: Parameters<typeof renderWithConsoleQuery>[0]) =>
},
})
vi.mock('@/next/navigation', () => ({
usePathname: vi.fn(),
useRouter: vi.fn(),
}))
vi.mock('@/next/navigation', () => mockNavigation)
vi.mock('@/service/knowledge/use-dataset', () => ({
useDatasetDetail: vi.fn(),
@ -61,8 +61,8 @@ vi.mock('@/hooks/use-document-title', () => ({
default: vi.fn(),
}))
const mockUseRouter = vi.mocked(useRouter)
const mockUsePathname = vi.mocked(usePathname)
const mockUseRouter = mockNavigation.useRouter
const mockUsePathname = mockNavigation.usePathname
const mockUseDatasetDetail = vi.mocked(useDatasetDetail)
describe('DatasetDetailLayout', () => {
@ -71,12 +71,7 @@ describe('DatasetDetailLayout', () => {
mockIsRbacEnabled = true
mockUsePathname.mockReturnValue('/datasets/dataset-1/documents')
mockUseRouter.mockReturnValue({
back: vi.fn(),
forward: vi.fn(),
refresh: vi.fn(),
push: vi.fn(),
replace: mockReplace,
prefetch: vi.fn(),
})
})

View File

@ -1,38 +0,0 @@
'use client'
import type { FC } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { RiAddLine, RiEditLine } from '@remixicon/react'
import { noop } from 'es-toolkit/function'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
type IOperationBtnProps = {
className?: string
type: 'add' | 'edit'
actionName?: string
onClick?: () => void
}
const iconMap = {
add: <RiAddLine className="size-3.5" />,
edit: <RiEditLine className="size-3.5" />,
}
const OperationBtn: FC<IOperationBtnProps> = ({ className, type, actionName, onClick = noop }) => {
const { t } = useTranslation()
return (
<div
className={cn(
'flex h-7 cursor-pointer items-center space-x-1 rounded-md px-3 text-text-secondary select-none hover:bg-state-base-hover',
className,
)}
onClick={onClick}
>
<div>{iconMap[type]}</div>
<div className="text-xs font-medium">
{actionName || t(($) => $[`operation.${type}`], { ns: 'common' })}
</div>
</div>
)
}
export default React.memo(OperationBtn)

View File

@ -0,0 +1,35 @@
'use client'
import type { ButtonProps } from '@langgenius/dify-ui/button'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
type OperationButtonProps = Omit<ButtonProps, 'children' | 'size' | 'variant'> & {
operation: 'add' | 'edit'
actionName?: string
}
export function OperationButton({
operation,
actionName,
className,
...buttonProps
}: OperationButtonProps) {
const { t } = useTranslation()
return (
<Button
{...buttonProps}
variant="ghost"
size="small"
className={cn('h-7 gap-1 px-3 text-text-secondary', className)}
>
<span
aria-hidden
className={cn('size-3.5', operation === 'add' ? 'i-ri-add-line' : 'i-ri-edit-line')}
/>
<span className="text-xs font-medium">
{actionName || t(($) => $[`operation.${operation}`], { ns: 'common' })}
</span>
</Button>
)
}

View File

@ -3,7 +3,7 @@ import type { FC } from 'react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Panel from '@/app/components/app/configuration/base/feature-panel'
import OperationBtn from '@/app/components/app/configuration/base/operation-btn'
import { OperationButton } from '@/app/components/app/configuration/base/operation-button'
import { MessageClockCircle } from '@/app/components/base/icons/src/vender/solid/general'
type Props = Readonly<{
@ -32,7 +32,7 @@ const HistoryPanel: FC<Props> = ({ showWarning, onShowEditModal }) => {
{t(($) => $['feature.conversationHistory.description'], { ns: 'appDebug' })}
</div>
<div className="ml-3 h-[14px] w-px bg-divider-regular"></div>
<OperationBtn type="edit" onClick={onShowEditModal} />
<OperationButton operation="edit" onClick={onShowEditModal} />
</div>
}
noBodySpacing

View File

@ -9,7 +9,7 @@ import {
} from '@langgenius/dify-ui/dropdown-menu'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import OperationBtn from '@/app/components/app/configuration/base/operation-btn'
import { OperationButton } from '@/app/components/app/configuration/base/operation-button'
import { ApiConnection } from '@/app/components/base/icons/src/vender/solid/development'
import InputVarTypeIcon from '@/app/components/workflow/nodes/_base/components/input-var-type-icon'
import { InputVarType } from '@/app/components/workflow/types'
@ -50,9 +50,7 @@ const SelectVarType: FC<Props> = ({ onChange }) => {
}
return (
<DropdownMenu>
<DropdownMenuTrigger nativeButton={false} render={<div className="block" />}>
<OperationBtn type="add" />
</DropdownMenuTrigger>
<DropdownMenuTrigger render={<OperationButton operation="add" />} />
<DropdownMenuContent
placement="bottom-end"
sideOffset={8}

View File

@ -17,7 +17,7 @@ import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import Panel from '@/app/components/app/configuration/base/feature-panel'
import OperationBtn from '@/app/components/app/configuration/base/operation-btn'
import { OperationButton } from '@/app/components/app/configuration/base/operation-button'
import AppIcon from '@/app/components/base/app-icon'
import { DefaultToolIcon } from '@/app/components/base/icons/src/public/other'
import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
@ -190,7 +190,7 @@ const AgentTools: FC = () => {
<>
<div className="mr-1 ml-3 h-3.5 w-px bg-divider-regular"></div>
<ToolPicker
trigger={<OperationBtn type="add" />}
trigger={<OperationButton operation="add" />}
isShow={isShowChooseTool}
onShowChange={setIsShowChooseTool}
disabled={false}

View File

@ -35,7 +35,7 @@ import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { AppModeEnum } from '@/types/app'
import { getDatasetACLCapabilities } from '@/utils/permission'
import FeaturePanel from '../base/feature-panel'
import OperationBtn from '../base/operation-btn'
import { OperationButton } from '../base/operation-button'
import { useFormattingChangedDispatcher } from '../debug/hooks'
import CardItem from './card-item'
import ContextVar from './context-var'
@ -294,7 +294,7 @@ const DatasetConfig: FC<Props> = ({ readonly, hideMetadataFilter }) => {
!readonly && (
<div className="flex items-center gap-1">
{!isAgent && <ParamsConfig disabled={!hasData} selectedDatasets={dataSet} />}
<OperationBtn type="add" onClick={showSelectDataSet} />
<OperationButton operation="add" onClick={showSelectDataSet} />
</div>
)
}

View File

@ -10,7 +10,6 @@ import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/con
import AccountSection from '@/app/components/main-nav/components/account-section'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { useRouter } from '@/next/navigation'
import { useLogout } from '@/service/use-common'
import { createAccountProfileQueryClient } from '@/test/console/account-profile'
import { renderWithConsoleQuery } from '@/test/console/query-data'
@ -55,6 +54,7 @@ const mockConsoleState = vi.hoisted(() => ({
current: undefined as ConsoleStateFixture | undefined,
}))
const mockConsoleStateReader = vi.hoisted(() => vi.fn())
const mockUseRouter = vi.hoisted(() => vi.fn())
vi.mock('next-themes', () => ({
useTheme: () => ({
@ -97,7 +97,7 @@ vi.mock('@/next/navigation', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/next/navigation')>()
return {
...actual,
useRouter: vi.fn(),
useRouter: mockUseRouter,
}
})
@ -223,13 +223,8 @@ describe('AccountDropdown', () => {
vi.mocked(useLogout).mockReturnValue({
mutateAsync: mockLogout,
} as unknown as ReturnType<typeof useLogout>)
vi.mocked(useRouter).mockReturnValue({
mockUseRouter.mockReturnValue({
push: mockPush,
replace: vi.fn(),
prefetch: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
refresh: vi.fn(),
})
})

View File

@ -28,12 +28,18 @@ describe('SearchBox', () => {
const user = userEvent.setup()
render(<SearchHarness usedInMarketplace={mode} />)
const input = screen.getByPlaceholderText('Search plugins')
const input = screen.getByRole('searchbox', { name: 'Search plugins' })
await user.type(input, 'agent')
expect(input).toHaveValue('agent')
await user.click(screen.getByRole('button'))
await user.tab()
const clearButton = screen.getByRole('button', {
name: /^plugin\.clearSearch/,
})
expect(clearButton).toHaveFocus()
await user.keyboard('{Enter}')
expect(input).toHaveValue('')
expect(input).toHaveFocus()
})
it('opens the custom tool flow from the add button', async () => {
@ -52,7 +58,9 @@ describe('SearchBox', () => {
/>,
)
await user.click(screen.getByRole('button'))
const addButton = screen.getByRole('button', { name: 'tools.addToolModal.custom.tip' })
addButton.focus()
await user.keyboard('{Enter}')
expect(onShowAddCustomCollectionModal).toHaveBeenCalledOnce()
})
@ -71,6 +79,6 @@ describe('SearchBox', () => {
/>,
)
expect(ref.current).toBe(screen.getByPlaceholderText('Search plugins'))
expect(ref.current).toBe(screen.getByRole('searchbox', { name: 'Search plugins' }))
})
})

View File

@ -1,4 +1,5 @@
import { fireEvent, render, screen, within } from '@testing-library/react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import TagsFilter from '../tags-filter'
@ -32,51 +33,14 @@ vi.mock('@/app/components/plugins/hooks', () => ({
}),
}))
vi.mock('@/app/components/base/input', () => ({
default: ({
value,
onChange,
placeholder,
}: {
value: string
onChange: (event: { target: { value: string } }) => void
placeholder: string
}) => (
<input
aria-label="tags-search"
value={value}
placeholder={placeholder}
onChange={(event) => onChange({ target: { value: event.target.value } })}
/>
),
}))
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
vi.mock('../trigger/marketplace', () => ({
default: ({ selectedTagsLength }: { selectedTagsLength: number }) => (
<div data-testid="marketplace-trigger">
marketplace:
{selectedTagsLength}
</div>
),
}))
vi.mock('../trigger/tool-selector', () => ({
default: ({ selectedTagsLength }: { selectedTagsLength: number }) => (
<div data-testid="tool-trigger">
tool:
{selectedTagsLength}
</div>
),
}))
describe('TagsFilter', () => {
const ensurePopoverOpen = () => {
if (!screen.queryByTestId('popover-content'))
fireEvent.click(screen.getByTestId('popover-trigger'))
const ensurePopoverOpen = async (user: ReturnType<typeof userEvent.setup>) => {
if (!screen.queryByRole('searchbox', { name: 'pluginTags.searchTags' }))
await user.click(screen.getByRole('button', { name: 'pluginTags.allTags' }))
return screen.getByTestId('popover-content')
return screen.getByRole('searchbox', { name: 'pluginTags.searchTags' })
}
beforeEach(() => {
@ -86,53 +50,43 @@ describe('TagsFilter', () => {
)
})
it('renders marketplace trigger when used in marketplace', () => {
render(<TagsFilter tags={['agent']} onTagsChange={vi.fn()} usedInMarketplace />)
expect(screen.getByTestId('marketplace-trigger')).toHaveTextContent('marketplace:1')
expect(screen.queryByTestId('tool-trigger')).not.toBeInTheDocument()
})
it('renders tool selector trigger when used outside marketplace', () => {
render(<TagsFilter tags={['agent']} onTagsChange={vi.fn()} />)
expect(screen.getByTestId('tool-trigger')).toHaveTextContent('tool:1')
expect(screen.queryByTestId('marketplace-trigger')).not.toBeInTheDocument()
})
it('filters tag options by search text', () => {
it('filters tag options by search text', async () => {
const user = userEvent.setup()
render(<TagsFilter tags={[]} onTagsChange={vi.fn()} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
const search = await ensurePopoverOpen(user)
expect(screen.getByText('Agent')).toBeInTheDocument()
expect(screen.getByText('RAG')).toBeInTheDocument()
expect(screen.getByText('Search')).toBeInTheDocument()
expect(screen.getByRole('checkbox', { name: 'Agent' })).toBeInTheDocument()
expect(screen.getByRole('checkbox', { name: 'RAG' })).toBeInTheDocument()
expect(screen.getByRole('checkbox', { name: 'Search' })).toBeInTheDocument()
fireEvent.change(screen.getByLabelText('tags-search'), { target: { value: 'ra' } })
await user.type(search, 'ra')
expect(screen.queryByText('Agent')).not.toBeInTheDocument()
expect(screen.getByText('RAG')).toBeInTheDocument()
expect(screen.queryByText('Search')).not.toBeInTheDocument()
expect(screen.queryByRole('checkbox', { name: 'Agent' })).not.toBeInTheDocument()
expect(screen.getByRole('checkbox', { name: 'RAG' })).toBeInTheDocument()
expect(screen.queryByRole('checkbox', { name: 'Search' })).not.toBeInTheDocument()
})
it('adds and removes selected tags when options are clicked', () => {
it('adds and removes selected tags when options are clicked', async () => {
const user = userEvent.setup()
const onTagsChange = vi.fn()
const { rerender } = render(<TagsFilter tags={['agent']} onTagsChange={onTagsChange} />)
fireEvent.click(within(ensurePopoverOpen()).getByText('Agent'))
await user.click(screen.getByRole('button', { name: 'Agent' }))
await user.click(screen.getByRole('checkbox', { name: 'Agent' }))
expect(onTagsChange).toHaveBeenCalledWith([])
rerender(<TagsFilter tags={['agent']} onTagsChange={onTagsChange} />)
fireEvent.click(within(ensurePopoverOpen()).getByText('RAG'))
await user.click(screen.getByRole('checkbox', { name: 'RAG' }))
expect(onTagsChange).toHaveBeenCalledWith(['agent', 'rag'])
})
it('falls back to an empty placeholder when translation is missing', () => {
it('falls back to an empty placeholder when translation is missing', async () => {
const user = userEvent.setup()
mockTranslate.mockImplementation(() => undefined as unknown as string)
render(<TagsFilter tags={[]} onTagsChange={vi.fn()} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
await user.click(screen.getByRole('button'))
expect(screen.getByLabelText('tags-search')).toHaveAttribute('placeholder', '')
expect(screen.getByRole('searchbox')).toHaveAttribute('placeholder', '')
})
})

View File

@ -1,8 +1,9 @@
'use client'
import type { Ref } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { RiAddLine, RiCloseLine, RiSearchLine } from '@remixicon/react'
import ActionButton from '@/app/components/base/action-button'
import { useImperativeHandle, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import TagsFilter from './tags-filter'
@ -20,11 +21,10 @@ type SearchBoxProps = {
supportAddCustomTool?: boolean
usedInMarketplace?: boolean
onShowAddCustomCollectionModal?: () => void
onAddedCustomTool?: () => void
autoFocus?: boolean
showTags?: boolean
}
const SearchBox = ({
function SearchBox({
ref,
search,
onSearchChange,
@ -40,7 +40,17 @@ const SearchBox = ({
onShowAddCustomCollectionModal,
autoFocus = false,
showTags = true,
}: SearchBoxProps) => {
}: SearchBoxProps) {
const { t } = useTranslation()
const accessibleLabel = placeholder || t(($) => $.searchTools, { ns: 'plugin' })!
const inputRef = useRef<HTMLInputElement>(null)
useImperativeHandle(ref, () => inputRef.current as HTMLInputElement, [])
const handleClear = () => {
onSearchChange('')
inputRef.current?.focus()
}
return (
<div className={cn('z-11 flex items-center', wrapperClassName)}>
<div
@ -63,10 +73,13 @@ const SearchBox = ({
)}
<div className="flex grow items-center gap-x-2 p-1">
<input
ref={ref}
aria-label={placeholder || undefined}
ref={inputRef}
type="search"
name="query"
autoComplete="off"
aria-label={accessibleLabel}
className={cn(
'inline-block grow appearance-none bg-transparent body-md-medium text-text-secondary outline-hidden',
'inline-block grow appearance-none bg-transparent body-md-medium text-text-secondary outline-hidden [&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none',
inputElementClassName,
)}
value={search}
@ -76,9 +89,18 @@ const SearchBox = ({
placeholder={placeholder}
/>
{search && (
<ActionButton onClick={() => onSearchChange('')} className="shrink-0">
<RiCloseLine className="size-4" />
</ActionButton>
<Button
variant="ghost"
size="small"
aria-label={t(($) => $.clearSearch, {
ns: 'plugin',
label: accessibleLabel,
})}
onClick={handleClear}
className="size-6 min-h-0 shrink-0 p-0 focus-visible:ring-inset"
>
<span className="i-ri-close-line size-4" aria-hidden />
</Button>
)}
</div>
</>
@ -86,16 +108,24 @@ const SearchBox = ({
{!usedInMarketplace && (
<>
<div className="flex h-8 min-w-0 grow items-center pr-2 pl-2">
<RiSearchLine
className={cn('size-4 text-components-input-text-placeholder', searchIconClassName)}
<span
aria-hidden
className={cn(
'i-ri-search-line',
'size-4 text-components-input-text-placeholder',
searchIconClassName,
)}
/>
<input
ref={ref}
aria-label={placeholder || undefined}
ref={inputRef}
type="search"
name="query"
autoComplete="off"
aria-label={accessibleLabel}
// oxlint-disable-next-line jsx-a11y/no-autofocus
autoFocus={autoFocus}
className={cn(
'mr-1 ml-1.5 inline-block min-w-0 grow appearance-none truncate bg-transparent system-sm-regular text-components-input-text-filled caret-primary-600 outline-hidden placeholder:text-components-input-text-placeholder',
'mr-1 ml-1.5 inline-block min-w-0 grow appearance-none truncate bg-transparent system-sm-regular text-components-input-text-filled caret-primary-600 outline-hidden placeholder:text-components-input-text-placeholder [&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none',
search && 'mr-2',
inputElementClassName,
)}
@ -106,9 +136,18 @@ const SearchBox = ({
placeholder={placeholder}
/>
{search && (
<ActionButton size="xs" onClick={() => onSearchChange('')} className="shrink-0">
<RiCloseLine className="size-4" />
</ActionButton>
<Button
variant="ghost"
size="small"
aria-label={t(($) => $.clearSearch, {
ns: 'plugin',
label: accessibleLabel,
})}
onClick={handleClear}
className="size-6 min-h-0 shrink-0 p-0 focus-visible:ring-inset"
>
<span className="i-ri-close-line size-4" aria-hidden />
</Button>
)}
</div>
{showTags && (
@ -122,12 +161,15 @@ const SearchBox = ({
</div>
{supportAddCustomTool && (
<div className="flex shrink-0 items-center">
<ActionButton
className="ml-2 rounded-full bg-components-button-primary-bg text-components-button-primary-text hover:bg-components-button-primary-bg hover:text-components-button-primary-text"
<Button
variant="primary"
size="small"
aria-label={t(($) => $['addToolModal.custom.tip'], { ns: 'tools' })}
className="ml-2 size-6 min-h-0 rounded-full p-0"
onClick={onShowAddCustomCollectionModal}
>
<RiAddLine className="size-4" />
</ActionButton>
<span className="i-ri-add-line size-4" aria-hidden />
</Button>
</div>
)}
</div>

View File

@ -2,10 +2,10 @@
import { Checkbox } from '@langgenius/dify-ui/checkbox'
import { CheckboxGroup } from '@langgenius/dify-ui/checkbox-group'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { Input } from '@langgenius/dify-ui/input'
import { Popover, PopoverContent } from '@langgenius/dify-ui/popover'
import { useState } from 'react'
import { useTranslation } from '#i18n'
import Input from '@/app/components/base/input'
import { useTags } from '@/app/components/plugins/hooks'
import MarketplaceTrigger from './trigger/marketplace'
import ToolSelectorTrigger from './trigger/tool-selector'
@ -15,7 +15,7 @@ type TagsFilterProps = {
onTagsChange: (tags: string[]) => void
usedInMarketplace?: boolean
}
const TagsFilter = ({ tags, onTagsChange, usedInMarketplace = false }: TagsFilterProps) => {
function TagsFilter({ tags, onTagsChange, usedInMarketplace = false }: TagsFilterProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [searchText, setSearchText] = useState('')
@ -27,31 +27,24 @@ const TagsFilter = ({ tags, onTagsChange, usedInMarketplace = false }: TagsFilte
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
nativeButton={false}
render={
<div className="shrink-0">
{usedInMarketplace && (
<MarketplaceTrigger
selectedTagsLength={selectedTagsLength}
open={open}
tags={tags}
tagsMap={tagsMap}
onTagsChange={onTagsChange}
/>
)}
{!usedInMarketplace && (
<ToolSelectorTrigger
selectedTagsLength={selectedTagsLength}
open={open}
tags={tags}
tagsMap={tagsMap}
onTagsChange={onTagsChange}
/>
)}
</div>
}
/>
{usedInMarketplace && (
<MarketplaceTrigger
selectedTagsLength={selectedTagsLength}
open={open}
tags={tags}
tagsMap={tagsMap}
onTagsChange={onTagsChange}
/>
)}
{!usedInMarketplace && (
<ToolSelectorTrigger
selectedTagsLength={selectedTagsLength}
open={open}
tags={tags}
tagsMap={tagsMap}
onTagsChange={onTagsChange}
/>
)}
<PopoverContent
placement="bottom-start"
sideOffset={4}
@ -60,12 +53,22 @@ const TagsFilter = ({ tags, onTagsChange, usedInMarketplace = false }: TagsFilte
>
<div className="w-[240px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs">
<div className="p-2 pb-1">
<Input
showLeftIcon
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
placeholder={t(($) => $.searchTags, { ns: 'pluginTags' }) || ''}
/>
<div className="relative">
<span
aria-hidden
className="absolute top-1/2 left-2 i-ri-search-line size-4 -translate-y-1/2 text-components-input-text-placeholder"
/>
<Input
type="search"
name="tag-query"
autoComplete="off"
aria-label={t(($) => $.searchTags, { ns: 'pluginTags' }) || ''}
className="pl-6.5"
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
placeholder={t(($) => $.searchTags, { ns: 'pluginTags' }) || ''}
/>
</div>
</div>
<CheckboxGroup
aria-label={t(($) => $.allTags, { ns: 'pluginTags' })}

View File

@ -1,5 +1,8 @@
import type { Tag } from '../../../../hooks'
import { fireEvent, render, screen } from '@testing-library/react'
import { Popover } from '@langgenius/dify-ui/popover'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { describe, expect, it, vi } from 'vitest'
import MarketplaceTrigger from '../marketplace'
@ -11,51 +14,70 @@ const tagsMap: Record<string, Tag> = {
describe('MarketplaceTrigger', () => {
it('shows all-tags text when no tags are selected', () => {
const { container } = render(
<MarketplaceTrigger
selectedTagsLength={0}
open={false}
tags={[]}
tagsMap={tagsMap}
onTagsChange={vi.fn()}
/>,
render(
<Popover>
<MarketplaceTrigger
selectedTagsLength={0}
open={false}
tags={[]}
tagsMap={tagsMap}
onTagsChange={vi.fn()}
/>
</Popover>,
)
expect(screen.getByText('pluginTags.allTags')).toBeInTheDocument()
expect(container.querySelectorAll('svg').length).toBeGreaterThan(0)
expect(container.querySelectorAll('svg').length).toBe(2)
expect(screen.getByRole('button', { name: 'pluginTags.allTags' })).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: /^pluginTags\.clearSelectedTags/ }),
).not.toBeInTheDocument()
})
it('shows selected tag labels and overflow count', () => {
render(
<MarketplaceTrigger
selectedTagsLength={3}
open
tags={['agent', 'rag', 'search']}
tagsMap={tagsMap}
onTagsChange={vi.fn()}
/>,
<Popover>
<MarketplaceTrigger
selectedTagsLength={3}
open
tags={['agent', 'rag', 'search']}
tagsMap={tagsMap}
onTagsChange={vi.fn()}
/>
</Popover>,
)
expect(screen.getByText('Agent,RAG')).toBeInTheDocument()
expect(screen.getByText('+1')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Agent, RAG, Search' })).toBeInTheDocument()
})
it('clears selected tags when clear icon is clicked', () => {
const onTagsChange = vi.fn()
it('clears selected tags from a separate button', async () => {
const user = userEvent.setup()
function Harness() {
const [tags, setTags] = useState(['agent'])
return (
<Popover>
<MarketplaceTrigger
selectedTagsLength={tags.length}
open={false}
tags={tags}
tagsMap={tagsMap}
onTagsChange={setTags}
/>
</Popover>
)
}
const { container } = render(
<MarketplaceTrigger
selectedTagsLength={1}
open={false}
tags={['agent']}
tagsMap={tagsMap}
onTagsChange={onTagsChange}
/>,
)
render(<Harness />)
fireEvent.click(container.querySelectorAll('svg')[1]!)
const trigger = screen.getByRole('button', { name: 'Agent' })
const clearButton = screen.getByRole('button', { name: /^pluginTags\.clearSelectedTags/ })
expect(trigger).not.toContainElement(clearButton)
expect(onTagsChange).toHaveBeenCalledWith([])
await user.click(clearButton)
expect(
screen.queryByRole('button', { name: /^pluginTags\.clearSelectedTags/ }),
).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'pluginTags.allTags' })).toHaveFocus()
})
})

View File

@ -1,5 +1,8 @@
import type { Tag } from '../../../../hooks'
import { fireEvent, render, screen } from '@testing-library/react'
import { Popover, PopoverContent } from '@langgenius/dify-ui/popover'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { describe, expect, it, vi } from 'vitest'
import ToolSelectorTrigger from '../tool-selector'
@ -11,51 +14,103 @@ const tagsMap: Record<string, Tag> = {
describe('ToolSelectorTrigger', () => {
it('renders only icon when no tags are selected', () => {
const { container } = render(
<ToolSelectorTrigger
selectedTagsLength={0}
open={false}
tags={[]}
tagsMap={tagsMap}
onTagsChange={vi.fn()}
/>,
render(
<Popover>
<ToolSelectorTrigger
selectedTagsLength={0}
open={false}
tags={[]}
tagsMap={tagsMap}
onTagsChange={vi.fn()}
/>
</Popover>,
)
expect(container.querySelectorAll('svg')).toHaveLength(1)
expect(screen.getByRole('button', { name: 'pluginTags.allTags' })).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: /^pluginTags\.clearSelectedTags/ }),
).not.toBeInTheDocument()
expect(screen.queryByText('Agent')).not.toBeInTheDocument()
})
it('renders selected tag labels and overflow count', () => {
const { container } = render(
<ToolSelectorTrigger
selectedTagsLength={3}
open
tags={['agent', 'rag', 'search']}
tagsMap={tagsMap}
onTagsChange={vi.fn()}
/>,
render(
<Popover>
<ToolSelectorTrigger
selectedTagsLength={3}
open
tags={['agent', 'rag', 'search']}
tagsMap={tagsMap}
onTagsChange={vi.fn()}
/>
</Popover>,
)
expect(screen.getByText('Agent,RAG')).toBeInTheDocument()
expect(screen.getByText('+1')).toBeInTheDocument()
expect(container.querySelectorAll('svg')).toHaveLength(2)
expect(screen.getByRole('button', { name: 'Agent, RAG, Search' })).toBeInTheDocument()
expect(
screen.getByRole('button', { name: /^pluginTags\.clearSelectedTags/ }),
).toBeInTheDocument()
})
it('clears selected tags when clear icon is clicked', () => {
const onTagsChange = vi.fn()
it('opens the tag filter from the keyboard', async () => {
const user = userEvent.setup()
const { container } = render(
<ToolSelectorTrigger
selectedTagsLength={1}
open={false}
tags={['agent']}
tagsMap={tagsMap}
onTagsChange={onTagsChange}
/>,
render(
<Popover>
<ToolSelectorTrigger
selectedTagsLength={0}
open={false}
tags={[]}
tagsMap={tagsMap}
onTagsChange={vi.fn()}
/>
<PopoverContent>Tag options</PopoverContent>
</Popover>,
)
fireEvent.click(container.querySelectorAll('svg')[1]!)
const trigger = screen.getByRole('button', { name: 'pluginTags.allTags' })
await user.tab()
expect(trigger).toHaveFocus()
expect(onTagsChange).toHaveBeenCalledWith([])
await user.keyboard('{Enter}')
expect(screen.getByText('Tag options')).toBeInTheDocument()
})
it('keeps clear as a separate action from the popover trigger', async () => {
const user = userEvent.setup()
function Harness() {
const [tags, setTags] = useState(['agent'])
return (
<Popover>
<ToolSelectorTrigger
selectedTagsLength={tags.length}
open={false}
tags={tags}
tagsMap={tagsMap}
onTagsChange={setTags}
/>
</Popover>
)
}
render(<Harness />)
const trigger = screen.getByRole('button', { name: 'Agent' })
const clearButton = screen.getByRole('button', { name: /^pluginTags\.clearSelectedTags/ })
expect(trigger).not.toContainElement(clearButton)
trigger.focus()
await user.tab()
expect(clearButton).toHaveFocus()
await user.keyboard('{Enter}')
expect(
screen.queryByRole('button', { name: /^pluginTags\.clearSelectedTags/ }),
).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'pluginTags.allTags' })).toHaveFocus()
})
})

View File

@ -1,7 +1,8 @@
import type { Tag } from '../../../hooks'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { RiArrowDownSLine, RiCloseCircleFill, RiFilter3Line } from '@remixicon/react'
import * as React from 'react'
import { PopoverTrigger } from '@langgenius/dify-ui/popover'
import { memo, useEffect, useRef } from 'react'
import { useTranslation } from '#i18n'
type MarketplaceTriggerProps = {
@ -12,54 +13,100 @@ type MarketplaceTriggerProps = {
onTagsChange: (tags: string[]) => void
}
const MarketplaceTrigger = ({
function MarketplaceTrigger({
selectedTagsLength,
open,
tags,
tagsMap,
onTagsChange,
}: MarketplaceTriggerProps) => {
}: MarketplaceTriggerProps) {
const { t } = useTranslation()
const triggerRef = useRef<HTMLButtonElement>(null)
const shouldRestoreFocusRef = useRef(false)
const selectedTagLabels = tags.map((tag) => tagsMap[tag]?.label).filter(Boolean)
const triggerLabel = selectedTagLabels.length
? selectedTagLabels.join(', ')
: t(($) => $.allTags, { ns: 'pluginTags' })
useEffect(() => {
if (selectedTagsLength || !shouldRestoreFocusRef.current) return
shouldRestoreFocusRef.current = false
triggerRef.current?.focus()
}, [selectedTagsLength])
return (
<div
className={cn(
'flex h-8 cursor-pointer items-center rounded-lg px-2 py-1 text-text-tertiary select-none',
!!selectedTagsLength &&
'border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs shadow-shadow-shadow-3',
open && !selectedTagsLength && 'bg-state-base-hover',
)}
>
<div className="p-0.5">
<RiFilter3Line className={cn('size-4', !!selectedTagsLength && 'text-text-secondary')} />
</div>
<div className="flex items-center gap-x-1 p-1 system-sm-medium">
{!selectedTagsLength && <span>{t(($) => $.allTags, { ns: 'pluginTags' })}</span>}
{!!selectedTagsLength && (
<span className="text-text-secondary">
{tags
.map((tag) => tagsMap[tag]!.label)
.slice(0, 2)
.join(',')}
</span>
)}
{selectedTagsLength > 2 && (
<div className="system-xs-medium text-text-tertiary">+{selectedTagsLength - 2}</div>
)}
</div>
<div className="relative inline-flex h-8 shrink-0 items-center">
<PopoverTrigger
render={
<Button
ref={triggerRef}
variant="ghost"
size="medium"
aria-label={triggerLabel}
className={cn(
'h-8 justify-start px-2 py-1 text-text-tertiary focus-visible:ring-inset',
!!selectedTagsLength &&
'border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg pr-8 shadow-xs shadow-shadow-shadow-3',
open && !selectedTagsLength && 'bg-state-base-hover',
)}
>
<span className="p-0.5">
<span
aria-hidden
className={cn(
'i-ri-filter-3-line block size-4',
!!selectedTagsLength && 'text-text-secondary',
)}
/>
</span>
<span className="flex items-center gap-x-1 p-1 system-sm-medium">
{!selectedTagsLength && <span>{t(($) => $.allTags, { ns: 'pluginTags' })}</span>}
{!!selectedTagsLength && (
<span className="text-text-secondary">
{tags
.map((tag) => tagsMap[tag]?.label)
.filter(Boolean)
.slice(0, 2)
.join(',')}
</span>
)}
{selectedTagsLength > 2 && (
<span className="system-xs-medium text-text-tertiary">
+{selectedTagsLength - 2}
</span>
)}
</span>
{!selectedTagsLength && (
<span className="p-0.5">
<span
aria-hidden
className="i-ri-arrow-down-s-line block size-4 text-text-tertiary"
/>
</span>
)}
</Button>
}
/>
{!!selectedTagsLength && (
<RiCloseCircleFill
className="size-4 text-text-quaternary"
onClick={() => onTagsChange([])}
/>
)}
{!selectedTagsLength && (
<div className="p-0.5">
<RiArrowDownSLine className="size-4 text-text-tertiary" />
</div>
<Button
variant="ghost"
size="small"
aria-label={t(($) => $.clearSelectedTags, {
ns: 'pluginTags',
tags: triggerLabel,
})}
className="absolute right-1 size-6 min-h-0 p-0 focus-visible:ring-inset"
onClick={() => {
shouldRestoreFocusRef.current = true
onTagsChange([])
}}
>
<span aria-hidden className="i-ri-close-circle-fill size-4 text-text-quaternary" />
</Button>
)}
</div>
)
}
export default React.memo(MarketplaceTrigger)
export default memo(MarketplaceTrigger)

View File

@ -1,7 +1,9 @@
import type { Tag } from '../../../hooks'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { RiCloseCircleFill, RiPriceTag3Line } from '@remixicon/react'
import * as React from 'react'
import { PopoverTrigger } from '@langgenius/dify-ui/popover'
import { memo, useEffect, useRef } from 'react'
import { useTranslation } from '#i18n'
type ToolSelectorTriggerProps = {
selectedTagsLength: number
@ -11,50 +13,92 @@ type ToolSelectorTriggerProps = {
onTagsChange: (tags: string[]) => void
}
const ToolSelectorTrigger = ({
function ToolSelectorTrigger({
selectedTagsLength,
open,
tags,
tagsMap,
onTagsChange,
}: ToolSelectorTriggerProps) => {
}: ToolSelectorTriggerProps) {
const { t } = useTranslation()
const triggerRef = useRef<HTMLButtonElement>(null)
const shouldRestoreFocusRef = useRef(false)
const selectedTagLabels = tags.map((tag) => tagsMap[tag]?.label).filter(Boolean)
const triggerLabel = selectedTagLabels.length
? selectedTagLabels.join(', ')
: t(($) => $.allTags, { ns: 'pluginTags' })
useEffect(() => {
if (selectedTagsLength || !shouldRestoreFocusRef.current) return
shouldRestoreFocusRef.current = false
triggerRef.current?.focus()
}, [selectedTagsLength])
return (
<div
className={cn(
'flex h-7 cursor-pointer items-center rounded-md p-0.5 text-text-tertiary select-none',
!selectedTagsLength && 'py-1 pr-2 pl-1.5',
!!selectedTagsLength &&
'border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg py-0.5 pr-1.5 pl-1 shadow-xs shadow-shadow-shadow-3',
open && !selectedTagsLength && 'bg-state-base-hover',
)}
>
<div className="p-0.5">
<RiPriceTag3Line className={cn('size-4', !!selectedTagsLength && 'text-text-secondary')} />
</div>
<div className="relative mr-1 inline-flex h-7 max-w-32 shrink-0 items-center">
<PopoverTrigger
render={
<Button
ref={triggerRef}
variant="ghost"
size="small"
aria-label={triggerLabel}
className={cn(
'h-7 max-w-32 justify-start text-text-tertiary focus-visible:ring-inset',
!selectedTagsLength && 'size-7 min-h-0 justify-center p-0',
!!selectedTagsLength &&
'border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg py-0.5 pr-7 pl-1 shadow-xs shadow-shadow-shadow-3',
open && !selectedTagsLength && 'bg-state-base-hover',
)}
>
<span className={cn('shrink-0', !!selectedTagsLength && 'p-0.5')}>
<span
aria-hidden
className={cn(
'i-ri-price-tag-3-line block size-4',
!!selectedTagsLength && 'text-text-secondary',
)}
/>
</span>
{!!selectedTagsLength && (
<span className="flex min-w-0 items-center gap-x-0.5 px-0.5 py-1 system-sm-medium">
<span className="truncate text-text-secondary">
{tags
.map((tag) => tagsMap[tag]?.label)
.filter(Boolean)
.slice(0, 2)
.join(',')}
</span>
{selectedTagsLength > 2 && (
<span className="shrink-0 system-xs-medium text-text-tertiary">
+{selectedTagsLength - 2}
</span>
)}
</span>
)}
</Button>
}
/>
{!!selectedTagsLength && (
<div className="flex items-center gap-x-0.5 px-0.5 py-1 system-sm-medium">
<span className="text-text-secondary">
{tags
.map((tag) => tagsMap[tag]!.label)
.slice(0, 2)
.join(',')}
</span>
{selectedTagsLength > 2 && (
<div className="system-xs-medium text-text-tertiary">+{selectedTagsLength - 2}</div>
)}
</div>
)}
{!!selectedTagsLength && (
<RiCloseCircleFill
className="size-4 text-text-quaternary"
onClick={(e) => {
e.stopPropagation()
<Button
variant="ghost"
size="small"
aria-label={t(($) => $.clearSelectedTags, {
ns: 'pluginTags',
tags: triggerLabel,
})}
className="absolute right-0.5 size-6 min-h-0 p-0 focus-visible:ring-inset"
onClick={() => {
shouldRestoreFocusRef.current = true
onTagsChange([])
}}
/>
>
<span aria-hidden className="i-ri-close-circle-fill size-4 text-text-quaternary" />
</Button>
)}
</div>
)
}
export default React.memo(ToolSelectorTrigger)
export default memo(ToolSelectorTrigger)

View File

@ -0,0 +1,18 @@
# Multiple Tool Selector
`index.tsx` is the public list composition for selecting and configuring multiple tools. It owns tool identity, list ordering, add and delete updates, enabled counts, and the optional collapsed state.
After a keyboard user deletes a tool, this module restores focus after the controlled `value` update. The target order is the next tool, the previous tool, then the add-tool button. `ToolSelector` only exposes the final default trigger through `triggerRef`; it does not infer sibling order.
Tools are identified by `provider_name` and `tool_name`, matching the module's deduplication contract. Callers must replace `value` after `onChange` so the list and pending focus target can settle together.
## Internal Modules
- `index.tsx`: List state composition, collapse control, and post-delete focus ownership.
- `__tests__/focus-restoration.spec.tsx`: Integration coverage through the real tool row and trigger chain.
## External Modules
- `app/components/plugins/plugin-detail-panel/tool-selector`: Single-tool trigger, Popover, and configuration form.
- `app/components/workflow/nodes/_base/components/mcp-tool-availability`: MCP availability policy.
- `service/use-tools`: Installed MCP tool data used by the enabled count.

View File

@ -0,0 +1,115 @@
import type { ToolValue } from '@/app/components/workflow/block-selector/types'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { CollectionType } from '@/app/components/tools/types'
import { MCPToolAvailabilityProvider } from '@/app/components/workflow/nodes/_base/components/mcp-tool-availability'
import MultipleToolSelector from '../index'
const mockTools = vi.hoisted(() => ({
builtIn: [] as ToolWithProvider[],
}))
vi.mock('@/service/use-tools', () => ({
useAllBuiltInTools: () => ({ data: mockTools.builtIn, isFetched: true }),
useAllCustomTools: () => ({ data: [], isFetched: true }),
useAllMCPTools: () => ({ data: [], isFetched: true }),
useAllWorkflowTools: () => ({ data: [], isFetched: true }),
useInvalidateAllBuiltInTools: () => vi.fn(),
}))
vi.mock('@/service/use-plugins', () => ({
useCheckInstalled: () => ({ data: undefined }),
useInvalidateInstalledPluginList: () => vi.fn(),
usePluginManifestInfo: () => ({ data: undefined }),
}))
const createToolValue = (index: number): ToolValue => ({
provider_name: `provider-${index}`,
tool_name: `tool-${index}`,
tool_label: `Tool ${index}`,
enabled: true,
})
const createToolProvider = (value: ToolValue): ToolWithProvider => ({
id: value.provider_name,
name: value.provider_name,
author: 'Dify',
description: { en_US: '', zh_Hans: '' },
icon: 'icon.svg',
label: { en_US: value.provider_name, zh_Hans: value.provider_name },
type: CollectionType.builtIn,
team_credentials: {},
is_team_authorization: true,
allow_delete: false,
labels: [],
tools: [
{
name: value.tool_name,
author: 'Dify',
label: { en_US: value.tool_label, zh_Hans: value.tool_label },
description: { en_US: '', zh_Hans: '' },
parameters: [],
labels: [],
output_schema: {},
},
],
meta: {} as ToolWithProvider['meta'],
})
function renderSelector(initialValue: ToolValue[]) {
mockTools.builtIn = initialValue.map(createToolProvider)
function Harness() {
const [value, setValue] = useState(initialValue)
return (
<MCPToolAvailabilityProvider versionSupported>
<MultipleToolSelector
value={value}
label="Tools"
onChange={setValue}
nodeOutputVars={[]}
availableNodes={[]}
/>
</MCPToolAvailabilityProvider>
)
}
return render(<Harness />)
}
describe('MultipleToolSelector focus restoration', () => {
it.each([
{
name: 'the next tool after deleting a middle item',
tools: [createToolValue(1), createToolValue(2), createToolValue(3)],
deleteIndex: 1,
expectedFocusName: 'Tool 3',
},
{
name: 'the previous tool after deleting the last item',
tools: [createToolValue(1), createToolValue(2)],
deleteIndex: 1,
expectedFocusName: 'Tool 1',
},
{
name: 'the add button after deleting the only item',
tools: [createToolValue(1)],
deleteIndex: 0,
expectedFocusName: 'plugin.detailPanel.toolSelector.title',
},
])('moves focus to $name', async ({ tools, deleteIndex, expectedFocusName }) => {
const user = userEvent.setup()
renderSelector(tools)
const deleteButton = screen.getAllByRole('button', {
name: 'common.operation.delete',
})[deleteIndex]
deleteButton?.focus()
await user.keyboard('{Enter}')
expect(screen.getByRole('button', { name: expectedFocusName })).toHaveFocus()
})
})

View File

@ -1,8 +1,10 @@
import type { MouseEventHandler, ReactElement } from 'react'
import type { Node } from 'reactflow'
import type { ToolValue } from '@/app/components/workflow/block-selector/types'
import type { NodeOutPutVar, ToolWithProvider } from '@/app/components/workflow/types'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
// ==================== Imports (after mocks) ====================
import { MCPToolAvailabilityProvider } from '@/app/components/workflow/nodes/_base/components/mcp-tool-availability'
@ -28,7 +30,8 @@ vi.mock('@/app/components/plugins/plugin-detail-panel/tool-selector', () => ({
onSelectMultiple,
onDelete,
controlledState,
onControlledStateChange: _onControlledStateChange,
onControlledStateChange,
trigger,
panelShowState,
onPanelShowStateChange: _onPanelShowStateChange,
isEdit,
@ -40,6 +43,10 @@ vi.mock('@/app/components/plugins/plugin-detail-panel/tool-selector', () => ({
onDelete?: () => void
controlledState?: boolean
onControlledStateChange?: (state: boolean) => void
trigger?: ReactElement<{
'aria-label'?: string
onClick?: MouseEventHandler<HTMLButtonElement>
}>
panelShowState?: boolean
onPanelShowStateChange?: (state: boolean) => void
isEdit?: boolean
@ -90,6 +97,16 @@ vi.mock('@/app/components/plugins/plugin-detail-panel/tool-selector', () => ({
data-controlled-state={controlledState}
data-panel-show-state={panelShowState}
>
{trigger && (
<button
type="button"
aria-label={trigger.props['aria-label']}
onClick={(event) => {
trigger.props.onClick?.(event)
onControlledStateChange?.(!controlledState)
}}
/>
)}
<button
data-testid="add-tool-btn"
onClick={() =>
@ -302,12 +319,11 @@ describe('MultipleToolSelector', () => {
})
it('should render add button when not disabled', () => {
// Arrange & Act
const { container } = renderComponent({ disabled: false })
renderComponent({ disabled: false })
// Assert
const addButton = container.querySelector('[class*="mx-1"]')
expect(addButton).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'plugin.detailPanel.toolSelector.title' }),
).toBeInTheDocument()
})
it('should not render add button when disabled', () => {
@ -315,18 +331,10 @@ describe('MultipleToolSelector', () => {
renderComponent({ disabled: true })
// Assert
const addSelectors = screen.queryAllByTestId('tool-selector-add')
// The add button should still be present but outside the disabled check
expect(addSelectors).toHaveLength(1)
})
it('should render tooltip when provided', () => {
// Arrange & Act
const { container } = renderComponent({ tooltip: 'This is a tooltip' })
// Assert - Tooltip icon should be present
const tooltipIcon = container.querySelector('svg')
expect(tooltipIcon).toBeInTheDocument()
expect(screen.queryByTestId('tool-selector-add')).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'plugin.detailPanel.toolSelector.title' }),
).not.toBeInTheDocument()
})
it('should render enabled count when tools are selected', () => {
@ -347,37 +355,27 @@ describe('MultipleToolSelector', () => {
// ==================== Collapse Functionality Tests ====================
describe('Collapse Functionality', () => {
it('should render collapse arrow when supportCollapse is true', () => {
// Arrange & Act
const { container } = renderComponent({ supportCollapse: true })
it('should render a non-interactive label when collapsing is unavailable', () => {
renderComponent({ supportCollapse: false })
// Assert
const collapseArrow = container.querySelector('svg[class*="cursor-pointer"]')
expect(collapseArrow).toBeInTheDocument()
expect(screen.getByText('Tools')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Tools' })).not.toBeInTheDocument()
})
it('should not render collapse arrow when supportCollapse is false', () => {
// Arrange & Act
const { container } = renderComponent({ supportCollapse: false })
// Assert
const collapseArrows = container.querySelectorAll('svg[class*="rotate"]')
expect(collapseArrows).toHaveLength(0)
})
it('should toggle collapse state when clicking header with supportCollapse enabled', () => {
// Arrange
it('should expose and toggle the collapsible tools region from the keyboard', async () => {
const user = userEvent.setup()
const tools = [createToolValue()]
const { container } = renderComponent({ supportCollapse: true, value: tools })
const headerArea = container.querySelector('[class*="cursor-pointer"]')
renderComponent({ supportCollapse: true, value: tools })
const collapseButton = screen.getByRole('button', { name: 'Tools' })
// Act - Initially visible
expect(collapseButton).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByTestId('tool-selector-edit')).toBeInTheDocument()
// Click to collapse
fireEvent.click(headerArea!)
await user.tab()
expect(collapseButton).toHaveFocus()
await user.keyboard('{Enter}')
// Assert - Should be collapsed
expect(collapseButton).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByTestId('tool-selector-edit')).not.toBeInTheDocument()
})
@ -394,17 +392,17 @@ describe('MultipleToolSelector', () => {
})
it('should expand when add button is clicked while collapsed', async () => {
// Arrange
const tools = [createToolValue()]
const { container } = renderComponent({ supportCollapse: true, value: tools })
const headerArea = container.querySelector('[class*="cursor-pointer"]')
renderComponent({ supportCollapse: true, value: tools })
const collapseButton = screen.getByRole('button', { name: 'Tools' })
// Collapse first
fireEvent.click(headerArea!)
fireEvent.click(collapseButton)
expect(screen.queryByTestId('tool-selector-edit')).not.toBeInTheDocument()
// Act - Click add button
const addButton = container.querySelector('button')
const addButton = screen.getByRole('button', {
name: 'plugin.detailPanel.toolSelector.title',
})
fireEvent.click(addButton!)
// Assert - Should be expanded
@ -467,14 +465,16 @@ describe('MultipleToolSelector', () => {
it('should manage open state for add tool panel', () => {
// Arrange
const { container } = renderComponent()
renderComponent()
// Initially closed
const addSelector = screen.getByTestId('tool-selector-add')
expect(addSelector).toHaveAttribute('data-controlled-state', 'false')
// Act - Click add button (ActionButton)
const actionButton = container.querySelector('[class*="mx-1"]')
const actionButton = screen.getByRole('button', {
name: 'plugin.detailPanel.toolSelector.title',
})
fireEvent.click(actionButton!)
// Assert - Open state should change to true
@ -628,25 +628,6 @@ describe('MultipleToolSelector', () => {
// Assert - Add tool panel should open
expect(screen.getByTestId('tool-selector-add')).toBeInTheDocument()
})
it('should handle collapse click with supportCollapse', () => {
// Arrange
const tools = [createToolValue()]
const { container } = renderComponent({ supportCollapse: true, value: tools })
const labelArea = container.querySelector('[class*="cursor-pointer"]')
// Act
fireEvent.click(labelArea!)
// Assert - Tools should be hidden
expect(screen.queryByTestId('tool-selector-edit')).not.toBeInTheDocument()
// Click again to expand
fireEvent.click(labelArea!)
// Assert - Tools should be visible again
expect(screen.getByTestId('tool-selector-edit')).toBeInTheDocument()
})
})
// ==================== Edge Cases Tests ====================
@ -982,13 +963,20 @@ describe('MultipleToolSelector', () => {
// ==================== Accessibility Tests ====================
describe('Accessibility', () => {
it('should have clickable add button', () => {
// Arrange
const { container } = renderComponent()
it('should open the add-tool panel from the keyboard', async () => {
const user = userEvent.setup()
renderComponent()
// Assert
const addButton = container.querySelector('button')
expect(addButton).toBeInTheDocument()
const addButton = screen.getByRole('button', {
name: 'plugin.detailPanel.toolSelector.title',
})
addButton.focus()
await user.keyboard('{Enter}')
expect(screen.getByTestId('tool-selector-add')).toHaveAttribute(
'data-controlled-state',
'true',
)
})
it('should show divider when tools are selected', () => {
@ -1003,27 +991,4 @@ describe('MultipleToolSelector', () => {
expect(divider).toBeInTheDocument()
})
})
// ==================== Tooltip Tests ====================
describe('Tooltip Rendering', () => {
it('should render question icon when tooltip is provided', () => {
// Arrange & Act
const { container } = renderComponent({ tooltip: 'Help text' })
// Assert
const questionIcon = container.querySelector('svg')
expect(questionIcon).toBeInTheDocument()
})
it('should not render question icon when tooltip is not provided', () => {
// Arrange & Act
const { container } = renderComponent({ tooltip: undefined })
// Assert - Should only have add icon, not question icon in label area
const labelDiv = container.querySelector('.system-sm-semibold-uppercase')
const icons = labelDiv?.querySelectorAll('svg') || []
// Question icon should not be in the label area
expect(icons.length).toBeLessThanOrEqual(1)
})
})
})

View File

@ -1,13 +1,12 @@
import type { Node } from 'reactflow'
import type { ToolValue } from '@/app/components/workflow/block-selector/types'
import type { NodeOutPutVar } from '@/app/components/workflow/types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { RiAddLine } from '@remixicon/react'
import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from '@langgenius/dify-ui/collapsible'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import Divider from '@/app/components/base/divider'
import { ArrowDownRoundFill } from '@/app/components/base/icons/src/vender/solid/general'
import { Infotip } from '@/app/components/base/infotip'
import ToolSelector from '@/app/components/plugins/plugin-detail-panel/tool-selector'
import { useMCPToolAvailability } from '@/app/components/workflow/nodes/_base/components/mcp-tool-availability'
@ -27,6 +26,8 @@ type Props = Readonly<{
nodeId?: string
}>
const getToolKey = (tool: ToolValue) => `${tool.provider_name}:${tool.tool_name}`
const MultipleToolSelector = ({
disabled,
value = [],
@ -43,19 +44,31 @@ const MultipleToolSelector = ({
const { t } = useTranslation()
const { allowed: isMCPToolAllowed } = useMCPToolAvailability()
const { data: mcpTools } = useAllMCPTools()
const addToolButtonRef = React.useRef<HTMLButtonElement>(null)
const toolItemTriggerByKeyRef = React.useRef(new Map<string, HTMLButtonElement>())
const pendingFocusTargetRef = React.useRef<{ toolKey?: string } | null>(null)
const enabledCount = value.filter((item) => {
const isMCPTool = mcpTools?.find((tool) => tool.id === item.provider_name)
if (isMCPTool) return item.enabled && isMCPToolAllowed
return item.enabled
}).length
// collapse control
const [collapse, setCollapse] = React.useState(false)
const handleCollapse = () => {
if (supportCollapse) setCollapse(!collapse)
}
const [toolsOpen, setToolsOpen] = React.useState(true)
React.useLayoutEffect(() => {
const pendingFocusTarget = pendingFocusTargetRef.current
if (!pendingFocusTarget) return
const focusTarget = pendingFocusTarget.toolKey
? toolItemTriggerByKeyRef.current.get(pendingFocusTarget.toolKey)
: addToolButtonRef.current
const resolvedFocusTarget = focusTarget ?? addToolButtonRef.current
resolvedFocusTarget?.focus()
pendingFocusTargetRef.current = null
}, [value])
// add tool
const [open, setOpen] = React.useState(false)
const [selectorOpen, setSelectorOpen] = React.useState(false)
const [panelShowState, setPanelShowState] = React.useState(true)
const handleAdd = (val: ToolValue) => {
const newValue = [...value, val]
@ -71,7 +84,7 @@ const MultipleToolSelector = ({
}, [] as ToolValue[])
// update value
onChange(deduplication)
setOpen(false)
setSelectorOpen(false)
}
const handleAddMultiple = (val: ToolValue[]) => {
@ -88,13 +101,17 @@ const MultipleToolSelector = ({
}, [] as ToolValue[])
// update value
onChange(deduplication)
setOpen(false)
setSelectorOpen(false)
}
// delete tool
const handleDelete = (index: number) => {
const newValue = [...value]
newValue.splice(index, 1)
const nextFocusTool = value[index + 1] ?? value[index - 1]
pendingFocusTargetRef.current = {
toolKey: nextFocusTool ? getToolKey(nextFocusTool) : undefined,
}
onChange(newValue)
}
@ -106,19 +123,34 @@ const MultipleToolSelector = ({
}
return (
<>
<Collapsible open={supportCollapse ? toolsOpen : true} onOpenChange={setToolsOpen}>
<div className="mb-1 flex items-center">
<div
className={cn(
'relative flex grow items-center gap-0.5',
supportCollapse && 'cursor-pointer',
<div className="flex grow items-center gap-0.5">
{supportCollapse ? (
<CollapsibleTrigger
aria-label={label}
className="group/collapse h-6 min-h-0 w-auto min-w-0 justify-start gap-0.5 bg-transparent p-0 hover:not-data-disabled:bg-transparent"
>
<span className="truncate system-sm-semibold-uppercase text-text-secondary">
{label}
</span>
{required && <span className="text-red-500">*</span>}
<span
aria-hidden
className={cn(
'i-custom-vender-solid-general-arrow-down-round-fill size-4 shrink-0 text-text-quaternary group-hover/collapse:text-text-secondary',
!toolsOpen && 'rotate-270',
)}
/>
</CollapsibleTrigger>
) : (
<>
<div className="flex h-6 min-w-0 items-center truncate system-sm-semibold-uppercase text-text-secondary">
{label}
</div>
{required && <div className="text-red-500">*</div>}
</>
)}
onClick={handleCollapse}
>
<div className="flex h-6 items-center system-sm-semibold-uppercase text-text-secondary">
{label}
</div>
{required && <div className="text-red-500">*</div>}
{tooltip ? (
<Infotip
aria-label={typeof tooltip === 'string' ? tooltip : label}
@ -127,14 +159,6 @@ const MultipleToolSelector = ({
{tooltip}
</Infotip>
) : null}
{supportCollapse && (
<ArrowDownRoundFill
className={cn(
'size-4 cursor-pointer text-text-quaternary group-hover/collapse:text-text-secondary',
collapse && 'rotate-270',
)}
/>
)}
</div>
{value.length > 0 && (
<>
@ -146,28 +170,50 @@ const MultipleToolSelector = ({
</>
)}
{!disabled && (
<ActionButton
className="mx-1"
onClick={() => {
setCollapse(false)
setOpen(!open)
setPanelShowState(true)
}}
>
<RiAddLine className="size-4" />
</ActionButton>
<ToolSelector
nodeId={nodeId}
nodeOutputVars={nodeOutputVars}
availableNodes={availableNodes}
scope={scope}
value={undefined}
selectedTools={value}
onSelect={handleAdd}
controlledState={selectorOpen}
onControlledStateChange={setSelectorOpen}
trigger={
<Button
ref={addToolButtonRef}
variant="ghost"
size="small"
aria-label={t(($) => $['detailPanel.toolSelector.title'], { ns: 'plugin' })}
className="mx-1 size-6 min-h-0 p-0"
onClick={() => {
setToolsOpen(true)
setPanelShowState(true)
}}
>
<span className="i-ri-add-line size-4" aria-hidden />
</Button>
}
panelShowState={panelShowState}
onPanelShowStateChange={setPanelShowState}
isEdit={false}
onSelectMultiple={handleAddMultiple}
/>
)}
</div>
{!collapse && (
<>
{value.length === 0 && (
<div className="flex justify-center rounded-[10px] bg-background-section p-3 system-xs-regular text-text-tertiary">
{t(($) => $['detailPanel.toolSelector.empty'], { ns: 'plugin' })}
</div>
)}
{value.length > 0 &&
value.map((item, index) => (
<div className="mb-1" key={index}>
<CollapsiblePanel>
{value.length === 0 && (
<div className="flex justify-center rounded-[10px] bg-background-section p-3 system-xs-regular text-text-tertiary">
{t(($) => $['detailPanel.toolSelector.empty'], { ns: 'plugin' })}
</div>
)}
{value.length > 0 &&
value.map((item, index) => {
const toolKey = getToolKey(item)
return (
<div className="mb-1" key={toolKey}>
<ToolSelector
nodeId={nodeId}
nodeOutputVars={nodeOutputVars}
@ -178,30 +224,18 @@ const MultipleToolSelector = ({
onSelect={(item) => handleConfigure(item, index)}
onSelectMultiple={handleAddMultiple}
onDelete={() => handleDelete(index)}
triggerRef={(element) => {
if (element) toolItemTriggerByKeyRef.current.set(toolKey, element)
else toolItemTriggerByKeyRef.current.delete(toolKey)
}}
supportEnableSwitch
isEdit
/>
</div>
))}
</>
)}
<ToolSelector
nodeId={nodeId}
nodeOutputVars={nodeOutputVars}
availableNodes={availableNodes}
scope={scope}
value={undefined}
selectedTools={value}
onSelect={handleAdd}
controlledState={open}
onControlledStateChange={setOpen}
trigger={<div className=""></div>}
panelShowState={panelShowState}
onPanelShowStateChange={setPanelShowState}
isEdit={false}
onSelectMultiple={handleAddMultiple}
/>
</>
)
})}
</CollapsiblePanel>
</Collapsible>
)
}

View File

@ -0,0 +1,23 @@
# Tool Selector
`index.tsx` is the public single-tool selector. It owns the configured or unconfigured trigger, Popover lifecycle, tool form, authorization, settings, and deletion action wiring.
The built-in trigger branch accepts `triggerRef`, which always resolves to its final native button. Callers that provide a custom `trigger` own that element and its ref directly. The two trigger modes are mutually exclusive in the component type contract.
Deletion only reports intent through `onDelete`. This module does not infer sibling order or choose a post-delete focus target; a list composition owner must coordinate that behavior.
## Internal Modules
- `components/tool-item`: Configured tool row and its primary and secondary actions.
- `components/tool-trigger`: Default unconfigured tool trigger.
- `components/tool-base-form`, `components/tool-authorization-section`, and `components/tool-settings-panel`: Popover content sections.
- `hooks/use-tool-selector` and `hooks/use-plugin-installed-check`: Selector state and installed-provider resolution.
## External Modules
- `app/components/plugins/plugin-auth`: Tool authorization controls.
- `app/components/plugins/readme-panel`: Plugin documentation entry.
- `app/components/tools`: Tool provider contracts and form conversion.
- `app/components/workflow/block-selector`: Tool picker and selected-tool contracts.
- `app/components/workflow/nodes/_base/components`: MCP availability and plugin recovery actions.
- `service/use-plugins` and `service/use-tools`: Installed plugin and tool-provider queries.

View File

@ -0,0 +1,74 @@
import { Button } from '@langgenius/dify-ui/button'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import ToolSelector from '../index'
vi.mock('../components/tool-authorization-section', () => ({
ToolAuthorizationSection: () => null,
}))
vi.mock('../components/tool-base-form', () => ({ ToolBaseForm: () => null }))
vi.mock('../components/tool-settings-panel', () => ({ ToolSettingsPanel: () => null }))
vi.mock('../hooks/use-tool-selector', () => ({
useToolSelector: () => ({
isShow: false,
setIsShow: vi.fn(),
isShowChooseTool: false,
setIsShowChooseTool: vi.fn(),
currType: 'settings',
setCurrType: vi.fn(),
currentProvider: undefined,
currentTool: undefined,
settingsFormSchemas: [],
paramsFormSchemas: [],
showTabSlider: false,
userSettingsOnly: false,
reasoningConfigOnly: false,
manifestIcon: '',
inMarketPlace: false,
manifest: undefined,
handleSelectTool: vi.fn(),
handleSelectMultipleTool: vi.fn(),
handleDescriptionChange: vi.fn(),
handleSettingsFormChange: vi.fn(),
handleParamsFormChange: vi.fn(),
handleEnabledChange: vi.fn(),
handleAuthorizationItemClick: vi.fn(),
handleInstall: vi.fn(),
settingsValue: {},
}),
}))
describe('ToolSelector', () => {
it('opens an unconfigured add-tool selector and restores focus when dismissed', async () => {
const user = userEvent.setup()
function Harness() {
const [open, setOpen] = useState(false)
return (
<ToolSelector
trigger={<Button>Add tool</Button>}
controlledState={open}
onControlledStateChange={setOpen}
onSelect={vi.fn()}
nodeOutputVars={[]}
availableNodes={[]}
/>
)
}
render(<Harness />)
const trigger = screen.getByRole('button', { name: 'Add tool' })
await user.click(trigger)
expect(trigger).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByText('plugin.detailPanel.toolSelector.title')).toBeInTheDocument()
await user.keyboard('{Escape}')
expect(screen.queryByText('plugin.detailPanel.toolSelector.title')).not.toBeInTheDocument()
expect(trigger).toHaveFocus()
})
})

View File

@ -8,25 +8,6 @@ import { Type } from '@/app/components/workflow/nodes/llm/types'
import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types'
import ReasoningConfigForm from '../reasoning-config-form'
vi.mock('@/app/components/base/input', () => ({
default: ({
value,
onChange,
placeholder,
}: {
value?: string
onChange: (e: { target: { value: string } }) => void
placeholder?: string
}) => (
<input
data-testid="number-input"
placeholder={placeholder}
value={value}
onChange={(e) => onChange({ target: { value: e.target.value } })}
/>
),
}))
vi.mock('@langgenius/dify-ui/select', async () => {
const React = await import('react')
const SelectContext = React.createContext<{
@ -41,16 +22,16 @@ vi.mock('@langgenius/dify-ui/select', async () => {
children: React.ReactNode
onValueChange?: (value: string) => void
}) => (
<SelectContext.Provider value={{ onValueChange }}>
<SelectContext value={{ onValueChange }}>
<div>{children}</div>
</SelectContext.Provider>
</SelectContext>
),
SelectTrigger: ({ children }: { children: React.ReactNode }) => (
<button type="button">{children}</button>
),
SelectContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SelectItem: ({ children, value }: { children: React.ReactNode; value: string }) => {
const context = React.useContext(SelectContext)
const context = React.use(SelectContext)
return (
<button
key={value}
@ -171,7 +152,7 @@ vi.mock('@/app/components/workflow/nodes/tool/components/mixed-variable-text-inp
}))
vi.mock('../schema-modal', () => ({
default: ({
SchemaModal: ({
isShow,
rootName,
onClose,
@ -486,7 +467,7 @@ describe('ReasoningConfigForm', () => {
expect(screen.getByText('Pick one')).toBeInTheDocument()
expect(screen.getByPlaceholderText('Enter count')).toBeInTheDocument()
fireEvent.change(screen.getByTestId('number-input'), { target: { value: '7' } })
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '7' } })
fireEvent.click(screen.getByTestId('boolean-input'))
fireEvent.click(screen.getByTestId('select-beta'))

View File

@ -1,6 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import SchemaModal from '../schema-modal'
import { SchemaModal } from '../schema-modal'
vi.mock('@langgenius/dify-ui/dialog', () => ({
Dialog: ({ children, open }: { children: React.ReactNode; open?: boolean }) =>

View File

@ -2,7 +2,7 @@ import type { ToolWithProvider } from '@/app/components/workflow/types'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { CollectionType } from '@/app/components/tools/types'
import ToolAuthorizationSection from '../tool-authorization-section'
import { ToolAuthorizationSection } from '../tool-authorization-section'
vi.mock('@/app/components/plugins/plugin-auth', () => ({
AuthCategory: {

View File

@ -12,7 +12,7 @@ vi.mock('@/app/components/workflow/block-selector/tool-picker', () => ({
}))
vi.mock('../tool-trigger', () => ({
default: ({ value, provider }: { open?: boolean; value?: unknown; provider?: unknown }) => (
ToolTrigger: ({ value, provider }: { open?: boolean; value?: unknown; provider?: unknown }) => (
<div data-testid="tool-trigger" data-has-value={!!value} data-has-provider={!!provider} />
),
}))
@ -32,12 +32,12 @@ const defaultProps = {
}
describe('ToolBaseForm', () => {
let ToolBaseForm: (typeof import('../tool-base-form'))['default']
let ToolBaseForm: (typeof import('../tool-base-form'))['ToolBaseForm']
beforeEach(async () => {
vi.clearAllMocks()
const mod = await import('../tool-base-form')
ToolBaseForm = mod.default
ToolBaseForm = mod.ToolBaseForm
})
it('should render tool trigger within tool picker', () => {

View File

@ -1,7 +1,17 @@
import type { ReactElement } from 'react'
import { Popover } from '@langgenius/dify-ui/popover'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ToolItem from '../tool-item'
import { ToolItem } from '../tool-item'
const renderToolItem = (item: ReactElement) => {
const view = render(<Popover>{item}</Popover>)
return {
...view,
rerenderToolItem: (nextItem: ReactElement) => view.rerender(<Popover>{nextItem}</Popover>),
}
}
let mcpAllowed = true
@ -34,13 +44,13 @@ describe('ToolItem', () => {
})
it('shows auth status actions for no-auth and auth-removed states', () => {
const { rerender } = render(
const { rerenderToolItem } = renderToolItem(
<ToolItem open={false} toolLabel="Search Tool" providerName="acme/search" noAuth />,
)
expect(screen.getByText('tools.notAuthorized')).toBeInTheDocument()
rerender(
rerenderToolItem(
<ToolItem open={false} toolLabel="Search Tool" providerName="acme/search" authRemoved />,
)
@ -49,7 +59,7 @@ describe('ToolItem', () => {
it('surfaces install and version mismatch recovery actions', () => {
const onInstall = vi.fn()
const { rerender } = render(
const { rerenderToolItem } = renderToolItem(
<ToolItem
open={false}
toolLabel="Search Tool"
@ -63,7 +73,7 @@ describe('ToolItem', () => {
fireEvent.click(screen.getByText('install plugin'))
expect(onInstall).toHaveBeenCalledTimes(1)
rerender(
rerenderToolItem(
<ToolItem
open={false}
toolLabel="Search Tool"
@ -80,13 +90,13 @@ describe('ToolItem', () => {
it('blocks unsupported MCP tools and still exposes error state', async () => {
mcpAllowed = false
const { rerender } = render(
const { rerenderToolItem } = renderToolItem(
<ToolItem open={false} toolLabel="Search Tool" providerName="acme/search" isMCPTool />,
)
expect(screen.getByTestId('mcp-tooltip')).toBeInTheDocument()
rerender(
rerenderToolItem(
<ToolItem
open={false}
toolLabel="Search Tool"
@ -99,4 +109,31 @@ describe('ToolItem', () => {
await userEvent.hover(screen.getByLabelText('tool failed'))
expect(await screen.findByText('tool failed')).toBeInTheDocument()
})
it('exposes one primary row action followed by a separate delete action', async () => {
const user = userEvent.setup()
const onDelete = vi.fn()
renderToolItem(
<ToolItem
open={false}
triggerLabel="Configure Search Tool"
toolLabel="Search Tool"
providerName="acme/search"
onDelete={onDelete}
/>,
)
const primaryAction = screen.getByRole('button', { name: 'Configure Search Tool' })
const deleteAction = screen.getByRole('button', { name: 'common.operation.delete' })
expect(primaryAction).not.toContainElement(deleteAction)
await user.tab()
expect(primaryAction).toHaveFocus()
await user.tab()
expect(deleteAction).toHaveFocus()
await user.keyboard('{Enter}')
expect(onDelete).toHaveBeenCalledOnce()
})
})

View File

@ -1,7 +1,7 @@
import type { ToolWithProvider } from '@/app/components/workflow/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import ToolSettingsPanel from '../tool-settings-panel'
import { ToolSettingsPanel } from '../tool-settings-panel'
vi.mock('@/app/components/base/tab-slider-plain', () => ({
default: ({

View File

@ -1,7 +1,9 @@
import type { ToolWithProvider } from '@/app/components/workflow/types'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { createRef } from 'react'
import { describe, expect, it, vi } from 'vitest'
import ToolTrigger from '../tool-trigger'
import { ToolTrigger } from '../tool-trigger'
vi.mock('@/app/components/workflow/block-icon', () => ({
default: () => <div data-testid="block-icon" />,
@ -35,4 +37,30 @@ describe('ToolTrigger', () => {
expect(screen.getByText('plugin.detailPanel.configureTool')).toBeInTheDocument()
})
it('forwards trigger attributes, events, and the anchor ref to the button', async () => {
const user = userEvent.setup()
const onClick = vi.fn()
const ref = createRef<HTMLButtonElement>()
render(
<ToolTrigger
ref={ref}
open={false}
aria-haspopup="dialog"
aria-expanded={false}
onClick={onClick}
/>,
)
const trigger = screen.getByRole('button', {
name: 'plugin.detailPanel.toolSelector.placeholder',
})
expect(ref.current).toBe(trigger)
expect(trigger).toHaveAttribute('aria-haspopup', 'dialog')
expect(trigger).toHaveAttribute('aria-expanded', 'false')
await user.click(trigger)
expect(onClick).toHaveBeenCalledTimes(1)
})
})

View File

@ -1,7 +0,0 @@
export { default as SchemaModal } from './schema-modal'
export { default as ToolAuthorizationSection } from './tool-authorization-section'
export { default as ToolBaseForm } from './tool-base-form'
export { default as ToolItem } from './tool-item'
export { default as ToolSettingsPanel } from './tool-settings-panel'
export { default as ToolTrigger } from './tool-trigger'

View File

@ -4,6 +4,7 @@ import type { ToolFormSchema } from '@/app/components/tools/utils/to-form-schema
import type { SchemaRoot } from '@/app/components/workflow/nodes/llm/types'
import type { NodeOutPutVar, ValueSelector } from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
import { Input } from '@langgenius/dify-ui/input'
import {
Select,
SelectContent,
@ -14,12 +15,10 @@ import {
} from '@langgenius/dify-ui/select'
import { Switch } from '@langgenius/dify-ui/switch'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { RiArrowRightUpLine, RiBracesLine } from '@remixicon/react'
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'
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'
@ -42,7 +41,7 @@ import {
updateVariableSelectorValue,
updateVariableTypeValue,
} from './reasoning-config-form.helpers'
import SchemaModal from './schema-modal'
import { SchemaModal } from './schema-modal'
export type ReasoningConfigValue = ReasoningConfigValueShape
@ -195,7 +194,7 @@ const ReasoningConfigForm: React.FC<Props> = ({
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" />
<span aria-hidden className="i-ri-braces-line size-3.5" />
</button>
}
/>
@ -205,10 +204,7 @@ const ReasoningConfigForm: React.FC<Props> = ({
</Tooltip>
)}
</div>
<div
className="flex cursor-pointer items-center gap-1 rounded-md border border-divider-subtle bg-background-default-lighter px-2 py-1 hover:bg-state-base-hover"
onClick={() => handleAutomatic(variable, !auto, type)}
>
<label className="flex cursor-pointer items-center gap-1 rounded-md border border-divider-subtle bg-background-default-lighter px-2 py-1 hover:bg-state-base-hover">
<span className="system-xs-medium text-text-secondary">
{t(($) => $['detailPanel.toolSelector.auto'], { ns: 'plugin' })}
</span>
@ -217,7 +213,7 @@ const ReasoningConfigForm: React.FC<Props> = ({
checked={!!auto}
onCheckedChange={(val) => handleAutomatic(variable, val, type)}
/>
</div>
</label>
</div>
{auto === 0 && (
<div className={cn('gap-1', !(isShowJSONEditor && isConstant) && 'flex')}>
@ -335,7 +331,7 @@ const ReasoningConfigForm: React.FC<Props> = ({
className="inline-flex items-center text-xs text-text-accent"
>
{t(($) => $.howToGet, { ns: 'tools' })}
<RiArrowRightUpLine className="ml-1 size-3" />
<span aria-hidden className="ml-1 i-ri-arrow-right-up-line size-3" />
</a>
)}
</div>

View File

@ -1,8 +1,6 @@
'use client'
import type { FC } from 'react'
import type { SchemaRoot } from '@/app/components/workflow/nodes/llm/types'
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import VisualEditor from '@/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor'
import {
@ -17,7 +15,7 @@ type Props = Readonly<{
onClose: () => void
}>
const SchemaModal: FC<Props> = ({ isShow, schema, rootName, onClose }) => {
export function SchemaModal({ isShow, schema, rootName, onClose }: Props) {
const { t } = useTranslation()
return (
<Dialog open={isShow} onOpenChange={(open) => !open && onClose()}>
@ -55,4 +53,3 @@ const SchemaModal: FC<Props> = ({ isShow, schema, rootName, onClose }) => {
</Dialog>
)
}
export default React.memo(SchemaModal)

View File

@ -1,5 +1,4 @@
'use client'
import type { FC } from 'react'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import Divider from '@/app/components/base/divider'
import { AuthCategory, PluginAuthInAgent } from '@/app/components/plugins/plugin-auth'
@ -11,11 +10,11 @@ type ToolAuthorizationSectionProps = {
onAuthorizationItemClick: (id: string) => void
}
const ToolAuthorizationSection: FC<ToolAuthorizationSectionProps> = ({
export function ToolAuthorizationSection({
currentProvider,
credentialId,
onAuthorizationItemClick,
}) => {
}: ToolAuthorizationSectionProps) {
// Only show for built-in providers that allow deletion
const shouldShow =
currentProvider &&
@ -41,5 +40,3 @@ const ToolAuthorizationSection: FC<ToolAuthorizationSectionProps> = ({
</>
)
}
export default ToolAuthorizationSection

View File

@ -1,19 +1,17 @@
'use client'
import type { OffsetOptions } from '@floating-ui/react'
import type { FC } from 'react'
import type { PluginDetail } from '@/app/components/plugins/types'
import type { ToolPickerScope } from '@/app/components/workflow/block-selector/tool-picker'
import type { ToolDefaultValue, ToolValue } from '@/app/components/workflow/block-selector/types'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { useTranslation } from 'react-i18next'
import ToolPicker from '@/app/components/workflow/block-selector/tool-picker'
import { ReadmeEntrance } from '../../../readme-panel/entrance'
import ToolTrigger from './tool-trigger'
import { ToolTrigger } from './tool-trigger'
type ToolBaseFormProps = {
value?: ToolValue
currentProvider?: ToolWithProvider
offset?: OffsetOptions
scope?: string
selectedTools?: ToolValue[]
isShowChooseTool: boolean
@ -26,10 +24,14 @@ type ToolBaseFormProps = {
onDescriptionChange: (value: string) => void
}
const ToolBaseForm: FC<ToolBaseFormProps> = ({
function resolveToolPickerScope(scope?: string): ToolPickerScope {
if (scope === 'plugins' || scope === 'custom' || scope === 'workflow') return scope
return 'all'
}
export function ToolBaseForm({
value,
currentProvider,
offset = 4,
scope,
selectedTools,
isShowChooseTool,
@ -40,12 +42,11 @@ const ToolBaseForm: FC<ToolBaseFormProps> = ({
onSelectTool,
onSelectMultipleTool,
onDescriptionChange,
}) => {
}: ToolBaseFormProps) {
const { t } = useTranslation()
return (
<div className="flex flex-col gap-3 px-4 py-2">
{/* Tool picker */}
<div className="flex flex-col gap-1">
<div className="flex h-6 items-center justify-between system-sm-semibold text-text-secondary">
{t(($) => $['detailPanel.toolSelector.toolLabel'], { ns: 'plugin' })}
@ -59,7 +60,7 @@ const ToolBaseForm: FC<ToolBaseFormProps> = ({
</div>
<ToolPicker
placement="bottom"
offset={offset}
sideOffset={4}
trigger={
<ToolTrigger
open={panelShowState || isShowChooseTool}
@ -68,17 +69,16 @@ const ToolBaseForm: FC<ToolBaseFormProps> = ({
/>
}
isShow={panelShowState || isShowChooseTool}
onShowChange={hasTrigger ? onPanelShowStateChange || (() => {}) : onShowChange}
onShowChange={hasTrigger ? onPanelShowStateChange || onShowChange : onShowChange}
disabled={false}
supportAddCustomTool
onSelect={onSelectTool}
onSelectMultiple={onSelectMultipleTool}
scope={scope}
scope={resolveToolPickerScope(scope)}
selectedTools={selectedTools}
/>
</div>
{/* Description */}
<div className="flex flex-col gap-1">
<div className="flex h-6 items-center system-sm-semibold text-text-secondary">
{t(($) => $['detailPanel.toolSelector.descriptionLabel'], { ns: 'plugin' })}
@ -97,5 +97,3 @@ const ToolBaseForm: FC<ToolBaseFormProps> = ({
</div>
)
}
export default ToolBaseForm

View File

@ -7,7 +7,6 @@ import { Switch } from '@langgenius/dify-ui/switch'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import AppIcon from '@/app/components/base/app-icon'
import { InstallPluginButton } from '@/app/components/workflow/nodes/_base/components/install-plugin-button'
import { useMCPToolAvailability } from '@/app/components/workflow/nodes/_base/components/mcp-tool-availability'
@ -15,6 +14,8 @@ import McpToolNotSupportTooltip from '@/app/components/workflow/nodes/_base/comp
import { SwitchPluginVersion } from '@/app/components/workflow/nodes/_base/components/switch-plugin-version'
type Props = Readonly<{
triggerRef?: React.Ref<HTMLButtonElement>
triggerLabel?: string
icon?: string | { content?: string; background?: string }
providerName?: string
isMCPTool?: boolean
@ -35,7 +36,9 @@ type Props = Readonly<{
authRemoved?: boolean
}>
const ToolItem = ({
export function ToolItem({
triggerRef,
triggerLabel,
open,
icon,
isMCPTool,
@ -54,26 +57,40 @@ const ToolItem = ({
errorTip,
versionMismatch,
authRemoved,
}: Props) => {
}: Props) {
const { t } = useTranslation()
const { allowed: isMCPToolAllowed } = useMCPToolAvailability()
const providerNameText = isMCPTool ? providerShowName : providerName?.split('/').pop()
const isTransparent = uninstalled || versionMismatch || isError
const [isDeleting, setIsDeleting] = useState(false)
const isShowCanNotChooseMCPTip = isMCPTool && !isMCPToolAllowed
const accessibleTriggerLabel =
triggerLabel ||
toolLabel ||
t(($) => $['detailPanel.toolSelector.toolSetting'], { ns: 'plugin' })
return (
<div
className={cn(
'group flex cursor-default items-center gap-1 rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg p-1.5 pr-2 shadow-xs hover:bg-components-panel-on-panel-item-bg-hover hover:shadow-sm',
'group relative flex items-center gap-1 rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg p-1.5 pr-2 shadow-xs hover:bg-components-panel-on-panel-item-bg-hover hover:shadow-sm',
open && 'bg-components-panel-on-panel-item-bg-hover shadow-sm',
isDeleting && 'border-state-destructive-border shadow-xs hover:bg-state-destructive-hover',
)}
>
<PopoverTrigger
render={
<button
ref={triggerRef}
type="button"
aria-label={accessibleTriggerLabel}
className="absolute inset-0 z-0 cursor-pointer rounded-lg border-0 bg-transparent outline-hidden focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid"
/>
}
/>
{icon && (
<div
className={cn(
'shrink-0',
'pointer-events-none relative z-1 shrink-0',
isTransparent && 'opacity-50',
isShowCanNotChooseMCPTip && 'opacity-30',
)}
@ -97,7 +114,7 @@ const ToolItem = ({
{!icon && (
<div
className={cn(
'flex h-7 w-7 items-center justify-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-subtle',
'pointer-events-none relative z-1 flex h-7 w-7 items-center justify-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-subtle',
isTransparent && 'opacity-50',
isShowCanNotChooseMCPTip && 'opacity-30',
)}
@ -109,7 +126,7 @@ const ToolItem = ({
)}
<div
className={cn(
'grow truncate pl-0.5',
'pointer-events-none relative z-1 grow truncate pl-0.5',
isTransparent && 'opacity-50',
isShowCanNotChooseMCPTip && 'opacity-30',
)}
@ -117,23 +134,28 @@ const ToolItem = ({
<div className="system-2xs-medium-uppercase text-text-tertiary">{providerNameText}</div>
<div className="system-xs-medium text-text-secondary">{toolLabel}</div>
</div>
<div className="hidden items-center gap-1 group-hover:flex">
<div className="relative z-10 hidden items-center gap-1 group-focus-within:flex group-hover:flex">
{!noAuth && !isError && !uninstalled && !versionMismatch && !isShowCanNotChooseMCPTip && (
<ActionButton>
<span
className="pointer-events-none flex size-6 items-center justify-center text-text-tertiary"
aria-hidden
>
<span className="i-ri-equalizer-2-line size-4" />
</ActionButton>
</span>
)}
{onDelete && (
<Button
variant="ghost"
size="small"
aria-label={t(($) => $['operation.delete'], { ns: 'common' })}
className="size-6 min-h-0 p-0 text-text-tertiary hover:text-text-destructive"
onClick={onDelete}
onMouseEnter={() => setIsDeleting(true)}
onMouseLeave={() => setIsDeleting(false)}
>
<span className="i-ri-delete-bin-line size-4" aria-hidden />
</Button>
)}
<div
className="cursor-pointer rounded-md p-1 text-text-tertiary hover:text-text-destructive"
onClick={(e) => {
e.stopPropagation()
onDelete?.()
}}
onMouseOver={() => setIsDeleting(true)}
onMouseLeave={() => setIsDeleting(false)}
>
<span className="i-ri-delete-bin-line size-4" />
</div>
</div>
{!isError &&
!uninstalled &&
@ -141,25 +163,37 @@ const ToolItem = ({
!versionMismatch &&
!isShowCanNotChooseMCPTip &&
showSwitch && (
<div className="mr-1" onClick={(e) => e.stopPropagation()}>
<div className="relative z-10 mr-1">
<Switch size="md" checked={switchValue ?? false} onCheckedChange={onSwitchChange} />
</div>
)}
{isShowCanNotChooseMCPTip && <McpToolNotSupportTooltip />}
{isShowCanNotChooseMCPTip && (
<div className="relative z-10">
<McpToolNotSupportTooltip />
</div>
)}
{!isError && !uninstalled && !versionMismatch && noAuth && (
<Button variant="secondary" size="small">
{t(($) => $.notAuthorized, { ns: 'tools' })}
<StatusDot className="ml-2" status="warning" />
</Button>
<PopoverTrigger
render={
<Button className="relative z-10" variant="secondary" size="small">
{t(($) => $.notAuthorized, { ns: 'tools' })}
<StatusDot className="ml-2" status="warning" />
</Button>
}
/>
)}
{!isError && !uninstalled && !versionMismatch && authRemoved && (
<Button variant="secondary" size="small">
{t(($) => $['auth.authRemoved'], { ns: 'plugin' })}
<StatusDot className="ml-2" status="error" />
</Button>
<PopoverTrigger
render={
<Button className="relative z-10" variant="secondary" size="small">
{t(($) => $['auth.authRemoved'], { ns: 'plugin' })}
<StatusDot className="ml-2" status="error" />
</Button>
}
/>
)}
{!isError && !uninstalled && versionMismatch && installInfo && (
<div onClick={(e) => e.stopPropagation()}>
<div className="relative z-10">
<SwitchPluginVersion
className="-mt-1"
uniqueIdentifier={installInfo}
@ -183,35 +217,36 @@ const ToolItem = ({
</div>
)}
{!isError && uninstalled && installInfo && (
<InstallPluginButton
onClick={(e) => e.stopPropagation()}
size="small"
uniqueIdentifier={installInfo}
onSuccess={() => {
onInstall?.()
}}
/>
<div className="relative z-10">
<InstallPluginButton
size="small"
uniqueIdentifier={installInfo}
onSuccess={() => {
onInstall?.()
}}
/>
</div>
)}
{isError && (
<Popover>
<PopoverTrigger
openOnHover
aria-label={
typeof errorTip === 'string'
? errorTip
: t(($) => $['detailPanel.toolSelector.unsupportedTitle'], { ns: 'plugin' })
}
className="inline-flex border-0 bg-transparent p-0"
>
<span className="i-ri-error-warning-fill size-4 text-text-destructive" />
</PopoverTrigger>
<PopoverContent popupClassName="px-3 py-2 system-xs-regular text-text-tertiary">
{errorTip}
</PopoverContent>
</Popover>
<div className="relative z-10">
<Popover>
<PopoverTrigger
openOnHover
aria-label={
typeof errorTip === 'string'
? errorTip
: t(($) => $['detailPanel.toolSelector.unsupportedTitle'], { ns: 'plugin' })
}
className="inline-flex border-0 bg-transparent p-0"
>
<span className="i-ri-error-warning-fill size-4 text-text-destructive" />
</PopoverTrigger>
<PopoverContent popupClassName="px-3 py-2 system-xs-regular text-text-tertiary">
{errorTip}
</PopoverContent>
</Popover>
</div>
)}
</div>
)
}
export default ToolItem

View File

@ -1,7 +1,6 @@
'use client'
import type { FC } from 'react'
import type { Node } from 'reactflow'
import type { TabType } from '../hooks/use-tool-selector-state'
import type { TabType } from '../hooks/use-tool-selector'
import type { ReasoningConfigValue } from './reasoning-config-form'
import type { CredentialFormSchema } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { ToolFormSchema } from '@/app/components/tools/utils/to-form-schema'
@ -35,7 +34,7 @@ type ToolSettingsPanelProps = {
/**
* Renders the settings/params tips section
*/
const ParamsTips: FC = () => {
function ParamsTips() {
const { t } = useTranslation()
return (
<div className="pb-1">
@ -49,7 +48,7 @@ const ParamsTips: FC = () => {
)
}
const ToolSettingsPanel: FC<ToolSettingsPanelProps> = ({
export function ToolSettingsPanel({
value,
currentProvider,
nodeId,
@ -65,7 +64,7 @@ const ToolSettingsPanel: FC<ToolSettingsPanelProps> = ({
onCurrTypeChange,
onSettingsFormChange,
onParamsFormChange,
}) => {
}: ToolSettingsPanelProps) {
const { t } = useTranslation()
// Check if panel should be shown
@ -157,5 +156,3 @@ const ToolSettingsPanel: FC<ToolSettingsPanelProps> = ({
</>
)
}
export default ToolSettingsPanel

View File

@ -1,30 +1,44 @@
'use client'
import type { ButtonProps } from '@langgenius/dify-ui/button'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { RiArrowDownSLine, RiEqualizer2Line } from '@remixicon/react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import BlockIcon from '@/app/components/workflow/block-icon'
import { BlockEnum } from '@/app/components/workflow/types'
type Props = Readonly<{
open: boolean
provider?: ToolWithProvider
value?: {
provider_name: string
tool_name: string
}
isConfigure?: boolean
}>
type Props = Readonly<
{
open: boolean
provider?: ToolWithProvider
value?: {
provider_name: string
tool_name: string
}
isConfigure?: boolean
} & Omit<ButtonProps, 'children' | 'size' | 'value' | 'variant'>
>
const ToolTrigger = ({ open, provider, value, isConfigure }: Props) => {
export function ToolTrigger({
open,
provider,
value,
isConfigure,
className,
...buttonProps
}: Props) {
const { t } = useTranslation()
return (
<div
<Button
{...buttonProps}
variant="ghost"
size="medium"
className={cn(
'group flex cursor-pointer items-center rounded-lg bg-components-input-bg-normal p-2 pl-3 hover:bg-state-base-hover-alt',
'group w-full justify-start bg-components-input-bg-normal px-3 hover:bg-state-base-hover-alt focus-visible:ring-inset',
open && 'bg-state-base-hover-alt',
value?.provider_name && 'py-1.5 pl-1.5',
className,
)}
>
{value?.provider_name && provider && (
@ -45,23 +59,23 @@ const ToolTrigger = ({ open, provider, value, isConfigure }: Props) => {
</div>
)}
{isConfigure && (
<RiEqualizer2Line
<span
aria-hidden
className={cn(
'ml-0.5 size-4 shrink-0 text-text-quaternary group-hover:text-text-secondary',
'ml-0.5 i-ri-equalizer-2-line size-4 shrink-0 text-text-quaternary group-hover:text-text-secondary',
open && 'text-text-secondary',
)}
/>
)}
{!isConfigure && (
<RiArrowDownSLine
<span
aria-hidden
className={cn(
'ml-0.5 size-4 shrink-0 text-text-quaternary group-hover:text-text-secondary',
'ml-0.5 i-ri-arrow-down-s-line size-4 shrink-0 text-text-quaternary group-hover:text-text-secondary',
open && 'text-text-secondary',
)}
/>
)}
</div>
</Button>
)
}
export default ToolTrigger

View File

@ -2,7 +2,7 @@ import type { ToolValue } from '@/app/components/workflow/block-selector/types'
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { CollectionType } from '@/app/components/tools/types'
import { useToolSelectorState } from '../use-tool-selector-state'
import { useToolSelector } from '../use-tool-selector'
const mockToolParams = [
{ name: 'param1', form: 'llm', type: 'string', required: true, label: { en_US: 'Param 1' } },
@ -60,7 +60,7 @@ vi.mock('@/app/components/tools/utils/to-form-schema', () => ({
getStructureValue: (value: Record<string, unknown>) => value || {},
}))
describe('useToolSelectorState', () => {
describe('useToolSelector', () => {
const mockOnSelect = vi.fn()
const _mockOnSelectMultiple = vi.fn()
@ -88,7 +88,7 @@ describe('useToolSelectorState', () => {
})
it('should initialize with default panel states', () => {
const { result } = renderHook(() => useToolSelectorState({ onSelect: mockOnSelect }))
const { result } = renderHook(() => useToolSelector({ onSelect: mockOnSelect }))
expect(result.current.isShow).toBe(false)
expect(result.current.isShowChooseTool).toBe(false)
@ -97,7 +97,7 @@ describe('useToolSelectorState', () => {
it('should find current provider from tool value', () => {
const { result } = renderHook(() =>
useToolSelectorState({ value: toolValue, onSelect: mockOnSelect }),
useToolSelector({ value: toolValue, onSelect: mockOnSelect }),
)
expect(result.current.currentProvider).toBeDefined()
@ -106,7 +106,7 @@ describe('useToolSelectorState', () => {
it('should find current tool from provider', () => {
const { result } = renderHook(() =>
useToolSelectorState({ value: toolValue, onSelect: mockOnSelect }),
useToolSelector({ value: toolValue, onSelect: mockOnSelect }),
)
expect(result.current.currentTool).toBeDefined()
@ -115,7 +115,7 @@ describe('useToolSelectorState', () => {
it('should compute tool settings and params correctly', () => {
const { result } = renderHook(() =>
useToolSelectorState({ value: toolValue, onSelect: mockOnSelect }),
useToolSelector({ value: toolValue, onSelect: mockOnSelect }),
)
// param2 has form='form' (not 'llm'), so it goes to settings
@ -129,7 +129,7 @@ describe('useToolSelectorState', () => {
it('should show tab slider when both settings and params exist', () => {
const { result } = renderHook(() =>
useToolSelectorState({ value: toolValue, onSelect: mockOnSelect }),
useToolSelector({ value: toolValue, onSelect: mockOnSelect }),
)
expect(result.current.showTabSlider).toBe(true)
@ -138,7 +138,7 @@ describe('useToolSelectorState', () => {
})
it('should toggle panel visibility', () => {
const { result } = renderHook(() => useToolSelectorState({ onSelect: mockOnSelect }))
const { result } = renderHook(() => useToolSelector({ onSelect: mockOnSelect }))
act(() => {
result.current.setIsShow(true)
@ -152,7 +152,7 @@ describe('useToolSelectorState', () => {
})
it('should switch tab type', () => {
const { result } = renderHook(() => useToolSelectorState({ onSelect: mockOnSelect }))
const { result } = renderHook(() => useToolSelector({ onSelect: mockOnSelect }))
act(() => {
result.current.setCurrType('params')
@ -162,7 +162,7 @@ describe('useToolSelectorState', () => {
it('should handle description change', () => {
const { result } = renderHook(() =>
useToolSelectorState({ value: toolValue, onSelect: mockOnSelect }),
useToolSelector({ value: toolValue, onSelect: mockOnSelect }),
)
act(() => {
@ -178,7 +178,7 @@ describe('useToolSelectorState', () => {
it('should handle enabled change', () => {
const { result } = renderHook(() =>
useToolSelectorState({ value: toolValue, onSelect: mockOnSelect }),
useToolSelector({ value: toolValue, onSelect: mockOnSelect }),
)
act(() => {
@ -194,7 +194,7 @@ describe('useToolSelectorState', () => {
it('should handle authorization item click', () => {
const { result } = renderHook(() =>
useToolSelectorState({ value: toolValue, onSelect: mockOnSelect }),
useToolSelector({ value: toolValue, onSelect: mockOnSelect }),
)
act(() => {
@ -209,7 +209,7 @@ describe('useToolSelectorState', () => {
})
it('should not call onSelect if value is undefined', () => {
const { result } = renderHook(() => useToolSelectorState({ onSelect: mockOnSelect }))
const { result } = renderHook(() => useToolSelector({ onSelect: mockOnSelect }))
act(() => {
result.current.handleEnabledChange(true)
@ -219,7 +219,7 @@ describe('useToolSelectorState', () => {
it('should return empty arrays when no provider matches', () => {
const { result } = renderHook(() =>
useToolSelectorState({
useToolSelector({
value: { ...toolValue, provider_name: 'nonexistent' },
onSelect: mockOnSelect,
}),
@ -232,7 +232,7 @@ describe('useToolSelectorState', () => {
})
it('should skip plugin checks after resolving the current provider and tool', () => {
renderHook(() => useToolSelectorState({ value: toolValue, onSelect: mockOnSelect }))
renderHook(() => useToolSelector({ value: toolValue, onSelect: mockOnSelect }))
expect(mockUsePluginInstalledCheck).toHaveBeenCalledWith({
providerPluginId: 'org/test-plugin',
@ -242,7 +242,7 @@ describe('useToolSelectorState', () => {
it('should keep plugin checks enabled when the current tool cannot be resolved', () => {
renderHook(() =>
useToolSelectorState({
useToolSelector({
value: { ...toolValue, tool_name: 'missing-tool' },
onSelect: mockOnSelect,
}),
@ -256,7 +256,7 @@ describe('useToolSelectorState', () => {
it('should keep marketplace fallback enabled after tool queries settle without resolving the provider', () => {
renderHook(() =>
useToolSelectorState({
useToolSelector({
value: {
...toolValue,
provider_name: 'org/market-plugin/search',
@ -276,7 +276,7 @@ describe('useToolSelectorState', () => {
areToolQueriesFetched = false
renderHook(() =>
useToolSelectorState({
useToolSelector({
value: {
...toolValue,
provider_name: 'org/market-plugin/search',
@ -294,7 +294,7 @@ describe('useToolSelectorState', () => {
it('should skip marketplace checks for unresolved non-plugin workflow tools', () => {
renderHook(() =>
useToolSelectorState({
useToolSelector({
value: {
...toolValue,
provider_name: 'author/tool-b',

View File

@ -24,27 +24,17 @@ import { usePluginInstalledCheck } from './use-plugin-installed-check'
export type TabType = 'settings' | 'params'
type UseToolSelectorStateProps = {
type UseToolSelectorProps = {
value?: ToolValue
onSelect: (tool: ToolValue) => void
onSelectMultiple?: (tool: ToolValue[]) => void
}
/**
* Custom hook for managing tool selector state and computed values.
* Consolidates state management, data fetching, and event handlers.
*/
export const useToolSelectorState = ({
value,
onSelect,
onSelectMultiple,
}: UseToolSelectorStateProps) => {
// Panel visibility states
export function useToolSelector({ value, onSelect, onSelectMultiple }: UseToolSelectorProps) {
const [isShow, setIsShow] = useState(false)
const [isShowChooseTool, setIsShowChooseTool] = useState(false)
const [currType, setCurrType] = useState<TabType>('settings')
// Fetch all tools data
const buildInToolsQuery = useAllBuiltInTools()
const customToolsQuery = useAllCustomTools()
const workflowToolsQuery = useAllWorkflowTools()
@ -56,7 +46,6 @@ export const useToolSelectorState = ({
const invalidateAllBuiltinTools = useInvalidateAllBuiltInTools()
const invalidateInstalledPluginList = useInvalidateInstalledPluginList()
// Merge all tools and find current provider
const currentProvider = useMemo(() => {
const mergedTools = [
...(buildInTools || []),
@ -73,7 +62,6 @@ export const useToolSelectorState = ({
mcpToolsQuery,
].every((toolProvidersQuery) => toolProvidersQuery.isFetched)
// Current tool from provider
const currentTool = useMemo(() => {
return currentProvider?.tools.find((tool) => tool.name === value?.tool_name)
}, [currentProvider?.tools, value?.tool_name])
@ -100,7 +88,6 @@ export const useToolSelectorState = ({
value?.type,
])
// Plugin info check
const { inMarketPlace, manifest, pluginID } = usePluginInstalledCheck({
providerPluginId,
enabled:
@ -109,7 +96,6 @@ export const useToolSelectorState = ({
(currentProvider !== undefined || areToolProvidersSettled || !!value?.plugin_id),
})
// Tool settings and params
const currentToolSettings = useMemo(() => {
if (!currentProvider) return []
return (
@ -128,7 +114,6 @@ export const useToolSelectorState = ({
)
}, [currentProvider, value])
// Form schemas
const settingsFormSchemas = useMemo(
() => toolParametersToFormSchemas(currentToolSettings),
[currentToolSettings],
@ -138,18 +123,15 @@ export const useToolSelectorState = ({
[currentToolParams],
)
// Tab visibility flags
const showTabSlider = currentToolSettings.length > 0 && currentToolParams.length > 0
const userSettingsOnly = currentToolSettings.length > 0 && !currentToolParams.length
const reasoningConfigOnly = currentToolParams.length > 0 && !currentToolSettings.length
// Manifest icon URL
const manifestIcon = useMemo(() => {
if (!manifest || !pluginID) return ''
return getIconFromMarketPlace(pluginID)
}, [manifest, pluginID])
// Convert tool default value to tool value format
const getToolValue = useCallback((tool: ToolDefaultValue): ToolValue => {
const settingValues = generateFormValue(
tool.params,
@ -181,7 +163,6 @@ export const useToolSelectorState = ({
}
}, [])
// Event handlers
const handleSelectTool = useCallback(
(tool: ToolDefaultValue) => {
const toolValue = getToolValue(tool)
@ -270,14 +251,13 @@ export const useToolSelectorState = ({
}
}, [invalidateAllBuiltinTools, invalidateInstalledPluginList])
const getSettingsValue = useCallback((): ResourceVarInputs => {
const settingsValue = useMemo((): ResourceVarInputs => {
return getPlainValue(
(value?.settings || {}) as Record<string, { value: unknown }>,
) as ResourceVarInputs
}, [value?.settings])
return {
// State
isShow,
setIsShow,
isShowChooseTool,
@ -285,7 +265,6 @@ export const useToolSelectorState = ({
currType,
setCurrType,
// Computed values
currentProvider,
currentTool,
currentToolSettings,
@ -298,8 +277,8 @@ export const useToolSelectorState = ({
manifestIcon,
inMarketPlace,
manifest,
settingsValue,
// Event handlers
handleSelectTool,
handleSelectMultipleTool,
handleDescriptionChange,
@ -308,6 +287,5 @@ export const useToolSelectorState = ({
handleEnabledChange,
handleAuthorizationItemClick,
handleInstall,
getSettingsValue,
}
}

View File

@ -1,29 +1,39 @@
'use client'
import type { OffsetOptions } from '@floating-ui/react'
import type { Placement } from '@langgenius/dify-ui/popover'
import type { FC } from 'react'
import type { ReactElement, Ref } from 'react'
import type { Node } from 'reactflow'
import type { ToolValue } from '@/app/components/workflow/block-selector/types'
import type { NodeOutPutVar } from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import * as React from 'react'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { CollectionType } from '@/app/components/tools/types'
import Link from '@/next/link'
import {
ToolAuthorizationSection,
ToolBaseForm,
ToolItem,
ToolSettingsPanel,
ToolTrigger,
} from './components'
import { useToolSelectorState } from './hooks/use-tool-selector-state'
import { ToolAuthorizationSection } from './components/tool-authorization-section'
import { ToolBaseForm } from './components/tool-base-form'
import { ToolItem } from './components/tool-item'
import { ToolSettingsPanel } from './components/tool-settings-panel'
import { ToolTrigger } from './components/tool-trigger'
import { useToolSelector } from './hooks/use-tool-selector'
type TriggerProps =
| {
trigger: ReactElement
triggerRef?: never
controlledState: boolean
onControlledStateChange: (state: boolean) => void
}
| {
trigger?: never
triggerRef?: Ref<HTMLButtonElement>
controlledState?: never
onControlledStateChange?: never
}
type Props = Readonly<{
disabled?: boolean
placement?: Placement
offset?: OffsetOptions
scope?: string
value?: ToolValue
selectedTools?: ToolValue[]
@ -32,24 +42,20 @@ type Props = Readonly<{
isEdit?: boolean
onDelete?: () => void
supportEnableSwitch?: boolean
supportAddCustomTool?: boolean
trigger?: React.ReactNode
controlledState?: boolean
onControlledStateChange?: (state: boolean) => void
panelShowState?: boolean
onPanelShowStateChange?: (state: boolean) => void
nodeOutputVars: NodeOutPutVar[]
availableNodes: Node[]
nodeId?: string
}>
}> &
TriggerProps
const ToolSelector: FC<Props> = ({
function ToolSelector({
value,
selectedTools,
isEdit,
disabled,
placement = 'left',
offset = 4,
onSelect,
onSelectMultiple,
onDelete,
@ -63,15 +69,9 @@ const ToolSelector: FC<Props> = ({
nodeOutputVars,
availableNodes,
nodeId = '',
}) => {
triggerRef,
}: Props) {
const { t } = useTranslation()
const sideOffset =
typeof offset === 'number' ? offset : typeof offset === 'function' ? 0 : (offset?.mainAxis ?? 0)
const alignOffset =
typeof offset === 'number' ? 0 : typeof offset === 'function' ? 0 : (offset?.crossAxis ?? 0)
// Use custom hook for state management
const state = useToolSelectorState({ value, onSelect, onSelectMultiple })
const {
isShow,
setIsShow,
@ -97,18 +97,17 @@ const ToolSelector: FC<Props> = ({
handleEnabledChange,
handleAuthorizationItemClick,
handleInstall,
getSettingsValue,
} = state
settingsValue,
} = useToolSelector({ value, onSelect, onSelectMultiple })
// Determine portal open state based on controlled vs uncontrolled mode
const portalOpen = trigger ? controlledState : isShow
const onPortalOpenChange = trigger ? onControlledStateChange : setIsShow
const handlePortalOpenChange = (nextOpen: boolean) => {
if (nextOpen && (disabled || !currentProvider || !currentTool)) return
const isConfiguredToolUnavailable = !!value?.provider_name && (!currentProvider || !currentTool)
if (nextOpen && (disabled || isConfiguredToolUnavailable)) return
onPortalOpenChange?.(nextOpen)
}
// Build error tooltip content
const renderErrorTip = () => (
<div className="max-w-[240px] space-y-1 text-xs">
<h3 className="font-semibold text-text-primary">
@ -131,63 +130,67 @@ const ToolSelector: FC<Props> = ({
return (
<Popover open={portalOpen} onOpenChange={handlePortalOpenChange}>
<PopoverTrigger nativeButton={false} render={<div className="w-full" />}>
{trigger}
{trigger ? <PopoverTrigger render={trigger} /> : null}
{/* Default trigger - no value */}
{!trigger && !value?.provider_name && (
<ToolTrigger isConfigure open={isShow} value={value} provider={currentProvider} />
)}
{!trigger && !value?.provider_name ? (
<PopoverTrigger
render={
<ToolTrigger
ref={triggerRef}
isConfigure
open={isShow}
value={value}
provider={currentProvider}
/>
}
/>
) : null}
{/* Default trigger - with value */}
{!trigger && value?.provider_name && (
<ToolItem
open={isShow}
icon={currentProvider?.icon || manifestIcon}
isMCPTool={currentProvider?.type === CollectionType.mcp}
providerName={value.provider_name}
providerShowName={value.provider_show_name}
toolLabel={value.tool_label || value.tool_name}
showSwitch={supportEnableSwitch}
switchValue={value.enabled}
onSwitchChange={handleEnabledChange}
onDelete={onDelete}
noAuth={currentProvider && currentTool && !currentProvider.is_team_authorization}
uninstalled={!currentProvider && inMarketPlace}
versionMismatch={currentProvider && inMarketPlace && !currentTool}
installInfo={manifest?.latest_package_identifier}
onInstall={handleInstall}
isError={(!currentProvider || !currentTool) && !inMarketPlace}
errorTip={renderErrorTip()}
/>
)}
</PopoverTrigger>
{!trigger && value?.provider_name ? (
<ToolItem
triggerRef={triggerRef}
triggerLabel={value.tool_label || value.tool_name}
open={isShow}
icon={currentProvider?.icon || manifestIcon}
isMCPTool={currentProvider?.type === CollectionType.mcp}
providerName={value.provider_name}
providerShowName={value.provider_show_name}
toolLabel={value.tool_label || value.tool_name}
showSwitch={supportEnableSwitch}
switchValue={value.enabled}
onSwitchChange={handleEnabledChange}
onDelete={onDelete}
noAuth={currentProvider && currentTool && !currentProvider.is_team_authorization}
uninstalled={!currentProvider && inMarketPlace}
versionMismatch={currentProvider && inMarketPlace && !currentTool}
installInfo={manifest?.latest_package_identifier}
onInstall={handleInstall}
isError={(!currentProvider || !currentTool) && !inMarketPlace}
errorTip={renderErrorTip()}
/>
) : null}
<PopoverContent
placement={placement}
sideOffset={sideOffset}
alignOffset={alignOffset}
sideOffset={4}
popupClassName="border-none bg-transparent shadow-none"
>
<div
className={cn(
'relative max-h-[642px] min-h-20 w-[361px] rounded-xl',
'border-[0.5px] border-components-panel-border bg-components-panel-bg-blur',
'overflow-y-auto pb-2 pb-4 shadow-lg backdrop-blur-xs',
'overflow-y-auto pb-4 shadow-lg backdrop-blur-xs',
)}
>
{/* Header */}
<div className="px-4 pt-3.5 pb-1 system-xl-semibold text-text-primary">
{t(($) => $[`detailPanel.toolSelector.${isEdit ? 'toolSetting' : 'title'}`], {
ns: 'plugin',
})}
</div>
{/* Base form: tool picker + description */}
<ToolBaseForm
value={value}
currentProvider={currentProvider}
offset={offset}
scope={scope}
selectedTools={selectedTools}
isShowChooseTool={isShowChooseTool}
@ -200,14 +203,12 @@ const ToolSelector: FC<Props> = ({
onDescriptionChange={handleDescriptionChange}
/>
{/* Authorization section */}
<ToolAuthorizationSection
currentProvider={currentProvider}
credentialId={value?.credential_id}
onAuthorizationItemClick={handleAuthorizationItemClick}
/>
{/* Settings panel */}
<ToolSettingsPanel
value={value}
currentProvider={currentProvider}
@ -215,7 +216,7 @@ const ToolSelector: FC<Props> = ({
currType={currType}
settingsFormSchemas={settingsFormSchemas}
paramsFormSchemas={paramsFormSchemas}
settingsValue={getSettingsValue()}
settingsValue={settingsValue}
showTabSlider={showTabSlider}
userSettingsOnly={userSettingsOnly}
reasoningConfigOnly={reasoningConfigOnly}
@ -231,4 +232,4 @@ const ToolSelector: FC<Props> = ({
)
}
export default React.memo(ToolSelector)
export default memo(ToolSelector)

View File

@ -1,25 +1,30 @@
'use client'
import type { FC, ReactNode } from 'react'
import type { ButtonHTMLAttributes, ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
type StartNodeOptionProps = {
type StartNodeOptionProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children'> & {
icon: ReactNode
title: string
subtitle?: string
description: string
onClick: () => void
}
const StartNodeOption: FC<StartNodeOptionProps> = ({
function StartNodeOption({
icon,
title,
subtitle,
description,
onClick,
}) => {
className,
...buttonProps
}: StartNodeOptionProps) {
return (
<div
onClick={onClick}
className="flex h-40 w-[280px] cursor-pointer flex-col gap-2 rounded-xl border-[0.5px] border-components-option-card-option-border bg-components-panel-on-panel-item-bg p-4 shadow-sm transition-all hover:shadow-md"
<button
type="button"
className={cn(
'flex h-40 w-[280px] cursor-pointer flex-col gap-2 rounded-xl border-[0.5px] border-components-option-card-option-border bg-components-panel-on-panel-item-bg p-4 text-left shadow-sm transition-shadow hover:shadow-md focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden',
className,
)}
{...buttonProps}
>
<div className="shrink-0">{icon}</div>
@ -37,7 +42,7 @@ const StartNodeOption: FC<StartNodeOptionProps> = ({
<p className="system-xs-regular text-text-tertiary">{description}</p>
</div>
</div>
</div>
</button>
)
}

View File

@ -3,7 +3,7 @@ import type { FC } from 'react'
import type { BlockDefaultValue } from '@/app/components/workflow/block-selector/types'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import NodeSelector from '@/app/components/workflow/block-selector'
import BlockSelector from '@/app/components/workflow/block-selector'
import { TabType } from '@/app/components/workflow/block-selector/types'
import { BlockEnum } from '@/app/components/workflow/types'
import StartNodeOption from './start-node-option'
@ -41,15 +41,12 @@ const StartNodeSelectionPanel: FC<StartNodeSelectionPanelProps> = ({
onClick={onSelectUserInput}
/>
<NodeSelector
<BlockSelector
open={showTriggerSelector}
onOpenChange={setShowTriggerSelector}
onSelect={handleTriggerSelect}
placement="right"
offset={-200}
noBlocks={true}
showStartTab={true}
defaultActiveTab={TabType.Start}
placement="bottom-end"
standalonePanel={TabType.Start}
availableBlocksTypes={[
BlockEnum.TriggerSchedule,
BlockEnum.TriggerWebhook,

View File

@ -53,14 +53,12 @@ vi.mock('@/app/components/workflow/block-selector', () => ({
onOpenChange: (open: boolean) => void
onSelect: (nodeType: string, pluginDefaultValue?: Record<string, unknown>) => void
availableBlocksTypes: string[]
triggerClassName?: () => string
}) => {
mockBlockSelector(props)
return (
<button
type="button"
data-testid="block-selector"
data-trigger-class={props.triggerClassName?.()}
onClick={() => {
props.onOpenChange(true)
props.onSelect('llm', { provider: 'openai' })
@ -247,10 +245,6 @@ describe('CustomEdge', () => {
'data-stroke',
'var(--color-workflow-link-line-normal)',
)
expect(screen.getByTestId('block-selector')).toHaveAttribute(
'data-trigger-class',
'hover:scale-150 transition-all',
)
expect(screen.getByTestId('block-selector').parentElement).toHaveStyle({
opacity: '0',
pointerEvents: 'none',

View File

@ -0,0 +1,73 @@
# Block Selector
## Public contract
`index.tsx` is the only public component entry. It exports `BlockSelectorProps` and defaults to `BlockSelector`. There is no forwarding entry file or compatibility alias.
The component owns controlled or uncontrolled open state, disabled behavior, the modal popover, initial focus, Escape dismissal, focus return, and selection-driven close. Canvas subscriptions and available-item resolution live in the mounted popup content so a closed selector does not subscribe to workflow state.
`trigger` must return one focusable button root that accepts the props and ref supplied by Dify UI `PopoverTrigger`. Compound or non-forwarding wrapper components are not valid trigger roots. Use `triggerAriaLabel` for icon-only triggers and `triggerTooltip` only when the visible control needs supplemental hover and focus help.
Positioning uses the Dify UI popover API directly. Prefer `placement` alone. `sideOffset` and `alignOffset` are escape hatches for a call site with a verified geometric constraint; the selector does not translate custom offset shapes.
Standalone selectors must declare `standalonePanel`. Availability props such as `noBlocks` only determine which tabs exist and must not change the layout mode implicitly.
## Internal Modules
- `index`: Public `BlockSelector` entry, popover lifecycle, trigger contract, focus management, and workflow-state adaptation.
- `tabs`: Tab order, one-session filter state, and panel routing.
- `tool-panel` and `tool-browser`: Installed-tool query adaptation and the complete tool browsing surface.
- `blocks`, `data-sources`, and `all-start-blocks`: Domain panels for workflow nodes, data sources, and entry nodes.
- `snippets`: Snippet filtering, insertion, list rows, and preview content.
- `tool`, `trigger-plugin`, and `marketplace-plugin`: Row and list modules for installed and Marketplace integrations.
- `featured-tools`, `featured-triggers`, and `rag-tool-recommendations`: Recommendation sections owned by this selector.
- `hooks`, `storage`, `tool-list-data`, and `types`: Selector-specific state and data contracts; these are not public compatibility entrypoints.
## External Modules
- `app/components/plugins/marketplace`: Marketplace queries, search controls, categories, and URL construction.
- `app/components/tools`: Installed tool contracts, permissions, and custom-tool creation.
- `app/components/workflow`: Workflow node contracts, stores, node metadata, and insertion callbacks.
- `features/system-features`: Marketplace feature availability.
- `service/use-plugins`, `service/use-tools`, and `service/use-triggers`: Remote recommendation and installed-provider queries.
## Ownership
`BlockSelectorPanels` owns one mounted popup session. Each tab keeps independent search and tag state for that session. Closing the popup unmounts the session and resets those values.
Each panel owns state that only affects its content:
- `ToolPanel` adapts installed-tool queries into `ToolBrowser`; `ToolBrowser` owns tool category, view, marketplace search, and tool-list presentation.
- `DataSources` owns local source filtering and datasource marketplace search.
- `AllStartBlocks` owns trigger providers and trigger-specific expansion state.
- `Snippets` owns snippet tags, pagination, insertion, and its preview-card handle.
Remote marketplace search is debounced at the section that issues the request. Local filtering uses the current input value. Data fetching and store synchronization do not belong in row components.
## Interaction contract
Tabs follow this order: Blocks, Tools, Sources, Start, Snippets. The tab list precedes the active panel in DOM order. Opening the selector focuses the active panel search field; `Shift+Tab` returns to the active tab. Arrow keys move focus between tabs and `Enter` or `Space` activates the focused tab.
Rows use one of three DOM structures:
- A single action is one native button.
- An independently expandable group uses `Collapsible` with a native trigger and an associated panel.
- A compound row uses a non-interactive container with separate primary and secondary buttons or links. Interactive elements must never be nested.
List controls use the shared two-pixel accent focus indicator. Use an inset ring inside clipped or scrollable surfaces so the ring follows the row boundary.
Each preview-enabled list owns one `PreviewCard` root and one detached handle. Rows provide focusable `PreviewCardTrigger` payloads with `delay={150}` and `closeDelay={150}`, and preview content uses `BlockSelectorPreviewCardContent`.
Block Selector deliberately composes those triggers with native row buttons rather than links. This is a feature-owned visual enhancement, not an extension of the shared Dify UI primitive contract. The row retains the complete selection or insertion action. Preview availability must not depend on an optional description; a card may show nonessential read-only context such as the item name, icon, author, or block types, but that context must not affect whether users can identify or activate the row. Preview content contains no independent interactions and never adds a second action. If any preview-only context becomes necessary for choosing an item, surface it in the row or replace this feature-level composition with an accessible disclosure.
## Testing
Tests protect observable behavior through public interfaces:
- roles, accessible names, states, and relationships;
- keyboard activation and logical Tab order;
- initial focus, Escape dismissal, and focus return;
- independent per-tab session state and reset after close;
- disabled behavior and selection side effects.
Do not assert utility classes, child indexes, component implementation details, or third-party primitive internals. Use `userEvent` and semantic queries. Verify geometry, clipped focus indicators, hover/focus reveal, and real browser focus order in Browser Mode or E2E rather than happy-dom.

View File

@ -2,7 +2,8 @@ import type { ReactElement } from 'react'
import type { TriggerWithProvider } from '../types'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useMarketplacePlugins } from '@/app/components/plugins/marketplace/hooks'
import { useMarketplacePlugins } from '@/app/components/plugins/marketplace/query'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { CollectionType } from '@/app/components/tools/types'
import { useGetLanguage, useLocale } from '@/context/i18n'
import useTheme from '@/hooks/use-theme'
@ -25,7 +26,7 @@ vi.mock('@/hooks/use-theme', () => ({
default: vi.fn(),
}))
vi.mock('@/app/components/plugins/marketplace/hooks', () => ({
vi.mock('@/app/components/plugins/marketplace/query', () => ({
useMarketplacePlugins: vi.fn(),
}))
@ -120,21 +121,16 @@ const render = (ui: ReactElement) =>
})
const createMarketplacePluginsMock = (
overrides: Partial<UseMarketplacePluginsReturn> = {},
): UseMarketplacePluginsReturn => ({
plugins: [],
total: 0,
resetPlugins: vi.fn(),
queryPlugins: vi.fn(),
queryPluginsWithDebounced: vi.fn(),
cancelQueryPluginsWithDebounced: vi.fn(),
isLoading: false,
isFetchingNextPage: false,
hasNextPage: false,
fetchNextPage: vi.fn(),
page: 0,
...overrides,
})
plugins: ReturnType<typeof createPlugin>[] = [],
): UseMarketplacePluginsReturn =>
({
data: plugins.length
? {
pages: [{ plugins, page: 1, page_size: 40, total: plugins.length }],
pageParams: [1],
}
: undefined,
}) as UseMarketplacePluginsReturn
const createTriggerPluginsQueryResult = (data: TriggerWithProvider[]): UseAllTriggerPluginsReturn =>
({
@ -204,7 +200,7 @@ describe('AllStartBlocks', () => {
const user = userEvent.setup()
const onSelect = vi.fn()
const { container } = render(
render(
<AllStartBlocks
searchText=""
onSelect={onSelect}
@ -221,8 +217,6 @@ describe('AllStartBlocks', () => {
expect(screen.queryByText('workflow.tabs.allTriggers')).not.toBeInTheDocument()
expect(screen.getByText('workflow.blocks.mostCommon')).toBeInTheDocument()
expect(screen.getByText('Provider One')).toBeInTheDocument()
expect(container.querySelectorAll('.bg-divider-subtle')).toHaveLength(0)
await user.click(screen.getByText('workflow.blocks.start'))
expect(onSelect).toHaveBeenCalledWith(BlockEnum.Start)
@ -238,7 +232,7 @@ describe('AllStartBlocks', () => {
)
})
it('should show marketplace footer when marketplace is enabled without filters', async () => {
it('should link to the trigger marketplace when marketplace is enabled', async () => {
enableMarketplaceForRender = true
render(
@ -249,21 +243,14 @@ describe('AllStartBlocks', () => {
/>,
)
const footer = await screen.findByRole('link', { name: /plugin\.findMoreInMarketplace/ })
expect(footer).toHaveAttribute('href', 'https://marketplace.test/plugins/trigger')
expect(footer).toHaveClass(
'system-sm-medium',
'h-8',
'rounded-b-lg',
'bg-components-panel-bg-blur',
'text-text-accent-light-mode-only',
'shadow-lg',
)
expect(footer.querySelector('.i-custom-vender-main-nav-marketplace')).not.toBeInTheDocument()
expect(footer.querySelector('svg')).toBeInTheDocument()
const footerLink = await screen.findByRole('link', {
name: /plugin\.findMoreInMarketplace/,
})
expect(footerLink.closest('footer')).toBeInTheDocument()
expect(footerLink).toHaveAttribute('href', 'https://marketplace.test/plugins/trigger')
})
it('should keep the panel marketplace footer icon style', async () => {
it('should expose the panel marketplace destination', async () => {
enableMarketplaceForRender = true
render(
@ -275,30 +262,11 @@ describe('AllStartBlocks', () => {
/>,
)
const footer = await screen.findByRole('link', {
const footerLink = await screen.findByRole('link', {
name: /workflow\.nodes\.startPlaceholder\.browseMoreOnMarketplace/,
})
expect(footer).toHaveAttribute('href', 'https://marketplace.test/plugins/trigger')
expect(footer.querySelector('.w-8 .bg-divider-subtle')).toBeInTheDocument()
expect(footer.querySelector('.i-custom-vender-workflow-marketplace')).toBeInTheDocument()
})
it('should keep the panel divider between user input and installed triggers', async () => {
const { container } = render(
<AllStartBlocks
variant="panel"
searchText=""
onSelect={vi.fn()}
availableBlocksTypes={[BlockEnum.Start, BlockEnum.TriggerPlugin]}
allowUserInputSelection
/>,
)
await waitFor(() => {
expect(screen.getByText('Provider One')).toBeInTheDocument()
})
expect(container.querySelectorAll('.px-4.py-1 .bg-divider-subtle')).toHaveLength(1)
expect(footerLink.closest('footer')).toBeInTheDocument()
expect(footerLink).toHaveAttribute('href', 'https://marketplace.test/plugins/trigger')
})
it('should render searched marketplace results after built-in and installed trigger options', async () => {
@ -311,14 +279,12 @@ describe('AllStartBlocks', () => {
]),
)
mockUseMarketplacePlugins.mockReturnValue(
createMarketplacePluginsMock({
plugins: [
createPlugin({
name: 'start-marketplace',
label: { en_US: 'Start Marketplace', zh_Hans: 'Start Marketplace' },
}),
],
}),
createMarketplacePluginsMock([
createPlugin({
name: 'start-marketplace',
label: { en_US: 'Start Marketplace', zh_Hans: 'Start Marketplace' },
}),
]),
)
const { container } = render(
@ -338,7 +304,6 @@ describe('AllStartBlocks', () => {
expect(text.indexOf('workflow.blocks.start')).toBeLessThan(text.indexOf('Start Provider'))
expect(text.indexOf('Start Provider')).toBeLessThan(text.indexOf('Start Marketplace'))
expect(screen.getAllByRole('link', { name: /plugin\.searchInMarketplace/i })).toHaveLength(1)
expect(container.querySelectorAll('.px-4.py-1 .bg-divider-subtle')).toHaveLength(1)
})
it('should show the user input conflict state without allowing another start selection', () => {
@ -366,21 +331,15 @@ describe('AllStartBlocks', () => {
screen.getByText('workflow.nodes.startPlaceholder.userInputConflictTip'),
).toBeInTheDocument()
expect(screen.queryByText('workflow.tabs.allTriggers')).not.toBeInTheDocument()
expect(screen.getByText('workflow.blocks.start')).toBeInTheDocument()
const userInputButton = screen.getByRole('button', { name: 'workflow.blocks.start' })
expect(userInputButton).toHaveAttribute('aria-disabled', 'true')
expect(screen.getByText('common.operation.added')).toBeInTheDocument()
const footer = screen.getByRole('link', { name: /plugin\.findMoreInMarketplace/ })
expect(footer).toHaveClass(
'system-sm-medium',
'h-8',
'rounded-b-lg',
'bg-components-panel-bg-blur',
'text-text-accent-light-mode-only',
'shadow-lg',
)
expect(footer.querySelector('.i-custom-vender-main-nav-marketplace')).not.toBeInTheDocument()
expect(footer.querySelector('svg')).toBeInTheDocument()
const disabledRegion = userInputButton.closest('[inert]')
expect(disabledRegion).toHaveAttribute('inert')
expect(disabledRegion).not.toContainElement(footer)
fireEvent.click(screen.getByText('workflow.blocks.start'))
fireEvent.click(userInputButton)
fireEvent.click(screen.getByText('Provider One'))
expect(onSelect).not.toHaveBeenCalled()
@ -400,21 +359,18 @@ describe('AllStartBlocks', () => {
)
expect(screen.queryByText('workflow.tabs.allTriggers')).not.toBeInTheDocument()
expect(screen.getByText('workflow.blocks.start')).toBeInTheDocument()
expect(
screen.getByText('workflow.blocks.mostCommon').closest('.opacity-30'),
).toBeInTheDocument()
expect(
screen.getByText('workflow.blocks.start').closest('.cursor-not-allowed'),
).toBeInTheDocument()
const userInputButton = screen.getByRole('button', {
name: /workflow\.blocks\.start.*workflow\.nodes\.startPlaceholder\.userInputConflictTip/,
})
expect(userInputButton).toHaveAttribute('aria-disabled', 'true')
await user.hover(screen.getByText('workflow.blocks.start'))
await user.hover(userInputButton)
expect(
await screen.findByText('workflow.nodes.startPlaceholder.userInputConflictTip'),
).toBeInTheDocument()
fireEvent.click(screen.getByText('workflow.blocks.start'))
fireEvent.click(userInputButton)
expect(onSelect).not.toHaveBeenCalled()
await user.click(screen.getByText('workflow.blocks.trigger-schedule'))
@ -424,14 +380,25 @@ describe('AllStartBlocks', () => {
// Empty filter states should surface the request-to-community fallback.
describe('Filtered Empty State', () => {
it('should query marketplace and show the no-results state when filters have no matches', async () => {
const queryPluginsWithDebounced = vi.fn()
enableMarketplaceForRender = true
mockUseMarketplacePlugins.mockReturnValue(
createMarketplacePluginsMock({
queryPluginsWithDebounced,
}),
it('should show the no-results state immediately when marketplace search is unavailable', () => {
mockUseAllTriggerPlugins.mockReturnValue(createTriggerPluginsQueryResult([]))
render(
<AllStartBlocks
searchText="missing"
onSelect={vi.fn()}
availableBlocksTypes={[BlockEnum.TriggerPlugin]}
/>,
)
expect(
screen.getByText('workflow.nodes.startPlaceholder.noTriggersFound'),
).toBeInTheDocument()
expect(mockUseMarketplacePlugins).toHaveBeenLastCalledWith(undefined)
})
it('should query marketplace and show the no-results state when filters have no matches', async () => {
enableMarketplaceForRender = true
mockUseAllTriggerPlugins.mockReturnValue(createTriggerPluginsQueryResult([]))
render(
@ -444,7 +411,7 @@ describe('AllStartBlocks', () => {
)
await waitFor(() => {
expect(queryPluginsWithDebounced).toHaveBeenCalledWith({
expect(mockUseMarketplacePlugins).toHaveBeenCalledWith({
query: 'missing',
tags: ['webhook'],
category: 'trigger',
@ -465,5 +432,49 @@ describe('AllStartBlocks', () => {
'https://marketplace.test/plugins/trigger',
)
})
it('should debounce marketplace requests across search and tag changes', async () => {
enableMarketplaceForRender = true
mockUseAllTriggerPlugins.mockReturnValue(createTriggerPluginsQueryResult([]))
const props = {
onSelect: vi.fn(),
availableBlocksTypes: [BlockEnum.TriggerPlugin],
}
const { rerender } = render(<AllStartBlocks {...props} searchText="" tags={[]} />)
rerender(<AllStartBlocks {...props} searchText="w" tags={[]} />)
rerender(<AllStartBlocks {...props} searchText="web" tags={['api']} />)
rerender(<AllStartBlocks {...props} searchText="webhook" tags={['automation']} />)
expect(
mockUseMarketplacePlugins.mock.calls
.map(([params]) => params)
.filter((params) => params?.query || params?.tags?.length),
).toEqual([])
expect(
screen.queryByText('workflow.nodes.startPlaceholder.noTriggersFound'),
).not.toBeInTheDocument()
await waitFor(() => {
expect(mockUseMarketplacePlugins).toHaveBeenLastCalledWith({
query: 'webhook',
tags: ['automation'],
category: PluginCategoryEnum.trigger,
})
})
expect(
mockUseMarketplacePlugins.mock.calls
.map(([params]) => params)
.filter((params) => params?.query || params?.tags?.length),
).toEqual([
{
query: 'webhook',
tags: ['automation'],
category: PluginCategoryEnum.trigger,
},
])
})
})
})

View File

@ -2,7 +2,7 @@ import type { ReactElement } from 'react'
import type { ToolWithProvider } from '../../types'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useMarketplacePlugins } from '@/app/components/plugins/marketplace/hooks'
import { useMarketplacePlugins } from '@/app/components/plugins/marketplace/query'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { CollectionType } from '@/app/components/tools/types'
import { useGetLanguage } from '@/context/i18n'
@ -20,7 +20,7 @@ vi.mock('@/hooks/use-theme', () => ({
default: vi.fn(),
}))
vi.mock('@/app/components/plugins/marketplace/hooks', () => ({
vi.mock('@/app/components/plugins/marketplace/query', () => ({
useMarketplacePlugins: vi.fn(),
}))
@ -66,20 +66,11 @@ const createToolProvider = (overrides: Partial<ToolWithProvider> = {}): ToolWith
const createMarketplacePluginsMock = (
overrides: Partial<UseMarketplacePluginsReturn> = {},
): UseMarketplacePluginsReturn => ({
plugins: [],
total: 0,
resetPlugins: vi.fn(),
queryPlugins: vi.fn(),
queryPluginsWithDebounced: vi.fn(),
cancelQueryPluginsWithDebounced: vi.fn(),
isLoading: false,
isFetchingNextPage: false,
hasNextPage: false,
fetchNextPage: vi.fn(),
page: 0,
...overrides,
})
): UseMarketplacePluginsReturn =>
({
data: undefined,
...overrides,
}) as UseMarketplacePluginsReturn
describe('DataSources', () => {
beforeEach(() => {
@ -150,19 +141,30 @@ describe('DataSources', () => {
// Marketplace search should only run when enabled and a search term is present.
describe('Marketplace Search', () => {
it('should query marketplace plugins for datasource search results', async () => {
const queryPluginsWithDebounced = vi.fn()
it('should debounce marketplace requests while keeping datasource filtering responsive', async () => {
enableMarketplaceForRender = true
mockUseMarketplacePlugins.mockReturnValue(
createMarketplacePluginsMock({
queryPluginsWithDebounced,
}),
const onSelect = vi.fn()
const dataSources = [createToolProvider()]
const { rerender } = render(
<DataSources searchText="" onSelect={onSelect} dataSources={dataSources} />,
)
render(<DataSources searchText="invoice" onSelect={vi.fn()} dataSources={[]} />)
rerender(<DataSources searchText="i" onSelect={onSelect} dataSources={dataSources} />)
expect(
screen.queryByRole('link', { name: /plugin\.findMoreInMarketplace/ }),
).not.toBeInTheDocument()
rerender(<DataSources searchText="in" onSelect={onSelect} dataSources={dataSources} />)
rerender(<DataSources searchText="invoice" onSelect={onSelect} dataSources={dataSources} />)
expect(
mockUseMarketplacePlugins.mock.calls
.map(([params]) => params)
.filter((params) => params?.query && params.query !== 'invoice'),
).toEqual([])
await waitFor(() => {
expect(queryPluginsWithDebounced).toHaveBeenCalledWith({
expect(mockUseMarketplacePlugins).toHaveBeenLastCalledWith({
query: 'invoice',
category: PluginCategoryEnum.datasource,
})

View File

@ -20,7 +20,7 @@ vi.mock('@/app/components/workflow/nodes/_base/components/mcp-tool-availability'
}),
}))
vi.mock('@/app/components/workflow/block-selector/market-place-plugin/action', () => ({
vi.mock('@/app/components/workflow/block-selector/marketplace-plugin/action', () => ({
default: () => <button type="button" aria-label="common.operation.more" />,
}))
@ -77,8 +77,6 @@ describe('FeaturedTools', () => {
const showMoreButton = screen.getByRole('button', {
name: 'workflow.tabs.showMoreFeatured',
})
expect(showMoreButton).not.toHaveAttribute('aria-expanded')
await user.click(showMoreButton)
expect(screen.getByText('Provider 10')).toBeInTheDocument()
@ -87,10 +85,16 @@ describe('FeaturedTools', () => {
await user.click(screen.getByRole('button', { name: 'workflow.tabs.showMoreFeatured' }))
expect(screen.getByText('Provider 11')).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'workflow.tabs.showLessFeatured' }),
).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'workflow.tabs.showLessFeatured' }))
expect(screen.queryByText('Provider 6')).not.toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'workflow.tabs.showMoreFeatured' }),
).toBeInTheDocument()
})
it('restores the collapsed state and expands from the keyboard', async () => {
@ -109,7 +113,8 @@ describe('FeaturedTools', () => {
expect(trigger).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByText('Provider One')).not.toBeInTheDocument()
trigger.focus()
await user.tab()
expect(trigger).toHaveFocus()
await user.keyboard('{Enter}')
expect(trigger).toHaveAttribute('aria-expanded', 'true')
@ -133,6 +138,8 @@ describe('FeaturedTools', () => {
const moreButton = screen.getByRole('button', { name: 'common.operation.more' })
expect(detailsLink).toHaveAttribute('href', 'https://marketplace.test/plugins/org/plugin-one')
expect(detailsLink).not.toContainElement(installButton)
expect(detailsLink).not.toContainElement(moreButton)
detailsLink.focus()
await user.tab()

View File

@ -13,7 +13,7 @@ vi.mock('@/hooks/use-theme', () => ({
default: vi.fn(),
}))
vi.mock('@/app/components/workflow/block-selector/market-place-plugin/action', () => ({
vi.mock('@/app/components/workflow/block-selector/marketplace-plugin/action', () => ({
default: () => <button type="button" aria-label="common.operation.more" />,
}))
@ -116,7 +116,8 @@ describe('FeaturedTriggers', () => {
const trigger = screen.getByRole('button', { name: /workflow\.tabs\.featuredTools/ })
expect(trigger).toHaveAttribute('aria-expanded', 'true')
await user.click(trigger)
trigger.focus()
await user.keyboard(' ')
expect(trigger).toHaveAttribute('aria-expanded', 'false')
@ -153,17 +154,21 @@ describe('FeaturedTriggers', () => {
const showMoreButton = screen.getByRole('button', {
name: 'workflow.tabs.showMoreFeatured',
})
expect(showMoreButton).not.toHaveAttribute('aria-expanded')
await user.click(showMoreButton)
expect(screen.getByText('Provider 9')).toBeInTheDocument()
expect(screen.queryByText('Provider 10')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'workflow.tabs.showMoreFeatured' }))
expect(screen.getByText('Provider 10')).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'workflow.tabs.showLessFeatured' }),
).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'workflow.tabs.showLessFeatured' }))
expect(screen.queryByText('Provider 5')).not.toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'workflow.tabs.showMoreFeatured' }),
).toBeInTheDocument()
})
})
@ -225,6 +230,8 @@ describe('FeaturedTriggers', () => {
'href',
'https://marketplace.test/plugins/org/trigger-plugin',
)
expect(detailsLink).not.toContainElement(installButton)
expect(detailsLink).not.toContainElement(moreButton)
detailsLink.focus()
await user.tab()

View File

@ -60,7 +60,25 @@ describe('block-selector hooks', () => {
it('includes the snippets tab by default', () => {
const { result } = renderHook(() => useTabs({}))
expect(result.current.tabs.some((tab) => tab.key === TabType.Snippets)).toBe(true)
expect(result.current.tabs.map((tab) => tab.key)).toEqual([
TabType.Blocks,
TabType.Tools,
TabType.Sources,
TabType.Snippets,
])
})
it('uses the same category order for tabs and the default fallback', () => {
const { result } = renderHook(() => useTabs({ noStart: false, noSources: false }))
expect(result.current.tabs.map((tab) => tab.key)).toEqual([
TabType.Blocks,
TabType.Tools,
TabType.Sources,
TabType.Start,
TabType.Snippets,
])
expect(result.current.initialTab).toBe(TabType.Blocks)
})
it('hides the snippets tab and falls back when snippets are disabled', () => {

View File

@ -1,79 +1,15 @@
import type { ToolWithProvider } from '../../types'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { CollectionType } from '../../../tools/types'
import IndexBar, {
CUSTOM_GROUP_NAME,
DATA_SOURCE_GROUP_NAME,
groupItems,
WORKFLOW_GROUP_NAME,
} from '../index-bar'
const createToolProvider = (overrides: Partial<ToolWithProvider> = {}): ToolWithProvider => ({
id: 'provider-1',
name: 'Provider 1',
author: 'Author',
description: { en_US: 'desc', zh_Hans: '描述' },
icon: 'icon',
label: { en_US: 'Alpha', zh_Hans: '甲' },
type: CollectionType.builtIn,
team_credentials: {},
is_team_authorization: false,
allow_delete: false,
labels: [],
tools: [],
meta: { version: '1.0.0' },
...overrides,
})
import { IndexBar } from '../index-bar'
describe('IndexBar', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Grouping should normalize Chinese initials, custom groups, and hash ordering.
describe('groupItems', () => {
it('should group providers by first letter and move hash to the end', () => {
const items: ToolWithProvider[] = [
createToolProvider({
id: 'alpha',
label: { en_US: 'Alpha', zh_Hans: '甲' },
type: CollectionType.builtIn,
author: 'Builtin',
}),
createToolProvider({
id: 'custom',
label: { en_US: '1Custom', zh_Hans: '1自定义' },
type: CollectionType.custom,
author: 'Custom',
}),
createToolProvider({
id: 'workflow',
label: { en_US: '中文工作流', zh_Hans: '中文工作流' },
type: CollectionType.workflow,
author: 'Workflow',
}),
createToolProvider({
id: 'source',
label: { en_US: 'Data Source', zh_Hans: '数据源' },
type: CollectionType.datasource,
author: 'Data',
}),
]
const result = groupItems(items, (item) => item.label.zh_Hans[0] || item.label.en_US[0] || '')
expect(result.letters).toEqual(['J', 'S', 'Z', '#'])
expect(result.groups.J!.Builtin).toHaveLength(1)
expect(result.groups.Z![WORKFLOW_GROUP_NAME]).toHaveLength(1)
expect(result.groups.S![DATA_SOURCE_GROUP_NAME]).toHaveLength(1)
expect(result.groups['#']![CUSTOM_GROUP_NAME]).toHaveLength(1)
})
})
// Clicking a letter should scroll the matching section into view.
describe('Rendering', () => {
it('should call scrollIntoView for the selected letter', async () => {
it('should let keyboard users scroll to the selected letter', async () => {
const user = userEvent.setup()
const scrollIntoView = vi.fn()
const itemRefs = {
@ -84,7 +20,9 @@ describe('IndexBar', () => {
render(<IndexBar letters={['A']} itemRefs={itemRefs} />)
await user.click(screen.getByRole('button', { name: 'A' }))
await user.tab()
expect(screen.getByRole('button', { name: 'A' })).toHaveFocus()
await user.keyboard('{Enter}')
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth' })
})

View File

@ -1,19 +1,41 @@
import type { NodeDefault, ToolWithProvider } from '../../types'
import { screen } from '@testing-library/react'
import type { ButtonHTMLAttributes, ReactElement } from 'react'
import type { NodeDefault } from '../../types'
import { Button } from '@langgenius/dify-ui/button'
import { act, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { FlowType } from '@/types/common'
import BlockSelector from '..'
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
import { BlockEnum } from '../../types'
import NodeSelectorWrapper from '../index'
import { BlockClassification } from '../types'
import { BlockClassification, TabType } from '../types'
vi.mock('reactflow', async () =>
(await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock(),
)
vi.mock('reactflow', () => ({
useStoreApi: () => ({
getState: () => ({
getNodes: () => [],
}),
}),
}))
vi.mock('@/service/use-plugins', () => ({
useFeaturedToolsRecommendations: () => ({
plugins: [],
isLoading: false,
}),
useFeaturedTriggersRecommendations: () => ({
plugins: [],
isLoading: false,
}),
}))
vi.mock('@/app/components/plugins/marketplace/query', () => ({
useMarketplacePlugins: () => ({ data: undefined }),
}))
vi.mock('@/service/use-triggers', () => ({
useAllTriggerPlugins: () => ({ data: [] }),
useInvalidateAllTriggerPlugins: () => vi.fn(),
}))
vi.mock('@/service/use-tools', () => ({
@ -26,10 +48,10 @@ vi.mock('@/service/use-tools', () => ({
const createBlock = (type: BlockEnum, title: string): NodeDefault => ({
metaData: {
classification: BlockClassification.Default,
sort: 0,
type,
title,
sort: 0,
classification: BlockClassification.Default,
author: 'Dify',
description: `${title} description`,
},
@ -37,51 +59,439 @@ const createBlock = (type: BlockEnum, title: string): NodeDefault => ({
checkValid: () => ({ isValid: true }),
})
const dataSource: ToolWithProvider = {
id: 'datasource-1',
name: 'datasource',
author: 'Dify',
description: { en_US: 'Data source', zh_Hans: '数据源' },
icon: 'icon',
label: { en_US: 'Data Source', zh_Hans: 'Data Source' },
type: 'datasource' as ToolWithProvider['type'],
team_credentials: {},
is_team_authorization: false,
allow_delete: false,
labels: [],
tools: [],
meta: { version: '1.0.0' } as ToolWithProvider['meta'],
type RenderBlockSelectorOptions = Parameters<typeof renderWorkflowComponent>[1]
const renderBlockSelector = (ui: ReactElement, options?: RenderBlockSelectorOptions) => {
return renderWorkflowComponent(ui, {
...options,
hooksStoreProps: {
...options?.hooksStoreProps,
configsMap: {
flowId: 'app-1',
flowType: FlowType.appFlow,
fileSettings: {} as never,
...options?.hooksStoreProps?.configsMap,
},
},
})
}
describe('NodeSelectorWrapper', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('filters hidden block types from hooks store and forwards data sources', async () => {
renderWorkflowComponent(
<NodeSelectorWrapper open onSelect={vi.fn()} availableBlocksTypes={[BlockEnum.Code]} />,
describe('BlockSelector', () => {
it('resolves available blocks from the mounted workflow content owner', async () => {
renderBlockSelector(
<BlockSelector open onSelect={vi.fn()} availableBlocksTypes={[BlockEnum.Code]} />,
{
hooksStoreProps: {
availableNodesMetaData: {
nodes: [
createBlock(BlockEnum.Start, 'Start'),
createBlock(BlockEnum.StartPlaceholder, 'Start Placeholder'),
createBlock(BlockEnum.Tool, 'Tool'),
createBlock(BlockEnum.Code, 'Code'),
createBlock(BlockEnum.DataSource, 'Data Source'),
],
},
},
initialStoreState: {
dataSourceList: [dataSource],
},
},
)
expect(await screen.findByText('Code')).toBeInTheDocument()
expect(screen.queryByText('Start')).not.toBeInTheDocument()
expect(screen.queryByText('Start Placeholder')).not.toBeInTheDocument()
expect(screen.queryByText('Tool')).not.toBeInTheDocument()
})
it('opens with the real blocks tab, filters by search, selects a block, and clears search after close', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
renderBlockSelector(
<BlockSelector
onSelect={onSelect}
blocks={[createBlock(BlockEnum.LLM, 'LLM'), createBlock(BlockEnum.End, 'End')]}
availableBlocksTypes={[BlockEnum.LLM, BlockEnum.End]}
trigger={(open) => (
<button type="button">{open ? 'selector-open' : 'selector-closed'}</button>
)}
/>,
)
const trigger = screen.getByRole('button', { name: 'selector-closed' })
expect(trigger).toHaveAttribute('aria-haspopup', 'dialog')
await user.click(trigger)
expect(screen.getByRole('dialog', { name: 'workflow.common.addBlock' })).toBeInTheDocument()
const searchInput = screen.getByRole('searchbox', { name: 'workflow.tabs.searchBlock' })
expect(screen.getByText('LLM')).toBeInTheDocument()
expect(screen.getByText('End')).toBeInTheDocument()
await user.type(searchInput, 'LLM')
expect(screen.getByText('LLM')).toBeInTheDocument()
expect(screen.queryByText('End')).not.toBeInTheDocument()
await user.click(screen.getByText('LLM'))
expect(onSelect).toHaveBeenCalledWith(BlockEnum.LLM, undefined)
await waitFor(() => {
expect(
screen.queryByRole('searchbox', { name: 'workflow.tabs.searchBlock' }),
).not.toBeInTheDocument()
})
await user.click(screen.getByRole('button', { name: 'selector-closed' }))
const reopenedInput = screen.getByRole('searchbox', {
name: 'workflow.tabs.searchBlock',
}) as HTMLInputElement
expect(reopenedInput.value).toBe('')
expect(screen.getByText('End')).toBeInTheDocument()
})
it('resets to the default tab after closing', async () => {
const user = userEvent.setup()
renderBlockSelector(
<BlockSelector
onSelect={vi.fn()}
blocks={[createBlock(BlockEnum.LLM, 'LLM')]}
availableBlocksTypes={[BlockEnum.LLM, BlockEnum.Start]}
showStartTab
trigger={(open) => (
<button type="button">{open ? 'selector-open' : 'selector-closed'}</button>
)}
/>,
)
await user.click(screen.getByRole('button', { name: 'selector-closed' }))
await user.click(screen.getByText('workflow.tabs.start'))
expect(
screen.getByRole('searchbox', { name: 'workflow.tabs.searchTrigger' }),
).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'selector-open' }))
await waitFor(() => {
expect(
screen.queryByRole('searchbox', { name: 'workflow.tabs.searchTrigger' }),
).not.toBeInTheDocument()
})
await user.click(screen.getByRole('button', { name: 'selector-closed' }))
expect(screen.getByRole('searchbox', { name: 'workflow.tabs.searchBlock' })).toBeInTheDocument()
})
it('preserves the current popup session until a controlled close actually unmounts it', async () => {
const user = userEvent.setup()
const onOpenChange = vi.fn()
renderBlockSelector(
<BlockSelector
open
onOpenChange={onOpenChange}
onSelect={vi.fn()}
blocks={[createBlock(BlockEnum.LLM, 'LLM')]}
availableBlocksTypes={[BlockEnum.LLM, BlockEnum.Start]}
showStartTab
trigger={() => <button type="button">selector-open</button>}
/>,
)
await user.click(screen.getByRole('tab', { name: 'workflow.tabs.start' }))
const searchInput = screen.getByRole('searchbox', { name: 'workflow.tabs.searchTrigger' })
await user.type(searchInput, 'webhook')
await user.click(screen.getByRole('button', { name: 'selector-open' }))
expect(onOpenChange).toHaveBeenCalledWith(false)
expect(screen.getByRole('tab', { name: 'workflow.tabs.start' })).toHaveAttribute(
'aria-selected',
'true',
)
expect(searchInput).toHaveValue('webhook')
})
it('focuses search and exposes a non-tabbing close action for touch screen readers', async () => {
const user = userEvent.setup()
renderBlockSelector(
<BlockSelector
onSelect={vi.fn()}
blocks={[createBlock(BlockEnum.LLM, 'LLM')]}
availableBlocksTypes={[BlockEnum.LLM, BlockEnum.Start]}
showStartTab
trigger={(open) => (
<button type="button">{open ? 'selector-open' : 'selector-closed'}</button>
)}
/>,
)
await user.click(screen.getByRole('button', { name: 'selector-closed' }))
expect(screen.getByRole('searchbox', { name: 'workflow.tabs.searchBlock' })).toHaveFocus()
await waitFor(() => {
expect(screen.getByRole('dialog').parentElement).toHaveStyle({ position: 'fixed' })
})
await user.tab({ shift: true })
const blocksTab = screen.getByRole('tab', { name: 'workflow.tabs.blocks' })
expect(blocksTab).toHaveFocus()
const closeButton = screen.getByRole('button', { name: 'common.operation.close' })
expect(closeButton).toHaveAttribute('tabindex', '-1')
const startTab = screen.getByRole('tab', { name: 'workflow.tabs.start' })
await user.click(startTab)
expect(startTab).toHaveFocus()
expect(screen.getByRole('searchbox', { name: 'workflow.tabs.searchTrigger' })).not.toHaveFocus()
})
it('does not open or emit open changes when disabled', async () => {
const user = userEvent.setup()
const onOpenChange = vi.fn()
renderBlockSelector(
<BlockSelector
disabled
onOpenChange={onOpenChange}
onSelect={vi.fn()}
blocks={[createBlock(BlockEnum.LLM, 'LLM')]}
availableBlocksTypes={[BlockEnum.LLM]}
trigger={(open) => (
<button type="button">{open ? 'selector-open' : 'selector-closed'}</button>
)}
/>,
)
const trigger = screen.getByRole('button', { name: 'selector-closed' })
expect(trigger).toBeDisabled()
await user.click(trigger)
expect(onOpenChange).not.toHaveBeenCalled()
expect(
screen.queryByRole('searchbox', { name: 'workflow.tabs.searchBlock' }),
).not.toBeInTheDocument()
})
it('allows an open selector to close after it becomes disabled', async () => {
const user = userEvent.setup()
let disableSelector = () => {}
function Harness() {
const [open, setOpen] = useState(true)
const [disabled, setDisabled] = useState(false)
disableSelector = () => setDisabled(true)
return (
<BlockSelector
open={open}
disabled={disabled}
onOpenChange={setOpen}
onSelect={vi.fn()}
blocks={[createBlock(BlockEnum.LLM, 'LLM')]}
trigger={() => <button type="button">selector-trigger</button>}
/>
)
}
renderBlockSelector(<Harness />)
expect(screen.getByRole('dialog', { name: 'workflow.common.addBlock' })).toBeInTheDocument()
act(disableSelector)
expect(screen.getByRole('button', { name: 'selector-trigger' })).toBeDisabled()
await user.keyboard('{Escape}')
await waitFor(() => {
expect(
screen.queryByRole('dialog', { name: 'workflow.common.addBlock' }),
).not.toBeInTheDocument()
})
})
it('returns focus to the trigger after Escape', async () => {
const user = userEvent.setup()
renderBlockSelector(
<BlockSelector
onSelect={vi.fn()}
blocks={[createBlock(BlockEnum.LLM, 'LLM')]}
trigger={() => <button type="button">selector-trigger</button>}
/>,
)
const trigger = screen.getByRole('button', { name: 'selector-trigger' })
await user.click(trigger)
expect(screen.getByRole('searchbox', { name: 'workflow.tabs.searchBlock' })).toHaveFocus()
await user.keyboard('{Escape}')
expect(trigger).toHaveFocus()
expect(
screen.queryByRole('dialog', { name: 'workflow.common.addBlock' }),
).not.toBeInTheDocument()
})
it('preserves the custom trigger click handler', async () => {
const user = userEvent.setup()
const onTriggerClick = vi.fn()
renderBlockSelector(
<BlockSelector
onSelect={vi.fn()}
blocks={[createBlock(BlockEnum.LLM, 'LLM')]}
availableBlocksTypes={[BlockEnum.LLM]}
trigger={() => (
<button type="button" onClick={onTriggerClick}>
open-selector
</button>
)}
/>,
)
await user.click(screen.getByRole('button', { name: 'open-selector' }))
expect(onTriggerClick).toHaveBeenCalledTimes(1)
expect(screen.getByRole('searchbox', { name: 'workflow.tabs.searchBlock' })).toBeInTheDocument()
})
it('can render a prop-forwarding button component as the popover root', async () => {
const user = userEvent.setup()
function ForwardingButtonTrigger(props: ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button type="button" {...props}>
open-selector-root
</button>
)
}
renderBlockSelector(
<BlockSelector
onSelect={vi.fn()}
blocks={[createBlock(BlockEnum.LLM, 'LLM')]}
availableBlocksTypes={[BlockEnum.LLM]}
trigger={() => <ForwardingButtonTrigger />}
/>,
)
const trigger = screen.getByRole('button', { name: 'open-selector-root' })
await user.click(trigger)
expect(trigger).toHaveAttribute('aria-haspopup', 'dialog')
expect(screen.getByRole('searchbox', { name: 'workflow.tabs.searchBlock' })).toBeInTheDocument()
})
it('can render the shared Button trigger as the popover root', async () => {
const user = userEvent.setup()
renderBlockSelector(
<BlockSelector
onSelect={vi.fn()}
blocks={[createBlock(BlockEnum.LLM, 'LLM')]}
availableBlocksTypes={[BlockEnum.LLM]}
trigger={() => <Button variant="primary">open-shared-button-trigger</Button>}
/>,
)
const trigger = screen.getByRole('button', { name: 'open-shared-button-trigger' })
await user.click(trigger)
expect(trigger).toHaveAttribute('aria-haspopup', 'dialog')
expect(screen.getByRole('searchbox', { name: 'workflow.tabs.searchBlock' })).toBeInTheDocument()
})
it('isolates popup keyboard events when opened from another keyboard-managed overlay', async () => {
const user = userEvent.setup()
const handleParentKeyDown = vi.fn()
renderBlockSelector(
<BlockSelector
open
isolateKeyboardEvents
onSelect={vi.fn()}
blocks={[createBlock(BlockEnum.LLM, 'LLM'), createBlock(BlockEnum.End, 'End')]}
availableBlocksTypes={[BlockEnum.LLM, BlockEnum.End]}
/>,
)
const searchInput = screen.getByRole('searchbox', {
name: 'workflow.tabs.searchBlock',
}) as HTMLInputElement
document.body.addEventListener('keydown', handleParentKeyDown)
try {
await user.type(searchInput, 'LLM')
} finally {
document.body.removeEventListener('keydown', handleParentKeyDown)
}
expect(searchInput.value).toBe('LLM')
expect(handleParentKeyDown).not.toHaveBeenCalled()
expect(screen.getByText('LLM')).toBeInTheDocument()
expect(screen.queryByText('End')).not.toBeInTheDocument()
})
it('disables the start tab with a setup tooltip when an unconfigured start node is on the canvas', async () => {
const user = userEvent.setup()
renderBlockSelector(
<BlockSelector
open
onSelect={vi.fn()}
blocks={[createBlock(BlockEnum.LLM, 'LLM')]}
availableBlocksTypes={[BlockEnum.LLM, BlockEnum.Start]}
showStartTab
defaultActiveTab={TabType.Start}
/>,
{
initialStoreState: {
nodes: [
{
id: 'start-placeholder',
data: {
type: BlockEnum.StartPlaceholder,
},
},
] as never,
},
},
)
await user.hover(screen.getByText('workflow.tabs.start'))
expect(
await screen.findByText('workflow.tabs.unconfiguredStartDisabledTip'),
).toBeInTheDocument()
expect(screen.queryByRole('link')).not.toBeInTheDocument()
expect(screen.getByRole('searchbox', { name: 'workflow.tabs.searchBlock' })).toBeInTheDocument()
})
it('keeps the start tab enabled when a configured user input start node is on the canvas', () => {
renderBlockSelector(
<BlockSelector
open
onSelect={vi.fn()}
blocks={[createBlock(BlockEnum.LLM, 'LLM')]}
availableBlocksTypes={[BlockEnum.LLM, BlockEnum.Start, BlockEnum.TriggerPlugin]}
showStartTab
defaultActiveTab={TabType.Start}
/>,
{
initialStoreState: {
nodes: [
{
id: 'start',
data: {
type: BlockEnum.Start,
},
},
] as never,
},
},
)
expect(screen.getByText('workflow.tabs.start')).toHaveAttribute('aria-disabled', 'false')
expect(
screen.getByText('workflow.nodes.startPlaceholder.userInputConflictTip'),
).toBeInTheDocument()
})
})

View File

@ -1,421 +0,0 @@
import type { ButtonHTMLAttributes, ReactElement } from 'react'
import type { NodeDefault } from '../../types'
import { Button } from '@langgenius/dify-ui/button'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { FlowType } from '@/types/common'
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
import { BlockEnum } from '../../types'
import NodeSelector from '../main'
import { BlockClassification, TabType } from '../types'
vi.mock('reactflow', () => ({
useStoreApi: () => ({
getState: () => ({
getNodes: () => [],
}),
}),
}))
vi.mock('@/service/use-plugins', () => ({
useFeaturedToolsRecommendations: () => ({
plugins: [],
isLoading: false,
}),
useFeaturedTriggersRecommendations: () => ({
plugins: [],
isLoading: false,
}),
}))
vi.mock('@/app/components/plugins/marketplace/hooks', () => ({
useMarketplacePlugins: () => ({
plugins: [],
queryPluginsWithDebounced: vi.fn(),
}),
}))
vi.mock('@/service/use-triggers', () => ({
useAllTriggerPlugins: () => ({ data: [] }),
useInvalidateAllTriggerPlugins: () => vi.fn(),
}))
vi.mock('@/service/use-tools', () => ({
useAllBuiltInTools: () => ({ data: [] }),
useAllCustomTools: () => ({ data: [] }),
useAllWorkflowTools: () => ({ data: [] }),
useAllMCPTools: () => ({ data: [] }),
useInvalidateAllBuiltInTools: () => vi.fn(),
}))
const createBlock = (type: BlockEnum, title: string): NodeDefault => ({
metaData: {
classification: BlockClassification.Default,
sort: 0,
type,
title,
author: 'Dify',
description: `${title} description`,
},
defaultValue: {},
checkValid: () => ({ isValid: true }),
})
type RenderNodeSelectorOptions = Parameters<typeof renderWorkflowComponent>[1]
const renderNodeSelector = (ui: ReactElement, options?: RenderNodeSelectorOptions) => {
return renderWorkflowComponent(ui, {
...options,
hooksStoreProps: {
...options?.hooksStoreProps,
configsMap: {
flowId: 'app-1',
flowType: FlowType.appFlow,
fileSettings: {} as never,
...options?.hooksStoreProps?.configsMap,
},
},
})
}
describe('NodeSelector', () => {
it('opens with the real blocks tab, filters by search, selects a block, and clears search after close', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
renderNodeSelector(
<NodeSelector
onSelect={onSelect}
blocks={[createBlock(BlockEnum.LLM, 'LLM'), createBlock(BlockEnum.End, 'End')]}
availableBlocksTypes={[BlockEnum.LLM, BlockEnum.End]}
trigger={(open) => (
<button type="button">{open ? 'selector-open' : 'selector-closed'}</button>
)}
/>,
)
const trigger = screen.getByRole('button', { name: 'selector-closed' })
expect(trigger.closest('[aria-haspopup="dialog"]')).toBe(trigger)
await user.click(trigger)
const searchInput = screen.getByPlaceholderText('workflow.tabs.searchBlock')
expect(screen.getByText('LLM')).toBeInTheDocument()
expect(screen.getByText('End')).toBeInTheDocument()
await user.type(searchInput, 'LLM')
expect(screen.getByText('LLM')).toBeInTheDocument()
expect(screen.queryByText('End')).not.toBeInTheDocument()
await user.click(screen.getByText('LLM'))
expect(onSelect).toHaveBeenCalledWith(BlockEnum.LLM, undefined)
await waitFor(() => {
expect(screen.queryByPlaceholderText('workflow.tabs.searchBlock')).not.toBeInTheDocument()
})
await user.click(screen.getByRole('button', { name: 'selector-closed' }))
const reopenedInput = screen.getByPlaceholderText(
'workflow.tabs.searchBlock',
) as HTMLInputElement
expect(reopenedInput.value).toBe('')
expect(screen.getByText('End')).toBeInTheDocument()
})
it('resets to the default tab after closing', async () => {
const user = userEvent.setup()
renderNodeSelector(
<NodeSelector
onSelect={vi.fn()}
blocks={[createBlock(BlockEnum.LLM, 'LLM')]}
availableBlocksTypes={[BlockEnum.LLM, BlockEnum.Start]}
showStartTab
trigger={(open) => (
<button type="button">{open ? 'selector-open' : 'selector-closed'}</button>
)}
/>,
)
await user.click(screen.getByRole('button', { name: 'selector-closed' }))
await user.click(screen.getByText('workflow.tabs.start'))
expect(screen.getByPlaceholderText('workflow.tabs.searchTrigger')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'selector-open' }))
await waitFor(() => {
expect(screen.queryByPlaceholderText('workflow.tabs.searchTrigger')).not.toBeInTheDocument()
})
await user.click(screen.getByRole('button', { name: 'selector-closed' }))
expect(screen.getByPlaceholderText('workflow.tabs.searchBlock')).toBeInTheDocument()
})
it('preserves the current popup session until a controlled close actually unmounts it', async () => {
const user = userEvent.setup()
const onOpenChange = vi.fn()
renderNodeSelector(
<NodeSelector
open
onOpenChange={onOpenChange}
onSelect={vi.fn()}
blocks={[createBlock(BlockEnum.LLM, 'LLM')]}
availableBlocksTypes={[BlockEnum.LLM, BlockEnum.Start]}
showStartTab
trigger={() => <button type="button">selector-open</button>}
/>,
)
await user.click(screen.getByRole('tab', { name: 'workflow.tabs.start' }))
const searchInput = screen.getByPlaceholderText('workflow.tabs.searchTrigger')
await user.type(searchInput, 'webhook')
await user.click(screen.getByRole('button', { name: 'selector-open' }))
expect(onOpenChange).toHaveBeenCalledWith(false)
expect(screen.getByRole('tab', { name: 'workflow.tabs.start' })).toHaveAttribute(
'aria-selected',
'true',
)
expect(searchInput).toHaveValue('webhook')
})
it('focuses the search input on open and keeps focus on a tab when it is activated', async () => {
const user = userEvent.setup()
renderNodeSelector(
<NodeSelector
onSelect={vi.fn()}
blocks={[createBlock(BlockEnum.LLM, 'LLM')]}
availableBlocksTypes={[BlockEnum.LLM, BlockEnum.Start]}
showStartTab
trigger={(open) => (
<button type="button">{open ? 'selector-open' : 'selector-closed'}</button>
)}
/>,
)
await user.click(screen.getByRole('button', { name: 'selector-closed' }))
expect(screen.getByPlaceholderText('workflow.tabs.searchBlock')).toHaveFocus()
await waitFor(() => {
expect(screen.getByRole('dialog').parentElement).toHaveStyle({ position: 'fixed' })
})
const startTab = screen.getByRole('tab', { name: 'workflow.tabs.start' })
await user.click(startTab)
expect(startTab).toHaveFocus()
expect(screen.getByPlaceholderText('workflow.tabs.searchTrigger')).not.toHaveFocus()
})
it('does not open or emit open changes when disabled', async () => {
const user = userEvent.setup()
const onOpenChange = vi.fn()
renderNodeSelector(
<NodeSelector
disabled
onOpenChange={onOpenChange}
onSelect={vi.fn()}
blocks={[createBlock(BlockEnum.LLM, 'LLM')]}
availableBlocksTypes={[BlockEnum.LLM]}
trigger={(open) => (
<button type="button">{open ? 'selector-open' : 'selector-closed'}</button>
)}
/>,
)
await user.click(screen.getByRole('button', { name: 'selector-closed' }))
expect(onOpenChange).not.toHaveBeenCalled()
expect(screen.queryByPlaceholderText('workflow.tabs.searchBlock')).not.toBeInTheDocument()
})
it('preserves the custom trigger click handler', async () => {
const user = userEvent.setup()
const onTriggerClick = vi.fn()
renderNodeSelector(
<NodeSelector
onSelect={vi.fn()}
blocks={[createBlock(BlockEnum.LLM, 'LLM')]}
availableBlocksTypes={[BlockEnum.LLM]}
trigger={() => (
<button type="button" onClick={onTriggerClick}>
open-selector
</button>
)}
/>,
)
await user.click(screen.getByRole('button', { name: 'open-selector' }))
expect(onTriggerClick).toHaveBeenCalledTimes(1)
expect(screen.getByPlaceholderText('workflow.tabs.searchBlock')).toBeInTheDocument()
})
it('opens when a custom component trigger does not forward props', async () => {
const user = userEvent.setup()
function TriggerShell() {
return <span>open-from-shell</span>
}
renderNodeSelector(
<NodeSelector
onSelect={vi.fn()}
blocks={[createBlock(BlockEnum.LLM, 'LLM')]}
availableBlocksTypes={[BlockEnum.LLM]}
trigger={() => <TriggerShell />}
/>,
)
await user.click(screen.getByRole('button', { name: 'open-from-shell' }))
expect(screen.getByPlaceholderText('workflow.tabs.searchBlock')).toBeInTheDocument()
})
it('can render a prop-forwarding button component as the popover root', async () => {
const user = userEvent.setup()
function ForwardingButtonTrigger(props: ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button type="button" data-testid="selector-root-trigger" {...props}>
open-selector-root
</button>
)
}
renderNodeSelector(
<NodeSelector
onSelect={vi.fn()}
blocks={[createBlock(BlockEnum.LLM, 'LLM')]}
availableBlocksTypes={[BlockEnum.LLM]}
renderTriggerAsButtonRoot
trigger={() => <ForwardingButtonTrigger />}
/>,
)
const trigger = screen.getByTestId('selector-root-trigger')
await user.click(trigger)
expect(trigger.closest('[aria-haspopup="dialog"]')).toBe(trigger)
expect(screen.getByPlaceholderText('workflow.tabs.searchBlock')).toBeInTheDocument()
})
it('can render the shared Button trigger as the popover root', async () => {
const user = userEvent.setup()
renderNodeSelector(
<NodeSelector
onSelect={vi.fn()}
blocks={[createBlock(BlockEnum.LLM, 'LLM')]}
availableBlocksTypes={[BlockEnum.LLM]}
renderTriggerAsButtonRoot
trigger={() => <Button variant="primary">open-shared-button-trigger</Button>}
/>,
)
const trigger = screen.getByRole('button', { name: 'open-shared-button-trigger' })
await user.click(trigger)
expect(trigger.closest('[aria-haspopup="dialog"]')).toBe(trigger)
expect(screen.getByPlaceholderText('workflow.tabs.searchBlock')).toBeInTheDocument()
})
it('isolates popup keyboard events when opened from another keyboard-managed overlay', async () => {
const user = userEvent.setup()
const handleParentKeyDown = vi.fn()
renderNodeSelector(
<NodeSelector
open
isolateKeyboardEvents
onSelect={vi.fn()}
blocks={[createBlock(BlockEnum.LLM, 'LLM'), createBlock(BlockEnum.End, 'End')]}
availableBlocksTypes={[BlockEnum.LLM, BlockEnum.End]}
/>,
)
const searchInput = screen.getByPlaceholderText('workflow.tabs.searchBlock') as HTMLInputElement
document.body.addEventListener('keydown', handleParentKeyDown)
try {
await user.type(searchInput, 'LLM')
} finally {
document.body.removeEventListener('keydown', handleParentKeyDown)
}
expect(searchInput.value).toBe('LLM')
expect(handleParentKeyDown).not.toHaveBeenCalled()
expect(screen.getByText('LLM')).toBeInTheDocument()
expect(screen.queryByText('End')).not.toBeInTheDocument()
})
it('disables the start tab with a setup tooltip when an unconfigured start node is on the canvas', async () => {
const user = userEvent.setup()
renderNodeSelector(
<NodeSelector
open
onSelect={vi.fn()}
blocks={[createBlock(BlockEnum.LLM, 'LLM')]}
availableBlocksTypes={[BlockEnum.LLM, BlockEnum.Start]}
showStartTab
defaultActiveTab={TabType.Start}
/>,
{
initialStoreState: {
nodes: [
{
id: 'start-placeholder',
data: {
type: BlockEnum.StartPlaceholder,
},
},
] as never,
},
},
)
await user.hover(screen.getByText('workflow.tabs.start'))
expect(
await screen.findByText('workflow.tabs.unconfiguredStartDisabledTip'),
).toBeInTheDocument()
expect(screen.queryByRole('link')).not.toBeInTheDocument()
expect(screen.getByPlaceholderText('workflow.tabs.searchBlock')).toBeInTheDocument()
})
it('keeps the start tab enabled when a configured user input start node is on the canvas', () => {
renderNodeSelector(
<NodeSelector
open
onSelect={vi.fn()}
blocks={[createBlock(BlockEnum.LLM, 'LLM')]}
availableBlocksTypes={[BlockEnum.LLM, BlockEnum.Start, BlockEnum.TriggerPlugin]}
showStartTab
defaultActiveTab={TabType.Start}
/>,
{
initialStoreState: {
nodes: [
{
id: 'start',
data: {
type: BlockEnum.Start,
},
},
] as never,
},
},
)
expect(screen.getByText('workflow.tabs.start')).toHaveAttribute('aria-disabled', 'false')
expect(
screen.getByText('workflow.nodes.startPlaceholder.userInputConflictTip'),
).toBeInTheDocument()
})
})

View File

@ -140,11 +140,6 @@ describe('StartBlocks', () => {
name: /workflow\.blocks\.start.*workflow\.nodes\.startPlaceholder\.userInputConflictTip/,
})
expect(userInputButton).toHaveAttribute('aria-disabled', 'true')
expect(
userInputButton
.querySelector('.i-custom-vender-workflow-user-input')
?.closest('.opacity-30'),
).toBeInTheDocument()
await user.tab()
expect(userInputButton).toHaveFocus()

View File

@ -2,14 +2,13 @@ import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import { SelectorContent } from '../tabs'
import { BlockSelectorPanels } from '../tabs'
import { TabType } from '../types'
const render = (ui: React.ReactElement) =>
renderWithConsoleQuery(ui, { systemFeatures: { enable_marketplace: true } })
const { mockSetState, mockInvalidateBuiltInTools, mockToolsState } = vi.hoisted(() => ({
mockSetState: vi.fn(),
const { mockInvalidateBuiltInTools, mockToolsState } = vi.hoisted(() => ({
mockInvalidateBuiltInTools: vi.fn(),
mockToolsState: {
buildInTools: [{ icon: '/tool.svg', name: 'tool' }] as
@ -42,7 +41,7 @@ vi.mock('@/utils/var', () => ({
vi.mock('../../store', () => ({
useWorkflowStore: () => ({
setState: mockSetState,
setState: vi.fn(),
}),
}))
@ -58,7 +57,7 @@ vi.mock('../data-sources', () => ({
default: () => <div>sources-content</div>,
}))
vi.mock('../all-tools', () => ({
vi.mock('../tool-browser', () => ({
default: (props: {
buildInTools: Array<{ icon: string | Record<string, string>; name: string }>
showFeatured: boolean
@ -101,7 +100,7 @@ describe('Tabs', () => {
it('should render start content and disabled tab tooltip text', async () => {
const user = userEvent.setup()
render(<SelectorContent {...baseProps} />)
render(<BlockSelectorPanels {...baseProps} />)
expect(screen.getByText('start-content'))!.toBeInTheDocument()
await user.hover(screen.getByRole('tab', { name: 'Blocks' }))
@ -110,13 +109,13 @@ describe('Tabs', () => {
it('should expose tab semantics and use manual keyboard activation', async () => {
const user = userEvent.setup()
render(<SelectorContent {...baseProps} />)
render(<BlockSelectorPanels {...baseProps} />)
const startTab = screen.getByRole('tab', { name: 'Start' })
const blocksTab = screen.getByRole('tab', { name: 'Blocks' })
const toolsTab = screen.getByRole('tab', { name: 'Tools' })
expect(screen.getByRole('tablist')).toBeInTheDocument()
expect(screen.getByRole('tablist', { name: 'workflow.common.addBlock' })).toBeInTheDocument()
expect(startTab).toHaveAttribute('aria-selected', 'true')
expect(blocksTab).toHaveAttribute('aria-disabled', 'true')
expect(screen.getByRole('tabpanel', { name: 'Start' })).toBeInTheDocument()
@ -131,19 +130,9 @@ describe('Tabs', () => {
expect(startTab).toHaveAttribute('aria-selected', 'true')
})
it('should sync normalized tools into workflow store state', () => {
render(<SelectorContent {...baseProps} defaultTab={TabType.Tools} />)
expect(screen.getByText('tools-content'))!.toBeInTheDocument()
expect(screen.getByText('/console/tool.svg'))!.toBeInTheDocument()
expect(screen.getByText('featured-on'))!.toBeInTheDocument()
expect(screen.getByText('featured-idle'))!.toBeInTheDocument()
expect(mockSetState).toHaveBeenCalled()
})
it('should ignore clicks on disabled and already active tabs', async () => {
const user = userEvent.setup()
render(<SelectorContent {...baseProps} />)
render(<BlockSelectorPanels {...baseProps} />)
const startTab = screen.getByRole('tab', { name: 'Start' })
await user.click(startTab)
@ -168,7 +157,7 @@ describe('Tabs', () => {
>
Remove tools
</button>
<SelectorContent {...baseProps} tabs={tabs} />
<BlockSelectorPanels {...baseProps} tabs={tabs} />
</>
)
}
@ -189,7 +178,7 @@ describe('Tabs', () => {
it('should render sources content when the sources tab is active and data sources are provided', () => {
render(
<SelectorContent
<BlockSelectorPanels
{...baseProps}
defaultTab={TabType.Sources}
tabs={[...baseProps.tabs, { key: TabType.Sources, name: 'Sources' }]}
@ -200,73 +189,37 @@ describe('Tabs', () => {
expect(screen.getByText('sources-content'))!.toBeInTheDocument()
})
it('should keep the previous workflow store state when tool references do not change', () => {
mockToolsState.buildInTools = [{ icon: '/console/already-prefixed.svg', name: 'tool' }]
it('keeps search state independent for each tab during the popup session', async () => {
const user = userEvent.setup()
render(
<BlockSelectorPanels
{...baseProps}
defaultTab={TabType.Blocks}
tabs={[
{ key: TabType.Blocks, name: 'Blocks' },
{ key: TabType.Tools, name: 'Tools' },
]}
/>,
)
render(<SelectorContent {...baseProps} defaultTab={TabType.Tools} />)
const blocksSearch = screen.getByRole('searchbox', { name: 'workflow.tabs.searchBlock' })
await user.type(blocksSearch, 'llm')
await user.click(screen.getByRole('tab', { name: 'Tools' }))
const previousState = {
buildInTools: mockToolsState.buildInTools,
customTools: mockToolsState.customTools,
workflowTools: mockToolsState.workflowTools,
mcpTools: mockToolsState.mcpTools,
}
const updateState = mockSetState.mock.calls[0]![0] as (
state: typeof previousState,
) => typeof previousState
const toolsSearch = screen.getByRole('searchbox', { name: 'plugin.searchTools' })
expect(toolsSearch).toHaveValue('')
await user.type(toolsSearch, 'slack')
await user.click(screen.getByRole('tab', { name: 'Blocks' }))
expect(updateState(previousState)).toBe(previousState)
})
it('should normalize every tool collection and merge updates into workflow store state', () => {
mockToolsState.buildInTools = [{ icon: { light: '/tool.svg' }, name: 'tool' }]
mockToolsState.customTools = [{ icon: '/custom.svg', name: 'custom' }]
mockToolsState.workflowTools = [{ icon: '/workflow.svg', name: 'workflow' }]
mockToolsState.mcpTools = [{ icon: '/mcp.svg', name: 'mcp' }]
render(<SelectorContent {...baseProps} defaultTab={TabType.Tools} />)
expect(screen.getByText('object-icon'))!.toBeInTheDocument()
const updateState = mockSetState.mock.calls[0]![0] as (state: {
buildInTools?: Array<{ icon: string | Record<string, string>; name: string }>
customTools?: Array<{ icon: string | Record<string, string>; name: string }>
workflowTools?: Array<{ icon: string | Record<string, string>; name: string }>
mcpTools?: Array<{ icon: string | Record<string, string>; name: string }>
}) => {
buildInTools?: Array<{ icon: string | Record<string, string>; name: string }>
customTools?: Array<{ icon: string | Record<string, string>; name: string }>
workflowTools?: Array<{ icon: string | Record<string, string>; name: string }>
mcpTools?: Array<{ icon: string | Record<string, string>; name: string }>
}
expect(
updateState({
buildInTools: [],
customTools: [],
workflowTools: [],
mcpTools: [],
}),
).toEqual({
buildInTools: [{ icon: { light: '/tool.svg' }, name: 'tool' }],
customTools: [{ icon: '/console/custom.svg', name: 'custom' }],
workflowTools: [{ icon: '/console/workflow.svg', name: 'workflow' }],
mcpTools: [{ icon: '/console/mcp.svg', name: 'mcp' }],
})
})
it('should skip normalization when a tool list is undefined', () => {
mockToolsState.buildInTools = undefined
render(<SelectorContent {...baseProps} defaultTab={TabType.Tools} />)
expect(screen.getByText('tools-content'))!.toBeInTheDocument()
expect(screen.getByRole('searchbox', { name: 'workflow.tabs.searchBlock' })).toHaveValue('llm')
await user.click(screen.getByRole('tab', { name: 'Tools' }))
expect(screen.getByRole('searchbox', { name: 'plugin.searchTools' })).toHaveValue('slack')
})
it('should force start content to render and invalidate built-in tools after featured installs', async () => {
const user = userEvent.setup()
render(<SelectorContent {...baseProps} defaultTab={TabType.Tools} />)
render(<BlockSelectorPanels {...baseProps} defaultTab={TabType.Tools} />)
await user.click(screen.getByRole('button', { name: 'Install featured tool' }))
@ -275,7 +228,7 @@ describe('Tabs', () => {
})
it('should compose start content directly without tab semantics in standalone mode', () => {
render(<SelectorContent {...baseProps} standalonePanel={TabType.Start} />)
render(<BlockSelectorPanels {...baseProps} standalonePanel={TabType.Start} />)
expect(screen.getByText('start-content'))!.toBeInTheDocument()
expect(screen.queryByRole('tablist')).not.toBeInTheDocument()

View File

@ -1,13 +1,14 @@
import type { ReactElement } from 'react'
import { screen, waitFor } from '@testing-library/react'
import { screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useMarketplacePlugins } from '@/app/components/plugins/marketplace/hooks'
import { useMarketplacePlugins } from '@/app/components/plugins/marketplace/query'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { CollectionType } from '@/app/components/tools/types'
import { useGetLanguage } from '@/context/i18n'
import useTheme from '@/hooks/use-theme'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import { Theme } from '@/types/app'
import AllTools from '../all-tools'
import ToolBrowser from '../tool-browser'
import { createToolProvider } from './factories'
vi.mock('@/context/i18n', () => ({
@ -18,7 +19,7 @@ vi.mock('@/hooks/use-theme', () => ({
default: vi.fn(),
}))
vi.mock('@/app/components/plugins/marketplace/hooks', () => ({
vi.mock('@/app/components/plugins/marketplace/query', () => ({
useMarketplacePlugins: vi.fn(),
}))
@ -48,21 +49,10 @@ const mockUseTheme = vi.mocked(useTheme)
const render = (ui: ReactElement, enableMarketplace = false) =>
renderWithConsoleQuery(ui, { systemFeatures: { enable_marketplace: enableMarketplace } })
const createMarketplacePluginsMock = () => ({
plugins: [],
total: 0,
resetPlugins: vi.fn(),
queryPlugins: vi.fn(),
queryPluginsWithDebounced: vi.fn(),
cancelQueryPluginsWithDebounced: vi.fn(),
isLoading: false,
isFetchingNextPage: false,
hasNextPage: false,
fetchNextPage: vi.fn(),
page: 0,
})
const createMarketplacePluginsMock = () =>
({ data: undefined }) as ReturnType<typeof useMarketplacePlugins>
describe('AllTools', () => {
describe('ToolBrowser', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseGetLanguage.mockReturnValue('en_US')
@ -74,7 +64,7 @@ describe('AllTools', () => {
const user = userEvent.setup()
render(
<AllTools
<ToolBrowser
searchText=""
tags={[]}
onSelect={vi.fn()}
@ -105,11 +95,60 @@ describe('AllTools', () => {
expect(screen.queryByText('Built In Provider')).not.toBeInTheDocument()
})
it('shows the selected category empty state and keeps the marketplace footer available', async () => {
const user = userEvent.setup()
render(
<ToolBrowser
searchText=""
tags={[]}
onSelect={vi.fn()}
buildInTools={[
createToolProvider({
id: 'provider-built-in',
label: { en_US: 'Built In Provider', zh_Hans: 'Built In Provider' },
}),
]}
customTools={[]}
workflowTools={[]}
mcpTools={[]}
/>,
true,
)
const customToolsFilter = screen.getByRole('button', {
name: 'workflow.tabs.customTool',
})
customToolsFilter.focus()
await user.keyboard('{Enter}')
expect(customToolsFilter).toHaveAttribute('aria-pressed', 'true')
expect(screen.queryByText('Built In Provider')).not.toBeInTheDocument()
expect(screen.getByText(/addToolModal\.custom\.title/)).toBeInTheDocument()
expect(screen.getByRole('link', { name: /addToolModal\.custom\.tip/ })).toHaveAttribute(
'href',
'/integrations/tools/api',
)
await user.click(screen.getByRole('button', { name: 'workflow.tabs.workflowTool' }))
expect(screen.getByText(/addToolModal\.workflow\.title/)).toBeInTheDocument()
expect(screen.getByRole('link', { name: /addToolModal\.workflow\.tip/ })).toHaveAttribute(
'href',
'/integrations/tools/workflow',
)
expect(screen.queryByText(/workflowToolEmpty\.step/)).not.toBeInTheDocument()
const footer = screen.getByRole('contentinfo')
expect(within(footer).getAllByRole('link')).toHaveLength(1)
expect(within(footer).getByRole('link', { name: /findMoreInMarketplace/ })).toBeInTheDocument()
})
it('updates the tools list title by the active tab', async () => {
const user = userEvent.setup()
render(
<AllTools
<ToolBrowser
searchText=""
tags={[]}
onSelect={vi.fn()}
@ -160,7 +199,7 @@ describe('AllTools', () => {
it('filters the rendered tools by the search text', () => {
render(
<AllTools
<ToolBrowser
searchText="report"
tags={[]}
onSelect={vi.fn()}
@ -186,7 +225,7 @@ describe('AllTools', () => {
it('shows the empty state when no tool matches the current filter', async () => {
render(
<AllTools
<ToolBrowser
searchText="missing"
tags={[]}
onSelect={vi.fn()}
@ -202,12 +241,54 @@ describe('AllTools', () => {
})
})
it('debounces marketplace requests across search and tag changes', async () => {
const baseProps = {
onSelect: vi.fn(),
buildInTools: [],
customTools: [],
workflowTools: [],
mcpTools: [],
}
const { rerender } = render(<ToolBrowser {...baseProps} searchText="" tags={[]} />, true)
rerender(<ToolBrowser {...baseProps} searchText="w" tags={[]} />)
rerender(<ToolBrowser {...baseProps} searchText="web" tags={['api']} />)
rerender(<ToolBrowser {...baseProps} searchText="webhook" tags={['automation']} />)
expect(
mockUseMarketplacePlugins.mock.calls
.map(([params]) => params)
.filter((params) => params?.query || params?.tags?.length),
).toEqual([])
expect(screen.queryByText('workflow.tabs.noPluginsFound')).not.toBeInTheDocument()
await waitFor(() => {
expect(mockUseMarketplacePlugins).toHaveBeenLastCalledWith({
query: 'webhook',
tags: ['automation'],
category: PluginCategoryEnum.tool,
})
})
expect(
mockUseMarketplacePlugins.mock.calls
.map(([params]) => params)
.filter((params) => params?.query || params?.tags?.length),
).toEqual([
{
query: 'webhook',
tags: ['automation'],
category: PluginCategoryEnum.tool,
},
])
})
it('returns the next tag value when loading more RAG tools', async () => {
const user = userEvent.setup()
const onTagsChange = vi.fn()
render(
<AllTools
<ToolBrowser
searchText=""
tags={[]}
onTagsChange={onTagsChange}

View File

@ -0,0 +1,131 @@
import { CollectionType } from '../../../tools/types'
import { createToolListData } from '../tool-list-data'
import { createToolProvider } from './factories'
describe('createToolListData', () => {
it('normalizes letters and keeps non-alphabetic tools at the end', () => {
const result = createToolListData(
[
createToolProvider({ id: 'custom', label: { en_US: '1Custom', zh_Hans: '1自定义' } }),
createToolProvider({ id: 'chinese', label: { en_US: 'Chinese', zh_Hans: '中文' } }),
createToolProvider({ id: 'alpha', label: { en_US: 'Alpha', zh_Hans: 'Alpha' } }),
],
(tool) => tool.label.zh_Hans[0] || '',
)
expect(result.letters).toEqual(['A', 'Z', '#'])
expect(result.flatTools.map(({ id, letter }) => ({ id, letter }))).toEqual([
{ id: 'alpha', letter: 'A' },
{ id: 'chinese', letter: 'Z' },
{ id: 'custom', letter: '#' },
])
})
it('sorts the flat view by letter without changing the tree view provider order', () => {
const result = createToolListData(
[
createToolProvider({
id: 'zeta',
author: 'Zeta',
label: { en_US: 'Zeta', zh_Hans: 'Zeta' },
}),
createToolProvider({
id: 'alpha',
author: 'Alpha',
label: { en_US: 'Alpha', zh_Hans: 'Alpha' },
}),
],
(tool) => tool.label.en_US[0] ?? '',
)
expect(result.flatTools.map(({ id }) => id)).toEqual(['alpha', 'zeta'])
expect(
result.treeGroups.map((group) => (group.kind === 'author' ? group.author : group.category)),
).toEqual(['Zeta', 'Alpha'])
})
it('keeps author and category group identities separate without sentinel keys', () => {
const result = createToolListData(
[
createToolProvider({ id: 'author', author: 'custom' }),
createToolProvider({ id: 'category', type: CollectionType.custom }),
],
() => 'A',
)
expect(result.treeGroups).toEqual([
{
kind: 'author',
author: 'custom',
tools: [expect.objectContaining({ id: 'author' })],
},
{
kind: 'category',
category: 'custom',
tools: [expect.objectContaining({ id: 'category' })],
},
])
})
it('maps workflow and data source providers to distinct categories', () => {
const result = createToolListData(
[
createToolProvider({ id: 'workflow', type: CollectionType.workflow }),
createToolProvider({ id: 'data-source', type: CollectionType.datasource }),
],
() => 'A',
)
expect(result.treeGroups).toEqual([
{
kind: 'category',
category: 'workflow',
tools: [expect.objectContaining({ id: 'workflow' })],
},
{
kind: 'category',
category: 'data-source',
tools: [expect.objectContaining({ id: 'data-source' })],
},
])
})
it('merges providers across letters and keeps MCP and unknown provider identities explicit', () => {
const lettersByToolId: Record<string, string> = {
'author-a': 'A',
'author-b': 'B',
mcp: 'C',
unknown: 'D',
}
const result = createToolListData(
[
createToolProvider({ id: 'author-a', author: 'Dify' }),
createToolProvider({ id: 'author-b', author: 'Dify' }),
createToolProvider({ id: 'mcp', type: CollectionType.mcp }),
createToolProvider({ id: 'unknown', author: 'Future', type: 'future-provider' }),
],
(tool) => lettersByToolId[tool.id] ?? '#',
)
expect(result.treeGroups).toEqual([
{
kind: 'author',
author: 'Dify',
tools: [
expect.objectContaining({ id: 'author-a' }),
expect.objectContaining({ id: 'author-b' }),
],
},
{
kind: 'category',
category: 'mcp',
tools: [expect.objectContaining({ id: 'mcp' })],
},
{
kind: 'author',
author: 'Future',
tools: [expect.objectContaining({ id: 'unknown' })],
},
])
})
})

View File

@ -4,8 +4,11 @@ import type { Plugin } from '@/app/components/plugins/types'
import type { Tool } from '@/app/components/tools/types'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { OperationButton } from '@/app/components/app/configuration/base/operation-button'
import { useTags } from '@/app/components/plugins/hooks'
import { useMarketplacePlugins } from '@/app/components/plugins/marketplace/hooks'
import { useMarketplacePlugins } from '@/app/components/plugins/marketplace/query'
import { ToolTrigger } from '@/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-trigger'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { CollectionType } from '@/app/components/tools/types'
import { useGetLanguage } from '@/context/i18n'
@ -82,7 +85,7 @@ vi.mock('@/app/components/plugins/hooks', async (importOriginal) => {
}
})
vi.mock('@/app/components/plugins/marketplace/hooks', () => ({
vi.mock('@/app/components/plugins/marketplace/query', () => ({
useMarketplacePlugins: vi.fn(),
}))
@ -358,17 +361,7 @@ describe('ToolPicker', () => {
getTagLabel: (name: string) => name,
})
mockUseMarketplacePlugins.mockReturnValue({
plugins: [],
total: 0,
resetPlugins: vi.fn(),
queryPlugins: vi.fn(),
queryPluginsWithDebounced: vi.fn(),
cancelQueryPluginsWithDebounced: vi.fn(),
isLoading: false,
isFetchingNextPage: false,
hasNextPage: false,
fetchNextPage: vi.fn(),
page: 0,
data: undefined,
} as ReturnType<typeof useMarketplacePlugins>)
mockUseAllBuiltInTools.mockReturnValue({ data: builtInTools } as ReturnType<
typeof useAllBuiltInTools
@ -414,7 +407,7 @@ describe('ToolPicker', () => {
renderToolPicker({ onShowChange })
await user.click(screen.getByText('open-picker').closest('[role="button"]')!)
await user.click(screen.getByRole('button', { name: 'open-picker' }))
expect(onShowChange.mock.calls[0]?.[0]).toBe(true)
renderToolPicker({
@ -422,42 +415,89 @@ describe('ToolPicker', () => {
onShowChange: disabledOnShowChange,
})
await user.click(screen.getAllByText('open-picker')[1]!.closest('[role="button"]')!)
await user.click(screen.getAllByRole('button', { name: 'open-picker' })[1]!)
expect(disabledOnShowChange).not.toHaveBeenCalled()
})
it('opens from the production plugin-detail trigger and restores focus on close', async () => {
const user = userEvent.setup()
function Harness() {
const [open, setOpen] = useState(false)
return (
<ToolPicker
disabled={false}
trigger={<ToolTrigger open={open} />}
isShow={open}
onShowChange={setOpen}
onSelect={vi.fn()}
onSelectMultiple={vi.fn()}
/>
)
}
renderWithConsoleQuery(<Harness />, { systemFeatures: { enable_marketplace: true } })
const trigger = screen.getByRole('button', {
name: 'plugin.detailPanel.toolSelector.placeholder',
})
await user.click(trigger)
expect(screen.getByRole('dialog')).toBeInTheDocument()
expect(trigger).toHaveAttribute('aria-expanded', 'true')
await user.keyboard('{Escape}')
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(trigger).toHaveFocus()
})
it('opens from the production agent-tools trigger and restores focus on close', async () => {
const user = userEvent.setup()
function Harness() {
const [open, setOpen] = useState(false)
return (
<ToolPicker
disabled={false}
trigger={<OperationButton operation="add" />}
isShow={open}
onShowChange={setOpen}
onSelect={vi.fn()}
onSelectMultiple={vi.fn()}
/>
)
}
renderWithConsoleQuery(<Harness />, { systemFeatures: { enable_marketplace: true } })
const trigger = screen.getByRole('button', { name: 'common.operation.add' })
await user.click(trigger)
expect(screen.getByRole('dialog')).toBeInTheDocument()
expect(trigger).toHaveAttribute('aria-expanded', 'true')
await user.keyboard('{Escape}')
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(trigger).toHaveFocus()
})
it('should link the find-more footer to the marketplace tool category', () => {
renderToolPicker({
isShow: true,
selectedTools: [],
})
expect(screen.getByRole('link', { name: /plugin\.findMoreInMarketplace/i })).toHaveAttribute(
'href',
'https://marketplace.test/plugins/tool',
)
const footerLink = screen.getByRole('link', { name: /plugin\.findMoreInMarketplace/i })
expect(footerLink.closest('footer')).toBeInTheDocument()
expect(footerLink).toHaveAttribute('href', 'https://marketplace.test/plugins/tool')
})
it('should render real search and tool lists, then forward tool selections', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
const onSelectMultiple = vi.fn()
const queryPluginsWithDebounced = vi.fn()
mockUseMarketplacePlugins.mockReturnValue({
plugins: [],
total: 0,
resetPlugins: vi.fn(),
queryPlugins: vi.fn(),
queryPluginsWithDebounced,
cancelQueryPluginsWithDebounced: vi.fn(),
isLoading: false,
isFetchingNextPage: false,
hasNextPage: false,
fetchNextPage: vi.fn(),
page: 0,
} as ReturnType<typeof useMarketplacePlugins>)
renderToolPicker({
isShow: true,
scope: 'custom',
@ -470,15 +510,20 @@ describe('ToolPicker', () => {
expect(screen.getByText('Custom Provider')).toBeInTheDocument()
expect(screen.getByText('MCP Provider')).toBeInTheDocument()
await user.type(screen.getByRole('textbox'), 'weather')
await user.type(screen.getByRole('searchbox', { name: 'plugin.searchTools' }), 'weather')
await waitFor(() => {
expect(queryPluginsWithDebounced).toHaveBeenLastCalledWith({
expect(mockUseMarketplacePlugins).toHaveBeenLastCalledWith({
query: 'weather',
tags: [],
category: PluginCategoryEnum.tool,
})
})
expect(
mockUseMarketplacePlugins.mock.calls
.map(([params]) => params)
.filter((params) => params?.query && params.query !== 'weather'),
).toEqual([])
await waitFor(() => {
expect(screen.getByText('Weather Tool')).toBeInTheDocument()
@ -512,13 +557,7 @@ describe('ToolPicker', () => {
supportAddCustomTool: true,
})
const addCustomToolButton = Array.from(document.querySelectorAll('button')).find((button) => {
return button.className.includes('bg-components-button-primary-bg')
})
expect(addCustomToolButton).toBeTruthy()
await user.click(addCustomToolButton!)
await user.click(screen.getByRole('button', { name: 'tools.addToolModal.custom.tip' }))
expect(screen.getByTestId('edit-custom-tool-modal')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'submit-custom-tool' }))
@ -548,10 +587,8 @@ describe('ToolPicker', () => {
expect(screen.getByText('Built-in Provider')).toBeInTheDocument()
expect(screen.getByText('MCP Provider')).toBeInTheDocument()
expect(
Array.from(document.querySelectorAll('button')).some((button) => {
return button.className.includes('bg-components-button-primary-bg')
}),
).toBe(false)
screen.queryByRole('button', { name: 'tools.addToolModal.custom.tip' }),
).not.toBeInTheDocument()
})
it('should invalidate all tool collections after featured install succeeds', async () => {

View File

@ -4,7 +4,7 @@ import { useGetLanguage } from '@/context/i18n'
import useTheme from '@/hooks/use-theme'
import { Theme } from '@/types/app'
import Tools from '../tools'
import { ViewType } from '../view-type-select'
import { ViewType } from '../types'
import { createToolProvider } from './factories'
vi.mock('@/context/i18n', () => ({
@ -66,7 +66,7 @@ describe('Tools', () => {
})
it('shows the alphabetical index in flat view when enough tools are present', () => {
const { container } = render(
render(
<Tools
tools={Array.from({ length: 11 }, (_, index) =>
createToolProvider({
@ -83,7 +83,8 @@ describe('Tools', () => {
/>,
)
expect(container.querySelector('.index-bar')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'A' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'K' })).toBeInTheDocument()
expect(screen.getByText('A Provider')).toBeInTheDocument()
expect(screen.getByText('K Provider')).toBeInTheDocument()
})

View File

@ -1,41 +1,46 @@
import { fireEvent, render } from '@testing-library/react'
import ViewTypeSelect, { ViewType } from '../view-type-select'
const getViewOptions = (container: HTMLElement) => {
const options = container.firstElementChild?.children
if (!options || options.length !== 2) throw new Error('Expected two view options')
return [options[0] as HTMLDivElement, options[1] as HTMLDivElement]
}
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { ViewType } from '../types'
import ViewTypeSelect from '../view-type-select'
describe('ViewTypeSelect', () => {
it('should highlight the active view type', () => {
const onChange = vi.fn()
const { container } = render(<ViewTypeSelect viewType={ViewType.flat} onChange={onChange} />)
it('exposes the current view through named toggle buttons', () => {
render(<ViewTypeSelect viewType={ViewType.flat} onChange={vi.fn()} />)
const [flatOption, treeOption] = getViewOptions(container)
expect(flatOption)!.toHaveClass('bg-components-segmented-control-item-active-bg')
expect(treeOption)!.toHaveClass('cursor-pointer')
expect(screen.getByRole('group', { name: 'common.operation.view' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'workflow.tabs.listView' })).toHaveAttribute(
'aria-pressed',
'true',
)
expect(screen.getByRole('button', { name: 'workflow.tabs.treeView' })).toHaveAttribute(
'aria-pressed',
'false',
)
})
it('should call onChange when switching to a different view type', () => {
const onChange = vi.fn()
const { container } = render(<ViewTypeSelect viewType={ViewType.flat} onChange={onChange} />)
it('changes the view with roving focus and keyboard activation', async () => {
const user = userEvent.setup()
const [, treeOption] = getViewOptions(container)
fireEvent.click(treeOption!)
function ViewTypeSelectHarness() {
const [viewType, setViewType] = useState<ViewType>(ViewType.flat)
return <ViewTypeSelect viewType={viewType} onChange={setViewType} />
}
expect(onChange).toHaveBeenCalledWith(ViewType.tree)
expect(onChange).toHaveBeenCalledTimes(1)
})
render(<ViewTypeSelectHarness />)
it('should ignore clicks on the current view type', () => {
const onChange = vi.fn()
const { container } = render(<ViewTypeSelect viewType={ViewType.tree} onChange={onChange} />)
const flatView = screen.getByRole('button', { name: 'workflow.tabs.listView' })
const treeView = screen.getByRole('button', { name: 'workflow.tabs.treeView' })
const [, treeOption] = getViewOptions(container)
fireEvent.click(treeOption!)
await user.tab()
expect(flatView).toHaveFocus()
expect(onChange).not.toHaveBeenCalled()
await user.keyboard('{ArrowRight}')
expect(treeView).toHaveFocus()
await user.keyboard(' ')
expect(flatView).toHaveAttribute('aria-pressed', 'false')
expect(treeView).toHaveAttribute('aria-pressed', 'true')
})
})

View File

@ -255,7 +255,7 @@ function AgentSelectorActionItem({ option }: { option: AgentSelectorActionOption
<Link href="/agents" target="_blank" rel="noopener noreferrer" />
)
}
className="flex min-h-7 w-full grid-cols-none items-center gap-2 rounded-md px-2 py-1.5 text-left system-sm-regular text-text-secondary hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden data-highlighted:bg-state-base-hover data-highlighted:text-text-secondary"
className="flex min-h-7 w-full grid-cols-none items-center gap-2 rounded-md px-2 py-1.5 text-left system-sm-regular text-text-secondary hover:bg-state-base-hover hover:text-text-secondary focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid focus-visible:outline-hidden data-highlighted:bg-state-base-hover data-highlighted:text-text-secondary"
>
<ComboboxItemText className="flex items-center gap-2 px-0 system-sm-regular text-text-secondary">
<span
@ -298,7 +298,7 @@ export function AgentBlockItem({
render={
<button
type="button"
className="flex h-8 w-full cursor-pointer items-center rounded-lg px-3 text-left hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden data-popup-open:bg-state-base-hover"
className="flex h-8 w-full cursor-pointer items-center rounded-lg px-3 text-left hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid focus-visible:outline-hidden data-popup-open:bg-state-base-hover"
>
<BlockIcon className="mr-2 shrink-0" type={block.metaData.type} />
<span className="min-w-0 grow truncate system-sm-medium text-text-secondary">

View File

@ -1,34 +1,33 @@
'use client'
import type { RefObject } from 'react'
import type { BlockEnum, OnSelectBlock } from '../types'
import type { ListRef } from './market-place-plugin/list'
import type { ListRef } from './marketplace-plugin/list'
import type { TriggerDefaultValue, TriggerWithProvider } from './types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { RiArrowRightUpLine } from '@remixicon/react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useDebounce } from 'ahooks'
import { useCallback, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import { SearchMenu } from '@/app/components/base/icons/src/vender/line/general'
import { useMarketplacePlugins } from '@/app/components/plugins/marketplace/query'
import { getMarketplaceCategoryUrl } from '@/app/components/plugins/marketplace/utils'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import Link from '@/next/link'
import { useFeaturedTriggersRecommendations } from '@/service/use-plugins'
import { useAllTriggerPlugins, useInvalidateAllTriggerPlugins } from '@/service/use-triggers'
import { useMarketplacePlugins } from '../../plugins/marketplace/hooks'
import { PluginCategoryEnum } from '../../plugins/types'
import { BlockEnum as BlockEnumValue } from '../types'
import { ENTRY_NODE_TYPES } from './constants'
import FeaturedTriggers from './featured-triggers'
import PluginList from './market-place-plugin/list'
import PluginList from './marketplace-plugin/list'
import StartBlocks from './start-blocks'
import TriggerPluginList from './trigger-plugin/list'
const popoverMarketplaceFooterClassName =
'system-sm-medium z-10 flex h-8 flex-none cursor-pointer items-center rounded-b-lg border-[0.5px] border-t border-components-panel-border bg-components-panel-bg-blur px-4 py-1 text-text-accent-light-mode-only shadow-lg'
'system-sm-medium z-10 flex h-8 flex-none items-center border-t border-divider-subtle bg-components-panel-bg-blur px-4 py-1'
const panelMarketplaceFooterClassName =
'system-xs-regular z-10 flex flex-none cursor-pointer flex-col items-start gap-2 px-4 pt-2 pb-4 text-text-tertiary hover:text-text-secondary'
'system-xs-regular z-10 flex flex-none flex-col items-start gap-2 rounded-lg px-4 pt-2 pb-4'
const DEFAULT_TAGS: string[] = []
const SectionDivider = () => (
<div className="px-4 py-1" aria-hidden>
@ -54,17 +53,17 @@ type AllStartBlocksProps = {
variant?: 'popover' | 'panel'
}
const AllStartBlocks = ({
function AllStartBlocks({
className,
searchText,
onSelect,
availableBlocksTypes,
tags = [],
tags = DEFAULT_TAGS,
allowUserInputSelection = false,
hasUserInputNode = false,
hasTriggerNode = false,
variant = 'popover',
}: AllStartBlocksProps) => {
}: AllStartBlocksProps) {
const { t } = useTranslation()
const [hasStartBlocksContent, setHasStartBlocksContent] = useState(false)
const [hasPluginContent, setHasPluginContent] = useState(false)
@ -96,10 +95,32 @@ const AllStartBlocks = ({
const trimmedSearchText = searchText.trim()
const hasSearchText = trimmedSearchText.length > 0
const hasFilter = hasSearchText || tags.length > 0
const marketplaceFilters = useMemo(
() => ({ query: trimmedSearchText, tags }),
[tags, trimmedSearchText],
)
const debouncedMarketplaceFilters = useDebounce(marketplaceFilters, { wait: 500 })
const isMarketplaceFilterSettled = debouncedMarketplaceFilters === marketplaceFilters
const isMarketplaceSearchEnabled = enableTriggerPlugin && enable_marketplace && hasFilter
const { plugins: featuredPlugins = [], isLoading: featuredLoading } =
useFeaturedTriggersRecommendations(enableTriggerPlugin && enable_marketplace && !hasFilter)
const { queryPluginsWithDebounced: fetchPlugins, plugins: marketplacePlugins = [] } =
useMarketplacePlugins()
const marketplaceSearchParams = useMemo(
() =>
isMarketplaceSearchEnabled && isMarketplaceFilterSettled
? {
query: debouncedMarketplaceFilters.query,
tags: debouncedMarketplaceFilters.tags,
category: PluginCategoryEnum.trigger,
}
: undefined,
[debouncedMarketplaceFilters, isMarketplaceSearchEnabled, isMarketplaceFilterSettled],
)
const { data: marketplacePluginsData, isFetching: isMarketplaceFetching } =
useMarketplacePlugins(marketplaceSearchParams)
const marketplacePlugins = useMemo(
() => marketplacePluginsData?.pages.flatMap((page) => page.plugins) ?? [],
[marketplacePluginsData?.pages],
)
const shouldShowFeatured = enableTriggerPlugin && enable_marketplace && !hasFilter
const shouldShowMarketplaceFooter = enable_marketplace
@ -113,34 +134,25 @@ const AllStartBlocks = ({
setHasPluginContent(hasContent)
}, [])
const hasInstalledPluginContent = enableTriggerPlugin && hasPluginContent
const hasMarketplaceContent =
enableTriggerPlugin && enable_marketplace && marketplacePlugins.length > 0
const hasAnyContent =
hasStartBlocksContent || hasPluginContent || shouldShowFeatured || hasMarketplaceContent
const shouldShowEmptyState = hasFilter && !hasAnyContent
hasStartBlocksContent ||
hasInstalledPluginContent ||
shouldShowFeatured ||
hasMarketplaceContent
const isMarketplaceSearchPending =
isMarketplaceSearchEnabled && (!isMarketplaceFilterSettled || isMarketplaceFetching)
const shouldShowEmptyState = hasFilter && !isMarketplaceSearchPending && !hasAnyContent
const shouldShowInstalledTriggersDivider =
isPanelVariant && hasStartBlocksContent && enableTriggerPlugin && hasPluginContent
isPanelVariant && hasStartBlocksContent && hasInstalledPluginContent
const shouldShowMarketplaceSectionDivider =
enableTriggerPlugin &&
enable_marketplace &&
(hasStartBlocksContent || hasPluginContent) &&
(hasStartBlocksContent || hasInstalledPluginContent) &&
(shouldShowFeatured || hasMarketplaceContent)
useEffect(() => {
if (!enableTriggerPlugin && hasPluginContent) setHasPluginContent(false)
}, [enableTriggerPlugin, hasPluginContent])
useEffect(() => {
if (!enableTriggerPlugin || !enable_marketplace) return
if (hasFilter) {
fetchPlugins({
query: searchText,
tags,
category: PluginCategoryEnum.trigger,
})
}
}, [enableTriggerPlugin, enable_marketplace, hasFilter, fetchPlugins, searchText, tags])
return (
<div
className={cn(
@ -176,7 +188,10 @@ const AllStartBlocks = ({
</div>
)}
<div className={cn(hasUserInputNode && 'pointer-events-none opacity-30')}>
<div
inert={hasUserInputNode ? true : undefined}
className={cn(hasUserInputNode && 'pointer-events-none opacity-30')}
>
<StartBlocks
searchText={trimmedSearchText}
onSelect={onSelect as OnSelectBlock}
@ -230,37 +245,44 @@ const AllStartBlocks = ({
{shouldShowEmptyState && (
<div className="flex h-full flex-col items-center justify-center gap-3 py-12 text-center">
<SearchMenu className="size-8 text-text-quaternary" />
<span
aria-hidden
className="i-custom-vender-line-general-search-menu size-8 text-text-quaternary"
/>
<div className="text-sm font-medium text-text-secondary">
{t(($) => $['nodes.startPlaceholder.noTriggersFound'], { ns: 'workflow' })}
</div>
<Link
className="inline-flex h-6 items-center rounded-md px-3 text-xs font-medium text-text-accent hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
href="https://github.com/langgenius/dify-plugins/issues/new?template=plugin_request.yaml"
target="_blank"
rel="noopener noreferrer"
>
<Button
size="small"
variant="secondary-accent"
className="h-6 cursor-pointer px-3 text-xs"
>
{t(($) => $['tabs.requestToCommunity'], { ns: 'workflow' })}
</Button>
{t(($) => $['tabs.requestToCommunity'], { ns: 'workflow' })}
</Link>
</div>
)}
</div>
{shouldShowMarketplaceFooter && (
<Link
<footer
className={
isPanelVariant ? panelMarketplaceFooterClassName : popoverMarketplaceFooterClassName
}
href={getMarketplaceCategoryUrl(PluginCategoryEnum.trigger)}
target="_blank"
>
{isPanelVariant ? (
<>
<MarketplaceFooterDivider />
{isPanelVariant && <MarketplaceFooterDivider />}
<Link
className={cn(
'inline-flex items-center rounded-md focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden',
isPanelVariant
? 'gap-1 text-text-tertiary hover:text-text-secondary'
: 'text-text-accent-light-mode-only',
)}
href={getMarketplaceCategoryUrl(PluginCategoryEnum.trigger)}
target="_blank"
rel="noopener noreferrer"
>
{isPanelVariant ? (
<span className="flex items-center gap-1">
<span
className="i-custom-vender-workflow-marketplace size-3 shrink-0"
@ -272,14 +294,14 @@ const AllStartBlocks = ({
})}
</span>
</span>
</>
) : (
<>
<span>{t(($) => $.findMoreInMarketplace, { ns: 'plugin' })}</span>
<RiArrowRightUpLine className="ml-0.5 size-3" />
</>
)}
</Link>
) : (
<>
<span>{t(($) => $.findMoreInMarketplace, { ns: 'plugin' })}</span>
<span aria-hidden className="ml-0.5 i-ri-arrow-right-up-line size-3" />
</>
)}
</Link>
</footer>
)}
</div>
</div>

View File

@ -33,7 +33,7 @@ const buttonClassName = (className?: string, disabled = false, hoverable = true)
cn(
rowClassName(className, disabled, hoverable),
!disabled && 'cursor-pointer',
'border-0 bg-transparent text-left focus-visible:ring-1 focus-visible:ring-components-input-border-hover focus-visible:outline-hidden',
'border-0 bg-transparent text-left focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid focus-visible:outline-hidden',
)
export function BlockSelectorRow(props: BlockSelectorRowProps) {

View File

@ -155,7 +155,7 @@ const Blocks = ({
<button
type="button"
aria-describedby={previewDescriptionId}
className="flex h-8 w-full cursor-pointer items-center rounded-lg px-3 text-left hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
className="flex h-8 w-full cursor-pointer items-center rounded-lg px-3 text-left hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid focus-visible:outline-hidden"
onClick={() => onSelect(block.metaData.type)}
>
<BlockIcon className="mr-2 shrink-0" type={block.metaData.type} />

View File

@ -1,20 +1,21 @@
import type { OnSelectBlock, ToolWithProvider } from '../types'
import type { DataSourceDefaultValue, ToolDefaultValue } from './types'
import type { ListRef } from '@/app/components/workflow/block-selector/market-place-plugin/list'
import type { ListRef } from '@/app/components/workflow/block-selector/marketplace-plugin/list'
import { cn } from '@langgenius/dify-ui/cn'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import PluginList from '@/app/components/workflow/block-selector/market-place-plugin/list'
import { useDebounce } from 'ahooks'
import { useCallback, useMemo, useRef } from 'react'
import { useMarketplacePlugins } from '@/app/components/plugins/marketplace/query'
import PluginList from '@/app/components/workflow/block-selector/marketplace-plugin/list'
import { useGetLanguage } from '@/context/i18n'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { useMarketplacePlugins } from '../../plugins/marketplace/hooks'
import { PluginCategoryEnum } from '../../plugins/types'
import { BlockEnum } from '../types'
import { DEFAULT_FILE_EXTENSIONS_IN_LOCAL_FILE_DATA_SOURCE } from './constants'
import Tools from './tools'
import { ViewType } from './view-type-select'
import { ViewType } from './types'
type AllToolsProps = {
type DataSourcesProps = {
className?: string
toolContentClassName?: string
searchText: string
@ -22,13 +23,13 @@ type AllToolsProps = {
dataSources: ToolWithProvider[]
}
const DataSources = ({
function DataSources({
className,
toolContentClassName,
searchText,
onSelect,
dataSources,
}: AllToolsProps) => {
}: DataSourcesProps) {
const language = useGetLanguage()
const pluginRef = useRef<ListRef>(null)
const wrapElemRef = useRef<HTMLDivElement>(null)
@ -66,7 +67,6 @@ const DataSources = ({
title: toolDefaultValue?.title,
plugin_unique_identifier: toolDefaultValue?.plugin_unique_identifier,
}
// Update defaultValue with fileExtensions if this is the local file data source
if (
toolDefaultValue?.provider_id === 'langgenius/file' &&
toolDefaultValue?.provider_name === 'file'
@ -86,25 +86,36 @@ const DataSources = ({
select: (s) => s.enable_marketplace,
})
const { queryPluginsWithDebounced: fetchPlugins, plugins: notInstalledPlugins = [] } =
useMarketplacePlugins()
useEffect(() => {
if (!enable_marketplace) return
if (searchText) {
fetchPlugins({
query: searchText,
category: PluginCategoryEnum.datasource,
})
}
}, [searchText, enable_marketplace])
const trimmedSearchText = searchText.trim()
const debouncedMarketplaceSearchText = useDebounce(trimmedSearchText, { wait: 500 })
const isMarketplaceSearchSettled = debouncedMarketplaceSearchText === trimmedSearchText
const marketplaceSearchParams = useMemo(
() =>
enable_marketplace && trimmedSearchText && isMarketplaceSearchSettled
? {
query: debouncedMarketplaceSearchText,
category: PluginCategoryEnum.datasource,
}
: undefined,
[
debouncedMarketplaceSearchText,
enable_marketplace,
isMarketplaceSearchSettled,
trimmedSearchText,
],
)
const { data: marketplacePluginsData } = useMarketplacePlugins(marketplaceSearchParams)
const notInstalledPlugins = useMemo(
() => marketplacePluginsData?.pages.flatMap((page) => page.plugins) ?? [],
[marketplacePluginsData?.pages],
)
return (
<div className={cn('w-[400px] max-w-full min-w-0', className)}>
<div
ref={wrapElemRef}
className="max-h-[464px] overflow-x-hidden overflow-y-auto"
onScroll={pluginRef.current?.handleScroll}
onScroll={() => pluginRef.current?.handleScroll()}
>
<Tools
className={toolContentClassName}
@ -114,14 +125,13 @@ const DataSources = ({
hasSearchText={!!searchText}
canNotSelectMultiple
/>
{/* Plugins from marketplace */}
{enable_marketplace && (
<PluginList
ref={pluginRef}
wrapElemRef={wrapElemRef}
list={notInstalledPlugins}
tags={[]}
searchText={searchText}
searchText={trimmedSearchText}
category={PluginCategoryEnum.datasource}
toolContentClassName={toolContentClassName}
/>

View File

@ -4,6 +4,7 @@ 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 { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from '@langgenius/dify-ui/collapsible'
import {
@ -18,7 +19,7 @@ import { PluginInstallPermissionProvider } from '@/app/components/plugins/instal
import useWorkspacePluginInstallPermission from '@/app/components/plugins/install-plugin/hooks/use-workspace-plugin-install-permission'
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
import { getMarketplaceCategoryUrl } from '@/app/components/plugins/marketplace/utils'
import Action from '@/app/components/workflow/block-selector/market-place-plugin/action'
import Action from '@/app/components/workflow/block-selector/marketplace-plugin/action'
import { useFeaturedToolsCollapsed } from '@/app/components/workflow/block-selector/storage'
import { useGetLanguage } from '@/context/i18n'
import Link from '@/next/link'
@ -27,11 +28,9 @@ import { getMarketplaceUrl } from '@/utils/var'
import { PluginCategoryEnum } from '../../plugins/types'
import BlockIcon from '../block-icon'
import { BlockEnum } from '../types'
import { BlockSelectorRow } from './block-selector-row'
import { BlockSelectorPreviewCardContent } from './preview-card'
import Tools from './tools'
import { ToolType } from './types'
import { ViewType } from './view-type-select'
import { ToolType, ViewType } from './types'
const MAX_RECOMMENDED_COUNT = 15
const INITIAL_VISIBLE_COUNT = 5
@ -116,7 +115,7 @@ const FeaturedTools = ({
)
const hasMoreToShow = totalVisible < maxAvailable
const canToggleVisibility = maxAvailable > INITIAL_VISIBLE_COUNT
const isExpanded = canToggleVisibility && !hasMoreToShow
const isShowingAll = canToggleVisibility && !hasMoreToShow
const showEmptyState = !isLoading && totalVisible === 0
return (
@ -125,7 +124,7 @@ const FeaturedTools = ({
open={!isCollapsed}
onOpenChange={(open) => setIsCollapsed(!open)}
>
<CollapsibleTrigger className="min-h-0 justify-start gap-0 rounded-md px-0 py-1 hover:not-data-disabled:bg-transparent">
<CollapsibleTrigger className="-ml-2 min-h-0 w-fit justify-start gap-0 rounded-md px-2 py-1 hover:not-data-disabled:bg-transparent focus-visible:ring-inset">
<span className="system-xs-medium text-text-primary">
{t(($) => $['tabs.featuredTools'], { ns: 'workflow' })}
</span>
@ -156,7 +155,7 @@ const FeaturedTools = ({
)}
{!showEmptyState && !isLoading && (
<>
<div>
{visibleInstalledProviders.length > 0 && (
<Tools
className="p-0"
@ -186,13 +185,14 @@ const FeaturedTools = ({
))}
</div>
)}
</>
</div>
)}
{!isLoading && totalVisible > 0 && canToggleVisibility && (
<button
type="button"
className="group mt-1 flex w-full cursor-pointer touch-manipulation items-center gap-x-2 rounded-lg border-0 bg-transparent py-1 pr-2 pl-3 text-left text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid focus-visible:outline-hidden"
<Button
variant="ghost"
size="medium"
className="group mt-1 w-full justify-start gap-x-2 pr-2 pl-3 text-left text-text-tertiary hover:text-text-secondary focus-visible:ring-inset"
onClick={() => {
setVisibleCount((count) => {
if (count >= maxAvailable) return INITIAL_VISIBLE_COUNT
@ -206,7 +206,7 @@ const FeaturedTools = ({
aria-hidden
className="i-ri-more-line size-4 group-hover:hidden group-focus-visible:hidden"
/>
{isExpanded ? (
{isShowingAll ? (
<span
aria-hidden
className="i-custom-vender-solid-arrows-arrow-up-double-line hidden size-4 group-hover:block group-focus-visible:block"
@ -219,11 +219,11 @@ const FeaturedTools = ({
)}
</div>
<div className="system-xs-regular">
{t(($) => $[isExpanded ? 'tabs.showLessFeatured' : 'tabs.showMoreFeatured'], {
{t(($) => $[isShowingAll ? 'tabs.showLessFeatured' : 'tabs.showMoreFeatured'], {
ns: 'workflow',
})}
</div>
</button>
</Button>
)}
</CollapsiblePanel>
<PreviewCard handle={previewCardHandle}>
@ -276,23 +276,30 @@ function FeaturedToolUninstalledItem({
const detailsLink = (
<Link
className="flex h-full min-w-0 flex-1 items-center rounded-lg focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
className="flex h-full w-full min-w-0 items-center rounded-lg px-3 group-hover:bg-state-base-hover focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid focus-visible:outline-hidden"
href={getMarketplaceUrl(`/plugins/${plugin.org}/${plugin.name}`)}
target="_blank"
rel="noopener noreferrer"
>
<div className="flex min-w-0 items-center">
<BlockIcon type={BlockEnum.Tool} toolIcon={plugin.icon} />
<div className="ml-2 min-w-0">
<div className="truncate system-sm-medium text-text-secondary">{label}</div>
</div>
</div>
<BlockIcon className="shrink-0" type={BlockEnum.Tool} toolIcon={plugin.icon} />
<span className="ml-2 truncate system-sm-medium text-text-secondary">{label}</span>
<span
aria-hidden
className={cn(
'ml-auto shrink-0 pl-2 system-xs-regular text-text-tertiary',
actionOpen
? 'invisible'
: 'group-focus-within:invisible group-hover:invisible [@media(hover:none)]:invisible',
)}
>
{installCountLabel}
</span>
</Link>
)
return (
<>
<BlockSelectorRow as="div" className="group pr-1 focus-within:bg-state-base-hover">
<div className="group relative flex h-8 w-full items-center rounded-lg">
{description ? (
<PreviewCardTrigger
delay={150}
@ -304,47 +311,36 @@ function FeaturedToolUninstalledItem({
) : (
detailsLink
)}
<div className="relative ml-auto flex h-full items-center pl-1">
<span
className={cn(
'system-xs-regular text-text-tertiary',
actionOpen
? 'hidden'
: 'group-focus-within:hidden group-hover:hidden [@media(hover:none)]:hidden',
)}
>
{installCountLabel}
</span>
<div
className={cn(
'absolute right-0 flex h-full items-center gap-1 system-xs-medium text-components-button-secondary-accent-text opacity-0 transition-opacity motion-reduce:transition-none [&_.action-btn]:size-6 [&_.action-btn]:min-h-0 [&_.action-btn]:rounded-lg [&_.action-btn]:p-0',
actionOpen
? 'pointer-events-auto opacity-100'
: 'pointer-events-none group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100 [@media(hover:none)]:pointer-events-auto [@media(hover:none)]:opacity-100',
)}
>
{canInstallPlugin && (
<button
type="button"
className="cursor-pointer rounded-md px-1.5 py-0.5 hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
onClick={() => {
setActionOpen(false)
setIsInstallModalOpen(true)
}}
>
{t(($) => $.installAction, { ns: 'plugin' })}
</button>
)}
<Action
open={actionOpen}
onOpenChange={setActionOpen}
author={plugin.org}
name={plugin.name}
version={plugin.latest_version}
/>
</div>
<div
className={cn(
'absolute inset-y-0 right-1 flex items-center gap-1 system-xs-medium text-components-button-secondary-accent-text opacity-0',
actionOpen
? 'pointer-events-auto opacity-100'
: 'pointer-events-none group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100 [@media(hover:none)]:pointer-events-auto [@media(hover:none)]:opacity-100',
)}
>
{canInstallPlugin && (
<Button
variant="ghost"
size="small"
className="text-components-button-secondary-accent-text"
onClick={() => {
setActionOpen(false)
setIsInstallModalOpen(true)
}}
>
{t(($) => $.installAction, { ns: 'plugin' })}
</Button>
)}
<Action
open={actionOpen}
onOpenChange={setActionOpen}
author={plugin.org}
name={plugin.name}
version={plugin.latest_version}
/>
</div>
</BlockSelectorRow>
</div>
{isInstallModalOpen && canInstallPlugin && (
<PluginInstallPermissionProvider
canInstallPlugin={canInstallPlugin}

View File

@ -4,6 +4,7 @@ import type { TriggerPluginActionPreviewPayload } from './trigger-plugin/action-
import type { TriggerDefaultValue, TriggerWithProvider } from './types'
import type { Plugin } from '@/app/components/plugins/types'
import type { Locale } from '@/i18n-config'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from '@langgenius/dify-ui/collapsible'
import {
@ -18,7 +19,7 @@ import { PluginInstallPermissionProvider } from '@/app/components/plugins/instal
import useWorkspacePluginInstallPermission from '@/app/components/plugins/install-plugin/hooks/use-workspace-plugin-install-permission'
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
import { getMarketplaceCategoryUrl } from '@/app/components/plugins/marketplace/utils'
import Action from '@/app/components/workflow/block-selector/market-place-plugin/action'
import Action from '@/app/components/workflow/block-selector/marketplace-plugin/action'
import { useFeaturedTriggersCollapsed } from '@/app/components/workflow/block-selector/storage'
import { useGetLanguage } from '@/context/i18n'
import Link from '@/next/link'
@ -27,7 +28,6 @@ import { getMarketplaceUrl } from '@/utils/var'
import { PluginCategoryEnum } from '../../plugins/types'
import BlockIcon from '../block-icon'
import { BlockEnum } from '../types'
import { BlockSelectorRow } from './block-selector-row'
import { BlockSelectorPreviewCardContent } from './preview-card'
import { TriggerPluginActionPreviewCard } from './trigger-plugin/action-item'
import TriggerPluginItem from './trigger-plugin/item'
@ -121,7 +121,7 @@ const FeaturedTriggers = ({
)
const hasMoreToShow = totalVisible < maxAvailable
const canToggleVisibility = maxAvailable > INITIAL_VISIBLE_COUNT
const isExpanded = canToggleVisibility && !hasMoreToShow
const isShowingAll = canToggleVisibility && !hasMoreToShow
const showEmptyState = !isLoading && totalVisible === 0
return (
@ -130,7 +130,7 @@ const FeaturedTriggers = ({
open={!isCollapsed}
onOpenChange={(open) => setIsCollapsed(!open)}
>
<CollapsibleTrigger className="min-h-0 justify-start gap-0 rounded-md px-4 py-1 hover:not-data-disabled:bg-transparent">
<CollapsibleTrigger className="ml-2 min-h-0 w-fit justify-start gap-0 rounded-md px-2 py-1 hover:not-data-disabled:bg-transparent focus-visible:ring-inset">
<span className="system-xs-medium text-text-primary">
{t(($) => $['tabs.featuredTools'], { ns: 'workflow' })}
</span>
@ -189,9 +189,10 @@ const FeaturedTriggers = ({
)}
{!isLoading && totalVisible > 0 && canToggleVisibility && (
<button
type="button"
className="group mt-1 flex w-full cursor-pointer touch-manipulation items-center gap-x-2 rounded-lg border-0 bg-transparent py-1 pr-2 pl-3 text-left text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid focus-visible:outline-hidden"
<Button
variant="ghost"
size="medium"
className="group mt-1 w-full justify-start gap-x-2 pr-2 pl-3 text-left text-text-tertiary hover:text-text-secondary focus-visible:ring-inset"
onClick={() => {
setVisibleCount((count) => {
if (count >= maxAvailable) return INITIAL_VISIBLE_COUNT
@ -205,7 +206,7 @@ const FeaturedTriggers = ({
aria-hidden
className="i-ri-more-line size-4 group-hover:hidden group-focus-visible:hidden"
/>
{isExpanded ? (
{isShowingAll ? (
<span
aria-hidden
className="i-custom-vender-solid-arrows-arrow-up-double-line hidden size-4 group-hover:block group-focus-visible:block"
@ -218,11 +219,11 @@ const FeaturedTriggers = ({
)}
</div>
<div className="system-xs-regular">
{t(($) => $[isExpanded ? 'tabs.showLessFeatured' : 'tabs.showMoreFeatured'], {
{t(($) => $[isShowingAll ? 'tabs.showLessFeatured' : 'tabs.showMoreFeatured'], {
ns: 'workflow',
})}
</div>
</button>
</Button>
)}
</CollapsiblePanel>
<PreviewCard handle={previewCardHandle}>
@ -284,28 +285,35 @@ function FeaturedTriggerUninstalledItem({
const detailsLink = (
<Link
className="flex h-full min-w-0 flex-1 items-center rounded-lg focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
className="flex h-full w-full min-w-0 items-center rounded-lg px-3 group-hover:bg-state-base-hover focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid focus-visible:outline-hidden"
href={getMarketplaceUrl(`/plugins/${plugin.org}/${plugin.name}`)}
target="_blank"
rel="noopener noreferrer"
>
<div className="flex min-w-0 items-center">
<BlockIcon
className="mr-2 shrink-0"
type={BlockEnum.TriggerPlugin}
size="sm"
toolIcon={plugin.icon}
/>
<div className="min-w-0">
<div className="truncate system-sm-medium text-text-secondary">{label}</div>
</div>
</div>
<BlockIcon
className="shrink-0"
type={BlockEnum.TriggerPlugin}
size="sm"
toolIcon={plugin.icon}
/>
<span className="ml-2 truncate system-sm-medium text-text-secondary">{label}</span>
<span
aria-hidden
className={cn(
'ml-auto shrink-0 pl-2 system-xs-regular text-text-tertiary',
actionOpen
? 'invisible'
: 'group-focus-within:invisible group-hover:invisible [@media(hover:none)]:invisible',
)}
>
{installCountLabel}
</span>
</Link>
)
return (
<>
<BlockSelectorRow as="div" className="group select-none focus-within:bg-state-base-hover">
<div className="group relative flex h-8 w-full items-center rounded-lg select-none">
{description ? (
<PreviewCardTrigger
delay={150}
@ -317,47 +325,36 @@ function FeaturedTriggerUninstalledItem({
) : (
detailsLink
)}
<div className="relative ml-auto flex h-6 items-center pl-1">
<span
className={cn(
'system-xs-regular text-text-tertiary',
actionOpen
? 'hidden'
: 'group-focus-within:hidden group-hover:hidden [@media(hover:none)]:hidden',
)}
>
{installCountLabel}
</span>
<div
className={cn(
'absolute right-0 flex h-full items-center gap-1 system-xs-medium text-components-button-secondary-accent-text opacity-0 transition-opacity motion-reduce:transition-none [&_.action-btn]:size-6 [&_.action-btn]:min-h-0 [&_.action-btn]:rounded-lg [&_.action-btn]:p-0',
actionOpen
? 'pointer-events-auto opacity-100'
: 'pointer-events-none group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100 [@media(hover:none)]:pointer-events-auto [@media(hover:none)]:opacity-100',
)}
>
{canInstallPlugin && (
<button
type="button"
className="cursor-pointer rounded-md px-1.5 py-0.5 hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
onClick={() => {
setActionOpen(false)
setIsInstallModalOpen(true)
}}
>
{t(($) => $.installAction, { ns: 'plugin' })}
</button>
)}
<Action
open={actionOpen}
onOpenChange={setActionOpen}
author={plugin.org}
name={plugin.name}
version={plugin.latest_version}
/>
</div>
<div
className={cn(
'absolute inset-y-0 right-1 flex items-center gap-1 system-xs-medium text-components-button-secondary-accent-text opacity-0',
actionOpen
? 'pointer-events-auto opacity-100'
: 'pointer-events-none group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100 [@media(hover:none)]:pointer-events-auto [@media(hover:none)]:opacity-100',
)}
>
{canInstallPlugin && (
<Button
variant="ghost"
size="small"
className="text-components-button-secondary-accent-text"
onClick={() => {
setActionOpen(false)
setIsInstallModalOpen(true)
}}
>
{t(($) => $.installAction, { ns: 'plugin' })}
</Button>
)}
<Action
open={actionOpen}
onOpenChange={setActionOpen}
author={plugin.org}
name={plugin.name}
version={plugin.latest_version}
/>
</div>
</BlockSelectorRow>
</div>
{isInstallModalOpen && canInstallPlugin && (
<PluginInstallPermissionProvider
canInstallPlugin={canInstallPlugin}

View File

@ -51,16 +51,16 @@ export const useTabs = ({
name: t(($) => $['tabs.blocks'], { ns: 'workflow' }),
show: !noBlocks,
},
{
key: TabType.Sources,
name: t(($) => $['tabs.sources'], { ns: 'workflow' }),
show: !noSources,
},
{
key: TabType.Tools,
name: t(($) => $['tabs.tools'], { ns: 'workflow' }),
show: !noTools,
},
{
key: TabType.Sources,
name: t(($) => $['tabs.sources'], { ns: 'workflow' }),
show: !noSources,
},
{
key: TabType.Start,
name: t(($) => $['tabs.start'], { ns: 'workflow' }),
@ -94,24 +94,11 @@ export const useTabs = ({
if (!tab || tab.disabled) return undefined
return tab.key
}
const fallbackTab = tabs.find((tab) => !tab.disabled)?.key ?? TabType.Blocks
const preferredDefault = getValidTabKey(defaultActiveTab)
if (preferredDefault) return preferredDefault
const preferredOrder: TabType[] = []
if (!noBlocks) preferredOrder.push(TabType.Blocks)
if (!noTools) preferredOrder.push(TabType.Tools)
if (!noSources) preferredOrder.push(TabType.Sources)
if (!noStart) preferredOrder.push(TabType.Start)
if (!noSnippets) preferredOrder.push(TabType.Snippets)
for (const tabKey of preferredOrder) {
const validKey = getValidTabKey(tabKey)
if (validKey) return validKey
}
return fallbackTab
}, [defaultActiveTab, noBlocks, noSources, noTools, noSnippets, noStart, tabs])
return tabs.find((tab) => !tab.disabled)?.key ?? TabType.Blocks
}, [defaultActiveTab, tabs])
return {
tabs,

View File

@ -1,70 +1,5 @@
import type { FC, RefObject } from 'react'
import type { ToolWithProvider } from '../types'
import type { RefObject } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { pinyin } from 'pinyin-pro'
import { CollectionType } from '../../tools/types'
export const CUSTOM_GROUP_NAME = '@@@custom@@@'
export const WORKFLOW_GROUP_NAME = '@@@workflow@@@'
export const DATA_SOURCE_GROUP_NAME = '@@@data_source@@@'
export const AGENT_GROUP_NAME = '@@@agent@@@'
/*
{
A: {
'google': [ // plugin organize name
...tools
],
'custom': [ // custom tools
...tools
],
'workflow': [ // workflow as tools
...tools
]
}
}
*/
export const groupItems = (
items: ToolWithProvider[],
getFirstChar: (item: ToolWithProvider) => string,
) => {
const groups = items.reduce((acc: Record<string, Record<string, ToolWithProvider[]>>, item) => {
const firstChar = getFirstChar(item)
if (!firstChar || firstChar.length === 0) return acc
let letter
// transform Chinese to pinyin
if (/[\u4E00-\u9FA5]/.test(firstChar))
letter = pinyin(firstChar, { pattern: 'first', toneType: 'none' })[0]!.toUpperCase()
else letter = firstChar.toUpperCase()
if (!/[A-Z]/.test(letter)) letter = '#'
if (!acc[letter]) acc[letter] = {}
let groupName: string = ''
if (item.type === CollectionType.builtIn) groupName = item.author
else if (item.type === CollectionType.custom) groupName = CUSTOM_GROUP_NAME
else if (item.type === CollectionType.workflow) groupName = WORKFLOW_GROUP_NAME
else if (item.type === CollectionType.datasource) groupName = DATA_SOURCE_GROUP_NAME
else groupName = AGENT_GROUP_NAME
if (!acc[letter]![groupName]) acc[letter]![groupName] = []
acc[letter]![groupName]!.push(item)
return acc
}, {})
const letters = Object.keys(groups).sort()
// move '#' to the end
const hashIndex = letters.indexOf('#')
if (hashIndex !== -1) {
letters.splice(hashIndex, 1)
letters.push('#')
}
return { letters, groups }
}
type IndexBarProps = {
letters: string[]
@ -72,7 +7,7 @@ type IndexBarProps = {
className?: string
}
const IndexBar: FC<IndexBarProps> = ({ letters, itemRefs, className }) => {
export function IndexBar({ letters, itemRefs, className }: IndexBarProps) {
const handleIndexClick = (letter: string) => {
const element = itemRefs.current?.[letter]
if (element) element.scrollIntoView({ behavior: 'smooth' })
@ -80,19 +15,18 @@ const IndexBar: FC<IndexBarProps> = ({ letters, itemRefs, className }) => {
return (
<div
className={cn(
'index-bar sticky top-[20px] flex h-full w-6 flex-col items-center justify-center text-xs font-medium text-text-quaternary',
'sticky top-5 flex h-full w-6 shrink-0 flex-col items-center justify-center text-xs font-medium text-text-quaternary',
className,
)}
>
<div
className={cn(
'absolute top-0 left-0 h-full w-px bg-[linear-gradient(270deg,rgba(255,255,255,0)_0%,rgba(16,24,40,0.08)_30%,rgba(16,24,40,0.08)_50%,rgba(16,24,40,0.08)_70.5%,rgba(255,255,255,0)_100%)]',
)}
></div>
aria-hidden
className="absolute top-0 left-0 h-full w-px bg-[linear-gradient(270deg,rgba(255,255,255,0)_0%,rgba(16,24,40,0.08)_30%,rgba(16,24,40,0.08)_50%,rgba(16,24,40,0.08)_70.5%,rgba(255,255,255,0)_100%)]"
/>
{letters.map((letter) => (
<button
type="button"
className="cursor-pointer border-none bg-transparent p-0 text-left hover:text-text-secondary"
className="flex h-4 w-5 cursor-pointer items-center justify-center rounded-sm border-0 bg-transparent p-0 outline-hidden hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid"
key={letter}
onClick={() => handleIndexClick(letter)}
>
@ -102,5 +36,3 @@ const IndexBar: FC<IndexBarProps> = ({ letters, itemRefs, className }) => {
</div>
)
}
export default IndexBar

View File

@ -1,43 +1,317 @@
import type { NodeSelectorProps } from './main'
import { useMemo } from 'react'
import { useHooksStore } from '@/app/components/workflow/hooks-store/store'
import { BlockEnum } from '@/app/components/workflow/types'
import type { Placement } from '@langgenius/dify-ui/popover'
import type { CSSProperties, KeyboardEvent, MouseEventHandler, ReactElement } from 'react'
import type {
CommonNodeType,
NodeDefault,
OnNodeAdd,
OnSelectBlock,
ToolWithProvider,
} from '../types'
import type { TabType } from './types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
PopoverClose,
PopoverContent,
PopoverTitle,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { memo, useCallback, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useHooksStore } from '@/app/components/workflow/hooks-store'
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
import { FlowType } from '@/types/common'
import TipPopup from '../operator/tip-popup'
import { useStore } from '../store'
import NodeSelector from './main'
import { BlockEnum, isTriggerNode } from '../types'
import { useTabs } from './hooks'
import { BlockSelectorPanels } from './tabs'
const NodeSelectorWrapper = (props: NodeSelectorProps) => {
const availableNodesMetaData = useHooksStore((s) => s.availableNodesMetaData)
const dataSourceList = useStore((s) => s.dataSourceList)
export type BlockSelectorProps = {
open?: boolean
onOpenChange?: (open: boolean) => void
onSelect: OnSelectBlock
trigger?: (open: boolean) => ReactElement
triggerTooltip?: string
placement?: Placement
sideOffset?: number
alignOffset?: number
triggerStyle?: CSSProperties
triggerClassName?: (open: boolean) => string
triggerAriaLabel?: string
popupClassName?: string
availableBlocksTypes?: BlockEnum[]
disabled?: boolean
blocks?: NodeDefault[]
dataSources?: ToolWithProvider[]
noBlocks?: boolean
noTools?: boolean
standalonePanel?: TabType
showStartTab?: boolean
defaultActiveTab?: TabType
ignoreNodeIds?: string[]
forceEnableStartTab?: boolean // Force enabling Start tab regardless of existing trigger/user input nodes (e.g., when changing Start node type).
allowUserInputSelection?: boolean // Override user-input availability; default logic blocks it when triggers exist.
snippetInsertPayload?: Parameters<OnNodeAdd>[1]
isolateKeyboardEvents?: boolean
}
function BlockSelector({
open: openFromProps,
onOpenChange,
onSelect,
trigger,
triggerTooltip,
placement = 'right',
sideOffset,
alignOffset,
triggerClassName,
triggerAriaLabel,
triggerStyle,
popupClassName,
availableBlocksTypes,
disabled,
blocks,
dataSources,
noBlocks = false,
noTools = false,
standalonePanel,
showStartTab = false,
defaultActiveTab,
ignoreNodeIds = [],
forceEnableStartTab = false,
allowUserInputSelection,
snippetInsertPayload,
isolateKeyboardEvents = false,
}: BlockSelectorProps) {
const { t } = useTranslation()
const [localOpen, setLocalOpen] = useState(false)
const searchInputRef = useRef<HTMLInputElement>(null)
const open = openFromProps === undefined ? localOpen : openFromProps
const handleOpenChange = useCallback(
(newOpen: boolean) => {
if (newOpen && disabled) return
const blocks = useMemo(() => {
const result = availableNodesMetaData?.nodes || []
setLocalOpen(newOpen)
if (onOpenChange) onOpenChange(newOpen)
},
[disabled, onOpenChange],
)
return result.filter((block) => {
if (block.metaData.type === BlockEnum.Start) return false
const handleTrigger = useCallback<MouseEventHandler<HTMLElement>>((e) => {
e.stopPropagation()
}, [])
const handlePopupClick = useCallback<MouseEventHandler<HTMLDivElement>>((event) => {
event.stopPropagation()
}, [])
if (block.metaData.type === BlockEnum.StartPlaceholder) return false
const handleSelect = useCallback<OnSelectBlock>(
(type, pluginDefaultValue) => {
if (disabled) return
handleOpenChange(false)
onSelect(type, pluginDefaultValue)
},
[disabled, handleOpenChange, onSelect],
)
if (block.metaData.type === BlockEnum.DataSource) return false
const handlePopupKeyDown = useCallback(
(event: KeyboardEvent) => {
if (isolateKeyboardEvents) event.stopPropagation()
},
[isolateKeyboardEvents],
)
if (block.metaData.type === BlockEnum.Tool) return false
if (block.metaData.type === BlockEnum.IterationStart) return false
if (block.metaData.type === BlockEnum.LoopStart) return false
if (block.metaData.type === BlockEnum.DataSourceEmpty) return false
return true
})
}, [availableNodesMetaData?.nodes])
const triggerControl = trigger ? (
<PopoverTrigger
aria-label={triggerAriaLabel}
disabled={disabled}
render={trigger(open)}
onClick={handleTrigger}
/>
) : (
<PopoverTrigger
aria-label={t(($) => $['common.addBlock'], { ns: 'workflow' })}
disabled={disabled}
render={
<Button
variant="primary"
size="small"
className={cn('z-10 size-4 rounded-full p-0', triggerClassName?.(open))}
style={triggerStyle}
/>
}
onClick={handleTrigger}
>
<span aria-hidden className="i-custom-vender-line-general-plus-02 size-2.5" />
</PopoverTrigger>
)
const triggerWithTooltip = triggerTooltip ? (
<TipPopup title={triggerTooltip}>{triggerControl}</TipPopup>
) : (
triggerControl
)
return (
<NodeSelector
{...props}
blocks={props.blocks || blocks}
dataSources={props.dataSources || dataSourceList || []}
<Popover modal="trap-focus" open={open} onOpenChange={handleOpenChange}>
{triggerWithTooltip}
<PopoverContent
placement={placement}
sideOffset={sideOffset}
alignOffset={alignOffset}
positionerProps={{ positionMethod: 'fixed' }}
popupClassName="border-none bg-transparent shadow-none"
popupProps={{
initialFocus: searchInputRef,
onClick: handlePopupClick,
...(isolateKeyboardEvents ? { onKeyDown: handlePopupKeyDown } : {}),
}}
>
<PopoverTitle className="sr-only">
{t(($) => $['common.addBlock'], { ns: 'workflow' })}
</PopoverTitle>
<div
className={cn(
'w-100 min-w-0 overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg',
popupClassName,
)}
>
<BlockSelectorContent
standalonePanel={standalonePanel}
searchInputRef={searchInputRef}
blocks={blocks}
onSelect={handleSelect}
onRequestClose={() => handleOpenChange(false)}
availableBlocksTypes={availableBlocksTypes}
dataSources={dataSources}
noBlocks={noBlocks}
noTools={noTools}
showStartTab={showStartTab}
defaultActiveTab={defaultActiveTab}
ignoreNodeIds={ignoreNodeIds}
forceEnableStartTab={forceEnableStartTab}
allowUserInputSelection={allowUserInputSelection}
snippetInsertPayload={snippetInsertPayload}
/>
</div>
<PopoverClose className="sr-only" tabIndex={-1}>
{t(($) => $['operation.close'], { ns: 'common' })}
</PopoverClose>
</PopoverContent>
</Popover>
)
}
type BlockSelectorContentProps = Pick<
BlockSelectorProps,
| 'allowUserInputSelection'
| 'availableBlocksTypes'
| 'blocks'
| 'dataSources'
| 'defaultActiveTab'
| 'forceEnableStartTab'
| 'ignoreNodeIds'
| 'noBlocks'
| 'noTools'
| 'showStartTab'
| 'snippetInsertPayload'
| 'standalonePanel'
> & {
onSelect: OnSelectBlock
onRequestClose: () => void
searchInputRef: React.RefObject<HTMLInputElement | null>
}
function BlockSelectorContent({
allowUserInputSelection,
availableBlocksTypes,
blocks: blocksFromProps,
dataSources: dataSourcesFromProps,
defaultActiveTab,
forceEnableStartTab = false,
ignoreNodeIds = [],
noBlocks = false,
noTools = false,
onRequestClose,
onSelect,
searchInputRef,
showStartTab = false,
snippetInsertPayload,
standalonePanel,
}: BlockSelectorContentProps) {
const nodes = useNodes()
const flowType = useHooksStore((state) => state.configsMap?.flowType)
const availableNodesMetaData = useHooksStore((state) => state.availableNodesMetaData)
const fallbackDataSources = useStore((state) => state.dataSourceList)
const blocks = useMemo(() => {
if (blocksFromProps) return blocksFromProps
return (availableNodesMetaData?.nodes ?? []).filter((block) => {
return ![
BlockEnum.Start,
BlockEnum.StartPlaceholder,
BlockEnum.DataSource,
BlockEnum.Tool,
BlockEnum.IterationStart,
BlockEnum.LoopStart,
BlockEnum.DataSourceEmpty,
].includes(block.metaData.type)
})
}, [availableNodesMetaData?.nodes, blocksFromProps])
const dataSources = dataSourcesFromProps ?? fallbackDataSources ?? []
const filteredNodes = useMemo(() => {
if (!ignoreNodeIds.length) return nodes
const ignoredNodeIds = new Set(ignoreNodeIds)
return nodes.filter((node) => !ignoredNodeIds.has(node.id))
}, [ignoreNodeIds, nodes])
const { hasTriggerNode, hasUserInputNode, hasStartPlaceholderNode } = useMemo(() => {
const result = {
hasTriggerNode: false,
hasUserInputNode: false,
hasStartPlaceholderNode: false,
}
for (const node of filteredNodes) {
const nodeType = (node.data as CommonNodeType | undefined)?.type
if (!nodeType) continue
if (nodeType === BlockEnum.Start) result.hasUserInputNode = true
if (nodeType === BlockEnum.StartPlaceholder) result.hasStartPlaceholderNode = true
if (isTriggerNode(nodeType)) result.hasTriggerNode = true
if (result.hasTriggerNode && result.hasUserInputNode && result.hasStartPlaceholderNode) break
}
return result
}, [filteredNodes])
const disableStartTab = flowType === FlowType.snippet
const { initialTab, tabs } = useTabs({
noBlocks,
noSources: !dataSources.length,
noTools,
noSnippets: flowType === FlowType.snippet,
noStart: !showStartTab,
defaultActiveTab,
hasStartPlaceholderNode,
disableStartTab,
forceEnableStartTab,
})
const allowStartNodeSelection = allowUserInputSelection ?? (!hasUserInputNode && !hasTriggerNode)
return (
<BlockSelectorPanels
tabs={tabs}
defaultTab={initialTab}
standalonePanel={standalonePanel}
searchInputRef={searchInputRef}
blocks={blocks}
allowStartNodeSelection={allowStartNodeSelection}
hasUserInputNode={hasUserInputNode}
hasTriggerNode={hasTriggerNode}
onSelect={onSelect}
onRequestClose={onRequestClose}
availableBlocksTypes={availableBlocksTypes}
dataSources={dataSources}
snippetInsertPayload={snippetInsertPayload}
/>
)
}
export default NodeSelectorWrapper
export default memo(BlockSelector)

View File

@ -1,235 +0,0 @@
import type { Placement } from '@langgenius/dify-ui/popover'
import type { MouseEventHandler } from 'react'
import type {
CommonNodeType,
NodeDefault,
OnNodeAdd,
OnSelectBlock,
ToolWithProvider,
} from '../types'
import type { TabType } from './types'
import { cn } from '@langgenius/dify-ui/cn'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import * as React from 'react'
import { memo, useCallback, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useHooksStore } from '@/app/components/workflow/hooks-store'
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
import { FlowType } from '@/types/common'
import { BlockEnum, isTriggerNode } from '../types'
import { useTabs } from './hooks'
import { SelectorContent } from './tabs'
type NodeSelectorOffset =
| number
| {
mainAxis?: number
crossAxis?: number
}
export type NodeSelectorProps = {
open?: boolean
onOpenChange?: (open: boolean) => void
onSelect: OnSelectBlock
trigger?: (open: boolean) => React.ReactNode
placement?: Placement
offset?: NodeSelectorOffset
triggerStyle?: React.CSSProperties
triggerClassName?: (open: boolean) => string
triggerInnerClassName?: string
renderTriggerAsButtonRoot?: boolean
popupClassName?: string
availableBlocksTypes?: BlockEnum[]
disabled?: boolean
blocks?: NodeDefault[]
dataSources?: ToolWithProvider[]
noBlocks?: boolean
noTools?: boolean
showStartTab?: boolean
defaultActiveTab?: TabType
ignoreNodeIds?: string[]
forceEnableStartTab?: boolean // Force enabling Start tab regardless of existing trigger/user input nodes (e.g., when changing Start node type).
allowUserInputSelection?: boolean // Override user-input availability; default logic blocks it when triggers exist.
snippetInsertPayload?: Parameters<OnNodeAdd>[1]
isolateKeyboardEvents?: boolean
}
function NodeSelector({
open: openFromProps,
onOpenChange,
onSelect,
trigger,
placement = 'right',
offset = 6,
triggerClassName,
triggerInnerClassName,
renderTriggerAsButtonRoot = false,
triggerStyle,
popupClassName,
availableBlocksTypes,
disabled,
blocks = [],
dataSources = [],
noBlocks = false,
noTools = false,
showStartTab = false,
defaultActiveTab,
ignoreNodeIds = [],
forceEnableStartTab = false,
allowUserInputSelection,
snippetInsertPayload,
isolateKeyboardEvents = false,
}: NodeSelectorProps) {
const { t } = useTranslation()
const nodes = useNodes()
const flowType = useHooksStore((s) => s.configsMap?.flowType)
const [localOpen, setLocalOpen] = useState(false)
const searchInputRef = useRef<HTMLInputElement>(null)
// Exclude nodes explicitly ignored (such as the node currently being edited) when checking canvas state.
const filteredNodes = useMemo(() => {
if (!ignoreNodeIds.length) return nodes
const ignoreSet = new Set(ignoreNodeIds)
return nodes.filter((node) => !ignoreSet.has(node.id))
}, [nodes, ignoreNodeIds])
const { hasTriggerNode, hasUserInputNode, hasStartPlaceholderNode } = useMemo(() => {
const result = {
hasTriggerNode: false,
hasUserInputNode: false,
hasStartPlaceholderNode: false,
}
for (const node of filteredNodes) {
const nodeType = (node.data as CommonNodeType | undefined)?.type
if (!nodeType) continue
if (nodeType === BlockEnum.Start) result.hasUserInputNode = true
if (nodeType === BlockEnum.StartPlaceholder) result.hasStartPlaceholderNode = true
if (isTriggerNode(nodeType)) result.hasTriggerNode = true
if (result.hasTriggerNode && result.hasUserInputNode && result.hasStartPlaceholderNode) break
}
return result
}, [filteredNodes])
// Default rule: user input option is only available when no Start node nor Trigger node exists on canvas.
const defaultAllowUserInputSelection = !hasUserInputNode && !hasTriggerNode
const canSelectUserInput = allowUserInputSelection ?? defaultAllowUserInputSelection
const disableStartTab = flowType === FlowType.snippet
const disableSnippetsTab = flowType === FlowType.snippet
const { initialTab, tabs } = useTabs({
noBlocks,
noSources: !dataSources.length,
noTools,
noSnippets: disableSnippetsTab,
noStart: !showStartTab,
defaultActiveTab,
hasStartPlaceholderNode,
disableStartTab,
forceEnableStartTab,
})
const open = openFromProps === undefined ? localOpen : openFromProps
const handleOpenChange = useCallback(
(newOpen: boolean) => {
if (disabled) return
setLocalOpen(newOpen)
if (onOpenChange) onOpenChange(newOpen)
},
[disabled, onOpenChange],
)
const handleTrigger = useCallback<MouseEventHandler<HTMLElement>>((e) => {
e.stopPropagation()
}, [])
const handlePopupClick = useCallback<MouseEventHandler<HTMLDivElement>>((event) => {
event.stopPropagation()
}, [])
const handleSelect = useCallback<OnSelectBlock>(
(type, pluginDefaultValue) => {
handleOpenChange(false)
onSelect(type, pluginDefaultValue)
},
[handleOpenChange, onSelect],
)
const handlePopupKeyDown = useCallback(
(event: React.KeyboardEvent) => {
if (isolateKeyboardEvents) event.stopPropagation()
},
[isolateKeyboardEvents],
)
const defaultTriggerElement = (
<PopoverTrigger
aria-label={t(($) => $['common.addBlock'], { ns: 'workflow' })}
className={cn(
'z-10 flex size-4 cursor-pointer items-center justify-center rounded-full border-0 bg-components-button-primary-bg p-0 text-text-primary-on-surface hover:bg-components-button-primary-bg-hover focus-visible:ring-1 focus-visible:ring-components-input-border-hover focus-visible:outline-hidden',
triggerClassName?.(open),
)}
style={triggerStyle}
onClick={handleTrigger}
>
<span aria-hidden className="i-custom-vender-line-general-plus-02 size-2.5" />
</PopoverTrigger>
)
const triggerElement = trigger?.(open)
const isValidTriggerElement = React.isValidElement(triggerElement)
const isNativeButtonTrigger = isValidTriggerElement && triggerElement.type === 'button'
const shouldRenderTriggerAsButtonRoot =
isValidTriggerElement && (renderTriggerAsButtonRoot || isNativeButtonTrigger)
const resolvedTriggerElement = shouldRenderTriggerAsButtonRoot ? (
triggerElement
) : (
<div className={triggerInnerClassName}>{triggerElement}</div>
)
const resolvedOffset = typeof offset === 'number' ? undefined : offset
const sideOffset = typeof offset === 'number' ? offset : (resolvedOffset?.mainAxis ?? 0)
const alignOffset = typeof offset === 'number' ? 0 : (resolvedOffset?.crossAxis ?? 0)
return (
<Popover open={open} onOpenChange={handleOpenChange}>
{trigger ? (
<PopoverTrigger
nativeButton={shouldRenderTriggerAsButtonRoot}
onClick={handleTrigger}
render={resolvedTriggerElement as React.ReactElement}
/>
) : (
defaultTriggerElement
)}
<PopoverContent
placement={placement}
sideOffset={sideOffset}
alignOffset={alignOffset}
positionerProps={{ positionMethod: 'fixed' }}
popupClassName="border-none bg-transparent shadow-none"
popupProps={{
initialFocus: searchInputRef,
onClick: handlePopupClick,
...(isolateKeyboardEvents ? { onKeyDown: handlePopupKeyDown } : {}),
}}
>
<div
className={cn(
'w-[400px] min-w-0 overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg',
popupClassName,
)}
>
<SelectorContent
tabs={tabs}
defaultTab={initialTab}
standalonePanel={noBlocks ? initialTab : undefined}
searchInputRef={searchInputRef}
blocks={blocks}
allowStartNodeSelection={canSelectUserInput}
hasUserInputNode={hasUserInputNode}
hasTriggerNode={hasTriggerNode}
onSelect={handleSelect}
onRequestClose={() => handleOpenChange(false)}
availableBlocksTypes={availableBlocksTypes}
dataSources={dataSources}
snippetInsertPayload={snippetInsertPayload}
/>
</div>
</PopoverContent>
</Popover>
)
}
export default memo(NodeSelector)

View File

@ -59,7 +59,7 @@ const renderComponent = (props?: Partial<ComponentProps<typeof OperationDropdown
)
}
describe('OperationDropdown', () => {
describe('MarketplaceOperationDropdown', () => {
beforeEach(() => {
vi.clearAllMocks()
mockDownloadPlugin.mockResolvedValue(new Blob(['plugin zip'], { type: 'application/zip' }))

View File

@ -2,6 +2,7 @@ import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { createPlugin } from '../../__tests__/factories'
import useStickyScroll, { ScrollPosition } from '../../use-sticky-scroll'
import Item from '../item'
import List from '../list'
@ -45,6 +46,11 @@ vi.mock('../action', () => ({
),
}))
vi.mock('../../use-sticky-scroll', async (importOriginal) => ({
...(await importOriginal<typeof import('../../use-sticky-scroll')>()),
default: vi.fn(),
}))
vi.mock('@/utils/var', async (importOriginal) => ({
...(await importOriginal<typeof import('@/utils/var')>()),
getMarketplaceUrl: (path = '', params?: Record<string, unknown>) => {
@ -54,7 +60,15 @@ vi.mock('@/utils/var', async (importOriginal) => ({
},
}))
describe('marketplace plugin selector components', () => {
describe('Marketplace plugin selector components', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useStickyScroll).mockReturnValue({
handleScroll: vi.fn(),
scrollPosition: ScrollPosition.belowTheWrap,
})
})
it('should render marketplace plugin metadata and open install modal', async () => {
const user = userEvent.setup()
@ -67,7 +81,6 @@ describe('marketplace plugin selector components', () => {
label: { en_US: 'Search Plugin', zh_Hans: 'Search Plugin' },
brief: { en_US: 'Searches documents', zh_Hans: 'Searches documents' },
})}
onAction={vi.fn()}
/>,
)
@ -75,7 +88,16 @@ describe('marketplace plugin selector components', () => {
expect(screen.getByText('Searches documents')).toBeInTheDocument()
expect(screen.getByText('LangGenius')).toBeInTheDocument()
await user.click(screen.getByText('plugin.installAction'))
const installButton = screen.getByRole('button', { name: 'plugin.installAction' })
const actionButton = screen.getByRole('button', { name: 'marketplace-action' })
await user.tab()
expect(installButton).toHaveFocus()
await user.tab()
expect(actionButton).toHaveFocus()
installButton.focus()
await user.keyboard('{Enter}')
expect(screen.getByTestId('install-from-marketplace')).toHaveTextContent('plugin-1@2.0.0')
@ -84,6 +106,58 @@ describe('marketplace plugin selector components', () => {
expect(screen.queryByTestId('install-from-marketplace')).not.toBeInTheDocument()
})
it('should expose separate keyboard actions for scrolling to and searching marketplace results', async () => {
const user = userEvent.setup()
const scrollIntoView = vi.fn()
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView
HTMLElement.prototype.scrollIntoView = scrollIntoView
try {
render(
<List
wrapElemRef={{ current: document.createElement('div') }}
list={[createPlugin()]}
searchText="filtered"
tags={[]}
category={PluginCategoryEnum.tool}
/>,
)
const scrollButton = screen.getByRole('button', { name: 'plugin.fromMarketplace' })
const searchLink = screen.getByRole('link', { name: 'plugin.searchInMarketplace' })
scrollButton.focus()
await user.keyboard('{Enter}')
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'start' })
expect(searchLink).toHaveAttribute('href', expect.stringContaining('q=filtered'))
} finally {
HTMLElement.prototype.scrollIntoView = originalScrollIntoView
}
})
it('should expose marketplace navigation as links when the results are already visible', () => {
vi.mocked(useStickyScroll).mockReturnValue({
handleScroll: vi.fn(),
scrollPosition: ScrollPosition.showing,
})
render(
<List
wrapElemRef={{ current: document.createElement('div') }}
list={[createPlugin()]}
searchText="filtered"
tags={['rag']}
category={PluginCategoryEnum.tool}
/>,
)
const marketplaceLink = screen.getByRole('link', { name: 'plugin.fromMarketplace' })
expect(marketplaceLink).toHaveAttribute('href', expect.stringContaining('q=filtered'))
expect(marketplaceLink).toHaveAttribute('href', expect.stringContaining('tags=rag'))
expect(screen.queryByRole('button', { name: 'plugin.fromMarketplace' })).not.toBeInTheDocument()
})
it('should render find-more footer for empty filters and marketplace results for filtered searches', () => {
const wrapElemRef = { current: document.createElement('div') }
const plugin = createPlugin({
@ -100,10 +174,9 @@ describe('marketplace plugin selector components', () => {
/>,
)
expect(screen.getByRole('link', { name: /plugin\.findMoreInMarketplace/i })).toHaveAttribute(
'href',
'https://marketplace.test/plugins/tool',
)
const findMoreLink = screen.getByRole('link', { name: /plugin\.findMoreInMarketplace/i })
expect(findMoreLink.closest('footer')).toBeInTheDocument()
expect(findMoreLink).toHaveAttribute('href', 'https://marketplace.test/plugins/tool')
rerender(
<List

View File

@ -1,5 +1,5 @@
'use client'
import type { FC } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import {
DropdownMenu,
DropdownMenuContent,
@ -9,9 +9,8 @@ import {
} from '@langgenius/dify-ui/dropdown-menu'
import { useMutation } from '@tanstack/react-query'
import { useTheme } from 'next-themes'
import * as React from 'react'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import { marketplaceQuery } from '@/service/client'
import { downloadBlob } from '@/utils/download'
import { getMarketplaceUrl } from '@/utils/var'
@ -24,7 +23,7 @@ type Props = Readonly<{
version: string
}>
const OperationDropdown: FC<Props> = ({ open, onOpenChange, author, name, version }) => {
function OperationDropdown({ open, onOpenChange, author, name, version }: Props) {
const { t } = useTranslation()
const { theme } = useTheme()
@ -53,15 +52,17 @@ const OperationDropdown: FC<Props> = ({ open, onOpenChange, author, name, versio
<DropdownMenu open={open} onOpenChange={onOpenChange}>
<DropdownMenuTrigger
render={
<ActionButton
className="focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-state-base-hover"
<Button
variant="ghost"
size="small"
className="size-6 min-h-0 rounded-lg p-0 focus-visible:ring-inset data-popup-open:bg-state-base-hover"
aria-label={t(($) => $['operation.more'], { ns: 'common' })}
>
<span
aria-hidden
className="i-ri-more-fill size-4 text-components-button-secondary-accent-text"
/>
</ActionButton>
</Button>
}
/>
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="min-w-[176px]">
@ -80,4 +81,5 @@ const OperationDropdown: FC<Props> = ({ open, onOpenChange, author, name, versio
</DropdownMenu>
)
}
export default React.memo(OperationDropdown)
export default memo(OperationDropdown)

View File

@ -1,9 +1,9 @@
'use client'
import type { FC } from 'react'
import type { Plugin } from '@/app/components/plugins/types.ts'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { memo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { PluginInstallPermissionProvider } from '@/app/components/plugins/install-plugin/components/plugin-install-permission-provider'
import useWorkspacePluginInstallPermission from '@/app/components/plugins/install-plugin/hooks/use-workspace-plugin-install-permission'
@ -12,16 +12,13 @@ import { useLocale } from '@/context/i18n'
import { formatNumber } from '@/utils/format'
import Action from './action'
type ActionType = 'install' | 'download'
type Props = Readonly<{
payload: Plugin
onAction: (type: ActionType) => void
}>
const Item: FC<Props> = ({ payload }) => {
function Item({ payload }: Props) {
const { t } = useTranslation()
const [open, setOpen] = React.useState(false)
const [open, setOpen] = useState(false)
const locale = useLocale()
const getLocalizedText = (obj: Record<string, string> | undefined) =>
obj?.[locale] || obj?.['en-US'] || obj?.en_US || ''
@ -51,21 +48,23 @@ const Item: FC<Props> = ({ payload }) => {
</div>
</div>
</div>
{/* Action */}
<div
className={cn(
!open ? 'hidden' : 'flex',
'h-4 items-center space-x-1 system-xs-medium text-components-button-secondary-accent-text group-hover/plugin:flex',
'flex h-4 items-center space-x-1 system-xs-medium text-components-button-secondary-accent-text opacity-0',
open
? 'pointer-events-auto opacity-100'
: 'pointer-events-none group-focus-within/plugin:pointer-events-auto group-focus-within/plugin:opacity-100 group-hover/plugin:pointer-events-auto group-hover/plugin:opacity-100 [@media(hover:none)]:pointer-events-auto [@media(hover:none)]:opacity-100',
)}
>
{canInstallPlugin && (
<button
type="button"
className="cursor-pointer rounded-md border-0 bg-transparent px-1.5 py-0.5 hover:bg-state-base-hover"
<Button
variant="ghost"
size="small"
className="h-6 px-1.5 text-components-button-secondary-accent-text focus-visible:ring-inset"
onClick={showInstallModal}
>
{t(($) => $.installAction, { ns: 'plugin' })}
</button>
</Button>
)}
<Action
open={open}
@ -92,4 +91,5 @@ const Item: FC<Props> = ({ payload }) => {
</div>
)
}
export default React.memo(Item)
export default memo(Item)

View File

@ -2,8 +2,6 @@
import type { RefObject } from 'react'
import type { Plugin, PluginCategoryEnum } from '@/app/components/plugins/types'
import { cn } from '@langgenius/dify-ui/cn'
import { RiArrowRightUpLine } from '@remixicon/react'
import { noop } from 'es-toolkit/function'
import { useEffect, useImperativeHandle, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { getMarketplaceCategoryUrl } from '@/app/components/plugins/marketplace/utils'
@ -25,7 +23,7 @@ export type ListProps = {
export type ListRef = { handleScroll: () => void }
const List = ({
function List({
wrapElemRef,
searchText,
tags,
@ -35,7 +33,7 @@ const List = ({
disableMaxWidth = false,
hideFindMoreFooter = false,
ref,
}: ListProps) => {
}: ListProps) {
const { t } = useTranslation()
const noFilter = !searchText && tags.length === 0
const hasRes = list.length > 0
@ -52,11 +50,11 @@ const List = ({
const stickyClassName = useMemo(() => {
switch (scrollPosition) {
case ScrollPosition.aboveTheWrap:
return 'top-0 h-9 pt-3 pb-2 shadow-xs bg-components-panel-bg-blur cursor-pointer'
return 'top-0 h-9 pt-3 pb-2 shadow-xs bg-components-panel-bg-blur'
case ScrollPosition.showing:
return 'bottom-0 pt-3 pb-1'
case ScrollPosition.belowTheWrap:
return 'bottom-0 items-center rounded-b-xl border-t border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg rounded-b-lg cursor-pointer'
return 'bottom-0 items-center border-t border-divider-subtle bg-components-panel-bg-blur'
}
}, [scrollPosition])
@ -66,29 +64,27 @@ const List = ({
useEffect(() => {
handleScroll()
}, [list])
}, [handleScroll, list])
const handleHeadClick = () => {
if (scrollPosition === ScrollPosition.belowTheWrap) {
nextToStickyELemRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
return
}
window.open(urlWithSearchText, '_blank')
const handleScrollToResults = () => {
nextToStickyELemRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
if (noFilter) {
if (hideFindMoreFooter) return null
return (
<Link
className="sticky bottom-0 z-10 flex h-8 cursor-pointer items-center rounded-b-lg border-[0.5px] border-t border-components-panel-border bg-components-panel-bg-blur px-4 py-1 system-sm-medium text-text-accent-light-mode-only shadow-lg"
href={getMarketplaceCategoryUrl(category)}
target="_blank"
rel="noopener noreferrer"
>
<span>{t(($) => $.findMoreInMarketplace, { ns: 'plugin' })}</span>
<RiArrowRightUpLine className="ml-0.5 size-3" />
</Link>
<footer className="sticky bottom-0 z-10 flex h-8 items-center border-t border-divider-subtle bg-components-panel-bg-blur px-4 py-1 system-sm-medium">
<Link
className="inline-flex items-center rounded-md text-text-accent-light-mode-only focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
href={getMarketplaceCategoryUrl(category)}
target="_blank"
rel="noopener noreferrer"
>
<span>{t(($) => $.findMoreInMarketplace, { ns: 'plugin' })}</span>
<span aria-hidden className="ml-0.5 i-ri-arrow-right-up-line size-3" />
</Link>
</footer>
)
}
@ -99,34 +95,47 @@ const List = ({
{hasRes && (
<div
className={cn(
'sticky z-10 flex h-8 cursor-pointer justify-between px-4 py-1 system-sm-medium text-text-primary',
'sticky z-10 flex h-8 justify-between px-4 py-1 system-sm-medium text-text-primary',
stickyClassName,
!disableMaxWidth && maxWidthClassName,
)}
onClick={handleHeadClick}
>
<span>{t(($) => $.fromMarketplace, { ns: 'plugin' })}</span>
{scrollPosition === ScrollPosition.belowTheWrap ? (
<button
type="button"
className="flex min-w-0 flex-1 cursor-pointer items-center rounded-md border-0 bg-transparent p-0 text-left focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid focus-visible:outline-hidden"
onClick={handleScrollToResults}
>
{t(($) => $.fromMarketplace, { ns: 'plugin' })}
</button>
) : (
<Link
href={urlWithSearchText}
target="_blank"
rel="noopener noreferrer"
className="flex min-w-0 flex-1 items-center rounded-md focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid focus-visible:outline-hidden"
>
{t(($) => $.fromMarketplace, { ns: 'plugin' })}
</Link>
)}
<Link
href={urlWithSearchText}
target="_blank"
rel="noopener noreferrer"
className="flex items-center text-text-accent-light-mode-only"
onClick={(e) => e.stopPropagation()}
className="ml-2 flex shrink-0 items-center rounded-md text-text-accent-light-mode-only focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid focus-visible:outline-hidden"
>
<span>{t(($) => $.searchInMarketplace, { ns: 'plugin' })}</span>
<RiArrowRightUpLine className="ml-0.5 size-3" />
<span aria-hidden className="ml-0.5 i-ri-arrow-right-up-line size-3" />
</Link>
</div>
)}
<div className={cn('p-1', !disableMaxWidth && maxWidthClassName)} ref={nextToStickyELemRef}>
{list.map((item, index) => (
<Item key={index} payload={item} onAction={noop} />
{list.map((item) => (
<Item key={item.plugin_id} payload={item} />
))}
</div>
</>
)
}
List.displayName = 'List'
export default List

View File

@ -6,7 +6,7 @@ import useTheme from '@/hooks/use-theme'
import { Theme } from '@/types/app'
import { BlockEnum } from '../../../types'
import { createPlugin, createToolProvider } from '../../__tests__/factories'
import { ViewType } from '../../view-type-select'
import { ViewType } from '../../types'
import List from '../list'
import UninstalledItem from '../uninstalled-item'
@ -100,7 +100,11 @@ describe('RAG tool recommendations list', () => {
expect(screen.getByText('RAG Plugin')).toBeInTheDocument()
await user.click(screen.getByText('plugin.installAction'))
const installButton = screen.getByRole('button', { name: 'plugin.installAction' })
await user.tab()
expect(installButton).toHaveFocus()
await user.keyboard('{Enter}')
expect(screen.getByTestId('install-from-marketplace')).toHaveTextContent('rag-plugin@1.0.0')
})

View File

@ -1,6 +1,7 @@
'use client'
import type { ViewType } from '@/app/components/workflow/block-selector/view-type-select'
import type { ViewType } from '@/app/components/workflow/block-selector/types'
import type { OnSelectBlock } from '@/app/components/workflow/types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { useMemo } from 'react'
import { Trans, useTranslation } from 'react-i18next'
@ -47,7 +48,7 @@ export function RAGToolRecommendations({
<div className="flex flex-col p-1">
<button
type="button"
className="flex w-full items-center rounded-md px-3 pt-1 pb-0.5 text-left text-text-tertiary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
className="flex w-full items-center rounded-md px-3 pt-1 pb-0.5 text-left text-text-tertiary focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid focus-visible:outline-hidden"
aria-expanded={!isCollapsed}
onClick={() => setIsCollapsed((prev) => !prev)}
>
@ -98,9 +99,10 @@ export function RAGToolRecommendations({
onSelect={onSelect}
viewType={viewType}
/>
<button
type="button"
className="flex w-full items-center gap-x-2 rounded-md py-1 pr-2 pl-3 text-left focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
<Button
variant="ghost"
size="medium"
className="w-full justify-start gap-x-2 pr-2 pl-3 text-left focus-visible:ring-inset"
onClick={onLoadMore}
>
<div className="px-1">
@ -112,7 +114,7 @@ export function RAGToolRecommendations({
<div className="system-xs-regular text-text-tertiary">
{t(($) => $['operation.more'], { ns: 'common' })}
</div>
</button>
</Button>
</>
)}
</>

View File

@ -7,11 +7,11 @@ 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 { createToolListData } from '../tool-list-data'
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'
import { ToolListTreeView } from '../tool/tool-list-tree-view/list'
import { ViewType } from '../types'
import UninstalledItem from './uninstalled-item'
type ListProps = {
@ -27,38 +27,10 @@ const List = ({ onSelect, tools, viewType, unInstalledPlugins, className }: List
const previewCardHandle = useMemo(() => createPreviewCardHandle<ToolActionPreviewPayload>(), [])
const isFlatView = viewType === ViewType.flat
const { letters, groups: withLetterAndGroupViewToolsData } = groupItems(
tools,
(tool) => tool.label[language]![0]!,
const { letters, flatTools, treeGroups } = useMemo(
() => createToolListData(tools, (tool) => tool.label[language]![0]!),
[language, tools],
)
const treeViewToolsData = useMemo(() => {
const result: Record<string, ToolWithProvider[]> = {}
Object.keys(withLetterAndGroupViewToolsData).forEach((letter) => {
Object.keys(withLetterAndGroupViewToolsData[letter]!).forEach((groupName) => {
if (!result[groupName]) result[groupName] = []
result[groupName].push(...(withLetterAndGroupViewToolsData[letter]![groupName] ?? []))
})
})
return result
}, [withLetterAndGroupViewToolsData])
const listViewToolData = useMemo(() => {
const result: ToolWithProvider[] = []
letters.forEach((letter) => {
Object.keys(withLetterAndGroupViewToolsData[letter]!).forEach((groupName) => {
result.push(
...withLetterAndGroupViewToolsData[letter]![groupName]!.map((item) => {
return {
...item,
letter,
}
}),
)
})
})
return result
}, [withLetterAndGroupViewToolsData, letters])
const toolRefsRef = useRef<Record<string, HTMLDivElement | null>>({})
@ -76,7 +48,7 @@ const List = ({ onSelect, tools, viewType, unInstalledPlugins, className }: List
<ToolListFlatView
toolRefs={toolRefsRef}
letters={letters}
payload={listViewToolData}
payload={flatTools}
previewCardHandle={previewCardHandle}
isShowLetterIndex={false}
hasSearchText={false}
@ -86,7 +58,7 @@ const List = ({ onSelect, tools, viewType, unInstalledPlugins, className }: List
/>
) : (
<ToolListTreeView
payload={treeViewToolsData}
payload={treeGroups}
previewCardHandle={previewCardHandle}
hasSearchText={false}
onSelect={handleSelect}

View File

@ -1,5 +1,6 @@
'use client'
import type { Plugin } from '@/app/components/plugins/types'
import { Button } from '@langgenius/dify-ui/button'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
@ -14,7 +15,7 @@ type UninstalledItemProps = {
payload: Plugin
}
const UninstalledItem = ({ payload }: UninstalledItemProps) => {
function UninstalledItem({ payload }: UninstalledItemProps) {
const { t } = useTranslation()
const locale = useLocale()
@ -35,12 +36,14 @@ const UninstalledItem = ({ payload }: UninstalledItemProps) => {
<span className="system-xs-regular text-text-quaternary">{payload.org}</span>
</div>
{canInstallPlugin && (
<div
className="cursor-pointer pl-1.5 system-xs-medium text-components-button-secondary-accent-text"
<Button
variant="ghost"
size="small"
className="h-6 px-1.5 text-components-button-secondary-accent-text focus-visible:ring-inset"
onClick={showInstallModal}
>
{t(($) => $.installAction, { ns: 'plugin' })}
</div>
</Button>
)}
{isShowInstallModal && canInstallPlugin && (
<PluginInstallPermissionProvider

View File

@ -1,4 +1,6 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import Snippets from '../index'
const mockUseInfiniteSnippetList = vi.fn()
@ -22,6 +24,10 @@ vi.mock('../use-insert-snippet', () => ({
}),
}))
vi.mock('../snippet-detail-card', () => ({
default: ({ snippet }: { snippet: { name: string } }) => <div>{`preview:${snippet.name}`}</div>,
}))
vi.mock('../snippet-tags-filter', () => ({
default: ({ value, onChange }: { value: string[]; onChange: (value: string[]) => void }) => (
<button type="button" onClick={() => onChange(['tag-1'])}>
@ -54,9 +60,9 @@ describe('Snippets', () => {
hasNextPage: undefined,
})
const { container } = render(<Snippets searchText="" />)
render(<Snippets searchText="" />)
expect(container.querySelectorAll('.bg-text-quaternary')).not.toHaveLength(0)
expect(screen.getByRole('status', { name: 'common.loading' })).toBeInTheDocument()
})
it('should render empty state when snippet list is empty', () => {
@ -114,9 +120,10 @@ describe('Snippets', () => {
describe('User Interactions', () => {
it('should filter snippets by selected snippet tags', async () => {
const user = userEvent.setup()
render(<Snippets searchText="" />)
fireEvent.click(screen.getByRole('button', { name: 'tag-filter:' }))
await user.click(screen.getByRole('button', { name: 'tag-filter:' }))
await waitFor(() => {
expect(mockUseInfiniteSnippetList).toHaveBeenLastCalledWith({
@ -128,19 +135,45 @@ describe('Snippets', () => {
})
})
it('should delegate embedded search changes', () => {
it('should delegate embedded search changes', async () => {
const user = userEvent.setup()
const onSearchTextChange = vi.fn()
render(<Snippets searchText="" onSearchTextChange={onSearchTextChange} />)
function Harness() {
const [searchText, setSearchText] = useState('')
fireEvent.change(screen.getByPlaceholderText('workflow.tabs.searchSnippets'), {
target: { value: 'review' },
return (
<Snippets
searchText={searchText}
onSearchTextChange={(value) => {
setSearchText(value)
onSearchTextChange(value)
}}
/>
)
}
render(<Harness />)
await user.type(
screen.getByRole('searchbox', { name: 'workflow.tabs.searchSnippets' }),
'review',
)
expect(onSearchTextChange).toHaveBeenLastCalledWith('review')
await user.tab()
const clearButton = screen.getByRole('button', {
name: 'workflow.tabs.clearSnippetSearch',
})
expect(clearButton).toHaveFocus()
await user.keyboard('{Enter}')
expect(onSearchTextChange).toHaveBeenCalledWith('review')
expect(onSearchTextChange).toHaveBeenLastCalledWith('')
})
it('should delegate insert action when snippet item is clicked', () => {
it('should expose snippet rows as buttons and delegate insertion', async () => {
const user = userEvent.setup()
mockUseInfiniteSnippetList.mockReturnValue({
data: {
pages: [
@ -174,9 +207,53 @@ describe('Snippets', () => {
render(<Snippets searchText="" />)
fireEvent.click(screen.getByText('Customer Review'))
const snippet = screen.getByRole('button', { name: /Customer Review/ })
await user.click(snippet)
expect(mockHandleInsertSnippet).toHaveBeenCalledWith('snippet-1', undefined)
})
it('should show a preview from the keyboard when a snippet has no description', async () => {
const user = userEvent.setup()
mockUseInfiniteSnippetList.mockReturnValue({
data: {
pages: [
{
data: [
{
id: 'snippet-1',
name: 'No Description',
description: '',
type: 'group',
is_published: true,
version: '1.0.0',
use_count: 3,
tags: [],
input_fields: [],
created_at: 1,
created_by: 'user-1',
updated_at: 2,
updated_by: 'user-1',
},
],
},
],
},
isLoading: false,
isFetching: false,
isFetchingNextPage: false,
fetchNextPage: vi.fn(),
hasNextPage: false,
})
render(<Snippets searchText="" />)
await user.tab()
await user.tab()
await user.tab()
expect(screen.getByRole('button', { name: 'No Description' })).toHaveFocus()
expect(await screen.findByText('preview:No Description')).toBeInTheDocument()
})
})
})

View File

@ -1,4 +1,4 @@
import type { PublishedSnippetListItem } from '../snippet-detail-card'
import type { SnippetListItem } from '@/types/snippet'
import { render, screen } from '@testing-library/react'
import SnippetDetailCard from '../snippet-detail-card'
@ -8,29 +8,7 @@ vi.mock('@/service/use-snippet-workflows', () => ({
useSnippetPublishedWorkflow: (...args: unknown[]) => mockUseSnippetPublishedWorkflow(...args),
}))
vi.mock('@/service/use-common', () => ({
useMembers: () => ({
data: {
accounts: [
{
id: 'user-1',
name: 'Evan',
email: 'evan@example.com',
avatar: '',
avatar_url: null,
role: 'editor',
last_login_at: '',
created_at: '',
status: 'active',
},
],
},
}),
}))
const createSnippet = (
overrides: Partial<PublishedSnippetListItem> = {},
): PublishedSnippetListItem => ({
const createSnippet = (overrides: Partial<SnippetListItem> = {}): SnippetListItem => ({
id: 'snippet-1',
name: 'Customer Review',
description: 'Snippet description',
@ -40,6 +18,7 @@ const createSnippet = (
tags: [],
created_at: 1,
created_by: 'user-1',
author_name: 'Evan',
updated_at: 2,
updated_by: 'user-1',
...overrides,
@ -61,6 +40,12 @@ describe('SnippetDetailCard', () => {
expect(screen.getByText('Evan')).toBeInTheDocument()
})
it('should render the unknown user label when the author name is unavailable', () => {
render(<SnippetDetailCard snippet={createSnippet({ author_name: null })} />)
expect(screen.getByText('snippet.unknownUser')).toBeInTheDocument()
})
it('should render unique block icons from published workflow graph', () => {
mockUseSnippetPublishedWorkflow.mockReturnValue({
data: {

View File

@ -1,10 +1,8 @@
import type { PublishedSnippetListItem } from '../snippet-detail-card'
import type { SnippetListItem as SnippetListItemData } from '@/types/snippet'
import { fireEvent, render, screen } from '@testing-library/react'
import SnippetListItem from '../snippet-list-item'
const createSnippet = (
overrides: Partial<PublishedSnippetListItem> = {},
): PublishedSnippetListItem => ({
const createSnippet = (overrides: Partial<SnippetListItemData> = {}): SnippetListItemData => ({
id: 'snippet-1',
name: 'Customer Review',
description: 'Snippet description',
@ -28,12 +26,7 @@ describe('SnippetListItem', () => {
describe('Rendering', () => {
it('should render snippet title and description', () => {
render(
<SnippetListItem
snippet={createSnippet()}
isHovered={false}
onMouseEnter={vi.fn()}
onMouseLeave={vi.fn()}
/>,
<SnippetListItem snippet={createSnippet()} onMouseEnter={vi.fn()} onMouseLeave={vi.fn()} />,
)
expect(screen.getByText('Customer Review')).toBeInTheDocument()
@ -46,7 +39,6 @@ describe('SnippetListItem', () => {
snippet={createSnippet({
tags: [{ id: 'tag-1', name: 'Search', type: 'snippet', binding_count: '' }],
})}
isHovered={false}
onMouseEnter={vi.fn()}
onMouseLeave={vi.fn()}
/>,
@ -67,14 +59,13 @@ describe('SnippetListItem', () => {
render(
<SnippetListItem
snippet={createSnippet()}
isHovered={false}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
/>,
)
const item = screen.getByText('Customer Review').closest('div')!
const item = screen.getByRole('button', { name: /Customer Review/ })
fireEvent.mouseEnter(item)
fireEvent.mouseLeave(item)

View File

@ -50,15 +50,36 @@ describe('SnippetTagsFilter', () => {
render(<SnippetTagsFilter value={[]} onChange={vi.fn()} />)
await user.click(screen.getByRole('button', { name: 'common.tag.placeholder' }))
await user.type(screen.getByPlaceholderText('pluginTags.searchTags'), 'sup')
const trigger = screen.getByRole('button', { name: 'common.tag.placeholder' })
trigger.focus()
await user.keyboard('{Enter}')
const search = screen.getByRole('textbox', { name: 'pluginTags.searchTags' })
await user.type(search, 'sup')
expect(screen.getByText('Support')).toBeInTheDocument()
expect(screen.queryByText('Sales')).not.toBeInTheDocument()
await user.clear(screen.getByPlaceholderText('pluginTags.searchTags'))
await user.type(screen.getByPlaceholderText('pluginTags.searchTags'), 'missing')
await user.clear(search)
await user.type(search, 'missing')
expect(screen.getByText('common.tag.noTag')).toBeInTheDocument()
})
it('resets the transient tag search after the popover closes', async () => {
const user = userEvent.setup()
render(<SnippetTagsFilter value={[]} onChange={vi.fn()} />)
const trigger = screen.getByRole('button', { name: 'common.tag.placeholder' })
await user.click(trigger)
expect(screen.getByRole('dialog', { name: 'common.tag.placeholder' })).toBeInTheDocument()
await user.type(screen.getByRole('textbox', { name: 'pluginTags.searchTags' }), 'sup')
expect(screen.queryByText('Sales')).not.toBeInTheDocument()
await user.keyboard('{Escape}')
await user.click(trigger)
expect(screen.getByText('Sales')).toBeInTheDocument()
expect(screen.getByText('Support')).toBeInTheDocument()
})
})

View File

@ -1,5 +1,12 @@
import type { OnNodeAdd } from '../../types'
import type { SnippetListItem as SnippetListItemData } from '@/types/snippet'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
createPreviewCardHandle,
PreviewCard,
PreviewCardTrigger,
} from '@langgenius/dify-ui/preview-card'
import {
ScrollAreaContent,
ScrollAreaRoot,
@ -7,12 +14,12 @@ import {
ScrollAreaThumb,
ScrollAreaViewport,
} from '@langgenius/dify-ui/scroll-area'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useInfiniteScroll } from 'ahooks'
import { memo, useCallback, useDeferredValue, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import { useInfiniteSnippetList } from '@/service/use-snippets'
import { BlockSelectorPreviewCardContent } from '../preview-card'
import SnippetDetailCard from './snippet-detail-card'
import SnippetEmptyState from './snippet-empty-state'
import SnippetListItem from './snippet-list-item'
@ -27,8 +34,14 @@ type SnippetsProps = {
}
const LoadingSkeleton = () => {
const { t } = useTranslation()
return (
<div className="relative overflow-hidden">
<div
role="status"
aria-label={t(($) => $.loading, { ns: 'common' })}
className="relative overflow-hidden"
>
<div className="p-1">
{['skeleton-1', 'skeleton-2', 'skeleton-3', 'skeleton-4'].map((key, index) => (
<div
@ -54,8 +67,8 @@ const Snippets = ({ searchText, onSearchTextChange, insertPayload, onInserted }:
const { handleInsertSnippet } = useInsertSnippet()
const deferredSearchText = useDeferredValue(searchText)
const viewportRef = useRef<HTMLDivElement>(null)
const [hoveredSnippetId, setHoveredSnippetId] = useState<string | null>(null)
const [tagIds, setTagIds] = useState<string[]>([])
const previewCardHandle = useMemo(() => createPreviewCardHandle<SnippetListItemData>(), [])
const keyword = deferredSearchText.trim() || undefined
@ -98,7 +111,7 @@ const Snippets = ({ searchText, onSearchTextChange, insertPayload, onInserted }:
const filter = (
<div className="p-2">
<div className="flex items-center rounded-lg border border-transparent bg-components-input-bg-normal focus-within:border-components-input-border-active hover:border-components-input-border-hover">
<div className="flex min-w-0 grow items-center py-1.75 pr-3 pl-2">
<div className="flex h-8 min-w-0 grow items-center pr-2 pl-2">
<span
className="i-ri-search-line size-4 shrink-0 text-components-input-text-placeholder"
aria-hidden="true"
@ -118,14 +131,15 @@ const Snippets = ({ searchText, onSearchTextChange, insertPayload, onInserted }:
onChange={(event) => onSearchTextChange?.(event.target.value)}
/>
{!!searchText && (
<button
type="button"
aria-label={t(($) => $['operation.clear'], { ns: 'common' })}
className="group shrink-0 cursor-pointer rounded-md p-1 outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
<Button
variant="ghost"
size="small"
aria-label={t(($) => $['tabs.clearSnippetSearch'], { ns: 'workflow' })}
className="size-6 min-h-0 shrink-0 p-0 focus-visible:ring-inset"
onClick={() => onSearchTextChange?.('')}
>
<span className="i-ri-close-line size-4 text-text-tertiary" aria-hidden="true" />
</button>
</Button>
)}
</div>
<div className="mx-0 mr-0.5 h-3.5 w-px bg-divider-regular" />
@ -145,26 +159,18 @@ const Snippets = ({ searchText, onSearchTextChange, insertPayload, onInserted }:
<ScrollAreaContent className="p-1">
{snippets.map((item) => {
const row = (
<SnippetListItem
snippet={item}
isHovered={hoveredSnippetId === item.id}
onClick={() => handleSnippetClick(item.id)}
onMouseEnter={() => setHoveredSnippetId(item.id)}
onMouseLeave={() =>
setHoveredSnippetId((current) => (current === item.id ? null : current))
}
/>
<SnippetListItem snippet={item} onClick={() => handleSnippetClick(item.id)} />
)
if (!item.description) return <div key={item.id}>{row}</div>
return (
<Tooltip key={item.id}>
<TooltipTrigger delay={0} render={row} />
<TooltipContent placement="right-start" className="bg-transparent! p-0!">
<SnippetDetailCard snippet={item} />
</TooltipContent>
</Tooltip>
<PreviewCardTrigger
key={item.id}
delay={150}
closeDelay={150}
handle={previewCardHandle}
payload={item}
render={row}
/>
)
})}
{isFetchingNextPage && (
@ -184,6 +190,17 @@ const Snippets = ({ searchText, onSearchTextChange, insertPayload, onInserted }:
<>
{filter}
<div className="border-t border-divider-subtle">{content}</div>
<PreviewCard handle={previewCardHandle}>
{({ payload }) => {
if (!payload) return null
return (
<BlockSelectorPreviewCardContent>
<SnippetDetailCard snippet={payload as SnippetListItemData} />
</BlockSelectorPreviewCardContent>
)
}}
</PreviewCard>
</>
)
}

View File

@ -1,28 +1,19 @@
import type { FC } from 'react'
import type { SnippetListItem } from '@/types/snippet'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useMembers } from '@/service/use-common'
import { useSnippetPublishedWorkflow } from '@/service/use-snippet-workflows'
import BlockIcon from '../../block-icon'
import { BlockEnum } from '../../types'
export type PublishedSnippetListItem = SnippetListItem
type SnippetDetailCardProps = {
snippet: PublishedSnippetListItem
snippet: SnippetListItem
}
const SnippetDetailCard: FC<SnippetDetailCardProps> = ({ snippet }) => {
const { description, name } = snippet
function SnippetDetailCard({ snippet }: SnippetDetailCardProps) {
const { author_name, description, name } = snippet
const { t } = useTranslation('snippet')
const { data: membersData } = useMembers()
const { data: workflow } = useSnippetPublishedWorkflow(snippet.id)
const creatorName = useMemo(() => {
const member = membersData?.accounts?.find((member) => member.id === snippet.created_by)
return member?.name || t(($) => $.unknownUser)
}, [membersData?.accounts, snippet.created_by, t])
const creatorName = author_name || t(($) => $.unknownUser)
const blockTypes = useMemo(() => {
const graph = workflow?.graph
@ -52,7 +43,7 @@ const SnippetDetailCard: FC<SnippetDetailCardProps> = ({ snippet }) => {
}, [workflow?.graph])
return (
<div className="w-56 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur px-3 pt-3 pb-4 shadow-lg backdrop-blur-[5px]">
<div>
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-2">
<div className="system-md-medium text-text-primary">{name}</div>

View File

@ -1,38 +1,29 @@
import type { ComponentPropsWithoutRef, Ref } from 'react'
import type { PublishedSnippetListItem } from './snippet-detail-card'
import type { SnippetListItem as SnippetListItemData } from '@/types/snippet'
import { cn } from '@langgenius/dify-ui/cn'
type SnippetListItemProps = {
isHovered: boolean
ref?: Ref<HTMLDivElement>
snippet: PublishedSnippetListItem
} & ComponentPropsWithoutRef<'div'>
ref?: Ref<HTMLButtonElement>
snippet: SnippetListItemData
} & ComponentPropsWithoutRef<'button'>
const SnippetListItem = ({
isHovered,
snippet,
className,
ref,
...props
}: SnippetListItemProps) => {
return (
<div
ref={ref}
className={cn(
'flex cursor-pointer flex-col gap-1 rounded-xl px-3 py-2',
isHovered && 'bg-background-default-hover',
className,
)}
{...props}
>
<div className="w-full truncate system-md-semibold text-text-secondary">{snippet.name}</div>
{!!snippet.description && (
<div className="line-clamp-1 w-full system-sm-regular text-text-tertiary">
{snippet.description}
</div>
)}
</div>
)
}
const SnippetListItem = ({ snippet, className, ref, ...props }: SnippetListItemProps) => (
<button
type="button"
ref={ref}
className={cn(
'flex w-full cursor-pointer flex-col gap-1 rounded-xl border-0 bg-transparent px-3 py-2 text-left outline-hidden hover:bg-background-default-hover focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid data-popup-open:bg-background-default-hover',
className,
)}
{...props}
>
<div className="w-full truncate system-md-semibold text-text-secondary">{snippet.name}</div>
{!!snippet.description && (
<div className="line-clamp-1 w-full system-sm-regular text-text-tertiary">
{snippet.description}
</div>
)}
</button>
)
export default SnippetListItem

View File

@ -1,8 +1,9 @@
import { Button } from '@langgenius/dify-ui/button'
import { Checkbox } from '@langgenius/dify-ui/checkbox'
import { CheckboxGroup } from '@langgenius/dify-ui/checkbox-group'
import { cn } from '@langgenius/dify-ui/cn'
import { Input } from '@langgenius/dify-ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { useQuery } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -44,20 +45,25 @@ const SnippetTagsFilter = ({ embedded = false, value, onChange }: SnippetTagsFil
const triggerLabel = selectedTags.length
? selectedTags.map((tag) => tag.name).join(', ')
: t(($) => $['tag.placeholder'], { ns: 'common' })
const handleOpenChange = (nextOpen: boolean) => {
setOpen(nextOpen)
if (!nextOpen) setSearchText('')
}
return (
<Popover open={open} onOpenChange={setOpen}>
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger
render={
<button
type="button"
<Button
variant="ghost"
size="small"
aria-label={triggerLabel}
className={cn(
'relative flex cursor-pointer items-center justify-center text-text-tertiary select-none',
'relative text-text-tertiary select-none focus-visible:ring-inset',
embedded
? 'h-7 rounded-md p-0.5'
? 'mr-1 h-7 rounded-md'
: 'h-8 min-w-8 rounded-lg border-[0.5px] border-components-panel-border bg-components-input-bg-normal px-2',
embedded && !value.length && 'py-1 pr-2 pl-1.5',
embedded && !value.length && 'size-7 min-h-0 p-0',
embedded &&
value.length > 0 &&
'border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg py-0.5 pr-1.5 pl-1 shadow-xs shadow-shadow-shadow-3',
@ -76,7 +82,7 @@ const SnippetTagsFilter = ({ embedded = false, value, onChange }: SnippetTagsFil
{value.length > 0 && (
<span className="ml-1 system-xs-medium text-text-secondary">{value.length}</span>
)}
</button>
</Button>
}
/>
<PopoverContent
@ -84,6 +90,7 @@ const SnippetTagsFilter = ({ embedded = false, value, onChange }: SnippetTagsFil
sideOffset={6}
popupClassName="w-[240px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-0 shadow-lg backdrop-blur-xs"
>
<PopoverTitle className="sr-only">{triggerLabel}</PopoverTitle>
<div className="p-2 pb-1">
<div className="relative">
<span
@ -91,6 +98,8 @@ const SnippetTagsFilter = ({ embedded = false, value, onChange }: SnippetTagsFil
aria-hidden="true"
/>
<Input
aria-label={t(($) => $.searchTags, { ns: 'pluginTags' }) || ''}
autoComplete="off"
className="pl-6.5"
value={searchText}
onChange={(event) => setSearchText(event.target.value)}

View File

@ -3,7 +3,6 @@ import type { BlockEnum, NodeDefault, OnNodeAdd, OnSelectBlock, ToolWithProvider
import { cn } from '@langgenius/dify-ui/cn'
import { Tabs, TabsIndicator, TabsList, TabsPanel, TabsTab } from '@langgenius/dify-ui/tabs'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useDebounce } from 'ahooks'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SearchInput } from '@/app/components/base/search-input'
@ -22,7 +21,7 @@ type TabConfig = {
disabledTip?: ReactNode
}
type SelectorContentProps = {
type BlockSelectorPanelsProps = {
defaultTab: TabType
standalonePanel?: TabType
tabs: TabConfig[]
@ -38,6 +37,22 @@ type SelectorContentProps = {
snippetInsertPayload?: Parameters<OnNodeAdd>[1]
}
type TabFilterState = Record<
TabType,
{
searchText: string
tags: string[]
}
>
const createTabFilterState = (): TabFilterState => ({
[TabType.Blocks]: { searchText: '', tags: [] },
[TabType.Tools]: { searchText: '', tags: [] },
[TabType.Sources]: { searchText: '', tags: [] },
[TabType.Start]: { searchText: '', tags: [] },
[TabType.Snippets]: { searchText: '', tags: [] },
})
function TabHeaderItem({
tab,
fallbackDisabledTip,
@ -64,14 +79,14 @@ function TabHeaderItem({
return (
<Tooltip>
<TooltipTrigger render={tabElement} />
<TooltipContent placement="top" className="max-w-[230px] rounded-xl px-4 py-3.5">
<TooltipContent placement="top" className="max-w-57.5 rounded-xl px-4 py-3.5">
{tab.disabledTip || fallbackDisabledTip}
</TooltipContent>
</Tooltip>
)
}
function SelectorContent({
function BlockSelectorPanels({
defaultTab,
standalonePanel,
tabs,
@ -85,15 +100,28 @@ function SelectorContent({
hasUserInputNode = false,
hasTriggerNode = false,
snippetInsertPayload,
}: SelectorContentProps) {
}: BlockSelectorPanelsProps) {
const { t } = useTranslation()
const [searchText, setSearchText] = useState('')
const debouncedSearchText = useDebounce(searchText, { wait: 500 })
const [tags, setTags] = useState<string[]>([])
const [filters, setFilters] = useState(createTabFilterState)
const fallbackDisabledTip = t(($) => $['tabs.startDisabledTip'], { ns: 'workflow' })
const setSearchText = (tab: TabType, searchText: string) => {
setFilters((currentFilters) => ({
...currentFilters,
[tab]: { ...currentFilters[tab], searchText },
}))
}
const setTags = (tab: TabType, tags: string[]) => {
setFilters((currentFilters) => ({
...currentFilters,
[tab]: { ...currentFilters[tab], tags },
}))
}
const renderSearchFilter = (tab: TabType, inputRef?: Ref<HTMLInputElement>) => {
if (tab === TabType.Snippets) return null
const { searchText, tags } = filters[tab]
const filter = (() => {
if (tab === TabType.Start) {
@ -101,9 +129,9 @@ function SelectorContent({
<SearchBox
ref={inputRef}
search={searchText}
onSearchChange={setSearchText}
onSearchChange={(value) => setSearchText(tab, value)}
tags={tags}
onTagsChange={setTags}
onTagsChange={(value) => setTags(tab, value)}
placeholder={t(($) => $['tabs.searchTrigger'], { ns: 'workflow' })}
inputClassName="grow"
/>
@ -115,9 +143,9 @@ function SelectorContent({
<SearchBox
ref={inputRef}
search={searchText}
onSearchChange={setSearchText}
onSearchChange={(value) => setSearchText(tab, value)}
tags={tags}
onTagsChange={setTags}
onTagsChange={(value) => setTags(tab, value)}
placeholder={t(($) => $.searchTools, { ns: 'plugin' })!}
inputClassName="grow"
/>
@ -138,7 +166,7 @@ function SelectorContent({
? t(($) => $['tabs.searchBlock'], { ns: 'workflow' })
: t(($) => $['tabs.searchDataSource'], { ns: 'workflow' })
}
onValueChange={setSearchText}
onValueChange={(value) => setSearchText(tab, value)}
/>
)
})()
@ -158,10 +186,10 @@ function SelectorContent({
allowUserInputSelection={allowStartNodeSelection}
hasUserInputNode={hasUserInputNode}
hasTriggerNode={hasTriggerNode}
searchText={debouncedSearchText}
searchText={filters[TabType.Start].searchText}
onSelect={onSelect}
availableBlocksTypes={availableBlocksTypes}
tags={tags}
tags={filters[TabType.Start].tags}
/>
</div>
</>
@ -174,7 +202,7 @@ function SelectorContent({
{searchFilter}
<div className="border-t border-divider-subtle">
<Blocks
searchText={searchText}
searchText={filters[TabType.Blocks].searchText}
onSelect={onSelect}
availableBlocksTypes={availableBlocksTypes}
blocks={blocks}
@ -189,7 +217,11 @@ function SelectorContent({
<>
{searchFilter}
<div className="border-t border-divider-subtle">
<DataSources searchText={searchText} onSelect={onSelect} dataSources={dataSources} />
<DataSources
searchText={filters[TabType.Sources].searchText}
onSelect={onSelect}
dataSources={dataSources}
/>
</div>
</>
)
@ -200,10 +232,10 @@ function SelectorContent({
<>
{searchFilter}
<ToolPanel
searchText={debouncedSearchText}
searchText={filters[TabType.Tools].searchText}
onSelect={onSelect}
tags={tags}
onTagsChange={setTags}
tags={filters[TabType.Tools].tags}
onTagsChange={(value) => setTags(TabType.Tools, value)}
dataSources={dataSources}
/>
</>
@ -212,8 +244,8 @@ function SelectorContent({
return (
<Snippets
searchText={searchText}
onSearchTextChange={setSearchText}
searchText={filters[TabType.Snippets].searchText}
onSearchTextChange={(value) => setSearchText(TabType.Snippets, value)}
insertPayload={snippetInsertPayload}
onInserted={onRequestClose}
/>
@ -233,7 +265,10 @@ function SelectorContent({
return (
<Tabs defaultValue={defaultTab} className="w-full min-w-0">
<TabsList className="relative w-full min-w-0 gap-0 bg-background-section-burn pt-1 pl-1">
<TabsList
aria-label={t(($) => $['common.addBlock'], { ns: 'workflow' })}
className="relative w-full min-w-0 gap-0 bg-background-section-burn pt-1 pl-1"
>
{tabs.map((tab) => (
<TabHeaderItem key={tab.key} tab={tab} fallbackDisabledTip={fallbackDisabledTip} />
))}
@ -248,11 +283,7 @@ function SelectorContent({
/>
</TabsList>
{tabs.map((tab) => (
<TabsPanel
key={tab.key}
value={tab.key}
className="focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden focus-visible:ring-inset"
>
<TabsPanel key={tab.key} value={tab.key} tabIndex={-1}>
{renderPanel(
tab.key,
tab.key === defaultTab && tab.key !== TabType.Snippets ? searchInputRef : undefined,
@ -263,4 +294,4 @@ function SelectorContent({
)
}
export { SelectorContent }
export { BlockSelectorPanels }

View File

@ -1,38 +1,83 @@
import type { RefObject } from 'react'
import type { ReactNode, RefObject } from 'react'
import type { Plugin } from '../../plugins/types'
import type { BlockEnum, ToolWithProvider } from '../types'
import type { ToolDefaultValue, ToolValue } from './types'
import type {
ListProps,
ListRef,
} from '@/app/components/workflow/block-selector/market-place-plugin/list'
} from '@/app/components/workflow/block-selector/marketplace-plugin/list'
import type { OnSelectBlock } from '@/app/components/workflow/types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { RiArrowRightUpLine } from '@remixicon/react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useDebounce } from 'ahooks'
import { useCallback, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import { SearchMenu } from '@/app/components/base/icons/src/vender/line/general'
import { buildIntegrationPath } from '@/app/components/integrations/routes'
import { useMarketplacePlugins } from '@/app/components/plugins/marketplace/query'
import { getMarketplaceCategoryUrl } from '@/app/components/plugins/marketplace/utils'
import PluginList from '@/app/components/workflow/block-selector/market-place-plugin/list'
import PluginList from '@/app/components/workflow/block-selector/marketplace-plugin/list'
import { useGetLanguage } from '@/context/i18n'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import Link from '@/next/link'
import { useMarketplacePlugins } from '../../plugins/marketplace/hooks'
import { PluginCategoryEnum } from '../../plugins/types'
import FeaturedTools from './featured-tools'
import { useToolTabs } from './hooks'
import { RAGToolRecommendations } from './rag-tool-recommendations'
import Tools from './tools'
import { ToolType } from './types'
import ViewTypeSelect, { ViewType } from './view-type-select'
import { ToolType, ViewType } from './types'
import ViewTypeSelect from './view-type-select'
const marketplaceFooterClassName =
'system-sm-medium z-10 flex h-8 flex-none cursor-pointer items-center rounded-b-lg border-[0.5px] border-t border-components-panel-border bg-components-panel-bg-blur px-4 py-1 text-text-accent-light-mode-only shadow-lg'
'system-sm-medium flex h-8 flex-none items-center border-t border-divider-subtle bg-components-panel-bg-blur px-4 py-1'
type AllToolsProps = {
function ToolsEmptyState({ title, action }: { title: string; action?: ReactNode }) {
return (
<div className="flex min-h-40 flex-col items-center justify-center gap-3 px-4 py-12 text-center">
<span
aria-hidden
className="i-custom-vender-line-general-search-menu size-8 text-text-quaternary"
/>
<div aria-live="polite" className="system-sm-medium text-text-secondary">
{title}
</div>
{action}
</div>
)
}
function ToolCategoryEmptyState({ type }: { type: ToolType }) {
const { t } = useTranslation()
const title = t(($) => $[`addToolModal.${type}.title`], { ns: 'tools' })
const tip = t(($) => $[`addToolModal.${type}.tip`], { ns: 'tools' })
const href = (() => {
if (type === ToolType.Custom) return buildIntegrationPath('custom-tool')
if (type === ToolType.Workflow) return buildIntegrationPath('workflow-tool')
if (type === ToolType.MCP) return buildIntegrationPath('mcp')
return undefined
})()
return (
<ToolsEmptyState
title={title}
action={
href && tip ? (
<Link
href={href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center rounded-md system-xs-regular text-text-tertiary hover:text-text-accent focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
>
<span>{tip}</span>
<span aria-hidden className="ml-0.5 i-ri-arrow-right-up-line size-3" />
</Link>
) : undefined
}
/>
)
}
type ToolBrowserProps = {
className?: string
toolContentClassName?: string
searchText: string
@ -53,9 +98,9 @@ type AllToolsProps = {
onFeaturedInstallSuccess?: () => Promise<void> | void
}
const DEFAULT_TAGS: AllToolsProps['tags'] = []
const DEFAULT_TAGS: ToolBrowserProps['tags'] = []
const AllTools = ({
function ToolBrowser({
className,
toolContentClassName,
searchText,
@ -74,7 +119,7 @@ const AllTools = ({
featuredLoading = false,
showFeatured = false,
onFeaturedInstallSuccess,
}: AllToolsProps) => {
}: ToolBrowserProps) {
const { t } = useTranslation()
const language = useGetLanguage()
const tabs = useToolTabs()
@ -84,6 +129,12 @@ const AllTools = ({
const hasSearchText = trimmedSearchText.length > 0
const hasTags = tags.length > 0
const hasFilter = hasSearchText || hasTags
const marketplaceFilters = useMemo(
() => ({ query: trimmedSearchText, tags }),
[tags, trimmedSearchText],
)
const debouncedMarketplaceFilters = useDebounce(marketplaceFilters, { wait: 500 })
const isMarketplaceSearchSettled = debouncedMarketplaceFilters === marketplaceFilters
const handleLoadMoreRAGTools = () => {
if (!onTagsChange || tags.includes('rag')) return
onTagsChange([...tags, 'rag'])
@ -165,24 +216,27 @@ const AllTools = ({
language,
])
const { queryPluginsWithDebounced: fetchPlugins, plugins: notInstalledPlugins = [] } =
useMarketplacePlugins()
const { data: enable_marketplace } = useSuspenseQuery({
...systemFeaturesQueryOptions(),
select: (s) => s.enable_marketplace,
})
useEffect(() => {
if (!enable_marketplace) return
if (hasFilter) {
fetchPlugins({
query: searchText,
tags,
category: PluginCategoryEnum.tool,
})
}
}, [searchText, tags, enable_marketplace, hasFilter, fetchPlugins])
const marketplaceSearchParams = useMemo(
() =>
enable_marketplace && hasFilter && isMarketplaceSearchSettled
? {
query: debouncedMarketplaceFilters.query,
tags: debouncedMarketplaceFilters.tags,
category: PluginCategoryEnum.tool,
}
: undefined,
[debouncedMarketplaceFilters, enable_marketplace, hasFilter, isMarketplaceSearchSettled],
)
const { data: marketplacePluginsData, isFetching: isMarketplaceFetching } =
useMarketplacePlugins(marketplaceSearchParams)
const notInstalledPlugins = useMemo(
() => marketplacePluginsData?.pages.flatMap((page) => page.plugins) ?? [],
[marketplacePluginsData?.pages],
)
const pluginRef = useRef<ListRef>(null)
const wrapElemRef = useRef<HTMLDivElement>(null)
@ -191,7 +245,12 @@ const AllTools = ({
const isShowRAGRecommendations = isInRAGPipeline && activeTab === ToolType.All && !hasFilter
const hasToolsListContent = tools.length > 0 || isShowRAGRecommendations
const hasPluginContent = enable_marketplace && notInstalledPlugins.length > 0
const shouldShowEmptyState = hasFilter && !hasToolsListContent && !hasPluginContent
const isMarketplaceSearchPending =
enable_marketplace && hasFilter && (!isMarketplaceSearchSettled || isMarketplaceFetching)
const shouldShowEmptyState =
hasFilter && !isMarketplaceSearchPending && !hasToolsListContent && !hasPluginContent
const shouldShowCategoryEmptyState =
!hasFilter && activeTab !== ToolType.All && !hasToolsListContent
const shouldShowFeatured =
showFeatured &&
enable_marketplace &&
@ -216,14 +275,14 @@ const AllTools = ({
}, [activeTab, t])
return (
<div className={cn('max-w-[500px]', className)}>
<div className={cn('max-w-125', className)}>
<div className="flex items-center justify-between border-b border-divider-subtle px-3">
<div className="flex h-8 items-center space-x-1">
{tabs.map((tab) => (
<button
type="button"
className={cn(
'flex h-6 cursor-pointer items-center rounded-md border-0 bg-transparent px-2 hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden',
'flex h-6 cursor-pointer items-center rounded-md border-0 bg-transparent px-2 hover:bg-state-base-hover focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid focus-visible:outline-hidden',
'text-xs font-medium text-text-secondary',
activeTab === tab.key && 'bg-state-base-hover-alt',
)}
@ -237,7 +296,7 @@ const AllTools = ({
</div>
{isSupportGroupView && <ViewTypeSelect viewType={activeView} onChange={setActiveView} />}
</div>
<div className="flex max-h-[464px] flex-col">
<div className="flex max-h-116 flex-col">
<div
ref={wrapElemRef}
className="flex-1 overflow-y-auto"
@ -279,19 +338,19 @@ const AllTools = ({
onSelect={onSelect}
canNotSelectMultiple={canNotSelectMultiple}
onSelectMultiple={onSelectMultiple}
toolType={activeTab}
viewType={isSupportGroupView ? activeView : ViewType.flat}
hasSearchText={hasSearchText}
selectedTools={selectedTools}
/>
</>
)}
{shouldShowCategoryEmptyState && <ToolCategoryEmptyState type={activeTab} />}
{enable_marketplace && (
<PluginList
ref={pluginRef}
wrapElemRef={wrapElemRef as RefObject<HTMLElement>}
list={notInstalledPlugins}
searchText={searchText}
searchText={trimmedSearchText}
category={PluginCategoryEnum.tool}
toolContentClassName={toolContentClassName}
tags={tags}
@ -301,39 +360,37 @@ const AllTools = ({
</div>
{shouldShowEmptyState && (
<div className="flex h-full flex-col items-center justify-center gap-3 py-12 text-center">
<SearchMenu className="size-8 text-text-quaternary" />
<div className="text-sm font-medium text-text-secondary">
{t(($) => $['tabs.noPluginsFound'], { ns: 'workflow' })}
</div>
<Link
href="https://github.com/langgenius/dify-plugins/issues/new?template=plugin_request.yaml"
target="_blank"
>
<Button
size="small"
variant="secondary-accent"
className="h-6 cursor-pointer px-3 text-xs"
<ToolsEmptyState
title={t(($) => $['tabs.noPluginsFound'], { ns: 'workflow' })}
action={
<Link
href="https://github.com/langgenius/dify-plugins/issues/new?template=plugin_request.yaml"
target="_blank"
rel="noopener noreferrer"
className="inline-flex h-6 items-center justify-center rounded-md border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3 text-xs font-medium text-components-button-secondary-accent-text shadow-xs hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
>
{t(($) => $['tabs.requestToCommunity'], { ns: 'workflow' })}
</Button>
</Link>
</div>
</Link>
}
/>
)}
</div>
{shouldShowMarketplaceFooter && (
<Link
className={marketplaceFooterClassName}
href={getMarketplaceCategoryUrl(PluginCategoryEnum.tool)}
target="_blank"
>
<span>{t(($) => $.findMoreInMarketplace, { ns: 'plugin' })}</span>
<RiArrowRightUpLine className="ml-0.5 size-3" />
</Link>
<footer className={marketplaceFooterClassName}>
<Link
className="inline-flex items-center rounded-md text-text-accent-light-mode-only focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
href={getMarketplaceCategoryUrl(PluginCategoryEnum.tool)}
target="_blank"
rel="noopener noreferrer"
>
<span>{t(($) => $.findMoreInMarketplace, { ns: 'plugin' })}</span>
<span aria-hidden className="ml-0.5 i-ri-arrow-right-up-line size-3" />
</Link>
</footer>
)}
</div>
</div>
)
}
export default AllTools
export default ToolBrowser

View File

@ -0,0 +1,150 @@
import type { ToolWithProvider } from '../types'
import { pinyin } from 'pinyin-pro'
import { CollectionType } from '../../tools/types'
type ToolCategoryGroup = 'custom' | 'data-source' | 'mcp' | 'workflow'
type AuthorToolGroup = {
kind: 'author'
author: string
tools: ToolWithProvider[]
}
type CategoryToolGroup = {
kind: 'category'
category: ToolCategoryGroup
tools: ToolWithProvider[]
}
export type ToolGroup = AuthorToolGroup | CategoryToolGroup
type ToolListData = {
letters: string[]
flatTools: Array<ToolWithProvider & { letter: string }>
treeGroups: ToolGroup[]
}
type LetterBucket = {
groups: ToolGroup[]
authorGroups: Map<string, AuthorToolGroup>
categoryGroups: Map<ToolCategoryGroup, CategoryToolGroup>
}
export function createToolListData(
tools: readonly ToolWithProvider[],
getFirstChar: (tool: ToolWithProvider) => string,
): ToolListData {
const buckets = new Map<string, LetterBucket>()
for (const tool of tools) {
const firstChar = getFirstChar(tool)
if (!firstChar) continue
const letter = normalizeLetter(firstChar)
const bucket = getOrCreateBucket(buckets, letter)
addToolToBucket(bucket, tool)
}
const sortedBuckets = [...buckets.entries()].sort(([left], [right]) => {
if (left === '#') return 1
if (right === '#') return -1
return left.localeCompare(right)
})
const letters = sortedBuckets.map(([letter]) => letter)
const flatTools = sortedBuckets.flatMap(([letter, bucket]) =>
bucket.groups.flatMap((group) => group.tools.map((tool) => ({ ...tool, letter }))),
)
const treeGroups = mergeGroupsByProvider([...buckets.values()])
return { letters, flatTools, treeGroups }
}
function normalizeLetter(firstChar: string) {
const pinyinInitial = /[\u4E00-\u9FA5]/.test(firstChar)
? pinyin(firstChar, { pattern: 'first', toneType: 'none' })[0]
: firstChar
const letter = (pinyinInitial || firstChar).toUpperCase()
return /[A-Z]/.test(letter) ? letter : '#'
}
function getOrCreateBucket(buckets: Map<string, LetterBucket>, letter: string) {
const existingBucket = buckets.get(letter)
if (existingBucket) return existingBucket
const bucket: LetterBucket = {
groups: [],
authorGroups: new Map(),
categoryGroups: new Map(),
}
buckets.set(letter, bucket)
return bucket
}
function addToolToBucket(bucket: LetterBucket, tool: ToolWithProvider) {
const category = getToolCategoryGroup(tool.type)
if (!category) {
addToolToAuthorGroup(bucket, tool)
return
}
const existingGroup = bucket.categoryGroups.get(category)
if (existingGroup) {
existingGroup.tools.push(tool)
return
}
const group: CategoryToolGroup = { kind: 'category', category, tools: [tool] }
bucket.categoryGroups.set(category, group)
bucket.groups.push(group)
}
function addToolToAuthorGroup(bucket: LetterBucket, tool: ToolWithProvider) {
const existingGroup = bucket.authorGroups.get(tool.author)
if (existingGroup) {
existingGroup.tools.push(tool)
return
}
const group: AuthorToolGroup = { kind: 'author', author: tool.author, tools: [tool] }
bucket.authorGroups.set(tool.author, group)
bucket.groups.push(group)
}
function getToolCategoryGroup(type: ToolWithProvider['type']): ToolCategoryGroup | undefined {
if (type === CollectionType.custom) return 'custom'
if (type === CollectionType.workflow) return 'workflow'
if (type === CollectionType.datasource) return 'data-source'
if (type === CollectionType.mcp) return 'mcp'
}
function mergeGroupsByProvider(buckets: LetterBucket[]) {
const groups: ToolGroup[] = []
const authorGroups = new Map<string, AuthorToolGroup>()
const categoryGroups = new Map<ToolCategoryGroup, CategoryToolGroup>()
for (const bucket of buckets) {
for (const group of bucket.groups) {
if (group.kind === 'author') {
const existingGroup = authorGroups.get(group.author)
if (existingGroup) existingGroup.tools.push(...group.tools)
else {
const mergedGroup: AuthorToolGroup = { ...group, tools: [...group.tools] }
authorGroups.set(group.author, mergedGroup)
groups.push(mergedGroup)
}
continue
}
const existingGroup = categoryGroups.get(group.category)
if (existingGroup) existingGroup.tools.push(...group.tools)
else {
const mergedGroup: CategoryToolGroup = { ...group, tools: [...group.tools] }
categoryGroups.set(group.category, mergedGroup)
groups.push(mergedGroup)
}
}
}
return groups
}

View File

@ -12,7 +12,7 @@ import {
} from '@/service/use-tools'
import { basePath } from '@/utils/var'
import { useWorkflowStore } from '../store'
import AllTools from './all-tools'
import ToolBrowser from './tool-browser'
function normalizeToolList(list: ToolWithProvider[] | undefined, currentBasePath?: string) {
if (!list || !currentBasePath) return list
@ -131,7 +131,7 @@ export function ToolPanel({
])
return (
<AllTools
<ToolBrowser
searchText={searchText}
onSelect={onSelect}
tags={tags}

View File

@ -1,12 +1,11 @@
'use client'
import type { OffsetOptions } from '@floating-ui/react'
import type { Placement } from '@langgenius/dify-ui/popover'
import type { ReactNode } from 'react'
import type { ReactElement } from 'react'
import type { ToolDefaultValue, ToolValue } from './types'
import type { CustomCollectionBackend } from '@/app/components/tools/types'
import type { BlockEnum, OnSelectBlock } from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { toast } from '@langgenius/dify-ui/toast'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useBoolean } from 'ahooks'
@ -15,7 +14,7 @@ import { useTranslation } from 'react-i18next'
import SearchBox from '@/app/components/plugins/marketplace/search-box'
import EditCustomToolModal from '@/app/components/tools/edit-custom-collection-modal'
import { useCanManageTools } from '@/app/components/tools/hooks/use-tool-permissions'
import AllTools from '@/app/components/workflow/block-selector/all-tools'
import ToolBrowser from '@/app/components/workflow/block-selector/tool-browser'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { createCustomCollection } from '@/service/tools'
import { useFeaturedToolsRecommendations } from '@/service/use-plugins'
@ -32,9 +31,9 @@ import {
type Props = Readonly<{
disabled: boolean
trigger: ReactNode
trigger: ReactElement
placement?: Placement
offset?: OffsetOptions
sideOffset?: number
isShow: boolean
onShowChange: (isShow: boolean) => void
}> &
@ -46,10 +45,12 @@ export type ToolPickerContentProps = Readonly<{
onSelect: (tool: ToolDefaultValue) => void
onSelectMultiple: (tools: ToolDefaultValue[]) => void
supportAddCustomTool?: boolean
scope?: string
scope?: ToolPickerScope
selectedTools?: ToolValue[]
}>
export type ToolPickerScope = 'all' | 'plugins' | 'custom' | 'workflow'
export function ToolPickerContent({
focusSearchOnMount = false,
onSelect,
@ -111,8 +112,6 @@ export function ToolPickerContent({
}
}, [scope, buildInTools, customTools, workflowTools])
const handleAddedCustomTool = invalidateCustomTools
const handleSelect = (_type: BlockEnum, tool?: ToolDefaultValue) => {
onSelect(tool!)
}
@ -132,7 +131,7 @@ export function ToolPickerContent({
await createCustomCollection(data)
toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' }))
hideEditCustomCollectionModal()
handleAddedCustomTool()
invalidateCustomTools()
}
if (isShowEditCollectionToolModal && canManageTools) {
@ -161,7 +160,6 @@ export function ToolPickerContent({
onTagsChange={setTags}
placeholder={t(($) => $.searchTools, { ns: 'plugin' })!}
supportAddCustomTool={supportAddCustomTool && canManageTools}
onAddedCustomTool={handleAddedCustomTool}
onShowAddCustomCollectionModal={showEditCustomCollectionModal}
// The picker replaces the focused menu item inside an already-open popover.
// Focusing search keeps keyboard users in the same add-tool workflow.
@ -170,7 +168,7 @@ export function ToolPickerContent({
inputClassName="grow"
/>
</div>
<AllTools
<ToolBrowser
className="mt-1"
toolContentClassName="max-w-full"
tags={tags}
@ -201,33 +199,28 @@ function ToolPicker({
disabled,
trigger,
placement = 'right-start',
offset = 0,
sideOffset = 0,
isShow,
onShowChange,
...contentProps
}: Props) {
const sideOffset =
typeof offset === 'number' ? offset : typeof offset === 'function' ? 0 : (offset?.mainAxis ?? 0)
const alignOffset =
typeof offset === 'number' ? 0 : typeof offset === 'function' ? 0 : (offset?.crossAxis ?? 0)
const { t } = useTranslation()
const handleOpenChange = (nextOpen: boolean) => {
if (nextOpen && disabled) return
onShowChange(nextOpen)
}
return (
<Popover open={isShow} onOpenChange={handleOpenChange}>
<PopoverTrigger nativeButton={false} render={<div className="inline-block" />}>
{trigger}
</PopoverTrigger>
<PopoverTrigger disabled={disabled} render={trigger} />
<PopoverContent
placement={placement}
sideOffset={sideOffset}
alignOffset={alignOffset}
popupClassName="border-none bg-transparent shadow-none"
>
<PopoverTitle className="sr-only">
{t(($) => $['detailPanel.toolSelector.title'], { ns: 'plugin' })}
</PopoverTitle>
<ToolPickerContent {...contentProps} />
</PopoverContent>
</Popover>

View File

@ -8,7 +8,7 @@ import useTheme from '@/hooks/use-theme'
import { Theme } from '@/types/app'
import { BlockEnum } from '../../../types'
import { createTool, createToolProvider } from '../../__tests__/factories'
import { ViewType } from '../../view-type-select'
import { ViewType } from '../../types'
import Tool from '../tool'
vi.mock('@/context/i18n', () => ({
@ -40,7 +40,92 @@ describe('Tool', () => {
mockUseTheme.mockReturnValue({ theme: Theme.light } as ReturnType<typeof useTheme>)
})
it('expands a provider and selects an action item', async () => {
it('keeps provider disclosures independently expanded', async () => {
const user = userEvent.setup()
render(
<>
<Tool
payload={createToolProvider()}
previewCardHandle={createPreviewCardHandle()}
viewType={ViewType.flat}
hasSearchText={false}
onSelect={vi.fn()}
/>
<Tool
payload={createToolProvider({
id: 'provider-2',
name: 'provider-two',
label: { en_US: 'Provider Two', zh_Hans: 'Provider Two' },
tools: [createTool('tool-b', 'Tool B')],
})}
previewCardHandle={createPreviewCardHandle()}
viewType={ViewType.flat}
hasSearchText={false}
onSelect={vi.fn()}
/>
</>,
)
const firstDisclosure = screen.getByRole('button', { name: /Provider One/ })
const secondDisclosure = screen.getByRole('button', { name: /Provider Two/ })
expect(firstDisclosure).toHaveAttribute('aria-expanded', 'false')
expect(firstDisclosure).toHaveAttribute('aria-controls')
expect(secondDisclosure).toHaveAttribute('aria-expanded', 'false')
await user.click(firstDisclosure)
await user.click(secondDisclosure)
expect(firstDisclosure).toHaveAttribute('aria-expanded', 'true')
expect(secondDisclosure).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByRole('button', { name: 'Tool A' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Tool B' })).toBeInTheDocument()
})
it('orders the provider, add-all, and action buttons without collapsing on add-all', async () => {
const user = userEvent.setup()
const onSelectMultiple = vi.fn()
render(
<Tool
payload={createToolProvider({
tools: [createTool('tool-a', 'Tool A'), createTool('tool-b', 'Tool B')],
})}
previewCardHandle={createPreviewCardHandle()}
viewType={ViewType.flat}
hasSearchText={false}
onSelect={vi.fn()}
onSelectMultiple={onSelectMultiple}
/>,
)
const disclosure = screen.getByRole('button', { name: /Provider One/ })
disclosure.focus()
await user.keyboard('{Enter}')
expect(disclosure).toHaveAttribute('aria-expanded', 'true')
await user.tab()
const addAllButton = screen.getByRole('button', { name: 'workflow.tabs.addAll' })
expect(addAllButton).toHaveFocus()
await user.keyboard('{Enter}')
expect(disclosure).toHaveAttribute('aria-expanded', 'true')
expect(onSelectMultiple).toHaveBeenCalledWith(
BlockEnum.Tool,
expect.arrayContaining([
expect.objectContaining({ tool_name: 'tool-a' }),
expect.objectContaining({ tool_name: 'tool-b' }),
]),
)
await user.tab()
expect(screen.getByRole('button', { name: 'Tool A' })).toHaveFocus()
})
it('selects an expanded action item', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
@ -56,8 +141,8 @@ describe('Tool', () => {
/>,
)
await user.click(screen.getByText('Provider One'))
await user.click(screen.getByText('Tool B'))
await user.click(screen.getByRole('button', { name: /Provider One/ }))
await user.click(screen.getByRole('button', { name: 'Tool B' }))
expect(onSelect).toHaveBeenCalledWith(
BlockEnum.Tool,

Some files were not shown because too many files have changed in this diff Show More