mirror of
https://github.com/langgenius/dify.git
synced 2026-07-24 13:08:34 +08:00
refactor(web): improve workflow block selector (#39377)
This commit is contained in:
parent
45ac70132c
commit
b95d313128
@ -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.',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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}`.
|
||||
|
||||
@ -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.",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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(),
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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)
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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' }))
|
||||
})
|
||||
})
|
||||
|
||||
@ -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', '')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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' })}
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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.
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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.
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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'))
|
||||
|
||||
|
||||
@ -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 }) =>
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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: ({
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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'
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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',
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
|
||||
73
web/app/components/workflow/block-selector/README.md
Normal file
73
web/app/components/workflow/block-selector/README.md
Normal 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.
|
||||
@ -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,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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' })
|
||||
})
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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}
|
||||
@ -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' })],
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@ -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 () => {
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
@ -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' }))
|
||||
@ -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
|
||||
@ -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)
|
||||
@ -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)
|
||||
@ -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
|
||||
@ -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')
|
||||
})
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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
|
||||
150
web/app/components/workflow/block-selector/tool-list-data.ts
Normal file
150
web/app/components/workflow/block-selector/tool-list-data.ts
Normal 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
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user