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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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`)
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
26
apps/studio/lib/isNonNullable.test.ts
Normal file
26
apps/studio/lib/isNonNullable.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
22
apps/studio/lib/isNonNullable.ts
Normal file
22
apps/studio/lib/isNonNullable.ts
Normal 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
|
||||
@@ -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'],
|
||||
|
||||
Reference in New Issue
Block a user