SnippetDropdown.tsx152 lines · main
| 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 | |
| 18 | import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' |
| 19 | import { useContentInfiniteQuery } from '@/data/content/content-infinite-query' |
| 20 | import type { Content } from '@/data/content/content-query' |
| 21 | import { SNIPPET_PAGE_LIMIT } from '@/data/content/sql-folders-query' |
| 22 | import { editorPanelState } from '@/state/editor-panel-state' |
| 23 | import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state' |
| 24 | |
| 25 | type 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 | |
| 35 | type SqlContentItem = Extract<Content, { type: 'sql' }> |
| 36 | |
| 37 | export 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 | } |