diff --git a/e2e/features/step-definitions/agent-v2/tools.steps.ts b/e2e/features/step-definitions/agent-v2/tools.steps.ts index 2fcfc233884..6219beb50be 100644 --- a/e2e/features/step-definitions/agent-v2/tools.steps.ts +++ b/e2e/features/step-definitions/agent-v2/tools.steps.ts @@ -23,7 +23,7 @@ import { expectProviderToolActionVisible, getCurrentAgentId } from './configure- const getToolsSection = (world: DifyWorld) => world.getPage().getByRole('region', { name: 'Tools' }) const getToolSelectorSearch = (world: DifyWorld) => - world.getPage().getByRole('textbox', { name: 'Search integrations...' }) + world.getPage().getByRole('searchbox', { name: 'Search integrations...' }) const jsonReplaceRuntimePrompt = [ 'You are a Dify Agent E2E JSON tool verifier.', diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index bdfb8b75bd2..2fde102ff63 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -394,14 +394,6 @@ "count": 1 } }, - "web/app/components/app/configuration/base/operation-btn/index.tsx": { - "jsx_a11y/click-events-have-key-events": { - "count": 1 - }, - "jsx_a11y/no-static-element-interactions": { - "count": 1 - } - }, "web/app/components/app/configuration/config-prompt/__tests__/index.spec.tsx": { "jsx_a11y/click-events-have-key-events": { "count": 1 @@ -3282,11 +3274,6 @@ "count": 1 } }, - "web/app/components/plugins/marketplace/search-box/tags-filter.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/plugins/plugin-auth/authorized/index.tsx": { "no-restricted-imports": { "count": 1 @@ -3382,14 +3369,6 @@ "count": 1 } }, - "web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/index.tsx": { - "jsx_a11y/click-events-have-key-events": { - "count": 1 - }, - "jsx_a11y/no-static-element-interactions": { - "count": 1 - } - }, "web/app/components/plugins/plugin-detail-panel/strategy-detail.tsx": { "jsx_a11y/click-events-have-key-events": { "count": 1 @@ -3466,43 +3445,6 @@ "count": 1 } }, - "web/app/components/plugins/plugin-detail-panel/tool-selector/components/index.ts": { - "no-barrel-files/no-barrel-files": { - "count": 6 - } - }, - "web/app/components/plugins/plugin-detail-panel/tool-selector/components/reasoning-config-form.tsx": { - "jsx_a11y/click-events-have-key-events": { - "count": 1 - }, - "jsx_a11y/no-static-element-interactions": { - "count": 1 - }, - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-base-form.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-item.tsx": { - "jsx_a11y/click-events-have-key-events": { - "count": 3 - }, - "jsx_a11y/mouse-events-have-key-events": { - "count": 1 - }, - "jsx_a11y/no-static-element-interactions": { - "count": 3 - } - }, - "web/app/components/plugins/plugin-detail-panel/tool-selector/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/plugins/plugin-detail-panel/trigger/event-detail-drawer.tsx": { "jsx_a11y/click-events-have-key-events": { "count": 1 @@ -3970,14 +3912,6 @@ "count": 3 } }, - "web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-option.tsx": { - "jsx_a11y/click-events-have-key-events": { - "count": 1 - }, - "jsx_a11y/no-static-element-interactions": { - "count": 1 - } - }, "web/app/components/workflow-app/hooks/index.ts": { "no-barrel-files/no-barrel-files": { "count": 13 @@ -4018,69 +3952,16 @@ "count": 2 } }, - "web/app/components/workflow/block-selector/all-start-blocks.tsx": { - "eslint-react/set-state-in-effect": { - "count": 1 - } - }, "web/app/components/workflow/block-selector/constants.tsx": { "react/only-export-components": { "count": 3 } }, - "web/app/components/workflow/block-selector/index-bar.tsx": { - "react/only-export-components": { - "count": 1 - } - }, - "web/app/components/workflow/block-selector/market-place-plugin/list.tsx": { - "jsx_a11y/click-events-have-key-events": { - "count": 1 - }, - "jsx_a11y/no-static-element-interactions": { - "count": 1 - } - }, - "web/app/components/workflow/block-selector/rag-tool-recommendations/uninstalled-item.tsx": { - "jsx_a11y/click-events-have-key-events": { - "count": 1 - }, - "jsx_a11y/no-static-element-interactions": { - "count": 1 - } - }, - "web/app/components/workflow/block-selector/tool-picker.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/workflow/block-selector/tool/tool.tsx": { - "jsx_a11y/click-events-have-key-events": { - "count": 2 - }, - "jsx_a11y/no-static-element-interactions": { - "count": 2 - } - }, "web/app/components/workflow/block-selector/use-sticky-scroll.ts": { "erasable-syntax-only/enums": { "count": 1 } }, - "web/app/components/workflow/block-selector/view-type-select.tsx": { - "erasable-syntax-only/enums": { - "count": 1 - }, - "jsx_a11y/click-events-have-key-events": { - "count": 2 - }, - "jsx_a11y/no-static-element-interactions": { - "count": 2 - }, - "react/only-export-components": { - "count": 1 - } - }, "web/app/components/workflow/comment/comment-input.tsx": { "jsx_a11y/no-autofocus": { "count": 1 @@ -4152,14 +4033,6 @@ "count": 1 } }, - "web/app/components/workflow/header/view-workflow-history.tsx": { - "jsx_a11y/click-events-have-key-events": { - "count": 3 - }, - "jsx_a11y/no-static-element-interactions": { - "count": 3 - } - }, "web/app/components/workflow/hooks-store/index.ts": { "no-barrel-files/no-barrel-files": { "count": 2 @@ -5329,11 +5202,6 @@ "count": 1 } }, - "web/app/components/workflow/nodes/tool/components/tool-form/item.tsx": { - "typescript/no-explicit-any": { - "count": 1 - } - }, "web/app/components/workflow/nodes/tool/default.ts": { "typescript/no-explicit-any": { "count": 6 @@ -5372,11 +5240,6 @@ "count": 1 } }, - "web/app/components/workflow/nodes/trigger-plugin/components/trigger-form/item.tsx": { - "typescript/no-explicit-any": { - "count": 1 - } - }, "web/app/components/workflow/nodes/trigger-plugin/default.ts": { "typescript/no-explicit-any": { "count": 10 diff --git a/packages/dify-ui/AGENTS.md b/packages/dify-ui/AGENTS.md index 33f47cf3bab..d37f44e934d 100644 --- a/packages/dify-ui/AGENTS.md +++ b/packages/dify-ui/AGENTS.md @@ -21,11 +21,11 @@ Shared design tokens, the `cn()` utility, CSS-first Tailwind styles, and headles Pick by the **trigger's purpose** and **a11y reach**, not visual richness. -| Primitive | Opens on | Trigger's purpose | Content | Reachable on touch / SR? | -| ------------- | --------------------- | -------------------------- | ------------------------- | ------------------------ | -| `Tooltip` | hover / focus | has its own action | short plain-text label | ❌ (label only) | -| `PreviewCard` | hover / focus | has a primary click target | supplementary preview | ❌ (via click target) | -| `Popover` | click / tap (+ hover) | **to open the popup** | anything, incl. long text | ✅ | +| Primitive | Opens on | Trigger's purpose | Content | Reachable on touch / SR? | +| ------------- | --------------------- | --------------------- | ------------------------- | ------------------------ | +| `Tooltip` | hover / focus | has its own action | short plain-text label | ❌ (label only) | +| `PreviewCard` | hover / focus | navigate through link | link destination preview | ❌ (visual enhancement) | +| `Popover` | click / tap (+ hover) | **open the popup** | anything, incl. long text | ✅ | Base UI decision rule ([docs]): @@ -35,9 +35,11 @@ Base UI decision rule ([docs]): Apply this first, then narrow: - `Tooltip` — ephemeral visual label. Trigger must already carry its own `aria-label` / visible text; tooltip mirrors it for sighted mouse/keyboard users. No interactive UI, no multi-line prose. Not dwell-able. -- `PreviewCard` — hover-revealed rich supplementary preview anchored to a trigger whose click goes somewhere (link, selectable row, jumpable chip). **Hard contract:** the popup MUST NOT contain information or actions unreachable from the trigger's click destination — touch and SR users can't open it. If the info is unique to the popup, switch to `Popover` (click or `openOnHover`) or move it to the click destination. Do not hand-roll "hover to open" on top of `Popover` to evade this split. +- `PreviewCard` — a visual enhancement for a link that previews its destination. Prefer the canonical anchor trigger and keep the popup non-interactive. Do not place unique or essential information or actions in the popup unless they are also available at the linked destination; touch and screen reader users cannot access the preview. If opening the popup is itself the trigger's purpose, or its content must be accessible across input modes, use `Popover` instead. - `Popover` — any popup with its own interactions, or any "infotip" (`?` / `(i)` glyph whose sole purpose is to reveal help text). Pass `openOnHover` on `PopoverTrigger` for the infotip case — unlike `Tooltip` / `PreviewCard`, this stays accessible to touch and SR users because the popover still opens on tap and focus. +Product-level polymorphic trigger compositions are local feature decisions. Document them in the owning feature and do not broaden or weaken the shared primitive contract to match one business workflow. + ## Border Radius: Figma Token → Tailwind Class Mapping The Figma design system uses `--radius/*` tokens whose scale is **offset by one step** from Tailwind CSS v4 defaults. When translating Figma specs to code, always use this mapping — never use `radius-*` as a CSS class, and never extend `borderRadius` in the preset. diff --git a/packages/dify-ui/README.md b/packages/dify-ui/README.md index 2c44707b287..6345c4e010e 100644 --- a/packages/dify-ui/README.md +++ b/packages/dify-ui/README.md @@ -179,9 +179,10 @@ See `[web/docs/overlay.md](../../web/docs/overlay.md)` for the web app overlay b - Never create an extra manual portal on top of our primitives — use the exported content / portal parts such as `DialogContent`, `PopoverContent`, and `DrawerPortal`. Base UI handles focus management, scroll-locking, and dismissal. - When a primitive needs additional presentation chrome (e.g. a custom backdrop), add it **inside** the exported component, not at call sites. -### Tooltip, infotip, and popover semantics +### Tooltip, preview card, infotip, and popover semantics - Use `Tooltip` only for short, non-interactive visual labels. The trigger must already have visible text or an `aria-label`; the tooltip is not the accessible name and must not contain links, buttons, forms, or structured prose. +- Use `PreviewCard` as a visual enhancement for a link that previews its destination. Its popup must remain non-interactive and must not contain unique or essential information unless that information is also available at the linked destination. Use `Popover` when opening the popup is the trigger's purpose or when users need to access its content on touch or with assistive technology. - Use `Popover` for explanatory content, long text, rich layout, or anything users may need to reach on touch or with assistive technology. In `web/`, the `Infotip` wrapper is the preferred pattern for a `?` help glyph backed by `Popover`. - Pick a `placement` and let the primitive own spacing. Avoid per-call-site offsets unless the component API explicitly needs a measured layout exception. - When passing a Base UI trigger `render` prop, render a real ` + ) +} diff --git a/web/app/components/app/configuration/config-prompt/conversation-history/history-panel.tsx b/web/app/components/app/configuration/config-prompt/conversation-history/history-panel.tsx index 8a43d876b2f..1b10dd9dd68 100644 --- a/web/app/components/app/configuration/config-prompt/conversation-history/history-panel.tsx +++ b/web/app/components/app/configuration/config-prompt/conversation-history/history-panel.tsx @@ -3,7 +3,7 @@ import type { FC } from 'react' import * as React from 'react' import { useTranslation } from 'react-i18next' import Panel from '@/app/components/app/configuration/base/feature-panel' -import OperationBtn from '@/app/components/app/configuration/base/operation-btn' +import { OperationButton } from '@/app/components/app/configuration/base/operation-button' import { MessageClockCircle } from '@/app/components/base/icons/src/vender/solid/general' type Props = Readonly<{ @@ -32,7 +32,7 @@ const HistoryPanel: FC = ({ showWarning, onShowEditModal }) => { {t(($) => $['feature.conversationHistory.description'], { ns: 'appDebug' })}
- + } noBodySpacing diff --git a/web/app/components/app/configuration/config-var/select-var-type.tsx b/web/app/components/app/configuration/config-var/select-var-type.tsx index a4a7b064084..79b51906eee 100644 --- a/web/app/components/app/configuration/config-var/select-var-type.tsx +++ b/web/app/components/app/configuration/config-var/select-var-type.tsx @@ -9,7 +9,7 @@ import { } from '@langgenius/dify-ui/dropdown-menu' import * as React from 'react' import { useTranslation } from 'react-i18next' -import OperationBtn from '@/app/components/app/configuration/base/operation-btn' +import { OperationButton } from '@/app/components/app/configuration/base/operation-button' import { ApiConnection } from '@/app/components/base/icons/src/vender/solid/development' import InputVarTypeIcon from '@/app/components/workflow/nodes/_base/components/input-var-type-icon' import { InputVarType } from '@/app/components/workflow/types' @@ -50,9 +50,7 @@ const SelectVarType: FC = ({ onChange }) => { } return ( - }> - - + } /> { <>
} + trigger={} isShow={isShowChooseTool} onShowChange={setIsShowChooseTool} disabled={false} diff --git a/web/app/components/app/configuration/dataset-config/index.tsx b/web/app/components/app/configuration/dataset-config/index.tsx index 984319b13e9..b1749bb2383 100644 --- a/web/app/components/app/configuration/dataset-config/index.tsx +++ b/web/app/components/app/configuration/dataset-config/index.tsx @@ -35,7 +35,7 @@ import { workspacePermissionKeysAtom } from '@/context/permission-state' import { AppModeEnum } from '@/types/app' import { getDatasetACLCapabilities } from '@/utils/permission' import FeaturePanel from '../base/feature-panel' -import OperationBtn from '../base/operation-btn' +import { OperationButton } from '../base/operation-button' import { useFormattingChangedDispatcher } from '../debug/hooks' import CardItem from './card-item' import ContextVar from './context-var' @@ -294,7 +294,7 @@ const DatasetConfig: FC = ({ readonly, hideMetadataFilter }) => { !readonly && (
{!isAgent && } - +
) } diff --git a/web/app/components/header/account-dropdown/__tests__/index.spec.tsx b/web/app/components/header/account-dropdown/__tests__/index.spec.tsx index 01952df1fc8..022b3db0fac 100644 --- a/web/app/components/header/account-dropdown/__tests__/index.spec.tsx +++ b/web/app/components/header/account-dropdown/__tests__/index.spec.tsx @@ -10,7 +10,6 @@ import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/con import AccountSection from '@/app/components/main-nav/components/account-section' import { useModalContext } from '@/context/modal-context' import { useProviderContext } from '@/context/provider-context' -import { useRouter } from '@/next/navigation' import { useLogout } from '@/service/use-common' import { createAccountProfileQueryClient } from '@/test/console/account-profile' import { renderWithConsoleQuery } from '@/test/console/query-data' @@ -55,6 +54,7 @@ const mockConsoleState = vi.hoisted(() => ({ current: undefined as ConsoleStateFixture | undefined, })) const mockConsoleStateReader = vi.hoisted(() => vi.fn()) +const mockUseRouter = vi.hoisted(() => vi.fn()) vi.mock('next-themes', () => ({ useTheme: () => ({ @@ -97,7 +97,7 @@ vi.mock('@/next/navigation', async (importOriginal) => { const actual = await importOriginal() return { ...actual, - useRouter: vi.fn(), + useRouter: mockUseRouter, } }) @@ -223,13 +223,8 @@ describe('AccountDropdown', () => { vi.mocked(useLogout).mockReturnValue({ mutateAsync: mockLogout, } as unknown as ReturnType) - vi.mocked(useRouter).mockReturnValue({ + mockUseRouter.mockReturnValue({ push: mockPush, - replace: vi.fn(), - prefetch: vi.fn(), - back: vi.fn(), - forward: vi.fn(), - refresh: vi.fn(), }) }) 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 817606d7827..e31146643ea 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 @@ -28,12 +28,18 @@ describe('SearchBox', () => { const user = userEvent.setup() render() - const input = screen.getByPlaceholderText('Search plugins') + const input = screen.getByRole('searchbox', { name: 'Search plugins' }) await user.type(input, 'agent') expect(input).toHaveValue('agent') - await user.click(screen.getByRole('button')) + await user.tab() + const clearButton = screen.getByRole('button', { + name: /^plugin\.clearSearch/, + }) + expect(clearButton).toHaveFocus() + await user.keyboard('{Enter}') expect(input).toHaveValue('') + expect(input).toHaveFocus() }) it('opens the custom tool flow from the add button', async () => { @@ -52,7 +58,9 @@ describe('SearchBox', () => { />, ) - await user.click(screen.getByRole('button')) + const addButton = screen.getByRole('button', { name: 'tools.addToolModal.custom.tip' }) + addButton.focus() + await user.keyboard('{Enter}') expect(onShowAddCustomCollectionModal).toHaveBeenCalledOnce() }) @@ -71,6 +79,6 @@ describe('SearchBox', () => { />, ) - expect(ref.current).toBe(screen.getByPlaceholderText('Search plugins')) + expect(ref.current).toBe(screen.getByRole('searchbox', { name: 'Search plugins' })) }) }) diff --git a/web/app/components/plugins/marketplace/search-box/__tests__/tags-filter.spec.tsx b/web/app/components/plugins/marketplace/search-box/__tests__/tags-filter.spec.tsx index f690264500f..ed4cc1e6988 100644 --- a/web/app/components/plugins/marketplace/search-box/__tests__/tags-filter.spec.tsx +++ b/web/app/components/plugins/marketplace/search-box/__tests__/tags-filter.spec.tsx @@ -1,4 +1,5 @@ -import { fireEvent, render, screen, within } from '@testing-library/react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vitest' import TagsFilter from '../tags-filter' @@ -32,51 +33,14 @@ vi.mock('@/app/components/plugins/hooks', () => ({ }), })) -vi.mock('@/app/components/base/input', () => ({ - default: ({ - value, - onChange, - placeholder, - }: { - value: string - onChange: (event: { target: { value: string } }) => void - placeholder: string - }) => ( - onChange({ target: { value: event.target.value } })} - /> - ), -})) - vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover')) -vi.mock('../trigger/marketplace', () => ({ - default: ({ selectedTagsLength }: { selectedTagsLength: number }) => ( -
- marketplace: - {selectedTagsLength} -
- ), -})) - -vi.mock('../trigger/tool-selector', () => ({ - default: ({ selectedTagsLength }: { selectedTagsLength: number }) => ( -
- tool: - {selectedTagsLength} -
- ), -})) - describe('TagsFilter', () => { - const ensurePopoverOpen = () => { - if (!screen.queryByTestId('popover-content')) - fireEvent.click(screen.getByTestId('popover-trigger')) + const ensurePopoverOpen = async (user: ReturnType) => { + if (!screen.queryByRole('searchbox', { name: 'pluginTags.searchTags' })) + await user.click(screen.getByRole('button', { name: 'pluginTags.allTags' })) - return screen.getByTestId('popover-content') + return screen.getByRole('searchbox', { name: 'pluginTags.searchTags' }) } beforeEach(() => { @@ -86,53 +50,43 @@ describe('TagsFilter', () => { ) }) - it('renders marketplace trigger when used in marketplace', () => { - render() - - expect(screen.getByTestId('marketplace-trigger')).toHaveTextContent('marketplace:1') - expect(screen.queryByTestId('tool-trigger')).not.toBeInTheDocument() - }) - - it('renders tool selector trigger when used outside marketplace', () => { - render() - - expect(screen.getByTestId('tool-trigger')).toHaveTextContent('tool:1') - expect(screen.queryByTestId('marketplace-trigger')).not.toBeInTheDocument() - }) - - it('filters tag options by search text', () => { + it('filters tag options by search text', async () => { + const user = userEvent.setup() render() - fireEvent.click(screen.getByTestId('popover-trigger')) + const search = await ensurePopoverOpen(user) - expect(screen.getByText('Agent')).toBeInTheDocument() - expect(screen.getByText('RAG')).toBeInTheDocument() - expect(screen.getByText('Search')).toBeInTheDocument() + expect(screen.getByRole('checkbox', { name: 'Agent' })).toBeInTheDocument() + expect(screen.getByRole('checkbox', { name: 'RAG' })).toBeInTheDocument() + expect(screen.getByRole('checkbox', { name: 'Search' })).toBeInTheDocument() - fireEvent.change(screen.getByLabelText('tags-search'), { target: { value: 'ra' } }) + await user.type(search, 'ra') - expect(screen.queryByText('Agent')).not.toBeInTheDocument() - expect(screen.getByText('RAG')).toBeInTheDocument() - expect(screen.queryByText('Search')).not.toBeInTheDocument() + expect(screen.queryByRole('checkbox', { name: 'Agent' })).not.toBeInTheDocument() + expect(screen.getByRole('checkbox', { name: 'RAG' })).toBeInTheDocument() + expect(screen.queryByRole('checkbox', { name: 'Search' })).not.toBeInTheDocument() }) - it('adds and removes selected tags when options are clicked', () => { + it('adds and removes selected tags when options are clicked', async () => { + const user = userEvent.setup() const onTagsChange = vi.fn() const { rerender } = render() - fireEvent.click(within(ensurePopoverOpen()).getByText('Agent')) + await user.click(screen.getByRole('button', { name: 'Agent' })) + await user.click(screen.getByRole('checkbox', { name: 'Agent' })) expect(onTagsChange).toHaveBeenCalledWith([]) rerender() - fireEvent.click(within(ensurePopoverOpen()).getByText('RAG')) + await user.click(screen.getByRole('checkbox', { name: 'RAG' })) expect(onTagsChange).toHaveBeenCalledWith(['agent', 'rag']) }) - it('falls back to an empty placeholder when translation is missing', () => { + it('falls back to an empty placeholder when translation is missing', async () => { + const user = userEvent.setup() mockTranslate.mockImplementation(() => undefined as unknown as string) render() - fireEvent.click(screen.getByTestId('popover-trigger')) + await user.click(screen.getByRole('button')) - expect(screen.getByLabelText('tags-search')).toHaveAttribute('placeholder', '') + expect(screen.getByRole('searchbox')).toHaveAttribute('placeholder', '') }) }) diff --git a/web/app/components/plugins/marketplace/search-box/index.tsx b/web/app/components/plugins/marketplace/search-box/index.tsx index 55ac59bdb39..54f6f8321a2 100644 --- a/web/app/components/plugins/marketplace/search-box/index.tsx +++ b/web/app/components/plugins/marketplace/search-box/index.tsx @@ -1,8 +1,9 @@ 'use client' import type { Ref } from 'react' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { RiAddLine, RiCloseLine, RiSearchLine } from '@remixicon/react' -import ActionButton from '@/app/components/base/action-button' +import { useImperativeHandle, useRef } from 'react' +import { useTranslation } from 'react-i18next' import Divider from '@/app/components/base/divider' import TagsFilter from './tags-filter' @@ -20,11 +21,10 @@ type SearchBoxProps = { supportAddCustomTool?: boolean usedInMarketplace?: boolean onShowAddCustomCollectionModal?: () => void - onAddedCustomTool?: () => void autoFocus?: boolean showTags?: boolean } -const SearchBox = ({ +function SearchBox({ ref, search, onSearchChange, @@ -40,7 +40,17 @@ const SearchBox = ({ onShowAddCustomCollectionModal, autoFocus = false, showTags = true, -}: SearchBoxProps) => { +}: SearchBoxProps) { + const { t } = useTranslation() + const accessibleLabel = placeholder || t(($) => $.searchTools, { ns: 'plugin' })! + const inputRef = useRef(null) + useImperativeHandle(ref, () => inputRef.current as HTMLInputElement, []) + + const handleClear = () => { + onSearchChange('') + inputRef.current?.focus() + } + return (
{search && ( - onSearchChange('')} className="shrink-0"> - - + )}
@@ -86,16 +108,24 @@ const SearchBox = ({ {!usedInMarketplace && ( <>
- {search && ( - onSearchChange('')} className="shrink-0"> - - + )}
{showTags && ( @@ -122,12 +161,15 @@ const SearchBox = ({
{supportAddCustomTool && (
- $['addToolModal.custom.tip'], { ns: 'tools' })} + className="ml-2 size-6 min-h-0 rounded-full p-0" onClick={onShowAddCustomCollectionModal} > - - + +
)} diff --git a/web/app/components/plugins/marketplace/search-box/tags-filter.tsx b/web/app/components/plugins/marketplace/search-box/tags-filter.tsx index 699c73fca0d..e3d2ee97574 100644 --- a/web/app/components/plugins/marketplace/search-box/tags-filter.tsx +++ b/web/app/components/plugins/marketplace/search-box/tags-filter.tsx @@ -2,10 +2,10 @@ import { Checkbox } from '@langgenius/dify-ui/checkbox' import { CheckboxGroup } from '@langgenius/dify-ui/checkbox-group' -import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' +import { Input } from '@langgenius/dify-ui/input' +import { Popover, PopoverContent } from '@langgenius/dify-ui/popover' import { useState } from 'react' import { useTranslation } from '#i18n' -import Input from '@/app/components/base/input' import { useTags } from '@/app/components/plugins/hooks' import MarketplaceTrigger from './trigger/marketplace' import ToolSelectorTrigger from './trigger/tool-selector' @@ -15,7 +15,7 @@ type TagsFilterProps = { onTagsChange: (tags: string[]) => void usedInMarketplace?: boolean } -const TagsFilter = ({ tags, onTagsChange, usedInMarketplace = false }: TagsFilterProps) => { +function TagsFilter({ tags, onTagsChange, usedInMarketplace = false }: TagsFilterProps) { const { t } = useTranslation() const [open, setOpen] = useState(false) const [searchText, setSearchText] = useState('') @@ -27,31 +27,24 @@ const TagsFilter = ({ tags, onTagsChange, usedInMarketplace = false }: TagsFilte return ( - - {usedInMarketplace && ( - - )} - {!usedInMarketplace && ( - - )} - - } - /> + {usedInMarketplace && ( + + )} + {!usedInMarketplace && ( + + )}
- setSearchText(e.target.value)} - placeholder={t(($) => $.searchTags, { ns: 'pluginTags' }) || ''} - /> +
+ + $.searchTags, { ns: 'pluginTags' }) || ''} + className="pl-6.5" + value={searchText} + onChange={(e) => setSearchText(e.target.value)} + placeholder={t(($) => $.searchTags, { ns: 'pluginTags' }) || ''} + /> +
$.allTags, { ns: 'pluginTags' })} diff --git a/web/app/components/plugins/marketplace/search-box/trigger/__tests__/marketplace.spec.tsx b/web/app/components/plugins/marketplace/search-box/trigger/__tests__/marketplace.spec.tsx index 3e8c6f752d4..d806b12391d 100644 --- a/web/app/components/plugins/marketplace/search-box/trigger/__tests__/marketplace.spec.tsx +++ b/web/app/components/plugins/marketplace/search-box/trigger/__tests__/marketplace.spec.tsx @@ -1,5 +1,8 @@ import type { Tag } from '../../../../hooks' -import { fireEvent, render, screen } from '@testing-library/react' +import { Popover } from '@langgenius/dify-ui/popover' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useState } from 'react' import { describe, expect, it, vi } from 'vitest' import MarketplaceTrigger from '../marketplace' @@ -11,51 +14,70 @@ const tagsMap: Record = { describe('MarketplaceTrigger', () => { it('shows all-tags text when no tags are selected', () => { - const { container } = render( - , + render( + + + , ) - expect(screen.getByText('pluginTags.allTags')).toBeInTheDocument() - expect(container.querySelectorAll('svg').length).toBeGreaterThan(0) - expect(container.querySelectorAll('svg').length).toBe(2) + expect(screen.getByRole('button', { name: 'pluginTags.allTags' })).toBeInTheDocument() + expect( + screen.queryByRole('button', { name: /^pluginTags\.clearSelectedTags/ }), + ).not.toBeInTheDocument() }) it('shows selected tag labels and overflow count', () => { render( - , + + + , ) expect(screen.getByText('Agent,RAG')).toBeInTheDocument() expect(screen.getByText('+1')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Agent, RAG, Search' })).toBeInTheDocument() }) - it('clears selected tags when clear icon is clicked', () => { - const onTagsChange = vi.fn() + it('clears selected tags from a separate button', async () => { + const user = userEvent.setup() + function Harness() { + const [tags, setTags] = useState(['agent']) + return ( + + + + ) + } - const { container } = render( - , - ) + render() - fireEvent.click(container.querySelectorAll('svg')[1]!) + const trigger = screen.getByRole('button', { name: 'Agent' }) + const clearButton = screen.getByRole('button', { name: /^pluginTags\.clearSelectedTags/ }) + expect(trigger).not.toContainElement(clearButton) - expect(onTagsChange).toHaveBeenCalledWith([]) + await user.click(clearButton) + + expect( + screen.queryByRole('button', { name: /^pluginTags\.clearSelectedTags/ }), + ).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'pluginTags.allTags' })).toHaveFocus() }) }) diff --git a/web/app/components/plugins/marketplace/search-box/trigger/__tests__/tool-selector.spec.tsx b/web/app/components/plugins/marketplace/search-box/trigger/__tests__/tool-selector.spec.tsx index 7e9069d61f3..de5caf322b0 100644 --- a/web/app/components/plugins/marketplace/search-box/trigger/__tests__/tool-selector.spec.tsx +++ b/web/app/components/plugins/marketplace/search-box/trigger/__tests__/tool-selector.spec.tsx @@ -1,5 +1,8 @@ import type { Tag } from '../../../../hooks' -import { fireEvent, render, screen } from '@testing-library/react' +import { Popover, PopoverContent } from '@langgenius/dify-ui/popover' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useState } from 'react' import { describe, expect, it, vi } from 'vitest' import ToolSelectorTrigger from '../tool-selector' @@ -11,51 +14,103 @@ const tagsMap: Record = { describe('ToolSelectorTrigger', () => { it('renders only icon when no tags are selected', () => { - const { container } = render( - , + render( + + + , ) - expect(container.querySelectorAll('svg')).toHaveLength(1) + expect(screen.getByRole('button', { name: 'pluginTags.allTags' })).toBeInTheDocument() + expect( + screen.queryByRole('button', { name: /^pluginTags\.clearSelectedTags/ }), + ).not.toBeInTheDocument() expect(screen.queryByText('Agent')).not.toBeInTheDocument() }) it('renders selected tag labels and overflow count', () => { - const { container } = render( - , + render( + + + , ) expect(screen.getByText('Agent,RAG')).toBeInTheDocument() expect(screen.getByText('+1')).toBeInTheDocument() - expect(container.querySelectorAll('svg')).toHaveLength(2) + expect(screen.getByRole('button', { name: 'Agent, RAG, Search' })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: /^pluginTags\.clearSelectedTags/ }), + ).toBeInTheDocument() }) - it('clears selected tags when clear icon is clicked', () => { - const onTagsChange = vi.fn() + it('opens the tag filter from the keyboard', async () => { + const user = userEvent.setup() - const { container } = render( - , + render( + + + Tag options + , ) - fireEvent.click(container.querySelectorAll('svg')[1]!) + const trigger = screen.getByRole('button', { name: 'pluginTags.allTags' }) + await user.tab() + expect(trigger).toHaveFocus() - expect(onTagsChange).toHaveBeenCalledWith([]) + await user.keyboard('{Enter}') + + expect(screen.getByText('Tag options')).toBeInTheDocument() + }) + + it('keeps clear as a separate action from the popover trigger', async () => { + const user = userEvent.setup() + function Harness() { + const [tags, setTags] = useState(['agent']) + return ( + + + + ) + } + + render() + + const trigger = screen.getByRole('button', { name: 'Agent' }) + const clearButton = screen.getByRole('button', { name: /^pluginTags\.clearSelectedTags/ }) + + expect(trigger).not.toContainElement(clearButton) + trigger.focus() + await user.tab() + expect(clearButton).toHaveFocus() + + await user.keyboard('{Enter}') + + expect( + screen.queryByRole('button', { name: /^pluginTags\.clearSelectedTags/ }), + ).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'pluginTags.allTags' })).toHaveFocus() }) }) diff --git a/web/app/components/plugins/marketplace/search-box/trigger/marketplace.tsx b/web/app/components/plugins/marketplace/search-box/trigger/marketplace.tsx index 6368bc84b91..2448a3f95f2 100644 --- a/web/app/components/plugins/marketplace/search-box/trigger/marketplace.tsx +++ b/web/app/components/plugins/marketplace/search-box/trigger/marketplace.tsx @@ -1,7 +1,8 @@ import type { Tag } from '../../../hooks' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { RiArrowDownSLine, RiCloseCircleFill, RiFilter3Line } from '@remixicon/react' -import * as React from 'react' +import { PopoverTrigger } from '@langgenius/dify-ui/popover' +import { memo, useEffect, useRef } from 'react' import { useTranslation } from '#i18n' type MarketplaceTriggerProps = { @@ -12,54 +13,100 @@ type MarketplaceTriggerProps = { onTagsChange: (tags: string[]) => void } -const MarketplaceTrigger = ({ +function MarketplaceTrigger({ selectedTagsLength, open, tags, tagsMap, onTagsChange, -}: MarketplaceTriggerProps) => { +}: MarketplaceTriggerProps) { const { t } = useTranslation() + const triggerRef = useRef(null) + const shouldRestoreFocusRef = useRef(false) + const selectedTagLabels = tags.map((tag) => tagsMap[tag]?.label).filter(Boolean) + const triggerLabel = selectedTagLabels.length + ? selectedTagLabels.join(', ') + : t(($) => $.allTags, { ns: 'pluginTags' }) + + useEffect(() => { + if (selectedTagsLength || !shouldRestoreFocusRef.current) return + + shouldRestoreFocusRef.current = false + triggerRef.current?.focus() + }, [selectedTagsLength]) return ( -
-
- -
-
- {!selectedTagsLength && {t(($) => $.allTags, { ns: 'pluginTags' })}} - {!!selectedTagsLength && ( - - {tags - .map((tag) => tagsMap[tag]!.label) - .slice(0, 2) - .join(',')} - - )} - {selectedTagsLength > 2 && ( -
+{selectedTagsLength - 2}
- )} -
+
+ + + + + + {!selectedTagsLength && {t(($) => $.allTags, { ns: 'pluginTags' })}} + {!!selectedTagsLength && ( + + {tags + .map((tag) => tagsMap[tag]?.label) + .filter(Boolean) + .slice(0, 2) + .join(',')} + + )} + {selectedTagsLength > 2 && ( + + +{selectedTagsLength - 2} + + )} + + {!selectedTagsLength && ( + + + + )} + + } + /> {!!selectedTagsLength && ( - onTagsChange([])} - /> - )} - {!selectedTagsLength && ( -
- -
+ )}
) } -export default React.memo(MarketplaceTrigger) +export default memo(MarketplaceTrigger) diff --git a/web/app/components/plugins/marketplace/search-box/trigger/tool-selector.tsx b/web/app/components/plugins/marketplace/search-box/trigger/tool-selector.tsx index 2add17987f4..e5e1cc45906 100644 --- a/web/app/components/plugins/marketplace/search-box/trigger/tool-selector.tsx +++ b/web/app/components/plugins/marketplace/search-box/trigger/tool-selector.tsx @@ -1,7 +1,9 @@ import type { Tag } from '../../../hooks' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { RiCloseCircleFill, RiPriceTag3Line } from '@remixicon/react' -import * as React from 'react' +import { PopoverTrigger } from '@langgenius/dify-ui/popover' +import { memo, useEffect, useRef } from 'react' +import { useTranslation } from '#i18n' type ToolSelectorTriggerProps = { selectedTagsLength: number @@ -11,50 +13,92 @@ type ToolSelectorTriggerProps = { onTagsChange: (tags: string[]) => void } -const ToolSelectorTrigger = ({ +function ToolSelectorTrigger({ selectedTagsLength, open, tags, tagsMap, onTagsChange, -}: ToolSelectorTriggerProps) => { +}: ToolSelectorTriggerProps) { + const { t } = useTranslation() + const triggerRef = useRef(null) + const shouldRestoreFocusRef = useRef(false) + const selectedTagLabels = tags.map((tag) => tagsMap[tag]?.label).filter(Boolean) + const triggerLabel = selectedTagLabels.length + ? selectedTagLabels.join(', ') + : t(($) => $.allTags, { ns: 'pluginTags' }) + + useEffect(() => { + if (selectedTagsLength || !shouldRestoreFocusRef.current) return + + shouldRestoreFocusRef.current = false + triggerRef.current?.focus() + }, [selectedTagsLength]) + return ( -
-
- -
+
+ + + + + {!!selectedTagsLength && ( + + + {tags + .map((tag) => tagsMap[tag]?.label) + .filter(Boolean) + .slice(0, 2) + .join(',')} + + {selectedTagsLength > 2 && ( + + +{selectedTagsLength - 2} + + )} + + )} + + } + /> {!!selectedTagsLength && ( -
- - {tags - .map((tag) => tagsMap[tag]!.label) - .slice(0, 2) - .join(',')} - - {selectedTagsLength > 2 && ( -
+{selectedTagsLength - 2}
- )} -
- )} - {!!selectedTagsLength && ( - { - e.stopPropagation() + )}
) } -export default React.memo(ToolSelectorTrigger) +export default memo(ToolSelectorTrigger) diff --git a/web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/README.md b/web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/README.md new file mode 100644 index 00000000000..752c4122336 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/README.md @@ -0,0 +1,18 @@ +# Multiple Tool Selector + +`index.tsx` is the public list composition for selecting and configuring multiple tools. It owns tool identity, list ordering, add and delete updates, enabled counts, and the optional collapsed state. + +After a keyboard user deletes a tool, this module restores focus after the controlled `value` update. The target order is the next tool, the previous tool, then the add-tool button. `ToolSelector` only exposes the final default trigger through `triggerRef`; it does not infer sibling order. + +Tools are identified by `provider_name` and `tool_name`, matching the module's deduplication contract. Callers must replace `value` after `onChange` so the list and pending focus target can settle together. + +## Internal Modules + +- `index.tsx`: List state composition, collapse control, and post-delete focus ownership. +- `__tests__/focus-restoration.spec.tsx`: Integration coverage through the real tool row and trigger chain. + +## External Modules + +- `app/components/plugins/plugin-detail-panel/tool-selector`: Single-tool trigger, Popover, and configuration form. +- `app/components/workflow/nodes/_base/components/mcp-tool-availability`: MCP availability policy. +- `service/use-tools`: Installed MCP tool data used by the enabled count. diff --git a/web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/__tests__/focus-restoration.spec.tsx b/web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/__tests__/focus-restoration.spec.tsx new file mode 100644 index 00000000000..97e0bc22ca0 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/__tests__/focus-restoration.spec.tsx @@ -0,0 +1,115 @@ +import type { ToolValue } from '@/app/components/workflow/block-selector/types' +import type { ToolWithProvider } from '@/app/components/workflow/types' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useState } from 'react' +import { CollectionType } from '@/app/components/tools/types' +import { MCPToolAvailabilityProvider } from '@/app/components/workflow/nodes/_base/components/mcp-tool-availability' +import MultipleToolSelector from '../index' + +const mockTools = vi.hoisted(() => ({ + builtIn: [] as ToolWithProvider[], +})) + +vi.mock('@/service/use-tools', () => ({ + useAllBuiltInTools: () => ({ data: mockTools.builtIn, isFetched: true }), + useAllCustomTools: () => ({ data: [], isFetched: true }), + useAllMCPTools: () => ({ data: [], isFetched: true }), + useAllWorkflowTools: () => ({ data: [], isFetched: true }), + useInvalidateAllBuiltInTools: () => vi.fn(), +})) + +vi.mock('@/service/use-plugins', () => ({ + useCheckInstalled: () => ({ data: undefined }), + useInvalidateInstalledPluginList: () => vi.fn(), + usePluginManifestInfo: () => ({ data: undefined }), +})) + +const createToolValue = (index: number): ToolValue => ({ + provider_name: `provider-${index}`, + tool_name: `tool-${index}`, + tool_label: `Tool ${index}`, + enabled: true, +}) + +const createToolProvider = (value: ToolValue): ToolWithProvider => ({ + id: value.provider_name, + name: value.provider_name, + author: 'Dify', + description: { en_US: '', zh_Hans: '' }, + icon: 'icon.svg', + label: { en_US: value.provider_name, zh_Hans: value.provider_name }, + type: CollectionType.builtIn, + team_credentials: {}, + is_team_authorization: true, + allow_delete: false, + labels: [], + tools: [ + { + name: value.tool_name, + author: 'Dify', + label: { en_US: value.tool_label, zh_Hans: value.tool_label }, + description: { en_US: '', zh_Hans: '' }, + parameters: [], + labels: [], + output_schema: {}, + }, + ], + meta: {} as ToolWithProvider['meta'], +}) + +function renderSelector(initialValue: ToolValue[]) { + mockTools.builtIn = initialValue.map(createToolProvider) + + function Harness() { + const [value, setValue] = useState(initialValue) + + return ( + + + + ) + } + + return render() +} + +describe('MultipleToolSelector focus restoration', () => { + it.each([ + { + name: 'the next tool after deleting a middle item', + tools: [createToolValue(1), createToolValue(2), createToolValue(3)], + deleteIndex: 1, + expectedFocusName: 'Tool 3', + }, + { + name: 'the previous tool after deleting the last item', + tools: [createToolValue(1), createToolValue(2)], + deleteIndex: 1, + expectedFocusName: 'Tool 1', + }, + { + name: 'the add button after deleting the only item', + tools: [createToolValue(1)], + deleteIndex: 0, + expectedFocusName: 'plugin.detailPanel.toolSelector.title', + }, + ])('moves focus to $name', async ({ tools, deleteIndex, expectedFocusName }) => { + const user = userEvent.setup() + renderSelector(tools) + + const deleteButton = screen.getAllByRole('button', { + name: 'common.operation.delete', + })[deleteIndex] + deleteButton?.focus() + await user.keyboard('{Enter}') + + expect(screen.getByRole('button', { name: expectedFocusName })).toHaveFocus() + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/__tests__/index.spec.tsx index 281a162acb3..2378de90d60 100644 --- a/web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/__tests__/index.spec.tsx @@ -1,8 +1,10 @@ +import type { MouseEventHandler, ReactElement } from 'react' import type { Node } from 'reactflow' import type { ToolValue } from '@/app/components/workflow/block-selector/types' import type { NodeOutPutVar, ToolWithProvider } from '@/app/components/workflow/types' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vitest' // ==================== Imports (after mocks) ==================== import { MCPToolAvailabilityProvider } from '@/app/components/workflow/nodes/_base/components/mcp-tool-availability' @@ -28,7 +30,8 @@ vi.mock('@/app/components/plugins/plugin-detail-panel/tool-selector', () => ({ onSelectMultiple, onDelete, controlledState, - onControlledStateChange: _onControlledStateChange, + onControlledStateChange, + trigger, panelShowState, onPanelShowStateChange: _onPanelShowStateChange, isEdit, @@ -40,6 +43,10 @@ vi.mock('@/app/components/plugins/plugin-detail-panel/tool-selector', () => ({ onDelete?: () => void controlledState?: boolean onControlledStateChange?: (state: boolean) => void + trigger?: ReactElement<{ + 'aria-label'?: string + onClick?: MouseEventHandler + }> panelShowState?: boolean onPanelShowStateChange?: (state: boolean) => void isEdit?: boolean @@ -90,6 +97,16 @@ vi.mock('@/app/components/plugins/plugin-detail-panel/tool-selector', () => ({ data-controlled-state={controlledState} data-panel-show-state={panelShowState} > + {trigger && ( + + } + panelShowState={panelShowState} + onPanelShowStateChange={setPanelShowState} + isEdit={false} + onSelectMultiple={handleAddMultiple} + /> )}
- {!collapse && ( - <> - {value.length === 0 && ( -
- {t(($) => $['detailPanel.toolSelector.empty'], { ns: 'plugin' })} -
- )} - {value.length > 0 && - value.map((item, index) => ( -
+ + {value.length === 0 && ( +
+ {t(($) => $['detailPanel.toolSelector.empty'], { ns: 'plugin' })} +
+ )} + {value.length > 0 && + value.map((item, index) => { + const toolKey = getToolKey(item) + + return ( +
handleConfigure(item, index)} onSelectMultiple={handleAddMultiple} onDelete={() => handleDelete(index)} + triggerRef={(element) => { + if (element) toolItemTriggerByKeyRef.current.set(toolKey, element) + else toolItemTriggerByKeyRef.current.delete(toolKey) + }} supportEnableSwitch isEdit />
- ))} - - )} -
} - panelShowState={panelShowState} - onPanelShowStateChange={setPanelShowState} - isEdit={false} - onSelectMultiple={handleAddMultiple} - /> - + ) + })} + + ) } diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/README.md b/web/app/components/plugins/plugin-detail-panel/tool-selector/README.md new file mode 100644 index 00000000000..ce2c698acd2 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/README.md @@ -0,0 +1,23 @@ +# Tool Selector + +`index.tsx` is the public single-tool selector. It owns the configured or unconfigured trigger, Popover lifecycle, tool form, authorization, settings, and deletion action wiring. + +The built-in trigger branch accepts `triggerRef`, which always resolves to its final native button. Callers that provide a custom `trigger` own that element and its ref directly. The two trigger modes are mutually exclusive in the component type contract. + +Deletion only reports intent through `onDelete`. This module does not infer sibling order or choose a post-delete focus target; a list composition owner must coordinate that behavior. + +## Internal Modules + +- `components/tool-item`: Configured tool row and its primary and secondary actions. +- `components/tool-trigger`: Default unconfigured tool trigger. +- `components/tool-base-form`, `components/tool-authorization-section`, and `components/tool-settings-panel`: Popover content sections. +- `hooks/use-tool-selector` and `hooks/use-plugin-installed-check`: Selector state and installed-provider resolution. + +## External Modules + +- `app/components/plugins/plugin-auth`: Tool authorization controls. +- `app/components/plugins/readme-panel`: Plugin documentation entry. +- `app/components/tools`: Tool provider contracts and form conversion. +- `app/components/workflow/block-selector`: Tool picker and selected-tool contracts. +- `app/components/workflow/nodes/_base/components`: MCP availability and plugin recovery actions. +- `service/use-plugins` and `service/use-tools`: Installed plugin and tool-provider queries. diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/__tests__/index.spec.tsx new file mode 100644 index 00000000000..f5034a9e96c --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/__tests__/index.spec.tsx @@ -0,0 +1,74 @@ +import { Button } from '@langgenius/dify-ui/button' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useState } from 'react' +import ToolSelector from '../index' + +vi.mock('../components/tool-authorization-section', () => ({ + ToolAuthorizationSection: () => null, +})) +vi.mock('../components/tool-base-form', () => ({ ToolBaseForm: () => null })) +vi.mock('../components/tool-settings-panel', () => ({ ToolSettingsPanel: () => null })) + +vi.mock('../hooks/use-tool-selector', () => ({ + useToolSelector: () => ({ + isShow: false, + setIsShow: vi.fn(), + isShowChooseTool: false, + setIsShowChooseTool: vi.fn(), + currType: 'settings', + setCurrType: vi.fn(), + currentProvider: undefined, + currentTool: undefined, + settingsFormSchemas: [], + paramsFormSchemas: [], + showTabSlider: false, + userSettingsOnly: false, + reasoningConfigOnly: false, + manifestIcon: '', + inMarketPlace: false, + manifest: undefined, + handleSelectTool: vi.fn(), + handleSelectMultipleTool: vi.fn(), + handleDescriptionChange: vi.fn(), + handleSettingsFormChange: vi.fn(), + handleParamsFormChange: vi.fn(), + handleEnabledChange: vi.fn(), + handleAuthorizationItemClick: vi.fn(), + handleInstall: vi.fn(), + settingsValue: {}, + }), +})) + +describe('ToolSelector', () => { + it('opens an unconfigured add-tool selector and restores focus when dismissed', async () => { + const user = userEvent.setup() + + function Harness() { + const [open, setOpen] = useState(false) + return ( + Add tool} + controlledState={open} + onControlledStateChange={setOpen} + onSelect={vi.fn()} + nodeOutputVars={[]} + availableNodes={[]} + /> + ) + } + + render() + + const trigger = screen.getByRole('button', { name: 'Add tool' }) + await user.click(trigger) + + expect(trigger).toHaveAttribute('aria-expanded', 'true') + expect(screen.getByText('plugin.detailPanel.toolSelector.title')).toBeInTheDocument() + + await user.keyboard('{Escape}') + + expect(screen.queryByText('plugin.detailPanel.toolSelector.title')).not.toBeInTheDocument() + expect(trigger).toHaveFocus() + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/reasoning-config-form.spec.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/reasoning-config-form.spec.tsx index 982e9564a56..df7f319816f 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/reasoning-config-form.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/reasoning-config-form.spec.tsx @@ -8,25 +8,6 @@ import { Type } from '@/app/components/workflow/nodes/llm/types' import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types' import ReasoningConfigForm from '../reasoning-config-form' -vi.mock('@/app/components/base/input', () => ({ - default: ({ - value, - onChange, - placeholder, - }: { - value?: string - onChange: (e: { target: { value: string } }) => void - placeholder?: string - }) => ( - onChange({ target: { value: e.target.value } })} - /> - ), -})) - vi.mock('@langgenius/dify-ui/select', async () => { const React = await import('react') const SelectContext = React.createContext<{ @@ -41,16 +22,16 @@ vi.mock('@langgenius/dify-ui/select', async () => { children: React.ReactNode onValueChange?: (value: string) => void }) => ( - +
{children}
-
+ ), SelectTrigger: ({ children }: { children: React.ReactNode }) => ( ), SelectContent: ({ children }: { children: React.ReactNode }) =>
{children}
, SelectItem: ({ children, value }: { children: React.ReactNode; value: string }) => { - const context = React.useContext(SelectContext) + const context = React.use(SelectContext) return ( } /> @@ -205,10 +204,7 @@ const ReasoningConfigForm: React.FC = ({ )}
-
handleAutomatic(variable, !auto, type)} - > +
+
{auto === 0 && (
@@ -335,7 +331,7 @@ const ReasoningConfigForm: React.FC = ({ className="inline-flex items-center text-xs text-text-accent" > {t(($) => $.howToGet, { ns: 'tools' })} - + )}
diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/schema-modal.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/schema-modal.tsx index 9250d38ffc4..5b520755e89 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/schema-modal.tsx +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/schema-modal.tsx @@ -1,8 +1,6 @@ 'use client' -import type { FC } from 'react' import type { SchemaRoot } from '@/app/components/workflow/nodes/llm/types' import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' -import * as React from 'react' import { useTranslation } from 'react-i18next' import VisualEditor from '@/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor' import { @@ -17,7 +15,7 @@ type Props = Readonly<{ onClose: () => void }> -const SchemaModal: FC = ({ isShow, schema, rootName, onClose }) => { +export function SchemaModal({ isShow, schema, rootName, onClose }: Props) { const { t } = useTranslation() return ( !open && onClose()}> @@ -55,4 +53,3 @@ const SchemaModal: FC = ({ isShow, schema, rootName, onClose }) => { ) } -export default React.memo(SchemaModal) diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-authorization-section.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-authorization-section.tsx index 2e70ff29bd4..0a40593d11c 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-authorization-section.tsx +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-authorization-section.tsx @@ -1,5 +1,4 @@ 'use client' -import type { FC } from 'react' import type { ToolWithProvider } from '@/app/components/workflow/types' import Divider from '@/app/components/base/divider' import { AuthCategory, PluginAuthInAgent } from '@/app/components/plugins/plugin-auth' @@ -11,11 +10,11 @@ type ToolAuthorizationSectionProps = { onAuthorizationItemClick: (id: string) => void } -const ToolAuthorizationSection: FC = ({ +export function ToolAuthorizationSection({ currentProvider, credentialId, onAuthorizationItemClick, -}) => { +}: ToolAuthorizationSectionProps) { // Only show for built-in providers that allow deletion const shouldShow = currentProvider && @@ -41,5 +40,3 @@ const ToolAuthorizationSection: FC = ({ ) } - -export default ToolAuthorizationSection diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-base-form.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-base-form.tsx index f79fe82c967..86895d08b2c 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-base-form.tsx +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-base-form.tsx @@ -1,19 +1,17 @@ 'use client' -import type { OffsetOptions } from '@floating-ui/react' -import type { FC } from 'react' import type { PluginDetail } from '@/app/components/plugins/types' +import type { ToolPickerScope } from '@/app/components/workflow/block-selector/tool-picker' import type { ToolDefaultValue, ToolValue } from '@/app/components/workflow/block-selector/types' import type { ToolWithProvider } from '@/app/components/workflow/types' import { Textarea } from '@langgenius/dify-ui/textarea' import { useTranslation } from 'react-i18next' import ToolPicker from '@/app/components/workflow/block-selector/tool-picker' import { ReadmeEntrance } from '../../../readme-panel/entrance' -import ToolTrigger from './tool-trigger' +import { ToolTrigger } from './tool-trigger' type ToolBaseFormProps = { value?: ToolValue currentProvider?: ToolWithProvider - offset?: OffsetOptions scope?: string selectedTools?: ToolValue[] isShowChooseTool: boolean @@ -26,10 +24,14 @@ type ToolBaseFormProps = { onDescriptionChange: (value: string) => void } -const ToolBaseForm: FC = ({ +function resolveToolPickerScope(scope?: string): ToolPickerScope { + if (scope === 'plugins' || scope === 'custom' || scope === 'workflow') return scope + return 'all' +} + +export function ToolBaseForm({ value, currentProvider, - offset = 4, scope, selectedTools, isShowChooseTool, @@ -40,12 +42,11 @@ const ToolBaseForm: FC = ({ onSelectTool, onSelectMultipleTool, onDescriptionChange, -}) => { +}: ToolBaseFormProps) { const { t } = useTranslation() return (
- {/* Tool picker */}
{t(($) => $['detailPanel.toolSelector.toolLabel'], { ns: 'plugin' })} @@ -59,7 +60,7 @@ const ToolBaseForm: FC = ({
= ({ /> } isShow={panelShowState || isShowChooseTool} - onShowChange={hasTrigger ? onPanelShowStateChange || (() => {}) : onShowChange} + onShowChange={hasTrigger ? onPanelShowStateChange || onShowChange : onShowChange} disabled={false} supportAddCustomTool onSelect={onSelectTool} onSelectMultiple={onSelectMultipleTool} - scope={scope} + scope={resolveToolPickerScope(scope)} selectedTools={selectedTools} />
- {/* Description */}
{t(($) => $['detailPanel.toolSelector.descriptionLabel'], { ns: 'plugin' })} @@ -97,5 +97,3 @@ const ToolBaseForm: FC = ({
) } - -export default ToolBaseForm diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-item.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-item.tsx index b7adb21e371..486a539e3bf 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-item.tsx +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-item.tsx @@ -7,7 +7,6 @@ import { Switch } from '@langgenius/dify-ui/switch' import * as React from 'react' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import ActionButton from '@/app/components/base/action-button' import AppIcon from '@/app/components/base/app-icon' import { InstallPluginButton } from '@/app/components/workflow/nodes/_base/components/install-plugin-button' import { useMCPToolAvailability } from '@/app/components/workflow/nodes/_base/components/mcp-tool-availability' @@ -15,6 +14,8 @@ import McpToolNotSupportTooltip from '@/app/components/workflow/nodes/_base/comp import { SwitchPluginVersion } from '@/app/components/workflow/nodes/_base/components/switch-plugin-version' type Props = Readonly<{ + triggerRef?: React.Ref + triggerLabel?: string icon?: string | { content?: string; background?: string } providerName?: string isMCPTool?: boolean @@ -35,7 +36,9 @@ type Props = Readonly<{ authRemoved?: boolean }> -const ToolItem = ({ +export function ToolItem({ + triggerRef, + triggerLabel, open, icon, isMCPTool, @@ -54,26 +57,40 @@ const ToolItem = ({ errorTip, versionMismatch, authRemoved, -}: Props) => { +}: Props) { const { t } = useTranslation() const { allowed: isMCPToolAllowed } = useMCPToolAvailability() const providerNameText = isMCPTool ? providerShowName : providerName?.split('/').pop() const isTransparent = uninstalled || versionMismatch || isError const [isDeleting, setIsDeleting] = useState(false) const isShowCanNotChooseMCPTip = isMCPTool && !isMCPToolAllowed + const accessibleTriggerLabel = + triggerLabel || + toolLabel || + t(($) => $['detailPanel.toolSelector.toolSetting'], { ns: 'plugin' }) return (
+ + } + /> {icon && (
{providerNameText}
{toolLabel}
-
+
{!noAuth && !isError && !uninstalled && !versionMismatch && !isShowCanNotChooseMCPTip && ( - + - + + )} + {onDelete && ( + )} -
{ - e.stopPropagation() - onDelete?.() - }} - onMouseOver={() => setIsDeleting(true)} - onMouseLeave={() => setIsDeleting(false)} - > - -
{!isError && !uninstalled && @@ -141,25 +163,37 @@ const ToolItem = ({ !versionMismatch && !isShowCanNotChooseMCPTip && showSwitch && ( -
e.stopPropagation()}> +
)} - {isShowCanNotChooseMCPTip && } + {isShowCanNotChooseMCPTip && ( +
+ +
+ )} {!isError && !uninstalled && !versionMismatch && noAuth && ( - + + {t(($) => $.notAuthorized, { ns: 'tools' })} + + + } + /> )} {!isError && !uninstalled && !versionMismatch && authRemoved && ( - + + {t(($) => $['auth.authRemoved'], { ns: 'plugin' })} + + + } + /> )} {!isError && !uninstalled && versionMismatch && installInfo && ( -
e.stopPropagation()}> +
)} {!isError && uninstalled && installInfo && ( - e.stopPropagation()} - size="small" - uniqueIdentifier={installInfo} - onSuccess={() => { - onInstall?.() - }} - /> +
+ { + onInstall?.() + }} + /> +
)} {isError && ( - - $['detailPanel.toolSelector.unsupportedTitle'], { ns: 'plugin' }) - } - className="inline-flex border-0 bg-transparent p-0" - > - - - - {errorTip} - - +
+ + $['detailPanel.toolSelector.unsupportedTitle'], { ns: 'plugin' }) + } + className="inline-flex border-0 bg-transparent p-0" + > + + + + {errorTip} + + +
)}
) } - -export default ToolItem diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-settings-panel.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-settings-panel.tsx index 6c3576f76eb..10088fda041 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-settings-panel.tsx +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-settings-panel.tsx @@ -1,7 +1,6 @@ 'use client' -import type { FC } from 'react' import type { Node } from 'reactflow' -import type { TabType } from '../hooks/use-tool-selector-state' +import type { TabType } from '../hooks/use-tool-selector' import type { ReasoningConfigValue } from './reasoning-config-form' import type { CredentialFormSchema } from '@/app/components/header/account-setting/model-provider-page/declarations' import type { ToolFormSchema } from '@/app/components/tools/utils/to-form-schema' @@ -35,7 +34,7 @@ type ToolSettingsPanelProps = { /** * Renders the settings/params tips section */ -const ParamsTips: FC = () => { +function ParamsTips() { const { t } = useTranslation() return (
@@ -49,7 +48,7 @@ const ParamsTips: FC = () => { ) } -const ToolSettingsPanel: FC = ({ +export function ToolSettingsPanel({ value, currentProvider, nodeId, @@ -65,7 +64,7 @@ const ToolSettingsPanel: FC = ({ onCurrTypeChange, onSettingsFormChange, onParamsFormChange, -}) => { +}: ToolSettingsPanelProps) { const { t } = useTranslation() // Check if panel should be shown @@ -157,5 +156,3 @@ const ToolSettingsPanel: FC = ({ ) } - -export default ToolSettingsPanel diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-trigger.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-trigger.tsx index b7b59b783c1..8f347113cb2 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-trigger.tsx +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-trigger.tsx @@ -1,30 +1,44 @@ 'use client' +import type { ButtonProps } from '@langgenius/dify-ui/button' import type { ToolWithProvider } from '@/app/components/workflow/types' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { RiArrowDownSLine, RiEqualizer2Line } from '@remixicon/react' import * as React from 'react' import { useTranslation } from 'react-i18next' import BlockIcon from '@/app/components/workflow/block-icon' import { BlockEnum } from '@/app/components/workflow/types' -type Props = Readonly<{ - open: boolean - provider?: ToolWithProvider - value?: { - provider_name: string - tool_name: string - } - isConfigure?: boolean -}> +type Props = Readonly< + { + open: boolean + provider?: ToolWithProvider + value?: { + provider_name: string + tool_name: string + } + isConfigure?: boolean + } & Omit +> -const ToolTrigger = ({ open, provider, value, isConfigure }: Props) => { +export function ToolTrigger({ + open, + provider, + value, + isConfigure, + className, + ...buttonProps +}: Props) { const { t } = useTranslation() return ( -
{value?.provider_name && provider && ( @@ -45,23 +59,23 @@ const ToolTrigger = ({ open, provider, value, isConfigure }: Props) => {
)} {isConfigure && ( - )} {!isConfigure && ( - )} -
+ ) } - -export default ToolTrigger diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/__tests__/use-tool-selector-state.spec.ts b/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/__tests__/use-tool-selector.spec.ts similarity index 87% rename from web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/__tests__/use-tool-selector-state.spec.ts rename to web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/__tests__/use-tool-selector.spec.ts index 0e0c891cd59..12c1f94866d 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/__tests__/use-tool-selector-state.spec.ts +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/__tests__/use-tool-selector.spec.ts @@ -2,7 +2,7 @@ import type { ToolValue } from '@/app/components/workflow/block-selector/types' import { act, renderHook } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { CollectionType } from '@/app/components/tools/types' -import { useToolSelectorState } from '../use-tool-selector-state' +import { useToolSelector } from '../use-tool-selector' const mockToolParams = [ { name: 'param1', form: 'llm', type: 'string', required: true, label: { en_US: 'Param 1' } }, @@ -60,7 +60,7 @@ vi.mock('@/app/components/tools/utils/to-form-schema', () => ({ getStructureValue: (value: Record) => value || {}, })) -describe('useToolSelectorState', () => { +describe('useToolSelector', () => { const mockOnSelect = vi.fn() const _mockOnSelectMultiple = vi.fn() @@ -88,7 +88,7 @@ describe('useToolSelectorState', () => { }) it('should initialize with default panel states', () => { - const { result } = renderHook(() => useToolSelectorState({ onSelect: mockOnSelect })) + const { result } = renderHook(() => useToolSelector({ onSelect: mockOnSelect })) expect(result.current.isShow).toBe(false) expect(result.current.isShowChooseTool).toBe(false) @@ -97,7 +97,7 @@ describe('useToolSelectorState', () => { it('should find current provider from tool value', () => { const { result } = renderHook(() => - useToolSelectorState({ value: toolValue, onSelect: mockOnSelect }), + useToolSelector({ value: toolValue, onSelect: mockOnSelect }), ) expect(result.current.currentProvider).toBeDefined() @@ -106,7 +106,7 @@ describe('useToolSelectorState', () => { it('should find current tool from provider', () => { const { result } = renderHook(() => - useToolSelectorState({ value: toolValue, onSelect: mockOnSelect }), + useToolSelector({ value: toolValue, onSelect: mockOnSelect }), ) expect(result.current.currentTool).toBeDefined() @@ -115,7 +115,7 @@ describe('useToolSelectorState', () => { it('should compute tool settings and params correctly', () => { const { result } = renderHook(() => - useToolSelectorState({ value: toolValue, onSelect: mockOnSelect }), + useToolSelector({ value: toolValue, onSelect: mockOnSelect }), ) // param2 has form='form' (not 'llm'), so it goes to settings @@ -129,7 +129,7 @@ describe('useToolSelectorState', () => { it('should show tab slider when both settings and params exist', () => { const { result } = renderHook(() => - useToolSelectorState({ value: toolValue, onSelect: mockOnSelect }), + useToolSelector({ value: toolValue, onSelect: mockOnSelect }), ) expect(result.current.showTabSlider).toBe(true) @@ -138,7 +138,7 @@ describe('useToolSelectorState', () => { }) it('should toggle panel visibility', () => { - const { result } = renderHook(() => useToolSelectorState({ onSelect: mockOnSelect })) + const { result } = renderHook(() => useToolSelector({ onSelect: mockOnSelect })) act(() => { result.current.setIsShow(true) @@ -152,7 +152,7 @@ describe('useToolSelectorState', () => { }) it('should switch tab type', () => { - const { result } = renderHook(() => useToolSelectorState({ onSelect: mockOnSelect })) + const { result } = renderHook(() => useToolSelector({ onSelect: mockOnSelect })) act(() => { result.current.setCurrType('params') @@ -162,7 +162,7 @@ describe('useToolSelectorState', () => { it('should handle description change', () => { const { result } = renderHook(() => - useToolSelectorState({ value: toolValue, onSelect: mockOnSelect }), + useToolSelector({ value: toolValue, onSelect: mockOnSelect }), ) act(() => { @@ -178,7 +178,7 @@ describe('useToolSelectorState', () => { it('should handle enabled change', () => { const { result } = renderHook(() => - useToolSelectorState({ value: toolValue, onSelect: mockOnSelect }), + useToolSelector({ value: toolValue, onSelect: mockOnSelect }), ) act(() => { @@ -194,7 +194,7 @@ describe('useToolSelectorState', () => { it('should handle authorization item click', () => { const { result } = renderHook(() => - useToolSelectorState({ value: toolValue, onSelect: mockOnSelect }), + useToolSelector({ value: toolValue, onSelect: mockOnSelect }), ) act(() => { @@ -209,7 +209,7 @@ describe('useToolSelectorState', () => { }) it('should not call onSelect if value is undefined', () => { - const { result } = renderHook(() => useToolSelectorState({ onSelect: mockOnSelect })) + const { result } = renderHook(() => useToolSelector({ onSelect: mockOnSelect })) act(() => { result.current.handleEnabledChange(true) @@ -219,7 +219,7 @@ describe('useToolSelectorState', () => { it('should return empty arrays when no provider matches', () => { const { result } = renderHook(() => - useToolSelectorState({ + useToolSelector({ value: { ...toolValue, provider_name: 'nonexistent' }, onSelect: mockOnSelect, }), @@ -232,7 +232,7 @@ describe('useToolSelectorState', () => { }) it('should skip plugin checks after resolving the current provider and tool', () => { - renderHook(() => useToolSelectorState({ value: toolValue, onSelect: mockOnSelect })) + renderHook(() => useToolSelector({ value: toolValue, onSelect: mockOnSelect })) expect(mockUsePluginInstalledCheck).toHaveBeenCalledWith({ providerPluginId: 'org/test-plugin', @@ -242,7 +242,7 @@ describe('useToolSelectorState', () => { it('should keep plugin checks enabled when the current tool cannot be resolved', () => { renderHook(() => - useToolSelectorState({ + useToolSelector({ value: { ...toolValue, tool_name: 'missing-tool' }, onSelect: mockOnSelect, }), @@ -256,7 +256,7 @@ describe('useToolSelectorState', () => { it('should keep marketplace fallback enabled after tool queries settle without resolving the provider', () => { renderHook(() => - useToolSelectorState({ + useToolSelector({ value: { ...toolValue, provider_name: 'org/market-plugin/search', @@ -276,7 +276,7 @@ describe('useToolSelectorState', () => { areToolQueriesFetched = false renderHook(() => - useToolSelectorState({ + useToolSelector({ value: { ...toolValue, provider_name: 'org/market-plugin/search', @@ -294,7 +294,7 @@ describe('useToolSelectorState', () => { it('should skip marketplace checks for unresolved non-plugin workflow tools', () => { renderHook(() => - useToolSelectorState({ + useToolSelector({ value: { ...toolValue, provider_name: 'author/tool-b', diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/use-tool-selector-state.ts b/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/use-tool-selector.ts similarity index 91% rename from web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/use-tool-selector-state.ts rename to web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/use-tool-selector.ts index 67146ab9bcf..90627db55fa 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/use-tool-selector-state.ts +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/use-tool-selector.ts @@ -24,27 +24,17 @@ import { usePluginInstalledCheck } from './use-plugin-installed-check' export type TabType = 'settings' | 'params' -type UseToolSelectorStateProps = { +type UseToolSelectorProps = { value?: ToolValue onSelect: (tool: ToolValue) => void onSelectMultiple?: (tool: ToolValue[]) => void } -/** - * Custom hook for managing tool selector state and computed values. - * Consolidates state management, data fetching, and event handlers. - */ -export const useToolSelectorState = ({ - value, - onSelect, - onSelectMultiple, -}: UseToolSelectorStateProps) => { - // Panel visibility states +export function useToolSelector({ value, onSelect, onSelectMultiple }: UseToolSelectorProps) { const [isShow, setIsShow] = useState(false) const [isShowChooseTool, setIsShowChooseTool] = useState(false) const [currType, setCurrType] = useState('settings') - // Fetch all tools data const buildInToolsQuery = useAllBuiltInTools() const customToolsQuery = useAllCustomTools() const workflowToolsQuery = useAllWorkflowTools() @@ -56,7 +46,6 @@ export const useToolSelectorState = ({ const invalidateAllBuiltinTools = useInvalidateAllBuiltInTools() const invalidateInstalledPluginList = useInvalidateInstalledPluginList() - // Merge all tools and find current provider const currentProvider = useMemo(() => { const mergedTools = [ ...(buildInTools || []), @@ -73,7 +62,6 @@ export const useToolSelectorState = ({ mcpToolsQuery, ].every((toolProvidersQuery) => toolProvidersQuery.isFetched) - // Current tool from provider const currentTool = useMemo(() => { return currentProvider?.tools.find((tool) => tool.name === value?.tool_name) }, [currentProvider?.tools, value?.tool_name]) @@ -100,7 +88,6 @@ export const useToolSelectorState = ({ value?.type, ]) - // Plugin info check const { inMarketPlace, manifest, pluginID } = usePluginInstalledCheck({ providerPluginId, enabled: @@ -109,7 +96,6 @@ export const useToolSelectorState = ({ (currentProvider !== undefined || areToolProvidersSettled || !!value?.plugin_id), }) - // Tool settings and params const currentToolSettings = useMemo(() => { if (!currentProvider) return [] return ( @@ -128,7 +114,6 @@ export const useToolSelectorState = ({ ) }, [currentProvider, value]) - // Form schemas const settingsFormSchemas = useMemo( () => toolParametersToFormSchemas(currentToolSettings), [currentToolSettings], @@ -138,18 +123,15 @@ export const useToolSelectorState = ({ [currentToolParams], ) - // Tab visibility flags const showTabSlider = currentToolSettings.length > 0 && currentToolParams.length > 0 const userSettingsOnly = currentToolSettings.length > 0 && !currentToolParams.length const reasoningConfigOnly = currentToolParams.length > 0 && !currentToolSettings.length - // Manifest icon URL const manifestIcon = useMemo(() => { if (!manifest || !pluginID) return '' return getIconFromMarketPlace(pluginID) }, [manifest, pluginID]) - // Convert tool default value to tool value format const getToolValue = useCallback((tool: ToolDefaultValue): ToolValue => { const settingValues = generateFormValue( tool.params, @@ -181,7 +163,6 @@ export const useToolSelectorState = ({ } }, []) - // Event handlers const handleSelectTool = useCallback( (tool: ToolDefaultValue) => { const toolValue = getToolValue(tool) @@ -270,14 +251,13 @@ export const useToolSelectorState = ({ } }, [invalidateAllBuiltinTools, invalidateInstalledPluginList]) - const getSettingsValue = useCallback((): ResourceVarInputs => { + const settingsValue = useMemo((): ResourceVarInputs => { return getPlainValue( (value?.settings || {}) as Record, ) as ResourceVarInputs }, [value?.settings]) return { - // State isShow, setIsShow, isShowChooseTool, @@ -285,7 +265,6 @@ export const useToolSelectorState = ({ currType, setCurrType, - // Computed values currentProvider, currentTool, currentToolSettings, @@ -298,8 +277,8 @@ export const useToolSelectorState = ({ manifestIcon, inMarketPlace, manifest, + settingsValue, - // Event handlers handleSelectTool, handleSelectMultipleTool, handleDescriptionChange, @@ -308,6 +287,5 @@ export const useToolSelectorState = ({ handleEnabledChange, handleAuthorizationItemClick, handleInstall, - getSettingsValue, } } diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/index.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/index.tsx index d0d3f3506de..c8dda9bfc08 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/index.tsx +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/index.tsx @@ -1,29 +1,39 @@ 'use client' -import type { OffsetOptions } from '@floating-ui/react' import type { Placement } from '@langgenius/dify-ui/popover' -import type { FC } from 'react' +import type { ReactElement, Ref } from 'react' import type { Node } from 'reactflow' import type { ToolValue } from '@/app/components/workflow/block-selector/types' import type { NodeOutPutVar } from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' -import * as React from 'react' +import { memo } from 'react' import { useTranslation } from 'react-i18next' import { CollectionType } from '@/app/components/tools/types' import Link from '@/next/link' -import { - ToolAuthorizationSection, - ToolBaseForm, - ToolItem, - ToolSettingsPanel, - ToolTrigger, -} from './components' -import { useToolSelectorState } from './hooks/use-tool-selector-state' +import { ToolAuthorizationSection } from './components/tool-authorization-section' +import { ToolBaseForm } from './components/tool-base-form' +import { ToolItem } from './components/tool-item' +import { ToolSettingsPanel } from './components/tool-settings-panel' +import { ToolTrigger } from './components/tool-trigger' +import { useToolSelector } from './hooks/use-tool-selector' + +type TriggerProps = + | { + trigger: ReactElement + triggerRef?: never + controlledState: boolean + onControlledStateChange: (state: boolean) => void + } + | { + trigger?: never + triggerRef?: Ref + controlledState?: never + onControlledStateChange?: never + } type Props = Readonly<{ disabled?: boolean placement?: Placement - offset?: OffsetOptions scope?: string value?: ToolValue selectedTools?: ToolValue[] @@ -32,24 +42,20 @@ type Props = Readonly<{ isEdit?: boolean onDelete?: () => void supportEnableSwitch?: boolean - supportAddCustomTool?: boolean - trigger?: React.ReactNode - controlledState?: boolean - onControlledStateChange?: (state: boolean) => void panelShowState?: boolean onPanelShowStateChange?: (state: boolean) => void nodeOutputVars: NodeOutPutVar[] availableNodes: Node[] nodeId?: string -}> +}> & + TriggerProps -const ToolSelector: FC = ({ +function ToolSelector({ value, selectedTools, isEdit, disabled, placement = 'left', - offset = 4, onSelect, onSelectMultiple, onDelete, @@ -63,15 +69,9 @@ const ToolSelector: FC = ({ nodeOutputVars, availableNodes, nodeId = '', -}) => { + triggerRef, +}: Props) { const { t } = useTranslation() - const sideOffset = - typeof offset === 'number' ? offset : typeof offset === 'function' ? 0 : (offset?.mainAxis ?? 0) - const alignOffset = - typeof offset === 'number' ? 0 : typeof offset === 'function' ? 0 : (offset?.crossAxis ?? 0) - - // Use custom hook for state management - const state = useToolSelectorState({ value, onSelect, onSelectMultiple }) const { isShow, setIsShow, @@ -97,18 +97,17 @@ const ToolSelector: FC = ({ handleEnabledChange, handleAuthorizationItemClick, handleInstall, - getSettingsValue, - } = state + settingsValue, + } = useToolSelector({ value, onSelect, onSelectMultiple }) - // Determine portal open state based on controlled vs uncontrolled mode const portalOpen = trigger ? controlledState : isShow const onPortalOpenChange = trigger ? onControlledStateChange : setIsShow const handlePortalOpenChange = (nextOpen: boolean) => { - if (nextOpen && (disabled || !currentProvider || !currentTool)) return + const isConfiguredToolUnavailable = !!value?.provider_name && (!currentProvider || !currentTool) + if (nextOpen && (disabled || isConfiguredToolUnavailable)) return onPortalOpenChange?.(nextOpen) } - // Build error tooltip content const renderErrorTip = () => (

@@ -131,63 +130,67 @@ const ToolSelector: FC = ({ return ( - }> - {trigger} + {trigger ? : null} - {/* Default trigger - no value */} - {!trigger && !value?.provider_name && ( - - )} + {!trigger && !value?.provider_name ? ( + + } + /> + ) : null} - {/* Default trigger - with value */} - {!trigger && value?.provider_name && ( - - )} - + {!trigger && value?.provider_name ? ( + + ) : null}
- {/* Header */}
{t(($) => $[`detailPanel.toolSelector.${isEdit ? 'toolSetting' : 'title'}`], { ns: 'plugin', })}
- {/* Base form: tool picker + description */} = ({ onDescriptionChange={handleDescriptionChange} /> - {/* Authorization section */} - {/* Settings panel */} = ({ currType={currType} settingsFormSchemas={settingsFormSchemas} paramsFormSchemas={paramsFormSchemas} - settingsValue={getSettingsValue()} + settingsValue={settingsValue} showTabSlider={showTabSlider} userSettingsOnly={userSettingsOnly} reasoningConfigOnly={reasoningConfigOnly} @@ -231,4 +232,4 @@ const ToolSelector: FC = ({ ) } -export default React.memo(ToolSelector) +export default memo(ToolSelector) diff --git a/web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-option.tsx b/web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-option.tsx index 3b148fd42fa..18137820869 100644 --- a/web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-option.tsx +++ b/web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-option.tsx @@ -1,25 +1,30 @@ 'use client' -import type { FC, ReactNode } from 'react' +import type { ButtonHTMLAttributes, ReactNode } from 'react' +import { cn } from '@langgenius/dify-ui/cn' -type StartNodeOptionProps = { +type StartNodeOptionProps = Omit, 'children'> & { icon: ReactNode title: string subtitle?: string description: string - onClick: () => void } -const StartNodeOption: FC = ({ +function StartNodeOption({ icon, title, subtitle, description, - onClick, -}) => { + className, + ...buttonProps +}: StartNodeOptionProps) { return ( -
{icon}
@@ -37,7 +42,7 @@ const StartNodeOption: FC = ({

{description}

-

+ ) } 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 982ffb01802..5fdc343a8c4 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 @@ -3,7 +3,7 @@ import type { FC } from 'react' import type { BlockDefaultValue } from '@/app/components/workflow/block-selector/types' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' -import NodeSelector from '@/app/components/workflow/block-selector' +import BlockSelector from '@/app/components/workflow/block-selector' import { TabType } from '@/app/components/workflow/block-selector/types' import { BlockEnum } from '@/app/components/workflow/types' import StartNodeOption from './start-node-option' @@ -41,15 +41,12 @@ const StartNodeSelectionPanel: FC = ({ onClick={onSelectUserInput} /> - ({ onOpenChange: (open: boolean) => void onSelect: (nodeType: string, pluginDefaultValue?: Record) => void availableBlocksTypes: string[] - triggerClassName?: () => string }) => { mockBlockSelector(props) return ( + )} + />, + ) + + const trigger = screen.getByRole('button', { name: 'selector-closed' }) + expect(trigger).toHaveAttribute('aria-haspopup', 'dialog') + + await user.click(trigger) + + expect(screen.getByRole('dialog', { name: 'workflow.common.addBlock' })).toBeInTheDocument() + + const searchInput = screen.getByRole('searchbox', { name: 'workflow.tabs.searchBlock' }) + expect(screen.getByText('LLM')).toBeInTheDocument() + expect(screen.getByText('End')).toBeInTheDocument() + + await user.type(searchInput, 'LLM') + expect(screen.getByText('LLM')).toBeInTheDocument() + expect(screen.queryByText('End')).not.toBeInTheDocument() + + await user.click(screen.getByText('LLM')) + + expect(onSelect).toHaveBeenCalledWith(BlockEnum.LLM, undefined) + await waitFor(() => { + expect( + screen.queryByRole('searchbox', { name: 'workflow.tabs.searchBlock' }), + ).not.toBeInTheDocument() + }) + + await user.click(screen.getByRole('button', { name: 'selector-closed' })) + + const reopenedInput = screen.getByRole('searchbox', { + name: 'workflow.tabs.searchBlock', + }) as HTMLInputElement + expect(reopenedInput.value).toBe('') + expect(screen.getByText('End')).toBeInTheDocument() + }) + + it('resets to the default tab after closing', async () => { + const user = userEvent.setup() + + renderBlockSelector( + ( + + )} + />, + ) + + await user.click(screen.getByRole('button', { name: 'selector-closed' })) + await user.click(screen.getByText('workflow.tabs.start')) + + expect( + screen.getByRole('searchbox', { name: 'workflow.tabs.searchTrigger' }), + ).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'selector-open' })) + await waitFor(() => { + expect( + screen.queryByRole('searchbox', { name: 'workflow.tabs.searchTrigger' }), + ).not.toBeInTheDocument() + }) + + await user.click(screen.getByRole('button', { name: 'selector-closed' })) + + expect(screen.getByRole('searchbox', { name: 'workflow.tabs.searchBlock' })).toBeInTheDocument() + }) + + it('preserves the current popup session until a controlled close actually unmounts it', async () => { + const user = userEvent.setup() + const onOpenChange = vi.fn() + + renderBlockSelector( + } + />, + ) + + await user.click(screen.getByRole('tab', { name: 'workflow.tabs.start' })) + const searchInput = screen.getByRole('searchbox', { name: 'workflow.tabs.searchTrigger' }) + await user.type(searchInput, 'webhook') + await user.click(screen.getByRole('button', { name: 'selector-open' })) + + expect(onOpenChange).toHaveBeenCalledWith(false) + expect(screen.getByRole('tab', { name: 'workflow.tabs.start' })).toHaveAttribute( + 'aria-selected', + 'true', + ) + expect(searchInput).toHaveValue('webhook') + }) + + it('focuses search and exposes a non-tabbing close action for touch screen readers', async () => { + const user = userEvent.setup() + + renderBlockSelector( + ( + + )} + />, + ) + + await user.click(screen.getByRole('button', { name: 'selector-closed' })) + expect(screen.getByRole('searchbox', { name: 'workflow.tabs.searchBlock' })).toHaveFocus() + await waitFor(() => { + expect(screen.getByRole('dialog').parentElement).toHaveStyle({ position: 'fixed' }) + }) + + await user.tab({ shift: true }) + const blocksTab = screen.getByRole('tab', { name: 'workflow.tabs.blocks' }) + expect(blocksTab).toHaveFocus() + + const closeButton = screen.getByRole('button', { name: 'common.operation.close' }) + expect(closeButton).toHaveAttribute('tabindex', '-1') + + const startTab = screen.getByRole('tab', { name: 'workflow.tabs.start' }) + await user.click(startTab) + + expect(startTab).toHaveFocus() + expect(screen.getByRole('searchbox', { name: 'workflow.tabs.searchTrigger' })).not.toHaveFocus() + }) + + it('does not open or emit open changes when disabled', async () => { + const user = userEvent.setup() + const onOpenChange = vi.fn() + + renderBlockSelector( + ( + + )} + />, + ) + + const trigger = screen.getByRole('button', { name: 'selector-closed' }) + expect(trigger).toBeDisabled() + await user.click(trigger) + + expect(onOpenChange).not.toHaveBeenCalled() + expect( + screen.queryByRole('searchbox', { name: 'workflow.tabs.searchBlock' }), + ).not.toBeInTheDocument() + }) + + it('allows an open selector to close after it becomes disabled', async () => { + const user = userEvent.setup() + let disableSelector = () => {} + + function Harness() { + const [open, setOpen] = useState(true) + const [disabled, setDisabled] = useState(false) + disableSelector = () => setDisabled(true) + + return ( + } + /> + ) + } + + renderBlockSelector() + expect(screen.getByRole('dialog', { name: 'workflow.common.addBlock' })).toBeInTheDocument() + + act(disableSelector) + expect(screen.getByRole('button', { name: 'selector-trigger' })).toBeDisabled() + + await user.keyboard('{Escape}') + + await waitFor(() => { + expect( + screen.queryByRole('dialog', { name: 'workflow.common.addBlock' }), + ).not.toBeInTheDocument() + }) + }) + + it('returns focus to the trigger after Escape', async () => { + const user = userEvent.setup() + + renderBlockSelector( + } + />, + ) + + const trigger = screen.getByRole('button', { name: 'selector-trigger' }) + await user.click(trigger) + expect(screen.getByRole('searchbox', { name: 'workflow.tabs.searchBlock' })).toHaveFocus() + + await user.keyboard('{Escape}') + + expect(trigger).toHaveFocus() + expect( + screen.queryByRole('dialog', { name: 'workflow.common.addBlock' }), + ).not.toBeInTheDocument() + }) + + it('preserves the custom trigger click handler', async () => { + const user = userEvent.setup() + const onTriggerClick = vi.fn() + + renderBlockSelector( + ( + + )} + />, + ) + + await user.click(screen.getByRole('button', { name: 'open-selector' })) + + expect(onTriggerClick).toHaveBeenCalledTimes(1) + expect(screen.getByRole('searchbox', { name: 'workflow.tabs.searchBlock' })).toBeInTheDocument() + }) + + it('can render a prop-forwarding button component as the popover root', async () => { + const user = userEvent.setup() + + function ForwardingButtonTrigger(props: ButtonHTMLAttributes) { + return ( + + ) + } + + renderBlockSelector( + } + />, + ) + + const trigger = screen.getByRole('button', { name: 'open-selector-root' }) + await user.click(trigger) + + expect(trigger).toHaveAttribute('aria-haspopup', 'dialog') + expect(screen.getByRole('searchbox', { name: 'workflow.tabs.searchBlock' })).toBeInTheDocument() + }) + + it('can render the shared Button trigger as the popover root', async () => { + const user = userEvent.setup() + + renderBlockSelector( + } + />, + ) + + const trigger = screen.getByRole('button', { name: 'open-shared-button-trigger' }) + await user.click(trigger) + + expect(trigger).toHaveAttribute('aria-haspopup', 'dialog') + expect(screen.getByRole('searchbox', { name: 'workflow.tabs.searchBlock' })).toBeInTheDocument() + }) + + it('isolates popup keyboard events when opened from another keyboard-managed overlay', async () => { + const user = userEvent.setup() + const handleParentKeyDown = vi.fn() + + renderBlockSelector( + , + ) + + const searchInput = screen.getByRole('searchbox', { + name: 'workflow.tabs.searchBlock', + }) as HTMLInputElement + document.body.addEventListener('keydown', handleParentKeyDown) + + try { + await user.type(searchInput, 'LLM') + } finally { + document.body.removeEventListener('keydown', handleParentKeyDown) + } + + expect(searchInput.value).toBe('LLM') + expect(handleParentKeyDown).not.toHaveBeenCalled() + expect(screen.getByText('LLM')).toBeInTheDocument() + expect(screen.queryByText('End')).not.toBeInTheDocument() + }) + + it('disables the start tab with a setup tooltip when an unconfigured start node is on the canvas', async () => { + const user = userEvent.setup() + + renderBlockSelector( + , + { + initialStoreState: { + nodes: [ + { + id: 'start-placeholder', + data: { + type: BlockEnum.StartPlaceholder, + }, + }, + ] as never, + }, + }, + ) + + await user.hover(screen.getByText('workflow.tabs.start')) + + expect( + await screen.findByText('workflow.tabs.unconfiguredStartDisabledTip'), + ).toBeInTheDocument() + expect(screen.queryByRole('link')).not.toBeInTheDocument() + expect(screen.getByRole('searchbox', { name: 'workflow.tabs.searchBlock' })).toBeInTheDocument() + }) + + it('keeps the start tab enabled when a configured user input start node is on the canvas', () => { + renderBlockSelector( + , + { + initialStoreState: { + nodes: [ + { + id: 'start', + data: { + type: BlockEnum.Start, + }, + }, + ] as never, + }, + }, + ) + + expect(screen.getByText('workflow.tabs.start')).toHaveAttribute('aria-disabled', 'false') + expect( + screen.getByText('workflow.nodes.startPlaceholder.userInputConflictTip'), + ).toBeInTheDocument() + }) }) diff --git a/web/app/components/workflow/block-selector/__tests__/main.spec.tsx b/web/app/components/workflow/block-selector/__tests__/main.spec.tsx deleted file mode 100644 index 68f77b25418..00000000000 --- a/web/app/components/workflow/block-selector/__tests__/main.spec.tsx +++ /dev/null @@ -1,421 +0,0 @@ -import type { ButtonHTMLAttributes, ReactElement } from 'react' -import type { NodeDefault } from '../../types' -import { Button } from '@langgenius/dify-ui/button' -import { screen, waitFor } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { FlowType } from '@/types/common' -import { renderWorkflowComponent } from '../../__tests__/workflow-test-env' -import { BlockEnum } from '../../types' -import NodeSelector from '../main' -import { BlockClassification, TabType } from '../types' - -vi.mock('reactflow', () => ({ - useStoreApi: () => ({ - getState: () => ({ - getNodes: () => [], - }), - }), -})) - -vi.mock('@/service/use-plugins', () => ({ - useFeaturedToolsRecommendations: () => ({ - plugins: [], - isLoading: false, - }), - useFeaturedTriggersRecommendations: () => ({ - plugins: [], - isLoading: false, - }), -})) - -vi.mock('@/app/components/plugins/marketplace/hooks', () => ({ - useMarketplacePlugins: () => ({ - plugins: [], - queryPluginsWithDebounced: vi.fn(), - }), -})) - -vi.mock('@/service/use-triggers', () => ({ - useAllTriggerPlugins: () => ({ data: [] }), - useInvalidateAllTriggerPlugins: () => vi.fn(), -})) - -vi.mock('@/service/use-tools', () => ({ - useAllBuiltInTools: () => ({ data: [] }), - useAllCustomTools: () => ({ data: [] }), - useAllWorkflowTools: () => ({ data: [] }), - useAllMCPTools: () => ({ data: [] }), - useInvalidateAllBuiltInTools: () => vi.fn(), -})) - -const createBlock = (type: BlockEnum, title: string): NodeDefault => ({ - metaData: { - classification: BlockClassification.Default, - sort: 0, - type, - title, - author: 'Dify', - description: `${title} description`, - }, - defaultValue: {}, - checkValid: () => ({ isValid: true }), -}) - -type RenderNodeSelectorOptions = Parameters[1] - -const renderNodeSelector = (ui: ReactElement, options?: RenderNodeSelectorOptions) => { - return renderWorkflowComponent(ui, { - ...options, - hooksStoreProps: { - ...options?.hooksStoreProps, - configsMap: { - flowId: 'app-1', - flowType: FlowType.appFlow, - fileSettings: {} as never, - ...options?.hooksStoreProps?.configsMap, - }, - }, - }) -} - -describe('NodeSelector', () => { - it('opens with the real blocks tab, filters by search, selects a block, and clears search after close', async () => { - const user = userEvent.setup() - const onSelect = vi.fn() - - renderNodeSelector( - ( - - )} - />, - ) - - const trigger = screen.getByRole('button', { name: 'selector-closed' }) - expect(trigger.closest('[aria-haspopup="dialog"]')).toBe(trigger) - - await user.click(trigger) - - const searchInput = screen.getByPlaceholderText('workflow.tabs.searchBlock') - expect(screen.getByText('LLM')).toBeInTheDocument() - expect(screen.getByText('End')).toBeInTheDocument() - - await user.type(searchInput, 'LLM') - expect(screen.getByText('LLM')).toBeInTheDocument() - expect(screen.queryByText('End')).not.toBeInTheDocument() - - await user.click(screen.getByText('LLM')) - - expect(onSelect).toHaveBeenCalledWith(BlockEnum.LLM, undefined) - await waitFor(() => { - expect(screen.queryByPlaceholderText('workflow.tabs.searchBlock')).not.toBeInTheDocument() - }) - - await user.click(screen.getByRole('button', { name: 'selector-closed' })) - - const reopenedInput = screen.getByPlaceholderText( - 'workflow.tabs.searchBlock', - ) as HTMLInputElement - expect(reopenedInput.value).toBe('') - expect(screen.getByText('End')).toBeInTheDocument() - }) - - it('resets to the default tab after closing', async () => { - const user = userEvent.setup() - - renderNodeSelector( - ( - - )} - />, - ) - - await user.click(screen.getByRole('button', { name: 'selector-closed' })) - await user.click(screen.getByText('workflow.tabs.start')) - - expect(screen.getByPlaceholderText('workflow.tabs.searchTrigger')).toBeInTheDocument() - - await user.click(screen.getByRole('button', { name: 'selector-open' })) - await waitFor(() => { - expect(screen.queryByPlaceholderText('workflow.tabs.searchTrigger')).not.toBeInTheDocument() - }) - - await user.click(screen.getByRole('button', { name: 'selector-closed' })) - - expect(screen.getByPlaceholderText('workflow.tabs.searchBlock')).toBeInTheDocument() - }) - - it('preserves the current popup session until a controlled close actually unmounts it', async () => { - const user = userEvent.setup() - const onOpenChange = vi.fn() - - renderNodeSelector( - } - />, - ) - - 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() - - renderNodeSelector( - ( - - )} - />, - ) - - await user.click(screen.getByRole('button', { name: 'selector-closed' })) - - expect(onOpenChange).not.toHaveBeenCalled() - expect(screen.queryByPlaceholderText('workflow.tabs.searchBlock')).not.toBeInTheDocument() - }) - - it('preserves the custom trigger click handler', async () => { - const user = userEvent.setup() - const onTriggerClick = vi.fn() - - renderNodeSelector( - ( - - )} - />, - ) - - await user.click(screen.getByRole('button', { name: 'open-selector' })) - - expect(onTriggerClick).toHaveBeenCalledTimes(1) - expect(screen.getByPlaceholderText('workflow.tabs.searchBlock')).toBeInTheDocument() - }) - - it('opens when a custom component trigger does not forward props', async () => { - const user = userEvent.setup() - - function TriggerShell() { - return open-from-shell - } - - renderNodeSelector( - } - />, - ) - - await user.click(screen.getByRole('button', { name: 'open-from-shell' })) - - expect(screen.getByPlaceholderText('workflow.tabs.searchBlock')).toBeInTheDocument() - }) - - it('can render a prop-forwarding button component as the popover root', async () => { - const user = userEvent.setup() - - function ForwardingButtonTrigger(props: ButtonHTMLAttributes) { - return ( - - ) - } - - renderNodeSelector( - } - />, - ) - - const trigger = screen.getByTestId('selector-root-trigger') - await user.click(trigger) - - expect(trigger.closest('[aria-haspopup="dialog"]')).toBe(trigger) - expect(screen.getByPlaceholderText('workflow.tabs.searchBlock')).toBeInTheDocument() - }) - - it('can render the shared Button trigger as the popover root', async () => { - const user = userEvent.setup() - - renderNodeSelector( - } - />, - ) - - const trigger = screen.getByRole('button', { name: 'open-shared-button-trigger' }) - await user.click(trigger) - - expect(trigger.closest('[aria-haspopup="dialog"]')).toBe(trigger) - expect(screen.getByPlaceholderText('workflow.tabs.searchBlock')).toBeInTheDocument() - }) - - it('isolates popup keyboard events when opened from another keyboard-managed overlay', async () => { - const user = userEvent.setup() - const handleParentKeyDown = vi.fn() - - renderNodeSelector( - , - ) - - const searchInput = screen.getByPlaceholderText('workflow.tabs.searchBlock') as HTMLInputElement - document.body.addEventListener('keydown', handleParentKeyDown) - - try { - await user.type(searchInput, 'LLM') - } finally { - document.body.removeEventListener('keydown', handleParentKeyDown) - } - - expect(searchInput.value).toBe('LLM') - expect(handleParentKeyDown).not.toHaveBeenCalled() - expect(screen.getByText('LLM')).toBeInTheDocument() - expect(screen.queryByText('End')).not.toBeInTheDocument() - }) - - it('disables the start tab with a setup tooltip when an unconfigured start node is on the canvas', async () => { - const user = userEvent.setup() - - renderNodeSelector( - , - { - initialStoreState: { - nodes: [ - { - id: 'start-placeholder', - data: { - type: BlockEnum.StartPlaceholder, - }, - }, - ] as never, - }, - }, - ) - - await user.hover(screen.getByText('workflow.tabs.start')) - - expect( - await screen.findByText('workflow.tabs.unconfiguredStartDisabledTip'), - ).toBeInTheDocument() - expect(screen.queryByRole('link')).not.toBeInTheDocument() - expect(screen.getByPlaceholderText('workflow.tabs.searchBlock')).toBeInTheDocument() - }) - - it('keeps the start tab enabled when a configured user input start node is on the canvas', () => { - renderNodeSelector( - , - { - initialStoreState: { - nodes: [ - { - id: 'start', - data: { - type: BlockEnum.Start, - }, - }, - ] as never, - }, - }, - ) - - expect(screen.getByText('workflow.tabs.start')).toHaveAttribute('aria-disabled', 'false') - expect( - screen.getByText('workflow.nodes.startPlaceholder.userInputConflictTip'), - ).toBeInTheDocument() - }) -}) 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 5d02ae0d488..7ec86860008 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 @@ -140,11 +140,6 @@ describe('StartBlocks', () => { name: /workflow\.blocks\.start.*workflow\.nodes\.startPlaceholder\.userInputConflictTip/, }) expect(userInputButton).toHaveAttribute('aria-disabled', 'true') - expect( - userInputButton - .querySelector('.i-custom-vender-workflow-user-input') - ?.closest('.opacity-30'), - ).toBeInTheDocument() await user.tab() expect(userInputButton).toHaveFocus() 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 ad47522dde9..2ac5ef970d3 100644 --- a/web/app/components/workflow/block-selector/__tests__/tabs.spec.tsx +++ b/web/app/components/workflow/block-selector/__tests__/tabs.spec.tsx @@ -2,14 +2,13 @@ import { screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' import { renderWithConsoleQuery } from '@/test/console/query-data' -import { SelectorContent } from '../tabs' +import { BlockSelectorPanels } from '../tabs' import { TabType } from '../types' const render = (ui: React.ReactElement) => renderWithConsoleQuery(ui, { systemFeatures: { enable_marketplace: true } }) -const { mockSetState, mockInvalidateBuiltInTools, mockToolsState } = vi.hoisted(() => ({ - mockSetState: vi.fn(), +const { mockInvalidateBuiltInTools, mockToolsState } = vi.hoisted(() => ({ mockInvalidateBuiltInTools: vi.fn(), mockToolsState: { buildInTools: [{ icon: '/tool.svg', name: 'tool' }] as @@ -42,7 +41,7 @@ vi.mock('@/utils/var', () => ({ vi.mock('../../store', () => ({ useWorkflowStore: () => ({ - setState: mockSetState, + setState: vi.fn(), }), })) @@ -58,7 +57,7 @@ vi.mock('../data-sources', () => ({ default: () =>
sources-content
, })) -vi.mock('../all-tools', () => ({ +vi.mock('../tool-browser', () => ({ default: (props: { buildInTools: Array<{ icon: string | Record; name: string }> showFeatured: boolean @@ -101,7 +100,7 @@ describe('Tabs', () => { it('should render start content and disabled tab tooltip text', async () => { const user = userEvent.setup() - render() + render() expect(screen.getByText('start-content'))!.toBeInTheDocument() await user.hover(screen.getByRole('tab', { name: 'Blocks' })) @@ -110,13 +109,13 @@ describe('Tabs', () => { it('should expose tab semantics and use manual keyboard activation', async () => { const user = userEvent.setup() - render() + render() const startTab = screen.getByRole('tab', { name: 'Start' }) const blocksTab = screen.getByRole('tab', { name: 'Blocks' }) const toolsTab = screen.getByRole('tab', { name: 'Tools' }) - expect(screen.getByRole('tablist')).toBeInTheDocument() + expect(screen.getByRole('tablist', { name: 'workflow.common.addBlock' })).toBeInTheDocument() expect(startTab).toHaveAttribute('aria-selected', 'true') expect(blocksTab).toHaveAttribute('aria-disabled', 'true') expect(screen.getByRole('tabpanel', { name: 'Start' })).toBeInTheDocument() @@ -131,19 +130,9 @@ describe('Tabs', () => { expect(startTab).toHaveAttribute('aria-selected', 'true') }) - it('should sync normalized tools into workflow store state', () => { - render() - - expect(screen.getByText('tools-content'))!.toBeInTheDocument() - expect(screen.getByText('/console/tool.svg'))!.toBeInTheDocument() - expect(screen.getByText('featured-on'))!.toBeInTheDocument() - expect(screen.getByText('featured-idle'))!.toBeInTheDocument() - expect(mockSetState).toHaveBeenCalled() - }) - it('should ignore clicks on disabled and already active tabs', async () => { const user = userEvent.setup() - render() + render() const startTab = screen.getByRole('tab', { name: 'Start' }) await user.click(startTab) @@ -168,7 +157,7 @@ describe('Tabs', () => { > Remove tools - + ) } @@ -189,7 +178,7 @@ describe('Tabs', () => { it('should render sources content when the sources tab is active and data sources are provided', () => { render( - { expect(screen.getByText('sources-content'))!.toBeInTheDocument() }) - it('should keep the previous workflow store state when tool references do not change', () => { - mockToolsState.buildInTools = [{ icon: '/console/already-prefixed.svg', name: 'tool' }] + it('keeps search state independent for each tab during the popup session', async () => { + const user = userEvent.setup() + render( + , + ) - render() + const blocksSearch = screen.getByRole('searchbox', { name: 'workflow.tabs.searchBlock' }) + await user.type(blocksSearch, 'llm') + await user.click(screen.getByRole('tab', { name: 'Tools' })) - const previousState = { - buildInTools: mockToolsState.buildInTools, - customTools: mockToolsState.customTools, - workflowTools: mockToolsState.workflowTools, - mcpTools: mockToolsState.mcpTools, - } - const updateState = mockSetState.mock.calls[0]![0] as ( - state: typeof previousState, - ) => typeof previousState + const toolsSearch = screen.getByRole('searchbox', { name: 'plugin.searchTools' }) + expect(toolsSearch).toHaveValue('') + await user.type(toolsSearch, 'slack') + await user.click(screen.getByRole('tab', { name: 'Blocks' })) - expect(updateState(previousState)).toBe(previousState) - }) - - it('should normalize every tool collection and merge updates into workflow store state', () => { - mockToolsState.buildInTools = [{ icon: { light: '/tool.svg' }, name: 'tool' }] - mockToolsState.customTools = [{ icon: '/custom.svg', name: 'custom' }] - mockToolsState.workflowTools = [{ icon: '/workflow.svg', name: 'workflow' }] - mockToolsState.mcpTools = [{ icon: '/mcp.svg', name: 'mcp' }] - - render() - - expect(screen.getByText('object-icon'))!.toBeInTheDocument() - - const updateState = mockSetState.mock.calls[0]![0] as (state: { - buildInTools?: Array<{ icon: string | Record; name: string }> - customTools?: Array<{ icon: string | Record; name: string }> - workflowTools?: Array<{ icon: string | Record; name: string }> - mcpTools?: Array<{ icon: string | Record; name: string }> - }) => { - buildInTools?: Array<{ icon: string | Record; name: string }> - customTools?: Array<{ icon: string | Record; name: string }> - workflowTools?: Array<{ icon: string | Record; name: string }> - mcpTools?: Array<{ icon: string | Record; name: string }> - } - - expect( - updateState({ - buildInTools: [], - customTools: [], - workflowTools: [], - mcpTools: [], - }), - ).toEqual({ - buildInTools: [{ icon: { light: '/tool.svg' }, name: 'tool' }], - customTools: [{ icon: '/console/custom.svg', name: 'custom' }], - workflowTools: [{ icon: '/console/workflow.svg', name: 'workflow' }], - mcpTools: [{ icon: '/console/mcp.svg', name: 'mcp' }], - }) - }) - - it('should skip normalization when a tool list is undefined', () => { - mockToolsState.buildInTools = undefined - - render() - - expect(screen.getByText('tools-content'))!.toBeInTheDocument() + expect(screen.getByRole('searchbox', { name: 'workflow.tabs.searchBlock' })).toHaveValue('llm') + await user.click(screen.getByRole('tab', { name: 'Tools' })) + expect(screen.getByRole('searchbox', { name: 'plugin.searchTools' })).toHaveValue('slack') }) it('should force start content to render and invalidate built-in tools after featured installs', async () => { const user = userEvent.setup() - render() + render() await user.click(screen.getByRole('button', { name: 'Install featured tool' })) @@ -275,7 +228,7 @@ describe('Tabs', () => { }) it('should compose start content directly without tab semantics in standalone mode', () => { - render() + render() expect(screen.getByText('start-content'))!.toBeInTheDocument() expect(screen.queryByRole('tablist')).not.toBeInTheDocument() diff --git a/web/app/components/workflow/block-selector/__tests__/all-tools.spec.tsx b/web/app/components/workflow/block-selector/__tests__/tool-browser.spec.tsx similarity index 63% rename from web/app/components/workflow/block-selector/__tests__/all-tools.spec.tsx rename to web/app/components/workflow/block-selector/__tests__/tool-browser.spec.tsx index 3cc4defb2f1..91f104966ad 100644 --- a/web/app/components/workflow/block-selector/__tests__/all-tools.spec.tsx +++ b/web/app/components/workflow/block-selector/__tests__/tool-browser.spec.tsx @@ -1,13 +1,14 @@ import type { ReactElement } from 'react' -import { screen, waitFor } from '@testing-library/react' +import { screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { useMarketplacePlugins } from '@/app/components/plugins/marketplace/hooks' +import { useMarketplacePlugins } from '@/app/components/plugins/marketplace/query' +import { PluginCategoryEnum } from '@/app/components/plugins/types' import { CollectionType } from '@/app/components/tools/types' import { useGetLanguage } from '@/context/i18n' import useTheme from '@/hooks/use-theme' import { renderWithConsoleQuery } from '@/test/console/query-data' import { Theme } from '@/types/app' -import AllTools from '../all-tools' +import ToolBrowser from '../tool-browser' import { createToolProvider } from './factories' vi.mock('@/context/i18n', () => ({ @@ -18,7 +19,7 @@ vi.mock('@/hooks/use-theme', () => ({ default: vi.fn(), })) -vi.mock('@/app/components/plugins/marketplace/hooks', () => ({ +vi.mock('@/app/components/plugins/marketplace/query', () => ({ useMarketplacePlugins: vi.fn(), })) @@ -48,21 +49,10 @@ const mockUseTheme = vi.mocked(useTheme) const render = (ui: ReactElement, enableMarketplace = false) => renderWithConsoleQuery(ui, { systemFeatures: { enable_marketplace: enableMarketplace } }) -const createMarketplacePluginsMock = () => ({ - plugins: [], - total: 0, - resetPlugins: vi.fn(), - queryPlugins: vi.fn(), - queryPluginsWithDebounced: vi.fn(), - cancelQueryPluginsWithDebounced: vi.fn(), - isLoading: false, - isFetchingNextPage: false, - hasNextPage: false, - fetchNextPage: vi.fn(), - page: 0, -}) +const createMarketplacePluginsMock = () => + ({ data: undefined }) as ReturnType -describe('AllTools', () => { +describe('ToolBrowser', () => { beforeEach(() => { vi.clearAllMocks() mockUseGetLanguage.mockReturnValue('en_US') @@ -74,7 +64,7 @@ describe('AllTools', () => { const user = userEvent.setup() render( - { expect(screen.queryByText('Built In Provider')).not.toBeInTheDocument() }) + it('shows the selected category empty state and keeps the marketplace footer available', async () => { + const user = userEvent.setup() + + render( + , + true, + ) + + const customToolsFilter = screen.getByRole('button', { + name: 'workflow.tabs.customTool', + }) + customToolsFilter.focus() + await user.keyboard('{Enter}') + + expect(customToolsFilter).toHaveAttribute('aria-pressed', 'true') + expect(screen.queryByText('Built In Provider')).not.toBeInTheDocument() + expect(screen.getByText(/addToolModal\.custom\.title/)).toBeInTheDocument() + expect(screen.getByRole('link', { name: /addToolModal\.custom\.tip/ })).toHaveAttribute( + 'href', + '/integrations/tools/api', + ) + + await user.click(screen.getByRole('button', { name: 'workflow.tabs.workflowTool' })) + + expect(screen.getByText(/addToolModal\.workflow\.title/)).toBeInTheDocument() + expect(screen.getByRole('link', { name: /addToolModal\.workflow\.tip/ })).toHaveAttribute( + 'href', + '/integrations/tools/workflow', + ) + expect(screen.queryByText(/workflowToolEmpty\.step/)).not.toBeInTheDocument() + + const footer = screen.getByRole('contentinfo') + expect(within(footer).getAllByRole('link')).toHaveLength(1) + expect(within(footer).getByRole('link', { name: /findMoreInMarketplace/ })).toBeInTheDocument() + }) + it('updates the tools list title by the active tab', async () => { const user = userEvent.setup() render( - { it('filters the rendered tools by the search text', () => { render( - { it('shows the empty state when no tool matches the current filter', async () => { render( - { }) }) + it('debounces marketplace requests across search and tag changes', async () => { + const baseProps = { + onSelect: vi.fn(), + buildInTools: [], + customTools: [], + workflowTools: [], + mcpTools: [], + } + const { rerender } = render(, true) + + rerender() + rerender() + rerender() + + expect( + mockUseMarketplacePlugins.mock.calls + .map(([params]) => params) + .filter((params) => params?.query || params?.tags?.length), + ).toEqual([]) + expect(screen.queryByText('workflow.tabs.noPluginsFound')).not.toBeInTheDocument() + + await waitFor(() => { + expect(mockUseMarketplacePlugins).toHaveBeenLastCalledWith({ + query: 'webhook', + tags: ['automation'], + category: PluginCategoryEnum.tool, + }) + }) + + expect( + mockUseMarketplacePlugins.mock.calls + .map(([params]) => params) + .filter((params) => params?.query || params?.tags?.length), + ).toEqual([ + { + query: 'webhook', + tags: ['automation'], + category: PluginCategoryEnum.tool, + }, + ]) + }) + it('returns the next tag value when loading more RAG tools', async () => { const user = userEvent.setup() const onTagsChange = vi.fn() render( - { + it('normalizes letters and keeps non-alphabetic tools at the end', () => { + const result = createToolListData( + [ + createToolProvider({ id: 'custom', label: { en_US: '1Custom', zh_Hans: '1自定义' } }), + createToolProvider({ id: 'chinese', label: { en_US: 'Chinese', zh_Hans: '中文' } }), + createToolProvider({ id: 'alpha', label: { en_US: 'Alpha', zh_Hans: 'Alpha' } }), + ], + (tool) => tool.label.zh_Hans[0] || '', + ) + + expect(result.letters).toEqual(['A', 'Z', '#']) + expect(result.flatTools.map(({ id, letter }) => ({ id, letter }))).toEqual([ + { id: 'alpha', letter: 'A' }, + { id: 'chinese', letter: 'Z' }, + { id: 'custom', letter: '#' }, + ]) + }) + + it('sorts the flat view by letter without changing the tree view provider order', () => { + const result = createToolListData( + [ + createToolProvider({ + id: 'zeta', + author: 'Zeta', + label: { en_US: 'Zeta', zh_Hans: 'Zeta' }, + }), + createToolProvider({ + id: 'alpha', + author: 'Alpha', + label: { en_US: 'Alpha', zh_Hans: 'Alpha' }, + }), + ], + (tool) => tool.label.en_US[0] ?? '', + ) + + expect(result.flatTools.map(({ id }) => id)).toEqual(['alpha', 'zeta']) + expect( + result.treeGroups.map((group) => (group.kind === 'author' ? group.author : group.category)), + ).toEqual(['Zeta', 'Alpha']) + }) + + it('keeps author and category group identities separate without sentinel keys', () => { + const result = createToolListData( + [ + createToolProvider({ id: 'author', author: 'custom' }), + createToolProvider({ id: 'category', type: CollectionType.custom }), + ], + () => 'A', + ) + + expect(result.treeGroups).toEqual([ + { + kind: 'author', + author: 'custom', + tools: [expect.objectContaining({ id: 'author' })], + }, + { + kind: 'category', + category: 'custom', + tools: [expect.objectContaining({ id: 'category' })], + }, + ]) + }) + + it('maps workflow and data source providers to distinct categories', () => { + const result = createToolListData( + [ + createToolProvider({ id: 'workflow', type: CollectionType.workflow }), + createToolProvider({ id: 'data-source', type: CollectionType.datasource }), + ], + () => 'A', + ) + + expect(result.treeGroups).toEqual([ + { + kind: 'category', + category: 'workflow', + tools: [expect.objectContaining({ id: 'workflow' })], + }, + { + kind: 'category', + category: 'data-source', + tools: [expect.objectContaining({ id: 'data-source' })], + }, + ]) + }) + + it('merges providers across letters and keeps MCP and unknown provider identities explicit', () => { + const lettersByToolId: Record = { + 'author-a': 'A', + 'author-b': 'B', + mcp: 'C', + unknown: 'D', + } + const result = createToolListData( + [ + createToolProvider({ id: 'author-a', author: 'Dify' }), + createToolProvider({ id: 'author-b', author: 'Dify' }), + createToolProvider({ id: 'mcp', type: CollectionType.mcp }), + createToolProvider({ id: 'unknown', author: 'Future', type: 'future-provider' }), + ], + (tool) => lettersByToolId[tool.id] ?? '#', + ) + + expect(result.treeGroups).toEqual([ + { + kind: 'author', + author: 'Dify', + tools: [ + expect.objectContaining({ id: 'author-a' }), + expect.objectContaining({ id: 'author-b' }), + ], + }, + { + kind: 'category', + category: 'mcp', + tools: [expect.objectContaining({ id: 'mcp' })], + }, + { + kind: 'author', + author: 'Future', + tools: [expect.objectContaining({ id: 'unknown' })], + }, + ]) + }) +}) diff --git a/web/app/components/workflow/block-selector/__tests__/tool-picker.spec.tsx b/web/app/components/workflow/block-selector/__tests__/tool-picker.spec.tsx index 9b8ddf6c3a3..c37614a3700 100644 --- a/web/app/components/workflow/block-selector/__tests__/tool-picker.spec.tsx +++ b/web/app/components/workflow/block-selector/__tests__/tool-picker.spec.tsx @@ -4,8 +4,11 @@ import type { Plugin } from '@/app/components/plugins/types' import type { Tool } from '@/app/components/tools/types' import { fireEvent, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { useState } from 'react' +import { OperationButton } from '@/app/components/app/configuration/base/operation-button' import { useTags } from '@/app/components/plugins/hooks' -import { useMarketplacePlugins } from '@/app/components/plugins/marketplace/hooks' +import { useMarketplacePlugins } from '@/app/components/plugins/marketplace/query' +import { ToolTrigger } from '@/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-trigger' import { PluginCategoryEnum } from '@/app/components/plugins/types' import { CollectionType } from '@/app/components/tools/types' import { useGetLanguage } from '@/context/i18n' @@ -82,7 +85,7 @@ vi.mock('@/app/components/plugins/hooks', async (importOriginal) => { } }) -vi.mock('@/app/components/plugins/marketplace/hooks', () => ({ +vi.mock('@/app/components/plugins/marketplace/query', () => ({ useMarketplacePlugins: vi.fn(), })) @@ -358,17 +361,7 @@ describe('ToolPicker', () => { getTagLabel: (name: string) => name, }) mockUseMarketplacePlugins.mockReturnValue({ - plugins: [], - total: 0, - resetPlugins: vi.fn(), - queryPlugins: vi.fn(), - queryPluginsWithDebounced: vi.fn(), - cancelQueryPluginsWithDebounced: vi.fn(), - isLoading: false, - isFetchingNextPage: false, - hasNextPage: false, - fetchNextPage: vi.fn(), - page: 0, + data: undefined, } as ReturnType) mockUseAllBuiltInTools.mockReturnValue({ data: builtInTools } as ReturnType< typeof useAllBuiltInTools @@ -414,7 +407,7 @@ describe('ToolPicker', () => { renderToolPicker({ onShowChange }) - await user.click(screen.getByText('open-picker').closest('[role="button"]')!) + await user.click(screen.getByRole('button', { name: 'open-picker' })) expect(onShowChange.mock.calls[0]?.[0]).toBe(true) renderToolPicker({ @@ -422,42 +415,89 @@ describe('ToolPicker', () => { onShowChange: disabledOnShowChange, }) - await user.click(screen.getAllByText('open-picker')[1]!.closest('[role="button"]')!) + await user.click(screen.getAllByRole('button', { name: 'open-picker' })[1]!) expect(disabledOnShowChange).not.toHaveBeenCalled() }) + it('opens from the production plugin-detail trigger and restores focus on close', async () => { + const user = userEvent.setup() + + function Harness() { + const [open, setOpen] = useState(false) + return ( + } + isShow={open} + onShowChange={setOpen} + onSelect={vi.fn()} + onSelectMultiple={vi.fn()} + /> + ) + } + + renderWithConsoleQuery(, { systemFeatures: { enable_marketplace: true } }) + + const trigger = screen.getByRole('button', { + name: 'plugin.detailPanel.toolSelector.placeholder', + }) + await user.click(trigger) + + expect(screen.getByRole('dialog')).toBeInTheDocument() + expect(trigger).toHaveAttribute('aria-expanded', 'true') + + await user.keyboard('{Escape}') + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(trigger).toHaveFocus() + }) + + it('opens from the production agent-tools trigger and restores focus on close', async () => { + const user = userEvent.setup() + + function Harness() { + const [open, setOpen] = useState(false) + return ( + } + isShow={open} + onShowChange={setOpen} + onSelect={vi.fn()} + onSelectMultiple={vi.fn()} + /> + ) + } + + renderWithConsoleQuery(, { systemFeatures: { enable_marketplace: true } }) + + const trigger = screen.getByRole('button', { name: 'common.operation.add' }) + await user.click(trigger) + + expect(screen.getByRole('dialog')).toBeInTheDocument() + expect(trigger).toHaveAttribute('aria-expanded', 'true') + + await user.keyboard('{Escape}') + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(trigger).toHaveFocus() + }) + it('should link the find-more footer to the marketplace tool category', () => { renderToolPicker({ isShow: true, selectedTools: [], }) - expect(screen.getByRole('link', { name: /plugin\.findMoreInMarketplace/i })).toHaveAttribute( - 'href', - 'https://marketplace.test/plugins/tool', - ) + const footerLink = screen.getByRole('link', { name: /plugin\.findMoreInMarketplace/i }) + expect(footerLink.closest('footer')).toBeInTheDocument() + expect(footerLink).toHaveAttribute('href', 'https://marketplace.test/plugins/tool') }) it('should render real search and tool lists, then forward tool selections', async () => { const user = userEvent.setup() const onSelect = vi.fn() const onSelectMultiple = vi.fn() - const queryPluginsWithDebounced = vi.fn() - - mockUseMarketplacePlugins.mockReturnValue({ - plugins: [], - total: 0, - resetPlugins: vi.fn(), - queryPlugins: vi.fn(), - queryPluginsWithDebounced, - cancelQueryPluginsWithDebounced: vi.fn(), - isLoading: false, - isFetchingNextPage: false, - hasNextPage: false, - fetchNextPage: vi.fn(), - page: 0, - } as ReturnType) - renderToolPicker({ isShow: true, scope: 'custom', @@ -470,15 +510,20 @@ describe('ToolPicker', () => { expect(screen.getByText('Custom Provider')).toBeInTheDocument() expect(screen.getByText('MCP Provider')).toBeInTheDocument() - await user.type(screen.getByRole('textbox'), 'weather') + await user.type(screen.getByRole('searchbox', { name: 'plugin.searchTools' }), 'weather') await waitFor(() => { - expect(queryPluginsWithDebounced).toHaveBeenLastCalledWith({ + expect(mockUseMarketplacePlugins).toHaveBeenLastCalledWith({ query: 'weather', tags: [], category: PluginCategoryEnum.tool, }) }) + expect( + mockUseMarketplacePlugins.mock.calls + .map(([params]) => params) + .filter((params) => params?.query && params.query !== 'weather'), + ).toEqual([]) await waitFor(() => { expect(screen.getByText('Weather Tool')).toBeInTheDocument() @@ -512,13 +557,7 @@ describe('ToolPicker', () => { supportAddCustomTool: true, }) - const addCustomToolButton = Array.from(document.querySelectorAll('button')).find((button) => { - return button.className.includes('bg-components-button-primary-bg') - }) - - expect(addCustomToolButton).toBeTruthy() - - await user.click(addCustomToolButton!) + await user.click(screen.getByRole('button', { name: 'tools.addToolModal.custom.tip' })) expect(screen.getByTestId('edit-custom-tool-modal')).toBeInTheDocument() await user.click(screen.getByRole('button', { name: 'submit-custom-tool' })) @@ -548,10 +587,8 @@ describe('ToolPicker', () => { expect(screen.getByText('Built-in Provider')).toBeInTheDocument() expect(screen.getByText('MCP Provider')).toBeInTheDocument() expect( - Array.from(document.querySelectorAll('button')).some((button) => { - return button.className.includes('bg-components-button-primary-bg') - }), - ).toBe(false) + screen.queryByRole('button', { name: 'tools.addToolModal.custom.tip' }), + ).not.toBeInTheDocument() }) it('should invalidate all tool collections after featured install succeeds', async () => { diff --git a/web/app/components/workflow/block-selector/__tests__/tools.spec.tsx b/web/app/components/workflow/block-selector/__tests__/tools.spec.tsx index 25387c45651..97a0f1c256a 100644 --- a/web/app/components/workflow/block-selector/__tests__/tools.spec.tsx +++ b/web/app/components/workflow/block-selector/__tests__/tools.spec.tsx @@ -4,7 +4,7 @@ import { useGetLanguage } from '@/context/i18n' import useTheme from '@/hooks/use-theme' import { Theme } from '@/types/app' import Tools from '../tools' -import { ViewType } from '../view-type-select' +import { ViewType } from '../types' import { createToolProvider } from './factories' vi.mock('@/context/i18n', () => ({ @@ -66,7 +66,7 @@ describe('Tools', () => { }) it('shows the alphabetical index in flat view when enough tools are present', () => { - const { container } = render( + render( createToolProvider({ @@ -83,7 +83,8 @@ describe('Tools', () => { />, ) - expect(container.querySelector('.index-bar')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'A' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'K' })).toBeInTheDocument() expect(screen.getByText('A Provider')).toBeInTheDocument() expect(screen.getByText('K Provider')).toBeInTheDocument() }) diff --git a/web/app/components/workflow/block-selector/__tests__/view-type-select.spec.tsx b/web/app/components/workflow/block-selector/__tests__/view-type-select.spec.tsx index 7936e904895..18f8657fe2a 100644 --- a/web/app/components/workflow/block-selector/__tests__/view-type-select.spec.tsx +++ b/web/app/components/workflow/block-selector/__tests__/view-type-select.spec.tsx @@ -1,41 +1,46 @@ -import { fireEvent, render } from '@testing-library/react' -import ViewTypeSelect, { ViewType } from '../view-type-select' - -const getViewOptions = (container: HTMLElement) => { - const options = container.firstElementChild?.children - if (!options || options.length !== 2) throw new Error('Expected two view options') - return [options[0] as HTMLDivElement, options[1] as HTMLDivElement] -} +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useState } from 'react' +import { ViewType } from '../types' +import ViewTypeSelect from '../view-type-select' describe('ViewTypeSelect', () => { - it('should highlight the active view type', () => { - const onChange = vi.fn() - const { container } = render() + it('exposes the current view through named toggle buttons', () => { + render() - const [flatOption, treeOption] = getViewOptions(container) - - expect(flatOption)!.toHaveClass('bg-components-segmented-control-item-active-bg') - expect(treeOption)!.toHaveClass('cursor-pointer') + expect(screen.getByRole('group', { name: 'common.operation.view' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'workflow.tabs.listView' })).toHaveAttribute( + 'aria-pressed', + 'true', + ) + expect(screen.getByRole('button', { name: 'workflow.tabs.treeView' })).toHaveAttribute( + 'aria-pressed', + 'false', + ) }) - it('should call onChange when switching to a different view type', () => { - const onChange = vi.fn() - const { container } = render() + it('changes the view with roving focus and keyboard activation', async () => { + const user = userEvent.setup() - const [, treeOption] = getViewOptions(container) - fireEvent.click(treeOption!) + function ViewTypeSelectHarness() { + const [viewType, setViewType] = useState(ViewType.flat) + return + } - expect(onChange).toHaveBeenCalledWith(ViewType.tree) - expect(onChange).toHaveBeenCalledTimes(1) - }) + render() - it('should ignore clicks on the current view type', () => { - const onChange = vi.fn() - const { container } = render() + const flatView = screen.getByRole('button', { name: 'workflow.tabs.listView' }) + const treeView = screen.getByRole('button', { name: 'workflow.tabs.treeView' }) - const [, treeOption] = getViewOptions(container) - fireEvent.click(treeOption!) + await user.tab() + expect(flatView).toHaveFocus() - expect(onChange).not.toHaveBeenCalled() + await user.keyboard('{ArrowRight}') + expect(treeView).toHaveFocus() + + await user.keyboard(' ') + + expect(flatView).toHaveAttribute('aria-pressed', 'false') + expect(treeView).toHaveAttribute('aria-pressed', 'true') }) }) diff --git a/web/app/components/workflow/block-selector/agent-selector.tsx b/web/app/components/workflow/block-selector/agent-selector.tsx index 25bc5a64309..aff8e9c260a 100644 --- a/web/app/components/workflow/block-selector/agent-selector.tsx +++ b/web/app/components/workflow/block-selector/agent-selector.tsx @@ -255,7 +255,7 @@ function AgentSelectorActionItem({ option }: { option: AgentSelectorActionOption ) } - className="flex min-h-7 w-full grid-cols-none items-center gap-2 rounded-md px-2 py-1.5 text-left system-sm-regular text-text-secondary hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden data-highlighted:bg-state-base-hover data-highlighted:text-text-secondary" + className="flex min-h-7 w-full grid-cols-none items-center gap-2 rounded-md px-2 py-1.5 text-left system-sm-regular text-text-secondary hover:bg-state-base-hover hover:text-text-secondary focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid focus-visible:outline-hidden data-highlighted:bg-state-base-hover data-highlighted:text-text-secondary" > diff --git a/web/app/components/workflow/block-selector/all-start-blocks.tsx b/web/app/components/workflow/block-selector/all-start-blocks.tsx index 285e46b653f..c83ef8ee61c 100644 --- a/web/app/components/workflow/block-selector/all-start-blocks.tsx +++ b/web/app/components/workflow/block-selector/all-start-blocks.tsx @@ -1,34 +1,33 @@ 'use client' import type { RefObject } from 'react' import type { BlockEnum, OnSelectBlock } from '../types' -import type { ListRef } from './market-place-plugin/list' +import type { ListRef } from './marketplace-plugin/list' import type { TriggerDefaultValue, TriggerWithProvider } from './types' -import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { RiArrowRightUpLine } from '@remixicon/react' import { useSuspenseQuery } from '@tanstack/react-query' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useDebounce } from 'ahooks' +import { useCallback, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import Divider from '@/app/components/base/divider' -import { SearchMenu } from '@/app/components/base/icons/src/vender/line/general' +import { useMarketplacePlugins } from '@/app/components/plugins/marketplace/query' import { getMarketplaceCategoryUrl } from '@/app/components/plugins/marketplace/utils' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import Link from '@/next/link' import { useFeaturedTriggersRecommendations } from '@/service/use-plugins' import { useAllTriggerPlugins, useInvalidateAllTriggerPlugins } from '@/service/use-triggers' -import { useMarketplacePlugins } from '../../plugins/marketplace/hooks' import { PluginCategoryEnum } from '../../plugins/types' import { BlockEnum as BlockEnumValue } from '../types' import { ENTRY_NODE_TYPES } from './constants' import FeaturedTriggers from './featured-triggers' -import PluginList from './market-place-plugin/list' +import PluginList from './marketplace-plugin/list' import StartBlocks from './start-blocks' import TriggerPluginList from './trigger-plugin/list' const popoverMarketplaceFooterClassName = - 'system-sm-medium z-10 flex h-8 flex-none cursor-pointer items-center rounded-b-lg border-[0.5px] border-t border-components-panel-border bg-components-panel-bg-blur px-4 py-1 text-text-accent-light-mode-only shadow-lg' + 'system-sm-medium z-10 flex h-8 flex-none items-center border-t border-divider-subtle bg-components-panel-bg-blur px-4 py-1' const panelMarketplaceFooterClassName = - 'system-xs-regular z-10 flex flex-none cursor-pointer flex-col items-start gap-2 px-4 pt-2 pb-4 text-text-tertiary hover:text-text-secondary' + 'system-xs-regular z-10 flex flex-none flex-col items-start gap-2 rounded-lg px-4 pt-2 pb-4' +const DEFAULT_TAGS: string[] = [] const SectionDivider = () => (
@@ -54,17 +53,17 @@ type AllStartBlocksProps = { variant?: 'popover' | 'panel' } -const AllStartBlocks = ({ +function AllStartBlocks({ className, searchText, onSelect, availableBlocksTypes, - tags = [], + tags = DEFAULT_TAGS, allowUserInputSelection = false, hasUserInputNode = false, hasTriggerNode = false, variant = 'popover', -}: AllStartBlocksProps) => { +}: AllStartBlocksProps) { const { t } = useTranslation() const [hasStartBlocksContent, setHasStartBlocksContent] = useState(false) const [hasPluginContent, setHasPluginContent] = useState(false) @@ -96,10 +95,32 @@ const AllStartBlocks = ({ const trimmedSearchText = searchText.trim() const hasSearchText = trimmedSearchText.length > 0 const hasFilter = hasSearchText || tags.length > 0 + const marketplaceFilters = useMemo( + () => ({ query: trimmedSearchText, tags }), + [tags, trimmedSearchText], + ) + const debouncedMarketplaceFilters = useDebounce(marketplaceFilters, { wait: 500 }) + const isMarketplaceFilterSettled = debouncedMarketplaceFilters === marketplaceFilters + const isMarketplaceSearchEnabled = enableTriggerPlugin && enable_marketplace && hasFilter const { plugins: featuredPlugins = [], isLoading: featuredLoading } = useFeaturedTriggersRecommendations(enableTriggerPlugin && enable_marketplace && !hasFilter) - const { queryPluginsWithDebounced: fetchPlugins, plugins: marketplacePlugins = [] } = - useMarketplacePlugins() + const marketplaceSearchParams = useMemo( + () => + isMarketplaceSearchEnabled && isMarketplaceFilterSettled + ? { + query: debouncedMarketplaceFilters.query, + tags: debouncedMarketplaceFilters.tags, + category: PluginCategoryEnum.trigger, + } + : undefined, + [debouncedMarketplaceFilters, isMarketplaceSearchEnabled, isMarketplaceFilterSettled], + ) + const { data: marketplacePluginsData, isFetching: isMarketplaceFetching } = + useMarketplacePlugins(marketplaceSearchParams) + const marketplacePlugins = useMemo( + () => marketplacePluginsData?.pages.flatMap((page) => page.plugins) ?? [], + [marketplacePluginsData?.pages], + ) const shouldShowFeatured = enableTriggerPlugin && enable_marketplace && !hasFilter const shouldShowMarketplaceFooter = enable_marketplace @@ -113,34 +134,25 @@ const AllStartBlocks = ({ setHasPluginContent(hasContent) }, []) + const hasInstalledPluginContent = enableTriggerPlugin && hasPluginContent const hasMarketplaceContent = enableTriggerPlugin && enable_marketplace && marketplacePlugins.length > 0 const hasAnyContent = - hasStartBlocksContent || hasPluginContent || shouldShowFeatured || hasMarketplaceContent - const shouldShowEmptyState = hasFilter && !hasAnyContent + hasStartBlocksContent || + hasInstalledPluginContent || + shouldShowFeatured || + hasMarketplaceContent + const isMarketplaceSearchPending = + isMarketplaceSearchEnabled && (!isMarketplaceFilterSettled || isMarketplaceFetching) + const shouldShowEmptyState = hasFilter && !isMarketplaceSearchPending && !hasAnyContent const shouldShowInstalledTriggersDivider = - isPanelVariant && hasStartBlocksContent && enableTriggerPlugin && hasPluginContent + isPanelVariant && hasStartBlocksContent && hasInstalledPluginContent const shouldShowMarketplaceSectionDivider = enableTriggerPlugin && enable_marketplace && - (hasStartBlocksContent || hasPluginContent) && + (hasStartBlocksContent || hasInstalledPluginContent) && (shouldShowFeatured || hasMarketplaceContent) - useEffect(() => { - if (!enableTriggerPlugin && hasPluginContent) setHasPluginContent(false) - }, [enableTriggerPlugin, hasPluginContent]) - - useEffect(() => { - if (!enableTriggerPlugin || !enable_marketplace) return - if (hasFilter) { - fetchPlugins({ - query: searchText, - tags, - category: PluginCategoryEnum.trigger, - }) - } - }, [enableTriggerPlugin, enable_marketplace, hasFilter, fetchPlugins, searchText, tags]) - return (
)} -
+
- +
{t(($) => $['nodes.startPlaceholder.noTriggersFound'], { ns: 'workflow' })}
- + {t(($) => $['tabs.requestToCommunity'], { ns: 'workflow' })}
)}
{shouldShowMarketplaceFooter && ( - - {isPanelVariant ? ( - <> - + {isPanelVariant && } + + {isPanelVariant ? ( - - ) : ( - <> - {t(($) => $.findMoreInMarketplace, { ns: 'plugin' })} - - - )} - + ) : ( + <> + {t(($) => $.findMoreInMarketplace, { ns: 'plugin' })} + + + )} + + )}
diff --git a/web/app/components/workflow/block-selector/block-selector-row.tsx b/web/app/components/workflow/block-selector/block-selector-row.tsx index 723e7dbea49..ba6ccd69b44 100644 --- a/web/app/components/workflow/block-selector/block-selector-row.tsx +++ b/web/app/components/workflow/block-selector/block-selector-row.tsx @@ -33,7 +33,7 @@ const buttonClassName = (className?: string, disabled = false, hoverable = true) cn( rowClassName(className, disabled, hoverable), !disabled && 'cursor-pointer', - 'border-0 bg-transparent text-left focus-visible:ring-1 focus-visible:ring-components-input-border-hover focus-visible:outline-hidden', + 'border-0 bg-transparent text-left focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid focus-visible:outline-hidden', ) export function BlockSelectorRow(props: BlockSelectorRowProps) { diff --git a/web/app/components/workflow/block-selector/blocks.tsx b/web/app/components/workflow/block-selector/blocks.tsx index 3a5633e67ce..bb83790cec1 100644 --- a/web/app/components/workflow/block-selector/blocks.tsx +++ b/web/app/components/workflow/block-selector/blocks.tsx @@ -155,7 +155,7 @@ const Blocks = ({ + )} @@ -276,23 +276,30 @@ function FeaturedToolUninstalledItem({ const detailsLink = ( -
- -
-
{label}
-
-
+ + {label} + + {installCountLabel} + ) return ( <> - +
{description ? ( - - {installCountLabel} - -
- {canInstallPlugin && ( - - )} - -
+
+ {canInstallPlugin && ( + + )} +
- +
{isInstallModalOpen && canInstallPlugin && ( INITIAL_VISIBLE_COUNT - const isExpanded = canToggleVisibility && !hasMoreToShow + const isShowingAll = canToggleVisibility && !hasMoreToShow const showEmptyState = !isLoading && totalVisible === 0 return ( @@ -130,7 +130,7 @@ const FeaturedTriggers = ({ open={!isCollapsed} onOpenChange={(open) => setIsCollapsed(!open)} > - + {t(($) => $['tabs.featuredTools'], { ns: 'workflow' })} @@ -189,9 +189,10 @@ const FeaturedTriggers = ({ )} {!isLoading && totalVisible > 0 && canToggleVisibility && ( - + )} @@ -284,28 +285,35 @@ function FeaturedTriggerUninstalledItem({ const detailsLink = ( -
- -
-
{label}
-
-
+ + {label} + + {installCountLabel} + ) return ( <> - +
{description ? ( - - {installCountLabel} - -
- {canInstallPlugin && ( - - )} - -
+
+ {canInstallPlugin && ( + + )} +
- +
{isInstallModalOpen && canInstallPlugin && ( $['tabs.blocks'], { ns: 'workflow' }), show: !noBlocks, }, - { - key: TabType.Sources, - name: t(($) => $['tabs.sources'], { ns: 'workflow' }), - show: !noSources, - }, { key: TabType.Tools, name: t(($) => $['tabs.tools'], { ns: 'workflow' }), show: !noTools, }, + { + key: TabType.Sources, + name: t(($) => $['tabs.sources'], { ns: 'workflow' }), + show: !noSources, + }, { key: TabType.Start, name: t(($) => $['tabs.start'], { ns: 'workflow' }), @@ -94,24 +94,11 @@ export const useTabs = ({ if (!tab || tab.disabled) return undefined return tab.key } - const fallbackTab = tabs.find((tab) => !tab.disabled)?.key ?? TabType.Blocks const preferredDefault = getValidTabKey(defaultActiveTab) if (preferredDefault) return preferredDefault - const preferredOrder: TabType[] = [] - if (!noBlocks) preferredOrder.push(TabType.Blocks) - if (!noTools) preferredOrder.push(TabType.Tools) - if (!noSources) preferredOrder.push(TabType.Sources) - if (!noStart) preferredOrder.push(TabType.Start) - if (!noSnippets) preferredOrder.push(TabType.Snippets) - - for (const tabKey of preferredOrder) { - const validKey = getValidTabKey(tabKey) - if (validKey) return validKey - } - - return fallbackTab - }, [defaultActiveTab, noBlocks, noSources, noTools, noSnippets, noStart, tabs]) + return tabs.find((tab) => !tab.disabled)?.key ?? TabType.Blocks + }, [defaultActiveTab, tabs]) return { tabs, diff --git a/web/app/components/workflow/block-selector/index-bar.tsx b/web/app/components/workflow/block-selector/index-bar.tsx index 9f5cd4f8921..4c28353de7e 100644 --- a/web/app/components/workflow/block-selector/index-bar.tsx +++ b/web/app/components/workflow/block-selector/index-bar.tsx @@ -1,70 +1,5 @@ -import type { FC, RefObject } from 'react' -import type { ToolWithProvider } from '../types' +import type { RefObject } from 'react' import { cn } from '@langgenius/dify-ui/cn' -import { pinyin } from 'pinyin-pro' -import { CollectionType } from '../../tools/types' - -export const CUSTOM_GROUP_NAME = '@@@custom@@@' -export const WORKFLOW_GROUP_NAME = '@@@workflow@@@' -export const DATA_SOURCE_GROUP_NAME = '@@@data_source@@@' -export const AGENT_GROUP_NAME = '@@@agent@@@' -/* -{ - A: { - 'google': [ // plugin organize name - ...tools - ], - 'custom': [ // custom tools - ...tools - ], - 'workflow': [ // workflow as tools - ...tools - ] - } -} -*/ -export const groupItems = ( - items: ToolWithProvider[], - getFirstChar: (item: ToolWithProvider) => string, -) => { - const groups = items.reduce((acc: Record>, item) => { - const firstChar = getFirstChar(item) - if (!firstChar || firstChar.length === 0) return acc - - let letter - - // transform Chinese to pinyin - if (/[\u4E00-\u9FA5]/.test(firstChar)) - letter = pinyin(firstChar, { pattern: 'first', toneType: 'none' })[0]!.toUpperCase() - else letter = firstChar.toUpperCase() - - if (!/[A-Z]/.test(letter)) letter = '#' - - if (!acc[letter]) acc[letter] = {} - - let groupName: string = '' - if (item.type === CollectionType.builtIn) groupName = item.author - else if (item.type === CollectionType.custom) groupName = CUSTOM_GROUP_NAME - else if (item.type === CollectionType.workflow) groupName = WORKFLOW_GROUP_NAME - else if (item.type === CollectionType.datasource) groupName = DATA_SOURCE_GROUP_NAME - else groupName = AGENT_GROUP_NAME - - if (!acc[letter]![groupName]) acc[letter]![groupName] = [] - - acc[letter]![groupName]!.push(item) - - return acc - }, {}) - - const letters = Object.keys(groups).sort() - // move '#' to the end - const hashIndex = letters.indexOf('#') - if (hashIndex !== -1) { - letters.splice(hashIndex, 1) - letters.push('#') - } - return { letters, groups } -} type IndexBarProps = { letters: string[] @@ -72,7 +7,7 @@ type IndexBarProps = { className?: string } -const IndexBar: FC = ({ letters, itemRefs, className }) => { +export function IndexBar({ letters, itemRefs, className }: IndexBarProps) { const handleIndexClick = (letter: string) => { const element = itemRefs.current?.[letter] if (element) element.scrollIntoView({ behavior: 'smooth' }) @@ -80,19 +15,18 @@ const IndexBar: FC = ({ letters, itemRefs, className }) => { return (
+ aria-hidden + className="absolute top-0 left-0 h-full w-px bg-[linear-gradient(270deg,rgba(255,255,255,0)_0%,rgba(16,24,40,0.08)_30%,rgba(16,24,40,0.08)_50%,rgba(16,24,40,0.08)_70.5%,rgba(255,255,255,0)_100%)]" + /> {letters.map((letter) => (
) } - -export default IndexBar diff --git a/web/app/components/workflow/block-selector/index.tsx b/web/app/components/workflow/block-selector/index.tsx index 4d54cc0e164..15a11c87171 100644 --- a/web/app/components/workflow/block-selector/index.tsx +++ b/web/app/components/workflow/block-selector/index.tsx @@ -1,43 +1,317 @@ -import type { NodeSelectorProps } from './main' -import { useMemo } from 'react' -import { useHooksStore } from '@/app/components/workflow/hooks-store/store' -import { BlockEnum } from '@/app/components/workflow/types' +import type { Placement } from '@langgenius/dify-ui/popover' +import type { CSSProperties, KeyboardEvent, MouseEventHandler, ReactElement } from 'react' +import type { + CommonNodeType, + NodeDefault, + OnNodeAdd, + OnSelectBlock, + ToolWithProvider, +} from '../types' +import type { TabType } from './types' +import { Button } from '@langgenius/dify-ui/button' +import { cn } from '@langgenius/dify-ui/cn' +import { + Popover, + PopoverClose, + PopoverContent, + PopoverTitle, + PopoverTrigger, +} from '@langgenius/dify-ui/popover' +import { memo, useCallback, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useHooksStore } from '@/app/components/workflow/hooks-store' +import useNodes from '@/app/components/workflow/store/workflow/use-nodes' +import { FlowType } from '@/types/common' +import TipPopup from '../operator/tip-popup' import { useStore } from '../store' -import NodeSelector from './main' +import { BlockEnum, isTriggerNode } from '../types' +import { useTabs } from './hooks' +import { BlockSelectorPanels } from './tabs' -const NodeSelectorWrapper = (props: NodeSelectorProps) => { - const availableNodesMetaData = useHooksStore((s) => s.availableNodesMetaData) - const dataSourceList = useStore((s) => s.dataSourceList) +export type BlockSelectorProps = { + open?: boolean + onOpenChange?: (open: boolean) => void + onSelect: OnSelectBlock + trigger?: (open: boolean) => ReactElement + triggerTooltip?: string + placement?: Placement + sideOffset?: number + alignOffset?: number + triggerStyle?: CSSProperties + triggerClassName?: (open: boolean) => string + triggerAriaLabel?: string + popupClassName?: string + availableBlocksTypes?: BlockEnum[] + disabled?: boolean + blocks?: NodeDefault[] + dataSources?: ToolWithProvider[] + noBlocks?: boolean + noTools?: boolean + standalonePanel?: TabType + showStartTab?: boolean + defaultActiveTab?: TabType + ignoreNodeIds?: string[] + forceEnableStartTab?: boolean // Force enabling Start tab regardless of existing trigger/user input nodes (e.g., when changing Start node type). + allowUserInputSelection?: boolean // Override user-input availability; default logic blocks it when triggers exist. + snippetInsertPayload?: Parameters[1] + isolateKeyboardEvents?: boolean +} +function BlockSelector({ + open: openFromProps, + onOpenChange, + onSelect, + trigger, + triggerTooltip, + placement = 'right', + sideOffset, + alignOffset, + triggerClassName, + triggerAriaLabel, + triggerStyle, + popupClassName, + availableBlocksTypes, + disabled, + blocks, + dataSources, + noBlocks = false, + noTools = false, + standalonePanel, + showStartTab = false, + defaultActiveTab, + ignoreNodeIds = [], + forceEnableStartTab = false, + allowUserInputSelection, + snippetInsertPayload, + isolateKeyboardEvents = false, +}: BlockSelectorProps) { + const { t } = useTranslation() + const [localOpen, setLocalOpen] = useState(false) + const searchInputRef = useRef(null) + const open = openFromProps === undefined ? localOpen : openFromProps + const handleOpenChange = useCallback( + (newOpen: boolean) => { + if (newOpen && disabled) return - const blocks = useMemo(() => { - const result = availableNodesMetaData?.nodes || [] + setLocalOpen(newOpen) + if (onOpenChange) onOpenChange(newOpen) + }, + [disabled, onOpenChange], + ) - return result.filter((block) => { - if (block.metaData.type === BlockEnum.Start) return false + const handleTrigger = useCallback>((e) => { + e.stopPropagation() + }, []) + const handlePopupClick = useCallback>((event) => { + event.stopPropagation() + }, []) - if (block.metaData.type === BlockEnum.StartPlaceholder) return false + const handleSelect = useCallback( + (type, pluginDefaultValue) => { + if (disabled) return + handleOpenChange(false) + onSelect(type, pluginDefaultValue) + }, + [disabled, handleOpenChange, onSelect], + ) - if (block.metaData.type === BlockEnum.DataSource) return false + const handlePopupKeyDown = useCallback( + (event: KeyboardEvent) => { + if (isolateKeyboardEvents) event.stopPropagation() + }, + [isolateKeyboardEvents], + ) - if (block.metaData.type === BlockEnum.Tool) return false - - if (block.metaData.type === BlockEnum.IterationStart) return false - - if (block.metaData.type === BlockEnum.LoopStart) return false - - if (block.metaData.type === BlockEnum.DataSourceEmpty) return false - - return true - }) - }, [availableNodesMetaData?.nodes]) + const triggerControl = trigger ? ( + + ) : ( + $['common.addBlock'], { ns: 'workflow' })} + disabled={disabled} + render={ + } /> @@ -80,4 +81,5 @@ const OperationDropdown: FC = ({ open, onOpenChange, author, name, versio ) } -export default React.memo(OperationDropdown) + +export default memo(OperationDropdown) diff --git a/web/app/components/workflow/block-selector/market-place-plugin/item.tsx b/web/app/components/workflow/block-selector/marketplace-plugin/item.tsx similarity index 78% rename from web/app/components/workflow/block-selector/market-place-plugin/item.tsx rename to web/app/components/workflow/block-selector/marketplace-plugin/item.tsx index 3946ffcff74..f04d6567854 100644 --- a/web/app/components/workflow/block-selector/market-place-plugin/item.tsx +++ b/web/app/components/workflow/block-selector/marketplace-plugin/item.tsx @@ -1,9 +1,9 @@ 'use client' -import type { FC } from 'react' import type { Plugin } from '@/app/components/plugins/types.ts' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { useBoolean } from 'ahooks' -import * as React from 'react' +import { memo, useState } from 'react' import { useTranslation } from 'react-i18next' import { PluginInstallPermissionProvider } from '@/app/components/plugins/install-plugin/components/plugin-install-permission-provider' import useWorkspacePluginInstallPermission from '@/app/components/plugins/install-plugin/hooks/use-workspace-plugin-install-permission' @@ -12,16 +12,13 @@ import { useLocale } from '@/context/i18n' import { formatNumber } from '@/utils/format' import Action from './action' -type ActionType = 'install' | 'download' - type Props = Readonly<{ payload: Plugin - onAction: (type: ActionType) => void }> -const Item: FC = ({ payload }) => { +function Item({ payload }: Props) { const { t } = useTranslation() - const [open, setOpen] = React.useState(false) + const [open, setOpen] = useState(false) const locale = useLocale() const getLocalizedText = (obj: Record | undefined) => obj?.[locale] || obj?.['en-US'] || obj?.en_US || '' @@ -51,21 +48,23 @@ const Item: FC = ({ payload }) => {
- {/* Action */}
{canInstallPlugin && ( - + )} = ({ payload }) => {
) } -export default React.memo(Item) + +export default memo(Item) diff --git a/web/app/components/workflow/block-selector/market-place-plugin/list.tsx b/web/app/components/workflow/block-selector/marketplace-plugin/list.tsx similarity index 52% rename from web/app/components/workflow/block-selector/market-place-plugin/list.tsx rename to web/app/components/workflow/block-selector/marketplace-plugin/list.tsx index 7e81c5b9d99..50bf74e513d 100644 --- a/web/app/components/workflow/block-selector/market-place-plugin/list.tsx +++ b/web/app/components/workflow/block-selector/marketplace-plugin/list.tsx @@ -2,8 +2,6 @@ import type { RefObject } from 'react' import type { Plugin, PluginCategoryEnum } from '@/app/components/plugins/types' import { cn } from '@langgenius/dify-ui/cn' -import { RiArrowRightUpLine } from '@remixicon/react' -import { noop } from 'es-toolkit/function' import { useEffect, useImperativeHandle, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' import { getMarketplaceCategoryUrl } from '@/app/components/plugins/marketplace/utils' @@ -25,7 +23,7 @@ export type ListProps = { export type ListRef = { handleScroll: () => void } -const List = ({ +function List({ wrapElemRef, searchText, tags, @@ -35,7 +33,7 @@ const List = ({ disableMaxWidth = false, hideFindMoreFooter = false, ref, -}: ListProps) => { +}: ListProps) { const { t } = useTranslation() const noFilter = !searchText && tags.length === 0 const hasRes = list.length > 0 @@ -52,11 +50,11 @@ const List = ({ const stickyClassName = useMemo(() => { switch (scrollPosition) { case ScrollPosition.aboveTheWrap: - return 'top-0 h-9 pt-3 pb-2 shadow-xs bg-components-panel-bg-blur cursor-pointer' + return 'top-0 h-9 pt-3 pb-2 shadow-xs bg-components-panel-bg-blur' case ScrollPosition.showing: return 'bottom-0 pt-3 pb-1' case ScrollPosition.belowTheWrap: - return 'bottom-0 items-center rounded-b-xl border-t border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg rounded-b-lg cursor-pointer' + return 'bottom-0 items-center border-t border-divider-subtle bg-components-panel-bg-blur' } }, [scrollPosition]) @@ -66,29 +64,27 @@ const List = ({ useEffect(() => { handleScroll() - }, [list]) + }, [handleScroll, list]) - const handleHeadClick = () => { - if (scrollPosition === ScrollPosition.belowTheWrap) { - nextToStickyELemRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }) - return - } - window.open(urlWithSearchText, '_blank') + const handleScrollToResults = () => { + nextToStickyELemRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }) } if (noFilter) { if (hideFindMoreFooter) return null return ( - - {t(($) => $.findMoreInMarketplace, { ns: 'plugin' })} - - +
+ + {t(($) => $.findMoreInMarketplace, { ns: 'plugin' })} + + +
) } @@ -99,34 +95,47 @@ const List = ({ {hasRes && (
- {t(($) => $.fromMarketplace, { ns: 'plugin' })} + {scrollPosition === ScrollPosition.belowTheWrap ? ( + + ) : ( + + {t(($) => $.fromMarketplace, { ns: 'plugin' })} + + )} e.stopPropagation()} + className="ml-2 flex shrink-0 items-center rounded-md text-text-accent-light-mode-only focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid focus-visible:outline-hidden" > {t(($) => $.searchInMarketplace, { ns: 'plugin' })} - +
)}
- {list.map((item, index) => ( - + {list.map((item) => ( + ))}
) } -List.displayName = 'List' - export default List diff --git a/web/app/components/workflow/block-selector/rag-tool-recommendations/__tests__/list.spec.tsx b/web/app/components/workflow/block-selector/rag-tool-recommendations/__tests__/list.spec.tsx index 5a900f19962..372d5c895af 100644 --- a/web/app/components/workflow/block-selector/rag-tool-recommendations/__tests__/list.spec.tsx +++ b/web/app/components/workflow/block-selector/rag-tool-recommendations/__tests__/list.spec.tsx @@ -6,7 +6,7 @@ import useTheme from '@/hooks/use-theme' import { Theme } from '@/types/app' import { BlockEnum } from '../../../types' import { createPlugin, createToolProvider } from '../../__tests__/factories' -import { ViewType } from '../../view-type-select' +import { ViewType } from '../../types' import List from '../list' import UninstalledItem from '../uninstalled-item' @@ -100,7 +100,11 @@ describe('RAG tool recommendations list', () => { expect(screen.getByText('RAG Plugin')).toBeInTheDocument() - await user.click(screen.getByText('plugin.installAction')) + const installButton = screen.getByRole('button', { name: 'plugin.installAction' }) + await user.tab() + expect(installButton).toHaveFocus() + + await user.keyboard('{Enter}') expect(screen.getByTestId('install-from-marketplace')).toHaveTextContent('rag-plugin@1.0.0') }) 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 5d6c47c18fa..0b1ee06b1da 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,6 +1,7 @@ 'use client' -import type { ViewType } from '@/app/components/workflow/block-selector/view-type-select' +import type { ViewType } from '@/app/components/workflow/block-selector/types' import type { OnSelectBlock } from '@/app/components/workflow/types' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { useMemo } from 'react' import { Trans, useTranslation } from 'react-i18next' @@ -47,7 +48,7 @@ export function RAGToolRecommendations({
+ )} diff --git a/web/app/components/workflow/block-selector/rag-tool-recommendations/list.tsx b/web/app/components/workflow/block-selector/rag-tool-recommendations/list.tsx index 2b12e6c3036..7f234f7cdd6 100644 --- a/web/app/components/workflow/block-selector/rag-tool-recommendations/list.tsx +++ b/web/app/components/workflow/block-selector/rag-tool-recommendations/list.tsx @@ -7,11 +7,11 @@ import { cn } from '@langgenius/dify-ui/cn' import { createPreviewCardHandle, PreviewCard } from '@langgenius/dify-ui/preview-card' import { useCallback, useMemo, useRef } from 'react' import { useGetLanguage } from '@/context/i18n' -import { groupItems } from '../index-bar' +import { createToolListData } from '../tool-list-data' import { ToolActionPreviewCard } from '../tool/action-item' import ToolListFlatView from '../tool/tool-list-flat-view/list' -import ToolListTreeView from '../tool/tool-list-tree-view/list' -import { ViewType } from '../view-type-select' +import { ToolListTreeView } from '../tool/tool-list-tree-view/list' +import { ViewType } from '../types' import UninstalledItem from './uninstalled-item' type ListProps = { @@ -27,38 +27,10 @@ const List = ({ onSelect, tools, viewType, unInstalledPlugins, className }: List const previewCardHandle = useMemo(() => createPreviewCardHandle(), []) const isFlatView = viewType === ViewType.flat - const { letters, groups: withLetterAndGroupViewToolsData } = groupItems( - tools, - (tool) => tool.label[language]![0]!, + const { letters, flatTools, treeGroups } = useMemo( + () => createToolListData(tools, (tool) => tool.label[language]![0]!), + [language, tools], ) - const treeViewToolsData = useMemo(() => { - const result: Record = {} - Object.keys(withLetterAndGroupViewToolsData).forEach((letter) => { - Object.keys(withLetterAndGroupViewToolsData[letter]!).forEach((groupName) => { - if (!result[groupName]) result[groupName] = [] - result[groupName].push(...(withLetterAndGroupViewToolsData[letter]![groupName] ?? [])) - }) - }) - return result - }, [withLetterAndGroupViewToolsData]) - - const listViewToolData = useMemo(() => { - const result: ToolWithProvider[] = [] - letters.forEach((letter) => { - Object.keys(withLetterAndGroupViewToolsData[letter]!).forEach((groupName) => { - result.push( - ...withLetterAndGroupViewToolsData[letter]![groupName]!.map((item) => { - return { - ...item, - letter, - } - }), - ) - }) - }) - - return result - }, [withLetterAndGroupViewToolsData, letters]) const toolRefsRef = useRef>({}) @@ -76,7 +48,7 @@ const List = ({ onSelect, tools, viewType, unInstalledPlugins, className }: List ) : ( { +function UninstalledItem({ payload }: UninstalledItemProps) { const { t } = useTranslation() const locale = useLocale() @@ -35,12 +36,14 @@ const UninstalledItem = ({ payload }: UninstalledItemProps) => { {payload.org}
{canInstallPlugin && ( -
{t(($) => $.installAction, { ns: 'plugin' })} -
+ )} {isShowInstallModal && canInstallPlugin && ( ({ }), })) +vi.mock('../snippet-detail-card', () => ({ + default: ({ snippet }: { snippet: { name: string } }) =>
{`preview:${snippet.name}`}
, +})) + vi.mock('../snippet-tags-filter', () => ({ default: ({ value, onChange }: { value: string[]; onChange: (value: string[]) => void }) => ( + )}
@@ -145,26 +159,18 @@ const Snippets = ({ searchText, onSearchTextChange, insertPayload, onInserted }: {snippets.map((item) => { const row = ( - handleSnippetClick(item.id)} - onMouseEnter={() => setHoveredSnippetId(item.id)} - onMouseLeave={() => - setHoveredSnippetId((current) => (current === item.id ? null : current)) - } - /> + handleSnippetClick(item.id)} /> ) - if (!item.description) return
{row}
- return ( - - - - - - + ) })} {isFetchingNextPage && ( @@ -184,6 +190,17 @@ const Snippets = ({ searchText, onSearchTextChange, insertPayload, onInserted }: <> {filter}
{content}
+ + {({ payload }) => { + if (!payload) return null + + return ( + + + + ) + }} + ) } diff --git a/web/app/components/workflow/block-selector/snippets/snippet-detail-card.tsx b/web/app/components/workflow/block-selector/snippets/snippet-detail-card.tsx index 5806b8eb749..9751cbd23e8 100644 --- a/web/app/components/workflow/block-selector/snippets/snippet-detail-card.tsx +++ b/web/app/components/workflow/block-selector/snippets/snippet-detail-card.tsx @@ -1,28 +1,19 @@ -import type { FC } from 'react' import type { SnippetListItem } from '@/types/snippet' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { useMembers } from '@/service/use-common' import { useSnippetPublishedWorkflow } from '@/service/use-snippet-workflows' import BlockIcon from '../../block-icon' import { BlockEnum } from '../../types' -export type PublishedSnippetListItem = SnippetListItem - type SnippetDetailCardProps = { - snippet: PublishedSnippetListItem + snippet: SnippetListItem } -const SnippetDetailCard: FC = ({ snippet }) => { - const { description, name } = snippet +function SnippetDetailCard({ snippet }: SnippetDetailCardProps) { + const { author_name, description, name } = snippet const { t } = useTranslation('snippet') - const { data: membersData } = useMembers() const { data: workflow } = useSnippetPublishedWorkflow(snippet.id) - - const creatorName = useMemo(() => { - const member = membersData?.accounts?.find((member) => member.id === snippet.created_by) - return member?.name || t(($) => $.unknownUser) - }, [membersData?.accounts, snippet.created_by, t]) + const creatorName = author_name || t(($) => $.unknownUser) const blockTypes = useMemo(() => { const graph = workflow?.graph @@ -52,7 +43,7 @@ const SnippetDetailCard: FC = ({ snippet }) => { }, [workflow?.graph]) return ( -
+
{name}
diff --git a/web/app/components/workflow/block-selector/snippets/snippet-list-item.tsx b/web/app/components/workflow/block-selector/snippets/snippet-list-item.tsx index 90077240a29..1c36256b5db 100644 --- a/web/app/components/workflow/block-selector/snippets/snippet-list-item.tsx +++ b/web/app/components/workflow/block-selector/snippets/snippet-list-item.tsx @@ -1,38 +1,29 @@ import type { ComponentPropsWithoutRef, Ref } from 'react' -import type { PublishedSnippetListItem } from './snippet-detail-card' +import type { SnippetListItem as SnippetListItemData } from '@/types/snippet' import { cn } from '@langgenius/dify-ui/cn' type SnippetListItemProps = { - isHovered: boolean - ref?: Ref - snippet: PublishedSnippetListItem -} & ComponentPropsWithoutRef<'div'> + ref?: Ref + snippet: SnippetListItemData +} & ComponentPropsWithoutRef<'button'> -const SnippetListItem = ({ - isHovered, - snippet, - className, - ref, - ...props -}: SnippetListItemProps) => { - return ( -
-
{snippet.name}
- {!!snippet.description && ( -
- {snippet.description} -
- )} -
- ) -} +const SnippetListItem = ({ snippet, className, ref, ...props }: SnippetListItemProps) => ( + +) export default SnippetListItem diff --git a/web/app/components/workflow/block-selector/snippets/snippet-tags-filter.tsx b/web/app/components/workflow/block-selector/snippets/snippet-tags-filter.tsx index 2461f576d20..3619b19f8db 100644 --- a/web/app/components/workflow/block-selector/snippets/snippet-tags-filter.tsx +++ b/web/app/components/workflow/block-selector/snippets/snippet-tags-filter.tsx @@ -1,8 +1,9 @@ +import { Button } from '@langgenius/dify-ui/button' import { Checkbox } from '@langgenius/dify-ui/checkbox' import { CheckboxGroup } from '@langgenius/dify-ui/checkbox-group' import { cn } from '@langgenius/dify-ui/cn' import { Input } from '@langgenius/dify-ui/input' -import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' +import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from '@langgenius/dify-ui/popover' import { useQuery } from '@tanstack/react-query' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -44,20 +45,25 @@ const SnippetTagsFilter = ({ embedded = false, value, onChange }: SnippetTagsFil const triggerLabel = selectedTags.length ? selectedTags.map((tag) => tag.name).join(', ') : t(($) => $['tag.placeholder'], { ns: 'common' }) + const handleOpenChange = (nextOpen: boolean) => { + setOpen(nextOpen) + if (!nextOpen) setSearchText('') + } return ( - + 0 && 'border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg py-0.5 pr-1.5 pl-1 shadow-xs shadow-shadow-shadow-3', @@ -76,7 +82,7 @@ const SnippetTagsFilter = ({ embedded = false, value, onChange }: SnippetTagsFil {value.length > 0 && ( {value.length} )} - + } /> + {triggerLabel}
@@ -174,7 +202,7 @@ function SelectorContent({ {searchFilter}
{searchFilter}
- +
) @@ -200,10 +232,10 @@ function SelectorContent({ <> {searchFilter} setTags(TabType.Tools, value)} dataSources={dataSources} /> @@ -212,8 +244,8 @@ function SelectorContent({ return ( setSearchText(TabType.Snippets, value)} insertPayload={snippetInsertPayload} onInserted={onRequestClose} /> @@ -233,7 +265,10 @@ function SelectorContent({ return ( - + $['common.addBlock'], { ns: 'workflow' })} + className="relative w-full min-w-0 gap-0 bg-background-section-burn pt-1 pl-1" + > {tabs.map((tab) => ( ))} @@ -248,11 +283,7 @@ function SelectorContent({ /> {tabs.map((tab) => ( - + {renderPanel( tab.key, tab.key === defaultTab && tab.key !== TabType.Snippets ? searchInputRef : undefined, @@ -263,4 +294,4 @@ function SelectorContent({ ) } -export { SelectorContent } +export { BlockSelectorPanels } diff --git a/web/app/components/workflow/block-selector/all-tools.tsx b/web/app/components/workflow/block-selector/tool-browser.tsx similarity index 64% rename from web/app/components/workflow/block-selector/all-tools.tsx rename to web/app/components/workflow/block-selector/tool-browser.tsx index 070cb048365..a684946446e 100644 --- a/web/app/components/workflow/block-selector/all-tools.tsx +++ b/web/app/components/workflow/block-selector/tool-browser.tsx @@ -1,38 +1,83 @@ -import type { RefObject } from 'react' +import type { ReactNode, RefObject } from 'react' import type { Plugin } from '../../plugins/types' import type { BlockEnum, ToolWithProvider } from '../types' import type { ToolDefaultValue, ToolValue } from './types' import type { ListProps, ListRef, -} from '@/app/components/workflow/block-selector/market-place-plugin/list' +} from '@/app/components/workflow/block-selector/marketplace-plugin/list' import type { OnSelectBlock } from '@/app/components/workflow/types' -import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { RiArrowRightUpLine } from '@remixicon/react' import { useSuspenseQuery } from '@tanstack/react-query' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useDebounce } from 'ahooks' +import { useCallback, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import Divider from '@/app/components/base/divider' -import { SearchMenu } from '@/app/components/base/icons/src/vender/line/general' +import { buildIntegrationPath } from '@/app/components/integrations/routes' +import { useMarketplacePlugins } from '@/app/components/plugins/marketplace/query' import { getMarketplaceCategoryUrl } from '@/app/components/plugins/marketplace/utils' -import PluginList from '@/app/components/workflow/block-selector/market-place-plugin/list' +import PluginList from '@/app/components/workflow/block-selector/marketplace-plugin/list' import { useGetLanguage } from '@/context/i18n' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import Link from '@/next/link' -import { useMarketplacePlugins } from '../../plugins/marketplace/hooks' import { PluginCategoryEnum } from '../../plugins/types' import FeaturedTools from './featured-tools' import { useToolTabs } from './hooks' import { RAGToolRecommendations } from './rag-tool-recommendations' import Tools from './tools' -import { ToolType } from './types' -import ViewTypeSelect, { ViewType } from './view-type-select' +import { ToolType, ViewType } from './types' +import ViewTypeSelect from './view-type-select' const marketplaceFooterClassName = - 'system-sm-medium z-10 flex h-8 flex-none cursor-pointer items-center rounded-b-lg border-[0.5px] border-t border-components-panel-border bg-components-panel-bg-blur px-4 py-1 text-text-accent-light-mode-only shadow-lg' + 'system-sm-medium flex h-8 flex-none items-center border-t border-divider-subtle bg-components-panel-bg-blur px-4 py-1' -type AllToolsProps = { +function ToolsEmptyState({ title, action }: { title: string; action?: ReactNode }) { + return ( +
+ +
+ {title} +
+ {action} +
+ ) +} + +function ToolCategoryEmptyState({ type }: { type: ToolType }) { + const { t } = useTranslation() + const title = t(($) => $[`addToolModal.${type}.title`], { ns: 'tools' }) + const tip = t(($) => $[`addToolModal.${type}.tip`], { ns: 'tools' }) + const href = (() => { + if (type === ToolType.Custom) return buildIntegrationPath('custom-tool') + if (type === ToolType.Workflow) return buildIntegrationPath('workflow-tool') + if (type === ToolType.MCP) return buildIntegrationPath('mcp') + return undefined + })() + + return ( + + {tip} + + + ) : undefined + } + /> + ) +} + +type ToolBrowserProps = { className?: string toolContentClassName?: string searchText: string @@ -53,9 +98,9 @@ type AllToolsProps = { onFeaturedInstallSuccess?: () => Promise | void } -const DEFAULT_TAGS: AllToolsProps['tags'] = [] +const DEFAULT_TAGS: ToolBrowserProps['tags'] = [] -const AllTools = ({ +function ToolBrowser({ className, toolContentClassName, searchText, @@ -74,7 +119,7 @@ const AllTools = ({ featuredLoading = false, showFeatured = false, onFeaturedInstallSuccess, -}: AllToolsProps) => { +}: ToolBrowserProps) { const { t } = useTranslation() const language = useGetLanguage() const tabs = useToolTabs() @@ -84,6 +129,12 @@ const AllTools = ({ const hasSearchText = trimmedSearchText.length > 0 const hasTags = tags.length > 0 const hasFilter = hasSearchText || hasTags + const marketplaceFilters = useMemo( + () => ({ query: trimmedSearchText, tags }), + [tags, trimmedSearchText], + ) + const debouncedMarketplaceFilters = useDebounce(marketplaceFilters, { wait: 500 }) + const isMarketplaceSearchSettled = debouncedMarketplaceFilters === marketplaceFilters const handleLoadMoreRAGTools = () => { if (!onTagsChange || tags.includes('rag')) return onTagsChange([...tags, 'rag']) @@ -165,24 +216,27 @@ const AllTools = ({ language, ]) - const { queryPluginsWithDebounced: fetchPlugins, plugins: notInstalledPlugins = [] } = - useMarketplacePlugins() - const { data: enable_marketplace } = useSuspenseQuery({ ...systemFeaturesQueryOptions(), select: (s) => s.enable_marketplace, }) - - useEffect(() => { - if (!enable_marketplace) return - if (hasFilter) { - fetchPlugins({ - query: searchText, - tags, - category: PluginCategoryEnum.tool, - }) - } - }, [searchText, tags, enable_marketplace, hasFilter, fetchPlugins]) + const marketplaceSearchParams = useMemo( + () => + enable_marketplace && hasFilter && isMarketplaceSearchSettled + ? { + query: debouncedMarketplaceFilters.query, + tags: debouncedMarketplaceFilters.tags, + category: PluginCategoryEnum.tool, + } + : undefined, + [debouncedMarketplaceFilters, enable_marketplace, hasFilter, isMarketplaceSearchSettled], + ) + const { data: marketplacePluginsData, isFetching: isMarketplaceFetching } = + useMarketplacePlugins(marketplaceSearchParams) + const notInstalledPlugins = useMemo( + () => marketplacePluginsData?.pages.flatMap((page) => page.plugins) ?? [], + [marketplacePluginsData?.pages], + ) const pluginRef = useRef(null) const wrapElemRef = useRef(null) @@ -191,7 +245,12 @@ const AllTools = ({ const isShowRAGRecommendations = isInRAGPipeline && activeTab === ToolType.All && !hasFilter const hasToolsListContent = tools.length > 0 || isShowRAGRecommendations const hasPluginContent = enable_marketplace && notInstalledPlugins.length > 0 - const shouldShowEmptyState = hasFilter && !hasToolsListContent && !hasPluginContent + const isMarketplaceSearchPending = + enable_marketplace && hasFilter && (!isMarketplaceSearchSettled || isMarketplaceFetching) + const shouldShowEmptyState = + hasFilter && !isMarketplaceSearchPending && !hasToolsListContent && !hasPluginContent + const shouldShowCategoryEmptyState = + !hasFilter && activeTab !== ToolType.All && !hasToolsListContent const shouldShowFeatured = showFeatured && enable_marketplace && @@ -216,14 +275,14 @@ const AllTools = ({ }, [activeTab, t]) return ( -
+
{tabs.map((tab) => (
-
+
)} + {shouldShowCategoryEmptyState && } {enable_marketplace && ( } list={notInstalledPlugins} - searchText={searchText} + searchText={trimmedSearchText} category={PluginCategoryEnum.tool} toolContentClassName={toolContentClassName} tags={tags} @@ -301,39 +360,37 @@ const AllTools = ({
{shouldShowEmptyState && ( -
- -
- {t(($) => $['tabs.noPluginsFound'], { ns: 'workflow' })} -
- - - -
+ + } + /> )}
{shouldShowMarketplaceFooter && ( - - {t(($) => $.findMoreInMarketplace, { ns: 'plugin' })} - - +
+ + {t(($) => $.findMoreInMarketplace, { ns: 'plugin' })} + + +
)}
) } -export default AllTools +export default ToolBrowser diff --git a/web/app/components/workflow/block-selector/tool-list-data.ts b/web/app/components/workflow/block-selector/tool-list-data.ts new file mode 100644 index 00000000000..13e21c301e2 --- /dev/null +++ b/web/app/components/workflow/block-selector/tool-list-data.ts @@ -0,0 +1,150 @@ +import type { ToolWithProvider } from '../types' +import { pinyin } from 'pinyin-pro' +import { CollectionType } from '../../tools/types' + +type ToolCategoryGroup = 'custom' | 'data-source' | 'mcp' | 'workflow' + +type AuthorToolGroup = { + kind: 'author' + author: string + tools: ToolWithProvider[] +} + +type CategoryToolGroup = { + kind: 'category' + category: ToolCategoryGroup + tools: ToolWithProvider[] +} + +export type ToolGroup = AuthorToolGroup | CategoryToolGroup + +type ToolListData = { + letters: string[] + flatTools: Array + treeGroups: ToolGroup[] +} + +type LetterBucket = { + groups: ToolGroup[] + authorGroups: Map + categoryGroups: Map +} + +export function createToolListData( + tools: readonly ToolWithProvider[], + getFirstChar: (tool: ToolWithProvider) => string, +): ToolListData { + const buckets = new Map() + + for (const tool of tools) { + const firstChar = getFirstChar(tool) + if (!firstChar) continue + + const letter = normalizeLetter(firstChar) + const bucket = getOrCreateBucket(buckets, letter) + addToolToBucket(bucket, tool) + } + + const sortedBuckets = [...buckets.entries()].sort(([left], [right]) => { + if (left === '#') return 1 + if (right === '#') return -1 + return left.localeCompare(right) + }) + const letters = sortedBuckets.map(([letter]) => letter) + const flatTools = sortedBuckets.flatMap(([letter, bucket]) => + bucket.groups.flatMap((group) => group.tools.map((tool) => ({ ...tool, letter }))), + ) + const treeGroups = mergeGroupsByProvider([...buckets.values()]) + + return { letters, flatTools, treeGroups } +} + +function normalizeLetter(firstChar: string) { + const pinyinInitial = /[\u4E00-\u9FA5]/.test(firstChar) + ? pinyin(firstChar, { pattern: 'first', toneType: 'none' })[0] + : firstChar + const letter = (pinyinInitial || firstChar).toUpperCase() + + return /[A-Z]/.test(letter) ? letter : '#' +} + +function getOrCreateBucket(buckets: Map, letter: string) { + const existingBucket = buckets.get(letter) + if (existingBucket) return existingBucket + + const bucket: LetterBucket = { + groups: [], + authorGroups: new Map(), + categoryGroups: new Map(), + } + buckets.set(letter, bucket) + return bucket +} + +function addToolToBucket(bucket: LetterBucket, tool: ToolWithProvider) { + const category = getToolCategoryGroup(tool.type) + if (!category) { + addToolToAuthorGroup(bucket, tool) + return + } + + const existingGroup = bucket.categoryGroups.get(category) + if (existingGroup) { + existingGroup.tools.push(tool) + return + } + + const group: CategoryToolGroup = { kind: 'category', category, tools: [tool] } + bucket.categoryGroups.set(category, group) + bucket.groups.push(group) +} + +function addToolToAuthorGroup(bucket: LetterBucket, tool: ToolWithProvider) { + const existingGroup = bucket.authorGroups.get(tool.author) + if (existingGroup) { + existingGroup.tools.push(tool) + return + } + + const group: AuthorToolGroup = { kind: 'author', author: tool.author, tools: [tool] } + bucket.authorGroups.set(tool.author, group) + bucket.groups.push(group) +} + +function getToolCategoryGroup(type: ToolWithProvider['type']): ToolCategoryGroup | undefined { + if (type === CollectionType.custom) return 'custom' + if (type === CollectionType.workflow) return 'workflow' + if (type === CollectionType.datasource) return 'data-source' + if (type === CollectionType.mcp) return 'mcp' +} + +function mergeGroupsByProvider(buckets: LetterBucket[]) { + const groups: ToolGroup[] = [] + const authorGroups = new Map() + const categoryGroups = new Map() + + for (const bucket of buckets) { + for (const group of bucket.groups) { + if (group.kind === 'author') { + const existingGroup = authorGroups.get(group.author) + if (existingGroup) existingGroup.tools.push(...group.tools) + else { + const mergedGroup: AuthorToolGroup = { ...group, tools: [...group.tools] } + authorGroups.set(group.author, mergedGroup) + groups.push(mergedGroup) + } + continue + } + + const existingGroup = categoryGroups.get(group.category) + if (existingGroup) existingGroup.tools.push(...group.tools) + else { + const mergedGroup: CategoryToolGroup = { ...group, tools: [...group.tools] } + categoryGroups.set(group.category, mergedGroup) + groups.push(mergedGroup) + } + } + } + + return groups +} diff --git a/web/app/components/workflow/block-selector/tool-panel.tsx b/web/app/components/workflow/block-selector/tool-panel.tsx index cf5dd880686..ae4c0b5ce26 100644 --- a/web/app/components/workflow/block-selector/tool-panel.tsx +++ b/web/app/components/workflow/block-selector/tool-panel.tsx @@ -12,7 +12,7 @@ import { } from '@/service/use-tools' import { basePath } from '@/utils/var' import { useWorkflowStore } from '../store' -import AllTools from './all-tools' +import ToolBrowser from './tool-browser' function normalizeToolList(list: ToolWithProvider[] | undefined, currentBasePath?: string) { if (!list || !currentBasePath) return list @@ -131,7 +131,7 @@ export function ToolPanel({ ]) return ( - void }> & @@ -46,10 +45,12 @@ export type ToolPickerContentProps = Readonly<{ onSelect: (tool: ToolDefaultValue) => void onSelectMultiple: (tools: ToolDefaultValue[]) => void supportAddCustomTool?: boolean - scope?: string + scope?: ToolPickerScope selectedTools?: ToolValue[] }> +export type ToolPickerScope = 'all' | 'plugins' | 'custom' | 'workflow' + export function ToolPickerContent({ focusSearchOnMount = false, onSelect, @@ -111,8 +112,6 @@ export function ToolPickerContent({ } }, [scope, buildInTools, customTools, workflowTools]) - const handleAddedCustomTool = invalidateCustomTools - const handleSelect = (_type: BlockEnum, tool?: ToolDefaultValue) => { onSelect(tool!) } @@ -132,7 +131,7 @@ export function ToolPickerContent({ await createCustomCollection(data) toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' })) hideEditCustomCollectionModal() - handleAddedCustomTool() + invalidateCustomTools() } if (isShowEditCollectionToolModal && canManageTools) { @@ -161,7 +160,6 @@ export function ToolPickerContent({ onTagsChange={setTags} placeholder={t(($) => $.searchTools, { ns: 'plugin' })!} supportAddCustomTool={supportAddCustomTool && canManageTools} - onAddedCustomTool={handleAddedCustomTool} onShowAddCustomCollectionModal={showEditCustomCollectionModal} // The picker replaces the focused menu item inside an already-open popover. // Focusing search keeps keyboard users in the same add-tool workflow. @@ -170,7 +168,7 @@ export function ToolPickerContent({ inputClassName="grow" />
- { if (nextOpen && disabled) return onShowChange(nextOpen) } - return ( - }> - {trigger} - + + + {t(($) => $['detailPanel.toolSelector.title'], { ns: 'plugin' })} + diff --git a/web/app/components/workflow/block-selector/tool/__tests__/tool.spec.tsx b/web/app/components/workflow/block-selector/tool/__tests__/tool.spec.tsx index 442fdf6b604..54fa93f5e0b 100644 --- a/web/app/components/workflow/block-selector/tool/__tests__/tool.spec.tsx +++ b/web/app/components/workflow/block-selector/tool/__tests__/tool.spec.tsx @@ -8,7 +8,7 @@ import useTheme from '@/hooks/use-theme' import { Theme } from '@/types/app' import { BlockEnum } from '../../../types' import { createTool, createToolProvider } from '../../__tests__/factories' -import { ViewType } from '../../view-type-select' +import { ViewType } from '../../types' import Tool from '../tool' vi.mock('@/context/i18n', () => ({ @@ -40,7 +40,92 @@ describe('Tool', () => { mockUseTheme.mockReturnValue({ theme: Theme.light } as ReturnType) }) - it('expands a provider and selects an action item', async () => { + it('keeps provider disclosures independently expanded', async () => { + const user = userEvent.setup() + + render( + <> + + + , + ) + + const firstDisclosure = screen.getByRole('button', { name: /Provider One/ }) + const secondDisclosure = screen.getByRole('button', { name: /Provider Two/ }) + + expect(firstDisclosure).toHaveAttribute('aria-expanded', 'false') + expect(firstDisclosure).toHaveAttribute('aria-controls') + expect(secondDisclosure).toHaveAttribute('aria-expanded', 'false') + + await user.click(firstDisclosure) + await user.click(secondDisclosure) + + expect(firstDisclosure).toHaveAttribute('aria-expanded', 'true') + expect(secondDisclosure).toHaveAttribute('aria-expanded', 'true') + expect(screen.getByRole('button', { name: 'Tool A' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Tool B' })).toBeInTheDocument() + }) + + it('orders the provider, add-all, and action buttons without collapsing on add-all', async () => { + const user = userEvent.setup() + const onSelectMultiple = vi.fn() + + render( + , + ) + + const disclosure = screen.getByRole('button', { name: /Provider One/ }) + disclosure.focus() + await user.keyboard('{Enter}') + + expect(disclosure).toHaveAttribute('aria-expanded', 'true') + + await user.tab() + const addAllButton = screen.getByRole('button', { name: 'workflow.tabs.addAll' }) + expect(addAllButton).toHaveFocus() + + await user.keyboard('{Enter}') + + expect(disclosure).toHaveAttribute('aria-expanded', 'true') + expect(onSelectMultiple).toHaveBeenCalledWith( + BlockEnum.Tool, + expect.arrayContaining([ + expect.objectContaining({ tool_name: 'tool-a' }), + expect.objectContaining({ tool_name: 'tool-b' }), + ]), + ) + + await user.tab() + expect(screen.getByRole('button', { name: 'Tool A' })).toHaveFocus() + }) + + it('selects an expanded action item', async () => { const user = userEvent.setup() const onSelect = vi.fn() @@ -56,8 +141,8 @@ describe('Tool', () => { />, ) - await user.click(screen.getByText('Provider One')) - await user.click(screen.getByText('Tool B')) + await user.click(screen.getByRole('button', { name: /Provider One/ })) + await user.click(screen.getByRole('button', { name: 'Tool B' })) expect(onSelect).toHaveBeenCalledWith( BlockEnum.Tool, 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 67d7738e786..d87e45dc420 100644 --- a/web/app/components/workflow/block-selector/tool/action-item.tsx +++ b/web/app/components/workflow/block-selector/tool/action-item.tsx @@ -3,6 +3,7 @@ import type { ComponentProps, FC } from 'react' import type { ToolWithProvider } from '../../types' import type { ToolDefaultValue } from '../types' import type { Tool } from '@/app/components/tools/types' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { PreviewCardTrigger } from '@langgenius/dify-ui/preview-card' import * as React from 'react' @@ -74,11 +75,12 @@ const ToolItem: FC = ({ }, [theme, normalizedIcon, normalizedIconDark]) const row = ( -
)} - + ) return ( diff --git a/web/app/components/workflow/block-selector/tool/tool-list-flat-view/list.tsx b/web/app/components/workflow/block-selector/tool/tool-list-flat-view/list.tsx index 3317c1b6dda..3e7b5bcbf68 100644 --- a/web/app/components/workflow/block-selector/tool/tool-list-flat-view/list.tsx +++ b/web/app/components/workflow/block-selector/tool/tool-list-flat-view/list.tsx @@ -5,7 +5,7 @@ import type { ToolDefaultValue, ToolValue } from '../../types' import type { ToolActionPreviewCardHandle } from '../action-item' import * as React from 'react' import { useMemo } from 'react' -import { ViewType } from '../../view-type-select' +import { ViewType } from '../../types' import Tool from '../tool' type Props = Readonly<{ @@ -44,8 +44,8 @@ const ToolViewFlatView: FC = ({ return res }, [payload, letters]) return ( -
-
+
+
{payload.map((tool) => (
({ useGetLanguage: vi.fn(), @@ -31,23 +31,64 @@ describe('ToolListTreeView', () => { mockUseTheme.mockReturnValue({ theme: Theme.light } as ReturnType) }) - it('translates built-in special group names and renders the nested providers', () => { + it('renders author names and translates category groups', () => { render( - { expect(screen.getByText('BuiltIn')).toBeInTheDocument() expect(screen.getByText('workflow.tabs.customTool')).toBeInTheDocument() + expect(screen.getByText('MCP')).toBeInTheDocument() + expect(screen.getByText('workflow.tabs.workflowTool')).toBeInTheDocument() + expect(screen.getByText('workflow.tabs.sources')).toBeInTheDocument() expect(screen.getByText('Built In Provider')).toBeInTheDocument() expect(screen.getByText('Custom Provider')).toBeInTheDocument() + expect(screen.getByText('MCP Provider')).toBeInTheDocument() }) }) diff --git a/web/app/components/workflow/block-selector/tool/tool-list-tree-view/item.tsx b/web/app/components/workflow/block-selector/tool/tool-list-tree-view/item.tsx index 7e30b482c2c..0a716335c4e 100644 --- a/web/app/components/workflow/block-selector/tool/tool-list-tree-view/item.tsx +++ b/web/app/components/workflow/block-selector/tool/tool-list-tree-view/item.tsx @@ -4,7 +4,7 @@ import type { BlockEnum, ToolWithProvider } from '../../../types' import type { ToolDefaultValue, ToolValue } from '../../types' import type { ToolActionPreviewCardHandle } from '../action-item' import * as React from 'react' -import { ViewType } from '../../view-type-select' +import { ViewType } from '../../types' import Tool from '../tool' type Props = Readonly<{ diff --git a/web/app/components/workflow/block-selector/tool/tool-list-tree-view/list.tsx b/web/app/components/workflow/block-selector/tool/tool-list-tree-view/list.tsx index e8bdff7fa09..4aa581b1ed9 100644 --- a/web/app/components/workflow/block-selector/tool/tool-list-tree-view/list.tsx +++ b/web/app/components/workflow/block-selector/tool/tool-list-tree-view/list.tsx @@ -1,16 +1,14 @@ 'use client' -import type { FC } from 'react' -import type { BlockEnum, ToolWithProvider } from '../../../types' +import type { BlockEnum } from '../../../types' +import type { ToolGroup } from '../../tool-list-data' import type { ToolDefaultValue, ToolValue } from '../../types' import type { ToolActionPreviewCardHandle } from '../action-item' -import * as React from 'react' -import { useCallback } from 'react' +import { memo } from 'react' import { useTranslation } from 'react-i18next' -import { AGENT_GROUP_NAME, CUSTOM_GROUP_NAME, WORKFLOW_GROUP_NAME } from '../../index-bar' import Item from './item' type Props = Readonly<{ - payload: Record + payload: ToolGroup[] previewCardHandle: ToolActionPreviewCardHandle hasSearchText: boolean onSelect: (type: BlockEnum, tool: ToolDefaultValue) => void @@ -19,48 +17,48 @@ type Props = Readonly<{ selectedTools?: ToolValue[] }> -const ToolListTreeView: FC = ({ - payload, - previewCardHandle, - hasSearchText, - onSelect, - canNotSelectMultiple, - onSelectMultiple, - selectedTools, -}) => { - const { t } = useTranslation() - const getI18nGroupName = useCallback( - (name: string) => { - if (name === CUSTOM_GROUP_NAME) return t(($) => $['tabs.customTool'], { ns: 'workflow' }) +export const ToolListTreeView = memo( + ({ + payload, + previewCardHandle, + hasSearchText, + onSelect, + canNotSelectMultiple, + onSelectMultiple, + selectedTools, + }: Props) => { + const { t } = useTranslation() + const getGroupName = (group: ToolGroup) => { + if (group.kind === 'author') return group.author - if (name === WORKFLOW_GROUP_NAME) return t(($) => $['tabs.workflowTool'], { ns: 'workflow' }) + switch (group.category) { + case 'custom': + return t(($) => $['tabs.customTool'], { ns: 'workflow' }) + case 'workflow': + return t(($) => $['tabs.workflowTool'], { ns: 'workflow' }) + case 'data-source': + return t(($) => $['tabs.sources'], { ns: 'workflow' }) + case 'mcp': + return 'MCP' + } + } - if (name === AGENT_GROUP_NAME) return t(($) => $['tabs.agent'], { ns: 'workflow' }) - - return name - }, - [t], - ) - - if (!payload) return null - - return ( -
- {Object.keys(payload).map((groupName) => ( - - ))} -
- ) -} - -export default React.memo(ToolListTreeView) + return ( +
+ {payload.map((group) => ( + + ))} +
+ ) + }, +) diff --git a/web/app/components/workflow/block-selector/tool/tool.tsx b/web/app/components/workflow/block-selector/tool/tool.tsx index 63405331efd..a1f788ea0cb 100644 --- a/web/app/components/workflow/block-selector/tool/tool.tsx +++ b/web/app/components/workflow/block-selector/tool/tool.tsx @@ -1,16 +1,14 @@ 'use client' -import type { FC } from 'react' import type { Tool as ToolType } from '../../../tools/types' import type { ToolWithProvider } from '../../types' import type { ToolDefaultValue, ToolValue } from '../types' import type { ToolActionPreviewCardHandle } from './action-item' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react' -import { useHover } from 'ahooks' +import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from '@langgenius/dify-ui/collapsible' import * as React from 'react' -import { useCallback, useMemo, useRef } from 'react' +import { useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { Mcp } from '@/app/components/base/icons/src/vender/other' import { useMCPToolAvailability } from '@/app/components/workflow/nodes/_base/components/mcp-tool-availability' import { useGetLanguage } from '@/context/i18n' import useTheme from '@/hooks/use-theme' @@ -20,7 +18,7 @@ import { CollectionType } from '../../../tools/types' import BlockIcon from '../../block-icon' import McpToolNotSupportTooltip from '../../nodes/_base/components/mcp-tool-not-support-tooltip' import { BlockEnum } from '../../types' -import { ViewType } from '../view-type-select' +import { ViewType } from '../types' import ActionItem from './action-item' const normalizeProviderIcon = (icon?: ToolWithProvider['icon']) => { @@ -48,7 +46,7 @@ type Props = Readonly<{ isShowLetterIndex?: boolean }> -const Tool: FC = ({ +function Tool({ className, payload, previewCardHandle, @@ -58,18 +56,16 @@ const Tool: FC = ({ canNotSelectMultiple, onSelectMultiple, selectedTools, -}) => { +}: Props) { const { t } = useTranslation() const { allowed: isMCPToolAllowed } = useMCPToolAvailability() const language = useGetLanguage() const isFlatView = viewType === ViewType.flat const notShowProvider = payload.type === CollectionType.workflow const actions = payload.tools - const hasAction = !notShowProvider - const [isFold, setIsFold] = React.useState(true) - const [isFoldHasSearchText, setIsFoldHasSearchText] = React.useState(hasSearchText) - const ref = useRef(null) - const isHovering = useHover(ref) + const panelId = React.useId() + const [open, setOpen] = React.useState(hasSearchText) + const [previousHasSearchText, setPreviousHasSearchText] = React.useState(hasSearchText) const isMCPTool = payload.type === CollectionType.mcp const isShowCanNotChooseMCPTip = !isMCPToolAllowed && isMCPTool const { theme } = useTheme() @@ -101,95 +97,11 @@ const Tool: FC = ({ const selectedToolsNum = actions.filter((action) => getIsDisabled(action)).length const isAllSelected = selectedToolsNum === totalToolsNum - const notShowProviderSelectInfo = useMemo(() => { - if (isAllSelected) { - return ( - - {t(($) => $['addToolModal.added'], { ns: 'tools' })} - - ) - } - }, [isAllSelected, t]) - const selectedInfo = useMemo(() => { - if (isHovering && !isAllSelected) { - return ( - { - onSelectMultiple?.( - BlockEnum.Tool, - actions - .filter((action) => !getIsDisabled(action)) - .map((tool) => { - const params: Record = {} - if (tool.parameters) { - tool.parameters.forEach((item) => { - params[item.name] = '' - }) - } - return { - provider_id: payload.id, - provider_type: payload.type, - provider_name: payload.name, - provider_show_name: payload.label[language], - plugin_id: payload.plugin_id!, - plugin_unique_identifier: payload.plugin_unique_identifier!, - provider_icon: normalizedIcon, - provider_icon_dark: normalizedIconDark, - tool_name: tool.name, - tool_label: tool.label[language]!, - tool_description: tool.description[language], - title: tool.label[language]!, - is_team_authorization: payload.is_team_authorization, - paramSchemas: tool.parameters, - params, - } - }), - ) - }} - > - {t(($) => $['tabs.addAll'], { ns: 'workflow' })} - - ) - } - - if (selectedToolsNum === 0) return <> - - return ( - - {isAllSelected - ? t(($) => $['tabs.allAdded'], { ns: 'workflow' }) - : `${selectedToolsNum} / ${totalToolsNum}`} - - ) - }, [ - actions, - getIsDisabled, - isAllSelected, - isHovering, - language, - normalizedIcon, - normalizedIconDark, - onSelectMultiple, - payload.id, - payload.is_team_authorization, - payload.label, - payload.name, - payload.plugin_id, - payload.plugin_unique_identifier, - payload.type, - selectedToolsNum, - t, - totalToolsNum, - ]) - - if (isFoldHasSearchText !== hasSearchText) { - setIsFoldHasSearchText(hasSearchText) - setIsFold(!hasSearchText) + if (previousHasSearchText !== hasSearchText) { + setPreviousHasSearchText(hasSearchText) + setOpen(hasSearchText) } - const FoldIcon = isFold ? RiArrowRightSLine : RiArrowDownSLine - const groupName = useMemo(() => { if (payload.type === CollectionType.builtIn) return payload.author @@ -202,92 +114,183 @@ const Tool: FC = ({ return '' }, [payload.author, payload.type, t]) - return ( -
-
-
{ - if (hasAction) { - setIsFold(!isFold) - return - } - - const tool = actions[0] - const params: Record = {} - if (tool!.parameters) { - tool!.parameters.forEach((item) => { - params[item.name] = '' - }) - } - onSelect(BlockEnum.Tool, { - provider_id: payload.id, - provider_type: payload.type, - provider_name: payload.name, - provider_show_name: payload.label[language], - plugin_id: payload.plugin_id, - plugin_unique_identifier: payload.plugin_unique_identifier, - provider_icon: normalizedIcon, - provider_icon_dark: normalizedIconDark, - tool_name: tool!.name, - tool_label: tool!.label[language]!, - tool_description: tool!.description[language], - title: tool!.label[language]!, - is_team_authorization: payload.is_team_authorization, - paramSchemas: tool!.parameters, - params, - }) - }} - > -
- -
- - {notShowProvider ? actions[0]?.label[language] : payload.label[language]} - - {isFlatView && groupName && ( - - {groupName} - - )} - {isMCPTool && } -
-
- -
- {!isShowCanNotChooseMCPTip && - !canNotSelectMultiple && - (notShowProvider ? notShowProviderSelectInfo : selectedInfo)} - {isShowCanNotChooseMCPTip && } - {hasAction && ( - - )} -
-
- - {!notShowProvider && - hasAction && - !isFold && - actions.map((action) => ( - - ))} + const providerDetails = ( +
+ +
+ + {notShowProvider ? actions[0]?.label[language] : payload.label[language]} + + {isFlatView && groupName && ( + {groupName} + )} + {isMCPTool && ( + + )}
) + + const selectedStatus = selectedToolsNum + ? isAllSelected + ? t(($) => $['tabs.allAdded'], { ns: 'workflow' }) + : `${selectedToolsNum} / ${totalToolsNum}` + : undefined + + const handleSelectAll = () => { + onSelectMultiple?.( + BlockEnum.Tool, + actions + .filter((action) => !getIsDisabled(action)) + .map((tool) => { + const params: Record = {} + tool.parameters?.forEach((item) => { + params[item.name] = '' + }) + return { + provider_id: payload.id, + provider_type: payload.type, + provider_name: payload.name, + provider_show_name: payload.label[language], + plugin_id: payload.plugin_id!, + plugin_unique_identifier: payload.plugin_unique_identifier!, + provider_icon: normalizedIcon, + provider_icon_dark: normalizedIconDark, + tool_name: tool.name, + tool_label: tool.label[language]!, + tool_description: tool.description[language], + title: tool.label[language]!, + is_team_authorization: payload.is_team_authorization, + paramSchemas: tool.parameters, + params, + } + }), + ) + } + + const handleWorkflowToolSelect = () => { + const tool = actions[0]! + const params: Record = {} + tool.parameters?.forEach((item) => { + params[item.name] = '' + }) + onSelect(BlockEnum.Tool, { + provider_id: payload.id, + provider_type: payload.type, + provider_name: payload.name, + provider_show_name: payload.label[language], + plugin_id: payload.plugin_id, + plugin_unique_identifier: payload.plugin_unique_identifier, + provider_icon: normalizedIcon, + provider_icon_dark: normalizedIconDark, + tool_name: tool.name, + tool_label: tool.label[language]!, + tool_description: tool.description[language], + title: tool.label[language]!, + is_team_authorization: payload.is_team_authorization, + paramSchemas: tool.parameters, + params, + }) + } + + if (notShowProvider) { + return ( +
+ +
+ ) + } + + return ( + +
+ + {providerDetails} + {!isShowCanNotChooseMCPTip && !canNotSelectMultiple && selectedStatus && ( + + {selectedStatus} + + )} + + + + {!isShowCanNotChooseMCPTip && + !canNotSelectMultiple && + !isAllSelected && + onSelectMultiple && ( + + )} + {isShowCanNotChooseMCPTip && ( +
+ +
+ )} +
+ + + {actions.map((action) => ( + + ))} + +
+ ) } export default React.memo(Tool) diff --git a/web/app/components/workflow/block-selector/tools.tsx b/web/app/components/workflow/block-selector/tools.tsx index ee687eb7fae..4cb1d360fb5 100644 --- a/web/app/components/workflow/block-selector/tools.tsx +++ b/web/app/components/workflow/block-selector/tools.tsx @@ -6,11 +6,12 @@ import { createPreviewCardHandle, PreviewCard } from '@langgenius/dify-ui/previe import { memo, useMemo, useRef } from 'react' import Empty from '@/app/components/tools/provider/empty' import { useGetLanguage } from '@/context/i18n' -import IndexBar, { groupItems } from './index-bar' +import { IndexBar } from './index-bar' +import { createToolListData } from './tool-list-data' import { ToolActionPreviewCard } from './tool/action-item' import ToolListFlatView from './tool/tool-list-flat-view/list' -import ToolListTreeView from './tool/tool-list-tree-view/list' -import { ViewType } from './view-type-select' +import { ToolListTreeView } from './tool/tool-list-tree-view/list' +import { ViewType } from './types' type ToolsProps = { onSelect: (type: BlockEnum, tool: ToolDefaultValue) => void @@ -43,54 +44,14 @@ const Tools = ({ const isFlatView = viewType === ViewType.flat const isShowLetterIndex = isFlatView && tools.length > 10 - /* - treeViewToolsData: - { - A: { - 'google': [ // plugin organize name - ...tools - ], - 'custom': [ // custom tools - ...tools - ], - 'workflow': [ // workflow as tools - ...tools - ] - } - } - */ - const { letters, groups: withLetterAndGroupViewToolsData } = groupItems( - tools, - (tool) => tool.label[language]![0]!, + const { letters, flatTools, treeGroups } = useMemo( + () => + createToolListData( + tools, + (tool) => (tool.label[language] || tool.label['en-US'] || tool.name)[0] || '#', + ), + [language, tools], ) - const treeViewToolsData = useMemo(() => { - const result: Record = {} - Object.keys(withLetterAndGroupViewToolsData).forEach((letter) => { - Object.keys(withLetterAndGroupViewToolsData[letter]!).forEach((groupName) => { - if (!result[groupName]) result[groupName] = [] - result[groupName].push(...(withLetterAndGroupViewToolsData[letter]![groupName] ?? [])) - }) - }) - return result - }, [withLetterAndGroupViewToolsData]) - - const listViewToolData = useMemo(() => { - const result: ToolWithProvider[] = [] - letters.forEach((letter) => { - Object.keys(withLetterAndGroupViewToolsData[letter]!).forEach((groupName) => { - result.push( - ...withLetterAndGroupViewToolsData[letter]![groupName]!.map((item) => { - return { - ...item, - letter, - } - }), - ) - }) - }) - - return result - }, [withLetterAndGroupViewToolsData, letters]) const toolRefsRef = useRef>({}) @@ -98,7 +59,7 @@ const Tools = ({
{!tools.length && !hasSearchText && (
- +
)} {!!tools.length && @@ -106,7 +67,7 @@ const Tools = ({ ) : ( { await user.click(screen.getByText('Trigger Provider')) - expect(screen.getByLabelText('workflow.tabs.allTriggers')).toHaveClass( - 'max-h-[240px]', - 'overscroll-contain', - ) - await user.click(screen.getByText('Second Event')) expect(onSelect).toHaveBeenCalledWith( diff --git a/web/app/components/workflow/block-selector/trigger-plugin/action-item.tsx b/web/app/components/workflow/block-selector/trigger-plugin/action-item.tsx index ddfb6c23dc5..d0b17b57f46 100644 --- a/web/app/components/workflow/block-selector/trigger-plugin/action-item.tsx +++ b/web/app/components/workflow/block-selector/trigger-plugin/action-item.tsx @@ -48,7 +48,7 @@ const TriggerPluginActionItem: FC = ({ aria-describedby={previewDescription ? previewDescriptionId : undefined} disabled={disabled} className={cn( - 'flex w-full items-center justify-between rounded-lg border-0 bg-transparent pr-1 pl-[21px] text-left focus-visible:ring-1 focus-visible:ring-components-input-border-hover focus-visible:outline-hidden', + 'flex w-full items-center justify-between rounded-lg border-0 bg-transparent pr-1 pl-[21px] text-left focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid focus-visible:outline-hidden', disabled ? 'cursor-default' : 'cursor-pointer hover:bg-state-base-hover', )} onClick={() => { diff --git a/web/app/components/workflow/block-selector/types.ts b/web/app/components/workflow/block-selector/types.ts index 109b41e7856..011c2fff6fb 100644 --- a/web/app/components/workflow/block-selector/types.ts +++ b/web/app/components/workflow/block-selector/types.ts @@ -29,6 +29,13 @@ export const ToolType = { export type ToolType = (typeof ToolType)[keyof typeof ToolType] +export const ViewType = { + flat: 'flat', + tree: 'tree', +} as const + +export type ViewType = (typeof ViewType)[keyof typeof ViewType] + export const BlockClassification = { Default: '-', QuestionUnderstand: 'question-understand', diff --git a/web/app/components/workflow/block-selector/view-type-select.tsx b/web/app/components/workflow/block-selector/view-type-select.tsx index 874c938ea6c..1efce1ad211 100644 --- a/web/app/components/workflow/block-selector/view-type-select.tsx +++ b/web/app/components/workflow/block-selector/view-type-select.tsx @@ -1,56 +1,46 @@ 'use client' -import type { FC } from 'react' -import { cn } from '@langgenius/dify-ui/cn' -import { RiNodeTree, RiSortAlphabetAsc } from '@remixicon/react' -import * as React from 'react' -import { useCallback } from 'react' - -export enum ViewType { - flat = 'flat', - tree = 'tree', -} +import { SegmentedControl, SegmentedControlItem } from '@langgenius/dify-ui/segmented-control' +import { memo } from 'react' +import { useTranslation } from 'react-i18next' +import { ViewType } from './types' type Props = Readonly<{ viewType: ViewType onChange: (viewType: ViewType) => void }> -const ViewTypeSelect: FC = ({ viewType, onChange }) => { - const handleChange = useCallback( - (nextViewType: ViewType) => { - return () => { - if (nextViewType === viewType) return - onChange(nextViewType) - } - }, - [viewType, onChange], - ) +function ViewTypeSelect({ viewType, onChange }: Props) { + const { t } = useTranslation() + + const handleValueChange = (value: ViewType[]) => { + const nextViewType = value[0] + if (!nextViewType || nextViewType === viewType) return + + onChange(nextViewType) + } return ( -
-
+ value={[viewType]} + aria-label={t(($) => $['operation.view'], { ns: 'common' })} + className="gap-0 rounded-lg p-px" + onValueChange={handleValueChange} + > + + value={ViewType.flat} + aria-label={t(($) => $['tabs.listView'], { ns: 'workflow' })} + className="size-5.5 rounded-lg border-0 p-0 text-text-tertiary" > - -
-
+ + + value={ViewType.tree} + aria-label={t(($) => $['tabs.treeView'], { ns: 'workflow' })} + className="size-5.5 rounded-lg border-0 p-0 text-text-tertiary" > - -
-
+ + + ) } -export default React.memo(ViewTypeSelect) +export default memo(ViewTypeSelect) diff --git a/web/app/components/workflow/custom-edge.tsx b/web/app/components/workflow/custom-edge.tsx index 730376fbf1f..871b0b4f7c4 100644 --- a/web/app/components/workflow/custom-edge.tsx +++ b/web/app/components/workflow/custom-edge.tsx @@ -146,7 +146,7 @@ const CustomEdge = ({ nextNodeTargetHandle: targetHandleId || 'target', }} availableBlocksTypes={intersection(availablePrevBlocks, availableNextBlocks)} - triggerClassName={() => 'hover:scale-150 transition-all'} + triggerClassName={() => 'transition-transform hover:scale-150'} />
diff --git a/web/app/components/workflow/header/__tests__/undo-redo.spec.tsx b/web/app/components/workflow/header/__tests__/undo-redo.spec.tsx index 43d43c43191..4f3b6616760 100644 --- a/web/app/components/workflow/header/__tests__/undo-redo.spec.tsx +++ b/web/app/components/workflow/header/__tests__/undo-redo.spec.tsx @@ -1,4 +1,5 @@ import { act, fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import UndoRedo from '../undo-redo' const mockUnsubscribe = vi.fn() @@ -69,16 +70,23 @@ describe('UndoRedo', () => { expect(mockHandleRedo).toHaveBeenCalledTimes(1) }) - it('keeps the buttons disabled before history is available', () => { + it('keeps unavailable history actions focusable without activating them', async () => { + const user = userEvent.setup() render() const undoButton = screen.getByRole('button', { name: 'workflow.common.undo' }) const redoButton = screen.getByRole('button', { name: 'workflow.common.redo' }) - fireEvent.click(undoButton) - fireEvent.click(redoButton) + expect(undoButton).toHaveAttribute('aria-disabled', 'true') + expect(redoButton).toHaveAttribute('aria-disabled', 'true') + + await user.tab() + expect(undoButton).toHaveFocus() + await user.keyboard('{Enter}') + + await user.tab() + expect(redoButton).toHaveFocus() + await user.keyboard(' ') - expect(undoButton).toBeDisabled() - expect(redoButton).toBeDisabled() expect(mockHandleUndo).not.toHaveBeenCalled() expect(mockHandleRedo).not.toHaveBeenCalled() }) @@ -99,8 +107,8 @@ describe('UndoRedo', () => { fireEvent.click(undoButton) fireEvent.click(redoButton) - expect(undoButton).toBeDisabled() - expect(redoButton).toBeDisabled() + expect(undoButton).toHaveAttribute('aria-disabled', 'true') + expect(redoButton).toHaveAttribute('aria-disabled', 'true') expect(mockHandleUndo).not.toHaveBeenCalled() expect(mockHandleRedo).not.toHaveBeenCalled() }) diff --git a/web/app/components/workflow/header/undo-redo.tsx b/web/app/components/workflow/header/undo-redo.tsx index f56c82a7234..3aeaef9adf6 100644 --- a/web/app/components/workflow/header/undo-redo.tsx +++ b/web/app/components/workflow/header/undo-redo.tsx @@ -1,4 +1,4 @@ -import type { FC } from 'react' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { memo, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -9,7 +9,7 @@ import Divider from '../../base/divider' import TipPopup from '../operator/tip-popup' type UndoRedoProps = { handleUndo: () => void; handleRedo: () => void } -const UndoRedo: FC = ({ handleUndo, handleRedo }) => { +function UndoRedo({ handleUndo, handleRedo }: UndoRedoProps) { const { t } = useTranslation() const { store } = useWorkflowHistoryStore() const [buttonsDisabled, setButtonsDisabled] = useState({ undo: true, redo: true }) @@ -29,36 +29,40 @@ const UndoRedo: FC = ({ handleUndo, handleRedo }) => { return (
$['common.undo'], { ns: 'workflow' })!} shortcut="workflow.undo"> - + + $['common.redo'], { ns: 'workflow' })!} shortcut="workflow.redo"> - + + diff --git a/web/app/components/workflow/header/view-workflow-history.tsx b/web/app/components/workflow/header/view-workflow-history.tsx index d6848fa28f2..285866aa842 100644 --- a/web/app/components/workflow/header/view-workflow-history.tsx +++ b/web/app/components/workflow/header/view-workflow-history.tsx @@ -1,7 +1,7 @@ import type { WorkflowHistoryState } from '../store/workflow/history-slice' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' -import { RiCloseLine, RiHistoryLine } from '@remixicon/react' import { memo, useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { useShallow } from 'zustand/react/shallow' @@ -145,49 +145,57 @@ const ViewWorkflowHistory = () => { setOpen(nextOpen) }} > - $['changeHistory.title'], { ns: 'workflow' })} - disabled={nodesReadOnly} - className={cn( - 'box-border inline-flex size-8 max-h-8 min-h-8 max-w-8 min-w-8 shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-md p-0 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary data-popup-open:bg-state-accent-active data-popup-open:text-text-accent', - nodesReadOnly && - 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled', - )} - onClick={() => { - if (nodesReadOnly) return - setCurrentLogItem() - setShowMessageLogModal(false) - }} - > - $['changeHistory.title'], { ns: 'workflow' })}> - - - - - - } - /> + $['changeHistory.title'], { ns: 'workflow' })}> + {/* Tooltip and Popover share data-popup-open on this button, so read Popover state directly. */} + + cn( + popoverOpen && + 'bg-state-accent-active text-text-accent hover:bg-state-accent-active hover:text-text-accent', + ) + } + render={ + + + } onClick={() => { setCurrentLogItem() @@ -203,7 +211,10 @@ const ViewWorkflowHistory = () => { > {!calculateChangeList.statesCount && (
- +
{t(($) => $['changeHistory.placeholder'], { ns: 'workflow' })}
@@ -211,10 +222,11 @@ const ViewWorkflowHistory = () => { )}
{calculateChangeList.futureStates.map((item: ChangeHistoryEntry) => ( -
{ @@ -239,13 +251,14 @@ const ViewWorkflowHistory = () => { )
-
+ ))} {calculateChangeList.pastStates.map((item: ChangeHistoryEntry) => ( -
{ @@ -267,16 +280,17 @@ const ViewWorkflowHistory = () => { ({calculateStepLabel(item?.index)})
-
+ ))}
{!!calculateChangeList.statesCount && (
-
{ @@ -289,7 +303,7 @@ const ViewWorkflowHistory = () => { {t(($) => $['changeHistory.clearHistory'], { ns: 'workflow' })}
-
+
)}
diff --git a/web/app/components/workflow/nodes/_base/components/__tests__/agent-strategy-selector.spec.tsx b/web/app/components/workflow/nodes/_base/components/__tests__/agent-strategy-selector.spec.tsx index bff3e7d0847..1ec98aaf2c0 100644 --- a/web/app/components/workflow/nodes/_base/components/__tests__/agent-strategy-selector.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/__tests__/agent-strategy-selector.spec.tsx @@ -123,7 +123,7 @@ vi.mock('@/app/components/workflow/block-selector/tools', () => ({ ), })) -vi.mock('@/app/components/workflow/block-selector/market-place-plugin/list', () => ({ +vi.mock('@/app/components/workflow/block-selector/marketplace-plugin/list', () => ({ default: ({ list, searchText }: { list: Array<{ plugin_id: string }>; searchText: string }) => (
{`${searchText}:${list.map((item) => item.plugin_id).join(',')}`} diff --git a/web/app/components/workflow/nodes/_base/components/agent-strategy-selector.tsx b/web/app/components/workflow/nodes/_base/components/agent-strategy-selector.tsx index 7585aaa668f..c8e68ed8206 100644 --- a/web/app/components/workflow/nodes/_base/components/agent-strategy-selector.tsx +++ b/web/app/components/workflow/nodes/_base/components/agent-strategy-selector.tsx @@ -5,7 +5,7 @@ import type { StrategyPluginDetail } from '@/app/components/plugins/types' import type { ListProps, ListRef, -} from '@/app/components/workflow/block-selector/market-place-plugin/list' +} from '@/app/components/workflow/block-selector/marketplace-plugin/list' import { cn } from '@langgenius/dify-ui/cn' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' @@ -17,12 +17,13 @@ import useGetIcon from '@/app/components/plugins/install-plugin/base/use-get-ico import { useMarketplacePlugins } from '@/app/components/plugins/marketplace/hooks' import { PluginCategoryEnum } from '@/app/components/plugins/types' import { CollectionType } from '@/app/components/tools/types' -import PluginList from '@/app/components/workflow/block-selector/market-place-plugin/list' +import PluginList from '@/app/components/workflow/block-selector/marketplace-plugin/list' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import Link from '@/next/link' import { useStrategyProviders } from '@/service/use-strategy' import Tools from '../../../block-selector/tools' -import ViewTypeSelect, { ViewType } from '../../../block-selector/view-type-select' +import { ViewType } from '../../../block-selector/types' +import ViewTypeSelect from '../../../block-selector/view-type-select' import { useStrategyInfo } from '../../agent/use-config' import { InstallPluginButton } from './install-plugin-button' import { SwitchPluginVersion } from './switch-plugin-version' diff --git a/web/app/components/workflow/nodes/_base/components/next-step/add.tsx b/web/app/components/workflow/nodes/_base/components/next-step/add.tsx index af1a19003bb..568b3c8ec1a 100644 --- a/web/app/components/workflow/nodes/_base/components/next-step/add.tsx +++ b/web/app/components/workflow/nodes/_base/components/next-step/add.tsx @@ -1,4 +1,5 @@ import type { CommonNodeType, OnSelectBlock } from '@/app/components/workflow/types' +import { Button } from '@langgenius/dify-ui/button' import { RiAddLine } from '@remixicon/react' import { memo, useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -57,14 +58,16 @@ const Add = ({ nodeId, nodeData, sourceHandle, isParallel, isFailBranch }: AddPr const renderTrigger = useCallback( (open: boolean) => { return ( -
- +
{tip}
-
+ ) }, [nodesReadOnly, tip], @@ -81,7 +84,7 @@ const Add = ({ nodeId, nodeData, sourceHandle, isParallel, isFailBranch }: AddPr prevNodeSourceHandle: sourceHandle, }} placement="top" - offset={0} + sideOffset={0} trigger={renderTrigger} popupClassName="w-[328px]!" availableBlocksTypes={availableNextBlocks} diff --git a/web/app/components/workflow/nodes/_base/components/next-step/operator.tsx b/web/app/components/workflow/nodes/_base/components/next-step/operator.tsx index 581f901700b..1683e4258d6 100644 --- a/web/app/components/workflow/nodes/_base/components/next-step/operator.tsx +++ b/web/app/components/workflow/nodes/_base/components/next-step/operator.tsx @@ -36,9 +36,9 @@ const ChangeItem = ({ data, nodeId, sourceHandle }: ChangeItemProps) => { const renderTrigger = useCallback(() => { return ( -
+
+ ) }, [t]) @@ -46,10 +46,8 @@ const ChangeItem = ({ data, nodeId, sourceHandle }: ChangeItemProps) => { { @@ -14,11 +14,11 @@ const DataSourceEmptyNode = ({ id, data }: NodeProps) => { const renderTrigger = useCallback(() => { return ( ) - }, []) + }, [t]) return (
{
diff --git a/web/app/components/workflow/nodes/iteration/add-block.tsx b/web/app/components/workflow/nodes/iteration/add-block.tsx index 64fe89453d7..0bfa5b3a55b 100644 --- a/web/app/components/workflow/nodes/iteration/add-block.tsx +++ b/web/app/components/workflow/nodes/iteration/add-block.tsx @@ -1,5 +1,6 @@ import type { IterationNodeType } from './types' import type { OnSelectBlock } from '@/app/components/workflow/types' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { RiAddLine } from '@remixicon/react' import { memo, useCallback } from 'react' @@ -37,16 +38,14 @@ const AddBlock = ({ iterationNodeData }: AddBlockProps) => { const renderTriggerElement = useCallback( (open: boolean) => { return ( -
- + {t(($) => $['common.addBlock'], { ns: 'workflow' })} -
+ ) }, [nodesReadOnly, t], @@ -65,7 +64,6 @@ const AddBlock = ({ iterationNodeData }: AddBlockProps) => { prevNodeSourceHandle: 'source', }} trigger={renderTriggerElement} - triggerInnerClassName="inline-flex" popupClassName="min-w-[256px]!" availableBlocksTypes={availableNextBlocks} /> diff --git a/web/app/components/workflow/nodes/loop/add-block.tsx b/web/app/components/workflow/nodes/loop/add-block.tsx index b1becf5327d..565d8fd80b5 100644 --- a/web/app/components/workflow/nodes/loop/add-block.tsx +++ b/web/app/components/workflow/nodes/loop/add-block.tsx @@ -1,5 +1,6 @@ import type { LoopNodeType } from './types' import type { OnSelectBlock } from '@/app/components/workflow/types' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { RiAddLine } from '@remixicon/react' import { memo, useCallback } from 'react' @@ -37,16 +38,14 @@ const AddBlock = ({ loopNodeData }: AddBlockProps) => { const renderTriggerElement = useCallback( (open: boolean) => { return ( -
- + {t(($) => $['common.addBlock'], { ns: 'workflow' })} -
+ ) }, [nodesReadOnly, t], @@ -65,7 +64,6 @@ const AddBlock = ({ loopNodeData }: AddBlockProps) => { prevNodeSourceHandle: 'source', }} trigger={renderTriggerElement} - triggerInnerClassName="inline-flex" popupClassName="min-w-[256px]!" availableBlocksTypes={availableNextBlocks} /> diff --git a/web/app/components/workflow/nodes/parameter-extractor/__tests__/integration.spec.tsx b/web/app/components/workflow/nodes/parameter-extractor/__tests__/integration.spec.tsx index ee07c4e3d74..f1907000932 100644 --- a/web/app/components/workflow/nodes/parameter-extractor/__tests__/integration.spec.tsx +++ b/web/app/components/workflow/nodes/parameter-extractor/__tests__/integration.spec.tsx @@ -1,17 +1,13 @@ import type { ReactNode } from 'react' import type { Var } from '../../../types' import type { Param, ParameterExtractorNodeType } from '../types' -import type { ToolParameter } from '@/app/components/tools/types' -import type { ToolDefaultValue } from '@/app/components/workflow/block-selector/types' import type { PanelProps } from '@/types/workflow' import { toast } from '@langgenius/dify-ui/toast' import { fireEvent, render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { useTextGenerationCurrentProviderAndModelAndModelList } from '@/app/components/header/account-setting/model-provider-page/hooks' -import { CollectionType } from '@/app/components/tools/types' import { AppModeEnum } from '@/types/app' import { BlockEnum } from '../../../types' -import ImportFromTool from '../components/extract-parameter/import-from-tool' import ExtractParameter from '../components/extract-parameter/list' import AddExtractParameter from '../components/extract-parameter/update' import ReasoningModePicker from '../components/reasoning-mode-picker' @@ -24,20 +20,6 @@ const reasoningModeFunctionToolCallingLabel = 'workflow.nodes.parameterExtractor.reasoningModeFunctionToolCalling' const reasoningModePromptLabel = 'workflow.nodes.parameterExtractor.reasoningModePrompt' -type MockToolCollection = { - id: string - tools: Array<{ - name: string - parameters: ToolParameter[] - }> -} - -let mockBuiltInTools: MockToolCollection[] = [] -let mockCustomTools: MockToolCollection[] = [] -let mockWorkflowTools: MockToolCollection[] = [] -let mockSelectedToolInfo: ToolDefaultValue | undefined -let mockBlockSelectorOpen = false - vi.mock('@langgenius/dify-ui/toast', () => ({ toast: { success: vi.fn(), @@ -47,32 +29,11 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ }, })) -vi.mock('@/app/components/workflow/block-selector', () => ({ - __esModule: true, - default: ({ - trigger, - onSelect, - }: { - trigger?: (open: boolean) => ReactNode - onSelect?: (type: BlockEnum, value?: ToolDefaultValue) => void - }) => ( - - ), -})) - vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ useLanguage: () => 'en_US', useTextGenerationCurrentProviderAndModelAndModelList: vi.fn(), })) -vi.mock('@/service/use-tools', () => ({ - useAllBuiltInTools: () => ({ data: mockBuiltInTools }), - useAllCustomTools: () => ({ data: mockCustomTools }), - useAllWorkflowTools: () => ({ data: mockWorkflowTools }), -})) - vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({ __esModule: true, default: ({ defaultModel }: { defaultModel?: { provider: string; model: string } }) => ( @@ -263,44 +224,32 @@ vi.mock('../use-config', () => ({ default: vi.fn(), })) +vi.mock('../components/extract-parameter/import-from-tool', () => ({ + __esModule: true, + default: ({ onImport }: { onImport: (params: Param[]) => void }) => ( + + ), +})) + const mockUseTextGeneration = vi.mocked(useTextGenerationCurrentProviderAndModelAndModelList) const mockUseConfig = vi.mocked(useConfig) const mockToastError = vi.mocked(toast.error) -const createToolParameter = (overrides: Partial = {}): ToolParameter => ({ - name: 'city', - label: { en_US: 'City', zh_Hans: '城市' }, - human_description: { en_US: 'City input', zh_Hans: '城市输入' }, - type: ParamType.string, - form: 'llm', - llm_description: 'City name', - required: true, - multiple: false, - default: '', - options: [ - { - value: 'draft', - label: { en_US: 'Draft', zh_Hans: '草稿' }, - }, - ], - ...overrides, -}) - -const createToolInfo = (overrides: Partial = {}): ToolDefaultValue => ({ - provider_id: 'builtin-1', - provider_type: CollectionType.builtIn, - provider_name: 'builtin', - tool_name: 'search', - tool_label: 'Search', - tool_description: 'Search tool', - title: 'Search', - is_team_authorization: false, - params: {}, - paramSchemas: [], - output_schema: {}, - ...overrides, -}) - const createParam = (overrides: Partial = {}): Param => ({ name: 'city', type: ParamType.string, @@ -372,11 +321,6 @@ describe('parameter-extractor path', () => { beforeEach(() => { vi.clearAllMocks() mockToastError.mockClear() - mockBuiltInTools = [] - mockCustomTools = [] - mockWorkflowTools = [] - mockSelectedToolInfo = createToolInfo() - mockBlockSelectorOpen = false mockUseTextGeneration.mockReturnValue({ currentProvider: undefined, currentModel: undefined, @@ -387,160 +331,6 @@ describe('parameter-extractor path', () => { }) describe('Tool import and parameter editing', () => { - it('should import llm parameters from the selected tool', async () => { - const user = userEvent.setup() - const onImport = vi.fn() - - mockBuiltInTools = [ - { - id: 'builtin-1', - tools: [ - { - name: 'search', - parameters: [ - createToolParameter(), - createToolParameter({ - name: 'internal_only', - form: 'form', - }), - ], - }, - ], - }, - ] - - render() - - await user.click( - screen.getByRole('button', { name: /workflow.nodes.parameterExtractor.importFromTool/i }), - ) - - expect(onImport).toHaveBeenCalledWith([ - { - name: 'city', - type: ParamType.string, - required: true, - description: 'City name', - options: ['Draft'], - }, - ]) - }) - - it('should ignore invalid tool selections when importing parameters', async () => { - const user = userEvent.setup() - const onImport = vi.fn() - - mockSelectedToolInfo = undefined - - render() - - await user.click( - screen.getByRole('button', { name: /workflow.nodes.parameterExtractor.importFromTool/i }), - ) - - expect(onImport).not.toHaveBeenCalled() - }) - - it('should import llm parameters from custom and workflow tool collections', async () => { - const user = userEvent.setup() - const onImport = vi.fn() - - mockSelectedToolInfo = createToolInfo({ - provider_id: 'custom-1', - provider_type: CollectionType.custom, - }) - mockCustomTools = [ - { - id: 'custom-1', - tools: [ - { - name: 'search', - parameters: [ - createToolParameter({ name: 'custom_city', llm_description: 'Custom city' }), - ], - }, - ], - }, - ] - - render() - - await user.click( - screen.getByRole('button', { name: /workflow.nodes.parameterExtractor.importFromTool/i }), - ) - - expect(onImport).toHaveBeenLastCalledWith([ - { - name: 'custom_city', - type: ParamType.string, - required: true, - description: 'Custom city', - options: ['Draft'], - }, - ]) - }) - - it('should import llm parameters from workflow tool collections', async () => { - const user = userEvent.setup() - const onImport = vi.fn() - - mockSelectedToolInfo = createToolInfo({ - provider_id: 'workflow-1', - provider_type: CollectionType.workflow, - tool_name: 'transform', - }) - mockWorkflowTools = [ - { - id: 'workflow-1', - tools: [ - { - name: 'transform', - parameters: [ - createToolParameter({ name: 'workflow_city', llm_description: 'Workflow city' }), - ], - }, - ], - }, - ] - - render() - await user.click( - screen.getByRole('button', { name: /workflow.nodes.parameterExtractor.importFromTool/i }), - ) - - expect(onImport).toHaveBeenLastCalledWith([ - { - name: 'workflow_city', - type: ParamType.string, - required: true, - description: 'Workflow city', - options: ['Draft'], - }, - ]) - }) - - it('should highlight the trigger when open and return an empty import for unknown providers', async () => { - const user = userEvent.setup() - const onImport = vi.fn() - - mockBlockSelectorOpen = true - mockSelectedToolInfo = createToolInfo({ - provider_type: 'unknown' as CollectionType, - }) - - render() - - expect(screen.getByText('workflow.nodes.parameterExtractor.importFromTool')).toHaveClass( - 'bg-state-base-hover', - ) - - await user.click( - screen.getByRole('button', { name: /workflow.nodes.parameterExtractor.importFromTool/i }), - ) - - expect(onImport).toHaveBeenCalledWith([]) - }) - it('should show the empty state for an empty parameter list', () => { render() @@ -809,18 +599,6 @@ describe('parameter-extractor path', () => { const handleVisionResolutionEnabledChange = vi.fn() const handleVisionResolutionChange = vi.fn() - mockBuiltInTools = [ - { - id: 'builtin-1', - tools: [ - { - name: 'search', - parameters: [createToolParameter()], - }, - ], - }, - ] - mockUseConfig.mockReturnValueOnce( createConfigResult({ inputs: createData({ diff --git a/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/__tests__/import-from-tool.spec.tsx b/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/__tests__/import-from-tool.spec.tsx index e7c7b52e7d4..a586a671241 100644 --- a/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/__tests__/import-from-tool.spec.tsx +++ b/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/__tests__/import-from-tool.spec.tsx @@ -136,41 +136,62 @@ describe('parameter-extractor/extract-parameter/import-from-tool', () => { }) }) - it('imports llm parameters from workflow tool collections', async () => { - const user = userEvent.setup() - const handleImport = vi.fn() - const provider = createProviderWithTool( - CollectionType.workflow, - 'workflow-1', - 'transform', - 'Workflow Tool', - ) - const workflowParameters = [ - createToolParameter({ - name: 'summary', - llm_description: 'Summary text', - label: { en_US: 'Summary', zh_Hans: 'Summary' }, - human_description: { en_US: 'Summary', zh_Hans: 'Summary' }, - }), - ] satisfies ToolWithProvider['tools'][number]['parameters'] - provider.tools[0]!.parameters = workflowParameters - mockToolCollections.workflow = [provider] + it.each([ + { + collectionType: CollectionType.custom, + providerId: 'custom-1', + toolName: 'lookup', + toolLabel: 'Custom Tool', + parameterName: 'record_id', + description: 'Record identifier', + }, + { + collectionType: CollectionType.workflow, + providerId: 'workflow-1', + toolName: 'transform', + toolLabel: 'Workflow Tool', + parameterName: 'summary', + description: 'Summary text', + }, + ])( + 'imports llm parameters from $collectionType tool collections', + async ({ collectionType, providerId, toolName, toolLabel, parameterName, description }) => { + const user = userEvent.setup() + const handleImport = vi.fn() + const provider = createProviderWithTool(collectionType, providerId, toolName, toolLabel) + const parameters = [ + createToolParameter({ + name: parameterName, + llm_description: description, + label: { en_US: parameterName, zh_Hans: parameterName }, + human_description: { en_US: description, zh_Hans: description }, + }), + ] satisfies ToolWithProvider['tools'][number]['parameters'] + provider.tools[0]!.parameters = parameters + mockToolCollections[collectionType === CollectionType.custom ? 'custom' : 'workflow'] = [ + provider, + ] - renderImportFromTool() + renderImportFromTool() - await user.click(screen.getByText('workflow.nodes.parameterExtractor.importFromTool')) - await user.click(await screen.findByText('Workflow Tool')) + await user.click(screen.getByText('workflow.nodes.parameterExtractor.importFromTool')) + if (collectionType === CollectionType.custom) { + await user.click(screen.getByRole('button', { name: 'workflow.tabs.customTool' })) + await user.click(screen.getByText('Provider One')) + } + await user.click(await screen.findByText(toolLabel)) - await waitFor(() => { - expect(handleImport).toHaveBeenCalledWith([ - { - name: 'summary', - type: 'string', - required: false, - description: 'Summary text', - options: [], - }, - ]) - }) - }) + await waitFor(() => { + expect(handleImport).toHaveBeenCalledWith([ + { + name: parameterName, + type: 'string', + required: false, + description, + options: [], + }, + ]) + }) + }, + ) }) diff --git a/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/import-from-tool.tsx b/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/import-from-tool.tsx index 0a9a754891b..a4265c3589c 100644 --- a/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/import-from-tool.tsx +++ b/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/import-from-tool.tsx @@ -7,6 +7,7 @@ import type { ToolDefaultValue, } from '@/app/components/workflow/block-selector/types' import type { BlockEnum } from '@/app/components/workflow/types' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { memo, useCallback } from 'react' import { useTranslation } from 'react-i18next' @@ -15,6 +16,7 @@ import { CollectionType } from '@/app/components/tools/types' import { useAllBuiltInTools, useAllCustomTools, useAllWorkflowTools } from '@/service/use-tools' import { canFindTool } from '@/utils' import BlockSelector from '../../../../block-selector' +import { TabType } from '../../../../block-selector/types' const i18nPrefix = 'nodes.parameterExtractor' @@ -70,16 +72,13 @@ const ImportFromTool: FC = ({ onImport }) => { const renderTrigger = useCallback( (open: boolean) => { return ( -
-
- {t(($) => $[`${i18nPrefix}.importFromTool`], { ns: 'workflow' })} -
-
+ ) }, [t], @@ -88,13 +87,11 @@ const ImportFromTool: FC = ({ onImport }) => { return ( ) } diff --git a/web/app/components/workflow/nodes/tool/components/tool-form/__tests__/item.spec.tsx b/web/app/components/workflow/nodes/tool/components/tool-form/__tests__/item.spec.tsx index 7667c810c00..eab2a87e540 100644 --- a/web/app/components/workflow/nodes/tool/components/tool-form/__tests__/item.spec.tsx +++ b/web/app/components/workflow/nodes/tool/components/tool-form/__tests__/item.spec.tsx @@ -34,19 +34,22 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () useLanguage: () => mockUseLanguage(), })) -vi.mock('@/app/components/plugins/plugin-detail-panel/tool-selector/components', () => ({ - SchemaModal: (props: MockSchemaModalProps) => { - mockSchemaModal(props) - return props.isShow ? ( -
- {props.rootName} - -
- ) : null - }, -})) +vi.mock( + '@/app/components/plugins/plugin-detail-panel/tool-selector/components/schema-modal', + () => ({ + SchemaModal: (props: MockSchemaModalProps) => { + mockSchemaModal(props) + return props.isShow ? ( +
+ {props.rootName} + +
+ ) : null + }, + }), +) vi.mock('@/app/components/workflow/nodes/_base/components/form-input-item', () => ({ default: (props: MockFormInputItemProps) => { diff --git a/web/app/components/workflow/nodes/tool/components/tool-form/item.tsx b/web/app/components/workflow/nodes/tool/components/tool-form/item.tsx index 0380b4ae3ba..94abfb14f55 100644 --- a/web/app/components/workflow/nodes/tool/components/tool-form/item.tsx +++ b/web/app/components/workflow/nodes/tool/components/tool-form/item.tsx @@ -5,12 +5,11 @@ import type { CredentialFormSchema } from '@/app/components/header/account-setti import type { Tool } from '@/app/components/tools/types' import type { ToolWithProvider } from '@/app/components/workflow/types' import { Button } from '@langgenius/dify-ui/button' -import { RiBracesLine } from '@remixicon/react' import { useBoolean } from 'ahooks' import { Infotip } from '@/app/components/base/infotip' import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' -import { SchemaModal } from '@/app/components/plugins/plugin-detail-panel/tool-selector/components' +import { SchemaModal } from '@/app/components/plugins/plugin-detail-panel/tool-selector/components/schema-modal' import FormInputItem from '@/app/components/workflow/nodes/_base/components/form-input-item' const URL_REGEX = /(https?:\/\/\S+)/g @@ -23,7 +22,7 @@ const renderDescriptionWithLinks = (description: string): ReactNode => { const parts: ReactNode[] = [] let currentIndex = 0 - matches.forEach((match, index) => { + matches.forEach((match) => { const [url] = match const start = match.index ?? 0 @@ -31,7 +30,7 @@ const renderDescriptionWithLinks = (description: string): ReactNode => { parts.push( void - extraParams?: Record + extraParams?: Record providerType?: 'tool' | 'trigger' }> @@ -111,7 +110,7 @@ const ToolFormItem: FC = ({ onClick={showSchema} className="px-1 system-xs-regular text-text-tertiary" > - + JSON Schema diff --git a/web/app/components/workflow/nodes/trigger-plugin/components/trigger-form/item.tsx b/web/app/components/workflow/nodes/trigger-plugin/components/trigger-form/item.tsx index 7b9843d2596..1fbaecd4484 100644 --- a/web/app/components/workflow/nodes/trigger-plugin/components/trigger-form/item.tsx +++ b/web/app/components/workflow/nodes/trigger-plugin/components/trigger-form/item.tsx @@ -5,12 +5,11 @@ import type { Event } from '@/app/components/tools/types' import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types' import type { PluginTriggerVarInputs } from '@/app/components/workflow/nodes/trigger-plugin/types' import { Button } from '@langgenius/dify-ui/button' -import { RiBracesLine } from '@remixicon/react' import { useBoolean } from 'ahooks' import { Infotip } from '@/app/components/base/infotip' import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' -import { SchemaModal } from '@/app/components/plugins/plugin-detail-panel/tool-selector/components' +import { SchemaModal } from '@/app/components/plugins/plugin-detail-panel/tool-selector/components/schema-modal' import FormInputItem from '@/app/components/workflow/nodes/_base/components/form-input-item' type Props = Readonly<{ @@ -22,7 +21,7 @@ type Props = Readonly<{ inPanel?: boolean currentEvent?: Event currentProvider?: TriggerWithProvider - extraParams?: Record + extraParams?: Record disableVariableInsertion?: boolean }> @@ -71,7 +70,7 @@ const TriggerFormItem: FC = ({ onClick={showSchema} className="px-1 system-xs-regular text-text-tertiary" > - + JSON Schema diff --git a/web/app/components/workflow/operator/__tests__/add-block.spec.tsx b/web/app/components/workflow/operator/__tests__/add-block.spec.tsx index 81c4dc85269..fe9f03bda31 100644 --- a/web/app/components/workflow/operator/__tests__/add-block.spec.tsx +++ b/web/app/components/workflow/operator/__tests__/add-block.spec.tsx @@ -12,10 +12,8 @@ type BlockSelectorMockProps = { disabled: boolean onSelect: (type: BlockEnum, pluginDefaultValue?: Record) => void placement: string - offset: { - mainAxis: number - crossAxis: number - } + sideOffset: number + alignOffset: number trigger: (open: boolean) => ReactNode popupClassName: string availableBlocksTypes: BlockEnum[] @@ -152,14 +150,12 @@ describe('AddBlock', () => { availableBlocksTypes: mockAvailableNextBlocks, showStartTab: true, placement: 'right-start', + sideOffset: 4, + alignOffset: -8, popupClassName: 'min-w-[256px]!', isolateKeyboardEvents: undefined, }) expect(latestBlockSelectorProps?.defaultActiveTab).toBeUndefined() - expect(latestBlockSelectorProps?.offset).toEqual({ - mainAxis: 4, - crossAxis: -8, - }) }) it('should hide the start tab for chat mode and rag pipeline flows', async () => { @@ -199,6 +195,18 @@ describe('AddBlock', () => { expect(latestBlockSelectorProps?.isolateKeyboardEvents).toBe(true) }) + + it('should keep the read-only trigger focusable', async () => { + mockNodesReadOnly = true + + renderWithReactFlow([]) + + const trigger = screen.getByRole('button') + expect(trigger).toHaveAttribute('aria-disabled', 'true') + + trigger.focus() + expect(trigger).toHaveFocus() + }) }) // User interactions that bridge selector state and workflow state. diff --git a/web/app/components/workflow/operator/__tests__/control.spec.tsx b/web/app/components/workflow/operator/__tests__/control.spec.tsx index a487bfe4470..8a92d57e6ee 100644 --- a/web/app/components/workflow/operator/__tests__/control.spec.tsx +++ b/web/app/components/workflow/operator/__tests__/control.spec.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from 'react' import { fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { ControlMode } from '../../types' import Control from '../control' @@ -134,14 +135,19 @@ describe('Control', () => { expect(mockHandleLayout).toHaveBeenCalledTimes(1) }) - it('should block note creation when editing is not allowed', () => { + it('should keep read-only actions focusable without activating them', async () => { + const user = userEvent.setup() mockNodesReadOnly = true render() - fireEvent.click( - screen.getByTestId('workflow.nodes.note.addNote').firstElementChild as HTMLElement, - ) + const noteButton = screen.getByTestId('workflow.nodes.note.addNote') + .firstElementChild as HTMLButtonElement + + expect(noteButton).toHaveAttribute('aria-disabled', 'true') + await user.tab() + expect(noteButton).toHaveFocus() + await user.keyboard('{Enter}') expect(mockHandleAddNote).not.toHaveBeenCalled() }) @@ -161,16 +167,19 @@ describe('Control', () => { expect(mockHandleModeComment).toHaveBeenCalledTimes(1) }) - it('should disable comment mode when comment operation is blocked', () => { + it('should keep blocked comment mode focusable without activating it', async () => { + const user = userEvent.setup() mockCanUseCommentMode = false render() const commentButton = screen.getByTestId('workflow.common.commentMode') .firstElementChild as HTMLButtonElement - expect(commentButton).toBeDisabled() + expect(commentButton).toHaveAttribute('aria-disabled', 'true') - fireEvent.click(commentButton) + commentButton.focus() + expect(commentButton).toHaveFocus() + await user.keyboard('{Enter}') expect(mockHandleModeComment).not.toHaveBeenCalled() }) diff --git a/web/app/components/workflow/operator/__tests__/more-actions.spec.tsx b/web/app/components/workflow/operator/__tests__/more-actions.spec.tsx index 54410ed01f1..a31c6afd4a8 100644 --- a/web/app/components/workflow/operator/__tests__/more-actions.spec.tsx +++ b/web/app/components/workflow/operator/__tests__/more-actions.spec.tsx @@ -45,12 +45,28 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', async () => { ), DropdownMenuTrigger: ({ children, + render, className, }: { children: React.ReactNode + render?: React.ReactElement<{ + className?: string + disabled?: boolean + onClick?: React.MouseEventHandler + }> className?: string }) => { const { open, setOpen } = useDropdownMenuContext() + if (render) { + return React.cloneElement( + render, + { + className, + onClick: () => setOpen(!open), + }, + children, + ) + } return ( ) }, - [nodesReadOnly, t], + [nodesReadOnly], ) return ( @@ -113,14 +115,11 @@ const AddBlock = ({ disabled={nodesReadOnly} onSelect={handleSelect} placement="right-start" - offset={ - offset ?? { - mainAxis: 4, - crossAxis: -8, - } - } + sideOffset={sideOffset ?? 4} + alignOffset={alignOffset ?? -8} trigger={renderTrigger || renderTriggerElement} - renderTriggerAsButtonRoot={renderTriggerAsButtonRoot} + triggerAriaLabel={t(($) => $['common.addBlock'], { ns: 'workflow' })} + triggerTooltip={t(($) => $['common.addBlock'], { ns: 'workflow' })} popupClassName="min-w-[256px]!" availableBlocksTypes={availableNextBlocks} showStartTab={showStartTab} diff --git a/web/app/components/workflow/operator/control.tsx b/web/app/components/workflow/operator/control.tsx index 2ad3eef3340..1fdbaf976ab 100644 --- a/web/app/components/workflow/operator/control.tsx +++ b/web/app/components/workflow/operator/control.tsx @@ -1,9 +1,8 @@ import type { MouseEvent } from 'react' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { RiCursorLine, RiFunctionAddLine, RiHand, RiStickyNoteAddLine } from '@remixicon/react' import { memo } from 'react' import { useTranslation } from 'react-i18next' -import { Comment } from '@/app/components/base/icons/src/public/other' import Divider from '../../base/divider' import { useNodesReadOnly, useWorkflowMoveMode, useWorkflowOrganize } from '../hooks' import { useStore } from '../store' @@ -38,80 +37,92 @@ const Control = () => {
$['nodes.note.addNote'], { ns: 'workflow' })}> - + + $['common.pointerMode'], { ns: 'workflow' })} shortcut="workflow.pointer-mode" > - + + $['common.handMode'], { ns: 'workflow' })} shortcut="workflow.hand-mode" > - + + {isCommentModeAvailable && ( $['common.commentMode'], { ns: 'workflow' })} shortcut="workflow.comment-mode" > - + + )} @@ -119,18 +130,21 @@ const Control = () => { title={t(($) => $['panel.organizeBlocks'], { ns: 'workflow' })} shortcut="workflow.organize" > - + +
diff --git a/web/app/components/workflow/operator/more-actions.tsx b/web/app/components/workflow/operator/more-actions.tsx index de424e4673a..f902fa46d36 100644 --- a/web/app/components/workflow/operator/more-actions.tsx +++ b/web/app/components/workflow/operator/more-actions.tsx @@ -1,3 +1,4 @@ +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { DropdownMenu, @@ -6,7 +7,6 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' -import { RiExportLine, RiMoreFill } from '@remixicon/react' import { toJpeg, toPng, toSvg } from 'html-to-image' import { memo, useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -155,20 +155,29 @@ function MoreActions() { setOpen(nextOpen) }} > - - $['common.moreActions'], { ns: 'workflow' })}> - - - + $['common.moreActions'], { ns: 'workflow' })}> + $['common.moreActions'], { ns: 'workflow' })} + disabled={isReadOnly} + focusableWhenDisabled + className={cn( + 'size-8 p-0 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary', + isReadOnly && + 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled', + )} + /> + } + > + + +
- + {t(($) => $['common.exportImage'], { ns: 'workflow' })}
diff --git a/web/app/components/workflow/panel-contextmenu.tsx b/web/app/components/workflow/panel-contextmenu.tsx index df256379c2b..74564c6eafb 100644 --- a/web/app/components/workflow/panel-contextmenu.tsx +++ b/web/app/components/workflow/panel-contextmenu.tsx @@ -83,13 +83,10 @@ export function PanelContextmenu({ onClose }: { onClose: () => void }) { {canEditWorkflow && ( )} {canEditWorkflow && ( diff --git a/web/i18n/ar-TN/plugin-tags.json b/web/i18n/ar-TN/plugin-tags.json index a27d719d762..3a14bb6fb79 100644 --- a/web/i18n/ar-TN/plugin-tags.json +++ b/web/i18n/ar-TN/plugin-tags.json @@ -1,5 +1,6 @@ { "allTags": "كل العلامات", + "clearSelectedTags": "مسح العلامات المحددة: {{tags}}", "searchTags": "البحث في العلامات", "tags.agent": "وكيل", "tags.business": "أعمال", diff --git a/web/i18n/ar-TN/plugin.json b/web/i18n/ar-TN/plugin.json index 181ce8e01a2..282a8ad3652 100644 --- a/web/i18n/ar-TN/plugin.json +++ b/web/i18n/ar-TN/plugin.json @@ -97,6 +97,7 @@ "categorySingle.model": "نموذج", "categorySingle.tool": "أداة", "categorySingle.trigger": "مشغل", + "clearSearch": "مسح {{label}}", "debugInfo.title": "تصحيح الأخطاء", "debugInfo.viewDocs": "عرض المستندات", "deprecated": "مهمل", diff --git a/web/i18n/ar-TN/workflow.json b/web/i18n/ar-TN/workflow.json index 9ed5a8660ba..9dad0b64692 100644 --- a/web/i18n/ar-TN/workflow.json +++ b/web/i18n/ar-TN/workflow.json @@ -1240,11 +1240,13 @@ "tabs.allTool": "الكل", "tabs.allTriggers": "كل المشغلات", "tabs.blocks": "العقد", + "tabs.clearSnippetSearch": "مسح بحث المقاطع", "tabs.createSnippet": "قم بإنشاء مقتطف", "tabs.customTool": "Swagger API", "tabs.featuredTools": "المميزة", "tabs.hideActions": "إخفاء الأدوات", "tabs.installed": "مثبت", + "tabs.listView": "عرض القائمة", "tabs.logic": "المنطق", "tabs.noFeaturedPlugins": "اكتشف المزيد من الأدوات في السوق", "tabs.noFeaturedTriggers": "اكتشف المزيد من المشغلات في السوق", @@ -1270,6 +1272,7 @@ "tabs.startNotSupportedTip": "علامة التبويب \"ابدأ\" غير مدعومة في المقتطفات.", "tabs.tools": "الأدوات", "tabs.transform": "تحويل", + "tabs.treeView": "عرض الشجرة", "tabs.unconfiguredStartDisabledTip": "تمت إضافة عقدة بداية غير مكوّنة إلى اللوحة. أكمل الإعداد قبل المتابعة.", "tabs.usePlugin": "حدد الأداة", "tabs.utilities": "الأدوات المساعدة", diff --git a/web/i18n/de-DE/plugin-tags.json b/web/i18n/de-DE/plugin-tags.json index 534eb865551..ba44bcc88a2 100644 --- a/web/i18n/de-DE/plugin-tags.json +++ b/web/i18n/de-DE/plugin-tags.json @@ -1,5 +1,6 @@ { "allTags": "Alle Schlagwörter", + "clearSelectedTags": "Ausgewählte Tags löschen: {{tags}}", "searchTags": "Such-Tags", "tags.agent": "Agent", "tags.business": "Geschäft", diff --git a/web/i18n/de-DE/plugin.json b/web/i18n/de-DE/plugin.json index d28efaa2206..0677e5dd1c4 100644 --- a/web/i18n/de-DE/plugin.json +++ b/web/i18n/de-DE/plugin.json @@ -97,6 +97,7 @@ "categorySingle.model": "Modell", "categorySingle.tool": "Werkzeug", "categorySingle.trigger": "Auslöser", + "clearSearch": "{{label}} leeren", "debugInfo.title": "Debuggen", "debugInfo.viewDocs": "Dokumente anzeigen", "deprecated": "Abgelehnt", diff --git a/web/i18n/de-DE/workflow.json b/web/i18n/de-DE/workflow.json index 4e5c85ef766..de8028f55d5 100644 --- a/web/i18n/de-DE/workflow.json +++ b/web/i18n/de-DE/workflow.json @@ -1240,11 +1240,13 @@ "tabs.allTool": "Alle", "tabs.allTriggers": "Alle Auslöser", "tabs.blocks": "Knoten", + "tabs.clearSnippetSearch": "Snippet-Suche löschen", "tabs.createSnippet": "Erstellen Sie einen Ausschnitt", "tabs.customTool": "Swagger API", "tabs.featuredTools": "Ausgewählt", "tabs.hideActions": "Werkzeuge ausblenden", "tabs.installed": "Installiert", + "tabs.listView": "Listenansicht", "tabs.logic": "Logik", "tabs.noFeaturedPlugins": "Entdecken Sie weitere Tools im Marktplatz", "tabs.noFeaturedTriggers": "Entdecken Sie weitere Auslöser im Marketplace", @@ -1270,6 +1272,7 @@ "tabs.startNotSupportedTip": "Die Registerkarte „Start“ wird in Snippets nicht unterstützt.", "tabs.tools": "Werkzeuge", "tabs.transform": "Transformieren", + "tabs.treeView": "Baumansicht", "tabs.unconfiguredStartDisabledTip": "Ein nicht konfigurierter Startknoten wurde zur Arbeitsfläche hinzugefügt. Schließen Sie die Einrichtung ab, bevor Sie fortfahren.", "tabs.usePlugin": "Werkzeug auswählen", "tabs.utilities": "Dienstprogramme", diff --git a/web/i18n/en-US/plugin-tags.json b/web/i18n/en-US/plugin-tags.json index 520f6fa3ef3..c4020a1f8af 100644 --- a/web/i18n/en-US/plugin-tags.json +++ b/web/i18n/en-US/plugin-tags.json @@ -1,5 +1,6 @@ { "allTags": "All Tags", + "clearSelectedTags": "Clear selected tags: {{tags}}", "searchTags": "Search Tags", "tags.agent": "Agent", "tags.business": "Business", diff --git a/web/i18n/en-US/plugin.json b/web/i18n/en-US/plugin.json index 4ebcfaed421..2a0b319818d 100644 --- a/web/i18n/en-US/plugin.json +++ b/web/i18n/en-US/plugin.json @@ -97,6 +97,7 @@ "categorySingle.model": "Model", "categorySingle.tool": "Tool", "categorySingle.trigger": "Trigger", + "clearSearch": "Clear {{label}}", "debugInfo.title": "Debugging", "debugInfo.viewDocs": "View Docs", "deprecated": "Deprecated", diff --git a/web/i18n/en-US/workflow.json b/web/i18n/en-US/workflow.json index 6706029c5b6..8f35a0d9a21 100644 --- a/web/i18n/en-US/workflow.json +++ b/web/i18n/en-US/workflow.json @@ -1240,11 +1240,13 @@ "tabs.allTool": "All", "tabs.allTriggers": "All triggers", "tabs.blocks": "Nodes", + "tabs.clearSnippetSearch": "Clear snippet search", "tabs.createSnippet": "Create a snippet", "tabs.customTool": "Swagger API", "tabs.featuredTools": "Featured", "tabs.hideActions": "Hide tools", "tabs.installed": "Installed", + "tabs.listView": "List view", "tabs.logic": "Logic", "tabs.noFeaturedPlugins": "Discover more tools in Marketplace", "tabs.noFeaturedTriggers": "Discover more triggers in Marketplace", @@ -1270,6 +1272,7 @@ "tabs.startNotSupportedTip": "The Start tab is not supported in snippets.", "tabs.tools": "Tools", "tabs.transform": "Transform", + "tabs.treeView": "Tree view", "tabs.unconfiguredStartDisabledTip": "An unconfigured start node has been added to canvas. Please complete the setup before continuing.", "tabs.usePlugin": "Select tool", "tabs.utilities": "Utilities", diff --git a/web/i18n/es-ES/plugin-tags.json b/web/i18n/es-ES/plugin-tags.json index 7d37065cbaf..0c75a209637 100644 --- a/web/i18n/es-ES/plugin-tags.json +++ b/web/i18n/es-ES/plugin-tags.json @@ -1,5 +1,6 @@ { "allTags": "Todas las etiquetas", + "clearSelectedTags": "Borrar etiquetas seleccionadas: {{tags}}", "searchTags": "Etiquetas de búsqueda", "tags.agent": "Agente", "tags.business": "Negocio", diff --git a/web/i18n/es-ES/plugin.json b/web/i18n/es-ES/plugin.json index 9c5792d1f02..1ed5e19b8cb 100644 --- a/web/i18n/es-ES/plugin.json +++ b/web/i18n/es-ES/plugin.json @@ -97,6 +97,7 @@ "categorySingle.model": "Modelo", "categorySingle.tool": "Herramienta", "categorySingle.trigger": "Disparador", + "clearSearch": "Borrar {{label}}", "debugInfo.title": "Depuración", "debugInfo.viewDocs": "Ver documentos", "deprecated": "Obsoleto", diff --git a/web/i18n/es-ES/workflow.json b/web/i18n/es-ES/workflow.json index b01c3e2779a..c9ab300565e 100644 --- a/web/i18n/es-ES/workflow.json +++ b/web/i18n/es-ES/workflow.json @@ -1240,11 +1240,13 @@ "tabs.allTool": "Todos", "tabs.allTriggers": "Todos los desencadenantes", "tabs.blocks": "Nodos", + "tabs.clearSnippetSearch": "Borrar búsqueda de fragmentos", "tabs.createSnippet": "Crear un fragmento", "tabs.customTool": "Swagger API", "tabs.featuredTools": "Destacado", "tabs.hideActions": "Ocultar herramientas", "tabs.installed": "Instalado", + "tabs.listView": "Vista de lista", "tabs.logic": "Lógica", "tabs.noFeaturedPlugins": "Descubre más herramientas en el Marketplace", "tabs.noFeaturedTriggers": "Descubre más desencadenantes en el Marketplace", @@ -1270,6 +1272,7 @@ "tabs.startNotSupportedTip": "La pestaña Inicio no se admite en fragmentos.", "tabs.tools": "Herramientas", "tabs.transform": "Transformar", + "tabs.treeView": "Vista de árbol", "tabs.unconfiguredStartDisabledTip": "Se ha añadido al lienzo un nodo de inicio sin configurar. Completa la configuración antes de continuar.", "tabs.usePlugin": "Seleccionar herramienta", "tabs.utilities": "Utilidades", diff --git a/web/i18n/fa-IR/plugin-tags.json b/web/i18n/fa-IR/plugin-tags.json index 989e342c87a..3a930f04095 100644 --- a/web/i18n/fa-IR/plugin-tags.json +++ b/web/i18n/fa-IR/plugin-tags.json @@ -1,5 +1,6 @@ { "allTags": "همه برچسب ها", + "clearSelectedTags": "پاک کردن برچسب‌های انتخاب‌شده: {{tags}}", "searchTags": "جستجو برچسب ها", "tags.agent": "عامل", "tags.business": "تجاری", diff --git a/web/i18n/fa-IR/plugin.json b/web/i18n/fa-IR/plugin.json index 702125a4996..cef047a8276 100644 --- a/web/i18n/fa-IR/plugin.json +++ b/web/i18n/fa-IR/plugin.json @@ -97,6 +97,7 @@ "categorySingle.model": "مدل", "categorySingle.tool": "ابزار", "categorySingle.trigger": "محرک", + "clearSearch": "پاک کردن {{label}}", "debugInfo.title": "اشکال زدایی", "debugInfo.viewDocs": "مشاهده اسناد", "deprecated": "منسوخ شده", diff --git a/web/i18n/fa-IR/workflow.json b/web/i18n/fa-IR/workflow.json index 7449b104be0..e37e2919295 100644 --- a/web/i18n/fa-IR/workflow.json +++ b/web/i18n/fa-IR/workflow.json @@ -1240,11 +1240,13 @@ "tabs.allTool": "همه", "tabs.allTriggers": "همه تریگرها", "tabs.blocks": "گره‌ها", + "tabs.clearSnippetSearch": "پاک کردن جستجوی قطعات", "tabs.createSnippet": "یک قطعه ایجاد کنید", "tabs.customTool": "Swagger API", "tabs.featuredTools": "برگزیده", "tabs.hideActions": "مخفی کردن ابزارها", "tabs.installed": "نصب‌شده", + "tabs.listView": "نمای فهرست", "tabs.logic": "منطق", "tabs.noFeaturedPlugins": "ابزارهای بیشتر را در بازارچه پیدا کنید", "tabs.noFeaturedTriggers": "تریگرهای بیشتر را در بازارچه پیدا کنید", @@ -1270,6 +1272,7 @@ "tabs.startNotSupportedTip": "تب Start در قطعه‌ها پشتیبانی نمی‌شود.", "tabs.tools": "ابزارها", "tabs.transform": "تبدیل", + "tabs.treeView": "نمای درختی", "tabs.unconfiguredStartDisabledTip": "یک گره شروع پیکربندی‌نشده به بوم اضافه شده است. پیش از ادامه، تنظیمات را کامل کنید.", "tabs.usePlugin": "انتخاب ابزار", "tabs.utilities": "ابزارهای کاربردی", diff --git a/web/i18n/fr-FR/plugin-tags.json b/web/i18n/fr-FR/plugin-tags.json index a855e6c4a8a..792984b36db 100644 --- a/web/i18n/fr-FR/plugin-tags.json +++ b/web/i18n/fr-FR/plugin-tags.json @@ -1,5 +1,6 @@ { "allTags": "Tous les mots-clés", + "clearSelectedTags": "Effacer les tags sélectionnés : {{tags}}", "searchTags": "Mots-clés de recherche", "tags.agent": "Agent", "tags.business": "Affaire", diff --git a/web/i18n/fr-FR/plugin.json b/web/i18n/fr-FR/plugin.json index 97ab7c6ea0b..82f11bd62c2 100644 --- a/web/i18n/fr-FR/plugin.json +++ b/web/i18n/fr-FR/plugin.json @@ -97,6 +97,7 @@ "categorySingle.model": "Modèle", "categorySingle.tool": "Outil", "categorySingle.trigger": "Déclencheur", + "clearSearch": "Effacer {{label}}", "debugInfo.title": "Débogage", "debugInfo.viewDocs": "Voir la documentation", "deprecated": "Obsolète", diff --git a/web/i18n/fr-FR/workflow.json b/web/i18n/fr-FR/workflow.json index a1151b26744..52b1fa0ea3e 100644 --- a/web/i18n/fr-FR/workflow.json +++ b/web/i18n/fr-FR/workflow.json @@ -1240,11 +1240,13 @@ "tabs.allTool": "Tous", "tabs.allTriggers": "Tous les déclencheurs", "tabs.blocks": "Nœuds", + "tabs.clearSnippetSearch": "Effacer la recherche d’extraits", "tabs.createSnippet": "Créer un extrait", "tabs.customTool": "Swagger API", "tabs.featuredTools": "En vedette", "tabs.hideActions": "Cacher les outils", "tabs.installed": "Installé", + "tabs.listView": "Vue en liste", "tabs.logic": "Logique", "tabs.noFeaturedPlugins": "Découvrez plus d'outils dans le Marketplace", "tabs.noFeaturedTriggers": "Découvrez plus de déclencheurs dans le Marketplace", @@ -1270,6 +1272,7 @@ "tabs.startNotSupportedTip": "L'onglet Démarrer n'est pas pris en charge dans les extraits de code.", "tabs.tools": "Outils", "tabs.transform": "Transformer", + "tabs.treeView": "Vue arborescente", "tabs.unconfiguredStartDisabledTip": "Un nœud de départ non configuré a été ajouté au canevas. Terminez la configuration avant de continuer.", "tabs.usePlugin": "Sélectionner l'outil", "tabs.utilities": "Utilitaires", diff --git a/web/i18n/hi-IN/plugin-tags.json b/web/i18n/hi-IN/plugin-tags.json index bb307b69633..dcfcc4303ad 100644 --- a/web/i18n/hi-IN/plugin-tags.json +++ b/web/i18n/hi-IN/plugin-tags.json @@ -1,5 +1,6 @@ { "allTags": "सभी टैग", + "clearSelectedTags": "चुने गए टैग साफ़ करें: {{tags}}", "searchTags": "खोज टैग", "tags.agent": "एजेंट", "tags.business": "व्यवसाय", diff --git a/web/i18n/hi-IN/plugin.json b/web/i18n/hi-IN/plugin.json index 4c9879d5dc0..cd6216d8ef7 100644 --- a/web/i18n/hi-IN/plugin.json +++ b/web/i18n/hi-IN/plugin.json @@ -97,6 +97,7 @@ "categorySingle.model": "मॉडल", "categorySingle.tool": "उपकरण", "categorySingle.trigger": "ट्रिगर", + "clearSearch": "{{label}} साफ़ करें", "debugInfo.title": "डिबगिंग", "debugInfo.viewDocs": "दस्तावेज़ देखें", "deprecated": "अनुशंसित नहीं", diff --git a/web/i18n/hi-IN/workflow.json b/web/i18n/hi-IN/workflow.json index a60259ac69b..e3cf845ad39 100644 --- a/web/i18n/hi-IN/workflow.json +++ b/web/i18n/hi-IN/workflow.json @@ -1240,11 +1240,13 @@ "tabs.allTool": "सभी", "tabs.allTriggers": "सभी ट्रिगर्स", "tabs.blocks": "नोड्स", + "tabs.clearSnippetSearch": "स्निपेट खोज साफ़ करें", "tabs.createSnippet": "एक स्निपेट बनाएं", "tabs.customTool": "Swagger API", "tabs.featuredTools": "विशेष रूप से प्रदर्शित", "tabs.hideActions": "उपकरण छुपाएँ", "tabs.installed": "स्थापित", + "tabs.listView": "सूची दृश्य", "tabs.logic": "तर्क", "tabs.noFeaturedPlugins": "मार्केटप्लेस में और उपकरणों की खोज करें", "tabs.noFeaturedTriggers": "मार्केटप्लेस में और ट्रिगर खोजें", @@ -1270,6 +1272,7 @@ "tabs.startNotSupportedTip": "स्निपेट्स में स्टार्ट टैब समर्थित नहीं है।", "tabs.tools": "टूल्स", "tabs.transform": "परिवर्तन", + "tabs.treeView": "ट्री दृश्य", "tabs.unconfiguredStartDisabledTip": "कैनवास में एक असंरचित प्रारंभ नोड जोड़ा गया है। जारी रखने से पहले सेटअप पूरा करें।", "tabs.usePlugin": "उपकरण चुनें", "tabs.utilities": "उपयोगिताएं", diff --git a/web/i18n/id-ID/plugin-tags.json b/web/i18n/id-ID/plugin-tags.json index 68463b16e57..4794392fd19 100644 --- a/web/i18n/id-ID/plugin-tags.json +++ b/web/i18n/id-ID/plugin-tags.json @@ -1,5 +1,6 @@ { "allTags": "Semua Tag", + "clearSelectedTags": "Hapus tag yang dipilih: {{tags}}", "searchTags": "Tag Pencarian", "tags.agent": "Agen", "tags.business": "Bisnis", diff --git a/web/i18n/id-ID/plugin.json b/web/i18n/id-ID/plugin.json index a98c216d1a3..cffd482e2ca 100644 --- a/web/i18n/id-ID/plugin.json +++ b/web/i18n/id-ID/plugin.json @@ -97,6 +97,7 @@ "categorySingle.model": "Pola", "categorySingle.tool": "Alat", "categorySingle.trigger": "Pemicu", + "clearSearch": "Hapus {{label}}", "debugInfo.title": "Debugging", "debugInfo.viewDocs": "Lihat Dokumen", "deprecated": "Usang", diff --git a/web/i18n/id-ID/workflow.json b/web/i18n/id-ID/workflow.json index 0bfdaba0f7f..5fe6e34f2b3 100644 --- a/web/i18n/id-ID/workflow.json +++ b/web/i18n/id-ID/workflow.json @@ -1240,11 +1240,13 @@ "tabs.allTool": "Semua", "tabs.allTriggers": "Semua pemicu", "tabs.blocks": "Node", + "tabs.clearSnippetSearch": "Hapus pencarian cuplikan", "tabs.createSnippet": "Buat cuplikan", "tabs.customTool": "Swagger API", "tabs.featuredTools": "Unggulan", "tabs.hideActions": "Sembunyikan alat", "tabs.installed": "Terpasang", + "tabs.listView": "Tampilan daftar", "tabs.logic": "Logika", "tabs.noFeaturedPlugins": "Temukan lebih banyak alat di Marketplace", "tabs.noFeaturedTriggers": "Temukan lebih banyak pemicu di Marketplace", @@ -1270,6 +1272,7 @@ "tabs.startNotSupportedTip": "Tab Mulai tidak didukung dalam cuplikan.", "tabs.tools": "Perkakas", "tabs.transform": "Mengubah", + "tabs.treeView": "Tampilan pohon", "tabs.unconfiguredStartDisabledTip": "Node awal yang belum dikonfigurasi telah ditambahkan ke kanvas. Selesaikan penyiapan sebelum melanjutkan.", "tabs.usePlugin": "Pilih alat", "tabs.utilities": "Utilitas", diff --git a/web/i18n/it-IT/plugin-tags.json b/web/i18n/it-IT/plugin-tags.json index a14f06675a4..207a1c0b118 100644 --- a/web/i18n/it-IT/plugin-tags.json +++ b/web/i18n/it-IT/plugin-tags.json @@ -1,5 +1,6 @@ { "allTags": "Tutti i tag", + "clearSelectedTags": "Cancella tag selezionati: {{tags}}", "searchTags": "Cerca Tag", "tags.agent": "Agente", "tags.business": "Azienda", diff --git a/web/i18n/it-IT/plugin.json b/web/i18n/it-IT/plugin.json index 2518d1b5099..b0dace55b44 100644 --- a/web/i18n/it-IT/plugin.json +++ b/web/i18n/it-IT/plugin.json @@ -97,6 +97,7 @@ "categorySingle.model": "Modello", "categorySingle.tool": "Strumento", "categorySingle.trigger": "Innescare", + "clearSearch": "Cancella {{label}}", "debugInfo.title": "Debug", "debugInfo.viewDocs": "Visualizza documenti", "deprecated": "Deprecato", diff --git a/web/i18n/it-IT/workflow.json b/web/i18n/it-IT/workflow.json index 36848c664bf..2679d236b04 100644 --- a/web/i18n/it-IT/workflow.json +++ b/web/i18n/it-IT/workflow.json @@ -1240,11 +1240,13 @@ "tabs.allTool": "Tutti", "tabs.allTriggers": "Tutti i trigger", "tabs.blocks": "Nodi", + "tabs.clearSnippetSearch": "Cancella ricerca snippet", "tabs.createSnippet": "Crea uno snippet", "tabs.customTool": "Swagger API", "tabs.featuredTools": "In evidenza", "tabs.hideActions": "Nascondi strumenti", "tabs.installed": "Installato", + "tabs.listView": "Vista elenco", "tabs.logic": "Logica", "tabs.noFeaturedPlugins": "Scopri altri strumenti nel Marketplace", "tabs.noFeaturedTriggers": "Scopri altri trigger nel Marketplace", @@ -1270,6 +1272,7 @@ "tabs.startNotSupportedTip": "La scheda Start non è supportata negli snippet.", "tabs.tools": "Strumenti", "tabs.transform": "Trasforma", + "tabs.treeView": "Vista ad albero", "tabs.unconfiguredStartDisabledTip": "Un nodo iniziale non configurato è stato aggiunto alla tela. Completa la configurazione prima di continuare.", "tabs.usePlugin": "Strumento di selezione", "tabs.utilities": "Utility", diff --git a/web/i18n/ja-JP/plugin-tags.json b/web/i18n/ja-JP/plugin-tags.json index 44acb2fab0d..77ad516bb75 100644 --- a/web/i18n/ja-JP/plugin-tags.json +++ b/web/i18n/ja-JP/plugin-tags.json @@ -1,5 +1,6 @@ { "allTags": "すべてのタグ", + "clearSelectedTags": "選択したタグをクリア: {{tags}}", "searchTags": "検索タグ", "tags.agent": "エージェント", "tags.business": "ビジネス", diff --git a/web/i18n/ja-JP/plugin.json b/web/i18n/ja-JP/plugin.json index b337cd1ada2..6f55c0fef40 100644 --- a/web/i18n/ja-JP/plugin.json +++ b/web/i18n/ja-JP/plugin.json @@ -97,6 +97,7 @@ "categorySingle.model": "モデル", "categorySingle.tool": "ツール", "categorySingle.trigger": "トリガー", + "clearSearch": "{{label}}をクリア", "debugInfo.title": "デバッグ", "debugInfo.viewDocs": "ドキュメントを見る", "deprecated": "非推奨", diff --git a/web/i18n/ja-JP/workflow.json b/web/i18n/ja-JP/workflow.json index 2cc7269e601..6204ccf87d6 100644 --- a/web/i18n/ja-JP/workflow.json +++ b/web/i18n/ja-JP/workflow.json @@ -1240,11 +1240,13 @@ "tabs.allTool": "すべて", "tabs.allTriggers": "すべてのトリガー", "tabs.blocks": "ブロック", + "tabs.clearSnippetSearch": "スニペット検索をクリア", "tabs.createSnippet": "スニペットを作成する", "tabs.customTool": "Swagger API", "tabs.featuredTools": "特集", "tabs.hideActions": "ツールを隠す", "tabs.installed": "インストール済み", + "tabs.listView": "リスト表示", "tabs.logic": "ロジック", "tabs.noFeaturedPlugins": "マーケットプレイスでさらにツールを見つける", "tabs.noFeaturedTriggers": "マーケットプレイスでさらにトリガーを見つける", @@ -1270,6 +1272,7 @@ "tabs.startNotSupportedTip": "[スタート] タブはスニペットではサポートされていません。", "tabs.tools": "ツール", "tabs.transform": "変換", + "tabs.treeView": "ツリー表示", "tabs.unconfiguredStartDisabledTip": "未設定の開始ノードがキャンバスに追加されています。続行する前に設定を完了してください。", "tabs.usePlugin": "ツールを選択", "tabs.utilities": "ツール", diff --git a/web/i18n/ko-KR/plugin-tags.json b/web/i18n/ko-KR/plugin-tags.json index 53571f3e192..b133d0ee41c 100644 --- a/web/i18n/ko-KR/plugin-tags.json +++ b/web/i18n/ko-KR/plugin-tags.json @@ -1,5 +1,6 @@ { "allTags": "모든 태그", + "clearSelectedTags": "선택한 태그 지우기: {{tags}}", "searchTags": "검색 태그", "tags.agent": "에이전트", "tags.business": "사업", diff --git a/web/i18n/ko-KR/plugin.json b/web/i18n/ko-KR/plugin.json index 5cf37f6039f..687f57039a4 100644 --- a/web/i18n/ko-KR/plugin.json +++ b/web/i18n/ko-KR/plugin.json @@ -97,6 +97,7 @@ "categorySingle.model": "모델", "categorySingle.tool": "도구", "categorySingle.trigger": "트리거", + "clearSearch": "{{label}} 지우기", "debugInfo.title": "디버깅", "debugInfo.viewDocs": "문서 보기", "deprecated": "사용 중단됨", diff --git a/web/i18n/ko-KR/workflow.json b/web/i18n/ko-KR/workflow.json index b6d4fe0ae95..b9f0eed2871 100644 --- a/web/i18n/ko-KR/workflow.json +++ b/web/i18n/ko-KR/workflow.json @@ -1240,11 +1240,13 @@ "tabs.allTool": "전체", "tabs.allTriggers": "모든 트리거", "tabs.blocks": "노드", + "tabs.clearSnippetSearch": "스니펫 검색 지우기", "tabs.createSnippet": "조각 만들기", "tabs.customTool": "Swagger API", "tabs.featuredTools": "추천", "tabs.hideActions": "도구 숨기기", "tabs.installed": "설치됨", + "tabs.listView": "목록 보기", "tabs.logic": "논리", "tabs.noFeaturedPlugins": "마켓플레이스에서 더 많은 도구를 발견하세요", "tabs.noFeaturedTriggers": "마켓플레이스에서 더 많은 트리거 발견하기", @@ -1270,6 +1272,7 @@ "tabs.startNotSupportedTip": "시작 탭은 조각에서 지원되지 않습니다.", "tabs.tools": "도구", "tabs.transform": "변환", + "tabs.treeView": "트리 보기", "tabs.unconfiguredStartDisabledTip": "구성되지 않은 시작 노드가 캔버스에 추가되었습니다. 계속하기 전에 설정을 완료하세요.", "tabs.usePlugin": "도구 선택", "tabs.utilities": "유틸리티", diff --git a/web/i18n/nl-NL/plugin-tags.json b/web/i18n/nl-NL/plugin-tags.json index 520f6fa3ef3..a2e326cb7ca 100644 --- a/web/i18n/nl-NL/plugin-tags.json +++ b/web/i18n/nl-NL/plugin-tags.json @@ -1,5 +1,6 @@ { "allTags": "All Tags", + "clearSelectedTags": "Geselecteerde tags wissen: {{tags}}", "searchTags": "Search Tags", "tags.agent": "Agent", "tags.business": "Business", diff --git a/web/i18n/nl-NL/plugin.json b/web/i18n/nl-NL/plugin.json index 02482550973..e2d2d728a7d 100644 --- a/web/i18n/nl-NL/plugin.json +++ b/web/i18n/nl-NL/plugin.json @@ -97,6 +97,7 @@ "categorySingle.model": "Model", "categorySingle.tool": "Tool", "categorySingle.trigger": "Trigger", + "clearSearch": "{{label}} wissen", "debugInfo.title": "Debugging", "debugInfo.viewDocs": "View Docs", "deprecated": "Verouderd", diff --git a/web/i18n/nl-NL/workflow.json b/web/i18n/nl-NL/workflow.json index 0ce2ec2b6d0..b43b470ea3d 100644 --- a/web/i18n/nl-NL/workflow.json +++ b/web/i18n/nl-NL/workflow.json @@ -1240,11 +1240,13 @@ "tabs.allTool": "All", "tabs.allTriggers": "All triggers", "tabs.blocks": "Nodes", + "tabs.clearSnippetSearch": "Zoekopdracht voor fragmenten wissen", "tabs.createSnippet": "Maak een fragment", "tabs.customTool": "Swagger API", "tabs.featuredTools": "Featured", "tabs.hideActions": "Hide tools", "tabs.installed": "Installed", + "tabs.listView": "Lijstweergave", "tabs.logic": "Logic", "tabs.noFeaturedPlugins": "Discover more tools in Marketplace", "tabs.noFeaturedTriggers": "Discover more triggers in Marketplace", @@ -1270,6 +1272,7 @@ "tabs.startNotSupportedTip": "Het tabblad Start wordt niet ondersteund in fragmenten.", "tabs.tools": "Tools", "tabs.transform": "Transform", + "tabs.treeView": "Boomweergave", "tabs.unconfiguredStartDisabledTip": "Er is een niet-geconfigureerde startnode aan het canvas toegevoegd. Voltooi de configuratie voordat je doorgaat.", "tabs.usePlugin": "Select tool", "tabs.utilities": "Utilities", diff --git a/web/i18n/pl-PL/plugin-tags.json b/web/i18n/pl-PL/plugin-tags.json index 5c4117ab707..834fb6eee4f 100644 --- a/web/i18n/pl-PL/plugin-tags.json +++ b/web/i18n/pl-PL/plugin-tags.json @@ -1,5 +1,6 @@ { "allTags": "Wszystkie tagi", + "clearSelectedTags": "Wyczyść wybrane tagi: {{tags}}", "searchTags": "Szukaj tagów", "tags.agent": "Agent", "tags.business": "Biznes", diff --git a/web/i18n/pl-PL/plugin.json b/web/i18n/pl-PL/plugin.json index 251050d729e..09a555be8a1 100644 --- a/web/i18n/pl-PL/plugin.json +++ b/web/i18n/pl-PL/plugin.json @@ -97,6 +97,7 @@ "categorySingle.model": "Model", "categorySingle.tool": "Narzędzie", "categorySingle.trigger": "Spust", + "clearSearch": "Wyczyść {{label}}", "debugInfo.title": "Debugowanie", "debugInfo.viewDocs": "Wyświetlanie dokumentów", "deprecated": "Nieaktualny", diff --git a/web/i18n/pl-PL/workflow.json b/web/i18n/pl-PL/workflow.json index 3c82d8afefa..3a052ae1efe 100644 --- a/web/i18n/pl-PL/workflow.json +++ b/web/i18n/pl-PL/workflow.json @@ -1240,11 +1240,13 @@ "tabs.allTool": "Wszystkie", "tabs.allTriggers": "Wszystkie wyzwalacze", "tabs.blocks": "Węzły", + "tabs.clearSnippetSearch": "Wyczyść wyszukiwanie fragmentów", "tabs.createSnippet": "Utwórz fragment", "tabs.customTool": "Swagger API", "tabs.featuredTools": "Polecane", "tabs.hideActions": "Ukryj narzędzia", "tabs.installed": "Zainstalowano", + "tabs.listView": "Widok listy", "tabs.logic": "Logika", "tabs.noFeaturedPlugins": "Odkryj więcej narzędzi w Marketplace", "tabs.noFeaturedTriggers": "Odkryj więcej wyzwalaczy w Marketplace", @@ -1270,6 +1272,7 @@ "tabs.startNotSupportedTip": "Karta Start nie jest obsługiwana we fragmentach.", "tabs.tools": "Narzędzia", "tabs.transform": "Transformacja", + "tabs.treeView": "Widok drzewa", "tabs.unconfiguredStartDisabledTip": "Do obszaru roboczego dodano nieskonfigurowany węzeł startowy. Przed kontynuowaniem dokończ konfigurację.", "tabs.usePlugin": "Wybierz narzędzie", "tabs.utilities": "Narzędzia pomocnicze", diff --git a/web/i18n/pt-BR/plugin-tags.json b/web/i18n/pt-BR/plugin-tags.json index b35ea31943b..488585f3654 100644 --- a/web/i18n/pt-BR/plugin-tags.json +++ b/web/i18n/pt-BR/plugin-tags.json @@ -1,5 +1,6 @@ { "allTags": "Todas as tags", + "clearSelectedTags": "Limpar tags selecionadas: {{tags}}", "searchTags": "Tags de pesquisa", "tags.agent": "Agente", "tags.business": "Negócio", diff --git a/web/i18n/pt-BR/plugin.json b/web/i18n/pt-BR/plugin.json index 0c688fb25bc..d6aaad094c4 100644 --- a/web/i18n/pt-BR/plugin.json +++ b/web/i18n/pt-BR/plugin.json @@ -97,6 +97,7 @@ "categorySingle.model": "Modelo", "categorySingle.tool": "Ferramenta", "categorySingle.trigger": "Gatilho", + "clearSearch": "Limpar {{label}}", "debugInfo.title": "Depuração", "debugInfo.viewDocs": "Ver documentos", "deprecated": "Obsoleto", diff --git a/web/i18n/pt-BR/workflow.json b/web/i18n/pt-BR/workflow.json index d3fd2df9ed8..a56d58e4509 100644 --- a/web/i18n/pt-BR/workflow.json +++ b/web/i18n/pt-BR/workflow.json @@ -1240,11 +1240,13 @@ "tabs.allTool": "Todos", "tabs.allTriggers": "Todos os gatilhos", "tabs.blocks": "Nodos", + "tabs.clearSnippetSearch": "Limpar busca de trechos", "tabs.createSnippet": "Crie um trecho", "tabs.customTool": "Swagger API", "tabs.featuredTools": "Em destaque", "tabs.hideActions": "Ocultar ferramentas", "tabs.installed": "Instalado", + "tabs.listView": "Visualização em lista", "tabs.logic": "Lógica", "tabs.noFeaturedPlugins": "Descubra mais ferramentas no Marketplace", "tabs.noFeaturedTriggers": "Descubra mais gatilhos no Marketplace", @@ -1270,6 +1272,7 @@ "tabs.startNotSupportedTip": "A guia Iniciar não é compatível com snippets.", "tabs.tools": "Ferramentas", "tabs.transform": "Transformar", + "tabs.treeView": "Visualização em árvore", "tabs.unconfiguredStartDisabledTip": "Um nó inicial não configurado foi adicionado à tela. Conclua a configuração antes de continuar.", "tabs.usePlugin": "Selecionar ferramenta", "tabs.utilities": "Utilitários", diff --git a/web/i18n/ro-RO/plugin-tags.json b/web/i18n/ro-RO/plugin-tags.json index 756b6f19714..ff9a9bb3e89 100644 --- a/web/i18n/ro-RO/plugin-tags.json +++ b/web/i18n/ro-RO/plugin-tags.json @@ -1,5 +1,6 @@ { "allTags": "Toate etichetele", + "clearSelectedTags": "Șterge etichetele selectate: {{tags}}", "searchTags": "Etichete de căutare", "tags.agent": "Agent", "tags.business": "Afacere", diff --git a/web/i18n/ro-RO/plugin.json b/web/i18n/ro-RO/plugin.json index 437d59a4fb6..8f3eda80b5d 100644 --- a/web/i18n/ro-RO/plugin.json +++ b/web/i18n/ro-RO/plugin.json @@ -97,6 +97,7 @@ "categorySingle.model": "Model", "categorySingle.tool": "Unealtă", "categorySingle.trigger": "Declanșator", + "clearSearch": "Șterge {{label}}", "debugInfo.title": "Depanare", "debugInfo.viewDocs": "Vizualizați documentele", "deprecated": "Încetat de a mai fi utilizat", diff --git a/web/i18n/ro-RO/workflow.json b/web/i18n/ro-RO/workflow.json index 799d15dcd16..d1acc740067 100644 --- a/web/i18n/ro-RO/workflow.json +++ b/web/i18n/ro-RO/workflow.json @@ -1240,11 +1240,13 @@ "tabs.allTool": "Toate", "tabs.allTriggers": "Toate declanșatoarele", "tabs.blocks": "Noduri", + "tabs.clearSnippetSearch": "Șterge căutarea fragmentelor", "tabs.createSnippet": "Creați un fragment", "tabs.customTool": "Swagger API", "tabs.featuredTools": "Recomandat", "tabs.hideActions": "Ascunde uneltele", "tabs.installed": "Instalat", + "tabs.listView": "Vizualizare listă", "tabs.logic": "Logică", "tabs.noFeaturedPlugins": "Descoperă mai multe instrumente în Marketplace", "tabs.noFeaturedTriggers": "Descoperă mai multe declanșatoare în Marketplace", @@ -1270,6 +1272,7 @@ "tabs.startNotSupportedTip": "Fila Start nu este acceptată în fragmente.", "tabs.tools": "Instrumente", "tabs.transform": "Transformare", + "tabs.treeView": "Vizualizare arborescentă", "tabs.unconfiguredStartDisabledTip": "Un nod de pornire neconfigurat a fost adăugat pe canvas. Finalizați configurarea înainte de a continua.", "tabs.usePlugin": "Selectează instrumentul", "tabs.utilities": "Utilități", diff --git a/web/i18n/ru-RU/plugin-tags.json b/web/i18n/ru-RU/plugin-tags.json index 6b8faed3d28..3025dbddd51 100644 --- a/web/i18n/ru-RU/plugin-tags.json +++ b/web/i18n/ru-RU/plugin-tags.json @@ -1,5 +1,6 @@ { "allTags": "Все теги", + "clearSelectedTags": "Очистить выбранные теги: {{tags}}", "searchTags": "Поиск тегов", "tags.agent": "Агент", "tags.business": "Дело", diff --git a/web/i18n/ru-RU/plugin.json b/web/i18n/ru-RU/plugin.json index a86914f6eee..3532ac4636c 100644 --- a/web/i18n/ru-RU/plugin.json +++ b/web/i18n/ru-RU/plugin.json @@ -97,6 +97,7 @@ "categorySingle.model": "Модель", "categorySingle.tool": "Инструмент", "categorySingle.trigger": "Спусковой крючок", + "clearSearch": "Очистить {{label}}", "debugInfo.title": "Отладка", "debugInfo.viewDocs": "Просмотр документации", "deprecated": "Устаревший", diff --git a/web/i18n/ru-RU/workflow.json b/web/i18n/ru-RU/workflow.json index 4fa25d5b1cf..bb656c122bf 100644 --- a/web/i18n/ru-RU/workflow.json +++ b/web/i18n/ru-RU/workflow.json @@ -1240,11 +1240,13 @@ "tabs.allTool": "Все", "tabs.allTriggers": "Все триггеры", "tabs.blocks": "Узлы", + "tabs.clearSnippetSearch": "Очистить поиск фрагментов", "tabs.createSnippet": "Создать фрагмент", "tabs.customTool": "Swagger API", "tabs.featuredTools": "Избранное", "tabs.hideActions": "Скрыть инструменты", "tabs.installed": "Установлено", + "tabs.listView": "Список", "tabs.logic": "Логика", "tabs.noFeaturedPlugins": "Откройте больше инструментов в Маркетплейсе", "tabs.noFeaturedTriggers": "Откройте больше триггеров на Маркетплейсе", @@ -1270,6 +1272,7 @@ "tabs.startNotSupportedTip": "Вкладка «Пуск» не поддерживается во фрагментах.", "tabs.tools": "Инструменты", "tabs.transform": "Преобразование", + "tabs.treeView": "Дерево", "tabs.unconfiguredStartDisabledTip": "На холст добавлен ненастроенный начальный узел. Завершите настройку, прежде чем продолжить.", "tabs.usePlugin": "Выбрать инструмент", "tabs.utilities": "Утилиты", diff --git a/web/i18n/sl-SI/plugin-tags.json b/web/i18n/sl-SI/plugin-tags.json index bdf0d148523..e7b33b4641b 100644 --- a/web/i18n/sl-SI/plugin-tags.json +++ b/web/i18n/sl-SI/plugin-tags.json @@ -1,5 +1,6 @@ { "allTags": "Vse nalepke", + "clearSelectedTags": "Počisti izbrane oznake: {{tags}}", "searchTags": "Iskalne oznake", "tags.agent": "Agent", "tags.business": "Poslovanje", diff --git a/web/i18n/sl-SI/plugin.json b/web/i18n/sl-SI/plugin.json index fa7e41c3a9a..a6cc22b31fa 100644 --- a/web/i18n/sl-SI/plugin.json +++ b/web/i18n/sl-SI/plugin.json @@ -97,6 +97,7 @@ "categorySingle.model": "Model", "categorySingle.tool": "Orodje", "categorySingle.trigger": "Sprožilec", + "clearSearch": "Počisti {{label}}", "debugInfo.title": "Odpravljanje napak", "debugInfo.viewDocs": "Oglejte si dokumente", "deprecated": "Zastaran", diff --git a/web/i18n/sl-SI/workflow.json b/web/i18n/sl-SI/workflow.json index d6ef82e11ea..1a2fc56648d 100644 --- a/web/i18n/sl-SI/workflow.json +++ b/web/i18n/sl-SI/workflow.json @@ -1240,11 +1240,13 @@ "tabs.allTool": "Vse", "tabs.allTriggers": "Vsi sprožilci", "tabs.blocks": "Vozlišča", + "tabs.clearSnippetSearch": "Počisti iskanje izrezkov", "tabs.createSnippet": "Ustvari izrezek", "tabs.customTool": "Swagger API", "tabs.featuredTools": "Priporočeno", "tabs.hideActions": "Skrij orodja", "tabs.installed": "Nameščeno", + "tabs.listView": "Pogled seznama", "tabs.logic": "Logika", "tabs.noFeaturedPlugins": "Odkrijte več orodij na Tržnici", "tabs.noFeaturedTriggers": "Odkrijte več sprožilcev na Tržnici", @@ -1270,6 +1272,7 @@ "tabs.startNotSupportedTip": "Zavihek Start ni podprt v izrezkih.", "tabs.tools": "Orodja", "tabs.transform": "Pretvori", + "tabs.treeView": "Drevesni pogled", "tabs.unconfiguredStartDisabledTip": "Na platno je bilo dodano nekonfigurirano začetno vozlišče. Pred nadaljevanjem dokončajte nastavitev.", "tabs.usePlugin": "Izberi orodje", "tabs.utilities": "Komunalne storitve", diff --git a/web/i18n/th-TH/plugin-tags.json b/web/i18n/th-TH/plugin-tags.json index 0e78e911819..75aa0c31195 100644 --- a/web/i18n/th-TH/plugin-tags.json +++ b/web/i18n/th-TH/plugin-tags.json @@ -1,5 +1,6 @@ { "allTags": "แท็กทั้งหมด", + "clearSelectedTags": "ล้างแท็กที่เลือก: {{tags}}", "searchTags": "แท็กค้นหา", "tags.agent": "ตัวแทน", "tags.business": "ธุรกิจ", diff --git a/web/i18n/th-TH/plugin.json b/web/i18n/th-TH/plugin.json index a1de1513efb..35cd960763b 100644 --- a/web/i18n/th-TH/plugin.json +++ b/web/i18n/th-TH/plugin.json @@ -97,6 +97,7 @@ "categorySingle.model": "แบบ", "categorySingle.tool": "เครื่องมือ", "categorySingle.trigger": "ทริกเกอร์", + "clearSearch": "ล้าง {{label}}", "debugInfo.title": "การแก้จุดบกพร่อง", "debugInfo.viewDocs": "ดูเอกสาร", "deprecated": "เลิกใช้", diff --git a/web/i18n/th-TH/workflow.json b/web/i18n/th-TH/workflow.json index 4d5ed0f98a3..f964975948b 100644 --- a/web/i18n/th-TH/workflow.json +++ b/web/i18n/th-TH/workflow.json @@ -1240,11 +1240,13 @@ "tabs.allTool": "ทั้งหมด", "tabs.allTriggers": "ทริกเกอร์ทั้งหมด", "tabs.blocks": "โหนด", + "tabs.clearSnippetSearch": "ล้างการค้นหาส่วนย่อย", "tabs.createSnippet": "สร้างตัวอย่าง", "tabs.customTool": "Swagger API", "tabs.featuredTools": "เด่น", "tabs.hideActions": "ซ่อนเครื่องมือ", "tabs.installed": "ติดตั้งแล้ว", + "tabs.listView": "มุมมองรายการ", "tabs.logic": "ตรรกวิทยา", "tabs.noFeaturedPlugins": "ค้นพบเครื่องมือเพิ่มเติมใน Marketplace", "tabs.noFeaturedTriggers": "ค้นพบทริกเกอร์เพิ่มเติมใน Marketplace", @@ -1270,6 +1272,7 @@ "tabs.startNotSupportedTip": "ตัวอย่างข้อมูลไม่รองรับแท็บเริ่มต้น", "tabs.tools": "เครื่อง มือ", "tabs.transform": "แปลง", + "tabs.treeView": "มุมมองแบบต้นไม้", "tabs.unconfiguredStartDisabledTip": "มีการเพิ่มโหนดเริ่มต้นที่ยังไม่ได้กำหนดค่าลงในแคนวาส โปรดตั้งค่าให้เสร็จก่อนดำเนินการต่อ", "tabs.usePlugin": "เลือกเครื่องมือ", "tabs.utilities": "สาธารณูปโภค", diff --git a/web/i18n/tr-TR/plugin-tags.json b/web/i18n/tr-TR/plugin-tags.json index 3105bde5859..3a1f4c975fc 100644 --- a/web/i18n/tr-TR/plugin-tags.json +++ b/web/i18n/tr-TR/plugin-tags.json @@ -1,5 +1,6 @@ { "allTags": "Tüm Etiketler", + "clearSelectedTags": "Seçili etiketleri temizle: {{tags}}", "searchTags": "Arama Etiketleri", "tags.agent": "Aracı", "tags.business": "İş", diff --git a/web/i18n/tr-TR/plugin.json b/web/i18n/tr-TR/plugin.json index 62dea7a9ad0..fb8b75170c7 100644 --- a/web/i18n/tr-TR/plugin.json +++ b/web/i18n/tr-TR/plugin.json @@ -97,6 +97,7 @@ "categorySingle.model": "Model", "categorySingle.tool": "Araç", "categorySingle.trigger": "Tetikleyici", + "clearSearch": "{{label}} temizle", "debugInfo.title": "Hata ayıklama", "debugInfo.viewDocs": "Belgeleri Görüntüle", "deprecated": "Kaldırılmış", diff --git a/web/i18n/tr-TR/workflow.json b/web/i18n/tr-TR/workflow.json index d5416a9cefb..e2170efb95f 100644 --- a/web/i18n/tr-TR/workflow.json +++ b/web/i18n/tr-TR/workflow.json @@ -1240,11 +1240,13 @@ "tabs.allTool": "Hepsi", "tabs.allTriggers": "Tüm tetikleyiciler", "tabs.blocks": "Düğümler", + "tabs.clearSnippetSearch": "Parçacık aramasını temizle", "tabs.createSnippet": "Parçacık oluştur", "tabs.customTool": "Swagger API", "tabs.featuredTools": "Öne Çıkan", "tabs.hideActions": "Araçları gizle", "tabs.installed": "Yüklendi", + "tabs.listView": "Liste görünümü", "tabs.logic": "Mantık", "tabs.noFeaturedPlugins": "Pazar Yeri'nde daha fazla araç keşfedin", "tabs.noFeaturedTriggers": "Pazar Yeri'nde daha fazla tetikleyici keşfedin", @@ -1270,6 +1272,7 @@ "tabs.startNotSupportedTip": "Başlangıç sekmesi parçacıklarda desteklenmez.", "tabs.tools": "Araçlar", "tabs.transform": "Dönüştür", + "tabs.treeView": "Ağaç görünümü", "tabs.unconfiguredStartDisabledTip": "Tuvale yapılandırılmamış bir başlangıç düğümü eklendi. Devam etmeden önce kurulumu tamamlayın.", "tabs.usePlugin": "Araç seç", "tabs.utilities": "Yardımcı Araçlar", diff --git a/web/i18n/uk-UA/plugin-tags.json b/web/i18n/uk-UA/plugin-tags.json index c15d74a0e11..da9ff375d7a 100644 --- a/web/i18n/uk-UA/plugin-tags.json +++ b/web/i18n/uk-UA/plugin-tags.json @@ -1,5 +1,6 @@ { "allTags": "Всі теги", + "clearSelectedTags": "Очистити вибрані теги: {{tags}}", "searchTags": "Пошукові теги", "tags.agent": "Агент", "tags.business": "Бізнес", diff --git a/web/i18n/uk-UA/plugin.json b/web/i18n/uk-UA/plugin.json index 4cdbd56f92f..2589916a493 100644 --- a/web/i18n/uk-UA/plugin.json +++ b/web/i18n/uk-UA/plugin.json @@ -97,6 +97,7 @@ "categorySingle.model": "Модель", "categorySingle.tool": "Інструмент", "categorySingle.trigger": "Спусковий механізм", + "clearSearch": "Очистити {{label}}", "debugInfo.title": "Налагодження", "debugInfo.viewDocs": "Переглянути документи", "deprecated": "Застарілий", diff --git a/web/i18n/uk-UA/workflow.json b/web/i18n/uk-UA/workflow.json index 1baf7a92613..85f314b0396 100644 --- a/web/i18n/uk-UA/workflow.json +++ b/web/i18n/uk-UA/workflow.json @@ -1240,11 +1240,13 @@ "tabs.allTool": "Усі", "tabs.allTriggers": "Всі тригери", "tabs.blocks": "Вузли", + "tabs.clearSnippetSearch": "Очистити пошук фрагментів", "tabs.createSnippet": "Створіть фрагмент", "tabs.customTool": "Swagger API", "tabs.featuredTools": "Рекомендоване", "tabs.hideActions": "Сховати інструменти", "tabs.installed": "Встановлено", + "tabs.listView": "Список", "tabs.logic": "Логіка", "tabs.noFeaturedPlugins": "Відкрийте більше інструментів у Маркетплейсі", "tabs.noFeaturedTriggers": "Дізнайтеся більше тригерів у Маркетплейсі", @@ -1270,6 +1272,7 @@ "tabs.startNotSupportedTip": "Вкладка «Пуск» не підтримується у фрагментах.", "tabs.tools": "Інструменти", "tabs.transform": "Трансформація", + "tabs.treeView": "Дерево", "tabs.unconfiguredStartDisabledTip": "На полотно додано неналаштований початковий вузол. Завершіть налаштування, перш ніж продовжити.", "tabs.usePlugin": "Вибрати інструмент", "tabs.utilities": "Утиліти", diff --git a/web/i18n/vi-VN/plugin-tags.json b/web/i18n/vi-VN/plugin-tags.json index 0c7405c3633..ef5da130cf2 100644 --- a/web/i18n/vi-VN/plugin-tags.json +++ b/web/i18n/vi-VN/plugin-tags.json @@ -1,5 +1,6 @@ { "allTags": "Tất cả thẻ", + "clearSelectedTags": "Xóa các thẻ đã chọn: {{tags}}", "searchTags": "Thẻ tìm kiếm", "tags.agent": "Người đại lý", "tags.business": "Kinh doanh", diff --git a/web/i18n/vi-VN/plugin.json b/web/i18n/vi-VN/plugin.json index b8027d9c580..8ae3a9d6f95 100644 --- a/web/i18n/vi-VN/plugin.json +++ b/web/i18n/vi-VN/plugin.json @@ -97,6 +97,7 @@ "categorySingle.model": "Mẫu", "categorySingle.tool": "Công cụ", "categorySingle.trigger": "Kích hoạt", + "clearSearch": "Xóa {{label}}", "debugInfo.title": "Gỡ lỗi", "debugInfo.viewDocs": "Xem tài liệu", "deprecated": "Đã bị ngưng sử dụng", diff --git a/web/i18n/vi-VN/workflow.json b/web/i18n/vi-VN/workflow.json index cb0b3ce6669..314ecc3ea16 100644 --- a/web/i18n/vi-VN/workflow.json +++ b/web/i18n/vi-VN/workflow.json @@ -1240,11 +1240,13 @@ "tabs.allTool": "Tất cả", "tabs.allTriggers": "Tất cả các trình kích hoạt", "tabs.blocks": "Nút", + "tabs.clearSnippetSearch": "Xóa tìm kiếm đoạn trích", "tabs.createSnippet": "Tạo đoạn trích", "tabs.customTool": "Swagger API", "tabs.featuredTools": "Nổi bật", "tabs.hideActions": "Ẩn công cụ", "tabs.installed": "Đã cài đặt", + "tabs.listView": "Chế độ xem danh sách", "tabs.logic": "Logic", "tabs.noFeaturedPlugins": "Khám phá thêm các công cụ trong Marketplace", "tabs.noFeaturedTriggers": "Khám phá thêm các yếu tố kích hoạt trong Marketplace", @@ -1270,6 +1272,7 @@ "tabs.startNotSupportedTip": "Tab bắt đầu không được hỗ trợ trong đoạn trích.", "tabs.tools": "Công cụ", "tabs.transform": "Chuyển đổi", + "tabs.treeView": "Chế độ xem cây", "tabs.unconfiguredStartDisabledTip": "Một nút bắt đầu chưa được cấu hình đã được thêm vào canvas. Vui lòng hoàn tất thiết lập trước khi tiếp tục.", "tabs.usePlugin": "Chọn công cụ", "tabs.utilities": "Tiện ích", diff --git a/web/i18n/zh-Hans/plugin-tags.json b/web/i18n/zh-Hans/plugin-tags.json index d0cd56216c1..818fcb71556 100644 --- a/web/i18n/zh-Hans/plugin-tags.json +++ b/web/i18n/zh-Hans/plugin-tags.json @@ -1,5 +1,6 @@ { "allTags": "所有标签", + "clearSelectedTags": "清空已选标签:{{tags}}", "searchTags": "搜索标签", "tags.agent": "Agent", "tags.business": "商业", diff --git a/web/i18n/zh-Hans/plugin.json b/web/i18n/zh-Hans/plugin.json index 638b94e4fd1..5f893cb3842 100644 --- a/web/i18n/zh-Hans/plugin.json +++ b/web/i18n/zh-Hans/plugin.json @@ -97,6 +97,7 @@ "categorySingle.model": "模型", "categorySingle.tool": "工具", "categorySingle.trigger": "触发器", + "clearSearch": "清空{{label}}", "debugInfo.title": "调试", "debugInfo.viewDocs": "查看文档", "deprecated": "已弃用", diff --git a/web/i18n/zh-Hans/workflow.json b/web/i18n/zh-Hans/workflow.json index 00bb5e715d3..500b044ad17 100644 --- a/web/i18n/zh-Hans/workflow.json +++ b/web/i18n/zh-Hans/workflow.json @@ -1240,11 +1240,13 @@ "tabs.allTool": "全部", "tabs.allTriggers": "全部触发器", "tabs.blocks": "节点", + "tabs.clearSnippetSearch": "清空 Snippets 搜索", "tabs.createSnippet": "创建 snippet", "tabs.customTool": "Swagger API", "tabs.featuredTools": "精选推荐", "tabs.hideActions": "收起工具", "tabs.installed": "已安装", + "tabs.listView": "列表视图", "tabs.logic": "逻辑", "tabs.noFeaturedPlugins": "前往 Marketplace 查看更多工具", "tabs.noFeaturedTriggers": "前往 Marketplace 查看更多触发器", @@ -1270,6 +1272,7 @@ "tabs.startNotSupportedTip": "Snippet 暂不支持 Start 标签。", "tabs.tools": "工具", "tabs.transform": "转换", + "tabs.treeView": "树状视图", "tabs.unconfiguredStartDisabledTip": "画布上已有未配置的开始节点。请先完成设置后再继续。", "tabs.usePlugin": "选择工具", "tabs.utilities": "工具", diff --git a/web/i18n/zh-Hant/plugin-tags.json b/web/i18n/zh-Hant/plugin-tags.json index 5584e81c87a..ea2ec126e07 100644 --- a/web/i18n/zh-Hant/plugin-tags.json +++ b/web/i18n/zh-Hant/plugin-tags.json @@ -1,5 +1,6 @@ { "allTags": "所有標籤", + "clearSelectedTags": "清空所選標籤:{{tags}}", "searchTags": "搜索標籤", "tags.agent": "代理", "tags.business": "商", diff --git a/web/i18n/zh-Hant/plugin.json b/web/i18n/zh-Hant/plugin.json index 6a6afa8e5ef..d3d67f16b80 100644 --- a/web/i18n/zh-Hant/plugin.json +++ b/web/i18n/zh-Hant/plugin.json @@ -97,6 +97,7 @@ "categorySingle.model": "型", "categorySingle.tool": "工具", "categorySingle.trigger": "觸發器", + "clearSearch": "清空{{label}}", "debugInfo.title": "調試", "debugInfo.viewDocs": "查看文件", "deprecated": "不推薦使用的", diff --git a/web/i18n/zh-Hant/workflow.json b/web/i18n/zh-Hant/workflow.json index 731ec6a96e2..e32e5058b6f 100644 --- a/web/i18n/zh-Hant/workflow.json +++ b/web/i18n/zh-Hant/workflow.json @@ -1240,11 +1240,13 @@ "tabs.allTool": "全部", "tabs.allTriggers": "所有觸發器", "tabs.blocks": "節點", + "tabs.clearSnippetSearch": "清空片段搜尋", "tabs.createSnippet": "建立一個片段", "tabs.customTool": "Swagger API", "tabs.featuredTools": "精選", "tabs.hideActions": "隱藏工具", "tabs.installed": "已安裝", + "tabs.listView": "列表檢視", "tabs.logic": "邏輯", "tabs.noFeaturedPlugins": "前往 Marketplace 查看更多工具", "tabs.noFeaturedTriggers": "前往 Marketplace 查看更多觸發器", @@ -1270,6 +1272,7 @@ "tabs.startNotSupportedTip": "代码段中不支持“开始”选项卡。", "tabs.tools": "工具", "tabs.transform": "轉換", + "tabs.treeView": "樹狀檢視", "tabs.unconfiguredStartDisabledTip": "畫布上已有未設定的開始節點。請先完成設定後再繼續。", "tabs.usePlugin": "選取工具", "tabs.utilities": "工具",