SecondLevelNav.StoragePicker.tsx 4.2 KB

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