Analytics bucket support creating bucket tables in dashboard (#40858)

* Midway

* Hook up create/delete table, and update all iceberg RQs with mgmt API routes

* Add insert data CTA, update create table logic for decimal and fixed types

* Clean up

* Nit

* Fix test
This commit is contained in:
Joshen Lim authored and GitHub committed 2025-12-02 22:17:13 +08:00
1 parent 7860dad841
commit 27e89ed64e
30 files changed
+1211 -771

No files matched your search

@@ -7,7 +7,6 @@ import * as z from 'zod'
import { useFlag, useParams } from 'common'
import { useApiKeysVisibility } from 'components/interfaces/APIKeys/hooks/useApiKeysVisibility'
import { getCatalogURI } from 'components/interfaces/Storage/StorageSettings/StorageSettings.utils'
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'
@@ -221,19 +220,10 @@ export const DestinationPanel = ({
// Helper function to handle namespace creation if needed
const resolveNamespace = async (data: z.infer<typeof FormSchema>) => {
if (data.namespace === CREATE_NEW_NAMESPACE) {
if (!data.newNamespaceName) {
throw new Error('New namespace name is required')
}
// Construct catalog URI for namespace creation
const protocol = projectSettings?.app_config?.protocol ?? 'https'
const endpoint =
projectSettings?.app_config?.storage_endpoint || projectSettings?.app_config?.endpoint
const catalogUri = getCatalogURI(project?.ref ?? '', protocol, endpoint)
if (!data.newNamespaceName) throw new Error('New namespace name is required')
await createNamespace({
projectRef,
catalogUri,
warehouse: data.warehouseName!,
namespace: data.newNamespaceName,
})
@@ -1,10 +1,9 @@
import { Eye, EyeOff, Loader2 } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useState } from 'react'
import type { UseFormReturn } from 'react-hook-form'
import { useParams } from 'common'
import { useApiKeysVisibility } from 'components/interfaces/APIKeys/hooks/useApiKeysVisibility'
import { getCatalogURI } from 'components/interfaces/Storage/StorageSettings/StorageSettings.utils'
import { InlineLink } from 'components/ui/InlineLink'
import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query'
import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
@@ -154,30 +153,19 @@ export const AnalyticsBucketFields = ({
isError: isErrorBuckets,
} = useAnalyticsBucketsQuery({ projectRef })
// Construct catalog URI for iceberg namespaces query
const catalogUri = useMemo(() => {
if (!project?.ref || !projectSettings) return ''
const protocol = projectSettings.app_config?.protocol ?? 'https'
const endpoint =
projectSettings.app_config?.storage_endpoint || projectSettings.app_config?.endpoint
return getCatalogURI(project.ref, protocol, endpoint)
}, [project?.ref, projectSettings])
const canSelectNamespace = !!warehouseName && !!serviceApiKey
const {
data: namespaces = [],
isLoading: isLoadingNamespaces,
isError: isErrorNamespaces,
refetch: refetchNamespaces,
} = useIcebergNamespacesQuery(
{
projectRef,
catalogUri,
warehouse: warehouseName || '',
warehouse: warehouseName,
},
{
enabled: type === 'Analytics Bucket' && !!catalogUri && !!warehouseName && !!serviceApiKey,
enabled: type === 'Analytics Bucket' && !!serviceApiKey,
}
)
@@ -8,7 +8,7 @@ import {
} from 'components/layouts/Scaffold'
import { HIDE_REPLICATION_USER_FLOW } from './AnalyticsBucketDetails.constants'
import { ConnectTablesDialog } from './ConnectTablesDialog'
import { CreateTableInstructionsDialog } from './CreateTableInstructions/CreateTableInstructionsDialog'
import { CreateTableInstructionsDialog } from './CreateTable/CreateTableInstructionsDialog'
interface BucketHeaderProps {
showActions?: boolean
@@ -35,14 +35,12 @@ export const BucketHeader = ({
</div>
{showActions && (
<div className="flex items-center gap-x-2">
{namespaces.length > 0 && (
<>
{HIDE_REPLICATION_USER_FLOW ? (
<CreateTableInstructionsDialog />
) : (
<ConnectTablesDialog onSuccessConnectTables={onSuccessConnectTables} />
)}
</>
{HIDE_REPLICATION_USER_FLOW ? (
<CreateTableInstructionsDialog />
) : (
namespaces.length > 0 && (
<ConnectTablesDialog onSuccessConnectTables={onSuccessConnectTables} />
)
)}
</div>
)}
@@ -1,3 +1,4 @@
import { Eye, EyeOff } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useParams } from 'common'
@@ -13,7 +14,6 @@ import {
} from 'data/vault/vault-secret-decrypted-value-query'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { DOCS_URL } from 'lib/constants'
import { Eye, EyeOff } from 'lucide-react'
import {
Accordion_Shadcn_,
AccordionContent_Shadcn_,
@@ -0,0 +1,71 @@
import { useFlag } from 'common'
import { ChevronDown, Plus } from 'lucide-react'
import { useState } from 'react'
import {
Button,
cn,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from 'ui'
import { CreateTableInstructions } from './CreateTableInstructions'
import { CreateTableSheet } from './CreateTableSheet'
export const CreateTableInstructionsDialog = () => {
const enableCreationOfTablesFromDashboard = useFlag('analyticsBucketsTableCreation')
const [showModal, setShowModal] = useState(false)
const [showSheet, setShowSheet] = useState(false)
return (
<>
<div className="flex items-center">
<Button
type="primary"
icon={<Plus />}
className={cn(enableCreationOfTablesFromDashboard && 'rounded-r-none hover:z-10')}
onClick={() => {
if (enableCreationOfTablesFromDashboard) setShowSheet(true)
else setShowModal(true)
}}
>
Create table
</Button>
{enableCreationOfTablesFromDashboard && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="primary"
className="w-7 rounded-l-none -ml-[1px]"
icon={<ChevronDown />}
/>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-48" align="end">
<DropdownMenuItem onClick={() => setShowModal(true)}>Via Pyiceberg</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<Dialog open={showModal} onOpenChange={setShowModal}>
<DialogContent size="xlarge">
<DialogHeader>
<DialogTitle>Adding tables to your Analytics Bucket</DialogTitle>
<DialogDescription>
Tables can be created or added to your bucket via Pyiceberg
</DialogDescription>
</DialogHeader>
<CreateTableInstructions hideHeader className="rounded-t-none border-x-0 border-b-0" />
</DialogContent>
</Dialog>
<CreateTableSheet open={showSheet} onOpenChange={setShowSheet} />
</>
)
}
@@ -0,0 +1,26 @@
export const NEW_NAMESPACE_MARKER = 'new-namespace'
export const COLUMN_TYPES = [
'boolean',
'int',
'long',
'float',
'double',
'string',
'timestamp',
'date',
'time',
'timestamptz',
'uuid',
'binary',
'decimal',
'fixed',
]
export const COLUMN_TYPE_FIELDS = {
decimal: [
{ name: 'precision', type: 'number' },
{ name: 'scale', type: 'number' },
],
fixed: [{ name: 'length', type: 'number' }],
}
@@ -0,0 +1,82 @@
import { z } from 'zod'
import { NEW_NAMESPACE_MARKER } from './CreateTableSheet.constants'
const getValidRegex = (type: 'namespace' | 'table') =>
type === 'namespace'
? /^(?!aws)[a-z0-9](?:[a-z0-9_]*[a-z0-9])?$/
: /^[a-z0-9](?:[a-z0-9_]*[a-z0-9])?$/
const getErrorRegex = (type: 'namespace' | 'table') =>
type === 'namespace'
? /^(?:(?<starts_with_reserved>aws.*)|(?<invalid_start>[^a-z0-9].*)|(?<invalid_char>.*[^a-z0-9_].*)|(?<invalid_end>.*[^a-z0-9]))$/
: /^(?:(?<invalid_start>[^a-z0-9].*)|(?<invalid_char>.*[^a-z0-9_].*)|(?<invalid_end>.*[^a-z0-9]))$/
const validateName = ({ name, type }: { name: string; type: 'namespace' | 'table' }) => {
const validRe = getValidRegex(type)
if (validRe.test(name)) return undefined
const errorRe = getErrorRegex(type)
const match = name.match(errorRe)?.groups || {}
if (match.starts_with_reserved) return "Namespace must not start with 'aws'"
if (match.invalid_start) return 'Name must begin with a lowercase letter or number'
if (match.invalid_end) return 'Name must end with a lowercase letter or number'
if (match.invalid_char) return 'Name may only contain lowercase letters, numbers, and underscores'
return 'Invalid name'
}
export const createFormSchema = () =>
z
.object({
namespace: z.string().min(1, 'Please select a namespace'),
newNamespace: z.string().max(255, 'Name must be within 255 characters').optional(),
name: z
.string()
.min(1, 'Provide a name for your table')
.max(255, 'Name must be within 255 characters'),
columns: z
.object({
name: z.string().min(1, 'Provide a name for your column'),
type: z.string().min(1, 'Select a type for your column'),
// For decimal type
precision: z.number().optional(),
scale: z.number().int().optional(),
// For fixed type
length: z.number().int().optional(),
})
.array()
.default([]),
})
.superRefine((data, ctx) => {
if (data.namespace === NEW_NAMESPACE_MARKER) {
if (data.newNamespace) {
const newNamespaceError = validateName({
name: data.newNamespace,
type: 'namespace',
})
if (newNamespaceError) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: newNamespaceError,
path: ['newNamespace'],
})
}
} else {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Provide a name for your new namespace',
path: ['newNamespace'],
})
}
}
if (data.name) {
const newTableError = validateName({ name: data.name, type: 'table' })
if (newTableError) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: newTableError,
path: ['name'],
})
}
}
})
@@ -0,0 +1,372 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { Plus, X } from 'lucide-react'
import { Fragment, useState } from 'react'
import { SubmitHandler, useFieldArray, useForm } from 'react-hook-form'
import { toast } from 'sonner'
import { z } from 'zod'
import { useParams } from 'common'
import { useIcebergNamespaceCreateMutation } from 'data/storage/iceberg-namespace-create-mutation'
import {
NamespaceTableFields,
useIcebergNamespaceTableCreateMutation,
} from 'data/storage/iceberg-namespace-table-create-mutation'
import { useIcebergNamespaceTablesQuery } from 'data/storage/iceberg-namespace-tables-query'
import { useIcebergNamespacesQuery } from 'data/storage/iceberg-namespaces-query'
import {
Button,
DialogSectionSeparator,
Form_Shadcn_,
FormControl_Shadcn_,
FormField_Shadcn_,
Input_Shadcn_,
Select_Shadcn_,
SelectContent_Shadcn_,
SelectItem_Shadcn_,
SelectSeparator_Shadcn_,
SelectTrigger_Shadcn_,
SelectValue_Shadcn_,
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetSection,
SheetTitle,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { COLUMN_TYPE_FIELDS, COLUMN_TYPES } from './CreateTableSheet.constants'
import { createFormSchema } from './CreateTableSheet.schema'
const formId = 'create-namespace-table'
const NEW_NAMESPACE_MARKER = 'new-namespace'
interface CreateTableSheetProps {
open: boolean
onOpenChange: (value: boolean) => void
}
export const CreateTableSheet = ({ open, onOpenChange }: CreateTableSheetProps) => {
const { ref: projectRef, bucketId } = useParams()
const [isCreating, setIsCreating] = useState(false)
const FormSchema = createFormSchema()
const defaultValues = {
namespace: '',
newNamespace: undefined,
name: '',
columns: [{ name: '', type: 'string' as any }],
}
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues,
mode: 'onChange',
})
const { namespace } = form.watch()
const {
fields: columns,
append: appendColumn,
remove: removeColumn,
} = useFieldArray({ control: form.control, name: 'columns' })
const { data: namespaces = [] } = useIcebergNamespacesQuery({ projectRef, warehouse: bucketId })
const { data: tables = [] } = useIcebergNamespaceTablesQuery(
{
projectRef,
warehouse: bucketId,
namespace,
},
{ enabled: namespace !== NEW_NAMESPACE_MARKER }
)
const { mutateAsync: createNamespace } = useIcebergNamespaceCreateMutation()
const { mutateAsync: createTable } = useIcebergNamespaceTableCreateMutation()
const onSubmit: SubmitHandler<z.infer<typeof FormSchema>> = async (values) => {
if (!bucketId) return console.error('Bucket ID is missing')
if (namespaces.includes(values.newNamespace ?? '')) {
return form.setError('newNamespace', { message: 'Namespace name already exists' })
}
if (tables.includes(values.name ?? '')) {
return form.setError('name', { message: 'Table name already exists' })
}
const isCreatingNewNamespace =
values.namespace === NEW_NAMESPACE_MARKER && !!values.newNamespace
try {
setIsCreating(true)
if (isCreatingNewNamespace) {
await createNamespace({
projectRef,
warehouse: bucketId,
namespace: values.newNamespace as string,
})
}
const fields = values.columns.map((column, idx) => {
return {
id: idx + 1,
name: column.name,
type:
column.type === 'decimal'
? `decimal(${column.precision}, ${column.scale})`
: column.type === 'fixed'
? `fixed[${column.length}]`
: column.type,
required: false,
}
}) as NamespaceTableFields
await createTable({
projectRef,
warehouse: bucketId,
namespace: isCreatingNewNamespace ? (values.newNamespace as string) : values.namespace,
name: values.name,
fields,
})
toast.success(`Successfully created table in ${values.newNamespace ?? values.namespace}!`)
onOpenChange(false)
form.reset(defaultValues)
} catch (error) {
} finally {
setIsCreating(false)
}
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<Form_Shadcn_ {...form}>
<form id={formId} className="flex flex-col gap-4" onSubmit={form.handleSubmit(onSubmit)}>
<SheetContent aria-describedby={undefined} className="flex flex-col gap-0">
<SheetHeader className="shrink-0 flex items-center gap-4">
<SheetTitle>Create a new table</SheetTitle>
</SheetHeader>
<SheetSection className="overflow-auto flex-grow p-0">
<div className="flex flex-col gap-y-4 py-4 px-5">
<FormField_Shadcn_
name="namespace"
control={form.control}
render={({ field }) => (
<FormItemLayout
name="namespace"
label="Select a namespace to create your table in"
>
<FormControl_Shadcn_>
<Select_Shadcn_
value={field.value}
onValueChange={(value) => {
field.onChange(value)
form.resetField('newNamespace')
}}
>
<SelectTrigger_Shadcn_>
<SelectValue_Shadcn_ placeholder="Select a namespace" />
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
{namespaces.map((x) => (
<SelectItem_Shadcn_ key={x} value={x}>
{x}
</SelectItem_Shadcn_>
))}
{namespaces.length > 0 && <SelectSeparator_Shadcn_ />}
<SelectItem_Shadcn_ value={NEW_NAMESPACE_MARKER}>
<div className="flex items-center gap-x-2">
<Plus size={14} />
<p>Create a new namespace</p>
</div>
</SelectItem_Shadcn_>
</SelectContent_Shadcn_>
</Select_Shadcn_>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
{namespace === NEW_NAMESPACE_MARKER && (
<FormField_Shadcn_
name="newNamespace"
control={form.control}
render={({ field }) => (
<FormItemLayout name="newNamespace" label="Name of new namespace">
<FormControl_Shadcn_>
<Input_Shadcn_
{...field}
placeholder="Provide a name for your new namespace"
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
)}
</div>
<DialogSectionSeparator />
{!!namespace && (
<div className="px-5 py-4 flex flex-col gap-y-4">
<FormField_Shadcn_
name="name"
control={form.control}
render={({ field }) => (
<FormItemLayout name="name" label="Name of table">
<FormControl_Shadcn_>
<Input_Shadcn_
{...field}
placeholder="Provide a name for your new table"
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<div className="flex flex-col gap-y-2">
<div className="flex items-center justify-between">
<p className="text-sm">Columns</p>
<Button
type="default"
icon={<Plus />}
onClick={() => appendColumn({ name: '', type: 'string' })}
>
Add column
</Button>
</div>
{columns.length === 0 ? (
<div className="flex items-center justify-center rounded border border-strong border-dashed py-4 text-foreground-lighter text-sm">
Add a column to your table
</div>
) : (
<>
<div className="grid grid-cols-[1fr,1fr,32px]">
<p className="text-xs text-foreground-lighter">Name</p>
<p className="text-xs text-foreground-lighter">Type</p>
</div>
{columns.map((_, idx) => {
const columnType = form.watch(`columns.${idx}.type`)
const additionalFields =
COLUMN_TYPE_FIELDS[columnType as keyof typeof COLUMN_TYPE_FIELDS] ?? []
return (
<Fragment key={`column-${idx}`}>
<div className="grid grid-cols-[1fr,1fr,32px] gap-x-1">
<FormField_Shadcn_
control={form.control}
name={`columns.${idx}.name`}
render={({ field }) => (
<FormControl_Shadcn_>
<Input_Shadcn_
{...field}
placeholder="Provide a column name"
disabled={isCreating}
className="h-auto"
/>
</FormControl_Shadcn_>
)}
/>
<FormField_Shadcn_
control={form.control}
name={`columns.${idx}.type`}
render={({ field }) => (
<FormControl_Shadcn_>
<Select_Shadcn_
value={field.value}
onValueChange={field.onChange}
>
<SelectTrigger_Shadcn_ className="h-auto">
<SelectValue_Shadcn_ placeholder="Select a type" />
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
{COLUMN_TYPES.map((x) => (
<SelectItem_Shadcn_ key={x} value={x}>
{x}
</SelectItem_Shadcn_>
))}
</SelectContent_Shadcn_>
</Select_Shadcn_>
</FormControl_Shadcn_>
)}
/>
<div className="flex items-center justify-center">
<Button
type="text"
size="tiny"
icon={<X strokeWidth={1.5} size={14} />}
className="w-6 h-6"
onClick={() => removeColumn(idx)}
/>
</div>
{additionalFields.length > 0 && (
<div className="col-span-full flex items-center mt-2">
<div className="grid grid-cols-2 gap-x-1 w-[85%]">
{additionalFields.map((x, index) => (
<div
className="flex items-center"
style={
additionalFields.length % 2 === 1 && index % 2 === 0
? { gridColumnStart: 2 }
: undefined
}
>
<div className="font-mono text-xs px-2 border border-r-0 rounded-l h-full flex items-center justify-center">
{x.name}
</div>
<FormField_Shadcn_
control={form.control}
key={`columns.${idx}.${x.name}`}
name={`columns.${idx}.${x.name}` as any}
render={({ field }) => (
<FormControl_Shadcn_>
<Input_Shadcn_
{...field}
type={x.type === 'number' ? 'number' : 'text'}
disabled={isCreating}
className="h-[34px] rounded-l-none"
{...form.register(
`columns.${idx}.${x.name}` as any,
{
valueAsNumber: true, // Ensure the value is handled as a number
}
)}
/>
</FormControl_Shadcn_>
)}
/>
</div>
))}
</div>
<div className="w-4 h-[1.6rem] border-r border-b rounded-br mr-3 border-control -translate-y-3" />
</div>
)}
</div>
</Fragment>
)
})}
</>
)}
</div>
</div>
)}
</SheetSection>
<SheetFooter>
<Button
disabled={isCreating}
type="default"
onClick={() => {
onOpenChange(false)
form.reset(defaultValues)
}}
>
Cancel
</Button>
<Button form={formId} htmlType="submit" loading={isCreating}>
Create table
</Button>
</SheetFooter>
</SheetContent>
</form>
</Form_Shadcn_>
</Sheet>
)
}
@@ -1,32 +0,0 @@
import { Plus } from 'lucide-react'
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from 'ui'
import { CreateTableInstructions } from '.'
export const CreateTableInstructionsDialog = () => {
return (
<Dialog>
<DialogTrigger asChild>
<Button type="primary" icon={<Plus />}>
Create table
</Button>
</DialogTrigger>
<DialogContent size="xlarge">
<DialogHeader>
<DialogTitle>Adding tables to your Analytics Bucket</DialogTitle>
<DialogDescription>
Tables can be created or added to your bucket via Pyiceberg
</DialogDescription>
</DialogHeader>
<CreateTableInstructions hideHeader className="rounded-t-none border-x-0 border-b-0" />
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,87 @@
import { useParams } from 'common'
import { DocsButton } from 'components/ui/DocsButton'
import { FDWTable } from 'data/fdw/fdws-query'
import { SqlEditor } from 'icons'
import { DOCS_URL } from 'lib/constants'
import Link from 'next/link'
import {
Button,
cn,
CodeBlock,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogSection,
DialogSectionSeparator,
DialogTitle,
DialogTrigger,
} from 'ui'
interface InsertDataDialogProps {
table: string
fdwTable: FDWTable
}
export const InsertDataDialog = ({ table, fdwTable }: InsertDataDialogProps) => {
const { ref } = useParams()
const sql = /* SQL */ `
insert into ${fdwTable.schema}.${fdwTable.name} (
-- specify columns
)
values (
-- specify values for each column
);
`.trim()
return (
<Dialog>
<DialogTrigger asChild>
<Button type="default">Insert data</Button>
</DialogTrigger>
<DialogContent aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>
Insert data into <code className="text-code-inline">{table}</code>
</DialogTitle>
</DialogHeader>
<DialogSectionSeparator />
<DialogSection className="flex flex-col gap-y-2">
<p className="text-sm">
The Iceberg Foreign Data Wrapper (FDW) supports inserting data into Iceberg tables using
standard SQL <code className="text-code-inline">INSERT</code> statements.
</p>
<p className="text-sm">
Use the following SQL snippet to insert data into your iceberg table:
</p>
</DialogSection>
<DialogSectionSeparator />
<DialogSection className="!p-0">
<CodeBlock
hideLineNumbers
wrapperClassName={cn('[&_pre]:px-4 [&_pre]:py-3 [&>pre]:rounded-none [&>pre]:border-0')}
className="[&_code]:text-foreground"
language="sql"
value={sql}
/>
</DialogSection>
<DialogFooter>
<DocsButton
href={`${DOCS_URL}/guides/database/extensions/wrappers/iceberg#data-insertion`}
/>
<Button asChild type="default" icon={<SqlEditor />}>
<Link href={`/project/${ref}/sql/new?content=${encodeURIComponent(sql)}`}>
Open in SQL Editor
</Link>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -12,12 +12,12 @@ import {
import { getDecryptedParameters } from 'components/interfaces/Storage/ImportForeignSchemaDialog.utils'
import { DotPing } from 'components/ui/DotPing'
import { DropdownMenuItemTooltip } from 'components/ui/DropdownMenuItemTooltip'
import { useFDWDropForeignTableMutation } from 'data/fdw/fdw-drop-foreign-table-mutation'
import { useFDWUpdateMutation } from 'data/fdw/fdw-update-mutation'
import { useReplicationPipelineStatusQuery } from 'data/replication/pipeline-status-query'
import { useUpdatePublicationMutation } from 'data/replication/publication-update-mutation'
import { useStartPipelineMutation } from 'data/replication/start-pipeline-mutation'
import { useReplicationTablesQuery } from 'data/replication/tables-query'
import { useFDWDropForeignTableMutation } from 'data/fdw/fdw-drop-foreign-table-mutation'
import { useFDWUpdateMutation } from 'data/fdw/fdw-update-mutation'
import { useIcebergNamespaceTableDeleteMutation } from 'data/storage/iceberg-namespace-table-delete-mutation'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { SqlEditor, TableEditor } from 'icons'
@@ -43,6 +43,7 @@ import {
} from '../AnalyticsBucketDetails.utils'
import { useAnalyticsBucketAssociatedEntities } from '../useAnalyticsBucketAssociatedEntities'
import { useAnalyticsBucketWrapperInstance } from '../useAnalyticsBucketWrapperInstance'
import { InsertDataDialog } from './InsertDataDialog'
import { inferPostgresTableFromNamespaceTable } from './NamespaceWithTables.utils'
interface TableRowComponentProps {
@@ -78,7 +79,7 @@ export const TableRowComponent = ({ table, schema, namespace }: TableRowComponen
const { mutateAsync: updateFDW } = useFDWUpdateMutation()
const { mutateAsync: dropForeignTable } = useFDWDropForeignTableMutation()
const { mutateAsync: deleteNamespaceTable, isLoading: isDeletingNamespaceTable } =
const { mutateAsync: deleteNamespaceTable, isPending: isDeletingNamespaceTable } =
useIcebergNamespaceTableDeleteMutation({ onError: () => {} })
const { mutateAsync: updatePublication } = useUpdatePublicationMutation()
const { mutateAsync: startPipeline } = useStartPipelineMutation()
@@ -227,7 +228,6 @@ export const TableRowComponent = ({ table, schema, namespace }: TableRowComponen
const wrapperValues = convertKVStringArrayToJson(wrapperInstance?.server_options ?? [])
await deleteNamespaceTable({
projectRef,
catalogUri: wrapperValues.catalog_uri,
warehouse: wrapperValues.warehouse,
namespace: namespace,
table: table.name,
@@ -257,7 +257,6 @@ export const TableRowComponent = ({ table, schema, namespace }: TableRowComponen
const wrapperValues = convertKVStringArrayToJson(wrapperInstance?.server_options ?? [])
await deleteNamespaceTable({
projectRef,
catalogUri: wrapperValues.catalog_uri,
warehouse: wrapperValues.warehouse,
namespace: namespace,
table: table.name,
@@ -419,7 +418,7 @@ export const TableRowComponent = ({ table, schema, namespace }: TableRowComponen
<TableRow key={x.id}>
<TableCell className="pl-6">
<div className="flex items-center gap-x-2 rounded">
<div className="w-4 h-4 rounded-bl-lg border-l-2 border-b-2 border-control -translate-y-1.5" />
<div className="w-4 h-5 rounded-bl-lg border-l-2 border-b-2 border-control -translate-y-2" />
<div
className={cn(
'flex items-center justify-center text-xs h-4 w-4 rounded-[2px] font-bold',
@@ -433,7 +432,8 @@ export const TableRowComponent = ({ table, schema, namespace }: TableRowComponen
</p>
</div>
</TableCell>
<TableCell className="flex flex-row justify-end">
<TableCell className="flex flex-row justify-end gap-x-2">
<InsertDataDialog table={table.name} fdwTable={x} />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" className="w-7" icon={<MoreVertical />} />
@@ -5,7 +5,6 @@ import { toast } from 'sonner'
import { useParams } from 'common'
import { FormattedWrapperTable } from 'components/interfaces/Integrations/Wrappers/Wrappers.utils'
import { ImportForeignSchemaDialog } from 'components/interfaces/Storage/ImportForeignSchemaDialog'
import { getCatalogURI } from 'components/interfaces/Storage/StorageSettings/StorageSettings.utils'
import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
import { useFDWDropForeignTableMutation } from 'data/fdw/fdw-drop-foreign-table-mutation'
import { useFDWImportForeignSchemaMutation } from 'data/fdw/fdw-import-foreign-schema-mutation'
@@ -79,7 +78,6 @@ export const NamespaceWithTables = ({
isSuccess: isSuccessNamespaceTables,
} = useIcebergNamespaceTablesQuery(
{
catalogUri: wrapperValues.catalog_uri,
warehouse: wrapperValues.warehouse,
namespace: namespace,
projectRef,
@@ -179,11 +177,6 @@ export const NamespaceWithTables = ({
const onConfirmDeleteNamespace = async () => {
if (!bucketId) return console.error('Bucket ID is required')
// Construct catalog URI for namespace creation
const protocol = projectSettings?.app_config?.protocol ?? 'https'
const endpoint =
projectSettings?.app_config?.storage_endpoint || projectSettings?.app_config?.endpoint
const catalogUri = getCatalogURI(project?.ref ?? '', protocol, endpoint)
try {
setIsDeletingNamespace(true)
@@ -193,7 +186,6 @@ export const NamespaceWithTables = ({
allTables.map((table) =>
deleteNamespaceTable({
projectRef,
catalogUri,
warehouse: bucketId,
namespace,
table: table.name,
@@ -213,7 +205,7 @@ export const NamespaceWithTables = ({
)
)
await deleteNamespace({ projectRef, catalogUri, warehouse: bucketId, namespace })
await deleteNamespace({ projectRef, warehouse: bucketId, namespace })
toast.success(`Successfully deleted namespace "${namespace}"`)
setShowConfirmDeleteNamespace(false)
@@ -38,7 +38,7 @@ import { useSelectedAnalyticsBucket } from '../useSelectedAnalyticsBucket'
import { HIDE_REPLICATION_USER_FLOW } from './AnalyticsBucketDetails.constants'
import { BucketHeader } from './BucketHeader'
import { ConnectTablesDialog } from './ConnectTablesDialog'
import { CreateTableInstructions } from './CreateTableInstructions'
import { CreateTableInstructions } from './CreateTable/CreateTableInstructions'
import { NamespaceWithTables } from './NamespaceWithTables'
import { SimpleConfigurationDetails } from './SimpleConfigurationDetails'
import { useAnalyticsBucketAssociatedEntities } from './useAnalyticsBucketAssociatedEntities'
@@ -120,7 +120,6 @@ export const AnalyticBucketDetails = () => {
} = useIcebergNamespacesQuery(
{
projectRef,
catalogUri: wrapperValues.catalog_uri,
warehouse: wrapperValues.warehouse,
},
{
@@ -1,28 +1,21 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { Plus } from 'lucide-react'
import { useRouter } from 'next/router'
import { parseAsBoolean, useQueryState } from 'nuqs'
import { SubmitHandler, useForm } from 'react-hook-form'
import { toast } from 'sonner'
import z from 'zod'
import { useParams } from 'common'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { InlineLink } from 'components/ui/InlineLink'
import { useIsAnalyticsBucketsEnabled } from 'data/config/project-storage-config-query'
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 { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { DOCS_URL } from 'lib/constants'
import {
Button,
cn,
Dialog,
DialogContent,
DialogFooter,
@@ -30,7 +23,6 @@ import {
DialogSection,
DialogSectionSeparator,
DialogTitle,
DialogTrigger,
Form_Shadcn_,
FormControl_Shadcn_,
FormField_Shadcn_,
@@ -122,40 +114,22 @@ const formId = 'create-analytics-storage-bucket-form'
export type CreateAnalyticsBucketForm = z.infer<typeof FormSchema>
interface CreateAnalyticsBucketModalProps {
buttonSize?: 'tiny' | 'small'
buttonType?: 'default' | 'primary'
buttonClassName?: string
disabled?: boolean
tooltip?: {
content: {
side?: 'top' | 'bottom' | 'left' | 'right'
text?: string
}
}
open: boolean
onOpenChange: (value: boolean) => void
}
export const CreateAnalyticsBucketModal = ({
buttonSize = 'tiny',
buttonType = 'default',
buttonClassName,
disabled = false,
tooltip,
open,
onOpenChange,
}: CreateAnalyticsBucketModalProps) => {
const router = useRouter()
const { ref } = useParams()
const { data: org } = useSelectedOrganizationQuery()
const { data: project } = useSelectedProjectQuery()
const { can: canCreateBuckets } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*')
const { extension: wrappersExtension, state: wrappersExtensionState } =
useIcebergWrapperExtension()
const [visible, setVisible] = useQueryState(
'new',
parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true })
)
const { data: buckets = [], isLoading } = useAnalyticsBucketsQuery({ projectRef: ref })
const icebergCatalogEnabled = useIsAnalyticsBucketsEnabled({ projectRef: ref })
const { data: buckets = [] } = useAnalyticsBucketsQuery({ projectRef: ref })
const wrappersExtenstionNeedsUpgrading = wrappersExtensionState === 'needs-upgrade'
const { mutate: sendEvent } = useSendEventMutation()
@@ -174,8 +148,6 @@ export const CreateAnalyticsBucketModal = ({
const config = BUCKET_TYPES['analytics']
const isCreating = isEnablingExtension || isCreatingIcebergWrapper || isCreatingAnalyticsBucket
const isDisabled =
!canCreateBuckets || !icebergCatalogEnabled || isLoading || buckets.length >= 2 || disabled
const form = useForm<CreateAnalyticsBucketForm>({
resolver: zodResolver(FormSchema),
@@ -217,8 +189,7 @@ export const CreateAnalyticsBucketModal = ({
form.reset()
toast.success(`Created bucket “${values.name}”`)
setVisible(false)
router.push(`/project/${ref}/storage/analytics/buckets/${values.name}`)
onOpenChange(false)
} catch (error: any) {
toast.error(`Failed to create bucket: ${error.message}`)
}
@@ -226,44 +197,16 @@ export const CreateAnalyticsBucketModal = ({
const handleClose = () => {
form.reset()
setVisible(false)
onOpenChange(false)
}
return (
<Dialog
open={visible}
open={open}
onOpenChange={(open) => {
if (!open) handleClose()
}}
>
<DialogTrigger asChild>
<ButtonTooltip
block
size={buttonSize}
type={buttonType}
className={buttonClassName}
icon={<Plus size={14} />}
disabled={isDisabled}
style={{ justifyContent: 'start' }}
onClick={() => setVisible(true)}
tooltip={{
content: {
side: tooltip?.content?.side || 'bottom',
className: cn(!icebergCatalogEnabled ? 'w-72 text-center' : ''),
text: !icebergCatalogEnabled
? 'Analytics buckets are not enabled for your project. Please contact support to enable it.'
: !canCreateBuckets
? 'You need additional permissions to create buckets'
: buckets.length >= 2
? 'Bucket limit reached'
: tooltip?.content?.text,
},
}}
>
New bucket
</ButtonTooltip>
</DialogTrigger>
<DialogContent size="large" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>Create {config.singularName} bucket</DialogTitle>
@@ -336,7 +279,7 @@ export const CreateAnalyticsBucketModal = ({
</Form_Shadcn_>
<DialogFooter>
<Button type="default" disabled={isCreating} onClick={() => setVisible(false)}>
<Button type="default" disabled={isCreating} onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
@@ -1,5 +1,6 @@
import { ChevronRight, Search } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { parseAsBoolean, useQueryState } from 'nuqs'
import { useState } from 'react'
import { useParams } from 'common'
@@ -26,6 +27,7 @@ import { Input } from 'ui-patterns/DataInputs/Input'
import { PageContainer } from 'ui-patterns/PageContainer'
import { PageSection, PageSectionContent, PageSectionTitle } from 'ui-patterns/PageSection'
import { EmptyBucketState } from '../EmptyBucketState'
import { CreateBucketButton } from '../NewBucketButton'
import { CreateAnalyticsBucketModal } from './CreateAnalyticsBucketModal'
export const AnalyticsBuckets = () => {
@@ -34,6 +36,11 @@ export const AnalyticsBuckets = () => {
const [filterString, setFilterString] = useState('')
const [visible, setVisible] = useQueryState(
'new',
parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true })
)
const {
data: buckets = [],
error: bucketsError,
@@ -64,133 +71,142 @@ export const AnalyticsBuckets = () => {
}
return (
<PageContainer>
<PageSection>
<PageSectionContent>
<AlphaNotice
entity="Analytics buckets"
feedbackUrl="https://github.com/orgs/supabase/discussions/40116"
/>
<>
<PageContainer>
<PageSection>
<PageSectionContent>
<AlphaNotice
entity="Analytics buckets"
feedbackUrl="https://github.com/orgs/supabase/discussions/40116"
/>
{isLoadingBuckets && <GenericSkeletonLoader />}
{isLoadingBuckets && <GenericSkeletonLoader />}
{isErrorBuckets && (
<AlertError error={bucketsError} subject="Failed to retrieve analytics buckets" />
)}
{isErrorBuckets && (
<AlertError error={bucketsError} subject="Failed to retrieve analytics buckets" />
)}
{isSuccessBuckets && (
<>
{hasNoBuckets ? (
<EmptyBucketState bucketType="analytics" />
) : (
<div className="flex flex-col gap-y-4">
<div className="flex flex-row items-center gap-x-2">
<PageSectionTitle>Buckets</PageSectionTitle>
{analyticsBuckets.length > 0 && (
<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
</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>
</Tooltip>
{isSuccessBuckets && (
<>
{hasNoBuckets ? (
<EmptyBucketState
bucketType="analytics"
onCreateBucket={() => setVisible(true)}
/>
) : (
<div className="flex flex-col gap-y-4">
<div className="flex flex-row items-center gap-x-2">
<PageSectionTitle>Buckets</PageSectionTitle>
{analyticsBuckets.length > 0 && (
<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
</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>
</Tooltip>
)}
</div>
<div className="flex flex-grow justify-between gap-x-2 items-center">
<Input
size="tiny"
className="flex-grow lg:flex-grow-0 w-52"
placeholder="Search for a bucket"
value={filterString}
onChange={(e) => setFilterString(e.target.value)}
icon={<Search />}
/>
<CreateBucketButton onClick={() => setVisible(true)} />
</div>
{isLoadingBuckets ? (
<GenericSkeletonLoader />
) : (
<Card>
<Table>
<TableHeader>
<TableRow>
{analyticsBuckets.length > 0 && (
<TableHead className="w-2 pr-1">
<span className="sr-only">Icon</span>
</TableHead>
)}
<TableHead>Name</TableHead>
<TableHead>Created at</TableHead>
<TableHead>
<span className="sr-only">Actions</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{analyticsBuckets.length === 0 && filterString.length > 0 && (
<TableRow className="[&>td]:hover:bg-inherit">
<TableCell colSpan={3}>
<p className="text-sm text-foreground">No results found</p>
<p className="text-sm text-foreground-light">
Your search for "{filterString}" did not return any results
</p>
</TableCell>
</TableRow>
)}
{analyticsBuckets.map((bucket) => (
<TableRow
key={bucket.name}
className="relative cursor-pointer h-16 inset-focus"
onClick={(event) => handleBucketNavigation(bucket.name, event)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
handleBucketNavigation(bucket.name, event)
}
}}
tabIndex={0}
>
<TableCell className="w-2 pr-1">
<AnalyticsBucketIcon
size={16}
className="text-foreground-muted"
/>
</TableCell>
<TableCell>
<p className="whitespace-nowrap max-w-[512px] truncate">
{bucket.name}
</p>
</TableCell>
<TableCell>
<p className="text-foreground-light">
<TimestampInfo
utcTimestamp={bucket.created_at}
className="text-sm text-foreground-light"
/>
</p>
</TableCell>
<TableCell>
<div className="flex justify-end items-center h-full">
<ChevronRight size={14} className="text-foreground-muted/60" />
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
)}
</div>
<div className="flex flex-grow justify-between gap-x-2 items-center">
<Input
size="tiny"
className="flex-grow lg:flex-grow-0 w-52"
placeholder="Search for a bucket"
value={filterString}
onChange={(e) => setFilterString(e.target.value)}
icon={<Search />}
/>
<CreateAnalyticsBucketModal buttonType="primary" buttonClassName="w-fit" />
</div>
{isLoadingBuckets ? (
<GenericSkeletonLoader />
) : (
<Card>
<Table>
<TableHeader>
<TableRow>
{analyticsBuckets.length > 0 && (
<TableHead className="w-2 pr-1">
<span className="sr-only">Icon</span>
</TableHead>
)}
<TableHead>Name</TableHead>
<TableHead>Created at</TableHead>
<TableHead>
<span className="sr-only">Actions</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{analyticsBuckets.length === 0 && filterString.length > 0 && (
<TableRow className="[&>td]:hover:bg-inherit">
<TableCell colSpan={3}>
<p className="text-sm text-foreground">No results found</p>
<p className="text-sm text-foreground-light">
Your search for "{filterString}" did not return any results
</p>
</TableCell>
</TableRow>
)}
{analyticsBuckets.map((bucket) => (
<TableRow
key={bucket.name}
className="relative cursor-pointer h-16 inset-focus"
onClick={(event) => handleBucketNavigation(bucket.name, event)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
handleBucketNavigation(bucket.name, event)
}
}}
tabIndex={0}
>
<TableCell className="w-2 pr-1">
<AnalyticsBucketIcon size={16} className="text-foreground-muted" />
</TableCell>
<TableCell>
<p className="whitespace-nowrap max-w-[512px] truncate">
{bucket.name}
</p>
</TableCell>
<TableCell>
<p className="text-foreground-light">
<TimestampInfo
utcTimestamp={bucket.created_at}
className="text-sm text-foreground-light"
/>
</p>
</TableCell>
<TableCell>
<div className="flex justify-end items-center h-full">
<ChevronRight size={14} className="text-foreground-muted/60" />
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
)}
</div>
)}
</>
)}
</PageSectionContent>
</PageSection>
</PageContainer>
)}
</>
)}
</PageSectionContent>
</PageSection>
</PageContainer>
<CreateAnalyticsBucketModal open={visible} onOpenChange={setVisible} />
</>
)
}
@@ -1,20 +1,15 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { Plus } from 'lucide-react'
import { useState } from 'react'
import { SubmitHandler, useForm } from 'react-hook-form'
import { toast } from 'sonner'
import z from 'zod'
import { parseAsBoolean, useQueryState } from 'nuqs'
import { useParams } from 'common'
import { StorageSizeUnits } from 'components/interfaces/Storage/StorageSettings/StorageSettings.constants'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { InlineLink } from 'components/ui/InlineLink'
import { useProjectStorageConfigQuery } from 'data/config/project-storage-config-query'
import { useBucketCreateMutation } from 'data/storage/bucket-create-mutation'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { IS_PLATFORM } from 'lib/constants'
import {
@@ -26,7 +21,6 @@ import {
DialogSection,
DialogSectionSeparator,
DialogTitle,
DialogTrigger,
Form_Shadcn_,
FormControl_Shadcn_,
FormField_Shadcn_,
@@ -85,30 +79,17 @@ const formId = 'create-storage-bucket-form'
export type CreateBucketForm = z.infer<typeof FormSchema>
interface CreateBucketModalProps {
buttonSize?: 'tiny' | 'small'
buttonType?: 'default' | 'primary'
buttonClassName?: string
label?: string
open: boolean
onOpenChange: (value: boolean) => void
}
export const CreateBucketModal = ({
buttonSize = 'tiny',
buttonType = 'default',
buttonClassName,
label = 'New bucket',
}: CreateBucketModalProps) => {
export const CreateBucketModal = ({ open, onOpenChange }: CreateBucketModalProps) => {
const { ref } = useParams()
const { data: org } = useSelectedOrganizationQuery()
const [visible, setVisible] = useQueryState(
'new',
parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true })
)
const [selectedUnit, setSelectedUnit] = useState<string>(StorageSizeUnits.MB)
const [hasAllowedMimeTypes, setHasAllowedMimeTypes] = useState(false)
const { can: canCreateBuckets } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*')
const { data } = useProjectStorageConfigQuery({ projectRef: ref }, { enabled: IS_PLATFORM })
const { value, unit } = convertFromBytes(data?.fileSizeLimit ?? 0)
const formattedGlobalUploadLimit = `${value} ${unit}`
@@ -172,7 +153,7 @@ export const CreateBucketModal = ({
toast.success(`Successfully created bucket ${values.name}`)
form.reset()
setSelectedUnit(StorageSizeUnits.MB)
setVisible(false)
onOpenChange(false)
} catch (error: any) {
// Handle specific error cases for inline display
const errorMessage = error.message?.toLowerCase() || ''
@@ -196,42 +177,18 @@ export const CreateBucketModal = ({
const handleClose = () => {
form.reset()
setSelectedUnit(StorageSizeUnits.MB)
setVisible(false)
onOpenChange(false)
}
return (
<Dialog
open={visible}
open={open}
onOpenChange={(open) => {
if (!open) {
handleClose()
}
}}
>
<DialogTrigger asChild>
<ButtonTooltip
block
size={buttonSize}
type={buttonType}
className={buttonClassName}
icon={<Plus size={14} />}
disabled={!canCreateBuckets}
style={{ justifyContent: 'start' }}
onClick={() => setVisible(true)}
tabIndex={!canCreateBuckets ? -1 : 0}
tooltip={{
content: {
side: 'bottom',
text: !canCreateBuckets
? 'You need additional permissions to create buckets'
: undefined,
},
}}
>
{label}
</ButtonTooltip>
</DialogTrigger>
<DialogContent aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>Create file bucket</DialogTitle>
@@ -378,7 +335,7 @@ export const CreateBucketModal = ({
<InlineLink
className="text-destructive decoration-destructive-500 hover:decoration-destructive"
href={`/project/${ref}/storage/settings`}
onClick={() => setVisible(false)}
onClick={() => onOpenChange(false)}
>
Storage Settings
</InlineLink>{' '}
@@ -392,7 +349,7 @@ export const CreateBucketModal = ({
<InlineLink
className="text-foreground-light hover:text-foreground"
href={`/project/${ref}/storage/settings`}
onClick={() => setVisible(false)}
onClick={() => onOpenChange(false)}
>
global file size limit
</InlineLink>{' '}
@@ -449,7 +406,7 @@ export const CreateBucketModal = ({
</Form_Shadcn_>
<DialogFooter>
<Button type="default" disabled={isCreatingBucket} onClick={() => setVisible(false)}>
<Button type="default" disabled={isCreatingBucket} onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
@@ -1,9 +1,7 @@
import { BucketPlus } from 'icons'
import { EmptyStatePresentational } from 'ui-patterns'
import { CreateAnalyticsBucketModal } from './AnalyticsBuckets/CreateAnalyticsBucketModal'
import { CreateBucketModal } from './CreateBucketModal'
import { CreateBucketButton } from './NewBucketButton'
import { BUCKET_TYPES } from './Storage.constants'
import { CreateVectorBucketButton } from './VectorBuckets/CreateVectorBucketDialog'
interface EmptyBucketStateProps {
bucketType: keyof typeof BUCKET_TYPES
@@ -25,17 +23,7 @@ export const EmptyBucketState = ({
description={config.valueProp}
className={className}
>
{bucketType === 'files' && (
<CreateBucketModal buttonSize="tiny" buttonType="primary" buttonClassName="w-fit" />
)}
{bucketType === 'analytics' && (
<CreateAnalyticsBucketModal
buttonSize="tiny"
buttonType="primary"
buttonClassName="w-fit"
/>
)}
{bucketType === 'vectors' && <CreateVectorBucketButton onClick={onCreateBucket} />}
<CreateBucketButton onClick={onCreateBucket} />
</EmptyStatePresentational>
)
}
@@ -9,6 +9,7 @@ import { useProjectStorageConfigQuery } from 'data/config/project-storage-config
import { useBucketsQuery } from 'data/storage/buckets-query'
import { IS_PLATFORM } from 'lib/constants'
import { formatBytes } from 'lib/helpers'
import { parseAsBoolean, useQueryState } from 'nuqs'
import { useStorageExplorerStateSnapshot } from 'state/storage-explorer'
import {
Button,
@@ -25,6 +26,7 @@ import { PageContainer } from 'ui-patterns/PageContainer'
import { PageSection, PageSectionContent } from 'ui-patterns/PageSection'
import { CreateBucketModal } from '../CreateBucketModal'
import { EmptyBucketState } from '../EmptyBucketState'
import { CreateBucketButton } from '../NewBucketButton'
import { STORAGE_BUCKET_SORT } from '../Storage.constants'
import { BucketsTable } from './BucketsTable'
@@ -33,6 +35,11 @@ export const FilesBuckets = () => {
const snap = useStorageExplorerStateSnapshot()
const [filterString, setFilterString] = useState('')
const [visible, setVisible] = useQueryState(
'new',
parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true })
)
const { data } = useProjectStorageConfigQuery({ projectRef: ref }, { enabled: IS_PLATFORM })
const {
data: buckets = [],
@@ -67,81 +74,84 @@ export const FilesBuckets = () => {
)
return (
<PageContainer>
<PageSection>
<PageSectionContent className="h-full gap-y-4">
{isLoadingBuckets && <GenericSkeletonLoader />}
{isErrorBuckets && (
<>
{hasNoApiKeys ? (
<Admonition type="warning" title="Project has no active API keys enabled">
<p className="!leading-normal text-sm">
The Dashboard relies on having active API keys on the project to function. If
you'd like to use Storage through the Dashboard, create a set of API keys{' '}
<InlineLink href={`/project/${ref}/settings/api-keys/new`}>here</InlineLink>.
</p>
</Admonition>
) : (
<AlertError error={bucketsError} subject="Failed to retrieve buckets" />
)}
</>
)}
{isSuccessBuckets && (
<>
{hasNoBuckets ? (
<EmptyBucketState bucketType="files" />
) : (
<>
<div className="flex flex-grow justify-between gap-x-2 items-center mb-4">
<div className="flex items-center gap-x-2">
<Input
size="tiny"
className="flex-grow lg:flex-grow-0 w-52"
placeholder="Search for a bucket"
value={filterString}
onChange={(e) => setFilterString(e.target.value)}
icon={<Search />}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" icon={<ArrowDownNarrowWide />}>
Sorted by {snap.sortBucket === 'alphabetical' ? 'name' : 'created at'}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-40">
<DropdownMenuRadioGroup
value={snap.sortBucket}
onValueChange={(value) =>
snap.setSortBucket(value as STORAGE_BUCKET_SORT)
}
>
<DropdownMenuRadioItem value="alphabetical">
Sort by name
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="created_at">
Sort by created at
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<>
<PageContainer>
<PageSection>
<PageSectionContent className="h-full gap-y-4">
{isLoadingBuckets && <GenericSkeletonLoader />}
{isErrorBuckets && (
<>
{hasNoApiKeys ? (
<Admonition type="warning" title="Project has no active API keys enabled">
<p className="!leading-normal text-sm">
The Dashboard relies on having active API keys on the project to function. If
you'd like to use Storage through the Dashboard, create a set of API keys{' '}
<InlineLink href={`/project/${ref}/settings/api-keys/new`}>here</InlineLink>.
</p>
</Admonition>
) : (
<AlertError error={bucketsError} subject="Failed to retrieve buckets" />
)}
</>
)}
{isSuccessBuckets && (
<>
{hasNoBuckets ? (
<EmptyBucketState bucketType="files" onCreateBucket={() => setVisible(true)} />
) : (
<>
<div className="flex flex-grow justify-between gap-x-2 items-center mb-4">
<div className="flex items-center gap-x-2">
<Input
size="tiny"
className="flex-grow lg:flex-grow-0 w-52"
placeholder="Search for a bucket"
value={filterString}
onChange={(e) => setFilterString(e.target.value)}
icon={<Search />}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" icon={<ArrowDownNarrowWide />}>
Sorted by {snap.sortBucket === 'alphabetical' ? 'name' : 'created at'}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-40">
<DropdownMenuRadioGroup
value={snap.sortBucket}
onValueChange={(value) =>
snap.setSortBucket(value as STORAGE_BUCKET_SORT)
}
>
<DropdownMenuRadioItem value="alphabetical">
Sort by name
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="created_at">
Sort by created at
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
<CreateBucketButton onClick={() => setVisible(true)} />
</div>
<CreateBucketModal buttonType="primary" buttonClassName="w-fit" />
</div>
<Card>
<BucketsTable
buckets={sortedFilesBuckets}
projectRef={ref ?? '_'}
filterString={filterString}
formattedGlobalUploadLimit={formattedGlobalUploadLimit}
/>
</Card>
</>
)}
</>
)}
</PageSectionContent>
</PageSection>
</PageContainer>
<Card>
<BucketsTable
buckets={sortedFilesBuckets}
projectRef={ref ?? '_'}
filterString={filterString}
formattedGlobalUploadLimit={formattedGlobalUploadLimit}
/>
</Card>
</>
)}
</>
)}
</PageSectionContent>
</PageSection>
</PageContainer>
<CreateBucketModal open={visible} onOpenChange={setVisible} />
</>
)
}
@@ -0,0 +1,34 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { Plus } from 'lucide-react'
import { MouseEventHandler } from 'react'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
export const CreateBucketButton = ({
onClick,
}: {
onClick?: MouseEventHandler<HTMLButtonElement>
}) => {
const { can: canCreateBuckets } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*')
return (
<ButtonTooltip
block
size="tiny"
type="primary"
className="w-fit"
icon={<Plus size={14} />}
disabled={!canCreateBuckets}
onClick={onClick}
tooltip={{
content: {
side: 'bottom',
text: !canCreateBuckets ? 'You need additional permissions to create buckets' : undefined,
},
}}
>
New bucket
</ButtonTooltip>
)
}
@@ -1,13 +1,10 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { Plus } from 'lucide-react'
import { MouseEventHandler, useEffect, useState } from 'react'
import { useEffect, useState } from 'react'
import { SubmitHandler, useForm } from 'react-hook-form'
import { toast } from 'sonner'
import z from 'zod'
import { useParams } from 'common'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { InlineLink } from 'components/ui/InlineLink'
import { useDatabaseExtensionEnableMutation } from 'data/database-extensions/database-extension-enable-mutation'
import { useSchemaCreateMutation } from 'data/database/schema-create-mutation'
@@ -15,7 +12,6 @@ import { useS3VectorsWrapperCreateMutation } from 'data/storage/s3-vectors-wrapp
import { useVectorBucketCreateMutation } from 'data/storage/vector-bucket-create-mutation'
import { useVectorBucketsQuery } from 'data/storage/vector-buckets-query'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { DOCS_URL } from 'lib/constants'
@@ -87,34 +83,6 @@ const formId = 'create-storage-bucket-form'
export type CreateBucketForm = z.infer<typeof FormSchema>
export const CreateVectorBucketButton = ({
onClick,
}: {
onClick?: MouseEventHandler<HTMLButtonElement>
}) => {
const { can: canCreateBuckets } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*')
return (
<ButtonTooltip
block
size="tiny"
type="primary"
className="w-fit"
icon={<Plus size={14} />}
disabled={!canCreateBuckets}
onClick={onClick}
tooltip={{
content: {
side: 'bottom',
text: !canCreateBuckets ? 'You need additional permissions to create buckets' : undefined,
},
}}
>
New bucket
</ButtonTooltip>
)
}
export const CreateVectorBucketDialog = ({
visible,
setVisible,
@@ -16,7 +16,8 @@ import { PageContainer } from 'ui-patterns/PageContainer'
import { PageSection, PageSectionContent, PageSectionTitle } from 'ui-patterns/PageSection'
import { TimestampInfo } from 'ui-patterns/TimestampInfo'
import { EmptyBucketState } from '../EmptyBucketState'
import { CreateVectorBucketButton, CreateVectorBucketDialog } from './CreateVectorBucketDialog'
import { CreateBucketButton } from '../NewBucketButton'
import { CreateVectorBucketDialog } from './CreateVectorBucketDialog'
/**
* [Joshen] Low-priority refactor: We should use a virtualized table here as per how we do it
@@ -59,124 +60,131 @@ export const VectorsBuckets = () => {
}
return (
<PageContainer>
<PageSection>
<PageSectionContent>
<AlphaNotice
entity="Vector buckets"
feedbackUrl="https://github.com/orgs/supabase/discussions/40815"
/>
<>
<PageContainer>
<PageSection>
<PageSectionContent>
<AlphaNotice
entity="Vector buckets"
feedbackUrl="https://github.com/orgs/supabase/discussions/40815"
/>
{isLoadingBuckets && <GenericSkeletonLoader />}
{isLoadingBuckets && <GenericSkeletonLoader />}
{isErrorBuckets && (
<AlertError error={bucketsError} subject="Failed to retrieve vector buckets" />
)}
{isErrorBuckets && (
<AlertError error={bucketsError} subject="Failed to retrieve vector buckets" />
)}
{isSuccessBuckets && (
<>
{bucketsList.length === 0 ? (
<EmptyBucketState bucketType="vectors" onCreateBucket={() => setVisible(true)} />
) : (
<div className="flex flex-col gap-y-4">
<div className="py-0">
<PageSectionTitle>Buckets</PageSectionTitle>
</div>
<div className="flex flex-grow justify-between gap-x-2 items-center">
<Input
size="tiny"
className="flex-grow lg:flex-grow-0 w-52"
placeholder="Search for a bucket"
value={filterString}
onChange={(e) => setFilterString(e.target.value)}
icon={<Search />}
/>
{isSuccessBuckets && (
<>
{bucketsList.length === 0 ? (
<EmptyBucketState bucketType="vectors" onCreateBucket={() => setVisible(true)} />
) : (
<div className="flex flex-col gap-y-4">
<div className="py-0">
<PageSectionTitle>Buckets</PageSectionTitle>
</div>
<div className="flex flex-grow justify-between gap-x-2 items-center">
<Input
size="tiny"
className="flex-grow lg:flex-grow-0 w-52"
placeholder="Search for a bucket"
value={filterString}
onChange={(e) => setFilterString(e.target.value)}
icon={<Search />}
/>
<CreateVectorBucketButton onClick={() => setVisible(true)} />
</div>
<CreateBucketButton onClick={() => setVisible(true)} />
</div>
{isLoadingBuckets ? (
<GenericSkeletonLoader />
) : (
<Card>
<Table>
<TableHeader>
<TableRow>
{filteredBuckets.length > 0 && (
<TableHead className="w-2 pr-1">
<span className="sr-only">Icon</span>
{isLoadingBuckets ? (
<GenericSkeletonLoader />
) : (
<Card>
<Table>
<TableHeader>
<TableRow>
{filteredBuckets.length > 0 && (
<TableHead className="w-2 pr-1">
<span className="sr-only">Icon</span>
</TableHead>
)}
<TableHead>Name</TableHead>
<TableHead>Created at</TableHead>
<TableHead>
<span className="sr-only">Actions</span>
</TableHead>
)}
<TableHead>Name</TableHead>
<TableHead>Created at</TableHead>
<TableHead>
<span className="sr-only">Actions</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredBuckets.length === 0 && filterString.length > 0 && (
<TableRow className="[&>td]:hover:bg-inherit">
<TableCell colSpan={3}>
<p className="text-sm text-foreground">No results found</p>
<p className="text-sm text-foreground-lighter">
Your search for "{filterString}" did not return any results
</p>
</TableCell>
</TableRow>
)}
{filteredBuckets.map((bucket, idx: number) => {
const id = `bucket-${idx}`
const name = bucket.vectorBucketName
// the creation time is in seconds, convert it to milliseconds
const created = +bucket.creationTime * 1000
return (
<TableRow
key={id}
className="relative cursor-pointer h-16 inset-focus"
onClick={(event) => handleBucketNavigation(name, event)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
handleBucketNavigation(name, event)
}
}}
tabIndex={0}
>
<TableCell className="w-2 pr-1">
<VectorBucketIcon size={16} className="text-foreground-muted" />
</TableCell>
<TableCell>
<p className="whitespace-nowrap max-w-[512px] truncate">{name}</p>
</TableCell>
<TableCell>
<p className="text-foreground-light">
<TimestampInfo
utcTimestamp={created}
className="text-sm text-foreground-light"
/>
</TableHeader>
<TableBody>
{filteredBuckets.length === 0 && filterString.length > 0 && (
<TableRow className="[&>td]:hover:bg-inherit">
<TableCell colSpan={3}>
<p className="text-sm text-foreground">No results found</p>
<p className="text-sm text-foreground-lighter">
Your search for "{filterString}" did not return any results
</p>
</TableCell>
<TableCell>
<div className="flex justify-end items-center h-full">
<ChevronRight size={14} className="text-foreground-muted/60" />
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</Card>
)}
</div>
)}
</>
)}
<CreateVectorBucketDialog visible={visible} setVisible={setVisible} />
</PageSectionContent>
</PageSection>
</PageContainer>
)}
{filteredBuckets.map((bucket, idx: number) => {
const id = `bucket-${idx}`
const name = bucket.vectorBucketName
// the creation time is in seconds, convert it to milliseconds
const created = +bucket.creationTime * 1000
return (
<TableRow
key={id}
className="relative cursor-pointer h-16 inset-focus"
onClick={(event) => handleBucketNavigation(name, event)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
handleBucketNavigation(name, event)
}
}}
tabIndex={0}
>
<TableCell className="w-2 pr-1">
<VectorBucketIcon size={16} className="text-foreground-muted" />
</TableCell>
<TableCell>
<p className="whitespace-nowrap max-w-[512px] truncate">
{name}
</p>
</TableCell>
<TableCell>
<p className="text-foreground-light">
<TimestampInfo
utcTimestamp={created}
className="text-sm text-foreground-light"
/>
</p>
</TableCell>
<TableCell>
<div className="flex justify-end items-center h-full">
<ChevronRight
size={14}
className="text-foreground-muted/60"
/>
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</Card>
)}
</div>
)}
</>
)}
</PageSectionContent>
</PageSection>
</PageContainer>
<CreateVectorBucketDialog visible={visible} setVisible={setVisible} />
</>
)
}
@@ -3,8 +3,8 @@ import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ProjectContextProvider } from 'components/layouts/ProjectLayout/ProjectContext'
import { addAPIMock } from 'tests/lib/msw'
import { customRender } from 'tests/lib/custom-render'
import { addAPIMock } from 'tests/lib/msw'
import { routerMock } from 'tests/lib/route-mock'
import { CreateBucketModal } from '../CreateBucketModal'
@@ -42,7 +42,7 @@ describe(`CreateBucketModal`, () => {
it(`renders a dialog with a form`, async () => {
customRender(
<ProjectContextProvider projectRef="default">
<CreateBucketModal />
<CreateBucketModal open={true} onOpenChange={() => {}} />
</ProjectContextProvider>,
{
nuqs: {
@@ -1,56 +1,30 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { getOrRefreshTemporaryApiKey } from 'data/api-keys/temp-api-keys-utils'
import { constructHeaders, fetchHandler, handleError } from 'data/fetchers'
import { handleError, post } from 'data/fetchers'
import type { ResponseError, UseCustomMutationOptions } from 'types'
import { storageKeys } from './keys'
type CreateIcebergNamespaceVariables = {
projectRef?: string
catalogUri: string
warehouse: string
namespace: string
}
const errorPrefix = 'Failed to create Iceberg namespace'
async function createIcebergNamespace({
projectRef,
catalogUri,
warehouse,
namespace,
}: CreateIcebergNamespaceVariables) {
try {
if (!projectRef) throw new Error(`${errorPrefix}: projectRef is required`)
if (!projectRef) throw new Error('projectRef is required')
const tempApiKeyObj = await getOrRefreshTemporaryApiKey(projectRef)
const tempApiKey = tempApiKeyObj.apiKey
const { error } = await post('/platform/storage/{ref}/analytics-buckets/{id}/namespaces', {
params: { path: { ref: projectRef, id: warehouse } },
body: { namespace },
})
let headers = new Headers()
headers = await constructHeaders({
'Content-Type': 'application/json',
apikey: tempApiKey,
})
headers.delete('Authorization')
const url = `${catalogUri}/v1/${warehouse}/namespaces`.replaceAll(/(?<!:)\/\//g, '/')
const response = await fetchHandler(url, {
headers,
method: 'POST',
body: JSON.stringify({ namespace: namespace }),
})
const result = await response.json()
if (result.error) {
if (result.error.message) throw new Error(`${errorPrefix}: ${result.error.message}`)
else throw new Error(errorPrefix)
}
return result
} catch (error) {
handleError(error)
}
if (error) handleError(error)
return true
}
type IcebergNamespaceCreateData = Awaited<ReturnType<typeof createIcebergNamespace>>
@@ -72,18 +46,9 @@ export const useIcebergNamespaceCreateMutation = ({
return useMutation<IcebergNamespaceCreateData, ResponseError, CreateIcebergNamespaceVariables>({
mutationFn: (vars) => createIcebergNamespace({ ...vars }),
async onSuccess(data, variables, context) {
await queryClient.invalidateQueries({
queryKey: storageKeys.icebergNamespace({
projectRef: variables.projectRef,
catalog: variables.catalogUri,
warehouse: variables.warehouse,
namespace: variables.namespace,
}),
})
await queryClient.invalidateQueries({
queryKey: storageKeys.icebergNamespaces({
projectRef: variables.projectRef,
catalog: variables.catalogUri,
warehouse: variables.warehouse,
}),
})
@@ -1,52 +1,32 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { getOrRefreshTemporaryApiKey } from 'data/api-keys/temp-api-keys-utils'
import { constructHeaders, fetchHandler, handleError } from 'data/fetchers'
import { del, handleError } from 'data/fetchers'
import type { ResponseError, UseCustomMutationOptions } from 'types'
import { storageKeys } from './keys'
type DeleteIcebergNamespaceVariables = {
projectRef?: string
catalogUri: string
warehouse: string
namespace: string
}
const errorPrefix = 'Failed to delete Iceberg namespace'
async function deleteIcebergNamespace({
projectRef,
catalogUri,
warehouse,
namespace,
}: DeleteIcebergNamespaceVariables) {
try {
if (!projectRef) throw new Error(`${errorPrefix}: projectRef is required`)
if (!projectRef) throw new Error('projectRef is required')
const tempApiKeyObj = await getOrRefreshTemporaryApiKey(projectRef)
const tempApiKey = tempApiKeyObj.apiKey
const { error } = await del(
'/platform/storage/{ref}/analytics-buckets/{id}/namespaces/{namespace}',
{
params: { path: { ref: projectRef, id: warehouse, namespace } },
}
)
let headers = new Headers()
headers = await constructHeaders({
'Content-Type': 'application/json',
apikey: tempApiKey,
})
headers.delete('Authorization')
const url = `${catalogUri}/v1/${warehouse}/namespaces/${namespace}`.replaceAll(
/(?<!:)\/\//g,
'/'
)
const response = await fetchHandler(url, {
headers,
method: 'DELETE',
})
return response.status === 204
} catch (error) {
handleError(error)
}
if (error) handleError(error)
return true
}
type IcebergNamespaceDeleteData = Awaited<ReturnType<typeof deleteIcebergNamespace>>
@@ -71,7 +51,6 @@ export const useIcebergNamespaceDeleteMutation = ({
await queryClient.invalidateQueries({
queryKey: storageKeys.icebergNamespaces({
projectRef: variables.projectRef,
catalog: variables.catalogUri,
warehouse: variables.warehouse,
}),
})
@@ -0,0 +1,81 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { components } from 'api-types'
import { handleError, post } from 'data/fetchers'
import type { ResponseError, UseCustomMutationOptions } from 'types'
import { storageKeys } from './keys'
export type NamespaceTableFields = components['schemas']['CreateNamespaceTableBody']['fields']
type CreateIcebergNamespaceTableVariables = {
projectRef?: string
warehouse: string
namespace: string
name: string
fields: NamespaceTableFields
}
async function createIcebergNamespaceTable({
projectRef,
warehouse,
namespace,
name,
fields,
}: CreateIcebergNamespaceTableVariables) {
if (!projectRef) throw new Error('projectRef is required')
const { data, error } = await post(
'/platform/storage/{ref}/analytics-buckets/{id}/namespaces/{namespace}/tables',
{
params: { path: { ref: projectRef, id: warehouse, namespace } },
body: { name, fields },
}
)
if (error) handleError(error)
return data
}
type IcebergNamespaceTableCreateData = Awaited<ReturnType<typeof createIcebergNamespaceTable>>
export const useIcebergNamespaceTableCreateMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseCustomMutationOptions<
IcebergNamespaceTableCreateData,
ResponseError,
CreateIcebergNamespaceTableVariables
>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<
IcebergNamespaceTableCreateData,
ResponseError,
CreateIcebergNamespaceTableVariables
>({
mutationFn: (vars) => createIcebergNamespaceTable({ ...vars }),
async onSuccess(data, variables, context) {
await queryClient.invalidateQueries({
queryKey: storageKeys.icebergNamespace({
projectRef: variables.projectRef,
warehouse: variables.warehouse,
namespace: variables.namespace,
}),
})
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
if (onError === undefined) {
toast.error(`Failed to create Iceberg namespace table: ${data.message}`)
} else {
onError(data, variables, context)
}
},
...options,
})
}
@@ -1,52 +1,37 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { getOrRefreshTemporaryApiKey } from 'data/api-keys/temp-api-keys-utils'
import { constructHeaders, fetchHandler, handleError } from 'data/fetchers'
import { del, handleError } from 'data/fetchers'
import type { ResponseError, UseCustomMutationOptions } from 'types'
import { storageKeys } from './keys'
type DeleteIcebergNamespaceTableVariables = {
catalogUri: string
warehouse: string
namespace: string
table: string
projectRef?: string
}
const errorPrefix = 'Failed to delete Iceberg namespace table'
async function deleteIcebergNamespaceTable({
projectRef,
catalogUri,
warehouse,
namespace,
table,
}: DeleteIcebergNamespaceTableVariables) {
try {
if (!projectRef) throw new Error(`${errorPrefix}: projectRef is required`)
if (!projectRef) throw new Error('projectRef is required')
const tempApiKeyObj = await getOrRefreshTemporaryApiKey(projectRef)
const tempApiKey = tempApiKeyObj.apiKey
const { error } = await del(
'/platform/storage/{ref}/analytics-buckets/{id}/namespaces/{namespace}/tables/{table}',
{
params: {
path: { ref: projectRef, id: warehouse, namespace, table },
query: { purge: true },
},
}
)
let headers = new Headers()
headers = await constructHeaders({
'Content-Type': 'application/json',
apikey: tempApiKey,
})
headers.delete('Authorization')
const url =
`${catalogUri}/v1/${warehouse}/namespaces/${namespace}/tables/${table}?purgeRequested=true`.replaceAll(
/(?<!:)\/\//g,
'/'
)
const response = await fetchHandler(url, { headers, method: 'DELETE' })
return response.status === 204
} catch (error) {
handleError(error)
}
if (error) handleError(error)
return true
}
type IcebergNamespaceTableDeleteData = Awaited<ReturnType<typeof deleteIcebergNamespaceTable>>
@@ -75,7 +60,6 @@ export const useIcebergNamespaceTableDeleteMutation = ({
await queryClient.invalidateQueries({
queryKey: storageKeys.icebergNamespace({
projectRef: variables.projectRef,
catalog: variables.catalogUri,
warehouse: variables.warehouse,
namespace: variables.namespace,
}),
@@ -1,55 +1,33 @@
import { useQuery } from '@tanstack/react-query'
import { getOrRefreshTemporaryApiKey } from 'data/api-keys/temp-api-keys-utils'
import { constructHeaders, fetchHandler, handleError } from 'data/fetchers'
import { get, handleError } from 'data/fetchers'
import type { ResponseError, UseCustomQueryOptions } from 'types'
import { storageKeys } from './keys'
type GetNamespaceTablesVariables = {
catalogUri: string
warehouse: string
namespace: string
warehouse?: string
namespace?: string
projectRef?: string
}
const errorPrefix = 'Failed to retrieve Iceberg namespace tables'
async function getNamespaceTables(
{ projectRef, warehouse, namespace }: GetNamespaceTablesVariables,
signal?: AbortSignal
) {
if (!projectRef) throw new Error('projectRef is required')
if (!namespace) throw new Error('namespace is required')
if (!warehouse) throw new Error('warehouse is required')
async function getNamespaceTables({
projectRef,
catalogUri,
warehouse,
namespace,
}: GetNamespaceTablesVariables) {
try {
if (!projectRef) throw new Error(`${errorPrefix}: projectRef is required`)
const tempApiKeyObj = await getOrRefreshTemporaryApiKey(projectRef)
const tempApiKey = tempApiKeyObj.apiKey
let headers = new Headers()
headers = await constructHeaders({
'Content-Type': 'application/json',
apikey: tempApiKey,
})
headers.delete('Authorization')
const url = `${catalogUri}/v1/${warehouse}/namespaces/${namespace}/tables`.replaceAll(
/(?<!:)\/\//g,
'/'
)
const response = await fetchHandler(url, { headers, method: 'GET' })
const result = await response.json()
if (result.error) {
if (result.error.message) throw new Error(`${errorPrefix}: ${result.error.message}`)
else throw new Error(errorPrefix)
const { data, error } = await get(
'/platform/storage/{ref}/analytics-buckets/{id}/namespaces/{namespace}/tables',
{
params: { path: { ref: projectRef, id: warehouse, namespace } },
signal,
}
)
const r = result as { identifiers: { name: string; namespace: string[] }[] }
return r.identifiers.map((i) => i.name)
} catch (error) {
handleError(error)
}
if (error) handleError(error)
return data.data.map((x) => x.name)
}
type IcebergNamespaceTablesData = Awaited<ReturnType<typeof getNamespaceTables>>
@@ -63,22 +41,20 @@ export const useIcebergNamespaceTablesQuery = <TData = IcebergNamespaceTablesDat
...options
}: UseCustomQueryOptions<IcebergNamespaceTablesData, IcebergNamespaceTablesError, TData> = {}
) => {
const { projectRef, catalogUri, warehouse, namespace } = params
const { projectRef, warehouse, namespace } = params
return useQuery<IcebergNamespaceTablesData, IcebergNamespaceTablesError, TData>({
queryKey: storageKeys.icebergNamespaceTables({
projectRef,
warehouse,
namespace,
catalog: catalogUri,
}),
queryFn: () => getNamespaceTables({ ...params }),
enabled:
enabled &&
typeof projectRef !== 'undefined' &&
typeof warehouse !== 'undefined' &&
typeof namespace !== 'undefined' &&
typeof catalogUri !== 'undefined',
typeof namespace !== 'undefined',
...options,
})
}
@@ -1,46 +1,28 @@
import { useQuery } from '@tanstack/react-query'
import { getOrRefreshTemporaryApiKey } from 'data/api-keys/temp-api-keys-utils'
import { constructHeaders, fetchHandler, handleError } from 'data/fetchers'
import { get, handleError } from 'data/fetchers'
import type { ResponseError, UseCustomQueryOptions } from 'types'
import { storageKeys } from './keys'
type GetNamespacesVariables = {
catalogUri: string
warehouse: string
warehouse?: string
projectRef?: string
}
const errorPrefix = 'Failed to retrieve Iceberg namespaces'
async function getNamespaces(
{ projectRef, warehouse }: GetNamespacesVariables,
signal?: AbortSignal
) {
if (!projectRef) throw new Error('projectRef is required')
if (!warehouse) throw new Error('warehouse is required')
async function getNamespaces({ projectRef, catalogUri, warehouse }: GetNamespacesVariables) {
try {
if (!projectRef) throw new Error(`${errorPrefix}: projectRef is required`)
const { data, error } = await get('/platform/storage/{ref}/analytics-buckets/{id}/namespaces', {
params: { path: { ref: projectRef, id: warehouse } },
signal,
})
const tempApiKeyObj = await getOrRefreshTemporaryApiKey(projectRef)
const tempApiKey = tempApiKeyObj.apiKey
let headers = new Headers()
headers = await constructHeaders({
'Content-Type': 'application/json',
apikey: tempApiKey,
})
headers.delete('Authorization')
const url = `${catalogUri}/v1/${warehouse}/namespaces`.replaceAll(/(?<!:)\/\//g, '/')
const response = await fetchHandler(url, { headers, method: 'GET' })
const result = await response.json()
if (result.error) {
if (result.error.message) throw new Error(`${errorPrefix}: ${result.error.message}`)
else throw new Error(errorPrefix)
}
const r = result as { namespaces: string[][] }
return r.namespaces.flat()
} catch (error) {
handleError(error)
}
if (error) handleError(error)
return data.data.map((x) => x.namespace).flat()
}
type IcebergNamespacesData = Awaited<ReturnType<typeof getNamespaces>>
@@ -54,20 +36,17 @@ export const useIcebergNamespacesQuery = <TData = IcebergNamespacesData>(
...options
}: UseCustomQueryOptions<IcebergNamespacesData, IcebergNamespacesError, TData> = {}
) => {
const { projectRef, catalogUri, warehouse } = params
const { projectRef, warehouse } = params
return useQuery<IcebergNamespacesData, IcebergNamespacesError, TData>({
queryKey: storageKeys.icebergNamespaces({
projectRef,
warehouse,
catalog: catalogUri,
}),
queryFn: () => getNamespaces({ ...params }),
queryFn: ({ signal }) => getNamespaces({ projectRef, warehouse }, signal),
enabled:
options &&
typeof projectRef !== 'undefined' &&
typeof catalogUri !== 'undefined' &&
catalogUri.length > 0 &&
typeof warehouse !== 'undefined' &&
warehouse.length > 0,
...options,
+6 -27
View File
@@ -9,45 +9,24 @@ export const storageKeys = {
vectorBucketsIndexes: (projectRef: string | undefined, vectorBucketName: string | undefined) =>
['projects', projectRef, 'vector-buckets', vectorBucketName, 'indexes'] as const,
archive: (projectRef: string | undefined) => ['projects', projectRef, 'archive'] as const,
icebergNamespaces: ({
projectRef,
catalog,
warehouse,
}: {
projectRef?: string
catalog: string
warehouse: string
}) => [projectRef, 'catalog', catalog, 'warehouse', warehouse, 'namespaces'] as const,
icebergNamespaces: ({ projectRef, warehouse }: { projectRef?: string; warehouse?: string }) =>
[projectRef, 'warehouse', warehouse, 'namespaces'] as const,
icebergNamespace: ({
projectRef,
catalog,
warehouse,
namespace,
}: {
projectRef?: string
catalog: string
warehouse: string
namespace: string
}) => [projectRef, 'catalog', catalog, 'warehouse', warehouse, 'namespaces', namespace] as const,
}) => [projectRef, 'warehouse', warehouse, 'namespaces', namespace] as const,
icebergNamespaceTables: ({
projectRef,
catalog,
warehouse,
namespace,
}: {
projectRef?: string
catalog: string
warehouse: string
namespace: string
}) =>
[
projectRef,
'catalog',
catalog,
'warehouse',
warehouse,
'namespaces',
namespace,
'tables',
] as const,
warehouse?: string
namespace?: string
}) => [projectRef, 'warehouse', warehouse, 'namespaces', namespace, 'tables'] as const,
}