mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
feat(studio): list existing analytics buckets (#39500)
* specialized dialog for analytics (and vector) bucket creation * handle missing wrapper extension and iceberg wrapper integration * bucket creation improvements * extension and integration checks * dialog header consistency * simplify bucket dialog creation button trigger * fix subtitle ternary * example bucket without dynamic route * create dynamic segment * fancy connection table * improve bucket page * connection table polish * polish * delete bucket modal * improve missing installations design * new table modal * fix copy icon * copy all env button placeholder * list all analytics buckets * specialised bucket modal * shared installer hooks and components * make iceberg wrapper not dependent on wrapper extension There are cases where the Iceberg Wrapper can be installed but the Wrapper extension has since been removed * better admonition copywriting * todo * remove later features * more removal of later work * remove old comment * remove unused content * Nits and hook up create analytics bucket logic * Nit --------- Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
1 parent
6617418e1a
commit
bf3f26d0a6
15 files changed
+560
-44
No files matched your search
@@ -80,7 +80,7 @@ export const AnalyticBucketDetails = ({ bucket }: { bucket: Bucket }) => {
|
||||
.find((w) => w.name === snakeCase(`${bucket.name}_fdw`))
|
||||
}, [data, bucket.name])
|
||||
|
||||
const extensionState = useIcebergWrapperExtension()
|
||||
const { state: extensionState } = useIcebergWrapperExtension()
|
||||
|
||||
const integration = INTEGRATIONS.find((i) => i.id === 'iceberg_wrapper' && i.type === 'wrapper')
|
||||
|
||||
|
||||
+4
-4
@@ -13,7 +13,7 @@ export const useIcebergWrapperExtension = () => {
|
||||
|
||||
if (!integration || integration.type !== 'wrapper') {
|
||||
// This should never happen
|
||||
return 'not-found'
|
||||
return { extension: undefined, state: 'not-found' }
|
||||
}
|
||||
|
||||
const wrapperMeta = integration.meta
|
||||
@@ -23,13 +23,13 @@ export const useIcebergWrapperExtension = () => {
|
||||
const hasRequiredVersion =
|
||||
(wrappersExtension?.installed_version ?? '') >= (wrapperMeta?.minimumExtensionVersion ?? '')
|
||||
|
||||
const state = isExtensionsLoading
|
||||
const state: 'loading' | 'installed' | 'needs-upgrade' | 'not-installed' = isExtensionsLoading
|
||||
? 'loading'
|
||||
: isWrappersExtensionInstalled
|
||||
? hasRequiredVersion
|
||||
? 'installed'
|
||||
: 'needs-upgrade'
|
||||
: ('not-installed' as const)
|
||||
: 'not-installed'
|
||||
|
||||
return state
|
||||
return { extension: wrappersExtension, state }
|
||||
}
|
||||
@@ -1,6 +1,175 @@
|
||||
import { MoreVertical, Search, Trash2 } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { ScaffoldHeader, ScaffoldSection, ScaffoldSectionTitle } from 'components/layouts/Scaffold'
|
||||
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
|
||||
import { Bucket, useBucketsQuery } from 'data/storage/buckets-query'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from 'ui'
|
||||
import { TimestampInfo } from 'ui-patterns'
|
||||
import { Input } from 'ui-patterns/DataInputs/Input'
|
||||
import { CreateSpecializedBucketModal } from './CreateSpecializedBucketModal'
|
||||
import { DeleteBucketModal } from './DeleteBucketModal'
|
||||
import { EmptyBucketState } from './EmptyBucketState'
|
||||
|
||||
export const AnalyticsBuckets = () => {
|
||||
// Placeholder component - will be implemented in a later PR
|
||||
return <EmptyBucketState bucketType="analytics" />
|
||||
const router = useRouter()
|
||||
const { ref } = useParams()
|
||||
|
||||
const [modal, setModal] = useState<'edit' | 'empty' | 'delete' | null>(null)
|
||||
const [selectedBucket, setSelectedBucket] = useState<Bucket>()
|
||||
const [filterString, setFilterString] = useState('')
|
||||
|
||||
const { data: buckets = [], isLoading: isLoadingBuckets } = useBucketsQuery({ projectRef: ref })
|
||||
|
||||
const analyticsBuckets = buckets
|
||||
.filter((bucket) => !('type' in bucket) || bucket.type === 'ANALYTICS')
|
||||
.filter((bucket) =>
|
||||
filterString.length === 0
|
||||
? true
|
||||
: bucket.name.toLowerCase().includes(filterString.toLowerCase())
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isLoadingBuckets &&
|
||||
buckets.filter((bucket) => !('type' in bucket) || bucket.type === 'ANALYTICS').length ===
|
||||
0 ? (
|
||||
<EmptyBucketState bucketType="analytics" />
|
||||
) : (
|
||||
// Override the default first:pt-12 to match other storage types
|
||||
<ScaffoldSection isFullWidth className="gap-y-4 first:pt-8">
|
||||
<ScaffoldHeader className="py-0">
|
||||
<ScaffoldSectionTitle>Buckets</ScaffoldSectionTitle>
|
||||
</ScaffoldHeader>
|
||||
<div className="flex flex-grow justify-between gap-x-2 items-center">
|
||||
<Input
|
||||
size="tiny"
|
||||
className="flex-grow lg:flex-grow-0 w-52"
|
||||
placeholder="Search for a bucket"
|
||||
value={filterString}
|
||||
onChange={(e) => setFilterString(e.target.value)}
|
||||
icon={<Search size={12} />}
|
||||
/>
|
||||
<CreateSpecializedBucketModal
|
||||
buttonType="primary"
|
||||
buttonClassName="w-fit"
|
||||
bucketType="analytics"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLoadingBuckets ? (
|
||||
<GenericSkeletonLoader />
|
||||
) : (
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Created at</TableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{analyticsBuckets.length === 0 && filterString.length > 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3}>
|
||||
<p className="text-sm text-foreground">No results found</p>
|
||||
<p className="text-sm text-foreground-light">
|
||||
Your search for "{filterString}" did not return any results
|
||||
</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{analyticsBuckets.map((bucket) => (
|
||||
<TableRow
|
||||
key={bucket.id}
|
||||
className="cursor-pointer"
|
||||
onClick={(event) => {
|
||||
const url = `/project/${ref}/storage/analytics/buckets/${bucket.id}`
|
||||
if (event.metaKey) window.open(url, '_blank')
|
||||
else router.push(url)
|
||||
}}
|
||||
>
|
||||
<TableCell>
|
||||
<p className="text-foreground">{bucket.name}</p>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<p className="text-foreground-light">
|
||||
<TimestampInfo
|
||||
utcTimestamp={bucket.created_at}
|
||||
className="text-sm text-foreground-light"
|
||||
/>
|
||||
</p>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button asChild type="default">
|
||||
<Link
|
||||
href={`/project/${ref}/storage/analytics/buckets/${encodeURIComponent(bucket.id)}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
View contents
|
||||
</Link>
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="default"
|
||||
className="px-1"
|
||||
icon={<MoreVertical />}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" className="w-40">
|
||||
<DropdownMenuItem
|
||||
className="flex items-center space-x-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setModal('delete')
|
||||
setSelectedBucket(bucket)
|
||||
}}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
<p>Delete bucket</p>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
)}
|
||||
</ScaffoldSection>
|
||||
)}
|
||||
|
||||
{selectedBucket && (
|
||||
<DeleteBucketModal
|
||||
visible={modal === `delete`}
|
||||
bucket={selectedBucket}
|
||||
onClose={() => setModal(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -53,6 +53,7 @@ import { Admonition } from 'ui-patterns/admonition'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import { useIsNewStorageUIEnabled } from '../App/FeaturePreview/FeaturePreviewContext'
|
||||
import { inverseValidBucketNameRegex, validBucketNameRegex } from './CreateBucketModal.utils'
|
||||
import { BUCKET_TYPES } from './Storage.constants'
|
||||
import { convertFromBytes, convertToBytes } from './StorageSettings/StorageSettings.utils'
|
||||
|
||||
const FormSchema = z
|
||||
@@ -131,6 +132,8 @@ export const CreateBucketModal = ({
|
||||
const { value, unit } = convertFromBytes(data?.fileSizeLimit ?? 0)
|
||||
const formattedGlobalUploadLimit = `${value} ${unit}`
|
||||
|
||||
const config = BUCKET_TYPES['files']
|
||||
|
||||
const form = useForm<CreateBucketForm>({
|
||||
resolver: zodResolver(FormSchema),
|
||||
defaultValues: {
|
||||
@@ -149,7 +152,7 @@ export const CreateBucketModal = ({
|
||||
const isStandardBucket = form.watch('type') === 'STANDARD'
|
||||
const hasFileSizeLimit = form.watch('has_file_size_limit')
|
||||
const [hasAllowedMimeTypes, setHasAllowedMimeTypes] = useState(false)
|
||||
const icebergWrapperExtensionState = useIcebergWrapperExtension()
|
||||
const { state: icebergWrapperExtensionState } = useIcebergWrapperExtension()
|
||||
const icebergCatalogEnabled = data?.features?.icebergCatalog?.enabled
|
||||
|
||||
const onSubmit: SubmitHandler<CreateBucketForm> = async (values) => {
|
||||
@@ -264,7 +267,7 @@ export const CreateBucketModal = ({
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create storage bucket</DialogTitle>
|
||||
<DialogTitle>Create a {isStorageV2 ? config.singularName : 'storage'} bucket</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogSectionSeparator />
|
||||
@@ -279,8 +282,8 @@ export const CreateBucketModal = ({
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
name="name"
|
||||
label="Name of bucket"
|
||||
labelOptional="Buckets cannot be renamed once created."
|
||||
label="Bucket name"
|
||||
labelOptional="Cannot be changed after creation"
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useState } from 'react'
|
||||
import { SubmitHandler, useForm } from 'react-hook-form'
|
||||
import { toast } from 'sonner'
|
||||
import z from 'zod'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import { InlineLink } from 'components/ui/InlineLink'
|
||||
import { useProjectStorageConfigQuery } from 'data/config/project-storage-config-query'
|
||||
import { useDatabaseExtensionEnableMutation } from 'data/database-extensions/database-extension-enable-mutation'
|
||||
import { useBucketCreateMutation } from 'data/storage/bucket-create-mutation'
|
||||
import { useIcebergWrapperCreateMutation } from 'data/storage/iceberg-wrapper-create-mutation'
|
||||
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
|
||||
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { DOCS_URL, IS_PLATFORM } from 'lib/constants'
|
||||
import {
|
||||
Button,
|
||||
cn,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogSection,
|
||||
DialogSectionSeparator,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
Form_Shadcn_,
|
||||
FormControl_Shadcn_,
|
||||
FormField_Shadcn_,
|
||||
Input_Shadcn_,
|
||||
} from 'ui'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import { useIcebergWrapperExtension } from './AnalyticBucketDetails/useIcebergWrapper'
|
||||
import { inverseValidBucketNameRegex, validBucketNameRegex } from './CreateBucketModal.utils'
|
||||
import { BUCKET_TYPES } from './Storage.constants'
|
||||
|
||||
const FormSchema = z
|
||||
.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'Please provide a name for your bucket')
|
||||
.max(100, 'Bucket name should be below 100 characters')
|
||||
.refine(
|
||||
(value) => !value.endsWith(' '),
|
||||
'The name of the bucket cannot end with a whitespace'
|
||||
)
|
||||
.refine(
|
||||
(value) => value !== 'public',
|
||||
'"public" is a reserved name. Please choose another name'
|
||||
),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (!validBucketNameRegex.test(data.name)) {
|
||||
const [match] = data.name.match(inverseValidBucketNameRegex) ?? []
|
||||
ctx.addIssue({
|
||||
path: ['name'],
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: !!match
|
||||
? `Bucket name cannot contain the "${match}" character`
|
||||
: 'Bucket name contains an invalid special character',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const formId = 'create-specialized-storage-bucket-form'
|
||||
|
||||
export type CreateSpecializedBucketForm = z.infer<typeof FormSchema>
|
||||
|
||||
interface CreateSpecializedBucketModalProps {
|
||||
bucketType: 'analytics' | 'vectors'
|
||||
buttonSize?: 'tiny' | 'small'
|
||||
buttonType?: 'default' | 'primary'
|
||||
buttonClassName?: string
|
||||
disabled?: boolean
|
||||
tooltip?: {
|
||||
content: {
|
||||
side?: 'top' | 'bottom' | 'left' | 'right'
|
||||
text?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const CreateSpecializedBucketModal = ({
|
||||
bucketType,
|
||||
buttonSize = 'tiny',
|
||||
buttonType = 'default',
|
||||
buttonClassName,
|
||||
disabled = false,
|
||||
tooltip,
|
||||
}: CreateSpecializedBucketModalProps) => {
|
||||
const router = useRouter()
|
||||
const { ref } = useParams()
|
||||
const { data: org } = useSelectedOrganizationQuery()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const { can: canCreateBuckets } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*')
|
||||
const { extension: wrappersExtension, state: wrappersExtensionState } =
|
||||
useIcebergWrapperExtension()
|
||||
|
||||
const [visible, setVisible] = useState(false)
|
||||
|
||||
const { data } = useProjectStorageConfigQuery({ projectRef: ref }, { enabled: IS_PLATFORM })
|
||||
const icebergCatalogEnabled = data?.features?.icebergCatalog?.enabled
|
||||
|
||||
const { mutate: sendEvent } = useSendEventMutation()
|
||||
const { mutateAsync: createBucket, isLoading: isCreatingBucket } = useBucketCreateMutation({
|
||||
// [Joshen] Silencing the error here as it's being handled in onSubmit
|
||||
onError: () => {},
|
||||
})
|
||||
const { mutateAsync: createIcebergWrapper, isLoading: isCreatingIcebergWrapper } =
|
||||
useIcebergWrapperCreateMutation()
|
||||
const { mutateAsync: enableExtension, isLoading: isEnablingExtension } =
|
||||
useDatabaseExtensionEnableMutation()
|
||||
|
||||
const config = BUCKET_TYPES['analytics']
|
||||
const isCreating = isCreatingBucket || isEnablingExtension || isCreatingIcebergWrapper
|
||||
|
||||
const form = useForm<CreateSpecializedBucketForm>({
|
||||
resolver: zodResolver(FormSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit: SubmitHandler<CreateSpecializedBucketForm> = async (values) => {
|
||||
if (!ref) return console.error('Project ref is required')
|
||||
if (!project) return console.error('Project details is required')
|
||||
if (!wrappersExtension) return console.error('Unable to find wrappers extension')
|
||||
|
||||
if (wrappersExtensionState === 'needs-upgrade') {
|
||||
// [Joshen] Double check if this is the right CTA
|
||||
return toast.error(
|
||||
<p className="prose max-w-full text-sm">
|
||||
Wrappers extensions needs to be updated to create an Iceberg Wrapper. Update the extension
|
||||
by disabling and enabling the <code className="text-xs">wrappers</code> extension first in
|
||||
the{' '}
|
||||
<InlineLink href={`/project/${ref}/database/extensions?filter=wrappers`}>
|
||||
database extensions page
|
||||
</InlineLink>{' '}
|
||||
before creating an Analytics bucket.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
// Determine bucket type based on the bucketType prop
|
||||
// [Danny] Change STANDARD to VECTORS when ready
|
||||
const bucketTypeValue = bucketType === 'analytics' ? 'ANALYTICS' : 'STANDARD'
|
||||
|
||||
try {
|
||||
await createBucket({
|
||||
projectRef: ref,
|
||||
id: values.name,
|
||||
type: bucketTypeValue,
|
||||
isPublic: false, // Specialized buckets are not public by default
|
||||
file_size_limit: undefined, // Specialized buckets do not have a file size limit
|
||||
allowed_mime_types: undefined, // Specialized buckets do not have allowed MIME types
|
||||
})
|
||||
|
||||
sendEvent({
|
||||
action: 'storage_bucket_created',
|
||||
properties: { bucketType: bucketTypeValue },
|
||||
groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' },
|
||||
})
|
||||
|
||||
if (wrappersExtensionState === 'not-installed') {
|
||||
await enableExtension({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
name: wrappersExtension.name,
|
||||
schema: wrappersExtension.schema ?? 'extensions',
|
||||
version: wrappersExtension.default_version,
|
||||
})
|
||||
await createIcebergWrapper({ bucketName: values.name })
|
||||
} else if (wrappersExtensionState === 'installed') {
|
||||
await createIcebergWrapper({ bucketName: values.name })
|
||||
}
|
||||
|
||||
toast.success(`Created bucket “${values.name}”`)
|
||||
form.reset()
|
||||
setVisible(false)
|
||||
router.push(`/project/${ref}/storage/${bucketType}/${values.name}`)
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to create bucket: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
form.reset()
|
||||
setVisible(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={visible}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) handleClose()
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<ButtonTooltip
|
||||
block
|
||||
size={buttonSize}
|
||||
type={buttonType}
|
||||
className={buttonClassName}
|
||||
icon={<Plus size={14} />}
|
||||
disabled={!canCreateBuckets || !icebergCatalogEnabled || disabled}
|
||||
style={{ justifyContent: 'start' }}
|
||||
onClick={() => setVisible(true)}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: tooltip?.content?.side || 'bottom',
|
||||
className: cn(!icebergCatalogEnabled ? 'w-72 text-center' : ''),
|
||||
text: !icebergCatalogEnabled
|
||||
? 'Analytics buckets are not enabled for your project. Please contact support to enable it.'
|
||||
: !canCreateBuckets
|
||||
? 'You need additional permissions to create buckets'
|
||||
: tooltip?.content?.text,
|
||||
},
|
||||
}}
|
||||
>
|
||||
New bucket
|
||||
</ButtonTooltip>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create {config.singularName} bucket</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogSectionSeparator />
|
||||
|
||||
<Form_Shadcn_ {...form}>
|
||||
<form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<DialogSection className="flex flex-col gap-y-4">
|
||||
<FormField_Shadcn_
|
||||
key="name"
|
||||
name="name"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
name="name"
|
||||
label="Bucket name"
|
||||
labelOptional="Cannot be changed after creation"
|
||||
description="Must be between 3–63 characters. Only lowercase letters, numbers, dots, and hyphens are allowed."
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_
|
||||
id="name"
|
||||
data-1p-ignore
|
||||
data-lpignore="true"
|
||||
data-form-type="other"
|
||||
data-bwignore
|
||||
{...field}
|
||||
placeholder="Enter bucket name"
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
|
||||
{bucketType === 'analytics' && (
|
||||
<Admonition type="default">
|
||||
<p>
|
||||
Supabase will install the{' '}
|
||||
{wrappersExtensionState !== 'installed' ? 'Wrappers extension and ' : ''}
|
||||
Iceberg Wrapper integration on your behalf.{' '}
|
||||
<InlineLink href={`${DOCS_URL}/guides/database/extensions/wrappers/iceberg`}>
|
||||
Learn more
|
||||
</InlineLink>
|
||||
.
|
||||
</p>
|
||||
</Admonition>
|
||||
)}
|
||||
</DialogSection>
|
||||
</form>
|
||||
</Form_Shadcn_>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="default" disabled={isCreating} onClick={() => setVisible(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button form={formId} htmlType="submit" loading={isCreating} disabled={isCreating}>
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -156,7 +156,7 @@ export const DeleteBucketModal = ({ visible, bucket, onClose }: DeleteBucketModa
|
||||
id="confirm"
|
||||
autoComplete="off"
|
||||
{...field}
|
||||
placeholder="Type in name of bucket"
|
||||
placeholder="Type bucket name"
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
|
||||
@@ -213,8 +213,8 @@ export const EditBucketModal = ({ visible, bucket, onClose }: EditBucketModalPro
|
||||
<FormItemLayout
|
||||
hideMessage
|
||||
name="name"
|
||||
label="Name of bucket"
|
||||
labelOptional="Buckets cannot be renamed once created."
|
||||
label="Bucket name"
|
||||
labelOptional="Cannot be changed after creation"
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_ id="name" {...field} disabled />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BucketAdd } from 'icons'
|
||||
import { CreateBucketModal } from './CreateBucketModal'
|
||||
import { CreateSpecializedBucketModal } from './CreateSpecializedBucketModal'
|
||||
import { BUCKET_TYPES } from './Storage.constants'
|
||||
|
||||
interface EmptyBucketStateProps {
|
||||
@@ -10,19 +11,35 @@ export const EmptyBucketState = ({ bucketType }: EmptyBucketStateProps) => {
|
||||
const config = BUCKET_TYPES[bucketType]
|
||||
|
||||
return (
|
||||
<aside className="mt-12 border border-dashed w-full bg-surface-100 rounded-lg px-4 py-10 flex flex-col gap-y-4 items-center text-center gap-1 text-balance">
|
||||
<div className="flex flex-col gap-3 items-center text-center">
|
||||
<BucketAdd size={24} strokeWidth={1.5} className="text-foreground-muted" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3>Create {config.label}</h3>
|
||||
<p className="text-foreground-light text-sm">{config.valueProp}</p>
|
||||
</div>
|
||||
<aside className="mt-12 border border-dashed w-full bg-surface-100 rounded-lg px-4 py-10 flex flex-col gap-y-3 items-center text-center gap-1 text-balance">
|
||||
<BucketAdd size={24} strokeWidth={1.5} className="text-foreground-muted" />
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<h3>
|
||||
Create {config.article} {config.singularName} bucket
|
||||
</h3>
|
||||
<p className="text-foreground-light text-sm">{config.valueProp}</p>
|
||||
</div>
|
||||
|
||||
{/* [Joshen] We can render the individual bucket modals here instead - where each modal has its own trigger */}
|
||||
{/* [Danny] CreateBucketModal needs to be split into CreateFileBucketModal, CreateAnalyticsBucketModal, CreateVectorsBucketModal */}
|
||||
{bucketType === 'files' && (
|
||||
{bucketType === 'files' ? (
|
||||
<CreateBucketModal buttonSize="tiny" buttonType="primary" buttonClassName="w-fit" />
|
||||
) : (
|
||||
<CreateSpecializedBucketModal
|
||||
bucketType={bucketType}
|
||||
buttonSize="tiny"
|
||||
buttonType="primary"
|
||||
buttonClassName="w-fit"
|
||||
disabled={bucketType === 'vectors'}
|
||||
tooltip={
|
||||
bucketType === 'vectors'
|
||||
? {
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: 'Vector buckets are not supported yet',
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
|
||||
@@ -62,6 +62,7 @@ export const FilesBuckets = () => {
|
||||
buckets.filter((bucket) => !('type' in bucket) || bucket.type === 'STANDARD').length === 0 ? (
|
||||
<EmptyBucketState bucketType="files" />
|
||||
) : (
|
||||
// Add !pt-8 to override the default first:pt-12
|
||||
<ScaffoldSection isFullWidth className="gap-y-4">
|
||||
<div className="flex flex-grow justify-between gap-x-2 items-center">
|
||||
<Input
|
||||
|
||||
@@ -65,21 +65,24 @@ export const CONTEXT_MENU_KEYS = {
|
||||
export const BUCKET_TYPES = {
|
||||
files: {
|
||||
displayName: 'Files',
|
||||
label: 'a file bucket',
|
||||
singularName: 'file',
|
||||
article: 'a',
|
||||
description: 'General file storage for most types of digital content.',
|
||||
valueProp: 'Store images, videos, documents, and any other file type.',
|
||||
docsUrl: `${DOCS_URL}/guides/storage/buckets/fundamentals`,
|
||||
},
|
||||
analytics: {
|
||||
displayName: 'Analytics',
|
||||
label: 'an analytics bucket',
|
||||
singularName: 'analytics',
|
||||
article: 'an',
|
||||
description: 'Purpose-built storage for analytical workloads.',
|
||||
valueProp: 'Store large datasets for analytics and reporting.',
|
||||
docsUrl: `${DOCS_URL}/guides/storage/analytics/introduction`,
|
||||
},
|
||||
vectors: {
|
||||
displayName: 'Vectors',
|
||||
label: 'a vector bucket',
|
||||
singularName: 'vector',
|
||||
article: 'a',
|
||||
description: 'Purpose-built storage for vector data.',
|
||||
valueProp: 'Store, index, and query your vector embeddings at scale.',
|
||||
docsUrl: `${DOCS_URL}/guides/storage/vectors`,
|
||||
|
||||
@@ -54,7 +54,7 @@ describe(`CreateBucketModal`, () => {
|
||||
expect(screen.getByRole(`dialog`)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const nameInput = screen.getByLabelText(`Name of bucket`)
|
||||
const nameInput = screen.getByLabelText(`Bucket name`)
|
||||
await userEvent.type(nameInput, `test`)
|
||||
|
||||
const standardOption = screen.getByLabelText(`Standard bucket`)
|
||||
|
||||
@@ -73,7 +73,7 @@ describe(`EditBucketModal`, () => {
|
||||
await userEvent.click(openButton)
|
||||
await screen.findByRole(`dialog`)
|
||||
|
||||
const nameInput = screen.getByLabelText(`Name of bucket`)
|
||||
const nameInput = screen.getByLabelText(`Bucket name`)
|
||||
expect(nameInput).toHaveValue(`test`)
|
||||
expect(nameInput).toBeDisabled()
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useRouter } from 'next/router'
|
||||
import { PropsWithChildren, useEffect } from 'react'
|
||||
import { PropsWithChildren } from 'react'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { useIsNewStorageUIEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
|
||||
import { BUCKET_TYPES } from 'components/interfaces/Storage/Storage.constants'
|
||||
import { useStorageV2Page } from 'components/interfaces/Storage/Storage.utils'
|
||||
import { DocsButton } from 'components/ui/DocsButton'
|
||||
@@ -15,10 +13,7 @@ export const StorageBucketsLayout = ({
|
||||
children,
|
||||
}: PropsWithChildren<{ title?: string; hideSubtitle?: boolean }>) => {
|
||||
const { ref } = useParams()
|
||||
const router = useRouter()
|
||||
const page = useStorageV2Page()
|
||||
const isStorageV2 = useIsNewStorageUIEnabled()
|
||||
|
||||
const config = !!page && page !== 's3' ? BUCKET_TYPES[page] : undefined
|
||||
|
||||
const navigationItems =
|
||||
@@ -39,15 +34,11 @@ export const StorageBucketsLayout = ({
|
||||
]
|
||||
: []
|
||||
|
||||
useEffect(() => {
|
||||
if (!isStorageV2) router.replace(`/project/${ref}/storage/buckets`)
|
||||
}, [isStorageV2, ref, router])
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
title={title || (config?.displayName ?? 'Storage')}
|
||||
subtitle={
|
||||
hideSubtitle ? config?.description || 'Manage your storage buckets and files.' : null
|
||||
!hideSubtitle ? config?.description || 'Manage your storage buckets and files.' : null
|
||||
}
|
||||
navigationItems={navigationItems}
|
||||
secondaryActions={config?.docsUrl ? [<DocsButton key="docs" href={config.docsUrl} />] : []}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { ReactNode, useEffect } from 'react'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { useIsNewStorageUIEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
|
||||
import { useSelectedBucket } from 'components/interfaces/Storage/StorageExplorer/useSelectedBucket'
|
||||
import { StorageMenu } from 'components/interfaces/Storage/StorageMenu'
|
||||
import { StorageMenuV2 } from 'components/interfaces/Storage/StorageMenuV2'
|
||||
import { withAuth } from 'hooks/misc/withAuth'
|
||||
@@ -12,7 +15,42 @@ export interface StorageLayoutProps {
|
||||
}
|
||||
|
||||
const StorageLayout = ({ title, children }: StorageLayoutProps) => {
|
||||
const router = useRouter()
|
||||
const isStorageV2 = useIsNewStorageUIEnabled()
|
||||
const { ref, featurePreviewModal, bucketId } = useParams()
|
||||
const { bucket } = useSelectedBucket()
|
||||
|
||||
// [Joshen] Handle redirects between existing UI and feature preview
|
||||
useEffect(() => {
|
||||
const { pathname } = router
|
||||
const suffix = !!featurePreviewModal ? `?featurePreviewModal=${featurePreviewModal}` : ''
|
||||
|
||||
if (isStorageV2) {
|
||||
if (pathname.endsWith('/storage/settings')) {
|
||||
router.push(`/project/${ref}/storage/files/settings${suffix}`)
|
||||
} else if (pathname.endsWith('/storage/policies')) {
|
||||
router.push(`/project/${ref}/storage/files/policies${suffix}`)
|
||||
} else if (pathname.endsWith('/storage/buckets/[bucketId]')) {
|
||||
if (!!bucket && bucket.type === 'STANDARD') {
|
||||
router.push(`/project/${ref}/storage/files/buckets/${bucketId}${suffix}`)
|
||||
} else if (!!bucket && bucket.type === 'ANALYTICS') {
|
||||
router.push(`/project/${ref}/storage/analytics/buckets/${bucketId}${suffix}`)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (pathname.endsWith('/files/settings')) {
|
||||
router.push(`/project/${ref}/storage/settings${suffix}`)
|
||||
} else if (pathname.endsWith('/files/policies')) {
|
||||
router.push(`/project/${ref}/storage/policies${suffix}`)
|
||||
} else if (
|
||||
pathname.endsWith('/files/buckets/[bucketId]') ||
|
||||
pathname.endsWith('/analytics/buckets/[bucketId]')
|
||||
) {
|
||||
router.push(`/project/${ref}/storage/buckets/${bucketId}${suffix}`)
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isStorageV2])
|
||||
|
||||
return (
|
||||
<ProjectLayout
|
||||
|
||||
@@ -33,9 +33,7 @@ const PageLayout: NextPageWithLayout = () => {
|
||||
) : (
|
||||
<StorageExplorer bucket={bucket} />
|
||||
)
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user