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