OrganizationProjectSelector.tsx302 lines · main
| 1 | import { keepPreviousData } from '@tanstack/react-query' |
| 2 | import { useDebounce, useIntersectionObserver } from '@uidotdev/usehooks' |
| 3 | import { ChevronsUpDown, HelpCircle } from 'lucide-react' |
| 4 | import { ReactNode, useEffect, useId, useMemo, useRef, useState } from 'react' |
| 5 | import { |
| 6 | Button, |
| 7 | cn, |
| 8 | Command, |
| 9 | CommandGroup, |
| 10 | CommandInput, |
| 11 | CommandList, |
| 12 | Popover, |
| 13 | PopoverContent, |
| 14 | PopoverTrigger, |
| 15 | ScrollArea, |
| 16 | Tooltip, |
| 17 | TooltipContent, |
| 18 | TooltipTrigger, |
| 19 | } from 'ui' |
| 20 | import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' |
| 21 | |
| 22 | import { EmbeddedProjectList } from './OrganizationProjectSelector/EmbeddedProjectList' |
| 23 | import { ProjectCommandItem } from './OrganizationProjectSelector/ProjectCommandItem' |
| 24 | import { |
| 25 | OrgProject, |
| 26 | useOrgProjectsInfiniteQuery, |
| 27 | } from '@/data/projects/org-projects-infinite-query' |
| 28 | import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' |
| 29 | |
| 30 | interface OrganizationProjectSelectorSelectorProps { |
| 31 | slug?: string |
| 32 | open?: boolean |
| 33 | selectedRef?: string | null |
| 34 | searchPlaceholder?: string |
| 35 | sameWidthAsTrigger?: boolean |
| 36 | checkPosition?: 'right' | 'left' |
| 37 | setOpen?: (value: boolean) => void |
| 38 | renderRow?: (project: OrgProject) => ReactNode |
| 39 | renderTrigger?: ({ |
| 40 | isLoading, |
| 41 | project, |
| 42 | listboxId, |
| 43 | open, |
| 44 | }: { |
| 45 | isLoading: boolean |
| 46 | project?: OrgProject |
| 47 | listboxId: string |
| 48 | open: boolean |
| 49 | }) => ReactNode |
| 50 | renderActions?: (setOpen: (value: boolean) => void, options?: { embedded?: boolean }) => ReactNode |
| 51 | onSelect?: (project: OrgProject) => void |
| 52 | onInitialLoad?: (projects: OrgProject[]) => void |
| 53 | isOptionDisabled?: (project: OrgProject) => boolean |
| 54 | fetchOnMount?: boolean |
| 55 | modal?: boolean |
| 56 | /** When true, render only the command list (no popover/trigger). For use inside sheet or popover. */ |
| 57 | embedded?: boolean |
| 58 | className?: string |
| 59 | } |
| 60 | |
| 61 | export const OrganizationProjectSelector = ({ |
| 62 | slug: _slug, |
| 63 | open: _open, |
| 64 | setOpen: _setOpen, |
| 65 | selectedRef, |
| 66 | searchPlaceholder = 'Find project...', |
| 67 | sameWidthAsTrigger = false, |
| 68 | checkPosition = 'right', |
| 69 | renderRow, |
| 70 | renderTrigger, |
| 71 | renderActions, |
| 72 | onSelect, |
| 73 | onInitialLoad, |
| 74 | isOptionDisabled, |
| 75 | fetchOnMount = false, |
| 76 | modal = false, |
| 77 | embedded = false, |
| 78 | className, |
| 79 | }: OrganizationProjectSelectorSelectorProps) => { |
| 80 | const { data: organization } = useSelectedOrganizationQuery() |
| 81 | const slug = _slug ?? organization?.slug |
| 82 | |
| 83 | const [openInternal, setOpenInternal] = useState(false) |
| 84 | const open = _open ?? openInternal |
| 85 | const setOpen = _setOpen ?? setOpenInternal |
| 86 | const listboxId = useId() |
| 87 | |
| 88 | const [search, setSearch] = useState('') |
| 89 | const debouncedSearch = useDebounce(search, 500) |
| 90 | |
| 91 | const scrollRootRef = useRef<HTMLDivElement | null>(null) |
| 92 | const [sentinelRef, entry] = useIntersectionObserver({ |
| 93 | root: scrollRootRef.current, |
| 94 | threshold: 0, |
| 95 | rootMargin: '0px', |
| 96 | }) |
| 97 | |
| 98 | const { |
| 99 | data, |
| 100 | error: projectsError, |
| 101 | isLoading: isLoadingProjects, |
| 102 | isError: isErrorProjects, |
| 103 | isSuccess: isSuccessProjects, |
| 104 | isFetching, |
| 105 | isFetchingNextPage, |
| 106 | hasNextPage, |
| 107 | fetchNextPage, |
| 108 | } = useOrgProjectsInfiniteQuery( |
| 109 | { slug, search: search.length === 0 ? search : debouncedSearch }, |
| 110 | { enabled: fetchOnMount || open, placeholderData: keepPreviousData } |
| 111 | ) |
| 112 | |
| 113 | const projects = useMemo(() => data?.pages.flatMap((page) => page.projects), [data?.pages]) || [] |
| 114 | const selectedProject = projects.find((p) => p.ref === selectedRef) |
| 115 | |
| 116 | useEffect(() => { |
| 117 | if ( |
| 118 | !isLoadingProjects && |
| 119 | !isFetching && |
| 120 | entry?.isIntersecting && |
| 121 | hasNextPage && |
| 122 | !isFetchingNextPage |
| 123 | ) { |
| 124 | fetchNextPage() |
| 125 | } |
| 126 | }, [ |
| 127 | entry?.isIntersecting, |
| 128 | hasNextPage, |
| 129 | isFetching, |
| 130 | isFetchingNextPage, |
| 131 | isLoadingProjects, |
| 132 | fetchNextPage, |
| 133 | ]) |
| 134 | |
| 135 | useEffect(() => { |
| 136 | // isLoadingProjects is true only during initial load. If the variables for the query change (slug), isLoadingProjects |
| 137 | // will be true again. |
| 138 | if (!isLoadingProjects && isSuccessProjects) { |
| 139 | onInitialLoad?.(projects) |
| 140 | } |
| 141 | // eslint-disable-next-line react-hooks/exhaustive-deps |
| 142 | }, [isLoadingProjects, isSuccessProjects]) |
| 143 | |
| 144 | function renderListContent() { |
| 145 | if (isLoadingProjects) { |
| 146 | return ( |
| 147 | <> |
| 148 | <div className="px-2 py-1"> |
| 149 | <ShimmeringLoader className="py-2" /> |
| 150 | </div> |
| 151 | <div className="px-2 py-1 w-4/5"> |
| 152 | <ShimmeringLoader className="py-2" /> |
| 153 | </div> |
| 154 | </> |
| 155 | ) |
| 156 | } |
| 157 | if (isErrorProjects) { |
| 158 | return ( |
| 159 | <div className="flex items-center gap-x-2 py-3 justify-center"> |
| 160 | <p className="text-xs text-foreground-lighter">Failed to retrieve projects</p> |
| 161 | <Tooltip> |
| 162 | <TooltipTrigger> |
| 163 | <HelpCircle size={14} /> |
| 164 | </TooltipTrigger> |
| 165 | <TooltipContent side="bottom">Error: {projectsError?.message}</TooltipContent> |
| 166 | </Tooltip> |
| 167 | </div> |
| 168 | ) |
| 169 | } |
| 170 | if (search.length > 0 && projects.length === 0) { |
| 171 | return ( |
| 172 | <p className="text-xs text-center text-foreground-lighter py-3"> |
| 173 | No projects found based on your search |
| 174 | </p> |
| 175 | ) |
| 176 | } |
| 177 | if (projects.length === 0) { |
| 178 | return <p className="text-xs text-center text-foreground-lighter py-3">No projects found</p> |
| 179 | } |
| 180 | if (embedded) { |
| 181 | return ( |
| 182 | <EmbeddedProjectList |
| 183 | projects={projects} |
| 184 | selectedRef={selectedRef ?? undefined} |
| 185 | onSelect={onSelect} |
| 186 | onClose={() => setOpen(false)} |
| 187 | renderRow={renderRow} |
| 188 | checkPosition={checkPosition} |
| 189 | isOptionDisabled={isOptionDisabled} |
| 190 | sentinelRef={sentinelRef} |
| 191 | hasNextPage={!!hasNextPage} |
| 192 | /> |
| 193 | ) |
| 194 | } |
| 195 | return ( |
| 196 | <ScrollArea className={(projects || []).length > 7 ? 'h-full md:h-[210px]' : ''}> |
| 197 | {projects?.map((project) => ( |
| 198 | <ProjectCommandItem |
| 199 | key={project.ref} |
| 200 | project={project} |
| 201 | selectedRef={selectedRef ?? undefined} |
| 202 | onSelect={onSelect} |
| 203 | onClose={() => setOpen(false)} |
| 204 | renderRow={renderRow} |
| 205 | checkPosition={checkPosition} |
| 206 | isOptionDisabled={isOptionDisabled} |
| 207 | /> |
| 208 | ))} |
| 209 | <div ref={sentinelRef} className="h-1 -mt-1" /> |
| 210 | {hasNextPage && ( |
| 211 | <div className="px-2 py-1"> |
| 212 | <ShimmeringLoader className="py-2" /> |
| 213 | </div> |
| 214 | )} |
| 215 | </ScrollArea> |
| 216 | ) |
| 217 | } |
| 218 | |
| 219 | const commandContent = ( |
| 220 | <Command |
| 221 | shouldFilter={false} |
| 222 | className={cn(className, embedded && 'flex flex-col flex-1 min-h-0 overflow-hidden')} |
| 223 | > |
| 224 | {embedded && !!renderActions && ( |
| 225 | <div className="flex items-center gap-2 shrink-0 border-b p-2"> |
| 226 | {renderActions(setOpen, { embedded: true })} |
| 227 | </div> |
| 228 | )} |
| 229 | <CommandInput |
| 230 | showResetIcon |
| 231 | value={search} |
| 232 | onValueChange={setSearch} |
| 233 | placeholder={searchPlaceholder} |
| 234 | handleReset={() => setSearch('')} |
| 235 | wrapperClassName={embedded ? 'shrink-0 border-b' : undefined} |
| 236 | className="text-base sm:text-sm" |
| 237 | /> |
| 238 | <CommandList |
| 239 | className={ |
| 240 | embedded |
| 241 | ? 'flex-1 min-h-0 overflow-y-auto overflow-x-hidden max-h-none!' |
| 242 | : 'max-h-none md:max-h-[300px] overflow-y-auto overflow-x-hidden' |
| 243 | } |
| 244 | > |
| 245 | <CommandGroup className={embedded ? 'flex-1 min-h-0 overflow-hidden' : ''}> |
| 246 | {renderListContent()} |
| 247 | </CommandGroup> |
| 248 | {!!renderActions && !embedded && ( |
| 249 | <> |
| 250 | <div className="h-px bg-border-overlay -mx-1 shrink-0" /> |
| 251 | {renderActions(setOpen)} |
| 252 | </> |
| 253 | )} |
| 254 | </CommandList> |
| 255 | </Command> |
| 256 | ) |
| 257 | |
| 258 | if (embedded) { |
| 259 | return commandContent |
| 260 | } |
| 261 | |
| 262 | return ( |
| 263 | <Popover open={open} onOpenChange={setOpen} modal={modal}> |
| 264 | <PopoverTrigger asChild> |
| 265 | {renderTrigger ? ( |
| 266 | renderTrigger({ |
| 267 | isLoading: isLoadingProjects || isFetching, |
| 268 | project: selectedProject, |
| 269 | listboxId, |
| 270 | open, |
| 271 | }) |
| 272 | ) : ( |
| 273 | <Button |
| 274 | block |
| 275 | type="default" |
| 276 | role="combobox" |
| 277 | size="small" |
| 278 | aria-expanded={open} |
| 279 | aria-controls={listboxId} |
| 280 | className="justify-between" |
| 281 | iconRight={<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />} |
| 282 | > |
| 283 | {isLoadingProjects || isFetching ? ( |
| 284 | <ShimmeringLoader className="w-44 py-2" /> |
| 285 | ) : ( |
| 286 | (selectedProject?.name ?? 'Select a project') |
| 287 | )} |
| 288 | </Button> |
| 289 | )} |
| 290 | </PopoverTrigger> |
| 291 | <PopoverContent |
| 292 | id={listboxId} |
| 293 | sameWidthAsTrigger={sameWidthAsTrigger} |
| 294 | className="p-0" |
| 295 | side="bottom" |
| 296 | align="start" |
| 297 | > |
| 298 | {commandContent} |
| 299 | </PopoverContent> |
| 300 | </Popover> |
| 301 | ) |
| 302 | } |