diff --git a/packages/connect-react/CHANGELOG.md b/packages/connect-react/CHANGELOG.md index eedcf1ee6416f..c4ef3d82a7701 100644 --- a/packages/connect-react/CHANGELOG.md +++ b/packages/connect-react/CHANGELOG.md @@ -2,6 +2,17 @@ # Changelog +## [2.1.0] - 2025-10-10 + +### Added + +- Added infinite scroll (with pagination) for `SelectApp` and `SelectComponent` dropdowns +- Increased default page size to 50 items per request for better UX + +### Fixed + +- Remote options now properly reset when parent props change (e.g., switching accounts) + ## [2.0.0] - 2025-10-02 ### Breaking Changes diff --git a/packages/connect-react/package.json b/packages/connect-react/package.json index 024bd45e639bc..7d0810927d045 100644 --- a/packages/connect-react/package.json +++ b/packages/connect-react/package.json @@ -1,6 +1,6 @@ { "name": "@pipedream/connect-react", - "version": "2.0.0", + "version": "2.1.0", "description": "Pipedream Connect library for React", "files": [ "dist" diff --git a/packages/connect-react/src/components/ControlSelect.tsx b/packages/connect-react/src/components/ControlSelect.tsx index 3d7c0a3499853..927c3829f5ce8 100644 --- a/packages/connect-react/src/components/ControlSelect.tsx +++ b/packages/connect-react/src/components/ControlSelect.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo, useState, + useRef, } from "react"; import type { CSSObjectWithLabel, MenuListProps, @@ -112,30 +113,53 @@ export function ControlSelect({ selectOptions, ]); - const LoadMore = ({ - // eslint-disable-next-line react/prop-types - children, ...props - }: MenuListProps, boolean>) => { - return ( - - {children} -
- { })} /> -
-
- ) - } - const props = select.getProps("controlSelect", baseSelectProps) - const finalComponents = { - ...props.components, - ...componentsOverride, - }; + // Use ref to store latest onLoadMore callback + // This allows stable component reference while calling current callback + const onLoadMoreRef = useRef(onLoadMore); + useEffect(() => { + onLoadMoreRef.current = onLoadMore; + }, [ + onLoadMore, + ]); - if (showLoadMoreButton) { - finalComponents.MenuList = LoadMore; - } + const showLoadMoreButtonRef = useRef(showLoadMoreButton); + showLoadMoreButtonRef.current = showLoadMoreButton; + + // Memoize custom components to prevent remounting + // Recompute when caller/customizer supplies new component overrides + const finalComponents = useMemo(() => { + const mergedComponents = { + ...(props.components ?? {}), + ...(componentsOverride ?? {}), + }; + const ParentMenuList = mergedComponents.MenuList ?? components.MenuList; + + // Always set MenuList, conditionally render button inside + const CustomMenuList = ({ + // eslint-disable-next-line react/prop-types + children, ...menuProps + }: MenuListProps, boolean>) => ( + + {children} + {showLoadMoreButtonRef.current && ( +
+ onLoadMoreRef.current?.()} /> +
+ )} +
+ ); + CustomMenuList.displayName = "CustomMenuList"; + + return { + ...mergedComponents, + MenuList: CustomMenuList, + }; + }, [ + props.components, + componentsOverride, + ]); const handleCreate = (inputValue: string) => { const newOption = sanitizeOption(inputValue as T) @@ -215,6 +239,7 @@ export function ControlSelect({ onChange={handleChange} {...props} {...selectProps} + components={finalComponents} {...additionalProps} /> ); diff --git a/packages/connect-react/src/components/RemoteOptionsContainer.tsx b/packages/connect-react/src/components/RemoteOptionsContainer.tsx index f3016cdebafe9..b1d020f940038 100644 --- a/packages/connect-react/src/components/RemoteOptionsContainer.tsx +++ b/packages/connect-react/src/components/RemoteOptionsContainer.tsx @@ -2,22 +2,34 @@ import type { ConfigurePropOpts, PropOptionValue, } from "@pipedream/sdk"; import { useQuery } from "@tanstack/react-query"; -import { useState } from "react"; +import { + useState, useEffect, useRef, useMemo, +} from "react"; import { useFormContext } from "../hooks/form-context"; import { useFormFieldContext } from "../hooks/form-field-context"; import { useFrontendClient } from "../hooks/frontend-client-context"; import { ConfigureComponentContext, RawPropOption, } from "../types"; -import { - isString, sanitizeOption, -} from "../utils/type-guards"; +import { sanitizeOption } from "../utils/type-guards"; import { ControlSelect } from "./ControlSelect"; export type RemoteOptionsContainerProps = { queryEnabled?: boolean; }; +type ConfigurePropResult = { + error: { name: string; message: string; } | undefined; + options: RawPropOption[]; + context: ConfigureComponentContext | undefined; +}; + +// Helper to extract value from an option +const extractOptionValue = (o: RawPropOption): PropOptionValue | null => { + const normalized = sanitizeOption(o); + return normalized.value ?? null; +}; + export function RemoteOptionsContainer({ queryEnabled }: RemoteOptionsContainerProps) { const client = useFrontendClient(); const { @@ -29,7 +41,7 @@ export function RemoteOptionsContainer({ queryEnabled }: RemoteOptionsContainerP props: { disableQueryDisabling }, } = useFormContext(); const { - idx, prop, + idx, prop, onChange, } = useFormFieldContext(); const [ @@ -42,53 +54,89 @@ export function RemoteOptionsContainer({ queryEnabled }: RemoteOptionsContainerP setPage, ] = useState(0); + const [ + context, + setContext, + ] = useState(undefined); + + const [ + nextContext, + setNextContext, + ] = useState(undefined); + const [ canLoadMore, setCanLoadMore, ] = useState(true); const [ - context, - setContext, - ] = useState(undefined); + accumulatedData, + setAccumulatedData, + ] = useState([]); + // State variable unused - we only use the setter and derive values from prevValues const [ - pageable, - setPageable, - ] = useState<{ - page: number; - prevContext: ConfigureComponentContext | undefined; - data: RawPropOption[]; - values: Set; - }>({ - page: 0, - prevContext: {}, - data: [], - values: new Set(), - }) - - const configuredPropsUpTo: Record = {}; - for (let i = 0; i < idx; i++) { - const prop = configurableProps[i]; - configuredPropsUpTo[prop.name] = configuredProps[prop.name]; - } - const componentConfigureInput: ConfigurePropOpts = { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _accumulatedValues, + setAccumulatedValues, + ] = useState>(new Set()); + + // Memoize configured props up to current index + const configuredPropsUpTo = useMemo(() => { + const props: Record = {}; + for (let i = 0; i < idx; i++) { + const p = configurableProps[i]; + props[p.name] = configuredProps[p.name]; + } + return props; + }, [ + idx, + configurableProps, + configuredProps, + ]); + + // Memoize account value for tracking changes + const accountValue = useMemo(() => { + const accountProp = configurableProps.find((p: { type: string; name: string; }) => p.type === "app"); + return accountProp + ? configuredProps[accountProp.name] + : undefined; + }, [ + configurableProps, + configuredProps, + ]); + + const componentConfigureInput: ConfigurePropOpts = useMemo(() => { + const input: ConfigurePropOpts = { + externalUserId, + page, + prevContext: context, + id: component.key, + propName: prop.name, + configuredProps: configuredPropsUpTo, + dynamicPropsId: dynamicProps?.id, + }; + if (prop.useQuery) { + input.query = query || ""; + } + return input; + }, [ externalUserId, page, - prevContext: context, - id: component.key, - propName: prop.name, - configuredProps: configuredPropsUpTo, - dynamicPropsId: dynamicProps?.id, - }; - if (prop.useQuery) { - componentConfigureInput.query = query || ""; // TODO ref.value ? Is this still supported? - } - // exclude dynamicPropsId from the key since only affect it should have is to add / remove props but prop by name should not change! - const queryKeyInput = { - ...componentConfigureInput, - } - delete queryKeyInput.dynamicPropsId + context, + component.key, + prop.name, + prop.useQuery, + configuredPropsUpTo, + dynamicProps?.id, + query, + ]); + + const queryKeyInput = useMemo(() => { + return componentConfigureInput; + }, [ + componentConfigureInput, + ]); const [ error, @@ -96,119 +144,263 @@ export function RemoteOptionsContainer({ queryEnabled }: RemoteOptionsContainerP ] = useState<{ name: string; message: string; }>(); const onLoadMore = () => { - setPage(pageable.page) - setContext(pageable.prevContext) - setPageable({ - ...pageable, - prevContext: {}, - }) - } - - // TODO handle error! + setPage((prev) => prev + 1); + setContext(nextContext); + }; + + // Track queryKey and account changes (need these before effects that use them) + const queryKeyString = JSON.stringify(queryKeyInput); + const accountKey = JSON.stringify(accountValue); + const prevResetKeyRef = useRef(); + const prevAccountKeyRef = useRef(); + + const resetKey = useMemo(() => { + const { + page: _page, + prevContext: _prevContext, + ...rest + } = componentConfigureInput; + void _page; + void _prevContext; + return JSON.stringify(rest); + }, [ + componentConfigureInput, + ]); + + // Check if there's an account prop - if so, it must be set for the query to be enabled + const hasAccountProp = useMemo(() => { + return configurableProps.some((p: { type: string; }) => p.type === "app"); + }, [ + configurableProps, + ]); + + const isQueryEnabled = useMemo(() => { + if (!queryEnabled) return false; + // If there's an account prop, it must be set + if (hasAccountProp && !accountValue) return false; + return true; + }, [ + queryEnabled, + hasAccountProp, + accountValue, + ]); + + // Fetch data without side effects - just return the raw response const { - isFetching, refetch, - } = useQuery({ + data: queryData, isFetching, refetch, dataUpdatedAt, + } = useQuery({ queryKey: [ "componentConfigure", queryKeyInput, ], - queryFn: async () => { - setError(undefined); + queryFn: async (): Promise => { const res = await client.components.configureProp(componentConfigureInput); - // XXX look at errors in response here too const { options, stringOptions, errors, } = res; if (errors?.length) { - // TODO field context setError? (for validity, etc.) + let error; try { - setError(JSON.parse(errors[0])); + error = JSON.parse(errors[0]); } catch { - setError({ + error = { name: "Error", message: errors[0], - }); + }; } - return []; - } - let _options: RawPropOption[] = [] - if (options?.length) { - _options = options; - } - if (stringOptions?.length) { - const options = []; - for (const stringOption of stringOptions) { - options.push({ - label: stringOption, - value: stringOption, - }); - } - _options = options; + return { + error, + options: [], + context: res.context, + }; } - const newOptions = [] - const allValues = new Set(pageable.values) - for (const o of _options || []) { - let value: PropOptionValue; - if (isString(o)) { - value = o; - } else if (o && typeof o === "object" && "value" in o && o.value != null) { - value = o.value; - } else { - // Skip items that don't match expected format + const stringOptionObjects = stringOptions?.map((str) => ({ + label: str, + value: str, + })) ?? []; + const _options: RawPropOption[] = [ + ...(options ?? []), + ...stringOptionObjects, + ]; + + return { + error: undefined, + options: _options, + context: res.context, + }; + }, + enabled: isQueryEnabled, + }); + + // Sync query data into accumulated state + useEffect(() => { + if (!queryData) return; + + // Handle errors + if (queryData.error) { + setError(queryData.error); + return; + } + + setError(undefined); + + // Store the context for the next page + setNextContext(queryData.context); + + // Determine if this is a fresh query or pagination + const isFirstPage = page === 0; + + // Track if we found new options (for canLoadMore logic) + let foundNewOptions = false; + + // Update values set + setAccumulatedValues((prevValues) => { + const baseValues = isFirstPage + ? new Set() + : prevValues; + const newValues = new Set(baseValues); + + for (const o of queryData.options) { + const value = extractOptionValue(o); + if (value === null) { console.warn("Skipping invalid option:", o); continue; } - if (allValues.has(value)) { - continue + + if (!newValues.has(value)) { + newValues.add(value); + foundNewOptions = true; } - allValues.add(value) - newOptions.push(o) } - let data = pageable.data - if (newOptions.length) { - data = [ - ...pageable.data, - ...newOptions, - ] as RawPropOption[] - setPageable({ - page: page + 1, - prevContext: res.context, - data, - values: allValues, - }) - } else { - setCanLoadMore(false) + + return newValues; + }); + + // Update accumulated data independently + setAccumulatedData((prevData) => { + const baseData = isFirstPage + ? [] + : prevData; + const newOptions: RawPropOption[] = []; + const tempValues = new Set(); + + // Build temp values set from existing data for deduplication + if (!isFirstPage) { + for (const o of baseData) { + const value = extractOptionValue(o); + if (value !== null) { + tempValues.add(value); + } + } } - return data; - }, - enabled: !!queryEnabled, - }); - const showLoadMoreButton = () => { - return !isFetching && !error && canLoadMore - } + for (const o of queryData.options) { + const value = extractOptionValue(o); + if (value === null) continue; + + if (!tempValues.has(value)) { + tempValues.add(value); + newOptions.push(o); + } + } + + if (!newOptions.length) { + return prevData; + } + + return [ + ...baseData, + ...newOptions, + ] as RawPropOption[]; + }); + + // Update canLoadMore flag after processing + if (!foundNewOptions) { + setCanLoadMore(false); + } + }, [ + queryData, + page, + dataUpdatedAt, + queryKeyString, + ]); + + // Reset pagination when queryKey changes + useEffect(() => { + if (!prevResetKeyRef.current) { + prevResetKeyRef.current = resetKey; + return; + } + + const queryParamsChanged = prevResetKeyRef.current !== resetKey; + + if (queryParamsChanged) { + setPage(0); + setContext(undefined); + setNextContext(undefined); + setCanLoadMore(true); + setAccumulatedData([]); + setAccumulatedValues(new Set()); + } + + prevResetKeyRef.current = resetKey; + }, [ + resetKey, + ]); + + // Separately track account changes to clear field value + useEffect(() => { + const accountChanged = prevAccountKeyRef.current && prevAccountKeyRef.current !== accountKey; + + if (accountChanged) { + // Account changed - clear the field value + onChange(undefined); + + // Always refetch when account changes to a non-null value + // This handles cases like A -> null -> A where queryKey returns to a previous value + if (accountValue != null && isQueryEnabled) { + refetch(); + } + } + + prevAccountKeyRef.current = accountKey; + }, [ + accountKey, + onChange, + isQueryEnabled, + accountValue, + refetch, + ]); + + const showLoadMoreButton = useMemo(() => { + return !isFetching && !error && canLoadMore; + }, [ + isFetching, + error, + canLoadMore, + ]); // TODO show error in different spot! const placeholder = error ? error.message : disableQueryDisabling ? "Click to configure" - : !queryEnabled + : !isQueryEnabled ? "Configure props above first" : undefined; const isDisabled = disableQueryDisabling ? false - : !queryEnabled; + : !isQueryEnabled; return ( o.nameSlug === value?.nameSlug) - || (value?.nameSlug - ? value as App - : null); + const isLoadingMoreRef = useRef(isLoadingMore); + isLoadingMoreRef.current = isLoadingMore; + + // Memoize the selected value to prevent unnecessary recalculations + const selectedValue = useMemo(() => { + return apps?.find((o: App) => o.nameSlug === value?.nameSlug) + || (value?.nameSlug + ? value as App + : null); + }, [ + apps, + value?.nameSlug, + ]); + + // Memoize loadMore callback + const handleMenuScrollToBottom = useCallback(() => { + if (hasMore && !isLoadingMore) { + loadMore(); + } + }, [ + hasMore, + isLoadingMore, + loadMore, + ]); + + // Memoize custom components to prevent remounting + const customComponents = useMemo(() => ({ + Option: (optionProps: OptionProps) => ( + + ), + SingleValue: (singleValueProps: SingleValueProps) => ( + +
+ {singleValueProps.data.name} + + {singleValueProps.data.name} + +
+
+ ), + MenuList: (props: MenuListProps) => ( + + {props.children} + {isLoadingMoreRef.current && ( +
+ Loading more apps... +
+ )} +
+ ), + IndicatorSeparator: () => null, + }), [ + Option, + SingleValue, + MenuList, + ]); return ( o.name || o.key} getOptionValue={(o) => o.key} value={selectedValue} onChange={(o) => onChange?.((o as Component) || undefined)} + onMenuScrollToBottom={handleMenuScrollToBottom} isLoading={isLoading} - components={{ - IndicatorSeparator: () => null, - }} + components={customComponents} /> ); } diff --git a/packages/connect-react/src/hooks/use-apps.tsx b/packages/connect-react/src/hooks/use-apps.tsx index 1ae04ec852c2b..62508639ea955 100644 --- a/packages/connect-react/src/hooks/use-apps.tsx +++ b/packages/connect-react/src/hooks/use-apps.tsx @@ -1,28 +1,77 @@ -import { useQuery } from "@tanstack/react-query"; +import { + useEffect, + useRef, +} from "react"; +import { + useQuery, UseQueryResult, +} from "@tanstack/react-query"; import type { - AppsListRequest, App, + AppsListRequest, + App, } from "@pipedream/sdk"; import { useFrontendClient } from "./frontend-client-context"; +import { isPaginatedPage } from "../utils/pagination"; +import { usePaginatedSdkList } from "./use-paginated-sdk-list"; + +export type UseAppsResult = Omit, "data"> & { + apps: App[]; + isLoadingMore: boolean; + hasMore: boolean; + loadMore: () => Promise; + loadMoreError?: Error; +}; /** - * Get list of apps that can be authenticated + * Get list of apps that can be authenticated with pagination support */ -export const useApps = (input?: AppsListRequest): { - apps: App[]; - isLoading: boolean; - error: Error | null; -} => { +export const useApps = (input?: AppsListRequest): UseAppsResult => { const client = useFrontendClient(); + + const { + items: apps, + hasMore, + isLoadingMore, + loadMore, + loadMoreError, + resetWithPage, + } = usePaginatedSdkList(); + + const prevQueryDataRef = useRef(); + const query = useQuery({ queryKey: [ "apps", input, ], - queryFn: () => client.apps.list(input), + queryFn: () => client.apps.list({ + limit: 50, + ...input, + }), }); + // Reset pagination ONLY when query data changes + useEffect(() => { + const inputKey = JSON.stringify(input ?? null); + const hasNewData = prevQueryDataRef.current !== query.data; + + if (!query.data || !isPaginatedPage(query.data) || !hasNewData) { + return; + } + + prevQueryDataRef.current = query.data; + resetWithPage(query.data, inputKey); + }, [ + query.data, + input, + resetWithPage, + ]); + return { ...query, - apps: query.data?.data || [], + apps, + isLoadingMore, + hasMore, + loadMore, + loadMoreError, }; }; diff --git a/packages/connect-react/src/hooks/use-components.tsx b/packages/connect-react/src/hooks/use-components.tsx index 1d3e77d70ab1f..3c4fa9419184a 100644 --- a/packages/connect-react/src/hooks/use-components.tsx +++ b/packages/connect-react/src/hooks/use-components.tsx @@ -1,29 +1,77 @@ -import { useQuery } from "@tanstack/react-query"; +import { + useEffect, + useRef, +} from "react"; +import { + useQuery, UseQueryResult, +} from "@tanstack/react-query"; import type { ComponentsListRequest, Component, } from "@pipedream/sdk"; import { useFrontendClient } from "./frontend-client-context"; +import { isPaginatedPage } from "../utils/pagination"; +import { usePaginatedSdkList } from "./use-paginated-sdk-list"; + +export type UseComponentsResult = Omit, "data"> & { + components: Component[]; + isLoadingMore: boolean; + hasMore: boolean; + loadMore: () => Promise; + loadMoreError?: Error; +}; /** - * Get list of components + * Get list of components with pagination support */ -export const useComponents = (input?: ComponentsListRequest): { - components: Component[]; - isLoading: boolean; - error: Error | null; -} => { +export const useComponents = (input?: ComponentsListRequest): UseComponentsResult => { const client = useFrontendClient(); + + const { + items: allComponents, + hasMore, + isLoadingMore, + loadMore, + loadMoreError, + resetWithPage, + } = usePaginatedSdkList(); + + const prevQueryDataRef = useRef(); + const query = useQuery({ queryKey: [ "components", input, ], - queryFn: () => client.components.list(input), + queryFn: () => client.components.list({ + limit: 50, + ...input, + }), }); + // Reset pagination ONLY when query data changes + useEffect(() => { + const inputKey = JSON.stringify(input ?? null); + const hasNewData = prevQueryDataRef.current !== query.data; + + if (!query.data || !isPaginatedPage(query.data) || !hasNewData) { + return; + } + + prevQueryDataRef.current = query.data; + resetWithPage(query.data, inputKey); + }, [ + query.data, + input, + resetWithPage, + ]); + return { ...query, - components: query.data?.data || [], + components: allComponents, + isLoadingMore, + hasMore, + loadMore, + loadMoreError, }; }; diff --git a/packages/connect-react/src/hooks/use-mounted-ref.ts b/packages/connect-react/src/hooks/use-mounted-ref.ts new file mode 100644 index 0000000000000..e7b04cc1e43f8 --- /dev/null +++ b/packages/connect-react/src/hooks/use-mounted-ref.ts @@ -0,0 +1,17 @@ +import { + useEffect, + useRef, +} from "react"; + +export function useMountedRef() { + const isMountedRef = useRef(false); + + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); + + return isMountedRef; +} diff --git a/packages/connect-react/src/hooks/use-paginated-sdk-list.ts b/packages/connect-react/src/hooks/use-paginated-sdk-list.ts new file mode 100644 index 0000000000000..e8259ff346b49 --- /dev/null +++ b/packages/connect-react/src/hooks/use-paginated-sdk-list.ts @@ -0,0 +1,136 @@ +import { + useCallback, + useRef, + useState, +} from "react"; +import { + clonePaginatedPage, + PaginatedPage, +} from "../utils/pagination"; +import { useMountedRef } from "./use-mounted-ref"; + +export type PaginatedSdkListState = { + items: T[]; + hasMore: boolean; + isLoadingMore: boolean; + loadMoreError?: Error; + loadMore: () => Promise; + resetWithPage: (page: PaginatedPage, identityKey: string) => void; +}; + +type QueryIdentity = { + version: number; +}; + +export function usePaginatedSdkList(): PaginatedSdkListState { + const [ + items, + setItems, + ] = useState([]); + const [ + hasMore, + setHasMore, + ] = useState(false); + const [ + isLoadingMore, + setIsLoadingMore, + ] = useState(false); + const [ + loadMoreError, + setLoadMoreError, + ] = useState(); + + const pageRef = useRef | null>(null); + const identityRef = useRef(); + const isMountedRef = useMountedRef(); + + const resetWithPage = useCallback((page: PaginatedPage, _identityKey: string) => { + const clone = clonePaginatedPage(page); + const nextVersion = (identityRef.current?.version ?? 0) + 1; + + identityRef.current = { + version: nextVersion, + }; + pageRef.current = clone; + + const nextItems = clone.data + ? clone.data.slice() + : []; + setItems(nextItems); + setHasMore(clone.hasNextPage()); + setIsLoadingMore(false); + setLoadMoreError(undefined); + }, []); + + const loadMore = useCallback(async () => { + const activeIdentity = identityRef.current; + const activePage = pageRef.current; + + if ( + !activeIdentity + || !activePage + || !hasMore + || isLoadingMore + ) { + return; + } + + const requestVersion = activeIdentity.version; + setIsLoadingMore(true); + + try { + const nextPage = await activePage.getNextPage(); + if (!isMountedRef.current) { + return; + } + + if (requestVersion !== (identityRef.current?.version ?? 0)) { + return; + } + + const clone = clonePaginatedPage(nextPage); + pageRef.current = clone; + setItems((prev) => { + if (!clone.data || clone.data.length === 0) { + return prev; + } + + return prev.concat(clone.data); + }); + setHasMore(clone.hasNextPage()); + setLoadMoreError(undefined); + } catch (err) { + if (!isMountedRef.current) { + return; + } + + if (requestVersion !== (identityRef.current?.version ?? 0)) { + return; + } + + setLoadMoreError(err instanceof Error + ? err + : new Error(String(err))); + } finally { + if ( + isMountedRef.current + && requestVersion === (identityRef.current?.version ?? 0) + ) { + setIsLoadingMore(false); + } + } + }, [ + hasMore, + isLoadingMore, + isMountedRef, + ]); + + return { + items, + hasMore, + isLoadingMore, + loadMoreError, + loadMore, + resetWithPage, + }; +} diff --git a/packages/connect-react/src/utils/pagination.ts b/packages/connect-react/src/utils/pagination.ts new file mode 100644 index 0000000000000..a85f0002cf9e0 --- /dev/null +++ b/packages/connect-react/src/utils/pagination.ts @@ -0,0 +1,26 @@ +export type PaginatedPage = { + data?: T[]; + hasNextPage: () => boolean; + getNextPage: () => Promise>; +}; + +export const isPaginatedPage = (value: unknown): value is PaginatedPage => { + if (!value || typeof value !== "object") { + return false; + } + + const candidate = value as { + hasNextPage?: unknown; + getNextPage?: unknown; + }; + + return ( + typeof candidate.hasNextPage === "function" + && typeof candidate.getNextPage === "function" + ); +}; + +export const clonePaginatedPage = (page: PaginatedPage): PaginatedPage => { + const prototype = Object.getPrototypeOf(page); + return Object.assign(Object.create(prototype ?? Object.prototype), page); +};