SecondLevelNav.StoragePicker.tsx139 lines · main
1import { keepPreviousData } from '@tanstack/react-query'
2import { useDebounce, useIntersectionObserver } from '@uidotdev/usehooks'
3import { useEffect, useMemo, useRef, useState } from 'react'
4import { cn, Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from 'ui'
5
6import type { ResourcePickerRenderProps } from './SecondLevelNav.Layout'
7import { usePaginatedBucketsQuery } from '@/data/storage/buckets-query'
8
9type StorageResourceListProps = ResourcePickerRenderProps & {
10 projectRef?: string
11}
12
13const SEARCH_DEBOUNCE_MS = 400
14
15const useSearchQuery = () => {
16 const [search, setSearch] = useState('')
17 const debouncedSearch = useDebounce(search, SEARCH_DEBOUNCE_MS)
18 const searchQuery = search.length === 0 ? undefined : debouncedSearch
19
20 return {
21 rawQuery: search,
22 query: searchQuery,
23 setSearch,
24 }
25}
26
27type UseInfiniteLoadingBucketsParams = {
28 projectRef?: string
29 searchQuery?: string
30 rawQuery: string
31}
32
33const useInfiniteLoadingBuckets = ({
34 projectRef,
35 searchQuery,
36 rawQuery,
37}: UseInfiniteLoadingBucketsParams) => {
38 const { data, isFetching, hasNextPage, fetchNextPage, isFetchingNextPage } =
39 usePaginatedBucketsQuery(
40 { projectRef, search: searchQuery },
41 {
42 enabled: !!projectRef,
43 placeholderData: rawQuery.length === 0 ? keepPreviousData : undefined,
44 }
45 )
46 const buckets = useMemo(() => data?.pages.flatMap((page) => page) ?? [], [data])
47
48 const scrollContainerRef = useRef<HTMLDivElement | null>(null)
49 const [sentinelRef, entry] = useIntersectionObserver({
50 threshold: 1,
51 root: scrollContainerRef.current,
52 rootMargin: '0px',
53 })
54
55 useEffect(() => {
56 if (entry?.isIntersecting && hasNextPage && !isFetching) {
57 fetchNextPage()
58 }
59 }, [entry?.isIntersecting, fetchNextPage, hasNextPage, isFetching])
60
61 return {
62 isFetching,
63 hasNextPage,
64 isFetchingNextPage,
65 buckets,
66 scrollContainerRef,
67 sentinelRef,
68 }
69}
70
71export const StorageResourceList = ({
72 projectRef,
73 selectedResource,
74 onSelect,
75 closePopover,
76}: StorageResourceListProps) => {
77 const { rawQuery, query: searchQuery, setSearch } = useSearchQuery()
78
79 const { isFetching, hasNextPage, isFetchingNextPage, buckets, scrollContainerRef, sentinelRef } =
80 useInfiniteLoadingBuckets({
81 projectRef,
82 searchQuery,
83 rawQuery,
84 })
85
86 const handleSelect = (value: string) => {
87 onSelect(value)
88 closePopover()
89 }
90
91 const showEmptyState = !isFetching && buckets.length === 0
92 const emptyMessage =
93 rawQuery.length > 0 ? 'No buckets found for this search' : 'No buckets available'
94
95 return (
96 <Command shouldFilter={false}>
97 <CommandInput
98 showResetIcon
99 value={rawQuery}
100 onValueChange={setSearch}
101 placeholder="Search buckets..."
102 handleReset={() => setSearch('')}
103 />
104 <CommandList>
105 <CommandEmpty hidden={!showEmptyState} className="py-3 text-sm text-foreground-light">
106 {emptyMessage}
107 </CommandEmpty>
108 <CommandGroup>
109 {isFetching && buckets.length === 0 ? (
110 <div className="px-4 py-3 text-sm text-foreground-light">Loading buckets...</div>
111 ) : (
112 <div ref={scrollContainerRef} className="max-h-72 min-h-[150px] overflow-y-auto">
113 {buckets.map((bucket) => {
114 const isActive = bucket.name === selectedResource
115 return (
116 <CommandItem
117 key={bucket.id}
118 value={bucket.name}
119 className={cn(
120 'cursor-pointer px-4',
121 isActive ? 'text-foreground bg-selection' : 'text-foreground-light'
122 )}
123 onSelect={() => handleSelect(bucket.name)}
124 >
125 <p className="truncate">{bucket.name}</p>
126 </CommandItem>
127 )
128 })}
129 {hasNextPage && <div ref={sentinelRef} className="h-2 w-full" />}
130 </div>
131 )}
132 </CommandGroup>
133 {isFetchingNextPage && (
134 <div className="px-4 py-2 text-sm text-foreground-light">Loading more buckets...</div>
135 )}
136 </CommandList>
137 </Command>
138 )
139}