diff --git a/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.ComboBox.tsx b/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.ComboBox.tsx index 53d62179b15..3433857be1d 100644 --- a/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.ComboBox.tsx +++ b/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.ComboBox.tsx @@ -1,11 +1,10 @@ import { noop } from 'lodash-es' import { Check, ChevronsUpDown } from 'lucide-react' -import { useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { Button_Shadcn_ as Button, cn, Command_Shadcn_ as Command, - CommandEmpty_Shadcn_ as CommandEmpty, CommandGroup_Shadcn_ as CommandGroup, CommandInput_Shadcn_ as CommandInput, CommandItem_Shadcn_ as CommandItem, @@ -15,6 +14,8 @@ import { PopoverTrigger_Shadcn_ as PopoverTrigger, ScrollArea, } from 'ui' +import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' +import { useIntersectionObserver } from '~/hooks/useIntersectionObserver' export interface ComboBoxOption { id: string @@ -28,25 +29,56 @@ export function ComboBox({ name, options, selectedOption, + selectedDisplayName, onSelectOption = noop, className, + search = '', + hasNextPage = false, + isFetching = false, + isFetchingNextPage = false, + fetchNextPage, + setSearch = () => {}, + useCommandSearch = true, }: { isLoading: boolean disabled?: boolean name: string options: Opt[] selectedOption?: string + selectedDisplayName?: string onSelectOption?: (newValue: string) => void className?: string + search?: string + hasNextPage?: boolean + isFetching?: boolean + isFetchingNextPage?: boolean + fetchNextPage?: () => void + setSearch?: (value: string) => void + useCommandSearch?: boolean }) { const [open, setOpen] = useState(false) - const selectedOptionDisplayName = options.find( - (option) => option.value === selectedOption - )?.displayName + const scrollRootRef = useRef(null) + const [sentinelRef, entry] = useIntersectionObserver({ + root: scrollRootRef.current, + threshold: 0, + rootMargin: '0px', + }) + + useEffect(() => { + if (!isLoading && !isFetching && !isFetchingNextPage && hasNextPage && entry?.isIntersecting) { + fetchNextPage?.() + } + }, [isLoading, isFetching, isFetchingNextPage, hasNextPage, entry?.isIntersecting, fetchNextPage]) return ( - + { + setOpen(value) + if (!value) setSearch('') + }} + > - - - + + + setSearch('')} + /> - No {name} found. - 10 ? 'h-[280px]' : ''}> - {options.map((option) => ( - { - setOpen(false) - onSelectOption(selectedValue) - }} - className="cursor-pointer" - > - - {option.displayName} - - ))} - + {isLoading ? ( +
+ + +
+ ) : ( + <> + {search.length > 0 && options.length === 0 && ( +

+ No {name}s found based on your search +

+ )} + 7 ? 'h-[210px]' : ''}> + {options.map((option) => ( + { + setOpen(false) + onSelectOption(selectedValue) + }} + className="cursor-pointer" + > + + {option.displayName} + + ))} +
+ {hasNextPage && } + + + )} diff --git a/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.tsx b/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.tsx index d384746540b..ff60b0eca6b 100644 --- a/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.tsx +++ b/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.tsx @@ -3,14 +3,12 @@ import type { Branch, Org, - Project, Variable, } from '~/components/ProjectConfigVariables/ProjectConfigVariables.utils' -import type { ProjectKeys, ProjectSettings } from '~/lib/fetch/projectApi' import { Check, Copy } from 'lucide-react' import Link from 'next/link' -import { useEffect, useMemo } from 'react' +import { useEffect, useMemo, useState } from 'react' import CopyToClipboard from 'react-copy-to-clipboard' import { withErrorBoundary } from 'react-error-boundary' import { proxy, useSnapshot } from 'valtio' @@ -31,11 +29,16 @@ import { toOrgProjectValue, } from '~/components/ProjectConfigVariables/ProjectConfigVariables.utils' import { useCopy } from '~/hooks/useCopy' +import { useDebounce } from '~/hooks/useDebounce' import { useBranchesQuery } from '~/lib/fetch/branches' import { useOrganizationsQuery } from '~/lib/fetch/organizations' -import { type SupavisorConfigData, useSupavisorConfigQuery } from '~/lib/fetch/pooler' -import { useProjectSettingsQuery, useProjectKeysQuery } from '~/lib/fetch/projectApi' -import { isProjectPaused, useProjectsQuery } from '~/lib/fetch/projects' +import { useSupavisorConfigQuery, type SupavisorConfigData } from '~/lib/fetch/pooler' +import { useProjectKeysQuery, useProjectSettingsQuery } from '~/lib/fetch/projectApi' +import { + isProjectPaused, + ProjectInfoInfinite, + useProjectsInfiniteQuery, +} from '~/lib/fetch/projects-infinite' import { retrieve, storeOrRemoveNull } from '~/lib/storage' import { useOnLogout } from '~/lib/userAuth' @@ -67,8 +70,8 @@ type VariableDataState = const projectsStore = proxy({ selectedOrg: null as Org | null, - selectedProject: null as Project | null, - setSelectedOrgProject: (org: Org | null, project: Project | null) => { + selectedProject: null as ProjectInfoInfinite | null, + setSelectedOrgProject: (org: Org | null, project: ProjectInfoInfinite | null) => { projectsStore.selectedOrg = org storeOrRemoveNull('local', LOCAL_STORAGE_KEYS.SAVED_ORG, org?.id.toString()) @@ -90,6 +93,9 @@ function OrgProjectSelector() { const isUserLoading = useIsUserLoading() const isLoggedIn = useIsLoggedIn() + const [search, setSearch] = useState('') + const debouncedSearch = useDebounce(search, 500) + const { selectedOrg, selectedProject, setSelectedOrgProject } = useSnapshot(projectsStore) const { @@ -97,11 +103,21 @@ function OrgProjectSelector() { isPending: organizationsIsPending, isError: organizationsIsError, } = useOrganizationsQuery({ enabled: isLoggedIn }) + const { - data: projects, + data: projectsData, isPending: projectsIsPending, isError: projectsIsError, - } = useProjectsQuery({ enabled: isLoggedIn }) + isFetching, + isFetchingNextPage, + hasNextPage, + fetchNextPage, + } = useProjectsInfiniteQuery( + { search: search.length === 0 ? search : debouncedSearch }, + { enabled: isLoggedIn } + ) + const projects = + useMemo(() => projectsData?.pages.flatMap((page) => page.projects), [projectsData?.pages]) || [] const anyIsPending = organizationsIsPending || projectsIsPending const anyIsError = organizationsIsError || projectsIsError @@ -141,7 +157,7 @@ function OrgProjectSelector() { const storedMaybeProjectRef = retrieve('local', LOCAL_STORAGE_KEYS.SAVED_PROJECT) let storedOrg: Org | undefined - let storedProject: Project | undefined + let storedProject: ProjectInfoInfinite | undefined if (storedMaybeOrgId && storedMaybeProjectRef) { storedOrg = organizations!.find((org) => org.id === Number(storedMaybeOrgId)) storedProject = projects!.find((project) => project.ref === storedMaybeProjectRef) @@ -167,6 +183,11 @@ function OrgProjectSelector() { stateSummary === 'loggedIn.dataSuccess.hasNoData' } options={formattedData} + selectedDisplayName={ + selectedOrg && selectedProject + ? toDisplayNameOrgProject(selectedOrg, selectedProject) + : undefined + } selectedOption={ selectedOrg && selectedProject ? toOrgProjectValue(selectedOrg, selectedProject) : undefined } @@ -181,6 +202,13 @@ function OrgProjectSelector() { setSelectedOrgProject(org, project) } }} + search={search} + isFetching={isFetching} + isFetchingNextPage={isFetchingNextPage} + hasNextPage={hasNextPage} + fetchNextPage={fetchNextPage} + setSearch={setSearch} + useCommandSearch={false} /> ) } @@ -190,6 +218,7 @@ function BranchSelector() { const isLoggedIn = useIsLoggedIn() const { selectedProject, selectedBranch, setSelectedBranch } = useSnapshot(projectsStore) + const [branchSearch, setBranchSearch] = useState('') const projectPaused = isProjectPaused(selectedProject) const hasBranches = selectedProject?.is_branch_enabled ?? false @@ -253,7 +282,10 @@ function BranchSelector() { stateSummary === 'loggedIn.branches.dataSuccess.noData' } options={formattedData} + selectedDisplayName={selectedBranch?.name} selectedOption={selectedBranch ? toBranchValue(selectedBranch) : undefined} + search={branchSearch} + setSearch={setBranchSearch} onSelectOption={(option) => { const [branchId] = fromBranchValue(option) if (branchId) { diff --git a/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.utils.ts b/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.utils.ts index cb06766dbad..15e5c1128ae 100644 --- a/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.utils.ts +++ b/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.utils.ts @@ -1,9 +1,8 @@ import type { BranchesData } from '~/lib/fetch/branches' import type { OrganizationsData } from '~/lib/fetch/organizations' -import type { ProjectsData } from '~/lib/fetch/projects' +import { ProjectInfoInfinite } from '~/lib/fetch/projects-infinite' export type Org = OrganizationsData[number] -export type Project = ProjectsData[number] export type Branch = BranchesData[number] export type Variable = 'url' | 'publishable' | 'anon' | 'sessionPooler' @@ -39,11 +38,17 @@ type DeepReadonly = { readonly [P in keyof T]: DeepReadonly } -export function toDisplayNameOrgProject(org: DeepReadonly, project: DeepReadonly) { +export function toDisplayNameOrgProject( + org: DeepReadonly, + project: DeepReadonly +) { return `${org.name} / ${project.name}` } -export function toOrgProjectValue(org: DeepReadonly, project: DeepReadonly) { +export function toOrgProjectValue( + org: DeepReadonly, + project: DeepReadonly +) { return escapeDoubleQuotes( // @ts-ignore -- problem in OpenAPI spec -- project has ref property JSON.stringify([org.id, project.ref, removeDoubleQuotes(toDisplayNameOrgProject(org, project))]) diff --git a/apps/docs/features/ui/McpConfigPanel.tsx b/apps/docs/features/ui/McpConfigPanel.tsx index be86b5261d5..87ccd8c1d1a 100644 --- a/apps/docs/features/ui/McpConfigPanel.tsx +++ b/apps/docs/features/ui/McpConfigPanel.tsx @@ -4,12 +4,11 @@ import { useIsLoggedIn, useIsUserLoading } from 'common' import { Check, ChevronDown } from 'lucide-react' import { useTheme } from 'next-themes' import Link from 'next/link' -import { useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { Button, cn, Command_Shadcn_, - CommandEmpty_Shadcn_, CommandGroup_Shadcn_, CommandInput_Shadcn_, CommandItem_Shadcn_, @@ -17,10 +16,14 @@ import { Popover_Shadcn_, PopoverContent_Shadcn_, PopoverTrigger_Shadcn_, + ScrollArea, } from 'ui' import { Admonition } from 'ui-patterns' import { McpConfigPanel as McpConfigPanelBase } from 'ui-patterns/McpUrlBuilder' -import { useProjectsQuery } from '~/lib/fetch/projects' +import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' +import { useDebounce } from '~/hooks/useDebounce' +import { useIntersectionObserver } from '~/hooks/useIntersectionObserver' +import { useProjectsInfiniteQuery } from '~/lib/fetch/projects-infinite' type PlatformType = (typeof PLATFORMS)[number]['value'] @@ -29,6 +32,7 @@ const PLATFORMS = [ { value: 'local', label: 'CLI' }, ] as const satisfies Array<{ value: string; label: string }> +// [Joshen] Ideally we consolidate this component with what's in ProjectConfigVariables - they seem to be doing the same thing function ProjectSelector({ className, selectedProject, @@ -38,14 +42,57 @@ function ProjectSelector({ selectedProject?: { ref: string; name: string } | null onProjectSelect?: (project: { ref: string; name: string } | null) => void }) { + const [open, setOpen] = useState(false) + const [search, setSearch] = useState('') + const debouncedSearch = useDebounce(search, 500) + + const scrollRootRef = useRef(null) + const [sentinelRef, entry] = useIntersectionObserver({ + root: scrollRootRef.current, + threshold: 0, + rootMargin: '0px', + }) + const isUserLoading = useIsUserLoading() const isLoggedIn = useIsLoggedIn() - const { data: projects, isLoading, isError } = useProjectsQuery({ enabled: isLoggedIn }) - const [open, setOpen] = useState(false) + const { + data: projectsData, + isLoading, + isError, + isFetching, + isFetchingNextPage, + hasNextPage, + fetchNextPage, + } = useProjectsInfiniteQuery( + { search: search.length === 0 ? search : debouncedSearch }, + { enabled: isLoggedIn } + ) + const projects = + useMemo(() => projectsData?.pages.flatMap((page) => page.projects), [projectsData?.pages]) || [] + + useEffect(() => { + if ( + !isLoading && + !isFetching && + !isFetchingNextPage && + hasNextPage && + entry?.isIntersecting && + !!fetchNextPage + ) { + fetchNextPage() + } + }, [isLoading, isFetching, isFetchingNextPage, hasNextPage, entry?.isIntersecting, fetchNextPage]) return ( - + { + setOpen(open) + if (!open) setSearch('') + }} + >
Project @@ -71,43 +118,68 @@ function ProjectSelector({ } >
- {isUserLoading || isLoading - ? 'Loading projects...' - : isError - ? 'Error fetching projects' - : selectedProject?.name ?? 'Select a project'} + {selectedProject?.name ?? + (isUserLoading || isLoading + ? 'Loading projects...' + : isError + ? 'Error fetching projects' + : 'Select a project')}
)}
- - - + + + setSearch('')} + /> - No results found. - {projects?.map((project) => ( - { - onProjectSelect?.(project.ref === selectedProject?.ref ? null : project) - setOpen(false) - }} - className="flex gap-2 items-center" - > - {project.name} - - - ))} + {isLoading ? ( +
+ + +
+ ) : ( + <> + {search.length > 0 && projects.length === 0 && ( +

+ No projects found based on your search +

+ )} + 7 ? 'h-[210px]' : ''}> + {projects?.map((project) => ( + { + onProjectSelect?.(project.ref === selectedProject?.ref ? null : project) + setOpen(false) + }} + className="flex gap-2 items-center" + > + {project.name} + + + ))} +
+ {hasNextPage && } + + + )} diff --git a/apps/docs/hooks/useDebounce.tsx b/apps/docs/hooks/useDebounce.tsx new file mode 100644 index 00000000000..30e6d7fea06 --- /dev/null +++ b/apps/docs/hooks/useDebounce.tsx @@ -0,0 +1,19 @@ +import { useEffect, useState } from 'react' + +// [Joshen] Copying from uidotdev/usehooks instead of installing the whole package +// https://github.com/uidotdev/usehooks/blob/945436df0037bc21133379a5e13f1bd73f1ffc36/index.js#L239 +export function useDebounce(value, delay) { + const [debouncedValue, setDebouncedValue] = useState(value) + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value) + }, delay) + + return () => { + clearTimeout(handler) + } + }, [value, delay]) + + return debouncedValue +} diff --git a/apps/docs/hooks/useIntersectionObserver.tsx b/apps/docs/hooks/useIntersectionObserver.tsx new file mode 100644 index 00000000000..aed2be307a6 --- /dev/null +++ b/apps/docs/hooks/useIntersectionObserver.tsx @@ -0,0 +1,40 @@ +import { RefCallback, useCallback, useRef, useState } from 'react' + +// [Joshen] Copying from uidotdev/usehooks instead of installing the whole package +// https://github.com/uidotdev/usehooks/blob/945436df0037bc21133379a5e13f1bd73f1ffc36/index.js#L512 +export function useIntersectionObserver( + options: { + root?: Element | Document | null + rootMargin?: string + threshold?: number | number[] + } = {} +): [RefCallback, IntersectionObserverEntry | null] { + const { threshold = 1, root = null, rootMargin = '0px' } = options + const [entry, setEntry] = useState(null) + + const previousObserver = useRef(null) + + const customRef = useCallback( + (node) => { + if (previousObserver.current) { + previousObserver.current.disconnect() + previousObserver.current = null + } + + if (node?.nodeType === Node.ELEMENT_NODE) { + const observer = new IntersectionObserver( + ([entry]) => { + setEntry(entry) + }, + { threshold, root, rootMargin } + ) + + observer.observe(node) + previousObserver.current = observer + } + }, + [threshold, root, rootMargin] + ) + + return [customRef, entry] +} diff --git a/apps/docs/lib/fetch/projects-infinite.ts b/apps/docs/lib/fetch/projects-infinite.ts new file mode 100644 index 00000000000..6d86bd7616e --- /dev/null +++ b/apps/docs/lib/fetch/projects-infinite.ts @@ -0,0 +1,86 @@ +import { useInfiniteQuery, UseInfiniteQueryOptions } from '@tanstack/react-query' +import { components } from 'api-types' +import type { ResponseError } from '~/types/fetch' +import { get } from './fetchWrappers' + +const DEFAULT_LIMIT = 10 +const projectKeys = { + listInfinite: (params?: { + limit: number + sort?: 'name_asc' | 'name_desc' | 'created_asc' | 'created_desc' + search?: string + }) => ['all-projects-infinite', params].filter(Boolean), +} + +interface GetProjectsInfiniteVariables { + limit?: number + sort?: 'name_asc' | 'name_desc' | 'created_asc' | 'created_desc' + search?: string + page?: number +} + +export type ProjectInfoInfinite = + components['schemas']['ListProjectsPaginatedResponse']['projects'][number] + +async function getProjects( + { + limit = DEFAULT_LIMIT, + page = 0, + sort = 'name_asc', + search: _search = '', + }: GetProjectsInfiniteVariables, + signal?: AbortSignal, + headers?: Record +) { + const offset = page * limit + const search = _search.length === 0 ? undefined : _search + + const { data, error } = await get('/platform/projects', { + // @ts-ignore [Joshen] API type issue for Version 2 endpoints + params: { query: { limit, offset, sort, search } }, + signal, + headers: { ...headers, Version: '2' }, + }) + + if (error) throw error + return data as unknown as components['schemas']['ListProjectsPaginatedResponse'] +} + +export type ProjectsInfiniteData = Awaited> +export type ProjectsInfiniteError = ResponseError + +export const useProjectsInfiniteQuery = < + TData = { pages: ProjectsInfiniteData[]; pageParams: number[] }, +>( + { limit = DEFAULT_LIMIT, sort = 'name_asc', search }: GetProjectsInfiniteVariables, + { + enabled = true, + ...options + }: Omit< + UseInfiniteQueryOptions, + 'queryKey' | 'getNextPageParam' | 'initialPageParam' + > +) => { + return useInfiniteQuery({ + enabled, + queryKey: projectKeys.listInfinite({ limit, sort, search }), + queryFn: ({ signal, pageParam }) => + getProjects({ limit, page: pageParam as any, sort, search }, signal), + initialPageParam: 0, + getNextPageParam(lastPage, pages) { + const page = pages.length + const currentTotalCount = page * limit + // @ts-ignore [Joshen] API type issue for Version 2 endpoints + const totalCount = lastPage.pagination.count + + if (currentTotalCount >= totalCount) return undefined + return page + }, + staleTime: 30 * 60 * 1000, // 30 minutes + ...options, + }) +} + +export function isProjectPaused(project: { status: string } | null): boolean | undefined { + return !project ? undefined : project.status === 'INACTIVE' +} diff --git a/apps/docs/lib/fetch/projects.ts b/apps/docs/lib/fetch/projects.ts deleted file mode 100644 index de4771ee14f..00000000000 --- a/apps/docs/lib/fetch/projects.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { useQuery, UseQueryOptions } from '@tanstack/react-query' -import type { ResponseError } from '~/types/fetch' -import { get } from './fetchWrappers' -import { type ReadonlyRecursive } from '~/types/utils' - -const projectKeys = { - list: () => ['all-projects'] as const, -} - -export async function getProjects(signal?: AbortSignal) { - const { data, error } = await get('/platform/projects', { signal }) - if (error) throw error - return data -} - -export type ProjectsData = Awaited> -type ProjectsError = ResponseError - -export function useProjectsQuery({ - enabled = true, - ...options -}: Omit, 'queryKey'> = {}) { - return useQuery({ - queryKey: projectKeys.list(), - queryFn: ({ signal }) => getProjects(signal), - enabled, - ...options, - }) -} - -export function isProjectPaused( - project: ReadonlyRecursive | null -): boolean | undefined { - return !project ? undefined : project.status === 'INACTIVE' -}