mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
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>
|
||||
|
||||
+64
-55
@@ -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'
|
||||
+234
@@ -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
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user