mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
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:
1 parent
9bc530a750
commit
d150246ffb
13 files changed
+548
-492
No files matched your search
+28
-30
@@ -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_>
|
||||
|
||||
+1
-5
@@ -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_>
|
||||
|
||||
+29
-21
@@ -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}
|
||||
|
||||
+67
-60
@@ -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>
|
||||
)
|
||||
}
|
||||
+46
-19
@@ -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_>
|
||||
|
||||
+49
-51
@@ -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}
|
||||
/>
|
||||
|
||||
+280
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
File renamed without changes.
+4
-275
@@ -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>
|
||||
)
|
||||
|
||||
+26
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user