ProjectList.tsx 11 KB

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