ProjectList.tsx318 lines · main
| 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 | |
| 19 | import { LoadingCardView, LoadingTableView, NoProjectsState } from './EmptyStates' |
| 20 | import { LoadMoreRows } from './LoadMoreRow' |
| 21 | import { ProjectCard } from './ProjectCard' |
| 22 | import { |
| 23 | getNextProjectListSortForColumn, |
| 24 | getProjectListAriaSort, |
| 25 | PROJECT_LIST_SORT_VALUES, |
| 26 | toTableHeadSortValue, |
| 27 | } from './ProjectListSort.utils' |
| 28 | import { ProjectTableRow } from './ProjectTableRow' |
| 29 | import AlertError from '@/components/ui/AlertError' |
| 30 | import { NoSearchResults } from '@/components/ui/NoSearchResults' |
| 31 | import { useGitHubConnectionsQuery } from '@/data/integrations/github-connections-query' |
| 32 | import { useOrgIntegrationsQuery } from '@/data/integrations/integrations-query-org-only' |
| 33 | import { usePermissionsQuery } from '@/data/permissions/permissions-query' |
| 34 | import { useOrgProjectsInfiniteQuery } from '@/data/projects/org-projects-infinite-query' |
| 35 | import { useResourceWarningsQuery } from '@/data/usage/resource-warnings-query' |
| 36 | import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' |
| 37 | import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' |
| 38 | import { IS_PLATFORM } from '@/lib/constants' |
| 39 | import type { Organization } from '@/types' |
| 40 | |
| 41 | export interface ProjectListProps { |
| 42 | organization?: Organization |
| 43 | rewriteHref?: (projectRef: string) => string |
| 44 | } |
| 45 | |
| 46 | export 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 | } |