From d150246ffbaedace650b47ee2e9e886be2d1186b Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Wed, 17 Dec 2025 21:33:23 +0800 Subject: [PATCH] Update layout of existing destination panel + add some improvements (#41429) * Update layout of existing destination panel + add some improvements * update icn * Smol fix --- .../DestinationPanel/AdvancedSettings.tsx | 58 ++-- .../DestinationPanel/DestinationNameInput.tsx | 6 +- .../DestinationPanel/DestinationPanel.tsx | 50 ++-- .../DestinationPanelFields.tsx | 127 ++++---- .../DestinationTypeSelection.tsx | 65 ++-- .../DestinationPanel/PublicationSelection.tsx | 100 +++---- .../Replication/NewPublicationPanel.tsx | 33 +-- .../Replication/PublicationsComboBox.tsx | 3 +- .../CreateAnalyticsBucketForm.tsx | 280 ++++++++++++++++++ ....ts => CreateAnalyticsBucketForm.utils.ts} | 0 .../CreateAnalyticsBucketModal.tsx | 279 +---------------- .../CreateAnalyticsBucketSheet.tsx | 26 ++ .../Storage/AnalyticsBuckets/index.tsx | 13 +- 13 files changed, 548 insertions(+), 492 deletions(-) create mode 100644 apps/studio/components/interfaces/Storage/AnalyticsBuckets/CreateAnalyticsBucketForm.tsx rename apps/studio/components/interfaces/Storage/AnalyticsBuckets/{CreateAnalyticsBucketModal.utils.ts => CreateAnalyticsBucketForm.utils.ts} (100%) create mode 100644 apps/studio/components/interfaces/Storage/AnalyticsBuckets/CreateAnalyticsBucketSheet.tsx diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/AdvancedSettings.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/AdvancedSettings.tsx index 47ab32593b5..11dc657b876 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/AdvancedSettings.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/AdvancedSettings.tsx @@ -35,7 +35,7 @@ export const AdvancedSettings = ({ form }: { form: UseFormReturn - + {/* Batch wait time - applies to all destinations */} ( @@ -61,34 +61,32 @@ export const AdvancedSettings = ({ form }: { form: UseFormReturn - ( - - Maximum staleness (minutes) - BigQuery only - - } - 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." - > - - - - - )} - /> - + ( + + Maximum staleness (minutes) + BigQuery only + + } + 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." + > + + + + + )} + /> )} diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationNameInput.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationNameInput.tsx index 3e9208b371b..d444f9ae624 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationNameInput.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationNameInput.tsx @@ -14,11 +14,7 @@ export const DestinationNameInput = ({ form }: DestinationNameInputProps) => { control={form.control} name="name" render={({ field }) => ( - + diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationPanel.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationPanel.tsx index 1bc865359a4..69ad59bb8c8 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationPanel.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationPanel.tsx @@ -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 | null>( null ) @@ -438,9 +441,12 @@ export const DestinationPanel = ({ <>
@@ -458,13 +464,12 @@ export const DestinationPanel = ({ ) : (
-
-

- Destination details -

-

- Name your destination and choose which data to replicate -

+ + + + +
+

Destination details

@@ -476,23 +481,21 @@ export const DestinationPanel = ({ />
+ - + {selectedType === 'BigQuery' && etlEnableBigQuery ? ( - <> - - - + ) : selectedType === 'Analytics Bucket' && etlEnableIceberg ? ( - <> - - - + setNewBucketSheetVisible(true)} + /> ) : null} + + @@ -526,6 +529,11 @@ export const DestinationPanel = ({ onClose={() => setPublicationPanelVisible(false)} /> + + }) => { return ( - <> -
-

BigQuery settings

-

- Configure how data is sent to your BigQuery destination -

-
-
+
+

BigQuery settings

+
( ( @@ -79,7 +74,7 @@ export const BigQueryFields = ({ form }: { form: UseFormReturn (
- +
) } @@ -113,9 +108,11 @@ export const BigQueryFields = ({ form }: { form: UseFormReturn 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 ( - <> -
-

Analytics Bucket settings

-

- Configure how data is sent to your Analytics Bucket destination -

-
-
+
+

Analytics Bucket settings

+ +
( @@ -209,10 +202,14 @@ export const AnalyticsBucketFields = ({ { - 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', '') + } }} > @@ -231,6 +228,10 @@ export const AnalyticsBucketFields = ({ )) )} + + + Create a new bucket + @@ -246,7 +247,7 @@ export const AnalyticsBucketFields = ({ render={({ field }) => ( @@ -300,7 +301,7 @@ export const AnalyticsBucketFields = ({ )) )} - {namespaces.length > 0 && } + Create a new namespace @@ -320,7 +321,7 @@ export const AnalyticsBucketFields = ({ render={({ field }) => ( @@ -337,7 +338,7 @@ export const AnalyticsBucketFields = ({ name="catalogToken" render={({ field }) => ( ( - + +

+ Access keys are managed in your Storage{' '} + S3 settings +

+ + {isSuccessKeys && keyNoLongerExists && ( + +

+ Please select another key or create a new set, as this destination will not + work otherwise. S3 access keys can be managed in your{' '} + + storage settings + +

+
+ )} + + {s3AccessKeyId === CREATE_NEW_KEY && ( + + )} +
+ } + className="px-5" + > {isLoadingKeys ? (
- +
) } diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationTypeSelection.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationTypeSelection.tsx index d99fb5fc99d..c7b9362a717 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationTypeSelection.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationTypeSelection.tsx @@ -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 (
-

Destination type

- {editMode ? ( -

- The destination type cannot be changed after creation -

- ) : ( -

- Choose which platform to send your database changes to -

- )} +
+

Type

+ {editMode && ( + + )} +
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')) && ( + > +
+ +
+

BigQuery

+

+ Send data to Google Cloud's data warehouse for analytics and business + intelligence +

+
+
+
)} {((!editMode && etlEnableIceberg) || (editMode && field.value === 'Analytics Bucket')) && ( + value="Analytics Bucket" + > +
+ +
+

Analytics Bucket

+

+ Send data to Apache Iceberg tables in your Supabase Storage for flexible + analytics workflows +

+
+
+
)} diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/PublicationSelection.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/PublicationSelection.tsx index 7b619107dbb..0c3da86f327 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/PublicationSelection.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/PublicationSelection.tsx @@ -51,56 +51,54 @@ export const PublicationSelection = ({ const hasTablesWithNoPrimaryKeys = (checkPrimaryKeysExistsData?.offendingTables ?? []).length > 0 return ( - <> - ( - - - onSelectNewPublication()} - /> - - {isSelectedPublicationMissing ? ( - -

- The publication {publicationName} was - not found, it may have been renamed or deleted, please select another one. -

-
- ) : hasTablesWithNoPrimaryKeys ? ( - -

- Replication requires every table in the publication to have a primary key to work, - which these tables are missing: -

-
    - {(checkPrimaryKeysExistsData?.offendingTables ?? []).map((x) => { - const value = `${x.schema}.${x.name}` - return ( -
  • - - {value} - -
  • - ) - })} -
-

Ensure that these tables have primary keys first.

-
- ) : null} -
- )} - /> - + ( + + + onSelectNewPublication()} + /> + + {isSelectedPublicationMissing ? ( + +

+ The publication {publicationName} was + not found, it may have been renamed or deleted, please select another one. +

+
+ ) : hasTablesWithNoPrimaryKeys ? ( + +

+ Replication requires every table in the publication to have a primary key to work, + which these tables are missing: +

+
    + {(checkPrimaryKeysExistsData?.offendingTables ?? []).map((x) => { + const value = `${x.schema}.${x.name}` + return ( +
  • + + {value} + +
  • + ) + })} +
+

Ensure that these tables have primary keys first.

+
+ ) : null} +
+ )} + /> ) } diff --git a/apps/studio/components/interfaces/Database/Replication/NewPublicationPanel.tsx b/apps/studio/components/interfaces/Database/Replication/NewPublicationPanel.tsx index 12fa405e967..e4e48da7146 100644 --- a/apps/studio/components/interfaces/Database/Replication/NewPublicationPanel.tsx +++ b/apps/studio/components/interfaces/Database/Replication/NewPublicationPanel.tsx @@ -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 ( <> - +
-
-
- New Publication - - Create a new publication to replicate table changes to destinations - -
- - - Close - -
+ Create a new Publication + Replicate table changes to destinations
@@ -134,9 +115,11 @@ export const NewPublicationPanel = ({ visible, sourceId, onClose }: NewPublicati onValuesChange={field.onChange} disabled={creatingPublication} > - - - + {tables?.map((table) => ( diff --git a/apps/studio/components/interfaces/Database/Replication/PublicationsComboBox.tsx b/apps/studio/components/interfaces/Database/Replication/PublicationsComboBox.tsx index f577f937723..0e021a1d6b1 100644 --- a/apps/studio/components/interfaces/Database/Replication/PublicationsComboBox.tsx +++ b/apps/studio/components/interfaces/Database/Replication/PublicationsComboBox.tsx @@ -78,10 +78,11 @@ export const PublicationsComboBox = ({ {selectedPublication || 'Select publication'} - + diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/CreateAnalyticsBucketForm.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/CreateAnalyticsBucketForm.tsx new file mode 100644 index 00000000000..2e2ce35a22f --- /dev/null +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/CreateAnalyticsBucketForm.tsx @@ -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 + +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({ + resolver: zodResolver(FormSchema), + defaultValues: { name: '' }, + }) + + const onSubmit: SubmitHandler = 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 ( + <> +
+ +
+ ( + + + + + + )} + /> + + {wrappersExtensionNeedsUpgrading ? ( + +

+ Update the wrappers extension by + upgrading your project from your{' '} + + project settings + {' '} + before creating an Analytics bucket.{' '} + + Learn more + + . +

+
+ ) : ( + +

+ Supabase will install the{' '} + {wrappersExtensionState !== 'installed' ? 'Wrappers extension and ' : ''} + Iceberg Wrapper integration on your behalf.{' '} + + Learn more + + . +

+
+ )} + +
+
+ +
+ + +
+ + ) +} diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/CreateAnalyticsBucketModal.utils.ts b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/CreateAnalyticsBucketForm.utils.ts similarity index 100% rename from apps/studio/components/interfaces/Storage/AnalyticsBuckets/CreateAnalyticsBucketModal.utils.ts rename to apps/studio/components/interfaces/Storage/AnalyticsBuckets/CreateAnalyticsBucketForm.utils.ts diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/CreateAnalyticsBucketModal.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/CreateAnalyticsBucketModal.tsx index bb8bd0c54c1..86b3609e38e 100644 --- a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/CreateAnalyticsBucketModal.tsx +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/CreateAnalyticsBucketModal.tsx @@ -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 +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({ - resolver: zodResolver(FormSchema), - defaultValues: { name: '' }, - }) - - const onSubmit: SubmitHandler = 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 ( - { - if (!open) handleClose() - }} - > + Create {config.singularName} bucket - - - -
- - ( - - - - - - )} - /> - - {wrappersExtenstionNeedsUpgrading ? ( - -

- Update the wrappers extension by - upgrading your project from your{' '} - - project settings - {' '} - before creating an Analytics bucket.{' '} - - Learn more - - . -

-
- ) : ( - -

- Supabase will install the{' '} - {wrappersExtensionState !== 'installed' ? 'Wrappers extension and ' : ''} - Iceberg Wrapper integration on your behalf.{' '} - - Learn more - - . -

-
- )} -
-
-
- - - - - +
) diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/CreateAnalyticsBucketSheet.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/CreateAnalyticsBucketSheet.tsx new file mode 100644 index 00000000000..732381fc0df --- /dev/null +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/CreateAnalyticsBucketSheet.tsx @@ -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 ( + + + + Create {config.singularName} bucket + + + + + ) +} diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/index.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/index.tsx index 19d3f328e3f..03c04784582 100644 --- a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/index.tsx +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/index.tsx @@ -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 = () => { - {analyticsBuckets.length} - /2 + {analyticsBuckets.length}/{maxAnalyticsBuckets} - - Each project can only have up to 2 buckets while analytics buckets are - in alpha{' '} + + Each project can only have up to {maxAnalyticsBuckets} buckets while + analytics buckets are in alpha{' '} )}