Files
supabase/apps/studio/components/interfaces/Home/ProjectList/ProjectList.tsx
T
Danny WhiteandJoshen Lim 487c74f174 feat(studio): sortable projects (#43118)
## What kind of change does this PR introduce?

Feature. Resolves DEPR-390

## What is the current behavior?

Projects aren’t sortable in either the card or table view.

## What is the new behavior?

Projects are sortable in both:

- Card view: sort dropdown
- Table view: sort dropdown or table column headers

| Before | After |
| --- | --- |
| <img width="1382" height="797"
alt="Supabase-4D1BFE40-875D-494C-8F17-A68D92826458"
src="https://github.com/user-attachments/assets/c4f17b77-bc90-447f-90cd-78a11c2e4129"
/> | <img width="1382" height="797"
alt="Supabase-D8C0AC7C-A28D-4AA6-BA7C-0FCD61DB5D11"
src="https://github.com/user-attachments/assets/d926d03d-2702-48e5-9d1f-0e09d163079d"
/> |
| <img width="1382" height="797"
alt="Supabase-0A545C5C-40B5-47F7-9ACD-2200879BB95E"
src="https://github.com/user-attachments/assets/f2103c32-a150-4db7-a78a-8bd610e2a028"
/> | <img width="1382" height="797"
alt="Supabase-0F7AB608-2E86-4F0C-BB60-C85D9B7F3D57"
src="https://github.com/user-attachments/assets/baa63f14-4059-483d-a9d6-33663e5cff43"
/> |

## Additional context

I wonder if this is overkill given most folks only have 1–2 projects.
Some ideas:

- Only sortable in table view via column headers
- Conditional rendering for folks with 2+ projects
- Opt-in feature
- Feature-flag

I’ve opted to make it global and synced for now.

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
2026-02-25 10:47:56 +11:00

318 lines
11 KiB
TypeScript

import { keepPreviousData } from '@tanstack/react-query'
import { useDebounce } from '@uidotdev/usehooks'
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
import AlertError from 'components/ui/AlertError'
import { NoSearchResults } from 'components/ui/NoSearchResults'
import { useGitHubConnectionsQuery } from 'data/integrations/github-connections-query'
import { useOrgIntegrationsQuery } from 'data/integrations/integrations-query-org-only'
import { usePermissionsQuery } from 'data/permissions/permissions-query'
import { useOrgProjectsInfiniteQuery } from 'data/projects/org-projects-infinite-query'
import { useResourceWarningsQuery } from 'data/usage/resource-warnings-query'
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { IS_PLATFORM } from 'lib/constants'
import { parseAsArrayOf, parseAsString, parseAsStringLiteral, useQueryState } from 'nuqs'
import { useMemo } from 'react'
import type { Organization } from 'types'
import {
Card,
cn,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableHeadSort,
TableRow,
} from 'ui'
import { LoadingCardView, LoadingTableView, NoProjectsState } from './EmptyStates'
import { LoadMoreRows } from './LoadMoreRow'
import { ProjectCard } from './ProjectCard'
import {
getNextProjectListSortForColumn,
getProjectListAriaSort,
PROJECT_LIST_SORT_VALUES,
toTableHeadSortValue,
} from './ProjectListSort.utils'
import { ProjectTableRow } from './ProjectTableRow'
export interface ProjectListProps {
organization?: Organization
rewriteHref?: (projectRef: string) => string
}
export const ProjectList = ({ organization: organization_, rewriteHref }: ProjectListProps) => {
const { slug: urlSlug } = useParams()
const { data: selectedOrganization } = useSelectedOrganizationQuery()
const [search] = useQueryState('search', parseAsString.withDefault(''))
const debouncedSearch = useDebounce(search, 500)
const [filterStatus, setFilterStatus] = useQueryState(
'status',
parseAsArrayOf(parseAsString, ',').withDefault([])
)
const [sort, setSort] = useQueryState(
'sort',
parseAsStringLiteral(PROJECT_LIST_SORT_VALUES).withDefault('name_asc')
)
const [viewMode] = useLocalStorageQuery(LOCAL_STORAGE_KEYS.PROJECTS_VIEW, 'grid')
const organization = organization_ ?? selectedOrganization
const slug = organization?.slug ?? urlSlug
const {
data,
error: projectsError,
isLoading: isLoadingProjects,
isSuccess: isSuccessProjects,
isError: isErrorProjects,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
} = useOrgProjectsInfiniteQuery(
{
slug,
sort,
search: search.length === 0 ? search : debouncedSearch,
statuses: filterStatus,
},
{
placeholderData: keepPreviousData,
}
)
const orgProjects =
useMemo(() => data?.pages.flatMap((page) => page.projects), [data?.pages]) || []
const {
isPending: _isLoadingPermissions,
isError: isErrorPermissions,
error: permissionsError,
} = usePermissionsQuery()
const { data: resourceWarnings } = useResourceWarningsQuery({ slug })
// Move all hooks to the top to comply with Rules of Hooks
const { data: integrations } = useOrgIntegrationsQuery({ orgSlug: organization?.slug })
const { data: connections } = useGitHubConnectionsQuery({ organizationId: organization?.id })
const isLoadingPermissions = IS_PLATFORM ? _isLoadingPermissions : false
const isEmpty =
debouncedSearch.length === 0 &&
filterStatus.length === 0 &&
(!orgProjects || orgProjects.length === 0)
const noResultsFromSearch =
debouncedSearch.length > 0 && isSuccessProjects && orgProjects.length === 0
const noResultsFromStatusFilter =
filterStatus.length > 0 && isSuccessProjects && orgProjects.length === 0
const noResults = noResultsFromStatusFilter || noResultsFromSearch
const tableHeadSortValue = toTableHeadSortValue(sort)
const githubConnections = connections?.map((connection) => ({
id: String(connection.id),
added_by: {
id: String(connection.user?.id),
primary_email: connection.user?.primary_email ?? '',
username: connection.user?.username ?? '',
},
foreign_project_id: String(connection.repository.id),
supabase_project_ref: connection.project.ref,
organization_integration_id: 'unused',
inserted_at: connection.inserted_at,
updated_at: connection.updated_at,
metadata: {
name: connection.repository.name,
} as any,
}))
const vercelConnections = integrations
?.filter((integration) => integration.integration.name === 'Vercel')
.flatMap((integration) => integration.connections)
if (isErrorPermissions) {
return (
<AlertError
subject="Failed to retrieve permissions for your account"
error={permissionsError}
/>
)
}
if (isErrorProjects) {
return (
<AlertError
subject={`Failed to retrieve projects under ${organization?.name}`}
error={projectsError}
/>
)
}
if (isLoadingPermissions || isLoadingProjects || !organization) {
return viewMode === 'table' ? <LoadingTableView /> : <LoadingCardView />
}
if (isEmpty) {
return <NoProjectsState slug={organization?.slug ?? ''} />
}
if (viewMode === 'table') {
return (
<Card className="flex-1 min-h-0 overflow-y-auto mb-8">
<Table>
{/* [Joshen] Ideally we can figure out sticky table headers here */}
<TableHeader>
<TableRow>
<TableHead
className={cn(noResults && 'text-foreground-muted')}
aria-sort={getProjectListAriaSort(sort)}
>
<TableHeadSort
column="name"
currentSort={tableHeadSortValue}
onSortChange={() => {
const sortValue = sort.includes('created')
? 'name_asc'
: getNextProjectListSortForColumn(sort)
setSort(sortValue)
}}
className={cn(noResults && 'text-foreground-muted')}
>
Project
</TableHeadSort>
</TableHead>
<TableHead className={cn(noResults && 'text-foreground-muted')}>Status</TableHead>
<TableHead className={cn(noResults && 'text-foreground-muted')}>Compute</TableHead>
<TableHead className={cn(noResults && 'text-foreground-muted')}>Region</TableHead>
<TableHead
className={cn(noResults && 'text-foreground-muted')}
aria-sort={getProjectListAriaSort(sort)}
>
<TableHeadSort
column="created"
currentSort={tableHeadSortValue}
onSortChange={() => {
const sortValue = sort.includes('name')
? 'created_asc'
: getNextProjectListSortForColumn(sort)
setSort(sortValue)
}}
className={cn(noResults && 'text-foreground-muted')}
>
Created
</TableHeadSort>
</TableHead>
<TableHead className={cn(noResults && 'text-foreground-muted')} />
</TableRow>
</TableHeader>
<TableBody>
{noResultsFromStatusFilter ? (
<TableRow className="[&>td]:hover:bg-inherit">
<TableCell colSpan={6}>
<NoSearchResults
withinTableCell
label={
filterStatus.length === 0
? `No projects found`
: `No ${filterStatus[0] === 'INACTIVE' ? 'paused' : 'active'} projects found`
}
description="Your search for projects with the specified status did not return any results"
onResetFilter={() => setFilterStatus([])}
/>
</TableCell>
</TableRow>
) : noResultsFromSearch ? (
<TableRow className="[&>td]:hover:bg-inherit">
<TableCell colSpan={6}>
<NoSearchResults searchString={search} withinTableCell />
</TableCell>
</TableRow>
) : (
<>
{orgProjects?.map((project) => (
<ProjectTableRow
key={project.ref}
project={project}
organization={organization}
rewriteHref={rewriteHref ? rewriteHref(project.ref) : undefined}
resourceWarnings={resourceWarnings?.find(
(resourceWarning) => resourceWarning.project === project.ref
)}
githubIntegration={githubConnections?.find(
(connection) => connection.supabase_project_ref === project.ref
)}
vercelIntegration={vercelConnections?.find(
(connection) => connection.supabase_project_ref === project.ref
)}
/>
))}
{hasNextPage && (
<LoadMoreRows
type="table"
isFetchingNextPage={isFetchingNextPage}
fetchNextPage={fetchNextPage}
/>
)}
</>
)}
</TableBody>
</Table>
</Card>
)
}
return (
<>
{noResultsFromStatusFilter ? (
<NoSearchResults
label={
filterStatus.length === 0
? `No projects found`
: `No ${filterStatus[0] === 'INACTIVE' ? 'paused' : 'active'} projects found`
}
description="Your search for projects with the specified status did not return any results"
onResetFilter={() => setFilterStatus([])}
/>
) : noResultsFromSearch ? (
<NoSearchResults searchString={search} />
) : (
<div className="flex flex-col gap-y-2 md:gap-y-4 pb-6">
<ul
className={cn(
'min-h-0 w-full mx-auto',
'grid grid-cols-1 gap-2 md:gap-4',
'sm:grid-cols-1 md:grid-cols-1 lg:grid-cols-2 xl:grid-cols-3'
)}
>
{orgProjects?.map((project) => (
<ProjectCard
key={project.ref}
slug={slug}
project={project}
rewriteHref={rewriteHref ? rewriteHref(project.ref) : undefined}
resourceWarnings={resourceWarnings?.find(
(resourceWarning) => resourceWarning.project === project.ref
)}
githubIntegration={githubConnections?.find(
(connection) => connection.supabase_project_ref === project.ref
)}
vercelIntegration={vercelConnections?.find(
(connection) => connection.supabase_project_ref === project.ref
)}
/>
))}
</ul>
{hasNextPage && (
<LoadMoreRows
type="card"
isFetchingNextPage={isFetchingNextPage}
fetchNextPage={fetchNextPage}
/>
)}
</div>
)}
</>
)
}