ProjectList.tsx318 lines · main
1// @ts-nocheck
2import { keepPreviousData } from '@tanstack/react-query'
3import { useDebounce } from '@uidotdev/usehooks'
4import { LOCAL_STORAGE_KEYS, useParams } from 'common'
5import { parseAsArrayOf, parseAsString, parseAsStringLiteral, useQueryState } from 'nuqs'
6import { useMemo } from 'react'
7import {
8 Card,
9 cn,
10 Table,
11 TableBody,
12 TableCell,
13 TableHead,
14 TableHeader,
15 TableHeadSort,
16 TableRow,
17} from 'ui'
18
19import { LoadingCardView, LoadingTableView, NoProjectsState } from './EmptyStates'
20import { LoadMoreRows } from './LoadMoreRow'
21import { ProjectCard } from './ProjectCard'
22import {
23 getNextProjectListSortForColumn,
24 getProjectListAriaSort,
25 PROJECT_LIST_SORT_VALUES,
26 toTableHeadSortValue,
27} from './ProjectListSort.utils'
28import { ProjectTableRow } from './ProjectTableRow'
29import AlertError from '@/components/ui/AlertError'
30import { NoSearchResults } from '@/components/ui/NoSearchResults'
31import { useGitHubConnectionsQuery } from '@/data/integrations/github-connections-query'
32import { useOrgIntegrationsQuery } from '@/data/integrations/integrations-query-org-only'
33import { usePermissionsQuery } from '@/data/permissions/permissions-query'
34import { useOrgProjectsInfiniteQuery } from '@/data/projects/org-projects-infinite-query'
35import { useResourceWarningsQuery } from '@/data/usage/resource-warnings-query'
36import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
37import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
38import { IS_PLATFORM } from '@/lib/constants'
39import type { Organization } from '@/types'
40
41export interface ProjectListProps {
42 organization?: Organization
43 rewriteHref?: (projectRef: string) => string
44}
45
46export const ProjectList = ({ organization: organization_, rewriteHref }: ProjectListProps) => {
47 const { slug: urlSlug } = useParams()
48 const { data: selectedOrganization } = useSelectedOrganizationQuery()
49
50 const [search] = useQueryState('search', parseAsString.withDefault(''))
51 const debouncedSearch = useDebounce(search, 500)
52
53 const [filterStatus, setFilterStatus] = useQueryState(
54 'status',
55 parseAsArrayOf(parseAsString, ',').withDefault([])
56 )
57 const [sort, setSort] = useQueryState(
58 'sort',
59 parseAsStringLiteral(PROJECT_LIST_SORT_VALUES).withDefault('name_asc')
60 )
61 const [viewMode] = useLocalStorageQuery(LOCAL_STORAGE_KEYS.PROJECTS_VIEW, 'grid')
62
63 const organization = organization_ ?? selectedOrganization
64 const slug = organization?.slug ?? urlSlug
65
66 const {
67 data,
68 error: projectsError,
69 isLoading: isLoadingProjects,
70 isSuccess: isSuccessProjects,
71 isError: isErrorProjects,
72 isFetchingNextPage,
73 hasNextPage,
74 fetchNextPage,
75 } = useOrgProjectsInfiniteQuery(
76 {
77 slug,
78 sort,
79 search: search.length === 0 ? search : debouncedSearch,
80 statuses: filterStatus,
81 },
82 {
83 placeholderData: keepPreviousData,
84 }
85 )
86 const orgProjects =
87 useMemo(() => data?.pages.flatMap((page) => page.projects), [data?.pages]) || []
88
89 const {
90 isPending: _isLoadingPermissions,
91 isError: isErrorPermissions,
92 error: permissionsError,
93 } = usePermissionsQuery()
94 const { data: resourceWarnings } = useResourceWarningsQuery({ slug })
95
96 // Move all hooks to the top to comply with Rules of Hooks
97 const { data: integrations } = useOrgIntegrationsQuery({ orgSlug: organization?.slug })
98 const { data: connections } = useGitHubConnectionsQuery({ organizationId: organization?.id })
99
100 const isLoadingPermissions = IS_PLATFORM ? _isLoadingPermissions : false
101
102 const isEmpty =
103 debouncedSearch.length === 0 &&
104 filterStatus.length === 0 &&
105 (!orgProjects || orgProjects.length === 0)
106
107 const noResultsFromSearch =
108 debouncedSearch.length > 0 && isSuccessProjects && orgProjects.length === 0
109 const noResultsFromStatusFilter =
110 filterStatus.length > 0 && isSuccessProjects && orgProjects.length === 0
111
112 const noResults = noResultsFromStatusFilter || noResultsFromSearch
113 const tableHeadSortValue = toTableHeadSortValue(sort)
114
115 const githubConnections = connections?.map((connection) => ({
116 id: String(connection.id),
117 added_by: {
118 id: String(connection.user?.id),
119 primary_email: connection.user?.primary_email ?? '',
120 username: connection.user?.username ?? '',
121 },
122 foreign_project_id: String(connection.repository.id),
123 briven_project_ref: connection.project.ref,
124 organization_integration_id: 'unused',
125 inserted_at: connection.inserted_at,
126 updated_at: connection.updated_at,
127 metadata: {
128 name: connection.repository.name,
129 } as any,
130 }))
131 const vercelConnections = integrations
132 ?.filter((integration) => integration.integration.name === 'Vercel')
133 .flatMap((integration) => integration.connections)
134
135 if (isErrorPermissions) {
136 return (
137 <AlertError
138 subject="Failed to retrieve permissions for your account"
139 error={permissionsError}
140 />
141 )
142 }
143
144 if (isErrorProjects) {
145 return (
146 <AlertError
147 subject={`Failed to retrieve projects under ${organization?.name}`}
148 error={projectsError}
149 />
150 )
151 }
152
153 if (isLoadingPermissions || isLoadingProjects || !organization) {
154 return viewMode === 'table' ? <LoadingTableView /> : <LoadingCardView />
155 }
156
157 if (isEmpty) {
158 return <NoProjectsState slug={organization?.slug ?? ''} />
159 }
160
161 if (viewMode === 'table') {
162 return (
163 <Card className="flex-1 min-h-0 overflow-y-auto mb-8">
164 <Table>
165 {/* [Joshen] Ideally we can figure out sticky table headers here */}
166 <TableHeader>
167 <TableRow>
168 <TableHead
169 className={cn(noResults && 'text-foreground-muted')}
170 aria-sort={getProjectListAriaSort(sort)}
171 >
172 <TableHeadSort
173 column="name"
174 currentSort={tableHeadSortValue}
175 onSortChange={() => {
176 const sortValue = sort.includes('created')
177 ? 'name_asc'
178 : getNextProjectListSortForColumn(sort)
179 setSort(sortValue)
180 }}
181 className={cn(noResults && 'text-foreground-muted')}
182 >
183 Project
184 </TableHeadSort>
185 </TableHead>
186 <TableHead className={cn(noResults && 'text-foreground-muted')}>Status</TableHead>
187 <TableHead className={cn(noResults && 'text-foreground-muted')}>Compute</TableHead>
188 <TableHead className={cn(noResults && 'text-foreground-muted')}>Region</TableHead>
189 <TableHead
190 className={cn(noResults && 'text-foreground-muted')}
191 aria-sort={getProjectListAriaSort(sort)}
192 >
193 <TableHeadSort
194 column="created"
195 currentSort={tableHeadSortValue}
196 onSortChange={() => {
197 const sortValue = sort.includes('name')
198 ? 'created_asc'
199 : getNextProjectListSortForColumn(sort)
200 setSort(sortValue)
201 }}
202 className={cn(noResults && 'text-foreground-muted')}
203 >
204 Created
205 </TableHeadSort>
206 </TableHead>
207 <TableHead className={cn(noResults && 'text-foreground-muted')} />
208 </TableRow>
209 </TableHeader>
210 <TableBody>
211 {noResultsFromStatusFilter ? (
212 <TableRow className="[&>td]:hover:bg-inherit">
213 <TableCell colSpan={6}>
214 <NoSearchResults
215 withinTableCell
216 label={
217 filterStatus.length === 0
218 ? `No projects found`
219 : `No ${filterStatus[0] === 'INACTIVE' ? 'paused' : 'active'} projects found`
220 }
221 description="Your search for projects with the specified status did not return any results"
222 onResetFilter={() => setFilterStatus([])}
223 />
224 </TableCell>
225 </TableRow>
226 ) : noResultsFromSearch ? (
227 <TableRow className="[&>td]:hover:bg-inherit">
228 <TableCell colSpan={6}>
229 <NoSearchResults searchString={search} withinTableCell />
230 </TableCell>
231 </TableRow>
232 ) : (
233 <>
234 {orgProjects?.map((project) => (
235 <ProjectTableRow
236 key={project.ref}
237 project={project}
238 organization={organization}
239 rewriteHref={rewriteHref ? rewriteHref(project.ref) : undefined}
240 resourceWarnings={resourceWarnings?.find(
241 (resourceWarning) => resourceWarning.project === project.ref
242 )}
243 githubIntegration={githubConnections?.find(
244 (connection) => connection.briven_project_ref === project.ref
245 )}
246 vercelIntegration={vercelConnections?.find(
247 (connection) => connection.briven_project_ref === project.ref
248 )}
249 />
250 ))}
251 {hasNextPage && (
252 <LoadMoreRows
253 type="table"
254 isFetchingNextPage={isFetchingNextPage}
255 fetchNextPage={fetchNextPage}
256 />
257 )}
258 </>
259 )}
260 </TableBody>
261 </Table>
262 </Card>
263 )
264 }
265
266 return (
267 <>
268 {noResultsFromStatusFilter ? (
269 <NoSearchResults
270 label={
271 filterStatus.length === 0
272 ? `No projects found`
273 : `No ${filterStatus[0] === 'INACTIVE' ? 'paused' : 'active'} projects found`
274 }
275 description="Your search for projects with the specified status did not return any results"
276 onResetFilter={() => setFilterStatus([])}
277 />
278 ) : noResultsFromSearch ? (
279 <NoSearchResults searchString={search} />
280 ) : (
281 <div className="flex flex-col gap-y-2 md:gap-y-4 pb-6">
282 <ul
283 className={cn(
284 'min-h-0 w-full mx-auto',
285 'grid grid-cols-1 gap-2 md:gap-4',
286 'sm:grid-cols-1 md:grid-cols-1 lg:grid-cols-2 xl:grid-cols-3'
287 )}
288 >
289 {orgProjects?.map((project) => (
290 <ProjectCard
291 key={project.ref}
292 slug={slug}
293 project={project}
294 rewriteHref={rewriteHref ? rewriteHref(project.ref) : undefined}
295 resourceWarnings={resourceWarnings?.find(
296 (resourceWarning) => resourceWarning.project === project.ref
297 )}
298 githubIntegration={githubConnections?.find(
299 (connection) => connection.briven_project_ref === project.ref
300 )}
301 vercelIntegration={vercelConnections?.find(
302 (connection) => connection.briven_project_ref === project.ref
303 )}
304 />
305 ))}
306 </ul>
307 {hasNextPage && (
308 <LoadMoreRows
309 type="card"
310 isFetchingNextPage={isFetchingNextPage}
311 fetchNextPage={fetchNextPage}
312 />
313 )}
314 </div>
315 )}
316 </>
317 )
318}