diff --git a/apps/studio/components/interfaces/Storage/BucketRow.tsx b/apps/studio/components/interfaces/Storage/BucketRow.tsx index 78ef08e09c0..4023d5ab356 100644 --- a/apps/studio/components/interfaces/Storage/BucketRow.tsx +++ b/apps/studio/components/interfaces/Storage/BucketRow.tsx @@ -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(null) + const onClose = () => setModal(null) return (
onSelectEditBucket()} + onClick={() => setModal(`edit`)} >

Edit bucket

@@ -93,7 +91,7 @@ const BucketRow = ({ onSelectEmptyBucket()} + onClick={() => setModal(`empty`)} >

Empty bucket

@@ -103,7 +101,7 @@ const BucketRow = ({ onSelectDeleteBucket()} + onClick={() => setModal(`delete`)} >

Delete bucket

@@ -113,6 +111,10 @@ const BucketRow = ({ ) : (
)} + + + +
) } diff --git a/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx b/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx index b02df4a2a4e..db5181a618d 100644 --- a/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx +++ b/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx @@ -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 -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.BYTES) + const [selectedUnit, setSelectedUnit] = useState(StorageSizeUnits.BYTES) const [showConfiguration, setShowConfiguration] = useState(false) const form = useForm({ @@ -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 = 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 ( - onClose()} + { + if (!open) { + handleClose() + } + }} > - -
- - ( - - - - - - )} - /> - -
+ + } + disabled={!canCreateBuckets} + style={{ justifyContent: 'start' }} + onClick={() => setVisible(true)} + tooltip={{ + content: { + side: 'bottom', + text: !canCreateBuckets + ? 'You need additional permissions to create buckets' + : undefined, + }, + }} + > + New bucket + + + + + Create storage bucket + + + + + ( + + + + + + )} + /> + + ( field.onChange(v)} value={field.value} + onValueChange={(v) => field.onChange(v)} > -
-
-

- Compatible with S3 buckets. -

