Update layout of existing destination panel + add some improvements (#41429)

* Update layout of existing destination panel + add some improvements

* update icn

* Smol fix
This commit is contained in:
Joshen Lim authored and GitHub committed 2025-12-17 21:33:23 +08:00
1 parent 9bc530a750
commit d150246ffb
13 files changed
+548 -492

No files matched your search

@@ -35,7 +35,7 @@ export const AdvancedSettings = ({ form }: { form: UseFormReturn<DestinationPane
</span>
</div>
</AccordionTrigger_Shadcn_>
<AccordionContent_Shadcn_ className="!pb-0 space-y-6 pt-3">
<AccordionContent_Shadcn_ className="!pb-0 pt-3 [&>div]:flex [&>div]:flex-col [&>div]:gap-y-4">
{/* Batch wait time - applies to all destinations */}
<FormField_Shadcn_
control={form.control}
@@ -43,7 +43,7 @@ export const AdvancedSettings = ({ form }: { form: UseFormReturn<DestinationPane
render={({ field }) => (
<FormItemLayout
label="Batch wait time (milliseconds)"
layout="vertical"
layout="horizontal"
description="How long to wait for more changes before sending. Shorter times mean more real-time updates but higher overhead."
>
<FormControl_Shadcn_>
@@ -61,34 +61,32 @@ export const AdvancedSettings = ({ form }: { form: UseFormReturn<DestinationPane
{/* BigQuery-specific: Max staleness */}
{type === 'BigQuery' && (
<div className="pt-2">
<FormField_Shadcn_
control={form.control}
name="maxStalenessMins"
render={({ field }) => (
<FormItemLayout
label={
<div className="flex items-center gap-2">
<span>Maximum staleness (minutes)</span>
<Badge>BigQuery only</Badge>
</div>
}
layout="vertical"
description="Maximum age of cached data before BigQuery reads from base tables at query time. Lower values ensure fresher results but may increase query costs."
>
<FormControl_Shadcn_>
<Input_Shadcn_
{...field}
type="number"
value={field.value ?? ''}
onChange={handleNumberChange(field)}
placeholder="e.g. 60 (1 hour)"
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</div>
<FormField_Shadcn_
control={form.control}
name="maxStalenessMins"
render={({ field }) => (
<FormItemLayout
label={
<div className="flex flex-col gap-y-2">
<span>Maximum staleness (minutes)</span>
<Badge className="w-min">BigQuery only</Badge>
</div>
}
layout="horizontal"
description="Maximum age of cached data before BigQuery reads from base tables at query time. Lower values ensure fresher results but may increase query costs."
>
<FormControl_Shadcn_>
<Input_Shadcn_
{...field}
type="number"
value={field.value ?? ''}
onChange={handleNumberChange(field)}
placeholder="e.g. 60 (1 hour)"
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
)}
</AccordionContent_Shadcn_>
</AccordionItem_Shadcn_>
@@ -14,11 +14,7 @@ export const DestinationNameInput = ({ form }: DestinationNameInputProps) => {
control={form.control}
name="name"
render={({ field }) => (
<FormItemLayout
label="Name"
layout="vertical"
description="A descriptive name to identify this destination"
>
<FormItemLayout label="Name" layout="horizontal">
<FormControl_Shadcn_>
<Input_Shadcn_ {...field} placeholder="My destination" />
</FormControl_Shadcn_>
@@ -7,6 +7,7 @@ import { toast } from 'sonner'
import * as z from 'zod'
import { useFlag, useParams } from 'common'
import { CreateAnalyticsBucketSheet } from 'components/interfaces/Storage/AnalyticsBuckets/CreateAnalyticsBucketSheet'
import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query'
import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
import { useCheckPrimaryKeysExists } from 'data/database/primary-keys-exists-query'
@@ -27,6 +28,7 @@ import {
} from 'state/replication-pipeline-request-status'
import {
Button,
cn,
DialogSectionSeparator,
Form_Shadcn_,
Sheet,
@@ -91,6 +93,7 @@ export const DestinationPanel = ({
const editMode = !!existingDestination
const [showDisclaimerDialog, setShowDisclaimerDialog] = useState(false)
const [publicationPanelVisible, setPublicationPanelVisible] = useState(false)
const [newBucketSheetVisible, setNewBucketSheetVisible] = useState(false)
const [pendingFormValues, setPendingFormValues] = useState<z.infer<typeof FormSchema> | null>(
null
)
@@ -438,9 +441,12 @@ export const DestinationPanel = ({
<>
<Sheet open={visible} onOpenChange={onClose}>
<SheetContent
showClose={false}
size="default"
className={publicationPanelVisible ? 'right-32' : 'right-0'}
showClose={false}
className={cn(
'md:!w-[700px]',
publicationPanelVisible || newBucketSheetVisible ? 'right-20' : 'right-0'
)}
>
<div className="flex flex-col h-full" tabIndex={-1}>
<SheetHeader>
@@ -458,13 +464,12 @@ export const DestinationPanel = ({
) : (
<Form_Shadcn_ {...form}>
<form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
<div className="p-5">
<p className="text-sm font-medium text-foreground mb-1">
Destination details
</p>
<p className="text-sm text-foreground-light mb-4">
Name your destination and choose which data to replicate
</p>
<DestinationTypeSelection form={form} editMode={editMode} />
<DialogSectionSeparator />
<div className="p-5 flex flex-col gap-y-6">
<p className="text-sm font-medium text-foreground">Destination details</p>
<div className="space-y-4">
<DestinationNameInput form={form} />
@@ -476,23 +481,21 @@ export const DestinationPanel = ({
/>
</div>
</div>
<DialogSectionSeparator />
<DestinationTypeSelection form={form} editMode={editMode} />
{selectedType === 'BigQuery' && etlEnableBigQuery ? (
<>
<DialogSectionSeparator />
<BigQueryFields form={form} />
</>
<BigQueryFields form={form} />
) : selectedType === 'Analytics Bucket' && etlEnableIceberg ? (
<>
<DialogSectionSeparator />
<AnalyticsBucketFields
form={form}
setIsFormInteracting={setIsFormInteracting}
/>
</>
<AnalyticsBucketFields
form={form}
setIsFormInteracting={setIsFormInteracting}
onSelectNewBucket={() => setNewBucketSheetVisible(true)}
/>
) : null}
<DialogSectionSeparator />
<AdvancedSettings form={form} />
</form>
</Form_Shadcn_>
@@ -526,6 +529,11 @@ export const DestinationPanel = ({
onClose={() => setPublicationPanelVisible(false)}
/>
<CreateAnalyticsBucketSheet
open={newBucketSheetVisible}
onOpenChange={setNewBucketSheetVisible}
/>
<ReplicationDisclaimerDialog
open={showDisclaimerDialog}
onOpenChange={handleDisclaimerDialogChange}
@@ -32,20 +32,15 @@ import type { DestinationPanelSchemaType } from './DestinationPanel.schema'
export const BigQueryFields = ({ form }: { form: UseFormReturn<DestinationPanelSchemaType> }) => {
return (
<>
<div className="px-5 pt-5">
<p className="text-sm font-medium text-foreground mb-1">BigQuery settings</p>
<p className="text-sm text-foreground-light mb-4">
Configure how data is sent to your BigQuery destination
</p>
</div>
<div className="space-y-4 pb-5">
<div className="flex flex-col gap-y-6">
<p className="text-sm font-medium text-foreground px-5 pt-5">BigQuery settings</p>
<div className="flex flex-col gap-y-4 pb-5">
<FormField_Shadcn_
control={form.control}
name="projectId"
render={({ field }) => (
<FormItemLayout
layout="vertical"
layout="horizontal"
className="px-5"
label="Project ID"
description="The Google Cloud project ID where data will be sent"
@@ -63,7 +58,7 @@ export const BigQueryFields = ({ form }: { form: UseFormReturn<DestinationPanelS
render={({ field }) => (
<FormItemLayout
label="Dataset ID"
layout="vertical"
layout="horizontal"
className="px-5"
description="The BigQuery dataset where replicated tables will be created"
>
@@ -79,7 +74,7 @@ export const BigQueryFields = ({ form }: { form: UseFormReturn<DestinationPanelS
name="serviceAccountKey"
render={({ field }) => (
<FormItemLayout
layout="vertical"
layout="horizontal"
className="px-5"
label="Service Account Key"
description="Service account credentials JSON for authenticating with BigQuery"
@@ -97,7 +92,7 @@ export const BigQueryFields = ({ form }: { form: UseFormReturn<DestinationPanelS
)}
/>
</div>
</>
</div>
)
}
@@ -113,9 +108,11 @@ export const BigQueryFields = ({ form }: { form: UseFormReturn<DestinationPanelS
export const AnalyticsBucketFields = ({
form,
setIsFormInteracting,
onSelectNewBucket,
}: {
form: UseFormReturn<DestinationPanelSchemaType>
setIsFormInteracting: (value: boolean) => void
onSelectNewBucket: () => void
}) => {
const { warehouseName, type, s3AccessKeyId, namespace } = form.watch()
const [showCatalogToken, setShowCatalogToken] = useState(false)
@@ -166,21 +163,17 @@ export const AnalyticsBucketFields = ({
)
return (
<>
<div className="px-5 pt-5">
<p className="text-sm font-medium text-foreground mb-1">Analytics Bucket settings</p>
<p className="text-sm text-foreground-light mb-4">
Configure how data is sent to your Analytics Bucket destination
</p>
</div>
<div className="space-y-4 pb-5">
<div className="flex flex-col gap-y-6">
<p className="text-sm font-medium text-foreground px-5 pt-5">Analytics Bucket settings</p>
<div className="flex flex-col gap-y-4 pb-5">
<FormField_Shadcn_
control={form.control}
name="warehouseName"
render={({ field }) => (
<FormItemLayout
label="Bucket"
layout="vertical"
layout="horizontal"
className="px-5"
description="The Analytics Bucket where data will be stored"
>
@@ -209,10 +202,14 @@ export const AnalyticsBucketFields = ({
<Select_Shadcn_
value={field.value}
onValueChange={(value) => {
setIsFormInteracting(true)
field.onChange(value)
// [Joshen] Ideally should select the first namespace of the selected bucket
form.setValue('namespace', '')
if (value === 'new-bucket') {
onSelectNewBucket()
} else {
setIsFormInteracting(true)
field.onChange(value)
// [Joshen] Ideally should select the first namespace of the selected bucket
form.setValue('namespace', '')
}
}}
>
<SelectTrigger_Shadcn_>
@@ -231,6 +228,10 @@ export const AnalyticsBucketFields = ({
</SelectItem_Shadcn_>
))
)}
<SelectSeparator_Shadcn_ />
<SelectItem_Shadcn_ value="new-bucket">
Create a new bucket
</SelectItem_Shadcn_>
</SelectGroup_Shadcn_>
</SelectContent_Shadcn_>
</Select_Shadcn_>
@@ -246,7 +247,7 @@ export const AnalyticsBucketFields = ({
render={({ field }) => (
<FormItemLayout
label="Namespace"
layout="vertical"
layout="horizontal"
className="px-5"
description="The namespace within the bucket where tables will be organized"
>
@@ -300,7 +301,7 @@ export const AnalyticsBucketFields = ({
</SelectItem_Shadcn_>
))
)}
{namespaces.length > 0 && <SelectSeparator_Shadcn_ />}
<SelectSeparator_Shadcn_ />
<SelectItem_Shadcn_ key={CREATE_NEW_NAMESPACE} value={CREATE_NEW_NAMESPACE}>
Create a new namespace
</SelectItem_Shadcn_>
@@ -320,7 +321,7 @@ export const AnalyticsBucketFields = ({
render={({ field }) => (
<FormItemLayout
label="New Namespace Name"
layout="vertical"
layout="horizontal"
className="px-5"
description="A unique name for the new namespace"
>
@@ -337,7 +338,7 @@ export const AnalyticsBucketFields = ({
name="catalogToken"
render={({ field }) => (
<FormItemLayout
layout="vertical"
layout="horizontal"
label="Catalog Token"
className="px-5"
description={
@@ -375,7 +376,38 @@ export const AnalyticsBucketFields = ({
control={form.control}
name="s3AccessKeyId"
render={({ field }) => (
<FormItemLayout layout="vertical" label="S3 Access Key ID" className="px-5">
<FormItemLayout
layout="horizontal"
label="S3 Access Key ID"
description={
<div className="flex flex-col gap-y-2">
<p>
Access keys are managed in your Storage{' '}
<InlineLink href={`/project/${projectRef}/storage/s3`}>S3 settings</InlineLink>
</p>
{isSuccessKeys && keyNoLongerExists && (
<Admonition type="warning" title="Unable to find access key ID in project">
<p className="!leading-normal">
Please select another key or create a new set, as this destination will not
work otherwise. S3 access keys can be managed in your{' '}
<InlineLink href={`/project/${projectRef}/storage/files/settings`}>
storage settings
</InlineLink>
</p>
</Admonition>
)}
{s3AccessKeyId === CREATE_NEW_KEY && (
<Admonition
type="default"
title="A new set of S3 access keys will be created"
/>
)}
</div>
}
className="px-5"
>
{isLoadingKeys ? (
<Button
disabled
@@ -427,39 +459,13 @@ export const AnalyticsBucketFields = ({
)}
/>
{isSuccessKeys && keyNoLongerExists && (
<div className="px-5">
<Admonition type="warning" title="Unable to find access key ID in project">
<p className="!leading-normal">
Please select another key or create a new set, as this destination will not work
otherwise. S3 access keys can be managed in your{' '}
<InlineLink href={`/project/${projectRef}/storage/files/settings`}>
storage settings
</InlineLink>
</p>
</Admonition>
</div>
)}
{s3AccessKeyId === CREATE_NEW_KEY ? (
<div className="px-5">
<Admonition type="default" title="A new set of S3 access keys will be created">
<p className="!leading-normal">
S3 access keys can be managed in your{' '}
<InlineLink href={`/project/${projectRef}/storage/files/settings`}>
storage settings
</InlineLink>
.
</p>
</Admonition>
</div>
) : (
{s3AccessKeyId !== CREATE_NEW_KEY && (
<FormField_Shadcn_
control={form.control}
name="s3SecretAccessKey"
render={({ field }) => (
<FormItemLayout
layout="vertical"
layout="horizontal"
label="S3 Secret Access Key"
className="relative px-5"
description="The secret key corresponding to your selected access key ID"
@@ -469,12 +475,13 @@ export const AnalyticsBucketFields = ({
{...field}
type={showSecretAccessKey ? 'text' : 'password'}
value={field.value ?? ''}
placeholder="Provide the secret access key"
/>
</FormControl_Shadcn_>
<Button
type="default"
icon={showSecretAccessKey ? <Eye /> : <EyeOff />}
className="w-7 absolute right-6 top-[33px]"
className="w-7 absolute right-6 top-[4px]"
onClick={() => setShowSecretAccessKey(!showSecretAccessKey)}
/>
</FormItemLayout>
@@ -482,6 +489,6 @@ export const AnalyticsBucketFields = ({
/>
)}
</div>
</>
</div>
)
}
@@ -1,12 +1,15 @@
import type { UseFormReturn } from 'react-hook-form'
import { useFlag } from 'common'
import { AnalyticsBucket, BigQuery } from 'icons'
import {
cn,
FormControl_Shadcn_,
FormField_Shadcn_,
RadioGroupStacked,
RadioGroupStackedItem,
} from 'ui'
import { Admonition } from 'ui-patterns'
import type { DestinationPanelSchemaType } from './DestinationPanel.schema'
type DestinationTypeSelectionProps = {
@@ -20,16 +23,15 @@ export const DestinationTypeSelection = ({ form, editMode }: DestinationTypeSele
return (
<div className="px-5 py-5">
<p className="text-sm font-medium text-foreground mb-1">Destination type</p>
{editMode ? (
<p className="text-sm text-foreground-light mb-4">
The destination type cannot be changed after creation
</p>
) : (
<p className="text-sm text-foreground-light mb-4">
Choose which platform to send your database changes to
</p>
)}
<div className="flex flex-col gap-y-2 mb-4">
<p className="text-sm font-medium text-foreground">Type</p>
{editMode && (
<Admonition
type="default"
title="The destination type cannot be changed after creation"
/>
)}
</div>
<FormField_Shadcn_
name="type"
control={form.control}
@@ -39,25 +41,50 @@ export const DestinationTypeSelection = ({ form, editMode }: DestinationTypeSele
disabled={editMode}
value={field.value}
onValueChange={(value) => field.onChange(value)}
className={cn(
'grid grid-cols-2 [&>button>div]:py-4',
'[&>button:first-of-type]:rounded-none [&>button:last-of-type]:rounded-none',
'[&>button:first-of-type]:!rounded-l-lg [&>button:last-of-type]:!rounded-r-lg'
)}
>
{((!editMode && etlEnableBigQuery) || (editMode && field.value === 'BigQuery')) && (
<RadioGroupStackedItem
label=""
showIndicator={false}
id="BigQuery"
value="BigQuery"
label="BigQuery"
className="[&>div>div>p]:text-left"
description="Send data to Google Cloud's data warehouse for analytics and business intelligence"
/>
>
<div className="flex flex-col gap-y-2">
<BigQuery size={20} />
<div className="flex flex-col gap-y-0.5 text-sm text-left">
<p>BigQuery</p>
<p className="text-foreground-lighter">
Send data to Google Cloud's data warehouse for analytics and business
intelligence
</p>
</div>
</div>
</RadioGroupStackedItem>
)}
{((!editMode && etlEnableIceberg) ||
(editMode && field.value === 'Analytics Bucket')) && (
<RadioGroupStackedItem
value="Analytics Bucket"
label=""
showIndicator={false}
id="Analytics Bucket"
label="Analytics Bucket"
className="[&>div>div>p]:text-left"
description="Send data to Apache Iceberg tables in your Supabase Storage for flexible analytics workflows"
/>
value="Analytics Bucket"
>
<div className="flex flex-col gap-y-2">
<AnalyticsBucket size={20} />
<div className="flex flex-col gap-y-0.5 text-sm text-left">
<p>Analytics Bucket</p>
<p className="text-foreground-lighter">
Send data to Apache Iceberg tables in your Supabase Storage for flexible
analytics workflows
</p>
</div>
</div>
</RadioGroupStackedItem>
)}
</RadioGroupStacked>
</FormControl_Shadcn_>
@@ -51,56 +51,54 @@ export const PublicationSelection = ({
const hasTablesWithNoPrimaryKeys = (checkPrimaryKeysExistsData?.offendingTables ?? []).length > 0
return (
<>
<FormField_Shadcn_
control={form.control}
name="publicationName"
render={({ field }) => (
<FormItemLayout
label="Publication"
layout="vertical"
description="Choose which tables to replicate to this destination"
>
<FormControl_Shadcn_>
<PublicationsComboBox
publications={publicationNames}
isLoadingPublications={isLoadingPublications}
isLoadingCheck={!!selectedPublication && isLoadingCheck}
field={field}
onNewPublicationClick={() => onSelectNewPublication()}
/>
</FormControl_Shadcn_>
{isSelectedPublicationMissing ? (
<Admonition type="warning" className="mt-2">
<p className="!leading-normal">
The publication <strong className="text-foreground">{publicationName}</strong> was
not found, it may have been renamed or deleted, please select another one.
</p>
</Admonition>
) : hasTablesWithNoPrimaryKeys ? (
<Admonition type="warning" className="mt-2">
<p className="!leading-normal">
Replication requires every table in the publication to have a primary key to work,
which these tables are missing:
</p>
<ul className="list-disc pl-6 mb-2">
{(checkPrimaryKeysExistsData?.offendingTables ?? []).map((x) => {
const value = `${x.schema}.${x.name}`
return (
<li key={value} className="!leading-normal">
<InlineLink href={`/project/${projectRef}/editor/${x.id}`}>
{value}
</InlineLink>
</li>
)
})}
</ul>
<p className="!leading-normal">Ensure that these tables have primary keys first.</p>
</Admonition>
) : null}
</FormItemLayout>
)}
/>
</>
<FormField_Shadcn_
control={form.control}
name="publicationName"
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label="Publication"
description="Choose which tables to replicate to this destination"
>
<FormControl_Shadcn_>
<PublicationsComboBox
publications={publicationNames}
isLoadingPublications={isLoadingPublications}
isLoadingCheck={!!selectedPublication && isLoadingCheck}
field={field}
onNewPublicationClick={() => onSelectNewPublication()}
/>
</FormControl_Shadcn_>
{isSelectedPublicationMissing ? (
<Admonition type="warning" className="mt-2">
<p className="!leading-normal">
The publication <strong className="text-foreground">{publicationName}</strong> was
not found, it may have been renamed or deleted, please select another one.
</p>
</Admonition>
) : hasTablesWithNoPrimaryKeys ? (
<Admonition type="warning" className="mt-2">
<p className="!leading-normal">
Replication requires every table in the publication to have a primary key to work,
which these tables are missing:
</p>
<ul className="list-disc pl-6 mb-2">
{(checkPrimaryKeysExistsData?.offendingTables ?? []).map((x) => {
const value = `${x.schema}.${x.name}`
return (
<li key={value} className="!leading-normal">
<InlineLink href={`/project/${projectRef}/editor/${x.id}`}>
{value}
</InlineLink>
</li>
)
})}
</ul>
<p className="!leading-normal">Ensure that these tables have primary keys first.</p>
</Admonition>
) : null}
</FormItemLayout>
)}
/>
)
}
@@ -1,5 +1,4 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { X } from 'lucide-react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import { z } from 'zod'
@@ -9,13 +8,11 @@ import { useCreatePublicationMutation } from 'data/replication/publication-creat
import { useReplicationTablesQuery } from 'data/replication/tables-query'
import {
Button,
cn,
Form_Shadcn_,
FormControl_Shadcn_,
FormField_Shadcn_,
Input_Shadcn_,
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
@@ -80,27 +77,11 @@ export const NewPublicationPanel = ({ visible, sourceId, onClose }: NewPublicati
return (
<>
<Sheet open={visible} onOpenChange={onClose}>
<SheetContent showClose={false} size="default">
<SheetContent size="default">
<div className="flex flex-col h-full">
<SheetHeader>
<div className="flex flex-row justify-between items-center">
<div>
<SheetTitle>New Publication</SheetTitle>
<SheetDescription>
Create a new publication to replicate table changes to destinations
</SheetDescription>
</div>
<SheetClose
className={cn(
'text-muted hover:opacity-100',
'focus:outline-none focus:ring-2',
'disabled:pointer-events-none'
)}
>
<X className="h-3 w-3" />
<span className="sr-only">Close</span>
</SheetClose>
</div>
<SheetTitle>Create a new Publication</SheetTitle>
<SheetDescription>Replicate table changes to destinations</SheetDescription>
</SheetHeader>
<SheetSection className="flex-grow overflow-auto">
<Form_Shadcn_ {...form}>
@@ -134,9 +115,11 @@ export const NewPublicationPanel = ({ visible, sourceId, onClose }: NewPublicati
onValuesChange={field.onChange}
disabled={creatingPublication}
>
<MultiSelector.Trigger>
<MultiSelector.Input placeholder="Select tables" />
</MultiSelector.Trigger>
<MultiSelector.Trigger
badgeLimit="wrap"
label="Select tables..."
mode="inline-combobox"
/>
<MultiSelector.Content>
<MultiSelector.List>
{tables?.map((table) => (
@@ -78,10 +78,11 @@ export const PublicationsComboBox = ({
{selectedPublication || 'Select publication'}
</Button>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_ className="p-0" sameWidthAsTrigger>
<PopoverContent_Shadcn_ sameWidthAsTrigger className="p-0" align="start">
<Command_Shadcn_>
<CommandInput_Shadcn_
placeholder="Find publication..."
className="text-xs"
value={searchTerm}
onValueChange={setSearchTerm}
/>
@@ -0,0 +1,280 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { SubmitHandler, useForm } from 'react-hook-form'
import { toast } from 'sonner'
import z from 'zod'
import { useParams } from 'common'
import { InlineLink } from 'components/ui/InlineLink'
import { useDatabaseExtensionEnableMutation } from 'data/database-extensions/database-extension-enable-mutation'
import { useAnalyticsBucketCreateMutation } from 'data/storage/analytics-bucket-create-mutation'
import { useAnalyticsBucketsQuery } from 'data/storage/analytics-buckets-query'
import { useIcebergWrapperCreateMutation } from 'data/storage/iceberg-wrapper-create-mutation'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { DOCS_URL } from 'lib/constants'
import {
Button,
cn,
DialogFooter,
DialogSection,
Form_Shadcn_,
FormControl_Shadcn_,
FormField_Shadcn_,
Input_Shadcn_,
SheetFooter,
SheetSection,
} from 'ui'
import { Admonition } from 'ui-patterns'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { useIcebergWrapperExtension } from './AnalyticsBucketDetails/useIcebergWrapper'
import {
reservedPrefixes,
reservedSuffixes,
validBucketNameRegex,
} from './CreateAnalyticsBucketForm.utils'
const FormSchema = z
.object({
name: z
.string()
.trim()
.min(3, 'Bucket name should be at least 3 characters')
.max(63, 'Bucket name should be up to 63 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 (reservedPrefixes.test(data.name)) {
const [match] = data.name.match(reservedPrefixes) ?? []
return ctx.addIssue({
path: ['name'],
code: z.ZodIssueCode.custom,
message: `Bucket name cannot start with "${match}"`,
})
}
if (reservedSuffixes.test(data.name)) {
const [match] = data.name.match(reservedSuffixes) ?? []
return ctx.addIssue({
path: ['name'],
code: z.ZodIssueCode.custom,
message: `Bucket name cannot end with "${match}"`,
})
}
if (/[A-Z]/.test(data.name)) {
return ctx.addIssue({
path: ['name'],
code: z.ZodIssueCode.custom,
message: 'Bucket name can only be lowercase characters',
})
}
if (!validBucketNameRegex.test(data.name)) {
if (!/^[a-z0-9]/.test(data.name)) {
return ctx.addIssue({
path: ['name'],
code: z.ZodIssueCode.custom,
message: 'Bucket name must start with a lowercase letter or number.',
})
}
if (!/[a-z0-9]$/.test(data.name)) {
return ctx.addIssue({
path: ['name'],
code: z.ZodIssueCode.custom,
message: 'Bucket name must end with a lowercase letter or number.',
})
}
const [match] = data.name.match(/[^a-z0-9-]/) ?? []
return 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-analytics-storage-bucket-form'
export type CreateAnalyticsBucketForm = z.infer<typeof FormSchema>
interface CreateAnalyticsBucketFormProps {
type?: 'dialog' | 'sheet'
onOpenChange: (value: boolean) => void
}
export const CreateAnalyticsBucketForm = ({
type = 'dialog',
onOpenChange,
}: CreateAnalyticsBucketFormProps) => {
const { ref } = useParams()
const { data: org } = useSelectedOrganizationQuery()
const { data: project } = useSelectedProjectQuery()
const { extension: wrappersExtension, state: wrappersExtensionState } =
useIcebergWrapperExtension()
const { data: buckets = [] } = useAnalyticsBucketsQuery({ projectRef: ref })
const wrappersExtensionNeedsUpgrading = wrappersExtensionState === 'needs-upgrade'
const { mutate: sendEvent } = useSendEventMutation()
const { mutateAsync: createAnalyticsBucket, isPending: isCreatingAnalyticsBucket } =
useAnalyticsBucketCreateMutation({
// [Joshen] Silencing the error here as it's being handled in onSubmit
onError: () => {},
})
const { mutateAsync: createIcebergWrapper, isPending: isCreatingIcebergWrapper } =
useIcebergWrapperCreateMutation()
const { mutateAsync: enableExtension, isPending: isEnablingExtension } =
useDatabaseExtensionEnableMutation()
const isCreating = isEnablingExtension || isCreatingIcebergWrapper || isCreatingAnalyticsBucket
const form = useForm<CreateAnalyticsBucketForm>({
resolver: zodResolver(FormSchema),
defaultValues: { name: '' },
})
const onSubmit: SubmitHandler<CreateAnalyticsBucketForm> = 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')
const hasExistingBucket = buckets.some((x) => x.name === values.name)
if (hasExistingBucket) return toast.error('Bucket name already exists')
try {
await createAnalyticsBucket({
projectRef: ref,
bucketName: values.name,
})
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 })
sendEvent({
action: 'storage_bucket_created',
properties: { bucketType: 'analytics' },
groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' },
})
form.reset()
toast.success(`Created bucket “${values.name}”`)
onOpenChange(false)
} catch (error: any) {
toast.error(`Failed to create bucket: ${error.message}`)
}
}
const Section = type === 'dialog' ? DialogSection : SheetSection
const Footer = type === 'dialog' ? DialogFooter : SheetFooter
return (
<>
<Section className="flex flex-col !p-0 flex-grow">
<Form_Shadcn_ {...form}>
<form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
<FormField_Shadcn_
key="name"
name="name"
control={form.control}
render={({ field }) => (
<FormItemLayout
name="name"
className="p-5"
label="Bucket name"
labelOptional="Cannot be changed after creation"
description="Must be between 3 – 63 characters. Only lowercase letters, numbers, 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>
)}
/>
{wrappersExtensionNeedsUpgrading ? (
<Admonition
type="warning"
className={cn('border-x-0 rounded-none', type === 'dialog' && 'border-b-0')}
title="Wrappers extension must be updated for Iceberg Wrapper support"
>
<p className="prose max-w-full text-sm !leading-normal">
Update the <code className="text-code-inline">wrappers</code> extension by
upgrading your project from your{' '}
<InlineLink href={`/project/${ref}/settings/infrastructure`}>
project settings
</InlineLink>{' '}
before creating an Analytics bucket.{' '}
<InlineLink href={`${DOCS_URL}/guides/database/extensions/wrappers/iceberg`}>
Learn more
</InlineLink>
.
</p>
</Admonition>
) : (
<Admonition
type="default"
className={cn('border-x-0 rounded-none', type === 'dialog' && 'border-b-0')}
>
<p className="!leading-normal">
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>
)}
</form>
</Form_Shadcn_>
</Section>
<Footer>
<Button type="default" disabled={isCreating} onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
form={formId}
htmlType="submit"
loading={isCreating}
disabled={wrappersExtensionNeedsUpgrading || isCreating}
>
Create bucket
</Button>
</Footer>
</>
)
}
@@ -1,117 +1,6 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useRouter } from 'next/router'
import { SubmitHandler, useForm } from 'react-hook-form'
import { toast } from 'sonner'
import z from 'zod'
import { useParams } from 'common'
import { InlineLink } from 'components/ui/InlineLink'
import { useDatabaseExtensionEnableMutation } from 'data/database-extensions/database-extension-enable-mutation'
import { useAnalyticsBucketCreateMutation } from 'data/storage/analytics-bucket-create-mutation'
import { useAnalyticsBucketsQuery } from 'data/storage/analytics-buckets-query'
import { useIcebergWrapperCreateMutation } from 'data/storage/iceberg-wrapper-create-mutation'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { DOCS_URL } from 'lib/constants'
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogSection,
DialogSectionSeparator,
DialogTitle,
Form_Shadcn_,
FormControl_Shadcn_,
FormField_Shadcn_,
Input_Shadcn_,
} from 'ui'
import { Admonition } from 'ui-patterns'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { Dialog, DialogContent, DialogHeader, DialogSectionSeparator, DialogTitle } from 'ui'
import { BUCKET_TYPES } from '../Storage.constants'
import { useIcebergWrapperExtension } from './AnalyticsBucketDetails/useIcebergWrapper'
import {
reservedPrefixes,
reservedSuffixes,
validBucketNameRegex,
} from './CreateAnalyticsBucketModal.utils'
const FormSchema = z
.object({
name: z
.string()
.trim()
.min(3, 'Bucket name should be at least 3 characters')
.max(63, 'Bucket name should be up to 63 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 (reservedPrefixes.test(data.name)) {
const [match] = data.name.match(reservedPrefixes) ?? []
return ctx.addIssue({
path: ['name'],
code: z.ZodIssueCode.custom,
message: `Bucket name cannot start with "${match}"`,
})
}
if (reservedSuffixes.test(data.name)) {
const [match] = data.name.match(reservedSuffixes) ?? []
return ctx.addIssue({
path: ['name'],
code: z.ZodIssueCode.custom,
message: `Bucket name cannot end with "${match}"`,
})
}
if (/[A-Z]/.test(data.name)) {
return ctx.addIssue({
path: ['name'],
code: z.ZodIssueCode.custom,
message: 'Bucket name can only be lowercase characters',
})
}
if (!validBucketNameRegex.test(data.name)) {
if (!/^[a-z0-9]/.test(data.name)) {
return ctx.addIssue({
path: ['name'],
code: z.ZodIssueCode.custom,
message: 'Bucket name must start with a lowercase letter or number.',
})
}
if (!/[a-z0-9]$/.test(data.name)) {
return ctx.addIssue({
path: ['name'],
code: z.ZodIssueCode.custom,
message: 'Bucket name must end with a lowercase letter or number.',
})
}
const [match] = data.name.match(/[^a-z0-9-]/) ?? []
return 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-analytics-storage-bucket-form'
export type CreateAnalyticsBucketForm = z.infer<typeof FormSchema>
import { CreateAnalyticsBucketForm } from './CreateAnalyticsBucketForm'
interface CreateAnalyticsBucketModalProps {
open: boolean
@@ -122,176 +11,16 @@ export const CreateAnalyticsBucketModal = ({
open,
onOpenChange,
}: CreateAnalyticsBucketModalProps) => {
const router = useRouter()
const { ref } = useParams()
const { data: org } = useSelectedOrganizationQuery()
const { data: project } = useSelectedProjectQuery()
const { extension: wrappersExtension, state: wrappersExtensionState } =
useIcebergWrapperExtension()
const { data: buckets = [] } = useAnalyticsBucketsQuery({ projectRef: ref })
const wrappersExtenstionNeedsUpgrading = wrappersExtensionState === 'needs-upgrade'
const { mutate: sendEvent } = useSendEventMutation()
const { mutateAsync: createAnalyticsBucket, isPending: isCreatingAnalyticsBucket } =
useAnalyticsBucketCreateMutation({
// [Joshen] Silencing the error here as it's being handled in onSubmit
onError: () => {},
})
const { mutateAsync: createIcebergWrapper, isPending: isCreatingIcebergWrapper } =
useIcebergWrapperCreateMutation()
const { mutateAsync: enableExtension, isPending: isEnablingExtension } =
useDatabaseExtensionEnableMutation()
const config = BUCKET_TYPES['analytics']
const isCreating = isEnablingExtension || isCreatingIcebergWrapper || isCreatingAnalyticsBucket
const form = useForm<CreateAnalyticsBucketForm>({
resolver: zodResolver(FormSchema),
defaultValues: { name: '' },
})
const onSubmit: SubmitHandler<CreateAnalyticsBucketForm> = 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')
const hasExistingBucket = buckets.some((x) => x.name === values.name)
if (hasExistingBucket) return toast.error('Bucket name already exists')
try {
await createAnalyticsBucket({
projectRef: ref,
bucketName: values.name,
})
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 })
sendEvent({
action: 'storage_bucket_created',
properties: { bucketType: 'analytics' },
groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' },
})
form.reset()
toast.success(`Created bucket “${values.name}”`)
onOpenChange(false)
} catch (error: any) {
toast.error(`Failed to create bucket: ${error.message}`)
}
}
const handleClose = () => {
form.reset()
onOpenChange(false)
}
return (
<Dialog
open={open}
onOpenChange={(open) => {
if (!open) handleClose()
}}
>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent size="medium" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>Create {config.singularName} bucket</DialogTitle>
</DialogHeader>
<DialogSectionSeparator />
<Form_Shadcn_ {...form}>
<form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
<DialogSection className="flex flex-col !p-0">
<FormField_Shadcn_
key="name"
name="name"
control={form.control}
render={({ field }) => (
<FormItemLayout
name="name"
className="p-5"
label="Bucket name"
labelOptional="Cannot be changed after creation"
description="Must be between 3–63 characters. Only lowercase letters, numbers, 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>
)}
/>
{wrappersExtenstionNeedsUpgrading ? (
<Admonition
type="warning"
className="border-x-0 border-b-0 rounded-none"
title="Wrappers extension must be updated for Iceberg Wrapper support"
>
<p className="prose max-w-full text-sm !leading-normal">
Update the <code className="text-code-inline">wrappers</code> extension by
upgrading your project from your{' '}
<InlineLink href={`/project/${ref}/settings/infrastructure`}>
project settings
</InlineLink>{' '}
before creating an Analytics bucket.{' '}
<InlineLink href={`${DOCS_URL}/guides/database/extensions/wrappers/iceberg`}>
Learn more
</InlineLink>
.
</p>
</Admonition>
) : (
<Admonition type="default" className="border-x-0 border-b-0 rounded-none">
<p className="!leading-normal">
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={() => onOpenChange(false)}>
Cancel
</Button>
<Button
form={formId}
htmlType="submit"
loading={isCreating}
disabled={wrappersExtenstionNeedsUpgrading || isCreating}
>
Create bucket
</Button>
</DialogFooter>
<CreateAnalyticsBucketForm type="dialog" onOpenChange={onOpenChange} />
</DialogContent>
</Dialog>
)
@@ -0,0 +1,26 @@
import { Sheet, SheetContent, SheetHeader, SheetTitle } from 'ui'
import { BUCKET_TYPES } from '../Storage.constants'
import { CreateAnalyticsBucketForm } from './CreateAnalyticsBucketForm'
interface CreateAnalyticsBucketSheetProps {
open: boolean
onOpenChange: (value: boolean) => void
}
export const CreateAnalyticsBucketSheet = ({
open,
onOpenChange,
}: CreateAnalyticsBucketSheetProps) => {
const config = BUCKET_TYPES['analytics']
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent aria-describedby={undefined} className="flex flex-col gap-y-0">
<SheetHeader>
<SheetTitle>Create {config.singularName} bucket</SheetTitle>
</SheetHeader>
<CreateAnalyticsBucketForm type="sheet" onOpenChange={onOpenChange} />
</SheetContent>
</Sheet>
)
}
@@ -7,6 +7,7 @@ import { useParams } from 'common'
import AlertError from 'components/ui/AlertError'
import { AlphaNotice } from 'components/ui/AlphaNotice'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import { useProjectStorageConfigQuery } from 'data/config/project-storage-config-query'
import { useAnalyticsBucketsQuery } from 'data/storage/analytics-buckets-query'
import { AnalyticsBucket as AnalyticsBucketIcon } from 'icons'
import { BASE_PATH } from 'lib/constants'
@@ -41,6 +42,9 @@ export const AnalyticsBuckets = () => {
parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true })
)
const { data: config } = useProjectStorageConfigQuery({ projectRef: ref })
const maxAnalyticsBuckets = config?.features.icebergCatalog.maxCatalogs ?? 2
const {
data: buckets = [],
error: bucketsError,
@@ -101,13 +105,12 @@ export const AnalyticsBuckets = () => {
<Tooltip>
<TooltipTrigger>
<span className="bg-surface-200 rounded-full px-2 py-1 leading-none text-xs text-foreground-lighter tracking-widest">
{analyticsBuckets.length}
/2
{analyticsBuckets.length}/{maxAnalyticsBuckets}
</span>
</TooltipTrigger>
<TooltipContent side="bottom" className="w-64 text-center">
Each project can only have up to 2 buckets while analytics buckets are
in alpha{' '}
<TooltipContent side="bottom" className="w-72 text-center">
Each project can only have up to {maxAnalyticsBuckets} buckets while
analytics buckets are in alpha{' '}
</TooltipContent>
</Tooltip>
)}