diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index a236d235f8a..64375380f27 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -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 diff --git a/packages/dify-ui/src/collapsible/index.stories.tsx b/packages/dify-ui/src/collapsible/index.stories.tsx index e90303c31cd..9e538da94ac 100644 --- a/packages/dify-ui/src/collapsible/index.stories.tsx +++ b/packages/dify-ui/src/collapsible/index.stories.tsx @@ -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.', }, }, }, diff --git a/packages/dify-ui/src/preview-card/__tests__/index.spec.tsx b/packages/dify-ui/src/preview-card/__tests__/index.spec.tsx index a4c2c03002a..7f155fe9952 100644 --- a/packages/dify-ui/src/preview-card/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/preview-card/__tests__/index.spec.tsx @@ -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( - - Open - - } - /> + Open Default content , ) - 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( - - Open - - } - /> + Open Custom placement content , ) - 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( - - Open - - } - /> + Open Preview body @@ -102,40 +67,26 @@ describe('PreviewCardContent', () => { , ) - 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( - - Open - - } - /> - + Preview destination + Preview body , ) - 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') }) }) }) diff --git a/packages/dify-ui/src/preview-card/index.stories.tsx b/packages/dify-ui/src/preview-card/index.stories.tsx index 6dee917e455..66739e26127 100644 --- a/packages/dify-ui/src/preview-card/index.stories.tsx +++ b/packages/dify-ui/src/preview-card/index.stories.tsx @@ -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 ` - } - /> - -
-
gpt-4o
-
- Multimodal flagship model. Vision, audio and 128k context. -
-
-
- - ), -} - const PLACEMENTS: Placement[] = [ 'top-start', 'top', @@ -152,13 +117,9 @@ const PlacementsDemo = () => { ))} - - Hover me - - } - /> + + Hover me +
@@ -187,12 +148,11 @@ const CustomDelayDemo = () => ( - Snappy trigger - - } - /> + href="#preview-card-delay" + className={triggerButtonClassName} + > + Snappy trigger +
Fast hover
diff --git a/packages/dify-ui/src/preview-card/index.tsx b/packages/dify-ui/src/preview-card/index.tsx index 38a5b5aa23e..086e48baffb 100644 --- a/packages/dify-ui/src/preview-card/index.tsx +++ b/packages/dify-ui/src/preview-card/index.tsx @@ -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 = { diff --git a/web/app/components/base/icons/src/vender/solid/arrows/ArrowDownRoundFill.json b/web/app/components/base/icons/src/vender/solid/arrows/ArrowDownRoundFill.json deleted file mode 100644 index edd488350c4..00000000000 --- a/web/app/components/base/icons/src/vender/solid/arrows/ArrowDownRoundFill.json +++ /dev/null @@ -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" -} diff --git a/web/app/components/base/icons/src/vender/solid/arrows/ArrowDownRoundFill.tsx b/web/app/components/base/icons/src/vender/solid/arrows/ArrowDownRoundFill.tsx deleted file mode 100644 index 9ee4105a103..00000000000 --- a/web/app/components/base/icons/src/vender/solid/arrows/ArrowDownRoundFill.tsx +++ /dev/null @@ -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 & { - ref?: React.RefObject> -}) => - -Icon.displayName = 'ArrowDownRoundFill' - -export default Icon diff --git a/web/app/components/base/icons/src/vender/solid/arrows/index.ts b/web/app/components/base/icons/src/vender/solid/arrows/index.ts deleted file mode 100644 index e27f5526a31..00000000000 --- a/web/app/components/base/icons/src/vender/solid/arrows/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as ArrowDownRoundFill } from './ArrowDownRoundFill' diff --git a/web/app/components/base/search-input/__tests__/index.spec.tsx b/web/app/components/base/search-input/__tests__/index.spec.tsx index 100e8d6e58d..410f54c2a4e 100644 --- a/web/app/components/base/search-input/__tests__/index.spec.tsx +++ b/web/app/components/base/search-input/__tests__/index.spec.tsx @@ -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() + render( {}} />) + + 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( {}} autoFocus />) expect(screen.getByRole('searchbox', { name: 'common.operation.search' })).toHaveFocus() }) diff --git a/web/app/components/base/search-input/index.tsx b/web/app/components/base/search-input/index.tsx index fe531a8eb16..b047b7e2b62 100644 --- a/web/app/components/base/search-input/index.tsx +++ b/web/app/components/base/search-input/index.tsx @@ -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 value: string onValueChange: (value: string) => void placeholder?: string @@ -12,6 +14,7 @@ type SearchInputProps = { } & Pick export function SearchInput({ + ref, placeholder, className, value, @@ -25,6 +28,7 @@ export function SearchInput({ const compositionCommitRef = useRef(null) const [compositionValue, setCompositionValue] = useState('') const inputValue = isComposingRef.current ? compositionValue : value + useImperativeHandle(ref, () => inputRef.current as HTMLInputElement, []) const handleClear = () => { isComposingRef.current = false diff --git a/web/app/components/integrations/__tests__/tool-provider-list.spec.tsx b/web/app/components/integrations/__tests__/tool-provider-list.spec.tsx index 784af405349..317fc40d7fb 100644 --- a/web/app/components/integrations/__tests__/tool-provider-list.spec.tsx +++ b/web/app/components/integrations/__tests__/tool-provider-list.spec.tsx @@ -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) }) }) diff --git a/web/app/components/plugins/marketplace/search-box/__tests__/index.spec.tsx b/web/app/components/plugins/marketplace/search-box/__tests__/index.spec.tsx index 73c7f8704f7..817606d7827 100644 --- a/web/app/components/plugins/marketplace/search-box/__tests__/index.spec.tsx +++ b/web/app/components/plugins/marketplace/search-box/__tests__/index.spec.tsx @@ -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() + + render( + , + ) + + expect(ref.current).toBe(screen.getByPlaceholderText('Search plugins')) + }) }) diff --git a/web/app/components/plugins/marketplace/search-box/index.tsx b/web/app/components/plugins/marketplace/search-box/index.tsx index 5e9e271c1da..55ac59bdb39 100644 --- a/web/app/components/plugins/marketplace/search-box/index.tsx +++ b/web/app/components/plugins/marketplace/search-box/index.tsx @@ -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 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 = ({ )}
= {}): Trigg id: 'sub-1', name: 'Subscription One', provider: 'provider-1', - credential_type: TriggerCredentialTypeEnum.ApiKey, + credential_type: TriggerCredentialType.ApiKey, credentials: {}, endpoint: 'https://example.com', parameters: {}, diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/list-view.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/list-view.spec.tsx index 7a849d8cd9c..d9c3a2dc9b4 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/list-view.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/list-view.spec.tsx @@ -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 = {}): Trigg id: 'sub-1', name: 'Subscription One', provider: 'provider-1', - credential_type: TriggerCredentialTypeEnum.ApiKey, + credential_type: TriggerCredentialType.ApiKey, credentials: {}, endpoint: 'https://example.com', parameters: {}, diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-entry.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-entry.spec.tsx index c48ce82fcaf..30c652434a3 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-entry.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-entry.spec.tsx @@ -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 = {}): Trigg id: 'sub-1', name: 'Subscription One', provider: 'provider-1', - credential_type: TriggerCredentialTypeEnum.ApiKey, + credential_type: TriggerCredentialType.ApiKey, credentials: {}, endpoint: 'https://example.com', parameters: {}, diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-view.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-view.spec.tsx index 1e330b2b2b1..df028cb4280 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-view.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-view.spec.tsx @@ -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 = {}): Trigg id: 'sub-1', name: 'Subscription One', provider: 'provider-1', - credential_type: TriggerCredentialTypeEnum.ApiKey, + credential_type: TriggerCredentialType.ApiKey, credentials: {}, endpoint: 'https://example.com', parameters: {}, diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/subscription-card.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/subscription-card.spec.tsx index 27ea82f1ecf..c7680457947 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/subscription-card.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/subscription-card.spec.tsx @@ -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 = {}): Trigg id: 'sub-1', name: 'Subscription One', provider: 'provider-1', - credential_type: TriggerCredentialTypeEnum.ApiKey, + credential_type: TriggerCredentialType.ApiKey, credentials: {}, endpoint: 'https://example.com', parameters: {}, diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/common-modal.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/common-modal.spec.tsx index 4c47b226a6b..dd22d041d36 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/common-modal.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/common-modal.spec.tsx @@ -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: {}, diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/index.spec.tsx index 72dea627880..504fd9ec9eb 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/index.spec.tsx @@ -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 = {}): 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: {}, diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/oauth-client.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/oauth-client.spec.tsx index 5428d0c53f4..910a15dc179 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/oauth-client.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/oauth-client.spec.tsx @@ -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: {}, diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-common-modal-state.spec.ts b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-common-modal-state.spec.ts index 7cbeb8ff0f0..5bb4fb71c4f 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-common-modal-state.spec.ts +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-common-modal-state.spec.ts @@ -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({ diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-oauth-client-state.spec.ts b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-oauth-client-state.spec.ts index 265aa1be09a..c5ef694e415 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-oauth-client-state.spec.ts +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-oauth-client-state.spec.ts @@ -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: {}, diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-common-modal-state.ts b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-common-modal-state.ts index 95815ec82a1..e092e631be8 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-common-modal-state.ts +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-common-modal-state.ts @@ -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.APIKEY]: TriggerCredentialTypeEnum.ApiKey, - [SupportedCreationMethods.OAUTH]: TriggerCredentialTypeEnum.Oauth2, - [SupportedCreationMethods.MANUAL]: TriggerCredentialTypeEnum.Unauthorized, +const CREDENTIAL_TYPE_MAP: Record = { + [SupportedCreationMethods.APIKEY]: TriggerCredentialType.ApiKey, + [SupportedCreationMethods.OAUTH]: TriggerCredentialType.Oauth2, + [SupportedCreationMethods.MANUAL]: TriggerCredentialType.Unauthorized, } export const MODAL_TITLE_KEY_MAP: Record< diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/index.spec.tsx index 2b5ec6065a0..f89ab95087e 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/index.spec.tsx @@ -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 = {}): 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( { , ) @@ -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', () => { { { { 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', () => { { { { const credentialType = subscription.credential_type switch (credentialType) { - case TriggerCredentialTypeEnum.Unauthorized: + case TriggerCredentialType.Unauthorized: return ( { pluginDetail={pluginDetail} /> ) - case TriggerCredentialTypeEnum.Oauth2: + case TriggerCredentialType.Oauth2: return ( ) - case TriggerCredentialTypeEnum.ApiKey: + case TriggerCredentialType.ApiKey: return ( ({ 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() @@ -16,7 +16,7 @@ describe('Empty', () => { }) it('links the workflow guide to Studio and the documentation', () => { - render() + render() 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() + render() expect(screen.queryByRole('link')).not.toBeInTheDocument() }) diff --git a/web/app/components/tools/provider/empty.tsx b/web/app/components/tools/provider/empty.tsx index 7d0da305491..502be93d011 100644 --- a/web/app/components/tools/provider/empty.tsx +++ b/web/app/components/tools/provider/empty.tsx @@ -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 (
diff --git a/web/app/components/tools/utils/index.ts b/web/app/components/tools/utils/index.ts index 9a075569527..34b3e28da50 100644 --- a/web/app/components/tools/utils/index.ts +++ b/web/app/components/tools/utils/index.ts @@ -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 } } diff --git a/web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-selection-panel.tsx b/web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-selection-panel.tsx index 9b3fb27e088..982ffb01802 100644 --- a/web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-selection-panel.tsx +++ b/web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-selection-panel.tsx @@ -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 = ({ offset={-200} noBlocks={true} showStartTab={true} - defaultActiveTab={TabsEnum.Start} - forceShowStartContent={true} + defaultActiveTab={TabType.Start} availableBlocksTypes={[ BlockEnum.TriggerSchedule, BlockEnum.TriggerWebhook, diff --git a/web/app/components/workflow/block-selector/__tests__/all-tools.spec.tsx b/web/app/components/workflow/block-selector/__tests__/all-tools.spec.tsx index aab04fbaf8f..8fa92760e79 100644 --- a/web/app/components/workflow/block-selector/__tests__/all-tools.spec.tsx +++ b/web/app/components/workflow/block-selector/__tests__/all-tools.spec.tsx @@ -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 }) => ( + + ), +})) + 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( + , + ) + + await user.click(screen.getByRole('button', { name: 'Load more RAG tools' })) + + expect(onTagsChange).toHaveBeenCalledWith(['rag']) + }) }) diff --git a/web/app/components/workflow/block-selector/__tests__/blocks.spec.tsx b/web/app/components/workflow/block-selector/__tests__/blocks.spec.tsx index a5d5d57bc07..7322c743cd8 100644 --- a/web/app/components/workflow/block-selector/__tests__/blocks.spec.tsx +++ b/web/app/components/workflow/block-selector/__tests__/blocks.spec.tsx @@ -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), ]} /> @@ -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)]} /> , @@ -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)]} /> , @@ -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)]} /> , diff --git a/web/app/components/workflow/block-selector/__tests__/featured-tools.spec.tsx b/web/app/components/workflow/block-selector/__tests__/featured-tools.spec.tsx index 4179e304a9b..8e6a4712fe4 100644 --- a/web/app/components/workflow/block-selector/__tests__/featured-tools.spec.tsx +++ b/web/app/components/workflow/block-selector/__tests__/featured-tools.spec.tsx @@ -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: () => } + />, + ) + + 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( + ( + + )} + />, + ) + + 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: { diff --git a/web/app/components/workflow/block-selector/__tests__/start-blocks.spec.tsx b/web/app/components/workflow/block-selector/__tests__/start-blocks.spec.tsx index 840e0edfc6f..5d02ae0d488 100644 --- a/web/app/components/workflow/block-selector/__tests__/start-blocks.spec.tsx +++ b/web/app/components/workflow/block-selector/__tests__/start-blocks.spec.tsx @@ -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) }) diff --git a/web/app/components/workflow/block-selector/__tests__/tabs.spec.tsx b/web/app/components/workflow/block-selector/__tests__/tabs.spec.tsx index 46a8156d3b9..d56bdee5c2e 100644 --- a/web/app/components/workflow/block-selector/__tests__/tabs.spec.tsx +++ b/web/app/components/workflow/block-selector/__tests__/tabs.spec.tsx @@ -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 }> + buildInTools: Array<{ icon: string | Record; name: string }> showFeatured: boolean featuredLoading: boolean onFeaturedInstallSuccess: () => Promise }) => (
tools-content - {props.buildInTools.map((tool, index) => ( - {typeof tool.icon === 'string' ? tool.icon : 'object-icon'} + {props.buildInTools.map((tool) => ( + {typeof tool.icon === 'string' ? tool.icon : 'object-icon'} ))} {props.showFeatured ? 'featured-on' : 'featured-off'} {props.featuredLoading ? 'featured-loading' : 'featured-idle'} @@ -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(), 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:
filter
, } it('should render start content and disabled tab tooltip text', async () => { const user = userEvent.setup() - render() + render() 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() - render() + 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() - 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() - expect(mockSetState).toHaveBeenCalled() }) it('should ignore clicks on disabled and already active tabs', async () => { const user = userEvent.setup() - const onActiveTabChange = vi.fn() + render() - render() + 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 ( + <> + + + + ) + } + + render() + + 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( - , ) @@ -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() + render() 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() + render() 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() + render() 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() + render() 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() + it('should compose start content directly without tab semantics in standalone mode', () => { + render() expect(screen.getByText('start-content'))!.toBeInTheDocument() + expect(screen.queryByRole('tablist')).not.toBeInTheDocument() }) }) diff --git a/web/app/components/workflow/block-selector/all-tools.tsx b/web/app/components/workflow/block-selector/all-tools.tsx index 376cf4dfe7c..070cb048365 100644 --- a/web/app/components/workflow/block-selector/all-tools.tsx +++ b/web/app/components/workflow/block-selector/all-tools.tsx @@ -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> + 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.All) const [activeView, setActiveView] = useState(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 | null) => { @@ -182,9 +186,9 @@ const AllTools = ({ const pluginRef = useRef(null) const wrapElemRef = useRef(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 = ({
{tabs.map((tab) => ( -
setActiveTab(tab.key)} > {tab.name} -
+ ))}
{isSupportGroupView && } @@ -242,7 +248,7 @@ const AllTools = ({ )} {shouldShowFeatured && ( diff --git a/web/app/components/workflow/block-selector/blocks.tsx b/web/app/components/workflow/block-selector/blocks.tsx index 0de591c14a3..3a5633e67ce 100644 --- a/web/app/components/workflow/block-selector/blocks.tsx +++ b/web/app/components/workflow/block-selector/blocks.tsx @@ -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(), []) + 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 ( - onSelect(block.metaData.type)} - > - - - {block.metaData.title} - - {block.metaData.type === BlockEnum.LoopEnd && ( - $['nodes.loop.loopNode'], { ns: 'workflow' })} - className="ml-2 shrink-0" - /> - )} - - } - /> + + onSelect(block.metaData.type)} + > + + + {block.metaData.title} + + {block.metaData.type === BlockEnum.LoopEnd && ( + $['nodes.loop.loopNode'], { ns: 'workflow' })} + className="ml-2 shrink-0" + /> + )} + + } + /> + {previewDescriptionId && ( + + {block.metaData.description} + + )} + ) })}
) }, - [groups, onSelect, previewCardHandle, t, store], + [groups, onSelect, previewCardHandle, previewDescriptionBaseId, t, store], ) return ( @@ -198,15 +210,13 @@ function BlockPreviewCard({ payload }: BlockPreviewCardProps) { const { block } = payload return ( - -
- -
{block.metaData.title}
-
- {block.metaData.description} -
+ + +
{block.metaData.title}
+
+ {block.metaData.description}
- +
) } diff --git a/web/app/components/workflow/block-selector/constants.tsx b/web/app/components/workflow/block-selector/constants.tsx index a036cc270cb..3c60b33ef07 100644 --- a/web/app/components/workflow/block-selector/constants.tsx +++ b/web/app/components/workflow/block-selector/constants.tsx @@ -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', }, diff --git a/web/app/components/workflow/block-selector/featured-tools.tsx b/web/app/components/workflow/block-selector/featured-tools.tsx index e8c3c588f7d..ee08898aef1 100644 --- a/web/app/components/workflow/block-selector/featured-tools.tsx +++ b/web/app/components/workflow/block-selector/featured-tools.tsx @@ -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 ( -
- + - {!isCollapsed && ( - <> - {isLoading && ( -
- -
- )} + + {isLoading && ( +
+ +
+ )} - {showEmptyState && ( -

- - {t(($) => $['tabs.noFeaturedPlugins'], { ns: 'workflow' })} - -

- )} + {showEmptyState && ( +

+ + {t(($) => $['tabs.noFeaturedPlugins'], { ns: 'workflow' })} + +

+ )} - {!showEmptyState && !isLoading && ( - <> - {visibleInstalledProviders.length > 0 && ( - + {visibleInstalledProviders.length > 0 && ( + + )} + + {visibleUninstalledPlugins.length > 0 && ( +
+ {visibleUninstalledPlugins.map((plugin) => ( + { + await onInstallSuccess?.() + }} + t={t} + /> + ))} +
+ )} + + )} + + {!isLoading && totalVisible > 0 && canToggleVisibility && ( + + )} +
{({ payload }) => ( )} -
+ ) } @@ -266,63 +274,77 @@ function FeaturedToolUninstalledItem({ } }, [actionOpen]) - const row = ( -
-
+ const detailsLink = ( + +
{label}
-
- - {installCountLabel} - -
- {canInstallPlugin && ( - - )} - -
-
-
+ ) 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. - - ) : ( - row - )} + + {description ? ( + + ) : ( + detailsLink + )} +
+ + {installCountLabel} + +
+ {canInstallPlugin && ( + + )} + +
+
+
{isInstallModalOpen && canInstallPlugin && ( -
- -
{payload.label}
-
- {payload.description} -
+ + +
{payload.label}
+
+ {payload.description}
- +
) } diff --git a/web/app/components/workflow/block-selector/featured-triggers.tsx b/web/app/components/workflow/block-selector/featured-triggers.tsx index 15277ad856a..04160c4deaf 100644 --- a/web/app/components/workflow/block-selector/featured-triggers.tsx +++ b/web/app/components/workflow/block-selector/featured-triggers.tsx @@ -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 ( -
- + - {!isCollapsed && ( - <> - {isLoading && ( -
- -
- )} + + {isLoading && ( +
+ +
+ )} - {showEmptyState && ( -

- - {t(($) => $['tabs.noFeaturedTriggers'], { ns: 'workflow' })} - -

- )} - - {!showEmptyState && !isLoading && ( -
- {visibleInstalledProviders.map((provider) => ( - - ))} - - {visibleUninstalledPlugins.map((plugin) => ( -
- { - await onInstallSuccess?.() - }} - t={t} - /> -
- ))} -
- )} - - {!isLoading && totalVisible > 0 && canToggleVisibility && ( -
{ - setVisibleCount((count) => { - if (count >= maxAvailable) return INITIAL_VISIBLE_COUNT - - return Math.min(count + INITIAL_VISIBLE_COUNT, maxAvailable) - }) - }} + {showEmptyState && ( +

+ -

- - {isExpanded ? ( - - ) : ( - - )} -
-
- {t(($) => $[isExpanded ? 'tabs.showLessFeatured' : 'tabs.showMoreFeatured'], { - ns: 'workflow', - })} + {t(($) => $['tabs.noFeaturedTriggers'], { ns: 'workflow' })} + +

+ )} + + {!showEmptyState && !isLoading && ( +
+ {visibleInstalledProviders.map((provider) => ( + + ))} + + {visibleUninstalledPlugins.map((plugin) => ( +
+ { + await onInstallSuccess?.() + }} + t={t} + />
+ ))} +
+ )} + + {!isLoading && totalVisible > 0 && canToggleVisibility && ( + + )} + {({ payload }) => ( )} -
+ ) } @@ -275,8 +282,13 @@ function FeaturedTriggerUninstalledItem({ } }, [actionOpen]) - const row = ( - + const detailsLink = ( +
{label}
-
- - {installCountLabel} - -
- {canInstallPlugin && ( - - )} - -
-
- + ) 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. - - ) : ( - row - )} + + {description ? ( + + ) : ( + detailsLink + )} +
+ + {installCountLabel} + +
+ {canInstallPlugin && ( + + )} + +
+
+
{isInstallModalOpen && canInstallPlugin && ( -
- -
{payload.label}
-
- {payload.description} -
+ + +
{payload.label}
+
+ {payload.description}
- +
) } diff --git a/web/app/components/workflow/block-selector/hooks.ts b/web/app/components/workflow/block-selector/hooks.ts index 62ed987256f..ae5ff994eaa 100644 --- a/web/app/components/workflow/block-selector/hooks.ts +++ b/web/app/components/workflow/block-selector/hooks.ts @@ -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', }) } diff --git a/web/app/components/workflow/block-selector/main.tsx b/web/app/components/workflow/block-selector/main.tsx index 58dc84e7962..1c35dbf8bf8 100644 --- a/web/app/components/workflow/block-selector/main.tsx +++ b/web/app/components/workflow/block-selector/main.tsx @@ -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([]) const [localOpen, setLocalOpen] = useState(false) + const searchInputRef = useRef(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>((e) => { e.stopPropagation() }, []) + const handlePopupClick = useCallback>((event) => { + event.stopPropagation() + }, []) const handleSelect = useCallback( (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 = ( $['common.addBlock'], { ns: 'workflow' })} @@ -223,8 +178,7 @@ function NodeSelector({ ) : (
{triggerElement}
) - 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 } : {}), + }} >
- e.stopPropagation()}> - {activeTab === TabsEnum.Start && ( - - )} - {activeTab === TabsEnum.Blocks && ( - setSearchText(e.target.value)} - onClear={() => setSearchText('')} - /> - )} - {activeTab === TabsEnum.Sources && ( - setSearchText(e.target.value)} - onClear={() => setSearchText('')} - /> - )} - {activeTab === TabsEnum.Tools && ( - $.searchTools, { ns: 'plugin' })!} - inputClassName="grow" - /> - )} -
- ) - } onSelect={handleSelect} - searchText={filterSearchText} - tags={tags} + onRequestClose={() => handleOpenChange(false)} availableBlocksTypes={availableBlocksTypes} - noBlocks={noBlocks} dataSources={dataSources} - noTools={noTools} - onTagsChange={setTags} - forceShowStartContent={forceShowStartContent} - snippetsElem={ - disableSnippetsTab ? undefined : ( - handleOpenChange(false)} - /> - ) - } + snippetInsertPayload={snippetInsertPayload} />
diff --git a/web/app/components/workflow/block-selector/preview-card.tsx b/web/app/components/workflow/block-selector/preview-card.tsx new file mode 100644 index 00000000000..a752f918f7d --- /dev/null +++ b/web/app/components/workflow/block-selector/preview-card.tsx @@ -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 ( + + + {children} + + + ) +} diff --git a/web/app/components/workflow/block-selector/rag-tool-recommendations/index.tsx b/web/app/components/workflow/block-selector/rag-tool-recommendations/index.tsx index 5bc1725071a..5d6c47c18fa 100644 --- a/web/app/components/workflow/block-selector/rag-tool-recommendations/index.tsx +++ b/web/app/components/workflow/block-selector/rag-tool-recommendations/index.tsx @@ -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> + 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 (
{!isCollapsed && ( @@ -103,17 +98,21 @@ const RAGToolRecommendations = ({ onSelect={onSelect} viewType={viewType} /> -
- +
{t(($) => $['operation.more'], { ns: 'common' })}
-
+ )} @@ -121,5 +120,3 @@ const RAGToolRecommendations = ({
) } - -export default React.memo(RAGToolRecommendations) diff --git a/web/app/components/workflow/block-selector/snippets/__tests__/index.spec.tsx b/web/app/components/workflow/block-selector/snippets/__tests__/index.spec.tsx index ffd1e83efbe..baa8c8a1dee 100644 --- a/web/app/components/workflow/block-selector/snippets/__tests__/index.spec.tsx +++ b/web/app/components/workflow/block-selector/snippets/__tests__/index.spec.tsx @@ -45,7 +45,16 @@ describe('Snippets', () => { describe('Rendering', () => { it('should render loading skeleton when loading', () => { - const { container } = render() + mockUseInfiniteSnippetList.mockReturnValue({ + data: undefined, + isLoading: true, + isFetching: true, + isFetchingNextPage: false, + fetchNextPage: vi.fn(), + hasNextPage: undefined, + }) + + const { container } = render() 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, diff --git a/web/app/components/workflow/block-selector/snippets/index.tsx b/web/app/components/workflow/block-selector/snippets/index.tsx index 3cf35398fe8..974426db476 100644 --- a/web/app/components/workflow/block-selector/snippets/index.tsx +++ b/web/app/components/workflow/block-selector/snippets/index.tsx @@ -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[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 = ( -
+
) - if (loading || isLoading || (isFetching && snippets.length === 0)) { - return ( - <> - {filter} - - + const content = + isLoading || (isFetching && snippets.length === 0) ? ( + + ) : !snippets.length ? ( + + ) : ( + + + + {snippets.map((item) => { + const row = ( + handleSnippetClick(item.id)} + onMouseEnter={() => setHoveredSnippetId(item.id)} + onMouseLeave={() => + setHoveredSnippetId((current) => (current === item.id ? null : current)) + } + /> + ) + + if (!item.description) return
{row}
+ + return ( + + + + + + + ) + })} + {isFetchingNextPage && ( +
+ +
+ )} +
+
+ + + +
) - } return ( <> {filter} - {!snippets.length ? ( - - ) : ( - - - - {snippets.map((item) => { - const row = ( - handleSnippetClick(item.id)} - onMouseEnter={() => setHoveredSnippetId(item.id)} - onMouseLeave={() => - setHoveredSnippetId((current) => (current === item.id ? null : current)) - } - /> - ) - - if (!item.description) return
{row}
- - return ( - - - - - - - ) - })} - {isFetchingNextPage && ( -
- -
- )} -
-
- - - -
- )} +
{content}
) } diff --git a/web/app/components/workflow/block-selector/start-blocks.tsx b/web/app/components/workflow/block-selector/start-blocks.tsx index 87489f96308..5e93e744495 100644 --- a/web/app/components/workflow/block-selector/start-blocks.tsx +++ b/web/app/components/workflow/block-selector/start-blocks.tsx @@ -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(), []) - // 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 = ( { @@ -153,34 +156,38 @@ const StartBlocks = ({ if (isUserInputDisabled) { return ( - - - -

{disabledReason}

-
-
+ + + + {disabledReason} + + + {description} + + ) } return ( - + + + + {description} + + ) }, [ 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 ( - -
- -
- {t(($) => $[`blocks.${block.type}`], { ns: 'workflow' })} + + +
{label}
+
{description}
+ {showDifyTeamAuthor && ( +
+ {t(($) => $.author, { ns: 'tools' })} {t(($) => $.difyTeam, { ns: 'workflow' })}
-
{description}
- {showDifyTeamAuthor && ( -
- {t(($) => $.author, { ns: 'tools' })} {t(($) => $.difyTeam, { ns: 'workflow' })} -
- )} -
- + )} + ) } diff --git a/web/app/components/workflow/block-selector/tabs.tsx b/web/app/components/workflow/block-selector/tabs.tsx index d2b70361b85..3fbb902023f 100644 --- a/web/app/components/workflow/block-selector/tabs.tsx +++ b/web/app/components/workflow/block-selector/tabs.tsx @@ -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> +type TabConfig = { + key: TabType + name: string + disabled?: boolean + disabledTip?: ReactNode +} + +type SelectorContentProps = { + defaultTab: TabType + standalonePanel?: TabType + tabs: TabConfig[] + searchInputRef: Ref 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[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 = {} - - 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 = ( + + {tab.name} + ) - const handleClick = () => { - if (tab.disabled || activeTab === tab.key) return - onActiveTabChange(tab.key) + if (!tab.disabled) return tabElement + + return ( + + + + {tab.disabledTip || fallbackDisabledTip} + + + ) +} + +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([]) + const fallbackDisabledTip = t(($) => $['tabs.startDisabledTip'], { ns: 'workflow' }) + + const renderSearchFilter = (tab: TabType, inputRef?: Ref) => { + if (tab === TabType.Snippets) return null + + const filter = (() => { + if (tab === TabType.Start) { + return ( + $['tabs.searchTrigger'], { ns: 'workflow' })} + inputClassName="grow" + /> + ) + } + + if (tab === TabType.Tools) { + return ( + $.searchTools, { ns: 'plugin' })!} + inputClassName="grow" + /> + ) + } + + return ( + $['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
{filter}
} - if (tab.disabled) { - return ( - - - {tab.name} - - } - /> - -
-

{disabledTip}

- {disabledTipLinkHref && disabledTipLinkLabel && ( - e.stopPropagation()} - > - {disabledTipLinkLabel} - - )} + const renderPanel = (tab: TabType, inputRef?: Ref) => { + const searchFilter = renderSearchFilter(tab, inputRef) + + if (tab === TabType.Start) { + return ( + <> + {searchFilter} +
+
- - + + ) + } + + if (tab === TabType.Blocks) { + return ( + <> + {searchFilter} +
+ +
+ + ) + } + + if (tab === TabType.Sources) { + return ( + <> + {searchFilter} +
+ +
+ + ) + } + + if (tab === TabType.Tools) { + return ( + <> + {searchFilter} + + + ) + } + + return ( + + ) + } + + if (standalonePanel) { + return ( +
+ {renderPanel( + standalonePanel, + standalonePanel === TabType.Snippets ? undefined : searchInputRef, + )} +
) } return ( -
- {tab.name} -
- ) -} - -const Tabs: FC = ({ - 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 ( -
e.stopPropagation()}> - {!noBlocks && ( -
- {tabs.map((tab) => ( - $['tabs.startDisabledTipLearnMore'], { ns: 'workflow' }) - : undefined - } - /> - ))} -
- )} - {filterElem} - {activeTab === TabsEnum.Start && (!noBlocks || forceShowStartContent) && ( -
- -
- )} - {activeTab === TabsEnum.Blocks && !noBlocks && ( -
- -
- )} - {activeTab === TabsEnum.Sources && !!dataSources.length && ( -
- -
- )} - {activeTab === TabsEnum.Tools && !noTools && ( - { - invalidateBuiltInTools() + + + {tabs.map((tab) => ( + + ))} + - )} - {activeTab === TabsEnum.Snippets && Boolean(snippetsElem) ? ( -
{snippetsElem}
- ) : null} -
+ + {tabs.map((tab) => ( + + {renderPanel( + tab.key, + tab.key === defaultTab && tab.key !== TabType.Snippets ? searchInputRef : undefined, + )} + + ))} + ) } -export default memo(Tabs) +export { SelectorContent } diff --git a/web/app/components/workflow/block-selector/tool-panel.tsx b/web/app/components/workflow/block-selector/tool-panel.tsx new file mode 100644 index 00000000000..cf5dd880686 --- /dev/null +++ b/web/app/components/workflow/block-selector/tool-panel.tsx @@ -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 = {} + + 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 ( + + ) +} diff --git a/web/app/components/workflow/block-selector/tool/__tests__/action-item.spec.tsx b/web/app/components/workflow/block-selector/tool/__tests__/action-item.spec.tsx index 559c88cf77a..ac58022a08f 100644 --- a/web/app/components/workflow/block-selector/tool/__tests__/action-item.spec.tsx +++ b/web/app/components/workflow/block-selector/tool/__tests__/action-item.spec.tsx @@ -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, diff --git a/web/app/components/workflow/block-selector/tool/action-item.tsx b/web/app/components/workflow/block-selector/tool/action-item.tsx index dbd40cded34..67d7738e786 100644 --- a/web/app/components/workflow/block-selector/tool/action-item.tsx +++ b/web/app/components/workflow/block-selector/tool/action-item.tsx @@ -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 = ({ const { t } = useTranslation() const language = useGetLanguage() + const previewDescriptionId = React.useId() + const previewDescription = payload.description[language] const { theme } = useTheme() const normalizedIcon = useMemo(() => { return normalizeProviderIcon(provider.icon) ?? provider.icon @@ -72,8 +75,8 @@ const ToolItem: FC = ({ const row = (