From 7843afc91cc133c06b0b1fc057733b2ba81b9270 Mon Sep 17 00:00:00 2001 From: Maries Date: Fri, 9 Jan 2026 13:55:49 +0800 Subject: [PATCH 1/6] feat(workflows): add agent-dev deploy workflow (#30774) --- .../{deploy-trigger-dev.yml => deploy-agent-dev.yml} | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) rename .github/workflows/{deploy-trigger-dev.yml => deploy-agent-dev.yml} (75%) diff --git a/.github/workflows/deploy-trigger-dev.yml b/.github/workflows/deploy-agent-dev.yml similarity index 75% rename from .github/workflows/deploy-trigger-dev.yml rename to .github/workflows/deploy-agent-dev.yml index 2d9a904fc5..dff48b5510 100644 --- a/.github/workflows/deploy-trigger-dev.yml +++ b/.github/workflows/deploy-agent-dev.yml @@ -1,4 +1,4 @@ -name: Deploy Trigger Dev +name: Deploy Agent Dev permissions: contents: read @@ -7,7 +7,7 @@ on: workflow_run: workflows: ["Build and Push API & Web"] branches: - - "deploy/trigger-dev" + - "deploy/agent-dev" types: - completed @@ -16,12 +16,12 @@ jobs: runs-on: ubuntu-latest if: | github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.head_branch == 'deploy/trigger-dev' + github.event.workflow_run.head_branch == 'deploy/agent-dev' steps: - name: Deploy to server uses: appleboy/ssh-action@v0.1.8 with: - host: ${{ secrets.TRIGGER_SSH_HOST }} + host: ${{ secrets.AGENT_DEV_SSH_HOST }} username: ${{ secrets.SSH_USER }} key: ${{ secrets.SSH_PRIVATE_KEY }} script: | From 77f097ce762455b33fc430047446368e2c597f9a Mon Sep 17 00:00:00 2001 From: wangxiaolei Date: Fri, 9 Jan 2026 14:07:40 +0800 Subject: [PATCH 2/6] fix: fix enhance app mode check (#30758) --- web/app/components/apps/list.spec.tsx | 1 + web/app/components/apps/list.tsx | 40 +++++++++++++++++++++++++++ web/service/use-apps.ts | 17 ++++++++++-- 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/web/app/components/apps/list.spec.tsx b/web/app/components/apps/list.spec.tsx index e5854f68b4..07c30cd588 100644 --- a/web/app/components/apps/list.spec.tsx +++ b/web/app/components/apps/list.spec.tsx @@ -10,6 +10,7 @@ const mockReplace = vi.fn() const mockRouter = { replace: mockReplace } vi.mock('next/navigation', () => ({ useRouter: () => mockRouter, + useSearchParams: () => new URLSearchParams(''), })) // Mock app context diff --git a/web/app/components/apps/list.tsx b/web/app/components/apps/list.tsx index 290a73fc7c..e5c9954626 100644 --- a/web/app/components/apps/list.tsx +++ b/web/app/components/apps/list.tsx @@ -12,6 +12,7 @@ import { useDebounceFn } from 'ahooks' import dynamic from 'next/dynamic' import { useRouter, + useSearchParams, } from 'next/navigation' import { parseAsString, useQueryState } from 'nuqs' import { useCallback, useEffect, useRef, useState } from 'react' @@ -36,6 +37,16 @@ import useAppsQueryState from './hooks/use-apps-query-state' import { useDSLDragDrop } from './hooks/use-dsl-drag-drop' import NewAppCard from './new-app-card' +// Define valid tabs at module scope to avoid re-creation on each render and stale closures +const validTabs = new Set([ + 'all', + AppModeEnum.WORKFLOW, + AppModeEnum.ADVANCED_CHAT, + AppModeEnum.CHAT, + AppModeEnum.AGENT_CHAT, + AppModeEnum.COMPLETION, +]) + const TagManagementModal = dynamic(() => import('@/app/components/base/tag-management'), { ssr: false, }) @@ -47,12 +58,41 @@ const List = () => { const { t } = useTranslation() const { systemFeatures } = useGlobalPublicStore() const router = useRouter() + const searchParams = useSearchParams() const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext() const showTagManagementModal = useTagStore(s => s.showTagManagementModal) const [activeTab, setActiveTab] = useQueryState( 'category', parseAsString.withDefault('all').withOptions({ history: 'push' }), ) + + // valid tabs for apps list; anything else should fallback to 'all' + + // 1) Normalize legacy/incorrect query params like ?mode=discover -> ?category=all + useEffect(() => { + // avoid running on server + if (typeof window === 'undefined') + return + const mode = searchParams.get('mode') + if (!mode) + return + const url = new URL(window.location.href) + url.searchParams.delete('mode') + if (validTabs.has(mode)) { + // migrate to category key + url.searchParams.set('category', mode) + } + else { + url.searchParams.set('category', 'all') + } + router.replace(url.pathname + url.search) + }, [router, searchParams]) + + // 2) If category has an invalid value (e.g., 'discover'), reset to 'all' + useEffect(() => { + if (!validTabs.has(activeTab)) + setActiveTab('all') + }, [activeTab, setActiveTab]) const { query: { tagIDs = [], keywords = '', isCreatedByMe: queryIsCreatedByMe = false }, setQuery } = useAppsQueryState() const [isCreatedByMe, setIsCreatedByMe] = useState(queryIsCreatedByMe) const [tagFilterValue, setTagFilterValue] = useState(tagIDs) diff --git a/web/service/use-apps.ts b/web/service/use-apps.ts index d16d44af20..74e7662492 100644 --- a/web/service/use-apps.ts +++ b/web/service/use-apps.ts @@ -10,13 +10,14 @@ import type { AppVoicesListResponse, WorkflowDailyConversationsResponse, } from '@/models/app' -import type { App, AppModeEnum } from '@/types/app' +import type { App } from '@/types/app' import { keepPreviousData, useInfiniteQuery, useQuery, useQueryClient, } from '@tanstack/react-query' +import { AppModeEnum } from '@/types/app' import { get, post } from './base' import { useInvalid } from './use-base' @@ -36,6 +37,16 @@ type DateRangeParams = { end?: string } +// Allowed app modes for filtering; defined at module scope to avoid re-creating on every call +const allowedModes = new Set([ + 'all', + AppModeEnum.WORKFLOW, + AppModeEnum.ADVANCED_CHAT, + AppModeEnum.CHAT, + AppModeEnum.AGENT_CHAT, + AppModeEnum.COMPLETION, +]) + const normalizeAppListParams = (params: AppListParams) => { const { page = 1, @@ -46,11 +57,13 @@ const normalizeAppListParams = (params: AppListParams) => { is_created_by_me, } = params + const safeMode = allowedModes.has((mode as any)) ? mode : undefined + return { page, limit, name, - ...(mode && mode !== 'all' ? { mode } : {}), + ...(safeMode && safeMode !== 'all' ? { mode: safeMode } : {}), ...(tag_ids?.length ? { tag_ids } : {}), ...(is_created_by_me ? { is_created_by_me } : {}), } From 9d9f027246ff41901d5c46fb2c50beee28ea338d Mon Sep 17 00:00:00 2001 From: lif <1835304752@qq.com> Date: Fri, 9 Jan 2026 14:08:37 +0800 Subject: [PATCH 3/6] fix(web): invalidate app list cache after deleting app from detail page (#30751) Signed-off-by: majiayu000 <1835304752@qq.com> --- web/app/components/app-sidebar/app-info.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/web/app/components/app-sidebar/app-info.tsx b/web/app/components/app-sidebar/app-info.tsx index cbd1640295..255feaccdf 100644 --- a/web/app/components/app-sidebar/app-info.tsx +++ b/web/app/components/app-sidebar/app-info.tsx @@ -26,6 +26,7 @@ import { NEED_REFRESH_APP_LIST_KEY } from '@/config' import { useAppContext } from '@/context/app-context' import { useProviderContext } from '@/context/provider-context' import { copyApp, deleteApp, exportAppConfig, updateAppInfo } from '@/service/apps' +import { useInvalidateAppList } from '@/service/use-apps' import { fetchWorkflowDraft } from '@/service/workflow' import { AppModeEnum } from '@/types/app' import { getRedirection } from '@/utils/app-redirection' @@ -66,6 +67,7 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx const { onPlanInfoChanged } = useProviderContext() const appDetail = useAppStore(state => state.appDetail) const setAppDetail = useAppStore(state => state.setAppDetail) + const invalidateAppList = useInvalidateAppList() const [open, setOpen] = useState(openState) const [showEditModal, setShowEditModal] = useState(false) const [showDuplicateModal, setShowDuplicateModal] = useState(false) @@ -191,6 +193,7 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx try { await deleteApp(appDetail.id) notify({ type: 'success', message: t('appDeleted', { ns: 'app' }) }) + invalidateAppList() onPlanInfoChanged() setAppDetail() replace('/apps') @@ -202,7 +205,7 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx }) } setShowConfirmDelete(false) - }, [appDetail, notify, onPlanInfoChanged, replace, setAppDetail, t]) + }, [appDetail, invalidateAppList, notify, onPlanInfoChanged, replace, setAppDetail, t]) const { isCurrentWorkspaceEditor } = useAppContext() From d4432ed80fa2d8bdbda08087d8a31fd50fcc7166 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 9 Jan 2026 14:31:24 +0800 Subject: [PATCH 4/6] refactor: marketplace state management (#30702) Co-authored-by: Claude Opus 4.5 --- web/app/(commonLayout)/plugins/page.tsx | 4 +- .../components/plugins/marketplace/atoms.ts | 81 + .../plugins/marketplace/constants.ts | 24 + .../plugins/marketplace/context.tsx | 332 ---- .../components/plugins/marketplace/hooks.ts | 7 +- .../plugins/marketplace/hydration-client.tsx | 15 + .../plugins/marketplace/hydration-server.tsx | 45 + .../plugins/marketplace/index.spec.tsx | 1339 +---------------- .../components/plugins/marketplace/index.tsx | 58 +- .../plugins/marketplace/list/index.spec.tsx | 423 ++---- .../plugins/marketplace/list/index.tsx | 3 - .../marketplace/list/list-with-collection.tsx | 9 +- .../plugins/marketplace/list/list-wrapper.tsx | 43 +- .../marketplace/plugin-type-switch.tsx | 49 +- .../components/plugins/marketplace/query.ts | 38 + .../marketplace/search-box/index.spec.tsx | 42 +- .../search-box/search-box-wrapper.tsx | 8 +- .../plugins/marketplace/search-params.ts | 9 + .../marketplace/sort-dropdown/index.spec.tsx | 15 +- .../marketplace/sort-dropdown/index.tsx | 5 +- .../components/plugins/marketplace/state.ts | 54 + .../sticky-search-and-switch-wrapper.tsx | 6 +- .../components/plugins/marketplace/utils.ts | 81 +- .../components/plugins/plugin-page/index.tsx | 2 +- .../auto-update-setting/index.spec.tsx | 2 +- .../auto-update-setting/tool-picker.tsx | 5 +- web/context/query-client-server.ts | 16 + web/context/query-client.tsx | 28 +- web/hooks/use-query-params.spec.tsx | 169 --- web/hooks/use-query-params.ts | 34 - 30 files changed, 578 insertions(+), 2368 deletions(-) create mode 100644 web/app/components/plugins/marketplace/atoms.ts delete mode 100644 web/app/components/plugins/marketplace/context.tsx create mode 100644 web/app/components/plugins/marketplace/hydration-client.tsx create mode 100644 web/app/components/plugins/marketplace/hydration-server.tsx create mode 100644 web/app/components/plugins/marketplace/query.ts create mode 100644 web/app/components/plugins/marketplace/search-params.ts create mode 100644 web/app/components/plugins/marketplace/state.ts create mode 100644 web/context/query-client-server.ts diff --git a/web/app/(commonLayout)/plugins/page.tsx b/web/app/(commonLayout)/plugins/page.tsx index 81bda3a8a3..f366200cf9 100644 --- a/web/app/(commonLayout)/plugins/page.tsx +++ b/web/app/(commonLayout)/plugins/page.tsx @@ -2,11 +2,11 @@ import Marketplace from '@/app/components/plugins/marketplace' import PluginPage from '@/app/components/plugins/plugin-page' import PluginsPanel from '@/app/components/plugins/plugin-page/plugins-panel' -const PluginList = async () => { +const PluginList = () => { return ( } - marketplace={} + marketplace={} /> ) } diff --git a/web/app/components/plugins/marketplace/atoms.ts b/web/app/components/plugins/marketplace/atoms.ts new file mode 100644 index 0000000000..6ca9bd1c05 --- /dev/null +++ b/web/app/components/plugins/marketplace/atoms.ts @@ -0,0 +1,81 @@ +import type { ActivePluginType } from './constants' +import type { PluginsSort, SearchParamsFromCollection } from './types' +import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai' +import { useQueryState } from 'nuqs' +import { useCallback } from 'react' +import { DEFAULT_SORT, PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants' +import { marketplaceSearchParamsParsers } from './search-params' + +const marketplaceSortAtom = atom(DEFAULT_SORT) +export function useMarketplaceSort() { + return useAtom(marketplaceSortAtom) +} +export function useMarketplaceSortValue() { + return useAtomValue(marketplaceSortAtom) +} +export function useSetMarketplaceSort() { + return useSetAtom(marketplaceSortAtom) +} + +/** + * Preserve the state for marketplace + */ +export const preserveSearchStateInQueryAtom = atom(false) + +const searchPluginTextAtom = atom('') +const activePluginTypeAtom = atom('all') +const filterPluginTagsAtom = atom([]) + +export function useSearchPluginText() { + const preserveSearchStateInQuery = useAtomValue(preserveSearchStateInQueryAtom) + const queryState = useQueryState('q', marketplaceSearchParamsParsers.q) + const atomState = useAtom(searchPluginTextAtom) + return preserveSearchStateInQuery ? queryState : atomState +} +export function useActivePluginType() { + const preserveSearchStateInQuery = useAtomValue(preserveSearchStateInQueryAtom) + const queryState = useQueryState('category', marketplaceSearchParamsParsers.category) + const atomState = useAtom(activePluginTypeAtom) + return preserveSearchStateInQuery ? queryState : atomState +} +export function useFilterPluginTags() { + const preserveSearchStateInQuery = useAtomValue(preserveSearchStateInQueryAtom) + const queryState = useQueryState('tags', marketplaceSearchParamsParsers.tags) + const atomState = useAtom(filterPluginTagsAtom) + return preserveSearchStateInQuery ? queryState : atomState +} + +/** + * Not all categories have collections, so we need to + * force the search mode for those categories. + */ +export const searchModeAtom = atom(null) + +export function useMarketplaceSearchMode() { + const [searchPluginText] = useSearchPluginText() + const [filterPluginTags] = useFilterPluginTags() + const [activePluginType] = useActivePluginType() + + const searchMode = useAtomValue(searchModeAtom) + const isSearchMode = !!searchPluginText + || filterPluginTags.length > 0 + || (searchMode ?? (!PLUGIN_CATEGORY_WITH_COLLECTIONS.has(activePluginType))) + return isSearchMode +} + +export function useMarketplaceMoreClick() { + const [,setQ] = useSearchPluginText() + const setSort = useSetAtom(marketplaceSortAtom) + const setSearchMode = useSetAtom(searchModeAtom) + + return useCallback((searchParams?: SearchParamsFromCollection) => { + if (!searchParams) + return + setQ(searchParams?.query || '') + setSort({ + sortBy: searchParams?.sort_by || DEFAULT_SORT.sortBy, + sortOrder: searchParams?.sort_order || DEFAULT_SORT.sortOrder, + }) + setSearchMode(true) + }, [setQ, setSort, setSearchMode]) +} diff --git a/web/app/components/plugins/marketplace/constants.ts b/web/app/components/plugins/marketplace/constants.ts index 92c3e7278f..6613fbe3de 100644 --- a/web/app/components/plugins/marketplace/constants.ts +++ b/web/app/components/plugins/marketplace/constants.ts @@ -1,6 +1,30 @@ +import { PluginCategoryEnum } from '../types' + export const DEFAULT_SORT = { sortBy: 'install_count', sortOrder: 'DESC', } export const SCROLL_BOTTOM_THRESHOLD = 100 + +export const PLUGIN_TYPE_SEARCH_MAP = { + all: 'all', + model: PluginCategoryEnum.model, + tool: PluginCategoryEnum.tool, + agent: PluginCategoryEnum.agent, + extension: PluginCategoryEnum.extension, + datasource: PluginCategoryEnum.datasource, + trigger: PluginCategoryEnum.trigger, + bundle: 'bundle', +} as const + +type ValueOf = T[keyof T] + +export type ActivePluginType = ValueOf + +export const PLUGIN_CATEGORY_WITH_COLLECTIONS = new Set( + [ + PLUGIN_TYPE_SEARCH_MAP.all, + PLUGIN_TYPE_SEARCH_MAP.tool, + ], +) diff --git a/web/app/components/plugins/marketplace/context.tsx b/web/app/components/plugins/marketplace/context.tsx deleted file mode 100644 index 31b6a7f592..0000000000 --- a/web/app/components/plugins/marketplace/context.tsx +++ /dev/null @@ -1,332 +0,0 @@ -'use client' - -import type { - ReactNode, -} from 'react' -import type { TagKey } from '../constants' -import type { Plugin } from '../types' -import type { - MarketplaceCollection, - PluginsSort, - SearchParams, - SearchParamsFromCollection, -} from './types' -import { debounce } from 'es-toolkit/compat' -import { noop } from 'es-toolkit/function' -import { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react' -import { - createContext, - useContextSelector, -} from 'use-context-selector' -import { useMarketplaceFilters } from '@/hooks/use-query-params' -import { useInstalledPluginList } from '@/service/use-plugins' -import { - getValidCategoryKeys, - getValidTagKeys, -} from '../utils' -import { DEFAULT_SORT } from './constants' -import { - useMarketplaceCollectionsAndPlugins, - useMarketplaceContainerScroll, - useMarketplacePlugins, -} from './hooks' -import { PLUGIN_TYPE_SEARCH_MAP } from './plugin-type-switch' -import { - getMarketplaceListCondition, - getMarketplaceListFilterType, -} from './utils' - -export type MarketplaceContextValue = { - searchPluginText: string - handleSearchPluginTextChange: (text: string) => void - filterPluginTags: string[] - handleFilterPluginTagsChange: (tags: string[]) => void - activePluginType: string - handleActivePluginTypeChange: (type: string) => void - page: number - handlePageChange: () => void - plugins?: Plugin[] - pluginsTotal?: number - resetPlugins: () => void - sort: PluginsSort - handleSortChange: (sort: PluginsSort) => void - handleQueryPlugins: () => void - handleMoreClick: (searchParams: SearchParamsFromCollection) => void - marketplaceCollectionsFromClient?: MarketplaceCollection[] - setMarketplaceCollectionsFromClient: (collections: MarketplaceCollection[]) => void - marketplaceCollectionPluginsMapFromClient?: Record - setMarketplaceCollectionPluginsMapFromClient: (map: Record) => void - isLoading: boolean - isSuccessCollections: boolean -} - -export const MarketplaceContext = createContext({ - searchPluginText: '', - handleSearchPluginTextChange: noop, - filterPluginTags: [], - handleFilterPluginTagsChange: noop, - activePluginType: 'all', - handleActivePluginTypeChange: noop, - page: 1, - handlePageChange: noop, - plugins: undefined, - pluginsTotal: 0, - resetPlugins: noop, - sort: DEFAULT_SORT, - handleSortChange: noop, - handleQueryPlugins: noop, - handleMoreClick: noop, - marketplaceCollectionsFromClient: [], - setMarketplaceCollectionsFromClient: noop, - marketplaceCollectionPluginsMapFromClient: {}, - setMarketplaceCollectionPluginsMapFromClient: noop, - isLoading: false, - isSuccessCollections: false, -}) - -type MarketplaceContextProviderProps = { - children: ReactNode - searchParams?: SearchParams - shouldExclude?: boolean - scrollContainerId?: string - showSearchParams?: boolean -} - -export function useMarketplaceContext(selector: (value: MarketplaceContextValue) => any) { - return useContextSelector(MarketplaceContext, selector) -} - -export const MarketplaceContextProvider = ({ - children, - searchParams, - shouldExclude, - scrollContainerId, - showSearchParams, -}: MarketplaceContextProviderProps) => { - // Use nuqs hook for URL-based filter state - const [urlFilters, setUrlFilters] = useMarketplaceFilters() - - const { data, isSuccess } = useInstalledPluginList(!shouldExclude) - const exclude = useMemo(() => { - if (shouldExclude) - return data?.plugins.map(plugin => plugin.plugin_id) - }, [data?.plugins, shouldExclude]) - - // Initialize from URL params (legacy support) or use nuqs state - const queryFromSearchParams = searchParams?.q || urlFilters.q - const tagsFromSearchParams = getValidTagKeys(urlFilters.tags as TagKey[]) - const hasValidTags = !!tagsFromSearchParams.length - const hasValidCategory = getValidCategoryKeys(urlFilters.category) - const categoryFromSearchParams = hasValidCategory || PLUGIN_TYPE_SEARCH_MAP.all - - const [searchPluginText, setSearchPluginText] = useState(queryFromSearchParams) - const searchPluginTextRef = useRef(searchPluginText) - const [filterPluginTags, setFilterPluginTags] = useState(tagsFromSearchParams) - const filterPluginTagsRef = useRef(filterPluginTags) - const [activePluginType, setActivePluginType] = useState(categoryFromSearchParams) - const activePluginTypeRef = useRef(activePluginType) - const [sort, setSort] = useState(DEFAULT_SORT) - const sortRef = useRef(sort) - const { - marketplaceCollections: marketplaceCollectionsFromClient, - setMarketplaceCollections: setMarketplaceCollectionsFromClient, - marketplaceCollectionPluginsMap: marketplaceCollectionPluginsMapFromClient, - setMarketplaceCollectionPluginsMap: setMarketplaceCollectionPluginsMapFromClient, - queryMarketplaceCollectionsAndPlugins, - isLoading, - isSuccess: isSuccessCollections, - } = useMarketplaceCollectionsAndPlugins() - const { - plugins, - total: pluginsTotal, - resetPlugins, - queryPlugins, - queryPluginsWithDebounced, - cancelQueryPluginsWithDebounced, - isLoading: isPluginsLoading, - fetchNextPage: fetchNextPluginsPage, - hasNextPage: hasNextPluginsPage, - page: pluginsPage, - } = useMarketplacePlugins() - const page = Math.max(pluginsPage || 0, 1) - - useEffect(() => { - if (queryFromSearchParams || hasValidTags || hasValidCategory) { - queryPlugins({ - query: queryFromSearchParams, - category: hasValidCategory, - tags: hasValidTags ? tagsFromSearchParams : [], - sortBy: sortRef.current.sortBy, - sortOrder: sortRef.current.sortOrder, - type: getMarketplaceListFilterType(activePluginTypeRef.current), - }) - } - else { - if (shouldExclude && isSuccess) { - queryMarketplaceCollectionsAndPlugins({ - exclude, - type: getMarketplaceListFilterType(activePluginTypeRef.current), - }) - } - } - }, [queryPlugins, queryMarketplaceCollectionsAndPlugins, isSuccess, exclude]) - - const handleQueryMarketplaceCollectionsAndPlugins = useCallback(() => { - queryMarketplaceCollectionsAndPlugins({ - category: activePluginTypeRef.current === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : activePluginTypeRef.current, - condition: getMarketplaceListCondition(activePluginTypeRef.current), - exclude, - type: getMarketplaceListFilterType(activePluginTypeRef.current), - }) - resetPlugins() - }, [exclude, queryMarketplaceCollectionsAndPlugins, resetPlugins]) - - const applyUrlFilters = useCallback(() => { - if (!showSearchParams) - return - const nextFilters = { - q: searchPluginTextRef.current, - category: activePluginTypeRef.current, - tags: filterPluginTagsRef.current, - } - const categoryChanged = urlFilters.category !== nextFilters.category - setUrlFilters(nextFilters, { - history: categoryChanged ? 'push' : 'replace', - }) - }, [setUrlFilters, showSearchParams, urlFilters.category]) - - const debouncedUpdateSearchParams = useMemo(() => debounce(() => { - applyUrlFilters() - }, 500), [applyUrlFilters]) - - const handleUpdateSearchParams = useCallback((debounced?: boolean) => { - if (debounced) { - debouncedUpdateSearchParams() - } - else { - applyUrlFilters() - } - }, [applyUrlFilters, debouncedUpdateSearchParams]) - - const handleQueryPlugins = useCallback((debounced?: boolean) => { - handleUpdateSearchParams(debounced) - if (debounced) { - queryPluginsWithDebounced({ - query: searchPluginTextRef.current, - category: activePluginTypeRef.current === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : activePluginTypeRef.current, - tags: filterPluginTagsRef.current, - sortBy: sortRef.current.sortBy, - sortOrder: sortRef.current.sortOrder, - exclude, - type: getMarketplaceListFilterType(activePluginTypeRef.current), - }) - } - else { - queryPlugins({ - query: searchPluginTextRef.current, - category: activePluginTypeRef.current === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : activePluginTypeRef.current, - tags: filterPluginTagsRef.current, - sortBy: sortRef.current.sortBy, - sortOrder: sortRef.current.sortOrder, - exclude, - type: getMarketplaceListFilterType(activePluginTypeRef.current), - }) - } - }, [exclude, queryPluginsWithDebounced, queryPlugins, handleUpdateSearchParams]) - - const handleQuery = useCallback((debounced?: boolean) => { - if (!searchPluginTextRef.current && !filterPluginTagsRef.current.length) { - handleUpdateSearchParams(debounced) - cancelQueryPluginsWithDebounced() - handleQueryMarketplaceCollectionsAndPlugins() - return - } - - handleQueryPlugins(debounced) - }, [handleQueryMarketplaceCollectionsAndPlugins, handleQueryPlugins, cancelQueryPluginsWithDebounced, handleUpdateSearchParams]) - - const handleSearchPluginTextChange = useCallback((text: string) => { - setSearchPluginText(text) - searchPluginTextRef.current = text - - handleQuery(true) - }, [handleQuery]) - - const handleFilterPluginTagsChange = useCallback((tags: string[]) => { - setFilterPluginTags(tags) - filterPluginTagsRef.current = tags - - handleQuery() - }, [handleQuery]) - - const handleActivePluginTypeChange = useCallback((type: string) => { - setActivePluginType(type) - activePluginTypeRef.current = type - - handleQuery() - }, [handleQuery]) - - const handleSortChange = useCallback((sort: PluginsSort) => { - setSort(sort) - sortRef.current = sort - - handleQueryPlugins() - }, [handleQueryPlugins]) - - const handlePageChange = useCallback(() => { - if (hasNextPluginsPage) - fetchNextPluginsPage() - }, [fetchNextPluginsPage, hasNextPluginsPage]) - - const handleMoreClick = useCallback((searchParams: SearchParamsFromCollection) => { - setSearchPluginText(searchParams?.query || '') - searchPluginTextRef.current = searchParams?.query || '' - setSort({ - sortBy: searchParams?.sort_by || DEFAULT_SORT.sortBy, - sortOrder: searchParams?.sort_order || DEFAULT_SORT.sortOrder, - }) - sortRef.current = { - sortBy: searchParams?.sort_by || DEFAULT_SORT.sortBy, - sortOrder: searchParams?.sort_order || DEFAULT_SORT.sortOrder, - } - handleQueryPlugins() - }, [handleQueryPlugins]) - - useMarketplaceContainerScroll(handlePageChange, scrollContainerId) - - return ( - - {children} - - ) -} diff --git a/web/app/components/plugins/marketplace/hooks.ts b/web/app/components/plugins/marketplace/hooks.ts index 11558e8c96..b1e4f50767 100644 --- a/web/app/components/plugins/marketplace/hooks.ts +++ b/web/app/components/plugins/marketplace/hooks.ts @@ -26,6 +26,9 @@ import { getMarketplacePluginsByCollectionId, } from './utils' +/** + * @deprecated Use useMarketplaceCollectionsAndPlugins from query.ts instead + */ export const useMarketplaceCollectionsAndPlugins = () => { const [queryParams, setQueryParams] = useState() const [marketplaceCollectionsOverride, setMarketplaceCollections] = useState() @@ -89,7 +92,9 @@ export const useMarketplacePluginsByCollectionId = ( isSuccess, } } - +/** + * @deprecated Use useMarketplacePlugins from query.ts instead + */ export const useMarketplacePlugins = () => { const queryClient = useQueryClient() const [queryParams, setQueryParams] = useState() diff --git a/web/app/components/plugins/marketplace/hydration-client.tsx b/web/app/components/plugins/marketplace/hydration-client.tsx new file mode 100644 index 0000000000..5698db711f --- /dev/null +++ b/web/app/components/plugins/marketplace/hydration-client.tsx @@ -0,0 +1,15 @@ +'use client' + +import { useHydrateAtoms } from 'jotai/utils' +import { preserveSearchStateInQueryAtom } from './atoms' + +export function HydrateMarketplaceAtoms({ + preserveSearchStateInQuery, + children, +}: { + preserveSearchStateInQuery: boolean + children: React.ReactNode +}) { + useHydrateAtoms([[preserveSearchStateInQueryAtom, preserveSearchStateInQuery]]) + return <>{children} +} diff --git a/web/app/components/plugins/marketplace/hydration-server.tsx b/web/app/components/plugins/marketplace/hydration-server.tsx new file mode 100644 index 0000000000..0aa544cff1 --- /dev/null +++ b/web/app/components/plugins/marketplace/hydration-server.tsx @@ -0,0 +1,45 @@ +import type { SearchParams } from 'nuqs' +import { dehydrate, HydrationBoundary } from '@tanstack/react-query' +import { createLoader } from 'nuqs/server' +import { getQueryClientServer } from '@/context/query-client-server' +import { PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants' +import { marketplaceKeys } from './query' +import { marketplaceSearchParamsParsers } from './search-params' +import { getCollectionsParams, getMarketplaceCollectionsAndPlugins } from './utils' + +// The server side logic should move to marketplace's codebase so that we can get rid of Next.js + +async function getDehydratedState(searchParams?: Promise) { + if (!searchParams) { + return + } + const loadSearchParams = createLoader(marketplaceSearchParamsParsers) + const params = await loadSearchParams(searchParams) + + if (!PLUGIN_CATEGORY_WITH_COLLECTIONS.has(params.category)) { + return + } + + const queryClient = getQueryClientServer() + + await queryClient.prefetchQuery({ + queryKey: marketplaceKeys.collections(getCollectionsParams(params.category)), + queryFn: () => getMarketplaceCollectionsAndPlugins(getCollectionsParams(params.category)), + }) + return dehydrate(queryClient) +} + +export async function HydrateQueryClient({ + searchParams, + children, +}: { + searchParams: Promise | undefined + children: React.ReactNode +}) { + const dehydratedState = await getDehydratedState(searchParams) + return ( + + {children} + + ) +} diff --git a/web/app/components/plugins/marketplace/index.spec.tsx b/web/app/components/plugins/marketplace/index.spec.tsx index b3b1d58dd4..1a3cd15b6b 100644 --- a/web/app/components/plugins/marketplace/index.spec.tsx +++ b/web/app/components/plugins/marketplace/index.spec.tsx @@ -1,6 +1,6 @@ -import type { MarketplaceCollection, SearchParams, SearchParamsFromCollection } from './types' +import type { MarketplaceCollection } from './types' import type { Plugin } from '@/app/components/plugins/types' -import { act, fireEvent, render, renderHook, screen } from '@testing-library/react' +import { act, render, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { PluginCategoryEnum } from '@/app/components/plugins/types' @@ -9,10 +9,7 @@ import { PluginCategoryEnum } from '@/app/components/plugins/types' // ================================ // Note: Import after mocks are set up -import { DEFAULT_SORT, SCROLL_BOTTOM_THRESHOLD } from './constants' -import { MarketplaceContext, MarketplaceContextProvider, useMarketplaceContext } from './context' -import PluginTypeSwitch, { PLUGIN_TYPE_SEARCH_MAP } from './plugin-type-switch' -import StickySearchAndSwitchWrapper from './sticky-search-and-switch-wrapper' +import { DEFAULT_SORT, PLUGIN_TYPE_SEARCH_MAP, SCROLL_BOTTOM_THRESHOLD } from './constants' import { getFormattedPlugin, getMarketplaceListCondition, @@ -62,7 +59,7 @@ vi.mock('@/service/use-plugins', () => ({ // Mock tanstack query const mockFetchNextPage = vi.fn() -let mockHasNextPage = false +const mockHasNextPage = false let mockInfiniteQueryData: { pages: Array<{ plugins: unknown[], total: number, page: number, pageSize: number }> } | undefined let capturedInfiniteQueryFn: ((ctx: { pageParam: number, signal: AbortSignal }) => Promise) | null = null let capturedQueryFn: ((ctx: { signal: AbortSignal }) => Promise) | null = null @@ -176,7 +173,7 @@ vi.mock('@/i18n-config/server', () => ({ })) // Mock useTheme hook -let mockTheme = 'light' +const mockTheme = 'light' vi.mock('@/hooks/use-theme', () => ({ default: () => ({ theme: mockTheme, @@ -367,47 +364,6 @@ const createMockCollection = (overrides?: Partial): Marke ...overrides, }) -// ================================ -// Shared Test Components -// ================================ - -// Search input test component - used in multiple tests -const SearchInputTestComponent = () => { - const searchText = useMarketplaceContext(v => v.searchPluginText) - const handleChange = useMarketplaceContext(v => v.handleSearchPluginTextChange) - - return ( -
- handleChange(e.target.value)} - /> -
{searchText}
-
- ) -} - -// Plugin type change test component -const PluginTypeChangeTestComponent = () => { - const handleChange = useMarketplaceContext(v => v.handleActivePluginTypeChange) - return ( - - ) -} - -// Page change test component -const PageChangeTestComponent = () => { - const handlePageChange = useMarketplaceContext(v => v.handlePageChange) - return ( - - ) -} - // ================================ // Constants Tests // ================================ @@ -490,7 +446,7 @@ describe('utils', () => { org: 'test-org', name: 'test-plugin', tags: [{ name: 'search' }], - } + } as unknown as Plugin const formatted = getFormattedPlugin(rawPlugin) @@ -504,7 +460,7 @@ describe('utils', () => { name: 'test-bundle', description: 'Bundle description', labels: { 'en-US': 'Test Bundle' }, - } + } as unknown as Plugin const formatted = getFormattedPlugin(rawBundle) @@ -1514,955 +1470,6 @@ describe('flatMap Coverage', () => { }) }) -// ================================ -// Context Tests -// ================================ -describe('MarketplaceContext', () => { - beforeEach(() => { - vi.clearAllMocks() - mockPortalOpenState = false - }) - - describe('MarketplaceContext default values', () => { - it('should have correct default context values', () => { - expect(MarketplaceContext).toBeDefined() - }) - }) - - describe('useMarketplaceContext', () => { - it('should return selected value from context', () => { - const TestComponent = () => { - const searchText = useMarketplaceContext(v => v.searchPluginText) - return
{searchText}
- } - - render( - - - , - ) - - expect(screen.getByTestId('search-text')).toHaveTextContent('') - }) - }) - - describe('MarketplaceContextProvider', () => { - it('should render children', () => { - render( - -
Test Child
-
, - ) - - expect(screen.getByTestId('child')).toBeInTheDocument() - }) - - it('should initialize with default values', () => { - // Reset mock data before this test - mockInfiniteQueryData = undefined - - const TestComponent = () => { - const activePluginType = useMarketplaceContext(v => v.activePluginType) - const filterPluginTags = useMarketplaceContext(v => v.filterPluginTags) - const sort = useMarketplaceContext(v => v.sort) - const page = useMarketplaceContext(v => v.page) - - return ( -
-
{activePluginType}
-
{filterPluginTags.join(',')}
-
{sort.sortBy}
-
{page}
-
- ) - } - - render( - - - , - ) - - expect(screen.getByTestId('active-type')).toHaveTextContent('all') - expect(screen.getByTestId('tags')).toHaveTextContent('') - expect(screen.getByTestId('sort')).toHaveTextContent('install_count') - // Page depends on mock data, could be 0 or 1 depending on query state - expect(screen.getByTestId('page')).toBeInTheDocument() - }) - - it('should initialize with searchParams from props', () => { - const searchParams: SearchParams = { - q: 'test query', - category: 'tool', - } - - const TestComponent = () => { - const searchText = useMarketplaceContext(v => v.searchPluginText) - return
{searchText}
- } - - render( - - - , - ) - - expect(screen.getByTestId('search')).toHaveTextContent('test query') - }) - - it('should provide handleSearchPluginTextChange function', () => { - render( - - - , - ) - - const input = screen.getByTestId('search-input') - fireEvent.change(input, { target: { value: 'new search' } }) - - expect(screen.getByTestId('search-display')).toHaveTextContent('new search') - }) - - it('should provide handleFilterPluginTagsChange function', () => { - const TestComponent = () => { - const tags = useMarketplaceContext(v => v.filterPluginTags) - const handleChange = useMarketplaceContext(v => v.handleFilterPluginTagsChange) - - return ( -
- -
{tags.join(',')}
-
- ) - } - - render( - - - , - ) - - fireEvent.click(screen.getByTestId('add-tag')) - - expect(screen.getByTestId('tags-display')).toHaveTextContent('search,image') - }) - - it('should provide handleActivePluginTypeChange function', () => { - const TestComponent = () => { - const activeType = useMarketplaceContext(v => v.activePluginType) - const handleChange = useMarketplaceContext(v => v.handleActivePluginTypeChange) - - return ( -
- -
{activeType}
-
- ) - } - - render( - - - , - ) - - fireEvent.click(screen.getByTestId('change-type')) - - expect(screen.getByTestId('type-display')).toHaveTextContent('tool') - }) - - it('should provide handleSortChange function', () => { - const TestComponent = () => { - const sort = useMarketplaceContext(v => v.sort) - const handleChange = useMarketplaceContext(v => v.handleSortChange) - - return ( -
- -
{`${sort.sortBy}-${sort.sortOrder}`}
-
- ) - } - - render( - - - , - ) - - fireEvent.click(screen.getByTestId('change-sort')) - - expect(screen.getByTestId('sort-display')).toHaveTextContent('created_at-ASC') - }) - - it('should provide handleMoreClick function', () => { - const TestComponent = () => { - const searchText = useMarketplaceContext(v => v.searchPluginText) - const sort = useMarketplaceContext(v => v.sort) - const handleMoreClick = useMarketplaceContext(v => v.handleMoreClick) - - const searchParams: SearchParamsFromCollection = { - query: 'more query', - sort_by: 'version_updated_at', - sort_order: 'DESC', - } - - return ( -
- -
{searchText}
-
{`${sort.sortBy}-${sort.sortOrder}`}
-
- ) - } - - render( - - - , - ) - - fireEvent.click(screen.getByTestId('more-click')) - - expect(screen.getByTestId('search-display')).toHaveTextContent('more query') - expect(screen.getByTestId('sort-display')).toHaveTextContent('version_updated_at-DESC') - }) - - it('should provide resetPlugins function', () => { - const TestComponent = () => { - const resetPlugins = useMarketplaceContext(v => v.resetPlugins) - const plugins = useMarketplaceContext(v => v.plugins) - - return ( -
- -
{plugins ? 'has plugins' : 'no plugins'}
-
- ) - } - - render( - - - , - ) - - fireEvent.click(screen.getByTestId('reset-plugins')) - - // Plugins should remain undefined after reset - expect(screen.getByTestId('plugins-display')).toHaveTextContent('no plugins') - }) - - it('should accept shouldExclude prop', () => { - const TestComponent = () => { - const isLoading = useMarketplaceContext(v => v.isLoading) - return
{isLoading.toString()}
- } - - render( - - - , - ) - - expect(screen.getByTestId('loading')).toBeInTheDocument() - }) - - it('should accept scrollContainerId prop', () => { - render( - -
Child
-
, - ) - - expect(screen.getByTestId('child')).toBeInTheDocument() - }) - - it('should accept showSearchParams prop', () => { - render( - -
Child
-
, - ) - - expect(screen.getByTestId('child')).toBeInTheDocument() - }) - }) -}) - -// ================================ -// PluginTypeSwitch Tests -// ================================ -describe('PluginTypeSwitch', () => { - // Mock context values for PluginTypeSwitch - const mockContextValues = { - activePluginType: 'all', - handleActivePluginTypeChange: vi.fn(), - } - - beforeEach(() => { - vi.clearAllMocks() - mockContextValues.activePluginType = 'all' - mockContextValues.handleActivePluginTypeChange = vi.fn() - - vi.doMock('./context', () => ({ - useMarketplaceContext: (selector: (v: typeof mockContextValues) => unknown) => selector(mockContextValues), - })) - }) - - // Note: PluginTypeSwitch uses internal context, so we test within the provider - describe('Rendering', () => { - it('should render without crashing', () => { - const TestComponent = () => { - const activeType = useMarketplaceContext(v => v.activePluginType) - const handleChange = useMarketplaceContext(v => v.handleActivePluginTypeChange) - - return ( -
-
handleChange('all')} - data-testid="all-option" - > - All -
-
handleChange('tool')} - data-testid="tool-option" - > - Tools -
-
- ) - } - - render( - - - , - ) - - expect(screen.getByTestId('all-option')).toBeInTheDocument() - expect(screen.getByTestId('tool-option')).toBeInTheDocument() - }) - - it('should highlight active plugin type', () => { - const TestComponent = () => { - const activeType = useMarketplaceContext(v => v.activePluginType) - const handleChange = useMarketplaceContext(v => v.handleActivePluginTypeChange) - - return ( -
-
handleChange('all')} - data-testid="all-option" - > - All -
-
- ) - } - - render( - - - , - ) - - expect(screen.getByTestId('all-option')).toHaveClass('active') - }) - }) - - describe('User Interactions', () => { - it('should call handleActivePluginTypeChange when option is clicked', () => { - const TestComponent = () => { - const handleChange = useMarketplaceContext(v => v.handleActivePluginTypeChange) - const activeType = useMarketplaceContext(v => v.activePluginType) - - return ( -
-
handleChange('tool')} - data-testid="tool-option" - > - Tools -
-
{activeType}
-
- ) - } - - render( - - - , - ) - - fireEvent.click(screen.getByTestId('tool-option')) - expect(screen.getByTestId('active-type')).toHaveTextContent('tool') - }) - - it('should update active type when different option is selected', () => { - const TestComponent = () => { - const activeType = useMarketplaceContext(v => v.activePluginType) - const handleChange = useMarketplaceContext(v => v.handleActivePluginTypeChange) - - return ( -
-
handleChange('model')} - data-testid="model-option" - > - Models -
-
{activeType}
-
- ) - } - - render( - - - , - ) - - fireEvent.click(screen.getByTestId('model-option')) - - expect(screen.getByTestId('active-display')).toHaveTextContent('model') - }) - }) - - describe('Props', () => { - it('should accept locale prop', () => { - const TestComponent = () => { - const activeType = useMarketplaceContext(v => v.activePluginType) - return
{activeType}
- } - - render( - - - , - ) - - expect(screen.getByTestId('type')).toBeInTheDocument() - }) - - it('should accept className prop', () => { - const { container } = render( - -
- Content -
-
, - ) - - expect(container.querySelector('.custom-class')).toBeInTheDocument() - }) - }) -}) - -// ================================ -// StickySearchAndSwitchWrapper Tests -// ================================ -describe('StickySearchAndSwitchWrapper', () => { - beforeEach(() => { - vi.clearAllMocks() - mockPortalOpenState = false - }) - - describe('Rendering', () => { - it('should render without crashing', () => { - const { container } = render( - - - , - ) - - expect(container.firstChild).toBeInTheDocument() - }) - - it('should apply default styling', () => { - const { container } = render( - - - , - ) - - const wrapper = container.querySelector('.mt-4.bg-background-body') - expect(wrapper).toBeInTheDocument() - }) - - it('should apply sticky positioning when pluginTypeSwitchClassName contains top-', () => { - const { container } = render( - - - , - ) - - const wrapper = container.querySelector('.sticky.z-10') - expect(wrapper).toBeInTheDocument() - }) - - it('should not apply sticky positioning without top- class', () => { - const { container } = render( - - - , - ) - - const wrapper = container.querySelector('.sticky') - expect(wrapper).toBeNull() - }) - }) - - describe('Props', () => { - it('should accept showSearchParams prop', () => { - render( - - - , - ) - - expect(screen.getByTestId('portal-elem')).toBeInTheDocument() - }) - - it('should pass pluginTypeSwitchClassName to wrapper', () => { - const { container } = render( - - - , - ) - - const wrapper = container.querySelector('.top-16.custom-style') - expect(wrapper).toBeInTheDocument() - }) - }) -}) - -// ================================ -// Integration Tests -// ================================ -describe('Marketplace Integration', () => { - beforeEach(() => { - vi.clearAllMocks() - mockPortalOpenState = false - mockTheme = 'light' - }) - - describe('Context with child components', () => { - it('should share state between multiple consumers', () => { - const SearchDisplay = () => { - const searchText = useMarketplaceContext(v => v.searchPluginText) - return
{searchText || 'empty'}
- } - - const SearchInput = () => { - const handleChange = useMarketplaceContext(v => v.handleSearchPluginTextChange) - return ( - handleChange(e.target.value)} - /> - ) - } - - render( - - - - , - ) - - expect(screen.getByTestId('search-display')).toHaveTextContent('empty') - - fireEvent.change(screen.getByTestId('search-input'), { target: { value: 'test' } }) - - expect(screen.getByTestId('search-display')).toHaveTextContent('test') - }) - - it('should update tags and reset plugins when search criteria changes', () => { - const TestComponent = () => { - const tags = useMarketplaceContext(v => v.filterPluginTags) - const handleTagsChange = useMarketplaceContext(v => v.handleFilterPluginTagsChange) - const resetPlugins = useMarketplaceContext(v => v.resetPlugins) - - const handleAddTag = () => { - handleTagsChange(['search']) - } - - const handleReset = () => { - handleTagsChange([]) - resetPlugins() - } - - return ( -
- - -
{tags.join(',') || 'none'}
-
- ) - } - - render( - - - , - ) - - expect(screen.getByTestId('tags')).toHaveTextContent('none') - - fireEvent.click(screen.getByTestId('add-tag')) - expect(screen.getByTestId('tags')).toHaveTextContent('search') - - fireEvent.click(screen.getByTestId('reset')) - expect(screen.getByTestId('tags')).toHaveTextContent('none') - }) - }) - - describe('Sort functionality', () => { - it('should update sort and trigger query', () => { - const TestComponent = () => { - const sort = useMarketplaceContext(v => v.sort) - const handleSortChange = useMarketplaceContext(v => v.handleSortChange) - - return ( -
- - -
{sort.sortBy}
-
- ) - } - - render( - - - , - ) - - expect(screen.getByTestId('current-sort')).toHaveTextContent('install_count') - - fireEvent.click(screen.getByTestId('sort-recent')) - expect(screen.getByTestId('current-sort')).toHaveTextContent('version_updated_at') - - fireEvent.click(screen.getByTestId('sort-popular')) - expect(screen.getByTestId('current-sort')).toHaveTextContent('install_count') - }) - }) - - describe('Plugin type switching', () => { - it('should filter by plugin type', () => { - const TestComponent = () => { - const activeType = useMarketplaceContext(v => v.activePluginType) - const handleTypeChange = useMarketplaceContext(v => v.handleActivePluginTypeChange) - - return ( -
- {Object.entries(PLUGIN_TYPE_SEARCH_MAP).map(([key, value]) => ( - - ))} -
{activeType}
-
- ) - } - - render( - - - , - ) - - expect(screen.getByTestId('active-type')).toHaveTextContent('all') - - fireEvent.click(screen.getByTestId('type-tool')) - expect(screen.getByTestId('active-type')).toHaveTextContent('tool') - - fireEvent.click(screen.getByTestId('type-model')) - expect(screen.getByTestId('active-type')).toHaveTextContent('model') - - fireEvent.click(screen.getByTestId('type-bundle')) - expect(screen.getByTestId('active-type')).toHaveTextContent('bundle') - }) - }) -}) - -// ================================ -// Edge Cases Tests -// ================================ -describe('Edge Cases', () => { - beforeEach(() => { - vi.clearAllMocks() - mockPortalOpenState = false - }) - - describe('Empty states', () => { - it('should handle empty search text', () => { - const TestComponent = () => { - const searchText = useMarketplaceContext(v => v.searchPluginText) - return
{searchText || 'empty'}
- } - - render( - - - , - ) - - expect(screen.getByTestId('search')).toHaveTextContent('empty') - }) - - it('should handle empty tags array', () => { - const TestComponent = () => { - const tags = useMarketplaceContext(v => v.filterPluginTags) - return
{tags.length === 0 ? 'no tags' : tags.join(',')}
- } - - render( - - - , - ) - - expect(screen.getByTestId('tags')).toHaveTextContent('no tags') - }) - - it('should handle undefined plugins', () => { - const TestComponent = () => { - const plugins = useMarketplaceContext(v => v.plugins) - return
{plugins === undefined ? 'undefined' : 'defined'}
- } - - render( - - - , - ) - - expect(screen.getByTestId('plugins')).toHaveTextContent('undefined') - }) - }) - - describe('Special characters in search', () => { - it('should handle special characters in search text', () => { - render( - - - , - ) - - const input = screen.getByTestId('search-input') - - // Test with special characters - fireEvent.change(input, { target: { value: 'test@#$%^&*()' } }) - expect(screen.getByTestId('search-display')).toHaveTextContent('test@#$%^&*()') - - // Test with unicode characters - fireEvent.change(input, { target: { value: '测试中文' } }) - expect(screen.getByTestId('search-display')).toHaveTextContent('测试中文') - - // Test with emojis - fireEvent.change(input, { target: { value: '🔍 search' } }) - expect(screen.getByTestId('search-display')).toHaveTextContent('🔍 search') - }) - }) - - describe('Rapid state changes', () => { - it('should handle rapid search text changes', async () => { - render( - - - , - ) - - const input = screen.getByTestId('search-input') - - // Rapidly change values - fireEvent.change(input, { target: { value: 'a' } }) - fireEvent.change(input, { target: { value: 'ab' } }) - fireEvent.change(input, { target: { value: 'abc' } }) - fireEvent.change(input, { target: { value: 'abcd' } }) - fireEvent.change(input, { target: { value: 'abcde' } }) - - // Final value should be the last one - expect(screen.getByTestId('search-display')).toHaveTextContent('abcde') - }) - - it('should handle rapid type changes', () => { - const TestComponent = () => { - const activeType = useMarketplaceContext(v => v.activePluginType) - const handleChange = useMarketplaceContext(v => v.handleActivePluginTypeChange) - - return ( -
- - - -
{activeType}
-
- ) - } - - render( - - - , - ) - - // Rapidly click different types - fireEvent.click(screen.getByTestId('type-tool')) - fireEvent.click(screen.getByTestId('type-model')) - fireEvent.click(screen.getByTestId('type-all')) - fireEvent.click(screen.getByTestId('type-tool')) - - expect(screen.getByTestId('active-type')).toHaveTextContent('tool') - }) - }) - - describe('Boundary conditions', () => { - it('should handle very long search text', () => { - const longText = 'a'.repeat(1000) - - const TestComponent = () => { - const searchText = useMarketplaceContext(v => v.searchPluginText) - const handleChange = useMarketplaceContext(v => v.handleSearchPluginTextChange) - - return ( -
- handleChange(e.target.value)} - /> -
{searchText.length}
-
- ) - } - - render( - - - , - ) - - fireEvent.change(screen.getByTestId('search-input'), { target: { value: longText } }) - - expect(screen.getByTestId('search-length')).toHaveTextContent('1000') - }) - - it('should handle large number of tags', () => { - const manyTags = Array.from({ length: 100 }, (_, i) => `tag-${i}`) - - const TestComponent = () => { - const tags = useMarketplaceContext(v => v.filterPluginTags) - const handleChange = useMarketplaceContext(v => v.handleFilterPluginTagsChange) - - return ( -
- -
{tags.length}
-
- ) - } - - render( - - - , - ) - - fireEvent.click(screen.getByTestId('add-many-tags')) - - expect(screen.getByTestId('tags-count')).toHaveTextContent('100') - }) - }) - - describe('Sort edge cases', () => { - it('should handle same sort selection', () => { - const TestComponent = () => { - const sort = useMarketplaceContext(v => v.sort) - const handleSortChange = useMarketplaceContext(v => v.handleSortChange) - - return ( -
- -
{`${sort.sortBy}-${sort.sortOrder}`}
-
- ) - } - - render( - - - , - ) - - // Initial sort should be install_count-DESC - expect(screen.getByTestId('sort-display')).toHaveTextContent('install_count-DESC') - - // Click same sort - should not cause issues - fireEvent.click(screen.getByTestId('select-same-sort')) - - expect(screen.getByTestId('sort-display')).toHaveTextContent('install_count-DESC') - }) - }) -}) - // ================================ // Async Utils Tests // ================================ @@ -2685,338 +1692,6 @@ describe('useMarketplaceContainerScroll', () => { }) }) -// ================================ -// Plugin Type Switch Component Tests -// ================================ -describe('PluginTypeSwitch Component', () => { - beforeEach(() => { - vi.clearAllMocks() - mockPortalOpenState = false - }) - - describe('Rendering actual component', () => { - it('should render all plugin type options', () => { - render( - - - , - ) - - // Note: The global mock returns the key with namespace prefix (plugin.) - expect(screen.getByText('plugin.category.all')).toBeInTheDocument() - expect(screen.getByText('plugin.category.models')).toBeInTheDocument() - expect(screen.getByText('plugin.category.tools')).toBeInTheDocument() - expect(screen.getByText('plugin.category.datasources')).toBeInTheDocument() - expect(screen.getByText('plugin.category.triggers')).toBeInTheDocument() - expect(screen.getByText('plugin.category.agents')).toBeInTheDocument() - expect(screen.getByText('plugin.category.extensions')).toBeInTheDocument() - expect(screen.getByText('plugin.category.bundles')).toBeInTheDocument() - }) - - it('should apply className prop', () => { - const { container } = render( - - - , - ) - - expect(container.querySelector('.custom-class')).toBeInTheDocument() - }) - - it('should call handleActivePluginTypeChange on option click', () => { - const TestWrapper = () => { - const activeType = useMarketplaceContext(v => v.activePluginType) - return ( -
- -
{activeType}
-
- ) - } - - render( - - - , - ) - - fireEvent.click(screen.getByText('plugin.category.tools')) - expect(screen.getByTestId('active-type-display')).toHaveTextContent('tool') - }) - - it('should highlight active option with correct classes', () => { - const TestWrapper = () => { - const handleChange = useMarketplaceContext(v => v.handleActivePluginTypeChange) - return ( -
- - -
- ) - } - - render( - - - , - ) - - fireEvent.click(screen.getByTestId('set-model')) - const modelOption = screen.getByText('plugin.category.models').closest('div') - expect(modelOption).toHaveClass('shadow-xs') - }) - }) - - describe('Popstate handling', () => { - it('should handle popstate event when showSearchParams is true', () => { - const originalHref = window.location.href - - const TestWrapper = () => { - const activeType = useMarketplaceContext(v => v.activePluginType) - return ( -
- -
{activeType}
-
- ) - } - - render( - - - , - ) - - const popstateEvent = new PopStateEvent('popstate') - window.dispatchEvent(popstateEvent) - - expect(screen.getByTestId('active-type')).toBeInTheDocument() - expect(window.location.href).toBe(originalHref) - }) - - it('should not handle popstate when showSearchParams is false', () => { - const TestWrapper = () => { - const activeType = useMarketplaceContext(v => v.activePluginType) - return ( -
- -
{activeType}
-
- ) - } - - render( - - - , - ) - - expect(screen.getByTestId('active-type')).toHaveTextContent('all') - - const popstateEvent = new PopStateEvent('popstate') - window.dispatchEvent(popstateEvent) - - expect(screen.getByTestId('active-type')).toHaveTextContent('all') - }) - }) -}) - -// ================================ -// Context Advanced Tests -// ================================ -describe('Context Advanced', () => { - beforeEach(() => { - vi.clearAllMocks() - mockPortalOpenState = false - mockSetUrlFilters.mockClear() - mockHasNextPage = false - }) - - describe('URL filter synchronization', () => { - it('should update URL filters when showSearchParams is true and type changes', () => { - render( - - - , - ) - - fireEvent.click(screen.getByTestId('change-type')) - expect(mockSetUrlFilters).toHaveBeenCalled() - }) - - it('should not update URL filters when showSearchParams is false', () => { - render( - - - , - ) - - fireEvent.click(screen.getByTestId('change-type')) - expect(mockSetUrlFilters).not.toHaveBeenCalled() - }) - }) - - describe('handlePageChange', () => { - it('should invoke fetchNextPage when hasNextPage is true', () => { - mockHasNextPage = true - - render( - - - , - ) - - fireEvent.click(screen.getByTestId('next-page')) - expect(mockFetchNextPage).toHaveBeenCalled() - }) - - it('should not invoke fetchNextPage when hasNextPage is false', () => { - mockHasNextPage = false - - render( - - - , - ) - - fireEvent.click(screen.getByTestId('next-page')) - expect(mockFetchNextPage).not.toHaveBeenCalled() - }) - }) - - describe('setMarketplaceCollectionsFromClient', () => { - it('should provide setMarketplaceCollectionsFromClient function', () => { - const TestComponent = () => { - const setCollections = useMarketplaceContext(v => v.setMarketplaceCollectionsFromClient) - - return ( -
- -
- ) - } - - render( - - - , - ) - - expect(screen.getByTestId('set-collections')).toBeInTheDocument() - // The function should be callable without throwing - expect(() => fireEvent.click(screen.getByTestId('set-collections'))).not.toThrow() - }) - }) - - describe('setMarketplaceCollectionPluginsMapFromClient', () => { - it('should provide setMarketplaceCollectionPluginsMapFromClient function', () => { - const TestComponent = () => { - const setPluginsMap = useMarketplaceContext(v => v.setMarketplaceCollectionPluginsMapFromClient) - - return ( -
- -
- ) - } - - render( - - - , - ) - - expect(screen.getByTestId('set-plugins-map')).toBeInTheDocument() - // The function should be callable without throwing - expect(() => fireEvent.click(screen.getByTestId('set-plugins-map'))).not.toThrow() - }) - }) - - describe('handleQueryPlugins', () => { - it('should provide handleQueryPlugins function that can be called', () => { - const TestComponent = () => { - const handleQueryPlugins = useMarketplaceContext(v => v.handleQueryPlugins) - return ( - - ) - } - - render( - - - , - ) - - expect(screen.getByTestId('query-plugins')).toBeInTheDocument() - fireEvent.click(screen.getByTestId('query-plugins')) - expect(screen.getByTestId('query-plugins')).toBeInTheDocument() - }) - }) - - describe('isLoading state', () => { - it('should expose isLoading state', () => { - const TestComponent = () => { - const isLoading = useMarketplaceContext(v => v.isLoading) - return
{isLoading.toString()}
- } - - render( - - - , - ) - - expect(screen.getByTestId('loading')).toHaveTextContent('false') - }) - }) - - describe('isSuccessCollections state', () => { - it('should expose isSuccessCollections state', () => { - const TestComponent = () => { - const isSuccess = useMarketplaceContext(v => v.isSuccessCollections) - return
{isSuccess.toString()}
- } - - render( - - - , - ) - - expect(screen.getByTestId('success')).toHaveTextContent('false') - }) - }) - - describe('pluginsTotal', () => { - it('should expose plugins total count', () => { - const TestComponent = () => { - const total = useMarketplaceContext(v => v.pluginsTotal) - return
{total || 0}
- } - - render( - - - , - ) - - expect(screen.getByTestId('total')).toHaveTextContent('0') - }) - }) -}) - // ================================ // Test Data Factory Tests // ================================ diff --git a/web/app/components/plugins/marketplace/index.tsx b/web/app/components/plugins/marketplace/index.tsx index 08d1bc833f..1f32ee4d29 100644 --- a/web/app/components/plugins/marketplace/index.tsx +++ b/web/app/components/plugins/marketplace/index.tsx @@ -1,55 +1,39 @@ -import type { MarketplaceCollection, SearchParams } from './types' -import type { Plugin } from '@/app/components/plugins/types' +import type { SearchParams } from 'nuqs' import { TanstackQueryInitializer } from '@/context/query-client' -import { MarketplaceContextProvider } from './context' import Description from './description' +import { HydrateMarketplaceAtoms } from './hydration-client' +import { HydrateQueryClient } from './hydration-server' import ListWrapper from './list/list-wrapper' import StickySearchAndSwitchWrapper from './sticky-search-and-switch-wrapper' -import { getMarketplaceCollectionsAndPlugins } from './utils' type MarketplaceProps = { showInstallButton?: boolean - shouldExclude?: boolean - searchParams?: SearchParams pluginTypeSwitchClassName?: string - scrollContainerId?: string - showSearchParams?: boolean + /** + * Pass the search params from the request to prefetch data on the server + * and preserve the search params in the URL. + */ + searchParams?: Promise } + const Marketplace = async ({ showInstallButton = true, - shouldExclude, - searchParams, pluginTypeSwitchClassName, - scrollContainerId, - showSearchParams = true, + searchParams, }: MarketplaceProps) => { - let marketplaceCollections: MarketplaceCollection[] = [] - let marketplaceCollectionPluginsMap: Record = {} - if (!shouldExclude) { - const marketplaceCollectionsAndPluginsData = await getMarketplaceCollectionsAndPlugins() - marketplaceCollections = marketplaceCollectionsAndPluginsData.marketplaceCollections - marketplaceCollectionPluginsMap = marketplaceCollectionsAndPluginsData.marketplaceCollectionPluginsMap - } - return ( - - - - - + + + + + + + ) } diff --git a/web/app/components/plugins/marketplace/list/index.spec.tsx b/web/app/components/plugins/marketplace/list/index.spec.tsx index c8fc6309a4..81616f5958 100644 --- a/web/app/components/plugins/marketplace/list/index.spec.tsx +++ b/web/app/components/plugins/marketplace/list/index.spec.tsx @@ -1,6 +1,6 @@ import type { MarketplaceCollection, SearchParamsFromCollection } from '../types' import type { Plugin } from '@/app/components/plugins/types' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { PluginCategoryEnum } from '@/app/components/plugins/types' import List from './index' @@ -30,23 +30,27 @@ vi.mock('#i18n', () => ({ useLocale: () => 'en-US', })) -// Mock useMarketplaceContext with controllable values -const mockContextValues = { - plugins: undefined as Plugin[] | undefined, - pluginsTotal: 0, - marketplaceCollectionsFromClient: undefined as MarketplaceCollection[] | undefined, - marketplaceCollectionPluginsMapFromClient: undefined as Record | undefined, - isLoading: false, - isSuccessCollections: false, - handleQueryPlugins: vi.fn(), - searchPluginText: '', - filterPluginTags: [] as string[], - page: 1, - handleMoreClick: vi.fn(), -} +// Mock marketplace state hooks with controllable values +const { mockMarketplaceData, mockMoreClick } = vi.hoisted(() => { + return { + mockMarketplaceData: { + plugins: undefined as Plugin[] | undefined, + pluginsTotal: 0, + marketplaceCollections: undefined as MarketplaceCollection[] | undefined, + marketplaceCollectionPluginsMap: undefined as Record | undefined, + isLoading: false, + page: 1, + }, + mockMoreClick: vi.fn(), + } +}) -vi.mock('../context', () => ({ - useMarketplaceContext: (selector: (v: typeof mockContextValues) => unknown) => selector(mockContextValues), +vi.mock('../state', () => ({ + useMarketplaceData: () => mockMarketplaceData, +})) + +vi.mock('../atoms', () => ({ + useMarketplaceMoreClick: () => mockMoreClick, })) // Mock useLocale context @@ -578,7 +582,7 @@ describe('ListWithCollection', () => { // View More Button Tests // ================================ describe('View More Button', () => { - it('should render View More button when collection is searchable and onMoreClick is provided', () => { + it('should render View More button when collection is searchable', () => { const collections = [createMockCollection({ name: 'collection-0', searchable: true, @@ -587,14 +591,12 @@ describe('ListWithCollection', () => { const pluginsMap: Record = { 'collection-0': createMockPluginList(1), } - const onMoreClick = vi.fn() render( , ) @@ -609,42 +611,19 @@ describe('ListWithCollection', () => { const pluginsMap: Record = { 'collection-0': createMockPluginList(1), } - const onMoreClick = vi.fn() render( , ) expect(screen.queryByText('View More')).not.toBeInTheDocument() }) - it('should not render View More button when onMoreClick is not provided', () => { - const collections = [createMockCollection({ - name: 'collection-0', - searchable: true, - })] - const pluginsMap: Record = { - 'collection-0': createMockPluginList(1), - } - - render( - , - ) - - expect(screen.queryByText('View More')).not.toBeInTheDocument() - }) - - it('should call onMoreClick with search_params when View More is clicked', () => { + it('should call moreClick hook with search_params when View More is clicked', () => { const searchParams: SearchParamsFromCollection = { query: 'test-query', sort_by: 'install_count' } const collections = [createMockCollection({ name: 'collection-0', @@ -654,21 +633,19 @@ describe('ListWithCollection', () => { const pluginsMap: Record = { 'collection-0': createMockPluginList(1), } - const onMoreClick = vi.fn() render( , ) fireEvent.click(screen.getByText('View More')) - expect(onMoreClick).toHaveBeenCalledTimes(1) - expect(onMoreClick).toHaveBeenCalledWith(searchParams) + expect(mockMoreClick).toHaveBeenCalledTimes(1) + expect(mockMoreClick).toHaveBeenCalledWith(searchParams) }) }) @@ -802,24 +779,15 @@ describe('ListWithCollection', () => { // ListWrapper Component Tests // ================================ describe('ListWrapper', () => { - const defaultProps = { - marketplaceCollections: [] as MarketplaceCollection[], - marketplaceCollectionPluginsMap: {} as Record, - showInstallButton: false, - } - beforeEach(() => { vi.clearAllMocks() - // Reset context values - mockContextValues.plugins = undefined - mockContextValues.pluginsTotal = 0 - mockContextValues.marketplaceCollectionsFromClient = undefined - mockContextValues.marketplaceCollectionPluginsMapFromClient = undefined - mockContextValues.isLoading = false - mockContextValues.isSuccessCollections = false - mockContextValues.searchPluginText = '' - mockContextValues.filterPluginTags = [] - mockContextValues.page = 1 + // Reset mock data + mockMarketplaceData.plugins = undefined + mockMarketplaceData.pluginsTotal = 0 + mockMarketplaceData.marketplaceCollections = undefined + mockMarketplaceData.marketplaceCollectionPluginsMap = undefined + mockMarketplaceData.isLoading = false + mockMarketplaceData.page = 1 }) // ================================ @@ -827,32 +795,32 @@ describe('ListWrapper', () => { // ================================ describe('Rendering', () => { it('should render without crashing', () => { - render() + render() expect(document.body).toBeInTheDocument() }) it('should render with scrollbarGutter style', () => { - const { container } = render() + const { container } = render() const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveStyle({ scrollbarGutter: 'stable' }) }) it('should render Loading component when isLoading is true and page is 1', () => { - mockContextValues.isLoading = true - mockContextValues.page = 1 + mockMarketplaceData.isLoading = true + mockMarketplaceData.page = 1 - render() + render() expect(screen.getByTestId('loading-component')).toBeInTheDocument() }) it('should not render Loading component when page > 1', () => { - mockContextValues.isLoading = true - mockContextValues.page = 2 + mockMarketplaceData.isLoading = true + mockMarketplaceData.page = 2 - render() + render() expect(screen.queryByTestId('loading-component')).not.toBeInTheDocument() }) @@ -863,26 +831,26 @@ describe('ListWrapper', () => { // ================================ describe('Plugins Header', () => { it('should render plugins result count when plugins are present', () => { - mockContextValues.plugins = createMockPluginList(5) - mockContextValues.pluginsTotal = 5 + mockMarketplaceData.plugins = createMockPluginList(5) + mockMarketplaceData.pluginsTotal = 5 - render() + render() expect(screen.getByText('5 plugins found')).toBeInTheDocument() }) it('should render SortDropdown when plugins are present', () => { - mockContextValues.plugins = createMockPluginList(1) + mockMarketplaceData.plugins = createMockPluginList(1) - render() + render() expect(screen.getByTestId('sort-dropdown')).toBeInTheDocument() }) it('should not render plugins header when plugins is undefined', () => { - mockContextValues.plugins = undefined + mockMarketplaceData.plugins = undefined - render() + render() expect(screen.queryByTestId('sort-dropdown')).not.toBeInTheDocument() }) @@ -892,197 +860,60 @@ describe('ListWrapper', () => { // List Rendering Logic Tests // ================================ describe('List Rendering Logic', () => { - it('should render List when not loading', () => { - mockContextValues.isLoading = false - const collections = createMockCollectionList(1) - const pluginsMap: Record = { + it('should render collections when not loading', () => { + mockMarketplaceData.isLoading = false + mockMarketplaceData.marketplaceCollections = createMockCollectionList(1) + mockMarketplaceData.marketplaceCollectionPluginsMap = { 'collection-0': createMockPluginList(1), } - render( - , - ) + render() expect(screen.getByText('Collection 0')).toBeInTheDocument() }) it('should render List when loading but page > 1', () => { - mockContextValues.isLoading = true - mockContextValues.page = 2 - const collections = createMockCollectionList(1) - const pluginsMap: Record = { + mockMarketplaceData.isLoading = true + mockMarketplaceData.page = 2 + mockMarketplaceData.marketplaceCollections = createMockCollectionList(1) + mockMarketplaceData.marketplaceCollectionPluginsMap = { 'collection-0': createMockPluginList(1), } - render( - , - ) + render() expect(screen.getByText('Collection 0')).toBeInTheDocument() }) - - it('should use client collections when available', () => { - const serverCollections = createMockCollectionList(1) - serverCollections[0].label = { 'en-US': 'Server Collection' } - const clientCollections = createMockCollectionList(1) - clientCollections[0].label = { 'en-US': 'Client Collection' } - - const serverPluginsMap: Record = { - 'collection-0': createMockPluginList(1), - } - const clientPluginsMap: Record = { - 'collection-0': createMockPluginList(1), - } - - mockContextValues.marketplaceCollectionsFromClient = clientCollections - mockContextValues.marketplaceCollectionPluginsMapFromClient = clientPluginsMap - - render( - , - ) - - expect(screen.getByText('Client Collection')).toBeInTheDocument() - expect(screen.queryByText('Server Collection')).not.toBeInTheDocument() - }) - - it('should use server collections when client collections are not available', () => { - const serverCollections = createMockCollectionList(1) - serverCollections[0].label = { 'en-US': 'Server Collection' } - const serverPluginsMap: Record = { - 'collection-0': createMockPluginList(1), - } - - mockContextValues.marketplaceCollectionsFromClient = undefined - mockContextValues.marketplaceCollectionPluginsMapFromClient = undefined - - render( - , - ) - - expect(screen.getByText('Server Collection')).toBeInTheDocument() - }) }) // ================================ - // Context Integration Tests + // Data Integration Tests // ================================ - describe('Context Integration', () => { - it('should pass plugins from context to List', () => { - const plugins = createMockPluginList(2) - mockContextValues.plugins = plugins + describe('Data Integration', () => { + it('should pass plugins from state to List', () => { + mockMarketplaceData.plugins = createMockPluginList(2) - render() + render() expect(screen.getByTestId('card-plugin-0')).toBeInTheDocument() expect(screen.getByTestId('card-plugin-1')).toBeInTheDocument() }) - it('should pass handleMoreClick from context to List', () => { - const mockHandleMoreClick = vi.fn() - mockContextValues.handleMoreClick = mockHandleMoreClick - - const collections = [createMockCollection({ + it('should show View More button and call moreClick hook', () => { + mockMarketplaceData.marketplaceCollections = [createMockCollection({ name: 'collection-0', searchable: true, search_params: { query: 'test' }, })] - const pluginsMap: Record = { + mockMarketplaceData.marketplaceCollectionPluginsMap = { 'collection-0': createMockPluginList(1), } - render( - , - ) + render() fireEvent.click(screen.getByText('View More')) - expect(mockHandleMoreClick).toHaveBeenCalled() - }) - }) - - // ================================ - // Effect Tests (handleQueryPlugins) - // ================================ - describe('handleQueryPlugins Effect', () => { - it('should call handleQueryPlugins when conditions are met', async () => { - const mockHandleQueryPlugins = vi.fn() - mockContextValues.handleQueryPlugins = mockHandleQueryPlugins - mockContextValues.isSuccessCollections = true - mockContextValues.marketplaceCollectionsFromClient = undefined - mockContextValues.searchPluginText = '' - mockContextValues.filterPluginTags = [] - - render() - - await waitFor(() => { - expect(mockHandleQueryPlugins).toHaveBeenCalled() - }) - }) - - it('should not call handleQueryPlugins when client collections exist', async () => { - const mockHandleQueryPlugins = vi.fn() - mockContextValues.handleQueryPlugins = mockHandleQueryPlugins - mockContextValues.isSuccessCollections = true - mockContextValues.marketplaceCollectionsFromClient = createMockCollectionList(1) - mockContextValues.searchPluginText = '' - mockContextValues.filterPluginTags = [] - - render() - - // Give time for effect to run - await waitFor(() => { - expect(mockHandleQueryPlugins).not.toHaveBeenCalled() - }) - }) - - it('should not call handleQueryPlugins when search text exists', async () => { - const mockHandleQueryPlugins = vi.fn() - mockContextValues.handleQueryPlugins = mockHandleQueryPlugins - mockContextValues.isSuccessCollections = true - mockContextValues.marketplaceCollectionsFromClient = undefined - mockContextValues.searchPluginText = 'search text' - mockContextValues.filterPluginTags = [] - - render() - - await waitFor(() => { - expect(mockHandleQueryPlugins).not.toHaveBeenCalled() - }) - }) - - it('should not call handleQueryPlugins when filter tags exist', async () => { - const mockHandleQueryPlugins = vi.fn() - mockContextValues.handleQueryPlugins = mockHandleQueryPlugins - mockContextValues.isSuccessCollections = true - mockContextValues.marketplaceCollectionsFromClient = undefined - mockContextValues.searchPluginText = '' - mockContextValues.filterPluginTags = ['tag1'] - - render() - - await waitFor(() => { - expect(mockHandleQueryPlugins).not.toHaveBeenCalled() - }) + expect(mockMoreClick).toHaveBeenCalled() }) }) @@ -1090,32 +921,32 @@ describe('ListWrapper', () => { // Edge Cases Tests // ================================ describe('Edge Cases', () => { - it('should handle empty plugins array from context', () => { - mockContextValues.plugins = [] - mockContextValues.pluginsTotal = 0 + it('should handle empty plugins array', () => { + mockMarketplaceData.plugins = [] + mockMarketplaceData.pluginsTotal = 0 - render() + render() expect(screen.getByText('0 plugins found')).toBeInTheDocument() expect(screen.getByTestId('empty-component')).toBeInTheDocument() }) it('should handle large pluginsTotal', () => { - mockContextValues.plugins = createMockPluginList(10) - mockContextValues.pluginsTotal = 10000 + mockMarketplaceData.plugins = createMockPluginList(10) + mockMarketplaceData.pluginsTotal = 10000 - render() + render() expect(screen.getByText('10000 plugins found')).toBeInTheDocument() }) it('should handle both loading and has plugins', () => { - mockContextValues.isLoading = true - mockContextValues.page = 2 - mockContextValues.plugins = createMockPluginList(5) - mockContextValues.pluginsTotal = 50 + mockMarketplaceData.isLoading = true + mockMarketplaceData.page = 2 + mockMarketplaceData.plugins = createMockPluginList(5) + mockMarketplaceData.pluginsTotal = 50 - render() + render() // Should show plugins header and list expect(screen.getByText('50 plugins found')).toBeInTheDocument() @@ -1428,106 +1259,72 @@ describe('CardWrapper (via List integration)', () => { describe('Combined Workflows', () => { beforeEach(() => { vi.clearAllMocks() - mockContextValues.plugins = undefined - mockContextValues.pluginsTotal = 0 - mockContextValues.isLoading = false - mockContextValues.page = 1 - mockContextValues.marketplaceCollectionsFromClient = undefined - mockContextValues.marketplaceCollectionPluginsMapFromClient = undefined + mockMarketplaceData.plugins = undefined + mockMarketplaceData.pluginsTotal = 0 + mockMarketplaceData.isLoading = false + mockMarketplaceData.page = 1 + mockMarketplaceData.marketplaceCollections = undefined + mockMarketplaceData.marketplaceCollectionPluginsMap = undefined }) it('should transition from loading to showing collections', async () => { - mockContextValues.isLoading = true - mockContextValues.page = 1 + mockMarketplaceData.isLoading = true + mockMarketplaceData.page = 1 - const { rerender } = render( - , - ) + const { rerender } = render() expect(screen.getByTestId('loading-component')).toBeInTheDocument() // Simulate loading complete - mockContextValues.isLoading = false - const collections = createMockCollectionList(1) - const pluginsMap: Record = { + mockMarketplaceData.isLoading = false + mockMarketplaceData.marketplaceCollections = createMockCollectionList(1) + mockMarketplaceData.marketplaceCollectionPluginsMap = { 'collection-0': createMockPluginList(1), } - mockContextValues.marketplaceCollectionsFromClient = collections - mockContextValues.marketplaceCollectionPluginsMapFromClient = pluginsMap - rerender( - , - ) + rerender() expect(screen.queryByTestId('loading-component')).not.toBeInTheDocument() expect(screen.getByText('Collection 0')).toBeInTheDocument() }) it('should transition from collections to search results', async () => { - const collections = createMockCollectionList(1) - const pluginsMap: Record = { + mockMarketplaceData.marketplaceCollections = createMockCollectionList(1) + mockMarketplaceData.marketplaceCollectionPluginsMap = { 'collection-0': createMockPluginList(1), } - mockContextValues.marketplaceCollectionsFromClient = collections - mockContextValues.marketplaceCollectionPluginsMapFromClient = pluginsMap - const { rerender } = render( - , - ) + const { rerender } = render() expect(screen.getByText('Collection 0')).toBeInTheDocument() // Simulate search results - mockContextValues.plugins = createMockPluginList(5) - mockContextValues.pluginsTotal = 5 + mockMarketplaceData.plugins = createMockPluginList(5) + mockMarketplaceData.pluginsTotal = 5 - rerender( - , - ) + rerender() expect(screen.queryByText('Collection 0')).not.toBeInTheDocument() expect(screen.getByText('5 plugins found')).toBeInTheDocument() }) it('should handle empty search results', () => { - mockContextValues.plugins = [] - mockContextValues.pluginsTotal = 0 + mockMarketplaceData.plugins = [] + mockMarketplaceData.pluginsTotal = 0 - render( - , - ) + render() expect(screen.getByTestId('empty-component')).toBeInTheDocument() expect(screen.getByText('0 plugins found')).toBeInTheDocument() }) it('should support pagination (page > 1)', () => { - mockContextValues.plugins = createMockPluginList(40) - mockContextValues.pluginsTotal = 80 - mockContextValues.isLoading = true - mockContextValues.page = 2 + mockMarketplaceData.plugins = createMockPluginList(40) + mockMarketplaceData.pluginsTotal = 80 + mockMarketplaceData.isLoading = true + mockMarketplaceData.page = 2 - render( - , - ) + render() // Should show existing results while loading more expect(screen.getByText('80 plugins found')).toBeInTheDocument() @@ -1542,9 +1339,9 @@ describe('Combined Workflows', () => { describe('Accessibility', () => { beforeEach(() => { vi.clearAllMocks() - mockContextValues.plugins = undefined - mockContextValues.isLoading = false - mockContextValues.page = 1 + mockMarketplaceData.plugins = undefined + mockMarketplaceData.isLoading = false + mockMarketplaceData.page = 1 }) it('should have semantic structure with collections', () => { @@ -1573,13 +1370,11 @@ describe('Accessibility', () => { const pluginsMap: Record = { 'collection-0': createMockPluginList(1), } - const onMoreClick = vi.fn() render( , ) diff --git a/web/app/components/plugins/marketplace/list/index.tsx b/web/app/components/plugins/marketplace/list/index.tsx index 80b33d0ffd..4ce7272e80 100644 --- a/web/app/components/plugins/marketplace/list/index.tsx +++ b/web/app/components/plugins/marketplace/list/index.tsx @@ -13,7 +13,6 @@ type ListProps = { showInstallButton?: boolean cardContainerClassName?: string cardRender?: (plugin: Plugin) => React.JSX.Element | null - onMoreClick?: () => void emptyClassName?: string } const List = ({ @@ -23,7 +22,6 @@ const List = ({ showInstallButton, cardContainerClassName, cardRender, - onMoreClick, emptyClassName, }: ListProps) => { return ( @@ -36,7 +34,6 @@ const List = ({ showInstallButton={showInstallButton} cardContainerClassName={cardContainerClassName} cardRender={cardRender} - onMoreClick={onMoreClick} /> ) } diff --git a/web/app/components/plugins/marketplace/list/list-with-collection.tsx b/web/app/components/plugins/marketplace/list/list-with-collection.tsx index c17715e71e..264227b666 100644 --- a/web/app/components/plugins/marketplace/list/list-with-collection.tsx +++ b/web/app/components/plugins/marketplace/list/list-with-collection.tsx @@ -1,12 +1,12 @@ 'use client' import type { MarketplaceCollection } from '../types' -import type { SearchParamsFromCollection } from '@/app/components/plugins/marketplace/types' import type { Plugin } from '@/app/components/plugins/types' import { useLocale, useTranslation } from '#i18n' import { RiArrowRightSLine } from '@remixicon/react' import { getLanguage } from '@/i18n-config/language' import { cn } from '@/utils/classnames' +import { useMarketplaceMoreClick } from '../atoms' import CardWrapper from './card-wrapper' type ListWithCollectionProps = { @@ -15,7 +15,6 @@ type ListWithCollectionProps = { showInstallButton?: boolean cardContainerClassName?: string cardRender?: (plugin: Plugin) => React.JSX.Element | null - onMoreClick?: (searchParams?: SearchParamsFromCollection) => void } const ListWithCollection = ({ marketplaceCollections, @@ -23,10 +22,10 @@ const ListWithCollection = ({ showInstallButton, cardContainerClassName, cardRender, - onMoreClick, }: ListWithCollectionProps) => { const { t } = useTranslation() const locale = useLocale() + const onMoreClick = useMarketplaceMoreClick() return ( <> @@ -44,10 +43,10 @@ const ListWithCollection = ({
{collection.description[getLanguage(locale)]}
{ - collection.searchable && onMoreClick && ( + collection.searchable && (
onMoreClick?.(collection.search_params)} + onClick={() => onMoreClick(collection.search_params)} > {t('marketplace.viewMore', { ns: 'plugin' })} diff --git a/web/app/components/plugins/marketplace/list/list-wrapper.tsx b/web/app/components/plugins/marketplace/list/list-wrapper.tsx index 84fcf92daf..a1b0c2529a 100644 --- a/web/app/components/plugins/marketplace/list/list-wrapper.tsx +++ b/web/app/components/plugins/marketplace/list/list-wrapper.tsx @@ -1,46 +1,26 @@ 'use client' -import type { Plugin } from '../../types' -import type { MarketplaceCollection } from '../types' import { useTranslation } from '#i18n' -import { useEffect } from 'react' import Loading from '@/app/components/base/loading' -import { useMarketplaceContext } from '../context' import SortDropdown from '../sort-dropdown' +import { useMarketplaceData } from '../state' import List from './index' type ListWrapperProps = { - marketplaceCollections: MarketplaceCollection[] - marketplaceCollectionPluginsMap: Record showInstallButton?: boolean } const ListWrapper = ({ - marketplaceCollections, - marketplaceCollectionPluginsMap, showInstallButton, }: ListWrapperProps) => { const { t } = useTranslation() - const plugins = useMarketplaceContext(v => v.plugins) - const pluginsTotal = useMarketplaceContext(v => v.pluginsTotal) - const marketplaceCollectionsFromClient = useMarketplaceContext(v => v.marketplaceCollectionsFromClient) - const marketplaceCollectionPluginsMapFromClient = useMarketplaceContext(v => v.marketplaceCollectionPluginsMapFromClient) - const isLoading = useMarketplaceContext(v => v.isLoading) - const isSuccessCollections = useMarketplaceContext(v => v.isSuccessCollections) - const handleQueryPlugins = useMarketplaceContext(v => v.handleQueryPlugins) - const searchPluginText = useMarketplaceContext(v => v.searchPluginText) - const filterPluginTags = useMarketplaceContext(v => v.filterPluginTags) - const page = useMarketplaceContext(v => v.page) - const handleMoreClick = useMarketplaceContext(v => v.handleMoreClick) - useEffect(() => { - if ( - !marketplaceCollectionsFromClient?.length - && isSuccessCollections - && !searchPluginText - && !filterPluginTags.length - ) { - handleQueryPlugins() - } - }, [handleQueryPlugins, marketplaceCollections, marketplaceCollectionsFromClient, isSuccessCollections, searchPluginText, filterPluginTags]) + const { + plugins, + pluginsTotal, + marketplaceCollections, + marketplaceCollectionPluginsMap, + isLoading, + page, + } = useMarketplaceData() return (
1) && ( ) } diff --git a/web/app/components/plugins/marketplace/plugin-type-switch.tsx b/web/app/components/plugins/marketplace/plugin-type-switch.tsx index b9572413ed..6e56a288d8 100644 --- a/web/app/components/plugins/marketplace/plugin-type-switch.tsx +++ b/web/app/components/plugins/marketplace/plugin-type-switch.tsx @@ -1,4 +1,5 @@ 'use client' +import type { ActivePluginType } from './constants' import { useTranslation } from '#i18n' import { RiArchive2Line, @@ -8,35 +9,27 @@ import { RiPuzzle2Line, RiSpeakAiLine, } from '@remixicon/react' -import { useCallback, useEffect } from 'react' +import { useSetAtom } from 'jotai' import { Trigger as TriggerIcon } from '@/app/components/base/icons/src/vender/plugin' import { cn } from '@/utils/classnames' -import { PluginCategoryEnum } from '../types' -import { useMarketplaceContext } from './context' +import { searchModeAtom, useActivePluginType } from './atoms' +import { PLUGIN_CATEGORY_WITH_COLLECTIONS, PLUGIN_TYPE_SEARCH_MAP } from './constants' -export const PLUGIN_TYPE_SEARCH_MAP = { - all: 'all', - model: PluginCategoryEnum.model, - tool: PluginCategoryEnum.tool, - agent: PluginCategoryEnum.agent, - extension: PluginCategoryEnum.extension, - datasource: PluginCategoryEnum.datasource, - trigger: PluginCategoryEnum.trigger, - bundle: 'bundle', -} type PluginTypeSwitchProps = { className?: string - showSearchParams?: boolean } const PluginTypeSwitch = ({ className, - showSearchParams, }: PluginTypeSwitchProps) => { const { t } = useTranslation() - const activePluginType = useMarketplaceContext(s => s.activePluginType) - const handleActivePluginTypeChange = useMarketplaceContext(s => s.handleActivePluginTypeChange) + const [activePluginType, handleActivePluginTypeChange] = useActivePluginType() + const setSearchMode = useSetAtom(searchModeAtom) - const options = [ + const options: Array<{ + value: ActivePluginType + text: string + icon: React.ReactNode | null + }> = [ { value: PLUGIN_TYPE_SEARCH_MAP.all, text: t('category.all', { ns: 'plugin' }), @@ -79,23 +72,6 @@ const PluginTypeSwitch = ({ }, ] - const handlePopState = useCallback(() => { - if (!showSearchParams) - return - // nuqs handles popstate automatically - const url = new URL(window.location.href) - const category = url.searchParams.get('category') || PLUGIN_TYPE_SEARCH_MAP.all - handleActivePluginTypeChange(category) - }, [showSearchParams, handleActivePluginTypeChange]) - - useEffect(() => { - // nuqs manages popstate internally, but we keep this for URL sync - window.addEventListener('popstate', handlePopState) - return () => { - window.removeEventListener('popstate', handlePopState) - } - }, [handlePopState]) - return (
{ handleActivePluginTypeChange(option.value) + if (PLUGIN_CATEGORY_WITH_COLLECTIONS.has(option.value)) { + setSearchMode(null) + } }} > {option.icon} diff --git a/web/app/components/plugins/marketplace/query.ts b/web/app/components/plugins/marketplace/query.ts new file mode 100644 index 0000000000..c5a1421146 --- /dev/null +++ b/web/app/components/plugins/marketplace/query.ts @@ -0,0 +1,38 @@ +import type { CollectionsAndPluginsSearchParams, PluginsSearchParams } from './types' +import { useInfiniteQuery, useQuery } from '@tanstack/react-query' +import { getMarketplaceCollectionsAndPlugins, getMarketplacePlugins } from './utils' + +// TODO: Avoid manual maintenance of query keys and better service management, +// https://github.com/langgenius/dify/issues/30342 + +export const marketplaceKeys = { + all: ['marketplace'] as const, + collections: (params?: CollectionsAndPluginsSearchParams) => [...marketplaceKeys.all, 'collections', params] as const, + collectionPlugins: (collectionId: string, params?: CollectionsAndPluginsSearchParams) => [...marketplaceKeys.all, 'collectionPlugins', collectionId, params] as const, + plugins: (params?: PluginsSearchParams) => [...marketplaceKeys.all, 'plugins', params] as const, +} + +export function useMarketplaceCollectionsAndPlugins( + collectionsParams: CollectionsAndPluginsSearchParams, +) { + return useQuery({ + queryKey: marketplaceKeys.collections(collectionsParams), + queryFn: ({ signal }) => getMarketplaceCollectionsAndPlugins(collectionsParams, { signal }), + }) +} + +export function useMarketplacePlugins( + queryParams: PluginsSearchParams | undefined, +) { + return useInfiniteQuery({ + queryKey: marketplaceKeys.plugins(queryParams), + queryFn: ({ pageParam = 1, signal }) => getMarketplacePlugins(queryParams, pageParam, signal), + getNextPageParam: (lastPage) => { + const nextPage = lastPage.page + 1 + const loaded = lastPage.page * lastPage.pageSize + return loaded < (lastPage.total || 0) ? nextPage : undefined + }, + initialPageParam: 1, + enabled: queryParams !== undefined, + }) +} diff --git a/web/app/components/plugins/marketplace/search-box/index.spec.tsx b/web/app/components/plugins/marketplace/search-box/index.spec.tsx index 3e9cc40be0..85be82cb33 100644 --- a/web/app/components/plugins/marketplace/search-box/index.spec.tsx +++ b/web/app/components/plugins/marketplace/search-box/index.spec.tsx @@ -26,16 +26,19 @@ vi.mock('#i18n', () => ({ }), })) -// Mock useMarketplaceContext -const mockContextValues = { - searchPluginText: '', - handleSearchPluginTextChange: vi.fn(), - filterPluginTags: [] as string[], - handleFilterPluginTagsChange: vi.fn(), -} +// Mock marketplace state hooks +const { mockSearchPluginText, mockHandleSearchPluginTextChange, mockFilterPluginTags, mockHandleFilterPluginTagsChange } = vi.hoisted(() => { + return { + mockSearchPluginText: '', + mockHandleSearchPluginTextChange: vi.fn(), + mockFilterPluginTags: [] as string[], + mockHandleFilterPluginTagsChange: vi.fn(), + } +}) -vi.mock('../context', () => ({ - useMarketplaceContext: (selector: (v: typeof mockContextValues) => unknown) => selector(mockContextValues), +vi.mock('../atoms', () => ({ + useSearchPluginText: () => [mockSearchPluginText, mockHandleSearchPluginTextChange], + useFilterPluginTags: () => [mockFilterPluginTags, mockHandleFilterPluginTagsChange], })) // Mock useTags hook @@ -430,9 +433,6 @@ describe('SearchBoxWrapper', () => { beforeEach(() => { vi.clearAllMocks() mockPortalOpenState = false - // Reset context values - mockContextValues.searchPluginText = '' - mockContextValues.filterPluginTags = [] }) describe('Rendering', () => { @@ -456,28 +456,14 @@ describe('SearchBoxWrapper', () => { }) }) - describe('Context Integration', () => { - it('should use searchPluginText from context', () => { - mockContextValues.searchPluginText = 'context search' - render() - - expect(screen.getByDisplayValue('context search')).toBeInTheDocument() - }) - + describe('Hook Integration', () => { it('should call handleSearchPluginTextChange when search changes', () => { render() const input = screen.getByRole('textbox') fireEvent.change(input, { target: { value: 'new search' } }) - expect(mockContextValues.handleSearchPluginTextChange).toHaveBeenCalledWith('new search') - }) - - it('should use filterPluginTags from context', () => { - mockContextValues.filterPluginTags = ['agent', 'rag'] - render() - - expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + expect(mockHandleSearchPluginTextChange).toHaveBeenCalledWith('new search') }) }) diff --git a/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx b/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx index d7fc004236..9957e9bc42 100644 --- a/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx +++ b/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx @@ -1,15 +1,13 @@ 'use client' import { useTranslation } from '#i18n' -import { useMarketplaceContext } from '../context' +import { useFilterPluginTags, useSearchPluginText } from '../atoms' import SearchBox from './index' const SearchBoxWrapper = () => { const { t } = useTranslation() - const searchPluginText = useMarketplaceContext(v => v.searchPluginText) - const handleSearchPluginTextChange = useMarketplaceContext(v => v.handleSearchPluginTextChange) - const filterPluginTags = useMarketplaceContext(v => v.filterPluginTags) - const handleFilterPluginTagsChange = useMarketplaceContext(v => v.handleFilterPluginTagsChange) + const [searchPluginText, handleSearchPluginTextChange] = useSearchPluginText() + const [filterPluginTags, handleFilterPluginTagsChange] = useFilterPluginTags() return ( (Object.values(PLUGIN_TYPE_SEARCH_MAP) as ActivePluginType[]).withDefault('all').withOptions({ history: 'replace', clearOnDefault: false }), + q: parseAsString.withDefault('').withOptions({ history: 'replace' }), + tags: parseAsArrayOf(parseAsString).withDefault([]).withOptions({ history: 'replace' }), +} diff --git a/web/app/components/plugins/marketplace/sort-dropdown/index.spec.tsx b/web/app/components/plugins/marketplace/sort-dropdown/index.spec.tsx index 3ed7d78b07..f91c7ba4d3 100644 --- a/web/app/components/plugins/marketplace/sort-dropdown/index.spec.tsx +++ b/web/app/components/plugins/marketplace/sort-dropdown/index.spec.tsx @@ -1,4 +1,3 @@ -import type { MarketplaceContextValue } from '../context' import { fireEvent, render, screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -28,18 +27,12 @@ vi.mock('#i18n', () => ({ }), })) -// Mock marketplace context with controllable values -let mockSort = { sortBy: 'install_count', sortOrder: 'DESC' } +// Mock marketplace atoms with controllable values +let mockSort: { sortBy: string, sortOrder: string } = { sortBy: 'install_count', sortOrder: 'DESC' } const mockHandleSortChange = vi.fn() -vi.mock('../context', () => ({ - useMarketplaceContext: (selector: (value: MarketplaceContextValue) => unknown) => { - const contextValue = { - sort: mockSort, - handleSortChange: mockHandleSortChange, - } as unknown as MarketplaceContextValue - return selector(contextValue) - }, +vi.mock('../atoms', () => ({ + useMarketplaceSort: () => [mockSort, mockHandleSortChange], })) // Mock portal component with controllable open state diff --git a/web/app/components/plugins/marketplace/sort-dropdown/index.tsx b/web/app/components/plugins/marketplace/sort-dropdown/index.tsx index 984b114d03..1f7bab1005 100644 --- a/web/app/components/plugins/marketplace/sort-dropdown/index.tsx +++ b/web/app/components/plugins/marketplace/sort-dropdown/index.tsx @@ -10,7 +10,7 @@ import { PortalToFollowElemContent, PortalToFollowElemTrigger, } from '@/app/components/base/portal-to-follow-elem' -import { useMarketplaceContext } from '../context' +import { useMarketplaceSort } from '../atoms' const SortDropdown = () => { const { t } = useTranslation() @@ -36,8 +36,7 @@ const SortDropdown = () => { text: t('marketplace.sortOption.firstReleased', { ns: 'plugin' }), }, ] - const sort = useMarketplaceContext(v => v.sort) - const handleSortChange = useMarketplaceContext(v => v.handleSortChange) + const [sort, handleSortChange] = useMarketplaceSort() const [open, setOpen] = useState(false) const selectedOption = options.find(option => option.value === sort.sortBy && option.order === sort.sortOrder) ?? options[0] diff --git a/web/app/components/plugins/marketplace/state.ts b/web/app/components/plugins/marketplace/state.ts new file mode 100644 index 0000000000..1c1abfc0a1 --- /dev/null +++ b/web/app/components/plugins/marketplace/state.ts @@ -0,0 +1,54 @@ +import type { PluginsSearchParams } from './types' +import { useDebounce } from 'ahooks' +import { useCallback, useMemo } from 'react' +import { useActivePluginType, useFilterPluginTags, useMarketplaceSearchMode, useMarketplaceSortValue, useSearchPluginText } from './atoms' +import { PLUGIN_TYPE_SEARCH_MAP } from './constants' +import { useMarketplaceContainerScroll } from './hooks' +import { useMarketplaceCollectionsAndPlugins, useMarketplacePlugins } from './query' +import { getCollectionsParams, getMarketplaceListFilterType } from './utils' + +export function useMarketplaceData() { + const [searchPluginTextOriginal] = useSearchPluginText() + const searchPluginText = useDebounce(searchPluginTextOriginal, { wait: 500 }) + const [filterPluginTags] = useFilterPluginTags() + const [activePluginType] = useActivePluginType() + + const collectionsQuery = useMarketplaceCollectionsAndPlugins( + getCollectionsParams(activePluginType), + ) + + const sort = useMarketplaceSortValue() + const isSearchMode = useMarketplaceSearchMode() + const queryParams = useMemo((): PluginsSearchParams | undefined => { + if (!isSearchMode) + return undefined + return { + query: searchPluginText, + category: activePluginType === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : activePluginType, + tags: filterPluginTags, + sortBy: sort.sortBy, + sortOrder: sort.sortOrder, + type: getMarketplaceListFilterType(activePluginType), + } + }, [isSearchMode, searchPluginText, activePluginType, filterPluginTags, sort]) + + const pluginsQuery = useMarketplacePlugins(queryParams) + const { hasNextPage, fetchNextPage, isFetching } = pluginsQuery + + const handlePageChange = useCallback(() => { + if (hasNextPage && !isFetching) + fetchNextPage() + }, [fetchNextPage, hasNextPage, isFetching]) + + // Scroll pagination + useMarketplaceContainerScroll(handlePageChange) + + return { + marketplaceCollections: collectionsQuery.data?.marketplaceCollections, + marketplaceCollectionPluginsMap: collectionsQuery.data?.marketplaceCollectionPluginsMap, + plugins: pluginsQuery.data?.pages.flatMap(page => page.plugins), + pluginsTotal: pluginsQuery.data?.pages[0]?.total, + page: pluginsQuery.data?.pages.length || 1, + isLoading: collectionsQuery.isLoading || pluginsQuery.isLoading, + } +} diff --git a/web/app/components/plugins/marketplace/sticky-search-and-switch-wrapper.tsx b/web/app/components/plugins/marketplace/sticky-search-and-switch-wrapper.tsx index 3d3530c83e..4da3844c0a 100644 --- a/web/app/components/plugins/marketplace/sticky-search-and-switch-wrapper.tsx +++ b/web/app/components/plugins/marketplace/sticky-search-and-switch-wrapper.tsx @@ -6,12 +6,10 @@ import SearchBoxWrapper from './search-box/search-box-wrapper' type StickySearchAndSwitchWrapperProps = { pluginTypeSwitchClassName?: string - showSearchParams?: boolean } const StickySearchAndSwitchWrapper = ({ pluginTypeSwitchClassName, - showSearchParams, }: StickySearchAndSwitchWrapperProps) => { const hasCustomTopClass = pluginTypeSwitchClassName?.includes('top-') @@ -24,9 +22,7 @@ const StickySearchAndSwitchWrapper = ({ )} > - +
) } diff --git a/web/app/components/plugins/marketplace/utils.ts b/web/app/components/plugins/marketplace/utils.ts index e51c9b76a6..eaf299314c 100644 --- a/web/app/components/plugins/marketplace/utils.ts +++ b/web/app/components/plugins/marketplace/utils.ts @@ -1,16 +1,19 @@ +import type { ActivePluginType } from './constants' import type { CollectionsAndPluginsSearchParams, MarketplaceCollection, + PluginsSearchParams, } from '@/app/components/plugins/marketplace/types' -import type { Plugin } from '@/app/components/plugins/types' +import type { Plugin, PluginsFromMarketplaceResponse } from '@/app/components/plugins/types' import { PluginCategoryEnum } from '@/app/components/plugins/types' import { APP_VERSION, IS_MARKETPLACE, MARKETPLACE_API_PREFIX, } from '@/config' +import { postMarketplace } from '@/service/base' import { getMarketplaceUrl } from '@/utils/var' -import { PLUGIN_TYPE_SEARCH_MAP } from './plugin-type-switch' +import { PLUGIN_TYPE_SEARCH_MAP } from './constants' type MarketplaceFetchOptions = { signal?: AbortSignal @@ -26,12 +29,13 @@ export const getPluginIconInMarketplace = (plugin: Plugin) => { return `${MARKETPLACE_API_PREFIX}/plugins/${plugin.org}/${plugin.name}/icon` } -export const getFormattedPlugin = (bundle: any) => { +export const getFormattedPlugin = (bundle: Plugin): Plugin => { if (bundle.type === 'bundle') { return { ...bundle, icon: getPluginIconInMarketplace(bundle), brief: bundle.description, + // @ts-expect-error I do not have enough information label: bundle.labels, } } @@ -129,6 +133,64 @@ export const getMarketplaceCollectionsAndPlugins = async ( } } +export const getMarketplacePlugins = async ( + queryParams: PluginsSearchParams | undefined, + pageParam: number, + signal?: AbortSignal, +) => { + if (!queryParams) { + return { + plugins: [] as Plugin[], + total: 0, + page: 1, + pageSize: 40, + } + } + + const { + query, + sortBy, + sortOrder, + category, + tags, + type, + pageSize = 40, + } = queryParams + const pluginOrBundle = type === 'bundle' ? 'bundles' : 'plugins' + + try { + const res = await postMarketplace<{ data: PluginsFromMarketplaceResponse }>(`/${pluginOrBundle}/search/advanced`, { + body: { + page: pageParam, + page_size: pageSize, + query, + sort_by: sortBy, + sort_order: sortOrder, + category: category !== 'all' ? category : '', + tags, + type, + }, + signal, + }) + const resPlugins = res.data.bundles || res.data.plugins || [] + + return { + plugins: resPlugins.map(plugin => getFormattedPlugin(plugin)), + total: res.data.total, + page: pageParam, + pageSize, + } + } + catch { + return { + plugins: [], + total: 0, + page: pageParam, + pageSize, + } + } +} + export const getMarketplaceListCondition = (pluginType: string) => { if ([PluginCategoryEnum.tool, PluginCategoryEnum.agent, PluginCategoryEnum.model, PluginCategoryEnum.datasource, PluginCategoryEnum.trigger].includes(pluginType as PluginCategoryEnum)) return `category=${pluginType}` @@ -142,7 +204,7 @@ export const getMarketplaceListCondition = (pluginType: string) => { return '' } -export const getMarketplaceListFilterType = (category: string) => { +export const getMarketplaceListFilterType = (category: ActivePluginType) => { if (category === PLUGIN_TYPE_SEARCH_MAP.all) return undefined @@ -151,3 +213,14 @@ export const getMarketplaceListFilterType = (category: string) => { return 'plugin' } + +export function getCollectionsParams(category: ActivePluginType): CollectionsAndPluginsSearchParams { + if (category === PLUGIN_TYPE_SEARCH_MAP.all) { + return {} + } + return { + category, + condition: getMarketplaceListCondition(category), + type: getMarketplaceListFilterType(category), + } +} diff --git a/web/app/components/plugins/plugin-page/index.tsx b/web/app/components/plugins/plugin-page/index.tsx index b8fc891254..1f88f691ef 100644 --- a/web/app/components/plugins/plugin-page/index.tsx +++ b/web/app/components/plugins/plugin-page/index.tsx @@ -27,7 +27,7 @@ import { cn } from '@/utils/classnames' import { PLUGIN_PAGE_TABS_MAP } from '../hooks' import InstallFromLocalPackage from '../install-plugin/install-from-local-package' import InstallFromMarketplace from '../install-plugin/install-from-marketplace' -import { PLUGIN_TYPE_SEARCH_MAP } from '../marketplace/plugin-type-switch' +import { PLUGIN_TYPE_SEARCH_MAP } from '../marketplace/constants' import { PluginPageContextProvider, usePluginPageContext, diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/index.spec.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/index.spec.tsx index d65b0b7957..1008ef461d 100644 --- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/index.spec.tsx +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/index.spec.tsx @@ -262,7 +262,7 @@ vi.mock('@/app/components/base/icons/src/vender/other', () => ({ })) // Mock PLUGIN_TYPE_SEARCH_MAP -vi.mock('../../marketplace/plugin-type-switch', () => ({ +vi.mock('../../marketplace/constants', () => ({ PLUGIN_TYPE_SEARCH_MAP: { all: 'all', model: 'model', diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/tool-picker.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/tool-picker.tsx index a91df6c793..4e681a6b67 100644 --- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/tool-picker.tsx +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/tool-picker.tsx @@ -1,5 +1,6 @@ 'use client' import type { FC } from 'react' +import type { ActivePluginType } from '../../marketplace/constants' import * as React from 'react' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -12,7 +13,7 @@ import { import SearchBox from '@/app/components/plugins/marketplace/search-box' import { useInstalledPluginList } from '@/service/use-plugins' import { cn } from '@/utils/classnames' -import { PLUGIN_TYPE_SEARCH_MAP } from '../../marketplace/plugin-type-switch' +import { PLUGIN_TYPE_SEARCH_MAP } from '../../marketplace/constants' import { PluginSource } from '../../types' import NoDataPlaceholder from './no-data-placeholder' import ToolItem from './tool-item' @@ -73,7 +74,7 @@ const ToolPicker: FC = ({ }, ] - const [pluginType, setPluginType] = useState(PLUGIN_TYPE_SEARCH_MAP.all) + const [pluginType, setPluginType] = useState(PLUGIN_TYPE_SEARCH_MAP.all) const [query, setQuery] = useState('') const [tags, setTags] = useState([]) const { data, isLoading } = useInstalledPluginList() diff --git a/web/context/query-client-server.ts b/web/context/query-client-server.ts new file mode 100644 index 0000000000..3650e30f52 --- /dev/null +++ b/web/context/query-client-server.ts @@ -0,0 +1,16 @@ +import { QueryClient } from '@tanstack/react-query' +import { cache } from 'react' + +const STALE_TIME = 1000 * 60 * 30 // 30 minutes + +export function makeQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + staleTime: STALE_TIME, + }, + }, + }) +} + +export const getQueryClientServer = cache(makeQueryClient) diff --git a/web/context/query-client.tsx b/web/context/query-client.tsx index 9562686f6f..a72393490c 100644 --- a/web/context/query-client.tsx +++ b/web/context/query-client.tsx @@ -1,23 +1,27 @@ 'use client' +import type { QueryClient } from '@tanstack/react-query' import type { FC, PropsWithChildren } from 'react' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { QueryClientProvider } from '@tanstack/react-query' +import { useState } from 'react' import { TanStackDevtoolsLoader } from '@/app/components/devtools/tanstack/loader' +import { makeQueryClient } from './query-client-server' -const STALE_TIME = 1000 * 60 * 30 // 30 minutes +let browserQueryClient: QueryClient | undefined -const client = new QueryClient({ - defaultOptions: { - queries: { - staleTime: STALE_TIME, - }, - }, -}) +function getQueryClient() { + if (typeof window === 'undefined') { + return makeQueryClient() + } + if (!browserQueryClient) + browserQueryClient = makeQueryClient() + return browserQueryClient +} -export const TanstackQueryInitializer: FC = (props) => { - const { children } = props +export const TanstackQueryInitializer: FC = ({ children }) => { + const [queryClient] = useState(getQueryClient) return ( - + {children} diff --git a/web/hooks/use-query-params.spec.tsx b/web/hooks/use-query-params.spec.tsx index 2aa6b7998f..b187471809 100644 --- a/web/hooks/use-query-params.spec.tsx +++ b/web/hooks/use-query-params.spec.tsx @@ -8,7 +8,6 @@ import { PRICING_MODAL_QUERY_PARAM, PRICING_MODAL_QUERY_VALUE, useAccountSettingModal, - useMarketplaceFilters, usePluginInstallation, usePricingModal, } from './use-query-params' @@ -302,174 +301,6 @@ describe('useQueryParams hooks', () => { }) }) - // Marketplace filters query behavior. - describe('useMarketplaceFilters', () => { - it('should return default filters when query params are missing', () => { - // Arrange - const { result } = renderWithAdapter(() => useMarketplaceFilters()) - - // Act - const [filters] = result.current - - // Assert - expect(filters.q).toBe('') - expect(filters.category).toBe('all') - expect(filters.tags).toEqual([]) - }) - - it('should parse filters when query params are present', () => { - // Arrange - const { result } = renderWithAdapter( - () => useMarketplaceFilters(), - '?q=prompt&category=tool&tags=ai,ml', - ) - - // Act - const [filters] = result.current - - // Assert - expect(filters.q).toBe('prompt') - expect(filters.category).toBe('tool') - expect(filters.tags).toEqual(['ai', 'ml']) - }) - - it('should treat empty tags param as empty array', () => { - // Arrange - const { result } = renderWithAdapter( - () => useMarketplaceFilters(), - '?tags=', - ) - - // Act - const [filters] = result.current - - // Assert - expect(filters.tags).toEqual([]) - }) - - it('should preserve other filters when updating a single field', async () => { - // Arrange - const { result } = renderWithAdapter( - () => useMarketplaceFilters(), - '?category=tool&tags=ai,ml', - ) - - // Act - act(() => { - result.current[1]({ q: 'search' }) - }) - - // Assert - await waitFor(() => expect(result.current[0].q).toBe('search')) - expect(result.current[0].category).toBe('tool') - expect(result.current[0].tags).toEqual(['ai', 'ml']) - }) - - it('should clear q param when q is empty', async () => { - // Arrange - const { result, onUrlUpdate } = renderWithAdapter( - () => useMarketplaceFilters(), - '?q=search', - ) - - // Act - act(() => { - result.current[1]({ q: '' }) - }) - - // Assert - await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) - const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0] - expect(update.searchParams.has('q')).toBe(false) - }) - - it('should serialize tags as comma-separated values', async () => { - // Arrange - const { result, onUrlUpdate } = renderWithAdapter(() => useMarketplaceFilters()) - - // Act - act(() => { - result.current[1]({ tags: ['ai', 'ml'] }) - }) - - // Assert - await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) - const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0] - expect(update.searchParams.get('tags')).toBe('ai,ml') - }) - - it('should remove tags param when list is empty', async () => { - // Arrange - const { result, onUrlUpdate } = renderWithAdapter( - () => useMarketplaceFilters(), - '?tags=ai,ml', - ) - - // Act - act(() => { - result.current[1]({ tags: [] }) - }) - - // Assert - await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) - const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0] - expect(update.searchParams.has('tags')).toBe(false) - }) - - it('should keep category in the URL when set to default', async () => { - // Arrange - const { result, onUrlUpdate } = renderWithAdapter( - () => useMarketplaceFilters(), - '?category=tool', - ) - - // Act - act(() => { - result.current[1]({ category: 'all' }) - }) - - // Assert - await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) - const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0] - expect(update.searchParams.get('category')).toBe('all') - }) - - it('should clear all marketplace filters when set to null', async () => { - // Arrange - const { result, onUrlUpdate } = renderWithAdapter( - () => useMarketplaceFilters(), - '?q=search&category=tool&tags=ai,ml', - ) - - // Act - act(() => { - result.current[1](null) - }) - - // Assert - await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) - const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0] - expect(update.searchParams.has('q')).toBe(false) - expect(update.searchParams.has('category')).toBe(false) - expect(update.searchParams.has('tags')).toBe(false) - }) - - it('should use replace history when updating filters', async () => { - // Arrange - const { result, onUrlUpdate } = renderWithAdapter(() => useMarketplaceFilters()) - - // Act - act(() => { - result.current[1]({ q: 'search' }) - }) - - // Assert - await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) - const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0] - expect(update.options.history).toBe('replace') - }) - }) - // Plugin installation query behavior. describe('usePluginInstallation', () => { it('should parse package ids from JSON arrays', () => { diff --git a/web/hooks/use-query-params.ts b/web/hooks/use-query-params.ts index e0d7cc3c02..73798a4a4f 100644 --- a/web/hooks/use-query-params.ts +++ b/web/hooks/use-query-params.ts @@ -15,7 +15,6 @@ import { createParser, - parseAsArrayOf, parseAsString, useQueryState, useQueryStates, @@ -93,39 +92,6 @@ export function useAccountSettingModal() { return [{ isOpen, payload: currentTab }, setState] as const } -/** - * Marketplace Search Query Parameters - */ -export type MarketplaceFilters = { - q: string // search query - category: string // plugin category - tags: string[] // array of tags -} - -/** - * Hook to manage marketplace search/filter state via URL - * Provides atomic updates - all params update together - * - * @example - * const [filters, setFilters] = useMarketplaceFilters() - * setFilters({ q: 'search', category: 'tool', tags: ['ai'] }) // Updates all at once - * setFilters({ q: '' }) // Only updates q, keeps others - * setFilters(null) // Clears all marketplace params - */ -export function useMarketplaceFilters() { - return useQueryStates( - { - q: parseAsString.withDefault(''), - category: parseAsString.withDefault('all').withOptions({ clearOnDefault: false }), - tags: parseAsArrayOf(parseAsString).withDefault([]), - }, - { - // Update URL without pushing to history (replaceState behavior) - history: 'replace', - }, - ) -} - /** * Plugin Installation Query Parameters */ From ae0a26f5b6a8a77263fecd63fbea5a129522e9e7 Mon Sep 17 00:00:00 2001 From: QuantumGhost Date: Fri, 9 Jan 2026 16:08:24 +0800 Subject: [PATCH 5/6] revert: "fix: fix assign value stand as default (#30651)" (#30717) The original fix seems correct on its own. However, for chatflows with multiple answer nodes, the `message_replace` command only preserves the output of the last executed answer node. --- .../advanced_chat/generate_task_pipeline.py | 19 - ...test_generate_task_pipeline_answer_node.py | 390 ------------------ 2 files changed, 409 deletions(-) delete mode 100644 api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline_answer_node.py diff --git a/api/core/app/apps/advanced_chat/generate_task_pipeline.py b/api/core/app/apps/advanced_chat/generate_task_pipeline.py index 4dd95be52d..da1e9f19b6 100644 --- a/api/core/app/apps/advanced_chat/generate_task_pipeline.py +++ b/api/core/app/apps/advanced_chat/generate_task_pipeline.py @@ -358,25 +358,6 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport): if node_finish_resp: yield node_finish_resp - # For ANSWER nodes, check if we need to send a message_replace event - # Only send if the final output differs from the accumulated task_state.answer - # This happens when variables were updated by variable_assigner during workflow execution - if event.node_type == NodeType.ANSWER and event.outputs: - final_answer = event.outputs.get("answer") - if final_answer is not None and final_answer != self._task_state.answer: - logger.info( - "ANSWER node final output '%s' differs from accumulated answer '%s', sending message_replace event", - final_answer, - self._task_state.answer, - ) - # Update the task state answer - self._task_state.answer = str(final_answer) - # Send message_replace event to update the UI - yield self._message_cycle_manager.message_replace_to_stream_response( - answer=str(final_answer), - reason="variable_update", - ) - def _handle_node_failed_events( self, event: Union[QueueNodeFailedEvent, QueueNodeExceptionEvent], diff --git a/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline_answer_node.py b/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline_answer_node.py deleted file mode 100644 index 205b157542..0000000000 --- a/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline_answer_node.py +++ /dev/null @@ -1,390 +0,0 @@ -""" -Tests for AdvancedChatAppGenerateTaskPipeline._handle_node_succeeded_event method, -specifically testing the ANSWER node message_replace logic. -""" - -from datetime import datetime -from types import SimpleNamespace -from unittest.mock import MagicMock, Mock, patch - -import pytest - -from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity -from core.app.entities.queue_entities import QueueNodeSucceededEvent -from core.workflow.enums import NodeType -from models import EndUser -from models.model import AppMode - - -class TestAnswerNodeMessageReplace: - """Test cases for ANSWER node message_replace event logic.""" - - @pytest.fixture - def mock_application_generate_entity(self): - """Create a mock application generate entity.""" - entity = Mock(spec=AdvancedChatAppGenerateEntity) - entity.task_id = "test-task-id" - entity.app_id = "test-app-id" - entity.workflow_run_id = "test-workflow-run-id" - # minimal app_config used by pipeline internals - entity.app_config = SimpleNamespace( - tenant_id="test-tenant-id", - app_id="test-app-id", - app_mode=AppMode.ADVANCED_CHAT, - app_model_config_dict={}, - additional_features=None, - sensitive_word_avoidance=None, - ) - entity.query = "test query" - entity.files = [] - entity.extras = {} - entity.trace_manager = None - entity.inputs = {} - entity.invoke_from = "debugger" - return entity - - @pytest.fixture - def mock_workflow(self): - """Create a mock workflow.""" - workflow = Mock() - workflow.id = "test-workflow-id" - workflow.features_dict = {} - return workflow - - @pytest.fixture - def mock_queue_manager(self): - """Create a mock queue manager.""" - manager = Mock() - manager.listen.return_value = [] - manager.graph_runtime_state = None - return manager - - @pytest.fixture - def mock_conversation(self): - """Create a mock conversation.""" - conversation = Mock() - conversation.id = "test-conversation-id" - conversation.mode = "advanced_chat" - return conversation - - @pytest.fixture - def mock_message(self): - """Create a mock message.""" - message = Mock() - message.id = "test-message-id" - message.query = "test query" - message.created_at = Mock() - message.created_at.timestamp.return_value = 1234567890 - return message - - @pytest.fixture - def mock_user(self): - """Create a mock end user.""" - user = MagicMock(spec=EndUser) - user.id = "test-user-id" - user.session_id = "test-session-id" - return user - - @pytest.fixture - def mock_draft_var_saver_factory(self): - """Create a mock draft variable saver factory.""" - return Mock() - - @pytest.fixture - def pipeline( - self, - mock_application_generate_entity, - mock_workflow, - mock_queue_manager, - mock_conversation, - mock_message, - mock_user, - mock_draft_var_saver_factory, - ): - """Create an AdvancedChatAppGenerateTaskPipeline instance with mocked dependencies.""" - from core.app.apps.advanced_chat.generate_task_pipeline import AdvancedChatAppGenerateTaskPipeline - - with patch("core.app.apps.advanced_chat.generate_task_pipeline.db"): - pipeline = AdvancedChatAppGenerateTaskPipeline( - application_generate_entity=mock_application_generate_entity, - workflow=mock_workflow, - queue_manager=mock_queue_manager, - conversation=mock_conversation, - message=mock_message, - user=mock_user, - stream=True, - dialogue_count=1, - draft_var_saver_factory=mock_draft_var_saver_factory, - ) - # Initialize workflow run id to avoid validation errors - pipeline._workflow_run_id = "test-workflow-run-id" - # Mock the message cycle manager methods we need to track - pipeline._message_cycle_manager.message_replace_to_stream_response = Mock() - return pipeline - - def test_answer_node_with_different_output_sends_message_replace(self, pipeline, mock_application_generate_entity): - """ - Test that when an ANSWER node's final output differs from accumulated answer, - a message_replace event is sent. - """ - # Arrange: Set initial accumulated answer - pipeline._task_state.answer = "initial answer" - - # Create ANSWER node succeeded event with different final output - event = QueueNodeSucceededEvent( - node_execution_id="test-node-execution-id", - node_id="test-answer-node", - node_type=NodeType.ANSWER, - start_at=datetime.now(), - outputs={"answer": "updated final answer"}, - ) - - # Mock the workflow response converter to avoid extra processing - pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = Mock(return_value=None) - pipeline._save_output_for_event = Mock() - - # Act - responses = list(pipeline._handle_node_succeeded_event(event)) - - # Assert - assert pipeline._task_state.answer == "updated final answer" - # Verify message_replace was called - pipeline._message_cycle_manager.message_replace_to_stream_response.assert_called_once_with( - answer="updated final answer", reason="variable_update" - ) - - def test_answer_node_with_same_output_does_not_send_message_replace(self, pipeline): - """ - Test that when an ANSWER node's final output is the same as accumulated answer, - no message_replace event is sent. - """ - # Arrange: Set initial accumulated answer - pipeline._task_state.answer = "same answer" - - # Create ANSWER node succeeded event with same output - event = QueueNodeSucceededEvent( - node_execution_id="test-node-execution-id", - node_id="test-answer-node", - node_type=NodeType.ANSWER, - start_at=datetime.now(), - outputs={"answer": "same answer"}, - ) - - # Mock the workflow response converter - pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = Mock(return_value=None) - pipeline._save_output_for_event = Mock() - - # Act - list(pipeline._handle_node_succeeded_event(event)) - - # Assert: answer should remain unchanged - assert pipeline._task_state.answer == "same answer" - # Verify message_replace was NOT called - pipeline._message_cycle_manager.message_replace_to_stream_response.assert_not_called() - - def test_answer_node_with_none_output_does_not_send_message_replace(self, pipeline): - """ - Test that when an ANSWER node's output is None or missing 'answer' key, - no message_replace event is sent. - """ - # Arrange: Set initial accumulated answer - pipeline._task_state.answer = "existing answer" - - # Create ANSWER node succeeded event with None output - event = QueueNodeSucceededEvent( - node_execution_id="test-node-execution-id", - node_id="test-answer-node", - node_type=NodeType.ANSWER, - start_at=datetime.now(), - outputs={"answer": None}, - ) - - # Mock the workflow response converter - pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = Mock(return_value=None) - pipeline._save_output_for_event = Mock() - - # Act - list(pipeline._handle_node_succeeded_event(event)) - - # Assert: answer should remain unchanged - assert pipeline._task_state.answer == "existing answer" - # Verify message_replace was NOT called - pipeline._message_cycle_manager.message_replace_to_stream_response.assert_not_called() - - def test_answer_node_with_empty_outputs_does_not_send_message_replace(self, pipeline): - """ - Test that when an ANSWER node has empty outputs dict, - no message_replace event is sent. - """ - # Arrange: Set initial accumulated answer - pipeline._task_state.answer = "existing answer" - - # Create ANSWER node succeeded event with empty outputs - event = QueueNodeSucceededEvent( - node_execution_id="test-node-execution-id", - node_id="test-answer-node", - node_type=NodeType.ANSWER, - start_at=datetime.now(), - outputs={}, - ) - - # Mock the workflow response converter - pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = Mock(return_value=None) - pipeline._save_output_for_event = Mock() - - # Act - list(pipeline._handle_node_succeeded_event(event)) - - # Assert: answer should remain unchanged - assert pipeline._task_state.answer == "existing answer" - # Verify message_replace was NOT called - pipeline._message_cycle_manager.message_replace_to_stream_response.assert_not_called() - - def test_answer_node_with_no_answer_key_in_outputs(self, pipeline): - """ - Test that when an ANSWER node's outputs don't contain 'answer' key, - no message_replace event is sent. - """ - # Arrange: Set initial accumulated answer - pipeline._task_state.answer = "existing answer" - - # Create ANSWER node succeeded event without 'answer' key in outputs - event = QueueNodeSucceededEvent( - node_execution_id="test-node-execution-id", - node_id="test-answer-node", - node_type=NodeType.ANSWER, - start_at=datetime.now(), - outputs={"other_key": "some value"}, - ) - - # Mock the workflow response converter - pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = Mock(return_value=None) - pipeline._save_output_for_event = Mock() - - # Act - list(pipeline._handle_node_succeeded_event(event)) - - # Assert: answer should remain unchanged - assert pipeline._task_state.answer == "existing answer" - # Verify message_replace was NOT called - pipeline._message_cycle_manager.message_replace_to_stream_response.assert_not_called() - - def test_non_answer_node_does_not_send_message_replace(self, pipeline): - """ - Test that non-ANSWER nodes (e.g., LLM, END) don't trigger message_replace events. - """ - # Arrange: Set initial accumulated answer - pipeline._task_state.answer = "existing answer" - - # Test with LLM node - llm_event = QueueNodeSucceededEvent( - node_execution_id="test-llm-execution-id", - node_id="test-llm-node", - node_type=NodeType.LLM, - start_at=datetime.now(), - outputs={"answer": "different answer"}, - ) - - # Mock the workflow response converter - pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = Mock(return_value=None) - pipeline._save_output_for_event = Mock() - - # Act - list(pipeline._handle_node_succeeded_event(llm_event)) - - # Assert: answer should remain unchanged - assert pipeline._task_state.answer == "existing answer" - # Verify message_replace was NOT called - pipeline._message_cycle_manager.message_replace_to_stream_response.assert_not_called() - - def test_end_node_does_not_send_message_replace(self, pipeline): - """ - Test that END nodes don't trigger message_replace events even with 'answer' output. - """ - # Arrange: Set initial accumulated answer - pipeline._task_state.answer = "existing answer" - - # Create END node succeeded event with answer output - event = QueueNodeSucceededEvent( - node_execution_id="test-end-execution-id", - node_id="test-end-node", - node_type=NodeType.END, - start_at=datetime.now(), - outputs={"answer": "different answer"}, - ) - - # Mock the workflow response converter - pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = Mock(return_value=None) - pipeline._save_output_for_event = Mock() - - # Act - list(pipeline._handle_node_succeeded_event(event)) - - # Assert: answer should remain unchanged - assert pipeline._task_state.answer == "existing answer" - # Verify message_replace was NOT called - pipeline._message_cycle_manager.message_replace_to_stream_response.assert_not_called() - - def test_answer_node_with_numeric_output_converts_to_string(self, pipeline): - """ - Test that when an ANSWER node's final output is numeric, - it gets converted to string properly. - """ - # Arrange: Set initial accumulated answer - pipeline._task_state.answer = "text answer" - - # Create ANSWER node succeeded event with numeric output - event = QueueNodeSucceededEvent( - node_execution_id="test-node-execution-id", - node_id="test-answer-node", - node_type=NodeType.ANSWER, - start_at=datetime.now(), - outputs={"answer": 12345}, - ) - - # Mock the workflow response converter - pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = Mock(return_value=None) - pipeline._save_output_for_event = Mock() - - # Act - list(pipeline._handle_node_succeeded_event(event)) - - # Assert: answer should be converted to string - assert pipeline._task_state.answer == "12345" - # Verify message_replace was called with string - pipeline._message_cycle_manager.message_replace_to_stream_response.assert_called_once_with( - answer="12345", reason="variable_update" - ) - - def test_answer_node_files_are_recorded(self, pipeline): - """ - Test that ANSWER nodes properly record files from outputs. - """ - # Arrange - pipeline._task_state.answer = "existing answer" - - # Create ANSWER node succeeded event with files - event = QueueNodeSucceededEvent( - node_execution_id="test-node-execution-id", - node_id="test-answer-node", - node_type=NodeType.ANSWER, - start_at=datetime.now(), - outputs={ - "answer": "same answer", - "files": [ - {"type": "image", "transfer_method": "remote_url", "remote_url": "http://example.com/img.png"} - ], - }, - ) - - # Mock the workflow response converter - pipeline._workflow_response_converter.fetch_files_from_node_outputs = Mock(return_value=event.outputs["files"]) - pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = Mock(return_value=None) - pipeline._save_output_for_event = Mock() - - # Act - list(pipeline._handle_node_succeeded_event(event)) - - # Assert: files should be recorded - assert len(pipeline._recorded_files) == 1 - assert pipeline._recorded_files[0] == event.outputs["files"][0] From 0711dd4159457d62a937273dd2d8162d92b4e5a1 Mon Sep 17 00:00:00 2001 From: wangxiaolei Date: Fri, 9 Jan 2026 16:13:17 +0800 Subject: [PATCH 6/6] feat: enhance start node object value check (#30732) --- api/core/app/app_config/entities.py | 13 ++---- .../apps/advanced_chat/app_config_manager.py | 1 - api/core/app/apps/base_app_generator.py | 33 ++++++++------ api/core/workflow/nodes/start/start_node.py | 28 +++++------- .../nodes/test_start_node_json_object.py | 18 +++++--- .../config-var/config-modal/index.tsx | 2 +- web/app/components/base/chat/chat/utils.ts | 16 ++++++- .../share/text-generation/run-once/index.tsx | 2 +- .../components/before-run-form/form-item.tsx | 8 +++- .../nodes/_base/components/variable/utils.ts | 2 +- web/app/components/workflow/types.ts | 2 +- web/models/debug.ts | 2 +- web/service/workflow-payload.ts | 45 ++++++++++++++++++- web/service/workflow.ts | 4 +- 14 files changed, 121 insertions(+), 55 deletions(-) diff --git a/api/core/app/app_config/entities.py b/api/core/app/app_config/entities.py index 307af3747c..13c51529cc 100644 --- a/api/core/app/app_config/entities.py +++ b/api/core/app/app_config/entities.py @@ -1,4 +1,3 @@ -import json from collections.abc import Sequence from enum import StrEnum, auto from typing import Any, Literal @@ -121,7 +120,7 @@ class VariableEntity(BaseModel): allowed_file_types: Sequence[FileType] | None = Field(default_factory=list) allowed_file_extensions: Sequence[str] | None = Field(default_factory=list) allowed_file_upload_methods: Sequence[FileTransferMethod] | None = Field(default_factory=list) - json_schema: str | None = Field(default=None) + json_schema: dict | None = Field(default=None) @field_validator("description", mode="before") @classmethod @@ -135,17 +134,11 @@ class VariableEntity(BaseModel): @field_validator("json_schema") @classmethod - def validate_json_schema(cls, schema: str | None) -> str | None: + def validate_json_schema(cls, schema: dict | None) -> dict | None: if schema is None: return None - try: - json_schema = json.loads(schema) - except json.JSONDecodeError: - raise ValueError(f"invalid json_schema value {schema}") - - try: - Draft7Validator.check_schema(json_schema) + Draft7Validator.check_schema(schema) except SchemaError as e: raise ValueError(f"Invalid JSON schema: {e.message}") return schema diff --git a/api/core/app/apps/advanced_chat/app_config_manager.py b/api/core/app/apps/advanced_chat/app_config_manager.py index e4b308a6f6..c21c494efe 100644 --- a/api/core/app/apps/advanced_chat/app_config_manager.py +++ b/api/core/app/apps/advanced_chat/app_config_manager.py @@ -26,7 +26,6 @@ class AdvancedChatAppConfigManager(BaseAppConfigManager): @classmethod def get_app_config(cls, app_model: App, workflow: Workflow) -> AdvancedChatAppConfig: features_dict = workflow.features_dict - app_mode = AppMode.value_of(app_model.mode) app_config = AdvancedChatAppConfig( tenant_id=app_model.tenant_id, diff --git a/api/core/app/apps/base_app_generator.py b/api/core/app/apps/base_app_generator.py index a6aace168e..e4486e892c 100644 --- a/api/core/app/apps/base_app_generator.py +++ b/api/core/app/apps/base_app_generator.py @@ -1,4 +1,3 @@ -import json from collections.abc import Generator, Mapping, Sequence from typing import TYPE_CHECKING, Any, Union, final @@ -76,12 +75,24 @@ class BaseAppGenerator: user_inputs = {**user_inputs, **files_inputs, **file_list_inputs} # Check if all files are converted to File - if any(filter(lambda v: isinstance(v, dict), user_inputs.values())): - raise ValueError("Invalid input type") - if any( - filter(lambda v: isinstance(v, dict), filter(lambda item: isinstance(item, list), user_inputs.values())) - ): - raise ValueError("Invalid input type") + invalid_dict_keys = [ + k + for k, v in user_inputs.items() + if isinstance(v, dict) + and entity_dictionary[k].type not in {VariableEntityType.FILE, VariableEntityType.JSON_OBJECT} + ] + if invalid_dict_keys: + raise ValueError(f"Invalid input type for {invalid_dict_keys}") + + invalid_list_dict_keys = [ + k + for k, v in user_inputs.items() + if isinstance(v, list) + and any(isinstance(item, dict) for item in v) + and entity_dictionary[k].type != VariableEntityType.FILE_LIST + ] + if invalid_list_dict_keys: + raise ValueError(f"Invalid input type for {invalid_list_dict_keys}") return user_inputs @@ -178,12 +189,8 @@ class BaseAppGenerator: elif value == 0: value = False case VariableEntityType.JSON_OBJECT: - if not isinstance(value, str): - raise ValueError(f"{variable_entity.variable} in input form must be a string") - try: - json.loads(value) - except json.JSONDecodeError: - raise ValueError(f"{variable_entity.variable} in input form must be a valid JSON object") + if not isinstance(value, dict): + raise ValueError(f"{variable_entity.variable} in input form must be a dict") case _: raise AssertionError("this statement should be unreachable.") diff --git a/api/core/workflow/nodes/start/start_node.py b/api/core/workflow/nodes/start/start_node.py index 36fc5078c5..53c1b4ee6b 100644 --- a/api/core/workflow/nodes/start/start_node.py +++ b/api/core/workflow/nodes/start/start_node.py @@ -1,4 +1,3 @@ -import json from typing import Any from jsonschema import Draft7Validator, ValidationError @@ -43,25 +42,22 @@ class StartNode(Node[StartNodeData]): if value is None and variable.required: raise ValueError(f"{key} is required in input form") + # If no value provided, skip further processing for this key + if not value: + continue + + if not isinstance(value, dict): + raise ValueError(f"JSON object for '{key}' must be an object") + + # Overwrite with normalized dict to ensure downstream consistency + node_inputs[key] = value + + # If schema exists, then validate against it schema = variable.json_schema if not schema: continue - if not value: - continue - try: - json_schema = json.loads(schema) - except json.JSONDecodeError as e: - raise ValueError(f"{schema} must be a valid JSON object") - - try: - json_value = json.loads(value) - except json.JSONDecodeError as e: - raise ValueError(f"{value} must be a valid JSON object") - - try: - Draft7Validator(json_schema).validate(json_value) + Draft7Validator(schema).validate(value) except ValidationError as e: raise ValueError(f"JSON object for '{key}' does not match schema: {e.message}") - node_inputs[key] = json_value diff --git a/api/tests/unit_tests/core/workflow/nodes/test_start_node_json_object.py b/api/tests/unit_tests/core/workflow/nodes/test_start_node_json_object.py index 539e72edb5..16b432bae6 100644 --- a/api/tests/unit_tests/core/workflow/nodes/test_start_node_json_object.py +++ b/api/tests/unit_tests/core/workflow/nodes/test_start_node_json_object.py @@ -58,6 +58,8 @@ def test_json_object_valid_schema(): } ) + schema = json.loads(schema) + variables = [ VariableEntity( variable="profile", @@ -68,7 +70,7 @@ def test_json_object_valid_schema(): ) ] - user_inputs = {"profile": json.dumps({"age": 20, "name": "Tom"})} + user_inputs = {"profile": {"age": 20, "name": "Tom"}} node = make_start_node(user_inputs, variables) result = node._run() @@ -87,6 +89,8 @@ def test_json_object_invalid_json_string(): "required": ["age", "name"], } ) + + schema = json.loads(schema) variables = [ VariableEntity( variable="profile", @@ -97,12 +101,12 @@ def test_json_object_invalid_json_string(): ) ] - # Missing closing brace makes this invalid JSON + # Providing a string instead of an object should raise a type error user_inputs = {"profile": '{"age": 20, "name": "Tom"'} node = make_start_node(user_inputs, variables) - with pytest.raises(ValueError, match='{"age": 20, "name": "Tom" must be a valid JSON object'): + with pytest.raises(ValueError, match="JSON object for 'profile' must be an object"): node._run() @@ -118,6 +122,8 @@ def test_json_object_does_not_match_schema(): } ) + schema = json.loads(schema) + variables = [ VariableEntity( variable="profile", @@ -129,7 +135,7 @@ def test_json_object_does_not_match_schema(): ] # age is a string, which violates the schema (expects number) - user_inputs = {"profile": json.dumps({"age": "twenty", "name": "Tom"})} + user_inputs = {"profile": {"age": "twenty", "name": "Tom"}} node = make_start_node(user_inputs, variables) @@ -149,6 +155,8 @@ def test_json_object_missing_required_schema_field(): } ) + schema = json.loads(schema) + variables = [ VariableEntity( variable="profile", @@ -160,7 +168,7 @@ def test_json_object_missing_required_schema_field(): ] # Missing required field "name" - user_inputs = {"profile": json.dumps({"age": 20})} + user_inputs = {"profile": {"age": 20}} node = make_start_node(user_inputs, variables) diff --git a/web/app/components/app/configuration/config-var/config-modal/index.tsx b/web/app/components/app/configuration/config-var/config-modal/index.tsx index 782744882e..5ffa87375c 100644 --- a/web/app/components/app/configuration/config-var/config-modal/index.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/index.tsx @@ -83,7 +83,7 @@ const ConfigModal: FC = ({ if (!isJsonObject || !tempPayload.json_schema) return '' try { - return JSON.stringify(JSON.parse(tempPayload.json_schema), null, 2) + return tempPayload.json_schema } catch { return '' diff --git a/web/app/components/base/chat/chat/utils.ts b/web/app/components/base/chat/chat/utils.ts index ab150f3e61..a64c8162dc 100644 --- a/web/app/components/base/chat/chat/utils.ts +++ b/web/app/components/base/chat/chat/utils.ts @@ -37,7 +37,7 @@ export const getProcessedInputs = (inputs: Record, inputsForm: Inpu return } - if (!inputValue) + if (inputValue == null) return if (item.type === InputVarType.singleFile) { @@ -52,6 +52,20 @@ export const getProcessedInputs = (inputs: Record, inputsForm: Inpu else processedInputs[item.variable] = getProcessedFiles(inputValue) } + else if (item.type === InputVarType.jsonObject) { + // Prefer sending an object if the user entered valid JSON; otherwise keep the raw string. + try { + const v = typeof inputValue === 'string' ? JSON.parse(inputValue) : inputValue + if (v && typeof v === 'object' && !Array.isArray(v)) + processedInputs[item.variable] = v + else + processedInputs[item.variable] = inputValue + } + catch { + // keep original string; backend will parse/validate + processedInputs[item.variable] = inputValue + } + } }) return processedInputs diff --git a/web/app/components/share/text-generation/run-once/index.tsx b/web/app/components/share/text-generation/run-once/index.tsx index 6094147bbd..b8193fd944 100644 --- a/web/app/components/share/text-generation/run-once/index.tsx +++ b/web/app/components/share/text-generation/run-once/index.tsx @@ -195,7 +195,7 @@ const RunOnce: FC = ({ noWrapper className="bg h-[80px] overflow-y-auto rounded-[10px] bg-components-input-bg-normal p-1" placeholder={ -
{item.json_schema}
+
{typeof item.json_schema === 'string' ? item.json_schema : JSON.stringify(item.json_schema || '', null, 2)}
} /> )} diff --git a/web/app/components/workflow/nodes/_base/components/before-run-form/form-item.tsx b/web/app/components/workflow/nodes/_base/components/before-run-form/form-item.tsx index 81a8453582..3eef34bd7b 100644 --- a/web/app/components/workflow/nodes/_base/components/before-run-form/form-item.tsx +++ b/web/app/components/workflow/nodes/_base/components/before-run-form/form-item.tsx @@ -48,6 +48,12 @@ const FormItem: FC = ({ const { t } = useTranslation() const { type } = payload const fileSettings = useHooksStore(s => s.configsMap?.fileSettings) + const jsonSchemaPlaceholder = React.useMemo(() => { + const schema = (payload as any)?.json_schema + if (!schema) + return '' + return typeof schema === 'string' ? schema : JSON.stringify(schema, null, 2) + }, [payload]) const handleArrayItemChange = useCallback((index: number) => { return (newValue: any) => { @@ -211,7 +217,7 @@ const FormItem: FC = ({ noWrapper className="bg h-[80px] overflow-y-auto rounded-[10px] bg-components-input-bg-normal p-1" placeholder={ -
{payload.json_schema}
+
{jsonSchemaPlaceholder}
} /> )} diff --git a/web/app/components/workflow/nodes/_base/components/variable/utils.ts b/web/app/components/workflow/nodes/_base/components/variable/utils.ts index 9f77be0ce2..e5e8174456 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/utils.ts +++ b/web/app/components/workflow/nodes/_base/components/variable/utils.ts @@ -353,7 +353,7 @@ const formatItem = ( try { if (type === VarType.object && v.json_schema) { varRes.children = { - schema: JSON.parse(v.json_schema), + schema: typeof v.json_schema === 'string' ? JSON.parse(v.json_schema) : v.json_schema, } } } diff --git a/web/app/components/workflow/types.ts b/web/app/components/workflow/types.ts index 740f1c1113..63a66eb5cd 100644 --- a/web/app/components/workflow/types.ts +++ b/web/app/components/workflow/types.ts @@ -223,7 +223,7 @@ export type InputVar = { getVarValueFromDependent?: boolean hide?: boolean isFileItem?: boolean - json_schema?: string // for jsonObject type + json_schema?: string | Record // for jsonObject type } & Partial export type ModelConfig = { diff --git a/web/models/debug.ts b/web/models/debug.ts index 5290268fe9..73d0910e82 100644 --- a/web/models/debug.ts +++ b/web/models/debug.ts @@ -62,7 +62,7 @@ export type PromptVariable = { icon?: string icon_background?: string hide?: boolean // used in frontend to hide variable - json_schema?: string + json_schema?: string | Record } export type CompletionParams = { diff --git a/web/service/workflow-payload.ts b/web/service/workflow-payload.ts index b294141cb7..5e2cdebdb3 100644 --- a/web/service/workflow-payload.ts +++ b/web/service/workflow-payload.ts @@ -66,7 +66,30 @@ export const sanitizeWorkflowDraftPayload = (params: WorkflowDraftSyncParams): W if (!graph?.nodes?.length) return params - const sanitizedNodes = graph.nodes.map(node => sanitizeTriggerPluginNode(node as Node)) + const sanitizedNodes = graph.nodes.map((node) => { + // First sanitize known node types (TriggerPlugin) + const n = sanitizeTriggerPluginNode(node as Node) as Node + + // Normalize Start node variable json_schema: ensure dict, not string + if ((n.data as any)?.type === BlockEnum.Start && Array.isArray((n.data as any).variables)) { + const next = { ...n, data: { ...n.data } } + next.data.variables = (n.data as any).variables.map((v: any) => { + if (v && v.type === 'json_object' && typeof v.json_schema === 'string') { + try { + const obj = JSON.parse(v.json_schema) + return { ...v, json_schema: obj } + } + catch { + return v + } + } + return v + }) + return next + } + + return n + }) return { ...params, @@ -126,7 +149,25 @@ export const hydrateWorkflowDraftResponse = (draft: FetchWorkflowDraftResponse): if (node.data) removeTempProperties(node.data as Record) - return hydrateTriggerPluginNode(node) + let n = hydrateTriggerPluginNode(node) + // Normalize Start node variable json_schema to object when loading + if ((n.data as any)?.type === BlockEnum.Start && Array.isArray((n.data as any).variables)) { + const next = { ...n, data: { ...n.data } } as Node + next.data.variables = (n.data as any).variables.map((v: any) => { + if (v && v.type === 'json_object' && typeof v.json_schema === 'string') { + try { + const obj = JSON.parse(v.json_schema) + return { ...v, json_schema: obj } + } + catch { + return v + } + } + return v + }) + n = next + } + return n }) } diff --git a/web/service/workflow.ts b/web/service/workflow.ts index 7571e804a9..3a37db791b 100644 --- a/web/service/workflow.ts +++ b/web/service/workflow.ts @@ -9,6 +9,7 @@ import type { } from '@/types/workflow' import { get, post } from './base' import { getFlowPrefix } from './utils' +import { sanitizeWorkflowDraftPayload } from './workflow-payload' export const fetchWorkflowDraft = (url: string) => { return get(url, {}, { silent: true }) as Promise @@ -18,7 +19,8 @@ export const syncWorkflowDraft = ({ url, params }: { url: string params: Pick }) => { - return post(url, { body: params }, { silent: true }) + const sanitized = sanitizeWorkflowDraftPayload(params) + return post(url, { body: sanitized }, { silent: true }) } export const fetchNodesDefaultConfigs = (url: string) => {