-
-
-
+ /> {IS_PLATFORM && ( -
-
-

- Stores Iceberg files and is optimized for analytical workloads. -

-
-
- {!icebergCatalogEnabled && ( -
- - - This is currently in alpha and not enabled for your project. Sign - up{' '} - - here - - . - -
- )} + <> +

+ Stores Iceberg files and is optimized for analytical workloads. +

+ + {icebergCatalogEnabled ? null : ( +
+ + + This feature is currently in alpha and not yet enabled for your + project. Sign up{' '} + + here + + . + +
+ )} +
)}
@@ -268,26 +311,28 @@ const CreateBucketModal = ({ visible, onClose }: CreateBucketModalProps) => {
)} /> -
-
- - {isStandardBucket ? ( - <> - -
+ + + + {isStandardBucket ? ( + <> ( - + - @@ -296,235 +341,240 @@ const CreateBucketModal = ({ visible, onClose }: CreateBucketModalProps) => { {isPublicBucket && ( -

- Users can read objects in public buckets without any authorization. -

-

- Row level security (RLS) policies are still required for other operations - such as object uploads and deletes. -

-
- )} -
-
- - setShowConfiguration(!showConfiguration)} - > - -
-

Additional restrictions

- +

+ Users can read objects in public buckets without any authorization. +

+

+ Row level security (RLS) policies are still required for other + operations such as object uploads and deletes. +

+ + } /> -
-
- -
-
+ )} + setShowConfiguration(!showConfiguration)} + > + + + + +
+ ( + + + + + + )} + /> + {hasFileSizeLimit && ( +
+
+ ( + + + + + + )} + /> +
+ + + + <>{selectedUnit} + + + + {Object.values(StorageSizeUnits).map((unit: string) => ( + +
{unit}
+
+ ))} +
+
+ {IS_PLATFORM && ( +
+

+ Note: Individual bucket uploads will still be capped at the{' '} + + global upload limit + {' '} + of {formattedGlobalUploadLimit} +

+
+ )} +
+ )} +
( - + - )} /> - {hasFileSizeLimit && ( -
-
- ( - - - { - if (event.charCode < 48 || event.charCode > 57) { - event.preventDefault() - } - }} - /> - - - Equivalent to{' '} - {convertToBytes( - formattedSizeLimit, - selectedUnit - ).toLocaleString()}{' '} - bytes. - - - )} - /> -
-
- - {Object.values(StorageSizeUnits).map((unit: string) => ( - -
{unit}
-
- ))} -
-
- {IS_PLATFORM && ( -
-

- Note: Individual bucket uploads will still be capped at the{' '} - - global upload limit - {' '} - of {formattedGlobalUploadLimit} -

-
- )} -
- )} -
- ( - - - - - - )} - /> -
-
-
- - ) : ( - - {icebergWrapperExtensionState === 'installed' ? ( - -

- Supabase will setup a - - foreign data wrapper - {bucketName && {`${bucketName}_fdw`}} - - - {' '} - for easier access to the data. This action will also create{' '} - - S3 Access Keys - {bucketName && ( - <> - {' '} - named {`${bucketName}_keys`} - - )} - - and - - four Vault Secrets - {bucketName && ( - <> - {' '} - prefixed with{' '} - {`${bucketName}_vault_`} - - )} - - . - -

-

- As a final step, you'll need to create an{' '} - Iceberg namespace before you - connect the Iceberg data to your database. -

-
+ + + ) : ( - - - - You need to install the Iceberg wrapper extension to connect your Analytic - bucket to your database. - - -

- You need to install the wrappers extension - (with the minimum version of 0.5.3) if you want to connect your - Analytics bucket to your database. -

-
-
+ <> + {icebergWrapperExtensionState === 'installed' ? ( + +

+ Supabase will setup a + + foreign data wrapper + {bucketName && {`${bucketName}_fdw`}} + + + {' '} + for easier access to the data. This action will also create{' '} + + S3 Access Keys + {bucketName && ( + <> + {' '} + named {`${bucketName}_keys`} + + )} + + and + + four Vault Secrets + {bucketName && ( + <> + {' '} + prefixed with{' '} + {`${bucketName}_vault_`} + + )} + + . + +

+

+ As a final step, you'll need to create an{' '} + Iceberg namespace before you + connect the Iceberg data to your database. +

+
+ ) : ( + + + + You need to install the Iceberg wrapper extension to connect your Analytic + bucket to your database. + + +

+ You need to install the wrappers{' '} + extension (with the minimum version of 0.5.3) if you want to + connect your Analytics bucket to your database. +

+
+
+ )} + )} -
- )} - - - - - - -
-
+ + + + + + + + + ) } diff --git a/apps/studio/components/interfaces/Storage/DeleteBucketModal.tsx b/apps/studio/components/interfaces/Storage/DeleteBucketModal.tsx index 0515641d074..509b1969d46 100644 --- a/apps/studio/components/interfaces/Storage/DeleteBucketModal.tsx +++ b/apps/studio/components/interfaces/Storage/DeleteBucketModal.tsx @@ -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>({ + 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> = 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 ( - - Your bucket {bucket?.name} and all its - contents will be permanently deleted. - - } - alert={{ - title: 'You cannot recover this bucket once deleted.', - description: 'All bucket data will be lost.', + { + if (!open) { + onClose() + } }} - confirmLabel="Delete bucket" - /> + > + + + {`Confirm deletion of ${bucket.name}`} + + + + +

+ Your bucket {bucket.name} and all its + contents will be permanently deleted. +

+
+ + + +
+ ( + + Type {bucket.name} to + confirm. + + } + > + + + + + )} + /> + +
+
+ + + + +
+
) } diff --git a/apps/studio/components/interfaces/Storage/EditBucketModal.tsx b/apps/studio/components/interfaces/Storage/EditBucketModal.tsx index ba9cb5b9a51..975f340279b 100644 --- a/apps/studio/components/interfaces/Storage/EditBucketModal.tsx +++ b/apps/studio/components/interfaces/Storage/EditBucketModal.tsx @@ -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.BYTES) + const [selectedUnit, setSelectedUnit] = useState(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>({ + 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> = 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 ( - { + if (!open) { + form.reset() + onClose() + } + }} > -
- {({ 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 ( - <> - - -
- + + {`Edit bucket "${bucket?.name}"`} + + + + + + ( + + + + + + )} + /> + ( + - {isChangingBucketVisibility && ( - -

- {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.` - : ''} -

+ description="Anyone can read any object without any authorization" + layout="flex" + > + + + +
+ )} + /> + {isChangingBucketVisibility && ( + + {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.` +

+ ) : null} + {isMakingBucketPrivate && (

- 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 ' + } purging the cache - {' '} - or moving your assets to a new bucket. + + {' or moving your assets to a new bucket.'}

)} -
- )} -
-
- + } + /> + )} + setShowConfiguration(!showConfiguration)} > - -
+ +
-
- -
-
- - {values.has_file_size_limit && ( -
-
- { - if (event.charCode < 48 || event.charCode > 57) { - event.preventDefault() - } - }} - descriptionText={`Equivalent to ${convertToBytes( - values.formatted_size_limit, - selectedUnit - ).toLocaleString()} bytes.`} + + + +
+ ( + + + -
-
- - {Object.values(StorageSizeUnits).map((unit: string) => ( - -
{unit}
-
- ))} -
-
- {IS_PLATFORM && ( -
-

- Note: Individual bucket upload will still be capped at the{' '} - - global upload limit - {' '} - of {formattedGlobalUploadLimit} -

-
- )} -
+ + )} -
- + {hasFileSizeLimit && ( +
+
+ ( + + + + + + )} + /> +
+ + + + <>{selectedUnit} + + + + {Object.values(StorageSizeUnits).map((unit: string) => ( + +
{unit}
+
+ ))} +
+
+ {IS_PLATFORM && ( +
+

+ Note: Individual bucket upload will still be capped at the{' '} + + global upload limit + {' '} + of {formattedGlobalUploadLimit} +

+
+ )} +
+ )}
- - - - - - - - - ) - }} - - + ( + + + + + + )} + /> + + + + + + + + + + + ) } diff --git a/apps/studio/components/interfaces/Storage/EmptyBucketModal.tsx b/apps/studio/components/interfaces/Storage/EmptyBucketModal.tsx index 77696bc4862..18e0e901eb9 100644 --- a/apps/studio/components/interfaces/Storage/EmptyBucketModal.tsx +++ b/apps/studio/components/interfaces/Storage/EmptyBucketModal.tsx @@ -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 ( - onClose()} - onConfirm={onEmptyBucket} - alert={{ - title: 'This action cannot be undone', - description: 'The contents of your bucket cannot be recovered once deleted', + { + if (!open) { + onClose() + } }} > -

Are you sure you want to empty the bucket "{bucket?.name}"?

-
+ + + {`Confirm to delete all contents from ${bucket?.name}`} + + + + +

Are you sure you want to empty the bucket "{bucket?.name}"?

+
+ + + + +
+ ) } + +export default EmptyBucketModal diff --git a/apps/studio/components/interfaces/Storage/StorageMenu.tsx b/apps/studio/components/interfaces/Storage/StorageMenu.tsx index 44e88926c81..88a9d629784 100644 --- a/apps/studio/components/interfaces/Storage/StorageMenu.tsx +++ b/apps/studio/components/interfaces/Storage/StorageMenu.tsx @@ -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('') - const [showCreateBucketModal, setShowCreateBucketModal] = useState(false) - const [selectedBucketToEdit, setSelectedBucketToEdit] = useState() - const [selectedBucketToEmpty, setSelectedBucketToEmpty] = useState() - const [selectedBucketToDelete, setSelectedBucketToDelete] = useState() - const canCreateBuckets = useCheckPermissions(PermissionAction.STORAGE_WRITE, '*') const page = router.pathname.split('/')[4] as | undefined @@ -69,24 +57,7 @@ const StorageMenu = () => { <>
- } - disabled={!canCreateBuckets} - style={{ justifyContent: 'start' }} - onClick={() => setShowCreateBucketModal(true)} - tooltip={{ - content: { - side: 'bottom', - text: !canCreateBuckets - ? 'You need additional permissions to create buckets' - : undefined, - }, - }} - > - New bucket - + { bucket={bucket} projectRef={ref} isSelected={isSelected} - onSelectEmptyBucket={() => setSelectedBucketToEmpty(bucket)} - onSelectDeleteBucket={() => setSelectedBucketToDelete(bucket)} - onSelectEditBucket={() => setSelectedBucketToEdit(bucket)} /> ) })} @@ -195,29 +163,6 @@ const StorageMenu = () => {
- - setShowCreateBucketModal(false)} - /> - - setSelectedBucketToEdit(undefined)} - /> - - setSelectedBucketToEmpty(undefined)} - /> - - setSelectedBucketToDelete(undefined)} - /> ) } diff --git a/apps/studio/components/interfaces/Storage/__tests__/CreateBucketModal.test.tsx b/apps/studio/components/interfaces/Storage/__tests__/CreateBucketModal.test.tsx new file mode 100644 index 00000000000..b0c4c7fc7b9 --- /dev/null +++ b/apps/studio/components/interfaces/Storage/__tests__/CreateBucketModal.test.tsx @@ -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( + + + + ) + + 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`) + ) + }) +}) diff --git a/apps/studio/components/interfaces/Storage/__tests__/DeleteBucketModal.test.tsx b/apps/studio/components/interfaces/Storage/__tests__/DeleteBucketModal.test.tsx new file mode 100644 index 00000000000..ea63c932272 --- /dev/null +++ b/apps/studio/components/interfaces/Storage/__tests__/DeleteBucketModal.test.tsx @@ -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 ( + + + + { + setOpen(false) + onClose() + }} + /> + + ) +} + +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() + + 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() + + 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() + }) + }) +}) diff --git a/apps/studio/components/interfaces/Storage/__tests__/EditBucketModal.test.tsx b/apps/studio/components/interfaces/Storage/__tests__/EditBucketModal.test.tsx new file mode 100644 index 00000000000..4824277d557 --- /dev/null +++ b/apps/studio/components/interfaces/Storage/__tests__/EditBucketModal.test.tsx @@ -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 ( + + + + { + setOpen(false) + onClose() + }} + /> + + ) +} + +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() + + 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()) + }) +}) diff --git a/apps/studio/components/interfaces/Storage/__tests__/EmptyBucketModal.test.tsx b/apps/studio/components/interfaces/Storage/__tests__/EmptyBucketModal.test.tsx new file mode 100644 index 00000000000..85762108745 --- /dev/null +++ b/apps/studio/components/interfaces/Storage/__tests__/EmptyBucketModal.test.tsx @@ -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 ( + + + + { + setOpen(false) + onClose() + }} + /> + + ) +} + +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() + + 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()) + }) +}) diff --git a/apps/studio/lib/isNonNullable.test.ts b/apps/studio/lib/isNonNullable.test.ts new file mode 100644 index 00000000000..6c26736d19f --- /dev/null +++ b/apps/studio/lib/isNonNullable.test.ts @@ -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) + }) +}) diff --git a/apps/studio/lib/isNonNullable.ts b/apps/studio/lib/isNonNullable.ts new file mode 100644 index 00000000000..2fe7efdb6b5 --- /dev/null +++ b/apps/studio/lib/isNonNullable.ts @@ -0,0 +1,22 @@ +export type Maybe = 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`. + * + * ### Example Usage: + * + * ```ts + * const fn = (str: Maybe) => { + * if (!isNonNullable(str)) { + * // typeof str = null | undefined + * // ... + * } + * // typeof str = string + * // ... + * } + * ``` + */ +export const isNonNullable = >(val?: T): val is NonNullable => + typeof val !== `undefined` && val !== null diff --git a/apps/studio/vitest.config.ts b/apps/studio/vitest.config.ts index 964b7ff991a..4091d3e7cad 100644 --- a/apps/studio/vitest.config.ts +++ b/apps/studio/vitest.config.ts @@ -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'],