feat(studio): add infinite pagination for buckets policies (#41277)

## Summary of changes

Buckets policies page at /project/_/storage/files/policies now uses the
paginated buckets endpoint to fetch buckets.

## Before

Used a non-paginated query.

## After

To support a paginated query, we now have:
- Infinite scroll loading behavior
- Fixed some of the buckets utilities to account for policy references
to buckets that haven't loaded yet
This commit is contained in:
Charis authored and GitHub committed 2025-12-11 15:45:16 -05:00
1 parent fef1f043ad
commit c0c824809e
11 files changed
+477 -199

No files matched your search

@@ -1,8 +1,9 @@
import { PostgresPolicy } from '@supabase/postgres-meta'
import { difference, groupBy } from 'lodash'
import { difference } from 'lodash'
import { useRouter } from 'next/router'
import { Bucket } from 'data/storage/buckets-query'
import { createWrappedSymbol } from 'lib/helpers'
import { STORAGE_CLIENT_LIBRARY_MAPPINGS } from './Storage.constants'
import type { StoragePolicyFormField } from './Storage.types'
@@ -16,13 +17,18 @@ const shortHash = (str: string) => {
return new Uint32Array([hash])[0].toString(36)
}
export type PoliciesByBucket = { name: string | Symbol; policies: PostgresPolicy[] }[]
/**
* Formats the policies from the objects table in the storage schema
* to be consumable for the storage policies dashboard.
* Output: [{ bucket: <string>, policies: <Policy[]> }]
* @param {Array} policies: All policies from a table in a schema
*
* @param policies All policies from a table in a schema
*/
export const formatPoliciesForStorage = (buckets: Bucket[], policies: PostgresPolicy[]) => {
export const formatPoliciesForStorage = (
buckets: Bucket[],
policies: PostgresPolicy[]
): PoliciesByBucket => {
if (policies.length === 0) return []
/**
@@ -34,15 +40,20 @@ export const formatPoliciesForStorage = (buckets: Bucket[], policies: PostgresPo
*/
const formattedPolicies = formatStoragePolicies(buckets, policies)
/**
* Package policies by grouping them by bucket:
* [{ name: <string>, policies: <Policy[]> }]
*/
const policiesByBucket = groupPoliciesByBucket(formattedPolicies)
return policiesByBucket
}
/**
* Policy that belongs to a bucket which is not loaded yet (might not have been
* paginated to yet, or might have been deleted)
*/
export const UNKNOWN_BUCKET_SYMBOL = createWrappedSymbol('unknown-bucket', 'Unknown')
/**
* Policy that is not associated with a specific bucket
*/
export const UNGROUPED_POLICY_SYMBOL = createWrappedSymbol('ungrouped-policy', 'Ungrouped')
const formatStoragePolicies = (buckets: Bucket[], policies: PostgresPolicy[]) => {
const availableBuckets = buckets.map((bucket) => bucket.name)
const formattedPolicies = policies.map((policy) => {
@@ -53,16 +64,16 @@ const formatStoragePolicies = (buckets: Bucket[], policies: PostgresPolicy[]) =>
? extractBucketNameFromDefinition(policyDefinition)
: extractBucketNameFromDefinition(policyCheck)
if (bucketName && availableBuckets.includes(bucketName)) {
// [JOSHEN TODO] We cannot override definition here anymore cause we're gonna be using the auth editor
// const definition = policyDefinition !== null ? policyDefinition : policyCheck
if (bucketName) {
const isBucketLoaded = availableBuckets.includes(bucketName)
return {
...policy,
bucket: bucketName,
bucket: isBucketLoaded ? bucketName : UNKNOWN_BUCKET_SYMBOL,
}
}
return { ...policy, bucket: 'Ungrouped' }
return { ...policy, bucket: UNGROUPED_POLICY_SYMBOL }
})
return formattedPolicies
@@ -78,11 +89,11 @@ export const extractBucketNameFromDefinition = (definition: string | null) => {
return bucketDefinition ? bucketDefinition.split("'")[1] : null
}
const groupPoliciesByBucket = (policies: (PostgresPolicy & { bucket: string })[]) => {
const policiesByBucket = groupBy(policies, 'bucket')
return Object.keys(policiesByBucket).map((bucketName) => {
return { name: bucketName, policies: policiesByBucket[bucketName] }
})
const groupPoliciesByBucket = (policies: (PostgresPolicy & { bucket: string | Symbol })[]) => {
const policiesByBucket = Map.groupBy(policies, (policy) => policy.bucket)
return [
...policiesByBucket.entries().map(([bucketName, policies]) => ({ name: bucketName, policies })),
]
}
export const createPayloadsForAddPolicy = (
@@ -1,22 +1,19 @@
import { PostgresPolicy } from '@supabase/postgres-meta'
import { useParams } from 'common'
import { isEmpty } from 'lodash'
import { Search, X } from 'lucide-react'
import { parseAsString, useQueryState } from 'nuqs'
import { useMemo, useState } from 'react'
import { toast } from 'sonner'
import PolicyEditorModal from 'components/interfaces/Auth/Policies/PolicyEditorModal'
import { NoSearchResults } from 'components/ui/NoSearchResults'
import { useDatabasePoliciesQuery } from 'data/database-policies/database-policies-query'
import { useDatabasePolicyCreateMutation } from 'data/database-policies/database-policy-create-mutation'
import { useDatabasePolicyDeleteMutation } from 'data/database-policies/database-policy-delete-mutation'
import { useDatabasePolicyUpdateMutation } from 'data/database-policies/database-policy-update-mutation'
import { useBucketsQuery } from 'data/storage/buckets-query'
import { usePaginatedBucketsQuery } from 'data/storage/buckets-query'
import { useDebouncedValue } from 'hooks/misc/useDebouncedValue'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { Button } from 'ui'
import { GenericSkeletonLoader } from 'ui-patterns'
import { Input } from 'ui-patterns/DataInputs/Input'
import ConfirmModal from 'ui-patterns/Dialogs/ConfirmDialog'
import { PageContainer } from 'ui-patterns/PageContainer'
import {
@@ -27,10 +24,10 @@ import {
PageSectionSummary,
PageSectionTitle,
} from 'ui-patterns/PageSection'
import { formatPoliciesForStorage } from '../Storage.utils'
import { formatPoliciesForStorage, UNGROUPED_POLICY_SYMBOL } from '../Storage.utils'
import { StoragePoliciesBucketRow } from './StoragePoliciesBucketRow'
import { BucketsPolicies, type SelectBucketPolicyForAction } from './StoragePoliciesBucketsSection'
import StoragePoliciesEditPolicyModal from './StoragePoliciesEditPolicyModal'
import StoragePoliciesPlaceholder from './StoragePoliciesPlaceholder'
export const StoragePolicies = () => {
const { ref: projectRef } = useParams()
@@ -46,8 +43,19 @@ export const StoragePolicies = () => {
'search',
parseAsString.withDefault('').withOptions({ history: 'replace', clearOnDefault: true })
)
const debouncedSearchString = useDebouncedValue(searchString, 250)
const { data: buckets = [], isPending: isLoadingBuckets } = useBucketsQuery({ projectRef })
const {
data: bucketsData,
isPending: isLoadingBuckets,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
} = usePaginatedBucketsQuery({
projectRef,
search: debouncedSearchString || undefined,
})
const buckets = useMemo(() => bucketsData?.pages.flatMap((page) => page) ?? [], [bucketsData])
const {
data: policies = [],
@@ -81,50 +89,46 @@ export const StoragePolicies = () => {
const showGeneralPolicyEditor = !isEmpty(isEditingPolicyForBucket) && !showStoragePolicyEditor
// Policies under storage.objects
const storageObjectsPolicies = policies.filter(
(x) => x.schema === 'storage' && x.table === 'objects'
)
const formattedStorageObjectPolicies = formatPoliciesForStorage(buckets, storageObjectsPolicies)
const ungroupedPolicies =
formattedStorageObjectPolicies.find((x) => x.name === 'Ungrouped')?.policies ?? []
// Policies under storage.buckets
const storageBucketPolicies = policies.filter(
(x) => x.schema === 'storage' && x.table === 'buckets'
)
// Policies under storage.objects
const storageObjectsPolicies = policies.filter(
(x) => x.schema === 'storage' && x.table === 'objects'
)
/**
* Filter buckets based on search string
* - Filter buckets by name matching the search string
* - Show all policies for filtered buckets (policies are not filtered)
*/
const filteredBucketsWithPolicies = useMemo(() => {
const searchFilter = searchString?.toLowerCase() || ''
const formattedStorageObjectPolicies = useMemo(
() => formatPoliciesForStorage(buckets, storageObjectsPolicies),
[buckets, storageObjectsPolicies]
)
// Filter buckets by name if search filter is present
const filteredBucketsList = searchFilter
? buckets.filter((bucket) => bucket.name.toLowerCase().includes(searchFilter))
: buckets
const ungroupedPolicies =
formattedStorageObjectPolicies.find((x) => x.name === UNGROUPED_POLICY_SYMBOL)?.policies ?? []
const bucketsWithPolicies = useMemo(() => {
// Get policies for filtered buckets (show all policies, don't filter them)
// Show all filtered buckets, even if they don't have policies (similar to auth/policies.tsx)
const filteredBucketsWithPoliciesList = filteredBucketsList.map((bucket) => {
const policies =
// Show all filtered buckets, even if they don't have policies
return buckets.map((bucket) => {
const bucketPolicies =
formattedStorageObjectPolicies.find((x) => x.name === bucket.name)?.policies ?? []
return { bucket, policies }
return { bucket, policies: bucketPolicies }
})
}, [buckets, formattedStorageObjectPolicies])
// Schema-level policies should always be shown, unaffected by search filter
return filteredBucketsWithPoliciesList
}, [buckets, searchString, formattedStorageObjectPolicies])
const onSelectPolicyAdd = (bucketName = '', table = '') => {
const onSelectPolicyAdd: SelectBucketPolicyForAction['addPolicy'] = (
bucketName = '',
table = ''
) => {
setSelectedPolicyToEdit(undefined)
setIsEditingPolicyForBucket({ bucket: bucketName, table })
}
const onSelectPolicyEdit = (policy: any, bucketName = '', table = '') => {
const onSelectPolicyEdit: SelectBucketPolicyForAction['editPolicy'] = (
policy,
bucketName = '',
table = ''
) => {
setIsEditingPolicyForBucket({ bucket: bucketName, table })
setSelectedPolicyToEdit(policy)
}
@@ -133,7 +137,8 @@ export const StoragePolicies = () => {
setIsEditingPolicyForBucket(undefined)
}
const onSelectPolicyDelete = (policy: any) => setSelectedPolicyToDelete(policy)
const onSelectPolicyDelete: SelectBucketPolicyForAction['deletePolicy'] = (policy: any) =>
setSelectedPolicyToDelete(policy)
const onCancelPolicyDelete = () => setSelectedPolicyToDelete(undefined)
const onSavePolicySuccess = async () => {
@@ -237,72 +242,22 @@ export const StoragePolicies = () => {
</PageSection>
) : (
<div>
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Buckets</PageSectionTitle>
<PageSectionDescription>
Write policies for each bucket to control access to the bucket and its contents
</PageSectionDescription>
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent>
{buckets.length === 0 && <StoragePoliciesPlaceholder />}
{buckets.length > 0 && (
<div className="mb-4">
<Input
size="tiny"
placeholder="Filter buckets"
className="block"
containerClassName="w-full lg:w-52"
value={searchString || ''}
onChange={(e) => {
const str = e.target.value
setSearchString(str)
}}
icon={<Search />}
actions={
searchString ? (
<Button
size="tiny"
type="text"
className="p-0 h-5 w-5"
icon={<X />}
onClick={() => setSearchString('')}
/>
) : null
}
/>
</div>
)}
{searchString.length > 0 && filteredBucketsWithPolicies.length === 0 && (
<NoSearchResults
searchString={searchString}
onResetFilter={() => setSearchString('')}
/>
)}
{/* Sections for policies grouped by buckets */}
<div className="flex flex-col gap-y-4">
{filteredBucketsWithPolicies.map(({ bucket, policies }) => {
return (
<StoragePoliciesBucketRow
key={bucket.name}
table="objects"
label={bucket.name}
bucket={bucket}
policies={policies}
onSelectPolicyAdd={onSelectPolicyAdd}
onSelectPolicyEdit={onSelectPolicyEdit}
onSelectPolicyDelete={onSelectPolicyDelete}
/>
)
})}
</div>
</PageSectionContent>
</PageSection>
<BucketsPolicies
buckets={bucketsWithPolicies}
search={searchString}
debouncedSearch={debouncedSearchString}
setSearch={setSearchString}
actions={{
addPolicy: onSelectPolicyAdd,
editPolicy: onSelectPolicyEdit,
deletePolicy: onSelectPolicyDelete,
}}
pagination={{
hasNextPage,
isFetchingNextPage,
fetchNextPage,
}}
/>
<PageSection>
<PageSectionMeta>
@@ -5,6 +5,7 @@ import { PolicyRow } from 'components/interfaces/Auth/Policies/PolicyTableRow/Po
import { Bucket } from 'data/storage/buckets-query'
import { FilesBucket as FilesBucketIcon } from 'icons'
import { forwardRef, type CSSProperties } from 'react'
import {
Badge,
Button,
@@ -24,64 +25,72 @@ interface StoragePoliciesBucketRowProps {
label: string
bucket?: Bucket
policies: PostgresPolicy[]
style?: CSSProperties
onSelectPolicyAdd: (bucketName: string | undefined, table: string) => void
onSelectPolicyEdit: (policy: PostgresPolicy, bucketName: string, table: string) => void
onSelectPolicyDelete: (policy: PostgresPolicy) => void
}
export const StoragePoliciesBucketRow = ({
table = '',
label = '',
bucket,
policies = [],
onSelectPolicyAdd = noop,
onSelectPolicyEdit = noop,
onSelectPolicyDelete = noop,
}: StoragePoliciesBucketRowProps) => {
return (
<Card>
<CardHeader className="flex flex-row w-full items-center justify-between gap-2 space-y-0">
<div className="flex flex-1 min-w-0 items-center gap-3">
<FilesBucketIcon className="text-foreground-muted" size={16} strokeWidth={1.5} />
<div className="flex flex-1 min-w-0 items-center gap-1.5">
<CardTitle className="truncate">{label}</CardTitle>
{bucket?.public && <Badge variant="warning">Public</Badge>}
export const StoragePoliciesBucketRow = forwardRef<HTMLDivElement, StoragePoliciesBucketRowProps>(
(
{
table = '',
label = '',
bucket,
policies = [],
style,
onSelectPolicyAdd = noop,
onSelectPolicyEdit = noop,
onSelectPolicyDelete = noop,
},
ref
) => {
return (
<Card ref={ref} style={style}>
<CardHeader className="flex flex-row w-full items-center justify-between gap-2 space-y-0">
<div className="flex flex-1 min-w-0 items-center gap-3">
<FilesBucketIcon className="text-foreground-muted" size={16} strokeWidth={1.5} />
<div className="flex flex-1 min-w-0 items-center gap-1.5">
<CardTitle className="truncate">{label}</CardTitle>
{bucket?.public && <Badge variant="warning">Public</Badge>}
</div>
</div>
</div>
<Button type="outline" onClick={() => onSelectPolicyAdd(bucket?.name, table)}>
New policy
</Button>
</CardHeader>
{policies.length === 0 ? (
<CardContent>
<p className="text-sm text-foreground-lighter">No policies created yet</p>
</CardContent>
) : (
<CardContent className="p-0">
<Table className="table-fixed">
<TableHeader>
<TableRow>
<TableHead className="w-[40%]">Name</TableHead>
<TableHead className="w-[20%]">Command</TableHead>
<TableHead className="w-[30%]">Applied to</TableHead>
<TableHead className="w-0 text-right">
<span className="sr-only">Actions</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{policies.map((policy) => (
<PolicyRow
key={policy.id ?? policy.name}
policy={policy}
onSelectEditPolicy={(p) => onSelectPolicyEdit(p, bucket?.name ?? '', table)}
onSelectDeletePolicy={onSelectPolicyDelete}
/>
))}
</TableBody>
</Table>
</CardContent>
)}
</Card>
)
}
<Button type="outline" onClick={() => onSelectPolicyAdd(bucket?.name, table)}>
New policy
</Button>
</CardHeader>
{policies.length === 0 ? (
<CardContent>
<p className="text-sm text-foreground-lighter">No policies created yet</p>
</CardContent>
) : (
<CardContent className="p-0">
<Table className="table-fixed">
<TableHeader>
<TableRow>
<TableHead className="w-[40%]">Name</TableHead>
<TableHead className="w-[20%]">Command</TableHead>
<TableHead className="w-[30%]">Applied to</TableHead>
<TableHead className="w-0 text-right">
<span className="sr-only">Actions</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{policies.map((policy) => (
<PolicyRow
key={policy.id ?? policy.name}
policy={policy}
onSelectEditPolicy={(p) => onSelectPolicyEdit(p, bucket?.name ?? '', table)}
onSelectDeletePolicy={onSelectPolicyDelete}
/>
))}
</TableBody>
</Table>
</CardContent>
)}
</Card>
)
}
)
StoragePoliciesBucketRow.displayName = 'StoragePoliciesBucketRow'
@@ -0,0 +1,234 @@
import { PostgresPolicy } from '@supabase/postgres-meta'
import { useVirtualizer } from '@tanstack/react-virtual'
import { ChevronUp, Search, X } from 'lucide-react'
import { forwardRef, useEffect, useState, type HTMLAttributes, type ReactNode } from 'react'
import { useMainScrollContainer } from 'components/layouts/MainScrollContainerContext'
import { NoSearchResults } from 'components/ui/NoSearchResults'
import { type Bucket } from 'data/storage/buckets-query'
import { useStaticEffectEvent } from 'hooks/useStaticEffectEvent'
import {
Button,
cn,
Collapsible_Shadcn_,
CollapsibleContent_Shadcn_,
CollapsibleTrigger_Shadcn_,
} from 'ui'
import { ShimmeringLoader } from 'ui-patterns'
import { Input } from 'ui-patterns/DataInputs/Input'
import {
PageSection,
PageSectionContent,
PageSectionDescription,
PageSectionMeta,
PageSectionSummary,
PageSectionTitle,
} from 'ui-patterns/PageSection'
import { StoragePoliciesBucketRow } from './StoragePoliciesBucketRow'
import StoragePoliciesPlaceholder from './StoragePoliciesPlaceholder'
export type SelectBucketPolicyForAction = {
addPolicy: (bucketName?: string, table?: string) => void
editPolicy: (policy: PostgresPolicy, bucketName?: string, table?: string) => void
deletePolicy: (policy: PostgresPolicy) => void
}
type BucketsPoliciesProps = {
buckets: { bucket: Bucket; policies: PostgresPolicy[] }[]
search?: string
debouncedSearch?: string
setSearch: (search: string) => void
actions: SelectBucketPolicyForAction
pagination: {
hasNextPage: boolean
isFetchingNextPage: boolean
fetchNextPage: () => void
}
}
export const BucketsPolicies = ({
buckets,
search,
debouncedSearch,
setSearch,
actions,
pagination,
}: BucketsPoliciesProps): ReactNode => {
const [expanded, setExpanded] = useState(true)
const showEmptyState = buckets.length === 0 && (!debouncedSearch || debouncedSearch.length === 0)
return (
<PageSection>
<Collapsible_Shadcn_ open={expanded} onOpenChange={setExpanded}>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Buckets</PageSectionTitle>
<PageSectionDescription>
Write policies for each bucket to control access to the bucket and its contents
</PageSectionDescription>
</PageSectionSummary>
<CollapsibleTrigger_Shadcn_ asChild>
<button>
<span className="sr-only">Toggle bucket list</span>
<ChevronUp
size={14}
className={cn(
!expanded && 'rotate-180',
'transition',
'text-foreground-light hover:text-foreground'
)}
/>
</button>
</CollapsibleTrigger_Shadcn_>
</PageSectionMeta>
<CollapsibleContent_Shadcn_>
<PageSectionContent className="mt-6">
{showEmptyState && <StoragePoliciesPlaceholder />}
{buckets.length > 0 && (
<div className="mb-4">
<Input
size="tiny"
placeholder="Filter buckets"
className="block"
containerClassName="w-full lg:w-52"
value={search || ''}
onChange={(e) => {
const str = e.target.value
setSearch(str)
}}
icon={<Search />}
actions={
search ? (
<Button
size="tiny"
type="text"
className="p-0 h-5 w-5"
icon={<X />}
onClick={() => setSearch('')}
/>
) : null
}
/>
</div>
)}
{!!search && search.length > 0 && buckets.length === 0 && (
<NoSearchResults searchString={search} onResetFilter={() => setSearch('')} />
)}
<BucketsPoliciesVirtualizedList
items={buckets}
actions={actions}
pagination={pagination}
/>
</PageSectionContent>
</CollapsibleContent_Shadcn_>
</Collapsible_Shadcn_>
</PageSection>
)
}
type BucketsPoliciesVirtualizedListProps = {
items: { bucket: Bucket; policies: PostgresPolicy[] }[]
actions: SelectBucketPolicyForAction
pagination: BucketsPoliciesProps['pagination']
}
const BucketsPoliciesVirtualizedList = ({
items,
actions,
pagination,
}: BucketsPoliciesVirtualizedListProps) => {
const { hasNextPage, isFetchingNextPage, fetchNextPage } = pagination
const itemCount = hasNextPage ? items.length + 1 : items.length
const scrollElement = useMainScrollContainer()
const virtualizer = useVirtualizer({
count: itemCount,
estimateSize: () => 129,
overscan: 5,
getItemKey: (index) => items[index]?.bucket.name ?? `bucket-${index}`,
getScrollElement: () => scrollElement,
})
const virtualItems = virtualizer.getVirtualItems()
const lastItem = virtualItems[virtualItems.length - 1]
const fetchNext = useStaticEffectEvent(() => {
if (lastItem && lastItem.index >= items.length - 1 && hasNextPage && !isFetchingNextPage) {
fetchNextPage()
}
})
useEffect(fetchNext, [lastItem, fetchNext])
return (
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualItems.map((virtualRow) => {
const isLoaderRow = virtualRow.index > items.length - 1
const commonStyle = {
position: 'absolute' as const,
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualRow.start}px)`,
}
if (isLoaderRow) {
return (
<BucketsPoliciesLoader
key={`loader-${virtualRow.index}`}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
className="pb-4"
style={commonStyle}
/>
)
}
const item = items[virtualRow.index]
if (!item) return null
return (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
className="pb-4"
style={commonStyle}
>
<StoragePoliciesBucketRow
table="objects"
label={item.bucket.name}
bucket={item.bucket}
policies={item.policies}
onSelectPolicyAdd={actions.addPolicy}
onSelectPolicyEdit={actions.editPolicy}
onSelectPolicyDelete={actions.deletePolicy}
/>
</div>
)
})}
</div>
)
}
type BucketsPoliciesLoaderProps = HTMLAttributes<HTMLDivElement>
const BucketsPoliciesLoader = forwardRef<HTMLDivElement, BucketsPoliciesLoaderProps>(
(props: BucketsPoliciesLoaderProps, ref) => (
<div ref={ref} {...props}>
<p className="sr-only">Loading more...</p>
<ShimmeringLoader />
</div>
)
)
BucketsPoliciesLoader.displayName = 'BucketsPoliciesLoader'
@@ -0,0 +1,28 @@
import { createContext, useContext, useState, type ReactNode } from 'react'
const MainScrollContainerContext = createContext<HTMLElement | null>(null)
const SetMainScrollContainerContext = createContext<
React.Dispatch<React.SetStateAction<HTMLElement | null>>
>(() => {})
export const MainScrollContainerProvider = ({ children }: { children: ReactNode }) => {
const [container, setContainer] = useState<HTMLElement | null>(null)
return (
<MainScrollContainerContext.Provider value={container}>
<SetMainScrollContainerContext.Provider value={setContainer}>
{children}
</SetMainScrollContainerContext.Provider>
</MainScrollContainerContext.Provider>
)
}
export const useSetMainScrollContainer = () => {
const context = useContext(SetMainScrollContainerContext)
return context
}
export const useMainScrollContainer = () => {
const context = useContext(MainScrollContainerContext)
return context
}
@@ -2,7 +2,7 @@ import Head from 'next/head'
import { useRouter } from 'next/router'
import { forwardRef, Fragment, PropsWithChildren, ReactNode, useEffect } from 'react'
import { useFlag, useParams } from 'common'
import { mergeRefs, useFlag, useParams } from 'common'
import { CreateBranchModal } from 'components/interfaces/BranchManagement/CreateBranchModal'
import { ProjectAPIDocs } from 'components/interfaces/ProjectAPIDocs/ProjectAPIDocs'
import { ResourceExhaustionWarningBanner } from 'components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner'
@@ -17,6 +17,7 @@ import { useDatabaseSelectorStateSnapshot } from 'state/database-selector'
import { cn, LogoLoader, ResizableHandle, ResizablePanel, ResizablePanelGroup } from 'ui'
import MobileSheetNav from 'ui-patterns/MobileSheetNav/MobileSheetNav'
import { useEditorType } from '../editors/EditorsLayout.hooks'
import { useSetMainScrollContainer } from '../MainScrollContainerContext'
import BuildingState from './BuildingState'
import ConnectingState from './ConnectingState'
import { LoadingState } from './LoadingState'
@@ -87,6 +88,9 @@ export const ProjectLayout = forwardRef<HTMLDivElement, PropsWithChildren<Projec
const { data: selectedProject } = useSelectedProjectQuery()
const { mobileMenuOpen, showSidebar, setMobileMenuOpen } = useAppStateSnapshot()
const setMainScrollContainer = useSetMainScrollContainer()
const combinedRef = mergeRefs(ref, setMainScrollContainer)
const { appTitle } = useCustomContent(['app:title'])
const titleSuffix = appTitle || 'Supabase'
@@ -189,7 +193,7 @@ export const ProjectLayout = forwardRef<HTMLDivElement, PropsWithChildren<Projec
>
<main
className="h-full flex flex-col flex-1 w-full overflow-y-auto overflow-x-hidden @container"
ref={ref}
ref={combinedRef}
>
{showPausedState ? (
<div className="mx-auto my-16 w-full h-full max-w-7xl flex items-center">
@@ -89,6 +89,7 @@ type InfiniteListWrapperProps<Item, Component extends ElementType = 'div'> = {
items: Item[]
getItemKey?: (index: number) => string
getItemSize: (index: number) => number
gap?: number
hasNextPage?: boolean
isLoadingNextPage?: boolean
onLoadNextPage?: () => void
@@ -100,6 +101,7 @@ export const InfiniteListScrollWrapper = <Item, Wrapper extends ElementType = 'd
items,
getItemKey,
getItemSize,
gap,
hasNextPage = false,
isLoadingNextPage = false,
onLoadNextPage = () => {},
@@ -114,6 +116,7 @@ export const InfiniteListScrollWrapper = <Item, Wrapper extends ElementType = 'd
getItemKey,
estimateSize: getItemSize,
overscan: 5,
gap,
})
const virtualItems = rowVirtualizer.getVirtualItems()
@@ -304,6 +307,7 @@ type InfiniteListDefaultProps<Item, ItemComponentProps extends object = Record<s
itemProps?: ItemComponentProps
getItemKey?: (index: number) => string
getItemSize: (index: number) => number
gap?: number
hasNextPage?: boolean
isLoadingNextPage?: boolean
onLoadNextPage?: () => void
@@ -320,6 +324,7 @@ export const InfiniteListDefault = <
itemProps,
getItemKey,
getItemSize,
gap,
hasNextPage = false,
isLoadingNextPage = false,
onLoadNextPage = () => {},
@@ -332,6 +337,7 @@ export const InfiniteListDefault = <
items={items}
getItemKey={getItemKey}
getItemSize={getItemSize}
gap={gap}
hasNextPage={hasNextPage}
isLoadingNextPage={isLoadingNextPage}
onLoadNextPage={onLoadNextPage}
@@ -0,0 +1,16 @@
import { useState } from 'react'
import { useDebounce } from 'react-use'
export const useDebouncedValue = (value: string, delay: number) => {
const [debouncedValue, setDebouncedValue] = useState(value)
useDebounce(
() => {
setDebouncedValue(value)
},
delay,
[value]
)
return debouncedValue
}
+13
View File
@@ -353,4 +353,17 @@ export const cleanPointerEventsNoneOnBody = (timeoutMs: number = 300) => {
}
}
export const createWrappedSymbol = (name: string, display: string): Symbol => {
const sym = Symbol(name)
const wrapper = Object(sym)
wrapper.toString = () => display
Object.freeze(wrapper)
return wrapper
}
// Intentional for generic use; does not affect type safety since this branch is
// unreachable.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function neverGuard(_: never): any {}
+4 -1
View File
@@ -48,6 +48,7 @@ import { FeaturePreviewContextProvider } from 'components/interfaces/App/Feature
import FeaturePreviewModal from 'components/interfaces/App/FeaturePreview/FeaturePreviewModal'
import { MonacoThemeProvider } from 'components/interfaces/App/MonacoThemeProvider'
import { RouteValidationWrapper } from 'components/interfaces/App/RouteValidationWrapper'
import { MainScrollContainerProvider } from 'components/layouts/MainScrollContainerContext'
import { GlobalErrorBoundaryState } from 'components/ui/ErrorBoundary/GlobalErrorBoundaryState'
import { useRootQueryClient } from 'data/query-client'
import { customFont, sourceCodePro } from 'fonts'
@@ -160,7 +161,9 @@ function CustomApp({ Component, pageProps }: AppPropsWithLayout) {
<AppBannerContextProvider>
<CommandProvider>
<FeaturePreviewContextProvider>
{getLayout(<Component {...pageProps} />)}
<MainScrollContainerProvider>
{getLayout(<Component {...pageProps} />)}
</MainScrollContainerProvider>
<StudioCommandMenu />
<FeaturePreviewModal />
</FeaturePreviewContextProvider>
@@ -1,4 +1,4 @@
import type { CSSProperties } from 'react'
import { forwardRef, type CSSProperties } from 'react'
import { Card, cn, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'ui'
export interface ShimmeringLoader {
@@ -8,23 +8,22 @@ export interface ShimmeringLoader {
animationDelay?: number
}
export const ShimmeringLoader = ({
className,
style,
delayIndex = 0,
animationDelay = 150,
}: ShimmeringLoader) => {
return (
<div
className={cn('shimmering-loader rounded py-3', className)}
style={{
...style,
animationFillMode: 'backwards',
animationDelay: `${delayIndex * animationDelay}ms`,
}}
/>
)
}
export const ShimmeringLoader = forwardRef<HTMLDivElement, ShimmeringLoader>(
({ className, style, delayIndex = 0, animationDelay = 150 }, ref) => {
return (
<div
ref={ref}
className={cn('shimmering-loader rounded py-3', className)}
style={{
...style,
animationFillMode: 'backwards',
animationDelay: `${delayIndex * animationDelay}ms`,
}}
/>
)
}
)
ShimmeringLoader.displayName = 'ShimmeringLoader'
interface GenericSkeletonLoaderProps {
className?: string