OrganizationProjectSelector.tsx302 lines · main
1import { keepPreviousData } from '@tanstack/react-query'
2import { useDebounce, useIntersectionObserver } from '@uidotdev/usehooks'
3import { ChevronsUpDown, HelpCircle } from 'lucide-react'
4import { ReactNode, useEffect, useId, useMemo, useRef, useState } from 'react'
5import {
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'
20import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
21
22import { EmbeddedProjectList } from './OrganizationProjectSelector/EmbeddedProjectList'
23import { ProjectCommandItem } from './OrganizationProjectSelector/ProjectCommandItem'
24import {
25 OrgProject,
26 useOrgProjectsInfiniteQuery,
27} from '@/data/projects/org-projects-infinite-query'
28import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
29
30interface 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
61export 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}