diff --git a/packages/connect-react/src/components/SelectApp.tsx b/packages/connect-react/src/components/SelectApp.tsx index 0042e4b0446ec..652ecc5fb1232 100644 --- a/packages/connect-react/src/components/SelectApp.tsx +++ b/packages/connect-react/src/components/SelectApp.tsx @@ -24,13 +24,20 @@ export function SelectApp({ const instanceId = useId(); - // Debounce the search query + // Debounce the search query with cleanup guard useEffect(() => { + let cancelled = false; + const timer = setTimeout(() => { - setQ(inputValue); + if (!cancelled) { + setQ(inputValue); + } }, 300); // 300ms delay - return () => clearTimeout(timer); + return () => { + cancelled = true; + clearTimeout(timer); + }; }, [ inputValue, ]); @@ -106,7 +113,7 @@ export function SelectApp({ getOptionLabel={(o) => o.name || o.name_slug} // TODO fetch initial value app so we show name getOptionValue={(o) => o.name_slug} value={selectedValue} - onChange={(o) => onChange?.((o as AppResponse) || undefined)} + onChange={(o) => onChange?.(o ? (o as AppResponse) : undefined)} onInputChange={(v, { action }) => { // Only update on user input, not on blur/menu-close/etc if (action === "input-change") { diff --git a/packages/connect-react/src/components/SelectComponent.tsx b/packages/connect-react/src/components/SelectComponent.tsx index b20a6073da49c..b871785dcdf80 100644 --- a/packages/connect-react/src/components/SelectComponent.tsx +++ b/packages/connect-react/src/components/SelectComponent.tsx @@ -1,6 +1,6 @@ -import { useId } from "react"; -import Select from "react-select"; -import { useComponents } from "../hooks/use-components"; +import { useId, useState, useEffect, useMemo } from "react"; +import Select, { components } from "react-select"; +import { useComponentsWithPagination } from "../hooks/use-components"; import { AppResponse, V1Component, } from "@pipedream/sdk"; @@ -19,28 +19,107 @@ export function SelectComponent({ onChange, }: SelectComponentProps) { const instanceId = useId(); + const [searchQuery, setSearchQuery] = useState(""); + const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(""); + + // Debounce search query to avoid excessive API calls + useEffect(() => { + let cancelled = false; + + const timer = setTimeout(() => { + if (!cancelled) { + setDebouncedSearchQuery(searchQuery); + } + }, 300); + + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [searchQuery]); + const { - isLoading, components, - } = useComponents({ + isLoading, + components: allComponents, + hasMore, + loadMore, + reset, + isLoadingMore, + } = useComponentsWithPagination({ app: app?.name_slug, componentType, }); - const selectedValue = components?.find((c) => c.key === value?.key) || null; + // Filter components based on search query (client-side) + const componentList = useMemo(() => { + if (!debouncedSearchQuery) { + return allComponents; + } + const query = debouncedSearchQuery.toLowerCase(); + return allComponents.filter(component => + component.name?.toLowerCase().includes(query) || + component.key?.toLowerCase().includes(query) + ); + }, [allComponents, debouncedSearchQuery]); + + const selectedValue = useMemo(() => { + // If we have a value but it's not in the current components list, + // preserve it for display purposes + const foundComponent = componentList?.find((c) => c.key === value?.key); + if (foundComponent) { + return foundComponent; + } else if (value?.key) { + // Return the partial value to maintain selection display + return value as V1Component; + } + return null; + }, [componentList, value]); + + // Custom MenuList component for infinite scroll - memoized to prevent recreation + const MenuList = useMemo(() => { + return (props: any) => ( + + {props.children} + {hasMore && !isLoading && ( + { + if (!isLoadingMore) { + loadMore(); + } + }} + > + {isLoadingMore ? 'Loading more...' : 'Load more'} + + )} + + ); + }, [hasMore, isLoading, isLoadingMore, loadMore]); return ( o.name || o.key} getOptionValue={(o) => o.key} value={selectedValue} - onChange={(o) => onChange?.((o as V1Component) || undefined)} + onChange={(o) => onChange?.(o ? (o as V1Component) : undefined)} isLoading={isLoading} + onInputChange={(inputValue, { action }) => { + if (action === "input-change") { + setSearchQuery(inputValue); + } + }} + noOptionsMessage={({ inputValue }) => + isLoading ? "Loading..." : + inputValue ? `No components found for "${inputValue}"` : + "No components available" + } components={{ IndicatorSeparator: () => null, + MenuList, }} /> ); diff --git a/packages/connect-react/src/hooks/use-apps.tsx b/packages/connect-react/src/hooks/use-apps.tsx index 8c34c3754f48b..df3a77efc7c11 100644 --- a/packages/connect-react/src/hooks/use-apps.tsx +++ b/packages/connect-react/src/hooks/use-apps.tsx @@ -10,9 +10,11 @@ export const useApps = (input?: GetAppsOpts) => { const query = useQuery({ queryKey: [ "apps", - input, + input?.q || "", // Stable key even if input is undefined ], queryFn: () => client.apps(input), + staleTime: 60000, // Consider data fresh for 1 minute + gcTime: 300000, // Keep in cache for 5 minutes (formerly cacheTime) }); return { diff --git a/packages/connect-react/src/hooks/use-component.tsx b/packages/connect-react/src/hooks/use-component.tsx index 5d70f0c09de39..eedb67e7fd536 100644 --- a/packages/connect-react/src/hooks/use-component.tsx +++ b/packages/connect-react/src/hooks/use-component.tsx @@ -15,12 +15,14 @@ export const useComponent = ( const query = useQuery({ queryKey: [ "component", - key, + key || "", ], queryFn: () => client.component({ key: key!, }), enabled: !!key, + staleTime: 60000, // Consider data fresh for 1 minute + gcTime: 300000, // Keep in cache for 5 minutes ...opts?.useQueryOpts, }); diff --git a/packages/connect-react/src/hooks/use-components.tsx b/packages/connect-react/src/hooks/use-components.tsx index 31ad8b0c2f181..fa192066f877e 100644 --- a/packages/connect-react/src/hooks/use-components.tsx +++ b/packages/connect-react/src/hooks/use-components.tsx @@ -1,5 +1,6 @@ import { useQuery } from "@tanstack/react-query"; -import type { GetComponentOpts } from "@pipedream/sdk"; +import { useState, useCallback, useEffect } from "react"; +import type { GetComponentOpts, V1Component } from "@pipedream/sdk"; import { useFrontendClient } from "./frontend-client-context"; /** @@ -10,9 +11,15 @@ export const useComponents = (input?: GetComponentOpts) => { const query = useQuery({ queryKey: [ "components", - input, + input?.app || "", + input?.componentType || "", + input?.q || "", + input?.limit || 100, + input?.after || "", ], queryFn: () => client.components(input), + staleTime: 60000, // Consider data fresh for 1 minute + gcTime: 300000, // Keep in cache for 5 minutes }); return { @@ -20,3 +27,115 @@ export const useComponents = (input?: GetComponentOpts) => { components: query.data?.data || [], }; }; + +/** + * Get list of components with pagination support + */ +export const useComponentsWithPagination = (input?: Omit) => { + const client = useFrontendClient(); + const [allComponents, setAllComponents] = useState([]); + const [hasMore, setHasMore] = useState(true); + const [cursor, setCursor] = useState(undefined); + const [isLoadingMore, setIsLoadingMore] = useState(false); + + // Create stable query params object + const queryParams = useCallback(() => ({ + ...input, + limit: 50, + after: cursor, + }), [input?.app, input?.componentType, cursor]); + + const query = useQuery({ + queryKey: [ + "components-paginated", + input?.app || "", + input?.componentType || "", + cursor || "", + ], + queryFn: () => client.components(queryParams()), + staleTime: 60000, // Consider data fresh for 1 minute + gcTime: 300000, // Keep in cache for 5 minutes + }); + + // Handle successful data fetch with cleanup guard + useEffect(() => { + let cancelled = false; + + if (query.isSuccess && query.data && !cancelled) { + const data = query.data; + + if (cursor) { + // This is a "load more" request, append to existing components + setAllComponents(prev => { + if (cancelled) return prev; + const existingKeys = new Set(prev.map(c => c.key)); + const newComponents = data.data?.filter((c: V1Component) => !existingKeys.has(c.key)) || []; + return [...prev, ...newComponents]; + }); + } else { + // This is initial load, replace all components + if (!cancelled) { + setAllComponents(data.data || []); + } + } + + // Update pagination state + if (!cancelled) { + const pageInfo = data.page_info; + setHasMore(pageInfo ? (pageInfo.count >= 50) : false); + setIsLoadingMore(false); + } + } + + if (query.isError && !cancelled) { + setIsLoadingMore(false); + } + + return () => { + cancelled = true; + }; + }, [query.isSuccess, query.isError, query.data, cursor]); + + // Load more function - don't depend on entire query object + const loadMore = useCallback(() => { + if (hasMore && !query.isFetching && !isLoadingMore && query.data?.page_info?.end_cursor) { + setIsLoadingMore(true); + setCursor(query.data.page_info.end_cursor); + } + }, [hasMore, query.isFetching, query.data?.page_info?.end_cursor, isLoadingMore]); + + const reset = useCallback(() => { + setAllComponents([]); + setHasMore(true); + setCursor(undefined); + setIsLoadingMore(false); + }, []); + + // Reset when input changes (e.g., different app or componentType) + useEffect(() => { + let cancelled = false; + + // Use a microtask to avoid state updates during render + queueMicrotask(() => { + if (!cancelled) { + setAllComponents([]); + setHasMore(true); + setCursor(undefined); + setIsLoadingMore(false); + } + }); + + return () => { + cancelled = true; + }; + }, [input?.app, input?.componentType]); + + return { + ...query, + components: allComponents, + hasMore, + loadMore, + reset, + isLoadingMore, + }; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e492d9ef4862e..b913a85ad5d17 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -990,8 +990,7 @@ importers: specifier: ^3.0.0 version: 3.0.3 - components/arpoone: - specifiers: {} + components/arpoone: {} components/arxiv: {} @@ -6794,8 +6793,7 @@ importers: specifier: ^1.6.5 version: 1.6.6 - components/issue_badge: - specifiers: {} + components/issue_badge: {} components/itemize: dependencies: @@ -7449,11 +7447,9 @@ importers: specifier: ^3.0.0 version: 3.0.3 - components/limitless: - specifiers: {} + components/limitless: {} - components/limitless_ai: - specifiers: {} + components/limitless_ai: {} components/limoexpress: dependencies: @@ -10372,8 +10368,7 @@ importers: specifier: ^1.5.1 version: 1.6.6 - components/postmaster: - specifiers: {} + components/postmaster: {} components/power_automate: {} @@ -15558,8 +15553,7 @@ importers: specifier: ^3.0.0 version: 3.0.3 - components/zowie: - specifiers: {} + components/zowie: {} components/zulip: dependencies: @@ -36158,6 +36152,8 @@ snapshots: '@putout/operator-filesystem': 5.0.0(putout@36.13.1(eslint@8.57.1)(typescript@5.6.3)) '@putout/operator-json': 2.2.0 putout: 36.13.1(eslint@8.57.1)(typescript@5.6.3) + transitivePeerDependencies: + - supports-color '@putout/operator-regexp@1.0.0(putout@36.13.1(eslint@8.57.1)(typescript@5.6.3))': dependencies: