Refactor Storage Create/Edit/Empty/Delete Modals to use Shadcn components (#37517)

* Refactor `StorageMenu` modals to replace deprecated patterns

* add test for `DeleteBucketModal` and update test setup

Note: Because this component uses `useParams`, it's necessary to have the dynamic route segment passed to `next-router-mock`'s `createDynamicRouteParser`. In order not to have to manually list all of these as the application grows, I added a glob utility that uses the `pages/` directory  to automatically generate an array of dynamic route paths in this case.

* add test for `EmptyBucketModal`

* add test for `EditBucketModal` and add `isNonNullable` utility function

* add test for `CreateBucketModal`

* implement requested changes

* implement visual fixes
This commit is contained in:
Drake Costa
2025-08-14 12:23:08 -07:00
committed by GitHub
parent b6b906cff4
commit ca4b3bc624
13 changed files with 1361 additions and 648 deletions

View File

@@ -1,10 +1,13 @@
import { useState } from 'react'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { noop } from 'lodash'
import { Columns3, Edit2, MoreVertical, Trash, XCircle } from 'lucide-react'
import Link from 'next/link'
import type { Bucket } from 'data/storage/buckets-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
import EditBucketModal from 'components/interfaces/Storage/EditBucketModal'
import DeleteBucketModal from 'components/interfaces/Storage/DeleteBucketModal'
import EmptyBucketModal from 'components/interfaces/Storage/EmptyBucketModal'
import {
Badge,
Button,
@@ -23,20 +26,15 @@ export interface BucketRowProps {
bucket: Bucket
projectRef?: string
isSelected: boolean
onSelectEmptyBucket: () => void
onSelectDeleteBucket: () => void
onSelectEditBucket: () => void
}
const BucketRow = ({
bucket,
projectRef = '',
isSelected = false,
onSelectEmptyBucket = noop,
onSelectDeleteBucket = noop,
onSelectEditBucket = noop,
}: BucketRowProps) => {
const canUpdateBuckets = useCheckPermissions(PermissionAction.STORAGE_WRITE, '*')
const BucketRow = ({ bucket, projectRef = '', isSelected = false }: BucketRowProps) => {
const { can: canUpdateBuckets } = useAsyncCheckProjectPermissions(
PermissionAction.STORAGE_WRITE,
'*'
)
const [modal, setModal] = useState<string | null>(null)
const onClose = () => setModal(null)
return (
<div
@@ -84,7 +82,7 @@ const BucketRow = ({
<DropdownMenuItem
key="toggle-private"
className="space-x-2"
onClick={() => onSelectEditBucket()}
onClick={() => setModal(`edit`)}
>
<Edit2 size={14} />
<p>Edit bucket</p>
@@ -93,7 +91,7 @@ const BucketRow = ({
<DropdownMenuItem
key="empty-bucket"
className="space-x-2"
onClick={() => onSelectEmptyBucket()}
onClick={() => setModal(`empty`)}
>
<XCircle size={14} />
<p>Empty bucket</p>
@@ -103,7 +101,7 @@ const BucketRow = ({
<DropdownMenuItem
key="delete-bucket"
className="space-x-2"
onClick={() => onSelectDeleteBucket()}
onClick={() => setModal(`delete`)}
>
<Trash size={14} />
<p>Delete bucket</p>
@@ -113,6 +111,10 @@ const BucketRow = ({
) : (
<div className="w-7 mr-1" />
)}
<EditBucketModal visible={modal === `edit`} bucket={bucket} onClose={onClose} />
<EmptyBucketModal visible={modal === `empty`} bucket={bucket} onClose={onClose} />
<DeleteBucketModal visible={modal === `delete`} bucket={bucket} onClose={onClose} />
</div>
)
}

View File

@@ -1,9 +1,9 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { snakeCase } from 'lodash'
import { ChevronDown } from 'lucide-react'
import { ChevronDown, Edit } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
import { useState } from 'react'
import { SubmitHandler, useForm } from 'react-hook-form'
import { toast } from 'sonner'
import z from 'zod'
@@ -24,28 +24,39 @@ import {
AlertTitle_Shadcn_,
Button,
cn,
Collapsible,
Collapsible_Shadcn_,
CollapsibleContent_Shadcn_,
CollapsibleTrigger_Shadcn_,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogSection,
DialogSectionSeparator,
DialogTitle,
DialogTrigger,
Form_Shadcn_,
FormControl_Shadcn_,
FormField_Shadcn_,
Input_Shadcn_,
Label_Shadcn_,
Listbox,
Modal,
RadioGroupStacked,
RadioGroupStackedItem,
Toggle,
Select_Shadcn_,
SelectContent_Shadcn_,
SelectItem_Shadcn_,
SelectTrigger_Shadcn_,
SelectValue_Shadcn_,
Switch,
WarningIcon,
} from 'ui'
import { Admonition } from 'ui-patterns/admonition'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { inverseValidBucketNameRegex, validBucketNameRegex } from './CreateBucketModal.utils'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { convertFromBytes, convertToBytes } from './StorageSettings/StorageSettings.utils'
export interface CreateBucketModalProps {
visible: boolean
onClose: () => void
}
import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
const FormSchema = z
.object({
@@ -84,13 +95,20 @@ const FormSchema = z
}
})
const formId = 'create-storage-bucket-form'
export type CreateBucketForm = z.infer<typeof FormSchema>
const CreateBucketModal = ({ visible, onClose }: CreateBucketModalProps) => {
const CreateBucketModal = () => {
const [visible, setVisible] = useState(false)
const { ref } = useParams()
const router = useRouter()
const { data: org } = useSelectedOrganizationQuery()
const { mutate: sendEvent } = useSendEventMutation()
const router = useRouter()
const { can: canCreateBuckets } = useAsyncCheckProjectPermissions(
PermissionAction.STORAGE_WRITE,
'*'
)
const { mutateAsync: createBucket, isLoading: isCreating } = useBucketCreateMutation({
// [Joshen] Silencing the error here as it's being handled in onSubmit
@@ -103,7 +121,7 @@ const CreateBucketModal = ({ visible, onClose }: CreateBucketModalProps) => {
const { value, unit } = convertFromBytes(data?.fileSizeLimit ?? 0)
const formattedGlobalUploadLimit = `${value} ${unit}`
const [selectedUnit, setSelectedUnit] = useState<StorageSizeUnits>(StorageSizeUnits.BYTES)
const [selectedUnit, setSelectedUnit] = useState<string>(StorageSizeUnits.BYTES)
const [showConfiguration, setShowConfiguration] = useState(false)
const form = useForm<CreateBucketForm>({
@@ -124,6 +142,7 @@ const CreateBucketModal = ({ visible, onClose }: CreateBucketModalProps) => {
const hasFileSizeLimit = form.watch('has_file_size_limit')
const formattedSizeLimit = form.watch('formatted_size_limit')
const icebergWrapperExtensionState = useIcebergWrapperExtension()
const icebergCatalogEnabled = data?.features?.icebergCatalog?.enabled
const onSubmit: SubmitHandler<CreateBucketForm> = async (values) => {
if (!ref) return console.error('Project ref is required')
@@ -137,7 +156,7 @@ const CreateBucketModal = ({ visible, onClose }: CreateBucketModalProps) => {
try {
const fileSizeLimit = values.has_file_size_limit
? convertToBytes(values.formatted_size_limit, selectedUnit)
? convertToBytes(values.formatted_size_limit, selectedUnit as StorageSizeUnits)
: undefined
const allowedMimeTypes =
@@ -162,105 +181,129 @@ const CreateBucketModal = ({ visible, onClose }: CreateBucketModalProps) => {
if (values.type === 'ANALYTICS' && icebergWrapperExtensionState === 'installed') {
await createIcebergWrapper({ bucketName: values.name })
}
toast.success(`Successfully created bucket ${values.name}`)
router.push(`/project/${ref}/storage/buckets/${values.name}`)
onClose()
} catch (error: any) {
toast.error(`Failed to create bucket: ${error.message}`)
}
}
useEffect(() => {
if (visible) {
form.reset()
setSelectedUnit(StorageSizeUnits.BYTES)
setShowConfiguration(false)
setVisible(false)
toast.success(`Successfully created bucket ${values.name}`)
router.push(`/project/${ref}/storage/buckets/${values.name}`)
} catch (error) {
console.error(error)
toast.error('Failed to create bucket')
}
}, [visible, form])
}
const icebergCatalogEnabled = data?.features?.icebergCatalog?.enabled
const handleClose = () => {
form.reset()
setSelectedUnit(StorageSizeUnits.BYTES)
setShowConfiguration(false)
setVisible(false)
}
return (
<Modal
hideFooter
visible={visible}
size="medium"
header="Create storage bucket"
onCancel={() => onClose()}
<Dialog
open={visible}
onOpenChange={(open) => {
if (!open) {
handleClose()
}
}}
>
<Form_Shadcn_ {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<Modal.Content>
<FormField_Shadcn_
control={form.control}
name="name"
render={({ field }) => (
<FormItemLayout
layout="vertical"
label="Name of bucket"
labelOptional="Buckets cannot be renamed once created."
>
<FormControl_Shadcn_>
<Input_Shadcn_ {...field} placeholder="Enter bucket name" />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<div className="flex flex-col gap-y-2 mt-2">
<DialogTrigger asChild>
<ButtonTooltip
block
type="default"
icon={<Edit />}
disabled={!canCreateBuckets}
style={{ justifyContent: 'start' }}
onClick={() => setVisible(true)}
tooltip={{
content: {
side: 'bottom',
text: !canCreateBuckets
? 'You need additional permissions to create buckets'
: undefined,
},
}}
>
New bucket
</ButtonTooltip>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create storage bucket</DialogTitle>
</DialogHeader>
<DialogSectionSeparator />
<DialogSection>
<Form_Shadcn_ {...form}>
<form
id={formId}
className="flex flex-col gap-4"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField_Shadcn_
key="name"
name="name"
control={form.control}
render={({ field }) => (
<FormItemLayout
name="name"
label="Name of bucket"
labelOptional="Buckets cannot be renamed once created."
>
<FormControl_Shadcn_>
<Input_Shadcn_ id="name" {...field} placeholder="Enter bucket name" />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
key="type"
name="type"
control={form.control}
render={({ field }) => (
<FormItemLayout label="Bucket type">
<FormControl_Shadcn_>
<RadioGroupStacked
id="type"
onValueChange={(v) => field.onChange(v)}
value={field.value}
onValueChange={(v) => field.onChange(v)}
>
<RadioGroupStackedItem
value="STANDARD"
id="STANDARD"
value="STANDARD"
label="Standard bucket"
description="Compatible with S3 buckets."
showIndicator={false}
>
<div className="flex gap-x-5">
<div className="flex flex-col">
<p className="text-foreground-light text-left">
Compatible with S3 buckets.
</p>
</div>
</div>
</RadioGroupStackedItem>
/>
{IS_PLATFORM && (
<RadioGroupStackedItem
value="ANALYTICS"
id="ANALYTICS"
value="ANALYTICS"
label="Analytics bucket"
showIndicator={false}
disabled={!icebergCatalogEnabled}
>
<div className="flex gap-x-5">
<div className="flex flex-col">
<p className="text-foreground-light text-left">
Stores Iceberg files and is optimized for analytical workloads.
</p>
</div>
</div>
{!icebergCatalogEnabled && (
<div className="w-full flex gap-x-2 py-2 items-center">
<WarningIcon />
<span className="text-xs text-left text-foreground-lighter">
This is currently in alpha and not enabled for your project. Sign
up{' '}
<InlineLink href="https://forms.supabase.com/analytics-buckets">
here
</InlineLink>
.
</span>
</div>
)}
<>
<p className="text-foreground-light text-left">
Stores Iceberg files and is optimized for analytical workloads.
</p>
{icebergCatalogEnabled ? null : (
<div className="w-full flex gap-x-2 py-2 items-center">
<WarningIcon />
<span className="text-xs text-left">
This feature is currently in alpha and not yet enabled for your
project. Sign up{' '}
<InlineLink href="https://forms.supabase.com/analytics-buckets">
here
</InlineLink>
.
</span>
</div>
)}
</>
</RadioGroupStackedItem>
)}
</RadioGroupStacked>
@@ -268,26 +311,28 @@ const CreateBucketModal = ({ visible, onClose }: CreateBucketModalProps) => {
</FormItemLayout>
)}
/>
</div>
</Modal.Content>
<Modal.Separator />
{isStandardBucket ? (
<>
<Modal.Content className="!px-0 !pb-0">
<div className="flex flex-col gap-y-2">
<DialogSectionSeparator />
{isStandardBucket ? (
<>
<FormField_Shadcn_
control={form.control}
key="public"
name="public"
control={form.control}
render={({ field }) => (
<FormItemLayout className="px-5">
<FormItemLayout
name="public"
label="Public bucket"
description="Anyone can read any object without any authorization"
layout="flex"
>
<FormControl_Shadcn_>
<Toggle
<Switch
id="public"
size="large"
checked={field.value}
onChange={field.onChange}
layout="flex"
label="Public bucket"
descriptionText="Anyone can read any object without any authorization"
onCheckedChange={field.onChange}
/>
</FormControl_Shadcn_>
</FormItemLayout>
@@ -296,235 +341,240 @@ const CreateBucketModal = ({ visible, onClose }: CreateBucketModalProps) => {
{isPublicBucket && (
<Admonition
type="warning"
className="rounded-none border-x-0 border-b-0 mb-0 [&>div>p]:!leading-normal"
className="rounded-none border-x-0 border-b-0 mb-0 pb-0 px-0 [&>svg]:left-0 [&>div>p]:!leading-normal"
title="Public buckets are not protected"
>
<p className="mb-2">
Users can read objects in public buckets without any authorization.
</p>
<p>
Row level security (RLS) policies are still required for other operations
such as object uploads and deletes.
</p>
</Admonition>
)}
</div>
</Modal.Content>
<Modal.Separator />
<Collapsible
open={showConfiguration}
onOpenChange={() => setShowConfiguration(!showConfiguration)}
>
<Collapsible.Trigger asChild>
<div className="w-full cursor-pointer py-3 px-5 flex items-center justify-between">
<p className="text-sm">Additional restrictions</p>
<ChevronDown
size={18}
strokeWidth={2}
className={cn('text-foreground-light', showConfiguration && 'rotate-180')}
description={
<>
<p className="mb-2">
Users can read objects in public buckets without any authorization.
</p>
<p>
Row level security (RLS) policies are still required for other
operations such as object uploads and deletes.
</p>
</>
}
/>
</div>
</Collapsible.Trigger>
<Collapsible.Content className="py-4">
<div className="w-full space-y-5 px-5">
<div className="space-y-5">
)}
<Collapsible_Shadcn_
open={showConfiguration}
onOpenChange={() => setShowConfiguration(!showConfiguration)}
>
<CollapsibleTrigger_Shadcn_ asChild>
<button className="w-full cursor-pointer py-3 flex items-center justify-between border-t border-default">
<p className="text-sm">Additional configuration</p>
<ChevronDown
size={18}
strokeWidth={2}
className={cn('text-foreground-light', showConfiguration && 'rotate-180')}
/>
</button>
</CollapsibleTrigger_Shadcn_>
<CollapsibleContent_Shadcn_ className="py-4 space-y-4">
<div className="space-y-2">
<FormField_Shadcn_
key="has_file_size_limit"
name="has_file_size_limit"
control={form.control}
render={({ field }) => (
<FormItemLayout
name="has_file_size_limit"
label="Restrict file upload size for bucket"
description="Prevent uploading of file sizes greater than a specified limit"
layout="flex"
>
<FormControl_Shadcn_>
<Switch
id="has_file_size_limit"
size="large"
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
{hasFileSizeLimit && (
<div className="grid grid-cols-12 col-span-12 gap-x-2 gap-y-1">
<div className="col-span-8">
<FormField_Shadcn_
key="formatted_size_limit"
name="formatted_size_limit"
control={form.control}
render={({ field }) => (
<FormItemLayout
name="formatted_size_limit"
description={`Equivalent to ${convertToBytes(
formattedSizeLimit,
selectedUnit as StorageSizeUnits
).toLocaleString()} bytes.`}
>
<FormControl_Shadcn_>
<Input_Shadcn_
id="formatted_size_limit"
aria-label="File size limit"
type="number"
min={0}
{...field}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</div>
<Select_Shadcn_ value={selectedUnit} onValueChange={setSelectedUnit}>
<SelectTrigger_Shadcn_
aria-label="File size limit unit"
size="small"
className="col-span-4"
>
<SelectValue_Shadcn_ asChild>
<>{selectedUnit}</>
</SelectValue_Shadcn_>
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
{Object.values(StorageSizeUnits).map((unit: string) => (
<SelectItem_Shadcn_ key={unit} value={unit} className="text-xs">
<div>{unit}</div>
</SelectItem_Shadcn_>
))}
</SelectContent_Shadcn_>
</Select_Shadcn_>
{IS_PLATFORM && (
<div className="col-span-12">
<p className="text-foreground-light text-sm">
Note: Individual bucket uploads will still be capped at the{' '}
<Link
href={`/project/${ref}/settings/storage`}
className="font-bold underline"
>
global upload limit
</Link>{' '}
of {formattedGlobalUploadLimit}
</p>
</div>
)}
</div>
)}
</div>
<FormField_Shadcn_
key="allowed_mime_types"
name="allowed_mime_types"
control={form.control}
name="has_file_size_limit"
render={({ field }) => (
<FormItemLayout>
<FormItemLayout
name="allowed_mime_types"
label="Allowed MIME types"
labelOptional="Comma separated values"
description="Wildcards are allowed, e.g. image/*. Leave blank to allow any MIME type."
>
<FormControl_Shadcn_>
<Toggle
id="has_file_size_limit"
checked={field.value}
onChange={field.onChange}
layout="flex"
label="Restrict file upload size for bucket"
descriptionText="Prevent uploading of file sizes greater than a specified limit"
<Input_Shadcn_
id="allowed_mime_types"
{...field}
placeholder="e.g image/jpeg, image/png, audio/mpeg, video/mp4, etc"
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
{hasFileSizeLimit && (
<div className="grid grid-cols-12 col-span-12 gap-x-2 gap-y-1">
<div className="col-span-8">
<FormField_Shadcn_
control={form.control}
name="formatted_size_limit"
render={({ field }) => (
<FormItemLayout>
<FormControl_Shadcn_>
<Input_Shadcn_
type="number"
step={1}
{...field}
onKeyPress={(event) => {
if (event.charCode < 48 || event.charCode > 57) {
event.preventDefault()
}
}}
/>
</FormControl_Shadcn_>
<span className="text-foreground-light text-xs">
Equivalent to{' '}
{convertToBytes(
formattedSizeLimit,
selectedUnit
).toLocaleString()}{' '}
bytes.
</span>
</FormItemLayout>
)}
/>
</div>
<div className="col-span-4">
<Listbox
id="size_limit_units"
value={selectedUnit}
onChange={setSelectedUnit}
>
{Object.values(StorageSizeUnits).map((unit: string) => (
<Listbox.Option key={unit} label={unit} value={unit}>
<div>{unit}</div>
</Listbox.Option>
))}
</Listbox>
</div>
{IS_PLATFORM && (
<div className="col-span-12">
<p className="text-foreground-light text-sm">
Note: Individual bucket uploads will still be capped at the{' '}
<Link
href={`/project/${ref}/storage/settings`}
className="font-bold underline"
>
global upload limit
</Link>{' '}
of {formattedGlobalUploadLimit}
</p>
</div>
)}
</div>
)}
</div>
<FormField_Shadcn_
control={form.control}
name="allowed_mime_types"
render={({ field }) => (
<FormItemLayout
label="Allowed MIME types"
labelOptional="Comma separated values"
description="Wildcards are allowed, e.g. image/*. Leave blank to allow any MIME type."
layout="vertical"
>
<FormControl_Shadcn_>
<Input_Shadcn_
{...field}
placeholder="e.g image/jpeg, image/png, audio/mpeg, video/mp4, etc"
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</div>
</Collapsible.Content>
</Collapsible>
</>
) : (
<Modal.Content>
{icebergWrapperExtensionState === 'installed' ? (
<Label_Shadcn_ className="text-foreground-lighter leading-1 flex flex-col gap-y-2">
<p>
<span>Supabase will setup a </span>
<a
href={`${BASE_PATH}/project/${ref}/integrations/iceberg_wrapper/overview`}
target="_blank"
className="underline text-foreground-light"
>
foreign data wrapper
{bucketName && <span className="text-brand"> {`${bucketName}_fdw`}</span>}
</a>
<span>
{' '}
for easier access to the data. This action will also create{' '}
<a
href={`${BASE_PATH}/project/${ref}/storage/access-keys`}
target="_blank"
className="underline text-foreground-light"
>
S3 Access Keys
{bucketName && (
<>
{' '}
named <span className="text-brand"> {`${bucketName}_keys`}</span>
</>
)}
</a>
<span> and </span>
<a
href={`${BASE_PATH}/project/${ref}/integrations/vault/secrets`}
target="_blank"
className="underline text-foreground-light"
>
four Vault Secrets
{bucketName && (
<>
{' '}
prefixed with{' '}
<span className="text-brand"> {`${bucketName}_vault_`}</span>
</>
)}
</a>
.
</span>
</p>
<p>
As a final step, you'll need to create an{' '}
<span className="text-foreground-light">Iceberg namespace</span> before you
connect the Iceberg data to your database.
</p>
</Label_Shadcn_>
</CollapsibleContent_Shadcn_>
</Collapsible_Shadcn_>
</>
) : (
<Alert_Shadcn_ variant="warning">
<WarningIcon />
<AlertTitle_Shadcn_>
You need to install the Iceberg wrapper extension to connect your Analytic
bucket to your database.
</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_ className="flex flex-col gap-y-2">
<p>
You need to install the <span className="text-brand">wrappers</span> extension
(with the minimum version of <span>0.5.3</span>) if you want to connect your
Analytics bucket to your database.
</p>
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
<>
{icebergWrapperExtensionState === 'installed' ? (
<Label_Shadcn_ className="text-foreground-lighter leading-1 flex flex-col gap-y-2">
<p>
<span>Supabase will setup a </span>
<a
href={`${BASE_PATH}/project/${ref}/integrations/iceberg_wrapper/overview`}
target="_blank"
className="underline text-foreground-light"
>
foreign data wrapper
{bucketName && <span className="text-brand"> {`${bucketName}_fdw`}</span>}
</a>
<span>
{' '}
for easier access to the data. This action will also create{' '}
<a
href={`${BASE_PATH}/project/${ref}/storage/access-keys`}
target="_blank"
className="underline text-foreground-light"
>
S3 Access Keys
{bucketName && (
<>
{' '}
named <span className="text-brand"> {`${bucketName}_keys`}</span>
</>
)}
</a>
<span> and </span>
<a
href={`${BASE_PATH}/project/${ref}/integrations/vault/secrets`}
target="_blank"
className="underline text-foreground-light"
>
four Vault Secrets
{bucketName && (
<>
{' '}
prefixed with{' '}
<span className="text-brand"> {`${bucketName}_vault_`}</span>
</>
)}
</a>
.
</span>
</p>
<p>
As a final step, you'll need to create an{' '}
<span className="text-foreground-light">Iceberg namespace</span> before you
connect the Iceberg data to your database.
</p>
</Label_Shadcn_>
) : (
<Alert_Shadcn_ variant="warning">
<WarningIcon />
<AlertTitle_Shadcn_>
You need to install the Iceberg wrapper extension to connect your Analytic
bucket to your database.
</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_ className="flex flex-col gap-y-2">
<p>
You need to install the <span className="text-brand">wrappers</span>{' '}
extension (with the minimum version of <span>0.5.3</span>) if you want to
connect your Analytics bucket to your database.
</p>
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)}
</>
)}
</Modal.Content>
)}
<Modal.Separator />
<Modal.Content className="flex items-center space-x-2 justify-end">
<Button
type="default"
htmlType="button"
disabled={isCreating || isCreatingIcebergWrapper}
onClick={() => onClose()}
>
Cancel
</Button>
<Button
type="primary"
htmlType="submit"
loading={isCreating || isCreatingIcebergWrapper}
disabled={isCreating || isCreatingIcebergWrapper}
>
Create
</Button>
</Modal.Content>
</form>
</Form_Shadcn_>
</Modal>
</form>
</Form_Shadcn_>
</DialogSection>
<DialogFooter>
<Button
type="default"
disabled={isCreating || isCreatingIcebergWrapper}
onClick={() => setVisible(false)}
>
Cancel
</Button>
<Button
form={formId}
htmlType="submit"
loading={isCreating || isCreatingIcebergWrapper}
disabled={isCreating || isCreatingIcebergWrapper}
>
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,27 +1,58 @@
import { useParams } from 'common'
import { get as _get, find } from 'lodash'
import { useRouter } from 'next/router'
import { SubmitHandler, useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import z from 'zod'
import { toast } from 'sonner'
import { useParams } from 'common'
import { useDatabasePoliciesQuery } from 'data/database-policies/database-policies-query'
import { useDatabasePolicyDeleteMutation } from 'data/database-policies/database-policy-delete-mutation'
import { useBucketDeleteMutation } from 'data/storage/bucket-delete-mutation'
import { Bucket, useBucketsQuery } from 'data/storage/buckets-query'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import TextConfirmModal from 'ui-patterns/Dialogs/TextConfirmModal'
import { formatPoliciesForStorage } from './Storage.utils'
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogSection,
DialogSectionSeparator,
DialogTitle,
Form_Shadcn_,
FormControl_Shadcn_,
FormField_Shadcn_,
Input_Shadcn_,
Label_Shadcn_,
} from 'ui'
import { Admonition } from 'ui-patterns'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
export interface DeleteBucketModalProps {
visible: boolean
bucket?: Bucket
bucket: Bucket
onClose: () => void
}
const DeleteBucketModal = ({ visible = false, bucket, onClose }: DeleteBucketModalProps) => {
const formId = `delete-storage-bucket-form`
export const DeleteBucketModal = ({ visible, bucket, onClose }: DeleteBucketModalProps) => {
const router = useRouter()
const { ref: projectRef } = useParams()
const { data: project } = useSelectedProjectQuery()
const schema = z.object({
confirm: z.literal(bucket.name, {
errorMap: () => ({ message: `Please enter "${bucket.name}" to confirm` }),
}),
})
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
})
const { data } = useBucketsQuery({ projectRef })
const { data: policies } = useDatabasePoliciesQuery({
projectRef: project?.ref,
@@ -30,7 +61,7 @@ const DeleteBucketModal = ({ visible = false, bucket, onClose }: DeleteBucketMod
})
const { mutateAsync: deletePolicy } = useDatabasePolicyDeleteMutation()
const { mutate: deleteBucket, isLoading: isDeleting } = useBucketDeleteMutation({
const { mutate: deleteBucket, isLoading } = useBucketDeleteMutation({
onSuccess: async () => {
if (!project) return console.error('Project is required')
@@ -41,7 +72,7 @@ const DeleteBucketModal = ({ visible = false, bucket, onClose }: DeleteBucketMod
storageObjectsPolicies
)
const bucketPolicies = _get(
find(formattedStorageObjectPolicies, { name: bucket!.name }),
find(formattedStorageObjectPolicies, { name: bucket.name }),
['policies'],
[]
)
@@ -57,12 +88,12 @@ const DeleteBucketModal = ({ visible = false, bucket, onClose }: DeleteBucketMod
)
)
toast.success(`Successfully deleted bucket ${bucket?.name}`)
toast.success(`Successfully deleted bucket ${bucket.name}`)
router.push(`/project/${projectRef}/storage/buckets`)
onClose()
} catch (error) {
toast.success(
`Successfully deleted bucket ${bucket?.name}. However, there was a problem deleting the policies tied to the bucket. Please review them in the storage policies section`
`Successfully deleted bucket ${bucket.name}. However, there was a problem deleting the policies tied to the bucket. Please review them in the storage policies section`
)
}
},
@@ -70,34 +101,83 @@ const DeleteBucketModal = ({ visible = false, bucket, onClose }: DeleteBucketMod
const buckets = data ?? []
const onDeleteBucket = async () => {
const onSubmit: SubmitHandler<z.infer<typeof schema>> = async () => {
if (!projectRef) return console.error('Project ref is required')
if (!bucket) return console.error('No bucket is selected')
deleteBucket({ projectRef, id: bucket.id, type: bucket.type })
}
return (
<TextConfirmModal
variant={'destructive'}
visible={visible}
title={`Confirm deletion of ${bucket?.name}`}
confirmPlaceholder="Type in name of bucket"
onConfirm={onDeleteBucket}
onCancel={onClose}
confirmString={bucket?.name ?? ''}
loading={isDeleting}
text={
<>
Your bucket <span className="font-bold text-foreground">{bucket?.name}</span> and all its
contents will be permanently deleted.
</>
}
alert={{
title: 'You cannot recover this bucket once deleted.',
description: 'All bucket data will be lost.',
<Dialog
open={visible}
onOpenChange={(open) => {
if (!open) {
onClose()
}
}}
confirmLabel="Delete bucket"
/>
>
<DialogContent>
<DialogHeader>
<DialogTitle>{`Confirm deletion of ${bucket.name}`}</DialogTitle>
</DialogHeader>
<DialogSectionSeparator />
<DialogSection className="flex flex-col gap-4">
<Admonition
type="destructive"
title="You cannot recover this bucket once deleted."
description="All bucket data will be lost."
/>
<p>
Your bucket <span className="font-bold text-foreground">{bucket.name}</span> and all its
contents will be permanently deleted.
</p>
</DialogSection>
<DialogSectionSeparator />
<DialogSection>
<Form_Shadcn_ {...form}>
<form
id={formId}
className="flex flex-col gap-4"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField_Shadcn_
key="confirm"
name="confirm"
control={form.control}
render={({ field }) => (
<FormItemLayout
name="confirm"
label={
<>
Type <span className="font-bold text-foreground">{bucket.name}</span> to
confirm.
</>
}
>
<FormControl_Shadcn_>
<Input_Shadcn_
id="confirm"
autoComplete="off"
{...field}
placeholder="Type in name of bucket"
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</form>
</Form_Shadcn_>
</DialogSection>
<DialogFooter>
<Button type="default" disabled={isLoading} onClick={onClose}>
Cancel
</Button>
<Button form={formId} htmlType="submit" type="danger" loading={isLoading}>
Delete Bucket
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,9 +1,35 @@
import { useParams } from 'common'
import { ChevronDown } from 'lucide-react'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { useState } from 'react'
import { toast } from 'sonner'
import { Button, Collapsible, Form, Input, Listbox, Modal, Toggle, cn } from 'ui'
import { type SubmitHandler, useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import {
Button,
CollapsibleContent_Shadcn_,
CollapsibleTrigger_Shadcn_,
Collapsible_Shadcn_,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogSectionSeparator,
DialogSection,
DialogTitle,
FormControl_Shadcn_,
FormField_Shadcn_,
Form_Shadcn_,
Input_Shadcn_,
SelectContent_Shadcn_,
SelectItem_Shadcn_,
SelectTrigger_Shadcn_,
SelectValue_Shadcn_,
Select_Shadcn_,
Switch,
cn,
} from 'ui'
import { StorageSizeUnits } from 'components/interfaces/Storage/StorageSettings/StorageSettings.constants'
import {
@@ -16,256 +42,333 @@ import { useBucketUpdateMutation } from 'data/storage/bucket-update-mutation'
import { IS_PLATFORM } from 'lib/constants'
import { Admonition } from 'ui-patterns'
import { Bucket } from 'data/storage/buckets-query'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { isNonNullable } from 'lib/isNonNullable'
export interface EditBucketModalProps {
visible: boolean
bucket?: Bucket
bucket: Bucket
onClose: () => void
}
const EditBucketModal = ({ visible, bucket, onClose }: EditBucketModalProps) => {
const BucketSchema = z.object({
name: z.string(),
public: z.boolean().default(false),
has_file_size_limit: z.boolean().default(false),
formatted_size_limit: z.coerce
.number()
.min(0, 'File size upload limit has to be at least 0')
.default(0),
allowed_mime_types: z.string().trim().default(''),
})
const formId = 'edit-storage-bucket-form'
export const EditBucketModal = ({ visible, bucket, onClose }: EditBucketModalProps) => {
const { ref } = useParams()
const { mutate: updateBucket, isLoading: isUpdating } = useBucketUpdateMutation({
onSuccess: () => {
toast.success(`Successfully updated bucket "${bucket?.name}"`)
onClose()
},
})
const { data } = useProjectStorageConfigQuery(
{ projectRef: ref },
{ enabled: IS_PLATFORM && visible }
)
const { mutate: updateBucket, isLoading: isUpdating } = useBucketUpdateMutation()
const { data } = useProjectStorageConfigQuery({ projectRef: ref }, { enabled: IS_PLATFORM })
const { value, unit } = convertFromBytes(data?.fileSizeLimit ?? 0)
const formattedGlobalUploadLimit = `${value} ${unit}`
const [selectedUnit, setSelectedUnit] = useState<StorageSizeUnits>(StorageSizeUnits.BYTES)
const [selectedUnit, setSelectedUnit] = useState<string>(StorageSizeUnits.BYTES)
const [showConfiguration, setShowConfiguration] = useState(false)
const { value: fileSizeLimit } = convertFromBytes(bucket?.file_size_limit ?? 0)
const validate = (values: any) => {
const errors = {} as any
if (values.has_file_size_limit && values.formatted_size_limit < 0) {
errors.formatted_size_limit = 'File size upload limit has to be at least 0'
}
return errors
}
const form = useForm<z.infer<typeof BucketSchema>>({
resolver: zodResolver(BucketSchema),
defaultValues: {
name: bucket?.name ?? '',
public: bucket?.public,
has_file_size_limit: isNonNullable(bucket?.file_size_limit),
formatted_size_limit: fileSizeLimit ?? 0,
allowed_mime_types: (bucket?.allowed_mime_types ?? []).join(', '),
},
values: {
name: bucket?.name ?? '',
public: bucket?.public,
has_file_size_limit: isNonNullable(bucket?.file_size_limit),
formatted_size_limit: fileSizeLimit ?? 0,
allowed_mime_types: (bucket?.allowed_mime_types ?? []).join(', '),
},
mode: 'onSubmit',
})
const onSubmit = async (values: any) => {
const isPublicBucket = form.watch('public')
const hasFileSizeLimit = form.watch('has_file_size_limit')
const formattedSizeLimit = form.watch('formatted_size_limit')
const isChangingBucketVisibility = bucket?.public !== isPublicBucket
const isMakingBucketPrivate = bucket?.public && !isPublicBucket
const isMakingBucketPublic = !bucket?.public && isPublicBucket
const onSubmit: SubmitHandler<z.infer<typeof BucketSchema>> = async (values) => {
if (bucket === undefined) return console.error('Bucket is required')
if (ref === undefined) return console.error('Project ref is required')
updateBucket({
projectRef: ref,
id: bucket.id,
isPublic: values.public,
file_size_limit: values.has_file_size_limit
? convertToBytes(values.formatted_size_limit, selectedUnit)
: null,
allowed_mime_types:
values.allowed_mime_types.length > 0
? values.allowed_mime_types.split(',').map((x: string) => x.trim())
updateBucket(
{
projectRef: ref,
id: bucket.id,
isPublic: values.public,
file_size_limit: values.has_file_size_limit
? convertToBytes(values.formatted_size_limit, selectedUnit as StorageSizeUnits)
: null,
})
allowed_mime_types:
values.allowed_mime_types.length > 0
? values.allowed_mime_types.split(',').map((x: string) => x.trim())
: null,
},
{
onSuccess: () => {
toast.success(`Successfully updated bucket "${bucket?.name}"`)
onClose()
},
}
)
}
useEffect(() => {
if (visible) {
const { unit } = convertFromBytes(bucket?.file_size_limit ?? 0)
setSelectedUnit(unit)
setShowConfiguration(false)
}
}, [visible])
return (
<Modal
hideFooter
visible={visible}
size="medium"
header={`Edit bucket "${bucket?.name}"`}
onCancel={onClose}
<Dialog
open={visible}
onOpenChange={(open) => {
if (!open) {
form.reset()
onClose()
}
}}
>
<Form validateOnBlur={false} initialValues={{}} validate={validate} onSubmit={onSubmit}>
{({ values, resetForm }: { values: any; resetForm: any }) => {
const isChangingBucketVisibility = bucket?.public !== values.public
const isMakingBucketPrivate = bucket?.public && !values.public
const isMakingBucketPublic = !bucket?.public && values.public
// [Alaister] although this "technically" is breaking the rules of React hooks
// it won't error because the hooks are always rendered in the same order
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
if (visible && bucket !== undefined) {
const { value: fileSizeLimit } = convertFromBytes(bucket.file_size_limit ?? 0)
const values = {
name: bucket.name ?? '',
public: bucket.public,
file_size_limit: bucket.file_size_limit,
allowed_mime_types: (bucket.allowed_mime_types ?? []).join(', '),
has_file_size_limit: bucket.file_size_limit !== null,
formatted_size_limit: fileSizeLimit ?? 0,
}
resetForm({ values, initialValues: values })
}
}, [visible])
return (
<>
<Modal.Content className={cn('!px-0', isChangingBucketVisibility && '!pb-0')}>
<Input
disabled
id="name"
name="name"
type="text"
className="w-full px-5"
layout="vertical"
label="Name of bucket"
labelOptional="Buckets cannot be renamed once created."
/>
<div className={cn('flex flex-col gap-y-2 mt-6')}>
<Toggle
id="public"
<DialogContent>
<DialogHeader>
<DialogTitle>{`Edit bucket "${bucket?.name}"`}</DialogTitle>
</DialogHeader>
<DialogSectionSeparator />
<DialogSection>
<Form_Shadcn_ {...form}>
<form
id={formId}
className="flex flex-col gap-4"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField_Shadcn_
key="name"
name="name"
control={form.control}
render={({ field }) => (
<FormItemLayout
name="name"
label="Name of bucket"
labelOptional="Buckets cannot be renamed once created."
>
<FormControl_Shadcn_>
<Input_Shadcn_ id="name" {...field} disabled />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
key="public"
name="public"
control={form.control}
render={({ field }) => (
<FormItemLayout
name="public"
layout="flex"
label="Public bucket"
className="px-5"
descriptionText="Anyone can read any object without any authorization"
/>
{isChangingBucketVisibility && (
<Admonition
type="warning"
className="rounded-none border-x-0 border-b-0 mb-0 [&>div>p]:!leading-normal"
title={
isMakingBucketPublic
? 'Warning: Making bucket public'
: isMakingBucketPrivate
? 'Warning: Making bucket private'
: ''
}
>
<p>
{isMakingBucketPublic
? `This will make all objects in your bucket publicly accessible.`
: isMakingBucketPrivate
? `All objects in your bucket will be private and only accessible via signed URLs, or downloaded with the right authorisation headers.`
: ''}
</p>
description="Anyone can read any object without any authorization"
layout="flex"
>
<FormControl_Shadcn_>
<Switch
id="public"
size="large"
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
{isChangingBucketVisibility && (
<Admonition
type="warning"
className="rounded-none border-x-0 border-b-0 mb-0 pb-0 px-0 [&>svg]:left-0 [&>div>p]:!leading-normal"
title={
isMakingBucketPublic
? 'Warning: Making bucket public'
: isMakingBucketPrivate
? 'Warning: Making bucket private'
: ''
}
description={
<>
{isMakingBucketPublic ? (
<p>`This will make all objects in your bucket publicly accessible.`</p>
) : isMakingBucketPrivate ? (
<p>
`All objects in your bucket will be private and only accessible via signed
URLs, or downloaded with the right authorisation headers.`
</p>
) : null}
{isMakingBucketPrivate && (
<p>
Assets cached in the CDN may still be publicly accessible. You can
consider{' '}
{
'Assets cached in the CDN may still be publicly accessible. You can consider '
}
<InlineLink href="https://supabase.com/docs/guides/storage/cdn/smart-cdn#cache-eviction">
purging the cache
</InlineLink>{' '}
or moving your assets to a new bucket.
</InlineLink>
{' or moving your assets to a new bucket.'}
</p>
)}
</Admonition>
)}
</div>
</Modal.Content>
<Collapsible
</>
}
/>
)}
<Collapsible_Shadcn_
open={showConfiguration}
onOpenChange={() => setShowConfiguration(!showConfiguration)}
>
<Collapsible.Trigger asChild>
<div className="w-full cursor-pointer py-3 px-5 flex items-center justify-between border-t border-default">
<CollapsibleTrigger_Shadcn_ asChild>
<button className="w-full cursor-pointer py-3 flex items-center justify-between border-t border-default">
<p className="text-sm">Additional configuration</p>
<ChevronDown
size={18}
strokeWidth={2}
className={cn('text-foreground-light', showConfiguration && 'rotate-180')}
/>
</div>
</Collapsible.Trigger>
<Collapsible.Content className="py-4">
<div className="w-full space-y-4 px-5">
<div className="space-y-2">
<Toggle
id="has_file_size_limit"
name="has_file_size_limit"
layout="flex"
label="Restrict file upload size for bucket"
descriptionText="Prevent uploading of file sizes greater than a specified limit"
/>
{values.has_file_size_limit && (
<div className="grid grid-cols-12 col-span-12 gap-x-2 gap-y-1">
<div className="col-span-8">
<Input
type="number"
step={1}
id="formatted_size_limit"
name="formatted_size_limit"
disabled={false}
onKeyPress={(event) => {
if (event.charCode < 48 || event.charCode > 57) {
event.preventDefault()
}
}}
descriptionText={`Equivalent to ${convertToBytes(
values.formatted_size_limit,
selectedUnit
).toLocaleString()} bytes.`}
</button>
</CollapsibleTrigger_Shadcn_>
<CollapsibleContent_Shadcn_ className="py-4 space-y-4">
<div className="space-y-2">
<FormField_Shadcn_
key="has_file_size_limit"
name="has_file_size_limit"
control={form.control}
render={({ field }) => (
<FormItemLayout
name="has_file_size_limit"
label="Restrict file upload size for bucket"
description="Prevent uploading of file sizes greater than a specified limit"
layout="flex"
>
<FormControl_Shadcn_>
<Switch
id="has_file_size_limit"
size="large"
checked={field.value}
onCheckedChange={field.onChange}
/>
</div>
<div className="col-span-4">
<Listbox
id="size_limit_units"
disabled={false}
value={selectedUnit}
onChange={setSelectedUnit}
>
{Object.values(StorageSizeUnits).map((unit: string) => (
<Listbox.Option key={unit} label={unit} value={unit}>
<div>{unit}</div>
</Listbox.Option>
))}
</Listbox>
</div>
{IS_PLATFORM && (
<div className="col-span-12 mt-2">
<p className="text-foreground-light text-sm">
Note: Individual bucket upload will still be capped at the{' '}
<Link
href={`/project/${ref}/storage/settings`}
className="font-bold underline"
>
global upload limit
</Link>{' '}
of {formattedGlobalUploadLimit}
</p>
</div>
)}
</div>
</FormControl_Shadcn_>
</FormItemLayout>
)}
</div>
<Input
id="allowed_mime_types"
name="allowed_mime_types"
layout="vertical"
label="Allowed MIME types"
placeholder="e.g image/jpeg, image/png, audio/mpeg, video/mp4, etc"
labelOptional="Comma separated values"
descriptionText="Wildcards are allowed, e.g. image/*. Leave blank to allow any MIME type."
/>
{hasFileSizeLimit && (
<div className="grid grid-cols-12 col-span-12 gap-x-2 gap-y-1">
<div className="col-span-8">
<FormField_Shadcn_
key="formatted_size_limit"
name="formatted_size_limit"
control={form.control}
render={({ field }) => (
<FormItemLayout
name="formatted_size_limit"
description={`Equivalent to ${convertToBytes(
formattedSizeLimit,
selectedUnit as StorageSizeUnits
).toLocaleString()} bytes.`}
>
<FormControl_Shadcn_>
<Input_Shadcn_
id="formatted_size_limit"
aria-label="File size limit"
type="number"
min={0}
{...field}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</div>
<Select_Shadcn_ value={selectedUnit} onValueChange={setSelectedUnit}>
<SelectTrigger_Shadcn_
aria-label="File size limit unit"
size="small"
className="col-span-4"
>
<SelectValue_Shadcn_ asChild>
<>{selectedUnit}</>
</SelectValue_Shadcn_>
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
{Object.values(StorageSizeUnits).map((unit: string) => (
<SelectItem_Shadcn_ key={unit} value={unit} className="text-xs">
<div>{unit}</div>
</SelectItem_Shadcn_>
))}
</SelectContent_Shadcn_>
</Select_Shadcn_>
{IS_PLATFORM && (
<div className="col-span-12 mt-2">
<p className="text-foreground-light text-sm">
Note: Individual bucket upload will still be capped at the{' '}
<Link
href={`/project/${ref}/settings/storage`}
className="font-bold underline"
>
global upload limit
</Link>{' '}
of {formattedGlobalUploadLimit}
</p>
</div>
)}
</div>
)}
</div>
</Collapsible.Content>
</Collapsible>
<Modal.Separator />
<Modal.Content className="flex items-center space-x-2 justify-end">
<Button type="default" disabled={isUpdating} onClick={() => onClose()}>
Cancel
</Button>
<Button type="primary" htmlType="submit" loading={isUpdating} disabled={isUpdating}>
Save
</Button>
</Modal.Content>
</>
)
}}
</Form>
</Modal>
<FormField_Shadcn_
key="allowed_mime_types"
name="allowed_mime_types"
control={form.control}
render={({ field }) => (
<FormItemLayout
name="allowed_mime_types"
label="Allowed MIME types"
labelOptional="Comma separated values"
description="Wildcards are allowed, e.g. image/*. Leave blank to allow any MIME type."
>
<FormControl_Shadcn_>
<Input_Shadcn_
id="allowed_mime_types"
{...field}
placeholder="e.g image/jpeg, image/png, audio/mpeg, video/mp4, etc"
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</CollapsibleContent_Shadcn_>
</Collapsible_Shadcn_>
</form>
</Form_Shadcn_>
</DialogSection>
<DialogFooter>
<Button
type="default"
disabled={isUpdating}
onClick={() => {
form.reset()
onClose()
}}
>
Cancel
</Button>
<Button form={formId} htmlType="submit" loading={isUpdating}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -4,7 +4,17 @@ import { toast } from 'sonner'
import { useBucketEmptyMutation } from 'data/storage/bucket-empty-mutation'
import type { Bucket } from 'data/storage/buckets-query'
import { useStorageExplorerStateSnapshot } from 'state/storage-explorer'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import {
Button,
Dialog,
DialogHeader,
DialogTitle,
DialogContent,
DialogSection,
DialogSectionSeparator,
DialogFooter,
} from 'ui'
import { Admonition } from 'ui-patterns'
export interface EmptyBucketModalProps {
visible: boolean
@@ -12,7 +22,7 @@ export interface EmptyBucketModalProps {
onClose: () => void
}
export const EmptyBucketModal = ({ visible = false, bucket, onClose }: EmptyBucketModalProps) => {
export const EmptyBucketModal = ({ visible, bucket, onClose }: EmptyBucketModalProps) => {
const { ref: projectRef } = useParams()
const { fetchFolderContents } = useStorageExplorerStateSnapshot()
@@ -37,21 +47,38 @@ export const EmptyBucketModal = ({ visible = false, bucket, onClose }: EmptyBuck
}
return (
<ConfirmationModal
variant={'destructive'}
size="small"
title={`Confirm to delete all contents from ${bucket?.name}`}
confirmLabel="Empty bucket"
visible={visible}
loading={isLoading}
onCancel={() => onClose()}
onConfirm={onEmptyBucket}
alert={{
title: 'This action cannot be undone',
description: 'The contents of your bucket cannot be recovered once deleted',
<Dialog
open={visible}
onOpenChange={(open) => {
if (!open) {
onClose()
}
}}
>
<p className="text-sm">Are you sure you want to empty the bucket "{bucket?.name}"?</p>
</ConfirmationModal>
<DialogContent>
<DialogHeader>
<DialogTitle>{`Confirm to delete all contents from ${bucket?.name}`}</DialogTitle>
</DialogHeader>
<DialogSectionSeparator />
<DialogSection className="flex flex-col gap-4">
<Admonition
type="destructive"
title="This action cannot be undone"
description="The contents of your bucket cannot be recovered once deleted."
/>
<p className="text-sm">Are you sure you want to empty the bucket "{bucket?.name}"?</p>
</DialogSection>
<DialogFooter>
<Button type="default" disabled={isLoading} onClick={onClose}>
Cancel
</Button>
<Button type="danger" loading={isLoading} onClick={onEmptyBucket}>
Empty Bucket
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export default EmptyBucketModal

View File

@@ -1,18 +1,11 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { Edit } from 'lucide-react'
import { useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useState } from 'react'
import { useParams } from 'common'
import { DeleteBucketModal } from 'components/interfaces/Storage'
import CreateBucketModal from 'components/interfaces/Storage/CreateBucketModal'
import EditBucketModal from 'components/interfaces/Storage/EditBucketModal'
import { EmptyBucketModal } from 'components/interfaces/Storage/EmptyBucketModal'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
import { Bucket, useBucketsQuery } from 'data/storage/buckets-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useBucketsQuery } from 'data/storage/buckets-query'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { useStorageExplorerStateSnapshot } from 'state/storage-explorer'
import { Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_, Menu } from 'ui'
@@ -28,16 +21,11 @@ import BucketRow from './BucketRow'
const StorageMenu = () => {
const router = useRouter()
const { ref, bucketId } = useParams()
const { data: project } = useSelectedProjectQuery()
const { data: projectDetails } = useSelectedProjectQuery()
const snap = useStorageExplorerStateSnapshot()
const isBranch = project?.parent_project_ref !== undefined
const isBranch = projectDetails?.parent_project_ref !== undefined
const [searchText, setSearchText] = useState<string>('')
const [showCreateBucketModal, setShowCreateBucketModal] = useState(false)
const [selectedBucketToEdit, setSelectedBucketToEdit] = useState<Bucket>()
const [selectedBucketToEmpty, setSelectedBucketToEmpty] = useState<Bucket>()
const [selectedBucketToDelete, setSelectedBucketToDelete] = useState<Bucket>()
const canCreateBuckets = useCheckPermissions(PermissionAction.STORAGE_WRITE, '*')
const page = router.pathname.split('/')[4] as
| undefined
@@ -69,24 +57,7 @@ const StorageMenu = () => {
<>
<Menu type="pills" className="mt-6 flex flex-grow flex-col">
<div className="mb-6 mx-5 flex flex-col gap-y-1.5">
<ButtonTooltip
block
type="default"
icon={<Edit />}
disabled={!canCreateBuckets}
style={{ justifyContent: 'start' }}
onClick={() => setShowCreateBucketModal(true)}
tooltip={{
content: {
side: 'bottom',
text: !canCreateBuckets
? 'You need additional permissions to create buckets'
: undefined,
},
}}
>
New bucket
</ButtonTooltip>
<CreateBucketModal />
<InnerSideBarFilters className="px-0">
<InnerSideBarFilterSearchInput
@@ -170,9 +141,6 @@ const StorageMenu = () => {
bucket={bucket}
projectRef={ref}
isSelected={isSelected}
onSelectEmptyBucket={() => setSelectedBucketToEmpty(bucket)}
onSelectDeleteBucket={() => setSelectedBucketToDelete(bucket)}
onSelectEditBucket={() => setSelectedBucketToEdit(bucket)}
/>
)
})}
@@ -195,29 +163,6 @@ const StorageMenu = () => {
</div>
</div>
</Menu>
<CreateBucketModal
visible={showCreateBucketModal}
onClose={() => setShowCreateBucketModal(false)}
/>
<EditBucketModal
visible={selectedBucketToEdit !== undefined}
bucket={selectedBucketToEdit}
onClose={() => setSelectedBucketToEdit(undefined)}
/>
<EmptyBucketModal
visible={selectedBucketToEmpty !== undefined}
bucket={selectedBucketToEmpty}
onClose={() => setSelectedBucketToEmpty(undefined)}
/>
<DeleteBucketModal
visible={selectedBucketToDelete !== undefined}
bucket={selectedBucketToDelete}
onClose={() => setSelectedBucketToDelete(undefined)}
/>
</>
)
}

View File

@@ -0,0 +1,100 @@
import { describe, expect, it, beforeEach, vi } from 'vitest'
import { screen, waitFor, fireEvent } from '@testing-library/dom'
import userEvent from '@testing-library/user-event'
import { addAPIMock } from 'tests/lib/msw'
import { ProjectContextProvider } from 'components/layouts/ProjectLayout/ProjectContext'
import { render } from 'tests/helpers'
import { routerMock } from 'tests/lib/route-mock'
import CreateBucketModal from '../CreateBucketModal'
describe(`CreateBucketModal`, () => {
beforeEach(() => {
vi.mock(`hooks/misc/useCheckPermissions`, () => ({
useCheckPermissions: vi.fn(),
useAsyncCheckProjectPermissions: vi.fn().mockImplementation(() => ({ can: true })),
}))
// useParams
routerMock.setCurrentUrl(`/project/default/storage/buckets`)
// useSelectedProject -> Project
addAPIMock({
method: `get`,
path: `/platform/projects/:ref`,
// @ts-expect-error
response: {
cloud_provider: 'localhost',
id: 1,
inserted_at: '2021-08-02T06:40:40.646Z',
name: 'Default Project',
organization_id: 1,
ref: 'default',
region: 'local',
status: 'ACTIVE_HEALTHY',
},
})
// useBucketCreateMutation
addAPIMock({
method: `post`,
path: `/platform/storage/:ref/buckets`,
})
})
it(`renders a dialog with a form`, async () => {
render(
<ProjectContextProvider projectRef="default">
<CreateBucketModal />
</ProjectContextProvider>
)
const dialogTrigger = screen.getByRole(`button`, { name: `New bucket` })
await userEvent.click(dialogTrigger)
await waitFor(() => {
expect(screen.getByRole(`dialog`)).toBeInTheDocument()
})
const nameInput = screen.getByLabelText(`Name of bucket`)
await userEvent.type(nameInput, `test`)
const standardOption = screen.getByLabelText(`Standard bucket`)
await userEvent.click(standardOption)
const publicToggle = screen.getByLabelText(`Public bucket`)
expect(publicToggle).not.toBeChecked()
await userEvent.click(publicToggle)
expect(publicToggle).toBeChecked()
const detailsTrigger = screen.getByRole(`button`, { name: `Additional configuration` })
expect(detailsTrigger).toHaveAttribute(`data-state`, `closed`)
await userEvent.click(detailsTrigger)
expect(detailsTrigger).toHaveAttribute(`data-state`, `open`)
const sizeLimitToggle = screen.getByLabelText(`Restrict file upload size for bucket`)
expect(sizeLimitToggle).not.toBeChecked()
await userEvent.click(sizeLimitToggle)
expect(sizeLimitToggle).toBeChecked()
const sizeLimitInput = screen.getByLabelText(`File size limit`)
expect(sizeLimitInput).toHaveValue(0)
await userEvent.type(sizeLimitInput, `25`)
const sizeLimitUnitSelect = screen.getByLabelText(`File size limit unit`)
expect(sizeLimitUnitSelect).toHaveTextContent(`bytes`)
await userEvent.click(sizeLimitUnitSelect)
const mbOption = screen.getByRole(`option`, { name: `MB` })
await userEvent.click(mbOption)
expect(sizeLimitUnitSelect).toHaveTextContent(`MB`)
const mimeTypeInput = screen.getByLabelText(`Allowed MIME types`)
expect(mimeTypeInput).toHaveValue(``)
await userEvent.type(mimeTypeInput, `image/jpeg, image/png`)
const submitButton = screen.getByRole(`button`, { name: `Create` })
fireEvent.click(submitButton)
await waitFor(() =>
expect(routerMock.asPath).toStrictEqual(`/project/default/storage/buckets/test`)
)
})
})

View File

@@ -0,0 +1,141 @@
import { describe, expect, it, beforeEach, vi } from 'vitest'
import { screen, waitFor, fireEvent } from '@testing-library/dom'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { faker } from '@faker-js/faker'
import { addAPIMock } from 'tests/lib/msw'
import { ProjectContextProvider } from 'components/layouts/ProjectLayout/ProjectContext'
import { Bucket } from 'data/storage/buckets-query'
import DeleteBucketModal from '../DeleteBucketModal'
import { render } from 'tests/helpers'
import { routerMock } from 'tests/lib/route-mock'
const bucket: Bucket = {
id: faker.string.uuid(),
name: `test`,
owner: faker.string.uuid(),
public: faker.datatype.boolean(),
allowed_mime_types: faker.helpers.multiple(() => faker.system.mimeType(), {
count: { min: 1, max: 5 },
}),
file_size_limit: faker.number.int({ min: 0, max: 25165824 }),
type: faker.helpers.arrayElement(['STANDARD', 'ANALYTICS', undefined]),
created_at: faker.date.recent().toISOString(),
updated_at: faker.date.recent().toISOString(),
}
const Page = ({ onClose }: { onClose: () => void }) => {
const [open, setOpen] = useState(false)
return (
<ProjectContextProvider projectRef="default">
<button onClick={() => setOpen(true)}>Open</button>
<DeleteBucketModal
visible={open}
bucket={bucket}
onClose={() => {
setOpen(false)
onClose()
}}
/>
</ProjectContextProvider>
)
}
describe(`DeleteBucketModal`, () => {
beforeEach(() => {
// useParams
routerMock.setCurrentUrl(`/project/default/storage/buckets/test`)
// useProjectContext
addAPIMock({
method: `get`,
path: `/platform/projects/:ref`,
// @ts-expect-error
response: {
cloud_provider: 'localhost',
id: 1,
inserted_at: '2021-08-02T06:40:40.646Z',
name: 'Default Project',
organization_id: 1,
ref: 'default',
region: 'local',
status: 'ACTIVE_HEALTHY',
},
})
// useBucketsQuery
addAPIMock({
method: `get`,
path: `/platform/storage/:ref/buckets`,
response: [bucket],
})
// useDatabasePoliciesQuery
addAPIMock({
method: `get`,
path: `/platform/pg-meta/:ref/policies`,
response: [
{
id: faker.number.int({ min: 1 }),
name: faker.word.noun(),
action: faker.helpers.arrayElement(['PERMISSIVE', 'RESTRICTIVE']),
command: faker.helpers.arrayElement(['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'ALL']),
table: faker.word.noun(),
table_id: faker.number.int({ min: 1 }),
check: null,
definition: null,
schema: faker.lorem.sentence(),
roles: faker.helpers.multiple(() => faker.word.noun(), {
count: { min: 1, max: 5 },
}),
},
],
})
// useBucketDeleteMutation
addAPIMock({
method: `post`,
path: `/platform/storage/:ref/buckets/:id/empty`,
})
// useDatabasePolicyDeleteMutation
addAPIMock({
method: `delete`,
path: `/platform/storage/:ref/buckets/:id`,
})
})
it(`renders a confirmation dialog`, async () => {
const onClose = vi.fn()
render(<Page onClose={onClose} />)
const openButton = screen.getByRole(`button`, { name: `Open` })
await userEvent.click(openButton)
await screen.findByRole(`dialog`)
const input = screen.getByLabelText(/Type/)
await userEvent.type(input, `test`)
const confirmButton = screen.getByRole(`button`, { name: `Delete Bucket` })
fireEvent.click(confirmButton)
await waitFor(() => expect(onClose).toHaveBeenCalledOnce())
expect(routerMock.asPath).toStrictEqual(`/project/default/storage/buckets`)
})
it(`prevents submission when the input doesn't match the bucket name`, async () => {
const onClose = vi.fn()
render(<Page onClose={onClose} />)
const openButton = screen.getByRole(`button`, { name: `Open` })
await userEvent.click(openButton)
await screen.findByRole(`dialog`)
const input = screen.getByLabelText(/Type/)
await userEvent.type(input, `invalid`)
const confirmButton = screen.getByRole(`button`, { name: `Delete Bucket` })
fireEvent.click(confirmButton)
await waitFor(() => {
expect(screen.getByText(/Please enter/)).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,119 @@
import { describe, expect, it, beforeEach, vi } from 'vitest'
import { screen, waitFor, fireEvent } from '@testing-library/dom'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { faker } from '@faker-js/faker'
import { addAPIMock } from 'tests/lib/msw'
import { ProjectContextProvider } from 'components/layouts/ProjectLayout/ProjectContext'
import { Bucket } from 'data/storage/buckets-query'
import { render } from 'tests/helpers'
import { routerMock } from 'tests/lib/route-mock'
import EditBucketModal from '../EditBucketModal'
const bucket: Bucket = {
id: faker.string.uuid(),
name: `test`,
owner: faker.string.uuid(),
public: false,
allowed_mime_types: [],
file_size_limit: undefined,
type: 'STANDARD',
created_at: faker.date.recent().toISOString(),
updated_at: faker.date.recent().toISOString(),
}
const Page = ({ onClose }: { onClose: () => void }) => {
const [open, setOpen] = useState(false)
return (
<ProjectContextProvider projectRef="default">
<button onClick={() => setOpen(true)}>Open</button>
<EditBucketModal
visible={open}
bucket={bucket}
onClose={() => {
setOpen(false)
onClose()
}}
/>
</ProjectContextProvider>
)
}
describe(`EditBucketModal`, () => {
beforeEach(() => {
// useParams
routerMock.setCurrentUrl(`/project/default/storage/buckets/test`)
// useSelectedProject -> Project
addAPIMock({
method: `get`,
path: `/platform/projects/:ref`,
// @ts-expect-error
response: {
cloud_provider: 'localhost',
id: 1,
inserted_at: '2021-08-02T06:40:40.646Z',
name: 'Default Project',
organization_id: 1,
ref: 'default',
region: 'local',
status: 'ACTIVE_HEALTHY',
},
})
// useBucketUpdateMutation
addAPIMock({
method: `patch`,
path: `/platform/storage/:ref/buckets/:id`,
})
})
it(`renders a dialog with a form`, async () => {
const onClose = vi.fn()
render(<Page onClose={onClose} />)
const openButton = screen.getByRole(`button`, { name: `Open` })
await userEvent.click(openButton)
await screen.findByRole(`dialog`)
const nameInput = screen.getByLabelText(`Name of bucket`)
expect(nameInput).toHaveValue(`test`)
expect(nameInput).toBeDisabled()
const publicToggle = screen.getByLabelText(`Public bucket`)
expect(publicToggle).not.toBeChecked()
await userEvent.click(publicToggle)
expect(publicToggle).toBeChecked()
const detailsTrigger = screen.getByRole(`button`, { name: `Additional configuration` })
expect(detailsTrigger).toHaveAttribute(`data-state`, `closed`)
await userEvent.click(detailsTrigger)
expect(detailsTrigger).toHaveAttribute(`data-state`, `open`)
const sizeLimitToggle = screen.getByLabelText(`Restrict file upload size for bucket`)
expect(sizeLimitToggle).not.toBeChecked()
await userEvent.click(sizeLimitToggle)
expect(sizeLimitToggle).toBeChecked()
const sizeLimitInput = screen.getByLabelText(`File size limit`)
expect(sizeLimitInput).toHaveValue(0)
await userEvent.type(sizeLimitInput, `25`)
const sizeLimitUnitSelect = screen.getByLabelText(`File size limit unit`)
expect(sizeLimitUnitSelect).toHaveTextContent(`bytes`)
await userEvent.click(sizeLimitUnitSelect)
const mbOption = screen.getByRole(`option`, { name: `MB` })
await userEvent.click(mbOption)
expect(sizeLimitUnitSelect).toHaveTextContent(`MB`)
const mimeTypeInput = screen.getByLabelText(`Allowed MIME types`)
expect(mimeTypeInput).toHaveValue(``)
await userEvent.type(mimeTypeInput, `image/jpeg, image/png`)
const confirmButton = screen.getByRole(`button`, { name: `Save` })
fireEvent.click(confirmButton)
await waitFor(() => expect(onClose).toHaveBeenCalledOnce())
})
})

View File

@@ -0,0 +1,96 @@
import { describe, expect, it, beforeEach, vi } from 'vitest'
import { screen, waitFor, fireEvent } from '@testing-library/dom'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { faker } from '@faker-js/faker'
import { addAPIMock } from 'tests/lib/msw'
import { ProjectContextProvider } from 'components/layouts/ProjectLayout/ProjectContext'
import { Bucket } from 'data/storage/buckets-query'
import EmptyBucketModal from '../EmptyBucketModal'
import { render } from 'tests/helpers'
import { routerMock } from 'tests/lib/route-mock'
const bucket: Bucket = {
id: faker.string.uuid(),
name: `test`,
owner: faker.string.uuid(),
public: faker.datatype.boolean(),
allowed_mime_types: faker.helpers.multiple(() => faker.system.mimeType(), {
count: { min: 1, max: 5 },
}),
file_size_limit: faker.number.int({ min: 0, max: 25165824 }),
type: faker.helpers.arrayElement(['STANDARD', 'ANALYTICS', undefined]),
created_at: faker.date.recent().toISOString(),
updated_at: faker.date.recent().toISOString(),
}
const Page = ({ onClose }: { onClose: () => void }) => {
const [open, setOpen] = useState(false)
return (
<ProjectContextProvider projectRef="default">
<button onClick={() => setOpen(true)}>Open</button>
<EmptyBucketModal
visible={open}
bucket={bucket}
onClose={() => {
setOpen(false)
onClose()
}}
/>
</ProjectContextProvider>
)
}
describe(`EmptyBucketModal`, () => {
beforeEach(() => {
// useParams
routerMock.setCurrentUrl(`/project/default/storage/buckets/test`)
// useSelectedProject -> Project
addAPIMock({
method: `get`,
path: `/platform/projects/:ref`,
// @ts-expect-error
response: {
cloud_provider: 'localhost',
id: 1,
inserted_at: '2021-08-02T06:40:40.646Z',
name: 'Default Project',
organization_id: 1,
ref: 'default',
region: 'local',
status: 'ACTIVE_HEALTHY',
},
})
// useBucketEmptyMutation
addAPIMock({
method: `post`,
path: `/platform/storage/:ref/buckets/:id/empty`,
})
// Called by useStorageExplorerStateSnapshot but seems
// to be unnecessary for succesful test?
//
// useProjectSettingsV2Query -> ProjectSettings
// GET /platform/projects/:ref/settings
// useAPIKeysQuery -> APIKey[]
// GET /v1/projects/:ref/api-keys
// listBucketObjects -> ListBucketObjectsData
// POST /platform/storage/:ref/buckets/:id/objects/list
})
it(`renders a confirmation dialog`, async () => {
const onClose = vi.fn()
render(<Page onClose={onClose} />)
const openButton = screen.getByRole(`button`, { name: `Open` })
await userEvent.click(openButton)
await screen.findByRole(`dialog`)
const confirmButton = screen.getByRole(`button`, { name: `Empty Bucket` })
fireEvent.click(confirmButton)
await waitFor(() => expect(onClose).toHaveBeenCalledOnce())
})
})

View File

@@ -0,0 +1,26 @@
import { describe, it, expect } from 'vitest'
import { isNonNullable } from './isNonNullable.js'
describe(`isNonNullable`, () => {
it.each([
[null, false],
[undefined, false],
// void
[(() => {})(), false],
// Truthy
[`string`, true],
[1, true],
[true, true],
// Falsy
[``, true],
[NaN, true],
[0, true],
[0, true],
[false, true],
// Type coercion
[[], true],
[{}, true],
])(`correctly matches against nullish values`, (val, expected) => {
expect(isNonNullable(val)).toStrictEqual(expected)
})
})

View File

@@ -0,0 +1,22 @@
export type Maybe<T> = T | null | undefined
/**
* Used to test whether a `Maybe` typed value is `null` or `undefined`.
*
* When called, the given value's type is narrowed to `NonNullable<T>`.
*
* ### Example Usage:
*
* ```ts
* const fn = (str: Maybe<string>) => {
* if (!isNonNullable(str)) {
* // typeof str = null | undefined
* // ...
* }
* // typeof str = string
* // ...
* }
* ```
*/
export const isNonNullable = <T extends Maybe<unknown>>(val?: T): val is NonNullable<T> =>
typeof val !== `undefined` && val !== null

View File

@@ -1,6 +1,6 @@
import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vitest/config'
import { configDefaults, defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import tsconfigPaths from 'vite-tsconfig-paths'
@@ -26,9 +26,11 @@ export default defineConfig({
environment: 'jsdom', // TODO(kamil): This should be set per test via header in .tsx files only
setupFiles: [
resolve(dirname, './tests/vitestSetup.ts'),
resolve(dirname, './tests/setup/polyfills.js'),
resolve(dirname, './tests/setup/polyfills.ts'),
resolve(dirname, './tests/setup/radix.js'),
],
// Don't look for tests in the nextjs output directory
exclude: [...configDefaults.exclude, `.next/*`],
reporters: [['default']],
coverage: {
reporter: ['lcov'],