From cfb02bceaf8d47c84da620a069275ce2b30ea4af Mon Sep 17 00:00:00 2001 From: yyh Date: Mon, 9 Mar 2026 17:03:43 +0800 Subject: [PATCH] feat(workflow): open install bundle from checklist and strict marketplace parsing --- .../__tests__/use-install-multi-state.spec.ts | 18 ++++ .../steps/hooks/use-install-multi-state.ts | 96 +++++++++++-------- .../header/checklist/plugin-group.spec.tsx | 64 +++++++++++++ .../header/checklist/plugin-group.tsx | 69 ++++++------- 4 files changed, 173 insertions(+), 74 deletions(-) create mode 100644 web/app/components/workflow/header/checklist/plugin-group.spec.tsx diff --git a/web/app/components/plugins/install-plugin/install-bundle/steps/hooks/__tests__/use-install-multi-state.spec.ts b/web/app/components/plugins/install-plugin/install-bundle/steps/hooks/__tests__/use-install-multi-state.spec.ts index 1950a47f6d..9c72f4ed85 100644 --- a/web/app/components/plugins/install-plugin/install-bundle/steps/hooks/__tests__/use-install-multi-state.spec.ts +++ b/web/app/components/plugins/install-plugin/install-bundle/steps/hooks/__tests__/use-install-multi-state.spec.ts @@ -270,6 +270,24 @@ describe('useInstallMultiState', () => { // ==================== Error Handling ==================== describe('Error Handling', () => { + it('should mark marketplace index as error when identifier is missing', async () => { + const params = createDefaultParams({ + allPlugins: [ + { + type: 'marketplace', + value: { + version: '1.0.0', + }, + } as GitHubItemAndMarketPlaceDependency, + ] as Dependency[], + }) + const { result } = renderHook(() => useInstallMultiState(params)) + + await waitFor(() => { + expect(result.current.errorIndexes).toContain(0) + }) + }) + it('should mark all marketplace indexes as errors on fetch failure', async () => { mockMarketplaceError = new Error('Fetch failed') diff --git a/web/app/components/plugins/install-plugin/install-bundle/steps/hooks/use-install-multi-state.ts b/web/app/components/plugins/install-plugin/install-bundle/steps/hooks/use-install-multi-state.ts index b430d47afd..7087f24fc0 100644 --- a/web/app/components/plugins/install-plugin/install-bundle/steps/hooks/use-install-multi-state.ts +++ b/web/app/components/plugins/install-plugin/install-bundle/steps/hooks/use-install-multi-state.ts @@ -14,14 +14,30 @@ type UseInstallMultiStateParams = { onLoadedAllPlugin: (installedInfo: Record) => void } +type MarketplacePluginInfo = { + organization: string + plugin: string + version?: string +} + export function getPluginKey(plugin: Plugin | undefined): string { return `${plugin?.org || plugin?.author}/${plugin?.name}` } -function parseMarketplaceIdentifier(identifier: string) { - const [orgPart, nameAndVersionPart] = identifier.split('@')[0].split('/') - const [name, version] = nameAndVersionPart.split(':') - return { organization: orgPart, plugin: name, version } +function parseMarketplaceIdentifier(identifier?: string): MarketplacePluginInfo | null { + if (!identifier) + return null + + const withoutHash = identifier.split('@')[0] + const [organization, nameAndVersionPart] = withoutHash.split('/') + if (!organization || !nameAndVersionPart) + return null + + const [plugin, version] = nameAndVersionPart.split(':') + if (!plugin) + return null + + return { organization, plugin, version } } function initPluginsFromDependencies(allPlugins: Dependency[]): (Plugin | undefined)[] { @@ -61,67 +77,67 @@ export function useInstallMultiState({ }, []) }, [allPlugins]) - // Marketplace data fetching: by unique identifier and by meta info + const { marketplacePayloadByIdentifier, invalidMarketplaceIndexes } = useMemo(() => { + return marketplacePlugins.reduce<{ + marketplacePayloadByIdentifier: MarketplacePluginInfo[] + invalidMarketplaceIndexes: number[] + }>((acc, d, marketplaceIndex) => { + const parsedIdentifier = parseMarketplaceIdentifier( + d.value.marketplace_plugin_unique_identifier || d.value.plugin_unique_identifier, + ) + const dslIndex = marketPlaceInDSLIndex[marketplaceIndex] + if (parsedIdentifier) + acc.marketplacePayloadByIdentifier.push(parsedIdentifier) + else if (dslIndex !== undefined) + acc.invalidMarketplaceIndexes.push(dslIndex) + return acc + }, { + marketplacePayloadByIdentifier: [], + invalidMarketplaceIndexes: [], + }) + }, [marketPlaceInDSLIndex, marketplacePlugins]) + + // Marketplace data fetching: by unique identifier const { isLoading: isFetchingById, data: infoGetById, error: infoByIdError, } = useFetchPluginsInMarketPlaceByInfo( - marketplacePlugins.map(d => parseMarketplaceIdentifier(d.value.marketplace_plugin_unique_identifier!)), - ) - - const { - isLoading: isFetchingByMeta, - data: infoByMeta, - error: infoByMetaError, - } = useFetchPluginsInMarketPlaceByInfo( - marketplacePlugins.map(d => d.value!), + marketplacePayloadByIdentifier, ) // Derive marketplace plugin data and errors from API responses const { marketplacePluginMap, marketplaceErrorIndexes } = useMemo(() => { const pluginMap = new Map() - const errorSet = new Set() + const errorSet = new Set(invalidMarketplaceIndexes) // Process "by ID" response if (!isFetchingById && infoGetById?.data.list) { - const sortedList = marketplacePlugins.map((d) => { - const id = d.value.marketplace_plugin_unique_identifier?.split(':')[0] - const retPluginInfo = infoGetById.data.list.find(item => item.plugin.plugin_id === id)?.plugin - return { ...retPluginInfo, from: d.type } as Plugin - }) + const pluginById = new Map( + infoGetById.data.list.map(item => [item.plugin.plugin_id, item.plugin]), + ) + marketPlaceInDSLIndex.forEach((index, i) => { - if (sortedList[i]) { + const dependency = marketplacePlugins[i] + const pluginId = (dependency?.value.marketplace_plugin_unique_identifier || dependency?.value.plugin_unique_identifier)?.split(':')[0] + const pluginInfo = pluginId ? pluginById.get(pluginId) : undefined + if (pluginInfo) { pluginMap.set(index, { - ...sortedList[i], - version: sortedList[i]!.version || sortedList[i]!.latest_version, + ...pluginInfo, + from: dependency.type, + version: pluginInfo.version || pluginInfo.latest_version, }) } else { errorSet.add(index) } }) } - // Process "by meta" response (may overwrite "by ID" results) - if (!isFetchingByMeta && infoByMeta?.data.list) { - const payloads = infoByMeta.data.list - marketPlaceInDSLIndex.forEach((index, i) => { - if (payloads[i]) { - const item = payloads[i] - pluginMap.set(index, { - ...item.plugin, - plugin_id: item.version.unique_identifier, - } as Plugin) - } - else { errorSet.add(index) } - }) - } - // Mark all marketplace indexes as errors on fetch failure - if (infoByMetaError || infoByIdError) + if (infoByIdError) marketPlaceInDSLIndex.forEach(index => errorSet.add(index)) return { marketplacePluginMap: pluginMap, marketplaceErrorIndexes: errorSet } - }, [isFetchingById, isFetchingByMeta, infoGetById, infoByMeta, infoByMetaError, infoByIdError, marketPlaceInDSLIndex, marketplacePlugins]) + }, [invalidMarketplaceIndexes, isFetchingById, infoGetById, infoByIdError, marketPlaceInDSLIndex, marketplacePlugins]) // GitHub-fetched plugins and errors (imperative state from child callbacks) const [githubPluginMap, setGithubPluginMap] = useState>(() => new Map()) diff --git a/web/app/components/workflow/header/checklist/plugin-group.spec.tsx b/web/app/components/workflow/header/checklist/plugin-group.spec.tsx new file mode 100644 index 0000000000..14c4d980b8 --- /dev/null +++ b/web/app/components/workflow/header/checklist/plugin-group.spec.tsx @@ -0,0 +1,64 @@ +import type { ChecklistItem } from '../../hooks/use-checklist' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it } from 'vitest' +import { useStore as usePluginDependencyStore } from '../../plugin-dependency/store' +import { BlockEnum } from '../../types' +import { ChecklistPluginGroup } from './plugin-group' + +const createChecklistItem = (overrides: Partial = {}): ChecklistItem => ({ + id: 'node-1', + type: BlockEnum.Tool, + title: 'Tool Node', + errorMessages: [], + canNavigate: false, + isPluginMissing: true, + pluginUniqueIdentifier: 'langgenius/test-plugin:1.0.0@sha256', + ...overrides, +}) + +describe('ChecklistPluginGroup', () => { + beforeEach(() => { + usePluginDependencyStore.setState({ dependencies: [] }) + }) + + it('should set marketplace dependencies when install button is clicked', () => { + const items: ChecklistItem[] = [ + createChecklistItem({ id: 'node-1', pluginUniqueIdentifier: 'langgenius/test-plugin:1.0.0@sha256' }), + createChecklistItem({ id: 'node-2', pluginUniqueIdentifier: 'langgenius/test-plugin:1.0.0@sha256' }), + createChecklistItem({ id: 'node-3', pluginUniqueIdentifier: 'langgenius/another-plugin:2.0.0@sha256' }), + ] + + render() + + fireEvent.click(screen.getByRole('button')) + + expect(usePluginDependencyStore.getState().dependencies).toEqual([ + { + type: 'marketplace', + value: { + marketplace_plugin_unique_identifier: 'langgenius/test-plugin:1.0.0@sha256', + plugin_unique_identifier: 'langgenius/test-plugin:1.0.0@sha256', + version: '1.0.0', + }, + }, + { + type: 'marketplace', + value: { + marketplace_plugin_unique_identifier: 'langgenius/another-plugin:2.0.0@sha256', + plugin_unique_identifier: 'langgenius/another-plugin:2.0.0@sha256', + version: '2.0.0', + }, + }, + ]) + }) + + it('should keep install button disabled when no identifier is available', () => { + render() + + const installButton = screen.getByRole('button') + expect(installButton).toBeDisabled() + + fireEvent.click(installButton) + expect(usePluginDependencyStore.getState().dependencies).toEqual([]) + }) +}) diff --git a/web/app/components/workflow/header/checklist/plugin-group.tsx b/web/app/components/workflow/header/checklist/plugin-group.tsx index c29ec59cfe..a4863c1363 100644 --- a/web/app/components/workflow/header/checklist/plugin-group.tsx +++ b/web/app/components/workflow/header/checklist/plugin-group.tsx @@ -1,49 +1,57 @@ import type { MouseEventHandler } from 'react' import type { ChecklistItem } from '../../hooks/use-checklist' import type { BlockEnum } from '../../types' -import { memo, useMemo, useState } from 'react' +import type { Dependency } from '@/app/components/plugins/types' +import { memo, useMemo } from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' -import checkTaskStatus from '@/app/components/plugins/install-plugin/base/check-task-status' -import { useInstallPackageFromMarketPlace } from '@/service/use-plugins' import BlockIcon from '../../block-icon' +import { useStore as usePluginDependencyStore } from '../../plugin-dependency/store' import { ItemIndicator } from './item-indicator' +function getVersionFromMarketplaceIdentifier(identifier: string): string | undefined { + const withoutHash = identifier.split('@')[0] + const [, version] = withoutHash.split(':') + return version || undefined +} + export const ChecklistPluginGroup = memo(({ items, }: { items: ChecklistItem[] }) => { const { t } = useTranslation() - const install = useInstallPackageFromMarketPlace() - const [installing, setInstalling] = useState(false) const identifiers = useMemo( - () => items - .map(i => i.pluginUniqueIdentifier) - .filter((id): id is string => Boolean(id)), + () => Array.from( + new Set( + items + .map(i => i.pluginUniqueIdentifier) + .filter((id): id is string => Boolean(id)), + ), + ), [items], ) - const handleInstallAll: MouseEventHandler = async (e) => { + const dependencies = useMemo(() => { + return identifiers.map((identifier) => { + return { + type: 'marketplace', + value: { + marketplace_plugin_unique_identifier: identifier, + plugin_unique_identifier: identifier, + version: getVersionFromMarketplaceIdentifier(identifier), + }, + } + }) + }, [identifiers]) + + const handleInstallAll: MouseEventHandler = (e) => { e.stopPropagation() - if (installing || identifiers.length === 0) + if (dependencies.length === 0) return - setInstalling(true) - for (const id of identifiers) { - try { - const response = await install.mutateAsync(id) - if (response?.task_id) { - const { check } = checkTaskStatus() - await check({ taskId: response.task_id, pluginUniqueIdentifier: id }) - } - } - catch { - // continue installing remaining plugins - } - } - setInstalling(false) - install.reset() + const { setDependencies } = usePluginDependencyStore.getState() + setDependencies(dependencies) } return ( @@ -59,16 +67,9 @@ export const ChecklistPluginGroup = memo(({ variant="secondary" size="small" onClick={handleInstallAll} - disabled={installing || identifiers.length === 0} + disabled={dependencies.length === 0} > - {installing - ? ( - <> - {t('nodes.agent.pluginInstaller.installing', { ns: 'workflow' })} - - - ) - : t('nodes.agent.pluginInstaller.install', { ns: 'workflow' })} + {t('nodes.agent.pluginInstaller.install', { ns: 'workflow' })}