feat(workflow): improve block selector navigation and previews (#39212)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
yyh 2026-07-18 17:56:36 +08:00 committed by GitHub
parent fa65f45bff
commit 2ec34b2cfb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
82 changed files with 1719 additions and 1679 deletions

View File

@ -1594,11 +1594,6 @@
"count": 1
}
},
"web/app/components/base/icons/src/vender/solid/arrows/index.ts": {
"no-barrel-files/no-barrel-files": {
"count": 1
}
},
"web/app/components/base/icons/src/vender/solid/communication/index.ts": {
"no-barrel-files/no-barrel-files": {
"count": 6
@ -2112,11 +2107,6 @@
"count": 1
}
},
"web/app/components/base/search-input/__tests__/index.spec.tsx": {
"jsx_a11y/no-autofocus": {
"count": 1
}
},
"web/app/components/base/search-input/index.stories.tsx": {
"jsx_a11y/label-has-associated-control": {
"count": 3
@ -3346,11 +3336,6 @@
"count": 1
}
},
"web/app/components/plugins/marketplace/search-box/index.tsx": {
"jsx_a11y/no-autofocus": {
"count": 1
}
},
"web/app/components/plugins/marketplace/search-box/tags-filter.tsx": {
"no-restricted-imports": {
"count": 1
@ -4105,59 +4090,16 @@
"count": 1
}
},
"web/app/components/workflow/block-selector/all-tools.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
},
"jsx_a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/workflow/block-selector/constants.tsx": {
"react/only-export-components": {
"count": 3
}
},
"web/app/components/workflow/block-selector/featured-tools.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
},
"jsx_a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/workflow/block-selector/featured-triggers.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
},
"jsx_a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/workflow/block-selector/hooks.ts": {
"eslint-react/set-state-in-effect": {
"count": 1
}
},
"web/app/components/workflow/block-selector/index-bar.tsx": {
"react/only-export-components": {
"count": 1
}
},
"web/app/components/workflow/block-selector/main.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
},
"jsx_a11y/no-autofocus": {
"count": 4
},
"jsx_a11y/no-static-element-interactions": {
"count": 1
},
"no-restricted-imports": {
"count": 2
}
},
"web/app/components/workflow/block-selector/market-place-plugin/list.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
@ -4166,14 +4108,6 @@
"count": 1
}
},
"web/app/components/workflow/block-selector/rag-tool-recommendations/index.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
@ -4182,19 +4116,6 @@
"count": 1
}
},
"web/app/components/workflow/block-selector/snippets/index.tsx": {
"jsx_a11y/no-autofocus": {
"count": 1
}
},
"web/app/components/workflow/block-selector/tabs.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 2
},
"jsx_a11y/no-static-element-interactions": {
"count": 2
}
},
"web/app/components/workflow/block-selector/tool-picker.tsx": {
"no-restricted-imports": {
"count": 1
@ -4208,11 +4129,6 @@
"count": 2
}
},
"web/app/components/workflow/block-selector/types.ts": {
"erasable-syntax-only/enums": {
"count": 4
}
},
"web/app/components/workflow/block-selector/use-sticky-scroll.ts": {
"erasable-syntax-only/enums": {
"count": 1
@ -5669,11 +5585,6 @@
"count": 1
}
},
"web/app/components/workflow/operator/add-block.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/operator/hooks.ts": {
"typescript/no-explicit-any": {
"count": 1

View File

@ -12,7 +12,7 @@ const meta = {
docs: {
description: {
component:
'Unstyled Base UI Collapsible primitive. The examples mirror the official Root, Trigger, and Panel anatomy, with presentation supplied at the call site using Dify UI tokens.',
'Styled Dify disclosure wrapper over Base UI Collapsible. It preserves the official Root, Trigger, and Panel anatomy while providing Dify layout, focus, disabled, and motion styles.',
},
},
},

View File

@ -10,91 +10,56 @@ describe('PreviewCardContent', () => {
it('should use bottom placement and default offsets when placement props are not provided', async () => {
const screen = await renderWithSafeViewport(
<PreviewCard open>
<PreviewCardTrigger
render={
<button type="button" aria-label="preview trigger">
Open
</button>
}
/>
<PreviewCardTrigger href="#default-preview">Open</PreviewCardTrigger>
<PreviewCardContent
positionerProps={{ role: 'group', 'aria-label': 'default positioner' }}
popupProps={{ role: 'dialog', 'aria-label': 'default popup' }}
positionerProps={{ id: 'default-positioner' }}
popupProps={{ id: 'default-popup' }}
>
<span>Default content</span>
</PreviewCardContent>
</PreviewCard>,
)
await expect
.element(screen.getByRole('group', { name: 'default positioner' }))
.toHaveAttribute('data-side', 'bottom')
await expect
.element(screen.getByRole('group', { name: 'default positioner' }))
.toHaveAttribute('data-align', 'center')
await expect
.element(screen.getByRole('dialog', { name: 'default popup' }))
.toHaveTextContent('Default content')
await expect.element(screen.getByText('Default content')).toBeInTheDocument()
expect(document.getElementById('default-positioner')).toHaveAttribute('data-side', 'bottom')
expect(document.getElementById('default-positioner')).toHaveAttribute('data-align', 'center')
expect(document.getElementById('default-popup')).toHaveTextContent('Default content')
})
it('should apply parsed custom placement and custom offsets when placement props are provided', async () => {
const screen = await renderWithSafeViewport(
<PreviewCard open>
<PreviewCardTrigger
render={
<button type="button" aria-label="preview trigger">
Open
</button>
}
/>
<PreviewCardTrigger href="#custom-preview">Open</PreviewCardTrigger>
<PreviewCardContent
placement="top-end"
sideOffset={14}
alignOffset={6}
positionerProps={{ role: 'group', 'aria-label': 'custom positioner' }}
popupProps={{ role: 'dialog', 'aria-label': 'custom popup' }}
positionerProps={{ id: 'custom-positioner' }}
popupProps={{ id: 'custom-popup' }}
>
<span>Custom placement content</span>
</PreviewCardContent>
</PreviewCard>,
)
await expect
.element(screen.getByRole('group', { name: 'custom positioner' }))
.toHaveAttribute('data-side', 'top')
await expect
.element(screen.getByRole('group', { name: 'custom positioner' }))
.toHaveAttribute('data-align', 'end')
await expect
.element(screen.getByRole('dialog', { name: 'custom popup' }))
.toHaveTextContent('Custom placement content')
await expect.element(screen.getByText('Custom placement content')).toBeInTheDocument()
expect(document.getElementById('custom-positioner')).toHaveAttribute('data-side', 'top')
expect(document.getElementById('custom-positioner')).toHaveAttribute('data-align', 'end')
expect(document.getElementById('custom-popup')).toHaveTextContent('Custom placement content')
})
})
describe('Passthrough props', () => {
it('should forward positionerProps and popupProps when passthrough props are provided', async () => {
const onPopupClick = vi.fn()
const screen = await render(
<PreviewCard open>
<PreviewCardTrigger
render={
<button type="button" aria-label="preview trigger">
Open
</button>
}
/>
<PreviewCardTrigger href="#passthrough-preview">Open</PreviewCardTrigger>
<PreviewCardContent
positionerProps={{
role: 'group',
'aria-label': 'preview positioner',
id: 'preview-positioner-id',
}}
popupProps={{
id: 'preview-popup-id',
role: 'dialog',
'aria-label': 'preview content',
onClick: onPopupClick,
}}
>
<span>Preview body</span>
@ -102,40 +67,26 @@ describe('PreviewCardContent', () => {
</PreviewCard>,
)
const popup = screen.getByRole('dialog', { name: 'preview content' })
await popup.click()
await expect
.element(screen.getByRole('group', { name: 'preview positioner' }))
.toHaveAttribute('id', 'preview-positioner-id')
await expect.element(popup).toHaveAttribute('id', 'preview-popup-id')
expect(onPopupClick).toHaveBeenCalledTimes(1)
await expect.element(screen.getByText('Preview body')).toBeInTheDocument()
expect(document.getElementById('preview-positioner-id')).toBeInTheDocument()
expect(document.getElementById('preview-popup-id')).toBeInTheDocument()
})
})
describe('Trigger click behavior', () => {
it('should forward the trigger click to the consumer handler so the primary action runs', async () => {
const onPrimaryClick = vi.fn()
describe('Trigger semantics', () => {
it('should preserve the link destination', async () => {
const screen = await renderWithSafeViewport(
<PreviewCard>
<PreviewCardTrigger
render={
<button type="button" aria-label="preview trigger" onClick={onPrimaryClick}>
Open
</button>
}
/>
<PreviewCardContent popupProps={{ role: 'dialog', 'aria-label': 'preview content' }}>
<PreviewCardTrigger href="/preview-destination">Preview destination</PreviewCardTrigger>
<PreviewCardContent>
<span>Preview body</span>
</PreviewCardContent>
</PreviewCard>,
)
const trigger = screen.getByRole('button', { name: 'preview trigger' })
await trigger.click()
expect(onPrimaryClick).toHaveBeenCalledTimes(1)
await expect
.element(screen.getByRole('link', { name: 'Preview destination' }))
.toHaveAttribute('href', '/preview-destination')
})
})
})

View File

@ -3,9 +3,6 @@ import type { Placement } from '.'
import * as React from 'react'
import { createPreviewCardHandle, PreviewCard, PreviewCardContent, PreviewCardTrigger } from '.'
const rowButtonClassName =
'flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm text-text-secondary outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid'
const triggerButtonClassName =
'rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-1.5 text-sm text-text-secondary shadow-xs outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid'
@ -20,7 +17,7 @@ const meta = {
docs: {
description: {
component:
"Hover- and focus-activated rich preview for triggers whose primary click has its own destination (following a link, selecting a row, jumping to a definition). Built on Base UI PreviewCard.\n\n**A11y contract:** touch and screen-reader users cannot open the preview. Never place information or actions in the popup that are not also reachable from the trigger's primary click destination. If that is unavoidable, add a separate click affordance (Popover) or move the unique content onto the destination.",
'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.',
},
},
},
@ -85,38 +82,6 @@ export const LinkPreview: Story = {
),
}
export const Supplementary: Story = {
name: 'Supplementary preview on a button trigger',
parameters: {
docs: {
description: {
story:
'Application-level adaptation of the same semantic: the trigger is a `<button>` that owns a primary action (selecting a model row) rather than an `<a>`. The preview still only shows supplementary info reachable from the selection destination, so the a11y contract holds.',
},
},
},
render: () => (
<PreviewCard>
<PreviewCardTrigger
render={
<button type="button" className={rowButtonClassName}>
<span className="i-ri-sparkling-fill h-4 w-4 text-text-accent" />
<span>gpt-4o</span>
</button>
}
/>
<PreviewCardContent placement="right" popupClassName="w-[220px] p-3">
<div className="flex flex-col gap-2">
<div className="text-sm font-medium text-text-primary">gpt-4o</div>
<div className="text-xs text-text-tertiary">
Multimodal flagship model. Vision, audio and 128k context.
</div>
</div>
</PreviewCardContent>
</PreviewCard>
),
}
const PLACEMENTS: Placement[] = [
'top-start',
'top',
@ -152,13 +117,9 @@ const PlacementsDemo = () => {
))}
</div>
<PreviewCard open>
<PreviewCardTrigger
render={
<button type="button" className={triggerButtonClassName}>
Hover me
</button>
}
/>
<PreviewCardTrigger href="#preview-card-placement" className={triggerButtonClassName}>
Hover me
</PreviewCardTrigger>
<PreviewCardContent placement={placement} popupClassName="w-56 p-3">
<div className="flex flex-col gap-1">
<div className="text-sm font-semibold text-text-primary">
@ -187,12 +148,11 @@ const CustomDelayDemo = () => (
<PreviewCardTrigger
delay={100}
closeDelay={100}
render={
<button type="button" className={triggerButtonClassName}>
Snappy trigger
</button>
}
/>
href="#preview-card-delay"
className={triggerButtonClassName}
>
Snappy trigger
</PreviewCardTrigger>
<PreviewCardContent popupClassName="w-64 p-3">
<div className="flex flex-col gap-1">
<div className="text-sm font-semibold text-text-primary">Fast hover</div>

View File

@ -11,20 +11,22 @@ export type { Placement }
/**
* PreviewCard is a hover/focus-triggered rich preview intended to supplement a
* trigger whose primary action is its own click destination (e.g. a link, a
* selectable row, a chip that jumps to a definition).
* link. Base UI's canonical trigger renders an anchor.
*
* A11y contract match Base UI's guidance:
* - The popup MUST NOT contain information or actions that are not also
* reachable from the trigger's primary click destination. Touch and screen
* reader users cannot open the card and must be able to get the same
* information/actions without it.
* 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.
*/
export const PreviewCard = BasePreviewCard.Root
export const PreviewCardTrigger = BasePreviewCard.Trigger
export const PreviewCardViewport = BasePreviewCard.Viewport
export const createPreviewCardHandle = BasePreviewCard.createHandle
type PreviewCardContentProps = {

View File

@ -1,27 +0,0 @@
{
"icon": {
"type": "element",
"isRootNode": true,
"name": "svg",
"attributes": {
"width": "16",
"height": "16",
"viewBox": "0 0 16 16",
"fill": "none",
"xmlns": "http://www.w3.org/2000/svg"
},
"children": [
{
"type": "element",
"name": "path",
"attributes": {
"d": "M6.02888 6.23572C5.08558 6.23572 4.56458 7.33027 5.15943 8.06239L7.13069 10.4885C7.57898 11.0403 8.42124 11.0403 8.86962 10.4885L10.8408 8.06239C11.4357 7.33027 10.9147 6.23572 9.97134 6.23572H6.02888Z",
"fill": "currentColor",
"fill-opacity": "0.3"
},
"children": []
}
]
},
"name": "ArrowDownRoundFill"
}

View File

@ -1,18 +0,0 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import type { IconData } from '@/app/components/base/icons/IconBase'
import * as React from 'react'
import IconBase from '@/app/components/base/icons/IconBase'
import data from './ArrowDownRoundFill.json'
const Icon = ({
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
}) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'ArrowDownRoundFill'
export default Icon

View File

@ -1 +0,0 @@
export { default as ArrowDownRoundFill } from './ArrowDownRoundFill'

View File

@ -1,5 +1,5 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { useState } from 'react'
import { createRef, useState } from 'react'
import { SearchInput } from '..'
describe('SearchInput', () => {
@ -26,7 +26,15 @@ describe('SearchInput', () => {
expect(screen.getByRole('searchbox', { name: 'Search providers' })).toBeInTheDocument()
})
it('exposes the input element through its ref', () => {
const ref = createRef<HTMLInputElement>()
render(<SearchInput ref={ref} value="" onValueChange={() => {}} />)
expect(ref.current).toBe(screen.getByRole('searchbox', { name: 'common.operation.search' }))
})
it('focuses the searchbox when autoFocus is enabled', () => {
// oxlint-disable-next-line jsx-a11y/no-autofocus
render(<SearchInput value="" onValueChange={() => {}} autoFocus />)
expect(screen.getByRole('searchbox', { name: 'common.operation.search' })).toHaveFocus()
})

View File

@ -1,10 +1,12 @@
import type { InputProps } from '@langgenius/dify-ui/input'
import type { Ref } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { Input } from '@langgenius/dify-ui/input'
import { useRef, useState } from 'react'
import { useImperativeHandle, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
type SearchInputProps = {
ref?: Ref<HTMLInputElement>
value: string
onValueChange: (value: string) => void
placeholder?: string
@ -12,6 +14,7 @@ type SearchInputProps = {
} & Pick<InputProps, 'aria-label' | 'autoFocus'>
export function SearchInput({
ref,
placeholder,
className,
value,
@ -25,6 +28,7 @@ export function SearchInput({
const compositionCommitRef = useRef<string | null>(null)
const [compositionValue, setCompositionValue] = useState('')
const inputValue = isComposingRef.current ? compositionValue : value
useImperativeHandle(ref, () => inputRef.current as HTMLInputElement, [])
const handleClear = () => {
isComposingRef.current = false

View File

@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features'
import { getToolType } from '@/app/components/tools/utils'
import { renderWithNuqs } from '@/test/nuqs-testing'
import { ToolTypeEnum } from '../../workflow/block-selector/types'
import { ToolType } from '../../workflow/block-selector/types'
import ProviderList from '../tool-provider-list'
vi.mock('@/app/components/plugins/hooks', () => ({
@ -371,12 +371,12 @@ vi.mock('@/app/components/tools/mcp/create-card', () => ({
describe('getToolType', () => {
it.each([
['builtin', ToolTypeEnum.BuiltIn],
['api', ToolTypeEnum.Custom],
['workflow', ToolTypeEnum.Workflow],
['mcp', ToolTypeEnum.MCP],
['unknown', ToolTypeEnum.BuiltIn],
])('returns correct ToolTypeEnum for "%s"', (input, expected) => {
['builtin', ToolType.BuiltIn],
['api', ToolType.Custom],
['workflow', ToolType.Workflow],
['mcp', ToolType.MCP],
['unknown', ToolType.BuiltIn],
])('returns correct ToolType for "%s"', (input, expected) => {
expect(getToolType(input)).toBe(expected)
})
})

View File

@ -1,6 +1,6 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { createRef, useState } from 'react'
import { describe, expect, it, vi } from 'vitest'
import SearchBox from '../index'
@ -55,4 +55,22 @@ describe('SearchBox', () => {
await user.click(screen.getByRole('button'))
expect(onShowAddCustomCollectionModal).toHaveBeenCalledOnce()
})
it('exposes the input element through its ref', () => {
const ref = createRef<HTMLInputElement>()
render(
<SearchBox
ref={ref}
search=""
onSearchChange={vi.fn()}
tags={[]}
onTagsChange={vi.fn()}
placeholder="Search plugins"
showTags={false}
/>,
)
expect(ref.current).toBe(screen.getByPlaceholderText('Search plugins'))
})
})

View File

@ -1,4 +1,5 @@
'use client'
import type { Ref } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { RiAddLine, RiCloseLine, RiSearchLine } from '@remixicon/react'
import ActionButton from '@/app/components/base/action-button'
@ -6,6 +7,7 @@ import Divider from '@/app/components/base/divider'
import TagsFilter from './tags-filter'
type SearchBoxProps = {
ref?: Ref<HTMLInputElement>
search: string
onSearchChange: (search: string) => void
wrapperClassName?: string
@ -23,6 +25,7 @@ type SearchBoxProps = {
showTags?: boolean
}
const SearchBox = ({
ref,
search,
onSearchChange,
wrapperClassName,
@ -60,6 +63,8 @@ const SearchBox = ({
)}
<div className="flex grow items-center gap-x-2 p-1">
<input
ref={ref}
aria-label={placeholder || undefined}
className={cn(
'inline-block grow appearance-none bg-transparent body-md-medium text-text-secondary outline-hidden',
inputElementClassName,
@ -85,6 +90,9 @@ const SearchBox = ({
className={cn('size-4 text-components-input-text-placeholder', searchIconClassName)}
/>
<input
ref={ref}
aria-label={placeholder || undefined}
// 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',

View File

@ -2,7 +2,7 @@ import type { PluginDeclaration, PluginDetail } from '@/app/components/plugins/t
import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
import { TriggerCredentialType } from '@/app/components/workflow/block-selector/types'
import { createReactI18nextMock } from '@/test/i18n-mock'
import { SubscriptionList } from '../index'
import { SubscriptionListMode } from '../types'
@ -53,7 +53,7 @@ const createSubscription = (overrides: Partial<TriggerSubscription> = {}): Trigg
id: 'sub-1',
name: 'Subscription One',
provider: 'provider-1',
credential_type: TriggerCredentialTypeEnum.ApiKey,
credential_type: TriggerCredentialType.ApiKey,
credentials: {},
endpoint: 'https://example.com',
parameters: {},

View File

@ -1,7 +1,7 @@
import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
import { TriggerCredentialType } from '@/app/components/workflow/block-selector/types'
import { SubscriptionListView } from '../list-view'
let mockSubscriptions: TriggerSubscription[] = []
@ -24,7 +24,7 @@ const createSubscription = (overrides: Partial<TriggerSubscription> = {}): Trigg
id: 'sub-1',
name: 'Subscription One',
provider: 'provider-1',
credential_type: TriggerCredentialTypeEnum.ApiKey,
credential_type: TriggerCredentialType.ApiKey,
credentials: {},
endpoint: 'https://example.com',
parameters: {},

View File

@ -1,7 +1,7 @@
import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
import { TriggerCredentialType } from '@/app/components/workflow/block-selector/types'
import { SubscriptionSelectorEntry } from '../selector-entry'
vi.mock('@langgenius/dify-ui/popover', async () => {
@ -86,7 +86,7 @@ const createSubscription = (overrides: Partial<TriggerSubscription> = {}): Trigg
id: 'sub-1',
name: 'Subscription One',
provider: 'provider-1',
credential_type: TriggerCredentialTypeEnum.ApiKey,
credential_type: TriggerCredentialType.ApiKey,
credentials: {},
endpoint: 'https://example.com',
parameters: {},

View File

@ -1,7 +1,7 @@
import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
import { TriggerCredentialType } from '@/app/components/workflow/block-selector/types'
import { SubscriptionSelectorView } from '../selector-view'
let mockSubscriptions: TriggerSubscription[] = []
@ -41,7 +41,7 @@ const createSubscription = (overrides: Partial<TriggerSubscription> = {}): Trigg
id: 'sub-1',
name: 'Subscription One',
provider: 'provider-1',
credential_type: TriggerCredentialTypeEnum.ApiKey,
credential_type: TriggerCredentialType.ApiKey,
credentials: {},
endpoint: 'https://example.com',
parameters: {},

View File

@ -1,7 +1,7 @@
import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
import { TriggerCredentialType } from '@/app/components/workflow/block-selector/types'
import SubscriptionCard from '../subscription-card'
const mockRefetch = vi.fn()
@ -47,7 +47,7 @@ const createSubscription = (overrides: Partial<TriggerSubscription> = {}): Trigg
id: 'sub-1',
name: 'Subscription One',
provider: 'provider-1',
credential_type: TriggerCredentialTypeEnum.ApiKey,
credential_type: TriggerCredentialType.ApiKey,
credentials: {},
endpoint: 'https://example.com',
parameters: {},

View File

@ -3,7 +3,7 @@ import type { TriggerSubscriptionBuilder } from '@/app/components/workflow/block
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { SupportedCreationMethods } from '@/app/components/plugins/types'
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
import { TriggerCredentialType } from '@/app/components/workflow/block-selector/types'
import { CommonCreateModal } from '../common-modal'
type PluginDetail = {
@ -63,7 +63,7 @@ function createMockSubscriptionBuilder(
id: 'builder-123',
name: 'Test Builder',
provider: 'test-provider',
credential_type: TriggerCredentialTypeEnum.ApiKey,
credential_type: TriggerCredentialType.ApiKey,
credentials: {},
endpoint: 'https://example.com/callback',
parameters: {},

View File

@ -9,7 +9,7 @@ import { toast } from '@langgenius/dify-ui/toast'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { SupportedCreationMethods } from '@/app/components/plugins/types'
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
import { TriggerCredentialType } from '@/app/components/workflow/block-selector/types'
import { CreateSubscriptionButton } from '../index'
import { CreateButtonType, DEFAULT_METHOD } from '../types'
@ -116,7 +116,7 @@ vi.mock('../oauth-client', () => ({
id: 'test-builder',
name: 'test',
provider: 'test-provider',
credential_type: TriggerCredentialTypeEnum.Oauth2,
credential_type: TriggerCredentialType.Oauth2,
credentials: {},
endpoint: 'https://test.com',
parameters: {},
@ -285,7 +285,7 @@ const createSubscription = (overrides: Partial<TriggerSubscription> = {}): Trigg
id: 'test-subscription',
name: 'Test Subscription',
provider: 'test-provider',
credential_type: TriggerCredentialTypeEnum.ApiKey,
credential_type: TriggerCredentialType.ApiKey,
credentials: {},
endpoint: 'https://test.com',
parameters: {},
@ -1133,7 +1133,7 @@ describe('CreateSubscriptionButton', () => {
id: 'oauth-builder',
name: 'OAuth Builder',
provider: 'test-provider',
credential_type: TriggerCredentialTypeEnum.Oauth2,
credential_type: TriggerCredentialType.Oauth2,
credentials: {},
endpoint: 'https://test.com',
parameters: {},
@ -1598,7 +1598,7 @@ describe('CreateSubscriptionButton', () => {
id: 'oauth-builder',
name: 'OAuth Builder',
provider: 'test-provider',
credential_type: TriggerCredentialTypeEnum.Oauth2,
credential_type: TriggerCredentialType.Oauth2,
credentials: {},
endpoint: 'https://test.com',
parameters: {},
@ -1770,7 +1770,7 @@ describe('CreateSubscriptionButton', () => {
id: 'oauth-builder',
name: 'OAuth Builder',
provider: 'test-provider',
credential_type: TriggerCredentialTypeEnum.Oauth2,
credential_type: TriggerCredentialType.Oauth2,
credentials: {},
endpoint: 'https://test.com',
parameters: {},

View File

@ -5,7 +5,7 @@ import type {
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
import { TriggerCredentialType } from '@/app/components/workflow/block-selector/types'
import { OAuthClientSettingsModal } from '../oauth-client'
type PluginDetail = {
@ -59,7 +59,7 @@ function createMockSubscriptionBuilder(
id: 'builder-123',
name: 'Test Builder',
provider: 'test-provider',
credential_type: TriggerCredentialTypeEnum.Oauth2,
credential_type: TriggerCredentialType.Oauth2,
credentials: {},
endpoint: 'https://example.com/callback',
parameters: {},

View File

@ -3,7 +3,7 @@ import type { TriggerSubscriptionBuilder } from '@/app/components/workflow/block
import { act, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { SupportedCreationMethods } from '@/app/components/plugins/types'
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
import { TriggerCredentialType } from '@/app/components/workflow/block-selector/types'
import { ApiKeyStep, useCommonModalState } from '../use-common-modal-state'
type MockPluginDetail = {
@ -27,7 +27,7 @@ const createMockBuilder = (
id: 'builder-1',
name: 'builder',
provider: 'provider-a',
credential_type: TriggerCredentialTypeEnum.ApiKey,
credential_type: TriggerCredentialType.ApiKey,
credentials: {},
endpoint: 'https://example.com/callback',
parameters: {},
@ -148,7 +148,7 @@ describe('useCommonModalState', () => {
expect(mockCreateBuilder).toHaveBeenCalledWith({
provider: 'provider-a',
credential_type: TriggerCredentialTypeEnum.ApiKey,
credential_type: TriggerCredentialType.ApiKey,
})
expect(result.current.currentStep).toBe(ApiKeyStep.Verify)
expect(result.current.apiKeyCredentialsSchema[0]).toMatchObject({
@ -248,7 +248,7 @@ describe('useCommonModalState', () => {
vi.useFakeTimers()
const builder = createMockBuilder({
credential_type: TriggerCredentialTypeEnum.Unauthorized,
credential_type: TriggerCredentialType.Unauthorized,
})
const { result } = renderHook(() =>
useCommonModalState({

View File

@ -4,7 +4,7 @@ import type {
} from '@/app/components/workflow/block-selector/types'
import { act, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
import { TriggerCredentialType } from '@/app/components/workflow/block-selector/types'
import {
AuthorizationStatusEnum,
ClientTypeEnum,
@ -52,7 +52,7 @@ function createMockSubscriptionBuilder(
id: 'builder-123',
name: 'Test Builder',
provider: 'test-provider',
credential_type: TriggerCredentialTypeEnum.Oauth2,
credential_type: TriggerCredentialType.Oauth2,
credentials: {},
endpoint: 'https://example.com/callback',
parameters: {},

View File

@ -12,7 +12,7 @@ import { debounce } from 'es-toolkit/compat'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SupportedCreationMethods } from '@/app/components/plugins/types'
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
import { TriggerCredentialType } from '@/app/components/workflow/block-selector/types'
import {
useBuildTriggerSubscription,
useCreateTriggerSubscriptionBuilder,
@ -42,10 +42,10 @@ export enum ApiKeyStep {
Configuration = 'configuration',
}
const CREDENTIAL_TYPE_MAP: Record<SupportedCreationMethods, TriggerCredentialTypeEnum> = {
[SupportedCreationMethods.APIKEY]: TriggerCredentialTypeEnum.ApiKey,
[SupportedCreationMethods.OAUTH]: TriggerCredentialTypeEnum.Oauth2,
[SupportedCreationMethods.MANUAL]: TriggerCredentialTypeEnum.Unauthorized,
const CREDENTIAL_TYPE_MAP: Record<SupportedCreationMethods, TriggerCredentialType> = {
[SupportedCreationMethods.APIKEY]: TriggerCredentialType.ApiKey,
[SupportedCreationMethods.OAUTH]: TriggerCredentialType.Oauth2,
[SupportedCreationMethods.MANUAL]: TriggerCredentialType.Unauthorized,
}
export const MODAL_TITLE_KEY_MAP: Record<

View File

@ -4,7 +4,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { FormTypeEnum } from '@/app/components/base/form/types'
import { PluginCategoryEnum, PluginSource } from '@/app/components/plugins/types'
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
import { TriggerCredentialType } from '@/app/components/workflow/block-selector/types'
import { ApiKeyEditModal } from '../apikey-edit-modal'
import { EditModal } from '../index'
import { ManualEditModal } from '../manual-edit-modal'
@ -191,7 +191,7 @@ const createSubscription = (overrides: Partial<TriggerSubscription> = {}): Trigg
id: 'test-subscription-id',
name: 'Test Subscription',
provider: 'test-provider',
credential_type: TriggerCredentialTypeEnum.Unauthorized,
credential_type: TriggerCredentialType.Unauthorized,
credentials: {},
endpoint: 'https://example.com/webhook',
parameters: {},
@ -317,9 +317,9 @@ describe('Edit Modal Components', () => {
describe('EditModal (Router)', () => {
it.each([
{ type: TriggerCredentialTypeEnum.Unauthorized, name: 'ManualEditModal' },
{ type: TriggerCredentialTypeEnum.Oauth2, name: 'OAuthEditModal' },
{ type: TriggerCredentialTypeEnum.ApiKey, name: 'ApiKeyEditModal' },
{ type: TriggerCredentialType.Unauthorized, name: 'ManualEditModal' },
{ type: TriggerCredentialType.Oauth2, name: 'OAuthEditModal' },
{ type: TriggerCredentialType.ApiKey, name: 'ApiKeyEditModal' },
])('should render $name for $type credential type', ({ type }) => {
render(
<EditModal
@ -335,7 +335,7 @@ describe('Edit Modal Components', () => {
<EditModal
onClose={vi.fn()}
subscription={createSubscription({
credential_type: 'unknown' as TriggerCredentialTypeEnum,
credential_type: 'unknown' as TriggerCredentialType,
})}
/>,
)
@ -707,7 +707,7 @@ describe('Edit Modal Components', () => {
const createProps = (overrides = {}) => ({
onClose: vi.fn(),
subscription: createSubscription({ credential_type: TriggerCredentialTypeEnum.Oauth2 }),
subscription: createSubscription({ credential_type: TriggerCredentialType.Oauth2 }),
...overrides,
})
@ -744,7 +744,7 @@ describe('Edit Modal Components', () => {
<OAuthEditModal
{...createProps({
subscription: createSubscription({
credential_type: TriggerCredentialTypeEnum.Oauth2,
credential_type: TriggerCredentialType.Oauth2,
parameters: { channel: 'general' },
}),
})}
@ -808,7 +808,7 @@ describe('Edit Modal Components', () => {
<OAuthEditModal
{...createProps({
subscription: createSubscription({
credential_type: TriggerCredentialTypeEnum.Oauth2,
credential_type: TriggerCredentialType.Oauth2,
parameters: { channel: 'general' },
}),
})}
@ -830,7 +830,7 @@ describe('Edit Modal Components', () => {
<OAuthEditModal
{...createProps({
subscription: createSubscription({
credential_type: TriggerCredentialTypeEnum.Oauth2,
credential_type: TriggerCredentialType.Oauth2,
parameters: { channel: 'old' },
}),
})}
@ -982,7 +982,7 @@ describe('Edit Modal Components', () => {
const createProps = (overrides = {}) => ({
onClose: vi.fn(),
subscription: createSubscription({ credential_type: TriggerCredentialTypeEnum.ApiKey }),
subscription: createSubscription({ credential_type: TriggerCredentialType.ApiKey }),
...overrides,
})
@ -1038,7 +1038,7 @@ describe('Edit Modal Components', () => {
<ApiKeyEditModal
{...createProps({
subscription: createSubscription({
credential_type: TriggerCredentialTypeEnum.ApiKey,
credential_type: TriggerCredentialType.ApiKey,
credentials: { api_key: '[__HIDDEN__]' },
}),
})}
@ -1375,7 +1375,7 @@ describe('Edit Modal Components', () => {
<ApiKeyEditModal
{...createProps({
subscription: createSubscription({
credential_type: TriggerCredentialTypeEnum.ApiKey,
credential_type: TriggerCredentialType.ApiKey,
parameters: { param1: 'value' },
}),
})}
@ -1404,7 +1404,7 @@ describe('Edit Modal Components', () => {
<ApiKeyEditModal
{...createProps({
subscription: createSubscription({
credential_type: TriggerCredentialTypeEnum.ApiKey,
credential_type: TriggerCredentialType.ApiKey,
parameters: { param1: 'old_value' },
}),
})}

View File

@ -1,7 +1,7 @@
'use client'
import type { PluginDetail } from '@/app/components/plugins/types'
import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types'
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
import { TriggerCredentialType } from '@/app/components/workflow/block-selector/types'
import { ApiKeyEditModal } from './apikey-edit-modal'
import { ManualEditModal } from './manual-edit-modal'
import { OAuthEditModal } from './oauth-edit-modal'
@ -16,7 +16,7 @@ export const EditModal = ({ onClose, subscription, pluginDetail }: Props) => {
const credentialType = subscription.credential_type
switch (credentialType) {
case TriggerCredentialTypeEnum.Unauthorized:
case TriggerCredentialType.Unauthorized:
return (
<ManualEditModal
onClose={onClose}
@ -24,11 +24,11 @@ export const EditModal = ({ onClose, subscription, pluginDetail }: Props) => {
pluginDetail={pluginDetail}
/>
)
case TriggerCredentialTypeEnum.Oauth2:
case TriggerCredentialType.Oauth2:
return (
<OAuthEditModal onClose={onClose} subscription={subscription} pluginDetail={pluginDetail} />
)
case TriggerCredentialTypeEnum.ApiKey:
case TriggerCredentialType.ApiKey:
return (
<ApiKeyEditModal
onClose={onClose}

View File

@ -1,14 +1,14 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { ToolTypeEnum } from '../../../workflow/block-selector/types'
import { ToolType } from '../../../workflow/block-selector/types'
import Empty from '../empty'
vi.mock('@/hooks/use-theme', () => ({ default: () => ({ theme: 'light' }) }))
describe('Empty', () => {
it.each([
[ToolTypeEnum.Custom, '/integrations/tools/api'],
[ToolTypeEnum.MCP, '/integrations/tools/mcp'],
[ToolType.Custom, '/integrations/tools/api'],
[ToolType.MCP, '/integrations/tools/mcp'],
])('links the %s empty state to its integration page', (type, href) => {
render(<Empty type={type} />)
@ -16,7 +16,7 @@ describe('Empty', () => {
})
it('links the workflow guide to Studio and the documentation', () => {
render(<Empty type={ToolTypeEnum.Workflow} />)
render(<Empty type={ToolType.Workflow} />)
expect(screen.getByRole('link', { name: /goToStudio/i })).toHaveAttribute('href', '/apps')
expect(screen.getByRole('link', { name: /learnMore/i })).toHaveAttribute(
@ -26,7 +26,7 @@ describe('Empty', () => {
})
it('does not offer installation navigation in an agent empty state', () => {
render(<Empty type={ToolTypeEnum.Custom} isAgent />)
render(<Empty type={ToolType.Custom} isAgent />)
expect(screen.queryByRole('link')).not.toBeInTheDocument()
})

View File

@ -7,10 +7,10 @@ import { useDocLink } from '@/context/i18n'
import useTheme from '@/hooks/use-theme'
import Link from '@/next/link'
import { NoToolPlaceholder } from '../../base/icons/src/vender/other'
import { ToolTypeEnum } from '../../workflow/block-selector/types'
import { ToolType } from '../../workflow/block-selector/types'
type Props = Readonly<{
type?: ToolTypeEnum
type?: ToolType
isAgent?: boolean
}>
@ -20,11 +20,11 @@ const workflowToolStepKeys = [
'workflowToolEmpty.step3',
] as const
const getLink = (type?: ToolTypeEnum) => {
const getLink = (type?: ToolType) => {
switch (type) {
case ToolTypeEnum.Custom:
case ToolType.Custom:
return buildIntegrationPath('custom-tool')
case ToolTypeEnum.MCP:
case ToolType.MCP:
return buildIntegrationPath('mcp')
default:
return buildIntegrationPath('custom-tool')
@ -35,7 +35,7 @@ const Empty = ({ type, isAgent }: Props) => {
const docLink = useDocLink()
const { theme } = useTheme()
const hasLink = type && [ToolTypeEnum.Custom, ToolTypeEnum.MCP].includes(type)
const hasLink = type === ToolType.Custom || type === ToolType.MCP
const renderType = isAgent ? ('agent' as const) : type
const hasTitle =
renderType &&
@ -52,7 +52,7 @@ const Empty = ({ type, isAgent }: Props) => {
</>
)
if (!isAgent && type === ToolTypeEnum.Workflow) {
if (!isAgent && type === ToolType.Workflow) {
return (
<div className="flex w-full max-w-[1060px] flex-col items-center gap-8 text-center">
<div className="flex w-full max-w-[739px] flex-col items-center gap-1">

View File

@ -1,20 +1,20 @@
import type { ThoughtItem } from '@/app/components/base/chat/chat/type'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { VisionFile } from '@/types/app'
import { ToolTypeEnum } from '../../workflow/block-selector/types'
import { ToolType } from '../../workflow/block-selector/types'
export const getToolType = (type: string) => {
switch (type) {
case 'builtin':
return ToolTypeEnum.BuiltIn
return ToolType.BuiltIn
case 'api':
return ToolTypeEnum.Custom
return ToolType.Custom
case 'workflow':
return ToolTypeEnum.Workflow
return ToolType.Workflow
case 'mcp':
return ToolTypeEnum.MCP
return ToolType.MCP
default:
return ToolTypeEnum.BuiltIn
return ToolType.BuiltIn
}
}

View File

@ -4,7 +4,7 @@ import type { BlockDefaultValue } from '@/app/components/workflow/block-selector
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import NodeSelector from '@/app/components/workflow/block-selector'
import { TabsEnum } from '@/app/components/workflow/block-selector/types'
import { TabType } from '@/app/components/workflow/block-selector/types'
import { BlockEnum } from '@/app/components/workflow/types'
import StartNodeOption from './start-node-option'
@ -49,8 +49,7 @@ const StartNodeSelectionPanel: FC<StartNodeSelectionPanelProps> = ({
offset={-200}
noBlocks={true}
showStartTab={true}
defaultActiveTab={TabsEnum.Start}
forceShowStartContent={true}
defaultActiveTab={TabType.Start}
availableBlocksTypes={[
BlockEnum.TriggerSchedule,
BlockEnum.TriggerWebhook,

View File

@ -33,6 +33,14 @@ vi.mock('@/utils/var', async (importOriginal) => ({
getMarketplaceUrl: (path = '') => `https://marketplace.test${path}`,
}))
vi.mock('../rag-tool-recommendations', () => ({
RAGToolRecommendations: ({ onLoadMore }: { onLoadMore: () => void }) => (
<button type="button" onClick={onLoadMore}>
Load more RAG tools
</button>
),
}))
const mockUseMarketplacePlugins = vi.mocked(useMarketplacePlugins)
const mockUseGetLanguage = vi.mocked(useGetLanguage)
const mockUseTheme = vi.mocked(useTheme)
@ -193,4 +201,27 @@ describe('AllTools', () => {
expect(screen.getByText('workflow.tabs.noPluginsFound')).toBeInTheDocument()
})
})
it('returns the next tag value when loading more RAG tools', async () => {
const user = userEvent.setup()
const onTagsChange = vi.fn()
render(
<AllTools
searchText=""
tags={[]}
onTagsChange={onTagsChange}
onSelect={vi.fn()}
buildInTools={[createToolProvider({ id: 'provider-built-in' })]}
customTools={[]}
workflowTools={[]}
mcpTools={[]}
isInRAGPipeline
/>,
)
await user.click(screen.getByRole('button', { name: 'Load more RAG tools' }))
expect(onTagsChange).toHaveBeenCalledWith(['rag'])
})
})

View File

@ -11,7 +11,7 @@ import { HooksStoreContext } from '../../hooks-store/provider'
import { createHooksStore } from '../../hooks-store/store'
import { BlockEnum } from '../../types'
import Blocks from '../blocks'
import { BlockClassificationEnum } from '../types'
import { BlockClassification } from '../types'
const runtimeState = vi.hoisted(() => ({
appType: 'workflow' as string | undefined,
@ -53,7 +53,7 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
const createBlock = (
type: BlockEnum,
title: string,
classification = BlockClassificationEnum.Default,
classification: BlockClassification = BlockClassification.Default,
sort = 0,
): NodeDefault => ({
metaData: {
@ -142,18 +142,20 @@ describe('Blocks', () => {
availableBlocksTypes={[BlockEnum.LLM, BlockEnum.LoopEnd, BlockEnum.KnowledgeBase]}
blocks={[
createBlock(BlockEnum.LLM, 'LLM'),
createBlock(BlockEnum.LoopEnd, 'Exit Loop', BlockClassificationEnum.Logic),
createBlock(BlockEnum.LoopEnd, 'Exit Loop', BlockClassification.Logic),
createBlock(BlockEnum.KnowledgeBase, 'Knowledge Retrieval'),
]}
/>,
)
expect(screen.getByRole('button', { name: 'LLM' })).toBeInTheDocument()
const llmButton = screen.getByRole('button', { name: 'LLM' })
expect(llmButton).toBeInTheDocument()
expect(llmButton).toHaveAccessibleDescription('LLM description')
expect(screen.getByText('Exit Loop')).toBeInTheDocument()
expect(screen.getByText('workflow.nodes.loop.loopNode')).toBeInTheDocument()
expect(screen.queryByText('Knowledge Retrieval')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'LLM' }))
await user.click(llmButton)
expect(onSelect).toHaveBeenCalledWith(BlockEnum.LLM)
})
@ -245,8 +247,8 @@ describe('Blocks', () => {
onSelect={onSelect}
availableBlocksTypes={[BlockEnum.LLM, BlockEnum.AgentV2]}
blocks={[
createBlock(BlockEnum.LLM, 'LLM', BlockClassificationEnum.Default, 0),
createBlock(BlockEnum.AgentV2, 'Agent', BlockClassificationEnum.Default, 3),
createBlock(BlockEnum.LLM, 'LLM', BlockClassification.Default, 0),
createBlock(BlockEnum.AgentV2, 'Agent', BlockClassification.Default, 3),
]}
/>
</HooksStoreContext>
@ -334,7 +336,7 @@ describe('Blocks', () => {
searchText=""
onSelect={vi.fn()}
availableBlocksTypes={[BlockEnum.AgentV2]}
blocks={[createBlock(BlockEnum.AgentV2, 'Agent', BlockClassificationEnum.Default, 3)]}
blocks={[createBlock(BlockEnum.AgentV2, 'Agent', BlockClassification.Default, 3)]}
/>
</HooksStoreContext>
</QueryClientProvider>,
@ -392,7 +394,7 @@ describe('Blocks', () => {
searchText=""
onSelect={onSelect}
availableBlocksTypes={[BlockEnum.AgentV2]}
blocks={[createBlock(BlockEnum.AgentV2, 'Agent', BlockClassificationEnum.Default, 3)]}
blocks={[createBlock(BlockEnum.AgentV2, 'Agent', BlockClassification.Default, 3)]}
/>
</HooksStoreContext>
</QueryClientProvider>,
@ -435,7 +437,7 @@ describe('Blocks', () => {
searchText=""
onSelect={onSelect}
availableBlocksTypes={[BlockEnum.AgentV2]}
blocks={[createBlock(BlockEnum.AgentV2, 'Agent', BlockClassificationEnum.Default, 3)]}
blocks={[createBlock(BlockEnum.AgentV2, 'Agent', BlockClassification.Default, 3)]}
/>
</HooksStoreContext>
</QueryClientProvider>,

View File

@ -20,6 +20,21 @@ vi.mock('@/app/components/workflow/nodes/_base/components/mcp-tool-availability'
}),
}))
vi.mock('@/app/components/workflow/block-selector/market-place-plugin/action', () => ({
default: () => <button type="button" aria-label="common.operation.more" />,
}))
vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () => ({
default: () => <div data-testid="install-from-marketplace" />,
}))
vi.mock(
'@/app/components/plugins/install-plugin/hooks/use-workspace-plugin-install-permission',
() => ({
default: () => ({ canInstallPlugin: true, currentDifyVersion: '1.0.0' }),
}),
)
vi.mock('@/utils/var', async (importOriginal) => ({
...(await importOriginal<typeof import('@/utils/var')>()),
getMarketplaceUrl: (path = '') => `https://marketplace.test${path}`,
@ -36,9 +51,9 @@ describe('FeaturedTools', () => {
mockUseTheme.mockReturnValue({ theme: Theme.light } as ReturnType<typeof useTheme>)
})
it('shows more featured tools when the list exceeds the initial quota', async () => {
it('reveals featured tools in batches and then returns to the initial list', async () => {
const user = userEvent.setup()
const plugins = Array.from({ length: 6 }, (_, index) =>
const plugins = Array.from({ length: 11 }, (_, index) =>
createPlugin({
plugin_id: `plugin-${index + 1}`,
latest_package_identifier: `plugin-${index + 1}@1.0.0`,
@ -59,12 +74,27 @@ describe('FeaturedTools', () => {
expect(screen.getByText('Provider 1')).toBeInTheDocument()
expect(screen.queryByText('Provider 6')).not.toBeInTheDocument()
await user.click(screen.getByText('workflow.tabs.showMoreFeatured'))
const showMoreButton = screen.getByRole('button', {
name: 'workflow.tabs.showMoreFeatured',
})
expect(showMoreButton).not.toHaveAttribute('aria-expanded')
expect(screen.getByText('Provider 6')).toBeInTheDocument()
await user.click(showMoreButton)
expect(screen.getByText('Provider 10')).toBeInTheDocument()
expect(screen.queryByText('Provider 11')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'workflow.tabs.showMoreFeatured' }))
expect(screen.getByText('Provider 11')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'workflow.tabs.showLessFeatured' }))
expect(screen.queryByText('Provider 6')).not.toBeInTheDocument()
})
it('honors the persisted collapsed state', () => {
it('restores the collapsed state and expands from the keyboard', async () => {
const user = userEvent.setup()
localStorage.setItem('workflow_tools_featured_collapsed', 'true')
render(
@ -75,8 +105,44 @@ describe('FeaturedTools', () => {
/>,
)
expect(screen.getByText('workflow.tabs.featuredTools')).toBeInTheDocument()
const trigger = screen.getByRole('button', { name: 'workflow.tabs.featuredTools' })
expect(trigger).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByText('Provider One')).not.toBeInTheDocument()
trigger.focus()
await user.keyboard('{Enter}')
expect(trigger).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByText('Provider One')).toBeInTheDocument()
expect(globalThis.localStorage.setItem).toHaveBeenCalledWith(
'workflow_tools_featured_collapsed',
'false',
)
})
it('keeps the marketplace link and row actions keyboard reachable', async () => {
const user = userEvent.setup()
render(
<FeaturedTools
plugins={[createPlugin({ name: 'plugin-one' })]}
providerMap={new Map()}
onSelect={vi.fn()}
/>,
)
const detailsLink = screen.getByRole('link', { name: 'Plugin One' })
const installButton = screen.getByRole('button', { name: 'plugin.installAction' })
const moreButton = screen.getByRole('button', { name: 'common.operation.more' })
expect(detailsLink).toHaveAttribute('href', 'https://marketplace.test/plugins/org/plugin-one')
detailsLink.focus()
await user.tab()
expect(installButton).toHaveFocus()
await user.tab()
expect(moreButton).toHaveFocus()
})
it('shows the marketplace empty state when no featured tools are available', () => {

View File

@ -14,13 +14,20 @@ vi.mock('@/hooks/use-theme', () => ({
}))
vi.mock('@/app/components/workflow/block-selector/market-place-plugin/action', () => ({
default: () => <div data-testid="marketplace-action" />,
default: () => <button type="button" aria-label="common.operation.more" />,
}))
vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () => ({
default: () => <div data-testid="install-from-marketplace" />,
}))
vi.mock(
'@/app/components/plugins/install-plugin/hooks/use-workspace-plugin-install-permission',
() => ({
default: () => ({ canInstallPlugin: true, currentDifyVersion: '1.0.0' }),
}),
)
vi.mock('@/utils/var', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/utils/var')>()
return {
@ -106,7 +113,12 @@ describe('FeaturedTriggers', () => {
render(<FeaturedTriggers plugins={[]} providerMap={new Map()} onSelect={vi.fn()} />)
await user.click(screen.getByRole('button', { name: /workflow\.tabs\.featuredTools/ }))
const trigger = screen.getByRole('button', { name: /workflow\.tabs\.featuredTools/ })
expect(trigger).toHaveAttribute('aria-expanded', 'true')
await user.click(trigger)
expect(trigger).toHaveAttribute('aria-expanded', 'false')
expect(
screen.queryByRole('link', { name: 'workflow.tabs.noFeaturedTriggers' }),
@ -117,9 +129,9 @@ describe('FeaturedTriggers', () => {
)
})
it('should show more and show less across installed providers', async () => {
it('should reveal installed providers in batches and then return to the initial list', async () => {
const user = userEvent.setup()
const providers = Array.from({ length: 6 }).map((_, index) =>
const providers = Array.from({ length: 11 }).map((_, index) =>
createTriggerProvider({
id: `provider-${index}`,
name: `provider-${index}`,
@ -141,10 +153,19 @@ describe('FeaturedTriggers', () => {
expect(screen.getByText('Provider 4')).toBeInTheDocument()
expect(screen.queryByText('Provider 5')).not.toBeInTheDocument()
await user.click(screen.getByText('workflow.tabs.showMoreFeatured'))
expect(screen.getByText('Provider 5')).toBeInTheDocument()
const showMoreButton = screen.getByRole('button', {
name: 'workflow.tabs.showMoreFeatured',
})
expect(showMoreButton).not.toHaveAttribute('aria-expanded')
await user.click(screen.getByText('workflow.tabs.showLessFeatured'))
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()
await user.click(screen.getByRole('button', { name: 'workflow.tabs.showLessFeatured' }))
expect(screen.queryByText('Provider 5')).not.toBeInTheDocument()
})
})
@ -192,38 +213,28 @@ describe('FeaturedTriggers', () => {
)
})
it('should align featured item icons with the trigger list column', () => {
const provider = createTriggerProvider()
it('should keep the marketplace link and row actions keyboard reachable', async () => {
const user = userEvent.setup()
render(
<FeaturedTriggers
plugins={[
createPlugin({ plugin_id: 'plugin-1', latest_package_identifier: 'plugin-1@1.0.0' }),
createPlugin({
name: 'plugin-two',
plugin_id: 'plugin-2',
latest_package_identifier: 'plugin-2@1.0.0',
label: { en_US: 'Plugin Two', zh_Hans: '插件二' },
}),
]}
providerMap={
new Map([
['plugin-1', provider],
['plugin-1@1.0.0', provider],
])
}
onSelect={vi.fn()}
/>,
<FeaturedTriggers plugins={[createPlugin()]} providerMap={new Map()} onSelect={vi.fn()} />,
)
const installedRow = screen.getByText('Provider One').closest('.select-none')
expect(installedRow).toHaveClass('h-8', 'pr-2', 'pl-3')
expect(installedRow?.parentElement?.parentElement?.parentElement).toHaveClass('p-1')
const detailsLink = screen.getByRole('link', { name: 'Plugin One' })
const installButton = screen.getByRole('button', { name: 'plugin.installAction' })
const moreButton = screen.getByRole('button', { name: 'common.operation.more' })
const uninstalledRow = screen.getByText('Plugin Two').closest('.group')
expect(uninstalledRow).toHaveClass('h-8', 'pr-2', 'pl-3')
expect(uninstalledRow?.parentElement).toHaveClass('mb-1', 'last-of-type:mb-0')
expect(uninstalledRow?.parentElement?.parentElement).toHaveClass('p-1')
expect(detailsLink).toHaveAttribute(
'href',
'https://marketplace.test/plugins/org/trigger-plugin',
)
detailsLink.focus()
await user.tab()
expect(installButton).toHaveFocus()
await user.tab()
expect(moreButton).toHaveFocus()
})
})
})

View File

@ -1,6 +1,6 @@
import { act, renderHook } from '@testing-library/react'
import { renderHook } from '@testing-library/react'
import { useTabs, useToolTabs } from '../hooks'
import { TabsEnum, ToolTypeEnum } from '../types'
import { TabType, ToolType } from '../types'
describe('block-selector hooks', () => {
beforeEach(() => {
@ -11,12 +11,12 @@ describe('block-selector hooks', () => {
const { result } = renderHook(() =>
useTabs({
noStart: false,
defaultActiveTab: TabsEnum.Start,
defaultActiveTab: TabType.Start,
}),
)
expect(result.current.tabs.find((tab) => tab.key === TabsEnum.Start)?.disabled).toBeFalsy()
expect(result.current.activeTab).toBe(TabsEnum.Start)
expect(result.current.tabs.find((tab) => tab.key === TabType.Start)?.disabled).toBeFalsy()
expect(result.current.initialTab).toBe(TabType.Start)
})
it('disables the start tab when an unconfigured start placeholder exists', () => {
@ -24,87 +24,54 @@ describe('block-selector hooks', () => {
useTabs({
noStart: false,
hasStartPlaceholderNode: true,
defaultActiveTab: TabsEnum.Start,
defaultActiveTab: TabType.Start,
}),
)
const startTab = result.current.tabs.find((tab) => tab.key === TabsEnum.Start)
const startTab = result.current.tabs.find((tab) => tab.key === TabType.Start)
expect(startTab?.disabled).toBe(true)
expect(startTab?.disabledTip).toBe('workflow.tabs.unconfiguredStartDisabledTip')
expect(startTab?.disabledTipLinkKey).toBe('startNodesDocs')
expect(result.current.activeTab).toBe(TabsEnum.Blocks)
expect(result.current.initialTab).toBe(TabType.Blocks)
})
it('keeps the start tab enabled when forcing it on and resets to a valid tab after disabling blocks', () => {
const props: Parameters<typeof useTabs>[0] = {
noBlocks: false,
noStart: false,
forceEnableStartTab: true,
}
it('chooses the enabled start panel when blocks, sources, and tools are unavailable', () => {
const { result } = renderHook(() =>
useTabs({
noBlocks: true,
noSources: true,
noTools: true,
noStart: false,
forceEnableStartTab: true,
}),
)
const { result, rerender } = renderHook((nextProps) => useTabs(nextProps), {
initialProps: props,
})
expect(result.current.tabs.find((tab) => tab.key === TabsEnum.Start)?.disabled).toBeFalsy()
act(() => {
result.current.setActiveTab(TabsEnum.Blocks)
})
rerender({
...props,
noBlocks: true,
noSources: true,
noTools: true,
})
expect(result.current.activeTab).toBe(TabsEnum.Start)
expect(result.current.tabs.find((tab) => tab.key === TabType.Start)?.disabled).toBeFalsy()
expect(result.current.initialTab).toBe(TabType.Start)
})
it('returns the MCP tab only when it is not hidden', () => {
const { result: visible } = renderHook(() => useToolTabs())
const { result: hidden } = renderHook(() => useToolTabs(true))
expect(visible.current.some((tab) => tab.key === ToolTypeEnum.MCP)).toBe(true)
expect(hidden.current.some((tab) => tab.key === ToolTypeEnum.MCP)).toBe(false)
expect(visible.current.some((tab) => tab.key === ToolType.MCP)).toBe(true)
expect(hidden.current.some((tab) => tab.key === ToolType.MCP)).toBe(false)
})
it('includes the snippets tab by default', () => {
const { result } = renderHook(() => useTabs({}))
expect(result.current.tabs.some((tab) => tab.key === TabsEnum.Snippets)).toBe(true)
expect(result.current.tabs.some((tab) => tab.key === TabType.Snippets)).toBe(true)
})
it('hides the snippets tab and falls back when snippets are disabled', () => {
const { result } = renderHook(() =>
useTabs({
defaultActiveTab: TabsEnum.Snippets,
defaultActiveTab: TabType.Snippets,
noSnippets: true,
}),
)
expect(result.current.tabs.some((tab) => tab.key === TabsEnum.Snippets)).toBe(false)
expect(result.current.activeTab).toBe(TabsEnum.Blocks)
})
it('resets the active tab to the current default tab', () => {
const { result } = renderHook(() =>
useTabs({
noStart: false,
}),
)
act(() => {
result.current.setActiveTab(TabsEnum.Start)
})
expect(result.current.activeTab).toBe(TabsEnum.Start)
act(() => {
result.current.resetActiveTab()
})
expect(result.current.activeTab).toBe(TabsEnum.Blocks)
expect(result.current.tabs.some((tab) => tab.key === TabType.Snippets)).toBe(false)
expect(result.current.initialTab).toBe(TabType.Blocks)
})
})

View File

@ -3,7 +3,7 @@ import { screen } from '@testing-library/react'
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
import { BlockEnum } from '../../types'
import NodeSelectorWrapper from '../index'
import { BlockClassificationEnum } from '../types'
import { BlockClassification } from '../types'
vi.mock('reactflow', async () =>
(await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock(),
@ -29,7 +29,7 @@ const createBlock = (type: BlockEnum, title: string): NodeDefault => ({
type,
title,
sort: 0,
classification: BlockClassificationEnum.Default,
classification: BlockClassification.Default,
author: 'Dify',
description: `${title} description`,
},

View File

@ -7,7 +7,7 @@ import { FlowType } from '@/types/common'
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
import { BlockEnum } from '../../types'
import NodeSelector from '../main'
import { BlockClassificationEnum, TabsEnum } from '../types'
import { BlockClassification, TabType } from '../types'
vi.mock('reactflow', () => ({
useStoreApi: () => ({
@ -50,7 +50,7 @@ vi.mock('@/service/use-tools', () => ({
const createBlock = (type: BlockEnum, title: string): NodeDefault => ({
metaData: {
classification: BlockClassificationEnum.Default,
classification: BlockClassification.Default,
sort: 0,
type,
title,
@ -153,6 +153,63 @@ describe('NodeSelector', () => {
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()
@ -307,7 +364,7 @@ describe('NodeSelector', () => {
blocks={[createBlock(BlockEnum.LLM, 'LLM')]}
availableBlocksTypes={[BlockEnum.LLM, BlockEnum.Start]}
showStartTab
defaultActiveTab={TabsEnum.Start}
defaultActiveTab={TabType.Start}
/>,
{
initialStoreState: {
@ -328,9 +385,7 @@ describe('NodeSelector', () => {
expect(
await screen.findByText('workflow.tabs.unconfiguredStartDisabledTip'),
).toBeInTheDocument()
expect(
screen.getByRole('link', { name: 'workflow.tabs.startDisabledTipLearnMore' }),
).toHaveAttribute('href', 'https://docs.dify.ai/en/self-host/use-dify/nodes/trigger/overview')
expect(screen.queryByRole('link')).not.toBeInTheDocument()
expect(screen.getByPlaceholderText('workflow.tabs.searchBlock')).toBeInTheDocument()
})
@ -342,7 +397,7 @@ describe('NodeSelector', () => {
blocks={[createBlock(BlockEnum.LLM, 'LLM')]}
availableBlocksTypes={[BlockEnum.LLM, BlockEnum.Start, BlockEnum.TriggerPlugin]}
showStartTab
defaultActiveTab={TabsEnum.Start}
defaultActiveTab={TabType.Start}
/>,
{
initialStoreState: {

View File

@ -50,12 +50,16 @@ describe('StartBlocks', () => {
/>,
)
expect(screen.getByText('workflow.blocks.start')).toBeInTheDocument()
const userInputButton = screen.getByRole('button', { name: 'workflow.blocks.start' })
expect(userInputButton).toBeInTheDocument()
expect(userInputButton).toHaveAccessibleDescription(
'workflow.nodes.start.userInputTipDescription',
)
expect(screen.getByText('workflow.blocks.trigger-webhook')).toBeInTheDocument()
expect(screen.getByText('workflow.blocks.originalStartNode')).toBeInTheDocument()
expect(onContentStateChange).toHaveBeenCalledWith(true)
await user.click(screen.getByText('workflow.blocks.start'))
await user.click(userInputButton)
expect(onSelect).toHaveBeenCalledWith(BlockEnum.Start)
})

View File

@ -1,9 +1,9 @@
import { fireEvent, screen } from '@testing-library/react'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import Tabs from '../tabs'
import { TabsEnum } from '../types'
import { SelectorContent } from '../tabs'
import { TabType } from '../types'
const render = (ui: React.ReactElement) =>
renderWithSystemFeatures(ui, { systemFeatures: { enable_marketplace: true } })
@ -60,15 +60,15 @@ vi.mock('../data-sources', () => ({
vi.mock('../all-tools', () => ({
default: (props: {
buildInTools: Array<{ icon: string | Record<string, string> }>
buildInTools: Array<{ icon: string | Record<string, string>; name: string }>
showFeatured: boolean
featuredLoading: boolean
onFeaturedInstallSuccess: () => Promise<void>
}) => (
<div>
tools-content
{props.buildInTools.map((tool, index) => (
<span key={index}>{typeof tool.icon === 'string' ? tool.icon : 'object-icon'}</span>
{props.buildInTools.map((tool) => (
<span key={tool.name}>{typeof tool.icon === 'string' ? tool.icon : 'object-icon'}</span>
))}
<span>{props.showFeatured ? 'featured-on' : 'featured-off'}</span>
<span>{props.featuredLoading ? 'featured-loading' : 'featured-idle'}</span>
@ -87,67 +87,112 @@ describe('Tabs', () => {
})
const baseProps = {
activeTab: TabsEnum.Start,
onActiveTabChange: vi.fn(),
searchText: '',
tags: [],
onTagsChange: vi.fn(),
defaultTab: TabType.Start,
searchInputRef: React.createRef<HTMLInputElement>(),
onSelect: vi.fn(),
onRequestClose: vi.fn(),
blocks: [],
tabs: [
{ key: TabsEnum.Start, name: 'Start' },
{ key: TabsEnum.Blocks, name: 'Blocks', disabled: true },
{ key: TabsEnum.Tools, name: 'Tools' },
{ key: TabType.Start, name: 'Start' },
{ key: TabType.Blocks, name: 'Blocks', disabled: true },
{ key: TabType.Tools, name: 'Tools' },
],
filterElem: <div>filter</div>,
}
it('should render start content and disabled tab tooltip text', async () => {
const user = userEvent.setup()
render(<Tabs {...baseProps} />)
render(<SelectorContent {...baseProps} />)
expect(screen.getByText('start-content'))!.toBeInTheDocument()
await user.hover(screen.getByText('Blocks'))
await user.hover(screen.getByRole('tab', { name: 'Blocks' }))
expect(await screen.findByText('workflow.tabs.startDisabledTip'))!.toBeInTheDocument()
})
it('should switch tabs through click handlers and render tools content with normalized icons', () => {
const onActiveTabChange = vi.fn()
it('should expose tab semantics and use manual keyboard activation', async () => {
const user = userEvent.setup()
render(<SelectorContent {...baseProps} />)
render(<Tabs {...baseProps} activeTab={TabsEnum.Tools} onActiveTabChange={onActiveTabChange} />)
const startTab = screen.getByRole('tab', { name: 'Start' })
const blocksTab = screen.getByRole('tab', { name: 'Blocks' })
const toolsTab = screen.getByRole('tab', { name: 'Tools' })
fireEvent.click(screen.getByText('Start'))
expect(screen.getByRole('tablist')).toBeInTheDocument()
expect(startTab).toHaveAttribute('aria-selected', 'true')
expect(blocksTab).toHaveAttribute('aria-disabled', 'true')
expect(screen.getByRole('tabpanel', { name: 'Start' })).toBeInTheDocument()
await user.click(startTab)
await user.keyboard('{ArrowRight}')
expect(blocksTab).toHaveFocus()
expect(startTab).toHaveAttribute('aria-selected', 'true')
await user.keyboard('{ArrowRight}')
expect(toolsTab).toHaveFocus()
expect(startTab).toHaveAttribute('aria-selected', 'true')
})
it('should sync normalized tools into workflow store state', () => {
render(<SelectorContent {...baseProps} defaultTab={TabType.Tools} />)
expect(onActiveTabChange).toHaveBeenCalledWith(TabsEnum.Start)
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()
})
it('should sync normalized tools into workflow store state', () => {
render(<Tabs {...baseProps} activeTab={TabsEnum.Tools} />)
expect(mockSetState).toHaveBeenCalled()
})
it('should ignore clicks on disabled and already active tabs', async () => {
const user = userEvent.setup()
const onActiveTabChange = vi.fn()
render(<SelectorContent {...baseProps} />)
render(<Tabs {...baseProps} activeTab={TabsEnum.Start} onActiveTabChange={onActiveTabChange} />)
const startTab = screen.getByRole('tab', { name: 'Start' })
await user.click(startTab)
await user.click(screen.getByRole('tab', { name: 'Blocks' }))
await user.click(screen.getByText('Start'))
await user.click(screen.getByText('Blocks'))
expect(startTab).toHaveAttribute('aria-selected', 'true')
})
expect(onActiveTabChange).not.toHaveBeenCalled()
it('should fall back to an available tab when the active tab is removed', async () => {
const user = userEvent.setup()
function Harness() {
const [tabs, setTabs] = React.useState(baseProps.tabs)
return (
<>
<button
type="button"
onClick={() =>
setTabs((currentTabs) => currentTabs.filter((tab) => tab.key !== TabType.Tools))
}
>
Remove tools
</button>
<SelectorContent {...baseProps} tabs={tabs} />
</>
)
}
render(<Harness />)
await user.click(screen.getByRole('tab', { name: 'Tools' }))
await waitFor(() => {
expect(screen.getByRole('tab', { name: 'Tools' })).toHaveAttribute('aria-selected', 'true')
})
await user.click(screen.getByRole('button', { name: 'Remove tools' }))
await waitFor(() => {
expect(screen.getByRole('tab', { name: 'Start' })).toHaveAttribute('aria-selected', 'true')
})
})
it('should render sources content when the sources tab is active and data sources are provided', () => {
render(
<Tabs
<SelectorContent
{...baseProps}
activeTab={TabsEnum.Sources}
defaultTab={TabType.Sources}
tabs={[...baseProps.tabs, { key: TabType.Sources, name: 'Sources' }]}
dataSources={[{ name: 'dataset', icon: '/dataset.svg' } as never]}
/>,
)
@ -158,7 +203,7 @@ describe('Tabs', () => {
it('should keep the previous workflow store state when tool references do not change', () => {
mockToolsState.buildInTools = [{ icon: '/console/already-prefixed.svg', name: 'tool' }]
render(<Tabs {...baseProps} activeTab={TabsEnum.Tools} />)
render(<SelectorContent {...baseProps} defaultTab={TabType.Tools} />)
const previousState = {
buildInTools: mockToolsState.buildInTools,
@ -179,7 +224,7 @@ describe('Tabs', () => {
mockToolsState.workflowTools = [{ icon: '/workflow.svg', name: 'workflow' }]
mockToolsState.mcpTools = [{ icon: '/mcp.svg', name: 'mcp' }]
render(<Tabs {...baseProps} activeTab={TabsEnum.Tools} />)
render(<SelectorContent {...baseProps} defaultTab={TabType.Tools} />)
expect(screen.getByText('object-icon'))!.toBeInTheDocument()
@ -213,7 +258,7 @@ describe('Tabs', () => {
it('should skip normalization when a tool list is undefined', () => {
mockToolsState.buildInTools = undefined
render(<Tabs {...baseProps} activeTab={TabsEnum.Tools} />)
render(<SelectorContent {...baseProps} defaultTab={TabType.Tools} />)
expect(screen.getByText('tools-content'))!.toBeInTheDocument()
})
@ -221,7 +266,7 @@ describe('Tabs', () => {
it('should force start content to render and invalidate built-in tools after featured installs', async () => {
const user = userEvent.setup()
render(<Tabs {...baseProps} activeTab={TabsEnum.Tools} />)
render(<SelectorContent {...baseProps} defaultTab={TabType.Tools} />)
await user.click(screen.getByRole('button', { name: 'Install featured tool' }))
@ -229,9 +274,10 @@ describe('Tabs', () => {
expect(mockInvalidateBuiltInTools).toHaveBeenCalledTimes(1)
})
it('should render start content when blocks are hidden but forceShowStartContent is enabled', () => {
render(<Tabs {...baseProps} activeTab={TabsEnum.Start} noBlocks forceShowStartContent />)
it('should compose start content directly without tab semantics in standalone mode', () => {
render(<SelectorContent {...baseProps} standalonePanel={TabType.Start} />)
expect(screen.getByText('start-content'))!.toBeInTheDocument()
expect(screen.queryByRole('tablist')).not.toBeInTheDocument()
})
})

View File

@ -1,4 +1,4 @@
import type { Dispatch, RefObject, SetStateAction } from 'react'
import type { RefObject } from 'react'
import type { Plugin } from '../../plugins/types'
import type { BlockEnum, ToolWithProvider } from '../types'
import type { ToolDefaultValue, ToolValue } from './types'
@ -24,9 +24,9 @@ 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 { RAGToolRecommendations } from './rag-tool-recommendations'
import Tools from './tools'
import { ToolTypeEnum } from './types'
import { ToolType } from './types'
import ViewTypeSelect, { ViewType } from './view-type-select'
const marketplaceFooterClassName =
@ -45,7 +45,7 @@ type AllToolsProps = {
canNotSelectMultiple?: boolean
onSelectMultiple?: (type: BlockEnum, tools: ToolDefaultValue[]) => void
selectedTools?: ToolValue[]
onTagsChange?: Dispatch<SetStateAction<string[]>>
onTagsChange?: (tags: string[]) => void
isInRAGPipeline?: boolean
featuredPlugins?: Plugin[]
featuredLoading?: boolean
@ -78,12 +78,16 @@ const AllTools = ({
const { t } = useTranslation()
const language = useGetLanguage()
const tabs = useToolTabs()
const [activeTab, setActiveTab] = useState(ToolTypeEnum.All)
const [activeTab, setActiveTab] = useState<ToolType>(ToolType.All)
const [activeView, setActiveView] = useState<ViewType>(ViewType.flat)
const trimmedSearchText = searchText.trim()
const hasSearchText = trimmedSearchText.length > 0
const hasTags = tags.length > 0
const hasFilter = hasSearchText || hasTags
const handleLoadMoreRAGTools = () => {
if (!onTagsChange || tags.includes('rag')) return
onTagsChange([...tags, 'rag'])
}
const isMatchingKeywords = (text: string, keywords: string) => {
return text.toLowerCase().includes(keywords.toLowerCase())
}
@ -101,12 +105,12 @@ const AllTools = ({
}, [allProviders])
const tools = useMemo(() => {
let mergedTools: ToolWithProvider[] = []
if (activeTab === ToolTypeEnum.All)
if (activeTab === ToolType.All)
mergedTools = [...buildInTools, ...customTools, ...workflowTools, ...mcpTools]
if (activeTab === ToolTypeEnum.BuiltIn) mergedTools = buildInTools
if (activeTab === ToolTypeEnum.Custom) mergedTools = customTools
if (activeTab === ToolTypeEnum.Workflow) mergedTools = workflowTools
if (activeTab === ToolTypeEnum.MCP) mergedTools = mcpTools
if (activeTab === ToolType.BuiltIn) mergedTools = buildInTools
if (activeTab === ToolType.Custom) mergedTools = customTools
if (activeTab === ToolType.Workflow) mergedTools = workflowTools
if (activeTab === ToolType.MCP) mergedTools = mcpTools
const normalizedSearch = trimmedSearchText.toLowerCase()
const getLocalizedText = (text?: Record<string, string> | null) => {
@ -182,9 +186,9 @@ const AllTools = ({
const pluginRef = useRef<ListRef>(null)
const wrapElemRef = useRef<HTMLDivElement>(null)
const isSupportGroupView = [ToolTypeEnum.All, ToolTypeEnum.BuiltIn].includes(activeTab)
const isSupportGroupView = activeTab === ToolType.All || activeTab === ToolType.BuiltIn
const isShowRAGRecommendations = isInRAGPipeline && activeTab === ToolTypeEnum.All && !hasFilter
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
@ -192,7 +196,7 @@ const AllTools = ({
showFeatured &&
enable_marketplace &&
!isInRAGPipeline &&
activeTab === ToolTypeEnum.All &&
activeTab === ToolType.All &&
!hasFilter
const shouldShowMarketplaceFooter = enable_marketplace && !hasFilter
@ -204,10 +208,10 @@ const AllTools = ({
[onSelect],
)
const toolsListTitle = useMemo(() => {
if (activeTab === ToolTypeEnum.BuiltIn) return t(($) => $.allToolPlugins, { ns: 'tools' })
if (activeTab === ToolTypeEnum.Custom) return t(($) => $.allSwaggerAPIAsTool, { ns: 'tools' })
if (activeTab === ToolTypeEnum.Workflow) return t(($) => $.allWorkflowAsTool, { ns: 'tools' })
if (activeTab === ToolTypeEnum.MCP) return t(($) => $.allMCP, { ns: 'tools' })
if (activeTab === ToolType.BuiltIn) return t(($) => $.allToolPlugins, { ns: 'tools' })
if (activeTab === ToolType.Custom) return t(($) => $.allSwaggerAPIAsTool, { ns: 'tools' })
if (activeTab === ToolType.Workflow) return t(($) => $.allWorkflowAsTool, { ns: 'tools' })
if (activeTab === ToolType.MCP) return t(($) => $.allMCP, { ns: 'tools' })
return t(($) => $.allTools, { ns: 'tools' })
}, [activeTab, t])
@ -216,17 +220,19 @@ const AllTools = ({
<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) => (
<div
<button
type="button"
className={cn(
'flex h-6 cursor-pointer items-center rounded-md px-2 hover:bg-state-base-hover',
'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',
'text-xs font-medium text-text-secondary',
activeTab === tab.key && 'bg-state-base-hover-alt',
)}
key={tab.key}
aria-pressed={activeTab === tab.key}
onClick={() => setActiveTab(tab.key)}
>
{tab.name}
</div>
</button>
))}
</div>
{isSupportGroupView && <ViewTypeSelect viewType={activeView} onChange={setActiveView} />}
@ -242,7 +248,7 @@ const AllTools = ({
<RAGToolRecommendations
viewType={isSupportGroupView ? activeView : ViewType.flat}
onSelect={handleRAGSelect}
onTagsChange={onTagsChange}
onLoadMore={handleLoadMoreRAGTools}
/>
)}
{shouldShowFeatured && (

View File

@ -1,13 +1,12 @@
import type { NodeDefault, OnSelectBlock } from '../types'
import type { BlockClassificationEnum } from './types'
import type { BlockClassification } from './types'
import {
createPreviewCardHandle,
PreviewCard,
PreviewCardContent,
PreviewCardTrigger,
} from '@langgenius/dify-ui/preview-card'
import { groupBy } from 'es-toolkit/compat'
import { memo, useCallback, useMemo } from 'react'
import { Fragment, memo, useCallback, useId, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useStoreApi } from 'reactflow'
import Badge from '@/app/components/base/badge'
@ -16,6 +15,7 @@ import { BlockEnum } from '../types'
import { AgentBlockItem } from './agent-selector'
import { BLOCK_CLASSIFICATIONS } from './constants'
import { useBlocks } from './hooks'
import { BlockSelectorPreviewCardContent } from './preview-card'
type BlocksProps = {
searchText: string
@ -37,6 +37,7 @@ const Blocks = ({
const store = useStoreApi()
const blocksFromHooks = useBlocks()
const previewCardHandle = useMemo(() => createPreviewCardHandle<BlockPreviewPayload>(), [])
const previewDescriptionBaseId = useId()
// Use external blocks if provided, otherwise fallback to hook-based blocks
const blocks =
@ -89,7 +90,7 @@ const Blocks = ({
const isEmpty = Object.values(groups).every((list) => !list.length)
const renderGroup = useCallback(
(classification: BlockClassificationEnum) => {
(classification: BlockClassification) => {
const list = [...groups[classification]!].sort((a, b) => {
if (a.metaData.type === BlockEnum.AgentV2) return -1
if (b.metaData.type === BlockEnum.AgentV2) return 1
@ -139,38 +140,49 @@ const Blocks = ({
)
}
const previewDescriptionId = block.metaData.description
? `${previewDescriptionBaseId}-${block.metaData.type}`
: undefined
return (
<PreviewCardTrigger
key={block.metaData.type}
delay={150}
closeDelay={150}
handle={previewCardHandle}
payload={{ block }}
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"
onClick={() => onSelect(block.metaData.type)}
>
<BlockIcon className="mr-2 shrink-0" type={block.metaData.type} />
<span className="min-w-0 grow truncate text-sm text-text-secondary">
{block.metaData.title}
</span>
{block.metaData.type === BlockEnum.LoopEnd && (
<Badge
text={t(($) => $['nodes.loop.loopNode'], { ns: 'workflow' })}
className="ml-2 shrink-0"
/>
)}
</button>
}
/>
<Fragment key={block.metaData.type}>
<PreviewCardTrigger
delay={150}
closeDelay={150}
handle={previewCardHandle}
payload={{ block }}
render={
<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"
onClick={() => onSelect(block.metaData.type)}
>
<BlockIcon className="mr-2 shrink-0" type={block.metaData.type} />
<span className="min-w-0 grow truncate text-sm text-text-secondary">
{block.metaData.title}
</span>
{block.metaData.type === BlockEnum.LoopEnd && (
<Badge
text={t(($) => $['nodes.loop.loopNode'], { ns: 'workflow' })}
className="ml-2 shrink-0"
/>
)}
</button>
}
/>
{previewDescriptionId && (
<span id={previewDescriptionId} className="sr-only">
{block.metaData.description}
</span>
)}
</Fragment>
)
})}
</div>
)
},
[groups, onSelect, previewCardHandle, t, store],
[groups, onSelect, previewCardHandle, previewDescriptionBaseId, t, store],
)
return (
@ -198,15 +210,13 @@ function BlockPreviewCard({ payload }: BlockPreviewCardProps) {
const { block } = payload
return (
<PreviewCardContent placement="right" popupClassName="w-[200px] border-none px-3 py-2">
<div>
<BlockIcon size="md" className="mb-2" type={block.metaData.type} />
<div className="mb-1 system-md-medium text-text-primary">{block.metaData.title}</div>
<div className="system-xs-regular wrap-break-word text-text-tertiary">
{block.metaData.description}
</div>
<BlockSelectorPreviewCardContent>
<BlockIcon size="md" className="mb-2" type={block.metaData.type} />
<div className="mb-1 system-md-medium text-text-primary">{block.metaData.title}</div>
<div className="system-xs-regular wrap-break-word text-text-tertiary">
{block.metaData.description}
</div>
</PreviewCardContent>
</BlockSelectorPreviewCardContent>
)
}

View File

@ -1,13 +1,13 @@
import type { Block } from '../types'
import { BlockEnum } from '../types'
import { BlockClassificationEnum } from './types'
import { BlockClassification } from './types'
export const BLOCK_CLASSIFICATIONS = [
BlockClassificationEnum.Default,
BlockClassificationEnum.QuestionUnderstand,
BlockClassificationEnum.Logic,
BlockClassificationEnum.Transform,
BlockClassificationEnum.Utilities,
BlockClassification.Default,
BlockClassification.QuestionUnderstand,
BlockClassification.Logic,
BlockClassification.Transform,
BlockClassification.Utilities,
] as const
export const DEFAULT_FILE_EXTENSIONS_IN_LOCAL_FILE_DATA_SOURCE = [
@ -34,19 +34,19 @@ export const DEFAULT_FILE_EXTENSIONS_IN_LOCAL_FILE_DATA_SOURCE = [
export const START_BLOCKS = [
{
classification: BlockClassificationEnum.Default,
classification: BlockClassification.Default,
type: BlockEnum.Start,
title: 'User Input',
description: 'Traditional start node for user input',
},
{
classification: BlockClassificationEnum.Default,
classification: BlockClassification.Default,
type: BlockEnum.TriggerSchedule,
title: 'Schedule Trigger',
description: 'Time-based workflow trigger',
},
{
classification: BlockClassificationEnum.Default,
classification: BlockClassification.Default,
type: BlockEnum.TriggerWebhook,
title: 'Webhook Trigger',
description: 'HTTP callback trigger',
@ -62,98 +62,98 @@ export const ENTRY_NODE_TYPES = [
export const BLOCKS = [
{
classification: BlockClassificationEnum.Default,
classification: BlockClassification.Default,
type: BlockEnum.Agent,
title: 'Agent',
},
{
classification: BlockClassificationEnum.Default,
classification: BlockClassification.Default,
type: BlockEnum.AgentV2,
title: 'Agent',
},
{
classification: BlockClassificationEnum.Default,
classification: BlockClassification.Default,
type: BlockEnum.LLM,
title: 'LLM',
},
{
classification: BlockClassificationEnum.Default,
classification: BlockClassification.Default,
type: BlockEnum.KnowledgeRetrieval,
title: 'Knowledge Retrieval',
},
{
classification: BlockClassificationEnum.Default,
classification: BlockClassification.Default,
type: BlockEnum.End,
title: 'End',
},
{
classification: BlockClassificationEnum.Default,
classification: BlockClassification.Default,
type: BlockEnum.Answer,
title: 'Direct Answer',
},
{
classification: BlockClassificationEnum.QuestionUnderstand,
classification: BlockClassification.QuestionUnderstand,
type: BlockEnum.QuestionClassifier,
title: 'Question Classifier',
},
{
classification: BlockClassificationEnum.Logic,
classification: BlockClassification.Logic,
type: BlockEnum.IfElse,
title: 'IF/ELSE',
},
{
classification: BlockClassificationEnum.Logic,
classification: BlockClassification.Logic,
type: BlockEnum.LoopEnd,
title: 'Exit Loop',
description: '',
},
{
classification: BlockClassificationEnum.Logic,
classification: BlockClassification.Logic,
type: BlockEnum.Iteration,
title: 'Iteration',
},
{
classification: BlockClassificationEnum.Logic,
classification: BlockClassification.Logic,
type: BlockEnum.Loop,
title: 'Loop',
},
{
classification: BlockClassificationEnum.Transform,
classification: BlockClassification.Transform,
type: BlockEnum.Code,
title: 'Code',
},
{
classification: BlockClassificationEnum.Transform,
classification: BlockClassification.Transform,
type: BlockEnum.TemplateTransform,
title: 'Templating Transform',
},
{
classification: BlockClassificationEnum.Transform,
classification: BlockClassification.Transform,
type: BlockEnum.VariableAggregator,
title: 'Variable Aggregator',
},
{
classification: BlockClassificationEnum.Transform,
classification: BlockClassification.Transform,
type: BlockEnum.DocExtractor,
title: 'Doc Extractor',
},
{
classification: BlockClassificationEnum.Transform,
classification: BlockClassification.Transform,
type: BlockEnum.Assigner,
title: 'Variable Assigner',
},
{
classification: BlockClassificationEnum.Transform,
classification: BlockClassification.Transform,
type: BlockEnum.ParameterExtractor,
title: 'Parameter Extractor',
},
{
classification: BlockClassificationEnum.Utilities,
classification: BlockClassification.Utilities,
type: BlockEnum.HttpRequest,
title: 'HTTP Request',
},
{
classification: BlockClassificationEnum.Utilities,
classification: BlockClassification.Utilities,
type: BlockEnum.ListFilter,
title: 'List Filter',
},

View File

@ -5,10 +5,10 @@ import type { ToolDefaultValue, ToolValue } from './types'
import type { Plugin } from '@/app/components/plugins/types'
import type { Locale } from '@/i18n-config'
import { cn } from '@langgenius/dify-ui/cn'
import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from '@langgenius/dify-ui/collapsible'
import {
createPreviewCardHandle,
PreviewCard,
PreviewCardContent,
PreviewCardTrigger,
} from '@langgenius/dify-ui/preview-card'
import { useEffect, useMemo, useState } from 'react'
@ -23,11 +23,14 @@ import { useFeaturedToolsCollapsed } from '@/app/components/workflow/block-selec
import { useGetLanguage } from '@/context/i18n'
import Link from '@/next/link'
import { formatNumber } from '@/utils/format'
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 { ToolTypeEnum } from './types'
import { ToolType } from './types'
import { ViewType } from './view-type-select'
const MAX_RECOMMENDED_COUNT = 15
@ -117,113 +120,118 @@ const FeaturedTools = ({
const showEmptyState = !isLoading && totalVisible === 0
return (
<div className="px-3 pt-2 pb-3">
<button
type="button"
className="flex w-full items-center rounded-md px-0 py-1 text-left text-text-primary"
onClick={() => setIsCollapsed((prev) => !prev)}
>
<Collapsible
className="px-3 pt-2 pb-3"
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">
<span className="system-xs-medium text-text-primary">
{t(($) => $['tabs.featuredTools'], { ns: 'workflow' })}
</span>
<span
className={cn(
'i-custom-vender-solid-arrows-arrow-down-round-fill',
'ml-0.5 size-4 text-text-tertiary transition-transform',
isCollapsed ? '-rotate-90' : 'rotate-0',
)}
aria-hidden
className="ml-0.5 i-custom-vender-solid-arrows-arrow-down-round-fill size-4 -rotate-90 text-text-tertiary transition-transform group-data-panel-open:rotate-0 motion-reduce:transition-none"
/>
</button>
</CollapsibleTrigger>
{!isCollapsed && (
<>
{isLoading && (
<div className="py-3">
<Loading type="app" />
</div>
)}
<CollapsiblePanel>
{isLoading && (
<div className="py-3">
<Loading type="app" />
</div>
)}
{showEmptyState && (
<p className="py-2 system-xs-regular text-text-tertiary">
<Link
className="text-text-accent"
href={getMarketplaceCategoryUrl(PluginCategoryEnum.tool)}
target="_blank"
rel="noopener noreferrer"
>
{t(($) => $['tabs.noFeaturedPlugins'], { ns: 'workflow' })}
</Link>
</p>
)}
{showEmptyState && (
<p className="py-2 system-xs-regular text-text-tertiary">
<Link
className="text-text-accent"
href={getMarketplaceCategoryUrl(PluginCategoryEnum.tool)}
target="_blank"
rel="noopener noreferrer"
>
{t(($) => $['tabs.noFeaturedPlugins'], { ns: 'workflow' })}
</Link>
</p>
)}
{!showEmptyState && !isLoading && (
<>
{visibleInstalledProviders.length > 0 && (
<Tools
className="p-0"
tools={visibleInstalledProviders}
onSelect={onSelect}
canNotSelectMultiple
toolType={ToolTypeEnum.All}
viewType={ViewType.flat}
hasSearchText={false}
selectedTools={selectedTools}
{!showEmptyState && !isLoading && (
<>
{visibleInstalledProviders.length > 0 && (
<Tools
className="p-0"
tools={visibleInstalledProviders}
onSelect={onSelect}
canNotSelectMultiple
toolType={ToolType.All}
viewType={ViewType.flat}
hasSearchText={false}
selectedTools={selectedTools}
/>
)}
{visibleUninstalledPlugins.length > 0 && (
<div className="mt-1 flex flex-col gap-1">
{visibleUninstalledPlugins.map((plugin) => (
<FeaturedToolUninstalledItem
key={plugin.plugin_id}
plugin={plugin}
language={language}
previewCardHandle={previewCardHandle}
onInstallSuccess={async () => {
await onInstallSuccess?.()
}}
t={t}
/>
))}
</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"
onClick={() => {
setVisibleCount((count) => {
if (count >= maxAvailable) return INITIAL_VISIBLE_COUNT
return Math.min(count + INITIAL_VISIBLE_COUNT, maxAvailable)
})
}}
>
<div className="flex items-center px-1 text-text-tertiary group-hover:text-text-secondary group-focus-visible:text-text-secondary">
<span
aria-hidden
className="i-ri-more-line size-4 group-hover:hidden group-focus-visible:hidden"
/>
{isExpanded ? (
<span
aria-hidden
className="i-custom-vender-solid-arrows-arrow-up-double-line hidden size-4 group-hover:block group-focus-visible:block"
/>
) : (
<span
aria-hidden
className="i-custom-vender-solid-arrows-arrow-down-double-line hidden size-4 group-hover:block group-focus-visible:block"
/>
)}
{visibleUninstalledPlugins.length > 0 && (
<div className="mt-1 flex flex-col gap-1">
{visibleUninstalledPlugins.map((plugin) => (
<FeaturedToolUninstalledItem
key={plugin.plugin_id}
plugin={plugin}
language={language}
previewCardHandle={previewCardHandle}
onInstallSuccess={async () => {
await onInstallSuccess?.()
}}
t={t}
/>
))}
</div>
)}
</>
)}
{!isLoading && totalVisible > 0 && canToggleVisibility && (
<div
className="group mt-1 flex cursor-pointer items-center gap-x-2 rounded-lg py-1 pr-2 pl-3 text-text-tertiary transition-colors hover:bg-state-base-hover hover:text-text-secondary"
onClick={() => {
setVisibleCount((count) => {
if (count >= maxAvailable) return INITIAL_VISIBLE_COUNT
return Math.min(count + INITIAL_VISIBLE_COUNT, maxAvailable)
})
}}
>
<div className="flex items-center px-1 text-text-tertiary transition-colors group-hover:text-text-secondary">
<span className="i-ri-more-line size-4 group-hover:hidden" />
{isExpanded ? (
<span className="i-custom-vender-solid-arrows-arrow-up-double-line hidden size-4 group-hover:block" />
) : (
<span className="i-custom-vender-solid-arrows-arrow-down-double-line hidden size-4 group-hover:block" />
)}
</div>
<div className="system-xs-regular">
{t(($) => $[isExpanded ? 'tabs.showLessFeatured' : 'tabs.showMoreFeatured'], {
ns: 'workflow',
})}
</div>
</div>
)}
</>
)}
<div className="system-xs-regular">
{t(($) => $[isExpanded ? 'tabs.showLessFeatured' : 'tabs.showMoreFeatured'], {
ns: 'workflow',
})}
</div>
</button>
)}
</CollapsiblePanel>
<PreviewCard handle={previewCardHandle}>
{({ payload }) => (
<FeaturedToolPreviewCard payload={payload as FeaturedToolPreviewPayload | undefined} />
)}
</PreviewCard>
</div>
</Collapsible>
)
}
@ -266,63 +274,77 @@ function FeaturedToolUninstalledItem({
}
}, [actionOpen])
const row = (
<div className="group flex h-8 w-full items-center rounded-lg pr-1 pl-3 hover:bg-state-base-hover">
<div className="flex h-full min-w-0 items-center">
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"
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>
<div className="ml-auto flex h-full items-center gap-1 pl-1">
<span
className={`system-xs-regular text-text-tertiary ${actionOpen ? 'hidden' : 'group-hover:hidden'}`}
>
{installCountLabel}
</span>
<div
className={`flex h-full items-center gap-1 system-xs-medium text-components-button-secondary-accent-text [&_.action-btn]:size-6 [&_.action-btn]:min-h-0 [&_.action-btn]:rounded-lg [&_.action-btn]:p-0 ${actionOpen ? '' : 'hidden group-hover:flex'}`}
>
{canInstallPlugin && (
<button
type="button"
className="cursor-pointer rounded-md px-1.5 py-0.5 hover:bg-state-base-hover"
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>
</div>
</Link>
)
return (
<>
{description ? (
// Preview is supplementary: icon / label / brief are all reachable from
// the InstallFromMarketplace modal that opens on click, so hover/focus-only
// activation is a11y-safe. See packages/dify-ui/AGENTS.md → Overlay Primitive Selection.
<PreviewCardTrigger
delay={150}
closeDelay={150}
handle={previewCardHandle}
payload={{ plugin, label, description }}
render={row}
/>
) : (
row
)}
<BlockSelectorRow as="div" className="group pr-1 focus-within:bg-state-base-hover">
{description ? (
<PreviewCardTrigger
delay={150}
closeDelay={150}
handle={previewCardHandle}
payload={{ plugin, label, description }}
render={detailsLink}
/>
) : (
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>
</BlockSelectorRow>
{isInstallModalOpen && canInstallPlugin && (
<PluginInstallPermissionProvider
canInstallPlugin={canInstallPlugin}
@ -353,20 +375,13 @@ function FeaturedToolPreviewCard({ payload }: FeaturedToolPreviewCardProps) {
if (!payload) return null
return (
<PreviewCardContent placement="right" popupClassName="w-[224px] px-3 py-2.5">
<div>
<BlockIcon
size="md"
className="mb-2"
type={BlockEnum.Tool}
toolIcon={payload.plugin.icon}
/>
<div className="mb-1 text-sm/5 text-text-primary">{payload.label}</div>
<div className="text-xs leading-[18px] wrap-break-word text-text-secondary">
{payload.description}
</div>
<BlockSelectorPreviewCardContent>
<BlockIcon size="md" className="mb-2" type={BlockEnum.Tool} toolIcon={payload.plugin.icon} />
<div className="mb-1 text-sm/5 text-text-primary">{payload.label}</div>
<div className="text-xs leading-[18px] wrap-break-word text-text-secondary">
{payload.description}
</div>
</PreviewCardContent>
</BlockSelectorPreviewCardContent>
)
}

View File

@ -5,10 +5,10 @@ import type { TriggerDefaultValue, TriggerWithProvider } from './types'
import type { Plugin } from '@/app/components/plugins/types'
import type { Locale } from '@/i18n-config'
import { cn } from '@langgenius/dify-ui/cn'
import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from '@langgenius/dify-ui/collapsible'
import {
createPreviewCardHandle,
PreviewCard,
PreviewCardContent,
PreviewCardTrigger,
} from '@langgenius/dify-ui/preview-card'
import { useEffect, useMemo, useState } from 'react'
@ -23,10 +23,12 @@ import { useFeaturedTriggersCollapsed } from '@/app/components/workflow/block-se
import { useGetLanguage } from '@/context/i18n'
import Link from '@/next/link'
import { formatNumber } from '@/utils/format'
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'
@ -123,101 +125,106 @@ const FeaturedTriggers = ({
const showEmptyState = !isLoading && totalVisible === 0
return (
<div className="pt-2 pb-3">
<button
type="button"
className="flex w-full items-center rounded-md px-4 py-1 text-left text-text-primary"
onClick={() => setIsCollapsed((prev) => !prev)}
>
<Collapsible
className="pt-2 pb-3"
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">
<span className="system-xs-medium text-text-primary">
{t(($) => $['tabs.featuredTools'], { ns: 'workflow' })}
</span>
<span
className={cn(
'i-custom-vender-solid-arrows-arrow-down-round-fill',
'ml-0.5 size-4 text-text-tertiary transition-transform',
isCollapsed ? '-rotate-90' : 'rotate-0',
)}
aria-hidden
className="ml-0.5 i-custom-vender-solid-arrows-arrow-down-round-fill size-4 -rotate-90 text-text-tertiary transition-transform group-data-panel-open:rotate-0 motion-reduce:transition-none"
/>
</button>
</CollapsibleTrigger>
{!isCollapsed && (
<>
{isLoading && (
<div className="py-3">
<Loading type="app" />
</div>
)}
<CollapsiblePanel>
{isLoading && (
<div className="py-3">
<Loading type="app" />
</div>
)}
{showEmptyState && (
<p className="px-4 py-2 system-xs-regular text-text-tertiary">
<Link
className="text-text-accent"
href={getMarketplaceCategoryUrl(PluginCategoryEnum.trigger)}
target="_blank"
rel="noopener noreferrer"
>
{t(($) => $['tabs.noFeaturedTriggers'], { ns: 'workflow' })}
</Link>
</p>
)}
{!showEmptyState && !isLoading && (
<div className="mt-1 p-1">
{visibleInstalledProviders.map((provider) => (
<TriggerPluginItem
key={provider.id}
payload={provider}
hasSearchText={false}
previewCardHandle={triggerActionPreviewCardHandle}
onSelect={onSelect}
/>
))}
{visibleUninstalledPlugins.map((plugin) => (
<div key={plugin.plugin_id} className="mb-1 last-of-type:mb-0">
<FeaturedTriggerUninstalledItem
plugin={plugin}
language={language}
previewCardHandle={previewCardHandle}
onInstallSuccess={async () => {
await onInstallSuccess?.()
}}
t={t}
/>
</div>
))}
</div>
)}
{!isLoading && totalVisible > 0 && canToggleVisibility && (
<div
className="group mt-1 flex cursor-pointer items-center gap-x-2 rounded-lg py-1 pr-2 pl-3 text-text-tertiary transition-colors hover:bg-state-base-hover hover:text-text-secondary"
onClick={() => {
setVisibleCount((count) => {
if (count >= maxAvailable) return INITIAL_VISIBLE_COUNT
return Math.min(count + INITIAL_VISIBLE_COUNT, maxAvailable)
})
}}
{showEmptyState && (
<p className="px-4 py-2 system-xs-regular text-text-tertiary">
<Link
className="text-text-accent"
href={getMarketplaceCategoryUrl(PluginCategoryEnum.trigger)}
target="_blank"
rel="noopener noreferrer"
>
<div className="flex items-center px-1 text-text-tertiary transition-colors group-hover:text-text-secondary">
<span className="i-ri-more-line size-4 group-hover:hidden" />
{isExpanded ? (
<span className="i-custom-vender-solid-arrows-arrow-up-double-line hidden size-4 group-hover:block" />
) : (
<span className="i-custom-vender-solid-arrows-arrow-down-double-line hidden size-4 group-hover:block" />
)}
</div>
<div className="system-xs-regular">
{t(($) => $[isExpanded ? 'tabs.showLessFeatured' : 'tabs.showMoreFeatured'], {
ns: 'workflow',
})}
{t(($) => $['tabs.noFeaturedTriggers'], { ns: 'workflow' })}
</Link>
</p>
)}
{!showEmptyState && !isLoading && (
<div className="mt-1 p-1">
{visibleInstalledProviders.map((provider) => (
<TriggerPluginItem
key={provider.id}
payload={provider}
hasSearchText={false}
previewCardHandle={triggerActionPreviewCardHandle}
onSelect={onSelect}
/>
))}
{visibleUninstalledPlugins.map((plugin) => (
<div key={plugin.plugin_id} className="mb-1 last-of-type:mb-0">
<FeaturedTriggerUninstalledItem
plugin={plugin}
language={language}
previewCardHandle={previewCardHandle}
onInstallSuccess={async () => {
await onInstallSuccess?.()
}}
t={t}
/>
</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"
onClick={() => {
setVisibleCount((count) => {
if (count >= maxAvailable) return INITIAL_VISIBLE_COUNT
return Math.min(count + INITIAL_VISIBLE_COUNT, maxAvailable)
})
}}
>
<div className="flex items-center px-1 text-text-tertiary group-hover:text-text-secondary group-focus-visible:text-text-secondary">
<span
aria-hidden
className="i-ri-more-line size-4 group-hover:hidden group-focus-visible:hidden"
/>
{isExpanded ? (
<span
aria-hidden
className="i-custom-vender-solid-arrows-arrow-up-double-line hidden size-4 group-hover:block group-focus-visible:block"
/>
) : (
<span
aria-hidden
className="i-custom-vender-solid-arrows-arrow-down-double-line hidden size-4 group-hover:block group-focus-visible:block"
/>
)}
</div>
)}
</>
)}
<div className="system-xs-regular">
{t(($) => $[isExpanded ? 'tabs.showLessFeatured' : 'tabs.showMoreFeatured'], {
ns: 'workflow',
})}
</div>
</button>
)}
</CollapsiblePanel>
<PreviewCard handle={previewCardHandle}>
{({ payload }) => (
<FeaturedTriggerPreviewCard
@ -232,7 +239,7 @@ const FeaturedTriggers = ({
/>
)}
</PreviewCard>
</div>
</Collapsible>
)
}
@ -275,8 +282,13 @@ function FeaturedTriggerUninstalledItem({
}
}, [actionOpen])
const row = (
<BlockSelectorRow as="div" className="group select-none">
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"
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"
@ -288,55 +300,64 @@ function FeaturedTriggerUninstalledItem({
<div className="truncate system-sm-medium text-text-secondary">{label}</div>
</div>
</div>
<div className="ml-auto flex h-6 items-center gap-1 pl-1">
<span
className={`system-xs-regular text-text-tertiary ${actionOpen ? 'hidden' : 'group-hover:hidden'}`}
>
{installCountLabel}
</span>
<div
className={`flex h-full items-center gap-1 system-xs-medium text-components-button-secondary-accent-text [&_.action-btn]:size-6 [&_.action-btn]:min-h-0 [&_.action-btn]:rounded-lg [&_.action-btn]:p-0 ${actionOpen ? '' : 'hidden group-hover:flex'}`}
>
{canInstallPlugin && (
<button
type="button"
className="cursor-pointer rounded-md px-1.5 py-0.5 hover:bg-state-base-hover"
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>
</BlockSelectorRow>
</Link>
)
return (
<>
{description ? (
// Preview is supplementary: icon / label / brief are all reachable from
// the InstallFromMarketplace modal that opens on click, so hover/focus-only
// activation is a11y-safe. See packages/dify-ui/AGENTS.md → Overlay Primitive Selection.
<PreviewCardTrigger
delay={150}
closeDelay={150}
handle={previewCardHandle}
payload={{ plugin, label, description }}
render={row}
/>
) : (
row
)}
<BlockSelectorRow as="div" className="group select-none focus-within:bg-state-base-hover">
{description ? (
<PreviewCardTrigger
delay={150}
closeDelay={150}
handle={previewCardHandle}
payload={{ plugin, label, description }}
render={detailsLink}
/>
) : (
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>
</BlockSelectorRow>
{isInstallModalOpen && canInstallPlugin && (
<PluginInstallPermissionProvider
canInstallPlugin={canInstallPlugin}
@ -367,20 +388,18 @@ function FeaturedTriggerPreviewCard({ payload }: FeaturedTriggerPreviewCardProps
if (!payload) return null
return (
<PreviewCardContent placement="right" popupClassName="w-[224px] px-3 py-2.5">
<div>
<BlockIcon
size="md"
className="mb-2"
type={BlockEnum.TriggerPlugin}
toolIcon={payload.plugin.icon}
/>
<div className="mb-1 text-sm/5 text-text-primary">{payload.label}</div>
<div className="text-xs leading-[18px] wrap-break-word text-text-secondary">
{payload.description}
</div>
<BlockSelectorPreviewCardContent>
<BlockIcon
size="md"
className="mb-2"
type={BlockEnum.TriggerPlugin}
toolIcon={payload.plugin.icon}
/>
<div className="mb-1 text-sm/5 text-text-primary">{payload.label}</div>
<div className="text-xs leading-[18px] wrap-break-word text-text-secondary">
{payload.description}
</div>
</PreviewCardContent>
</BlockSelectorPreviewCardContent>
)
}

View File

@ -1,10 +1,8 @@
import type { ReactNode } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { BLOCKS } from './constants'
import { TabsEnum, ToolTypeEnum } from './types'
const startNodesDocsTipLinkKey = 'startNodesDocs' as const
import { TabType, ToolType } from './types'
export const useBlocks = () => {
const { t } = useTranslation()
@ -33,7 +31,7 @@ export const useTabs = ({
noTools?: boolean
noSnippets?: boolean
noStart?: boolean
defaultActiveTab?: TabsEnum
defaultActiveTab?: TabType
hasStartPlaceholderNode?: boolean
disableStartTab?: boolean
forceEnableStartTab?: boolean
@ -49,33 +47,29 @@ export const useTabs = ({
const tabs = useMemo(() => {
const tabConfigs = [
{
key: TabsEnum.Blocks,
key: TabType.Blocks,
name: t(($) => $['tabs.blocks'], { ns: 'workflow' }),
show: !noBlocks,
},
{
key: TabsEnum.Sources,
key: TabType.Sources,
name: t(($) => $['tabs.sources'], { ns: 'workflow' }),
show: !noSources,
},
{
key: TabsEnum.Tools,
key: TabType.Tools,
name: t(($) => $['tabs.tools'], { ns: 'workflow' }),
show: !noTools,
},
{
key: TabsEnum.Start,
key: TabType.Start,
name: t(($) => $['tabs.start'], { ns: 'workflow' }),
show: shouldShowStartTab,
disabled: shouldDisableStartTab,
disabledTip: shouldDisableStartTab ? startDisabledTip : undefined,
disabledTipLinkKey:
shouldDisableStartTab && !disableStartTab && hasStartPlaceholderNode
? startNodesDocsTipLinkKey
: undefined,
},
{
key: TabsEnum.Snippets,
key: TabType.Snippets,
name: t(($) => $['tabs.snippets'], { ns: 'workflow' }),
show: !noSnippets,
},
@ -91,31 +85,25 @@ export const useTabs = ({
shouldShowStartTab,
shouldDisableStartTab,
startDisabledTip,
disableStartTab,
hasStartPlaceholderNode,
])
const getValidTabKey = useCallback(
(targetKey?: TabsEnum) => {
const initialTab = useMemo(() => {
const getValidTabKey = (targetKey?: TabType) => {
if (!targetKey) return undefined
const tab = tabs.find((tabItem) => tabItem.key === targetKey)
if (!tab || tab.disabled) return undefined
return tab.key
},
[tabs],
)
const initialTab = useMemo(() => {
const fallbackTab = tabs.find((tab) => !tab.disabled)?.key ?? TabsEnum.Blocks
}
const fallbackTab = tabs.find((tab) => !tab.disabled)?.key ?? TabType.Blocks
const preferredDefault = getValidTabKey(defaultActiveTab)
if (preferredDefault) return preferredDefault
const preferredOrder: TabsEnum[] = []
if (!noBlocks) preferredOrder.push(TabsEnum.Blocks)
if (!noTools) preferredOrder.push(TabsEnum.Tools)
if (!noSources) preferredOrder.push(TabsEnum.Sources)
if (!noStart) preferredOrder.push(TabsEnum.Start)
if (!noSnippets) preferredOrder.push(TabsEnum.Snippets)
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)
@ -123,48 +111,37 @@ export const useTabs = ({
}
return fallbackTab
}, [defaultActiveTab, noBlocks, noSources, noTools, noSnippets, noStart, tabs, getValidTabKey])
const [activeTab, setActiveTab] = useState(initialTab)
const resetActiveTab = useCallback(() => {
setActiveTab(initialTab)
}, [initialTab])
useEffect(() => {
const currentTab = tabs.find((tab) => tab.key === activeTab)
if (!currentTab || currentTab.disabled) resetActiveTab()
}, [tabs, activeTab, resetActiveTab])
}, [defaultActiveTab, noBlocks, noSources, noTools, noSnippets, noStart, tabs])
return {
tabs,
activeTab,
setActiveTab,
resetActiveTab,
initialTab,
}
}
export const useToolTabs = (isHideMCPTools?: boolean) => {
const { t } = useTranslation()
const tabs = [
const tabs: Array<{ key: ToolType; name: string }> = [
{
key: ToolTypeEnum.All,
key: ToolType.All,
name: t(($) => $['tabs.allTool'], { ns: 'workflow' }),
},
{
key: ToolTypeEnum.BuiltIn,
key: ToolType.BuiltIn,
name: t(($) => $['tabs.plugin'], { ns: 'workflow' }),
},
{
key: ToolTypeEnum.Custom,
key: ToolType.Custom,
name: t(($) => $['tabs.customTool'], { ns: 'workflow' }),
},
{
key: ToolTypeEnum.Workflow,
key: ToolType.Workflow,
name: t(($) => $['tabs.workflowTool'], { ns: 'workflow' }),
},
]
if (!isHideMCPTools) {
tabs.push({
key: ToolTypeEnum.MCP,
key: ToolType.MCP,
name: 'MCP',
})
}

View File

@ -1,4 +1,4 @@
import type { OffsetOptions, Placement } from '@floating-ui/react'
import type { Placement } from '@langgenius/dify-ui/popover'
import type { MouseEventHandler } from 'react'
import type {
CommonNodeType,
@ -7,22 +7,25 @@ import type {
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 { useDebounce } from 'ahooks'
import * as React from 'react'
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
import { memo, useCallback, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import SearchBox from '@/app/components/plugins/marketplace/search-box'
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 Snippets from './snippets'
import Tabs from './tabs'
import { TabsEnum } from './types'
import { SelectorContent } from './tabs'
type NodeSelectorOffset =
| number
| {
mainAxis?: number
crossAxis?: number
}
export type NodeSelectorProps = {
open?: boolean
@ -30,7 +33,7 @@ export type NodeSelectorProps = {
onSelect: OnSelectBlock
trigger?: (open: boolean) => React.ReactNode
placement?: Placement
offset?: OffsetOptions
offset?: NodeSelectorOffset
triggerStyle?: React.CSSProperties
triggerClassName?: (open: boolean) => string
triggerInnerClassName?: string
@ -43,8 +46,7 @@ export type NodeSelectorProps = {
noBlocks?: boolean
noTools?: boolean
showStartTab?: boolean
defaultActiveTab?: TabsEnum
forceShowStartContent?: 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.
@ -71,7 +73,6 @@ function NodeSelector({
noTools = false,
showStartTab = false,
defaultActiveTab,
forceShowStartContent = false,
ignoreNodeIds = [],
forceEnableStartTab = false,
allowUserInputSelection,
@ -81,13 +82,8 @@ function NodeSelector({
const { t } = useTranslation()
const nodes = useNodes()
const flowType = useHooksStore((s) => s.configsMap?.flowType)
const [searchText, setSearchText] = useState('')
const [snippetsLoading, setSnippetsLoading] = useState(
() => Boolean(openFromProps) && defaultActiveTab === TabsEnum.Snippets,
)
const debouncedSearchText = useDebounce(searchText, { wait: 500 })
const [tags, setTags] = useState<string[]>([])
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
@ -116,7 +112,7 @@ function NodeSelector({
const canSelectUserInput = allowUserInputSelection ?? defaultAllowUserInputSelection
const disableStartTab = flowType === FlowType.snippet
const disableSnippetsTab = flowType === FlowType.snippet
const { activeTab, resetActiveTab, setActiveTab, tabs } = useTabs({
const { initialTab, tabs } = useTabs({
noBlocks,
noSources: !dataSources.length,
noTools,
@ -133,22 +129,16 @@ function NodeSelector({
if (disabled) return
setLocalOpen(newOpen)
if (!newOpen) {
setSearchText('')
setSnippetsLoading(false)
resetActiveTab()
} else if (activeTab === TabsEnum.Snippets) {
setSnippetsLoading(true)
}
if (onOpenChange) onOpenChange(newOpen)
},
[activeTab, disabled, onOpenChange, resetActiveTab],
[disabled, onOpenChange],
)
const handleTrigger = useCallback<MouseEventHandler<HTMLElement>>((e) => {
e.stopPropagation()
}, [])
const handlePopupClick = useCallback<MouseEventHandler<HTMLDivElement>>((event) => {
event.stopPropagation()
}, [])
const handleSelect = useCallback<OnSelectBlock>(
(type, pluginDefaultValue) => {
@ -158,13 +148,6 @@ function NodeSelector({
[handleOpenChange, onSelect],
)
const handleActiveTabChange = useCallback(
(newActiveTab: TabsEnum) => {
setActiveTab(newActiveTab)
if (open && newActiveTab === TabsEnum.Snippets) setSnippetsLoading(true)
},
[open, setActiveTab],
)
const handlePopupKeyDown = useCallback(
(event: React.KeyboardEvent) => {
if (isolateKeyboardEvents) event.stopPropagation()
@ -172,34 +155,6 @@ function NodeSelector({
[isolateKeyboardEvents],
)
useEffect(() => {
if (!snippetsLoading) return
const timer = window.setTimeout(() => {
setSnippetsLoading(false)
}, 200)
return () => {
window.clearTimeout(timer)
}
}, [snippetsLoading])
const filterSearchText =
activeTab === TabsEnum.Start || activeTab === TabsEnum.Tools ? debouncedSearchText : searchText
const searchPlaceholder = useMemo(() => {
if (activeTab === TabsEnum.Start) return t(($) => $['tabs.searchTrigger'], { ns: 'workflow' })
if (activeTab === TabsEnum.Blocks) return t(($) => $['tabs.searchBlock'], { ns: 'workflow' })
if (activeTab === TabsEnum.Tools) return t(($) => $['tabs.searchTool'], { ns: 'workflow' })
if (activeTab === TabsEnum.Sources)
return t(($) => $['tabs.searchDataSource'], { ns: 'workflow' })
if (activeTab === TabsEnum.Snippets)
return t(($) => $['tabs.searchSnippets'], { ns: 'workflow' })
return ''
}, [activeTab, t])
const defaultTriggerElement = (
<PopoverTrigger
aria-label={t(($) => $['common.addBlock'], { ns: 'workflow' })}
@ -223,8 +178,7 @@ function NodeSelector({
) : (
<div className={triggerInnerClassName}>{triggerElement}</div>
)
const resolvedOffset =
typeof offset === 'number' || typeof offset === 'function' ? undefined : offset
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)
@ -243,8 +197,13 @@ function NodeSelector({
placement={placement}
sideOffset={sideOffset}
alignOffset={alignOffset}
positionerProps={{ positionMethod: 'fixed' }}
popupClassName="border-none bg-transparent shadow-none"
popupProps={isolateKeyboardEvents ? { onKeyDown: handlePopupKeyDown } : undefined}
popupProps={{
initialFocus: searchInputRef,
onClick: handlePopupClick,
...(isolateKeyboardEvents ? { onKeyDown: handlePopupKeyDown } : {}),
}}
>
<div
className={cn(
@ -252,84 +211,20 @@ function NodeSelector({
popupClassName,
)}
>
<Tabs
<SelectorContent
tabs={tabs}
activeTab={activeTab}
defaultTab={initialTab}
standalonePanel={noBlocks ? initialTab : undefined}
searchInputRef={searchInputRef}
blocks={blocks}
allowStartNodeSelection={canSelectUserInput}
hasUserInputNode={hasUserInputNode}
hasTriggerNode={hasTriggerNode}
onActiveTabChange={handleActiveTabChange}
filterElem={
activeTab === TabsEnum.Snippets ? null : (
<div className="relative m-2" onClick={(e) => e.stopPropagation()}>
{activeTab === TabsEnum.Start && (
<SearchBox
autoFocus
search={searchText}
onSearchChange={setSearchText}
tags={tags}
onTagsChange={setTags}
placeholder={searchPlaceholder}
inputClassName="grow"
/>
)}
{activeTab === TabsEnum.Blocks && (
<Input
showLeftIcon
showClearIcon
autoFocus
value={searchText}
placeholder={searchPlaceholder}
onChange={(e) => setSearchText(e.target.value)}
onClear={() => setSearchText('')}
/>
)}
{activeTab === TabsEnum.Sources && (
<Input
showLeftIcon
showClearIcon
autoFocus
value={searchText}
placeholder={searchPlaceholder}
onChange={(e) => setSearchText(e.target.value)}
onClear={() => setSearchText('')}
/>
)}
{activeTab === TabsEnum.Tools && (
<SearchBox
autoFocus
search={searchText}
onSearchChange={setSearchText}
tags={tags}
onTagsChange={setTags}
placeholder={t(($) => $.searchTools, { ns: 'plugin' })!}
inputClassName="grow"
/>
)}
</div>
)
}
onSelect={handleSelect}
searchText={filterSearchText}
tags={tags}
onRequestClose={() => handleOpenChange(false)}
availableBlocksTypes={availableBlocksTypes}
noBlocks={noBlocks}
dataSources={dataSources}
noTools={noTools}
onTagsChange={setTags}
forceShowStartContent={forceShowStartContent}
snippetsElem={
disableSnippetsTab ? undefined : (
<Snippets
loading={snippetsLoading}
searchText={searchText}
onSearchTextChange={setSearchText}
insertPayload={snippetInsertPayload}
onInserted={() => handleOpenChange(false)}
/>
)
}
snippetInsertPayload={snippetInsertPayload}
/>
</div>
</PopoverContent>

View File

@ -0,0 +1,29 @@
import type { ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { PreviewCardContent, PreviewCardViewport } from '@langgenius/dify-ui/preview-card'
export function BlockSelectorPreviewCardContent({ children }: { children: ReactNode }) {
return (
<PreviewCardContent
placement="right"
className="h-(--positioner-height) w-(--positioner-width) max-w-(--available-width) transition-[top,left,right,bottom,transform] duration-180 ease-[cubic-bezier(0.22,1,0.36,1)] data-instant:transition-none motion-reduce:transition-none"
popupClassName="relative h-[var(--popup-height,auto)] w-[var(--popup-width,auto)] overflow-hidden border-none transition-[width,height,transform,scale,opacity] duration-[180ms] ease-[cubic-bezier(0.22,1,0.36,1)] data-instant:transition-none motion-reduce:transition-none"
>
<PreviewCardViewport
className={cn(
'relative h-full w-full overflow-clip',
'**:data-current:w-56 **:data-current:translate-y-0 **:data-current:px-3 **:data-current:py-2.5 **:data-current:opacity-100 **:data-current:transition-[translate,opacity] **:data-current:duration-[180ms,90ms] **:data-current:ease-[cubic-bezier(0.22,1,0.36,1)]',
'**:data-previous:w-56 **:data-previous:translate-y-0 **:data-previous:px-3 **:data-previous:py-2.5 **:data-previous:opacity-100 **:data-previous:transition-[translate,opacity] **:data-previous:duration-[180ms,90ms] **:data-previous:ease-[cubic-bezier(0.22,1,0.36,1)]',
'data-instant:**:data-current:transition-none data-instant:**:data-previous:transition-none',
"data-[activation-direction~='up']:**:data-current:data-starting-style:-translate-y-2 data-[activation-direction~='up']:**:data-current:data-starting-style:opacity-0",
"data-[activation-direction~='up']:**:data-previous:data-ending-style:translate-y-2 data-[activation-direction~='up']:**:data-previous:data-ending-style:opacity-0",
"data-[activation-direction~='down']:**:data-current:data-starting-style:translate-y-2 data-[activation-direction~='down']:**:data-current:data-starting-style:opacity-0",
"data-[activation-direction~='down']:**:data-previous:data-ending-style:-translate-y-2 data-[activation-direction~='down']:**:data-previous:data-ending-style:opacity-0",
'motion-reduce:**:data-current:transition-none motion-reduce:**:data-previous:transition-none',
)}
>
{children}
</PreviewCardViewport>
</PreviewCardContent>
)
}

View File

@ -1,12 +1,9 @@
'use client'
import type { Dispatch, SetStateAction } from 'react'
import type { ViewType } from '@/app/components/workflow/block-selector/view-type-select'
import type { OnSelectBlock } from '@/app/components/workflow/types'
import { RiMoreLine } from '@remixicon/react'
import * as React from 'react'
import { useCallback, useMemo } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { useMemo } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { ArrowDownRoundFill } from '@/app/components/base/icons/src/vender/solid/arrows'
import Loading from '@/app/components/base/loading'
import { getFormattedPlugin } from '@/app/components/plugins/marketplace/utils'
import { useRAGRecommendationsCollapsed } from '@/app/components/workflow/block-selector/storage'
@ -18,14 +15,14 @@ import List from './list'
type RAGToolRecommendationsProps = {
viewType: ViewType
onSelect: OnSelectBlock
onTagsChange: Dispatch<SetStateAction<string[]>>
onLoadMore: () => void
}
const RAGToolRecommendations = ({
export function RAGToolRecommendations({
viewType,
onSelect,
onTagsChange,
}: RAGToolRecommendationsProps) => {
onLoadMore,
}: RAGToolRecommendationsProps) {
const { t } = useTranslation()
const [isCollapsed, setIsCollapsed] = useRAGRecommendationsCollapsed()
@ -46,25 +43,23 @@ const RAGToolRecommendations = ({
return []
}, [ragRecommendedPlugins])
const loadMore = useCallback(() => {
onTagsChange((prev) => {
if (prev.includes('rag')) return prev
return [...prev, 'rag']
})
}, [onTagsChange])
return (
<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"
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"
aria-expanded={!isCollapsed}
onClick={() => setIsCollapsed((prev) => !prev)}
>
<span className="system-xs-medium text-text-tertiary">
{t(($) => $['ragToolSuggestions.title'], { ns: 'pipeline' })}
</span>
<ArrowDownRoundFill
className={`ml-1 size-4 text-text-tertiary transition-transform ${isCollapsed ? '-rotate-90' : 'rotate-0'}`}
<span
aria-hidden="true"
className={cn(
'ml-1 i-custom-vender-solid-arrows-arrow-down-round-fill size-4 text-text-tertiary transition-transform motion-reduce:transition-none',
isCollapsed && '-rotate-90',
)}
/>
</button>
{!isCollapsed && (
@ -103,17 +98,21 @@ const RAGToolRecommendations = ({
onSelect={onSelect}
viewType={viewType}
/>
<div
className="flex cursor-pointer items-center gap-x-2 py-1 pr-2 pl-3"
onClick={loadMore}
<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"
onClick={onLoadMore}
>
<div className="px-1">
<RiMoreLine className="size-4 text-text-tertiary" />
<span
aria-hidden="true"
className="i-ri-more-line block size-4 text-text-tertiary"
/>
</div>
<div className="system-xs-regular text-text-tertiary">
{t(($) => $['operation.more'], { ns: 'common' })}
</div>
</div>
</button>
</>
)}
</>
@ -121,5 +120,3 @@ const RAGToolRecommendations = ({
</div>
)
}
export default React.memo(RAGToolRecommendations)

View File

@ -45,7 +45,16 @@ describe('Snippets', () => {
describe('Rendering', () => {
it('should render loading skeleton when loading', () => {
const { container } = render(<Snippets loading searchText="" />)
mockUseInfiniteSnippetList.mockReturnValue({
data: undefined,
isLoading: true,
isFetching: true,
isFetchingNextPage: false,
fetchNextPage: vi.fn(),
hasNextPage: undefined,
})
const { container } = render(<Snippets searchText="" />)
expect(container.querySelectorAll('.bg-text-quaternary')).not.toHaveLength(0)
})
@ -59,7 +68,7 @@ describe('Snippets', () => {
).not.toBeInTheDocument()
})
it('should render snippet rows from infinite list data', () => {
it('should keep cached snippet rows visible while refetching', () => {
mockUseInfiniteSnippetList.mockReturnValue({
data: {
pages: [
@ -85,7 +94,7 @@ describe('Snippets', () => {
],
},
isLoading: false,
isFetching: false,
isFetching: true,
isFetchingNextPage: false,
fetchNextPage: vi.fn(),
hasNextPage: false,

View File

@ -20,7 +20,6 @@ import SnippetTagsFilter from './snippet-tags-filter'
import { useInsertSnippet } from './use-insert-snippet'
type SnippetsProps = {
loading?: boolean
searchText: string
onSearchTextChange?: (searchText: string) => void
insertPayload?: Parameters<OnNodeAdd>[1]
@ -50,13 +49,7 @@ const LoadingSkeleton = () => {
)
}
const Snippets = ({
loading = false,
searchText,
onSearchTextChange,
insertPayload,
onInserted,
}: SnippetsProps) => {
const Snippets = ({ searchText, onSearchTextChange, insertPayload, onInserted }: SnippetsProps) => {
const { t } = useTranslation()
const { handleInsertSnippet } = useInsertSnippet()
const deferredSearchText = useDeferredValue(searchText)
@ -103,7 +96,7 @@ const Snippets = ({
)
const filter = (
<div className="border-b border-divider-subtle p-2">
<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">
<span
@ -111,11 +104,15 @@ const Snippets = ({
aria-hidden="true"
/>
<input
autoFocus
type="search"
aria-label={t(($) => $['tabs.searchSnippets'], { ns: 'workflow' })}
name="query"
autoComplete="off"
value={searchText}
placeholder={t(($) => $['tabs.searchSnippets'], { ns: 'workflow' })}
className={cn(
'mr-1 ml-1.5 inline-block min-w-0 grow appearance-none bg-transparent system-sm-regular text-components-input-text-filled outline-hidden placeholder:text-components-input-text-placeholder',
'[&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none',
searchText && 'mr-2',
)}
onChange={(event) => onSearchTextChange?.(event.target.value)}
@ -124,7 +121,7 @@ const Snippets = ({
<button
type="button"
aria-label={t(($) => $['operation.clear'], { ns: 'common' })}
className="group shrink-0 cursor-pointer rounded-md p-1 hover:bg-state-base-hover"
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"
onClick={() => onSearchTextChange?.('')}
>
<span className="i-ri-close-line size-4 text-text-tertiary" aria-hidden="true" />
@ -137,60 +134,56 @@ const Snippets = ({
</div>
)
if (loading || isLoading || (isFetching && snippets.length === 0)) {
return (
<>
{filter}
<LoadingSkeleton />
</>
const content =
isLoading || (isFetching && snippets.length === 0) ? (
<LoadingSkeleton />
) : !snippets.length ? (
<SnippetEmptyState />
) : (
<ScrollAreaRoot className="relative max-h-120 max-w-125 overflow-hidden">
<ScrollAreaViewport ref={viewportRef}>
<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))
}
/>
)
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>
)
})}
{isFetchingNextPage && (
<div className="flex justify-center px-3 py-2">
<Loading />
</div>
)}
</ScrollAreaContent>
</ScrollAreaViewport>
<ScrollAreaScrollbar orientation="vertical">
<ScrollAreaThumb />
</ScrollAreaScrollbar>
</ScrollAreaRoot>
)
}
return (
<>
{filter}
{!snippets.length ? (
<SnippetEmptyState />
) : (
<ScrollAreaRoot className="relative max-h-120 max-w-125 overflow-hidden">
<ScrollAreaViewport ref={viewportRef}>
<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))
}
/>
)
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>
)
})}
{isFetchingNextPage && (
<div className="flex justify-center px-3 py-2">
<Loading />
</div>
)}
</ScrollAreaContent>
</ScrollAreaViewport>
<ScrollAreaScrollbar orientation="vertical">
<ScrollAreaThumb />
</ScrollAreaScrollbar>
</ScrollAreaRoot>
)}
<div className="border-t border-divider-subtle">{content}</div>
</>
)
}

View File

@ -4,18 +4,17 @@ import { cn } from '@langgenius/dify-ui/cn'
import {
createPreviewCardHandle,
PreviewCard,
PreviewCardContent,
PreviewCardTrigger,
} from '@langgenius/dify-ui/preview-card'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { memo, useCallback, useEffect, useMemo } from 'react'
import { Fragment, memo, useCallback, useEffect, useId, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
import BlockIcon from '../block-icon'
import { BlockEnum as BlockEnumValues } from '../types'
import { BlockSelectorRow } from './block-selector-row'
// import { useNodeMetaData } from '../hooks'
import { START_BLOCKS } from './constants'
import { BlockSelectorPreviewCardContent } from './preview-card'
type StartBlocksProps = {
searchText: string
@ -30,6 +29,8 @@ type StartBlocksProps = {
}
type StartBlockPreviewPayload = {
block: (typeof START_BLOCKS)[number]
label: string
description: string
}
const StartBlocks = ({
@ -46,7 +47,7 @@ const StartBlocks = ({
const { t } = useTranslation()
const nodes = useNodes()
const previewCardHandle = useMemo(() => createPreviewCardHandle<StartBlockPreviewPayload>(), [])
// const nodeMetaData = useNodeMetaData()
const previewDescriptionBaseId = useId()
const filteredBlocks = useMemo(() => {
// Check if Start node already exists in workflow
@ -99,22 +100,24 @@ const StartBlocks = ({
onContentStateChange?.(!isEmpty)
}, [isEmpty, onContentStateChange])
// Preview is supplementary: the block icon, title and description all become
// reachable from the inspector + canvas once the row is clicked to insert
// the start node, so hover/focus-only activation is a11y-safe. See
// packages/dify-ui/AGENTS.md → Overlay Primitive Selection.
const renderBlock = useCallback(
(block: (typeof START_BLOCKS)[number]) => {
const isUserInput = block.type === BlockEnumValues.Start
const isUserInputDisabled = isUserInput && showUserInputDisabled
const isRowDisabled = disabled || (isUserInput && showUserInputAdded) || isUserInputDisabled
const label = t(($) => $[`blocks.${block.type}`], { ns: 'workflow' })
const description =
block.type === BlockEnumValues.Start
? t(($) => $['nodes.start.userInputTipDescription'], { ns: 'workflow' })
: t(($) => $[`blocksAbout.${block.type}`], { ns: 'workflow' })
const previewDescriptionId = `${previewDescriptionBaseId}-${block.type}`
const disabledReason = t(($) => $['nodes.startPlaceholder.userInputConflictTip'], {
ns: 'workflow',
})
const row = (
<BlockSelectorRow
aria-disabled={isRowDisabled}
aria-describedby={previewDescriptionId}
aria-label={isUserInputDisabled ? `${label}. ${disabledReason}` : label}
disabled={isRowDisabled}
onClick={() => {
@ -153,34 +156,38 @@ const StartBlocks = ({
if (isUserInputDisabled) {
return (
<Tooltip key={block.type}>
<TooltipTrigger render={row} />
<TooltipContent
placement="right"
sideOffset={8}
className="max-w-[240px] rounded-xl border-[0.5px] border-components-panel-border bg-components-tooltip-bg px-4 py-3.5 shadow-lg"
>
<p className="system-xs-regular text-text-secondary">{disabledReason}</p>
</TooltipContent>
</Tooltip>
<Fragment key={block.type}>
<Tooltip>
<TooltipTrigger render={row} />
<TooltipContent className="rounded-xl">{disabledReason}</TooltipContent>
</Tooltip>
<span id={previewDescriptionId} className="sr-only">
{description}
</span>
</Fragment>
)
}
return (
<PreviewCardTrigger
key={block.type}
delay={150}
closeDelay={150}
handle={previewCardHandle}
payload={{ block }}
render={row}
/>
<Fragment key={block.type}>
<PreviewCardTrigger
delay={150}
closeDelay={150}
handle={previewCardHandle}
payload={{ block, label, description }}
render={row}
/>
<span id={previewDescriptionId} className="sr-only">
{description}
</span>
</Fragment>
)
},
[
disabled,
onSelect,
previewCardHandle,
previewDescriptionBaseId,
showMostCommonBadge,
showUserInputAdded,
showUserInputDisabled,
@ -223,11 +230,7 @@ type StartBlockPreviewCardProps = {
function StartBlockPreviewCard({ payload, t }: StartBlockPreviewCardProps) {
if (!payload) return null
const { block } = payload
const description =
block.type === BlockEnumValues.Start
? t(($) => $['nodes.start.userInputTipDescription'], { ns: 'workflow' })
: t(($) => $[`blocksAbout.${block.type}`], { ns: 'workflow' })
const { block, label, description } = payload
const showDifyTeamAuthor = [
BlockEnumValues.Start,
BlockEnumValues.TriggerWebhook,
@ -235,20 +238,16 @@ function StartBlockPreviewCard({ payload, t }: StartBlockPreviewCardProps) {
].includes(block.type)
return (
<PreviewCardContent placement="right" popupClassName="w-[224px] px-3 pt-3 pb-2.5">
<div>
<BlockIcon size="md" className="mb-2" type={block.type} />
<div className="mb-1 system-md-medium text-text-primary">
{t(($) => $[`blocks.${block.type}`], { ns: 'workflow' })}
<BlockSelectorPreviewCardContent>
<BlockIcon size="md" className="mb-2" type={block.type} />
<div className="mb-1 system-md-medium text-text-primary">{label}</div>
<div className="system-xs-regular wrap-break-word text-text-secondary">{description}</div>
{showDifyTeamAuthor && (
<div className="mt-1 system-xs-regular text-text-tertiary">
{t(($) => $.author, { ns: 'tools' })} {t(($) => $.difyTeam, { ns: 'workflow' })}
</div>
<div className="system-xs-regular wrap-break-word text-text-secondary">{description}</div>
{showDifyTeamAuthor && (
<div className="mt-1 system-xs-regular text-text-tertiary">
{t(($) => $.author, { ns: 'tools' })} {t(($) => $.difyTeam, { ns: 'workflow' })}
</div>
)}
</div>
</PreviewCardContent>
)}
</BlockSelectorPreviewCardContent>
)
}

View File

@ -1,331 +1,266 @@
import type { Dispatch, FC, ReactNode, SetStateAction } from 'react'
import type { BlockEnum, NodeDefault, OnSelectBlock, ToolWithProvider } from '../types'
import type { ReactNode, Ref } from 'react'
import type { BlockEnum, NodeDefault, OnNodeAdd, OnSelectBlock, ToolWithProvider } from '../types'
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 { useSuspenseQuery } from '@tanstack/react-query'
import { memo, useEffect, useMemo } from 'react'
import { useDebounce } from 'ahooks'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useDocLink } from '@/context/i18n'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { useFeaturedToolsRecommendations } from '@/service/use-plugins'
import {
useAllBuiltInTools,
useAllCustomTools,
useAllMCPTools,
useAllWorkflowTools,
useInvalidateAllBuiltInTools,
} from '@/service/use-tools'
import { basePath } from '@/utils/var'
import { useWorkflowStore } from '../store'
import { SearchInput } from '@/app/components/base/search-input'
import SearchBox from '@/app/components/plugins/marketplace/search-box'
import AllStartBlocks from './all-start-blocks'
import AllTools from './all-tools'
import Blocks from './blocks'
import DataSources from './data-sources'
import { TabsEnum } from './types'
import Snippets from './snippets'
import { ToolPanel } from './tool-panel'
import { TabType } from './types'
type TabsProps = {
activeTab: TabsEnum
onActiveTabChange: (activeTab: TabsEnum) => void
searchText: string
tags: string[]
onTagsChange: Dispatch<SetStateAction<string[]>>
type TabConfig = {
key: TabType
name: string
disabled?: boolean
disabledTip?: ReactNode
}
type SelectorContentProps = {
defaultTab: TabType
standalonePanel?: TabType
tabs: TabConfig[]
searchInputRef: Ref<HTMLInputElement>
onSelect: OnSelectBlock
onRequestClose: () => void
availableBlocksTypes?: BlockEnum[]
blocks: NodeDefault[]
dataSources?: ToolWithProvider[]
tabs: Array<{
key: TabsEnum
name: string
disabled?: boolean
disabledTip?: ReactNode
disabledTipLinkKey?: 'startNodesDocs'
}>
filterElem: React.ReactNode
noBlocks?: boolean
noTools?: boolean
forceShowStartContent?: boolean // Force show Start content even when noBlocks=true
allowStartNodeSelection?: boolean // Allow user input option even when trigger node already exists (e.g. change-node flow or when no Start node yet).
allowStartNodeSelection?: boolean
hasUserInputNode?: boolean
hasTriggerNode?: boolean
snippetsElem?: React.ReactNode
snippetInsertPayload?: Parameters<OnNodeAdd>[1]
}
const normalizeToolList = (list: ToolWithProvider[] | undefined, currentBasePath?: string) => {
if (!list || !currentBasePath) return list
let changed = false
const normalized = list.map((provider) => {
if (typeof provider.icon !== 'string') return provider
const shouldPrefix =
provider.icon.startsWith('/') && !provider.icon.startsWith(`${currentBasePath}/`)
if (!shouldPrefix) return provider
changed = true
return {
...provider,
icon: `${currentBasePath}${provider.icon}`,
}
})
return changed ? normalized : list
}
const getStoreToolUpdates = ({
state,
buildInTools,
customTools,
workflowTools,
mcpTools,
}: {
state: {
buildInTools?: ToolWithProvider[]
customTools?: ToolWithProvider[]
workflowTools?: ToolWithProvider[]
mcpTools?: ToolWithProvider[]
}
buildInTools?: ToolWithProvider[]
customTools?: ToolWithProvider[]
workflowTools?: ToolWithProvider[]
mcpTools?: ToolWithProvider[]
}) => {
const updates: Partial<typeof state> = {}
if (buildInTools !== undefined && state.buildInTools !== buildInTools)
updates.buildInTools = buildInTools
if (customTools !== undefined && state.customTools !== customTools)
updates.customTools = customTools
if (workflowTools !== undefined && state.workflowTools !== workflowTools)
updates.workflowTools = workflowTools
if (mcpTools !== undefined && state.mcpTools !== mcpTools) updates.mcpTools = mcpTools
return updates
}
const TabHeaderItem = ({
function TabHeaderItem({
tab,
activeTab,
onActiveTabChange,
disabledTip,
disabledTipLinkHref,
disabledTipLinkLabel,
fallbackDisabledTip,
}: {
tab: TabsProps['tabs'][number]
activeTab: TabsEnum
onActiveTabChange: (activeTab: TabsEnum) => void
disabledTip: ReactNode
disabledTipLinkHref?: string
disabledTipLinkLabel?: string
}) => {
const className = cn(
'relative mr-0.5 flex h-8 items-center rounded-t-lg px-3 system-sm-medium',
tab.disabled
? 'cursor-not-allowed text-text-disabled opacity-60'
: activeTab === tab.key
? 'sm-no-bottom cursor-default bg-components-panel-bg text-text-accent'
: 'cursor-pointer text-text-tertiary',
tab: TabConfig
fallbackDisabledTip: ReactNode
}) {
const tabElement = (
<TabsTab
value={tab.key}
disabled={tab.disabled}
className={cn(
'z-10 mr-0.5 h-8 rounded-t-lg border-b-0 px-3 py-0 system-sm-medium text-text-tertiary',
'data-active:cursor-default data-active:border-transparent data-active:text-text-accent',
'data-disabled:text-text-disabled data-disabled:opacity-60',
)}
>
{tab.name}
</TabsTab>
)
const handleClick = () => {
if (tab.disabled || activeTab === tab.key) return
onActiveTabChange(tab.key)
if (!tab.disabled) return tabElement
return (
<Tooltip>
<TooltipTrigger render={tabElement} />
<TooltipContent placement="top" className="max-w-[230px] rounded-xl px-4 py-3.5">
{tab.disabledTip || fallbackDisabledTip}
</TooltipContent>
</Tooltip>
)
}
function SelectorContent({
defaultTab,
standalonePanel,
tabs,
searchInputRef,
onSelect,
onRequestClose,
availableBlocksTypes,
blocks,
dataSources = [],
allowStartNodeSelection = false,
hasUserInputNode = false,
hasTriggerNode = false,
snippetInsertPayload,
}: SelectorContentProps) {
const { t } = useTranslation()
const [searchText, setSearchText] = useState('')
const debouncedSearchText = useDebounce(searchText, { wait: 500 })
const [tags, setTags] = useState<string[]>([])
const fallbackDisabledTip = t(($) => $['tabs.startDisabledTip'], { ns: 'workflow' })
const renderSearchFilter = (tab: TabType, inputRef?: Ref<HTMLInputElement>) => {
if (tab === TabType.Snippets) return null
const filter = (() => {
if (tab === TabType.Start) {
return (
<SearchBox
ref={inputRef}
search={searchText}
onSearchChange={setSearchText}
tags={tags}
onTagsChange={setTags}
placeholder={t(($) => $['tabs.searchTrigger'], { ns: 'workflow' })}
inputClassName="grow"
/>
)
}
if (tab === TabType.Tools) {
return (
<SearchBox
ref={inputRef}
search={searchText}
onSearchChange={setSearchText}
tags={tags}
onTagsChange={setTags}
placeholder={t(($) => $.searchTools, { ns: 'plugin' })!}
inputClassName="grow"
/>
)
}
return (
<SearchInput
ref={inputRef}
value={searchText}
placeholder={
tab === TabType.Blocks
? t(($) => $['tabs.searchBlock'], { ns: 'workflow' })
: t(($) => $['tabs.searchDataSource'], { ns: 'workflow' })
}
aria-label={
tab === TabType.Blocks
? t(($) => $['tabs.searchBlock'], { ns: 'workflow' })
: t(($) => $['tabs.searchDataSource'], { ns: 'workflow' })
}
onValueChange={setSearchText}
/>
)
})()
return <div className="relative m-2">{filter}</div>
}
if (tab.disabled) {
return (
<Tooltip key={tab.key}>
<TooltipTrigger
render={
<button
type="button"
className={className}
aria-disabled={tab.disabled}
onClick={handleClick}
>
{tab.name}
</button>
}
/>
<TooltipContent placement="top" className="max-w-[230px] rounded-xl px-4 py-3.5">
<div className="flex flex-col items-start gap-1 system-xs-regular text-text-secondary">
<p>{disabledTip}</p>
{disabledTipLinkHref && disabledTipLinkLabel && (
<a
className="text-text-accent hover:underline"
href={disabledTipLinkHref}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
>
{disabledTipLinkLabel}
</a>
)}
const renderPanel = (tab: TabType, inputRef?: Ref<HTMLInputElement>) => {
const searchFilter = renderSearchFilter(tab, inputRef)
if (tab === TabType.Start) {
return (
<>
{searchFilter}
<div className="border-t border-divider-subtle">
<AllStartBlocks
allowUserInputSelection={allowStartNodeSelection}
hasUserInputNode={hasUserInputNode}
hasTriggerNode={hasTriggerNode}
searchText={debouncedSearchText}
onSelect={onSelect}
availableBlocksTypes={availableBlocksTypes}
tags={tags}
/>
</div>
</TooltipContent>
</Tooltip>
</>
)
}
if (tab === TabType.Blocks) {
return (
<>
{searchFilter}
<div className="border-t border-divider-subtle">
<Blocks
searchText={searchText}
onSelect={onSelect}
availableBlocksTypes={availableBlocksTypes}
blocks={blocks}
/>
</div>
</>
)
}
if (tab === TabType.Sources) {
return (
<>
{searchFilter}
<div className="border-t border-divider-subtle">
<DataSources searchText={searchText} onSelect={onSelect} dataSources={dataSources} />
</div>
</>
)
}
if (tab === TabType.Tools) {
return (
<>
{searchFilter}
<ToolPanel
searchText={debouncedSearchText}
onSelect={onSelect}
tags={tags}
onTagsChange={setTags}
dataSources={dataSources}
/>
</>
)
}
return (
<Snippets
searchText={searchText}
onSearchTextChange={setSearchText}
insertPayload={snippetInsertPayload}
onInserted={onRequestClose}
/>
)
}
if (standalonePanel) {
return (
<div className="w-full min-w-0">
{renderPanel(
standalonePanel,
standalonePanel === TabType.Snippets ? undefined : searchInputRef,
)}
</div>
)
}
return (
<div key={tab.key} className={className} aria-disabled={tab.disabled} onClick={handleClick}>
{tab.name}
</div>
)
}
const Tabs: FC<TabsProps> = ({
activeTab,
onActiveTabChange,
tags,
onTagsChange,
searchText,
onSelect,
availableBlocksTypes,
blocks,
dataSources = [],
tabs = [],
filterElem,
noBlocks,
noTools,
forceShowStartContent = false,
allowStartNodeSelection = false,
hasUserInputNode = false,
hasTriggerNode = false,
snippetsElem,
}) => {
const { t } = useTranslation()
const docLink = useDocLink()
const { data: buildInTools } = useAllBuiltInTools()
const { data: customTools } = useAllCustomTools()
const { data: workflowTools } = useAllWorkflowTools()
const { data: mcpTools } = useAllMCPTools()
const invalidateBuiltInTools = useInvalidateAllBuiltInTools()
const { data: enable_marketplace } = useSuspenseQuery({
...systemFeaturesQueryOptions(),
select: (s) => s.enable_marketplace,
})
const workflowStore = useWorkflowStore()
const inRAGPipeline = dataSources.length > 0
const { plugins: featuredPlugins = [], isLoading: isFeaturedLoading } =
useFeaturedToolsRecommendations(enable_marketplace && !inRAGPipeline)
const normalizedBuiltInTools = useMemo(
() => normalizeToolList(buildInTools, basePath),
[buildInTools],
)
const normalizedCustomTools = useMemo(
() => normalizeToolList(customTools, basePath),
[customTools],
)
const normalizedWorkflowTools = useMemo(
() => normalizeToolList(workflowTools, basePath),
[workflowTools],
)
const normalizedMcpTools = useMemo(() => normalizeToolList(mcpTools, basePath), [mcpTools])
const disabledTip = t(($) => $['tabs.startDisabledTip'], { ns: 'workflow' })
useEffect(() => {
workflowStore.setState((state) => {
const updates = getStoreToolUpdates({
state,
buildInTools: normalizedBuiltInTools,
customTools: normalizedCustomTools,
workflowTools: normalizedWorkflowTools,
mcpTools: normalizedMcpTools,
})
if (!Object.keys(updates).length) return state
return {
...state,
...updates,
}
})
}, [
normalizedBuiltInTools,
normalizedCustomTools,
normalizedMcpTools,
normalizedWorkflowTools,
workflowStore,
])
return (
<div className="w-full min-w-0" onClick={(e) => e.stopPropagation()}>
{!noBlocks && (
<div className="relative flex w-full min-w-0 bg-background-section-burn pt-1 pl-1">
{tabs.map((tab) => (
<TabHeaderItem
key={tab.key}
tab={tab}
activeTab={activeTab}
onActiveTabChange={onActiveTabChange}
disabledTip={tab.disabledTip || disabledTip}
disabledTipLinkHref={
tab.disabledTipLinkKey === 'startNodesDocs'
? docLink('/use-dify/nodes/trigger/overview')
: undefined
}
disabledTipLinkLabel={
tab.disabledTipLinkKey === 'startNodesDocs'
? t(($) => $['tabs.startDisabledTipLearnMore'], { ns: 'workflow' })
: undefined
}
/>
))}
</div>
)}
{filterElem}
{activeTab === TabsEnum.Start && (!noBlocks || forceShowStartContent) && (
<div className="border-t border-divider-subtle">
<AllStartBlocks
allowUserInputSelection={allowStartNodeSelection}
hasUserInputNode={hasUserInputNode}
hasTriggerNode={hasTriggerNode}
searchText={searchText}
onSelect={onSelect}
availableBlocksTypes={availableBlocksTypes}
tags={tags}
/>
</div>
)}
{activeTab === TabsEnum.Blocks && !noBlocks && (
<div className="border-t border-divider-subtle">
<Blocks
searchText={searchText}
onSelect={onSelect}
availableBlocksTypes={availableBlocksTypes}
blocks={blocks}
/>
</div>
)}
{activeTab === TabsEnum.Sources && !!dataSources.length && (
<div className="border-t border-divider-subtle">
<DataSources searchText={searchText} onSelect={onSelect} dataSources={dataSources} />
</div>
)}
{activeTab === TabsEnum.Tools && !noTools && (
<AllTools
searchText={searchText}
onSelect={onSelect}
tags={tags}
canNotSelectMultiple
buildInTools={normalizedBuiltInTools || []}
customTools={normalizedCustomTools || []}
workflowTools={normalizedWorkflowTools || []}
mcpTools={normalizedMcpTools || []}
onTagsChange={onTagsChange}
isInRAGPipeline={inRAGPipeline}
featuredPlugins={featuredPlugins}
featuredLoading={isFeaturedLoading}
showFeatured={enable_marketplace && !inRAGPipeline}
onFeaturedInstallSuccess={async () => {
invalidateBuiltInTools()
<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">
{tabs.map((tab) => (
<TabHeaderItem key={tab.key} tab={tab} fallbackDisabledTip={fallbackDisabledTip} />
))}
<TabsIndicator
className="sm-no-bottom pointer-events-none absolute left-0 rounded-t-lg bg-components-panel-bg transition-[translate,width] duration-150 ease-in-out motion-reduce:transition-none"
style={{
top: 'var(--active-tab-top)',
translate: 'var(--active-tab-left)',
width: 'var(--active-tab-width)',
height: 'var(--active-tab-height)',
}}
/>
)}
{activeTab === TabsEnum.Snippets && Boolean(snippetsElem) ? (
<div className="border-t border-divider-subtle">{snippetsElem}</div>
) : null}
</div>
</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"
>
{renderPanel(
tab.key,
tab.key === defaultTab && tab.key !== TabType.Snippets ? searchInputRef : undefined,
)}
</TabsPanel>
))}
</Tabs>
)
}
export default memo(Tabs)
export { SelectorContent }

View File

@ -0,0 +1,151 @@
import type { OnSelectBlock, ToolWithProvider } from '../types'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useEffect, useMemo } from 'react'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { useFeaturedToolsRecommendations } from '@/service/use-plugins'
import {
useAllBuiltInTools,
useAllCustomTools,
useAllMCPTools,
useAllWorkflowTools,
useInvalidateAllBuiltInTools,
} from '@/service/use-tools'
import { basePath } from '@/utils/var'
import { useWorkflowStore } from '../store'
import AllTools from './all-tools'
function normalizeToolList(list: ToolWithProvider[] | undefined, currentBasePath?: string) {
if (!list || !currentBasePath) return list
let changed = false
const normalized = list.map((provider) => {
if (typeof provider.icon !== 'string') return provider
const shouldPrefix =
provider.icon.startsWith('/') && !provider.icon.startsWith(`${currentBasePath}/`)
if (!shouldPrefix) return provider
changed = true
return {
...provider,
icon: `${currentBasePath}${provider.icon}`,
}
})
return changed ? normalized : list
}
function getStoreToolUpdates({
state,
buildInTools,
customTools,
workflowTools,
mcpTools,
}: {
state: {
buildInTools?: ToolWithProvider[]
customTools?: ToolWithProvider[]
workflowTools?: ToolWithProvider[]
mcpTools?: ToolWithProvider[]
}
buildInTools?: ToolWithProvider[]
customTools?: ToolWithProvider[]
workflowTools?: ToolWithProvider[]
mcpTools?: ToolWithProvider[]
}) {
const updates: Partial<typeof state> = {}
if (buildInTools !== undefined && state.buildInTools !== buildInTools)
updates.buildInTools = buildInTools
if (customTools !== undefined && state.customTools !== customTools)
updates.customTools = customTools
if (workflowTools !== undefined && state.workflowTools !== workflowTools)
updates.workflowTools = workflowTools
if (mcpTools !== undefined && state.mcpTools !== mcpTools) updates.mcpTools = mcpTools
return updates
}
export function ToolPanel({
searchText,
tags,
onTagsChange,
onSelect,
dataSources,
}: {
searchText: string
tags: string[]
onTagsChange: (tags: string[]) => void
onSelect: OnSelectBlock
dataSources: ToolWithProvider[]
}) {
const { data: buildInTools } = useAllBuiltInTools()
const { data: customTools } = useAllCustomTools()
const { data: workflowTools } = useAllWorkflowTools()
const { data: mcpTools } = useAllMCPTools()
const invalidateBuiltInTools = useInvalidateAllBuiltInTools()
const { data: enableMarketplace } = useSuspenseQuery({
...systemFeaturesQueryOptions(),
select: (systemFeatures) => systemFeatures.enable_marketplace,
})
const workflowStore = useWorkflowStore()
const inRAGPipeline = dataSources.length > 0
const { plugins: featuredPlugins = [], isLoading: isFeaturedLoading } =
useFeaturedToolsRecommendations(enableMarketplace && !inRAGPipeline)
const normalizedBuiltInTools = useMemo(
() => normalizeToolList(buildInTools, basePath),
[buildInTools],
)
const normalizedCustomTools = useMemo(
() => normalizeToolList(customTools, basePath),
[customTools],
)
const normalizedWorkflowTools = useMemo(
() => normalizeToolList(workflowTools, basePath),
[workflowTools],
)
const normalizedMcpTools = useMemo(() => normalizeToolList(mcpTools, basePath), [mcpTools])
useEffect(() => {
workflowStore.setState((state) => {
const updates = getStoreToolUpdates({
state,
buildInTools: normalizedBuiltInTools,
customTools: normalizedCustomTools,
workflowTools: normalizedWorkflowTools,
mcpTools: normalizedMcpTools,
})
if (!Object.keys(updates).length) return state
return {
...state,
...updates,
}
})
}, [
normalizedBuiltInTools,
normalizedCustomTools,
normalizedMcpTools,
normalizedWorkflowTools,
workflowStore,
])
return (
<AllTools
searchText={searchText}
onSelect={onSelect}
tags={tags}
canNotSelectMultiple
buildInTools={normalizedBuiltInTools || []}
customTools={normalizedCustomTools || []}
workflowTools={normalizedWorkflowTools || []}
mcpTools={normalizedMcpTools || []}
onTagsChange={onTagsChange}
isInRAGPipeline={inRAGPipeline}
featuredPlugins={featuredPlugins}
featuredLoading={isFeaturedLoading}
showFeatured={enableMarketplace && !inRAGPipeline}
onFeaturedInstallSuccess={invalidateBuiltInTools}
/>
)
}

View File

@ -45,7 +45,10 @@ describe('ToolActionItem', () => {
/>,
)
await user.click(screen.getByRole('button', { name: 'Search Tool' }))
const toolButton = screen.getByRole('button', { name: 'Search Tool' })
expect(toolButton).toHaveAccessibleDescription('Search Tool description')
await user.click(toolButton)
expect(onSelect).toHaveBeenCalledWith(
BlockEnum.Tool,

View File

@ -4,7 +4,7 @@ import type { ToolWithProvider } from '../../types'
import type { ToolDefaultValue } from '../types'
import type { Tool } from '@/app/components/tools/types'
import { cn } from '@langgenius/dify-ui/cn'
import { PreviewCardContent, PreviewCardTrigger } from '@langgenius/dify-ui/preview-card'
import { PreviewCardTrigger } from '@langgenius/dify-ui/preview-card'
import * as React from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
@ -15,6 +15,7 @@ import { Theme } from '@/types/app'
import { basePath } from '@/utils/var'
import BlockIcon from '../../block-icon'
import { BlockEnum } from '../../types'
import { BlockSelectorPreviewCardContent } from '../preview-card'
const normalizeProviderIcon = (icon?: ToolWithProvider['icon']) => {
if (!icon) return icon
@ -57,6 +58,8 @@ const ToolItem: FC<Props> = ({
const { t } = useTranslation()
const language = useGetLanguage()
const previewDescriptionId = React.useId()
const previewDescription = payload.description[language]
const { theme } = useTheme()
const normalizedIcon = useMemo<ToolWithProvider['icon']>(() => {
return normalizeProviderIcon(provider.icon) ?? provider.icon
@ -72,8 +75,8 @@ const ToolItem: FC<Props> = ({
const row = (
<button
key={payload.name}
type="button"
aria-describedby={previewDescription ? previewDescriptionId : undefined}
disabled={disabled}
className="flex w-full cursor-pointer items-center justify-between rounded-lg border-none bg-transparent pr-1 pl-[21px] text-left hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden disabled:cursor-default"
onClick={() => {
@ -108,11 +111,7 @@ const ToolItem: FC<Props> = ({
})
}}
>
<div
className={cn(
'truncate border-l-2 border-divider-subtle py-2 pl-4 system-sm-medium text-text-secondary',
)}
>
<div className="truncate border-l-2 border-divider-subtle py-2 pl-4 system-sm-medium text-text-secondary">
<span className={cn(disabled && 'opacity-30')}>{payload.label[language]}</span>
</div>
{isAdded && (
@ -124,22 +123,24 @@ const ToolItem: FC<Props> = ({
)
return (
// Preview is supplementary: provider icon, tool label and description are all
// reachable from the node inspector after the row is clicked to add the tool,
// so hover/focus-only activation is a11y-safe. See
// packages/dify-ui/AGENTS.md → Overlay Primitive Selection.
<PreviewCardTrigger
key={payload.name}
delay={150}
closeDelay={150}
handle={previewCardHandle}
payload={{
providerIcon,
payload,
language,
}}
render={row}
/>
<>
<PreviewCardTrigger
delay={150}
closeDelay={150}
handle={previewCardHandle}
payload={{
providerIcon,
payload,
language,
}}
render={row}
/>
{previewDescription && (
<span id={previewDescriptionId} className="sr-only">
{previewDescription}
</span>
)}
</>
)
}
@ -151,22 +152,15 @@ export function ToolActionPreviewCard({ payload }: ToolActionPreviewCardProps) {
if (!payload) return null
return (
<PreviewCardContent placement="right" popupClassName="w-[200px] px-3 py-2.5">
<div>
<BlockIcon
size="md"
className="mb-2"
type={BlockEnum.Tool}
toolIcon={payload.providerIcon}
/>
<div className="mb-1 text-sm/5 text-text-primary">
{payload.payload.label[payload.language]}
</div>
<div className="text-xs leading-[18px] wrap-break-word text-text-secondary">
{payload.payload.description[payload.language]}
</div>
<BlockSelectorPreviewCardContent>
<BlockIcon size="md" className="mb-2" type={BlockEnum.Tool} toolIcon={payload.providerIcon} />
<div className="mb-1 text-sm/5 wrap-break-word text-text-primary">
{payload.payload.label[payload.language]}
</div>
</PreviewCardContent>
<div className="text-xs leading-[18px] wrap-break-word text-text-secondary">
{payload.payload.description[payload.language]}
</div>
</BlockSelectorPreviewCardContent>
)
}

View File

@ -1,6 +1,6 @@
import type { BlockEnum, ToolWithProvider } from '../types'
import type { ToolActionPreviewPayload } from './tool/action-item'
import type { ToolDefaultValue, ToolTypeEnum, ToolValue } from './types'
import type { ToolDefaultValue, ToolType, ToolValue } from './types'
import { cn } from '@langgenius/dify-ui/cn'
import { createPreviewCardHandle, PreviewCard } from '@langgenius/dify-ui/preview-card'
import { memo, useMemo, useRef } from 'react'
@ -19,7 +19,7 @@ type ToolsProps = {
tools: ToolWithProvider[]
viewType: ViewType
hasSearchText: boolean
toolType?: ToolTypeEnum
toolType?: ToolType
isAgent?: boolean
className?: string
indexBarClassName?: string

View File

@ -111,7 +111,10 @@ describe('trigger plugin selector components', () => {
/>,
)
await user.click(screen.getByText('On Created'))
const triggerButton = screen.getByRole('button', { name: 'On Created' })
expect(triggerButton).toHaveAccessibleDescription('On Created description')
await user.click(triggerButton)
expect(onSelect).toHaveBeenCalledWith(
BlockEnum.TriggerPlugin,

View File

@ -3,12 +3,13 @@ import type { ComponentProps, FC } from 'react'
import type { TriggerDefaultValue, TriggerWithProvider } from '../types'
import type { Event } from '@/app/components/tools/types'
import { cn } from '@langgenius/dify-ui/cn'
import { PreviewCardContent, PreviewCardTrigger } from '@langgenius/dify-ui/preview-card'
import { PreviewCardTrigger } from '@langgenius/dify-ui/preview-card'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { useGetLanguage } from '@/context/i18n'
import BlockIcon from '../../block-icon'
import { BlockEnum } from '../../types'
import { BlockSelectorPreviewCardContent } from '../preview-card'
type Props = Readonly<{
provider: TriggerWithProvider
@ -38,12 +39,14 @@ const TriggerPluginActionItem: FC<Props> = ({
}) => {
const { t } = useTranslation()
const language = useGetLanguage()
const previewDescriptionId = React.useId()
const previewDescription = payload.description[language]
const row = (
<button
type="button"
aria-describedby={previewDescription ? previewDescriptionId : undefined}
disabled={disabled}
key={payload.name}
className={cn(
'flex w-full items-center justify-between rounded-lg border-0 bg-transparent pr-1 pl-[21px] text-left focus-visible:ring-1 focus-visible:ring-components-input-border-hover focus-visible:outline-hidden',
disabled ? 'cursor-default' : 'cursor-pointer hover:bg-state-base-hover',
@ -74,11 +77,7 @@ const TriggerPluginActionItem: FC<Props> = ({
})
}}
>
<div
className={cn(
'truncate border-l-2 border-divider-subtle py-2 pl-4 system-sm-medium text-text-secondary',
)}
>
<div className="truncate border-l-2 border-divider-subtle py-2 pl-4 system-sm-medium text-text-secondary">
<span className={cn(disabled && 'opacity-30')}>{payload.label[language]}</span>
</div>
{isAdded && (
@ -90,18 +89,20 @@ const TriggerPluginActionItem: FC<Props> = ({
)
return (
// Preview is supplementary: provider icon, event label and description are all
// reachable from the node inspector after the row is clicked to add the trigger,
// so hover/focus-only activation is a11y-safe. See
// packages/dify-ui/AGENTS.md → Overlay Primitive Selection.
<PreviewCardTrigger
key={payload.name}
delay={150}
closeDelay={150}
handle={previewCardHandle}
payload={{ provider, payload, language }}
render={row}
/>
<>
<PreviewCardTrigger
delay={150}
closeDelay={150}
handle={previewCardHandle}
payload={{ provider, payload, language }}
render={row}
/>
{previewDescription && (
<span id={previewDescriptionId} className="sr-only">
{previewDescription}
</span>
)}
</>
)
}
@ -113,22 +114,20 @@ export function TriggerPluginActionPreviewCard({ payload }: TriggerPluginActionP
if (!payload) return null
return (
<PreviewCardContent placement="right" popupClassName="w-[224px] px-3 py-2.5">
<div>
<BlockIcon
size="md"
className="mb-2"
type={BlockEnum.TriggerPlugin}
toolIcon={payload.provider.icon}
/>
<div className="mb-1 text-sm/5 text-text-primary">
{payload.payload.label[payload.language]}
</div>
<div className="text-xs leading-[18px] wrap-break-word text-text-secondary">
{payload.payload.description[payload.language]}
</div>
<BlockSelectorPreviewCardContent>
<BlockIcon
size="md"
className="mb-2"
type={BlockEnum.TriggerPlugin}
toolIcon={payload.provider.icon}
/>
<div className="mb-1 text-sm/5 text-text-primary">
{payload.payload.label[payload.language]}
</div>
</PreviewCardContent>
<div className="text-xs leading-[18px] wrap-break-word text-text-secondary">
{payload.payload.description[payload.language]}
</div>
</BlockSelectorPreviewCardContent>
)
}

View File

@ -9,29 +9,35 @@ import type {
import type { Collection, Event } from '../../tools/types'
import type { TypeWithI18N } from '@/app/components/header/account-setting/model-provider-page/declarations'
export enum TabsEnum {
Start = 'start',
Blocks = 'blocks',
Tools = 'tools',
Sources = 'sources',
Snippets = 'snippets',
}
export const TabType = {
Start: 'start',
Blocks: 'blocks',
Tools: 'tools',
Sources: 'sources',
Snippets: 'snippets',
} as const
export enum ToolTypeEnum {
All = 'all',
BuiltIn = 'built-in',
Custom = 'custom',
Workflow = 'workflow',
MCP = 'mcp',
}
export type TabType = (typeof TabType)[keyof typeof TabType]
export enum BlockClassificationEnum {
Default = '-',
QuestionUnderstand = 'question-understand',
Logic = 'logic',
Transform = 'transform',
Utilities = 'utilities',
}
export const ToolType = {
All: 'all',
BuiltIn: 'built-in',
Custom: 'custom',
Workflow: 'workflow',
MCP: 'mcp',
} as const
export type ToolType = (typeof ToolType)[keyof typeof ToolType]
export const BlockClassification = {
Default: '-',
QuestionUnderstand: 'question-understand',
Logic: 'logic',
Transform: 'transform',
Utilities: 'utilities',
} as const
export type BlockClassification = (typeof BlockClassification)[keyof typeof BlockClassification]
type PluginCommonDefaultValue = {
provider_id: string
@ -215,17 +221,20 @@ export type TriggerWithProvider = Collection & {
// Trigger subscription instance types
export enum TriggerCredentialTypeEnum {
ApiKey = 'api-key',
Oauth2 = 'oauth2',
Unauthorized = 'unauthorized',
}
export const TriggerCredentialType = {
ApiKey: 'api-key',
Oauth2: 'oauth2',
Unauthorized: 'unauthorized',
} as const
export type TriggerCredentialType =
(typeof TriggerCredentialType)[keyof typeof TriggerCredentialType]
type TriggerSubscriptionStructure = {
id: string
name: string
provider: string
credential_type: TriggerCredentialTypeEnum
credential_type: TriggerCredentialType
credentials: Record<string, unknown>
endpoint: string
parameters: Record<string, unknown>

View File

@ -1,6 +1,6 @@
import type { NodeDefault } from '../../types'
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
import { BlockClassificationEnum } from '../../block-selector/types'
import { BlockClassification } from '../../block-selector/types'
import { BlockEnum } from '../../types'
import { useAvailableBlocks } from '../use-available-blocks'
@ -29,7 +29,7 @@ const mockNodeTypes = [
function createNodeDefault(type: BlockEnum): NodeDefault {
return {
metaData: {
classification: BlockClassificationEnum.Default,
classification: BlockClassification.Default,
sort: 0,
type,
title: type,

View File

@ -6,7 +6,7 @@ import {
renderWorkflowFlowHook,
renderWorkflowHook,
} from '../../__tests__/workflow-test-env'
import { BlockClassificationEnum } from '../../block-selector/types'
import { BlockClassification } from '../../block-selector/types'
import { BlockEnum, WorkflowRunningStatus } from '../../types'
import {
useIsChatMode,
@ -32,7 +32,7 @@ beforeEach(() => {
function createNodeDefault(type: BlockEnum): NodeDefault {
return {
metaData: {
classification: BlockClassificationEnum.Default,
classification: BlockClassification.Default,
sort: 0,
type,
title: type,

View File

@ -7,7 +7,7 @@ import { cloneElement, memo, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { UserAvatarList } from '@/app/components/base/user-avatar-list'
import BlockIcon from '@/app/components/workflow/block-icon'
import { ToolTypeEnum } from '@/app/components/workflow/block-selector/types'
import { ToolType } from '@/app/components/workflow/block-selector/types'
import { useCollaboration } from '@/app/components/workflow/collaboration/hooks/use-collaboration'
import { useNodesReadOnly, useToolIcon } from '@/app/components/workflow/hooks'
import useInspectVarsCrud from '@/app/components/workflow/hooks/use-inspect-vars-crud'
@ -271,7 +271,7 @@ const BaseNode: FC<BaseNodeProps> = ({ id, data, children }) => {
{hasRetryNode(data.type) && <RetryOnNode id={id} data={data} />}
{hasErrorHandleNode(data.type) && <ErrorHandleOnNode id={id} data={data} />}
<NodeDescription data={data} />
{data.type === BlockEnum.Tool && data.provider_type === ToolTypeEnum.MCP && (
{data.type === BlockEnum.Tool && data.provider_type === ToolType.MCP && (
<div className="px-3 pb-2">
<CopyID content={data.provider_id || ''} />
</div>

View File

@ -1,7 +1,7 @@
import type { TFunction } from 'i18next'
import type { NodeDefault } from '../../types'
import type { AssignerNodeType } from './types'
import { BlockClassificationEnum } from '@/app/components/workflow/block-selector/types'
import { BlockClassification } from '@/app/components/workflow/block-selector/types'
import { BlockEnum } from '@/app/components/workflow/types'
import { genNodeMetaData } from '@/app/components/workflow/utils'
import { WriteMode } from './types'
@ -9,7 +9,7 @@ import { WriteMode } from './types'
const i18nPrefix = 'errorMsg'
const metaData = genNodeMetaData({
classification: BlockClassificationEnum.Transform,
classification: BlockClassification.Transform,
sort: 5,
type: BlockEnum.Assigner,
helpLinkUri: 'variable-assigner',

View File

@ -1,7 +1,7 @@
import type { TFunction } from 'i18next'
import type { NodeDefault } from '../../types'
import type { CodeNodeType } from './types'
import { BlockClassificationEnum } from '@/app/components/workflow/block-selector/types'
import { BlockClassification } from '@/app/components/workflow/block-selector/types'
import { BlockEnum } from '@/app/components/workflow/types'
import { genNodeMetaData } from '@/app/components/workflow/utils'
import { CodeLanguage } from './types'
@ -9,7 +9,7 @@ import { CodeLanguage } from './types'
const i18nPrefix = 'errorMsg'
const metaData = genNodeMetaData({
classification: BlockClassificationEnum.Transform,
classification: BlockClassification.Transform,
sort: 1,
type: BlockEnum.Code,
})

View File

@ -1,14 +1,14 @@
import type { TFunction } from 'i18next'
import type { NodeDefault } from '../../types'
import type { DocExtractorNodeType } from './types'
import { BlockClassificationEnum } from '@/app/components/workflow/block-selector/types'
import { BlockClassification } from '@/app/components/workflow/block-selector/types'
import { BlockEnum } from '@/app/components/workflow/types'
import { genNodeMetaData } from '@/app/components/workflow/utils'
const i18nPrefix = 'errorMsg'
const metaData = genNodeMetaData({
classification: BlockClassificationEnum.Transform,
classification: BlockClassification.Transform,
sort: 4,
type: BlockEnum.DocExtractor,
helpLinkUri: 'doc-extractor',

View File

@ -1,13 +1,13 @@
import type { TFunction } from 'i18next'
import type { NodeDefault } from '../../types'
import type { BodyPayload, HttpNodeType } from './types'
import { BlockClassificationEnum } from '@/app/components/workflow/block-selector/types'
import { BlockClassification } from '@/app/components/workflow/block-selector/types'
import { BlockEnum } from '@/app/components/workflow/types'
import { genNodeMetaData } from '@/app/components/workflow/utils'
import { AuthorizationType, BodyType, Method } from './types'
const metaData = genNodeMetaData({
classification: BlockClassificationEnum.Utilities,
classification: BlockClassification.Utilities,
sort: 1,
type: BlockEnum.HttpRequest,
})

View File

@ -1,7 +1,7 @@
import type { TFunction } from 'i18next'
import type { NodeDefault, Var } from '../../types'
import type { DeliveryMethod, EmailConfig, FormInputItem, HumanInputNodeType } from './types'
import { BlockClassificationEnum } from '@/app/components/workflow/block-selector/types'
import { BlockClassification } from '@/app/components/workflow/block-selector/types'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
import { genNodeMetaData } from '@/app/components/workflow/utils'
import { DeliveryMethodType } from './types'
@ -9,7 +9,7 @@ import { DeliveryMethodType } from './types'
const i18nPrefix = 'nodes.humanInput.errorMsg'
const metaData = genNodeMetaData({
classification: BlockClassificationEnum.Logic,
classification: BlockClassification.Logic,
sort: 1,
type: BlockEnum.HumanInput,
})

View File

@ -1,7 +1,7 @@
import type { TFunction } from 'i18next'
import type { NodeDefault } from '../../types'
import type { IfElseNodeType } from './types'
import { BlockClassificationEnum } from '@/app/components/workflow/block-selector/types'
import { BlockClassification } from '@/app/components/workflow/block-selector/types'
import { BlockEnum } from '@/app/components/workflow/types'
import { genNodeMetaData } from '@/app/components/workflow/utils'
import { VarType } from '../../types'
@ -11,7 +11,7 @@ import { isEmptyRelatedOperator } from './utils'
const i18nPrefix = 'errorMsg'
const metaData = genNodeMetaData({
classification: BlockClassificationEnum.Logic,
classification: BlockClassification.Logic,
sort: 1,
type: BlockEnum.IfElse,
helpLinkUri: 'ifelse',

View File

@ -1,14 +1,14 @@
import type { TFunction } from 'i18next'
import type { NodeDefault } from '../../types'
import type { IterationNodeType } from './types'
import { BlockClassificationEnum } from '@/app/components/workflow/block-selector/types'
import { BlockClassification } from '@/app/components/workflow/block-selector/types'
import { genNodeMetaData } from '@/app/components/workflow/utils'
import { BlockEnum, ErrorHandleMode } from '../../types'
const i18nPrefix = ''
const metaData = genNodeMetaData({
classification: BlockClassificationEnum.Logic,
classification: BlockClassification.Logic,
sort: 2,
type: BlockEnum.Iteration,
isTypeFixed: true,

View File

@ -1,7 +1,7 @@
import type { TFunction } from 'i18next'
import type { NodeDefault } from '../../types'
import type { ListFilterNodeType } from './types'
import { BlockClassificationEnum } from '@/app/components/workflow/block-selector/types'
import { BlockClassification } from '@/app/components/workflow/block-selector/types'
import { genNodeMetaData } from '@/app/components/workflow/utils'
import { BlockEnum, VarType } from '../../types'
import { comparisonOperatorNotRequireValue } from '../if-else/utils'
@ -10,7 +10,7 @@ import { OrderBy } from './types'
const i18nPrefix = 'errorMsg'
const metaData = genNodeMetaData({
classification: BlockClassificationEnum.Utilities,
classification: BlockClassification.Utilities,
sort: 2,
type: BlockEnum.ListFilter,
})

View File

@ -1,11 +1,11 @@
import type { NodeDefault } from '../../types'
import type { SimpleNodeType } from '@/app/components/workflow/simple-node/types'
import { BlockClassificationEnum } from '@/app/components/workflow/block-selector/types'
import { BlockClassification } from '@/app/components/workflow/block-selector/types'
import { BlockEnum } from '@/app/components/workflow/types'
import { genNodeMetaData } from '@/app/components/workflow/utils'
const metaData = genNodeMetaData({
classification: BlockClassificationEnum.Logic,
classification: BlockClassification.Logic,
sort: 2,
type: BlockEnum.LoopEnd,
isSingleton: true,

View File

@ -2,7 +2,7 @@ import type { TFunction } from 'i18next'
import type { NodeDefault } from '../../types'
import type { LoopNodeType } from './types'
import type { I18nKeysByPrefix } from '@/types/i18n'
import { BlockClassificationEnum } from '@/app/components/workflow/block-selector/types'
import { BlockClassification } from '@/app/components/workflow/block-selector/types'
import { BlockEnum } from '@/app/components/workflow/types'
import { genNodeMetaData } from '@/app/components/workflow/utils'
import { LOOP_NODE_MAX_COUNT } from '@/config'
@ -14,7 +14,7 @@ import { isEmptyRelatedOperator } from './utils'
const i18nPrefix = 'errorMsg'
const metaData = genNodeMetaData({
classification: BlockClassificationEnum.Logic,
classification: BlockClassification.Logic,
sort: 3,
type: BlockEnum.Loop,
author: 'AICT-Team',

View File

@ -1,7 +1,7 @@
import type { TFunction } from 'i18next'
import type { NodeDefault } from '../../types'
import type { ParameterExtractorNodeType } from './types'
import { BlockClassificationEnum } from '@/app/components/workflow/block-selector/types'
import { BlockClassification } from '@/app/components/workflow/block-selector/types'
import { BlockEnum } from '@/app/components/workflow/types'
import { genNodeMetaData } from '@/app/components/workflow/utils'
import { AppModeEnum } from '@/types/app'
@ -10,7 +10,7 @@ import { ReasoningModeType } from './types'
const i18nPrefix = ''
const metaData = genNodeMetaData({
classification: BlockClassificationEnum.Transform,
classification: BlockClassification.Transform,
sort: 6,
type: BlockEnum.ParameterExtractor,
})

View File

@ -1,7 +1,7 @@
import type { TFunction } from 'i18next'
import type { NodeDefault } from '../../types'
import type { QuestionClassifierNodeType } from './types'
import { BlockClassificationEnum } from '@/app/components/workflow/block-selector/types'
import { BlockClassification } from '@/app/components/workflow/block-selector/types'
import { BlockEnum } from '@/app/components/workflow/types'
import { genNodeMetaData } from '@/app/components/workflow/utils'
import { AppModeEnum } from '@/types/app'
@ -9,7 +9,7 @@ import { AppModeEnum } from '@/types/app'
const i18nPrefix = ''
const metaData = genNodeMetaData({
classification: BlockClassificationEnum.QuestionUnderstand,
classification: BlockClassification.QuestionUnderstand,
sort: 1,
type: BlockEnum.QuestionClassifier,
})

View File

@ -1,14 +1,14 @@
import type { TFunction } from 'i18next'
import type { NodeDefault } from '../../types'
import type { TemplateTransformNodeType } from './types'
import { BlockClassificationEnum } from '@/app/components/workflow/block-selector/types'
import { BlockClassification } from '@/app/components/workflow/block-selector/types'
import { BlockEnum } from '@/app/components/workflow/types'
import { genNodeMetaData } from '@/app/components/workflow/utils'
const i18nPrefix = 'errorMsg'
const metaData = genNodeMetaData({
classification: BlockClassificationEnum.Transform,
classification: BlockClassification.Transform,
sort: 2,
type: BlockEnum.TemplateTransform,
helpLinkUri: 'template',

View File

@ -1,13 +1,13 @@
import type { SelectorParam, TFunction } from 'i18next'
import type { NodeDefault } from '../../types'
import type { VariableAssignerNodeType } from './types'
import { BlockClassificationEnum } from '@/app/components/workflow/block-selector/types'
import { BlockClassification } from '@/app/components/workflow/block-selector/types'
import { BlockEnum } from '@/app/components/workflow/types'
import { genNodeMetaData } from '@/app/components/workflow/utils'
import { VarType } from '../../types'
const metaData = genNodeMetaData({
classification: BlockClassificationEnum.Transform,
classification: BlockClassification.Transform,
sort: 3,
type: BlockEnum.VariableAggregator,
})

View File

@ -1,4 +1,4 @@
import type { OffsetOptions } from '@floating-ui/react'
import type { NodeSelectorProps } from '@/app/components/workflow/block-selector/main'
import type { Node, OnSelectBlock } from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
import { RiAddCircleFill } from '@remixicon/react'
@ -27,7 +27,7 @@ import TipPopup from './tip-popup'
type AddBlockProps = {
renderTrigger?: (open: boolean) => React.ReactNode
renderTriggerAsButtonRoot?: boolean
offset?: OffsetOptions
offset?: NodeSelectorProps['offset']
onClose?: () => void
isolateKeyboardEvents?: boolean
}

View File

@ -2,7 +2,7 @@ import type { Edge as ReactFlowEdge, Node as ReactFlowNode, Viewport, XYPosition
import type { Plugin, PluginMeta } from '@/app/components/plugins/types'
import type { Collection, Tool } from '@/app/components/tools/types'
import type {
BlockClassificationEnum,
BlockClassification,
BlockDefaultValue,
PluginDefaultValue,
} from '@/app/components/workflow/block-selector/types'
@ -329,7 +329,7 @@ export type NodeOutPutVar = {
export type NodeDefault<T = {}> = {
metaData: {
classification: BlockClassificationEnum
classification: BlockClassification
sort: number
type: BlockEnum
title: string

View File

@ -1,4 +1,4 @@
import { BlockClassificationEnum } from '../../block-selector/types'
import { BlockClassification } from '../../block-selector/types'
import { BlockEnum } from '../../types'
import { genNodeMetaData } from '../gen-node-meta-data'
@ -11,7 +11,7 @@ describe('genNodeMetaData', () => {
})
expect(result).toEqual({
classification: BlockClassificationEnum.Default,
classification: BlockClassification.Default,
sort: 1,
type: BlockEnum.LLM,
title: 'LLM Node',
@ -27,7 +27,7 @@ describe('genNodeMetaData', () => {
it('should use custom values when provided', () => {
const result = genNodeMetaData({
classification: BlockClassificationEnum.Logic,
classification: BlockClassification.Logic,
sort: 5,
type: BlockEnum.Start,
title: 'Start',
@ -40,7 +40,7 @@ describe('genNodeMetaData', () => {
isTypeFixed: true,
})
expect(result.classification).toBe(BlockClassificationEnum.Logic)
expect(result.classification).toBe(BlockClassification.Logic)
expect(result.author).toBe('Custom')
expect(result.helpLinkUri).toBe('code')
expect(result.isRequired).toBe(true)

View File

@ -1,9 +1,9 @@
import type { BlockEnum } from '@/app/components/workflow/types'
import type { UseDifyNodesPath } from '@/types/doc-paths'
import { BlockClassificationEnum } from '@/app/components/workflow/block-selector/types'
import { BlockClassification } from '@/app/components/workflow/block-selector/types'
type GenNodeMetaDataParams = {
classification?: BlockClassificationEnum
classification?: BlockClassification
sort: number
type: BlockEnum
title?: string
@ -16,7 +16,7 @@ type GenNodeMetaDataParams = {
isTypeFixed?: boolean
}
export const genNodeMetaData = ({
classification = BlockClassificationEnum.Default,
classification = BlockClassification.Default,
sort,
type,
title = '',

View File

@ -4,7 +4,7 @@ import type { TFunction } from 'i18next'
import type { ReactNode } from 'react'
import type { AgentOrchestrateAddAction, AgentOrchestrateAddedItem } from '../add-actions-context'
import type { Tool } from '@/app/components/tools/types'
import type { ToolTypeEnum, ToolValue } from '@/app/components/workflow/block-selector/types'
import type { ToolValue } from '@/app/components/workflow/block-selector/types'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import type {
AgentFileNode,
@ -21,7 +21,7 @@ import { getMarketplaceCategoryUrl } from '@/app/components/plugins/marketplace/
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { CollectionType } from '@/app/components/tools/types'
import BlockIcon from '@/app/components/workflow/block-icon'
import { ToolTypeEnum as ToolTabEnum } from '@/app/components/workflow/block-selector/types'
import { ToolType } from '@/app/components/workflow/block-selector/types'
import { BlockEnum } from '@/app/components/workflow/types'
import { useGetLanguage } from '@/context/i18n'
import { ENABLE_AGENT_CLI_TOOLS } from '@/features/agent-v2/agent-detail/configure/feature-flags'
@ -330,10 +330,10 @@ function AgentPromptToolRows({
: []
const availableProviders = useMemo(() => {
if (activeTab === 'all') return [...builtInTools, ...workflowTools, ...customTools, ...mcpTools]
if (activeTab === ToolTabEnum.BuiltIn) return builtInTools
if (activeTab === ToolTabEnum.Workflow) return workflowTools
if (activeTab === ToolTabEnum.Custom) return customTools
if (activeTab === ToolTabEnum.MCP) return mcpTools
if (activeTab === ToolType.BuiltIn) return builtInTools
if (activeTab === ToolType.Workflow) return workflowTools
if (activeTab === ToolType.Custom) return customTools
if (activeTab === ToolType.MCP) return mcpTools
return []
}, [activeTab, builtInTools, customTools, mcpTools, workflowTools])
@ -345,15 +345,15 @@ function AgentPromptToolRows({
const tabs = [
{ key: 'all' as const, label: t(($) => $['agentDetail.configure.tools.toolTabs.all']) },
{
key: ToolTabEnum.BuiltIn,
key: ToolType.BuiltIn,
label: t(($) => $['agentDetail.configure.tools.toolTabs.plugins']),
},
{
key: ToolTabEnum.Workflow,
key: ToolType.Workflow,
label: t(($) => $['agentDetail.configure.tools.toolTabs.workflow']),
},
{ key: ToolTabEnum.Custom, label: t(($) => $['agentDetail.configure.tools.toolTabs.custom']) },
{ key: ToolTabEnum.MCP, label: t(($) => $['agentDetail.configure.tools.toolTabs.mcp']) },
{ key: ToolType.Custom, label: t(($) => $['agentDetail.configure.tools.toolTabs.custom']) },
{ key: ToolType.MCP, label: t(($) => $['agentDetail.configure.tools.toolTabs.mcp']) },
...(ENABLE_AGENT_CLI_TOOLS
? [{ key: 'cli' as const, label: t(($) => $['agentDetail.configure.tools.toolTabs.cli']) }]
: []),
@ -449,7 +449,7 @@ function AgentPromptToolRows({
)
}
type ToolPromptTab = ToolTypeEnum | 'all' | 'cli'
type ToolPromptTab = ToolType | 'cli'
function getLocalizedText(text: Record<string, string> | undefined | null, language: string) {
if (!text) return ''

View File

@ -17,7 +17,7 @@ import { useMutation, useQuery } from '@tanstack/react-query'
import { FormTypeEnum } from '@/app/components/base/form/types'
import { SupportedCreationMethods } from '@/app/components/plugins/types'
import { CollectionType } from '@/app/components/tools/types'
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
import { TriggerCredentialType } from '@/app/components/workflow/block-selector/types'
import { consoleClient, consoleQuery } from '@/service/client'
import { useInvalid } from './use-base'
@ -346,16 +346,16 @@ export const normalizeTriggerProvider = (
const normalizeCredentialType = (
credentialType: GeneratedTriggerSubscription['credential_type'],
): TriggerCredentialTypeEnum => {
): TriggerCredentialType => {
switch (credentialType) {
case TriggerCredentialTypeEnum.ApiKey:
return TriggerCredentialTypeEnum.ApiKey
case TriggerCredentialTypeEnum.Oauth2:
return TriggerCredentialTypeEnum.Oauth2
case TriggerCredentialTypeEnum.Unauthorized:
return TriggerCredentialTypeEnum.Unauthorized
case TriggerCredentialType.ApiKey:
return TriggerCredentialType.ApiKey
case TriggerCredentialType.Oauth2:
return TriggerCredentialType.Oauth2
case TriggerCredentialType.Unauthorized:
return TriggerCredentialType.Unauthorized
}
return TriggerCredentialTypeEnum.Unauthorized
return TriggerCredentialType.Unauthorized
}
const normalizeTriggerSubscription = (