From 487c74f1744afe4b245abf5a52b21078eb3d1ab7 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Wed, 25 Feb 2026 10:47:56 +1100 Subject: [PATCH] feat(studio): sortable projects (#43118) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 | | --- | --- | | Supabase-4D1BFE40-875D-494C-8F17-A68D92826458 | Supabase-D8C0AC7C-A28D-4AA6-BA7C-0FCD61DB5D11 | | Supabase-0A545C5C-40B5-47F7-9ACD-2200879BB95E | Supabase-0F7AB608-2E86-4F0C-BB60-C85D9B7F3D57 | ## 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 --- .../interfaces/Auth/Users/SortDropdown.tsx | 2 +- .../Home/ProjectList/ProjectList.tsx | 77 ++++++++++++++++--- .../Home/ProjectList/ProjectListSort.utils.ts | 24 ++++++ .../components/interfaces/HomePageActions.tsx | 48 ++++++++---- apps/studio/components/ui/SortDropdown.tsx | 61 +++++++++++++++ apps/studio/data/auth/auth-config-query.ts | 3 +- .../config/project-storage-config-query.ts | 4 +- apps/studio/pages/org/[slug]/index.tsx | 5 +- .../pages/project/_/[[...routeSlug]].tsx | 9 +-- packages/common/constants/local-storage.ts | 1 + 10 files changed, 197 insertions(+), 37 deletions(-) create mode 100644 apps/studio/components/interfaces/Home/ProjectList/ProjectListSort.utils.ts create mode 100644 apps/studio/components/ui/SortDropdown.tsx diff --git a/apps/studio/components/interfaces/Auth/Users/SortDropdown.tsx b/apps/studio/components/interfaces/Auth/Users/SortDropdown.tsx index 900e42ee314a7..173d0cdac7ea6 100644 --- a/apps/studio/components/interfaces/Auth/Users/SortDropdown.tsx +++ b/apps/studio/components/interfaces/Auth/Users/SortDropdown.tsx @@ -1,6 +1,5 @@ import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { ArrowDownNarrowWide, ArrowDownWideNarrow } from 'lucide-react' - import { Button, DropdownMenu, @@ -24,6 +23,7 @@ interface SortDropdownProps { improvedSearchEnabled: boolean } +/** [Joshen] To refactor to use the SortDropdown component in components/ui */ export const SortDropdown = ({ specificFilterColumn, sortColumn, diff --git a/apps/studio/components/interfaces/Home/ProjectList/ProjectList.tsx b/apps/studio/components/interfaces/Home/ProjectList/ProjectList.tsx index f3c0c18b0d135..96b9c286896dd 100644 --- a/apps/studio/components/interfaces/Home/ProjectList/ProjectList.tsx +++ b/apps/studio/components/interfaces/Home/ProjectList/ProjectList.tsx @@ -1,5 +1,3 @@ -import { useMemo } from 'react' - import { keepPreviousData } from '@tanstack/react-query' import { useDebounce } from '@uidotdev/usehooks' import { LOCAL_STORAGE_KEYS, useParams } from 'common' @@ -13,12 +11,30 @@ 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, useQueryState } from 'nuqs' +import { parseAsArrayOf, parseAsString, parseAsStringLiteral, useQueryState } from 'nuqs' +import { useMemo } from 'react' import type { Organization } from 'types' -import { Card, cn, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'ui' +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 { @@ -37,6 +53,10 @@ export const ProjectList = ({ organization: organization_, rewriteHref }: Projec '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 @@ -54,6 +74,7 @@ export const ProjectList = ({ organization: organization_, rewriteHref }: Projec } = useOrgProjectsInfiniteQuery( { slug, + sort, search: search.length === 0 ? search : debouncedSearch, statuses: filterStatus, }, @@ -81,7 +102,6 @@ export const ProjectList = ({ organization: organization_, rewriteHref }: Projec debouncedSearch.length === 0 && filterStatus.length === 0 && (!orgProjects || orgProjects.length === 0) - const sortedProjects = [...(orgProjects || [])].sort((a, b) => a.name.localeCompare(b.name)) const noResultsFromSearch = debouncedSearch.length > 0 && isSuccessProjects && orgProjects.length === 0 @@ -89,6 +109,7 @@ export const ProjectList = ({ organization: organization_, rewriteHref }: Projec filterStatus.length > 0 && isSuccessProjects && orgProjects.length === 0 const noResults = noResultsFromStatusFilter || noResultsFromSearch + const tableHeadSortValue = toTableHeadSortValue(sort) const githubConnections = connections?.map((connection) => ({ id: String(connection.id), @@ -143,18 +164,52 @@ export const ProjectList = ({ organization: organization_, rewriteHref }: Projec {/* [Joshen] Ideally we can figure out sticky table headers here */} - Project + + { + const sortValue = sort.includes('created') + ? 'name_asc' + : getNextProjectListSortForColumn(sort) + setSort(sortValue) + }} + className={cn(noResults && 'text-foreground-muted')} + > + Project + + Status Compute Region - Created + + { + const sortValue = sort.includes('name') + ? 'created_asc' + : getNextProjectListSortForColumn(sort) + setSort(sortValue) + }} + className={cn(noResults && 'text-foreground-muted')} + > + Created + + {noResultsFromStatusFilter ? ( - + ) : noResultsFromSearch ? ( - + ) : ( <> - {sortedProjects?.map((project) => ( + {orgProjects?.map((project) => ( - {sortedProjects?.map((project) => ( + {orgProjects?.map((project) => ( sort.replace('_', ':') + +export const getNextProjectListSortForColumn = (currentSort: ProjectListSort): ProjectListSort => { + if (currentSort.includes('asc')) return currentSort.replace('asc', 'desc') as ProjectListSort + if (currentSort.includes('desc')) return currentSort.replace('desc', 'asc') as ProjectListSort + return 'name_asc' +} + +export const getProjectListAriaSort = ( + currentSort: ProjectListSort +): 'ascending' | 'descending' | 'none' => { + if (currentSort.includes('asc')) return 'ascending' + if (currentSort.includes('desc')) return 'descending' + return 'none' +} diff --git a/apps/studio/components/interfaces/HomePageActions.tsx b/apps/studio/components/interfaces/HomePageActions.tsx index 0667f73c2cf0c..105588fd130b2 100644 --- a/apps/studio/components/interfaces/HomePageActions.tsx +++ b/apps/studio/components/interfaces/HomePageActions.tsx @@ -1,30 +1,30 @@ import { keepPreviousData } from '@tanstack/react-query' import { useDebounce } from '@uidotdev/usehooks' import { LOCAL_STORAGE_KEYS, useParams } from 'common' +import { + PROJECT_LIST_SORT_VALUES, + type ProjectListSort, +} from 'components/interfaces/Home/ProjectList/ProjectListSort.utils' import { useOrgProjectsInfiniteQuery } from 'data/projects/org-projects-infinite-query' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' import { PROJECT_STATUS } from 'lib/constants' import { Grid, List, Loader2, Plus, Search, X } from 'lucide-react' import Link from 'next/link' -import { parseAsArrayOf, parseAsString, useQueryState } from 'nuqs' +import { parseAsArrayOf, parseAsString, parseAsStringLiteral, useQueryState } from 'nuqs' import { useEffect } from 'react' import { Button, ToggleGroup, ToggleGroupItem } from 'ui' import { Input } from 'ui-patterns/DataInputs/Input' import { FilterPopover } from '../ui/FilterPopover' +import { SortDropdown } from '../ui/SortDropdown' interface HomePageActionsProps { slug?: string hideNewProject?: boolean - showViewToggle?: boolean } -export const HomePageActions = ({ - slug: _slug, - hideNewProject = false, - showViewToggle = false, -}: HomePageActionsProps) => { +export const HomePageActions = ({ slug: _slug, hideNewProject = false }: HomePageActionsProps) => { const { slug: urlSlug } = useParams() const projectCreationEnabled = useIsFeatureEnabled('projects:create') @@ -35,15 +35,22 @@ export const HomePageActions = ({ 'status', parseAsArrayOf(parseAsString, ',').withDefault([]) ) + const [sort, setSort] = useQueryState( + 'sort', + parseAsStringLiteral(PROJECT_LIST_SORT_VALUES).withDefault('name_asc') + ) const [viewMode, setViewMode] = useLocalStorageQuery(LOCAL_STORAGE_KEYS.PROJECTS_VIEW, 'grid') - const [filterStatusStorage, setFilterStatusStorage, { isSuccess }] = useLocalStorageQuery< - string[] - >(LOCAL_STORAGE_KEYS.PROJECTS_FILTER, []) + const [filterStatusStorage, setFilterStatusStorage, { isSuccess: isSuccessFilterStatusStorage }] = + useLocalStorageQuery(LOCAL_STORAGE_KEYS.PROJECTS_FILTER, []) + + const [sortStorage, setSortStorage, { isSuccess: isSuccessSortStorage }] = + useLocalStorageQuery(LOCAL_STORAGE_KEYS.PROJECTS_SORT, 'name_asc') const { isFetching: isFetchingProjects } = useOrgProjectsInfiniteQuery( { slug, + sort, search: search.length === 0 ? search : debouncedSearch, statuses: filterStatus, }, @@ -51,11 +58,15 @@ export const HomePageActions = ({ ) useEffect(() => { - if (isSuccess && !!urlSlug) setFilterStatus(filterStatusStorage) - }, [filterStatusStorage, isSuccess, urlSlug, setFilterStatus]) + if (isSuccessFilterStatusStorage && !!slug) setFilterStatus(filterStatusStorage) + }, [filterStatusStorage, isSuccessFilterStatusStorage, setFilterStatus, slug]) + + useEffect(() => { + if (isSuccessSortStorage && slug) setSort(sortStorage) + }, [sortStorage, isSuccessSortStorage, setSort, slug]) return ( -
+
setFilterStatusStorage(options)} /> + setSortStorage(val as ProjectListSort)} + /> + {isFetchingProjects && }
- {showViewToggle && viewMode && setViewMode && ( + {viewMode && setViewMode && ( void +} + +export const SortDropdown = ({ options, value, setValue }: SortDropdownProps) => { + const [sortColumn, sortOrder] = value.split('_') + const columnLabel = options.find((x) => x.value === sortColumn)?.label + + return ( + + + + + + + + {options.map((option) => { + return ( + + Sort by {option.label} + + + Ascending + + + Descending + + + + ) + })} + + + + ) +} diff --git a/apps/studio/data/auth/auth-config-query.ts b/apps/studio/data/auth/auth-config-query.ts index 514157b29d16b..78629c57d95f5 100644 --- a/apps/studio/data/auth/auth-config-query.ts +++ b/apps/studio/data/auth/auth-config-query.ts @@ -4,6 +4,7 @@ import { get, handleError } from 'data/fetchers' import { IS_PLATFORM } from 'lib/constants' import { useCallback } from 'react' import type { ResponseError, UseCustomQueryOptions } from 'types' + import { authKeys } from './keys' export type AuthConfigVariables = { @@ -40,7 +41,7 @@ export const useAuthConfigQuery = ( useQuery({ queryKey: authKeys.authConfig(projectRef), queryFn: ({ signal }) => getProjectAuthConfig({ projectRef }, signal), - enabled: enabled && IS_PLATFORM && typeof projectRef !== 'undefined', + enabled: enabled && IS_PLATFORM && typeof projectRef !== 'undefined' && projectRef !== '_', ...options, }) diff --git a/apps/studio/data/config/project-storage-config-query.ts b/apps/studio/data/config/project-storage-config-query.ts index 1b4e60bbc9a1a..49f78d1fdda0f 100644 --- a/apps/studio/data/config/project-storage-config-query.ts +++ b/apps/studio/data/config/project-storage-config-query.ts @@ -1,9 +1,9 @@ import { useQuery } from '@tanstack/react-query' - import { components } from 'data/api' import { get, handleError } from 'data/fetchers' import { IS_PLATFORM } from 'lib/constants' import type { ResponseError, UseCustomQueryOptions } from 'types' + import { configKeys } from './keys' export type ProjectStorageConfigVariables = { @@ -48,7 +48,7 @@ export const useProjectStorageConfigQuery = ( useQuery({ queryKey: configKeys.storage(projectRef), queryFn: ({ signal }) => getProjectStorageConfig({ projectRef }, signal), - enabled: enabled && IS_PLATFORM && typeof projectRef !== 'undefined', + enabled: enabled && IS_PLATFORM && typeof projectRef !== 'undefined' && projectRef !== '_', ...options, }) diff --git a/apps/studio/pages/org/[slug]/index.tsx b/apps/studio/pages/org/[slug]/index.tsx index bb42b54ccc196..b223258fe7175 100644 --- a/apps/studio/pages/org/[slug]/index.tsx +++ b/apps/studio/pages/org/[slug]/index.tsx @@ -1,5 +1,3 @@ -import Link from 'next/link' - import { useIsMFAEnabled } from 'common' import { ProjectList } from 'components/interfaces/Home/ProjectList/ProjectList' import { HomePageActions } from 'components/interfaces/HomePageActions' @@ -8,6 +6,7 @@ import OrganizationLayout from 'components/layouts/OrganizationLayout' import { PageLayout } from 'components/layouts/PageLayout/PageLayout' import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' +import Link from 'next/link' import type { NextPageWithLayout } from 'types' import { Button } from 'ui' import { Admonition } from 'ui-patterns' @@ -40,7 +39,7 @@ const ProjectsPage: NextPageWithLayout = () => { /> ) : (
- +
)} diff --git a/apps/studio/pages/project/_/[[...routeSlug]].tsx b/apps/studio/pages/project/_/[[...routeSlug]].tsx index e80b6cc3bf730..97fd1241512d4 100644 --- a/apps/studio/pages/project/_/[[...routeSlug]].tsx +++ b/apps/studio/pages/project/_/[[...routeSlug]].tsx @@ -1,8 +1,3 @@ -import { AlertTriangleIcon } from 'lucide-react' -import { NextPage } from 'next' -import { useRouter } from 'next/router' -import { useEffect, useState } from 'react' - import { IS_PLATFORM, LOCAL_STORAGE_KEYS, useParams } from 'common' import { Header, @@ -16,6 +11,10 @@ import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' import { useOrganizationsQuery } from 'data/organizations/organizations-query' import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' import { withAuth } from 'hooks/misc/withAuth' +import { AlertTriangleIcon } from 'lucide-react' +import { NextPage } from 'next' +import { useRouter } from 'next/router' +import { useEffect, useState } from 'react' import { Alert_Shadcn_, AlertDescription_Shadcn_, diff --git a/packages/common/constants/local-storage.ts b/packages/common/constants/local-storage.ts index 55d7e1ecc113a..174d43c24504b 100644 --- a/packages/common/constants/local-storage.ts +++ b/packages/common/constants/local-storage.ts @@ -8,6 +8,7 @@ export const LOCAL_STORAGE_KEYS = { EDITOR_PANEL_STATE: 'supabase-editor-panel-state', PROJECTS_VIEW: 'projects-view', PROJECTS_FILTER: 'projects-filter', + PROJECTS_SORT: 'projects-sort', FEEDBACK_WIDGET_CONTENT: 'feedback-widget-content', FEEDBACK_WIDGET_SCREENSHOT: 'feedback-widget-screenshot', INCIDENT_BANNER_DISMISSED: (id: string) => `incident-banner-dismissed-${id}`,