SnippetDropdown.tsx 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152
  1. import { keepPreviousData } from '@tanstack/react-query'
  2. import { useDebounce, useIntersectionObserver } from '@uidotdev/usehooks'
  3. import { Plus } from 'lucide-react'
  4. import { ReactNode, useEffect, useMemo, useRef, useState } from 'react'
  5. import {
  6. Command,
  7. CommandGroup,
  8. CommandInput,
  9. CommandItem,
  10. CommandList,
  11. DropdownMenu,
  12. DropdownMenuContent,
  13. DropdownMenuTrigger,
  14. ScrollArea,
  15. } from 'ui'
  16. import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
  17. import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
  18. import { useContentInfiniteQuery } from '@/data/content/content-infinite-query'
  19. import type { Content } from '@/data/content/content-query'
  20. import { SNIPPET_PAGE_LIMIT } from '@/data/content/sql-folders-query'
  21. import { editorPanelState } from '@/state/editor-panel-state'
  22. import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state'
  23. type SnippetDropdownProps = {
  24. projectRef?: string
  25. trigger: ReactNode
  26. side?: 'top' | 'bottom' | 'left' | 'right'
  27. align?: 'start' | 'center' | 'end'
  28. className?: string
  29. autoFocus?: boolean
  30. onSelect: (snippet: { id: string; name: string }) => void
  31. }
  32. type SqlContentItem = Extract<Content, { type: 'sql' }>
  33. export const SnippetDropdown = ({
  34. projectRef,
  35. trigger,
  36. side = 'bottom',
  37. align = 'end',
  38. className,
  39. autoFocus = false,
  40. onSelect,
  41. }: SnippetDropdownProps) => {
  42. const { openSidebar } = useSidebarManagerSnapshot()
  43. const scrollRootRef = useRef<HTMLDivElement | null>(null)
  44. const [open, setOpen] = useState(false)
  45. const [search, setSearch] = useState('')
  46. const debouncedSearch = useDebounce(search, 500)
  47. const {
  48. data,
  49. isPending: isLoading,
  50. hasNextPage,
  51. fetchNextPage,
  52. isFetchingNextPage,
  53. } = useContentInfiniteQuery(
  54. {
  55. projectRef,
  56. type: 'sql',
  57. limit: SNIPPET_PAGE_LIMIT,
  58. name: search.length === 0 ? search : debouncedSearch,
  59. },
  60. { placeholderData: keepPreviousData }
  61. )
  62. const snippets = useMemo(() => {
  63. const items = data?.pages.flatMap((page) => page.content) ?? []
  64. return items as SqlContentItem[]
  65. }, [data?.pages])
  66. const [sentinelRef, entry] = useIntersectionObserver({
  67. root: scrollRootRef.current,
  68. threshold: 0,
  69. rootMargin: '0px',
  70. })
  71. useEffect(() => {
  72. if (entry?.isIntersecting && hasNextPage && !isFetchingNextPage && !isLoading) {
  73. fetchNextPage()
  74. }
  75. }, [entry?.isIntersecting, hasNextPage, isFetchingNextPage, isLoading, fetchNextPage])
  76. return (
  77. <DropdownMenu open={open} onOpenChange={setOpen}>
  78. <DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
  79. <DropdownMenuContent
  80. side={side}
  81. align={align}
  82. className={['w-80 p-0', className].filter(Boolean).join(' ')}
  83. >
  84. <Command shouldFilter={false}>
  85. <CommandInput
  86. showResetIcon
  87. autoFocus={autoFocus}
  88. placeholder="Search snippets..."
  89. value={search}
  90. onValueChange={setSearch}
  91. handleReset={() => setSearch('')}
  92. />
  93. <CommandList ref={scrollRootRef}>
  94. {isLoading ? (
  95. <p className="text-xs text-center text-foreground-lighter py-3">Loading...</p>
  96. ) : search.length > 0 && snippets.length === 0 ? (
  97. <p className="text-xs text-center text-foreground-lighter py-3">No snippets found</p>
  98. ) : (
  99. <CommandGroup>
  100. <ScrollArea className={snippets.length > 7 ? 'h-[210px]' : ''}>
  101. {snippets.map((snippet) => (
  102. <CommandItem
  103. key={snippet.id}
  104. value={snippet.id}
  105. onSelect={() => onSelect({ id: snippet.id, name: snippet.name })}
  106. >
  107. {snippet.name}
  108. </CommandItem>
  109. ))}
  110. <div ref={sentinelRef} className="h-1 -mt-1" />
  111. {hasNextPage && (
  112. <div className="px-2 py-1">
  113. <ShimmeringLoader className="py-2" />
  114. </div>
  115. )}
  116. </ScrollArea>
  117. </CommandGroup>
  118. )}
  119. <div className="h-px bg-border-overlay -mx-1" />
  120. <CommandGroup>
  121. <CommandItem
  122. className="cursor-pointer w-full"
  123. onSelect={() => {
  124. setOpen(false)
  125. editorPanelState.openAsNew()
  126. openSidebar(SIDEBAR_KEYS.EDITOR_PANEL)
  127. }}
  128. >
  129. <div className="w-full flex items-center gap-2">
  130. <Plus size={14} strokeWidth={1.5} />
  131. <p>Create snippet</p>
  132. </div>
  133. </CommandItem>
  134. </CommandGroup>
  135. </CommandList>
  136. </Command>
  137. </DropdownMenuContent>
  138. </DropdownMenu>
  139. )
  140. }