diff --git a/apps/studio/components/interfaces/Storage/BucketRow.tsx b/apps/studio/components/interfaces/Storage/BucketRow.tsx index d7c781cb47bca..294939db04123 100644 --- a/apps/studio/components/interfaces/Storage/BucketRow.tsx +++ b/apps/studio/components/interfaces/Storage/BucketRow.tsx @@ -28,7 +28,7 @@ export interface BucketRowProps { isSelected: boolean } -const BucketRow = ({ bucket, projectRef = '', isSelected = false }: BucketRowProps) => { +export const BucketRow = ({ bucket, projectRef = '', isSelected = false }: BucketRowProps) => { const { can: canUpdateBuckets } = useAsyncCheckProjectPermissions( PermissionAction.STORAGE_WRITE, '*' @@ -118,5 +118,3 @@ const BucketRow = ({ bucket, projectRef = '', isSelected = false }: BucketRowPro ) } - -export default BucketRow diff --git a/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx b/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx index 36b8f5b7990ed..a6ad858197a24 100644 --- a/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx +++ b/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx @@ -1,14 +1,13 @@ import { zodResolver } from '@hookform/resolvers/zod' +import { PermissionAction } from '@supabase/shared-types/out/constants' import { snakeCase } from 'lodash' -import { ChevronDown, Edit } from 'lucide-react' -import Link from 'next/link' +import { Edit } from 'lucide-react' import { useRouter } from 'next/router' import { useState } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' import { toast } from 'sonner' import z from 'zod' -import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' import { useIcebergWrapperExtension } from 'components/interfaces/Storage/AnalyticBucketDetails/useIcebergWrapper' import { StorageSizeUnits } from 'components/interfaces/Storage/StorageSettings/StorageSettings.constants' @@ -26,10 +25,6 @@ import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Button, - cn, - Collapsible_Shadcn_, - CollapsibleContent_Shadcn_, - CollapsibleTrigger_Shadcn_, Dialog, DialogContent, DialogFooter, @@ -41,6 +36,7 @@ import { Form_Shadcn_, FormControl_Shadcn_, FormField_Shadcn_, + FormMessage_Shadcn_, Input_Shadcn_, Label_Shadcn_, RadioGroupStacked, @@ -79,7 +75,7 @@ const FormSchema = z formatted_size_limit: z.coerce .number() .min(0, 'File size upload limit has to be at least 0') - .default(0), + .optional(), allowed_mime_types: z.string().trim().default(''), }) .superRefine((data, ctx) => { @@ -99,17 +95,20 @@ const formId = 'create-storage-bucket-form' export type CreateBucketForm = z.infer -const CreateBucketModal = () => { - const [visible, setVisible] = useState(false) +export const CreateBucketModal = () => { + const router = useRouter() const { ref } = useParams() const { data: org } = useSelectedOrganizationQuery() - const { mutate: sendEvent } = useSendEventMutation() - const router = useRouter() + + const [visible, setVisible] = useState(false) + const [selectedUnit, setSelectedUnit] = useState(StorageSizeUnits.MB) + const { can: canCreateBuckets } = useAsyncCheckProjectPermissions( PermissionAction.STORAGE_WRITE, '*' ) + const { mutate: sendEvent } = useSendEventMutation() const { mutateAsync: createBucket, isLoading: isCreating } = useBucketCreateMutation({ // [Joshen] Silencing the error here as it's being handled in onSubmit onError: () => {}, @@ -121,9 +120,6 @@ const CreateBucketModal = () => { const { value, unit } = convertFromBytes(data?.fileSizeLimit ?? 0) const formattedGlobalUploadLimit = `${value} ${unit}` - const [selectedUnit, setSelectedUnit] = useState(StorageSizeUnits.BYTES) - const [showConfiguration, setShowConfiguration] = useState(false) - const form = useForm({ resolver: zodResolver(FormSchema), defaultValues: { @@ -131,16 +127,17 @@ const CreateBucketModal = () => { public: false, type: 'STANDARD', has_file_size_limit: false, - formatted_size_limit: 0, + formatted_size_limit: undefined, allowed_mime_types: '', }, }) + const { formatted_size_limit: formattedSizeLimitError } = form.formState.errors const bucketName = snakeCase(form.watch('name')) const isPublicBucket = form.watch('public') const isStandardBucket = form.watch('type') === 'STANDARD' const hasFileSizeLimit = form.watch('has_file_size_limit') - const formattedSizeLimit = form.watch('formatted_size_limit') + const [hasAllowedMimeTypes, setHasAllowedMimeTypes] = useState(false) const icebergWrapperExtensionState = useIcebergWrapperExtension() const icebergCatalogEnabled = data?.features?.icebergCatalog?.enabled @@ -148,22 +145,30 @@ const CreateBucketModal = () => { if (!ref) return console.error('Project ref is required') if (values.type === 'ANALYTICS' && !icebergCatalogEnabled) { - toast.error( + return toast.error( 'The Analytics catalog feature is not enabled for your project. Please contact support to enable it.' ) - return } + // [Joshen] Should shift this into superRefine in the form schema try { - const fileSizeLimit = values.has_file_size_limit - ? convertToBytes(values.formatted_size_limit, selectedUnit as StorageSizeUnits) - : undefined + const fileSizeLimit = + values.has_file_size_limit && values.formatted_size_limit !== undefined + ? convertToBytes(values.formatted_size_limit, selectedUnit as StorageSizeUnits) + : undefined const allowedMimeTypes = - values.allowed_mime_types.length > 0 + hasAllowedMimeTypes && values.allowed_mime_types.length > 0 ? values.allowed_mime_types.split(',').map((x) => x.trim()) : undefined + if (!!fileSizeLimit && !!data?.fileSizeLimit && fileSizeLimit > data.fileSizeLimit) { + return form.setError('formatted_size_limit', { + type: 'manual', + message: 'exceed_global_limit', + }) + } + await createBucket({ projectRef: ref, id: values.name, @@ -181,22 +186,36 @@ const CreateBucketModal = () => { if (values.type === 'ANALYTICS' && icebergWrapperExtensionState === 'installed') { await createIcebergWrapper({ bucketName: values.name }) } + + toast.success(`Successfully created bucket ${values.name}`) form.reset() - setSelectedUnit(StorageSizeUnits.BYTES) - setShowConfiguration(false) + + setSelectedUnit(StorageSizeUnits.MB) 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') + } catch (error: any) { + // Handle specific error cases for inline display + const errorMessage = error.message?.toLowerCase() || '' + + if ( + errorMessage.includes('mime type') && + (errorMessage.includes('is not supported') || errorMessage.includes('not supported')) + ) { + // Set form error for the MIME types field + form.setError('allowed_mime_types', { + type: 'manual', + message: 'Invalid MIME type format. Please check your input.', + }) + } else { + // For other errors, show a toast as fallback + toast.error(`Failed to create bucket: ${error.message}`) + } } } const handleClose = () => { form.reset() - setSelectedUnit(StorageSizeUnits.BYTES) - setShowConfiguration(false) + setSelectedUnit(StorageSizeUnits.MB) setVisible(false) } @@ -239,7 +258,7 @@ const CreateBucketModal = () => {
- + { {isStandardBucket ? ( <> - + { hideMessage name="public" label="Public bucket" - description="Anyone can read any object without any authorization" + description="Allow anyone to read objects without authorization" layout="flex" > @@ -340,154 +359,166 @@ const CreateBucketModal = () => { )} /> + {isPublicBucket && ( + + )} - {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)} - > - - - - -
- ( - - - - - - )} - /> - {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 && ( +
( - - - +
+
+ + + +
+
+ + + {selectedUnit} + + + {Object.values(StorageSizeUnits).map((unit: string) => ( + + {unit} + + ))} + + +
+
)} /> - - + {formattedSizeLimitError?.message === 'exceed_global_limit' && ( + + Exceeds global limit of {formattedGlobalUploadLimit}. Increase limit in{' '} + setVisible(false)} + > + Storage Settings + {' '} + first. + + )} + + {IS_PLATFORM && ( +

+ This project has a{' '} + setVisible(false)} + > + global file size limit + {' '} + of {formattedGlobalUploadLimit}. +

+ )} +
+ )} +
+ + + + + + + + + + {hasAllowedMimeTypes && ( + ( + + + + + + )} + /> + )} ) : ( @@ -586,5 +617,3 @@ const CreateBucketModal = () => { ) } - -export default CreateBucketModal diff --git a/apps/studio/components/interfaces/Storage/EditBucketModal.tsx b/apps/studio/components/interfaces/Storage/EditBucketModal.tsx index 79482cd1a3fc5..8f8cad5bedef3 100644 --- a/apps/studio/components/interfaces/Storage/EditBucketModal.tsx +++ b/apps/studio/components/interfaces/Storage/EditBucketModal.tsx @@ -1,15 +1,22 @@ import { zodResolver } from '@hookform/resolvers/zod' -import { useParams } from 'common' -import { ChevronDown } from 'lucide-react' -import Link from 'next/link' -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { type SubmitHandler, useForm } from 'react-hook-form' import { toast } from 'sonner' +import { z } from 'zod' + +import { useParams } from 'common' +import { StorageSizeUnits } from 'components/interfaces/Storage/StorageSettings/StorageSettings.constants' +import { + convertFromBytes, + convertToBytes, +} from 'components/interfaces/Storage/StorageSettings/StorageSettings.utils' +import { InlineLink } from 'components/ui/InlineLink' +import { useProjectStorageConfigQuery } from 'data/config/project-storage-config-query' +import { useBucketUpdateMutation } from 'data/storage/bucket-update-mutation' +import { Bucket } from 'data/storage/buckets-query' +import { IS_PLATFORM } from 'lib/constants' import { Button, - CollapsibleContent_Shadcn_, - CollapsibleTrigger_Shadcn_, - Collapsible_Shadcn_, Dialog, DialogContent, DialogFooter, @@ -19,6 +26,7 @@ import { DialogTitle, FormControl_Shadcn_, FormField_Shadcn_, + FormMessage_Shadcn_, Form_Shadcn_, Input_Shadcn_, SelectContent_Shadcn_, @@ -27,21 +35,7 @@ import { SelectValue_Shadcn_, Select_Shadcn_, Switch, - cn, } from 'ui' -import { z } from 'zod' - -import { StorageSizeUnits } from 'components/interfaces/Storage/StorageSettings/StorageSettings.constants' -import { - convertFromBytes, - convertToBytes, -} from 'components/interfaces/Storage/StorageSettings/StorageSettings.utils' -import { InlineLink } from 'components/ui/InlineLink' -import { useProjectStorageConfigQuery } from 'data/config/project-storage-config-query' -import { useBucketUpdateMutation } from 'data/storage/bucket-update-mutation' -import { Bucket } from 'data/storage/buckets-query' -import { IS_PLATFORM } from 'lib/constants' -import { isNonNullable } from 'lib/isNonNullable' import { Admonition } from 'ui-patterns' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' @@ -58,7 +52,7 @@ const BucketSchema = z.object({ formatted_size_limit: z.coerce .number() .min(0, 'File size upload limit has to be at least 0') - .default(0), + .optional(), allowed_mime_types: z.string().trim().default(''), }) @@ -67,83 +61,138 @@ const formId = 'edit-storage-bucket-form' export const EditBucketModal = ({ visible, bucket, onClose }: EditBucketModalProps) => { const { ref } = useParams() - 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 [showConfiguration, setShowConfiguration] = useState(false) + const bucketIdRef = useRef(null) + const [selectedUnit, setSelectedUnit] = useState(StorageSizeUnits.MB) const { value: fileSizeLimit } = convertFromBytes(bucket?.file_size_limit ?? 0) - 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(', '), + const { mutate: updateBucket, isLoading: isUpdating } = useBucketUpdateMutation({ + onSuccess: () => { + toast.success(`Successfully updated bucket "${bucket?.name}"`) + onClose() }, - 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(', '), + onError: (error) => { + // Handle specific error cases for inline display + const errorMessage = error.message?.toLowerCase() || '' + + if ( + errorMessage.includes('exceeded the maximum allowed size') || + errorMessage.includes('maximum allowed size') || + errorMessage.includes('entity too large') || + errorMessage.includes('payload too large') + ) { + // Set form error for the file size limit field + form.setError('formatted_size_limit', { + type: 'manual', + message: `Exceeds global limit of ${formattedGlobalUploadLimit}.`, + }) + } else if ( + errorMessage.includes('mime type') && + (errorMessage.includes('is not supported') || errorMessage.includes('not supported')) + ) { + // Set form error for the MIME types field + form.setError('allowed_mime_types', { + type: 'manual', + message: 'Invalid MIME type format. Please check your input.', + }) + } else { + // For other errors, show a toast as fallback + toast.error(`Failed to update bucket: ${error.message || 'Unknown error'}`) + } }, + }) + + const defaultValues = { + name: bucket?.name ?? '', + public: bucket?.public, + has_file_size_limit: Boolean(bucket?.file_size_limit), + formatted_size_limit: bucket?.file_size_limit ? fileSizeLimit ?? 0 : undefined, + allowed_mime_types: (bucket?.allowed_mime_types ?? []).join(', '), + } + + const form = useForm>({ + resolver: zodResolver(BucketSchema), + defaultValues, + values: defaultValues, mode: 'onSubmit', }) + const { formatted_size_limit: formattedSizeLimitError } = form.formState.errors const isPublicBucket = form.watch('public') const hasFileSizeLimit = form.watch('has_file_size_limit') - const formattedSizeLimit = form.watch('formatted_size_limit') + const [hasAllowedMimeTypes, setHasAllowedMimeTypes] = useState( + Boolean(bucket?.allowed_mime_types?.length) + ) + const isChangingBucketVisibility = bucket?.public !== isPublicBucket const isMakingBucketPrivate = bucket?.public && !isPublicBucket const isMakingBucketPublic = !bucket?.public && isPublicBucket + const closeModal = () => { + form.reset() + onClose() + } + 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 + // Client-side validation: Check if bucket limit exceeds global limit + // [Joshen] Should shift this into superRefine in the form schema + if ( + values.has_file_size_limit && + values.formatted_size_limit !== undefined && + data?.fileSizeLimit + ) { + const bucketLimitInBytes = convertToBytes( + values.formatted_size_limit, + selectedUnit as StorageSizeUnits + ) + + if (bucketLimitInBytes > data.fileSizeLimit) { + return form.setError('formatted_size_limit', { + type: 'manual', + message: 'exceed_global_limit', + }) + } + } + + updateBucket({ + projectRef: ref, + id: bucket.id, + isPublic: values.public, + file_size_limit: + values.has_file_size_limit && values.formatted_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() - }, - } - ) + allowed_mime_types: hasAllowedMimeTypes + ? values.allowed_mime_types.length > 0 + ? values.allowed_mime_types.split(',').map((x: string) => x.trim()) + : null + : null, + }) } useEffect(() => { if (visible && bucket) { - setShowConfiguration(false) - const { unit } = convertFromBytes(bucket.file_size_limit ?? 0) - setSelectedUnit(unit) + // Only set the selectedUnit when the bucket changes (different bucket ID) + // This preserves the user's unit selection when reopening the modal for the same bucket + if (bucketIdRef.current !== bucket.id && bucket.file_size_limit) { + const { unit } = convertFromBytes(bucket.file_size_limit) + setSelectedUnit(unit) + bucketIdRef.current = bucket.id + } } - }, [visible, bucket]) + }, [visible, bucket, form]) return ( { - if (!open) { - form.reset() - onClose() - } + if (!open) closeModal() }} > @@ -155,7 +204,7 @@ export const EditBucketModal = ({ visible, bucket, onClose }: EditBucketModalPro - + )} /> + +
+ ( + + + + + + )} + /> + + {isChangingBucketVisibility && ( + + {isMakingBucketPublic && ( +

This will make all objects in your bucket publicly accessible.

+ )} + + {isMakingBucketPrivate && ( + <> +

+ All objects in your bucket will only accessible via signed URLs, or + downloaded with the right authorization headers. +

+

+ Assets cached in the CDN may still be publicly accessible. You can + consider{' '} + + purging the cache + {' '} + or moving your assets to a new bucket. +

+ + )} + + } + /> + )} +
+
+ + + + ( )} /> - - - {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. -

-

- Assets cached in the CDN may still be publicly accessible. You can - consider{' '} - - purging the cache - {' '} - or moving your assets to a new bucket. -

- - )} - - } - /> - )} - - - - - setShowConfiguration(!showConfiguration)} - > - - - - -
- ( - - - - - - )} - /> - {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} -

