From 73ece8a470241c0b5a1d7e14b3003d2aa2134e28 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Fri, 12 Dec 2025 19:35:43 +1000 Subject: [PATCH] chore(studio): improve projects presentation (#41179) * improve admonition * remove redundant loader * copywriting * table empty states * improve integrations presentation * match integration styling * fix clipping and h-full container * revert test * improve status presentation * nicer status * card status improvements * comment clarification * prettier lint * fix testes * comment * Fix infinite loading behaviour * Clean u0p * Replace NoFilterResults with NoSearchResults --------- Co-authored-by: Joshen Lim --- .../content/docs/ui-patterns/empty-states.mdx | 5 + .../Home/ProjectList/EmptyStates.tsx | 77 +++------ .../Home/ProjectList/LoadMoreRow.tsx | 60 +++++++ .../Home/ProjectList/ProjectCard.tsx | 20 ++- .../Home/ProjectList/ProjectCardStatus.tsx | 102 ++++++------ .../Home/ProjectList/ProjectList.tsx | 153 +++++++++--------- .../Home/ProjectList/ProjectTableRow.tsx | 24 +-- .../components/interfaces/HomePageActions.tsx | 2 +- .../components/ui/ComputeBadgeWrapper.tsx | 2 +- apps/studio/components/ui/NoSearchResults.tsx | 19 ++- apps/studio/pages/org/[slug]/index.tsx | 31 ++-- .../ui/src/components/shadcn/ui/badge.tsx | 18 ++- .../ui/src/components/shadcn/ui/table.tsx | 2 + 13 files changed, 286 insertions(+), 229 deletions(-) create mode 100644 apps/studio/components/interfaces/Home/ProjectList/LoadMoreRow.tsx diff --git a/apps/design-system/content/docs/ui-patterns/empty-states.mdx b/apps/design-system/content/docs/ui-patterns/empty-states.mdx index 9199c4e37c8..851fda3068e 100644 --- a/apps/design-system/content/docs/ui-patterns/empty-states.mdx +++ b/apps/design-system/content/docs/ui-patterns/empty-states.mdx @@ -42,6 +42,11 @@ A [Table](../components/table) instance with zero results should display a singl +Studio contains two pre-built components to handle these cases consistently: + +- No Filter Results +- No Search Results + #### Data Grid [Data Grid](../ui-patterns/tables#data-grid) and [Data Table](../ui-patterns/tables#data-table) component patterns typically span the full height and width of a container. A classic example is [Users](https://supabase.com/dashboard/project/_/auth/users), which (as it sounds) displays a list of the project’s registered users. Any instance with zero results should display a more prominent empty with a clear title, description, and supporting illustration. diff --git a/apps/studio/components/interfaces/Home/ProjectList/EmptyStates.tsx b/apps/studio/components/interfaces/Home/ProjectList/EmptyStates.tsx index e7db6d16c1a..60f4c422d2b 100644 --- a/apps/studio/components/interfaces/Home/ProjectList/EmptyStates.tsx +++ b/apps/studio/components/interfaces/Home/ProjectList/EmptyStates.tsx @@ -7,7 +7,6 @@ import { BASE_PATH } from 'lib/constants' import { Button, Card, - cn, Skeleton, Table, TableBody, @@ -36,62 +35,6 @@ export const Header = () => { ) } -export const NoFilterResults = ({ - filterStatus, - resetFilterStatus, - className, -}: { - filterStatus: string[] - resetFilterStatus?: () => void - className?: string -}) => { - return ( -
-
- {/* [Joshen] Just keeping it simple for now unless we decide to extend this to other statuses */} -

- {filterStatus.length === 0 - ? `No projects found` - : `No ${filterStatus[0] === 'INACTIVE' ? 'paused' : 'active'} projects found`} -

-

- Your search for projects with the specified status did not return any results -

-
- {resetFilterStatus !== undefined && ( - - )} -
- ) -} - -export const LoadingTableRow = () => ( - - - - - - - - - - - - - - - - - -) - export const LoadingTableView = () => { return ( @@ -107,7 +50,23 @@ export const LoadingTableView = () => { {[...Array(3)].map((_, i) => ( - + + + + + + + + + + + + + + + + + ))} @@ -142,7 +101,7 @@ export const NoProjectsState = ({ slug }: { slug: string }) => { ) } -export const NoOrganizationsState = ({}) => { +export const NoOrganizationsState = () => { return ( void +} + +export const LoadMoreRows = ({ type, isFetchingNextPage, fetchNextPage }: LoadMoreRowProps) => { + const [sentinelRef, entry] = useIntersectionObserver({ + threshold: 0, + rootMargin: '200px 0px 200px 0px', + }) + + useEffect(() => { + if (entry?.isIntersecting && !isFetchingNextPage) { + fetchNextPage?.() + } + }, [entry?.isIntersecting, isFetchingNextPage, fetchNextPage]) + + if (type === 'card') { + return ( +
    + {[...Array(2)].map((_, i) => ( + + ))} +
+ ) + } + + return ( + + + + + + + + + + + + + + + + + + ) +} diff --git a/apps/studio/components/interfaces/Home/ProjectList/ProjectCard.tsx b/apps/studio/components/interfaces/Home/ProjectList/ProjectCard.tsx index 8d82d4e6641..ed36761b42f 100644 --- a/apps/studio/components/interfaces/Home/ProjectList/ProjectCard.tsx +++ b/apps/studio/components/interfaces/Home/ProjectList/ProjectCard.tsx @@ -53,9 +53,13 @@ export const ProjectCard = ({ linkHref={rewriteHref ? rewriteHref : `/project/${projectRef}`} className="h-44 !px-0 group pt-5 pb-0" title={ -
-

{name}

- {desc} +
+ {/* Text */} +
+
{name}
+

{desc}

+
+ {/* Compute and integrations */}
{project.status !== 'INACTIVE' && projectHomepageShowInstanceSize && ( )} {isVercelIntegrated && ( -
+
)} {isGithubIntegrated && ( - <> -
+
+
-

{githubRepository}

- +

{githubRepository}

+
)}
diff --git a/apps/studio/components/interfaces/Home/ProjectList/ProjectCardStatus.tsx b/apps/studio/components/interfaces/Home/ProjectList/ProjectCardStatus.tsx index 607d463836c..ad6d47508bb 100644 --- a/apps/studio/components/interfaces/Home/ProjectList/ProjectCardStatus.tsx +++ b/apps/studio/components/interfaces/Home/ProjectList/ProjectCardStatus.tsx @@ -3,21 +3,13 @@ import { AlertTriangle, Info, PauseCircle, RefreshCcw } from 'lucide-react' import { RESOURCE_WARNING_MESSAGES } from 'components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner.constants' import { getWarningContent } from 'components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner.utils' import type { ResourceWarning } from 'data/usage/resource-warnings-query' -import { - Alert_Shadcn_, - AlertTitle_Shadcn_, - Badge, - cn, - Tooltip, - TooltipContent, - TooltipTrigger, -} from 'ui' +import { Badge, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui' import { InferredProjectStatus } from './ProjectCard.utils' export interface ProjectCardWarningsProps { resourceWarnings?: ResourceWarning projectStatus: InferredProjectStatus - renderMode?: 'alert' | 'badge' // New prop to control rendering mode + renderMode?: 'alert' | 'badge' } export const ProjectCardStatus = ({ @@ -130,12 +122,20 @@ export const ProjectCardStatus = ({ projectStatus === 'isHealthy' ) { if (renderMode === 'badge') { - return Active + return ( + // Badge must be wrapped in a div in order to be centered in table cell +
+ Active +
+ ) } return null } if (renderMode === 'badge') { + // Render a fallback en dash if no title is available + if (!alertTitle) return + const badgeVariant = isCritical ? 'destructive' : activeWarnings.length > 0 || @@ -146,50 +146,60 @@ export const ProjectCardStatus = ({ ? 'success' : 'default' - return alertDescription ? ( - - - - {alertTitle} - - - {alertDescription} - - ) : ( - - {alertTitle} - + return ( + // Badge must be wrapped in a div in order to be centered in table cell +
+ {alertDescription ? ( + + + {alertTitle} + + {alertDescription} + + ) : ( + {alertTitle} + )} +
) } + // Only render if an alert title is available + if (!alertTitle) return null + return ( - svg]:left-[1.25rem] [&>svg]:top-3.5 [&>svg]:border', - !isCritical ? '[&>svg]:text-foreground [&>svg]:bg-surface-100' : '' - )} - > - {['isPaused', 'isPausing'].includes(projectStatus ?? '') ? ( - - ) : ['isRestoring', 'isComingUp', 'isRestarting', 'isResizing'].includes( - projectStatus ?? '' - ) ? ( - - ) : ( - - )} -
- {alertTitle} +
+ {/* Icon */} +
svg]:text-destructive-600', + alertType === 'warning' && 'border-warning-400 [&>svg]:text-warning-600', + alertType === 'default' && 'border-strong [&>svg]:text-foreground' + )} + > + {['isPaused', 'isPausing'].includes(projectStatus ?? '') ? ( + + ) : ['isRestoring', 'isComingUp', 'isRestarting', 'isResizing'].includes( + projectStatus ?? '' + ) ? ( + + ) : ( + + )} +
+ {/* Text and tooltip icon */} +
+

{alertTitle}

- + {alertDescription}
- +
) } diff --git a/apps/studio/components/interfaces/Home/ProjectList/ProjectList.tsx b/apps/studio/components/interfaces/Home/ProjectList/ProjectList.tsx index d53ea042153..6e25f8fa504 100644 --- a/apps/studio/components/interfaces/Home/ProjectList/ProjectList.tsx +++ b/apps/studio/components/interfaces/Home/ProjectList/ProjectList.tsx @@ -1,4 +1,4 @@ -import { UIEvent, useMemo } from 'react' +import { useMemo } from 'react' import { keepPreviousData } from '@tanstack/react-query' import { useDebounce } from '@uidotdev/usehooks' @@ -13,30 +13,13 @@ 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 { isAtBottom } from 'lib/helpers' import { parseAsArrayOf, parseAsString, useQueryState } from 'nuqs' import type { Organization } from 'types' -import { - Card, - cn, - LoadingLine, - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from 'ui' -import { - LoadingCardView, - LoadingTableRow, - LoadingTableView, - NoFilterResults, - NoProjectsState, -} from './EmptyStates' +import { Card, cn, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'ui' +import { LoadingCardView, LoadingTableView, NoProjectsState } from './EmptyStates' +import { LoadMoreRows } from './LoadMoreRow' import { ProjectCard } from './ProjectCard' import { ProjectTableRow } from './ProjectTableRow' -import { ShimmeringCard } from './ShimmeringCard' export interface ProjectListProps { organization?: Organization @@ -65,7 +48,6 @@ export const ProjectList = ({ organization: organization_, rewriteHref }: Projec isLoading: isLoadingProjects, isSuccess: isSuccessProjects, isError: isErrorProjects, - isFetching, isFetchingNextPage, hasNextPage, fetchNextPage, @@ -106,6 +88,8 @@ export const ProjectList = ({ organization: organization_, rewriteHref }: Projec const noResultsFromStatusFilter = filterStatus.length > 0 && isSuccessProjects && orgProjects.length === 0 + const noResults = noResultsFromStatusFilter || noResultsFromSearch + const githubConnections = connections?.map((connection) => ({ id: String(connection.id), added_by: { @@ -126,11 +110,6 @@ export const ProjectList = ({ organization: organization_, rewriteHref }: Projec ?.filter((integration) => integration.integration.name === 'Vercel') .flatMap((integration) => integration.connections) - const handleScroll = (event: UIEvent) => { - if (isLoadingProjects || isFetchingNextPage || !isAtBottom(event)) return - fetchNextPage() - } - if (isErrorPermissions) { return ( + {/* [Joshen] Ideally we can figure out sticky table headers here */} - + - Project - Status - Compute - Region - Created - - - - - + Project + Status + Compute + Region + Created {noResultsFromStatusFilter ? ( - - - setFilterStatus([])} - className="border-0" + + + setFilterStatus([])} /> ) : noResultsFromSearch ? ( - - - + + + ) : ( @@ -212,7 +191,13 @@ export const ProjectList = ({ organization: organization_, rewriteHref }: Projec )} /> ))} - {hasNextPage && } + {hasNextPage && ( + + )} )} @@ -224,40 +209,52 @@ export const ProjectList = ({ organization: organization_, rewriteHref }: Projec return ( <> {noResultsFromStatusFilter ? ( - setFilterStatus([])} + setFilterStatus([])} /> ) : noResultsFromSearch ? ( ) : ( -
    - {sortedProjects?.map((project) => ( - resourceWarning.project === project.ref - )} - githubIntegration={githubConnections?.find( - (connection) => connection.supabase_project_ref === project.ref - )} - vercelIntegration={vercelConnections?.find( - (connection) => connection.supabase_project_ref === project.ref - )} +
    +
      + {sortedProjects?.map((project) => ( + 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 && ( + - ))} - {hasNextPage && [...Array(2)].map((_, i) => )} -
+ )} + )} ) diff --git a/apps/studio/components/interfaces/Home/ProjectList/ProjectTableRow.tsx b/apps/studio/components/interfaces/Home/ProjectList/ProjectTableRow.tsx index 3ed1f237cef..b3c2e4723d9 100644 --- a/apps/studio/components/interfaces/Home/ProjectList/ProjectTableRow.tsx +++ b/apps/studio/components/interfaces/Home/ProjectList/ProjectTableRow.tsx @@ -53,15 +53,17 @@ export const ProjectTableRow = ({ }} > -
+
+ {/* Text */}
-

{name}

-

ID: {projectRef}

+
{name}
+

ID: {projectRef}

+ {/* Integrations */} {(isGithubIntegrated || isVercelIntegrated) && ( -
+
{isVercelIntegrated && ( -
+
)} {isGithubIntegrated && ( - <> -
+
+
{githubRepository && ( - - {githubRepository} - +

{githubRepository}

)} - +
)}
)} @@ -102,7 +102,7 @@ export const ProjectTableRow = ({ computeSize={getComputeSize(project)} /> ) : ( - - + )}
diff --git a/apps/studio/components/interfaces/HomePageActions.tsx b/apps/studio/components/interfaces/HomePageActions.tsx index b1cfed20599..1617e16d140 100644 --- a/apps/studio/components/interfaces/HomePageActions.tsx +++ b/apps/studio/components/interfaces/HomePageActions.tsx @@ -1,3 +1,4 @@ +import { keepPreviousData } from '@tanstack/react-query' import { useDebounce } from '@uidotdev/usehooks' import { Filter, Grid, List, Loader2, Plus, Search, X } from 'lucide-react' import Link from 'next/link' @@ -19,7 +20,6 @@ import { ToggleGroupItem, } from 'ui' import { Input } from 'ui-patterns/DataInputs/Input' -import { keepPreviousData } from '@tanstack/react-query' interface HomePageActionsProps { slug?: string diff --git a/apps/studio/components/ui/ComputeBadgeWrapper.tsx b/apps/studio/components/ui/ComputeBadgeWrapper.tsx index 2a5a9e2901b..80ed25cc3de 100644 --- a/apps/studio/components/ui/ComputeBadgeWrapper.tsx +++ b/apps/studio/components/ui/ComputeBadgeWrapper.tsx @@ -79,7 +79,7 @@ export const ComputeBadgeWrapper = ({ return ( setOpenState(!open)} openDelay={280}> e.stopPropagation()}> -
+
diff --git a/apps/studio/components/ui/NoSearchResults.tsx b/apps/studio/components/ui/NoSearchResults.tsx index ce44e4b1ae2..65b80adaf2c 100644 --- a/apps/studio/components/ui/NoSearchResults.tsx +++ b/apps/studio/components/ui/NoSearchResults.tsx @@ -1,27 +1,34 @@ import { Button, cn } from 'ui' export interface NoSearchResultsProps { - searchString: string + searchString?: string + withinTableCell?: boolean onResetFilter?: () => void className?: string + label?: string + description?: string } export const NoSearchResults = ({ searchString, + withinTableCell = false, onResetFilter, className, + label, + description, }: NoSearchResultsProps) => { return (
-
-

No results found

-

- Your search for "{searchString}" did not return any results +

+

{label ?? 'No results found'}

+

+ {description ?? `Your search for “${searchString}” did not return any results`}

{onResetFilter !== undefined && ( diff --git a/apps/studio/pages/org/[slug]/index.tsx b/apps/studio/pages/org/[slug]/index.tsx index 71d5cc8872a..bb42b54ccc1 100644 --- a/apps/studio/pages/org/[slug]/index.tsx +++ b/apps/studio/pages/org/[slug]/index.tsx @@ -1,3 +1,5 @@ +import Link from 'next/link' + import { useIsMFAEnabled } from 'common' import { ProjectList } from 'components/interfaces/Home/ProjectList/ProjectList' import { HomePageActions } from 'components/interfaces/HomePageActions' @@ -5,9 +7,9 @@ import DefaultLayout from 'components/layouts/DefaultLayout' import OrganizationLayout from 'components/layouts/OrganizationLayout' import { PageLayout } from 'components/layouts/PageLayout/PageLayout' import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' -import { InlineLink } from 'components/ui/InlineLink' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import type { NextPageWithLayout } from 'types' +import { Button } from 'ui' import { Admonition } from 'ui-patterns' const ProjectsPage: NextPageWithLayout = () => { @@ -20,17 +22,24 @@ const ProjectsPage: NextPageWithLayout = () => { {disableAccessMfa ? ( - -

- Set up MFA on your account through your{' '} - account preferences to access this - organization -

-
+ + Set up multi-factor authentication (MFA) on your account to access this + organization’s projects. + + } + actions={ + + } + /> ) : ( - // [Joshen] Very odd, but the h-px here is required for ProjectList to have a max - // height based on the remaining space that it can grow to -
+
diff --git a/packages/ui/src/components/shadcn/ui/badge.tsx b/packages/ui/src/components/shadcn/ui/badge.tsx index b304a7b9e10..413ac13dbce 100644 --- a/packages/ui/src/components/shadcn/ui/badge.tsx +++ b/packages/ui/src/components/shadcn/ui/badge.tsx @@ -28,12 +28,16 @@ export interface BadgeProps extends React.HTMLAttributes, VariantProps {} -function Badge({ className, variant = 'default', children, ...props }: BadgeProps) { - return ( -
- {children} -
- ) -} +// Forward refs in order to allow tooltips to be applied to the badge +const Badge = React.forwardRef( + ({ className, variant = 'default', children, ...props }, ref) => { + return ( +
+ {children} +
+ ) + } +) +Badge.displayName = 'Badge' export { Badge } diff --git a/packages/ui/src/components/shadcn/ui/table.tsx b/packages/ui/src/components/shadcn/ui/table.tsx index 3b4458a8f28..5be4a2dcdcb 100644 --- a/packages/ui/src/components/shadcn/ui/table.tsx +++ b/packages/ui/src/components/shadcn/ui/table.tsx @@ -70,6 +70,8 @@ const TableHead = React.forwardRef< ref={ref} className={cn( 'h-10 px-4 text-left align-middle heading-meta whitespace-nowrap text-foreground-lighter [&:has([role=checkbox])]:pr-0', + // Transition text color when NoSearchResults or NoFilterResults empty state is shown + 'transition-colors', className )} {...props}