OrganizationProjectSelector.tsx 8.6 KB

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