-
- )} -
- )} -
+ {hasFileSizeLimit && ( +
( - - - +
+
+ + + +
+
+ + + {selectedUnit} + + + {Object.values(StorageSizeUnits).map((unit: string) => ( + + {unit} + + ))} + + +
+
)} /> - - + {formattedSizeLimitError?.message === 'exceed_global_limit' && ( + + Exceeds global limit of {formattedGlobalUploadLimit}. Increase limit in{' '} + + Storage Settings + {' '} + first. + + )} + + {IS_PLATFORM && ( +

+ This project has a{' '} + + global file size limit + {' '} + of {formattedGlobalUploadLimit}. +

+ )} +
+ )} +
+ + + + + + + + + + {hasAllowedMimeTypes && ( + ( + + + + + + )} + /> + )}
- - )} - - - - + + {form.formState.isDirty && ( + + )} + + + + + )} + )}
diff --git a/apps/studio/components/interfaces/Storage/__tests__/CreateBucketModal.test.tsx b/apps/studio/components/interfaces/Storage/__tests__/CreateBucketModal.test.tsx index 4d0b2d37c5508..36ed53271691e 100644 --- a/apps/studio/components/interfaces/Storage/__tests__/CreateBucketModal.test.tsx +++ b/apps/studio/components/interfaces/Storage/__tests__/CreateBucketModal.test.tsx @@ -1,13 +1,13 @@ -import { describe, expect, it, beforeEach, vi } from 'vitest' -import { screen, waitFor, fireEvent } from '@testing-library/dom' +import { fireEvent, screen, waitFor } from '@testing-library/dom' import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' -import { addAPIMock } from 'tests/lib/msw' import { ProjectContextProvider } from 'components/layouts/ProjectLayout/ProjectContext' +import { addAPIMock } from 'tests/lib/msw' import { render } from 'tests/helpers' import { routerMock } from 'tests/lib/route-mock' -import CreateBucketModal from '../CreateBucketModal' +import { CreateBucketModal } from '../CreateBucketModal' describe(`CreateBucketModal`, () => { beforeEach(() => { @@ -65,26 +65,26 @@ describe(`CreateBucketModal`, () => { 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`) + const sizeLimitToggle = screen.getByLabelText(`Restrict file size`) expect(sizeLimitToggle).not.toBeChecked() await userEvent.click(sizeLimitToggle) expect(sizeLimitToggle).toBeChecked() const sizeLimitInput = screen.getByLabelText(`File size limit`) - expect(sizeLimitInput).toHaveValue(0) + expect(sizeLimitInput).toHaveValue(null) 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`) + await userEvent.click(sizeLimitUnitSelect) + const bytesOption = screen.getByRole(`option`, { name: `bytes` }) + await userEvent.click(bytesOption) + expect(sizeLimitUnitSelect).toHaveTextContent(`bytes`) + + const mimeTypeToggle = screen.getByLabelText(`Restrict MIME types`) + expect(mimeTypeToggle).not.toBeChecked() + await userEvent.click(mimeTypeToggle) + expect(mimeTypeToggle).toBeChecked() const mimeTypeInput = screen.getByLabelText(`Allowed MIME types`) expect(mimeTypeInput).toHaveValue(``) diff --git a/apps/studio/components/interfaces/Storage/__tests__/EditBucketModal.test.tsx b/apps/studio/components/interfaces/Storage/__tests__/EditBucketModal.test.tsx index 3dd0c9bb8feb1..c0b47cdae0cae 100644 --- a/apps/studio/components/interfaces/Storage/__tests__/EditBucketModal.test.tsx +++ b/apps/studio/components/interfaces/Storage/__tests__/EditBucketModal.test.tsx @@ -82,26 +82,26 @@ describe(`EditBucketModal`, () => { 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`) + const sizeLimitToggle = screen.getByLabelText(`Restrict file size`) expect(sizeLimitToggle).not.toBeChecked() await userEvent.click(sizeLimitToggle) expect(sizeLimitToggle).toBeChecked() const sizeLimitInput = screen.getByLabelText(`File size limit`) - expect(sizeLimitInput).toHaveValue(0) + expect(sizeLimitInput).toHaveValue(null) await userEvent.type(sizeLimitInput, `25`) const sizeLimitUnitSelect = screen.getByLabelText(`File size limit unit`) - expect(sizeLimitUnitSelect).toHaveTextContent(`bytes`) + expect(sizeLimitUnitSelect).toHaveTextContent(`MB`) await userEvent.click(sizeLimitUnitSelect) - const mbOption = screen.getByRole(`option`, { name: `MB` }) + const mbOption = screen.getByRole(`option`, { name: `GB` }) await userEvent.click(mbOption) - expect(sizeLimitUnitSelect).toHaveTextContent(`MB`) + expect(sizeLimitUnitSelect).toHaveTextContent(`GB`) + + const mimeTypeToggle = screen.getByLabelText(`Restrict MIME types`) + expect(mimeTypeToggle).not.toBeChecked() + await userEvent.click(mimeTypeToggle) + expect(mimeTypeToggle).toBeChecked() const mimeTypeInput = screen.getByLabelText(`Allowed MIME types`) expect(mimeTypeInput).toHaveValue(``) diff --git a/apps/studio/components/interfaces/Storage/index.ts b/apps/studio/components/interfaces/Storage/index.ts deleted file mode 100644 index 2cc7a39e25a75..0000000000000 --- a/apps/studio/components/interfaces/Storage/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { default as StorageExplorer } from './StorageExplorer/StorageExplorer' -export { default as StoragePolicies } from './StoragePolicies/StoragePolicies' -export { default as StorageSettings } from './StorageSettings/StorageSettings' - -export { DeleteBucketModal } from './DeleteBucketModal' diff --git a/apps/studio/components/ui/UpgradeToPro.tsx b/apps/studio/components/ui/UpgradeToPro.tsx index fe15d76aebf23..7c1406ef389e8 100644 --- a/apps/studio/components/ui/UpgradeToPro.tsx +++ b/apps/studio/components/ui/UpgradeToPro.tsx @@ -17,6 +17,7 @@ interface UpgradeToProProps { buttonText?: string source?: string disabled?: boolean + fullWidth?: boolean } const UpgradeToPro = ({ @@ -27,6 +28,7 @@ const UpgradeToPro = ({ buttonText, source = 'upgrade', disabled = false, + fullWidth = false, }: UpgradeToProProps) => { const { data: project } = useSelectedProjectQuery() const { data: organization } = useSelectedOrganizationQuery() @@ -41,8 +43,8 @@ const UpgradeToPro = ({ return (
diff --git a/apps/studio/data/storage/bucket-create-mutation.ts b/apps/studio/data/storage/bucket-create-mutation.ts index adfde525a43d1..b276e75ba1196 100644 --- a/apps/studio/data/storage/bucket-create-mutation.ts +++ b/apps/studio/data/storage/bucket-create-mutation.ts @@ -6,14 +6,14 @@ import { handleError, post } from 'data/fetchers' import type { ResponseError } from 'types' import { storageKeys } from './keys' -export type BucketCreateVariables = Omit & { +type BucketCreateVariables = Omit & { projectRef: string isPublic: boolean } type CreateStorageBucketBody = components['schemas']['CreateStorageBucketBody'] -export async function createBucket({ +async function createBucket({ projectRef, id, type, diff --git a/apps/studio/data/storage/bucket-delete-mutation.ts b/apps/studio/data/storage/bucket-delete-mutation.ts index a623863551db1..383a4331eb797 100644 --- a/apps/studio/data/storage/bucket-delete-mutation.ts +++ b/apps/studio/data/storage/bucket-delete-mutation.ts @@ -6,13 +6,13 @@ import type { ResponseError } from 'types' import { BucketType } from './buckets-query' import { storageKeys } from './keys' -export type BucketDeleteVariables = { +type BucketDeleteVariables = { projectRef: string id: string type: BucketType } -export async function deleteBucket({ projectRef, id, type }: BucketDeleteVariables) { +async function deleteBucket({ projectRef, id, type }: BucketDeleteVariables) { if (!projectRef) throw new Error('projectRef is required') if (!id) throw new Error('Bucket name is requried') diff --git a/apps/studio/data/storage/bucket-update-mutation.ts b/apps/studio/data/storage/bucket-update-mutation.ts index df971366c0828..2ba8f6b8e6b67 100644 --- a/apps/studio/data/storage/bucket-update-mutation.ts +++ b/apps/studio/data/storage/bucket-update-mutation.ts @@ -2,11 +2,11 @@ import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react import { toast } from 'sonner' import { components } from 'api-types' -import { handleError, patch } from 'data/fetchers' +import { patch } from 'data/fetchers' import type { ResponseError } from 'types' import { storageKeys } from './keys' -export type BucketUpdateVariables = { +type BucketUpdateVariables = { projectRef: string id: string isPublic: boolean @@ -23,13 +23,13 @@ type UpdateStorageBucketBody = Omit< file_size_limit: number | null } -export async function updateBucket({ +async function updateBucket({ projectRef, id, isPublic, file_size_limit, allowed_mime_types, -}: BucketUpdateVariables) { +}: BucketUpdateVariables): Promise { if (!projectRef) throw new Error('projectRef is required') if (!id) throw new Error('Bucket name is requried') @@ -42,10 +42,15 @@ export async function updateBucket({ body: payload as any, }) - if (error) handleError(error) - return data + if (error) { + // Return the error instead of throwing it, so we can handle it gracefully + return { data: null, error } + } + + return { data, error: null } } +type BucketUpdateResult = { data: any; error: null } | { data: null; error: any } type BucketUpdateData = Awaited> export const useBucketUpdateMutation = ({ @@ -59,7 +64,13 @@ export const useBucketUpdateMutation = ({ const queryClient = useQueryClient() return useMutation( - (vars) => updateBucket(vars), + async (vars) => { + const result = await updateBucket(vars) + if (result.error) { + throw result.error + } + return result.data + }, { async onSuccess(data, variables, context) { const { projectRef } = variables diff --git a/apps/studio/pages/project/[ref]/storage/buckets/[bucketId].tsx b/apps/studio/pages/project/[ref]/storage/buckets/[bucketId].tsx index f55a43c03badc..a0c112abc8f64 100644 --- a/apps/studio/pages/project/[ref]/storage/buckets/[bucketId].tsx +++ b/apps/studio/pages/project/[ref]/storage/buckets/[bucketId].tsx @@ -1,8 +1,8 @@ import { useParams } from 'common' -import { StorageExplorer } from 'components/interfaces/Storage' import { AnalyticBucketDetails } from 'components/interfaces/Storage/AnalyticBucketDetails' import StorageBucketsError from 'components/interfaces/Storage/StorageBucketsError' +import { StorageExplorer } from 'components/interfaces/Storage/StorageExplorer/StorageExplorer' import { useSelectedBucket } from 'components/interfaces/Storage/StorageExplorer/useSelectedBucket' import DefaultLayout from 'components/layouts/DefaultLayout' import StorageLayout from 'components/layouts/StorageLayout/StorageLayout' diff --git a/apps/studio/pages/project/[ref]/storage/policies.tsx b/apps/studio/pages/project/[ref]/storage/policies.tsx index 5c96607f23cb6..debcf304dfb14 100644 --- a/apps/studio/pages/project/[ref]/storage/policies.tsx +++ b/apps/studio/pages/project/[ref]/storage/policies.tsx @@ -1,9 +1,9 @@ -import StorageLayout from 'components/layouts/StorageLayout/StorageLayout' +import { StoragePolicies } from 'components/interfaces/Storage/StoragePolicies/StoragePolicies' import DefaultLayout from 'components/layouts/DefaultLayout' -import { StoragePolicies } from 'components/interfaces/Storage' -import type { NextPageWithLayout } from 'types' import { PageLayout } from 'components/layouts/PageLayout/PageLayout' -import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' +import { ScaffoldContainer } from 'components/layouts/Scaffold' +import StorageLayout from 'components/layouts/StorageLayout/StorageLayout' +import type { NextPageWithLayout } from 'types' const StoragePoliciesPage: NextPageWithLayout = () => { return diff --git a/apps/studio/pages/project/[ref]/storage/settings.tsx b/apps/studio/pages/project/[ref]/storage/settings.tsx index b204a9239c5bb..fb18607e983d1 100644 --- a/apps/studio/pages/project/[ref]/storage/settings.tsx +++ b/apps/studio/pages/project/[ref]/storage/settings.tsx @@ -1,10 +1,10 @@ +import { S3Connection } from 'components/interfaces/Storage/StorageSettings/S3Connection' +import { StorageSettings } from 'components/interfaces/Storage/StorageSettings/StorageSettings' import DefaultLayout from 'components/layouts/DefaultLayout' +import { PageLayout } from 'components/layouts/PageLayout/PageLayout' +import { ScaffoldContainer } from 'components/layouts/Scaffold' import StorageLayout from 'components/layouts/StorageLayout/StorageLayout' -import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' -import { StorageSettings } from 'components/interfaces/Storage' -import { S3Connection } from 'components/interfaces/Storage/StorageSettings/S3Connection' import type { NextPageWithLayout } from 'types' -import { PageLayout } from 'components/layouts/PageLayout/PageLayout' const StorageSettingsPage: NextPageWithLayout = () => { return (