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 = () => {
+ >
+ ) : (
+ fileSizeLimitError.message
+ )}
+
+ )}
+
+ {isFreeTier && (
+
)}
- />
-
-
-
- (
-
- Optimize and resize images on the fly.{' '}
-
- Learn more
-
- .
- >
- }
- >
-
-
-
-
+ {isSpendCapOn && (
+
)}
- />
-
- {isFreeTier && (
-
-
-
- )}
- {isSpendCapOn && (
-
-
-
- )}
-
- {!canUpdateStorageSettings && (
-
-
- You need additional permissions to update storage settings
-
-
- )}
+ {!canUpdateStorageSettings && (
+
+
+ You need additional permissions to update storage settings
+
+
+ )}
-
- {form.formState.isDirty && (
-
- )}
-
-
-
-
+
+ {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 (