mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
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:
1 parent
7860dad841
commit
27e89ed64e
30 files changed
+1211
-771
No files matched your search
+1
-11
@@ -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,
|
||||
})
|
||||
|
||||
+3
-15
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
+7
-9
@@ -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>
|
||||
)}
|
||||
|
||||
File renamed without changes.
+1
-1
@@ -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_,
|
||||
+71
@@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
+26
@@ -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' }],
|
||||
}
|
||||
+82
@@ -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'],
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
+372
@@ -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>
|
||||
)
|
||||
}
|
||||
-32
@@ -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>
|
||||
)
|
||||
}
|
||||
+87
@@ -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>
|
||||
)
|
||||
}
|
||||
+7
-7
@@ -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 />} />
|
||||
|
||||
+1
-9
@@ -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)
|
||||
|
||||
+1
-2
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
+9
-66
@@ -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
-33
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
Reference in new issue
Block a user