mirror of
https://github.com/langgenius/dify.git
synced 2026-07-19 16:44:00 -04:00
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:
@@ -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
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
@@ -1 +0,0 @@
|
||||
export { default as ArrowDownRoundFill } from './ArrowDownRoundFill'
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
+2
-2
@@ -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: {},
|
||||
|
||||
+2
-2
@@ -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: {},
|
||||
|
||||
+2
-2
@@ -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: {},
|
||||
|
||||
+2
-2
@@ -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: {},
|
||||
|
||||
+2
-2
@@ -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: {},
|
||||
|
||||
+2
-2
@@ -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: {},
|
||||
|
||||
+6
-6
@@ -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: {},
|
||||
|
||||
+2
-2
@@ -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: {},
|
||||
|
||||
+4
-4
@@ -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({
|
||||
|
||||
+2
-2
@@ -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: {},
|
||||
|
||||
+5
-5
@@ -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<
|
||||
|
||||
+14
-14
@@ -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' },
|
||||
}),
|
||||
})}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-3
@@ -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,
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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`,
|
||||
},
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+4
-1
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = '',
|
||||
|
||||
+11
-11
@@ -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 ''
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
Reference in New Issue
Block a user