Support for projects pagination part 1 (#38298)

* init

* Infinite scrolling pagination for projects in table view

* Infinite scrolling pagination for projects in cards view

* Shift searching projects to URL state + to server side

* Shift status filter to URL state

* opt to use local storage query for view moe

* update wildcard project route

* add created at value, and shift filter status to server side

* Nit

* final nit

* Address comments

* Fix

* Prettier

* Raise page limit to 96
This commit is contained in:
Joshen Lim authored and GitHub committed 2025-09-11 14:02:58 +08:00
1 parent b78f56d5c1
commit dabfaa8972
16 files changed
+397 -275

No files matched your search

@@ -52,6 +52,26 @@ export const NoFilterResults = ({
)
}
export const LoadingTableRow = () => (
<TableRow>
<TableCell>
<Skeleton className="bg-surface-400 h-4 w-32"></Skeleton>
</TableCell>
<TableCell>
<Skeleton className="bg-surface-400 h-4 w-16"></Skeleton>
</TableCell>
<TableCell>
<Skeleton className="bg-surface-400 h-4 w-20"></Skeleton>
</TableCell>
<TableCell>
<Skeleton className="bg-surface-400 h-4 w-20"></Skeleton>
</TableCell>
<TableCell>
<Skeleton className="bg-surface-400 h-4 w-24"></Skeleton>
</TableCell>
</TableRow>
)
export const LoadingTableView = () => {
return (
<Card>
@@ -67,23 +87,7 @@ export const LoadingTableView = () => {
</TableHeader>
<TableBody>
{[...Array(3)].map((_, i) => (
<TableRow key={i}>
<TableCell>
<Skeleton className="bg-surface-400 h-4 w-32"></Skeleton>
</TableCell>
<TableCell>
<Skeleton className="bg-surface-400 h-4 w-16"></Skeleton>
</TableCell>
<TableCell>
<Skeleton className="bg-surface-400 h-4 w-20"></Skeleton>
</TableCell>
<TableCell>
<Skeleton className="bg-surface-400 h-4 w-20"></Skeleton>
</TableCell>
<TableCell>
<Skeleton className="bg-surface-400 h-4 w-24"></Skeleton>
</TableCell>
</TableRow>
<LoadingTableRow key={i} />
))}
</TableBody>
</Table>
@@ -1,19 +1,19 @@
import { Github } from 'lucide-react'
import InlineSVG from 'react-inlinesvg'
import CardButton from 'components/ui/CardButton'
import { ComputeBadgeWrapper } from 'components/ui/ComputeBadgeWrapper'
import type { IntegrationProjectConnection } from 'data/integrations/integrations.types'
import { ProjectIndexPageLink } from 'data/prefetchers/project.$ref'
import type { ProjectInfo } from 'data/projects/projects-query'
import { OrgProject } from 'data/projects/projects-infinite-query'
import type { ResourceWarning } from 'data/usage/resource-warnings-query'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { BASE_PATH } from 'lib/constants'
import InlineSVG from 'react-inlinesvg'
import { inferProjectStatus } from './ProjectCard.utils'
import { ProjectCardStatus } from './ProjectCardStatus'
export interface ProjectCardProps {
project: ProjectInfo
project: OrgProject
rewriteHref?: string
githubIntegration?: IntegrationProjectConnection
vercelIntegration?: IntegrationProjectConnection
@@ -28,20 +28,20 @@ export const ProjectCard = ({
resourceWarnings,
}: ProjectCardProps) => {
const { name, ref: projectRef } = project
const desc = `${project.cloud_provider} | ${project.region}`
const infraInformation = project.databases.find((x) => x.identifier === project.ref)
const desc = `${infraInformation?.cloud_provider} | ${project.region}`
const { projectHomepageShowInstanceSize } = useIsFeatureEnabled([
'project_homepage:show_instance_size',
])
const isBranchingEnabled = project.preview_branch_refs?.length > 0
const isGithubIntegrated = githubIntegration !== undefined
const isVercelIntegrated = vercelIntegration !== undefined
const githubRepository = githubIntegration?.metadata.name ?? undefined
const projectStatus = inferProjectStatus(project)
const projectStatus = inferProjectStatus(project.status)
return (
<li className="list-none">
<li className="list-none h-min">
<CardButton
linkHref={rewriteHref ? rewriteHref : `/project/${projectRef}`}
className="h-44 !px-0 group pt-5 pb-0"
@@ -1,9 +1,8 @@
import type { ProjectInfo } from 'data/projects/projects-query'
import { PROJECT_STATUS } from 'lib/constants'
export const inferProjectStatus = (project: ProjectInfo) => {
export const inferProjectStatus = (projectStatus: string) => {
let status = undefined
switch (project.status) {
switch (projectStatus) {
case PROJECT_STATUS.ACTIVE_HEALTHY:
status = 'isHealthy'
break
@@ -1,47 +1,85 @@
import { UIEvent, useMemo } from 'react'
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 { useProjectsQuery } from 'data/projects/projects-query'
import { useOrgProjectsInfiniteQuery } from 'data/projects/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 { makeRandomString } from 'lib/helpers'
import { isAtBottom } from 'lib/helpers'
import { parseAsArrayOf, parseAsString, useQueryState } from 'nuqs'
import type { Organization } from 'types'
import { Card, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'ui'
import { LoadingCardView, LoadingTableView, NoFilterResults, NoProjectsState } from './EmptyStates'
import {
Card,
cn,
LoadingLine,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from 'ui'
import {
LoadingCardView,
LoadingTableRow,
LoadingTableView,
NoFilterResults,
NoProjectsState,
} from './EmptyStates'
import { ProjectCard } from './ProjectCard'
import { ProjectTableRow } from './ProjectTableRow'
import { ShimmeringCard } from './ShimmeringCard'
export interface ProjectListProps {
organization?: Organization
rewriteHref?: (projectRef: string) => string
search?: string
filterStatus?: string[]
resetFilterStatus?: () => void
viewMode?: 'grid' | 'table'
}
export const ProjectList = ({
search = '',
organization: organization_,
rewriteHref,
filterStatus,
resetFilterStatus,
viewMode = 'grid',
}: ProjectListProps) => {
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 [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,
error: projectsError,
} = useProjectsQuery()
const allProjects = data?.projects ?? []
isFetching,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
} = useOrgProjectsInfiniteQuery(
{
slug,
search: search.length === 0 ? search : debouncedSearch,
statuses: filterStatus,
},
{
keepPreviousData: true,
}
)
const orgProjects =
useMemo(() => data?.pages.flatMap((page) => page.projects), [data?.pages]) || []
const {
isLoading: _isLoadingPermissions,
@@ -54,42 +92,18 @@ export const ProjectList = ({
const { data: integrations } = useOrgIntegrationsQuery({ orgSlug: organization?.slug })
const { data: connections } = useGitHubConnectionsQuery({ organizationId: organization?.id })
const orgProjects = allProjects.filter((x) => x.organization_slug === organization?.slug)
const isLoadingPermissions = IS_PLATFORM ? _isLoadingPermissions : false
const hasFilterStatusApplied = filterStatus !== undefined && filterStatus.length !== 2
const noResultsFromSearch =
search.length > 0 &&
isSuccessProjects &&
orgProjects.filter((project) => {
return (
project.name.toLowerCase().includes(search.toLowerCase()) ||
project.ref.includes(search.toLowerCase())
)
}).length === 0
const noResultsFromStatusFilter =
hasFilterStatusApplied &&
isSuccessProjects &&
orgProjects.filter((project) => filterStatus.includes(project.status)).length === 0
const isEmpty = !orgProjects || orgProjects.length === 0
const isEmpty =
debouncedSearch.length === 0 &&
filterStatus.length === 0 &&
(!orgProjects || orgProjects.length === 0)
const sortedProjects = [...(orgProjects || [])].sort((a, b) => a.name.localeCompare(b.name))
const filteredProjects =
search.length > 0
? sortedProjects.filter((project) => {
return (
project.name.toLowerCase().includes(search.toLowerCase()) ||
project.ref.includes(search.toLowerCase())
)
})
: sortedProjects
const filteredProjectsByStatus =
filterStatus !== undefined
? filterStatus.length === 2
? filteredProjects
: filteredProjects.filter((project) => filterStatus.includes(project.status))
: filteredProjects
const noResultsFromSearch =
debouncedSearch.length > 0 && isSuccessProjects && orgProjects.length === 0
const noResultsFromStatusFilter =
filterStatus.length > 0 && isSuccessProjects && orgProjects.length === 0
const githubConnections = connections?.map((connection) => ({
id: String(connection.id),
@@ -111,6 +125,11 @@ export const ProjectList = ({
?.filter((integration) => integration.integration.name === 'Vercel')
.flatMap((integration) => integration.connections)
const handleScroll = (event: UIEvent<HTMLDivElement | HTMLUListElement>) => {
if (isLoadingProjects || isFetchingNextPage || !isAtBottom(event)) return
fetchNextPage()
}
if (isErrorPermissions) {
return (
<AlertError
@@ -139,9 +158,10 @@ export const ProjectList = ({
if (viewMode === 'table') {
return (
<Card>
<Card className="flex-1 min-h-0 overflow-y-auto mb-8" onScroll={handleScroll}>
<Table>
<TableHeader>
{/* [Joshen] Ideally we can figure out sticky table headers here */}
<TableHeader className="[&>tr>th]:sticky [&>tr>th]:top-0">
<TableRow>
<TableHead>Project</TableHead>
<TableHead>Status</TableHead>
@@ -149,6 +169,11 @@ export const ProjectList = ({
<TableHead>Region</TableHead>
<TableHead>Created</TableHead>
</TableRow>
<TableRow className="!border-b-0">
<TableCell colSpan={5} className="p-0">
<LoadingLine loading={isFetching} />
</TableCell>
</TableRow>
</TableHeader>
<TableBody>
{noResultsFromStatusFilter ? (
@@ -156,7 +181,7 @@ export const ProjectList = ({
<TableCell colSpan={5} className="p-0">
<NoFilterResults
filterStatus={filterStatus}
resetFilterStatus={resetFilterStatus}
resetFilterStatus={() => setFilterStatus([])}
className="border-0"
/>
</TableCell>
@@ -168,22 +193,26 @@ export const ProjectList = ({
</TableCell>
</TableRow>
) : (
filteredProjectsByStatus?.map((project) => (
<ProjectTableRow
key={project.ref}
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
)}
/>
))
<>
{sortedProjects?.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 && <LoadingTableRow />}
</>
)}
</TableBody>
</Table>
@@ -194,14 +223,24 @@ export const ProjectList = ({
return (
<>
{noResultsFromStatusFilter ? (
<NoFilterResults filterStatus={filterStatus} resetFilterStatus={resetFilterStatus} />
<NoFilterResults
filterStatus={filterStatus}
resetFilterStatus={() => setFilterStatus([])}
/>
) : noResultsFromSearch ? (
<NoSearchResults searchString={search} />
) : (
<ul className="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">
{filteredProjectsByStatus?.map((project) => (
<ul
onScroll={handleScroll}
className={cn(
'min-h-0 overflow-auto w-full mx-auto ',
'grid grid-cols-1 gap-2 md:gap-4 overflow-y-scroll',
'sm:grid-cols-1 md:grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 pb-6'
)}
>
{sortedProjects?.map((project) => (
<ProjectCard
key={makeRandomString(5)}
key={project.ref}
project={project}
rewriteHref={rewriteHref ? rewriteHref(project.ref) : undefined}
resourceWarnings={resourceWarnings?.find(
@@ -215,6 +254,7 @@ export const ProjectList = ({
)}
/>
))}
{hasNextPage && [...Array(2)].map((_, i) => <ShimmeringCard key={i} />)}
</ul>
)}
</>
@@ -4,16 +4,18 @@ import InlineSVG from 'react-inlinesvg'
import { ComputeBadgeWrapper } from 'components/ui/ComputeBadgeWrapper'
import type { IntegrationProjectConnection } from 'data/integrations/integrations.types'
import type { ProjectInfo } from 'data/projects/projects-query'
import { OrgProject } from 'data/projects/projects-infinite-query'
import type { ResourceWarning } from 'data/usage/resource-warnings-query'
import { BASE_PATH } from 'lib/constants'
import { Organization } from 'types'
import { TableCell, TableRow } from 'ui'
import { TimestampInfo } from 'ui-patterns'
import { inferProjectStatus } from './ProjectCard.utils'
import { ProjectCardStatus } from './ProjectCardStatus'
export interface ProjectTableRowProps {
project: ProjectInfo
project: OrgProject
organization?: Organization
rewriteHref?: string
githubIntegration?: IntegrationProjectConnection
vercelIntegration?: IntegrationProjectConnection
@@ -22,6 +24,7 @@ export interface ProjectTableRowProps {
export const ProjectTableRow = ({
project,
organization,
rewriteHref,
githubIntegration,
vercelIntegration,
@@ -29,14 +32,15 @@ export const ProjectTableRow = ({
}: ProjectTableRowProps) => {
const router = useRouter()
const { name, ref: projectRef } = project
const projectStatus = inferProjectStatus(project)
const projectStatus = inferProjectStatus(project.status)
const url = rewriteHref ?? `/project/${project.ref}`
const isBranchingEnabled = project.preview_branch_refs?.length > 0
const isGithubIntegrated = githubIntegration !== undefined
const isVercelIntegrated = vercelIntegration !== undefined
const githubRepository = githubIntegration?.metadata.name ?? undefined
const infraInformation = project.databases.find((x) => x.identifier === project.ref)
return (
<TableRow
className="cursor-pointer hover:bg-surface-200"
@@ -54,7 +58,7 @@ export const ProjectTableRow = ({
<p className="font-medium">{name}</p>
<p className="text-xs text-foreground-lighter">ID: {projectRef}</p>
</div>
{(isGithubIntegrated || isVercelIntegrated || isBranchingEnabled) && (
{(isGithubIntegrated || isVercelIntegrated) && (
<div className="flex items-center gap-x-2">
{isVercelIntegrated && (
<div className="w-fit p-1 border rounded-md flex items-center text-black dark:text-white">
@@ -91,7 +95,14 @@ export const ProjectTableRow = ({
<TableCell>
<div className="w-fit">
{project.status !== 'INACTIVE' ? (
<ComputeBadgeWrapper project={project} />
<ComputeBadgeWrapper
project={{
ref: project.ref,
organization_slug: organization?.slug,
cloud_provider: infraInformation?.cloud_provider,
infra_compute_size: infraInformation?.infra_compute_size,
}}
/>
) : (
<span className="text-xs text-foreground-light">-</span>
)}
@@ -99,7 +110,7 @@ export const ProjectTableRow = ({
</TableCell>
<TableCell>
<span className="lowercase text-sm text-foreground-light">
{project.cloud_provider} | {project.region || 'N/A'}
{infraInformation?.cloud_provider} | {project.region || 'N/A'}
</span>
</TableCell>
<TableCell>
@@ -1,9 +1,13 @@
import { Filter, Grid, List, Plus, Search } from 'lucide-react'
import { Filter, Grid, List, Loader2, Plus, Search, X } from 'lucide-react'
import Link from 'next/link'
import { useParams } from 'common'
import { useDebounce } from '@uidotdev/usehooks'
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
import { useOrgProjectsInfiniteQuery } from 'data/projects/projects-infinite-query'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
import { PROJECT_STATUS } from 'lib/constants'
import { parseAsArrayOf, parseAsString, useQueryState } from 'nuqs'
import {
Button,
Checkbox_Shadcn_,
@@ -17,29 +21,37 @@ import {
import { Input } from 'ui-patterns/DataInputs/Input'
interface HomePageActionsProps {
search: string
filterStatus: string[]
slug?: string
hideNewProject?: boolean
viewMode?: 'grid' | 'table'
showViewToggle?: boolean
setSearch: (value: string) => void
setFilterStatus: (value: string[]) => void
setViewMode?: (value: 'grid' | 'table') => void
}
export const HomePageActions = ({
search,
filterStatus,
slug: _slug,
hideNewProject = false,
viewMode,
showViewToggle = false,
setSearch,
setFilterStatus,
setViewMode,
}: HomePageActionsProps) => {
const { slug } = useParams()
const { slug: urlSlug } = useParams()
const projectCreationEnabled = useIsFeatureEnabled('projects:create')
const slug = _slug ?? urlSlug
const [search, setSearch] = useQueryState('search', parseAsString.withDefault(''))
const debouncedSearch = useDebounce(search, 500)
const [filterStatus, setFilterStatus] = useQueryState(
'status',
parseAsArrayOf(parseAsString, ',').withDefault([])
)
const [viewMode, setViewMode] = useLocalStorageQuery(LOCAL_STORAGE_KEYS.PROJECTS_VIEW, 'grid')
const { isFetching: isFetchingProjects } = useOrgProjectsInfiniteQuery(
{
slug,
search: search.length === 0 ? search : debouncedSearch,
statuses: filterStatus,
},
{ keepPreviousData: true }
)
return (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
@@ -47,15 +59,26 @@ export const HomePageActions = ({
placeholder="Search for a project"
icon={<Search size={12} />}
size="tiny"
className="w-64 pl-8 [&>div>div>div>input]:!pl-7 [&>div>div>div>div]:!pl-2"
className="w-32 md:w-64 pl-8 [&>div>div>div>input]:!pl-7 [&>div>div>div>div]:!pl-2"
value={search}
onChange={(event) => setSearch(event.target.value)}
actions={[
search && (
<Button
size="tiny"
type="text"
icon={<X />}
onClick={() => setSearch('')}
className="p-0 h-5 w-5"
/>
),
]}
/>
<Popover_Shadcn_>
<PopoverTrigger_Shadcn_ asChild>
<Button
type={filterStatus.length !== 2 ? 'secondary' : 'dashed'}
type={filterStatus.length === 0 ? 'dashed' : 'secondary'}
className="h-[26px] w-[26px]"
icon={<Filter />}
/>
@@ -73,10 +96,12 @@ export const HomePageActions = ({
<Checkbox_Shadcn_
id={key}
name={key}
checked={filterStatus.includes(key)}
checked={filterStatus.length === 0 || filterStatus.includes(key)}
onCheckedChange={() => {
if (filterStatus.includes(key)) {
setFilterStatus(filterStatus.filter((y) => y !== key))
} else if (filterStatus.length === 1) {
setFilterStatus([])
} else {
setFilterStatus(filterStatus.concat([key]))
}
@@ -100,6 +125,8 @@ export const HomePageActions = ({
</div>
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
{isFetchingProjects && <Loader2 className="animate-spin" size={14} />}
</div>
<div className="flex items-center gap-2">
@@ -1,13 +1,12 @@
import { useRouter } from 'next/router'
import { PropsWithChildren } from 'react'
import { LOCAL_STORAGE_KEYS } from 'common'
import { useParams } from 'common'
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
import { AppBannerWrapper } from 'components/interfaces/App'
import { AppBannerContextProvider } from 'components/interfaces/App/AppBannerWrapperContext'
import { Sidebar } from 'components/interfaces/Sidebar'
import { useCheckLatestDeploy } from 'hooks/use-check-latest-deploy'
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
import { useCheckLatestDeploy } from 'hooks/use-check-latest-deploy'
import { useAppStateSnapshot } from 'state/app-state'
import { SidebarProvider } from 'ui'
import { LayoutHeader } from './ProjectLayout/LayoutHeader'
@@ -75,7 +75,7 @@ export const PageLayout = ({
const router = useRouter()
return (
<div className="w-full min-h-full flex flex-col items-stretch">
<div className={cn('w-full min-h-full flex flex-col items-stretch', className)}>
<ScaffoldContainer
size={size}
className={cn(
@@ -83,8 +83,7 @@ export const PageLayout = ({
size === 'full' &&
(isCompact ? 'max-w-none !px-6 border-b pt-4' : 'max-w-none pt-6 !px-10 border-b'),
size !== 'full' && (isCompact ? 'pt-4' : 'pt-12'),
navigationItems.length === 0 && size === 'full' && (isCompact ? 'pb-4' : 'pb-8'),
className
navigationItems.length === 0 && size === 'full' && (isCompact ? 'pb-4' : 'pb-8')
)}
>
{/* Header section */}
+9
View File
@@ -1,5 +1,14 @@
export const projectKeys = {
list: () => ['all-projects'] as const,
infiniteListByOrg: (
slug: string | undefined,
params: {
limit: number
sort?: 'name_asc' | 'name_desc' | 'created_asc' | 'created_desc'
search?: string
statuses?: string[]
}
) => ['all-projects', slug, params] as const,
status: (projectRef: string | undefined) => ['project', projectRef, 'status'] as const,
types: (projectRef: string | undefined) => ['project', projectRef, 'types'] as const,
detail: (projectRef: string | undefined) => ['project', projectRef, 'detail'] as const,
@@ -0,0 +1,87 @@
import { useInfiniteQuery, UseInfiniteQueryOptions } from '@tanstack/react-query'
import { components } from 'api-types'
import { get, handleError } from 'data/fetchers'
import { useProfile } from 'lib/profile'
import { ResponseError } from 'types'
import { projectKeys } from './keys'
// [Joshen] Try to keep this value a multiple of 6 (common denominator of 2 and 3) to fit the cards view
// So that the last row will always be a full row of cards while there's a next page
// API max rows is 100, I'm just choosing 96 here as the highest value thats a multiple of 6
const DEFAULT_LIMIT = 96
interface GetOrgProjectsInfiniteVariables {
slug?: string
limit?: number
sort?: 'name_asc' | 'name_desc' | 'created_asc' | 'created_desc'
search?: string
page?: number
statuses?: string[]
}
export type OrgProject = components['schemas']['OrganizationProjectsResponse']['projects'][number]
async function getOrganizationProjects(
{
slug,
limit = DEFAULT_LIMIT,
page = 0,
sort = 'name_asc',
search: _search = '',
statuses: _statuses = [],
}: GetOrgProjectsInfiniteVariables,
signal?: AbortSignal,
headers?: Record<string, string>
) {
if (!slug) throw new Error('Slug is required')
const offset = page * limit
const search = _search.length === 0 ? undefined : _search
const statuses = _statuses.length === 0 ? undefined : _statuses.join(',')
const { data, error } = await get('/platform/organizations/{slug}/projects', {
params: { path: { slug }, query: { limit, offset, sort, search, statuses } },
signal,
headers,
})
if (error) handleError(error)
return data
}
export type OrgProjectsInfiniteData = Awaited<ReturnType<typeof getOrganizationProjects>>
export type OrgProjectsInfiniteError = ResponseError
export const useOrgProjectsInfiniteQuery = <TData = OrgProjectsInfiniteData>(
{
slug,
limit = DEFAULT_LIMIT,
sort = 'name_asc',
search,
statuses = [],
}: GetOrgProjectsInfiniteVariables,
{
enabled = true,
...options
}: UseInfiniteQueryOptions<OrgProjectsInfiniteData, OrgProjectsInfiniteError, TData> = {}
) => {
const { profile } = useProfile()
return useInfiniteQuery<OrgProjectsInfiniteData, OrgProjectsInfiniteError, TData>(
projectKeys.infiniteListByOrg(slug, { limit, sort, search, statuses }),
({ signal, pageParam }) =>
getOrganizationProjects({ slug, limit, page: pageParam, sort, search, statuses }, signal),
{
enabled: enabled && profile !== undefined && typeof slug !== 'undefined',
getNextPageParam(lastPage, pages) {
const page = pages.length
const currentTotalCount = page * limit
const totalCount = lastPage.pagination.count
if (currentTotalCount >= totalCount) return undefined
return page
},
...options,
}
)
}
+1 -1
View File
@@ -3,7 +3,7 @@ export { default as uuidv4 } from './uuid'
import { UIEvent } from 'react'
import type { TablesData } from '../data/tables/tables-query'
export const isAtBottom = ({ currentTarget }: UIEvent<HTMLDivElement>): boolean => {
export const isAtBottom = ({ currentTarget }: UIEvent<HTMLElement>): boolean => {
return currentTarget.scrollTop + 10 >= currentTarget.scrollHeight - currentTarget.clientHeight
}
+7 -29
View File
@@ -1,5 +1,3 @@
import { useState } from 'react'
import { useIsMFAEnabled } from 'common'
import { ProjectList } from 'components/interfaces/Home/ProjectList/ProjectList'
import { HomePageActions } from 'components/interfaces/HomePageActions'
@@ -10,7 +8,6 @@ import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
import { InlineLink } from 'components/ui/InlineLink'
import { useAutoProjectsPrefetch } from 'data/projects/projects-query'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { PROJECT_STATUS } from 'lib/constants'
import type { NextPageWithLayout } from 'types'
import { Admonition } from 'ui-patterns'
@@ -19,18 +16,11 @@ const ProjectsPage: NextPageWithLayout = () => {
const isUserMFAEnabled = useIsMFAEnabled()
const disableAccessMfa = org?.organization_requires_mfa && !isUserMFAEnabled
const [search, setSearch] = useState('')
const [filterStatus, setFilterStatus] = useState<string[]>([
PROJECT_STATUS.ACTIVE_HEALTHY,
PROJECT_STATUS.INACTIVE,
])
const [viewMode, setViewMode] = useState<'grid' | 'table'>('grid')
useAutoProjectsPrefetch()
return (
<ScaffoldContainer>
<ScaffoldSection isFullWidth>
<ScaffoldContainer className="flex-grow flex">
<ScaffoldSection isFullWidth className="flex-grow pb-0">
{disableAccessMfa ? (
<Admonition type="note" title={`The organization "${org?.name}" has MFA enforced`}>
<p className="!m-0">
@@ -40,23 +30,11 @@ const ProjectsPage: NextPageWithLayout = () => {
</p>
</Admonition>
) : (
<div className="flex flex-col gap-y-4">
<HomePageActions
search={search}
setSearch={setSearch}
filterStatus={filterStatus}
setFilterStatus={setFilterStatus}
viewMode={viewMode}
setViewMode={setViewMode}
showViewToggle={true}
/>
<ProjectList
search={search}
filterStatus={filterStatus}
resetFilterStatus={() => setFilterStatus(['ACTIVE_HEALTHY', 'INACTIVE'])}
viewMode={viewMode}
/>
// [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
<div className="flex flex-col gap-y-4 flex-grow h-px">
<HomePageActions showViewToggle={true} />
<ProjectList />
</div>
)}
</ScaffoldSection>
@@ -1,18 +1,28 @@
import { partition } from 'lodash'
import { AlertTriangleIcon, Boxes } from 'lucide-react'
import { AlertTriangleIcon } from 'lucide-react'
import { NextPage } from 'next'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { Fragment, useMemo, useState } from 'react'
import { useEffect, useState } from 'react'
import { IS_PLATFORM, LOCAL_STORAGE_KEYS } from 'common'
import { ProjectList } from 'components/interfaces/Home/ProjectList/ProjectList'
import { HomePageActions } from 'components/interfaces/HomePageActions'
import { PageLayout } from 'components/layouts/PageLayout/PageLayout'
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 { BASE_PATH, PROJECT_STATUS } from 'lib/constants'
import { Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_, Badge } from 'ui'
import { BASE_PATH } from 'lib/constants'
import {
Alert_Shadcn_,
AlertDescription_Shadcn_,
AlertTitle_Shadcn_,
Select_Shadcn_,
SelectContent_Shadcn_,
SelectItem_Shadcn_,
SelectTrigger_Shadcn_,
SelectValue_Shadcn_,
} from 'ui'
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
const Header = () => {
@@ -32,6 +42,26 @@ const Header = () => {
)
}
const OrganizationLoadingState = () => {
return (
<>
<ShimmeringLoader className="w-3/4" />
<ShimmeringLoader className="w-1/2" />
<ShimmeringLoader className="w-1/4" />
</>
)
}
const OrganizationErrorState = () => {
return (
<Alert_Shadcn_ variant="warning">
<AlertTriangleIcon />
<AlertTitle_Shadcn_>Failed to load your Supabase organizations</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>Try refreshing the page</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)
}
// [Joshen] I'd say we don't do route validation here, this page will act more
// like a proxy to the project specific pages, and we let those pages handle
// any route validation logic instead
@@ -40,24 +70,22 @@ const GenericProjectPage: NextPage = () => {
const router = useRouter()
const { routeSlug, ...queryParams } = router.query
const [search, setSearch] = useState('')
const [filterStatus, setFilterStatus] = useState<string[]>([
PROJECT_STATUS.ACTIVE_HEALTHY,
PROJECT_STATUS.INACTIVE,
])
const [lastVisitedOrgSlug] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.LAST_VISITED_ORGANIZATION,
''
)
const [selectedSlug, setSlug] = useState(lastVisitedOrgSlug)
const {
data: organizations = [],
isSuccess: isSuccessOrganizations,
isLoading: isLoadingOrganizations,
isError: isErrorOrganizations,
} = useOrganizationsQuery({
enabled: IS_PLATFORM,
})
const selectedOrganization = organizations.find((x) => x.slug === selectedSlug)
const query = Object.keys(queryParams).length
? `?${new URLSearchParams(queryParams as Record<string, string>)}`
@@ -76,89 +104,55 @@ const GenericProjectPage: NextPage = () => {
}
}
const [[lastVisitedOrganization], otherOrganizations] = useMemo(
() => partition(organizations, (org) => org.slug === lastVisitedOrgSlug),
[lastVisitedOrgSlug, organizations]
)
useEffect(() => {
if (!!lastVisitedOrgSlug) {
setSlug(lastVisitedOrgSlug)
} else if (isSuccessOrganizations) {
setSlug(organizations[0].slug)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lastVisitedOrgSlug, isSuccessOrganizations])
return (
<>
<div className="h-screen flex flex-col">
<Header />
<div className="flex flex-col mx-auto w-full">
<h1 className="mt-8 max-w-5xl mx-auto w-full">Select a project to continue</h1>
<div
className="flex-grow py-6 overflow-y-auto"
style={{ maxHeight: 'calc(100vh - 49px - 64px)' }}
>
<div className="w-full max-w-5xl mx-auto flex flex-col gap-y-8">
<HomePageActions
hideNewProject
search={search}
setSearch={setSearch}
filterStatus={filterStatus}
setFilterStatus={setFilterStatus}
/>
<PageLayout className="flex-grow min-h-0" title="Select a project to continue">
<ScaffoldContainer className="flex-grow flex flex-col">
<ScaffoldSection isFullWidth>
<div className="flex items-center gap-x-2">
<Select_Shadcn_ value={selectedSlug} onValueChange={setSlug}>
<SelectTrigger_Shadcn_ size="tiny" className="w-60 truncate">
<div className="flex items-center gap-x-2">
<p className="text-xs text-foreground-light">Organization:</p>
<SelectValue_Shadcn_ placeholder="Select an organization" />
</div>
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_ className="col-span-8">
{organizations.map((org) => (
<SelectItem_Shadcn_ key={org.slug} value={org.slug} className="text-xs">
{org.name}
</SelectItem_Shadcn_>
))}
</SelectContent_Shadcn_>
</Select_Shadcn_>
<HomePageActions hideNewProject />
</div>
</ScaffoldSection>
<ScaffoldSection isFullWidth className="flex-grow pt-0 flex flex-col gap-y-4 h-px">
{isLoadingOrganizations ? (
<OrganizationLoadingState />
) : isErrorOrganizations ? (
<OrganizationErrorState />
) : (
<>
{!!lastVisitedOrganization && (
<>
<h2 className="flex items-center gap-2">
<Boxes size={14} strokeWidth={1.5} className="text-foreground-lighter" />
{lastVisitedOrganization.name}
<Badge variant="default">Recently visited</Badge>
</h2>
<ProjectList
search={search}
filterStatus={filterStatus}
organization={lastVisitedOrganization}
rewriteHref={urlRewriterFactory(routeSlug)}
/>
</>
)}
{otherOrganizations.map((organization) => (
<Fragment key={organization.id}>
<h2 className="flex items-center gap-2">
<Boxes size={14} strokeWidth={1.5} className="text-foreground-lighter" />
{organization.name}
</h2>
<ProjectList
search={search}
filterStatus={filterStatus}
organization={organization}
rewriteHref={urlRewriterFactory(routeSlug)}
/>
</Fragment>
))}
</>
)}
</div>
</div>
</div>
</>
)
}
function OrganizationLoadingState() {
return (
<>
<ShimmeringLoader className="w-3/4" />
<ShimmeringLoader className="w-1/2" />
<ShimmeringLoader className="w-1/4" />
</>
)
}
function OrganizationErrorState() {
return (
<Alert_Shadcn_ variant="warning">
<AlertTriangleIcon />
<AlertTitle_Shadcn_>Failed to load your Supabase organizations</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>Try refreshing the page</AlertDescription_Shadcn_>
</Alert_Shadcn_>
) : !!selectedOrganization ? (
<ProjectList
organization={selectedOrganization}
rewriteHref={urlRewriterFactory(routeSlug)}
/>
) : null}
</ScaffoldSection>
</ScaffoldContainer>
</PageLayout>
</div>
)
}
-25
View File
@@ -1,6 +1,4 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import './../../../packages/ui/build/css/source/global.css';
@import './../../../packages/ui/build/css/themes/dark.css';
@@ -46,21 +44,6 @@
--sidebar-ring: 217.2 91.2% 59.8%;
}
@layer utilities {
.btn-primary {
@apply inline-block rounded border border-green-500 bg-green-500 py-1 px-3 text-sm;
color: #fff !important;
font-weight: 600;
line-height: 20px;
text-align: center;
}
.btn-primary-hover {
@apply bg-green-600;
cursor: pointer;
}
}
html,
body,
#__next,
@@ -161,14 +144,6 @@ input.is-invalid {
@apply placeholder:text-red-600;
}
input[type='submit'] {
@apply btn-primary;
}
input[type='submit']:hover {
@apply btn-primary-hover;
}
input::placeholder {
@apply text-foreground-lighter;
}
-1
View File
@@ -1,5 +1,4 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
@@ -6,6 +6,7 @@ export const LOCAL_STORAGE_KEYS = {
`supabase-ai-assistant-state-${projectRef}`,
SIDEBAR_BEHAVIOR: 'supabase-sidebar-behavior',
EDITOR_PANEL_STATE: 'supabase-editor-panel-state',
PROJECTS_VIEW: 'projects-view',
UI_PREVIEW_API_SIDE_PANEL: 'supabase-ui-api-side-panel',
UI_PREVIEW_CLS: 'supabase-ui-cls',